# dsh-md-live
**DSH Web 右侧栏的 Markdown 实时预览与编辑器**
把 `.md` 开在对话旁边:文件一变就重绘,可以直接编辑保存,还能把选中的段落送进输入框。
[](https://www.npmjs.com/package/dsh-md-live)
[](#兼容性)
[](LICENSE)
[](#已知限制)
[快速开始](#快速开始) · [特性](#特性) · [使用](#使用) · [工作原理](#工作原理) · [开发](#开发) · [更新日志](CHANGELOG.md) · [English](README.md)
> **一句话** —— 右侧栏里的一个 Markdown 标签页:渲染 GFM、KaTeX 和代码高亮,跟随 Agent 写下的文件,经会话沙箱保存,还能把选中的一段引用进输入框。
## 快速开始
```bash
dsh plugin --profile web add dsh-md-live
```
重启 harness 后刷新页面。只刷新页面不够:Host 半身是在 `dsh web` 启动时组合的,所以最好在服务停止时安装,装完再启动;或者在装完后把服务停掉重开。`package.json` 里的 `dsh.client` 声明会让 Host 自动把 `lib/client.cjs` 挂成这个包的客户端半身,不需要额外的 patch 行。
然后在右侧栏顶部点 `+`,在指南里选 **md-live**,再挑一个工作区里的 `.md`。
> **依赖**:DSH ≥ 0.2.1-rc.2。发布包里 Host 和 Client 两半都是构建好的,安装时不需要 Node.js 工具链;React 由 DSH 的浏览器模块表提供。
### 从源码安装
```bash
git clone https://github.com/lg114/dsh-md-live.git
cd dsh-md-live
npm install
npm run build
dsh plugin --profile web add .
```
构建需要 Node.js ≥ 18。
## 特性
下面这些全部在 Client 半身里。Host 半身只补两件事:`preview_md` 工具和写入 Remote。
### 找到文档
- **原生右侧 tab** —— 走 `ctx.sidebarRightTabs` + `sidebar.right.pane.tab` 注册,和 Files / Terminal 那些标签页同一套机制,不是往主区域塞面板。
- **全工作区扫描** —— 从会话的工作区根目录开始遍历整棵树,并行(每层 8 个并发)、逐层出结果:第一层扫完就先画出来,不等整棵树。
- **过滤规则可编辑** —— 默认跳过 `node_modules`、`.git`、`dist` 等目录;规则在界面上直接改、存在浏览器里,也可以一键全量扫描。
- **搜索与最近打开** —— 按路径片段过滤列表;最近打开过的文档排在最上面。
- **路径可见** —— 头部显示 `目录/文件名`(相对工作区),窄面板时目录先省略,文件名始终完整;悬停看绝对路径。
- **交给别处打开** —— 头部两个按钮:用系统默认应用打开、在文件管理器中显示。
### 阅读
- **实时刷新** —— 基于 `remote.workspaceFiles.changes` 的文件监听,保存即更新;只有监听不可用时才出现手动刷新按钮。
- **TOC** —— 自动提取标题,锚点由渲染阶段注入,点击真的能跳转;滚动时高亮当前所在章节,面板窄时目录可折叠成一条窄栏。
- **文档旁边的图能显示** —— 相对路径的 `` 会按文件自身所在目录解析,再由 Host 的文件端点提供,和 `.md` 放在一起的截图真的能显示出来;点一下图片可以放大到全尺寸。
- **链接点得动** —— 指向另一个 `.md` 的链接在当前 tab 打开它,`#锚点` 在文档内滚动,`https://` 链接交给浏览器而不是把整个应用导航走。
### 渲染
- **安全渲染** —— DOMPurify(HTML profile)净化,脚本、iframe、事件属性、`javascript:` 链接都会被剥掉。
- **代码高亮** —— highlight.js,按需注册 27 种常用语言。
- **数学公式** —— `$行内$` 和 `$$块级$$` 走 KaTeX。输出用 MathML 而不是 KaTeX 自己的 HTML:后者依赖 KaTeX 的 web 字体,那几百 KB 插件没办法提供,所以交给浏览器自己排版。
- **代码块可操作** —— 每块代码悬停时显示语言名和复制按钮;复制会去掉结尾换行,粘进终端不会自己执行。
- **frontmatter 不混进正文** —— 开头的 `---` 块被单独拎出来折叠显示,而不是变成一条多余的分隔线加一段 `title: …` 正文。
### 编辑
- **就地编辑与保存** —— **编辑**把正文换成 textarea,背后还是同一个文件;**保存**(或 `Ctrl/Cmd+S`)经 Host 写回,走的是当前会话自己的沙箱策略。保存是乐观并发:请求带着「打开编辑时的 version」,文件在底下被改过就弹「覆盖 / 重新加载」,而不是悄悄丢掉某一方。
- **草稿有守卫** —— 返回、从列表换文件、跟随 Agent 切换、浏览器卸载,都会先问;热更新也不会覆盖脏草稿。
### 和 Agent 一起用
- **跟随 Agent(面板没开也会弹出来)** —— Agent 在工作区里写完一个 `.md`,面板直接打开那个文件。订阅是插件级的,靠 `remote.session.follow` 跟着当前在屏的会话,所以不管之前有没有开着 tab 都生效。切换等的是 `tool/result` 而不是 `tool/call`,读到的是写完之后的内容而不是写之前的。面板设置里一个复选框可以关掉。
- **Agent 可以主动「拿给你看」** —— 注册了一个 `preview_md` 工具,Agent 被要求「打开/看看某个 md」时可以直接把文件推到面板上。它不受上面那个复选框影响:这一次是用户明确要求的。
- **选中即引用** —— 在预览里选中文字,下方浮出「加入对话」;点一下就变成带出处路径的引用块,插到当前会话输入框的**光标处**并聚焦,草稿、@ 文件引用和你已经打了一半的话都不受影响。若那一瞬间草稿正好变了,会明确告知而不是覆盖。
### 融入应用
- **阅读设置** —— 正文字号、行距、行宽,按浏览器记住。所有默认值都是「跟随设置」,也就是说在你明确改之前,排版仍然由 DSH 自己的 Markdown token 决定。
- **跟随设置** —— 颜色、字号、字体全部读 DSH 的 `--dsw-*` 设计 token,明暗主题、字号步进器、第三方主题都会跟着变。
- **跟随 DSH 的语言** —— 面板文案是一个 `md-live` locale 命名空间,带 `zh`/`en` 两份字典,所以跟着「设置 → 常规」里选的语言走,并且**实时切换**:slot 渲染器会带着新的 `t` 重新渲染 body 和标签页标题,不用刷新页面。拒绝信息以 code + 参数形式从 Host 传来,所以连沙箱拒绝也是读者当前的语言,而不是把 Host 的中文直接丢过去。
## 使用
1. 右侧栏顶部点 `+`,在指南里选 **md-live**,再挑一个 `.md`
2. 想换文件:点标题左侧的 `←` 回到列表
3. **编辑**就地改,`Ctrl/Cmd+S` 保存,**预览**回到渲染视图
4. 在预览里选中文字会浮出「加入对话」,`Esc` 关掉
列表底部那行 `已跳过 N 个目录(M 条过滤规则)` 点开就是规则编辑器:一行一个目录名,保存后立刻重新扫描;「恢复默认」回到内置列表,「全部显示」清空规则(`node_modules` 里的 md 也会列出来)。
规则存在 `localStorage` 的 `dsh-md-live.skip-dirs.v1`,按浏览器保存,不写回 profile 配置。
## 工作原理
```text
Client(全部能力都在这里)
├── 注册 tab 类型 sidebarRightTabs.register({ id, kind, title, guide })
├── 注册 body/title slots(key = id)
├── 文件列表 remote.workspaceFiles.list 逐层 BFS + 并发 + 渐进渲染
├── 文件内容 remote.workspaceFiles.readBytes → TextDecoder(返回 content + version)
├── 热更新 remote.workspaceFiles.changes → $stream + accept()
├── 跟随写入 remote.session.follow → tool/result → openRightbar + openTab(插件级)
└── 写入 remote.mdLive.writeText(sessionId, path, content, version) → Host fs + 会话沙箱策略
Host(lib/index.js)
├── preview_md 工具(可选):Agent 唯一能「请求面板打开文件」的通道
└── mdLive Remote (可选):MdLiveRemote 服务 — 会话查找 → 沙箱策略 → resolve → stat → 版本校验 → writeText
```
实现基本就是约束堆出来的,下面这几条都咬过人:
八个必须知道的约束
1. **`ctx.remote.*` 返回的是 Result 信封**,不是裸数据:`{ ok: true, value } | { ok: false, error }`。直接取字段会得到 `undefined` 而且不报错——空文件列表看起来就像"工作区里没有文件"。
2. **`list`/`readBytes` 的 path 不能为空**,Host 会抛 `path is required`。起点必须是会话的绝对 cwd(slot 的标准 prop `useSessions` → `byId[sessionId].cwd`)。
3. **`changes` 是 Remote 流**,要用 `remote.$stream({ name, open, ended })` 包一层并逐帧 `accept()`,否则上行窗口满了就静默停更。
4. **没有 host → client 的事件桥,但有会话流**。Host 侧 `ctx.emit` 的事件不会到达浏览器。客户端**能**观察的是 `remote.session.follow` —— 一条携带会话自身事件(包括 `tool/call`、`tool/result`)的 Remote 流。「跟随 Agent 写入」全靠它,也正因为如此 Host 半身可以保持为空。
5. **client bundle 必须是 `window.__ModuleLoader__.load({ id, factory })` 格式**:脚本执行时只注册工厂,`require` 由 loader 在物化时传入。丢掉这层包装,产物里的 `require("react")` 会在浏览器全局作用域执行,直接 `ReferenceError`。
6. **故意没有复用官方的 `MarkdownText`**。它在浏览器模块表里,用它能省约 480 KB 产物,但它从 mdast 树渲染:原始 HTML 原样注入、不做净化,标题不带 `id`,也不做语法高亮 —— 而这三件正是这个面板在「任意工作区文件」上必须做对的。它的 KaTeX 支持和代码工具栏是真实的;工具栏这里在现有渲染器上重做了,公式则用 MathML 交给浏览器排版,而不是 KaTeX 的 HTML(见特性)。
7. **自定义 Typert Remote 可以手写,不需要 codegen。** `mdLive/writeText` 的 manifest 是手写的,形状与 `dsh-typert-generator` 生成的完全一致。Host 服务继承 `TypertRemoteService`,手动在 prototype 上放 Remote 方法标记(`@Remote` 装饰器需要构建管线支持 Stage 3 装饰器)。Gateway 通过 descriptor 的 `namespace/method` 与 prototype 标记的 `method` 名匹配来连接两端。注册方式是 `new MdLiveRemote(ctx)`(`Service` 基类构造器调 `ctx.reflect.provide`)加 `ctx.typert.register({ package, descriptors })`。
8. **客户端的 Typert descriptors 与 Host manifest 分开手写。** 把 Host 的 `TYPERT` 常量导入客户端产物会拉进 `zod` 和 `createTypertRemoteInfo`(约 550 KB),但 Gateway 在客户端只需要 descriptor 元数据和 `invoke` 调度器 —— 两者都不碰 zod。所以 `src/client/descriptors.ts` 手写了同样的 descriptor 形状,省掉了导入成本。Host 新增 Remote 方法时,这里也要加对应的 descriptor;两个文件故意解耦。
### 写入链路
`workspaceFiles` 没有修改接口,所以写入是自己手写的 Typert Remote:`writeText(sessionId, path, content, version)` 四个位置参数。第一个不是普通字段,而是 `session` **lookup**——Gateway 用 `dsh-session` 注册的条目把这个 id 解析成活跃会话,Host 方法拿到的是会话对象本身。也就是说浏览器说不了「用什么 mode、写哪个根」,只能选「按谁的规矩来」。
1. `ctx.sandboxPolicy.resolve({ session })` 给出 mode(会话的 `sandbox/mode` 事件,没有就用部署默认 `DSH_PERMISSION_MODE ?? workspace-write`)和可写根(`session.header.cwd`)。
2. 这份策略作为 `fs.writeText` 的第五个参数传下去。`SandboxedFileSystem` 会重新 canonicalize 目标并做包含判定——`read-only` 直接拒,`danger-full-access` 不设栅栏。
3. 不传这个参数,就是之前那个 bug:后端退回部署策略,而部署策略的根是 Host 进程的 **cwd**,于是 `dsh web` 启动目录之外的文件全被拒,连会话自己的工作区也不例外。
兜底一律往「拒绝」一侧倒:lookup 没解析出对象就用裸 id 走 `ctx.get('sessions')` 再查一次;会话已经不在了就明确拒绝而不是猜一个策略;组合里没有 `sandboxPolicy`(纯 `fs-local`)就传 `undefined`,保持后端原有行为;认不出的 I/O 错误原样透出。它**不会**做的是自己放宽权限——不换根重试、不升格成 `danger-full-access`、不接受客户端传来的 mode 或 root。
## 开发
| 命令 | 作用 |
| --- | --- |
| `npm run build` | Host(`tsc`)+ Client(`esbuild`) |
| `npm run build:client` | 只构建 Client |
| `npm run verify` | 两端都跑,先 Host |
| `npm run verify:client` | 用 jsdom 挂载真实产物 —— 92 项检查 |
| `npm run verify:host` | 用假 ctx 跑 Host 半身 —— 28 项检查 |
| `npm run clean` | 删掉 `lib/` |
`verify:client` 会模拟 DSH 的模块加载器(注册 → 物化 → apply),再在 jsdom 里挂载 tab body,覆盖:注册与 slot key、Remote 信封解包、目录递归、过滤规则与持久化、渐进渲染、热更新、卸载清理、TOC 锚点、净化、代码高亮、图片与链接解析、外部打开调用、搜索、最近打开、目录折叠、阅读设置、代码工具栏、frontmatter、跟随 Agent 写入、数学公式、语言切换、引用浮层、导航守卫,以及编辑器:脏标记、保存、版本冲突弹窗。改完 client 代码先跑它,比在浏览器里点快得多。
`verify:host` 直接 import 构建好的 Host 半身、用一个假 cordis ctx 调 `apply`:注入了什么、工具的 schema、会拒绝哪些参数、descriptor 的 wire 参数、透传下去的每调用沙箱策略、拒绝信息怎么措辞,以及「注册抛错时只损失工具、不损失插件」。它跑不了真实的 Agent 轮次——那仍然要在浏览器里点。
仓库根目录的两个文件是给浏览器里看的、不属于自动化测试:`test.md` 是一份手动检查清单(frontmatter、三段代码块、一张相对路径图片并顺带验证 `..` 归一化、四类链接、锚点、表格与列表),`test-image.png` 是它引用的图。改完渲染相关代码,在面板里打开 `test.md` 过一遍——这是 `verify:client` 覆盖不到的那部分。
## 已知限制
- **写入走自定义 Remote,不是 `workspaceFiles`**:`workspaceFiles` 没有修改接口,所以由 `mdLive/writeText` 提供(见[写入链路](#写入链路))。保存需要**活跃**会话——mode 和可写根都是从它解析出来的;会话不活跃就明确拒绝,而不是拿猜的 mode 去写。Session id 由客户端提供,它只是选择器、不是授权:mode 和根由 Host 决定。这同时也是官方模型的现状:`workspaceFileScope` 就是对一个客户端提供的 id 做 lookup,DSH 认证的是**调用方**(Host/Origin 围栏之后的 owner Peer,加进程 token),而不是「这次调用属于哪个会话」。Remote 处理函数拿不到 Peer,所以插件自己没办法收紧这一层。
- **跟随靠的是工具参数,不是文件系统**:Agent 用 `bash`(`cat > x.md`、heredoc)改的 `.md` 不会被跟随,因为那次调用里没有路径。`write`、`edit`、`str_replace_editor` 会。
- **草稿扛不住用 `×` 关掉标签页**:右侧栏没有 per-tab 的关闭否决(`canCloseTab` 只拒绝「guide 是唯一固定页」这一种),关掉就卸载,草稿一起没了。守卫覆盖的是插件发起的导航(返回、列表、跟随)和浏览器 `beforeunload`,不包括应用内关闭。
- **只跟随在屏的会话**:后台会话里的写入不会弹面板,因为那不是读者正在进行的对话。
- **`preview_md` 不校验路径**:工具只负责把请求带过去,解析和读取由面板做,所以路径错了会在面板里报出来而不是在工具结果里。两边都解析等于对同一个问题给两个答案,而实际生效的是面板那个。
- **过滤规则只匹配目录名**:任意层级、忽略大小写,不支持 `docs/archive` 这类路径通配。
- **没有 Mermaid**:```` ```mermaid ```` 保持为带复制按钮的高亮代码块,不是图——测量数据见[路线图](#路线图)。
- **这版还不认识的拒绝 code 会原样显示**,也就是 Host 的中文:面板只翻译自己认识的 code,其余回退到 Host 自己的句子。
- **公式是 MathML,不是 KaTeX 的 HTML**:交给浏览器自带的排版引擎,所以不需要字体和样式表,但个别结构的观感比 KaTeX 的排版朴素一些。
- **体积**:入口 bundle 约 313 KB(marked + DOMPurify + hljs 核心)。KaTeX(372 KB)和 27 种 highlight.js 语言定义(227 KB)作为独立 chunk 按需加载——只在文档包含公式或代码块时才加载。
## 路线图
- [x] 写入 API(自定义 Typert Remote `mdLive/writeText`,带版本校验,受会话沙箱策略约束)
- [x] 编辑器(textarea + 脏标记 + 保存 + 版本冲突弹窗)
- [x] 代码拆分(KaTeX 和 highlight.js 语言定义通过 `require.async` 作为独立 chunk 按需加载)
有一项故意不在列表里:**Mermaid** 是量出来的,不是猜的:打包后增加 11.2 MB(未压缩)、5.0 MB(压缩)。它的 ESM 入口只有 54 KB,是因为内部拆成了动态 import,而这种模块格式必须把它们全部内联。所以 ```` ```mermaid ```` 围栏保持为带复制按钮的高亮代码块——对一个 5 MB 的引擎来说,这个交换是划算的。即使支持 chunk 加载,npm 发布包仍然会携带完整的约 11 MB,每个用户都会下载,无论是否使用 Mermaid。
## 兼容性
| 组成 | 要求 |
| --- | --- |
| DSH | ≥ 0.2.1-rc.2 —— `sidebarRightTabs`、`sidebar.right.pane.tab`、`remote.workspaceFiles`;写入 Remote 依赖 `typert`;文案依赖 `locale`;chunk 加载依赖 `require.async` |
| 沙箱服务 | 组合里有 `sessions`、`sandboxPolicy` 时写入链路会读它们;没有 `sandboxPolicy`(纯 `fs-local`)时不传策略,保持后端原有行为 |
| 桌面动作 | `remote.session.openWorkspacePath` / `canOpenWorkspacePath`;Host 没有桌面可打开时按钮会自动隐藏 |
| `preview_md` | 只需要 `tools` 服务 —— 工具是普通注册定义,不会往 profile 里装任何 Host 包 |
| 数学公式 | `katex` 0.16,直接引它的压缩版——客户端产物故意不压缩,引未压缩入口体积会翻倍 |
| 构建 | Node.js ≥ 18 |
| 运行时 | React ≥ 18,由 DSH 的浏览器模块表提供,不打包进产物 |
## 许可证
MIT —— 见 [LICENSE](LICENSE)。
---
**有问题或想加功能?** [提一个 issue](https://github.com/lg114/dsh-md-live/issues)。