dsh-session-curator 0.1.0 → 0.1.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/lib/index.js CHANGED
@@ -2,9 +2,11 @@
2
2
  * dsh-session-curator — host 半区。
3
3
  *
4
4
  * 提供三块能力:
5
- * 1. 会话软删除:把 `<DSH_HOME>/sessions/<slug>/<sessionId>/` 整个搬到
6
- * `<DSH_HOME>/storages/session-manager/trash/<sessionId>/`,并从工作区记账里
7
- * 摘掉(`Workspace.detachSession`)。可完整恢复。
5
+ * 1. 会话软删除:先把会话**藏起来**(进 registry-global 的归档集合 —— DSH 里唯一能让它
6
+ * 从侧边栏所有分组面消失的机制,否则它会以"未分组"的幽灵行留在那里),再把它从
7
+ * 工作区记账里摘掉(`Workspace.detachSession`),最后把
8
+ * `<DSH_HOME>/sessions/<slug>/<sessionId>/` 整个搬到
9
+ * `<DSH_HOME>/storages/session-manager/trash/<sessionId>/`。可完整恢复。
8
10
  * ⚠️ 目录名 `session-manager` 是**故意保留的旧名**(见下面 TRASH_SUBPATH 的注释)。
9
11
  * 2. 回收站管理:列出 / 恢复 / 永久删除。
10
12
  * 3. 归档会话管理:列出全部归档会话(标题、目录、体积),支持批量取消归档、置顶,
@@ -14,7 +16,7 @@
14
16
  * `ctx.connection.fetch.register`):信任检查与会话鉴权由 Connection 的 `/api`
15
17
  * 前缀路由先做完,所以插件自己不需要任何鉴权代码。
16
18
  */
17
- import { promises as fsp, existsSync } from "node:fs";
19
+ import { promises as fsp, existsSync, readFileSync } from "node:fs";
18
20
  import { homedir } from "node:os";
19
21
  import { dirname, join, resolve } from "node:path";
20
22
 
@@ -60,6 +62,82 @@ const TRASH_SUBPATH = ["storages", "session-manager", "trash"];
60
62
  const META_FILE = "__trash.json";
61
63
  const PROJ_FILE = "__projcache.json";
62
64
 
65
+ // ── 版本与更新检查 ───────────────────────────────────────────────────────
66
+ /**
67
+ * 本地版本:`lib/index.js` 的上一级就是包根(无论插件是装在 `node_modules` 里还是被
68
+ * junction 指过来,`import.meta.url` 都指向真实文件),所以直接读自己的 manifest。
69
+ */
70
+ const LOCAL_VERSION = readLocalVersion();
71
+
72
+ function readLocalVersion() {
73
+ try {
74
+ const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
75
+ return typeof manifest.version === "string" && manifest.version !== "" ? manifest.version : null;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * 更新检查:问 npm registry 的 `latest` 标签。
83
+ *
84
+ * 为什么放在**宿主**而不是客户端直接 fetch:客户端那侧要过 CORS / 可能的 CSP 和桌面端网络
85
+ * 策略,宿主本来就是普通 Node,顺便还能做超时与缓存。只读 GET,没有任何副作用。
86
+ */
87
+ const UPDATE_REGISTRY = "https://registry.npmjs.org";
88
+ /** 10 分钟内复用上次结果:打开配置页不该每次都打 registry。 */
89
+ const UPDATE_TTL_MS = 10 * 60 * 1000;
90
+ const UPDATE_TIMEOUT_MS = 5000;
91
+ let updateCache = null;
92
+
93
+ /**
94
+ * 宽松 semver 比较:只按数字段比,带 prerelease 后缀的算更低
95
+ * (`0.1.1` > `0.1.1-rc.1`,`0.2.0` > `0.1.9`)。够判断"有没有新版",不引依赖。
96
+ * 导出是为了让 `scripts/smoke.mjs` 直接单测它(浏览器里用不到)。
97
+ */
98
+ export function compareVersions(left, right) {
99
+ const parse = (value) => {
100
+ const [core, ...rest] = String(value).replace(/^v/, "").split("-");
101
+ return { parts: core.split(".").map((part) => Number.parseInt(part, 10) || 0), prerelease: rest.length > 0 };
102
+ };
103
+ const a = parse(left);
104
+ const b = parse(right);
105
+ for (let index = 0; index < Math.max(a.parts.length, b.parts.length); index += 1) {
106
+ const difference = (a.parts[index] ?? 0) - (b.parts[index] ?? 0);
107
+ if (difference !== 0) return difference > 0 ? 1 : -1;
108
+ }
109
+ if (a.prerelease === b.prerelease) return 0;
110
+ return a.prerelease ? -1 : 1;
111
+ }
112
+
113
+ async function checkForUpdate(force) {
114
+ const now = Date.now();
115
+ if (force !== true && updateCache !== null && now - updateCache.at < UPDATE_TTL_MS) return updateCache.value;
116
+ const base = { current: LOCAL_VERSION, latest: null, outdated: false, checkedAt: now };
117
+ let value;
118
+ try {
119
+ const response = await fetch(`${UPDATE_REGISTRY}/${name}/latest`, {
120
+ headers: { accept: "application/json" },
121
+ signal: AbortSignal.timeout(UPDATE_TIMEOUT_MS)
122
+ });
123
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
124
+ const body = await response.json();
125
+ const latest = typeof body?.version === "string" && body.version !== "" ? body.version : null;
126
+ if (latest === null) throw new Error("registry 没有返回 version 字段");
127
+ value = {
128
+ ...base,
129
+ latest,
130
+ outdated: LOCAL_VERSION !== null && compareVersions(latest, LOCAL_VERSION) > 0,
131
+ checkedAt: Date.now()
132
+ };
133
+ } catch (error) {
134
+ // 离线 / 超时 / 代理不通都走这里:界面照常显示版本号,只是没有更新提示。
135
+ value = { ...base, error: messageOf(error) };
136
+ }
137
+ updateCache = { at: Date.now(), value };
138
+ return value;
139
+ }
140
+
63
141
  // ── 文件系统小工具 ───────────────────────────────────────────────────────
64
142
  function trashRoot() {
65
143
  return dshHomePath(...TRASH_SUBPATH);
@@ -236,21 +314,31 @@ class SessionManager {
236
314
  async buildSnapshot() {
237
315
  const registry = this.registry;
238
316
  const dirs = await this.scanSessionDirs();
317
+ const trashed = await this.listTrash();
318
+ // 回收站条目**只**归回收站页签。删除时我们故意让 id 留在归档集合里(那是 DSH
319
+ // 唯一"从所有分组面消失"的机制,见 trash()),所以这里必须把交集滤掉:
320
+ // 否则同一个会话会同时出现在"已归档"和"回收站",而"已归档"那条还是没有目录的幽灵行。
321
+ const inTrash = new Set(trashed.map((row) => row.sessionId));
239
322
  const archived = [];
240
323
  for (const sessionId of registry.archivedSessionIds) {
324
+ // 顺带把"目录已经不在了"的 id 也滤掉:`archivedSessionIds` 是**只增不减**的记账
325
+ //(DSH 自己的注释:目录没了的条目照样留在集合里),而 DSH 的侧边栏只从会话清单里
326
+ // 取行 —— 这种 id 在它那边本来就不显示。列出来只会得到一条 0 B、点不动的幽灵行。
327
+ if (inTrash.has(sessionId) || !dirs.has(sessionId)) continue;
241
328
  archived.push(await this.describeSession(sessionId, dirs));
242
329
  }
243
330
  const pinned = [];
244
331
  for (const sessionId of registry.pinnedSessionIds) {
332
+ if (inTrash.has(sessionId) || !dirs.has(sessionId)) continue;
245
333
  pinned.push(await this.describeSession(sessionId, dirs));
246
334
  }
247
- const trashed = await this.listTrash();
248
335
  let archivedBytes = 0;
249
336
  for (const row of archived) archivedBytes += row.bytes;
250
337
  let trashBytes = 0;
251
338
  for (const row of trashed) trashBytes += row.bytes;
252
339
  return {
253
340
  dshHome: dshHomePath(),
341
+ version: LOCAL_VERSION,
254
342
  trashDir: trashRoot(),
255
343
  sessionsDir: sessionsRoot(),
256
344
  archived,
@@ -313,8 +401,20 @@ class SessionManager {
313
401
  }
314
402
 
315
403
  /**
316
- * 软删除:移出工作区记账 + 搬到回收站(含投影缓存)。
317
- * 归档中的会话先取消归档,这样恢复后它能回到原来的位置。
404
+ * 软删除:把会话**藏起来**(进归档集合)+ 移出工作区记账 + 搬到回收站(含投影缓存)。
405
+ *
406
+ * 为什么是"归档"而不是"取消归档":DSH 里**唯一**能让一个会话从所有分组面
407
+ *(侧边栏的工作区分组、**未分组**、置顶)消失的机制,就是 `archivedSessionIds`
408
+ * 这个 registry-global 集合 —— 它的注释原话是 "sessions hidden from every grouping
409
+ * surface",而且归档**不动工作区记账**,槽位留着,所以恢复时位置不变。
410
+ *
411
+ * 反过来做(先 `unarchiveSession`)必然出幽灵行:取消归档会把会话重新暴露给
412
+ * 客户端那份会话列表(`ctx.sessions.list`),而它的目录虽然已经被我们搬走,
413
+ * 客户端 list 里那条还在 —— 侧边栏 `groupByWorkspace()` 的判定是
414
+ * "在 list 里 && 不属于任何工作区 && 不在归档集合里" → 落进**未分组**。
415
+ * 同时归档集合里已经没有它,等于谁也管不住这条幽灵行了。
416
+ *
417
+ * 原来是否归档记在 `__trash.json` 的 `wasArchived` 里,恢复时照原样还原。
318
418
  */
319
419
  async trash(ids) {
320
420
  await fsp.mkdir(trashRoot(), { recursive: true });
@@ -329,9 +429,17 @@ class SessionManager {
329
429
  const info = dirs.get(sessionId);
330
430
  if (!info) throw new Error("找不到会话日志目录(可能已被删除)");
331
431
 
332
- if (this.registry.archivedSessionIds.includes(sessionId)) {
333
- await this.registry.unarchiveSession(sessionId);
334
- steps.push("unarchive");
432
+ // 必须**趁目录还在**归档:`archiveSession` 要先通过 `sessionKnown()`
433
+ //(live 会话 / 头部索引 / 持久化清单),搬走之后三者都查不到了。
434
+ const wasArchived = this.registry.archivedSessionIds.includes(sessionId);
435
+ if (!wasArchived) {
436
+ try {
437
+ await this.registry.archiveSession(sessionId);
438
+ steps.push("archive");
439
+ } catch (error) {
440
+ // 藏不住不该阻止删除:会话可能继续留在侧边栏,但数据照样进回收站。
441
+ steps.push(`archive-failed:${messageOf(error)}`);
442
+ }
335
443
  }
336
444
  // 置顶的会话也要先取消置顶:否则它的 id 还留在 pinnedSessionIds 里,
337
445
  // 搬进回收站之后会同时出现在"已置顶"和"回收站"两个页签。
@@ -375,6 +483,9 @@ class SessionManager {
375
483
  cwd: projection.cwd,
376
484
  workspaceId: workspace ? String(workspace.id) : null,
377
485
  workspaceTitle: workspace ? workspace.title : null,
486
+ // 删除前是否已经归档 —— 恢复时要照原样还原(老条目没有这个字段,
487
+ // 一律按"没归档过"处理,也就是恢复成普通会话)。
488
+ wasArchived,
378
489
  deletedAt: Date.now()
379
490
  },
380
491
  null,
@@ -386,7 +497,14 @@ class SessionManager {
386
497
  });
387
498
  }
388
499
 
389
- /** 从回收站恢复;尽力把会话重新挂回原工作区。 */
500
+ /**
501
+ * 从回收站恢复;尽力把会话重新挂回原工作区,并把归档状态还原成删除前的样子。
502
+ *
503
+ * `trash()` 会把会话塞进归档集合(藏起来),所以这里必须配对处理:
504
+ * - 删除前**已经归档**(`wasArchived: true`)→ 保持归档,它就回到"已归档"页签;
505
+ * - 其余(含老版本写下的、没有这个字段的条目)→ 取消归档,恢复成普通会话。
506
+ * 不还原的话,恢复出来的会话会莫名其妙地"还在已归档里"。
507
+ */
390
508
  async restore(ids) {
391
509
  const dirs = await this.scanSessionDirs();
392
510
  return this.each(ids, async (sessionId) => {
@@ -423,6 +541,12 @@ class SessionManager {
423
541
  // 元数据残留无害
424
542
  }
425
543
 
544
+ // 归档状态先还原,再挂回工作区:attachSession 要读得到会话头(目录已经回来了)。
545
+ if (meta.wasArchived !== true && this.registry.archivedSessionIds.includes(sessionId)) {
546
+ await this.registry.unarchiveSession(sessionId);
547
+ steps.push("unarchive");
548
+ }
549
+
426
550
  if (typeof meta.cwd === "string" && meta.cwd !== "") {
427
551
  try {
428
552
  const workspace = await this.registry.resolveByPath(meta.cwd);
@@ -438,14 +562,32 @@ class SessionManager {
438
562
  });
439
563
  }
440
564
 
441
- /** 永久删除(不可恢复)。 */
565
+ /**
566
+ * 永久删除(不可恢复)。
567
+ *
568
+ * 先取消归档:`trash()` 为了让会话从侧边栏消失而把它放进了归档集合,永久删除之后
569
+ * 再没人会去动这条 id —— 留在集合里就是一条**永久幽灵**(DSH 自身会一直以为它归档着,
570
+ * 界面上又找不到它)。
571
+ */
442
572
  async purge(ids) {
443
573
  return this.each(ids, async (sessionId) => {
444
574
  const target = join(trashRoot(), sessionId);
445
575
  if (!existsSync(target)) throw new Error("回收站里没有这个会话");
576
+ const steps = [];
577
+ if (this.registry.archivedSessionIds.includes(sessionId)) {
578
+ await this.registry.unarchiveSession(sessionId);
579
+ steps.push("unarchive");
580
+ }
446
581
  await fsp.rm(target, { recursive: true, force: true });
582
+ steps.push("purge");
583
+ return steps;
447
584
  });
448
585
  }
586
+
587
+ /** 更新检查:只读,结果在模块级缓存(见 checkForUpdate)。 */
588
+ checkUpdate(force) {
589
+ return checkForUpdate(force);
590
+ }
449
591
  }
450
592
 
451
593
  // ── 传输 / 分发 ──────────────────────────────────────────────────────────
@@ -480,7 +622,8 @@ const ENDPOINTS = {
480
622
  unpin: (manager, payload) => manager.unpin(payload.ids),
481
623
  trash: (manager, payload) => manager.trash(payload.ids),
482
624
  restore: (manager, payload) => manager.restore(payload.ids),
483
- purge: (manager, payload) => manager.purge(payload.ids)
625
+ purge: (manager, payload) => manager.purge(payload.ids),
626
+ "check-update": (manager, payload) => manager.checkUpdate(payload.force === true)
484
627
  };
485
628
 
486
629
  /** 只接受字符串数组;其余一律忽略,避免把宿主路径当 id 传来的越权尝试。 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-curator",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "DeepSeek Harness plugin: soft-delete sessions to a recoverable trash and manage archived/pinned ones, grouped by their original workspace folder. | DSH 插件:会话软删除到可恢复的回收站,并按原来的工作区文件夹管理已归档 / 已置顶会话。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -115,7 +115,7 @@ function assertFetchRoute(route) {
115
115
  if (new Set(route.methods).size !== route.methods.length) throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} repeats a method`);
116
116
  }
117
117
 
118
- const ENDPOINTS = ["snapshot", "archive", "unarchive", "pin", "unpin", "trash", "restore", "purge"];
118
+ const ENDPOINTS = ["snapshot", "archive", "unarchive", "pin", "unpin", "trash", "restore", "purge", "check-update"];
119
119
  const CHANNEL = "/api";
120
120
  const PREFIX = "session-curator";
121
121
  const ROUTE_PREFIX = `${CHANNEL}/${PREFIX}`;
@@ -24,6 +24,17 @@ globalThis.window = {
24
24
  }
25
25
  }
26
26
  };
27
+ /** 内存版 localStorage:设置持久化走的就是它(浏览器里是 window.localStorage)。 */
28
+ const fakeStorage = new Map();
29
+ globalThis.localStorage = {
30
+ getItem: (key) => (fakeStorage.has(key) ? fakeStorage.get(key) : null),
31
+ setItem: (key, value) => {
32
+ fakeStorage.set(key, String(value));
33
+ },
34
+ removeItem: (key) => {
35
+ fakeStorage.delete(key);
36
+ }
37
+ };
27
38
 
28
39
  // ── 迷你 React:够跑一次挂载 + 若干次重渲染 ──────────────────────────────
29
40
  const runtime = { index: 0, values: [], callbacks: [], records: [], pending: [], dirty: false };
@@ -103,6 +114,8 @@ const PRIMITIVE_NAMES = [
103
114
  "Menu",
104
115
  "MenuItemButton",
105
116
  "Modal",
117
+ "SegmentedControl",
118
+ "Switch",
106
119
  "IconChevronDownOutlineRegular",
107
120
  "IconChevronRightOutlineRegular",
108
121
  "IconEllipsisOutlineRegular",
@@ -243,6 +256,7 @@ check("the ⋯ trigger is dimmed like the sidebar's row actions", rowMenuElement
243
256
  // ── B. 整块面板:假 ctx 调 apply(),拿到真正注册的面板组件后渲染 ─────────
244
257
  const SNAPSHOT = {
245
258
  dshHome: "C:\\Users\\Motues\\.dsh",
259
+ version: "0.1.0",
246
260
  trashDir: "C:\\Users\\Motues\\.dsh\\storages\\session-manager\\trash",
247
261
  sessionsDir: "C:\\Users\\Motues\\.dsh\\sessions",
248
262
  archived: [
@@ -258,12 +272,18 @@ const SNAPSHOT = {
258
272
  const slots = [];
259
273
  let dictionaries = null;
260
274
  const hostCalls = [];
275
+ /** 更新检查的假回答;置 null 表示这次让宿主失败(测错误分支)。 */
276
+ let updateResponse = { current: "0.1.0", latest: "0.1.1", outdated: true, checkedAt: 0 };
261
277
  const fakeCtx = {
262
278
  get: (name) => (name === "connection"
263
279
  ? {
264
280
  rpc: {
265
281
  call: async (channel, endpoint, payload) => {
266
282
  hostCalls.push({ channel, endpoint, payload });
283
+ if (endpoint.endsWith("check-update")) {
284
+ if (updateResponse === null) return { ok: false, error: { code: "session-curator/error", message: "registry 不通" } };
285
+ return { ok: true, value: updateResponse };
286
+ }
267
287
  return { ok: true, value: SNAPSHOT };
268
288
  }
269
289
  }
@@ -297,6 +317,9 @@ const tPanel = (key, params) => {
297
317
  return value;
298
318
  };
299
319
 
320
+ /** 官方侧边栏的视图存储:预置成"显示已归档",用来验证配置页那个开关读的是同一份设置。 */
321
+ globalThis.localStorage.setItem("dsh.workspace.view.v5", JSON.stringify({ groupBy: "flat", orderBy: "updated", archivedFilter: "show" }));
322
+
300
323
  let tree = await render(section.component, { t: tPanel });
301
324
  const nodesOf = (current, type) => collect(current, []).filter((node) => node.type === type);
302
325
  const headers = () => nodesOf(tree, client.GroupHeader);
@@ -389,6 +412,134 @@ check("trash tab groups by the recorded workspace folder too", headers().length
389
412
  check("trash rows offer restore + permanent delete", menuOfRow("s-9").props.items.map((item) => item.id).join(",") === "restore,purge", menuOfRow("s-9").props.items.map((item) => item.label).join(" · "));
390
413
  check("trash rows keep the same single-⋯ shape", draw(rowsSeen()[0]).children[2].children.length === 1);
391
414
 
415
+ // ── 配置页:动作在右上、基础设置、版本号与升级提示 ───────────────────────
416
+ // 面板根节点的第 1 个子节点就是顶部条:左边页签组,右边动作组。
417
+ const headerOf = () => tree.children[0];
418
+ const tabBarOf = () => headerOf().children[0];
419
+ const actionsOf = () => headerOf().children[1];
420
+ /** 页签那一组是 `tabs.map(...)` 的结果,迷你渲染器把它当**一个**子节点存着,所以要递归收集。 */
421
+ const tabButtons = () => collect(tabBarOf(), []).filter((node) => node.type === "Button");
422
+ const tabLabels = () => tabButtons().map((node) => node.children[0]).join(" | ");
423
+ const actionButtons = () => (actionsOf() === null ? [] : actionsOf().children.filter(Boolean));
424
+ const switchNodes = () => nodesOf(tree, "Switch");
425
+ /** 按 label(面板传的就是本地化过的名字)找控件,比按下标稳。 */
426
+ const switchByLabel = (label) => switchNodes().find((node) => node.props.label === label);
427
+ const segmentedNodes = () => nodesOf(tree, "SegmentedControl");
428
+ const spanWith = (prefix) => collect(tree, []).find((node) => node.type === "span" && typeof node.children?.[0] === "string" && node.children[0].startsWith(prefix));
429
+
430
+ check(
431
+ "the three data tabs stay on the top-left, with 配置 appended",
432
+ tabButtons().length === 4 && tabLabels() === "已归档 (3) | 已置顶 (1) | 回收站 (1) | 配置",
433
+ tabLabels()
434
+ );
435
+ check(
436
+ "分组 and 刷新 moved to the top-right actions group",
437
+ actionsOf() !== null && actionsOf().props.style.marginInlineStart === "auto" && actionButtons().length === 2
438
+ && actionButtons()[0].children[0].startsWith("分组") && actionButtons()[1].children[0] === "刷新",
439
+ `marginInlineStart=${actionsOf()?.props.style.marginInlineStart} buttons=${actionButtons().map((node) => node.children[0]).join(", ")}`
440
+ );
441
+ check("both right-hand buttons carry a visible border (outline variant)", actionButtons().every((node) => node.props.variant === "outline"), actionButtons().map((node) => node.props.variant).join(", "));
442
+ check("the tab buttons stay in the left group (not among the actions)", tabButtons().every((node) => node.props.variant !== "outline"), tabButtons().map((node) => node.props.variant).join(", "));
443
+
444
+ // 打开配置页。
445
+ buttonByLabel("配置").props.onClick();
446
+ tree = await render(section.component, { t: tPanel });
447
+ check("the config tab drops the list-only actions from the right", headerOf().children[1] === null && tabLabels().endsWith("配置"), tabLabels());
448
+ check("the config page renders 4 switches and 2 segmented controls", switchNodes().length === 4 && segmentedNodes().length === 2, `${switchNodes().length} switch(es), ${segmentedNodes().length} segmented`);
449
+ check(
450
+ "the default-tab control offers exactly the three data tabs (never 配置 itself)",
451
+ segmentedNodes()[0].props.options.map((option) => option.value).join(",") === "archived,pinned,trash",
452
+ segmentedNodes()[0].props.options.map((option) => option.label).join(", ")
453
+ );
454
+ check(
455
+ "the about block shows the version in small light text",
456
+ spanWith("版本") !== undefined && spanWith("版本").children[0] === "版本 v0.1.0"
457
+ && spanWith("版本").props.style.fontSize === "11px" && spanWith("版本").props.style.color === "var(--dsw-alias-label-tertiary)",
458
+ spanWith("版本")?.children?.[0]
459
+ );
460
+ check(
461
+ "an outdated version renders the notice plus the exact upgrade command",
462
+ JSON.stringify(tree).includes("有新版本 0.1.1(当前 0.1.0)")
463
+ && JSON.stringify(tree).includes("升级步骤")
464
+ && JSON.stringify(tree).includes("dsh plugin --profile desktop add dsh-session-curator@latest"),
465
+ String(spanWith("有新版本")?.children?.[0])
466
+ );
467
+ check("opening the config tab really asked the host for updates", hostCalls.some((entry) => entry.endpoint.endsWith("check-update")), hostCalls.map((entry) => entry.endpoint).join(", "));
468
+
469
+ // ── 侧边栏"显示已归档":读写的必须是官方那份设置(dsh.workspace.view.v5) ──
470
+ const SIDEBAR_SWITCH = "侧边栏显示已归档会话";
471
+ check(
472
+ "the sidebar-archived switch mirrors the official setting (seeded to show archived)",
473
+ switchByLabel(SIDEBAR_SWITCH) !== undefined && switchByLabel(SIDEBAR_SWITCH).props.checked === true,
474
+ String(switchByLabel(SIDEBAR_SWITCH)?.props.checked)
475
+ );
476
+ check("no refresh hint before the sidebar value is touched", !JSON.stringify(tree).includes("已写入官方设置"));
477
+
478
+ switchByLabel(SIDEBAR_SWITCH).props.onChange(false);
479
+ tree = await render(section.component, { t: tPanel });
480
+ const sidebarView = JSON.parse(globalThis.localStorage.getItem("dsh.workspace.view.v5"));
481
+ check(
482
+ "turning it off writes only archivedFilter into the official key",
483
+ sidebarView.archivedFilter === "default" && sidebarView.groupBy === "flat" && sidebarView.orderBy === "updated",
484
+ JSON.stringify(sidebarView)
485
+ );
486
+ check(
487
+ "the plugin's own settings key is not polluted by it",
488
+ JSON.parse(globalThis.localStorage.getItem("dsh-session-curator.settings")).archivedFilter === undefined,
489
+ globalThis.localStorage.getItem("dsh-session-curator.settings")
490
+ );
491
+ check(
492
+ "a refresh hint appears, because the official store only reads that key when it is created",
493
+ JSON.stringify(tree).includes("已写入官方设置")
494
+ && collect(tree, []).some((node) => node.type === "Button" && node.children?.[0] === "刷新页面"),
495
+ JSON.stringify(collect(tree, []).filter((node) => node.type === "Button").map((node) => node.children?.[0]))
496
+ );
497
+
498
+ // 设置真的生效:关掉"行内显示体积与文件数",回已归档页签看行。
499
+ switchByLabel("行内显示体积与文件数").props.onChange(false);
500
+ tree = await render(section.component, { t: tPanel });
501
+ check(
502
+ "a switch writes the setting through to localStorage",
503
+ JSON.parse(globalThis.localStorage.getItem("dsh-session-curator.settings")).showMeta === false,
504
+ globalThis.localStorage.getItem("dsh-session-curator.settings")
505
+ );
506
+ buttonByLabel("已归档").props.onClick();
507
+ tree = await render(section.component, { t: tPanel });
508
+ const slimMeta = rowsSeen().map((node) => metaOf(draw(node)));
509
+ check(
510
+ "showMeta = false drops size and file count from every row",
511
+ slimMeta.length === 3 && slimMeta.every((meta) => !meta.includes(" B") && !meta.includes("个文件")),
512
+ JSON.stringify(slimMeta)
513
+ );
514
+
515
+ // 排序:按体积降序(组内),组间照旧按文件夹名。
516
+ buttonByLabel("配置").props.onClick();
517
+ tree = await render(section.component, { t: tPanel });
518
+ segmentedNodes()[1].props.onChange("bytes");
519
+ tree = await render(section.component, { t: tPanel });
520
+ buttonByLabel("已归档").props.onClick();
521
+ tree = await render(section.component, { t: tPanel });
522
+ check(
523
+ "order = bytes sorts rows inside each group",
524
+ rowsSeen().map((node) => node.props.row.sessionId).join(",") === "s-3,s-2,s-1",
525
+ rowsSeen().map((node) => node.props.row.sessionId).join(",")
526
+ );
527
+
528
+ // 检查失败:显示原因,不谎报"已是最新"。
529
+ buttonByLabel("配置").props.onClick();
530
+ tree = await render(section.component, { t: tPanel });
531
+ updateResponse = null;
532
+ const recheck = collect(tree, []).find((node) => node.type === "Button" && node.children?.[0] === "检查更新");
533
+ recheck.props.onClick();
534
+ tree = await render(section.component, { t: tPanel });
535
+ check(
536
+ "a failed check shows the reason instead of claiming to be current",
537
+ JSON.stringify(tree).includes("检查更新失败") && !JSON.stringify(tree).includes("已是最新版本")
538
+ && collect(tree, []).some((node) => node.props.style?.color === "var(--dsw-alias-state-error-primary)"),
539
+ String(spanWith("检查更新失败")?.children?.[0])
540
+ );
541
+ updateResponse = { current: "0.1.0", latest: "0.1.1", outdated: true, checkedAt: 0 };
542
+
392
543
  // ── 设置页 id:导航栏图标是「按 id 查表」的(外壳的 navIcon()) ──────────
393
544
  check('with an empty ledger the section borrows the "archived-sessions" id (archive glyph in the nav)', sectionIdWith(() => []) === "archived-sessions");
394
545