dsh-sidebar-row-menu 1.4.0 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -88,8 +88,10 @@ window.__ModuleLoader__.load({
88
88
  if (children.length === 0) return jsx(type, props);
89
89
  return jsx(type, { ...props, children: children.length === 1 ? children[0] : children });
90
90
  }
91
- /** 永久删除会话的宿主端点(只允许删除已归档会话,见本文件条目)。 */
91
+ /** 永久删除会话的宿主端点;是否对未归档会话开放由本地开关决定。 */
92
92
  const DELETE_ENDPOINT = '/dsh-sidebar-row-menu/session/delete';
93
+ /** 删除「工作区 + 其下全部会话」的宿主端点。 */
94
+ const WORKSPACE_DELETE_ENDPOINT = '/dsh-sidebar-row-menu/workspace/delete';
93
95
  /** 官方 open-in-app 的三条路由;用文档相对路径让浏览器自带认证。 */
94
96
  const APPS_ROUTE = 'open-in-app/apps';
95
97
  const OPEN_ROUTE = 'open-in-app/open';
@@ -104,7 +106,33 @@ window.__ModuleLoader__.load({
104
106
  'intellij', 'pycharm', 'webstorm', 'phpstorm', 'goland', 'rider', 'rustrover',
105
107
  'androidstudio', 'xcode',
106
108
  ];
107
- const ITEM_KEYS = ['openWith', 'deleteSession', 'deleteArchived'];
109
+ /**
110
+ * 各项菜单开关的默认值。删除未归档会话是破坏性能力,默认关闭,
111
+ * 必须在设置里主动开启后才出现在普通会话的右键菜单里。
112
+ */
113
+ const ITEM_DEFAULTS = {
114
+ openWith: true,
115
+ deleteSession: true,
116
+ deleteArchived: true,
117
+ deleteUnarchived: false,
118
+ };
119
+ const ITEM_KEYS = Object.keys(ITEM_DEFAULTS);
120
+ /**
121
+ * 「右键专属条目是否也放进行尾…菜单」的逐项开关。
122
+ * 全部默认关闭:不勾选时点「…」看到的仍是 DSH 原生菜单。
123
+ */
124
+ const ELLIPSIS_KEY = 'dsh.sidebar-row-menu.ellipsis.v1';
125
+ const ELLIPSIS_DEFAULTS = {
126
+ newSession: false,
127
+ openWith: false,
128
+ copyPath: false,
129
+ openInTab: false,
130
+ deleteSessions: false,
131
+ sessionOpenWith: false,
132
+ sessionDelete: false,
133
+ sessionDeleteArchived: false,
134
+ };
135
+ const ELLIPSIS_KEYS = Object.keys(ELLIPSIS_DEFAULTS);
108
136
  /**
109
137
  * dsh-session-menu 注册的同一能力条目 id。检测到对方存在时,本插件对应项
110
138
  * 自动隐藏,避免同一份菜单里出现两条一模一样的动作。
@@ -125,6 +153,7 @@ window.__ModuleLoader__.load({
125
153
  copyPath: '复制工作区路径',
126
154
  openInTab: '在新标签中打开',
127
155
  deleteWorkspace: '删除工作区',
156
+ deleteWorkspaceSessions: '删除工作区及所有会话',
128
157
  copied: '已复制',
129
158
  copyFailed: '复制失败,请重试',
130
159
  noPath: '没有可用的目录路径',
@@ -138,9 +167,11 @@ window.__ModuleLoader__.load({
138
167
  'settings.openWith.label': '打开方式',
139
168
  'settings.openWith.hint': '在子菜单里选择编辑器、终端或文件管理器打开工作区或会话目录;置顶的应用直接显示在菜单一级。',
140
169
  'settings.deleteSession.label': '永久删除会话',
141
- 'settings.deleteSession.hint': '在已归档会话的菜单里永久删除这一个会话及其日志。',
170
+ 'settings.deleteSession.hint': '在会话菜单里永久删除这一个会话及其日志;默认只对已归档会话显示。',
142
171
  'settings.deleteArchived.label': '删除所有已归档会话',
143
172
  'settings.deleteArchived.hint': '在已归档会话的菜单里一次清理全部归档会话。',
173
+ 'settings.deleteUnarchived.label': '允许删除未归档会话',
174
+ 'settings.deleteUnarchived.hint': '开启后,未归档会话的右键菜单也会出现「永久删除会话」;确认后日志立即从磁盘移除,不可恢复。',
144
175
  'settings.children': '选择要显示的应用',
145
176
  'settings.pin': '置顶',
146
177
  'settings.pinned': '已置顶',
@@ -154,6 +185,26 @@ window.__ModuleLoader__.load({
154
185
  'settings.appsAllOff': '所有应用都被二级开关隐藏了',
155
186
  'settings.expand': '展开应用列表',
156
187
  'settings.collapse': '收起应用列表',
188
+ 'settings.ellipsisTitle': '同时放进「…」菜单',
189
+ 'settings.ellipsisHint': '勾选后,对应条目会额外出现在行尾「…」按钮的菜单里,并沿用上面的单项开关;不勾选则只在右键菜单出现。',
190
+ 'settings.ellipsisWorkspace': '工作区行',
191
+ 'settings.ellipsisSession': '会话行',
192
+ 'settings.ellipsis.newSession.label': '新建会话',
193
+ 'settings.ellipsis.newSession.hint': '在「…」菜单里直接新建该工作区的会话。',
194
+ 'settings.ellipsis.openWith.label': '打开方式',
195
+ 'settings.ellipsis.openWith.hint': '在「…」菜单里用外部应用打开工作区或会话目录。',
196
+ 'settings.ellipsis.copyPath.label': '拷贝工作区路径',
197
+ 'settings.ellipsis.copyPath.hint': '在「…」菜单里复制工作区目录路径。',
198
+ 'settings.ellipsis.openInTab.label': '在新标签页打开',
199
+ 'settings.ellipsis.openInTab.hint': '在「…」菜单里把工作区作为标签页打开。',
200
+ 'settings.ellipsis.deleteSessions.label': '删除工作区及所有会话',
201
+ 'settings.ellipsis.deleteSessions.hint': '在「…」菜单里同时清掉工作区记录和它下面的全部会话。',
202
+ 'settings.ellipsis.sessionOpenWith.label': '打开方式',
203
+ 'settings.ellipsis.sessionOpenWith.hint': '在「…」菜单里用外部应用打开会话目录。',
204
+ 'settings.ellipsis.sessionDelete.label': '永久删除会话',
205
+ 'settings.ellipsis.sessionDelete.hint': '在「…」菜单里永久删除这一个会话及其日志。',
206
+ 'settings.ellipsis.sessionDeleteArchived.label': '删除所有已归档会话',
207
+ 'settings.ellipsis.sessionDeleteArchived.hint': '在已归档会话的「…」菜单里一次清理全部归档会话。',
157
208
  'delete.title': '永久删除会话?',
158
209
  'delete.description': '会话「{name}」的日志将从磁盘永久删除,此操作无法撤销。',
159
210
  'delete.archivedTitle': '删除所有已归档会话?',
@@ -168,6 +219,14 @@ window.__ModuleLoader__.load({
168
219
  'delete.unavailable': '删除服务不可用',
169
220
  'delete.done': '已删除会话',
170
221
  'delete.doneMany': '已删除 {n} 个会话',
222
+ 'delete.workspaceTitle': '删除工作区及所有会话?',
223
+ 'delete.workspaceDescription': '工作区「{name}」及其下 {n} 个会话的日志将被永久删除,目录本身保留。此操作无法撤销。',
224
+ 'delete.workspaceEmptyDescription': '工作区「{name}」下没有会话,将只删除工作区记录。',
225
+ 'delete.workspaceConfirm': '全部删除',
226
+ 'delete.workspacePending': '正在删除工作区及会话…',
227
+ 'delete.workspacePartial': '已删除 {done} 个会话,失败 {failed} 个;工作区已保留。',
228
+ 'delete.workspaceDone': '已删除工作区及 {n} 个会话',
229
+ 'delete.workspaceEmptyDone': '已删除工作区',
171
230
  };
172
231
  const en = {
173
232
  newSession: 'New session',
@@ -177,6 +236,7 @@ window.__ModuleLoader__.load({
177
236
  copyPath: 'Copy workspace path',
178
237
  openInTab: 'Open in new tab',
179
238
  deleteWorkspace: 'Delete workspace',
239
+ deleteWorkspaceSessions: 'Delete workspace and all sessions',
180
240
  copied: 'Copied',
181
241
  copyFailed: 'Copy failed, please retry',
182
242
  noPath: 'No usable directory path',
@@ -190,9 +250,11 @@ window.__ModuleLoader__.load({
190
250
  'settings.openWith.label': 'Open with',
191
251
  'settings.openWith.hint': 'Pick an editor, terminal or file manager for workspace or session directories; pinned apps show up directly in the menu.',
192
252
  'settings.deleteSession.label': 'Delete session permanently',
193
- 'settings.deleteSession.hint': 'Permanently delete a single archived session and its transcript from an archived row.',
253
+ 'settings.deleteSession.hint': 'Permanently delete one session and its transcript from its menu; shown on archived sessions by default.',
194
254
  'settings.deleteArchived.label': 'Delete all archived sessions',
195
255
  'settings.deleteArchived.hint': 'Clean up every archived session from an archived session row.',
256
+ 'settings.deleteUnarchived.label': 'Allow deleting unarchived sessions',
257
+ 'settings.deleteUnarchived.hint': 'Also show "Delete session permanently" on unarchived rows. The transcript is removed from disk immediately and cannot be restored.',
196
258
  'settings.children': 'Applications to show',
197
259
  'settings.pin': 'Pin',
198
260
  'settings.pinned': 'Pinned',
@@ -206,6 +268,26 @@ window.__ModuleLoader__.load({
206
268
  'settings.appsAllOff': 'Every application is hidden by the switches above',
207
269
  'settings.expand': 'Show applications',
208
270
  'settings.collapse': 'Hide applications',
271
+ 'settings.ellipsisTitle': 'Also show in the "…" menu',
272
+ 'settings.ellipsisHint': 'Checked entries also appear in the row\'s "…" button menu, still subject to the switches above. Unchecked entries stay right-click only.',
273
+ 'settings.ellipsisWorkspace': 'Workspace rows',
274
+ 'settings.ellipsisSession': 'Session rows',
275
+ 'settings.ellipsis.newSession.label': 'New session',
276
+ 'settings.ellipsis.newSession.hint': 'Create a session for this workspace from the "…" menu.',
277
+ 'settings.ellipsis.openWith.label': 'Open with',
278
+ 'settings.ellipsis.openWith.hint': 'Open the workspace or session directory with an external app from the "…" menu.',
279
+ 'settings.ellipsis.copyPath.label': 'Copy workspace path',
280
+ 'settings.ellipsis.copyPath.hint': 'Copy the workspace directory path from the "…" menu.',
281
+ 'settings.ellipsis.openInTab.label': 'Open in new tab',
282
+ 'settings.ellipsis.openInTab.hint': 'Open the workspace as a tab from the "…" menu.',
283
+ 'settings.ellipsis.deleteSessions.label': 'Delete workspace and all sessions',
284
+ 'settings.ellipsis.deleteSessions.hint': 'Remove the workspace record and every session under it from the "…" menu.',
285
+ 'settings.ellipsis.sessionOpenWith.label': 'Open with',
286
+ 'settings.ellipsis.sessionOpenWith.hint': 'Open the session directory with an external app from the "…" menu.',
287
+ 'settings.ellipsis.sessionDelete.label': 'Delete session permanently',
288
+ 'settings.ellipsis.sessionDelete.hint': 'Permanently delete this session and its log from the "…" menu.',
289
+ 'settings.ellipsis.sessionDeleteArchived.label': 'Delete all archived sessions',
290
+ 'settings.ellipsis.sessionDeleteArchived.hint': 'Clean up every archived session from an archived row\'s "…" menu.',
209
291
  'delete.title': 'Delete this session permanently?',
210
292
  'delete.description': 'The log of "{name}" will be removed from disk. This cannot be undone.',
211
293
  'delete.archivedTitle': 'Delete all archived sessions?',
@@ -220,6 +302,14 @@ window.__ModuleLoader__.load({
220
302
  'delete.unavailable': 'The delete service is unavailable',
221
303
  'delete.done': 'Session deleted',
222
304
  'delete.doneMany': 'Deleted {n} sessions',
305
+ 'delete.workspaceTitle': 'Delete the workspace and all its sessions?',
306
+ 'delete.workspaceDescription': 'The workspace "{name}" and the logs of its {n} sessions will be permanently deleted; the directory itself is kept. This cannot be undone.',
307
+ 'delete.workspaceEmptyDescription': 'The workspace "{name}" has no sessions; only the workspace record will be deleted.',
308
+ 'delete.workspaceConfirm': 'Delete all',
309
+ 'delete.workspacePending': 'Deleting the workspace and sessions…',
310
+ 'delete.workspacePartial': 'Deleted {done} sessions, failed {failed}; the workspace was kept.',
311
+ 'delete.workspaceDone': 'Deleted the workspace and {n} sessions',
312
+ 'delete.workspaceEmptyDone': 'Workspace deleted',
223
313
  };
224
314
 
225
315
  /** 官方 open-in-app 的应用 id → 显示名。官方 locale 没有这张表。 */
@@ -341,6 +431,8 @@ window.__ModuleLoader__.load({
341
431
  /** 当前挂着的菜单宿主与 React root。 */
342
432
  let menuHost = null;
343
433
  let menuRoot = null;
434
+ /** 当前「…」菜单锚定的按钮;用来判断再次点击是否应收起。 */
435
+ let ellipsisTrigger = null;
344
436
  /** 客户端根上下文(apply 时记下,右键时用来取服务 / 文案)。 */
345
437
  let rootCtx = null;
346
438
 
@@ -361,6 +453,7 @@ window.__ModuleLoader__.load({
361
453
  const root = menuRoot;
362
454
  menuHost = null;
363
455
  menuRoot = null;
456
+ ellipsisTrigger = null;
364
457
  unmountAt(host, root);
365
458
  }
366
459
 
@@ -487,8 +580,7 @@ window.__ModuleLoader__.load({
487
580
  /* ------------------------------------------------ 菜单项设置(localStorage) */
488
581
 
489
582
  function readStoredItems() {
490
- const defaults = {};
491
- for (const key of ITEM_KEYS) defaults[key] = true;
583
+ const defaults = { ...ITEM_DEFAULTS };
492
584
  try {
493
585
  const raw = globalThis.localStorage?.getItem(ITEMS_KEY);
494
586
  if (typeof raw !== 'string' || raw === '') return defaults;
@@ -543,7 +635,77 @@ window.__ModuleLoader__.load({
543
635
  }
544
636
 
545
637
  function itemOn(key) {
546
- return itemSettings[key] !== false;
638
+ return itemSettings[key] ?? ITEM_DEFAULTS[key] ?? false;
639
+ }
640
+
641
+ /* -------------------------------- 「…」菜单附加开关(localStorage) */
642
+
643
+ function readStoredEllipsis() {
644
+ const defaults = { ...ELLIPSIS_DEFAULTS };
645
+ try {
646
+ const raw = globalThis.localStorage?.getItem(ELLIPSIS_KEY);
647
+ if (typeof raw !== 'string' || raw === '') return defaults;
648
+ const parsed = JSON.parse(raw);
649
+ if (parsed === null || typeof parsed !== 'object') return defaults;
650
+ for (const key of ELLIPSIS_KEYS) {
651
+ if (typeof parsed[key] === 'boolean') defaults[key] = parsed[key];
652
+ }
653
+ } catch {
654
+ /* 坏数据回退默认 */
655
+ }
656
+ return defaults;
657
+ }
658
+
659
+ let ellipsisSettings = readStoredEllipsis();
660
+ const ellipsisListeners = new Set();
661
+
662
+ function publishEllipsis() {
663
+ for (const listener of ellipsisListeners) {
664
+ try {
665
+ listener();
666
+ } catch {
667
+ /* ignore */
668
+ }
669
+ }
670
+ }
671
+
672
+ function subscribeEllipsis(listener) {
673
+ ellipsisListeners.add(listener);
674
+ return () => {
675
+ ellipsisListeners.delete(listener);
676
+ };
677
+ }
678
+
679
+ function ellipsisSnapshot() {
680
+ return ellipsisSettings;
681
+ }
682
+
683
+ function useEllipsis() {
684
+ return useSyncExternalStore(subscribeEllipsis, ellipsisSnapshot, ellipsisSnapshot);
685
+ }
686
+
687
+ function setEllipsis(key, enabled) {
688
+ if (!ELLIPSIS_KEYS.includes(key)) return;
689
+ ellipsisSettings = { ...ellipsisSettings, [key]: enabled === true };
690
+ try {
691
+ globalThis.localStorage?.setItem(ELLIPSIS_KEY, JSON.stringify(ellipsisSettings));
692
+ } catch {
693
+ /* 隐私模式写不进去也照样生效到本次会话 */
694
+ }
695
+ publishEllipsis();
696
+ }
697
+
698
+ function ellipsisOn(key) {
699
+ return ellipsisSettings[key] === true;
700
+ }
701
+
702
+ /** 工作区行是否至少有一项要在「…」菜单里显示(否则完全不接管原生菜单)。 */
703
+ function workspaceEllipsisOn() {
704
+ return ellipsisSettings.newSession === true
705
+ || ellipsisSettings.openWith === true
706
+ || ellipsisSettings.copyPath === true
707
+ || ellipsisSettings.openInTab === true
708
+ || ellipsisSettings.deleteSessions === true;
547
709
  }
548
710
 
549
711
  /* ------------------------------------- 打开方式:应用列表与二级开关 */
@@ -889,6 +1051,20 @@ window.__ModuleLoader__.load({
889
1051
  .catch(() => ({ ok: false, deleted: 0, failed: ids.length, error: 'network' }));
890
1052
  }
891
1053
 
1054
+ function postWorkspaceDelete(workspaceId) {
1055
+ if (typeof fetch !== 'function') return Promise.resolve({ ok: false, deleted: 0, failed: 0, error: 'network' });
1056
+ return fetch(WORKSPACE_DELETE_ENDPOINT, {
1057
+ method: 'POST',
1058
+ headers: { 'content-type': 'application/json' },
1059
+ body: JSON.stringify({ workspaceId }),
1060
+ })
1061
+ .then((response) => response.json().catch(() => null)
1062
+ .then((payload) => (payload !== null && typeof payload === 'object'
1063
+ ? payload
1064
+ : { ok: false, deleted: 0, failed: 0, error: `HTTP ${response.status}` })))
1065
+ .catch(() => ({ ok: false, deleted: 0, failed: 0, error: 'network' }));
1066
+ }
1067
+
892
1068
  /** shell.overlay 上的对话框宿主;没有请求时什么都不渲染。 */
893
1069
  function DeleteDialog({ t }) {
894
1070
  const request = useDeleteRequest();
@@ -900,8 +1076,11 @@ window.__ModuleLoader__.load({
900
1076
  const [busy, setBusy] = useState(false);
901
1077
  const [error, setError] = useState(null);
902
1078
  const [partial, setPartial] = useState(null);
1079
+ const workspace = request.mode === 'workspace';
903
1080
  const bulk = request.mode === 'archived';
904
- const ids = bulk ? request.sessionIds : [request.sessionId];
1081
+ const ids = workspace
1082
+ ? (Array.isArray(request.sessionIds) ? request.sessionIds : [])
1083
+ : bulk ? request.sessionIds : [request.sessionId];
905
1084
  const close = () => {
906
1085
  if (!busy) closeDelete();
907
1086
  };
@@ -913,11 +1092,29 @@ window.__ModuleLoader__.load({
913
1092
  const wasMain = ids.some((id) => isMainSession(id));
914
1093
  void (async () => {
915
1094
  try {
916
- const result = await postDelete(ids);
1095
+ const result = workspace
1096
+ ? await postWorkspaceDelete(request.workspaceId)
1097
+ : await postDelete(ids);
917
1098
  if (result.ok !== true && (result.deleted ?? 0) === 0) {
918
1099
  const reason = typeof result.error === 'string' && result.error !== '' ? result.error : t('delete.unavailable');
919
1100
  throw new Error(reason);
920
1101
  }
1102
+ if (workspace) {
1103
+ // 会话删除有失败时 host 会保留工作区,让用户重试;成功才关框。
1104
+ if (result.ok !== true) {
1105
+ setPartial({ done: result.deleted ?? 0, failed: result.failed ?? 0, workspace: true });
1106
+ return;
1107
+ }
1108
+ if (wasMain) serviceOf(rootCtx, 'uiWorkspace')?.clearMain?.();
1109
+ closeDelete();
1110
+ toast(
1111
+ (result.deleted ?? 0) > 0
1112
+ ? t('delete.workspaceDone', { n: result.deleted ?? 0 })
1113
+ : t('delete.workspaceEmptyDone'),
1114
+ 'success',
1115
+ );
1116
+ return;
1117
+ }
921
1118
  if (wasMain) serviceOf(rootCtx, 'uiWorkspace')?.clearMain?.();
922
1119
  if ((result.failed ?? 0) > 0) {
923
1120
  setPartial({ done: result.deleted ?? 0, failed: result.failed });
@@ -936,24 +1133,34 @@ window.__ModuleLoader__.load({
936
1133
  open: true,
937
1134
  onClose: close,
938
1135
  closeLabel: t('delete.cancel'),
939
- title: bulk ? t('delete.archivedTitle') : t('delete.title'),
940
- description: bulk
941
- ? t('delete.archivedDescription', { n: ids.length })
942
- : t('delete.description', { name: request.displayTitle ?? '' }),
1136
+ title: workspace ? t('delete.workspaceTitle') : bulk ? t('delete.archivedTitle') : t('delete.title'),
1137
+ description: workspace
1138
+ ? (ids.length > 0
1139
+ ? t('delete.workspaceDescription', { name: request.displayTitle ?? '', n: ids.length })
1140
+ : t('delete.workspaceEmptyDescription', { name: request.displayTitle ?? '' }))
1141
+ : bulk
1142
+ ? t('delete.archivedDescription', { n: ids.length })
1143
+ : t('delete.description', { name: request.displayTitle ?? '' }),
943
1144
  footer: h(Fragment, null,
944
1145
  jsx(Button, { variant: 'outline', disabled: busy, onClick: close, children: t('delete.cancel') }),
945
1146
  jsx(Button, {
946
1147
  variant: 'outline',
947
1148
  disabled: busy,
948
1149
  onClick: confirm,
949
- children: h('span', { className: 'dsrm-danger-label' }, bulk ? t('delete.archivedConfirm') : t('delete.confirm')),
1150
+ children: h('span', { className: 'dsrm-danger-label' }, workspace
1151
+ ? t('delete.workspaceConfirm')
1152
+ : bulk ? t('delete.archivedConfirm') : t('delete.confirm')),
950
1153
  })),
951
1154
  children: [
952
1155
  busy
953
- ? h('div', { className: 'dsrm-status-text', role: 'status', key: 'busy' }, bulk ? t('delete.archivedPending') : t('delete.pending'))
1156
+ ? h('div', { className: 'dsrm-status-text', role: 'status', key: 'busy' }, workspace
1157
+ ? t('delete.workspacePending')
1158
+ : bulk ? t('delete.archivedPending') : t('delete.pending'))
954
1159
  : null,
955
1160
  partial !== null
956
- ? h('div', { className: 'dsrm-status-text', role: 'status', key: 'partial' }, t('delete.archivedPartial', { done: partial.done, failed: partial.failed }))
1161
+ ? h('div', { className: 'dsrm-status-text', role: 'status', key: 'partial' }, partial.workspace
1162
+ ? t('delete.workspacePartial', { done: partial.done, failed: partial.failed })
1163
+ : t('delete.archivedPartial', { done: partial.done, failed: partial.failed }))
957
1164
  : null,
958
1165
  error !== null
959
1166
  ? h('div', { className: 'dsrm-error-text', role: 'alert', key: 'error' }, t('delete.failed', { message: error }))
@@ -1152,7 +1359,9 @@ window.__ModuleLoader__.load({
1152
1359
  * 且和 dsh-session-menu 的同名能力二选一。
1153
1360
  */
1154
1361
  function SessionOpenWithMenuItem({ sessionId, useMenuOpenState, t }) {
1155
- const visible = useRightClickEntry();
1362
+ const rightClick = useRightClickEntry();
1363
+ const ellipsis = useEllipsis();
1364
+ const visible = rightClick || ellipsis.sessionOpenWith === true;
1156
1365
  const items = useItems();
1157
1366
  const apps = useApps();
1158
1367
  const setMenuOpen = menuCloser(useMenuOpenState);
@@ -1173,7 +1382,9 @@ window.__ModuleLoader__.load({
1173
1382
  * 放进一级菜单,用户也可以在设置里改。置顶项不再重复出现在「打开方式」里。
1174
1383
  */
1175
1384
  function SessionOpenWithPinnedMenuItem({ sessionId, useMenuOpenState, t }) {
1176
- const visible = useRightClickEntry();
1385
+ const rightClick = useRightClickEntry();
1386
+ const ellipsis = useEllipsis();
1387
+ const visible = rightClick || ellipsis.sessionOpenWith === true;
1177
1388
  const items = useItems();
1178
1389
  useChildren();
1179
1390
  const apps = useApps();
@@ -1206,16 +1417,18 @@ window.__ModuleLoader__.load({
1206
1417
  }
1207
1418
 
1208
1419
  /**
1209
- * 会话条目「永久删除会话」:只在已归档会话的菜单里显示;和 dsh-session-menu
1210
- * 的同名功能二选一。
1420
+ * 会话条目「永久删除会话」:已归档会话默认可见;未归档会话要先在设置里
1421
+ * 打开「允许删除未归档会话」。和 dsh-session-menu 的同名功能二选一。
1211
1422
  */
1212
1423
  function SessionDeleteMenuItem({ sessionId, displayTitle, useMenuOpenState, t }) {
1213
- const visible = useRightClickEntry();
1424
+ const rightClick = useRightClickEntry();
1425
+ const ellipsis = useEllipsis();
1426
+ const visible = rightClick || ellipsis.sessionDelete === true;
1214
1427
  const items = useItems();
1215
1428
  const archived = useArchivedIds();
1216
1429
  const setMenuOpen = menuCloser(useMenuOpenState);
1217
1430
  if (!visible || items.deleteSession === false || takenOver('deleteSession')) return null;
1218
- if (!archived.has(sessionId)) return null;
1431
+ if (!archived.has(sessionId) && items.deleteUnarchived !== true) return null;
1219
1432
  return jsx(MenuItemButton, {
1220
1433
  danger: true,
1221
1434
  separatorBefore: true,
@@ -1233,7 +1446,9 @@ window.__ModuleLoader__.load({
1233
1446
  * 和 dsh-session-menu 的同名功能二选一。displayTitle 用作确认文案。
1234
1447
  */
1235
1448
  function SessionDeleteArchivedMenuItem({ sessionId, displayTitle, useMenuOpenState, t }) {
1236
- const visible = useRightClickEntry();
1449
+ const rightClick = useRightClickEntry();
1450
+ const ellipsis = useEllipsis();
1451
+ const visible = rightClick || ellipsis.sessionDeleteArchived === true;
1237
1452
  const items = useItems();
1238
1453
  const archived = useArchivedIds();
1239
1454
  const setMenuOpen = menuCloser(useMenuOpenState);
@@ -1258,11 +1473,29 @@ window.__ModuleLoader__.load({
1258
1473
  const SETTINGS_ROWS = [
1259
1474
  ['openWith', 'settings.openWith.label', 'settings.openWith.hint'],
1260
1475
  ['deleteSession', 'settings.deleteSession.label', 'settings.deleteSession.hint'],
1476
+ ['deleteUnarchived', 'settings.deleteUnarchived.label', 'settings.deleteUnarchived.hint'],
1261
1477
  ['deleteArchived', 'settings.deleteArchived.label', 'settings.deleteArchived.hint'],
1262
1478
  ];
1263
1479
 
1480
+ /** 「…」菜单附加开关;按行分组,避免用户分不清工作区项和会话项。 */
1481
+ const ELLIPSIS_ROWS = [
1482
+ ['workspace', 'settings.ellipsisWorkspace', [
1483
+ ['newSession', 'settings.ellipsis.newSession.label', 'settings.ellipsis.newSession.hint'],
1484
+ ['openWith', 'settings.ellipsis.openWith.label', 'settings.ellipsis.openWith.hint'],
1485
+ ['copyPath', 'settings.ellipsis.copyPath.label', 'settings.ellipsis.copyPath.hint'],
1486
+ ['openInTab', 'settings.ellipsis.openInTab.label', 'settings.ellipsis.openInTab.hint'],
1487
+ ['deleteSessions', 'settings.ellipsis.deleteSessions.label', 'settings.ellipsis.deleteSessions.hint'],
1488
+ ]],
1489
+ ['session', 'settings.ellipsisSession', [
1490
+ ['sessionOpenWith', 'settings.ellipsis.sessionOpenWith.label', 'settings.ellipsis.sessionOpenWith.hint'],
1491
+ ['sessionDelete', 'settings.ellipsis.sessionDelete.label', 'settings.ellipsis.sessionDelete.hint'],
1492
+ ['sessionDeleteArchived', 'settings.ellipsis.sessionDeleteArchived.label', 'settings.ellipsis.sessionDeleteArchived.hint'],
1493
+ ]],
1494
+ ];
1495
+
1264
1496
  function SettingsSection({ t }) {
1265
1497
  const items = useItems();
1498
+ const ellipsis = useEllipsis();
1266
1499
  const children = useChildren();
1267
1500
  const apps = useApps();
1268
1501
  usePinned();
@@ -1309,6 +1542,9 @@ window.__ModuleLoader__.load({
1309
1542
 
1310
1543
  // 应用开关必须紧跟「打开方式」这一行,而不是落到整个列表末尾。
1311
1544
  const rows = SETTINGS_ROWS.flatMap(([key, labelKey, hintKey]) => {
1545
+ // 「允许删除未归档会话」依赖「永久删除会话」这条主开关。
1546
+ const lockedByParent = key === 'deleteUnarchived'
1547
+ && (items.deleteSession === false || takenOver('deleteSession'));
1312
1548
  const row = h('div', { className: 'dsrm-settings-row', key },
1313
1549
  h('div', { className: 'dsrm-settings-copy' },
1314
1550
  h('div', { className: 'dsrm-settings-label' }, t(labelKey)),
@@ -1316,9 +1552,12 @@ window.__ModuleLoader__.load({
1316
1552
  ),
1317
1553
  h('div', { className: 'dsrm-settings-controls' },
1318
1554
  jsx(Switch, {
1319
- checked: items[key] !== false,
1320
- disabled: takenOver(key),
1555
+ checked: items[key] === true,
1556
+ disabled: takenOver(key) || lockedByParent,
1321
1557
  label: t(labelKey),
1558
+ title: lockedByParent
1559
+ ? t('settings.childLocked', { name: t('settings.deleteSession.label') })
1560
+ : undefined,
1322
1561
  onChange: (next) => setItem(key, next),
1323
1562
  }),
1324
1563
  key === 'openWith'
@@ -1339,10 +1578,36 @@ window.__ModuleLoader__.load({
1339
1578
  return key === 'openWith' && childRows ? [row, childRows] : [row];
1340
1579
  });
1341
1580
 
1581
+ const ellipsisGroups = ELLIPSIS_ROWS.map(([groupKey, titleKey, rows]) => h('div', {
1582
+ className: 'dsrm-settings-group',
1583
+ key: groupKey,
1584
+ },
1585
+ h('div', { className: 'dsrm-settings-group-title' }, t(titleKey)),
1586
+ h('div', { className: 'dsrm-settings-list' }, rows.map(([key, labelKey, hintKey]) => h('div', {
1587
+ className: 'dsrm-settings-row',
1588
+ key,
1589
+ },
1590
+ h('div', { className: 'dsrm-settings-copy' },
1591
+ h('div', { className: 'dsrm-settings-label' }, t(labelKey)),
1592
+ h('div', { className: 'dsrm-settings-hint' }, t(hintKey)),
1593
+ ),
1594
+ h('div', { className: 'dsrm-settings-controls' },
1595
+ jsx(Switch, {
1596
+ checked: ellipsis[key] === true,
1597
+ label: t(labelKey),
1598
+ onChange: (next) => setEllipsis(key, next),
1599
+ }),
1600
+ ),
1601
+ ))),
1602
+ ));
1603
+
1342
1604
  return h('div', { className: 'dsrm-settings' },
1343
1605
  h('h2', { className: 'dsrm-settings-title' }, t('settings.title')),
1344
1606
  anyConflict ? h('div', { className: 'dsrm-settings-note' }, t('settings.conflictNote')) : null,
1345
1607
  h('div', { className: 'dsrm-settings-list' }, rows),
1608
+ h('h2', { className: 'dsrm-settings-title dsrm-settings-subtitle' }, t('settings.ellipsisTitle')),
1609
+ h('div', { className: 'dsrm-settings-note' }, t('settings.ellipsisHint')),
1610
+ h('div', { className: 'dsrm-settings-groups' }, ellipsisGroups),
1346
1611
  );
1347
1612
  }
1348
1613
 
@@ -1352,16 +1617,20 @@ window.__ModuleLoader__.load({
1352
1617
  * 工作区行右键菜单。官方 Menu 的 items 只认单层条目,不支持子菜单,
1353
1618
  * 所以这里自绘 panel;普通行仍用 MenuItemButton,分隔线与菜单外观和官方一致。
1354
1619
  */
1355
- function WorkspaceRowMenu({ x, y, props, onClose }) {
1620
+ function WorkspaceRowMenu({ x, y, props, onClose, mode = 'context' }) {
1356
1621
  const t = localeOf(rootCtx);
1357
1622
  const items = useItems();
1623
+ const ellipsis = useEllipsis();
1358
1624
  const apps = useApps();
1359
1625
  usePinned();
1360
1626
  useChildren();
1361
1627
  const hostRef = useRef(null);
1362
1628
  const [pos, setPos] = useState(null);
1363
1629
 
1364
- const openWithOn = items.openWith !== false;
1630
+ const ellipsisMode = mode === 'ellipsis';
1631
+ /** 右键菜单永远显示;「…」菜单只在对应开关打开时显示(内置项除外)。 */
1632
+ const show = (key) => !ellipsisMode || ellipsis[key] === true;
1633
+ const openWithOn = items.openWith !== false && show('openWith');
1365
1634
 
1366
1635
  useEffect(() => {
1367
1636
  if (openWithOn) ensureApps();
@@ -1411,6 +1680,26 @@ window.__ModuleLoader__.load({
1411
1680
  });
1412
1681
  };
1413
1682
 
1683
+ const requestWorkspaceDelete = () => {
1684
+ if (workspaceId === '') {
1685
+ toast(t('actionFailed'));
1686
+ return;
1687
+ }
1688
+ const snapshot = serviceOf(rootCtx, 'workspaces')?.list?.getSnapshot?.();
1689
+ const entry = Array.isArray(snapshot?.items)
1690
+ ? snapshot.items.find((candidate) => candidate?.workspaceId === workspaceId)
1691
+ : undefined;
1692
+ const sessionIds = Array.isArray(entry?.sessionIds)
1693
+ ? entry.sessionIds.filter((id) => typeof id === 'string' && id !== '')
1694
+ : [];
1695
+ const title = typeof group.label === 'string' && group.label !== ''
1696
+ ? group.label
1697
+ : typeof group.title === 'string' && group.title !== ''
1698
+ ? group.title
1699
+ : cwd;
1700
+ requestDelete({ mode: 'workspace', workspaceId, sessionIds, displayTitle: title });
1701
+ };
1702
+
1414
1703
  const run = (id) => {
1415
1704
  if (id === 'new-session') {
1416
1705
  try {
@@ -1436,6 +1725,10 @@ window.__ModuleLoader__.load({
1436
1725
  }
1437
1726
  return;
1438
1727
  }
1728
+ if (id === 'delete-sessions') {
1729
+ requestWorkspaceDelete();
1730
+ return;
1731
+ }
1439
1732
  if (id === 'tab') {
1440
1733
  Promise.resolve().then(() => tabs.openWorkspaceInTab(workspaceId)).catch((reason) => {
1441
1734
  console.warn('[dsh-sidebar-row-menu] open in tab failed:', reason);
@@ -1453,7 +1746,7 @@ window.__ModuleLoader__.load({
1453
1746
  };
1454
1747
 
1455
1748
  const rows = [];
1456
- if (typeof props.onCreate === 'function') {
1749
+ if (typeof props.onCreate === 'function' && show('newSession')) {
1457
1750
  rows.push(jsx(MenuItemButton, {
1458
1751
  key: 'new-session',
1459
1752
  onSelect: () => {
@@ -1495,17 +1788,19 @@ window.__ModuleLoader__.load({
1495
1788
  }));
1496
1789
  }
1497
1790
 
1498
- rows.push(jsx(MenuItemButton, {
1499
- key: 'copy-path',
1500
- separatorBefore: !openWithOn,
1501
- onSelect: () => {
1502
- onClose();
1503
- run('copy-path');
1504
- },
1505
- icon: jsx(IconCopyOutlineRegular, { size: 14 }),
1506
- children: t('copyPath'),
1507
- }));
1508
- if (canOpenInTab) {
1791
+ if (show('copyPath')) {
1792
+ rows.push(jsx(MenuItemButton, {
1793
+ key: 'copy-path',
1794
+ separatorBefore: !openWithOn,
1795
+ onSelect: () => {
1796
+ onClose();
1797
+ run('copy-path');
1798
+ },
1799
+ icon: jsx(IconCopyOutlineRegular, { size: 14 }),
1800
+ children: t('copyPath'),
1801
+ }));
1802
+ }
1803
+ if (canOpenInTab && show('openInTab')) {
1509
1804
  rows.push(jsx(MenuItemButton, {
1510
1805
  key: 'tab',
1511
1806
  onSelect: () => {
@@ -1527,6 +1822,18 @@ window.__ModuleLoader__.load({
1527
1822
  icon: jsx(IconTrashOutlineRegular, {}),
1528
1823
  children: t('deleteWorkspace'),
1529
1824
  }));
1825
+ if (show('deleteSessions')) {
1826
+ rows.push(jsx(MenuItemButton, {
1827
+ key: 'delete-sessions',
1828
+ danger: true,
1829
+ onSelect: () => {
1830
+ onClose();
1831
+ run('delete-sessions');
1832
+ },
1833
+ icon: jsx(IconTrashOutlineRegular, {}),
1834
+ children: t('deleteWorkspaceSessions'),
1835
+ }));
1836
+ }
1530
1837
 
1531
1838
  return h('div', {
1532
1839
  className: 'dsrm-menu',
@@ -1539,8 +1846,8 @@ window.__ModuleLoader__.load({
1539
1846
  }, h(MenuSurface, { role: 'group', className: 'dsrm-menu-surface' }, rows));
1540
1847
  }
1541
1848
 
1542
- /** 在 (x, y) 处挂出工作区菜单。 */
1543
- function showWorkspaceMenu(props, x, y) {
1849
+ /** 在 (x, y) 处挂出工作区菜单。mode='ellipsis' 表示从行尾「…」按钮打开。 */
1850
+ function showWorkspaceMenu(props, x, y, mode = 'context') {
1544
1851
  closeMenu();
1545
1852
  menuHost = document.createElement('div');
1546
1853
  menuHost.setAttribute(MENU_ATTR, '1');
@@ -1551,6 +1858,7 @@ window.__ModuleLoader__.load({
1551
1858
  x,
1552
1859
  y,
1553
1860
  props,
1861
+ mode,
1554
1862
  onClose: () => closeMenu(),
1555
1863
  }));
1556
1864
  }
@@ -1565,9 +1873,44 @@ window.__ModuleLoader__.load({
1565
1873
  }
1566
1874
 
1567
1875
  /** 工作区行右键菜单(没有 slot 可复用,只能自己组)。 */
1568
- function openWorkspaceMenu(props, x, y) {
1876
+ function openWorkspaceMenu(props, x, y, mode = 'context') {
1569
1877
  if (rootCtx === null) return;
1570
- showWorkspaceMenu(props, x, y);
1878
+ showWorkspaceMenu(props, x, y, mode);
1879
+ }
1880
+
1881
+ /**
1882
+ * 行尾「…」按钮已经展开内置菜单时,用同一个按钮位置改挂本插件菜单。
1883
+ * 只在用户为工作区行开启了至少一个「…」附加项时接管;否则原生菜单原样工作。
1884
+ */
1885
+ function onWorkspaceEllipsisClick(event) {
1886
+ if (event.defaultPrevented || event.button !== 0) return;
1887
+ if (event.target === null || event.target === undefined) return;
1888
+ if (rootCtx === null || !workspaceEllipsisOn()) return;
1889
+ const target = event.target;
1890
+ if (typeof target.closest !== 'function') return;
1891
+ const row = target.closest('[data-row-key]');
1892
+ if (row === null) return;
1893
+ const rowKey = row.getAttribute('data-row-key') ?? '';
1894
+ if (!rowKey.startsWith('workspace:')) return;
1895
+ const button = target.closest('button');
1896
+ if (button === null || !row.contains(button)) return;
1897
+ // 只接管行内第一个按钮(「…」);新建会话按钮带快捷键标记,直接放行。
1898
+ const buttons = row.querySelectorAll('button');
1899
+ if (buttons.length === 0 || buttons[0] !== button) return;
1900
+ if (button.getAttribute('aria-keyshortcuts') !== null) return;
1901
+ const props = resolveWorkspaceRow(target);
1902
+ if (props === null) return;
1903
+ event.preventDefault();
1904
+ event.stopPropagation();
1905
+ event.stopImmediatePropagation?.();
1906
+ // 再点一次「…」视为收起当前菜单。
1907
+ if (menuHost !== null && ellipsisTrigger === button) {
1908
+ closeMenu();
1909
+ return;
1910
+ }
1911
+ const rect = button.getBoundingClientRect();
1912
+ openWorkspaceMenu(props, rect.left + rect.width, rect.bottom + 4, 'ellipsis');
1913
+ ellipsisTrigger = button;
1571
1914
  }
1572
1915
 
1573
1916
  /* ------------------------------------------------ 会话菜单的插件条目 */
@@ -1586,7 +1929,7 @@ window.__ModuleLoader__.load({
1586
1929
  /**
1587
1930
  * 会话菜单条目:
1588
1931
  * - 置顶应用 + 「打开方式」子菜单(官方 open-in-app 路由)
1589
- * - 「永久删除会话」「删除所有已归档会话」(仅已归档会话显示)
1932
+ * - 「永久删除会话」「删除所有已归档会话」(已归档会话显示;未归档会话需在设置里开启)
1590
1933
  * 目录从客户端服务里现取 —— 这样不依赖行组件的内部闭包。
1591
1934
  */
1592
1935
  function registerSessionMenu(ctx) {
@@ -1838,6 +2181,9 @@ window.__ModuleLoader__.load({
1838
2181
  .dsrm-app-icon{width:14px;height:14px;border-radius:3px;display:block;object-fit:contain}
1839
2182
  .dsrm-settings{max-width:820px;margin:0 auto;padding:2px 0 36px}
1840
2183
  .dsrm-settings-title{margin:0;font-size:16px;line-height:24px;font-weight:600}
2184
+ .dsrm-settings-subtitle{margin-top:28px}
2185
+ .dsrm-settings-groups{margin-top:10px;display:flex;flex-direction:column;gap:18px}
2186
+ .dsrm-settings-group-title{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;font-weight:600;margin-bottom:2px}
1841
2187
  .dsrm-settings-note{margin-top:8px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
1842
2188
  .dsrm-settings-list{margin-top:10px;display:flex;flex-direction:column}
1843
2189
  .dsrm-settings-row{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:14px 0;border-top:.5px solid var(--dsw-alias-border-l2)}
@@ -1912,9 +2258,11 @@ window.__ModuleLoader__.load({
1912
2258
 
1913
2259
  function install() {
1914
2260
  window.addEventListener('contextmenu', onContextMenu, true);
2261
+ document.addEventListener('click', onWorkspaceEllipsisClick, true);
1915
2262
  console.info('[dsh-sidebar-row-menu] installed');
1916
2263
  return () => {
1917
2264
  window.removeEventListener('contextmenu', onContextMenu, true);
2265
+ document.removeEventListener('click', onWorkspaceEllipsisClick, true);
1918
2266
  closeMenu();
1919
2267
  closeToast();
1920
2268
  };