# 数据目录 插件自己的全部状态都落在一个目录里。这个目录**不是** `~/.dsh` 的子目录,而是它的兄弟: ``` C:\Users\\.dsh ← DSH 自己的 C:\Users\\.dsh-session-explorer ← 本插件的 ``` 两个理由(`lib/paths.js:1-26`): 1. 回收站里装的是**整个会话目录**,几百 MB。塞进 `~/.dsh` 会让备份、迁移、清理的归属变得模糊。 2. 一个环境变量覆盖让自测指向一次性目录,测试永远碰不到真实数据。 ## 1. 目录内容 ``` / ├── tree.v1.json 文件夹、放置、workspace 顺序、快捷方式、置顶 ├── annotations.v1.json 标签、备注、收藏/待办标记、共享标签库 ├── settings.v1.json 跨浏览器的偏好(布局在 localStorage) ├── move-journal.v1.json 正在进行的跨工作区移动(见 §6.2) ├── .location 迁移后留下的指针(只有 copy 模式会留) ├── .tmp/ 原子写的临时文件,启动时只清 `*.tmp` ├── quarantine/ 读不出来的文档原件(`.corrupt-.json`) ├── trash/ │ ├── index.v1.json 回收站索引 │ └── / 被删会话的 payload(整个会话目录) ├── component/ 可选的 Electron 运行时(见 component.md) └── detached/ ├── window.json 独立窗口的几何与控制端口 └── chromium// helper 专用 Chromium profile ``` `detached/chromium/` 的 `` 是 `sha256(dataDir)` 的前 16 位十六进制 (`lib/detached/launcher.js:40`):必须给 helper 一个专属的 `--user-data-dir`, 和主应用共用 profile 会让两边的 Chromium 状态互相踩。 **侧车只存 DSH 不知道的东西。** 标题、cwd、时间戳、回合数、归档集、置顶集每次请求都从 DSH 读, 侧车删掉也不会丢会话——这是 `lib/store/documents.js:1-17` 写明的设计约束(源文档 S8)。 ## 2. 定位顺序(三级回退) `resolveDataDir()`(`lib/paths.js:293`)按顺序: | 优先级 | 来源 | 场景 | |---|---|---| | 1 | `DSH_SE_DATA_DIR` | 测试、显式覆盖 | | 2 | `<默认>/settings.v1.json` 的 `dataDir.current` | 一次已完成的迁移 | | 3 | `<默认>` 目录本身 | 首次运行 | `<默认>` 里的 `.location` 指针会被跟随(最多 8 跳,防止手改出的环卡住启动)。 这就是 `copy` 模式迁移后旧树还在磁盘上、下次启动不会读到旧副本的原因。 `DSH_SE_DATA_DIR` 是**校验过的**:解析结果若是文件系统根(`/`、`C:\`)、`~/.dsh` 本身或其内部, 或者反过来**包含** `~/.dsh`(家目录就是最常见的误填),都忽略并警告,回落默认值。 理由写在 `lib/paths.js:isUsableDataDir`:清空回收站会递归删掉数据目录下的 payload, 一个能包含 DSH 数据的数据目录会把"清空回收站"变成"删掉 DSH"。 同一条规则用在四个入口上:`DSH_SE_DATA_DIR`、已记录的 `dataDir.current`(`settings.v1.json` 是磁盘上的普通 JSON,手改过的值不能直接信)、`POST /datadir/migrate` 的 body、以及迁移目标目录。 ## 3. 四份侧车文档 `lib/store/documents.js:29` 的 `openDocuments()` 一次打开四份。每份的初始形状: | 文件 | 初始形状 | 说明 | |---|---|---| | `tree.v1.json` | `{ folders, placement, workspaceOrder, shortcuts, pins, sessionPlacement }` | `placement` 是 workspace 的显式顺序;`sessionPlacement` 是会话的**显示**归属覆盖 | | `annotations.v1.json` | `{ sessions: {}, tags: [] }` | `tags` 是共享标签库;`sessions[id]` 是单条注解 | | `settings.v1.json` | `defaultSettings()` | 形状的唯一出处是 `lib/domain/settings.js:74` | | `trash/index.v1.json` | `{ entries: {} }` | 见第 5 节 | `settings.v1.json` 的完整形状: ```jsonc { "shell": { "open": { "mode": "panel" }, // panel | detached "modal": { "sizing": "fixed", // fixed | percent "size": "md", // sm | md | lg | xl "percent": "p70", // p50 | p70 | p85 | p100 "dim": 0, // 0 – 0.8,默认无遮罩 "closeOnBackdrop": true }, "window": { "alwaysOnTop": false, "bounds": null } // 独立窗口记住的几何 }, "list": { "autoCollapse": false, "showArchived": true, "collapseSubagents": true, // 折叠子智能体:开关,默认折叠 "hideBlank": true, // 过滤空会话:开关,默认过滤 "dateFormat": "date", // date | relative,详细日期 vs 距今时间 "sizeFormat": "auto" }, // auto | kb,自动转换 vs 固定 KB "dataDir": { "current": null } } ``` 三条硬约束写在这个文件里:`dim` 默认 0 且上限 `MAX_DIM = 0.8`(面板在壁纸上要看得清); 归档是视觉降权而不是隐藏,所以 `showArchived` 默认 `true`; 默认打开方式是浮窗,因为它不会失败——独立窗口依赖可能没装的组件。 `collapseSubagents` 以前是 `subagents: "collapsed" | "expanded"` 两选一,现在是一个开关: 两种状态本来就是全部选择,用词表把它做成模式只是让一个二元偏好看起来更复杂。旧文档里的 `subagents` 仍然被读:`expanded` 迁成 `false`,两者同时存在时以开关为准。 `dateFormat` 与 `sizeFormat` 是列表里那两列怎么写:`date` 写具体时刻,`relative` 写「3 天前」 (单位随距离走:分钟 → 小时 → 天 → 月 → 年,用 `Intl.RelativeTimeFormat`,语言跟着面板当前的 语言);`auto` 在 B / KB / MB / GB 里挑合适的单位,`kb` 一列只用一个单位,方便竖着比大小。 两者的默认值都等于改造之前的样子,所以升级不会改变任何人的列表。 `mergeSettings()` 只认已知键,未知键在归一化时被丢掉,而不是落盘等下个版本困惑。 ## 4. 原子写与乐观锁 `lib/store/sidecar.js` 保证两件事: - **不撕裂**:写临时文件(同卷的 `.tmp/`)→ flush → `rename` 覆盖目标。崩溃只会留下旧文件, 不会留下半个 JSON。 - **不互相覆盖**:每次成功写入 `revision += 1`。客户端带着它看到的 revision 来, 不匹配就 `revision-conflict`,而不是静默覆盖别人刚做的改动。 读损坏(不是 JSON 对象)时文件被改名成 `.corrupt-.json` 移进 `quarantine/`, 然后以空文档重建——**不会**因为一个坏文件让插件起不来。 放在 `quarantine/` 而不是 `.tmp/`:`.tmp/` 每次启动都会清空,那份"留着给人手工恢复"的原件 会被自己删掉。启动时只清 `.tmp/` 下的 `*.tmp`(原子写的残留),`*.corrupt-*` 会被搬进 `quarantine/`。 ## 5. 回收站 `lib/host/trash.js` 做的就是文件管理器做的事: | 操作 | 动作 | |---|---| | 删除 | `rename ///` → `/trash//` | | 恢复 | rename 回去 | | 清空 | `rm -rf` payload 目录 | 跨卷时 rename 退化成 copy-then-delete,其余流程不变。 **索引条目**(`lib/domain/trash.js:95`): ```jsonc { "sessionId": "01J…", "trashId": "k3f9ab12", "title": "…", "cwd": "…", "workspaceId": "…", "workspaceTitle": "…", "sizeBytes": 1234567, "createdAt": 1730000000000, "originalLogPath": "///session.jsonl", "deletedAt": 1730000000000, "pending": true // 只在写入过程中短暂存在 } ``` `title/cwd/workspaceId/…` 是**快照**而不是镜像:payload 已经不可读了,这几项描述的是它进回收站 那一刻的样子。 **崩溃安全**:索引条目在 payload 移动**之前**写入并标记 `pending`。两者之间崩溃只会留下 "pending 但 payload 还在原地"——可对账,不是丢会话。下次启动的 `reconcileTrash` 收尾: payload 已经到位 → 升级为 ready;payload 从未离开 → 删掉这条记录。 **认领**:写 `pending` 这一步同时充当互斥锁。两个窗口同时删同一会话时,后到者在文档锁里看见 条目已存在就放弃(记 `skipped`),不会各生成一个 trashId——否则后到者的补偿会删掉先到者的 条目,留下一个没有索引指向的 payload,既恢复不了也清不掉(`lib/host/trash.js:218-234`)。 **恢复落点**(`projectDirFor()`,`lib/host/trash.js:255`)是三级阶梯: 1. 记录里的原项目目录,不存在就重建; 2. 同一 workspace 下还有会话的目录(让恢复的会话落在邻居旁边); 3. 任何一个活的项目目录。 第 3 级就是"未分组"能当安全落点的原因:workspace 成员关系存在 DSH 的 registry 里, 一个不再属于任何 workspace 的 id 自动出现在未分组。 清空回收站时,指向已删会话的快捷方式会一并删除——引用不能活得比目标久。 **清空回收站 ≠ 清除数据。** 前者只处理回收站里的 payload(一次一个会话,可恢复的那部分仍然是 活的),后者是设置页「危险操作」里那一个按钮:四份侧车文档(含虚拟树 `tree.v1.json`)、`trash/` 里剩下的全部 payload、 `quarantine/` 与 `.tmp/` 一起清空(目录本身留下),删完 `invalidateMemory()` 抖掉面板的 memo。它不可逆,所以 host 先验用户打的字是不是 `DELETE`,再过一遍 `isUsableDataDir()`; 浏览器组件不在其中——那是安装,不是用户数据。详见 `docs/api.md` §4.12。 ## 6. 迁移 ### 6.1 数据目录迁移 `POST /datadir/migrate`(`lib/host/datadir.js:189`)按顺序做七件事,每一步都推进 `steps` 数组: | # | 步骤 | 说明 | |---|---|---| | 1 | 预检 | 复用 `inspect` 的全部校验:绝对路径、非根、非当前目录、可写、目标不含另一套安装 | | 2 | `api.migration` 上锁 | 期间所有非 GET 请求返回 `409 migration-in-progress` | | 3 | `copied` | `cp` 递归复制(同卷 `move` 也用 `cp`:它保留时间戳、源缺失会失败) | | 4 | `verified` | 校验目标可读、内容齐全 | | 5 | `reopened` | 关闭并重开读端,指向新目录 | | 6 | `recorded` | 把 `dataDir.current` 写进**新目录**的 `settings.v1.json` | | 7 | `pointer` / `pruned` / `removed` | `copy` 模式在旧目录留 `.location` 指针并清空其余内容(`pruned`);`move` 模式直接删旧目录(`removed`) | 任何一步失败都走同一个回滚(`lib/host/datadir.js:204` 的 `rollbackCopy`):重开旧目录、失效缓存, 再**只删这次复制写进去的东西**——目标目录原本不存在就整个删掉,原本存在(空目录)就只删 `carry` 写过的那些顶层条目。回滚不会删用户自己放进去的文件。 **旧目录在成功之前从不被碰**,所以一次失败的迁移只损失那次复制。 `mode` 不是用户选的,是**同卷性**决定的:同卷 `move`,跨卷 `copy`。 `move` 是唯一会删旧目录的模式。 `datadir-not-empty` 有两个来源(`lib/host/datadir.js:146-155`): - `OCCUPANCY_MARKERS = ["tree.v1.json", "trash/index.v1.json"]` 在目标里出现,说明那里是另一套安装; - 目标目录存在且**非空**。迁移是往目标里复制、失败时按 `carry` 写过的条目回滚,所以目标必须从空开始; 否则要么两套数据混在一起,要么回滚时误删用户的文件。 ## 7. 跨工作区移动的意图日志 一次移动是两次写:先把 payload 挪到目标 project 目录,再让 workspace 注册表知道这件事 (`lib/host/move.js` 文件头)。中间崩溃会留下"字节在目标、注册表还指着旧家"的状态——会话 在两边都看不到,正是回收站用 `pending` + `reconcileTrash` 挡掉的那种情况。 `move-journal.v1.json`(`lib/host/move-journal.js`)是移动版的同一道保险: ```json { "version": 1, "moves": [{ "sessionId": "…", "from": "…", "to": "…", "logName": "…", "workspaceId": "…", "header": { "id": "…", "cwd": "…" }, "startedAt": 1760000000000 }] } ``` - payload 离开原目录**之前**写进一条,注册表跟上之后删掉;写文件是 `temp + rename`,崩溃只会留下旧日志或新日志。 - 启动时 `reconcileMoves()`(`lib/host/move.js`,`lib/index.js` 里紧挨 `reconcileTrash` 调用)读剩下的条目: 目标目录在 → 补上索引与归属(把 `moveOne` 的后半段幂等重放一遍);目标目录不在 → payload 没走,直接丢弃条目。 - 修复抛错时条目**留在**日志里而不是被删:下次启动重试,而这条记录是"会话正处于两个家之间"的唯一证据。 ## 8. 相关文件 | 文件 | 职责 | |---|---| | `lib/paths.js` | 目录解析、`.location` 跟随、`dataPaths()` 的路径表 | | `lib/store/sidecar.js` | 原子写、revision、损坏隔离、`codedError` | | `lib/store/documents.js` | 四份文档的初始形状与迁移函数 | | `lib/domain/settings.js` | 设置形状的唯一出处、`mergeSettings`、`normalizeSettings` | | `lib/host/datadir.js` | `describeDataDir` / `inspectDataDir` / `migrateDataDir` | | `lib/host/trash.js` | 移动字节、维持索引、对账、恢复阶梯 | | `lib/host/move.js` | 跨工作区移动/复制、回滚、`reconcileMoves` | | `lib/host/move-journal.js` | 移动意图日志的读写(`recordMove` / `forgetMove` / `pendingMoves`) |