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/README.md +22 -189
- package/README.zh-CN.md +18 -144
- package/lib/client.js +440 -21
- package/lib/index.js +156 -13
- package/package.json +1 -1
- package/scripts/activation.mjs +1 -1
- package/scripts/client.mjs +151 -0
- package/scripts/smoke.mjs +226 -25
package/lib/index.js
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
* dsh-session-curator — host 半区。
|
|
3
3
|
*
|
|
4
4
|
* 提供三块能力:
|
|
5
|
-
* 1.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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.
|
|
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",
|
package/scripts/activation.mjs
CHANGED
|
@@ -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}`;
|
package/scripts/client.mjs
CHANGED
|
@@ -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
|
|