Compare commits

...
9 Commits
Author SHA1 Message Date
Menghuan1918 0bcafd476d docs(readme): complete the v0.13.1 changelog (mermaid safe render, terminal shell, aggregate double-mount, rc.7 peers) 2026-08-18 21:46:06 +08:00
Atlas Zeng e8757026a1 fix(deps): align @deepseek-ai/* peers with DSH 0.1.0-rc.7 (fixes #206) (#207) 2026-08-18 21:41:28 +08:00
Atlas Zeng b5e8665499 fix(bundle): 聚合双挂载自动退让 —— 已有同包名启用挂载时不再挂自己 (#200)
* fix(bundle): 聚合双挂载自动退让 —— 已有同包名启用挂载时不再挂自己

@linxin666/dsh-web-ui-all 等聚合包以独立条目 id(web-ui-better-sidebar)
挂载 dsh-better-sidebar 时,插件自身 bundle patch 的 better-sidebar 行会
与之双挂载:两个实例都注册 /sidebar/api,第二个抛 duplicate prefix route,
整个插件树启动失败。

better-sidebar 行的 disabled 改为 !!js 表达式:loader 按 entry-list 顺序
处理本行时,若已存在其他启用中的同包名挂载(排除自身 id),本行自动
disabled,由聚合行接管 sidebar。顺序假设(聚合 bundle 先于本包)写入注释。

- cordis.patch.yml:守卫表达式 + 说明注释
- README:常见问题「两个侧边栏」更新为自动退让说明
- scripts/e2e-aggregate-mount.sh:聚合双挂载真实挂载回归(scratch profile
  聚合先行 + 插件后到 → boot 无 duplicate、/sidebar/api 存活)
- tests/fixtures/aggregate-better-sidebar:最小聚合 fixture

* ci(plugin-mount): 增加聚合双挂载回归 step

复用已装好的钉住版 DSH CLI 与已 pack 的 tarball,跑
scripts/e2e-aggregate-mount.sh(fixture 聚合先行 → 插件 bundle 行必须
自动退让,boot 无 duplicate prefix route)。

* fix(review): 双轴审查反馈——双语 README、注释补全、脚本健壮性

- README_EN.md:FAQ「两个侧边栏」行同步自动退让说明(双语惯例)
- cordis.patch.yml:注释补上计划要求的「反向顺序为已知限制,
  不引入运行时单例守卫」一句
- scripts/e2e-aggregate-mount.sh:TMP_DIR 纳入 trap 清理;FIXTURE_TGZ
  glob 加 2>/dev/null || true 让 die 路径生效; 花括号显式化
  (LANG=UTF-8 下 bash 会把紧跟的全角字符吞进变量名,实测复现)
- tests/fixtures/aggregate-better-sidebar/package.json:补 "type": "module"
  与 lib/index.js 的 ESM 一致

* fix(e2e): 保护调用方提供的 DSH_HOME_BASE——只删除本调用拥有的子目录

chatgpt-codex-connector P2:当 DSH_HOME_BASE 指向已存在的目录(如开发者
真实 ~/.dsh)时,脚本会把 scratch profile 写进去并在退出时 rm -rf 整个
根目录。现在脚本始终在根目录下新建本调用拥有的独立子目录
(DSH_HOME_BASE/dsh-e2e-*.XXXXXX),只写入/删除该子目录;调用方提供的
目录本身绝不触碰。缺省路径仍用系统临时目录。

- scripts/e2e-aggregate-mount.sh:SCRATCH 子目录化 + 清理逻辑
- scripts/e2e-mount.sh:同款存量问题一并修复(bot 只审了 diff 内脚本)

* test(e2e): 用真实 API 方法探活——terminal.deps 需返回 ok:true

chatgpt-codex-connector P2:generic missing-route 404 无法证明 sidebar
路由真的注册了。新增对插件自身 handler /sidebar/api/terminal.deps 的
POST 探活,要求 HTTP 200 + {"ok":true};未知方法 404 保留为次要断言。
2026-08-18 20:29:14 +08:00
NuperAki c973d6ec1b feat(terminal): 支持配置 shell 与 shellArgs,终端标题使用 shell 名 (#125)
- 支持通过 cordis.patch.yml 指定终端 shell
- 支持 shellArgs 带参启动终端 shell
- 终端标题改用 shell 名,内部使用 uuid 管理
2026-08-18 19:56:04 +08:00
Atlas Zeng 498528d2eb Update README.md 2026-08-18 14:45:06 +08:00
Atlas Zeng 23132560fb Update README.md 2026-08-18 14:32:16 +08:00
Atlas Zeng 5e416eccef feat(mermaid): Markdown 预览安全渲染 Mermaid 图表(strict 清洗 + 懒加载 chunk + 点击放大) (#164)
* feat(mermaid): 新增 mermaid 懒加载 chunk(构建链四处同步 + mermaid 依赖)

tsdown CHUNKS / bundle-route CHUNK_NAMES / chunk-loader ChunkName /
package.json files 同步注册 mermaid chunk;mermaid ^11.16.1 进依赖与
lockfile;chunk 构建注入 uuid 浏览器入口别名,绕开 node:crypto 纯度门。

* feat(mermaid): Markdown 预览安全渲染 mermaid 图表 + 点击放大弹窗

- splitMermaidBlocks 纯函数按 fence 切块,无 mermaid 文件零行为变化;
- mermaid.initialize 走 securityLevel strict + htmlLabels:false,不应用
  bindFunctions(静态图);渲染 SVG 经 mermaid-sanitize 二次清洗:
  删 foreignObject/script/外来 HTML 元素、剥 @*/on* 属性、移除全部
  href/xlink:href,仅接受 SVG 根,解析失败返回空串;
- 点击图表弹出放大弹窗(滚轮缩放/拖拽平移/快捷键,借鉴 PR #75 交互,
  基于已清洗 SVG 克隆重写,不新增攻击面);
- 深浅色跟随重渲、失败回退错误条 + 原码、CodeBlock 同款 chrome。

* test(mermaid): splitter/sanitize 单测 + chunk 清单同步 + 挂载冒烟 diagram.md

- mermaid-blocks.spec 10 例 fence 切块语义;
- mermaid-sanitize.spec 8 例 XSS(foreignObject/on*/@*/href/xlink:href/
  script/外来元素/畸形 XML/非 SVG 根);
- bundle-route / chunk-artifact / manifest-consistency 清单加入 mermaid
  (chunk-artifact 补 addEventListener stub);
- 挂载冒烟新增 diagram.md:client-mermaid.js 往返、SVG 节点文字、
  点击弹窗 Esc 关闭、preview 模式编辑器隐藏。

* docs(mermaid): 安全渲染设计文档 + lazy-chunks 表 + README 双语更新

设计文档记录基线决策(PR #99 架构 + PR #75 点击放大)、安全模型
(strict/htmlLabels/清洗/href 剥离/无 bindFunctions)、验证与取舍。

* fix(mermaid): sanitizer 属性名大小写归一,堵住混合大小写属性绕过(CR P1)

XML 属性名保留大小写,而输出会经 HTML 解析归一化(dangerouslySetInnerHTML),
oNload/HREF/xlink:HREF 这类混合大小写属性此前会漏过清洗。比较前先
toLowerCase(),按原名删除;新增混合大小写事件/链接属性回归用例。

* fix(mermaid): sanitizer 元素黑名单大小写归一,堵住混合大小写元素绕过(CR P1)

XML 元素名保留大小写,querySelectorAll('script') 等选择器匹配不到
<sCrIpT>/<foreignobject>,而输出经 HTML 解析会被归一化为 SVG
script/foreignObject(可执行/可注入 HTML)。改为遍历全部元素按
localName.toLowerCase() 命中小写黑名单后整节点删除;新增混合大小写
script/foreignObject/外来 HTML 元素回归用例(jsdom 实证:旧选择器
命中 0,新逻辑命中 2)。

* fix(mermaid): 单次 Markdown 解析 + 占位交换(CR P1)+ CommonMark fence 规则(CR P2)

P1(跨 fence 语义):弃用逐块 MarkdownText 拆分,改为整篇文档单次
MarkdownText 渲染,布局后把 language-mermaid 代码块交换为 MermaidDiagram
(React 管理的 .md-code-block 宿主保留在树中、display:contents 抑制盒子,
协调安全;块不再 mermaid 时还原原 children)。新增
tests/mermaid-markdown.spec.tsx 与 e2e 跨 fence 引用链接断言。

P2(fence 匹配):支持 3+ 反引号与波浪号开 fence;闭 fence 须同字符且
长度不小于开 fence;反引号 info string 含反引号不视为开 fence;修复
带缩进开 fence 的 info 提取。blocks 单测 +3 例。
2026-08-18 01:22:28 +08:00
Atlas Zeng 9bea6c5539 Update README.md 2026-08-18 01:15:55 +08:00
Menghuan1918 af637b4f1b docs(readme): complete the v0.13.0 changelog (editor-explorer merge, select rows, mutual exclusion) 2026-08-18 00:55:29 +08:00
46 changed files with 3589 additions and 1085 deletions
+10 -1
View File
@@ -82,7 +82,7 @@ jobs:
# (dsh-base / dsh-web-app) resolves from this installation. Keep in
# step with the plugin's @deepseek-ai/* peer range when bumping.
- name: Install dsh CLI (pinned)
run: npm install -g @deepseek-ai/dsh@0.1.0-rc.6
run: npm install -g @deepseek-ai/dsh@0.1.0-rc.7
# lib/ is gitignored; the tarball is produced from this build.
- name: Build and pack the npm tarball
@@ -94,6 +94,15 @@ jobs:
- name: Mount + headless-render smoke
run: pnpm test:mount
# Aggregate double-mount regression: a fixture aggregate bundle mounts
# dsh-better-sidebar under its own entry id BEFORE the plugin's bundle
# row; the plugin must back off (no duplicate /sidebar/api, boot OK).
# Uses the pinned DSH CLI installed above.
- name: Aggregate double-mount regression
env:
DSH_CMD: dsh
run: bash scripts/e2e-aggregate-mount.sh
- name: Upload Playwright report on failure
if: failure()
uses: actions/upload-artifact@v4
+1 -1
View File
@@ -21,7 +21,7 @@ better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务(Cordis context
2. `scripts/e2e-mount.sh` 用官方 CLI 把它装进一个**全新 scratch profile**(`dsh plugin --profile web add file:<tarball>`,触发 `dsh.profile.bundles` 协调),然后启动真实 `dsh web`(keyless,`--port 0`)。
3. `tests/e2e/mount.e2e.ts`(Playwright Chromium)加载页面,断言外壳与 `[data-dsh-better-sidebar]` 挂载、无 `dsh-better-sidebar:` 错误条、无 pageerror/插件 console 错误,显式展开面板(`openByDefault` 默认关)后通过「+ 菜单」逐个打开内置 tab(含终端懒加载 chunk)深扫,再经 Files 文件窗口的内嵌树打开 seed 文件强制加载 editor 懒加载 chunk(`client-editor.js`,原地模式:seed 的 home tab 原地切换到文件)——缺失的内置 tab 或 chunk 都会使门禁变红。
本地跑:`pnpm build && pnpm pack && pnpm exec playwright install chromium && pnpm test:mount`(需 PATH 上有 `dsh` 或可经 npx 拉取)。DSH CLI 版本在 CI 钉住 `@deepseek-ai/dsh@0.1.0-rc.6`(与插件 peer 范围同步)。`tests/e2e` 的 spec 命名 `*.e2e.ts` + vitest `exclude` 双保险与 vitest 隔离;**改动 vitest `exclude` 时必须保留默认排除项**(`exclude` 会整体替换默认值)。
本地跑:`pnpm build && pnpm pack && pnpm exec playwright install chromium && pnpm test:mount`(需 PATH 上有 `dsh` 或可经 npx 拉取)。DSH CLI 版本在 CI 钉住 `@deepseek-ai/dsh@0.1.0-rc.7`(与插件 peer 范围同步)。`tests/e2e` 的 spec 命名 `*.e2e.ts` + vitest `exclude` 双保险与 vitest 隔离;**改动 vitest `exclude` 时必须保留默认排除项**(`exclude` 会整体替换默认值)。
### npm 发版(GitHub Release → npm publish)
+29 -37
View File
@@ -15,13 +15,13 @@
</div>
<div align="center">
<video src="https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86" muted autoplay loop playsinline controls width="100%"></video>
<img alt="dsh-better-sidebar 工作台截图" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" />
<video src="https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86" muted autoplay loop playsinline controls width="100%"></video>
</div>
## ✨ 功能一览
- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown / HTML / PDF / Office 内联预览
- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown(含 Mermaid 图表,strict 安全渲染 + 点击放大)/ HTML / PDF / Office 内联预览
- **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整)
- **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存 / 提交 / 还原
@@ -29,7 +29,7 @@
- **🪟 双工作台**:右侧栏 + 底部面板;拖 Tab 拆分 / 合并分栏(可跨面板),移动端自动合并全宽抽屉
- **🔁 会话隔离**:布局 / Tab / 面板按会话持久化,陈旧状态自动净化
- **⚙️ 声明式设置**:设置页「侧边卡片」逐项独立开关,二级设置经齿轮弹窗
- **⚡ 按需加载**:启动只拉 ~325KB 核心,终端 / 编辑器等重依赖用到才按需拉取([设计文档](docs/plans/2026-08-12-lazy-chunks-design.md))
- **⚡ 按需加载**:启动只拉 ~325KB 核心,终端 / 编辑器 / Mermaid 图表等重依赖用到才按需拉取([设计文档](docs/plans/2026-08-12-lazy-chunks-design.md))
- **🌏 多语言**:界面文案跟随 DSH 语言(zh / en)实时切换
> 🔌 **核心理念**:服务优先——内置的 7 tab + 6 viewer 与第三方插件通过同一套 `ctx.betterSidebar` API 注册,能力完全对等;官方不再内置、可由生态提供的功能,交由生态插件实现。接入文档见下方「🔌 服务化」与 [外部插件接入指南](./docs/external-plugin-guide.md)。
@@ -37,15 +37,30 @@
## 🆕 最近更新
<div align="center">
<a href="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0"><img width="45%" alt="服务化基座截图" src="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0" /></a>
<a href="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e"><img width="45%" alt="添加插件截图" src="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e" /></a>
<a href="https://github.com/user-attachments/assets/d2aea86b-a776-4f01-a6b8-b26b27314336"><img width="33%" alt="侧边栏" src="https://github.com/user-attachments/assets/d2aea86b-a776-4f01-a6b8-b26b27314336" /></a>
<a href="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0"><img width="33%" alt="服务化基座截图" src="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0" /></a>
<a href="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e"><img width="33%" alt="添加插件截图" src="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e" /></a>
</div>
### v0.13.1
**✨ 新功能**
- 📊 **Markdown 预览安全渲染 Mermaid 图表**([#164](https://github.com/omdsh-dev/DSH-better-sidebar/pull/164)):预览的 md 含 mermaid fence 时按需下发 `client-mermaid.js` chunk(~7MB,无 mermaid 文件零加载);纵深防御渲染——`securityLevel: 'strict'` + `htmlLabels: false`(节点文字走真实 SVG `<text>`)+ SVG 注入前二次清洗(删 `foreignObject`/`script`/外来 HTML 元素、剥 `@*`/`on*`/`href` 属性);点击图表在弹窗中放大(滚轮以鼠标为中心缩放、拖拽平移、工具栏与快捷键),深浅色跟随重渲、解析失败回退原码
- 🖥️ **终端 shell 与 shellArgs 可配置**([#125](https://github.com/omdsh-dev/DSH-better-sidebar/pull/125)):`cordis.patch.yml` 的 `better-sidebar.config` 可指定 `shell` / `shellArgs`(`shellArgs` 非空时完全替换默认参数;未配置维持自动解析 `$SHELL` / 登录 shell / `powershell.exe` 原行为),UI 终端与 agent 终端(`terminal_create`)同时生效;终端 tab 标题改用 shell 名(bash / zsh / powershell),内部标识改 UUID,同 shell 可开多个终端
**🐛 修复**
- 🔗 **聚合双挂载自动退让**([#200](https://github.com/omdsh-dev/DSH-better-sidebar/pull/200)):聚合包(如 dsh-web-ui-all)以独立条目 id 挂载同包时,`cordis.patch.yml` 的守卫表达式自动禁用自身 `better-sidebar` 行,不再重复注册 `/sidebar/api` 导致 `duplicate prefix route` 整个插件树启动失败(`dsh web` 崩溃);独立安装行为不变
- 🔧 **peer 依赖对齐 DSH 0.1.0-rc.7**([#207](https://github.com/omdsh-dev/DSH-better-sidebar/pull/207),修复 [#206](https://github.com/omdsh-dev/DSH-better-sidebar/issues/206)):全部 `@deepseek-ai/*` peer / devDependencies 从 `^0.1.0-rc.6` 升至 `^0.1.0-rc.7`,CI 挂载冒烟同步钉版——消除主框架升至 rc.7 后 rc.6 / rc.7 混用依赖树导致的 `agent-presets: refusing to compose an unscoped context`(选模型 / 发消息报错)
### v0.13.0
**✨ 新功能**
- 🔀 **与 dsh-web-ui 家族右侧面板互斥**:读取 `aionui-panel` 设置命名空间的提供方选择——当选择「使用 aionui-panel」时,整个 better-sidebar(右侧栏 / 底部面板 / 浮动入口 / 各类接管)不再挂载;选择 DSH-better-sidebar(或未安装 aionui)时正常。设置页保存后实时生效(settings-document 推送),无需刷新
- 📁 **文件窗口与资源管理器二合一**([#151](https://github.com/omdsh-dev/DSH-better-sidebar/pull/151)):新 `editorExplorer` 设置(默认开,编辑器卡齿轮)——文件 tab 增加路径输入框头部 + 可开关的右侧停靠文件树(每 tab 记忆展开/宽度,左缘拖拽调宽 160~480px,全局文件名搜索走 host `fs.search` 路由,预算封顶并跳过 `.git` / 符号链接目录);合并模式下树点击 / 输入框 Enter **原地切换**当前 tab,独立模式按路径新开;新会话默认 seed 空文件窗口(`Files`)替代 explorer tab,无路径窗口在合并模式为带 chrome 的空文件窗口、独立模式为纯资源管理器;树右键提供「在新 Tab 中打开」「在侧边打开」(split)
- 🎛️ **声明式设置 select 行**([#151](https://github.com/omdsh-dev/DSH-better-sidebar/pull/151)):设置项新增 `type: 'select'`(`options` 支持 value/title/desc/icon,`multi` 多选存数组);带图标的选项渲染大图标选项卡、收起态同样显示图标;`editorExplorer` 改为图标化下拉(合并 / 独立);能力清单新增 `settingSelect`
- 🔀 **与 dsh-web-ui 家族右侧面板互斥**([#181](https://github.com/omdsh-dev/DSH-better-sidebar/pull/181)):读取 `aionui-panel` 设置命名空间的提供方选择——当选择「使用 aionui-panel」时,整个 better-sidebar(右侧栏 / 底部面板 / 浮动入口 / 各类接管)不再挂载;选择 DSH-better-sidebar(或未安装 aionui)时正常。设置页保存后实时生效(settings-document 推送),无需刷新
### v0.12.3
@@ -65,34 +80,6 @@
- 🐛 **node-pty 加载失败不再拖垮 server**([#140](https://github.com/omdsh-dev/DSH-better-sidebar/issues/140)):宿主半改为懒加载 node-pty,缺失时插件照常挂载,终端以修复提示横幅(可复制命令 + 重试按钮)呈现,agent 终端工具自动跳过
- 🧪 测试工程:单元测试拆分(#141)+ smoke 偶发失败修复
**🚀 工程**
- 接入 GitHub Release 自动发版 npm(Trusted Publishing,产物带 provenance),本版起打 tag 即自动发布([#148](https://github.com/omdsh-dev/DSH-better-sidebar/pull/148))
### v0.12.2
- 📐 **位置兼容模式**:设置页新增开关:为 Windows 右上角原生标题栏预留顶部空间,侧边栏按钮与内容整体下移(默认关闭);下移距离可在齿轮弹窗中自定义(0–120px)
- 🔌 **服务化基座**:完整类型导出 + `version`/`features` 能力探测、状态订阅(`getSnapshot`/`subscribeState`)、tab 角标、`onOpen`/`onActivate`/`onClose` 生命周期回调、`updateTab`/`activateTab`/`openFile`、定向打开、`meta` 跨刷新持久化、插件自有设置(`pluginToggles`/`render`)、外链点击目标认领(`urlTarget`)
- ➕ **添加插件**:设置页「推荐插件目录」+ 一键复制安装命令;内置 Office 预览迁至推荐插件
- 🖱️ **标签页滚轮**:标签页栏支持鼠标滚轮横向滚动
- 🐛 **修复**:远程访问 403(信任栅栏改用 `trustedHosts`)、侧边栏崩溃 [#31](https://github.com/omdsh-dev/DSH-better-sidebar/issues/31)、Windows 下 HTML 预览盘符路径
### v0.12.1
- 🔌 **服务化基座**:完整类型导出 + `version`/`features` 能力探测、状态订阅(`getSnapshot`/`subscribeState`)、tab 角标、`onOpen`/`onActivate`/`onClose` 生命周期回调、`updateTab`/`activateTab`/`openFile`、定向打开、`meta` 跨刷新持久化、插件自有设置(`pluginToggles`/`render`)
- ➕ **添加插件**:设置页「推荐插件目录」+ 一键复制安装命令;内置 Office 预览迁至推荐插件
- 🖱️ **标签页滚轮**:标签页栏支持鼠标滚轮横向滚动
- 🐛 **修复**:远程访问 403(信任栅栏改用 `trustedHosts`)、侧边栏崩溃 [#31](https://github.com/omdsh-dev/DSH-better-sidebar/issues/31)、Windows 下 HTML 预览盘符路径
> 📝 说明:0.12.0 正式版因 npm 判定版本已发布无法复用,正式发布改用 0.12.1,两者内容一致。
### v0.12.0
- 🔌 **服务化基座**:完整类型导出 + `version`/`features` 能力探测、状态订阅、tab 角标、生命周期回调、定向打开、`meta` 跨刷新持久化、插件自有设置
- ➕ **添加插件**:设置页「推荐插件目录」+ 一键复制安装命令;内置 Office 预览迁至推荐插件
- 🖱️ **标签页滚轮**:标签页栏支持鼠标滚轮横向滚动
- 🐛 **修复**:远程访问 403(信任栅栏改用 `trustedHosts`)、侧边栏崩溃 [#31](https://github.com/omdsh-dev/DSH-better-sidebar/issues/31)、Windows 下 HTML 预览盘符路径
## 🚀 安装
**前置**:已装好 DSH(`dsh web` 能正常运行),Node.js ≥ 20、pnpm ≥ 10。
@@ -122,7 +109,7 @@ dsh plugin --profile web add dsh-better-sidebar@latest
| 报 `Ignored build scripts` | pnpm 11 拦截构建脚本。在 profile 目录(`~/.dsh/profiles/web`)跑 `pnpm approve-builds --all`。 |
| 报 `minimum release age` / 版本不足 24h | 装的版本发布不足 24 小时。等 24h 或重跑一次(pnpm 会自动补 `minimumReleaseAgeExclude`)。 |
| 报「找不到 profile 目录」 | 先跑一次 `dsh web`,让它初始化 `~/.dsh/profiles/web`。 |
| 页面出现**两个侧边栏** | 双挂载:`~/.dsh/profiles/web/cordis.patch.yml` 还留着旧的手动挂载行,删掉那段 `- insert: ... better-sidebar ...`。 |
| 页面出现**两个侧边栏** | 双挂载。旧的手动挂载行:`~/.dsh/profiles/web/cordis.patch.yml` 还留着 `- insert: ... better-sidebar ...`,删掉那段(同 id 重复挂载 loader 会直接报 `duplicate loader entry id`)。聚合包(如 `@linxin666/dsh-web-ui-all`)以**不同 id** 挂载本包时,0.13.x 起插件自身 bundle patch 会自动退让(检测到已有启用中的同包名挂载就不挂自己),无需手动处理;若仍双挂载,先确认聚合包的 bundle 顺序在 `dsh-better-sidebar` 之前。 |
| Windows 下终端无法使用 | `node-pty` 依赖预编译二进制;若当前 Node 版本没有对应产物,需装编译工具链(VS Build Tools)。主流 Node 版本一般已有预编译。 |
| 终端提示「node-pty 加载失败」 | `node-pty` 安装缺失/损坏(如 pnpm 拦截了构建脚本)。终端横幅会给出修复命令:复制到 DSH 所在环境的终端/cmd 执行(在 `~/.dsh/profiles/web` 下 `pnpm approve-builds --all && pnpm rebuild node-pty`),完成后重启 DSH 并点重试。插件与 DSH 核心使用同一 `node-pty@^1.1.0`,修复后两者同步恢复。 |
| 提示 `dsh: command not found` | 先安装 DSH;或直接用 `npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar@latest`。 |
@@ -138,10 +125,15 @@ dsh plugin --profile web add dsh-better-sidebar@latest
1. git clone https://github.com/omdsh-dev/DSH-better-sidebar.git ~/Code/DSH-better-sidebar
cd ~/Code/DSH-better-sidebar && pnpm install && pnpm build
2. ~/.dsh/profiles/web/package.json 的 dependencies 写 "dsh-better-sidebar": "link:<克隆目录绝对路径>"
3. ~/.dsh/profiles/web/cordis.patch.yml 追加挂载行:
3. ~/.dsh/profiles/web/cordis.patch.yml 追加挂载行(需要指定终端 shell 时,在行内加 `config.shell`;`config.shellArgs` 可带参启动,非空时替换默认的 `-l`。不填则自动解析 `$SHELL` / 登录 shell / powershell.exe):
- insert:
- id: better-sidebar
name: 'dsh-better-sidebar'
config:
shell: /bin/zsh
shellArgs:
- --noprofile
- --no-rc
4. 在 ~/.dsh/profiles/web 执行 pnpm install
5. 硬刷新浏览器(Cmd/Ctrl+Shift+R)即可看到效果(client 改动无需重启 DSH;host 半改动才需重启)
```
@@ -207,7 +199,7 @@ v0.12.1 补齐基座能力(完整类型导出、能力探测、状态订阅、
## 🛠️ 开发与构建
```sh
pnpm install # @deepseek-ai/* 已发布到 npm(^0.1.0-rc.6),直接解析、无需令牌
pnpm install # @deepseek-ai/* 已发布到 npm(^0.1.0-rc.7),直接解析、无需令牌
pnpm typecheck # tsc --noEmit
pnpm build # → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types
pnpm test # vitest(含 manifest 一致性守卫,需先 build)
+29 -6
View File
@@ -21,7 +21,7 @@
## ✨ Features
- **🗂️ File Workbench**: file explorer (lazy-loading tree; symlinks show their target kind — directory links expand, dangling links flagged) + CodeMirror editor; inline preview for images / Markdown / HTML / PDF / Office
- **🗂️ File Workbench**: file explorer (lazy-loading tree; symlinks show their target kind — directory links expand, dangling links flagged) + CodeMirror editor; inline preview for images / Markdown (incl. Mermaid diagrams, strict-mode safe rendering + click-to-zoom) / HTML / PDF / Office
- **🌐 Embedded Browser**: multiple web tabs with back / forward / refresh; content runs in a sandboxed iframe; external links are routed by protocol by default — HTTP opens in the sidebar, HTTPS goes to the system browser (both adjustable in settings)
- **💻 Real Terminal**: xterm.js + node-pty real shell, reconnect with transcript replay; optionally injects `terminal_*` tools for the model
- **🌿 Git Panel**: real diff + VSCode-style diff tabs, history, right-click to stage / commit / revert
@@ -29,7 +29,7 @@
- **🪟 Dual Workbench**: right sidebar + bottom panel; drag tabs to split / merge panes (cross-panel), mobile auto-merges into a full-width drawer
- **🔁 Session Isolation**: layout / tabs / panels persisted per session, stale state auto-purged
- **⚙️ Declarative Settings**: per-item toggles in the "Side Cards" settings section, secondary settings via the gear dialog
- **⚡ On-demand Loading**: only ~325KB core at startup; heavy deps (terminal / editor) load on demand ([design](docs/plans/2026-08-12-lazy-chunks-design.md))
- **⚡ On-demand Loading**: only ~325KB core at startup; heavy deps (terminal / editor / mermaid diagrams) load on demand ([design](docs/plans/2026-08-12-lazy-chunks-design.md))
- **🌏 i18n**: UI text follows DSH's language (zh / en) with live switching
> 🔌 **Core principle**: service-first — the 7 built-in tabs + 6 viewers register through the same `ctx.betterSidebar` API as third-party plugins, with fully equal capabilities; anything the ecosystem can provide better is delegated to ecosystem plugins. See the "🔌 Service" section below and the [external plugin guide](./docs/external-plugin-guide.md).
@@ -41,11 +41,29 @@
<a href="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e"><img width="45%" alt="Add Plugins screenshot" src="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e" /></a>
</div>
### v0.13.1
**✨ New features**
- 📊 **Safe Mermaid rendering in the Markdown preview** ([#164](https://github.com/omdsh-dev/DSH-better-sidebar/pull/164)): when a previewed md file contains mermaid fences, a `client-mermaid.js` chunk (~7MB) is served on demand (zero load without mermaid); defense-in-depth rendering — `securityLevel: 'strict'` + `htmlLabels: false` (node labels use real SVG `<text>`) + a second sanitize pass before SVG injection (foreignObject/script/foreign HTML elements removed, `@*`/`on*`/`href` attributes stripped); click a diagram to zoom in a modal overlay (wheel zoom centered on the cursor, drag pan, toolbar & shortcuts), re-renders with light/dark theme, falls back to the raw code block on parse failure
- 🖥️ **Configurable terminal shell & shellArgs** ([#125](https://github.com/omdsh-dev/DSH-better-sidebar/pull/125)): `cordis.patch.yml` `better-sidebar.config` can set `shell` / `shellArgs` (a non-empty `shellArgs` fully replaces the defaults; unset keeps the previous auto-resolution of `$SHELL` / login shell / `powershell.exe`), applied to both UI terminals and agent terminals (`terminal_create`); terminal tab titles now show the shell name (bash/zsh/powershell) and internal tab ids use UUIDs so the same shell can open multiple terminals
**🐛 Fixes**
- 🔗 **Aggregate double-mount auto-yield** ([#200](https://github.com/omdsh-dev/DSH-better-sidebar/pull/200)): when an aggregate package (e.g. dsh-web-ui-all) mounts the same package under its own entry id, the guard expression in `cordis.patch.yml` disables the plugin's own `better-sidebar` row so `/sidebar/api` is no longer registered twice (`duplicate prefix route` crashing the whole plugin tree / `dsh web`); standalone installs behave as before
- 🔧 **Peer dependencies aligned to DSH 0.1.0-rc.7** ([#207](https://github.com/omdsh-dev/DSH-better-sidebar/pull/207), fixes [#206](https://github.com/omdsh-dev/DSH-better-sidebar/issues/206)): all `@deepseek-ai/*` peer/devDependencies raised from `^0.1.0-rc.6` to `^0.1.0-rc.7`, and the CI mount lane pins the same version — eliminating the mixed rc.6/rc.7 dependency tree (after the main framework moved to rc.7) that surfaced as `agent-presets: refusing to compose an unscoped context` when picking a model / sending a message
### v0.13.0
**✨ New features**
- 🔀 **Mutual exclusion with the dsh-web-ui family right panel**: reads the `aionui-panel` settings namespace's provider choice — when "Use aionui-panel" is selected, the whole better-sidebar (right sidebar / bottom panel / floating entry / all takeovers) does not mount; with DSH-better-sidebar (or no aionui installed) it behaves as before. Takes effect live after a settings save (settings-document push), no reload needed
- 📁 **Files window merged with the explorer** ([#151](https://github.com/omdsh-dev/DSH-better-sidebar/pull/151)): new `editorExplorer` setting (default on, editor card gear) — file tabs gain a path-input header plus a toggleable right-docked file tree (per-tab open/width memory, drag-resize 160–480px from the left edge, global filename search via the host `fs.search` route with a hard budget, skipping `.git` and symlink dirs); in merged mode tree clicks / Enter in the path input switch the current tab **in place**, separate mode opens by path; fresh sessions seed an empty Files window instead of the explorer tab, and a path-less window is a chrome'd empty file window in merged mode / a bare file manager in separate mode; the tree context menu offers "Open in new tab" and "Open to the side" (split)
- 🎛️ **Select rows for declarative settings** ([#151](https://github.com/omdsh-dev/DSH-better-sidebar/pull/151)): settings rows gain `type: 'select'` (`options` with value/title/desc/icon, `multi` stores the picked values as an array); options with icons render big-icon option cards and keep the icon in the closed anchor; `editorExplorer` became an iconed select (merged vs separate); the capability list gained `settingSelect`
- 🔀 **Mutual exclusion with the dsh-web-ui family right panel** ([#181](https://github.com/omdsh-dev/DSH-better-sidebar/pull/181)): reads the `aionui-panel` settings namespace's provider choice — when "Use aionui-panel" is selected, the whole better-sidebar (right sidebar / bottom panel / floating entry / all takeovers) does not mount; with DSH-better-sidebar (or no aionui installed) it behaves as before. Takes effect live after a settings save (settings-document push), no reload needed
**📝 Other**
- Install / update commands unified to `dsh-better-sidebar@latest` (bilingual README sync)
### v0.12.3
@@ -122,7 +140,7 @@ or bump the version in `~/.dsh/profiles/web/package.json` (e.g. `"^0.13.0"`) and
| `Ignored build scripts` | pnpm 11 blocked build scripts. Run `pnpm approve-builds --all` in the profile directory (`~/.dsh/profiles/web`). |
| `minimum release age` / version `< 24h` | The release is younger than 24 hours. Wait, or re-run once (pnpm auto-adds `minimumReleaseAgeExclude`). |
| "profile directory not found" | Run `dsh web` once so it initializes `~/.dsh/profiles/web`. |
| Two sidebars on the page | Double-mount: `~/.dsh/profiles/web/cordis.patch.yml` still has the old hand-written `- insert: ... better-sidebar ...` line — delete it. |
| Two sidebars on the page | Double-mount. Old hand-written line: `~/.dsh/profiles/web/cordis.patch.yml` still has `- insert: ... better-sidebar ...` — delete it (a same-id duplicate mount makes the loader fail loudly with `duplicate loader entry id`). When an aggregate bundle (e.g. `@linxin666/dsh-web-ui-all`) mounts this package under a **different** id, the plugin's own bundle patch backs off automatically since 0.13.x (it detects an already-enabled mount of the same package name and does not mount itself) — no manual fix needed; if it still double-mounts, make sure the aggregate bundle precedes `dsh-better-sidebar` in `dsh.profile.bundles`. |
| Terminal fails on Windows | `node-pty` relies on prebuilt binaries; if none match your Node version, install a build toolchain (VS Build Tools). Mainstream Node versions are usually covered. |
| Terminal shows "node-pty failed to load" | The `node-pty` install is missing or broken (e.g. pnpm skipped its build script). The terminal banner shows a repair command — copy it into a terminal/cmd on the DSH machine and run it (in `~/.dsh/profiles/web`: `pnpm approve-builds --all && pnpm rebuild node-pty`), then restart DSH and click Retry. The plugin and DSH core share the same `node-pty@^1.1.0`, so the repair restores both. |
| `dsh: command not found` | Install DSH first, or run `npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar@latest`. |
@@ -138,10 +156,15 @@ To debug local changes or track the dev branch, point the dependency at a local
1. git clone https://github.com/omdsh-dev/DSH-better-sidebar.git ~/Code/DSH-better-sidebar
cd ~/Code/DSH-better-sidebar && pnpm install && pnpm build
2. In ~/.dsh/profiles/web/package.json dependencies write "dsh-better-sidebar": "link:<absolute path of the clone>"
3. Append this mount line to ~/.dsh/profiles/web/cordis.patch.yml:
3. Append this mount line to ~/.dsh/profiles/web/cordis.patch.yml (to pick the terminal shell, add `config.shell`; `config.shellArgs` starts it with explicit args — when non-empty they replace the default `-l`. When omitted the host resolves `$SHELL` / the login shell / powershell.exe):
- insert:
- id: better-sidebar
name: 'dsh-better-sidebar'
config:
shell: /bin/zsh
shellArgs:
- --noprofile
- --no-rc
4. Run pnpm install in ~/.dsh/profiles/web
5. Restart DSH and hard-refresh
```
@@ -207,7 +230,7 @@ The dashed cards at the end of the "Sidebar content" / "File viewers" grids in t
## 🛠️ Development & Build
```sh
pnpm install # @deepseek-ai/* resolved from npm (^0.1.0-rc.6, published) — no token needed
pnpm install # @deepseek-ai/* resolved from npm (^0.1.0-rc.7, published) — no token needed
pnpm typecheck # tsc --noEmit
pnpm build # → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types
pnpm test # vitest (includes manifest consistency guard; build first)
+30
View File
@@ -14,6 +14,36 @@
# If the profile still carries the old manual mount line in its own
# cordis.patch.yml, remove it before switching to the bundle channel to
# avoid double-mounting (two Node halves, two sidebars).
#
# To choose the terminal shell, add `config.shell` to the inserted plugin
# row (or to the profile's manual cordis.patch.yml mount line). When omitted,
# the host keeps resolving $SHELL / the login shell / powershell.exe.
# `config.shellArgs` optionally supplies explicit startup arguments; when
# non-empty they replace the automatic POSIX `-l` login flag:
#
# - insert:
# - id: better-sidebar
# name: 'dsh-better-sidebar'
# config:
# shell: /bin/zsh
# shellArgs:
# - --noprofile
# - --no-rc
#
# Aggregate double-mount guard: aggregate bundles (e.g. @linxin666/dsh-web-ui-all)
# may already mount this package under their own entry id (e.g.
# `web-ui-better-sidebar`). Two mounts register /sidebar/api twice and fail the
# whole plugin tree at boot ("duplicate prefix route"). The `!!js` disabled
# expression backs THIS row off when another *enabled* entry already mounts
# `dsh-better-sidebar`; the aggregate instance then owns the sidebar. The
# loader evaluates it when this row is processed (entry-list order, only rows
# before this one are visible), so the aggregate bundle must precede this one
# in `dsh.profile.bundles` — the standard install order, since `dsh plugin add`
# appends new bundles last. The reverse order (this bundle before the
# aggregate) is a known limitation: the guard cannot see rows that come after
# it, and no runtime singleton guard is added. Same-id manual duplicates still
# fail loudly at the loader as before.
- insert:
- id: better-sidebar
name: 'dsh-better-sidebar'
disabled: !!js "[...ctx.loader.entries()].some((e) => e.options.name === 'dsh-better-sidebar' && e.options.id !== 'better-sidebar' && !e.disabled)"
@@ -46,6 +46,7 @@ chunk 脚本**不经过** `window.__ModuleLoader__` / `ClientModuleSystem.import
| `pptx` | `src/client/chunks/pptx.tsx` | `PptxView` + pptx-renderer | lib/client-pptx.js(~2.4MB) | 打开 .pptx |
| `terminal` | `src/client/chunks/terminal.tsx` | `TerminalView` + xterm + addon-fit + xterm.css | lib/client-terminal.js(~450KB) | 打开终端 tab |
| `editor` | `src/client/chunks/editor.tsx` | `TextEditor` + @codemirror/* + lang.ts + cm-themes | lib/client-editor.js(~1.6MB) | 打开 code/md/html 文件 |
| `mermaid` | `src/client/chunks/mermaid.tsx` | `MermaidBlocks` + mermaid + d3/dagre/cytoscape | lib/client-mermaid.js(~7MB) | 预览含 mermaid fence 的 md |
| 核心 | `src/client/index.tsx` | 其余全部 | lib/client.js(~325KB) | 启动 |
关键拆分:**`office-view.tsx` 拆为 `docx-view.tsx` + `xlsx-view.tsx` + `office-shared.tsx`**(共享 `LoadState`/`BinaryFallback`)。若不拆,docx chunk 会经共享文件静态拖入 `xlsx-to-univer` → Univer 14.5MB。原路径 `office-view.tsx` 保留为 re-export 垫片,兼容 `./src/*` 深导入。
@@ -0,0 +1,82 @@
# Markdown 预览 Mermaid 安全渲染设计
> 日期:2026-08-17
> 状态:已实现
> 范围:`dsh-better-sidebar` 插件,Markdown 预览 + 新 `mermaid` 懒加载 chunk
> 基线:社区 PR #99(`feat/mermaid-md-preview`)的架构与安全姿势;借鉴 PR #75(`feat/mermaid-rendering`)的点击放大交互
## 1. 背景与问题
Markdown 预览(`TextEditor.tsx` 的 preview 模式)直接把全文交给 DSH 的 `MarkdownText`(`@deepseek-ai/dsh-client-ui-primitives`)渲染。该渲染器的 fenced code 一律走 `CodeBlock`(Shiki 高亮 + 复制按钮),且**不支持自定义 fence 渲染器**——```` ```mermaid ```` 块只会显示成一段无高亮的代码,不会渲染成图(DSH 宿主内 `mermaid` 字样出现 0 次,Shiki 别名表也无此语言)。
社区出现过两个 mermaid PR,安全姿势差异极大:
| | PR #75 | PR #99 |
|---|---|---|
| 加载 | 运行时 jsdelivr CDN(第三方供应链、离线不可用、无版本锁) | 自有懒加载 chunk(mermaid 11.16.1 本地打包,`/sidebar/bundle` 同源下发) |
| 安全 | `securityLevel: 'loose'`(标签可注入原始 HTML)+ 无清洗 + `innerHTML` 直插 | `securityLevel: 'strict'` + `htmlLabels: false` + SVG 二次清洗 + 不应用 `bindFunctions` |
| 测试/文档 | 无 | 单测 + e2e + 设计文档 |
| 亮点 | 点击放大弹窗(滚轮缩放/拖拽/快捷键) | 主题跟随、错误回退、CodeBlock 同款 chrome |
**结论:以 #99 为基线**。md 文件来自工作区(可能来自克隆仓库/下载),不可信;`loose` + 无清洗在 DSH GUI origin 下就是 XSS(可读会话数据)。**仅借鉴 #75 的点击放大交互**,在 #99 的已清洗 SVG 之上用 React 重写(克隆节点无事件面,弹窗不新增攻击面)。本地图片引用重写(#99 顺带功能)不在本 PR 范围,另行独立 PR。
## 2. 方案
### 2.1 检测:纯函数按 fence 切块
新增 `src/client/mermaid-blocks.ts`(`splitMermaidBlocks`,纯函数、无依赖),职责是**检测**预览是否需要 mermaid chunk:
- 逐行扫描,命中 info string 为 `mermaid` 的围栏(大小写不敏感,支持 `mermaid{...}` 属性后缀,缩进 ≤3 空格,CommonMark 语义)即"提出"为 `{ kind: 'mermaid', code }` 块;
- 其余行原样保留在 `{ kind: 'markdown', text }` 块中(含非 mermaid 围栏);
- 未闭合的 mermaid fence 吞掉文件剩余部分(与 CommonMark 对开 fence 的恢复一致);
- **CommonMark fence 规则完整**:开 fence 为 3+ 反引号**或波浪号**;闭 fence 必须同字符且长度 ≥ 开 fence;反引号 fence 的 info string 含反引号时该行不是开 fence。
`TextEditor` 预览时先 `splitMermaidBlocks`,**无 mermaid 块则走原 `MarkdownText` 直渲路径(零行为变化)**;有则渲染 `MermaidMarkdown`(mermaid chunk 内)。
### 2.2 渲染架构:单次解析 + 占位替换(CR 第 3 轮 P1 修正)
早期方案把文档切成多个独立 `MarkdownText` 片段,会破坏跨 fence 的 Markdown 语义(引用式链接定义在 fence 之后、脚注、有序列表连续性)。当前实现:
- **整篇文档只走一次 `MarkdownText`**(`MermaidMarkdown`),跨 fence 语义完整;
- 布局阶段(`useLayoutEffect`)扫描容器内 `.md-code-block`,识别 mermaid 块(双通道:shiki 路径的 `code.language-mermaid` 类,或 plain 路径的 banner infostring 文本 === `mermaid`——mermaid 无 shiki 语法必走 plain,语言只显示在 infostring 里),用 `createRoot` 把 `MermaidDiagram` 挂进替换后的子节点;**React 管理的 `.md-code-block` 宿主节点保留在树中**(仅替换其 children,CSS `display: contents` 抑制代码块自身盒子),React 协调不会丢失宿主;
- 块内容变化(编辑后重新预览)按源码 diff 重渲;块不再是 mermaid fence 时还原原 children(普通代码块恢复显示);fence 消失时卸载对应 root;
- DSH `MarkdownText` 无代码块渲染扩展入口(且仓库硬约束禁止改 DSH),这是约束内的最小侵入方案。
### 2.2 懒加载 chunk:`mermaid`
mermaid 及其图依赖(d3 / dagre-d3-es / cytoscape 等)打包进新的 `lib/client-mermaid.js`(~7MB),复用既有 chunk 机制(tsdown `CHUNKS` + `/sidebar/bundle` 路由 + `globalThis.__dshChunks__` 注册表 + `lazyChunkComponent` 懒包装),仅当预览的 md 文件实际含 mermaid fence 时才下载/执行。chunk 名在 4 处同步:`tsdown.config.ts` `CHUNKS`、`src/bundle-route.ts` `CHUNK_NAMES`、`src/client/chunk-loader.ts` `ChunkName`、`package.json` `files`(及各 spec 的硬编码清单)。构建侧对 mermaid chunk 注入 `mermaidChunkAliases`:把裸 `uuid` 说明符解析到浏览器入口(`uuid/dist/index.js`,Web Crypto 实现),绕过其 node 入口的 `node:crypto` 触发客户端纯度门。
### 2.3 渲染与安全(纵深防御)
- `mermaid.render(id, code)` 客户端渲染 → SVG 字符串注入;每个渲染调用用单调递增 id(文档内唯一);
- `mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', htmlLabels: false, theme })`——strict 下标签被转义、`click` 指令失效;`htmlLabels: false` 强制节点文字走真实 SVG `<text>`(默认的 `foreignObject` HTML 标签通道会被清洗器整体删除,导致文字丢失),同时把 HTML 标签注入面关死;`bindFunctions` **不**应用(静态图,无点击交互);订阅 `subscribeColorScheme`,深浅色切换自动按新主题重渲;
- **SVG 注入前二次清洗**(`src/client/mermaid-sanitize.ts`,独立纯函数、可单测):`DOMParser`(`image/svg+xml`)解析,删除 `foreignObject` 与 `script`/外来 HTML 元素(`img`/`iframe`/`object`/`embed`/`video`/`audio`/`input`/`button`/`form`/`link`/`meta`/`base`),剥离 `@*`/`on*` 属性,**移除全部 `href`/`xlink:href`**(静态图不需要链接,杜绝 javascript:/data: 乃至普通外链把整个 GUI 导航走);仅接受根元素为 `<svg>` 的文档;解析失败(parsererror/非 SVG 根)返回空串,调用方显示错误回退,**原始字符串永不过清洗器**;
- 渲染失败(语法错误/清洗拒绝)展示错误条 + 原始代码块(便于对照修改),错误全文挂 tooltip;空 fence 不渲染;
- 块头保留与 `CodeBlock` 一致的 chrome:info string「mermaid」+ 复制按钮(`writeClipboard`,沿用 `copy`/`copied` 字典)。块体带稳定锚点 `data-mermaid-diagram`(e2e 断言用)。
### 2.4 点击放大弹窗(借鉴 PR #75,React 重写)
点击 `[data-mermaid-diagram]` 内 SVG → 克隆节点(**已清洗**,无事件属性)→ portalled fixed overlay(`data-mermaid-modal`):
- 滚轮缩放(以鼠标位置为不动点,被动标志关闭防止页面滚动);
- 拖拽平移(mousedown/mousemove/mouseup on window);
- 工具栏按钮:− / + / ⟳ / ✕(i18n title);
- 键盘:`+`/`=` 放大、`-` 缩小、`0` 重置、`Esc` 关闭;
- 背景点击关闭;卸载时移除全部监听;
- 样式全部走 CSS Module + DSH 令牌(遮罩 `--dsw-alias-bg-mask-1` + `blur(2px)`,与 DSH `Modal` 一致;z-index 1000 落在 DSH 浮层栈)。
## 3. 验证
- 单测:`tests/mermaid-blocks.spec.ts`(14 例:fence 识别大小写/属性/缩进、4 反引号/波浪号、同字符且不短于开 fence 的闭 fence、非 mermaid 围栏不动、交错顺序、开 fence 恢复、空文件);`tests/mermaid-sanitize.spec.ts`(10 例 XSS:foreignObject/script/外来元素整节点剥离(含混合大小写元素名与属性名)、`on*`/`@*` 剥离、href/xlink:href 全删、畸形 XML → 空串、非 SVG 根 → 空串、良性图保真);`tests/mermaid-markdown.spec.tsx`(3 例架构回归:mermaid 块被交换为图、**跨 fence 引用式链接在单次解析下解析**、非 mermaid 围栏不动);
- 产物契约:`chunk-artifact.spec.ts`(mermaid chunk 执行 + registry 槽位 + 平台 externals require,`addEventListener` stub)/ `manifest-consistency.spec.ts` / `bundle-route.spec.ts` 的 chunk 清单同步加入 `mermaid`;
- 挂载冒烟:`tests/e2e/mount.e2e.ts` 新增 seed `diagram.md`,打开后强制 `client-mermaid.js` 往返、断言 `[data-mermaid-diagram] svg` 含节点文字(真实 `<text>`)、**跨 fence 引用式链接解析为真实锚点(单次解析证明)**、点击弹窗出现且 Esc 关闭、预览模式编辑器隐藏;缺 chunk、文字丢失、语义破坏、弹窗失效任一即红;
- 现有 `markdown-copy-labels.spec.tsx` 不回归(无 mermaid 文件走原路径)。
## 4. 风险与取舍
- 编辑器 chunk 之外新增 ~7MB 懒 chunk(mermaid + d3/dagre/cytoscape/katex 图依赖);仅预览含 mermaid fence 的 md 时下载,启动与普通文本打开路径零成本;
- `splitMermaidBlocks` 对"mermaid 围栏嵌在其他代码块内"的极边缘情况会误提(只影响是否走 mermaid 路径,不影响渲染正确性)——预览级工具接受该取舍(与常见 md 渲染器行为一致);
- 占位替换依赖 DSH `MarkdownText` 的 DOM 输出结构(`.md-code-block` + `language-mermaid`):宿主节点保留在 React 树中、仅换 children,协调安全;若未来 DSH 渲染器结构变化,`data-mermaid-processed` 扫描点需要同步(有 e2e 兜底);
- 清洗器删除全部 `href`/`xlink:href`:`<use href>` 类图标引用可能不显示(纯装饰性损失),换取确定性的无导航/无脚本面;
- mermaid 官方核心包持续演化,锁 `^11.16.1`,升级靠 Renovate 常规流程;
- 回滚:删除 chunk 三处声明 + `files` 条目 + `TextEditor` 分支即可回到纯代码块展示。
+1 -1
View File
@@ -1,6 +1,6 @@
{
"id": "dsh-external/dsh-better-sidebar",
"version": "0.13.0",
"version": "0.13.1",
"main": "./lib/index.js",
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
"engines": {
+33 -31
View File
@@ -1,6 +1,6 @@
{
"name": "dsh-better-sidebar",
"version": "0.13.0",
"version": "0.13.1",
"description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.",
"type": "module",
"repository": {
@@ -60,6 +60,7 @@
"lib/client-registry.js",
"lib/client-terminal.js",
"lib/client-editor.js",
"lib/client-mermaid.js",
"lib/types/**/*.d.ts",
"src",
"scripts/install.sh",
@@ -83,21 +84,21 @@
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-schema-form": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-web-react": "^0.1.0-rc.6",
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
"@deepseek-ai/dsh-agent": "^0.1.0-rc.7",
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.7",
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.7",
"@deepseek-ai/dsh-client-schema-form": "^0.1.0-rc.7",
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.7",
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.7",
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.7",
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.7",
"@deepseek-ai/dsh-client-web-react": "^0.1.0-rc.7",
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.7",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.7",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.7",
"@deepseek-ai/dsh-session": "^0.1.0-rc.7",
"@deepseek-ai/dsh-settings": "^0.1.0-rc.7",
"@deepseek-ai/dsh-tools": "^0.1.0-rc.7",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0",
"react-dom": "^18.2.0"
@@ -130,6 +131,7 @@
"@codemirror/view": "^6.43.8",
"@lezer/highlight": "^1.2.3",
"clsx": "^2.1.1",
"mermaid": "^11.16.1",
"node-pty": "^1.1.0",
"rxjs": "^7.8.2",
"schemastery": "^3.18.0",
@@ -137,21 +139,21 @@
},
"devDependencies": {
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-schema-form": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-web-react": "^0.1.0-rc.6",
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
"@deepseek-ai/dsh-agent": "0.1.0-rc.7",
"@deepseek-ai/dsh-client-locale": "0.1.0-rc.7",
"@deepseek-ai/dsh-client-runtime": "0.1.0-rc.7",
"@deepseek-ai/dsh-client-schema-form": "0.1.0-rc.7",
"@deepseek-ai/dsh-client-ui-conversation": "0.1.0-rc.7",
"@deepseek-ai/dsh-client-ui-primitives": "0.1.0-rc.7",
"@deepseek-ai/dsh-client-ui-settings": "0.1.0-rc.7",
"@deepseek-ai/dsh-client-ui-slots": "0.1.0-rc.7",
"@deepseek-ai/dsh-client-web-react": "0.1.0-rc.7",
"@deepseek-ai/dsh-host-webserver": "0.1.0-rc.7",
"@deepseek-ai/dsh-invariants": "0.1.0-rc.7",
"@deepseek-ai/dsh-llm": "0.1.0-rc.7",
"@deepseek-ai/dsh-session": "0.1.0-rc.7",
"@deepseek-ai/dsh-settings": "0.1.0-rc.7",
"@deepseek-ai/dsh-tools": "0.1.0-rc.7",
"@playwright/test": "^1.62.1",
"@types/node": "^24.0.0",
"@types/react": "~18.3.1",
+1698 -904
View File
File diff suppressed because it is too large Load Diff
+58 -58
View File
@@ -69,62 +69,62 @@ minimumReleaseAgeExclude:
- '@deepseek-ai/cordis-plugin-loader@1.0.2'
- '@deepseek-ai/cordis@4.0.1'
- '@deepseek-ai/cosmokit@1.8.2'
- '@deepseek-ai/dsh-agent-default-model@0.1.0-rc.6'
- '@deepseek-ai/dsh-agent-presets@0.1.0-rc.6'
- '@deepseek-ai/dsh-agent@0.1.0-rc.6'
- '@deepseek-ai/dsh-api-gateway@0.1.0-rc.6'
- '@deepseek-ai/dsh-api-remotes@0.1.0-rc.6'
- '@deepseek-ai/dsh-atomic-write@0.1.0-rc.6'
- '@deepseek-ai/dsh-attachment@0.1.0-rc.6'
- '@deepseek-ai/dsh-brand@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-connection@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-locale@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-runtime@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-schema-form@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-ui-attachment@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-ui-conversation@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-ui-input-trigger@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-ui-primitives@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-ui-settings@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-ui-slots@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-web-react@0.1.0-rc.6'
- '@deepseek-ai/dsh-code-runtime@0.1.0-rc.6'
- '@deepseek-ai/dsh-commands@0.1.0-rc.6'
- '@deepseek-ai/dsh-compaction@0.1.0-rc.6'
- '@deepseek-ai/dsh-cordis-host-runner@0.1.0-rc.6'
- '@deepseek-ai/dsh-credentials@0.1.0-rc.6'
- '@deepseek-ai/dsh-goal@0.1.0-rc.6'
- '@deepseek-ai/dsh-home-paths@0.1.0-rc.6'
- '@deepseek-ai/dsh-host-apiproxy@0.1.0-rc.6'
- '@deepseek-ai/dsh-host-directory-picker@0.1.0-rc.6'
- '@deepseek-ai/dsh-host-plugin-inventory@0.1.0-rc.6'
- '@deepseek-ai/dsh-host-webserver@0.1.0-rc.6'
- '@deepseek-ai/dsh-invariants@0.1.0-rc.6'
- '@deepseek-ai/dsh-jobs@0.1.0-rc.6'
- '@deepseek-ai/dsh-llm-retry@0.1.0-rc.6'
- '@deepseek-ai/dsh-llm@0.1.0-rc.6'
- '@deepseek-ai/dsh-message-feedback@0.1.0-rc.6'
- '@deepseek-ai/dsh-native-command@0.1.0-rc.6'
- '@deepseek-ai/dsh-scope@0.1.0-rc.6'
- '@deepseek-ai/dsh-session-persistence@0.1.0-rc.6'
- '@deepseek-ai/dsh-session-projection-cache@0.1.0-rc.6'
- '@deepseek-ai/dsh-session-projection@0.1.0-rc.6'
- '@deepseek-ai/dsh-session-query@0.1.0-rc.6'
- '@deepseek-ai/dsh-session-stats@0.1.0-rc.6'
- '@deepseek-ai/dsh-session-title@0.1.0-rc.6'
- '@deepseek-ai/dsh-session@0.1.0-rc.6'
- '@deepseek-ai/dsh-settings@0.1.0-rc.6'
- '@deepseek-ai/dsh-skill@0.1.0-rc.6'
- '@deepseek-ai/dsh-storage-domain@0.1.0-rc.6'
- '@deepseek-ai/dsh-storage@0.1.0-rc.6'
- '@deepseek-ai/dsh-subagent@0.1.0-rc.6'
- '@deepseek-ai/dsh-system-prompt@0.1.0-rc.6'
- '@deepseek-ai/dsh-timeout@0.1.0-rc.6'
- '@deepseek-ai/dsh-token-meter@0.1.0-rc.6'
- '@deepseek-ai/dsh-tools@0.1.0-rc.6'
- '@deepseek-ai/dsh-typert-protocol@0.1.0-rc.6'
- '@deepseek-ai/dsh-typert-registry@0.1.0-rc.6'
- '@deepseek-ai/dsh-user-approval@0.1.0-rc.6'
- '@deepseek-ai/dsh-user-questions@0.1.0-rc.6'
- '@deepseek-ai/dsh-workspace@0.1.0-rc.6'
- '@deepseek-ai/dsh-agent-default-model@0.1.0-rc.7'
- '@deepseek-ai/dsh-agent-presets@0.1.0-rc.7'
- '@deepseek-ai/dsh-agent@0.1.0-rc.7'
- '@deepseek-ai/dsh-api-gateway@0.1.0-rc.7'
- '@deepseek-ai/dsh-api-remotes@0.1.0-rc.7'
- '@deepseek-ai/dsh-atomic-write@0.1.0-rc.7'
- '@deepseek-ai/dsh-attachment@0.1.0-rc.7'
- '@deepseek-ai/dsh-brand@0.1.0-rc.7'
- '@deepseek-ai/dsh-client-connection@0.1.0-rc.7'
- '@deepseek-ai/dsh-client-locale@0.1.0-rc.7'
- '@deepseek-ai/dsh-client-runtime@0.1.0-rc.7'
- '@deepseek-ai/dsh-client-schema-form@0.1.0-rc.7'
- '@deepseek-ai/dsh-client-ui-attachment@0.1.0-rc.7'
- '@deepseek-ai/dsh-client-ui-conversation@0.1.0-rc.7'
- '@deepseek-ai/dsh-client-ui-input-trigger@0.1.0-rc.7'
- '@deepseek-ai/dsh-client-ui-primitives@0.1.0-rc.7'
- '@deepseek-ai/dsh-client-ui-settings@0.1.0-rc.7'
- '@deepseek-ai/dsh-client-ui-slots@0.1.0-rc.7'
- '@deepseek-ai/dsh-client-web-react@0.1.0-rc.7'
- '@deepseek-ai/dsh-code-runtime@0.1.0-rc.7'
- '@deepseek-ai/dsh-commands@0.1.0-rc.7'
- '@deepseek-ai/dsh-compaction@0.1.0-rc.7'
- '@deepseek-ai/dsh-cordis-host-runner@0.1.0-rc.7'
- '@deepseek-ai/dsh-credentials@0.1.0-rc.7'
- '@deepseek-ai/dsh-goal@0.1.0-rc.7'
- '@deepseek-ai/dsh-home-paths@0.1.0-rc.7'
- '@deepseek-ai/dsh-host-apiproxy@0.1.0-rc.7'
- '@deepseek-ai/dsh-host-directory-picker@0.1.0-rc.7'
- '@deepseek-ai/dsh-host-plugin-inventory@0.1.0-rc.7'
- '@deepseek-ai/dsh-host-webserver@0.1.0-rc.7'
- '@deepseek-ai/dsh-invariants@0.1.0-rc.7'
- '@deepseek-ai/dsh-jobs@0.1.0-rc.7'
- '@deepseek-ai/dsh-llm-retry@0.1.0-rc.7'
- '@deepseek-ai/dsh-llm@0.1.0-rc.7'
- '@deepseek-ai/dsh-message-feedback@0.1.0-rc.7'
- '@deepseek-ai/dsh-native-command@0.1.0-rc.7'
- '@deepseek-ai/dsh-scope@0.1.0-rc.7'
- '@deepseek-ai/dsh-session-persistence@0.1.0-rc.7'
- '@deepseek-ai/dsh-session-projection-cache@0.1.0-rc.7'
- '@deepseek-ai/dsh-session-projection@0.1.0-rc.7'
- '@deepseek-ai/dsh-session-query@0.1.0-rc.7'
- '@deepseek-ai/dsh-session-stats@0.1.0-rc.7'
- '@deepseek-ai/dsh-session-title@0.1.0-rc.7'
- '@deepseek-ai/dsh-session@0.1.0-rc.7'
- '@deepseek-ai/dsh-settings@0.1.0-rc.7'
- '@deepseek-ai/dsh-skill@0.1.0-rc.7'
- '@deepseek-ai/dsh-storage-domain@0.1.0-rc.7'
- '@deepseek-ai/dsh-storage@0.1.0-rc.7'
- '@deepseek-ai/dsh-subagent@0.1.0-rc.7'
- '@deepseek-ai/dsh-system-prompt@0.1.0-rc.7'
- '@deepseek-ai/dsh-timeout@0.1.0-rc.7'
- '@deepseek-ai/dsh-token-meter@0.1.0-rc.7'
- '@deepseek-ai/dsh-tools@0.1.0-rc.7'
- '@deepseek-ai/dsh-typert-protocol@0.1.0-rc.7'
- '@deepseek-ai/dsh-typert-registry@0.1.0-rc.7'
- '@deepseek-ai/dsh-user-approval@0.1.0-rc.7'
- '@deepseek-ai/dsh-user-questions@0.1.0-rc.7'
- '@deepseek-ai/dsh-workspace@0.1.0-rc.7'
- '@deepseek-ai/schemastery@3.18.1'
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env bash
# =============================================================================
# dsh-better-sidebar 聚合双挂载冒烟(CI + 本地):
#
# 场景:一个聚合 bundle(tests/fixtures/aggregate-better-sidebar)先以独立
# 条目 id 挂载 dsh-better-sidebar,插件自身的 bundle patch 后到 —— 旧行为
# 两个实例都注册 /sidebar/api,整个插件树启动失败(duplicate prefix
# route);新行为插件行检测到已有启用中的同包名挂载后自动 disabled,由
# 聚合行接管 sidebar。
#
# 流程:
# 1. 打包 fixture 聚合包;
# 2. 用官方 CLI 把 fixture 先装进全新 scratch profile,再把插件 tarball
# 装进同一 profile(复刻真实安装顺序:聚合先行、dsh plugin add 后到);
# 3. 启动真实 `dsh web`(keyless,--port 0);
# 4. 断言:日志出现启动 URL、无 "duplicate prefix route"、/sidebar/api
# 路由存活(POST 未知方法应返回 404 JSON 而非崩溃)。
#
# 用法:
# bash scripts/e2e-aggregate-mount.sh
#
# 环境变量(均可省略):
# DSH_CMD dsh 命令;缺省 `npx -y --package @deepseek-ai/dsh dsh`
# (与 pm2 启动器同源,避免依赖可能失效的 PATH dsh)
# TARBALL 插件 tarball;缺省仓库根 dsh-better-sidebar-*.tgz(须已 pack)
# PORT 固定端口(默认 0 = OS 分配,从日志解析 URL)
# DSH_HOME_BASE scratch 根目录(默认系统临时目录)。脚本始终在其下新建
# 本调用拥有的独立子目录,只写入/删除该子目录;调用方提供
# 的目录本身(可能是真实 ~/.dsh)绝不写入或删除。
# KEEP_HOME 非空时保留 scratch 目录(调试用)
#
# 退出码 = 0 通过;非 0 失败。服务器与 scratch 目录由 trap 兜底清理。
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
FIXTURE_DIR="$ROOT/tests/fixtures/aggregate-better-sidebar"
DSH_CMD="${DSH_CMD:-npx -y --package @deepseek-ai/dsh dsh}"
TARBALL="${TARBALL:-}"
PORT="${PORT:-0}"
KEEP_HOME="${KEEP_HOME:-}"
say() { printf '\033[32m[e2e-aggregate-mount]\033[0m %s\n' "$*"; }
warn() { printf '\033[33m[e2e-aggregate-mount]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[31m[e2e-aggregate-mount]\033[0m %s\n' "$*" >&2; exit 1; }
command -v node >/dev/null 2>&1 || die "未找到 node(DSH 运行需要 Node.js >= 20)"
command -v pnpm >/dev/null 2>&1 || die "未找到 pnpm(dsh plugin 转发给 pnpm)"
command -v npm >/dev/null 2>&1 || die "未找到 npm(打包 fixture 需要)"
command -v curl >/dev/null 2>&1 || die "未找到 curl"
if [ -z "$TARBALL" ]; then
TARBALL="$(ls "$ROOT"/dsh-better-sidebar-*.tgz 2>/dev/null | head -1 || true)"
fi
[ -n "$TARBALL" ] && [ -f "$TARBALL" ] || die "未找到插件 tarball:先在本仓库跑 pnpm build && npm pack,或用 TARBALL 指定"
# ── scratch home ────────────────────────────────────────────────────────────
# 始终在本调用拥有的全新目录里运行:调用方给了 DSH_HOME_BASE(可能是真实
# ~/.dsh)时,只在其下新建子目录并只删除该子目录;缺省时直接用系统临时
# 目录。调用方提供的目录本身绝不写入或删除。
DSH_HOME_BASE="${DSH_HOME_BASE:-}"
if [ -n "$DSH_HOME_BASE" ]; then
SCRATCH="$(mktemp -d "$DSH_HOME_BASE/dsh-e2e-agg.XXXXXX")"
else
SCRATCH="$(mktemp -d /tmp/dsh-e2e-agg.XXXXXX)"
fi
export DSH_HOME="$SCRATCH"
LOG_DIR="$DSH_HOME/logs"; mkdir -p "$LOG_DIR"
OUT_LOG="$LOG_DIR/dsh-web.out.log"; ERR_LOG="$LOG_DIR/dsh-web.err.log"
SERVER_PID=""
TMP_DIR=""
cleanup() {
if [ -n "$SERVER_PID" ] && kill -0 "$SERVER_PID" 2>/dev/null; then
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
fi
if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then
rm -rf "$TMP_DIR"
fi
if [ -z "$KEEP_HOME" ] && [ -d "$SCRATCH" ]; then
rm -rf "$SCRATCH"
else
warn "KEEP_HOME 已设置,保留 $SCRATCH"
fi
}
trap cleanup EXIT
# ── 打包 fixture 聚合包 ─────────────────────────────────────────────────────
TMP_DIR="$(mktemp -d)"
( cd "$FIXTURE_DIR" && npm pack --silent --pack-destination "$TMP_DIR" >/dev/null )
FIXTURE_TGZ="$(ls "$TMP_DIR"/*.tgz 2>/dev/null | head -1 || true)"
[ -n "$FIXTURE_TGZ" ] || die "fixture 打包失败"
# ── 引导 scratch profile(web 模板)────────────────────────────────────────
# 先写 pnpm-workspace.yaml 的 allowBuilds / minimumReleaseAgeExclude,避免
# pnpm 11 strict-dep-builds 拦截 node-pty/protobufjs——同 e2e-mount.sh。
PROFILE_DIR="$DSH_HOME/profiles/web"
mkdir -p "$PROFILE_DIR"
cat > "$PROFILE_DIR/package.json" <<EOF
{
"name": "dsh-profile-web",
"private": true,
"dependencies": {},
"dsh": {
"profile": {
"bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"]
}
}
}
EOF
printf '[]\n' > "$PROFILE_DIR/cordis.patch.yml"
cat > "$PROFILE_DIR/pnpm-workspace.yaml" <<'EOF'
packages:
- .
nodeLinker: hoisted
autoInstallPeers: false
allowBuilds:
node-pty: true
protobufjs: true
minimumReleaseAgeExclude:
- dsh-better-sidebar
EOF
# ── 组装 profile:聚合先行,插件后到 ──────────────────────────────────────
say "安装 fixture 聚合包(先行)…"
$DSH_CMD plugin --profile web add "file:$FIXTURE_TGZ"
say "安装插件 tarball(后到)…"
$DSH_CMD plugin --profile web add "file:$TARBALL"
# ── 启动真实 dsh web ───────────────────────────────────────────────────────
say "启动 dsh web(--port ${PORT},日志 ${LOG_DIR})…"
$DSH_CMD web --port "$PORT" >"$OUT_LOG" 2>"$ERR_LOG" &
SERVER_PID=$!
# 等待启动 URL 或进程退出(最多 120s)
URL=""
for _ in $(seq 1 120); do
if ! kill -0 "$SERVER_PID" 2>/dev/null; then break; fi
URL="$(grep -oE 'http://127\.0\.0\.1:[0-9]+' "$OUT_LOG" | tail -1 || true)"
[ -n "$URL" ] && break
sleep 1
done
if [ -z "$URL" ]; then
warn "out log 尾部:$(tail -5 "$OUT_LOG" 2>/dev/null || true)"
warn "err log 尾部:$(tail -5 "$ERR_LOG" 2>/dev/null || true)"
die "dsh web 未在 120s 内启动"
fi
say "已启动:$URL"
# ── 断言 ────────────────────────────────────────────────────────────────────
if grep -q "duplicate prefix route" "$ERR_LOG" "$OUT_LOG" 2>/dev/null; then
die "检测到 duplicate prefix route(双挂载未退让)"
fi
# 真实方法探活:terminal.deps 是插件自己的 handler(writeOk → HTTP 200 +
# {"ok":true,...})。成功响应证明至少一个实例真正注册了 /sidebar/api 路由
# ——generic missing-route 404 无法冒充(P2: Probe a real sidebar API method)。
DEPS="$(curl -s -X POST "$URL/sidebar/api/terminal.deps" 2>/dev/null || true)"
if ! printf '%s' "$DEPS" | grep -q '"ok":true'; then
die "/sidebar/api/terminal.deps 未返回 ok:true(响应:$(printf '%s' "$DEPS" | head -c 200))"
fi
say "/sidebar/api/terminal.deps → ok:true(真实 handler 存活)"
STATUS="$(curl -s -o /dev/null -w '%{http_code}' -X POST "$URL/sidebar/api/__e2e_unknown__" 2>/dev/null || true)"
say "/sidebar/api POST 未知方法 → HTTP ${STATUS}(期望 404)"
[ "$STATUS" = "404" ] || die "/sidebar/api 未知方法未按预期返回 404(HTTP ${STATUS})"
say "通过:聚合双挂载场景下插件自动退让,侧边栏由聚合行接管。"
+11 -3
View File
@@ -16,7 +16,9 @@
# DSH_CMD dsh 命令;缺省 PATH 上的 `dsh`,回退 npx 拉官方包
# TARBALL 插件 tarball;缺省仓库根 dsh-better-sidebar-*.tgz(须已 pack)
# PORT 固定端口(默认 0 = OS 分配,从日志解析 URL)
# DSH_HOME_BASE 覆盖 scratch 根目录(默认 mktemp -d)
# DSH_HOME_BASE 覆盖 scratch 根目录(默认系统临时目录)。脚本始终在其下
# 新建本调用拥有的独立子目录,只写入/删除该子目录;调用方
# 提供的目录本身(可能是真实 ~/.dsh)绝不写入或删除。
# KEEP_HOME 非空时保留 scratch home(调试用)
#
# 退出码 = playwright 的退出码;服务器与 scratch 目录由 trap 兜底清理。
@@ -57,8 +59,14 @@ fi
TARBALL="$(cd "$(dirname "$TARBALL")" && pwd)/$(basename "$TARBALL")"
say "tarball: $TARBALL"
# scratch home(每次全新,绝不触碰真实 ~/.dsh)
SCRATCH="${DSH_HOME_BASE:-$(mktemp -d /tmp/dsh-e2e-mount.XXXXXX)}"
# scratch home(每次全新,绝不触碰真实 ~/.dsh):调用方给了 DSH_HOME_BASE
# 时,只在其下新建本调用拥有的子目录并只删除该子目录;缺省时直接用系统
# 临时目录。
if [ -n "${DSH_HOME_BASE:-}" ]; then
SCRATCH="$(mktemp -d "$DSH_HOME_BASE/dsh-e2e-mount.XXXXXX")"
else
SCRATCH="$(mktemp -d /tmp/dsh-e2e-mount.XXXXXX)"
fi
export DSH_HOME="$SCRATCH/home"
WORKSPACE_DIR="$SCRATCH/workspace"
LOG_DIR="$SCRATCH"
+2 -1
View File
@@ -192,6 +192,7 @@ export class AgentPtyRegistry {
constructor(
private readonly shell: string,
private readonly shellArgs: string[] = [],
/** The loaded node-pty module (injected so a broken install degrades instead of crashing the plugin). */
private readonly nodePty: NodePtyModule = loadRequiredNodePty(),
) {
@@ -216,7 +217,7 @@ export class AgentPtyRegistry {
): string {
const uuid = randomUUID()
const dims = clampDims(cols, rows)
const pty = this.nodePty.spawn(this.shell, shellSpawnArgs(), {
const pty = this.nodePty.spawn(this.shell, shellSpawnArgs(this.shellArgs), {
name: 'xterm-256color',
cols: dims.cols,
rows: dims.rows,
+1 -1
View File
@@ -19,7 +19,7 @@ import { fileURLToPath } from 'node:url'
import type { Context, SidebarHttpRequest, SidebarHttpResponse } from './context-types.ts'
/** The chunk names the client may request (mirror of src/client/chunk-loader.ts). */
export const CHUNK_NAMES = ['terminal', 'editor'] as const
export const CHUNK_NAMES = ['terminal', 'editor', 'mermaid'] as const
export type ChunkName = (typeof CHUNK_NAMES)[number]
/** Directory of this host-half module (lib/ — the chunk scripts live next to it). */
+35 -7
View File
@@ -13,7 +13,8 @@
* the FileViewerProps toolbar callbacks so the host's path-input header
* renders the controls instead.
*/
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { ComponentType } from 'react'
import { createPortal } from 'react-dom'
import clsx from 'clsx'
import { EditorState } from '@codemirror/state'
@@ -27,6 +28,8 @@ import { isDarkScheme, subscribeColorScheme } from './theme.ts'
import { SandboxStatusBar } from './SandboxStatusBar.tsx'
import { appendToDraft } from './conversation-draft.ts'
import { buildSelectionInsert, linesOfSelection } from './selection-payload.ts'
import { lazyChunkComponent } from './lazy-chunk.tsx'
import { splitMermaidBlocks, type MermaidMarkdownProps } from './mermaid-blocks.ts'
import { t } from './locales.ts'
import type { EditorToolbarState, FileViewerProps } from './service.ts'
import css from './sidebar.module.css'
@@ -41,6 +44,16 @@ interface SelectionPopup {
top: number
}
/**
* The chunk-resident markdown preview renderer (mermaid lazy chunk): one
* MarkdownText pass over the whole source, with rendered mermaid fences
* swapped for diagrams. Module-level `pick` keeps the load effect stable.
*/
const LazyMermaidMarkdown = lazyChunkComponent<MermaidMarkdownProps>(
'mermaid',
(mod) => mod.MermaidMarkdown as ComponentType<MermaidMarkdownProps> | undefined,
)
/**
* The sandbox tokens of the HTML preview iframe. NO allow-same-origin (the
* preview must stay in an opaque origin — with the route's own origin it
@@ -237,6 +250,19 @@ export function TextEditor(props: FileViewerProps) {
const markdown = viewerId === 'markdown'
const html = viewerId === 'html'
/** The markdown source the preview renders (draft wins over saved content). */
const mdText = draft ?? content ?? ''
/** md/mermaid block split for the preview (mermaid fences lift out). Split
* only in preview mode: edit-mode keystrokes must not re-scan the source. */
const mdBlocks = useMemo(
() => (markdown && mode === 'preview' ? splitMermaidBlocks(mdText) : []),
[markdown, mode, mdText],
)
const hasMermaid = useMemo(
() => mdBlocks.some(block => block.kind === 'mermaid'),
[mdBlocks],
)
const codeLabels = { copyLabel: t('copy'), copiedLabel: t('copied') }
/**
* Selection popup for the markdown preview: a mouse-up inside the preview
@@ -263,7 +289,7 @@ export function TextEditor(props: FileViewerProps) {
return
}
const rect = sel.getRangeAt(0).getBoundingClientRect()
const lines = linesOfSelection(draft ?? content ?? '', text)
const lines = linesOfSelection(mdText, text)
showPopup(
buildSelectionInsert(path, scope.cwd, lines ?? undefined, text),
rect.left + rect.width / 2,
@@ -361,11 +387,13 @@ export function TextEditor(props: FileViewerProps) {
dictionary: the DSH MarkdownText/CodeBlock are cordis-free and
fall back to hardcoded Chinese otherwise (same pattern as the
chat's AssistantMarkdown). Render-time t() keeps them following
the active locale on live switches. */}
<MarkdownText
text={draft ?? content ?? ''}
codeLabels={{ copyLabel: t('copy'), copiedLabel: t('copied') }}
/>
the active locale on live switches. Mermaid fences hand the
whole document to the mermaid lazy chunk (single markdown
parse; cross-fence references/footnotes stay intact); files
without one render exactly as before. */}
{hasMermaid
? <LazyMermaidMarkdown text={mdText} codeLabels={codeLabels} />
: <MarkdownText text={mdText} codeLabels={codeLabels} />}
</div>
)}
{html && mode === 'preview' && (
+3
View File
@@ -201,6 +201,9 @@ export const api = {
id,
...(reason !== undefined ? { reason } : {}),
})),
/** The effective terminal shell and its display name (plugin-global). */
shellGet: () =>
call<{ shell: string; name: string }>('shell.get', {}),
/** Read the side card preferences (plugin-global, no session scope). */
settingsGet: () =>
call<{ value?: unknown; revision?: number; externalDisable?: boolean }>('settings.get', {}),
+7 -3
View File
@@ -8,7 +8,7 @@
*/
import type { Context } from '../../context-types.ts'
import type { BetterSidebarService } from '../service.ts'
import { builtinTabs } from './tabs.tsx'
import { builtinTabs, type BuiltinTabOptions } from './tabs.tsx'
import { builtinViewers } from './viewers.tsx'
/**
@@ -17,9 +17,13 @@ import { builtinViewers } from './viewers.tsx'
* disposal). The `ctx` is threaded into tab descriptors that need it
* (EditorHost reads `ctx.betterSidebar` for file-viewer matching).
*/
export function registerBuiltins(ctx: Context, service: BetterSidebarService): () => void {
export function registerBuiltins(
ctx: Context,
service: BetterSidebarService,
options: BuiltinTabOptions = {},
): () => void {
const disposers: (() => void)[] = []
for (const tab of builtinTabs(ctx)) {
for (const tab of builtinTabs(ctx, options)) {
disposers.push(service.registerTab(tab))
}
for (const viewer of builtinViewers()) {
+20 -4
View File
@@ -3,7 +3,7 @@
* (editor / git / terminal / browser / subagent / diff) through
* the same {@link BetterSidebarService} external plugins use — eating its
* own dogfood. The terminal descriptor owns its quota (`TERMINAL_LIMIT`)
* and mints `terminal:<n>` ids through `createTab`; the browser mints
* and mints `terminal:<uuid>` ids through `createTab`; the browser mints
* `browser:<n>` the same way (no quota). The editor IS the files window
* (the old standalone explorer merged into it).
*/
@@ -52,6 +52,20 @@ interface TerminalViewProps {
/** How many UI-owned terminals may be open at once (agent-owned ones are uncapped). */
export const TERMINAL_LIMIT = 3
/** Optional per-registration builtin behavior (currently terminal title). */
export interface BuiltinTabOptions {
/** Returns the display title for newly opened terminal tabs. */
terminalTitle?: () => string
}
/** A client-side uuid for terminal tab identity (not shown in the UI). */
function terminalUuid(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `t${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`
}
/** Count UI-owned terminals (agent:` tabs excluded — they are the model's). */
function uiTerminalCount(state: SidebarState): number {
return allLeaves(state.splits)
@@ -60,7 +74,7 @@ function uiTerminalCount(state: SidebarState): number {
}
/** The 6 built-in tab descriptors. */
export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): readonly TabDescriptor[] {
return [
{
id: 'editor',
@@ -192,10 +206,12 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
if (count >= TERMINAL_LIMIT) return null
return {
tab: {
id: `terminal:${state.nextTerminal}`,
id: `terminal:${terminalUuid()}`,
type: 'terminal',
title: `${t('terminal')} ${state.nextTerminal}`,
title: options.terminalTitle?.() ?? t('terminal'),
},
// Keep the legacy counter advancing for compatibility with older
// persisted states; new ids no longer use it.
patch: { nextTerminal: state.nextTerminal + 1 },
}
},
+1 -1
View File
@@ -44,7 +44,7 @@
* open. Chunk-only source edits still need a manual page refresh (the HMR
* poll watches only client.js).
*/
export type ChunkName = 'terminal' | 'editor'
export type ChunkName = 'terminal' | 'editor' | 'mermaid'
/** The module exports a chunk factory provides (namespace-ish record). */
export type ChunkExports = Record<string, unknown>
+10
View File
@@ -0,0 +1,10 @@
/**
* Lazy chunk entry: the mermaid diagram renderer for the markdown preview
* (mermaid + its d3/dagre/cytoscape graph deps). Built as
* lib/client-mermaid.js and registered under the `mermaid` global chunk
* slot — fetched only when a previewed markdown file contains a mermaid
* fence (see chunk-loader.ts and the mermaid markdown preview design doc).
* Never import this module from the core bundle: it pulls mermaid into the
* startup path.
*/
export { MermaidMarkdown } from '../mermaid.tsx'
+22 -2
View File
@@ -11,7 +11,7 @@
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import type { Context } from '../context-types.ts'
import { createSidebarStore } from './state.ts'
import { allLeaves, createSidebarStore, isAgentTabId } from './state.ts'
import { createBetterSidebarService, matchUrlTarget } from './service.ts'
import { resetChunks } from './chunk-loader.ts'
import { registerBuiltins } from './builtins/index.ts'
@@ -66,11 +66,31 @@ export function apply(ctx: Context): void {
// are ready by the time the sidebar renders.
const service = createBetterSidebarService(sidebarStore)
ctx.provide('betterSidebar', service)
// Terminal tab titles use the host's effective shell name (e.g. bash/zsh)
// instead of "Terminal 1". Start with a safe fallback and replace it as
// soon as the host shell info resolves. Tabs created before the response
// arrives keep the fallback title, so also retitle any already-open UI
// terminal tabs that still carry it.
const fallbackTitle = t('terminal')
let terminalTitle = fallbackTitle
void api.shellGet().then(({ name }) => {
terminalTitle = name
const snapshot = service.getSnapshot()
if (snapshot.state === undefined) return
const tabs = allLeaves(snapshot.state.splits)
.concat(allLeaves(snapshot.state.bottomSplits))
.flatMap(leaf => leaf.tabs)
for (const tab of tabs) {
if (tab.type === 'terminal' && !isAgentTabId(tab.id) && tab.title === fallbackTitle) {
service.updateTab(tab.id, { title: name })
}
}
}).catch(() => { /* keep fallback */ })
// Register the plugin's own built-in tabs and viewers through the same
// service (eating our own dogfood). The disposer unregisters them on
// fiber disposal (HMR-safe).
ctx.effect(
() => registerBuiltins(ctx, service),
() => registerBuiltins(ctx, service, { terminalTitle: () => terminalTitle }),
'dsh-better-sidebar: register built-in tabs and viewers',
)
// A failure anywhere in the client lifecycle must never take the app down
+10
View File
@@ -49,6 +49,11 @@ export const zh = {
terminalDepsProfile: '(检测到 profile:{profile})',
preview: '预览',
edit: '编辑',
mermaidError: 'Mermaid 渲染失败',
mermaidZoomIn: '放大',
mermaidZoomOut: '缩小',
mermaidZoomReset: '重置',
mermaidZoomHint: '滚轮缩放 · 拖拽平移 · Esc 关闭',
refresh: '刷新',
save: '保存',
saved: '已保存',
@@ -291,6 +296,11 @@ export const en: Record<keyof typeof zh, string> = {
terminalDepsProfile: ' (detected profile: {profile})',
preview: 'Preview',
edit: 'Edit',
mermaidError: 'Mermaid render failed',
mermaidZoomIn: 'Zoom in',
mermaidZoomOut: 'Zoom out',
mermaidZoomReset: 'Reset',
mermaidZoomHint: 'Scroll to zoom · drag to pan · Esc to close',
refresh: 'Refresh',
save: 'Save',
saved: 'Saved',
+110
View File
@@ -0,0 +1,110 @@
/**
* Markdown/mermaid fence detection for the markdown preview. The preview
* renders the WHOLE document through one DSH `MarkdownText` pass (so
* cross-fence semantics — reference-style links, footnotes, list
* continuity — stay intact) and the mermaid lazy chunk then swaps the
* rendered `language-mermaid` code blocks for diagrams. This module's pure
* splitter exists to detect whether the source contains a mermaid fence at
* all, so the mermaid chunk is only fetched when needed (unit-tested in
* tests/mermaid-blocks.spec.ts).
*/
/** One fenced mermaid diagram lifted out of the markdown source. */
export interface MermaidBlock {
kind: 'mermaid'
/** The raw diagram source between the fences (info string stripped). */
code: string
}
/** A span of plain markdown source (may itself contain non-mermaid fences). */
export interface MarkdownBlock {
kind: 'markdown'
text: string
}
export type MdBlock = MarkdownBlock | MermaidBlock
/** Props of the chunk-resident `MermaidMarkdown` component (shared contract). */
export interface MermaidMarkdownProps {
/** The full markdown source (rendered in a single MarkdownText pass). */
text: string
codeLabels: { copyLabel: string; copiedLabel: string }
}
/** CommonMark opening fence: 0-3 spaces indent + a run of 3+ backticks or tildes. */
const OPEN_FENCE_RE = /^ {0,3}(`{3,}|~{3,})/
/** A closing-fence line: 0-3 spaces indent + 3+ backticks/tildes + trailing spaces only. */
const CLOSE_FENCE_RE = /^ {0,3}(`{3,}|~{3,})[ \t]*$/
/** Parse the info string from the line tail after the fence run; null when invalid. */
function fenceInfo(rest: string, fence: string): string | null {
const info = rest.trimStart().split(/\s+/)[0] ?? ''
// CommonMark: a backtick fence's info string may not contain backticks —
// such a line is not an opening fence at all.
if (fence.charAt(0) === '`' && info.includes('`')) return null
return info
}
/** True when the fence info string names mermaid (bare or `mermaid{...}`). */
function isMermaidInfo(info: string): boolean {
const word = info.toLowerCase()
return word === 'mermaid' || word.startsWith('mermaid{')
}
/**
* Split markdown source into md/mermaid blocks for detection: only fences
* whose info string names mermaid are lifted; every other line stays in the
* markdown stream untouched. CommonMark fence rules are honored — opening
* fences of 3+ backticks OR tildes, and a closing fence must use the same
* character with at least as many characters as the opening fence. An
* unterminated mermaid fence swallows the rest of the file (the same
* recovery CommonMark applies to open fences).
*/
export function splitMermaidBlocks(text: string): MdBlock[] {
if (text === '') return []
const lines = text.split('\n')
const blocks: MdBlock[] = []
let markdown: string[] = []
let index = 0
const flushMarkdown = (): void => {
if (markdown.length === 0) return
blocks.push({ kind: 'markdown', text: markdown.join('\n') })
markdown = []
}
while (index < lines.length) {
const line = lines[index] ?? ''
const fenceMatch = OPEN_FENCE_RE.exec(line)
if (fenceMatch === null) {
markdown.push(line)
index += 1
continue
}
const fence = fenceMatch[1]!
// The line tail after the whole matched prefix (indent + fence run).
const rest = line.slice(fenceMatch.index + fenceMatch[0].length)
const info = fenceInfo(rest, fence)
if (info === null || !isMermaidInfo(info)) {
markdown.push(line)
index += 1
continue
}
flushMarkdown()
const char = fence.charAt(0)
const length = fence.length
const code: string[] = []
index += 1
while (index < lines.length) {
const candidate = lines[index] ?? ''
const close = CLOSE_FENCE_RE.exec(candidate)
if (close !== null && close[1]!.charAt(0) === char && close[1]!.length >= length) break
code.push(candidate)
index += 1
}
// Consume the closing fence (or run off the end on an open fence).
index += 1
blocks.push({ kind: 'mermaid', code: code.join('\n') })
}
flushMarkdown()
return blocks
}
+93
View File
@@ -0,0 +1,93 @@
/**
* SVG sanitization for mermaid-rendered diagrams (markdown preview). The
* diagrams come from untrusted markdown sources, so the emitted SVG is
* re-sanitized before it reaches dangerouslySetInnerHTML — defense in depth
* on top of mermaid's own `securityLevel: 'strict'` (labels escaped, click
* directives inert) and `htmlLabels: false` (labels as real SVG <text>).
*
* What is stripped, and why:
* - `foreignObject`: the only channel that can carry raw HTML inside an
* SVG document — with it gone, a hostile label cannot smuggle an
* <img onerror> / <iframe> through the XML parse (any non-SVG element
* outside foreignObject makes the XML parse fail and the whole diagram
* is rejected).
* - `script` and foreign HTML elements (`img`/`iframe`/`object`/`embed`/
* `video`/`audio`/`input`/`button`/`form`/`link`/`meta`/`base`): belt and
* braces — real browsers reject these outside foreignObject at XML parse
* time (→ '') while lenient parsers keep them, so they are stripped
* explicitly instead of relying on the parser alone. Element names are
* matched case-insensitively (XML preserves their case; the output is
* re-parsed as HTML whose parser normalizes tag casing, so `<sCrIpT>` /
* `<foreignobject>` would otherwise come back as script/foreignObject).
* - `@*` / `on*` attributes: event-handler channels (Vue-style directives
* and native listeners), matched case-insensitively — the output is
* re-parsed as HTML whose parser normalizes attribute casing, so
* `oNload`/`OnClick` must not survive either.
* - ALL `href` / `xlink:href` attributes: the diagrams are static (no
* bindFunctions, no click navigation), so links carry no value — and an
* attacker-controlled URL (even an http(s) one) could otherwise navigate
* the whole GUI away from the sidebar. Removing every href keeps the
* surface deterministic.
*
* Parsing happens with `image/svg+xml`, so a malformed SVG fails the XML
* parse and the function returns '' (the caller surfaces the error
* fallback) instead of ever passing the raw string through; only a document
* whose root is an `<svg>` element is accepted.
*/
/** Element local names stripped case-insensitively (all lowercase). */
const STRIP_ELEMENTS = new Set([
'foreignobject',
'script',
'img',
'iframe',
'object',
'embed',
'video',
'audio',
'input',
'button',
'form',
'link',
'meta',
'base',
])
/** A parse failure keeps nothing of the input: the caller shows the error. */
export function sanitizeSvg(svg: string): string {
if (typeof DOMParser === 'undefined' || typeof XMLSerializer === 'undefined') return ''
let doc: Document
try {
doc = new DOMParser().parseFromString(svg, 'image/svg+xml')
} catch {
return ''
}
if (doc.querySelector('parsererror') !== null) return ''
if (doc.documentElement === null || doc.documentElement.localName !== 'svg') return ''
doc.querySelectorAll('*').forEach((node) => {
// XML element names preserve case; the string is later re-parsed as HTML
// whose parser normalizes tag casing — so mixed-case spellings of the
// strip list (e.g. `<sCrIpT>`, `<foreignobject>`) must be caught the
// same way as their canonical forms.
if (STRIP_ELEMENTS.has(node.localName.toLowerCase())) {
node.remove()
return
}
for (const attribute of [...node.attributes]) {
const name = attribute.name
// XML attribute names preserve case; the string is later re-parsed as
// HTML (dangerouslySetInnerHTML), whose parser normalizes attribute
// casing — so a mixed-case `oNload`/`HREF` must be caught the same way
// as its lowercase form. Compare on the lowercased name, remove with
// the original.
const normalized = name.toLowerCase()
if (normalized.startsWith('@') || normalized.startsWith('on')) {
node.removeAttribute(name)
continue
}
if (normalized === 'href' || normalized === 'xlink:href') {
node.removeAttribute(name)
}
}
})
return new XMLSerializer().serializeToString(doc.documentElement)
}
+401
View File
@@ -0,0 +1,401 @@
/**
* Mermaid diagram renderer for the markdown preview, resident in the
* `mermaid` lazy chunk (src/client/chunks/mermaid.tsx): the mermaid library
* and its transitive graph deps (d3/dagre/cytoscape) are inlined into
* lib/client-mermaid.js and fetched only when a previewed markdown file
* actually contains a mermaid fence (see mermaid-blocks.ts).
*
* Rendering architecture: the whole document is rendered ONCE through the
* DSH `MarkdownText` (so cross-fence semantics — reference-style links,
* footnotes, ordered-list continuity — stay intact), then a layout effect
* swaps every rendered `language-mermaid` CodeBlock for a diagram. The
* swap keeps the React-managed `.md-code-block` host node in the tree and
* only replaces its children (display:contents suppresses the code-block
* chrome), so React reconciliation never loses the host; when a swapped
* block stops being a mermaid fence, the original children are restored.
*
* Security: mermaid.render → sanitized SVG injected into the block.
* `bindFunctions` is intentionally NOT applied (static diagrams; no
* flowchart click handlers), `securityLevel` stays 'strict' (labels are
* escaped, no raw-HTML foreignObject), `htmlLabels: false` keeps node text
* as real SVG <text>, and the emitted SVG is re-sanitized (see
* mermaid-sanitize.ts) before it reaches dangerouslySetInnerHTML. Clicking
* a rendered diagram opens a zoom/pan modal (borrowed interaction design
* from the mermaid PR #75, reimplemented on top of the sanitized SVG —
* the clone carries no event surface, so the modal adds no attack surface).
*/
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
import { createPortal } from 'react-dom'
import { createRoot, type Root } from 'react-dom/client'
import mermaid from 'mermaid'
import { IconCopyOutline16, MarkdownText, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
import { isDarkScheme, subscribeColorScheme } from './theme.ts'
import { t } from './locales.ts'
import { sanitizeSvg } from './mermaid-sanitize.ts'
import type { MermaidMarkdownProps } from './mermaid-blocks.ts'
import css from './sidebar.module.css'
/** Monotonic id seed: every render call gets a fresh, document-unique id. */
let mermaidSeq = 0
/** Configure mermaid for the current color scheme (idempotent). */
function configureMermaid(): void {
mermaid.initialize({
startOnLoad: false,
securityLevel: 'strict',
// Labels as real SVG <text>: mermaid's default htmlLabels renders node
// text inside <foreignObject>, which the sanitizer strips wholesale —
// forcing pure SVG text keeps labels visible and the HTML label channel
// closed (strict already escapes label content).
htmlLabels: false,
theme: isDarkScheme() ? 'dark' : 'default',
})
}
/** First lines of a mermaid error (its dumps are huge; the head explains). */
function summarizeError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error)
return message.split('\n').slice(0, 6).join('\n')
}
/** The zoom/pan modal for one rendered diagram (click-to-enlarge). */
function MermaidZoomModal({ svg, onClose }: { svg: SVGSVGElement; onClose: () => void }): React.ReactNode {
const overlayRef = useRef<HTMLDivElement>(null)
const stageRef = useRef<HTMLDivElement>(null)
const svgRef = useRef<SVGSVGElement | null>(null)
const dragRef = useRef({ active: false, startX: 0, startY: 0 })
const zoomRef = useRef({ scale: 1, tx: 0, ty: 0 })
const applyTransform = (): void => {
const node = svgRef.current
if (node === null) return
const { scale, tx, ty } = zoomRef.current
node.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`
}
/** Zoom by `delta` keeping the stage point (centerX/centerY) fixed. */
const zoom = useCallback((delta: number, centerX?: number, centerY?: number): void => {
const stage = stageRef.current
if (stage === null) return
const rect = stage.getBoundingClientRect()
const cx = centerX ?? rect.width / 2
const cy = centerY ?? rect.height / 2
const current = zoomRef.current
const newScale = Math.min(8, Math.max(0.2, current.scale * delta))
// The svg is flex-centered in the stage, so its center sits at
// (rect.width/2, rect.height/2). Solve for the translate that keeps the
// mouse point stationary in stage coordinates across the scale change.
const sx = rect.width / 2
const sy = rect.height / 2
const ratio = newScale / current.scale
current.tx = cx - sx - (cx - sx - current.tx) * ratio
current.ty = cy - sy - (cy - sy - current.ty) * ratio
current.scale = newScale
applyTransform()
}, [])
const reset = useCallback((): void => {
zoomRef.current = { scale: 1, tx: 0, ty: 0 }
applyTransform()
}, [])
const close = useCallback((): void => { onClose() }, [onClose])
// Mount the caller-provided (sanitized) svg clone imperatively: React
// types don't accept a raw DOM node as a child, and the modal owns the
// node's lifetime (removed on unmount; the preview copy is untouched).
useEffect(() => {
const stage = stageRef.current
if (stage === null) return
svgRef.current = svg
stage.appendChild(svg)
return () => {
svg.remove()
svgRef.current = null
}
}, [svg])
useEffect(() => {
const stage = stageRef.current
const node = svgRef.current
const overlay = overlayRef.current
if (stage === null || node === null || overlay === null) return
const onWheel = (event: WheelEvent): void => {
event.preventDefault()
const rect = stage.getBoundingClientRect()
zoom(event.deltaY < 0 ? 1.1 : 1 / 1.1, event.clientX - rect.left, event.clientY - rect.top)
}
const onKey = (event: KeyboardEvent): void => {
if (event.key === 'Escape') { close(); return }
if (event.key === '+' || event.key === '=') zoom(1.2)
else if (event.key === '-') zoom(1 / 1.2)
else if (event.key === '0') reset()
}
const onMouseDown = (event: MouseEvent): void => {
event.preventDefault()
dragRef.current = {
active: true,
startX: event.clientX - zoomRef.current.tx,
startY: event.clientY - zoomRef.current.ty,
}
}
const onMouseMove = (event: MouseEvent): void => {
if (!dragRef.current.active) return
zoomRef.current.tx = event.clientX - dragRef.current.startX
zoomRef.current.ty = event.clientY - dragRef.current.startY
applyTransform()
}
const onMouseUp = (): void => { dragRef.current.active = false }
const onOverlayClick = (event: MouseEvent): void => {
if (event.target === overlay) close()
}
// React's synthetic wheel is passive; a native listener is required to
// preventDefault (the page must not scroll while zooming the modal).
stage.addEventListener('wheel', onWheel, { passive: false })
node.addEventListener('mousedown', onMouseDown)
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
window.addEventListener('keydown', onKey)
overlay.addEventListener('click', onOverlayClick)
return () => {
stage.removeEventListener('wheel', onWheel)
node.removeEventListener('mousedown', onMouseDown)
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
window.removeEventListener('keydown', onKey)
overlay.removeEventListener('click', onOverlayClick)
}
}, [zoom, reset, close])
return createPortal(
<div className={css.mermaidModal} data-mermaid-modal ref={overlayRef}>
<div className={css.mermaidModalToolbar}>
<button
type="button"
className={css.mermaidModalButton}
title={t('mermaidZoomOut')}
onClick={() => zoom(1 / 1.2)}
>
−
</button>
<button
type="button"
className={css.mermaidModalButton}
title={t('mermaidZoomIn')}
onClick={() => zoom(1.2)}
>
+
</button>
<button
type="button"
className={css.mermaidModalButton}
title={t('mermaidZoomReset')}
onClick={reset}
>
⟳
</button>
<button
type="button"
className={css.mermaidModalButton}
title={t('close')}
onClick={close}
>
✕
</button>
</div>
<div className={css.mermaidModalStage} ref={stageRef} />
<div className={css.mermaidModalHint}>{t('mermaidZoomHint')}</div>
</div>,
document.body,
)
}
/** One rendered mermaid fence: header chrome + diagram (or error + source). */
function MermaidDiagram({ code }: { code: string }): React.ReactNode {
const [svg, setSvg] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
/** Scheme state: a flip re-renders the diagram with the matching theme. */
const [dark, setDark] = useState(() => isDarkScheme())
/** The cloned svg shown in the zoom modal (null = closed). */
const [zoomSvg, setZoomSvg] = useState<SVGSVGElement | null>(null)
const copyTimer = useRef<number | undefined>(undefined)
useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), [])
useEffect(() => {
let cancelled = false
setSvg(null)
setError(null)
if (code.trim() === '') return () => { cancelled = true }
configureMermaid()
const id = `dsh-md-mermaid-${mermaidSeq += 1}`
mermaid.render(id, code)
.then(({ svg: rendered }) => {
if (cancelled) return
const clean = sanitizeSvg(rendered)
if (clean === '') {
// Parse/sanitize rejection: never pass the raw string through.
setError(t('mermaidError'))
return
}
setSvg(clean)
})
.catch((reason: unknown) => {
if (cancelled) return
setError(summarizeError(reason))
})
return () => { cancelled = true }
}, [code, dark])
const onCopy = useCallback(() => {
if (copied) return
writeClipboard(code).then((ok) => {
if (!ok) return
setCopied(true)
window.clearTimeout(copyTimer.current)
copyTimer.current = window.setTimeout(() => { setCopied(false) }, 1000)
})
}, [code, copied])
/** Clicking the diagram opens the zoom modal with a sanitized clone. */
const onBodyClick = (event: ReactMouseEvent<HTMLDivElement>): void => {
const svgEl = (event.target as Element).closest('svg')
if (svgEl === null) return
const clone = svgEl.cloneNode(true) as SVGSVGElement
// The modal owns sizing/transform; drop the preview's inline geometry.
clone.removeAttribute('style')
clone.removeAttribute('width')
clone.removeAttribute('height')
setZoomSvg(clone)
}
return (
<div className={css.mermaidWrap}>
<div className={css.mermaidHeader}>
<span className={css.mermaidInfo}>mermaid</span>
<button
type="button"
className={css.mermaidCopy}
onClick={onCopy}
aria-label={t('copy')}
title={t('copy')}
>
<IconCopyOutline16 />
<span>{copied ? t('copied') : t('copy')}</span>
</button>
</div>
{error !== null && <div className={css.mermaidError} title={error}>{t('mermaidError')}</div>}
{svg !== null && (
<div
className={css.mermaidBody}
data-mermaid-diagram
onClick={onBodyClick}
dangerouslySetInnerHTML={{ __html: svg }}
/>
)}
{error !== null && <pre className={css.mermaidCode}><code>{code}</code></pre>}
{zoomSvg !== null && <MermaidZoomModal svg={zoomSvg} onClose={() => { setZoomSvg(null) }} />}
</div>
)
}
/** One swapped mount: the diagram root + the CodeBlock children it displaced. */
interface MermaidMount {
root: Root
source: string
removed: ChildNode[]
}
/**
* True when a rendered CodeBlock is a mermaid fence. The DSH CodeBlock has
* two bodies: the shiki path carries the `language-*` class on generated
* <code> elements, while the plain path (which is always the one mermaid
* takes — no shiki grammar) only shows the language in the banner
* infostring (first element of the banner row; CSS-module classes are
* hashed, so that match is structural).
*/
function isMermaidBlock(block: HTMLElement): boolean {
const code = block.querySelector('code')
if (code !== null && [...code.classList].some(c => c.startsWith('language-mermaid'))) return true
const infostring = block.firstElementChild?.firstElementChild?.firstElementChild
return infostring !== null
&& infostring !== undefined
&& (infostring.textContent ?? '').trim() === 'mermaid'
}
/**
* The chunk-resident markdown preview renderer: ONE MarkdownText pass over
* the full source (cross-fence reference/footnote/list semantics intact),
* then every rendered `language-mermaid` code block is swapped for a
* `MermaidDiagram`. The `.md-code-block` host stays in the React tree —
* only its children are replaced — so reconciliation never loses the host;
* a block that stops being a mermaid fence gets its original children back.
* Only mounted when the source contains at least one mermaid fence (see
* TextEditor.tsx).
*/
export function MermaidMarkdown({ text, codeLabels }: MermaidMarkdownProps): React.ReactNode {
const containerRef = useRef<HTMLDivElement>(null)
const mountsRef = useRef(new Map<HTMLElement, MermaidMount>())
useLayoutEffect(() => {
const container = containerRef.current
if (container === null) return
const mounts = mountsRef.current
const seen = new Set<HTMLElement>()
for (const block of container.querySelectorAll<HTMLElement>('.md-code-block')) {
const mount = mounts.get(block)
const isMermaid = isMermaidBlock(block)
if (!isMermaid) {
// A previously swapped block that is no longer a mermaid fence:
// restore the CodeBlock children React still manages, so the plain
// fence renders normally again.
if (mount !== undefined) {
mount.root.unmount()
block.replaceChildren(...mount.removed)
block.removeAttribute('data-mermaid-processed')
mounts.delete(block)
}
continue
}
seen.add(block)
// The plain body always carries the fence source in <code>.
const source = block.querySelector('code')?.textContent ?? ''
if (mount !== undefined && mount.source === source) continue
if (mount === undefined) {
const host = document.createElement('div')
const removed = [...block.childNodes]
block.replaceChildren(host)
block.setAttribute('data-mermaid-processed', 'true')
const root = createRoot(host)
mounts.set(block, { root, source, removed })
root.render(<MermaidDiagram code={source} />)
} else {
mount.source = source
mount.root.render(<MermaidDiagram code={source} />)
}
}
// Drop mounts whose code block left the tree (fence removed, document
// restructured): the host node is gone with it.
for (const [block, mount] of mounts) {
if (seen.has(block)) continue
mount.root.unmount()
mounts.delete(block)
}
}, [text])
useEffect(() => () => {
for (const { root } of mountsRef.current.values()) root.unmount()
mountsRef.current.clear()
}, [])
return (
<div className={css.mermaidMarkdown} ref={containerRef}>
<MarkdownText text={text} codeLabels={codeLabels} />
</div>
)
}
+1 -1
View File
@@ -470,7 +470,7 @@ export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDes
* The plugin version this service instance reports. Keep in lockstep with
* `package.json`'s version — `tests/service.spec.ts` asserts the pair.
*/
export const SIDEBAR_SERVICE_VERSION = '0.13.0'
export const SIDEBAR_SERVICE_VERSION = '0.13.1'
/**
* Monotonic capability list consumers use to gate new API usage (features
+168
View File
@@ -1290,6 +1290,174 @@
font: var(--dsw-font-xs-13);
}
/* ── Mermaid diagram block (markdown preview, mermaid lazy chunk) ────────
Mirrors the md-code-block chrome the DSH CodeBlock renders, so a diagram
reads as a peer of the surrounding fences: hairline border, layer-2
header with the info string + copy action, layer-1 body. The SVG body is
static (no bindFunctions) and sanitized (no foreignObject/script/@attrs/
hrefs), so it carries no event surface beyond the copy button; the body
click opens the zoom modal on a sanitized clone. */
.mermaidWrap {
margin: 6px 0;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 6px;
background: var(--dsw-alias-bg-layer-1);
overflow: hidden;
}
.mermaidHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 4px 8px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
background: var(--dsw-alias-bg-layer-2);
}
.mermaidInfo {
font: var(--dsw-font-xxxs-strong-11);
color: var(--dsw-alias-label-tertiary);
}
.mermaidCopy {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0 6px;
height: 20px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xxxs-11);
cursor: pointer;
}
.mermaidCopy:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.mermaidBody {
display: flex;
justify-content: center;
padding: 10px;
overflow: auto;
cursor: zoom-in;
}
.mermaidBody :global(svg) {
max-width: 100%;
height: auto;
}
.mermaidError {
padding: 6px 10px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
color: var(--dsw-alias-state-error-primary);
font: var(--dsw-font-xxxs-11);
}
/* The diagram source shown under a render error (helps fixing the fence). */
.mermaidCode {
margin: 0;
padding: 8px 10px;
overflow: auto;
font: var(--dsw-font-xxxs-11);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
/* The mermaid markdown container (single MarkdownText pass): a swapped
code block keeps its React-managed .md-code-block wrapper in the tree
(reconciliation safety), but the wrapper's own box is suppressed so only
the diagram chrome renders; the swapped-in host div flows as a block. */
.mermaidMarkdown :global(.md-code-block[data-mermaid-processed]) {
display: contents;
}
/* ── Mermaid zoom modal (click-to-enlarge; portalled to document.body) ──
Fixed overlay above the DSH overlay stack (1000+), token-driven surface.
The stage centers the (sanitized, cloned) svg and the modal owns the
transform; the drag/zoom listeners live in the component, nothing here
targets global class names. */
.mermaidModal {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--dsw-alias-bg-mask-1);
backdrop-filter: blur(2px);
}
.mermaidModalToolbar {
position: absolute;
top: 16px;
right: 16px;
display: flex;
gap: 8px;
z-index: 10;
}
.mermaidModalButton {
width: 36px;
height: 36px;
border-radius: 8px;
border: 1px solid var(--dsw-alias-border-l1);
background: var(--dsw-alias-bg-layer-2);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xs-strong-13);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.mermaidModalButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.mermaidModalStage {
width: 90vw;
height: 80vh;
overflow: hidden;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.mermaidModalStage :global(svg) {
max-width: none;
max-height: none;
cursor: grab;
transform-origin: center center;
user-select: none;
-webkit-user-drag: none;
background: var(--dsw-alias-bg-layer-1);
padding: 16px;
border-radius: 12px;
}
.mermaidModalStage :global(svg):active {
cursor: grabbing;
}
.mermaidModalHint {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
color: var(--dsw-alias-label-tertiary);
font: var(--dsw-font-xxxs-11);
pointer-events: none;
}
/* The floating "add to conversation" button anchored above a text
selection in the preview/code viewers. Portalled to document.body and
position:fixed, so the editor's overflow clips cannot crop it; flat
+14 -1
View File
@@ -53,9 +53,17 @@ export interface SidebarConfig {
* terminal tabs and the model-facing `terminal_*` tools. Empty = auto:
* POSIX follows `$SHELL` then the account login shell; Windows follows
* `DSH_SIDEBAR_SHELL`, then probes for `pwsh.exe`, then falls back to the
* inbox `powershell.exe` (5.1).
* inbox `powershell.exe` (5.1). Set it from `cordis.patch.yml` / profile
* plugin config, e.g. `config: { shell: /bin/zsh }`.
*/
shell?: string
/**
* Optional arguments passed to the shell executable. When non-empty these
* REPLACE the automatic platform defaults (POSIX `-l` / Windows none), so
* the deployment has full control over how the shell starts. When omitted
* the existing default behavior is kept.
*/
shellArgs?: string[]
}
/** Schemastery schema for the plugin configuration. */
@@ -66,6 +74,7 @@ export const Config: z<SidebarConfig> = z.object({
terminalsPerSession: z.number().step(1).min(1).default(3),
reconnectGraceMs: z.number().step(1).min(0).default(30_000),
shell: z.string().default(''),
shellArgs: z.array(z.string()).default([]),
})
/** Fully defaulted sidebar host settings. */
@@ -75,7 +84,10 @@ export interface ResolvedSidebarConfig {
listLimit: number
terminalsPerSession: number
reconnectGraceMs: number
/** The configured terminal shell; empty means the host auto-resolves it. */
shell: string
/** Explicit shell arguments; empty means use the platform defaults. */
shellArgs: string[]
}
/**
@@ -92,6 +104,7 @@ export function resolveSidebarConfig(config: SidebarConfig | undefined): Resolve
terminalsPerSession: config?.terminalsPerSession ?? 3,
reconnectGraceMs: config?.reconnectGraceMs ?? 30_000,
shell: config?.shell?.trim() ?? '',
shellArgs: config?.shellArgs ?? [],
}
}
+15 -5
View File
@@ -36,7 +36,7 @@ import { isTrustedApiRequest, isLoopbackHostname } from './trust-fence.ts'
import { registerBundleRoute } from './bundle-route.ts'
import * as git from './git.ts'
import { SettingsConflictError, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
import { defaultShell, ensureSpawnHelper, PtyManager } from './pty-manager.ts'
import { defaultShell, ensureSpawnHelper, PtyManager, shellDisplayName } from './pty-manager.ts'
import { AgentPtyRegistry, clampDims, type AgentTerminalHandle } from './agent-pty.ts'
import {
DSH_NODE_PTY_RANGE,
@@ -191,12 +191,13 @@ export interface SidebarSettingsFace {
update(patch: Record<string, unknown>, expectedRevision?: number): Promise<{ value?: unknown; revision?: number }>
}
/** Build the API method table bound to the plugin context, pty manager, agent pty registry, and resolved config. */
/** Build the API method table bound to the plugin context, pty manager, agent pty registry, resolved config, and effective terminal shell. */
function buildApi(
ctx: Context,
ptyManager: PtyManager | null,
agentPtyRegistry: AgentPtyRegistry | null,
resolved: ResolvedSidebarConfig,
terminalShell: string,
getSettings: () => SidebarSettingsFace | undefined,
): Record<string, ApiMethod> {
const cwdOf = (payload: unknown): { sessionId: string; cwd: string } => {
@@ -363,6 +364,11 @@ function buildApi(
// exists. Kill is fenced to the owning session by the jobs registry.
'jobs.output': (payload) => jobsApi.output(payload),
'jobs.kill': (payload) => jobsApi.kill(payload),
// The effective terminal shell and its display name. The client uses
// this to title terminal tabs with the shell name instead of a numbered
// "Terminal N" label; the shell itself is configured through
// `cordis.patch.yml` (`config.shell`) or resolved by the host default.
'shell.get': () => ({ shell: terminalShell, name: shellDisplayName(terminalShell) }),
// The side card preferences. The settings service is optional in the
// composition; while absent the routes report undefined and the client
// keeps the schema defaults. Writes are revision-guarded: a stale editor
@@ -480,13 +486,17 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
: `${status.cause}. Repair: ${status.command}`
ctx.logger?.warn(`[dsh-better-sidebar] node-pty (${DSH_NODE_PTY_RANGE}) failed to load: ${detail}`)
}
const ptyManager = nodePty !== null ? new PtyManager(terminalShell, resolved.terminalsPerSession, nodePty) : null
const ptyManager = nodePty !== null
? new PtyManager(terminalShell, resolved.terminalsPerSession, resolved.shellArgs, nodePty)
: null
// The agent-owned terminal registry: parallel to the UI-tab ptyManager,
// keyed by uuid (the model's opaque handle) instead of `${sessionId}:${tabId}`,
// uncapped, and torn down with the plugin. The model creates terminals here
// through the terminal_create tool; the sidebar view attaches through the
// same /sidebar/ws/terminal upgrade with ?uuid=... instead of ?tab=...
const agentPtyRegistry = nodePty !== null ? new AgentPtyRegistry(terminalShell, nodePty) : null
const agentPtyRegistry = nodePty !== null
? new AgentPtyRegistry(terminalShell, resolved.shellArgs, nodePty)
: null
// ── User-facing "Side card" preferences ──────────────────────────────────
// Register the namespace with the settings provider so the Settings page
@@ -560,7 +570,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
})
// ── JSON API ────────────────────────────────────────────────────────────
const api = buildApi(ctx, ptyManager, agentPtyRegistry, resolved, () => settingsFace)
const api = buildApi(ctx, ptyManager, agentPtyRegistry, resolved, terminalShell, () => settingsFace)
ctx.effect(() => ctx.webServer.register({
kind: 'prefix',
path: '/sidebar/api',
+19 -2
View File
@@ -72,6 +72,7 @@ export class PtyManager {
constructor(
private readonly shell: string,
private readonly maxPerSession: number,
private readonly shellArgs: string[] = [],
/** The loaded node-pty module (injected so a broken install degrades instead of crashing the plugin). */
private readonly nodePty: NodePtyModule = loadRequiredNodePty(),
) {}
@@ -122,7 +123,7 @@ export class PtyManager {
sessionId,
tabId,
cwd,
pty: this.nodePty.spawn(this.shell, shellSpawnArgs(), {
pty: this.nodePty.spawn(this.shell, shellSpawnArgs(this.shellArgs), {
name: 'xterm-256color',
cols: Math.max(2, Math.floor(cols)),
rows: Math.max(2, Math.floor(rows)),
@@ -294,11 +295,27 @@ export function defaultShell(options: ShellResolutionOptions = {}): string {
return '/bin/bash'
}
/**
* A short display name for a shell executable, used as the terminal tab
* title. `/bin/zsh` → `zsh`, `C:\...\powershell.exe` → `powershell`.
* Falls back to the raw value when no basename can be derived.
*/
export function shellDisplayName(shell: string): string {
const normalized = shell.replace(/\\/g, '/')
const base = normalized.slice(normalized.lastIndexOf('/') + 1)
if (base === '') return shell
return base.replace(/\.(exe|cmd|bat)$/i, '')
}
/**
* Spawn arguments that make the shell behave like a terminal-emulator tab:
* POSIX shells start as login shells (`-l`) so they read the profile files
* (`~/.profile`, `~/.zprofile`); Windows PowerShell takes no login flag.
*
* When explicit `configured` args are supplied they REPLACE the platform
* defaults entirely, giving deployments full control over shell startup.
*/
export function shellSpawnArgs(): string[] {
export function shellSpawnArgs(configured: string[] = []): string[] {
if (configured.length > 0) return [...configured]
return process.platform === 'win32' ? [] : ['-l']
}
+28 -2
View File
@@ -15,11 +15,13 @@ import { createBetterSidebarService } from '../src/client/service.ts'
import { createSidebarStore } from '../src/client/state.ts'
import { allLeaves } from '../src/client/state.ts'
import { registerBuiltins } from '../src/client/builtins/index.ts'
import type { BuiltinTabOptions } from '../src/client/builtins/tabs.tsx'
import { t } from '../src/client/locales.ts'
function setup(): { service: ReturnType<typeof createBetterSidebarService>; store: ReturnType<typeof createSidebarStore>; dispose: () => void } {
function setup(options: BuiltinTabOptions = {}): { service: ReturnType<typeof createBetterSidebarService>; store: ReturnType<typeof createSidebarStore>; dispose: () => void } {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const dispose = registerBuiltins({} as Context, service)
const dispose = registerBuiltins({} as Context, service, options)
return { service, store, dispose }
}
@@ -111,6 +113,30 @@ describe('built-in tab registrations', () => {
expect(state.nextBrowser).toBe(3)
})
it('the terminal createTab uses shell-name titles and hidden uuid ids, allowing duplicates', () => {
const { service, store } = setup({ terminalTitle: () => 'bash' })
store.setSession('s1')
service.openTab({ type: 'terminal' })
service.openTab({ type: 'terminal' })
const state = store.getSnapshot().state!
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs).filter(t => t.type === 'terminal')
expect(tabs).toHaveLength(2)
expect(tabs[0]!.title).toBe('bash')
expect(tabs[1]!.title).toBe('bash')
expect(tabs[0]!.id).toMatch(/^terminal:[0-9a-f-]{36}$/)
expect(tabs[1]!.id).toMatch(/^terminal:[0-9a-f-]{36}$/)
expect(tabs[0]!.id).not.toBe(tabs[1]!.id)
})
it('the terminal createTab falls back to the localized terminal label before shell info resolves', () => {
const { service, store } = setup()
store.setSession('s1')
service.openTab({ type: 'terminal' })
const state = store.getSnapshot().state!
const tab = allLeaves(state.splits).flatMap(leaf => leaf.tabs).find(t => t.type === 'terminal')
expect(tab?.title).toBe(t('terminal'))
})
it('every built-in tab carries the settings-surface icon', () => {
const { service } = setup()
for (const tab of service.getTabs()) {
+1 -1
View File
@@ -145,6 +145,6 @@ describe('/sidebar/bundle route', () => {
})
it('exports the chunk allowlist (mirror of src/client/chunk-loader.ts)', () => {
expect([...CHUNK_NAMES]).toEqual(['terminal', 'editor'])
expect([...CHUNK_NAMES]).toEqual(['terminal', 'editor', 'mermaid'])
})
})
+6 -1
View File
@@ -15,11 +15,16 @@ import { CHUNK_EXTERNALS } from '../src/client/chunk-loader.ts'
const g = globalThis as Record<string, unknown>
const CHUNKS = ['terminal', 'editor']
const CHUNKS = ['terminal', 'editor', 'mermaid']
describe('built chunk artifacts', () => {
it('each chunk assigns its global registry slot when executed as a script', () => {
g.window = g // classic-script globals
// mermaid's core hooks window.addEventListener('load') at module scope
// (its startOnLoad wiring); the Node global lacks the API, so stub it
// exactly like browser-globals.ts does for its window stub.
if (typeof g.addEventListener !== 'function') g.addEventListener = () => {}
if (typeof g.removeEventListener !== 'function') g.removeEventListener = () => {}
for (const name of CHUNKS) {
const code = readFileSync(`lib/client-${name}.js`, 'utf8')
// eslint-disable-next-line no-new-func
+81 -7
View File
@@ -15,11 +15,12 @@
* fail() strips, no `pageerror`, no plugin-prefixed console errors);
* 4. expands the collapsed panel (openByDefault defaults off), sweeps every
* built-in tab (Files / Source Control / Tasks / Terminal / Browser) —
* including the lazily-fetched terminal chunk — and then opens a seeded
* file through the Files window's tree (in-place mode: the seeded home
* tab itself switches to the file, no new tab), while a response wait
* armed before goto proves the lazily-fetched editor chunk
* (client-editor.js) loaded.
* including the lazily-fetched terminal chunk — and then opens seeded
* files through the Files window's tree (in-place mode: the seeded home
* tab itself switches to the file, no new tab), while response waits
* armed before goto prove the lazily-fetched editor chunk
* (client-editor.js) and the mermaid chunk (client-mermaid.js, rendered
* SVG diagram + zoom modal) loaded.
*
* Deterministic by construction: every wait is on a DOM/network marker, the
* suite is serial (one server instance), and any crash trips the very next
@@ -42,6 +43,11 @@ const WORKSPACE_PATH = process.env.DSH_E2E_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-
* exercise the file-open path (editor chunk = client-editor.js). */
const SEEDED_FILE = 'hello.txt'
/** A markdown file with a mermaid fence, opened through the Files window's
* tree to force the lazily-packed mermaid chunk (client-mermaid.js) to load
* and render a sanitized SVG diagram. */
const SEEDED_MD_FILE = 'diagram.md'
/**
* The plugin's crash markers. The client mounts inside an error boundary that
* renders a strip whose text starts with these prefixes instead of crashing
@@ -54,11 +60,31 @@ const BUILTIN_TABS = ['Files', 'Source Control', 'Tasks', 'Terminal', 'Browser']
let api: APIRequestContext
/** Seed one workspace + one session (plus a file for the editor-chunk probe)
* through the host's unary RPC surface. */
/** Seed one workspace + one session (plus files for the editor/mermaid-chunk
* probes) through the host's unary RPC surface. */
async function seedSession(): Promise<void> {
mkdirSync(WORKSPACE_PATH, { recursive: true })
writeFileSync(join(WORKSPACE_PATH, SEEDED_FILE), 'hello from the mount lane\n')
// The mermaid-chunk probe file: a markdown doc whose preview must fetch
// client-mermaid.js and render the fence into an SVG diagram. The
// reference-style link's definition sits AFTER the fence: it only
// resolves when the preview is one single markdown parse (the mermaid
// path must not split the document into independent MarkdownText blocks).
writeFileSync(join(WORKSPACE_PATH, SEEDED_MD_FILE), [
'# Diagram',
'',
'[before][shared]',
'',
'```mermaid',
'graph TD',
' A[Hello] --> B[World]',
'```',
'',
'[shared]: https://example.com',
'',
'tail text',
'',
].join('\n'))
const workspace = await api.post(`${BASE_URL}/api/workspace.create`, {
data: { type: 'client-request', rpcId: 'e2e-workspace', method: 'workspace.create', payload: { path: WORKSPACE_PATH } },
})
@@ -230,6 +256,54 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
await page.waitForTimeout(1_500)
await assertNoCrash()
// The mermaid chunk (client-mermaid.js) only loads when a previewed
// markdown file contains a mermaid fence. Open the seeded diagram file
// from the files window's tree (the embedded tree stays pinned while
// hello.txt is open) and require the full round-trip: chunk fetch +
// sanitized SVG diagram in the preview, so a missing/corrupt mermaid
// chunk or a broken render fails the lane.
const mermaidChunk = page.waitForResponse(
(response) => response.url().includes('/sidebar/bundle/mermaid.js'),
{ timeout: 30_000 },
)
const mdRow = sidebar.locator(`[role="button"][title$="${SEEDED_MD_FILE}"]:visible`)
await expect(mdRow, `the seeded "${SEEDED_MD_FILE}" file must appear in the files window's tree`).toHaveCount(1, { timeout: 30_000 })
await mdRow.click({ position: { x: 8, y: 8 } })
await mermaidChunk
await expect(
sidebar.locator('[data-mermaid-diagram] svg'),
'the mermaid fence must render into an SVG diagram in the markdown preview',
).toHaveCount(1, { timeout: 30_000 })
// Labels must survive as real SVG <text> (htmlLabels stays off so the
// sanitizer's foreignObject strip cannot eat the node text).
await expect(
sidebar.locator('[data-mermaid-diagram]').first(),
'the diagram node labels must render inside the SVG',
).toContainText('Hello', { timeout: 30_000 })
// Cross-fence semantics: the reference-style link [before][shared] must
// resolve to the definition that sits AFTER the fence — proof that the
// preview is a single markdown parse and not per-fence fragments.
await expect(
sidebar.locator('a[href="https://example.com"]').first(),
'reference-style links with definitions across a mermaid fence must resolve',
).toContainText('before', { timeout: 30_000 })
// Click-to-enlarge: clicking the diagram opens the zoom modal (portalled
// to document.body), Esc closes it again.
const modal = page.locator('[data-mermaid-modal]')
await sidebar.locator('[data-mermaid-diagram] svg').first().click()
await expect(modal, 'clicking the diagram must open the zoom modal').toHaveCount(1, { timeout: 10_000 })
await page.keyboard.press('Escape')
await expect(modal, 'Esc must close the zoom modal').toHaveCount(0, { timeout: 10_000 })
// The preview/edit toggle is mutually exclusive: in preview mode the
// CodeMirror surface must be hidden (regression guard — a stale css copy
// in the page made the editor stay visible under the preview, breaking
// the toggle semantics).
await expect(
sidebar.locator('.cm-editor').first(),
'preview mode must hide the CodeMirror editor (mutually exclusive toggle)',
).toBeHidden()
await assertNoCrash()
// The plugin's own console prefix must never appear in errors, and no
// unhandled rejection may escape the sweep.
const pluginErrors = consoleErrors.filter((text) => /dsh-better-sidebar|Unhandled/.test(text))
@@ -0,0 +1,7 @@
# Fixture aggregate bundle: mounts dsh-better-sidebar under its own entry id,
# the way real aggregate bundles (e.g. @linxin666/dsh-web-ui-all) do. Used by
# scripts/e2e-aggregate-mount.sh to prove the plugin's own bundle patch backs
# off when another enabled entry already mounts the same package.
- insert:
- id: aggregate-better-sidebar
name: 'dsh-better-sidebar'
+4
View File
@@ -0,0 +1,4 @@
// No-op plugin body. This fixture only carries a patch layer (see
// cordis.patch.yml); no entry ever references this package by name, but the
// main file keeps npm pack's manifest valid.
export function apply() {}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "fixture-aggregate-better-sidebar",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "lib/index.js",
"files": [
"lib",
"cordis.patch.yml"
],
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
}
}
}
+1 -1
View File
@@ -65,7 +65,7 @@ function bundleId(file: string): string {
}
/** The lazy chunk bundle names (mirror of src/bundle-route.ts CHUNK_NAMES). */
const CHUNK_FILES = ['terminal', 'editor'].map(name => `lib/client-${name}.js`)
const CHUNK_FILES = ['terminal', 'editor', 'mermaid'].map(name => `lib/client-${name}.js`)
/** The global registry slot a built chunk script assigns (its factory key). */
function chunkSlot(file: string): string {
+92
View File
@@ -0,0 +1,92 @@
/**
* Markdown/mermaid block splitting: the pure splitter the markdown preview
* uses to lift mermaid fences out of the MarkdownText stream. Covers fence
* recognition (case, attributes, indentation), non-mermaid fences staying
* untouched, interleaved ordering, and open-fence recovery.
*/
import { describe, expect, it } from 'vitest'
import { splitMermaidBlocks } from '../src/client/mermaid-blocks.ts'
describe('splitMermaidBlocks', () => {
it('leaves mermaid-free markdown as one markdown block', () => {
expect(splitMermaidBlocks('# hi\n\ntext')).toEqual([
{ kind: 'markdown', text: '# hi\n\ntext' },
])
})
it('lifts a mermaid fence into a diagram block with the raw source', () => {
const blocks = splitMermaidBlocks('before\n```mermaid\ngraph TD\n A-->B\n```\nafter')
expect(blocks).toEqual([
{ kind: 'markdown', text: 'before' },
{ kind: 'mermaid', code: 'graph TD\n A-->B' },
{ kind: 'markdown', text: 'after' },
])
})
it('is case-insensitive and accepts mermaid attribute info strings', () => {
const lower = splitMermaidBlocks('```Mermaid\na-->b\n```')
expect(lower[0]).toEqual({ kind: 'mermaid', code: 'a-->b' })
const attributed = splitMermaidBlocks("```mermaid{theme:'dark'}\na-->b\n```")
expect(attributed[0]).toEqual({ kind: 'mermaid', code: 'a-->b' })
})
it('allows up to 3 spaces of fence indent (CommonMark)', () => {
const blocks = splitMermaidBlocks(' ```mermaid\na-->b\n ```')
expect(blocks).toEqual([{ kind: 'mermaid', code: 'a-->b' }])
})
it('does not lift fences inside a 4-space indented code block', () => {
const source = ' ```mermaid\n a-->b\n ```'
expect(splitMermaidBlocks(source)).toEqual([{ kind: 'markdown', text: source }])
})
it('keeps non-mermaid fences in the markdown stream untouched', () => {
const source = '```ts\nconst a = 1\n```'
expect(splitMermaidBlocks(source)).toEqual([{ kind: 'markdown', text: source }])
})
it('interleaves multiple diagrams preserving source order', () => {
const blocks = splitMermaidBlocks(
'# t\n```mermaid\ngraph TD\n A\n```\nmid\n```mermaid\npie\n "a": 1\n```\ntail',
)
expect(blocks.map(block => block.kind)).toEqual(['markdown', 'mermaid', 'markdown', 'mermaid', 'markdown'])
expect(blocks[1]).toEqual({ kind: 'mermaid', code: 'graph TD\n A' })
expect(blocks[3]).toEqual({ kind: 'mermaid', code: 'pie\n "a": 1' })
})
it('handles an empty file and mermaid-free text with no markdown blocks', () => {
expect(splitMermaidBlocks('')).toEqual([])
expect(splitMermaidBlocks('a\n\nb')).toEqual([{ kind: 'markdown', text: 'a\n\nb' }])
})
it('swallows the rest of the file on an unterminated mermaid fence', () => {
const blocks = splitMermaidBlocks('```mermaid\ngraph TD\n A-->B')
expect(blocks).toEqual([{ kind: 'mermaid', code: 'graph TD\n A-->B' }])
})
it('treats an empty mermaid fence as an empty diagram block', () => {
const blocks = splitMermaidBlocks('```mermaid\n```')
expect(blocks).toEqual([{ kind: 'mermaid', code: '' }])
})
it('lifts four-backtick and tilde fences (CommonMark fence runs)', () => {
const backticks = splitMermaidBlocks('````mermaid\ngraph TD\n A\n````')
expect(backticks).toEqual([{ kind: 'mermaid', code: 'graph TD\n A' }])
const tildes = splitMermaidBlocks('~~~mermaid\ngraph TD\n A\n~~~')
expect(tildes).toEqual([{ kind: 'mermaid', code: 'graph TD\n A' }])
})
it('only a same-char, at-least-as-long fence closes the opening fence', () => {
// A shorter backtick run must not close a 4-backtick opening fence.
const blocks = splitMermaidBlocks('````mermaid\na\n```\nb\n````')
expect(blocks).toEqual([{ kind: 'mermaid', code: 'a\n```\nb' }])
// A different fence character must not close the fence either.
const mixed = splitMermaidBlocks('~~~mermaid\na\n```\n~~~')
expect(mixed).toEqual([{ kind: 'mermaid', code: 'a\n```' }])
})
it('ignores backticks inside a backtick fence info string (CommonMark)', () => {
const source = '```mermaid`graph\na-->b\n```'
expect(splitMermaidBlocks(source)).toEqual([{ kind: 'markdown', text: source }])
})
})
+84
View File
@@ -0,0 +1,84 @@
/**
* MermaidMarkdown architecture spec: the preview renders the WHOLE document
* through one MarkdownText pass (cross-fence reference-style links must
* resolve — the P1 regression from the CR), then swaps every rendered
* mermaid CodeBlock for a diagram. mermaid is mocked so the swap + semantics
* are asserted without pulling the real layout engine into jsdom.
*/
// @vitest-environment jsdom
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
// The act() environment flag (React 18.2 reads it before flushing effects).
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
vi.mock('mermaid', () => ({
default: {
initialize: () => {},
render: async () => ({
svg: '<svg xmlns="http://www.w3.org/2000/svg"><text>MOCK-DIAGRAM</text></svg>',
}),
},
}))
import { MermaidMarkdown } from '../src/client/mermaid.tsx'
const codeLabels = { copyLabel: 'Copy', copiedLabel: 'Copied' }
async function renderMarkdown(text: string): Promise<{ container: HTMLElement; root: Root }> {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
await act(async () => {
root.render(createElement(MermaidMarkdown, { text, codeLabels }))
// Let the mocked mermaid.render promise settle inside the act scope.
await new Promise(resolve => { setTimeout(resolve, 0) })
})
return { container, root }
}
async function unmount(root: Root): Promise<void> {
await act(async () => { root.unmount() })
}
beforeEach(() => {
document.body.innerHTML = ''
})
describe('MermaidMarkdown', () => {
it('swaps the mermaid code block for a rendered diagram', async () => {
const { container, root } = await renderMarkdown('```mermaid\ngraph TD\n A-->B\n```')
const diagram = container.querySelector('[data-mermaid-diagram] svg')
expect(diagram, 'the mermaid fence must be swapped for a diagram').not.toBeNull()
expect(diagram?.textContent).toContain('MOCK-DIAGRAM')
await unmount(root)
})
it('resolves cross-fence reference-style links (single markdown parse)', async () => {
const text = [
'[before][shared]',
'',
'```mermaid',
'graph TD',
' A --> B',
'```',
'',
'[shared]: https://example.com',
].join('\n')
const { container, root } = await renderMarkdown(text)
const link = container.querySelector('a[href="https://example.com"]')
expect(link, 'the definition after the fence must resolve the link before it').not.toBeNull()
expect(link?.textContent).toContain('before')
expect(container.querySelector('[data-mermaid-diagram] svg'), 'the diagram must still swap in').not.toBeNull()
await unmount(root)
})
it('leaves non-mermaid fences untouched', async () => {
const { container, root } = await renderMarkdown('```ts\nconst a = 1\n```')
expect(container.querySelector('[data-mermaid-diagram]'), 'no mermaid fence → no swap').toBeNull()
expect(container.querySelectorAll('.md-code-block').length, 'the ts fence stays a code block').toBe(1)
await unmount(root)
})
})
+116
View File
@@ -0,0 +1,116 @@
/**
* Mermaid SVG sanitizer security spec: the rendered diagram must never
* carry an executable surface into the markdown preview — foreignObject
* (the raw-HTML channel) is stripped wholesale, event/`@*` attributes are
* removed, href/xlink:href are removed, script elements are removed, and a
* malformed/foreign document is rejected entirely (empty string) rather
* than passed through.
*/
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import { sanitizeSvg } from '../src/client/mermaid-sanitize.ts'
describe('sanitizeSvg', () => {
it('keeps a benign diagram (svg + text nodes)', () => {
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><text>Hello</text></svg>'
const out = sanitizeSvg(svg)
expect(out).toContain('<text>Hello</text>')
expect(out).not.toContain('foreignObject')
})
it('strips foreignObject wholesale (the raw-HTML channel)', () => {
const svg = [
'<svg xmlns="http://www.w3.org/2000/svg">',
'<foreignObject><div><img src="x" onerror="alert(1)"/></div></foreignObject>',
'<text>keep</text>',
'</svg>',
].join('')
const out = sanitizeSvg(svg)
expect(out).not.toContain('foreignObject')
expect(out).not.toContain('<img')
expect(out).not.toContain('onerror')
expect(out).toContain('<text>keep</text>')
})
it('strips on* and @* attributes from every element', () => {
const svg = '<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><text onclick="x()" @click="y()">t</text></svg>'
const out = sanitizeSvg(svg)
expect(out).not.toContain('onload')
expect(out).not.toContain('onclick')
expect(out).not.toContain('@click')
})
it('strips mixed-case event/link attributes (HTML parsing normalizes case)', () => {
const svg = [
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">',
'<text oNload="alert(1)" OnClick="x()">t</text>',
'<a HREF="javascript:alert(1)" xlink:HREF="data:text/html,<script>1</script>">x</a>',
'<text @Click="y()">u</text>',
'</svg>',
].join('')
const out = sanitizeSvg(svg)
expect(out).not.toContain('oNload')
expect(out).not.toContain('OnClick')
expect(out).not.toContain('@Click')
expect(out).not.toContain('HREF')
expect(out).not.toContain('xlink:HREF')
expect(out).not.toContain('javascript:')
})
it('removes href and xlink:href regardless of scheme', () => {
const svg = [
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">',
'<a href="javascript:alert(1)" xlink:href="data:text/html,<script>1</script>">x</a>',
'<a href="https://example.com">y</a>',
'</svg>',
].join('')
const out = sanitizeSvg(svg)
expect(out).not.toContain('href')
expect(out).not.toContain('javascript:')
expect(out).not.toContain('data:text/html')
})
it('removes script elements', () => {
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script><text>t</text></svg>'
const out = sanitizeSvg(svg)
expect(out).not.toContain('script')
expect(out).toContain('<text>t</text>')
})
it('strips mixed-case element names (HTML parsing normalizes tag casing)', () => {
const svg = [
'<svg xmlns="http://www.w3.org/2000/svg">',
'<sCrIpT>alert(1)</sCrIpT>',
'<foreignobject><div><img src="x" onerror="alert(1)"/></div></foreignobject>',
'<ImG src="y" onerror="alert(2)"/>',
'<iFrAmE src="https://evil.test"/>',
'<text>keep</text>',
'</svg>',
].join('')
const out = sanitizeSvg(svg)
expect(out).not.toContain('sCrIpT')
expect(out).not.toContain('alert')
expect(out).not.toContain('foreignobject')
expect(out).not.toContain('<img')
expect(out).not.toContain('<ImG')
expect(out).not.toContain('iFrAmE')
expect(out).not.toContain('onerror')
expect(out).toContain('<text>keep</text>')
})
it('rejects malformed XML instead of passing it through', () => {
const out = sanitizeSvg('<svg xmlns="http://www.w3.org/2000/svg"><text>oops</svg>')
expect(out).toBe('')
})
it('strips foreign HTML elements smuggled past lenient parsers', () => {
const out = sanitizeSvg('<svg xmlns="http://www.w3.org/2000/svg"><img src="x" onerror="alert(1)"/></svg>')
expect(out).not.toContain('<img')
expect(out).not.toContain('onerror')
})
it('rejects a non-svg document', () => {
const out = sanitizeSvg('<html><body onload="alert(1)">hi</body></html>')
expect(out).toBe('')
})
})
+8
View File
@@ -37,10 +37,18 @@ describe('dsh-better-sidebar plugin export shape', () => {
// The terminal shell config defaults to auto-resolution (empty shell =
// the platform chain in defaultShell()).
expect(resolved.shell).toBe('')
// Shell args default to an empty list; non-empty values replace the
// automatic platform login flag.
expect(resolved.shellArgs).toEqual([])
const configured = (schema as unknown as {
(input: Record<string, unknown> | undefined): Record<string, unknown>
})({ shell: 'pwsh.exe' })
expect(configured.shell).toBe('pwsh.exe')
const configuredWithArgs = (schema as unknown as {
(input: Record<string, unknown> | undefined): Record<string, unknown>
})({ shell: '/bin/zsh', shellArgs: ['--noprofile', '--no-rc'] })
expect(configuredWithArgs.shell).toBe('/bin/zsh')
expect(configuredWithArgs.shellArgs).toEqual(['--noprofile', '--no-rc'])
})
it('registers the side card preferences schema with the documented defaults', async () => {
+17 -1
View File
@@ -13,7 +13,7 @@ vi.mock('node:os', async (importOriginal) => {
})
import { resolveSidebarConfig } from '../src/config.ts'
import { defaultShell, ensureSpawnHelper } from '../src/pty-manager.ts'
import { defaultShell, ensureSpawnHelper, shellDisplayName, shellSpawnArgs } from '../src/pty-manager.ts'
describe('pty helpers', () => {
it('prefers an explicit shell, then SHELL, then the account login shell on POSIX', () => {
@@ -69,6 +69,22 @@ describe('pty helpers', () => {
it('trims the configured shell and defaults it to auto for old documents', () => {
expect(resolveSidebarConfig(undefined).shell).toBe('')
expect(resolveSidebarConfig({ shell: ' pwsh.exe ' }).shell).toBe('pwsh.exe')
expect(resolveSidebarConfig({ shell: '/bin/zsh', shellArgs: ['--noprofile'] }).shellArgs).toEqual(['--noprofile'])
expect(resolveSidebarConfig(undefined).shellArgs).toEqual([])
})
it('derives a short display name for terminal tab titles', () => {
expect(shellDisplayName('/bin/zsh')).toBe('zsh')
expect(shellDisplayName('/usr/bin/bash')).toBe('bash')
expect(shellDisplayName('C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe')).toBe('powershell')
expect(shellDisplayName('pwsh.exe')).toBe('pwsh')
expect(shellDisplayName('/weird')).toBe('weird')
expect(shellDisplayName('/')).toBe('/')
})
it('uses explicit shell args verbatim and keeps platform defaults when none are configured', () => {
expect(shellSpawnArgs(['--noprofile', '--no-rc'])).toEqual(['--noprofile', '--no-rc'])
expect(shellSpawnArgs([])).toEqual(process.platform === 'win32' ? [] : ['-l'])
})
it('restores the spawn-helper executable bit idempotently', () => {
+11
View File
@@ -578,6 +578,17 @@ describe('side card settings routes', () => {
expect((result.value as { externalDisable?: boolean }).externalDisable).toBe(true)
})
it('serves the effective terminal shell and its display name', async () => {
const route = mountWithSettings(undefined)
const result = await invoke(route, 'shell.get', {})
expect(result.ok).toBe(true)
expect(result.value).toMatchObject({
shell: expect.any(String),
name: expect.any(String),
})
expect(String((result.value as { name: unknown }).name).length).toBeGreaterThan(0)
})
it('reads the resolved prefs and writes a patch through the seam', async () => {
const route = mountWithSettings(createFakeSettings())
const read = await invoke(route, 'settings.get', {})
+29 -2
View File
@@ -204,7 +204,11 @@ function chunkBundle(name: string): UserConfig {
},
},
noExternal: (id: string) => (CLIENT_EXTERNALS.includes(id) ? undefined : true),
plugins: [purityGatePlugin(), makeCssPlugin('dsh-better-sidebar')],
plugins: [
purityGatePlugin(),
makeCssPlugin('dsh-better-sidebar'),
...(name === 'mermaid' ? [mermaidChunkAliases()] : []),
],
outputOptions: {
entryFileNames: `client-${name}.js`,
sourcemapPathTransform: browserSourcePath,
@@ -219,6 +223,29 @@ function chunkBundle(name: string): UserConfig {
/** A rolldown plugin as tsdown's config accepts it (contextual `this` for load/resolveId). */
type BuildPlugin = NonNullable<UserConfig['plugins']>
/**
* Mermaid-chunk-only alias: pin uuid's BROWSER entry. The mermaid core
* (mindmap definition) imports the bare `uuid` specifier, which rolldown
* resolves to uuid's node entry — its dist-node modules import
* `node:crypto` and trip the client purity gate. The browser entry
* (uuid/dist/index.js, Web Crypto based) carries no Node builtins, so alias
* the specifier there instead of special-casing the gate. Resolved relative
* to mermaid's own dependency tree (pnpm/npm layout agnostic).
*/
function mermaidChunkAliases(): BuildPlugin {
const uuidBrowserEntry = resolvePath(
dirname(require.resolve('uuid/package.json', { paths: [dirname(require.resolve('mermaid/package.json'))] })),
'dist/index.js',
)
return {
name: 'dsh-mermaid-uuid-browser-alias',
resolveId(source: string) {
if (source === 'uuid') return uuidBrowserEntry
return null
},
}
}
/** The shared client-bundle purity gate (see the clientBundle doc). */
function purityGatePlugin(): BuildPlugin {
return {
@@ -287,7 +314,7 @@ function makeCssPlugin(pluginId: string): BuildPlugin {
}
/** The lazy chunk names (keep in sync with src/bundle-route.ts CHUNK_NAMES). */
const CHUNKS = ['terminal', 'editor']
const CHUNKS = ['terminal', 'editor', 'mermaid']
export default [
{