dsh-session-curator 0.1.2 → 0.3.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.
@@ -0,0 +1,77 @@
1
+ /**
2
+ * 危险操作:**物理删除**(不可恢复)。
3
+ *
4
+ * 为什么不再是"软删除到回收站":回收站与归档在做同一件事(把会话从侧边栏藏起来、之后还能
5
+ * 找回来),而归档是 DSH 自带的机制、不动磁盘、随时可逆。所以删除就做成**真正的删除**:
6
+ * 删掉 `<DSH_HOME>/sessions/<slug>/<sessionId>/` 与它的投影缓存,不可撤销。想留退路的
7
+ * 用户在确认弹窗里会被明确提示改用归档。
8
+ *
9
+ * 顺序不能随便改(与旧 `purge` 同一条教训):**先把记账清干净,再删目录**。
10
+ * - `archivedSessionIds` / `pinnedSessionIds` 是持久化的集合,删除后还留着 id 就是一条
11
+ * "DSH 一直以为它归档着、界面上又找不到"的永久幽灵;
12
+ * - 工作区记账(`sessionIds`)同样要摘掉,否则侧边栏的"未分组"里会留一条点不开的幽灵行;
13
+ * - 目录最后删。万一 `rm` 失败(占用 / 权限),这一项在 `results[]` 里报失败,会话还在
14
+ * 磁盘上,只是已经被取消归档 —— 用户仍然看得到它,可以重试,不会凭空丢数据。
15
+ *
16
+ * 正在跑的会话与**当前正在使用的会话**一律拒绝删除:前者由官方的
17
+ * `workspace/session-activity` 判定(与官方归档同一条判据),后者是
18
+ * `process.env.DSH_SESSION_ID`。
19
+ */
20
+ import { promises as fsp } from "node:fs";
21
+ import { messageOf } from "./fs-utils.js";
22
+ import { projCacheFile } from "./paths.js";
23
+ /**
24
+ * 逐项物理删除:会话目录 + 投影缓存,并把归档 / 置顶 / 工作区三处记账清干净。
25
+ *
26
+ * 单项失败只留在 `results[]` 里,不影响其余项(`each()` 的语义)。
27
+ */
28
+ export async function deleteSessions(host, ids) {
29
+ const dirs = await host.scanSessionDirs();
30
+ return host.each(ids, async (sessionId) => {
31
+ const steps = [];
32
+ if (sessionId === process.env.DSH_SESSION_ID) {
33
+ throw new Error("这是当前正在使用的会话,不能删除");
34
+ }
35
+ const activity = await host.sessionActivity(sessionId);
36
+ if (activity.length > 0)
37
+ throw new Error("会话正在运行,请先停止它");
38
+ const info = dirs.get(sessionId);
39
+ if (!info)
40
+ throw new Error("找不到会话日志目录(可能已被删除)");
41
+ // 三处记账先清:见文件头"顺序不能随便改"。
42
+ if (host.registry.archivedSessionIds.includes(sessionId)) {
43
+ await host.registry.unarchiveSession(sessionId);
44
+ steps.push("unarchive");
45
+ }
46
+ if (host.registry.pinnedSessionIds.includes(sessionId)) {
47
+ await host.registry.unpinSession(sessionId);
48
+ steps.push("unpin");
49
+ }
50
+ const workspace = host.findWorkspaceOf(sessionId);
51
+ if (workspace) {
52
+ try {
53
+ if (typeof workspace.detachSession === "function") {
54
+ await workspace.detachSession(sessionId);
55
+ steps.push("detach");
56
+ }
57
+ }
58
+ catch (error) {
59
+ // 摘不掉记账不该阻止删除:目录没了之后 DSH 自己也会把它清掉。
60
+ steps.push(`detach-failed:${messageOf(error)}`);
61
+ }
62
+ }
63
+ // 会话目录整个删掉(这是唯一不可逆的一步)。
64
+ await fsp.rm(info.dir, { recursive: true, force: true });
65
+ steps.push("delete");
66
+ // 投影缓存是 DSH 自己的文件(`storages/session_projcache/sessions/<id>.json`):
67
+ // 会话都没了,留着一份标题 / cwd 的缓存只会在下次重建列表时对不上。
68
+ try {
69
+ await fsp.rm(projCacheFile(sessionId), { force: true });
70
+ steps.push("projcache");
71
+ }
72
+ catch {
73
+ // 缓存删不掉不影响会话本身
74
+ }
75
+ return steps;
76
+ });
77
+ }
@@ -1,37 +1,61 @@
1
1
  /** 文件系统小工具 + 错误消息归一化。 */
2
2
  import { promises as fsp } from "node:fs";
3
- import { dirname, join } from "node:path";
3
+ import { join } from "node:path";
4
4
  /** 任何抛出物 → 一句人话(宿主 logger 与端点错误信封都用它)。 */
5
5
  export function messageOf(error) {
6
6
  if (error instanceof Error)
7
7
  return error.message;
8
8
  return String(error);
9
9
  }
10
- /** 跨卷 rename 会抛 EXDEV,退回 copy + rm。 */
11
- export async function moveDir(from, to) {
12
- await fsp.mkdir(dirname(to), { recursive: true });
13
- try {
14
- await fsp.rename(from, to);
15
- return;
16
- }
17
- catch (error) {
18
- if (error?.code !== "EXDEV")
19
- throw error;
20
- }
21
- await fsp.cp(from, to, { recursive: true, force: true });
22
- await fsp.rm(from, { recursive: true, force: true });
10
+ /**
11
+ * 最多 `limit` 个任务同时在跑;返回值**按入参顺序**排列。
12
+ *
13
+ * 为什么不用 `Promise.all(items.map(...))`:快照要一次给上百个目录做 `readdir` + `stat`,
14
+ * 无上限并发会把磁盘打成一锅粥(Windows 上尤其明显,还会跟 DSH 自己的会话扫描抢 IO)。
15
+ * 这里只做"有上限的并发",超出的任务排队,单项抛错照原样往外冒(由调用方决定怎么办)。
16
+ */
17
+ export async function mapLimit(items, limit, worker) {
18
+ const size = items.length;
19
+ const results = new Array(size);
20
+ if (size === 0)
21
+ return results;
22
+ const width = Math.max(1, Math.min(Math.floor(limit) || 1, size));
23
+ let next = 0;
24
+ const run = async () => {
25
+ while (next < size) {
26
+ const index = next;
27
+ next += 1;
28
+ results[index] = await worker(items[index], index);
29
+ }
30
+ };
31
+ await Promise.all(Array.from({ length: width }, run));
32
+ return results;
33
+ }
34
+ const DIR_STATS_CACHE = new Map();
35
+ /** 缓存上限:超过就整个清掉。磁盘目录数远小于它,这里只是防止长期运行无限增长。 */
36
+ const DIR_STATS_CACHE_LIMIT = 2000;
37
+ /** 给自检用:清掉缓存后重新统计(`scripts/smoke.mjs` 要断言"同一次快照内命中缓存")。 */
38
+ export function forgetDirStats() {
39
+ DIR_STATS_CACHE.clear();
23
40
  }
24
- /** 只统计顶层文件:会话目录里就是那几份日志。 */
41
+ /** 只统计顶层文件:会话目录里就是那几份日志。目录 mtime 没变就直接回缓存。 */
25
42
  export async function dirStats(dir) {
26
- let bytes = 0;
27
- let files = 0;
43
+ let mtimeMs;
28
44
  let entries;
29
45
  try {
46
+ const info = await fsp.stat(dir);
47
+ mtimeMs = info.mtimeMs;
30
48
  entries = await fsp.readdir(dir, { withFileTypes: true });
31
49
  }
32
50
  catch {
33
51
  return { bytes: 0, files: 0 };
34
52
  }
53
+ const cached = DIR_STATS_CACHE.get(dir);
54
+ if (cached !== undefined && cached.mtimeMs === mtimeMs && cached.entryCount === entries.length) {
55
+ return { bytes: cached.bytes, files: cached.files };
56
+ }
57
+ let bytes = 0;
58
+ let files = 0;
35
59
  for (const entry of entries) {
36
60
  if (!entry.isFile())
37
61
  continue;
@@ -44,6 +68,9 @@ export async function dirStats(dir) {
44
68
  // 竞态:文件刚好消失,忽略
45
69
  }
46
70
  }
71
+ if (DIR_STATS_CACHE.size >= DIR_STATS_CACHE_LIMIT)
72
+ DIR_STATS_CACHE.clear();
73
+ DIR_STATS_CACHE.set(dir, { mtimeMs, entryCount: entries.length, bytes, files });
47
74
  return { bytes, files };
48
75
  }
49
76
  export async function readProjectionFile(file) {
@@ -0,0 +1,98 @@
1
+ /**
2
+ * 「复制路径 / 在资源管理器中显示」的宿主侧:**只认自己算出来的目录**。
3
+ *
4
+ * 安全边界(这一段是这个文件存在的全部理由):
5
+ * - 路径**不接受客户端传**:只吃 `sessionId`,由宿主自己按 `<DSH_HOME>/sessions/<slug>/<id>`
6
+ * 拼出来。客户端就算传了别的路径也没用 —— 入参里根本没有路径字段。
7
+ * - 拼出来之后再 `resolve()` 一次并断言它落在**允许的根**(`<DSH_HOME>/sessions`)
8
+ * 之下。这一道是为了挡住 `../` 之类的 id(线协议的 `idsOf()` 只保证是
9
+ * 非空字符串,不保证它不是一个相对路径)。
10
+ * - 只有 `target: "reveal"` 才会真的去叫系统文件管理器,而且只用**固定的可执行名**
11
+ * (`explorer.exe` / `open` / `xdg-open`),参数是那个已经校验过的目录。不拼 shell 字符串,
12
+ * 走 `spawn` + 参数数组,所以路径里的空格 / 引号 / `&` 都不会被解释成命令。
13
+ *
14
+ * 为什么不用 `shell.openPath`(Electron 的 API):宿主半区跑在 DSH 的 Electron 主进程里,
15
+ * 但插件是普通 ESM 模块,拿不到 `electron` 模块(那是 DSH 自己的依赖,junction 挂载下
16
+ * Node 按 realpath 解析根本找不到 —— 与 AGENTS.md §5 禁止裸导入 `@deepseek-ai/*` 同一条
17
+ * 理由)。所以这里退到"用平台自带的文件管理器命令",零依赖。
18
+ */
19
+ import { spawn } from "node:child_process";
20
+ import { existsSync } from "node:fs";
21
+ import { resolve, sep } from "node:path";
22
+ import { messageOf } from "./fs-utils.js";
23
+ import { sessionsRoot } from "./paths.js";
24
+ /** `path` / `reveal`:`reveal` 之外的任何值都当成 `path`(只读,最保守的那条)。 */
25
+ export function openTargetOf(value) {
26
+ return value === "reveal" ? "reveal" : "path";
27
+ }
28
+ /**
29
+ * 允许的根:整个会话目录。`resolve()` 之后比较,且要求"相等或以 `<root>+分隔符`
30
+ * 开头" —— 单比 `startsWith(root)` 会把 `<DSH_HOME>/sessions-evil` 放进来。
31
+ */
32
+ function allowedRoots() {
33
+ return [resolve(sessionsRoot())];
34
+ }
35
+ function inside(root, target) {
36
+ return target === root || target.startsWith(root.endsWith(sep) ? root : root + sep);
37
+ }
38
+ /** 平台对应的文件管理器命令;不支持的平台返回 null(`path` 仍然可用)。 */
39
+ function revealCommand() {
40
+ if (process.platform === "win32")
41
+ return { command: "explorer.exe", args: [] };
42
+ if (process.platform === "darwin")
43
+ return { command: "open", args: [] };
44
+ if (process.platform === "linux")
45
+ return { command: "xdg-open", args: [] };
46
+ return null;
47
+ }
48
+ /**
49
+ * 把文件管理器叫起来。**不等它退出**:`explorer.exe` 会立刻返回、`xdg-open` 可能一直活着,
50
+ * 等它就会把端点请求挂住。所以 `detached` + `stdio: "ignore"` + `unref()`,
51
+ * 只监听 `error`(命令不存在那种);真正"有没有打开"由用户自己看得到。
52
+ */
53
+ function reveal(path) {
54
+ const command = revealCommand();
55
+ if (command === null)
56
+ return { ok: false, reason: `不支持的平台:${process.platform}` };
57
+ try {
58
+ const child = spawn(command.command, [...command.args, path], { detached: true, stdio: "ignore" });
59
+ child.on("error", () => {
60
+ // 没有文件管理器(无头环境)不该让请求失败:路径已经回了,界面提示用户手动打开。
61
+ });
62
+ child.unref();
63
+ return { ok: true };
64
+ }
65
+ catch (error) {
66
+ return { ok: false, reason: messageOf(error) };
67
+ }
68
+ }
69
+ /**
70
+ * `open` 端点:算路径 → 校验 → (可选)叫文件管理器。
71
+ *
72
+ * `sessionId` 必须能在磁盘上找到;找不到就抛错,由信封兜住(与 `describe` 一致)。
73
+ */
74
+ export async function openSession(host, ids, target) {
75
+ const sessionId = ids[0];
76
+ if (typeof sessionId !== "string" || sessionId === "")
77
+ throw new Error("缺少会话 id");
78
+ const dirs = await host.scanSessionDirs();
79
+ const path = dirs.get(sessionId)?.dir ?? null;
80
+ if (path === null)
81
+ throw new Error("找不到这个会话的日志目录(磁盘上不存在)");
82
+ const resolved = resolve(path);
83
+ const roots = allowedRoots();
84
+ if (!roots.some((root) => inside(root, resolved))) {
85
+ // 到不了这里才说明 id 里有 `../` 之类的东西 —— 直接拒绝,别去碰根目录外面的路径。
86
+ throw new Error("拒绝打开:这个路径不在 DSH 的会话目录里");
87
+ }
88
+ if (!existsSync(resolved))
89
+ throw new Error("日志目录已经不在了");
90
+ if (target !== "reveal")
91
+ return { sessionId, path: resolved, revealed: false };
92
+ const opened = reveal(resolved);
93
+ return opened.ok
94
+ ? { sessionId, path: resolved, revealed: true }
95
+ : { sessionId, path: resolved, revealed: false, reason: opened.reason };
96
+ }
97
+ /** 允许的根(导出给自检断言"路径校验真的挡得住 `../`")。 */
98
+ export { allowedRoots, inside as insideRoot };
package/lib/host/paths.js CHANGED
@@ -25,20 +25,12 @@ export function dshHomePath(...segments) {
25
25
  return join(resolveDshHome(), ...segments);
26
26
  }
27
27
  /**
28
- * 回收站根目录:`<DSH_HOME>/storages/session-manager/trash`。
29
- *
30
- * 这里的 `session-manager` 是**故意不改的旧名**:插件从 `dsh-session-manager` 改名成
31
- * `dsh-session-curator` 时,回收站里可能还躺着用户已经删掉的会话(本机就有一个)——
32
- * 改路径会把它们变成孤儿。数据目录用历史名、其余 id 用新名,是权衡后的结果。
28
+ * 历史遗留:本插件 0.2.0 之前把"软删除"的会话搬到
29
+ * `<DSH_HOME>/storages/session-manager/trash`(目录名 `session-manager` 是更早的包名)。
30
+ * 回收站功能已在 0.3.0 移除,删除改成物理删除,所以插件**不再读写**那个目录:
31
+ * 里面可能还有用户当年软删除的会话,插件不主动迁移也不删除它们;需要时人工把
32
+ * `trash/<sessionId>/` 搬回 `sessions/<slug>/<sessionId>/` 即可。
33
33
  */
34
- export const TRASH_SUBPATH = ["storages", "session-manager", "trash"];
35
- /** 回收站条目的元数据:原 slug / cwd / 标题 / 删除时间 / 删除前是否已归档。 */
36
- export const META_FILE = "__trash.json";
37
- /** 从投影缓存搬进来的那份文件(恢复时要搬回原位)。 */
38
- export const PROJ_FILE = "__projcache.json";
39
- export function trashRoot() {
40
- return dshHomePath(...TRASH_SUBPATH);
41
- }
42
34
  export function sessionsRoot() {
43
35
  return dshHomePath("sessions");
44
36
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 传输 / 分发:九个端点 → 九条精确 Fetch 路由。
2
+ * 传输 / 分发:十个端点 → 十条精确 Fetch 路由。
3
3
  *
4
4
  * ── 为什么**不用** `ctx.connection.rpc.handle()`(踩过的坑,改回去之前先读)──
5
5
  *
@@ -31,13 +31,17 @@ const ROUTE_METHODS = ["POST"];
31
31
  /** 一个频道(`/api/session-curator`):精确路由表按完整 pathname 命中,所以每个端点一条。 */
32
32
  export const ENDPOINTS = {
33
33
  snapshot: (manager) => manager.snapshot(),
34
+ describe: (manager, payload) => manager.describe(payload.ids ?? []),
34
35
  archive: (manager, payload) => manager.archive(payload.ids ?? [], payload.stopActivity),
35
36
  unarchive: (manager, payload) => manager.unarchive(payload.ids ?? []),
36
37
  pin: (manager, payload) => manager.pin(payload.ids ?? []),
37
38
  unpin: (manager, payload) => manager.unpin(payload.ids ?? []),
38
- trash: (manager, payload) => manager.trash(payload.ids ?? []),
39
- restore: (manager, payload) => manager.restore(payload.ids ?? []),
40
- purge: (manager, payload) => manager.purge(payload.ids ?? []),
39
+ // 物理删除:不可恢复,确认弹窗由客户端把关(见 components/dialog-host.ts)。
40
+ delete: (manager, payload) => manager.delete(payload.ids ?? []),
41
+ // 磁盘占用视图(只读)与"复制路径 / 在资源管理器中显示"。
42
+ usage: (manager) => manager.usage(),
43
+ // `target` 只认字面量 `"reveal"`;其余一律当 `"path"`(只读,最保守的那条)。
44
+ open: (manager, payload) => manager.open(payload.ids ?? [], payload.target === "reveal" ? "reveal" : "path"),
41
45
  "check-update": (manager, payload) => manager.checkUpdate(payload.force === true)
42
46
  };
43
47
  /** 只接受字符串数组;其余一律忽略,避免把宿主路径当 id 传来的越权尝试。 */
@@ -1,16 +1,29 @@
1
1
  /**
2
2
  * 会话管理器:**只读投影**(快照 / 标题 / 体积)+ 归档 / 置顶这类轻量变更。
3
3
  *
4
- * 危险操作(软删除 / 恢复 / 永久删除)在 `trash.ts` 里,那边带完整的事故注释;
4
+ * 危险操作(物理删除)在 `delete.ts` 里,那边带完整的事故注释;
5
5
  * 这里只负责把磁盘与 registry 的事实汇总成快照,以及逐项执行的框架 `each()`。
6
6
  */
7
7
  import { promises as fsp } from "node:fs";
8
8
  import { join } from "node:path";
9
- import { dirStats, messageOf, readProjectionFile } from "./fs-utils.js";
10
- import { META_FILE, dshHomePath, projCacheFile, sessionsRoot, trashRoot } from "./paths.js";
9
+ import { dirStats, mapLimit, messageOf, readProjectionFile } from "./fs-utils.js";
10
+ import { dshHomePath, projCacheFile, sessionsRoot } from "./paths.js";
11
11
  import { LOCAL_VERSION } from "./manifest.js";
12
12
  import { checkForUpdate } from "./update.js";
13
- import { purgeSessions, restoreSessions, trashSessions } from "./trash.js";
13
+ import { deleteSessions } from "./delete.js";
14
+ import { openSession } from "./open.js";
15
+ /**
16
+ * 文件系统侧的并发上限。快照一次要给上百个目录做 `readdir` + `stat`,串行 await 会让
17
+ * 每次变更都慢一拍;无上限并发又会跟 DSH 自己的会话扫描抢 IO。8 是"够快且不过分"的折中。
18
+ */
19
+ const FS_CONCURRENCY = 8;
20
+ /**
21
+ * 活动判定的并发上限。每个会话问一次官方的 `workspace/session-activity`(瀑布钩子,
22
+ * 可能被别的插件挂成异步 IO),所以给得比文件系统小一些。
23
+ */
24
+ const ACTIVITY_CONCURRENCY = 4;
25
+ /** `usage` 的两个榜单各回几条(客户端的文案按同一个数字算)。 */
26
+ export const USAGE_TOP_LIMIT = 5;
14
27
  export class SessionManager {
15
28
  ctx;
16
29
  constructor(ctx) {
@@ -65,11 +78,22 @@ export class SessionManager {
65
78
  }
66
79
  return undefined;
67
80
  }
68
- async describeSession(sessionId, dirs) {
81
+ /**
82
+ * 一个会话的完整只读事实。
83
+ *
84
+ * 两件事互不依赖(投影缓存、磁盘体积),所以并发拿:单条的耗时从"两次串行 IO"
85
+ * 降到"最慢的那一次"。`findWorkspaceOf()` 是纯内存遍历,不必并发。
86
+ *
87
+ * `active` 由调用方注入(快照会先把整批活动判定并发算完)——`sessionActivity()` 是
88
+ * 瀑布钩子,逐行现问会让"有没有别的插件挂了这个钩子"直接变成列表的延迟。
89
+ */
90
+ async describeSession(sessionId, dirs, active) {
69
91
  const info = dirs.get(sessionId);
70
- const projection = await this.readProjection(sessionId);
92
+ const [projection, stats] = await Promise.all([
93
+ this.readProjection(sessionId),
94
+ info ? this.dirStats(info.dir) : Promise.resolve({ bytes: 0, files: 0 })
95
+ ]);
71
96
  const workspace = this.findWorkspaceOf(sessionId);
72
- const stats = info ? await this.dirStats(info.dir) : { bytes: 0, files: 0 };
73
97
  return {
74
98
  sessionId,
75
99
  title: projection.title ?? (workspace ? workspace.title : null) ?? sessionId,
@@ -80,99 +104,96 @@ export class SessionManager {
80
104
  slug: info ? info.slug : null,
81
105
  onDisk: Boolean(info),
82
106
  bytes: stats.bytes,
83
- files: stats.files
107
+ files: stats.files,
108
+ active
84
109
  };
85
110
  }
86
- async listTrash() {
87
- const root = trashRoot();
88
- let entries;
89
- try {
90
- entries = await fsp.readdir(root, { withFileTypes: true });
91
- }
92
- catch {
93
- return [];
94
- }
95
- const rows = [];
96
- for (const entry of entries) {
97
- if (!entry.isDirectory())
98
- continue;
99
- const dir = join(root, entry.name);
100
- let meta = null;
101
- try {
102
- meta = JSON.parse(await fsp.readFile(join(dir, META_FILE), "utf8"));
103
- }
104
- catch {
105
- meta = null;
106
- }
107
- const stats = await this.dirStats(dir);
108
- rows.push({
109
- sessionId: entry.name,
110
- title: typeof meta?.title === "string" && meta.title !== "" ? meta.title : entry.name,
111
- cwd: typeof meta?.cwd === "string" ? meta.cwd : null,
112
- slug: typeof meta?.slug === "string" ? meta.slug : null,
113
- // 与 describeSession 对齐:客户端按"原来的工作区文件夹"分组要用到这两个字段。
114
- workspaceId: typeof meta?.workspaceId === "string" ? meta.workspaceId : null,
115
- workspaceTitle: typeof meta?.workspaceTitle === "string" ? meta.workspaceTitle : null,
116
- deletedAt: typeof meta?.deletedAt === "number" ? meta.deletedAt : 0,
117
- bytes: stats.bytes,
118
- files: stats.files,
119
- restorable: typeof meta?.slug === "string" && meta.slug !== ""
120
- });
121
- }
122
- rows.sort((left, right) => right.deletedAt - left.deletedAt);
123
- return rows;
124
- }
125
- /** 当前归档 / 置顶 / 回收站的完整快照,附带目录位置。 */
111
+ /** 当前归档 / 置顶的完整快照,附带目录位置。 */
126
112
  async buildSnapshot() {
127
113
  const registry = this.registry;
114
+ // 目录索引(哪些 id 真的在磁盘上)是唯一的只读来源;registry 记账可能包含已经删掉的 id。
128
115
  const dirs = await this.scanSessionDirs();
129
- const trashed = await this.listTrash();
130
- // 回收站条目**只**归回收站页签。删除时我们故意让 id 留在归档集合里(那是 DSH
131
- // 唯一"从所有分组面消失"的机制,见 trash.ts),所以这里必须把交集滤掉:
132
- // 否则同一个会话会同时出现在"已归档"和"回收站",而"已归档"那条还是没有目录的幽灵行。
133
- const inTrash = new Set(trashed.map((row) => row.sessionId));
134
- const archived = [];
116
+ const archivedIds = [];
135
117
  for (const sessionId of registry.archivedSessionIds) {
136
- // 顺带把"目录已经不在了"的 id 也滤掉:`archivedSessionIds` 是**只增不减**的记账
118
+ // 把"目录已经不在了"的 id 滤掉:`archivedSessionIds` 是**只增不减**的记账
137
119
  //(DSH 自己的注释:目录没了的条目照样留在集合里),而 DSH 的侧边栏只从会话清单里
138
120
  // 取行 —— 这种 id 在它那边本来就不显示。列出来只会得到一条 0 B、点不动的幽灵行。
139
- if (inTrash.has(sessionId) || !dirs.has(sessionId))
121
+ if (!dirs.has(sessionId))
140
122
  continue;
141
- archived.push(await this.describeSession(sessionId, dirs));
123
+ archivedIds.push(sessionId);
142
124
  }
143
- const pinned = [];
125
+ const pinnedIds = [];
144
126
  for (const sessionId of registry.pinnedSessionIds) {
145
- if (inTrash.has(sessionId) || !dirs.has(sessionId))
127
+ if (!dirs.has(sessionId))
146
128
  continue;
147
- pinned.push(await this.describeSession(sessionId, dirs));
129
+ pinnedIds.push(sessionId);
148
130
  }
131
+ // 活动判定:两个集合一起去重问一遍(同一个 id 可能既归档又置顶)。结果既写进每行的
132
+ // `active`,也平铺成 `activeSessionIds` 给客户端的跨页签批量用。
133
+ const activeIds = await this.activeAmong([...new Set(archivedIds.concat(pinnedIds))]);
134
+ // 逐条取事实:并发上限里跑,顺序仍按 registry 给的顺序(行不会整片跳位)。
135
+ const [archived, pinned] = await Promise.all([
136
+ mapLimit(archivedIds, FS_CONCURRENCY, (sessionId) => this.describeSession(sessionId, dirs, activeIds.has(sessionId))),
137
+ mapLimit(pinnedIds, FS_CONCURRENCY, (sessionId) => this.describeSession(sessionId, dirs, activeIds.has(sessionId)))
138
+ ]);
149
139
  let archivedBytes = 0;
150
140
  for (const row of archived)
151
141
  archivedBytes += row.bytes;
152
- let trashBytes = 0;
153
- for (const row of trashed)
154
- trashBytes += row.bytes;
155
142
  return {
156
143
  dshHome: dshHomePath(),
157
144
  version: LOCAL_VERSION,
158
- trashDir: trashRoot(),
159
145
  sessionsDir: sessionsRoot(),
160
146
  archived,
161
147
  pinned,
162
- trashed,
163
148
  totals: {
164
149
  archived: archived.length,
165
150
  archivedBytes,
166
- pinned: pinned.length,
167
- trashed: trashed.length,
168
- trashBytes
169
- }
151
+ pinned: pinned.length
152
+ },
153
+ activeSessionIds: [...activeIds]
170
154
  };
171
155
  }
156
+ /** 一批 id 里哪些正在跑(有上限地并发问 `workspace/session-activity`)。 */
157
+ async activeAmong(sessionIds) {
158
+ const flags = await mapLimit(sessionIds, ACTIVITY_CONCURRENCY, async (sessionId) => (await this.sessionActivity(sessionId)).length > 0);
159
+ const active = new Set();
160
+ sessionIds.forEach((sessionId, index) => {
161
+ if (flags[index] === true)
162
+ active.add(sessionId);
163
+ });
164
+ return active;
165
+ }
172
166
  // ── 变更操作 ─────────────────────────────────────────────────────────
173
167
  snapshot() {
174
168
  return this.buildSnapshot();
175
169
  }
170
+ /**
171
+ * **单个**会话的完整事实(侧边栏 hover 卡片用)。
172
+ *
173
+ * 入参复用 `ids`(线协议的 `idsOf()` 只接受字符串数组):取第一条,其余忽略 ——
174
+ * hover 卡片一次只问一个会话,把它做成批量端点没有意义。
175
+ *
176
+ * 客户端的 hover 会在 800ms 之后触发、且**每次划过都会问**,所以这里刻意只做
177
+ * `scanSessionDirs()` + 单条 `dirStats()`,不做全量快照;端到端的节流由客户端那份
178
+ * 带 TTL 的缓存负责(`src/client/detail.ts`)。
179
+ */
180
+ async describe(ids) {
181
+ const sessionId = ids[0];
182
+ if (typeof sessionId !== "string" || sessionId === "")
183
+ throw new Error("缺少会话 id");
184
+ const registry = this.registry;
185
+ const archived = registry.archivedSessionIds.includes(sessionId);
186
+ const pinned = registry.pinnedSessionIds.includes(sessionId);
187
+ const dirs = await this.scanSessionDirs();
188
+ if (!dirs.has(sessionId))
189
+ throw new Error("找不到这个会话(日志目录不在磁盘上)");
190
+ const row = await this.describeSession(sessionId, dirs, (await this.sessionActivity(sessionId)).length > 0);
191
+ return {
192
+ ...row,
193
+ archived,
194
+ pinned
195
+ };
196
+ }
176
197
  /** 逐个执行,单项失败只记录在 results 里,不影响其余项。 */
177
198
  async each(ids, action) {
178
199
  const results = [];
@@ -213,18 +234,107 @@ export class SessionManager {
213
234
  return [];
214
235
  }
215
236
  }
216
- // ── 危险操作:实现在 trash.ts(那边有完整的事故注释) ─────────────────
217
- trash(ids) {
218
- return trashSessions(this, ids);
237
+ // ── 危险操作:物理删除,实现在 delete.ts(那边有完整的事故注释) ─────
238
+ delete(ids) {
239
+ return deleteSessions(this, ids);
219
240
  }
220
- restore(ids) {
221
- return restoreSessions(this, ids);
241
+ /** 复制路径 / 在资源管理器中显示(实现在 `open.ts`,那边有完整的路径校验注释)。 */
242
+ open(ids, target) {
243
+ return openSession(this, ids, target);
222
244
  }
223
- purge(ids) {
224
- return purgeSessions(this, ids);
245
+ /**
246
+ * 磁盘占用视图(只读):整个 `<DSH_HOME>/sessions` 的占用合计 + 两个榜单。
247
+ *
248
+ * 与 `buildSnapshot()` 的关系:两者都读同一批目录,`dirStats()` 的 mtime 缓存让重复的
249
+ * 目录遍历只有第一次真的走磁盘;快照刚刚跑过的那批(归档 / 置顶)在这里直接命中缓存。
250
+ *
251
+ * 为什么要有这条端点而不是让客户端从快照里算:快照只有两个集合,**普通会话**(正在用的
252
+ * 那些)根本不在里面 —— 而"哪些会话最占地方"恰恰要问全部。
253
+ *
254
+ * 聚合按会话**当前所在的文件夹**(投影缓存里的 cwd):与列表分组同一套键,用户看到的
255
+ * "工作区"是同一个东西。
256
+ */
257
+ async usage() {
258
+ const registry = this.registry;
259
+ const dirs = await this.scanSessionDirs();
260
+ const onDiskIds = [...dirs.keys()];
261
+ const activeIds = await this.activeAmong(onDiskIds);
262
+ const rows = [];
263
+ const push = (row) => {
264
+ rows.push(row);
265
+ };
266
+ await mapLimit(onDiskIds, FS_CONCURRENCY, async (sessionId) => {
267
+ const described = await this.describeSession(sessionId, dirs, activeIds.has(sessionId));
268
+ push({
269
+ sessionId,
270
+ title: described.title,
271
+ cwd: described.cwd,
272
+ workspaceTitle: described.workspaceTitle,
273
+ bytes: described.bytes,
274
+ files: described.files,
275
+ createdAt: described.createdAt,
276
+ archived: registry.archivedSessionIds.includes(sessionId),
277
+ pinned: registry.pinnedSessionIds.includes(sessionId),
278
+ active: described.active
279
+ });
280
+ });
281
+ let totalBytes = 0;
282
+ const groups = new Map();
283
+ for (const row of rows) {
284
+ totalBytes += row.bytes;
285
+ const key = groupKeyOfRow(row);
286
+ const group = groups.get(key);
287
+ if (group === undefined) {
288
+ groups.set(key, { key, title: groupTitleOf(row), count: 1, bytes: row.bytes });
289
+ }
290
+ else {
291
+ group.count += 1;
292
+ group.bytes += row.bytes;
293
+ }
294
+ }
295
+ // 榜单按"体积 / 时间"取前 N 条:客户端只画这几条,"最占地方"也不该把 191 行都发过去。
296
+ const largest = [...rows].sort((left, right) => right.bytes - left.bytes).slice(0, USAGE_TOP_LIMIT);
297
+ const oldest = [...rows]
298
+ // 没有创建时间的沉底(`createdAt: null`):`0` 会让它们排到最前面,看起来像"最老的"。
299
+ .sort((left, right) => (left.createdAt ?? Number.POSITIVE_INFINITY) - (right.createdAt ?? Number.POSITIVE_INFINITY))
300
+ .slice(0, USAGE_TOP_LIMIT);
301
+ return {
302
+ totalSessions: rows.length,
303
+ totalBytes,
304
+ groups: [...groups.values()].sort((left, right) => right.bytes - left.bytes),
305
+ largest,
306
+ oldest
307
+ };
225
308
  }
226
309
  /** 更新检查:只读,结果在模块级缓存(见 update.ts)。 */
227
310
  checkUpdate(force) {
228
311
  return checkForUpdate(force);
229
312
  }
230
313
  }
314
+ /**
315
+ * 聚合用的分组键:与客户端 `groups.ts` 的 `groupKeyOf()` **同一套语义**(cwd 归一化后优先,
316
+ * 其次 workspaceId,最后"未知目录")。两边各写一份是因为客户端那份在浏览器里、这份在宿主,
317
+ * 但规则必须一致 —— 否则"磁盘占用"里的工作区名会和列表分组对不上。
318
+ */
319
+ function groupKeyOfRow(row) {
320
+ if (typeof row.cwd === "string" && row.cwd.trim() !== "") {
321
+ return `cwd:${row.cwd
322
+ .trim()
323
+ .replace(/[\\/]+$/, "")
324
+ .replace(/\\/g, "/")
325
+ .toLowerCase()}`;
326
+ }
327
+ return "unknown";
328
+ }
329
+ /** 分组标题:workspaceTitle 优先,其次路径尾段,最后交给客户端填"未知目录"。 */
330
+ function groupTitleOf(row) {
331
+ if (typeof row.workspaceTitle === "string" && row.workspaceTitle !== "")
332
+ return row.workspaceTitle;
333
+ if (typeof row.cwd === "string" && row.cwd !== "") {
334
+ const parts = row.cwd.split(/[\\/]/).filter((part) => part !== "");
335
+ if (parts.length > 0)
336
+ return parts[parts.length - 1];
337
+ return row.cwd;
338
+ }
339
+ return "";
340
+ }