# 实现思路
面向改这个插件的人(人和代理)。**怎么用**见 [README.md](README.md),这里是**为什么这么写**:
设计决策、两半怎么拼起来、以及每条约束背后踩过的坑。与代码冲突时以代码为准。
源码只有两个文件,一个包两半:[index.js](index.js)(Host 半边,六条路由)与
[client.js](client.js)(浏览器半边,编辑器 + 「文件编辑」面板)。
## 一页总览
```
Harness 启动(profile 作用域)
└─ index.js 的 apply(ctx)
└─ 按 ENDPOINTS 表注册六条精确路由:
write / delete / mkdir / rename / copy / upload
└─ 每次变更:POST → route(ctx, req, res, endpoint)
├─ rejected() Connection 信任围栏(不可信 Host/Origin、未认证)
├─ 媒体类型 / 请求体 / 字段校验(endpoint.parse)
├─ requestLifetime() 断连 + 30s 墙钟 → AbortSignal
├─ writeWorkspaceFile() ← 唯一走 ctx.fs 的一条
│ ├─ resolveSessionRoot() 会话身份 → 工作区根
│ ├─ lstat 最终节点必须是普通文件(符号链接一律拒)
│ ├─ resolve + contains 路径围栏:必须落在会话工作区内
│ └─ ctx.fs.writeText() 带沙箱策略与版本闸的原子写
└─ delete / mkdir / rename / copy / upload ← 五条走 node:fs
├─ guardMutation() 会话根 → resolve/contains → 自己判 read-only
├─ entryNoFollow() lstat:符号链接一律拒
├─ requireParentDirectory() 目标父级必须存在且是目录
├─ delete:ctx.fs.stat().version 比对 expectedVersion → 409 stale-version
│ unlink / rmdir / rm -r(recursive 由客户端显式确认后才带)
├─ mkdir: mkdir(EEXIST → 409 target-exists)
├─ rename: 源存在 + 目标不存在 + 不搬进自己子树 → rename(2)
├─ copy: copyFile(COPYFILE_EXCL)(内核级排他,不覆盖)
└─ upload: writeFile(data, { flag: 'wx' })(base64 解码后的二进制,只建新文件)
浏览器加载(bundle 由 dsh-client-modules 从磁盘现读)
└─ client.js 的 factory → apply(ctx)
├─ locale.register(NS, { zh, en })
├─ documentPreviews.register × 2 默认档(extension)/ 可选档(builtin)
├─ slots.register × 2 sidebar.right.tab.document 的两份正文
├─ sidebarRightTabs.register kind: workspace-editor(pattern 命中不了真实地址)
├─ slots.register sidebar.right.pane.tab 的编辑器正文
├─ slots.register sidebar.right.tab.document.actions 的「✎ 编辑」按钮(order 5)
├─ slots.register 同一槽位的「🗑 删除」按钮(order 6)
├─ sidebarRightTabs.register kind: workspace-editor-files(页类型,guide 卡片入口)
├─ slots.register sidebar.right.pane.tab 的「文件编辑」文件树面板
│ └─ 粘贴:内部剪贴板(模块级,复制 / 剪切)优先 → 复制 / 改名路由
│ 否则系统剪贴板里的文件(Ctrl/Cmd+V 的 paste 事件,或 navigator.clipboard.read)
│ → POST /workspace-editor/upload(base64 → 新文件)
└─ shortcuts.register workspace.editor.files(Ctrl/Cmd+Alt+E)
↓ 三个入口共用
EditorSurface:读盘 → 草稿 → 版本闸保存 → 冲突横幅
├─ 语法高亮层:textarea 底下那层可见文字(字透明)+ Custom Highlight API 上色
↓ 读走 ctx.remote.workspaceFiles.readBytes / list / stat / changes(官方只读接口)
↓ 写走 fetch('/workspace-editor/*')(本插件自己的六条路由)
```
两条通道刻意分开:**读**复用官方生成的 Remote 客户端(类型安全、不用自己造
`InvocationDescriptor`),**写与删**走自己注册的精确路由。下面 D1 解释为什么写不能也走 Remote。
## 四条不可动摇的设计决策
### D1 写走自注册的 HTTP 路由,不新增 Remote 命名空间
官方 `@deepseek-ai/dsh-api-workspace-files` 是只读的
("The service exposes no mutation operation.")。看起来最自然的补法是新增一个 Typert Remote
命名空间 `workspaceEditor.write`,但**新增 Remote 方法需要构建期生成的
`InvocationDescriptor`** —— 客户端 `ctx.remote.$mount()` 只接受生成物,而本插件以 `link:`
方式装进 profile、**没有构建步骤**,走不通。
于是采用官方 `dsh-host-open-in-app` 对「UI 动作需要 Host 能力」时的同款做法:
`ctx.webServer.register()` 注册一条精确路由,用 **Connection 的信任围栏**挡住一切非本机认证
页面发来的请求。路由只发 JSON、只写文件,不返回任何可浏览的文档 —— 它不是「多开了一个网站
入口」,而是「给已有 GUI 会话加一个动作端点」。
**推论:浏览器半边不能用 `ctx.remote` 去调写接口**,只能用 `fetch`。这也是为什么写请求的
错误处理是**按 `code` 分支**的(`stale-version` → 冲突横幅;`not-observed` → 「文件已存在」),
而不是抛一个类型化异常。
### D2 三档入口,正文只有一份
「在右侧栏编辑」有三种进入方式,它们**不是三套实现**:
| 入口 | 注册点 | 为什么需要它 |
|---|---|---|
| 默认档渲染器 | `documentPreviews.register` + `sidebar.right.tab.document` | 数据 / 配置 / 纯文本类文件官方本来也只有只读预览,直接当默认视图 |
| 可选档渲染器 | 同上(`priority: 'builtin'`) | 官方已有更合适的只读渲染器(Markdown 排版、代码高亮 + 行号),编辑器只做「打开方式」里的备选 |
| 编辑器页签 | `sidebarRightTabs.register` + `sidebar.right.pane.tab` | 给「默认不进编辑器」的文件一个一键入口,且能顶掉预览页签 |
三者共用同一个 `EditorSurface`,差别只在 props(`variant`、`wrap`、`reloadKey` 与
`onLoaded` / `onFailed` 回调)。**改正文逻辑只需要改一处**;要加「第四种入口」时也照这个形状做。
### D3 「✎ 编辑」换的是页签,不是渲染器
预览用哪个渲染器由文档预览自己的 store 决定:
```js
selected = candidates.find((id) => id === state.rendererId) ?? candidates[0]
```
`state.rendererId` 只有它自己的「打开方式」下拉能写,外部插件没有公开入口 —— 所以
「原地把预览换成编辑器」**做不到**。右侧栏的**页签**是公开的(`ctx.sidebarRightTabs.register`
注册 kind,`ctx.sidebarRight.openResource(address, { kind, replaceTab })` 显式打开),
于是:
- 按钮用编辑器页签**顶掉**当前预览页签(同一个面板、同一个位置,不新增页签);
- 编辑器页签类型声明一个**命中不了任何真实地址**的 pattern
(`dsh-resource://workspace-editor/**`),于是它永远不参与自动排名 —— 普通打开文件仍旧是
官方预览,编辑器只在按钮 / 显式打开时出现;
- 显式指定 kind 的打开**不看 pattern**(侧边栏的 `claim(address, kind)` 只查 `canOpen`),
所以「命中不了」不会挡住按钮。`canOpen` 仍然生效,它只放行 `dsh-resource://file/` 开头的地址。
按钮还要知道「顶掉哪张页签」:用 `ctx.sidebarRight.commandTarget(event.currentTarget)` 从按钮
所在的真实 DOM 位置问出面板与页签(浮动面板里问不到就退回「当前活动的停靠面板」,在旁边开)。
### D4 「文件编辑」是一个**页签类型**,不是一个新槽位
右侧栏没有「注册一个工具面板」这种 API,官方「工作区文件」走的是公开两步:
1. `ctx.sidebarRightTabs.register({ id, kind, priority, title, guide })` —— 声明一个**页类型**
(没有 `patterns`,所以它不参与任何地址的自动排名,只能按 kind 打开:guide 卡片、快捷键、
代码里的 `openTab(kind)`);
2. `ctx.slots.register({ name: 'sidebar.right.pane.tab', key: <那个 id> }, Body)` —— 该类型的
正文。
本插件的「文件编辑」照做一遍(kind `workspace-editor-files`,id `@dongasai/dsh-editor/files`),
外加一条 `ctx.shortcuts.register`(`workspace.editor.files`,默认 `Ctrl/Cmd+Alt+E`)。
**不去改** 官方 `ui-sidebar-files`(那是别人的 store 与交互),也不新增槽位(slot 是产品
自己的声明面,插件只能往已声明的槽位里注册)。
面板与官方文件树的差别只有一处是本质的:**它自带写操作**。官方面板只调
`workspaceFiles.list` / `changes` 这类只读 Remote,本插件面板的每个变更都打到自己的六条路由上。
## Host 半边:一次保存的完整路径
```
route(ctx, req, res, endpoint)
├─ rejected(ctx, req, res) ctx.connection.requestRejection → 403/401,直接结束
├─ req.method !== 'POST' → 405 + allow: POST
├─ mediaTypeOf(req) !== application/json → 415 unsupported-media-type
├─ requestLifetime(req, res) 断连 或 30s 墙钟 → AbortSignal
├─ readBody(req) 8 MiB 上限,超限先 req.resume() 再抛 413
├─ endpoint.parse(value) 六条路由各自的字段校验
└─ endpoint.run(ctx, input, signal)
├─ writeWorkspaceFile() ← 唯一走 ctx.fs 的一条
│ ├─ resolveSessionRoot() 活会话 header.cwd → 存档头 → 404
│ ├─ lstat 存在且不是 file → 409 not-regular-file
│ ├─ resolve + contains 不在工作区内 → 403 outside-workspace
│ ├─ policy = sandboxPolicy.resolve({ session }) ← 交给 ctx.fs 执行
│ ├─ intent = createIfAbsent | replaceIfVersion | undefined
│ └─ ctx.fs.writeText(target, content, intent, signal, policy)
└─ delete / mkdir / rename / copy / upload ← 五条走 node:fs
├─ guardMutation() 会话根 → resolve/contains → 自己拒绝 read-only
├─ entryNoFollow() 路径本身是符号链接 → 409 not-regular-file
├─ requireParentDirectory() 父级不存在 → 404,父级不是目录 → 409
├─ delete:stat(target).version !== expectedVersion → 409 stale-version
│ unlink | rmdir | rm -r(recursive 只有客户端确认过才带)
├─ mkdir: mkdir(EEXIST → 409 target-exists)
├─ rename:目标已存在 → 409 target-exists;搬进自己子树 → 409 invalid-target
├─ copy: copyFile(..., COPYFILE_EXCL)(EEXIST → 409 target-exists)
└─ upload:base64 解码 → writeFile(..., { flag: 'wx' })(EEXIST → 409 target-exists)
```
六条路由**共用同一个外壳**(围栏 → 方法 → 媒体类型 → 生命周期 → 校验 → 落盘 → 应答),差异
只在 `ENDPOINTS` 表里的三项:路径、校验器、落盘实现,外加 500 时用的失败码
(`write-failed` / `delete-failed` / `mkdir-failed` / `rename-failed` / `copy-failed` /
`upload-failed`)。
外壳共用是刻意的:三道闸的顺序、断连语义、应答形状只有一份,改一处不会漏另一处。
几个刻意的选择:
- **错误按 `code` 判别,绝不看 message**(`FS_FAILURES` / `NODE_FAILURES` 两张表)。`FsError`
是官方包里的类,而本模块不能 import 官方包(见约束 1),所以只按 `error.code` 这个字符串判别;
表里没有的码一律 500 并记日志 —— 宁可把未知故障显式暴露,也不要猜。
- **`lstat` 与 `resolve`+`contains` 两道都要**:前者保证最终节点不是符号链接(`lstat` 不跟随),
后者保证解析后的真实路径没跑出工作区。少任何一道,软链就是一个写穿 / 删穿口子。
- **`session === undefined` 时 `sandboxPolicy.resolve()` 不带参数**:冷会话(只有存档头)拿不到
session 对象,此时用部署默认策略,路径围栏仍然按存档头的 `cwd` 生效。
- **`requestLifetime` 只在「应答没写完就断开」时才算客户端走了**(`!res.writableEnded`),
否则正常应答结束触发的 `close` 会把成功写入判成取消。
## 删除 / 改名 / 建目录 / 复制 / 粘贴上传为什么走 `node:fs`(以及它们的代价)
`ctx.fs` 的变更面**只有** `writeText` / `editText`;`FileSystem` 抽象类里没有删除、改名、建目录、
复制或**写二进制**的方法,官方 `workspaceFiles` Remote 也全是只读。要让 GUI 能整理文件,只有两条
路:改官方包(本插件是 `link:` 安装的第三方插件,不该也不会去改),或者在这一层用 `node:fs`。
于是有五条必须记住的代价:
1. **沙箱策略那道闸不再自动生效。** 写路径把 `ctx.sandboxPolicy.resolve({ session })` 交给
`ctx.fs.writeText`,由组合文件系统(`dsh-fs-sandbox`)在真正落盘前拒绝越界;`node:fs` 绕过了
整个 `ctx.fs`,这层保护没有了。所以五条路由都从 `guardMutation()` 过:解析同一份策略,并且
`policy.mode === 'read-only'` 一律 403 `denied` —— 这不是「顺手加的一层」,而是**补回**被绕过
的那道闸。`upload` 也一样:它虽然写的是「内容」,但走的是 `writeFile`,所以第三道闸必须自己判。
改这五条路径时,这一条和 `entryNoFollow()` 的软链检查都不能省。
2. **版本闸与落盘之间有一个毫秒级窗口。** 写入是原子的(`writeFileAtomic`),但 POSIX 没有
「inode 一致才 unlink / rename」的系统调用:删除先 `ctx.fs.stat` 比对版本令牌,紧接着 `unlink`;
改名先判「目标不存在」,紧接着 `rename(2)`(它会覆盖已存在的目标)。如果另一个进程正好落在
窗口里(典型的写法就是「写临时文件 + rename」),被删掉 / 被覆盖的会是**新**那份。窗口极小
(两个相邻的 syscall),而且**用户点删除的意图本来就是「删掉这个路径上的条目」**,所以接受这个
残余风险,不去做「先改名再校验」的花活(改名本身会改 ctime,版本令牌就不再可比)。
**复制与粘贴上传没有这个窗口**:`copyFile(..., COPYFILE_EXCL)` 与 `writeFile(..., { flag: 'wx' })`
让内核来判「目标必须不存在」。
真要更强,只能给 `ctx.fs` 层加变更接口 —— 那是官方包的边界,不在这里。
3. **目录的版本闸只盖到直接条目。** 目录的版本令牌是它自己的 inode 元数据(`mtime`/`ctime`),
直接子项增删会变、子目录**内部**的改动不会变。所以「递归删除」确认之后,如果有人在某个子目录
深处改了东西,宿主是看不到的。这是刻意的取舍:要盖住整个子树就得先递归 stat 一遍(成本随
目录规模爆炸),而删除的语义本来就是「把这个目录删掉」。
4. **errno 自己翻译。** `node:fs` 抛的是 Node 的 `Error`(`code: 'ENOENT'` / `'EEXIST'` /
`'ENOTEMPTY'` 这类),不是 `FsError`;`NODE_FAILURES` 把它们映射到与 `FS_FAILURES` 同一套线上
码(`not-found` / `target-exists` / `not-empty` / `invalid-target` / …),映射不到的走各自路由的
500(`delete-failed` / `mkdir-failed` / `rename-failed` / `copy-failed` / `upload-failed`)。
5. **base64 校验不能用大正则。** `upload` 的正文是 base64,第一版校验写成
`/^(?:[A-Za-z0-9+/]{4})*…$/` —— 在**正好 4 MiB** 的载荷上,V8 的 regexp 栈直接被这个量词撑爆,
抛 `RangeError: Maximum call stack size exceeded`,被 500 兜成 `upload-failed`。探针里真踩到,
现在改成一次线性字符扫描(先判长度能被 4 整除、再逐字符判字母表与结尾的 `=`)。大输入的校验
一律按这个教训写:**不要拿大量词去套几 MB 的字符串**。
**改名、复制与粘贴上传刻意不带版本闸**:它们不改已有内容,用户点它的意思是「换名字 / 拷一份 /
把剪贴板里的东西放进来」。它们要防的是「把别人的东西顶掉」,所以卡的是「目标必须不存在」——
复制与粘贴上传做成内核级排他,改名是预检加窗口(见代价 2)。删除才需要版本闸,因为它是唯一会
丢内容的操作。
## 「文件编辑」面板:浏览器侧的设计
面板是一个**页类型**(见 D4),正文 `FilesTree` 的状态都在组件里,没有自建 store —— 只有一处
刻意的例外:**剪贴板**。它跟草稿一样是「跨重挂」的界面状态(关掉面板再打开不该丢),所以放在
模块级的 `filesClipboard` 里,组件只持有它的渲染镜像;它记的是一条 `{ mode, path, name }`,
路径真不真由 Host 的三道闸兜底(换会话后粘贴会被 403 `outside-workspace` 挡下,不会贴错地方)。
- **懒加载**:一层目录一次 `workspaceFiles.list(sessionId, 绝对路径, signal)`。用**绝对路径**
而不是相对路径,跟官方文件树保持一致(`changes` 的目标绑定也是按绝对路径记的)。
- **实时**:每个加载过的目录都订阅一次 `remote.$stream({ open: () => workspaceFiles.changes(...) })`。
用受管的 `$stream` 而不是裸 `changes()`,断线由 Remote 自己重连;变更帧按目录 120ms 防抖合并成
一次重列(一次保存会连着来好几帧)。代次号(`generations`)保证晚到的旧结果不会覆盖新结果。
- **排序**:后端给的是稳定的名字序,面板再按「目录在前、同类按名字」排一次 —— 只影响展示。
- **输入行**:新建 / 改名都是行内 ``,回车提交、Esc 取消、失焦也提交。提交做了幂等
(`draft.done`):回车之后输入框卸载,部分浏览器还会补一次 `blur`,不拦就会重复发一次请求。
- **删除的两级确认**:点垃圾桶先 `stat` 拿版本基线(目录再 `list` 一次拿条目数),确认条按
「文件 / 空目录 / 非空目录」给不同文案与按钮;非空目录的按钮才是「递归删除」,请求才带
`recursive: true`。`stale-version` → 只提示、按钮换「仍然删除」(这一次不带版本闸);
`not-empty`(确认期间目录里多了东西)→ 升级成递归确认并**丢掉已经失效的版本基线**。
- **切走不卸载(`keepMounted`)**:树的状态(展开层、滚动、光标)全活在组件里(对应约束 15),
而页签一切走正文就会卸载 —— 点一个文件进编辑器页签、再切回来只剩根一层(用户报过)。
官方页签类型有 `keepMounted` 选项,语义是「访问过一次就把正文留住,靠隐藏 + `inert` 切走」,
展开状态与滚动位置因此都还在;编辑器 / 图片页签**不**跟着开(每个页签都持有一份正文与高亮层,
草稿另有模块级 `drafts` 兜着,没必要为滚动位置常驻)。
- **拖拽**:HTML5 拖放,`dragRef` 记住被拖的路径。落点按「这一行代表哪个目录」定:
**目录行 = 它自己,文件行 = 它所在的目录**(VS Code 同款)。文件行必须算落点 —— 根目录
**自己没有行**,树一长、正文里连空白都没有,只认目录行的话「搬到根」就无从下手。
行的 `onDragOver` / `onDrop` **一律 `stopPropagation`**:指针落在某一行上时落点归那一行,
事件再冒到正文就会被当成「根落点」(高亮跳到根上、松手直接搬去根)。
拖到自己身上、拖回原地(落点目录 = 来源目录)、把目录拖进自己的子树(`isInside`)都不接受,
也不发请求。另外**一开始拖就亮一条「工作区根目录」落点条**
(`data-workspace-editor-files-drop-root`):根没有行,这条是根唯一**看得见**的落点,
丢上去就是搬到根;拖的东西本来就在根下时不亮(那是空操作)。
- **粘贴(两个来源,一场两路)**:**内部剪贴板优先**。
1. **树内剪贴板**:「复制到剪贴板」/「剪切」/ Ctrl+C / Ctrl+X 写模块级 `filesClipboard`。
粘贴时复制走 `POST /copy`(重名先按已列出的条目编号,剪贴板**保留**,可以连着贴几处)、
剪切走 `POST /rename`(重名直接报「目标已经存在」—— 移动悄悄改名比报错更糟),贴成功后清空。
2. **系统剪贴板里的文件**:键盘 Ctrl/Cmd+V 走 `paste` 事件(`clipboardData` 里的 `File` 带
**真文件名**),菜单 / 工具条上的「粘贴」只能走 `navigator.clipboard.read()`(那个 API
**不携带文件名**,只能按 MIME 起名 `pasted-<时间戳>.<扩展名>`)。两条路最后都打成
`POST /upload`:base64 → `writeFile(wx)` 建**新文件**,绝不覆盖。
`paste` 事件只处理文件:纯文本一律不 `preventDefault`(往这棵树里贴文本没有去处),剪贴板里
的**目录**明确说不支持 —— 浏览器拿得到目录入口、拿不到它的内容(这条也写进了 README 的已知边界)。
- **图片预览是一个页签类型,不是一个文档渲染器**:官方已经有图片渲染器(`IMAGE_MEDIA_TYPES` 八种
后缀 + 共享缩放视图),注册一个默认档渲染器会把它顶掉 —— 而这里要解决的**不是**「官方看不了
图片」,而是「本插件的文件树点图片会落到文本编辑器上」(读盘按 UTF-8 严格解码 → 「这个文件不是
UTF-8 文本」)。所以走与编辑器页签同一套做法:`sidebarRightTabs.register` 一个新 kind +
`sidebar.right.pane.tab` 正文,pattern 同样命中不了真实地址(图片的自动打开仍归官方),只有
文件树显式点名 kind 才开。三个实现细节:①字节 → `Blob` + `URL.createObjectURL`,换文件 / 卸载
`revoke`(不释放就一直占着这份字节),`createObjectURL` 不可用时退化成失败提示而不是白屏;
②后缀表比官方宽(`tiff` / `heic` 也认),浏览器解不开的格式由 `
` 的 `onError` 兜住并给出
「重试 / 用编辑器打开」,比回一句「不是 UTF-8 文本」诚实;③文件信息(字节数、`宽×高`、MIME)
来自 `naturalWidth/naturalHeight` 与后缀,不做额外探测。
- **预览工具栏的「✎ 编辑」对图片不出现**:这个按钮是**我们的**(注册在
`sidebar.right.tab.document.actions`),对图片点它只会得到那个「不是 UTF-8」的失败页;
「🗑 删除」照旧给图片(删除对任何文件都成立)。
- **编辑器右键菜单里的同一组动作**:编辑器的菜单原本只有「剪切 / 复制 / 粘贴 / 全选 + 发送到 DSH」,
这一轮把「复制路径 / 复制相对路径 / 在终端打开」也加了进去。这三个动作**两个面共用同一份实现**
(工厂作用域里的 `copyPathText` / `copyPathToClipboard` / `directoryOfFile` / `openTerminalAt`),
各面只负责把结果渲染成自己位置的横幅 —— 它们是同一件事(「这个文件 / 条目的路径」与「在这个
目录开终端」),复制两份必然分叉。两个面共用的还有一次性提示的状态机 `useNotice`(成功自己消失、
失败粘住),只有剪贴板与终端服务本身是外部的。编辑器这一侧多两个细节:路径优先用读盘拿到的
`absolutePath`(渲染器入口给的可能是相对路径),而**文件所在目录**要用 `directoryOfFile()` 算 ——
路径里没有分隔符时不能把文件名当目录(那是 `cd` 到自己),要回工作区根;拿不到工作区根时
相对路径回落到绝对路径,不凭空造一个假的相对路径。
- **在终端打开**:官方 `remote.terminal.create` 只有 `shellPath`(**没有 cwd**),页签 params 也只有
`terminalId` / `shellPath` —— 侧边栏终端一律从会话工作区根起步,所以「在该目录打开」只能做成
「开页签 → 等 `state.writable` → `view.write("cd '<目录>'\r")`」。三个必须按官方实现来的点:
①`TerminalView.write()` 在**不可写时静默丢弃**(官方实现开头就是 `if (!state.writable …) return`),
抢跑等于什么都没发生,所以订阅 `view.state` 等 `writable === true` 且**只写一次**;
②页签身份只能从公开的 `ctx.sidebarRight.active()` / `tabsIn()` 现读(布局 store 是异步提交的),
所以是轮询取差集,取不到就报「没能打开」而不是猜一个 id;
③终端类型、`webTerminals` 都可能不在(部署裁剪 / 别的宿主)—— 前者先问
`ctx.sidebarRightTabs.entries()`,后者一律 `ctx.get('webTerminals')`(约束 20),拿不到就明说。
watcher 刻意**不随面板卸载取消**:开了终端之后面板很可能被聚焦的终端页签顶掉,而 cd 还没发出去;
它自己在「可写 / 失败·关闭 / 超时」三个出口收尾,失败不叠第二条横幅(终端页签自己会显示原因)。
- **复制路径 / 复制相对路径**:菜单项只做一件事 —— 把路径写进系统剪贴板。两个刻意的选择:
①「相对」是相对**这个面板的根**(= 会话 cwd),不是相对当前展开的目录,因为贴出去多半是给
终端或别的工具用,基准越稳定越好;根本身没有行,代码里那条 `path === root → '.'` 只是防御。
②成功走**一次性提示横幅**(`NOTICE_MS` 后自己消失,也能点掉),失败走**错误横幅** ——
这个动作的产物只有剪贴板,静默失败等于「点了没反应」,是最难排查的一类体验。
- **打开文件**:单击文件行 → `ctx.sidebarRight.openResource(absoluteFileAddress(path), { kind:
'workspace-editor', paneId })` —— 带上**本面板所在 pane**、不带 `replaceTab`,所以面板不会被
顶掉,同一栏里多一张编辑器页签(同一个文件已经有编辑器页签时按 reveal 语义聚焦它)。
- **页脚(插件信息)**:面板最底部固定一行 `标题 · v版本`,来源是 Host 的插件清单
(`listBundles()`,按包名认领自己那一行;标题走 `locale.resolveText` 解析本地化文本)。
**客户端不写死版本号** —— 写死就要跟 `package.json` 的 `version` 手动同步,迟早对不上;
读接口本来就走官方生成的 Remote(与工作区文件的读同一条路)。
**命名空间必须用 `ctx.get('remote.pluginManager')` 取**:它是 cordis 服务,没在 `inject` 里
声明就写 `ctx.remote.pluginManager` 会抛「cannot get property … without inject」(cordis 4.0.4
实测),而这个 catch 一吃掉就变成「页脚只剩包名」的静默失效;反过来把它写进 `inject` 又是硬依赖,
没有插件管理器的部署里整个插件都会卡在等待中不加载。所以:可选能力走 `ctx.get`,拿不到就兜底。
整条路径都是「拿不到就不显示版本」:没有插件管理器、远程门禁挡住、返回里没有这一行,页脚就只
显示包名,绝不让一行归属信息把面板搞坏。清单**读到才缓存**(一个页面只问一次),同屏多个面板
共用同一份结果;读失败不缓存,下次开面板再试。
浏览器侧对应删除的「两次点击 + 版本闸」:第一下点击抓一份当前版本(`workspaceFiles.stat`),第二下
才带着它发请求;Host 判不过就回 `stale-version`,UI 只提示、改成「仍然删除」让用户再点一次。
抓版本放在**第一次点击**而不是第二次,就是为了让闸门盖住「你正在犹豫」的这段真实时间窗。
### 光标与勾选是两个字段(多选)
`selected`(光标 / 锚点)与 `selection`(勾选集)**刻意不合并**:光标可以在没有任何勾选时单独存在
(工具条的「重命名选中行」、F2、Shift 范围选择的锚点都还要用它),而勾选集是「批量动作的作用域」。
合并成一个 `Set` 就得从「集合里哪个是锚点」反推光标,那才是真的两套语义挤在一个字段里。
两者也**各有各的视觉**:光标是一圈虚线(`data-anchor`),勾选是实心 ✓ + 行背景 + 左侧色带。合成一个
就说不清「F2 会改哪一行」与「删除会删掉哪些」。
- **行左侧的常显勾选框是主入口**。第一版只做了 `Ctrl/Cmd+点`,用户反馈「能多选,但没有选中效果」——
拆开看是两件事:① 选中态只靠 `--dsw-alias-interactive-bg-selected` 一条背景,而皮肤(本机装的
orca-link)可以把它改成和常态**没有区别**,于是状态正确、眼睛看不见;② 入口藏在一个需要预先知道
的快捷键里,等于没有入口(与本仓库那条「只有悬停才出现的按钮等于没有按钮」是同一类错误)。
现在勾选态是**三重独立信号**,其中勾选框与左侧色带都带硬编码兜底色 —— 皮肤改不出「看不见」。
- **勾选只由显式入口产生**:勾选框、`Ctrl/Cmd+点`(与勾选框共用 `toggleSelection`)、`Shift+点`、
`Ctrl/Cmd+A`。**普通单击只挪光标并清空勾选**(与 VS Code 一致)—— 否则「勾了 1 个」看不到批量
操作,而随便单击一下又永远撑出批量条,两边都不对。右键**不碰勾选集**(菜单项本来就作用于被右键
的那一行,看一眼菜单不该把批量作用域清掉)。
- **批量条门槛是 ≥1**:勾选框成了主入口之后,「只勾一个」是完全正常的用法,门槛设成 2 会让第一个
勾选看起来毫无反应。批量条最左那颗是全选框,与每行的勾选框**纵向对齐**(多选相关的东西全靠左)。
- **Shift 范围与 Ctrl+A 的坐标是「可见行」**:`walk` 渲染时顺手把行路径按顺序收集进
`visiblePaths`,范围选择就在这个数组上切片。所以折叠起来的子树、没展开的目录都不在范围里 ——
这与「所见即所选」一致,也意味着**改排序会改范围选择的落点**(同一个 Shift 点击选到不同的行)。
这是刻意的:排序改了,屏幕上的顺序就变了,范围当然跟着屏幕走。
- **勾选框与展开箭头都必须 `stopPropagation`**:它们在行内部,不拦冒泡的话点一下勾选框会顺手把
目录展开、把文件打开。这一类 bug 恰好是「探针的冒泡链坏掉」时**看不见**的(见 AGENTS 验证规范里
「探针会假绿」那一段)。
### 批量删除:一次确认、逐条版本闸、失败必须有出路
批量删除没有另开路由(复用单条的 `POST /workspace-editor/delete`),也没有「整批无条件」的口子:
- **先收敛选择集**(`pruneForBatchDelete`):被选中的目录已经会连内容一起删,它下面那些**也被选中**
的路径必须去掉,否则第二次请求会撞「路径已经不在了」;工作区根自己永远不删。
- **一次确认**(不是每条问一遍),但**逐条取版本基线**:版本闸是每条路径各自的令牌,删之前必须知道
「我看到的这一份」是哪一份。目录一律按递归删 —— 用户已经在确认条上看过「会连内容一起删」。
- **逐条串行**:一条失败不影响其余;也正因为串行,「正在处理哪一条」看得见,面板卸载时能立刻停下。
- **部分失败 → 失败项留在选择里 + 一条带「重试删除」的横幅**。刻意**不做**「整批仍然删除」:
一个能一次推平 N 个条目的按钮,比让用户逐条重试危险得多。重试会**重新取基线**,所以「确认期间
被改过」的那几条也能在用户看过之后删掉 —— 出路是有的,只是不给他一把万能钥匙。
### 排序放在渲染时,不放在读盘时
`levels[dir].entries` 存的是**后端给的原始顺序**,排序在 `walk` 里做(`sortEntries(entries, sort)`)。
理由很直接:换一次排序键要立刻重排,不能为它再读一次盘 —— 面板里换个顺序是很轻的动作,让它触发
N 个网络请求既慢又没道理。顺带一个好处:`sortEntries` 成了纯函数,可以整段截出来在 Node 里断言
(`/* tree:start */` 区,见 AGENTS 的验证规范)。
排序**不持久化**(活在组件里):它不是会话数据,重挂后回到默认(目录优先 + 按名字)不会丢任何东西;
而为了它引一层 localStorage 就要处理「键名 / 版本 / 清理」三件与它无关的事。
名字比较也刻意**不用 `localeCompare`**:它依赖运行时 locale,同一棵树在两台机器上可能给出不同顺序,
而顺序会被 Shift 范围选择当成坐标用 —— 必须可复现(大小写不敏感 + 原始码点决胜)。
### 目录链折叠:行代表**链尾**,预读有预算且不订阅
一路只有一个真子目录的链折成一行(VSCode 的 compact folders)。两条语义上的决定:
- **行代表链尾**:标签是 `a/b/c`,那么改名 / 删除 / 新建 / 拖入都作用在 `c` 上(`chain` 的最后一
项就是这一行的 `path`)。这样「点开这一行看到的是 `c` 的内容」与「操作这一行就是操作 `c`」是
一致的;如果行代表链头,用户在一个写着 `a/b/c` 的行上点「新建文件」,文件却落在 `a` 里。
- **只用已经读过的层级**:没读过的目录不知道它有几个子项,猜着折会把两条不同的链显示成一行。
所以挂载后会对可见层级里还没读过的目录各探一次(`chainCandidates` + `preloadDir`),链一旦不是
「唯一子目录」就停;`preloaded` 记账保证收敛(每个路径只试一次,失败也不重试),
`TREE_PRELOAD_BUDGET = 64` 兜住病态深树。
- **预读的目录不订阅变更**:订阅只给展开着的目录(`watchDir`)。折叠标签因此可能在别处新建 / 删除
之后短暂过期 —— 这是**刻意**的取舍:为了几个字符的标签去 watch 一整棵树的每一层,会把句柄和
通知量放大一个量级。展开一次就会重新读,标签随即正确。
- 隐藏项(点开头)**不计入**「唯一子项」判定,与 VSCode 一致;隐藏项本身只淡显不过滤。
## 把文件与行号发到对话输入框:为什么这么做
编辑器正文里**右键 →「发送到 DSH 输入框」**,把「当前文件 + 选区所在行」变成
`@路径#L15` / `@路径#L15-20` 插进对话输入框。四个设计点:
**1. 写入口袋:官方 `InputActions`,两条路都试。** DSH 给插件的公开写入面是 `inputActions`:
`captureInsertion()` 拿「当前选区 + 草稿版本」的快照,`insertText(text, span)` 按这个快照 CAS
插入。它出现的位置有两处,本插件**都用**:编辑器自己槽位上的 prop(有就用,作用域最明确),
以及 `conversation.input.dock` 上那个渲染 `null` 的**哨兵**登记进模块级 `inputTargets` 的那一份
—— 官方语音输入、目标栏都在这个槽位拿它,所以它一定有。
**为什么不只信槽位 prop(白屏教训)**:slot catalog 列的是**类型并集**,某个渲染点可能只传了
子集。`sidebar.right.tab.document` 的正文实际拿不到 `useSessions`,而渲染期直接调用它的后果是
`TypeError` → React 卸载整棵子树 → **编辑器整块白屏**。这次真踩了,所以工作区根也不走 prop:
改成在事件里从 `ctx.sessions.list.getSnapshot()` 现读(比渲染期的 prop 还新),读不到就退回绝对
路径 —— 少一个相对化,绝不让整块正文挂掉。
**追加子元素时别把括号提前闭合(第二个白屏)**:往 `return h('div', {...}, 子元素…)` 的尾部追加
内容时,多打一个 `)` 会把整段变成**逗号表达式** —— `return h(...), menu && h(...)` 的返回值是
最后一个 `&&`(菜单关着就是 `false`),React 渲染 `false` 等于什么都不画:**白屏、控制台没有
任何报错**。`node --check` 只验语法,这种语义错误它看不见;要用 acorn 确认那个 `return` 仍是
`CallExpression` 才算数。两条教训都固化在 AGENTS.md 约束 19。
**2. 纯文本而不是芯片。** 带结构化引用的插入(`slash/input-insert-reference`)只对内通过 scoped
event 开放,不跨插件边界;而公开面只有 `insertText`。所以 `@路径#L行号` 以普通字符落进输入框,
`#L` 语义交给模型侧解析 —— 这与 Harness 系统提示里规定的 `@…#L` 引用写法一致,所以仍然「认」。
引用在**右键那一刻**就算好并缓存进菜单状态:点菜单不会动选区,先算一次还能让菜单项的 title 直接
显示「将要插入什么」。
**3. 尾随空格是安全相关,不是排版。** `@` 触发检测(`activeAtToken`)要求触发 token 紧贴光标才
算「正在输入」。插入的文本如果以 `#L15-20` 结尾、光标停在引用尾部,检测会认为用户在输 `@` 引用,
把 `@` 文件菜单唤出来 —— 用户得手动 Esc。末尾补一个空格,token 不再紧贴光标,菜单不会弹。
**4. 路径按 DSH 惯例相对化,失败要说出来。** 工作区内的绝对路径改写成相对路径(与官方
`relativizeToCwd` 同规则),工作区外保留绝对路径;路径带空白按官方 grammar 加引号
(`@"a b.html"#L15`),含控制字符 / 引号(grammar 表示不了)时这项禁用并说明原因。拿不到
`inputActions`(会话没起来 / composer 被接管)、插入被拒(正在提交、草稿刚变过)时都在编辑器里
亮横幅 —— 不静默失败,因为「以为发了其实没发」比报错更难排查。
右键菜单**替代原生文本框菜单**(`preventDefault`):原生菜单里没有「发送到 DSH」,而在原生菜单
旁边再挂浮层会出现两份菜单。替代的代价是剪切 / 复制 / 粘贴 / 全选要自己实现,所以照给了这四项;
「粘贴」读不了剪贴板(非安全上下文 / 权限被拒)时静默跳过,键盘快捷键不受影响。
## 浏览器半边:数据不丢的四条规矩
### 规矩 1:未保存草稿活过重挂
切标签、关掉编辑器页签再打开都会**卸载**正文组件(React 状态随之消失),所以草稿放在模块级
`drafts` 里,按 `${sessionId}\u0000${path}` 索引:
- 卸载时(`useEffect` 的 cleanup):脏则写进 `drafts`,干净则 `delete`;
- 挂载读盘后:有草稿就恢复草稿(连同「草稿基于哪个版本」),没有就用磁盘内容;
- 保存成功或主动丢弃时立刻删除。
它是**纯派生的 UI 缓存**,不是持久化:刷新页面就没了,而且只在浏览器内存里。
### 规矩 2:磁盘变化不覆盖草稿
渲染器模式下文件在磁盘上变了会推进 `content.revision`(owner 给的信号),正文据此重读。此时:
- 缓冲区**干净** → 直接换成新内容;
- 缓冲区**脏** → 只把新版本记进 `doc.diskVersion` 并亮「磁盘上的文件已被改动」横幅,
正文一个字都不动,由用户选「加载磁盘版本」还是「继续编辑」。
还有一条反直觉的(**别删**):读盘后**总是**把读到的磁盘版本回报给 `content.loaded(version)`,
**即使缓冲区是脏的**。文档预览的 owner 用这个版本号判断「磁盘变了没有」,若在脏的时候回报旧
版本,它的自动刷新会一直认为需要重读 —— 形成重读循环。
### 规矩 3:保存带版本闸,冲突要人点
保存时把「缓冲区基线对应的版本」(`doc.version`)作为 `expectedVersion` 送上去:
- 磁盘没变 → 写入成功,把返回的新版本同时记进 `version` 与 `diskVersion`;
- 磁盘变过 → `stale-version`,进冲突横幅,**只有**用户点「覆盖保存」才走 `force`
(此时请求里不带 `expectedVersion`,即无条件覆盖);
- `not-observed`(`create` 模式下文件已存在)走同一张横幅,文案不同。
`doc.version` 与 `doc.diskVersion` 是两个不同的东西:前者是**草稿的基线**(版本闸用),后者是
**磁盘现状**(展示与冲突判定用)。草稿恢复时两者可以不同,这正是冲突判定的依据。
### 规矩 4:删除要两次点击,抓版本抓在第一次
预览头部的「🗑 删除」是**唯一会毁掉数据**的入口,所以它的状态机比保存更保守:
- 第一下点击只把按钮变成「确认删除」,同时异步 `workspaceFiles.stat` 抓一份当前版本存进
`pendingRef`;这段等待期按钮显示「检查中…」并禁用,避免在基线还没到手时就能删。
- 第二下才发请求,带上那份版本。Host 判不过 → `stale-version`:只提示「文件已变更,未删除」,
按钮换成「仍然删除」(此时请求不带 `expectedVersion`,即无条件删除)。
- 确认态与冲突态 **8 秒自己过期**(`setTimeout` + 依赖 `state.phase` 的 effect):一次误点不该
留下一个随时会删文件的按钮。读写请求都可能在页签卸载后才落地,所以一律先看 `aliveRef`。
- 删成功就 `ctx.sidebarRight.closeTarget(点击时捕获的 target)`:文件没了,这张预览没有内容可看。
页签已经换过(返回 `stale`)时不硬关,留在原地显示「已删除」。失败态也给一个「取消」出口
(不给出口的话按钮会消失,用户连重试都点不到 —— 这是探针跑出来的一个真 bug)。
版本抓在**第一次**点击而不是第二次,是为了让闸门盖住「用户在犹豫」的那段真实时间窗:
如果抓在第二次点击的瞬间,闸门就退化成「stat 与 unlink 之间没人动过」,几乎等于没有。
## 状态条为什么要显示路径
用户提的需求是「此处增加路径」。三条实现上的决定:
- **口径复用 `relativizeToCwd` + `sessionCwd`**,不自己拼字符串:这两个函数已经是右键
「复制相对路径」的实现,状态条另算一套就会出现「菜单说相对路径是 `a.md`、状态条显示
`/work/a.md`」这种自相矛盾(约束 16)。顺带白拿两个边界:工作区外的文件自动回退成绝对路径、
拿不到 cwd 时原样显示。
- **缩写从头部截,而不是交给 CSS**:`text-overflow: ellipsis` 只会截**尾部**,而这条 bar 里最有
信息量的就是文件名 —— 截成 `…/dsh_edito` 等于没显示。所以 `shortenPath` 从头部吃掉、保住
`README.md`。已知退化:栏窄到连缩写后的字符串都装不下时,CSS 仍会再截一次尾部,此时以悬停
`title` 与 `[data-workspace-editor-status-path]` 为准(锚点值**永远是绝对路径**,与显示出的
相对路径分开,这样自动化断言不必关心栏宽)。
- **图片预览那一面一起加**:它的 bar 与编辑器同构,只有元信息没有路径,用户在同一棵树里点图片
会突然不知道看的是哪个文件。两面共用同一个锚点名 `data-workspace-editor-status-path`,
断言一次覆盖两处。图片那面算相对路径用 `owner`(`paneTarget` 解析出的归属会话)而不是
`sessionId` —— 与那面自己的三个菜单动作一致,跨会话查看时才不会把相对路径算错。
## 语法高亮:为什么是「高亮层 + Custom Highlight API」
编辑器正文是一个 `