Compare commits

..
24 Commits
Author SHA1 Message Date
Menghuan1918 ff94d0c124 chore: release v0.4.1 — 侧边栏设置声明式化 + 小卡片网格 + 二级设置弹窗
- 版本从 0.5.0 定为 0.4.1(patch release,含未打 tag 的 v0.4.0 服务化改造)
- 文档版本标注(AGENTS.md / 设计文档)同步为 v0.4.1+
2026-08-11 14:34:18 +08:00
Menghuan1918 0e20413641 feat(settings): 小卡片响应式网格 + 二级设置原生弹窗
- 清单改为小卡片,置于 repeat(auto-fill, minmax(148px,1fr)) 响应式网格,
  一行自适应排多个、随宽度换行;卡片 = 顶行(图标+标题省略+勾选徽标
  钉在最右端)+ 类型/扩展名描述行(省略号 + title 完整值)
- 声明了 settings.toggles 的卡片右下角渲染齿轮角标(IconSettingsOutline16,
  仅父级启用时显示),点击打开原生 Modal 弹窗:DSH 原生设置行
  (标题/描述 + 品牌强调原生复选框 + hairline 分隔)
- 弹窗行体抽为 FeatureSettingsRows 供直测;Modal 条件挂载
  (常闭挂载在测试 dual-react 分裂下会崩)
- 全部 token 使用 DSH 原生别名(interactive-bg-active/button-primary-fill/
  label-* 等);规格测试改为断言网格卡片与齿轮
- 文档(README/AGENTS.md/设计文档偏差记录 5/6)同步
2026-08-11 14:27:16 +08:00
Menghuan1918 1b0d2085bb feat(settings): 开关改为可点击卡片 — 高亮即启用
- 清单项(侧边栏内容/文件预览)、嵌套相关设置与「新会话默认打开」
  全部从复选框行改为 <button aria-pressed> 卡片:整卡点击切换,
  视觉状态即状态(启用 = 品牌描边 + 交互选中填充 + 勾选徽标;
  禁用 = 中性 hairline + 文字降级),悬停仅在禁用卡上显示
- 嵌套相关设置渲染为更小的缩进卡片(仍仅父级启用时显示);
  宽度输入保持原生设置行(数值非开关)
- 设置导航齿轮为宿主 shell navIcon 硬编码(无插件注入点),
  按用户选择不改宿主、保持现状
- 新增 settingsGeneralTitle 文案;规格测试改为断言 aria-pressed
- 文档(README/AGENTS.md/设计文档偏差记录)同步
2026-08-11 14:19:18 +08:00
Menghuan1918 89ddf8a9e7 feat(settings): 侧边栏设置声明式化 — 注册表驱动的开关清单 + 内置支持插头化
- SidebarPrefs 新增 tabsEnabled / viewersEnabled 开放 map(缺省 = 启用),
  PrefsSchema 用 z.dict 校验,旧文档零迁移
- TabDescriptor/FileViewerDescriptor 新增声明式设置面:viewer 补 title/icon,
  两者补 settings.toggles(子代理声明 autoOpenSubagent、终端声明 agentTerminalTools)
- 服务新增 isTabEnabled/isViewerEnabled;openTab 拒绝禁用类型、matchFileViewer
  跳过禁用 viewer
- SideCardSection 重写为注册表驱动清单:侧边栏内容(图标+类型id+开关+嵌套开关)
  + 文件预览(图标+标题+扩展名+开关),开关持久化 + 乐观提交回滚
- 消费点 gating:+ 菜单过滤、子代理自动展开、agent 终端 reconcile、
  产出文件拦截(editor 关闭时回落默认行)、新会话 explorer 预置
- 内置 viewer 补 6 个手绘 outline 图标;store 快照携带 prefs 使开关变更即时生效
- 测试:service gating / builtins 声明 / parsePrefs map 校验 / schema 断言 /
  新增 SideCardSection renderToString 规格
- 文档:README 设置小节、AGENTS.md 描述符与服务方法、新增设计文档;版本 0.5.0
2026-08-11 14:08:55 +08:00
Atlas Zeng 89d2c65707 Merge pull request #7 from dsh-external/cleanup/drop-dshclient
chore: 移除旧字段 dshClient(只适配新 dsh 的 dsh.client 声明)
2026-08-11 12:03:19 +08:00
Menghuan1918 7c792c2a96 chore: 移除旧字段 dshClient — 只适配新 dsh 版本(dsh.client 声明)
PR #6 修复 client 半未进引导清单时保留了旧字段 dshClient 兼容。
按需精简:新 dsh 的 client-modules 扫描器只读 pkg.dsh.client,旧字段
无任何消费方,删除并同步 README 规范符合性说明。
2026-08-11 12:03:07 +08:00
Atlas Zeng f2d47f1437 Merge pull request #6 from dsh-external/fix/client-modules-declaration
fix: package.json 补 dsh.client 声明(client 半未进引导清单的根因)
2026-08-11 12:00:31 +08:00
Menghuan1918 53a9ced6bf fix(client-modules): package.json 补 dsh.client 声明 — client 半未进引导清单的根因修复
诊断(2026-08-11 重启后侧边栏不显示):host 半正常(/sidebar/api 路由
200),但页面 window.__DSH_BOOT__ 引导清单 30 个 client 插件中没有
dsh-better-sidebar。client-modules 扫描器(packages/client/modules)读
package.json 的 pkg.dsh.client 声明 + exports["./client"] 组合 bundle;
插件只声明了旧字段 dshClient(dsH 新版本约定改为 dsh.client,工作正常的
ui-genui 两者并存)。旧字段缺失 → 负判定缓存 → 无 client 入口 → 浏览器
不加载侧边栏。

修复:package.json 新增 "dsh": { "client": { inject, platform } }
与 dshClient 并存(兼容新旧 dsh);README 规范符合性章节同步字段说明,
并顺带修正上一轮安装章节重排遗留的两个失效锚点链接。
2026-08-11 12:00:15 +08:00
Atlas Zeng 1074dc8678 Merge pull request #5 from dsh-external/docs/readme-install-order
docs: README 安装章节重排 — 官方 profile 安装置顶,registry 降为可选
2026-08-11 11:55:56 +08:00
Menghuan1918 8e6d0d799c docs: 安装章节重排 — 官方 profile 安装置顶(推荐),plugin-registry 降为可选通道
实测(2026-08-11 全新安装验证)发现 registry 通道前置条件(dsh registry
命令)并非默认具备,而官方 profile 安装(link: + cordis.patch.yml 挂载行)
零前置可用。重排 README:

1. 「官方 profile 安装(推荐,无需 plugin-registry)」置顶,含一键提示词与更新流程
2. 「通过 plugin-registry 安装(可选)」降为第二章节:已集成 registry 时
   才可走该通道;通道互斥说明同步更新(不再引用章节位置)
2026-08-11 11:55:40 +08:00
Atlas Zeng 8b53c0a3ab Merge pull request #4 from dsh-external/feat/modular-registry
refactor: 全插件注册化收尾 — 审查修正 + 8/8 viewer 吃狗粮 + 模块化 builtins/
2026-08-11 11:29:49 +08:00
Atlas Zeng 2648c0f8e2 Merge pull request #3 from dsh-external/feat/service-registry
feat: 服务化改造 — ctx.betterSidebar 注册表(registerTab/registerFileViewer)+ 内置迁移到 registry,v0.4.0
2026-08-11 11:29:29 +08:00
Menghuan1918 39455ff18e docs+fix: exports 加 ./client/api 别名;catch-all+detect 纯嗅探规则;设计文档偏差记录
- package.json exports 新增 './client/api' 别名指向 service.d.ts(设计文档
  §7 的公共导入路径,api.ts 已被 fetch 助手占用故不换文件名)
- matchFileViewer 补充 sniff-only 规则:exts: [] + detect 的 descriptor 在
  无 head 时不得盲认领(AGENTS.md 的 magic-sniffer 示例原实现会吞掉
  图片/PDF 等真实 viewer 的文件);有 head 时只认领 detect 命中的。
  新增测试断言三种情形(无 head / magic 命中 / magic miss)
- docs/plans/2026-08-11-service-registry-design.md 追加 §17 实施偏差记录:
  dedupeKey+single 语法糖、available 三参、单趟匹配+嗅探规则、EditorHost/
  TextEditor 拆分、fs.read head 字段、导出别名、+ 菜单 disabled 行、
  保留的 store 级操作、内置清单、测试
- AGENTS.md:available 三参、single 语法糖、viewerId、单趟匹配+head 语义、
  8 viewer 内置清单、openTab seed.title 可选、参考实现指向 builtins/ 目录
- README 服务化章节:6 viewer → 8 viewer,标注全注册表驱动
2026-08-11 11:25:37 +08:00
Menghuan1918 1b24bc8ff2 refactor(builtins): 目录重组为 builtins/{tabs,viewers,index},清理死代码与内联样式
- 新 src/client/builtins/ 目录:tabs.tsx(6 个 tab descriptor +
  TERMINAL_LIMIT/uiTerminalCount,terminal 配额知识离开 state.ts)、
  viewers.tsx(8 个 viewer descriptor)、index.ts(registerBuiltins 聚合
  注册 + 幂等 disposer);删除旧 builtins.tsx
- icons.tsx:删除零调用者的 tabTypeIcon 及多余 import(图标已归
  descriptor.icon,映射随 tab 类型一起注册化)
- OrphanedTab:内联 style → sidebar.module.css 新增 .orphanedType 类
- 新增 tests/builtins.spec.ts(6 tab + 8 viewer 注册断言、code catch-all、
  binary-download NUL detect、office 优先级、disposer 幂等)
- 新增 tests/orphaned-tab.spec.tsx(sanitize 保留未注册类型 tab、
  OrphanedTab 占位渲染);tests/browser-globals.ts 供拉入 xterm/
  CodeMirror 的 spec 首个 import(模块求值期需要 self/document/navigator)
- service.spec 补 openDiffTab 与 diff dedupeKey 一致性用例
2026-08-11 11:22:28 +08:00
Menghuan1918 0467101faf feat(editor): 拆分 EditorHost/TextEditor — code/markdown 成为注册 viewer,fs.read 增加 head 字节
- 新 src/client/EditorHost.tsx:'editor' tab 组件。matchFileViewer →
  按 fetchStrategy 分发(纯逻辑在 editor-load.ts)→ 渲染 viewer.component;
  binary 无匹配时渲染共享 BinaryDownload。标题栏保留在 host
- 新 src/client/TextEditor.tsx:code/markdown 的 viewer 组件(CodeMirror +
  保存/脏点/截断横幅 + md 预览/编辑切换),内容由 host 经 fsRead 传入,
  自身不再 fetch;按 viewerId === 'markdown' 分支(不再解析扩展名)
- 新 src/client/editor-load.ts:planFirstMatch / planFsReadOutcome /
  decodeHead 纯函数,策略分发可单测
- 新 src/client/binary-download.tsx:共享下载兜底组件(viewer 注册与
  host fallback 同一 UI)
- host src/index.ts:fs.read 二进制响应增加 head 字段(base64,前 4KB,
  唯一 host 触碰,设计文档 detect 机制所需);客户端 FsBinaryResult 同步
- builtins:注册 8 个 viewer(新增 markdown exts md/markdown、code
  catch-all -100);binary-download 增加 NUL detect + 复用 BinaryDownload
- 删除 src/client/EditorView.tsx(职责一分为二);陈旧注释同步
- 新增 tests/editor-load.spec.ts(12 用例)
2026-08-11 11:16:36 +08:00
Menghuan1918 db0ba36610 fix(service): 注册表核心修正 — seed.title 优先、single 语法糖、available 三参、单趟匹配、dedupe 委托
- service.openTab 的 seed.title 优先于 descriptor.title(修复编辑器 tab 标题
  从文件名变成 t('editor') 的回归);title 变为可选,descriptor.title 兜底
- TabDescriptor 新增 single?: boolean 语法糖(展开为 dedupeKey: () => id),
  dedupeKey 显式给出时优先
- available 签名升级为 (ctx, scope, state) 三参(设计文档 §5.2 的超集),
  Sidebar.buildNewTabOptions 与 terminal 配额检查适配
- matchFileViewer 从三趟全局遍历改为设计文档 §8.2 的单趟 per-descriptor 算法:
  priority 降序,每个 descriptor 先 detect(head 可用时)再 exts,[] 为 catch-all
- applyDedupe 的落位逻辑委托给 state.openTabInActivePane(消除重复实现),
  同时 id 安全网现在也覆盖 service.openTab 路径(同 id 打开聚焦既有 tab)
- state.ts:删除零引用的 SIDEBAR_BUILTIN_TAB_TYPES;TERMINAL_LIMIT 移入
  builtins.tsx(state 不再关心 terminal);openTab reducer 改名 openTabInActivePane
- Sidebar.tsx 删除 TERMINAL_LIMIT/allLeaves 死 import;openDiffTab 补充
  dedupeKey 一致性注释(其按 id 去重与 diff descriptor 的 dedupeKey 同规则)
- tests/service.spec.ts 重写匹配/去重断言并新增 title/single/available 用例
2026-08-11 11:12:29 +08:00
HuanLinOTO 7687204b43 docs: 服务化接入文档(AGENTS.md)+ README 标注
- AGENTS.md: 完整接入文档(消费插件开发者面向)
  - TabDescriptor / FileViewerDescriptor 全字段
  - fetchStrategy 对照 + matchFileViewer 匹配算法
  - HMR 生命周期 + 平台约束陷阱
  - 完整最小示例(package.json + client half + profile 注册)
- README.md: 加「服务化(v0.4.0+)」章节,链接到 AGENTS.md
2026-08-11 01:44:00 +08:00
HuanLinOTO e40039ceec refactor: 完全解耦内置 tab — dedupe 归 service,图标归 descriptor
- state.openTab 简化为 '添加到 pane + id dedupe',移除 isSingle/editor-path/diff-id 三分支
  (dedupe 策略归 service.openTab 通过 descriptor.dedupeKey 统一)
- intercept.tsx openSidebarFile 改走 service.openTab(editor descriptor 的 dedupeKey 接管 per-path 去重)
- Sidebar.tsx 两处 subagent 触发(auto-activation + jump-back)改走 service.openTab
- service.openTab seed 加可选 id 字段(editor 需要 path-derived id)
- TabBar.tsx 加 getTabIcon 回调,tab 标签图标查 descriptor.icon 替代 tabTypeIcon
- split-pane.tsx 移除 tabTypeIcon 兜底(buildNewTabOptions 已从 descriptor 填 icon)
- Workbench/NodeView/LeafView 透传 getTabIcon
- tests/unit.spec.ts 更新 openTab 测试(id dedupe 替代 type dedupe)
2026-08-11 01:39:20 +08:00
HuanLinOTO 291158a3ba feat: 服务化改造 — ctx.betterSidebar 注册表(registerTab/registerFileViewer),内置 6 tab + 6 viewer 迁移到 registry,v0.4.0
- 新增 src/client/service.ts: BetterSidebarService 接口 + createBetterSidebarService 工厂
  - registerTab/registerFileViewer 返回 disposer(HMR-safe,挂到 cordis fiber)
  - matchFileViewer 三段匹配: priority desc → detect(内容嗅探) → exts
  - openTab 走 descriptor.dedupeKey 统一去重(替代 isSingle/editor-path/diff-id 三分支)
  - createTab 回调支持自定义 tab 创建(terminal 的 nextTerminal 递增)
- 新增 src/client/builtins.tsx: 6 内置 tab + 6 内置 viewer 自我注册(吃狗粮)
  - tab: editor(hidden) / explorer / git / subagent / terminal / diff(hidden)
  - viewer: image / pdf / docx / xlsx / pptx / binary-download
  - code/markdown 保持 EditorView 内联兜底(matchFileViewer 返回 undefined 时走 fsRead+CodeMirror)
- 新增 src/client/OrphanedTab.tsx: 未注册 type 的占位组件(插件未加载时降级)
- 改造 src/client/index.tsx: ctx.provide('betterSidebar') + registerBuiltins
- 改造 src/client/Sidebar.tsx: TabContent/buildNewTabOptions/onNewTab 查表替代 switch
- 改造 src/client/EditorView.tsx: 预览路由查 matchFileViewer 替代 if/else 链
- 改造 src/client/state.ts: TabType 联合字面量→string,sanitizeNode 接受所有 string type
- 改造 src/context-types.ts: declare module 'cordis' 加 betterSidebar 字段
- 改造 src/index.ts: re-export Context + 服务类型(consumer import type 触发 augmentation)
- package.json: 加 ./client/service exports 子路径,version 0.4.0
- tests/service.spec.ts: 12 个新测试覆盖 register/dispose/matchFileViewer/dedupe/createTab
- tests/unit.spec.ts: sanitizeState 测试更新(未知 type 不再被拒绝,渲染 OrphanedTab)
2026-08-11 00:31:23 +08:00
HuanLinOTO fbebcf5138 docs: 服务化改造设计(registerTab/registerFileViewer + 内置迁移到 registry) 2026-08-11 00:09:46 +08:00
Menghuan1918 069c754877 feat: Git 面板真正的 diff 显示 + VSCode 式历史与右键高级操作,v0.3.0
- 真正的统一 diff 显示:hunk 头 + 双行号 + 红绿着色,长行换行无横向滚动,untracked 全新增视图
- 点击变更/历史自动打开独立 diff tab(首次在下半栏新增分栏,同变更去重聚焦,临时页签刷新不恢复)
- 历史行:分支徽章 + 作者/相对时间,懒加载分页(20 条/页 + 加载更多)
- 右键菜单:文件行(打开/暂存/放弃更改/复制路径)、历史行(复制哈希/信息/还原/捡取),破坏性操作确认弹窗
- 修复暂存/未暂存分类错误(' M' 被误标为已暂存导致 diff 恒空)
- 修复 git 相对路径解析(会话在仓库子目录时 diff/discard/show/read 可用)
- host 新增 git.commit-diff / git.discard / git.revert / git.cherry-pick 路由与 log 分页
2026-08-10 22:29:24 +08:00
Atlas Zeng feea87557c Merge pull request #2 from dsh-external/feat/agent-terminal-tools
feat: 为 agent 暴露 8 个终端工具,侧边栏自动同步 agent 终端 tab,v0.2.0
2026-08-10 21:49:58 +08:00
Menghuan1918 8edfbfde44 fix: 修复 agent 终端工具审查问题(terminal_list 输出契约、跨会话归属、UTF-8 截断边界等),工具注入改为设置面板开关(默认关闭) 2026-08-10 21:43:58 +08:00
HuanLinOTO 6f62d19905 feat: 为 agent 暴露 8 个终端工具(terminal_create/list/send/read/wait_for/resize/signal/close),侧边栏自动同步 agent 终端 tab,v0.2.0 2026-08-10 19:08:58 +08:00
54 changed files with 7605 additions and 552 deletions
+486
View File
@@ -0,0 +1,486 @@
# dsh-better-sidebar 插件接入文档
> 面向**消费插件开发者**:如何让你的插件向 better-sidebar 注册新的侧边栏页面(tab)和文件类型预览器。
better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务(Cordis context 属性),其他插件通过 `registerTab` / `registerFileViewer` 注册扩展点,返回 disposer 由 Cordis fiber 自动管理生命周期(HMR-safe)。
---
## 1. 服务定位
- **服务名**:`betterSidebar`(即 `ctx.betterSidebar`)
- **发布侧**:better-sidebar 的 client half(`src/client/index.tsx`,通过 `ctx.provide('betterSidebar', service)` 发布)
- **消费侧**:你的插件的 client half(`inject = ['betterSidebar', ...]`,然后 `ctx.betterSidebar.registerTab(...)`)
- **类型合并**:`declare module 'cordis' { interface Context { betterSidebar: BetterSidebarService } }` 由 `dsh-better-sidebar` 包导出;消费插件 `import type {} from 'dsh-better-sidebar'` 即触发类型合并
> ⚠️ **host 半不发布此服务**:`ctx.betterSidebar` 只在 client 侧存在。如果你的插件 host 半需要读 better-sidebar 状态,走 better-sidebar 自己的 HTTP/WS 路由(`/sidebar/api/*`),不走服务。
---
## 2. 消费插件的最小骨架
### 2.1 `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.2 client half 入口
```ts
// my-plugin/src/client/index.ts
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
export const inject = ['betterSidebar', 'slots'] // 声明服务依赖
export function apply(ctx: Context): void {
// 注册一个 sidebar tab
ctx.effect(() =>
ctx.betterSidebar.registerTab({
id: 'my-plugin:db',
title: () => 'Database',
icon: <DbIcon />,
order: 50,
component: ({ ctx, scope, tab }) => <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} />,
})
)
}
```
> ⚠️ **构建期纯度门**:client bundle 禁止 value-import 别的插件代码(`tsdown.config.ts` 的纯度门会挡)。`import type {}` 会被擦除,**不触发门禁**——所以类型可以自由共享,运行时符号不行。所有运行时交互必须走 `ctx.betterSidebar` 的方法调用。
### 2.3 类型导入
```ts
import type { TabDescriptor, FileViewerDescriptor, BetterSidebarService } from 'dsh-better-sidebar'
```
类型定义在 `lib/types/client/service.d.ts`,通过 `package.json` 的 `./client/service`(别名 `./client/api`)exports 子路径暴露。
---
## 3. Tab 注册 API
### 3.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+):每个注册的 tab 都会在 Side card 设置页获得一行
* 开关(图标 + 标题 + 类型 id),`settings.toggles` 在其行下追加嵌套开关,
* 绑定 SidebarPrefs 字段。嵌套开关仅父级启用时显示。
*/
settings?: {
toggles?: readonly {
/** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools') */
key: string
title: string | (() => string)
desc?: string | (() => string)
}[]
}
/** 渲染函数 */
component: (props: TabComponentProps) => ReactNode
}
```
### 3.2 `TabComponentProps`
```ts
interface TabComponentProps {
ctx: Context // client cordis context
store: SidebarStore // better-sidebar 的状态 store(可调 reduce 等)
scope: SessionScope // { sessionId, cwd? }
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
}
```
### 3.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' })
```
**条件可见**(仅 git 仓库时显示):
```ts
ctx.effect(() =>
ctx.betterSidebar.registerTab({
id: 'my-plugin:commits',
title: 'Commits',
icon: <CommitIcon />,
order: 70,
available: (state) => hasGitRepo(state), // 返回 false 时 + 菜单显示为 disabled
dedupeKey: () => 'my-plugin:commits',
component: ({ scope }) => <CommitsView sessionId={scope.sessionId} />,
})
)
```
### 3.4 内置 tab(不可重复注册)
| id | order | single | hidden | 用途 |
|---|---|---|---|---|
| `editor` | -1 | 否(按 path 去重) | 是 | 文件编辑/预览(由 openSidebarFile 触发) |
| `explorer` | 10 | 是 | 否 | 文件资源管理器 |
| `git` | 20 | 是 | 否 | Git 面板 |
| `subagent` | 30 | 是 | 否 | 子代理拓扑 |
| `terminal` | 40 | 否 | 否 | 终端(nextTerminal 自增) |
| `diff` | -1 | 否(按 id 去重) | 是 | 差异查看(由 GitView 触发) |
你的 `id` 不可与上述重复,否则 `registerTab` 抛 `"tab type \"X\" already registered"`。
---
## 4. FileViewer 注册 API
### 4.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
}
```
### 4.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() 的返回值)
}
```
### 4.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` | 不预览,显示下载按钮 | (无) | 无客户端渲染器的二进制格式 |
### 4.4 匹配算法
`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),编辑器会用它对 `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 即可覆盖。
### 4.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} />,
})
)
```
---
## 5. 服务方法完整清单
```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
}
```
> **声明式设置(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 丢弃。
---
## 6. 生命周期与 HMR
- **disposer 必须返回**:`registerTab` / `registerFileViewer` 返回 `() => void`,Cordis fiber 卸载时自动调用。**务必**用 `ctx.effect(() => register(...))` 包裹,否则 fiber 卸载(HMR / 插件禁用)时不会撤销注册,导致下次激活时 `"already registered"` 错误。
- **注册时机**:better-sidebar 在 `apply()` 开头 `ctx.provide('betterSidebar', service)`,所以你的插件 `inject = ['betterSidebar']` 时,better-sidebar 已经就绪。
- **顺序无关**:Cordis 的 `inject` 保证服务就绪后才激活你的插件;你的插件可在 `apply` 内任意时刻注册。
- **持久化降级**:localStorage 里持久化的 tab 若其 type 未注册(你的插件未加载),渲染为 `<OrphanedTab/>` 占位卡(显示 "插件未加载" + 关闭按钮);你的插件加载后下次渲染自动恢复。
---
## 7. 平台约束与陷阱
| 陷阱 | 说明 |
|---|---|
| **构建纯度门** | client bundle 禁止 value-import `@dsh-external/*` 或非白名单的 `@deepseek-ai/*`;类型 `import type {}` 会被擦除,不触发门禁 |
| **双 cordis 实例** | 外部插件解析不到 DSH monorepo 的 cordis augmentation;better-sidebar 自己重述了 `interface Context { betterSidebar: ... }`,你 `import type {}` 即拿到类型 |
| **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`) |
---
## 8. 完整最小示例
> 假设插件 `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
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**:在 `~/.dsh/profiles/web/package.json` 的 `dependencies` 加 `"my-plugin": "link:<你的插件路径>"`,在 `cordis.patch.yml` 加挂载行,`pnpm install`,重启 `dsh web` + 浏览器硬刷新。
---
## 9. 参考实现
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/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 + 声明式元数据)
- **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`**:设计文档(含实施偏差记录)
调试时直接读这些文件即可看到所有 API 的真实用法。
+67 -31
View File
@@ -5,32 +5,7 @@
<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" />
## 🚀 通过 plugin-registry 安装(标准)
前置条件:DSH 已集成 [plugin-registry](https://github.com/dsh-external/plugin-registry)(`dsh registry` 命令可用;集成步骤见其 README)。
```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 安装与下方「不依赖 plugin-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
```
## 🚀 一键安装(把提示词发给 DSH)
## 🚀 官方 profile 安装(推荐,无需 plugin-registry)
把下面提示词**整段**发给 DSH,它会自动完成克隆、构建、注册与安装(前置条件:已安装 DSH 且 `dsh web` 可运行,Node.js ≥ 20、pnpm ≥ 10):
@@ -72,6 +47,31 @@ dsh registry enable dsh-external/dsh-better-sidebar
- 含 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 分栏对比,全在对话旁边完成,无需切窗口
@@ -104,13 +104,20 @@ dsh registry enable dsh-external/dsh-better-sidebar
### 💻 终端
- xterm.js + node-pty **真实 shell**,每会话最多 3 个
- 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 面板
- status 暂存/未暂存分组、按文件 diff(HEAD vs 工作区/暂存区)、stage/unstage(单个与全部)、commit、分支切换、提交历史
- **真正的 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 页面
@@ -147,7 +154,12 @@ dsh registry enable dsh-external/dsh-better-sidebar
- 设置页新增 **Side card(侧边卡片)** 分区,持久化到 DSH 用户设置文档:
- **新会话默认打开**:新建会话是否自动展开侧边卡片(已存在的会话保持各自布局)
- **默认宽度占比**:新建会话时侧边卡片占窗口宽度的百分比(20–60,默认 30)
- **检测到子代理时自动展开子代理页面**(默认开启):当前会话产生新的子代理时,自动展开侧边栏并打开 Subagent 页面
- **声明式的功能清单**:分区按注册表实时渲染当前支持的侧边栏内容与文件预览器(内置与外部插件注册的 tab/viewer 一视同仁),每项是一张**小卡片**,在响应式网格中**一行自适应排多个**——图标 + 类型 id + 描述,**点击整卡切换开/关,卡片高亮(品牌描边 + 勾选徽标钉在卡片最右端)即代表启用**,未启用呈灰色降级态:
- **侧边栏内容**:资源管理器 / 源代码管理 / 子代理 / 终端 / 编辑器 / 差异 等 tab 类型。关闭某类型后:`+` 菜单不再显示、无法新开(已打开的 tab 保留可继续使用,关闭后需重新开启才能再开)
- **文件预览**:图片 / PDF / Word / Excel / PPT / Markdown / 代码 / 二进制下载 等查看器,卡片上显示其覆盖的扩展名。关闭某查看器后文件预览落到下一个匹配的查看器(关闭兜底的代码查看器后无法预览的文件显示下载按钮)
- **二级设置弹窗**:声明了相关设置的卡片(子代理、终端)右下角有齿轮按钮,点击打开原生弹窗(Modal),内为 DSH 原生设置行——「检测到子代理时自动展开子代理页面」「为模型注入终端工具」等以复选框行呈现;父级卡片关闭时齿轮隐藏(相关设置随功能休眠)
- **检测到子代理时自动展开子代理页面**(默认开启):当前会话产生新的子代理时,自动展开侧边栏并打开 Subagent 页面
- **为模型注入终端工具**(默认关闭):开启后模型可通过 8 个 `terminal_*` 工具创建并操作侧边栏终端;关闭会注销工具并释放已创建的 agent 终端
## ⌨️ 快捷键速查
@@ -252,10 +264,10 @@ 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 行,见[一键安装](#一键安装把提示词发给-dsh))**或** registry 通道(`dsh registry`,见[通过 plugin-registry 安装](#通过-plugin-registry-安装标准)),二者互斥;下面的 portal 条目只描述面板在页面上的渲染方式。
插件按 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`)、`dshClient`(`platform: 'web'` + 信息性 `inject` 边)、peerDependencies、`engines`、`files` 产物明细、`prepare`(消费者侧 `tsdown`,git 安装可用)
- **清单**:`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 契约互不相同,各产出一份、同源不漂移;每个部署二选一(见安装章节「通道互斥」)
@@ -278,6 +290,30 @@ pnpm watch # tsdown --watch(client bundle 热重建)
- 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} />,
}))
}
```
**完整接入文档**:见 [`AGENTS.md`](./AGENTS.md) ——含 `TabDescriptor` / `FileViewerDescriptor` 全字段、`fetchStrategy` 对照、匹配算法、HMR 陷阱、完整最小示例。
## 🔐 安全边界
- 路由受 Host 头信任围栏保护(与 `/api` 一致;`0.0.0.0` 部署时由 `dsh web` 启动器动态派生的 LAN IP 列表生效)
@@ -0,0 +1,137 @@
# 侧边栏设置声明式化 + 内置支持"插头化"设计
**日期**:2026-08-11
**状态**:已实施(本文档含实施偏差记录)
**目标版本**:v0.4.1
## 1. 目标
1. 把 DSH 设置页的「侧边卡片」分区从硬编码 4 行改为**注册表驱动的声明式清单**:
- 展示当前注册的所有侧边栏内容(tab 类型)与文件查看器(viewer),每项可**直接打开/关闭**;
- 每项声明式元数据:**类型(id)、图标(SVG)、相关设置**(如子代理的"检测到子代理就自动打开")。
2. 内置支持(资源管理 / Git / 终端 / 子代理 / 编辑器 / 差异)与 8 个文件预览器继续以**插头(descriptor)形式**注册,并在描述符上携带上述声明。
## 2. 非目标
- 不改宿主路由 / pty / tools 的 gating 语义:`agentTerminalTools` 仍是独立布尔偏好(宿主 `syncToolsGate` 零改动),只是它的**设置行渲染位置**改由终端 tab 描述符声明。
- 不做"已打开 tab 随禁用即时关闭"(保留式语义:禁用只影响新开,已打开 tab 继续渲染)。
- 不实现外部插件自定义 settings 键的动态 schema(见 §6 限制)。
- 不迁移 bundle 形态、不加依赖、不动 peerDependencies。
## 3. 现状回顾
- `SideCardSection.tsx` 硬编码 4 行:`openByDefault` / `defaultWidthPercent` / `autoOpenSubagent` / `agentTerminalTools`。
- `PrefsSchema`(`config.ts`)为扁平对象,`parsePrefs`(`client/prefs.ts`)逐字段校验。
- 内置 tab/viewer 已通过 `registerBuiltins` 走同一注册表(v0.4.0),但描述符缺少设置面元数据:viewer 无 `title`/`icon`,tab 无相关设置声明。
- 服务无启用态概念:`getTabs`/`matchFileViewer`/`openTab` 不过滤。
## 4. 设计
### 4.1 偏好扩展(`prefs-shared.ts` / `config.ts` / `client/prefs.ts`)
```ts
interface SidebarPrefs {
openByDefault: boolean
defaultWidthPercent: number
autoOpenSubagent: boolean
agentTerminalTools: boolean
tabsEnabled: Record<string, boolean> // 按 tab descriptor id;缺省 = 启用
viewersEnabled: Record<string, boolean> // 按 viewer descriptor id;缺省 = 启用
}
```
- schema:`z.dict(z.boolean()).default({})`(schemastery `dict`);旧文档自动解析为 `{}`(全开),零迁移。
- `parsePrefs`:record-of-boolean 校验(非对象/非布尔值回退 `{}` / 丢弃该键)。
- 设置页开关写入 `tabsEnabled: { ...prefs.tabsEnabled, [id]: next }`(viewers 同理)。
### 4.2 声明式描述符(`client/service.ts`)
```ts
interface SidebarSettingToggle {
key: string // SidebarPrefs 字段名('autoOpenSubagent')
title: string | (() => string)
desc?: string | (() => string)
}
interface SidebarSettingsDeclaration {
toggles?: readonly SidebarSettingToggle[]
}
// TabDescriptor 增加:
settings?: SidebarSettingsDeclaration
// FileViewerDescriptor 增加(设置清单展示用):
title?: string | (() => string) // 缺省回退 id
icon?: ReactNode | ((size) => ReactNode)
settings?: SidebarSettingsDeclaration
```
服务新增:
```ts
isTabEnabled(id: string): boolean // store.getPrefs().tabsEnabled[id] !== false
isViewerEnabled(id: string): boolean
```
行为变化:
- `openTab`:类型被禁用 → no-op(`console.warn` 提示);`matchFileViewer`:跳过禁用 viewer(文件落到下一个匹配或下载按钮)。
- `getTabs()/getFileViewers()` 仍返回**全部**注册项(设置页清单必须看到已禁用的项)。
### 4.3 内置声明(`builtins/tabs.tsx` / `builtins/viewers.tsx`)
| tab | 相关设置声明 |
|---|---|
| subagent | `toggles: [{ key: 'autoOpenSubagent', ... }]` |
| terminal | `toggles: [{ key: 'agentTerminalTools', ... }]` |
| explorer/git/editor/diff | 无(仅开关) |
8 个 viewer 全部补 `title`(locales 新增 `viewerImage`…`viewerBinary`)+ `icon`(`icons.tsx` 新增 6 个手绘 outline SVG:image/pdf/docx/xlsx/pptx/markdown;code/binary-download 复用 `IconCodeOutline16` / `IconDownloadOutline16`)。
### 4.4 Store 快照携带 prefs(`client/state.ts`)
`SidebarSnapshot` 增加 `prefs` 字段;`setPrefs` 更新快照并 `notify()`(`setSession`/`update`/`reduce` 构造快照时带上当前 prefs)。效果:+ 菜单等 `useSyncExternalStore` 消费者在设置变更后**立即**重渲染(禁用 git → + 菜单马上消失)。
`makeDefaultState(width, panelOpen, seedExplorer = true)` 增加第三参;`loadState` 在 `tabsEnabled['explorer'] === false` 时传 `false`——新会话不再预置 explorer tab(与"已存在会话保持布局"语义并列:禁用只影响新布局)。
### 4.5 消费点 gating
| 消费点 | 行为 |
|---|---|
| + 菜单(`buildNewTabOptions`) | `filter(!d.hidden && service.isTabEnabled(d.id))` |
| 子代理自动展开 effect | 追加 `isTabEnabled('subagent') === false` 早退(禁用时不展开面板) |
| agent 终端 reconcile(WS 推送) | `isTabEnabled('terminal') === false` 时忽略推送(不自动补 tab;重新启用后下一次推送自愈) |
| 产出文件拦截(`intercept.tsx` select) | `tabsEnabled['editor'] === false` 时 select 返回 null(回落默认交付行) |
| `openTab` / `matchFileViewer` | 见 §4.2 |
| 新会话默认布局 | explorer 禁用 → 空 pane |
### 4.6 设置页声明式渲染(`SideCardSection.tsx`)
- `inject: () => ({ store, service })`(`index.tsx` 闭包直接捕获 service)。
- 注册表清单:本地 state + `service.subscribe` 同步(注册变化罕见,无需 external-store)。
- 行结构:
- **常规**:`openByDefault`、宽度(既有逻辑)。
- **侧边栏内容**:按 `order` 升序、`hidden` 置后遍历 tabs——图标(16) + 标题 + desc=`tab.id`(类型)+ 开关;`settings.toggles` 渲染为缩进子行(仅父级启用时显示,绑定 `prefs[toggle.key]`)。
- **文件预览**:按 priority 降序遍历 viewers——图标 + `title ?? id` + desc=`exts.join(' · ') || t('settingsViewerCatchAll')` + 开关。
- 提交机制不变:串行化 + revision 守卫 + 失败回滚 + `store.setPrefs` 同步。
## 5. 测试
- `service.spec.ts`:启用态缺省 true;`openTab` 拒绝禁用类型;`matchFileViewer` 跳过禁用 viewer(含兜底 code 被关 → undefined)。
- `builtins.spec.ts`:subagent/terminal 的 toggles 声明;全部 tab 有 icon;全部 viewer 有 title+icon。
- `unit.spec.ts`:`parsePrefs` 的 map 校验(非对象/非布尔回退)+ 新字段默认值;`loadState` explorer 禁用 → 空 pane。
- `plugin-shape.spec.ts` / `smoke.spec.ts`:schema 解析与 settings 路由断言补新字段。
- `side-card-section.spec.tsx`(新增):`renderToString` 断言声明式行(图标/标题/id/exts/开关/嵌套开关隐藏规则)。
## 6. 限制与失败模式
- **外部插件的自定义 settings 键**:`toggles[].key` 必须是宿主 `PrefsSchema` 的字段(内置:`autoOpenSubagent` / `agentTerminalTools`);未知键会被 settings seam 丢弃(schemastery object 非严格模式放行未知键但 schema 不含其校验)。文档注明。
- 设置路由失败 / 文档缺失:沿用既有降级(schema 默认 + 本地 state,开关默认全开)。
- 已打开 tab 的类型被禁用:tab 保留可继续使用;关闭后无法重开。
- 父级(子代理/终端)关闭时嵌套开关隐藏,其值保留在 prefs 不销毁。
## 7. 实施偏差记录
1. **嵌套开关的"父级关闭即隐藏"**:设计初稿曾考虑始终显示嵌套开关;实施决定仅在父级启用时渲染(避免"autoOpenSubagent 已开但子代理页面被关"的无效组合),值保留。
2. **宿主 `syncToolsGate` 不改**:`agentTerminalTools` 仅随自身开关走,不与 `tabsEnabled['terminal']` 联动(模型终端工具是模型面向能力,与侧边栏 UI 开关解耦)。
3. **`renderRow` 统一行渲染**:tab/viewer/嵌套开关共用一行渲染器(`sub` 参数区分缩进),CSS 新增 `.sectionHeading` / `.rowIcon` / `.titleLine` / `.subRow`。
4. **清单排序**:tabs 按 `hidden` 置后 + `order` 升序(editor/diff 殿后);viewers 按 priority 降序(code 兜底最后)。
5. **开关改为卡片点击(v0.4.1 追加)**:清单项与嵌套相关设置、`openByDefault` 全部从"复选框行"改为**可点击卡片**——`<button aria-pressed>`,整卡即开关,视觉状态即状态(启用 = 品牌描边 + 交互选中填充 + `IconCheckOutline16` 勾选徽标;禁用 = 中性 hairline + 文字降级)。嵌套相关设置渲染为更小的缩进卡片;宽度输入保持原生设置行(它是数值不是开关)。设置导航齿轮图标为宿主 shell 硬编码(`navIcon` 按 section id 映射,无插件注入点),用户选择不改宿主,保持现状。
6. **小卡片响应式网格 + 二级设置弹窗(v0.4.1 再追加)**:清单项改为**小卡片**,置于 `repeat(auto-fill, minmax(148px, 1fr))` 响应式网格(一行自适应多个,随宽度换行);卡片结构 = 顶行(图标 + 标题省略 + **勾选徽标钉在最右端**)+ 类型/扩展名描述行(省略号截断,`title` 属性给出完整值)。声明了 `settings.toggles` 的卡片右下角渲染**齿轮角标按钮**(`IconSettingsOutline16`,仅父级启用时显示),点击打开**原生 `Modal` 弹窗**,内含 DSH 原生设置行(标题/描述 + 品牌强调原生复选框 + hairline 分隔,末行去分隔线)——二级设置不再内嵌于网格。实现细节:Modal 采用条件挂载(`settingsFor !== null && <Modal open …>`),因为 Modal 原语无条件调用 hooks,常闭挂载在测试的 dual-react(react 18.2 server / 18.3 bundled)分裂环境下会崩;弹窗行体抽成 `FeatureSettingsRows` 供 renderToString 直测。
@@ -0,0 +1,416 @@
# DSH-better-sidebar 服务化改造设计
**日期**:2026-08-11
**状态**:已批准,待实施
**作者**:opencode + 用户
**当前版本**:v0.3.0(main `069c754`)
**目标版本**:v0.4.0
## 1. 目标
将 `dsh-better-sidebar` 从一个"纯消费方"插件改造为"服务提供者":
1. 允许其他 DSH 插件向 better-sidebar **注册新的侧边栏页面(tab 类型)**
2. 允许其他 DSH 插件向 better-sidebar **注册文件类型预览器**
3. 把现有 5 个内置 tab 和 6 种内置文件预览器**全部迁移到新注册表**(吃自己的狗粮)
## 2. 非目标(Out of Scope)
- **不动 host 半**(`src/index.ts` 的 HTTP 路由 / pty / tools / settings)
- **不迁移到标准 bundle 形态**(不加 `cordis.patch.yml`、不弃用 `dsh.plugin.json`、不放弃 `client-registry.js`)—— 作为独立工作
- 不动 server 端 `ctx.provide`:本次纯 client 端服务
## 3. 现状回顾
### 3.1 Tab 系统硬编码点(7 处)
| 文件:行 | 用途 |
|---|---|
| `src/client/state.ts:14` | `TabType` 联合类型(5 字面量) |
| `src/client/state.ts:295-297` | `isSingle(type)` 单实例判定 |
| `src/client/state.ts:555-561` | `sanitizeNode` 白名单校验 |
| `src/client/Sidebar.tsx:56-83` | `TabContent` switch 渲染 |
| `src/client/Sidebar.tsx:87-102` | `buildNewTabOptions` + 菜单项 |
| `src/client/Sidebar.tsx:341-364` | `onNewTab` 创建分支 |
| `src/client/icons.tsx:26-33` | `tabTypeIcon` 图标 switch |
### 3.2 文件预览硬编码点
- `src/client/EditorView.tsx:58-106`:顺序 if/else 选 `load.kind`
- `src/client/EditorView.tsx:251-272`:if 链按 `load.kind` 渲染组件
- 4 个预览组件(DocxView / XlsxView / PptxView / PdfView)静态 import 在 `EditorView.tsx:22-26`
- 3 个纯函数模块(`office-types.ts` / `image-types.ts` / `pdf-types.ts`)+ `lang.ts` 已半抽象,可作为内置注册器的实现细节复用
### 3.3 服务暴露现状
- `src/index.ts` / `src/client/index.tsx` 全文搜 `ctx.provide` / `Context.service` / `extends Service` —— **零命中**
- `package.json` 的 `exports` 没有暴露任何供外部 import 的注册 API
- README:262 已承认 portal 挂载的限制(整面板 slot 不可用),但未提服务化方案
### 3.4 平台约束
- **构建期纯度门**(`tsdown.config.ts:177-180`):client bundle 禁止 value-import 别的插件代码;**type-only import 会被擦除,不触发门禁**
- **双 cordis 实例**:外部插件解析不到 DSH monorepo 的 cordis augmentation,必须由 better-sidebar 自己导出 `declare module 'cordis'` augmentation
- **ModuleLoader 运行时支持跨 bundle require,但被构建门挡** → 协作只能走 cordis 服务或 slot
## 4. 方案选型
| 方案 | 机制 | 评估 |
|---|---|---|
| **A. `ctx.provide` + 服务对象(采纳)** | client `apply()` 内 `ctx.provide('betterSidebar', service)`,service 暴露 `registerTab/registerFileViewer` 返回 disposer | registry 语义自然;disposer 生命周期贴合 cordis fiber;precedent:`dsh/packages/client/locale/src/client/index.ts:362` |
| B. `ctx.slots.inject` 声明式 slot | 声明 `betterSidebar.tabType` 等 slot | 偏 UI 链替换,对带元数据 + disposer 的 registry 别扭 |
| C. `extends Service` 类形式 | 完整 Service 类 + Config schema | client 半不支持 `extends Service`;本次纯 client,没必要 |
**采纳方案 A**。
## 5. 服务接口设计
### 5.1 主服务接口
```ts
// 新文件 src/client/api.ts
export interface BetterSidebarService {
/** 注册一种 tab 类型;返回 disposer */
registerTab(descriptor: TabDescriptor): () => void
/** 注册一种文件预览器;返回 disposer */
registerFileViewer(descriptor: FileViewerDescriptor): () => void
/** 运行时快照(同步,供 React useSyncExternalStore 用) */
getTabs(): readonly TabDescriptor[]
getFileViewers(): readonly FileViewerDescriptor[]
/** 匹配文件的预览器(按 priority 降序、exts/includes、detect() 三段匹配) */
matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined
/** 工具方法:让外部 tab 也能开/关 tab */
openTab(tab: { type: string; title: string; path?: string }): void
closeTab(tabId: string): void
/** 订阅 registry 变化(注册/卸载时触发) */
subscribe(listener: () => void): () => void
}
```
### 5.2 TabDescriptor
```ts
export interface TabDescriptor {
id: string // 唯一,如 'explorer' / 'my-plugin:db'
title: string | (() => string) // i18n 友好
icon?: ReactNode | ((size: number) => ReactNode)
order?: number // + 菜单排序,默认 100
single?: boolean // 单实例(explorer/git/subagent)
hidden?: boolean // 不出现在 + 菜单(editor 用)
available?: (ctx: Context, scope: SessionScope) => boolean
component: (props: TabComponentProps) => ReactNode
}
export interface TabComponentProps {
ctx: Context
store: SidebarStore
scope: SessionScope
tab: SidebarTab
}
```
### 5.3 FileViewerDescriptor
```ts
export interface FileViewerDescriptor {
id: string // 'image' / 'pdf' / 'my-plugin:csv'
exts: readonly string[] // 小写无点,如 ['png','jpg']
priority?: number // 高优先;默认 0;内置默认 0;兜底 code 用 -100
fetchStrategy: 'none' | 'fsRead' | 'mediaUrl' | 'custom' | 'binary-download'
detect?: (path: string, head: Uint8Array) => boolean // 内容嗅探(覆盖 exts)
load?: (path: string, scope: SessionScope) => Promise<unknown> // fetchStrategy='custom' 时
component: (props: FileViewerProps) => ReactNode
}
export interface FileViewerProps {
ctx: Context
store: SidebarStore
scope: SessionScope
path: string
title: string
content?: string // fetchStrategy='fsRead' 时
truncated?: boolean
mediaUrl?: string // fetchStrategy='mediaUrl' 时
customData?: unknown // fetchStrategy='custom' 时 load() 的返回值
}
```
## 6. Cordis 类型合并 outlet
```ts
// 新文件 src/types.ts (纯类型)
import type { BetterSidebarService } from './client/api'
declare module 'cordis' {
interface Context {
betterSidebar: BetterSidebarService
}
}
```
通过 `src/index.ts` 的 `export type * from './types.ts'` re-export 到主入口。Consumer 插件 `import type {} from 'dsh-better-sidebar'` 触发合并。
## 7. package.json exports 新增
```jsonc
"exports": {
".": { ... },
"./invariant": { ... },
"./client": { ... },
"./client/api": { // 新增,纯类型导出
"types": "./lib/types/client/api.d.ts",
"default": "./lib/client.js" // 运行时落点回到 client bundle(不会被 value import)
},
"./src/*": "./src/*",
"./package.json": "./package.json"
}
```
`files` 字段相应加 `lib/types/client/api.d.ts`。
## 8. 内置 tab 自我注册(吃狗粮)
`src/client/index.tsx` 的 `apply()` 内:
```ts
const service = createBetterSidebarService(store)
ctx.provide('betterSidebar', service)
registerBuiltins(service) // 5 个内置 tab + 内置 viewer 全部用 service.registerTab/registerFileViewer
```
### 8.1 5 个内置 tab
| id | order | single | hidden | available | 组件 |
|---|---|---|---|---|---|
| `editor` | -1 | false | true | always | `EditorView`(由 open-file 流程触发,不在 + 菜单) |
| `explorer` | 10 | true | false | always | `ExplorerView` |
| `git` | 20 | true | false | 检查 `.git` 存在 | `GitView` |
| `subagent` | 30 | true | false | always | `SubagentView` |
| `terminal` | 40 | false | false | 检查 terminal 配额 | `TerminalView` |
### 8.2 内置 file viewer
| id | exts | priority | fetchStrategy | 组件 |
|---|---|---|---|---|
| `image` | png/jpg/jpeg/gif/webp/svg/bmp/ico/avif | 0 | mediaUrl | `<img>` |
| `pdf` | pdf | 0 | mediaUrl | `PdfView` |
| `docx` | docx | 0 | mediaUrl | `DocxView` |
| `xlsx` | xlsx | 0 | mediaUrl | `XlsxView` |
| `pptx` | pptx | 0 | mediaUrl | `PptxView` |
| `markdown` | md/markdown | 0 | fsRead | `<MarkdownText>` |
| `code` | [](兜底) | -100 | fsRead | CodeMirror |
| `binary-download` | doc/xls/ppt + NUL 探测为 binary | -50 | binary-download | 下载按钮 |
`matchFileViewer(path, head?)` 算法:
1. 按 `priority` 降序遍历
2. 若 `descriptor.detect` 提供且 `head` 可用 → 调 `detect(path, head)`,true 则命中
3. 否则匹配 `exts`(小写无点)
4. 全部 miss → 返回 `undefined`(caller 走兜底 `binary-download`)
## 9. 7 处硬编码 switch 改造点
| 文件:行 | 原状 | 改造后 |
|---|---|---|
| `src/client/state.ts:14` | `TabType` 联合 5 字面量 | `type TabType = string`;保留 `SIDEBAR_BUILTIN_TAB_TYPES` 常量数组供向后兼容 |
| `src/client/state.ts:295-297` `isSingle` | switch 5 字面量 | `(type) => service.getTabs().find(t => t.id === type)?.single ?? false`;store 持有 service 引用 |
| `src/client/state.ts:555-561` `sanitizeNode` | 白名单 5 type | 未注册 type 标记 `orphaned: true` 不丢弃,渲染占位卡 |
| `src/client/Sidebar.tsx:56-83` `TabContent` | switch 5 分支 | 查 `service.getTabs().find(t => t.id === tab.type)`,调 `descriptor.component(props)`;未找到渲染 `<OrphanedTab/>` |
| `src/client/Sidebar.tsx:87-102` `buildNewTabOptions` | 硬编码 4 项 | `service.getTabs().filter(t => !t.hidden && (t.available?.(ctx, scope) ?? true)).sort(byOrder).map(...)` |
| `src/client/Sidebar.tsx:341-364` `onNewTab` | if optionId === 'explorer' 等 | `service.openTab({ type: optionId, title: descriptor.title() })` |
| `src/client/icons.tsx:26-33` `tabTypeIcon` | switch 5 分支 | 废弃;调用方直接读 `descriptor.icon` |
## 10. 文件预览路由改造
### 10.1 `src/client/EditorView.tsx:58-106`(选预览器)
```ts
const viewer = service.matchFileViewer(path, head?)
if (!viewer || viewer.fetchStrategy === 'binary-download') {
setLoad({ status: 'binary' })
return
}
switch (viewer.fetchStrategy) {
case 'mediaUrl': setLoad({ status: 'ready', kind: 'mediaUrl' }); break
case 'fsRead':
api.fsRead(scope, path).then(({ content, truncated }) =>
setLoad({ status: 'ready', kind: 'fsRead', content, truncated })
)
break
case 'custom':
viewer.load(path, scope).then(data =>
setLoad({ status: 'ready', kind: 'custom', customData: data })
)
break
case 'none': setLoad({ status: 'ready', kind: 'none' }); break
}
```
### 10.2 `src/client/EditorView.tsx:251-272`(渲染)
```ts
const viewer = service.matchFileViewer(path)
if (!viewer) return <BinaryDownload ... />
return <viewer.component {...props} />
```
4 个内置预览组件(DocxView/XlsxView/PptxView/PdfView)从 `EditorView.tsx` 静态 import 移到 `registerBuiltins` 内部 import + 注册。
## 11. 持久化与降级
- localStorage 里 `orphaned` tab:渲染 `<OrphanedTab title="..." hint="Plugin not loaded: ${tab.type}"/>` + 关闭按钮
- `sanitizeState` / `sanitizeNode` 不再丢弃未识别 type,改为标记 `orphaned`,保留 `title/path` 以备插件恢复时复用
## 12. Consumer 插件使用范例
### 12.1 Consumer `package.json`
```jsonc
{
"peerDependencies": {
"cordis": "^4.0.0-rc.7",
"dsh-better-sidebar": "workspace:*"
},
"peerDependenciesMeta": {
"dsh-better-sidebar": { "optional": true }
}
}
```
### 12.2 Consumer client half
```ts
// my-plugin/src/client/index.tsx
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
export const inject = ['betterSidebar', 'slots']
export function apply(ctx: Context): void {
ctx.effect(() =>
ctx.betterSidebar.registerTab({
id: 'my-plugin:db',
title: () => 'Database',
icon: <DbIcon />,
order: 50,
component: ({ scope, store, tab }) => <DbView scope={scope} />,
})
)
ctx.effect(() =>
ctx.betterSidebar.registerFileViewer({
id: 'my-plugin:csv',
exts: ['csv'],
fetchStrategy: 'custom',
load: async (path, scope) => parseCsv(await fetchCsv(scope, path)),
component: ({ customData, path }) => <CsvGrid data={customData} path={path} />,
})
)
}
```
## 13. 测试策略
### 13.1 单元测试
- `tests/service.spec.ts`:`createBetterSidebarService` 的 register/dispose/subscribe 生命周期
- `tests/match-file-viewer.spec.ts`:`matchFileViewer` 的 priority + exts + detect 三段匹配算法
- `tests/builtins.spec.ts`:5 个内置 tab + 8 个内置 viewer 都正确注册
- 现有 `tests/state.spec.ts` / `tests/office-types.spec.ts` / `tests/image-types.spec.ts` 保持通过
### 13.2 集成测试
- `tests/orphaned-tab.spec.ts`:localStorage 里有未注册 type 的 tab → 渲染 `<OrphanedTab/>`,注册后恢复
### 13.3 现有 manifest 守卫
- `tests/manifest-consistency.spec.ts` 的 `contributes.tools/skills` 空数组守卫保持通过
- `CLIENT_EXTERNALS` 列表守卫保持通过(不新增外部 value import)
## 14. 风险与缓解
| 风险 | 缓解 |
|---|---|
| 双 cordis 实例导致 consumer 拿不到 `ctx.betterSidebar` 类型 | 由 dsh-better-sidebar 自己 `declare module 'cordis'`,consumer `import type {}` 触发合并 |
| 注册顺序:consumer 在 better-sidebar 之前激活导致 inject 等待 | cordis `inject` 自带就绪等待,consumer 不会在服务就绪前激活 |
| HMR 时 disposer 漏调导致注册项残留 | `ctx.effect(() => disposer)` 挂到 fiber,卸载时自动调 |
| localStorage 里旧版本的 `TabType` 字面量 tab | `sanitizeNode` 改为标记 `orphaned` 不丢弃,向后兼容 |
| `EditorView` 的 `kind` 字段类型从联合字面量变成 string | 内部 `load` 状态类型加 `kind: string` + `customData?: unknown`,TypeScript 收窄在渲染层做 |
| 兜底 `code` viewer priority -100 与外部 `binary-download` -50 冲突 | `matchFileViewer` 先尝试 `detect`(NUL 探测为 binary),再走 exts;外部 `binary-download` 的 `exts: ['doc','xls','ppt']` 优先于兜底 `code` 的 `exts: []` |
## 15. 版本与发布
- 版本号:`v0.4.0`(破坏性:`TabType` 从联合字面量改 string)
- peerDependencies 不变(不加新依赖)
- `dsh.plugin.json` 的 `version` 同步更新到 `0.4.0`
- 重建 `lib/` + 推送 main + profile 更新
## 16. 实施顺序(高层)
1. 拉取 main v0.3.0 到工作分支
2. 新建 `src/client/api.ts`(类型 + `createBetterSidebarService` 工厂)
3. 新建 `src/types.ts`(`declare module 'cordis'` augmentation)+ `src/index.ts` re-export
4. `src/client/index.tsx` 改造:`ctx.provide('betterSidebar', service)` + `registerBuiltins(service)`
5. 抽 `registerBuiltins.ts`:5 个内置 tab + 8 个内置 viewer 注册
6. 改造 7 处硬编码 switch(state.ts / Sidebar.tsx / icons.tsx)
7. 改造 `EditorView.tsx` 的选预览器 + 渲染两段
8. `package.json` 加 `./client/api` exports + `files` 字段
9. 加 `<OrphanedTab/>` 组件 + sanitize 降级
10. 写测试(service / matchFileViewer / builtins / orphaned)
11. typecheck + test + build 三件套
12. 重建 lib/,提交,推送,更新 profile web
详细实施计划由 writing-plans skill 生成。
---
## 17. 实施偏差记录(2026-08-11,feat/modular-registry)
实现(PR #3 + 本分支)与上文设计有意或被迫不同的点,逐条记录。文档以本节为准。
### 17.1 去重机制:`dedupeKey` 为唯一机制,`single` 是其语法糖
§5.2 的 `single?: boolean` 与实现的 `dedupeKey` 合并为单一机制:
- `dedupeKey?: (tab) => string | undefined` 是唯一去重规则(单实例 `() => id`、按 path、按 id 三种旧策略统一表达);
- `single: true` ≡ `dedupeKey: () => id`(语法糖,显式 dedupeKey 优先)。
### 17.2 `available` 三参签名
§5.2 的 `available?: (ctx, scope) => boolean` 实现为 `(ctx, scope, state) => boolean`(state 用于 terminal 配额等 UI 状态判定,是设计签名的超集)。
### 17.3 匹配算法:单趟 per-descriptor,非三趟全局
§8.2 的算法(priority 降序逐 descriptor,detect 或 exts 二选一)为准;实现确认单趟,并补充一条规则:**`exts: []` + `detect` 是纯嗅探模式**——无 head 时不认领任何文件(避免 magic-sniffer 吞掉图片/PDF 等真实 viewer 的文件),有 head 时只认领 detect 命中的。盲 catch-all(无 detect)行为不变。
### 17.4 编辑器拆分:EditorHost + TextEditor(code/markdown 成为注册 viewer)
§10 的 EditorView 职责一分为二:
- `EditorHost`('editor' tab 组件):标题栏 + `matchFileViewer` → 按 fetchStrategy 分发(纯逻辑在 `editor-load.ts` 的 `planFirstMatch`/`planFsReadOutcome`)→ 渲染 viewer.component;binary 无匹配渲染共享 `BinaryDownload`;
- `TextEditor`(viewer 组件):code/markdown 的 CodeMirror 编辑 + md 预览/编辑切换 + 保存/脏点,内容由 host 经 fsRead 传入,按 `viewerId === 'markdown'` 分支。
- 代码/保存按钮从标题行移到内容区第二行(VSCode 式工具栏,有意 UX 变更)。
### 17.5 `fs.read` 增加 `head` 字段(唯一 host 触碰)
§8.2/§14 的 detect 机制需要 head 字节:host `fs.read` 对二进制响应附加 `head`(base64,前 4KB,常量 `READ_HEAD_LIMIT`)。客户端在 fsRead 结果为二进制时用 head 重匹配一次(无额外 IO),NUL 探测因此成立。向后兼容(新字段)。纯文本文件不触发重匹配(文本嗅探请用 exts 或 custom 策略)。
### 17.6 导出路径:`./client/service` 为主,`./client/api` 为别名
§7 要求 `./client/api`,但 `api.ts` 已被既有的 fetch 助手占用;package.json exports 增加 `"./client/api"` 别名指向 `lib/types/client/service.d.ts`,公共导入路径满足设计,内部文件名不变。
### 17.7 + 菜单:disabled 行而非 filter
§9 的 `filter(!hidden && available)` 实现为"available 返回 false 显示 disabled 行"(与 v0.3.0 旧 UX 一致;终端配额满时用户能看到选项为何不可用)。行为有意保留,文档更新为现状。
### 17.8 保留的 store 级操作(非注册表职责)
- `openDiffTab`(state reducer):diff 的 split 树放置手术(粘性 diff pane、首次拆分下方),保留;其按 id 去重与 diff descriptor 的 `dedupeKey: (tab) => tab.id` 同规则(测试断言两者一致)。
- `reconcileAgentTerminals`:agent 终端列表同步(host 推送驱动),直接落 state(`openTabInActivePane`),不经过 service(terminal 的 createTab 语义不适用)。
- 命名:state.ts 的 `openTab` reducer 改名 `openTabInActivePane`(与 `service.openTab` 区分)。
### 17.9 内置清单
- tab 6 个不变(editor/explorer/git/subagent/terminal/diff),explorer/git/subagent 改用 `single: true`。
- viewer 8 个:image/pdf/docx/xlsx/pptx(mediaUrl)+ markdown(fsRead)+ code(fsRead,catch-all -100)+ binary-download(-50,exts doc/xls/ppt + NUL detect)。
- `SIDEBAR_BUILTIN_TAB_TYPES` 常量删除(零引用);`tabTypeIcon` 删除(图标归 descriptor.icon)。
### 17.10 测试
新增 `tests/builtins.spec.ts`(内置注册清单)、`tests/orphaned-tab.spec.tsx`(sanitize 保留未注册类型 + 占位渲染)、`tests/editor-load.spec.ts`(策略分发纯函数);`tests/browser-globals.ts` 为拉入 xterm/CodeMirror 的 spec 提供模块求值期浏览器全局 mock。`matchFileViewer` 相关断言按 17.3 语义重写。
+2 -2
View File
@@ -1,8 +1,8 @@
{
"id": "dsh-external/dsh-better-sidebar",
"version": "0.1.9",
"version": "0.4.1",
"main": "./lib/index.js",
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git,按会话隔离",
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
"engines": {
"dsh": ">=0.0.1"
},
+25 -9
View File
@@ -1,7 +1,7 @@
{
"name": "dsh-better-sidebar",
"version": "0.1.9",
"description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git), isolated per conversation session.",
"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.",
"private": true,
"type": "module",
"main": "lib/index.js",
@@ -19,19 +19,29 @@
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./client/service": {
"types": "./lib/types/client/service.d.ts",
"default": "./lib/client.js"
},
"./client/api": {
"types": "./lib/types/client/service.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"engines": {
"node": ">=20"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-slots",
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-slots",
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
}
},
"files": [
"lib/index.js",
@@ -50,6 +60,7 @@
},
"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",
@@ -59,8 +70,10 @@
"@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",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0",
"react-dom": "^18.2.0"
@@ -101,6 +114,7 @@
"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",
@@ -110,8 +124,10 @@
"@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",
"@types/node": "^24.0.0",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.1",
+20 -11
View File
@@ -111,39 +111,48 @@ importers:
'@cordisjs/plugin-loader':
specifier: ^1.0.0-rc.5
version: 1.0.0-rc.5(cordis@4.0.0-rc.7)
'@deepseek-ai/dsh-agent':
specifier: link:/Users/menghuan/.dsh/source/current/packages/core/agent
version: link:/Users/menghuan/.dsh/source/current/packages/core/agent
'@deepseek-ai/dsh-client-runtime':
specifier: link:/Users/menghuan/.dsh/source/current/packages/client/runtime
version: link:../../.dsh/source/current/packages/client/runtime
version: link:/Users/menghuan/.dsh/source/current/packages/client/runtime
'@deepseek-ai/dsh-client-schema-form':
specifier: link:/Users/menghuan/.dsh/source/current/packages/client/schema-form
version: link:../../.dsh/source/current/packages/client/schema-form
version: link:/Users/menghuan/.dsh/source/current/packages/client/schema-form
'@deepseek-ai/dsh-client-ui-conversation':
specifier: link:/Users/menghuan/.dsh/source/current/packages/client/ui-conversation
version: link:../../.dsh/source/current/packages/client/ui-conversation
version: link:/Users/menghuan/.dsh/source/current/packages/client/ui-conversation
'@deepseek-ai/dsh-client-ui-primitives':
specifier: link:/Users/menghuan/.dsh/source/current/packages/client/ui-primitives
version: link:../../.dsh/source/current/packages/client/ui-primitives
version: link:/Users/menghuan/.dsh/source/current/packages/client/ui-primitives
'@deepseek-ai/dsh-client-ui-settings':
specifier: link:/Users/menghuan/.dsh/source/current/packages/client/ui-settings
version: link:../../.dsh/source/current/packages/client/ui-settings
version: link:/Users/menghuan/.dsh/source/current/packages/client/ui-settings
'@deepseek-ai/dsh-client-ui-slots':
specifier: link:/Users/menghuan/.dsh/source/current/packages/client/ui-slots
version: link:../../.dsh/source/current/packages/client/ui-slots
version: link:/Users/menghuan/.dsh/source/current/packages/client/ui-slots
'@deepseek-ai/dsh-client-web-react':
specifier: link:/Users/menghuan/.dsh/source/current/packages/client/web-react
version: link:../../.dsh/source/current/packages/client/web-react
version: link:/Users/menghuan/.dsh/source/current/packages/client/web-react
'@deepseek-ai/dsh-host-webserver':
specifier: link:/Users/menghuan/.dsh/source/current/packages/host/webserver
version: link:../../.dsh/source/current/packages/host/webserver
version: link:/Users/menghuan/.dsh/source/current/packages/host/webserver
'@deepseek-ai/dsh-invariants':
specifier: link:/Users/menghuan/.dsh/source/current/packages/support/invariants
version: link:../../.dsh/source/current/packages/support/invariants
version: link:/Users/menghuan/.dsh/source/current/packages/support/invariants
'@deepseek-ai/dsh-llm':
specifier: link:/Users/menghuan/.dsh/source/current/packages/llm/llm
version: link:/Users/menghuan/.dsh/source/current/packages/llm/llm
'@deepseek-ai/dsh-session':
specifier: link:/Users/menghuan/.dsh/source/current/packages/core/session
version: link:../../.dsh/source/current/packages/core/session
version: link:/Users/menghuan/.dsh/source/current/packages/core/session
'@deepseek-ai/dsh-settings':
specifier: link:/Users/menghuan/.dsh/source/current/packages/settings/settings
version: link:../../.dsh/source/current/packages/settings/settings
version: link:/Users/menghuan/.dsh/source/current/packages/settings/settings
'@deepseek-ai/dsh-tools':
specifier: link:/Users/menghuan/.dsh/source/current/packages/core/tools
version: link:/Users/menghuan/.dsh/source/current/packages/core/tools
'@types/node':
specifier: ^24.0.0
version: 24.13.3
+519
View File
@@ -0,0 +1,519 @@
/**
* Agent-owned terminal registry: a uuid-keyed table of long-lived PTY
* sessions created by the model through the `terminal_create` tool. Each
* handle survives across tool calls (and across WebSocket disconnects from
* the sidebar view) until the model calls `terminal_close` or the user
* closes the corresponding sidebar tab — tmux semantics, scoped per agent
* session.
*
* This is a parallel registry to {@link PtyManager}: UI tabs are keyed by
* `${sessionId}:${tabId}` and capped per session, while agent terminals are
* keyed by uuid and uncapped (the model is trusted to close unused ones).
* Both registries share the same shell resolver and spawn-helper fix.
*/
import { randomUUID } from 'node:crypto'
import * as nodePty from 'node-pty'
import { ensureSpawnHelper } from './pty-manager.ts'
import { SidebarError } from './wire.ts'
/** Per-agent-terminal transcript bound (bytes kept for replay and reads). */
const TRANSCRIPT_LIMIT = 1 << 20
/** POSIX signals the registry forwards to a live pty. */
export const ALLOWED_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGKILL', 'SIGHUP', 'SIGTSTP'] as const
/** Signal name accepted by `signal()`. */
export type AgentTerminalSignal = (typeof ALLOWED_SIGNALS)[number]
/** Default read page size (lines) when the caller omits `count`. */
export const DEFAULT_READ_COUNT = 500
/** Smallest pty dimension the registry accepts (mirrors the tool contract). */
export const TERMINAL_DIM_MIN = 2
/** Largest pty dimension the registry accepts (mirrors the tool contract). */
export const TERMINAL_DIM_MAX = 1024
/** Clamp one cols×rows pair into the supported pty range (flooring decimals). */
export function clampDims(cols: number, rows: number): { cols: number; rows: number } {
const clamp = (value: number): number =>
Math.min(TERMINAL_DIM_MAX, Math.max(TERMINAL_DIM_MIN, Math.floor(value)))
return { cols: clamp(cols), rows: clamp(rows) }
}
/**
* Serializable snapshot of one agent terminal — the shape the model sees
* through `terminal_list` and the sidebar sees through the push endpoint.
* Carries no pty reference and no transcript (those are reached through
* dedicated read/attach paths), and no sessionId: ownership is registry
* internals, scoped by the caller (list filters by session, the push
* endpoint scopes by its query param), never part of the serialized view.
*/
export interface AgentTerminalSnapshot {
/** Stable opaque handle the model passes back to other terminal_* tools. */
uuid: string
/** Display title the model chose at create time. */
title: string
/** The command the model asked to run at create time (verbatim). */
command: string
/** Whether the top-level process has exited. */
exited: boolean
/** Exit code if exited normally; absent until the process exits. */
exitCode?: number | null
/** Exit signal name if the process was killed by a signal; null otherwise. */
exitSignal?: string | null
}
/** Map a POSIX signal number to its conventional name (best-effort). */
const SIGNAL_NAMES: Record<number, string> = {
1: 'SIGHUP', 2: 'SIGINT', 3: 'SIGQUIT', 4: 'SIGILL', 6: 'SIGABRT',
9: 'SIGKILL', 11: 'SIGSEGV', 13: 'SIGPIPE', 14: 'SIGALRM', 15: 'SIGTERM',
17: 'SIGCHLD', 18: 'SIGCONT', 19: 'SIGSTOP', 20: 'SIGTSTP',
}
/** Convert a raw signal number to a name (or null when absent/unknown). */
function signalNameOf(signal: number | null | undefined): string | null {
if (signal === null || signal === undefined) return null
return SIGNAL_NAMES[signal] ?? `signal ${signal}`
}
/** Locate the first occurrence of `needle` in `transcript`, returning its line/column. */
function locateNeedle(transcript: string, needle: string): { line: number; column: number } | undefined {
if (needle === '') return undefined
const idx = transcript.indexOf(needle)
if (idx === -1) return undefined
// Walk the transcript up to the match index, counting newlines to derive
// the 0-based line; the column is the offset within that line.
let line = 0
let lineStart = 0
for (let i = 0; i < idx; i += 1) {
if (transcript.charCodeAt(i) === 0x0a /* \n */) {
line += 1
lineStart = i + 1
}
}
return { line, column: idx - lineStart }
}
/** One live agent terminal. */
export interface AgentTerminalHandle {
/** Stable opaque handle. */
uuid: string
/** Owning conversation id. */
sessionId: string
/** Display title. */
title: string
/** The command written to stdin right after spawn. */
command: string
/** The working directory the process was spawned with. */
cwd: string
/** The live pty process. */
pty: nodePty.IPty
/** Output accumulated since spawn (bounded; head dropped when over the limit). */
transcript: string
/** Whether the top-level process exited (transcript stays replayable). */
exited: boolean
/** Exit code once known. */
exitCode?: number | null
/** Exit signal number once known (POSIX only; undefined on Windows). */
exitSignal?: number | null
}
/** Read result shape (mirrors the official tool-pty terminal_read contract). */
export interface AgentTerminalReadResult {
/** The slice of transcript text for the requested page. */
text: string
/** Total lines in the retained transcript (the page may be a subset). */
totalLines: number
/** 0-based index of the first line in `text` (inclusive). */
lineBegin: number
/** 0-based index of the last line in `text` (exclusive). */
lineEnd: number
}
/** Outcome of {@link AgentPtyRegistry.waitFor}. */
export type AgentTerminalWaitResult =
| {
/** The needle was found in the transcript. */
kind: 'found'
/** The matched substring. */
needle: string
/** 0-based line index (in the retained transcript) where the needle first appeared. */
line: number
/** 0-based column index within that line where the match starts. */
column: number
/** Elapsed wall-clock milliseconds from the wait start to the match. */
elapsedMs: number
}
| {
/** The needle did not appear before the timeout. */
kind: 'timeout'
/** The needle that was awaited. */
needle: string
/** The configured timeout in milliseconds. */
timeoutMs: number
/** Total lines retained when the timeout fired (call terminal_read to inspect). */
totalLines: number
}
| {
/** The terminal exited before the needle appeared. */
kind: 'exited'
/** The needle that was awaited. */
needle: string
/** The exit code, if known. */
exitCode?: number | null
/** The exit signal name, if the process was killed by a signal. */
exitSignal?: string | null
}
/** Snapshot projection of a handle (drops the pty reference and transcript). */
export function snapshotOf(handle: AgentTerminalHandle): AgentTerminalSnapshot {
const out: AgentTerminalSnapshot = {
uuid: handle.uuid,
title: handle.title,
command: handle.command,
exited: handle.exited,
}
if (handle.exited) {
out.exitCode = handle.exitCode ?? null
out.exitSignal = signalNameOf(handle.exitSignal)
}
return out
}
/**
* The agent terminal registry. The constructor takes the resolved shell
* binary (the same `defaultShell()` the UI-tab registry uses) and runs the
* spawn-helper chmod fix once at construction so the first agent terminal
* does not race a lazy fixer.
*/
export class AgentPtyRegistry {
private readonly sessions = new Map<string, AgentTerminalHandle>()
private readonly changeListeners = new Set<() => void>()
constructor(private readonly shell: string) {
ensureSpawnHelper()
}
/**
* Spawn one agent terminal: start the shell in `cwd`, then write
* `command + '\n'` to stdin so the command runs in the fresh shell. The
* terminal stays alive after the command exits — the model can send more
* input through `terminal_send` until it calls `terminal_close` or the
* user closes the sidebar tab. An empty `command` spawns a bare shell.
* @returns the new handle's uuid (the model-facing opaque id).
*/
create(
sessionId: string,
title: string,
command: string,
cwd: string,
cols = 80,
rows = 24,
): string {
const uuid = randomUUID()
const dims = clampDims(cols, rows)
const pty = nodePty.spawn(this.shell, [], {
name: 'xterm-256color',
cols: dims.cols,
rows: dims.rows,
cwd,
env: { ...process.env },
})
const handle: AgentTerminalHandle = {
uuid,
sessionId,
title,
command,
cwd,
pty,
transcript: '',
exited: false,
}
pty.onData((data) => {
handle.transcript += data
if (handle.transcript.length > TRANSCRIPT_LIMIT) {
handle.transcript = handle.transcript.slice(handle.transcript.length - TRANSCRIPT_LIMIT)
}
})
pty.onExit(({ exitCode, signal }) => {
handle.exited = true
handle.exitCode = exitCode
handle.exitSignal = signal
this.notify()
})
if (command !== '') {
// Write the command + Enter so it runs in the freshly spawned shell.
// Use \r (carriage return) — the actual character a terminal sends for
// the Enter key — not \n (line feed). PowerShell treats a bare \n as a
// soft line break (continuation prompt ">>") rather than a command
// submit; \r is the cross-shell Enter semantics on both POSIX and Windows.
try {
pty.write(`${command}\r`)
} catch {
// A spawn that failed between onData and onExit surfaces its own
// exit; the create call still returns the uuid so the model can
// read the transcript and see the failure.
}
}
this.sessions.set(uuid, handle)
this.notify()
return uuid
}
/** All live agent terminals belonging to one conversation. */
list(sessionId: string): AgentTerminalSnapshot[] {
const out: AgentTerminalSnapshot[] = []
for (const handle of this.sessions.values()) {
if (handle.sessionId === sessionId) out.push(snapshotOf(handle))
}
return out
}
/** Resolve a live handle by uuid, or throw `not-found`. */
private expect(uuid: string): AgentTerminalHandle {
const handle = this.sessions.get(uuid)
if (handle === undefined) {
throw new SidebarError('not-found', `agent terminal "${uuid}" not found`, 404)
}
return handle
}
/**
* Resolve a live handle that belongs to `sessionId`, or throw `not-found`.
* The model-facing tools call this before every uuid-keyed operation: a
* uuid from another session is indistinguishable from an unknown one, so a
* model can never reach (or probe) a terminal it does not own.
*/
assertOwned(uuid: string, sessionId: string): AgentTerminalHandle {
const handle = this.expect(uuid)
if (handle.sessionId !== sessionId) {
throw new SidebarError('not-found', `agent terminal "${uuid}" not found`, 404)
}
return handle
}
/** Resolve a handle's snapshot, or undefined if it does not exist. */
snapshot(uuid: string): AgentTerminalSnapshot | undefined {
const handle = this.sessions.get(uuid)
return handle === undefined ? undefined : snapshotOf(handle)
}
/** Write raw text to a terminal's stdin (tmux `send-keys` semantics). */
send(uuid: string, text: string): void {
const handle = this.expect(uuid)
if (handle.exited) {
throw new SidebarError('bad-request', `agent terminal "${uuid}" has exited`, 400)
}
handle.pty.write(text)
}
/**
* Read one bounded page of the retained transcript. `offset` is a 0-based
* line index from the start of the retained transcript (default 0);
* `count` caps the page size (default 500). A negative `offset` reads
* from the end (e.g. -50 reads the last 50 lines). Returns `totalLines`
* so the model can paginate.
*/
read(uuid: string, offset?: number, count?: number): AgentTerminalReadResult {
const handle = this.expect(uuid)
const lines = handle.transcript.split('\n')
const totalLines = lines.length
const pageSize = Math.max(1, Math.min(count ?? DEFAULT_READ_COUNT, DEFAULT_READ_COUNT))
let start: number
if (offset === undefined || offset === 0) {
start = 0
} else if (offset < 0) {
// Negative offset: read from the end (e.g. -50 → last 50 lines).
start = Math.max(0, totalLines + offset)
} else {
start = Math.min(offset, totalLines)
}
const end = Math.min(start + pageSize, totalLines)
const slice = lines.slice(start, end).join('\n')
return {
text: slice,
totalLines,
lineBegin: start,
lineEnd: end,
}
}
/**
* Resize a terminal's pty, clamped to the 2..1024 sane range.
* @returns the dimensions actually applied (the caller echoes these, so the
* reported value always matches the pty).
*/
resize(uuid: string, cols: number, rows: number): { cols: number; rows: number } {
const handle = this.expect(uuid)
const dims = clampDims(cols, rows)
if (!handle.exited) handle.pty.resize(dims.cols, dims.rows)
return dims
}
/**
* Wait for `needle` to appear in a terminal's transcript, or for the
* terminal to exit, or for the timeout to elapse — whichever happens
* first. The wait polls the live transcript every ~50ms and short-circuits
* on `signal` abort (re-thrown as the abort reason so the tool layer
* surfaces cancellation).
*
* The match scans the FULL retained transcript on each poll, not just the
* delta since the last poll — a needle that scrolled past the most recent
* chunk but is still within the ~1 MiB bound is still a match. The
* returned line/column locate the FIRST occurrence (oldest), which is what
* a user watching the terminal would have seen first.
*
* The implementation uses polling (not pty onData subscription) because
* node-pty's onData fires before the registry's own onData listener
* updates the transcript (listener order is not guaranteed), and on
* Windows ConPTY output can arrive in bursts with batching delays that
* make event-driven wakeups unreliable. A 50ms poll is fast enough for
* interactive use and simple enough to be obviously correct.
* @param uuid - terminal to watch.
* @param needle - substring to search for (case-sensitive, verbatim).
* @param timeoutMs - max wait; default 10000 (10s). Clamped to ≥100ms.
* @param signal - caller-owned cancellation; aborts the wait re-throwing.
* @returns one of `found` / `timeout` / `exited`.
*/
async waitFor(
uuid: string,
needle: string,
timeoutMs = 10_000,
signal?: AbortSignal,
): Promise<AgentTerminalWaitResult> {
if (needle === '') {
throw new SidebarError('bad-request', 'needle must be a non-empty string', 400)
}
const handle = this.expect(uuid)
const timeout = Math.max(100, Math.floor(timeoutMs))
const start = Date.now()
const deadline = start + timeout
// Fast path: already exited, or the needle is already in the transcript
// (a `terminal_send` may have produced the expected output before this
// call even started).
if (handle.exited) {
return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) }
}
const firstHit = locateNeedle(handle.transcript, needle)
if (firstHit !== undefined) {
return { kind: 'found', needle, line: firstHit.line, column: firstHit.column, elapsedMs: Date.now() - start }
}
// Poll loop: check the transcript every 50ms, exit on match / exit /
// abort / timeout. The handle is read live each iteration (its transcript
// and exited fields mutate as the pty produces output).
while (true) {
if (signal?.aborted) signal.throwIfAborted()
if (handle.exited) {
return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) }
}
const hit = locateNeedle(handle.transcript, needle)
if (hit !== undefined) {
return { kind: 'found', needle, line: hit.line, column: hit.column, elapsedMs: Date.now() - start }
}
if (Date.now() >= deadline) {
return { kind: 'timeout', needle, timeoutMs: timeout, totalLines: handle.transcript.split('\n').length }
}
await new Promise(resolve => {
const t = setTimeout(resolve, 50)
// Allow the Node process to exit even if the timer is pending.
if (typeof t === 'object' && 'unref' in t) (t as { unref: () => void }).unref()
})
}
}
/**
* Send a POSIX signal to a terminal's foreground process.
*
* Two delivery paths, by signal kind:
* - **Interactive control signals** (SIGINT, SIGTSTP) are delivered by
* writing the corresponding control character to the pty stdin. This is
* how a real terminal sends Ctrl+C / Ctrl+Z: the byte hits the kernel
* line discipline (POSIX ISIG mode) or the ConPTY input pipeline
* (Windows), which translates it into a SIGINT/SIGTSTP for the
* foreground process group. This works on every platform — calling
* `node-pty.kill('SIGINT')` throws on Windows and is fragile on POSIX,
* but writing `\x03` is universally correct.
* - **Termination signals** (SIGKILL, SIGTERM, SIGHUP) use `pty.kill()`,
* which maps to the platform's process-termination path (POSIX
* `kill(2)`, Windows `TerminateProcess`). These cannot be faked with
* control characters.
*/
signal(uuid: string, signal: AgentTerminalSignal): void {
const handle = this.expect(uuid)
if (handle.exited) return
// Ctrl+C → 0x03 (ETX), Ctrl+Z → 0x1A (SUB). Writing these to the pty
// master is the cross-platform way to deliver the signal to the
// foreground process group through the terminal's input pipeline.
if (signal === 'SIGINT' || signal === 'SIGTSTP') {
const ctrlByte = signal === 'SIGINT' ? '\x03' : '\x1a'
try {
handle.pty.write(ctrlByte)
} catch {
// A pty that rejects writes is already tearing down; the next
// onExit will mark it exited. Not an error from the caller's view.
}
return
}
// SIGKILL / SIGTERM / SIGHUP: use the process-termination path.
try {
handle.pty.kill(signal)
} catch {
// node-pty on Windows rejects named signals other than the default;
// fall back to the default kill (TerminateProcess on Windows,
// SIGKILL-equivalent on POSIX) so the signal still takes effect.
try {
handle.pty.kill()
} catch {
// Already exited or gone; nothing left to kill.
}
}
}
/**
* Close a terminal and drop its state. Idempotent: a second close of the
* same uuid is a no-op. Returns true iff a live handle was actually
* dropped.
*/
close(uuid: string): boolean {
const handle = this.sessions.get(uuid)
if (handle === undefined) return false
this.sessions.delete(uuid)
try {
handle.pty.kill()
} catch {
// Already exited or gone; nothing left to kill.
}
this.notify()
return true
}
/** Resolve a live handle by uuid (for the WS attach path). */
get(uuid: string): AgentTerminalHandle | undefined {
return this.sessions.get(uuid)
}
/**
* Subscribe to registry changes (create / close / exit). The sidebar push
* endpoint uses this to forward snapshots to the connected view. Returns
* the unsubscribe function.
*/
subscribe(listener: () => void): () => void {
this.changeListeners.add(listener)
return () => { this.changeListeners.delete(listener) }
}
/** Close every agent terminal (plugin teardown). */
disposeAll(): void {
for (const uuid of [...this.sessions.keys()]) this.close(uuid)
}
/** Fire every change listener (callers wrap in try/catch if needed). */
private notify(): void {
for (const listener of [...this.changeListeners]) {
try {
listener()
} catch {
// A listener throwing must not break the others or the registry.
}
}
}
}
+110
View File
@@ -0,0 +1,110 @@
/**
* The diff tab: one change opened from the git panel, like VSCode's diff
* editor. A worktree ref loads the file's unified diff (`git diff`, staged or
* not; untracked files — which git diff never covers — render as a full-file
* addition from their content), a commit ref loads the commit's full patch
* (`git.show`-style). The header carries a refresh button because the tab
* stays mounted while the git panel's staging/discard operations change the
* very content it shows.
*/
import { useCallback, useEffect, useState } from 'react'
import { IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SessionScope } from './api.ts'
import { api } from './api.ts'
import type { SidebarDiffRef } from './state.ts'
import { DiffView } from './DiffView.tsx'
import { t } from './locales.ts'
import css from './sidebar.module.css'
/** The loaded diff surface (untracked content rendered as a full addition). */
interface DiffData {
diff: string
untracked?: string
}
export function DiffTab(props: { sessionId: string; cwd: string | undefined; diff: SidebarDiffRef }) {
const { sessionId, cwd, diff } = props
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [data, setData] = useState<DiffData | null>(null)
const [tick, setTick] = useState(0)
const refresh = useCallback((): void => { setTick(value => value + 1) }, [])
useEffect(() => {
let cancelled = false
const scope: SessionScope = { sessionId, cwd }
setLoading(true)
setError(null)
setData(null)
const load = async (): Promise<void> => {
try {
if (diff.kind === 'commit') {
const result = await api.gitCommitDiff(scope, diff.hashFull)
if (!cancelled) setData({ diff: result.diff })
return
}
let result = await api.gitDiff(scope, diff.path, diff.staged)
if (result.diff === '') {
// The requested side is empty — try the OTHER side once: the ref
// may predate the staged-flag fix, or the change moved sides (a
// file staged after its tab opened). Both sides empty means the
// file genuinely has no text changes.
const other = await api.gitDiff(scope, diff.path, !diff.staged)
if (other.diff !== '') result = other
}
if (result.diff !== '') {
if (!cancelled) setData({ diff: result.diff })
return
}
// Empty diff: an untracked file (git diff never lists it) falls back
// to a full-file addition; anything else is a genuine no-text-change.
if (diff.untracked === true && !diff.staged) {
const text = await api.fsRead(scope, diff.path)
if (!cancelled) {
setData(text.kind === 'text' ? { diff: '', untracked: text.content } : { diff: '' })
}
return
}
if (!cancelled) setData({ diff: '' })
} catch (reason) {
if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason))
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => { cancelled = true }
}, [sessionId, cwd, diff, tick])
return (
<div className={css.gitDiffTab}>
<div className={css.gitDiffTabHeader}>
<span className={css.gitDiffTabTitle} title={diff.kind === 'worktree' ? diff.path : `${diff.hash} ${diff.subject}`}>
{diff.kind === 'worktree' ? diff.path : `${diff.hash} ${diff.subject}`}
</span>
<button
type="button"
className={css.iconButton}
aria-label={t('refresh')}
title={t('refresh')}
onClick={refresh}
>
<IconRefreshOutline16 />
</button>
</div>
{loading && <div className={css.gitPlaceholder}>{t('loading')}</div>}
{!loading && error !== null && <div className={css.gitError}>{t('diffLoadError')}: {error}</div>}
{!loading && error === null && data !== null && (
<>
{data.untracked !== undefined
? <DiffView diff="" untrackedPath={diff.kind === 'worktree' ? diff.path : ''} untrackedContent={data.untracked} />
: <DiffView diff={data.diff} />}
{data.diff === '' && data.untracked === undefined && (
<div className={css.gitEmpty}>{t('diffEmpty')}</div>
)}
</>
)}
</div>
)
}
+266
View File
@@ -0,0 +1,266 @@
/**
* The real diff surface for the git panel: parses the host's unified diff
* text (`git diff` / `git show`) and renders it VSCode-style — per-file
* sections with hunks (`@@ -a,b +c,d @@` headers), old/new line-number
* gutters, and aligned context / deleted / added rows colored through the
* DSH tokens. Untracked files produce no `git diff` output, so the caller
* can pass the file content to render as a full-file addition instead.
*
* The parser is a pure function (`parseUnifiedDiff`) so the interesting
* cases are unit-tested without a DOM.
*/
import { useMemo, useState, type ReactNode } from 'react'
import clsx from 'clsx'
import { t } from './locales.ts'
import css from './sidebar.module.css'
/** One rendered diff line. */
export interface DiffLine {
kind: 'ctx' | 'del' | 'add' | 'meta'
/** The line content without its diff marker ('' for the no-newline marker). */
text: string
/** Old-side line number (null for pure additions / metadata). */
oldNum: number | null
/** New-side line number (null for pure deletions / metadata). */
newNum: number | null
}
/** One parsed hunk. */
export interface DiffHunk {
/** The old-side start line (`-a[,b]`). */
oldStart: number
/** The new-side start line (`+c[,d]`). */
newStart: number
/** The section text after the trailing `@@` (may be empty). */
header: string
lines: DiffLine[]
}
/** One parsed file section of a unified diff. */
export interface DiffFile {
/** The `---` path verbatim ('/dev/null' for a new file). */
oldPath: string
/** The `+++` path verbatim ('/dev/null' for a deleted file). */
newPath: string
/** The file changed with binary content: no hunks to draw. */
binary: boolean
hunks: DiffHunk[]
}
/** The parsed unified diff. */
export interface ParsedDiff {
files: DiffFile[]
}
/** Parse the hunk header `@@ -a[,b] +c[,d] @@ section` (section may contain '@@'). */
function parseHunkHeader(line: string): { oldStart: number; newStart: number; header: string } | null {
const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/.exec(line)
if (match === null) return null
return { oldStart: Number(match[1]), newStart: Number(match[3]), header: match[5] ?? '' }
}
/**
* Parse `git diff --no-color` output into file sections and hunks. Rows
* outside a file section (leading noise) and metadata rows between the
* `diff --git`/`---`/`+++` headers and the first hunk (index lines, mode
* changes, rename/similarity lines) are skipped; a section that never
* reaches a hunk (a mode/rename-only change) stays hunkless so the caller
* can still draw its path.
*/
export function parseUnifiedDiff(text: string): ParsedDiff {
const files: DiffFile[] = []
let current: DiffFile | null = null
let inHunk = false
let hunk: DiffHunk | null = null
let oldNum = 0
let newNum = 0
const flushHunk = (): void => {
if (current !== null && hunk !== null) current.hunks.push(hunk)
hunk = null
inHunk = false
}
for (const raw of text.split('\n')) {
if (raw.startsWith('diff --git ')) {
flushHunk()
current = { oldPath: '', newPath: '', binary: false, hunks: [] }
files.push(current)
continue
}
if (current === null) continue
if (raw.startsWith('Binary files ') || raw === 'GIT binary patch') {
flushHunk()
current.binary = true
continue
}
if (raw.startsWith('--- ')) {
flushHunk()
current.oldPath = raw.slice(4)
continue
}
if (raw.startsWith('+++ ')) {
current.newPath = raw.slice(4)
continue
}
const header = parseHunkHeader(raw)
if (header !== null) {
flushHunk()
hunk = { oldStart: header.oldStart, newStart: header.newStart, header: header.header, lines: [] }
oldNum = header.oldStart
newNum = header.newStart
inHunk = true
continue
}
if (!inHunk || hunk === null) continue
const marker = raw[0]
if (marker === '\\') {
// `\ No newline at end of file`: metadata attached to the previous row.
hunk.lines.push({ kind: 'meta', text: raw.slice(1), oldNum: null, newNum: null })
continue
}
if (marker === ' ') {
hunk.lines.push({ kind: 'ctx', text: raw.slice(1), oldNum, newNum })
oldNum += 1
newNum += 1
} else if (marker === '-') {
hunk.lines.push({ kind: 'del', text: raw.slice(1), oldNum, newNum: null })
oldNum += 1
} else if (marker === '+') {
hunk.lines.push({ kind: 'add', text: raw.slice(1), oldNum: null, newNum })
newNum += 1
} else {
// Not a diff line (a hunk can never contain one): stop the hunk.
flushHunk()
}
}
flushHunk()
return { files }
}
/** Build the untracked-file shape: one file, one hunk of pure additions. */
function untrackedFile(path: string, content: string): DiffFile {
const lines: DiffLine[] = []
const body = content.endsWith('\n') ? content.slice(0, -1) : content
if (body !== '') {
let num = 1
for (const line of body.split('\n')) {
lines.push({ kind: 'add', text: line, oldNum: null, newNum: num })
num += 1
}
}
return { oldPath: '/dev/null', newPath: `b/${path}`, binary: false, hunks: [{ oldStart: 0, newStart: 1, header: '', lines }] }
}
/** Strip the `a/` / `b/` prefix git puts on diff paths (not on /dev/null). */
function displayPath(path: string): string {
if (path === '/dev/null') return path
if (path.startsWith('a/') || path.startsWith('b/')) return path.slice(2)
return path
}
/** The file header badge: added / deleted / renamed / binary ('' for a plain edit). */
function fileTag(file: DiffFile): string | null {
if (file.binary) return t('diffBinary')
if (file.oldPath === '/dev/null') return t('diffAdded')
if (file.newPath === '/dev/null') return t('diffDeleted')
const oldPath = displayPath(file.oldPath)
const newPath = displayPath(file.newPath)
if (oldPath !== newPath) return t('diffRenamed')
return null
}
/** Cap the flattened rows like DiffBlock: head + tail, expand button between. */
const MAX_DIFF_ROWS = 500
export interface DiffViewProps {
/** Unified diff text (`git.diff` or `git.commit-diff` payloads). */
diff: string
/** Untracked-file content: when present, renders as a full-file addition instead of parsing. */
untrackedPath?: string
untrackedContent?: string
}
export function DiffView({ diff, untrackedPath, untrackedContent }: DiffViewProps) {
const parsed = useMemo<ParsedDiff>(() => {
if (untrackedPath !== undefined) {
return { files: [untrackedFile(untrackedPath, untrackedContent ?? '')] }
}
return parseUnifiedDiff(diff)
}, [diff, untrackedPath, untrackedContent])
const [expanded, setExpanded] = useState(false)
// Flatten into display rows so the cap can slice a single list.
const rows = useMemo(() => {
const out: Array<{ key: string; file: DiffFile; type: 'path' | 'hunk' | 'line'; hunk?: DiffHunk; line?: DiffLine }> = []
parsed.files.forEach((file, fileIndex) => {
out.push({ key: `f${fileIndex}`, file, type: 'path' })
if (file.binary) return
file.hunks.forEach((hunk, hunkIndex) => {
out.push({ key: `f${fileIndex}h${hunkIndex}`, file, type: 'hunk', hunk })
hunk.lines.forEach((line, lineIndex) => {
out.push({ key: `f${fileIndex}h${hunkIndex}l${lineIndex}`, file, type: 'line', hunk, line })
})
})
})
return out
}, [parsed])
const hidden = rows.length - MAX_DIFF_ROWS
const capped = hidden > 0 && !expanded
const headLines = Math.ceil(MAX_DIFF_ROWS / 2)
const tailLines = MAX_DIFF_ROWS - headLines
const head = capped ? rows.slice(0, headLines) : rows
const tail = capped ? rows.slice(rows.length - tailLines) : []
if (rows.length === 0) return null
const renderRow = (row: (typeof rows)[number]): ReactNode => {
if (row.type === 'path') {
const tag = fileTag(row.file)
const from = displayPath(row.file.oldPath)
const to = displayPath(row.file.newPath)
return (
<div key={row.key} className={css.gitDiffFile}>
<span className={css.gitDiffFilePath}>{to}</span>
{from !== to && <span className={css.gitDiffFileOld}>← {from}</span>}
{tag !== null && <span className={css.gitDiffFileTag}>{tag}</span>}
</div>
)
}
if (row.type === 'hunk') {
const hunk = row.hunk!
return (
<div key={row.key} className={css.gitDiffHunk}>
<span className={css.gitDiffHunkHeader}>@@ -{hunk.oldStart},{hunk.lines.filter(l => l.oldNum !== null).length} +{hunk.newStart},{hunk.lines.filter(l => l.newNum !== null).length} @@</span>
{hunk.header !== '' && <span className={css.gitDiffHunkSection}>{hunk.header}</span>}
</div>
)
}
const line = row.line!
const lineClass = line.kind === 'del' ? css.gitDiffDel : line.kind === 'add' ? css.gitDiffAdd : line.kind === 'meta' ? css.gitDiffMeta : css.gitDiffCtx
return (
<div key={row.key} className={clsx(css.gitDiffLine, lineClass)}>
{line.kind === 'meta'
? <span className={css.gitDiffMetaText}>{line.text}</span>
: (
<>
<span className={css.gitDiffNum}>{line.oldNum ?? ''}</span>
<span className={css.gitDiffNum}>{line.newNum ?? ''}</span>
<span className={css.gitDiffCode}>{line.text}</span>
</>
)}
</div>
)
}
return (
<div className={css.gitDiff}>
{head.map(renderRow)}
{hidden > 0 && (
<button type="button" className={css.gitDiffExpand} aria-expanded={expanded} onClick={() => { setExpanded(value => !value) }}>
{expanded ? t('diffCollapse') : t('diffExpand', { count: hidden })}
</button>
)}
{tail.map(renderRow)}
</div>
)
}
+101
View File
@@ -0,0 +1,101 @@
/**
* The editor tab host: resolves a file's previewer through the sidebar
* registry (`matchFileViewer`), fetches bytes per the matched viewer's
* fetch strategy, and renders its component — or the shared download pane
* when nothing can render the file. The header shows the file title; the
* editable code/markdown viewers render their own toolbar below it.
*
* The strategy dispatch is pure (planFirstMatch / planFsReadOutcome in
* editor-load.ts); this component only wires it to the host APIs.
*/
import { useEffect, useState } from 'react'
import { createElement } from 'react'
import type { Context } from '../context-types.ts'
import { api, mediaUrl, type SessionScope } from './api.ts'
import { BinaryDownload } from './binary-download.tsx'
import { planFirstMatch, planFsReadOutcome, type EditorLoadAction } from './editor-load.ts'
import { t } from './locales.ts'
import type { FileViewerDescriptor } from './service.ts'
import type { SidebarStore } from './state.ts'
import css from './sidebar.module.css'
type EditorLoad =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'ready'; viewer: FileViewerDescriptor; content?: string; truncated?: boolean; mediaUrl?: string; customData?: unknown }
| { status: 'binary' }
export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: SessionScope; path: string; title: string }) {
const { ctx, store, scope, path, title } = props
const [load, setLoad] = useState<EditorLoad>({ status: 'loading' })
useEffect(() => {
let cancelled = false
setLoad({ status: 'loading' })
const mediaUrlOf = (): string => mediaUrl(scope, path)
const apply = (action: EditorLoadAction): void => {
if (cancelled) return
switch (action.kind) {
case 'binary':
setLoad({ status: 'binary' })
return
case 'render':
setLoad({
status: 'ready',
viewer: action.viewer,
content: action.content,
truncated: action.truncated,
mediaUrl: action.mediaUrl,
customData: action.customData,
})
return
case 'customLoad':
void action.viewer.load?.(path, scope).then((data) => {
if (cancelled) return
setLoad({ status: 'ready', viewer: action.viewer, customData: data })
}).catch((error: unknown) => {
if (cancelled) return
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
})
return
case 'fetchFsRead':
api.fsRead(scope, path).then((result) => {
if (cancelled) return
// Binary reads carry the head bytes for the detect re-match.
const outcome = planFsReadOutcome(action.viewer, {
binary: result.kind === 'binary',
content: result.kind === 'text' ? result.content : '',
truncated: result.truncated,
head: result.kind === 'binary' ? result.head : undefined,
}, (head) => ctx.betterSidebar?.matchFileViewer(path, head), mediaUrlOf)
apply(outcome)
}).catch((error: unknown) => {
if (cancelled) return
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
})
return
}
}
apply(planFirstMatch(ctx.betterSidebar?.matchFileViewer(path), mediaUrlOf))
return () => { cancelled = true }
}, [scope.sessionId, scope.cwd, path, ctx])
return (
<div className={css.editor}>
<div className={css.editorHeader}>
<span className={css.editorTitle} title={path}>{title}</span>
</div>
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
{load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
{load.status === 'binary' && <BinaryDownload scope={scope} path={path} />}
{load.status === 'ready' && createElement(load.viewer.component, {
ctx, store, scope, path, title,
viewerId: load.viewer.id,
content: load.content,
truncated: load.truncated,
mediaUrl: load.mediaUrl,
customData: load.customData,
})}
</div>
)
}
+325 -56
View File
@@ -1,19 +1,23 @@
/**
* The source-control panel: status list (staged vs unstaged), per-file diff
* through the shared DiffBlock (old = HEAD/index content, new = worktree or
* index content), stage/unstage, commit with a message box, branch list with
* switch, and recent history. Refresh is manual + on mount/focus (no file
* watcher — KISS).
* The source-control panel: status list (staged vs unstaged), stage/unstage,
* commit with a message box, branch switch, and a VSCode-like history — rows
* carry branch decorations, author and relative time. Clicking a changed
* file or a history row opens a dedicated diff TAB (see {@link DiffTab}),
* placed below the git pane on first use. File rows and history rows open a
* right-click context menu with advanced operations (open in editor, discard,
* revert, cherry-pick, copy paths/hashes). Refresh is manual + on mount/
* focus (no file watcher — KISS).
*/
import { useCallback, useEffect, useState, type ReactNode } from 'react'
import clsx from 'clsx'
import { useCallback, useEffect, useState, type MouseEvent, type ReactNode } from 'react'
import {
DiffBlock, IconBranchOutline16, IconRefreshOutline16, IconTrashOutline16,
Input,
Button, IconBranchOutline16, IconCodeOutline16, IconCopyOutline16, IconRefreshOutline16,
IconTrashOutline16, Input, Menu, Modal, writeClipboard,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { GitLogEntry, GitStatusEntry, GitStatusResult, SessionScope } from './api.ts'
import { api } from './api.ts'
import { t } from './locales.ts'
import { relativeTo } from './paths.ts'
import { relativeTime, t } from './locales.ts'
import type { SidebarTab } from './state.ts'
import css from './sidebar.module.css'
/** The XY status letters a row badge shows (X = index, Y = worktree). */
@@ -25,8 +29,63 @@ function badgeOf(entry: GitStatusEntry): string {
return '?'
}
export function GitView(props: { scope: SessionScope }) {
const { scope } = props
/** Whether the entry carries STAGED (index) changes — the X letter is set. */
function isStagedEntry(entry: GitStatusEntry): boolean {
const index = entry.xy[0]
return index !== undefined && index !== ' ' && index !== '?'
}
/** Whether the entry carries UNSTAGED (worktree) changes — the Y letter is set
* (untracked `??` counts as unstaged: it is a worktree-only change). A file
* with both letters set ('MM') lands in BOTH sections. */
function isUnstagedEntry(entry: GitStatusEntry): boolean {
if (entry.xy === '??') return true
const worktree = entry.xy[1]
return worktree !== undefined && worktree !== ' ' && worktree !== '?'
}
/** Whether the entry is untracked (`??`): git diff never includes it. */
function isUntracked(entry: GitStatusEntry): boolean {
return badgeOf(entry) === '?'
}
/** The last path segment (tab title for a file's diff). */
function baseName(path: string): string {
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
return at === -1 ? path : path.slice(at + 1)
}
/** The ref names of one log row's decorations (`HEAD -> main` → `main`), deduped. */
function refNames(refs: string): string[] {
return [...new Set(
refs
.split(',')
.map(ref => ref.trim())
.filter(ref => ref !== '')
.map(ref => (ref.includes(' -> ') ? ref.slice(ref.indexOf(' -> ') + 4) : ref))
.map(ref => (ref.startsWith('tag: ') ? ref.slice(5) : ref)),
)]
}
/** The pending destructive action (discard / revert / cherry-pick), gated by a confirm modal. */
interface ConfirmState {
title: string
description: string
confirmLabel: string
onConfirm: () => Promise<unknown>
}
/** History batch size: the log loads lazily in pages so a long history never
* floods the panel at once (the end of the log is reached by paging). */
const LOG_BATCH = 20
export function GitView(props: {
scope: SessionScope
onOpenFile: (path: string) => void
/** Open a diff tab (the shell places it below the git pane on first use). */
onOpenDiff: (tab: SidebarTab) => void
}) {
const { scope, onOpenFile, onOpenDiff } = props
const [status, setStatus] = useState<GitStatusResult | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -35,12 +94,16 @@ export function GitView(props: { scope: SessionScope }) {
const [commitMsg, setCommitMsg] = useState('')
const [busy, setBusy] = useState(false)
const [commitError, setCommitError] = useState<string | null>(null)
/** Whether the history was fully paged (a batch shorter than LOG_BATCH). */
const [logEnded, setLogEnded] = useState(false)
const [logLoadingMore, setLogLoadingMore] = useState(false)
// Selected diff: path + side (staged) + the two text sides for DiffBlock.
const [diffPath, setDiffPath] = useState<string | null>(null)
const [diffStaged, setDiffStaged] = useState(false)
const [diffOld, setDiffOld] = useState<string | null>(null)
const [diffNew, setDiffNew] = useState('')
/** The open file-row context menu (cursor position for the portaled Menu). */
const [fileMenu, setFileMenu] = useState<{ entry: GitStatusEntry; staged: boolean; x: number; y: number } | null>(null)
/** The open history-row context menu. */
const [historyMenu, setHistoryMenu] = useState<{ entry: GitLogEntry; x: number; y: number } | null>(null)
/** The pending destructive action awaiting confirmation. */
const [confirm, setConfirm] = useState<ConfirmState | null>(null)
const refresh = useCallback(async (): Promise<void> => {
setLoading(true)
@@ -49,11 +112,13 @@ export function GitView(props: { scope: SessionScope }) {
const [statusResult, branchResult, logResult] = await Promise.all([
api.gitStatus(scope),
api.gitBranch(scope).catch(() => ({ current: '', names: [] as string[] })),
api.gitLog(scope).catch(() => [] as GitLogEntry[]),
// The first history page only; the rest arrives via "load more".
api.gitLog(scope, LOG_BATCH, 0).catch(() => [] as GitLogEntry[]),
])
setStatus(statusResult)
setBranchNames(branchResult.names)
setLogEntries(logResult)
setLogEnded(logResult.length < LOG_BATCH)
} catch (reason) {
setError(reason instanceof Error ? reason.message : String(reason))
} finally {
@@ -63,23 +128,39 @@ export function GitView(props: { scope: SessionScope }) {
useEffect(() => { void refresh() }, [refresh])
/** Load the diff sides for one entry: HEAD content vs worktree/index content. */
const selectDiff = async (entry: GitStatusEntry, staged: boolean): Promise<void> => {
setDiffPath(entry.path)
setDiffStaged(staged)
setDiffOld(null)
setDiffNew('')
const oldPromise = api.gitShow(scope, 'HEAD', entry.path)
.then(result => result.content)
.catch(() => null)
const newPromise = staged
? api.gitShow(scope, ':', entry.path).then(result => result.content ?? '').catch(() => '')
: api.fsRead(scope, entry.path)
.then(result => (result.kind === 'text' ? result.content : ''))
.catch(() => '')
const [oldText, newText] = await Promise.all([oldPromise, newPromise])
setDiffOld(oldText)
setDiffNew(newText)
/** Append the next history page (lazy: only when the user asks for more). */
const loadMoreLog = async (): Promise<void> => {
if (logLoadingMore || logEnded) return
setLogLoadingMore(true)
try {
const next = await api.gitLog(scope, LOG_BATCH, logEntries.length)
setLogEntries(entries => [...entries, ...next])
if (next.length < LOG_BATCH) setLogEnded(true)
} catch (reason) {
setCommitError(`${t('historyLoadError')}: ${reason instanceof Error ? reason.message : String(reason)}`)
} finally {
setLogLoadingMore(false)
}
}
/** The diff tab for one changed file (one tab per path+side; same id = focused). */
const openWorktreeDiff = (entry: GitStatusEntry, staged: boolean): void => {
onOpenDiff({
id: `diff:w:${staged ? 's' : 'u'}:${entry.path}`,
type: 'diff',
title: baseName(entry.path),
diff: { kind: 'worktree', path: entry.path, staged, untracked: isUntracked(entry) },
})
}
/** The diff tab for one commit (one tab per commit). */
const openCommitDiff = (entry: GitLogEntry): void => {
onOpenDiff({
id: `diff:c:${entry.hashFull}`,
type: 'diff',
title: `${entry.hash} ${entry.subject}`,
diff: { kind: 'commit', hash: entry.hash, hashFull: entry.hashFull, subject: entry.subject },
})
}
const stageEntry = async (entry: GitStatusEntry, staged: boolean): Promise<void> => {
@@ -87,7 +168,6 @@ export function GitView(props: { scope: SessionScope }) {
try {
if (staged) await api.gitUnstage(scope, entry.path)
else await api.gitStage(scope, entry.path)
setDiffPath(null)
await refresh()
} finally {
setBusy(false)
@@ -99,7 +179,6 @@ export function GitView(props: { scope: SessionScope }) {
try {
if (staged) await api.gitUnstage(scope)
else await api.gitStage(scope)
setDiffPath(null)
await refresh()
} finally {
setBusy(false)
@@ -114,7 +193,6 @@ export function GitView(props: { scope: SessionScope }) {
try {
await api.gitCommit(scope, message)
setCommitMsg('')
setDiffPath(null)
await refresh()
} catch (reason) {
setCommitError(reason instanceof Error ? reason.message : String(reason))
@@ -129,7 +207,6 @@ export function GitView(props: { scope: SessionScope }) {
setCommitError(null)
try {
await api.gitCheckout(scope, branch)
setDiffPath(null)
await refresh()
} catch (reason) {
setCommitError(`${t('checkoutError')}: ${reason instanceof Error ? reason.message : String(reason)}`)
@@ -138,18 +215,51 @@ export function GitView(props: { scope: SessionScope }) {
}
}
const stagedEntries = (status?.entries ?? []).filter(entry => badgeOf(entry) !== '?')
const unstagedEntries = (status?.entries ?? []).filter(entry => badgeOf(entry) === '?')
/** Run one destructive operation after the confirm modal, then refresh. */
const runConfirmed = (confirmState: ConfirmState): void => {
setConfirm({ ...confirmState, onConfirm: async () => {
setBusy(true)
setCommitError(null)
try {
await confirmState.onConfirm()
await refresh()
} catch (reason) {
setCommitError(reason instanceof Error ? reason.message : String(reason))
} finally {
setBusy(false)
}
} })
}
/** Copy `text` to the clipboard (best-effort; no visual feedback needed — the menu closes). */
const copy = (text: string): void => {
void writeClipboard(text)
}
const openFileMenu = (event: MouseEvent, entry: GitStatusEntry, staged: boolean): void => {
event.preventDefault()
event.stopPropagation()
setFileMenu({ entry, staged, x: event.clientX, y: event.clientY })
}
const openHistoryMenu = (event: MouseEvent, entry: GitLogEntry): void => {
event.preventDefault()
event.stopPropagation()
setHistoryMenu({ entry, x: event.clientX, y: event.clientY })
}
const stagedEntries = (status?.entries ?? []).filter(isStagedEntry)
const unstagedEntries = (status?.entries ?? []).filter(isUnstagedEntry)
const renderEntry = (entry: GitStatusEntry, staged: boolean): ReactNode => {
const selected = diffPath === entry.path && diffStaged === staged
return (
<div key={`${staged ? 's' : 'u'}:${entry.path}`} className={clsx(css.gitRow, selected && css.gitRowSelected)}>
<div key={`${staged ? 's' : 'u'}:${entry.path}`} className={css.gitRow}>
<button
type="button"
className={css.gitRowMain}
title={entry.path}
onClick={() => { void selectDiff(entry, staged) }}
onClick={() => { openWorktreeDiff(entry, staged) }}
onContextMenu={(event) => { openFileMenu(event, entry, staged) }}
>
<span className={css.gitBadge}>{badgeOf(entry)}</span>
<span className={css.gitName}>{entry.path}</span>
@@ -224,15 +334,6 @@ export function GitView(props: { scope: SessionScope }) {
{unstagedEntries.map(entry => renderEntry(entry, false))}
</div>
{diffPath !== null && (
<div className={css.gitDiff}>
<DiffBlock
diffs={[{ path: diffPath, oldText: diffOld, newText: diffNew }]}
maxLines={300}
/>
</div>
)}
<div className={css.gitCommit}>
<Input
className={css.gitCommitInput}
@@ -258,12 +359,180 @@ export function GitView(props: { scope: SessionScope }) {
<div className={css.gitSection}>
<div className={css.gitSectionHeader}><span>{t('history')}</span></div>
{logEntries.map(entry => (
<div key={entry.hash} className={css.gitLogRow} title={`${entry.author} · ${entry.date}`}>
<span className={css.gitLogHash}>{entry.hash}</span>
<span className={css.gitLogSubject}>{entry.subject}</span>
<div
key={entry.hashFull}
role="button"
tabIndex={0}
className={css.gitLogRow}
title={`${entry.author} · ${entry.date}\n${entry.hashFull}`}
onClick={() => { openCommitDiff(entry) }}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
openCommitDiff(entry)
}
}}
onContextMenu={(event) => { openHistoryMenu(event, entry) }}
>
<span className={css.gitLogLine1}>
<span className={css.gitLogHash}>{entry.hash}</span>
<span className={css.gitLogSubject}>{entry.subject}</span>
</span>
<span className={css.gitLogLine2}>
{refNames(entry.refs).map(ref => (
<span key={ref} className={css.gitLogRef}>{ref}</span>
))}
<span className={css.gitLogMeta}>{entry.author} · {relativeTime(entry.date)}</span>
</span>
</div>
))}
{!logEnded && (
<button
type="button"
className={css.gitLogMore}
disabled={logLoadingMore || busy}
onClick={() => { void loadMoreLog() }}
>
{logLoadingMore ? t('loading') : t('loadMore')}
</button>
)}
</div>
{/*
The one shared file-row context menu, positioned at the right-click
cursor (portal so the panel's overflow clip cannot crop it).
*/}
<Menu
open={fileMenu !== null}
onClose={() => { setFileMenu(null) }}
items={[
{ id: 'open', label: t('openEditor'), icon: <IconCodeOutline16 size={14} /> },
fileMenu?.staged === true
? { id: 'stage', label: t('unstage'), icon: <IconTrashOutline16 size={14} /> }
: { id: 'stage', label: t('stage'), icon: <IconBranchOutline16 size={14} /> },
...(fileMenu !== null && !isUntracked(fileMenu.entry)
? [{ id: 'discard', label: t('discard'), icon: <IconTrashOutline16 size={14} />, danger: true }]
: []),
{ type: 'separator', id: 'sep1' },
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={14} /> },
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={14} /> },
]}
onSelect={(id) => {
const target = fileMenu
if (target === null) return
setFileMenu(null)
if (id === 'open') {
onOpenFile(target.entry.path)
return
}
if (id === 'stage') {
void stageEntry(target.entry, target.staged)
return
}
if (id === 'discard') {
runConfirmed({
title: t('discardTitle'),
description: t('discardDesc', { path: target.entry.path }),
confirmLabel: t('discard'),
onConfirm: () => api.gitDiscard(scope, target.entry.path),
})
return
}
if (id === 'relative') {
copy(relativeTo(scope.cwd ?? '', target.entry.path))
return
}
if (id === 'absolute') copy(target.entry.path)
}}
portal
align="start"
getAnchorRect={() => (fileMenu === null ? null : new DOMRect(fileMenu.x, fileMenu.y, 0, 0))}
anchor={<span />}
/>
{/* The shared history-row context menu. */}
<Menu
open={historyMenu !== null}
onClose={() => { setHistoryMenu(null) }}
items={[
{ id: 'view', label: t('viewCommitDiff') },
{ id: 'copyShort', label: t('copyShortHash'), icon: <IconCopyOutline16 size={14} /> },
{ id: 'copyFull', label: t('copyFullHash'), icon: <IconCopyOutline16 size={14} /> },
{ id: 'copySubject', label: t('copySubject'), icon: <IconCopyOutline16 size={14} /> },
{ type: 'separator', id: 'sep2' },
{ id: 'revert', label: t('revertCommit'), danger: true },
{ id: 'cherryPick', label: t('cherryPickCommit'), danger: true },
]}
onSelect={(id) => {
const target = historyMenu
if (target === null) return
setHistoryMenu(null)
if (id === 'view') {
openCommitDiff(target.entry)
return
}
if (id === 'copyShort') {
copy(target.entry.hash)
return
}
if (id === 'copyFull') {
copy(target.entry.hashFull)
return
}
if (id === 'copySubject') {
copy(target.entry.subject)
return
}
if (id === 'revert') {
runConfirmed({
title: t('revertTitle'),
description: t('revertDesc', { subject: target.entry.subject }),
confirmLabel: t('revertCommit'),
onConfirm: () => api.gitRevert(scope, target.entry.hashFull),
})
return
}
if (id === 'cherryPick') {
runConfirmed({
title: t('cherryPickTitle'),
description: t('cherryPickDesc', { subject: target.entry.subject }),
confirmLabel: t('cherryPickCommit'),
onConfirm: () => api.gitCherryPick(scope, target.entry.hashFull),
})
}
}}
portal
align="start"
getAnchorRect={() => (historyMenu === null ? null : new DOMRect(historyMenu.x, historyMenu.y, 0, 0))}
anchor={<span />}
/>
{/* Destructive actions land here first: Cancel / Confirm. */}
<Modal
open={confirm !== null}
onClose={() => { setConfirm(null) }}
title={confirm?.title ?? ''}
closeLabel={t('cancel')}
footer={(
<>
<Button variant="outline" onClick={() => { setConfirm(null) }}>{t('cancel')}</Button>
<Button
variant="primary"
disabled={busy}
onClick={() => {
const pending = confirm
if (pending === null) return
setConfirm(null)
void pending.onConfirm()
}}
>
{confirm?.confirmLabel ?? ''}
</Button>
</>
)}
>
<p className={css.gitConfirmDesc}>{confirm?.description}</p>
</Modal>
</>
)}
</div>
+27
View File
@@ -0,0 +1,27 @@
/**
* Placeholder shown when a persisted tab's type is not registered (the
* contributing plugin is not installed / not yet loaded). The tab is kept
* in state so it can recover if the plugin loads later; the user can close
* it through the tab bar's X button as usual. This is the graceful-
* degradation path for the open tab-type set: {@link Sidebar} renders this
* instead of crashing on an unknown type.
*/
import type { ReactNode } from 'react'
import { t } from './locales.ts'
import css from './sidebar.module.css'
import type { TabComponentProps } from './service.ts'
export function OrphanedTab(props: TabComponentProps): ReactNode {
const { tab } = props
return (
<div className={css.editor}>
<div className={css.editorHeader}>
<span className={css.editorTitle} title={tab.type}>{tab.title}</span>
</div>
<div className={css.editorPlaceholder}>
<span>{t('pluginNotLoaded')}</span>
<code className={css.orphanedType}>{tab.type}</code>
</div>
</div>
)
}
+183 -8
View File
@@ -1,13 +1,20 @@
/**
* Side card settings section chrome: the native settings-row recipe (figma
* row 16/0 padding, hairline separator, 14px/22px title, 12px/17px desc) with
* the native checkbox (16px, brand accent) and the Input atom + "%" suffix.
* The shell strips the last row's separator via the trailing-child rule.
* Side card settings section chrome (DSH-native tokens throughout):
* - the feature inventories are responsive GRIDS of small toggle cards
* (auto-fill columns — several cards per row, wrapping);
* - a card's on/off state IS its visual state: enabled = brand border +
* tinted fill + a check badge pinned to the far right of the top row,
* disabled = neutral hairline + dimmed text;
* - features that declare related settings carry a gear corner button
* opening a native Modal whose rows are the DSH settings-row recipe
* (title/desc left + native accent checkbox, hairline separators);
* - the width input stays a native settings row (a value, not a switch).
*/
.section {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
@@ -15,14 +22,160 @@
border-bottom: none;
}
/* Responsive inventory: as many small cards per row as the width allows. */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
gap: 8px;
}
/* One small toggle card (visual chrome; the interactive part is .cardMain). */
.card {
position: relative;
display: flex;
flex-direction: column;
border-radius: 8px;
border: 1px solid var(--dsw-alias-border-l2);
background: transparent;
font: inherit;
color: inherit;
cursor: pointer;
transition: background 0.12s ease, border-color 0.12s ease;
}
/* Hover affordance on disabled cards only (enabled cards are already lit). */
.card:not(.cardOn):hover {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Enabled = highlighted: brand border + tinted fill. */
.cardOn {
border-color: var(--dsw-alias-button-primary-fill);
background: var(--dsw-alias-interactive-bg-active);
}
/* The card's switch: the whole main area (top row + desc) flips the feature. */
.cardMain {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: 10px 12px;
border: 0;
border-radius: inherit;
background: transparent;
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
}
.cardMain:focus-visible,
.cardGear:focus-visible {
outline: 2px solid var(--dsw-alias-border-l4);
outline-offset: 2px;
}
.cardTop {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.cardIcon {
display: inline-flex;
align-items: center;
flex: none;
color: var(--dsw-alias-label-tertiary);
}
.cardOn .cardIcon {
color: var(--dsw-alias-button-primary-fill);
}
.cardTitle {
flex: 1;
min-width: 0;
font-size: 13px;
line-height: 20px;
color: var(--dsw-alias-label-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cardOn .cardTitle {
color: var(--dsw-alias-label-primary);
}
/* The check badge: pinned to the FAR RIGHT of the top row (enabled only). */
.cardCheck {
display: inline-flex;
align-items: center;
flex: none;
color: var(--dsw-alias-button-primary-fill);
}
.cardDesc {
font-size: 11px;
line-height: 16px;
color: var(--dsw-alias-label-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cardOn .cardDesc {
color: var(--dsw-alias-label-secondary);
}
/* The gear corner button: bottom-right of cards that declare related
settings; the desc gets right padding so the ellipsis never runs under it. */
.cardWithGear .cardDesc {
padding-right: 18px;
}
.cardGear {
position: absolute;
right: 6px;
bottom: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.cardGear:hover {
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-interactive-bg-hover);
}
/* Inventory section heading (常规 / 侧边栏内容 / 文件预览). */
.sectionHeading {
padding: 14px 0 0;
font-size: 12px;
line-height: 17px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--dsw-alias-label-secondary);
}
/* Native settings row recipe (the width input row + popup rows). */
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 0;
padding: 12px 0;
border-bottom: 1px solid var(--dsw-alias-border-l2);
cursor: pointer;
}
.rowText {
@@ -44,7 +197,7 @@
color: var(--dsw-alias-label-secondary);
}
/* Native checkbox: 16px, brand accent, no custom chrome (RiskConfirmation recipe). */
/* Native checkbox: 16px, brand accent (the settings-row recipe). */
.toggle {
flex: none;
width: 16px;
@@ -59,6 +212,28 @@
outline-offset: 2px;
}
/* Secondary settings popup body: stacked native rows, trailing separator
stripped by the trailing-child rule. */
.popupRows {
display: flex;
flex-direction: column;
width: 100%;
}
.popupRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 0;
border-bottom: 1px solid var(--dsw-alias-border-l2);
cursor: pointer;
}
.popupRows > :last-child {
border-bottom: none;
}
.control {
flex: none;
display: flex;
@@ -84,7 +259,7 @@
}
@media (prefers-reduced-motion: reduce) {
.row {
.card {
transition: none;
}
}
+262 -47
View File
@@ -1,21 +1,43 @@
/**
* "Side card" settings section: the user-facing preferences for the sidebar
* panel, rendered natively in the DSH Settings shell (nav label "Side card").
* Three rows:
* - new conversations open the panel by default (native checkbox),
* - the default panel width as a percent of the window (number input + %),
* - auto-open the Subagent page when a new subagent appears (native checkbox,
* on by default).
*
* The section is DECLARATIVE — it renders the enable/disable inventory from
* the sidebar service's registries instead of hardcoding rows:
* - 常规: new conversations open the panel by default (a toggle card), and
* the default panel width as a percent of the window (number input row).
* - 侧边栏内容: one SMALL CARD per REGISTERED tab type (built-ins and
* external plugins alike), laid out in a responsive grid that wraps
* several cards per row — icon + title + type id, clicked to toggle the
* switch persisted in `prefs.tabsEnabled[id]`.
* - 文件预览: one SMALL CARD per REGISTERED file viewer — icon + title +
* the extensions it covers, clicked to toggle `prefs.viewersEnabled[id]`.
*
* A card's on/off state is its VISUAL STATE: enabled = highlighted (brand
* border + tinted fill + a check badge pinned to the card's far right),
* disabled = neutral and dimmed. Features that declare `settings.toggles`
* carry a gear corner button that opens a native Modal with the related
* settings as native checkbox rows (e.g. the Subagent page's "auto-open
* when a subagent appears", the Terminal page's model terminal tools).
*
* Writes ride the plugin's own fenced settings route (the host calls the
* settings seam in-process — the DSH settings RPC domain does not serve
* third-party namespaces to configuration clients); the shared SidebarStore
* is refreshed on success so the very next brand-new session seeds from the
* new values. Any failure reverts the optimistic UI and shows the wire error
* inline — a broken settings surface never crashes the shell.
* new values and the sidebar's consumption points (the + menu, derived
* flows) re-render immediately. Any failure reverts the optimistic UI and
* shows the wire error inline — a broken settings surface never crashes the
* shell.
*/
import { useEffect, useRef, useState } from 'react'
import { Input } from '@deepseek-ai/dsh-client-ui-primitives'
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
import {
IconCheckOutline16,
IconPanelLeftOutline16,
IconSettingsOutline16,
Input,
Modal,
} from '@deepseek-ai/dsh-client-ui-primitives'
import clsx from 'clsx'
// Type-only: pulls the settings shell's SlotMap merges ('settings.section').
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
@@ -29,11 +51,18 @@ import { api } from './api.ts'
import { parsePrefs } from './prefs.ts'
import { t } from './locales.ts'
import type { SidebarStore } from './state.ts'
import type {
BetterSidebarService,
FileViewerDescriptor,
SidebarSettingToggle,
TabDescriptor,
} from './service.ts'
import css from './SideCardSection.module.css'
/** Injected business face: the shared store (prefs cache). */
/** Injected business face: the shared store (prefs cache) + the sidebar service (registries). */
export interface SideCardSectionInjected {
store: SidebarStore
service: BetterSidebarService
}
/** Full section props: the runtime share plus the injected face. */
@@ -47,15 +76,90 @@ function messageOf(error: unknown): string {
return `${t('settingsSaveFailed')} ${error instanceof Error ? error.message : String(error)}`
}
/** Resolve an i18n-friendly string-or-function value. */
function textOf(value: string | (() => string) | undefined): string {
if (value === undefined) return ''
return typeof value === 'function' ? value() : value
}
/** Resolve a descriptor icon (ReactNode or size function). */
function iconOf(icon: ReactNode | ((size: number) => ReactNode) | undefined, size: number): ReactNode {
if (icon === undefined) return null
return typeof icon === 'function' ? icon(size) : icon
}
/** Tab inventory order: hidden types (editor/diff) last, then + menu order. */
function tabOrder(a: TabDescriptor, b: TabDescriptor): number {
if (a.hidden !== b.hidden) return a.hidden === true ? 1 : -1
return (a.order ?? 100) - (b.order ?? 100)
}
/** Viewer inventory order: priority desc (the catch-all `code` comes last). */
function viewerOrder(a: FileViewerDescriptor, b: FileViewerDescriptor): number {
return (b.priority ?? 0) - (a.priority ?? 0)
}
/** Read one boolean pref by declarative key (missing = false). */
function prefBool(prefs: SidebarPrefs, key: string): boolean {
return (prefs as unknown as Record<string, boolean>)[key] === true
}
/**
* The body of a feature's secondary settings popup: one native checkbox row
* per declared toggle. Extracted so the rows are testable without opening
* the Modal (the Modal portal renders only while open).
*/
export function FeatureSettingsRows(props: {
toggles: readonly SidebarSettingToggle[]
prefs: SidebarPrefs
onToggle: (toggle: SidebarSettingToggle, next: boolean) => void
}) {
const { toggles, prefs, onToggle } = props
return (
<div className={css.popupRows}>
{toggles.map(toggle => {
const title = textOf(toggle.title)
return (
<label key={toggle.key} className={css.popupRow}>
<span className={css.rowText}>
<span className={css.title}>{title}</span>
{textOf(toggle.desc) !== '' && <span className={css.desc}>{textOf(toggle.desc)}</span>}
</span>
<input
type="checkbox"
className={css.toggle}
checked={prefBool(prefs, toggle.key)}
aria-label={title}
onChange={event => { onToggle(toggle, event.currentTarget.checked) }}
/>
</label>
)
})}
</div>
)
}
/**
* Render the Side card preferences section.
* @param props - composed slot props (runtime share + injected store).
* @param props - composed slot props (runtime share + injected store/service).
* @returns the section element tree.
*/
export function SideCardSection({ store }: SideCardSectionProps) {
export function SideCardSection({ store, service }: SideCardSectionProps) {
const [prefs, setPrefs] = useState<SidebarPrefs>(() => store.getPrefs())
const [widthDraft, setWidthDraft] = useState<string>(String(store.getPrefs().defaultWidthPercent))
const [error, setError] = useState<string | null>(null)
// Which feature's secondary settings popup is open (null = closed).
const [settingsFor, setSettingsFor] = useState<TabDescriptor | null>(null)
// The declarative inventory: the registered tab types and file viewers.
// Local state + service.subscribe (registry changes are rare — plugin
// load/unload — so a plain effect is enough; no external-store ceremony).
const [tabs, setTabs] = useState<TabDescriptor[]>(() => [...service.getTabs()].sort(tabOrder))
const [viewers, setViewers] = useState<FileViewerDescriptor[]>(() => [...service.getFileViewers()].sort(viewerOrder))
useEffect(() => service.subscribe(() => {
setTabs([...service.getTabs()].sort(tabOrder))
setViewers([...service.getFileViewers()].sort(viewerOrder))
}), [service])
// The settings document revision (guards concurrent writes). A ref: commits
// read the freshest value at execution time, no re-render needed.
@@ -84,7 +188,7 @@ export function SideCardSection({ store }: SideCardSectionProps) {
}, [])
/** Persist one patch through the settings route (serialized, revision-guarded). */
const commit = (patch: Partial<SidebarPrefs>): Promise<{ ok: boolean; prefs: SidebarPrefs }> => {
const commit = (patch: Record<string, unknown>): Promise<{ ok: boolean; prefs: SidebarPrefs }> => {
dirtyRef.current = true
const run = inFlightRef.current.then(async () => {
const view = await api.settingsUpdate(
@@ -114,18 +218,31 @@ export function SideCardSection({ store }: SideCardSectionProps) {
setWidthDraft(String(settled.defaultWidthPercent))
}
const onToggle = (next: boolean): void => {
/** Optimistically flip one boolean pref, then commit (revert on failure). */
const togglePref = (patch: Record<string, unknown>): void => {
const previous = prefs
setPrefs({ ...previous, openByDefault: next })
setPrefs({ ...previous, ...patch } as SidebarPrefs)
setError(null)
void commit({ openByDefault: next }).then(outcome => applyOutcome(previous, outcome))
void commit(patch).then(outcome => applyOutcome(previous, outcome))
}
const onToggleSubagent = (next: boolean): void => {
const previous = prefs
setPrefs({ ...previous, autoOpenSubagent: next })
setError(null)
void commit({ autoOpenSubagent: next }).then(outcome => applyOutcome(previous, outcome))
const onToggle = (next: boolean): void => {
togglePref({ openByDefault: next })
}
/** Flip one per-tab enable switch (merge into the tabsEnabled map). */
const onToggleTab = (id: string, next: boolean): void => {
togglePref({ tabsEnabled: { ...prefs.tabsEnabled, [id]: next } })
}
/** Flip one per-viewer enable switch (merge into the viewersEnabled map). */
const onToggleViewer = (id: string, next: boolean): void => {
togglePref({ viewersEnabled: { ...prefs.viewersEnabled, [id]: next } })
}
/** Flip one declaratively-declared toggle (a SidebarPrefs boolean field). */
const onToggleSetting = (toggle: SidebarSettingToggle, next: boolean): void => {
togglePref({ [toggle.key]: next })
}
const commitWidth = (): void => {
@@ -142,21 +259,71 @@ export function SideCardSection({ store }: SideCardSectionProps) {
void commit({ defaultWidthPercent: clamped }).then(outcome => applyOutcome(previous, outcome))
}
/**
* One SMALL toggle card for the responsive inventory grid: the card's main
* area is the switch (click to flips, visual state IS the state), the
* check badge sits at the far right, and a feature that declares related
* settings carries a gear corner button opening its settings popup.
*/
const renderCard = (props: {
title: string
desc: string
icon?: ReactNode
enabled: boolean
onToggle: (next: boolean) => void
/** A feature with declared related settings shows the gear corner button. */
onOpenSettings?: () => void
}) => {
const hasSettings = props.onOpenSettings !== undefined
return (
<div
className={clsx(css.card, props.enabled && css.cardOn, hasSettings && css.cardWithGear)}
>
<button
type="button"
className={css.cardMain}
aria-pressed={props.enabled}
title={props.desc}
onClick={() => { props.onToggle(!props.enabled) }}
>
<span className={css.cardTop}>
{props.icon !== null && props.icon !== undefined && (
<span className={css.cardIcon}>{props.icon}</span>
)}
<span className={css.cardTitle}>{props.title}</span>
{props.enabled && (
<span className={css.cardCheck}>
<IconCheckOutline16 size={14} />
</span>
)}
</span>
<span className={css.cardDesc}>{props.desc}</span>
</button>
{hasSettings && (
<button
type="button"
className={css.cardGear}
aria-label={`${props.title} ${t('settingsPopup')}`}
title={t('settingsPopup')}
onClick={props.onOpenSettings}
>
<IconSettingsOutline16 size={14} />
</button>
)}
</div>
)
}
return (
<div className={css.section}>
<label className={css.row}>
<span className={css.rowText}>
<span className={css.title}>{t('settingsOpenTitle')}</span>
<span className={css.desc}>{t('settingsOpenDesc')}</span>
</span>
<input
type="checkbox"
className={css.toggle}
checked={prefs.openByDefault}
aria-label={t('settingsOpenTitle')}
onChange={event => { onToggle(event.currentTarget.checked) }}
/>
</label>
<div className={css.sectionHeading}>{t('settingsGeneralTitle')}</div>
{renderCard({
title: t('settingsOpenTitle'),
desc: t('settingsOpenDesc'),
icon: <IconPanelLeftOutline16 size={16} />,
enabled: prefs.openByDefault,
onToggle,
})}
<div className={css.row}>
<span className={css.rowText}>
<span className={css.title}>{t('settingsWidthTitle')}</span>
@@ -180,19 +347,67 @@ export function SideCardSection({ store }: SideCardSectionProps) {
<span className={css.suffix}>{t('settingsWidthSuffix')}</span>
</span>
</div>
<label className={css.row}>
<span className={css.rowText}>
<span className={css.title}>{t('settingsSubagentTitle')}</span>
<span className={css.desc}>{t('settingsSubagentDesc')}</span>
</span>
<input
type="checkbox"
className={css.toggle}
checked={prefs.autoOpenSubagent}
aria-label={t('settingsSubagentTitle')}
onChange={event => { onToggleSubagent(event.currentTarget.checked) }}
/>
</label>
{/* 侧边栏内容: one small card per registered tab type in a responsive
grid; features declaring `settings.toggles` open their settings in
the popup (gear corner button) instead of nested inline rows. */}
<div className={css.sectionHeading}>{t('settingsTabsTitle')}</div>
<div className={css.grid}>
{tabs.map(tab => (
<Fragment key={tab.id}>
{renderCard({
title: textOf(tab.title),
desc: tab.id,
icon: iconOf(tab.icon, 16),
enabled: prefs.tabsEnabled[tab.id] !== false,
onToggle: (next) => { onToggleTab(tab.id, next) },
// The settings gear only while the feature is enabled: its
// related settings are dormant while the feature is off.
onOpenSettings: prefs.tabsEnabled[tab.id] !== false
&& (tab.settings?.toggles?.length ?? 0) > 0
? () => { setSettingsFor(tab) }
: undefined,
})}
</Fragment>
))}
</div>
{/* 文件预览: one small card per registered file viewer. */}
<div className={css.sectionHeading}>{t('settingsViewersTitle')}</div>
<div className={css.grid}>
{viewers.map(viewer => (
<Fragment key={viewer.id}>
{renderCard({
title: textOf(viewer.title) || viewer.id,
desc: viewer.exts.length === 0 ? t('settingsViewerCatchAll') : viewer.exts.join(' · '),
icon: iconOf(viewer.icon, 16),
enabled: prefs.viewersEnabled[viewer.id] !== false,
onToggle: (next) => { onToggleViewer(viewer.id, next) },
})}
</Fragment>
))}
</div>
{/* The secondary settings popup: a feature's declared related settings
as native checkbox rows (Modal chrome is the app's own). Mounted
only while a feature is open — the Modal primitive runs hooks
unconditionally, so a closed-but-mounted Modal would break SSR
(and the renderToString spec) under the test dual-react split. */}
{settingsFor !== null && (
<Modal
open
onClose={() => { setSettingsFor(null) }}
title={textOf(settingsFor.title)}
closeLabel={t('close')}
>
<FeatureSettingsRows
toggles={settingsFor.settings?.toggles ?? []}
prefs={prefs}
onToggle={onToggleSetting}
/>
</Modal>
)}
{error !== null && (
<div className={css.error} role="alert">
{error}
+132 -99
View File
@@ -15,26 +15,25 @@ import clsx from 'clsx'
import { IconChevronRightOutline14, IconFullscreenOutline16, IconPanelLeftOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context, SidebarConversation, SidebarSessionList } from '../context-types.ts'
import {
allLeaves, closeTab, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openTab,
agentUuidOf, closeTab, isAgentTabId, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openDiffTab,
reconcileAgentTerminals,
resizeSplit, setWidth, toggleExpanded, togglePanel,
TERMINAL_LIMIT, type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
} from './state.ts'
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
import type { NewTabOption } from './TabBar.tsx'
import type { TabDragPayload } from './TabBar.tsx'
import { tabTypeIcon } from './icons.tsx'
import { relativeTo } from './paths.ts'
import { ExplorerView } from './ExplorerView.tsx'
import { EditorView } from './EditorView.tsx'
import { TerminalView } from './TerminalView.tsx'
import { GitView } from './GitView.tsx'
import { SubagentView } from './SubagentView.tsx'
import { OrphanedTab } from './OrphanedTab.tsx'
import { detectNewDirectSubagent } from './subagent-detect.ts'
import { t } from './locales.ts'
import { api } from './api.ts'
import { openSidebarFile } from './intercept.tsx'
import { api, type SessionScope } from './api.ts'
import css from './sidebar.module.css'
/** How many consecutive reconnect failures stop the agent-terminals push loop
* (mirror of the terminal view's own cap; the loop restarts on session switch). */
const FAILURE_LIMIT = 3
/** Render the content of one tab (dispatched by type). */
function TabContent(props: {
tab: SidebarTab
@@ -49,55 +48,38 @@ function TabContent(props: {
visible: boolean
/** Fired before a topology node jumps to its child session (see Sidebar). */
onSubagentJump: (childSessionId: string) => void
/** Open a diff tab from the git panel (placement handled by the store). */
onOpenDiff: (tab: SidebarTab) => void
}) {
const { tab, sessionId, cwd, expanded, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump } = props
const { tab, sessionId, cwd, expanded, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump, onOpenDiff } = props
const scope = { sessionId, cwd }
switch (tab.type) {
case 'explorer':
return (
<ExplorerView
sessionId={sessionId}
cwd={cwd}
expanded={expanded}
onToggle={onToggleDir}
onOpenFile={(path) => { openSidebarFile(ctx, store, sessionId, path) }}
onReferenceFile={onReferenceFile}
/>
)
case 'git':
return <GitView scope={scope} />
case 'terminal':
return <TerminalView scope={scope} tabId={tab.id} store={store} />
case 'editor':
return <EditorView scope={scope} path={tab.path ?? ''} title={tab.title} />
case 'subagent':
return (
<SubagentView
sessionId={sessionId}
ctx={ctx}
active={visible}
onOpenChild={(address) => { onSubagentJump(address.childSessionId) }}
/>
)
const descriptor = ctx.betterSidebar?.getTab(tab.type)
if (descriptor === undefined) {
return <OrphanedTab ctx={ctx} store={store} scope={scope} tab={tab} visible={visible} />
}
return descriptor.component({
ctx, store, scope, tab, visible, expanded,
onToggleDir, onReferenceFile, onOpenDiff, onSubagentJump,
})
}
/** The + menu options for the current state (terminal cap applied). */
function buildNewTabOptions(state: SidebarState): NewTabOption[] {
const terminalCount = allLeaves(state.splits)
.flatMap(leaf => leaf.tabs)
.filter(tab => tab.type === 'terminal').length
return [
{ id: 'explorer', label: t('openExplorer'), icon: tabTypeIcon('explorer', 16) },
{ id: 'git', label: t('openGit'), icon: tabTypeIcon('git', 16) },
{ id: 'subagent', label: t('openSubagent'), icon: tabTypeIcon('subagent', 16) },
{
id: 'terminal',
label: terminalCount >= TERMINAL_LIMIT ? `${t('newTerminal')} (${t('terminalLimit')})` : t('newTerminal'),
disabled: terminalCount >= TERMINAL_LIMIT,
icon: tabTypeIcon('terminal', 16),
},
]
/** The + menu options for the current state, driven by the tab registry.
* Hidden tabs (editor/diff) never show; `available` returning false shows
* a disabled row (e.g. terminal at capacity) instead of hiding the option.
* Tabs the user disabled in the side card settings are filtered out
* entirely — re-enabling them is the settings page's job. */
function buildNewTabOptions(state: SidebarState, ctx: Context, scope: SessionScope): NewTabOption[] {
const service = ctx.betterSidebar
if (service === undefined) return []
return service.getTabs()
.filter(d => !d.hidden && service.isTabEnabled(d.id))
.sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
.map(d => ({
id: d.id,
label: typeof d.title === 'function' ? d.title() : d.title,
disabled: !(d.available?.(ctx, scope, state) ?? true),
icon: typeof d.icon === 'function' ? d.icon(16) : d.icon,
}))
}
export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
@@ -137,14 +119,71 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
}, [sessionId, summaryCwd])
const cwd = summaryCwd ?? fetchedCwd
/**
* Agent terminals push: subscribe to the host's live list of agent-owned
* terminals for this session (created by the model through the
* `terminal_create` tool). The host pushes a JSON array on every
* create / close / exit; the sidebar reconciles the list into tabs
* (id `agent:<uuid>`, title from the agent). A disconnected socket
* retries with a short backoff so a refresh or transient drop reattaches
* the same shell without losing the agent's work — capped like the
* terminal view's own reconnect loop, so a refused endpoint never spins
* forever (the next session switch restarts the loop).
* While the terminal tab type is disabled in settings, pushes are
* ignored (no auto-added tabs); re-enabling makes the next push converge.
*/
useEffect(() => {
if (sessionId === undefined) return
let socket: WebSocket | null = null
let retry: number | undefined
let closed = false
let failures = 0
const connect = (): void => {
if (closed) return
const url = new URL('/sidebar/ws/agent-terminals', location.origin)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
url.search = new URLSearchParams({ sessionId }).toString()
socket = new WebSocket(url.toString())
socket.onmessage = (event) => {
if (typeof event.data !== 'string') return
try {
const list = JSON.parse(event.data) as Array<{ uuid: string; title: string; command: string; exited: boolean }>
if (!Array.isArray(list)) return
store.reduce(s => ctx.betterSidebar?.isTabEnabled('terminal') === false
? s
: reconcileAgentTerminals(s, list))
} catch {
// Malformed push: ignore (the next push will reconcile).
}
}
socket.onclose = () => {
if (closed) return
failures += 1
if (failures >= FAILURE_LIMIT) {
console.error('[dsh-better-sidebar] agent-terminals connection failed; stopping reconnect loop', sessionId)
return
}
retry = window.setTimeout(connect, 2000)
}
socket.onerror = () => { socket?.close() }
}
connect()
return () => {
closed = true
window.clearTimeout(retry)
socket?.close()
}
}, [sessionId, store])
/**
* Subagent auto-activation: the moment the current conversation spawns its
* FIRST direct subagent (a 0 → N transition on the list feed) and the
* "auto open" pref is on, open the panel (if collapsed) and focus the
* Subagent page (single-instance: an existing tab is focused, never
* duplicated). Switching to a session that already has subagents never
* triggers — its baseline starts at the current count — so a deliberate
* layout is never fought.
* FIRST direct subagent (a 0 → N transition on the list feed), the "auto
* open" pref is on, and the Subagent tab type is enabled in settings,
* open the panel (if collapsed) and focus the Subagent page
* (single-instance: an existing tab is focused, never duplicated).
* Switching to a session that already has subagents never triggers — its
* baseline starts at the current count — so a deliberate layout is never
* fought.
*/
const listBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
useEffect(() => {
@@ -153,12 +192,10 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
if (sessionId === undefined || prev === undefined) return
if (!detectNewDirectSubagent(prev, sessionList, sessionId)) return
if (!store.getPrefs().autoOpenSubagent) return
store.reduce(s => openTab(s.panelOpen ? s : togglePanel(s), {
id: 'subagent',
type: 'subagent',
title: t('subagent'),
}))
}, [sessionList, sessionId, store])
if (ctx.betterSidebar?.isTabEnabled('subagent') === false) return
store.reduce(s => s.panelOpen ? s : togglePanel(s))
ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
}, [sessionList, sessionId, store, ctx])
/**
* Topology jump-back: clicking a subagent node on the Subagent page calls
@@ -176,12 +213,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
const pending = subagentJumpRef.current
if (pending === undefined || sessionId !== pending) return
subagentJumpRef.current = undefined
store.reduce(s => openTab(s.panelOpen ? s : togglePanel(s), {
id: 'subagent',
type: 'subagent',
title: t('subagent'),
}))
}, [sessionId, store])
store.reduce(s => s.panelOpen ? s : togglePanel(s))
ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
}, [sessionId, store, ctx])
// Panel width drag (left edge strip).
const widthDrag = useRef({ startX: 0, startWidth: 0 })
@@ -212,12 +246,21 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// A closed terminal releases its pty immediately — including when its
// socket is mid-reconnect, where the unmount close frame never reaches
// the host and the process would hold the quota until the grace ends.
// Agent terminals (tabId `agent:<uuid>`) close through a different
// host route: the WS close frame is the primary path (sent by
// TerminalView on unmount), and the agent-pty.close HTTP route is the
// fallback when the WS is down.
const current = store.getSnapshot().state
const leaf = current === undefined ? undefined : leafWithTab(current.splits, tabId)
const tab = leaf?.tabs.find(candidate => candidate.id === tabId)
store.reduce(s => closeTab(s, paneId, tabId))
if (sessionId !== undefined && tab?.type === 'terminal') {
void api.ptyClose({ sessionId, cwd }, tabId).catch(() => { /* the host may already have released it */ })
if (tab?.type === 'terminal') {
if (isAgentTabId(tabId)) {
const uuid = agentUuidOf(tabId)
void api.agentPtyClose(uuid).catch(() => { /* the host may already have released it */ })
} else if (sessionId !== undefined) {
void api.ptyClose({ sessionId, cwd }, tabId).catch(() => { /* the host may already have released it */ })
}
}
},
activateTab: (paneId, tabId) => {
@@ -286,28 +329,11 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
}
const onNewTab = (optionId: string): void => {
store.reduce((s) => {
if (optionId === 'explorer') {
return openTab(s, { id: 'explorer', type: 'explorer', title: t('explorer') })
}
if (optionId === 'git') {
return openTab(s, { id: 'git', type: 'git', title: t('git') })
}
if (optionId === 'subagent') {
return openTab(s, { id: 'subagent', type: 'subagent', title: t('subagent') })
}
if (optionId === 'terminal') {
const count = allLeaves(s.splits).flatMap(leaf => leaf.tabs).filter(tab => tab.type === 'terminal').length
if (count >= TERMINAL_LIMIT) return s
const tab: SidebarTab = {
id: `terminal:${s.nextTerminal}`,
type: 'terminal',
title: `${t('terminal')} ${s.nextTerminal}`,
}
return { ...openTab(s, tab), nextTerminal: s.nextTerminal + 1 }
}
return s
})
const service = ctx.betterSidebar
const descriptor = service?.getTab(optionId)
if (descriptor === undefined) return
const title = typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title
service.openTab({ type: optionId, title })
}
/**
@@ -320,9 +346,10 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
* Render one tab's content. `active` (from the workbench) tells whether
* this tab is the active one in its pane; combined with the panel's
* open/closed state it gates live views (the Subagent topology pauses its
* polling while the page is not actually visible).
* polling while the page is not actually visible). The pane id travels
* with the tab so diff tabs can split below their source pane.
*/
const renderTab = (tab: SidebarTab, active: boolean) => (
const renderTab = (tab: SidebarTab, active: boolean, paneId: string) => (
<TabContent
tab={tab}
sessionId={sessionId}
@@ -334,6 +361,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
store={store}
visible={state.panelOpen && active}
onSubagentJump={(childSessionId) => { subagentJumpRef.current = childSessionId }}
onOpenDiff={(diffTab) => { store.reduce(s => openDiffTab(s, paneId, diffTab)) }}
/>
)
@@ -413,10 +441,15 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
<div className={css.panelBody}>
<Workbench
state={state}
newTabOptions={buildNewTabOptions(state)}
newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
actions={actions}
onNewTab={onNewTab}
renderTab={renderTab}
getTabIcon={(tab) => {
const descriptor = ctx.betterSidebar?.getTab(tab.type)
if (descriptor === undefined) return null
return typeof descriptor.icon === 'function' ? descriptor.icon(14) : descriptor.icon
}}
/>
</div>
</div>
+4 -3
View File
@@ -11,7 +11,6 @@ import {
IconCloseFill14, IconPlusOutline16, Menu,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarTab } from './state.ts'
import { tabTypeIcon } from './icons.tsx'
import { t } from './locales.ts'
import css from './sidebar.module.css'
@@ -62,9 +61,11 @@ export function TabBar(props: {
newTabOptions: NewTabOption[]
/** Drop of a tab from any pane: (payload, insertBeforeTabId | null). */
onDropTab: (payload: TabDragPayload, before: string | null) => void
/** Icon resolver for tab labels (reads from the tab descriptor registry). */
getTabIcon?: (tab: SidebarTab) => ReactNode
}) {
const {
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab,
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, getTabIcon,
} = props
const [menuOpen, setMenuOpen] = useState(false)
const [dragOver, setDragOver] = useState(false)
@@ -134,7 +135,7 @@ export function TabBar(props: {
}
}}
>
{tabTypeIcon(tab.type, 14)}
{getTabIcon?.(tab) ?? null}
<span className={css.tabTitle}>{tab.title}</span>
<button
type="button"
+28 -7
View File
@@ -7,6 +7,17 @@
* a failed pty spawn) stops the loop and shows the reason with a manual
* retry, and repeated unreasoned failures surface the close code after three
* attempts, so the banner never spins forever.
*
* Two attach modes share one upgrade endpoint:
* - `tabId` starting with `agent:` is an agent-owned terminal (created by
* the `terminal_create` tool). The uuid is the suffix after `agent:`; the
* view connects with `?uuid=...`. A close frame kills the pty (the agent's
* terminal closes when the user closes the tab); a bare socket drop
* leaves the pty alive (the agent owns the lifetime).
* - Any other `tabId` is a UI-tab terminal (the user created it from the +
* menu). The view connects with `?tab=...&sessionId=...&cwd=...`. A close
* frame schedules a 0-ms close; a bare socket drop gets the host's
* reconnect grace.
*/
import { useEffect, useRef, useState } from 'react'
import { Terminal, type ITheme } from 'xterm'
@@ -14,7 +25,7 @@ import { FitAddon } from '@xterm/addon-fit'
import 'xterm/css/xterm.css'
import { t } from './locales.ts'
import type { SessionScope } from './api.ts'
import type { SidebarStore } from './state.ts'
import { agentUuidOf, isAgentTabId, type SidebarStore } from './state.ts'
import { isDarkScheme, subscribeColorScheme, tokenValue } from './theme.ts'
import css from './sidebar.module.css'
@@ -97,14 +108,21 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
let failures = 0
const wsUrl = (): string => {
const params = new URLSearchParams({ sessionId: scope.sessionId, tab: tabId })
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
const url = new URL('/sidebar/ws/terminal', location.origin)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
// Agent terminals attach by uuid (the host looks them up in the agent
// pty registry); UI-tab terminals attach by sessionId+tab (the host
// uses the UI-tab pty manager). Same upgrade endpoint, different query.
if (isAgentTabId(tabId)) {
url.search = new URLSearchParams({ uuid: agentUuidOf(tabId) }).toString()
} else {
const params = new URLSearchParams({ sessionId: scope.sessionId, tab: tabId })
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
url.search = params.toString()
}
// Same construction the app's own downlink WebSockets use (new URL
// over location.origin + protocol swap): whatever the environment
// does to the app's websockets applies identically here.
const url = new URL('/sidebar/ws/terminal', location.origin)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
url.search = params.toString()
return url.toString()
}
@@ -175,11 +193,14 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
schemeSub()
inputSub.dispose()
// The close frame tells the host the owning tab is GONE (immediate
// quota release). A bare unmount — conversation switch, re-render,
// pty release). A bare unmount — conversation switch, re-render,
// page unload — leaves the tab open, so the socket drop alone hands
// the process to the host's reconnect grace: switching back or
// refreshing reattaches the SAME shell instead of respawning one.
// (The host respawns on its own when the authoritative cwd changed.)
// Agent terminals follow the same rule: a close frame kills the pty
// (the user closed the sidebar tab); a bare socket drop leaves it
// alive (the agent owns the lifetime).
if (!store.tabOpen(scope.sessionId, tabId)
&& socket !== null && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'close' }))
@@ -1,11 +1,13 @@
/**
* The file editor tab: a CodeMirror 6 editor with line wrapping and syntax
* highlighting (extension-keyed language), a dirty dot and Ctrl/Cmd+S save,
* image viewing through the media route, and a plain notice for binary
* files. Markdown files open in rendered preview with a preview/edit toggle:
* edit mode shows the source in the editor, preview renders the live draft,
* and unsaved edits survive the toggle. Reads cap at the host's 512KB bound
* (a banner marks truncation).
* The code/markdown file viewer: a CodeMirror 6 editor with line wrapping,
* syntax highlighting (extension-keyed language), a dirty dot and Ctrl/Cmd+S
* save, and a preview/edit toggle for markdown files. Registered as the
* `code` (catch-all) and `markdown` built-in viewers; the editor tab host
* fetches the content through the fsRead strategy and passes it in props,
* so this component never fetches or dispatches — it only edits.
*
* The toolbar (mode toggle / dirty dot / save / status) renders as its own
* row below the host's title bar, VSCode-style.
*/
import { useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
@@ -13,33 +15,19 @@ 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, downloadUrl, mediaUrl, type SessionScope } from './api.ts'
import { api } from './api.ts'
import { languageForPath } from './lang.ts'
import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts'
import { isDarkScheme, subscribeColorScheme } from './theme.ts'
import { t } from './locales.ts'
import { officeKindForExt } from './office-types.ts'
import { DocxView, XlsxView } from './office-view.tsx'
import { isPdfExt } from './pdf-types.ts'
import { PdfView } from './PdfView.tsx'
import { PptxView } from './PptxView.tsx'
import { isImageExt } from './image-types.ts'
import type { FileViewerProps } from './service.ts'
import css from './sidebar.module.css'
const MD_EXT = ['.md', '.markdown']
type EditorLoad =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'ready'; kind: 'text' | 'image' | 'md' | 'docx' | 'xlsx' | 'pptx' | 'pdf'; content: string; truncated: boolean }
| { status: 'binary' } // unknown binary / OLE legacy formats → download button
/** Previewable files (rendered output vs source editing). */
type ViewMode = 'preview' | 'edit'
export function EditorView(props: { scope: SessionScope; path: string; title: string }) {
const { scope, path, title } = props
const [load, setLoad] = useState<EditorLoad>({ status: 'loading' })
export function TextEditor(props: FileViewerProps) {
const { scope, path, viewerId, content, truncated } = props
const [mode, setMode] = useState<ViewMode>('preview')
/** The editor's current text (null while clean); preview renders this. */
const [draft, setDraft] = useState<string | null>(null)
@@ -55,71 +43,29 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st
useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), [])
// A new file (tab switch) starts clean: fresh preview mode, no draft.
useEffect(() => {
let cancelled = false
setLoad({ status: 'loading' })
setMode('preview')
setDraft(null)
setDirty(false)
setSaveState('idle')
// Office preview short-circuit: the host's readText would NUL-probe every
// Office file as binary (a wasted round-trip), and .docx/.xlsx have their
// own renderers below. Detect by extension and skip fsRead entirely —
// the renderer fetches bytes through the media route itself.
const ext = extOfPath(path)
// Images are binary by nature; short-circuit before fsRead's NUL probe or
// every PNG/JPEG would be classified as an unsupported binary file.
if (isImageExt(ext)) {
setLoad({ status: 'ready', kind: 'image', content: '', truncated: false })
return
}
if (isPdfExt(ext)) {
setLoad({ status: 'ready', kind: 'pdf', content: '', truncated: false })
return
}
const officeKind = officeKindForExt(ext)
if (officeKind === 'docx' || officeKind === 'xlsx' || officeKind === 'pptx') {
setLoad({ status: 'ready', kind: officeKind, content: '', truncated: false })
return
}
if (officeKind === 'download-only') {
setLoad({ status: 'binary' })
return
}
api.fsRead(scope, path).then((result) => {
if (cancelled) return
if (result.kind === 'binary') {
setLoad({ status: 'binary' })
return
}
setLoad({
status: 'ready',
kind: MD_EXT.includes(ext) ? 'md' : 'text',
content: result.content,
truncated: result.truncated,
})
}).catch((error: unknown) => {
if (cancelled) return
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
})
return () => { cancelled = true }
}, [scope.sessionId, scope.cwd, path])
}, [content])
// Create the CodeMirror editor once the file is loaded. The view owns the
// document; React only tracks dirty/draft state through the update
// Create the CodeMirror editor once the content is loaded. The view owns
// the document; React only tracks dirty/draft state through the update
// listener. For markdown the view stays mounted while previewing (hidden),
// so unsaved edits survive the preview/edit toggle. The theme + syntax
// colors live in a compartment so a scheme flip reconfigures only that
// part — the document, undo history and scroll position survive.
useEffect(() => {
if (load.status !== 'ready' || (load.kind !== 'text' && load.kind !== 'md')) return
if (content === undefined) return
const host = hostRef.current
if (host === null) return
const language = languageForPath(path)
const themeComp = new CmThemeCompartment()
themeCompRef.current = themeComp
const state = EditorState.create({
doc: load.content,
doc: content,
extensions: [
CodeMirrorView.lineWrapping,
lineNumbers(),
@@ -156,7 +102,7 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st
// The keymap's save() reads live refs; scope/path are stable for a
// tab's lifetime, and the dark flip is handled by the reconfigure
// effect below (recreating the view here would drop the draft).
}, [load])
}, [content, path])
// Scheme flip: re-theme in place (the compartment holds only the
// scheme-dependent extensions; everything else is untouched).
@@ -189,15 +135,14 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st
})
}
const previewable = load.status === 'ready' && load.kind === 'md'
const editable = load.status === 'ready' && (load.kind === 'text' || load.kind === 'md')
const markdown = viewerId === 'markdown'
const editable = content !== undefined
const saveLabel = saveState === 'saving' ? t('loading') : saveState === 'saved' ? t('saved') : saveState === 'failed' ? t('saveFailed') : ''
return (
<div className={css.editor}>
<>
<div className={css.editorHeader}>
<span className={css.editorTitle} title={path}>{title}</span>
{previewable && (
{markdown && (
<div className={css.editorModeToggle}>
<button
type="button"
@@ -229,55 +174,20 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st
)}
{saveLabel !== '' && <span className={clsx(css.editorStatus, saveState === 'failed' && css.editorStatusError)}>{saveLabel}</span>}
</div>
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
{load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
{load.status === 'binary' && (
<div className={css.editorBinary}>
<span className={css.editorBinaryNotice}>{t('binaryNoPreview')}</span>
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
{t('downloadToView')}
</a>
</div>
)}
{editable && (
<>
{load.truncated && mode === 'edit' && <div className={css.editorBanner}>{t('truncation')}</div>}
{truncated === true && mode === 'edit' && <div className={css.editorBanner}>{t('truncation')}</div>}
<div
className={clsx(css.editorCm, previewable && mode === 'preview' && css.editorCmHidden)}
className={clsx(css.editorCm, markdown && mode === 'preview' && css.editorCmHidden)}
ref={hostRef}
/>
</>
)}
{load.status === 'ready' && load.kind === 'image' && (
<div className={css.editorImageWrap}>
<img className={css.editorImage} src={mediaUrl(scope, path)} alt={title} />
</div>
)}
{load.status === 'ready' && load.kind === 'docx' && (
<DocxView scope={scope} path={path} title={title} />
)}
{load.status === 'ready' && load.kind === 'xlsx' && (
<XlsxView scope={scope} path={path} title={title} />
)}
{load.status === 'ready' && load.kind === 'pptx' && (
<PptxView scope={scope} path={path} title={title} />
)}
{load.status === 'ready' && load.kind === 'pdf' && (
<PdfView scope={scope} path={path} title={title} />
)}
{previewable && mode === 'preview' && (
{markdown && mode === 'preview' && (
<div className={css.editorMd}>
<MarkdownText text={draft ?? load.content} />
<MarkdownText text={draft ?? content ?? ''} />
</div>
)}
</div>
</>
)
}
/** The lowercased file extension of a path ('' when none). */
function extOfPath(path: string): string {
const at = path.lastIndexOf('.')
if (at === -1) return ''
const base = path.slice(at).toLowerCase()
return base.includes('/') || base.includes('\\') ? '' : base
}
+30 -6
View File
@@ -40,16 +40,23 @@ export interface GitStatusResult {
/** One git log row. */
export interface GitLogEntry {
/** Short hash (7+ chars, display). */
hash: string
/** Full 40-char hash (advanced operations). */
hashFull: string
subject: string
author: string
/** ISO 8601 author date (`%ai`). */
date: string
/** Ref decorations (--decorate=short), e.g. `HEAD -> main, origin/main`; '' when none. */
refs: string
}
/** Text read result. */
export interface FsTextResult { kind: 'text'; content: string; truncated: boolean }
/** Binary read result (no content; images load through the media route). */
export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean }
/** Binary read result (no content; images load through the media route).
* `head` carries the first bytes (base64) for viewer detect sniffing. */
export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean; head: string }
async function call<T>(method: string, payload: Record<string, unknown>, signal?: AbortSignal): Promise<T> {
let response: Response
@@ -100,8 +107,6 @@ export const api = {
call<GitStatusResult>('git.status', scopePayload(scope, {}), signal),
gitDiff: (scope: SessionScope, path: string | undefined, staged: boolean, signal?: AbortSignal) =>
call<{ diff: string }>('git.diff', scopePayload(scope, { ...(path !== undefined ? { path } : {}), staged }), signal),
gitShow: (scope: SessionScope, rev: string, path: string, signal?: AbortSignal) =>
call<{ content: string | null }>('git.show', scopePayload(scope, { rev, path }), signal),
gitStage: (scope: SessionScope, path?: string) =>
call<{ ok: true }>('git.stage', scopePayload(scope, { ...(path !== undefined ? { path } : {}) })),
gitUnstage: (scope: SessionScope, path?: string) =>
@@ -112,13 +117,32 @@ export const api = {
call<{ current: string; names: string[] }>('git.branch', scopePayload(scope, {}), signal),
gitCheckout: (scope: SessionScope, branch: string) =>
call<{ ok: true }>('git.checkout', scopePayload(scope, { branch })),
gitLog: (scope: SessionScope, signal?: AbortSignal) =>
call<GitLogEntry[]>('git.log', scopePayload(scope, {}), signal),
/** Recent commit history, lazily pageable (skip/count; defaults 0/30). */
gitLog: (scope: SessionScope, count?: number, skip?: number, signal?: AbortSignal) =>
call<GitLogEntry[]>('git.log', scopePayload(scope, {
...(count !== undefined ? { count } : {}),
...(skip !== undefined ? { skip } : {}),
}), signal),
/** Full patch text of one commit (diff display for the history rows). */
gitCommitDiff: (scope: SessionScope, hash: string, signal?: AbortSignal) =>
call<{ diff: string }>('git.commit-diff', scopePayload(scope, { hash }), signal),
/** Discard the worktree changes of one file (the index is untouched). */
gitDiscard: (scope: SessionScope, path: string) =>
call<{ ok: true }>('git.discard', scopePayload(scope, { path })),
/** Revert one commit onto the current branch. */
gitRevert: (scope: SessionScope, hash: string) =>
call<{ ok: true }>('git.revert', scopePayload(scope, { hash })),
/** Cherry-pick one commit onto the current branch. */
gitCherryPick: (scope: SessionScope, hash: string) =>
call<{ ok: true }>('git.cherry-pick', scopePayload(scope, { hash })),
/** Release a terminal's process immediately (tab closed; the WS close frame
* may be unreachable while the socket is down, so the host also accepts
* this explicit route). */
ptyClose: (scope: SessionScope, tab: string) =>
call<{ ok: true }>('pty.close', scopePayload(scope, { tab })),
/** Release an agent terminal by uuid (tab closed while WS was down). */
agentPtyClose: (uuid: string) =>
call<{ ok: true }>('agent-pty.close', { uuid }),
/** Read the side card preferences (plugin-global, no session scope). */
settingsGet: () =>
call<{ value?: unknown; revision?: number }>('settings.get', {}),
+23
View File
@@ -0,0 +1,23 @@
/**
* The "no preview — download instead" pane: shown by the editor host when no
* viewer can render a file (binary without a registered renderer, or the
* `binary-download` strategy) and registered as the `binary-download` viewer
* component so the declarative route and the host fallback share one UI.
*/
import type { ReactNode } from 'react'
import type { SessionScope } from './api.ts'
import { downloadUrl } from './api.ts'
import { t } from './locales.ts'
import css from './sidebar.module.css'
export function BinaryDownload(props: { scope: SessionScope; path: string }): ReactNode {
const { scope, path } = props
return (
<div className={css.editorBinary}>
<span className={css.editorBinaryNotice}>{t('binaryNoPreview')}</span>
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
{t('downloadToView')}
</a>
</div>
)
}
+33
View File
@@ -0,0 +1,33 @@
/**
* Built-in registration: the plugin registers its own tab pages and file
* previewers through the same {@link BetterSidebarService} external plugins
* use — eating its own dogfood. The descriptors live next to their feature
* modules (tabs.tsx / viewers.tsx); this module only aggregates them and
* owns the disposer lifecycle (cordis auto-invokes it on fiber disposal,
* HMR-safe).
*/
import type { Context } from '../../context-types.ts'
import type { BetterSidebarService } from '../service.ts'
import { builtinTabs } from './tabs.tsx'
import { builtinViewers } from './viewers.tsx'
/**
* Register all built-in tabs and viewers with the service. Returns a
* disposer that unregisters everything (cordis auto-invokes it on fiber
* disposal). The `ctx` is threaded into tab descriptors that need it
* (EditorHost reads `ctx.betterSidebar` for file-viewer matching).
*/
export function registerBuiltins(ctx: Context, service: BetterSidebarService): () => void {
const disposers: (() => void)[] = []
for (const tab of builtinTabs(ctx)) {
disposers.push(service.registerTab(tab))
}
for (const viewer of builtinViewers()) {
disposers.push(service.registerFileViewer(viewer))
}
return () => {
for (const d of disposers) {
try { d() } catch { /* already disposed */ }
}
}
}
+146
View File
@@ -0,0 +1,146 @@
/**
* 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`.
*/
import { IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../../context-types.ts'
import { allLeaves, isAgentTabId, type SidebarState } from '../state.ts'
import { t } from '../locales.ts'
import { openSidebarFile } from '../intercept.tsx'
import { ExplorerView } from '../ExplorerView.tsx'
import { EditorHost } from '../EditorHost.tsx'
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 type { TabDescriptor } from '../service.ts'
/** How many UI-owned terminals may be open at once (agent-owned ones are uncapped). */
export const TERMINAL_LIMIT = 3
/** Count UI-owned terminals (agent:` tabs excluded — they are the model's). */
function uiTerminalCount(state: SidebarState): number {
return allLeaves(state.splits)
.flatMap(leaf => leaf.tabs)
.filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length
}
/** The 6 built-in tab descriptors. */
export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
return [
{
id: 'editor',
title: () => t('editor'),
icon: (size: number) => <IconCodeOutline16 size={size} />,
order: -1,
hidden: true,
dedupeKey: (tab) => tab.path,
component: ({ ctx, store, scope, tab }) => (
<EditorHost ctx={ctx} store={store} scope={scope} path={tab.path ?? ''} title={tab.title} />
),
},
{
id: 'explorer',
title: () => t('explorer'),
icon: (size: number) => <IconFolderOpen16 size={size} />,
order: 10,
single: true,
component: ({ ctx, store, scope, expanded, onToggleDir, onReferenceFile }) => (
<ExplorerView
sessionId={scope.sessionId}
cwd={scope.cwd}
expanded={expanded ?? []}
onToggle={onToggleDir ?? (() => { /* no-op */ })}
onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }}
onReferenceFile={onReferenceFile ?? (() => { /* no-op */ })}
/>
),
},
{
id: 'git',
title: () => t('git'),
icon: (size: number) => <IconBranchOutline16 size={size} />,
order: 20,
single: true,
component: ({ ctx, store, scope, onOpenDiff }) => (
<GitView
scope={scope}
onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }}
onOpenDiff={onOpenDiff ?? (() => { /* no-op */ })}
/>
),
},
{
id: 'subagent',
title: () => t('subagent'),
icon: (size: number) => <IconThinkOutline16 size={size} />,
order: 30,
single: true,
// Declarative settings: the auto-open switch renders under this row in
// the Side card settings page (the Subagent page's own related setting).
settings: {
toggles: [{
key: 'autoOpenSubagent',
title: () => t('settingsSubagentTitle'),
desc: () => t('settingsSubagentDesc'),
}],
},
component: ({ ctx, scope, visible, onSubagentJump }) => (
<SubagentView
sessionId={scope.sessionId}
ctx={ctx}
active={visible}
onOpenChild={(address) => { onSubagentJump?.(address.childSessionId) }}
/>
),
},
{
id: 'terminal',
title: () => t('terminal'),
icon: (size: number) => <IconTerminalOutline16 size={size} />,
order: 40,
available: (_ctx, _scope, state) => uiTerminalCount(state) < TERMINAL_LIMIT,
// Declarative settings: the model-facing terminal tools switch renders
// under this row in the Side card settings page (the Terminal page's
// own related setting; the host gates the toolset on it independently).
settings: {
toggles: [{
key: 'agentTerminalTools',
title: () => t('settingsToolsTitle'),
desc: () => t('settingsToolsDesc'),
}],
},
createTab: (state) => {
const count = uiTerminalCount(state)
if (count >= TERMINAL_LIMIT) return null
return {
tab: {
id: `terminal:${state.nextTerminal}`,
type: 'terminal',
title: `${t('terminal')} ${state.nextTerminal}`,
},
patch: { nextTerminal: state.nextTerminal + 1 },
}
},
component: ({ scope, tab, store }) => (
<TerminalView scope={scope} tabId={tab.id} store={store} />
),
},
{
id: 'diff',
title: () => t('git'),
icon: (size: number) => <IconDiffOutline16 size={size} />,
order: -1,
hidden: true,
dedupeKey: (tab) => tab.id,
component: ({ scope, tab }) => (
tab.diff === undefined ? null
: <DiffTab sessionId={scope.sessionId} cwd={scope.cwd} diff={tab.diff} />
),
},
]
}
+116
View File
@@ -0,0 +1,116 @@
/**
* 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
* binaries and serves doc/xls/ppt by extension; `code` is the catch-all
* (`exts: []`, lowest priority) that claims any file no other viewer did.
*
* Every viewer carries the declarative settings-surface fields — `title`
* and `icon` — so the Side card settings page can render the enable/disable
* inventory without hardcoding (eating our own dogfood).
*/
import { IconCodeOutline16, IconDownloadOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { DocxView, XlsxView } from '../office-view.tsx'
import { PdfView } from '../PdfView.tsx'
import { PptxView } from '../PptxView.tsx'
import { TextEditor } from '../TextEditor.tsx'
import { BinaryDownload } from '../binary-download.tsx'
import {
IconImageOutline16,
IconMarkdownOutline16,
IconPdfOutline16,
IconDocxOutline16,
IconXlsxOutline16,
IconPptxOutline16,
} 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. */
export function builtinViewers(): readonly FileViewerDescriptor[] {
return [
{
id: 'image',
title: () => t('viewerImage'),
icon: (size: number) => <IconImageOutline16 size={size} />,
exts: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif'],
fetchStrategy: 'mediaUrl',
component: ({ mediaUrl: url, title }) => (
<div className={css.editorImageWrap}>
<img className={css.editorImage} src={url} alt={title} />
</div>
),
},
{
id: 'pdf',
title: () => t('viewerPdf'),
icon: (size: number) => <IconPdfOutline16 size={size} />,
exts: ['pdf'],
fetchStrategy: 'mediaUrl',
component: ({ scope, path, title }) => (
<PdfView scope={scope} path={path} title={title} />
),
},
{
id: 'docx',
title: () => t('viewerDocx'),
icon: (size: number) => <IconDocxOutline16 size={size} />,
exts: ['docx'],
fetchStrategy: 'mediaUrl',
component: ({ scope, path, title }) => (
<DocxView scope={scope} path={path} title={title} />
),
},
{
id: 'xlsx',
title: () => t('viewerXlsx'),
icon: (size: number) => <IconXlsxOutline16 size={size} />,
exts: ['xlsx'],
fetchStrategy: 'mediaUrl',
component: ({ scope, path, title }) => (
<XlsxView scope={scope} path={path} title={title} />
),
},
{
id: 'pptx',
title: () => t('viewerPptx'),
icon: (size: number) => <IconPptxOutline16 size={size} />,
exts: ['pptx'],
fetchStrategy: 'mediaUrl',
component: ({ scope, path, title }) => (
<PptxView scope={scope} path={path} title={title} />
),
},
{
id: 'markdown',
title: () => t('viewerMarkdown'),
icon: (size: number) => <IconMarkdownOutline16 size={size} />,
exts: ['md', 'markdown'],
fetchStrategy: 'fsRead',
component: (props) => <TextEditor {...props} />,
},
{
id: 'code',
title: () => t('viewerCode'),
icon: (size: number) => <IconCodeOutline16 size={size} />,
exts: [],
priority: -100,
fetchStrategy: 'fsRead',
component: (props) => <TextEditor {...props} />,
},
{
id: 'binary-download',
title: () => t('viewerBinary'),
icon: (size: number) => <IconDownloadOutline16 size={size} />,
exts: ['doc', 'xls', 'ppt'],
priority: -50,
fetchStrategy: 'binary-download',
// NUL probe: a file whose head bytes contain a NUL is binary — claimed
// before the catch-all code viewer on the head re-match.
detect: (_path, head) => head.includes(0),
component: ({ scope, path }) => <BinaryDownload scope={scope} path={path} />,
},
]
}
+1 -1
View File
@@ -5,7 +5,7 @@
* values, and those come from the same designed palettes the app's code
* surfaces use — the one-dark family for dark, the one-light family for
* light. The scheme flip reconfigures these via a compartment (see
* EditorView), so the document, undo history and scroll survive re-theming.
* TextEditor), so the document, undo history and scroll survive re-theming.
*/
import { Compartment } from '@codemirror/state'
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
+92
View File
@@ -0,0 +1,92 @@
/**
* Pure editor-load planning: the decision logic the editor host needs to
* turn a matched file viewer + a host fs.read result into a render action.
* Kept dependency-free (no React, no fetch) so the strategy dispatch is
* unit-testable and the wire contract (head bytes, binary flag) is pinned.
*
* The host flow this module drives:
* 1. `matchFileViewer(path)` picks a viewer by extension/priority.
* 2. `planFirstMatch` dispatches its fetchStrategy.
* 3. An fsRead viewer fetches through the host; `planFsReadOutcome`
* decides what to do with the result — including the head-based
* re-match that lets a `detect` viewer claim a binary the extension
* match could not see (the builtin NUL probe on `binary-download`).
*/
import type { FileViewerDescriptor } from './service.ts'
/** One host fs.read result (mirror of the wire; `head` present when binary). */
export interface FsReadOutcome {
binary: boolean
content: string
truncated: boolean
/** base64 of the first bytes (present on binary reads; sniffing material). */
head?: string
}
/** What the editor host should do next. */
export type EditorLoadAction =
/** No renderer: show the download UI. */
| { kind: 'binary' }
/** Render `viewer`'s component with the carried payload. */
| { kind: 'render'; viewer: FileViewerDescriptor; content?: string; truncated?: boolean; mediaUrl?: string; customData?: unknown }
/** Fetch the file through the host (fsRead strategy). */
| { kind: 'fetchFsRead'; viewer: FileViewerDescriptor }
/** Call the viewer's load() and render with its return value. */
| { kind: 'customLoad'; viewer: FileViewerDescriptor }
/** Decode the host's base64 head bytes into the sniffing buffer. */
export function decodeHead(headBase64: string): Uint8Array {
const binary = atob(headBase64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
return bytes
}
/**
* Dispatch one matched viewer's fetchStrategy. A missing viewer or a
* `binary-download` strategy both mean "no client-side renderer" → the
* download UI. `mediaUrlOf` builds the media URL for `mediaUrl`/`none`
* strategies (pure, but scope-bound — injected by the host).
*/
export function planFirstMatch(
viewer: FileViewerDescriptor | undefined,
mediaUrlOf: () => string,
): EditorLoadAction {
if (viewer === undefined || viewer.fetchStrategy === 'binary-download') return { kind: 'binary' }
switch (viewer.fetchStrategy) {
case 'mediaUrl':
case 'none':
return { kind: 'render', viewer, mediaUrl: mediaUrlOf() }
case 'custom':
return { kind: 'customLoad', viewer }
case 'fsRead':
return { kind: 'fetchFsRead', viewer }
}
}
/**
* Decide what an fsRead result means for the editor.
* - Text: the first match stands (content is valid for any fsRead viewer).
* - Binary: the host head bytes enable a re-match — a `detect` viewer (e.g.
* a plugin sniffing a binary format) may claim the file. `custom` viewers
* load their own bytes; `mediaUrl`/`none` viewers render the media route;
* an fsRead viewer or nothing cannot render binary → download UI.
*/
export function planFsReadOutcome(
viewer: FileViewerDescriptor,
result: FsReadOutcome,
rematch: (head: Uint8Array) => FileViewerDescriptor | undefined,
mediaUrlOf: () => string,
): EditorLoadAction {
if (!result.binary) {
return { kind: 'render', viewer, content: result.content, truncated: result.truncated }
}
const claimed = result.head === undefined ? undefined : rematch(decodeHead(result.head))
if (claimed !== undefined && claimed.fetchStrategy === 'custom') {
return { kind: 'customLoad', viewer: claimed }
}
if (claimed !== undefined && (claimed.fetchStrategy === 'mediaUrl' || claimed.fetchStrategy === 'none')) {
return { kind: 'render', viewer: claimed, mediaUrl: mediaUrlOf() }
}
return { kind: 'binary' }
}
+68 -17
View File
@@ -1,14 +1,10 @@
/**
* Icons the sidebar needs beyond the primitives set: a terminal glyph (the
* icon library has none) and the per-tab-type icon mapping shared by the
* tab strip, the + menu, and the empty-pane cards.
* icon library has none) and a diff glyph. Per-tab icons live on the tab
* descriptors (`descriptor.icon`), not in a type-keyed switch — the icon
* mapping was registry-ized with the tab types.
*/
import type { ReactNode } from 'react'
import {
IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconThinkOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TabType } from './state.ts'
/**
* Terminal glyph in the app's outline style (1.5px stroke, currentColor):
@@ -22,13 +18,68 @@ export const IconTerminalOutline16 = ({ size = 16, className }: IconProps) => (
</svg>
)
/** The one icon per tab type (shared by tabs, the + menu, and pane cards). */
export function tabTypeIcon(type: TabType, size = 16): ReactNode {
switch (type) {
case 'explorer': return <IconFolderOpen16 size={size} />
case 'git': return <IconBranchOutline16 size={size} />
case 'terminal': return <IconTerminalOutline16 size={size} />
case 'subagent': return <IconThinkOutline16 size={size} />
case 'editor': return <IconCodeOutline16 size={size} />
}
}
/** Diff glyph in the app's outline style: a file frame with a plus and a minus row. */
export const IconDiffOutline16 = ({ 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">
<rect x="1.5" y="1.5" width="13" height="13" rx="2.5" stroke="currentColor" strokeWidth="1.5" />
<path d="M4 5h3M5.5 3.5v3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M9.5 12.5h2.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
// ── File-viewer inventory glyphs (Side card settings page) ────────────────
/** Image viewer glyph: a picture frame with a sun and a mountain. */
export const IconImageOutline16 = ({ 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">
<rect x="1.5" y="2.5" width="13" height="11" rx="2" stroke="currentColor" strokeWidth="1.5" />
<circle cx="5.5" cy="6" r="1.2" stroke="currentColor" strokeWidth="1.5" />
<path d="m3.5 12 3-3 2.25 2.25L11.5 8.5 13 10.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
/** PDF viewer glyph: a document frame with the "PDF" label. */
export const IconPdfOutline16 = ({ 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 13.5v-3h1.4c.75 0 1.1.32 1.1.85 0 .54-.35.85-1.1.85H5.3" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" />
<path d="M8.3 13.5v-3h1.05c.8 0 1.35.5 1.35 1.5s-.55 1.5-1.35 1.5z" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" />
<path d="M11.6 13.5v-3h1.3" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
</svg>
)
/** Word viewer glyph: a document frame with a "W". */
export const IconDocxOutline16 = ({ 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="M6.2 13.4 7.4 10l1.2 3.4M7.4 10.6l-.35-1.1c-.2-.62.2-1.25.85-1.25h.2c.65 0 1.05.63.85 1.25l-.35 1.1" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" />
<path d="M8.75 10.6 9.2 9.4" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
</svg>
)
/** Excel viewer glyph: a spreadsheet grid. */
export const IconXlsxOutline16 = ({ 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">
<rect x="1.5" y="2" width="13" height="12" rx="2" stroke="currentColor" strokeWidth="1.5" />
<path d="M1.5 6h13M1.5 9.5h13M6 6v8M10.5 6v8" stroke="currentColor" strokeWidth="1.25" />
<path d="m3.8 13.2 2-3M5.8 13.2l-2-3" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
</svg>
)
/** PowerPoint viewer glyph: a chart with rising bars. */
export const IconPptxOutline16 = ({ 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">
<rect x="1.5" y="2.5" width="13" height="11" rx="2" stroke="currentColor" strokeWidth="1.5" />
<path d="M4 12.5v-3M7 12.5V7M10 12.5V4.5M13 12.5v-1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
/** Markdown viewer glyph: the classic "M with a down arrow" badge. */
export const IconMarkdownOutline16 = ({ 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">
<rect x="1.5" y="2.5" width="13" height="11" rx="2" stroke="currentColor" strokeWidth="1.5" />
<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>
)
+18 -2
View File
@@ -12,6 +12,8 @@ import { Component, createElement, type ErrorInfo, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import type { Context } from '../context-types.ts'
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 { loadPrefs } from './prefs.ts'
@@ -69,6 +71,19 @@ export function apply(ctx: Context): void {
// registrations (the official createXXXStore() factory rule — no
// module-level singleton).
const sidebarStore = createSidebarStore()
// The sidebar registry service: external plugins register tab types and
// file previewers through `ctx.betterSidebar.registerTab/registerFileViewer`.
// Published before the panel mounts so consumers injecting 'betterSidebar'
// are ready by the time the sidebar renders.
const service = createBetterSidebarService(sidebarStore)
ctx.provide('betterSidebar', service)
// Register the plugin's own built-in tabs and viewers through the same
// service (eating our own dogfood). The disposer unregisters them on
// fiber disposal (HMR-safe).
ctx.effect(
() => registerBuiltins(ctx, service),
'dsh-better-sidebar: register built-in tabs and viewers',
)
// A failure anywhere in the client lifecycle must never take the app down
// silently: log with the plugin prefix and pin a visible diagnostic strip
// to the page so a blank panel is never the only symptom.
@@ -134,13 +149,14 @@ export function apply(ctx: Context): void {
// 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
// fenced settings route and keeps the shared store in sync.
// fenced settings route, keeps the shared store in sync, and renders the
// declarative enable/disable inventory from the tab/viewer registry.
ctx.slots.inject('settings.section', () => ctx.slots.register({
name: 'settings.section',
id: 'better-sidebar',
order: 100,
label: () => t('settingsNav'),
inject: () => ({ store: sidebarStore }),
inject: () => ({ store: sidebarStore, service }),
}, SideCardSection))
} catch (error) {
fail('load', error)
+11 -8
View File
@@ -8,7 +8,7 @@
*/
import { IconCodeOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../context-types.ts'
import { openTab, type SidebarStore } from './state.ts'
import type { SidebarStore } from './state.ts'
import { t } from './locales.ts'
import { resolveSidebarPath, selectProducedFiles } from './produced-files.ts'
import css from './sidebar.module.css'
@@ -19,12 +19,9 @@ export function openSidebarFile(ctx: Context, store: SidebarStore, sessionId: st
const absolute = resolveSidebarPath(summary?.cwd, path)
const at = Math.max(absolute.lastIndexOf('/'), absolute.lastIndexOf('\\'))
const title = at === -1 ? absolute : absolute.slice(at + 1)
store.reduce(state => openTab(state, {
id: `editor:${absolute}`,
type: 'editor',
title,
path: absolute,
}))
// Route through the sidebar service so the editor descriptor's dedupeKey
// (per-path) applies; the id is path-derived so multiple editors coexist.
ctx.betterSidebar?.openTab({ type: 'editor', title, path: absolute, id: `editor:${absolute}` })
}
/** The intercepted produced-files row (visual twin of the deliverables chips). */
@@ -63,7 +60,13 @@ export function SidebarProducedFiles(props: {
export function registerTurnTailInterception(ctx: Context, store: SidebarStore): () => void {
return ctx.slots.register({
name: 'conversation.chat.turnTail',
select: selectProducedFiles,
// Decline the takeover while the editor tab type is disabled in the side
// card settings: the produced-files row falls back to the default
// deliverables behavior instead of offering chips that cannot open.
select: (owner) => {
if (store.getPrefs().tabsEnabled['editor'] === false) return null
return selectProducedFiles(owner)
},
priority: -1,
registrant: 'dsh-better-sidebar',
inject: (sessionId: string) => ({
+102
View File
@@ -54,11 +54,40 @@ const zh = {
changes: '变更',
staged: '已暂存',
unstaged: '未暂存',
cancel: '取消',
diffEmpty: '没有文本差异',
diffLoadError: '加载差异失败',
diffBinary: '二进制',
diffAdded: '新增',
diffDeleted: '删除',
diffRenamed: '重命名',
diffExpand: '展开其余 {count} 行',
diffCollapse: '收起',
discard: '放弃更改',
discardTitle: '放弃更改',
discardDesc: '将丢弃「{path}」的工作区修改(不可恢复)。',
viewCommitDiff: '查看提交差异',
copyShortHash: '复制短哈希',
copyFullHash: '复制完整哈希',
copySubject: '复制提交信息',
revertCommit: '还原此提交',
revertTitle: '还原此提交',
revertDesc: '将在当前分支创建一个反转「{subject}」的新提交。',
cherryPickCommit: '捡取此提交',
cherryPickTitle: '捡取此提交',
cherryPickDesc: '将「{subject}」的更改应用到当前分支。',
timeJustNow: '刚刚',
timeMinutesAgo: '{n} 分钟前',
timeHoursAgo: '{n} 小时前',
timeYesterday: '昨天',
loadMore: '加载更多',
historyLoadError: '加载更多历史失败',
produced: '本次产出',
producedOpen: '在侧边栏中打开',
disconnected: '终端连接断开,重连中…',
exited: '终端进程已退出',
noSession: '选择一个会话以使用侧边栏',
pluginNotLoaded: '插件未加载,标签页暂不可用:',
hiddenFiles: '隐藏文件',
parent: '上级目录',
copied: '已复制',
@@ -90,6 +119,21 @@ const zh = {
zoomHint: 'Alt + 滚轮',
settingsSubagentTitle: '检测到子代理时自动展开子代理页面',
settingsSubagentDesc: '当前会话产生新的子代理时,自动展开侧边栏并打开子代理页面;关闭后需手动打开',
settingsToolsTitle: '为模型注入终端工具',
settingsToolsDesc: '开启后,模型可通过 terminal_create 等 8 个工具创建并操作侧边栏终端(默认关闭)',
settingsTabsTitle: '侧边栏内容',
settingsViewersTitle: '文件预览',
settingsGeneralTitle: '常规',
settingsPopup: '功能设置',
settingsViewerCatchAll: '兜底:任意文件',
viewerImage: '图片',
viewerPdf: 'PDF',
viewerDocx: 'Word 文档',
viewerXlsx: 'Excel 表格',
viewerPptx: 'PPT 演示',
viewerMarkdown: 'Markdown',
viewerCode: '代码',
viewerBinary: '二进制下载',
subagent: '子代理',
openSubagent: '子代理',
subagentMainAgent: '主代理',
@@ -156,11 +200,40 @@ const en: Record<keyof typeof zh, string> = {
changes: 'Changes',
staged: 'Staged',
unstaged: 'Unstaged',
cancel: 'Cancel',
diffEmpty: 'No text changes',
diffLoadError: 'Failed to load diff',
diffBinary: 'Binary',
diffAdded: 'Added',
diffDeleted: 'Deleted',
diffRenamed: 'Renamed',
diffExpand: 'Expand {count} more rows',
diffCollapse: 'Collapse',
discard: 'Discard changes',
discardTitle: 'Discard changes',
discardDesc: 'This discards the worktree changes of "{path}" (not recoverable).',
viewCommitDiff: 'View commit diff',
copyShortHash: 'Copy short hash',
copyFullHash: 'Copy full hash',
copySubject: 'Copy subject',
revertCommit: 'Revert commit',
revertTitle: 'Revert commit',
revertDesc: 'Create a new commit on the current branch that reverts "{subject}".',
cherryPickCommit: 'Cherry-pick commit',
cherryPickTitle: 'Cherry-pick commit',
cherryPickDesc: 'Apply the changes of "{subject}" to the current branch.',
timeJustNow: 'just now',
timeMinutesAgo: '{n} min ago',
timeHoursAgo: '{n} h ago',
timeYesterday: 'yesterday',
loadMore: 'Load more',
historyLoadError: 'Failed to load more history',
produced: 'Produced',
producedOpen: 'Open in sidebar',
disconnected: 'Terminal disconnected, reconnecting…',
exited: 'Terminal process exited',
noSession: 'Select a conversation to use the sidebar',
pluginNotLoaded: 'Plugin not loaded; tab unavailable:',
hiddenFiles: 'Hidden files',
parent: 'Parent directory',
copied: 'Copied',
@@ -192,6 +265,21 @@ const en: Record<keyof typeof zh, string> = {
zoomHint: 'Alt + wheel',
settingsSubagentTitle: 'Auto-open the Subagent page when a subagent appears',
settingsSubagentDesc: 'Expand the side card and open the Subagent page when the current conversation spawns a new subagent; turn off to open it manually',
settingsToolsTitle: 'Inject terminal tools for the model',
settingsToolsDesc: 'When enabled, the model can create and drive sidebar terminals through the 8 terminal_* tools (off by default)',
settingsTabsTitle: 'Sidebar content',
settingsViewersTitle: 'File viewers',
settingsGeneralTitle: 'General',
settingsPopup: 'Feature settings',
settingsViewerCatchAll: 'Catch-all: any file',
viewerImage: 'Image',
viewerPdf: 'PDF',
viewerDocx: 'Word',
viewerXlsx: 'Excel',
viewerPptx: 'PowerPoint',
viewerMarkdown: 'Markdown',
viewerCode: 'Code',
viewerBinary: 'Binary download',
subagent: 'Subagents',
openSubagent: 'Subagents',
subagentMainAgent: 'Main agent',
@@ -229,3 +317,17 @@ export function t(key: CopyKey, params?: Record<string, string | number>): strin
export function isZh(): boolean {
return typeof navigator !== 'undefined' && navigator.language.toLowerCase().startsWith('zh')
}
/** Format an ISO 8601 author date relative to now (刚刚 / N 分钟前 / N 小时前 / 昨天 / date). */
export function relativeTime(iso: string): string {
const then = Date.parse(iso)
if (Number.isNaN(then)) return iso
const seconds = Math.floor((Date.now() - then) / 1000)
if (seconds < 60) return t('timeJustNow')
if (seconds < 3600) return t('timeMinutesAgo', { n: Math.floor(seconds / 60) })
if (seconds < 86400) return t('timeHoursAgo', { n: Math.floor(seconds / 3600) })
if (seconds < 172800) return t('timeYesterday')
const date = new Date(then)
const pad = (value: number): string => String(value).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
+2 -2
View File
@@ -1,6 +1,6 @@
/**
* Office file type dispatch — pure functions mapping file extensions to
* preview kinds. Kept separate from {@link EditorView} so the mapping is
* preview kinds. Kept separate from the editor host so the mapping is
* unit-testable without mounting React or pulling in docx-preview / Univer.
*
* The host side never inspects Office extensions: the binary NUL probe in
@@ -36,7 +36,7 @@ export type OfficeKind = 'docx' | 'xlsx' | 'pptx' | 'download-only' | null
/**
* Map a lowercased extension (with leading dot) to its preview kind.
*
* @param ext - the path extension from {@link EditorView.extOfPath} (`.docx`,
* @param ext - the path extension (with leading dot) (`.docx`,
* `.xlsx`, `.pptx`, `.doc`, `.xls`, `.ppt`, …); `''` when the path has none.
*/
export function officeKindForExt(ext: string): OfficeKind {
+1 -1
View File
@@ -234,7 +234,7 @@ export function XlsxView(props: OfficeViewProps): JSX.Element {
/**
* The shared error / fallback affordance: the failure reason plus a download
* link, so the user always has a path to the file. Used by both DocxView and
* XlsxView, and matches the binary placeholder's download link in EditorView.
* XlsxView, and matches the binary placeholder download link.
*/
function BinaryFallback(props: { scope: SessionScope; path: string; message: string }): JSX.Element {
const { scope, path, message } = props
+20 -2
View File
@@ -21,8 +21,7 @@ export type { SidebarPrefs }
/** The settings wire face the preferences need (a subset of the plugin api). */
export type SidebarSettingsClient = Pick<typeof api, 'settingsGet' | 'settingsUpdate'>
/**
* Validate one raw resolved value into {@link SidebarPrefs}. Used for the
/** Validate one raw resolved value into {@link SidebarPrefs}. Used for the
* settings.get payload AND the settings.update response (both carry the
* layered resolved value); any malformed field falls back to its default.
* @param value - the raw resolved section from the settings wire.
@@ -41,9 +40,28 @@ export function parsePrefs(value: unknown): SidebarPrefs {
autoOpenSubagent: typeof record.autoOpenSubagent === 'boolean'
? record.autoOpenSubagent
: SIDEBAR_PREFS_DEFAULTS.autoOpenSubagent,
agentTerminalTools: typeof record.agentTerminalTools === 'boolean'
? record.agentTerminalTools
: SIDEBAR_PREFS_DEFAULTS.agentTerminalTools,
tabsEnabled: booleanMapOf(record.tabsEnabled),
viewersEnabled: booleanMapOf(record.viewersEnabled),
}
}
/**
* Validate one enable-switch map (per-tab / per-viewer). Only boolean values
* survive; a non-object or a non-boolean entry falls back to the empty map /
* drops the entry — an absent key means the feature stays enabled.
*/
function booleanMapOf(value: unknown): Record<string, boolean> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return {}
const out: Record<string, boolean> = {}
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
if (typeof item === 'boolean') out[key] = item
}
return out
}
/**
* Read the resolved side card preferences through the plugin's settings route.
* @param settings - the settings wire face (the plugin api by default).
+379
View File
@@ -0,0 +1,379 @@
/**
* The BetterSidebar client service: a registry that external plugins use
* to contribute sidebar tab types and file previewers. The service is
* published to the cordis context as `ctx.betterSidebar` (see
* {@link ../context-types.ts}); consumers declare it in `inject` and call
* `registerTab` / `registerFileViewer`, both returning a disposer that
* cordis auto-invokes on fiber disposal (HMR-safe).
*
* Design notes:
* - The registry is synchronous-snapshot (Map + listener set) so React
* can read it through `useSyncExternalStore` without tearing.
* - `dedupeKey` unifies the three open-tab strategies the builtins used to
* hardcode: single-instance (`() => type`), per-path (`tab => tab.path`),
* and per-id (`tab => tab.id` for diff tabs whose id is change-derived).
* `single: true` is sugar for `dedupeKey: () => id`.
* - `createTab` lets a descriptor own tab instantiation (the terminal
* builtin uses it to mint `terminal:<n>` ids and bump `nextTerminal`).
* - `matchFileViewer` walks descriptors in priority order (desc, stable):
* per descriptor it tries `detect` first (when `head` bytes are given),
* then `exts`; `exts: []` is a catch-all that matches any path.
*/
import type { ReactNode } from 'react'
import type { Context } from '../context-types.ts'
import {
activateTab, allLeaves, closeTab as closeTabReducer, openTabInActivePane,
type SidebarState, type SidebarStore, type SidebarTab,
} from './state.ts'
import type { SessionScope } from './api.ts'
/** One declarative boolean setting of a tab/viewer, rendered as a nested
* switch row in the Side card settings page (e.g. the Subagent page's
* "auto-open when a subagent appears"). */
export interface SidebarSettingToggle {
/** The SidebarPrefs field this toggle reads and writes ('autoOpenSubagent'). */
key: string
/** Row title (i18n friendly: string or () => string). */
title: string | (() => string)
/** Row description (i18n friendly). */
desc?: string | (() => string)
}
/** Declarative settings of one registered tab or file viewer. */
export interface SidebarSettingsDeclaration {
/**
* Extra boolean toggles rendered under the feature's own row in the
* settings page (only while the feature is enabled). Keys must be fields
* of the host's PrefsSchema (built-ins: 'autoOpenSubagent',
* 'agentTerminalTools'); unknown keys are dropped by the settings seam.
*/
toggles?: readonly SidebarSettingToggle[]
}
/** Props every tab component receives (builtins and external alike). */
export interface TabComponentProps {
ctx: Context
store: SidebarStore
scope: SessionScope
tab: SidebarTab
/** Whether this tab is the active one AND the panel is open (live views pause otherwise). */
visible: boolean
/** The explorer's expanded directory set (ExplorerView). */
expanded?: string[]
onToggleDir?: (path: string) => void
onReferenceFile?: (path: string) => void
onOpenFile?: (path: string) => void
onOpenDiff?: (tab: SidebarTab) => void
onSubagentJump?: (childSessionId: string) => void
}
/** Describes one kind of sidebar tab (builtins register themselves too). */
export interface TabDescriptor {
/** Unique id; also the `SidebarTab.type` value (`'explorer'`, `'my-plugin:db'`). */
id: string
title: string | (() => string)
icon?: ReactNode | ((size: number) => ReactNode)
/** + menu sort order (ascending); default 100. */
order?: number
/** Hide from the + menu (the editor tab is opened by file-open, not by the menu). */
hidden?: boolean
/**
* + menu disabled predicate (e.g. terminal at capacity). Receives the
* session scope and the live sidebar state (counts, expansions).
*/
available?: (ctx: Context, scope: SessionScope, state: SidebarState) => boolean
/**
* Single-instance sugar: `true` is shorthand for `dedupeKey: () => id`
* (opening the tab focuses an existing one of the same type instead of
* creating a duplicate). An explicit `dedupeKey` always wins when both
* are given. Builtins: explorer/git/subagent use `single: true`.
*/
single?: boolean
/**
* If provided, opening a tab whose `dedupeKey(tab)` matches an existing
* tab's key focuses the existing one instead of creating a new one.
* Returning `undefined` means "no dedup — always open a new tab".
* Builtins: editor uses `tab => tab.path`; diff uses `tab => tab.id`
* (openDiffTab mints change-derived ids).
*/
dedupeKey?: (tab: SidebarTab) => string | undefined
/**
* Custom tab creation (minting the `SidebarTab` and any state patches).
* Return `null` to refuse creation. The terminal builtin uses this to
* mint `terminal:<n>` ids and bump `nextTerminal`.
* When omitted, a default `{ id, type, title }` tab is created.
*/
createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null
/**
* Declarative settings shown in the Side card settings page: every
* registered tab gets an enable/disable switch (icon + title + id), and
* `settings.toggles` adds nested switches tied to SidebarPrefs fields
* (e.g. the subagent tab's 'autoOpenSubagent').
*/
settings?: SidebarSettingsDeclaration
component: (props: TabComponentProps) => ReactNode
}
/** How the host loads a file's bytes for one viewer. */
export type FileFetchStrategy =
| 'none' // no bytes needed (image/pdf/office fetch through mediaUrl themselves)
| 'fsRead' // text read through /sidebar/api fs.read
| 'mediaUrl' // the viewer gets a media URL string
| 'custom' // the viewer's load() fetches its own bytes
| 'binary-download' // show a download button (no client-side renderer)
/** Props every file viewer component receives. */
export interface FileViewerProps {
ctx: Context
store: SidebarStore
scope: SessionScope
path: string
title: string
/** The matching descriptor's id (`'code'`, `'my-plugin:csv'`). */
viewerId: string
/** fsRead text content (fetchStrategy='fsRead'). */
content?: string
truncated?: boolean
/** mediaUrl for the path (fetchStrategy='mediaUrl'). */
mediaUrl?: string
/** custom load() return value (fetchStrategy='custom'). */
customData?: unknown
}
/** Describes one file previewer (builtins register themselves too). */
export interface FileViewerDescriptor {
/** Unique id (`'image'`, `'pdf'`, `'my-plugin:csv'`). */
id: string
/** Display name for the settings inventory (falls back to `id` when absent). */
title?: string | (() => string)
/** Icon shown in the settings inventory. */
icon?: ReactNode | ((size: number) => ReactNode)
/** Lowercase extensions without leading dot (`['png','jpg']`). `[]` = match any (catch-all). */
exts: readonly string[]
/** Higher wins; default 0. Builtins use 0; the catch-all `code` viewer uses -100. */
priority?: number
fetchStrategy: FileFetchStrategy
/**
* Content sniff: when `head` bytes are available the descriptor's `detect`
* is consulted before its `exts` (per-descriptor, in priority order).
*/
detect?: (path: string, head: Uint8Array) => boolean
/** fetchStrategy='custom' loader. */
load?: (path: string, scope: SessionScope) => Promise<unknown>
/**
* Declarative settings shown in the Side card settings page: every
* registered viewer gets an enable/disable switch (icon + title + exts).
*/
settings?: SidebarSettingsDeclaration
component: (props: FileViewerProps) => ReactNode
}
/** The registry service published as `ctx.betterSidebar`. */
export interface BetterSidebarService {
registerTab(descriptor: TabDescriptor): () => void
registerFileViewer(descriptor: FileViewerDescriptor): () => void
getTabs(): readonly TabDescriptor[]
getFileViewers(): readonly FileViewerDescriptor[]
/** Find a tab descriptor by id (undefined if not registered). */
getTab(id: string): TabDescriptor | undefined
/**
* Whether a tab type is enabled in the side card prefs. An absent
* `tabsEnabled[id]` entry means enabled — only an explicit `false`
* disables the type (hidden from the + menu, `openTab` refuses, and
* derived flows gate on it).
*/
isTabEnabled(id: string): boolean
/** Whether a file viewer is enabled (absent `viewersEnabled[id]` = enabled). */
isViewerEnabled(id: string): boolean
/**
* Find a file viewer for a path (priority desc; detect first, then exts).
* Disabled viewers are skipped, so files fall through to the next match.
*/
matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined
/**
* 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.
*/
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void
/** Close a tab by id. */
closeTab(tabId: string): void
/** Subscribe to registry changes (register/dispose). */
subscribe(listener: () => void): () => void
}
/** Extract the lowercase extension without leading dot from a path. */
function extOfPath(path: string): string {
const at = path.lastIndexOf('.')
if (at === -1) return ''
const base = path.slice(at + 1).toLowerCase()
return base.includes('/') || base.includes('\\') ? '' : base
}
/**
* Create one BetterSidebar service bound to a store. The service owns the
* tab/viewer registries (Map + listener set) and proxies openTab/closeTab
* to the store's reducer. One instance per client plugin activation.
*/
export function createBetterSidebarService(store: SidebarStore): BetterSidebarService {
const tabs = new Map<string, TabDescriptor>()
const viewers = new Map<string, FileViewerDescriptor>()
const listeners = new Set<() => void>()
const notify = (): void => {
for (const fn of [...listeners]) fn()
}
const subscribe = (listener: () => void): (() => void) => {
listeners.add(listener)
return () => { listeners.delete(listener) }
}
const registerTab = (descriptor: TabDescriptor): (() => void) => {
if (tabs.has(descriptor.id)) {
throw new Error(`[dsh-better-sidebar] tab type "${descriptor.id}" already registered`)
}
tabs.set(descriptor.id, descriptor)
notify()
return () => {
if (tabs.get(descriptor.id) === descriptor) {
tabs.delete(descriptor.id)
notify()
}
}
}
const registerFileViewer = (descriptor: FileViewerDescriptor): (() => void) => {
if (viewers.has(descriptor.id)) {
throw new Error(`[dsh-better-sidebar] file viewer "${descriptor.id}" already registered`)
}
viewers.set(descriptor.id, descriptor)
notify()
return () => {
if (viewers.get(descriptor.id) === descriptor) {
viewers.delete(descriptor.id)
notify()
}
}
}
const getTabs = (): readonly TabDescriptor[] => Array.from(tabs.values())
const getFileViewers = (): readonly FileViewerDescriptor[] => Array.from(viewers.values())
const getTab = (id: string): TabDescriptor | undefined => tabs.get(id)
// The enable switches come from the user's side card prefs (the shared
// store the service is bound to): an absent key means enabled.
const isTabEnabled = (id: string): boolean => store.getPrefs().tabsEnabled[id] !== false
const isViewerEnabled = (id: string): boolean => store.getPrefs().viewersEnabled[id] !== false
const matchFileViewer = (path: string, head?: Uint8Array): FileViewerDescriptor | undefined => {
const ext = extOfPath(path)
// Single pass in priority order (descending; stable for equal
// priorities — insertion order). Each descriptor gets first refusal in
// its own turn: `detect` (when head bytes are available) beats its own
// `exts`, and `exts: []` is a catch-all matching any path — so the
// catch-all `code` viewer (-100) only sees paths no higher-priority
// descriptor claimed. Disabled viewers are skipped entirely.
for (const v of Array.from(viewers.values()).sort(
(a, b) => (b.priority ?? 0) - (a.priority ?? 0),
)) {
if (!isViewerEnabled(v.id)) continue
// Content sniff first (only when head bytes are available).
if (head !== undefined && v.detect !== undefined) {
if (v.detect(path, head)) return v
// A catch-all with detect is SNIFF-ONLY: it must not blind-claim
// paths it never sniffed (a magic-number viewer must not swallow
// every file before the real viewers get their turn).
if (v.exts.length === 0) continue
} else if (v.exts.length === 0) {
// Blind catch-all (no detect) claims anything; a sniff-only
// catch-all (detect defined, no head yet) yields this round.
if (v.detect === undefined) return v
continue
}
if (v.exts.includes(ext)) return v
}
return undefined
}
const openTab = (seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: 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.
if (!isTabEnabled(seed.type)) {
console.warn(`[dsh-better-sidebar] tab type "${seed.type}" is disabled in the side card settings`)
return
}
store.reduce((state) => {
const descriptor = tabs.get(seed.type)
if (descriptor === undefined) return state
// Let the descriptor mint the tab (terminal's nextTerminal bump, etc.).
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
}
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 } : {}),
}
return applyDedupe(state, tab, descriptor)
})
}
const closeTab = (tabId: string): void => {
store.reduce((state) => {
const paneId = findPaneIdOf(state, tabId)
if (paneId === '') return state
return closeTabReducer(state, paneId, tabId)
})
}
return {
registerTab,
registerFileViewer,
getTabs,
getFileViewers,
getTab,
isTabEnabled,
isViewerEnabled,
matchFileViewer,
openTab,
closeTab,
subscribe,
}
}
/**
* Apply dedup: if a tab whose `dedupeKey` matches an existing tab of the
* same type exists, focus it; otherwise land the tab through
* `openTabInActivePane` (the id safety net + active-pane landing are that
* reducer's job — not re-implemented here).
* `single: true` resolves to the id-key sugar when no explicit key is given.
*/
function applyDedupe(state: SidebarState, tab: SidebarTab, descriptor: TabDescriptor): SidebarState {
const dedupeKey = descriptor.dedupeKey ?? (descriptor.single === true ? () => descriptor.id : undefined)
const key = dedupeKey?.(tab)
if (key !== undefined) {
for (const leaf of allLeaves(state.splits)) {
const existing = leaf.tabs.find(t => t.type === tab.type && dedupeKey!(t) === key)
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
}
}
return openTabInActivePane(state, tab)
}
/** Find which pane hosts a tab id ('' if none). */
function findPaneIdOf(state: SidebarState, tabId: string): string {
for (const leaf of allLeaves(state.splits)) {
if (leaf.tabs.some(t => t.id === tabId)) return leaf.id
}
return state.activePane ?? ''
}
+241 -2
View File
@@ -122,6 +122,7 @@
.panelBody {
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
}
@@ -639,6 +640,15 @@
text-align: center;
}
/* The unregistered tab type shown by the orphaned-tab placeholder. */
.orphanedType {
display: block;
margin-top: 8px;
font-size: 12px;
opacity: 0.7;
overflow-wrap: anywhere;
}
/* Binary / download-only files (pptx, OLE .doc/.xls/.ppt, unknown binaries):
a centered notice plus a download link — the user always gets the file. */
.editorBinary {
@@ -1051,9 +1061,13 @@
.git {
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
overflow-y: auto;
/* Tab pages never scroll horizontally: content wraps or truncates to the
panel width instead of panning sideways. */
overflow-x: hidden;
}
.gitHeader {
@@ -1189,6 +1203,167 @@
padding: 8px;
}
/* The diff tab (a change opened from the git panel, like VSCode's diff
editor): header + scrollable diff body, no horizontal panning. */
.gitDiffTab {
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
overflow-y: auto;
overflow-x: hidden;
}
.gitDiffTabHeader {
flex: none;
display: flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 8px 0 12px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
}
.gitDiffTabTitle {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: var(--dsw-font-xxs-strong-12);
color: var(--dsw-alias-label-primary);
}
/* One changed file's section: path header, hunk headers, aligned lines. */
.gitDiffFile {
display: flex;
align-items: baseline;
gap: 6px;
padding: 8px 2px 2px;
}
.gitDiffFilePath {
font: var(--dsw-font-xxs-strong-12);
color: var(--dsw-alias-label-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gitDiffFileOld {
flex: none;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 40%;
}
.gitDiffFileTag {
flex: none;
padding: 0 6px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
font: var(--dsw-font-xxxs-strong-11);
color: var(--dsw-alias-label-secondary);
}
.gitDiffHunk {
display: flex;
gap: 8px;
padding: 3px 2px;
font: var(--dsw-font-markdown-code-block-small);
color: var(--dsw-alias-label-tertiary);
}
.gitDiffHunkHeader {
flex: none;
color: var(--dsw-alias-label-secondary);
}
.gitDiffHunkSection {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* One aligned diff line: old gutter / new gutter / code. Lines WRAP instead
of scrolling horizontally (the tab-page rule: fill the width adaptively),
with the long-token fallback breaking anywhere a space never comes. */
.gitDiffLine {
display: flex;
align-items: stretch;
font: var(--dsw-font-markdown-code-block-small);
line-height: 20px;
white-space: pre-wrap;
overflow-wrap: anywhere;
min-width: 0;
}
.gitDiffNum {
flex: none;
width: 36px;
padding-right: 8px;
text-align: right;
color: var(--dsw-alias-label-tertiary);
user-select: none;
}
.gitDiffCode {
flex: 1;
min-width: 0;
overflow: visible;
}
.gitDiffCtx {
color: var(--dsw-alias-label-primary);
}
.gitDiffDel {
color: var(--dsw-alias-state-error-primary);
background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);
}
.gitDiffAdd {
color: var(--dsw-alias-state-success-primary);
background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent);
}
.gitDiffMeta {
padding-left: 2px;
}
.gitDiffMetaText {
font: var(--dsw-font-xxxs-11);
font-style: italic;
color: var(--dsw-alias-label-tertiary);
}
.gitDiffExpand {
display: block;
width: 100%;
margin: 4px 0;
border: none;
background: transparent;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-brand-primary);
cursor: pointer;
text-align: center;
}
.gitDiffExpand:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.gitConfirmDesc {
margin: 0;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
white-space: pre-wrap;
}
.gitCommit {
display: flex;
gap: 6px;
@@ -1223,12 +1398,26 @@
cursor: default;
}
/* A history row: line 1 = hash + subject, line 2 = ref badges + author/date
(badges live on the second line so they never crowd the subject). */
.gitLogRow {
display: flex;
flex-direction: column;
gap: 2px;
padding: 5px 12px;
cursor: pointer;
border-radius: 8px;
}
.gitLogRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.gitLogLine1 {
display: flex;
align-items: baseline;
gap: 8px;
padding: 3px 12px;
font: var(--dsw-font-xxs-12);
min-width: 0;
}
.gitLogHash {
@@ -1237,15 +1426,62 @@
color: var(--dsw-alias-label-tertiary);
}
.gitLogLine2 {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex-wrap: wrap;
}
.gitLogRef {
flex: none;
padding: 0 5px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
font: var(--dsw-font-xxxs-strong-11);
color: var(--dsw-alias-brand-primary);
white-space: nowrap;
}
.gitLogSubject {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
}
.gitLogMeta {
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
}
.gitLogMore {
display: block;
width: calc(100% - 24px);
margin: 4px 12px 8px;
padding: 6px 0;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 6px;
background: transparent;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-secondary);
cursor: pointer;
}
.gitLogMore:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.gitLogMore:disabled {
opacity: 0.5;
cursor: default;
}
/* ── Intercepted produced-files row ────────────────────────────────────── */
.producedRow {
@@ -1307,6 +1543,9 @@
.gitRowMain:focus-visible,
.gitLink:focus-visible,
.gitCommitButton:focus-visible,
.gitLogRow:focus-visible,
.gitLogMore:focus-visible,
.gitDiffExpand:focus-visible,
.terminalRetry:focus-visible,
.editorModeButton:focus-visible,
.editorDownloadLink:focus-visible,
+15 -9
View File
@@ -15,7 +15,6 @@ import clsx from 'clsx'
import type { SidebarState, SidebarTab, SplitNode } from './state.ts'
import type { DropZone } from './state.ts'
import { TabBar, type NewTabOption, parseDrag, type TabDragPayload } from './TabBar.tsx'
import { tabTypeIcon } from './icons.tsx'
import css from './sidebar.module.css'
/** Actions the workbench needs (bound to the store by the sidebar shell). */
@@ -108,7 +107,7 @@ function PaneEmptyCards(props: {
title={option.label}
onClick={() => { onNewTab(option.id) }}
>
{option.icon ?? tabTypeIcon(option.id as SidebarTab['type'], 16)}
{option.icon ?? null}
<span>{option.label}</span>
</button>
))}
@@ -122,9 +121,10 @@ function LeafView(props: {
newTabOptions: NewTabOption[]
actions: WorkbenchActions
onNewTab: (optionId: string) => void
renderTab: (tab: SidebarTab, active: boolean) => ReactNode
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
getTabIcon?: (tab: SidebarTab) => ReactNode
}) {
const { leaf, newTabOptions, actions, onNewTab, renderTab } = props
const { leaf, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props
const [dropZone, setDropZone] = useState<DropZone | null>(null)
const activeTab = leaf.tabs.find(tab => tab.id === leaf.active) ?? leaf.tabs[leaf.tabs.length - 1]
@@ -177,6 +177,7 @@ function LeafView(props: {
onClose={(tabId) => { actions.closeTab(leaf.id, tabId) }}
onNewTab={onNewTab}
newTabOptions={newTabOptions}
getTabIcon={getTabIcon}
onDropTab={(payload, before) => {
if (before === null) actions.moveTabToEdge(payload, leaf.id, 'center')
else actions.moveTabBefore(payload, leaf.id, before)
@@ -196,7 +197,7 @@ function LeafView(props: {
key={tab.id}
className={clsx(css.paneTab, tab.id !== activeTab?.id && css.paneTabHidden)}
>
{renderTab(tab, tab.id === activeTab?.id)}
{renderTab(tab, tab.id === activeTab?.id, leaf.id)}
</div>
))}
</div>
@@ -214,9 +215,10 @@ function NodeView(props: {
newTabOptions: NewTabOption[]
actions: WorkbenchActions
onNewTab: (optionId: string) => void
renderTab: (tab: SidebarTab, active: boolean) => ReactNode
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
getTabIcon?: (tab: SidebarTab) => ReactNode
}) {
const { node, state, newTabOptions, actions, onNewTab, renderTab } = props
const { node, state, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props
if (node.kind === 'leaf') {
return (
<LeafView
@@ -225,6 +227,7 @@ function NodeView(props: {
actions={actions}
onNewTab={onNewTab}
renderTab={renderTab}
getTabIcon={getTabIcon}
/>
)
}
@@ -250,6 +253,7 @@ function NodeView(props: {
actions={actions}
onNewTab={onNewTab}
renderTab={renderTab}
getTabIcon={getTabIcon}
/>
</div>
</Fragment>
@@ -264,9 +268,10 @@ export function Workbench(props: {
newTabOptions: NewTabOption[]
actions: WorkbenchActions
onNewTab: (optionId: string) => void
renderTab: (tab: SidebarTab, active: boolean) => ReactNode
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
getTabIcon?: (tab: SidebarTab) => ReactNode
}) {
const { state, newTabOptions, actions, onNewTab, renderTab } = props
const { state, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props
return (
<div className={css.workbench}>
<NodeView
@@ -276,6 +281,7 @@ export function Workbench(props: {
actions={actions}
onNewTab={onNewTab}
renderTab={renderTab}
getTabIcon={getTabIcon}
/>
</div>
)
+181 -44
View File
@@ -11,14 +11,27 @@
*/
import { SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs } from '../prefs-shared.ts'
export type TabType = 'explorer' | 'git' | 'editor' | 'terminal' | 'subagent'
/**
* Tab type identifier. Builtins register their ids (explorer / git / editor
* / terminal / subagent / diff) through the sidebar service; external
* plugins register their own (e.g. `'my-plugin:db'`). Kept as `string` so
* the registry stays open.
*/
export type TabType = string
/** One open tab. `path` carries the file (editor) or is absent (explorer/git). */
/** What a diff tab shows: a worktree/index change of one path, or one commit's full patch. */
export type SidebarDiffRef =
| { kind: 'worktree'; path: string; staged: boolean; untracked?: boolean }
| { kind: 'commit'; hash: string; hashFull: string; subject: string }
/** One open tab. `path` carries the file (editor) or is absent (explorer/git);
* `diff` carries the change a diff tab shows. */
export interface SidebarTab {
id: string
type: TabType
title: string
path?: string
diff?: SidebarDiffRef
}
/** A tab group. */
@@ -57,7 +70,6 @@ export const PANEL_MIN = 280
export const PANEL_MAX = 640
export const PANEL_DEFAULT = 400
export const TAB_MAX_WIDTH = 160
export const TERMINAL_LIMIT = 3
let nextIdCounter = 0
/** Unique pane/tab id within one state instance. */
@@ -102,11 +114,16 @@ function maxCounterId(parsed: unknown): number {
/** A fresh default state: one explorer tab in one pane, open per the caller's
* preference. `width` is the caller's preferred panel width (default
* PANEL_DEFAULT) and `panelOpen` whether the panel starts expanded (default
* true); the store seeds new sessions from the user's side card prefs. */
export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true): SidebarState {
* true); the store seeds new sessions from the user's side card prefs.
* `seedExplorer` places the default explorer tab — the store passes false
* when the user disabled the explorer tab type in settings, so a fresh
* session starts with an empty pane instead of a tab they turned off. */
export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seedExplorer = true): SidebarState {
const leaf: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
leaf.tabs = [{ id: uid('tab'), type: 'explorer', title: 'Explorer' }]
leaf.active = leaf.tabs[0]!.id
if (seedExplorer) {
leaf.tabs = [{ id: uid('tab'), type: 'explorer', title: 'Explorer' }]
leaf.active = leaf.tabs[0]!.id
}
return {
panelOpen,
width,
@@ -291,34 +308,26 @@ export function activateTab(state: SidebarState, paneId: string, tabId: string):
}
}
/** Whether a tab type is single-instance per session (explorer/git/subagent). */
function isSingle(type: TabType): boolean {
return type === 'explorer' || type === 'git' || type === 'subagent'
}
/**
* Open a tab (or focus its existing instance). Single-instance types focus
* the existing tab wherever it lives; editors dedupe by path. The tab lands
* in the active pane (or the first pane when the active one is gone).
* 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
* tab descriptor through {@link BetterSidebarService.openTab} / `dedupeKey`;
* this reducer only handles the id-based safety net (reconcile and
* openDiffTab already check existence before calling) and the landing
* itself — the service's dedupe path delegates here after its dedupeKey
* check misses.
*/
export function openTab(state: SidebarState, tab: SidebarTab): SidebarState {
export function openTabInActivePane(state: SidebarState, tab: SidebarTab): SidebarState {
let targetId = state.activePane ?? firstLeaf(state.splits).id
// A stale activePane (its pane was closed since) must not swallow the
// open: fall back to the first pane instead of dropping the tab.
if (!allLeaves(state.splits).some(leaf => leaf.id === targetId)) {
targetId = firstLeaf(state.splits).id
}
if (isSingle(tab.type)) {
for (const leaf of allLeaves(state.splits)) {
const existing = leaf.tabs.find(candidate => candidate.type === tab.type)
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
}
}
if (tab.type === 'editor' && tab.path !== undefined) {
for (const leaf of allLeaves(state.splits)) {
const existing = leaf.tabs.find(candidate => candidate.type === 'editor' && candidate.path === tab.path)
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
}
// Id-based safety net: if a tab with the same id exists, focus it.
for (const leaf of allLeaves(state.splits)) {
const existing = leaf.tabs.find(candidate => candidate.id === tab.id)
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
}
return {
...state,
@@ -358,6 +367,44 @@ export function splitPane(state: SidebarState, dir: 'row' | 'col'): SidebarState
return { ...state, splits: splitLeafAt(state.splits, paneId, dir) }
}
/**
* Open a diff tab the VSCode way: an existing instance of the same change is
* focused wherever it lives; otherwise the tab joins the first pane that
* already holds diff tabs (diff panes are sticky — repeated clicks stack
* there); on the FIRST diff of a layout the source pane splits vertically so
* the diff lands in a fresh pane below it ("默认在下半栏新增一个").
*
* This is split-tree placement surgery, not registry dispatch: the diff tab
* descriptor's `dedupeKey` is `(tab) => tab.id`, and the existing-instance
* check below is exactly that rule — the two agree by construction (asserted
* in tests). Diff tabs minted by the Git view carry change-derived ids, so
* the id check is the per-change dedupe.
* @returns the new state, with the diff pane active.
*/
export function openDiffTab(state: SidebarState, sourcePaneId: string, tab: SidebarTab): SidebarState {
const existingLeaf = leafWithTab(state.splits, tab.id)
if (existingLeaf !== undefined) return activateTab(state, existingLeaf.id, tab.id)
const diffLeaf = allLeaves(state.splits).find(leaf => leaf.tabs.some(candidate => candidate.type === 'diff'))
if (diffLeaf !== undefined) {
return {
...state,
activePane: diffLeaf.id,
splits: mapLeaf(state.splits, diffLeaf.id, (leaf) => {
leaf.tabs = [...leaf.tabs, tab]
leaf.active = tab.id
}),
}
}
// First diff: split the source pane, the diff tab in the new LOWER leaf.
// (A stale sourcePaneId — its pane closed meanwhile — degrades to the
// regular open path instead of dropping the tab into an orphaned leaf.)
if (!allLeaves(state.splits).some(leaf => leaf.id === sourcePaneId)) {
return openTabInActivePane(state, tab)
}
const result = insertLeafAt(state.splits, sourcePaneId, 'col', tab, false)
return { ...state, splits: result.node, activePane: result.leafId }
}
/** Toggle the panel open/closed (opening restores the previous layout). */
export function togglePanel(state: SidebarState): SidebarState {
return { ...state, panelOpen: !state.panelOpen }
@@ -396,6 +443,70 @@ export function resizeSplit(node: SplitNode, splitId: string, index: number, del
}
}
/** Prefix marking a tab id as an agent-owned terminal (suffix is the uuid). */
export const AGENT_TAB_PREFIX = 'agent:'
/** Whether a tab id refers to an agent-owned terminal. */
export function isAgentTabId(tabId: string): boolean {
return tabId.startsWith(AGENT_TAB_PREFIX)
}
/** Extract the agent terminal uuid from an `agent:<uuid>` tab id. */
export function agentUuidOf(tabId: string): string {
return tabId.slice(AGENT_TAB_PREFIX.length)
}
/** Build the sidebar tab id for one agent terminal uuid. */
export function agentTabId(uuid: string): string {
return `${AGENT_TAB_PREFIX}${uuid}`
}
/**
* Reconcile the sidebar's agent-terminal tabs with the host's live list.
* The host pushes the current list of agent terminals (created by the model
* through the `terminal_create` tool) over a dedicated WebSocket; this
* reducer mirrors that list into tabs: new uuids get a tab, vanished uuids
* lose theirs. The agent owns the lifetime — the user closing a tab sends a
* WS close frame that kills the pty, which fires a change, which converges
* the view. Idempotent: a no-op when the lists already match.
* @param state - the current per-session sidebar state.
* @param agentTerminals - the live agent terminal snapshots from the host.
* @returns the next state (or the same reference if no change was needed).
*/
export function reconcileAgentTerminals(
state: SidebarState,
agentTerminals: ReadonlyArray<{ uuid: string; title: string }>,
): SidebarState {
const existingTabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
const existingAgentTabs = existingTabs.filter(tab => isAgentTabId(tab.id))
const existingUuids = new Set(existingAgentTabs.map(tab => agentUuidOf(tab.id)))
const serverUuids = new Set(agentTerminals.map(t => t.uuid))
const toAdd = agentTerminals.filter(t => !existingUuids.has(t.uuid))
const toRemove = existingAgentTabs.filter(tab => !serverUuids.has(agentUuidOf(tab.id)))
if (toAdd.length === 0 && toRemove.length === 0) return state
// Remove tabs whose uuids vanished from the server list (the agent closed
// them, or the pty exited and was reaped). Reuse closeTab's leaf cleanup.
let splits = state.splits
for (const tab of toRemove) {
const leaf = leafWithTab(splits, tab.id)
if (leaf !== undefined) {
splits = closeTab({ ...state, splits }, leaf.id, tab.id).splits
}
}
// Add tabs for new uuids (the agent created a terminal). They land in the
// active pane via openTabInActivePane; the next reconcile is a no-op for them.
let next: SidebarState = { ...state, splits }
for (const terminal of toAdd) {
const tab: SidebarTab = {
id: agentTabId(terminal.uuid),
type: 'terminal',
title: terminal.title,
}
next = openTabInActivePane(next, tab)
}
return next
}
// ── The per-session store ──────────────────────────────────────────────────
const STORAGE_PREFIX = 'dsh-sidebar:v1'
@@ -404,6 +515,12 @@ const STORAGE_PREFIX = 'dsh-sidebar:v1'
export interface SidebarSnapshot {
sessionId: string | undefined
state: SidebarState | undefined
/**
* The current side card prefs. Carried IN the snapshot (not a separate
* subscription) so prefs changes re-render the consumers that gate on
* them — the + menu hides a tab type the moment its switch flips.
*/
prefs: SidebarPrefs
}
/** Default panel width for one viewport: the prefs percent of the window,
@@ -429,13 +546,14 @@ function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
}
// New sessions seed from the user's side card prefs: the width is the
// chosen percent of the window (clamped to the panel floor and the
// viewport so a huge percent can never crush the app shell), and the
// panel starts open only when the preference says so.
// viewport so a huge percent can never crush the app shell), the panel
// starts open only when the preference says so, and the default explorer
// tab is skipped when the user disabled the explorer tab type.
const viewport = typeof window !== 'undefined' ? window.innerWidth : undefined
const width = viewport === undefined
? PANEL_DEFAULT
: defaultWidthFor(viewport, prefs.defaultWidthPercent)
return makeDefaultState(width, prefs.openByDefault)
return makeDefaultState(width, prefs.openByDefault, prefs.tabsEnabled['explorer'] !== false)
}
/**
@@ -499,17 +617,23 @@ function sanitizeNode(node: unknown, seen: Set<string>, reid: Map<string, string
if (record.kind === 'leaf') {
if (typeof record.id !== 'string' || !Array.isArray(record.tabs)) return undefined
const tabs: SidebarTab[] = []
let droppedDiff = false
for (const tab of record.tabs) {
if (tab === null || typeof tab !== 'object') return undefined
const candidate = tab as Record<string, unknown>
if (typeof candidate.id !== 'string' || typeof candidate.title !== 'string') return undefined
if (
candidate.type !== 'explorer' && candidate.type !== 'git'
&& candidate.type !== 'editor' && candidate.type !== 'terminal'
&& candidate.type !== 'subagent'
) {
return undefined
// Diff tabs are ephemeral by nature (like VSCode's diff editors):
// they never survive a reload, so a stale ref (e.g. a wrong staged
// side persisted by an older build) cannot resurface as a dead tab
// showing "no text changes" after every refresh.
if (candidate.type === 'diff') {
droppedDiff = true
continue
}
// Tab types are an open set (external plugins register their own);
// accept any string type here — an unregistered type renders an
// <OrphanedTab/> at view time and recovers if its plugin loads later.
if (typeof candidate.type !== 'string') return undefined
tabs.push({
id: candidate.id,
type: candidate.type,
@@ -518,8 +642,10 @@ function sanitizeNode(node: unknown, seen: Set<string>, reid: Map<string, string
})
}
const active = typeof record.active === 'string' ? record.active : null
if (active !== null && !tabs.some(tab => tab.id === active)) return undefined
return { kind: 'leaf', id: uniqueNodeId(record.id, seen, reid), tabs, active }
// An active pointer into a dropped diff tab is expected after the drop;
// any other missing active is structural corruption → reset the state.
if (active !== null && !tabs.some(tab => tab.id === active) && !droppedDiff) return undefined
return { kind: 'leaf', id: uniqueNodeId(record.id, seen, reid), tabs, active: active !== null && tabs.some(tab => tab.id === active) ? active : null }
}
if (record.kind === 'split') {
if (typeof record.id !== 'string' || (record.dir !== 'row' && record.dir !== 'col')) return undefined
@@ -545,15 +671,26 @@ function sanitizeNode(node: unknown, seen: Set<string>, reid: Map<string, string
/** The session-scoped store: one state per conversation, localStorage-backed. */
export class SidebarStore {
private readonly bySession = new Map<string, SidebarState>()
private snapshot: SidebarSnapshot = { sessionId: undefined, state: undefined }
private snapshot: SidebarSnapshot = {
sessionId: undefined,
state: undefined,
prefs: { ...SIDEBAR_PREFS_DEFAULTS },
}
private readonly listeners = new Set<() => void>()
private persistTimer: number | undefined
/** User-facing side card prefs seeding brand-new session states (defaults until the settings RPC resolves). */
private prefs: SidebarPrefs = { ...SIDEBAR_PREFS_DEFAULTS }
/** Replace the side card prefs (the settings RPC result / settings page write). */
/**
* Replace the side card prefs (the settings RPC result / settings page
* write). Notifies like any store change: the snapshot carries the prefs,
* so consumers that gate on enable switches (the + menu, derived flows)
* re-render with the new values immediately.
*/
setPrefs(prefs: SidebarPrefs): void {
this.prefs = { ...prefs }
this.snapshot = { ...this.snapshot, prefs: this.prefs }
this.notify()
}
/** The current side card prefs (seeds new sessions; persisted states win). */
@@ -565,7 +702,7 @@ export class SidebarStore {
setSession(sessionId: string | undefined): void {
if (this.snapshot.sessionId === sessionId) return
if (sessionId === undefined) {
this.snapshot = { sessionId: undefined, state: undefined }
this.snapshot = { sessionId: undefined, state: undefined, prefs: this.prefs }
} else {
let state = this.bySession.get(sessionId)
if (state === undefined) {
@@ -577,7 +714,7 @@ export class SidebarStore {
// pane/split ids can never collide with its tree.
nextIdCounter = maxCounterId(state)
}
this.snapshot = { sessionId, state }
this.snapshot = { sessionId, state, prefs: this.prefs }
}
this.notify()
}
@@ -599,7 +736,7 @@ export class SidebarStore {
const draft = structuredClone(state)
mutator(draft)
this.bySession.set(sessionId, draft)
this.snapshot = { sessionId, state: draft }
this.snapshot = { sessionId, state: draft, prefs: this.prefs }
this.schedulePersist(sessionId, draft)
this.notify()
}
@@ -626,7 +763,7 @@ export class SidebarStore {
if (sessionId === undefined || state === undefined) return
const next = reducer(state)
this.bySession.set(sessionId, next)
this.snapshot = { sessionId, state: next }
this.snapshot = { sessionId, state: next, prefs: this.prefs }
this.schedulePersist(sessionId, next)
this.notify()
}
+6
View File
@@ -79,4 +79,10 @@ export const PrefsSchema: z<SidebarPrefs> = z.object({
openByDefault: z.boolean().default(true),
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),
// 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.
tabsEnabled: z.dict(z.boolean()).default({}),
viewersEnabled: z.dict(z.boolean()).default({}),
})
+33
View File
@@ -20,6 +20,7 @@
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { Duplex } from 'node:stream'
import type { Context } from 'cordis'
import type { BetterSidebarService } from './client/service.ts'
/** One named webserver route (mirror of the host-webserver WebRoute). */
export interface SidebarWebRoute {
@@ -274,6 +275,31 @@ export interface SidebarSettingsService {
update(ns: string, patch: object, expectedRevision?: number): Promise<void>
}
/**
* The tools service face (mirror of @deepseek-ai/dsh-tools' ToolRegistry).
* The host half registers model-facing tools here; the registry attaches the
* returned disposer to the contributing fiber so unloading unregisters them.
*/
export interface SidebarToolsService {
/** Register one tool definition (raw JSON-Schema or defineTool-sugar form). */
register(tool: unknown): () => void
}
/**
* The agent face a tool sees on `exec.agent` (mirror of @deepseek-ai/dsh-agent's
* Agent). Only the slices the terminal tools touch are restated: the live
* session identity and its header cwd, both readonly.
*/
export interface SidebarAgent {
/** The live session identity shared with the session log. */
readonly id: string
/** The live session this agent drives. */
readonly session: {
/** The session's header (validated cwd, lineage metadata). */
readonly header: { readonly cwd?: string }
}
}
declare module 'cordis' {
interface Context {
httpServer: SidebarHttpServer
@@ -283,6 +309,13 @@ declare module 'cordis' {
slots: SidebarSlotsService
settings: SidebarSettingsService
invariants: SidebarInvariantsService
tools: SidebarToolsService
/**
* The client-side sidebar registry: external plugins register tab types
* and file previewers here. Provided by the client half (see
* {@link ./client/index.tsx}); undefined on the host side.
*/
betterSidebar: BetterSidebarService
/**
* Register a lifecycle callback (DSH-vendored cordis): runs at plugin
* activation; its returned cleanup runs at disposal.
+50 -6
View File
@@ -27,10 +27,16 @@ export interface GitStatusResult {
/** One `git log` row. */
export interface GitLogEntry {
/** Short hash (7+ chars, display). */
hash: string
/** Full 40-char hash (advanced operations: revert / cherry-pick). */
hashFull: string
subject: string
author: string
/** ISO 8601 author date (`%ai`), e.g. `2024-01-01 10:00:00 +0800`. */
date: string
/** Ref decorations (`%D` with --decorate=short), e.g. `HEAD -> main, origin/main`; '' when none. */
refs: string
}
/** One git failure (stderr text as the message). */
@@ -65,14 +71,21 @@ export function parsePorcelainZ(output: string): GitStatusEntry[] {
return entries
}
/** Parse `git log --pretty=format:%h%x1f%s%x1f%an%x1f%ai` rows. */
/** Parse `git log --pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D` rows. */
export function parseLogLines(output: string): GitLogEntry[] {
const rows: GitLogEntry[] = []
for (const line of output.split('\n')) {
if (line === '') continue
const [hash, subject, author, date] = line.split('\x1f')
const [hash, subject, author, date, hashFull, refs] = line.split('\x1f')
if (hash === undefined || subject === undefined) continue
rows.push({ hash, subject, author: author ?? '', date: date ?? '' })
rows.push({
hash,
subject,
author: author ?? '',
date: date ?? '',
hashFull: hashFull ?? hash,
refs: refs ?? '',
})
}
return rows
}
@@ -118,6 +131,12 @@ export async function isGitRepo(cwd: string): Promise<boolean> {
}
}
/** The repository top level containing `cwd` (`git rev-parse --show-toplevel`). */
export async function repoRoot(cwd: string): Promise<string> {
const out = await runGit(cwd, ['rev-parse', '--show-toplevel'])
return out.trim()
}
/** The current branch name (`git rev-parse --abbrev-ref HEAD`; 'HEAD' when detached). */
export async function currentBranch(cwd: string): Promise<string> {
const out = await runGit(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])
@@ -173,9 +192,12 @@ export async function checkout(cwd: string, branch: string): Promise<void> {
await runGit(cwd, ['checkout', branch])
}
/** Recent commit history (newest first). */
export async function log(cwd: string, count = 30): Promise<GitLogEntry[]> {
const raw = await runGit(cwd, ['log', `-n ${count}`, '--pretty=format:%h%x1f%s%x1f%an%x1f%ai'])
/** Recent commit history (newest first), lazily pageable via skip/count. */
export async function log(cwd: string, count = 30, skip = 0): Promise<GitLogEntry[]> {
const raw = await runGit(cwd, [
'log', '-n', String(count), '--skip', String(skip), '--decorate=short',
'--pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D',
])
return parseLogLines(raw)
}
@@ -190,3 +212,25 @@ export async function show(cwd: string, rev: string, path: string): Promise<stri
return null
}
}
/** Full patch text of one commit (`git show` with the commit header suppressed).
* Merge commits show their diff against the first parent (`-m --first-parent`
* is a no-op for regular commits), so a history click always has content. */
export async function commitDiff(cwd: string, hash: string): Promise<string> {
return runGit(cwd, ['show', '--no-ext-diff', '--no-color', '--format=', '-m', '--first-parent', hash])
}
/** Discard the worktree changes of one path (`git checkout -- <path>`; the index is untouched). */
export async function discard(cwd: string, path: string): Promise<void> {
await runGit(cwd, ['checkout', '--', path])
}
/** Revert one commit onto the current branch with an auto-generated message. */
export async function revert(cwd: string, hash: string): Promise<void> {
await runGit(cwd, ['revert', '--no-edit', hash])
}
/** Cherry-pick one commit onto the current branch. */
export async function cherryPick(cwd: string, hash: string): Promise<void> {
await runGit(cwd, ['cherry-pick', hash])
}
+287 -19
View File
@@ -12,7 +12,7 @@
* processes are keyed by session.
*/
import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
import { basename, dirname, extname } from 'node:path'
import { basename, dirname, extname, isAbsolute, join } from 'node:path'
import type { IncomingMessage } from 'node:http'
import { WebSocket, WebSocketServer } from 'ws'
import type { Context } from './context-types.ts'
@@ -23,22 +23,38 @@ import {
SIDEBAR_PREFS_NS,
type ResolvedSidebarConfig,
type SidebarConfig,
type SidebarPrefs,
} from './config.ts'
import { isWithin, parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts'
import { isTrustedApiRequest } 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'
import { AgentPtyRegistry, clampDims, type AgentTerminalHandle } from './agent-pty.ts'
import { registerTools } from './tools.ts'
import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
export { Config }
export type { SidebarConfig, ResolvedSidebarConfig }
// Re-export the Context augmentation (declare module 'cordis') so consumers
// `import type {} from 'dsh-better-sidebar'` and gain `ctx.betterSidebar`.
// Also re-export the service descriptor types so consumers can type their
// registerTab / registerFileViewer arguments without reaching into /client.
export type { Context } from './context-types.ts'
export type {
BetterSidebarService,
TabDescriptor,
TabComponentProps,
FileViewerDescriptor,
FileViewerProps,
FileFetchStrategy,
} from './client/service.ts'
/** Plugin identity for cordis.yml rows. */
export const name = 'dsh-better-sidebar'
/** Services required before mounting: the webserver routes, the session store, and the loader's connection row. */
export const inject = ['httpServer', 'sessions', 'loader']
/** Services required before mounting: the webserver routes, the session store, the loader's connection row, and the tool registry. */
export const inject = ['httpServer', 'sessions', 'loader', 'tools']
/** Content types for the media route, by extension. */
const MEDIA_TYPES: Record<string, string> = {
@@ -94,8 +110,32 @@ function sessionCwdOf(ctx: Context, sessionId: string, clientCwd?: string): stri
return process.cwd()
}
/** Text read of a file with the size cap; binary detection via NUL probe. */
async function readText(path: string, readLimit: number): Promise<{ content: string; truncated: boolean; binary: boolean; size: number }> {
/**
* Resolve a path that a git command reported — `git status`/`git diff`
* print paths RELATIVE TO THE REPO TOP LEVEL, which may sit above the
* session cwd (a session inside a subdirectory of a repository). Absolute
* paths pass through; relative ones join the repo root (falling back to the
* cwd when the root cannot be resolved, e.g. a bare directory).
*/
async function resolveGitPath(cwd: string, raw: string): Promise<string> {
if (isAbsolute(raw)) return requireAbsolute(raw)
const root = await git.repoRoot(cwd).catch(() => cwd)
return requireAbsolute(join(root, raw))
}
/** How many leading bytes a binary read returns for client-side detect sniffing. */
const READ_HEAD_LIMIT = 4096
/** Text read of a file with the size cap; binary detection via NUL probe.
* Binary reads also return the first {@link READ_HEAD_LIMIT} bytes (base64)
* so the client can re-match viewers by content (`detect`). */
async function readText(path: string, readLimit: number): Promise<{
content: string
truncated: boolean
binary: boolean
size: number
head?: string
}> {
const info = await stat(path).catch((error: unknown) => {
throw new SidebarError('fs-error', `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
})
@@ -112,7 +152,10 @@ async function readText(path: string, readLimit: number): Promise<{ content: str
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0)
const slice = buffer.subarray(0, bytesRead)
const binary = slice.includes(0)
return { content: binary ? '' : slice.toString('utf8'), truncated, binary, size }
const head = binary
? slice.subarray(0, Math.min(slice.length, READ_HEAD_LIMIT)).toString('base64')
: undefined
return { content: binary ? '' : slice.toString('utf8'), truncated, binary, size, head }
} finally {
await handle.close()
}
@@ -135,10 +178,11 @@ export interface SidebarSettingsFace {
update(patch: Record<string, unknown>, expectedRevision?: number): Promise<{ value?: unknown; revision?: number }>
}
/** Build the API method table bound to the plugin context, pty manager, and resolved config. */
/** Build the API method table bound to the plugin context, pty manager, agent pty registry, and resolved config. */
function buildApi(
ctx: Context,
ptyManager: PtyManager,
agentPtyRegistry: AgentPtyRegistry,
resolved: ResolvedSidebarConfig,
getSettings: () => SidebarSettingsFace | undefined,
): Record<string, ApiMethod> {
@@ -161,9 +205,11 @@ function buildApi(
},
'fs.read': async (payload) => {
const { cwd } = cwdOf(payload)
const path = requireAbsolute(requireString(payload, 'path'))
const { content, truncated, binary, size } = await readText(path, resolved.readLimit)
if (binary) return { kind: 'binary', size, truncated }
// Relative paths are git-derived (status/diff report repo-root-relative
// names; the untracked diff view reads the file through this route).
const path = await resolveGitPath(cwd, requireString(payload, 'path'))
const { content, truncated, binary, size, head } = await readText(path, resolved.readLimit)
if (binary) return { kind: 'binary', size, truncated, head }
return { kind: 'text', content, truncated }
},
'fs.write': async (payload) => {
@@ -188,7 +234,7 @@ function buildApi(
'git.diff': async (payload) => {
const { cwd } = cwdOf(payload)
const record = payload as { path?: unknown; staged?: unknown }
const path = record.path === undefined ? undefined : requireAbsolute(requireString(payload, 'path'))
const path = record.path === undefined ? undefined : await resolveGitPath(cwd, requireString(payload, 'path'))
return { diff: await git.diff(cwd, path, record.staged === true) }
},
'git.stage': async (payload) => {
@@ -222,11 +268,37 @@ function buildApi(
},
'git.log': async (payload) => {
const { cwd } = cwdOf(payload)
return git.log(cwd)
const record = payload as { count?: unknown; skip?: unknown }
const count = typeof record.count === 'number' && Number.isInteger(record.count) && record.count > 0
? record.count
: undefined
const skip = typeof record.skip === 'number' && Number.isInteger(record.skip) && record.skip >= 0
? record.skip
: undefined
return git.log(cwd, count, skip)
},
'git.commit-diff': async (payload) => {
const { cwd } = cwdOf(payload)
return { diff: await git.commitDiff(cwd, requireString(payload, 'hash')) }
},
'git.discard': async (payload) => {
const { cwd } = cwdOf(payload)
await git.discard(cwd, await resolveGitPath(cwd, requireString(payload, 'path')))
return { ok: true }
},
'git.revert': async (payload) => {
const { cwd } = cwdOf(payload)
await git.revert(cwd, requireString(payload, 'hash'))
return { ok: true }
},
'git.cherry-pick': async (payload) => {
const { cwd } = cwdOf(payload)
await git.cherryPick(cwd, requireString(payload, 'hash'))
return { ok: true }
},
'git.show': async (payload) => {
const { cwd } = cwdOf(payload)
const path = requireAbsolute(requireString(payload, 'path'))
const path = await resolveGitPath(cwd, requireString(payload, 'path'))
const rev = requireString(payload, 'rev')
return { content: await git.show(cwd, rev, path) }
},
@@ -240,6 +312,15 @@ function buildApi(
ptyManager.close(`${sessionId}:${tab}`)
return { ok: true }
},
// Release an agent terminal by uuid. The WS close frame already does
// this while the socket is open; this route covers the tab-close that
// happens while the socket is down (reconnect loop) so a closed agent
// tab never leaves a zombie pty behind. Idempotent.
'agent-pty.close': (payload) => {
const uuid = requireString(payload, 'uuid')
agentPtyRegistry.close(uuid)
return { ok: true }
},
// The side card preferences. The settings service is optional in the
// composition; while absent the routes report undefined and the client
// keeps the schema defaults. Writes are revision-guarded: a stale editor
@@ -287,6 +368,12 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
const trustedHosts = trustedHostsOf(ctx)
const fence = (req: IncomingMessage): boolean => isTrustedApiRequest(req, trustedHosts)
const ptyManager = new PtyManager(defaultShell(), resolved.terminalsPerSession)
// The agent-owned terminal registry: parallel to the UI-tab ptyManager,
// keyed by uuid (the model's opaque handle) instead of `${sessionId}:${tabId}`,
// uncapped, and torn down with the plugin. The model creates terminals here
// through the terminal_create tool; the sidebar view attaches through the
// same /sidebar/ws/terminal upgrade with ?uuid=... instead of ?tab=...
const agentPtyRegistry = new AgentPtyRegistry(defaultShell())
// ── User-facing "Side card" preferences ──────────────────────────────────
// Register the namespace with the settings provider so the Settings page
@@ -297,9 +384,34 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
// which call the seam in-process. Deployments without a settings service
// simply never fill the face and the client falls back to the defaults.
let settingsFace: SidebarSettingsFace | undefined
// The model-facing terminal tools are gated on the side-card setting
// `agentTerminalTools` (default off): nothing is injected until the user
// turns the feature on, and turning it off mid-session unregisters the
// tools and releases the agent terminals they created.
let toolsDisposers: (() => void) | null = null
const syncToolsGate = (scope: { get(): SidebarPrefs }): void => {
if (scope.get().agentTerminalTools) {
if (toolsDisposers === null) {
toolsDisposers = registerTools(ctx, agentPtyRegistry, (sessionId) => sessionCwdOf(ctx, sessionId))
}
} else if (toolsDisposers !== null) {
toolsDisposers()
toolsDisposers = null
// The feature is off: release every agent terminal the model created
// while it was on (they are only reachable through the tools). The
// registry change fires the push, so the sidebar reconciles them away.
agentPtyRegistry.disposeAll()
}
}
ctx.inject(['settings'], (sctx) => {
const ns: SettingsNamespace = settingsNamespace(SIDEBAR_PREFS_NS)
sctx.settings.register(ns, PrefsSchema)
// The structural settings mirror types `schema` as unknown, so the
// generic is not inferred here; the real service resolves it from the
// schemastery schema (PrefsSchema) — narrow the owner scope explicitly.
const scope = sctx.settings.register(ns, PrefsSchema) as {
get(): SidebarPrefs
watch(callback: (next: SidebarPrefs, prev: SidebarPrefs) => void): () => void
}
const viewOf = (): { value?: unknown; revision?: number } => {
const descriptor = sctx.settings.describe({ redactSecrets: true }).find(candidate => candidate.ns === ns)
return descriptor === undefined
@@ -313,10 +425,14 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
return viewOf()
},
}
// Register (or unregister) the terminal tools from the current setting,
// and keep them in sync with every settings commit.
syncToolsGate(scope)
scope.watch(() => { syncToolsGate(scope) })
})
// ── JSON API ────────────────────────────────────────────────────────────
const api = buildApi(ctx, ptyManager, resolved, () => settingsFace)
const api = buildApi(ctx, ptyManager, agentPtyRegistry, resolved, () => settingsFace)
ctx.effect(() => ctx.httpServer.register({
kind: 'prefix',
path: '/sidebar/api',
@@ -398,6 +514,13 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
}), 'dsh-better-sidebar: /sidebar/file media route')
// ── Terminal WebSocket ──────────────────────────────────────────────────
// One upgrade endpoint serves both UI-tab terminals (?tab=...) and
// agent-owned terminals (?uuid=...). The two paths attach to different
// registries but share the wire protocol: input frames are raw text,
// resize frames are JSON `{type:'resize',cols,rows}`, and a close frame
// `{type:'close'}` releases the underlying pty (immediate for agent
// terminals, scheduled-0 for UI tabs which keep the same reconnect grace
// contract the host has always had).
const wss = new WebSocketServer({ noServer: true })
ctx.effect(() => ctx.httpServer.registerUpgrade({
path: '/sidebar/ws/terminal',
@@ -406,30 +529,106 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
socket.destroy()
return
}
wss.handleUpgrade(req, socket, head, (ws) => { void attachTerminal(ctx, ptyManager, ws, req, resolved) })
wss.handleUpgrade(req, socket, head, (ws) => {
void attachTerminal(ctx, ptyManager, agentPtyRegistry, ws, req, resolved)
})
},
}), 'dsh-better-sidebar: terminal WebSocket')
// ── Agent terminals push WebSocket ──────────────────────────────────────
// Pushes the live list of agent terminals for one session to the sidebar
// view: the client mirrors the list into tabs (id `agent:<uuid>`,
// title from the agent's `terminal_create` call). The host fires on every
// create / close / exit; the client reconciles by adding tabs for new
// uuids and dropping tabs whose uuids disappeared (the user closing a tab
// sends `{type:'close'}` on the terminal WS, which kills the pty, which
// fires a change here, which converges the view).
const agentListWss = new WebSocketServer({ noServer: true })
ctx.effect(() => ctx.httpServer.registerUpgrade({
path: '/sidebar/ws/agent-terminals',
handler: (req, socket, head) => {
if (!fence(req)) {
socket.destroy()
return
}
agentListWss.handleUpgrade(req, socket, head, (ws) => {
void attachAgentList(agentPtyRegistry, ws, req)
})
},
}), 'dsh-better-sidebar: agent-terminals push WebSocket')
ctx.effect(() => () => {
toolsDisposers?.()
ptyManager.disposeAll()
agentPtyRegistry.disposeAll()
wss.close()
agentListWss.close()
}, 'dsh-better-sidebar: teardown')
}
/** Wire one terminal socket to its pty: replay transcript, pump both ways. */
/** Push the live agent-terminal list for one session to a connected sidebar view. */
async function attachAgentList(
registry: AgentPtyRegistry,
ws: WebSocket,
req: IncomingMessage,
): Promise<void> {
try {
const url = new URL(req.url ?? '/', 'http://dsh.internal')
const sessionId = url.searchParams.get('sessionId')
if (sessionId === null) {
ws.close(1008, 'sessionId is required')
return
}
const send = (): void => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(registry.list(sessionId)))
}
}
send()
const unsubscribe = registry.subscribe(send)
ws.on('close', () => { unsubscribe() })
ws.on('error', () => { unsubscribe() })
} catch (error) {
ws.close(1011, error instanceof Error ? error.message : String(error))
}
}
/**
* Wire one terminal socket to its pty: replay transcript, pump both ways.
* Two attach modes share the wire protocol:
* - `?uuid=...` attaches to an agent-owned terminal (created by the
* `terminal_create` tool). The close frame kills the pty immediately
* (the agent's terminal closes when the user closes the sidebar tab); a
* bare socket drop (refresh, tab switch) leaves the pty alive for the
* reconnect grace, exactly like UI-tab terminals.
* - `?tab=...&sessionId=...` attaches to a UI-tab terminal (the user
* created it from the + menu). The close frame schedules a 0-ms close
* (the host's reconnect grace keeps the shell alive across a refresh).
*/
async function attachTerminal(
ctx: Context,
ptyManager: PtyManager,
agentPtyRegistry: AgentPtyRegistry,
ws: WebSocket,
req: IncomingMessage,
resolved: ResolvedSidebarConfig,
): Promise<void> {
try {
const url = new URL(req.url ?? '/', 'http://dsh.internal')
const uuid = url.searchParams.get('uuid')
if (uuid !== null) {
const handle = agentPtyRegistry.get(uuid)
if (handle === undefined) {
ws.close(1011, `agent terminal "${uuid}" not found`)
return
}
pumpAgentTerminal(agentPtyRegistry, handle, ws)
return
}
const sessionId = url.searchParams.get('sessionId')
const tabId = url.searchParams.get('tab')
if (sessionId === null || tabId === null) {
ws.close(1008, 'sessionId and tab are required')
ws.close(1008, 'either ?uuid or ?sessionId+?tab are required')
return
}
const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
@@ -470,7 +669,8 @@ async function attachTerminal(
&& control.type === 'resize'
&& typeof control.cols === 'number' && typeof control.rows === 'number'
) {
handle.pty.resize(Math.max(2, Math.floor(control.cols)), Math.max(2, Math.floor(control.rows)))
const dims = clampDims(control.cols, control.rows)
handle.pty.resize(dims.cols, dims.rows)
} else {
handle.pty.write(text)
}
@@ -487,3 +687,71 @@ async function attachTerminal(
ws.close(1011, error instanceof Error ? error.message : String(error))
}
}
/**
* Pump one agent terminal's pty to a connected view. The close frame kills
* the pty immediately (the agent's terminal closes when the user closes the
* sidebar tab); a bare socket drop leaves the pty alive — the agent owns
* the lifetime, and only `terminal_close`, a `{type:'close'}` frame, or
* plugin teardown kills it.
*/
function pumpAgentTerminal(
registry: AgentPtyRegistry,
handle: AgentTerminalHandle,
ws: WebSocket,
): void {
if (handle.transcript !== '') ws.send(handle.transcript)
const onData = (data: string): void => {
if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount < 4 * 1024 * 1024) {
ws.send(data)
}
}
const onExit = ({ exitCode }: { exitCode: number; signal?: number }): void => {
onData(`\r\n[process exited with code ${String(exitCode)}]\r\n`)
}
const dataSub = handle.pty.onData(onData)
const exitSub = handle.pty.onExit(onExit)
ws.on('message', (data) => {
if (handle.exited) return
const text = data.toString('utf8')
let control: { type?: unknown; cols?: unknown; rows?: unknown } | null = null
try {
const parsed: unknown = JSON.parse(text)
if (parsed !== null && typeof parsed === 'object') {
control = parsed as { type?: unknown; cols?: unknown; rows?: unknown }
}
} catch {
// Not JSON: terminal input.
}
if (control !== null && control.type === 'close') {
// The user closed the sidebar tab: kill the pty immediately. The
// agent's next terminal_list / terminal_send will see it gone.
registry.close(handle.uuid)
return
}
if (
control !== null
&& control.type === 'resize'
&& typeof control.cols === 'number' && typeof control.rows === 'number'
) {
const dims = clampDims(control.cols, control.rows)
handle.pty.resize(dims.cols, dims.rows)
} else if (control === null) {
// Raw text input (a JSON-looking string the pty would have received
// verbatim is reachable in theory but is exotic for an agent terminal;
// preserve the UI-tab semantics and forward as input).
handle.pty.write(text)
}
// An unrecognized JSON control frame is dropped (the UI-tab path also
// treats non-resize JSON controls as input, but for an agent terminal
// there is no realistic input that is also valid JSON).
})
ws.on('close', () => {
dataSub.dispose()
exitSub.dispose()
// A bare socket drop (refresh, tab switch) leaves the agent's pty alive.
// The agent owns the lifetime: only `terminal_close`, a `{type:'close'}`
// frame, or plugin teardown kills it. A reconnecting view reattaches the
// same shell and gets the full transcript replayed.
})
}
+27
View File
@@ -20,6 +20,30 @@ export interface SidebarPrefs {
* Subagent page when the current conversation spawns a new subagent.
*/
autoOpenSubagent: boolean
/**
* Whether the model-facing agent terminal tools (terminal_create / list /
* send / read / wait_for / resize / signal / close) are injected into the
* model's toolset. Off by default: the feature stays dormant until the
* user explicitly enables it in the side card settings.
*/
agentTerminalTools: boolean
/**
* Per-tab enable switches, keyed by tab descriptor id (`'explorer'`,
* `'my-plugin:db'`). An ABSENT key means enabled — only an explicit
* `false` disables a tab type (hidden from the + menu, `openTab` refuses,
* and derived flows like subagent auto-open / agent-terminal tabs stop).
* Already-open tabs of a disabled type keep rendering (closing one
* prevents reopening), matching the "existing conversations keep their
* own layouts" rule.
*/
tabsEnabled: Record<string, boolean>
/**
* Per-viewer enable switches, keyed by file viewer descriptor id
* (`'image'`, `'my-plugin:csv'`). An ABSENT key means enabled; a disabled
* viewer is skipped by `matchFileViewer` so files fall through to the
* next matching viewer (or the download button when none match).
*/
viewersEnabled: Record<string, boolean>
}
/** Range contract of {@link SidebarPrefs.defaultWidthPercent}. */
@@ -32,6 +56,9 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
openByDefault: true,
defaultWidthPercent: WIDTH_PERCENT_DEFAULT,
autoOpenSubagent: true,
agentTerminalTools: false,
tabsEnabled: {},
viewersEnabled: {},
}
/** Clamp one width percent into the contract range (shared by schema and client reads). */
+477
View File
@@ -0,0 +1,477 @@
/**
* Eight model-facing tools for the agent-owned sidebar terminals (tmux
* semantics: spawn-and-detach, send-keys, read, wait-for, resize, signal,
* close, list). Each tool binds to the calling agent's session through
* `exec.agent.session.id`, so the model never passes a sessionId — the
* agent identity is the scope.
*
* Conventions (per plugin-development-guide.md §3):
* C1 — parameters schema-validated before `execute` runs.
* C4 — `execute` returns one canonical JSON value; `render` is a separate
* pure text projection.
* C6 — `exec.signal.throwIfAborted()` before any spawn.
* C10 — no UI/transport vocabulary in the canonical value.
*/
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { ToolRunContext } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Context } from './context-types.ts'
import {
AgentPtyRegistry,
ALLOWED_SIGNALS,
type AgentTerminalSignal,
type AgentTerminalSnapshot,
} from './agent-pty.ts'
/** Maximum UTF-8 bytes of one `terminal_read` result text. */
const READ_BYTE_LIMIT = 256 * 1024
/**
* Bound a string to a byte limit, marking truncation. Truncation never
* splits a multi-byte UTF-8 sequence: when the byte cap lands inside one,
* the walk-back retreats to the sequence's leading byte so the retained
* prefix decodes cleanly (a split would decode to U+FFFD).
* @internal exported for the unit tests, like {@link snapshotOf}.
*/
export function boundBytes(text: string, maxBytes: number): { text: string; truncated: boolean } {
const buf = Buffer.from(text, 'utf8')
if (buf.byteLength <= maxBytes) return { text, truncated: false }
// The byte at `end` is a continuation byte (10xxxxxx) exactly when the
// character that started before `end` spills past the cap. Stop at the
// first non-continuation byte — the retained prefix is then intact.
let end = maxBytes
while (end > 0 && ((buf[end] ?? 0) & 0xc0) === 0x80) end -= 1
return { text: buf.subarray(0, end).toString('utf8'), truncated: true }
}
/** Pure text projection helper (the canonical value is already structured). */
function textRender<T>(fn: (value: T) => string): (_args: unknown, value: unknown) => ContentBlock[] {
return (_args, value) => [{ type: 'text', text: fn(value as T) }]
}
/** Extract the calling agent or throw the canonical "no agent" error. */
function requireAgent(agent: Agent | undefined): Agent {
if (agent === undefined) {
throw new Error('sidebar terminal tools require an initiating agent')
}
return agent
}
/** Resolve the calling agent's session id (the registry scope + ownership key). */
function sessionIdOf(exec: ToolRunContext): string {
return requireAgent(exec.agent).session.id
}
/**
* Register the eight terminal tools against the host tool registry. The
* `resolveCwd` callback threads the live session cwd (authoritative from the
* session store, falling back to the process cwd) so a freshly-created
* terminal lands in the right directory without the model passing it.
* Every uuid-keyed tool first asserts the terminal belongs to the calling
* session (`registry.assertOwned`), so one agent can never reach another
* session's terminals.
* @param ctx - host plugin context (carries the tools service).
* @param registry - the agent-owned terminal registry.
* @param resolveCwd - live cwd resolver for one session id.
* @returns a disposer that unregisters all eight tools (the caller gates
* registration on the side-card setting and calls this to turn them off).
*/
export function registerTools(
ctx: Context,
registry: AgentPtyRegistry,
resolveCwd: (sessionId: string) => string,
): () => void {
const disposers: Array<() => void> = []
const register = (tool: ReturnType<typeof defineTool>): void => {
disposers.push(ctx.tools.register(tool))
}
register(defineTool({
name: 'terminal_create',
description:
'Open a persistent terminal in the sidebar and run a command in it. '
+ 'Spawns an interactive shell, writes the command + Enter to its stdin, and returns a uuid handle. '
+ 'The terminal stays alive after the command exits — send more input with terminal_send (set submit=true to run a command), '
+ 'read output with terminal_read, send Ctrl+C with terminal_signal(signal="SIGINT"), '
+ 'and close it with terminal_close when done. '
+ 'Use this for interactive shells, REPLs, long-running dev servers, '
+ 'or any work that needs persistent terminal state across tool calls. '
+ 'The terminal appears as a new tab in the right sidebar (titled with the `title` you provide) so the user can watch and interact with it.',
parameters: {
title: {
type: 'string',
required: true,
description: 'Short human-readable label for the terminal tab (e.g. "dev server", "python repl").',
},
command: {
type: 'string',
required: true,
description: 'Shell command to run in the freshly spawned shell. The host appends an Enter key automatically — do NOT include a trailing newline. Pass "" to open a bare shell with no command.',
},
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
uuid: { type: 'string', required: true, description: 'Opaque handle for the new terminal. Pass to terminal_send / terminal_read / terminal_resize / terminal_signal / terminal_close.' },
title: { type: 'string', required: true, description: 'The title you provided (echoed for confirmation).' },
},
},
render: textRender((v: { uuid: string; title: string }) =>
`Opened terminal "${v.title}" (uuid: ${v.uuid}). The sidebar tab appears automatically; use terminal_read to see output and terminal_send (with submit=true) to run more commands.`,
),
},
execute: (args: { title: string; command: string }, exec) => {
exec.signal.throwIfAborted()
const sessionId = sessionIdOf(exec)
const cwd = resolveCwd(sessionId)
const uuid = registry.create(sessionId, args.title, args.command, cwd, 80, 24)
return Promise.resolve({ uuid, title: args.title })
},
}))
register(defineTool({
name: 'terminal_list',
description:
'List every terminal the current agent has opened in this session. Returns each terminal\'s uuid, title, '
+ 'the command it was started with, and whether the top-level process has exited (with exit code/signal if so). '
+ 'Use this to recover state after a long sequence of tool calls or to find a terminal you forgot to close.',
parameters: {},
output: {
schema: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
properties: {
uuid: { type: 'string', required: true },
title: { type: 'string', required: true },
command: { type: 'string', required: true },
exited: { type: 'boolean', required: true },
exitCode: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
exitSignal: { oneOf: [{ type: 'string' }, { type: 'null' }] },
},
},
},
render: (_args, value) => {
const list = value as AgentTerminalSnapshot[]
if (list.length === 0) return [{ type: 'text', text: 'No agent terminals open in this session.' }]
const lines = list.map((t) => {
const status = t.exited
? `exited (code ${t.exitCode ?? '?'}, signal ${t.exitSignal ?? 'none'})`
: 'running'
return ` ${t.uuid} "${t.title}" [${status}] $ ${t.command}`
})
return [{ type: 'text', text: `Agent terminals in this session:\n${lines.join('\n')}` }]
},
},
execute: (_args, exec) => {
const sessionId = sessionIdOf(exec)
return Promise.resolve(registry.list(sessionId))
},
}))
register(defineTool({
name: 'terminal_send',
description:
'Send raw text (keystrokes) to a terminal opened with terminal_create — tmux send-keys semantics. '
+ 'The text is written verbatim to the pty stdin. '
+ 'To submit a command, set submit=true (appends an Enter key); do NOT put "\\n" or "\\r" in the text yourself. '
+ 'To send Ctrl+C (interrupt the running command), use the terminal_signal tool with signal="SIGINT" — do NOT try to send the control character "\\u0003" as text. '
+ 'Use terminal_signal with signal="SIGTSTP" for Ctrl+Z (suspend) as well. '
+ 'This tool does NOT wait for the command to finish or for output to settle — pair with terminal_read to observe the result. '
+ 'Throws if the terminal has exited.',
parameters: {
uuid: {
type: 'string',
required: true,
description: 'Terminal uuid from terminal_create or terminal_list.',
},
text: {
type: 'string',
required: true,
description: 'UTF-8 text to write to the terminal stdin (verbatim, no shell escaping). Do not include trailing newlines — use the submit flag instead.',
},
submit: {
type: 'boolean',
description: 'Append an Enter key (carriage return) after the text to submit a command. Default: false. Set to true when sending a command to run; leave false for partial input or control sequences.',
},
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
uuid: { type: 'string', required: true },
bytes: { type: 'integer', required: true, description: 'Number of UTF-8 bytes written (including the Enter key if submit was true).' },
},
},
render: textRender((v: { uuid: string; bytes: number }) =>
`Sent ${v.bytes} byte(s) to terminal ${v.uuid}.`,
),
},
execute: (args: { uuid: string; text: string; submit?: boolean }, exec) => {
exec.signal.throwIfAborted()
const sessionId = sessionIdOf(exec)
registry.assertOwned(args.uuid, sessionId)
const payload = args.submit === true ? `${args.text}\r` : args.text
registry.send(args.uuid, payload)
return Promise.resolve({ uuid: args.uuid, bytes: Buffer.byteLength(payload, 'utf8') })
},
}))
register(defineTool({
name: 'terminal_read',
description:
'Read a bounded page of retained output from an agent terminal without sending input. '
+ 'The host keeps up to ~1 MiB of scrollback; this tool returns up to 500 lines per call. '
+ 'Use `offset` to paginate forward ( 0-based from the start of the retained transcript ) or backward ( negative reads from the end, e.g. -50 reads the last 50 lines ). '
+ 'Returns `totalLines` so you know how much scrollback remains. '
+ 'Output is bounded to 256 KiB per call; longer pages are truncated with the `truncated` flag.',
parameters: {
uuid: {
type: 'string',
required: true,
description: 'Terminal uuid from terminal_create or terminal_list.',
},
offset: {
type: 'number',
description: '0-based line offset from the start of the retained transcript (default 0). Negative reads from the end (e.g. -50 = last 50 lines).',
},
count: {
type: 'number',
description: 'Maximum lines to return (default 500, hard cap 500).',
},
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
text: { type: 'string', required: true, description: 'The slice of transcript for the requested page.' },
totalLines: { type: 'integer', required: true, description: 'Total lines in the retained transcript.' },
lineBegin: { type: 'integer', required: true, description: '0-based index of the first line in `text` (inclusive).' },
lineEnd: { type: 'integer', required: true, description: '0-based index of the last line in `text` (exclusive).' },
truncated: { type: 'boolean', required: true, description: 'Whether `text` was truncated to fit the 256 KiB read cap.' },
},
},
render: (_args, value) => {
const v = value as { text: string; totalLines: number; lineBegin: number; lineEnd: number; truncated: boolean }
const head = `[lines ${v.lineBegin}..${v.lineEnd} of ${v.totalLines}${v.truncated ? '; truncated to 256KiB' : ''}]`
return [{ type: 'text', text: `${head}\n${v.text}` }]
},
},
execute: (args: { uuid: string; offset?: number; count?: number }, exec) => {
exec.signal.throwIfAborted()
const sessionId = sessionIdOf(exec)
registry.assertOwned(args.uuid, sessionId)
const result = registry.read(args.uuid, args.offset, args.count)
const bounded = boundBytes(result.text, READ_BYTE_LIMIT)
return Promise.resolve({
text: bounded.text,
totalLines: result.totalLines,
lineBegin: result.lineBegin,
lineEnd: result.lineEnd,
truncated: bounded.truncated,
})
},
}))
register(defineTool({
name: 'terminal_wait_for',
description:
'Block until a substring appears in a terminal\'s retained transcript, or until the timeout elapses, or until the terminal exits — whichever happens first. '
+ 'Use this to synchronize on command completion cues ( e.g. a shell prompt, "done", "Listening on", "Build successful" ) '
+ 'without busy-polling terminal_read. '
+ 'The wait scans the FULL retained transcript (up to ~1 MiB) on every poll, so a needle that scrolled past the most recent chunk is still a match. '
+ 'Returns `found` with the line/column of the first occurrence, `timeout` if the needle did not appear in time, or `exited` if the terminal process died before the needle appeared. '
+ 'Default timeout is 10 seconds; raise it for long-running commands ( dev servers, test suites ). '
+ 'The wait is cooperative: a tool-call cancel ( or agent turn end ) aborts it immediately.',
parameters: {
uuid: {
type: 'string',
required: true,
description: 'Terminal uuid from terminal_create or terminal_list.',
},
needle: {
type: 'string',
required: true,
description: 'Substring to wait for (case-sensitive, verbatim). Must be non-empty.',
},
timeout_ms: {
type: 'number',
description: 'Maximum wait in milliseconds (default 10000, i.e. 10s). Clamped to a minimum of 100ms.',
},
},
output: {
schema: {
oneOf: [
{
type: 'object',
additionalProperties: false,
properties: {
kind: { type: 'string', required: true, const: 'found' },
needle: { type: 'string', required: true },
line: { type: 'integer', required: true, description: '0-based line index in the retained transcript where the needle first appeared.' },
column: { type: 'integer', required: true, description: '0-based column index within that line where the match starts.' },
elapsedMs: { type: 'integer', required: true, description: 'Wall-clock milliseconds from wait start to match.' },
},
},
{
type: 'object',
additionalProperties: false,
properties: {
kind: { type: 'string', required: true, const: 'timeout' },
needle: { type: 'string', required: true },
timeoutMs: { type: 'integer', required: true, description: 'The configured timeout that elapsed.' },
totalLines: { type: 'integer', required: true, description: 'Total lines retained when the timeout fired. Call terminal_read to inspect the tail.' },
},
},
{
type: 'object',
additionalProperties: false,
properties: {
kind: { type: 'string', required: true, const: 'exited' },
needle: { type: 'string', required: true },
exitCode: { oneOf: [{ type: 'integer' }, { type: 'null' }], description: 'Exit code, if known.' },
exitSignal: { oneOf: [{ type: 'string' }, { type: 'null' }], description: 'Exit signal name, if killed by a signal.' },
},
},
],
},
render: (_args, value) => {
const v = value as { kind: 'found' | 'timeout' | 'exited'; needle: string; elapsedMs?: number; timeoutMs?: number; line?: number; column?: number; exitCode?: number | null; exitSignal?: string | null }
if (v.kind === 'found') {
return [{ type: 'text', text: `Found "${v.needle}" at line ${v.line}, column ${v.column} (after ${v.elapsedMs}ms).` }]
}
if (v.kind === 'timeout') {
return [{ type: 'text', text: `Timed out after ${v.timeoutMs}ms waiting for "${v.needle}". Call terminal_read to inspect the transcript.` }]
}
const exitInfo = v.exitCode !== undefined && v.exitCode !== null ? ` (exit code ${v.exitCode})` : ''
return [{ type: 'text', text: `Terminal exited before "${v.needle}" appeared${exitInfo}.` }]
},
},
async execute(args: { uuid: string; needle: string; timeout_ms?: number }, exec) {
exec.signal.throwIfAborted()
const sessionId = sessionIdOf(exec)
registry.assertOwned(args.uuid, sessionId)
// The registry validates the needle (empty → bad-request) and returns
// the camelCase result the schema above declares directly — no
// field-by-field projection.
const timeoutMs = args.timeout_ms ?? 10_000
return await registry.waitFor(args.uuid, args.needle, timeoutMs, exec.signal)
},
}))
register(defineTool({
name: 'terminal_resize',
description:
'Resize an agent terminal\'s pty ( cols × rows ). The host clamps both to a 2..1024 sane range. '
+ 'Most shells redraw their prompt and any full-screen TUI on the next output frame. '
+ 'No-op if the terminal has exited. Returns the dimensions actually applied.',
parameters: {
uuid: { type: 'string', required: true, description: 'Terminal uuid from terminal_create or terminal_list.' },
cols: { type: 'integer', required: true, description: 'New column count ( clamped to 2..1024 ).' },
rows: { type: 'integer', required: true, description: 'New row count ( clamped to 2..1024 ).' },
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
uuid: { type: 'string', required: true },
cols: { type: 'integer', required: true },
rows: { type: 'integer', required: true },
},
},
render: textRender((v: { uuid: string; cols: number; rows: number }) =>
`Resized terminal ${v.uuid} to ${v.cols}×${v.rows}.`,
),
},
execute: (args: { uuid: string; cols: number; rows: number }, exec) => {
exec.signal.throwIfAborted()
const sessionId = sessionIdOf(exec)
registry.assertOwned(args.uuid, sessionId)
const dims = registry.resize(args.uuid, args.cols, args.rows)
return Promise.resolve({ uuid: args.uuid, ...dims })
},
}))
register(defineTool({
name: 'terminal_signal',
description:
'Send a POSIX signal to an agent terminal\'s foreground process — this is how you send Ctrl+C, Ctrl+Z, etc. '
+ 'Use signal="SIGINT" for Ctrl+C (interrupt the running command), signal="SIGTERM" to request termination, '
+ 'signal="SIGKILL" to force-kill the pty, signal="SIGHUP" to hang up (many shells exit), signal="SIGTSTP" for Ctrl+Z (suspend). '
+ 'Do NOT try to send control characters (like "\\u0003") through terminal_send — use this tool instead. '
+ 'On Windows, only SIGKILL and SIGTERM are effective — others are accepted but may no-op. '
+ 'No-op if the terminal has already exited. Use terminal_close to dispose of the terminal entirely.',
parameters: {
uuid: { type: 'string', required: true, description: 'Terminal uuid from terminal_create or terminal_list.' },
signal: {
type: 'string',
required: true,
enum: ALLOWED_SIGNALS as readonly string[],
description: 'Signal to deliver: SIGINT (Ctrl+C) | SIGTERM | SIGKILL | SIGHUP | SIGTSTP (Ctrl+Z).',
},
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
uuid: { type: 'string', required: true },
signal: { type: 'string', required: true },
},
},
render: textRender((v: { uuid: string; signal: AgentTerminalSignal }) =>
`Sent ${v.signal} to terminal ${v.uuid}.`,
),
},
execute: (args: { uuid: string; signal: AgentTerminalSignal }, exec) => {
exec.signal.throwIfAborted()
const sessionId = sessionIdOf(exec)
registry.assertOwned(args.uuid, sessionId)
registry.signal(args.uuid, args.signal)
return Promise.resolve({ uuid: args.uuid, signal: args.signal })
},
}))
register(defineTool({
name: 'terminal_close',
description:
'Close an agent terminal and release its process. The uuid becomes invalid for all subsequent tool calls. '
+ 'Idempotent: closing an already-closed uuid is a no-op. '
+ 'The corresponding sidebar tab is removed automatically when the host pushes the updated terminal list. '
+ 'Always close terminals you no longer need — the host keeps the pty alive until you do.',
parameters: {
uuid: { type: 'string', required: true, description: 'Terminal uuid from terminal_create or terminal_list.' },
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
uuid: { type: 'string', required: true },
closed: { type: 'boolean', required: true, description: 'Whether a live terminal was actually dropped (false if the uuid was already gone).' },
},
},
render: textRender((v: { uuid: string; closed: boolean }) =>
v.closed ? `Closed terminal ${v.uuid}.` : `Terminal ${v.uuid} was already closed.`,
),
},
execute: (args: { uuid: string }, exec) => {
exec.signal.throwIfAborted()
const sessionId = sessionIdOf(exec)
registry.assertOwned(args.uuid, sessionId)
const closed = registry.close(args.uuid)
return Promise.resolve({ uuid: args.uuid, closed })
},
}))
return () => {
for (const dispose of disposers) dispose()
}
}
+340
View File
@@ -0,0 +1,340 @@
/**
* AgentPtyRegistry unit tests: spawn real ptys (mirroring the pty-manager
* smoke tests) and verify uuid-keyed CRUD, transcript bounding, exit
* tracking, and change-event subscription. The "AttachConsole failed"
* console noise from node-pty's ConPTY module on Windows is expected and
* does not affect the assertions.
*/
import { describe, expect, it } from 'vitest'
import { AgentPtyRegistry, ALLOWED_SIGNALS, snapshotOf, type AgentTerminalSnapshot } from '../src/agent-pty.ts'
/**
* Resolve a shell binary for tests: on Windows use PowerShell (available on
* every modern Windows host), on POSIX use /bin/sh (universally available).
*/
function testShell(): string {
return process.platform === 'win32' ? 'powershell.exe' : '/bin/sh'
}
/** Wait for a terminal's transcript to contain a substring (or timeout). */
async function waitForTranscript(
registry: AgentPtyRegistry,
uuid: string,
needle: string,
timeoutMs = 5000,
): Promise<string> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const handle = registry.get(uuid)
if (handle !== undefined && handle.transcript.includes(needle)) return handle.transcript
await new Promise(resolve => setTimeout(resolve, 50))
}
const handle = registry.get(uuid)
return handle?.transcript ?? ''
}
describe('AgentPtyRegistry', () => {
it('creates a terminal with a uuid, writes the command to stdin, and lists it', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'echo test', 'echo hello-agent-pty', process.cwd(), 80, 24)
expect(uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)
const list = registry.list('s1')
expect(list).toHaveLength(1)
expect(list[0]!.uuid).toBe(uuid)
expect(list[0]!.title).toBe('echo test')
expect(list[0]!.command).toBe('echo hello-agent-pty')
expect(list[0]!.exited).toBe(false)
// The command was written to stdin; wait for the output.
const transcript = await waitForTranscript(registry, uuid, 'hello-agent-pty')
expect(transcript).toContain('hello-agent-pty')
} finally {
registry.disposeAll()
}
})
it('spawns a bare shell when command is empty', () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'bare', '', process.cwd(), 80, 24)
const handle = registry.get(uuid)
expect(handle).toBeDefined()
expect(handle!.command).toBe('')
expect(handle!.exited).toBe(false)
// The pty process exists; its transcript may take a moment to
// accumulate on Windows ConPTY. The handle is alive and listed.
expect(registry.list('s1')).toHaveLength(1)
} finally {
registry.disposeAll()
}
})
it('sends raw text to stdin (send-keys semantics)', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'sender', '', process.cwd(), 80, 24)
// Wait for the shell prompt, then send a command.
await waitForTranscript(registry, uuid, '', 1000)
// The registry's send() writes verbatim; the caller (tool layer) is
// responsible for appending \r when submit=true. Here we test the
// registry directly: send text + \r to submit.
registry.send(uuid, 'echo sent-via-send\r')
const transcript = await waitForTranscript(registry, uuid, 'sent-via-send')
expect(transcript).toContain('sent-via-send')
} finally {
registry.disposeAll()
}
})
it('reads a bounded page of the transcript', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'reader', 'echo line1\necho line2\necho line3', process.cwd(), 80, 24)
await waitForTranscript(registry, uuid, 'line3')
const page = registry.read(uuid)
expect(page.totalLines).toBeGreaterThan(0)
expect(page.text).toContain('line1')
expect(page.text).toContain('line3')
// Negative offset reads from the end.
const tail = registry.read(uuid, -2)
expect(tail.lineBegin).toBeGreaterThanOrEqual(0)
expect(tail.lineEnd).toBe(tail.lineBegin + tail.text.split('\n').length)
} finally {
registry.disposeAll()
}
})
it('resizes without throwing', () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'resizer', '', process.cwd(), 80, 24)
expect(() => registry.resize(uuid, 120, 40)).not.toThrow()
} finally {
registry.disposeAll()
}
})
it('resize clamps to the 2..1024 range and returns the applied dimensions', () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'clamp', '', process.cwd(), 80, 24)
// Oversized / undersized requests report the clamped values, so the
// echoed result always matches the pty (the tool contract).
expect(registry.resize(uuid, 5000, 1)).toEqual({ cols: 1024, rows: 2 })
expect(registry.resize(uuid, -3, 80.9)).toEqual({ cols: 2, rows: 80 })
expect(registry.resize(uuid, 120, 40)).toEqual({ cols: 120, rows: 40 })
// create clamps through the same helper.
const other = registry.create('s1', 'clamped-create', '', process.cwd(), 1, 9999)
expect(registry.get(other)!.pty.cols).toBe(2)
expect(registry.get(other)!.pty.rows).toBe(1024)
} finally {
registry.disposeAll()
}
})
it('assertOwned rejects a uuid owned by another session', () => {
const registry = new AgentPtyRegistry(testShell())
try {
const mine = registry.create('s1', 'mine', '', process.cwd(), 80, 24)
const theirs = registry.create('s2', 'theirs', '', process.cwd(), 80, 24)
expect(() => registry.assertOwned(mine, 's1')).not.toThrow()
// A foreign uuid is indistinguishable from an unknown one.
expect(() => registry.assertOwned(theirs, 's1')).toThrow(/not found/)
expect(() => registry.assertOwned('nope', 's1')).toThrow(/not found/)
} finally {
registry.disposeAll()
}
})
it('closes a terminal and removes it from the list', () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'closer', '', process.cwd(), 80, 24)
expect(registry.list('s1')).toHaveLength(1)
const closed = registry.close(uuid)
expect(closed).toBe(true)
expect(registry.list('s1')).toHaveLength(0)
// Closing again is a no-op.
const closedAgain = registry.close(uuid)
expect(closedAgain).toBe(false)
} finally {
registry.disposeAll()
}
})
it('scopes list by session id', () => {
const registry = new AgentPtyRegistry(testShell())
try {
registry.create('s1', 'a', '', process.cwd(), 80, 24)
registry.create('s1', 'b', '', process.cwd(), 80, 24)
registry.create('s2', 'c', '', process.cwd(), 80, 24)
expect(registry.list('s1')).toHaveLength(2)
expect(registry.list('s2')).toHaveLength(1)
expect(registry.list('s3')).toHaveLength(0)
} finally {
registry.disposeAll()
}
})
it('fires change listeners on create, close, and exit', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
let changeCount = 0
registry.subscribe(() => { changeCount += 1 })
const uuid = registry.create('s1', 'watched', '', process.cwd(), 80, 24)
// create fires one change.
expect(changeCount).toBeGreaterThanOrEqual(1)
registry.close(uuid)
// close fires another change.
expect(changeCount).toBeGreaterThanOrEqual(2)
} finally {
registry.disposeAll()
}
})
it('disposeAll closes every terminal', () => {
const registry = new AgentPtyRegistry(testShell())
registry.create('s1', 'a', '', process.cwd(), 80, 24)
registry.create('s2', 'b', '', process.cwd(), 80, 24)
registry.disposeAll()
expect(registry.list('s1')).toHaveLength(0)
expect(registry.list('s2')).toHaveLength(0)
})
it('snapshotOf drops the pty reference and transcript', () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'snap', '', process.cwd(), 80, 24)
const handle = registry.get(uuid)!
const snap = snapshotOf(handle)
expect(snap.uuid).toBe(uuid)
expect(snap.title).toBe('snap')
expect(snap.exited).toBe(false)
// No pty or transcript fields on the snapshot.
expect('pty' in snap).toBe(false)
expect('transcript' in snap).toBe(false)
// sessionId is registry-internal ownership, never serialized (it would
// violate the terminal_list output schema's additionalProperties:false).
expect('sessionId' in snap).toBe(false)
expect('sessionId' in registry.list('s1')[0]!).toBe(false)
} finally {
registry.disposeAll()
}
})
it('ALLOWED_SIGNALS includes SIGINT, SIGTERM, SIGKILL, SIGHUP, SIGTSTP', () => {
expect(ALLOWED_SIGNALS).toContain('SIGINT')
expect(ALLOWED_SIGNALS).toContain('SIGTERM')
expect(ALLOWED_SIGNALS).toContain('SIGKILL')
expect(ALLOWED_SIGNALS).toContain('SIGHUP')
expect(ALLOWED_SIGNALS).toContain('SIGTSTP')
})
it('waitFor returns found when the needle is already in the transcript', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'echo-test', 'echo wait-for-fast', process.cwd(), 80, 24)
await waitForTranscript(registry, uuid, 'wait-for-fast')
// The needle is already present; waitFor should return immediately.
const result = await registry.waitFor(uuid, 'wait-for-fast', 2000)
expect(result.kind).toBe('found')
if (result.kind === 'found') {
expect(result.needle).toBe('wait-for-fast')
expect(result.elapsedMs).toBeLessThan(500)
}
} finally {
registry.disposeAll()
}
})
it('waitFor returns found after the needle appears (async output)', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
// Spawn a shell WITH a command (so output is guaranteed), but start
// the wait for a DIFFERENT needle that only appears after we send
// a follow-up command. This exercises the subscribe/poll path: the
// wait must notice output arriving AFTER it started, not just find
// pre-existing content.
const uuid = registry.create('s1', 'async-echo', 'echo shell-ready', process.cwd(), 80, 24)
// Wait for the initial command's output so we know the shell is live.
await waitForTranscript(registry, uuid, 'shell-ready', 10_000)
// Start the wait BEFORE sending the follow-up command. The needle is
// NOT in the transcript yet. ConPTY under test concurrency can be slow,
// so the timeout is generous.
const waitPromise = registry.waitFor(uuid, 'UNIQUE_NEEDLE_42', 30_000)
// Give the wait a moment to subscribe, then send the command.
await new Promise(resolve => setTimeout(resolve, 200))
registry.send(uuid, 'echo UNIQUE_NEEDLE_42\r')
const result = await waitPromise
expect(result.kind).toBe('found')
if (result.kind === 'found') {
expect(result.needle).toBe('UNIQUE_NEEDLE_42')
}
} finally {
registry.disposeAll()
}
}, 40_000)
it('waitFor returns timeout when the needle never appears', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'silent', '', process.cwd(), 80, 24)
await waitForTranscript(registry, uuid, '', 1000)
// Wait for a needle that will never appear; use a short timeout to
// keep the test fast.
const result = await registry.waitFor(uuid, 'this-needle-will-never-appear', 500)
expect(result.kind).toBe('timeout')
if (result.kind === 'timeout') {
expect(result.timeoutMs).toBe(500)
expect(result.totalLines).toBeGreaterThan(0)
}
} finally {
registry.disposeAll()
}
})
it('waitFor throws on an empty needle', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'empty-needle', '', process.cwd(), 80, 24)
await expect(registry.waitFor(uuid, '', 500)).rejects.toThrow()
} finally {
registry.disposeAll()
}
})
it('waitFor throws on an unknown uuid', async () => {
const registry = new AgentPtyRegistry(testShell())
await expect(registry.waitFor('nonexistent-uuid', 'foo', 500)).rejects.toThrow()
})
it('delivers SIGINT and SIGTSTP by writing control characters (cross-platform)', () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'signal-test', '', process.cwd(), 80, 24)
// SIGINT and SIGTSTP are delivered by writing \x03 / \x1a to the pty
// stdin — the cross-platform way terminals send Ctrl+C / Ctrl+Z.
// These must NOT throw (the old kill(SIGINT) path threw on Windows).
expect(() => registry.signal(uuid, 'SIGINT')).not.toThrow()
expect(() => registry.signal(uuid, 'SIGTSTP')).not.toThrow()
// The control byte was written to the pty; the shell received it.
// On POSIX the line discipline generates the signal; on Windows
// ConPTY translates it. Either way the write path is exercised.
} finally {
registry.disposeAll()
}
})
it('delivers SIGKILL via the process-termination path', () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'kill-test', '', process.cwd(), 80, 24)
// SIGKILL uses pty.kill() (TerminateProcess on Windows). Must not
// throw even when the pty's kill() rejects named signals — the
// fallback to the default kill ensures the signal takes effect.
expect(() => registry.signal(uuid, 'SIGKILL')).not.toThrow()
} finally {
registry.disposeAll()
}
})
})
+59
View File
@@ -0,0 +1,59 @@
/**
* Browser globals for specs that pull browser-bound modules. Import this
* FIRST in a spec file: unlike statements after imports (which vitest
* hoists above the module body), an import statement evaluates in order,
* so this runs before any later import's module graph (xterm's UMD wrapper
* checks `self` and its color defaults probe `document` at evaluation time;
* window/localStorage are only touched at runtime by the store).
*/
const g = globalThis as Record<string, unknown>
if (g.self === undefined) g.self = globalThis
const elementStub = (): Record<string, unknown> => ({
style: {},
classList: { add: () => {}, remove: () => {}, contains: () => false },
setAttribute: () => {},
getAttribute: () => null,
appendChild: () => {},
addEventListener: () => {},
removeEventListener: () => {},
getBoundingClientRect: () => ({ left: 0, top: 0, width: 0, height: 0 }),
// xterm's color palette probe measures a canvas context at module load.
getContext: () => null,
})
if (g.document === undefined) {
g.document = {
createElement: () => elementStub(),
createDocumentFragment: () => elementStub(),
addEventListener: () => {},
removeEventListener: () => {},
defaultView: globalThis,
body: elementStub(),
// CodeMirror's view module probes the UA and injects styles at load.
documentElement: elementStub(),
head: elementStub(),
styleSheets: [],
}
}
if (g.navigator === undefined) {
g.navigator = { userAgent: 'node', platform: 'node', vendor: '', maxTouchPoints: 0 }
}
if (g.window === undefined) {
g.window = {
clearTimeout: () => {},
setTimeout: (_fn: () => void) => 0,
innerWidth: 1024,
getComputedStyle: () => ({ getPropertyValue: () => '' }),
}
}
if (g.localStorage === undefined) {
g.localStorage = {
getItem: () => null,
setItem: () => {},
}
}
+129
View File
@@ -0,0 +1,129 @@
/**
* Built-in registration tests: the plugin registers 6 tabs and 8 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.
*/
import { describe, expect, it } from 'vitest'
// First import: browser globals before the xterm-carrying builtin graph loads.
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 { registerBuiltins } from '../src/client/builtins/index.ts'
function setup(): { service: ReturnType<typeof createBetterSidebarService>; dispose: () => void } {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const dispose = registerBuiltins({} as Context, service)
return { service, dispose }
}
describe('built-in tab registrations', () => {
it('registers the 6 built-in tabs', () => {
const { service } = setup()
expect(service.getTabs().map(t => t.id).sort()).toEqual(
['diff', 'editor', 'explorer', 'git', 'subagent', 'terminal'],
)
})
it('editor and diff are hidden from the + menu (opened by file-open / git view)', () => {
const { service } = setup()
expect(service.getTabs().filter(t => t.hidden).map(t => t.id).sort()).toEqual(['diff', 'editor'])
})
it('single-instance tabs use the single sugar', () => {
const { service } = setup()
for (const id of ['explorer', 'git', 'subagent']) {
expect(service.getTab(id)?.single).toBe(true)
}
})
it('the subagent tab declares its auto-open related setting', () => {
const { service } = setup()
const toggles = service.getTab('subagent')?.settings?.toggles ?? []
expect(toggles.map(t => t.key)).toEqual(['autoOpenSubagent'])
})
it('the terminal tab declares the model terminal-tools related setting', () => {
const { service } = setup()
const toggles = service.getTab('terminal')?.settings?.toggles ?? []
expect(toggles.map(t => t.key)).toEqual(['agentTerminalTools'])
})
it('every built-in tab carries the settings-surface icon', () => {
const { service } = setup()
for (const tab of service.getTabs()) {
expect(tab.icon, tab.id).toBeDefined()
}
})
})
describe('built-in file viewer registrations', () => {
it('registers the 8 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'],
)
})
it('code is the catch-all at the lowest priority', () => {
const { service } = setup()
const code = service.getFileViewers().find(v => v.id === 'code')
expect(code?.exts).toEqual([])
expect(code?.priority).toBe(-100)
expect(code?.fetchStrategy).toBe('fsRead')
expect(service.matchFileViewer('anything.zzz')?.id).toBe('code')
})
it('markdown claims md/markdown before the catch-all', () => {
const { service } = setup()
expect(service.matchFileViewer('readme.md')?.id).toBe('markdown')
expect(service.matchFileViewer('readme.markdown')?.id).toBe('markdown')
expect(service.matchFileViewer('readme.md', new Uint8Array([0x61]))?.id).toBe('markdown')
})
it('binary-download claims legacy office by extension', () => {
const { service } = setup()
expect(service.matchFileViewer('old.doc')?.id).toBe('binary-download')
expect(service.matchFileViewer('old.xls')?.id).toBe('binary-download')
expect(service.matchFileViewer('old.ppt')?.id).toBe('binary-download')
})
it('binary-download NUL detect claims unknown-extension binaries over code', () => {
const { service } = setup()
// First match (no head) falls to the catch-all code viewer...
expect(service.matchFileViewer('blob.zzz')?.id).toBe('code')
// ...but the head re-match (NUL probe) routes it to binary-download.
expect(service.matchFileViewer('blob.zzz', new Uint8Array([0x01, 0x00, 0x02]))?.id).toBe('binary-download')
// A NUL-free blob stays with code.
expect(service.matchFileViewer('blob.zzz', new Uint8Array([0x61, 0x62]))?.id).toBe('code')
})
it('office mediaUrl viewers beat the binary sniffers for their own extensions', () => {
const { service } = setup()
// A .docx is a zip (no NUL in its head): the docx viewer (priority 0)
// claims it before binary-download (-50) is consulted.
expect(service.matchFileViewer('book.docx', new Uint8Array([0x50, 0x4b, 0x03, 0x04]))?.id).toBe('docx')
})
it('every built-in viewer carries the declarative settings surface (title + icon)', () => {
const { service } = setup()
for (const viewer of service.getFileViewers()) {
expect(viewer.title, viewer.id).toBeDefined()
expect(viewer.icon, viewer.id).toBeDefined()
}
})
})
describe('built-in disposer', () => {
it('unregisters everything (HMR-safe)', () => {
const { service, dispose } = setup()
dispose()
expect(service.getTabs()).toHaveLength(0)
expect(service.getFileViewers()).toHaveLength(0)
// The disposer is idempotent.
dispose()
})
})
+100
View File
@@ -0,0 +1,100 @@
/**
* Tests for the editor-load planning: the pure strategy dispatch the
* editor host runs (planFirstMatch / planFsReadOutcome / decodeHead).
*/
import { describe, expect, it } from 'vitest'
import { decodeHead, planFirstMatch, planFsReadOutcome } from '../src/client/editor-load.ts'
import type { FileViewerDescriptor } from '../src/client/service.ts'
const viewer = (over: Partial<FileViewerDescriptor>): FileViewerDescriptor => ({
id: 'v',
exts: [],
fetchStrategy: 'fsRead',
component: () => null,
...over,
})
describe('planFirstMatch', () => {
it('no viewer → binary (download UI)', () => {
expect(planFirstMatch(undefined, () => '/m')).toEqual({ kind: 'binary' })
})
it('binary-download strategy → binary', () => {
const v = viewer({ id: 'dl', fetchStrategy: 'binary-download' })
expect(planFirstMatch(v, () => '/m')).toEqual({ kind: 'binary' })
})
it('mediaUrl / none → render with the media URL', () => {
const v = viewer({ id: 'img', fetchStrategy: 'mediaUrl' })
expect(planFirstMatch(v, () => '/media')).toEqual({ kind: 'render', viewer: v, mediaUrl: '/media' })
expect(planFirstMatch(viewer({ id: 'none', fetchStrategy: 'none' }), () => '/m').kind).toBe('render')
})
it('custom → customLoad (the viewer loads its own bytes)', () => {
const v = viewer({ id: 'csv', fetchStrategy: 'custom', load: async () => [] })
expect(planFirstMatch(v, () => '/m')).toEqual({ kind: 'customLoad', viewer: v })
})
it('fsRead → fetchFsRead', () => {
const v = viewer({ id: 'code', fetchStrategy: 'fsRead' })
expect(planFirstMatch(v, () => '/m')).toEqual({ kind: 'fetchFsRead', viewer: v })
})
})
describe('planFsReadOutcome', () => {
const rematch = (head: Uint8Array): FileViewerDescriptor | undefined => {
// The builtin binary-download NUL probe shape.
if (head.includes(0)) return viewer({ id: 'binary-download', fetchStrategy: 'binary-download' })
return undefined
}
it('text result renders with content, same viewer', () => {
const v = viewer({ id: 'code' })
const action = planFsReadOutcome(v, { binary: false, content: 'hello', truncated: true }, rematch, () => '/m')
expect(action).toEqual({ kind: 'render', viewer: v, content: 'hello', truncated: true })
})
it('binary without head bytes → binary', () => {
expect(planFsReadOutcome(viewer({}), { binary: true, content: '', truncated: false }, rematch, () => '/m')).toEqual({ kind: 'binary' })
})
it('binary whose NUL head re-matches nothing renderable → binary', () => {
const action = planFsReadOutcome(
viewer({}),
{ binary: true, content: '', truncated: false, head: 'AAAAAA==' }, // 0x00 bytes
rematch,
() => '/m',
)
// binary-download claims it — still the download UI.
expect(action).toEqual({ kind: 'binary' })
})
it('binary re-matched to a custom viewer → customLoad (external sniffer)', () => {
const sniffer = viewer({ id: 'my:raw', fetchStrategy: 'custom', load: async () => ({}) })
const action = planFsReadOutcome(
viewer({}),
{ binary: true, content: '', truncated: false, head: 'AAAAAA==' },
() => sniffer,
() => '/m',
)
expect(action).toEqual({ kind: 'customLoad', viewer: sniffer })
})
it('binary re-matched to a mediaUrl viewer → render with the media URL', () => {
const media = viewer({ id: 'my:binimg', fetchStrategy: 'mediaUrl' })
const action = planFsReadOutcome(
viewer({}),
{ binary: true, content: '', truncated: false, head: 'AAAAAA==' },
() => media,
() => '/m?x=1',
)
expect(action).toEqual({ kind: 'render', viewer: media, mediaUrl: '/m?x=1' })
})
})
describe('decodeHead', () => {
it('decodes base64 to the exact bytes', () => {
const head = decodeHead('AP//AA==')
expect(Array.from(head)).toEqual([0x00, 0xff, 0xff, 0x00])
})
})
+87
View File
@@ -0,0 +1,87 @@
/**
* Orphaned-tab tests: a persisted tab whose type is not registered must
* survive sanitize (title/path kept for recovery) and render the graceful
* placeholder instead of crashing — the degradation path of the open
* tab-type set (a contributing plugin not installed / not yet loaded).
*/
import { describe, expect, it } from 'vitest'
import { renderToString } from 'react-dom/server'
import { OrphanedTab } from '../src/client/OrphanedTab.tsx'
import { sanitizeState } from '../src/client/state.ts'
/** Collect every tab type in a sanitized tree (leaf/split recursive). */
function typesOf(node: unknown): string[] {
if (node === null || typeof node !== 'object') return []
const record = node as { kind?: unknown; tabs?: unknown; children?: unknown }
if (record.kind === 'leaf' && Array.isArray(record.tabs)) {
return (record.tabs as Array<{ type: string }>).map(tab => tab.type)
}
if (Array.isArray(record.children)) return record.children.flatMap(typesOf)
return []
}
describe('sanitize keeps unregistered tab types', () => {
it('preserves a persisted tab of an unknown type with its title/path', () => {
const raw = {
panelOpen: true,
width: 400,
nextTerminal: 1,
activePane: null,
expanded: [],
splits: {
kind: 'leaf',
id: 'pane:1',
tabs: [
{ id: 'tab:1', type: 'my-plugin:db', title: 'Database', path: '/data.sqlite' },
{ id: 'tab:2', type: 'explorer', title: 'Explorer' },
],
active: 'tab:1',
},
}
const clean = sanitizeState(raw)
expect(clean).toBeDefined()
expect(typesOf(clean!.splits)).toContain('my-plugin:db')
// The kept tab keeps its payload so a later registration can reuse it.
const leaf = clean!.splits as unknown as { kind: 'leaf'; tabs: Array<Record<string, unknown>> }
const kept = leaf.tabs.find(tab => tab.type === 'my-plugin:db')
expect(kept).toMatchObject({ title: 'Database', path: '/data.sqlite' })
})
it('an unknown type alone in a pane still sanitizes cleanly', () => {
const raw = {
panelOpen: true,
width: 400,
nextTerminal: 1,
activePane: null,
expanded: [],
splits: {
kind: 'leaf',
id: 'pane:1',
tabs: [{ id: 'tab:1', type: 'some-plugin:page', title: 'Page' }],
active: 'tab:1',
},
}
const clean = sanitizeState(raw)
expect(clean).toBeDefined()
expect(typesOf(clean!.splits)).toEqual(['some-plugin:page'])
})
})
describe('OrphanedTab placeholder', () => {
const tab = { id: 'tab:1', type: 'my-plugin:db', title: 'Database' }
it('renders the unregistered type and keeps the title', () => {
const html = renderToString((
<OrphanedTab
ctx={undefined as never}
store={undefined as never}
scope={{ sessionId: 's1', cwd: '/p' }}
tab={tab}
visible={false}
/>
))
expect(html).toContain('my-plugin:db')
expect(html).toContain('Database')
expect(html).not.toContain('undefined')
})
})
+8 -2
View File
@@ -17,7 +17,7 @@ describe('dsh-better-sidebar plugin export shape', () => {
const unwrapped = loader.unwrapExports(sidebar) as Record<string, unknown>
expect(unwrapped).toBe(sidebar)
expect(unwrapped.name).toBe('dsh-better-sidebar')
expect(unwrapped.inject).toEqual(['httpServer', 'sessions', 'loader'])
expect(unwrapped.inject).toEqual(['httpServer', 'sessions', 'loader', 'tools'])
expect(unwrapped.Config).toBeDefined()
expect(typeof unwrapped.apply).toBe('function')
})
@@ -45,12 +45,18 @@ describe('dsh-better-sidebar plugin export shape', () => {
expect(resolved.openByDefault).toBe(true)
expect(resolved.defaultWidthPercent).toBe(30)
expect(resolved.autoOpenSubagent).toBe(true)
// The terminal tools default OFF (the feature is dormant until the user
// enables it in the side card settings).
expect(resolved.agentTerminalTools).toBe(false)
// The enable-switch maps resolve to {} (everything on) for old documents.
expect(resolved.tabsEnabled).toEqual({})
expect(resolved.viewersEnabled).toEqual({})
// A stored overridden value resolves through (the range contract is
// enforced by the settings service on write); the new pref keeps its
// default when the stored document predates it.
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 })
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, tabsEnabled: {}, viewersEnabled: {} })
})
})
+394
View File
@@ -0,0 +1,394 @@
/**
* Tests for the BetterSidebar service registry: register/dispose lifecycle,
* matchFileViewer priority/exts/detect algorithm, and openTab dedupe.
*/
import { describe, it, expect } from 'vitest'
// Mock browser globals (SidebarStore.reduce → schedulePersist uses window.setTimeout)
const g = globalThis as Record<string, unknown>
if (g.window === undefined) {
g.window = {
clearTimeout: () => {},
setTimeout: (_fn: () => void) => 0,
innerWidth: 1024,
}
}
if (g.localStorage === undefined) {
g.localStorage = {
getItem: () => null,
setItem: () => {},
}
}
import { createBetterSidebarService } from '../src/client/service.ts'
import { createSidebarStore, allLeaves, openDiffTab } from '../src/client/state.ts'
describe('BetterSidebar service', () => {
it('registerTab adds to the registry and dispose removes it', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
expect(service.getTabs()).toHaveLength(0)
const dispose = service.registerTab({
id: 'test:tab',
title: 'Test',
component: () => null,
})
expect(service.getTabs()).toHaveLength(1)
expect(service.getTab('test:tab')?.id).toBe('test:tab')
dispose()
expect(service.getTabs()).toHaveLength(0)
expect(service.getTab('test:tab')).toBeUndefined()
})
it('registerTab throws on duplicate id', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'dup', title: 'A', component: () => null })
expect(() => service.registerTab({ id: 'dup', title: 'B', component: () => null })).toThrow()
})
it('registerFileViewer adds and dispose removes', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
expect(service.getFileViewers()).toHaveLength(0)
const dispose = service.registerFileViewer({
id: 'csv',
exts: ['csv'],
fetchStrategy: 'custom',
component: () => null,
})
expect(service.getFileViewers()).toHaveLength(1)
dispose()
expect(service.getFileViewers()).toHaveLength(0)
})
it('subscribe fires on register and dispose', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
let calls = 0
const unsub = service.subscribe(() => { calls++ })
const dispose = service.registerTab({ id: 'x', title: 'X', component: () => null })
expect(calls).toBe(1)
dispose()
expect(calls).toBe(2)
unsub()
service.registerTab({ id: 'y', title: 'Y', component: () => null })
expect(calls).toBe(2)
})
})
describe('enable switches (declarative settings)', () => {
/** A fresh store + service with one tab and one viewer registered. */
const setup = () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'explorer', title: 'Explorer', component: () => null })
service.registerFileViewer({ id: 'image', exts: ['png'], fetchStrategy: 'mediaUrl', component: () => null })
return { store, service }
}
it('an absent map key means enabled (default state)', () => {
const { service } = setup()
expect(service.isTabEnabled('explorer')).toBe(true)
expect(service.isViewerEnabled('image')).toBe(true)
// Unknown ids are enabled too (nothing registered → the check is open).
expect(service.isTabEnabled('whatever')).toBe(true)
})
it('only an explicit false disables a tab type', () => {
const { store, service } = setup()
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { explorer: false } })
expect(service.isTabEnabled('explorer')).toBe(false)
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { explorer: true } })
expect(service.isTabEnabled('explorer')).toBe(true)
})
it('openTab refuses a disabled tab type (no tab lands, no createTab)', () => {
const { store, service } = setup()
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { explorer: false } })
store.setSession('s1')
service.openTab({ type: 'explorer', title: 'Explorer' })
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs)
expect(tabs).toHaveLength(0)
})
it('matchFileViewer skips a disabled viewer (files fall through)', () => {
const { store, service } = setup()
service.registerFileViewer({ id: 'code', exts: [], priority: -100, fetchStrategy: 'fsRead', component: () => null })
// image claims .png; disabling it lets the catch-all take over.
expect(service.matchFileViewer('photo.png')?.id).toBe('image')
store.setPrefs({ ...store.getPrefs(), viewersEnabled: { image: false } })
expect(service.matchFileViewer('photo.png')?.id).toBe('code')
// Disabling the catch-all too → no viewer at all.
store.setPrefs({ ...store.getPrefs(), viewersEnabled: { image: false, code: false } })
expect(service.matchFileViewer('photo.png')).toBeUndefined()
// Re-enabling restores the image viewer.
store.setPrefs({ ...store.getPrefs(), viewersEnabled: {} })
expect(service.matchFileViewer('photo.png')?.id).toBe('image')
})
})
describe('matchFileViewer', () => {
it('matches by extension', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerFileViewer({ id: 'img', exts: ['png', 'jpg'], fetchStrategy: 'mediaUrl', component: () => null })
expect(service.matchFileViewer('photo.png')?.id).toBe('img')
expect(service.matchFileViewer('photo.JPG')?.id).toBe('img')
expect(service.matchFileViewer('doc.txt')).toBeUndefined()
})
it('higher priority wins on extension conflict', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerFileViewer({ id: 'basic', exts: ['png'], priority: 0, fetchStrategy: 'mediaUrl', component: () => null })
service.registerFileViewer({ id: 'advanced', exts: ['png'], priority: 10, fetchStrategy: 'custom', component: () => null })
expect(service.matchFileViewer('x.png')?.id).toBe('advanced')
})
it('catch-all (exts: []) matches anything at lowest priority', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerFileViewer({ id: 'catchall', exts: [], priority: -100, fetchStrategy: 'fsRead', component: () => null })
service.registerFileViewer({ id: 'img', exts: ['png'], priority: 0, fetchStrategy: 'mediaUrl', component: () => null })
expect(service.matchFileViewer('x.png')?.id).toBe('img')
expect(service.matchFileViewer('x.txt')?.id).toBe('catchall')
})
it('detect claims files the viewer would otherwise miss, at its priority', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
// by-magic does not match 'bin' by extension; only its detect (PNG magic)
// can claim it — and only when head bytes are available.
service.registerFileViewer({ id: 'by-ext', exts: ['bin'], priority: 5, fetchStrategy: 'fsRead', component: () => null })
service.registerFileViewer({
id: 'by-magic',
exts: ['mag'],
priority: 10,
fetchStrategy: 'fsRead',
detect: (_path, head) => head[0] === 0x89,
component: () => null,
})
// No head: by-magic's exts miss, by-ext claims .bin.
expect(service.matchFileViewer('file.bin')?.id).toBe('by-ext')
// Head with PNG magic: by-magic's detect fires at priority 10 before
// by-ext (5) is consulted.
expect(service.matchFileViewer('file.bin', new Uint8Array([0x89, 0x50]))?.id).toBe('by-magic')
// Head without the magic: detect misses, by-ext claims .bin again.
expect(service.matchFileViewer('file.bin', new Uint8Array([0x00, 0x50]))?.id).toBe('by-ext')
})
it('priority decides first: a higher-priority exts match beats a lower-priority detect', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerFileViewer({ id: 'by-ext', exts: ['bin'], priority: 10, fetchStrategy: 'fsRead', component: () => null })
service.registerFileViewer({
id: 'by-magic',
exts: ['mag'],
priority: 5,
fetchStrategy: 'fsRead',
detect: (_path, head) => head[0] === 0x89,
component: () => null,
})
// Per-descriptor walk: by-ext (priority 10) claims .bin before by-magic's
// detect (priority 5) is ever consulted — the design's priority-first rule.
expect(service.matchFileViewer('file.bin', new Uint8Array([0x89, 0x50]))?.id).toBe('by-ext')
})
it('a catch-all with detect is sniff-only: it never blind-claims without head', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerFileViewer({ id: 'img', exts: ['png'], priority: 0, fetchStrategy: 'mediaUrl', component: () => null })
service.registerFileViewer({
id: 'magic-sniffer',
exts: [],
priority: 100,
fetchStrategy: 'custom',
detect: (_path, head) => head[0] === 0x89,
component: () => null,
})
// No head: the sniff-only catch-all yields — the real png viewer wins.
expect(service.matchFileViewer('photo.png')?.id).toBe('img')
// Head with the magic: the sniffer claims it (detect at priority 100).
expect(service.matchFileViewer('photo.png', new Uint8Array([0x89, 0x50]))?.id).toBe('magic-sniffer')
// Head without the magic: the sniffer yields again, img claims it.
expect(service.matchFileViewer('photo.png', new Uint8Array([0x00, 0x50]))?.id).toBe('img')
})
it('returns undefined when no viewer matches and no catch-all', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerFileViewer({ id: 'img', exts: ['png'], fetchStrategy: 'mediaUrl', component: () => null })
expect(service.matchFileViewer('doc.txt')).toBeUndefined()
})
})
describe('service.openTab dedupe', () => {
it('dedupeKey focuses existing tab instead of duplicating', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({
id: 'singleton',
title: 'Singleton',
dedupeKey: () => 'singleton',
component: () => null,
})
store.setSession('s1')
service.openTab({ type: 'singleton', title: 'Singleton' })
service.openTab({ type: 'singleton', title: 'Singleton' })
const state = store.getSnapshot().state!
const tabs = allLeaves(state.splits).flatMap(l => l.tabs)
expect(tabs.filter(t => t.type === 'singleton')).toHaveLength(1)
})
it('no dedupeKey opens a new tab for each distinct id', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({
id: 'multi',
title: 'Multi',
component: () => null,
})
store.setSession('s1')
service.openTab({ type: 'multi', title: 'Multi', id: 'multi:1' })
service.openTab({ type: 'multi', title: 'Multi', id: 'multi:2' })
const state = store.getSnapshot().state!
const tabs = allLeaves(state.splits).flatMap(l => l.tabs)
expect(tabs.filter(t => t.type === 'multi')).toHaveLength(2)
})
it('reopening with the same id focuses the existing tab (id safety net)', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({
id: 'multi',
title: 'Multi',
component: () => null,
})
store.setSession('s1')
service.openTab({ type: 'multi', title: 'Multi', id: 'multi:1' })
service.openTab({ type: 'multi', title: 'Multi', id: 'multi:1' })
const state = store.getSnapshot().state!
const tabs = allLeaves(state.splits).flatMap(l => l.tabs)
expect(tabs.filter(t => t.type === 'multi')).toHaveLength(1)
})
it('createTab mints custom ids and patches state', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({
id: 'counter',
title: 'Counter',
createTab: (state) => ({
tab: { id: `counter:${state.nextTerminal}`, type: 'counter', title: `C${state.nextTerminal}` },
patch: { nextTerminal: state.nextTerminal + 1 },
}),
component: () => null,
})
store.setSession('s1')
service.openTab({ type: 'counter', title: 'Counter' })
service.openTab({ type: 'counter', title: 'Counter' })
const state = store.getSnapshot().state!
const tabs = allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'counter')
expect(tabs).toHaveLength(2)
expect(tabs[0]!.id).toBe('counter:1')
expect(tabs[1]!.id).toBe('counter:2')
expect(state.nextTerminal).toBe(3)
})
it('a caller-provided title wins over the descriptor title (editor shows the file name)', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'editor', title: () => 'Editor', component: () => null })
store.setSession('s1')
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
const state = store.getSnapshot().state!
const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'editor')
expect(tab?.title).toBe('main.ts')
})
it('the descriptor title is the default when no title is given', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'plain', title: () => 'Plain', component: () => null })
store.setSession('s1')
service.openTab({ type: 'plain' })
const state = store.getSnapshot().state!
const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'plain')
expect(tab?.title).toBe('Plain')
})
it('single: true dedupes like dedupeKey: () => id (sugar)', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'singleton', title: 'Singleton', single: true, component: () => null })
store.setSession('s1')
service.openTab({ type: 'singleton' })
service.openTab({ type: 'singleton', id: 'singleton:extra' })
const state = store.getSnapshot().state!
const tabs = allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'singleton')
expect(tabs).toHaveLength(1)
})
it('an explicit dedupeKey wins over single: true', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({
id: 'multi',
title: 'Multi',
single: true,
dedupeKey: (tab) => tab.id, // per-id, not per-type: two tabs coexist
component: () => null,
})
store.setSession('s1')
service.openTab({ type: 'multi', id: 'multi:1' })
service.openTab({ type: 'multi', id: 'multi:2' })
const state = store.getSnapshot().state!
const tabs = allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'multi')
expect(tabs).toHaveLength(2)
service.openTab({ type: 'multi', id: 'multi:1' })
const state2 = store.getSnapshot().state!
const tabs2 = allLeaves(state2.splits).flatMap(l => l.tabs).filter(t => t.type === 'multi')
expect(tabs2).toHaveLength(2)
})
it('available receives ctx, scope and the live state (superset signature)', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const seen: unknown[] = []
service.registerTab({
id: 'gated',
title: 'Gated',
available: (ctx, scope, state) => {
seen.push([ctx, scope, state])
return true
},
component: () => null,
})
store.setSession('s1')
expect(service.getTabs()[0]!.available?.({} as never, { sessionId: 's1', cwd: '/p' }, store.getSnapshot().state!)).toBe(true)
expect(seen).toHaveLength(1)
})
it('openDiffTab and the service dedupeKey agree on diff identity (per-change id rule)', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({
id: 'diff',
title: 'Diff',
dedupeKey: (tab) => tab.id,
component: () => null,
})
store.setSession('s1')
const seed = { kind: 'worktree' as const, path: '/p/a.ts', staged: false }
const paneId = store.getSnapshot().state!.activePane!
// The git view's placement path (split surgery) opens the diff tab...
store.reduce(s => openDiffTab(s, paneId, { id: 'diff:1', type: 'diff', title: 'a.ts', diff: seed }))
// ...and a service open of the same change focuses the existing tab
// (the descriptor's dedupeKey is the same per-change id rule).
service.openTab({ type: 'diff', title: 'a.ts', diff: seed, id: 'diff:1' })
const state = store.getSnapshot().state!
const tabs = allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'diff')
expect(tabs).toHaveLength(1)
})
})
+156
View File
@@ -0,0 +1,156 @@
/**
* Side card settings section render tests: the section is DECLARATIVE —
* every small card (icon, title, type id, extensions, on/off state) derives
* from the sidebar service's tab/viewer registries instead of hardcoded
* copy. The toggles are CARDS in a responsive grid: the card's main area is
* the switch, the visual state IS the state (highlighted = enabled),
* announced via `aria-pressed`, and the check badge sits at the far right.
* Features that declare related settings carry a gear corner button whose
* popup rows (native checkboxes) are tested through the extracted
* FeatureSettingsRows component (the Modal portal renders only while open).
*
* Rendered with renderToString (mount effects — the settings RPC sync — do
* not run in SSR; the initial store prefs are the render input).
*/
import { describe, expect, it } from 'vitest'
import { renderToString } from 'react-dom/server'
import { createElement } from 'react'
import { createSidebarStore, type SidebarStore } from '../src/client/state.ts'
import { createBetterSidebarService, type BetterSidebarService } from '../src/client/service.ts'
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
import { FeatureSettingsRows, SideCardSection, type SideCardSectionProps } from '../src/client/SideCardSection.tsx'
/** One tab + one viewer + the subagent-style nested toggle under a tab. */
function mount(): { store: SidebarStore; service: BetterSidebarService } {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({
id: 'explorer',
title: () => 'Explorer',
icon: () => createElement('svg', { 'data-icon': 'explorer' }),
order: 10,
component: () => null,
})
service.registerTab({
id: 'subagent',
title: () => 'Subagents',
icon: () => createElement('svg', { 'data-icon': 'subagent' }),
order: 30,
settings: {
toggles: [{
key: 'autoOpenSubagent',
title: () => 'Auto-open Subagents',
desc: () => 'Expand on new subagent',
}],
},
component: () => null,
})
service.registerFileViewer({
id: 'image',
title: () => 'Image',
icon: () => createElement('svg', { 'data-icon': 'image' }),
exts: ['png', 'jpg'],
fetchStrategy: 'mediaUrl',
component: () => null,
})
return { store, service }
}
function renderSection(store: SidebarStore, service: BetterSidebarService): string {
return renderToString(createElement(
SideCardSection,
{ store, service } as unknown as SideCardSectionProps,
))
}
/** Count `aria-pressed` occurrences of one value in the rendered HTML. */
function pressedCount(html: string, value: string): number {
return html.match(new RegExp(`aria-pressed="${value}"`, 'g'))?.length ?? 0
}
describe('SideCardSection declarative inventory', () => {
it('renders one small card per registered tab: icon + title + type id + pressed state', () => {
const { store, service } = mount()
const html = renderSection(store, service)
expect(html).toContain('data-icon="explorer"')
expect(html).toContain('>Explorer<')
// The type id is the card's desc (the declarative "type" surface).
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)
expect(pressedCount(html, 'false')).toBe(0)
expect(html).not.toContain('Auto-open Subagents')
})
it('renders one small card per registered viewer: icon + title + exts', () => {
const { store, service } = mount()
const html = renderSection(store, service)
expect(html).toContain('data-icon="image"')
expect(html).toContain('>Image<')
// The covered extensions are the card's desc.
expect(html).toContain('png · jpg')
})
it('renders the gear corner button on features that declare related settings', () => {
const { store, service } = mount()
const html = renderSection(store, service)
// Subagents declares a toggle → its card carries the settings gear
// (aria-label = "<title> Feature settings"); Explorer and Image declare
// none → no gear.
expect(html.match(/aria-label="[^"]*Feature settings"/g)?.length).toBe(1)
})
it('a disabled feature renders pressed=false', () => {
const { store, service } = mount()
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { subagent: false }, viewersEnabled: { image: false } })
const html = renderSection(store, service)
expect(html).toContain('>Subagents<')
expect(html).toContain('>Image<')
expect(pressedCount(html, 'false')).toBe(2)
expect(pressedCount(html, 'true')).toBe(2)
})
it('hides the gear of a disabled feature (its related settings are dormant)', () => {
const { store, service } = mount()
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { subagent: false } })
const html = renderSection(store, service)
expect(html).not.toContain('Feature settings')
})
})
describe('FeatureSettingsRows (the secondary settings popup body)', () => {
const prefs: typeof SIDEBAR_PREFS_DEFAULTS = {
...SIDEBAR_PREFS_DEFAULTS,
autoOpenSubagent: false,
}
const toggles = [{
key: 'autoOpenSubagent',
title: () => 'Auto-open Subagents',
desc: () => 'Expand on new subagent',
}]
it('renders one native checkbox row per declared toggle with its current value', () => {
const html = renderToString(createElement(FeatureSettingsRows, {
toggles,
prefs,
onToggle: () => {},
}))
expect(html).toContain('Auto-open Subagents')
expect(html).toContain('Expand on new subagent')
// The checkbox reflects the prefs value (false here → unchecked).
expect(html).not.toContain('checked=""')
})
it('checks the row when the pref is on', () => {
const html = renderToString(createElement(FeatureSettingsRows, {
toggles,
prefs: { ...prefs, autoOpenSubagent: true },
onToggle: () => {},
}))
expect(html).toContain('checked=""')
})
})
+235 -5
View File
@@ -4,8 +4,10 @@
* actual repository, and a real directory listing. Runs with `pnpm test`.
*/
import { describe, expect, it } from 'vitest'
import { spawnSync } from 'node:child_process'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { resolve as resolvePath } from 'node:path'
import { join, resolve as resolvePath } from 'node:path'
import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-settings'
import { apply, mediaTypeForPath } from '../src/index.ts'
import * as git from '../src/git.ts'
@@ -20,6 +22,7 @@ interface FakeContext {
registerUpgrade: (route: SidebarWebUpgradeRoute) => () => void
}
sessions: { get: (id: string) => { header: { cwd?: string } } | undefined }
tools: { register: (tool: unknown) => () => void }
effect: (fn: () => void | (() => void), label?: string) => void
/** The settings service never appears in the smoke context: the inject
* callback must never run (mirror of cordis' service-less inject). */
@@ -43,6 +46,7 @@ describe('host plugin smoke', () => {
registerUpgrade: (route) => { upgrades.push(route); return () => {} },
},
sessions: { get: () => undefined },
tools: { register: () => () => {} },
// The DSH-vendored cordis runs the registration effect immediately and
// keeps its cleanup for disposal.
effect: (fn) => {
@@ -55,7 +59,7 @@ describe('host plugin smoke', () => {
}
apply(ctx as never)
expect(routes.map(route => route.path)).toEqual(['/sidebar/api', '/sidebar/file'])
expect(upgrades.map(route => route.path)).toEqual(['/sidebar/ws/terminal'])
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()
})
@@ -73,6 +77,31 @@ describe('host plugin smoke', () => {
expect(branches.names).toContain(branches.current)
})
it('enriches the log (full hash + refs) and renders commit diffs', async () => {
const cwd = process.cwd()
const log = await git.log(cwd)
const first = log[0]!
expect(first.hashFull).toMatch(/^[0-9a-f]{40}$/)
expect(typeof first.refs).toBe('string')
const patch = await git.commitDiff(cwd, first.hashFull)
expect(patch).toContain('diff --git')
})
it('pages the log lazily with skip/count', async () => {
const cwd = process.cwd()
const first = await git.log(cwd, 5, 0)
expect(first).toHaveLength(5)
const second = await git.log(cwd, 5, 5)
expect(second).toHaveLength(5)
// The pages are disjoint windows over the same ordered history.
expect(first[0]!.hashFull).not.toBe(second[0]!.hashFull)
const all = await git.log(cwd, 10, 0)
expect(all.slice(0, 5)).toEqual(first)
expect(all.slice(5)).toEqual(second)
// A skip past the end returns an empty page (the lazy loader's stop sign).
expect(await git.log(cwd, 5, 10_000)).toEqual([])
})
it('pty manager releases the quota on close and respawns after exit', async () => {
const manager = new PtyManager(defaultShell(), 3)
try {
@@ -163,6 +192,110 @@ describe('host plugin smoke', () => {
})
})
/**
* Destructive git operations (discard / revert / cherry-pick) run against a
* throwaway repository under the OS temp dir — never the plugin repo. The
* fixture's commit identity comes from the GIT_AUTHOR / GIT_COMMITTER
* environment variables, confined to the fixture process: no git config is
* touched anywhere (the plugin never sets an identity, and neither does its
* test fixture).
*/
describe('git destructive operations (scratch repository)', () => {
const FIXTURE_IDENTITY = {
GIT_AUTHOR_NAME: 'dsh-better-sidebar-test',
GIT_AUTHOR_EMAIL: 'test@dsh.invalid',
GIT_COMMITTER_NAME: 'dsh-better-sidebar-test',
GIT_COMMITTER_EMAIL: 'test@dsh.invalid',
}
const gitRun = (cwd: string, args: string[]): string => {
const result = spawnSync('git', ['-C', cwd, '--no-pager', '-c', 'color.ui=false', ...args], {
encoding: 'utf8',
env: { ...process.env, ...FIXTURE_IDENTITY },
})
if (result.status !== 0) {
throw new Error(result.stderr || `git ${args[0] ?? ''} exited with ${String(result.status)}`)
}
return result.stdout
}
/** A fresh repo on branch `main` with one committed file `a.txt`. */
const makeScratchRepo = (): string => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-git-'))
gitRun(dir, ['init', '-q'])
gitRun(dir, ['checkout', '-q', '-b', 'main'])
writeFileSync(join(dir, 'a.txt'), 'one\ntwo\nthree\n')
gitRun(dir, ['add', '-A'])
gitRun(dir, ['commit', '-q', '-m', 'base'])
return dir
}
it('discard restores the worktree file from the index (staged changes kept)', async () => {
const dir = makeScratchRepo()
try {
// Unstaged-only changes: fully reverts to the committed content.
writeFileSync(join(dir, 'a.txt'), 'one\nCHANGED\nthree\n')
await git.discard(dir, 'a.txt')
expect(readFileSync(join(dir, 'a.txt'), 'utf8')).toBe('one\ntwo\nthree\n')
// Staged changes: the worktree snaps back to the STAGED content and
// the index is untouched (`git checkout -- <path>` restores from the
// index — VSCode's "Discard Changes" semantics).
writeFileSync(join(dir, 'a.txt'), 'one\nCHANGED\nthree\n')
gitRun(dir, ['add', '-A'])
await git.discard(dir, 'a.txt')
expect(readFileSync(join(dir, 'a.txt'), 'utf8')).toBe('one\nCHANGED\nthree\n')
const staged = await git.diff(dir, 'a.txt', true)
expect(staged).toContain('-two')
expect(staged).toContain('+CHANGED')
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('revert creates a revert commit', async () => {
const dir = makeScratchRepo()
try {
writeFileSync(join(dir, 'a.txt'), 'one\nTWO\nthree\n')
gitRun(dir, ['add', '-A'])
gitRun(dir, ['commit', '-q', '-m', 'change'])
const featureHash = (await git.log(dir))[0]!.hashFull
await git.revert(dir, featureHash)
expect(readFileSync(join(dir, 'a.txt'), 'utf8')).toBe('one\ntwo\nthree\n')
expect((await git.log(dir))[0]!.subject).toBe('Revert "change"')
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('cherry-pick applies a commit from another branch', async () => {
const dir = makeScratchRepo()
try {
gitRun(dir, ['checkout', '-q', '-b', 'feature'])
writeFileSync(join(dir, 'b.txt'), 'feature work\n')
gitRun(dir, ['add', '-A'])
gitRun(dir, ['commit', '-q', '-m', 'feature work'])
const featureHash = (await git.log(dir))[0]!.hashFull
gitRun(dir, ['checkout', '-q', 'main'])
await git.cherryPick(dir, featureHash)
expect(readFileSync(join(dir, 'b.txt'), 'utf8')).toBe('feature work\n')
expect((await git.log(dir))[0]!.subject).toBe('feature work')
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('reports a failing destructive operation as a GitCommandError', async () => {
const dir = makeScratchRepo()
try {
// An unknown revision fails before touching anything.
await expect(git.revert(dir, 'deadbeef00000000000000000000000000000000')).rejects.toThrow()
await expect(git.cherryPick(dir, 'deadbeef00000000000000000000000000000000')).rejects.toThrow()
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
})
describe('session cwd resolution over the API route', () => {
interface CtxOverrides {
sessions?: { get: (id: string) => { header: { cwd?: string } } | undefined }
@@ -177,6 +310,7 @@ describe('session cwd resolution over the API route', () => {
registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} },
},
sessions: overrides.sessions ?? { get: () => undefined },
tools: { register: () => () => {} },
// The vendored cordis runs registration effects immediately.
effect: (fn: () => void | (() => void)) => { fn() },
// No settings service: the namespace registration never runs.
@@ -248,12 +382,40 @@ describe('session cwd resolution over the API route', () => {
const missing = await invoke(route, 'pty.close', { sessionId: 's-pty' })
expect(missing.ok).toBe(false)
})
it('git.diff resolves repo-relative paths (session in a subdirectory)', async () => {
// The plugin repo's status paths are relative to the repo top level
// (e.g. `src/git.ts`); a session whose cwd sits inside the repo must
// still load per-file diffs instead of failing with "not an absolute
// path". The session header points INTO the repository.
const route = mount({
sessions: {
get: () => ({ header: { cwd: join(process.cwd(), 'src') } }),
},
})
const result = await invoke(route, 'git.diff', { sessionId: 's-sub', path: 'src/git.ts', staged: false })
expect(result.ok).toBe(true)
const value = result as unknown as { ok: boolean; value?: { diff: string } }
expect(typeof value.value?.diff).toBe('string')
})
it('fs.read resolves repo-relative paths (untracked diff fallback)', async () => {
const route = mount({
sessions: {
get: () => ({ header: { cwd: join(process.cwd(), 'src') } }),
},
})
const result = await invoke(route, 'fs.read', { sessionId: 's-sub', path: 'src/git.ts' })
expect(result.ok).toBe(true)
const value = result as unknown as { ok: boolean; value?: { kind: string; content: string } }
expect(value.value?.kind).toBe('text')
expect(value.value?.content).toContain('runGit')
})
})
describe('side card settings routes', () => {
/** A minimal settings seam: register/describe/update with the revision guard. */
const createFakeSettings = () => {
const namespaces = new Map<string, {
const createFakeSettings = () => { const namespaces = new Map<string, {
schema: unknown
value: Record<string, unknown> | undefined
revision: number
@@ -296,6 +458,7 @@ describe('side card settings routes', () => {
registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} },
},
sessions: { get: () => undefined },
tools: { register: () => () => {} },
effect: (fn: () => void | (() => void)) => { fn() },
inject: (deps: string[], callback: (sctx: { settings: unknown }) => void) => {
if (deps.includes('settings') && settings !== undefined) callback({ settings })
@@ -339,7 +502,15 @@ describe('side card settings routes', () => {
const read = await invoke(route, 'settings.get', {})
expect(read.ok).toBe(true)
expect(read.value).toEqual({
value: { openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true },
value: {
openByDefault: true,
defaultWidthPercent: 30,
autoOpenSubagent: true,
agentTerminalTools: false,
// The enable-switch maps default to {} (everything on).
tabsEnabled: {},
viewersEnabled: {},
},
revision: 0,
})
@@ -371,3 +542,62 @@ describe('side card settings routes', () => {
expect(result.error?.message).toMatch(/plain object/)
})
})
describe('agent terminal tool gating', () => {
it('injects the eight tools only when the side-card setting is enabled (default off)', () => {
let registered = 0
let disposed = 0
// The tools currently registered (registered minus disposed).
const live = (): number => registered - disposed
// A ref container: the watch callback is only assigned inside a closure,
// which TypeScript's control-flow analysis ignores (the bare variable
// would narrow to null and refuse the optional call).
const watcherRef: { current: (() => void) | null } = { current: null }
let enabled = false
const settings = {
register() {
return {
get: () => ({ agentTerminalTools: enabled }),
watch: (callback: () => void) => { watcherRef.current = callback; return () => {} },
update: async () => {},
replace: async () => {},
}
},
describe: () => [],
async update() {},
}
const ctx = {
loader: { entries: () => [] },
httpServer: {
register: (route: SidebarWebRoute) => { void route; return () => {} },
registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} },
},
sessions: { get: () => undefined },
tools: { register: () => { registered += 1; return () => { disposed += 1 } } },
effect: (fn: () => void | (() => void)) => { fn() },
inject: (deps: readonly string[], callback: (sctx: { settings: unknown }) => void) => {
if (deps.includes('settings')) callback({ settings })
return () => {}
},
}
apply(ctx as never)
// Default off: no tools are registered even though the settings service is mounted.
expect(live()).toBe(0)
// Flipping the setting on registers all eight tools.
enabled = true
watcherRef.current?.()
expect(live()).toBe(8)
expect(disposed).toBe(0)
// Flipping it back off unregisters them (and releases any agent terminals).
enabled = false
watcherRef.current?.()
expect(live()).toBe(0)
expect(disposed).toBe(8)
// And a redundant toggle registers them fresh (no double-registration per
// flip: the guard only skips when the tools are already live).
enabled = true
watcherRef.current?.()
expect(live()).toBe(8)
expect(registered).toBe(16)
})
})
+223
View File
@@ -0,0 +1,223 @@
/**
* Tool-layer tests for the eight agent-terminal tools: registration shape,
* the combined disposer, canonical-output contract (validated against each
* tool's declared output schema exactly like the registry's
* `createSuccessResult` does), per-session ownership, and the UTF-8
* truncation boundary. The pty registry is stubbed (no real spawn): the
* wiring of the tools themselves is the subject here — pty behavior is
* covered by agent-pty.spec.ts.
*/
import { describe, expect, it } from 'vitest'
import { validateJsonSchemaValue, type ToolDefinition, type ToolRunContext } from '@deepseek-ai/dsh-tools'
import { boundBytes, registerTools } from '../src/tools.ts'
import type { AgentPtyRegistry } from '../src/agent-pty.ts'
import type { Context } from '../src/context-types.ts'
/** One registration captured by the fake tools service. */
interface CapturedTool {
definition: ToolDefinition
}
/** Stub registry: uuid-keyed rows without a real pty. */
class FakeRegistry {
readonly terminals = new Map<string, { sessionId: string; title: string; command: string; exited: boolean }>()
create(sessionId: string, title: string, command: string): string {
const uuid = `uuid-${this.terminals.size + 1}`
this.terminals.set(uuid, { sessionId, title, command, exited: false })
return uuid
}
list(sessionId: string): Array<{ uuid: string; title: string; command: string; exited: boolean }> {
return [...this.terminals.entries()]
.filter(([, row]) => row.sessionId === sessionId)
.map(([uuid, row]) => ({ uuid, title: row.title, command: row.command, exited: row.exited }))
}
assertOwned(uuid: string, sessionId: string): void {
const row = this.terminals.get(uuid)
if (row === undefined || row.sessionId !== sessionId) {
throw new Error(`agent terminal "${uuid}" not found`)
}
}
send(): void {}
read(): { text: string; totalLines: number; lineBegin: number; lineEnd: number } {
// A page larger than the tool's 256 KiB read cap, to exercise boundBytes.
return { text: 'x'.repeat(300 * 1024), totalLines: 1, lineBegin: 0, lineEnd: 1 }
}
resize(_uuid: string, cols: number, rows: number): { cols: number; rows: number } {
return { cols, rows }
}
signal(): void {}
close(): boolean {
return true
}
waitFor(_uuid: string, needle: string): Promise<{ kind: 'found'; needle: string; line: number; column: number; elapsedMs: number }> {
// Mirrors the registry's empty-needle rejection (the tool layer no
// longer duplicates this check).
if (needle === '') return Promise.reject(new Error('needle must be a non-empty string'))
return Promise.resolve({ kind: 'found', needle, line: 0, column: 0, elapsedMs: 1 })
}
}
/** A minimal ToolRunContext for one calling session (only the slices the tools touch). */
function exec(sessionId: string): ToolRunContext {
return {
signal: { throwIfAborted: () => {}, aborted: false },
agent: { session: { id: sessionId } },
} as unknown as ToolRunContext
}
/** Register the eight tools against a fake service; the tools bind to `registry`. */
function mount(): { captured: CapturedTool[]; registry: FakeRegistry; dispose: () => number } {
const captured: CapturedTool[] = []
let disposeCount = 0
const ctx = {
tools: {
register: (tool: unknown): (() => void) => {
captured.push({ definition: tool as ToolDefinition })
return () => { disposeCount += 1 }
},
},
} as unknown as Context
const registry = new FakeRegistry()
const dispose = registerTools(ctx, registry as unknown as AgentPtyRegistry, () => '/cwd')
return { captured, registry, dispose: () => { dispose(); return disposeCount } }
}
/** The captured definition for one tool name. */
function toolOf(captured: CapturedTool[], name: string): ToolDefinition {
const found = captured.find(candidate => candidate.definition.name === name)
if (found === undefined) throw new Error(`tool "${name}" was not registered`)
return found.definition
}
describe('agent terminal tools', () => {
it('registers all eight tools and the combined disposer unregisters them all', () => {
const { captured, dispose } = mount()
expect(captured.map(candidate => candidate.definition.name)).toEqual([
'terminal_create',
'terminal_list',
'terminal_send',
'terminal_read',
'terminal_wait_for',
'terminal_resize',
'terminal_signal',
'terminal_close',
])
expect(dispose()).toBe(8)
// Disposing twice must not throw (each registry disposer is idempotent).
expect(() => mount().dispose()).not.toThrow()
})
it('terminal_create returns the uuid and echoed title (schema-valid)', async () => {
const { captured, registry } = mount()
const tool = toolOf(captured, 'terminal_create')
const value = await tool.execute({ title: 'dev server', command: 'npm run dev' }, exec('s1'))
expect(value).toMatchObject({ title: 'dev server' })
expect(typeof (value as { uuid: unknown }).uuid).toBe('string')
expect(registry.terminals.size).toBe(1)
expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([])
})
it('terminal_list output matches the declared schema — no sessionId leaks', async () => {
const { captured, registry } = mount()
const tool = toolOf(captured, 'terminal_list')
const uuid = registry.create('s1', 'dev server', 'npm run dev')
registry.create('s2', 'other session', 'top secret')
const value = await tool.execute({}, exec('s1'))
const list = value as Array<Record<string, unknown>>
expect(list).toHaveLength(1)
expect(list[0]!.uuid).toBe(uuid)
// The session id is registry-internal ownership, never serialized.
expect('sessionId' in list[0]!).toBe(false)
// And the whole value validates against the declared output schema —
// this is the contract the registry enforces at runtime (additionalProperties: false).
expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([])
})
it('rejects a uuid owned by another session (send/read/resize/signal/close/wait_for)', async () => {
const { captured, registry } = mount()
const mine = registry.create('s1', 'mine', '')
const foreign = registry.create('s2', 'theirs', '')
for (const name of ['terminal_send', 'terminal_read', 'terminal_resize', 'terminal_signal', 'terminal_close', 'terminal_wait_for']) {
const tool = toolOf(captured, name)
const args = name === 'terminal_send' ? { uuid: foreign, text: 'ls' }
: name === 'terminal_read' ? { uuid: foreign }
: name === 'terminal_resize' ? { uuid: foreign, cols: 80, rows: 24 }
: name === 'terminal_signal' ? { uuid: foreign, signal: 'SIGINT' }
: name === 'terminal_close' ? { uuid: foreign }
: { uuid: foreign, needle: 'x', timeout_ms: 100 }
// A foreign uuid is indistinguishable from an unknown one.
await expect(tool.execute(args, exec('s1'))).rejects.toThrow(/not found/)
}
// Own-session uuids still work.
const sendTool = toolOf(captured, 'terminal_send')
await expect(sendTool.execute({ uuid: mine, text: 'ls' }, exec('s1'))).resolves.toBeDefined()
const closeTool = toolOf(captured, 'terminal_close')
await expect(closeTool.execute({ uuid: mine }, exec('s1'))).resolves.toMatchObject({ closed: true })
})
it('terminal_read truncates at the byte cap and reports it (schema-valid)', async () => {
const { captured, registry } = mount()
const tool = toolOf(captured, 'terminal_read')
const uuid = registry.create('s1', 'reader', '')
const value = await tool.execute({ uuid }, exec('s1'))
const result = value as { text: string; truncated: boolean }
expect(result.truncated).toBe(true)
expect(Buffer.byteLength(result.text, 'utf8')).toBeLessThanOrEqual(256 * 1024)
expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([])
})
it('terminal_resize echoes the registry-applied dimensions (schema-valid)', async () => {
const { captured, registry } = mount()
const tool = toolOf(captured, 'terminal_resize')
const uuid = registry.create('s1', 'resizer', '')
const value = await tool.execute({ uuid, cols: 120, rows: 40 }, exec('s1'))
expect(value).toEqual({ uuid, cols: 120, rows: 40 })
expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([])
})
it('terminal_wait_for returns the registry result in camelCase (schema-valid, no projection)', async () => {
const { captured, registry } = mount()
const tool = toolOf(captured, 'terminal_wait_for')
const uuid = registry.create('s1', 'waiter', '')
const value = await tool.execute({ uuid, needle: 'done' }, exec('s1'))
expect(value).toEqual({ kind: 'found', needle: 'done', line: 0, column: 0, elapsedMs: 1 })
expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([])
})
it('terminal_wait_for rejects an empty needle through the registry (no duplicate check)', async () => {
const { captured, registry } = mount()
const tool = toolOf(captured, 'terminal_wait_for')
const uuid = registry.create('s1', 'waiter', '')
await expect(tool.execute({ uuid, needle: '' }, exec('s1'))).rejects.toThrow(/non-empty/)
})
})
describe('boundBytes', () => {
it('never splits a multi-byte UTF-8 sequence at the cap', () => {
// '€' is 3 bytes (E2 82 AC); the cap must retreat to a sequence boundary.
expect(boundBytes('a€b', 2)).toEqual({ text: 'a', truncated: true })
expect(boundBytes('a€b', 3)).toEqual({ text: 'a', truncated: true })
expect(boundBytes('a€b', 4)).toEqual({ text: 'a€', truncated: true })
expect(boundBytes('a€b', 5)).toEqual({ text: 'a€b', truncated: false })
expect(boundBytes('€', 1)).toEqual({ text: '', truncated: true })
// ASCII pages truncate at the byte cap directly.
expect(boundBytes('hello', 3)).toEqual({ text: 'hel', truncated: true })
})
it('every retained prefix decodes without U+FFFD', () => {
for (let n = 1; n <= 18; n += 1) {
const { text } = boundBytes('中文字符串测试 123', n)
expect(text.includes('\uFFFD')).toBe(false)
expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(n)
}
})
})
+376 -34
View File
@@ -1,10 +1,11 @@
import { describe, expect, it } from 'vitest'
import { compareEntries, isWithin, parentOf, rootLabel, requireAbsolute } from '../src/fs-tree.ts'
import { parseLogLines, parsePorcelainZ } from '../src/git.ts'
import { parseUnifiedDiff } from '../src/client/DiffView.tsx'
import {
activateTab, allLeaves, closeTab, createSidebarStore, defaultWidthFor, insertLeafAt, makeDefaultState,
moveTab, moveTabToEdge, openTab, resizeSplit, sanitizeState, splitPane, tabOpenIn, toggleExpanded,
type SidebarState, type SplitNode,
moveTab, moveTabToEdge, openDiffTab, openTabInActivePane, 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'
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
@@ -89,42 +90,261 @@ describe('git parsing', () => {
])
})
it('parses log rows with unit separators', () => {
const rows = parseLogLines('abc1234\x1fFirst subject\x1fAlice\x1f2024-01-01 10:00:00 +0800\n')
it('parses log rows with unit separators (full hash + refs)', () => {
const rows = parseLogLines(
'abc1234\x1fFirst subject\x1fAlice\x1f2024-01-01 10:00:00 +0800\x1fabc1234def5678abc1234def5678abc1234def5678\x1fHEAD -> main, origin/main\n'
+ 'def5678\x1fSecond subject\x1fBob\x1f2024-01-02 10:00:00 +0800\x1fdef5678abc1234def5678abc1234def5678abc1234\x1f\n',
)
expect(rows).toEqual([
{ hash: 'abc1234', subject: 'First subject', author: 'Alice', date: '2024-01-01 10:00:00 +0800' },
{
hash: 'abc1234',
subject: 'First subject',
author: 'Alice',
date: '2024-01-01 10:00:00 +0800',
hashFull: 'abc1234def5678abc1234def5678abc1234def5678',
refs: 'HEAD -> main, origin/main',
},
{
hash: 'def5678',
subject: 'Second subject',
author: 'Bob',
date: '2024-01-02 10:00:00 +0800',
hashFull: 'def5678abc1234def5678abc1234def5678abc1234',
refs: '',
},
])
})
it('parses a multi-file unified diff with aligned line numbers', () => {
const diff = [
'diff --git a/src/a.ts b/src/a.ts',
'index 1234567..89abcde 100644',
'--- a/src/a.ts',
'+++ b/src/a.ts',
'@@ -1,4 +1,5 @@ section with @@ inside',
' line1',
'-line2',
'+line2b',
' context',
'+trailing',
'diff --git a/README.md b/README.md',
'new file mode 100644',
'index 0000000..1234567',
'--- /dev/null',
'+++ b/README.md',
'@@ -0,0 +1,2 @@',
'+hello',
'+world',
'',
].join('\n')
const parsed = parseUnifiedDiff(diff)
expect(parsed.files).toHaveLength(2)
const first = parsed.files[0]!
expect(first.oldPath).toBe('a/src/a.ts')
expect(first.newPath).toBe('b/src/a.ts')
expect(first.binary).toBe(false)
expect(first.hunks).toHaveLength(1)
expect(first.hunks[0]!.oldStart).toBe(1)
expect(first.hunks[0]!.newStart).toBe(1)
expect(first.hunks[0]!.header).toBe(' section with @@ inside')
expect(first.hunks[0]!.lines).toEqual([
{ kind: 'ctx', text: 'line1', oldNum: 1, newNum: 1 },
{ kind: 'del', text: 'line2', oldNum: 2, newNum: null },
{ kind: 'add', text: 'line2b', oldNum: null, newNum: 2 },
{ kind: 'ctx', text: 'context', oldNum: 3, newNum: 3 },
{ kind: 'add', text: 'trailing', oldNum: null, newNum: 4 },
])
const second = parsed.files[1]!
expect(second.oldPath).toBe('/dev/null')
expect(second.hunks[0]!.lines[0]).toEqual({ kind: 'add', text: 'hello', oldNum: null, newNum: 1 })
expect(second.hunks[0]!.lines[1]).toEqual({ kind: 'add', text: 'world', oldNum: null, newNum: 2 })
})
it('parses binary, deletion and no-newline markers', () => {
const diff = [
'diff --git a/img.png b/img.png',
'index 111..222 100644',
'Binary files a/img.png and b/img.png differ',
'diff --git a/gone.ts b/gone.ts',
'deleted file mode 100644',
'--- a/gone.ts',
'+++ /dev/null',
'@@ -1,2 +0,0 @@',
'-one',
'-two',
'\\ No newline at end of file',
'',
].join('\n')
const parsed = parseUnifiedDiff(diff)
expect(parsed.files).toHaveLength(2)
expect(parsed.files[0]!.binary).toBe(true)
expect(parsed.files[0]!.hunks).toHaveLength(0)
const gone = parsed.files[1]!
expect(gone.newPath).toBe('/dev/null')
expect(gone.hunks[0]!.lines).toEqual([
{ kind: 'del', text: 'one', oldNum: 1, newNum: null },
{ kind: 'del', text: 'two', oldNum: 2, newNum: null },
{ kind: 'meta', text: ' No newline at end of file', oldNum: null, newNum: null },
])
})
it('keeps mode/rename-only sections hunkless', () => {
const parsed = parseUnifiedDiff([
'diff --git a/run.sh b/run.sh',
'old mode 100644',
'new mode 100755',
'diff --git a/old.ts b/new.ts',
'similarity index 90%',
'rename from old.ts',
'rename to new.ts',
'',
].join('\n'))
expect(parsed.files).toHaveLength(2)
expect(parsed.files[0]!.oldPath).toBe('')
expect(parsed.files[0]!.hunks).toHaveLength(0)
expect(parsed.files[1]!.hunks).toHaveLength(0)
})
it('parses an empty or junk diff into no files', () => {
expect(parseUnifiedDiff('').files).toEqual([])
expect(parseUnifiedDiff('no diff here\n').files).toEqual([])
})
})
describe('sidebar state', () => {
const state = (): SidebarState => makeDefaultState()
it('opens tabs into the active pane and dedupes single-instance types', () => {
it('opens tabs into the active pane and dedupes by id (safety net)', () => {
let s = state()
const gitTab = { id: 'git', type: 'git' as const, title: 'Git' }
s = openTab(s, gitTab)
s = openTabInActivePane(s, gitTab)
expect(s.splits.kind).toBe('leaf')
expect((s.splits as { tabs: unknown[] }).tabs).toHaveLength(2)
// Reopening git focuses the existing tab instead of duplicating.
const after = openTab(s, { id: 'git2', type: 'git' as const, title: 'Git' })
// Reopening with the SAME id focuses the existing tab instead of duplicating.
const after = openTabInActivePane(s, { id: 'git', type: 'git' as const, title: 'Git' })
expect((after.splits as { tabs: unknown[] }).tabs).toHaveLength(2)
// A different id opens a new tab (type-level dedupe is the service's job).
const after2 = openTabInActivePane(s, { id: 'git2', type: 'git' as const, title: 'Git' })
expect((after2.splits as { tabs: unknown[] }).tabs).toHaveLength(3)
})
it('dedupes editors by path', () => {
it('opens multiple editors with distinct ids (path-level dedupe is the service descriptor\'s job)', () => {
let s = state()
const firstId = (s.splits as { tabs: { id: string }[] }).tabs[0]!.id
s = openTab(s, { id: 'e1', type: 'editor', title: 'a.ts', path: '/p/a.ts' })
const after = openTab(s, { id: 'e2', type: 'editor', title: 'a.ts', path: '/p/a.ts' })
expect((after.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([firstId, 'e1'])
s = openTabInActivePane(s, { id: 'e1', type: 'editor', title: 'a.ts', path: '/p/a.ts' })
const after = openTabInActivePane(s, { id: 'e2', type: 'editor', title: 'a.ts', path: '/p/a.ts' })
expect((after.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([firstId, 'e1', 'e2'])
})
const diffTab = (id: string): SidebarTab => ({
id,
type: 'diff',
title: id,
diff: { kind: 'worktree', path: 'src/a.ts', staged: false },
})
it('first diff splits the source pane vertically (diff below)', () => {
const s = state()
const gitTab = { id: 'git', type: 'git' as const, title: 'Git' }
const withGit = openTabInActivePane(s, gitTab)
const sourcePane = (withGit.splits as { kind: 'leaf'; id: string }).id
const after = openDiffTab(withGit, sourcePane, diffTab('diff:w:u:src/a.ts'))
expect(after.splits.kind).toBe('split')
const split = after.splits as { dir: string; children: { kind: string; tabs?: SidebarTab[]; id: string }[] }
expect(split.dir).toBe('col')
expect(split.children).toHaveLength(2)
// The source stays on TOP (first child), the diff lands in the new bottom leaf.
expect(split.children[0]!.id).toBe(sourcePane)
expect(split.children[1]!.tabs?.map(tab => tab.id)).toEqual(['diff:w:u:src/a.ts'])
expect(after.activePane).toBe(split.children[1]!.id)
})
it('reopening the same diff focuses its existing tab', () => {
const s = state()
const gitTab = { id: 'git', type: 'git' as const, title: 'Git' }
const withGit = openTabInActivePane(s, gitTab)
const sourcePane = (withGit.splits as { kind: 'leaf'; id: string }).id
const first = openDiffTab(withGit, sourcePane, diffTab('diff:w:u:src/a.ts'))
const second = openDiffTab(first, sourcePane, diffTab('diff:w:u:src/a.ts'))
// No new panes, no duplicate tabs.
expect(second.splits.kind).toBe('split')
const split = second.splits as { children: { kind: string; tabs?: SidebarTab[] }[] }
const allTabs = split.children.flatMap(child => child.tabs ?? [])
expect(allTabs.filter(tab => tab.type === 'diff')).toHaveLength(1)
})
it('subsequent diffs stack into the existing diff pane', () => {
const s = state()
const gitTab = { id: 'git', type: 'git' as const, title: 'Git' }
const withGit = openTabInActivePane(s, gitTab)
const sourcePane = (withGit.splits as { kind: 'leaf'; id: string }).id
const first = openDiffTab(withGit, sourcePane, diffTab('diff:w:u:src/a.ts'))
const second = openDiffTab(first, sourcePane, diffTab('diff:c:abc1234def5678abc1234def5678abc1234def5678'))
// Still one split: the second diff joins the bottom leaf instead of splitting again.
expect(second.splits.kind).toBe('split')
const split = second.splits as { children: { kind: string; tabs?: SidebarTab[] }[] }
const diffLeaves = split.children.filter(child => child.tabs?.some(tab => tab.type === 'diff'))
expect(diffLeaves).toHaveLength(1)
expect(diffLeaves[0]!.tabs?.map(tab => tab.id)).toEqual([
'diff:w:u:src/a.ts',
'diff:c:abc1234def5678abc1234def5678abc1234def5678',
])
})
it('openDiffTab degrades to a regular open when the source pane is gone', () => {
const s = state()
const after = openDiffTab(s, 'pane:gone', diffTab('diff:w:u:src/a.ts'))
expect(after.splits.kind).toBe('leaf')
expect((after.splits as { tabs: SidebarTab[] }).tabs.map(tab => tab.id)).toContain('diff:w:u:src/a.ts')
})
it('sanitize drops diff tabs (ephemeral, like VSCode diff editors)', () => {
const valid = sanitizeState({
panelOpen: true,
width: 400,
nextTerminal: 1,
activePane: 'pane:1',
expanded: [],
splits: {
kind: 'leaf',
id: 'pane:1',
active: 'd1',
tabs: [
{ id: 'explorer-tab', type: 'explorer', title: 'Explorer' },
{ id: 'd1', type: 'diff', title: 'a.ts', diff: { kind: 'worktree', path: 'src/a.ts', staged: false } },
],
},
})
expect(valid?.splits.kind).toBe('leaf')
const tabs = (valid?.splits as { tabs: SidebarTab[] }).tabs
expect(tabs.map(tab => tab.id)).toEqual(['explorer-tab'])
// The dropped diff tab was the active one: the leaf falls back to a null
// active instead of resetting the whole state.
expect((valid?.splits as { active: string | null }).active).toBeNull()
// A leaf of ONLY diff tabs survives as an empty pane (welcome cards).
const onlyDiff = sanitizeState({
panelOpen: true,
width: 400,
nextTerminal: 1,
activePane: 'pane:1',
expanded: [],
splits: {
kind: 'leaf',
id: 'pane:1',
active: 'd1',
tabs: [{ id: 'd1', type: 'diff', title: 'a.ts' }],
},
})
expect(onlyDiff?.splits.kind).toBe('leaf')
expect((onlyDiff?.splits as { tabs: SidebarTab[] }).tabs).toEqual([])
})
it('dedupes the single-instance subagent tab (focuses instead of duplicating)', () => {
let s = state()
s = openTab(s, { id: 'subagent', type: 'subagent', title: 'Subagents' })
s = openTabInActivePane(s, { id: 'subagent', type: 'subagent', title: 'Subagents' })
expect((s.splits as { tabs: unknown[] }).tabs).toHaveLength(2)
// Reopening (e.g. the auto-activation effect) focuses the existing tab.
const after = openTab(s, { id: 'subagent', type: 'subagent', title: 'Subagents' })
const after = openTabInActivePane(s, { id: 'subagent', type: 'subagent', title: 'Subagents' })
expect((after.splits as { tabs: unknown[] }).tabs).toHaveLength(2)
const tabs = (after.splits as { tabs: { type: string; id: string }[] }).tabs
expect(tabs.filter(tab => tab.type === 'subagent')).toHaveLength(1)
@@ -155,7 +375,7 @@ describe('sidebar state', () => {
const paneB = split.children[1] as { id: string; tabs: { id: string }[] }
const tabId = paneA.tabs[0]!.id
// 先给 paneB 一个 tab,然后拖 paneA 的 tab 到 paneB 的 right 边缘。
s = openTab(s, { id: 't2', type: 'terminal', title: 'T2' })
s = openTabInActivePane(s, { id: 't2', type: 'terminal', title: 'T2' })
s = moveTabToEdge(s, paneA.id, tabId, paneB.id, 'right')
const after = s.splits as Extract<SplitNode, { kind: 'split' }>
// paneB 现在是 split(row) [旧leaf, 新leaf(tabId)];其父 split 仍存在。
@@ -181,7 +401,7 @@ describe('sidebar state', () => {
it('dragging a tab back onto its own pane center reorders it', () => {
let s = state()
s = openTab(s, { id: 't2', type: 'terminal', title: 'T2' })
s = openTabInActivePane(s, { id: 't2', type: 'terminal', title: 'T2' })
const leaf = s.splits as { id: string; tabs: { id: string }[] }
const first = leaf.tabs[0]!.id
s = moveTabToEdge(s, leaf.id, first, leaf.id, 'center')
@@ -199,7 +419,7 @@ describe('sidebar state', () => {
const explorerId = paneA.tabs[0]!.id
// paneA gets a terminal; the explorer moves to paneB; closing the
// terminal empties paneA, which is removed, promoting paneB.
s = openTab(s, { id: 't', type: 'terminal', title: 'Terminal 1' })
s = openTabInActivePane(s, { id: 't', type: 'terminal', title: 'Terminal 1' })
s = moveTab(s, paneA.id, explorerId, paneB.id)
s = activateTab(s, paneA.id, 't')
s = closeTab(s, paneA.id, 't')
@@ -248,11 +468,67 @@ describe('sidebar state', () => {
s = closeTab(s, target.id, explorerId)
expect(tabOpenIn(s, explorerId)).toBe(false)
// A terminal tab added later is open too.
s = openTab(s, { id: 'terminal:9', type: 'terminal', title: 'Terminal 9' })
s = openTabInActivePane(s, { id: 'terminal:9', type: 'terminal', title: 'Terminal 9' })
expect(tabOpenIn(s, 'terminal:9')).toBe(true)
})
})
describe('agent terminal reconciliation', () => {
it('adds tabs for new agent terminals', () => {
let s = makeDefaultState(400, true)
s = reconcileAgentTerminals(s, [
{ uuid: 'aaa-111', title: 'dev server' },
{ uuid: 'bbb-222', title: 'python repl' },
])
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true)
expect(tabOpenIn(s, 'agent:bbb-222')).toBe(true)
// The titles are preserved.
const tabs = allLeaves(s.splits).flatMap(leaf => leaf.tabs)
const agentTab = tabs.find(t => t.id === 'agent:aaa-111')
expect(agentTab?.title).toBe('dev server')
expect(agentTab?.type).toBe('terminal')
})
it('removes tabs for agent terminals that vanished from the server list', () => {
let s = makeDefaultState(400, true)
s = reconcileAgentTerminals(s, [
{ uuid: 'aaa-111', title: 'keep' },
{ uuid: 'bbb-222', title: 'remove' },
])
expect(tabOpenIn(s, 'agent:bbb-222')).toBe(true)
// Next push drops bbb-222.
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'keep' }])
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true)
expect(tabOpenIn(s, 'agent:bbb-222')).toBe(false)
})
it('is a no-op (same reference) when the lists already match', () => {
let s = makeDefaultState(400, true)
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'stable' }])
const next = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'stable' }])
expect(next).toBe(s)
})
it('does not touch non-agent terminal tabs', () => {
let s = makeDefaultState(400, true)
s = openTabInActivePane(s, { id: 'terminal:1', type: 'terminal', title: 'UI Tab' })
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'agent' }])
expect(tabOpenIn(s, 'terminal:1')).toBe(true)
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true)
})
it('handles an empty server list (removes all agent tabs)', () => {
let s = makeDefaultState(400, true)
s = reconcileAgentTerminals(s, [
{ uuid: 'aaa-111', title: 'a' },
{ uuid: 'bbb-222', title: 'b' },
])
s = reconcileAgentTerminals(s, [])
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(false)
expect(tabOpenIn(s, 'agent:bbb-222')).toBe(false)
})
})
describe('pty helpers', () => {
it('falls back from an empty SHELL to a usable shell', () => {
const previous = process.env.SHELL
@@ -371,10 +647,16 @@ describe('persisted state sanitization', () => {
const withSplit = JSON.parse(JSON.stringify(makeDefaultState(400)))
withSplit.splits = { kind: 'split', id: 's1', dir: 'row', sizes: [0.5], children: [] }
expect(sanitizeState(withSplit)).toBeUndefined()
// Unknown tab types (older layouts) are rejected.
const withBadTab = JSON.parse(JSON.stringify(makeDefaultState(400)))
withBadTab.splits.tabs[0].type = 'watcher'
expect(sanitizeState(withBadTab)).toBeUndefined()
// Unknown tab types (external plugins not yet loaded) are accepted —
// they render as <OrphanedTab/> at view time and recover if the plugin
// loads later. Only diff tabs are dropped (ephemeral).
const withExternalTab = JSON.parse(JSON.stringify(makeDefaultState(400)))
withExternalTab.splits.tabs[0].type = 'my-plugin:db'
const externalClean = sanitizeState(withExternalTab)
expect(externalClean).toBeDefined()
if (externalClean !== undefined && externalClean.splits.kind === 'leaf') {
expect(externalClean.splits.tabs[0]!.type).toBe('my-plugin:db')
}
// An active id that no tab carries is rejected.
const withBadActive = JSON.parse(JSON.stringify(makeDefaultState(400)))
withBadActive.splits.active = 'ghost-tab'
@@ -406,7 +688,7 @@ describe('persisted state sanitization', () => {
expect(new Set(leaves.map(leaf => leaf.id)).size).toBe(2)
expect(clean!.activePane).toBe(leaves[1]!.id)
// And an open must land in exactly one pane of the healed tree.
const opened = openTab(clean!, { id: 'editor:/a.ts', type: 'editor', title: 'a.ts', path: '/a.ts' })
const opened = openTabInActivePane(clean!, { id: 'editor:/a.ts', type: 'editor', title: 'a.ts', path: '/a.ts' })
const owners = allLeaves(opened.splits).filter(leaf => leaf.tabs.some(tab => tab.path === '/a.ts'))
expect(owners).toHaveLength(1)
})
@@ -416,13 +698,13 @@ describe('persisted state sanitization', () => {
const paneA = allLeaves(s.splits)[0]!.id
const explorerTab = allLeaves(s.splits)[0]!.tabs.find(tab => tab.type === 'explorer')!.id
s = closeTab(s, paneA, explorerTab)
s = openTab(s, { id: 'editor:/a.ts', type: 'editor', title: 'a.ts', path: '/a.ts' })
s = openTabInActivePane(s, { id: 'editor:/a.ts', type: 'editor', title: 'a.ts', path: '/a.ts' })
const split = insertLeafAt(s.splits, paneA, 'col', { id: 'terminal:1', type: 'terminal', title: 'Terminal 1' }, false)
s = { ...s, splits: split.node, activePane: paneA }
// Closing the editor empties paneA; the pane is removed but activePane
// still points at it. The next open must land in the surviving pane.
s = closeTab(s, paneA, 'editor:/a.ts')
s = openTab(s, { id: 'editor:/b.ts', type: 'editor', title: 'b.ts', path: '/b.ts' })
s = openTabInActivePane(s, { id: 'editor:/b.ts', type: 'editor', title: 'b.ts', path: '/b.ts' })
const owners = allLeaves(s.splits).filter(leaf => leaf.tabs.some(tab => tab.path === '/b.ts'))
expect(owners).toHaveLength(1)
expect(owners[0]!.tabs.some(tab => tab.type === 'terminal')).toBe(true)
@@ -546,29 +828,70 @@ describe('side card preferences', () => {
})
it('parses a valid value and clamps the percent into the contract range', async () => {
expect(await loadPrefs(wire({ openByDefault: false, defaultWidthPercent: 80, autoOpenSubagent: false })))
.toEqual({ openByDefault: false, defaultWidthPercent: 60, autoOpenSubagent: false })
expect(await loadPrefs(wire({ openByDefault: false, defaultWidthPercent: 80, autoOpenSubagent: false, agentTerminalTools: true })))
.toEqual({
openByDefault: false,
defaultWidthPercent: 60,
autoOpenSubagent: false,
agentTerminalTools: true,
tabsEnabled: {},
viewersEnabled: {},
})
})
it('falls back per-field when a stored field is malformed', async () => {
expect(await loadPrefs(wire({ openByDefault: 'yes', defaultWidthPercent: 33, autoOpenSubagent: 'no' })))
.toEqual({ openByDefault: true, defaultWidthPercent: 33, autoOpenSubagent: true })
expect(await loadPrefs(wire({ openByDefault: 'yes', defaultWidthPercent: 33, autoOpenSubagent: 'no', agentTerminalTools: 'yes' })))
.toEqual({
openByDefault: true,
defaultWidthPercent: 33,
autoOpenSubagent: true,
agentTerminalTools: false,
tabsEnabled: {},
viewersEnabled: {},
})
})
it('defaults autoOpenSubagent to true when the stored value is absent or malformed', async () => {
it('defaults autoOpenSubagent to true and agentTerminalTools to false when the stored value is absent or malformed', async () => {
expect(await loadPrefs(wire({ openByDefault: false, defaultWidthPercent: 40 })))
.toEqual({ openByDefault: false, defaultWidthPercent: 40, autoOpenSubagent: true })
.toEqual({
openByDefault: false,
defaultWidthPercent: 40,
autoOpenSubagent: true,
agentTerminalTools: false,
tabsEnabled: {},
viewersEnabled: {},
})
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenSubagent: 1 }))).autoOpenSubagent)
.toBe(true)
// The terminal-tools feature is OFF by default; only an explicit true turns it on.
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40 }))).agentTerminalTools)
.toBe(false)
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentTerminalTools: 1 }))).agentTerminalTools)
.toBe(false)
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentTerminalTools: true }))).agentTerminalTools)
.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({})
expect((await loadPrefs(wire({ viewersEnabled: [1, 2] }))).viewersEnabled).toEqual({})
// Non-boolean entries are dropped; boolean entries survive verbatim.
const parsed = await loadPrefs(wire({
tabsEnabled: { git: false, explorer: true, bad: 'yes' },
viewersEnabled: { image: false, code: 1 },
}))
expect(parsed.tabsEnabled).toEqual({ git: false, explorer: true })
expect(parsed.viewersEnabled).toEqual({ image: false })
})
it('seeds new-session defaults from the store prefs (open flag + width)', () => {
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 })
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, tabsEnabled: {}, viewersEnabled: {} })
store.setSession('fresh-session')
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true })
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, tabsEnabled: {}, viewersEnabled: {} })
const snapshot = store.getSnapshot()
expect(snapshot.sessionId).toBe('fresh-session')
expect(snapshot.state?.panelOpen).toBe(false)
@@ -579,6 +902,22 @@ describe('side card preferences', () => {
expect(openStore.getSnapshot().state?.panelOpen).toBe(true)
})
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.setSession('no-explorer')
const state = store.getSnapshot().state!
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
expect(tabs).toHaveLength(0)
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.setSession('with-explorer')
const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
expect(openTabs.map(tab => tab.type)).toEqual(['explorer'])
})
it('derives the default width from the window percent with clamps', () => {
expect(defaultWidthFor(1440, 30)).toBe(432)
expect(defaultWidthFor(800, 30)).toBe(280) // the panel floor
@@ -589,6 +928,9 @@ describe('side card preferences', () => {
expect(makeDefaultState().panelOpen).toBe(true)
expect(makeDefaultState(400, false).panelOpen).toBe(false)
expect(makeDefaultState(400, false).width).toBe(400)
// The seedExplorer flag controls the default explorer tab.
expect(makeDefaultState(400, true, false).splits.kind).toBe('leaf')
expect((makeDefaultState(400, true, false).splits as { tabs: unknown[] }).tabs).toHaveLength(0)
})
})