# AGENTS.md
给 AI 编码代理的项目须知。文档分工:[README.md](README.md) 讲**怎么用**(安装、怎么进编辑器、
线上协议、排错、怎么验证、已知边界),[IMPLEMENTATION.md](IMPLEMENTATION.md) 讲**为什么这么
实现**(设计决策、两半怎么拼、四条约束的来龙去脉)。与代码冲突时以代码为准。
## 项目是什么
`@dongasai/dsh-editor` —— DSH 插件,一个包两半:**Host 半边** [index.js](index.js) 注册六条变更
路由(`/workspace-editor/` 下的 `write` / `delete` / `mkdir` / `rename` / `copy` / `upload`);
**浏览器半边** [client.js](client.js) 在右侧栏注册可编辑的文档渲染器、一个编辑器页签类型、预览
头部的「✎ 编辑」与「🗑 删除」按钮,以及一个自带文件树、自带剪贴板的工具面板 **「文件编辑」**。
三句话概括设计:变更走**自注册的 HTTP 路由**(新增 Remote 命名空间需要构建期生成物,`link:`
安装走不通);三档入口(默认渲染器 / 可选渲染器 / 编辑器页签)**共用同一份正文**,「文件编辑」
是一个**页类型**(`sidebarRightTabs.register`,没有新槽位);破坏性变更**带版本闸**、覆盖性变更
**带排他**(目标必须不存在),磁盘中途变过就停下来问人,不静默覆盖、也不静默删除。
## 命令
| 目的 | 命令 |
|---|---|
| Host 半边语法自检 | `node --check index.js` |
| 浏览器半边语法自检 | `node --check client.js` |
无构建、无 lint、无依赖安装、**没有测试目录**:`package.json` 没有 `dependencies`,`index.js`
只用 `node:` 内建模块;`client.js` 是手写的浏览器 bundle(由 Host 直接读盘发出,不经打包器)。
**没有测试目录是刻意的**(取舍见 [IMPLEMENTATION.md](IMPLEMENTATION.md) 的「为什么没有测试
目录」):可测部分几乎全在浏览器侧,没有前端测试环境,为 Host 写「stub 掉 `ctx.fs` 再断言调了
`writeText`」的用例,验证的是插件自己的转述。验证靠 README「怎么验证」的两张清单:
命令行验注册与围栏,浏览器验交互。
**改哪一半、怎么生效**:
| 改了 | 生效方式 |
|---|---|
| `client.js` | 刷新浏览器页面(bundle 每次请求从磁盘现读,按内容 revision 换 URL) |
| `index.js` | **重启 Harness**(本目录不在 HMR 监视集内,禁用 / 启用插件只会重挂 fiber,不会重新 `import()` 模块) |
| `cordis.patch.yml` / `package.json` 的 `dsh.*` | 重启 Harness |
**但「装进 profile / 启停插件行」本身是热应用的**(2026-10-17 实测,宿主全程没重启):
`dsh plugin --profile web add <本仓库>` 之后六条路由立刻回 403(未认证被围栏挡 = 已注册),在
profile 的 `cordis.patch.yml` 里给 `- id: workspace-editor` 加 `disabled: true` 后同一批路由变
200(SPA 兜底 = 未注册),摘掉这块又回 403。所以:**新装载与启停不用重启**;上面那行「重启
Harness」只针对**改自己 `index.js` 的内容** —— 那个模块已经被 `import()` 过,禁用再启用只会重挂
fiber,不会重新读盘。判据(可直接 curl,无需认证):`GET /workspace-editor/write` 回 **403 = Host
半边在位**,回 **200 = 没加载**;对照 `GET /workspace-editor/随便一个不存在的名字` 恒为 200。
## 文件地图
| 文件 | 作用 |
|---|---|
| [index.js](index.js) | Host 半边全部实现:六条路由共用外壳 + 三道闸 + 版本闸 + `node:fs` 的 errno 映射 + base64 线性校验 |
| [client.js](client.js) | 浏览器半边全部实现:三档入口 + `EditorSurface` + 语法高亮层与令牌表 + 删除按钮 + 「文件编辑」文件树面板(含剪贴板 / 粘贴)+ 右键菜单动作(复制路径 / 复制相对路径 / 在终端打开,编辑器与文件树共用一份实现)+ 右键「发送到 DSH」 |
| [cordis.patch.yml](cordis.patch.yml) | 插进 profile 的那一行 |
| [package.json](package.json) | bundle 清单:`dsh.bundle.patch` + `dsh.client`(`platform` / `immediately` / `inject`) |
| [locale/zh.json](locale/zh.json) / [locale/en.json](locale/en.json) | 插件列表里的标题与描述 |
| [icon.svg](icon.svg) | 插件卡片图标 |
| [README.md](README.md) | **怎么用**:安装、编辑器入口、线上协议、排错、怎么验证、已知边界 |
| [IMPLEMENTATION.md](IMPLEMENTATION.md) | **为什么**:设计决策、一次保存 / 删除的路径、四条约束 |
| [AGENTS.md](AGENTS.md) | 本文件:命令、约束清单、改动连带项 |
`index.js` 内部顺序:常量(含六条路由的 `ENDPOINTS` 表、`isBase64`)→ `FS_FAILURES` /
`NODE_FAILURES` 码表 → `name` / `inject` → `apply`(按表注册六条路由)→ `route`(六条共用的
外壳)→ `rejected`(信任围栏)→ `requestLifetime`(断连 + 墙钟)→ 六个 `parse*Request` →
`writeWorkspaceFile`(lstat → resolve/contains → sandboxPolicy → writeText)→
`deleteWorkspaceEntry` / `mkdirWorkspaceEntry` / `renameWorkspaceEntry` / `copyWorkspaceEntry` /
`uploadWorkspaceFile` → `guardMutation`(会话根 →
contains → 自判 sandboxPolicy)→ `entryNoFollow` → `requireParentDirectory` → `removeEntry` /
`makeEntryDirectory` / `moveEntry` / `copyEntry` → `nodeFailure`(errno → 线上码)→
`resolveSessionRoot` → `readBody` → `sendFailure` → 若干纯工具函数。
`client.js` 内部顺序:常量(`WRITE_URL` / `DELETE_URL` / `MKDIR_URL` / `RENAME_URL` / `COPY_URL` /
`UPLOAD_URL` / `MAX_PASTE_BYTES` / 两个扩展名表 / 四个 id(默认渲染器、可选渲染器、编辑器页签、
图片页签)/ `EDITOR_TAB_PATTERN` / `IMAGE_TAB_PATTERN` / 模块级 `drafts` /
`inputTargets` / `filesClipboard` / `CSS`)→ 文案 `zh` / `en` → 地址与纯工具
(`addressParts` / `parseSessionFileAddress` / `absoluteFileAddress` / `fileNameOf` / `paneTarget` /
`relativizeToCwd` / `lineNumberOf` / `lineReferenceToken` / `menuAnchorFor` / `sessionCwd` /
`writeClipboard` / `readClipboard`)→ **路径工具区**(`/* path-tools:start */` …
`/* path-tools:end */`:`shortenPath`,**必须留在工厂作用域** —— 它被 `EditorSurface` 与
`ImageBody` 共用)→ **文件树纯函数区**(`/* tree:start */` …
`/* tree:end */`:`TREE_SORT_KEYS` / `DEFAULT_TREE_SORT` / `TREE_CHAIN_MAX` /
`TREE_PRELOAD_BUDGET` / `extensionOf` / `isHiddenName` / `compareTreeNames` / `sortEntries` /
`foldLevel` / `chainCandidates` / `rangeSelection` / `pruneForBatchDelete`)→
**粘贴 / 终端命令纯函数区**(`/* paste:start */` …
`/* paste:end */`:`PASTE_EXTENSIONS` / `base64OfBytes` / `pastedNameFor` / `clipboardFilesOf` /
`shellQuotePath` / `IMAGE_MEDIA_TYPES` / `imageMediaTypeOf`)→
**语法高亮区**(`/* highlight:start */` …
`/* highlight:end */`:上限常量 / 高亮名映射 / 关键字表 / 各语言规则表 / `keywordSource` /
`clikeRules` / `makeScanner` / `scannerFor` / `languageOfPath` / `normalizeNewlines` /
`needsTrailingLine` / `highlightRegistry` / `highlightSupported` / `tokenize`)→
`HIGHLIGHT_API_SUPPORTED` + `applyHighlights` / `releaseHighlights` / `rebuildHighlights`(DOM 侧,
Range 与 `CSS.highlights`)→ `InputSink`(`conversation.input.dock` 哨兵)→
`createEditor(ctx)`(`EditorSurface` / `DocumentBody` / `PaneBody` / `EditAction` / `DeleteAction`)
→ **两半共用的动作层**(`useNotice` 一次性提示状态机 / `copyPathText` / `copyPathToClipboard` /
`directoryOfFile` / `openTerminalAt`+`sendCdInto`+`waitForTerminalTab`:编辑器菜单、文件树菜单、
图片预览菜单三处同一份实现)→ `createImageBody(ctx)`(`ImageBody`:读字节 → Blob 对象 URL →
适配 / 原始大小、元信息、右键菜单、解不开与读失败的出路;**核对官方那三个契约别变**)→ `createFilesPanel(ctx)`(`FilesPanel` / `FilesTree`:目录树 + 内部剪贴板 /
系统剪贴板粘贴 + 菜单里的 `copyPath` / `openInTerminal`)→
`inject` + `apply`(编辑 / 删除按钮注册在
同一个槽位,id 与 order 各一档;「文件编辑」是页签类型 + `sidebar.right.pane.tab` 正文 + 快捷键
+ 输入框哨兵)。
## 硬性约束(违反即出 bug,别试探)
完整来龙去脉见 [IMPLEMENTATION.md](IMPLEMENTATION.md) —— 这里是要背下来的规则。
### Host 半边
1. **只 import `node:` 内建模块**,不新增任何依赖。`FsError` 这类官方类型改为按 `error.code`
判别(查 `FS_FAILURES` 表),不 import 官方包,也不导出 `Config`(那需要 schemastery)。
2. **变更必须过三道闸**:Connection 信任围栏(`ctx.connection.requestRejection`)→ 会话工作区
包含性检查(`ctx.fs.contains`)→ 沙箱策略围栏。写路径的第三道闸由 `ctx.fs.writeText` 执行;
**另外五条没有这层自动保护**(`ctx.fs` 没有删除 / 改名 / 建目录 / 复制 / 写二进制的接口,它们
走 `node:fs`),必须走 `guardMutation()` 自己按 `policy.mode === 'read-only'` 拒绝。**不要为了
「方便保存 / 方便删除 / 方便粘贴」放宽任何一道**,也不要拿掉 `entryNoFollow()` 的符号链接
检查(这五条既不递归跟软链,也不删 / 改软链指向的东西)。
3. **破坏性变更带版本闸,覆盖性变更带排他**:写 `expectedVersion` → `{ kind: 'replaceIfVersion' }`
(`create` 走 `createIfAbsent`);删在落盘前逐字比对同一个版本令牌;改名卡「目标必须不存在」;
复制用 `COPYFILE_EXCL`、粘贴上传用 `writeFile` 的 `wx`。不传 `expectedVersion` 才是无条件变更
(只允许发生在用户点过「覆盖保存」/「仍然删除」之后)。
4. **不改观察状态**:不 `emit('fs/observed', …)`。人工在 GUI 里改过的文件对模型来说必须仍是
「没读过」。
5. **错误按 `code` 判别,绝不看 `message`**;表里没有的码一律 500 并记日志(写 `write-failed`,
五条 `node:fs` 路由各有 `*-failed`),不要新增「猜」的分支。`upload` 的 base64 校验**不许用
大正则**(`(?:…{4})*` 套在 4 MiB 字符串上会把 V8 的 regexp 栈撑爆,见 `isBase64` 的注释)。
6. **五条 `node:fs` 路由的每一步都过公共前置**:`guardMutation()`(会话根 → `contains` →
自己判 `read-only`)→ `entryNoFollow()`(软链一律拒)→ `requireParentDirectory()`(目标父级
必须存在且是目录)→ 才轮到 `unlink` / `rmdir` / `rm -r` / `mkdir` / `rename` / `copyFile` /
`writeFile`。删除带版本闸、复制带 `COPYFILE_EXCL`、粘贴上传带 `wx`、改名卡「目标必须不存在」
—— 这几条不要互相「统一」掉。
### 浏览器半边
7. **三个入口共用 `EditorSurface`**,不要为某个入口复制一份正文逻辑;入口差异只体现在 props
(`variant` / `wrap` / `reloadKey` / `onLoaded` / `onFailed`)。
8. **草稿活过重挂**:卸载时脏则写 `drafts`、干净则删;挂载读盘后有草稿就恢复草稿**及其基线
版本**。`drafts` 是纯派生的 UI 缓存,保存或丢弃后必须立刻删。
9. **读盘后总是回报磁盘版本**(`content.loaded(version)`),**即使缓冲区是脏的**。回报旧版本会
让 owner 的自动刷新陷入重读循环 —— 这条最容易被「顺手优化」掉。
10. **磁盘变化不覆盖脏缓冲区**:只亮横幅,正文一个字都不动,由用户选「加载磁盘版本」还是
「继续编辑」。
11. **编辑器页签(以及图片预览页签)的 pattern 必须命中不了真实地址**
(`dsh-resource://workspace-editor/**` / `dsh-resource://workspace-editor-image/**`):
它们一旦能命中,普通打开文件就会被抢走,官方预览(包括官方那个图片渲染器)从此进不来。
显式打开不看 pattern,所以这两条不会挡住树里的单击。
12. **`doc.version`(草稿基线,版本闸用)与 `doc.diskVersion`(磁盘现状,展示与冲突判定用)
是两个字段**,不要合并。
13. **删除按钮要两次点击**:第一下只进确认态、同时抓版本基线(按钮在这期间禁用显示「检查中…」),
第二下才发请求;确认态 / 冲突态 8 秒自己过期;**每个状态都要有出路**(失败态也得留「取消」,
否则按钮消失、用户连重试都点不到);删成功用 `closeTarget` 关掉点击时捕获的那张页签。
14. **「文件编辑」面板是页类型,不是新槽位**:`sidebarRightTabs.register`(无 patterns,只能按
kind 打开)+ `sidebar.right.pane.tab` 同 id 的正文;列表 / 监听只用官方只读 Remote
(`list` / `stat` / `changes` 走受管 `$stream`),变更一律打插件自己的六条路由。树的面板
**单击文件开编辑器页签时必须带 `paneId` 且不带 `replaceTab`** —— 否则一点文件就把面板自己
顶掉了。
15. **树的状态都在组件里,不要把目录树塞进模块级缓存**:换会话 / 换工作区根靠外层 `key` 重挂,
每个加载过的目录订阅一次变更流(120ms 防抖合并重列),读盘用代次号挡住晚到的旧结果。
因此「文件编辑」页签必须声明官方的 `keepMounted: true`:状态在组件里,页签切走就卸载,
点一个文件进编辑器页签再回来会只剩根一层(用户报过「打开新文件再回来树缩回去了」)。
编辑器 / 图片页签**不要**跟着开 —— 它们每个页签都持有一份正文与高亮层。
16. **行操作只允许有「一套实现、多个入口」**:行内图标、行尾常显的「⋯」菜单、右键菜单、
工具条按钮、F2 全部调同一个 `startRename()` —— 不要再写第二份改名逻辑。新增行操作时照这个
形状加(`menuItem()` 一处声明、各处复用),并记得**常显入口**:只有悬停才出现的按钮等于
没有按钮(用户反馈过一次「tree 缺重命名」)。**跨面同理**:编辑器右键菜单与文件树菜单的
「复制路径 / 复制相对路径 / 在终端打开」是同一份实现(工厂作用域那几个函数),各面只渲染
自己位置的横幅 —— 想在某一面加动作时先问「另一面要不要」,要就加到共用那一层。
17. **「发送到 DSH」的两个细节不要「顺手优化」掉**:插进输入框的引用**必须带尾随空格** ——
否则 `@` 触发检测会认为用户正在输引用、把 `@` 文件菜单唤出来,用户得手动 Esc;
`captureInsertion()` 与 `insertText()` 必须挨着调用 —— 中间任何编辑都会让版本 CAS 失败、
插入被静默拒绝。引用文本只用 `lineReferenceToken()` 拼,自己拼字符串必然绕不开
引号 / 空白 / 控制字符的边界(官方 grammar 表示不了的就是 `null`,别硬塞)。
18. **高亮层与 textarea 必须逐字逐行重合**:字体 / 内边距 / 换行规则放在**同一条 CSS 规则**里
(`.dsh-we-editor,.dsh-we-hl`);宽度按 `textarea.clientWidth` 贴(滚动条会改变内容宽度)、
滚动位置每轮渲染后贴一次并在 `onScroll` 里直接贴;**正文结尾是换行或正文为空时要补一个
`
`**(textarea 给结尾空行留行盒,块级文本不给)。上色走 Custom Highlight API:高亮层里
**只有一个文本节点**,一个 span 都不加;`Range` 只能建在文本节点上(传元素会按子节点下标解释
offset → `IndexSizeError` → layout effect 抛错 → React 卸载整棵子树 → 白屏,真踩过);
能力探测走 `globalThis.CSS`(本模块的 `const CSS` 会遮蔽全局命名空间);只增删自己那 9 个高亮名,
**绝不 `CSS.highlights.clear()`**(全局注册表,别的插件也在用);同屏多个实例的高亮由
`highlightSources` 统一重建(高亮名必须写死在样式表里,没法按实例命名)。
19. **别假设槽位标准 prop 都在;也别往 `h(...)` 的子元素里多打一个 `)`** —— 这两条各造成过
一次编辑器白屏,代价很大,务必记住:
- **slot catalog 列的是类型并集,某个渲染点可能只传子集**:`sidebar.right.tab.document`
的正文实际拿不到 `useSessions`,渲染期直接调用它的后果是 `TypeError` → React 卸载整棵
子树。要用某个 prop 之前先做守卫,或改从 ctx 服务现读 —— 工作区根走
`ctx.sessions.list.getSnapshot()`(事件里现读,比渲染期 prop 还新);输入框写入面走
**「槽位 prop 优先 + `conversation.input.dock` 哨兵登记(`inputTargets`)兜底」**两条路。
- **`return h('div', {...}, 子元素…)` 尾部追加元素时,多一个 `)` 会让整段变成逗号表达式**:
`return h(...), menu && h(...)` 的返回值是最后那个 `&&`(菜单关着就是 `false`),
React 渲染 `false` = 什么都不画 —— **白屏且控制台没有报错**。`node --check` 只验语法,
这种语义错误它看不见:改完用 `node -e` + acorn 确认那个 `return` 仍是 `CallExpression`。
20. **跨插件的 cordis 服务 / 命名空间一律用 `ctx.get('remote.')` 取,不要写进 `inject`**:
命名空间是 cordis **服务**,没在 `inject` 里声明就写 `ctx.remote.` 会直接抛
「cannot get property "remote." without inject」(cordis 4.0.4 实测);被 try/catch 吃掉
就变成「功能静默失效」(真踩过:面板页脚读不到版本,只剩包名)。而写进 `inject` 又是硬依赖 ——
没有那个服务的部署里整个插件会卡在等待中,编辑器与面板全都不出来。本插件的 `inject` 只列
自己**必需**的服务(`remote.workspaceFiles` 是编辑器读盘必需的);可选的(如
`remote.pluginManager`)一律 `ctx.get(...)`,拿不到就退回兜底表现。
21. **粘贴是「两个来源、一场两路」,别把它统一成一条**:内部剪贴板(`filesClipboard`,模块级 ——
这是约束 15 的唯一例外,它跟草稿一样要活过重挂)优先,走已有的复制 / 改名路由(剪切贴完
必须清空,复制贴完保留);没有内部剪贴板时才去系统剪贴板拿文件,走 `POST /upload`。
系统剪贴板那两条路的差别是**信息和能力不一样,不是实现偷懒**:
- 键盘 **Ctrl/Cmd+V** 只能走 `paste` 事件(`clipboardData` 带**真文件名**);
- 菜单 / 工具条上的「粘贴」只能走 `navigator.clipboard.read()` —— 它**不携带文件名**,
只能按 MIME 起名(`pasted-<时间戳>.<扩展名>`),且需要安全上下文与权限。
另外:`paste` 事件里**纯文本一律 `return`(不 `preventDefault`)**,目录要明确说「不支持」
(浏览器拿得到目录入口、拿不到内容)—— 别顺手把它做成「贴文本建文件」,那是另一个需求。
22. **文件树的「光标」与「勾选」是两个字段,且勾选只由显式入口产生**:`selected`(光标 / 锚点,
工具条 / F2 / Shift 范围都从它出发,视觉上是一圈虚线 `data-anchor`)与 `selection`(勾选集,
视觉上是实心 ✓ + 行背景 + 左侧色带)。**行左侧的常显勾选框是主入口**(悬停才出现的按钮等于
没有按钮);`Ctrl/Cmd+点` 与它共用 `toggleSelection`;`Shift+点` 按可见行取范围;
`Ctrl/Cmd+A` 全选可见行;**普通单击只挪光标并清空勾选**(不产生勾选);**右键不碰勾选集**。
批量条门槛是 **≥1**(勾一个就得拿到批量操作),最左那颗是全选框(✓ / − 两态)。
**不带修饰键的点击必须保持老语义**(目录展开 / 文件进编辑器);Ctrl / Shift / 勾选框都**不许
打开文件页签**。Shift 范围与全选的坐标是**当前渲染出来的可见行**(`walk` 收集的
`visiblePaths`)—— 折叠起来的子树不在范围里,**改排序会改范围落点**,这是刻意的。
勾选框与展开箭头**必须 `stopPropagation`**,否则点一下勾选框会顺手展开目录 / 打开文件。
23. **排序在渲染时做,折叠只用读过的层级**:`levels[dir].entries` 存后端原始顺序,排序在 `walk` 里
用 `sortEntries(entries, sort)` —— 换排序键**不许触发读盘**。名字比较**不用 `localeCompare`**
(locale 相关,会让同一棵树在不同机器上顺序不同,而顺序是范围选择的坐标)。目录链折叠的
**行代表链尾**(`chain` 最后一项就是行的 `path`),改名 / 删除 / 新建 / 拖入都作用在它上面;
为发现链而做的预读(`chainCandidates` + `preloadDir`)必须:① 每个路径只试一次(`preloaded`
记账,失败不重试);② 受 `TREE_PRELOAD_BUDGET` 限制;③ **不给预读目录订阅变更**(订阅只给
展开着的目录);④ 隐藏项不计入「唯一子项」判定。
24. **批量删除不许有「整批无条件」的口子**:先 `pruneForBatchDelete` 收敛选择集(父目录已选中就
去掉子项、工作区根不删)→ **一次确认** → **逐条取版本基线**(每条路径各自的令牌)→ **逐条
串行**发 `POST /workspace-editor/delete`(目录带 `recursive`)。部分失败必须:失败项**留在
选择里** + 一条带「重试删除」的横幅(重试重新取基线)。**不要**加一个「整批仍然删除」按钮,
也不要为了快改成并发。
25. **`client.js` 的顶层只允许注释 + 一个 IIFE,别的什么都不许放**:宿主
`@deepseek-ai/dsh-client-modules` 的 `buildComboScript` 把 profile 里**所有** client bundle
的源码**直接拼成一个 classic script**(`source += resource.source + ';\n'`),**不按包加作用域
隔离** —— 所有插件的顶层词法声明共享同一个全局作用域。两个插件各写一个同名顶层
`const PACKAGE_NAME`,整条 combo 就抛 `SyntaxError: Identifier … has already been declared`,
于是**一个 bundle 都不注册**,页面只报「loaded without registering … via
`__ModuleLoader__.load`」,**完全看不出是跟别的插件撞了名**(真踩过:与
`@dongasai/dsh-sidebar-terminal` 相撞,表现为整个插件加载失败并被用户在 profile 里关掉)。
所以:① 新常量一律放 IIFE 内部;② 改完跑 `reference/probes/combo-scope.mjs`(它按宿主的
拼法把 profile 里全部 bundle 拼起来解析,并会点名是哪两个包撞名)。
26. **拖拽落点按「这一行代表哪个目录」算,且行必须挡住冒泡**:目录行 = 它自己,**文件行 = 它
所在的目录**(VS Code 同款)。文件行必须算落点,因为**根目录自己没有行** —— 树一长、正文里
连空白都没有,只认目录行的话「搬到根」就没入口(用户正是这么反馈的)。行的 `onDragOver` /
`onDrop` **一律 `stopPropagation`**:指针在某一行上时落点归那一行,事件冒到正文会被当成
「根落点」(高亮跳到根、松手直接搬去根)。另外一开始拖就亮一条「工作区根目录」落点条
(`data-workspace-editor-files-drop-root`),那是根唯一**看得见**的落点;拖的东西本来就在
根下时不亮。空操作 / 非法落点(自己、原地、把目录拖进自己子树)**不发请求**。
## 代码风格
- 中文 JSDoc(`@param` / `@returns` / `@throws`);注释解释**为什么** —— 尤其是反直觉的取舍
(版本闸、脏缓冲区回报版本、页签顶替、三道闸的顺序),不解释语法。
- **`index.js`**:无分号、单引号、2 空格缩进;`function` 声明 + 顶层 `const` 常量;工具函数保持
纯函数,失败用 `RequestError`(带 `status` + 线上 `code`)表达。
- **`client.js`**:无分号、单引号、2 空格缩进;文案字典与 `CSS` 模板里**内层用双引号**
(`'…'` 包着 `"…"`),这是刻意的,不要「统一」成单引号。
- 组件用 `React.createElement`(`h`),不用 JSX —— 这个 bundle 没有构建步骤。
- 样式只挂组件本地 `