The writable half of the right-sidebar document preview: edit and save any text file in place. Every save carries the version that was opened, so when the file changed on disk during the edit it stops and asks instead of overwriting. The preview toolbar gains an edit button and a two-step delete button. A separate File editor sidebar tool brings its own file tree: create, rename or move, copy, cut and paste (including a file taken from the system clipboard), delete, and drag to reorganize. Two renderer bands cover 107 plain-text, config and source extensions; the second is offered only as an open-with choice, leaving the shipped Markdown and highlighted-code views as the default. Mutations run through the plugin own trusted host routes, behind the connection fence, the workspace containment check and the sandbox policy.
Install
# from GitHub (first run asks for allowBuilds approval — follow the hint, retry)
dsh plugin --profile web add github:dongasai/dsh_editor
Any plugin you install runs third-party code with your own permissions — it can read your files, use your credentials, and reach the network, and tool approvals don’t sandbox it. GitHub-sourced plugins also run build scripts at install time — pnpm blocks those until you allow them, so an install can stop with ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED or ERR_PNPM_IGNORED_BUILDS; dsh prints the exact key to add under allowBuilds in your profile’s pnpm-workspace.yaml, and the install works on the next run. Allowing a build is a trust decision: only install sources you trust, and pin a commit (github:owner/repo#sha).
README
This plugin publishes its README in Chinese only.
在 DSH Web GUI 的右侧栏里直接把工作区文件改掉:官方文档预览注册的 Markdown / 代码 /
图片 / PDF / Office 渲染器全部只读,读文件的 Remote 命名空间
@deepseek-ai/dsh-api-workspace-files 也明确写着 "The service exposes no mutation operation.",
这个插件补上写与删的那一半 —— 可编辑的正文、保存按钮、未保存状态与冲突处理,预览头部
的「🗑 删除」按钮(两次点击确认),外加一个自带文件树的右侧栏工具 「文件编辑」:新建、
改名 / 移动、复制 / 剪切 / 粘贴、删除、拖拽整理,都在 GUI 里做完 —— 包括把系统剪贴板里复制好的
文件直接粘进工作区。它们全部打到本插件自己的 Host 路由。
与「用终端 / 让 Agent 改」的差别不是快慢,而是谁在改:人在 GUI 里改完点保存, 版本闸记得住「你读到的是哪一版」,磁盘中途被模型改过就停下来问,不静默覆盖; 删除同样带版本闸 —— 你确认期间文件被改过就停下来,要删得再点一次「仍然删除」。
- 怎么用插件 → 本文件(安装、怎么进编辑器、线上协议、排错、已知边界)。
- 为什么这么实现 → IMPLEMENTATION.md(设计决策、两半怎么拼起来、 四条实现约束的来龙去脉)。
快速开始
两步:装插件 → 在右侧栏打开一个文件。
1. 装插件
三种装法,按场景选。都走同一条 pnpm 路径(@deepseek-ai/dsh-plugin-manager/operations,
共用一把 profile 写锁),装完都会出现在侧栏「插件」页的已安装列表里。
都不需要手写注册行:本包在 package.json 里声明了 dsh.bundle.patch,只要这个 bundle
被选中,生效的那一行就来自本包自带的 cordis.patch.yml。命令行(方式二、
三)安装会顺带把它选进 dsh.profile.bundles;Web 上(方式一)是装完点 立即启用才选中 ——
不点就只是「装着但没启用」。
本包 private: true、未发布到 npm,所以没有「按包名从 registry 装」这条路:spec 要么是本地
路径,要么是 git 地址。
方式一:Web 界面(推荐)
DSH Web → 左侧栏插件 → 添加插件 → 填入本仓库绝对路径 /data/ai/dsh/dsh_editor
(或下面的 git 地址)→ 安装 → 装完点立即启用。
- 「添加插件」接受:包名(可带版本)、Git 地址、压缩包、本地绝对路径。
- 装完不点立即启用,就只是「装着但没启用」。
- 侧栏这个「插件」页才能装和开关;设置 → 内置插件里的清单是只读的。
- 填 git 地址时若 GitHub 连不上,界面会提示无法访问 GitHub 并给出改用国内镜像的选项 (镜像只提供注册表里的包与依赖,不代替 GitHub 仓库下载)。
方式二:命令行 · git 源(分发)
dsh plugin --profile web add github:dongasai/dsh_editor
# 或
dsh plugin --profile web add git+https://github.com/dongasai/dsh_editor.git
dsh plugin --profile <name> <pnpm args> 把参数转发给该 profile 目录里的 pnpm。装进来的是
副本:之后改本仓库代码不影响已装的副本(想边改边用就走方式三)。
--profile必填(不写直接报required option '--profile <name> not specified');web换成你要装进的 profile。- 钉版本:URL 后加
#,例如git+https://github.com/dongasai/dsh_editor.git#v1.1.0(tag / 分支 / commit 都行)。 本仓库当前 tag 是v1.1.0,对应package.json的1.1.0—— 不带#就跟main走, 每次装到的都可能是新代码(本机开发用方式三的link:,不受影响)。 - 私有仓库:
git+ssh://git@github.com/dongasai/dsh_editor.git(需已配好 SSH key), 或先把 HTTPS 凭据配好。 - 必须带
git+或github:前缀。git@github.com:owner/repo.git这种 scp 写法不会被认成 git spec,pnpm 会把它当路径,装出一条指向不存在目录的link:死链(本机 profile 里就留着一条 这样的残留:依赖名git→link:github.com:yangshen830-eng/dsh-editor.git,node_modules/git是断链)。CLI 识别 git spec 的判定也是这个:/^git\+|^github:|\.git(?:#|$)/。 - pnpm 默认会拦
prepare等依赖构建脚本;本包没有任何构建步骤(client.js是手写的浏览器 bundle,由 Host 直接读盘发出),不会遇到。 - git / tarball 这类 spec 要先 fetch 才知道 peer 范围,所以 DSH 版本兼容判定发生在安装之后;
判定不兼容会回滚
package.json与pnpm-lock.yaml并打印allow-version命令。本包没有声明 任何 DSH peer 依赖,不会触发。
方式三:命令行 · 本地目录(link,开发)
dsh plugin --profile web add link:/data/ai/dsh/dsh_editor
装成 link: 依赖,代码改动立刻是「已装的那份」—— 适合边改插件边用。
- 改 Host 半边(
index.js):要重启 Harness 才生效(见「已知边界」)。 - 改 浏览器半边(
client.js):刷新页面即可(bundle 每次请求都从磁盘现读, 按内容 revision 换 URL),不需要重启。
装完确认
Config.listConfigs(name: "@dongasai/dsh-editor")里能看到行、且 enabled 为真,即已生效。
再新开一个会话(或重启 Harness)让插件加载,然后按第 2 步在右侧栏验证。
2. 在右侧栏用起来
- 打开右侧栏的「文件」页,点开任意一个文本文件。
- 默认就以编辑器打开的文件:
.txt .text .log .json .jsonc .json5 .jsonl .ndjson .yaml .yml .toml .ini .cfg .conf .config .env .properties .lock—— 官方对这些文件本来也只有只读纯文本 / 代码预览,没有不可替代的能力,所以直接给编辑器 当默认视图,点开就能改。 - 其它文本 / 代码文件(
.md .ts .py .sh .html .css .sql .csv…):默认仍是官方的排版 / 高亮预览。要编辑,点预览工具栏上的「✎ 编辑」按钮 —— 它用编辑器页签顶掉这张预览 页签(同一个面板、同一个位置);该文件的编辑器页签已经开着时只聚焦它。也可以走 「打开方式」下拉选「编辑器」,那是就地换渲染器,选择按标签页记住。 - 改完点**「保存」**,或按 Ctrl/Cmd+S。
- 要删掉这个文件:点预览工具栏上的**「🗑 删除」(在「✎ 编辑」右边)。第一下只是把它 变成「确认删除」,第二下才真的删 —— 中间那几秒里如果你不点,它会自己变回「删除」。 删掉的文件不进回收站**,页签会随之关掉,文件树里那一行也会消失(树自己盯着变更)。
- 把文件与行号发到对话输入框:在编辑器正文里右键,选**「发送到 DSH 输入框」** ——
它把「当前文件 + 选区所在行」拼成
@路径#L行号(选中多行是@路径#L起-止,没有选区就是 光标所在行)插进当前会话的对话输入框,插在输入框光标上次所在的位置。工作区内的文件按 相对路径写,工作区外用绝对路径;路径带空格时按官方 grammar 加引号。插进去的是普通文本 (不是文件芯片),模型侧按@…#L引用语法解析。
3. 用「文件编辑」面板
右侧栏的加号 / 引导页里有一张「文件编辑」卡片(快捷键 Ctrl/Cmd+Alt+E,可在快捷键设置里改)。 它开一个自带文件树的页签:
| 想做什么 | 怎么做 |
|---|---|
| 看目录 | 点目录行展开 / 收起;条目默认「目录在前、再按名字」(工具条上的排序按钮可切成「按类型」,或关掉「目录优先」);一路只有一个子目录的链会折成一行(a/b/c,点它展开链尾);名字以点开头的条目淡显;目录变了会自己刷新(订阅了官方变更流) |
| 找操作 | 每行行尾有常显的「⋯」(不用悬停),行上也有悬停出现的图标;右键任意一行是一份完整菜单 |
| 打开文件 | 单击文件行 → 在同一栏里开本插件的页签(面板自己不消失):文本进编辑器页签,图片进图片预览页签(见下) |
| 新建 | 工具条上的「新建文件 / 新建文件夹」落在当前选中的目录(选中文件时是它所在的目录);也可以点某个目录行上的 + |
| 改名 / 移动 | 四个入口:行上铅笔(悬停出现)、行尾常显的「⋯」菜单、右键菜单、工具条上的铅笔(作用于选中行)。输入框里回车提交,名字里带 / 就是搬到子目录(a/b.md);键盘党按 F2 |
| 创建副本 | 文件行上的复制按钮:同目录生成「xxx 副本」,重名自动往后编号;目录不支持复制 |
| 复制 / 剪切到剪贴板 | 「⋯」或右键菜单里的**「复制到剪贴板」(只收文件)与「剪切」**(文件与目录都行);键盘党在树上按 Ctrl/Cmd+C / Ctrl/Cmd+X。剪贴板贴着面板底部显示,带一个「清空」 |
| 粘贴 | Ctrl/Cmd+V 贴系统剪贴板里的文件(在系统文件管理器里复制一个文件,回面板按一下就行);菜单 / 工具条上的「粘贴」也贴同一份。有内部剪贴板时优先贴内部那一份:复制过来的是「再复制一份」(重名自动编号、剪贴板留着可以连贴),剪切过来的是移动(贴完剪贴板清空) |
| 复制路径 | 「⋯」或右键菜单里的**「复制路径」(这个条目的绝对路径)与「复制相对路径」(相对工作区根,如 sub/keep.txt)。成功时面板上方亮一条一次性提示(几秒后自己消失);写剪贴板失败时亮同一行提示但不会自动消失**(带「取消」点掉) |
| 在终端打开 | 「⋯」或右键菜单里的**「在终端打开」**:在右侧栏开一个终端页签,等它的 shell 一变得可写就替你敲一条 cd '<该目录>'(目录行 = 它自己,文件行 = 它所在的目录),终端里能看到这一行。见下面「为什么是敲一条 cd」 |
| 删除 | 行上的垃圾桶按钮 → 确认条再点一次。空目录一次确认;非空目录确认条会报条目数,按钮是「递归删除」 |
| 拖拽 | 把行拖到目录行上松手 = 搬进它;拖到文件行上 = 搬到它所在的目录(所以拖到根下任意一个文件行就是搬到根);一开始拖,工具条下面会亮出「工作区根目录」落点条,丢到它上面(或树正文的空白处)就是搬到根目录。拖到自己身上、拖回原地、把目录拖进自己的子树都不接受 |
| 多选 | 每行左侧有一颗常显的勾选框 —— 点它就是多选的主入口(不用知道快捷键)。也可以 Ctrl/Cmd+点切换、Shift+点按可见行顺序取一段(这两种都不打开文件)、Ctrl/Cmd+A 全选可见行、Esc 或点空白处清空。普通单击只移动光标、不产生勾选。勾了至少一项就在工具条下方出现批量条:最左是全选框(全选 ✓ / 半选 −),后面是已选 N 项 / 复制路径 / 删除 / 清空 |
| 批量删除 | 批量条上的「删除」→ 一次确认条(里面有目录时会写明「会连内容一起删」)→ 逐条串行删除,每条都带自己的版本闸。部分失败时失败项留在选择里,并亮一条带「重试删除」的横幅(重试会重新取一遍基线,所以磁盘变过的那几条也能接着处理) |
为什么是「敲一条 cd」而不是「让终端在该目录启动」:官方终端接口
(remote.terminal.create)只有 shellPath,没有 cwd —— 侧边栏终端一律从会话工作区根
起步,页签参数也只有 terminalId / shellPath。所以这个动作是「开页签 → 等它可写 → 替你敲一条
cd '<绝对路径>'」:你能在终端里看到这行命令,之后就在那个目录里了。如果这个部署里没有侧边栏
终端(终端页签类型没注册),菜单项会直接说明,不会装作开了个终端。
删除同样带版本闸:你点「删除」的瞬间它会记下这个条目的版本,等你点确认时如果磁盘上已经变过 (模型或别的工具动过),Host 会拒绝,确认条上改说「这个条目在确认期间变过,没有删除」并把按钮换成 「仍然删除」——要删得再点一次。
批量删除刻意没有「整批仍然删除」这种口子:一次点下去就把 N 个条目全部无条件推平,比逐条重试 危险得多。所以部分失败时它只做两件事 —— 把失败项留在选择里、给一条带「重试删除」的横幅;重试会 重新取每一条的基线,于是「确认期间被改过」的那几条也能在你看过之后删掉。
排序与折叠都不改数据:排序只改渲染顺序(换一次排序键不会重新读盘);折叠只用已经读过的 层级 —— 为了知道「这一层是不是只有一个子目录」,它会对可见层级里那些还没读过的目录各探一次 (每次挂载最多 64 个,链一旦不是「唯一子目录」就停),但不会给预读到的目录订阅变更:折叠标签 只是显示,真要内容还是得展开(展开时才订阅)。
切走再回来不丢版面:这个面板注册成 keepMounted,点开一个文件(切到编辑器页签)再点回来,
展开的目录、滚动位置、光标都还在;以前每次回来都会重挂、缩回根一层。
面板最底部固定一行插件信息(工作区文件编辑器 · v1.1.0 这样),鼠标放上去显示插件的介绍;
标题与版本号是从 Host 的插件清单里现读的(按包名认领自己那一行),所以升级插件后这里自己就变,
不需要谁去改客户端;部署里没有插件管理器(读不到清单)时只显示包名 @dongasai/dsh-editor,
关掉面板重开一次会再试一遍。
「没有工作区根」那种情况下页脚同样在底部。
4. 确认它真的在工作
改一个字,底部状态条应当从「已与磁盘同步」变成「未保存的修改」并且「保存」按钮变成品牌色;
点保存后变「已保存」,用 cat / git diff 看磁盘,内容确实变了。看不到编辑器时按「排错」表逐条对。
在「文件编辑」面板里新建一个文件,ls 应当立刻看得到它 —— 面板自己的列表也会跟着刷出来。
图片预览
在「文件编辑」面板里单击图片,开的是本插件的图片预览页签(不是文本编辑器 —— 那会得到 「这个文件不是 UTF-8 文本」):
- 图片适应窗口显示,工具条上一个「原始大小」开关可以在适配窗口 / 原始大小之间切(原始大小 时超出部分靠滚动看)。
- 工具条左侧是文件信息:字节数,解码出来之后还有
宽×高与 MIME 类型。 - 右键有「复制路径 / 复制相对路径 / 在终端打开」—— 与文件树、编辑器菜单调的是同一份实现。
- 后缀认得出、但浏览器解不开的格式(
tiff/heic这类,本插件不内置解码器):明确说 「浏览器解不开这个格式」,并给出「重试」与「用编辑器打开」两个出路(后者对svg看源码很顺手)。 - 读盘失败(文件被删 / 移走)时同理:显示 Host 的失败码与说明 + 「重试」。
- 预览工具栏上的「✎ 编辑」对图片不显示 —— 编辑器对图片给不了任何东西。
官方那个图片渲染器完全不动:本插件只补自己的入口(「文件编辑」面板的单击)。官方文件树、 官方的自动打开、官方右上角的「打开方式」下拉都还走官方图片预览;图片也不进「哪些文件能进 编辑器」那两张表。
编辑器长什么样
状态条会显示「已与磁盘同步 / 未保存的修改 / 保存中… / 已保存」,紧跟着这个文件的相对路径
(与右键「复制相对路径」同一份口径:工作区内显示成 docs/README.md,工作区外的文件自动退回绝对
路径);太长时从头部截断以保住文件名(…/that/going/README.md),完整绝对路径在鼠标悬停的
title 与 [data-workspace-editor-status-path] 上。标题栏右侧是「重新加载」
(丢弃本地修改、重读磁盘)与「保存」。编辑器页签里多一个「换行:开 / 关」(页签窄,默认开)。
图片预览那一面也显示同样的路径(同一份实现、同一个锚点)。
正文里右键有一份菜单,三组:剪切 / 复制 / 粘贴 / 全选,分隔线后的 「发送到 DSH 输入框」(禁用时把鼠标放上去会说明原因),再一条分隔线后的 「复制路径」「复制相对路径」「在终端打开」 —— 作用对象是正在编辑的这个文件: 路径优先用读盘拿到的绝对路径,「相对」是相对会话工作区根;「在终端打开」打开的是它所在的 目录(文件在根目录下就是工作区根)。这三项与「文件编辑」面板里的同名菜单项共用同一份实现 (含失败话术),只是横幅渲染在编辑器自己的位置。
正文带语法高亮:输入框的字是透明的(只留光标与选区),看得见的字形来自它底下那层等宽文本,
颜色用主题的 --shiki-* 令牌(与官方代码预览同一套配色,亮 / 暗主题都跟着走)。语言按后缀
判:JS / TS / JSON / HTML 系(含 Vue、Svelte、XML、SVG)/ CSS 系(含 SCSS、LESS)/ Markdown /
YAML / INI 系(含 TOML、.env、properties)/ Python / Shell 系(含 PowerShell、批处理)/ SQL /
diff / patch,以及 C 家族(C/C++/Java/C#/Go/Rust/Kotlin/Swift/PHP 等,按近似的关键字表上色);
认不出的后缀(.txt、Dockerfile、没有后缀的文件)按纯文本显示。
两个上限与一个前提:超过 256 KiB 字符或 2 万令牌就不上色(正文照旧能编辑、能存),
上色用 Custom Highlight API(Chrome 105+ / Safari 17.2+ / Firefox 140+),不支持它的浏览器按
纯文本显示。当前状态可以直接在元素上看:[data-workspace-editor-highlight] 是
on / plain / over-limit / unsupported。取舍与实测代价见 IMPLEMENTATION.md。
「✎ 编辑」按钮换的是页签而不是就地切换渲染器:预览用哪个渲染器由文档预览自己的 store 决定(
state.rendererId),外部插件没有公开的写入入口;右侧栏的页签类型是公开的, 所以按钮走ctx.sidebarRight.openResource(address, { kind: 'workspace-editor', replaceTab })。 想回预览:从文件树再点一次该文件(会开新的预览页签)。 编辑器页签类型刻意声明了一个命中不了任何真实地址的 pattern,因此它永远不会参与 自动排名 —— 普通打开文件仍旧是官方预览,编辑器只在按钮 / 显式打开时出现。
数据不会悄悄丢
- 磁盘变了(模型或别的工具改了同一个文件):不会覆盖你正在编辑的内容,改为顶部横幅 提示,让你选「加载磁盘版本」还是「继续编辑」。
- 保存时磁盘变过:Host 的版本闸会拒绝,横幅里让你选「覆盖保存」或「加载磁盘版本」。
- 删除时磁盘变过:从你点「删除」到点「确认删除」之间文件被改过,Host 一样会拒绝 —— 按钮变成「仍然删除」,要删得再点一下,不会稀里糊涂删掉别人刚写的东西。
- 切到别的标签页、或关掉编辑器页签再打开:未保存的草稿还在(草稿按
会话 + 路径缓存在浏览器内存里)。 - 读盘临时失败:已经显示着正文时只挂一条提示,不把正文擦掉(磁盘抽风不该毁掉草稿)。
结构
| 文件 | 作用 |
|---|---|
| index.js | Host 半边:注册六条变更路由(write / delete / mkdir / rename / copy / upload),做信任围栏、路径围栏、沙箱策略与版本闸;写走 ctx.fs 的原子写,其余五条走 node:fs。只 import node: 内置模块。 |
| client.js | 浏览器半边:两档文档渲染器 + 编辑器页签类型 + 预览头部的「编辑」「删除」按钮 + 「文件编辑」工具面板(自带文件树)。 |
| cordis.patch.yml | 只插入一行插件;浏览器半边由 package.json 的 dsh.client 声明,由 dsh-client-modules 扫出来。 |
| package.json | bundle 清单:dsh.bundle.patch + dsh.client。 |
| locale/{zh,en}.json | 插件卡片上的显示名与说明。 |
| icon.svg | 插件卡片图标。 |
Host 半边 inject:webServer / connection / fs / sandboxPolicy / sessions
(sessionPersistence 用 ctx.get() 可选取,冷会话才需要)。
浏览器半边占用的扩展点:
| 扩展点 | 注册物 |
|---|---|
ctx.documentPreviews.register |
@dongasai/dsh-editor/text(extension 档,默认视图)、@dongasai/dsh-editor/text-option(builtin 档,下拉备选) |
sidebar.right.tab.document |
上面两个 id 的正文 |
ctx.sidebarRightTabs.register |
kind workspace-editor,id @dongasai/dsh-editor/tab,pattern 命中不了真实地址 |
sidebar.right.pane.tab |
该 id 的页签正文(编辑器) |
ctx.sidebarRightTabs.register |
kind workspace-editor-image,id @dongasai/dsh-editor/image,pattern 同样命中不了真实地址(图片的自动打开仍归官方预览) |
sidebar.right.pane.tab |
该 id 的页签正文(图片预览) |
sidebar.right.tab.document.actions |
id workspace-editor、order: 5 的「✎ 编辑」按钮;id workspace-editor-delete、order: 6 的「🗑 删除」按钮 |
ctx.sidebarRightTabs.register |
kind workspace-editor-files,id @dongasai/dsh-editor/files,guide 卡片「文件编辑」(没有 patterns,只能按 kind 打开) |
sidebar.right.pane.tab |
该 id 的文件树面板正文(自带新建 / 改名 / 复制 / 剪切 / 粘贴 / 删除 / 拖拽) |
ctx.shortcuts.register |
命令 workspace.editor.files(默认 Ctrl/Cmd+Alt+E),打开「文件编辑」面板 |
哪些文件能进编辑器
| 档位 | priority |
扩展名 | 打开方式 |
|---|---|---|---|
| 默认 | extension |
.txt .text .log .json .jsonc .json5 .jsonl .ndjson .yaml .yml .toml .ini .cfg .conf .config .env .properties .lock |
从文件树点开就是编辑器 |
| 可选 | builtin |
代码 / 文档 / 标记 / 表格类(.md .ts .py .sh .html .css .sql .csv 等,见 client.js 的 OPTIONAL_EXTENSIONS) |
只在「打开方式」下拉里出现,不抢默认 |
| 兜底 | — | 能在预览里显示出正文与 Host 路径的文本文件(预览的「不支持此文件」分支里没有这个按钮) | 点工具栏上的「✎ 编辑」按钮 |
priority 是档位而不是排序分值:extension 档按扩展名声明就能当默认,builtin 档只
作为显式备选。
没有扩展名的文件(Makefile、LICENSE)进不了编辑器:官方预览匹配不到渲染器时会走它的
「不支持此文件」分支,那个分支不渲染 sidebar.right.tab.document.actions 槽位,所以
「✎ 编辑」按钮根本不出现。想编辑就给文件名加个扩展名,或从「打开方式」下拉里选(前提是它
至少能匹配到可选档)。
线上协议
写请求:
POST /workspace-editor/write
content-type: application/json
{
"sessionId": "session-3", // 用来定位会话工作区根与沙箱策略
"path": "src/main.ts", // 相对工作区根,或绝对路径
"content": "…完整文件内容…",
"expectedVersion": "…", // 读到的版本;省略 = 无条件覆盖(用户点过「覆盖保存」)
"mode": "replace" // 或 "create"(要求文件不存在)
}
成功(200):{ "ok": true, "path": "/abs/path", "operation": "create" | "update", "version": "…", "bytes": 123 }
删请求:
POST /workspace-editor/delete
content-type: application/json
{
"sessionId": "session-3",
"path": "src", // 文件或目录
"expectedVersion": "…", // 点「删除」时看到的版本;省略 = 无条件删除(用户点过「仍然删除」)
"recursive": false // 目录:false/省略只删空的(rmdir),true 才连内容一起删
}
成功(200):{ "ok": true, "path": "/abs/path", "operation": "delete" }
建目录 / 改名 / 复制 / 粘贴上传:
POST /workspace-editor/mkdir { "sessionId": "…", "path": "docs" } // 不递归建父级
POST /workspace-editor/rename { "sessionId": "…", "from": "a.md", "to": "b/c.md" } // 改名或移动
POST /workspace-editor/copy { "sessionId": "…", "from": "a.md", "to": "a 副本.md" } // 只复制文件
POST /workspace-editor/upload { "sessionId": "…", "path": "logo.png", "data": "iVBORw0…" } // base64 → 新文件
成功(200):mkdir → { "ok": true, "path": "…", "operation": "mkdir" };
rename / copy → { "ok": true, "from": "…", "to": "…", "operation": "rename" | "copy" };
upload → { "ok": true, "path": "…", "operation": "upload", "bytes": 1234 }。
upload 是「粘贴系统剪贴板里的文件」的落盘口:正文是 base64(可能是图片、压缩包这类二进制),
目标一律必须不存在 —— 它相当于复制(COPYFILE_EXCL),不是保存,没有版本闸、也不接受
expectedVersion。重名由客户端先编号,真撞车由落盘那一步的 wx 拒绝。
失败:{ "code": "…", "message": "…", "fsCode": "…" },HTTP 状态按码给出。六条路由共用下表:
| code | HTTP | 含义 |
|---|---|---|
bad-request |
400 | 请求体不是 JSON 对象、字段类型不对(sessionId / path / from / to 必须是非空字符串,content / data 必须是字符串,recursive 必须是布尔),或 upload 的 data 不是规范 base64 |
outside-workspace |
403 | 目标不在该会话的工作区内(rename / copy 的两端都要在里面) |
denied |
403 | 沙箱策略拒绝(read-only 会话;写由 ctx.fs 判,其余五条由插件按 ctx.sandboxPolicy.resolve 自己判) |
unknown-session |
404 | 这个 Host 既不认识活会话,也没有它的存档头 |
not-found |
404 | 目标不存在,或父目录不存在(写:create 之外的模式;删 / 改名 / 复制:条目已经不在;粘贴上传:父目录不存在) |
stale-version |
409 | 磁盘上的条目变过(写:编辑期间;删:确认期间)或已被删除 —— UI 写提示「覆盖保存」、删提示「仍然删除」 |
not-observed |
409 | 写:create 模式下文件已存在 |
target-exists |
409 | 改名 / 复制 / 粘贴上传 / 新建目录:目标已经存在(不覆盖、不改名;复制是 COPYFILE_EXCL、粘贴上传是 writeFile 的 wx,都是内核级拒绝) |
not-empty |
409 | 删除:目录不是空的,而这次没有带 recursive: true |
invalid-target |
409 | 改名 / 复制:源与目标是同一个路径,或把目录搬进它自己的子树 |
not-regular-file |
409 | 目标是符号链接(一律拒绝)或类型不对(复制与粘贴上传只收普通文件,改名收文件与目录) |
not-directory |
409 | 路径中间的一段不是目录(父级不存在是 404 not-found) |
too-large |
413 | 请求体(8 MiB)、内容(4 MiB 字符)或粘贴上传的字节(解码后 4 MiB)超限 |
unsupported-media-type |
415 | 不是 application/json |
not-text |
415 | 底层文件系统策略判定不是文本(本插件读盘走 readBytes,不做文本判定,所以这一条只在底层拒绝时出现;非 UTF-8 / 含 NUL 的文件通常由浏览器侧的 TextDecoder 先判掉,见「已知边界」) |
aborted |
408 | 客户端断开或超过 30s 墙钟上限 |
write-failed / delete-failed / mkdir-failed / rename-failed / copy-failed / upload-failed / io-error |
500 | 其它文件系统故障(原始信息在 message;后五个是各自路由上未映射的 node:fs errno) |
其它方法一律 405 并带 allow: POST;所有应答都带 cache-control: no-store。
fsCode 只在失败源自底层文件系统时出现(FS_STALE_VERSION 这类原始码),排查时看它比看
message 稳。
安全模型(三道闸 + 一道版本闸)
- 信任围栏:每个请求先过
ctx.connection.requestRejection(req)—— 不可信 Host/Origin 直接 403,浏览器没认证就是 401。非本机认证页面发不进来。 - 路径围栏:目标解析后必须落在该会话的工作区根之内
(
ctx.fs.contains(会话根, 目标)),并且最终节点不能是符号链接(lstat检查), 想借软链写穿出去会被 403 挡住。 - 沙箱策略围栏:写入显式带上
ctx.sandboxPolicy.resolve({ session }),由组合文件系统ctx.fs再判一次(read-only会话一律拒绝,workspace-write只放行工作区内与平台临时区)。 另外五条没有这层自动保护 ——ctx.fs没有删除 / 改名 / 建目录 / 复制 / 写二进制的接口, 它们走node:fs,所以插件按同一份ctx.sandboxPolicy.resolve({ session })自己判read-only(guardMutation()一处收口)。 - 破坏性变更有版本闸:
expectedVersion写时走FsWriteIntent.replaceIfVersion(或createIfAbsent),写入是原子的;删时在落盘之前逐字比对同一个版本令牌。版本不符就FS_STALE_VERSION,绝不静默覆盖、也绝不静默删除。改名、复制与粘贴上传不带版本闸:它们 防的是另一件事 —— 目标必须不存在(改名预检,复制用COPYFILE_EXCL、粘贴上传用wx让内核 来判)。upload的字节也走guardMutation():那是补回被node:fs绕过的第三道闸。
边界说明:这不是内核级隔离。DSH 的文件沙箱本身是「可信代码对不可信路径」的包含性检查
(官方注释原话:containment, not a security boundary),本插件与官方 Agent 的 write
工具站在同一层能力上,没有引入更宽的口子;能访问到这个 Host 端口的本机用户,本来就能让
Agent 写文件。
与 Agent 的关系
- 插件不给模型加任何工具:Agent 自己的
read/edit/write工具足以改文件, 再加一个同名工具只会污染工具表。 - 插件不伪造文件观察状态(不
emit('fs/observed', …),也不用replaceIfVersion去 「补」一次观察)。人在 GUI 里改过的文件,对模型来说就是「没读过」—— 模型应当重新read之后再edit,这是正确且更安全的语义。 - 反向的一条:人在编辑器里保存后,Agent 那边基于旧内容发起的
edit会照常撞上版本不符, 不会把人的修改冲掉。
排错
浏览器侧的现象先看状态条与横幅文案(它们直接对应下表的码);Host 侧的现象看 Harness
日志里那条 500 write-failed。
| 症状 | 原因 | 处置 |
|---|---|---|
页面报「Failed to load plugins … loaded without registering @dongasai/dsh-editor via __ModuleLoader__.load」,插件整个不出来 |
跟别的插件撞了顶层变量名。宿主把 profile 里所有 client bundle 拼成一个 classic script(不按包隔离作用域),两个插件各有一个同名顶层 const 就会让整条 combo 抛 SyntaxError、一个都不注册 —— 而这条报错完全不提撞名(真踩过:与 @dongasai/dsh-sidebar-terminal 的顶层 const PACKAGE_NAME 相撞)。node --check 也查不出来,因为单看每个文件语法都是好的 |
跑 node reference/probes/combo-scope.mjs:它按宿主的拼法把 profile 里全部 bundle 拼起来解析,并点名是哪两个包撞名。本插件顶层只允许注释 + 一个 IIFE(见 AGENTS.md 约束 25) |
| 打开文件只有只读预览,没有「✎ 编辑」按钮 | 该文件没匹配到任何渲染器,预览头部的 actions 槽位没挂上(没有扩展名的文件是典型) | 换文件名加扩展名,或从「打开方式」下拉选「编辑器」 |
| 预览工具栏上没有「🗑 删除」按钮 | 同上:这一条也注册在 sidebar.right.tab.document.actions 槽位,槽位没挂就没有;编辑器页签里也没有(编辑器不是官方预览) |
回到该文件的预览页签再删,或用终端 / Agent 删 |
| 删除报「文件已变更,未删除」 | 版本闸生效:从你点「删除」到点「确认删除」之间文件被改过 | 看一眼是不是模型在写;确认要删就再点「仍然删除」 |
删除报 404 not-found |
文件已经不在了(模型 / 别的工具刚删掉,或本来就不是文件) | 页签可以直接关掉;刷新文件树 |
删除报 403 denied / outside-workspace |
会话是 read-only 沙箱,或目标不在该会话工作区内 |
换可写会话,或确认文件属于当前工作区 |
| 删除报「删除请求没有到达 Host」 | 请求根本没到(网络 / 页面 / 反向代理) | 文件没有被删除,重试即可;连着必现就看代理是否拦了 /workspace-editor/delete |
| 删除报「Host 侧还没有这条删除路由」 | 改过 index.js 但只刷新了页面:这条路由还没注册,静态 fallback 对 POST 回 405 |
重启 Harness;文件没有被删除 |
| 「打开方式」下拉里没有「编辑器」 | 该扩展名不在 OPTIONAL_EXTENSIONS 里 |
直接点「✎ 编辑」按钮;要长期支持就在 client.js 里加扩展名 |
| 状态条一直是「读取中…」然后转成读取失败 | 该会话 / 路径读不出来(文件被删、权限、不是文本) | 文案里有 Host 的原始说明;不是 UTF-8、含 NUL 或超过 1 MiB 的文件本来就只能只读看 |
| 保存后横幅说「磁盘上的文件在编辑期间变过」 | 版本闸生效:内容被模型或别的工具改过 | 选「加载磁盘版本」看对方改了什么,或确认要覆盖再点「覆盖保存」 |
| 保存时提示「文件已存在,没有覆盖」 | 走的是 create 模式(新建语义) |
这是刻意的:新建入口不会覆盖同名文件 |
| 保存报「保存请求没有到达 Host」 | 请求根本没到(网络 / 页面 / 反向代理) | 内容还在编辑器里,重试即可;连着必现就看代理是否拦了 /workspace-editor/write |
保存报 403 outside-workspace |
目标不在该会话的工作区根内(多根工作区里点开了别的根) | 确认文件属于当前会话的工作区 |
保存报 403 denied |
会话是 read-only 沙箱 |
换可写会话,或改会话的沙箱策略 |
保存报 404 unknown-session |
会话已经被这个 Host 忘掉(换了 Harness 实例、存档已清) | 重开会话再编辑 |
| 保存报 413 | 内容超过 4 MiB 字符 / 请求体超过 8 MiB | 用别的工具改大文件 |
| 引导页里没有「文件编辑」卡片 | 页签类型没注册(Host 半边没重启 / bundle 是旧的) | 刷新页面;改了 index.js 就重启 Harness |
| 「文件编辑」面板里列表出不来 | 这个会话还没有工作区根,或工作区读不出来 | 面板会直接说「这个会话还没有工作区根」;否则横幅里有 Host 的原始诊断 |
| 面板里新建 / 改名 / 复制 / 粘贴报「Host 侧还没有这条路由」 | 只刷新了页面,mkdir / rename / copy / upload 还没注册(静态 fallback 对 POST 回 405) |
重启 Harness |
| 在「文件编辑」面板里点图片,开的却是编辑器并报「不是 UTF-8 文本」 | 浏览器里还是旧的 bundle | 刷新页面(图片路由在 client.js 里,刷新即生效) |
| 图片预览显示「浏览器解不开这个格式」 | 那个后缀浏览器真的解不开(tiff / heic 这类),本插件不内置解码器 |
用「用编辑器打开」看源码(svg),或在系统里打开;文件本身没问题 |
| 「在终端打开」报「这个部署没有侧边栏终端」 | 终端页签类型(官方 ui-sidebar-terminal)没注册。编辑器与文件树里的这一项是同一份实现,两边表现一致 |
装 / 启用那个客户端插件后刷新页面 |
| 「在终端打开」报了,但终端停在工作区根 | 等它变成可写超时(默认 15 秒)—— 终端页签没被显示出来、或终端数量到上限时可能发生 | 看一眼那个终端页签自己的状态;直接在里面敲 cd 也一样 |
| 粘贴报「浏览器不让读剪贴板」 | 菜单 / 工具条上的「粘贴」走 navigator.clipboard.read():非安全上下文或权限被拒时读不到 |
把鼠标点进树里,按 Ctrl/Cmd+V(paste 事件不受这条限制) |
粘贴的文件名变成 pasted-20261016-203245.png 这样 |
菜单 / 工具条那条路走 navigator.clipboard.read(),这个 API 不给文件名,只能按 MIME 起名 |
想保住原文件名,用 Ctrl/Cmd+V 粘(clipboardData 带真名) |
| 粘贴报「剪贴板里没有文件」 | 剪贴板里只有文本 | 先在系统文件管理器里复制一个文件;纯文本粘贴不处理(那是另一个需求) |
| 粘贴一个目录报不支持 | 浏览器拿不到剪贴板里目录的内容(只有入口,没有内容) | 用拖拽或改名 / 移动来搬目录 |
| 面板里改名 / 复制报「目标已经存在」 | 目标路径已经有东西 | 换个名字;复制会自动往后编号,但列表可能已经过期 |
| 面板里删目录报「目录不是空的」 | 你点「删除」之后目录里出现了新条目(版本闸之外的兜底) | 确认条会升级成「递归删除」,再点一次 |
| 拖拽没反应 | 有些宿主环境(iframe / 触屏)不给 HTML5 拖放事件 | 用行上的铅笔按钮改名 / 移动(同一套 Host 路由) |
从 shell 里 curl 这条路由拿到 {"ok":false,"error":"unauthorized"} |
不是插件的错:本 profile 装了 @xgone/dsh-remote,它猴补 ctx.webServer.register,给之后注册的每条路由套一层自己的会话门禁(wrapHttp),先撞上的是它 |
带上它自己的登录 cookie,或按「怎么验证」在页面里点;浏览器本来就带着它的会话,不受影响 |
plugin 行是 enabled: true 但页面里什么都没变 |
浏览器 bundle 是上一次加载的 | 刷新页面(link: 安装时 client.js 每次从磁盘现读);Host 半边改了则要重启 Harness |
改了 index.js 不生效 |
Host 半边不在 HMR 监视集内,ESM 模块缓存按解析后的 URL 命中 | 重启 Harness(禁用 / 启用插件不够) |
怎么验证
命令行能覆盖的部分
静态自检:
| 检查 | 命令 | 期望 |
|---|---|---|
| Host 半边语法 | node --check index.js |
退出码 0 |
| 浏览器半边语法 | node --check client.js |
退出码 0 |
运行时注册走 Cordis 检视工具:先 cordis_inspect_list 看这台 Host 上有哪些 provider,再用
cordis_inspect_query({ platform, provider, method, input }) 查(provider / method 名字必须
来自 cordis_inspect_list 的结果,别猜)。
| 检查 | 手段 | 期望 |
|---|---|---|
| Loader 行激活 | Host provider Config.listConfigs,{ name: "@dongasai/dsh-editor" }(或按 id workspace-editor) |
目录里出现该 entry 且 Config 正常 —— 即 apply() 跑完(cordis.patch.yml 插的是 id: workspace-editor / name: '@dongasai/dsh-editor') |
| 渲染器注册 + 正文 | Client provider Slots.listSubTree,root: sidebar.right.tab.document |
occupants 里出现 @dongasai/dsh-editor/text 与 .../text-option,active: true |
| 编辑器页签注册 + 正文 | 同上,root: sidebar.right.pane.tab |
occupants 里出现 @dongasai/dsh-editor/tab,active: true |
| 「编辑」按钮注册 | 同上,root: sidebar.right.tab.document.actions |
occupants 里出现 { id: workspace-editor, order: 5 },active: true |
| 「删除」按钮注册 | 同上,root: sidebar.right.tab.document.actions |
occupants 里出现 { id: workspace-editor-delete, order: 6 },active: true(排在「编辑」后面) |
| 「文件编辑」正文注册 | Slots.listSubTree,root: sidebar.right.pane.tab |
occupants 里出现 @dongasai/dsh-editor/files,active: true |
| 页签类型注册 | 「编辑」按钮的注册在 ctx.sidebarRightTabs.register(...) 之后执行且成功 |
类型注册没抛错(同一 apply 内,上一条通过即蕴含);workspace-editor 与 workspace-editor-files 两个 kind 都在 |
| 快捷键注册 | 命令面板 / 快捷键设置里找 workspace.editor.files |
默认绑定是 Ctrl/Cmd+Alt+E,没有冲突标记 |
Client provider 的查询要等页面回一次响应(Host 转发),所以页面得开着;超时说明没有活页面。
HTTP 面(不经过检视工具,直接打这条路由):
| 检查 | 手段 | 期望 |
|---|---|---|
| 信任围栏 | 无认证的 POST /workspace-editor/write、/delete、/mkdir、/rename、/copy |
都被拒(403),不会落到 SPA fallback |
| 路由存在性 | 这五个 URL 用 GET |
各自 405 + allow: POST(而不是 SPA 的 HTML) |
本机 profile 装了 @xgone/dsh-remote 时,从 shell 打这两条会先撞上它的会话门禁(返回
{"ok":false,"error":"unauthorized"})—— 那不是本插件的应答,见「排错」表最后几行。
只能在浏览器里看的
UI 与交互(按钮点下去开不开页签、状态条、两处横幅、页签怎么排布、草稿能不能活过切换) 没有前端测试环境,命令行替代不了。手工走一遍:
.json/.yaml/.txt默认就是编辑器:底部状态条显示「已与磁盘同步」,改一个字变 「未保存的修改」且「保存」变品牌色,点「保存」或 Ctrl/Cmd+S 变「已保存」,磁盘同步变化。打开一个
.md或.ts(默认是官方预览),点工具栏上的**「✎ 编辑」** —— 同一张页签 应变成标题为「编辑 main.ts」的编辑器页签(位置不变、不新增页签),正文可编辑; 该文件的编辑器页签已开着时再点只聚焦,不会开出第二个。编辑器页签里点「换行:开 / 关」应在自动换行与横向滚动间切换。
- 状态条上的路径:「已与磁盘同步」右边应跟着这个文件的相对路径(工作区内的文件是
docs/README.md这样,不是绝对路径);鼠标悬停显示完整绝对路径,[data-workspace-editor-status-path]的值也应是绝对路径。把右侧栏拖窄,路径应从 头部被截断(…/dir/README.md)而文件名看得见;栏窄到 CSS 也装不下时它会退化成 普通截尾,这时以悬停title与锚点值为准。打开一个工作区外的文件时 这里应直接显示绝对路径。图片预览那一面(从文件树点图片)也有同样一条,锚点同名。
- 状态条上的路径:「已与磁盘同步」右边应跟着这个文件的相对路径(工作区内的文件是
在编辑器里改几个字(先别保存),从文件树点开另一个文件、再点回来:草稿还在。
让 Agent 改同一个文件,再回来看:出现「磁盘上的文件已被改动」横幅,你的正文没被覆盖。
删除:在一个明文副本上(先
cp一份)找到预览工具栏的「🗑 删除」——- 点第一下:按钮变「确认删除」并带「删除后无法恢复」提示,旁边多一个「取消」; 点「取消」应回到「删除」,什么都不发生;不动它 8 秒应自己变回去。
- 点第二下:页签关掉,文件从磁盘和文件树里消失(
ls确认)。 - 想验版本闸:点第一下「删除」后,在终端里
touch/改一下那个文件,再点「确认删除」—— 应该停在「文件已变更,未删除」并给出「仍然删除」;点它才会真的删掉。
「文件编辑」面板(先
cp -r一份工作区,或在一个新建的临时目录里试):- 引导页加号里能找到「文件编辑」,
Ctrl/Cmd+Alt+E也能开;面板标题是「文件编辑」。 - 面板最底部有一行插件信息(
工作区文件编辑器 · v<版本>,与插件列表里的版本一致), 鼠标悬停显示插件介绍;它贴在面板底边,不随树的滚动跑;换会话 / 换工作区根后仍在底部。 元素上可断言:[data-workspace-editor-files-plugin="@dongasai/dsh-editor"]与[data-workspace-editor-files-version](值是版本号;读不到清单时是空串)。 - 根目录列出来了,目录在前;点目录行展开,能一层层点下去;空目录显示「空目录」。
- 单击文件 → 同一栏开了编辑器页签,面板没有消失;单击一个图片(
png/jpg/svg) 应开图片预览页签:图片显示出来、工具条上有字节数与宽×高、「原始大小」开关能切、 右键有「复制路径 / 复制相对路径 / 在终端打开」三项;再点一个.tiff(或随便把扩展名改成.tiff的文本)应看到「浏览器解不开这个格式」+「重试」「用编辑器打开」。 - 新建文件 / 新建文件夹:工具条上的按钮落在当前选中的目录;输入名字回车,文件真的出现在磁盘上
(
ls确认),面板自己也刷出来了。 - 改名:四个入口都要试一次 —— 行尾「⋯」菜单、右键菜单、工具条铅笔(先点一行再点它)、
行上悬停出现的铅笔;键盘 F2 也可以。回车生效;输入
sub/name.md会搬进子目录。 - 创建副本:文件行上的复制按钮生成「xxx 副本」;再点一次是「xxx 副本 2」。
- 粘贴系统剪贴板里的文件:在系统文件管理器里复制一个文件(图片最好,能顺带验二进制),
回到面板,鼠标点进树里按 Ctrl/Cmd+V —— 文件出现在当前选中目录(选中文件时是它所在的
目录),
ls能看到它,字节与源文件一致(cmp)。面板列表自己刷出新行。 重名再贴一次应变成「xxx 2.ext」。把复制的是目录的情况也试一下:应明确说「目录不支持」, 不产生 0 字节文件。 - 菜单 / 工具条上的「粘贴」也贴同一份剪贴板里的文件(这条路读不到文件名,会按 MIME 起名成
pasted-<时间戳>.png);浏览器不给读剪贴板时应提示「按 Ctrl/Cmd+V」。 - 内部剪贴板:文件行「⋯」→「复制到剪贴板」,面板底部出现剪贴板行(
data-workspace-editor-files-clipboard, 带data-workspace-editor-files-clipboard-mode="copy");再到某个目录行「⋯」→「粘贴」, 该目录里出现同名文件,内容一致;再贴一次自动编号。换成「剪切」再粘进另一个目录 = 移动 (原位置消失),贴完剪贴板行自己消失。「清空」按钮能清掉剪贴板行。 - 键盘:点一行后按 Ctrl/Cmd+C / Ctrl/Cmd+X 应出现剪贴板行(目录上 Ctrl+C 不生效, Ctrl+X 生效);F2 仍是改名。
- 在终端打开:在一个子目录行右键 →「在终端打开」→ 右侧栏出现一个终端页签,等它起来后
应当看到一行
cd '<该子目录>',提示符就在那个目录里(pwd确认);对文件行做同样的事, 应当 cd 到它所在的目录。终端里那条 cd 是插件替你敲的,属于预期。 边界:终端页签类型没注册的部署会直接说明;等 15 秒还没可写会亮「没能自动切到该目录」的横幅 (终端本身已经开着);终端数量到上限(默认 8)时终端页签自己会报错,插件不叠第二条横幅。 - 复制路径:右键(或「⋯」)任意一行 →「复制路径」→ 面板上方出现「已复制:<绝对路径>」
的提示,粘到终端里应当正好是这个条目的绝对路径;「复制相对路径」粘出来是相对工作区根的
路径(文件在根目录下就是
a.txt,在子目录里就是sub/a.txt)。提示几秒后自己消失, 右边的「取消」也能立刻点掉。断言锚点:[data-workspace-editor-files-notice](data-tone是ok)与[data-workspace-editor-files-notice-dismiss]。 浏览器不给写剪贴板时(非安全上下文 / 权限被拒)应当亮错误横幅,而不是装作复制成功。 - 重挂:复制到剪贴板后切走再切回(关掉面板重开)剪贴板行仍在 —— 它是模块级状态,不是组件的。
- 拖拽:把一个文件行拖到另一个目录行上松手,文件真的移动了;把目录拖进它自己的子树应无反应。
- 删除:点垃圾桶 → 确认条 → 再点一次,文件消失;对非空目录按钮应显示「递归删除」,
对空目录显示「删除」。想验版本闸:点「删除」后先在终端
touch那个文件,再点确认 —— 确认条应改说「变过」,按钮变「仍然删除」。 - 排序:工具条最右的排序按钮(
[data-workspace-editor-files-sort],值是当前键)点开有 「按名称 / 按类型 / 目录优先」;切到「按类型」后,同一个目录里b.md应排在a.txt前面 (默认按名字时相反)。切换顺序不应触发任何读盘(Network 里没有新的workspaceFiles/list)。 选完一项菜单自己收起([data-workspace-editor-files-sort-menu]消失)。 - 目录链折叠:造一条
a/b/c只有单子目录的链(mkdir -p a/b/c && touch a/b/c/x.txt, 再在a/b/c里多放一个目录),面板里应当只看到一行a/b/c,属性[data-workspace-editor-files-row="/…/a/b/c"]带data-chain="b/c";点它展开的是c的内容。 再在a里放第二个目录,链应当立刻散开(a自己一行、b自己一行)。 - 隐藏项淡显:放一个
.hidden.txt,它应比同级条目淡(data-hidden="true"), 而且仍然可见(这是淡显不是过滤)。 - 多选与批量条:每行左侧有一颗常显勾选框 → 点一颗,该行变实心 ✓、行背景加深、左侧多一条
色带(三重信号,皮肤改不掉),工具条下方同时出现批量条
(
[data-workspace-editor-files-batch="1"]);点勾选框既不展开目录也不打开文件页签。 再点一颗 → 计数 2;点已勾那颗 → 取消。批量条最左那颗是全选框:半选画−、全选画 ✓, 点一下全选可见行、再点清空。Ctrl/Cmd+点与点勾选框等价;Shift+点取范围;Ctrl/Cmd+A全选;Esc或点树里的空白处清空。普通单击(不带修饰键)仍是老语义:打开文件 / 展开目录, 并且不留下勾选(光标行只有一圈虚线,data-anchor="true")。右键也不清空已勾的项。 - 批量删除:选中两个文件(先
cp备份)→ 批量条「删除」→ 确认条 → 再点一次,两个文件都消失 (ls确认),批量条与确认条都收起。部分失败:选中两个,点确认前在终端touch其中一个 → 确认后成功那条消失、被改过那条仍在选择里,并出现带「重试删除」的横幅 ([data-workspace-editor-files-batch-failed]/-batch-retry);直接点「重试删除」应能把它删掉。 选一个非空目录加它里面的一个文件一起删:确认条应只报 1 项(子项被父目录覆盖), 且请求里带recursive。
- 引导页加号里能找到「文件编辑」,
发送到 DSH 输入框(在编辑器正文里右键):
- 菜单应是「剪切 / 复制 / 粘贴 / 全选 + 分隔线 + 发送到 DSH 输入框」;原生文本框菜单被 这份菜单替代(四项编辑操作照能用)。
- 选区跨 15–20 行时右键,菜单项标题应是
@<文件>#L15-20;点下去,对话输入框里出现@<文件>#L15-20(末尾一个空格),且@文件菜单没有被唤出来。 - 只把光标放在某一行(无选区)再发,应插入
@<文件>#L<该行>;工作区内的文件应是相对路径。 - 菜单里的「剪切 / 复制」真的好用(粘到别处验证);「粘贴」把剪贴板内容插进选区。
- 第二条分隔线后是**「复制路径」「复制相对路径」「在终端打开」(作用对象是正在编辑的文件):
复制路径粘出来是它的绝对路径;复制相对路径在子目录里是
sub/a.txt这样、在根目录下就是a.txt;「在终端打开」开出的终端最终停在它所在的目录**(文件在根目录下就停在根)。 成功提示在编辑器上方([data-workspace-editor-action-notice],data-tone="ok")几秒后消失; 失败(比如终端页签类型没注册)留到用户点掉(-action-notice-dismiss)。 - 反例:输入框正忙(正在提交 / 等待响应)时发送,编辑器里应亮一条「输入框正忙或内容刚变过」 的横幅,输入框里已有的内容不被破坏。
语法高亮(打开一个
.js、一个.md、一个.css):- 正文有颜色:注释灰、字符串绿、关键字粉、数字蓝、函数名紫;暗色主题下同样有颜色。
- 光标落在正文任意位置打字,高亮层与输入框的字始终重合(不会出现两份字、不会错行); 把右侧栏拖窄让长行自动换行,仍然重合;滚到正文最底部也不跑偏。
- 选中一段文字:选区是半透明色块,底下的彩色文字仍然看得见;光标(插入符)可见。
- 中文输入法:切到拼音,边打边看 —— 拼音与候选上屏的字都要看得见(组合中的字同样来自 高亮层),不会出现「输入框里一片空白」;上屏后正文照常上色。
- 元素状态可断言:
document.querySelector('[data-workspace-editor-highlight]')的data-workspace-editor-highlight对.js是on、对.txt是plain。 - 上限:
yes 'const a = 1' | head -c 400000 > big.js再打开,状态应是over-limit, 正文仍是纯文本但能编辑、能保存。
已知边界
- 只处理 UTF-8 文本:非 UTF-8、含 NUL 或超过 1 MiB 字符的文件会显示说明而不给编辑。 这是浏览器侧的编辑上限;Host 接受的上限更宽(内容 4 MiB 字符 / 请求体 8 MiB), 两者别混。
- 编辑器没有行号、查找替换、多文件 —— 目前只解决「能改、能存、不丢、不覆盖」。 新建入口在「文件编辑」面板里(工具条上的「新建文件」)。
- 语法高亮是近似上色、且有体量上限:按后缀选一套规则表(不是真正的语法解析), 超过 256 KiB 字符 / 2 万令牌、或浏览器没有 Custom Highlight API 时按纯文本显示。 认不出的后缀也不上色(比硬套关键字表更不容易误导)。细节见「编辑器长什么样」。
- 点「✎ 编辑」会把预览页签换成编辑器页签,不能把预览就地换成编辑器正文 —— 后者的开关在 文档预览自己的 store 里,外部插件没有公开入口。想回预览:从文件树再点该文件。
- 官方文件树仍然只读(不改官方
ui-sidebar-files):改名 / 新建 / 删除要在本插件的 「文件编辑」面板里做,或在预览工具栏上删单个文件(那条只对「预览得出来」的文件生效, 编辑器页签里没有)。 - 删除是永久的:没有回收站、没有软删,删掉就是
unlink/rmdir/rm -r。二次确认 + 版本闸是唯一的护栏。版本闸与落盘之间有一个毫秒级窗口(并发写正好落在里面时会删到刚写下的 那一份);改名的预检与rename(2)之间同理。代价与理由写在 IMPLEMENTATION.md。 - 「文件编辑」面板不做的事:不复制目录、不递归新建中间目录、不做下载、没有搜索与过滤。 「移动」只有两种做法:改名输入框里写相对路径、或拖到目录行上(剪贴板粘贴也能移动,但那是 「先剪切再贴」)。
- 多选只认可见行:Shift 范围选择与 Ctrl/Cmd+A 全选都以当前渲染出来的行为坐标 —— 折叠起来 的目录、没展开的子树都不在里面(这也是为什么排序一改,范围选择的落点就跟着变)。Esc 除了关浮层, 现在也会清空选区。
- 批量删除是逐条串行的:一条一条发请求(这样「正在处理哪一条」看得见、卸载时也能立刻停), 所以选很多条时会有可感的耗时;每条都带自己的版本闸,失败项留在选择里由「重试删除」接手。
- 排序偏好不持久化:它活在面板组件里,换会话 / 换工作区根重挂后回到默认(目录优先 + 按名字)。 折叠与隐藏项只影响显示:隐藏项是淡显而不是过滤,面板里也没有「显示隐藏文件」开关。
- 目录链折叠会多读几层:为了判断「这一层是不是只有一个子目录」,挂载后会对可见层级里还没读过的 目录各探一次(每次挂载上限 64 个,链一旦不是唯一子目录就停)。这些预读的目录不订阅变更 —— 折叠标签因此可能在别处新建 / 删除之后短暂过期,展开一次就会重新读。
- 粘贴上传只有一条路、两个上限:只处理系统剪贴板里的文件(一次最多 4 MiB / 个,
超了跳过并说明,剩下的继续贴)。纯文本粘贴不处理(不
preventDefault,留给浏览器默认 行为),剪贴板里的目录也只能明确说不支持 —— 浏览器拿得到目录入口、拿不到它的内容。 - 两条粘贴路读到的信息不一样:键盘 Ctrl/Cmd+V 走
paste事件,clipboardData里带 真文件名;菜单 / 工具条上的「粘贴」只能走navigator.clipboard.read(),那个 API 不携带 文件名,所以按 MIME 起名(pasted-<时间戳>.<扩展名>,未知类型是.bin)。后者还需要安全 上下文(http://127.0.0.1算安全上下文)与剪贴板读权限。 - 「在终端打开」会往你的 shell 里发一次输入:目录只能靠开完再敲
cd '<绝对路径>'带过去 (官方终端接口没有 cwd,见上文)。开的是全新的空 shell,但理论上仍与你手速有毫秒级竞争。 引号用 POSIX 单引号('→'\''):POSIX shell 与 PowerShell 都认,cmd.exe 不认 —— 那些平台上这条自动 cd 可能不生效,手动敲即可。终端页签的开 / 关始终由用户与官方 UI 决定, 插件不关终端、也不改它的标题。 - 图片预览只从本插件的入口进:官方文件树 / 官方「打开方式」里的图片仍然走官方渲染器(本插件 注册的是页签类型而不是文档渲染器,刻意不去顶掉官方那个)。想看本插件的图片预览,就从 「文件编辑」面板点图片。
- 图片预览不做解码兜底:
tiff/heic这类浏览器解不开的格式只给一句说明(插件不引入 wasm 解码器,那是另一个量级的东西);svg想看源码用预览里的「用编辑器打开」。 - 图片是整份读进内存再交给
<img>(Blob + 对象 URL,换文件 / 关页签时释放),所以超大图 和编辑器一样受 Host 读接口的体量约束。 - 「复制相对路径」是相对工作区根算的,不是相对当前展开的目录;工作区根自己没有单独的行 (根只出现在工具条那行路径里),所以这两个动作都作用在条目上。
- 剪贴板(内部那份)只活在内存里:刷新页面、换个浏览器标签就没了;它记的是绝对路径,
换会话 / 换工作区后粘贴会被 Host 的包含性检查挡下来(403
outside-workspace),不会贴错地方。 - 面板的版本闸只盖到「直接条目」:删目录时比对的是目录自身的版本令牌(它随直接子项的增删 变化),子目录内部的改动不会改变它 —— 递归删除的残余风险见 IMPLEMENTATION.md。
- 面板不会去动已经开着的编辑器页签:在面板里改名 / 移动 / 删除一个正被编辑器打开的文件时, 那张页签还指着旧路径(保存会按版本闸报错或把它重新创建出来)。改名后想接着编辑,从面板里再点 一次那个文件。
- 拖拽依赖 HTML5 拖放事件:宿主不给这些事件时(部分触屏 / 嵌入环境)拖不动,用铅笔按钮改名 一样能移动。
link:安装时 Host 半边改完要重启 Harness:本目录不在 HMR 的监视集内,plugin_manager的禁用 / 启用只会重挂 fiber,不会重新import()模块。浏览器半边不受这条 限制(bundle 按内容 revision 取,刷新即新)。- 草稿只在浏览器内存里:刷新页面、换个标签页打开同一个会话都会丢未保存的草稿 (它是「重挂活下来」的缓存,不是持久化)。
- 一次保存是一个请求:没有分片、没有断点续传,超过上限的文件只能换工具改。
- 「发送到 DSH 输入框」发送的是纯文本,不是文件芯片:官方公开面只提供
insertText, 带结构化引用的插入不跨插件边界,所以@路径#L行号以普通字符落进输入框(末尾带一个空格), 行号语义由模型侧解析。设计取舍见 IMPLEMENTATION.md。 - 发送目标恒为「当前会话」的输入框:写入面取「编辑器自己槽位上的
inputActions」或 「conversation.input.dock哨兵登记的那一份」。两者都没有时(会话还在起、对话区没打开、 输入框被审批 / 问答接管)这项不可用,编辑器里会亮横幅说明。 - 右键菜单替代了原生文本框菜单:剪切 / 复制 / 粘贴 / 全选都在菜单里,但「粘贴」依赖
navigator.clipboard.readText—— 非安全上下文(用局域网 IP 的 http 打开页面)或权限被拒时 菜单里的粘贴不可用,键盘 Ctrl/Cmd+V 不受影响。 - 插入不会替你聚焦输入框:文本落在输入框里光标上次所在的位置,要接着说事请自己点一下 输入框(公开面没有「聚焦」这个动作)。
相关文件
| 文件 | 关系 |
|---|---|
| IMPLEMENTATION.md | 为什么这么实现:设计决策、两半怎么拼、四条约束与「文件编辑」面板的来龙去脉 |
| AGENTS.md | 给代理的速查:命令、硬性约束、改动连带项 |
| index.js | Host 半边全部实现(六条路由) |
| client.js | 浏览器半边全部实现(编辑器 + 「文件编辑」面板) |
| cordis.patch.yml | profile 里的插入行 |
Links
More in this category
zhu1090093659/dsh-web#packages/dsh-task-board★ 9262
Task board for the dsh web GUI: a sidebar multi-column kanban whose cards run in real DSH agent sessions and can also be scheduled with cron expressions, executed host-side even with the browser closed.
zhu1090093659/dsh-web#packages/dsh-web-all★ 9262
Plugin and skin collection for the DSH Web UI: task board, Git graph, right-side panel, remote mobile UI, pet, live token stats, and a skin center.
MeteorNOX/DeepSeek-Balance-Whale-Widget★ 5677
A fixed-corner whale widget for the DSH web GUI — balance, today's usage and per-turn cost with peak/off-peak pricing, editable balance-alert and daily-budget bubbles, a module-based custom bubble queue with A/B weighted choices and random lines or images, 30+ vendor templates (OpenAI, OpenRouter, Kimi, SiliconFlow, Ark, Zhipu, MiniMax and more) with per-model balance and subscription quota, plus task-end sound, imported audio, custom roles and a resource manager. Local-only, no telemetry.
ccch1mneyyy/dsh-TUI★ 5125
Claude Code-style full-screen terminal UI: pixel-whale header, live status line, and streaming thought expansion.
omdsh-dev/DSH-better-sidebar★ 4413
Full sidebar workbench with file rendering and editing, terminal, Git, and subagents; third-party plugins can register new tabs.
Devin-AXIS/deepseek-design#deepseek-idesign★ 1491
Visual design studio for websites, app prototypes, posters, cards, reports, and magazines, with templates, direct element editing, selection-aware AI draft handoff, and export.
Community comments
Comments are public GitHub Discussions. Loading them connects to GitHub and Giscus; a GitHub account is required to post.