dsh-sidebar-row-menu 1.3.4 → 1.4.0

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/host/route.js CHANGED
@@ -1,15 +1,21 @@
1
1
  /**
2
- * POST /dsh-sidebar-row-menu/open —— 浏览器端请宿主打开目录的唯一边界。
2
+ * HTTP 边界:/open 与 /session/delete。
3
+ *
4
+ * 浏览器端不能自己打开系统应用,也不该直接碰会话日志,所以这两件事都放在 host:
5
+ * POST /dsh-sidebar-row-menu/open 打开目录(VS Code / 资源管理器)
6
+ * POST /dsh-sidebar-row-menu/session/delete 永久删除会话(仅已归档,由 UI 把关)
3
7
  */
4
8
 
5
9
  import { inspectOpenPath } from './paths.js';
6
10
  import { isLoopbackRequest } from './security.js';
7
11
  import { openInVscode, revealDirectory } from './launch.js';
12
+ import { DELETE_BATCH_LIMIT, removeSessions } from './session-delete.js';
8
13
 
9
14
  /** 路由前缀;客户端半边硬编码同一个值。 */
10
15
  export const ROUTE_PREFIX = '/dsh-sidebar-row-menu';
11
16
  export const ROUTE_OPEN = `${ROUTE_PREFIX}/open`;
12
- /** 请求体上限(只传一个路径,64KB 足够)。 */
17
+ export const ROUTE_DELETE = `${ROUTE_PREFIX}/session/delete`;
18
+ /** 请求体上限(只传路径或会话 id 列表,64KB 足够)。 */
13
19
  const MAX_BODY_BYTES = 64 * 1024;
14
20
 
15
21
  /** 写一个 JSON 响应;handler 自己拥有响应生命周期。 */
@@ -50,6 +56,10 @@ function readJsonBody(request, limit = MAX_BODY_BYTES) {
50
56
  });
51
57
  }
52
58
 
59
+ function pathnameOf(request) {
60
+ return new URL(request.url ?? '/', 'http://dsh.invalid').pathname;
61
+ }
62
+
53
63
  /**
54
64
  * 处理一次打开请求。
55
65
  * 状态码约定:403 非本机 / 405 方法错 / 404 路径或目标资源不存在 /
@@ -64,8 +74,7 @@ export async function handleOpenRequest(request, response) {
64
74
  writeJson(response, 405, { ok: false, error: 'method-not-allowed' });
65
75
  return;
66
76
  }
67
- const pathname = new URL(request.url ?? '/', 'http://dsh.invalid').pathname;
68
- if (pathname !== ROUTE_OPEN) {
77
+ if (pathnameOf(request) !== ROUTE_OPEN) {
69
78
  writeJson(response, 404, { ok: false, error: 'not-found' });
70
79
  return;
71
80
  }
@@ -100,11 +109,83 @@ export async function handleOpenRequest(request, response) {
100
109
  writeJson(response, 200, { ok: true, via: opened.via });
101
110
  }
102
111
 
112
+ /**
113
+ * 处理一次永久删除请求。
114
+ * 请求体:{ sessionIds: string[] }(1..500,服务端去重)。
115
+ * 200 返回 { ok, deleted, failed, error?, code? },部分失败也是 200(客户端据此显示计数)。
116
+ */
117
+ export async function handleSessionDeleteRequest(request, response, ctx) {
118
+ if (!isLoopbackRequest(request)) {
119
+ writeJson(response, 403, { ok: false, error: 'forbidden' });
120
+ return;
121
+ }
122
+ if (request.method !== 'POST') {
123
+ writeJson(response, 405, { ok: false, error: 'method-not-allowed' });
124
+ return;
125
+ }
126
+ if (pathnameOf(request) !== ROUTE_DELETE) {
127
+ writeJson(response, 404, { ok: false, error: 'not-found' });
128
+ return;
129
+ }
130
+
131
+ let payload;
132
+ try {
133
+ payload = await readJsonBody(request);
134
+ } catch {
135
+ writeJson(response, 400, { ok: false, error: 'bad-request' });
136
+ return;
137
+ }
138
+ const ids = payload?.sessionIds;
139
+ if (
140
+ !Array.isArray(ids)
141
+ || ids.length === 0
142
+ || ids.length > DELETE_BATCH_LIMIT
143
+ || ids.some((id) => typeof id !== 'string' || id.trim() === '')
144
+ ) {
145
+ writeJson(response, 400, { ok: false, error: 'bad-request' });
146
+ return;
147
+ }
148
+
149
+ if (ctx === undefined || ctx === null || typeof ctx.get !== 'function') {
150
+ writeJson(response, 503, { ok: false, error: 'service-unavailable' });
151
+ return;
152
+ }
153
+ for (const name of ['sessions', 'sessionQuery', 'sessionPersistence']) {
154
+ let available;
155
+ try {
156
+ available = ctx.get(name) !== undefined && ctx.get(name) !== null;
157
+ } catch {
158
+ available = false;
159
+ }
160
+ if (!available) {
161
+ writeJson(response, 503, { ok: false, error: 'service-unavailable' });
162
+ return;
163
+ }
164
+ }
165
+ try {
166
+ const outcome = await removeSessions(ctx, ids);
167
+ writeJson(response, 200, outcome);
168
+ } catch (error) {
169
+ ctx.logger?.warn?.('[dsh-sidebar-row-menu] session delete failed:', error);
170
+ writeJson(response, 500, { ok: false, error: 'delete-failed' });
171
+ }
172
+ }
173
+
103
174
  /** 构造 webServer 路由对象(kind=prefix)。 */
104
- export function createOpenRoute() {
175
+ export function createOpenRoute(ctx) {
105
176
  return {
106
177
  kind: 'prefix',
107
178
  path: ROUTE_PREFIX,
108
- handler: handleOpenRequest,
179
+ handler: (request, response) => {
180
+ const pathname = pathnameOf(request);
181
+ if (pathname === ROUTE_OPEN) return handleOpenRequest(request, response);
182
+ if (pathname === ROUTE_DELETE) return handleSessionDeleteRequest(request, response, ctx);
183
+ if (!isLoopbackRequest(request)) {
184
+ writeJson(response, 403, { ok: false, error: 'forbidden' });
185
+ return undefined;
186
+ }
187
+ writeJson(response, 404, { ok: false, error: 'not-found' });
188
+ return undefined;
189
+ },
109
190
  };
110
191
  }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * 永久删除会话:宿主端唯一的破坏性操作。
3
+ *
4
+ * 浏览器端删不掉磁盘上的 jsonl 日志,所以删除边界放在 host:
5
+ * stopActivity → detachLive → removeTranscript(强校验目录名)→ forget → emit
6
+ *
7
+ * 行为参考 dsh-session-menu v0.1.3 的 removeSession(MIT, big-head-pigeon),
8
+ * 但拆成普通函数 + 动态 ctx.get(...),不引入 Typert/remote 依赖。
9
+ * 本插件只删目标会话,不级联删除 fork 子会话。
10
+ */
11
+
12
+ import { readdir, rm, stat } from 'node:fs/promises';
13
+ import { basename, dirname, join } from 'node:path';
14
+
15
+ /** 一次请求最多删除的会话数;与 dsh-session-menu 的上限一致。 */
16
+ export const DELETE_BATCH_LIMIT = 500;
17
+
18
+ /** 取服务:cordis 里既可用 ctx.sessions,也可用 ctx.get('sessions')。 */
19
+ function service(ctx, name) {
20
+ try {
21
+ const value = ctx?.get?.(name);
22
+ if (value !== undefined && value !== null) return value;
23
+ } catch {
24
+ /* get 失败就退回属性访问 */
25
+ }
26
+ try {
27
+ return ctx?.[name];
28
+ } catch {
29
+ return undefined;
30
+ }
31
+ }
32
+
33
+ async function pathExists(path) {
34
+ try {
35
+ await stat(path);
36
+ return true;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ /** 读取非 live 会话的 header。 */
43
+ async function coldHeader(ctx, id) {
44
+ try {
45
+ const records = await service(ctx, 'sessionQuery')?.listSessions?.();
46
+ for (const record of Array.isArray(records) ? records : []) {
47
+ if (record?.header?.id === id) return record.header;
48
+ }
49
+ } catch {
50
+ /* 读不到就当作不存在 */
51
+ }
52
+ return undefined;
53
+ }
54
+
55
+ /** 运行中的会话先取消,最多等 3 秒离开 running。 */
56
+ async function stopActivity(ctx, id, live) {
57
+ if (live === undefined) return;
58
+ const agents = service(ctx, 'agents');
59
+ if (agents?.get?.(id)?.status !== 'running') return;
60
+ try {
61
+ const controller = service(ctx, 'sessionController');
62
+ void Promise.resolve(controller?.cancel?.({ sessionId: id })).catch(() => {});
63
+ } catch {
64
+ /* 取消失败也继续:下一步 detach 同样会阻止继续写入 */
65
+ }
66
+ for (let tick = 0; tick < 30; tick += 1) {
67
+ if (agents?.get?.(id)?.status !== 'running') return;
68
+ await new Promise((resolve) => setTimeout(resolve, 100));
69
+ }
70
+ }
71
+
72
+ /** 把会话从运行时摘掉;失败只记日志,不影响后续删盘。 */
73
+ function detachLive(ctx, live) {
74
+ if (live === undefined) return;
75
+ try {
76
+ const sessions = service(ctx, 'sessions');
77
+ const entry = sessions?.liveEntryFor?.(live);
78
+ sessions?.detachEntered?.(entry);
79
+ } catch (error) {
80
+ ctx?.logger?.warn?.(`[dsh-sidebar-row-menu] detach failed for "${live?.header?.id}": ${String(error)}`);
81
+ }
82
+ }
83
+
84
+ /** 优先用 sessionPersistence.locate,否则扫 <root>/<project>/<id>。 */
85
+ function transcriptDirectory(ctx, header) {
86
+ const persistence = service(ctx, 'sessionPersistence');
87
+ if (persistence === undefined || typeof persistence?.locate !== 'function') return undefined;
88
+ try {
89
+ const located = persistence.locate({ id: header.id, cwd: header.cwd });
90
+ if (typeof located?.path === 'string' && located.path !== '') return dirname(located.path);
91
+ } catch {
92
+ /* 退回扫描 */
93
+ }
94
+ return undefined;
95
+ }
96
+
97
+ async function scanTranscriptDirectory(ctx, header) {
98
+ const root = service(ctx, 'sessionPersistence')?.root;
99
+ if (typeof root !== 'string' || root === '') return undefined;
100
+ try {
101
+ for (const entry of await readdir(root, { withFileTypes: true })) {
102
+ if (!entry.isDirectory()) continue;
103
+ if (basename(entry.name) !== entry.name) continue;
104
+ const candidate = join(root, entry.name, header.id);
105
+ const found = await stat(candidate).catch(() => undefined);
106
+ if (found?.isDirectory() === true) return candidate;
107
+ }
108
+ } catch {
109
+ /* 扫描失败交给调用方报错 */
110
+ }
111
+ return undefined;
112
+ }
113
+
114
+ /**
115
+ * 删除会话日志目录。目录名必须与 session id 逐字一致才会 rm;
116
+ * 删除后再复查三次,防止 jsonl 句柄把目录写回来。
117
+ */
118
+ async function removeTranscript(ctx, header) {
119
+ let directory = transcriptDirectory(ctx, header);
120
+ if (directory === undefined) directory = await scanTranscriptDirectory(ctx, header);
121
+ if (directory === undefined || basename(directory) !== header.id) {
122
+ return { ok: false, code: 'path-unverified', error: `no verified directory for session "${header.id}"` };
123
+ }
124
+ try {
125
+ await rm(directory, { recursive: true, force: true });
126
+ } catch (error) {
127
+ return { ok: false, code: 'remove-failed', error: String(error) };
128
+ }
129
+ for (let tick = 0; tick < 3; tick += 1) {
130
+ if (!(await pathExists(directory))) return { ok: true, directory };
131
+ await new Promise((resolve) => setTimeout(resolve, 150));
132
+ }
133
+ return { ok: false, code: 'remove-failed', error: `directory "${directory}" came back after removal` };
134
+ }
135
+
136
+ /** 清掉归档/置顶记录;两个方法对不存在 id 都是 no-op。 */
137
+ async function forget(ctx, id) {
138
+ const registry = service(ctx, 'workspaceRegistry');
139
+ for (const method of ['unarchiveSession', 'unpinSession']) {
140
+ try {
141
+ await registry?.[method]?.(id);
142
+ } catch {
143
+ /* 账本清理失败不影响已经删掉的日志 */
144
+ }
145
+ }
146
+ }
147
+
148
+ /**
149
+ * 删除一个会话。
150
+ * @returns {Promise<{removed:boolean, missing?:boolean, code?:string, error?:string}>}
151
+ * removed=true 表示确实删掉了磁盘目录;missing=true 表示本来就不存在(幂等)。
152
+ */
153
+ export async function removeSession(ctx, id) {
154
+ const sessions = service(ctx, 'sessions');
155
+ if (sessions === undefined || typeof sessions?.get !== 'function') {
156
+ return { removed: false, code: 'unavailable', error: 'sessions service is unavailable' };
157
+ }
158
+ const live = sessions.get(id);
159
+ const header = live?.header ?? (await coldHeader(ctx, id));
160
+ if (header === undefined) {
161
+ await forget(ctx, id);
162
+ return { removed: false, missing: true };
163
+ }
164
+
165
+ await stopActivity(ctx, id, live);
166
+ detachLive(ctx, live);
167
+ const removed = await removeTranscript(ctx, header);
168
+ if (removed.ok !== true) {
169
+ return { removed: false, code: removed.code, error: removed.error };
170
+ }
171
+ await forget(ctx, id);
172
+ try {
173
+ ctx?.emit?.('api-session/removed', id);
174
+ } catch {
175
+ /* 通知失败不改变删除结果,下一次列表刷新也会消失 */
176
+ }
177
+ return { removed: true };
178
+ }
179
+
180
+ /**
181
+ * 批量删除(串行,避免同时 rm 多个会话目录)。
182
+ * @returns {Promise<{ok:boolean, deleted:number, failed:number, error:string, code?:string}>}
183
+ */
184
+ export async function removeSessions(ctx, ids) {
185
+ const unique = [];
186
+ const seen = new Set();
187
+ for (const id of Array.isArray(ids) ? ids : []) {
188
+ if (typeof id !== 'string') continue;
189
+ const value = id.trim();
190
+ if (value === '' || seen.has(value)) continue;
191
+ seen.add(value);
192
+ unique.push(value);
193
+ if (unique.length >= DELETE_BATCH_LIMIT) break;
194
+ }
195
+ if (unique.length === 0) {
196
+ return { ok: false, deleted: 0, failed: 0, error: 'no session ids', code: 'bad-request' };
197
+ }
198
+
199
+ let deleted = 0;
200
+ let failed = 0;
201
+ let firstCode = '';
202
+ let firstError = '';
203
+ for (const id of unique) {
204
+ const outcome = await removeSession(ctx, id);
205
+ if (outcome.removed === true) {
206
+ deleted += 1;
207
+ continue;
208
+ }
209
+ if (outcome.missing === true) continue;
210
+ failed += 1;
211
+ if (firstCode === '') {
212
+ firstCode = outcome.code ?? 'remove-failed';
213
+ firstError = outcome.error ?? '';
214
+ }
215
+ }
216
+ if (failed > 0) {
217
+ return {
218
+ ok: false,
219
+ deleted,
220
+ failed,
221
+ error: firstError === '' ? firstCode : firstError,
222
+ code: 'batch-failed',
223
+ };
224
+ }
225
+ return { ok: true, deleted, failed: 0, error: '' };
226
+ }
package/lib/index.js CHANGED
@@ -1,42 +1,47 @@
1
1
  /**
2
2
  * dsh-sidebar-row-menu — 宿主半边(host half)。
3
3
  *
4
- * 只做一件事:把 `/open` 路由挂到 ctx.webServer 上,让浏览器端能请宿主用系统
5
- * 命令打开目录。具体实现拆成三个模块:
4
+ * 两件事:
5
+ * 1. `/open` 让浏览器请宿主用系统命令打开目录;
6
+ * 2. `/session/delete` 永久删除会话日志(仅 UI 对已归档会话开放)。
6
7
  *
7
- * lib/host/security.js loopback 请求围栏
8
- * lib/host/paths.js 路径校验与 vscode:// URL 编码
9
- * lib/host/launch.js VS Code / 资源管理器启动
10
- * lib/host/route.js HTTP 边界与状态码
8
+ * 具体实现拆成四个模块:
11
9
  *
12
- * 浏览器自己不能打开资源管理器,所以这一小块必须放在 host。
13
- * 请求体:{ target: 'vscode' | 'explorer', path: '<绝对目录路径>' }
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 边界与状态码
14
15
  *
15
- * 实现参考 dsh-sidebar-context-menu v0.2.0(MIT, miao-ge);额外要求路径
16
- * 必须存在且是目录,因为右键对象既可能是工作区,也可能是会话自己的 cwd。
16
+ * 浏览器自己不能打开资源管理器,也不该直接删会话日志,所以这些必须放在 host。
17
+ * 打开请求体:{ target: 'vscode' | 'explorer', path: '<绝对目录路径>' }
18
+ * 删除请求体:{ sessionIds: ['<会话 id>', ...] }
19
+ *
20
+ * 删除流程参考 dsh-session-menu v0.1.3(MIT, big-head-pigeon),不引入 Typert 依赖。
17
21
  */
18
22
 
19
23
  import { createOpenRoute } from './host/route.js';
20
24
 
21
25
  export const name = 'sidebar-row-menu';
22
- /** cordis 依赖:网页服务器服务(ctx.webServer)。 */
23
- export const inject = ['webServer'];
26
+ /** cordis 依赖:网页服务器 + 删除会话时需要读写的宿主服务。 */
27
+ export const inject = ['webServer', 'sessions', 'sessionPersistence', 'sessionQuery', 'workspaceRegistry'];
24
28
 
25
29
  export { isLoopbackRequest } from './host/security.js';
26
30
  export { inspectOpenPath, toVscodeFileUrl } from './host/paths.js';
27
31
  export { resolveVscodeLauncher } from './host/launch.js';
28
- export { createOpenRoute } from './host/route.js';
32
+ export { createOpenRoute, handleSessionDeleteRequest } from './host/route.js';
33
+ export { DELETE_BATCH_LIMIT, removeSession, removeSessions } from './host/session-delete.js';
29
34
 
30
35
  /**
31
- * 注册打开路由。
32
- * @param ctx - 宿主插件上下文(带 webServer 服务)。
36
+ * 注册路由。
37
+ * @param ctx - 宿主插件上下文(带 webServer 与会话服务)。
33
38
  */
34
39
  export function apply(ctx) {
35
- const route = createOpenRoute();
40
+ const route = createOpenRoute(ctx);
36
41
  try {
37
- ctx.effect(() => ctx.webServer.register(route), 'dsh-sidebar-row-menu: /open route');
42
+ ctx.effect(() => ctx.webServer.register(route), 'dsh-sidebar-row-menu: routes');
38
43
  } catch (error) {
39
- // 注册失败不应该拖垮整个插件树:客户端菜单里的两个「打开」会用不了,其余照常。
44
+ // 注册失败不应该拖垮整个插件树:打开与删除会用不了,其余功能照常。
40
45
  ctx.logger?.warn?.('[dsh-sidebar-row-menu] route registration failed:', error);
41
46
  }
42
47
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "dsh-sidebar-row-menu",
3
- "version": "1.3.4",
3
+ "version": "1.4.0",
4
4
  "type": "module",
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.",
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": [
7
7
  "deepseek",
8
8
  "dsh",
@@ -35,6 +35,7 @@
35
35
  "cordis.patch.yml",
36
36
  "README.md",
37
37
  "README.en.md",
38
+ "CHANGELOG.md",
38
39
  "LICENSE",
39
40
  "NOTICE.md",
40
41
  "NOTICE.en.md",
@@ -59,7 +60,7 @@
59
60
  "scripts": {
60
61
  "test": "node --test test/host.test.mjs test/client.test.mjs",
61
62
  "test:local": "node --test --test-isolation=none test/host.test.mjs test/client.test.mjs",
62
- "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",
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
64
  "build": "npm pack",
64
65
  "marketplace:check": "node scripts/marketplace-preflight.mjs",
65
66
  "prepack": "npm run check",