dsh-session-curator 0.1.1 → 0.2.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.
@@ -1,569 +0,0 @@
1
- /**
2
- * 客户端半区自检 —— 不需要浏览器,也不需要 DSH。
3
- *
4
- * 两段:
5
- * A. 纯逻辑:给一个假的 `window.__ModuleLoader__` 截住束工厂,用最小 `react` 桩把工厂跑
6
- * 起来拿到导出,直接单测"按工作区文件夹分组"的 `groupRows` / `groupKeyOf`,
7
- * 再直接调两个纯 props 组件(`GroupHeader` / `SessionRow`,它们不用 hooks)。
8
- * B. 渲染:同一个工厂配一个**迷你 React**(useState / useEffect / useCallback + 依赖比较),
9
- * 用假 `ctx` 调 `apply(ctx)`,拿到真正注册到 `settings.section` 的面板组件,喂一份假快照,
10
- * 断言分组标题、行归属、折叠、整组勾选、关分组、切页签 —— 不渲染 DOM,只走普通对象树。
11
- *
12
- * 用法:`node scripts/client.mjs`
13
- */
14
- import { join } from "node:path";
15
- import { pathToFileURL } from "node:url";
16
-
17
- const BUNDLE = join(import.meta.dirname, "../lib/client.js");
18
-
19
- let spec;
20
- globalThis.window = {
21
- __ModuleLoader__: {
22
- load: (value) => {
23
- spec = value;
24
- }
25
- }
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
- };
38
-
39
- // ── 迷你 React:够跑一次挂载 + 若干次重渲染 ──────────────────────────────
40
- const runtime = { index: 0, values: [], callbacks: [], records: [], pending: [], dirty: false };
41
-
42
- const React = {
43
- // 记成普通对象树,断言直接走 type / props / children。
44
- createElement: (type, props, ...children) => ({ type, props: props || {}, children }),
45
- useState: (initial) => {
46
- const at = runtime.index++;
47
- if (!(at in runtime.values)) runtime.values[at] = typeof initial === "function" ? initial() : initial;
48
- const set = (next) => {
49
- runtime.values[at] = typeof next === "function" ? next(runtime.values[at]) : next;
50
- runtime.dirty = true;
51
- };
52
- return [runtime.values[at], set];
53
- },
54
- useEffect: (callback, deps) => {
55
- const at = runtime.index++;
56
- const previous = runtime.records[at];
57
- const changed = deps === undefined
58
- || previous === undefined
59
- || deps.length !== previous.deps.length
60
- || deps.some((value, position) => !Object.is(value, previous.deps[position]));
61
- runtime.records[at] = { deps, cleanup: previous?.cleanup };
62
- runtime.pending.push({ at, callback, run: changed });
63
- },
64
- useCallback: (callback) => {
65
- const at = runtime.index++;
66
- runtime.callbacks[at] ??= callback;
67
- return runtime.callbacks[at];
68
- }
69
- };
70
-
71
- /** 跑一次渲染 + 本轮该跑的 effect,直到不再有 setState 为止(上限兜底防死循环)。 */
72
- async function render(component, props) {
73
- let tree;
74
- for (let pass = 0; pass < 8; pass += 1) {
75
- runtime.index = 0;
76
- runtime.pending = [];
77
- runtime.dirty = false;
78
- tree = component(props);
79
- for (const entry of runtime.pending) {
80
- if (!entry.run) continue;
81
- const record = runtime.records[entry.at];
82
- if (typeof record.cleanup === "function") record.cleanup();
83
- const cleanup = entry.callback();
84
- record.cleanup = typeof cleanup === "function" ? cleanup : undefined;
85
- }
86
- await new Promise((resolve) => setTimeout(resolve, 0));
87
- if (!runtime.dirty) break;
88
- }
89
- return tree;
90
- }
91
-
92
- /** 迷你渲染器不递归组件:需要看组件内部时显式调一次(这两个组件都不用 hooks)。 */
93
- function draw(node) {
94
- return typeof node.type === "function" ? node.type(node.props) : node;
95
- }
96
-
97
- /** 深度收集节点,便于按 `type` 找组件。 */
98
- function collect(node, out = []) {
99
- if (node === null || node === undefined || typeof node !== "object") return out;
100
- if (Array.isArray(node)) {
101
- for (const item of node) collect(item, out);
102
- return out;
103
- }
104
- if (node.type !== undefined) out.push(node);
105
- for (const child of node.children ?? []) collect(child, out);
106
- return out;
107
- }
108
-
109
- await import(pathToFileURL(BUNDLE).href);
110
-
111
- /** 只桩工厂在**加载期**真正碰到的东西。名字与 DSH 的真实导出逐一核对过。 */
112
- const PRIMITIVE_NAMES = [
113
- "Button",
114
- "Menu",
115
- "MenuItemButton",
116
- "Modal",
117
- "SegmentedControl",
118
- "Switch",
119
- "IconChevronDownOutlineRegular",
120
- "IconChevronRightOutlineRegular",
121
- "IconEllipsisOutlineRegular",
122
- "IconFolderCloseRegular",
123
- "IconFolderOpenRegular",
124
- "IconPinOutlineRegular",
125
- "IconRefreshOutlineRegular",
126
- "IconTrashOutlineRegular",
127
- "IconUnarchiveOutlineRegular"
128
- ];
129
- function requireStub(name) {
130
- if (name === "react") return React;
131
- if (name === "@deepseek-ai/dsh-client-ui-primitives") {
132
- // 每个名字映射成它自己:元素 type 就是组件名,断言直接看名字。
133
- return Object.fromEntries(PRIMITIVE_NAMES.map((primitive) => [primitive, primitive]));
134
- }
135
- throw new Error(`unexpected require(${JSON.stringify(name)})`);
136
- }
137
-
138
- const checks = [];
139
- function check(label, condition, detail) {
140
- checks.push({ label, ok: Boolean(condition) });
141
- console.log(`${condition ? " ok " : " FAIL "} ${label}${detail === undefined ? "" : ` — ${detail}`}`);
142
- }
143
-
144
- check("bundle exposes its id", spec?.id === "dsh-session-curator", spec?.id);
145
-
146
- const client = spec.factory(requireStub);
147
- const { groupRows, groupKeyOf, SessionRow, GroupHeader } = client;
148
- check("groupRows / groupKeyOf are exported as pure functions", typeof groupRows === "function" && typeof groupKeyOf === "function");
149
-
150
- const t = (key) => ({
151
- noProject: "(unknown)",
152
- sessions: "{n} sessions",
153
- selectAllShort: "Select all",
154
- clearShort: "Clear",
155
- rowActions: "More actions"
156
- }[key] ?? key);
157
-
158
- /** 造一行:只保留分组会看的字段。 */
159
- const row = (sessionId, cwd, workspaceTitle, bytes, workspaceId) => ({ sessionId, cwd, workspaceTitle, bytes, workspaceId });
160
-
161
- // ── A. 分组纯逻辑 ────────────────────────────────────────────────────────
162
- // 1) 同一个文件夹的不同写法必须落到同一组:大小写、末尾分隔符、正反斜杠。
163
- const sameFolder = groupRows([
164
- row("a", "C:\\Data\\Desktop\\Github\\Momo", "Momo", 100),
165
- row("b", "c:/data/desktop/github/momo\\", "Momo", 250),
166
- row("c", "C:\\Data\\Desktop\\Github\\Momo\\", null, 0)
167
- ], t);
168
- check("cwd casing / separators / trailing slash collapse into one group", sameFolder.length === 1 && sameFolder[0].rows.length === 3, `${sameFolder.length} group(s)`);
169
- check("group bytes accumulate (non-numbers ignored)", sameFolder[0].bytes === 350, String(sameFolder[0].bytes));
170
-
171
- // 2) 组名:先 workspaceTitle,再文件夹名,最后 (unknown)。
172
- const titles = groupRows([
173
- row("a", "C:\\work\\Alpha", "Alpha 标题", 1),
174
- row("b", "C:\\work\\beta", null, 1),
175
- row("c", null, null, 1)
176
- ], t);
177
- check("title prefers workspaceTitle, then the folder basename", titles.some((group) => group.title === "Alpha 标题") && titles.some((group) => group.title === "beta"), titles.map((group) => group.title).join(", "));
178
- check('rows with neither cwd nor workspaceId land in the "unknown" group, sorted last', titles[titles.length - 1].title === "(unknown)" && titles[titles.length - 1].key === "unknown");
179
-
180
- // 3) 组间按标题排序(与宿主顺序无关),组内保持宿主顺序。
181
- const ordered = groupRows([
182
- row("z1", "C:\\w\\Zeta", "Zeta", 1),
183
- row("a1", "C:\\w\\alpha", "alpha", 1),
184
- row("z2", "C:\\w\\Zeta", "Zeta", 1)
185
- ], t);
186
- check("groups are sorted by title", ordered.map((group) => group.title).join(" < ") === "alpha < Zeta", ordered.map((group) => group.title).join(", "));
187
- check("rows keep the host order inside a group", ordered[1].rows.map((entry) => entry.sessionId).join(",") === "z1,z2", ordered[1].rows.map((entry) => entry.sessionId).join(","));
188
-
189
- // 4) 没有 cwd 时退到 workspaceId:不同工作区不能被并成一组。
190
- const byId = groupRows([row("a", null, "W1", 1, "ws-1"), row("b", null, "W1", 1, "ws-2")], t);
191
- check("missing cwd falls back to workspaceId", byId.length === 2 && byId.every((group) => group.key.startsWith("id:")), byId.map((group) => group.key).join(", "));
192
-
193
- // 5) 空输入 / 空 cwd 字符串都不炸。
194
- check("empty input yields no groups", groupRows([], t).length === 0);
195
- check("blank cwd is treated as missing", groupKeyOf({ sessionId: "a", cwd: " " }) === "unknown", groupKeyOf({ sessionId: "a", cwd: " " }));
196
-
197
- // 6) 两个新组件是纯 props 函数,可以直接调。
198
- const alpha = groupRows([
199
- row("a", "C:\\w\\Alpha", "Alpha", 10),
200
- row("b", "C:\\w\\Alpha", "Alpha", 20)
201
- ], t)[0];
202
-
203
- let collapses = 0;
204
- let groupToggles = 0;
205
- const header = GroupHeader({
206
- group: alpha,
207
- t,
208
- first: true,
209
- busy: false,
210
- selected: ["a", "b"],
211
- collapsed: false,
212
- onToggleCollapse: () => {
213
- collapses += 1;
214
- },
215
- onToggleGroup: () => {
216
- groupToggles += 1;
217
- }
218
- });
219
- const headerChildren = header.children;
220
- const toggleButton = headerChildren[0];
221
- const selectButton = headerChildren[1];
222
- check("group header hides the card top border and paints a faint band", header.props.style.borderTop === "none" && header.props.style.background === "var(--dsw-alias-interactive-bg-active)");
223
- check("no checkbox on the left of the folder row", headerChildren.length === 2 && toggleButton.type === "button" && toggleButton.children.every((child) => child?.type !== "input"));
224
-
225
- // 标题按钮里的顺序:折叠箭头 → 文件夹图标 → 名字 → 数量 → 体积 → 路径。
226
- const toggleChildren = toggleButton.children;
227
- check("the chevron sits before the folder icon, which sits before the name", toggleChildren[0].type === "span" && toggleChildren[0].children[0].type === "IconChevronDownOutlineRegular" && toggleChildren[1].type === "span" && toggleChildren[1].children[0].type === "IconFolderOpenRegular" && toggleChildren[2].children[0] === "Alpha");
228
- check("the folder icon is dimmer than the title and inherits currentColor", toggleChildren[1].props.style.color === "var(--dsw-alias-label-secondary)" && toggleChildren[1].children[0].props.size === 14);
229
- const headerLabels = toggleChildren.filter((child) => typeof child?.children?.[0] === "string").map((child) => child.children[0]);
230
- check("group header shows title, count and size", ["Alpha", "2 sessions", "30 B"].every((label) => headerLabels.includes(label)), JSON.stringify(headerLabels));
231
- 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"));
232
- toggleButton.props.onClick();
233
- selectButton.props.onClick();
234
- check("header click / right-hand button call the handlers", collapses === 1 && groupToggles === 1, `collapse=${collapses} select=${groupToggles}`);
235
-
236
- const freshHeader = GroupHeader({ group: alpha, t, first: false, busy: true, selected: [], collapsed: false, onToggleCollapse: () => {}, onToggleGroup: () => {} });
237
- check("an unselected group offers Select all and disables it while busy", freshHeader.children[1].children[0] === "Select all" && freshHeader.children[1].props.disabled === true);
238
-
239
- const collapsedHeader = GroupHeader({ group: alpha, t, first: false, busy: false, selected: [], collapsed: true, onToggleCollapse: () => {}, onToggleGroup: () => {} });
240
- const collapsedToggle = draw(collapsedHeader).children[0];
241
- check("collapsing swaps chevron-down for chevron-right and the open folder for a closed one", collapsedToggle.children[0].children[0].type === "IconChevronRightOutlineRegular" && collapsedToggle.children[1].children[0].type === "IconFolderCloseRegular");
242
-
243
- // 行:动作收进一个「⋯」菜单。
244
- const menu = { items: [{ id: "restore", label: "Restore", icon: null }], run: () => {} };
245
- const rowMenuOf = (element) => draw(element).children[2].children[0];
246
- const withCwd = SessionRow({ row: alpha.rows[0], t, first: true, busy: false, showCwd: true, showDeletedAt: false, checked: false, onToggle: () => {}, menu, menuOpen: false, onToggleMenu: () => {}, onCloseMenu: () => {} });
247
- const withoutCwd = SessionRow({ row: alpha.rows[0], t, first: false, busy: false, showCwd: false, showDeletedAt: false, checked: true, onToggle: () => {}, menu, menuOpen: true, onToggleMenu: () => {}, onCloseMenu: () => {} });
248
- const metaOf = (element) => element.children[1].children[1].children.join("");
249
- check("row shows the folder only in flat mode", metaOf(withCwd).includes("C:\\w\\Alpha") && !metaOf(withoutCwd).includes("C:\\w\\Alpha"), `${JSON.stringify(metaOf(withCwd))} vs ${JSON.stringify(metaOf(withoutCwd))}`);
250
- check("a row's right side is exactly one ⋯ menu, not a row of buttons", draw(withoutCwd).children[2].children.length === 1 && rowMenuOf(withoutCwd).type === "Menu");
251
- const rowMenuElement = rowMenuOf(withoutCwd);
252
- 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
- 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
- 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"));
255
-
256
- // ── B. 整块面板:假 ctx 调 apply(),拿到真正注册的面板组件后渲染 ─────────
257
- const SNAPSHOT = {
258
- dshHome: "C:\\Users\\Motues\\.dsh",
259
- version: "0.1.0",
260
- trashDir: "C:\\Users\\Motues\\.dsh\\storages\\session-manager\\trash",
261
- sessionsDir: "C:\\Users\\Motues\\.dsh\\sessions",
262
- archived: [
263
- { sessionId: "s-1", title: "修 Header", cwd: "C:\\Data\\Momo", workspaceId: "w1", workspaceTitle: "Momo", bytes: 100, files: 1, createdAt: 1 },
264
- { sessionId: "s-2", title: "改毛玻璃", cwd: "C:\\Data\\Momo", workspaceId: "w1", workspaceTitle: "Momo", bytes: 200, files: 1, createdAt: 2 },
265
- { sessionId: "s-3", title: "写博客", cwd: "C:\\Data\\BlogPage", workspaceId: "w2", workspaceTitle: "BlogPage", bytes: 300, files: 1, createdAt: 3 }
266
- ],
267
- pinned: [{ sessionId: "s-4", title: "钉住的", cwd: "C:\\Data\\Momo", workspaceId: "w1", workspaceTitle: "Momo", bytes: 10, files: 1, createdAt: 4 }],
268
- trashed: [{ sessionId: "s-9", title: "旧会话", cwd: "C:\\Data\\BlogPage", workspaceId: "w2", workspaceTitle: "BlogPage", bytes: 40, files: 1, deletedAt: 9 }],
269
- totals: { archived: 3, archivedBytes: 600, pinned: 1, trashed: 1, trashBytes: 40 }
270
- };
271
-
272
- const slots = [];
273
- let dictionaries = null;
274
- const hostCalls = [];
275
- /** 更新检查的假回答;置 null 表示这次让宿主失败(测错误分支)。 */
276
- let updateResponse = { current: "0.1.0", latest: "0.1.1", outdated: true, checkedAt: 0 };
277
- const fakeCtx = {
278
- get: (name) => (name === "connection"
279
- ? {
280
- rpc: {
281
- call: async (channel, endpoint, payload) => {
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
- }
287
- return { ok: true, value: SNAPSHOT };
288
- }
289
- }
290
- }
291
- : undefined),
292
- locale: {
293
- register: (namespace, dicts) => {
294
- dictionaries = dicts;
295
- },
296
- bind: () => (key) => dictionaries.zh[key] ?? key
297
- },
298
- slots: {
299
- inject: (slot, callback) => callback(),
300
- register: (options, component) => {
301
- slots.push({ options, component });
302
- },
303
- // 槽位账本:设置外壳的 navIcon(id) 只认几个固定 id,我们借 archived-sessions。
304
- entries: () => []
305
- },
306
- effect: (callback) => callback(),
307
- logger: { warn: (message) => console.log(" [warn]", message) }
308
- };
309
-
310
- client.apply(fakeCtx);
311
- const section = slots.find((entry) => entry.options.name === "settings.section");
312
- check("apply() registers exactly the three contributions", slots.length === 3 && section !== undefined, slots.map((entry) => entry.options.id).join(", "));
313
- /** 面板拿到的 `t`:直接用面板注册时交上去的那份中文词典(带 {n} 插值)。 */
314
- const tPanel = (key, params) => {
315
- let value = dictionaries.zh[key] ?? key;
316
- for (const name of Object.keys(params ?? {})) value = value.split(`{${name}}`).join(String(params[name]));
317
- return value;
318
- };
319
-
320
- /** 官方侧边栏的视图存储:预置成"显示已归档",用来验证配置页那个开关读的是同一份设置。 */
321
- globalThis.localStorage.setItem("dsh.workspace.view.v5", JSON.stringify({ groupBy: "flat", orderBy: "updated", archivedFilter: "show" }));
322
-
323
- let tree = await render(section.component, { t: tPanel });
324
- const nodesOf = (current, type) => collect(current, []).filter((node) => node.type === type);
325
- const headers = () => nodesOf(tree, client.GroupHeader);
326
- const rowsSeen = () => nodesOf(tree, client.SessionRow);
327
- /** 组标题的两个交互件:`[折叠按钮, 右侧全选按钮]`。 */
328
- const partsOf = (element) => draw(element).children;
329
- const footerText = () => JSON.stringify(tree);
330
-
331
- check("archived tab renders one group header per workspace, sorted by title", headers().length === 2 && headers()[0].props.group.title === "BlogPage" && headers()[1].props.group.title === "Momo", headers().map((node) => node.props.group.title).join(", "));
332
- check("rows stay inside their group (1 + 2)", headers()[0].props.group.rows.length === 1 && headers()[1].props.group.rows.length === 2);
333
- check("the card's first node has no top border", headers()[0].props.first === true && headers()[1].props.first === false);
334
- check("rows are counted, not just headers", rowsSeen().length === 3, `${rowsSeen().length} row(s)`);
335
- check("footer shows the session total", footerText().includes("3 个会话 · 600 B"));
336
- check("footer carries the workspace count", footerText().includes("2 个工作区"));
337
- check("every group header renders the chevron, the folder icon and the right-hand select button", headers().every((node) => {
338
- const parts = partsOf(node);
339
- const toggle = parts[0].children;
340
- return toggle[0].children[0].type === "IconChevronDownOutlineRegular" && toggle[1].children[0].type === "IconFolderOpenRegular" && parts[1].type === "Button" && toggle.every((child) => child?.type !== "input");
341
- }), "");
342
- check("every folder row paints the faint band", headers().every((node) => draw(node).props.style.background === "var(--dsw-alias-interactive-bg-active)"), "");
343
-
344
- // 折叠 Momo 组:点它的标题按钮 → 该组的行必须消失,但组标题还在。
345
- partsOf(headers()[1])[0].props.onClick();
346
- tree = await render(section.component, { t: tPanel });
347
- check("collapsing a group keeps its header but hides its rows", headers().length === 2 && rowsSeen().length === 1, `${headers().length} header(s), ${rowsSeen().length} row(s)`);
348
- check("the collapsed header shows a chevron-right and a closed folder", headers()[1].props.collapsed === true && partsOf(headers()[1])[0].children[0].children[0].type === "IconChevronRightOutlineRegular" && partsOf(headers()[1])[0].children[1].children[0].type === "IconFolderCloseRegular");
349
-
350
- // 展开回来。
351
- partsOf(headers()[1])[0].props.onClick();
352
- tree = await render(section.component, { t: tPanel });
353
- check("expanding restores the rows", rowsSeen().length === 3, `${rowsSeen().length} row(s)`);
354
-
355
- // 整组勾选:点 Momo 组右侧的小按钮 → 该组两行都进选择,页脚出现"已选 2 项",按钮翻成"取消"。
356
- check("the right-hand button starts as 全选", partsOf(headers()[1])[1].children[0] === "全选", partsOf(headers()[1])[1].children[0]);
357
- partsOf(headers()[1])[1].props.onClick();
358
- tree = await render(section.component, { t: tPanel });
359
- check("the group select button selects the whole workspace", footerText().includes("已选 2 项"));
360
- check("a fully selected group flips the button to 取消", partsOf(headers()[1])[1].children[0] === "取消", partsOf(headers()[1])[1].children[0]);
361
- partsOf(headers()[1])[1].props.onClick();
362
- tree = await render(section.component, { t: tPanel });
363
- check("clicking 取消 clears the whole workspace again", !footerText().includes("已选"));
364
-
365
- // ── 行的「⋯」菜单:形状统一、动作真的打到宿主 ──────────────────────────
366
- const menusOf = (element) => collect(draw(element), []).filter((node) => node.type === "Menu");
367
- const menuOfRow = (sessionId) => {
368
- const element = rowsSeen().find((node) => node.props.row.sessionId === sessionId);
369
- return element === undefined ? undefined : menusOf(element)[0];
370
- };
371
-
372
- check("each session row has exactly one ⋯ menu", rowsSeen().length === 3 && rowsSeen().every((node) => menusOf(node).length === 1 && draw(node).children[2].children.length === 1), rowsSeen().map((node) => menusOf(node).length).join(","));
373
- const archivedMenu = menuOfRow("s-1");
374
- check("archived rows offer unarchive / pin / move-to-trash (no dead 永久删除)", archivedMenu.props.items.map((item) => item.id).join(",") === "unarchive,pin,trash", archivedMenu.props.items.map((item) => item.label).join(" · "));
375
- check("the destructive item is flagged danger and every item carries an icon", archivedMenu.props.items[2].danger === true && archivedMenu.props.items[0].icon.type === "IconUnarchiveOutlineRegular" && archivedMenu.props.items[2].icon.type === "IconTrashOutlineRegular", archivedMenu.props.items.map((item) => item.icon.type).join(","));
376
- check("menus start closed", menusOf(rowsSeen()[0]).every((node) => node.props.open === false));
377
-
378
- // 点 ⋯ → 只开这一行。
379
- archivedMenu.props.anchor.props.onClick({ stopPropagation: () => {} });
380
- tree = await render(section.component, { t: tPanel });
381
- check("clicking ⋯ opens only that row's menu", menuOfRow("s-1").props.open === true && menuOfRow("s-2").props.open === false);
382
-
383
- // 选"取消归档" → 关菜单 + 打到宿主 unarchive。
384
- const callsBefore = hostCalls.length;
385
- menuOfRow("s-1").props.onSelect("unarchive");
386
- tree = await render(section.component, { t: tPanel });
387
- check("selecting unarchive closes the menu and calls the host once", menuOfRow("s-1").props.open === false && hostCalls.length === callsBefore + 1 && hostCalls[hostCalls.length - 1].endpoint === "session-curator/unarchive" && hostCalls[hostCalls.length - 1].payload.ids.join(",") === "s-1", JSON.stringify(hostCalls[hostCalls.length - 1]));
388
-
389
- // 选"移入回收站" → 不打宿主,改成弹确认框(软删除永远先问)。
390
- menuOfRow("s-2").props.onSelect("trash");
391
- tree = await render(section.component, { t: tPanel });
392
- check("selecting move-to-trash asks for confirmation instead of calling the host", hostCalls.length === callsBefore + 1 && menuOfRow("s-2").props.open === false);
393
-
394
- // 关掉分组 → 平铺:没有组标题,行里重新显示文件夹。
395
- /** 每次都要重新找按钮:元素是快照,闭包里的状态也会过期。 */
396
- const buttonByLabel = (prefix) => collect(tree, []).find((node) => node.type === "Button" && (node.children?.[0] ?? "").startsWith(prefix));
397
- check("toolbar exposes the grouping toggle", buttonByLabel("分组") !== undefined, JSON.stringify(collect(tree, []).filter((node) => node.type === "Button").map((node) => node.children?.[0])));
398
- buttonByLabel("分组").props.onClick();
399
- tree = await render(section.component, { t: tPanel });
400
- const flatMeta = rowsSeen().map((node) => metaOf(draw(node)));
401
- check("turning grouping off renders a flat list with the folder back on each row", headers().length === 0 && rowsSeen().length === 3 && flatMeta.every((meta) => meta.includes("C:\\Data\\")), `${headers().length} header(s), ${rowsSeen().length} row(s), meta=${JSON.stringify(flatMeta)}`);
402
- check("the toggle now reports the flat mode", buttonByLabel("分组:平铺") !== undefined);
403
- buttonByLabel("分组:平铺").props.onClick();
404
- tree = await render(section.component, { t: tPanel });
405
- check("turning grouping back on restores the headers", headers().length === 2, `${headers().length} header(s)`);
406
-
407
- // 切到回收站页签:同样按工作区分组(回收站行用 __trash.json 里的 cwd)。
408
- check("trash tab button exists", buttonByLabel("回收站") !== undefined, JSON.stringify(collect(tree, []).filter((node) => node.type === "Button").map((node) => node.children?.[0])));
409
- buttonByLabel("回收站").props.onClick();
410
- tree = await render(section.component, { t: tPanel });
411
- check("trash tab groups by the recorded workspace folder too", headers().length === 1 && headers()[0].props.group.title === "BlogPage" && rowsSeen().length === 1, `${headers().length} header(s), ${rowsSeen().length} row(s)`);
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(" · "));
413
- check("trash rows keep the same single-⋯ shape", draw(rowsSeen()[0]).children[2].children.length === 1);
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
-
543
- // ── 设置页 id:导航栏图标是「按 id 查表」的(外壳的 navIcon()) ──────────
544
- check('with an empty ledger the section borrows the "archived-sessions" id (archive glyph in the nav)', sectionIdWith(() => []) === "archived-sessions");
545
-
546
- /** 只关心 id 的探针:换不同账本,看设置页最后用哪个 id。 */
547
- function sectionIdWith(entries) {
548
- const registered = [];
549
- const ctx = {
550
- get: () => undefined,
551
- locale: { register: () => {}, bind: () => (key) => key },
552
- slots: {
553
- inject: (slot, callback) => callback(),
554
- register: (options) => registered.push(options)
555
- },
556
- effect: (callback) => callback(),
557
- logger: { warn: () => {} }
558
- };
559
- if (entries !== undefined) ctx.slots.entries = entries;
560
- client.apply(ctx);
561
- return registered.find((options) => options.name === "settings.section").id;
562
- }
563
-
564
- check("without a slot ledger it falls back to its own id", sectionIdWith(undefined) === "session-curator");
565
- check("and it never collides when DSH ships its own archived-sessions section", sectionIdWith(() => [{ options: { id: "archived-sessions" } }]) === "session-curator");
566
-
567
- const failed = checks.filter((entry) => !entry.ok);
568
- console.log(`\n${checks.length - failed.length}/${checks.length} checks passed`);
569
- process.exit(failed.length === 0 ? 0 : 1);