# 实现思路 面向改这个插件的人(人和代理)。**怎么用**见 [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」 编辑器正文是一个 `