dsh-session-curator 0.1.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-curator",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
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",
@@ -101,8 +101,10 @@
101
101
  }
102
102
  },
103
103
  "scripts": {
104
- "test": "node scripts/client.mjs && node scripts/smoke.mjs && node scripts/activation.mjs",
105
- "check": "node --check lib/index.js && node --check lib/client.js && node --check scripts/client.mjs",
104
+ "build": "node scripts/build.mjs",
105
+ "typecheck": "tsc -p tsconfig.host.json && tsc -p tsconfig.client.json",
106
+ "test": "npm run build && node scripts/client.mjs && node scripts/smoke.mjs && node scripts/activation.mjs",
107
+ "check": "npm run typecheck && node --check lib/index.js && node --check lib/client.js && node --check scripts/client.mjs",
106
108
  "prepublishOnly": "npm test"
107
109
  },
108
110
  "files": [
@@ -114,5 +116,10 @@
114
116
  "LICENSE",
115
117
  "README.md",
116
118
  "README.zh-CN.md"
117
- ]
119
+ ],
120
+ "devDependencies": {
121
+ "@types/node": "^26.6.5",
122
+ "esbuild": "^0.28.2",
123
+ "typescript": "^5.9.3"
124
+ }
118
125
  }
@@ -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)`);
@@ -147,6 +147,25 @@ const client = spec.factory(requireStub);
147
147
  const { groupRows, groupKeyOf, SessionRow, GroupHeader } = client;
148
148
  check("groupRows / groupKeyOf are exported as pure functions", typeof groupRows === "function" && typeof groupKeyOf === "function");
149
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
+
150
169
  const t = (key) => ({
151
170
  noProject: "(unknown)",
152
171
  sessions: "{n} sessions",
@@ -229,6 +248,19 @@ check("the folder icon is dimmer than the title and inherits currentColor", togg
229
248
  const headerLabels = toggleChildren.filter((child) => typeof child?.children?.[0] === "string").map((child) => child.children[0]);
230
249
  check("group header shows title, count and size", ["Alpha", "2 sessions", "30 B"].every((label) => headerLabels.includes(label)), JSON.stringify(headerLabels));
231
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
+ );
232
264
  toggleButton.props.onClick();
233
265
  selectButton.props.onClick();
234
266
  check("header click / right-hand button call the handlers", collapses === 1 && groupToggles === 1, `collapse=${collapses} select=${groupToggles}`);
@@ -252,6 +284,7 @@ const rowMenuElement = rowMenuOf(withoutCwd);
252
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}`);
253
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);
254
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));
255
288
 
256
289
  // ── B. 整块面板:假 ctx 调 apply(),拿到真正注册的面板组件后渲染 ─────────
257
290
  const SNAPSHOT = {
@@ -340,6 +373,14 @@ check("every group header renders the chevron, the folder icon and the right-han
340
373
  return toggle[0].children[0].type === "IconChevronDownOutlineRegular" && toggle[1].children[0].type === "IconFolderOpenRegular" && parts[1].type === "Button" && toggle.every((child) => child?.type !== "input");
341
374
  }), "");
342
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)));
343
384
 
344
385
  // 折叠 Momo 组:点它的标题按钮 → 该组的行必须消失,但组标题还在。
345
386
  partsOf(headers()[1])[0].props.onClick();
@@ -425,6 +466,8 @@ const switchNodes = () => nodesOf(tree, "Switch");
425
466
  /** 按 label(面板传的就是本地化过的名字)找控件,比按下标稳。 */
426
467
  const switchByLabel = (label) => switchNodes().find((node) => node.props.label === label);
427
468
  const segmentedNodes = () => nodesOf(tree, "SegmentedControl");
469
+ /** 按 label 找分段控件(面板传的就是本地化过的名字),比按下标稳。 */
470
+ const segmentedByLabel = (label) => segmentedNodes().find((node) => node.props.label === label);
428
471
  const spanWith = (prefix) => collect(tree, []).find((node) => node.type === "span" && typeof node.children?.[0] === "string" && node.children[0].startsWith(prefix));
429
472
 
430
473
  check(
@@ -445,12 +488,19 @@ check("the tab buttons stay in the left group (not among the actions)", tabButto
445
488
  buttonByLabel("配置").props.onClick();
446
489
  tree = await render(section.component, { t: tPanel });
447
490
  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`);
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`);
449
492
  check(
450
493
  "the default-tab control offers exactly the three data tabs (never 配置 itself)",
451
494
  segmentedNodes()[0].props.options.map((option) => option.value).join(",") === "archived,pinned,trash",
452
495
  segmentedNodes()[0].props.options.map((option) => option.label).join(", ")
453
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
+ );
454
504
  check(
455
505
  "the about block shows the version in small light text",
456
506
  spanWith("版本") !== undefined && spanWith("版本").children[0] === "版本 v0.1.0"
@@ -515,7 +565,7 @@ check(
515
565
  // 排序:按体积降序(组内),组间照旧按文件夹名。
516
566
  buttonByLabel("配置").props.onClick();
517
567
  tree = await render(section.component, { t: tPanel });
518
- segmentedNodes()[1].props.onChange("bytes");
568
+ segmentedByLabel("排序").props.onChange("bytes");
519
569
  tree = await render(section.component, { t: tPanel });
520
570
  buttonByLabel("已归档").props.onClick();
521
571
  tree = await render(section.component, { t: tPanel });
@@ -525,6 +575,78 @@ check(
525
575
  rowsSeen().map((node) => node.props.row.sessionId).join(",")
526
576
  );
527
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
+
528
650
  // 检查失败:显示原因,不谎报"已是最新"。
529
651
  buttonByLabel("配置").props.onClick();
530
652
  tree = await render(section.component, { t: tPanel });