dsh-session-curator 0.1.0 → 0.1.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.
@@ -0,0 +1,96 @@
1
+ /**
2
+ * 构建:`src/`(TypeScript,唯一真源)→ `lib/`(提交进仓库的产物,DSH 直接加载的就是它)。
3
+ *
4
+ * 两个半区各走一条路,因为运行环境不同:
5
+ * 1. 宿主半区:`tsc` 直接输出**多文件 ESM**(`lib/index.js` + `lib/host/*.js`)——
6
+ * Electron 主进程就是 Node,相对 import 天然可用。
7
+ * 2. 客户端半区:`esbuild` 先把 `src/client` 打成一份 CJS,再由本脚本包进 DSH 的手写束
8
+ * `window.__ModuleLoader__.load({id, factory})`。浏览器那边拿到的是 classic script
9
+ * (loader 走 `document.createElement("script")` + `el.src`),**不能**是 ESM,
10
+ * 所以客户端多文件必须在构建期合回一份。
11
+ *
12
+ * `react` 与 `@deepseek-ai/*` 保持 external:它们由 DSH 的模块表在运行时注入,
13
+ * 也就是束里 `factory(require)` 收到的那个 `require`。
14
+ *
15
+ * 用法:`npm run build`(或 `node scripts/build.mjs`)。
16
+ */
17
+
18
+ /** 构建期依赖只在开发 / 发布时存在(发布包不发 devDependencies),先给一句人话再退出。 */
19
+ const MISSING = "build 需要 devDependencies(esbuild + typescript):先在本仓库里跑 npm install。";
20
+
21
+ let esbuild;
22
+ try {
23
+ esbuild = await import("esbuild");
24
+ } catch {
25
+ console.error(MISSING);
26
+ process.exit(1);
27
+ }
28
+
29
+ const { spawnSync } = await import("node:child_process");
30
+ const { readFile, rm, writeFile } = await import("node:fs/promises");
31
+ const { createRequire } = await import("node:module");
32
+ const { join } = await import("node:path");
33
+
34
+ const root = join(import.meta.dirname, "..");
35
+ const lib = join(root, "lib");
36
+ const manifest = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
37
+
38
+ /** 客户端束里必须在线的外部模块:DSH 的模块表注入它们,别打进产物。 */
39
+ const CLIENT_EXTERNAL = ["react", "@deepseek-ai/*"];
40
+
41
+ // 只清自己产出的东西(`lib/` 是提交进仓库的产物,别顺手扫掉别的)。
42
+ await rm(join(lib, "host"), { recursive: true, force: true });
43
+ await rm(join(lib, "index.js"), { force: true });
44
+ await rm(join(lib, "client.js"), { force: true });
45
+
46
+ // ── 1) 宿主半区:tsc 输出多文件 ESM ──────────────────────────────────────
47
+ let tsc;
48
+ try {
49
+ tsc = createRequire(import.meta.url).resolve("typescript/bin/tsc");
50
+ } catch {
51
+ console.error(MISSING);
52
+ process.exit(1);
53
+ }
54
+ const emitted = spawnSync(process.execPath, [tsc, "-p", join(root, "tsconfig.build.json")], {
55
+ stdio: "inherit",
56
+ cwd: root
57
+ });
58
+ if (emitted.status !== 0) process.exit(emitted.status ?? 1);
59
+
60
+ // ── 2) 客户端半区:esbuild → CJS → DSH 手写束 ────────────────────────────
61
+ const client = await esbuild.build({
62
+ entryPoints: [join(root, "src", "client", "index.ts")],
63
+ bundle: true,
64
+ format: "cjs",
65
+ platform: "browser",
66
+ target: ["es2020"],
67
+ external: CLIENT_EXTERNAL,
68
+ write: false,
69
+ legalComments: "none",
70
+ charset: "utf8",
71
+ logLevel: "warning"
72
+ });
73
+ const body = client.outputFiles[0].text.trimEnd();
74
+
75
+ await writeFile(
76
+ join(lib, "client.js"),
77
+ [
78
+ "// 本文件由 `node scripts/build.mjs` 从 src/client/**/*.ts 生成,不要手改。",
79
+ "window.__ModuleLoader__.load({",
80
+ `\tid: ${JSON.stringify(manifest.name)},`,
81
+ "\t// 浏览器只认这种「工厂 + 局部 require」的束:factory 收到的 require 就是 DSH 的模块表,",
82
+ "\t// esbuild 把 external 的 import 编译成了对这个参数的调用。",
83
+ "\tfactory: (require) => {",
84
+ "\t\tconst module = { exports: {} };",
85
+ "\t\t(function (module, exports, require) {",
86
+ body,
87
+ "\t\t})(module, module.exports, require);",
88
+ "\t\treturn module.exports;",
89
+ "\t}",
90
+ "});",
91
+ ""
92
+ ].join("\n"),
93
+ "utf8"
94
+ );
95
+
96
+ console.log(`built: lib/index.js + lib/host/*.js (tsc) and lib/client.js (esbuild ${body.length} B)`);
@@ -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",
@@ -134,6 +147,25 @@ const client = spec.factory(requireStub);
134
147
  const { groupRows, groupKeyOf, SessionRow, GroupHeader } = client;
135
148
  check("groupRows / groupKeyOf are exported as pure functions", typeof groupRows === "function" && typeof groupKeyOf === "function");
136
149
 
150
+ // 折叠决策(`groups.ts`):默认状态 + 手动覆盖两张表合成一个答案。
151
+ check(
152
+ "isGroupCollapsed lets untouched folders follow the configured default",
153
+ client.isGroupCollapsed("archived:k", {}, "collapsed") === true && client.isGroupCollapsed("archived:k", {}, "expanded") === false
154
+ );
155
+ check(
156
+ "isGroupCollapsed prefers the manual override",
157
+ client.isGroupCollapsed("archived:k", { "archived:k": false }, "collapsed") === false && client.isGroupCollapsed("archived:k", { "archived:k": true }, "expanded") === true
158
+ );
159
+ check(
160
+ "toggledCollapse records an override, and drops it again once it matches the default",
161
+ JSON.stringify(client.toggledCollapse("k", {}, "expanded")) === '{"k":true}'
162
+ && JSON.stringify(client.toggledCollapse("k", { k: true }, "expanded")) === "{}"
163
+ && JSON.stringify(client.toggledCollapse("k", {}, "collapsed")) === '{"k":false}'
164
+ && JSON.stringify(client.toggledCollapse("k", { k: false }, "collapsed")) === "{}",
165
+ `${JSON.stringify(client.toggledCollapse("k", {}, "expanded"))} / ${JSON.stringify(client.toggledCollapse("k", { k: true }, "expanded"))} / ${JSON.stringify(client.toggledCollapse("k", {}, "collapsed"))}`
166
+ );
167
+ check("collapseKey keeps the state per tab", client.collapseKey("trash", "unknown") === "trash:unknown", client.collapseKey("trash", "unknown"));
168
+
137
169
  const t = (key) => ({
138
170
  noProject: "(unknown)",
139
171
  sessions: "{n} sessions",
@@ -216,6 +248,19 @@ check("the folder icon is dimmer than the title and inherits currentColor", togg
216
248
  const headerLabels = toggleChildren.filter((child) => typeof child?.children?.[0] === "string").map((child) => child.children[0]);
217
249
  check("group header shows title, count and size", ["Alpha", "2 sessions", "30 B"].every((label) => headerLabels.includes(label)), JSON.stringify(headerLabels));
218
250
  check("a fully selected group offers the short clear action on the right", selectButton.type === "Button" && selectButton.children[0] === "Clear" && selectButton.props.title.includes("Alpha"));
251
+ // 长路径回归:右侧动作按钮不参与 flex 收缩、也不许折行;收缩全部由左侧折叠按钮消化
252
+ //(标题 / 路径各有 minWidth: 0 + 省略号)。DSH 的 Button 没有 white-space: nowrap,
253
+ // 一旦被挤窄,中文标签就会在按钮里折成两行。
254
+ check(
255
+ "the right-hand action button neither shrinks nor wraps its label",
256
+ selectButton.props.style?.flex === "0 0 auto" && selectButton.props.style?.whiteSpace === "nowrap",
257
+ JSON.stringify(selectButton.props.style)
258
+ );
259
+ check(
260
+ "the folder toggle is the flex item that absorbs the shrink (so title/path ellipsize instead)",
261
+ toggleButton.props.style?.flex === "1 1 auto" && toggleButton.props.style?.minWidth === 0,
262
+ JSON.stringify({ flex: toggleButton.props.style?.flex, minWidth: toggleButton.props.style?.minWidth })
263
+ );
219
264
  toggleButton.props.onClick();
220
265
  selectButton.props.onClick();
221
266
  check("header click / right-hand button call the handlers", collapses === 1 && groupToggles === 1, `collapse=${collapses} select=${groupToggles}`);
@@ -239,10 +284,12 @@ const rowMenuElement = rowMenuOf(withoutCwd);
239
284
  check("the menu is anchored to an icon-only ghost Button carrying the ellipsis icon", rowMenuElement.props.anchor.type === "Button" && rowMenuElement.props.anchor.props.variant === "ghost" && rowMenuElement.props.anchor.props.icon.type === "IconEllipsisOutlineRegular" && rowMenuElement.props.anchor.props.children === undefined, `icon=${rowMenuElement.props.anchor.props.icon?.type}`);
240
285
  check("the menu follows the panel's open state and portals itself", rowMenuElement.props.open === true && rowMenuOf(withCwd).props.open === false && rowMenuElement.props.portal === true);
241
286
  check("the ⋯ trigger is dimmed like the sidebar's row actions", rowMenuElement.props.anchor.props.style.color === "var(--dsw-alias-label-secondary)" && String(rowMenuElement.props.anchor.props["aria-label"]).includes("More actions"));
287
+ check("the ⋯ trigger keeps its own width when the title is long", rowMenuElement.props.anchor.props.style.flex === "0 0 auto", JSON.stringify(rowMenuElement.props.anchor.props.style));
242
288
 
243
289
  // ── B. 整块面板:假 ctx 调 apply(),拿到真正注册的面板组件后渲染 ─────────
244
290
  const SNAPSHOT = {
245
291
  dshHome: "C:\\Users\\Motues\\.dsh",
292
+ version: "0.1.0",
246
293
  trashDir: "C:\\Users\\Motues\\.dsh\\storages\\session-manager\\trash",
247
294
  sessionsDir: "C:\\Users\\Motues\\.dsh\\sessions",
248
295
  archived: [
@@ -258,12 +305,18 @@ const SNAPSHOT = {
258
305
  const slots = [];
259
306
  let dictionaries = null;
260
307
  const hostCalls = [];
308
+ /** 更新检查的假回答;置 null 表示这次让宿主失败(测错误分支)。 */
309
+ let updateResponse = { current: "0.1.0", latest: "0.1.1", outdated: true, checkedAt: 0 };
261
310
  const fakeCtx = {
262
311
  get: (name) => (name === "connection"
263
312
  ? {
264
313
  rpc: {
265
314
  call: async (channel, endpoint, payload) => {
266
315
  hostCalls.push({ channel, endpoint, payload });
316
+ if (endpoint.endsWith("check-update")) {
317
+ if (updateResponse === null) return { ok: false, error: { code: "session-curator/error", message: "registry 不通" } };
318
+ return { ok: true, value: updateResponse };
319
+ }
267
320
  return { ok: true, value: SNAPSHOT };
268
321
  }
269
322
  }
@@ -297,6 +350,9 @@ const tPanel = (key, params) => {
297
350
  return value;
298
351
  };
299
352
 
353
+ /** 官方侧边栏的视图存储:预置成"显示已归档",用来验证配置页那个开关读的是同一份设置。 */
354
+ globalThis.localStorage.setItem("dsh.workspace.view.v5", JSON.stringify({ groupBy: "flat", orderBy: "updated", archivedFilter: "show" }));
355
+
300
356
  let tree = await render(section.component, { t: tPanel });
301
357
  const nodesOf = (current, type) => collect(current, []).filter((node) => node.type === type);
302
358
  const headers = () => nodesOf(tree, client.GroupHeader);
@@ -317,6 +373,14 @@ check("every group header renders the chevron, the folder icon and the right-han
317
373
  return toggle[0].children[0].type === "IconChevronDownOutlineRegular" && toggle[1].children[0].type === "IconFolderOpenRegular" && parts[1].type === "Button" && toggle.every((child) => child?.type !== "input");
318
374
  }), "");
319
375
  check("every folder row paints the faint band", headers().every((node) => draw(node).props.style.background === "var(--dsw-alias-interactive-bg-active)"), "");
376
+ // 长路径回归(面板里也验一遍):右侧按钮定宽不折行,路径那段自己带省略号。
377
+ check("in the rendered panel every select button stays rigid and every path can ellipsize", headers().every((node) => {
378
+ const parts = partsOf(node);
379
+ // 按**文本**找路径那段:标题 span 的 tooltip 也是 cwd,别按 title 找(会先命中标题)。
380
+ const path = parts[0].children.find((child) => typeof child?.children?.[0] === "string" && child.children[0].includes("\\"));
381
+ return parts[1].props.style.flex === "0 0 auto" && parts[1].props.style.whiteSpace === "nowrap"
382
+ && path !== undefined && path.props.style.minWidth === 0 && path.props.style.textOverflow === "ellipsis";
383
+ }), JSON.stringify(headers().map((node) => partsOf(node)[1].props.style)));
320
384
 
321
385
  // 折叠 Momo 组:点它的标题按钮 → 该组的行必须消失,但组标题还在。
322
386
  partsOf(headers()[1])[0].props.onClick();
@@ -389,6 +453,215 @@ check("trash tab groups by the recorded workspace folder too", headers().length
389
453
  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
454
  check("trash rows keep the same single-⋯ shape", draw(rowsSeen()[0]).children[2].children.length === 1);
391
455
 
456
+ // ── 配置页:动作在右上、基础设置、版本号与升级提示 ───────────────────────
457
+ // 面板根节点的第 1 个子节点就是顶部条:左边页签组,右边动作组。
458
+ const headerOf = () => tree.children[0];
459
+ const tabBarOf = () => headerOf().children[0];
460
+ const actionsOf = () => headerOf().children[1];
461
+ /** 页签那一组是 `tabs.map(...)` 的结果,迷你渲染器把它当**一个**子节点存着,所以要递归收集。 */
462
+ const tabButtons = () => collect(tabBarOf(), []).filter((node) => node.type === "Button");
463
+ const tabLabels = () => tabButtons().map((node) => node.children[0]).join(" | ");
464
+ const actionButtons = () => (actionsOf() === null ? [] : actionsOf().children.filter(Boolean));
465
+ const switchNodes = () => nodesOf(tree, "Switch");
466
+ /** 按 label(面板传的就是本地化过的名字)找控件,比按下标稳。 */
467
+ const switchByLabel = (label) => switchNodes().find((node) => node.props.label === label);
468
+ const segmentedNodes = () => nodesOf(tree, "SegmentedControl");
469
+ /** 按 label 找分段控件(面板传的就是本地化过的名字),比按下标稳。 */
470
+ const segmentedByLabel = (label) => segmentedNodes().find((node) => node.props.label === label);
471
+ const spanWith = (prefix) => collect(tree, []).find((node) => node.type === "span" && typeof node.children?.[0] === "string" && node.children[0].startsWith(prefix));
472
+
473
+ check(
474
+ "the three data tabs stay on the top-left, with 配置 appended",
475
+ tabButtons().length === 4 && tabLabels() === "已归档 (3) | 已置顶 (1) | 回收站 (1) | 配置",
476
+ tabLabels()
477
+ );
478
+ check(
479
+ "分组 and 刷新 moved to the top-right actions group",
480
+ actionsOf() !== null && actionsOf().props.style.marginInlineStart === "auto" && actionButtons().length === 2
481
+ && actionButtons()[0].children[0].startsWith("分组") && actionButtons()[1].children[0] === "刷新",
482
+ `marginInlineStart=${actionsOf()?.props.style.marginInlineStart} buttons=${actionButtons().map((node) => node.children[0]).join(", ")}`
483
+ );
484
+ 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(", "));
485
+ 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(", "));
486
+
487
+ // 打开配置页。
488
+ buttonByLabel("配置").props.onClick();
489
+ tree = await render(section.component, { t: tPanel });
490
+ check("the config tab drops the list-only actions from the right", headerOf().children[1] === null && tabLabels().endsWith("配置"), tabLabels());
491
+ check("the config page renders 4 switches and 3 segmented controls", switchNodes().length === 4 && segmentedNodes().length === 3, `${switchNodes().length} switch(es), ${segmentedNodes().length} segmented`);
492
+ check(
493
+ "the default-tab control offers exactly the three data tabs (never 配置 itself)",
494
+ segmentedNodes()[0].props.options.map((option) => option.value).join(",") === "archived,pinned,trash",
495
+ segmentedNodes()[0].props.options.map((option) => option.label).join(", ")
496
+ );
497
+ check(
498
+ "the folder default-state control offers 展开 / 折叠 and carries the saved value",
499
+ segmentedByLabel("分组默认状态") !== undefined
500
+ && segmentedByLabel("分组默认状态").props.options.map((option) => option.value).join(",") === "expanded,collapsed"
501
+ && segmentedByLabel("分组默认状态").props.value === "expanded",
502
+ String(segmentedByLabel("分组默认状态")?.props.value)
503
+ );
504
+ check(
505
+ "the about block shows the version in small light text",
506
+ spanWith("版本") !== undefined && spanWith("版本").children[0] === "版本 v0.1.0"
507
+ && spanWith("版本").props.style.fontSize === "11px" && spanWith("版本").props.style.color === "var(--dsw-alias-label-tertiary)",
508
+ spanWith("版本")?.children?.[0]
509
+ );
510
+ check(
511
+ "an outdated version renders the notice plus the exact upgrade command",
512
+ JSON.stringify(tree).includes("有新版本 0.1.1(当前 0.1.0)")
513
+ && JSON.stringify(tree).includes("升级步骤")
514
+ && JSON.stringify(tree).includes("dsh plugin --profile desktop add dsh-session-curator@latest"),
515
+ String(spanWith("有新版本")?.children?.[0])
516
+ );
517
+ 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(", "));
518
+
519
+ // ── 侧边栏"显示已归档":读写的必须是官方那份设置(dsh.workspace.view.v5) ──
520
+ const SIDEBAR_SWITCH = "侧边栏显示已归档会话";
521
+ check(
522
+ "the sidebar-archived switch mirrors the official setting (seeded to show archived)",
523
+ switchByLabel(SIDEBAR_SWITCH) !== undefined && switchByLabel(SIDEBAR_SWITCH).props.checked === true,
524
+ String(switchByLabel(SIDEBAR_SWITCH)?.props.checked)
525
+ );
526
+ check("no refresh hint before the sidebar value is touched", !JSON.stringify(tree).includes("已写入官方设置"));
527
+
528
+ switchByLabel(SIDEBAR_SWITCH).props.onChange(false);
529
+ tree = await render(section.component, { t: tPanel });
530
+ const sidebarView = JSON.parse(globalThis.localStorage.getItem("dsh.workspace.view.v5"));
531
+ check(
532
+ "turning it off writes only archivedFilter into the official key",
533
+ sidebarView.archivedFilter === "default" && sidebarView.groupBy === "flat" && sidebarView.orderBy === "updated",
534
+ JSON.stringify(sidebarView)
535
+ );
536
+ check(
537
+ "the plugin's own settings key is not polluted by it",
538
+ JSON.parse(globalThis.localStorage.getItem("dsh-session-curator.settings")).archivedFilter === undefined,
539
+ globalThis.localStorage.getItem("dsh-session-curator.settings")
540
+ );
541
+ check(
542
+ "a refresh hint appears, because the official store only reads that key when it is created",
543
+ JSON.stringify(tree).includes("已写入官方设置")
544
+ && collect(tree, []).some((node) => node.type === "Button" && node.children?.[0] === "刷新页面"),
545
+ JSON.stringify(collect(tree, []).filter((node) => node.type === "Button").map((node) => node.children?.[0]))
546
+ );
547
+
548
+ // 设置真的生效:关掉"行内显示体积与文件数",回已归档页签看行。
549
+ switchByLabel("行内显示体积与文件数").props.onChange(false);
550
+ tree = await render(section.component, { t: tPanel });
551
+ check(
552
+ "a switch writes the setting through to localStorage",
553
+ JSON.parse(globalThis.localStorage.getItem("dsh-session-curator.settings")).showMeta === false,
554
+ globalThis.localStorage.getItem("dsh-session-curator.settings")
555
+ );
556
+ buttonByLabel("已归档").props.onClick();
557
+ tree = await render(section.component, { t: tPanel });
558
+ const slimMeta = rowsSeen().map((node) => metaOf(draw(node)));
559
+ check(
560
+ "showMeta = false drops size and file count from every row",
561
+ slimMeta.length === 3 && slimMeta.every((meta) => !meta.includes(" B") && !meta.includes("个文件")),
562
+ JSON.stringify(slimMeta)
563
+ );
564
+
565
+ // 排序:按体积降序(组内),组间照旧按文件夹名。
566
+ buttonByLabel("配置").props.onClick();
567
+ tree = await render(section.component, { t: tPanel });
568
+ segmentedByLabel("排序").props.onChange("bytes");
569
+ tree = await render(section.component, { t: tPanel });
570
+ buttonByLabel("已归档").props.onClick();
571
+ tree = await render(section.component, { t: tPanel });
572
+ check(
573
+ "order = bytes sorts rows inside each group",
574
+ rowsSeen().map((node) => node.props.row.sessionId).join(",") === "s-3,s-2,s-1",
575
+ rowsSeen().map((node) => node.props.row.sessionId).join(",")
576
+ );
577
+
578
+ // ── 分组默认折叠 / 展开(配置项 groupDefault) ───────────────────────────
579
+ // 语义:没手动点过的文件夹跟随配置里的默认状态(改配置立即生效);手动点过的保留手动
580
+ // 状态;手动翻**回**默认值之后重新跟随配置(覆盖被丢掉,见 groups.ts 的 toggledCollapse)。
581
+ buttonByLabel("配置").props.onClick();
582
+ tree = await render(section.component, { t: tPanel });
583
+ segmentedByLabel("分组默认状态").props.onChange("collapsed");
584
+ tree = await render(section.component, { t: tPanel });
585
+ check(
586
+ "turning the folder default to 折叠 is persisted",
587
+ JSON.parse(globalThis.localStorage.getItem("dsh-session-curator.settings")).groupDefault === "collapsed",
588
+ globalThis.localStorage.getItem("dsh-session-curator.settings")
589
+ );
590
+ buttonByLabel("已归档").props.onClick();
591
+ tree = await render(section.component, { t: tPanel });
592
+ check(
593
+ "every folder follows the default and starts collapsed",
594
+ headers().length === 2 && headers().every((node) => node.props.collapsed === true) && rowsSeen().length === 0,
595
+ `collapsed=${headers().map((node) => node.props.collapsed).join(",")} rows=${rowsSeen().length}`
596
+ );
597
+ // 手动展开 BlogPage(headers()[0]):只有它变,另一组照旧跟随默认。
598
+ partsOf(headers()[0])[0].props.onClick();
599
+ tree = await render(section.component, { t: tPanel });
600
+ check(
601
+ "expanding one folder by hand leaves the others collapsed",
602
+ headers()[0].props.collapsed === false && headers()[1].props.collapsed === true
603
+ && rowsSeen().map((node) => node.props.row.sessionId).join(",") === "s-3",
604
+ `collapsed=${headers().map((node) => node.props.collapsed).join(",")} rows=${rowsSeen().map((node) => node.props.row.sessionId).join(",")}`
605
+ );
606
+ // 改回默认"展开":没手动点过的 Momo 立刻跟着展开,手动展开的 BlogPage 不变。
607
+ buttonByLabel("配置").props.onClick();
608
+ tree = await render(section.component, { t: tPanel });
609
+ check("the control carries the saved 折叠 value", segmentedByLabel("分组默认状态").props.value === "collapsed", String(segmentedByLabel("分组默认状态").props.value));
610
+ segmentedByLabel("分组默认状态").props.onChange("expanded");
611
+ tree = await render(section.component, { t: tPanel });
612
+ buttonByLabel("已归档").props.onClick();
613
+ tree = await render(section.component, { t: tPanel });
614
+ check(
615
+ "changing the default only moves the folders nobody touched by hand",
616
+ headers().length === 2 && headers().every((node) => node.props.collapsed === false) && rowsSeen().length === 3,
617
+ `collapsed=${headers().map((node) => node.props.collapsed).join(",")} rows=${rowsSeen().length}`
618
+ );
619
+ // 手动折叠再展开(翻回默认值)→ 这条分组重新跟随配置。
620
+ partsOf(headers()[1])[0].props.onClick();
621
+ tree = await render(section.component, { t: tPanel });
622
+ check("collapsing one folder by hand hides exactly its rows", rowsSeen().length === 1, `${rowsSeen().length} row(s)`);
623
+ partsOf(headers()[1])[0].props.onClick();
624
+ tree = await render(section.component, { t: tPanel });
625
+ buttonByLabel("配置").props.onClick();
626
+ tree = await render(section.component, { t: tPanel });
627
+ segmentedByLabel("分组默认状态").props.onChange("collapsed");
628
+ tree = await render(section.component, { t: tPanel });
629
+ buttonByLabel("已归档").props.onClick();
630
+ tree = await render(section.component, { t: tPanel });
631
+ check(
632
+ "a folder toggled back to the default follows the setting again",
633
+ headers()[1].props.collapsed === true,
634
+ `collapsed=${headers().map((node) => node.props.collapsed).join(",")}`
635
+ );
636
+ check(
637
+ "a folder still expanded by hand keeps its manual state across a default change",
638
+ headers()[0].props.collapsed === false && rowsSeen().map((node) => node.props.row.sessionId).join(",") === "s-3",
639
+ `collapsed=${headers().map((node) => node.props.collapsed).join(",")} rows=${rowsSeen().map((node) => node.props.row.sessionId).join(",")}`
640
+ );
641
+ // 还原成默认展开:后面几条断言依赖行都在。
642
+ buttonByLabel("配置").props.onClick();
643
+ tree = await render(section.component, { t: tPanel });
644
+ segmentedByLabel("分组默认状态").props.onChange("expanded");
645
+ tree = await render(section.component, { t: tPanel });
646
+ buttonByLabel("已归档").props.onClick();
647
+ tree = await render(section.component, { t: tPanel });
648
+ check("the list is back to expanded for the following checks", rowsSeen().length === 3, `${rowsSeen().length} row(s)`);
649
+
650
+ // 检查失败:显示原因,不谎报"已是最新"。
651
+ buttonByLabel("配置").props.onClick();
652
+ tree = await render(section.component, { t: tPanel });
653
+ updateResponse = null;
654
+ const recheck = collect(tree, []).find((node) => node.type === "Button" && node.children?.[0] === "检查更新");
655
+ recheck.props.onClick();
656
+ tree = await render(section.component, { t: tPanel });
657
+ check(
658
+ "a failed check shows the reason instead of claiming to be current",
659
+ JSON.stringify(tree).includes("检查更新失败") && !JSON.stringify(tree).includes("已是最新版本")
660
+ && collect(tree, []).some((node) => node.props.style?.color === "var(--dsw-alias-state-error-primary)"),
661
+ String(spanWith("检查更新失败")?.children?.[0])
662
+ );
663
+ updateResponse = { current: "0.1.0", latest: "0.1.1", outdated: true, checkedAt: 0 };
664
+
392
665
  // ── 设置页 id:导航栏图标是「按 id 查表」的(外壳的 navIcon()) ──────────
393
666
  check('with an empty ledger the section borrows the "archived-sessions" id (archive glyph in the nav)', sectionIdWith(() => []) === "archived-sessions");
394
667