dsh-sidebar-row-menu 1.4.1 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.2 — 2026-11-03
4
+
5
+ ### Added
6
+
7
+ - **Delete workspace and all sessions**: a new workspace row entry removes every
8
+ session transcript under the workspace first, then drops the workspace
9
+ registration. The built-in "Delete workspace" is unchanged and still keeps
10
+ sessions under "Ungrouped". If any session fails to delete, the workspace is kept
11
+ so the user can retry. Backed by a new `POST /dsh-sidebar-row-menu/workspace/delete`
12
+ route.
13
+ - **Also show in the "…" menu**: every plugin right-click entry can now be added
14
+ individually to the row's native "…" menu (Settings → "Row menu" → "Also show in
15
+ the "…" menu"). Session entries mount through the existing session slot;
16
+ workspace entries make the plugin render the "…" menu with the official
17
+ Rename / Delete workspace items plus the enabled entries. All switches are off by
18
+ default, so the native menu is untouched until opted in.
19
+
20
+ ### 中文摘要
21
+
22
+ - 工作区右键新增「删除工作区及所有会话」:先逐个永久删除会话日志,全部成功后再删除
23
+ 工作区登记;任一会话失败则保留工作区便于重试。内置「删除工作区」行为不变(会话归入
24
+ 「未分组」)。新增宿主路由 `POST /dsh-sidebar-row-menu/workspace/delete`。
25
+ - 设置 →「行菜单」新增「同时放进「…」菜单」:可逐项把插件右键条目加进行尾「…」菜单。
26
+ 会话条目走原生 slot;工作区开启任意一项后由插件接管「…」按钮,保留官方「重命名 /
27
+ 删除工作区」。全部默认关闭,未开启时「…」菜单与 DSH 原样一致。
28
+
3
29
  ## 1.4.1 — 2026-11-03
4
30
 
5
31
  ### Added
package/README.en.md CHANGED
@@ -39,7 +39,8 @@ or hardcode any built-in action.
39
39
  | Open with | A submenu of external apps (editors, terminals, file managers) sharing the session menu's catalog, pinning and toggles; hover expands it |
40
40
  | Copy workspace path | Copies the directory path |
41
41
  | Open in new tab | Only when `dsh-project-tabs` (exposing `openWorkspaceInTab`) is installed |
42
- | Delete workspace | Opens the official delete confirmation |
42
+ | Delete workspace | Opens the official delete confirmation; removes only the workspace registration and leaves its sessions under "Ungrouped" |
43
+ | Delete workspace and all sessions | Permanently deletes every session and transcript under the workspace first, then the workspace registration. If any session fails, the workspace is kept so the user can retry. The directory itself is not deleted |
43
44
 
44
45
  ### Session row
45
46
 
@@ -67,9 +68,16 @@ Both delete entries are served by this plugin's own host route. They leave
67
68
  unarchived sessions alone by default; the opt-in switch extends the single-session
68
69
  entry to them. Deletion never cascades into forked child sessions.
69
70
 
70
- These entries are **right-click only**: they do not mount when the "…" button opens
71
- the menu. A short-lived window flag is set before opening; outside that window the
72
- entries render `null`.
71
+ These entries are **right-click only by default**: they do not mount when the "…"
72
+ button opens the menu. A short-lived window flag is set before opening; outside that
73
+ window the entries render `null`.
74
+
75
+ To use them from the row's "…" button, enable them one by one under Settings →
76
+ "Row menu" → "Also show in the "…" menu". Session entries mount into the native
77
+ "…" menu; enabling any workspace entry makes this plugin take over the row's "…"
78
+ button and render the official "Rename / Delete workspace" items alongside the
79
+ enabled plugin entries. With everything off, the "…" button behaves exactly like
80
+ DSH's own.
73
81
 
74
82
  Blank draft sessions have no native "…" menu and are left alone. Rows that are
75
83
  neither a workspace nor a session (e.g. "Show more") are not intercepted.
@@ -85,9 +93,15 @@ Settings → "Row menu" toggles every new entry:
85
93
  | Delete session | Hides the single-session delete entry in session menus |
86
94
  | Allow deleting unarchived sessions | Off by default; also shows "Delete session permanently" on unarchived rows. Requires the previous switch |
87
95
  | Delete all archived sessions | Hides the bulk-delete entry |
96
+ | Also show in the "…" menu → workspace | Adds workspace right-click entries one by one to the row's "…" menu: New session, Open with, Copy workspace path, Open in new tab, Delete workspace and all sessions. Enabling any of them makes this plugin render the "…" menu |
97
+ | Also show in the "…" menu → session | Adds session right-click entries one by one to the native "…" menu: Open with, Delete session, Delete all archived sessions |
98
+
99
+ The "Also show in the "…" menu" switches are all off by default; until one is
100
+ enabled the "…" menu is exactly DSH's own.
88
101
 
89
102
  Switches live in browser `localStorage` (`dsh.sidebar-row-menu.items.v1`,
90
- `dsh.sidebar-row-menu.open-with.v1` and `dsh.sidebar-row-menu.open-with.pinned.v1`)
103
+ `dsh.sidebar-row-menu.open-with.v1`, `dsh.sidebar-row-menu.open-with.pinned.v1` and
104
+ `dsh.sidebar-row-menu.ellipsis.v1`)
91
105
  and apply immediately. Before the first explicit pin, the plugin derives one: the
92
106
  first detected editor (usually VS Code), or the first catalog app when no editor
93
107
  exists.
@@ -126,13 +140,13 @@ resolve it directly by name:
126
140
  Pin a version by appending it to the name:
127
141
 
128
142
  ```
129
- dsh-sidebar-row-menu@1.4.1
143
+ dsh-sidebar-row-menu@1.4.2
130
144
  ```
131
145
 
132
146
  Equivalent command line:
133
147
 
134
148
  ```powershell
135
- dsh plugin --profile desktop add dsh-sidebar-row-menu@1.4.1
149
+ dsh plugin --profile desktop add dsh-sidebar-row-menu@1.4.2
136
150
  ```
137
151
 
138
152
  ### Install from GitHub
@@ -152,13 +166,13 @@ repository is already runnable, so **no local build is required**.
152
166
  That URL tracks the latest commit on `main`. Pin a tag for a stable version:
153
167
 
154
168
  ```
155
- https://github.com/TLEWA/dsh-sidebar-row-menu#v1.4.1
169
+ https://github.com/TLEWA/dsh-sidebar-row-menu#v1.4.2
156
170
  ```
157
171
 
158
172
  Or install the tarball built by Actions directly, without Git:
159
173
 
160
174
  ```
161
- https://github.com/TLEWA/dsh-sidebar-row-menu/releases/download/v1.4.1/dsh-sidebar-row-menu-1.4.1.tgz
175
+ https://github.com/TLEWA/dsh-sidebar-row-menu/releases/download/v1.4.2/dsh-sidebar-row-menu-1.4.2.tgz
162
176
  ```
163
177
 
164
178
  Equivalent command line:
@@ -290,8 +304,9 @@ lib/index.js host entry: name / inject / apply plus re-exported helpers
290
304
  lib/host/security.js loopback request fence
291
305
  lib/host/paths.js path validation, vscode://file encoding
292
306
  lib/host/launch.js VS Code / Explorer launching
293
- lib/host/route.js /open and /session/delete routes, HTTP status codes
307
+ lib/host/route.js /open, /session/delete and /workspace/delete routes, HTTP status codes
294
308
  lib/host/session-delete.js transcript verification and deletion (detach / rm / re-check)
309
+ lib/host/workspace-delete.js workspace + all sessions deletion, workspace kept on failure
295
310
  lib/client.js browser half: fiber lookup, slot registration, menu mount
296
311
  cordis.patch.yml bundle patch inserting the plugin into the profile tree
297
312
  scripts/install.ps1 pnpm-free install / uninstall
@@ -387,6 +402,16 @@ the row's existing menu.
387
402
  - partial failures return `200` + `{ ok:false, deleted, failed, error }`; the dialog
388
403
  stays open and reports the counts.
389
404
 
405
+ `POST /dsh-sidebar-row-menu/workspace/delete`, body `{ workspaceId: string }`.
406
+
407
+ - same loopback / POST / 64 KB body checks; `workspaceId` must be a non-empty string;
408
+ - reads `workspaceRegistry.get(workspaceId).sessionIds` and permanently deletes each
409
+ session with the flow above;
410
+ - only when every session was deleted (including sessions that were already gone)
411
+ does it call `workspaceRegistry.delete()`; any failure returns `200` + `ok:false`
412
+ and keeps the workspace, so no orphaned "Ungrouped" sessions are left behind;
413
+ - an unknown workspace returns `ok:true, missing:true` and is not an error.
414
+
390
415
  "Open with" does not touch this plugin's host: it calls DSH's built-in
391
416
  `open-in-app/apps`, `open-in-app/open` and `open-in-app/icon/<id>` directly.
392
417
 
package/README.md CHANGED
@@ -33,7 +33,8 @@
33
33
  | 打开方式 | 二级菜单列出宿主提供的外部应用(编辑器、终端、文件管理器);与会话菜单共用同一套应用列表、置顶与开关设置,鼠标悬浮自动展开 |
34
34
  | 复制工作区路径 | 复制目录路径到剪贴板 |
35
35
  | 在新标签中打开 | 仅在装有 `dsh-project-tabs`(暴露 `openWorkspaceInTab`)时出现 |
36
- | 删除工作区 | 打开官方删除确认对话框 |
36
+ | 删除工作区 | 打开官方删除确认对话框;只删除工作区登记,会话保留并归入「未分组」 |
37
+ | 删除工作区及所有会话 | 先永久删除该工作区下的每个会话及其日志,全部成功后再删除工作区登记;任一会话删除失败则保留工作区,便于重试。工作区目录本身不删除 |
37
38
 
38
39
  ### 会话行右键
39
40
 
@@ -59,9 +60,13 @@
59
60
  一级菜单。两个删除入口由本插件自己的宿主路由完成,默认不会删除未归档会话;
60
61
  开启「允许删除未归档会话」后才会对普通会话开放。删除不会级联删除 fork 子会话。
61
62
 
62
- 这些条目都是**右键专属**:点行尾「…」按钮时它们不挂载。实现上使用一个只在右键展开瞬间
63
+ 这些条目默认是**右键专属**:点行尾「…」按钮时它们不挂载。实现上使用一个只在右键展开瞬间
63
64
  有效的窗口标记,窗口外的渲染直接返回 `null`。
64
65
 
66
+ 如果想直接使用行尾「…」菜单,可以在设置 →「行菜单」→「同时放进「…」菜单」里逐个开启:
67
+ 会话条目会挂进原生「…」菜单;工作区行开启任意一项后,本插件会接管行尾「…」按钮,用同一套
68
+ 菜单展示官方「重命名 / 删除工作区」加上已开启的插件条目。全部关闭时「…」按钮保持 DSH 原样。
69
+
65
70
  草稿会话(还没有任何消息的空白会话)没有内置「…」菜单,本插件同样不接管。
66
71
  非工作区 / 非会话的侧边栏行(如“显示更多”)也不会被拦截。
67
72
 
@@ -76,9 +81,14 @@
76
81
  | 永久删除会话 | 关闭后会话菜单里不再出现单条删除入口 |
77
82
  | 允许删除未归档会话 | 默认关闭;开启后未归档会话的右键菜单也会出现「永久删除会话」,需先开启上一项 |
78
83
  | 删除所有已归档会话 | 关闭后不再出现批量删除入口 |
84
+ | 同时放进「…」菜单 → 工作区 | 逐项把工作区行右键条目加进行尾「…」菜单:新会话、打开方式、复制工作区路径、在新标签中打开、删除工作区及所有会话。开启任意一项后「…」按钮改由本插件渲染 |
85
+ | 同时放进「…」菜单 → 会话 | 逐项把会话行右键条目加进原生「…」菜单:打开方式、永久删除会话、删除所有已归档会话 |
86
+
87
+ 「同时放进「…」菜单」下的开关默认全部关闭,`…` 菜单在未开启时与 DSH 完全一致。
79
88
 
80
89
  开关存在浏览器 `localStorage`(`dsh.sidebar-row-menu.items.v1`、
81
- `dsh.sidebar-row-menu.open-with.v1` 与 `dsh.sidebar-row-menu.open-with.pinned.v1`),
90
+ `dsh.sidebar-row-menu.open-with.v1`、`dsh.sidebar-row-menu.open-with.pinned.v1` 与
91
+ `dsh.sidebar-row-menu.ellipsis.v1`),
82
92
  改完立即生效,不需要重启。置顶项在第一次配置前是自动推导的:优先置顶检测到的第一个
83
93
  编辑器(通常是 VS Code),没有编辑器时置顶应用列表里的第一个。
84
94
 
@@ -113,13 +123,13 @@
113
123
  要固定版本,在包名后加版本号:
114
124
 
115
125
  ```
116
- dsh-sidebar-row-menu@1.4.1
126
+ dsh-sidebar-row-menu@1.4.2
117
127
  ```
118
128
 
119
129
  命令行等价写法:
120
130
 
121
131
  ```powershell
122
- dsh plugin --profile desktop add dsh-sidebar-row-menu@1.4.1
132
+ dsh plugin --profile desktop add dsh-sidebar-row-menu@1.4.2
123
133
  ```
124
134
 
125
135
  ### 从 GitHub 安装
@@ -139,13 +149,13 @@ DSH 桌面端通过 pnpm 安装 Git 依赖;本仓库的 `lib/` 已是可直接
139
149
  该地址跟随 `main` 的最新提交。要固定版本,在地址后加标签:
140
150
 
141
151
  ```
142
- https://github.com/TLEWA/dsh-sidebar-row-menu#v1.4.1
152
+ https://github.com/TLEWA/dsh-sidebar-row-menu#v1.4.2
143
153
  ```
144
154
 
145
155
  也可以直接安装 Actions 打出的压缩包(不经过 Git):
146
156
 
147
157
  ```
148
- https://github.com/TLEWA/dsh-sidebar-row-menu/releases/download/v1.4.1/dsh-sidebar-row-menu-1.4.1.tgz
158
+ https://github.com/TLEWA/dsh-sidebar-row-menu/releases/download/v1.4.2/dsh-sidebar-row-menu-1.4.2.tgz
149
159
  ```
150
160
 
151
161
  命令行等价写法:
@@ -260,8 +270,9 @@ lib/index.js 宿主入口:name / inject / apply,再导出可测试
260
270
  lib/host/security.js loopback 请求围栏
261
271
  lib/host/paths.js 路径校验、vscode://file 编码
262
272
  lib/host/launch.js VS Code / 资源管理器启动
263
- lib/host/route.js /open 与 /session/delete 路由、HTTP 状态码
273
+ lib/host/route.js /open、/session/delete 与 /workspace/delete 路由、HTTP 状态码
264
274
  lib/host/session-delete.js 会话日志目录的校验与删除(detach / rm / 复查)
275
+ lib/host/workspace-delete.js 删除工作区及其所有会话;会话失败时保留工作区
265
276
  lib/client.js 客户端半边:fiber 解析、slot 注册、菜单挂载
266
277
  cordis.patch.yml bundle patch:把插件插进 profile 插件树
267
278
  scripts/install.ps1 无 pnpm 的安装 / 卸载脚本
@@ -341,6 +352,14 @@ test/*.test.mjs node:test
341
352
  `api-session/removed`;
342
353
  - 部分失败返回 `200` + `{ ok:false, deleted, failed, error }`,前端保留对话框并显示计数。
343
354
 
355
+ `POST /dsh-sidebar-row-menu/workspace/delete`,body `{ workspaceId: string }`。
356
+
357
+ - 同样的 loopback / POST / 64 KB body 校验;`workspaceId` 必须是非空字符串;
358
+ - 先读取 `workspaceRegistry.get(workspaceId).sessionIds`,用上面的会话删除流程逐个永久删除;
359
+ - 只有全部会话删除成功(包括本来就不存在的会话)才调用 `workspaceRegistry.delete()`;
360
+ 任一会话失败返回 `200` + `ok:false` 并保留工作区,避免留下无人管理的「未分组」会话;
361
+ - 未知工作区返回 `ok:true, missing:true`,不视为错误。
362
+
344
363
  「打开方式」不经过本插件宿主:直接调用 DSH 内置的 `open-in-app/apps`、
345
364
  `open-in-app/open`、`open-in-app/icon/<id>`。
346
365
 
package/lib/client.js CHANGED
@@ -90,6 +90,8 @@ window.__ModuleLoader__.load({
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';
@@ -115,6 +117,22 @@ window.__ModuleLoader__.load({
115
117
  deleteUnarchived: false,
116
118
  };
117
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);
118
136
  /**
119
137
  * dsh-session-menu 注册的同一能力条目 id。检测到对方存在时,本插件对应项
120
138
  * 自动隐藏,避免同一份菜单里出现两条一模一样的动作。
@@ -135,6 +153,7 @@ window.__ModuleLoader__.load({
135
153
  copyPath: '复制工作区路径',
136
154
  openInTab: '在新标签中打开',
137
155
  deleteWorkspace: '删除工作区',
156
+ deleteWorkspaceSessions: '删除工作区及所有会话',
138
157
  copied: '已复制',
139
158
  copyFailed: '复制失败,请重试',
140
159
  noPath: '没有可用的目录路径',
@@ -166,6 +185,26 @@ window.__ModuleLoader__.load({
166
185
  'settings.appsAllOff': '所有应用都被二级开关隐藏了',
167
186
  'settings.expand': '展开应用列表',
168
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': '在已归档会话的「…」菜单里一次清理全部归档会话。',
169
208
  'delete.title': '永久删除会话?',
170
209
  'delete.description': '会话「{name}」的日志将从磁盘永久删除,此操作无法撤销。',
171
210
  'delete.archivedTitle': '删除所有已归档会话?',
@@ -180,6 +219,14 @@ window.__ModuleLoader__.load({
180
219
  'delete.unavailable': '删除服务不可用',
181
220
  'delete.done': '已删除会话',
182
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': '已删除工作区',
183
230
  };
184
231
  const en = {
185
232
  newSession: 'New session',
@@ -189,6 +236,7 @@ window.__ModuleLoader__.load({
189
236
  copyPath: 'Copy workspace path',
190
237
  openInTab: 'Open in new tab',
191
238
  deleteWorkspace: 'Delete workspace',
239
+ deleteWorkspaceSessions: 'Delete workspace and all sessions',
192
240
  copied: 'Copied',
193
241
  copyFailed: 'Copy failed, please retry',
194
242
  noPath: 'No usable directory path',
@@ -220,6 +268,26 @@ window.__ModuleLoader__.load({
220
268
  'settings.appsAllOff': 'Every application is hidden by the switches above',
221
269
  'settings.expand': 'Show applications',
222
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.',
223
291
  'delete.title': 'Delete this session permanently?',
224
292
  'delete.description': 'The log of "{name}" will be removed from disk. This cannot be undone.',
225
293
  'delete.archivedTitle': 'Delete all archived sessions?',
@@ -234,6 +302,14 @@ window.__ModuleLoader__.load({
234
302
  'delete.unavailable': 'The delete service is unavailable',
235
303
  'delete.done': 'Session deleted',
236
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',
237
313
  };
238
314
 
239
315
  /** 官方 open-in-app 的应用 id → 显示名。官方 locale 没有这张表。 */
@@ -355,6 +431,8 @@ window.__ModuleLoader__.load({
355
431
  /** 当前挂着的菜单宿主与 React root。 */
356
432
  let menuHost = null;
357
433
  let menuRoot = null;
434
+ /** 当前「…」菜单锚定的按钮;用来判断再次点击是否应收起。 */
435
+ let ellipsisTrigger = null;
358
436
  /** 客户端根上下文(apply 时记下,右键时用来取服务 / 文案)。 */
359
437
  let rootCtx = null;
360
438
 
@@ -375,6 +453,7 @@ window.__ModuleLoader__.load({
375
453
  const root = menuRoot;
376
454
  menuHost = null;
377
455
  menuRoot = null;
456
+ ellipsisTrigger = null;
378
457
  unmountAt(host, root);
379
458
  }
380
459
 
@@ -559,6 +638,76 @@ window.__ModuleLoader__.load({
559
638
  return itemSettings[key] ?? ITEM_DEFAULTS[key] ?? false;
560
639
  }
561
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;
709
+ }
710
+
562
711
  /* ------------------------------------- 打开方式:应用列表与二级开关 */
563
712
 
564
713
  function readStoredChildren() {
@@ -902,6 +1051,20 @@ window.__ModuleLoader__.load({
902
1051
  .catch(() => ({ ok: false, deleted: 0, failed: ids.length, error: 'network' }));
903
1052
  }
904
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
+
905
1068
  /** shell.overlay 上的对话框宿主;没有请求时什么都不渲染。 */
906
1069
  function DeleteDialog({ t }) {
907
1070
  const request = useDeleteRequest();
@@ -913,8 +1076,11 @@ window.__ModuleLoader__.load({
913
1076
  const [busy, setBusy] = useState(false);
914
1077
  const [error, setError] = useState(null);
915
1078
  const [partial, setPartial] = useState(null);
1079
+ const workspace = request.mode === 'workspace';
916
1080
  const bulk = request.mode === 'archived';
917
- const ids = bulk ? request.sessionIds : [request.sessionId];
1081
+ const ids = workspace
1082
+ ? (Array.isArray(request.sessionIds) ? request.sessionIds : [])
1083
+ : bulk ? request.sessionIds : [request.sessionId];
918
1084
  const close = () => {
919
1085
  if (!busy) closeDelete();
920
1086
  };
@@ -926,11 +1092,29 @@ window.__ModuleLoader__.load({
926
1092
  const wasMain = ids.some((id) => isMainSession(id));
927
1093
  void (async () => {
928
1094
  try {
929
- const result = await postDelete(ids);
1095
+ const result = workspace
1096
+ ? await postWorkspaceDelete(request.workspaceId)
1097
+ : await postDelete(ids);
930
1098
  if (result.ok !== true && (result.deleted ?? 0) === 0) {
931
1099
  const reason = typeof result.error === 'string' && result.error !== '' ? result.error : t('delete.unavailable');
932
1100
  throw new Error(reason);
933
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
+ }
934
1118
  if (wasMain) serviceOf(rootCtx, 'uiWorkspace')?.clearMain?.();
935
1119
  if ((result.failed ?? 0) > 0) {
936
1120
  setPartial({ done: result.deleted ?? 0, failed: result.failed });
@@ -949,24 +1133,34 @@ window.__ModuleLoader__.load({
949
1133
  open: true,
950
1134
  onClose: close,
951
1135
  closeLabel: t('delete.cancel'),
952
- title: bulk ? t('delete.archivedTitle') : t('delete.title'),
953
- description: bulk
954
- ? t('delete.archivedDescription', { n: ids.length })
955
- : 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 ?? '' }),
956
1144
  footer: h(Fragment, null,
957
1145
  jsx(Button, { variant: 'outline', disabled: busy, onClick: close, children: t('delete.cancel') }),
958
1146
  jsx(Button, {
959
1147
  variant: 'outline',
960
1148
  disabled: busy,
961
1149
  onClick: confirm,
962
- 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')),
963
1153
  })),
964
1154
  children: [
965
1155
  busy
966
- ? 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'))
967
1159
  : null,
968
1160
  partial !== null
969
- ? 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 }))
970
1164
  : null,
971
1165
  error !== null
972
1166
  ? h('div', { className: 'dsrm-error-text', role: 'alert', key: 'error' }, t('delete.failed', { message: error }))
@@ -1165,7 +1359,9 @@ window.__ModuleLoader__.load({
1165
1359
  * 且和 dsh-session-menu 的同名能力二选一。
1166
1360
  */
1167
1361
  function SessionOpenWithMenuItem({ sessionId, useMenuOpenState, t }) {
1168
- const visible = useRightClickEntry();
1362
+ const rightClick = useRightClickEntry();
1363
+ const ellipsis = useEllipsis();
1364
+ const visible = rightClick || ellipsis.sessionOpenWith === true;
1169
1365
  const items = useItems();
1170
1366
  const apps = useApps();
1171
1367
  const setMenuOpen = menuCloser(useMenuOpenState);
@@ -1186,7 +1382,9 @@ window.__ModuleLoader__.load({
1186
1382
  * 放进一级菜单,用户也可以在设置里改。置顶项不再重复出现在「打开方式」里。
1187
1383
  */
1188
1384
  function SessionOpenWithPinnedMenuItem({ sessionId, useMenuOpenState, t }) {
1189
- const visible = useRightClickEntry();
1385
+ const rightClick = useRightClickEntry();
1386
+ const ellipsis = useEllipsis();
1387
+ const visible = rightClick || ellipsis.sessionOpenWith === true;
1190
1388
  const items = useItems();
1191
1389
  useChildren();
1192
1390
  const apps = useApps();
@@ -1223,7 +1421,9 @@ window.__ModuleLoader__.load({
1223
1421
  * 打开「允许删除未归档会话」。和 dsh-session-menu 的同名功能二选一。
1224
1422
  */
1225
1423
  function SessionDeleteMenuItem({ sessionId, displayTitle, useMenuOpenState, t }) {
1226
- const visible = useRightClickEntry();
1424
+ const rightClick = useRightClickEntry();
1425
+ const ellipsis = useEllipsis();
1426
+ const visible = rightClick || ellipsis.sessionDelete === true;
1227
1427
  const items = useItems();
1228
1428
  const archived = useArchivedIds();
1229
1429
  const setMenuOpen = menuCloser(useMenuOpenState);
@@ -1246,7 +1446,9 @@ window.__ModuleLoader__.load({
1246
1446
  * 和 dsh-session-menu 的同名功能二选一。displayTitle 用作确认文案。
1247
1447
  */
1248
1448
  function SessionDeleteArchivedMenuItem({ sessionId, displayTitle, useMenuOpenState, t }) {
1249
- const visible = useRightClickEntry();
1449
+ const rightClick = useRightClickEntry();
1450
+ const ellipsis = useEllipsis();
1451
+ const visible = rightClick || ellipsis.sessionDeleteArchived === true;
1250
1452
  const items = useItems();
1251
1453
  const archived = useArchivedIds();
1252
1454
  const setMenuOpen = menuCloser(useMenuOpenState);
@@ -1275,8 +1477,25 @@ window.__ModuleLoader__.load({
1275
1477
  ['deleteArchived', 'settings.deleteArchived.label', 'settings.deleteArchived.hint'],
1276
1478
  ];
1277
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
+
1278
1496
  function SettingsSection({ t }) {
1279
1497
  const items = useItems();
1498
+ const ellipsis = useEllipsis();
1280
1499
  const children = useChildren();
1281
1500
  const apps = useApps();
1282
1501
  usePinned();
@@ -1359,10 +1578,36 @@ window.__ModuleLoader__.load({
1359
1578
  return key === 'openWith' && childRows ? [row, childRows] : [row];
1360
1579
  });
1361
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
+
1362
1604
  return h('div', { className: 'dsrm-settings' },
1363
1605
  h('h2', { className: 'dsrm-settings-title' }, t('settings.title')),
1364
1606
  anyConflict ? h('div', { className: 'dsrm-settings-note' }, t('settings.conflictNote')) : null,
1365
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),
1366
1611
  );
1367
1612
  }
1368
1613
 
@@ -1372,16 +1617,20 @@ window.__ModuleLoader__.load({
1372
1617
  * 工作区行右键菜单。官方 Menu 的 items 只认单层条目,不支持子菜单,
1373
1618
  * 所以这里自绘 panel;普通行仍用 MenuItemButton,分隔线与菜单外观和官方一致。
1374
1619
  */
1375
- function WorkspaceRowMenu({ x, y, props, onClose }) {
1620
+ function WorkspaceRowMenu({ x, y, props, onClose, mode = 'context' }) {
1376
1621
  const t = localeOf(rootCtx);
1377
1622
  const items = useItems();
1623
+ const ellipsis = useEllipsis();
1378
1624
  const apps = useApps();
1379
1625
  usePinned();
1380
1626
  useChildren();
1381
1627
  const hostRef = useRef(null);
1382
1628
  const [pos, setPos] = useState(null);
1383
1629
 
1384
- 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');
1385
1634
 
1386
1635
  useEffect(() => {
1387
1636
  if (openWithOn) ensureApps();
@@ -1431,6 +1680,26 @@ window.__ModuleLoader__.load({
1431
1680
  });
1432
1681
  };
1433
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
+
1434
1703
  const run = (id) => {
1435
1704
  if (id === 'new-session') {
1436
1705
  try {
@@ -1456,6 +1725,10 @@ window.__ModuleLoader__.load({
1456
1725
  }
1457
1726
  return;
1458
1727
  }
1728
+ if (id === 'delete-sessions') {
1729
+ requestWorkspaceDelete();
1730
+ return;
1731
+ }
1459
1732
  if (id === 'tab') {
1460
1733
  Promise.resolve().then(() => tabs.openWorkspaceInTab(workspaceId)).catch((reason) => {
1461
1734
  console.warn('[dsh-sidebar-row-menu] open in tab failed:', reason);
@@ -1473,7 +1746,7 @@ window.__ModuleLoader__.load({
1473
1746
  };
1474
1747
 
1475
1748
  const rows = [];
1476
- if (typeof props.onCreate === 'function') {
1749
+ if (typeof props.onCreate === 'function' && show('newSession')) {
1477
1750
  rows.push(jsx(MenuItemButton, {
1478
1751
  key: 'new-session',
1479
1752
  onSelect: () => {
@@ -1515,17 +1788,19 @@ window.__ModuleLoader__.load({
1515
1788
  }));
1516
1789
  }
1517
1790
 
1518
- rows.push(jsx(MenuItemButton, {
1519
- key: 'copy-path',
1520
- separatorBefore: !openWithOn,
1521
- onSelect: () => {
1522
- onClose();
1523
- run('copy-path');
1524
- },
1525
- icon: jsx(IconCopyOutlineRegular, { size: 14 }),
1526
- children: t('copyPath'),
1527
- }));
1528
- 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')) {
1529
1804
  rows.push(jsx(MenuItemButton, {
1530
1805
  key: 'tab',
1531
1806
  onSelect: () => {
@@ -1547,6 +1822,18 @@ window.__ModuleLoader__.load({
1547
1822
  icon: jsx(IconTrashOutlineRegular, {}),
1548
1823
  children: t('deleteWorkspace'),
1549
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
+ }
1550
1837
 
1551
1838
  return h('div', {
1552
1839
  className: 'dsrm-menu',
@@ -1559,8 +1846,8 @@ window.__ModuleLoader__.load({
1559
1846
  }, h(MenuSurface, { role: 'group', className: 'dsrm-menu-surface' }, rows));
1560
1847
  }
1561
1848
 
1562
- /** 在 (x, y) 处挂出工作区菜单。 */
1563
- function showWorkspaceMenu(props, x, y) {
1849
+ /** 在 (x, y) 处挂出工作区菜单。mode='ellipsis' 表示从行尾「…」按钮打开。 */
1850
+ function showWorkspaceMenu(props, x, y, mode = 'context') {
1564
1851
  closeMenu();
1565
1852
  menuHost = document.createElement('div');
1566
1853
  menuHost.setAttribute(MENU_ATTR, '1');
@@ -1571,6 +1858,7 @@ window.__ModuleLoader__.load({
1571
1858
  x,
1572
1859
  y,
1573
1860
  props,
1861
+ mode,
1574
1862
  onClose: () => closeMenu(),
1575
1863
  }));
1576
1864
  }
@@ -1585,9 +1873,44 @@ window.__ModuleLoader__.load({
1585
1873
  }
1586
1874
 
1587
1875
  /** 工作区行右键菜单(没有 slot 可复用,只能自己组)。 */
1588
- function openWorkspaceMenu(props, x, y) {
1876
+ function openWorkspaceMenu(props, x, y, mode = 'context') {
1589
1877
  if (rootCtx === null) return;
1590
- 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;
1591
1914
  }
1592
1915
 
1593
1916
  /* ------------------------------------------------ 会话菜单的插件条目 */
@@ -1858,6 +2181,9 @@ window.__ModuleLoader__.load({
1858
2181
  .dsrm-app-icon{width:14px;height:14px;border-radius:3px;display:block;object-fit:contain}
1859
2182
  .dsrm-settings{max-width:820px;margin:0 auto;padding:2px 0 36px}
1860
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}
1861
2187
  .dsrm-settings-note{margin-top:8px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
1862
2188
  .dsrm-settings-list{margin-top:10px;display:flex;flex-direction:column}
1863
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)}
@@ -1932,9 +2258,11 @@ window.__ModuleLoader__.load({
1932
2258
 
1933
2259
  function install() {
1934
2260
  window.addEventListener('contextmenu', onContextMenu, true);
2261
+ document.addEventListener('click', onWorkspaceEllipsisClick, true);
1935
2262
  console.info('[dsh-sidebar-row-menu] installed');
1936
2263
  return () => {
1937
2264
  window.removeEventListener('contextmenu', onContextMenu, true);
2265
+ document.removeEventListener('click', onWorkspaceEllipsisClick, true);
1938
2266
  closeMenu();
1939
2267
  closeToast();
1940
2268
  };
package/lib/host/route.js CHANGED
@@ -1,20 +1,23 @@
1
1
  /**
2
- * HTTP 边界:/open 与 /session/delete。
2
+ * HTTP 边界:/open、/session/delete 与 /workspace/delete。
3
3
  *
4
- * 浏览器端不能自己打开系统应用,也不该直接碰会话日志,所以这两件事都放在 host:
5
- * POST /dsh-sidebar-row-menu/open 打开目录(VS Code / 资源管理器)
6
- * POST /dsh-sidebar-row-menu/session/delete 永久删除会话(归档范围由 UI 把关)
4
+ * 浏览器端不能自己打开系统应用,也不该直接碰会话日志,所以这几件事都放在 host:
5
+ * POST /dsh-sidebar-row-menu/open 打开目录(VS Code / 资源管理器)
6
+ * POST /dsh-sidebar-row-menu/session/delete 永久删除会话(归档范围由 UI 把关)
7
+ * POST /dsh-sidebar-row-menu/workspace/delete 永久删除工作区及其全部会话
7
8
  */
8
9
 
9
10
  import { inspectOpenPath } from './paths.js';
10
11
  import { isLoopbackRequest } from './security.js';
11
12
  import { openInVscode, revealDirectory } from './launch.js';
12
13
  import { DELETE_BATCH_LIMIT, removeSessions } from './session-delete.js';
14
+ import { removeWorkspaceWithSessions } from './workspace-delete.js';
13
15
 
14
16
  /** 路由前缀;客户端半边硬编码同一个值。 */
15
17
  export const ROUTE_PREFIX = '/dsh-sidebar-row-menu';
16
18
  export const ROUTE_OPEN = `${ROUTE_PREFIX}/open`;
17
19
  export const ROUTE_DELETE = `${ROUTE_PREFIX}/session/delete`;
20
+ export const ROUTE_WORKSPACE_DELETE = `${ROUTE_PREFIX}/workspace/delete`;
18
21
  /** 请求体上限(只传路径或会话 id 列表,64KB 足够)。 */
19
22
  const MAX_BODY_BYTES = 64 * 1024;
20
23
 
@@ -171,8 +174,65 @@ export async function handleSessionDeleteRequest(request, response, ctx) {
171
174
  }
172
175
  }
173
176
 
174
- /** 构造 webServer 路由对象(kind=prefix)。 */
175
- export function createOpenRoute(ctx) {
177
+ /**
178
+ * 处理一次「删除工作区及其全部会话」请求。
179
+ * 请求体:{ workspaceId: string }。
180
+ * 200 返回 { ok, sessions, deleted, failed, workspaceDeleted, error?, code? }。
181
+ * 会话删除有失败时不会删工作区,返回 ok:false 让客户端保留工作区并提示重试。
182
+ */
183
+ export async function handleWorkspaceDeleteRequest(request, response, ctx) {
184
+ if (!isLoopbackRequest(request)) {
185
+ writeJson(response, 403, { ok: false, error: 'forbidden' });
186
+ return;
187
+ }
188
+ if (request.method !== 'POST') {
189
+ writeJson(response, 405, { ok: false, error: 'method-not-allowed' });
190
+ return;
191
+ }
192
+ if (pathnameOf(request) !== ROUTE_WORKSPACE_DELETE) {
193
+ writeJson(response, 404, { ok: false, error: 'not-found' });
194
+ return;
195
+ }
196
+
197
+ let payload;
198
+ try {
199
+ payload = await readJsonBody(request);
200
+ } catch {
201
+ writeJson(response, 400, { ok: false, error: 'bad-request' });
202
+ return;
203
+ }
204
+ const workspaceId = payload?.workspaceId;
205
+ if (typeof workspaceId !== 'string' || workspaceId.trim() === '') {
206
+ writeJson(response, 400, { ok: false, error: 'bad-request' });
207
+ return;
208
+ }
209
+
210
+ if (ctx === undefined || ctx === null || typeof ctx.get !== 'function') {
211
+ writeJson(response, 503, { ok: false, error: 'service-unavailable' });
212
+ return;
213
+ }
214
+ for (const name of ['sessions', 'sessionQuery', 'sessionPersistence', 'workspaceRegistry']) {
215
+ let available;
216
+ try {
217
+ available = ctx.get(name) !== undefined && ctx.get(name) !== null;
218
+ } catch {
219
+ available = false;
220
+ }
221
+ if (!available) {
222
+ writeJson(response, 503, { ok: false, error: 'service-unavailable' });
223
+ return;
224
+ }
225
+ }
226
+ try {
227
+ const outcome = await removeWorkspaceWithSessions(ctx, workspaceId);
228
+ writeJson(response, 200, outcome);
229
+ } catch (error) {
230
+ ctx.logger?.warn?.('[dsh-sidebar-row-menu] workspace delete failed:', error);
231
+ writeJson(response, 500, { ok: false, error: 'delete-failed' });
232
+ }
233
+ }
234
+
235
+ /** 构造 webServer 路由对象(kind=prefix)。 */export function createOpenRoute(ctx) {
176
236
  return {
177
237
  kind: 'prefix',
178
238
  path: ROUTE_PREFIX,
@@ -180,6 +240,7 @@ export function createOpenRoute(ctx) {
180
240
  const pathname = pathnameOf(request);
181
241
  if (pathname === ROUTE_OPEN) return handleOpenRequest(request, response);
182
242
  if (pathname === ROUTE_DELETE) return handleSessionDeleteRequest(request, response, ctx);
243
+ if (pathname === ROUTE_WORKSPACE_DELETE) return handleWorkspaceDeleteRequest(request, response, ctx);
183
244
  if (!isLoopbackRequest(request)) {
184
245
  writeJson(response, 403, { ok: false, error: 'forbidden' });
185
246
  return undefined;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * 删除工作区及其下所有会话。
3
+ *
4
+ * 内置 `workspaces.delete(id)`(registry.delete)只摘掉工作区登记,会话会被
5
+ * 重新归到「未分组」。这里先按工作区的 sessionIds 永久删除每个会话日志,
6
+ * 全部成功后再删工作区登记;任何一个会话删除失败就保留工作区,避免留下
7
+ * 一堆没人管的「未分组」会话。
8
+ *
9
+ * 工作区目录本身不删(和内置行为一致),只删会话日志与工作区记录。
10
+ */
11
+
12
+ import { removeSessions } from './session-delete.js';
13
+
14
+ function service(ctx, name) {
15
+ try {
16
+ return ctx.get(name);
17
+ } catch {
18
+ return undefined;
19
+ }
20
+ }
21
+
22
+ /**
23
+ * 永久删除一个工作区及其全部会话。
24
+ * @param ctx - 宿主插件上下文。
25
+ * @param workspaceId - 工作区 id。
26
+ * @returns {Promise<{ok:boolean,sessions:number,deleted:number,failed:number,
27
+ * workspaceDeleted:boolean,missing?:boolean,code?:string,error:string}>}
28
+ */
29
+ export async function removeWorkspaceWithSessions(ctx, workspaceId) {
30
+ const registry = service(ctx, 'workspaceRegistry');
31
+ if (
32
+ registry === null
33
+ || registry === undefined
34
+ || typeof registry.get !== 'function'
35
+ || typeof registry.delete !== 'function'
36
+ ) {
37
+ return {
38
+ ok: false,
39
+ sessions: 0,
40
+ deleted: 0,
41
+ failed: 0,
42
+ workspaceDeleted: false,
43
+ code: 'unavailable',
44
+ error: 'workspace-registry-unavailable',
45
+ };
46
+ }
47
+
48
+ let workspace;
49
+ try {
50
+ workspace = registry.get(workspaceId);
51
+ } catch (error) {
52
+ return {
53
+ ok: false,
54
+ sessions: 0,
55
+ deleted: 0,
56
+ failed: 0,
57
+ workspaceDeleted: false,
58
+ code: 'lookup-failed',
59
+ error: error instanceof Error ? error.message : String(error),
60
+ };
61
+ }
62
+ if (workspace === null || workspace === undefined) {
63
+ return { ok: true, sessions: 0, deleted: 0, failed: 0, workspaceDeleted: false, missing: true, error: '' };
64
+ }
65
+
66
+ const ids = Array.isArray(workspace.sessionIds)
67
+ ? workspace.sessionIds.filter((id) => typeof id === 'string' && id.trim() !== '')
68
+ : [];
69
+ let deleted = 0;
70
+ if (ids.length > 0) {
71
+ const outcome = await removeSessions(ctx, ids);
72
+ deleted = outcome.deleted ?? 0;
73
+ if (outcome.ok !== true) {
74
+ return {
75
+ ok: false,
76
+ sessions: ids.length,
77
+ deleted,
78
+ failed: outcome.failed ?? Math.max(0, ids.length - deleted),
79
+ workspaceDeleted: false,
80
+ code: typeof outcome.code === 'string' ? outcome.code : 'session-delete-failed',
81
+ error: typeof outcome.error === 'string' ? outcome.error : '',
82
+ };
83
+ }
84
+ }
85
+
86
+ let workspaceDeleted = false;
87
+ try {
88
+ workspaceDeleted = (await registry.delete(workspaceId)) !== false;
89
+ } catch (error) {
90
+ return {
91
+ ok: false,
92
+ sessions: ids.length,
93
+ deleted,
94
+ failed: 0,
95
+ workspaceDeleted: false,
96
+ code: 'workspace-delete-failed',
97
+ error: error instanceof Error ? error.message : String(error),
98
+ };
99
+ }
100
+ return { ok: true, sessions: ids.length, deleted, failed: 0, workspaceDeleted, error: '' };
101
+ }
package/lib/index.js CHANGED
@@ -4,18 +4,21 @@
4
4
  * 两件事:
5
5
  * 1. `/open` 让浏览器请宿主用系统命令打开目录;
6
6
  * 2. `/session/delete` 永久删除会话日志(默认由 UI 对已归档会话开放;
7
+ * 3. `/workspace/delete` 永久删除工作区及其下全部会话。
7
8
  *
8
- * 具体实现拆成四个模块:
9
+ * 具体实现拆成六个模块:
9
10
  *
10
- * lib/host/security.js loopback 请求围栏
11
- * lib/host/paths.js 路径校验与 vscode:// URL 编码
12
- * lib/host/launch.js VS Code / 资源管理器启动
13
- * lib/host/session-delete.js 停跑、摘运行时、删日志、清归档记录
14
- * lib/host/route.js HTTP 边界与状态码
11
+ * lib/host/security.js loopback 请求围栏
12
+ * lib/host/paths.js 路径校验与 vscode:// URL 编码
13
+ * lib/host/launch.js VS Code / 资源管理器启动
14
+ * lib/host/session-delete.js 停跑、摘运行时、删日志、清归档记录
15
+ * lib/host/workspace-delete.js 先删会话再摘工作区登记
16
+ * lib/host/route.js HTTP 边界与状态码
15
17
  *
16
18
  * 浏览器自己不能打开资源管理器,也不该直接删会话日志,所以这些必须放在 host。
17
19
  * 打开请求体:{ target: 'vscode' | 'explorer', path: '<绝对目录路径>' }
18
- * 删除请求体:{ sessionIds: ['<会话 id>', ...] }
20
+ * 删除会话请求体:{ sessionIds: ['<会话 id>', ...] }
21
+ * 删除工作区请求体:{ workspaceId: '<工作区 id>' }
19
22
  *
20
23
  * 删除流程参考 dsh-session-menu v0.1.3(MIT, big-head-pigeon),不引入 Typert 依赖。
21
24
  */
@@ -29,8 +32,9 @@ export const inject = ['webServer', 'sessions', 'sessionPersistence', 'sessionQu
29
32
  export { isLoopbackRequest } from './host/security.js';
30
33
  export { inspectOpenPath, toVscodeFileUrl } from './host/paths.js';
31
34
  export { resolveVscodeLauncher } from './host/launch.js';
32
- export { createOpenRoute, handleSessionDeleteRequest } from './host/route.js';
35
+ export { createOpenRoute, handleSessionDeleteRequest, handleWorkspaceDeleteRequest } from './host/route.js';
33
36
  export { DELETE_BATCH_LIMIT, removeSession, removeSessions } from './host/session-delete.js';
37
+ export { removeWorkspaceWithSessions } from './host/workspace-delete.js';
34
38
 
35
39
  /**
36
40
  * 注册路由。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-sidebar-row-menu",
3
- "version": "1.4.1",
3
+ "version": "1.4.2",
4
4
  "type": "module",
5
5
  "description": "DSH sidebar right-click menus: a self-composed menu for workspace rows, and the native slot menu opened at the pointer for session rows, with open-with and archived-session cleanup.",
6
6
  "keywords": [
@@ -60,7 +60,7 @@
60
60
  "scripts": {
61
61
  "test": "node --test test/host.test.mjs test/client.test.mjs",
62
62
  "test:local": "node --test --test-isolation=none test/host.test.mjs test/client.test.mjs",
63
- "check": "node --check lib/index.js && node --check lib/client.js && node --check lib/host/paths.js && node --check lib/host/security.js && node --check lib/host/launch.js && node --check lib/host/route.js && node --check lib/host/session-delete.js",
63
+ "check": "node --check lib/index.js && node --check lib/client.js && node --check lib/host/paths.js && node --check lib/host/security.js && node --check lib/host/launch.js && node --check lib/host/route.js && node --check lib/host/session-delete.js && node --check lib/host/workspace-delete.js",
64
64
  "build": "npm pack",
65
65
  "marketplace:check": "node scripts/marketplace-preflight.mjs",
66
66
  "prepack": "npm run check",