Compare commits

...
6 Commits
Author SHA1 Message Date
Menghuan1918 956fcc005b docs: README 精简重排 — 浏览器/HTML 功能说明完善(沙箱、嵌入拒绝面板、外链拦截) 2026-08-11 19:28:51 +08:00
Menghuan1918 302c20109f feat: 内置 HTML 预览器 + 沙箱浏览器 tab(v0.5.0)
- html viewer:.html/.htm 沙箱 iframe 预览(相对资源经 /sidebar/html 路径编码路由加载,CSP sandbox + 信任围栏 + cwd 边界),预览/编辑切换
- browser tab:+ 菜单多开(browser:<n>),地址栏后退/前进/刷新/在浏览器中打开(15px 图标),URL 与标题按会话持久化(patchTab + nextBrowser)
- 安全:iframe 一律不透明源沙箱(无 allow-same-origin/top-navigation、no-referrer、权限策略全禁、禁 srcdoc);地址栏仅 http(s)、拒绝 localhost 等本机地址;界面实时沙箱状态(关闭红色警示)+ 临时解锁/恢复
- 设置:htmlViewerNoSandbox / htmlViewerDefaultUnsafe / browserNoSandbox / browserInterceptLinks 四个声明式二级设置(默认安全)
- 外链拦截:聊天/界面 http(s) 外链默认在侧边栏打开(openTab seed.url),Ctrl/Cmd 点击放行
- 嵌入拒绝面板:browser.probe 路由探测 X-Frame-Options/frame-ancestors,被拒站点显示原因 + 在浏览器中打开/仍然加载
- 设置页可滚动;内置 7 tab + 9 viewer;新增设计文档;268 个测试全绿
2026-08-11 19:28:48 +08:00
Menghuan1918 35cde82eb2 docs: 外部插件接入指南 — 基于 ctx.betterSidebar 注册新页面与文件预览器 2026-08-11 16:49:41 +08:00
Menghuan1918 67ec136e98 feat: 拦截聊天区文件打开 — 聊天里的文件点击默认开进侧边栏编辑器
包装 ctx.workspaces.openPath(聊天区文件打开的唯一漏斗),接管时
经 openSidebarFile 开进 sidebar 编辑器而非系统默认应用;由
interceptOpenPath pref(默认开)与 editor tab 启用态双重门控,
拒接时原样放行,dispose 还原原始方法引用(HMR 安全)。

- prefs-shared/config/prefs: 新增 interceptOpenPath 字段(默认 true)
- openpath-intercept.ts: 零依赖包装器,纯插件层面实现,不改 DSH
- intercept.tsx: registerOpenPathInterception 接线(当前会话 scope)
- SideCardSection: 常规区新增开关卡片(zh/en 文案)
- 测试: 包装器单测 6 个 + 接线集成测试 + pref 解析/设置页断言更新
2026-08-11 16:48:59 +08:00
Menghuan1918 8f3bb1b264 chore: release v0.4.3 — README 精简重排 + npm 启动模式实测兼容
- README 341 → 130 行:安装/更新合并为一份提示词 + 手动要点,功能一览
  逐节压缩,registry 通道降为可选小节,开发/架构/限制精简;新增
  「npm 包启动(npx @deepseek-ai/dsh)同样可用,v0.4.3 起实测验证」
- 版本 0.4.1 → 0.4.3;cordis.patch.yml 注释同步
2026-08-11 15:47:16 +08:00
Menghuan1918 80f4406ff2 chore: devDependencies 去硬编码 — @deepseek-ai/* 改从 npm 解析(^0.0.1-rc.1)
- package.json:14 条 link:/Users/menghuan/.dsh/source/current/... 绝对路径
  改为 ^0.0.1-rc.1(npm registry 预发布版,官方已发布 0.0.1-rc.1);
  peerDependencies 同步 ^0.0.1-rc.1 以匹配 rc 系列
- 安装需安装者自己的只读令牌(~/.npmrc 配置 //registry.npmjs.org/:_authToken,
  不写进仓库);或改回指向自己 ~/.dsh/source/current 的 link: —— README 三处说明更新
- .gitignore 加 .npmrc 防令牌误提交;token 全程仅环境变量/临时文件使用,已删除
- vitest.config.ts:npm 版 ui-primitives 的 lib 顶层 import katex css,
  node_modules 外部化会被 Node 原生加载撞上 .css —— inline 该包走 Vite 转换 stub
- pnpm-lock.yaml 由 link: 仓库改为 registry tarball(+2001 行);
  pnpm-workspace.yaml 追加 minimumReleaseAgeExclude(pnpm 11 自动记账)
2026-08-11 14:46:56 +08:00
48 changed files with 5179 additions and 410 deletions
+1
View File
@@ -145,3 +145,4 @@ lib/
# plugin-registry install staging (assembled by scripts/package-registry.mjs)
registry/
.npmrc
+9 -6
View File
@@ -124,7 +124,7 @@ interface TabDescriptor {
*/
settings?: {
toggles?: readonly {
/** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools') */
/** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools' / 'htmlViewerNoSandbox' / 'htmlViewerDefaultUnsafe' / 'browserNoSandbox' / 'browserInterceptLinks') */
key: string
title: string | (() => string)
desc?: string | (() => string)
@@ -210,6 +210,7 @@ ctx.effect(() =>
| `git` | 20 | 是 | 否 | Git 面板 |
| `subagent` | 30 | 是 | 否 | 子代理拓扑 |
| `terminal` | 40 | 否 | 否 | 终端(nextTerminal 自增) |
| `browser` | 50 | 否(createTab 铸造 browser:`<n>`,nextBrowser 自增) | 否 | 内嵌网页浏览器(沙箱 iframe;可设置关闭沙箱) |
| `diff` | -1 | 否(按 id 去重) | 是 | 差异查看(由 GitView 触发) |
你的 `id` 不可与上述重复,否则 `registerTab` 抛 `"tab type \"X\" already registered"`。
@@ -283,7 +284,7 @@ interface FileViewerProps {
> **head 字节从哪来**:第一次匹配(纯扩展名)没有 head。`fsRead` 策略读取后若文件为二进制,host 的 `fs.read` 响应会带 `head` 字段(base64,前 4KB),编辑器会用它对 `detect` viewer **重匹配一次**——所以 detect 型 viewer 的实际触发场景是"扩展名匹配落空/二进制文件"。文本文件的 detect 嗅探不在内置流程内(用 `exts` 或 `custom` 策略替代)。
> **内置 viewer**(不可重复注册,全部 8 个):image(0) / pdf(0) / docx(0) / xlsx(0) / pptx(0) / markdown(0, fsRead) / code(-100, catch-all, fsRead) / binary-download(-50, exts doc/xls/ppt + NUL detect)。
> **内置 viewer**(不可重复注册,全部 9 个):image(0) / pdf(0) / docx(0) / xlsx(0) / pptx(0) / markdown(0, fsRead) / html(0, fsRead, 沙箱 iframe 预览) / code(-100, catch-all, fsRead) / binary-download(-50, exts doc/xls/ppt + NUL detect)。
> code 是兜底 viewer:任何其他 viewer 未认领的文件都会落到 code(CodeMirror 文本编辑);二进制文件经 head 重匹配被 binary-download 的 NUL detect 认领(下载按钮)。外部 viewer 注册同扩展名 + 更高 priority 即可覆盖。
### 4.5 注册示例
@@ -360,9 +361,11 @@ interface BetterSidebarService {
* 打开一个 tab(+ 菜单和外部触发都用它;走 descriptor.dedupeKey 去重)。
* title 可选:给出时优先于 descriptor.title(editor 显示文件名);
* 有 createTab 的 descriptor(terminal)会忽略 title/path/id。
* url 可选:落地后把 tab 的 path 预填为 URL(侧边栏浏览器导航种子,
* 通常配合 hostname title;对 createTab 铸造的 tab 同样生效)。
* 被设置禁用的类型是 no-op(console.warn 提示)。
*/
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string; url?: string }): void
/** 关闭一个 tab */
closeTab(tabId: string): void
/** 订阅注册表变化(register/dispose 时触发) */
@@ -370,7 +373,7 @@ interface BetterSidebarService {
}
```
> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,勾选徽标钉在卡片最右端,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`)通过卡片右下角的齿轮按钮在**原生弹窗**中编辑(复选框行),父级卡片关闭时齿轮隐藏;**key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools`),外部插件的自定义键会被 settings seam 丢弃。
> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,勾选徽标钉在卡片最右端,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`)通过卡片右下角的齿轮按钮在**原生弹窗**中编辑(复选框行),父级卡片关闭时齿轮隐藏;**key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks`),外部插件的自定义键会被 settings seam 丢弃。
---
@@ -476,11 +479,11 @@ function parseCsv(text: string): string[][] { /* ... */ }
better-sidebar 自己的内置 tab 和 viewer 就是参考实现("吃狗粮"):
- **`src/client/builtins/`**:6 个内置 tab(explorer/git/subagent/terminal/editor/diff)+ 8 个内置 viewer(image/pdf/docx/xlsx/pptx/markdown/code/binary-download)的注册代码(tabs.tsx / viewers.tsx / index.ts)
- **`src/client/builtins/`**:7 个内置 tab(explorer/git/subagent/terminal/browser/editor/diff)+ 9 个内置 viewer(image/pdf/docx/xlsx/pptx/markdown/html/code/binary-download)的注册代码(tabs.tsx / viewers.tsx / index.ts)
- **`src/client/service.ts`**:`BetterSidebarService` 接口 + `createBetterSidebarService` 工厂实现
- **`src/client/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + `settings.toggles` 嵌套开关 + 开关持久化)
- **`tests/service.spec.ts`**:注册表生命周期 / 匹配算法 / dedupe / createTab / 启用态 gating 测试
- **`tests/builtins.spec.ts`**:内置注册清单断言(6 tab + 8 viewer + 声明式元数据)
- **`tests/builtins.spec.ts`**:内置注册清单断言(7 tab + 9 viewer + 声明式元数据)
- **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`**:设计文档(含实施偏差记录)
调试时直接读这些文件即可看到所有 API 的真实用法。
+72 -288
View File
@@ -1,13 +1,36 @@
# dsh-better-sidebar
> DeepSeek Harness Web GUI 的 **VSCode 风格右侧侧边栏**插件 —— 文件管理、编辑预览、真实终端、Git 面板、Subagent 页面,一个面板全部搞定。
> DeepSeek Harness Web GUI 的 **VSCode 风格右侧侧边栏**插件:文件管理、编辑预览、内嵌浏览器、真实终端、Git 面板、Subagent 页面,一个面板全部搞定。
<img width="4632" height="2720" alt="image" src="https://github.com/user-attachments/assets/39d86636-7654-412f-86ea-c60a2d5f20f0" />
<img width="1000" height="1186" alt="image" src="https://github.com/user-attachments/assets/9dadffe0-0738-4b6d-b929-f452f51768a2" />
## 🚀 官方 profile 安装(推荐,无需 plugin-registry)
## ✨ 功能一览
把下面提示词**整段**发给 DSH,它会自动完成克隆、构建、注册与安装(前置条件:已安装 DSH 且 `dsh web` 可运行,Node.js ≥ 20、pnpm ≥ 10):
- **🗂️ 资源管理器**:懒加载目录树(根 = 会话 cwd)、点击在侧边栏打开、行尾 `@文件` 引用到输入框、右键复制路径
- **📝 编辑与预览**:CodeMirror 6 多语言高亮 + Ctrl/Cmd+S 原子保存;图片 / Markdown(预览/编辑切换)/ HTML(沙箱 iframe 预览,相对资源可加载)/ PDF / Word / Excel / PPT 内联预览,切换 Tab 不丢草稿
- **🌐 浏览器**:内嵌网页浏览 tab(多开),后退/前进/刷新 +「在浏览器中打开」;页面在**沙箱 iframe** 中运行(不透明源:无法访问界面数据与本地文件,拒绝 localhost 等本机地址),界面实时显示沙箱状态、可临时解锁(关闭时红色警示);被站点拒绝嵌入(X-Frame-Options)时显示原因面板;聊天/界面里的 http(s) 外链默认在侧边栏打开
- **💻 终端**:xterm.js + node-pty 真实 shell(每会话 3 个 UI 上限)、Tab 保活重连回放;可选为模型注入 8 个 `terminal_*` 工具
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、懒加载历史、右键暂存/放弃/提交/还原/捡取
- **🧩 Subagent 页面**:主会话完整 agent 拓扑、点击直达执行记录、实时工具调用轮询、新子代理自动展开
- **🔧 分栏工作台**:拖 Tab 拆分/合并分栏、分隔线调比例、宽度拖拽、折叠 + 全屏
- **🔁 会话隔离**:布局/分栏/Tab 按会话持久化(localStorage),陈旧状态自动净化;聊天「产出文件」改在侧边栏打开
- **⚙️ 声明式设置**:设置页「侧边卡片」分区按注册表渲染功能清单(小卡片网格,高亮 = 启用),每项可独立开/关;二级设置(子代理自动展开、终端工具、沙箱开关等)经齿轮按钮在原生弹窗中编辑
- **🔌 服务化**:暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏 tab 与文件预览器(内置 7 tab + 9 viewer 也走同一服务,详见 [AGENTS.md](./AGENTS.md))
## ⌨️ 快捷键
| 操作 | 按键 |
|---|---|
| 保存编辑 | `Ctrl/Cmd + S` |
| Git 提交 | `Ctrl + Enter` |
| 关闭 Tab | 鼠标中键 |
| 拆分/合并分栏 | 拖 Tab 到分栏边缘 / 中间 |
| 引用文件到输入框 | 悬浮行尾 `@文件` 按钮 |
| 复制文件路径 | 右键行 → 复制相对/绝对地址 |
## 🚀 安装
前置:已安装 DSH(`dsh web` 可运行),Node.js ≥ 20、pnpm ≥ 10。把下面提示词**整段**发给 DSH 即可自动完成克隆、构建、注册与安装:
```text
请帮我把 dsh-better-sidebar 插件安装到我的 web profile(插件 = VSCode 风格右侧侧边栏,仓库 https://github.com/dsh-external/DSH-better-sidebar):
@@ -15,324 +38,85 @@
1. 克隆并构建:
git clone https://github.com/dsh-external/DSH-better-sidebar.git ~/Code/DSH-better-sidebar
cd ~/Code/DSH-better-sidebar && pnpm install && pnpm build
(若 pnpm install 因 @deepseek-ai/* 的 link: 依赖解析失败,说明 DSH 源码 checkout 不在 ~/.dsh/source/current —— 停下来告诉我,不要继续)
(@deepseek-ai/* 从 npm 解析(0.0.1-rc.? 预发布版);若 401/404,用安装者自己的只读令牌配置 ~/.npmrc,或把 devDependencies 改回指向自己 ~/.dsh/source/current 的 link:,不要继续)
2. 注册到 web profile:
a. 编辑 ~/.dsh/profiles/web/package.json,在 dependencies 中加入 "dsh-better-sidebar": "link:<第 1 步克隆目录的绝对路径>"
b. 编辑 ~/.dsh/profiles/web/cordis.patch.yml,追加挂载行:
a. ~/.dsh/profiles/web/package.json 的 dependencies 加 "dsh-better-sidebar": "link:<第 1 步克隆目录的绝对路径>"
b. ~/.dsh/profiles/web/cordis.patch.yml 追加:
- insert:
- id: better-sidebar
name: 'dsh-better-sidebar'
3. 在 ~/.dsh/profiles/web 目录执行 pnpm install
4. 全部完成后告诉我,我重启 DSH 并硬刷新(Cmd/Ctrl+Shift+R)验证
3. 在 ~/.dsh/profiles/web 执行 pnpm install
4. 重启 DSH 并硬刷新(Cmd/Ctrl+Shift+R)验证
```
> 安装 = 把插件登记进 web profile 的依赖清单(等价于 `dsh plugin --profile web add link:<路径>`)+ 一行 cordis 挂载行,**与 portal 无关**——portal 只指侧边栏面板在页面上的渲染方式(见下文[规范符合性](#规范符合性))。
### 更新(同样把提示词发给 DSH)
已安装过(`link:` 引用)时,把下面提示词**整段**发给 DSH 即可更新到最新版:
```text
请帮我更新 dsh-better-sidebar 插件(仓库在 ~/Code/DSH-better-sidebar,已通过 link: 安装到我的 web profile):
1. 拉取最新代码并重新构建:
cd ~/Code/DSH-better-sidebar && git pull && pnpm install && pnpm build
(若 pnpm install 因 @deepseek-ai/* 的 link: 依赖解析失败,说明 DSH 源码 checkout 不在 ~/.dsh/source/current —— 停下来告诉我,不要继续)
2. 核对注册仍然有效(缺失才需要补):
a. ~/.dsh/profiles/web/package.json 的 dependencies 中含 "dsh-better-sidebar": "link:<仓库目录的绝对路径>"
b. ~/.dsh/profiles/web/cordis.patch.yml 中含挂载行(id: better-sidebar, name: 'dsh-better-sidebar')
c. 若有缺失,补齐后在该 profile 目录执行 pnpm install
3. 完成后告诉我本次改动涉及 client 还是 host:
- 仅 client(src/client/*)→ 我硬刷新(Cmd/Ctrl+Shift+R)即可
- 含 host(src/index.ts、src/config.ts 等)→ 我需要重启 DSH 再硬刷新
```
## 🚀 通过 plugin-registry 安装(可选)
如果已经集成了 [plugin-registry](https://github.com/dsh-external/plugin-registry)(`dsh registry` 命令可用;集成步骤见其 README),也可以通过 registry 通道安装:
```sh
git clone https://github.com/dsh-external/DSH-better-sidebar.git
cd DSH-better-sidebar
pnpm install && pnpm build # 产物: lib/index.js + lib/invariant.js (host) + lib/client.js + lib/client-registry.js (client) + lib/types
node scripts/package-registry.mjs # 组装 registry/ 暂存目录(只含清单 + 构建产物 + README,不入库)
dsh registry install ./registry # 安装(默认禁用——信任边界)
dsh registry enable dsh-external/dsh-better-sidebar # 启用
```
> **通道互斥**:registry 安装与官方 profile 安装是同一插件的两种安装方式,**每个部署二选一**——同时启用会双挂载(Node 半挂两次、页面渲染两个侧边栏)。已用官方方式装过时,先移除 `~/.dsh/profiles/web/cordis.patch.yml` 里的 better-sidebar 挂载行与 `package.json` 的 `link:` 依赖,再走 registry。
### 更新(registry 通道)
```sh
cd DSH-better-sidebar && git pull && pnpm install && pnpm build
node scripts/package-registry.mjs
dsh registry uninstall dsh-external/dsh-better-sidebar
dsh registry install ./registry
dsh registry enable dsh-external/dsh-better-sidebar
```
## 为什么用它
- **⚡ 工作流不断档**:编辑文件、跑终端、看 Git 状态、拖 Tab 分栏对比,全在对话旁边完成,无需切窗口
- **🧠 按会话记忆与隔离**:每个 conversation 拥有独立的布局、分栏、Tab 与目录展开状态,切换会话即切换整套工作台
- **🎨 与 GUI 浑然一体**:完全复用 DSH 主题变量与官方组件,明暗主题自适应,观感就是原生的
---
## ✨ 功能一览
### 🗂️ 文件资源管理器
- 懒加载目录树,根目录 = 当前会话工作目录(cwd);目录优先、隐藏文件变暗、每层 1000 项上限
- 点击文件在侧边栏打开编辑器;刷新按钮重载
- **悬浮行尾 `@文件` 按钮**:一键把 `@相对路径` 追加到输入框草稿(如 `@src/main.ts`),引用文件不再手打路径
- **右键行 → 复制相对地址 / 复制绝对地址**,复制成功行内短暂显示"已复制"
### 📝 文件编辑与预览
- **CodeMirror 6**:自动换行 + 按扩展名语法高亮(js/ts/jsx/tsx、json、python、html、css、xml、yaml、sql、java、c/c++、rust、go、php、shell、toml、nginx、dockerfile、properties…)
- 脏点标记 + **Ctrl/Cmd+S 保存**(原子写入);512KB 截断横幅、二进制文件提示
- 图片直接预览;**Markdown 预览/编辑切换**,预览实时渲染未保存草稿
- **PDF 原生预览**:使用浏览器内置 PDF 查看器,预览区始终保留下载入口
- **Office 三件套预览**:
- `.docx` — 保真渲染(保留样式 / 页眉页脚 / 图片 / 表格),基于 [docx-preview](https://github.com/VolodymyrBaydalka/docxjs)
- `.xlsx` — 完整电子表格(多 sheet / 公式计算 / 列宽行高 / 合并单元格),基于 [Univer](https://univer.ai) + [SheetJS](https://sheetjs.com)
- `.pptx` — 高保真幻灯片预览(图片 / 表格 / 图表 / SmartArt)+ 上一页 / 下一页导航,基于 [pptx-renderer](https://github.com/aiden0z/pptx-renderer)
- `.doc` / `.xls` / `.ppt` 及其他二进制 — 下载按钮(旧 OLE 格式纯前端无成熟渲染库)
- 切换 Tab 不卸载:编辑器保留草稿与撤销栈
### 💻 终端
- xterm.js + node-pty **真实 shell**,每会话最多 3 个(仅 UI 手动新建的 tab 计入上限)
- **Tab 打开期间一直保活**:切换 Tab/分栏、切换会话(30 秒内返回)、刷新页面都重连到同一个 shell 进程并回放转录
- 真正关闭 Tab 立即释放配额;连续失败停止重连并显示原因与重试按钮
- **Agent 终端(默认关闭)**:设置页开启「为模型注入终端工具」后,模型可通过 `terminal_create` / `list` / `send` / `read` / `wait_for` / `resize` / `signal` / `close` 8 个工具创建持久终端,自动同步为侧边栏 tab(id `agent:<uuid>`,不占用 3 个 UI 上限)。终端由 agent 拥有——`terminal_close` 或用户关闭 tab 才销毁,WS 断开(刷新/切会话)不杀进程;`terminal_signal(SIGINT)` 通过写入 `\x03` 控制字符实现,Windows ConPTY 与 POSIX 通用
### 🌿 Git 面板
- **真正的 diff 显示**:解析 `git diff` 统一格式——`@@` hunk 头、旧/新双行号对齐、上下文/删除/新增行红绿着色(DSH 语义色 token),长行自动换行、无横向滚动;二进制文件显示提示、超长 diff 折叠展开、untracked 文件渲染为全新增视图
- **点击即开 diff tab(VSCode 式)**:点击变更文件行或历史提交行,自动打开独立 diff tab——首次打开时当前栏纵向一分为二、diff 默认落在**下半栏**,之后新 diff 堆叠进同一栏;同一变更(路径+暂存侧 / 提交)只存在一个实例,重复点击聚焦已有 tab;diff tab 为临时页签(刷新后不恢复,同 VSCode diff 编辑器),带手动刷新按钮
- **VSCode 式历史**:提交行 = 短哈希 + 主题,第二行 = 分支/标签徽章 + 作者 · 相对时间;**懒加载分页**(每页 20 条,底部「加载更多」,避免长历史挤爆面板)
- **右键高级操作**:
- 文件行:在编辑器中打开 / 暂存 / 取消暂存 / 放弃更改(确认弹窗)/ 复制相对·绝对路径
- 历史行:查看提交差异 / 复制短哈希 / 复制完整哈希 / 复制提交信息 / 还原此提交(revert)/ 捡取此提交(cherry-pick),破坏性操作均有确认弹窗
- 暂存/未暂存按 git XY 状态精确分组(`MM` 双区显示)、stage/unstage(单个与全部)、commit(Ctrl+Enter)、分支切换
### 🧩 Subagent 页面
- **主会话 agent 拓扑(分层显示)**:`+` 菜单(或空分栏卡片)打开 Subagent 标签页,显示**主会话(主代理)的完整 agent 拓扑**——主代理作为根节点卡片置顶(**点击它直接回到主代理会话**),其**所有层级的子代理**(含子代理再派生的后代)从它下面按层级展开,树连接线 + 逐级缩进区分层级;无论当前选中/跳转到多深的子代理,所有子代理都共享这一个主会话拓扑视图
- **点击直达执行记录**:点击子代理节点直接跳转到该子代理执行记录(官方 `openSubagent`);**跳转后 Subagent 页面保持打开**,主会话拓扑不变,当前所在节点**高亮**(`interactive-bg-active` 选中填充,同官方会话行)
- **运行中实时状态**:**仅运行中的节点**显示最后 Text 输出 + 最后工具调用(工具名 + 参数摘要,等宽字体),页面可见时每 3 秒自动刷新;暂无输出时显示「思考中…」;空闲节点保持安静;页面不可见(切标签/折叠面板)时暂停轮询
- **自动激活**:设置项「检测到子代理时自动展开子代理页面」默认开启——当前会话**新增**第一个子代理时,侧边栏自动展开并聚焦 Subagent 页面;关闭后需手动打开(切换会话/刷新已有子代理不会打扰)
### 🧩 VSCode 式分栏工作台
- **拖 Tab 到分栏边缘**(左/右/上/下)即拆分新分栏,拖到中间合并,拖到另一个 Tab 上插入其前
- 分隔线拖拽调整比例;面板宽度从左侧缘拖拽;折叠滑出隐藏 + 全屏展开
### 📑 Tab 栏
- **Tab 与 `+` 菜单均带类型图标**(文件夹 / 分支 / 终端 / 代码文件)
- `+` 菜单新建 Explorer / Git / Subagent / 终端(终端受 3 个上限约束);**中键点击关闭 Tab**
- 空分栏显示可打开类型卡片,点击即开
### 🔁 会话隔离与状态自愈
- 布局/分栏/Tab/目录展开按会话持久化(localStorage),切换会话即切换整套状态
- **陈旧状态自动净化**:结构校验、宽度钳制视口、损坏的重复 id 自动重新编号——刷新后分栏与新生成的 id 永不冲突,不会出现"文件开进两个分栏"
- 侧边栏**占用布局**(挤窄对话列而非悬浮遮挡,动画过渡)
- 拦截对话"产出文件"行:点击改为在侧边栏打开,而不是系统默认程序
### 🎨 界面风格
- 完全对齐 DSH 原生 chrome:**无阴影**的扁平面板(`--dsw-specific-sidebar-fill` 表面 + hairline 边框)、原生 rail 圆形图标按钮、`interactive-bg-active` 选中填充、34px/14px 原生行规格
- 键盘焦点可见(focus-visible 环)、`prefers-reduced-motion` 下关闭全部动画
### ⚙️ Side card 设置(DSH 设置页)
- 设置页新增 **Side card(侧边卡片)** 分区,持久化到 DSH 用户设置文档:
- **新会话默认打开**:新建会话是否自动展开侧边卡片(已存在的会话保持各自布局)
- **默认宽度占比**:新建会话时侧边卡片占窗口宽度的百分比(20–60,默认 30)
- **声明式的功能清单**:分区按注册表实时渲染当前支持的侧边栏内容与文件预览器(内置与外部插件注册的 tab/viewer 一视同仁),每项是一张**小卡片**,在响应式网格中**一行自适应排多个**——图标 + 类型 id + 描述,**点击整卡切换开/关,卡片高亮(品牌描边 + 勾选徽标钉在卡片最右端)即代表启用**,未启用呈灰色降级态:
- **侧边栏内容**:资源管理器 / 源代码管理 / 子代理 / 终端 / 编辑器 / 差异 等 tab 类型。关闭某类型后:`+` 菜单不再显示、无法新开(已打开的 tab 保留可继续使用,关闭后需重新开启才能再开)
- **文件预览**:图片 / PDF / Word / Excel / PPT / Markdown / 代码 / 二进制下载 等查看器,卡片上显示其覆盖的扩展名。关闭某查看器后文件预览落到下一个匹配的查看器(关闭兜底的代码查看器后无法预览的文件显示下载按钮)
- **二级设置弹窗**:声明了相关设置的卡片(子代理、终端)右下角有齿轮按钮,点击打开原生弹窗(Modal),内为 DSH 原生设置行——「检测到子代理时自动展开子代理页面」「为模型注入终端工具」等以复选框行呈现;父级卡片关闭时齿轮隐藏(相关设置随功能休眠)
- **检测到子代理时自动展开子代理页面**(默认开启):当前会话产生新的子代理时,自动展开侧边栏并打开 Subagent 页面
- **为模型注入终端工具**(默认关闭):开启后模型可通过 8 个 `terminal_*` 工具创建并操作侧边栏终端;关闭会注销工具并释放已创建的 agent 终端
## ⌨️ 快捷键速查
| 操作 | 按键 |
|---|---|
| 保存编辑 | `Ctrl/Cmd + S` |
| Git 提交 | `Ctrl + Enter` |
| 关闭 Tab | 鼠标中键 |
| 拆分分栏 | 拖 Tab 到分栏边缘 |
| 合并分栏 | 拖 Tab 到分栏中间 |
| 引用文件到输入框 | 悬浮行尾 `@文件` 按钮 |
| 复制文件路径 | 右键行 → 复制相对/绝对地址 |
---
## 🧰 手动安装与更新
不想用提示词时,也可以手动完成(等价于上面提示词的步骤):
### 前置条件
- 已安装 DSH(`dsh web` 可运行),Node.js ≥ 20、pnpm ≥ 10
- **构建/类型检查**需要 DSH 源码 checkout 位于 `~/.dsh/source/current`(`devDependencies` 中的 `@deepseek-ai/*` 以 `link:` 指向它;若 checkout 在其他路径,修改 `package.json` 中对应的 `link:` 路径)。**运行期不依赖该 checkout**——`@deepseek-ai/*` 声明在 `peerDependencies`,由 web profile 提供
### 1. 克隆、安装、构建
```sh
git clone https://github.com/dsh-external/DSH-better-sidebar.git
cd DSH-better-sidebar
pnpm install
pnpm build # 产物: lib/index.js + lib/invariant.js (host) + lib/client.js + lib/client-registry.js (client) + lib/types/*.d.ts
```
> 运行期依赖(cordis、react、`@deepseek-ai/dsh-client-*` 等)按官方插件清单规范声明在 `peerDependencies`——web profile 已内置全部 peer 依赖,安装本插件无需额外装包;`dependencies` 只保留插件自有的运行时依赖(node-pty、ws、xterm、CodeMirror、schemastery 等)。
### 2. 注册到 web profile
```sh
dsh plugin --profile web add link:/绝对路径/DSH-better-sidebar
```
(等价于手动把 `"dsh-better-sidebar": "link:<绝对路径>"` 写入 `~/.dsh/profiles/web/package.json` 的 `dependencies` 并 `pnpm install`。)
然后编辑 `~/.dsh/profiles/web/cordis.patch.yml`,追加插件挂载行(`dsh plugin` 只管理依赖清单,不写挂载行):
```yaml
- insert:
- id: better-sidebar
name: 'dsh-better-sidebar'
```
### 3. 重启 DSH 并刷新页面
重启 DSH(你的 `dsh web` 启动方式),浏览器**硬刷新**(Cmd/Ctrl+Shift+R)后,右侧出现侧边栏即安装成功。
> 安装 = 依赖登记(等价 `dsh plugin --profile web add link:<路径>`)+ 一行挂载行。**DSH 以 npm 包启动(如 `npx -p @deepseek-ai/dsh@0.0.1-rc.1 dsh web`)同样可用**(v0.4.3 起实测验证)。
### 更新
不想用提示词时手动更新(等价于[更新提示词](#更新同样把提示词发给-dsh)):
```sh
git pull && pnpm install && pnpm build # 在插件仓库目录执行
```text
1. cd ~/Code/DSH-better-sidebar && git pull && pnpm install && pnpm build(401/404 处理同上)
2. 核对注册仍有效(缺失才补):profile package.json 的 link: 依赖 + cordis.patch.yml 挂载行
3. 仅 client(src/client/*)→ 硬刷新即可;含 host(src/index.ts、src/config.ts 等)→ 重启 DSH + 硬刷新
```
- 只改了 **client** 代码(`src/client/*`)→ 硬刷新页面即可(bundle 由服务器按请求读取)
- 改了 **host** 代码(`src/index.ts`、`src/config.ts` 等)→ 重启 DSH + 硬刷新
### 通过 plugin-registry 安装(可选,与上述二选一)
`link:` 引用无需重新 install。
### 通过 registry 安装(手动,与上面的官方方案二选一)
前置条件:`dsh registry` 命令可用(DSH 已集成 plugin-registry)。安装源是 `scripts/package-registry.mjs` 组装的 `registry/` 暂存目录——只含清单 + 构建产物 + README,避免把 `node_modules/`(本机 `link:` 符号链接、node-pty 二进制)与 `.git/` 一并装进 `<dshHome>/plugins`。
前置:DSH 已集成 [plugin-registry](https://github.com/dsh-external/plugin-registry)(`dsh registry` 可用)。**同时启用两个通道会双挂载**(Node 半挂两次、页面两个侧边栏)。
```sh
pnpm build # 先构建(产物见上)
node scripts/package-registry.mjs # 组装 registry/(重新运行会整体重建)
git clone https://github.com/dsh-external/DSH-better-sidebar.git && cd DSH-better-sidebar
pnpm install && pnpm build
node scripts/package-registry.mjs # 组装 registry/ 暂存(含清单 + 产物 + README,不入库)
dsh registry install ./registry # 安装(默认禁用)
dsh registry enable dsh-external/dsh-better-sidebar
```
更新:
更新:`git pull && pnpm install && pnpm build` → `node scripts/package-registry.mjs` → `dsh registry uninstall/install/enable`。切换通道前先移除另一通道的挂载。
```sh
git pull && pnpm install && pnpm build
node scripts/package-registry.mjs
dsh registry uninstall dsh-external/dsh-better-sidebar
dsh registry install ./registry
dsh registry enable dsh-external/dsh-better-sidebar
```
## 🔌 服务化:注册 tab 与文件预览器
- 只改了 **client** 代码 → 硬刷新页面即可(registry 通道的 bundle 由服务器按请求读取)
- 改了 **host** 代码 → 重启 DSH + 硬刷新
- 与官方方案的互斥见上文「通道互斥」说明
从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 7 tab + 9 viewer 也走同一服务,吃自己的狗粮):
## 🛠️ 开发
```sh
pnpm typecheck # tsc --noEmit
pnpm build # rm -rf lib && tsc(声明) + tsdown → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types
pnpm test # vitest(单元 + 冒烟 + 插件形态 guard:真实 git/fs/node-pty 交互;manifest 一致性测试读取 lib/,需先 build)
pnpm watch # tsdown --watch(client bundle 热重建)
```
### 规范符合性
插件按 DSH 官方插件规范组织(参考 [dsh-external/turtle-ui](https://github.com/dsh-external/turtle-ui) 与 mainline `packages/client/AGENTS.md`),并额外提供 plugin-registry 标准发布面(`dsh.plugin.json`)。**安装通道与渲染方式是两个独立概念**:安装走 profile 清单协议(`dsh plugin` / cordis.yml 行,见[官方 profile 安装](#官方-profile-安装推荐无需-plugin-registry))**或** registry 通道(`dsh registry`,见[通过 plugin-registry 安装](#通过-plugin-registry-安装可选)),二者互斥;下面的 portal 条目只描述面板在页面上的渲染方式。
- **插件形态**:`export const name / inject / Config / apply`,无 default 导出;`tests/plugin-shape.spec.ts` 通过 `Loader.unwrapExports` 守卫该形态
- **清单**:`types` + `exports`(`.` / `./invariant` / `./client` / `./src/*` / `./package.json`)、`dsh.client`(`platform: 'web'` + `inject` 边)、peerDependencies、`engines`、`files` 产物明细、`prepare`(消费者侧 `tsdown`,git 安装可用)
- **client 契约**:仅导出 `apply`/`inject`(+ 类型);store 为 `createSidebarStore()` 工厂,实例归 `apply` 所有;`src/invariant.ts` 伴生;client bundle 复刻官方 preset(externals = 平台模块表 + runtime/client 豁免、纯度门、CSS Modules 内联)
- **registry 形态(标准发布面)**:仓库根 `dsh.plugin.json`(原生清单:id `dsh-external/dsh-better-sidebar`、`main` = host 构建产物、`client.main` = registry client bundle、`contributes` 空声明);`tests/manifest-consistency.spec.ts` 守卫清单与构建产物一致性(id 严格两段、version 与 package.json 同步、bundle id = 清单 id)
- **双 client bundle**:同一 `src/client/index.tsx` 构建出 `lib/client.js`(官方通道,id = 包名 `dsh-better-sidebar`)与 `lib/client-registry.js`(registry 通道,id = 清单 id)——两通道的 bundle-id 契约互不相同,各产出一份、同源不漂移;每个部署二选一(见安装章节「通道互斥」)
- **已知偏差**:侧边栏面板经 portal(`document.body` + `createRoot`)挂载而非 slot——官方 slot 系统的 `'root'` 由 ui-layout 独占声明,重复声明 fail loud,外部插件无整面板 slot 可用;与 shell 的集成点(`conversation.chat.turnTail`)走官方 chain-slot 机制
## 🏗️ 架构
一个 npm 包,host/client 双半结构(与 DSH monorepo 内 client 包同构):
| 半 | 入口 | 职责 |
|---|---|---|
| host | `src/index.ts` → `lib/index.js` | cordis 插件(`Config` 为 schemastery schema,可调 readLimit/mediaLimit/listLimit/terminalsPerSession/reconnectGraceMs):`/sidebar/api/*` JSON API、`/sidebar/file` 媒体路由、`/sidebar/ws/terminal` WebSocket;fs / git / pty 服务 |
| invariant | `src/invariant.ts` → `lib/invariant.js` | 包属 invariant 伴生(注册包名,无运行时断言) |
| client(官方通道) | `src/client/index.tsx` → `lib/client.js` | 浏览器 bundle(`__ModuleLoader__.load` 闭包工厂,id = 包名):portal 侧边栏 + 各视图 + turnTail 拦截 |
| client(registry 通道) | `src/client/index.tsx` → `lib/client-registry.js` | 同一源码的 registry 版本(id = 清单 id `dsh-external/dsh-better-sidebar`,`dsh.plugin.json` 的 `client.main` 指向它) |
| manifest | `dsh.plugin.json`(仓库根;`scripts/package-registry.mjs` 组装 `registry/` 安装根) | registry 发布面:id / version / main / client.main;仅 registry 通道读取 |
- 所有 API 携带 `sessionId`;cwd 权威值取自会话 header,会话未附加(页面加载竞态)时回退客户端摘要 cwd,再回退进程 cwd;终端按 `${sessionId}:${tabId}` 键控,重连时若权威 cwd 变化则重启 shell 到正确目录
- 路由与 `/api` 同款信任围栏(Host 头 loopback / `connection.trustedHosts`,`src/trust-fence.ts`,拷贝自 `@deepseek-ai/dsh-client-connection`,BSD-3-Clause)
- client 状态按会话持久化到 `localStorage`(`dsh-sidebar:v1:<sessionId>`),读取时结构校验 + 宽度钳制视口 + 重复 id 重新编号
- client bundle externals 仅模块表词条(react/cordis/ui-primitives 等),xterm/CodeMirror 等全部内联
## 🔌 服务化(v0.4.0+):让其他插件注册 tab 和文件预览器
better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务(Cordis context 属性),其他插件可通过它注册:
- **侧边栏页面(tab)**:`ctx.betterSidebar.registerTab({ id, title, icon, component, ... })`
- **文件类型预览器**:`ctx.betterSidebar.registerFileViewer({ id, exts, fetchStrategy, component, ... })`
两个方法都返回 disposer,用 `ctx.effect(() => register(...))` 包裹即可让 Cordis fiber 卸载时自动撤销(HMR-safe)。内置 6 个 tab(explorer/git/subagent/terminal/editor/diff)和 8 个 viewer(image/pdf/docx/xlsx/pptx/markdown/code/binary-download)也通过同一服务注册("吃狗粮"),外部插件与内置一视同仁。预览路由、+ 菜单、tab 图标全部由注册表驱动,无硬编码分发。
**消费插件最小骨架**:
```ts
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
export const inject = ['betterSidebar']
export function apply(ctx: Context) {
ctx.effect(() => ctx.betterSidebar.registerTab({
id: 'my-plugin:db',
title: 'Database',
component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
id: 'my-plugin:db', title: 'Database', component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
}))
}
```
**完整接入文档**:见 [`AGENTS.md`](./AGENTS.md) ——含 `TabDescriptor` / `FileViewerDescriptor` 全字段、`fetchStrategy` 对照、匹配算法、HMR 陷阱、完整最小示例。
完整接入文档(`TabDescriptor` / `FileViewerDescriptor` 全字段、匹配算法、HMR 陷阱、声明式设置):见 [`AGENTS.md`](./AGENTS.md)。
## 🔐 安全边界
## 🛠️ 开发与构建
- 路由受 Host 头信任围栏保护(与 `/api` 一致;`0.0.0.0` 部署时由 `dsh web` 启动器动态派生的 LAN IP 列表生效)
- `fs.write` 仅文本写入、原子替换(临时文件 + rename);`/sidebar/file` 仅限会话 cwd 内的媒体文件
- 提交/分支操作仅调用 git CLI,绝不设置 git 身份
```sh
pnpm install # @deepseek-ai/* 从 npm 解析(^0.0.1-rc.1),需安装者自己的只读令牌:
# ~/.npmrc 配 //registry.npmjs.org/:_authToken=<自己的令牌>(勿写进仓库),
# 或改 devDependencies 为 link: 指向自己的 ~/.dsh/source/current
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)
pnpm watch # tsdown --watch
```
## ⚠️ 已知限制(v1 非目标)
**架构**:单 npm 包、host/client 双半结构——host(`src/index.ts`):`/sidebar/api/*` JSON API、`/sidebar/file` 媒体路由、`/sidebar/html` 预览路由、`/sidebar/ws/terminal` WebSocket(fs / git / pty / 预览,全部会话级 + 信任围栏);client(`src/client/index.tsx`):portal 侧边栏 + 各视图 + 拦截;状态按会话持久化 localStorage。插件按 DSH 官方规范组织(无 default 导出、双 client bundle),运行期不依赖 npm / checkout(`@deepseek-ai/*` 由 web profile 提供)。
- Git 无 push/pull/fetch;无文件 watcher(手动刷新)
- 工具行内的文件打开按钮(核心代码)不可拦截,仅"产出文件"行被接管
- 终端 Tab **拖拽到另一分栏**时会重挂载(shell 重开);切换 Tab / 切换会话(30 秒内返回)/ 刷新页面不会
- `.xlsx` 预览不保留单元格样式(字体/颜色/边框/条件格式/图表)——SheetJS 社区版不解析样式,需保真请下载查看
- Office/PPTX 预览组件内联进 client bundle,当前约 23MB(gzip ~4.8MB),首次加载较慢
## 🔐 安全
- 路由受 Host 头信任围栏保护(与 `/api` 一致);`fs.write` 原子写入;媒体/预览路由仅限会话 cwd 内文件;git 只调 CLI、绝不设置身份
- HTML 预览与浏览器 tab 的内容在**不透明源沙箱 iframe** 中渲染(无 `allow-same-origin`/`allow-top-navigation`、`no-referrer`、权限策略全禁);`/sidebar/html` 路由带 CSP `sandbox` + 大小/路径边界;地址栏拒绝 `javascript:`/`data:`/`file:` 与 localhost 等本机地址
- 界面实时显示沙箱状态(关闭时红色警示),可临时解锁当前页面;设置页可按功能关闭沙箱(默认关闭该设置,带警告文案)——关闭后内容与界面同源,仅建议对完全可信内容使用
## ⚠️ 已知限制
- Git 无 push/pull/fetch;无文件 watcher(手动刷新);工具行内文件打开按钮不可拦截
- 终端 Tab 拖到另一分栏会重挂载(shell 重开)
- `.xlsx` 预览不保留单元格样式(SheetJS 社区版限制);Office/PPTX 预览内联进 client bundle(约 23MB),首次加载较慢
- 浏览器沙箱无登录态/第三方 Cookie 受限,部分站点登录需走弹窗;被 `X-Frame-Options`/`frame-ancestors` 拒绝嵌入的站点(如 arxiv.org)显示原因面板(含「在浏览器中打开」);iframe 内部跳转不进后退栈
- HTML 预览渲染的是已保存文件(不反映未保存草稿)
## 🖥️ 平台支持
代码按 **Windows / Linux / macOS** 三平台适配(macOS 为日常验证平台;Windows/Linux 的平台分支经单元测试覆盖,建议真机冒烟):
- 路径处理全部走 `node:path` 平台 API(列表拼接、`resolve`);媒体路由的前缀检查与相对路径投影容忍混合分隔符,Windows 上大小写不敏感
- Windows 上终端 shell 为 `powershell.exe`;node-pty 的 spawn-helper 修复(pnpm 剥执行位)自动跳过 Windows
- `node-pty` 安装优先下载预编译二进制(`prebuild.js`,需网络),失败则源码编译——Windows 需 Visual Studio Build Tools,Linux 需 `make`/`g++`/`python3`,macOS 需 Xcode Command Line Tools;`pnpm-workspace.yaml` 已声明 `allowBuilds: node-pty: true`
- git 操作仅依赖 `git` 在 PATH(Windows 需 Git for Windows)
Windows / Linux / macOS 三平台适配(macOS 日常验证;其余经单元测试覆盖);`node-pty` 优先预编译二进制,失败需编译工具链(Windows VS Build Tools / Linux make+g+++python3 / macOS Xcode CLT)。
+618
View File
@@ -0,0 +1,618 @@
# 外部插件接入指南:基于 dsh-better-sidebar 实现新页面
> 面向 **消费插件开发者**:如何让你的插件向 better-sidebar 注册新的侧边栏页面(tab)和文件类型预览器。
>
> 适用版本:**v0.4.0+**(`ctx.betterSidebar` 服务);声明式设置部分需要 **v0.4.1+**。当前版本 v0.4.3。
> 权威代码:`src/client/service.ts`(服务实现)、`src/client/builtins/`(内置 6 tab + 8 viewer 参考实现)、`lib/types/client/service.d.ts`(类型声明)。
---
## 1. 总览:你能扩展什么
better-sidebar 从 v0.4.0 起把自己改造成一个**注册表服务**:
- **新页面(tab)**:注册一种新的侧边栏 tab 类型,出现在侧边栏 `+` 菜单里,用户点击后在自己的分栏里打开你的 React 页面;
- **文件预览器(file viewer)**:注册一种文件类型预览器,让用户在侧边栏打开文件时走你的渲染组件(覆盖或补充内置的 image/pdf/code 等)。
内置的 6 个 tab(explorer / git / subagent / terminal / editor / diff)和 8 个 viewer(image / pdf / docx / xlsx / pptx / markdown / code / binary-download)**自己也是通过同一套 API 注册的**(吃自己的狗粮),所以外部插件的能力与内置功能完全对等。
关键机制一句话:better-sidebar 的 client half 在 `apply()` 开头执行 `ctx.provide('betterSidebar', service)`(`src/client/index.tsx`),消费插件在 `inject` 里声明 `'betterSidebar'`,Cordis 保证服务就绪后才激活你的插件,然后你调用 `ctx.betterSidebar.registerTab(...)` / `registerFileViewer(...)` 完成注册,返回的 disposer 由 Cordis fiber 在卸载(HMR / 禁用)时自动调用。
> ⚠️ **服务只在 client half**:`ctx.betterSidebar` 只存在于浏览器侧。你的插件 **host 半没有这个服务**;host 半需要读 better-sidebar 状态时,走它自己的 HTTP/WS 路由(`/sidebar/api/*`、`/sidebar/file`、`/sidebar/ws/*`),不走服务。
---
## 2. 前置:类型合并与依赖声明
### 2.1 双 cordis 实例问题
外部插件在 DSH monorepo 之外解析,拿不到官方 cordis 的 augmentation,因此 `ctx.betterSidebar` 不会自动出现在你的 `Context` 类型上。解法由 better-sidebar 自己提供:
```ts
import type {} from 'dsh-better-sidebar' // 触发 declare module 'cordis' 类型合并
```
这个 **type-only import** 在编译时被擦除,不产生任何运行时依赖,也不会触发构建纯度门(见 §9)。
### 2.2 package.json 声明
```jsonc
{
"name": "my-plugin",
"peerDependencies": {
"cordis": "^4.0.0-rc.7",
"dsh-better-sidebar": "workspace:*"
},
"peerDependenciesMeta": {
"dsh-better-sidebar": { "optional": true }
}
}
```
- `dsh-better-sidebar` 必须是 **peerDependency**(不是 dependency),避免两份实例;
- `optional: true`:better-sidebar 未安装时你的插件照常加载,注册代码因为 `ctx.betterSidebar` 为 undefined 而安全跳过。
### 2.3 类型导入路径
```ts
// 方式一:主入口(推荐,src/index.ts 已 re-export 全部描述符类型)
import type {
BetterSidebarService,
TabDescriptor,
TabComponentProps,
FileViewerDescriptor,
FileViewerProps,
FileFetchStrategy,
} from 'dsh-better-sidebar'
// 方式二:子路径(与主入口等价)
import type { TabDescriptor } from 'dsh-better-sidebar/client/service' // 别名 ./client/api
```
---
## 3. 最小骨架(client half)
```ts
// my-plugin/src/client/index.ts
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
import type { Context } from 'cordis'
export const inject = ['betterSidebar', 'slots'] // 声明服务依赖(slots 可选,按需)
export function apply(ctx: Context): void {
// 注册一个 sidebar tab:ctx.effect 包裹 → 卸载时自动撤销注册(HMR-safe)
ctx.effect(() =>
ctx.betterSidebar.registerTab({
id: 'my-plugin:db',
title: () => 'Database',
icon: <DbIcon />,
order: 50,
component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
})
)
// 注册一个文件预览器
ctx.effect(() =>
ctx.betterSidebar.registerFileViewer({
id: 'my-plugin:csv',
exts: ['csv'],
fetchStrategy: 'custom',
load: async (path, scope) => parseCsv(await fetchCsvBytes(scope, path)),
component: ({ customData, path }) => <CsvGrid data={customData} path={path} />,
})
)
}
```
要点:
- **注册必须包在 `ctx.effect(...)` 里**。`registerTab` / `registerFileViewer` 返回 `() => void` disposer,Cordis fiber 卸载时自动调用;不包 effect,HMR / 插件禁用后注册残留,下次激活会抛 `"already registered"`。
- `inject = ['betterSidebar']` 让 Cordis 在 better-sidebar 激活后才激活你的插件,注册时机无忧(顺序无关)。
- 注册在 `apply` 内任意时刻都行;服务在 better-sidebar 的 `apply()` 开头就绪。
---
## 4. 新页面(Tab)注册 API
### 4.1 `TabDescriptor` 完整字段
```ts
interface TabDescriptor {
/** 唯一 id;也是 SidebarTab.type 的值。建议带包前缀:'my-plugin:db'。 */
id: string
/** 标题(i18n 友好:传字符串或返回字符串的函数) */
title: string | (() => string)
/** 图标:ReactNode 或 (size: number) => ReactNode */
icon?: ReactNode | ((size: number) => ReactNode)
/** + 菜单排序(升序);默认 100。内置:explorer=10, git=20, subagent=30, terminal=40 */
order?: number
/** 从 + 菜单隐藏(editor/diff 用:由其他流程触发打开,不在菜单里) */
hidden?: boolean
/** + 菜单禁用判定(如 terminal 配额满)。三参:ctx、会话 scope、当前状态 */
available?: (ctx: Context, scope: SessionScope, state: SidebarState) => boolean
/**
* 单实例语法糖:`single: true` ≡ `dedupeKey: () => id`(打开时聚焦既有
* 同类型 tab 而非新开)。显式给出 dedupeKey 时优先于 single。
*/
single?: boolean
/**
* 去重键:openTab 时若已存在 dedupeKey 相同的 tab,则聚焦而非新开。
* 返回 undefined 表示不去重(每次都新开,但同 id 会被 id 安全网聚焦)。
* 内置策略:explorer/git/subagent 用 single: true;editor 用 tab => tab.path;diff 用 tab => tab.id。
*/
dedupeKey?: (tab: SidebarTab) => string | undefined
/**
* 自定义 tab 创建(minting SidebarTab + 状态 patch)。
* 返回 null 拒绝创建。terminal 用它生成 terminal:<n> id 并递增 nextTerminal。
* 省略时用默认 { id, type, title } + seed 里的 path/diff。
*/
createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null
/**
* 声明式设置(v0.4.1+):见 §7。
*/
settings?: {
toggles?: readonly {
key: string
title: string | (() => string)
desc?: string | (() => string)
}[]
}
/** 渲染函数 */
component: (props: TabComponentProps) => ReactNode
}
```
### 4.2 `TabComponentProps`(你的页面组件收到的 props)
```ts
interface TabComponentProps {
ctx: Context // client cordis context
store: SidebarStore // better-sidebar 的状态 store(可调 reduce 等)
scope: SessionScope // { sessionId, cwd? } —— 会话标识,调用 /sidebar API 必带
tab: SidebarTab // 当前 tab 实例(含 id/type/title/path?/diff?)
visible: boolean // 是否当前激活 tab 且面板打开(不可见时暂停轮询等)
// 以下由内置 tab 使用,外部 tab 可忽略:
expanded?: string[] // explorer 的展开目录集
onToggleDir?: (path: string) => void
onReferenceFile?: (path: string) => void
onOpenFile?: (path: string) => void
onOpenDiff?: (tab: SidebarTab) => void
onSubagentJump?: (childSessionId: string) => void
}
```
实践建议:
- **用 `visible` 做性能门**:subagent 内置页在 `visible === false` 时暂停轮询;你的页面若有轮询/订阅,同样处理。
- **用 `scope.sessionId`(+ `scope.cwd`)访问会话数据**:所有 `/sidebar/api/*` 请求都要带这两个字段(见 §6)。
### 4.3 注册示例
**最简单实例 tab**(+ 菜单可见):
```ts
ctx.effect(() =>
ctx.betterSidebar.registerTab({
id: 'my-plugin:notes',
title: 'Notes',
icon: <NoteIcon />,
order: 50,
single: true, // ≡ dedupeKey: () => 'my-plugin:notes'
component: ({ scope }) => <NotesView sessionId={scope.sessionId} />,
})
)
```
**多实例 tab + 外部触发打开**(每次新开,带自定义 id):
```ts
ctx.effect(() =>
ctx.betterSidebar.registerTab({
id: 'my-plugin:doc',
title: 'Doc',
icon: <DocIcon />,
order: 60,
// 不设 dedupeKey:每次 openTab 都新开
component: ({ tab, scope }) => <DocView docId={tab.id} sessionId={scope.sessionId} />,
})
)
// 外部触发打开(你的插件其他流程、甚至用户操作):
ctx.betterSidebar.openTab({ type: 'my-plugin:doc', title: 'Spec.md', id: 'doc:spec' })
```
**条件可见**(仅满足条件时 + 菜单可用;返回 false 显示为 disabled 行而非隐藏):
```ts
ctx.effect(() =>
ctx.betterSidebar.registerTab({
id: 'my-plugin:commits',
title: 'Commits',
icon: <CommitIcon />,
order: 70,
available: (ctx, scope, state) => hasGitRepo(state),
dedupeKey: () => 'my-plugin:commits',
component: ({ scope }) => <CommitsView sessionId={scope.sessionId} />,
})
)
```
**自定义创建**(mint 自增 id,terminal 内置页同款):
```ts
ctx.effect(() =>
ctx.betterSidebar.registerTab({
id: 'my-plugin:console',
title: 'Console',
order: 80,
createTab: (state) => ({
tab: { id: `console:${state.nextTerminal}`, type: 'my-plugin:console', title: `Console ${state.nextTerminal}` },
patch: { nextTerminal: state.nextTerminal + 1 }, // 借内置计数器;也可自建 state 字段
}),
component: ({ tab, scope }) => <ConsoleView tabId={tab.id} sessionId={scope.sessionId} />,
})
)
```
### 4.4 内置 tab 清单(不可重复注册)
| id | order | single | hidden | 用途 |
|---|---|---|---|---|
| `editor` | -1 | 否(按 path 去重) | 是 | 文件编辑/预览(由 openSidebarFile 触发) |
| `explorer` | 10 | 是 | 否 | 文件资源管理器 |
| `git` | 20 | 是 | 否 | Git 面板 |
| `subagent` | 30 | 是 | 否 | 子代理拓扑 |
| `terminal` | 40 | 否(createTab 自增) | 否 | 终端(配额 3) |
| `diff` | -1 | 否(按 id 去重) | 是 | 差异查看(由 GitView 触发) |
你的 `id` 不可与上述重复,否则 `registerTab` 抛 `"tab type \"X\" already registered"`。
---
## 5. 文件预览器(FileViewer)注册 API
### 5.1 `FileViewerDescriptor` 完整字段
```ts
interface FileViewerDescriptor {
/** 唯一 id:'image' / 'pdf' / 'my-plugin:csv' */
id: string
/** 设置清单展示名(v0.4.1+,i18n 友好);缺省回退到 id */
title?: string | (() => string)
/** 设置清单图标(v0.4.1+):ReactNode 或 (size: number) => ReactNode */
icon?: ReactNode | ((size: number) => ReactNode)
/** 小写无点的扩展名数组:['png','jpg']。[] = catch-all(仅最低优先级有效) */
exts: readonly string[]
/** 优先级(高优先);默认 0。内置默认 0;catch-all code 用 -100;binary-download 用 -50 */
priority?: number
/** 字节获取策略 */
fetchStrategy: 'none' | 'fsRead' | 'mediaUrl' | 'custom' | 'binary-download'
/** 内容嗅探(覆盖 exts):head 字节可用时,第一个 detect 返回 true 的 viewer 命中 */
detect?: (path: string, head: Uint8Array) => boolean
/** fetchStrategy='custom' 时的加载函数 */
load?: (path: string, scope: SessionScope) => Promise<unknown>
/** 声明式设置(v0.4.1+):形状同 TabDescriptor.settings */
settings?: { toggles?: readonly { key: string; title: string | (() => string); desc?: string | (() => string) }[] }
/** 渲染函数 */
component: (props: FileViewerProps) => ReactNode
}
```
### 5.2 `FileViewerProps`
```ts
interface FileViewerProps {
ctx: Context
store: SidebarStore
scope: SessionScope
path: string
title: string
viewerId: string // 命中 viewer 的 id(如 'code' / 'my-plugin:csv')
content?: string // fetchStrategy='fsRead' 时
truncated?: boolean // fetchStrategy='fsRead' 时
mediaUrl?: string // fetchStrategy='mediaUrl' 时
customData?: unknown // fetchStrategy='custom' 时(load() 的返回值)
}
```
### 5.3 `fetchStrategy` 对照
| 策略 | 字节来源 | 传给 component 的字段 | 适用 |
|---|---|---|---|
| `none` | 不需要字节 | (无) | 自渲染(如纯 UI) |
| `fsRead` | `/sidebar/api` 的 `fs.read` | `content`, `truncated` | 文本类(CSV/JSON/XML) |
| `mediaUrl` | `/sidebar/file` 媒体路由 URL | `mediaUrl` | 图片/PDF/Office(viewer 自己 fetch 字节) |
| `custom` | viewer 的 `load()` 函数 | `customData` | 自定义协议(如远程拉取) |
| `binary-download` | 不预览,显示下载按钮 | (无) | 无客户端渲染器的二进制格式 |
### 5.4 匹配算法(`matchFileViewer`)
`matchFileViewer(path, head?)` **单趟**按 priority 降序(稳定排序,相同 priority 按注册顺序)遍历每个 descriptor:
1. 若 `head` 字节可用且该 descriptor 有 `detect` → 调 `detect(path, head)`,true 则命中;**miss 且是 catch-all(`exts: []`)则本轮放弃**(纯嗅探型不得盲认领);
2. 否则匹配 `exts`(小写无点;`exts: []` 且无 `detect` 是盲 catch-all,直接命中)。
即:**priority 高的 descriptor 先获得裁决权**(其 detect 或 exts 任一命中即赢),低 priority 的 detect 不会越过高 priority 的 exts 匹配。`exts: []` + `detect` 的组合是"纯嗅探":无 head 时不认领任何文件(不会吞掉图片/PDF 等真实 viewer 的文件),有 head 时只认领 detect 命中的。全部 miss 返回 `undefined`(编辑器显示下载按钮)。
> **head 字节从哪来**:第一次匹配(纯扩展名)没有 head。`fsRead` 策略读取后若文件为二进制,host 的 `fs.read` 响应会带 `head` 字段(base64,前 4KB,`src/index.ts` 的 `READ_HEAD_LIMIT`),编辑器会用它对 `detect` viewer **重匹配一次**——所以 detect 型 viewer 的实际触发场景是"扩展名匹配落空/二进制文件"。文本文件的 detect 嗅探不在内置流程内(用 `exts` 或 `custom` 策略替代)。
> **内置 viewer**(不可重复注册,全部 8 个):image(0) / pdf(0) / docx(0) / xlsx(0) / pptx(0) / markdown(0, fsRead) / code(-100, catch-all, fsRead) / binary-download(-50, exts doc/xls/ppt + NUL detect)。
> code 是兜底 viewer:任何其他 viewer 未认领的文件都会落到 code(CodeMirror 文本编辑);二进制文件经 head 重匹配被 binary-download 的 NUL detect 认领(下载按钮)。外部 viewer 注册同扩展名 + 更高 priority 即可覆盖。
### 5.5 注册示例
**CSV 预览器**(自定义加载 + 渲染):
```ts
ctx.effect(() =>
ctx.betterSidebar.registerFileViewer({
id: 'my-plugin:csv',
exts: ['csv'],
fetchStrategy: 'custom',
load: async (path, scope) => {
const text = await fetchText(scope, path)
return parseCsv(text)
},
component: ({ customData, path }) => <CsvGrid rows={customData as string[][]} path={path} />,
})
)
```
**覆盖内置 image viewer**(如自定义 SVG 优化渲染):
```ts
ctx.effect(() =>
ctx.betterSidebar.registerFileViewer({
id: 'my-plugin:svg-pro',
exts: ['svg'],
priority: 10, // 高于内置 image 的 0
fetchStrategy: 'mediaUrl',
component: ({ mediaUrl }) => <OptimizedSvg src={mediaUrl} />,
})
)
```
**内容嗅探**(按 magic bytes 路由,忽略扩展名):
```ts
ctx.effect(() =>
ctx.betterSidebar.registerFileViewer({
id: 'my-plugin:magic-parquet',
exts: [], // catch-all,但 priority 高 + detect 精确命中
priority: 100,
fetchStrategy: 'custom',
detect: (_path, head) => head.length >= 4
&& head[0] === 0x50 && head[1] === 0x41
&& head[2] === 0x52 && head[3] === 0x31, // 'PAR1'
load: async (path, scope) => parseParquet(await fetchBytes(scope, path)),
component: ({ customData }) => <ParquetTable data={customData} />,
})
)
```
---
## 6. 页面内如何访问数据(/sidebar API)
你的 tab / viewer 组件运行在浏览器里,与内置视图同源同权。访问文件/会话数据直接 `fetch` better-sidebar 的 JSON API(内置 `src/client/api.ts` 的封装就是干这个的,你可以在自己插件里复制这个 fetch 模式):
```ts
// POST /sidebar/api/<method>,body 带 sessionId + cwd(可选)
const res = await fetch('/sidebar/api/fs.read', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sessionId: scope.sessionId, path }),
})
const { value } = await res.json() // 错误时 { ok: false, error: { code, message } }
```
常用方法(完整清单见 `src/client/api.ts`):
| 方法 | 说明 |
|---|---|
| `session.cwd` | 会话权威 cwd(`{ cwd, root, parent }`) |
| `fs.tree` | 目录列表(`{ path, entries: FsEntry[], truncated }`) |
| `fs.read` | 读文件:文本返回 `{ kind: 'text', content, truncated }`;二进制返回 `{ kind: 'binary', size, truncated, head }`(head = base64 前 4KB) |
| `fs.write` | 原子写文件 |
| `git.status` / `git.diff` / `git.log` 等 | 全套 Git 只读 + 写操作 |
| `pty.close` / `agent-pty.close` | 释放终端(外部 tab 一般用不到) |
| `settings.get` / `settings.update` | 侧边栏偏好读写(revision 守卫) |
媒体/下载字节走 `/sidebar/file` 路由(`?sessionId=&path=&cwd=&download=1`):
```ts
// 媒体 URL(图片等直接 <img src>):/sidebar/file?sessionId=...&path=...
const url = `/sidebar/file?${new URLSearchParams({ sessionId: scope.sessionId, path })}`
```
> 注:内置的 `api.ts` 是 better-sidebar 内部模块,外部插件 **不要** value-import 它(构建纯度门会挡);按上表模式自己 fetch 即可。所有路由带与 `/api` 相同的 Host 头信任围栏,浏览器同源访问天然通过。
---
## 7. 服务方法完整清单
```ts
interface BetterSidebarService {
/** 注册 tab 类型;返回 disposer */
registerTab(descriptor: TabDescriptor): () => void
/** 注册文件预览器;返回 disposer */
registerFileViewer(descriptor: FileViewerDescriptor): () => void
/** 当前已注册的 tab 描述符快照(同步,供 useSyncExternalStore 用;含被设置页禁用的类型) */
getTabs(): readonly TabDescriptor[]
/** 当前已注册的 file viewer 描述符快照(含被设置页禁用的 viewer) */
getFileViewers(): readonly FileViewerDescriptor[]
/** 按 id 查 tab 描述符 */
getTab(id: string): TabDescriptor | undefined
/** 某个 tab 类型是否在 Side card 设置中启用(v0.4.1+;缺省 = 启用) */
isTabEnabled(id: string): boolean
/** 某个 file viewer 是否在 Side card 设置中启用(v0.4.1+;缺省 = 启用) */
isViewerEnabled(id: string): boolean
/** 按 path 匹配 file viewer(priority 降序单趟:detect → exts;跳过硬禁用 viewer) */
matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined
/**
* 打开一个 tab(+ 菜单和外部触发都用它;走 descriptor.dedupeKey 去重)。
* title 可选:给出时优先于 descriptor.title(editor 显示文件名);
* 有 createTab 的 descriptor(terminal)会忽略 title/path/id。
* 被设置禁用的类型是 no-op(console.warn 提示)。
*/
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void
/** 关闭一个 tab */
closeTab(tabId: string): void
/** 订阅注册表变化(register/dispose 时触发) */
subscribe(listener: () => void): () => void
}
```
---
## 8. 声明式设置(v0.4.1+)
每个注册的 tab / viewer **自动**出现在 DSH 设置页「侧边卡片」分区(`SideCardSection` 按注册表驱动渲染,无硬编码):
- 展示:小卡片网格(图标 + 标题 + 类型 id),**高亮 = 启用**,勾选徽标钉在卡片最右端;viewer 卡片额外显示扩展名。
- 持久化:开关写入 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,**缺省 = 启用**,显式 `false` 才禁用)。
- 关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开(`console.warn`)、派生流程(子代理自动展开、agent 终端自动补 tab)停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。
- `settings.toggles`(可选):在卡片行下追加**嵌套开关**(仅父级启用时显示),绑定 `SidebarPrefs` 字段;通过卡片右下角齿轮按钮在原生弹窗中编辑(复选框行)。内置示例:subagent tab 的 `autoOpenSubagent`、terminal tab 的 `agentTerminalTools`。
```ts
ctx.effect(() =>
ctx.betterSidebar.registerTab({
id: 'my-plugin:db',
title: 'Database',
order: 50,
settings: {
toggles: [{
key: 'myPluginAutoConnect', // ⚠️ 必须是宿主 PrefsSchema 的字段,见下
title: 'Auto connect',
desc: 'Connect on open',
}],
},
component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
})
)
```
> ⚠️ **key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools`)。外部插件的自定义键会被 settings seam 丢弃——当前版本嵌套开关只支持宿主预置的布尔偏好;想给用户暴露你自己的设置,走 DSH 自己的 settings 服务(`ctx.settings`),不要依赖这里的 `toggles`。
---
## 9. 生命周期与 HMR
- **disposer 必须返回并被 fiber 持有**:`registerTab` / `registerFileViewer` 返回 `() => void`,Cordis fiber 卸载时自动调用。**务必**用 `ctx.effect(() => register(...))` 包裹,否则 fiber 卸载(HMR / 插件禁用)时不会撤销注册,导致下次激活时 `"already registered"` 错误。
- **注册时机**:better-sidebar 在 `apply()` 开头 `ctx.provide('betterSidebar', service)`,你的 `inject = ['betterSidebar']` 保证你激活时服务已就绪;注册顺序无关紧要。
- **持久化降级**:localStorage 里持久化的 tab 若其 type 未注册(你的插件未加载),渲染为 `<OrphanedTab/>` 占位卡("插件未加载" + 关闭按钮);你的插件加载后下次渲染自动恢复(`sanitizeNode` 保留未注册类型而非丢弃)。
- **`visible` 语义**:面板折叠或非激活 tab 的 `visible` 为 false;你的页面应借此暂停轮询/订阅,激活时恢复。
---
## 10. 平台约束与陷阱
| 陷阱 | 说明 |
|---|---|
| **构建纯度门** | client bundle 禁止 value-import `@dsh-external/*` 或非白名单的 `@deepseek-ai/*`(`tsdown.config.ts` 的 `dsh-client-bundle-purity` 插件);**类型 `import type {}` 会被擦除,不触发门禁**——类型可自由共享,运行时符号不行。所有跨插件交互走 `ctx.betterSidebar` 方法调用 |
| **双 cordis 实例** | 外部插件解析不到 DSH monorepo 的 cordis augmentation;better-sidebar 自己重述了 `interface Context { betterSidebar: ... }`(`src/context-types.ts`),你 `import type {} from 'dsh-better-sidebar'` 即拿到类型 |
| **ModuleLoader 不跨插件** | 运行时 `require()` 虽支持跨 bundle,但被构建门挡;所有交互走 `ctx.betterSidebar` 方法调用 |
| **host 半无此服务** | `ctx.betterSidebar` 只在 client 侧存在;host 半需要 better-sidebar 数据走 `/sidebar/api/*` HTTP 路由 |
| **portal 限制** | 整面板 slot 由 ui-layout 独占,外部 tab 只能进入 better-sidebar 的 portal 内部,无法全屏替换整个面板 |
| **id 冲突** | `registerTab` / `registerFileViewer` 对重复 id 抛错;建议用包前缀(`my-plugin:xxx`) |
| **不要 value-import `dsh-better-sidebar`** | 即使是 `./client/service` 子路径,运行时落点也是 client bundle;只做 type-only import |
---
## 11. 完整最小示例
假设插件 `my-plugin` 要加一个 "Database 浏览器" tab + `.csv` 文件预览器。
**`my-plugin/package.json`**:
```jsonc
{
"name": "my-plugin",
"version": "0.1.0",
"main": "lib/index.js",
"exports": {
".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" },
"./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }
},
"peerDependencies": {
"cordis": "^4.0.0-rc.7",
"dsh-better-sidebar": "workspace:*",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"react": "^18.2.0"
},
"peerDependenciesMeta": {
"dsh-better-sidebar": { "optional": true }
}
}
```
**`my-plugin/src/client/index.tsx`**:
```tsx
import { createElement } from 'react'
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
import type { Context } from 'cordis'
export const inject = ['betterSidebar']
export function apply(ctx: Context): void {
// Database tab(单实例,+ 菜单可见)
ctx.effect(() =>
ctx.betterSidebar.registerTab({
id: 'my-plugin:db',
title: () => 'Database',
order: 50,
dedupeKey: () => 'my-plugin:db',
component: ({ scope }) => createElement(DbView, { sessionId: scope.sessionId }),
})
)
// CSV viewer(custom 策略:自己拉字节 + 解析)
ctx.effect(() =>
ctx.betterSidebar.registerFileViewer({
id: 'my-plugin:csv',
exts: ['csv'],
fetchStrategy: 'custom',
load: async (path, scope) => {
const res = await fetch('/sidebar/api/fs.read', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sessionId: scope.sessionId, path }),
})
const { value } = await res.json()
return parseCsv(value.content)
},
component: ({ customData, path }) =>
createElement(CsvGrid, { rows: customData as string[][], path }),
})
)
}
function DbView(props: { sessionId: string }): React.ReactNode { /* ... */ }
function CsvGrid(props: { rows: string[][]; path: string }): React.ReactNode { /* ... */ }
function parseCsv(text: string): string[][] { /* ... */ }
```
**注册到 profile**:
1. `~/.dsh/profiles/web/package.json` 的 `dependencies` 加 `"my-plugin": "link:<你的插件路径>"`;
2. `~/.dsh/profiles/web/cordis.patch.yml` 追加挂载行(`- insert: - id: my-plugin / name: 'my-plugin'`);
3. 在 profile 目录 `pnpm install`;
4. 重启 `dsh web` + 浏览器硬刷新(Cmd/Ctrl+Shift+R)。
---
## 12. 参考实现与调试
better-sidebar 的内置 tab 和 viewer 就是参考实现("吃狗粮"):
- **`src/client/builtins/`**:6 个内置 tab(tabs.tsx)+ 8 个内置 viewer(viewers.tsx)的注册代码 + 聚合与 disposer 生命周期(index.ts)
- **`src/client/service.ts`**:`BetterSidebarService` 接口 + `createBetterSidebarService` 工厂实现(含匹配算法、dedupe、createTab、启用态 gating)
- **`src/client/Sidebar.tsx`**:`TabContent` 分发(查 `getTab` → 调 descriptor.component;未注册 → `<OrphanedTab/>`)、`+` 菜单构建(order 排序 + available disabled + 禁用过滤)
- **`src/client/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + `settings.toggles` 嵌套开关 + 开关持久化)
- **`src/client/api.ts`**:`/sidebar` API 的封装(复制其 fetch 模式到你的插件)
- **`tests/service.spec.ts`**:注册表生命周期 / 匹配算法 / dedupe / createTab / 启用态 gating 测试
- **`tests/builtins.spec.ts`**:内置注册清单断言(6 tab + 8 viewer + 声明式元数据)
- **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`**:设计文档(§17 含实施偏差记录,以现状为准)
调试时直接读这些文件即可看到所有 API 的真实用法。
@@ -0,0 +1,136 @@
# 内置 HTML 预览器 + 内嵌网页浏览器(沙箱化)设计
**日期**:2026-08-11
**状态**:已批准,已实施
**作者**:opencode + 用户
**当前版本**:v0.4.3(main `35cde82`)
**目标版本**:v0.5.0
## 1. 目标
在 `dsh-better-sidebar` 内按既有 builtins 模式新增两个内置能力:
1. **`html` 文件预览器**:`.html`/`.htm` 文件在侧边栏编辑器中默认以真实渲染预览(相对资源可加载),可切换代码编辑(CodeMirror HTML 高亮 + 保存)。
2. **`browser` 浏览器 tab**:`+` 菜单新增「浏览器」页面,地址栏输入 URL 在沙箱 iframe 中浏览,支持后退/前进/刷新,URL 与标题按 tab 持久化。
安全问题为第一优先级(用户显式要求"注意安全问题"):所有渲染走**不透明源沙箱 iframe**,host 侧新增 `/sidebar/html` 路由带 CSP + 信任围栏 + cwd 边界。另按用户追加要求提供**关闭沙箱的子设置**(默认关闭该设置;开启需明确勾选,设置项与界面都带安全警告文案)。
## 2. 复用门(dsh-reuse-first)
`NO EXISTING CAPABILITY`:已搜索 DSH checkout(docs/*-catalog、apps/web、packages 源码——sidecar/iframe/webview/html preview/CSP 均无等价物;`WorkspaceBrowser` 只是目录树)与 dsh-external org hub 目录(browser/html/webview/iframe/sidecar/viewer/preview 均 0 命中)。全新实现,落点为本仓库 builtins(用户已确认)。
## 3. 威胁模型与安全基线
| 威胁 | 对策 |
|---|---|
| 恶意 HTML/网页读取 GUI 数据(localStorage、`/sidebar/api`、同源 cookie) | iframe 一律 `sandbox` 且**不含 `allow-same-origin`** → 文档源为不透明源(null)。**禁止 srcdoc**(srcdoc 在无沙箱时继承父源),一律 route-src |
| 预览/浏览内容劫持 GUI 页面(top 导航) | 不含 `allow-top-navigation` / `allow-top-navigation-by-user-activation` |
| 表单 CSRF 打到本地服务 | html 预览器 iframe **不含 `allow-forms`**;浏览器 tab 含 `allow-forms`(登录流必需,残余风险见 §9) |
| 浏览器 tab 访问内部地址(GUI 自身、localhost、127.0.0.0/8) | 地址栏导航前客户端阻断(`browser.ts` 纯函数);`javascript:`/`data:`/`file:` 等非 http(s) 一律拒绝 |
| 预览页脚本请求 `/sidebar/api` | 三层:不透明源 fetch 被 CORS 拒读;信任围栏拒 `sec-fetch-site: cross-site`;`Origin: null` 无法伪造同源 |
| 敏感信息泄露给被浏览站点 | iframe `referrerPolicy="no-referrer"`;权限策略 `allow=""`(拒绝 camera/mic/geolocation 等一切策略特性) |
| 直接顶层打开 `/sidebar/html/*` URL(弹窗等) | 路由响应带 `Content-Security-Policy: sandbox ...; object-src 'none'`,顶层同样被沙箱化 |
| 路径逃逸 | 路由复用 `requireAbsolute` + `isWithin(cwd, path)` + `mediaLimit` 上限 + 信任围栏 |
| 关闭沙箱(用户主动选择) | 每功能独立子设置(`htmlViewerNoSandbox` / `browserNoSandbox`,默认 false);设置文案 + 渲染时持久警告条双警告 |
## 4. 变更清单
### 4.1 host:`/sidebar/html` 路由(src/html-route.ts 新 + src/index.ts)
URL 采用**路径编码**(无 query)——WHATSG URL 解析对纯路径相对引用会丢弃 base 的 query,query 式 URL 下 `./style.css` 会丢失 session 作用域:
```
/sidebar/html/<sessionId>/<绝对路径逐段 encodeURIComponent>
例:/sidebar/html/S/Users/me/proj/index.html
./style.css → /sidebar/html/S/Users/me/proj/style.css ✓
Windows: C:\Users\me\a.html → /sidebar/html/S/C%3A/Users/me/a.html
```
- `src/html-route.ts`(无依赖纯模块,client 可导入不触发纯度门):`encodeHtmlUrl(sessionId, path)`、`decodeHtmlUrl(pathname)`(404 错前缀;400 空路径/双斜杠/畸形编码/缺 sessionId 或文件路径/空段)。
- `src/index.ts`:注册 `/sidebar/html` prefix 路由(fence → GET 校验 → decode → `requireAbsolute` → `isWithin(cwd)` → stat isFile + `mediaLimit` → 读文件),响应头:
`content-type`(新增 `.html`/`.htm` → `text/html` 到 MEDIA_TYPES)、`cache-control: no-cache`、`X-Content-Type-Options: nosniff`、`Referrer-Policy: no-referrer`、`Content-Security-Policy: sandbox allow-scripts allow-popups allow-downloads allow-modals; object-src 'none'`。
- cwd 语义与媒体路由一致:session store 权威;会话附着前的首请求回退 `process.cwd()`,通常被 `isWithin` 拒绝(可接受)。
### 4.2 client:`html` 预览器(TextEditor.tsx 扩展 + builtins/viewers.tsx)
- descriptor:`{ id:'html', exts:['html','htm'], priority:0, fetchStrategy:'fsRead', settings.toggles:[htmlViewerNoSandbox] }`。
- TextEditor:`markdown || html` 都显示预览/编辑切换;html 预览分支渲染:
`<iframe src={htmlUrl(scope,path)} sandbox="allow-scripts allow-popups allow-downloads allow-modals" referrerPolicy="no-referrer" allow="" />`
- 导出 `HTML_IFRAME_SANDBOX` 常量(测试钉死);沙箱关闭时 `sandbox` 属性整体移除 + 渲染 `sandboxWarning` 警告条。
- 预览显示**已保存文件**(route-src),不反映未保存草稿(有意取舍,文档化)。
### 4.3 client:`browser` tab(BrowserView.tsx 新 + browser.ts 新 + builtins/tabs.tsx + state.ts)
- state.ts:`SidebarState.nextBrowser`(默认 1;sanitize 对缺失/畸形值回退 1,旧持久化布局继续加载);新 reducer `patchTab(state, tabId, {title?, path?})`。
- tabs.tsx:`{ id:'browser', order:50, settings.toggles:[browserNoSandbox], createTab: 铸造 browser:<n> + nextBrowser 递增 }`。
- browser.ts(无依赖纯模块):`isLoopbackHostname`(localhost/::1/0.0.0.0/127/8);`normalizeBrowserUrl(input, selfOrigin)`:
- 无 scheme → 补 `https://`;`://` 完整 URL 走解析后协议兜底;已知危险 scheme 前缀(javascript/data/file/about/...)直接拒绝;`host:port` 形态(如 `example.com:8080`)不被误判为 scheme。
- 拒绝顺序:协议 → 自身 origin **放行**(GUI 自身可在侧边栏打开,沙箱不透明源兜底;须先于 loopback 判定,因其主机通常就是 loopback)→ loopback 阻止 → ok。
- BrowserView:地址栏(后退/前进/刷新/输入/前往)+ iframe + 状态行;URL 经 `patchTab` 持久化到 `tab.path`/`tab.title`(hostname);历史栈仅记录地址栏导航(iframe 内部跳转跨域不可见,已知限制);无 URL 时显示起始页(不自动加载任何站点)。
- iframe:`sandbox="allow-scripts allow-forms allow-popups allow-downloads allow-modals allow-popups-to-escape-sandbox"`(导出 `BROWSER_IFRAME_SANDBOX`;无 allow-same-origin / allow-top-navigation;allow-popups-to-escape-sandbox 供 OAuth 弹窗);沙箱关闭时移除属性 + 警告条。
- 常驻底部小字沙箱说明(`browserHint`)。
### 4.4 设置(prefs-shared.ts + config.ts + SideCardSection 声明式渲染)
- `SidebarPrefs` 新增 `htmlViewerNoSandbox` / `browserNoSandbox`(boolean,默认 false);`SIDEBAR_PREFS_DEFAULTS` 与 `PrefsSchema` 同步;`parsePrefs` 按字段校验回退。
- 声明式设置:html viewer 行与 browser tab 行各自 `settings.toggles`,设置页自动渲染(父级关闭时隐藏),文案含「(不安全)」+ 后果说明。
### 4.5 其他
- `api.ts`:`htmlUrl(scope, path)` = `encodeHtmlUrl(sessionId, path)`。
- `icons.tsx`:`IconHtmlOutline16`(文件 + `‹/›`)、`IconGlobeOutline16`(地球)。
- `locales.ts`:zh/en 双份新键(viewerHtml/browser/浏览器栏/阻止原因/沙箱提示/设置文案)。
- `sidebar.module.css`:`sandboxWarning`、`editorHtml`、`.browser*` 系列。
## 5. 测试
| 文件 | 覆盖 |
|---|---|
| tests/html-route.spec.ts(新) | 编码/解码往返(POSIX/Windows/特殊字符)、相对解析保持同路由同 session、404/400 拒绝、遍历段留给 isWithin |
| tests/browser.spec.ts(新) | 补 https、host:port 不误判、危险 scheme/loopback 拒绝、自身 origin 放行、invalid |
| tests/sandbox-views.spec.tsx(新) | iframe sandbox 常量钉死(无 allow-same-origin/top-navigation)、route-src 非 srcdoc、referrer/allow 属性、沙箱关闭 → 无 sandbox 属性 + 警告条、markdown 分支回归、浏览器起始页不自动加载 |
| tests/builtins.spec.ts | 7 tab / 9 viewer 清单、html 扩展名匹配、browser createTab 铸造与 nextBrowser、两个 toggles 声明 |
| tests/unit.spec.ts | nextBrowser sanitize(缺失/畸形回退 1)、patchTab 更新/无操作、parsePrefs 新字段默认与校验 |
| tests/smoke.spec.ts | fenced routes 清单 + `/sidebar/html` |
## 6. 版本与文档
- 版本 0.4.3 → 0.5.0(package.json + dsh.plugin.json 同步,manifest-consistency 守卫)。
- README/AGENTS 内置清单更新为 7 tab + 9 viewer。
## 7. 验收标准
1. `pnpm typecheck && pnpm build && pnpm test` 全绿(245 用例)。
2. 手动:打开 `.html` 默认预览渲染(相对资源可加载),可切编辑/保存;`+` 菜单浏览器 tab 多开、地址栏浏览、后退/前进/刷新、刷新页面恢复 URL 与标题。
3. 安全:预览/浏览 iframe 无 allow-same-origin/top-navigation;`javascript:`/`data:`/`file:`/localhost/127.x 均被阻止并提示(自身 origin 允许);`/sidebar/html` 越界 403、超限 400、跨站 403;预览页 fetch `/sidebar/api` 读不到(CORS),预览器表单被沙箱禁(无 allow-forms)。
4. 关闭沙箱开关:设置页勾选后界面出现持久警告条,iframe 无 sandbox 属性。
## 8. 实施偏差记录
- 设计初期考虑过 srcdoc + `<base>` 注入以实现"预览反映未保存草稿",因 srcdoc 无沙箱时继承父源的致命风险与 base 注入的脆弱性,改采 route-src(预览 = 已保存文件);相对资源经路径编码 URL 天然解析,无需内容改写。
- `normalizeBrowserUrl` 的 scheme 判别:朴素正则会把 `example.com:8080` 误判为 scheme,最终采用「`://` 完整 URL + 危险 scheme 黑名单 + 解析后协议兜底」三段式。
- `nextBrowser` 在 sanitize 中为宽松字段(缺失/畸形回退 1),避免旧持久化布局整体失效(与严格校验的 `nextTerminal` 不同)。
## 8.1 追加需求(2026-08-11,v0.5.0 内)
1. **设置页滚动**:`SideCardSection.module.css` 的 `.section` 填满设置壳 options 区并内部滚动(`height:100% + min-height:0 + overflow-y:auto`),7 tab + 9 viewer 卡片再多也不会被裁切。
2. **沙箱状态实时显示 + 临时解锁**:新增 `SandboxStatusBar`(HTML 预览与浏览器 tab 共用)——沙箱开启显示绿色状态 + 「临时解锁(不安全)」按钮;关闭(全局设置或本页临时解锁)显示**红色警示**(`state-error-primary` 色系 + 危险文案)与「恢复沙箱」按钮(仅临时态)。临时解锁是组件内 state,不写回全局设置,随页面/标签卸载失效;iframe 随沙箱翻转重挂载。
3. **聊天/界面外链在侧边栏打开**:新设置 `browserInterceptLinks`(默认开,受浏览器 tab 启用开关 gate)——`link-intercept.ts` 在 document capture 阶段拦截 http(s) **外部**链接点击(同源/非 http(s)/修饰键点击一律放行),经 `openTab({type:'browser', url, title: hostname})`(openTab seed 新增 `url` 字段:落地后 patchTab 预填 path)在侧边栏打开。浏览器 tab 地址栏新增「在浏览器中打开」按钮(`window.open(url,'_blank','noopener')`,用户手势触发,等价真实浏览器新标签)。
## 8.2 追加需求(2026-08-11 二轮,v0.5.0 内)
1. **HTML「默认非安全」二级设置**:`htmlViewerDefaultUnsafe`(默认 false)——打开 HTML 文件时预览**初始**处于非沙箱状态(localUnlock 初始值取自该 pref),状态行仍可一键「恢复沙箱」;与 `htmlViewerNoSandbox`(全局强制关闭,不可界面恢复)语义区分。
2. **沙箱状态行单行化并取代底部说明**:浏览器 tab 底部常驻 `browserHint` 条移除,说明并入 `SandboxStatusBar` 开启态文案(「沙箱模式:已启用 · 页面无法访问界面数据与本地文件,登录态与第三方 Cookie 可能不可用」),状态行 `nowrap + ellipsis + title` 恒为一行;关闭态同样单行红色警示。
3. **「在浏览器中打开」图标放大**:`IconRightUpOutline14` → `IconRightUpOutline16`(15px 渲染)。
## 8.3 追加需求(2026-08-11 三轮,v0.5.0 内)
1. **图标改 15px**:`<IconRightUpOutline16 size={15} />`。
2. **嵌入拒绝面板**:站点通过 `X-Frame-Options` / CSP `frame-ancestors` 禁止嵌入时,浏览器 tab 不再显示空白拒绝框,而是显示 `BrowserEmbedBlocked` 面板——「{host} 拒绝了嵌入请求」+ 原因说明 + 「在浏览器中打开」(`window.open`)+ 「仍然加载」两个按钮。检测:新增 host 路由 `browser.probe`(HEAD→GET 兜底,8s 超时,仅 http(s) 非 loopback,返回目标响应头的 `x-frame-options`/`frame-ancestors`,受信任围栏保护)+ 客户端 `embeddabilityOf` 判定(XFO DENY/SAMEORIGIN 或 frame-ancestors 不含 `*` → blocked;探测失败 → unknown 保持普通 iframe)。探测在每次导航(含恢复的 path)时经 `useEffect(url)` 触发;「仍然加载」以 `forceEmbed` 保留普通 iframe。
## 9. 已知限制与残余风险
- 浏览器沙箱无登录态/第三方 Cookie 受限(SameSite=Lax 在跨站 iframe 不发送),部分站点登录需走弹窗(allow-popups-to-escape-sandbox 已开)。
- iframe 内部链接跳转不进后退栈(跨域不可见);被 `X-Frame-Options`/`frame-ancestors` 拒绝的站点显示空白(站点策略,无法绕过)。
- 用户主动浏览的恶意站点仍可在沙箱内发起盲 GET(无法读取响应)到本地服务;站点重定向链可把 iframe 带到 localhost(内容仍沙箱化、受围栏保护)。
- 关闭沙箱后预览/浏览内容获得 GUI 同源权限(可读会话文件/本地存储/调内部接口)——仅建议对完全可信内容开启,设置与界面双重警告。
+2 -2
View File
@@ -1,8 +1,8 @@
{
"id": "dsh-external/dsh-better-sidebar",
"version": "0.4.1",
"version": "0.5.0",
"main": "./lib/index.js",
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
"engines": {
"dsh": ">=0.0.1"
},
+30 -30
View File
@@ -1,7 +1,7 @@
{
"name": "dsh-better-sidebar",
"version": "0.4.1",
"description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.",
"version": "0.5.0",
"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.",
"private": true,
"type": "module",
"main": "lib/index.js",
@@ -60,20 +60,20 @@
},
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-schema-form": "^0.0.1",
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-settings": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-client-web-react": "^0.0.1",
"@deepseek-ai/dsh-host-webserver": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-settings": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-agent": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-schema-form": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-ui-settings": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-web-react": "^0.0.1-rc.1",
"@deepseek-ai/dsh-host-webserver": "^0.0.1-rc.1",
"@deepseek-ai/dsh-invariants": "^0.0.1-rc.1",
"@deepseek-ai/dsh-llm": "^0.0.1-rc.1",
"@deepseek-ai/dsh-session": "^0.0.1-rc.1",
"@deepseek-ai/dsh-settings": "^0.0.1-rc.1",
"@deepseek-ai/dsh-tools": "^0.0.1-rc.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0",
"react-dom": "^18.2.0"
@@ -114,20 +114,20 @@
"xterm": "^5.3.0"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "link:/Users/menghuan/.dsh/source/current/packages/core/agent",
"@deepseek-ai/dsh-client-runtime": "link:/Users/menghuan/.dsh/source/current/packages/client/runtime",
"@deepseek-ai/dsh-client-schema-form": "link:/Users/menghuan/.dsh/source/current/packages/client/schema-form",
"@deepseek-ai/dsh-client-ui-conversation": "link:/Users/menghuan/.dsh/source/current/packages/client/ui-conversation",
"@deepseek-ai/dsh-client-ui-primitives": "link:/Users/menghuan/.dsh/source/current/packages/client/ui-primitives",
"@deepseek-ai/dsh-client-ui-settings": "link:/Users/menghuan/.dsh/source/current/packages/client/ui-settings",
"@deepseek-ai/dsh-client-ui-slots": "link:/Users/menghuan/.dsh/source/current/packages/client/ui-slots",
"@deepseek-ai/dsh-client-web-react": "link:/Users/menghuan/.dsh/source/current/packages/client/web-react",
"@deepseek-ai/dsh-host-webserver": "link:/Users/menghuan/.dsh/source/current/packages/host/webserver",
"@deepseek-ai/dsh-invariants": "link:/Users/menghuan/.dsh/source/current/packages/support/invariants",
"@deepseek-ai/dsh-llm": "link:/Users/menghuan/.dsh/source/current/packages/llm/llm",
"@deepseek-ai/dsh-session": "link:/Users/menghuan/.dsh/source/current/packages/core/session",
"@deepseek-ai/dsh-settings": "link:/Users/menghuan/.dsh/source/current/packages/settings/settings",
"@deepseek-ai/dsh-tools": "link:/Users/menghuan/.dsh/source/current/packages/core/tools",
"@deepseek-ai/dsh-agent": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-schema-form": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-ui-settings": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-web-react": "^0.0.1-rc.1",
"@deepseek-ai/dsh-host-webserver": "^0.0.1-rc.1",
"@deepseek-ai/dsh-invariants": "^0.0.1-rc.1",
"@deepseek-ai/dsh-llm": "^0.0.1-rc.1",
"@deepseek-ai/dsh-session": "^0.0.1-rc.1",
"@deepseek-ai/dsh-settings": "^0.0.1-rc.1",
"@deepseek-ai/dsh-tools": "^0.0.1-rc.1",
"@types/node": "^24.0.0",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.1",
+2001 -28
View File
File diff suppressed because it is too large Load Diff
+58
View File
@@ -7,3 +7,61 @@ packages:
allowBuilds:
node-pty: true
protobufjs: true
minimumReleaseAgeExclude:
- '@deepseek-ai/cordis-plugin-include@1.0.5-rc.1'
- '@deepseek-ai/cordis-plugin-loader@1.0.1-rc.1'
- '@deepseek-ai/cordis@4.0.1-rc.1'
- '@deepseek-ai/cosmokit@1.8.2-rc.1'
- '@deepseek-ai/dsh-agent-default-model@0.0.1-rc.1'
- '@deepseek-ai/dsh-agent-presets@0.0.1-rc.1'
- '@deepseek-ai/dsh-agent@0.0.1-rc.1'
- '@deepseek-ai/dsh-api-remotes@0.0.1-rc.1'
- '@deepseek-ai/dsh-atomic-write@0.0.1-rc.1'
- '@deepseek-ai/dsh-attachment@0.0.1-rc.1'
- '@deepseek-ai/dsh-brand@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-connection@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-locale@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-runtime@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-schema-form@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-ui-conversation@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-ui-primitives@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-ui-settings@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-ui-slash@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-ui-slots@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-web-react@0.0.1-rc.1'
- '@deepseek-ai/dsh-code-runtime@0.0.1-rc.1'
- '@deepseek-ai/dsh-commands@0.0.1-rc.1'
- '@deepseek-ai/dsh-compact@0.0.1-rc.1'
- '@deepseek-ai/dsh-credentials@0.0.1-rc.1'
- '@deepseek-ai/dsh-goal@0.0.1-rc.1'
- '@deepseek-ai/dsh-host-apiproxy@0.0.1-rc.1'
- '@deepseek-ai/dsh-host-directory-picker@0.0.1-rc.1'
- '@deepseek-ai/dsh-host-webserver@0.0.1-rc.1'
- '@deepseek-ai/dsh-invariants@0.0.1-rc.1'
- '@deepseek-ai/dsh-llm-retry@0.0.1-rc.1'
- '@deepseek-ai/dsh-llm@0.0.1-rc.1'
- '@deepseek-ai/dsh-native-command@0.0.1-rc.1'
- '@deepseek-ai/dsh-paths@0.0.1-rc.1'
- '@deepseek-ai/dsh-scope@0.0.1-rc.1'
- '@deepseek-ai/dsh-session-persistence@0.0.1-rc.1'
- '@deepseek-ai/dsh-session-projection-cache@0.0.1-rc.1'
- '@deepseek-ai/dsh-session-projection@0.0.1-rc.1'
- '@deepseek-ai/dsh-session-query@0.0.1-rc.1'
- '@deepseek-ai/dsh-session-title@0.0.1-rc.1'
- '@deepseek-ai/dsh-session@0.0.1-rc.1'
- '@deepseek-ai/dsh-settings@0.0.1-rc.1'
- '@deepseek-ai/dsh-skill@0.0.1-rc.1'
- '@deepseek-ai/dsh-storage-domain@0.0.1-rc.1'
- '@deepseek-ai/dsh-storage@0.0.1-rc.1'
- '@deepseek-ai/dsh-subagent@0.0.1-rc.1'
- '@deepseek-ai/dsh-system-prompt@0.0.1-rc.1'
- '@deepseek-ai/dsh-timeout@0.0.1-rc.1'
- '@deepseek-ai/dsh-token-meter@0.0.1-rc.1'
- '@deepseek-ai/dsh-tools@0.0.1-rc.1'
- '@deepseek-ai/dsh-type-meta@0.0.1-rc.1'
- '@deepseek-ai/dsh-typert-registry@0.0.1-rc.1'
- '@deepseek-ai/dsh-user-approval@0.0.1-rc.1'
- '@deepseek-ai/dsh-user-interaction@0.0.1-rc.1'
- '@deepseek-ai/dsh-workspace@0.0.1-rc.1'
- '@deepseek-ai/schemastery@3.18.1-rc.1'
+26
View File
@@ -0,0 +1,26 @@
/**
* Pure helpers for the `browser.probe` route (sidebar browser): the host
* fetches the response HEADERS of a URL the user is browsing and the client
* decides whether the target site forbids being embedded (X-Frame-Options /
* CSP frame-ancestors are exactly the signals the browser enforces when it
* refuses an iframe load). Kept dependency-free so the parser is
* unit-testable.
*/
/**
* Extract the `frame-ancestors` source list of a Content-Security-Policy
* header, or undefined when the directive is absent (or empty). The
* directive is the only one with a source list; sources are space-separated
* tokens (`'none'`, `'self'`, `*`, or origins).
*/
export function extractFrameAncestors(csp: string | null): string[] | undefined {
if (csp === null) return undefined
for (const directive of csp.split(';')) {
const parts = directive.trim().split(/\s+/)
if (parts[0] === 'frame-ancestors') {
const sources = parts.slice(1).filter(source => source !== '')
return sources.length === 0 ? undefined : sources
}
}
return undefined
}
+253
View File
@@ -0,0 +1,253 @@
/**
* The built-in browser tab: an address bar plus a sandboxed iframe.
*
* Security model (see browser.ts + the sandbox tokens below): the iframe is
* ALWAYS sandboxed without `allow-same-origin` (opaque origin — the visited
* page can never sit on the GUI's origin, read its storage, or reach
* /sidebar/api) and without `allow-top-navigation` (a page must not hijack
* the GUI). The address bar only accepts http(s) and refuses loopback /
* the GUI's own origin. The side card setting "关闭浏览器沙箱" drops the
* sandbox attribute entirely for fully trusted sites — the visited page then
* runs with the GUI's own origin and full session access, so a persistent
* warning bar renders while it is off.
*
* The URL is persisted onto the tab (path/title via the patchTab reducer)
* so a reload restores the visited page; the back/forward stack only tracks
* address-bar navigations (in-frame link clicks are cross-origin and
* invisible — a documented limitation).
*/
import { useEffect, useState } from 'react'
import {
IconChevronLeftOutline14,
IconChevronRightOutline14,
IconLinkOutline14,
IconRefreshOutline14,
IconRightUpOutline16,
IconWarningOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { api } from './api.ts'
import { embeddabilityOf, normalizeBrowserUrl } from './browser.ts'
import { patchTab } from './state.ts'
import { SandboxStatusBar } from './SandboxStatusBar.tsx'
import { t } from './locales.ts'
import type { TabComponentProps } from './service.ts'
import css from './sidebar.module.css'
/**
* The browser iframe sandbox tokens. NO allow-same-origin (opaque origin —
* no GUI storage/API access), NO allow-top-navigation (a browsed page must
* not hijack the GUI). allow-forms/allow-popups/allow-downloads/allow-modals
* keep login flows working; allow-popups-to-escape-sandbox lets OAuth
* popups open as normal tabs (they are cross-origin to the GUI either way).
*/
export const BROWSER_IFRAME_SANDBOX =
'allow-scripts allow-forms allow-popups allow-downloads allow-modals allow-popups-to-escape-sandbox'
export function BrowserView(props: TabComponentProps) {
const { store, tab } = props
// The current address (initialized from the persisted tab.path so a
// reload restores the visited page).
const [url, setUrl] = useState<string | undefined>(tab.path)
const [input, setInput] = useState<string>(tab.path ?? '')
/** Blocked/invalid hint shown under the address bar (null = none). */
const [message, setMessage] = useState<string | null>(null)
/** Address-bar navigation history (in-frame clicks are not tracked). */
const [history, setHistory] = useState<string[]>(tab.path !== undefined ? [tab.path] : [])
const [cursor, setCursor] = useState<number>(tab.path !== undefined ? 0 : -1)
/** Bumped on reload to remount the iframe (also remounts on sandbox flip). */
const [reloadKey, setReloadKey] = useState(0)
/** TEMPORARY sandbox unlock for THIS surface only (never writes the global
* side card setting; lasts until the tab unmounts or the user restores). */
const [localUnlock, setLocalUnlock] = useState(false)
const noSandbox = store.getPrefs().browserNoSandbox === true || localUnlock
/** A site that refuses to be embedded (X-Frame-Options / frame-ancestors):
* the probe verdict shown instead of the blank iframe. */
const [embedBlocked, setEmbedBlocked] = useState<string | null>(null)
/** The user asked to load the refused site anyway (keeps the plain iframe). */
const [forceEmbed, setForceEmbed] = useState(false)
// Probe every navigation (address bar, history, restored path): when the
// target forbids embedding, show the reason + open-in-browser instead of
// the browser's cryptic "refused to connect" blank frame. A failed probe
// (unreachable) keeps the plain iframe.
useEffect(() => {
if (url === undefined) return
let cancelled = false
setEmbedBlocked(null)
setForceEmbed(false)
void api.browserProbe(url).then((probe) => {
if (!cancelled && embeddabilityOf(probe) === 'blocked') setEmbedBlocked(url)
}).catch(() => { /* unreachable: keep the plain iframe */ })
return () => { cancelled = true }
}, [url])
const persist = (nextUrl: string): void => {
let host = nextUrl
try { host = new URL(nextUrl).hostname } catch { /* keep the URL as title */ }
store.reduce(state => patchTab(state, tab.id, { path: nextUrl, title: host }))
}
const navigateTo = (raw: string): void => {
const result = normalizeBrowserUrl(raw, window.location.origin)
if (result.kind === 'ok') {
const next = result.url
setUrl(next)
setInput(next)
setMessage(null)
// Push onto the stack, dropping any stale forward entries.
setHistory(previous => [...previous.slice(0, cursor + 1), next])
setCursor(previous => previous + 1)
setReloadKey(key => key + 1)
persist(next)
return
}
setMessage(result.kind === 'invalid'
? t('browserInvalid')
: result.reason === 'scheme' ? t('browserBlockedScheme')
: t('browserBlockedLoopback'))
}
const goBack = (): void => {
if (cursor <= 0) return
const next = history[cursor - 1]!
setCursor(cursor - 1)
setUrl(next)
setInput(next)
setReloadKey(key => key + 1)
}
const goForward = (): void => {
if (cursor >= history.length - 1) return
const next = history[cursor + 1]!
setCursor(cursor + 1)
setUrl(next)
setInput(next)
setReloadKey(key => key + 1)
}
return (
<div className={css.browser}>
<div className={css.browserBar}>
<button
type="button"
className={css.iconButton}
aria-label={t('browserBack')}
title={t('browserBack')}
disabled={cursor <= 0}
onClick={goBack}
>
<IconChevronLeftOutline14 />
</button>
<button
type="button"
className={css.iconButton}
aria-label={t('browserForward')}
title={t('browserForward')}
disabled={cursor >= history.length - 1}
onClick={goForward}
>
<IconChevronRightOutline14 />
</button>
<button
type="button"
className={css.iconButton}
aria-label={t('refresh')}
title={t('refresh')}
onClick={() => { setReloadKey(key => key + 1) }}
>
<IconRefreshOutline14 />
</button>
<input
className={css.browserInput}
value={input}
placeholder={t('browserPlaceholder')}
spellCheck={false}
onChange={event => { setInput(event.target.value) }}
onKeyDown={event => {
if (event.key === 'Enter') navigateTo(input)
}}
/>
<button
type="button"
className={css.iconButton}
aria-label={t('browserGo')}
title={t('browserGo')}
onClick={() => { navigateTo(input) }}
>
<IconLinkOutline14 />
</button>
<button
type="button"
className={css.iconButton}
aria-label={t('browserOpenExternal')}
title={t('browserOpenExternal')}
disabled={url === undefined}
onClick={() => {
if (url !== undefined) window.open(url, '_blank', 'noopener')
}}
>
<IconRightUpOutline16 size={15} />
</button>
</div>
{message !== null && <div className={css.browserMessage}>{message}</div>}
<SandboxStatusBar
sandboxed={!noSandbox}
local={localUnlock}
dangerCopy={t('browserNoSandboxWarning')}
onUnlock={() => { setLocalUnlock(true) }}
onRestore={() => { setLocalUnlock(false) }}
/>
{url === undefined ? (
<div className={css.browserStart}>{t('browserStart')}</div>
) : embedBlocked !== null && !forceEmbed ? (
<BrowserEmbedBlocked
url={embedBlocked}
onOpenInBrowser={() => { window.open(embedBlocked, '_blank', 'noopener') }}
onLoadAnyway={() => { setForceEmbed(true) }}
/>
) : (
<iframe
key={`${reloadKey}:${noSandbox ? 'ns' : 'sb'}`}
className={css.browserFrame}
src={url}
sandbox={noSandbox ? undefined : BROWSER_IFRAME_SANDBOX}
referrerPolicy="no-referrer"
allow=""
title={url}
/>
)}
</div>
)
}
/**
* The embed-refusal panel: shown when the probed site forbids being
* displayed inside other pages (X-Frame-Options / frame-ancestors) — the
* iframe would only show the browser's "refused to connect" blank. Explains
* the reason and offers the real-browser open plus a load-anyway escape.
* Exported so the copy and the actions are testable without a DOM.
*/
export function BrowserEmbedBlocked(props: {
url: string
onOpenInBrowser: () => void
onLoadAnyway: () => void
}) {
const { url, onOpenInBrowser, onLoadAnyway } = props
let host = url
try { host = new URL(url).hostname } catch { /* keep the raw URL */ }
return (
<div className={css.browserBlocked}>
<IconWarningOutline16 size={16} />
<div className={css.browserBlockedTitle}>{t('browserEmbedBlocked', { host })}</div>
<div className={css.browserBlockedDesc}>{t('browserEmbedBlockedDesc')}</div>
<div className={css.browserBlockedActions}>
<button type="button" className={css.browserBlockedButton} onClick={onOpenInBrowser}>
{t('browserOpenExternal')}
</button>
<button type="button" className={css.browserBlockedButton} onClick={onLoadAnyway}>
{t('browserEmbedAnyway')}
</button>
</div>
</div>
)
}
+60
View File
@@ -0,0 +1,60 @@
/**
* The live sandbox status row of the two built-in web surfaces (HTML
* preview and the browser tab): a green "sandbox on" state with a one-tap
* TEMPORARY unlock, or a RED "sandbox off" state (global setting or the
* temporary unlock) with a restore action.
*
* The temporary unlock is component state only — it never writes the
* global side card setting (`htmlViewerNoSandbox` / `browserNoSandbox`);
* it lasts until the surface unmounts (tab switch / file switch) or the
* user restores the sandbox from the row. When the global setting already
* drops the sandbox, no unlock/restore action is offered (changing the
* global setting is the settings page's job) — the red warning stands.
*/
import clsx from 'clsx'
import { t } from './locales.ts'
import css from './sidebar.module.css'
export function SandboxStatusBar(props: {
/** The effective sandbox state (global pref OR the local temporary unlock). */
sandboxed: boolean
/** Whether the sandbox is off due to the LOCAL temporary unlock (shows the restore action). */
local: boolean
/** The red-state explanation (e.g. "the page runs with full GUI privileges"). */
dangerCopy: string
onUnlock: () => void
onRestore: () => void
}) {
const { sandboxed, local, dangerCopy, onUnlock, onRestore } = props
if (sandboxed) {
const copy = t('sandboxStatusOn')
return (
<div className={clsx(css.sandboxStatus, css.sandboxStatusOn)}>
<span className={css.sandboxDot} />
<span className={css.sandboxStatusText} title={copy}>{copy}</span>
<button
type="button"
className={css.sandboxAction}
onClick={onUnlock}
>
{t('sandboxUnlock')}
</button>
</div>
)
}
return (
<div className={clsx(css.sandboxStatus, css.sandboxStatusOff)}>
<span className={css.sandboxDot} />
<span className={css.sandboxStatusText} title={dangerCopy}>{dangerCopy}</span>
{local && (
<button
type="button"
className={css.sandboxAction}
onClick={onRestore}
>
{t('sandboxRestore')}
</button>
)}
</div>
)
}
+6
View File
@@ -16,6 +16,12 @@
flex-direction: column;
gap: 8px;
width: 100%;
/* Tall inventories (7 tab cards + 9 viewer cards + general rows) must
never be clipped by the settings shell: fill the shell's options area
and scroll inside the section when the content outgrows the panel. */
height: 100%;
min-height: 0;
overflow-y: auto;
}
.section > :last-child {
+8
View File
@@ -32,6 +32,7 @@
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
import {
IconCheckOutline16,
IconCodeOutline16,
IconPanelLeftOutline16,
IconSettingsOutline16,
Input,
@@ -347,6 +348,13 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
<span className={css.suffix}>{t('settingsWidthSuffix')}</span>
</span>
</div>
{renderCard({
title: t('settingsOpenPathTitle'),
desc: t('settingsOpenPathDesc'),
icon: <IconCodeOutline16 size={16} />,
enabled: prefs.interceptOpenPath,
onToggle: (next) => { togglePref({ interceptOpenPath: next }) },
})}
{/* 侧边栏内容: one small card per registered tab type in a responsive
grid; features declaring `settings.toggles` open their settings in
+46 -3
View File
@@ -15,10 +15,11 @@ import { EditorState } from '@codemirror/state'
import { EditorView as CodeMirrorView, keymap, lineNumbers } from '@codemirror/view'
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
import { IconCheckOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import { api } from './api.ts'
import { api, htmlUrl } from './api.ts'
import { languageForPath } from './lang.ts'
import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts'
import { isDarkScheme, subscribeColorScheme } from './theme.ts'
import { SandboxStatusBar } from './SandboxStatusBar.tsx'
import { t } from './locales.ts'
import type { FileViewerProps } from './service.ts'
import css from './sidebar.module.css'
@@ -26,6 +27,15 @@ import css from './sidebar.module.css'
/** Previewable files (rendered output vs source editing). */
type ViewMode = 'preview' | 'edit'
/**
* 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
* could read session data) and NO allow-top-navigation (a previewed page
* must not hijack the GUI). The user can disable the sandbox per-feature
* in the side card settings (warned); the toggle below reflects it.
*/
export const HTML_IFRAME_SANDBOX = 'allow-scripts allow-popups allow-downloads allow-modals'
export function TextEditor(props: FileViewerProps) {
const { scope, path, viewerId, content, truncated } = props
const [mode, setMode] = useState<ViewMode>('preview')
@@ -136,13 +146,23 @@ export function TextEditor(props: FileViewerProps) {
}
const markdown = viewerId === 'markdown'
const html = viewerId === 'html'
const editable = content !== undefined
const saveLabel = saveState === 'saving' ? t('loading') : saveState === 'saved' ? t('saved') : saveState === 'failed' ? t('saveFailed') : ''
// Per-feature sandbox escape hatch: the global side card setting (warned)
// plus a per-surface temporary unlock. The unlock state starts at the
// "default unsafe" pref so a preview can open straight into the red
// unsandboxed state (still restorable from the status row). With the
// sandbox OFF the preview iframe drops its sandbox attribute entirely —
// the previewed page then runs on the GUI's own origin with full session
// access.
const [localUnlock, setLocalUnlock] = useState(() => props.store?.getPrefs().htmlViewerDefaultUnsafe === true)
const htmlNoSandbox = props.store?.getPrefs().htmlViewerNoSandbox === true || localUnlock
return (
<>
<div className={css.editorHeader}>
{markdown && (
{(markdown || html) && (
<div className={css.editorModeToggle}>
<button
type="button"
@@ -178,7 +198,7 @@ export function TextEditor(props: FileViewerProps) {
<>
{truncated === true && mode === 'edit' && <div className={css.editorBanner}>{t('truncation')}</div>}
<div
className={clsx(css.editorCm, markdown && mode === 'preview' && css.editorCmHidden)}
className={clsx(css.editorCm, (markdown || html) && mode === 'preview' && css.editorCmHidden)}
ref={hostRef}
/>
</>
@@ -188,6 +208,29 @@ export function TextEditor(props: FileViewerProps) {
<MarkdownText text={draft ?? content ?? ''} />
</div>
)}
{html && mode === 'preview' && (
<>
<SandboxStatusBar
sandboxed={!htmlNoSandbox}
local={localUnlock}
dangerCopy={t('htmlNoSandboxWarning')}
onUnlock={() => { setLocalUnlock(true) }}
onRestore={() => { setLocalUnlock(false) }}
/>
{/* Route-src (never srcdoc — a srcdoc frame inherits the parent
origin when unsandboxed; the route URL keeps the frame
cross-origin by construction). The preview shows the SAVED
file; the draft is only visible in edit mode. */}
<iframe
className={css.editorHtml}
src={htmlUrl(scope, path)}
sandbox={htmlNoSandbox ? undefined : HTML_IFRAME_SANDBOX}
referrerPolicy="no-referrer"
allow=""
title={path}
/>
</>
)}
</>
)
}
+13
View File
@@ -6,6 +6,8 @@
* hydrating at page load (a detached session would otherwise fail the
* request). Failures surface as {@link SidebarApiError} with the wire code.
*/
import { encodeHtmlUrl } from '../html-route.ts'
import type { BrowserProbeResult } from './browser.ts'
/** One wire failure. */
export class SidebarApiError extends Error {
@@ -152,6 +154,10 @@ export const api = {
patch,
...(expectedRevision !== undefined ? { expectedRevision } : {}),
}),
/** Probe a URL's response headers (the sidebar browser's embeddability
* check; see the host's browser.probe route). */
browserProbe: (url: string, signal?: AbortSignal) =>
call<BrowserProbeResult>('browser.probe', { url }, signal),
}
/** Absolute URL of the media route for one path (images only). */
@@ -172,3 +178,10 @@ function fileUrl(scope: SessionScope, path: string, download: boolean): string {
if (download) params.set('download', '1')
return `/sidebar/file?${params.toString()}`
}
/** Absolute URL of the HTML preview route (see html-route.ts): the path is
* fully encoded so the previewed page's relative assets resolve back into
* the same route with the session scope intact. */
export function htmlUrl(scope: SessionScope, path: string): string {
return encodeHtmlUrl(scope.sessionId, path)
}
+119
View File
@@ -0,0 +1,119 @@
/**
* Pure URL policy for the built-in browser tab: normalize user input into
* an http(s) URL, and refuse destinations that would be dangerous to embed
* in the sidebar iframe. Kept dependency-free so it is unit-testable.
*
* The iframe sandbox (opaque origin, no allow-same-origin / top-navigation)
* is the primary security boundary; this module is the address-bar gate on
* top of it: only http/https may be navigated, and loopback addresses are
* refused so a browsed page cannot probe local services by user action.
* The GUI's OWN origin is explicitly ALLOWED — the user may open the GUI
* itself in the sidebar (debugging, mirroring); the sandbox still renders
* it in an opaque origin with no same-origin privileges, exactly like any
* other site.
*/
/** Why a navigation attempt was refused. */
export type BrowserBlockReason = 'scheme' | 'loopback'
/** Result of normalizing one address-bar input. */
export type BrowserNavigateResult =
| { kind: 'ok'; url: string }
| { kind: 'blocked'; reason: BrowserBlockReason }
| { kind: 'invalid' }
/** One browser.probe wire result (host fetch of the target's headers). */
export interface BrowserProbeResult {
reachable: boolean
/** The final (post-redirect) URL; present when reachable. */
url?: string
status?: number
xFrameOptions?: string
/** The CSP frame-ancestors source list; present when the directive exists. */
frameAncestors?: string[]
}
/** Embeddability verdict of one probe. */
export type Embeddability = 'embeddable' | 'blocked' | 'unknown'
/**
* Decide whether a site can render inside the sidebar iframe. The signals
* are exactly the ones the BROWSER enforces when it refuses an iframe load:
* X-Frame-Options DENY/SAMEORIGIN, or a frame-ancestors directive that does
* not allow `*` ('self' here means the SITE's own origin — never ours, so
* it also blocks the sidebar). A site we could not reach yields 'unknown'
* and the plain iframe stays.
*/
export function embeddabilityOf(probe: BrowserProbeResult): Embeddability {
if (probe.reachable !== true) return 'unknown'
const xfo = probe.xFrameOptions?.trim().toUpperCase()
if (xfo === 'DENY' || xfo === 'SAMEORIGIN') return 'blocked'
if (probe.frameAncestors !== undefined && !probe.frameAncestors.some(source => source === '*')) return 'blocked'
return 'embeddable'
}
/** A loopback hostname (localhost, IPv6 ::1, 127.0.0.0/8, 0.0.0.0). */
export function isLoopbackHostname(hostname: string): boolean {
const host = hostname.replace(/^\[|\]$/g, '').toLowerCase()
if (host === 'localhost' || host === '::1' || host === '0.0.0.0') return true
const parts = host.split('.')
return parts.length === 4
&& parts[0] === '127'
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
}
/**
* Normalize one address-bar input against the navigation policy.
* @param input - raw user text.
* @param selfOrigin - the GUI's own origin (window.location.origin). The GUI
* itself may be browsed in the sidebar (the sandbox keeps it opaque), so it
* is let through BEFORE the loopback check — its host is normally loopback.
*/
/** Schemes that must never reach the iframe, even without `//` (javascript:,
* data:, file:, ...). Host:port lookalikes (example.com:8080) are NOT here —
* they parse as hosts below. */
const FORBIDDEN_SCHEMES = new Set([
'javascript', 'data', 'file', 'about', 'vbscript', 'blob',
'mailto', 'tel', 'ftp', 'ftps', 'ws', 'wss', 'sftp', 'ssh',
'chrome', 'chrome-extension', 'moz-extension', 'edge', 'opera', 'resource', 'view-source',
])
export function normalizeBrowserUrl(input: string, selfOrigin: string): BrowserNavigateResult {
const trimmed = input.trim()
if (trimmed === '') return { kind: 'invalid' }
// Distinguish an explicit scheme from a bare host:port. "example.com:8080"
// would match a naive scheme regex (dots are legal in schemes), so a
// scheme prefix is only honored when it is http(s) or a known-forbidden
// scheme; anything else is treated as a host and gets https://.
const schemeMatch = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(trimmed)
let withScheme: string
if (schemeMatch === null) {
withScheme = `https://${trimmed}`
} else {
const scheme = schemeMatch[1]!.toLowerCase()
if (scheme === 'http' || scheme === 'https') withScheme = trimmed
else if (FORBIDDEN_SCHEMES.has(scheme)) return { kind: 'blocked', reason: 'scheme' }
else withScheme = `https://${trimmed}`
}
let url: URL
try {
url = new URL(withScheme)
} catch {
return { kind: 'invalid' }
}
// The protocol backstop: any URL that still parses to a non-http(s)
// scheme (e.g. ftp://, ws:// — which carry `//` and skip the list) is
// refused here.
if (url.protocol !== 'http:' && url.protocol !== 'https:') return { kind: 'blocked', reason: 'scheme' }
// The GUI's own origin is ALLOWED (the user may browse the GUI itself in
// the sidebar; the sandbox renders it in an opaque origin like any other
// site). It must be checked before the loopback gate because its host is
// normally loopback.
try {
if (url.origin === new URL(selfOrigin).origin) return { kind: 'ok', url: url.href }
} catch {
// Unparsable selfOrigin (never in practice): fall through to the loopback gate.
}
if (isLoopbackHostname(url.hostname)) return { kind: 'blocked', reason: 'loopback' }
return { kind: 'ok', url: url.href }
}
+38 -7
View File
@@ -1,9 +1,10 @@
/**
* The 6 built-in tab descriptors: the plugin registers its own pages
* (explorer / git / terminal / subagent / editor / 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 7 built-in tab descriptors: the plugin registers its own pages
* (explorer / git / terminal / browser / subagent / editor / 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
* `browser:<n>` the same way (no quota).
*/
import { IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../../context-types.ts'
@@ -16,7 +17,8 @@ import { TerminalView } from '../TerminalView.tsx'
import { GitView } from '../GitView.tsx'
import { DiffTab } from '../DiffTab.tsx'
import { SubagentView } from '../SubagentView.tsx'
import { IconTerminalOutline16, IconDiffOutline16 } from '../icons.tsx'
import { BrowserView } from '../BrowserView.tsx'
import { IconTerminalOutline16, IconDiffOutline16, IconGlobeOutline16 } from '../icons.tsx'
import type { TabDescriptor } from '../service.ts'
/** How many UI-owned terminals may be open at once (agent-owned ones are uncapped). */
@@ -29,7 +31,7 @@ function uiTerminalCount(state: SidebarState): number {
.filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length
}
/** The 6 built-in tab descriptors. */
/** The 7 built-in tab descriptors. */
export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
return [
{
@@ -130,6 +132,35 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
<TerminalView scope={scope} tabId={tab.id} store={store} />
),
},
{
id: 'browser',
title: () => t('browser'),
icon: (size: number) => <IconGlobeOutline16 size={size} />,
order: 50,
// Declarative settings: the sandbox escape hatch and the link
// takeover render under this tab's row in the Side card settings
// page (the sandbox one is warned on).
settings: {
toggles: [{
key: 'browserNoSandbox',
title: () => t('settingsBrowserSandboxTitle'),
desc: () => t('settingsBrowserSandboxDesc'),
}, {
key: 'browserInterceptLinks',
title: () => t('settingsBrowserLinksTitle'),
desc: () => t('settingsBrowserLinksDesc'),
}],
},
createTab: (state) => ({
tab: {
id: `browser:${state.nextBrowser}`,
type: 'browser',
title: t('browser'),
},
patch: { nextBrowser: state.nextBrowser + 1 },
}),
component: (props) => <BrowserView {...props} />,
},
{
id: 'diff',
title: () => t('git'),
+28 -5
View File
@@ -1,8 +1,8 @@
/**
* The 8 built-in file viewer descriptors: every preview surface is a
* registered viewer (image / pdf / docx / xlsx / pptx / markdown / code /
* binary-download), exactly like external plugins register theirs. The
* `binary-download` viewer sniffs NUL bytes via `detect` for unknown
* The 9 built-in file viewer descriptors: every preview surface is a
* registered viewer (image / pdf / docx / xlsx / pptx / markdown / html /
* code / binary-download), exactly like external plugins register theirs.
* The `binary-download` viewer sniffs NUL bytes via `detect` for unknown
* binaries and serves doc/xls/ppt by extension; `code` is the catch-all
* (`exts: []`, lowest priority) that claims any file no other viewer did.
*
@@ -23,12 +23,13 @@ import {
IconDocxOutline16,
IconXlsxOutline16,
IconPptxOutline16,
IconHtmlOutline16,
} from '../icons.tsx'
import type { FileViewerDescriptor } from '../service.ts'
import { t } from '../locales.ts'
import css from '../sidebar.module.css'
/** The 8 built-in file viewer descriptors. */
/** The 9 built-in file viewer descriptors. */
export function builtinViewers(): readonly FileViewerDescriptor[] {
return [
{
@@ -91,6 +92,28 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
fetchStrategy: 'fsRead',
component: (props) => <TextEditor {...props} />,
},
{
id: 'html',
title: () => t('viewerHtml'),
icon: (size: number) => <IconHtmlOutline16 size={size} />,
exts: ['html', 'htm'],
fetchStrategy: 'fsRead',
// Declarative settings: the sandbox escape hatch and the default-unsafe
// start state render under this viewer's row in the Side card settings
// page (both warned on).
settings: {
toggles: [{
key: 'htmlViewerNoSandbox',
title: () => t('settingsHtmlSandboxTitle'),
desc: () => t('settingsHtmlSandboxDesc'),
}, {
key: 'htmlViewerDefaultUnsafe',
title: () => t('settingsHtmlDefaultUnsafeTitle'),
desc: () => t('settingsHtmlDefaultUnsafeDesc'),
}],
},
component: (props) => <TextEditor {...props} />,
},
{
id: 'code',
title: () => t('viewerCode'),
+18
View File
@@ -83,3 +83,21 @@ export const IconMarkdownOutline16 = ({ size = 16, className }: IconProps) => (
<path d="M4 10.5V5.5l2 2.5 2-2.5v5M9.5 10.5v-5l2 2.5 2-2.5v5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
/** HTML viewer glyph: a document frame with a "‹/›" tag pair. */
export const IconHtmlOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.5 1.5h6.5L13.5 5v9.5h-10z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
<path d="M9.5 1.5V5h4" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
<path d="M5.6 13.2 4.2 10l1.4-3.2M7.4 6.8 8.8 10l-1.4 3.2" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
/** Browser tab glyph: a globe with meridians. */
export const IconGlobeOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="6.5" stroke="currentColor" strokeWidth="1.5" />
<ellipse cx="8" cy="8" rx="2.8" ry="6.5" stroke="currentColor" strokeWidth="1.5" />
<path d="M1.5 8h13M8 1.5c-2.4 1.8-2.4 11.2 0 13M8 1.5c2.4 1.8 2.4 11.2 0 13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
+39 -2
View File
@@ -15,7 +15,8 @@ import { createSidebarStore } from './state.ts'
import { createBetterSidebarService } from './service.ts'
import { registerBuiltins } from './builtins/index.ts'
import { Sidebar } from './Sidebar.tsx'
import { registerTurnTailInterception } from './intercept.tsx'
import { registerOpenPathInterception, registerTurnTailInterception } from './intercept.tsx'
import { registerLinkInterception } from './link-intercept.ts'
import { loadPrefs } from './prefs.ts'
import { SideCardSection } from './SideCardSection.tsx'
import { api } from './api.ts'
@@ -24,7 +25,7 @@ import css from './sidebar.module.css'
import './layout.css'
/** Services required before mounting (provided by the client runtime). */
export const inject = ['slots', 'sessions', 'connection']
export const inject = ['slots', 'sessions', 'connection', 'workspaces']
/**
* Error boundary over the sidebar tree: a render error must never blank the
@@ -146,6 +147,42 @@ export function apply(ctx: Context): void {
'dsh-better-sidebar: turn-tail interception',
)
ctx.effect(
() => {
try {
return registerOpenPathInterception(ctx, sidebarStore)
} catch (error) {
fail('interception', error)
return undefined
}
},
'dsh-better-sidebar: open-path interception',
)
ctx.effect(
() => {
try {
// External http(s) links in the chat/GUI open the sidebar browser
// instead of a new window (gated on the browserInterceptLinks pref
// AND the browser tab's enable switch; Ctrl/Cmd+click bypasses).
return registerLinkInterception({
takeoverEnabled: () => sidebarStore.getPrefs().browserInterceptLinks !== false
&& sidebarStore.getPrefs().tabsEnabled['browser'] !== false,
openInSidebar: (url) => {
let title: string | undefined
try { title = new URL(url).hostname } catch { /* keep the default title */ }
ctx.betterSidebar?.openTab({ type: 'browser', url, title })
},
selfOrigin: window.location.origin,
})
} catch (error) {
fail('interception', error)
return undefined
}
},
'dsh-better-sidebar: link interception',
)
// The "Side card" settings section: appears in the DSH Settings shell
// once the shell's declaration is on the ledger (slots.inject waits for
// it); the section reads/writes the prefs through the plugin's own
+18
View File
@@ -11,6 +11,7 @@ import type { Context } from '../context-types.ts'
import type { SidebarStore } from './state.ts'
import { t } from './locales.ts'
import { resolveSidebarPath, selectProducedFiles } from './produced-files.ts'
import { wrapOpenPath } from './openpath-intercept.ts'
import css from './sidebar.module.css'
/** Open a file in the sidebar's editor (used by the intercepted row and the explorer). */
@@ -74,3 +75,20 @@ export function registerTurnTailInterception(ctx: Context, store: SidebarStore):
}),
}, SidebarProducedFiles)
}
/**
* Register the chat file-open interception: wraps `ctx.workspaces.openPath`
* — the single funnel every chat-side file open goes through (tool-row path
* links, the produced-files row, prose mentions) — so opens land in the
* sidebar editor instead of the Host OS. Gated by BOTH the `interceptOpenPath`
* pref and the editor tab's enable switch; declined opens fall through to
* the original method. Returns the disposer restoring the original (HMR-safe).
*/
export function registerOpenPathInterception(ctx: Context, store: SidebarStore): () => void {
return wrapOpenPath(ctx.workspaces, {
takeoverEnabled: () => store.getPrefs().interceptOpenPath !== false
&& store.getPrefs().tabsEnabled['editor'] !== false,
currentSessionId: () => ctx.sessions.list.getSnapshot().current,
openInSidebar: (path, sessionId) => { openSidebarFile(ctx, store, sessionId, path) },
})
}
+66
View File
@@ -0,0 +1,66 @@
/**
* Chat/GUI external-link interception: clicking an http(s) link that points
* OUTSIDE the GUI (chat messages, tool rows, prose mentions) opens the
* sidebar browser instead of a new browser tab. Gated by BOTH the
* `browserInterceptLinks` pref and the browser tab's enable switch; a
* Ctrl/Cmd/Shift/Alt-modified click always bypasses the takeover so the
* user can still force a real browser tab.
*
* Only the GUI's OWN document is watched — links inside the browser tab's
* sandboxed iframe live in another document and never bubble here (and
* their clicks must keep working inside the sidebar).
*/
/** The pure decision: the URL to open in the sidebar, or null to let the
* click fall through. Extracted so the policy is unit-testable without a
* DOM. `anchorHref` must be the ABSOLUTE href (`<a>.href` already is). */
export function shouldInterceptLink(anchorHref: string, selfOrigin: string): string | null {
let url: URL
try {
url = new URL(anchorHref)
} catch {
return null
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
// Same-origin links are GUI-internal navigation (settings pages, tool
// docs) — never routed into the sidebar browser.
try {
if (url.origin === new URL(selfOrigin).origin) return null
} catch {
// Unparsable selfOrigin (never in practice): intercept defensively.
}
return url.href
}
/** Whether a left-click may be taken over (unmodified left click only). */
export function isPlainLeftClick(event: { button: number; metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean }): boolean {
return event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey
}
/**
* Register the document-level click capture that funnels external links
* into the sidebar browser. Returns the disposer (HMR-safe).
*/
export function registerLinkInterception(opts: {
takeoverEnabled: () => boolean
/** Open the sidebar browser tab at `url` (the caller resolves the session). */
openInSidebar: (url: string) => void
/** The GUI's own origin (window.location.origin at registration). */
selfOrigin: string
}): () => void {
const onClick = (event: MouseEvent): void => {
if (!isPlainLeftClick(event)) return
if (event.defaultPrevented) return
if (!opts.takeoverEnabled()) return
const target = event.target
if (target === null || typeof (target as Element).closest !== 'function') return
const anchor = (target as Element).closest('a[href]') as HTMLAnchorElement | null
if (anchor === null) return
const url = shouldInterceptLink(anchor.href, opts.selfOrigin)
if (url === null) return
event.preventDefault()
opts.openInSidebar(url)
}
document.addEventListener('click', onClick, true)
return () => { document.removeEventListener('click', onClick, true) }
}
+58
View File
@@ -105,6 +105,8 @@ const zh = {
settingsWidthTitle: '默认宽度占比',
settingsWidthDesc: '新建会话时侧边卡片占窗口宽度的百分比 (20–60)',
settingsWidthSuffix: '%',
settingsOpenPathTitle: '聊天区文件在侧边栏打开',
settingsOpenPathDesc: '在聊天里点击文件链接(工具行、产物列表、文件提及)时,在侧边栏编辑器中打开,不再调用系统默认应用',
settingsSaveFailed: '保存失败',
settingsConflict: '设置已被其他窗口修改,请重试',
binaryNoPreview: '此文件类型不支持预览',
@@ -134,6 +136,33 @@ const zh = {
viewerMarkdown: 'Markdown',
viewerCode: '代码',
viewerBinary: '二进制下载',
viewerHtml: 'HTML',
browser: '浏览器',
browserPlaceholder: '输入网址,例如 example.com',
browserGo: '前往',
browserBack: '后退',
browserForward: '前进',
browserStart: '输入网址开始浏览(沙箱模式)',
browserBlockedScheme: '已阻止:仅支持 http/https 链接',
browserBlockedLoopback: '已阻止:不允许在浏览器中访问本机或内部地址',
browserInvalid: '无效的网址',
browserNoSandboxWarning: '沙箱已关闭:当前页面与界面同源,拥有完整会话权限(可在设置中恢复)',
htmlNoSandboxWarning: '沙箱已关闭:此 HTML 与界面同源,可读取会话文件与内部接口(可在设置中恢复)',
sandboxStatusOn: '沙箱模式:已启用 · 页面无法访问界面数据与本地文件,登录态与第三方 Cookie 可能不可用',
sandboxUnlock: '临时解锁(不安全)',
sandboxRestore: '恢复沙箱',
settingsHtmlDefaultUnsafeTitle: 'HTML 预览默认以非沙箱模式打开(不安全)',
settingsHtmlDefaultUnsafeDesc: '开启后,每次打开 HTML 文件时预览默认处于非沙箱状态(与界面同源,可读取会话文件与内部接口);可在状态行临时恢复沙箱',
settingsHtmlSandboxTitle: '关闭 HTML 预览沙箱(不安全)',
settingsHtmlSandboxDesc: '关闭后,预览的 HTML 将与界面同源运行,可读取会话文件、本地存储并调用内部接口。仅对完全可信的文件开启',
settingsBrowserSandboxTitle: '关闭浏览器沙箱(不安全)',
settingsBrowserSandboxDesc: '关闭后,访问的任何网站都将与界面同源运行,可读取会话数据并冒充你的登录状态。仅对完全可信的站点开启',
settingsBrowserLinksTitle: '聊天区外链在侧边栏打开',
settingsBrowserLinksDesc: '开启后,点击聊天或界面中的 http/https 外链时在侧边栏浏览器中打开,不再弹出新窗口;Ctrl/Cmd 点击可临时放行',
browserOpenExternal: '在浏览器中打开',
browserEmbedBlocked: '{host} 拒绝了嵌入请求',
browserEmbedBlockedDesc: '该站点通过 X-Frame-Options / frame-ancestors 禁止在其它页面中显示,无法在侧边栏内加载。可在浏览器中直接打开',
browserEmbedAnyway: '仍然加载',
subagent: '子代理',
openSubagent: '子代理',
subagentMainAgent: '主代理',
@@ -251,6 +280,8 @@ const en: Record<keyof typeof zh, string> = {
settingsWidthTitle: 'Default width share',
settingsWidthDesc: 'The side card\'s default share of the window width for new conversations (20–60)',
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'Open chat files in the sidebar',
settingsOpenPathDesc: 'Open file links in the chat (tool rows, produced files, mentions) in the sidebar editor instead of the system default app',
settingsSaveFailed: 'Failed to save',
settingsConflict: 'The setting changed in another window — please retry',
binaryNoPreview: 'This file type cannot be previewed',
@@ -280,6 +311,33 @@ const en: Record<keyof typeof zh, string> = {
viewerMarkdown: 'Markdown',
viewerCode: 'Code',
viewerBinary: 'Binary download',
viewerHtml: 'HTML',
browser: 'Browser',
browserPlaceholder: 'Enter a URL, e.g. example.com',
browserGo: 'Go',
browserBack: 'Back',
browserForward: 'Forward',
browserStart: 'Enter a URL to start browsing (sandbox mode)',
browserBlockedScheme: 'Blocked: only http/https URLs are allowed',
browserBlockedLoopback: 'Blocked: local and internal addresses cannot be browsed here',
browserInvalid: 'Invalid URL',
browserNoSandboxWarning: 'Sandbox off: the current page runs with full GUI privileges (re-enable in settings)',
htmlNoSandboxWarning: 'Sandbox off: this HTML runs with full GUI privileges (re-enable in settings)',
sandboxStatusOn: 'Sandbox mode: on · pages cannot access the GUI\'s data or local files; logins and third-party cookies may not work',
sandboxUnlock: 'Temporarily disable (unsafe)',
sandboxRestore: 'Restore sandbox',
settingsHtmlDefaultUnsafeTitle: 'Open HTML previews unsandboxed by default (unsafe)',
settingsHtmlDefaultUnsafeDesc: 'When on, every newly opened HTML preview starts in the unsandboxed state (same origin as the GUI — it can read session files and internal APIs); the status row still offers a one-tap restore',
settingsHtmlSandboxTitle: 'Disable HTML preview sandbox (unsafe)',
settingsHtmlSandboxDesc: 'With the sandbox off, previewed HTML runs with the same origin as the GUI: it can read session files, local storage and call internal APIs. Only enable for fully trusted files',
settingsBrowserSandboxTitle: 'Disable browser sandbox (unsafe)',
settingsBrowserSandboxDesc: 'With the sandbox off, any visited site runs with the same origin as the GUI: it can read session data and act as your logged-in session. Only enable for fully trusted sites',
settingsBrowserLinksTitle: 'Open chat external links in the sidebar',
settingsBrowserLinksDesc: 'When on, clicking an http/https external link in the chat or GUI opens the sidebar browser instead of a new window; Ctrl/Cmd+click always bypasses',
browserOpenExternal: 'Open in browser',
browserEmbedBlocked: '{host} refused to be embedded',
browserEmbedBlockedDesc: 'The site forbids being displayed inside other pages (X-Frame-Options / frame-ancestors), so it cannot load in the sidebar. Open it directly in your browser instead.',
browserEmbedAnyway: 'Load anyway',
subagent: 'Subagents',
openSubagent: 'Subagents',
subagentMainAgent: 'Main agent',
+62
View File
@@ -0,0 +1,62 @@
/**
* Interception of the chat's file-open funnel. The client runtime's
* `ctx.workspaces.openPath` is the SINGLE door every chat-side file open goes
* through — ui-conversation's apply.ts resolves the path against the session
* cwd and calls it for tool-row path links, the produced-files row, and
* prose file mentions alike (verified against the DSH source:
* `packages/client/ui-conversation/src/client/apply.ts` is the only
* production caller). Wrapping that one method reroutes those opens into the
* sidebar editor instead of the Host OS — no DSH modification needed.
*
* The wrapper is dependency-free by design (no React / ui-primitives), so
* the takeover logic is unit-testable and the file stays importable from the
* test runtime.
*/
/** The one service method the wrapper replaces (mirror of the runtime IWorkspaces). */
export interface OpenPathService {
openPath(path: string): Promise<void>
}
/** Per-call decisions the wrapper needs (wired to the store + ctx in the client half). */
export interface OpenPathInterceptDeps {
/**
* Whether to take over this call: the `interceptOpenPath` pref AND the
* editor tab's own enable switch must both be on (an editor that cannot
* open must not swallow opens — they fall through to the Host).
*/
takeoverEnabled(): boolean
/** The session whose scope the sidebar editor loads the file in (current session). */
currentSessionId(): string | undefined
/** Route the open into the sidebar editor (the established openSidebarFile). */
openInSidebar(path: string, sessionId: string): void
}
/**
* Wrap `workspaces.openPath`: intercepted calls open the file in the sidebar
* editor instead of the Host OS and resolve as success (the original's
* callers ignore the result); anything that declines falls through to the
* original method untouched.
* @param workspaces - the client workspaces service to wrap.
* @param deps - per-call takeover decisions.
* @returns the disposer restoring the original method (HMR-safe).
*/
export function wrapOpenPath(workspaces: OpenPathService, deps: OpenPathInterceptDeps): () => void {
// The RAW method reference (never a bound copy): restore must put back the
// exact original so a chain of wrappers (other plugins wrapping the same
// method) keeps working across disposals in any order.
const original = workspaces.openPath
workspaces.openPath = (path: string): Promise<void> => {
if (deps.takeoverEnabled()) {
const sessionId = deps.currentSessionId()
if (sessionId !== undefined) {
deps.openInSidebar(path, sessionId)
return Promise.resolve()
}
}
return original.call(workspaces, path)
}
return () => {
workspaces.openPath = original
}
}
+15
View File
@@ -43,6 +43,21 @@ export function parsePrefs(value: unknown): SidebarPrefs {
agentTerminalTools: typeof record.agentTerminalTools === 'boolean'
? record.agentTerminalTools
: SIDEBAR_PREFS_DEFAULTS.agentTerminalTools,
interceptOpenPath: typeof record.interceptOpenPath === 'boolean'
? record.interceptOpenPath
: SIDEBAR_PREFS_DEFAULTS.interceptOpenPath,
htmlViewerNoSandbox: typeof record.htmlViewerNoSandbox === 'boolean'
? record.htmlViewerNoSandbox
: SIDEBAR_PREFS_DEFAULTS.htmlViewerNoSandbox,
htmlViewerDefaultUnsafe: typeof record.htmlViewerDefaultUnsafe === 'boolean'
? record.htmlViewerDefaultUnsafe
: SIDEBAR_PREFS_DEFAULTS.htmlViewerDefaultUnsafe,
browserNoSandbox: typeof record.browserNoSandbox === 'boolean'
? record.browserNoSandbox
: SIDEBAR_PREFS_DEFAULTS.browserNoSandbox,
browserInterceptLinks: typeof record.browserInterceptLinks === 'boolean'
? record.browserInterceptLinks
: SIDEBAR_PREFS_DEFAULTS.browserInterceptLinks,
tabsEnabled: booleanMapOf(record.tabsEnabled),
viewersEnabled: booleanMapOf(record.viewersEnabled),
}
+32 -15
View File
@@ -22,7 +22,7 @@
import type { ReactNode } from 'react'
import type { Context } from '../context-types.ts'
import {
activateTab, allLeaves, closeTab as closeTabReducer, openTabInActivePane,
activateTab, allLeaves, closeTab as closeTabReducer, openTabInActivePane, patchTab,
type SidebarState, type SidebarStore, type SidebarTab,
} from './state.ts'
import type { SessionScope } from './api.ts'
@@ -194,9 +194,12 @@ export interface BetterSidebarService {
* Open a tab (used by external tabs and the + menu). `title` overrides
* the descriptor's title when given (the editor tab shows the file name);
* when the descriptor provides `createTab` it mints the tab itself and
* `title`/`path`/`id` are ignored. A disabled tab type is a no-op.
* `title`/`path`/`id` are ignored. `url` lands the tab with its `path`
* pre-set to the URL (the browser tab's navigation seed; the caller
* usually pairs it with a hostname `title`). A disabled tab type is a
* no-op.
*/
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string; url?: string }): void
/** Close a tab by id. */
closeTab(tabId: string): void
/** Subscribe to registry changes (register/dispose). */
@@ -297,7 +300,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
return undefined
}
const openTab = (seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void => {
const openTab = (seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string; url?: string }): void => {
// A type the user disabled in settings never opens — neither from the
// + menu nor from derived flows (file opens, subagent auto-open,
// external plugins). Already-open tabs keep rendering.
@@ -309,22 +312,36 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
const descriptor = tabs.get(seed.type)
if (descriptor === undefined) return state
// Let the descriptor mint the tab (terminal's nextTerminal bump, etc.).
let tab: SidebarTab
let next: SidebarState
if (descriptor.createTab !== undefined) {
const result = descriptor.createTab(state)
if (result === null) return state
const next = applyDedupe(state, result.tab, descriptor)
return result.patch !== undefined ? { ...next, ...result.patch } : next
tab = result.tab
next = applyDedupe(state, result.tab, descriptor)
if (result.patch !== undefined) next = { ...next, ...result.patch }
} else {
tab = {
id: seed.id ?? seed.type,
type: seed.type,
// A caller-provided title wins (the editor shows the file name);
// otherwise the descriptor's (possibly i18n) title is the default.
title: seed.title ?? (typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title),
...(seed.path !== undefined ? { path: seed.path } : {}),
...(seed.diff !== undefined ? { diff: seed.diff } : {}),
}
next = applyDedupe(state, tab, descriptor)
}
const tab: SidebarTab = {
id: seed.id ?? seed.type,
type: seed.type,
// A caller-provided title wins (the editor shows the file name);
// otherwise the descriptor's (possibly i18n) title is the default.
title: seed.title ?? (typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title),
...(seed.path !== undefined ? { path: seed.path } : {}),
...(seed.diff !== undefined ? { diff: seed.diff } : {}),
// A URL seed pre-fills the tab's path (the browser tab navigates to it
// on mount). An explicit seed.title also wins over a createTab-minted
// default title (e.g. the sidebar-browser's hostname title).
if (seed.url !== undefined) {
return patchTab(next, tab.id, {
path: seed.url,
...(seed.title !== undefined ? { title: seed.title } : {}),
})
}
return applyDedupe(state, tab, descriptor)
return next
})
}
+180
View File
@@ -703,6 +703,186 @@
background: var(--dsw-alias-state-warn-tertiary);
}
/* Live sandbox status row (HTML preview / browser tab): GREEN while the
sandbox is on (with the one-tap temporary unlock), RED while it is off
(global setting or temporary unlock) — the red state is the persistent
warning that the page runs on the GUI's own origin. */
.sandboxStatus {
flex: none;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 10px;
font: var(--dsw-font-xxxs-11);
}
.sandboxStatusOn {
color: var(--dsw-alias-label-secondary);
background: var(--dsw-alias-bg-layer-1);
border-bottom: 1px solid var(--dsw-alias-border-l1);
}
.sandboxStatusOff {
color: var(--dsw-alias-state-error-primary);
background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent);
}
.sandboxDot {
flex: none;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--dsw-alias-state-success-primary);
}
.sandboxStatusOff .sandboxDot {
background: var(--dsw-alias-state-error-primary);
}
.sandboxStatusText {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sandboxAction {
flex: none;
padding: 2px 8px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 6px;
background: transparent;
font: inherit;
color: inherit;
cursor: pointer;
}
.sandboxAction:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
/* The HTML preview iframe: fills the editor body. Route-src (never srcdoc)
so the frame is cross-origin by construction even if the sandbox
attribute is ever dropped; sandboxed by default (see TextEditor). */
.editorHtml {
flex: 1;
min-height: 0;
width: 100%;
border: none;
background: var(--dsw-alias-bg-base);
}
/* ── Browser tab ──────────────────────────────────────────────────────── */
.browser {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.browserBar {
flex: none;
display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
}
.browserInput {
flex: 1;
min-width: 0;
height: 28px;
padding: 0 10px;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 6px;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxs-12);
}
.browserInput:focus {
outline: none;
border-color: var(--dsw-alias-border-l2);
}
.browserMessage {
flex: none;
padding: 4px 12px;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-state-warn-label);
background: var(--dsw-alias-state-warn-tertiary);
}
.browserFrame {
flex: 1;
min-height: 0;
width: 100%;
border: none;
background: var(--dsw-alias-bg-base);
}
.browserStart {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
text-align: center;
font: var(--dsw-font-xs-13);
color: var(--dsw-alias-label-tertiary);
}
/* Embed-refusal panel (X-Frame-Options / frame-ancestors): replaces the
blank refused iframe with the reason + open-in-browser actions. */
.browserBlocked {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 24px;
text-align: center;
color: var(--dsw-alias-state-warn-primary);
}
.browserBlockedTitle {
font: var(--dsw-font-xxs-strong-12);
color: var(--dsw-alias-label-primary);
}
.browserBlockedDesc {
max-width: 280px;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-secondary);
}
.browserBlockedActions {
display: flex;
gap: 8px;
margin-top: 6px;
}
.browserBlockedButton {
padding: 4px 12px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 6px;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxxs-11);
cursor: pointer;
}
.browserBlockedButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.editorCm {
flex: 1;
min-height: 0;
+40
View File
@@ -61,6 +61,8 @@ export interface SidebarState {
activePane: string | null
/** Monotonic terminal tab counter (ids survive reloads). */
nextTerminal: number
/** Monotonic browser tab counter (ids survive reloads; mirrors nextTerminal). */
nextBrowser: number
/** Explorer expansion set (absolute directory paths). */
expanded: string[]
splits: SplitNode
@@ -129,6 +131,7 @@ export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seedEx
width,
activePane: leaf.id,
nextTerminal: 1,
nextBrowser: 1,
expanded: [],
splits: leaf,
}
@@ -308,6 +311,36 @@ export function activateTab(state: SidebarState, paneId: string, tabId: string):
}
}
/** Update the display fields of one open tab (title / path) without
* re-opening it. The browser tab persists its current URL and hostname
* title through this reducer so a reload restores the visited page. A
* missing tab id is a no-op. */
export function patchTab(
state: SidebarState,
tabId: string,
patch: { title?: string; path?: string },
): SidebarState {
let changed = false
const walk = (node: SplitNode): SplitNode => {
if (node.kind === 'leaf') {
const tabs = node.tabs.map(tab => {
if (tab.id !== tabId) return tab
changed = true
return {
...tab,
...(patch.title !== undefined ? { title: patch.title } : {}),
...(patch.path !== undefined ? { path: patch.path } : {}),
}
})
return tabs === node.tabs ? node : { ...node, tabs }
}
const children = node.children.map(walk)
return children === node.children ? node : { ...node, children }
}
const splits = walk(state.splits)
return changed ? { ...state, splits } : state
}
/**
* Land a tab in the active pane (or focus its existing instance by id).
* Dedup strategies (single-instance, per-path, per-change) are owned by the
@@ -572,6 +605,12 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined {
if (typeof record.nextTerminal !== 'number' || !Number.isInteger(record.nextTerminal) || record.nextTerminal < 1) {
return undefined
}
// nextBrowser arrived in a later build; a missing or malformed value on an
// OLDER persisted state defaults to 1 so existing layouts keep loading
// (unlike nextTerminal, which is strict — it predates the v1 shape).
const nextBrowser = typeof record.nextBrowser === 'number' && Number.isInteger(record.nextBrowser) && record.nextBrowser >= 1
? record.nextBrowser
: 1
if (typeof record.activePane !== 'string' && record.activePane !== null) return undefined
if (!Array.isArray(record.expanded) || record.expanded.some(item => typeof item !== 'string')) return undefined
const reid = new Map<string, string>()
@@ -585,6 +624,7 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined {
// new tabs still land in the pane the user was using.
activePane: typeof record.activePane === 'string' ? (reid.get(record.activePane) ?? record.activePane) : null,
nextTerminal: record.nextTerminal,
nextBrowser,
expanded: record.expanded as string[],
splits,
}
+5
View File
@@ -80,6 +80,11 @@ export const PrefsSchema: z<SidebarPrefs> = z.object({
defaultWidthPercent: z.number().step(1).min(WIDTH_PERCENT_MIN).max(WIDTH_PERCENT_MAX).default(WIDTH_PERCENT_DEFAULT),
autoOpenSubagent: z.boolean().default(true),
agentTerminalTools: z.boolean().default(false),
interceptOpenPath: z.boolean().default(true),
htmlViewerNoSandbox: z.boolean().default(false),
htmlViewerDefaultUnsafe: z.boolean().default(false),
browserNoSandbox: z.boolean().default(false),
browserInterceptLinks: z.boolean().default(true),
// Per-feature enable switches are OPEN maps (any tab/viewer id, built-in or
// external): an absent key means enabled, so old documents resolve to {}
// (everything on) with no migration. Non-boolean values fail validation.
+12
View File
@@ -232,6 +232,17 @@ export interface SidebarConversation {
}
}
/**
* The client workspaces service face (mirror of the runtime IWorkspaces). Only
* the chat's file-open funnel is touched: `openPath` hands an absolute path
* to the Host OS's default application, and every chat-side file open
* (tool rows, produced-files, prose mentions) funnels through it.
*/
export interface SidebarWorkspacesService {
/** Open a filesystem path with the Host operating system's default application. */
openPath(path: string): Promise<void>
}
/**
* The invariant service face (mirror of @deepseek-ai/dsh-invariants'
* InvariantService). The upstream augmentation does not reach this Context
@@ -307,6 +318,7 @@ declare module 'cordis' {
connection: SidebarConnectionHandle
loader: SidebarLoader
slots: SidebarSlotsService
workspaces: SidebarWorkspacesService
settings: SidebarSettingsService
invariants: SidebarInvariantsService
tools: SidebarToolsService
+70
View File
@@ -0,0 +1,70 @@
/**
* Pure URL vocabulary of the /sidebar/html route (HTML previewer).
*
* Why path-encoded parameters instead of a query string: the previewed
* page resolves its relative assets (./style.css, img/x.png) against the
* document URL, and the WHATWG URL algorithm DROPS the query of a
* path-relative reference — `/sidebar/html?a=1&path=/a/b/` + `./style.css`
* would lose the session scope and the route would reject the asset.
* Encoding everything into the URL path keeps relative resolution inside
* the same route with every request self-contained:
*
* /sidebar/html/<sessionId>/<absolute-path segments, encodeURIComponent'd>
* /sidebar/html/S/Users/me/proj/index.html
* + ./style.css → /sidebar/html/S/Users/me/proj/style.css
* Windows: C:\Users\me\a.html → /sidebar/html/S/C%3A/Users/me/a.html
*
* This module is intentionally dependency-free (no node imports, no wire
* helpers) so the client bundle can import `encodeHtmlUrl` without tripping
* the build-time purity gate; the host converts decode failures into
* SidebarError responses at the route boundary.
*/
/** One decoded route reference. */
export interface HtmlRouteRef {
sessionId: string
/** Absolute file path (leading slash; Windows drives keep their colon). */
path: string
}
/** Decode outcome: the reference, or a client-error description. */
export type HtmlDecodeResult =
| { ok: true; ref: HtmlRouteRef }
| { ok: false; status: 400 | 404; message: string }
/** The route prefix both encoders/decoders agree on. */
export const HTML_ROUTE_PREFIX = '/sidebar/html/'
/** Build the route URL for one absolute file path (client + tests). */
export function encodeHtmlUrl(sessionId: string, path: string): string {
const segments = path.split(/[\\/]+/).filter(segment => segment !== '')
return `${HTML_ROUTE_PREFIX}${encodeURIComponent(sessionId)}/${segments.map(encodeURIComponent).join('/')}`
}
/**
* Decode a route pathname into the session + absolute file path. Rejects
* a wrong prefix (404), an empty or double-slash path, malformed percent
* encoding, and a missing sessionId or file path (400). The caller still
* must bound the decoded path with requireAbsolute + isWithin(cwd) — a
* decoded `..` segment resolves outside the cwd and is refused there.
*/
export function decodeHtmlUrl(pathname: string): HtmlDecodeResult {
if (!pathname.startsWith(HTML_ROUTE_PREFIX)) {
return { ok: false, status: 404, message: 'not an html route' }
}
const rest = pathname.slice(HTML_ROUTE_PREFIX.length)
if (rest === '' || rest.includes('//')) {
return { ok: false, status: 400, message: 'invalid html route path' }
}
let segments: string[]
try {
segments = rest.split('/').map(segment => decodeURIComponent(segment))
} catch {
return { ok: false, status: 400, message: 'malformed URL encoding' }
}
const [sessionId, ...pathSegments] = segments
if (sessionId === undefined || sessionId === '' || pathSegments.length === 0 || pathSegments.some(segment => segment === '')) {
return { ok: false, status: 400, message: 'sessionId and file path are required' }
}
return { ok: true, ref: { sessionId, path: `/${pathSegments.join('/')}` } }
}
+118 -1
View File
@@ -26,7 +26,9 @@ import {
type SidebarPrefs,
} from './config.ts'
import { isWithin, parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts'
import { isTrustedApiRequest } from './trust-fence.ts'
import { decodeHtmlUrl } from './html-route.ts'
import { extractFrameAncestors } from './browser-probe.ts'
import { isTrustedApiRequest, isLoopbackHostname } from './trust-fence.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'
@@ -68,6 +70,8 @@ const MEDIA_TYPES: Record<string, string> = {
'.ico': 'image/x-icon',
'.avif': 'image/avif',
'.pdf': 'application/pdf',
'.html': 'text/html',
'.htm': 'text/html',
}
/** Content type served by /sidebar/file (binary-safe fallback for unknowns). */
@@ -350,6 +354,55 @@ function buildApi(
throw new SidebarError('settings-rejected', error instanceof Error ? error.message : String(error), 400)
}
},
// Probe a URL's RESPONSE HEADERS so the sidebar browser can explain an
// iframe refusal: X-Frame-Options / CSP frame-ancestors are exactly the
// signals the browser enforces when it refuses to embed a site. The
// probe is display-only (headers back to the caller), restricted to
// http(s) non-loopback URLs with a hard timeout, and gated by the same
// trust fence as every other route — a cross-site page cannot reach it.
'browser.probe': async (payload) => {
const raw = requireString(payload, 'url')
let parsed: URL
try {
parsed = new URL(raw)
} catch {
throw new SidebarError('bad-request', 'invalid url', 400)
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new SidebarError('bad-request', 'only http/https urls can be probed', 400)
}
// Mirror the browser tab's address-bar policy: loopback stays unreachable
// from the sidebar, so probing it would leak nothing the tab could use.
if (isLoopbackHostname(parsed.hostname)) {
throw new SidebarError('bad-request', 'local addresses are not probed', 400)
}
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 8000)
try {
let response = await fetch(parsed, { method: 'HEAD', redirect: 'follow', signal: controller.signal })
// Some servers answer HEAD with 405/501; retry once as GET (the
// body is discarded — only the headers matter).
if (response.status === 405 || response.status === 501) {
response = await fetch(parsed, { method: 'GET', redirect: 'follow', signal: controller.signal })
}
const csp = response.headers.get('content-security-policy')
const frameAncestors = extractFrameAncestors(csp)
const xFrameOptions = response.headers.get('x-frame-options')
return {
reachable: true,
url: response.url,
status: response.status,
...(xFrameOptions !== null ? { xFrameOptions } : {}),
...(frameAncestors !== undefined ? { frameAncestors } : {}),
}
} catch {
// DNS / TLS / connection / timeout: nothing to judge — the client
// keeps the plain iframe.
return { reachable: false }
} finally {
clearTimeout(timer)
}
},
}
}
@@ -513,6 +566,70 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
},
}), 'dsh-better-sidebar: /sidebar/file media route')
// ── HTML preview route (sandboxed HTML + its relative assets) ───────────
// Serves files under the session cwd for the built-in HTML previewer. The
// URL is path-encoded (see html-route.ts) so the previewed page's relative
// assets (./style.css, img/x.png) resolve back into this route with the
// session scope intact — a query-encoded URL would drop the scope when the
// browser resolves relatives. Every response carries the CSP `sandbox`
// directive: inside the editor's iframe the sandbox ATTRIBUTE is the
// boundary, this header is defense-in-depth so even a top-level load of
// the URL (e.g. a popup opened by a previewed page) stays in an opaque
// origin with no same-origin access to the GUI.
ctx.effect(() => ctx.httpServer.register({
kind: 'prefix',
path: '/sidebar/html',
handler: async (req, res) => {
if (!fence(req)) {
res.writeHead(403)
res.end('forbidden')
return
}
if (req.method !== 'GET') {
res.writeHead(405)
res.end()
return
}
try {
const url = new URL(req.url ?? '/', 'http://dsh.internal')
const decoded = decodeHtmlUrl(url.pathname)
if (!decoded.ok) {
writeError(res, new SidebarError('bad-request', decoded.message, decoded.status))
return
}
const { sessionId, path } = decoded.ref
// The session's authoritative cwd (client cwd cannot ride in the URL
// — the path encoding has no query; a detached first request falls
// back to the process cwd and is normally refused by isWithin, same
// semantics as the media route's fallback).
const cwd = sessionCwdOf(ctx, sessionId)
const absolute = requireAbsolute(path)
if (!isWithin(cwd, absolute)) {
throw new SidebarError('fs-error', 'html path outside the session working directory', 403)
}
const info = await stat(absolute)
if (!info.isFile() || info.size > resolved.mediaLimit) {
throw new SidebarError('fs-error', 'not a file or too large', 400)
}
const type = mediaTypeForPath(absolute)
const body = await readFile(absolute)
res.writeHead(200, {
'content-type': type,
'cache-control': 'no-cache',
'x-content-type-options': 'nosniff',
'referrer-policy': 'no-referrer',
// The sandbox directive (no allow-same-origin → opaque origin) is
// the previewer's security boundary even for top-level loads;
// object-src 'none' blocks plugin embeds.
'content-security-policy': "sandbox allow-scripts allow-popups allow-downloads allow-modals; object-src 'none'",
})
res.end(body)
} catch (error) {
writeError(res, error)
}
},
}), 'dsh-better-sidebar: /sidebar/html preview route')
// ── Terminal WebSocket ──────────────────────────────────────────────────
// One upgrade endpoint serves both UI-tab terminals (?tab=...) and
// agent-owned terminals (?uuid=...). The two paths attach to different
+45
View File
@@ -27,6 +27,46 @@ export interface SidebarPrefs {
* user explicitly enables it in the side card settings.
*/
agentTerminalTools: boolean
/**
* Whether chat-side file opens (tool-row path links, the produced-files
* row, prose file mentions — every path that funnels through the client
* runtime's `ctx.workspaces.openPath`) open in the sidebar editor instead
* of the Host OS's default application. On by default; the editor tab's
* own enable switch gates it too (both must be on for the takeover).
*/
interceptOpenPath: boolean
/**
* Whether the HTML previewer drops its sandboxed iframe. Sandbox ON (the
* default) renders previewed HTML in an opaque-origin iframe that cannot
* touch the GUI; turning it OFF runs the previewed page with the GUI's
* own origin — full read/write access to session files and internal
* APIs. Only for trusted local content; the setting copy warns.
*/
htmlViewerNoSandbox: boolean
/**
* Whether a newly opened HTML preview starts UNSANDBOXED (the per-surface
* temporary unlock pre-applied). Off by default: previews open sandboxed
* and the status row offers the one-tap unlock; when on, previews open
* in the red unsandboxed state and the status row offers a one-tap
* restore for the current file.
*/
htmlViewerDefaultUnsafe: boolean
/**
* Whether the browser tab drops its sandboxed iframe. Sandbox ON (the
* default) keeps browsed sites in an opaque origin with no GUI access;
* turning it OFF runs any visited site with the GUI's own origin — it
* can read session data and act as the logged-in GUI. Only for trusted
* sites; the setting copy warns.
*/
browserNoSandbox: boolean
/**
* Whether clicking an http(s) EXTERNAL link in the GUI (chat messages,
* tool rows, prose mentions) opens the sidebar browser instead of a new
* browser tab. On by default; gated on the browser tab's own enable
* switch (both must be on for the takeover). Ctrl/Cmd+click always
* bypasses the takeover.
*/
browserInterceptLinks: boolean
/**
* Per-tab enable switches, keyed by tab descriptor id (`'explorer'`,
* `'my-plugin:db'`). An ABSENT key means enabled — only an explicit
@@ -57,6 +97,11 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
defaultWidthPercent: WIDTH_PERCENT_DEFAULT,
autoOpenSubagent: true,
agentTerminalTools: false,
interceptOpenPath: true,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
browserNoSandbox: false,
browserInterceptLinks: true,
tabsEnabled: {},
viewersEnabled: {},
}
+1
View File
@@ -15,6 +15,7 @@ const elementStub = (): Record<string, unknown> => ({
classList: { add: () => {}, remove: () => {}, contains: () => false },
setAttribute: () => {},
getAttribute: () => null,
hasAttribute: () => false,
appendChild: () => {},
addEventListener: () => {},
removeEventListener: () => {},
+50
View File
@@ -0,0 +1,50 @@
/**
* browser.probe helpers: the CSP frame-ancestors parser (host side) and the
* embeddability verdict (client side). Together they turn the target's
* response headers into the "refused to be embedded" explanation the
* sidebar browser shows instead of the browser's cryptic blank frame.
*/
import { describe, expect, it } from 'vitest'
import { extractFrameAncestors } from '../src/browser-probe.ts'
import { embeddabilityOf } from '../src/client/browser.ts'
describe('extractFrameAncestors', () => {
it('extracts a frame-ancestors source list from a CSP header', () => {
expect(extractFrameAncestors("default-src 'self'; frame-ancestors 'none'"))
.toEqual(["'none'"])
expect(extractFrameAncestors("frame-ancestors 'self' https://example.com"))
.toEqual(["'self'", 'https://example.com'])
expect(extractFrameAncestors('frame-ancestors *')).toEqual(['*'])
})
it('is undefined when the directive is absent or empty', () => {
expect(extractFrameAncestors(null)).toBeUndefined()
expect(extractFrameAncestors("default-src 'self'")).toBeUndefined()
expect(extractFrameAncestors('frame-ancestors')).toBeUndefined()
expect(extractFrameAncestors('frame-ancestors ; default-src *')).toBeUndefined()
})
})
describe('embeddabilityOf', () => {
it('blocks X-Frame-Options DENY / SAMEORIGIN', () => {
expect(embeddabilityOf({ reachable: true, status: 200, xFrameOptions: 'DENY' })).toBe('blocked')
expect(embeddabilityOf({ reachable: true, status: 200, xFrameOptions: 'SAMEORIGIN' })).toBe('blocked')
expect(embeddabilityOf({ reachable: true, status: 200, xFrameOptions: 'sameorigin' })).toBe('blocked')
})
it('blocks a frame-ancestors list that does not allow * ("self" means the SITE origin, never ours)', () => {
expect(embeddabilityOf({ reachable: true, frameAncestors: ["'none'"] })).toBe('blocked')
expect(embeddabilityOf({ reachable: true, frameAncestors: ["'self'"] })).toBe('blocked')
expect(embeddabilityOf({ reachable: true, frameAncestors: ["'self'", 'https://x.dev'] })).toBe('blocked')
})
it('allows a wildcard frame-ancestors and absent anti-framing headers', () => {
expect(embeddabilityOf({ reachable: true, status: 200, frameAncestors: ['*'] })).toBe('embeddable')
expect(embeddabilityOf({ reachable: true, status: 200 })).toBe('embeddable')
expect(embeddabilityOf({ reachable: true, status: 200, xFrameOptions: 'ALLOW-FROM https://x' })).toBe('embeddable')
})
it('is unknown when the target is unreachable (plain iframe stays)', () => {
expect(embeddabilityOf({ reachable: false })).toBe('unknown')
})
})
+79
View File
@@ -0,0 +1,79 @@
/**
* Browser address-bar policy tests: only http(s) URLs may be navigated,
* loopback addresses and the GUI's own origin are refused outright. The
* iframe sandbox (opaque origin) is the primary security boundary; this
* policy is the address-bar gate on top of it.
*/
import { describe, expect, it } from 'vitest'
import { isLoopbackHostname, normalizeBrowserUrl } from '../src/client/browser.ts'
const SELF = 'http://127.0.0.1:3080'
describe('normalizeBrowserUrl', () => {
it('normalizes a bare domain to https', () => {
expect(normalizeBrowserUrl('example.com', SELF)).toEqual({ kind: 'ok', url: 'https://example.com/' })
})
it('normalizes a host with a port to https', () => {
expect(normalizeBrowserUrl('example.com:8080/path', SELF)).toEqual({ kind: 'ok', url: 'https://example.com:8080/path' })
})
it('keeps an explicit http:// scheme', () => {
expect(normalizeBrowserUrl('http://example.com/a?b=1', SELF)).toEqual({ kind: 'ok', url: 'http://example.com/a?b=1' })
})
it('accepts a non-loopback IP literal', () => {
expect(normalizeBrowserUrl('https://8.8.8.8/dns', SELF)?.kind).toBe('ok')
})
it('refuses non-http(s) schemes', () => {
expect(normalizeBrowserUrl('javascript:alert(1)', SELF)).toEqual({ kind: 'blocked', reason: 'scheme' })
expect(normalizeBrowserUrl('data:text/html,<b>x</b>', SELF)).toEqual({ kind: 'blocked', reason: 'scheme' })
expect(normalizeBrowserUrl('file:///etc/passwd', SELF)).toEqual({ kind: 'blocked', reason: 'scheme' })
expect(normalizeBrowserUrl('about:blank', SELF)).toEqual({ kind: 'blocked', reason: 'scheme' })
})
it('refuses loopback hostnames in every spelling', () => {
for (const input of [
'http://localhost/', 'https://localhost:3080/', 'http://LOCALHOST/',
'http://127.0.0.1/', 'http://127.255.255.255/',
'http://[::1]/', 'http://0.0.0.0/',
]) {
expect(normalizeBrowserUrl(input, SELF), input).toEqual({ kind: 'blocked', reason: 'loopback' })
}
})
it('allows the GUI\'s own origin (the sandbox keeps it opaque like any site)', () => {
// The user may browse the GUI itself in the sidebar; its host is
// loopback, so the self check must win BEFORE the loopback gate.
expect(normalizeBrowserUrl('http://127.0.0.1:3080/sidebar', SELF)).toEqual({
kind: 'ok', url: 'http://127.0.0.1:3080/sidebar',
})
expect(normalizeBrowserUrl('http://127.0.0.1:3080/', SELF)).toEqual({
kind: 'ok', url: 'http://127.0.0.1:3080/',
})
// A different port of the same loopback host is NOT the GUI origin and
// stays blocked.
expect(normalizeBrowserUrl('http://127.0.0.1:9999/', SELF)).toEqual({ kind: 'blocked', reason: 'loopback' })
})
it('reports invalid input', () => {
expect(normalizeBrowserUrl('', SELF)).toEqual({ kind: 'invalid' })
expect(normalizeBrowserUrl(' ', SELF)).toEqual({ kind: 'invalid' })
expect(normalizeBrowserUrl('ht tp://x', SELF)).toEqual({ kind: 'invalid' })
})
})
describe('isLoopbackHostname', () => {
it('flags localhost, IPv6 loopback, and the 127/8 block', () => {
expect(isLoopbackHostname('localhost')).toBe(true)
expect(isLoopbackHostname('::1')).toBe(true)
expect(isLoopbackHostname('[::1]')).toBe(true)
expect(isLoopbackHostname('127.0.0.1')).toBe(true)
expect(isLoopbackHostname('127.8.9.10')).toBe(true)
expect(isLoopbackHostname('0.0.0.0')).toBe(true)
expect(isLoopbackHostname('example.com')).toBe(false)
expect(isLoopbackHostname('128.0.0.1')).toBe(false)
expect(isLoopbackHostname('192.168.1.1')).toBe(false)
})
})
+51 -9
View File
@@ -1,8 +1,8 @@
/**
* Built-in registration tests: the plugin registers 6 tabs and 8 file
* Built-in registration tests: the plugin registers 7 tabs and 9 file
* viewers through the same service external plugins use (dogfooding);
* the catch-all `code` viewer and the NUL-sniffing `binary-download`
* viewer pin the registry's matching behavior.
* the catch-all `code` viewer, the NUL-sniffing `binary-download` viewer,
* and the html/browser sandbox settings pin the registry's behavior.
*/
import { describe, expect, it } from 'vitest'
// First import: browser globals before the xterm-carrying builtin graph loads.
@@ -11,20 +11,21 @@ import './browser-globals.ts'
import type { Context } from '../src/context-types.ts'
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'
function setup(): { service: ReturnType<typeof createBetterSidebarService>; dispose: () => void } {
function setup(): { service: ReturnType<typeof createBetterSidebarService>; store: ReturnType<typeof createSidebarStore>; dispose: () => void } {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const dispose = registerBuiltins({} as Context, service)
return { service, dispose }
return { service, store, dispose }
}
describe('built-in tab registrations', () => {
it('registers the 6 built-in tabs', () => {
it('registers the 7 built-in tabs', () => {
const { service } = setup()
expect(service.getTabs().map(t => t.id).sort()).toEqual(
['diff', 'editor', 'explorer', 'git', 'subagent', 'terminal'],
['browser', 'diff', 'editor', 'explorer', 'git', 'subagent', 'terminal'],
)
})
@@ -52,6 +53,29 @@ describe('built-in tab registrations', () => {
expect(toggles.map(t => t.key)).toEqual(['agentTerminalTools'])
})
it('the browser tab declares its sandbox and link-takeover related settings', () => {
const { service } = setup()
const toggles = service.getTab('browser')?.settings?.toggles ?? []
expect(toggles.map(t => t.key)).toEqual(['browserNoSandbox', 'browserInterceptLinks'])
expect(toggles[0]?.title).toBeDefined()
expect(toggles[0]?.desc).toBeDefined()
expect(toggles[1]?.title).toBeDefined()
expect(toggles[1]?.desc).toBeDefined()
})
it('the browser createTab mints browser:<n> ids and bumps nextBrowser', () => {
const { service, store } = setup()
store.setSession('s1')
service.openTab({ type: 'browser' })
service.openTab({ type: 'browser' })
const state = store.getSnapshot().state!
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs).filter(t => t.type === 'browser')
expect(tabs).toHaveLength(2)
expect(tabs[0]!.id).toBe('browser:1')
expect(tabs[1]!.id).toBe('browser:2')
expect(state.nextBrowser).toBe(3)
})
it('every built-in tab carries the settings-surface icon', () => {
const { service } = setup()
for (const tab of service.getTabs()) {
@@ -61,10 +85,10 @@ describe('built-in tab registrations', () => {
})
describe('built-in file viewer registrations', () => {
it('registers the 8 built-in file viewers', () => {
it('registers the 9 built-in file viewers', () => {
const { service } = setup()
expect(service.getFileViewers().map(v => v.id).sort()).toEqual(
['binary-download', 'code', 'docx', 'image', 'markdown', 'pdf', 'pptx', 'xlsx'],
['binary-download', 'code', 'docx', 'html', 'image', 'markdown', 'pdf', 'pptx', 'xlsx'],
)
})
@@ -84,6 +108,24 @@ describe('built-in file viewer registrations', () => {
expect(service.matchFileViewer('readme.md', new Uint8Array([0x61]))?.id).toBe('markdown')
})
it('html claims html/htm before the catch-all', () => {
const { service } = setup()
expect(service.matchFileViewer('index.html')?.id).toBe('html')
expect(service.matchFileViewer('page.htm')?.id).toBe('html')
expect(service.matchFileViewer('index.html', new Uint8Array([0x3c, 0x21]))?.id).toBe('html')
expect(service.matchFileViewer('index.HTML')?.id).toBe('html')
})
it('the html viewer declares its sandbox and default-unsafe related settings', () => {
const { service } = setup()
const toggles = service.getFileViewers().find(v => v.id === 'html')?.settings?.toggles ?? []
expect(toggles.map(t => t.key)).toEqual(['htmlViewerNoSandbox', 'htmlViewerDefaultUnsafe'])
expect(toggles[0]?.title).toBeDefined()
expect(toggles[0]?.desc).toBeDefined()
expect(toggles[1]?.title).toBeDefined()
expect(toggles[1]?.desc).toBeDefined()
})
it('binary-download claims legacy office by extension', () => {
const { service } = setup()
expect(service.matchFileViewer('old.doc')?.id).toBe('binary-download')
+110
View File
@@ -0,0 +1,110 @@
/**
* /sidebar/html route vocabulary tests: the path-encoded URL scheme must
* round-trip absolute paths (POSIX and Windows), survive special characters,
* and refuse malformed input — and crucially, a relative asset reference
* resolved against an encoded document URL stays inside the same route with
* the session scope intact (the WHY of the path-encoding design).
*/
import { describe, expect, it } from 'vitest'
import { decodeHtmlUrl, encodeHtmlUrl } from '../src/html-route.ts'
describe('encodeHtmlUrl', () => {
it('encodes a POSIX absolute path into route segments', () => {
expect(encodeHtmlUrl('sess-1', '/Users/me/proj/index.html'))
.toBe('/sidebar/html/sess-1/Users/me/proj/index.html')
})
it('encodes a Windows absolute path (drive colon percent-encoded)', () => {
expect(encodeHtmlUrl('sess-1', 'C:\\Users\\me\\a.html'))
.toBe('/sidebar/html/sess-1/C%3A/Users/me/a.html')
})
it('percent-encodes special characters in segments', () => {
expect(encodeHtmlUrl('s-1', '/a b/中文/100%.html'))
.toBe('/sidebar/html/s-1/a%20b/%E4%B8%AD%E6%96%87/100%25.html')
})
it('ignores leading/trailing slashes (files only)', () => {
expect(encodeHtmlUrl('s', '/a//b/x.html')).toBe('/sidebar/html/s/a/b/x.html')
})
})
describe('decodeHtmlUrl', () => {
it('decodes a POSIX round-trip', () => {
const url = encodeHtmlUrl('sess-1', '/Users/me/proj/index.html')
expect(decodeHtmlUrl(url)).toEqual({
ok: true,
ref: { sessionId: 'sess-1', path: '/Users/me/proj/index.html' },
})
})
it('decodes a Windows round-trip', () => {
const url = encodeHtmlUrl('sess-1', 'C:\\Users\\me\\a.html')
expect(decodeHtmlUrl(url)).toEqual({
ok: true,
ref: { sessionId: 'sess-1', path: '/C:/Users/me/a.html' },
})
})
it('decodes special characters round-trip', () => {
const url = encodeHtmlUrl('s-1', '/a b/中文/100%.html')
const result = decodeHtmlUrl(url)
expect(result.ok).toBe(true)
if (result.ok) expect(result.ref.path).toBe('/a b/中文/100%.html')
})
it('refuses a wrong prefix (404)', () => {
expect(decodeHtmlUrl('/sidebar/api/fs.read')).toEqual({
ok: false, status: 404, message: 'not an html route',
})
})
it('refuses an empty or double-slash path (400)', () => {
expect(decodeHtmlUrl('/sidebar/html/').ok).toBe(false)
expect(decodeHtmlUrl('/sidebar/html//s/a.html').ok).toBe(false)
})
it('refuses malformed percent encoding (400)', () => {
expect(decodeHtmlUrl('/sidebar/html/s/%E0%A4%A').ok).toBe(false)
})
it('refuses a missing sessionId or file path (400)', () => {
expect(decodeHtmlUrl('/sidebar/html//a.html')).toEqual({
ok: false, status: 400, message: 'sessionId and file path are required',
})
expect(decodeHtmlUrl('/sidebar/html/s/')).toEqual({
ok: false, status: 400, message: 'sessionId and file path are required',
})
})
it('keeps encoded traversal segments for the isWithin fence to bound', () => {
// The decoder is not a security boundary by itself: an encoded `..`
// decodes to `..` and the HOST refuses it via requireAbsolute +
// isWithin(cwd) (the decoded path resolves outside the cwd).
const result = decodeHtmlUrl('/sidebar/html/s/Users/me/../../etc/passwd')
expect(result.ok).toBe(true)
if (result.ok) expect(result.ref.path).toBe('/Users/me/../../etc/passwd')
})
})
describe('relative asset resolution stays in-route', () => {
it('a relative reference against an encoded document URL keeps the session scope', () => {
// WHATWG URL resolution drops the QUERY of a path-relative reference,
// which is why the route is path-encoded: resolving ./style.css against
// the document URL must land back on the same route with the same
// session prefix.
const doc = encodeHtmlUrl('sess-1', '/Users/me/proj/index.html')
const asset = new URL('./style.css', `http://h${doc}`).pathname
expect(asset).toBe('/sidebar/html/sess-1/Users/me/proj/style.css')
expect(decodeHtmlUrl(asset)).toEqual({
ok: true,
ref: { sessionId: 'sess-1', path: '/Users/me/proj/style.css' },
})
})
it('deeper and parent-relative references resolve inside the route', () => {
const doc = `http://h${encodeHtmlUrl('s', '/a/b/index.html')}`
expect(new URL('img/x.png', doc).pathname).toBe('/sidebar/html/s/a/b/img/x.png')
expect(new URL('../c.css', doc).pathname).toBe('/sidebar/html/s/a/c.css')
})
})
+57
View File
@@ -0,0 +1,57 @@
/**
* External-link interception policy tests: the pure decision (which http(s)
* external links are taken over into the sidebar browser) and the click
* shape (plain left clicks only — modified clicks always bypass).
*/
import { describe, expect, it } from 'vitest'
import { isPlainLeftClick, shouldInterceptLink } from '../src/client/link-intercept.ts'
const SELF = 'http://127.0.0.1:3080'
describe('shouldInterceptLink', () => {
it('takes over http(s) external links', () => {
expect(shouldInterceptLink('https://example.com/page?a=1', SELF)).toBe('https://example.com/page?a=1')
expect(shouldInterceptLink('http://example.com/', SELF)).toBe('http://example.com/')
})
it('ignores non-http(s) links (mailto:, javascript:, file:)', () => {
expect(shouldInterceptLink('mailto:a@b.c', SELF)).toBeNull()
expect(shouldInterceptLink('javascript:void(0)', SELF)).toBeNull()
expect(shouldInterceptLink('file:///tmp/x.html', SELF)).toBeNull()
})
it('ignores unparsable hrefs', () => {
expect(shouldInterceptLink('not a url', SELF)).toBeNull()
})
it('never takes over same-origin (GUI-internal) links', () => {
expect(shouldInterceptLink('http://127.0.0.1:3080/settings', SELF)).toBeNull()
expect(shouldInterceptLink('http://127.0.0.1:3080/chat/session-x', SELF)).toBeNull()
// A different port of the same host is external.
expect(shouldInterceptLink('http://127.0.0.1:9999/', SELF)).toBe('http://127.0.0.1:9999/')
})
it("takes over LAN hosts (the browser's own blocklist, not the link policy)", () => {
// The link takeover funnels into the sidebar browser, which then applies
// its own loopback/self address-bar policy on render — the interception
// itself must not silently drop links the browser will surface as blocked.
expect(shouldInterceptLink('http://192.168.1.1/', SELF)).toBe('http://192.168.1.1/')
})
})
describe('isPlainLeftClick', () => {
const plain = { button: 0, metaKey: false, ctrlKey: false, shiftKey: false, altKey: false }
it('accepts a plain left click', () => {
expect(isPlainLeftClick(plain)).toBe(true)
})
it('bypasses modified clicks (Ctrl/Cmd/Shift/Alt) and non-left buttons', () => {
expect(isPlainLeftClick({ ...plain, metaKey: true })).toBe(false)
expect(isPlainLeftClick({ ...plain, ctrlKey: true })).toBe(false)
expect(isPlainLeftClick({ ...plain, shiftKey: true })).toBe(false)
expect(isPlainLeftClick({ ...plain, altKey: true })).toBe(false)
expect(isPlainLeftClick({ ...plain, button: 1 })).toBe(false)
expect(isPlainLeftClick({ ...plain, button: 2 })).toBe(false)
})
})
+1 -1
View File
@@ -57,6 +57,6 @@ describe('dsh-better-sidebar plugin export shape', () => {
const overridden = (PrefsSchema as unknown as {
(input: Record<string, unknown> | undefined): Record<string, unknown>
})({ openByDefault: false, defaultWidthPercent: 45 })
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, tabsEnabled: {}, viewersEnabled: {} })
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
})
})
+187
View File
@@ -0,0 +1,187 @@
/**
* Sandbox-contract tests for the two built-in web surfaces (HTML preview
* iframe and the browser tab iframe). The iframe sandbox — opaque origin,
* no allow-same-origin, no top-navigation — is the PRIMARY security
* boundary of both features; these tests pin the exact attribute so a
* refactor cannot silently widen it. The side card settings can drop the
* sandbox per-feature (warned); those paths render the warning bar and no
* sandbox attribute.
*/
import { describe, expect, it, beforeEach } from 'vitest'
import { renderToString } from 'react-dom/server'
import { createElement } from 'react'
import './browser-globals.ts'
import type { Context } from '../src/context-types.ts'
import { TextEditor, HTML_IFRAME_SANDBOX } from '../src/client/TextEditor.tsx'
import { BrowserView, BrowserEmbedBlocked, BROWSER_IFRAME_SANDBOX } from '../src/client/BrowserView.tsx'
import { createSidebarStore } from '../src/client/state.ts'
import type { FileViewerProps } from '../src/client/service.ts'
const CTX = {} as Context
// The copy assertions below pin the zh strings: force the zh locale (the
// test environment's navigator may be the real Node one with an en locale).
beforeEach(() => {
Object.defineProperty(globalThis.navigator, 'language', { value: 'zh-CN', configurable: true })
})
function viewerProps(store: ReturnType<typeof createSidebarStore>, overrides: Partial<FileViewerProps> = {}): FileViewerProps {
return {
ctx: CTX,
store,
scope: { sessionId: 's1', cwd: '/p' },
path: '/p/a/index.html',
title: 'index.html',
viewerId: 'html',
content: '<h1>hi</h1>',
...overrides,
}
}
describe('HTML preview iframe sandbox', () => {
it('renders the preview iframe with the exact sandbox tokens and no same-origin / top-navigation', () => {
const store = createSidebarStore()
const html = renderToString(createElement(TextEditor, viewerProps(store)))
const iframe = /<iframe[^>]*>/.exec(html)?.[0]
expect(iframe).toBeDefined()
// The sandbox tokens are exactly the exported constant...
expect(iframe).toContain(`sandbox="${HTML_IFRAME_SANDBOX}"`)
// ...which must never contain the dangerous tokens.
expect(HTML_IFRAME_SANDBOX).not.toContain('allow-same-origin')
expect(HTML_IFRAME_SANDBOX).not.toContain('allow-top-navigation')
// Cross-origin framing by construction: route-src (never srcdoc).
expect(iframe).toContain('src="/sidebar/html/s1/p/a/index.html"')
expect(iframe).not.toContain('srcdoc=')
// Referrer + permissions policy stay locked even when sandboxed.
// (React SSR renders the referrerPolicy prop camelCase as written.)
expect(iframe).toContain('referrerPolicy="no-referrer"')
expect(iframe).toContain('allow=""')
})
it('renders the live sandbox status row (green on + temporary unlock action)', () => {
const store = createSidebarStore()
const html = renderToString(createElement(TextEditor, viewerProps(store)))
// Sandbox ON: the green status + the one-tap temporary unlock button.
expect(html).toContain('沙箱模式:已启用')
expect(html).toContain('临时解锁(不安全)')
// No restore action while the sandbox is on.
expect(html).not.toContain('恢复沙箱')
})
it('drops the sandbox attribute with the red warning when the setting is on (no restore action — the global setting owns it)', () => {
const store = createSidebarStore()
store.setPrefs({ ...store.getPrefs(), htmlViewerNoSandbox: true })
const html = renderToString(createElement(TextEditor, viewerProps(store)))
const iframe = /<iframe[^>]*>/.exec(html)?.[0]
expect(iframe).toBeDefined()
expect(iframe).not.toContain('sandbox=')
// The red persistent warning copy is rendered; the temporary-unlock
// action is NOT offered (re-enabling is the settings page's job).
expect(html).toContain('沙箱已关闭')
expect(html).not.toContain('临时解锁(不安全)')
expect(html).not.toContain('恢复沙箱')
})
it('starts unsandboxed (red, restorable) when the default-unsafe pref is on', () => {
const store = createSidebarStore()
store.setPrefs({ ...store.getPrefs(), htmlViewerDefaultUnsafe: true })
const html = renderToString(createElement(TextEditor, viewerProps(store)))
const iframe = /<iframe[^>]*>/.exec(html)?.[0]
expect(iframe).toBeDefined()
expect(iframe).not.toContain('sandbox=')
// The red warning + the one-tap restore (this is the LOCAL state).
expect(html).toContain('沙箱已关闭')
expect(html).toContain('恢复沙箱')
expect(html).not.toContain('临时解锁(不安全)')
})
it('markdown preview keeps rendering markdown, not an iframe', () => {
const store = createSidebarStore()
const html = renderToString(createElement(TextEditor, viewerProps(store, {
viewerId: 'markdown',
path: '/p/readme.md',
content: '# hi',
})))
expect(html).not.toContain('<iframe')
expect(html).not.toContain('/sidebar/html/')
// The markdown is rendered into markup, not framed.
expect(html).toContain('<h1')
})
})
describe('browser tab iframe sandbox', () => {
function tabProps(store: ReturnType<typeof createSidebarStore>, path?: string) {
return {
ctx: CTX,
store,
scope: { sessionId: 's1', cwd: '/p' },
tab: { id: 'browser:1', type: 'browser', title: 'Browser', ...(path !== undefined ? { path } : {}) },
visible: true,
}
}
it('renders the start page before any navigation (no iframe, no auto-load)', () => {
const store = createSidebarStore()
const html = renderToString(createElement(BrowserView, tabProps(store)))
expect(html).not.toContain('<iframe')
expect(html).toContain('输入网址开始浏览')
})
it('sandboxes the iframe without same-origin / top-navigation', () => {
const store = createSidebarStore()
const html = renderToString(createElement(BrowserView, tabProps(store, 'https://example.com/')))
const iframe = /<iframe[^>]*>/.exec(html)?.[0]
expect(iframe).toBeDefined()
expect(iframe).toContain(`sandbox="${BROWSER_IFRAME_SANDBOX}"`)
expect(BROWSER_IFRAME_SANDBOX).not.toContain('allow-same-origin')
expect(BROWSER_IFRAME_SANDBOX).not.toContain('allow-top-navigation')
expect(iframe).toContain('src="https://example.com/"')
expect(iframe).toContain('referrerPolicy="no-referrer"')
expect(iframe).toContain('allow=""')
})
it('renders the live sandbox status row with the temporary unlock action', () => {
const store = createSidebarStore()
const html = renderToString(createElement(BrowserView, tabProps(store, 'https://example.com/')))
expect(html).toContain('沙箱模式:已启用')
expect(html).toContain('临时解锁(不安全)')
})
it('offers the open-in-browser action once a URL is loaded (disabled before navigation)', () => {
const store = createSidebarStore()
// No URL yet: the external-open action is disabled.
const start = renderToString(createElement(BrowserView, tabProps(store)))
expect(start).toContain('aria-label="在浏览器中打开"')
expect(start).toContain('title="在浏览器中打开" disabled=""')
// With a URL: enabled.
const loaded = renderToString(createElement(BrowserView, tabProps(store, 'https://example.com/')))
expect(loaded).toContain('aria-label="在浏览器中打开"')
expect(loaded).not.toContain('title="在浏览器中打开" disabled=""')
})
it('drops the sandbox attribute with the red warning when the setting is on (no restore action — the global setting owns it)', () => {
const store = createSidebarStore()
store.setPrefs({ ...store.getPrefs(), browserNoSandbox: true })
const html = renderToString(createElement(BrowserView, tabProps(store, 'https://example.com/')))
const iframe = /<iframe[^>]*>/.exec(html)?.[0]
expect(iframe).toBeDefined()
expect(iframe).not.toContain('sandbox=')
expect(html).toContain('沙箱已关闭')
expect(html).not.toContain('临时解锁(不安全)')
expect(html).not.toContain('恢复沙箱')
})
})
describe('browser embed-refusal panel', () => {
it('explains the refusal with the host, the reason, and both actions', () => {
const html = renderToString(createElement(BrowserEmbedBlocked, {
url: 'https://arxiv.org/abs/2401.10001',
onOpenInBrowser: () => {},
onLoadAnyway: () => {},
}))
expect(html).toContain('arxiv.org 拒绝了嵌入请求')
expect(html).toContain('X-Frame-Options / frame-ancestors')
expect(html).toContain('在浏览器中打开')
expect(html).toContain('仍然加载')
})
})
+22
View File
@@ -307,6 +307,28 @@ describe('service.openTab dedupe', () => {
expect(tab?.title).toBe('main.ts')
})
it('a url seed lands the tab with its path pre-set (the sidebar-browser navigation seed)', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({
id: 'browser',
title: () => 'Browser',
createTab: (state) => ({
tab: { id: `browser:${state.nextBrowser}`, type: 'browser', title: 'Browser' },
patch: { nextBrowser: state.nextBrowser + 1 },
}),
component: () => null,
})
store.setSession('s1')
service.openTab({ type: 'browser', url: 'https://example.com/x', title: 'example.com' })
const state = store.getSnapshot().state!
const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'browser')
expect(tab?.id).toBe('browser:1')
expect(tab?.path).toBe('https://example.com/x')
expect(tab?.title).toBe('example.com')
expect(state.nextBrowser).toBe(2)
})
it('the descriptor title is the default when no title is given', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
+6 -5
View File
@@ -78,10 +78,10 @@ describe('SideCardSection declarative inventory', () => {
expect(html).toContain('>explorer<')
expect(html).toContain('data-icon="subagent"')
expect(html).toContain('>Subagents<')
// Default prefs: openByDefault + both tabs + the image viewer are all
// enabled → 4 cards pressed, none pressed=false. The nested auto-open
// toggle is NOT an inline card (it lives in the popup).
expect(pressedCount(html, 'true')).toBe(4)
// Default prefs: openByDefault + interceptOpenPath + both tabs + the
// image viewer are all enabled → 5 cards pressed, none pressed=false.
// The nested auto-open toggle is NOT an inline card (it lives in the popup).
expect(pressedCount(html, 'true')).toBe(5)
expect(pressedCount(html, 'false')).toBe(0)
expect(html).not.toContain('Auto-open Subagents')
})
@@ -111,7 +111,8 @@ describe('SideCardSection declarative inventory', () => {
expect(html).toContain('>Subagents<')
expect(html).toContain('>Image<')
expect(pressedCount(html, 'false')).toBe(2)
expect(pressedCount(html, 'true')).toBe(2)
// openByDefault + interceptOpenPath + the explorer stay pressed.
expect(pressedCount(html, 'true')).toBe(3)
})
it('hides the gear of a disabled feature (its related settings are dormant)', () => {
+71 -2
View File
@@ -3,7 +3,7 @@
* exercises the real integrations — route registration, git against the
* actual repository, and a real directory listing. Runs with `pnpm test`.
*/
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { spawnSync } from 'node:child_process'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
@@ -58,7 +58,7 @@ describe('host plugin smoke', () => {
inject: () => () => {},
}
apply(ctx as never)
expect(routes.map(route => route.path)).toEqual(['/sidebar/api', '/sidebar/file'])
expect(routes.map(route => route.path)).toEqual(['/sidebar/api', '/sidebar/file', '/sidebar/html'])
expect(upgrades.map(route => route.path)).toEqual(['/sidebar/ws/terminal', '/sidebar/ws/agent-terminals'])
// Teardown runs without throwing (pty manager has nothing open).
for (const cleanup of effects) cleanup()
@@ -507,6 +507,11 @@ describe('side card settings routes', () => {
defaultWidthPercent: 30,
autoOpenSubagent: true,
agentTerminalTools: false,
interceptOpenPath: true,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
browserNoSandbox: false,
browserInterceptLinks: true,
// The enable-switch maps default to {} (everything on).
tabsEnabled: {},
viewersEnabled: {},
@@ -541,8 +546,70 @@ describe('side card settings routes', () => {
expect(result.ok).toBe(false)
expect(result.error?.message).toMatch(/plain object/)
})
/** Fake fetch responses shaped like what the route consumes. */
const respond = (status: number, headers: Record<string, string>): Response =>
({ status, url: 'https://site.example/', headers: new Headers(headers) }) as unknown as Response
it('reports X-Frame-Options and frame-ancestors from the target headers', async () => {
const route = mountWithSettings(undefined)
vi.stubGlobal('fetch', vi.fn(async () => respond(200, {
'x-frame-options': 'SAMEORIGIN',
'content-security-policy': "default-src 'self'; frame-ancestors 'none'",
})))
try {
const result = await invoke(route, 'browser.probe', { url: 'https://arxiv.org/' })
expect(result.ok).toBe(true)
expect(result.value).toEqual({
reachable: true,
url: 'https://site.example/',
status: 200,
xFrameOptions: 'SAMEORIGIN',
frameAncestors: ["'none'"],
})
} finally {
vi.unstubAllGlobals()
}
})
it('retries a 405 HEAD as GET', async () => {
const route = mountWithSettings(undefined)
const fetchMock = vi.fn()
.mockResolvedValueOnce(respond(405, {}))
.mockResolvedValueOnce(respond(200, {}))
vi.stubGlobal('fetch', fetchMock)
try {
const result = await invoke(route, 'browser.probe', { url: 'https://example.com/' })
expect(result.ok).toBe(true)
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(result.value).toMatchObject({ reachable: true, status: 200 })
} finally {
vi.unstubAllGlobals()
}
})
it('reports an unreachable target as reachable:false', async () => {
const route = mountWithSettings(undefined)
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('ENOTFOUND') }))
try {
const result = await invoke(route, 'browser.probe', { url: 'https://example.com/' })
expect(result.ok).toBe(true)
expect(result.value).toEqual({ reachable: false })
} finally {
vi.unstubAllGlobals()
}
})
it('refuses non-http(s) and loopback URLs', async () => {
const route = mountWithSettings(undefined)
for (const url of ['javascript:alert(1)', 'file:///etc/passwd', 'http://127.0.0.1:8080/', 'http://localhost/']) {
const result = await invoke(route, 'browser.probe', { url })
expect(result.ok, url).toBe(false)
expect(result.error?.code, url).toBe('bad-request')
}
})
})
describe('agent terminal tool gating', () => {
it('injects the eight tools only when the side-card setting is enabled (default off)', () => {
let registered = 0
@@ -600,4 +667,6 @@ describe('agent terminal tool gating', () => {
expect(live()).toBe(8)
expect(registered).toBe(16)
})
})
+218 -5
View File
@@ -4,7 +4,7 @@ import { parseLogLines, parsePorcelainZ } from '../src/git.ts'
import { parseUnifiedDiff } from '../src/client/DiffView.tsx'
import {
activateTab, allLeaves, closeTab, createSidebarStore, defaultWidthFor, insertLeafAt, makeDefaultState,
moveTab, moveTabToEdge, openDiffTab, openTabInActivePane, reconcileAgentTerminals, resizeSplit, sanitizeState, splitPane, tabOpenIn, toggleExpanded,
moveTab, moveTabToEdge, openDiffTab, openTabInActivePane, patchTab, reconcileAgentTerminals, resizeSplit, sanitizeState, splitPane, tabOpenIn, toggleExpanded,
type SidebarState, type SidebarTab, type SplitNode,
} from '../src/client/state.ts'
import { loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts'
@@ -15,6 +15,9 @@ import { isPdfExt } from '../src/client/pdf-types.ts'
import { isImageExt } from '../src/client/image-types.ts'
import { relativeTo } from '../src/client/paths.ts'
import { producedForClosing, resolveSidebarPath, selectProducedFiles } from '../src/client/produced-files.ts'
import { wrapOpenPath, type OpenPathInterceptDeps, type OpenPathService } from '../src/client/openpath-intercept.ts'
import { registerOpenPathInterception } from '../src/client/intercept.tsx'
import type { Context } from '../src/context-types.ts'
import { defaultShell, ensureSpawnHelper } from '../src/pty-manager.ts'
import {
collectBranchIds, countSubagentDescendants, detectNewDirectSubagent, directSubagentCount, rootAncestor,
@@ -451,6 +454,52 @@ describe('sidebar state', () => {
expect((after.splits as { active: string | null }).active).toBe(tabId)
})
it('patchTab updates the title and path of one open tab (browser persistence)', () => {
let s = state()
const leaf = s.splits as { id: string; tabs: { id: string; type: string; title: string; path?: string }[] }
s = openTabInActivePane(s, { id: 'browser:1', type: 'browser', title: 'Browser' })
const browserId = 'browser:1'
s = patchTab(s, browserId, { path: 'https://example.com/', title: 'example.com' })
const tab = (s.splits as { tabs: { id: string; title: string; path?: string }[] }).tabs.find(t => t.id === browserId)
expect(tab).toMatchObject({ title: 'example.com', path: 'https://example.com/' })
// A partial patch leaves the other field untouched.
s = patchTab(s, browserId, { title: 'example.org' })
const again = (s.splits as { tabs: { id: string; title: string; path?: string }[] }).tabs.find(t => t.id === browserId)
expect(again).toMatchObject({ title: 'example.org', path: 'https://example.com/' })
// Other tabs are untouched.
expect(leaf.tabs[0]).toBeDefined()
})
it('patchTab is a no-op for a missing tab id', () => {
const s = state()
const after = patchTab(s, 'nope', { title: 'X', path: 'https://x/' })
expect(after).toBe(s)
})
it('sanitize accepts nextBrowser (defaulting a missing/malformed one to 1)', () => {
const base = {
panelOpen: true,
width: 400,
nextTerminal: 1,
activePane: 'pane:1',
expanded: [],
splits: {
kind: 'leaf',
id: 'pane:1',
active: null,
tabs: [{ id: 't', type: 'explorer', title: 'Explorer' }],
},
}
// Older persisted states lack the field: they must keep loading.
expect(sanitizeState(base)?.nextBrowser).toBe(1)
// A present valid value survives; a malformed one falls back to 1.
expect(sanitizeState({ ...base, nextBrowser: 7 })?.nextBrowser).toBe(7)
expect(sanitizeState({ ...base, nextBrowser: 'x' })?.nextBrowser).toBe(1)
expect(sanitizeState({ ...base, nextBrowser: 0 })?.nextBrowser).toBe(1)
// The default state seeds 1.
expect(makeDefaultState().nextBrowser).toBe(1)
})
it('tabOpenIn: a tab is open until it is truly closed, wherever it lives', () => {
let s = state()
const leaf = s.splits as { id: string; tabs: { id: string }[] }
@@ -834,6 +883,11 @@ describe('side card preferences', () => {
defaultWidthPercent: 60,
autoOpenSubagent: false,
agentTerminalTools: true,
interceptOpenPath: true,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
browserNoSandbox: false,
browserInterceptLinks: true,
tabsEnabled: {},
viewersEnabled: {},
})
@@ -846,6 +900,11 @@ describe('side card preferences', () => {
defaultWidthPercent: 33,
autoOpenSubagent: true,
agentTerminalTools: false,
interceptOpenPath: true,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
browserNoSandbox: false,
browserInterceptLinks: true,
tabsEnabled: {},
viewersEnabled: {},
})
@@ -858,6 +917,11 @@ describe('side card preferences', () => {
defaultWidthPercent: 40,
autoOpenSubagent: true,
agentTerminalTools: false,
interceptOpenPath: true,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
browserNoSandbox: false,
browserInterceptLinks: true,
tabsEnabled: {},
viewersEnabled: {},
})
@@ -872,6 +936,16 @@ describe('side card preferences', () => {
.toBe(true)
})
it('defaults interceptOpenPath to true; only an explicit false turns the takeover off', async () => {
// Absent or malformed → on (the takeover is the safe default).
expect((await loadPrefs(wire({}))).interceptOpenPath).toBe(true)
expect((await loadPrefs(wire({ interceptOpenPath: 'yes' }))).interceptOpenPath).toBe(true)
expect((await loadPrefs(wire({ interceptOpenPath: 0 }))).interceptOpenPath).toBe(true)
// Explicit booleans survive verbatim.
expect((await loadPrefs(wire({ interceptOpenPath: false }))).interceptOpenPath).toBe(false)
expect((await loadPrefs(wire({ interceptOpenPath: true }))).interceptOpenPath).toBe(true)
})
it('validates the per-tab / per-viewer enable maps (absent keys mean enabled)', async () => {
// A non-object map falls back to {} (everything enabled).
expect((await loadPrefs(wire({ tabsEnabled: 'nope' }))).tabsEnabled).toEqual({})
@@ -889,9 +963,9 @@ describe('side card preferences', () => {
const store = createSidebarStore()
// Node environment: no window → the width falls back to PANEL_DEFAULT,
// while the open flag still follows the preference.
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, tabsEnabled: {}, viewersEnabled: {} })
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
store.setSession('fresh-session')
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, tabsEnabled: {}, viewersEnabled: {} })
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
const snapshot = store.getSnapshot()
expect(snapshot.sessionId).toBe('fresh-session')
expect(snapshot.state?.panelOpen).toBe(false)
@@ -904,7 +978,7 @@ describe('side card preferences', () => {
it('skips the default explorer tab when the explorer type is disabled', () => {
const store = createSidebarStore()
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false, tabsEnabled: { explorer: false }, viewersEnabled: {} })
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: { explorer: false }, viewersEnabled: {} })
store.setSession('no-explorer')
const state = store.getSnapshot().state!
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
@@ -912,7 +986,7 @@ describe('side card preferences', () => {
expect(state.splits.kind).toBe('leaf')
// Re-enabling seeds the explorer tab again.
const openStore = createSidebarStore()
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false, tabsEnabled: {}, viewersEnabled: {} })
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
openStore.setSession('with-explorer')
const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
expect(openTabs.map(tab => tab.type)).toEqual(['explorer'])
@@ -1103,3 +1177,142 @@ describe('subagent activity summary parser', () => {
expect(live.tool).toEqual({ name: 'web', args: '' })
})
})
describe('open-path interception', () => {
/** A minimal fake of the workspaces.openPath service method. */
const service = (): OpenPathService & { calls: string[]; opened: string[] } => {
const fake = {
calls: [] as string[],
opened: [] as string[],
async openPath(path: string): Promise<void> {
this.calls.push(path)
this.opened.push(path)
},
}
return fake
}
const deps = (overrides: Partial<OpenPathInterceptDeps> = {}): OpenPathInterceptDeps & {
sidebar: string[]
} => {
const sidebar: string[] = []
return {
sidebar,
takeoverEnabled: () => true,
currentSessionId: () => 's1',
openInSidebar: (path, sessionId) => { sidebar.push(`${sessionId}:${path}`) },
...overrides,
}
}
it('routes an intercepted open into the sidebar and resolves without the original call', async () => {
const ws = service()
const d = deps()
const restore = wrapOpenPath(ws, d)
await expect(ws.openPath('/abs/a.ts')).resolves.toBeUndefined()
expect(ws.calls).toEqual([])
expect(d.sidebar).toEqual(['s1:/abs/a.ts'])
restore()
})
it('falls through to the original when the takeover is disabled', async () => {
const ws = service()
const d = deps({ takeoverEnabled: () => false })
const restore = wrapOpenPath(ws, d)
await ws.openPath('/abs/a.ts')
expect(ws.opened).toEqual(['/abs/a.ts'])
expect(d.sidebar).toEqual([])
restore()
})
it('falls through when no session is current (nothing to scope the editor load to)', async () => {
const ws = service()
const d = deps({ currentSessionId: () => undefined })
const restore = wrapOpenPath(ws, d)
await ws.openPath('/abs/a.ts')
expect(ws.opened).toEqual(['/abs/a.ts'])
expect(d.sidebar).toEqual([])
restore()
})
it('passes the current session into the sidebar opener', async () => {
const ws = service()
let current = 's1'
const d = deps({ currentSessionId: () => current })
const restore = wrapOpenPath(ws, d)
await ws.openPath('/abs/a.ts')
current = 's2'
await ws.openPath('/abs/b.ts')
expect(d.sidebar).toEqual(['s1:/abs/a.ts', 's2:/abs/b.ts'])
restore()
})
it('restores the original method on dispose (HMR-safe)', async () => {
const ws = service()
const d = deps()
const original = ws.openPath
const restore = wrapOpenPath(ws, d)
expect(ws.openPath).not.toBe(original)
restore()
expect(ws.openPath).toBe(original)
await ws.openPath('/abs/a.ts')
expect(ws.opened).toEqual(['/abs/a.ts'])
})
it('treats a rejected promise like the original would (no swallowing)', async () => {
const failing: OpenPathService = {
async openPath() { throw new Error('host refused') },
}
const d = deps({ takeoverEnabled: () => false })
const restore = wrapOpenPath(failing, d)
await expect(failing.openPath('/abs/a.ts')).rejects.toThrow('host refused')
restore()
})
})
describe('open-path interception wiring', () => {
it('registerOpenPathInterception routes chat opens into the editor tab and restores on dispose', async () => {
// A realistic client-context fake: the sessions list feed (current + cwd),
// the workspaces funnel, and the sidebar service the editor goes through.
const opened: Array<Record<string, unknown>> = []
const funnel = { openPath: async (): Promise<void> => {} }
const ctx = {
sessions: {
list: { getSnapshot: () => ({ current: 's1', byId: { s1: { cwd: '/w' } } }) },
},
workspaces: funnel,
betterSidebar: { openTab: (seed: unknown) => { opened.push(seed as Record<string, unknown>) } },
} as unknown as Context
const store = createSidebarStore()
const original = ctx.workspaces.openPath
const restore = registerOpenPathInterception(ctx, store)
// Default prefs: the takeover routes the open into the sidebar editor
// with the session-scoped absolute path (chat already resolved it).
await ctx.workspaces.openPath('/w/src/a.ts')
expect(opened).toEqual([{
type: 'editor',
title: 'a.ts',
path: '/w/src/a.ts',
id: 'editor:/w/src/a.ts',
}])
// The interceptOpenPath pref off → the original funnel runs untouched.
store.setPrefs({ ...store.getPrefs(), interceptOpenPath: false })
const calls: string[] = []
ctx.workspaces.openPath = async (path: string) => { calls.push(path) }
await ctx.workspaces.openPath('/w/src/b.ts')
expect(calls).toEqual(['/w/src/b.ts'])
expect(opened).toHaveLength(1)
// The editor tab disabled → falls through too (an editor that cannot
// open must not swallow opens).
store.setPrefs({ ...store.getPrefs(), interceptOpenPath: true, tabsEnabled: { editor: false } })
await ctx.workspaces.openPath('/w/src/c.ts')
expect(calls).toEqual(['/w/src/b.ts', '/w/src/c.ts'])
// Disposal restores the raw original method (HMR-safe).
restore()
expect(ctx.workspaces.openPath).toBe(original)
})
})
+1 -1
View File
@@ -16,5 +16,5 @@
"noEmit": true,
"types": ["node"]
},
"include": ["src", "tests", "tsdown.config.ts"]
"include": ["src", "tests", "tsdown.config.ts", "vitest.config.ts"]
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Vitest config: inline the npm-published `@deepseek-ai/*` packages whose
* BUILT lib bundles css side-effect imports (e.g. `dsh-client-ui-primitives`
* imports `katex/dist/katex.min.css` at the top of its `lib/index.js`).
*
* Installed from the npm registry (the default since v0.4.1) these packages
* live under `node_modules/.pnpm` and are externalized by vitest — Node then
* chokes on the `.css` import. Inlining routes them through Vite's transform,
* which stubs css imports (the default `css: false`). The previous
* `link:`-to-source-checkout install needed no such config: linked files sit
* outside `node_modules` and are transformed by default.
*/
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
server: {
deps: {
inline: [/@deepseek-ai\/dsh-client-ui-primitives/],
},
},
},
})