dsh-session-groups 0.2.0 → 0.2.1

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 ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ ## 0.2.1 — 2026-11-12
4
+
5
+ ### Added
6
+
7
+ - 工作区右键 **新建会话**:在当前工作区创建并打开新会话。
8
+ - 分组右键 **在此分组新建会话**:新会话创建后直接归入该分组,并自动展开分组和上级。
9
+
10
+ ### Fixed
11
+
12
+ - 桌面端分组数据未落盘:宿主侧改为通过 `webServer` 注册已认证的 `/session-tree` 路由(`load` / `save`),使用 Connection 的 `requestRejection` 校验浏览器凭证;保留结构校验、原子写入和 `.bak` 备份。
13
+ - 宿主路由失败时客户端继续使用 `localStorage` 缓存,不阻塞侧边栏。
14
+
15
+ ## 0.2.0 — 2026-11-11
16
+
17
+ - 首个公开版本:工作区内的可嵌套会话分组树、分组/会话排序、移动、导入导出和本地落盘。
package/README.md CHANGED
@@ -26,6 +26,8 @@ DSH 原生的层级是:
26
26
 
27
27
  - 每个工作区独立维护自己的分组树
28
28
  - 新建根分组、新建子分组
29
+ - 工作区右键 **新建会话**:在该工作区直接创建并打开新会话
30
+ - 分组右键 **在此分组新建会话**:新会话创建后直接归入该分组,无需再移动
29
31
  - 行内重命名(Enter 提交,Esc 取消)
30
32
  - 折叠 / 展开分组,分组标题显示会话数量
31
33
  - 通过右键菜单“移动会话”归类会话,也可直接拖拽会话到分组标题
@@ -37,26 +39,29 @@ DSH 原生的层级是:
37
39
  - 分组和归类关系自动落盘到 DSH 主目录(原子写入并保留 `.bak` 备份),浏览器 `localStorage` 只作为缓存和降级
38
40
  - 支持导出 / 导入分组数据,方便备份、迁移和分享
39
41
  - 同一浏览器的多个标签页通过 `localStorage` 事件自动同步
40
- - 工作区右键菜单增加:新建分组、显示全部会话、展开全部分组、折叠全部分组、导出分组数据、导入分组数据
42
+ - 工作区右键菜单增加:新建会话、新建分组、显示全部会话、展开全部分组、折叠全部分组、导出分组数据、导入分组数据
41
43
  - 打开有分组的工作区时自动展开被原生“显示更多”隐藏的会话,保证分组树完整
42
44
 
43
45
  ## 使用
44
46
 
45
47
  1. 右键工作区标题 → **新建分组**(或右键已有分组 → **新建子分组**)。
46
48
  2. 输入分组名,按 Enter。
47
- 3. 归类会话:
49
+ 3. 新建会话:
50
+ - 右键工作区 → **新建会话**:在该工作区创建并打开;
51
+ - 右键分组 → **在此分组新建会话**:创建后直接归入该分组。
52
+ 4. 归类会话:
48
53
  - 右键会话 → **移动会话** → 选择目标分组,或选择 **新建分组并移入…**;或
49
54
  - 把会话行拖到分组标题上。
50
- 4. 在分组标题上:
55
+ 5. 在分组标题上:
51
56
  - 点击箭头 / 标题:折叠或展开
52
57
  - `+`:新建子分组
53
58
  - 铅笔:重命名
54
59
  - 垃圾桶:删除分组(有确认);会话和子分组会移到上一级并自动展开
55
- - 右键:新建子分组 / 重命名 / 折叠 / 移到顶层 / 分组排序 / 删除
56
- 5. 会话排序:右键组内会话 → **排序** → 移到最前 / 上移 / 下移 / 移到最后;需要回到原生顺序时选 **恢复默认顺序**。
57
- 6. 分组排序:右键分组 → **分组排序** → 移到最前 / 上移 / 下移 / 移到最后;同级分组也可按名称重排。
58
- 7. 要取消归类:右键会话 → **移动会话** → **移出分组**,或把会话拖到“未分组”。
59
- 8. 备份或迁移:右键任意工作区 → **导出分组数据**,会下载一个 JSON 文件;在新机器或重装后右键工作区 → **导入分组数据** 选择该文件即可。导入会替换当前全部分组,不会删除任何会话。
60
+ - 右键:新建会话 / 新建子分组 / 重命名 / 折叠 / 移到顶层 / 分组排序 / 删除
61
+ 6. 会话排序:右键组内会话 → **排序** → 移到最前 / 上移 / 下移 / 移到最后;需要回到原生顺序时选 **恢复默认顺序**。
62
+ 7. 分组排序:右键分组 → **分组排序** → 移到最前 / 上移 / 下移 / 移到最后;同级分组也可按名称重排。
63
+ 8. 要取消归类:右键会话 → **移动会话** → **移出分组**,或把会话拖到“未分组”。
64
+ 9. 备份或迁移:右键任意工作区 → **导出分组数据**,会下载一个 JSON 文件;在新机器或重装后右键工作区 → **导入分组数据** 选择该文件即可。导入会替换当前全部分组,不会删除任何会话。
60
65
 
61
66
  > 分组树只出现在原生“分组方式 = 工作区 / 工作区树”的列表中。搜索结果是临时平铺列表,不显示分组;切回工作区列表后分组会恢复。
62
67
 
@@ -133,16 +138,16 @@ window.__DSH_SESSION_TREE__.importJson(text) // 弹出确认后整份替换
133
138
  - `MutationObserver` 监听行增减,重新计算分组布局。
134
139
  - 分组右键菜单通过 `ctx.contextMenus` 注册一个插件私有 target;会话/工作区菜单通过官方 API 追加动作。
135
140
  - 归类关系按 `workspaceId → sessionId → groupId` 保存;不修改会话数据。
136
- - 宿主半侧在 `ctx.connection.rpc` 上注册插件私有通道 `/session-tree`(`load` / `save`),走官方已认证的 Connection RPC,不额外开放 HTTP 路由。
141
+ - 宿主半侧通过 `ctx.webServer` 注册已认证的 `/session-tree` 前缀路由(`load` / `save`),用官方 Connection 的 `requestRejection` 校验浏览器凭证;不依赖浏览器端 `connection.rpc` 的 HTTP 转发。
137
142
  - 宿主只做结构校验、大小上限和原子文件写入;名称、父链、归类关系仍由客户端 `sanitizeState` 修正。
138
- - 连接服务不可用时(例如非 Web combo)客户端自动退回 `localStorage`,侧边栏照常工作。
143
+ - Connection 服务不可用(例如非 Web combo)时只允许回环地址访问;客户端仍照常退回 `localStorage`。
139
144
 
140
145
  ## 兼容性
141
146
 
142
147
  - 目标版本:DSH `0.2.2-rc.1`(桌面端 Web GUI)。
143
148
  - 依赖官方行上的 `data-row-key` 约定和 `sidebar.workspaces` 区域的 DOM 结构。DSH 大版本升级后如果行结构变化,需要同步调整 `sectionForElement` / `wrapperOf`。
144
- - 宿主落盘依赖 Web combo 中的 `@deepseek-ai/dsh-client-connection`;缺失时自动退回 localStorage。
145
- - 不使用构建步骤:直接编辑 `lib/client.js`,刷新页面即可生效;修改 `lib/index.js` 后必须重启 DSH 才能重新加载宿主半侧(Node 会缓存已导入的模块,插件管理里的关闭/启用不会重新读取文件)。
149
+ - 宿主落盘依赖 Web combo 的 `webServer` 与 `connection` 服务;`connection` 缺失时只允许回环地址读写,不退回 localStorage。
150
+ - 不使用构建步骤:直接编辑 `lib/client.js`,刷新页面即可生效;修改 `lib/index.js` 后建议重启 DSH。插件管理的关闭/启用可能触发 HMR 重载宿主,但已打开的页面可能仍持有旧客户端 bundle,需要重新加载甚至重启才能恢复。
146
151
 
147
152
  ## 已知限制
148
153
 
@@ -152,5 +157,5 @@ window.__DSH_SESSION_TREE__.importJson(text) // 弹出确认后整份替换
152
157
  - 分组数据落在 `$DSH_HOME` 下;同一台机器、同一个 DSH 主目录的多个 profile 共用一份文件,不同主目录不共享,也不会跨设备同步。
153
158
  - 导入是“整份替换”,不是合并;导入前建议先导出一份当前数据。
154
159
  - 不支持跨工作区移动会话:DSH 的会话归属由会话工作目录/工作区记录决定,插件只保存显示分组;把会话显示到另一个工作区会与原生归属、权限和工作目录语义冲突。跨区需求应通过“在目标工作区新建会话”完成,而不是移动。
155
- - 落盘依赖 `dsh-client-connection` 的认证 RPC。该服务缺失或连接失败时,数据只保留在浏览器 `localStorage`,并会在 Console 给出警告。
160
+ - 落盘依赖 `webServer` 路由和 Connection 认证;`connection` 缺失时只允许回环地址访问。客户端 RPC 失败或宿主不可用时,数据保留在浏览器 `localStorage`,并在 Console 给出警告。
156
161
  - 插件通过 DOM 增强官方列表,不提供官方 API 级别的替换;如果 DSH 改变侧边栏 DOM 结构,需要适配。
package/lib/client.js CHANGED
@@ -51,6 +51,9 @@ window.__ModuleLoader__.load({
51
51
  const STRINGS = {
52
52
  zh: {
53
53
  newGroup: "新建分组",
54
+ newSession: "新建会话",
55
+ newSessionInGroup: "在此分组新建会话",
56
+ sessionCreateFailed: "新建会话失败",
54
57
  newSubgroup: "新建子分组",
55
58
  rename: "重命名",
56
59
  deleteGroup: "删除分组",
@@ -101,6 +104,9 @@ window.__ModuleLoader__.load({
101
104
  },
102
105
  en: {
103
106
  newGroup: "New group",
107
+ newSession: "New session",
108
+ newSessionInGroup: "New session in this group",
109
+ sessionCreateFailed: "Could not create a session",
104
110
  newSubgroup: "New subgroup",
105
111
  rename: "Rename",
106
112
  deleteGroup: "Delete group",
@@ -1426,6 +1432,51 @@ window.__ModuleLoader__.load({
1426
1432
  return group;
1427
1433
  }
1428
1434
 
1435
+ async function createSession(wsId, groupId) {
1436
+ const sessions = getService("sessions");
1437
+ if (!sessions || typeof sessions.create !== "function") {
1438
+ toast(t("sessionCreateFailed"));
1439
+ return;
1440
+ }
1441
+ try {
1442
+ ensureWorkspaceExpanded(wsId);
1443
+ const sessionId = await sessions.create({ workspaceId: wsId });
1444
+ if (!sessionId) throw new Error("session create returned no id");
1445
+
1446
+ if (groupId) {
1447
+ const ws = getWorkspace(wsId, true);
1448
+ const group = findGroup(ws, groupId);
1449
+ if (ws && group) {
1450
+ const previous = ws.assignments[sessionId];
1451
+ if (previous !== groupId) {
1452
+ normalizeGroupOrder(wsId, groupId);
1453
+ ws.assignments[sessionId] = groupId;
1454
+ ws.sessionOrder = ws.sessionOrder || {};
1455
+ ws.sessionOrder[sessionId] = nextSessionOrder(ws, groupId);
1456
+ }
1457
+ group.collapsed = false;
1458
+ let parentId = group.parentId;
1459
+ while (parentId) {
1460
+ const parent = findGroup(ws, parentId);
1461
+ if (!parent) break;
1462
+ parent.collapsed = false;
1463
+ parentId = parent.parentId;
1464
+ }
1465
+ saveState();
1466
+ scheduleApply();
1467
+ }
1468
+ }
1469
+
1470
+ const uiWorkspace = getService("uiWorkspace");
1471
+ if (uiWorkspace && typeof uiWorkspace.openSession === "function") {
1472
+ uiWorkspace.openSession(sessionId);
1473
+ }
1474
+ } catch (error) {
1475
+ console.warn("[" + MARK + "] could not create a session", error);
1476
+ toast(t("sessionCreateFailed"));
1477
+ }
1478
+ }
1479
+
1429
1480
  function beginRename(wsId, groupId, isNew) {
1430
1481
  const ws = getWorkspace(wsId, false);
1431
1482
  const group = findGroup(ws, groupId);
@@ -1839,6 +1890,7 @@ window.__ModuleLoader__.load({
1839
1890
  resolve: ({ workspaceId }) => {
1840
1891
  if (!workspaceId) return [];
1841
1892
  return [
1893
+ { id: "new-session", label: t("newSession"), run: () => createSession(workspaceId) },
1842
1894
  { id: "new-group", label: t("newGroup") + "…", run: () => createGroup(workspaceId, null) },
1843
1895
  { id: "show-all", label: t("showAll"), run: () => expandAllSessions(workspaceId, false) },
1844
1896
  { id: "expand-groups", label: t("expandAllGroups"), run: () => setAllGroupsCollapsed(workspaceId, false) },
@@ -1865,6 +1917,11 @@ window.__ModuleLoader__.load({
1865
1917
  const orderIndex = siblings.findIndex((candidate) => candidate.id === groupId);
1866
1918
  const orderLast = siblings.length - 1;
1867
1919
  return [
1920
+ {
1921
+ id: "new-session",
1922
+ label: t("newSessionInGroup"),
1923
+ run: () => createSession(workspaceId, groupId),
1924
+ },
1868
1925
  { id: "subgroup", label: t("newSubgroup") + "…", run: () => createGroup(workspaceId, groupId) },
1869
1926
  { id: "rename", label: t("rename") + "…", run: () => beginRename(workspaceId, groupId, false) },
1870
1927
  {
package/lib/index.js CHANGED
@@ -20,8 +20,8 @@ import { dirname, join } from 'node:path'
20
20
  /** Stable Cordis plugin name. */
21
21
  export const name = 'session-tree'
22
22
 
23
- /** No required host services; `connection` is injected opportunistically. */
24
- export const inject = []
23
+ /** The shared HTTP server supplies the persistence route. */
24
+ export const inject = ['webServer']
25
25
 
26
26
  const CHANNEL = '/session-tree'
27
27
  const FORMAT = 'dsh-session-tree'
@@ -160,52 +160,180 @@ function failure(code, message) {
160
160
  return { ok: false, error: { code, message, details: {} } }
161
161
  }
162
162
 
163
- function installRpc(scope) {
164
- const connection = scope.connection || (typeof scope.get === 'function' ? scope.get('connection') : undefined)
165
- const rpc = connection && connection.rpc
166
- if (!rpc || typeof rpc.handle !== 'function') {
167
- console.warn('[dsh-session-tree] connection RPC is unavailable; host persistence is off')
168
- return
163
+ const MAX_REQUEST_BYTES = MAX_STATE_BYTES + 64 * 1024
164
+
165
+ /** Whether the Host connection service accepts this request as authenticated. */
166
+ function authorized(req, connection) {
167
+ if (connection && typeof connection.requestRejection === 'function') {
168
+ try {
169
+ return connection.requestRejection(req) === undefined
170
+ } catch {
171
+ return false
172
+ }
169
173
  }
174
+ // No Connection service (for example a non-Web combo): only loopback may
175
+ // read or replace local group data.
176
+ const address = (req.socket && req.socket.remoteAddress) || ''
177
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
178
+ }
170
179
 
171
- rpc.handle(CHANNEL, async (endpoint, payload) => {
172
- if (endpoint === 'load') {
173
- try {
174
- const stored = await serialize(loadStored)
175
- return { ok: true, value: stored }
176
- } catch (error) {
177
- console.warn('[dsh-session-tree] host load failed', error)
178
- return failure('session-tree/load-failed', 'could not read saved groups')
180
+ /** Read a bounded request body as UTF-8. */
181
+ function readBody(req) {
182
+ return new Promise((resolve, reject) => {
183
+ const chunks = []
184
+ let total = 0
185
+ let settled = false
186
+ const fail = (error) => {
187
+ if (settled) return
188
+ settled = true
189
+ reject(error)
190
+ }
191
+ req.on('data', (chunk) => {
192
+ if (settled) return
193
+ total += chunk.length
194
+ if (total > MAX_REQUEST_BYTES) {
195
+ fail(Object.assign(new Error('request is too large'), { code: 'request-too-large' }))
196
+ return
179
197
  }
198
+ chunks.push(chunk)
199
+ })
200
+ req.on('end', () => {
201
+ if (settled) return
202
+ settled = true
203
+ resolve(Buffer.concat(chunks).toString('utf8'))
204
+ })
205
+ req.on('error', fail)
206
+ req.on('aborted', () => fail(new Error('request aborted')))
207
+ })
208
+ }
209
+
210
+ /** Write one JSON response without leaking connection-level details. */
211
+ function sendJson(res, status, value) {
212
+ const body = JSON.stringify(value)
213
+ res.writeHead(status, {
214
+ 'cache-control': 'no-store',
215
+ 'content-type': 'application/json; charset=utf-8',
216
+ 'content-length': Buffer.byteLength(body, 'utf8'),
217
+ })
218
+ res.end(body)
219
+ }
220
+
221
+ async function handleRpc(req, res, connection) {
222
+ if (req.method !== 'POST') {
223
+ res.writeHead(405, { allow: 'POST', 'cache-control': 'no-store' })
224
+ res.end()
225
+ return
226
+ }
227
+ if (!authorized(req, connection)) {
228
+ res.writeHead(401, { 'cache-control': 'no-store' })
229
+ res.end('unauthorized')
230
+ return
231
+ }
232
+ const contentType = String(req.headers['content-type'] || '')
233
+ .split(';', 1)[0]
234
+ .trim()
235
+ .toLowerCase()
236
+ if (contentType !== 'application/json') {
237
+ res.writeHead(415, { 'cache-control': 'no-store' })
238
+ res.end('content type must be application/json')
239
+ return
240
+ }
241
+
242
+ let envelope
243
+ try {
244
+ envelope = JSON.parse(await readBody(req))
245
+ } catch (error) {
246
+ if (error && error.code === 'request-too-large') {
247
+ res.writeHead(413, { 'cache-control': 'no-store' })
248
+ res.end('request is too large')
249
+ return
180
250
  }
251
+ res.writeHead(400, { 'cache-control': 'no-store' })
252
+ res.end('body is not JSON')
253
+ return
254
+ }
255
+ if (
256
+ !envelope ||
257
+ typeof envelope !== 'object' ||
258
+ envelope.type !== 'client-request' ||
259
+ typeof envelope.rpcId !== 'string' ||
260
+ typeof envelope.method !== 'string'
261
+ ) {
262
+ res.writeHead(400, { 'cache-control': 'no-store' })
263
+ res.end('invalid request envelope')
264
+ return
265
+ }
181
266
 
182
- if (endpoint === 'save') {
183
- const state = payload && typeof payload === 'object' ? payload.state : undefined
184
- try {
185
- assertState(state)
186
- const savedAt = await serialize(() => saveStored(state))
187
- return { ok: true, value: { savedAt } }
188
- } catch (error) {
189
- console.warn('[dsh-session-tree] host save failed', error)
190
- return failure('session-tree/save-failed', 'could not save groups')
191
- }
267
+ let result
268
+ if (envelope.method === 'load') {
269
+ try {
270
+ const stored = await serialize(loadStored)
271
+ result = { ok: true, value: stored }
272
+ } catch (error) {
273
+ console.warn('[dsh-session-tree] host load failed', error)
274
+ result = failure('session-tree/load-failed', 'could not read saved groups')
192
275
  }
276
+ } else if (envelope.method === 'save') {
277
+ const state = envelope.payload && typeof envelope.payload === 'object' ? envelope.payload.state : undefined
278
+ try {
279
+ assertState(state)
280
+ const savedAt = await serialize(() => saveStored(state))
281
+ result = { ok: true, value: { savedAt } }
282
+ } catch (error) {
283
+ console.warn('[dsh-session-tree] host save failed', error)
284
+ result = failure('session-tree/save-failed', 'could not save groups')
285
+ }
286
+ } else {
287
+ result = failure('session-tree/not-found', 'unknown endpoint ' + JSON.stringify(String(envelope.method)))
288
+ }
289
+ sendJson(res, 200, { type: 'server-response', rpcId: envelope.rpcId, result })
290
+ }
193
291
 
194
- return failure('session-tree/not-found', 'unknown endpoint ' + JSON.stringify(String(endpoint)))
292
+ function installRoute(scope, webServer) {
293
+ const dispose = webServer.register({
294
+ kind: 'prefix',
295
+ path: CHANNEL,
296
+ handler: (req, res) => {
297
+ let connection
298
+ try {
299
+ connection = typeof scope.get === 'function' ? scope.get('connection') : scope.connection
300
+ } catch {
301
+ connection = undefined
302
+ }
303
+ void handleRpc(req, res, connection).catch((error) => {
304
+ console.warn('[dsh-session-tree] host route failed', error)
305
+ if (!res.headersSent) {
306
+ res.writeHead(500, { 'cache-control': 'no-store' })
307
+ res.end('internal error')
308
+ } else {
309
+ res.end()
310
+ }
311
+ })
312
+ },
195
313
  })
314
+ return dispose
196
315
  }
197
316
 
198
317
  /** Mount the host half. */
199
318
  export function apply(ctx) {
200
319
  try {
201
- ctx.inject(['connection'], (scope) => {
202
- try {
203
- installRpc(scope)
204
- } catch (error) {
205
- console.warn('[dsh-session-tree] could not register host RPC', error)
206
- }
207
- })
320
+ const webServer = ctx.webServer || (typeof ctx.get === 'function' ? ctx.get('webServer') : undefined)
321
+ if (!webServer || typeof webServer.register !== 'function') {
322
+ console.warn('[dsh-session-tree] web server is unavailable; host persistence is off')
323
+ return
324
+ }
325
+ const dispose = installRoute(ctx, webServer)
326
+ ctx.effect(
327
+ () => () => {
328
+ try {
329
+ if (typeof dispose === 'function') dispose()
330
+ } catch {
331
+ /* ignore */
332
+ }
333
+ },
334
+ 'session-tree: host persistence route',
335
+ )
208
336
  } catch (error) {
209
- console.warn('[dsh-session-tree] could not inject connection', error)
337
+ console.warn('[dsh-session-tree] could not register host route', error)
210
338
  }
211
339
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-groups",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "DSH Web GUI sidebar session tree: group sessions inside each workspace into folders, persisted to disk with import/export.",
5
5
  "author": "westformula1",
6
6
  "license": "MIT",
@@ -24,6 +24,7 @@
24
24
  "README.md",
25
25
  "LICENSE",
26
26
  "PUBLISHING.md",
27
+ "CHANGELOG.md",
27
28
  "assets",
28
29
  "cordis.patch.yml"
29
30
  ],