Compare commits

...
147 Commits
Author SHA1 Message Date
Menghuan1918 ab785d71fb docs: 最近更新按 0.12.0–0.12.3 版本分栏(双语) 2026-08-17 00:35:05 +08:00
Atlas Zeng 333961be71 Merge pull request #150 from omdsh-dev/feat/release-ci
ci: GitHub Release 自动触发 npm 发版(Trusted Publishing)
2026-08-17 00:29:47 +08:00
Atlas Zeng 618858b5b1 Merge pull request #149 from omdsh-dev/release/v0.12.3
docs: v0.12.3 发布说明与安装命令更新
2026-08-17 00:29:40 +08:00
Menghuan1918 565e66d30e docs: 安装/更新说明只留 dsh plugin add 命令,移除一键脚本引用 2026-08-17 00:26:48 +08:00
Menghuan1918 de5234a3d7 docs: v0.12.3 最近更新改图片行+文本,安装直接提供 dsh plugin add 命令 2026-08-17 00:22:39 +08:00
Atlas Zeng a673f2399f ci: GitHub Release 自动触发 npm 发版(Trusted Publishing) (#148)
- 新增 .github/workflows/release.yml:release published 事件触发,
  校验 tag 与 package.json 版本一致后 pnpm publish --provenance
- 认证走 npm Trusted Publishing(OIDC),无需 NPM_TOKEN secret
- workflow_dispatch 提供 dry_run 调试入口(只打包不发版)
- AGENTS.md 补充发版流程与 npm 后台一次性配置说明
2026-08-17 00:16:03 +08:00
Menghuan1918 6f52aa79d2 ci: GitHub Release 自动触发 npm 发版(Trusted Publishing)
- 新增 .github/workflows/release.yml:release published 事件触发,
  校验 tag 与 package.json 版本一致后 pnpm publish --provenance
- 认证走 npm Trusted Publishing(OIDC),无需 NPM_TOKEN secret
- workflow_dispatch 提供 dry_run 调试入口(只打包不发版)
- AGENTS.md 补充发版流程与 npm 后台一次性配置说明
2026-08-17 00:12:29 +08:00
Atlas Zeng 8d9ab1cd2f Merge pull request #147 from omdsh-dev/fix/node-pty-load-failure-repair
fix: node-pty 加载失败降级为终端修复提示,不再拖垮 server(#140)
2026-08-17 00:06:20 +08:00
yq a0918496f3 Merge pull request #91 from yq04/feat/catalog-dsh-git-remotes
Recommend dsh-git-remotes in the tab plugin catalog
2026-08-17 00:05:24 +08:00
Atlas Zeng aea9fcd3a0 Merge pull request #134 from omdsh-dev/feat/unified-path-handling
feat: 统一路径处理——UNC/软链接分类/html 路由平台守卫(#65 #67 #43 #79 #115)
2026-08-16 23:47:26 +08:00
Menghuan1918 be95787c00 fix: symlink 探测有界并发 + UNC 标记平台中性化(回应 CR/自审)
- fs-tree: symlink 目标 stat 从串行改为 32 并发工作池(readdir 流关闭后统一
  探测),symlink 密集/网络目标目录不再累加延迟;顺序与分类语义不变
- html-route: 解码不再重建反斜杠 UNC,统一重建为平台中性的 //server/share/...
  前斜杠形态(win32/POSIX 均正确 resolve),从而删除 windowsPathStyle 平台信号
  与 client isWindowsStylePath 助手;同时解决无 cwd 时正斜杠 UNC 的往返盲点
- 测试/文档同步;移除未使用的 isPosix
2026-08-16 23:35:57 +08:00
Menghuan1918 9652bbda59 fix(html): 正斜杠 UNC 路径在匹配正斜杠 cwd 时也保留标记(回应 Codex P2 #2)
当 cwd 与产出路径都是 //server/share/... 形式时,此前两个
isWindowsStylePath 都为 false 而漏掉 '//' 标记,win32 解码后落在当前
盘符被 isWithin 拒绝。现按 review 建议:'//' 路径仅在其 '//' cwd 匹配时
视为 UNC;POSIX 会话的 '//' 绝对路径仍保持无标记。
2026-08-16 23:24:53 +08:00
Menghuan1918 0052061a72 fix(html): UNC 标记信号纳入文件路径本身(回应 Codex P2 review)
cwd 未水合或为正斜杠形式时,仅凭 scope.cwd 会漏掉反斜杠 UNC 路径的
'//' 标记,win32 上预览被 isWithin 拒绝(403)。htmlUrl 现在同时以
isWindowsStylePath(path) 判定:明确的反斜杠 UNC 路径始终保留标记;
'//' 正斜杠路径仍只由 cwd 形态决定(POSIX 不受影响)。
2026-08-16 23:24:50 +08:00
Menghuan1918 db189e3852 feat: 统一路径处理——UNC/软链接分类/html 路由平台守卫(#65 #67 #43 #79 #115)
- fs-tree: requireAbsolute 改用 path.isAbsolute(win32 UNC 放行、C:foo 仍拒);
  目录列表仅对 symlink 行 stat 一次,暴露 isSymlink/broken,目录软链接可展开
- html-route: UNC 路径经 '//' 标记编码往返;标记仅在 Windows 风格会话启用,
  POSIX '//' 绝对路径不再被误判(平台守卫)
- client: isAbsolutePath/isWindowsStylePath 镜像 + resolveSidebarPath 识别 UNC,
  htmlUrl 以会话 cwd 形态传递平台信号;FsEntry 增加 isSymlink/broken
- explorer: 软链接行链接徽标、悬空链接标红 + tooltip(issue #65)
- 测试: tests/platform.ts 守卫助手;win32 专属用例改为 skipIf 可见跳过,
  补 POSIX 反向分支与 UNC 矩阵;新增 fs-tree-symlink.spec
2026-08-16 23:24:43 +08:00
taurusandyukun.wu bb3b40ff15 refactor(tests): decentralize monolithic unit.spec.ts and add state boundary tests (#141)
Co-authored-by: yukun.wu <yukun.wu@justcoglobal.com>
2026-08-16 23:06:44 +08:00
zemul 5c138ca851 catalog: add dsh-video-preview (video previewer) to the file-previewer recommended plugins (#126)
* catalog: add dsh-video-preview to the file-previewer recommended plugins

Adds the video preview plugin (dsh-video-preview) to the "add preview plugin" modal catalog, with zh/en descriptions. Data integrity is guarded by tests/plugin-list.spec.ts.

* test: make add-plugin-modal window.open count catalog-driven

The test pinned the viewer catalog to a single entry (2 "Open:" buttons).
Adding a second recommended plugin (dsh-video-preview) renders 4 buttons
and broke the hardcoded assertion. Derive the expected count from
builtinViewerPlugins so adding/removing plugins keeps the test honest.
2026-08-16 22:32:17 +08:00
Atlas Zeng fc4031e65f fix: 迁移弃用的 xterm 依赖至 @xterm/xterm(Closes #122) (#128)
* fix: 迁移弃用的 xterm 依赖至 @xterm/xterm(#122)

- dependencies 移除 xterm@^5.3.0(已弃用)与 @xterm/addon-fit(二者始终
  内联进 lib/client-terminal.js 懒加载 chunk,运行时零依赖)
- 二者移至 devDependencies(构建期仍需:tsdown 内联 + CSS 构建期解析 + 类型)
- 代码与构建注释改用官方新包名 @xterm/xterm@^5.5.0(addon-fit@0.10.0 的
  peer 约束锁 ^5.0.0,不用 6.x),CSS 路径同步为 @xterm/xterm/css/xterm.css
- 消除用户安装时的 deprecated 警告与 missing peer @xterm/xterm 警告

* chore: 顺带升级 @xterm/addon-fit 至 ^0.11.0

- 0.11.0(2025-12 发布)不再声明 peer 依赖,消除 peer 解析噪音
- 其运行时 API(_renderService.clear / options.overviewRuler 兜底)与
  @xterm/xterm@^5.5.0 兼容:overviewRuler 在 5.x 为 undefined 时回退 14,
  与 0.10.0 行为一致;typecheck/build/test(515)/ 挂载门禁均通过
2026-08-16 19:14:57 +08:00
Atlas Zeng 3c196fffc3 feat: 皮肤兼容(令牌驱动)+ CSS 修复(#106 #105 #90 #60,附带 #52 #57 #92) (#110)
令牌驱动方案(主目标:与 dsh-web-ui 皮肤中心兼容):
- 面板表面改用通用卡片令牌 --dsw-alias-bg-layer-1,不再消费宿主左导航专属
  --dsw-specific-sidebar-fill——dsh-web-ui 10 款皮肤全部覆盖 --dsw-alias-*
  层,换肤后侧边栏自动跟随,零每皮肤适配(#60/#105/#106)。
- effectiveTokenValue 增加 alpha 阈值(新增 colorAlpha 解析 computed 颜色):
  transparent 与 alpha<0.9 的半透明玻璃值(dsh-web-ui 皮肤 bg-base 为
  rgba 0.16–0.7)一律回退不透明底色,终端/编辑器文字永不叠在皮肤背景画上
  (#90);≥0.9 的近不透明值放行,皮肤仍可控终端表面。

独立修复(保留):
- z-index 降到 DSH 浮层栈(100+)之下:面板 40 / 按钮簇 45 / 角手柄面板内
  局部 2,Cordis 弹出框不再被底部工作台遮挡(#52)。
- 角手柄移入右面板、CSS 相对定位(bottom: calc(var(--dsh-sidebar-height)
  + 6px)),删除 JS 内联 viewport 坐标。
- 刷新按钮统一 14px(#57 前半)。
- 拖拽逐帧回归 e2e(#92):真实挂载下 rAF 采样(拖条以 cursor: col-resize
  语义定位),断言拖拽期间 data-dsh-sidebar-dragging 存在、#root transition
  为 none、会话列与面板边单调 1:1 跟随(实测通过)。

按 KISS 回退首版契约:类名 kebab-case 改名与 data-bs-* 语义钩子全部移除
(令牌驱动下不需要,改名引入无谓 breaking),skin-hooks 测试删除;
mount e2e 改为断言 --dsh-sidebar-width 布局变量随面板挂载生效。

文档:AGENTS.md §8 重写为「皮肤兼容(令牌驱动)」;设计文档记录决策与
回退。README 与版本号不在本批更新(发布流程统一处理)。

测试:tests/theme.spec.ts(colorAlpha + effectiveTokenValue 阈值);
typecheck/vitest(511)/build/consumer-types/test:mount 全绿。
2026-08-16 16:37:11 +08:00
oy-g fd9544f16b feat: configurable terminal shell with Windows pwsh auto-probe (#95)
* feat: configurable terminal shell with Windows pwsh auto-probe

Windows previously hardcoded powershell.exe (5.1) in defaultShell(), ignoring PowerShell 7 installs. Resolve the shell in priority order: explicit shell config -> DSH_SIDEBAR_SHELL -> PATH/known-dir probe for pwsh.exe -> inbox 5.1 fallback. The same resolved shell feeds both the UI terminal tabs (PtyManager) and the model-facing terminal_* tools (AgentPtyRegistry).

defaultShell() now accepts injectable platform/env/exists options so the Windows chain is unit-testable on the ubuntu CI, which never executes win32 branches. The probe also checks ProgramW6432 for 32-bit Node, and machines without PowerShell 7 keep the 5.1 fallback.

Refs: #46

* fix: trim resolved SHELL and document ProgramW6432 in resolver contract

- POSIX SHELL is validated with trim() but was returned untrimmed; trailing whitespace could leak into the spawned executable path. Return the trimmed value like the explicit/config and Windows/env branches.
- ShellResolutionOptions.env comment now lists ProgramW6432, which the Windows probe prefers over ProgramFiles on 32-bit Node.
- Test pins the SHELL trimming behavior.
2026-08-16 16:34:21 +08:00
時芽ときめ 835d1538e0 Merge pull request #114 from XuYouo/main
fix(ui): 优化侧边卡片设置页图标与布局
2026-08-16 16:24:28 +08:00
Atlas Zeng 9b50f5fb64 Merge pull request #120 from yanzhaohui1999/feat/editor-languages
feat(editor): add C# / Kotlin / Swift syntax highlightingFeat/editor languages
2026-08-16 16:23:11 +08:00
ymac d33d6764fc test(smoke): fix flaky login-shell temp dir cleanup (ENOTEMPTY on CI)
disposeAll() only requests the pty kill; the bash child exits
asynchronously via onExit, so deleting the temp HOME right after
races the still-running shell (it may be writing history files) and
fails with ENOTEMPTY on CI (seen on both #114 and #120).

Wait for the spawned handle to report exited (bounded 2s) before
removing the directory, with a short retry loop as a fallback.
2026-08-16 13:45:18 +08:00
ymac 515ed0abd0 feat(editor): add Swift (.swift) syntax highlighting
Map .swift to the built-in swift StreamLanguage from
@codemirror/legacy-modes/mode/swift (no new dependencies).
2026-08-16 04:12:30 +08:00
ymac d7dc609553 feat(editor): add Kotlin (.kt/.kts) syntax highlighting
Map .kt/.kts to the built-in kotlin StreamLanguage from
@codemirror/legacy-modes/mode/clike (no new dependencies).
2026-08-16 04:11:30 +08:00
ymac c9372de69b feat(editor): add C# (.cs) syntax highlighting
Map the .cs extension to the built-in csharp StreamLanguage from
@codemirror/legacy-modes/mode/clike (already a dependency, no new packages),
so .cs files open with syntax highlighting in the sidebar editor.
2026-08-16 04:10:38 +08:00
Atlas Zeng ecebc97800 Merge pull request #24 from log-li/fix/markdown-editor-selection-insert
fix: enable selection-to-conversation popup in the markdown editor
2026-08-15 22:47:21 +08:00
Menghuan1918 b6ef4d544a docs: README 标题下新增 MIT/dshfind 徽章,功能小标统一为徽章样式;最近更新补充 v0.12.2 文本说明(双语同步) 2026-08-15 21:35:49 +08:00
Atlas Zeng 5d2d6e5801 Merge pull request #88 from omdsh-dev/feat/title-bar-compat
feat: 侧边栏新增位置兼容模式(v0.12.2)
2026-08-15 15:53:35 +08:00
Atlas Zeng 30d0b103f6 Merge pull request #72 from NolanHo/feat/dev
fix: 终端 Tab 使用账户 login shell 并以 login 模式启动
2026-08-15 15:53:26 +08:00
Menghuan1918 8ad1766390 feat: 位置兼容模式支持自定义下移距离(齿轮弹窗数字输入)
titleBarStripPx 新偏好(0–120px,默认 40):侧边栏按钮与内容的下移量改为
CSS 变量 --dsh-title-bar-strip 驱动,Sidebar shell 随 prefs 写入。设置页
「常规」的位置兼容模式行在开启时显示齿轮小图标,弹窗内用数字行设置下移
距离(复用 FeatureSettingsRows 行控件与提交钳制)。
2026-08-15 15:46:56 +08:00
Menghuan1918 648ff27b5f chore: 版本号升至 v0.12.2(位置兼容模式随本次发布) 2026-08-15 15:37:31 +08:00
Menghuan1918 1e7c72ccbe feat: 侧边栏新增位置兼容模式,为 Windows 原生标题栏预留顶部空间
新增 SidebarPrefs.titleBarCompat 设置项(默认关闭):开启后 toggle cluster
下移 40px、右侧面板内容从标题栏条带下方开始,避免被 Windows 无边框窗口
右上角的原生标题栏遮挡。底部面板不受影响。设置页「常规」新增一行开关,
zh/en 双语,读写下穿既有 settings 路由。测试覆盖 parsePrefs 回退、schema
默认值、设置页渲染与开关状态。
2026-08-15 15:37:31 +08:00
Menghuan1918 5c27882995 docs: 仓库硬约束新增「代码改动必须走 PR,仅文档类改动可直推 main」 2026-08-15 15:36:12 +08:00
Menghuan1918 ad787ab22d Revert "feat: 侧边栏新增位置兼容模式,为 Windows 原生标题栏预留顶部空间"
This reverts commit ae71aecb83.
2026-08-15 15:36:12 +08:00
Menghuan1918 ae71aecb83 feat: 侧边栏新增位置兼容模式,为 Windows 原生标题栏预留顶部空间
新增 SidebarPrefs.titleBarCompat 设置项(默认关闭):开启后 toggle cluster
下移 40px、右侧面板内容从标题栏条带下方开始,避免被 Windows 无边框窗口
右上角的原生标题栏遮挡。底部面板不受影响。设置页「常规」新增一行开关,
zh/en 双语,读写下穿既有 settings 路由。测试覆盖 parsePrefs 回退、schema
默认值、设置页渲染与开关状态。
2026-08-15 15:24:46 +08:00
Atlas Zeng 1a0d530711 Merge pull request #74 from NolanHo/feat/link-interception-toggle
feat: 服务化 API 新增 urlTarget 外链认领 + 浏览器 HTTP/HTTPS 二级设置开关
2026-08-15 15:20:23 +08:00
Menghuan1918 29f162dac0 feat: 服务化 API 新增 urlTarget 外链认领 + 浏览器 HTTP/HTTPS 二级设置开关
基于 PR #74 的颗粒度讨论向前兼容拓展服务化 API:
- TabDescriptor 新增可选 urlTarget(url) 谓词:聊天/界面外链被拦截时按注册
  顺序路由到第一个命中的插件 tab 类型(openTab({ type, url, title })),
  内置 browser 退居隐式兜底;SIDEBAR_FEATURES 追加 'urlTarget'
- 浏览器链接设置改为二级菜单(齿轮弹窗):新增 browserInterceptHttp
  (默认开)+ browserInterceptHttps(默认关);browserInterceptLinks
  保留为总闸(默认开,旧文档零迁移)
- 拦截管线:registerLinkInterception 的 takeoverEnabled 改收解析后的
  URL,协议/同源纯判定(shouldInterceptLink)签名不变
- 测试:matchUrlTarget(注册顺序/抛错吞掉/browser 永不命中)、jsdom
  点击接管门控、parsePrefs 新字段默认值;全默认字面量同步
- 文档:设计文档 + AGENTS.md(字段/示例/设置键)+ README(外链分流)
2026-08-15 15:05:06 +08:00
Atlas Zeng 5bd961f7f1 Merge pull request #76 from ChenRuoT/add-dsh-sidebar-qa
feat: 推荐插件目录收录 dsh-sidebar-qa (Tab 注册)
2026-08-15 11:23:20 +08:00
Menghuan1918 f842fa0029 docs: 友情链接收录 DeepSeek Harness Desktop(issue #78) 2026-08-15 10:47:30 +08:00
ChenRuoT 0b5405117d feat: 推荐插件目录收录 dsh-sidebar-qa (Tab 注册)
plugins-tabs.ts 收录 dsh-sidebar-qa (划选提问tab)
install 拼接官方一键命令
2026-08-15 02:28:57 +08:00
Atlas Zeng 2bace68af8 Merge pull request #71 from omdsh-dev/ci/mount-smoke
ci: 新增插件挂载冒烟门禁(npm 打包 → 真实挂载 DSH → 无头渲染防 crash)
2026-08-15 01:55:39 +08:00
Menghuan1918 f903ac77d6 test(e2e): 落实 Codex 审查意见 — 强制覆盖 editor 懒加载 chunk + 缺失内置 tab 即失败
- seed hello.txt,经 Explorer 打开文件并等待 /sidebar/bundle/editor.js 响应,
  缺失/损坏的 editor chunk 会令门禁变红(已实测:删 chunk → 错误条被检出)
- + 菜单缺失内置 tab 由跳过改为 toHaveCount(1) 硬失败,防深扫悄悄缩水
- AGENTS.md CI 冒烟说明同步
2026-08-15 01:52:54 +08:00
Atlas Zeng 33ee02e747 Update README.md (#70) 2026-08-15 01:26:56 +08:00
Menghuan1918 7ee0b457a3 feat: CI 挂载冒烟门禁 — npm 打包 → 真实挂载 DSH → 无头渲染防 crash
新增 plugin-mount job(pnpm test:mount):
- scripts/e2e-mount.sh:scratch profile 引导(allowBuilds/minimumReleaseAgeExclude 预写)→ 官方
  dsh plugin --profile web add file:<tarball> 挂载(dsh.profile.bundles 协调校验)→ keyless
  dsh web --port 0 → Playwright 无头渲染 → trap 清理
- tests/e2e/mount.e2e.ts:seed 工作区+会话(宿主 RPC)→ 断言 #root 与 [data-dsh-better-sidebar]
  挂载、无崩溃标记(错误条/pageerror/插件 console 错误)→ + 菜单逐个打开内置 tab 深扫
- playwright.config.ts:chromium headless、单 worker、retries 0、失败留 trace/截图/HTML 报告
- vitest exclude 与 *.e2e.ts 命名双保险隔离;exclude 整体替换默认值,须保留默认排除项
- CI 钉住 @deepseek-ai/dsh@0.1.0-rc.6;DSH_TELEMETRY_DISABLED=1;workflow_dispatch + PR 并发取消

已验证:本地端到端全绿;人为注入 tab 组件 crash → 门禁变红(错误条检出),还原后变绿;
typecheck/vitest 全绿;CI 同款 npm 安装 CLI 路径实测通过
2026-08-15 01:21:21 +08:00
Menghuan1918 d478780e43 docs: README 语气专业化并放大最近更新截图
- Hero 特性标签新增 <code>插件接入</code>(Plugin integration),以 code 标签承载插件注册接入能力
- 移除口语化表达(吃自己的狗粮 / 把自己当用户),改用中性措辞
- 「我们的态度」改为「核心理念」,表述更正式
- 最近更新截图放大(320 → 480)
- 同步英文 README
2026-08-15 01:18:58 +08:00
NolanHo 76fa7df264 fix: resolve the account login shell and spawn terminals as login shells
Terminal tabs resolved the shell from the dsh process environment only,
falling back to a hardcoded /bin/bash — service-managed launches without
SHELL therefore ignored the account's login shell (e.g. zsh). They also
spawned with an empty argv, so even a resolved zsh ran as a non-login
shell and skipped ~/.zprofile.

defaultShell() now falls back to the account's login shell (passwd) after
$SHELL, and both registries spawn POSIX shells with -l so the tab behaves
like a terminal emulator (loads ~/.profile / ~/.zprofile). Windows keeps
powershell.exe with no login flag.

Fixes omdsh-dev/DSH-better-sidebar#68
2026-08-14 10:15:34 -07:00
Atlas Zeng acd4a8313c Update README.md 2026-08-15 01:14:29 +08:00
Menghuan1918 cb6f2cdc8f docs: README 服务化定位前置、精简功能一览并修正内置 viewer 计数
- 开头(Hero)声明服务化框架定位:ctx.betterSidebar 服务 + registerTab/registerFileViewer,内置 tab/viewer 与外部插件走同一套服务
- Hero 附 GitHub topic dsh-better-sidebar,说明生态位插件
- 修复展示视频(裸 URL → <video> 内嵌)与截图排版
- 功能一览 15 条精简为 10 条,突出重点;新增「我们的态度」引言
- 最近更新保留截图列,整理为可点击缩略图
- 修正内置 viewer 计数 9 → 6(Office 预览已迁至推荐插件)
- 同步英文 README
2026-08-15 01:09:42 +08:00
Atlas Zeng 3825fced25 Update README.md 2026-08-15 00:49:59 +08:00
Menghuan1918 4e275aa321 chore: v0.12.1(0.12.0 被 npm 判定已发布过无法复用,正式版改用 0.12.1;README 版本号同步) 2026-08-15 00:36:09 +08:00
Menghuan1918 ef8a707da8 chore: v0.12.0(正式版) 2026-08-15 00:33:26 +08:00
Menghuan1918 83a658efd4 chore: v0.12.0-beta.1(版本三处同步,api-surface 测试放行预发布版本号) 2026-08-15 00:28:59 +08:00
Menghuan1918 cba7e77896 docs: README 精简,最近更新改为表格(v0.12.0),版本号 0.11.0→0.12.0 2026-08-14 23:33:40 +08:00
Atlas Zeng e484556c1e Merge pull request #63 from omdsh-dev/feat/recommend-sentinel
feat: 推荐插件目录收录 dsh-sentinel(Tab 注册)
2026-08-14 23:09:57 +08:00
Menghuan1918 7d524724ae feat: 推荐插件目录收录 dsh-sentinel(Tab 注册)
- plugins-tabs.ts 收录 dsh-sentinel(条件驱动的 agent 唤醒系统,注册 dsh-sentinel:watches Tab)
- install 拼接官方一键命令:cd ~/.dsh && dsh plugin --profile web add "github:fuhefei/dsh-sentinel#v0.7.0"
- 新增 pluginSentinelDesc 文案(zh/en);测试同步(tab 目录不再为空态)
2026-08-14 23:04:25 +08:00
Menghuan1918 c0aeefc936 fix: 回应审查保留意见 — 测试钉住跨站分支、删死接口、修正注释措辞
- tests/trusted-hosts.spec.ts:用例 4 改为受信 origin + cross-site marker
  以隔离拒绝分支(原同时踩 marker 与 origin 两条路径,删除 marker 检查
  测试依旧通过——变异验证现仅该用例失败);接受侧断言从 not.toBe(403)
  收紧为 toBe(200),请求体补 sessionId(session.cwd 的真实契约)
- src/context-types.ts:删除已无引用的 SidebarLoader/SidebarLoaderEntry
  死接口(loader 成员移除后残留)
- src/index.ts:修正 "same list / 无需重启" 过承诺注释,改为与 /api 网关
  同源的准确表述(boot 采样快照 + --trusted-host 权威,读 live 服务值)
2026-08-14 23:03:26 +08:00
Rookie_Zoe ea250c9e87 fix: /sidebar 信任栅栏改用 webRuntime.trustedHosts,修复远程访问全部 403
根因:trustedHostsOf 按 entry.options.name === 'connection' 查找 connection
行,但该行 name 是模块标识符(@deepseek-ai/dsh-client-connection,id 才是
connection),查找永远落空 → trustedHosts 为空 → 栅栏只放行回环 Host → 通过
域名或局域网 IP 访问时 /sidebar/bundle chunk 加载失败、资源管理器/代码管理器
全部 403 forbidden(/api 不受影响,它读的是 apply 时已求值的配置)。

修复(按 review 建议):host half 直接注入 webRuntime 服务,栅栏每请求实时
读取 ctx.webRuntime.trustedHosts —— 与 /api 网关同一信任列表(启动时采样的
LAN IP 字面量 + --trusted-host 权威),不再依赖 Loader entry / fiber /
!!js 节点求值;自定义信任域名由 dsh web --trusted-host 提供。

新增 tests/trusted-hosts.spec.ts 回归:域名/局域网放行、空列表仅回环、
跨站标记拒绝、每请求实时读取;plugin-shape 断言同步更新。
2026-08-14 23:03:26 +08:00
feifei 9b87c1da16 docs: register dsh-sentinel as the first real consumer integration (#58) 2026-08-14 23:03:20 +08:00
Atlas Zeng 001e86b1e1 feat: 标签页栏支持鼠标滚轮横向滚动 (#61)
普通鼠标滚轮只产生垂直 delta,overflow-x 的横向滚动容器不会消费它,
事件会冒泡导致页面滚动。给 TabBar 的滚动容器绑定原生非 passive wheel
监听(React 的 onWheel 在根节点是 passive,preventDefault 无效),
在标签条溢出时把 deltaY(含 deltaX、deltaMode 归一化)转为 scrollLeft:

- 未溢出时完全不拦截,页面正常滚动(空 pane 亦然)
- 按住 Shift/Ctrl/Cmd/Alt 时放行,保留浏览器原生横滚/缩放
- 每个 pane 的 TabBar 独立监听,只滚动悬停的标签条

新增 tests/tab-bar-wheel.spec.tsx(jsdom 挂载,7 个用例)。
2026-08-14 22:59:19 +08:00
Atlas Zeng 277e7d3b40 Merge pull request #59 from omdsh-dev/feat/plugin-api-v012
feat: 设置页「添加插件」推荐目录 + 复制安装命令;移除内置 Office 预览
2026-08-14 22:56:24 +08:00
Menghuan1918 58999c2f3b fix: 回应 Codex review——复制反馈仅在写入成功时显示;插件名改为 window.open 按钮
- copy 现在 await writeClipboard 的布尔结果,剪贴板被拒时不再显示「已复制」(避免误导用户粘贴从未写入的命令)
- 插件名由 <a target=_blank> 改为 <button>,与「跳转」按钮同走 window.open——锚点会被 document capture 的链接接管拦截(忽略 target=_blank)开进侧边栏浏览器,按钮形态绕过
- 测试:新增剪贴板拒绝用例 + 名字/跳转双按钮 window.open 断言
2026-08-14 22:53:14 +08:00
Menghuan1918 2bcfdfce87 chore: 移除 Office 预览相关依赖与产物声明
- dependencies 删除 @aiden0z/pptx-renderer / @univerjs/preset-sheets-core / @univerjs/presets / docx-preview / xlsx
- files 删除已废弃的 lib/client-docx|xlsx|pptx.js 声明
- pnpm-lock.yaml 同步更新
2026-08-14 22:37:23 +08:00
Menghuan1918 cbc28473a0 feat: 设置页「添加插件」推荐目录 + 复制安装命令;移除内置 Office 预览
- 设置页侧边栏内容/文件预览两个网格末尾各增虚线卡片,弹出对应 kind 的插件弹窗
- 弹窗:GitHub topic 按钮(新标签页)+ 推荐插件目录(plugins-tabs.ts / plugins-viewers.ts)
- 每条目「跳转」(window.open 新标签页直达仓库)与「复制」(writeClipboard + 已复制反馈)
- 不做终端预填流:不关闭弹窗、不打开终端、无失败路径(install/installNotice/installError* 文案删除)
- 内置 Office 预览(docx/xlsx/pptx)整体移除,chunk 白名单 5→2(terminal/editor)
- 文案「内置插件」→「推荐插件」;AGENTS.md/README/设计文档同步
2026-08-14 22:30:34 +08:00
Atlas Zeng 1b0b382edc Merge pull request #54 from omdsh-dev/feat/plugin-api-v012
feat: 公共 API 基座化(v0.12.0,纯增量)
2026-08-14 22:02:14 +08:00
Menghuan1918 0995a58454 fix: 回应独立 CR 的 P2/P3 意见(0×P1)
- P2-1 onOpen 收到真正落地的 tab:url 新建场景从 landed 态按 id 反查
  (补丁后对象),回调不再拿到未打 url 的 tab
- P2-2 插件设置行显式取值源:FeatureSettingsRows 新增 valueSource prop,
  插件行读自己的 pluginSettings blob——插件键与宿主 pref 同名时不再
  静默读宿主值(原投影合并有读宿主写插件的读写不对称);prop 命名避开
  Object.prototype.valueOf 冲突
- P2-3 consumer 类型检查接入工程:package.json 新增 check:consumer-types
  脚本 + CI 在 build 后执行;脚本补 lib/types 存在性前置检查(防假绿)
- P3-1 url seed 只在新建时应用:dedupe 聚焦的 tab 不再被覆写 path
- P3-2 closeTab/activateTab 未知 id 严格 no-op:store.reduce 对同引用
  结果短路(不 notify、不重写 localStorage),根因修复 + 回归测试
- P3-3 closeTab/activateTab 增加可选 scope(cwd 随回调传递),Sidebar
  双树激活/关闭路径传入 { sessionId, cwd }
- P3-4 定向打开不再自动展开不可见 session 的面板
- P3-5 Sidebar 终端 pty 释放查找覆盖双树(底部面板终端不再漏掉 HTTP
  兜底)
- P3-6/P3-7 与 Nits:AGENTS.md/指南文档化(dedupeKey 纯函数、meta 用
  null 清除、回调 scope 的 cwd 语义);consumer fixture 补常量导出覆盖

测试 +6(url 落地保真 / url 不覆写聚焦 / 未知 id 无 notify / 定向打开
不展开 / scope 随回调 / valueSource 键隔离),464 全绿;typecheck、
build、consumer 校验全部通过。
2026-08-14 21:28:41 +08:00
Menghuan1918 319d34ed09 fix: 回应 Codex 审查的 4 条意见(2×P1 + 2×P2)
- P1 state.ts reduceFor:恢复共享 uid 计数器到调用前水平(目标 session 的
  max 可能低于激活 session 已用 id,否则下次 mint 撞 id 损坏布局);
  回归测试实证:无修复时 split 产生重复 pane id
- P1 service.ts 生命周期分类:dedupe 聚焦判定改为基于输入态的存在性
  (dedupeKey 匹配或 id 匹配都算聚焦)——按 path 去重 + 新 id 打开时
  不再对从未插入的幽灵 tab 触发 onOpen(资源泄漏源)
- P2 SideCardSection:乐观 prefs ref(optimisticRef)驱动嵌套 map 合并
  (tabsEnabled/viewersEnabled/pluginSettings),同 tick 多次写入不再
  互相丢弃;mergePluginSetting 抽纯函数 + 单测
- P2 state.ts schedulePersist:每 session 独立防抖定时器(persistTimers
  Map),定向打开不再取消其他 session 的待写;单测验证双写都落盘
2026-08-14 21:13:53 +08:00
Menghuan1918 ced3a1eade docs: README 指向外部插件指南与 AGENTS.md,设计文档补 v0.12.0 扩展记录
- README/README_EN:服务化小节与特性清单同时链接 AGENTS.md 与
  docs/external-plugin-guide.md;安装示例版本 0.10.3 → 0.11.0(npm 最新已发布版)
- docs/plans/2026-08-11-service-registry-design.md:追加 §18 v0.12.0
  基座化扩展记录(类型导出/零 Node 依赖/新方法/新字段/设置 seam/load signal/测试)
- docs/plans/2026-08-11-declarative-sidebar-settings-design.md:追加 §8
  设置 seam 开放记录(pluginSettings 持久化/pluginToggles/render/SettingsBody)
- docs/external-plugin-guide.md:修正纯度门交叉引用(§9 → §10)
2026-08-14 20:49:00 +08:00
Menghuan1918 82a69546d2 feat: 公共 API 基座化(v0.12.0,纯增量)— 类型/能力/设置 seam 全面补齐
- 类型生态:service 模块 re-export SidebarTab/SidebarState/SidebarStore/
  SidebarSnapshot/SidebarDiffRef/TabType/SessionScope/SidebarPrefs 等公共
  状态词汇(此前 TS2459 无法命名);context-types 去 Node 依赖(结构镜像
  SidebarHttpRequest/Response/UpgradeSocket/Head),client 可达声明图零
  node:* 引用,外部消费者无 @types/node + skipLibCheck:false 可编译
  (scripts/check-consumer-types.sh 守护)
- 服务能力:version/features 版本与能力探测(单调只增)、getSnapshot/
  subscribeState 状态订阅、updateTab(title/path/meta)、activateTab、
  openFile(路径派生 id 与内置拦截一致)、openTab(seed, scope?) 定向到
  指定 session(store.reduceFor 不扰动 UI)
- TabDescriptor:badge 角标(99+ 封顶,抛错吞掉)、onOpen/onActivate/
  onClose 生命周期回调(service 路径派发,dedupe 聚焦触发 onActivate
  而非 onOpen,抛错不打断流程);SidebarTab.meta JSON 可序列化跨刷新
  持久化(sanitize 宽松保留)
- 设置 seam 开放:PrefsSchema 增 pluginSettings 开放嵌套 map(z.dict),
  settings.pluginToggles(插件自有行,同 switch/text/number 控件)与
  settings.render(自定义面板,含 store/service/prefs/updatePluginSetting/
  close props);viewer 卡片 v0.12.0 起也有齿轮设置弹窗
- FileViewerDescriptor.load 增第三参 AbortSignal(EditorHost 生命周期中止)
- UI 接线:tab 激活/关闭改走 service(跨树激活修复 + 回调触发)、+ 菜单
  传 scope、TabBar badge pill 渲染;active-session scope 走原 reduce 路径
  保证 notify
- 测试 +23:生命周期/定向打开/updateTab/activateTab/openFile/meta 持久化/
  reduceFor/能力常量(与 package.json 锁步)、consumer-types.ts 编译门、
  api-surface.spec.ts(client 图零 node 依赖守护);既有 430 测试全绿
- 文档:AGENTS.md 修 available 三参示例并补全部新 API;external-plugin-
  guide.md 刷新至 v0.12.0(7 tab + 9 viewer、设置 seam 开放、类型导入
  路径建议);README×2 服务化小节同步

未发版 npm(用户决定);分支 feat/plugin-api-v012。
2026-08-14 20:44:43 +08:00
Atlas Zeng 288a499c7f Merge pull request #51 from omdsh-dev/fix/issue-42-auto-terminal-regression
test: issue #42 底部面板首展自动终端触发链回归测试 + 诊断文档
2026-08-14 20:20:35 +08:00
Menghuan1918 c70d868c58 test: issue #42 触发链回归测试 + 诊断文档(底部面板首展自动终端)
#42 的根因与 #25 同源(零尺寸容器同步 open xterm → 渲染器未创建 →
.dimensions/.handleResize 崩溃),已在 v0.11.0 修复(openWhenSized);
「整个面板空白」放大器已由 #31 修复(per-tab 崩溃隔离)消除。
本次无代码改动,补齐缺失的覆盖:

- tests/bottom-auto-terminal.spec.tsx:钉住 #42 触发链(首展自动开终端
  恰好一个 tab 落在底部工作树、bottomOpenedOnce 原子置位、pref / 类型
  禁用双门控、面板完整存活),此前该 effect 零测试覆盖;
- docs/plans/2026-08-14-issue-42-verify-design.md:诊断证据链、
  修复覆盖路径、AttachConsole(node-pty 上游限制,仅文档化)、
  issue 回复草稿。

验证:pnpm test 34 文件 / 430 例全绿;typecheck、build 通过。
2026-08-14 20:17:27 +08:00
Atlas Zeng 61e31321d6 Merge pull request #48 from omdsh-dev/fix/issue-31-sidebar-crash
fix: sidebar crash #31 — layout-push leak + per-tab error containment
2026-08-14 20:02:16 +08:00
Menghuan1918 9aeb0464ce fix: sidebar crash #31 — layout-push CSS-variable leak + per-tab error containment
Two failure modes of a sidebar render crash, fixed together:

1. Layout-push leak (root cause): the layout-push effect wrote
   --dsh-sidebar-width/--dsh-sidebar-height on document.documentElement
   without a cleanup, so ANY unmount of the Sidebar (error-boundary swap,
   plugin disable, HMR) left the variables behind and layout.css kept
   squeezing #root with a stale margin — "the sidebar cannot be hidden"
   until a full reload. The effect now removes both properties on cleanup
   (the CSS fallback 0px restores the layout).

2. Crash containment (UX): the root error boundary previously replaced the
   WHOLE sidebar — toggle cluster included — with an error strip on any tab
   crash. A new shared RenderBoundary (src/client/RenderBoundary.tsx) now
   scopes containment per tab: a crashing viewer/editor shows a dismissible
   strip inside its own pane while the toggle cluster, the other tabs, and
   the panel stay alive. The descriptor is rendered as a real element
   (createElement) so even top-level component throws land in the boundary,
   not in TabContent's own render. The root boundary remains as the last
   resort for Workbench/shell errors.

Tests (tests/sidebar-crash.spec.tsx, jsdom + real Sidebar/store harness):
- unmount clears both CSS variables (red before the fix: 307px leaked)
- a crashing tab is contained: strip in-pane, collapse button alive,
  layout push still live
- the retry button recovers a tab whose crash has since been fixed

Full suite: 425 tests pass; tsc --noEmit clean.
2026-08-14 18:29:31 +08:00
Atlas Zeng dff1595e77 Merge pull request #43 from ma15803216102/fix/html-preview-windows-drive-path
fix(html): Windows 盘符路径解码去掉前导斜杠,修复 html 预览被 isWithin 拒绝
2026-08-14 18:26:40 +08:00
Atlas Zeng 28d20a46c0 Merge pull request #35 from omdsh-dev/ci/github-actions
ci: add GitHub Actions workflow (tests + type/build checks)
2026-08-14 17:53:07 +08:00
Menghuan1918 10fdeae409 docs: 友情链接新增 dshfind 三方插件市场 2026-08-14 17:39:40 +08:00
Menghuan1918 562bc96f7e docs: 友情链接新增 dsh-TUI(ccch1mneyyy) 2026-08-14 17:39:39 +08:00
Menghuan1918 9c8bdc4aa5 ci: bump actions to Node-24-based majors (checkout@v7, setup-node@v7, pnpm/action-setup@v6)
Clears the runner deprecation annotation: the previous major versions
target Node 20 and are force-run on Node 24.
2026-08-14 16:57:06 +08:00
Menghuan1918 bd49fcc3cd ci: restrict spawn-helper test to macOS, its only platform
node-pty@1.1.0 builds the spawn-helper executable only for OS=="mac"
(binding.gyp); linux uses forkpty directly and ships no helper at all.
The test's non-Windows premise was wrong for linux, and no candidate
exists there to assert on.
2026-08-14 16:54:50 +08:00
Menghuan1918 c8fc6c7cbf ci: fix Linux runner test failures (shallow clone, git identity, node-pty prebuild)
- checkout with fetch-depth: 0: the git log paging test needs the full
  repository history (default shallow clone yields one commit)
- configure a global git identity on the runner: src/git.ts deliberately
  never sets one and revert/cherry-pick rely on ambient config
- tests/unit.spec.ts: node-pty@1.1.0 ships a prebuilt spawn-helper only for
  darwin/win32; on linux node-gyp compiles it into build/Release. Align the
  assertion with ensureSpawnHelper's candidate list instead of hardcoding
  the prebuilds/ path (the Linux test run was red for this reason)
2026-08-14 16:52:57 +08:00
马泽阳 6b4fed1010 fix(html): Windows 盘符路径解码去掉前导斜杠,修复 html 预览被 isWithin 拒绝
decodeHtmlUrl 对 'D:\' 盘符路径重拼为 '/D:/...',node path.resolve() 在 Windows
上会变成 'D:\D:\...'(盘符重复),html 路由的 isWithin(cwd) 围栏因此拒绝所有盘
符路径——打开 html 报 "html path outside the session working directory"。

修复:首段匹配 /^[A-Za-z]:$/(盘符段)时改回无前导斜杠的 'D:/...' 形式,
requireAbsolute 可原样解析回盘符路径;POSIX 路径行为不变。

测试:html-route.spec 更新 Windows round-trip 断言,新增小写盘符用例与
resolve() 回归护栏(win32 平台守卫)。
2026-08-14 16:45:44 +08:00
Menghuan1918 20bb60dd03 ci: add GitHub Actions workflow for tests and type/build checks
Run pnpm install --frozen-lockfile, typecheck, test (vitest) and build on
push to main and all pull requests.
2026-08-14 16:02:57 +08:00
Menghuan1918 df1ab1c65f docs: 友情链接新增 dshfind 三方插件市场 2026-08-14 16:00:23 +08:00
Menghuan1918 131398efe1 docs: 友情链接新增 dsh-TUI(ccch1mneyyy) 2026-08-14 15:56:42 +08:00
Atlas Zeng 09de13c7bc Merge pull request #30 from starkirby125918/fix/install-ps1-powershell51
fix: install.ps1 兼容 PowerShell 5.1(node -e 引号被吞 + UTF-8 BOM)
2026-08-14 15:44:23 +08:00
Atlas Zeng 77ed1ba437 Merge pull request #33 from omdsh-dev/fix/install-script-ws-yml
fix: install.sh 变量加花括号($WS_YML:→ ${WS_YML}),修复 macOS bash 3.2 unbound variable(#23)
2026-08-14 15:38:54 +08:00
Menghuan1918 d94465c28d fix: install.sh 变量加花括号($WS_YML:→ ${WS_YML}),修复 macOS bash 3.2 下 unbound variable(#23) 2026-08-14 15:38:43 +08:00
Atlas Zeng 387992a13f Merge pull request #32 from omdsh-dev/Menghuan1918-patch-1
Update README.md
2026-08-14 15:30:26 +08:00
Atlas Zeng 52341a9d60 Update README.md 2026-08-14 15:30:12 +08:00
Atlas Zeng cde107ce9e Merge pull request #27 from omdsh-dev/fix/terminal-open-zero-size
fix: 终端延迟到容器有尺寸后再 open(零尺寸初始化崩溃,WKWebView 底部面板空白,issue #25)
2026-08-14 15:28:47 +08:00
starkirby125918 9642e95e89 fix: install.ps1 兼容 PowerShell 5.1(node -e 改临时文件,补 UTF-8 BOM) 2026-08-14 15:22:56 +08:00
Menghuan1918 0c254a2197 docs: README 末尾新增友情链接(dsh-tianshu-tui,双语同步) 2026-08-14 15:03:03 +08:00
Menghuan1918 c5fda3716d docs: README 增加最近更新块(v0.11 要点 + #25 修复),精简功能一览(双语同步) 2026-08-14 14:59:44 +08:00
Menghuan1918 37d91befdb fix: 终端延迟到容器有尺寸后再 open(零尺寸初始化崩溃,WKWebView 底部面板展开后终端空白,#25) 2026-08-14 14:53:03 +08:00
Menghuan1918 d83490c93e feat: 终端二级设置支持自定义字体(字体族 + 9–32px 字号,实时生效)
- SidebarPrefs 新增 terminalFontFamily(空 = 跟随主题等宽字体)/ terminalFontSize(9–32,默认 13),PrefsSchema 与 parsePrefs 同步校验
- 声明式设置 API 扩展:SidebarSettingToggle 支持 type: 'switch' | 'text' | 'number'(缺省 switch,向后兼容)+ min/max/placeholder/unit
- 终端卡片齿轮弹窗新增「终端字体」「终端字号」两行:blur/Enter 提交、number 钳制、提交失败回滚草稿
- TerminalView 经 store 订阅实时应用字体(options + re-fit + resize),懒加载 chunk 无新增依赖
- 新增 resolveTerminalFont 纯函数(回退链:用户字体 → 主题 token → 内置等宽栈)并单测
- 版本 0.11.0,README/AGENTS.md/设计文档同步
2026-08-14 14:36:49 +08:00
Menghuan1918 96064c31a9 docs: DSH 对 client 改动热加载,更新各处「重启 DSH」说明为硬刷新即可(仅 host 半改动需重启) 2026-08-14 14:07:55 +08:00
Menghuan1918 047f48337d feat: 侧边卡片设置页视觉重构——分组容器卡片、计数徽标、自定义开关、加宽设置弹窗(描述 + 完成按钮),卡片齿轮与勾选徽标纵向对齐同尺寸 2026-08-14 14:07:52 +08:00
Logan 1f9af78c3c fix: enable selection-to-conversation popup in the markdown editor
The "add to conversation" selection popup was registered only for the
catch-all code viewer (viewerId === 'code'), so selecting text in the
CodeMirror editor of a markdown file produced no popup; insertion only
worked in markdown preview mode via the separate DOM-selection handler.

Extend the CodeMirror selection popup to the markdown editor
(viewerId === 'markdown') so line-exact selections can be injected into
the composer draft (\`\`\`rel/path:start-end\`\`\` payload), matching
preview-mode behavior and the code viewer.
2026-08-14 12:59:17 +08:00
Atlas Zeng efb2e2bd58 Merge pull request #17 from omdsh-dev/release/v0.10.3
release: v0.10.3 — turnTail 拦截修复 + 安装体验升级(新增 Windows 一键脚本)
2026-08-14 01:04:27 +08:00
Menghuan1918 97e52b650e docs: 安装章节折叠技术细节为可折叠块,版本引用更新为 0.10.3 2026-08-14 00:59:02 +08:00
Menghuan1918 3cbf43854b docs: README_EN 同步安装章节(PowerShell 一键命令 + 分步手动 + 故障排查表) 2026-08-14 00:49:01 +08:00
Menghuan1918 e128c303bc feat: 新增 Windows PowerShell 一键安装脚本 install.ps1,README 安装章节两平台同步
- scripts/install.ps1:官方 CLI 方式一键安装(PowerShell 5.1+/pwsh),
  与 install.sh 对齐:预写 allowBuilds + minimumReleaseAgeExclude(幂等)、
  执行 dsh plugin add、幂等清理旧手动挂载行;支持 -Version/-Restart/-DryRun。
- scripts/install.sh:加固 — -h/--help、Windows Git Bash 下 DSH_HOME 回退
  $USERPROFILE、npm/pnpm view 双通道解析最新版本、失败提示细化、挂载行
  清理改为逐块扫描(删除注释块跟随)。
- README.md:安装章节改为「① 前置 → ② 对应平台一键命令 → ③ 重启硬刷新」,
  macOS/Linux 与 Windows PowerShell 双命令示例。
- package.json:files 列表纳入 scripts/install.ps1。
2026-08-14 00:48:44 +08:00
Menghuan1918 8bf6d1d7ba chore: v0.10.3 — 发布 turnTail 拦截 slots.inject 修复(issue #15) 2026-08-14 00:41:10 +08:00
Menghuan1918 0f9af851ae fix: 聊天产出文件行拦截改用 slots.inject 延迟注册(issue #15)
conversation.chat.turnTail 是宿主 ui-conversation 在 conversation.chat.node
children 表中声明的子插槽,直接 slots.register 会早于声明而抛
"not declared (a parent entry's children table must declare it)",
导致拦截注册失败回退默认 deliverables 行为并弹诊断条。

改用 ctx.slots.inject 等待声明落账后再注册(与官方
dsh-client-ui-deliverables 同款写法):已声明则同步注册,未声明则
在声明提交时注册,声明撤销自动卸载、重声明重新注册,fiber 卸载
幂等清理。editor tab 禁用门控与 openInSidebar 座位不变。

新增 tests/turn-tail-intercept.spec.ts 覆盖已声明/待声明/提前销毁
三种注册时机、editor 禁用回退与 openInSidebar 座位接线。
2026-08-14 00:41:08 +08:00
Menghuan1918 3d88752eb1 docs: 供应链策略说明改为「命令链无法完全规避,24h 后自动消失」 2026-08-14 00:21:01 +08:00
Menghuan1918 1b7c8ba497 docs: 安装置顶一键脚本;手动方式改为 npx dsh 命令链(自愈构建脚本拦截) 2026-08-14 00:12:25 +08:00
Menghuan1918 5a93f610d8 docs: 官方 CLI 安装默认最新版(不带 @版本) 2026-08-13 23:48:56 +08:00
Menghuan1918 9fdb44d9f9 feat: 支持官方 CLI 一行安装(dsh plugin add dsh-better-sidebar)
- package.json 声明 dsh.bundle.patch(./cordis.patch.yml),随包发布挂载
  patch;dsh plugin --profile <name> add dsh-better-sidebar@<v> 自动完成
  安装 + 注册到 dsh.profile.bundles + 下次启动挂载(无需手写挂载行)
- cordis.patch.yml:bundle 挂载层(insert better-sidebar)
- scripts/install.sh:改走官方 CLI;预写 allowBuilds(node-pty/protobufjs)
  规避 pnpm 11 strict-dep-builds 非零退出;幂等移除旧手动挂载行防双挂载
- README(EN):安装置顶官方 CLI 一行命令

v0.10.2
2026-08-13 23:19:05 +08:00
Menghuan1918 e515d74f77 chore: v0.10.2 — 发布 [data-slot] 布局修复 2026-08-13 22:54:19 +08:00
Menghuan1918 0a07cff6a3 fix: 适配 DSH 0.1.x 的 [data-slot] DOM 结构,恢复 Session log 挤压与布局
DSH 0.1.x 把 slot 宿主包进 [data-slot] 容器:
#root > div[data-slot=root] > div.frame > div.centerCol
  > div[data-slot=conversation] > div.root[data-phase]
    > div[data-slot=conversation.session.header] > header

旧选择器 #root > div > div:nth-child(2) 选中的是 data-slot=root 包装
层的第 2 子元素(不存在,其唯一子元素是 frame),导致:
- 折叠态 header 右 padding 挤压失效(Session log 胶囊被按钮簇盖住)
- 底部面板纵向挤压 / 拖动过渡 / 减少动效全部失效
- Sidebar.tsx 中间列定位器(#root > div 的 children[1])失效,
  底部面板宽度测量回退为 0

修复:全部改用 [data-slot] 语义锚点——
- 中间列:#root > div[data-slot=root] > div > div:nth-child(2)
- header:body[data-dsh-sidebar-collapsed]
  [data-slot=conversation.session.header] > header
- Sidebar.tsx 定位器:#root [data-slot=conversation] 的 parentElement
  (= centerCol,探针实测确认)

v0.10.1
2026-08-13 22:53:31 +08:00
Menghuan1918 a85d1ab004 feat: v0.10.1 — 新增一键安装脚本 scripts/install.sh,README 安装置顶 npm 方式
- scripts/install.sh:把插件以 npm 包形式装进 web profile(幂等写依赖 + 挂载行 + pnpm install;支持版本参数 / --restart / --dry-run)
- 版本 0.10.0 -> 0.10.1(修正 npm 元数据 repository URL 到 omdsh-dev)
- scripts/install.sh 纳入发布 files
2026-08-13 22:42:26 +08:00
Menghuan1918 b9f8eea56d docs: 安装方式改为以 npm 包为主(源码/plugin-registry 降为可折叠可选) 2026-08-13 22:33:41 +08:00
Menghuan1918 72ba010ab7 docs: README 增加 npm 安装方式(dsh-better-sidebar@0.10.0) 2026-08-13 22:29:49 +08:00
Menghuan1918 1b7014b2d4 docs: 仓库迁移至 omdsh-dev/DSH-better-sidebar,更新 URL 引用 2026-08-13 22:29:20 +08:00
Menghuan1918 64c6aa477e chore: v0.10.0 npm 发布准备
- @deepseek-ai/* 依赖换为实际发布版本(dsh-* ^0.1.0-rc.6、cordis ^4.0.1),
  补齐 dsh-client-locale peer/dev 声明(对齐 dsh.client.inject)
- pnpm-lock.yaml 重新生成(移除旧 0.0.1-rc.1 图;锁文件不再引用未公开的 dsh-type-meta)
- pnpm-workspace.yaml 补 minimumReleaseAgeExclude 新版本条目
- src/context-types.ts 增加 @deepseek-ai/cordis 双 augmentation
- package.json 发布脚手架:publishConfig.access、prepublishOnly、files 含 README_EN/LICENSE
- README 版本注释同步(^0.1.0-rc.6 / dsh@0.1.0-rc.6)
2026-08-13 22:25:20 +08:00
Menghuan1918 5f6d4c97d5 docs: 快捷键移至安装之后,头部介绍区改用 HTML 徽标样式排版 2026-08-13 20:28:05 +08:00
Menghuan1918 58d6167b69 docs: README 顶部介绍区改为 HTML 居中排版并新增多语言跳转 2026-08-13 20:23:20 +08:00
Menghuan1918 23b1b564d7 docs: 新增英文 README_EN.md,优化安装指引(@deepseek-ai/* 已公开、plugin-registry 折叠) 2026-08-13 20:19:56 +08:00
Menghuan1918 b31a9a601f fix: md 预览代码块复制按钮跟随 DSH i18n(MarkdownText 补传 codeLabels) 2026-08-13 10:26:57 +08:00
Menghuan1918 2e9db44a71 chore: v0.9.0 — 版本升级(适配 DSH 命名契约重命名) 2026-08-13 09:07:19 +08:00
Menghuan1918 1bfabbbfc2 feat: 适配 DSH 命名契约重命名(jobs/terminal/webServer)并处理 Session log 按钮位置
- tasks→jobs:ctx.jobs / job_output / job_id / session/jobs / jobsBySession / Job* 词汇
- httpServer→webServer(inject + augmentation + register/registerUpgrade)
- autoOpenTasks→autoOpenJobs 偏好键
- 右上角开关簇回到角落,注入 CSS 挤开 header 的「Session log」胶囊
2026-08-13 09:06:55 +08:00
Menghuan1918 ce052faee8 feat: 预览区划选文字浮窗「添加到对话」(md 预览 + 兜底 code 查看器) 2026-08-13 01:39:59 +08:00
Menghuan1918 a2bb04bce0 fix: 底栏宽度直测中间列,修复启动竞态下底栏跑到最左边
- 底栏 left/right 由「appSidebarWidth + detailsWidth + 面板宽反推」改为直接测量 AppFrame 中间列的 bounding rect(唯一真源,展开/收起动画期间跟随 margin-right 挤压)
- 拖动期间暂停测量(avoid per-frame 重渲染卡顿),拖动结束显式重测
- #root MutationObserver 自愈启动竞态:旧实现一次查询失败后永远卡在零尺寸回退(表现为 1px 细条贴在视口最左)
2026-08-13 01:17:01 +08:00
Menghuan1918 422d5e3c10 chore: v0.8.2 — 版本升级(跟随 DSH 的 i18n) 2026-08-13 00:43:36 +08:00
Menghuan1918 9c5293c94b feat: 界面文案跟随 DSH 的 i18n(ctx.locale 语言偏好、实时切换、词典入注册表) 2026-08-13 00:43:28 +08:00
Menghuan1918 983d2b2d71 chore: v0.8.1 — 版本升级(任务输出回放活事件镜像) 2026-08-12 23:04:30 +08:00
Menghuan1918 6e4474cd2e fix: 任务输出回放合并活事件镜像,修复重启后 store 会话滞后导致新读取不可见
- host 半挂 ctx.on('session/event')(api-proxy 同款 API),镜像 task_output
  的 tool/call + tool/result 活事件(每会话 200 条有界环,无关结果不缓存)
- tasks.output 回放合并「store 事件日志 + 活事件镜像」,按 seq 去重排序配对
- 零 DSH 写入:会话 store 在宿主重启后可能冻结在再水合边界(持久化文件
  停在重启时刻),活事件只走 append 推送链;镜像绕开该滞后
- 测试:新增镜像用例(live 事件、跨会话隔离、种子+镜像合并不双计),
  全量 373 通过
2026-08-12 23:04:30 +08:00
Menghuan1918 88f116338a feat: 任务管理页二级开关「有新后台任务时自动展开」(autoOpenTasks)
- 新 pref autoOpenTasks(默认开):当前会话出现新后台任务(任意新任务 id,
  非仅首个)时自动展开侧边栏并打开任务管理页,与 autoOpenSubagent 并列
- 注册为 subagent tab 的声明式二级开关(设置页齿轮弹窗,key 在 PrefsSchema)
- Sidebar 新增独立基线 effect(镜像子代理自动展开;刷新不触发、开关关闭/类型
  禁用不触发);detectNewTask 纯函数(settling/同 id/他会话不触发)
- 测试:detectNewTask 用例、prefs 解析断言、builtins 开关清单、schema 默认值
  断言;全量 371 通过
2026-08-12 23:04:30 +08:00
Menghuan1918 b90251cce5 fix: 遵守仓库硬约束(禁止修改 DSH 源码)——tasks.output 改为会话事件回放
- 回退对 harness peek 缝隙的依赖:输出查看不再调用注册表,改为从属主
  会话事件日志回放 task_output 的 tool/call+tool/result 对(模型已读内容)
- read:false 时面板提示「模型尚未读取」;'(no new output)' 增量与错误
  结果过滤;截断上限不变;kill 走注册表原生 API(缺席时 503)
- AGENTS.md 新增 §0 硬约束:禁止修改 DSH 源码、挂载只走 cordis.patch.yml
  + profile 机制;设计文档同步实施偏差
- 测试:tasks-routes 重写为回放语义(拼接/过滤/read 标志/截断/503),
  视图新增 read:false 提示用例;全量 369 通过
2026-08-12 23:04:30 +08:00
Menghuan1918 8c521be26e chore: v0.8.0 — 任务管理页(子代理拓扑 + 后台命令底部输出 Dock、两击确认终止)
- tab 更名为任务管理:同页显示当前树全部后台命令(bash/pwsh 徽标、状态、时长、属主、退出码)
- 点击任务行在侧边栏底部共享 Dock 显示输出(sticky 终端式、自动跟随、单一轮询实例)
- 运行中任务两击确认强制终止;大量任务时列表保持紧凑、排序变动不跳动
- 新增 /sidebar/api tasks.output(非消费 peek,不偷模型 task_output 字节)与 tasks.kill 路由
- 修复任务镜像清空时的 React #300 hook 顺序崩溃(useCallback 移到提前 return 之前)+ 回归测试
2026-08-12 23:04:30 +08:00
Atlas Zeng 27bc49830e Update README.md 2026-08-12 19:06:18 +08:00
Menghuan1918 96b83ae3b8 chore: v0.7.0 — 版本升级(客户端懒加载、移动端体验、内容打开自动展开) 2026-08-12 18:39:19 +08:00
Menghuan1918 cb9a7cf751 feat: 内容型打开自动展开承载面板(宽视口)
宽视口下 openTab 带 path/url seed 时,承载落点 pane 的面板折叠则自动展开:
落点在底部树展开底部面板,否则展开右侧面板;去重聚焦既有 tab 同样展开
(打开必须落在视野内)。窄视口抽屉逻辑不变;类型型打开(+ 菜单、agent
终端自动补 tab)不展开。同步 AGENTS.md / README 文档与测试。
2026-08-12 18:39:17 +08:00
Menghuan1918 ffc2270d77 fix: 移动端合并改为底部标签直接并入右侧栏(迁移式,删除双工作台堆叠方案)+ 窄屏断点收紧至 768px(不对齐宿主 1024) 2026-08-12 18:27:28 +08:00
Menghuan1918 b391ebffed feat: 移动端侧边栏体验 — 窄视口(<1024px)双工作台合并为单面板抽屉(可拖分隔条、高度与底部面板互通)、不提供底部面板按钮、文件/外链打开自动展开、新会话默认收起 2026-08-12 18:20:34 +08:00
Menghuan1918 bab659f8bd feat: 客户端懒加载分块 — 重依赖(Univer/xterm/CodeMirror/Office)拆独立 bundle 按需下发(/sidebar/bundle 路由 + __dshChunks__ 注册表),核心启动仅 ~325KB 2026-08-12 18:19:44 +08:00
Menghuan1918 bf54a60e37 chore: v0.6.0 — README 更新双面板说明并升版本 2026-08-12 16:54:36 +08:00
Menghuan1918 c7af7dccdb feat: 侧边栏双面板 — 右上角按钮簇、底部面板(首展自动终端/关闭按钮)、跨面板拖 Tab、拖动性能优化
- 右上角持久按钮簇:横向排列、独立 glyph(底栏/侧拉)、始终钉在视口右上角(侧边栏展开时位于其标签条右端,真实挤占 Tab 空间)
- 底部面板:独立工作台,只挤占中间 Agent 输出区(不覆盖左右侧边栏),右缘分割线、关闭 x、首展自动开终端(二级设置开关,默认开)
- 两面板拐角双向拖动调节尺寸;拖动改为 rAF 节流直写 DOM,消除卡顿;面板尺寸互钳制防丢失
- 跨面板拖 Tab(合并/拆分落点),moveTab/moveTabToEdge 支持跨树移动
- 欢迎卡片改响应式网格;布局/几何全部按会话持久化,旧状态平滑迁移
2026-08-12 16:43:24 +08:00
Menghuan1918 7eccc49f6e revert: 移除构建期 IME 守卫补丁 — 补丁打在产物上不具持久性
taekchef 的 apply-ime-guard.mjs 在每次 build/prepare/bundle 后把守卫
字符串注入 lib/client.js,但补丁对象是构建产物而非源码:
- 裸 tsdown 重建 / 其他构建路径产出无守卫的 bundle(#562 复发根因)
- 守卫逻辑游离于源码之外,难以测试与维护

运行时方案(src/client/ime-guard.ts + tests/ime-guard.spec.ts)已在
源码内实现同等守卫,经 document capture 覆盖全部劫持路径,任何构建
方式产物都自带守卫。合并远端提交后撤掉构建期补丁,保留运行时方案。
2026-08-12 14:41:36 +08:00
Menghuan1918 d1c393a048 Merge branch 'main' of https://github.com/dsh-external/DSH-better-sidebar 2026-08-12 14:41:26 +08:00
taekchef a465bdee38 fix(ime): 持久化 IME 守卫脚本并挂入 build/prepare/bundle 构建路径
- scripts/apply-ime-guard.mjs 纳入版本库(此前 untracked,重 clone 即丢)
- Univer InputNumber 在 document capture 拦截全页 ↑/↓ 且无 isComposing
  守卫,会摧毁中文输入法组合;prepare/bundle 裸 tsdown 重建会产出无守卫
  的 lib/client.js,2026-08-11 因此复发(dsh-external/issues#562)
- 脚本幂等:已注入则跳过;目标模式匹配不到则 exit 1 显式失败

Fixes #562
2026-08-12 03:13:31 +08:00
Menghuan1918 31b78f7941 fix: IME 组合期按键守卫 — document capture 拦截,防内联第三方组件劫持方向键(#562)
Univer InputNumber 的 keydown 对 ArrowUp/Down 直接 preventDefault 且无
isComposing 守卫,中文输入法组合期候选词方向键被吞(#562 回归,同类 #535)。
新增 src/client/ime-guard.ts:document capture 阶段对组合期按键(isComposing
或 keyCode 229)stopPropagation,运行于 React 委托与任何原生监听之前,覆盖
内联依赖的全部劫持路径;apply 时经 ctx.effect 注册(HMR-safe)。

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

- prefs-shared/config/prefs: 新增 interceptOpenPath 字段(默认 true)
- openpath-intercept.ts: 零依赖包装器,纯插件层面实现,不改 DSH
- intercept.tsx: registerOpenPathInterception 接线(当前会话 scope)
- SideCardSection: 常规区新增开关卡片(zh/en 文案)
- 测试: 包装器单测 6 个 + 接线集成测试 + pref 解析/设置页断言更新
2026-08-11 16:48:59 +08:00
Menghuan1918 8f3bb1b264 chore: release v0.4.3 — README 精简重排 + npm 启动模式实测兼容
- README 341 → 130 行:安装/更新合并为一份提示词 + 手动要点,功能一览
  逐节压缩,registry 通道降为可选小节,开发/架构/限制精简;新增
  「npm 包启动(npx @deepseek-ai/dsh)同样可用,v0.4.3 起实测验证」
- 版本 0.4.1 → 0.4.3;cordis.patch.yml 注释同步
2026-08-11 15:47:16 +08:00
Menghuan1918 80f4406ff2 chore: devDependencies 去硬编码 — @deepseek-ai/* 改从 npm 解析(^0.0.1-rc.1)
- package.json:14 条 link:/Users/menghuan/.dsh/source/current/... 绝对路径
  改为 ^0.0.1-rc.1(npm registry 预发布版,官方已发布 0.0.1-rc.1);
  peerDependencies 同步 ^0.0.1-rc.1 以匹配 rc 系列
- 安装需安装者自己的只读令牌(~/.npmrc 配置 //registry.npmjs.org/:_authToken,
  不写进仓库);或改回指向自己 ~/.dsh/source/current 的 link: —— README 三处说明更新
- .gitignore 加 .npmrc 防令牌误提交;token 全程仅环境变量/临时文件使用,已删除
- vitest.config.ts:npm 版 ui-primitives 的 lib 顶层 import katex css,
  node_modules 外部化会被 Node 原生加载撞上 .css —— inline 该包走 Vite 转换 stub
- pnpm-lock.yaml 由 link: 仓库改为 registry tarball(+2001 行);
  pnpm-workspace.yaml 追加 minimumReleaseAgeExclude(pnpm 11 自动记账)
2026-08-11 14:46:56 +08:00
161 changed files with 22680 additions and 6749 deletions
+105
View File
@@ -0,0 +1,105 @@
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
# Cancel a superseded PR run (it is on a stale commit); never cancel a
# push run — it is already producing the post-merge signal. The mount job
# boots a real DSH + Chromium, so the savings matter there most.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
ci:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
with:
# Tests run git log against this repository and need the full history.
fetch-depth: 0
- uses: pnpm/action-setup@v6
with:
version: 11
- uses: actions/setup-node@v7
with:
node-version: 22
cache: pnpm
# The plugin's git revert/cherry-pick spawn git without setting an
# identity (deliberate, see tests/smoke.spec.ts) and rely on ambient
# config; the runner has none by default.
- name: Configure git identity for tests
run: |
git config --global user.name "dsh-better-sidebar-ci"
git config --global user.email "ci@dsh.invalid"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Typecheck
run: pnpm typecheck
- name: Test
run: pnpm test
- name: Build
run: pnpm build
# The consumer-facing declaration surface guard: a browser-only plugin
# (no @types/node, skipLibCheck: false) must type-check against the
# shipped lib/types. Runs AFTER build (it reads the built output).
- name: Check consumer type surface
run: pnpm check:consumer-types
# Pack the plugin as an npm tarball, mount it into a REAL DSH instance
# through the official `dsh plugin --profile web add` channel, and render
# the DSH web UI headlessly (Playwright Chromium) to prove the plugin
# mounts without crashing the shell. Drives `pnpm test:mount`, which boots
# a keyless `dsh web` against a scratch profile and sweeps every built-in
# sidebar tab. No API key needed — the whole lane is deterministic.
plugin-mount:
runs-on: ubuntu-latest
timeout-minutes: 30
# CI runs must never report to the production telemetry endpoint baked
# into the DSH composition (same guard DSH's own CI uses).
env:
DSH_TELEMETRY_DISABLED: '1'
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
with:
version: 11
- uses: actions/setup-node@v7
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Pin the DSH CLI the lane mounts into; the profile's bundle stack
# (dsh-base / dsh-web-app) resolves from this installation. Keep in
# step with the plugin's @deepseek-ai/* peer range when bumping.
- name: Install dsh CLI (pinned)
run: npm install -g @deepseek-ai/dsh@0.1.0-rc.6
# lib/ is gitignored; the tarball is produced from this build.
- name: Build and pack the npm tarball
run: pnpm build && pnpm pack
- name: Install Playwright Chromium
run: pnpm exec playwright install --with-deps chromium
- name: Mount + headless-render smoke
run: pnpm test:mount
- name: Upload Playwright report on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: |
playwright-report/
test-results/
if-no-files-found: ignore
+97
View File
@@ -0,0 +1,97 @@
name: Release
# Publishes `dsh-better-sidebar` to npm via npm Trusted Publishing (OIDC) —
# no NPM_TOKEN secret required, provenance is attached to the tarball.
#
# Trigger: a GitHub Release is published (tag vX.Y.Z). The tag MUST match
# package.json `version`; the workflow verifies this before publishing.
#
# One-time npm-side setup (manual, on npmjs.com):
# 1. package `dsh-better-sidebar` -> Settings -> Publishing access
# -> Trusted Publishers -> "Add Trusted Publisher".
# 2. Provider: GitHub Actions
# Organization or user: omdsh-dev
# Repository: DSH-better-sidebar
# Workflow filename: release.yml
# Environment: (blank)
# 3. Save. Do NOT add an NPM_TOKEN secret — Trusted Publishing replaces it.
# Canonical reference: https://docs.npmjs.com/trusted-publishers
on:
release:
types: [published]
workflow_dispatch:
inputs:
dry_run:
description: Pack and verify everything, but do not publish
type: boolean
default: false
permissions:
contents: read
id-token: write # mint the OIDC token npm Trusted Publishing exchanges
concurrency:
group: npm-release
cancel-in-progress: false
jobs:
publish:
name: build · verify · publish
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
with:
# Tests run git log against this repository and need full history.
fetch-depth: 0
- uses: pnpm/action-setup@v6
with:
version: 11
- uses: actions/setup-node@v7
with:
node-version: 22
registry-url: https://registry.npmjs.org
cache: pnpm
# The plugin's git revert/cherry-pick spawn git without an identity
# (see tests/smoke.spec.ts); the runner has none by default.
- name: Configure git identity for tests
run: |
git config --global user.name "dsh-better-sidebar-ci"
git config --global user.email "ci@dsh.invalid"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Verify release tag matches package.json version
if: github.event_name == 'release'
env:
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
expected="v$(node -p "require('./package.json').version")"
echo "package.json version: ${expected#v}"
echo "release tag: ${RELEASE_TAG}"
if [ "${RELEASE_TAG}" != "${expected}" ]; then
echo "::error::Release tag ${RELEASE_TAG} does not match package.json version ${expected#v} (expected ${expected})."
exit 1
fi
- name: Build
run: pnpm build
- name: Typecheck
run: pnpm typecheck
- name: Test
run: pnpm test
- name: Dry-run publish (validation only)
if: github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'true'
run: pnpm publish --dry-run --no-git-checks
- name: Publish to npm
if: github.event_name == 'release' || github.event.inputs.dry_run != 'true'
run: pnpm publish --provenance --access public --no-git-checks
+9
View File
@@ -145,3 +145,12 @@ lib/
# plugin-registry install staging (assembled by scripts/package-registry.mjs)
registry/
.npmrc
# stray npm cache scratch dir
.npm-cache-tmp/
# Playwright headless-render lane (tests/e2e) artifacts
playwright-report/
test-results/
+214 -21
View File
@@ -6,6 +6,34 @@ better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务(Cordis context
---
## 0. 仓库硬约束(所有本仓库插件必须遵守)
- **禁止修改 DeepSeek Harness (DSH) 源码**:对官方源码 checkout(`~/.dsh/source/current`)零写入——不得改 harness 包、不得把 harness 改动提交到它的分支。
- **代码改动必须走 PR**:功能 / 修复 / 测试等非文档改动一律在分支上开发(`feat/*` / `fix/*`),用 `gh pr create` 发起 PR,review 合并后才进 main;**仅纯文档类改动**(README / AGENTS.md / docs/ 等)允许直接推送到 main。
- **挂载只走 `cordis.patch.yml` + profile 机制**(`~/.dsh/profiles/<profile>/`),插件永远作为独立包被 profile 引用,不反向侵入 DSH。
- 需要 harness 没有的能力时,用 DSH **现成的只读/公开 API** 或插件自有路由实现(参考 §7 的 `jobs.output` 事件回放:读会话事件日志而非动注册表);如果确实做不到,先向用户说明取舍,而不是直接改 DSH。
### CI 挂载冒烟(`plugin-mount` job / `pnpm test:mount`)
仓库的 CI 有一条「npm 打包 → 真实挂载 → 无头渲染」门禁(`.github/workflows/ci.yml` 的 `plugin-mount` job),证明**打包产物**在真实 DSH 上挂载后无头渲染不会 crash:
1. `pnpm build && pnpm pack` 产出 tarball(与发布产物一致)。
2. `scripts/e2e-mount.sh` 用官方 CLI 把它装进一个**全新 scratch profile**(`dsh plugin --profile web add file:<tarball>`,触发 `dsh.profile.bundles` 协调),然后启动真实 `dsh web`(keyless,`--port 0`)。
3. `tests/e2e/mount.e2e.ts`(Playwright Chromium)加载页面,断言外壳与 `[data-dsh-better-sidebar]` 挂载、无 `dsh-better-sidebar:` 错误条、无 pageerror/插件 console 错误,并通过「+ 菜单」逐个打开内置 tab(含终端懒加载 chunk)深扫,再经 Explorer 打开 seed 文件强制加载 editor 懒加载 chunk(`client-editor.js`)——缺失的内置 tab 或 chunk 都会使门禁变红。
本地跑:`pnpm build && pnpm pack && pnpm exec playwright install chromium && pnpm test:mount`(需 PATH 上有 `dsh` 或可经 npx 拉取)。DSH CLI 版本在 CI 钉住 `@deepseek-ai/dsh@0.1.0-rc.6`(与插件 peer 范围同步)。`tests/e2e` 的 spec 命名 `*.e2e.ts` + vitest `exclude` 双保险与 vitest 隔离;**改动 vitest `exclude` 时必须保留默认排除项**(`exclude` 会整体替换默认值)。
### npm 发版(GitHub Release → npm publish)
`.github/workflows/release.yml` 在 GitHub Release 发布(tag `vX.Y.Z`)时自动发版到 npm:
1. **发版前置**:`package.json` 版本号 bump 到 `X.Y.Z`(manifest 一致性守卫会校验其它副本),CI 全绿后打 tag `vX.Y.Z` 并发布 GitHub Release。tag 必须与 package.json 版本一致——workflow 在发布前校验,不匹配直接失败、不发版。
2. **流程**:workflow 依次执行 `pnpm build` / `pnpm typecheck` / `pnpm test`,校验 tag,然后 `pnpm publish --provenance --access public`(`prepublishOnly` 会重建产物;产物带 provenance 签名)。
3. **认证**:走 npm **Trusted Publishing(OIDC)**,不需要也不应配置 `NPM_TOKEN` secret。一次性手动配置(在 npmjs.com 完成):package `dsh-better-sidebar` → Settings → Publishing access → Trusted Publishers → Add Trusted Publisher,字段为 Provider `GitHub Actions`、Organization `omdsh-dev`、Repository `DSH-better-sidebar`、**Workflow filename `release.yml`**、Environment 留空。未配置前发布会失败(OIDC 交换报错),配置后无需改 workflow。
4. **调试**:`workflow_dispatch` 手动触发 + `dry_run=true` 只打包验证、不发版。
---
## 1. 服务定位
- **服务名**:`betterSidebar`(即 `ctx.betterSidebar`)
@@ -78,7 +106,16 @@ export function apply(ctx: Context): void {
import type { TabDescriptor, FileViewerDescriptor, BetterSidebarService } from 'dsh-better-sidebar'
```
类型定义在 `lib/types/client/service.d.ts`,通过 `package.json` 的 `./client/service`(别名 `./client/api`)exports 子路径暴露。
类型定义在 `lib/types/client/service.d.ts`,通过 `package.json` 的 `./client/service`(别名 `./client/api`)exports 子路径暴露。v0.12.0 起服务模块还 re-export 了完整的状态词汇表,消费者可以直接命名(不再只能靠推断):
```ts
import type {
SidebarTab, SidebarState, SidebarStore, SidebarSnapshot, SidebarDiffRef, TabType,
SessionScope, SidebarPrefs, OpenTabSeed, SidebarSettingsRenderProps,
} from 'dsh-better-sidebar/client/service'
```
> 💡 **类型合并触发路径**:`import type {} from 'dsh-better-sidebar/client/service'` 同样会加载 `Context` 的 augmentation(`declare module 'cordis'` 在 context-types.d.ts 中,service 声明会拉入它)——纯浏览器侧插件建议走 `client/service` 路径,避免拉进宿主半的 Node 类型图(主入口 `dsh-better-sidebar` 的声明面含宿主代码,宿主消费者本就处于 Node 环境)。client 可达声明图(`client/*` + context-types + html-route + prefs-shared)自 v0.12.0 起**零 Node 依赖**(`scripts/check-consumer-types.sh` 守护),无 `@types/node`、`skipLibCheck: false` 也能编译。
---
@@ -110,26 +147,95 @@ interface TabDescriptor {
* 返回 undefined 表示不去重(每次都新开,但同 id 会被 id 安全网聚焦)。
* 内置策略:explorer/git/subagent 用 single: true;editor 用 tab => tab.path;diff 用 tab => tab.id。
*/
dedupeKey?: (tab: SidebarTab) => string | undefined
dedupeKey?: (tab: SidebarTab) => string | undefined // 必须保持纯函数:每次 open 会求值两次,抛错会向外传播
/**
* 自定义 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.13.0+):聊天/界面外链被接管(`browserInterceptLinks`
* 总闸 + URL 对应协议开关均开)时,第一个 `urlTarget(url)` 命中且未被设置
* 禁用的注册 tab 类型以 `openTab({ type, url, title: hostname })` 打开——
* URL 即全部载荷(tab 从 `tab.path` 读取)。注册顺序先到先得;谓词抛错被
* 吞掉(跳过该类型)。内置 browser 不声明 urlTarget,永远是隐式兜底,不会
* 遮蔽插件声明。要同时容纳多个 URL 需用 createTab 铸造 per-URL id(browser
* 同款模式);否则同类型二次点击被 id 安全网聚焦,新 URL 不会覆写既有 path。
*/
urlTarget?: (url: URL) => boolean
/**
* 声明式设置(v0.4.1+):每个注册的 tab 都会在 Side card 设置页获得一行
* 开关(图标 + 标题 + 类型 id),`settings.toggles` 在其行下追加嵌套开关,
* 绑定 SidebarPrefs 字段。嵌套开关仅父级启用时显示。
* 开关(图标 + 标题 + 类型 id),`settings.toggles` 在其行下追加嵌套设置行,
* 绑定 SidebarPrefs 字段。嵌套设置仅父级启用时显示(v0.11.0 起行控件不限于
* 布尔开关:`type: 'switch' | 'text' | 'number'`,缺省 'switch';text/number
* 行 blur/Enter 提交,number 行按 min/max 钳制,unit 渲染单位后缀)。
* v0.12.0 起增加两个插件自有扩展(详见 §5 声明式设置):
* `pluginToggles`(插件自有 key,持久化在 pluginSettings[id],无需宿主 schema 字段)
* 与 `render`(自定义设置面板,替代行列表)。
*/
settings?: {
toggles?: readonly {
/** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools') */
/** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools' / 'terminalFontFamily' / 'htmlViewerNoSandbox' / 'htmlViewerDefaultUnsafe' / 'browserNoSandbox' / 'browserInterceptLinks' / 'browserInterceptHttp' / 'browserInterceptHttps') */
key: string
title: string | (() => string)
desc?: string | (() => string)
/** 行控件类型;缺省 'switch'(向后兼容)。 */
type?: 'switch' | 'text' | 'number'
/** number 行的提交钳制下限。 */
min?: number
/** number 行的提交钳制上限。 */
max?: number
/** text 行的输入占位符。 */
placeholder?: string
/** 输入框后的单位后缀(如 'px')。 */
unit?: string
}[]
/** 插件自有设置行(v0.12.0+):形状同 toggles,但 key 是插件局部的,
* 持久化在 `pluginSettings[<descriptor id>]`——不需要宿主 PrefsSchema 字段。 */
pluginToggles?: readonly {
key: string
title: string | (() => string)
desc?: string | (() => string)
type?: 'switch' | 'text' | 'number'
min?: number
max?: number
placeholder?: string
unit?: string
}[]
/** 自定义设置面板(v0.12.0+):给出时齿轮弹窗渲染它而非行列表。
* props 含 store/service/prefs、本 descriptor 的 pluginSettings blob、
* updatePluginSetting(key, value) 与 close()。抛错会被吞掉并显示内联错误。 */
render?: (props: {
store: SidebarStore
service: BetterSidebarService
prefs: SidebarPrefs
pluginSettings: Record<string, unknown>
updatePluginSetting: (key: string, value: unknown) => void
close: () => void
}) => ReactNode
}
/**
* tab 角标(v0.12.0+):tab 图标旁的小圆角 pill。number 渲染计数(99+ 封顶),
* string 原样文本,null/undefined 不显示。每次 tab 栏渲染都会调用——保持廉价;
* 抛错会被吞掉(不显示角标,不影响渲染)。
*/
badge?: (ctx: Context, scope: SessionScope, state: SidebarState) => string | number | null | undefined
/**
* 生命周期回调(v0.12.0+),只由 SERVICE 路径触发:
* - onOpen:openTab 真正**新建** tab 后(dedupe/id 安全网聚焦**不算**打开);
* - onActivate:tab 被聚焦时(dedupe 聚焦、id 安全网聚焦、tab 栏点击激活);
* - onClose:closeTab 关闭 tab 后。
* 内置专属流程(diff 拆分放置、agent 终端 reconcile)直接改 state,不触发回调——
* 但它们只作用于内置类型(diff/terminal),外部插件的 tab 永远走 service 路径。
* 回调抛错只 console.error,绝不打断打开/关闭流程。openTab 的回调 scope
* 携带调用者传入的 { sessionId, cwd? }(+ 菜单路径带 cwd;无 scope 的自动
* 打开路径只有 sessionId);closeTab/activateTab 的回调 scope 只在显式传入
* scope 参数时带 cwd。
*/
onOpen?: (tab: SidebarTab, scope: SessionScope) => void
onActivate?: (tab: SidebarTab, scope: SessionScope) => void
onClose?: (tab: SidebarTab, scope: SessionScope) => void
/** 渲染函数 */
component: (props: TabComponentProps) => ReactNode
}
@@ -194,13 +300,32 @@ ctx.effect(() =>
title: 'Commits',
icon: <CommitIcon />,
order: 70,
available: (state) => hasGitRepo(state), // 返回 false 时 + 菜单显示为 disabled
available: (ctx, scope, state) => hasGitRepo(state), // 三参 (ctx, scope, state);返回 false 时 + 菜单显示为 disabled
// 注意:available 只影响 + 菜单的 disabled 状态,不会拒绝 openTab(只有设置页的禁用开关会)。
dedupeKey: () => 'my-plugin:commits',
component: ({ scope }) => <CommitsView sessionId={scope.sessionId} />,
})
)
```
**认领外链点击**(v0.13.0+,`features.includes('urlTarget')` gate):聊天/界面里被拦截的 HTTP(S) 外链(协议开关开启时)路由到第一个 `urlTarget` 命中的 tab 类型,URL 作为 seed 预填 `tab.path`;内置浏览器是隐式兜底。多 URL 并存需用 `createTab` 铸造 per-URL id(browser 同款模式),否则同类型二次点击聚焦既有 tab、不覆写 path:
```ts
ctx.effect(() => {
if (!ctx.betterSidebar.features.includes('urlTarget')) return
return ctx.betterSidebar.registerTab({
id: 'my-plugin:web-docs',
title: () => 'Docs',
order: 80,
urlTarget: (url) => url.hostname === 'docs.my-site.com', // 只有该域名被点击时认领
createTab: (state) => ({
tab: { id: `my-plugin:web-docs:${state.nextBrowser}`, type: 'my-plugin:web-docs', title: 'Docs' },
patch: { nextBrowser: state.nextBrowser + 1 },
}),
component: ({ tab, scope }) => <WebDocsView url={tab.path} sessionId={scope.sessionId} />,
})
})
```
### 3.4 内置 tab(不可重复注册)
| id | order | single | hidden | 用途 |
@@ -210,6 +335,7 @@ ctx.effect(() =>
| `git` | 20 | 是 | 否 | Git 面板 |
| `subagent` | 30 | 是 | 否 | 子代理拓扑 |
| `terminal` | 40 | 否 | 否 | 终端(nextTerminal 自增) |
| `browser` | 50 | 否(createTab 铸造 browser:`<n>`,nextBrowser 自增) | 否 | 内嵌网页浏览器(沙箱 iframe;可设置关闭沙箱) |
| `diff` | -1 | 否(按 id 去重) | 是 | 差异查看(由 GitView 触发) |
你的 `id` 不可与上述重复,否则 `registerTab` 抛 `"tab type \"X\" already registered"`。
@@ -236,8 +362,9 @@ interface FileViewerDescriptor {
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>
/** fetchStrategy='custom' 时的加载函数;v0.12.0+ 第三参 signal 在 viewer
* 卸载/重匹配时中止(忽略 signal 的 load 也照常工作) */
load?: (path: string, scope: SessionScope, signal?: AbortSignal) => Promise<unknown>
/** 声明式设置(v0.4.1+):形状同 TabDescriptor.settings */
settings?: { toggles?: readonly { key: string; title: string | (() => string); desc?: string | (() => string) }[] }
/** 渲染函数 */
@@ -268,7 +395,7 @@ interface FileViewerProps {
|---|---|---|---|
| `none` | 不需要字节 | (无) | 自渲染(如纯 UI) |
| `fsRead` | `/sidebar/api` 的 `fs.read` | `content`, `truncated` | 文本类(CSV/JSON/XML) |
| `mediaUrl` | `/sidebar/file` 媒体路由 URL | `mediaUrl` | 图片/PDF/Office(viewer 自己 fetch 字节) |
| `mediaUrl` | `/sidebar/file` 媒体路由 URL | `mediaUrl` | 图片/PDF(viewer 自己 fetch 字节) |
| `custom` | viewer 的 `load()` 函数 | `customData` | 自定义协议(如远程拉取) |
| `binary-download` | 不预览,显示下载按钮 | (无) | 无客户端渲染器的二进制格式 |
@@ -283,7 +410,7 @@ interface FileViewerProps {
> **head 字节从哪来**:第一次匹配(纯扩展名)没有 head。`fsRead` 策略读取后若文件为二进制,host 的 `fs.read` 响应会带 `head` 字段(base64,前 4KB),编辑器会用它对 `detect` viewer **重匹配一次**——所以 detect 型 viewer 的实际触发场景是"扩展名匹配落空/二进制文件"。文本文件的 detect 嗅探不在内置流程内(用 `exts` 或 `custom` 策略替代)。
> **内置 viewer**(不可重复注册,全部 8 个):image(0) / pdf(0) / docx(0) / xlsx(0) / pptx(0) / markdown(0, fsRead) / code(-100, catch-all, fsRead) / binary-download(-50, exts doc/xls/ppt + NUL detect)。
> **内置 viewer**(不可重复注册,全部 6 个):image(0) / pdf(0) / markdown(0, fsRead) / html(0, fsRead, 沙箱 iframe 预览) / code(-100, catch-all, fsRead) / binary-download(-50, exts doc/xls/ppt + NUL detect)。Office 三件套预览(.docx/.xlsx/.pptx)**不再内置**——已迁至推荐插件(设置页「添加插件」→ 文件预览弹窗里的 Office 预览插件),该插件以相同 id 经 `ctx.betterSidebar.registerFileViewer` 注册。
> code 是兜底 viewer:任何其他 viewer 未认领的文件都会落到 code(CodeMirror 文本编辑);二进制文件经 head 重匹配被 binary-download 的 NUL detect 认领(下载按钮)。外部 viewer 注册同扩展名 + 更高 priority 即可覆盖。
### 4.5 注册示例
@@ -360,17 +487,61 @@ interface BetterSidebarService {
* 打开一个 tab(+ 菜单和外部触发都用它;走 descriptor.dedupeKey 去重)。
* title 可选:给出时优先于 descriptor.title(editor 显示文件名);
* 有 createTab 的 descriptor(terminal)会忽略 title/path/id。
* url 可选:把**新建** tab 的 path 预填为 URL(侧边栏浏览器导航种子,
* 通常配合 hostname title;对 createTab 铸造的 tab 同样生效)。聚焦既有
* tab 时 url 不会覆写其 path。
* 被设置禁用的类型是 no-op(console.warn 提示)。
* scope(v0.12.0+)定向到指定 session:给出且非当前 session 时,打开落在
* 该 session 的侧边栏状态里(没有则按 prefs 新建),不切换 UI 的激活 session;
* 定向打开**不自动展开**目标 session 的面板(用户看不见,展开无意义);
* 缺省或指向当前 session 时行为与之前完全一致。注意:available 不拦截 openTab。
* 内容型打开(带 path/url seed)必须落在视野内:承载落点 pane 的面板
* 折叠时自动展开(右侧面板;落点 pane 在底部树则展开底部面板;窄视口
* 展开合并抽屉);类型型打开(+ 菜单、agent 终端自动补 tab)不展开。
*/
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void
/** 关闭一个 tab */
closeTab(tabId: string): void
openTab(seed: OpenTabSeed, scope?: SessionScope): void
/** 关闭一个 tab(未知 id 严格 no-op,无状态搅动);scope(v0.12.0+)
* 随回调传递(含可选 cwd),缺省为 { sessionId: 当前 } */
closeTab(tabId: string, scope?: SessionScope): void
/** 订阅注册表变化(register/dispose 时触发) */
subscribe(listener: () => void): () => void
// ── v0.12.0+ ──────────────────────────────────────────────────────────
/** 插件版本(如 '0.12.0';与 package.json 同步,测试守护) */
readonly version: string
/** 单调能力清单(只增不删):'badge' | 'tabLifecycle' | 'updateTab' |
* 'openFile' | 'targetedOpen' | 'stateSubscription' | 'tabMeta' |
* 'pluginSettings' | 'urlTarget'——消费插件用 `features.includes('xxx')` 按能力 gate。 */
readonly features: readonly string[]
/** 当前快照:激活 sessionId + 其状态(面板几何/打开的 tabs/展开集)+ prefs。
* session 未激活时 state/sessionId 为 undefined。 */
getSnapshot(): SidebarSnapshot
/** 订阅快照变化(会话切换/状态变更/prefs 写入);返回 disposer */
subscribeState(listener: () => void): () => void
/** 更新一个已打开 tab 的显示字段(title/path/meta);tab 不存在时 no-op */
updateTab(tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void
/** 激活一个已打开的 tab(tab 栏点击路径;触发 descriptor.onActivate;
* 未知 id 严格 no-op);scope(v0.12.0+)随回调传递,同 closeTab */
activateTab(tabId: string, scope?: SessionScope): void
/** 在 scope.sessionId 的侧边栏编辑器打开一个文件(title 缺省为文件名;
* id 按路径派生,与内置 open-path 拦截一致,不同文件可并排打开) */
openFile(scope: SessionScope, path: string, title?: string): void
}
/** openTab 的 seed(v0.12.0 起导出命名类型) */
interface OpenTabSeed {
type: string
title?: string
path?: string
diff?: SidebarTab['diff']
id?: string
url?: string
/** JSON 可序列化的自定义状态,随 tab 持久化(刷新后原样恢复);
* updateTab/seed 传 undefined 表示「不改」,传 null 可显式清除 */
meta?: unknown
}
```
> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,勾选徽标钉在卡片最右端,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`)通过卡片右下角的齿轮按钮在**原生弹窗**中编辑(复选框行),父级卡片关闭时齿轮隐藏;**key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools`),外部插件的自定义键会被 settings seam 丢弃。
> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,勾选徽标钉在卡片最右端,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`、终端的 `terminalFontFamily`/`terminalFontSize`)通过卡片右下角的齿轮按钮在**原生弹窗**中编辑——`type: 'switch'` 行是复选框,`type: 'text'`/`'number'` 行是输入框(v0.11.0+)——父级卡片关闭时齿轮隐藏;`settings.toggles` 的 **key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `terminalFontFamily` / `terminalFontSize` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks` / `browserInterceptHttp` / `browserInterceptHttps`)。**v0.12.0 起设置 seam 已开放**:外部插件用 `settings.pluginToggles`(同款行控件,key 插件局部)或 `settings.render`(自定义面板)声明自己的设置,值持久化在 prefs 文档的 `pluginSettings[<descriptor id>]`(开放 map,宿主 schema 已有字段,无需注册)——齿轮弹窗对 tab 与 viewer 都可用(viewer 卡片 v0.12.0 起也有齿轮)。
---
@@ -393,10 +564,31 @@ interface BetterSidebarService {
| **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`) |
| **i18n 跟随** | 侧边栏界面文案跟随 DSH 的 `ctx.locale`(`@deepseek-ai/dsh-client-locale`):词典注册在 `betterSidebar` 命名空间,语言偏好(Host-backed `locale.preference`)与浏览器语言不一致时以 DSH 为准并实时切换;locale 服务缺失时回退浏览器语言。插件自身的 `t()`(`src/client/locales.ts`)由 `apply()` 挂接服务;消费插件**不要**依赖此内部函数——标题等字段传字符串或 `() => string` 即可(i18n 友好)。⚠️ 渲染 DSH 的 `MarkdownText` 时必须传 `codeLabels={{ copyLabel: t('copy'), copiedLabel: t('copied') }}`——该组件 cordis-free,漏传则代码块复制按钮回退硬编码中文 |
| **懒加载 chunk** | 内置重依赖(xterm/CodeMirror)在独立 bundle(`lib/client-<name>.js`)中,经 `/sidebar/bundle` 路由按需下发;每个脚本把 factory 赋到插件自有全局注册表 `globalThis.__dshChunks__[<name>]`,由 `src/client/chunk-loader.ts` 用自定义 require(externals 经 `__DSH_MODULES__` seed 分支解析)物化——**不经过** `__ModuleLoader__` 注册;**核心 bundle 禁止静态 import `src/client/chunks/*`**(会把库拖回启动路径);对消费插件透明——懒加载只作用于内置 descriptor,`component` 契约(`(props) => ReactNode` 纯渲染函数)不变 |
---
## 8. 完整最小示例
## 8. 皮肤兼容(令牌驱动)
> 侧边栏的皮肤兼容是**令牌驱动**的:所有视觉值都消费 DSH 的 `--dsw-alias-*` / `--dsw-font-*` / `--ds-*` 令牌(无硬编码颜色),皮肤系统覆盖什么令牌我们就跟随什么——**不做任何每皮肤适配**。当前已与 dsh-web-ui 皮肤中心兼容:其 10 款皮肤全部覆盖 `--dsw-alias-*` 层,换肤后面板自动跟随(测试:`tests/e2e/mount.e2e.ts` 断言布局变量随面板挂载生效;`tests/theme.spec.ts` 守护令牌读取)。
### 8.1 规则
- **面板表面**:右/底面板背景 = `var(--dsw-alias-bg-layer-1)`(通用卡片表面)。**绝不消费 `--dsw-specific-sidebar-fill`**——那是宿主左侧导航列专属令牌,皮肤系统按左导航语义覆盖它(dsh-web-ui 皮肤把它做成半透明玻璃或主题色,Aqua 设成 `transparent`),面板消费它会失去填充或与标签令牌冲突。皮肤要整体换面板表面:覆写 `--dsw-alias-bg-layer-1` 即可(dsh-web-ui 10 款皮肤都已覆盖,无需任何额外工作)。
- **终端/编辑器表面**:经 `effectiveTokenValue` 读取 `--dsw-alias-bg-base`——`transparent` 与 alpha < 0.9 的半透明玻璃值(dsh-web-ui 皮肤用 rgba 0.16–0.7)一律回退不透明底色,文字永不叠在皮肤背景画上滚动(issue #90);≥ 0.9 的近不透明值(如皮肤作用域内 0.96 的瓷器玻璃)放行,皮肤仍能控制终端表面。
- **根锚点**:宿主 div 带 `data-dsh-better-sidebar` 属性(append 到 `document.body`),面板是其 fixed 直接子级。皮肤若要做作用域覆盖(deep-whale 的做法),限定在 `[data-dsh-better-sidebar]` 内即可,避免全局改写影响宿主。
- **布局变量**(写在 `<html>` 上,面板打开时有效):`--dsh-sidebar-width` / `--dsh-sidebar-height`(面板几何;拖拽期间逐帧更新)。
- **z-index**:面板 40、折叠按钮簇 45(角手柄在面板内层叠,z-index 2 仅面板内有效)——全部低于 DSH 浮层栈(100/1000+),任何浮层天然盖住侧边栏。
### 8.2 注意事项
- 类名是 CSS Modules 哈希(`[hash]_[local]`),**不是契约**——皮肤不要依赖类名寻址;需要精确命中单表面时,用 `[data-dsh-better-sidebar]` 属性选择器配合子串类名(如 `[class*='panel']`)或 DOM 结构。
- 改动本契约(面板表面令牌、透明度阈值、z-index 层级)必须同步更新本文档、设计文档与 `tests/theme.spec.ts`。
---
## 9. 完整最小示例
> 假设插件 `my-plugin` 要加一个"Database 浏览器" tab + `.csv` 文件预览器。
@@ -468,19 +660,20 @@ 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` + 浏览器硬刷新。
**注册到 profile**:在 `~/.dsh/profiles/web/package.json` 的 `dependencies` 加 `"my-plugin": "link:<你的插件路径>"`,在 `cordis.patch.yml` 加挂载行,`pnpm install`,浏览器硬刷新即可(DSH 对 client 改动热加载,无需重启 `dsh web`;仅 host 半改动需要重启)。
---
## 9. 参考实现
## 10. 参考实现
better-sidebar 自己的内置 tab 和 viewer 就是参考实现("吃狗粮"):
- **`src/client/builtins/`**:6 个内置 tab(explorer/git/subagent/terminal/editor/diff)+ 8 个内置 viewer(image/pdf/docx/xlsx/pptx/markdown/code/binary-download)的注册代码(tabs.tsx / viewers.tsx / index.ts)
- **`src/client/builtins/`**:7 个内置 tab(explorer/git/subagent/terminal/browser/editor/diff)+ 6 个内置 viewer(image/pdf/markdown/html/code/binary-download)的注册代码(tabs.tsx / viewers.tsx / index.ts;Office 预览已迁至推荐插件,见 plugins-viewers.ts)
- **`src/client/service.ts`**:`BetterSidebarService` 接口 + `createBetterSidebarService` 工厂实现
- **`src/client/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + `settings.toggles` 嵌套开关 + 开关持久化)
- **`src/client/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + `settings.toggles` 嵌套设置行:switch/text/number + 持久化)
- **`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`**:设计文档(含实施偏差记录)
- **`tests/builtins.spec.ts`**:内置注册清单断言(7 tab + 6 viewer + 声明式元数据)
- **`src/client/plugins-tabs.ts`** / **`src/client/plugins-viewers.ts`**:推荐插件目录(名字/url/简介/安装脚本,分别对应 Tab 注册与文件预览注册),在设置页两个「添加插件」弹窗展示(共享类型在 `plugins-shared.ts`);插件作者可按扩展点加一条数据(弹窗内「跳转」直达仓库、「复制」把安装命令写入剪贴板,粘贴到 DSH 所在环境的终端执行)——数据完整性由 `tests/plugin-list.spec.ts` 守护
- **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`** / **`docs/plans/2026-08-14-add-plugins-modal-design.md`**:设计文档(含实施偏差记录)
调试时直接读这些文件即可看到所有 API 的真实用法。
+165 -283
View File
@@ -1,338 +1,220 @@
# dsh-better-sidebar
> DeepSeek Harness Web GUI 的 **VSCode 风格右侧侧边栏**插件 —— 文件管理、编辑预览、真实终端、Git 面板、Subagent 页面,一个面板全部搞定。
<!-- Hero -->
<div align="center">
<b style="font-size: 1.15em;">一个服务化的侧边栏框架,一套开箱即用的完整工作台</b><br /><br />
<a href="https://opensource.org/licenses/MIT"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-yellow.svg" /></a>
<a href="https://dshfind.com/zh/plugins/omdsh-dev/DSH-better-sidebar?ref=badge"><img alt="dshfind" src="https://dshfind.com/api/badge/omdsh-dev/DSH-better-sidebar?lang=zh" /></a><br /><br />
<img alt="文件管理" src="https://img.shields.io/badge/-文件管理-4d6bfe" /> <img alt="编辑预览" src="https://img.shields.io/badge/-编辑预览-4d6bfe" /> <img alt="内嵌浏览器" src="https://img.shields.io/badge/-内嵌浏览器-4d6bfe" /> <img alt="真实终端" src="https://img.shields.io/badge/-真实终端-4d6bfe" /> <img alt="Git 面板" src="https://img.shields.io/badge/-Git%20面板-4d6bfe" /> <img alt="后台任务" src="https://img.shields.io/badge/-后台任务-4d6bfe" /> <img alt="插件接入" src="https://img.shields.io/badge/-插件接入-4d6bfe" /><br /><br />
<b>右侧栏 + 底部面板双工作台</b>,并把 <code>ctx.betterSidebar</code> 服务开放给所有插件——<br />
通过 <code>registerTab</code> / <code>registerFileViewer</code> 注册新的侧边栏页面与文件预览器。
</div>
<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" />
<div align="center">
🌏 <a href="./README.md"><b>中文</b></a> · <a href="./README_EN.md">English</a>
</div>
## 🚀 官方 profile 安装(推荐,无需 plugin-registry)
把下面提示词**整段**发给 DSH,它会自动完成克隆、构建、注册与安装(前置条件:已安装 DSH 且 `dsh web` 可运行,Node.js ≥ 20、pnpm ≥ 10):
```text
请帮我把 dsh-better-sidebar 插件安装到我的 web profile(插件 = VSCode 风格右侧侧边栏,仓库 https://github.com/dsh-external/DSH-better-sidebar):
1. 克隆并构建:
git clone https://github.com/dsh-external/DSH-better-sidebar.git ~/Code/DSH-better-sidebar
cd ~/Code/DSH-better-sidebar && pnpm install && pnpm build
(若 pnpm install 因 @deepseek-ai/* 的 link: 依赖解析失败,说明 DSH 源码 checkout 不在 ~/.dsh/source/current —— 停下来告诉我,不要继续)
2. 注册到 web profile:
a. 编辑 ~/.dsh/profiles/web/package.json,在 dependencies 中加入 "dsh-better-sidebar": "link:<第 1 步克隆目录的绝对路径>"
b. 编辑 ~/.dsh/profiles/web/cordis.patch.yml,追加挂载行:
- insert:
- id: better-sidebar
name: 'dsh-better-sidebar'
3. 在 ~/.dsh/profiles/web 目录执行 pnpm install
4. 全部完成后告诉我,我重启 DSH 并硬刷新(Cmd/Ctrl+Shift+R)验证
```
> 安装 = 把插件登记进 web profile 的依赖清单(等价于 `dsh plugin --profile web add link:<路径>`)+ 一行 cordis 挂载行,**与 portal 无关**——portal 只指侧边栏面板在页面上的渲染方式(见下文[规范符合性](#规范符合性))。
### 更新(同样把提示词发给 DSH)
已安装过(`link:` 引用)时,把下面提示词**整段**发给 DSH 即可更新到最新版:
```text
请帮我更新 dsh-better-sidebar 插件(仓库在 ~/Code/DSH-better-sidebar,已通过 link: 安装到我的 web profile):
1. 拉取最新代码并重新构建:
cd ~/Code/DSH-better-sidebar && git pull && pnpm install && pnpm build
(若 pnpm install 因 @deepseek-ai/* 的 link: 依赖解析失败,说明 DSH 源码 checkout 不在 ~/.dsh/source/current —— 停下来告诉我,不要继续)
2. 核对注册仍然有效(缺失才需要补):
a. ~/.dsh/profiles/web/package.json 的 dependencies 中含 "dsh-better-sidebar": "link:<仓库目录的绝对路径>"
b. ~/.dsh/profiles/web/cordis.patch.yml 中含挂载行(id: better-sidebar, name: 'dsh-better-sidebar')
c. 若有缺失,补齐后在该 profile 目录执行 pnpm install
3. 完成后告诉我本次改动涉及 client 还是 host:
- 仅 client(src/client/*)→ 我硬刷新(Cmd/Ctrl+Shift+R)即可
- 含 host(src/index.ts、src/config.ts 等)→ 我需要重启 DSH 再硬刷新
```
## 🚀 通过 plugin-registry 安装(可选)
如果已经集成了 [plugin-registry](https://github.com/dsh-external/plugin-registry)(`dsh registry` 命令可用;集成步骤见其 README),也可以通过 registry 通道安装:
```sh
git clone https://github.com/dsh-external/DSH-better-sidebar.git
cd DSH-better-sidebar
pnpm install && pnpm build # 产物: lib/index.js + lib/invariant.js (host) + lib/client.js + lib/client-registry.js (client) + lib/types
node scripts/package-registry.mjs # 组装 registry/ 暂存目录(只含清单 + 构建产物 + README,不入库)
dsh registry install ./registry # 安装(默认禁用——信任边界)
dsh registry enable dsh-external/dsh-better-sidebar # 启用
```
> **通道互斥**:registry 安装与官方 profile 安装是同一插件的两种安装方式,**每个部署二选一**——同时启用会双挂载(Node 半挂两次、页面渲染两个侧边栏)。已用官方方式装过时,先移除 `~/.dsh/profiles/web/cordis.patch.yml` 里的 better-sidebar 挂载行与 `package.json` 的 `link:` 依赖,再走 registry。
### 更新(registry 通道)
```sh
cd DSH-better-sidebar && git pull && pnpm install && pnpm build
node scripts/package-registry.mjs
dsh registry uninstall dsh-external/dsh-better-sidebar
dsh registry install ./registry
dsh registry enable dsh-external/dsh-better-sidebar
```
## 为什么用它
- **⚡ 工作流不断档**:编辑文件、跑终端、看 Git 状态、拖 Tab 分栏对比,全在对话旁边完成,无需切窗口
- **🧠 按会话记忆与隔离**:每个 conversation 拥有独立的布局、分栏、Tab 与目录展开状态,切换会话即切换整套工作台
- **🎨 与 GUI 浑然一体**:完全复用 DSH 主题变量与官方组件,明暗主题自适应,观感就是原生的
---
<div align="center">
<video src="https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86" muted autoplay loop playsinline controls width="100%"></video>
<img alt="dsh-better-sidebar 工作台截图" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" />
</div>
## ✨ 功能一览
### 🗂️ 文件资源管理器
- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown / HTML / PDF / Office 内联预览
- **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整)
- **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存 / 提交 / 还原
- **🧩 后台任务页**:subagent 拓扑 + 后台任务(退出码 / 实时输出 / 强制终止)
- **🪟 双工作台**:右侧栏 + 底部面板;拖 Tab 拆分 / 合并分栏(可跨面板),移动端自动合并全宽抽屉
- **🔁 会话隔离**:布局 / Tab / 面板按会话持久化,陈旧状态自动净化
- **⚙️ 声明式设置**:设置页「侧边卡片」逐项独立开关,二级设置经齿轮弹窗
- **⚡ 按需加载**:启动只拉 ~325KB 核心,终端 / 编辑器等重依赖用到才按需拉取([设计文档](docs/plans/2026-08-12-lazy-chunks-design.md))
- **🌏 多语言**:界面文案跟随 DSH 语言(zh / en)实时切换
- 懒加载目录树,根目录 = 当前会话工作目录(cwd);目录优先、隐藏文件变暗、每层 1000 项上限
- 点击文件在侧边栏打开编辑器;刷新按钮重载
- **悬浮行尾 `@文件` 按钮**:一键把 `@相对路径` 追加到输入框草稿(如 `@src/main.ts`),引用文件不再手打路径
- **右键行 → 复制相对地址 / 复制绝对地址**,复制成功行内短暂显示"已复制"
> 🔌 **核心理念**:服务优先——内置的 7 tab + 6 viewer 与第三方插件通过同一套 `ctx.betterSidebar` API 注册,能力完全对等;官方不再内置、可由生态提供的功能,交由生态插件实现。接入文档见下方「🔌 服务化」与 [外部插件接入指南](./docs/external-plugin-guide.md)。
### 📝 文件编辑与预览
## 🆕 最近更新
- **CodeMirror 6**:自动换行 + 按扩展名语法高亮(js/ts/jsx/tsx、json、python、html、css、xml、yaml、sql、java、c/c++、rust、go、php、shell、toml、nginx、dockerfile、properties…)
- 脏点标记 + **Ctrl/Cmd+S 保存**(原子写入);512KB 截断横幅、二进制文件提示
- 图片直接预览;**Markdown 预览/编辑切换**,预览实时渲染未保存草稿
- **PDF 原生预览**:使用浏览器内置 PDF 查看器,预览区始终保留下载入口
- **Office 三件套预览**:
- `.docx` — 保真渲染(保留样式 / 页眉页脚 / 图片 / 表格),基于 [docx-preview](https://github.com/VolodymyrBaydalka/docxjs)
- `.xlsx` — 完整电子表格(多 sheet / 公式计算 / 列宽行高 / 合并单元格),基于 [Univer](https://univer.ai) + [SheetJS](https://sheetjs.com)
- `.pptx` — 高保真幻灯片预览(图片 / 表格 / 图表 / SmartArt)+ 上一页 / 下一页导航,基于 [pptx-renderer](https://github.com/aiden0z/pptx-renderer)
- `.doc` / `.xls` / `.ppt` 及其他二进制 — 下载按钮(旧 OLE 格式纯前端无成熟渲染库)
- 切换 Tab 不卸载:编辑器保留草稿与撤销栈
<div align="center">
<a href="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0"><img width="45%" alt="服务化基座截图" src="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0" /></a>
<a href="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e"><img width="45%" alt="添加插件截图" src="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e" /></a>
</div>
### 💻 终端
### v0.12.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 通用
- 🐛 **node-pty 加载失败不再拖垮 server**([#140](https://github.com/omdsh-dev/DSH-better-sidebar/issues/140)):宿主半改为懒加载 node-pty,缺失时插件照常挂载,终端以修复提示横幅(可复制命令 + 重试按钮)呈现,agent 终端工具自动跳过
- 🚀 **发布流水线**:接入 GitHub Release 自动发版 npm(Trusted Publishing,产物带 provenance),本版起打 tag 即自动发布
### 🌿 Git 面板
### v0.12.2
- **真正的 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)、分支切换
- 📐 **位置兼容模式**:设置页新增开关:为 Windows 右上角原生标题栏预留顶部空间,侧边栏按钮与内容整体下移(默认关闭);下移距离可在齿轮弹窗中自定义(0–120px)
- 🔌 **服务化基座**:完整类型导出 + `version`/`features` 能力探测、状态订阅(`getSnapshot`/`subscribeState`)、tab 角标、`onOpen`/`onActivate`/`onClose` 生命周期回调、`updateTab`/`activateTab`/`openFile`、定向打开、`meta` 跨刷新持久化、插件自有设置(`pluginToggles`/`render`)、外链点击目标认领(`urlTarget`)
- ➕ **添加插件**:设置页「推荐插件目录」+ 一键复制安装命令;内置 Office 预览迁至推荐插件
- 🖱️ **标签页滚轮**:标签页栏支持鼠标滚轮横向滚动
- 🐛 **修复**:远程访问 403(信任栅栏改用 `trustedHosts`)、侧边栏崩溃 [#31](https://github.com/omdsh-dev/DSH-better-sidebar/issues/31)、Windows 下 HTML 预览盘符路径
### 🧩 Subagent 页面
### v0.12.1
- **主会话 agent 拓扑(分层显示)**:`+` 菜单(或空分栏卡片)打开 Subagent 标签页,显示**主会话(主代理)的完整 agent 拓扑**——主代理作为根节点卡片置顶(**点击它直接回到主代理会话**),其**所有层级的子代理**(含子代理再派生的后代)从它下面按层级展开,树连接线 + 逐级缩进区分层级;无论当前选中/跳转到多深的子代理,所有子代理都共享这一个主会话拓扑视图
- **点击直达执行记录**:点击子代理节点直接跳转到该子代理执行记录(官方 `openSubagent`);**跳转后 Subagent 页面保持打开**,主会话拓扑不变,当前所在节点**高亮**(`interactive-bg-active` 选中填充,同官方会话行)
- **运行中实时状态**:**仅运行中的节点**显示最后 Text 输出 + 最后工具调用(工具名 + 参数摘要,等宽字体),页面可见时每 3 秒自动刷新;暂无输出时显示「思考中…」;空闲节点保持安静;页面不可见(切标签/折叠面板)时暂停轮询
- **自动激活**:设置项「检测到子代理时自动展开子代理页面」默认开启——当前会话**新增**第一个子代理时,侧边栏自动展开并聚焦 Subagent 页面;关闭后需手动打开(切换会话/刷新已有子代理不会打扰)
- 🔌 **服务化基座**:完整类型导出 + `version`/`features` 能力探测、状态订阅(`getSnapshot`/`subscribeState`)、tab 角标、`onOpen`/`onActivate`/`onClose` 生命周期回调、`updateTab`/`activateTab`/`openFile`、定向打开、`meta` 跨刷新持久化、插件自有设置(`pluginToggles`/`render`)
- ➕ **添加插件**:设置页「推荐插件目录」+ 一键复制安装命令;内置 Office 预览迁至推荐插件
- 🖱️ **标签页滚轮**:标签页栏支持鼠标滚轮横向滚动
- 🐛 **修复**:远程访问 403(信任栅栏改用 `trustedHosts`)、侧边栏崩溃 [#31](https://github.com/omdsh-dev/DSH-better-sidebar/issues/31)、Windows 下 HTML 预览盘符路径
### 🧩 VSCode 式分栏工作台
> 📝 说明:0.12.0 正式版因 npm 判定版本已发布无法复用,正式发布改用 0.12.1,两者内容一致。
- **拖 Tab 到分栏边缘**(左/右/上/下)即拆分新分栏,拖到中间合并,拖到另一个 Tab 上插入其前
- 分隔线拖拽调整比例;面板宽度从左侧缘拖拽;折叠滑出隐藏 + 全屏展开
### v0.12.0
### 📑 Tab 栏
- 🔌 **服务化基座**:完整类型导出 + `version`/`features` 能力探测、状态订阅、tab 角标、生命周期回调、定向打开、`meta` 跨刷新持久化、插件自有设置
- ➕ **添加插件**:设置页「推荐插件目录」+ 一键复制安装命令;内置 Office 预览迁至推荐插件
- 🖱️ **标签页滚轮**:标签页栏支持鼠标滚轮横向滚动
- 🐛 **修复**:远程访问 403(信任栅栏改用 `trustedHosts`)、侧边栏崩溃 [#31](https://github.com/omdsh-dev/DSH-better-sidebar/issues/31)、Windows 下 HTML 预览盘符路径
- **Tab 与 `+` 菜单均带类型图标**(文件夹 / 分支 / 终端 / 代码文件)
- `+` 菜单新建 Explorer / Git / Subagent / 终端(终端受 3 个上限约束);**中键点击关闭 Tab**
- 空分栏显示可打开类型卡片,点击即开
## 🚀 安装
### 🔁 会话隔离与状态自愈
**前置**:已装好 DSH(`dsh web` 能正常运行),Node.js ≥ 20、pnpm ≥ 10。
- 布局/分栏/Tab/目录展开按会话持久化(localStorage),切换会话即切换整套状态
- **陈旧状态自动净化**:结构校验、宽度钳制视口、损坏的重复 id 自动重新编号——刷新后分栏与新生成的 id 永不冲突,不会出现"文件开进两个分栏"
- 侧边栏**占用布局**(挤窄对话列而非悬浮遮挡,动画过渡)
- 拦截对话"产出文件"行:点击改为在侧边栏打开,而不是系统默认程序
```sh
dsh plugin --profile web add dsh-better-sidebar
```
### 🎨 界面风格
装完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可看到侧边栏(DSH 对 client 改动热加载,无需重启;仅 host 半更新时需要重启)。
- 完全对齐 DSH 原生 chrome:**无阴影**的扁平面板(`--dsw-specific-sidebar-fill` 表面 + hairline 边框)、原生 rail 圆形图标按钮、`interactive-bg-active` 选中填充、34px/14px 原生行规格
- 键盘焦点可见(focus-visible 环)、`prefers-reduced-motion` 下关闭全部动画
<details>
<summary><b>更新</b></summary>
### ⚙️ Side card 设置(DSH 设置页)
```sh
dsh plugin --profile web add dsh-better-sidebar
```
- 设置页新增 **Side card(侧边卡片)** 分区,持久化到 DSH 用户设置文档:
- **新会话默认打开**:新建会话是否自动展开侧边卡片(已存在的会话保持各自布局)
- **默认宽度占比**:新建会话时侧边卡片占窗口宽度的百分比(20–60,默认 30)
- **声明式的功能清单**:分区按注册表实时渲染当前支持的侧边栏内容与文件预览器(内置与外部插件注册的 tab/viewer 一视同仁),每项是一张**小卡片**,在响应式网格中**一行自适应排多个**——图标 + 类型 id + 描述,**点击整卡切换开/关,卡片高亮(品牌描边 + 勾选徽标钉在卡片最右端)即代表启用**,未启用呈灰色降级态:
- **侧边栏内容**:资源管理器 / 源代码管理 / 子代理 / 终端 / 编辑器 / 差异 等 tab 类型。关闭某类型后:`+` 菜单不再显示、无法新开(已打开的 tab 保留可继续使用,关闭后需重新开启才能再开)
- **文件预览**:图片 / PDF / Word / Excel / PPT / Markdown / 代码 / 二进制下载 等查看器,卡片上显示其覆盖的扩展名。关闭某查看器后文件预览落到下一个匹配的查看器(关闭兜底的代码查看器后无法预览的文件显示下载按钮)
- **二级设置弹窗**:声明了相关设置的卡片(子代理、终端)右下角有齿轮按钮,点击打开原生弹窗(Modal),内为 DSH 原生设置行——「检测到子代理时自动展开子代理页面」「为模型注入终端工具」等以复选框行呈现;父级卡片关闭时齿轮隐藏(相关设置随功能休眠)
- **检测到子代理时自动展开子代理页面**(默认开启):当前会话产生新的子代理时,自动展开侧边栏并打开 Subagent 页面
- **为模型注入终端工具**(默认关闭):开启后模型可通过 8 个 `terminal_*` 工具创建并操作侧边栏终端;关闭会注销工具并释放已创建的 agent 终端
也可把 `~/.dsh/profiles/web/package.json` 里的版本号改高后 `pnpm install`。改完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可(client 改动无需重启 DSH)。
## ⌨️ 快捷键速查
</details>
<details>
<summary><b>常见问题</b></summary>
| 现象 | 原因与解决 |
|---|---|
| 报 `Ignored build scripts` | pnpm 11 拦截构建脚本。在 profile 目录(`~/.dsh/profiles/web`)跑 `pnpm approve-builds --all`。 |
| 报 `minimum release age` / 版本不足 24h | 装的版本发布不足 24 小时。等 24h 或重跑一次(pnpm 会自动补 `minimumReleaseAgeExclude`)。 |
| 报「找不到 profile 目录」 | 先跑一次 `dsh web`,让它初始化 `~/.dsh/profiles/web`。 |
| 页面出现**两个侧边栏** | 双挂载:`~/.dsh/profiles/web/cordis.patch.yml` 还留着旧的手动挂载行,删掉那段 `- insert: ... better-sidebar ...`。 |
| Windows 下终端无法使用 | `node-pty` 依赖预编译二进制;若当前 Node 版本没有对应产物,需装编译工具链(VS Build Tools)。主流 Node 版本一般已有预编译。 |
| 终端提示「node-pty 加载失败」 | `node-pty` 安装缺失/损坏(如 pnpm 拦截了构建脚本)。终端横幅会给出修复命令:复制到 DSH 所在环境的终端/cmd 执行(在 `~/.dsh/profiles/web` 下 `pnpm approve-builds --all && pnpm rebuild node-pty`),完成后重启 DSH 并点重试。插件与 DSH 核心使用同一 `node-pty@^1.1.0`,修复后两者同步恢复。 |
| 提示 `dsh: command not found` | 先安装 DSH;或直接用 `npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar`。 |
</details>
<details>
<summary><b>从源码安装 / 开发(可选,替代 npm 方式)</b></summary>
调试本地改动或跟随开发分支时,把依赖指向本地克隆并自行构建:
```text
1. git clone https://github.com/omdsh-dev/DSH-better-sidebar.git ~/Code/DSH-better-sidebar
cd ~/Code/DSH-better-sidebar && pnpm install && pnpm build
2. ~/.dsh/profiles/web/package.json 的 dependencies 写 "dsh-better-sidebar": "link:<克隆目录绝对路径>"
3. ~/.dsh/profiles/web/cordis.patch.yml 追加挂载行:
- insert:
- id: better-sidebar
name: 'dsh-better-sidebar'
4. 在 ~/.dsh/profiles/web 执行 pnpm install
5. 硬刷新浏览器(Cmd/Ctrl+Shift+R)即可看到效果(client 改动无需重启 DSH;host 半改动才需重启)
```
更新:`git pull && pnpm install && pnpm build` → 硬刷新浏览器即可(client 改动热加载生效,无需重启 DSH;host 半改动才需重启)。切回 npm 通道时,把依赖改回 `"dsh-better-sidebar": "^0.12.3"` 再 `pnpm install`。
</details>
<details>
<summary><b>通过 plugin-registry 安装(可选,与上述二选一)</b></summary>
前置:DSH 已集成 [plugin-registry](https://github.com/dsh-external/plugin-registry)(`dsh registry` 可用)。**同时启用两个通道会双挂载**(Node 半挂两次、页面两个侧边栏)。
```sh
git clone https://github.com/omdsh-dev/DSH-better-sidebar.git && cd DSH-better-sidebar
pnpm install && pnpm build
node scripts/package-registry.mjs # 组装 registry/ 暂存(含清单 + 产物 + README,不入库)
dsh registry install ./registry # 安装(默认禁用)
dsh registry enable dsh-external/dsh-better-sidebar
```
更新:`git pull && pnpm install && pnpm build` → `node scripts/package-registry.mjs` → `dsh registry uninstall/install/enable`。切换通道前先移除另一通道的挂载。
</details>
## ⌨️ 快捷键
| 操作 | 按键 |
|---|---|
| 保存编辑 | `Ctrl/Cmd + S` |
| Git 提交 | `Ctrl + Enter` |
| 关闭 Tab | 鼠标中键 |
| 拆分分栏 | 拖 Tab 到分栏边缘 |
| 合并分栏 | 拖 Tab 到分栏中间 |
| 拆分/合并分栏 | 拖 Tab 到分栏边缘 / 中间 |
| 引用文件到输入框 | 悬浮行尾 `@文件` 按钮 |
| 复制文件路径 | 右键行 → 复制相对/绝对地址 |
---
## 🔌 服务化:注册 tab 与文件预览器
## 🧰 手动安装与更新
从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 7 tab + 6 viewer 亦通过同一服务注册):
不想用提示词时,也可以手动完成(等价于上面提示词的步骤):
### 前置条件
- 已安装 DSH(`dsh web` 可运行),Node.js ≥ 20、pnpm ≥ 10
- **构建/类型检查**需要 DSH 源码 checkout 位于 `~/.dsh/source/current`(`devDependencies` 中的 `@deepseek-ai/*` 以 `link:` 指向它;若 checkout 在其他路径,修改 `package.json` 中对应的 `link:` 路径)。**运行期不依赖该 checkout**——`@deepseek-ai/*` 声明在 `peerDependencies`,由 web profile 提供
### 1. 克隆、安装、构建
```sh
git clone https://github.com/dsh-external/DSH-better-sidebar.git
cd DSH-better-sidebar
pnpm install
pnpm build # 产物: lib/index.js + lib/invariant.js (host) + lib/client.js + lib/client-registry.js (client) + lib/types/*.d.ts
```
> 运行期依赖(cordis、react、`@deepseek-ai/dsh-client-*` 等)按官方插件清单规范声明在 `peerDependencies`——web profile 已内置全部 peer 依赖,安装本插件无需额外装包;`dependencies` 只保留插件自有的运行时依赖(node-pty、ws、xterm、CodeMirror、schemastery 等)。
### 2. 注册到 web profile
```sh
dsh plugin --profile web add link:/绝对路径/DSH-better-sidebar
```
(等价于手动把 `"dsh-better-sidebar": "link:<绝对路径>"` 写入 `~/.dsh/profiles/web/package.json` 的 `dependencies` 并 `pnpm install`。)
然后编辑 `~/.dsh/profiles/web/cordis.patch.yml`,追加插件挂载行(`dsh plugin` 只管理依赖清单,不写挂载行):
```yaml
- insert:
- id: better-sidebar
name: 'dsh-better-sidebar'
```
### 3. 重启 DSH 并刷新页面
重启 DSH(你的 `dsh web` 启动方式),浏览器**硬刷新**(Cmd/Ctrl+Shift+R)后,右侧出现侧边栏即安装成功。
### 更新
不想用提示词时手动更新(等价于[更新提示词](#更新同样把提示词发给-dsh)):
```sh
git pull && pnpm install && pnpm build # 在插件仓库目录执行
```
- 只改了 **client** 代码(`src/client/*`)→ 硬刷新页面即可(bundle 由服务器按请求读取)
- 改了 **host** 代码(`src/index.ts`、`src/config.ts` 等)→ 重启 DSH + 硬刷新
`link:` 引用无需重新 install。
### 通过 registry 安装(手动,与上面的官方方案二选一)
前置条件:`dsh registry` 命令可用(DSH 已集成 plugin-registry)。安装源是 `scripts/package-registry.mjs` 组装的 `registry/` 暂存目录——只含清单 + 构建产物 + README,避免把 `node_modules/`(本机 `link:` 符号链接、node-pty 二进制)与 `.git/` 一并装进 `<dshHome>/plugins`。
```sh
pnpm build # 先构建(产物见上)
node scripts/package-registry.mjs # 组装 registry/(重新运行会整体重建)
dsh registry install ./registry # 安装(默认禁用)
dsh registry enable dsh-external/dsh-better-sidebar
```
更新:
```sh
git pull && pnpm install && pnpm build
node scripts/package-registry.mjs
dsh registry uninstall dsh-external/dsh-better-sidebar
dsh registry install ./registry
dsh registry enable dsh-external/dsh-better-sidebar
```
- 只改了 **client** 代码 → 硬刷新页面即可(registry 通道的 bundle 由服务器按请求读取)
- 改了 **host** 代码 → 重启 DSH + 硬刷新
- 与官方方案的互斥见上文「通道互斥」说明
## 🛠️ 开发
```sh
pnpm typecheck # tsc --noEmit
pnpm build # rm -rf lib && tsc(声明) + tsdown → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types
pnpm test # vitest(单元 + 冒烟 + 插件形态 guard:真实 git/fs/node-pty 交互;manifest 一致性测试读取 lib/,需先 build)
pnpm watch # tsdown --watch(client bundle 热重建)
```
### 规范符合性
插件按 DSH 官方插件规范组织(参考 [dsh-external/turtle-ui](https://github.com/dsh-external/turtle-ui) 与 mainline `packages/client/AGENTS.md`),并额外提供 plugin-registry 标准发布面(`dsh.plugin.json`)。**安装通道与渲染方式是两个独立概念**:安装走 profile 清单协议(`dsh plugin` / cordis.yml 行,见[官方 profile 安装](#官方-profile-安装推荐无需-plugin-registry))**或** registry 通道(`dsh registry`,见[通过 plugin-registry 安装](#通过-plugin-registry-安装可选)),二者互斥;下面的 portal 条目只描述面板在页面上的渲染方式。
- **插件形态**:`export const name / inject / Config / apply`,无 default 导出;`tests/plugin-shape.spec.ts` 通过 `Loader.unwrapExports` 守卫该形态
- **清单**:`types` + `exports`(`.` / `./invariant` / `./client` / `./src/*` / `./package.json`)、`dsh.client`(`platform: 'web'` + `inject` 边)、peerDependencies、`engines`、`files` 产物明细、`prepare`(消费者侧 `tsdown`,git 安装可用)
- **client 契约**:仅导出 `apply`/`inject`(+ 类型);store 为 `createSidebarStore()` 工厂,实例归 `apply` 所有;`src/invariant.ts` 伴生;client bundle 复刻官方 preset(externals = 平台模块表 + runtime/client 豁免、纯度门、CSS Modules 内联)
- **registry 形态(标准发布面)**:仓库根 `dsh.plugin.json`(原生清单:id `dsh-external/dsh-better-sidebar`、`main` = host 构建产物、`client.main` = registry client bundle、`contributes` 空声明);`tests/manifest-consistency.spec.ts` 守卫清单与构建产物一致性(id 严格两段、version 与 package.json 同步、bundle id = 清单 id)
- **双 client bundle**:同一 `src/client/index.tsx` 构建出 `lib/client.js`(官方通道,id = 包名 `dsh-better-sidebar`)与 `lib/client-registry.js`(registry 通道,id = 清单 id)——两通道的 bundle-id 契约互不相同,各产出一份、同源不漂移;每个部署二选一(见安装章节「通道互斥」)
- **已知偏差**:侧边栏面板经 portal(`document.body` + `createRoot`)挂载而非 slot——官方 slot 系统的 `'root'` 由 ui-layout 独占声明,重复声明 fail loud,外部插件无整面板 slot 可用;与 shell 的集成点(`conversation.chat.turnTail`)走官方 chain-slot 机制
## 🏗️ 架构
一个 npm 包,host/client 双半结构(与 DSH monorepo 内 client 包同构):
| 半 | 入口 | 职责 |
|---|---|---|
| host | `src/index.ts` → `lib/index.js` | cordis 插件(`Config` 为 schemastery schema,可调 readLimit/mediaLimit/listLimit/terminalsPerSession/reconnectGraceMs):`/sidebar/api/*` JSON API、`/sidebar/file` 媒体路由、`/sidebar/ws/terminal` WebSocket;fs / git / pty 服务 |
| invariant | `src/invariant.ts` → `lib/invariant.js` | 包属 invariant 伴生(注册包名,无运行时断言) |
| client(官方通道) | `src/client/index.tsx` → `lib/client.js` | 浏览器 bundle(`__ModuleLoader__.load` 闭包工厂,id = 包名):portal 侧边栏 + 各视图 + turnTail 拦截 |
| client(registry 通道) | `src/client/index.tsx` → `lib/client-registry.js` | 同一源码的 registry 版本(id = 清单 id `dsh-external/dsh-better-sidebar`,`dsh.plugin.json` 的 `client.main` 指向它) |
| manifest | `dsh.plugin.json`(仓库根;`scripts/package-registry.mjs` 组装 `registry/` 安装根) | registry 发布面:id / version / main / client.main;仅 registry 通道读取 |
- 所有 API 携带 `sessionId`;cwd 权威值取自会话 header,会话未附加(页面加载竞态)时回退客户端摘要 cwd,再回退进程 cwd;终端按 `${sessionId}:${tabId}` 键控,重连时若权威 cwd 变化则重启 shell 到正确目录
- 路由与 `/api` 同款信任围栏(Host 头 loopback / `connection.trustedHosts`,`src/trust-fence.ts`,拷贝自 `@deepseek-ai/dsh-client-connection`,BSD-3-Clause)
- client 状态按会话持久化到 `localStorage`(`dsh-sidebar:v1:<sessionId>`),读取时结构校验 + 宽度钳制视口 + 重复 id 重新编号
- client bundle externals 仅模块表词条(react/cordis/ui-primitives 等),xterm/CodeMirror 等全部内联
## 🔌 服务化(v0.4.0+):让其他插件注册 tab 和文件预览器
better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务(Cordis context 属性),其他插件可通过它注册:
- **侧边栏页面(tab)**:`ctx.betterSidebar.registerTab({ id, title, icon, component, ... })`
- **文件类型预览器**:`ctx.betterSidebar.registerFileViewer({ id, exts, fetchStrategy, component, ... })`
两个方法都返回 disposer,用 `ctx.effect(() => register(...))` 包裹即可让 Cordis fiber 卸载时自动撤销(HMR-safe)。内置 6 个 tab(explorer/git/subagent/terminal/editor/diff)和 8 个 viewer(image/pdf/docx/xlsx/pptx/markdown/code/binary-download)也通过同一服务注册("吃狗粮"),外部插件与内置一视同仁。预览路由、+ 菜单、tab 图标全部由注册表驱动,无硬编码分发。
**消费插件最小骨架**:
```ts
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
export const inject = ['betterSidebar']
export function apply(ctx: Context) {
ctx.effect(() => ctx.betterSidebar.registerTab({
id: 'my-plugin:db',
title: 'Database',
component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
id: 'my-plugin:db', title: 'Database', component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
}))
}
```
**完整接入文档**:见 [`AGENTS.md`](./AGENTS.md) ——含 `TabDescriptor` / `FileViewerDescriptor` 全字段、`fetchStrategy` 对照、匹配算法、HMR 陷阱、完整最小示例。
v0.12.1 补齐基座能力(完整类型导出、能力探测、状态订阅、tab 角标、生命周期回调、定向打开、插件自有设置等),详见下方接入文档。
## 🔐 安全边界
完整接入文档:
- **[`AGENTS.md`](./AGENTS.md)**——仓库内维护的接入文档(全字段、匹配算法、HMR 陷阱、声明式设置、版本探测);
- **[`docs/external-plugin-guide.md`](./docs/external-plugin-guide.md)**——面向外部插件开发者的接入指南(含完整最小示例)。
- 路由受 Host 头信任围栏保护(与 `/api` 一致;`0.0.0.0` 部署时由 `dsh web` 启动器动态派生的 LAN IP 列表生效)
- `fs.write` 仅文本写入、原子替换(临时文件 + rename);`/sidebar/file` 仅限会话 cwd 内的媒体文件
- 提交/分支操作仅调用 git CLI,绝不设置 git 身份
### ➕ 添加插件(推荐插件目录)
## ⚠️ 已知限制(v1 非目标)
设置页「侧边卡片」两个网格末尾的**虚线卡片**分别打开 Tab / 预览插件弹窗:声明扩展点、「**在 GitHub 上浏览更多插件**」按钮([GitHub topic `dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar))、推荐插件目录(名字 / 仓库 / 简介 / 安装脚本),每个条目「**跳转**」直达仓库、「**复制**」把安装命令写入剪贴板。
- Git 无 push/pull/fetch;无文件 watcher(手动刷新)
- 工具行内的文件打开按钮(核心代码)不可拦截,仅"产出文件"行被接管
- 终端 Tab **拖拽到另一分栏**时会重挂载(shell 重开);切换 Tab / 切换会话(30 秒内返回)/ 刷新页面不会
- `.xlsx` 预览不保留单元格样式(字体/颜色/边框/条件格式/图表)——SheetJS 社区版不解析样式,需保真请下载查看
- Office/PPTX 预览组件内联进 client bundle,当前约 23MB(gzip ~4.8MB),首次加载较慢
**收录新插件**:向 [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts)(Tab 注册)或 [`src/client/plugins-viewers.ts`](./src/client/plugins-viewers.ts)(文件预览注册)追加一条 `PluginEntry`,并把仓库打上 `dsh-better-sidebar` topic;数据完整性由 `tests/plugin-list.spec.ts` 守护。
## 🛠️ 开发与构建
```sh
pnpm install # @deepseek-ai/* 已发布到 npm(^0.1.0-rc.6),直接解析、无需令牌
pnpm typecheck # tsc --noEmit
pnpm build # → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types
pnpm test # vitest(含 manifest 一致性守卫,需先 build)
pnpm watch # tsdown --watch
```
**架构**:单 npm 包、host/client 双半结构——host(`src/index.ts`):`/sidebar/api/*` JSON API、`/sidebar/file` 媒体路由、`/sidebar/html` 预览路由、`/sidebar/ws/terminal` WebSocket(fs / git / pty / 预览,全部会话级 + 信任围栏);client(`src/client/index.tsx`):portal 侧边栏 + 各视图 + 拦截;状态按会话持久化 localStorage。插件按 DSH 官方规范组织(无 default 导出、双 client bundle),运行期不依赖 npm / checkout(`@deepseek-ai/*` 由 web profile 提供)。
## 🔐 安全
- 路由受 Host 头信任围栏保护(与 `/api` 一致);`fs.write` 原子写入;媒体/预览路由仅限会话 cwd 内文件;git 只调 CLI、绝不设置身份
- HTML 预览与浏览器 tab 的内容在**不透明源沙箱 iframe** 中渲染(无 `allow-same-origin`/`allow-top-navigation`、`no-referrer`、权限策略全禁);`/sidebar/html` 路由带 CSP `sandbox` + 大小/路径边界;地址栏拒绝 `javascript:`/`data:`/`file:` 与 localhost 等本机地址
- 界面实时显示沙箱状态(关闭时红色警示),可临时解锁当前页面;设置页可按功能关闭沙箱(默认关闭该设置,带警告文案)——关闭后内容与界面同源,仅建议对完全可信内容使用
## ⚠️ 已知限制
- Git 无 push/pull/fetch;无文件 watcher(手动刷新);工具行内文件打开按钮不可拦截
- 终端 Tab 拖到另一分栏会重挂载(shell 重开)
- Office 三件套预览(.docx/.xlsx/.pptx)已移至「推荐插件」(Office 预览插件,见设置页「添加插件」弹窗);未安装时此类文件走代码/下载查看兜底
- 浏览器沙箱无登录态/第三方 Cookie 受限,部分站点登录需走弹窗;被 `X-Frame-Options`/`frame-ancestors` 拒绝嵌入的站点(如 arxiv.org)显示原因面板(含「在浏览器中打开」);iframe 内部跳转不进后退栈
- HTML 预览渲染的是已保存文件(不反映未保存草稿)
- 移动端(<768px)无底部面板:进入窄屏时其标签页一次性并入右侧栏(迁移后回桌面仍保留在右侧栏),桌面端的底部面板只在宽视口下可用;移动端底部首展自动开终端不触发
## 🖥️ 平台支持
代码按 **Windows / Linux / macOS** 三平台适配(macOS 为日常验证平台;Windows/Linux 的平台分支经单元测试覆盖,建议真机冒烟):
Windows / Linux / macOS 三平台适配(macOS 日常验证;其余经单元测试覆盖);`node-pty` 优先预编译二进制,失败需编译工具链(Windows VS Build Tools / Linux make+g+++python3 / macOS Xcode CLT)。
- 路径处理全部走 `node:path` 平台 API(列表拼接、`resolve`);媒体路由的前缀检查与相对路径投影容忍混合分隔符,Windows 上大小写不敏感
- Windows 上终端 shell 为 `powershell.exe`;node-pty 的 spawn-helper 修复(pnpm 剥执行位)自动跳过 Windows
- `node-pty` 安装优先下载预编译二进制(`prebuild.js`,需网络),失败则源码编译——Windows 需 Visual Studio Build Tools,Linux 需 `make`/`g++`/`python3`,macOS 需 Xcode Command Line Tools;`pnpm-workspace.yaml` 已声明 `allowBuilds: node-pty: true`
- git 操作仅依赖 `git` 在 PATH(Windows 需 Git for Windows)
## 🔗 友情链接
- [dsh-tianshu-tui](https://github.com/huiliyi37/dsh-tianshu-tui):DeepSeek Harness 交互式终端 UI 插件(渲染核心由自研 harness agent Tianshu-Tui 演进而来),在官方基础上增加 TDD 与证据门等工作流
- [dsh-TUI](https://github.com/ccch1mneyyy/dsh-TUI):Claude Code 风格全屏交互终端插件——像素鲸鱼顶栏、实时工作状态行、思考流式展开、双击 Esc 回滚、上下文进度条 + TPS 仪表,npm 一键安装
- [dshfind 插件超市](https://dshfind.com/zh/plugins):三方插件市场——GitHub topic `dsh-plugin` 下的公开仓库清单,每日同步 star、贡献者与增长数据
- [DeepSeek Harness Desktop](https://github.com/anywhere-labs/deepseek-harness-desktop):为 DeepSeek Harness 生态打造的现代化桌面端——无需配置 Node.js 或执行命令即可启动和管理本地 Harness 服务;[官网](https://www.dshdesktop.cn)
+220
View File
@@ -0,0 +1,220 @@
# dsh-better-sidebar
<!-- Hero -->
<div align="center">
<b style="font-size: 1.15em;">A service-oriented sidebar framework, and a complete workbench out of the box</b><br /><br />
<a href="https://opensource.org/licenses/MIT"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-yellow.svg" /></a>
<a href="https://dshfind.com/en/plugins/omdsh-dev/DSH-better-sidebar?ref=badge"><img alt="dshfind" src="https://dshfind.com/api/badge/omdsh-dev/DSH-better-sidebar?lang=en" /></a><br /><br />
<img alt="File management" src="https://img.shields.io/badge/-File%20management-4d6bfe" /> <img alt="Edit &amp; preview" src="https://img.shields.io/badge/-Edit%20%26%20preview-4d6bfe" /> <img alt="Embedded browser" src="https://img.shields.io/badge/-Embedded%20browser-4d6bfe" /> <img alt="Real terminal" src="https://img.shields.io/badge/-Real%20terminal-4d6bfe" /> <img alt="Git panel" src="https://img.shields.io/badge/-Git%20panel-4d6bfe" /> <img alt="Background tasks" src="https://img.shields.io/badge/-Background%20tasks-4d6bfe" /> <img alt="Plugin integration" src="https://img.shields.io/badge/-Plugin%20integration-4d6bfe" /><br /><br />
<b>A dual workbench (right sidebar + bottom panel)</b> that opens its <code>ctx.betterSidebar</code> service to every plugin —<br />
register new sidebar pages and file viewers via <code>registerTab</code> / <code>registerFileViewer</code>.
</div>
<div align="center">
🌏 <a href="./README.md">中文</a> · <a href="./README_EN.md"><b>English</b></a>
</div>
<div align="center">
<video src="https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86" muted autoplay loop playsinline controls width="100%"></video>
<img alt="dsh-better-sidebar workbench" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" />
</div>
## ✨ Features
- **🗂️ File Workbench**: file explorer (lazy-loading tree; symlinks show their target kind — directory links expand, dangling links flagged) + CodeMirror editor; inline preview for images / Markdown / HTML / PDF / Office
- **🌐 Embedded Browser**: multiple web tabs with back / forward / refresh; content runs in a sandboxed iframe; external links are routed by protocol by default — HTTP opens in the sidebar, HTTPS goes to the system browser (both adjustable in settings)
- **💻 Real Terminal**: xterm.js + node-pty real shell, reconnect with transcript replay; optionally injects `terminal_*` tools for the model
- **🌿 Git Panel**: real diff + VSCode-style diff tabs, history, right-click to stage / commit / revert
- **🧩 Background Tasks**: agent topology + background tasks (exit codes / live output / force-kill)
- **🪟 Dual Workbench**: right sidebar + bottom panel; drag tabs to split / merge panes (cross-panel), mobile auto-merges into a full-width drawer
- **🔁 Session Isolation**: layout / tabs / panels persisted per session, stale state auto-purged
- **⚙️ Declarative Settings**: per-item toggles in the "Side Cards" settings section, secondary settings via the gear dialog
- **⚡ On-demand Loading**: only ~325KB core at startup; heavy deps (terminal / editor) load on demand ([design](docs/plans/2026-08-12-lazy-chunks-design.md))
- **🌏 i18n**: UI text follows DSH's language (zh / en) with live switching
> 🔌 **Core principle**: service-first — the 7 built-in tabs + 6 viewers register through the same `ctx.betterSidebar` API as third-party plugins, with fully equal capabilities; anything the ecosystem can provide better is delegated to ecosystem plugins. See the "🔌 Service" section below and the [external plugin guide](./docs/external-plugin-guide.md).
## 🆕 Recent Updates
<div align="center">
<a href="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0"><img width="45%" alt="Service API base screenshot" src="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0" /></a>
<a href="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e"><img width="45%" alt="Add Plugins screenshot" src="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e" /></a>
</div>
### v0.12.3
- 🐛 **node-pty load failure no longer crashes the server** ([#140](https://github.com/omdsh-dev/DSH-better-sidebar/issues/140)): the host half now lazy-loads node-pty — when it is missing the plugin still mounts, the terminal shows a repair banner (copyable command + Retry button), and agent terminal tools are skipped
- 🚀 **Release pipeline**: npm publishing is now wired to GitHub Releases (Trusted Publishing, provenance-attached tarballs); tagging a release publishes automatically from this version on
### v0.12.2
- 📐 **Position compat mode**: new "Position compatibility mode" setting: reserves top space for the native Windows title bar (top-right) so the sidebar buttons and content sit below it (off by default); the shift distance is customizable in the gear popup (0–120px)
- 🔌 **Service API base**: complete type exports + `version`/`features` capability detection, state subscription (`getSnapshot`/`subscribeState`), tab `badge`, `onOpen`/`onActivate`/`onClose` lifecycle callbacks, `updateTab`/`activateTab`/`openFile`, targeted open, `meta` persisted across reloads, plugin-owned settings (`pluginToggles`/`render`), external-link claim (`urlTarget`)
- ➕ **Add Plugins**: recommended plugin catalog in settings + one-click copy install command; built-in Office preview moved to the recommended plugin
- 🖱️ **Tab-bar scroll**: mouse-wheel horizontal scrolling on the tab bar
- 🐛 **Fixes**: remote access 403 (trust fence now uses `trustedHosts`), sidebar crash [#31](https://github.com/omdsh-dev/DSH-better-sidebar/issues/31), Windows HTML-preview drive-path
### v0.12.1
- 🔌 **Service API base**: complete type exports + `version`/`features` capability detection, state subscription (`getSnapshot`/`subscribeState`), tab `badge`, `onOpen`/`onActivate`/`onClose` lifecycle callbacks, `updateTab`/`activateTab`/`openFile`, targeted open, `meta` persisted across reloads, plugin-owned settings (`pluginToggles`/`render`)
- ➕ **Add Plugins**: recommended plugin catalog in settings + one-click copy install command; built-in Office preview moved to the recommended plugin
- 🖱️ **Tab-bar scroll**: mouse-wheel horizontal scrolling on the tab bar
- 🐛 **Fixes**: remote access 403 (trust fence now uses `trustedHosts`), sidebar crash [#31](https://github.com/omdsh-dev/DSH-better-sidebar/issues/31), Windows HTML-preview drive-path
> 📝 Note: the 0.12.0 final could not be reused (npm reported the version as already published), so the public release became 0.12.1 — both carry identical content.
### v0.12.0
- 🔌 **Service API base**: complete type exports + `version`/`features` capability detection, state subscription, tab badges, lifecycle callbacks, targeted open, `meta` persisted across reloads, plugin-owned settings
- ➕ **Add Plugins**: recommended plugin catalog in settings + one-click copy install command; built-in Office preview moved to the recommended plugin
- 🖱️ **Tab-bar scroll**: mouse-wheel horizontal scrolling on the tab bar
- 🐛 **Fixes**: remote access 403 (trust fence now uses `trustedHosts`), sidebar crash [#31](https://github.com/omdsh-dev/DSH-better-sidebar/issues/31), Windows HTML-preview drive-path
## 🚀 Installation
**Prerequisites**: DSH installed (`dsh web` boots), Node.js ≥ 20, pnpm ≥ 10.
```sh
dsh plugin --profile web add dsh-better-sidebar
```
Then **hard-refresh the browser** (Cmd/Ctrl+Shift+R) to see the sidebar (DSH hot-reloads client changes; only host-half updates need a restart).
<details>
<summary><b>Updating</b></summary>
```sh
dsh plugin --profile web add dsh-better-sidebar
```
or bump the version in `~/.dsh/profiles/web/package.json` (e.g. `"^0.12.3"`) and run `pnpm install`. Then hard-refresh the browser (Cmd/Ctrl+Shift+R) — client changes do not need a DSH restart.
</details>
<details>
<summary><b>Troubleshooting</b></summary>
| Symptom | Cause & fix |
|---|---|
| `Ignored build scripts` | pnpm 11 blocked build scripts. Run `pnpm approve-builds --all` in the profile directory (`~/.dsh/profiles/web`). |
| `minimum release age` / version `< 24h` | The release is younger than 24 hours. Wait, or re-run once (pnpm auto-adds `minimumReleaseAgeExclude`). |
| "profile directory not found" | Run `dsh web` once so it initializes `~/.dsh/profiles/web`. |
| Two sidebars on the page | Double-mount: `~/.dsh/profiles/web/cordis.patch.yml` still has the old hand-written `- insert: ... better-sidebar ...` line — delete it. |
| Terminal fails on Windows | `node-pty` relies on prebuilt binaries; if none match your Node version, install a build toolchain (VS Build Tools). Mainstream Node versions are usually covered. |
| Terminal shows "node-pty failed to load" | The `node-pty` install is missing or broken (e.g. pnpm skipped its build script). The terminal banner shows a repair command — copy it into a terminal/cmd on the DSH machine and run it (in `~/.dsh/profiles/web`: `pnpm approve-builds --all && pnpm rebuild node-pty`), then restart DSH and click Retry. The plugin and DSH core share the same `node-pty@^1.1.0`, so the repair restores both. |
| `dsh: command not found` | Install DSH first, or run `npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar`. |
</details>
<details>
<summary><b>Install from source / develop (optional — alternative to the npm flow)</b></summary>
To debug local changes or track the dev branch, point the dependency at a local clone and build it yourself:
```text
1. git clone https://github.com/omdsh-dev/DSH-better-sidebar.git ~/Code/DSH-better-sidebar
cd ~/Code/DSH-better-sidebar && pnpm install && pnpm build
2. In ~/.dsh/profiles/web/package.json dependencies write "dsh-better-sidebar": "link:<absolute path of the clone>"
3. Append this mount line to ~/.dsh/profiles/web/cordis.patch.yml:
- insert:
- id: better-sidebar
name: 'dsh-better-sidebar'
4. Run pnpm install in ~/.dsh/profiles/web
5. Restart DSH and hard-refresh
```
Update: `git pull && pnpm install && pnpm build` → just hard-refresh the browser (client changes hot-reload; only host-half changes need a DSH restart). To switch back to the npm channel, restore `"dsh-better-sidebar": "^0.12.3"` and re-run `pnpm install`.
</details>
<details>
<summary><b>Install via plugin-registry (optional — use either this or the main flow)</b></summary>
Prerequisite: DSH with [plugin-registry](https://github.com/dsh-external/plugin-registry) integrated (`dsh registry` available). **Enabling both channels double-mounts** (the Node half loads twice, the page gets two sidebars).
```sh
git clone https://github.com/omdsh-dev/DSH-better-sidebar.git && cd DSH-better-sidebar
pnpm install && pnpm build
node scripts/package-registry.mjs # assemble the registry/ staging (manifest + artifacts + README, not committed)
dsh registry install ./registry # install (disabled by default)
dsh registry enable dsh-external/dsh-better-sidebar
```
Update: `git pull && pnpm install && pnpm build` → `node scripts/package-registry.mjs` → `dsh registry uninstall/install/enable`. Remove the other channel's mount before switching.
</details>
## ⌨️ Keyboard Shortcuts
| Action | Keys |
|---|---|
| Save edits | `Ctrl/Cmd + S` |
| Git commit | `Ctrl + Enter` |
| Close tab | Middle mouse button |
| Split / merge panes | Drag tab to pane edge / middle |
| Reference file to input | Hover the `@file` button at end of line |
| Copy file path | Right-click row → copy relative/absolute path |
## 🔌 Service: register tabs & file viewers
Since v0.4.0 the plugin exposes the `ctx.betterSidebar` service — other plugins can register sidebar pages and file viewers (the 7 built-in tabs + 6 viewers register through the same service):
```ts
import type {} from 'dsh-better-sidebar' // triggers the ctx.betterSidebar type merge
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} />,
}))
}
```
v0.12.1+ base capabilities (complete type exports, capability detection, state subscription, tab badge, lifecycle callbacks, targeted open, plugin-owned settings, etc.) — see the integration docs below.
Full integration docs:
- **[`AGENTS.md`](./AGENTS.md)** — the in-repo integration doc (full fields, matching algorithm, HMR pitfalls, declarative settings, version detection);
- **[`docs/external-plugin-guide.md`](./docs/external-plugin-guide.md)** — the external-plugin guide (with a complete minimal example).
### ➕ Add Plugins (recommended plugin catalog)
The dashed cards at the end of the "Sidebar content" / "File viewers" grids in the "Side Cards" settings section open the **Add tab plugins** / **Add preview plugins** modals: each declares its open extension point, offers a "**Browse more plugins on GitHub**" button (the [GitHub topic `dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar)), and lists the recommended catalog (name / repo / description / install script) — "**Open**" jumps to the repo, "**Copy**" writes the install command to the clipboard.
**Curating a new plugin**: append a `PluginEntry` to [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts) (tab registrations) or [`src/client/plugins-viewers.ts`](./src/client/plugins-viewers.ts) (file-previewer registrations) and tag your repo with the `dsh-better-sidebar` topic; data integrity is guarded by `tests/plugin-list.spec.ts`.
## 🛠️ Development & Build
```sh
pnpm install # @deepseek-ai/* resolved from npm (^0.1.0-rc.6, published) — no token needed
pnpm typecheck # tsc --noEmit
pnpm build # → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types
pnpm test # vitest (includes manifest consistency guard; build first)
pnpm watch # tsdown --watch
```
**Architecture**: a single npm package with host/client halves — host (`src/index.ts`): `/sidebar/api/*` JSON API, `/sidebar/file` media route, `/sidebar/html` preview route, `/sidebar/ws/terminal` WebSocket (fs / git / pty / preview, all session-scoped with a trust fence); client (`src/client/index.tsx`): portal sidebar + views + interception; state persisted per session in localStorage. Organized per DSH official conventions (no default export, dual client bundles); no dependency on npm / checkout at runtime (`@deepseek-ai/*` provided by the web profile).
## 🔐 Security
- Routes protected by a Host-header trust fence (same as `/api`); `fs.write` is atomic; media/preview routes only serve files inside the session cwd; git only shells out to the CLI and never sets identity
- HTML preview and browser tab content render in **opaque-origin sandboxed iframes** (no `allow-same-origin`/`allow-top-navigation`, `no-referrer`, all permission policies disabled); the `/sidebar/html` route carries a CSP `sandbox` + size/path bounds; the address bar rejects `javascript:`/`data:`/`file:` and local addresses like localhost
- The UI shows the sandbox status live (red warning when off) and can temporarily unlock the current page; the settings page can disable the sandbox per feature (disabled by default, with a warning) — when off, content shares the origin with the UI; only recommended for fully trusted content
## ⚠️ Known Limitations
- Git has no push/pull/fetch; no file watcher (manual refresh); tool inline file-open buttons cannot be intercepted
- Dragging a terminal tab to another pane remounts it (shell restarts)
- Office-suite preview (.docx/.xlsx/.pptx) moved to the recommended office plugin (see the "Add plugins" modals in settings); without it these files fall through to the code/download fallbacks
- Browser sandbox has no login state / third-party cookies are restricted; some sites need popup login; sites that refuse embedding via `X-Frame-Options`/`frame-ancestors` (e.g. arxiv.org) show a reason panel (with "Open in browser"); in-iframe navigation does not enter the back stack
- HTML preview renders the saved file (not unsaved drafts)
- No bottom panel on mobile (<768px): on narrow screens its tabs merge into the right sidebar once (after migrating back to desktop they stay in the right sidebar); the desktop bottom panel is only available on wide viewports; auto-open terminal on first bottom-panel expand does not trigger on mobile
## 🖥️ Platform Support
Windows / Linux / macOS (macOS validated daily; the rest covered by unit tests); `node-pty` prefers prebuilt binaries, otherwise a build toolchain is required (Windows VS Build Tools / Linux make+g+++python3 / macOS Xcode CLT).
## 🔗 Friends
- [dsh-tianshu-tui](https://github.com/huiliyi37/dsh-tianshu-tui): an interactive terminal UI plugin for DeepSeek Harness (its rendering core evolved from the self-developed harness agent Tianshu-Tui), adding TDD and evidence-gate workflows on top of the official harness
- [dsh-TUI](https://github.com/ccch1mneyyy/dsh-TUI): a Claude Code-style fullscreen interactive TUI plugin — pixel-whale top bar, live working-status row, streaming thought expansion, double-Esc rollback, context progress bar + TPS meter; one-command npm install
- [dshfind Plugin Market](https://dshfind.com/zh/plugins): a third-party plugin marketplace — a listing of public repos under the GitHub topic `dsh-plugin`, with stars, contributors and growth data synced daily
- [DeepSeek Harness Desktop](https://github.com/anywhere-labs/deepseek-harness-desktop): a modern desktop client for the DeepSeek Harness ecosystem — start and manage a local Harness service without configuring Node.js or running commands; [official site](https://www.dshdesktop.cn)
+19
View File
@@ -0,0 +1,19 @@
# dsh-better-sidebar bundle patch
#
# This file is the `dsh.bundle.patch` layer of the published npm package: when
# the plugin is installed through the official CLI —
#
# dsh plugin --profile <name> add dsh-better-sidebar@<version>
#
# — the command reconciles `dsh.profile.bundles` against installed packages
# and, seeing this declaration, appends `dsh-better-sidebar` to the bundle
# stack. The profile boot then merges THIS patch (a single `insert` of the
# plugin row) exactly like the manual cordis.patch.yml mount line used
# before. No profile file edits needed — one command installs and mounts.
#
# If the profile still carries the old manual mount line in its own
# cordis.patch.yml, remove it before switching to the bundle channel to
# avoid double-mounting (two Node halves, two sidebars).
- insert:
- id: better-sidebar
name: 'dsh-better-sidebar'
+788
View File
@@ -0,0 +1,788 @@
# 外部插件接入指南:基于 dsh-better-sidebar 实现新页面
> 面向 **消费插件开发者**:如何让你的插件向 better-sidebar 注册新的侧边栏页面(tab)和文件类型预览器。
>
> 适用版本:**v0.4.0+**(`ctx.betterSidebar` 服务);声明式设置 **v0.4.1+**;text/number 设置行 **v0.11.0+**;本文档其余新 API(badge/生命周期/定向打开/插件设置/版本探测等)**v0.12.0+**。当前版本 v0.12.0。
> 权威代码:`src/client/service.ts`(服务实现)、`src/client/builtins/`(内置 7 tab + 9 viewer 参考实现)、`lib/types/client/service.d.ts`(类型声明)。
---
## 1. 总览:你能扩展什么
better-sidebar 从 v0.4.0 起把自己改造成一个**注册表服务**:
- **新页面(tab)**:注册一种新的侧边栏 tab 类型,出现在侧边栏 `+` 菜单里,用户点击后在自己的分栏里打开你的 React 页面;
- **文件预览器(file viewer)**:注册一种文件类型预览器,让用户在侧边栏打开文件时走你的渲染组件(覆盖或补充内置的 image/pdf/code 等)。
内置的 7 个 tab(explorer / git / subagent / terminal / browser / editor / diff)和 6 个 viewer(image / pdf / markdown / html / code / binary-download)**自己也是通过同一套 API 注册的**(吃自己的狗粮),所以外部插件的能力与内置功能完全对等。
关键机制一句话:better-sidebar 的 client half 在 `apply()` 开头执行 `ctx.provide('betterSidebar', service)`(`src/client/index.tsx`),消费插件在 `inject` 里声明 `'betterSidebar'`,Cordis 保证服务就绪后才激活你的插件,然后你调用 `ctx.betterSidebar.registerTab(...)` / `registerFileViewer(...)` 完成注册,返回的 disposer 由 Cordis fiber 在卸载(HMR / 禁用)时自动调用。
> ⚠️ **服务只在 client half**:`ctx.betterSidebar` 只存在于浏览器侧。你的插件 **host 半没有这个服务**;host 半需要读 better-sidebar 状态时,走它自己的 HTTP/WS 路由(`/sidebar/api/*`、`/sidebar/file`、`/sidebar/ws/*`),不走服务。
---
## 2. 前置:类型合并与依赖声明
### 2.1 双 cordis 实例问题
外部插件在 DSH monorepo 之外解析,拿不到官方 cordis 的 augmentation,因此 `ctx.betterSidebar` 不会自动出现在你的 `Context` 类型上。解法由 better-sidebar 自己提供:
```ts
import type {} from 'dsh-better-sidebar' // 触发 declare module 'cordis' 类型合并
```
这个 **type-only import** 在编译时被擦除,不产生任何运行时依赖,也不会触发构建纯度门(见 §10)。
### 2.2 package.json 声明
```jsonc
{
"name": "my-plugin",
"peerDependencies": {
"cordis": "^4.0.0-rc.7",
"dsh-better-sidebar": "workspace:*"
},
"peerDependenciesMeta": {
"dsh-better-sidebar": { "optional": true }
}
}
```
- `dsh-better-sidebar` 必须是 **peerDependency**(不是 dependency),避免两份实例;
- `optional: true`:better-sidebar 未安装时你的插件照常加载,注册代码因为 `ctx.betterSidebar` 为 undefined 而安全跳过。
### 2.3 类型导入路径
```ts
// 方式一:主入口(推荐,src/index.ts 已 re-export 全部描述符类型)
import type {
BetterSidebarService,
TabDescriptor,
TabComponentProps,
FileViewerDescriptor,
FileViewerProps,
FileFetchStrategy,
} from 'dsh-better-sidebar'
// 方式二:子路径(与主入口等价)
import type { TabDescriptor } from 'dsh-better-sidebar/client/service' // 别名 ./client/api
```
v0.12.0 起,服务模块还 re-export 了完整的状态词汇表,消费者可以直接命名(不再只能靠推断):
```ts
import type {
SidebarTab, SidebarState, SidebarStore, SidebarSnapshot, SidebarDiffRef, TabType,
SessionScope, SidebarPrefs, OpenTabSeed, SidebarSettingsRenderProps,
} from 'dsh-better-sidebar/client/service'
```
> 💡 **类型合并触发路径**:`import type {} from 'dsh-better-sidebar/client/service'` 同样会加载 `Context` 的 augmentation(`declare module 'cordis'` 在 context-types.d.ts 中)——**纯浏览器侧插件建议走 `client/service` 路径**,避免拉进宿主半的 Node 类型图(主入口 `dsh-better-sidebar` 的声明面含宿主代码;宿主消费者本就处于 Node 环境则无所谓)。client 可达声明图(`client/*` + context-types + html-route + prefs-shared)自 v0.12.0 起**零 Node 依赖**(`scripts/check-consumer-types.sh` 守护),没有 `@types/node`、`skipLibCheck: false` 也能编译。
---
## 3. 最小骨架(client half)
```ts
// my-plugin/src/client/index.ts
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
import type { Context } from 'cordis'
export const inject = ['betterSidebar', 'slots'] // 声明服务依赖(slots 可选,按需)
export function apply(ctx: Context): void {
// 注册一个 sidebar tab:ctx.effect 包裹 → 卸载时自动撤销注册(HMR-safe)
ctx.effect(() =>
ctx.betterSidebar.registerTab({
id: 'my-plugin:db',
title: () => 'Database',
icon: <DbIcon />,
order: 50,
component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
})
)
// 注册一个文件预览器
ctx.effect(() =>
ctx.betterSidebar.registerFileViewer({
id: 'my-plugin:csv',
exts: ['csv'],
fetchStrategy: 'custom',
load: async (path, scope) => parseCsv(await fetchCsvBytes(scope, path)),
component: ({ customData, path }) => <CsvGrid data={customData} path={path} />,
})
)
}
```
要点:
- **注册必须包在 `ctx.effect(...)` 里**。`registerTab` / `registerFileViewer` 返回 `() => void` disposer,Cordis fiber 卸载时自动调用;不包 effect,HMR / 插件禁用后注册残留,下次激活会抛 `"already registered"`。
- `inject = ['betterSidebar']` 让 Cordis 在 better-sidebar 激活后才激活你的插件,注册时机无忧(顺序无关)。
- 注册在 `apply` 内任意时刻都行;服务在 better-sidebar 的 `apply()` 开头就绪。
---
## 4. 新页面(Tab)注册 API
### 4.1 `TabDescriptor` 完整字段
```ts
interface TabDescriptor {
/** 唯一 id;也是 SidebarTab.type 的值。建议带包前缀:'my-plugin:db'。 */
id: string
/** 标题(i18n 友好:传字符串或返回字符串的函数) */
title: string | (() => string)
/** 图标:ReactNode 或 (size: number) => ReactNode */
icon?: ReactNode | ((size: number) => ReactNode)
/** + 菜单排序(升序);默认 100。内置:explorer=10, git=20, subagent=30, terminal=40 */
order?: number
/** 从 + 菜单隐藏(editor/diff 用:由其他流程触发打开,不在菜单里) */
hidden?: boolean
/** + 菜单禁用判定(如 terminal 配额满)。三参:ctx、会话 scope、当前状态 */
available?: (ctx: Context, scope: SessionScope, state: SidebarState) => boolean
/**
* 单实例语法糖:`single: true` ≡ `dedupeKey: () => id`(打开时聚焦既有
* 同类型 tab 而非新开)。显式给出 dedupeKey 时优先于 single。
*/
single?: boolean
/**
* 去重键:openTab 时若已存在 dedupeKey 相同的 tab,则聚焦而非新开。
* 返回 undefined 表示不去重(每次都新开,但同 id 会被 id 安全网聚焦)。
* 内置策略:explorer/git/subagent 用 single: true;editor 用 tab => tab.path;diff 用 tab => tab.id。
*/
dedupeKey?: (tab: SidebarTab) => string | undefined
/**
* 自定义 tab 创建(minting SidebarTab + 状态 patch)。
* 返回 null 拒绝创建。terminal 用它生成 terminal:<n> id 并递增 nextTerminal。
* 省略时用默认 { id, type, title } + seed 里的 path/diff。
*/
createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null
/**
* 声明式设置(v0.4.1+):见 §8。v0.12.0 起增加 `pluginToggles`(插件自有
* 设置行,key 无需宿主 schema 字段)与 `render`(自定义设置面板)。
*/
settings?: SidebarSettingsDeclaration
/**
* tab 角标(v0.12.0+):tab 图标旁的小圆角 pill。number 渲染计数(99+ 封顶),
* string 原样文本,null/undefined 不显示。每次 tab 栏渲染都会调用——保持廉价;
* 抛错会被吞掉(不显示角标,不影响渲染)。
*/
badge?: (ctx: Context, scope: SessionScope, state: SidebarState) => string | number | null | undefined
/**
* 生命周期回调(v0.12.0+),只由 SERVICE 路径触发:
* - onOpen:openTab 真正**新建** tab 后(dedupe/id 安全网聚焦不算打开);
* - onActivate:tab 被聚焦时(dedupe 聚焦、id 安全网聚焦、tab 栏点击激活);
* - onClose:closeTab 关闭 tab 后。
* 内置专属流程(diff 拆分放置、agent 终端 reconcile)直接改 state,不触发
* 回调——但它们只作用于内置类型(diff/terminal),外部插件的 tab 永远走
* service 路径。回调抛错只 console.error,绝不打断打开/关闭流程。
*/
onOpen?: (tab: SidebarTab, scope: SessionScope) => void
onActivate?: (tab: SidebarTab, scope: SessionScope) => void
onClose?: (tab: SidebarTab, scope: SessionScope) => void
/** 渲染函数 */
component: (props: TabComponentProps) => ReactNode
}
/** 声明式设置声明(v0.12.0 完整形状;行控件见 §8)。 */
interface SidebarSettingsDeclaration {
/** 宿主 prefs 字段行(key 必须是宿主 PrefsSchema 的字段)。 */
toggles?: readonly {
key: string
title: string | (() => string)
desc?: string | (() => string)
type?: 'switch' | 'text' | 'number' // v0.11.0+;缺省 'switch'
min?: number
max?: number
placeholder?: string
unit?: string
}[]
/** 插件自有设置行(v0.12.0+):形状同 toggles,key 插件局部,
* 持久化在 pluginSettings[<descriptor id>],无需宿主 schema 字段。 */
pluginToggles?: readonly {
key: string
title: string | (() => string)
desc?: string | (() => string)
type?: 'switch' | 'text' | 'number'
min?: number
max?: number
placeholder?: string
unit?: string
}[]
/** 自定义设置面板(v0.12.0+):给出时齿轮弹窗渲染它而非行列表。 */
render?: (props: SidebarSettingsRenderProps) => ReactNode
}
/** settings.render 收到的 props(v0.12.0+)。 */
interface SidebarSettingsRenderProps {
store: SidebarStore
service: BetterSidebarService
prefs: SidebarPrefs
/** 本 descriptor 自己的持久化设置 blob(pluginSettings[id])。 */
pluginSettings: Record<string, unknown>
/** 持久化一条本 descriptor 的插件设置(值须 JSON 可序列化)。 */
updatePluginSetting(key: string, value: unknown): void
/** 关闭设置弹窗。 */
close(): void
}
```
### 4.2 `TabComponentProps`(你的页面组件收到的 props)
```ts
interface TabComponentProps {
ctx: Context // client cordis context
store: SidebarStore // better-sidebar 的状态 store(可调 reduce 等)
scope: SessionScope // { sessionId, cwd? } —— 会话标识,调用 /sidebar API 必带
tab: SidebarTab // 当前 tab 实例(含 id/type/title/path?/diff?)
visible: boolean // 是否当前激活 tab 且面板打开(不可见时暂停轮询等)
// 以下由内置 tab 使用,外部 tab 可忽略:
expanded?: string[] // explorer 的展开目录集
onToggleDir?: (path: string) => void
onReferenceFile?: (path: string) => void
onOpenFile?: (path: string) => void
onOpenDiff?: (tab: SidebarTab) => void
onSubagentJump?: (childSessionId: string) => void
}
```
实践建议:
- **用 `visible` 做性能门**:subagent 内置页在 `visible === false` 时暂停轮询;你的页面若有轮询/订阅,同样处理。
- **用 `scope.sessionId`(+ `scope.cwd`)访问会话数据**:所有 `/sidebar/api/*` 请求都要带这两个字段(见 §6)。
### 4.3 注册示例
**最简单实例 tab**(+ 菜单可见):
```ts
ctx.effect(() =>
ctx.betterSidebar.registerTab({
id: 'my-plugin:notes',
title: 'Notes',
icon: <NoteIcon />,
order: 50,
single: true, // ≡ dedupeKey: () => 'my-plugin:notes'
component: ({ scope }) => <NotesView sessionId={scope.sessionId} />,
})
)
```
**多实例 tab + 外部触发打开**(每次新开,带自定义 id):
```ts
ctx.effect(() =>
ctx.betterSidebar.registerTab({
id: 'my-plugin:doc',
title: 'Doc',
icon: <DocIcon />,
order: 60,
// 不设 dedupeKey:每次 openTab 都新开
component: ({ tab, scope }) => <DocView docId={tab.id} sessionId={scope.sessionId} />,
})
)
// 外部触发打开(你的插件其他流程、甚至用户操作):
ctx.betterSidebar.openTab({ type: 'my-plugin:doc', title: 'Spec.md', id: 'doc:spec' })
```
**条件可见**(仅满足条件时 + 菜单可用;返回 false 显示为 disabled 行而非隐藏):
```ts
ctx.effect(() =>
ctx.betterSidebar.registerTab({
id: 'my-plugin:commits',
title: 'Commits',
icon: <CommitIcon />,
order: 70,
available: (ctx, scope, state) => hasGitRepo(state),
dedupeKey: () => 'my-plugin:commits',
component: ({ scope }) => <CommitsView sessionId={scope.sessionId} />,
})
)
```
**自定义创建**(mint 自增 id,terminal 内置页同款):
```ts
ctx.effect(() =>
ctx.betterSidebar.registerTab({
id: 'my-plugin:console',
title: 'Console',
order: 80,
createTab: (state) => ({
tab: { id: `console:${state.nextTerminal}`, type: 'my-plugin:console', title: `Console ${state.nextTerminal}` },
patch: { nextTerminal: state.nextTerminal + 1 }, // 借内置计数器;也可自建 state 字段
}),
component: ({ tab, scope }) => <ConsoleView tabId={tab.id} sessionId={scope.sessionId} />,
})
)
```
### 4.4 内置 tab 清单(不可重复注册)
| id | order | single | hidden | 用途 |
|---|---|---|---|---|
| `editor` | -1 | 否(按 path 去重) | 是 | 文件编辑/预览(由 openSidebarFile 触发) |
| `explorer` | 10 | 是 | 否 | 文件资源管理器 |
| `git` | 20 | 是 | 否 | Git 面板 |
| `subagent` | 30 | 是 | 否 | 子代理拓扑 |
| `terminal` | 40 | 否(createTab 自增) | 否 | 终端(配额 3) |
| `browser` | 50 | 否(createTab 铸造 browser:`<n>`,nextBrowser 自增) | 否 | 内嵌网页浏览器(沙箱 iframe;可设置关闭沙箱) |
| `diff` | -1 | 否(按 id 去重) | 是 | 差异查看(由 GitView 触发) |
你的 `id` 不可与上述重复,否则 `registerTab` 抛 `"tab type \"X\" already registered"`。
---
## 5. 文件预览器(FileViewer)注册 API
### 5.1 `FileViewerDescriptor` 完整字段
```ts
interface FileViewerDescriptor {
/** 唯一 id:'image' / 'pdf' / 'my-plugin:csv' */
id: string
/** 设置清单展示名(v0.4.1+,i18n 友好);缺省回退到 id */
title?: string | (() => string)
/** 设置清单图标(v0.4.1+):ReactNode 或 (size: number) => ReactNode */
icon?: ReactNode | ((size: number) => ReactNode)
/** 小写无点的扩展名数组:['png','jpg']。[] = catch-all(仅最低优先级有效) */
exts: readonly string[]
/** 优先级(高优先);默认 0。内置默认 0;catch-all code 用 -100;binary-download 用 -50 */
priority?: number
/** 字节获取策略 */
fetchStrategy: 'none' | 'fsRead' | 'mediaUrl' | 'custom' | 'binary-download'
/** 内容嗅探(覆盖 exts):head 字节可用时,第一个 detect 返回 true 的 viewer 命中 */
detect?: (path: string, head: Uint8Array) => boolean
/** fetchStrategy='custom' 时的加载函数;v0.12.0+ 第三参 signal 在 viewer
* 卸载/重匹配时中止(忽略 signal 的 load 也照常工作) */
load?: (path: string, scope: SessionScope, signal?: AbortSignal) => Promise<unknown>
/** 声明式设置(v0.4.1+):形状同 TabDescriptor.settings(v0.12.0 起 viewer
* 卡片也有齿轮按钮,pluginToggles/render 同样可用) */
settings?: SidebarSettingsDeclaration
/** 渲染函数 */
component: (props: FileViewerProps) => ReactNode
}
```
### 5.2 `FileViewerProps`
```ts
interface FileViewerProps {
ctx: Context
store: SidebarStore
scope: SessionScope
path: string
title: string
viewerId: string // 命中 viewer 的 id(如 'code' / 'my-plugin:csv')
content?: string // fetchStrategy='fsRead' 时
truncated?: boolean // fetchStrategy='fsRead' 时
mediaUrl?: string // fetchStrategy='mediaUrl' 时
customData?: unknown // fetchStrategy='custom' 时(load() 的返回值)
}
```
### 5.3 `fetchStrategy` 对照
| 策略 | 字节来源 | 传给 component 的字段 | 适用 |
|---|---|---|---|
| `none` | 不需要字节 | (无) | 自渲染(如纯 UI) |
| `fsRead` | `/sidebar/api` 的 `fs.read` | `content`, `truncated` | 文本类(CSV/JSON/XML) |
| `mediaUrl` | `/sidebar/file` 媒体路由 URL | `mediaUrl` | 图片/PDF(viewer 自己 fetch 字节) |
| `custom` | viewer 的 `load()` 函数 | `customData` | 自定义协议(如远程拉取) |
| `binary-download` | 不预览,显示下载按钮 | (无) | 无客户端渲染器的二进制格式 |
### 5.4 匹配算法(`matchFileViewer`)
`matchFileViewer(path, head?)` **单趟**按 priority 降序(稳定排序,相同 priority 按注册顺序)遍历每个 descriptor:
1. 若 `head` 字节可用且该 descriptor 有 `detect` → 调 `detect(path, head)`,true 则命中;**miss 且是 catch-all(`exts: []`)则本轮放弃**(纯嗅探型不得盲认领);
2. 否则匹配 `exts`(小写无点;`exts: []` 且无 `detect` 是盲 catch-all,直接命中)。
即:**priority 高的 descriptor 先获得裁决权**(其 detect 或 exts 任一命中即赢),低 priority 的 detect 不会越过高 priority 的 exts 匹配。`exts: []` + `detect` 的组合是"纯嗅探":无 head 时不认领任何文件(不会吞掉图片/PDF 等真实 viewer 的文件),有 head 时只认领 detect 命中的。全部 miss 返回 `undefined`(编辑器显示下载按钮)。
> **head 字节从哪来**:第一次匹配(纯扩展名)没有 head。`fsRead` 策略读取后若文件为二进制,host 的 `fs.read` 响应会带 `head` 字段(base64,前 4KB,`src/index.ts` 的 `READ_HEAD_LIMIT`),编辑器会用它对 `detect` viewer **重匹配一次**——所以 detect 型 viewer 的实际触发场景是"扩展名匹配落空/二进制文件"。文本文件的 detect 嗅探不在内置流程内(用 `exts` 或 `custom` 策略替代)。
> **内置 viewer**(不可重复注册,全部 6 个):image(0) / pdf(0) / markdown(0, fsRead) / html(0, fsRead, 沙箱 iframe 预览) / code(-100, catch-all, fsRead) / binary-download(-50, exts doc/xls/ppt + NUL detect)。Office 三件套预览(.docx/.xlsx/.pptx)**不再内置**——已迁至推荐插件(设置页「添加插件」→ 文件预览弹窗里的 Office 预览插件)。
> code 是兜底 viewer:任何其他 viewer 未认领的文件都会落到 code(CodeMirror 文本编辑);二进制文件经 head 重匹配被 binary-download 的 NUL detect 认领(下载按钮)。外部 viewer 注册同扩展名 + 更高 priority 即可覆盖。
### 5.5 注册示例
**CSV 预览器**(自定义加载 + 渲染):
```ts
ctx.effect(() =>
ctx.betterSidebar.registerFileViewer({
id: 'my-plugin:csv',
exts: ['csv'],
fetchStrategy: 'custom',
load: async (path, scope) => {
const text = await fetchText(scope, path)
return parseCsv(text)
},
component: ({ customData, path }) => <CsvGrid rows={customData as string[][]} path={path} />,
})
)
```
**覆盖内置 image viewer**(如自定义 SVG 优化渲染):
```ts
ctx.effect(() =>
ctx.betterSidebar.registerFileViewer({
id: 'my-plugin:svg-pro',
exts: ['svg'],
priority: 10, // 高于内置 image 的 0
fetchStrategy: 'mediaUrl',
component: ({ mediaUrl }) => <OptimizedSvg src={mediaUrl} />,
})
)
```
**内容嗅探**(按 magic bytes 路由,忽略扩展名):
```ts
ctx.effect(() =>
ctx.betterSidebar.registerFileViewer({
id: 'my-plugin:magic-parquet',
exts: [], // catch-all,但 priority 高 + detect 精确命中
priority: 100,
fetchStrategy: 'custom',
detect: (_path, head) => head.length >= 4
&& head[0] === 0x50 && head[1] === 0x41
&& head[2] === 0x52 && head[3] === 0x31, // 'PAR1'
load: async (path, scope) => parseParquet(await fetchBytes(scope, path)),
component: ({ customData }) => <ParquetTable data={customData} />,
})
)
```
---
## 6. 页面内如何访问数据(/sidebar API)
你的 tab / viewer 组件运行在浏览器里,与内置视图同源同权。访问文件/会话数据直接 `fetch` better-sidebar 的 JSON API(内置 `src/client/api.ts` 的封装就是干这个的,你可以在自己插件里复制这个 fetch 模式):
```ts
// POST /sidebar/api/<method>,body 带 sessionId + cwd(可选)
const res = await fetch('/sidebar/api/fs.read', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sessionId: scope.sessionId, path }),
})
const { value } = await res.json() // 错误时 { ok: false, error: { code, message } }
```
常用方法(完整清单见 `src/client/api.ts`):
| 方法 | 说明 |
|---|---|
| `session.cwd` | 会话权威 cwd(`{ cwd, root, parent }`) |
| `fs.tree` | 目录列表(`{ path, entries: FsEntry[], truncated }`;FsEntry 含 `isSymlink`/`broken`,目录软链接的 `isDir` 按目标类型) |
| `fs.read` | 读文件:文本返回 `{ kind: 'text', content, truncated }`;二进制返回 `{ kind: 'binary', size, truncated, head }`(head = base64 前 4KB) |
| `fs.write` | 原子写文件 |
| `git.status` / `git.diff` / `git.log` 等 | 全套 Git 只读 + 写操作 |
| `pty.close` / `agent-pty.close` | 释放终端(外部 tab 一般用不到) |
| `settings.get` / `settings.update` | 侧边栏偏好读写(revision 守卫) |
媒体/下载字节走 `/sidebar/file` 路由(`?sessionId=&path=&cwd=&download=1`):
```ts
// 媒体 URL(图片等直接 <img src>):/sidebar/file?sessionId=...&path=...
const url = `/sidebar/file?${new URLSearchParams({ sessionId: scope.sessionId, path })}`
```
> 注:内置的 `api.ts` 是 better-sidebar 内部模块,外部插件 **不要** value-import 它(构建纯度门会挡);按上表模式自己 fetch 即可。所有路由带与 `/api` 相同的 Host 头信任围栏,浏览器同源访问天然通过。
---
## 7. 服务方法完整清单
```ts
interface BetterSidebarService {
/** 注册 tab 类型;返回 disposer */
registerTab(descriptor: TabDescriptor): () => void
/** 注册文件预览器;返回 disposer */
registerFileViewer(descriptor: FileViewerDescriptor): () => void
/** 当前已注册的 tab 描述符快照(同步,供 useSyncExternalStore 用;含被设置页禁用的类型) */
getTabs(): readonly TabDescriptor[]
/** 当前已注册的 file viewer 描述符快照(含被设置页禁用的 viewer) */
getFileViewers(): readonly FileViewerDescriptor[]
/** 按 id 查 tab 描述符 */
getTab(id: string): TabDescriptor | undefined
/** 某个 tab 类型是否在 Side card 设置中启用(v0.4.1+;缺省 = 启用) */
isTabEnabled(id: string): boolean
/** 某个 file viewer 是否在 Side card 设置中启用(v0.4.1+;缺省 = 启用) */
isViewerEnabled(id: string): boolean
/** 按 path 匹配 file viewer(priority 降序单趟:detect → exts;跳过硬禁用 viewer) */
matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined
/**
* 打开一个 tab(+ 菜单和外部触发都用它;走 descriptor.dedupeKey 去重)。
* title 可选:给出时优先于 descriptor.title(editor 显示文件名);
* 有 createTab 的 descriptor(terminal)会忽略 title/path/id。
* url 可选:把**新建** tab 的 path 预填为 URL(侧边栏浏览器导航种子);
* 聚焦既有 tab 时 url 不会覆写其 path。
* 被设置禁用的类型是 no-op(console.warn 提示)。
* scope(v0.12.0+)定向到指定 session:给出且非当前 session 时,打开落在
* 该 session 的侧边栏状态里(没有则按 prefs 新建),不切换 UI 的激活 session;
* 定向打开不自动展开目标 session 的面板;缺省或指向当前 session 时行为
* 与之前完全一致。注意:available 不拦截 openTab。
* 内容型打开(带 path/url seed)自动展开承载面板,保证落点可见。
*/
openTab(seed: OpenTabSeed, scope?: SessionScope): void
/** 关闭一个 tab(未知 id 严格 no-op,无状态搅动);scope(v0.12.0+)
* 随回调传递(含可选 cwd),缺省为 { sessionId: 当前 } */
closeTab(tabId: string, scope?: SessionScope): void
/** 订阅注册表变化(register/dispose 时触发) */
subscribe(listener: () => void): () => void
// ── v0.12.0+ ──────────────────────────────────────────────────────────
/** 插件版本(如 '0.12.0';与 package.json 同步,测试守护) */
readonly version: string
/** 单调能力清单(只增不删):'badge' | 'tabLifecycle' | 'updateTab' |
* 'openFile' | 'targetedOpen' | 'stateSubscription' | 'tabMeta' |
* 'pluginSettings'——用 `features.includes('xxx')` 按能力 gate。 */
readonly features: readonly string[]
/** 当前快照:激活 sessionId + 其状态(面板几何/打开的 tabs/展开集)+ prefs。
* session 未激活时 state/sessionId 为 undefined。 */
getSnapshot(): SidebarSnapshot
/** 订阅快照变化(会话切换/状态变更/prefs 写入);返回 disposer */
subscribeState(listener: () => void): () => void
/** 更新一个已打开 tab 的显示字段(title/path/meta);tab 不存在时 no-op */
updateTab(tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void
/** 激活一个已打开的 tab(tab 栏点击路径;触发 descriptor.onActivate;
* 未知 id 严格 no-op);scope(v0.12.0+)随回调传递,同 closeTab */
activateTab(tabId: string, scope?: SessionScope): void
/** 在 scope.sessionId 的侧边栏编辑器打开一个文件(title 缺省为文件名;
* id 按路径派生,与内置 open-path 拦截一致,不同文件可并排打开) */
openFile(scope: SessionScope, path: string, title?: string): void
}
/** openTab 的 seed(v0.12.0 起导出命名类型)。 */
interface OpenTabSeed {
type: string
title?: string
path?: string
diff?: SidebarTab['diff']
id?: string
url?: string
/** JSON 可序列化的自定义状态,随 tab 持久化(刷新后原样恢复) */
meta?: unknown
}
```
**版本与能力探测**(v0.12.0+):消费插件先查能力再使用新 API,老版本(或旧 DSH)下优雅降级:
```ts
if (ctx.betterSidebar.features.includes('badge')) {
// 使用 TabDescriptor.badge
}
if (ctx.betterSidebar.version >= '0.12.0') { /* 字符串比较即可:minor 只增 */ }
```
**生命周期示例**(v0.12.0+):打开时启动资源、关闭时释放——组件卸载 ≠ tab 关闭(会话切换也会卸载),所以释放资源要用 `onClose`:
```ts
ctx.effect(() =>
ctx.betterSidebar.registerTab({
id: 'my-plugin:db',
title: 'Database',
single: true,
badge: (_ctx, _scope, state) => /* 比如打开的连接数,每次 tab 栏渲染调用,保持廉价 */,
onOpen: (tab, scope) => { startWatcher(scope.sessionId) },
onClose: (tab, scope) => { stopWatcher(scope.sessionId) },
component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
})
)
```
---
## 8. 声明式设置(v0.4.1+)
每个注册的 tab / viewer **自动**出现在 DSH 设置页「侧边卡片」分区(`SideCardSection` 按注册表驱动渲染,无硬编码):
- 展示:小卡片网格(图标 + 标题 + 类型 id),**高亮 = 启用**,勾选徽标钉在卡片最右端;viewer 卡片额外显示扩展名。
- 持久化:开关写入 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,**缺省 = 启用**,显式 `false` 才禁用)。
- 关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开(`console.warn`)、派生流程(子代理自动展开、agent 终端自动补 tab)停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。
- `settings.toggles`(可选):在卡片行下追加**嵌套设置行**(仅父级启用时显示),绑定 `SidebarPrefs` 字段;通过卡片右下角齿轮按钮在原生弹窗中编辑。行控件 v0.11.0 起不限于布尔:`type: 'switch' | 'text' | 'number'`(缺省 'switch';text/number 行 blur/Enter 提交,number 行按 min/max 钳制,unit 渲染单位后缀)。内置示例:subagent tab 的 `autoOpenSubagent`、terminal tab 的 `agentTerminalTools` + 自定义字体行。
- `settings.pluginToggles`(可选,v0.12.0+):**插件自有设置行**,行控件与 toggles 相同,但 key 是插件局部的——持久化在 prefs 文档的 `pluginSettings[<descriptor id>]`(开放 map,无需宿主 schema 字段)。tab 与 viewer 都可用(v0.12.0 起 viewer 卡片也有齿轮按钮)。
- `settings.render`(可选,v0.12.0+):**自定义设置面板**——给出时齿轮弹窗渲染它而非行列表。props 含 store/service/prefs、本 descriptor 的 `pluginSettings` blob、`updatePluginSetting(key, value)` 与 `close()`;抛错会被吞掉并显示内联错误。
```ts
ctx.effect(() =>
ctx.betterSidebar.registerTab({
id: 'my-plugin:db',
title: 'Database',
order: 50,
settings: {
// 宿主 prefs 字段行:key 必须是宿主 PrefsSchema 的字段(仅此限制)
toggles: [{
key: 'autoOpenSubagent', // 宿主内置键
title: 'Auto-open',
desc: 'Open when a subagent appears',
}],
// 插件自有设置行:key 插件局部,持久化在 pluginSettings['my-plugin:db']
pluginToggles: [{
key: 'pageSize',
title: 'Page size',
type: 'number',
min: 1,
max: 100,
unit: 'rows',
}],
// 或完全自定义面板(给定时代替行列表)
render: ({ store, service, prefs, pluginSettings, updatePluginSetting, close }) => (
<MySettingsPanel
values={pluginSettings}
onChange={(key, value) => { updatePluginSetting(key, value) }}
onDone={close}
/>
),
},
component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
})
)
```
> ⚠️ **`toggles` 的 key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `terminalFontFamily` / `terminalFontSize` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks`)。**v0.12.0 起设置 seam 已开放**:你自己的设置走 `pluginToggles`(声明式行)或 `render`(自定义面板),值持久化在 `pluginSettings[id]`——不再需要宿主 schema 字段,也不再被 seam 丢弃。值须 JSON 可序列化(行控件只产出 string/number/boolean;自定义面板自行负责)。
---
## 9. 生命周期与 HMR
- **disposer 必须返回并被 fiber 持有**:`registerTab` / `registerFileViewer` 返回 `() => void`,Cordis fiber 卸载时自动调用。**务必**用 `ctx.effect(() => register(...))` 包裹,否则 fiber 卸载(HMR / 插件禁用)时不会撤销注册,导致下次激活时 `"already registered"` 错误。
- **注册时机**:better-sidebar 在 `apply()` 开头 `ctx.provide('betterSidebar', service)`,你的 `inject = ['betterSidebar']` 保证你激活时服务已就绪;注册顺序无关紧要。
- **持久化降级**:localStorage 里持久化的 tab 若其 type 未注册(你的插件未加载),渲染为 `<OrphanedTab/>` 占位卡("插件未加载" + 关闭按钮);你的插件加载后下次渲染自动恢复(`sanitizeNode` 保留未注册类型而非丢弃)。
- **`visible` 语义**:面板折叠或非激活 tab 的 `visible` 为 false;你的页面应借此暂停轮询/订阅,激活时恢复。
---
## 10. 平台约束与陷阱
| 陷阱 | 说明 |
|---|---|
| **构建纯度门** | client bundle 禁止 value-import `@dsh-external/*` 或非白名单的 `@deepseek-ai/*`(`tsdown.config.ts` 的 `dsh-client-bundle-purity` 插件);**类型 `import type {}` 会被擦除,不触发门禁**——类型可自由共享,运行时符号不行。所有跨插件交互走 `ctx.betterSidebar` 方法调用 |
| **双 cordis 实例** | 外部插件解析不到 DSH monorepo 的 cordis augmentation;better-sidebar 自己重述了 `interface Context { betterSidebar: ... }`(`src/context-types.ts`),你 `import type {} from 'dsh-better-sidebar'` 即拿到类型 |
| **ModuleLoader 不跨插件** | 运行时 `require()` 虽支持跨 bundle,但被构建门挡;所有交互走 `ctx.betterSidebar` 方法调用 |
| **host 半无此服务** | `ctx.betterSidebar` 只在 client 侧存在;host 半需要 better-sidebar 数据走 `/sidebar/api/*` HTTP 路由 |
| **portal 限制** | 整面板 slot 由 ui-layout 独占,外部 tab 只能进入 better-sidebar 的 portal 内部,无法全屏替换整个面板 |
| **id 冲突** | `registerTab` / `registerFileViewer` 对重复 id 抛错;建议用包前缀(`my-plugin:xxx`) |
| **不要 value-import `dsh-better-sidebar`** | 即使是 `./client/service` 子路径,运行时落点也是 client bundle;只做 type-only import |
| **client 声明图零 Node 依赖(v0.12.0+)** | `dsh-better-sidebar/client/service` 的可达声明面(含 `Context`)不引用 `node:*` / `Buffer`——纯浏览器侧插件无需 `@types/node`,`skipLibCheck: false` 也能编译(`scripts/check-consumer-types.sh` 守护;主入口含宿主声明,宿主消费者本就处于 Node 环境) |
---
## 11. 完整最小示例
假设插件 `my-plugin` 要加一个 "Database 浏览器" tab + `.csv` 文件预览器。
**`my-plugin/package.json`**:
```jsonc
{
"name": "my-plugin",
"version": "0.1.0",
"main": "lib/index.js",
"exports": {
".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" },
"./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }
},
"peerDependencies": {
"cordis": "^4.0.0-rc.7",
"dsh-better-sidebar": "workspace:*",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"react": "^18.2.0"
},
"peerDependenciesMeta": {
"dsh-better-sidebar": { "optional": true }
}
}
```
**`my-plugin/src/client/index.tsx`**:
```tsx
import { createElement } from 'react'
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
import type { Context } from 'cordis'
export const inject = ['betterSidebar']
export function apply(ctx: Context): void {
// Database tab(单实例,+ 菜单可见)
ctx.effect(() =>
ctx.betterSidebar.registerTab({
id: 'my-plugin:db',
title: () => 'Database',
order: 50,
dedupeKey: () => 'my-plugin:db',
component: ({ scope }) => createElement(DbView, { sessionId: scope.sessionId }),
})
)
// CSV viewer(custom 策略:自己拉字节 + 解析)
ctx.effect(() =>
ctx.betterSidebar.registerFileViewer({
id: 'my-plugin:csv',
exts: ['csv'],
fetchStrategy: 'custom',
load: async (path, scope) => {
const res = await fetch('/sidebar/api/fs.read', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sessionId: scope.sessionId, path }),
})
const { value } = await res.json()
return parseCsv(value.content)
},
component: ({ customData, path }) =>
createElement(CsvGrid, { rows: customData as string[][], path }),
})
)
}
function DbView(props: { sessionId: string }): React.ReactNode { /* ... */ }
function CsvGrid(props: { rows: string[][]; path: string }): React.ReactNode { /* ... */ }
function parseCsv(text: string): string[][] { /* ... */ }
```
**注册到 profile**:
1. `~/.dsh/profiles/web/package.json` 的 `dependencies` 加 `"my-plugin": "link:<你的插件路径>"`;
2. `~/.dsh/profiles/web/cordis.patch.yml` 追加挂载行(`- insert: - id: my-plugin / name: 'my-plugin'`);
3. 在 profile 目录 `pnpm install`;
4. 浏览器硬刷新(Cmd/Ctrl+Shift+R)即可看到效果(DSH 对 client 改动热加载,无需重启 `dsh web`;仅 host 半改动需要重启)。
---
## 12. 参考实现与调试
better-sidebar 的内置 tab 和 viewer 就是参考实现("吃狗粮"):
- **`src/client/builtins/`**:7 个内置 tab(tabs.tsx)+ 6 个内置 viewer(viewers.tsx)的注册代码 + 聚合与 disposer 生命周期(index.ts)
- **`src/client/service.ts`**:`BetterSidebarService` 接口 + `createBetterSidebarService` 工厂实现(含匹配算法、dedupe、createTab、启用态 gating)
- **`src/client/Sidebar.tsx`**:`TabContent` 分发(查 `getTab` → 调 descriptor.component;未注册 → `<OrphanedTab/>`)、`+` 菜单构建(order 排序 + available disabled + 禁用过滤)
- **`src/client/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + `settings.toggles` 嵌套开关 + 开关持久化)
- **`src/client/api.ts`**:`/sidebar` API 的封装(复制其 fetch 模式到你的插件)
- **`tests/service.spec.ts`**:注册表生命周期 / 匹配算法 / dedupe / createTab / 启用态 gating 测试
- **`tests/builtins.spec.ts`**:内置注册清单断言(7 tab + 9 viewer + 声明式元数据)
- **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`**:设计文档(§17 含实施偏差记录,以现状为准)
调试时直接读这些文件即可看到所有 API 的真实用法。
---
## 11. 真实接入案例
第一个通过 `ctx.betterSidebar` 接入的三方插件:[dsh-sentinel](https://github.com/fuhefei/dsh-sentinel) —— 条件驱动的 agent 唤醒系统(文件/进程/端口/HTTP/命令/webhook 传感器,条件达成自动唤醒休眠会话)。
- **接入方式**:可选软依赖——client half 本地重述最小服务契约(`registerTab`),未安装 better-sidebar 时注册静默跳过,插件原有表面不受影响;
- **注册内容**:`dsh-sentinel:watches` tab(order 60,单实例):全服务器监控表 + 最近触发历史;
- **类型处理**:未 value-import `dsh-better-sidebar`,构建零耦合;与 §2 的 `import type {}` 方案可互换;
- **实测**:v0.3.0 起,真实 web profile 验证通过。
通过 `ctx.betterSidebar` 的三方插件[dsh-sidebar-qa](https://github.com/ChenRuoT/dsh-sidebar-qa) —— 基于 better-sidebar 的划选提问。tab分页: 对话划选 → 右侧面板提问 → 同工作区独立追问会话(❓追问·主题):快速无思考模型压缩主对话上下文后与引文一起注入,不打断主对话;追问可嵌套、可继续、可归档
更多插件接入后欢迎在此登记(一句话 + 链接)。
@@ -260,7 +260,9 @@ if (officeKind === 'docx' || officeKind === 'xlsx') {
- `xlsx`(SheetJS,~400KB,xlsx 导入)
- `@univerjs/core` + `@univerjs/sheets` + `@univerjs/sheets-ui` + `@univerjs/sheets-formula` + `@univerjs/sheets-conditional-formatting` + `@univerjs/sheets-chart` + `@univerjs/docs-ui` + `@univerjs/design` + `@univerjs/engine-render`(~5MB 合计)
**不放 `peerDependencies`**:这些是插件自有运行时依赖,不由 web profile 提供(对比现有 `node-pty`/`xterm`/`CodeMirror` 也在 `dependencies`)。
**不放 `peerDependencies`**:这些是插件自有运行时依赖,不由 web profile 提供(对比现有 `node-pty`/`CodeMirror` 也在 `dependencies`)。
> **实施偏差记录(#122,2026-08)**:上文"`xterm` 也在 `dependencies`"已过时——`xterm` 与 `@xterm/addon-fit` 自 v0.13.0 起从 `dependencies` 迁至 `devDependencies`:二者始终内联进 `lib/client-terminal.js`(tsdown `noExternal` 全内联 + CSS 构建期解析),运行时零依赖;同时代码与开发依赖已从已弃用的 `xterm` 迁移到官方新包名 `@xterm/xterm@^5.5.0`(addon-fit@0.10.0 的 peer 约束锁 `^5.0.0`,不用 6.x),消除用户安装时的 deprecated 与 missing-peer 警告。Office 预览若落地,其依赖按同一原则处理。
### 5.2 tsdown.config.ts 调整
@@ -135,3 +135,14 @@ isViewerEnabled(id: string): boolean
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 直测。
---
## 8. v0.12.0 设置 seam 开放记录(2026-08-14,feat/plugin-api-v012)
设计正文「key 必须是宿主 PrefsSchema 的字段」的限制在 v0.12.0 被打开(纯增量,`toggles` 的宿主 key 语义不变):
- **持久化**:`SidebarPrefs` 增加 `pluginSettings: Record<string, Record<string, unknown>>`(开放嵌套 map,schema `z.dict(z.dict(z.any())).default({})`,按 descriptor id 分桶)——外部插件的自有设置不再需要宿主 schema 字段、不再被 settings seam 丢弃。`parsePrefs` 增加 `pluginSettingsMapOf` 校验(非对象整体回退 `{}`)。
- **声明**:`SidebarSettingsDeclaration` 增加 `pluginToggles`(声明式行,控件同 toggles 的 switch/text/number,值须 JSON 可序列化)与 `render?: (props: SidebarSettingsRenderProps) => ReactNode`(自定义面板:store/service/prefs/pluginSettings/updatePluginSetting/close;抛错被吞并显示内联错误)。
- **渲染**:齿轮弹窗体抽为 `SettingsBody`(`render` 存在时优先,否则 toggles 行 + pluginToggles 行,后者把 pluginSettings 投影到 prefs 面复用 `FeatureSettingsRows`);`settingsFor` 状态从 `TabDescriptor | null` 扩为 `TabDescriptor | FileViewerDescriptor | null`——viewer 卡片 v0.12.0 起同样有齿轮设置弹窗(§4.6 原仅 tab)。
- **向后兼容**:旧设置文档无 `pluginSettings` 字段 → schema default `{}`;`toggles` 行为与 v0.11.0 完全一致。
@@ -0,0 +1,137 @@
# 内置 HTML 预览器 + 内嵌网页浏览器(沙箱化)设计
**日期**:2026-08-11
**状态**:已批准,已实施
**作者**:opencode + 用户
**当前版本**:v0.4.3(main `35cde82`)
**目标版本**:v0.5.0
## 1. 目标
在 `dsh-better-sidebar` 内按既有 builtins 模式新增两个内置能力:
1. **`html` 文件预览器**:`.html`/`.htm` 文件在侧边栏编辑器中默认以真实渲染预览(相对资源可加载),可切换代码编辑(CodeMirror HTML 高亮 + 保存)。
2. **`browser` 浏览器 tab**:`+` 菜单新增「浏览器」页面,地址栏输入 URL 在沙箱 iframe 中浏览,支持后退/前进/刷新,URL 与标题按 tab 持久化。
安全问题为第一优先级(用户显式要求"注意安全问题"):所有渲染走**不透明源沙箱 iframe**,host 侧新增 `/sidebar/html` 路由带 CSP + 信任围栏 + cwd 边界。另按用户追加要求提供**关闭沙箱的子设置**(默认关闭该设置;开启需明确勾选,设置项与界面都带安全警告文案)。
## 2. 复用门(dsh-reuse-first)
`NO EXISTING CAPABILITY`:已搜索 DSH checkout(docs/*-catalog、apps/web、packages 源码——sidecar/iframe/webview/html preview/CSP 均无等价物;`WorkspaceBrowser` 只是目录树)与 dsh-external org hub 目录(browser/html/webview/iframe/sidecar/viewer/preview 均 0 命中)。全新实现,落点为本仓库 builtins(用户已确认)。
## 3. 威胁模型与安全基线
| 威胁 | 对策 |
|---|---|
| 恶意 HTML/网页读取 GUI 数据(localStorage、`/sidebar/api`、同源 cookie) | iframe 一律 `sandbox` 且**不含 `allow-same-origin`** → 文档源为不透明源(null)。**禁止 srcdoc**(srcdoc 在无沙箱时继承父源),一律 route-src |
| 预览/浏览内容劫持 GUI 页面(top 导航) | 不含 `allow-top-navigation` / `allow-top-navigation-by-user-activation` |
| 表单 CSRF 打到本地服务 | html 预览器 iframe **不含 `allow-forms`**;浏览器 tab 含 `allow-forms`(登录流必需,残余风险见 §9) |
| 浏览器 tab 访问内部地址(GUI 自身、localhost、127.0.0.0/8) | 地址栏导航前客户端阻断(`browser.ts` 纯函数);`javascript:`/`data:`/`file:` 等非 http(s) 一律拒绝 |
| 预览页脚本请求 `/sidebar/api` | 三层:不透明源 fetch 被 CORS 拒读;信任围栏拒 `sec-fetch-site: cross-site`;`Origin: null` 无法伪造同源 |
| 敏感信息泄露给被浏览站点 | iframe `referrerPolicy="no-referrer"`;权限策略 `allow=""`(拒绝 camera/mic/geolocation 等一切策略特性) |
| 直接顶层打开 `/sidebar/html/*` URL(弹窗等) | 路由响应带 `Content-Security-Policy: sandbox ...; object-src 'none'`,顶层同样被沙箱化 |
| 路径逃逸 | 路由复用 `requireAbsolute` + `isWithin(cwd, path)` + `mediaLimit` 上限 + 信任围栏 |
| 关闭沙箱(用户主动选择) | 每功能独立子设置(`htmlViewerNoSandbox` / `browserNoSandbox`,默认 false);设置文案 + 渲染时持久警告条双警告 |
## 4. 变更清单
### 4.1 host:`/sidebar/html` 路由(src/html-route.ts 新 + src/index.ts)
URL 采用**路径编码**(无 query)——WHATSG URL 解析对纯路径相对引用会丢弃 base 的 query,query 式 URL 下 `./style.css` 会丢失 session 作用域:
```
/sidebar/html/<sessionId>/<绝对路径逐段 encodeURIComponent>
例:/sidebar/html/S/Users/me/proj/index.html
./style.css → /sidebar/html/S/Users/me/proj/style.css ✓
Windows: C:\Users\me\a.html → /sidebar/html/S/C%3A/Users/me/a.html
```
- `src/html-route.ts`(无依赖纯模块,client 可导入不触发纯度门):`encodeHtmlUrl(sessionId, path)`、`decodeHtmlUrl(pathname)`(404 错前缀;400 空路径/双斜杠/畸形编码/缺 sessionId 或文件路径/空段)。
- `src/index.ts`:注册 `/sidebar/html` prefix 路由(fence → GET 校验 → decode → `requireAbsolute` → `isWithin(cwd)` → stat isFile + `mediaLimit` → 读文件),响应头:
`content-type`(新增 `.html`/`.htm` → `text/html` 到 MEDIA_TYPES)、`cache-control: no-cache`、`X-Content-Type-Options: nosniff`、`Referrer-Policy: no-referrer`、`Content-Security-Policy: sandbox allow-scripts allow-popups allow-downloads allow-modals; object-src 'none'`。
- cwd 语义与媒体路由一致:session store 权威;会话附着前的首请求回退 `process.cwd()`,通常被 `isWithin` 拒绝(可接受)。
### 4.2 client:`html` 预览器(TextEditor.tsx 扩展 + builtins/viewers.tsx)
- descriptor:`{ id:'html', exts:['html','htm'], priority:0, fetchStrategy:'fsRead', settings.toggles:[htmlViewerNoSandbox] }`。
- TextEditor:`markdown || html` 都显示预览/编辑切换;html 预览分支渲染:
`<iframe src={htmlUrl(scope,path)} sandbox="allow-scripts allow-popups allow-downloads allow-modals" referrerPolicy="no-referrer" allow="" />`
- 导出 `HTML_IFRAME_SANDBOX` 常量(测试钉死);沙箱关闭时 `sandbox` 属性整体移除 + 渲染 `sandboxWarning` 警告条。
- 预览显示**已保存文件**(route-src),不反映未保存草稿(有意取舍,文档化)。
### 4.3 client:`browser` tab(BrowserView.tsx 新 + browser.ts 新 + builtins/tabs.tsx + state.ts)
- state.ts:`SidebarState.nextBrowser`(默认 1;sanitize 对缺失/畸形值回退 1,旧持久化布局继续加载);新 reducer `patchTab(state, tabId, {title?, path?})`。
- tabs.tsx:`{ id:'browser', order:50, settings.toggles:[browserNoSandbox], createTab: 铸造 browser:<n> + nextBrowser 递增 }`。
- browser.ts(无依赖纯模块):`isLoopbackHostname`(localhost/::1/0.0.0.0/127/8);`normalizeBrowserUrl(input, selfOrigin)`:
- 无 scheme → 补 `https://`;`://` 完整 URL 走解析后协议兜底;已知危险 scheme 前缀(javascript/data/file/about/...)直接拒绝;`host:port` 形态(如 `example.com:8080`)不被误判为 scheme。
- 拒绝顺序:协议 → 自身 origin **放行**(GUI 自身可在侧边栏打开,沙箱不透明源兜底;须先于 loopback 判定,因其主机通常就是 loopback)→ loopback 阻止 → ok。
- BrowserView:地址栏(后退/前进/刷新/输入/前往)+ iframe + 状态行;URL 经 `patchTab` 持久化到 `tab.path`/`tab.title`(hostname);历史栈仅记录地址栏导航(iframe 内部跳转跨域不可见,已知限制);无 URL 时显示起始页(不自动加载任何站点)。
- iframe:`sandbox="allow-scripts allow-forms allow-popups allow-downloads allow-modals allow-popups-to-escape-sandbox"`(导出 `BROWSER_IFRAME_SANDBOX`;无 allow-same-origin / allow-top-navigation;allow-popups-to-escape-sandbox 供 OAuth 弹窗);沙箱关闭时移除属性 + 警告条。
- 常驻底部小字沙箱说明(`browserHint`)。
### 4.4 设置(prefs-shared.ts + config.ts + SideCardSection 声明式渲染)
- `SidebarPrefs` 新增 `htmlViewerNoSandbox` / `browserNoSandbox`(boolean,默认 false);`SIDEBAR_PREFS_DEFAULTS` 与 `PrefsSchema` 同步;`parsePrefs` 按字段校验回退。
- 声明式设置:html viewer 行与 browser tab 行各自 `settings.toggles`,设置页自动渲染(父级关闭时隐藏),文案含「(不安全)」+ 后果说明。
### 4.5 其他
- `api.ts`:`htmlUrl(scope, path)` = `encodeHtmlUrl(sessionId, path)`。
- `icons.tsx`:`IconHtmlOutline16`(文件 + `‹/›`)、`IconGlobeOutline16`(地球)。
- `locales.ts`:zh/en 双份新键(viewerHtml/browser/浏览器栏/阻止原因/沙箱提示/设置文案)。
- `sidebar.module.css`:`sandboxWarning`、`editorHtml`、`.browser*` 系列。
## 5. 测试
| 文件 | 覆盖 |
|---|---|
| tests/html-route.spec.ts(新) | 编码/解码往返(POSIX/Windows/特殊字符)、相对解析保持同路由同 session、404/400 拒绝、遍历段留给 isWithin |
| tests/browser.spec.ts(新) | 补 https、host:port 不误判、危险 scheme/loopback 拒绝、自身 origin 放行、invalid |
| tests/sandbox-views.spec.tsx(新) | iframe sandbox 常量钉死(无 allow-same-origin/top-navigation)、route-src 非 srcdoc、referrer/allow 属性、沙箱关闭 → 无 sandbox 属性 + 警告条、markdown 分支回归、浏览器起始页不自动加载 |
| tests/builtins.spec.ts | 7 tab / 9 viewer 清单、html 扩展名匹配、browser createTab 铸造与 nextBrowser、两个 toggles 声明 |
| tests/unit.spec.ts | nextBrowser sanitize(缺失/畸形回退 1)、patchTab 更新/无操作、parsePrefs 新字段默认与校验 |
| tests/smoke.spec.ts | fenced routes 清单 + `/sidebar/html` |
## 6. 版本与文档
- 版本 0.4.3 → 0.5.0(package.json + dsh.plugin.json 同步,manifest-consistency 守卫)。
- README/AGENTS 内置清单更新为 7 tab + 9 viewer。
## 7. 验收标准
1. `pnpm typecheck && pnpm build && pnpm test` 全绿(245 用例)。
2. 手动:打开 `.html` 默认预览渲染(相对资源可加载),可切编辑/保存;`+` 菜单浏览器 tab 多开、地址栏浏览、后退/前进/刷新、刷新页面恢复 URL 与标题。
3. 安全:预览/浏览 iframe 无 allow-same-origin/top-navigation;`javascript:`/`data:`/`file:`/localhost/127.x 均被阻止并提示(自身 origin 允许);`/sidebar/html` 越界 403、超限 400、跨站 403;预览页 fetch `/sidebar/api` 读不到(CORS),预览器表单被沙箱禁(无 allow-forms)。
4. 关闭沙箱开关:设置页勾选后界面出现持久警告条,iframe 无 sandbox 属性。
## 8. 实施偏差记录
- 设计初期考虑过 srcdoc + `<base>` 注入以实现"预览反映未保存草稿",因 srcdoc 无沙箱时继承父源的致命风险与 base 注入的脆弱性,改采 route-src(预览 = 已保存文件);相对资源经路径编码 URL 天然解析,无需内容改写。
- `normalizeBrowserUrl` 的 scheme 判别:朴素正则会把 `example.com:8080` 误判为 scheme,最终采用「`://` 完整 URL + 危险 scheme 黑名单 + 解析后协议兜底」三段式。
- `nextBrowser` 在 sanitize 中为宽松字段(缺失/畸形回退 1),避免旧持久化布局整体失效(与严格校验的 `nextTerminal` 不同)。
- **UNC 路径编码标记(PR #134 整合 #79/#115 时)**:`/sidebar/html` 对 `\\server\share\...` / `//server/share/...` 采用 sessionId 后的 `//` 标记,且解码统一重建为**前斜杠** `//server/share/...` 而非反斜杠——`node:path` 在 win32/POSIX 上分别解析为 `\\server\share\...` / `/server/share/...`,因此无需任何平台信号;原「400 双斜杠」约束改为「首个空段仅作 UNC 标记,其余空段仍 400」。
## 8.1 追加需求(2026-08-11,v0.5.0 内)
1. **设置页滚动**:`SideCardSection.module.css` 的 `.section` 填满设置壳 options 区并内部滚动(`height:100% + min-height:0 + overflow-y:auto`),7 tab + 9 viewer 卡片再多也不会被裁切。
2. **沙箱状态实时显示 + 临时解锁**:新增 `SandboxStatusBar`(HTML 预览与浏览器 tab 共用)——沙箱开启显示绿色状态 + 「临时解锁(不安全)」按钮;关闭(全局设置或本页临时解锁)显示**红色警示**(`state-error-primary` 色系 + 危险文案)与「恢复沙箱」按钮(仅临时态)。临时解锁是组件内 state,不写回全局设置,随页面/标签卸载失效;iframe 随沙箱翻转重挂载。
3. **聊天/界面外链在侧边栏打开**:新设置 `browserInterceptLinks`(默认开,受浏览器 tab 启用开关 gate)——`link-intercept.ts` 在 document capture 阶段拦截 http(s) **外部**链接点击(同源/非 http(s)/修饰键点击一律放行),经 `openTab({type:'browser', url, title: hostname})`(openTab seed 新增 `url` 字段:落地后 patchTab 预填 path)在侧边栏打开。浏览器 tab 地址栏新增「在浏览器中打开」按钮(`window.open(url,'_blank','noopener')`,用户手势触发,等价真实浏览器新标签)。
## 8.2 追加需求(2026-08-11 二轮,v0.5.0 内)
1. **HTML「默认非安全」二级设置**:`htmlViewerDefaultUnsafe`(默认 false)——打开 HTML 文件时预览**初始**处于非沙箱状态(localUnlock 初始值取自该 pref),状态行仍可一键「恢复沙箱」;与 `htmlViewerNoSandbox`(全局强制关闭,不可界面恢复)语义区分。
2. **沙箱状态行单行化并取代底部说明**:浏览器 tab 底部常驻 `browserHint` 条移除,说明并入 `SandboxStatusBar` 开启态文案(「沙箱模式:已启用 · 页面无法访问界面数据与本地文件,登录态与第三方 Cookie 可能不可用」),状态行 `nowrap + ellipsis + title` 恒为一行;关闭态同样单行红色警示。
3. **「在浏览器中打开」图标放大**:`IconRightUpOutline14` → `IconRightUpOutline16`(15px 渲染)。
## 8.3 追加需求(2026-08-11 三轮,v0.5.0 内)
1. **图标改 15px**:`<IconRightUpOutline16 size={15} />`。
2. **嵌入拒绝面板**:站点通过 `X-Frame-Options` / CSP `frame-ancestors` 禁止嵌入时,浏览器 tab 不再显示空白拒绝框,而是显示 `BrowserEmbedBlocked` 面板——「{host} 拒绝了嵌入请求」+ 原因说明 + 「在浏览器中打开」(`window.open`)+ 「仍然加载」两个按钮。检测:新增 host 路由 `browser.probe`(HEAD→GET 兜底,8s 超时,仅 http(s) 非 loopback,返回目标响应头的 `x-frame-options`/`frame-ancestors`,受信任围栏保护)+ 客户端 `embeddabilityOf` 判定(XFO DENY/SAMEORIGIN 或 frame-ancestors 不含 `*` → blocked;探测失败 → unknown 保持普通 iframe)。探测在每次导航(含恢复的 path)时经 `useEffect(url)` 触发;「仍然加载」以 `forceEmbed` 保留普通 iframe。
## 9. 已知限制与残余风险
- 浏览器沙箱无登录态/第三方 Cookie 受限(SameSite=Lax 在跨站 iframe 不发送),部分站点登录需走弹窗(allow-popups-to-escape-sandbox 已开)。
- iframe 内部链接跳转不进后退栈(跨域不可见);被 `X-Frame-Options`/`frame-ancestors` 拒绝的站点显示空白(站点策略,无法绕过)。
- 用户主动浏览的恶意站点仍可在沙箱内发起盲 GET(无法读取响应)到本地服务;站点重定向链可把 iframe 带到 localhost(内容仍沙箱化、受围栏保护)。
- 关闭沙箱后预览/浏览内容获得 GUI 同源权限(可读会话文件/本地存储/调内部接口)——仅建议对完全可信内容开启,设置与界面双重警告。
@@ -414,3 +414,19 @@ export function apply(ctx: Context): void {
### 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 语义重写。
---
## 18. v0.12.0 基座化扩展记录(2026-08-14,feat/plugin-api-v012)
§5 的服务接口在 v0.12.0 做了**纯增量**扩展(无任何签名删除 / 语义变更,向上兼容),全部记录于此,文档以本节为准:
- **类型导出补齐**:`./client/service` 入口 re-export 公共状态词汇——`SidebarTab` / `SidebarState` / `SidebarStore` / `SidebarSnapshot` / `SidebarDiffRef` / `TabType` / `SessionScope` / `SidebarPrefs` / `OpenTabSeed` / `SidebarSettingsRenderProps`。此前消费者无法命名这些类型(TS2459),只能靠推断。
- **client 声明图零 Node 依赖**:`context-types.ts` 的 `SidebarWebRoute` / `SidebarWebUpgradeRoute` 改用结构镜像类型(`SidebarHttpRequest` / `SidebarHttpResponse` / `SidebarUpgradeSocket` / `SidebarUpgradeHead`),宿主在 ws 边界显式转型(`src/index.ts` 两处)。外部消费插件无需 `@types/node`、`skipLibCheck: false` 也能编译(`scripts/check-consumer-types.sh` 双层校验守护)。
- **新服务方法**:`version` / `features`(单调能力清单,只增不删);`getSnapshot` / `subscribeState`(状态订阅,委托 store);`updateTab(tabId, patch)`(title/path/meta);`activateTab(tabId)`(跨树激活,tab 栏点击路径);`openFile(scope, path, title?)`(editor 打开,id 按路径派生与内置拦截一致);`openTab(seed, scope?)`(定向到指定 session:`SidebarStore.reduceFor` 加载/变更/持久化目标 session 而不切换 snapshot,UI 不扰动;scope 指向当前 session 时走原 reduce 路径保证 notify)。
- **新 descriptor 字段**:`badge`(tab 角标:number 计数 99+ 封顶 / string 文本 / null 不显示,每次 tab 栏渲染调用,抛错吞掉);`onOpen` / `onActivate` / `onClose`(生命周期回调,service 路径派发——dedupe/id 安全网聚焦触发 onActivate 而非 onOpen;内置 diff 拆分与 agent 终端 reconcile 直接改 state 不触发,但只作用于内置类型;回调抛错只 console.error);`SidebarTab.meta`(JSON 可序列化自定义状态,`sanitizeNode` 宽松保留,跨刷新原样恢复)。
- **设置 seam 开放**:`PrefsSchema` 增加 `pluginSettings: z.dict(z.dict(z.any())).default({})` 开放嵌套 map;`settings.pluginToggles`(插件自有声明式行)与 `settings.render`(自定义面板,props 见 §5)替代「自定义 key 被 seam 丢弃」的限制;viewer 卡片 v0.12.0 起同样有齿轮设置弹窗。
- **`FileViewerDescriptor.load` 第三参 `signal?: AbortSignal`**:EditorHost 在卸载/重匹配时 abort(忽略 signal 的 load 照常工作)。
- 测试:`tests/consumer-types.ts`(消费面编译门)、`tests/api-surface.spec.ts`(client 图零 node 依赖守护 + 能力常量)、`tests/service.spec.ts` 新增生命周期 / 定向打开 / updateTab / activateTab / openFile / meta 持久化用例、`tests/unit.spec.ts` 新增 `reduceFor` 与 sanitize meta 用例。
实现偏差:§5.1 的 `openTab(seed)` 单参签名保留为 `openTab(seed, scope?)` 的可选参数(非破坏);`SidebarSnapshot` 类型 re-export 自 `./client/service` 而非新建独立文件。
@@ -0,0 +1,65 @@
# 跟随 DSH 的 i18n(ctx.locale)设计
> 2026-08-12 · 随 v0.8.2 发布
## 背景
better-sidebar 的界面文案(约 260 处 `t()` 调用、28 个文件)此前自带 zh/en 词典并直接读
`navigator.language`(`src/client/locales.ts` 头注释曾明言"renders outside the slot
system's locale seat, so it reads the browser language directly")。问题:
- DSH 的语言偏好是 Host-backed(`locale.preference`,存于 `$DSH_HOME/settings.yaml`),
用户设置与浏览器语言不一致时侧边栏不跟随(如偏好 zh、浏览器 en 时侧边栏仍为英文)。
- 词典游离在 DSH 的 i18n 注册表之外,无法参与公共命名空间回退等机制。
DSH 的 i18n 系统(`@deepseek-ai/dsh-client-locale`):
- `ctx.locale` 服务(client-only):`getSnapshot()/subscribe()` 是 LocaleFace
(uSES 安全,revision 递增),`locale/change` 事件在切换时触发;
- `ctx.locale.register(ns, {zh, en})` 命名空间词典注册(typed 变体要求合并
`LocaleNamespaceMap`);查询链 ns → common → zh → key;
- 所有 DSH 客户端包以 `inject = [..., 'locale']` 消费。
## 设计决策
1. **结构镜像服务**(仓库惯例,`src/context-types.ts` 头注释):本插件解析在 DSH
monorepo 单一 cordis 实例之外,上游 `declare module 'cordis'` augmentation 不达此
Context。新增 `SidebarLocaleService` 结构脸(只镜像用到的切片:
`getSnapshot` / `subscribe` / `register`),merge `Context.locale`。不引入
`@deepseek-ai/dsh-client-locale` 依赖、不做 value import(client bundle 纯度门)。
2. **保留模块级 `t()`**:`t()` 被 28 个文件使用,穿 props 不可行。改为模块级 holder:
`apply()` 调 `attachLocale(ctx.locale)`,`t()`/`isZh()` 从 holder 读活跃 locale;
无 holder 时回退既有 `navigator.language` 检测(行为与改动前完全一致,也是 jsdom
测试与非标准组合的降级路径)。
3. **根级重渲染触发**:侧边栏自行 `createRoot` 挂在 slot 系统之外,拿不到 `t` seat。
在 `Sidebar.tsx` 根组件加 `useSyncExternalStore(ctx.locale.getSnapshot/subscribe)`
(该文件已用此 API 订阅 sessions/store);树内无 `React.memo`,根重渲染即整树
重渲染,所有渲染期 `t()` 求值读到新语言。事件期调用(intercept、api、subagent-tasks)
在调用时刻读当前语言,天然正确。SideCardSection 由 settings shell 渲染,shell 自身
订阅 locale revision(ui-settings-general 的 section rows 缓存含 locale revision),
自动跟随。
4. **命名空间 `'betterSidebar'`**:`'sidebar'` 已被 DSH 的 ui-sidebar 占用。注册走
非类型化单语言重载 `register(ns, locale, dict)`(DSH 内 ui-permission 同款先例);
键型安全由本地 `const en: Record<keyof typeof zh, string>` + 测试键集相等断言保证。
5. **词典注册进 DSH 注册表**:`ctx.effect` 内注册 zh/en 并返回合并 disposer
(fiber 卸载时撤销,HMR 重激活不抛 "already registered")。
6. **xlsx 预览的 Univer locale pack**:`xlsx-view.tsx` 原内联 navigator 检测改走
`isZh()`(跟随 DSH 语言;Univer 实例在挂载时创建,语言切换不重建实例——可接受)。
## 实施偏差记录
- 无。计划与实施一致。
- 版本号随 v0.8.2 发版提交(`package.json` + `dsh.plugin.json`)。
- v0.8.2 后补遗:md 预览(`TextEditor.tsx`)的代码块复制按钮文案——DSH 的
`MarkdownText`/`CodeBlock` 是 cordis-free 组件,未传 `codeLabels` 时回退到硬编码中文
「复制/复制成功」。修复为渲染时传入 `codeLabels={{ copyLabel: t('copy'), copiedLabel: t('copied') }}`
(对齐 DSH 聊天区 `AssistantMarkdown` 的做法),新增 `tests/markdown-copy-labels.spec.tsx`
钉住 zh/en 跟随。
## 验收
- `pnpm typecheck` / `pnpm test` / `pnpm build`(纯度门)全绿;`tests/locales.spec.ts`
覆盖:无服务回退、attach 后跟随活跃 locale 实时切换、detach 恢复回退、占位符插值、
relativeTime 链路、命名空间名、zh/en 键集相等。
- 手动:设置页切换语言 → 侧边栏全部文案实时切换;`locale.preference: zh` 且浏览器 en
→ 侧边栏中文。
@@ -0,0 +1,88 @@
# 客户端懒加载分块(lazy chunks)设计
> 日期:2026-08-12
> 状态:已实现
> 范围:`dsh-better-sidebar` 插件,客户端 bundle 拆分
## 1. 背景与问题
改造前客户端是单文件 bundle(`lib/client.js`,约 24.6MB),页面启动时全部**下载 + 解析 + 执行**。实测构成(按 sourcemap 源字节):
| 依赖 | 体积 | 用途 |
|---|---|---|
| `@univerjs/*` 全家桶 + echarts/zrender 等 | ~18MB | .xlsx 预览 |
| `@codemirror/*` + `@lezer/*` | ~1.9MB | 文本编辑器高亮 |
| `xlsx`(SheetJS) | ~0.6MB | .xlsx 解析 |
| `@aiden0z/pptx-renderer` | ~0.5MB | .pptx 预览 |
| `xterm` | ~0.3MB | 终端 |
| `docx-preview` + `jszip` | ~0.2MB | .docx 预览 |
| 插件自身代码 | ~0.35MB | — |
虽然 `office-view.tsx` 用了动态 `import()`,但 `codeSplitting: false` 只保证**延迟执行**(`Promise.resolve().then(() => (init_docx_preview(), ...))`),下载与解析成本仍在启动时;且 `xlsx-to-univer.ts` 对 `@univerjs/presets` 的静态 value-import 让 Univer 核心实际在启动时执行。
**目标**:重依赖拆成独立脚本,首次打开对应功能才下载/解析/执行;启动只拉核心(~325KB)。
## 2. 机制
chunk 脚本**不经过** `window.__ModuleLoader__` / `ClientModuleSystem.import()` 的物化路径——模块系统的 import() 只解析 seed word / shell-own 模块 / 已注册 factory / boot graph row,chunk id 四者皆非,解析行为依赖 DSH 版本(实测某些版本在 factory 已注册时仍抛 "cannot resolve")。因此改为插件自有的全局 factory 注册表:
1. **构建期**:每个 chunk 是独立 tsdown 浏览器 bundle(`lib/client-<name>.js`),脚本首行为:
```js
globalThis.__dshChunks__ = globalThis.__dshChunks__ || {};
globalThis.__dshChunks__["terminal"] = (require) => { /* CJS 闭包体 */ };
```
与主 bundle 相同的 externals(react 等走 module table)、纯度门、CSS 内联管线;`codeSplitting: false` 保持单文件。
2. **运行期** `loadChunk(name)`:
- 注入 `<script src="/sidebar/bundle/<name>.js">`(经典同源脚本;官方 `/plugins/<id>/client.js` 路由的路径白名单无法服务任意文件名,故走插件自有路由);
- 从 `globalThis.__dshChunks__[name]` 读取 factory(脚本执行即赋值,幂等——重复执行覆盖槽位,无 "duplicate factory registration" 错误类);
- 用**自定义 require** 物化:externals(CLIENT_EXTERNALS 清单)经 `__DSH_MODULES__.import(spec)` 的 **seed 分支**逐一解析(页面级记忆化;解析失败的 spec 标记未解析,仅当 chunk 实际 require 时才报错)——seed 分支是模块系统最稳定、跨版本一致的部分。
## 3. 分块清单
| chunk 名 | 入口 | 内容 | 构建产物 | 触发 |
|---|---|---|---|---|
| `xlsx` | `src/client/chunks/xlsx.tsx` | `XlsxView` + Univer 全家桶 + xlsx + Univer CSS | lib/client-xlsx.js(~20MB) | 打开 .xlsx |
| `docx` | `src/client/chunks/docx.tsx` | `DocxView` + docx-preview + jszip | lib/client-docx.js(~330KB) | 打开 .docx |
| `pptx` | `src/client/chunks/pptx.tsx` | `PptxView` + pptx-renderer | lib/client-pptx.js(~2.4MB) | 打开 .pptx |
| `terminal` | `src/client/chunks/terminal.tsx` | `TerminalView` + xterm + addon-fit + xterm.css | lib/client-terminal.js(~450KB) | 打开终端 tab |
| `editor` | `src/client/chunks/editor.tsx` | `TextEditor` + @codemirror/* + lang.ts + cm-themes | lib/client-editor.js(~1.6MB) | 打开 code/md/html 文件 |
| 核心 | `src/client/index.tsx` | 其余全部 | lib/client.js(~325KB) | 启动 |
关键拆分:**`office-view.tsx` 拆为 `docx-view.tsx` + `xlsx-view.tsx` + `office-shared.tsx`**(共享 `LoadState`/`BinaryFallback`)。若不拆,docx chunk 会经共享文件静态拖入 `xlsx-to-univer` → Univer 14.5MB。原路径 `office-view.tsx` 保留为 re-export 垫片,兼容 `./src/*` 深导入。
## 4. 缓存契约(三层,各带失败路径)
| 层 | 机制 | 失败路径 |
|---|---|---|
| 内存 | `loadChunk` 每 chunk 一个 in-flight promise,并发去重,常驻到 `resetChunks` | 失败删除缓存项 → 下次调用重试 |
| 执行 | 脚本执行即覆盖全局注册表槽位(赋值幂等) | 物化失败清缓存 → 重试重新注入 + 重新执行脚本 |
| HTTP | `/sidebar/bundle` 路由 `cache-control: no-cache` + ETag(内容哈希,按 mtime/size 记忆化),`If-None-Match` 命中返回 304 | 页面刷新/HMR 重激活时 304 免重下载 17MB;文件变化 ETag 轮转返回 200 |
**HMR**:`apply()` 每次激活调用 `resetChunks()`(清内存缓存 + externals 记忆),热更新后重新拉取新代码。chunk 源码单独改动需手动刷新页面(HMR 轮询只盯 client.js)。
## 5. 对外 API 兼容性(硬约束)
- `ctx.betterSidebar` 服务方法签名与行为零变化;懒加载只作用于**内置** descriptor,外部插件注册的原样存储/渲染。
- `component` 契约 `(props) => ReactNode` 保持:懒包装是**纯渲染函数**(函数体无 hooks,hooks 在内部 `LazyChunkView`),兼容 Sidebar 的函数式调用与 EditorHost 的 `createElement` 渲染两种风格。
- 内置 viewer/tab 的 id、exts、priority、detect、icon、title、settings 全部不变 → `matchFileViewer` 语义、外部插件同扩展名高优先级覆盖、设置页清单不变。
- 公开类型面(`./client/service`、`./client/api`)不变;无新增 peerDeps。
- 契约测试:`tests/lazy-chunk.spec.tsx` 钉住"component 可直接函数调用不抛错"。
## 6. 边界与失败模式
- chunk 加载/执行/物化失败 → 视图级错误 + 重试 UI(`css.editorError` + `css.terminalRetry`),面板其余部分不受影响;
- host 旧版本无 `/sidebar/bundle` 路由(404) → 同样的错误 + 重试,升级 host 后重试即可;
- `window.__DSH_MODULES__` 缺失(异常环境) → 明确报错,不触网;
- chunk 注册 id 恒定 `dsh-better-sidebar/<name>`,两渠道共用(每页只活一个渠道——双渠道并存本就双挂载,属既有破损组合);
- 跨 chunk 复用:`t()`/`api.ts`/`SandboxStatusBar`/`clsx`/`sidebar.module.css` 等无状态小模块允许在各 chunk 内联(几十 KB 级重复),避免 chunk→主 bundle require 的脆弱耦合;
- CSS:`sidebar.module.css` 的 tagId 与官方渠道核心一致 → 幂等跳过注入;registry 渠道会多注入一份同内容样式(数据标签差异,无功能影响);xterm.css/Univer.css 随各自 chunk 注入。
## 7. 实施偏差记录
- 原设想 React.lazy + Suspense 被否决:错误处理需 error boundary,重试语义差;改用有状态包装组件(loading/error/retry 三态),与本库"每个视图自带 loading/error/retry"风格一致。
- 原设想 chunk 注册 id 按渠道区分(`__DSH_PLUGIN_ID__` define)被否决:chunk 文件两渠道共用,恒定槽位即可;避免重复构建 6 份产物。
- `resetChunks` 同时清空测试注册表(测试隔离需要)。
- `lazy-chunk.tsx` 内部渲染用 `ComponentType<any>` 状态成员规避 React 18 createElement 泛型重载限制(仅内部一处显式 any)。
- **v2(运行时修复)**:最初实现经 `window.__ModuleLoader__.load({id})` 注册 chunk factory、再 `__DSH_MODULES__.import(id)` 物化——真实环境报 `client-modules: cannot resolve "dsh-better-sidebar/terminal"`(某 DSH 版本的 import() 不解析已注册的非 boot 图 id)。改为全局 factory 注册表(`globalThis.__dshChunks__`) + 自定义 externals require(seed 分支),彻底不依赖模块系统的 factory 解析行为;`resetChunks` 不再需要 invalidate。
- **v3(运行时修复)**:terminal 懒包装把整个 `TabComponentProps` 原样透传,但 `TerminalView` 的 props 是 `{ scope, tabId, store }` 而 `TabComponentProps` 只有 `tab` 对象、没有 `tabId` → `tabId` 为 undefined → `isAgentTabId` 里 `undefined.startsWith` 崩溃(`dsh-better-sidebar: Cannot read properties of undefined (reading 'startsWith')`)。修复:descriptor 显式映射 `tabId={tab.id}`,懒包装的 props 泛型 = TerminalView 真实签名(`{ scope, tabId, store }`);回归测试钉住映射(lazy-chunk.spec.tsx)。**教训:懒包装的 props 泛型必须等于目标组件真实 props,而非描述符 props 全集**。
- **v2 附加**:bundled 产物经真实 `ClientModuleSystem`(带真实 react seed)端到端验证——5 个 chunk 均能物化出真实 React 组件;产物契约由 `tests/chunk-artifact.spec.ts` 长期钉住(脚本执行赋值槽位 + factory 可被 externals require 物化)。
@@ -0,0 +1,77 @@
# 移动端(窄视口 <768px)侧边栏布局设计
**日期**:2026-08-12
**状态**:已实施(本文档含实施偏差记录)
**目标版本**:v0.7.0
## 1. 目标
按**视口宽度**(<768px——"真正的移动端宽度",刻意不对齐宿主 1024 断点)切换移动端侧边栏体验:
1. 移动端**不提供底部面板按钮**(右上角按钮簇只剩一枚右侧面板开关)。
2. **合并显示 = 只显示右侧栏**:进入窄屏时底部面板的标签页**直接并入右侧栏**(一次性状态迁移),右侧栏成为全宽抽屉,底部面板浮层整体不存在。
3. 附带移动端优化:新会话默认收起抽屉、文件/外链打开自动展开、宽度拖动条禁用、布局不挤压(抽屉悬浮)、窄屏标签收窄。
## 2. 非目标
- 不改持久化 schema:`bottomOpen` / `bottomHeight` / `bottomSplits` 字段原样保留。
- 不改桌面行为:≥768px 时双面板布局与之前完全一致(含布局挤压、拐角、底部首展自动终端)。
- 不改公开服务 API、PrefsSchema、host 半。
- 不处理 DSH 详情栏(宿主让步链在 <~996px 自动关闭详情栏,与本改动无交互)。
## 3. 现状回顾
- `Sidebar.tsx` 渲染两个独立浮层:右侧面板(全高、宽度可拖、布局挤压 `--dsh-sidebar-width`)与底部面板(只挤中间列、高度可拖、右上角按钮簇双按钮、共享拐角)。
- 底部面板状态字段:`bottomOpen` / `bottomHeight`(钳制 `[BOTTOM_MIN, innerHeight-PANEL_MIN]`)/ `bottomOpenedOnce` / `bottomSplits`(独立 split 树,pane/tab id 全局唯一)。
- 首次展开底部面板自动开终端(`bottomPanelAutoTerminal` pref)。
## 4. 设计
### 4.1 断点模块(新 `src/client/breakpoints.ts`)
- `NARROW_MAX_WIDTH = 768`;`isNarrowWidth(width)` 纯函数;`useNarrowViewport()` hook(初始读 `window.innerWidth`,resize + rAF 节流,`typeof window` 守卫;不用 matchMedia——jsdom 未实现)。
- CSS 侧配对 `@media (max-width: 767px)`(767 ≡ <768),两端注释互指。
- 刻意**不对齐**宿主 `SIDEBAR_AUTO_COLLAPSE = 1024`:1024px 窗口(小笔记本、分屏)保留桌面双面板;只有手机/竖屏平板才进移动布局。
### 4.2 合并 = 底部标签并入右侧栏(状态迁移)
- **新 reducer `migrateBottomTabs(state)`**(`state.ts`):
- 底部树所有标签按深度优先序**追加到右侧树第一个 leaf**(`firstLeaf(splits)`)的标签条;
- 底部树清空(结构保留——桌面端回显欢迎卡片)、`bottomOpen=false`、`activePane` 指向右侧树第一个 leaf(保证迁移后新开的标签落在可见面板);
- 幂等:底部无标签且面板已关且 activePane 不在底部树时返回同一引用。
- **Sidebar 触发 effect**:`narrow && sessionId 已定义` 时执行(覆盖:窄屏挂载、会话切换、桌面→窄屏过渡);幂等性保证其自然收敛,不循环。
- 迁移是**永久**的(符合"直接丢到右侧栏中"):回桌面后标签仍留在右侧树,底部面板为空(欢迎卡片);用户可在桌面重新往底部面板开标签,再进窄屏会再次迁移。
- 迁移后右侧栏用**现有 `Workbench` 原样渲染**——零视图层特判、零 action 路由修补(标签真实住在右侧树,close/activate/drag 全部走原路径)。
### 4.3 窄屏渲染(`Sidebar.tsx`)
- 面板宽度 `100vw`(全宽抽屉);`--dsh-sidebar-*` 布局变量恒 0(抽屉悬浮,不挤压 AppFrame);宽度拖动条、底部浮层、`bottomClose`、拐角、底部面板按钮全部不渲染。
- `visible` 语义回到原始:底部树只在桌面底部面板场景(`bottomOpen && active`)——窄屏下底部树为空且不渲染。
- 底部首展自动终端 effect 加 `narrow` 守卫。
### 4.4 行为修正
- `state.ts` `loadState`:窄屏新会话默认 `panelOpen=false`(首开才生效;用户手动展开后照常持久化)。
- `service.ts` `openTab`:窄屏且 seed 带 `path`/`url` 且面板收起 → 自动展开抽屉(dedupe-focus 分支同样生效);纯类型打开不展开;宽屏行为不变。
### 4.5 样式(`sidebar.module.css`)
- `@media (max-width: 767px)`:开放面板标签条预留宽度 72px → 40px(按钮簇只剩一枚);`.tab` min/max 宽收窄(48/128px)。
### 4.6 测试
- `tests/breakpoints.spec.ts`:断点边界(767 窄 / 768 宽;1024 明确为宽)。
- `tests/unit.spec.ts`:`migrateBottomTabs` 4 例(并入第一 leaf 且底部清空/面板关/activePane 重指、右树为 split 时并入最左 leaf、幂等性、空底部树但 activePane 在底部树时重指)+ 窄屏新会话默认收起(stub `window.innerWidth=390`)。
- `tests/service.spec.ts`:窄屏 auto-expand 5 例(390 < 768 仍窄)。
## 5. 边界情况
- 迁移触发:窄屏挂载 / 会话切换 / 桌面→窄屏过渡,均由同一 effect 覆盖;幂等保证收敛。
- 迁移后新开标签:`activePane` 已重指右侧树第一 leaf,`openTabInActivePane` 必落在可见面板(含"空底部树但 activePane 指向它"的陈旧指针场景)。
- 跨断点往返:迁移永久;桌面底部面板空显欢迎卡片;`bottomHeight` 不受影响。
- jsdom 兼容:hook 不用 matchMedia;既有测试 stub `innerWidth=1024` 自动走桌面路径,零行为变化。
## 6. 实施偏差记录
- **v1(已提交 b391ebf,用户反馈推翻)**:合并 = 单面板内上下堆叠两个工作台(`MobileWorkbench` + 可拖分隔条,高度复用 `bottomHeight`),断点 1024(对齐宿主)。用户明确:合并 = 只显示右侧栏、底部标签直接丢进右侧栏;窄屏 = 真窄屏、不对齐 1024。
- **v2(本文档,最终形态)**:删除 `MobileWorkbench`(组件 + CSS + spec),改为 `migrateBottomTabs` 状态迁移 + 现有 `Workbench` 渲染;断点 768。`bottomHeight` 不再参与移动端布局(仅桌面底部面板使用)。
@@ -0,0 +1,43 @@
# 任务管理页后台命令区块(查看输出 + 强制终止)设计
> 2026-08-12 · v0.8.0
## 目标
任务管理页(当前树的主代理拓扑,原「子代理」页)在拓扑树下方同页显示**当前树全部会话**(主代理 + 子代理)的后台任务:
- 点击任务行,输出显示在**侧边栏底部共享 Dock**(sticky 终端式,滚动时保持可见;运行中每 2s 轮询,页面不可见时暂停;自动跟随底部);
- 运行中的任务行带两击确认的终止按钮;
- **不破坏模型契约、不修改 DSH 源码**(本仓库规则:对官方源码 checkout 零写入,挂载只走 cordis.patch.yml + profile 机制):输出查看是**模型已读输出的回放**,绝不推进 `task_output` 的读取游标。
## 数据流
```
bash/tool run_in_background → ctx.tasks.start(owner = 执行 agent)
api-proxy session/tasks 帧(TaskView[],无输出)→ 客户端 tasksBySession 镜像
SubagentView 读镜像 → collectTreeTasks(树内会话) → 行渲染(单 Dock,选中行)
点行 → POST /sidebar/api/tasks.output(事件回放)→ 扫描属主会话 events 里
task_output 的 tool/call + tool/result 对 → 模型已读文本
终止 → POST /sidebar/api/tasks.kill → ctx.tasks.kill(DSH 现成 API)
```
## 关键决策
1. **输出 = 会话事件回放,零 DSH 写入**:`TaskService.read()` 是单一消费游标——UI 直接读会偷走模型 `task_output` 的字节(或读到空)。而 DSH 源码禁止修改,所以 `tasks.output` **不碰注册表**:扫描属主会话的 append-only 事件日志,取 `task_output` 调用的 `tool/call`(`arguments.task_id` 配对)与其 `tool/result`(finalize 后的文本,即模型实际收到的内容)按序拼接;`"(no new output)"` 增量与错误结果跳过;`read: false` 表示模型尚未读过。模型游标天然不被触碰。
- 语义代价(用户已确认接受):面板只在模型读过之后才有内容——没有实时流。
2. **列表不新增路由**:任务清单由 harness 的 `session/tasks` 推送镜像提供;只加 `tasks.output`(回放)与 `tasks.kill`(注册表原生 `kill`)两个路由(信任围栏与其余路由一致)。注册表缺席时 kill 降级 503(output 不需要注册表)。
3. **终止的访问围栏**:caller 由请求的 `sessionId` 现场解析 `ctx.agents.get(sessionId)`,注册表按 owner session id 拒绝 foreign 任务;未知/foreign 统一映射 404 `task-error`。
4. **展示范围 = 整棵树**:与页面作用域一致(拓扑即当前树);树跨多会话时行尾显示属主标题。
5. **单共享 Dock(大量任务的形态)**:行内展开会让列表随任务数膨胀、排序变化时面板跳动;改为一个底部 sticky Dock + 单一轮询实例,行保持紧凑、排序变动不惊吓,选中行高亮。
6. **两击确认终止**:首次点击进入「再次点击确认」态(3s 自动解除),防止误杀长任务。
7. **输出上限**:路由按 `readLimit`(默认 512KB)截断并置 `truncated` 标记。
## 实施偏差
- 曾实现 harness 侧非消费 peek 缝隙(`BashProcess.peekOutput` / `TaskService.peek`),因「禁止修改 DSH 源码」规则**整体回退**(harness 提交已 reset,checkout 恢复纯净快照),改用事件回放。
- 宿主不新增 `@deepseek-ai/*` 依赖(`ctx.tasks`/`ctx.agents` 走结构性镜像 + 运行时 `ctx.get`,可选服务降级 503);`ctx.sessions.get(id).events` 为只读访问。
## 验证
- 插件单测:tasks-routes(回放拼接/过滤/read 标志/截断/404/503 降级);subagent-tasks 纯函数;jsdom 视图(行渲染、单 Dock 切换、关闭、两击终止、read:false 提示、大量任务、隐藏不轮询、镜像清空 hook 顺序回归 #300)。
- 集成(真实 dsh web):启动 `run_in_background` bash → 模型 `task_output` 读取 → `/sidebar/api/tasks.output` 返回模型已读内容 → `/sidebar/api/tasks.kill` 后进程退出、任务结算 `killed (signal: SIGTERM)`。
@@ -0,0 +1,69 @@
# 「添加插件」弹窗 + 推荐插件列表 + 复制安装命令 设计
**日期**:2026-08-14
**状态**:已批准,已实施
**作者**:opencode + 用户
**当前版本**:v0.12.0(分支 `feat/plugin-api-v012`,已合入 main 后 rebase)
**目标版本**:v0.12.0(不 bump)
## 1. 目标
1. 为「侧边栏内容(Tab)」与「文件预览(viewer)」两类扩展点分别增加**添加入口**:DSH 设置页「Side card」分区的两个响应式网格(侧边栏内容 / 文件预览)末尾各新增一个与其他卡片同尺寸的**虚线边框**添加卡片(「添加 Tab 插件」/「添加预览插件」)。
2. 点击后弹出对应 kind 的原生 Modal:声明该类扩展点可由插件扩展(`ctx.betterSidebar` 服务),提供「在 GitHub 上浏览更多插件」**按钮**(打开 `https://github.com/topics/dsh-better-sidebar`,新标签页),并展示仓库**推荐**的同 kind 插件列表(名字 / url / 简介 / 安装脚本)。
3. 每个条目两个动作:**「跳转」按钮**(`window.open` 新标签页直达插件仓库,绕过链接接管)与**「复制」按钮**——点击只把安装脚本写入剪贴板(`writeClipboard`,按钮闪现「已复制」反馈),用户自行粘贴到 DSH 所在环境的终端执行。
4. **不关闭任何窗口、不打开终端、无失败路径**:复制是纯 client 动作,不依赖会话/终端/配额,弹窗保持打开可连续复制多个插件。
5. 推荐列表为仓库静态数据、**分为两个文件**(`plugins-tabs.ts` Tab 注册目录 / `plugins-viewers.ts` 文件预览注册目录),初始在 viewer 目录收录唯一真实三方插件 `@huanlin/dsh-plugin-better-sidebar-plugin-office`(简介不注版本前提);后续插件只加一条数据。
6. 内置 Office 预览(docx/xlsx/pptx)**从本体移除**——已迁至上述推荐插件(见偏差记录),`docx/xlsx/pptx` 文件在未装插件时落 code/下载兜底。
## 2. 非目标(Out of Scope)
- **不打开终端**:不做终端预填安装流(曾实现的 `OpenTabSeed.expand`、`TerminalSeedMeta` 预填/提示条、service meta 透传等已全部删除回滚)。
- 不加 + 菜单入口、不加编辑器空态入口(入口只在设置页)。
- 不做插件在线拉取 / 动态发现(列表为仓库推荐静态数据)。
- 不改 host 半的服务契约(`service.ts` 相对 v0.12.0 无 API 变化;host 半仅在 office 移除时缩减了 chunk 白名单)。
## 3. 实施要点
### 3.1 数据层:两个目录文件 + 共享词汇
- `src/client/plugins-shared.ts`:`PluginEntry { id, name, url, description, install }` + `PLUGIN_TOPIC_URL`。
- `src/client/plugins-tabs.ts`:`builtinTabPlugins`(收录 dsh-sentinel——条件驱动的 agent 唤醒系统,注册 `dsh-sentinel:watches` Tab;install 用官方 github: 源一键命令)。
- `src/client/plugins-viewers.ts`:`builtinViewerPlugins`(种子:office 插件,install = `cd ~/.dsh && dsh plugin --profile web add @huanlin/dsh-plugin-better-sidebar-plugin-office`)。
### 3.2 UI 层:`src/client/add-plugin-modal.tsx` + `SideCardSection`
- 两个网格末尾各一个虚线卡片(复用 `.card` 配方 + `IconPlusOutline16`),`addPluginsOpen: PluginKind | null` 状态;Modal 仅在打开时条件挂载(Modal 原语无条件跑 hooks,SSR 规则同 settingsFor)。
- `PluginListBody`(提取组件,直接可测):topic 按钮(`window.open`)+「推荐插件」分组(`groupHeading` + count)+ 条目(名字链接 / 简介 / mono 安装命令 / 跳转按钮 / 复制按钮)+ 空态。
- **复制**:`void writeClipboard(entry.install)` + `copiedId` 状态闪现 `t('copied')`(1.5s 后还原)。不关闭、不打开、不抛错。
### 3.3 i18n
新增键:`addPluginsTabCard/TabCardDesc/ViewerCard/ViewerCardDesc/TabDesc/ViewerDesc`、`addPluginsBrowseMore`、`addPluginsRecommended`(推荐插件)、`addPluginsEmpty`、`openPlugin`(跳转)、`copyInstall`(复制安装命令,按钮 aria-label);复用 `copy`/`copied`/`close`/`settingsDone`。已删除废弃键:`install`、`installNotice`、`installError*`、office 相关键(`viewerDocx/Xlsx/Pptx`、`officeTooLarge/Corrupt/Encrypted/LoadFailed`、`previousSlide/nextSlide/zoom/zoomHint`)。
### 3.4 内置 Office 预览移除(与推荐插件衔接)
删除 3 个 viewer 描述符 + 10 个源文件(docx-view/xlsx-view/PptxView/office-view/office-shared/office-types/xlsx-to-univer + chunks/docx|xlsx|pptx)+ 3 个图标 + office CSS + office locale 键;chunk 白名单 5→2(terminal/editor,同步 `bundle-route.ts` / `chunk-loader.ts` / `tsdown.config.ts` / `scripts/package-registry.mjs`);`tests/builtins.spec.ts` 断言 6 个 viewer 且不含 office id。`.docx`(zip、无 NUL)未装插件时落 code 兜底。
## 4. 边界情况与失败模式
- **复制失败**:`writeClipboard` 尽力而为(navigator.clipboard → execCommand 兜底),失败静默;按钮反馈照常闪现。无会话/无终端/配额等一切前置条件均不涉及——复制与它们无关。
- **空目录**:目录可为空(弹窗渲染空态 + topic 按钮);当前 Tab/Viewer 目录各有 1 条。
- **版本号**:v0.12.0(`package.json` / `dsh.plugin.json` / `service.ts` 的 `SIDEBAR_SERVICE_VERSION` 三处一致,manifest-consistency 测试守护)。
## 5. 实施偏差记录
- **评审修改(第一批)**:入口由 + 菜单/编辑器空态改为**仅设置页虚线卡片**;安装命令并入「安装按钮」单一交互。
- **评审修改(第二批)**:① topic 链接改为**按钮**(`window.open` 新标签页);② 添加按钮拆为**两个**(Tab 注册 / 文件预览注册),列表数据拆为**两个文件**;③ 安装错误只内联显示在设置内(预检 + try/catch);④ 终端提示条用品牌高亮色。
- **评审修改(第三批)**:内置 Office 预览整体移除(迁至推荐插件);「内置插件」全部改名**「推荐插件」**。
- **评审修改(第四批,最终形态)**:设置窗口关闭问题无法根治(openTab 的 store 涟漪会复活弹窗),且用户实测弹窗关闭不可靠——**放弃终端安装流**:安装按钮改为**只复制安装脚本**(`writeClipboard` + 「已复制」反馈),弹窗保持打开;随之删除整个终端预填流:`terminal-meta.ts`、`TerminalView` 的 meta/预填/提示条、`tabs.tsx` 的 meta 透传与 `uiTerminalCount` 导出、`service.ts` 的 `OpenTabSeed.expand` 与 createTab meta 透传(全部回滚到 v0.12.0 原始 API 面)、`.terminalNotice` CSS、相关测试与文档;`install`/`installNotice`/`installError*` 文案键删除,新增 `copyInstall`。
- 测试中 `AddPluginModal` 不能 renderToString(React 18 server renderer 拒绝 portal),改为 createRoot + act 挂载后断言 `document.body`(portal 落点)。
## 6. 验收清单
- [x] 设置页两个网格末尾各出现虚线边框添加卡片,尺寸与既有卡片一致
- [x] 两个弹窗(kind 区分):声明文案 + GitHub topic **按钮** + 对应推荐插件条目(名字/url/简介/安装命令/跳转按钮/复制按钮)
- [x] 点「复制」:安装脚本写入剪贴板,按钮闪现「已复制」,弹窗不关闭、不打开终端、无失败路径
- [x] 点「跳转」:`window.open` 新标签页直达插件仓库
- [x] 内置 Office 预览已移除;chunk 白名单 2 个;无「内置插件」残留文案
- [x] 版本号 v0.12.0 三处一致;`pnpm typecheck` / `pnpm test` / `pnpm build` 全绿
- [x] README/README_EN/AGENTS.md 更新到位
@@ -0,0 +1,63 @@
# Issue #42 诊断与收尾记录(底部面板首展自动终端 → 整面板空白)
**日期**:2026-08-14
**状态**:已诊断、已收尾(无新代码修复;回归测试 + 文档交付)
**关联**:issue #42、issue #25(同根因)、issue #31(放大器)、PR #27 / commit 37d91be、commit 9aeb046
**目标版本**:v0.11.0(根因修复已发布)
## 1. 问题报告
[issue #42](https://github.com/omdsh-dev/DSH-better-sidebar/issues/42)(Windows 11 / QQBrowser / dsh-launcher 宿主 / 插件 0.10.3):
1. 全新安装后重启 + 硬刷新,首次展开底部面板(`bottomPanelAutoTerminal` 默认开)→ **整个底部面板空白,连标签栏都没有**;
2. 控制台两条关键报错:
- `Uncaught TypeError: Cannot read properties of undefined (reading 'dimensions')`(`Viewport._innerRefresh`)
- `Uncaught TypeError: Cannot read properties of undefined (reading 'handleResize')`(`IdleTaskQueue._process`)
3. 关闭「底部面板首次展开自动开终端」后一切正常;右侧栏终端一直正常;
4. 附带问题:Windows 无控制台宿主下每次关闭终端,服务器日志出现 `Error: AttachConsole failed`(node-pty `conpty_console_list_agent.js`)。
## 2. 诊断结论
### 2.1 根因 = issue #25 的同一 bug
与 [issue #25](https://github.com/omdsh-dev/DSH-better-sidebar/issues/25)(WKWebView 底部面板终端空白)完全同源,证据链:
- **触发路径一致**:`Sidebar.tsx` 的「底部面板首次展开自动开终端」effect(`bottomOpenedOnce` 过渡)在面板 slide-in transition 的**同一次 commit** 挂载 `TerminalView`。`docs/plans/2026-08-14-terminal-open-when-sized-design.md` §3 在设计 #25 修复时已明确把该路径列为零尺寸挂载路径之一。
- **崩溃签名逐字一致**:0.10.3 的 `TerminalView` 挂载 effect 同步执行 `term.open(host); fit.fit()`(无尺寸守卫)。xterm 5.3.0 在零尺寸容器 open 时渲染器创建失败,`RenderService._renderer` 保持 undefined → `Viewport._innerRefresh` 读 `_renderService.dimensions`(getter 即 `_renderer.value.dimensions`)、idle 任务读 `handleResize` 全部抛 "Cannot read properties of undefined"——与 #42 两条报错逐字对应。
- **平台差异只是时序**:#25 设计文档注明「Chrome(Blink)对零尺寸布局更宽容所以不崩;WKWebView 稳定复现」。QQBrowser(Chromium fork)在 Windows + launcher 启动的组合下命中同一零尺寸窗口期,属边界时序,不是新缺陷。
- **版本差**:#42 报告针对 **0.10.3**(2026-08-13 发布);根因修复 37d91be(PR #27)于 2026-08-14 合并并随 **v0.11.0** 发布(早于 #42 提交约 1 小时,晚于 reporter 安装的版本)。
### 2.2 修复覆盖路径(无需新代码)
| 层 | 机制 | 位置 | 状态 |
|---|---|---|---|
| 根因(零尺寸 open) | `openWhenSized`:`term.open + fit + sendResize` 推迟到容器有尺寸后恰好执行一次;RO/字体订阅的 `fit()` 全部 try/catch 加固 | `src/client/open-when-sized.ts` + `TerminalView.tsx`(37d91be,v0.11.0) | 已发布 |
| 根因单测 | 手动步进 rAF 的 6 个用例(延迟/一次性/单维缺失/cancel/脱离文档/异常传播) | `tests/open-when-sized.spec.ts` | 已提交 |
| 「整面板空白」放大器 | per-tab `RenderBoundary` 崩溃隔离(单 tab 崩溃只显示该 pane 内条带,toggle 簇/其他 tab/面板存活)+ 根 boundary 兜底 + layout-push CSS 变量卸载清理 | `src/client/RenderBoundary.tsx`、`Sidebar.tsx`、`index.tsx`(9aeb046,#31) | 已提交 |
| 隔离测试 | 崩溃条带/重试恢复/布局变量清理 | `tests/sidebar-crash.spec.tsx` | 已提交 |
| **#42 触发链回归** | 底部面板首展自动开终端:恰好一个 terminal tab 落在底部工作树、`bottomOpenedOnce` 原子置位、pref / 类型禁用双门控、面板完整存活 | `tests/bottom-auto-terminal.spec.tsx`(本次新增,4 例) | 本次提交 |
三者合起来构成 #42 全链回归保护:触发链(本测试)→ 零尺寸延迟(openWhenSized 单测)→ 崩溃隔离(containment 测试)。
## 3. 附带问题:AttachConsole 报错(仅文档化)
`Error: AttachConsole failed`(node-pty `conpty_console_list_agent.js`)是 **node-pty 上游行为**:`kill()` 清理路径 fork 的 console-list 辅助进程在无控制台宿主(launcher/服务方式启动的 `dsh`)下 `AttachConsole(shellPid)` 失败。终端功能本身不受影响(spawn/输入输出正常),仅刷服务器日志。
- 插件侧不可消除:不改 DSH 源码、不 patch node-pty 依赖是仓库硬约束(见 AGENTS.md §0),且 `IPty.kill()` 是 node-pty 的唯一 kill 通道;
- 建议:保持现状(无功能影响),上游如提供无控制台规避选项再跟进;reporter 若在意日志噪音,可反馈 microsoft/node-pty。
> 交叉引用(issue #140):与本文档「仅刷日志」不同,**依赖缺失/损坏**(node-pty 安装不完整导致模块加载失败)已有插件侧降级处理——宿主半懒加载 node-pty,失败时终端 tab 显示可复制的修复命令(`scripts/install.sh --repair` / `install.ps1 -Repair`),详见 `src/pty-deps.ts` 与 `tests/pty-deps.spec.ts`。
## 4. 验证
- 全量测试:`pnpm test` → 34 文件 / 430 例全部通过(基线 425 + 同期 PR #43 的 html 盘符修复 1 例 + 本次新增 4 例);
- `pnpm typecheck`(`tsc --noEmit`)通过;
- `pnpm build`(`tsc -p tsconfig.build.json && tsdown`)通过。
## 5. Issue #42 回复草稿(供 maintainer 发布/关闭用)
> 感谢复现与详细报告。诊断结论:**这是 issue #25 同一根因在不同平台的复现**——底部面板首次展开自动开终端时,终端在容器尚为零尺寸(面板 slide-in 窗口期)就执行了 xterm `Terminal.open()`,渲染器创建失败导致后续 `Viewport._innerRefresh` / 空闲任务读取 `.dimensions` / `.handleResize` 崩溃(与 #25 的控制台报错逐字一致)。
>
> 该根因已在 **v0.11.0** 修复(终端延迟到容器有尺寸后再 open,PR #27);「整个面板空白」的放大器也已在最新 main 消除(单 tab 崩溃隔离 + 布局变量清理,issue #31)。**请升级到 v0.11.0 验证**:预期首次展开自动开终端不再空白;若仍复现,请提供 v0.11.0 下的控制台报错。
>
> 附带问题(`AttachConsole failed` 日志噪音)确认为 node-pty 在无控制台宿主(launcher 启动)下的上游行为,无功能影响,插件侧无法在不改动 node-pty/DSH 的前提下消除;已文档化(`docs/plans/2026-08-14-issue-42-verify-design.md`),并已补 #42 触发链回归测试。
@@ -0,0 +1,111 @@
# 终端自定义字体(设置页二级设置)设计
**日期**:2026-08-14
**状态**:已实施(本文档含实施偏差记录)
**目标版本**:v0.11.0
## 1. 目标
在 DSH 设置页「侧边卡片」分区中,终端 tab 卡片的二级设置弹窗(齿轮按钮,README 所称"终端卡片二级设置")新增两行:
1. **终端字体**(text 输入):自定义 CSS font-family stack,如 `"JetBrains Mono", monospace`;留空 = 跟随主题等宽字体(`--ds-font-family-code` token)。
2. **终端字号**(number 输入):9–32px,默认 13,带 `px` 后缀。
配置持久化到 `SidebarPrefs`(`dsh-better-sidebar` 命名空间,host 侧 PrefsSchema 校验),对**所有已打开终端实时生效**(UI tab 与 agent tab 共用 TerminalView):设置页提交 → `store.setPrefs` 通知 → xterm `options.fontFamily/fontSize` 更新 + 重排网格。
## 2. 非目标
- 不在终端视图内加菜单/按钮(用户明确选择仅设置页齿轮弹窗)。
- 不做 per-tab / per-terminal 隔离(全局偏好,与 SidebarPrefs 现有语义一致)。
- 不自动给字体族加引号、不做字体合法性嗅探(用户负责合法 CSS font-family;placeholder 示范格式)。
- 不改 pty / WS / 终端连接生命周期:字体变化不重连、不重启 shell。
## 3. 现状回顾
- 终端描述符(`builtins/tabs.tsx`)的 `settings.toggles` 只支持**布尔开关**(`SidebarSettingToggle` 无类型字段),`FeatureSettingsRows`(`SideCardSection.tsx`)只渲染 Switch 行。
- `SidebarPrefs` / `PrefsSchema` / `parsePrefs` 三处同步维护(`prefs-shared.ts` / `config.ts` / `client/prefs.ts`)。
- `TerminalView` 硬编码 `fontSize: 13` + `fontFamily: tokenValue('--ds-font-family-code') || 内置栈`,且不响应偏好变化。
## 4. 设计
### 4.1 偏好扩展(`prefs-shared.ts` / `config.ts` / `client/prefs.ts`)
```ts
interface SidebarPrefs {
// …
terminalFontFamily: string // '' = 跟随主题等宽字体
terminalFontSize: number // 9–32,默认 13
}
// prefs-shared.ts
export const TERMINAL_FONT_SIZE_MIN = 9
export const TERMINAL_FONT_SIZE_MAX = 32
export const TERMINAL_FONT_SIZE_DEFAULT = 13
export function clampTerminalFontSize(value: number): number // round + clamp(仿 clampWidthPercent)
```
- `PrefsSchema`:`terminalFontFamily: z.string().default('')`、`terminalFontSize: z.number().step(1).min(9).max(32).default(13)` —— 旧文档解析自动补默认,无迁移。
- `parsePrefs`:family 需 string 否则默认;size 需有限数并 `clampTerminalFontSize` 否则默认。
### 4.2 声明式 API 扩展(`client/service.ts`,向后兼容)
```ts
type SidebarSettingToggleType = 'switch' | 'text' | 'number'
interface SidebarSettingToggle {
key: string
title: string | (() => string)
desc?: string | (() => string)
type?: SidebarSettingToggleType // 缺省 'switch',外部插件现有用法不变
min?: number // number 行的下限(提交时钳制)
max?: number // number 行的上限
placeholder?: string // text 行的占位符
unit?: string // 输入框后的单位后缀(如 'px')
}
```
### 4.3 终端描述符声明(`builtins/tabs.tsx`)
`settings.toggles` 追加两行:`{ key: 'terminalFontFamily', type: 'text', placeholder: … }` 与 `{ key: 'terminalFontSize', type: 'number', min: 9, max: 32, unit: 'px' }`(标题/描述走 locales)。
### 4.4 设置行渲染(`SideCardSection.tsx` + CSS)
- `FeatureSettingsRows` 按 `toggle.type` 分支:`switch` 走现有 Switch 行;`text`/`number` 走新 `TypedRow`(局部 draft state,`Input` 原语,blur/Enter 提交,number 行带 unit 后缀)。
- `TypedRow` 的 `key` 含**已提交值**:提交失败 → prefs 回滚 → key 变化 → 行重挂载为存储值(输入中 draft 不因 key 变化被打断,因为 key 只随已提交值变)。
- 父级 `onCommitSetting(toggle, raw): string`:number 解析+钳制(非法/空 → 走 `Number('')=0` 钳到下限,与宽度行 `commitWidth` 先例一致);text 原样;乐观 `setPrefs` → `commit` → 失败 `applyOutcome` 回滚(复用既有 `applyPref` 管道,原 `togglePref` 更名)。
### 4.5 终端消费(`client/terminal-font.ts` 新模块 + `TerminalView.tsx`)
```ts
// terminal-font.ts(纯函数,可单测)
export const DEFAULT_TERMINAL_FONT_FAMILY = '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace'
export function resolveTerminalFont(prefs, themeFontFamily):
{ fontFamily: 非空用户值 ?? themeFontFamily ?? 内置栈, fontSize: clampTerminalFontSize(prefs.terminalFontSize) }
```
- `TerminalView` 构造 Terminal 时用解析值;主 effect 内 `store.subscribe` 回调 diff `term.options.fontFamily/fontSize`,变化则更新 options + `fit.fit()` + `sendResize()`(网格尺寸随字体变化);cleanup 退订。懒加载 chunk 无新增依赖(store 为运行时 prop)。
### 4.6 i18n(`locales.ts`)
zh/en 各新增:`settingsFontFamilyTitle/Desc/Placeholder`、`settingsFontSizeTitle/Desc/Suffix`(px)。
## 5. 测试
- `tests/unit.spec.ts`:`parsePrefs`/`loadPrefs` 新字段(合法 string 直通、越界钳制 9/32、非 number 回退、缺省默认);全等字面量补字段。
- `tests/plugin-shape.spec.ts`:PrefsSchema 默认值 + overridden 全等。
- `tests/builtins.spec.ts`:终端 toggles keys = 4 个,后两个 type/min/max 断言。
- `tests/smoke.spec.ts`:settings.get 全默认字面量补字段。
- `tests/side-card-section.spec.tsx`(SSR):text/number 行渲染(value/min/max/placeholder/unit,非 checkbox)。
- `tests/side-card-section-rows.spec.tsx`(新,jsdom + createRoot):blur 提交回调、number 钳制、空输入钳到下限、提交失败回滚草稿(key 重挂载)。
- `tests/terminal-font.spec.ts`(新):`resolveTerminalFont` 回退链 + `clampTerminalFontSize`。
## 6. 限制与取舍
- 外部插件的自定义 toggle key 照旧被 host PrefsSchema 校验丢弃(现有 seam 行为);`type: 'text'/'number'` 是公开 API 扩展,外部插件可用但 key 仍须是宿主 schema 字段。
- number 输入的"非法值"在浏览器里只会以空串出现(number input 拒绝非数字字符),因此 `Number('')=0 → 钳到下限` 是唯一可达路径,与宽度行先例一致。
- 字号实时生效依赖 xterm 的 options setter + re-fit;若未来 xterm 版本行为变化,回退方案是重建 Terminal(不推荐,会丢滚动缓冲)。
## 7. 实施偏差记录
- 计划中的"非法输入回退当前存储值"用例在实现中发现不可达(浏览器 number input 的值清洗),改为断言"空输入钳到下限"(与宽度行一致)。
- `togglePref` 更名为 `applyPref`(它本就接受任意 patch,不只是布尔翻转)。
@@ -0,0 +1,90 @@
# 终端延迟初始化(零尺寸容器 xterm open 崩溃修复)设计
**日期**:2026-08-14
**状态**:已实施(本文档含实施偏差记录)
**目标版本**:v0.11.x(issue #25 修复)
## 1. 目标
修复 [issue #25](https://github.com/omdsh-dev/DSH-better-sidebar/issues/25):WKWebView 下点击「展开底部面板」后底部面板内容空白、无终端,控制台报 `Cannot read properties of undefined (reading 'dimensions')` 与 `(reading 'handleResize')`。
根因:`TerminalView` 的挂载 effect 在容器可能为零尺寸时**同步**执行 `term.open(host); fit.fit()`。xterm 5.3.0 在零尺寸容器里 open 时渲染器创建失败,`RenderService._renderer.value` 保持 undefined,随后 Viewport 的 `_innerRefresh` 读 `_renderService.dimensions`(getter 即 `_renderer.value.dimensions`)崩溃。Chrome(Blink)对零尺寸布局更宽容所以不崩;WKWebView 稳定复现。
修复:把 `term.open(host)` + `fit.fit()` 推迟到容器有实际尺寸之后再执行(xterm 在隐藏/零尺寸容器初始化的标准修法),open 恰好执行一次。
## 2. 非目标
- 不换 xterm 渲染器(打包产物只有 DomRenderer 是现状,不是本次根因;`rendererType` 保持默认)。
- 不改 pty / WS / 终端连接生命周期:推迟 open 不推迟连接、不重启 shell、不丢 transcript。
- 不改 host 半与 chunk 打包结构(新模块随 terminal chunk 打入 `lib/client-terminal.js`)。
- 不 bump 版本号(release 流程负责)。
## 3. 现状回顾
- `src/client/TerminalView.tsx` 第 99–101 行同步 `term.open(host); fit.fit()`。
- 零尺寸挂载路径真实存在且不止一条:
- 底部面板首次展开自动开终端(`Sidebar.tsx` 的 `bottomOpenedOnce` effect)在面板 slide-in transition 的同一次 commit 挂载 TerminalView,WKWebView 下此刻 host 高度为 0;
- 所有 tab 保持挂载(`split-pane.tsx`:非激活 tab 用 `.paneTabHidden { display: none }` 隐藏),面板折叠时保持挂载(`.panelHidden`)——任何 `display:none` / 零尺寸祖先都会踩同一坑。
- 前提已验证(xterm 5.3.0 与 @xterm/addon-fit 0.10.0 源码):
- `FitAddon.fit()` 在 open 之前是安全 no-op(`proposeDimensions` 先查 `this._terminal.element`,undefined 即返回)。
- `WriteBuffer.write()` 不依赖 open 状态:数据解析进 buffer,open 后渲染——推迟 open 不丢初始 transcript。
- `TerminalView` 已有的 ResizeObserver / 字体订阅里的 `fit.fit()` 在 open 前 no-op、open 后照常 re-fit,无需改动。
## 4. 设计
### 4.1 新模块 `src/client/open-when-sized.ts`(纯逻辑,可单测)
```ts
export function openWhenSized(
host: HTMLElement,
open: () => void,
raf: (cb: FrameRequestCallback) => number = requestAnimationFrame,
caf: (id: number) => void = cancelAnimationFrame,
): () => void
```
- 单条代码路径:`raf(step)` → step 检查 `host.isConnected`(false 则停止,防卸载后空转)→ `clientWidth > 0 && clientHeight > 0` 则调 `open()` 并停止;否则再排一帧。
- 返回 cancel:`caf(frame)` 并置空(幂等),cleanup 调用。
- helper 不 try/catch(保持纯净),异常由调用方处理。
### 4.2 消费点 `src/client/TerminalView.tsx`
- 移除 effect 顶部的 `term.open(host); fit.fit()`(保留 `term.loadAddon(fit)`)。
- 在 effect 尾部(`sendResize` / `connect` 定义之后、`connect()` 调用之前)注册:
```ts
const cancelOpen = openWhenSized(host, () => {
try {
term.open(host)
fit.fit()
sendResize()
} catch (error) {
console.error('[dsh-better-sidebar] xterm open failed:', error)
}
})
```
- 回调里补 `sendResize()`:推迟路径下 socket 可能已先连上(`connect()` 在 effect 尾部同步调用),open+fit 后必须把真实 cols/rows 发出去,否则 pty 停留在默认 80x24 直到下次 resize;已尺寸快速路径下 socket 尚未 open,`sendResize` 内部守卫(`readyState === OPEN`)自然 no-op,无行为变化。
- cleanup 里在 `term.dispose()` 前加 `cancelOpen()`。
- 其余(connect / observer / fontSub / schemeSub / onData / 主题)不动。
## 5. 测试
`tests/open-when-sized.spec.ts`(jsdom + 注入手动步进的假 raf/caf;`Object.defineProperty` 桩 `clientWidth/clientHeight`;`appendChild/remove` 控制 `isConnected`):
1. 已尺寸 → tick 一次后 open 恰好一次,之后不再排帧;
2. 零尺寸 → 多次 tick 不 open,设尺寸后下一次 tick open 一次,后续 tick 不重复;
3. 只有一维缺失 → 补齐后 open;
4. cancel 后永不 open、无残留帧、cancel 幂等;
5. host 脱离文档 → 轮询停止、不 open;
6. open 抛错 → 异常向调用方传播(调用方负责 try/catch),不重复调用。
## 6. 限制与取舍
- rAF 轮询在 host 长期隐藏(`display:none` 祖先)期间持续排帧,每帧只读两个属性,开销可忽略;open 后立即停止。用 ResizeObserver 触发也可行,但轮询是 issue 中建议的标准修法,且不依赖 observer 对 transition 中间态的回调时序。
- 已尺寸快速路径比原行为晚一帧(rAF 回调时机),无感知差异。
- 若容器永远零尺寸(异常布局),终端永不 open、不崩溃——对不可见内容是可接受的降级。
## 7. 实施偏差记录
无(按设计实施)。
@@ -0,0 +1,67 @@
# 皮肤兼容(令牌驱动):面板表面、终端透明度回退与独立 CSS 修复
**日期**:2026-08-15
**状态**:已实施(本文档含实施偏差记录)
**目标版本**:随发布 bump(本 PR 不改版本号、不更新 README——发布流程统一处理)
## 1. 目标
让 better-sidebar 与 **dsh-web-ui 的皮肤中心**兼容:用户切换皮肤时侧边栏自动跟随换肤,零每皮肤适配(KISS:不做 aionui 式"每款皮肤 remap 一套令牌"的重活)。附带修复同批 CSS 问题([#52](https://github.com/omdsh-dev/DSH-better-sidebar/issues/52) 弹出框遮挡、[#57](https://github.com/omdsh-dev/DSH-better-sidebar/issues/57) 前半图标尺寸、[#92](https://github.com/omdsh-dev/DSH-better-sidebar/issues/92) 拖拽回归防护),并覆盖 [#60](https://github.com/omdsh-dev/DSH-better-sidebar/issues/60)/[#105](https://github.com/omdsh-dev/DSH-better-sidebar/issues/105)/[#90](https://github.com/omdsh-dev/DSH-better-sidebar/issues/90) 的相关诉求。
## 2. 调研结论
dsh-web-ui(`zhu1090093659/dsh-web-ui`)的皮肤机制:
- 皮肤 = cordis 插件(`ui-skin-<id>`),皮肤中心通过 `cordis.patch.yml` 切换激活;皮肤插件 client half 在 `document.body` 上打 `data-dsh-<skin>` 属性并注入 CSS。
- **10 款皮肤全部覆盖两层令牌**:`--dsw-static-*` 基础色板 + `--dsw-alias-*` 语义层(`--dsw-alias-bg-layer-1`、`--dsw-alias-bg-base` 等做成半透明玻璃 rgba 0.16–0.7,`--dsw-specific-sidebar-fill` 也全部覆盖)。
- dsh-web-ui 自家右侧面板(aionui)用独立 `--aion-*` 令牌,每款皮肤单独 remap——**重**。
结论:better-sidebar 只要**消费 DSH 标准令牌**(alias 层),换肤就自动跟随,不需要任何每皮肤适配。此前围绕第三方皮肤插件(Aqua/deep-whale)设计的类名改名与 `data-bs-*` 语义钩子按 KISS 原则**全部回退**(见 §6)。
## 3. 设计
### 3.1 面板表面(`sidebar.module.css`)
```css
.panel { background: var(--dsw-alias-bg-layer-1); }
.bottomPanel { background: var(--dsw-alias-bg-layer-1); }
```
- 直接消费通用卡片表面令牌,**不新增自有令牌、不加属性钩子**。
- **绝不消费 `--dsw-specific-sidebar-fill`**:那是宿主左侧导航列专属令牌,皮肤系统按左导航语义覆盖它(dsh-web-ui 半透明玻璃/主题色、Aqua `transparent`),面板消费它会失去填充或与标签令牌冲突。
- stock 外观色阶变化仅 ±8 RGB(bluish-50→75 / 900→875),视觉无差;dsh-web-ui 换肤后面板获得与宿主一致的玻璃效果。
### 3.2 终端/编辑器透明度回退(`theme.ts` / `TerminalView.tsx`)
`effectiveTokenValue` 现在把以下值视为"未设置"(触发调用方 `||` 回退):
- 空串与 CSS 重置关键字(`transparent`/`initial`/`inherit`/`unset`);
- **alpha < 0.9 的半透明颜色**(dsh-web-ui 皮肤的 `--dsw-alias-bg-base` 是 rgba 0.16–0.7 玻璃)。
新增 `colorAlpha()` 解析 computed 颜色(rgb()/rgba()/hsl()/hsla() 逗号与空格语法、`#rgb/#rgba/#rrggbb/#rrggbbaa`);无法解析的格式(命名色、`color()`)视为不透明放行。阈值 0.9:≥ 0.9 的近不透明值(如皮肤作用域内 0.96 瓷器玻璃)放行,皮肤仍能控制终端表面。
### 3.3 独立修复(与皮肤兼容无关,保留)
- **z-index(#52)**:`.toggleCluster` 55→45、`.panel`/`.bottomPanel` 50→40;角手柄移入面板后为面板内局部 `z-index: 2`。依据:DSH 浮层栈为 100(Menu/HoverCard/Tooltip/PopupSelect/Modal=1000),30–99 无任何 DSH 元素。
- **角手柄几何 CSS 化**:移入右面板 DOM,`position: absolute; left: -6px; bottom: calc(var(--dsh-sidebar-height, 0px) + 6px)`——复用拖拽逐帧写入的布局变量,删除 `cornerRef` JS 内联坐标。
- **刷新图标统一 `size={14}`(#57 前半)**:Explorer/Git/Diff 的 `IconRefreshOutline16` 显式 14px,与 Subagent/Browser 一致。
- **拖拽逐帧回归 e2e(#92)**:`tests/e2e/drag-layout.e2e.ts` rAF 采样,断言拖拽期间 `data-dsh-sidebar-dragging` 存在、`#root` transition 为 none、会话列与面板边单调 1:1 跟随(实测通过)。拖条以 `cursor: col-resize` 语义定位(无属性钩子可依赖)。
## 4. 测试
- `tests/theme.spec.ts`:`colorAlpha`(hex/rgb/hsl/不可解析)与 `effectiveTokenValue`(transparent/关键词/半透明回退/0.96 放行/缺失)。
- `tests/e2e/mount.e2e.ts`:追加 `--dsh-sidebar-width` 布局变量随面板挂载生效的断言(令牌驱动契约的挂载级守护)。
- `tests/e2e/drag-layout.e2e.ts`:拖拽逐帧回归。
- 门禁:`pnpm typecheck && pnpm test && pnpm build && pnpm pack && pnpm test:mount`。
## 5. 文档
- AGENTS.md §8「皮肤兼容(令牌驱动)」:面板表面令牌、透明度阈值、z-index 层级、根锚点 `[data-dsh-better-sidebar]`、类名非契约。
- README 不在本批更新(发布时随版本条目更新)。
## 6. 实施偏差记录
- **首版契约(已回退)**:初版围绕第三方皮肤插件设计了 `--dsh-sidebar-surface`/`--dsh-resize-strip-offset` 自有令牌(review 后已删)、panel 家族类名 kebab-case 改名(breaking)与 `data-bs-*` 语义钩子。确认主目标是 dsh-web-ui 皮肤中心后,按 KISS 全部回退:令牌驱动下这些都不需要,改名还引入无谓 breaking。最终契约 = 消费标准令牌 + 透明度阈值,无自有概念。
- **角手柄 `z-index` 语义变化**:移入面板后 z-index 为面板内层叠(2,与拖条一致),对外绝对层级由面板(40)决定;两面板同开时手柄盒与底面板上沿无重叠(≥6px 间隙),行为不变。
- **拖拽禁用断言修正**:`transition: none` 的 computed 值为 `transition-property: none`(非 `all`),已按实测修正。
- **面板默认表面色阶微调**:±8 RGB,未做像素级补偿(e2e 不依赖具体颜色)。
@@ -0,0 +1,89 @@
# 外链点击路由到插件 tab(urlTarget)+ 浏览器 HTTP/HTTPS 二级设置
**日期**:2026-08-15
**状态**:已实施(本文档含实施偏差记录)
**目标版本**:v0.13.0(API 兼容层,版本号未随本变更 bump,见 §7)
## 1. 目标
基于 [PR #74](https://github.com/omdsh-dev/DSH-better-sidebar/pull/74)(聊天区外链开关颗粒度讨论)向前兼容地拓展服务化 API:
1. **插件声明「点击打开的类型」**:`TabDescriptor` 新增可选 `urlTarget?: (url: URL) => boolean`——聊天区外链被拦截时,路由到第一个声明的、命中的、未被设置禁用的插件 tab 类型(`openTab({ type, url, title: hostname })`,URL 即全部载荷),内置浏览器 tab 退居隐式兜底。
2. **浏览器链接设置改为二级菜单**(浏览器卡片齿轮弹窗内):
- 侧边打开 HTTP 网页(默认开)
- 侧边打开 HTTPS 网页(默认关)
- `browserInterceptLinks` 保留为**总闸**(默认开,零迁移:旧文档显式 false 语义完整保留)。
## 2. 非目标
- 不实施 PR #74 的「外链开关提升到常规区」(用户确认:二级设置留在浏览器卡片齿轮弹窗,常规区不加行)。
- 不做 per-host / per-domain 路由规则(只有 per-protocol 开关 + 插件谓词)。
- 不改 + 菜单、不改手动打开的浏览器 tab、不改同源/修饰键点击放行规则。
- 不改 host 半逻辑(仅 PrefsSchema 加字段);不修改 DSH 源码。
- 不做版本号 bump(发布时决定;向前兼容机制 = `SIDEBAR_FEATURES` 追加 `'urlTarget'`)。
## 3. 现状回顾
- `link-intercept.ts` 的 `registerLinkInterception` 无参 `takeoverEnabled()` 只做布尔门;`index.tsx` 硬编码 `openTab({ type: 'browser', url, title: hostname })`。
- `browserInterceptLinks`(默认 true)与 `browserNoSandbox` 并列于浏览器卡片的 `settings.toggles`;拦截 gating = 该 pref && `tabsEnabled['browser']`。
- `openTab` seed 已有 `url` 字段(新建 tab 的 path 预填),插件 tab 无需新渲染管道。
## 4. 设计
### 4.1 Prefs(`prefs-shared.ts` / `config.ts` / `client/prefs.ts`)
```ts
interface SidebarPrefs {
// …
browserInterceptLinks: boolean // 总闸,默认 true(保留,零迁移)
browserInterceptHttp: boolean // HTTP 外链侧边栏接管,默认 true
browserInterceptHttps: boolean // HTTPS 外链侧边栏接管,默认 false
}
```
三处同步维护(类型 + schemastery 默认 + parsePrefs 校验),旧文档无需迁移:总闸保留使显式关闭过的用户升级后仍不拦截。
### 4.2 服务 API(`service.ts`)
```ts
interface TabDescriptor {
// …
urlTarget?: (url: URL) => boolean
}
/** 注册顺序先到先得;谓词抛错吞掉(console.error)继续;返回 undefined 由调用方回退 browser。 */
export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDescriptor | undefined
```
- 匹配函数为**模块级纯函数**(不进 `BetterSidebarService` 接口,KISS:不扩公开面);启用态过滤由调用方(`index.tsx`)按 `tabsEnabled` 过滤后传入。
- 内置 browser 不声明 `urlTarget`,永远不可能遮蔽插件声明;插件多 URL 并存需用 `createTab` 铸造 per-URL id(browser 同款模式),否则 id 安全网聚焦既有 tab 不覆写 path。
- `SIDEBAR_FEATURES` 追加 `'urlTarget'`(消费者按能力 gate)。
### 4.3 拦截管线(`link-intercept.ts` / `index.tsx`)
- `shouldInterceptLink` 签名不变(协议/同源纯判定);`takeoverEnabled` 改为 `(url: URL) => boolean`,onClick 顺序:纯判定 → gate(持 URL)→ preventDefault → open。
- `index.tsx` gate:总闸 `browserInterceptLinks` → 协议开关(https 读 `browserInterceptHttps`,否则读 `browserInterceptHttp`)→ 目标可开(插件命中即目标,否则 browser 的 `tabsEnabled`)。open 时再次解析目标并 `openTab({ type, url, title: hostname })`。
### 4.4 设置 UI(`builtins/tabs.tsx` / `locales.ts`)
浏览器卡片 `settings.toggles` 收敛为四行:`browserNoSandbox` / `browserInterceptLinks`(总闸)/ `browserInterceptHttp` / `browserInterceptHttps`;新增 zh/en 文案(HTTP 默认开、HTTPS 默认关及理由)。
## 5. 行为变化
- 默认设置下:HTTP 外链 → 侧边栏(不变);**HTTPS 外链 → 系统浏览器新窗口(变化,符合「默认关闭」)**。
- 声明 `urlTarget` 的插件 tab 认领被拦截的链接(优先于浏览器兜底);插件被禁用/谓词抛错时跳过。
- 总闸关闭 → 一律不拦截(旧文档语义保留)。
## 6. 测试
- `tests/builtins.spec.ts`:浏览器 toggles 断言收敛为四行 + 各行 title/desc 存在。
- `tests/service.spec.ts`:`matchUrlTarget`(无声明 → undefined / 注册顺序 / 抛错吞掉 / browser 永不命中)。
- `tests/link-intercept.spec.ts`(新增 jsdom):`takeoverEnabled(url)` 收到解析后的 URL、gate 决定是否接管、同源/非 http(s) 不 consult gate、修饰键绕过。
- `tests/unit.spec.ts`:parsePrefs 新字段默认值(http true / https false / 非法回退);总闸与协议开关相互独立。
- `tests/plugin-shape.spec.ts` / `tests/smoke.spec.ts`:全默认字面量补两个字段。
## 7. 实施偏差记录
- 计划曾考虑把 `matchUrlTarget` 作为 `BetterSidebarService` 接口方法(镜像 `matchFileViewer`);按 KISS 收敛为**模块级导出纯函数**,启用态过滤由调用方完成(匹配函数不感知 prefs),公开 API 面只剩 `urlTarget` 字段 + `'urlTarget'` feature 字符串。
- 版本号未 bump(保持 0.12.1):`SIDEBAR_SERVICE_VERSION` 与 `package.json` 的配对断言继续成立;`'urlTarget'` feature 已入 `SIDEBAR_FEATURES`,发布时再决定版本。
- PR #74 的 diff(常规区移动 + 其测试改动)被本方案取代,未合入。
+2 -2
View File
@@ -1,8 +1,8 @@
{
"id": "dsh-external/dsh-better-sidebar",
"version": "0.4.1",
"version": "0.12.3",
"main": "./lib/index.js",
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
"engines": {
"dsh": ">=0.0.1"
},
+69 -42
View File
@@ -1,9 +1,15 @@
{
"name": "dsh-better-sidebar",
"version": "0.4.1",
"description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.",
"private": true,
"version": "0.12.3",
"description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.",
"type": "module",
"repository": {
"type": "git",
"url": "https://github.com/omdsh-dev/DSH-better-sidebar"
},
"publishConfig": {
"access": "public"
},
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
@@ -34,9 +40,13 @@
"node": ">=20"
},
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-slots",
"@deepseek-ai/dsh-client-ui-conversation"
],
@@ -47,39 +57,57 @@
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/client-registry.js",
"lib/client-terminal.js",
"lib/client-editor.js",
"lib/types/**/*.d.ts",
"src"
"src",
"scripts/install.sh",
"scripts/install.ps1",
"cordis.patch.yml",
"README.md",
"README_EN.md",
"LICENSE"
],
"scripts": {
"build": "rm -rf lib && tsc -p tsconfig.build.json && tsdown",
"prepublishOnly": "pnpm build",
"typecheck": "tsc --noEmit",
"bundle": "tsdown",
"watch": "tsdown --watch",
"prepare": "tsdown",
"test": "vitest run"
"test": "vitest run",
"test:mount": "bash scripts/e2e-mount.sh",
"check:consumer-types": "bash scripts/check-consumer-types.sh"
},
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-schema-form": "^0.0.1",
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-settings": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-client-web-react": "^0.0.1",
"@deepseek-ai/dsh-host-webserver": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-settings": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-schema-form": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-web-react": "^0.1.0-rc.6",
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"peerDependenciesMeta": {
"cordis": {
"optional": true
}
},
"dependencies": {
"@aiden0z/pptx-renderer": "^1.2.4",
"@codemirror/commands": "^6.10.4",
"@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-css": "^6.3.1",
@@ -101,39 +129,38 @@
"@codemirror/state": "^6.7.1",
"@codemirror/view": "^6.43.8",
"@lezer/highlight": "^1.2.3",
"@univerjs/preset-sheets-core": "^0.25.1",
"@univerjs/presets": "^0.25.1",
"@xterm/addon-fit": "^0.10.0",
"clsx": "^2.1.1",
"docx-preview": "^0.4.0",
"node-pty": "^1.1.0",
"rxjs": "^7.8.2",
"schemastery": "^3.18.0",
"ws": "^8.18.0",
"xlsx": "^0.18.5",
"xterm": "^5.3.0"
"ws": "^8.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "link:/Users/menghuan/.dsh/source/current/packages/core/agent",
"@deepseek-ai/dsh-client-runtime": "link:/Users/menghuan/.dsh/source/current/packages/client/runtime",
"@deepseek-ai/dsh-client-schema-form": "link:/Users/menghuan/.dsh/source/current/packages/client/schema-form",
"@deepseek-ai/dsh-client-ui-conversation": "link:/Users/menghuan/.dsh/source/current/packages/client/ui-conversation",
"@deepseek-ai/dsh-client-ui-primitives": "link:/Users/menghuan/.dsh/source/current/packages/client/ui-primitives",
"@deepseek-ai/dsh-client-ui-settings": "link:/Users/menghuan/.dsh/source/current/packages/client/ui-settings",
"@deepseek-ai/dsh-client-ui-slots": "link:/Users/menghuan/.dsh/source/current/packages/client/ui-slots",
"@deepseek-ai/dsh-client-web-react": "link:/Users/menghuan/.dsh/source/current/packages/client/web-react",
"@deepseek-ai/dsh-host-webserver": "link:/Users/menghuan/.dsh/source/current/packages/host/webserver",
"@deepseek-ai/dsh-invariants": "link:/Users/menghuan/.dsh/source/current/packages/support/invariants",
"@deepseek-ai/dsh-llm": "link:/Users/menghuan/.dsh/source/current/packages/llm/llm",
"@deepseek-ai/dsh-session": "link:/Users/menghuan/.dsh/source/current/packages/core/session",
"@deepseek-ai/dsh-settings": "link:/Users/menghuan/.dsh/source/current/packages/settings/settings",
"@deepseek-ai/dsh-tools": "link:/Users/menghuan/.dsh/source/current/packages/core/tools",
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-schema-form": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-web-react": "^0.1.0-rc.6",
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
"@playwright/test": "^1.62.1",
"@types/node": "^24.0.0",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.1",
"@types/ws": "^8.5.10",
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"cordis": "^4.0.0-rc.7",
"jsdom": "^29.1.1",
"lightningcss": "^1.32.0",
"react": "^18.2.0",
"react-dom": "18.2.0",
+39
View File
@@ -0,0 +1,39 @@
/**
* Playwright config for the headless-render mount lane (tests/e2e).
*
* This lane does NOT spawn the server itself: `scripts/e2e-mount.sh` boots a
* real `dsh web` instance (with the npm-packed plugin mounted through the
* official `dsh plugin add` channel) and injects the base URL through
* `DSH_E2E_URL`. The spec's job is to render that URL in a headless Chromium
* and prove the plugin mounts without crashing the shell.
*
* Specs are named `*.e2e.ts` (not `*.spec.ts`) so vitest's default include
* never collects them; vitest.config.ts excludes the directory as well.
*/
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
// The lane's specs are named *.e2e.ts (so vitest's default include never
// collects them); tell Playwright to pick exactly that shape.
testMatch: '**/*.e2e.ts',
// A deterministic gate: no retries, one worker, one browser. The whole lane
// is a crash probe against one server instance, so serial execution keeps
// the assertions meaningful (a crash would trip the very next check).
retries: 0,
workers: 1,
fullyParallel: false,
timeout: 120_000,
reporter: [
['list'],
// Local debugging artifact; CI uploads the report dir on failure.
['html', { open: 'never' }],
],
use: {
browserName: 'chromium',
headless: true,
viewport: { width: 1440, height: 900 },
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
},
})
+2669 -3521
View File
File diff suppressed because it is too large Load Diff
+121
View File
@@ -7,3 +7,124 @@ packages:
allowBuilds:
node-pty: true
protobufjs: true
minimumReleaseAgeExclude:
- '@deepseek-ai/cordis-plugin-include@1.0.5-rc.1'
- '@deepseek-ai/cordis-plugin-loader@1.0.1-rc.1'
- '@deepseek-ai/cordis@4.0.1-rc.1'
- '@deepseek-ai/cosmokit@1.8.2-rc.1'
- '@deepseek-ai/dsh-agent-default-model@0.0.1-rc.1'
- '@deepseek-ai/dsh-agent-presets@0.0.1-rc.1'
- '@deepseek-ai/dsh-agent@0.0.1-rc.1'
- '@deepseek-ai/dsh-api-remotes@0.0.1-rc.1'
- '@deepseek-ai/dsh-atomic-write@0.0.1-rc.1'
- '@deepseek-ai/dsh-attachment@0.0.1-rc.1'
- '@deepseek-ai/dsh-brand@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-connection@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-locale@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-runtime@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-schema-form@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-ui-conversation@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-ui-primitives@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-ui-settings@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-ui-slash@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-ui-slots@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-web-react@0.0.1-rc.1'
- '@deepseek-ai/dsh-code-runtime@0.0.1-rc.1'
- '@deepseek-ai/dsh-commands@0.0.1-rc.1'
- '@deepseek-ai/dsh-compact@0.0.1-rc.1'
- '@deepseek-ai/dsh-credentials@0.0.1-rc.1'
- '@deepseek-ai/dsh-goal@0.0.1-rc.1'
- '@deepseek-ai/dsh-host-apiproxy@0.0.1-rc.1'
- '@deepseek-ai/dsh-host-directory-picker@0.0.1-rc.1'
- '@deepseek-ai/dsh-host-webserver@0.0.1-rc.1'
- '@deepseek-ai/dsh-invariants@0.0.1-rc.1'
- '@deepseek-ai/dsh-llm-retry@0.0.1-rc.1'
- '@deepseek-ai/dsh-llm@0.0.1-rc.1'
- '@deepseek-ai/dsh-native-command@0.0.1-rc.1'
- '@deepseek-ai/dsh-paths@0.0.1-rc.1'
- '@deepseek-ai/dsh-scope@0.0.1-rc.1'
- '@deepseek-ai/dsh-session-persistence@0.0.1-rc.1'
- '@deepseek-ai/dsh-session-projection-cache@0.0.1-rc.1'
- '@deepseek-ai/dsh-session-projection@0.0.1-rc.1'
- '@deepseek-ai/dsh-session-query@0.0.1-rc.1'
- '@deepseek-ai/dsh-session-title@0.0.1-rc.1'
- '@deepseek-ai/dsh-session@0.0.1-rc.1'
- '@deepseek-ai/dsh-settings@0.0.1-rc.1'
- '@deepseek-ai/dsh-skill@0.0.1-rc.1'
- '@deepseek-ai/dsh-storage-domain@0.0.1-rc.1'
- '@deepseek-ai/dsh-storage@0.0.1-rc.1'
- '@deepseek-ai/dsh-subagent@0.0.1-rc.1'
- '@deepseek-ai/dsh-system-prompt@0.0.1-rc.1'
- '@deepseek-ai/dsh-timeout@0.0.1-rc.1'
- '@deepseek-ai/dsh-token-meter@0.0.1-rc.1'
- '@deepseek-ai/dsh-tools@0.0.1-rc.1'
- '@deepseek-ai/dsh-type-meta@0.0.1-rc.1'
- '@deepseek-ai/dsh-typert-registry@0.0.1-rc.1'
- '@deepseek-ai/dsh-user-approval@0.0.1-rc.1'
- '@deepseek-ai/dsh-user-interaction@0.0.1-rc.1'
- '@deepseek-ai/dsh-workspace@0.0.1-rc.1'
- '@deepseek-ai/schemastery@3.18.1-rc.1'
- '@deepseek-ai/cordis-plugin-include@1.0.6'
- '@deepseek-ai/cordis-plugin-loader@1.0.2'
- '@deepseek-ai/cordis@4.0.1'
- '@deepseek-ai/cosmokit@1.8.2'
- '@deepseek-ai/dsh-agent-default-model@0.1.0-rc.6'
- '@deepseek-ai/dsh-agent-presets@0.1.0-rc.6'
- '@deepseek-ai/dsh-agent@0.1.0-rc.6'
- '@deepseek-ai/dsh-api-gateway@0.1.0-rc.6'
- '@deepseek-ai/dsh-api-remotes@0.1.0-rc.6'
- '@deepseek-ai/dsh-atomic-write@0.1.0-rc.6'
- '@deepseek-ai/dsh-attachment@0.1.0-rc.6'
- '@deepseek-ai/dsh-brand@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-connection@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-locale@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-runtime@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-schema-form@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-ui-attachment@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-ui-conversation@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-ui-input-trigger@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-ui-primitives@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-ui-settings@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-ui-slots@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-web-react@0.1.0-rc.6'
- '@deepseek-ai/dsh-code-runtime@0.1.0-rc.6'
- '@deepseek-ai/dsh-commands@0.1.0-rc.6'
- '@deepseek-ai/dsh-compaction@0.1.0-rc.6'
- '@deepseek-ai/dsh-cordis-host-runner@0.1.0-rc.6'
- '@deepseek-ai/dsh-credentials@0.1.0-rc.6'
- '@deepseek-ai/dsh-goal@0.1.0-rc.6'
- '@deepseek-ai/dsh-home-paths@0.1.0-rc.6'
- '@deepseek-ai/dsh-host-apiproxy@0.1.0-rc.6'
- '@deepseek-ai/dsh-host-directory-picker@0.1.0-rc.6'
- '@deepseek-ai/dsh-host-plugin-inventory@0.1.0-rc.6'
- '@deepseek-ai/dsh-host-webserver@0.1.0-rc.6'
- '@deepseek-ai/dsh-invariants@0.1.0-rc.6'
- '@deepseek-ai/dsh-jobs@0.1.0-rc.6'
- '@deepseek-ai/dsh-llm-retry@0.1.0-rc.6'
- '@deepseek-ai/dsh-llm@0.1.0-rc.6'
- '@deepseek-ai/dsh-message-feedback@0.1.0-rc.6'
- '@deepseek-ai/dsh-native-command@0.1.0-rc.6'
- '@deepseek-ai/dsh-scope@0.1.0-rc.6'
- '@deepseek-ai/dsh-session-persistence@0.1.0-rc.6'
- '@deepseek-ai/dsh-session-projection-cache@0.1.0-rc.6'
- '@deepseek-ai/dsh-session-projection@0.1.0-rc.6'
- '@deepseek-ai/dsh-session-query@0.1.0-rc.6'
- '@deepseek-ai/dsh-session-stats@0.1.0-rc.6'
- '@deepseek-ai/dsh-session-title@0.1.0-rc.6'
- '@deepseek-ai/dsh-session@0.1.0-rc.6'
- '@deepseek-ai/dsh-settings@0.1.0-rc.6'
- '@deepseek-ai/dsh-skill@0.1.0-rc.6'
- '@deepseek-ai/dsh-storage-domain@0.1.0-rc.6'
- '@deepseek-ai/dsh-storage@0.1.0-rc.6'
- '@deepseek-ai/dsh-subagent@0.1.0-rc.6'
- '@deepseek-ai/dsh-system-prompt@0.1.0-rc.6'
- '@deepseek-ai/dsh-timeout@0.1.0-rc.6'
- '@deepseek-ai/dsh-token-meter@0.1.0-rc.6'
- '@deepseek-ai/dsh-tools@0.1.0-rc.6'
- '@deepseek-ai/dsh-typert-protocol@0.1.0-rc.6'
- '@deepseek-ai/dsh-typert-registry@0.1.0-rc.6'
- '@deepseek-ai/dsh-user-approval@0.1.0-rc.6'
- '@deepseek-ai/dsh-user-questions@0.1.0-rc.6'
- '@deepseek-ai/dsh-workspace@0.1.0-rc.6'
- '@deepseek-ai/schemastery@3.18.1'
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
# Consumer-facing type surface check: build a tiny standalone consumer that
# imports `dsh-better-sidebar/client/service` WITHOUT @types/node and with
# skipLibCheck: false, then type-check it with the repo's tsc.
#
# This is the exact scenario the v0.12.0 API work cares about: the shipped
# declaration graph (service.d.ts -> context-types.d.ts -> state/api) must
# resolve and type-check for a browser-only plugin author. Run AFTER
# `pnpm build` (the check reads lib/types).
set -euo pipefail
cd "$(dirname "$0")/.."
# The check reads the BUILT declaration surface (lib/types) — fail loudly
# instead of silently passing when the build output is missing.
if [ ! -f lib/types/client/service.d.ts ]; then
echo "[check-consumer-types] FAIL: lib/types/client/service.d.ts not found — run 'pnpm build' first." >&2
exit 1
fi
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
mkdir -p "$WORK/node_modules"
ln -s "$(pwd)" "$WORK/node_modules/dsh-better-sidebar"
cat > "$WORK/check.ts" <<'EOF'
import { SIDEBAR_FEATURES, SIDEBAR_SERVICE_VERSION } from 'dsh-better-sidebar/client/service'
import type {} from 'dsh-better-sidebar/client/service'
import type {
BetterSidebarService, FileViewerDescriptor, OpenTabSeed, SidebarSettingsRenderProps,
TabComponentProps, TabDescriptor,
} from 'dsh-better-sidebar/client/service'
import type { SessionScope, SidebarSnapshot, SidebarState, SidebarStore, SidebarTab } from 'dsh-better-sidebar/client/service'
import type { SidebarPrefs } from 'dsh-better-sidebar/client/service'
declare const ctx: { betterSidebar: BetterSidebarService }
const tab: TabDescriptor = {
id: 'x:tab',
title: 'X',
single: true,
badge: (_ctx, _scope, state: SidebarState) => state.expanded.length,
onOpen: (_tab: SidebarTab, _scope: SessionScope) => {},
onClose: (_tab: SidebarTab, _scope: SessionScope) => {},
createTab: (state: SidebarState) => ({ tab: { id: 'x:1', type: 'x:tab', title: 'X', meta: {} } }),
settings: {
toggles: [{ key: 'autoOpenSubagent', title: 'A' }],
pluginToggles: [{ key: 'k', title: 'K', type: 'number', min: 0, max: 9 }],
render: (props: SidebarSettingsRenderProps) => { props.updatePluginSetting('a', 1); return null },
},
component: (props: TabComponentProps) => null,
}
ctx.betterSidebar.registerTab(tab)
const viewer: FileViewerDescriptor = {
id: 'x:csv', exts: ['csv'], fetchStrategy: 'custom',
load: (_path, _scope, signal?: AbortSignal) => { void signal; return Promise.resolve([]) },
component: () => null,
}
ctx.betterSidebar.registerFileViewer(viewer)
const seed: OpenTabSeed = { type: 'x:tab', title: 'X', meta: { a: 1 } }
ctx.betterSidebar.openTab(seed, { sessionId: 's1' })
ctx.betterSidebar.openFile({ sessionId: 's1' }, '/p/a.csv')
ctx.betterSidebar.updateTab('t', { meta: { b: 2 } })
const snap: SidebarSnapshot = ctx.betterSidebar.getSnapshot()
const prefs: SidebarPrefs = snap.prefs
const store: SidebarStore = null as unknown as SidebarStore
void prefs; void store; void ctx.betterSidebar.version; void ctx.betterSidebar.features
void SIDEBAR_SERVICE_VERSION; void SIDEBAR_FEATURES
EOF
cat > "$WORK/tsconfig.json" <<'EOF'
{
"compilerOptions": {
"target": "ES2023", "module": "esnext", "moduleResolution": "bundler",
"strict": true, "noEmit": true, "skipLibCheck": false, "types": [],
"jsx": "react-jsx", "lib": ["ES2023", "DOM"]
},
"include": ["check.ts"]
}
EOF
echo "[check-consumer-types] type-checking a browser-only consumer (no @types/node)..."
echo "[check-consumer-types] pass 1: skipLibCheck: true (the realistic consumer experience) — must be fully clean"
./node_modules/.bin/tsc -p "$WORK/tsconfig.json" --skipLibCheck true
echo "[check-consumer-types] pass 2: skipLibCheck: false — OUR declaration surface (lib/types/**) must be clean"
echo "[check-consumer-types] (upstream cordis/cosmokit declaration noise is filtered out; it predates this plugin)"
set +e
./node_modules/.bin/tsc -p "$WORK/tsconfig.json" --skipLibCheck false > "$WORK/strict.log" 2>&1
STATUS=$?
set -e
# Fail only on errors that mention OUR package / declarations / the fixture.
if grep -E "dsh-better-sidebar|lib/types|check\.ts" "$WORK/strict.log" | grep -v "node_modules/cordis\|node_modules/cosmokit"; then
echo "[check-consumer-types] FAIL: errors in the dsh-better-sidebar declaration surface:" >&2
grep -E "dsh-better-sidebar|lib/types|check\.ts" "$WORK/strict.log" >&2
exit 1
fi
if [ "$STATUS" -ne 0 ]; then
echo "[check-consumer-types] pass 2 note: strict mode only reports upstream declaration noise:"
grep -v "dsh-better-sidebar\|lib/types\|check\.ts" "$WORK/strict.log" | head -5
fi
echo "[check-consumer-types] OK: the client/service declaration surface is node-free and self-contained."
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env bash
# =============================================================================
# dsh-better-sidebar 挂载冒烟编排(CI + 本地):
#
# 1. 用官方 CLI 把 npm 打包产物(tarball)真实挂载进一个全新 scratch
# profile(`dsh plugin --profile web add file:<tarball>`,触发
# dsh.profile.bundles 协调,与用户安装路径一致);
# 2. 启动真实 `dsh web`(keyless,--port 0 取 OS 分配端口);
# 3. 运行 tests/e2e 无头渲染 lane(Playwright Chromium):断言外壳与
# 插件挂载、无崩溃标记,并驱动内置 tab 深扫。
#
# 用法:
# bash scripts/e2e-mount.sh [--grep <playwright-filter>]
#
# 环境变量(均可省略):
# DSH_CMD dsh 命令;缺省 PATH 上的 `dsh`,回退 npx 拉官方包
# TARBALL 插件 tarball;缺省仓库根 dsh-better-sidebar-*.tgz(须已 pack)
# PORT 固定端口(默认 0 = OS 分配,从日志解析 URL)
# DSH_HOME_BASE 覆盖 scratch 根目录(默认 mktemp -d)
# KEEP_HOME 非空时保留 scratch home(调试用)
#
# 退出码 = playwright 的退出码;服务器与 scratch 目录由 trap 兜底清理。
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
DSH_CMD="${DSH_CMD:-dsh}"
PORT="${PORT:-0}"
TARBALL="${TARBALL:-}"
GREP_FILTER=""
if [ "${1:-}" = "--grep" ]; then GREP_FILTER="${2:?--grep 需要参数}"; fi
say() { printf '\033[32m[e2e-mount]\033[0m %s\n' "$*"; }
warn() { printf '\033[33m[e2e-mount]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[31m[e2e-mount]\033[0m %s\n' "$*" >&2; exit 1; }
command -v node >/dev/null 2>&1 || die "未找到 node(DSH 运行需要 Node.js >= 20)"
command -v pnpm >/dev/null 2>&1 || die "未找到 pnpm(dsh plugin 转发给 pnpm)"
# dsh CLI 解析:PATH 上的 dsh 优先,否则 npx 拉官方包(同 scripts/install.sh)
if ! command -v "$DSH_CMD" >/dev/null 2>&1; then
if command -v npx >/dev/null 2>&1; then
say "PATH 上无 $DSH_CMD,回退 npx -y --package @deepseek-ai/dsh"
DSH_CMD="npx -y --package @deepseek-ai/dsh dsh"
else
die "未找到 $DSH_CMD 或 npx;请先安装 DSH CLI(npm i -g @deepseek-ai/dsh)或用 DSH_CMD 指定"
fi
fi
# tarball 解析
if [ -z "$TARBALL" ]; then
TARBALL="$(ls "$ROOT"/dsh-better-sidebar-*.tgz 2>/dev/null | head -1 || true)"
fi
[ -n "$TARBALL" ] && [ -f "$TARBALL" ] || die "找不到 tarball(TARBALL 或 \$ROOT/dsh-better-sidebar-*.tgz)——先运行 pnpm build && pnpm pack"
TARBALL="$(cd "$(dirname "$TARBALL")" && pwd)/$(basename "$TARBALL")"
say "tarball: $TARBALL"
# scratch home(每次全新,绝不触碰真实 ~/.dsh)
SCRATCH="${DSH_HOME_BASE:-$(mktemp -d /tmp/dsh-e2e-mount.XXXXXX)}"
export DSH_HOME="$SCRATCH/home"
WORKSPACE_DIR="$SCRATCH/workspace"
LOG_DIR="$SCRATCH"
WEB_LOG="$LOG_DIR/web.log"
mkdir -p "$DSH_HOME/profiles/web" "$WORKSPACE_DIR"
say "scratch home: ${DSH_HOME}(DSH_HOME=${DSH_HOME})"
SERVER_PID=""
cleanup() {
local code=$?
if [ -n "$SERVER_PID" ] && kill -0 "$SERVER_PID" 2>/dev/null; then
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
fi
if [ -z "${KEEP_HOME:-}" ]; then
rm -rf "$SCRATCH"
else
warn "KEEP_HOME 已设置,保留 $SCRATCH"
fi
exit "$code"
}
trap cleanup EXIT
# 步骤 1:引导 scratch profile(web 模板,镜像 dsh initProfile;先写
# pnpm-workspace.yaml 的 allowBuilds / minimumReleaseAgeExclude,避免 pnpm 11
# strict-dep-builds 拦截 node-pty/protobufjs 或拒绝 <24h 新版本——同 install.sh)
PROFILE_DIR="$DSH_HOME/profiles/web"
cat > "$PROFILE_DIR/package.json" <<EOF
{
"name": "dsh-profile-web",
"private": true,
"dependencies": {},
"dsh": {
"profile": {
"bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"]
}
}
}
EOF
printf '[]\n' > "$PROFILE_DIR/cordis.patch.yml"
cat > "$PROFILE_DIR/pnpm-workspace.yaml" <<'EOF'
packages:
- .
nodeLinker: hoisted
autoInstallPeers: false
allowBuilds:
node-pty: true
protobufjs: true
minimumReleaseAgeExclude:
- dsh-better-sidebar
EOF
# 步骤 2:官方 CLI 安装 tarball + bundle 协调(真实挂载路径)
say "执行 dsh plugin --profile web add file:$TARBALL ..."
$DSH_CMD plugin --profile web add "file:$TARBALL"
# 步骤 3:校验挂载生效(dsh.profile.bundles 含 dsh-better-sidebar)
if ! node -e '
const fs = require("fs");
const p = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
const bundles = p.dsh?.profile?.bundles ?? [];
process.exit(bundles.includes("dsh-better-sidebar") ? 0 : 1);
' "$PROFILE_DIR/package.json"; then
warn "dsh-better-sidebar 未出现在 dsh.profile.bundles 中——挂载未注册"
cat "$PROFILE_DIR/package.json"
exit 1
fi
say "挂载已注册:dsh.profile.bundles 包含 dsh-better-sidebar"
# 步骤 4:启动 dsh web(--port 0 = OS 分配,避免端口冲突;keyless 可起)
say "启动 dsh web(port=${PORT})..."
$DSH_CMD web --port "$PORT" > "$WEB_LOG" 2>&1 &
SERVER_PID=$!
URL=""
for _ in $(seq 1 120); do
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
echo "=== dsh web 提前退出,日志尾部 ===" >&2
tail -30 "$WEB_LOG" >&2 || true
exit 1
fi
if URL="$(grep -oE 'dsh web: http://127\.0\.0\.1:[0-9]+' "$WEB_LOG" | head -1 | awk '{print $3}')" && [ -n "$URL" ]; then
break
fi
sleep 1
done
[ -n "$URL" ] || { echo "=== 120s 内未等到 dsh web 就绪,日志尾部 ===" >&2; tail -40 "$WEB_LOG" >&2 || true; exit 1; }
say "dsh web 就绪:${URL}(pid ${SERVER_PID})"
# 步骤 5:运行无头渲染 lane
say "运行 Playwright 无头渲染 lane..."
DSH_E2E_URL="$URL" DSH_E2E_WORKSPACE="$WORKSPACE_DIR" \
pnpm exec playwright test ${GREP_FILTER:+--grep "$GREP_FILTER"}
say "通过:插件挂载到真实 DSH 后无头渲染未崩溃"
+289
View File
@@ -0,0 +1,289 @@
## =============================================================================
# dsh-better-sidebar 一键安装脚本(官方 CLI 方式,Windows PowerShell 5.1+ / pwsh)
#
# 通过 DSH 官方插件命令安装 npm 包并自动挂载:
# dsh plugin --profile web add dsh-better-sidebar@<version>
#
# 包内声明了 dsh.bundle.patch(cordis.patch.yml):CLI 的 bundle 协调会把它
# 自动加进 profile 的 dsh.profile.bundles,下次启动即挂载——无需手动写
# cordis.patch.yml 挂载行。符合仓库硬约束:不修改 DSH 源码,插件永远作为
# 独立包被 profile 引用。
#
# 用法(任选其一):
# # 默认最新版
# irm https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1 | iex
# # 指定版本 / 装完重启
# & ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.10.2 -Restart
# # 本地保存后运行
# powershell -ExecutionPolicy Bypass -File install.ps1 -Version 0.10.2 -DryRun
# # 修复 node-pty 依赖(终端提示「node-pty 加载失败」时用,见 issue #140)
# powershell -ExecutionPolicy Bypass -File install.ps1 -Repair
#
# 参数:
# -Version npm 版本号/范围,缺省 latest(自动解析为最新)。
# -Repair 修复模式:不重装插件,只确保 profile 的 pnpm-workspace.yaml
# 放行 node-pty 构建脚本,然后重跑 pnpm install + pnpm rebuild
# node-pty。
# -Profile 目标 profile 名(缺省 web);安装与修复模式均适用。
# -Restart 装完后尝试 `pm2 restart dsh-web`(无 pm2 时仅提示)。会断开当前页面会话。
# -DryRun 只打印将要执行的操作,不写任何文件。
#
# 环境变量(均可省略):
# DSH_HOME 默认 %USERPROFILE%\.dsh
# REGISTRY 默认 https://registry.npmjs.org
# DSH_CMD 默认优先 PATH 上的 dsh,缺省回退 npx -y --package @deepseek-ai/dsh
#
# 说明:
# - pnpm 11 的 strict-dep-builds 会拦截 node-pty/protobufjs 的构建脚本并使
# `dsh plugin add` 非零退出。脚本会先把这两个构建许可写进 profile 的
# pnpm-workspace.yaml(幂等),保证 CLI 一步成功。
# - pnpm 11 的 minimumReleaseAge 会拒绝发布 <24h 的新版本。脚本会预写
# minimumReleaseAgeExclude(幂等),放行本插件,避免"重跑一次才成功"。
# - 老版本(<0.10.2)用手动挂载行,bundle 通道激活后需移除,否则双挂载。
# 脚本会幂等移除 better-sidebar 挂载行。
# =============================================================================
param(
[string]$Version = '',
[switch]$Restart,
[switch]$DryRun,
[switch]$Repair,
[string]$Profile = 'web'
)
$PKG = 'dsh-better-sidebar'
$REGISTRY = if ($env:REGISTRY) { $env:REGISTRY } else { 'https://registry.npmjs.org' }
# DSH_HOME:DSH_HOME 环境变量 > %USERPROFILE% > $HOME
if ($env:DSH_HOME) {
$DSH_HOME = $env:DSH_HOME
} elseif ($env:USERPROFILE) {
$DSH_HOME = Join-Path $env:USERPROFILE '.dsh'
} else {
$DSH_HOME = Join-Path $HOME '.dsh'
}
$PROFILE_DIR = Join-Path $DSH_HOME "profiles\$Profile"
$WS_YML = Join-Path $PROFILE_DIR 'pnpm-workspace.yaml'
$PATCH_YML = Join-Path $PROFILE_DIR 'cordis.patch.yml'
function Say([string]$m) { Write-Host "[install] $m" -ForegroundColor Green }
function Warn([string]$m) { Write-Host "[warn] $m" -ForegroundColor Yellow }
function Die([string]$m) { Write-Host "[error] $m" -ForegroundColor Red; exit 1 }
# 解析版本 -> npm spec("x.y.z" / "^x.y.z" / latest)
function Resolve-Spec {
param([string]$Given)
if ([string]::IsNullOrWhiteSpace($Given) -or $Given -eq 'latest') {
$v = $null
foreach ($tool in @('npm', 'pnpm')) {
if (Get-Command $tool -ErrorAction SilentlyContinue) {
$v = (& $tool view $PKG version "--registry=$REGISTRY" 2>$null | Select-Object -Last 1)
if ($v) { break }
}
}
if ($v) { return ([string]$v).Trim() }
Warn '无法联网解析最新版本(npm/pnpm 查询失败),回退为 latest,由 pnpm 直接解析。'
Warn '若已知版本号,可显式传入:-Version 0.10.2'
return 'latest'
}
return $Given
}
# 组装 dsh CLI:优先 PATH 上的 dsh,缺省 npx 拉官方包
function Get-DshCli {
if ($env:DSH_CMD) { return $env:DSH_CMD }
if (Get-Command dsh -ErrorAction SilentlyContinue) { return 'dsh' }
if (Get-Command npx -ErrorAction SilentlyContinue) { return 'npx' }
return $null
}
# 步骤 1(安装与修复共用):预写 workspace 设置(幂等),保证 pnpm 不拦截
# node-pty/protobufjs 构建脚本、放行本插件新版本
function Ensure-WorkspaceSettings {
$wsScript = @'
const fs = require("fs");
const p = process.argv[2];
let t = fs.readFileSync(p, "utf8");
const before = t;
t = t.replace(/^(\s*)(node-pty|protobufjs):.*$/gm, "$1$2: true");
if (!/^\s*allowBuilds:\s*$/m.test(t)) {
t += "\nallowBuilds:\n node-pty: true\n protobufjs: true\n";
} else {
for (const k of ["node-pty", "protobufjs"]) {
if (!new RegExp("^\\s*" + k + ":\\s*true\\s*$", "m").test(t)) {
t = t.replace(/^(\s*allowBuilds:\s*)$/m, "$1\n " + k + ": true");
}
}
}
if (!/^\s*-\s+dsh-better-sidebar\s*$/m.test(t)) {
if (/^\s*minimumReleaseAgeExclude:\s*$/m.test(t)) {
t = t.replace(/^(\s*minimumReleaseAgeExclude:\s*)$/m, "$1\n - dsh-better-sidebar");
} else {
t += "\nminimumReleaseAgeExclude:\n - dsh-better-sidebar\n";
}
}
if (t !== before) fs.writeFileSync(p, t);
console.log(t === before ? "unchanged" : "updated");
'@
# PowerShell 5.1 把含内嵌双引号的多行 JS 作为参数传给 `node -e` 时,引号会被
# Windows 命令行解析吞掉,导致 JS 语法错误(Expected ',', got ':')。
# 改用临时文件方式,兼容 PS 5.1 与 pwsh 7。
$wsJs = Join-Path $env:TEMP ("dshbs-ws-" + [guid]::NewGuid().ToString("N") + ".js")
Set-Content -LiteralPath $wsJs -Value $wsScript -Encoding UTF8
$wsOut = node $wsJs "$WS_YML" 2>&1
$wsCode = $LASTEXITCODE
Remove-Item -LiteralPath $wsJs -Force -ErrorAction SilentlyContinue
$wsResult = (($wsOut | Out-String)).Trim()
if ($wsCode -ne 0) { Die "处理 $WS_YML 失败(node 退出码 $wsCode):$wsResult" }
if ($wsResult -eq 'updated') {
Say "已确保 $WS_YML:allowBuilds(node-pty/protobufjs: true)+ minimumReleaseAgeExclude($PKG)"
} else {
Say 'workspace 设置已就绪,跳过'
}
}
# 前置校验
if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
Die '未找到 node(DSH 运行需要 Node.js >= 20),请先安装 Node.js 并加入 PATH。'
}
if (-not (Test-Path $PROFILE_DIR)) {
Die "找不到 profile 目录:$PROFILE_DIR(请先安装并运行过一次 dsh web)"
}
if (-not (Test-Path $WS_YML)) {
Die "找不到 $WS_YML(请先初始化 $Profile profile)"
}
# ── 修复模式(issue #140):不重装插件,只修复 node-pty 依赖 ────────────
# 终端提示「node-pty 加载失败」时运行:确保 allowBuilds 后重跑
# pnpm install + pnpm rebuild node-pty(重放被 pnpm 11 拦截的构建脚本)。
if ($Repair) {
if ($DryRun) {
Say "[dry-run] 修复:确保 $WS_YML 含 allowBuilds(node-pty: true)"
Say "[dry-run] 修复:cd $PROFILE_DIR; pnpm install; pnpm rebuild node-pty"
exit 0
}
Say "修复模式:重装 node-pty(profile: $Profile,$PROFILE_DIR)..."
Ensure-WorkspaceSettings
Push-Location $PROFILE_DIR
try {
pnpm install
if ($LASTEXITCODE -ne 0) { Die "pnpm install 失败(退出码 $LASTEXITCODE)。请确认 pnpm 在 PATH 上、网络可用,然后重试。" }
pnpm rebuild node-pty
if ($LASTEXITCODE -ne 0) { Die "pnpm rebuild node-pty 失败(退出码 $LASTEXITCODE)。请确认 pnpm 在 PATH 上,然后重试。" }
} finally {
Pop-Location
}
Say '修复完成:node-pty 已重装(与 DSH 核心保持同一版本)。请重启 DSH 后重试终端。'
exit 0
}
$SPEC = Resolve-Spec $Version
$CLI = Get-DshCli
if (-not $CLI) {
Die '未找到 dsh 或 npx。请先安装 DSH(并确保 Node/npm 可用),或用 DSH_CMD 指定。'
}
Say "目标:$CLI plugin --profile $Profile add $PKG@$SPEC(profile: $PROFILE_DIR)"
if ($DryRun) {
Say "[dry-run] 步骤 1:确保 $WS_YML 含 allowBuilds(node-pty/protobufjs: true)与 minimumReleaseAgeExclude($PKG)"
Say "[dry-run] 步骤 2:执行 $CLI plugin --profile $Profile add $PKG@$SPEC(安装 + bundle 自动注册)"
Say "[dry-run] 步骤 3:校验 dsh.profile.bundles 含 $PKG"
Say "[dry-run] 步骤 4:幂等移除 $PATCH_YML 里旧的 better-sidebar 手动挂载行(避免双挂载)"
if ($Restart) { Say '[dry-run] 步骤 5:pm2 restart dsh-web' } else { Say '[dry-run] 步骤 5:提示用户手动重启 DSH' }
exit 0
}
# 步骤 1:预写 workspace 设置(幂等),保证 pnpm 不拦截构建、放行本插件新版本
Ensure-WorkspaceSettings
# 步骤 2:官方 CLI 安装 + bundle 自动注册(含挂载)
if ($CLI -eq 'dsh') {
$cliArgs = @('plugin', '--profile', $Profile, 'add', "$PKG@$SPEC")
} else {
$cliArgs = @('-y', '--package', '@deepseek-ai/dsh', 'dsh', 'plugin', '--profile', $Profile, 'add', "$PKG@$SPEC")
}
Say "执行 $CLI plugin --profile $Profile add $PKG@$SPEC ..."
$addOut = & $CLI @cliArgs 2>&1
$addCode = $LASTEXITCODE
$addOut | ForEach-Object { $_ }
if ($addCode -ne 0) {
Warn 'dsh plugin add 失败。已预写 allowBuilds 与 minimumReleaseAgeExclude,仍失败的可能原因:'
Warn ' - 网络/登录问题:npm registry 不可达或需要登录。'
Warn " - 依赖安装冲突:可手动重试 cd $PROFILE_DIR; pnpm install。"
exit 1
}
# 步骤 3:校验 bundle 已注册(挂载生效的判据)
$pkgJson = Get-Content -Raw (Join-Path $PROFILE_DIR 'package.json') | ConvertFrom-Json
$bundles = $pkgJson.dsh.profile.bundles
if ($bundles -notcontains $PKG) {
Warn 'dsh-better-sidebar 未出现在 dsh.profile.bundles 中——挂载未注册。'
Warn "若上面的 pnpm 输出提示 ignored build scripts,请确认 $WS_YML 的 allowBuilds 后重跑本脚本。"
exit 1
}
Say "bundle 已注册:dsh.profile.bundles 包含 $PKG(下次启动自动挂载)"
# 步骤 4:幂等移除旧的 manual 挂载行(避免与 bundle 双挂载)
$mountScript = @'
const fs = require("fs");
const p = process.argv[2];
const lines = fs.readFileSync(p, "utf8").split("\n");
const out = [];
let i = 0;
let removed = false;
while (i < lines.length) {
const line = lines[i];
if (/^[ \t]*- insert:\s*$/.test(line)) {
const block = [line];
let j = i + 1;
while (j < lines.length && lines[j].trim() !== "" && !/^-\s/.test(lines[j])) {
block.push(lines[j]);
j++;
}
if (block.some((l) => /id:\s*better-sidebar\b/.test(l))) {
while (out.length && /^[ \t]*#/.test(out[out.length - 1])) out.pop();
i = j;
removed = true;
continue;
}
}
out.push(line);
i++;
}
if (!removed) {
console.log("none");
} else {
const t = out.join("\n").replace(/\n{3,}/g, "\n\n");
fs.writeFileSync(p, t);
console.log("removed");
}
'@
$mountJs = Join-Path $env:TEMP ("dshbs-mount-" + [guid]::NewGuid().ToString("N") + ".js")
Set-Content -LiteralPath $mountJs -Value $mountScript -Encoding UTF8
$mountOut = node $mountJs "$PATCH_YML" 2>&1
$mountCode = $LASTEXITCODE
Remove-Item -LiteralPath $mountJs -Force -ErrorAction SilentlyContinue
$mountResult = (($mountOut | Out-String)).Trim()
if ($mountCode -ne 0) { Die "处理 $PATCH_YML 失败(node 退出码 $mountCode):$mountResult" }
if ($mountResult -eq 'removed') {
Say "已从 $PATCH_YML 移除旧的 better-sidebar 手动挂载行(bundle 通道接管挂载)"
} else {
Say '无旧手动挂载行,跳过'
}
Say "安装完成:$PKG@$SPEC"
# 步骤 5:重启提示
if ($Restart) {
if (Get-Command pm2 -ErrorAction SilentlyContinue) {
Say '重启 dsh-web(pm2)...'
pm2 restart dsh-web
if ($LASTEXITCODE -ne 0) { Warn 'pm2 restart 失败,请手动重启 DSH' }
} else {
Warn '未找到 pm2,请手动重启 DSH(如:pm2 restart dsh-web 或 dsh web)'
}
} else {
Say '下一步:重启 DSH 并硬刷新(Ctrl+Shift+R / Cmd+Shift+R)使新副本生效。'
if (Get-Command pm2 -ErrorAction SilentlyContinue) {
Say '本机可用:pm2 restart dsh-web(会短暂断开当前页面会话)'
}
}
+287
View File
@@ -0,0 +1,287 @@
#!/usr/bin/env bash
# =============================================================================
# dsh-better-sidebar 一键安装脚本(官方 CLI 方式,macOS / Linux / Windows Git Bash)
#
# 通过 DSH 官方插件命令安装 npm 包并自动挂载:
# dsh plugin --profile web add dsh-better-sidebar@<version>
#
# 包内声明了 dsh.bundle.patch(cordis.patch.yml):CLI 的 bundle 协调会把它
# 自动加进 profile 的 dsh.profile.bundles,下次启动即挂载——无需手动写
# cordis.patch.yml 挂载行。符合仓库硬约束:不修改 DSH 源码,插件永远作为
# 独立包被 profile 引用。
#
# 用法:
# bash scripts/install.sh [版本] [--restart] [--dry-run]
# bash scripts/install.sh --repair [--profile <名>] [--dry-run]
#
# 版本 npm 版本号/范围,缺省为 latest(自动解析为 ^<最新>)。
# 示例:0.10.2、^0.10.2、~0.10.2、latest
# --repair 修复模式:不重装插件,只确保 profile 的 pnpm-workspace.yaml
# 放行 node-pty 构建脚本,然后重跑 pnpm install + pnpm rebuild
# node-pty(终端提示「node-pty 加载失败」时用它,见 issue #140)。
# --profile 目标 profile 名(缺省 web);安装与修复模式均适用。
# --restart 装完后尝试 `pm2 restart dsh-web`(无 pm2 时仅打印提示)。
# 注意:重启会断开当前 DSH 页面会话,默认不自动重启。
# --dry-run 只打印将要执行的操作,不写任何文件。
# -h/--help 打印本帮助。
#
# 环境(均可省略,脚本会自动探测):
# DSH_HOME 默认 ~/.dsh(Windows Git Bash 下回退 $USERPROFILE/.dsh)
# REGISTRY 默认 https://registry.npmjs.org(发布源;装依赖仍走 pnpm 配置)
# DSH_CMD 默认优先用 PATH 上的 `dsh`,缺省回退 npx -y --package @deepseek-ai/dsh
#
# 说明:
# - pnpm 11 的 strict-dep-builds 会拦截 node-pty/protobufjs 的构建脚本并使
# `dsh plugin add` 非零退出(bundle 协调因此被跳过)。脚本会先把这两个
# 构建许可写进 profile 的 pnpm-workspace.yaml(幂等),保证 CLI 一步成功。
# - pnpm 11 的 minimumReleaseAge 会拒绝发布 <24h 的新版本。脚本会预写
# minimumReleaseAgeExclude(幂等),放行本插件,避免"重跑一次才成功"。
# - 老版本(<0.10.2)用手动挂载行,bundle 通道激活后需移除,否则双挂载
# (Node 半挂两次、页面两个侧边栏)。脚本会幂等移除 better-sidebar 挂载行。
# - 回滚:dsh plugin --profile web remove dsh-better-sidebar,或把 profile 依赖
# 改回 "dsh-better-sidebar": "link:<路径>" 再 pnpm install。
# =============================================================================
set -euo pipefail
# 帮助请求优先处理
for arg in "$@"; do
if [ "$arg" = "-h" ] || [ "$arg" = "--help" ]; then
cat <<'EOF'
dsh-better-sidebar 一键安装 / 依赖修复脚本
用法:
bash scripts/install.sh [版本] [--restart] [--dry-run] [--profile <名>]
bash scripts/install.sh --repair [--profile <名>] [--dry-run]
版本 npm 版本号/范围,缺省 latest(自动解析为最新)。示例:0.10.2、^0.10.2、latest
--repair 修复模式:确保 profile 放行 node-pty 构建脚本并重装 node-pty(终端提示依赖加载失败时用)
--profile 目标 profile 名(缺省 web)
--restart 装完后尝试 `pm2 restart dsh-web`(无 pm2 时仅提示)
--dry-run 只打印将要执行的操作,不写任何文件
环境变量(可省略):DSH_HOME(默认 ~/.dsh)、REGISTRY(npm 源)、DSH_CMD(dsh 命令)
EOF
exit 0
fi
done
DSH_HOME="${DSH_HOME:-${HOME:-${USERPROFILE:-}}/.dsh}"
REGISTRY="${REGISTRY:-https://registry.npmjs.org}"
PKG="dsh-better-sidebar"
DSH_CMD="${DSH_CMD:-dsh}"
RESTART=false
DRY_RUN=false
REPAIR=false
VERSION_SPEC=""
PROFILE_NAME="web"
while [ $# -gt 0 ]; do
case "$1" in
--restart) RESTART=true ;;
--dry-run) DRY_RUN=true ;;
--repair) REPAIR=true ;;
--profile)
if [ $# -lt 2 ]; then echo "--profile 需要一个 profile 名(如 web)" >&2; exit 2; fi
PROFILE_NAME="$2"; shift ;;
-h|--help) : ;; # 已在上面处理
-*) echo "未知参数: ${1}(用 -h 查看用法)" >&2; exit 2 ;;
*) VERSION_SPEC="$1" ;;
esac
shift
done
PROFILE_DIR="$DSH_HOME/profiles/$PROFILE_NAME"
WS_YML="$PROFILE_DIR/pnpm-workspace.yaml"
PATCH_YML="$PROFILE_DIR/cordis.patch.yml"
say() { printf '\033[32m[install]\033[0m %s\n' "$*"; }
warn() { printf '\033[33m[warn]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[31m[error]\033[0m %s\n' "$*" >&2; exit 1; }
# 步骤 1(安装与修复共用):预写 workspace 设置(幂等),保证 pnpm 不拦截
# node-pty/protobufjs 构建脚本、放行本插件新版本
ensure_workspace_settings() {
WS_RESULT="$(node -e '
const fs = require("fs");
const p = process.argv[1];
let t = fs.readFileSync(p, "utf8");
const before = t;
// allowBuilds:把 node-pty/protobufjs 归位为 true
t = t.replace(/^(\s*)(node-pty|protobufjs):.*$/gm, "$1$2: true");
if (!/^\s*allowBuilds:\s*$/m.test(t)) {
t += "\nallowBuilds:\n node-pty: true\n protobufjs: true\n";
} else {
for (const k of ["node-pty", "protobufjs"]) {
if (!new RegExp("^\\s*" + k + ":\\s*true\\s*$", "m").test(t)) {
t = t.replace(/^(\s*allowBuilds:\s*)$/m, "$1\n " + k + ": true");
}
}
}
// minimumReleaseAgeExclude:放行本插件(版本无关),避免 <24h 新版本被拒
if (!/^\s*-\s+dsh-better-sidebar\s*$/m.test(t)) {
if (/^\s*minimumReleaseAgeExclude:\s*$/m.test(t)) {
t = t.replace(/^(\s*minimumReleaseAgeExclude:\s*)$/m, "$1\n - dsh-better-sidebar");
} else {
t += "\nminimumReleaseAgeExclude:\n - dsh-better-sidebar\n";
}
}
if (t !== before) fs.writeFileSync(p, t);
console.log(t === before ? "unchanged" : "updated");
' "$WS_YML")"
[ "$WS_RESULT" = "updated" ] \
&& say "已确保 ${WS_YML}:allowBuilds(node-pty/protobufjs: true)+ minimumReleaseAgeExclude(${PKG})" \
|| say "workspace 设置已就绪,跳过"
}
# 解析用户给的版本 -> CLI 要用的 npm spec("x.y.z" / "^x.y.z" / latest)
resolve_spec() {
local given="${1:-latest}"
case "$given" in
latest)
local v=""
if command -v npm >/dev/null 2>&1; then
v="$(npm view "$PKG" version --registry="$REGISTRY" 2>/dev/null)" || v=""
fi
if [ -z "$v" ] && command -v pnpm >/dev/null 2>&1; then
v="$(pnpm view "$PKG" version --registry="$REGISTRY" 2>/dev/null)" || v=""
fi
if [ -n "$v" ]; then
printf '%s' "$v"
else
warn "无法联网解析最新版本(npm/pnpm 查询失败),回退为 latest,由 pnpm 直接解析。"
warn "若已知版本号,可显式传入:bash scripts/install.sh 0.10.2"
printf 'latest'
fi
;;
*) printf '%s' "$given" ;;
esac
}
# 组装 dsh CLI 调用:优先 PATH 上的 dsh,缺省 npx 拉官方包
dsh_cli() {
if command -v "$DSH_CMD" >/dev/null 2>&1; then
printf '%s' "$DSH_CMD"
elif command -v npx >/dev/null 2>&1; then
printf 'npx -y --package @deepseek-ai/dsh dsh'
else
die "未找到 dsh 或 npx。请先安装 DSH(并确保 Node/npm 可用),或用 DSH_CMD 指定 dsh 路径。"
fi
}
# 前置校验
command -v node >/dev/null 2>&1 || die "未找到 node(DSH 运行需要 Node.js ≥ 20),请先安装 Node.js 并加入 PATH。"
[ -d "$PROFILE_DIR" ] || die "找不到 profile 目录:${PROFILE_DIR}(请先安装并运行过一次 dsh web)"
[ -f "$WS_YML" ] || die "找不到 ${WS_YML}(请先初始化 ${PROFILE_NAME} profile)"
# ── 修复模式(issue #140):不重装插件,只修复 node-pty 依赖 ────────────
# 终端提示「node-pty 加载失败」时运行:确保 allowBuilds 后重跑
# pnpm install + pnpm rebuild node-pty(重放被 pnpm 11 拦截的构建脚本)。
if [ "$REPAIR" = true ]; then
if [ "$DRY_RUN" = true ]; then
say "[dry-run] 修复:确保 $WS_YML 含 allowBuilds(node-pty: true)"
say "[dry-run] 修复:cd $PROFILE_DIR && pnpm install && pnpm rebuild node-pty"
exit 0
fi
say "修复模式:重装 node-pty(profile: ${PROFILE_NAME},${PROFILE_DIR})..."
ensure_workspace_settings
if ! (cd "$PROFILE_DIR" && pnpm install && pnpm rebuild node-pty); then
die "修复失败:pnpm install / pnpm rebuild node-pty 非零退出。请确认 pnpm 在 PATH 上、网络可用,然后重试。"
fi
say "修复完成:node-pty 已重装(与 DSH 核心保持同一版本)。请重启 DSH 后重试终端。"
exit 0
fi
SPEC="$(resolve_spec "$VERSION_SPEC")"
CLI="$(dsh_cli)"
say "目标:$CLI plugin --profile $PROFILE_NAME add $PKG@${SPEC}(profile: ${PROFILE_DIR})"
if [ "$DRY_RUN" = true ]; then
say "[dry-run] 步骤 1:确保 $WS_YML 含 allowBuilds(node-pty/protobufjs: true)与 minimumReleaseAgeExclude(${PKG})"
say "[dry-run] 步骤 2:执行 $CLI plugin --profile $PROFILE_NAME add $PKG@${SPEC}(安装 + bundle 自动注册)"
say "[dry-run] 步骤 3:校验 dsh.profile.bundles 含 $PKG"
say "[dry-run] 步骤 4:幂等移除 $PATCH_YML 里旧的 better-sidebar 手动挂载行(避免双挂载)"
if [ "$RESTART" = true ]; then say "[dry-run] 步骤 5:pm2 restart dsh-web"; else say "[dry-run] 步骤 5:提示用户手动重启 DSH"; fi
exit 0
fi
# 步骤 1:预写 workspace 设置(幂等),保证 pnpm 不拦截构建、不放行旧版本策略
ensure_workspace_settings
# 步骤 2:官方 CLI 安装 + bundle 自动注册(含挂载)
say "执行 $CLI plugin --profile $PROFILE_NAME add $PKG@$SPEC ..."
if ! $CLI plugin --profile "$PROFILE_NAME" add "$PKG@$SPEC" 2>&1 | tail -n +1; then
warn "dsh plugin add 失败。已预写 allowBuilds 与 minimumReleaseAgeExclude,仍失败的可能原因:"
warn " - 网络/登录问题:npm registry 不可达或需要登录。"
warn " - 依赖安装冲突:可手动重试 cd $PROFILE_DIR && pnpm install。"
exit 1
fi
# 校验 bundle 已注册(挂载生效的判据)
if ! node -e '
const fs = require("fs");
const p = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
const bundles = p.dsh?.profile?.bundles ?? [];
process.exit(bundles.includes(process.argv[2]) ? 0 : 1);
' "$PROFILE_DIR/package.json" "$PKG"; then
warn "dsh-better-sidebar 未出现在 dsh.profile.bundles 中——挂载未注册。"
warn "若上面的 pnpm 输出提示 ignored build scripts,请确认 $WS_YML 的 allowBuilds 后重跑本脚本。"
exit 1
fi
say "bundle 已注册:dsh.profile.bundles 包含 ${PKG}(下次启动自动挂载)"
# 步骤 3:幂等移除旧的 manual 挂载行(避免与 bundle 双挂载)
MOUNT_RESULT="$(node -e '
const fs = require("fs");
const p = process.argv[1];
const lines = fs.readFileSync(p, "utf8").split("\n");
const out = [];
let i = 0;
let removed = false;
while (i < lines.length) {
const line = lines[i];
if (/^[ \t]*- insert:\s*$/.test(line)) {
const block = [line];
let j = i + 1;
while (j < lines.length && lines[j].trim() !== "" && !/^-\s/.test(lines[j])) {
block.push(lines[j]);
j++;
}
if (block.some((l) => /id:\s*better-sidebar\b/.test(l))) {
while (out.length && /^[ \t]*#/.test(out[out.length - 1])) out.pop();
i = j;
removed = true;
continue;
}
}
out.push(line);
i++;
}
if (!removed) {
console.log("none");
} else {
const t = out.join("\n").replace(/\n{3,}/g, "\n\n");
fs.writeFileSync(p, t);
console.log("removed");
}
' "$PATCH_YML")"
[ "$MOUNT_RESULT" = "removed" ] \
&& say "已从 $PATCH_YML 移除旧的 better-sidebar 手动挂载行(bundle 通道接管挂载)" \
|| say "无旧手动挂载行,跳过"
say "安装完成:$PKG@$SPEC"
# 步骤 4:重启提示
if [ "$RESTART" = true ]; then
if command -v pm2 >/dev/null 2>&1; then
say "重启 dsh-web(pm2)..."
pm2 restart dsh-web || warn "pm2 restart 失败,请手动重启 DSH"
else
warn "未找到 pm2,请手动重启 DSH(如:pm2 restart dsh-web 或 dsh web)"
fi
else
say "下一步:重启 DSH 并硬刷新(Cmd/Ctrl+Shift+R)使新副本生效。"
if command -v pm2 >/dev/null 2>&1; then
say "本机可用:pm2 restart dsh-web(会短暂断开当前页面会话)"
fi
fi
+6
View File
@@ -29,6 +29,12 @@ const files = [
'lib/invariant.js',
'lib/client-registry.js',
'lib/client-registry.js.map',
// Lazy chunks: the client fetches these from the host's /sidebar/bundle
// route on first use (see src/client/chunk-loader.ts / src/bundle-route.ts).
'lib/client-terminal.js',
'lib/client-terminal.js.map',
'lib/client-editor.js',
'lib/client-editor.js.map',
'README.md',
'LICENSE',
]
+10 -5
View File
@@ -12,8 +12,9 @@
* 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 type { IPty } from 'node-pty'
import { ensureSpawnHelper, shellSpawnArgs } from './pty-manager.ts'
import { loadRequiredNodePty, type NodePtyModule } from './pty-deps.ts'
import { SidebarError } from './wire.ts'
/** Per-agent-terminal transcript bound (bytes kept for replay and reads). */
@@ -106,7 +107,7 @@ export interface AgentTerminalHandle {
/** The working directory the process was spawned with. */
cwd: string
/** The live pty process. */
pty: nodePty.IPty
pty: IPty
/** Output accumulated since spawn (bounded; head dropped when over the limit). */
transcript: string
/** Whether the top-level process exited (transcript stays replayable). */
@@ -189,7 +190,11 @@ export class AgentPtyRegistry {
private readonly sessions = new Map<string, AgentTerminalHandle>()
private readonly changeListeners = new Set<() => void>()
constructor(private readonly shell: string) {
constructor(
private readonly shell: string,
/** The loaded node-pty module (injected so a broken install degrades instead of crashing the plugin). */
private readonly nodePty: NodePtyModule = loadRequiredNodePty(),
) {
ensureSpawnHelper()
}
@@ -211,7 +216,7 @@ export class AgentPtyRegistry {
): string {
const uuid = randomUUID()
const dims = clampDims(cols, rows)
const pty = nodePty.spawn(this.shell, [], {
const pty = this.nodePty.spawn(this.shell, shellSpawnArgs(), {
name: 'xterm-256color',
cols: dims.cols,
rows: dims.rows,
+26
View File
@@ -0,0 +1,26 @@
/**
* Pure helpers for the `browser.probe` route (sidebar browser): the host
* fetches the response HEADERS of a URL the user is browsing and the client
* decides whether the target site forbids being embedded (X-Frame-Options /
* CSP frame-ancestors are exactly the signals the browser enforces when it
* refuses an iframe load). Kept dependency-free so the parser is
* unit-testable.
*/
/**
* Extract the `frame-ancestors` source list of a Content-Security-Policy
* header, or undefined when the directive is absent (or empty). The
* directive is the only one with a source list; sources are space-separated
* tokens (`'none'`, `'self'`, `*`, or origins).
*/
export function extractFrameAncestors(csp: string | null): string[] | undefined {
if (csp === null) return undefined
for (const directive of csp.split(';')) {
const parts = directive.trim().split(/\s+/)
if (parts[0] === 'frame-ancestors') {
const sources = parts.slice(1).filter(source => source !== '')
return sources.length === 0 ? undefined : sources
}
}
return undefined
}
+129
View File
@@ -0,0 +1,129 @@
/**
* Lazy chunk route: serves the client bundle's chunk scripts
* (/sidebar/bundle/<name>.js). The official /plugins/<id>/client.js route
* cannot serve arbitrary file names, so the plugin serves its own split
* bundles (lib/client-<name>.js) here; the client injects the script on
* first use of the feature that needs it (see src/client/chunk-loader.ts).
*
* Caching contract: every response carries `cache-control: no-cache` plus an
* ETag (content hash, memoized per file by mtime/size) and honors
* If-None-Match — the browser revalidates each fetch, but a 304 avoids
* re-downloading multi-MB chunks that did not change (page refresh, HMR
* re-activation). Same browser-trust fence as every other /sidebar route;
* only allowlisted chunk names are servable (no path traversal).
*/
import { createHash } from 'node:crypto'
import { stat, readFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Context, SidebarHttpRequest, SidebarHttpResponse } from './context-types.ts'
/** The chunk names the client may request (mirror of src/client/chunk-loader.ts). */
export const CHUNK_NAMES = ['terminal', 'editor'] as const
export type ChunkName = (typeof CHUNK_NAMES)[number]
/** Directory of this host-half module (lib/ — the chunk scripts live next to it). */
const LIB_DIR = dirname(fileURLToPath(import.meta.url))
/** sha1 content hash shortened to 12 hex chars (same shape as the client-modules rev). */
function shortHash(input: string | Buffer): string {
return createHash('sha1').update(input).digest('hex').slice(0, 12)
}
interface ChunkEtag {
mtimeMs: number
size: number
etag: string
}
/** ETag memo: recompute the content hash only when the file's stat changed. */
const etags = new Map<string, ChunkEtag>()
/**
* The chunk file's ETag (quoted hash), or undefined when the file is
* missing. Hash is recomputed only when mtime/size changed (hashing a
* multi-MB chunk per request is wasteful).
*/
async function etagOf(name: ChunkName, chunkDir: string): Promise<string | undefined> {
const path = join(chunkDir, `client-${name}.js`)
const key = `${chunkDir}:${name}`
try {
const info = await stat(path)
const memo = etags.get(key)
if (memo !== undefined && memo.mtimeMs === info.mtimeMs && memo.size === info.size) {
return memo.etag
}
const etag = `"${shortHash(await readFile(path))}"`
etags.set(key, { mtimeMs: info.mtimeMs, size: info.size, etag })
return etag
} catch {
return undefined
}
}
/**
* Build the /sidebar/bundle route handler. `fence` is the shared browser-
* trust check every /sidebar route applies; `chunkDir` is the directory the
* chunk scripts live in (overridable for tests).
*/
export function createBundleRouteHandler(
fence: (req: SidebarHttpRequest) => boolean,
chunkDir: string = LIB_DIR,
): (req: SidebarHttpRequest, res: SidebarHttpResponse) => Promise<void> {
return async (req, res): Promise<void> => {
if (!fence(req)) {
res.writeHead(403)
res.end('forbidden')
return
}
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.writeHead(405)
res.end()
return
}
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
const match = /^\/sidebar\/bundle\/([a-z0-9-]+)\.js$/.exec(pathname)
const name = match?.[1] as ChunkName | undefined
if (name === undefined || !(CHUNK_NAMES as readonly string[]).includes(name)) {
res.writeHead(404)
res.end('not found')
return
}
const etag = await etagOf(name, chunkDir)
if (etag === undefined) {
// Registered name but unreadable (bundle not built yet): loud 404.
res.writeHead(404)
res.end('not found')
return
}
if (req.headers['if-none-match'] === etag) {
// Revalidation hit: unchanged chunk, no body — avoids re-downloading
// multi-MB scripts on page refresh / HMR re-activation.
res.writeHead(304, { 'cache-control': 'no-cache', etag })
res.end()
return
}
try {
const body = await readFile(join(chunkDir, `client-${name}.js`))
res.writeHead(200, {
'content-type': 'text/javascript; charset=utf-8',
'cache-control': 'no-cache',
etag,
})
res.end(body)
} catch {
// Read raced a delete/rebuild between the stat and the read.
res.writeHead(404)
res.end('not found')
}
}
}
/** Register the /sidebar/bundle route (disposed with the fiber). */
export function registerBundleRoute(ctx: Context, fence: (req: SidebarHttpRequest) => boolean): () => void {
return ctx.webServer.register({
kind: 'prefix',
path: '/sidebar/bundle',
handler: createBundleRouteHandler(fence),
})
}
+253
View File
@@ -0,0 +1,253 @@
/**
* The built-in browser tab: an address bar plus a sandboxed iframe.
*
* Security model (see browser.ts + the sandbox tokens below): the iframe is
* ALWAYS sandboxed without `allow-same-origin` (opaque origin — the visited
* page can never sit on the GUI's origin, read its storage, or reach
* /sidebar/api) and without `allow-top-navigation` (a page must not hijack
* the GUI). The address bar only accepts http(s) and refuses loopback /
* the GUI's own origin. The side card setting "关闭浏览器沙箱" drops the
* sandbox attribute entirely for fully trusted sites — the visited page then
* runs with the GUI's own origin and full session access, so a persistent
* warning bar renders while it is off.
*
* The URL is persisted onto the tab (path/title via the patchTab reducer)
* so a reload restores the visited page; the back/forward stack only tracks
* address-bar navigations (in-frame link clicks are cross-origin and
* invisible — a documented limitation).
*/
import { useEffect, useState } from 'react'
import {
IconChevronLeftOutline14,
IconChevronRightOutline14,
IconLinkOutline14,
IconRefreshOutline14,
IconRightUpOutline16,
IconWarningOutline16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { api } from './api.ts'
import { embeddabilityOf, normalizeBrowserUrl } from './browser.ts'
import { patchTab } from './state.ts'
import { SandboxStatusBar } from './SandboxStatusBar.tsx'
import { t } from './locales.ts'
import type { TabComponentProps } from './service.ts'
import css from './sidebar.module.css'
/**
* The browser iframe sandbox tokens. NO allow-same-origin (opaque origin —
* no GUI storage/API access), NO allow-top-navigation (a browsed page must
* not hijack the GUI). allow-forms/allow-popups/allow-downloads/allow-modals
* keep login flows working; allow-popups-to-escape-sandbox lets OAuth
* popups open as normal tabs (they are cross-origin to the GUI either way).
*/
export const BROWSER_IFRAME_SANDBOX =
'allow-scripts allow-forms allow-popups allow-downloads allow-modals allow-popups-to-escape-sandbox'
export function BrowserView(props: TabComponentProps) {
const { store, tab } = props
// The current address (initialized from the persisted tab.path so a
// reload restores the visited page).
const [url, setUrl] = useState<string | undefined>(tab.path)
const [input, setInput] = useState<string>(tab.path ?? '')
/** Blocked/invalid hint shown under the address bar (null = none). */
const [message, setMessage] = useState<string | null>(null)
/** Address-bar navigation history (in-frame clicks are not tracked). */
const [history, setHistory] = useState<string[]>(tab.path !== undefined ? [tab.path] : [])
const [cursor, setCursor] = useState<number>(tab.path !== undefined ? 0 : -1)
/** Bumped on reload to remount the iframe (also remounts on sandbox flip). */
const [reloadKey, setReloadKey] = useState(0)
/** TEMPORARY sandbox unlock for THIS surface only (never writes the global
* side card setting; lasts until the tab unmounts or the user restores). */
const [localUnlock, setLocalUnlock] = useState(false)
const noSandbox = store.getPrefs().browserNoSandbox === true || localUnlock
/** A site that refuses to be embedded (X-Frame-Options / frame-ancestors):
* the probe verdict shown instead of the blank iframe. */
const [embedBlocked, setEmbedBlocked] = useState<string | null>(null)
/** The user asked to load the refused site anyway (keeps the plain iframe). */
const [forceEmbed, setForceEmbed] = useState(false)
// Probe every navigation (address bar, history, restored path): when the
// target forbids embedding, show the reason + open-in-browser instead of
// the browser's cryptic "refused to connect" blank frame. A failed probe
// (unreachable) keeps the plain iframe.
useEffect(() => {
if (url === undefined) return
let cancelled = false
setEmbedBlocked(null)
setForceEmbed(false)
void api.browserProbe(url).then((probe) => {
if (!cancelled && embeddabilityOf(probe) === 'blocked') setEmbedBlocked(url)
}).catch(() => { /* unreachable: keep the plain iframe */ })
return () => { cancelled = true }
}, [url])
const persist = (nextUrl: string): void => {
let host = nextUrl
try { host = new URL(nextUrl).hostname } catch { /* keep the URL as title */ }
store.reduce(state => patchTab(state, tab.id, { path: nextUrl, title: host }))
}
const navigateTo = (raw: string): void => {
const result = normalizeBrowserUrl(raw, window.location.origin)
if (result.kind === 'ok') {
const next = result.url
setUrl(next)
setInput(next)
setMessage(null)
// Push onto the stack, dropping any stale forward entries.
setHistory(previous => [...previous.slice(0, cursor + 1), next])
setCursor(previous => previous + 1)
setReloadKey(key => key + 1)
persist(next)
return
}
setMessage(result.kind === 'invalid'
? t('browserInvalid')
: result.reason === 'scheme' ? t('browserBlockedScheme')
: t('browserBlockedLoopback'))
}
const goBack = (): void => {
if (cursor <= 0) return
const next = history[cursor - 1]!
setCursor(cursor - 1)
setUrl(next)
setInput(next)
setReloadKey(key => key + 1)
}
const goForward = (): void => {
if (cursor >= history.length - 1) return
const next = history[cursor + 1]!
setCursor(cursor + 1)
setUrl(next)
setInput(next)
setReloadKey(key => key + 1)
}
return (
<div className={css.browser}>
<div className={css.browserBar}>
<button
type="button"
className={css.iconButton}
aria-label={t('browserBack')}
title={t('browserBack')}
disabled={cursor <= 0}
onClick={goBack}
>
<IconChevronLeftOutline14 />
</button>
<button
type="button"
className={css.iconButton}
aria-label={t('browserForward')}
title={t('browserForward')}
disabled={cursor >= history.length - 1}
onClick={goForward}
>
<IconChevronRightOutline14 />
</button>
<button
type="button"
className={css.iconButton}
aria-label={t('refresh')}
title={t('refresh')}
onClick={() => { setReloadKey(key => key + 1) }}
>
<IconRefreshOutline14 />
</button>
<input
className={css.browserInput}
value={input}
placeholder={t('browserPlaceholder')}
spellCheck={false}
onChange={event => { setInput(event.target.value) }}
onKeyDown={event => {
if (event.key === 'Enter') navigateTo(input)
}}
/>
<button
type="button"
className={css.iconButton}
aria-label={t('browserGo')}
title={t('browserGo')}
onClick={() => { navigateTo(input) }}
>
<IconLinkOutline14 />
</button>
<button
type="button"
className={css.iconButton}
aria-label={t('browserOpenExternal')}
title={t('browserOpenExternal')}
disabled={url === undefined}
onClick={() => {
if (url !== undefined) window.open(url, '_blank', 'noopener')
}}
>
<IconRightUpOutline16 size={15} />
</button>
</div>
{message !== null && <div className={css.browserMessage}>{message}</div>}
<SandboxStatusBar
sandboxed={!noSandbox}
local={localUnlock}
dangerCopy={t('browserNoSandboxWarning')}
onUnlock={() => { setLocalUnlock(true) }}
onRestore={() => { setLocalUnlock(false) }}
/>
{url === undefined ? (
<div className={css.browserStart}>{t('browserStart')}</div>
) : embedBlocked !== null && !forceEmbed ? (
<BrowserEmbedBlocked
url={embedBlocked}
onOpenInBrowser={() => { window.open(embedBlocked, '_blank', 'noopener') }}
onLoadAnyway={() => { setForceEmbed(true) }}
/>
) : (
<iframe
key={`${reloadKey}:${noSandbox ? 'ns' : 'sb'}`}
className={css.browserFrame}
src={url}
sandbox={noSandbox ? undefined : BROWSER_IFRAME_SANDBOX}
referrerPolicy="no-referrer"
allow=""
title={url}
/>
)}
</div>
)
}
/**
* The embed-refusal panel: shown when the probed site forbids being
* displayed inside other pages (X-Frame-Options / frame-ancestors) — the
* iframe would only show the browser's "refused to connect" blank. Explains
* the reason and offers the real-browser open plus a load-anyway escape.
* Exported so the copy and the actions are testable without a DOM.
*/
export function BrowserEmbedBlocked(props: {
url: string
onOpenInBrowser: () => void
onLoadAnyway: () => void
}) {
const { url, onOpenInBrowser, onLoadAnyway } = props
let host = url
try { host = new URL(url).hostname } catch { /* keep the raw URL */ }
return (
<div className={css.browserBlocked}>
<IconWarningOutline16 size={16} />
<div className={css.browserBlockedTitle}>{t('browserEmbedBlocked', { host })}</div>
<div className={css.browserBlockedDesc}>{t('browserEmbedBlockedDesc')}</div>
<div className={css.browserBlockedActions}>
<button type="button" className={css.browserBlockedButton} onClick={onOpenInBrowser}>
{t('browserOpenExternal')}
</button>
<button type="button" className={css.browserBlockedButton} onClick={onLoadAnyway}>
{t('browserEmbedAnyway')}
</button>
</div>
</div>
)
}
+1 -1
View File
@@ -90,7 +90,7 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
title={t('refresh')}
onClick={refresh}
>
<IconRefreshOutline16 />
<IconRefreshOutline16 size={14} />
</button>
</div>
{loading && <div className={css.gitPlaceholder}>{t('loading')}</div>}
+5 -2
View File
@@ -31,6 +31,9 @@ export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: Se
useEffect(() => {
let cancelled = false
// Aborts the matched viewer's `load` when the editor tears down (tab
// closed, path changed, session switched) or re-matches the viewer.
const controller = new AbortController()
setLoad({ status: 'loading' })
const mediaUrlOf = (): string => mediaUrl(scope, path)
const apply = (action: EditorLoadAction): void => {
@@ -50,7 +53,7 @@ export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: Se
})
return
case 'customLoad':
void action.viewer.load?.(path, scope).then((data) => {
void action.viewer.load?.(path, scope, controller.signal).then((data) => {
if (cancelled) return
setLoad({ status: 'ready', viewer: action.viewer, customData: data })
}).catch((error: unknown) => {
@@ -77,7 +80,7 @@ export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: Se
}
}
apply(planFirstMatch(ctx.betterSidebar?.matchFileViewer(path), mediaUrlOf))
return () => { cancelled = true }
return () => { cancelled = true; controller.abort() }
}, [scope.sessionId, scope.cwd, path, ctx])
return (
+6 -4
View File
@@ -14,7 +14,7 @@ import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNo
import clsx from 'clsx'
import {
IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16, IconFolderClose16, IconFolderOpen16,
IconRefreshOutline16, Menu, writeClipboard,
IconLinkOutline16, IconRefreshOutline16, Menu, writeClipboard,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { api, downloadUrl, type FsEntry } from './api.ts'
import { relativeTo } from './paths.ts'
@@ -163,6 +163,7 @@ export function ExplorerView(props: {
>
{isOpen ? <IconFolderOpen16 size={14} /> : <IconFolderClose16 size={14} />}
<span className={css.explorerName}>{entry.name}</span>
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
{rowActions(entry)}
</div>
{isOpen && renderLevel(entry.path, depth + 1)}
@@ -174,9 +175,9 @@ export function ExplorerView(props: {
key={entry.path}
role="button"
tabIndex={0}
className={clsx(css.explorerRow, entry.hidden && css.explorerHidden)}
className={clsx(css.explorerRow, entry.hidden && css.explorerHidden, entry.broken && css.explorerBroken)}
style={{ paddingLeft: depth * 22 + 6 }}
title={entry.path}
title={entry.broken ? `${entry.path} — ${t('brokenSymlink')}` : entry.path}
onClick={() => { onOpenFile(entry.path) }}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
@@ -188,6 +189,7 @@ export function ExplorerView(props: {
>
<IconCodeOutline16 size={14} />
<span className={css.explorerName}>{entry.name}</span>
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
{rowActions(entry)}
</div>
)
@@ -209,7 +211,7 @@ export function ExplorerView(props: {
setRefreshTick(tick => tick + 1)
}}
>
<IconRefreshOutline16 />
<IconRefreshOutline16 size={14} />
</button>
</div>
<div className={css.explorerBody}>
+1 -1
View File
@@ -297,7 +297,7 @@ export function GitView(props: {
title={t('refresh')}
onClick={() => { void refresh() }}
>
<IconRefreshOutline16 />
<IconRefreshOutline16 size={14} />
</button>
</div>
-131
View File
@@ -1,131 +0,0 @@
/** High-fidelity browser-native PPTX preview with slide navigation. */
import { useEffect, useRef, useState } from 'react'
import { downloadUrl, mediaUrl, type SessionScope } from './api.ts'
import { t } from './locales.ts'
import css from './sidebar.module.css'
type PptxViewerInstance = import('@aiden0z/pptx-renderer').PptxViewer
type LoadState =
| { status: 'loading' }
| { status: 'ready'; count: number }
| { status: 'error'; message: string }
export function PptxView(props: { scope: SessionScope; path: string; title: string }) {
const { scope, path, title } = props
const hostRef = useRef<HTMLDivElement>(null)
const viewerRef = useRef<PptxViewerInstance | null>(null)
const [load, setLoad] = useState<LoadState>({ status: 'loading' })
const [slide, setSlide] = useState(0)
const [navigating, setNavigating] = useState(false)
useEffect(() => {
const controller = new AbortController()
const host = hostRef.current
if (host === null) return
setLoad({ status: 'loading' })
setSlide(0)
void (async () => {
try {
const response = await fetch(mediaUrl(scope, path), { signal: controller.signal })
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const bytes = await response.arrayBuffer()
if (controller.signal.aborted) return
const { PptxViewer, RECOMMENDED_ZIP_LIMITS } = await import('@aiden0z/pptx-renderer')
if (controller.signal.aborted) return
const viewer = await PptxViewer.open(bytes, host, {
renderMode: 'slide',
fitMode: 'contain',
lazyMedia: true,
lazySlides: true,
pdfjs: false,
signal: controller.signal,
zipLimits: RECOMMENDED_ZIP_LIMITS,
onSlideChange: (index) => {
if (!controller.signal.aborted) setSlide(index)
},
})
if (controller.signal.aborted) {
viewer.destroy()
return
}
viewerRef.current = viewer
setSlide(viewer.currentSlideIndex)
setLoad({ status: 'ready', count: viewer.slideCount })
} catch (error) {
if (controller.signal.aborted) return
try {
viewerRef.current?.destroy()
} catch {
// Partially initialized viewer — ignore disposal errors.
}
viewerRef.current = null
host.innerHTML = ''
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
}
})()
return () => {
controller.abort()
try {
viewerRef.current?.destroy()
} catch {
// Already destroyed.
}
viewerRef.current = null
host.innerHTML = ''
}
}, [scope.sessionId, scope.cwd, path])
const navigate = (target: number): void => {
const viewer = viewerRef.current
if (viewer === null || load.status !== 'ready' || navigating) return
const next = Math.max(0, Math.min(load.count - 1, target))
if (next === slide) return
setNavigating(true)
void viewer.goToSlide(next, { behavior: 'auto' }).then(() => {
setSlide(viewer.currentSlideIndex)
}).catch((error: unknown) => {
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
}).finally(() => {
setNavigating(false)
})
}
return (
<div className={css.editorPptx} aria-label={title}>
<div className={css.editorPptxToolbar}>
<button
type="button"
className={css.editorPptxButton}
disabled={load.status !== 'ready' || slide <= 0 || navigating}
onClick={() => { navigate(slide - 1) }}
>
{t('previousSlide')}
</button>
<span className={css.editorPptxPosition}>
{load.status === 'ready' ? `${slide + 1} / ${load.count}` : '– / –'}
</span>
<button
type="button"
className={css.editorPptxButton}
disabled={load.status !== 'ready' || slide >= load.count - 1 || navigating}
onClick={() => { navigate(slide + 1) }}
>
{t('nextSlide')}
</button>
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
{t('downloadToView')}
</a>
</div>
<div className={css.editorPptxStage}>
<div className={css.editorPptxHost} ref={hostRef} />
{load.status !== 'ready' && (
<div className={css.editorOfficeOverlay}>
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
{load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
</div>
)}
</div>
</div>
)
}
+49
View File
@@ -0,0 +1,49 @@
/**
* The generic render error boundary for the sidebar tree: a render error in
* the wrapped subtree shows a dismissible error strip (retry re-renders the
* children) instead of blanking the shell. Used at two scopes:
*
* - ROOT (index.tsx, `css.boundaryError`): last-resort containment for
* errors in the sidebar shell itself (Workbench, drag layout, …) — a full
* swap keeps the page alive.
* - PER-TAB (Sidebar.tsx TabContent, `css.tabBoundaryError`): a crashing
* viewer/editor shows a strip inside ITS OWN pane; the toggle cluster, the
* other tabs, and the panel itself stay alive (issue #31 — a tab crash
* must never take down the whole sidebar).
*
* The className prop selects the strip's geometry: the root's full-height
* fixed rail vs. the tab's pane-filling block.
*/
import { Component, type ErrorInfo, type ReactNode } from 'react'
import { t } from './locales.ts'
import css from './sidebar.module.css'
export class RenderBoundary extends Component<{ children?: ReactNode; className?: string }, { error: string | null }> {
state = { error: null as string | null }
static getDerivedStateFromError(error: unknown): { error: string } {
return { error: error instanceof Error ? error.message : String(error) }
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error('[dsh-better-sidebar] render error:', error, info.componentStack)
}
render(): ReactNode {
if (this.state.error !== null) {
return (
<div className={this.props.className}>
<span>dsh-better-sidebar: {this.state.error}</span>
<button
type="button"
className={css.terminalRetry}
onClick={() => { this.setState({ error: null }) }}
>
{t('terminalRetry')}
</button>
</div>
)
}
return this.props.children
}
}
+60
View File
@@ -0,0 +1,60 @@
/**
* The live sandbox status row of the two built-in web surfaces (HTML
* preview and the browser tab): a green "sandbox on" state with a one-tap
* TEMPORARY unlock, or a RED "sandbox off" state (global setting or the
* temporary unlock) with a restore action.
*
* The temporary unlock is component state only — it never writes the
* global side card setting (`htmlViewerNoSandbox` / `browserNoSandbox`);
* it lasts until the surface unmounts (tab switch / file switch) or the
* user restores the sandbox from the row. When the global setting already
* drops the sandbox, no unlock/restore action is offered (changing the
* global setting is the settings page's job) — the red warning stands.
*/
import clsx from 'clsx'
import { t } from './locales.ts'
import css from './sidebar.module.css'
export function SandboxStatusBar(props: {
/** The effective sandbox state (global pref OR the local temporary unlock). */
sandboxed: boolean
/** Whether the sandbox is off due to the LOCAL temporary unlock (shows the restore action). */
local: boolean
/** The red-state explanation (e.g. "the page runs with full GUI privileges"). */
dangerCopy: string
onUnlock: () => void
onRestore: () => void
}) {
const { sandboxed, local, dangerCopy, onUnlock, onRestore } = props
if (sandboxed) {
const copy = t('sandboxStatusOn')
return (
<div className={clsx(css.sandboxStatus, css.sandboxStatusOn)}>
<span className={css.sandboxDot} />
<span className={css.sandboxStatusText} title={copy}>{copy}</span>
<button
type="button"
className={css.sandboxAction}
onClick={onUnlock}
>
{t('sandboxUnlock')}
</button>
</div>
)
}
return (
<div className={clsx(css.sandboxStatus, css.sandboxStatusOff)}>
<span className={css.sandboxDot} />
<span className={css.sandboxStatusText} title={dangerCopy}>{dangerCopy}</span>
{local && (
<button
type="button"
className={css.sandboxAction}
onClick={onRestore}
>
{t('sandboxRestore')}
</button>
)}
</div>
)
}
+466 -69
View File
@@ -1,32 +1,85 @@
/**
* Side card settings section chrome (DSH-native tokens throughout):
* - the section is a column of GROUP CONTAINER cards (the PluginCard recipe:
* l2 hairline, 16px radius, layer-3 fill) with a heading + inventory count
* badge (the settings catalogHeading recipe), opening with a one-line
* intro (the DSH section heading+intro recipe);
* - 常规 holds full-width settings rows (the DSH settings-row recipe:
* title/desc left + control right, hairline separators) whose toggles are
* custom switches — a real checkbox (native semantics and focus) driving
* a styled track/thumb;
* - 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;
* (auto-fill columns — several cards per row, wrapping): icon chip +
* title + type id, the card's on/off state IS its visual state
* (enabled = brand border + tinted fill + circular check badge,
* disabled = neutral hairline + dimmed);
* - 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);
* opening a native Modal (wider than the primitive default) whose rows
* follow the same row recipe with switch controls and a Done footer;
* - the width input stays a native settings row (a value, not a switch).
*/
.section {
display: flex;
flex-direction: column;
gap: 8px;
gap: 14px;
width: 100%;
max-width: 760px;
/* The DSH settings shell's `.options` area owns vertical scrolling. Keep
this page a plain content column like Plugins → Plugin list so its
scrollbar stays at the shell edge instead of being inset by the options
area's right padding. */
}
.section > :last-child {
border-bottom: none;
/* One-line section intro (the DSH section heading+intro recipe). */
.intro {
margin: 0;
padding: 0 2px;
font-size: 13px;
line-height: 20px;
color: var(--dsw-alias-label-tertiary);
}
/* Group container: the DSH card recipe (PluginCard) — l2 hairline, r16,
layer-3 fill; holds its own heading + stacked rows / card grid. */
.group {
flex: none;
display: flex;
flex-direction: column;
gap: 8px;
padding: 18px 20px 20px;
box-sizing: border-box;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 16px;
background: var(--dsw-alias-bg-layer-3);
}
/* Group heading: 13/600 + the inventory count badge (the settings
catalogHeading recipe: count in tabular-nums, tertiary). */
.groupHeading {
display: flex;
align-items: baseline;
gap: 7px;
padding: 0 2px 6px;
font-size: 13px;
line-height: 20px;
font-weight: 600;
color: var(--dsw-alias-label-primary);
}
.count {
font-size: 12px;
line-height: 18px;
font-weight: 400;
color: var(--dsw-alias-label-tertiary);
font-variant-numeric: tabular-nums;
}
/* 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;
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
gap: 10px;
}
/* One small toggle card (visual chrome; the interactive part is .cardMain). */
@@ -34,7 +87,7 @@
position: relative;
display: flex;
flex-direction: column;
border-radius: 8px;
border-radius: 12px;
border: 1px solid var(--dsw-alias-border-l2);
background: transparent;
font: inherit;
@@ -46,6 +99,7 @@
/* Hover affordance on disabled cards only (enabled cards are already lit). */
.card:not(.cardOn):hover {
background: var(--dsw-alias-interactive-bg-hover);
border-color: var(--dsw-alias-label-dimmed);
}
/* Enabled = highlighted: brand border + tinted fill. */
@@ -58,9 +112,9 @@
.cardMain {
display: flex;
flex-direction: column;
gap: 2px;
gap: 6px;
width: 100%;
padding: 10px 12px;
padding: 12px;
border: 0;
border-radius: inherit;
background: transparent;
@@ -71,7 +125,8 @@
}
.cardMain:focus-visible,
.cardGear:focus-visible {
.cardGear:focus-visible,
.rowGear:focus-visible {
outline: 2px solid var(--dsw-alias-border-l4);
outline-offset: 2px;
}
@@ -79,18 +134,28 @@
.cardTop {
display: flex;
align-items: center;
gap: 6px;
gap: 8px;
min-width: 0;
}
.cardIcon {
/* The icon sits in a rounded chip: brand-tinted while the card is on,
neutral (layer-2 fill + hairline) while off. */
.cardIconChip {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 28px;
height: 28px;
border-radius: 8px;
border: 1px solid var(--dsw-alias-border-l2);
background: var(--dsw-alias-bg-layer-2);
color: var(--dsw-alias-label-tertiary);
}
.cardOn .cardIcon {
.cardOn .cardIconChip {
border-color: color-mix(in srgb, var(--dsw-alias-button-primary-fill) 35%, transparent);
background: color-mix(in srgb, var(--dsw-alias-button-primary-fill) 12%, transparent);
color: var(--dsw-alias-button-primary-fill);
}
@@ -99,6 +164,7 @@
min-width: 0;
font-size: 13px;
line-height: 20px;
font-weight: 600;
color: var(--dsw-alias-label-secondary);
white-space: nowrap;
overflow: hidden;
@@ -109,12 +175,18 @@
color: var(--dsw-alias-label-primary);
}
/* The check badge: pinned to the FAR RIGHT of the top row (enabled only). */
/* The check badge: a circular brand-filled pin at the card's far right
(enabled only), the icon in the inverted surface token (the .save recipe). */
.cardCheck {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
color: var(--dsw-alias-button-primary-fill);
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--dsw-alias-button-primary-fill);
color: var(--dsw-alias-bg-layer-3);
}
.cardDesc {
@@ -126,28 +198,64 @@
text-overflow: ellipsis;
}
/* The "add plugin" card: same grid cell / chrome as the inventory cards
(it shares .card), but a DASHED border — the repo's first dashed surface;
the hover accent follows the existing drag-drop dashed outlines
(sidebar.module.css .dropCenter / .tabBarDrop). */
.addCard {
align-items: flex-start;
padding: 12px;
border-style: dashed;
border-color: var(--dsw-alias-border-l2);
text-align: left;
}
.addCard:hover {
background: var(--dsw-alias-interactive-bg-hover);
border-color: var(--dsw-alias-interactive-bg-hover-accent);
color: var(--dsw-alias-label-primary);
}
.addCard:hover .cardTitle {
color: var(--dsw-alias-label-primary);
}
.addCard:hover .cardIconChip {
border-color: color-mix(in srgb, var(--dsw-alias-button-primary-fill) 35%, transparent);
color: var(--dsw-alias-button-primary-fill);
}
.addCard:focus-visible {
outline: 2px solid var(--dsw-alias-border-l4);
outline-offset: 2px;
}
.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. */
/* Cards with a settings gear keep the desc clear of it: the gear sits in
the desc row, sharing the check badge's right edge (a vertical column of
controls at the card's right side). */
.cardWithGear .cardDesc {
padding-right: 18px;
padding-right: 30px;
}
/* The gear corner button: directly UNDER the check badge on the card's
right edge — the two controls are vertically aligned (same right inset,
one row apart) and the SAME 16px size, not side by side. */
.cardGear {
position: absolute;
right: 6px;
bottom: 6px;
right: 12px;
top: 46px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
width: 16px;
height: 16px;
padding: 0;
border: 0;
border-radius: 6px;
border-radius: 50%;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
@@ -158,26 +266,44 @@
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);
/* The gear icon button on a general settings ROW (the position-compat
row's "customize the strip height" entry): the same 16px icon-button
recipe as the card gear, sitting in the row's control area. */
.rowGear {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
flex: none;
}
/* Native settings row recipe (the width input row + popup rows). */
.rowGear:hover {
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-interactive-bg-hover);
}
/* Settings-row recipe (the DSH General section rows): title/desc left,
control right, hairline separators (stripped on the last row). */
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 0;
padding: 12px 2px;
border-bottom: 1px solid var(--dsw-alias-border-l2);
}
.row:last-child {
border-bottom: none;
}
.rowText {
display: flex;
flex-direction: column;
@@ -193,45 +319,66 @@
.desc {
font-size: 12px;
line-height: 17px;
color: var(--dsw-alias-label-secondary);
line-height: 18px;
color: var(--dsw-alias-label-tertiary);
}
/* Native checkbox: 16px, brand accent (the settings-row recipe). */
.toggle {
/* Custom switch: a real checkbox (native semantics + focus, visually
hidden) driving a styled 36x20 track with a sliding thumb. */
.switch {
position: relative;
display: inline-flex;
flex: none;
width: 16px;
height: 16px;
cursor: pointer;
}
.switchInput {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
accent-color: var(--dsw-alias-button-primary-fill);
cursor: pointer;
opacity: 0;
}
.toggle:focus-visible {
outline: 2px solid var(--dsw-alias-border-l4);
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;
.switchTrack {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 0;
border-bottom: 1px solid var(--dsw-alias-border-l2);
cursor: pointer;
width: 36px;
height: 20px;
padding: 2px;
box-sizing: border-box;
border-radius: 10px;
border: 1px solid var(--dsw-alias-border-l2);
background: var(--dsw-alias-bg-layer-1);
transition: background 0.15s ease, border-color 0.15s ease;
}
.popupRows > :last-child {
border-bottom: none;
.switchThumb {
display: block;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--dsw-alias-label-secondary);
transition: transform 0.15s ease, background 0.15s ease;
}
.switch:hover .switchTrack {
border-color: var(--dsw-alias-label-dimmed);
}
.switchInput:checked + .switchTrack {
border-color: var(--dsw-alias-button-primary-fill);
background: var(--dsw-alias-button-primary-fill);
}
.switchInput:checked + .switchTrack .switchThumb {
transform: translateX(16px);
background: var(--dsw-alias-bg-layer-3);
}
.switchInput:focus-visible + .switchTrack {
outline: 2px solid var(--dsw-alias-state-business-primary);
outline-offset: 2px;
}
.control {
@@ -245,12 +392,77 @@
width: 76px;
}
/* Text/number rows in the settings popup: the free-form input (e.g. the
terminal font family) gets a generous width; numeric rows (font size)
keep the compact width of the general number inputs. */
.typedInput {
width: 200px;
}
.typedInputNumber {
width: 76px;
}
.suffix {
font-size: 14px;
line-height: 22px;
color: var(--dsw-alias-label-secondary);
}
/* Secondary settings popup: a wider dialog than the primitive default (the
dialog portals to document.body; the doubled class keeps the width rule
above the primitive's .dialog regardless of stylesheet order). */
.popupDialog.popupDialog {
width: min(460px, 100%);
}
/* Popup rows: each setting gets the same outer chrome as a plugin-list
PluginCard (l2 hairline, 12px radius, layer-3 fill). The row's title,
description and control geometry stay unchanged inside that frame. */
.popupRows {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.popupRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-width: 0;
padding: 14px 16px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
background: var(--dsw-alias-bg-layer-3);
transition: border-color 0.16s, background 0.16s;
}
.popupRow:hover {
border-color: var(--dsw-alias-label-dimmed);
}
/* The popup's Done button: the settings primary-button recipe
(PluginCard .save: label-primary fill, inverted surface text). */
.done {
appearance: none;
border: 1px solid transparent;
border-radius: 8px;
padding: 5px 14px;
font: inherit;
font-size: 13px;
line-height: 1.5;
cursor: pointer;
background: var(--dsw-alias-label-primary);
color: var(--dsw-alias-bg-layer-3);
}
.done:focus-visible {
outline: 2px solid var(--dsw-alias-brand-primary);
outline-offset: 1px;
}
.error {
padding: 10px 0 2px;
font-size: 12px;
@@ -258,8 +470,193 @@
color: var(--dsw-alias-state-error-primary);
}
/* ── "Add plugin" modal ──────────────────────────────────────────────── */
/* A wide dialog like the settings popup (doubled class keeps the width
rule above the primitive's .dialog regardless of stylesheet order). */
.pluginModal.pluginModal {
width: min(560px, 100%);
}
.pluginList {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
}
/* The GitHub topic entry: a full-width secondary BUTTON (not a link — it
opens a real new browser tab via window.open, bypassing the sidebar
link takeover). */
.pluginTopicBtn {
width: 100%;
appearance: none;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
padding: 6px 12px;
font: inherit;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-secondary);
background: var(--dsw-alias-bg-layer-1);
cursor: pointer;
}
.pluginTopicBtn:hover {
background: var(--dsw-alias-interactive-bg-hover);
border-color: var(--dsw-alias-interactive-bg-hover-accent);
color: var(--dsw-alias-label-primary);
}
.pluginTopicBtn:focus-visible {
outline: 2px solid var(--dsw-alias-border-l4);
outline-offset: 1px;
}
.pluginEmpty {
padding: 20px 2px;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-tertiary);
}
.pluginEntries {
display: flex;
flex-direction: column;
gap: 10px;
}
/* One catalog entry: name + install button on the head row, description,
then the install command in a mono block (the exact script that gets
pre-typed into the terminal — review it here before installing). */
.pluginEntry {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
background: var(--dsw-alias-bg-layer-1);
}
.pluginEntryHead {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.pluginEntryActions {
display: inline-flex;
align-items: center;
gap: 6px;
flex: none;
}
/* The per-entry "jump" button: opens the plugin repo in a REAL new browser
tab (window.open — a button, so the sidebar link takeover cannot reroute
it). Secondary style, quieter than the Install button. */
.pluginJumpBtn {
flex: none;
appearance: none;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
padding: 3px 12px;
font: inherit;
font-size: 12px;
line-height: 1.5;
cursor: pointer;
background: transparent;
color: var(--dsw-alias-label-secondary);
}
.pluginJumpBtn:hover {
background: var(--dsw-alias-interactive-bg-hover);
border-color: var(--dsw-alias-interactive-bg-hover-accent);
color: var(--dsw-alias-label-primary);
}
.pluginJumpBtn:focus-visible {
outline: 2px solid var(--dsw-alias-border-l4);
outline-offset: 1px;
}
/* The plugin name: a BUTTON (not an anchor — an anchor would be caught by
the sidebar link takeover at document capture, ignoring target=_blank)
styled like a link; opens the repo in a real new browser tab. */
.pluginName {
appearance: none;
min-width: 0;
padding: 0;
border: 0;
background: transparent;
font: inherit;
font-size: 13px;
line-height: 20px;
font-weight: 600;
color: var(--dsw-alias-label-primary);
text-align: left;
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.pluginName:hover {
text-decoration: underline;
color: var(--dsw-alias-button-primary-fill);
}
.pluginDesc {
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-secondary);
}
.pluginInstall {
display: block;
padding: 6px 10px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
line-height: 16px;
color: var(--dsw-alias-label-secondary);
background: var(--dsw-alias-bg-layer-2);
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 8px;
overflow-x: auto;
white-space: nowrap;
}
/* The per-entry copy button: copies the install script to the
clipboard (the settings primary-button recipe, smaller than .done). */
.pluginCopyBtn {
flex: none;
appearance: none;
border: 1px solid transparent;
border-radius: 8px;
padding: 3px 12px;
font: inherit;
font-size: 12px;
line-height: 1.5;
cursor: pointer;
background: var(--dsw-alias-label-primary);
color: var(--dsw-alias-bg-layer-3);
}
.pluginCopyBtn:hover {
background: var(--dsw-alias-button-primary-hover);
}
.pluginCopyBtn:focus-visible {
outline: 2px solid var(--dsw-alias-brand-primary);
outline-offset: 1px;
}
@media (prefers-reduced-motion: reduce) {
.card {
.card,
.switchTrack,
.switchThumb {
transition: none;
}
}
+574 -115
View File
@@ -4,21 +4,30 @@
*
* 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).
* - 常规: new conversations open the panel by default (a toggle row), the
* default panel width as a percent of the window (number input row), and
* the open-path interception toggle — the DSH settings-row recipe
* (title/desc left + control right, hairline separators).
* - 侧边栏内容: 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]`.
* several cards per row — icon chip + title + type id, clicked to toggle
* the switch persisted in `prefs.tabsEnabled[id]`.
* - 文件预览: one SMALL CARD per REGISTERED file viewer — icon chip + title
* + the extensions it covers, clicked to toggle `prefs.viewersEnabled[id]`.
*
* Every group lives in a container card (the DSH PluginCard recipe: l2
* hairline, 16px radius, layer-3 fill) with a heading and an inventory count
* badge (the settings catalogHeading recipe); the section opens with a
* one-line intro (the DSH section heading+intro recipe).
*
* 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).
* border + tinted fill + a circular 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
* (wider than the primitive default) with the related settings as
* title/desc + custom-switch rows and a Done footer. The toggles
* themselves are custom switches: a real checkbox (native semantics and
* focus) driving a styled track/thumb.
*
* 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
@@ -32,7 +41,7 @@
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
import {
IconCheckOutline16,
IconPanelLeftOutline16,
IconPlusOutline16,
IconSettingsOutline16,
Input,
Modal,
@@ -43,17 +52,21 @@ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import {
clampWidthPercent,
TITLE_BAR_STRIP_MAX,
TITLE_BAR_STRIP_MIN,
WIDTH_PERCENT_MAX,
WIDTH_PERCENT_MIN,
type SidebarPrefs,
} from '../prefs-shared.ts'
import { api } from './api.ts'
import { parsePrefs } from './prefs.ts'
import { AddPluginModal, type PluginKind } from './add-plugin-modal.tsx'
import { t } from './locales.ts'
import type { SidebarStore } from './state.ts'
import type {
BetterSidebarService,
FileViewerDescriptor,
SidebarSettingsRenderProps,
SidebarSettingToggle,
TabDescriptor,
} from './service.ts'
@@ -99,46 +112,265 @@ 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
/** Whether a feature declares any secondary settings (gear button shows). */
function hasSettings(feature: TabDescriptor | FileViewerDescriptor): boolean {
const settings = feature.settings
return settings !== undefined && (
(settings.toggles?.length ?? 0) > 0
|| (settings.pluginToggles?.length ?? 0) > 0
|| settings.render !== undefined
)
}
/** A feature's display name (viewers fall back to their id). */
function featureNameOf(feature: TabDescriptor | FileViewerDescriptor): string {
return textOf('title' in feature ? feature.title : undefined) || feature.id
}
/**
* 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).
* Merge one plugin-owned setting into a pluginSettings map (pure, v0.12.0+).
* Sequential merges are additive: each call spreads the map it was GIVEN,
* so building from the latest optimistic map keeps earlier keys intact
* (two same-tick writes must not drop each other).
*/
export function mergePluginSetting(
pluginSettings: Record<string, Record<string, unknown>>,
descriptorId: string,
key: string,
value: unknown,
): Record<string, Record<string, unknown>> {
return {
...pluginSettings,
[descriptorId]: { ...(pluginSettings[descriptorId] ?? {}), [key]: value },
}
}
/**
* Render a custom settings panel (`settings.render`) with error containment:
* a throwing panel shows an inline error line instead of breaking the whole
* settings page.
*/
function SettingsRender(props: {
render: (renderProps: SidebarSettingsRenderProps) => ReactNode
renderProps: SidebarSettingsRenderProps
}) {
let content: ReactNode
try {
content = props.render(props.renderProps)
} catch (error) {
content = (
<div className={css.error} role="alert">
{t('settingsSaveFailed')} {error instanceof Error ? error.message : String(error)}
</div>
)
}
return <>{content}</>
}
/**
* The custom switch: a real checkbox (hidden, native semantics and focus)
* driving a styled track/thumb. Used by the general toggle rows and the
* secondary settings popup rows.
*/
function Switch(props: {
checked: boolean
onChange: (next: boolean) => void
label: string
}) {
const { checked, onChange, label } = props
return (
<label className={css.switch}>
<input
type="checkbox"
className={css.switchInput}
checked={checked}
aria-label={label}
onChange={event => { onChange(event.currentTarget.checked) }}
/>
<span className={css.switchTrack} aria-hidden="true">
<span className={css.switchThumb} />
</span>
</label>
)
}
/**
* The body of a feature's secondary settings popup: one row (title/desc +
* control) per declared setting. Switches render the custom switch; text and
* number rows render a free-form / numeric input committed on blur/Enter
* (clamped to the declared min/max). 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
/** Commit one text/number row; returns the canonical value the row should
* display (clamped for numbers, the current pref when the input is
* invalid). Optional: rows with no handler keep their draft. */
onCommit?: (toggle: SidebarSettingToggle, raw: string) => string
/** Explicit value source (v0.12.0+): when given, rows read their values
* from it instead of the `prefs` face — plugin-owned rows read their
* own blob, so a plugin key can never collide with (or silently read)
* a host pref of the same name. (Named `valueSource`, not `valueOf`:
* the latter collides with the inherited Object.prototype.valueOf.) */
valueSource?: (key: string) => unknown
}) {
const { toggles, prefs, onToggle } = props
const { toggles, prefs, onToggle, onCommit, valueSource } = props
const read = valueSource ?? ((key: string): unknown => (prefs as unknown as Record<string, unknown>)[key])
return (
<div className={css.popupRows}>
{toggles.map(toggle => {
const title = textOf(toggle.title)
if ((toggle.type ?? 'switch') === 'switch') {
return (
<div 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>
<Switch
label={title}
checked={read(toggle.key) === true}
onChange={(next) => { onToggle(toggle, next) }}
/>
</div>
)
}
const value = String(read(toggle.key) ?? '')
// Keyed by the committed value: a failed commit reverts prefs, the
// key changes, and the row remounts with the stored value (typing
// never changes the key, so mid-edit drafts survive re-renders).
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>
<TypedRow
key={`${toggle.key}:${value}`}
toggle={toggle}
title={title}
value={value}
onCommit={onCommit}
/>
)
})}
</div>
)
}
/**
* One text/number row: a controlled input whose draft is local state,
* committed on blur/Enter through the parent's onCommit. The parent's
* canonical return is adopted (clamped numbers, stored value for invalid
* input); a `unit` suffix renders after the input (e.g. 'px').
*/
function TypedRow(props: {
toggle: SidebarSettingToggle
title: string
value: string
onCommit?: (toggle: SidebarSettingToggle, raw: string) => string
}) {
const { toggle, title, value, onCommit } = props
const [draft, setDraft] = useState(value)
const commit = (): void => {
const canonical = onCommit?.(toggle, draft) ?? draft
setDraft(canonical)
}
const number = toggle.type === 'number'
return (
<div 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>
<span className={css.control}>
<Input
type={number ? 'number' : 'text'}
className={number ? css.typedInputNumber : css.typedInput}
value={draft}
min={toggle.min}
max={toggle.max}
step={1}
placeholder={toggle.placeholder}
aria-label={title}
onChange={event => { setDraft(event.currentTarget.value) }}
onBlur={commit}
onKeyDown={event => {
if (event.key === 'Enter') event.currentTarget.blur()
}}
/>
{toggle.unit !== undefined && <span className={css.suffix}>{toggle.unit}</span>}
</span>
</div>
)
}
/**
* The secondary settings popup body of one feature (tab or viewer):
* - `settings.render` (custom panel) when declared — rendered with the
* shared store/service, the live prefs, the descriptor's own plugin
* settings blob, a persistence helper, and a close callback;
* - otherwise the host-prefs `toggles` rows, then the plugin-owned
* `pluginToggles` rows (their values live in `pluginSettings[feature.id]`,
* projected onto the prefs face so the shared row renderer reads them).
*/
export function SettingsBody(props: {
feature: TabDescriptor | FileViewerDescriptor
prefs: SidebarPrefs
store: SidebarStore
service: BetterSidebarService
onToggle: (toggle: SidebarSettingToggle, next: boolean) => void
onCommit: (toggle: SidebarSettingToggle, raw: string) => string
onPluginToggle: (toggle: SidebarSettingToggle, next: boolean) => void
onPluginCommit: (toggle: SidebarSettingToggle, raw: string) => string
onPluginWrite: (key: string, value: unknown) => void
onClose: () => void
}) {
const { feature, prefs, store, service, onToggle, onCommit, onPluginToggle, onPluginCommit, onPluginWrite, onClose } = props
const render = feature.settings?.render
if (render !== undefined) {
return (
<SettingsRender
render={render}
renderProps={{
store,
service,
prefs,
pluginSettings: prefs.pluginSettings[feature.id] ?? {},
updatePluginSetting: onPluginWrite,
close: onClose,
}}
/>
)
}
const toggles = feature.settings?.toggles ?? []
const pluginToggles = feature.settings?.pluginToggles ?? []
if (toggles.length === 0 && pluginToggles.length === 0) return null
// Plugin rows read their values from the descriptor's OWN blob through
// an explicit value source — no projection onto the prefs face, so a
// plugin key can never collide with (or silently read) a host pref of
// the same name.
const pluginBlob = prefs.pluginSettings[feature.id] ?? {}
return (
<div className={css.popupRows}>
{toggles.length > 0 && (
<FeatureSettingsRows
toggles={toggles}
prefs={prefs}
onToggle={onToggle}
onCommit={onCommit}
/>
)}
{pluginToggles.length > 0 && (
<FeatureSettingsRows
toggles={pluginToggles}
prefs={prefs}
onToggle={onPluginToggle}
onCommit={onPluginCommit}
valueSource={(key) => pluginBlob[key]}
/>
)}
</div>
)
}
/**
* Render the Side card preferences section.
* @param props - composed slot props (runtime share + injected store/service).
@@ -149,7 +381,21 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
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)
const [settingsFor, setSettingsFor] = useState<TabDescriptor | FileViewerDescriptor | null>(null)
// Whether the position-compat strip popup (the gear on the 常规 row) is open.
const [stripSettingsOpen, setStripSettingsOpen] = useState(false)
// Whether the "add plugin" modal (a dashed card at the end of the
// 侧边栏内容 / 文件预览 grids) is open, and for which extension point
// (null = closed).
const [addPluginsOpen, setAddPluginsOpen] = useState<PluginKind | null>(null)
// The LATEST optimistic prefs, kept in sync with the state. Nested-map
// merges (tabsEnabled / viewersEnabled / pluginSettings) MUST build from
// this ref, not from the render-time `prefs`: two same-tick writes (e.g.
// a settings panel updating several plugin keys at once) would otherwise
// both spread the stale map and the later patch would drop the earlier
// key even though the commits are serialized.
const optimisticRef = useRef(prefs)
useEffect(() => { optimisticRef.current = prefs }, [prefs])
// The declarative inventory: the registered tab types and file viewers.
// Local state + service.subscribe (registry changes are rare — plugin
@@ -218,31 +464,82 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
setWidthDraft(String(settled.defaultWidthPercent))
}
/** Optimistically flip one boolean pref, then commit (revert on failure). */
const togglePref = (patch: Record<string, unknown>): void => {
const previous = prefs
setPrefs({ ...previous, ...patch } as SidebarPrefs)
/** Optimistically apply one pref patch, then commit (revert on failure). */
const applyPref = (patch: Record<string, unknown>): void => {
const previous = optimisticRef.current
const next = { ...previous, ...patch } as SidebarPrefs
optimisticRef.current = next
setPrefs(next)
setError(null)
void commit(patch).then(outcome => applyOutcome(previous, outcome))
}
const onToggle = (next: boolean): void => {
togglePref({ openByDefault: next })
applyPref({ 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 } })
applyPref({ tabsEnabled: { ...optimisticRef.current.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 } })
applyPref({ viewersEnabled: { ...optimisticRef.current.viewersEnabled, [id]: next } })
}
/** Flip one declaratively-declared toggle (a SidebarPrefs boolean field). */
const onToggleSetting = (toggle: SidebarSettingToggle, next: boolean): void => {
togglePref({ [toggle.key]: next })
applyPref({ [toggle.key]: next })
}
/**
* Commit one declaratively-declared text/number row. Numbers are parsed
* and clamped to the toggle's declared min/max (an unparsable input falls
* back to the CURRENT stored value, mirroring the width row); text rows
* persist as-is (empty is meaningful, e.g. the theme-default font).
* Returns the canonical value the row should display.
*/
const onCommitSetting = (toggle: SidebarSettingToggle, raw: string): string => {
if (toggle.type === 'number') {
const parsed = Number(raw)
const fallback = String((prefs as unknown as Record<string, unknown>)[toggle.key] ?? '')
if (!Number.isFinite(parsed)) return fallback
let clamped = Math.round(parsed)
if (toggle.min !== undefined) clamped = Math.max(toggle.min, clamped)
if (toggle.max !== undefined) clamped = Math.min(toggle.max, clamped)
applyPref({ [toggle.key]: clamped })
return String(clamped)
}
applyPref({ [toggle.key]: raw })
return raw
}
/** Persist one plugin-owned setting of one descriptor (merged into the pluginSettings blob). */
const applyPluginSetting = (descriptorId: string, key: string, value: unknown): void => {
applyPref({ pluginSettings: mergePluginSetting(optimisticRef.current.pluginSettings, descriptorId, key, value) })
}
/** Flip one plugin-owned switch row (same row shape, plugin-scoped key). */
const onPluginToggle = (descriptorId: string, toggle: SidebarSettingToggle, next: boolean): void => {
applyPluginSetting(descriptorId, toggle.key, next)
}
/** Commit one plugin-owned text/number row (clamped like the host rows). */
const onPluginCommitSetting = (descriptorId: string, toggle: SidebarSettingToggle, raw: string): string => {
if (toggle.type === 'number') {
const parsed = Number(raw)
const blob = prefs.pluginSettings[descriptorId] ?? {}
const fallback = String(blob[toggle.key] ?? '')
if (!Number.isFinite(parsed)) return fallback
let clamped = Math.round(parsed)
if (toggle.min !== undefined) clamped = Math.max(toggle.min, clamped)
if (toggle.max !== undefined) clamped = Math.min(toggle.max, clamped)
applyPluginSetting(descriptorId, toggle.key, clamped)
return String(clamped)
}
applyPluginSetting(descriptorId, toggle.key, raw)
return raw
}
const commitWidth = (): void => {
@@ -261,9 +558,10 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
/**
* 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.
* area is the switch (click to flips, visual state IS the state), the icon
* sits in a rounded chip, the check badge pins to the far right, and a
* feature that declares related settings carries a gear corner button
* opening its settings popup.
*/
const renderCard = (props: {
title: string
@@ -288,12 +586,12 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
>
<span className={css.cardTop}>
{props.icon !== null && props.icon !== undefined && (
<span className={css.cardIcon}>{props.icon}</span>
<span className={css.cardIconChip}>{props.icon}</span>
)}
<span className={css.cardTitle}>{props.title}</span>
{props.enabled && (
<span className={css.cardCheck}>
<IconCheckOutline16 size={14} />
<IconCheckOutline16 size={12} />
</span>
)}
</span>
@@ -307,7 +605,7 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
title={t('settingsPopup')}
onClick={props.onOpenSettings}
>
<IconSettingsOutline16 size={14} />
<IconSettingsOutline16 size={12} />
</button>
)}
</div>
@@ -316,98 +614,259 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
return (
<div className={css.section}>
<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>
<span className={css.desc}>{t('settingsWidthDesc')}</span>
</span>
<span className={css.control}>
<Input
type="number"
className={css.percentInput}
value={widthDraft}
min={WIDTH_PERCENT_MIN}
max={WIDTH_PERCENT_MAX}
step={1}
aria-label={t('settingsWidthTitle')}
onChange={event => { setWidthDraft(event.currentTarget.value) }}
onBlur={commitWidth}
onKeyDown={event => {
if (event.key === 'Enter') event.currentTarget.blur()
}}
<p className={css.intro}>{t('settingsIntro')}</p>
{/* 常规: the DSH settings-row recipe — title/desc left, control right. */}
<div className={css.group}>
<div className={css.groupHeading}>{t('settingsGeneralTitle')}</div>
<div className={css.row}>
<span className={css.rowText}>
<span className={css.title}>{t('settingsOpenTitle')}</span>
<span className={css.desc}>{t('settingsOpenDesc')}</span>
</span>
<Switch
label={t('settingsOpenTitle')}
checked={prefs.openByDefault}
onChange={onToggle}
/>
<span className={css.suffix}>{t('settingsWidthSuffix')}</span>
</span>
</div>
<div className={css.row}>
<span className={css.rowText}>
<span className={css.title}>{t('settingsWidthTitle')}</span>
<span className={css.desc}>{t('settingsWidthDesc')}</span>
</span>
<span className={css.control}>
<Input
type="number"
className={css.percentInput}
value={widthDraft}
min={WIDTH_PERCENT_MIN}
max={WIDTH_PERCENT_MAX}
step={1}
aria-label={t('settingsWidthTitle')}
onChange={event => { setWidthDraft(event.currentTarget.value) }}
onBlur={commitWidth}
onKeyDown={event => {
if (event.key === 'Enter') event.currentTarget.blur()
}}
/>
<span className={css.suffix}>{t('settingsWidthSuffix')}</span>
</span>
</div>
<div className={css.row}>
<span className={css.rowText}>
<span className={css.title}>{t('settingsOpenPathTitle')}</span>
<span className={css.desc}>{t('settingsOpenPathDesc')}</span>
</span>
<Switch
label={t('settingsOpenPathTitle')}
checked={prefs.interceptOpenPath}
onChange={(next) => { applyPref({ interceptOpenPath: next }) }}
/>
</div>
<div className={css.row}>
<span className={css.rowText}>
<span className={css.title}>{t('settingsTitleBarTitle')}</span>
<span className={css.desc}>{t('settingsTitleBarDesc')}</span>
</span>
<span className={css.control}>
{/*
The position-compat row's gear (same popup pattern as the
feature cards): opens a Modal with the strip-height number row.
Hidden while the mode is off — its related setting is dormant
then (the feature-card convention).
*/}
{prefs.titleBarCompat && (
<button
type="button"
className={css.rowGear}
aria-label={`${t('settingsTitleBarTitle')} ${t('settingsPopup')}`}
title={t('settingsPopup')}
onClick={() => { setStripSettingsOpen(true) }}
>
<IconSettingsOutline16 size={14} />
</button>
)}
<Switch
label={t('settingsTitleBarTitle')}
checked={prefs.titleBarCompat}
onChange={(next) => { applyPref({ titleBarCompat: next }) }}
/>
</span>
</div>
</div>
{/* 侧边栏内容: 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 className={css.group}>
<div className={css.groupHeading}>
<span>{t('settingsTabsTitle')}</span>
<span className={css.count}>{tabs.length}</span>
</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 && hasSettings(tab)
? () => { setSettingsFor(tab) }
: undefined,
})}
</Fragment>
))}
{/* The "add tab plugin" entry: same card size as the inventory,
but a dashed border — it opens the TAB-registration plugin
modal instead of toggling a feature. */}
<button
type="button"
className={clsx(css.card, css.addCard)}
onClick={() => { setAddPluginsOpen('tab') }}
>
<span className={css.cardTop}>
<span className={css.cardIconChip}>
<IconPlusOutline16 size={16} />
</span>
<span className={css.cardTitle}>{t('addPluginsTabCard')}</span>
</span>
<span className={css.cardDesc}>{t('addPluginsTabCardDesc')}</span>
</button>
</div>
</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 className={css.group}>
<div className={css.groupHeading}>
<span>{t('settingsViewersTitle')}</span>
<span className={css.count}>{viewers.length}</span>
</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) },
onOpenSettings: prefs.viewersEnabled[viewer.id] !== false && hasSettings(viewer)
? () => { setSettingsFor(viewer) }
: undefined,
})}
</Fragment>
))}
{/* The "add preview plugin" entry: dashed card opening the
FILE-PREVIEWER registration modal. */}
<button
type="button"
className={clsx(css.card, css.addCard)}
onClick={() => { setAddPluginsOpen('viewer') }}
>
<span className={css.cardTop}>
<span className={css.cardIconChip}>
<IconPlusOutline16 size={16} />
</span>
<span className={css.cardTitle}>{t('addPluginsViewerCard')}</span>
</span>
<span className={css.cardDesc}>{t('addPluginsViewerCardDesc')}</span>
</button>
</div>
</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. */}
as title/desc + switch rows in a wider-than-default Modal with a
Done footer (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.
Content: `settings.render` (custom panel) when declared, else the
host-prefs `toggles` rows followed by the plugin-owned
`pluginToggles` rows (their values live in pluginSettings[id]). */}
{settingsFor !== null && (
<Modal
open
onClose={() => { setSettingsFor(null) }}
title={textOf(settingsFor.title)}
title={featureNameOf(settingsFor)}
description={t('settingsPopupDesc', { feature: featureNameOf(settingsFor) })}
closeLabel={t('close')}
className={css.popupDialog}
footer={(
<button type="button" className={css.done} onClick={() => { setSettingsFor(null) }}>
{t('settingsDone')}
</button>
)}
>
<FeatureSettingsRows
toggles={settingsFor.settings?.toggles ?? []}
<SettingsBody
feature={settingsFor}
prefs={prefs}
onToggle={onToggleSetting}
onCommit={onCommitSetting}
onPluginToggle={(toggle, next) => { onPluginToggle(settingsFor.id, toggle, next) }}
onPluginCommit={(toggle, raw) => onPluginCommitSetting(settingsFor.id, toggle, raw)}
onPluginWrite={(key, value) => { applyPluginSetting(settingsFor.id, key, value) }}
onClose={() => { setSettingsFor(null) }}
store={store}
service={service}
/>
</Modal>
)}
{/* The position-compat strip popup (opened by the gear on the 常规
row): one number row for the reserved strip height in px. Same
Modal chrome and row machinery as the feature popups — mounted
only while open (the Modal SSR rule above). */}
{stripSettingsOpen && (
<Modal
open
onClose={() => { setStripSettingsOpen(false) }}
title={t('settingsTitleBarTitle')}
description={t('settingsPopupDesc', { feature: t('settingsTitleBarTitle') })}
closeLabel={t('close')}
className={css.popupDialog}
footer={(
<button type="button" className={css.done} onClick={() => { setStripSettingsOpen(false) }}>
{t('settingsDone')}
</button>
)}
>
<FeatureSettingsRows
toggles={[{
key: 'titleBarStripPx',
type: 'number',
title: () => t('settingsTitleBarStripTitle'),
desc: () => t('settingsTitleBarStripDesc'),
min: TITLE_BAR_STRIP_MIN,
max: TITLE_BAR_STRIP_MAX,
unit: 'px',
}]}
prefs={prefs}
onToggle={onToggleSetting}
onCommit={onCommitSetting}
/>
</Modal>
)}
{/* The "add plugin" modal (opened by the dashed cards above): declares
the extension point of the clicked kind, opens the GitHub topic,
and lists the matching recommended plugin catalog with per-entry
install buttons (the install flow opens a ~/.dsh terminal with
the command pre-typed; failures render inline here, in settings
only). Mounted only while open (Modal runs hooks unconditionally
— same SSR rule as the settings popup above). */}
{addPluginsOpen !== null && (
<AddPluginModal
service={service}
onClose={() => { setAddPluginsOpen(null) }}
kind={addPluginsOpen}
/>
)}
{error !== null && (
<div className={css.error} role="alert">
{error}
+608 -119
View File
@@ -1,31 +1,52 @@
/**
* The sidebar shell: a fixed-position right panel portalled onto
* document.body (the core AppFrame owns the left sidebar / center / details
* columns and has no right-side hole for plugins), plus the edge toggle
* button. The panel width drags from its left edge; the whole layout lives
* in the per-session store, so switching conversations swaps the sidebar.
* The sidebar shell: fixed-position panels portalled onto document.body
* (the core AppFrame owns the left sidebar / center / details columns and
* has no right-side hole for plugins). The right panel hosts the original
* workbench; the bottom panel hosts a second, independent workbench. The
* bottom panel squeezes ONLY the center column (the agent output area): it
* spans from the app shell's own left sidebar to the right panel's left
* edge, so neither sidebar gives up any position (the right panel keeps its
* full height). A persistent two-button cluster at the top-right corner
* toggles each panel; the right panel's width drags from its left edge, the
* bottom panel's height from its top edge, and the shared corner drags both
* at once. The whole layout lives in the per-session store, so switching
* conversations swaps the sidebar.
*
* The shell binds the workbench actions to the store and dispatches tab
* content to the four views. New tabs come from the + menu (explorer / git /
* terminal; editors open from the explorer).
* content to the views. New tabs come from the + menu (explorer / git /
* terminal; editors open from the explorer). Tabs live in one tree only —
* they never cross panels; only the panel sizes drag against each other.
*
* Narrow (mobile, <768px) viewports show ONLY the right sidebar: entering
* narrow migrates the bottom panel's tabs INTO the right tree
* (migrateBottomTabs) — one workbench, the bottom tabs thrown into its
* strips. The right panel becomes a full-width drawer, the bottom panel
* and its toggle button disappear, and the layout push is disabled (the
* drawer floats). Widening does not migrate back: the tabs keep living in
* the right tree.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createElement, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useSyncExternalStore } from 'react'
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 { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context, SidebarSessionList } from '../context-types.ts'
import { appendToDraft } from './conversation-draft.ts'
import {
agentUuidOf, closeTab, isAgentTabId, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openDiffTab,
BOTTOM_MIN, PANEL_MIN, agentUuidOf, firstLeaf, isAgentTabId, leafWithTab, migrateBottomTabs, moveTab, moveTabToEdge, openDiffTab,
reconcileAgentTerminals,
resizeSplit, setWidth, toggleExpanded, togglePanel,
resizeSplitIn, setBottomHeight, setWidth, toggleBottomPanel, toggleExpanded, togglePanel,
type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
} from './state.ts'
import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx'
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
import { useNarrowViewport } from './breakpoints.ts'
import type { NewTabOption } from './TabBar.tsx'
import type { TabDragPayload } from './TabBar.tsx'
import { relativeTo } from './paths.ts'
import { OrphanedTab } from './OrphanedTab.tsx'
import { RenderBoundary } from './RenderBoundary.tsx'
import { detectNewDirectSubagent } from './subagent-detect.ts'
import { detectNewJob } from './subagent-jobs.ts'
import { t } from './locales.ts'
import { api, type SessionScope } from './api.ts'
import css from './sidebar.module.css'
@@ -57,10 +78,22 @@ function TabContent(props: {
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,
})
// One boundary per tab: a render crash in a viewer/editor shows a strip in
// THIS tab's pane only — the toggle cluster, the other tabs, and the panel
// stay alive (issue #31). The tab strip (close button) lives outside, so a
// crashing tab stays closable; the root boundary in index.tsx remains the
// last resort for errors in the sidebar shell itself. The descriptor is
// rendered as a REAL element (not called directly): a direct call would
// throw inside TabContent's own render, which the boundary cannot catch —
// as a child fiber, every render error (top-level or deep) lands in it.
return createElement(
RenderBoundary,
{ className: css.tabBoundaryError },
createElement(descriptor.component, {
ctx, store, scope, tab, visible, expanded,
onToggleDir, onReferenceFile, onOpenDiff, onSubagentJump,
}),
)
}
/** The + menu options for the current state, driven by the tab registry.
@@ -85,6 +118,25 @@ function buildNewTabOptions(state: SidebarState, ctx: Context, scope: SessionSco
export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
const { ctx, store } = props
// Copy freshness: re-render the whole tree when the DSH locale switches.
// The module-level t() reads the active locale at call time, so a root
// re-render alone re-localizes every panel (no memo barriers below).
const localeRevision = useSyncExternalStore(
useMemo(() => (callback: () => void) => ctx.locale.subscribe(callback), [ctx]),
useCallback(() => ctx.locale.getSnapshot().active, [ctx]),
)
void localeRevision
// Narrow (mobile) viewports collapse the two panels into one: the right
// panel becomes a full-width drawer holding BOTH workbenches, the bottom
// panel (and its toggle button) disappears, and the layout push is
// disabled (the drawer floats over the app shell). Entering narrow
// MIGRATES the bottom tree's tabs into the right tree (migrateBottomTabs)
// — the merged display is the right sidebar alone, the bottom tabs thrown
// into its strips. Widening never rewrites the migrated state: the tabs
// keep living in the right tree.
const narrow = useNarrowViewport()
// Current conversation (the sessions list feed).
const sessionList = useSyncExternalStore(
useMemo(() => (callback: () => void) => ctx.sessions.list.subscribe(callback), [ctx]),
@@ -103,6 +155,57 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
const sessionId = snapshot.sessionId
const summaryCwd = sessionId === undefined ? undefined : sessionList.byId[sessionId]?.cwd
// The collapsed toggle cluster reclaims the top-right corner, so the DSH
// session header's right-aligned utilities (the "Session log" download
// capsule) must yield. layout.css keys off this body attribute to push the
// header's right padding out past the cluster. Only the CLOSED panel needs
// it — an open panel already squeezes `#root` left, moving the header clear.
const collapsed = state === undefined || !state.panelOpen
useEffect(() => {
if (collapsed) document.body.setAttribute('data-dsh-sidebar-collapsed', '')
else document.body.removeAttribute('data-dsh-sidebar-collapsed')
return () => { document.body.removeAttribute('data-dsh-sidebar-collapsed') }
}, [collapsed])
// Position compatibility mode (titleBarCompat pref): Windows frameless
// windows draw the native title bar (minimize/maximize/close) at the
// window's top-right corner, OVER the web content. When the user enables
// the pref, the body attribute lets sidebar.module.css drop the toggle
// cluster below the strip and push the right panel's content below it.
// The strip height is user-tunable (titleBarStripPx) and rides a CSS
// variable so the rules stay declarative. The attribute rides the
// snapshot's prefs, so flipping the setting re-renders and re-applies
// immediately; the cleanup removes both on unmount/boundary swap so a
// crashed sidebar never leaves them behind.
const titleBarCompat = snapshot.prefs.titleBarCompat
const titleBarStrip = snapshot.prefs.titleBarStripPx
useEffect(() => {
const root = document.documentElement
if (titleBarCompat) {
document.body.setAttribute('data-dsh-title-bar-compat', '')
root.style.setProperty('--dsh-title-bar-strip', `${titleBarStrip}px`)
} else {
document.body.removeAttribute('data-dsh-title-bar-compat')
root.style.removeProperty('--dsh-title-bar-strip')
}
return () => {
document.body.removeAttribute('data-dsh-title-bar-compat')
root.style.removeProperty('--dsh-title-bar-strip')
}
}, [titleBarCompat, titleBarStrip])
/**
* Bottom-panel merge on narrow viewports: whenever a session is current
* while narrow (mount, session switch, or a desktop→narrow transition),
* throw the bottom tree's tabs into the right tree. Idempotent — after
* the first migration the bottom tree is empty and the reducer returns
* the same reference, so this effect settles immediately.
*/
useEffect(() => {
if (!narrow || sessionId === undefined) return
store.reduce(migrateBottomTabs)
}, [narrow, sessionId, store])
// While the session's header is still hydrating (or the session is blank),
// the list summary may carry no cwd; ask the host once (it falls back to
// the process cwd) so the explorer root and terminal cwd are real from
@@ -194,6 +297,32 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
if (!store.getPrefs().autoOpenSubagent) return
if (ctx.betterSidebar?.isTabEnabled('subagent') === false) return
store.reduce(s => s.panelOpen ? s : togglePanel(s))
// Pin the landing to the right panel: the auto-opened Subagent page must
// appear where the panel just expanded, not in a bottom-panel pane the
// user last touched.
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
}, [sessionList, sessionId, store, ctx])
/**
* Job auto-activation: the moment a NEW background job appears for the
* current conversation (a job id the previous snapshot lacked), the
* auto-open pref is on, and the Jobs tab type is enabled, open the panel
* (if collapsed) and focus the Jobs page. Unlike the subagent trigger
* (0 → N only), ANY new job id triggers: the agent may start several
* jobs in one session, and each should surface. A fresh page load never
* triggers — its baseline starts at the current snapshot.
*/
const jobBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
useEffect(() => {
const prev = jobBaselineRef.current
jobBaselineRef.current = sessionList
if (sessionId === undefined || prev === undefined) return
if (!detectNewJob(prev, sessionList, sessionId)) return
if (!store.getPrefs().autoOpenJobs) return
if (ctx.betterSidebar?.isTabEnabled('subagent') === false) return
store.reduce(s => s.panelOpen ? s : togglePanel(s))
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
}, [sessionList, sessionId, store, ctx])
@@ -214,33 +343,232 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
if (pending === undefined || sessionId !== pending) return
subagentJumpRef.current = undefined
store.reduce(s => s.panelOpen ? s : togglePanel(s))
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
}, [sessionId, store, ctx])
// Panel width drag (left edge strip).
// The app shell's center column: the bottom panel spans ONLY that column
// ("squeezes the agent output area") — it starts at the app sidebar's
// right edge and ends at the details column's left edge (the details
// column sits between the center and the right panel). Measured directly
// from the AppFrame's center column DOM (the parent of the
// [data-slot="conversation"] wrapper — layout.css's center column) so the
// bottom panel tracks the column's real
// horizontal edges — including the animated margin-right push while the
// right panel opens/closes; a frame that never appears keeps the initial
// zero-size fallback (the panel renders at 0 width until measured).
const [centerRect, setCenterRect] = useState({ left: 0, right: 0 })
// Refs keep the measure step stable across renders and let it skip work
// mid-drag: during a width/corner drag the layout push resizes the center
// column every frame, and reacting (setCenterRect → re-render) would
// re-introduce the drag lag this shell deliberately avoids. applyDrag
// writes the bottom panel's edges directly, so measurement pauses then.
const centerColRef = useRef<HTMLElement | null>(null)
const draggingRef = useRef(false)
const measureCenter = useCallback((): void => {
if (draggingRef.current) return
const col = centerColRef.current
if (col === null) return
const rect = col.getBoundingClientRect()
// The bottom panel only cares about the horizontal edges: a pure height
// change (the bottom panel itself opening/closing) must not re-render,
// so keep the previous object when left/right are unchanged.
setCenterRect(prev =>
prev.left === rect.left && prev.right === rect.right
? prev
: { left: rect.left, right: rect.right })
}, [])
useEffect(() => {
let disposed = false
let observer: ResizeObserver | undefined
// Locate the AppFrame's center column. DSH 0.1.x wraps slot hosts in
// [data-slot] containers: the conversation slot wrapper
// ([data-slot="conversation"]) sits directly inside the center column,
// so its parent IS that column — no hashed-class or positional
// dependency (layout.css uses the same anchor). The shell swaps the
// boot page for the AppFrame only AFTER boot settles, so the first
// query may miss it. Never give up: watch #root's children (the swap
// mutates them) and re-run this locator — querying once and bailing
// would strand the panel at the zero-size fallback forever (observed:
// a 1px sliver at the viewport's left edge).
const locate = (): void => {
if (disposed) return
const col = document.querySelector('#root [data-slot="conversation"]')
?.parentElement as HTMLElement | undefined
if (col === undefined) {
if (centerColRef.current !== null) {
centerColRef.current = null
observer?.disconnect()
observer = undefined
}
return
}
if (centerColRef.current !== col) {
centerColRef.current = col
observer?.disconnect()
observer = new ResizeObserver(measureCenter)
observer.observe(col)
}
measureCenter()
}
locate()
const watcher = new MutationObserver(locate)
const root = document.getElementById('root')
if (root !== null) watcher.observe(root, { childList: true })
return () => {
disposed = true
observer?.disconnect()
watcher.disconnect()
centerColRef.current = null
}
}, [measureCenter])
/**
* Bottom-panel first-expansion auto terminal: the FIRST time the user
* expands the bottom panel in a session, try to open a fresh terminal tab
* there. "Try" is literal — the terminal's own quota and enable switch
* gate the attempt (a full quota or a disabled terminal type makes it a
* no-op). Gated on the bottomPanelAutoTerminal pref (the terminal tab's
* nested settings toggle, default on). Only a false→true TRANSITION fires
* (a panel persisted open never counts as an expansion), and the session's
* bottomOpenedOnce flag is set atomically with the first fire so later
* expansions never repeat it.
*/
const bottomWasOpenRef = useRef<boolean | undefined>(undefined)
useEffect(() => {
// The bottom panel does not exist on narrow viewports (the two
// workbenches merge into one panel), so the first-expansion auto
// terminal is a desktop-only behavior.
if (narrow) return
if (state === undefined) return
const wasOpen = bottomWasOpenRef.current
bottomWasOpenRef.current = state.bottomOpen
if (wasOpen === undefined || wasOpen || !state.bottomOpen) return
if (state.bottomOpenedOnce) return
if (store.getPrefs().bottomPanelAutoTerminal === false) return
if (ctx.betterSidebar?.isTabEnabled('terminal') === false) return
// Land the tab in the bottom panel's first pane; the once-flag is set
// atomically so later expansions never repeat the auto-open.
store.reduce(s => ({ ...s, activePane: firstLeaf(s.bottomSplits).id, bottomOpenedOnce: true }))
ctx.betterSidebar?.openTab({ type: 'terminal' })
}, [state, store, ctx, narrow])
// Panel drags: the right panel's width (left edge strip), the bottom
// panel's height (top edge strip), and the shared corner (both at once).
// Drags write the sizes DIRECTLY to the DOM (panel styles + the layout CSS
// variables) instead of round-tripping the store on every pointer move —
// a store reduce re-renders both workbenches (terminals, editors…) per
// move, which is the visible drag lag. The store is committed once on
// pointer up (clamping + persistence).
const panelRef = useRef<HTMLDivElement | null>(null)
const bottomRef = useRef<HTMLDivElement | null>(null)
const widthDrag = useRef({ startX: 0, startWidth: 0 })
const [draggingWidth, setDraggingWidth] = useState(false)
const bottomDrag = useRef({ startY: 0, startHeight: 0 })
const [draggingBottom, setDraggingBottom] = useState(false)
const cornerDrag = useRef({ startX: 0, startY: 0, startWidth: 0, startHeight: 0 })
const [draggingCorner, setDraggingCorner] = useState(false)
const anyDragging = draggingWidth || draggingBottom || draggingCorner
// Layout push: the app shell gives up the panel's width while it is open
// (0 while collapsed), so the conversation and input bar are squeezed
// instead of covered. The margin is capped at the viewport so a stale
// persisted width (e.g. fullscreen on a bigger window) can never crush
// the app shell to zero. Dragging disables the layout transition.
// Pause center-column measurement while dragging, and re-measure once the
// drag settles at its committed size. The store commit lands on release and
// the final width equals the last drag width, so no ResizeObserver event
// fires to refresh centerRect — this explicit re-measure covers that gap.
useEffect(() => {
const width = snapshot.state?.panelOpen === true
draggingRef.current = anyDragging
if (!anyDragging) measureCenter()
}, [anyDragging, measureCenter])
// Clamp mirrors of setWidth/setBottomHeight for mid-drag values (the store
// re-clamps on commit; these keep the panels from overshooting mid-drag).
const clampWidth = (width: number): number =>
Math.min(Math.max(PANEL_MIN, Math.round(width)), Math.max(PANEL_MIN, window.innerWidth))
const clampHeight = (height: number): number =>
Math.min(Math.max(BOTTOM_MIN, Math.round(height)), Math.max(BOTTOM_MIN, window.innerHeight - PANEL_MIN))
/** Apply a drag size to the DOM without touching React state or the store.
* The bottom panel's right edge tracks the right panel's left edge HERE
* too — React state only updates on release, so the inline right must be
* written directly or the bottom panel would lag the sidebar mid-drag. */
const applyDrag = (width: number, height: number): void => {
panelRef.current?.style.setProperty('width', `${width}px`)
bottomRef.current?.style.setProperty('height', `${height}px`)
// centerRect.right is the center column's right edge at the committed
// width (innerWidth - state.width - detailsWidth), so this equals
// `width + detailsWidth` — derived from the measured column, keeping the
// drag write-only (no React re-render mid-drag).
bottomRef.current?.style.setProperty('right', `${(window.innerWidth - centerRect.right) + (width - (state?.width ?? 0))}px`)
document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
// The corner handle positions itself relative to the panel (CSS
// `bottom: calc(var(--dsh-sidebar-height) + 6px)`), so these two layout
// variables are all it needs — no viewport coordinates written here
// (issue #106: skins that inset the panels must not fight JS coords).
}
// Drags write at most once per frame: pointer events fire several times
// faster than the display refresh, and each write reflows the app shell
// (the layout push) plus the panels — batching to one write per frame is
// what keeps the drag smooth. The store is still committed once on release.
const dragFrame = useRef<number | null>(null)
const pendingDrag = useRef<{ width: number; height: number } | null>(null)
const scheduleDrag = (width: number, height: number): void => {
pendingDrag.current = { width, height }
if (dragFrame.current !== null) return
dragFrame.current = requestAnimationFrame(() => {
dragFrame.current = null
const pending = pendingDrag.current
if (pending !== null) {
pendingDrag.current = null
applyDrag(pending.width, pending.height)
}
})
}
/** Flush any pending drag write and stop scheduling (the store commit on
* pointer up applies the final clamped values). */
const stopDragScheduling = (): void => {
if (dragFrame.current !== null) {
cancelAnimationFrame(dragFrame.current)
dragFrame.current = null
}
pendingDrag.current = null
}
// Layout push: the app shell gives up the panel's width/height while the
// panels are open (0 while collapsed), so the conversation and input bar
// are squeezed instead of covered. The margins are capped at the viewport
// so a stale persisted size (e.g. fullscreen on a bigger window) can never
// crush the app shell to zero. Dragging disables the layout transition.
// On NARROW viewports the drawer FLOATS over the app shell — no push, the
// conversation keeps the full width behind the drawer.
useEffect(() => {
const width = !narrow && snapshot.state?.panelOpen === true
? Math.min(snapshot.state.width, window.innerWidth)
: 0
const height = !narrow && snapshot.state?.bottomOpen === true
? Math.min(snapshot.state.bottomHeight, window.innerHeight)
: 0
document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
}, [snapshot.state?.panelOpen, snapshot.state?.width])
document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
// Unmount must release the push (issue #31): when the boundary swaps the
// whole sidebar after a render crash (or the plugin fiber is disposed /
// HMR), the CSS variables would otherwise stay on <html> and layout.css
// keeps squeezing #root with a stale margin — "the sidebar cannot be
// hidden" until a full reload. removeProperty restores the CSS fallback
// (var(--dsh-sidebar-width, 0px)); React re-runs cleanup+setup in the
// same commit on state changes, so there is no visible flicker.
return () => {
document.documentElement.style.removeProperty('--dsh-sidebar-width')
document.documentElement.style.removeProperty('--dsh-sidebar-height')
}
}, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight])
useEffect(() => {
if (draggingWidth) document.body.setAttribute('data-dsh-sidebar-dragging', '')
if (anyDragging) document.body.setAttribute('data-dsh-sidebar-dragging', '')
else document.body.removeAttribute('data-dsh-sidebar-dragging')
}, [draggingWidth])
}, [anyDragging])
// Whether the panel currently fills the viewport (fullscreen expansion).
const fullscreen = state !== undefined && window.innerWidth - state.width < 8
const actions: WorkbenchActions = useMemo(() => ({
closeTab: (paneId, tabId) => {
// A closed terminal releases its pty immediately — including when its
@@ -251,9 +579,17 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// 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)
// Terminal tabs may live in EITHER tree (the bottom panel hosts them
// too) — the pty-release lookup covers both, or the HTTP fallback is
// skipped for a bottom-panel terminal whose WS frame never arrived.
const leaf = current === undefined
? undefined
: leafWithTab(current.splits, tabId) ?? leafWithTab(current.bottomSplits, tabId)
const tab = leaf?.tabs.find(candidate => candidate.id === tabId)
store.reduce(s => closeTab(s, paneId, tabId))
// Route through the service: the tab-bar close is the canonical close
// path (finds the pane itself, fires descriptor.onClose); the session
// scope (with its cwd) rides to the callback.
ctx.betterSidebar?.closeTab(tabId, sessionId === undefined ? undefined : { sessionId, cwd })
if (tab?.type === 'terminal') {
if (isAgentTabId(tabId)) {
const uuid = agentUuidOf(tabId)
@@ -264,13 +600,10 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
}
},
activateTab: (paneId, tabId) => {
store.reduce(s => ({
...s,
activePane: paneId,
splits: mapLeaf(s.splits, paneId, (leaf) => {
if (leaf.tabs.some(tab => tab.id === tabId)) leaf.active = tabId
}),
}))
// Route through the service: same reducer (finds the pane in EITHER
// tree, sets the active pane) and fires descriptor.onActivate; the
// session scope (with its cwd) rides to the callback.
ctx.betterSidebar?.activateTab(tabId, sessionId === undefined ? undefined : { sessionId, cwd })
},
focusPane: (paneId) => { store.reduce(s => ({ ...s, activePane: paneId })) },
moveTabToEdge: (payload: TabDragPayload, toPane: string, zone: DropZone) => {
@@ -287,7 +620,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
})
},
resizeSplit: (splitId, index, deltaFrac) => {
store.reduce(s => ({ ...s, splits: resizeSplit(s.splits, splitId, index, deltaFrac) }))
store.reduce(s => resizeSplitIn(s, splitId, index, deltaFrac))
},
}), [store, sessionId, cwd])
@@ -302,26 +635,22 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
*/
const referenceInChat = useCallback((path: string): void => {
if (sessionId === undefined) return
try {
const actx = ctx.sessions.scope(sessionId)
if (actx === undefined) return
const conversation = ctx.get('conversation') as SidebarConversation | undefined
if (conversation === undefined) return
const input = conversation.input.for(actx)
const mention = `@${relativeTo(cwd ?? '', path)}`
const draft = input.state.getSnapshot().draft
input.setDraft(draft.trim() === '' ? mention : `${draft} ${mention}`)
} catch (error) {
console.warn('[dsh-better-sidebar] reference insert failed:', error)
}
appendToDraft(ctx, sessionId, `@${relativeTo(cwd ?? '', path)}`)
}, [ctx, sessionId, cwd])
if (state === undefined || sessionId === undefined) {
return (
<div className={css.toggleRail}>
<div className={css.toggleCluster}>
{!narrow && (
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
<IconPanelBottomOutline16 />
</button>
</Tooltip>
)}
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
<IconPanelLeftOutline16 size={16} />
<IconPanelRightOutline16 />
</button>
</Tooltip>
</div>
@@ -333,7 +662,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
const descriptor = service?.getTab(optionId)
if (descriptor === undefined) return
const title = typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title
service.openTab({ type: optionId, title })
// The session scope rides along: lifecycle callbacks receive it (and
// the open stays in the current session, as before).
service.openTab({ type: optionId, title }, { sessionId, cwd })
}
/**
@@ -342,6 +673,33 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
* ctx and the conversation input service at click time; a missing service
* or scope degrades to a logged no-op, never a crash.
*/
/** The tab icon from the tab-type registry (shared by every workbench). */
const tabIconOf = (tab: SidebarTab): ReactNode => {
const descriptor = ctx.betterSidebar?.getTab(tab.type)
if (descriptor === undefined) return null
return typeof descriptor.icon === 'function' ? descriptor.icon(14) : descriptor.icon
}
/**
* The tab badge from the tab-type registry: a count (99+ capped) or a
* short text pill. A throwing badge is swallowed (no pill) — the tab
* strip must never break because a plugin's badge computation failed.
*/
const tabBadgeOf = (tab: SidebarTab): ReactNode => {
const descriptor = ctx.betterSidebar?.getTab(tab.type)
if (descriptor?.badge === undefined) return null
let value: string | number | null | undefined
try {
value = descriptor.badge(ctx, { sessionId, cwd }, state)
} catch (error) {
console.error('[dsh-better-sidebar] tab badge error:', error)
return null
}
if (value === null || value === undefined || value === '') return null
const text = typeof value === 'number' ? (value > 99 ? '99+' : String(value)) : String(value)
return <span className={css.tabBadge}>{text}</span>
}
/**
* 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
@@ -349,7 +707,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
* 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, paneId: string) => (
const renderTab = (tab: SidebarTab, active: boolean, paneId: string, bottom = false) => (
<TabContent
tab={tab}
sessionId={sessionId}
@@ -359,7 +717,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
onReferenceFile={referenceInChat}
ctx={ctx}
store={store}
visible={state.panelOpen && active}
visible={bottom ? state.bottomOpen && active : state.panelOpen && active}
onSubagentJump={(childSessionId) => { subagentJumpRef.current = childSessionId }}
onOpenDiff={(diffTab) => { store.reduce(s => openDiffTab(s, paneId, diffTab)) }}
/>
@@ -367,77 +725,85 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
return (
<>
{!state.panelOpen && (
<div className={css.toggleRail}>
<Tooltip label={t('expand')} side="bottom" delayMs={500}>
{/*
The persistent toggle cluster at the top-right corner: the bottom
panel's button (bottom glyph) LEFT of the right panel's (side glyph).
Always pinned to the viewport corner — inside the right panel's
top-right while it is open, sitting flush in the tab strip whose
right end it really squeezes (the strip reserves its width via CSS),
so the tabs genuinely yield space to it.
*/}
<div className={css.toggleCluster}>
{/*
Narrow viewports merge the two workbenches into the one drawer —
there is no bottom panel, so its toggle button is not offered.
*/}
{!narrow && (
<Tooltip label={state.bottomOpen ? t('collapseBottomPanel') : t('expandBottomPanel')} side="bottom" delayMs={500}>
<button
type="button"
className={css.toggleButton}
aria-label={t('expand')}
onClick={() => { store.reduce(togglePanel) }}
aria-label={state.bottomOpen ? t('collapseBottomPanel') : t('expandBottomPanel')}
onClick={() => { store.reduce(toggleBottomPanel) }}
>
<IconPanelLeftOutline16 size={16} />
<IconPanelBottomOutline16 />
</button>
</Tooltip>
</div>
)}
)}
<Tooltip label={state.panelOpen ? t('collapse') : t('expand')} side="bottom" delayMs={500}>
<button
type="button"
className={css.toggleButton}
aria-label={state.panelOpen ? t('collapse') : t('expand')}
onClick={() => { store.reduce(togglePanel) }}
>
<IconPanelRightOutline16 />
</button>
</Tooltip>
</div>
{/*
The panel stays mounted while collapsed (hidden off-screen) so the
slide in/out can animate; visibility hides it after the slide settles.
The right panel stays mounted while collapsed (hidden off-screen) so
the slide in/out can animate; visibility hides it after the slide
settles. Its bottom edge follows the bottom panel's height (0 while
the bottom panel is closed) — the VSCode-style "sidebar above panel".
On NARROW viewports it is a full-width drawer holding both
workbenches (see MobileWorkbench); the width drag strip is not
offered there — a full-screen sheet has nothing to drag.
*/}
<div
ref={panelRef}
className={clsx(css.panel, !state.panelOpen && css.panelHidden)}
style={{ width: Math.min(state.width, window.innerWidth) }}
data-dragging={draggingWidth || undefined}
style={{ width: narrow ? '100vw' : Math.min(state.width, window.innerWidth) }}
data-dragging={anyDragging || undefined}
>
<div
className={clsx(css.panelResize, draggingWidth && css.panelResizeActive)}
onPointerDown={(event) => {
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
widthDrag.current = { startX: event.clientX, startWidth: state.width }
setDraggingWidth(true)
}}
onPointerMove={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
const { startX, startWidth } = widthDrag.current
store.reduce(s => setWidth(s, startWidth + (startX - event.clientX)))
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
event.currentTarget.releasePointerCapture(event.pointerId)
setDraggingWidth(false)
}}
/>
<div className={css.panelHeader}>
<button
type="button"
className={css.iconButton}
aria-label={fullscreen ? t('restoreFullscreen') : t('expandFullscreen')}
title={fullscreen ? t('restoreFullscreen') : t('expandFullscreen')}
onClick={() => {
const viewport = window.innerWidth
store.reduce(s => setWidth(s, fullscreen
? Math.max(280, Math.round((viewport - 320) / 2))
: viewport))
{!narrow && (
<div
className={clsx(css.panelResize, draggingWidth && css.panelResizeActive)}
onPointerDown={(event) => {
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
widthDrag.current = { startX: event.clientX, startWidth: state.width }
setDraggingWidth(true)
}}
>
<IconFullscreenOutline16 />
</button>
<span className={css.panelTitle}>
{t('explorer')}
{cwd !== undefined ? ` · ${cwd.split(/[\\/]/).filter(Boolean).pop() ?? cwd}` : ''}
</span>
<button
type="button"
className={css.iconButton}
aria-label={t('collapse')}
title={t('collapse')}
onClick={() => { store.reduce(togglePanel) }}
>
<IconChevronRightOutline14 />
</button>
</div>
onPointerMove={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
const { startX, startWidth } = widthDrag.current
const width = clampWidth(startWidth + (startX - event.clientX))
const height = state.bottomOpen ? Math.min(state.bottomHeight, window.innerHeight) : 0
scheduleDrag(width, height)
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
event.currentTarget.releasePointerCapture(event.pointerId)
const { startX, startWidth } = widthDrag.current
stopDragScheduling()
store.reduce(s => setWidth(s, startWidth + (startX - event.clientX)))
setDraggingWidth(false)
}}
/>
)}
<div className={css.panelBody}>
<Workbench
state={state}
@@ -445,14 +811,137 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
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
getTabIcon={tabIconOf}
getTabBadge={tabBadgeOf}
/>
</div>
{/*
The shared corner (only while BOTH panels are open): the
intersection of the right panel's left edge and the bottom panel's
top edge. Horizontal drags resize the right panel's width, vertical
drags the bottom panel's height — the two panels drag against each
other. Rendered INSIDE the right panel and positioned by CSS
relative to it (left edge + the bottom panel's height via the
--dsh-sidebar-height layout variable) — no JS-written viewport
coordinates to keep in sync. (Never on narrow viewports: the
bottom panel does not exist there.)
*/}
{!narrow && state.panelOpen && state.bottomOpen && (
<div
className={css.cornerHandle}
data-dragging={draggingCorner || undefined}
onPointerDown={(event) => {
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
cornerDrag.current = {
startX: event.clientX,
startY: event.clientY,
startWidth: state.width,
startHeight: state.bottomHeight,
}
setDraggingCorner(true)
}}
onPointerMove={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
const { startX, startY, startWidth, startHeight } = cornerDrag.current
const width = clampWidth(startWidth + (startX - event.clientX))
const height = clampHeight(startHeight + (startY - event.clientY))
scheduleDrag(width, height)
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
event.currentTarget.releasePointerCapture(event.pointerId)
const { startX, startY, startWidth, startHeight } = cornerDrag.current
stopDragScheduling()
store.reduce(s => setBottomHeight(setWidth(s, startWidth + (startX - event.clientX)), startHeight + (startY - event.clientY)))
setDraggingCorner(false)
}}
/>
)}
</div>
{/*
The bottom panel: a second, independent workbench. It squeezes ONLY
the center column (the agent output area): it starts at the app
shell's own left sidebar and ends at the right panel's left edge —
neither sidebar gives up any position (the right panel keeps its
full height). Its resize strip is the top edge; hidden by sliding
down like the right panel. On NARROW viewports it does not exist —
the bottom workbench lives inside the drawer (MobileWorkbench).
*/}
{!narrow && (
<div
ref={bottomRef}
className={clsx(css.bottomPanel, !state.bottomOpen && css.bottomPanelHidden)}
style={{
height: Math.min(state.bottomHeight, window.innerHeight),
left: centerRect.left,
// Direct from the center column's measured right edge: the bottom
// panel spans ONLY the center column, ending exactly at the
// details column's left edge (the details column sits between the
// center and the right panel, and the right panel's margin-right
// push is already baked into centerRect.right).
right: window.innerWidth - centerRect.right,
// The seam against the open right panel needs its own hairline
// (the right panel's border-left alone is covered by this panel's
// fill — without it the corner looks cut off).
borderRight: state.panelOpen ? '1px solid var(--dsw-alias-border-l2)' : undefined,
}}
data-dragging={(draggingBottom || draggingCorner) || undefined}
>
<div
className={clsx(css.bottomResize, draggingBottom && css.bottomResizeActive)}
onPointerDown={(event) => {
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
bottomDrag.current = { startY: event.clientY, startHeight: state.bottomHeight }
setDraggingBottom(true)
}}
onPointerMove={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
const { startY, startHeight } = bottomDrag.current
const height = clampHeight(startHeight + (startY - event.clientY))
scheduleDrag(Math.min(state.width, window.innerWidth), height)
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
event.currentTarget.releasePointerCapture(event.pointerId)
const { startY, startHeight } = bottomDrag.current
stopDragScheduling()
store.reduce(s => setBottomHeight(s, startHeight + (startY - event.clientY)))
setDraggingBottom(false)
}}
/>
{/*
The bottom panel's own close control at its tab strip's right end
(the strip reserves the width via CSS so the + menu never hides
under it): one tap collapses the panel.
*/}
<Tooltip label={t('collapseBottomPanel')} side="bottom" delayMs={500}>
<button
type="button"
className={css.bottomClose}
aria-label={t('collapseBottomPanel')}
onClick={() => { store.reduce(toggleBottomPanel) }}
>
<IconCloseFill14 />
</button>
</Tooltip>
<div className={css.panelBody}>
<Workbench
state={state}
tree={state.bottomSplits}
newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
actions={actions}
onNewTab={onNewTab}
renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, true)}
getTabIcon={tabIconOf}
getTabBadge={tabBadgeOf}
/>
</div>
</div>
)}
</>
)
}
+272
View File
@@ -273,3 +273,275 @@
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
/* ── Background-job section (below the topology tree) ────────────────── */
.jobs {
margin-top: 10px;
border-top: 1px solid var(--dsw-alias-border-l2);
padding-top: 8px;
}
.jobsHeader {
display: flex;
align-items: center;
gap: 8px;
height: 26px;
padding: 0 2px;
}
.jobsTitle {
flex: 1;
min-width: 0;
font: var(--dsw-font-xxxs-strong-11);
color: var(--dsw-alias-label-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.jobsCount {
flex: none;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
}
.jobsList {
display: flex;
flex-direction: column;
gap: 2px;
margin: 0;
padding: 0;
list-style: none;
}
/* One job row; the whole row lights up on hover, the expandable target
stays transparent (the kill button rides on the row's right edge). */
.jobsRow {
display: flex;
align-items: center;
gap: 4px;
border-radius: 8px;
}
.jobsRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.jobsRowSettled {
opacity: 0.8;
}
/* The row whose output is shown in the bottom dock. */
.jobsRowSelected,
.jobsRowSelected:hover {
background: var(--dsw-alias-interactive-bg-active);
}
.jobsRowMain {
flex: 1;
display: flex;
align-items: flex-start;
gap: 8px;
min-width: 0;
padding: 6px 8px 6px 11px;
border: none;
border-radius: 8px;
background: transparent;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
text-align: left;
cursor: pointer;
outline: none;
}
.jobsRowMain:focus-visible {
background: var(--dsw-alias-interactive-bg-hover);
}
.jobsDot {
margin-top: 5px;
}
.jobsContent {
display: flex;
flex-direction: column;
min-width: 0;
gap: 1px;
}
/* The command line: a kind badge followed by the command itself in the code
face, like the terminal's live lines. */
.jobsLabelLine {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.jobsKind {
flex: none;
max-width: 90px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0 5px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 4px;
font: var(--dsw-font-xxxs-strong-11);
line-height: 14px;
color: var(--dsw-alias-label-tertiary);
}
.jobsLabel {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--ds-font-family-code);
font-size: var(--dsw-font-xxxs-11-font-size);
line-height: var(--dsw-font-xxxs-11-line-height);
color: var(--dsw-alias-label-primary);
}
.jobsSecondary {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
}
.jobsKill {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
margin-right: 4px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
}
.jobsKill:hover {
background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);
color: var(--dsw-alias-state-error-primary);
}
/* The armed (first-click) state: a filled confirm chip instead of the icon. */
.jobsKillArmed,
.jobsKillArmed:hover {
width: auto;
height: 20px;
padding: 0 8px;
background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);
color: var(--dsw-alias-state-error-primary);
font: var(--dsw-font-xxxs-strong-11);
white-space: nowrap;
}
.jobsKill:disabled {
opacity: 0.5;
cursor: default;
}
.jobsKillError {
flex: none;
margin-right: 4px;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-state-error-primary);
}
/* ── The shared output dock (bottom of the page, sticky like a terminal) ── */
/* Direct child of the scrolling body, so it docks at the scrollport's bottom
edge while the job list scrolls above it; a solid background keeps the
tree from showing through. */
.jobsPane {
position: sticky;
bottom: 0;
z-index: 1;
margin-top: 4px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
background: var(--dsw-alias-bg-base);
box-shadow: 0 -6px 12px -8px rgba(0, 0, 0, 0.35);
overflow: hidden;
}
.jobsPaneHeader {
display: flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 4px 0 10px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
}
.jobsPaneDot {
flex: none;
}
.jobsPaneLabel {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--ds-font-family-code);
font-size: var(--dsw-font-xxxs-11-font-size);
line-height: var(--dsw-font-xxxs-11-line-height);
color: var(--dsw-alias-label-primary);
}
.jobsPaneStatus {
flex: none;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
}
.jobsPaneClose {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: none;
border-radius: 5px;
background: transparent;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
}
.jobsPaneClose:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.jobsPanePre {
margin: 0;
max-height: 200px;
overflow: auto;
padding: 6px 10px;
font-family: var(--ds-font-family-code);
font-size: var(--dsw-font-xxxs-11-font-size);
line-height: 1.5;
color: var(--dsw-alias-label-primary);
white-space: pre-wrap;
word-break: break-word;
}
.jobsPaneHint {
padding: 8px 10px;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
}
.jobsPaneError {
color: var(--dsw-alias-state-error-primary);
}
+342 -60
View File
@@ -27,11 +27,13 @@ import {
} from '@deepseek-ai/dsh-client-ui-primitives'
import type {
Context,
SidebarSessionList,
SidebarSessionSummary,
SidebarSubagentAddress,
SidebarSubagentCatalog,
SidebarSubagentChildEntry,
SidebarSubagentDiagnosticEntry,
SidebarJobView,
} from '../context-types.ts'
import {
collectBranchIds,
@@ -39,6 +41,17 @@ import {
rootAncestor,
} from './subagent-detect.ts'
import { lastActivity } from './subagent-activity.ts'
import {
collectTreeJobs,
formatJobDuration,
isJobLive,
orderJobs,
jobDotState,
jobStatusLabel,
type TreeJob,
} from './subagent-jobs.ts'
import { api, type JobOutputResult } from './api.ts'
import { IconStopOutline16 } from './icons.tsx'
import { t } from './locales.ts'
import css from './SubagentView.module.css'
@@ -46,6 +59,10 @@ import css from './SubagentView.module.css'
const POLL_MS = 3000
/** Preview cap of one tool-call argument line. */
const ARGS_PREVIEW = 60
/** Refresh cadence of an expanded job-output panel while its job runs. */
const JOB_POLL_MS = 2000
/** How long the kill button stays armed before it needs re-confirming. */
const JOB_KILL_ARM_MS = 3000
/** The direct subagent children of one parent (durable `origin` rows). */
function directChildren(
@@ -340,6 +357,262 @@ function CatalogRows({
)
}
/**
* The shared output dock of the jobs section: ONE pane at the bottom of the
* sidebar body (sticky, terminal-like) shows the SELECTED job's output as
* the MODEL has read it so far (replayed from the owner session's event
* log), refreshed every {@link JOB_POLL_MS} while the job runs and the
* page is visible. The model's `job_output` cursor is never touched — the
* pane can never steal the agent's bytes, and it stays empty until the
* agent reads the job. A single dock — not a panel per row — keeps the
* job list compact and stable when many jobs are running.
*/
function JobOutputPane(props: {
ownerSessionId: string
job: SidebarJobView
/** The page is visible (active tab + open panel): skip polling otherwise. */
active: boolean
onClose: () => void
}) {
const { ownerSessionId, job, active, onClose } = props
const [state, setState] = useState<'loading' | JobOutputResult | 'error'>('loading')
const controllerRef = useRef<AbortController | undefined>(undefined)
const preRef = useRef<HTMLPreElement>(null)
const load = useCallback(async (): Promise<void> => {
controllerRef.current?.abort()
const controller = new AbortController()
controllerRef.current = controller
try {
const result = await api.jobOutput({ sessionId: ownerSessionId }, job.id, controller.signal)
setState(result)
} catch {
// A newer pull aborted this one, or the wire failed: keep the last
// known output; only a dock that never loaded anything shows an error.
setState(current => (current === 'loading' ? 'error' : current))
}
}, [ownerSessionId, job.id])
useEffect(() => {
void load()
if (!active || !isJobLive(job)) return
const timer = window.setInterval(() => { void load() }, JOB_POLL_MS)
return () => { window.clearInterval(timer) }
}, [load, active, job.status])
useEffect(() => () => { controllerRef.current?.abort() }, [])
// Terminal-tail behavior: while the job runs, each refresh pins the view
// to the newest output; a settled dock leaves scrolling to the reader.
useEffect(() => {
if (!isJobLive(job) || typeof state !== 'object' || state.text.length === 0) return
const pre = preRef.current
if (pre !== null) pre.scrollTop = pre.scrollHeight
}, [state, job.status])
return (
<div className={css.jobsPane} role="region" aria-label={`${job.label} ${t('jobs')}`}>
<div className={css.jobsPaneHeader}>
<StateDot state={jobDotState(job.status)} className={css.jobsPaneDot} />
<span className={css.jobsPaneLabel} title={job.label}>{job.label}</span>
<span className={css.jobsPaneStatus}>
{jobStatusLabel(job.status, t)}
{job.detail !== undefined && job.detail !== '' ? ` · ${job.detail}` : ''}
</span>
<button
type="button"
className={css.jobsPaneClose}
aria-label={t('close')}
title={t('close')}
onClick={onClose}
>
<IconStopOutline16 size={10} />
</button>
</div>
{state === 'loading' && <div className={css.jobsPaneHint}>{t('loading')}</div>}
{state === 'error' && (
<div className={`${css.jobsPaneHint} ${css.jobsPaneError}`}>{t('jobOutputError')}</div>
)}
{typeof state === 'object' && (
<>
{state.text.length > 0
? <pre ref={preRef} className={css.jobsPanePre}>{state.text}</pre>
: state.read
? <div className={css.jobsPaneHint}>{t('jobNoOutput')}</div>
: <div className={css.jobsPaneHint}>{t('jobNotReadYet')}</div>}
{state.truncated && <div className={css.jobsPaneHint}>{t('jobOutputTruncated')}</div>}
</>
)}
</div>
)
}
/**
* The background-job section of the Subagent page: every job of the whole
* current tree (main agent + subagents, owner-labeled), fed by the harness
* `session/jobs` push mirror. Clicking a row feeds its model-read output to
* the shared bottom dock (event replay — never the model's cursor); live
* rows carry a two-click-confirm kill button. Renders nothing while the
* tree has no jobs.
*/
function JobsSection(props: {
byId: SidebarSessionList['byId']
jobsBySession: SidebarSessionList['jobsBySession']
rootId: string | undefined
/** The page is visible (active tab + open panel): skip polling otherwise. */
active: boolean
}) {
const { byId, jobsBySession, rootId, active } = props
const rows = useMemo(
() => orderJobs(collectTreeJobs(byId, jobsBySession, rootId)),
[byId, jobsBySession, rootId],
)
const [selectedId, setSelectedId] = useState<string | undefined>(undefined)
const [armedId, setArmedId] = useState<string | undefined>(undefined)
const [killingId, setKillingId] = useState<string | undefined>(undefined)
const [killErrorId, setKillErrorId] = useState<string | undefined>(undefined)
// The duration clock only runs while a live row is on screen.
const [now, setNow] = useState(() => Date.now())
const selectedRow = useMemo(
() => (selectedId === undefined ? undefined : rows.find(row => row.job.id === selectedId)),
[rows, selectedId],
)
const liveCount = useMemo(
() => rows.reduce((count, row) => count + (isJobLive(row.job) ? 1 : 0), 0),
[rows],
)
const multiOwner = useMemo(
() => new Set(rows.map(row => row.ownerSessionId)).size > 1,
[rows],
)
// The kill button stays armed only briefly; a stray click must never kill.
useEffect(() => {
if (armedId === undefined) return
const timer = window.setTimeout(() => { setArmedId(undefined) }, JOB_KILL_ARM_MS)
return () => { window.clearTimeout(timer) }
}, [armedId])
useEffect(() => {
if (liveCount === 0) return
setNow(Date.now())
const timer = window.setInterval(() => { setNow(Date.now()) }, 1_000)
return () => { window.clearInterval(timer) }
}, [liveCount])
// The docked output pane follows its job: when the selected job leaves
// the mirror (settled and dropped, or the tree switched), close the dock.
useEffect(() => {
if (selectedId !== undefined && selectedRow === undefined) setSelectedId(undefined)
}, [selectedId, selectedRow])
// NOTE: every hook must live ABOVE the empty-state return — a hook below it
// would flip this component's hook count when the mirror empties and crash
// React with "Rendered fewer hooks than expected" (the #300 regression).
const kill = useCallback(async (row: TreeJob): Promise<void> => {
setKillingId(row.job.id)
setKillErrorId(undefined)
try {
await api.jobKill({ sessionId: row.ownerSessionId }, row.job.id)
} catch {
setKillErrorId(row.job.id)
} finally {
setKillingId(undefined)
setArmedId(undefined)
}
}, [])
if (rows.length === 0) return null
const countLabel = liveCount > 0
? t('jobsCountRunning', { count: rows.length, running: liveCount })
: t('jobsCount', { count: rows.length })
return (
<>
<section className={css.jobs} aria-label={t('jobs')}>
<div className={css.jobsHeader}>
<span className={css.jobsTitle}>{t('jobs')}</span>
<span className={css.jobsCount}>{countLabel}</span>
</div>
<ul className={css.jobsList} aria-label={t('jobs')}>
{rows.map((row) => {
const { job } = row
const live = isJobLive(job)
const selected = selectedId === job.id
const armed = armedId === job.id
const killing = killingId === job.id
const killFailed = killErrorId === job.id
const elapsed = live
? now - job.startedAt
: (job.finishedAt ?? job.startedAt) - job.startedAt
const secondary = [
...(multiOwner ? [row.ownerTitle] : []),
jobStatusLabel(job.status, t),
...(job.detail !== undefined && job.detail !== '' ? [job.detail] : []),
formatJobDuration(elapsed, t),
].filter(Boolean).join(' · ')
return (
<li
key={job.id}
className={clsx(
css.jobsRow,
!live && css.jobsRowSettled,
selected && css.jobsRowSelected,
)}
>
<button
type="button"
className={css.jobsRowMain}
aria-pressed={selected}
aria-label={`${job.label} ${secondary}`}
onClick={() => { setSelectedId(selected ? undefined : job.id) }}
>
<StateDot state={jobDotState(job.status)} className={css.jobsDot} />
<span className={css.jobsContent}>
<span className={css.jobsLabelLine}>
<span className={css.jobsKind}>{job.kind}</span>
<span className={css.jobsLabel} title={job.label}>{job.label}</span>
</span>
<span className={css.jobsSecondary}>{secondary}</span>
</span>
</button>
{job.status === 'running' && (
<button
type="button"
className={armed ? `${css.jobsKill} ${css.jobsKillArmed}` : css.jobsKill}
aria-label={armed ? t('jobKillConfirm') : t('jobKill')}
title={armed ? t('jobKillConfirm') : t('jobKill')}
disabled={killing}
onClick={(event) => {
event.stopPropagation()
if (armed) void kill(row)
else setArmedId(job.id)
}}
>
{armed ? t('jobKillConfirm') : <IconStopOutline16 size={12} />}
</button>
)}
{killFailed && <span className={css.jobsKillError}>{t('jobKillError')}</span>}
</li>
)
})}
</ul>
</section>
{selectedRow !== undefined && (
<JobOutputPane
ownerSessionId={selectedRow.ownerSessionId}
job={selectedRow.job}
active={active}
onClose={() => { setSelectedId(undefined) }}
/>
)}
</>
)
}
/**
* The sidebar's Subagent topology page.
* @param props - current session id, whether the page is actually visible
@@ -515,69 +788,78 @@ export function SubagentView(props: {
<div
ref={bodyRef}
className={css.subagentBody}
role="tree"
aria-label={t('subagent')}
aria-busy={summaryBackedLoading || undefined}
onKeyDown={onTreeKeyDown}
>
{rootId !== undefined && rootSummary !== undefined && (
<div
role="treeitem"
tabIndex={0}
aria-level={0}
aria-label={`${rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')} ${t('subagentMainAgent')}`}
aria-current={rootId === sessionId ? 'true' : undefined}
className={clsx(css.subagentRow, rootId === sessionId && css.subagentRowActive)}
onClick={openMain}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
event.stopPropagation()
openMain()
}
}}
>
<StateDot
state={rootSummary.running === true ? 'ongoing' : 'done'}
className={css.subagentDot}
/>
<span className={css.subagentContent}>
<span className={css.subagentLabel}>
{rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')}
</span>
<span className={css.subagentSecondary}>
{`${t('subagentMainAgent')} · ${rootSummary.running === true ? t('subagentRunning') : t('subagentInactive')}`}
</span>
</span>
</div>
)}
{rootId !== undefined && (
<div className={css.subagentChildren} role="group" aria-busy={summaryBackedLoading || undefined}>
{summaryBackedLoading && (
<CatalogLoadingRows parentSessionId={rootId} byId={byId} level={1} />
)}
{!summaryBackedLoading && (
<CatalogRows
parentSessionId={rootId}
catalog={rootCatalog}
catalogs={catalogs}
byId={byId}
level={1}
currentSessionId={sessionId}
active={active}
ctx={ctx}
openChild={openChild}
refresh={refresh}
<div
role="tree"
aria-label={t('subagent')}
aria-busy={summaryBackedLoading || undefined}
>
{rootId !== undefined && rootSummary !== undefined && (
<div
role="treeitem"
tabIndex={0}
aria-level={0}
aria-label={`${rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')} ${t('subagentMainAgent')}`}
aria-current={rootId === sessionId ? 'true' : undefined}
className={clsx(css.subagentRow, rootId === sessionId && css.subagentRowActive)}
onClick={openMain}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
event.stopPropagation()
openMain()
}
}}
>
<StateDot
state={rootSummary.running === true ? 'ongoing' : 'done'}
className={css.subagentDot}
/>
)}
</div>
)}
{readyEmpty && (
<div className={css.subagentEmpty}>
<div>{t('subagentEmpty')}</div>
<div className={css.subagentEmptyHint}>{t('subagentEmptyDesc')}</div>
</div>
)}
<span className={css.subagentContent}>
<span className={css.subagentLabel}>
{rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')}
</span>
<span className={css.subagentSecondary}>
{`${t('subagentMainAgent')} · ${rootSummary.running === true ? t('subagentRunning') : t('subagentInactive')}`}
</span>
</span>
</div>
)}
{rootId !== undefined && (
<div className={css.subagentChildren} role="group" aria-busy={summaryBackedLoading || undefined}>
{summaryBackedLoading && (
<CatalogLoadingRows parentSessionId={rootId} byId={byId} level={1} />
)}
{!summaryBackedLoading && (
<CatalogRows
parentSessionId={rootId}
catalog={rootCatalog}
catalogs={catalogs}
byId={byId}
level={1}
currentSessionId={sessionId}
active={active}
ctx={ctx}
openChild={openChild}
refresh={refresh}
/>
)}
</div>
)}
{readyEmpty && (
<div className={css.subagentEmpty}>
<div>{t('subagentEmpty')}</div>
<div className={css.subagentEmptyHint}>{t('subagentEmptyDesc')}</div>
</div>
)}
</div>
<JobsSection
byId={byId}
jobsBySession={list.jobsBySession}
rootId={rootId}
active={active}
/>
</div>
</div>
)
+28 -3
View File
@@ -5,7 +5,7 @@
* terminal). Tabs are draggable; dropping onto another tab inserts before it,
* dropping on the strip background appends to this pane.
*/
import { useEffect, useState, type ReactNode } from 'react'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import clsx from 'clsx'
import {
IconCloseFill14, IconPlusOutline16, Menu,
@@ -63,12 +63,36 @@ export function TabBar(props: {
onDropTab: (payload: TabDragPayload, before: string | null) => void
/** Icon resolver for tab labels (reads from the tab descriptor registry). */
getTabIcon?: (tab: SidebarTab) => ReactNode
/** Badge resolver for tab labels (reads the descriptor's `badge`; the
* resolver returns the rendered pill or null). */
getTabBadge?: (tab: SidebarTab) => ReactNode
}) {
const {
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, getTabIcon,
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, getTabIcon, getTabBadge,
} = props
const [menuOpen, setMenuOpen] = useState(false)
const [dragOver, setDragOver] = useState(false)
const listRef = useRef<HTMLDivElement>(null)
// Wheel over the strip scrolls the tab row horizontally (a plain mouse
// wheel emits deltaY, which overflow-x alone never consumes). Bound as a
// native NON-passive listener: React registers onWheel passively at the
// root, where preventDefault() is a no-op. Modifier keys keep their native
// meaning (shift = horizontal scroll, ctrl/cmd = zoom), and a strip that
// does not overflow leaves the event alone so the page scrolls normally.
useEffect(() => {
const el = listRef.current
if (el === null) return
const onWheel = (event: WheelEvent): void => {
if (event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return
if (el.scrollWidth <= el.clientWidth) return
event.preventDefault()
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? el.clientWidth : 1
el.scrollLeft += (event.deltaX + event.deltaY) * unit
}
el.addEventListener('wheel', onWheel, { passive: false })
return () => { el.removeEventListener('wheel', onWheel) }
}, [])
useEffect(() => {
const clear = (): void => { setTabDragging(false); setDragOver(false) }
@@ -104,7 +128,7 @@ export function TabBar(props: {
if (payload !== null) onDropTab(payload, null)
}}
>
<div className={css.tabList}>
<div ref={listRef} className={css.tabList}>
{tabs.map(tab => (
<div
key={tab.id}
@@ -136,6 +160,7 @@ export function TabBar(props: {
}}
>
{getTabIcon?.(tab) ?? null}
{getTabBadge?.(tab) ?? null}
<span className={css.tabTitle}>{tab.title}</span>
<button
type="button"
+136 -11
View File
@@ -20,18 +20,32 @@
* reconnect grace.
*/
import { useEffect, useRef, useState } from 'react'
import { Terminal, type ITheme } from 'xterm'
import { Terminal, type ITheme } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import 'xterm/css/xterm.css'
import { writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
import '@xterm/xterm/css/xterm.css'
import { t } from './locales.ts'
import type { SessionScope } from './api.ts'
import { openWhenSized } from './open-when-sized.ts'
import { api, type SessionScope, type TerminalDepsStatus } from './api.ts'
import { agentUuidOf, isAgentTabId, type SidebarStore } from './state.ts'
import { isDarkScheme, subscribeColorScheme, tokenValue } from './theme.ts'
import { isDarkScheme, subscribeColorScheme, effectiveTokenValue, tokenValue } from './theme.ts'
import { resolveTerminalFont } from './terminal-font.ts'
import css from './sidebar.module.css'
/** How many consecutive unreasoned failures before showing the error banner. */
const FAILURE_LIMIT = 3
/**
* The WS close-code-1011 reason the host sends when node-pty is unavailable
* (mirror of the host's PTY_DEPS_MISSING; the value is a wire contract, so
* the two sides keep the literal in lockstep). The view then fetches the
* full repair details from /sidebar/api/terminal.deps.
*/
const PTY_DEPS_MISSING = 'pty-deps-missing'
/** The degraded-mode payload rendered by {@link TerminalDepsBanner}. */
type TerminalDepsInfo = Extract<TerminalDepsStatus, { ok: false }>
/**
* Curated ANSI palettes for the terminal. The surface colors (background,
* foreground, cursor, selection) ride the theme tokens so the terminal
@@ -59,8 +73,14 @@ const ANSI_LIGHT: Record<string, string> = {
/** The xterm theme for the current scheme (surface from tokens, ANSI curated). */
function xtermTheme(): ITheme {
const dark = isDarkScheme()
const background = tokenValue('--dsw-alias-bg-base') || (dark ? '#111114' : '#ffffff')
const foreground = tokenValue('--dsw-alias-label-primary') || (dark ? '#e6e6e6' : '#1a1a1a')
// Skin systems set --dsw-alias-bg-base to `transparent` or translucent
// glass values (the dsh-web-ui skins use rgba 0.16–0.7); effectiveTokenValue
// treats those as unset below the opacity floor, so the opaque fallback
// engages and the terminal never renders see-through over the skin's
// backdrop (issue #90). Effectively opaque scoped surfaces (e.g. a skin's
// 0.96 porcelain) pass through — the skin still controls the terminal.
const background = effectiveTokenValue('--dsw-alias-bg-base') || (dark ? '#111114' : '#ffffff')
const foreground = effectiveTokenValue('--dsw-alias-label-primary') || (dark ? '#e6e6e6' : '#1a1a1a')
return {
background,
foreground,
@@ -76,16 +96,20 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
const hostRef = useRef<HTMLDivElement>(null)
const [connected, setConnected] = useState(false)
const [fatal, setFatal] = useState<string | null>(null)
const [depsFatal, setDepsFatal] = useState<TerminalDepsInfo | null>(null)
const [lastUrl, setLastUrl] = useState<string | null>(null)
const connectRef = useRef<(() => void) | null>(null)
useEffect(() => {
const host = hostRef.current
if (host === null) return
// The custom font prefs (side card settings, terminal card) resolve at
// mount; store changes re-apply them live below.
const font = resolveTerminalFont(store.getPrefs(), tokenValue('--ds-font-family-code'))
const term = new Terminal({
cursorBlink: true,
fontSize: 13,
fontFamily: tokenValue('--ds-font-family-code') || '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
fontSize: font.fontSize,
fontFamily: font.fontFamily,
allowTransparency: true,
convertEol: false,
scrollback: 4000,
@@ -93,8 +117,6 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
})
const fit = new FitAddon()
term.loadAddon(fit)
term.open(host)
fit.fit()
// Re-theme in place when the app's scheme flips (tokens + palette).
const applyTheme = (): void => {
term.options.theme = xtermTheme()
@@ -148,6 +170,25 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
}
socket.onclose = (event) => {
setConnected(false)
// node-pty dependency missing/broken (issue #140): the host closed
// with the short marker. Fetch the full repair details over HTTP —
// a WS close reason is capped at 123 bytes, too small for the
// pasteable command. A failed fetch falls back to the plain banner.
if (event.code === 1011 && event.reason === PTY_DEPS_MISSING) {
void api.terminalDeps().then((status) => {
if (status.ok) {
// The host recovered between the close and the fetch — the
// plain banner with a retry is the honest state.
setFatal(t('terminalDepsFailed'))
return
}
setFatal(null)
setDepsFatal(status)
}).catch(() => {
setFatal(t('terminalDepsFailed'))
})
return
}
// A server-side refusal carries a close code + reason; retrying it
// forever would only spin the banner, so surface it with a retry.
if (event.code === 1011 && event.reason !== '') {
@@ -185,11 +226,52 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
})
observer.observe(host)
// Custom font prefs (the terminal card's secondary settings) apply LIVE:
// on any store change re-resolve and diff the two options, re-fitting
// when they moved (the grid dimensions may change with the font). The
// subscribe fires on every store change (tabs, panels…), so the diff is
// what keeps this cheap.
const fontSub = store.subscribe(() => {
const next = resolveTerminalFont(store.getPrefs(), tokenValue('--ds-font-family-code'))
if (next.fontFamily !== term.options.fontFamily || next.fontSize !== term.options.fontSize) {
term.options.fontFamily = next.fontFamily
term.options.fontSize = next.fontSize
try {
fit.fit()
sendResize()
} catch {
// The terminal may be mid-dispose; ignore.
}
}
})
// The terminal must not be opened in a zero-size container: xterm's
// renderer creation fails there and the next Viewport refresh crashes
// reading `.dimensions` off the undefined renderer (blank terminal on
// WKWebView when the bottom panel's expand slide leaves the host at
// height 0; any display:none-hidden ancestor does the same). Defer
// open+fit until the host has a real size — writes arriving meanwhile
// are buffered by xterm's WriteBuffer and render once open, and
// FitAddon.fit() is a safe no-op before open. sendResize() here covers
// the deferred path where the socket may already be open with the
// default 80x24 dims.
const cancelOpen = openWhenSized(host, () => {
try {
term.open(host)
fit.fit()
sendResize()
} catch (error) {
console.error('[dsh-better-sidebar] xterm open failed:', error)
}
})
connect()
return () => {
closed = true
cancelOpen()
window.clearTimeout(retry)
observer.disconnect()
fontSub()
schemeSub()
inputSub.dispose()
// The close frame tells the host the owning tab is GONE (immediate
@@ -213,6 +295,9 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
return (
<div className={css.terminalWrap}>
{depsFatal !== null && (
<TerminalDepsBanner deps={depsFatal} onRetry={() => { setDepsFatal(null); connectRef.current?.() }} />
)}
{fatal !== null && (
<div className={css.terminalBanner}>
{t('terminalError')}: {fatal}
@@ -226,8 +311,48 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
</button>
</div>
)}
{fatal === null && !connected && <div className={css.terminalBanner}>{t('disconnected')}</div>}
{fatal === null && depsFatal === null && !connected && <div className={css.terminalBanner}>{t('disconnected')}</div>}
<div ref={hostRef} className={css.terminal} />
</div>
)
}
/**
* The node-pty dependency failure banner (issue #140): explains that the
* terminal's native dependency failed to load and shows the PASTEABLE repair
* command (bash / cmd / PowerShell) with a copy button — the user pastes it
* into a terminal where their DSH profile lives and runs it, then retries.
* Extracted as a standalone component for direct testing.
*/
export function TerminalDepsBanner(props: { deps: TerminalDepsInfo; onRetry: () => void }) {
const { deps, onRetry } = props
const [copied, setCopied] = useState(false)
const copy = async (): Promise<void> => {
const written = await writeClipboard(deps.command)
if (written) {
setCopied(true)
window.setTimeout(() => setCopied(false), 2000)
}
}
return (
<div className={css.terminalDepsBanner}>
<div className={css.terminalDepsTitle}>{t('terminalDepsFailed')}</div>
<div className={css.terminalDepsHint}>
{t('terminalDepsHint')}
{deps.profile !== null ? t('terminalDepsProfile', { profile: deps.profile }) : ''}
</div>
<div className={css.terminalDepsCommandRow}>
<pre className={css.terminalRepairCommand}>{deps.command}</pre>
<button type="button" className={css.terminalRetry} onClick={() => { void copy() }} aria-label={t('copy')}>
{copied ? t('copied') : t('copy')}
</button>
</div>
{deps.note !== undefined && <div className={css.terminalDepsNote}>{deps.note}</div>}
<div className={css.terminalDepsActions}>
<button type="button" className={css.terminalRetry} onClick={onRetry}>
{t('terminalRetry')}
</button>
</div>
</div>
)
}
+196 -7
View File
@@ -10,15 +10,19 @@
* row below the host's title bar, VSCode-style.
*/
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import clsx from 'clsx'
import { EditorState } from '@codemirror/state'
import { EditorView as CodeMirrorView, keymap, lineNumbers } from '@codemirror/view'
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
import { IconCheckOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import { api } from './api.ts'
import { api, htmlUrl } from './api.ts'
import { languageForPath } from './lang.ts'
import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts'
import { isDarkScheme, subscribeColorScheme } from './theme.ts'
import { SandboxStatusBar } from './SandboxStatusBar.tsx'
import { appendToDraft } from './conversation-draft.ts'
import { buildSelectionInsert, linesOfSelection } from './selection-payload.ts'
import { t } from './locales.ts'
import type { FileViewerProps } from './service.ts'
import css from './sidebar.module.css'
@@ -26,8 +30,24 @@ import css from './sidebar.module.css'
/** Previewable files (rendered output vs source editing). */
type ViewMode = 'preview' | 'edit'
/** The floating "add to conversation" action: payload + viewport anchor. */
interface SelectionPopup {
insert: string
left: number
top: number
}
/**
* The sandbox tokens of the HTML preview iframe. NO allow-same-origin (the
* preview must stay in an opaque origin — with the route's own origin it
* could read session data) and NO allow-top-navigation (a previewed page
* must not hijack the GUI). The user can disable the sandbox per-feature
* in the side card settings (warned); the toggle below reflects it.
*/
export const HTML_IFRAME_SANDBOX = 'allow-scripts allow-popups allow-downloads allow-modals'
export function TextEditor(props: FileViewerProps) {
const { scope, path, viewerId, content, truncated } = props
const { ctx, 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)
@@ -40,6 +60,36 @@ export function TextEditor(props: FileViewerProps) {
const themeCompRef = useRef<CmThemeCompartment | null>(null)
/** The app's resolved color scheme; the editor re-themes in place on flips. */
const [dark, setDark] = useState(() => isDarkScheme())
/** The floating "add to conversation" popup (viewport-anchored; null = hidden). */
const [popup, setPopup] = useState<SelectionPopup | null>(null)
/** Live mirror of the popup state for click-time reads (no re-render race). */
const popupRef = useRef<SelectionPopup | null>(null)
/** The markdown preview container (selection-containment + line lookup). */
const mdRef = useRef<HTMLDivElement>(null)
const hidePopup = (): void => {
popupRef.current = null
setPopup(null)
}
/** Anchor the popup above the selection center; clamp inside the viewport. */
const showPopup = (insert: string, left: number, top: number): void => {
const next: SelectionPopup = {
insert,
left: Math.min(Math.max(left, 80), window.innerWidth - 80),
top,
}
popupRef.current = next
setPopup(next)
}
/** The popup button's click: insert the stored payload into the draft. */
const commitPopup = (): void => {
const current = popupRef.current
if (current === null) return
appendToDraft(ctx, scope.sessionId, current.insert)
hidePopup()
}
useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), [])
@@ -49,6 +99,7 @@ export function TextEditor(props: FileViewerProps) {
setDraft(null)
setDirty(false)
setSaveState('idle')
hidePopup()
}, [content])
// Create the CodeMirror editor once the content is loaded. The view owns
@@ -90,6 +141,49 @@ export function TextEditor(props: FileViewerProps) {
...defaultKeymap,
...historyKeymap,
]),
// Selection popup (the code and markdown editors): a non-empty
// selection anchors the floating "add to conversation" button above
// its head. Scrolling (geometry/viewport change) or losing focus
// hides it; typing collapses the selection and hides it too.
...(viewerId === 'code' || viewerId === 'markdown' ? [
CodeMirrorView.updateListener.of((update) => {
if (update.geometryChanged || update.viewportChanged) {
hidePopup()
return
}
if (!update.view.hasFocus) {
hidePopup()
return
}
if (!(update.selectionSet || update.docChanged || update.focusChanged)) return
const sel = update.state.selection.main
if (sel.empty) {
hidePopup()
return
}
const text = update.state.sliceDoc(sel.from, sel.to)
if (text.trim() === '') {
hidePopup()
return
}
// Page coordinates (the document root may scroll); the popup is
// position:fixed, so convert to viewport coordinates.
const rect = update.view.coordsAtPos(sel.head)
if (rect === null) {
hidePopup()
return
}
const doc = update.state.doc
showPopup(
buildSelectionInsert(path, scope.cwd, {
start: doc.lineAt(sel.from).number,
end: doc.lineAt(sel.to).number,
}, text),
rect.left - window.scrollX + (rect.right - rect.left) / 2,
rect.top - window.scrollY,
)
}),
] : []),
],
})
const view = new CodeMirrorView({ state, parent: host })
@@ -114,8 +208,10 @@ export function TextEditor(props: FileViewerProps) {
}, [dark])
// The editor may have been display:none while previewing; re-measure when
// it becomes visible again (CodeMirror sizes itself on reveal).
// it becomes visible again (CodeMirror sizes itself on reveal). A mode
// flip also invalidates any anchored selection popup.
useEffect(() => {
hidePopup()
if (mode === 'edit') viewRef.current?.requestMeasure()
}, [mode])
@@ -136,13 +232,56 @@ export function TextEditor(props: FileViewerProps) {
}
const markdown = viewerId === 'markdown'
const html = viewerId === 'html'
/**
* Selection popup for the markdown preview: a mouse-up inside the preview
* container anchors the floating "add to conversation" button above the
* selection. Line numbers come from a best-effort reverse-search of the
* selected text in the source ({@link linesOfSelection} — an ambiguous or
* missing hit omits them). The button's own mousedown preventDefaults so
* the selection survives until the click commits.
*/
const handlePreviewMouseUp = (): void => {
const sel = window.getSelection()
if (sel === null || sel.isCollapsed || sel.anchorNode === null || sel.focusNode === null) {
hidePopup()
return
}
const host = mdRef.current
if (host === null || !host.contains(sel.anchorNode) || !host.contains(sel.focusNode)) {
hidePopup()
return
}
const text = sel.toString()
if (text.trim() === '') {
hidePopup()
return
}
const rect = sel.getRangeAt(0).getBoundingClientRect()
const lines = linesOfSelection(draft ?? content ?? '', text)
showPopup(
buildSelectionInsert(path, scope.cwd, lines ?? undefined, text),
rect.left + rect.width / 2,
rect.top,
)
}
const editable = content !== undefined
const saveLabel = saveState === 'saving' ? t('loading') : saveState === 'saved' ? t('saved') : saveState === 'failed' ? t('saveFailed') : ''
// Per-feature sandbox escape hatch: the global side card setting (warned)
// plus a per-surface temporary unlock. The unlock state starts at the
// "default unsafe" pref so a preview can open straight into the red
// unsandboxed state (still restorable from the status row). With the
// sandbox OFF the preview iframe drops its sandbox attribute entirely —
// the previewed page then runs on the GUI's own origin with full session
// access.
const [localUnlock, setLocalUnlock] = useState(() => props.store?.getPrefs().htmlViewerDefaultUnsafe === true)
const htmlNoSandbox = props.store?.getPrefs().htmlViewerNoSandbox === true || localUnlock
return (
<>
<div className={css.editorHeader}>
{markdown && (
{(markdown || html) && (
<div className={css.editorModeToggle}>
<button
type="button"
@@ -178,16 +317,66 @@ export function TextEditor(props: FileViewerProps) {
<>
{truncated === true && mode === 'edit' && <div className={css.editorBanner}>{t('truncation')}</div>}
<div
className={clsx(css.editorCm, markdown && mode === 'preview' && css.editorCmHidden)}
className={clsx(css.editorCm, (markdown || html) && mode === 'preview' && css.editorCmHidden)}
ref={hostRef}
/>
</>
)}
{markdown && mode === 'preview' && (
<div className={css.editorMd}>
<MarkdownText text={draft ?? content ?? ''} />
<div
className={css.editorMd}
ref={mdRef}
onMouseUp={handlePreviewMouseUp}
onScroll={hidePopup}
>
{/* The fence copy-button labels must come from this plugin's own
dictionary: the DSH MarkdownText/CodeBlock are cordis-free and
fall back to hardcoded Chinese otherwise (same pattern as the
chat's AssistantMarkdown). Render-time t() keeps them following
the active locale on live switches. */}
<MarkdownText
text={draft ?? content ?? ''}
codeLabels={{ copyLabel: t('copy'), copiedLabel: t('copied') }}
/>
</div>
)}
{html && mode === 'preview' && (
<>
<SandboxStatusBar
sandboxed={!htmlNoSandbox}
local={localUnlock}
dangerCopy={t('htmlNoSandboxWarning')}
onUnlock={() => { setLocalUnlock(true) }}
onRestore={() => { setLocalUnlock(false) }}
/>
{/* Route-src (never srcdoc — a srcdoc frame inherits the parent
origin when unsandboxed; the route URL keeps the frame
cross-origin by construction). The preview shows the SAVED
file; the draft is only visible in edit mode. */}
<iframe
className={css.editorHtml}
src={htmlUrl(scope, path)}
sandbox={htmlNoSandbox ? undefined : HTML_IFRAME_SANDBOX}
referrerPolicy="no-referrer"
allow=""
title={path}
/>
</>
)}
{popup !== null && createPortal(
<button
type="button"
className={css.selectionPopup}
style={{ left: popup.left, top: popup.top }}
// Keep the selection (and CodeMirror focus) alive until the click
// commits — without this the popup unmounts before click lands.
onMouseDown={(event) => { event.preventDefault() }}
onClick={commitPopup}
>
{t('addToConversation')}
</button>,
document.body,
)}
</>
)
}
+154
View File
@@ -0,0 +1,154 @@
/**
* The "add plugin" modals (Side card settings → the dashed cards at the
* end of the 侧边栏内容 / 文件预览 grids): declare that the sidebar's
* extension points — tab pages and file previewers — are open to plugins
* (registered through `ctx.betterSidebar`), point at the GitHub topic page
* for discovery, and show the repo's recommended plugin catalog of the
* matching kind (name / url / description / install script).
*
* Per entry there are two actions:
* - 「跳转」opens the plugin's repo in a REAL new browser tab (window.open
* — a button, so the sidebar link takeover cannot reroute it);
* - 「安装」only COPIES the install script to the clipboard (writeClipboard)
* with a transient "已复制" feedback on the button — the user pastes and
* runs it wherever they manage their DSH profile. No terminal is opened,
* nothing is closed, nothing can fail outward.
*
* The body is extracted as {@link PluginListBody} so tests render it
* directly — the Modal primitive runs hooks unconditionally, so an open
* Modal must never be renderToString'd (same rule as the settingsFor popup
* in SideCardSection); the modal itself mounts only while open.
*/
import { useState } from 'react'
import { Modal, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
import type { BetterSidebarService } from './service.ts'
import { PLUGIN_TOPIC_URL, type PluginEntry } from './plugins-shared.ts'
import { builtinTabPlugins } from './plugins-tabs.ts'
import { builtinViewerPlugins } from './plugins-viewers.ts'
import { t } from './locales.ts'
import css from './SideCardSection.module.css'
/** Which extension point the modal is adding a plugin for. */
export type PluginKind = 'tab' | 'viewer'
/** The catalog of one kind (kept in two repo files: plugins-tabs.ts /
* plugins-viewers.ts). */
function catalogOf(kind: PluginKind): readonly PluginEntry[] {
return kind === 'tab' ? builtinTabPlugins : builtinViewerPlugins
}
/** How long the "已复制" feedback stays on the copy button. */
const COPIED_FEEDBACK_MS = 1500
/** The modal body: the GitHub topic button + the recommended plugin list
* with per-entry jump/copy buttons (extracted for direct testing). */
export function PluginListBody(props: { service: BetterSidebarService; kind: PluginKind }) {
const { service, kind } = props
// Which entry's copy button currently shows the "已复制" feedback.
const [copiedId, setCopiedId] = useState<string | null>(null)
/** Copy the entry's install script to the clipboard and flash the button's
* "已复制" label for a moment. The feedback ONLY appears after a
* successful write — when the clipboard is unavailable or denied
* (writeClipboard resolves false) nothing is shown, so the user is never
* told to paste a command that was not placed on the clipboard. Never
* closes anything, never throws outward. */
const copy = async (entry: PluginEntry): Promise<void> => {
const written = await writeClipboard(entry.install)
if (!written) return
setCopiedId(entry.id)
window.setTimeout(() => {
setCopiedId(current => (current === entry.id ? null : current))
}, COPIED_FEEDBACK_MS)
}
/** Open the plugin's repo in a REAL new browser tab (window.open — a
* button, so the sidebar link takeover cannot reroute it). */
const jump = (entry: PluginEntry): void => {
window.open(entry.url, '_blank', 'noopener')
}
return (
<div className={css.pluginList}>
<button
type="button"
className={css.pluginTopicBtn}
onClick={() => { window.open(PLUGIN_TOPIC_URL, '_blank', 'noopener') }}
>
{t('addPluginsBrowseMore')}
</button>
<div className={css.groupHeading}>
<span>{t('addPluginsRecommended')}</span>
<span className={css.count}>{catalogOf(kind).length}</span>
</div>
{catalogOf(kind).length === 0 ? (
<div className={css.pluginEmpty}>{t('addPluginsEmpty')}</div>
) : (
<div className={css.pluginEntries}>
{catalogOf(kind).map(entry => (
<div key={entry.id} className={css.pluginEntry}>
<div className={css.pluginEntryHead}>
{/* The name is a BUTTON on the same window.open path as the
jump button: as an anchor it would be caught by the
document-capture link takeover (which ignores
target=_blank) and land in the sidebar browser. */}
<button
type="button"
className={css.pluginName}
aria-label={`${t('openPlugin')}: ${entry.name}`}
onClick={() => { jump(entry) }}
>
{entry.name}
</button>
<span className={css.pluginEntryActions}>
<button
type="button"
className={css.pluginJumpBtn}
aria-label={`${t('openPlugin')}: ${entry.name}`}
onClick={() => { jump(entry) }}
>
{t('openPlugin')}
</button>
<button
type="button"
className={css.pluginCopyBtn}
aria-label={`${t('copyInstall')}: ${entry.name}`}
onClick={() => { copy(entry) }}
>
{copiedId === entry.id ? t('copied') : t('copy')}
</button>
</span>
</div>
<div className={css.pluginDesc}>
{typeof entry.description === 'function' ? entry.description() : entry.description}
</div>
<code className={css.pluginInstall}>{entry.install}</code>
</div>
))}
</div>
)}
</div>
)
}
/** The modal itself (mounted only while open — see the module comment). */
export function AddPluginModal(props: { service: BetterSidebarService; onClose: () => void; kind: PluginKind }) {
const { service, onClose, kind } = props
return (
<Modal
open
onClose={onClose}
title={kind === 'tab' ? t('addPluginsTabCard') : t('addPluginsViewerCard')}
description={kind === 'tab' ? t('addPluginsTabDesc') : t('addPluginsViewerDesc')}
closeLabel={t('close')}
className={css.pluginModal}
footer={(
<button type="button" className={css.done} onClick={onClose}>
{t('settingsDone')}
</button>
)}
>
<PluginListBody service={service} kind={kind} />
</Modal>
)
}
+70
View File
@@ -6,6 +6,8 @@
* hydrating at page load (a detached session would otherwise fail the
* request). Failures surface as {@link SidebarApiError} with the wire code.
*/
import { encodeHtmlUrl } from '../html-route.ts'
import type { BrowserProbeResult } from './browser.ts'
/** One wire failure. */
export class SidebarApiError extends Error {
@@ -23,6 +25,10 @@ export interface FsEntry {
path: string
isDir: boolean
hidden: boolean
/** Whether the row is a symlink; `isDir` then describes the link's target. */
isSymlink: boolean
/** For symlinks: the target is missing or unreadable (stat failed). */
broken: boolean
}
/** Git status entry (host git shape). */
@@ -58,6 +64,36 @@ export interface FsTextResult { kind: 'text'; content: string; truncated: boolea
* `head` carries the first bytes (base64) for viewer detect sniffing. */
export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean; head: string }
/**
* One jobs.output response: the output the MODEL has read so far for the
* job (replayed from the owner session's event log — the model's
* job_output cursor is never touched, so the pane can never steal the
* agent's bytes). `read` is false until the model actually called
* job_output for the job.
*/
export interface JobOutputResult {
text: string
/** True when the host capped the text at its output limit. */
truncated: boolean
/** Whether the model has read the job at least once. */
read: boolean
}
/** Terminal dependency status (mirror of the host's depsStatus; issue #140). */
export type TerminalDepsStatus =
| { ok: true }
| {
ok: false
/** The require-time error message (module missing, native binding broken…). */
cause: string
/** The pasteable repair command (terminal/cmd). */
command: string
/** The detected profile name (null when undetected → the command defaults to web). */
profile: string | null
/** Optional supplementary hint (fallback command only). */
note?: string
}
async function call<T>(method: string, payload: Record<string, unknown>, signal?: AbortSignal): Promise<T> {
let response: Response
try {
@@ -143,6 +179,24 @@ export const api = {
/** Release an agent terminal by uuid (tab closed while WS was down). */
agentPtyClose: (uuid: string) =>
call<{ ok: true }>('agent-pty.close', { uuid }),
/** Terminal dependency status (issue #140): after a WS close 1011 with
* reason `pty-deps-missing` the view fetches the full repair details here
* (the close reason itself is capped at 123 bytes). */
terminalDeps: () =>
call<TerminalDepsStatus>('terminal.deps', {}),
/**
* The output the model has read so far for one background job (replayed
* from the owner session's event log — never the model's job_output
* cursor). The scope MUST be the job's OWNER session.
*/
jobOutput: (scope: SessionScope, id: string, signal?: AbortSignal) =>
call<JobOutputResult>('jobs.output', scopePayload(scope, { id }), signal),
/** Request cancellation of one background job (live jobs flip to stopping). */
jobKill: (scope: SessionScope, id: string, reason?: string) =>
call<{ ok: true; outcome: 'requested' | 'already-finished' }>('jobs.kill', scopePayload(scope, {
id,
...(reason !== undefined ? { reason } : {}),
})),
/** Read the side card preferences (plugin-global, no session scope). */
settingsGet: () =>
call<{ value?: unknown; revision?: number }>('settings.get', {}),
@@ -152,6 +206,10 @@ export const api = {
patch,
...(expectedRevision !== undefined ? { expectedRevision } : {}),
}),
/** Probe a URL's response headers (the sidebar browser's embeddability
* check; see the host's browser.probe route). */
browserProbe: (url: string, signal?: AbortSignal) =>
call<BrowserProbeResult>('browser.probe', { url }, signal),
}
/** Absolute URL of the media route for one path (images only). */
@@ -172,3 +230,15 @@ function fileUrl(scope: SessionScope, path: string, download: boolean): string {
if (download) params.set('download', '1')
return `/sidebar/file?${params.toString()}`
}
/**
* Absolute URL of the HTML preview route (see html-route.ts): the path is
* fully encoded so the previewed page's relative assets resolve back into
* the same route with the session scope intact. The UNC marker is
* platform-neutral — the host's requireAbsolute resolves the decoded
* forward-slash `//server/share/...` form on both win32 and POSIX — so no
* client-side platform signal is needed.
*/
export function htmlUrl(scope: SessionScope, path: string): string {
return encodeHtmlUrl(scope.sessionId, path)
}
+51
View File
@@ -0,0 +1,51 @@
/**
* Narrow-viewport ("mobile") breakpoint for the sidebar. Width-based, shared
* by the layout logic (JS) and the style gates (CSS). The CSS side pairs
* with this file via `@media (max-width: 767px)` rules (sidebar.module.css)
* — 767px ≡ widths below NARROW_MAX_WIDTH, documented at both ends.
*
* "Real narrow" on purpose: the mobile layout (one full-screen drawer, the
* bottom panel's tabs merged into the right sidebar) is a phone / portrait
* tablet experience. The value is deliberately NOT aligned to the DSH app
* shell's own 1024px breakpoint — 1024px windows (small laptops, split
* panes) keep the desktop two-panel layout.
*/
import { useEffect, useState } from 'react'
/** Viewport widths strictly below this are "mobile" (paired CSS: max-width: 767px). */
export const NARROW_MAX_WIDTH = 768
/** Whether a viewport width is narrow (mobile). */
export function isNarrowWidth(width: number): boolean {
return width < NARROW_MAX_WIDTH
}
/**
* Live narrow-viewport flag for components. Reads `window.innerWidth` and
* re-measures on resize (rAF-throttled, the repo's existing drag pattern).
* Deliberately avoids `matchMedia` (jsdom does not implement it) — the
* resize listener is equally exact for a breakpoint that never changes
* while the page is open.
*/
export function useNarrowViewport(): boolean {
const [narrow, setNarrow] = useState(
() => typeof window !== 'undefined' && isNarrowWidth(window.innerWidth),
)
useEffect(() => {
if (typeof window === 'undefined') return
let frame: number | null = null
const measure = (): void => {
frame = null
setNarrow(isNarrowWidth(window.innerWidth))
}
const onResize = (): void => {
if (frame === null) frame = requestAnimationFrame(measure)
}
window.addEventListener('resize', onResize)
return () => {
window.removeEventListener('resize', onResize)
if (frame !== null) cancelAnimationFrame(frame)
}
}, [])
return narrow
}
+119
View File
@@ -0,0 +1,119 @@
/**
* Pure URL policy for the built-in browser tab: normalize user input into
* an http(s) URL, and refuse destinations that would be dangerous to embed
* in the sidebar iframe. Kept dependency-free so it is unit-testable.
*
* The iframe sandbox (opaque origin, no allow-same-origin / top-navigation)
* is the primary security boundary; this module is the address-bar gate on
* top of it: only http/https may be navigated, and loopback addresses are
* refused so a browsed page cannot probe local services by user action.
* The GUI's OWN origin is explicitly ALLOWED — the user may open the GUI
* itself in the sidebar (debugging, mirroring); the sandbox still renders
* it in an opaque origin with no same-origin privileges, exactly like any
* other site.
*/
/** Why a navigation attempt was refused. */
export type BrowserBlockReason = 'scheme' | 'loopback'
/** Result of normalizing one address-bar input. */
export type BrowserNavigateResult =
| { kind: 'ok'; url: string }
| { kind: 'blocked'; reason: BrowserBlockReason }
| { kind: 'invalid' }
/** One browser.probe wire result (host fetch of the target's headers). */
export interface BrowserProbeResult {
reachable: boolean
/** The final (post-redirect) URL; present when reachable. */
url?: string
status?: number
xFrameOptions?: string
/** The CSP frame-ancestors source list; present when the directive exists. */
frameAncestors?: string[]
}
/** Embeddability verdict of one probe. */
export type Embeddability = 'embeddable' | 'blocked' | 'unknown'
/**
* Decide whether a site can render inside the sidebar iframe. The signals
* are exactly the ones the BROWSER enforces when it refuses an iframe load:
* X-Frame-Options DENY/SAMEORIGIN, or a frame-ancestors directive that does
* not allow `*` ('self' here means the SITE's own origin — never ours, so
* it also blocks the sidebar). A site we could not reach yields 'unknown'
* and the plain iframe stays.
*/
export function embeddabilityOf(probe: BrowserProbeResult): Embeddability {
if (probe.reachable !== true) return 'unknown'
const xfo = probe.xFrameOptions?.trim().toUpperCase()
if (xfo === 'DENY' || xfo === 'SAMEORIGIN') return 'blocked'
if (probe.frameAncestors !== undefined && !probe.frameAncestors.some(source => source === '*')) return 'blocked'
return 'embeddable'
}
/** A loopback hostname (localhost, IPv6 ::1, 127.0.0.0/8, 0.0.0.0). */
export function isLoopbackHostname(hostname: string): boolean {
const host = hostname.replace(/^\[|\]$/g, '').toLowerCase()
if (host === 'localhost' || host === '::1' || host === '0.0.0.0') return true
const parts = host.split('.')
return parts.length === 4
&& parts[0] === '127'
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
}
/**
* Normalize one address-bar input against the navigation policy.
* @param input - raw user text.
* @param selfOrigin - the GUI's own origin (window.location.origin). The GUI
* itself may be browsed in the sidebar (the sandbox keeps it opaque), so it
* is let through BEFORE the loopback check — its host is normally loopback.
*/
/** Schemes that must never reach the iframe, even without `//` (javascript:,
* data:, file:, ...). Host:port lookalikes (example.com:8080) are NOT here —
* they parse as hosts below. */
const FORBIDDEN_SCHEMES = new Set([
'javascript', 'data', 'file', 'about', 'vbscript', 'blob',
'mailto', 'tel', 'ftp', 'ftps', 'ws', 'wss', 'sftp', 'ssh',
'chrome', 'chrome-extension', 'moz-extension', 'edge', 'opera', 'resource', 'view-source',
])
export function normalizeBrowserUrl(input: string, selfOrigin: string): BrowserNavigateResult {
const trimmed = input.trim()
if (trimmed === '') return { kind: 'invalid' }
// Distinguish an explicit scheme from a bare host:port. "example.com:8080"
// would match a naive scheme regex (dots are legal in schemes), so a
// scheme prefix is only honored when it is http(s) or a known-forbidden
// scheme; anything else is treated as a host and gets https://.
const schemeMatch = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(trimmed)
let withScheme: string
if (schemeMatch === null) {
withScheme = `https://${trimmed}`
} else {
const scheme = schemeMatch[1]!.toLowerCase()
if (scheme === 'http' || scheme === 'https') withScheme = trimmed
else if (FORBIDDEN_SCHEMES.has(scheme)) return { kind: 'blocked', reason: 'scheme' }
else withScheme = `https://${trimmed}`
}
let url: URL
try {
url = new URL(withScheme)
} catch {
return { kind: 'invalid' }
}
// The protocol backstop: any URL that still parses to a non-http(s)
// scheme (e.g. ftp://, ws:// — which carry `//` and skip the list) is
// refused here.
if (url.protocol !== 'http:' && url.protocol !== 'https:') return { kind: 'blocked', reason: 'scheme' }
// The GUI's own origin is ALLOWED (the user may browse the GUI itself in
// the sidebar; the sandbox renders it in an opaque origin like any other
// site). It must be checked before the loopback gate because its host is
// normally loopback.
try {
if (url.origin === new URL(selfOrigin).origin) return { kind: 'ok', url: url.href }
} catch {
// Unparsable selfOrigin (never in practice): fall through to the loopback gate.
}
if (isLoopbackHostname(url.hostname)) return { kind: 'blocked', reason: 'loopback' }
return { kind: 'ok', url: url.href }
}
+106 -16
View File
@@ -1,9 +1,10 @@
/**
* The 6 built-in tab descriptors: the plugin registers its own pages
* (explorer / git / terminal / subagent / editor / diff) through the same
* {@link BetterSidebarService} external plugins use — eating its own
* dogfood. The terminal descriptor owns its quota (`TERMINAL_LIMIT`) and
* mints `terminal:<n>` ids through `createTab`.
* The 7 built-in tab descriptors: the plugin registers its own pages
* (explorer / git / terminal / browser / subagent / editor / diff) through
* the same {@link BetterSidebarService} external plugins use — eating its
* own dogfood. The terminal descriptor owns its quota (`TERMINAL_LIMIT`)
* and mints `terminal:<n>` ids through `createTab`; the browser mints
* `browser:<n>` the same way (no quota).
*/
import { IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../../context-types.ts'
@@ -12,13 +13,42 @@ 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 { lazyChunkComponent } from '../lazy-chunk.tsx'
import { GitView } from '../GitView.tsx'
import { DiffTab } from '../DiffTab.tsx'
import { SubagentView } from '../SubagentView.tsx'
import { IconTerminalOutline16, IconDiffOutline16 } from '../icons.tsx'
import { BrowserView } from '../BrowserView.tsx'
import { IconTerminalOutline16, IconDiffOutline16, IconGlobeOutline16 } from '../icons.tsx'
import { TERMINAL_FONT_SIZE_MAX, TERMINAL_FONT_SIZE_MIN } from '../../prefs-shared.ts'
import type { ComponentType } from 'react'
import type { SessionScope } from '../api.ts'
import type { SidebarStore } from '../state.ts'
import type { TabDescriptor } from '../service.ts'
/**
* Lazy wrapper over the terminal view: xterm (and its stylesheet) is fetched
* only when a terminal tab is first opened (see chunk-loader.ts). The
* wrapper keeps the descriptor contract `(props) => ReactNode` — Sidebar
* calls it as a plain function.
*
* TerminalView's props are { scope, tabId, store } — `tabId` is NOT part of
* TabComponentProps (it carries `tab: SidebarTab` instead), so the
* descriptor maps it explicitly; a bare pass-through would leave tabId
* undefined and TerminalView's isAgentTabId(tabId) would crash on
* `undefined.startsWith` (regression-pinned in tests/lazy-chunk.spec.tsx).
*/
const LazyTerminal = lazyChunkComponent<TerminalViewProps>(
'terminal',
(mod) => mod.TerminalView as ComponentType<TerminalViewProps> | undefined,
)
/** The terminal view's props (mirror of TerminalView's own signature). */
interface TerminalViewProps {
scope: SessionScope
tabId: string
store: SidebarStore
}
/** How many UI-owned terminals may be open at once (agent-owned ones are uncapped). */
export const TERMINAL_LIMIT = 3
@@ -29,7 +59,7 @@ function uiTerminalCount(state: SidebarState): number {
.filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length
}
/** The 6 built-in tab descriptors. */
/** The 7 built-in tab descriptors. */
export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
return [
{
@@ -80,13 +110,17 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
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).
// Declarative settings: the auto-open switches render under this row in
// the Side card settings page (the Jobs page's own related settings).
settings: {
toggles: [{
key: 'autoOpenSubagent',
title: () => t('settingsSubagentTitle'),
desc: () => t('settingsSubagentDesc'),
}, {
key: 'autoOpenJobs',
title: () => t('settingsJobsTitle'),
desc: () => t('settingsJobsDesc'),
}],
},
component: ({ ctx, scope, visible, onSubagentJump }) => (
@@ -104,14 +138,34 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
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).
// Declarative settings: the model-facing terminal tools switch, the
// bottom-panel first-expansion auto-terminal switch, and the custom
// font family/size rows render under this card in the Side card
// settings page (the host gates the toolset on the tools one
// independently; the font rows apply live to every terminal).
settings: {
toggles: [{
key: 'agentTerminalTools',
title: () => t('settingsToolsTitle'),
desc: () => t('settingsToolsDesc'),
}, {
key: 'bottomPanelAutoTerminal',
title: () => t('settingsBottomTerminalTitle'),
desc: () => t('settingsBottomTerminalDesc'),
}, {
key: 'terminalFontFamily',
type: 'text',
title: () => t('settingsFontFamilyTitle'),
desc: () => t('settingsFontFamilyDesc'),
placeholder: t('settingsFontFamilyPlaceholder'),
}, {
key: 'terminalFontSize',
type: 'number',
title: () => t('settingsFontSizeTitle'),
desc: () => t('settingsFontSizeDesc'),
min: TERMINAL_FONT_SIZE_MIN,
max: TERMINAL_FONT_SIZE_MAX,
unit: 'px',
}],
},
createTab: (state) => {
@@ -126,9 +180,45 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
patch: { nextTerminal: state.nextTerminal + 1 },
}
},
component: ({ scope, tab, store }) => (
<TerminalView scope={scope} tabId={tab.id} store={store} />
),
component: ({ tab, scope, store }) => <LazyTerminal scope={scope} store={store} tabId={tab.id} />,
},
{
id: 'browser',
title: () => t('browser'),
icon: (size: number) => <IconGlobeOutline16 size={size} />,
order: 50,
// Declarative settings: the sandbox escape hatch, the link-takeover
// MASTER switch, and the per-protocol takeover switches (http on /
// https off by default) render under this tab's row in the Side card
// settings page (the sandbox one is warned on).
settings: {
toggles: [{
key: 'browserNoSandbox',
title: () => t('settingsBrowserSandboxTitle'),
desc: () => t('settingsBrowserSandboxDesc'),
}, {
key: 'browserInterceptLinks',
title: () => t('settingsBrowserLinksTitle'),
desc: () => t('settingsBrowserLinksDesc'),
}, {
key: 'browserInterceptHttp',
title: () => t('settingsBrowserHttpTitle'),
desc: () => t('settingsBrowserHttpDesc'),
}, {
key: 'browserInterceptHttps',
title: () => t('settingsBrowserHttpsTitle'),
desc: () => t('settingsBrowserHttpsDesc'),
}],
},
createTab: (state) => ({
tab: {
id: `browser:${state.nextBrowser}`,
type: 'browser',
title: t('browser'),
},
patch: { nextBrowser: state.nextBrowser + 1 },
}),
component: (props) => <BrowserView {...props} />,
},
{
id: 'diff',
+55 -46
View File
@@ -1,34 +1,51 @@
/**
* 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.
* The 6 built-in file viewer descriptors: every preview surface is a
* registered viewer (image / pdf / markdown / html / code /
* binary-download), exactly like external plugins register theirs. Office
* previews (.docx / .xlsx / .pptx) are NOT built in anymore — they moved to
* the recommended office plugin (see plugins-viewers.ts), which registers
* the same ids through this service.
*
* The `binary-download` viewer sniffs NUL bytes via `detect` for unknown
* binaries and serves legacy doc/xls/ppt by extension; `code` is the
* catch-all (`exts: []`, lowest priority) that claims any file no other
* viewer did.
*
* The heavy viewers (the CodeMirror-backed markdown/html/code) render
* through {@link lazyChunkComponent} wrappers — their libraries are fetched
* only when such a file is first opened (see chunk-loader.ts). The
* descriptor metadata (id/exts/priority/detect) is identical either way,
* so matching semantics and external-plugin overrides are unaffected; the
* `component` wrapper keeps the descriptor contract `(props) => ReactNode`.
*
* 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 { lazyChunkComponent } from '../lazy-chunk.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,
IconHtmlOutline16,
} from '../icons.tsx'
import type { FileViewerDescriptor } from '../service.ts'
import type { ComponentType } from 'react'
import type { FileViewerDescriptor, FileViewerProps } from '../service.ts'
import { t } from '../locales.ts'
import css from '../sidebar.module.css'
/** The 8 built-in file viewer descriptors. */
/**
* Lazy wrapper over the chunk-resident viewer component. The `pick`
* function is module-level (stable identity — the wrapper effect depends
* on it); the cast bridges the chunk exports record to the descriptor prop
* shape (the view reads only its own subset of FileViewerProps).
*/
const LazyTextEditor = lazyChunkComponent<FileViewerProps>('editor', (mod) => mod.TextEditor as ComponentType<FileViewerProps> | undefined)
/** The 6 built-in file viewer descriptors. */
export function builtinViewers(): readonly FileViewerDescriptor[] {
return [
{
@@ -53,43 +70,35 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
<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} />,
component: (props) => <LazyTextEditor {...props} />,
},
{
id: 'html',
title: () => t('viewerHtml'),
icon: (size: number) => <IconHtmlOutline16 size={size} />,
exts: ['html', 'htm'],
fetchStrategy: 'fsRead',
// Declarative settings: the sandbox escape hatch and the default-unsafe
// start state render under this viewer's row in the Side card settings
// page (both warned on).
settings: {
toggles: [{
key: 'htmlViewerNoSandbox',
title: () => t('settingsHtmlSandboxTitle'),
desc: () => t('settingsHtmlSandboxDesc'),
}, {
key: 'htmlViewerDefaultUnsafe',
title: () => t('settingsHtmlDefaultUnsafeTitle'),
desc: () => t('settingsHtmlDefaultUnsafeDesc'),
}],
},
component: (props) => <LazyTextEditor {...props} />,
},
{
id: 'code',
@@ -98,7 +107,7 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
exts: [],
priority: -100,
fetchStrategy: 'fsRead',
component: (props) => <TextEditor {...props} />,
component: (props) => <LazyTextEditor {...props} />,
},
{
id: 'binary-download',
+198
View File
@@ -0,0 +1,198 @@
/**
* Lazy chunk loader for the client bundle. The heavy preview/terminal
* libraries (CodeMirror, xterm — the editor/terminal stacks, several MB)
* live in separate build-time bundles (`lib/client-<name>.js`) fetched only
* on first use of the feature that needs them, so startup downloads/parses
* only the ~1MB core bundle. (The office stack — Univer / docx-preview /
* pptx-renderer — is no longer bundled here: Office previews moved to the
* recommended office plugin, see plugins-viewers.ts.)
*
* How a chunk script works (see tsdown.config.ts chunkBundle):
*
* globalThis.__dshChunks__ = globalThis.__dshChunks__ || {};
* globalThis.__dshChunks__["terminal"] = (require) => { ...exports };
*
* The script registers its factory on a plugin-owned global registry (NOT
* through window.__ModuleLoader__.load — the module loader's import() only
* resolves seed words, shell-own modules, registered factories, and boot
* graph rows; a chunk id is none of those, so resolution would be version-
* dependent). Materialization is plugin-owned:
*
* 1. inject <script src="/sidebar/bundle/<name>.js"> (classic same-origin
* script; the official /plugins/<id>/client.js route cannot serve
* arbitrary file names, so the plugin's own host route serves the chunks),
* 2. read the factory from the global registry,
* 3. call it with a require that resolves the platform externals through
* `__DSH_MODULES__.import(spec)` — the seed-word branch, the one part of
* the module system that is stable across versions.
*
* Caching contract (three layers, each with a failure path):
* - In-memory: one in-flight promise per chunk, memoized until
* {@link resetChunks}; a failed load removes its entry so the next call
* retries from scratch.
* - Script execution: each re-execution overwrites the global registry slot
* (assignment, never registration) — no "duplicate factory registration"
* class of errors; a failed materialization clears the cache so the retry
* re-injects and re-executes.
* - HTTP: the bundle route revalidates every request (`cache-control:
* no-cache` + ETag, 304 when unchanged), so page refreshes and HMR
* re-activations never re-download a multi-MB chunk that did not change.
*
* HMR: each plugin activation calls {@link resetChunks}, which drops the
* in-memory cache (and any test registry), so a hot-reloaded core bundle
* re-fetches and re-executes the current chunk scripts on the next lazy
* open. Chunk-only source edits still need a manual page refresh (the HMR
* poll watches only client.js).
*/
export type ChunkName = 'terminal' | 'editor'
/** The module exports a chunk factory provides (namespace-ish record). */
export type ChunkExports = Record<string, unknown>
/** A chunk factory: (require) => exports (the chunk's CJS closure shape). */
type ChunkFactory = (require: (spec: string) => unknown) => ChunkExports
/**
* The platform externals a chunk bundle may require (mirror of
* CLIENT_EXTERNALS in tsdown.config.ts — the chunk builds keep these
* external and the loader resolves them here). A superset is safe: the
* require only answers what the chunk actually asks for.
*/
export const CHUNK_EXTERNALS: readonly string[] = [
'react',
'react/jsx-runtime',
'react-dom',
'react-dom/client',
'cordis',
'@deepseek-ai/dsh-client-ui-slots',
'@deepseek-ai/dsh-client-web-react',
'@deepseek-ai/dsh-client-ui-primitives',
'@deepseek-ai/dsh-client-schema-form',
'@deepseek-ai/dsh-client-runtime/client',
]
/** Chunk script endpoint served by the plugin host half (src/bundle-route.ts). */
const CHUNK_URL = (name: ChunkName): string => `/sidebar/bundle/${name}.js`
/** The client module system surface this loader needs (window.__DSH_MODULES__). */
interface ChunkModuleSystem {
import(specifier: string): Promise<unknown>
}
/** Resolve the shell-installed module system (set before any plugin activates). */
function moduleSystem(): ChunkModuleSystem | undefined {
return (globalThis as { __DSH_MODULES__?: ChunkModuleSystem }).__DSH_MODULES__
}
/** The plugin-owned chunk factory registry the chunk scripts populate. */
interface ChunkRegistry {
[name: string]: ChunkFactory | undefined
}
function chunkRegistry(): ChunkRegistry {
const g = globalThis as { __dshChunks__?: ChunkRegistry }
return g.__dshChunks__ ??= {}
}
/** Script-load hook; tests replace it with a stub (the default needs a real DOM + network). */
export type ChunkScriptLoader = (src: string) => Promise<void>
const defaultScriptLoader: ChunkScriptLoader = (src) => new Promise((resolve, reject) => {
const el = document.createElement('script')
el.async = true
el.src = src
el.addEventListener('load', () => {
el.remove()
resolve()
}, { once: true })
el.addEventListener('error', () => {
el.remove()
reject(new Error(`[dsh-better-sidebar] chunk script ${src} failed to load`))
}, { once: true })
document.head.append(el)
})
let scriptLoader: ChunkScriptLoader = defaultScriptLoader
/** Test hook: replace the chunk-script loader (pass null to restore the default). */
export function setChunkScriptLoaderForTests(loader: ChunkScriptLoader | null): void {
scriptLoader = loader ?? defaultScriptLoader
}
/** Test/dev hook: resolve a chunk without fetching a script (e.g. vitest). */
const testLoaders = new Map<ChunkName, () => Promise<ChunkExports>>()
export function registerChunkForTests(name: ChunkName, loader: () => Promise<ChunkExports>): void {
testLoaders.set(name, loader)
}
/** Memoized externals require, resolved once per page from the seed table. */
let externalsRequire: ((spec: string) => unknown) | undefined
async function buildExternalsRequire(modules: ChunkModuleSystem): Promise<(spec: string) => unknown> {
if (externalsRequire !== undefined) return externalsRequire
// Per-spec tolerance: a spec the running DSH version cannot resolve (e.g.
// the runtime/client exemption row) stays unresolved until a chunk
// actually requires it — only then it is a loud error.
const entries = await Promise.all(CHUNK_EXTERNALS.map(async (spec) => {
try {
return [spec, await modules.import(spec)] as const
} catch {
return [spec, undefined] as const
}
}))
const table = new Map<string, unknown>(entries)
externalsRequire = (spec: string): unknown => {
if (!table.has(spec)) {
// Single quotes: the bundle-consistency scan regexes for require("...")
// lexical calls — a double-quoted literal here would trip it.
throw new Error(`[dsh-better-sidebar] chunk require('${spec}') missed the module table`)
}
return table.get(spec)
}
return externalsRequire
}
/** In-flight/memoized chunk loads; a failure removes its entry so a retry re-fetches. */
const cache = new Map<ChunkName, Promise<ChunkExports>>()
/**
* Load (once) and materialize a lazy chunk, returning its module exports.
* Concurrent callers share one in-flight load; a failure clears the cache
* entry so the next call retries (the script re-executes and overwrites its
* global registry slot — assignments are idempotent).
* @param name - the chunk to load.
*/
export function loadChunk(name: ChunkName): Promise<ChunkExports> {
const cached = cache.get(name)
if (cached !== undefined) return cached
const task = (async (): Promise<ChunkExports> => {
const test = testLoaders.get(name)
if (test !== undefined) return test()
const modules = moduleSystem()
if (modules === undefined) {
throw new Error(`[dsh-better-sidebar] chunk "${name}": client module system unavailable`)
}
await scriptLoader(CHUNK_URL(name))
const factory = chunkRegistry()[name]
if (typeof factory !== 'function') {
throw new Error(`[dsh-better-sidebar] chunk "${name}" script did not register its factory`)
}
const require = await buildExternalsRequire(modules)
return factory(require)
})()
cache.set(name, task)
void task.catch(() => { cache.delete(name) })
return task
}
/**
* Drop all chunk state for a fresh plugin activation (HMR-safe): clear the
* in-memory cache and any test-registry entries, so the next lazy open
* re-fetches and re-executes the current chunk scripts (the registry slots
* are overwritten by the re-execution — no cleanup needed).
*/
export function resetChunks(): void {
cache.clear()
testLoaders.clear()
externalsRequire = undefined
}
+9
View File
@@ -0,0 +1,9 @@
/**
* Lazy chunk entry: the code/markdown/html text editor (CodeMirror 6 + the
* language packages). Built as `lib/client-editor.js` and registered under
* `dsh-better-sidebar/editor` — fetched only when a text file is first
* opened (see chunk-loader.ts and docs/plans/2026-08-12-lazy-chunks-design.md).
* Never import this module from the core bundle: it pulls CodeMirror into
* the startup path.
*/
export { TextEditor } from '../TextEditor.tsx'
+9
View File
@@ -0,0 +1,9 @@
/**
* Lazy chunk entry: the interactive terminal (xterm + fit addon). Built as
* `lib/client-terminal.js` and registered under
* `dsh-better-sidebar/terminal` — fetched only when a terminal tab is first
* opened (see chunk-loader.ts and docs/plans/2026-08-12-lazy-chunks-design.md).
* Never import this module from the core bundle: it pulls xterm (and its
* stylesheet) into the startup path.
*/
export { TerminalView } from '../TerminalView.tsx'
+29
View File
@@ -0,0 +1,29 @@
/**
* Append text to the current session's composer draft through the
* conversation service — the shared path behind the explorer's @-reference
* button and the viewer selection popup. The service is resolved lazily
* through `ctx.get` (the inject-free read the app's own plugins use); a
* missing service or scope degrades to a logged no-op, never a crash.
*/
import type { Context, SidebarConversation } from '../context-types.ts'
/**
* Append `text` to the session's composer draft (space-separated, like the
* @-mentions). Returns false — and logs — when the conversation service or
* the session scope is unavailable.
*/
export function appendToDraft(ctx: Context, sessionId: string, text: string): boolean {
try {
const actx = ctx.sessions.scope(sessionId)
if (actx === undefined) return false
const conversation = ctx.get('conversation') as SidebarConversation | undefined
if (conversation === undefined) return false
const input = conversation.input.for(actx)
const draft = input.state.getSnapshot().draft
input.setDraft(draft.trim() === '' ? text : `${draft} ${text}`)
return true
} catch (error) {
console.warn('[dsh-better-sidebar] draft insert failed:', error)
return false
}
}
+55 -30
View File
@@ -1,11 +1,35 @@
/**
* Icons the sidebar needs beyond the primitives set: a terminal glyph (the
* 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.
* icon library has none), a diff glyph, and the two panel-toggle glyphs for
* the top-right cluster. 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 { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
/**
* Right-panel toggle glyph (the "侧拉" button): a frame with a filled strip
* along its RIGHT edge, in the app's outline style (1.5px stroke,
* currentColor).
*/
export const IconPanelRightOutline16 = ({ 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.5" stroke="currentColor" strokeWidth="1.5" />
<rect x="10.5" y="3.25" width="2.75" height="9.5" rx="1" fill="currentColor" stroke="none" />
</svg>
)
/**
* Bottom-panel toggle glyph (the "底栏" button): a frame with a filled strip
* along its BOTTOM edge, in the app's outline style.
*/
export const IconPanelBottomOutline16 = ({ 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.5" stroke="currentColor" strokeWidth="1.5" />
<rect x="3.25" y="10" width="9.5" height="2.75" rx="1" fill="currentColor" stroke="none" />
</svg>
)
/**
* Terminal glyph in the app's outline style (1.5px stroke, currentColor):
* a rounded frame with a prompt chevron and underscore cursor.
@@ -27,6 +51,16 @@ export const IconDiffOutline16 = ({ size = 16, className }: IconProps) => (
</svg>
)
/**
* Stop glyph for the background-job kill button: a filled square in the
* app's outline scale (16), the universal "halt this work" mark.
*/
export const IconStopOutline16 = ({ 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="4" y="4" width="8" height="8" rx="1.5" fill="currentColor" stroke="none" />
</svg>
)
// ── File-viewer inventory glyphs (Side card settings page) ────────────────
/** Image viewer glyph: a picture frame with a sun and a mountain. */
@@ -49,33 +83,6 @@ export const IconPdfOutline16 = ({ size = 16, className }: IconProps) => (
</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">
@@ -83,3 +90,21 @@ export const IconMarkdownOutline16 = ({ size = 16, className }: IconProps) => (
<path d="M4 10.5V5.5l2 2.5 2-2.5v5M9.5 10.5v-5l2 2.5 2-2.5v5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
/** HTML viewer glyph: a document frame with a "‹/›" tag pair. */
export const IconHtmlOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.5 1.5h6.5L13.5 5v9.5h-10z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
<path d="M9.5 1.5V5h4" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
<path d="M5.6 13.2 4.2 10l1.4-3.2M7.4 6.8 8.8 10l-1.4 3.2" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
/** Browser tab glyph: a globe with meridians. */
export const IconGlobeOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="6.5" stroke="currentColor" strokeWidth="1.5" />
<ellipse cx="8" cy="8" rx="2.8" ry="6.5" stroke="currentColor" strokeWidth="1.5" />
<path d="M1.5 8h13M8 1.5c-2.4 1.8-2.4 11.2 0 13M8 1.5c2.4 1.8 2.4 11.2 0 13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
+47
View File
@@ -0,0 +1,47 @@
/**
* IME-composition key guard.
*
* While a Chinese/Japanese/Korean input method is composing (the user is
* picking a candidate from the IME window), every pressed key BELONGS to the
* input method: arrows move the candidate highlight, Enter/Space confirm the
* composition, Escape cancels it. Page code must not process those keys —
* a component that does (a number stepper calling preventDefault() on
* ArrowUp/ArrowDown, a submit handler reacting to Enter, ...) silently
* breaks the IME: candidates stop responding, the composition gets torn
* apart, and only bare letters come out.
*
* This guard enforces that rule at the document boundary: a capture-phase
* keydown/keyup listener that stops the event from propagating further
* whenever a composition is in progress. Because it runs in the capture
* phase on `document` — the outermost node — it fires BEFORE React's
* delegated handlers (attached at the root container) and before any native
* target/bubble listener, so an inlined third-party component (e.g. the
* Univer office UI bundled into this plugin) can never intercept
* composition keys. The browser's native IME processing is untouched:
* stopPropagation only silences page JS, not the default action.
*
* The composition signal follows the DSH core convention (InputBar's IME
* guard, issue #535): `isComposing` for modern engines, keyCode 229 as the
* legacy signal engines emit without isComposing.
*/
/** The pure decision: is this keyboard event part of an IME composition? */
export function isImeComposition(event: { isComposing: boolean; keyCode: number }): boolean {
return event.isComposing || event.keyCode === 229
}
/**
* Register the document-level capture guard. Returns the disposer
* (HMR-safe; call through `ctx.effect`).
*/
export function registerImeGuard(): () => void {
const onKey = (event: KeyboardEvent): void => {
if (isImeComposition(event)) event.stopPropagation()
}
document.addEventListener('keydown', onKey, true)
document.addEventListener('keyup', onKey, true)
return () => {
document.removeEventListener('keydown', onKey, true)
document.removeEventListener('keyup', onKey, true)
}
}
+118 -40
View File
@@ -8,64 +8,53 @@
* bundle itself is a module-table consumer only (react + ui-primitives +
* xterm, all provided or inlined).
*/
import { Component, createElement, type ErrorInfo, type ReactNode } from 'react'
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import type { Context } from '../context-types.ts'
import { createSidebarStore } from './state.ts'
import { createBetterSidebarService } from './service.ts'
import { createBetterSidebarService, matchUrlTarget } from './service.ts'
import { resetChunks } from './chunk-loader.ts'
import { registerBuiltins } from './builtins/index.ts'
import { Sidebar } from './Sidebar.tsx'
import { registerTurnTailInterception } from './intercept.tsx'
import { RenderBoundary } from './RenderBoundary.tsx'
import { registerOpenPathInterception, registerTurnTailInterception } from './intercept.tsx'
import { registerLinkInterception } from './link-intercept.ts'
import { registerImeGuard } from './ime-guard.ts'
import { registerSettingsNavIcon } from './settings-nav-icon.ts'
import { loadPrefs } from './prefs.ts'
import { SideCardSection } from './SideCardSection.tsx'
import { api } from './api.ts'
import { t } from './locales.ts'
import { LOCALE_NS, attachLocale, t, zh, en } from './locales.ts'
import css from './sidebar.module.css'
import './layout.css'
/** Services required before mounting (provided by the client runtime). */
export const inject = ['slots', 'sessions', 'connection']
/** Services required before mounting (provided by the client runtime; the
* locale service backs the sidebar's copy — see locales.ts). */
export const inject = ['slots', 'sessions', 'connection', 'workspaces', 'locale']
/**
* Error boundary over the sidebar tree: a render error must never blank the
* whole panel silently — it shows a dismissible error strip and logs the
* stack for diagnosis.
* Error boundary over the sidebar tree (root scope): a render error in the
* sidebar SHELL itself must never blank the page silently — the shared
* RenderBoundary shows a dismissible error strip and logs the stack. The
* per-tab scope (Sidebar.tsx) catches viewer/editor crashes first; this root
* boundary stays as the last resort for Workbench/shell errors.
*/
class SidebarBoundary extends Component<{ children: ReactNode }, { error: string | null }> {
state = { error: null as string | null }
static getDerivedStateFromError(error: unknown): { error: string } {
return { error: error instanceof Error ? error.message : String(error) }
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error('[dsh-better-sidebar] render error:', error, info.componentStack)
}
render(): ReactNode {
if (this.state.error !== null) {
return (
<div className={css.boundaryError}>
<span>dsh-better-sidebar: {this.state.error}</span>
<button
type="button"
className={css.terminalRetry}
onClick={() => { this.setState({ error: null }) }}
>
{t('terminalRetry')}
</button>
</div>
)
}
return this.props.children
}
}
/**
* Client plugin body.
* @param ctx - the client cordis context (slots, sessions).
*/
export function apply(ctx: Context): void {
// The sidebar follows the DSH i18n system: attach the locale service so
// the module-level t()/isZh() resolve the Host-backed language preference
// (and switch live — the Sidebar root subscribes to it), and register the
// plugin's dictionaries into the shared locale registry. The disposers
// run on fiber disposal, so re-activation (HMR) re-registers cleanly.
attachLocale(ctx.locale)
ctx.effect(() => {
const offZh = ctx.locale.register(LOCALE_NS, 'zh', zh)
const offEn = ctx.locale.register(LOCALE_NS, 'en', en)
return () => { offZh(); offEn() }
}, 'dsh-better-sidebar: dictionaries')
// One store instance per activation: production code creates it only here,
// then hands it to the mounted panel and closes over it in the slot
// registrations (the official createXXXStore() factory rule — no
@@ -101,6 +90,10 @@ export function apply(ctx: Context): void {
}
}
try {
// Fresh chunk state for this activation: invalidate any chunk factories
// registered by a previous fiber (HMR) and drop the in-memory load cache
// so the next lazy open re-fetches the current chunk scripts.
resetChunks()
ctx.effect(() => {
let disposed = false
let root: Root | undefined
@@ -122,7 +115,7 @@ export function apply(ctx: Context): void {
host.setAttribute('data-dsh-better-sidebar', '')
document.body.appendChild(host)
root = createRoot(host)
root.render(createElement(SidebarBoundary, null, createElement(Sidebar, { ctx, store: sidebarStore })))
root.render(createElement(RenderBoundary, { className: css.boundaryError }, createElement(Sidebar, { ctx, store: sidebarStore })))
} catch (error) {
fail('mount', error)
}
@@ -146,6 +139,91 @@ export function apply(ctx: Context): void {
'dsh-better-sidebar: turn-tail interception',
)
ctx.effect(
() => {
try {
return registerOpenPathInterception(ctx, sidebarStore)
} catch (error) {
fail('interception', error)
return undefined
}
},
'dsh-better-sidebar: open-path interception',
)
ctx.effect(
() => {
try {
// External http(s) links in the chat/GUI open the sidebar instead
// of a new window. Gated on the browserInterceptLinks MASTER pref,
// the URL's protocol flag (browserInterceptHttp / Https — https
// defaults OFF: most https sites refuse iframe embedding), and the
// target tab's enable switch; Ctrl/Cmd+click always bypasses. The
// target is the first registered tab whose `urlTarget` claims the
// URL (enabled tabs only), else the built-in browser tab.
const urlTargetOf = (url: URL): string | undefined => {
const prefs = sidebarStore.getPrefs()
const enabled = service.getTabs().filter(tab => prefs.tabsEnabled[tab.id] !== false)
return matchUrlTarget(enabled, url)?.id
}
return registerLinkInterception({
takeoverEnabled: (url) => {
const prefs = sidebarStore.getPrefs()
if (prefs.browserInterceptLinks === false) return false
const protocolOn = url.protocol === 'https:'
? prefs.browserInterceptHttps !== false
: prefs.browserInterceptHttp !== false
if (!protocolOn) return false
// A plugin claim is the target (already enabled-filtered);
// otherwise the built-in browser must be enabled.
return urlTargetOf(url) !== undefined || prefs.tabsEnabled['browser'] !== false
},
openInSidebar: (url) => {
let title: string | undefined
try { title = new URL(url).hostname } catch { /* keep the default title */ }
const type = urlTargetOf(new URL(url)) ?? 'browser'
ctx.betterSidebar?.openTab({ type, url, title })
},
selfOrigin: window.location.origin,
})
} catch (error) {
fail('interception', error)
return undefined
}
},
'dsh-better-sidebar: link interception',
)
// The IME guard: composition keys (candidate arrows, confirm, cancel)
// belong to the input method, never to page JS. Inlined third-party UI
// (formerly Univer's office controls, #562 regression) has shipped
// unguarded keydown handlers that hijack ArrowUp/ArrowDown and break
// Chinese input; the document-capture guard neutralizes the whole class
// before React or any native listener sees the event. Registered as
// early as possible so no other capture-phase listener can win the
// ordering race.
ctx.effect(
() => {
try {
return registerImeGuard()
} catch (error) {
fail('ime guard', error)
return undefined
}
},
'dsh-better-sidebar: IME composition guard',
)
// DSH 0.1.x does not yet carry an icon through the settings.section
// registration contract: its shell renders a generic gear for every
// external section. Mark only this plugin's localized nav row so
// layout.css can paint the requested Side card SVG; the disposer clears
// the marker for HMR / plugin disable.
ctx.effect(
() => registerSettingsNavIcon(() => t('settingsNav')),
'dsh-better-sidebar: settings navigation icon',
)
// 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
+34 -3
View File
@@ -11,6 +11,7 @@ import type { Context } from '../context-types.ts'
import type { SidebarStore } from './state.ts'
import { t } from './locales.ts'
import { resolveSidebarPath, selectProducedFiles } from './produced-files.ts'
import { wrapOpenPath } from './openpath-intercept.ts'
import css from './sidebar.module.css'
/** Open a file in the sidebar's editor (used by the intercepted row and the explorer). */
@@ -56,9 +57,22 @@ export function SidebarProducedFiles(props: {
)
}
/** Register the turn-tail interception (returns the disposer). */
/**
* Register the turn-tail interception (returns the disposer).
*
* The slot is a CHILD slot the host's ui-conversation declares in its
* `conversation.chat.node` children table (kind: chain, scope: session).
* Registering it directly races the declaration — the ui-slots core's
* load-time validation throws "not declared (a parent entry's children
* table must declare it)" when the parent entry is not on the ledger yet.
* slots.inject waits for the declaration: the callback runs synchronously
* when the slot is already declared, otherwise it runs inside the declaring
* register() call once the declaration commits; declaration collapse
* disposes the entry and a later declaration re-registers it. This mirrors
* @deepseek-ai/dsh-client-ui-deliverables' registration of the same slot.
*/
export function registerTurnTailInterception(ctx: Context, store: SidebarStore): () => void {
return ctx.slots.register({
return ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({
name: 'conversation.chat.turnTail',
// Decline the takeover while the editor tab type is disabled in the side
// card settings: the produced-files row falls back to the default
@@ -72,5 +86,22 @@ export function registerTurnTailInterception(ctx: Context, store: SidebarStore):
inject: (sessionId: string) => ({
openInSidebar: (path: string) => { openSidebarFile(ctx, store, sessionId, path) },
}),
}, SidebarProducedFiles)
}, SidebarProducedFiles))
}
/**
* Register the chat file-open interception: wraps `ctx.workspaces.openPath`
* — the single funnel every chat-side file open goes through (tool-row path
* links, the produced-files row, prose mentions) — so opens land in the
* sidebar editor instead of the Host OS. Gated by BOTH the `interceptOpenPath`
* pref and the editor tab's enable switch; declined opens fall through to
* the original method. Returns the disposer restoring the original (HMR-safe).
*/
export function registerOpenPathInterception(ctx: Context, store: SidebarStore): () => void {
return wrapOpenPath(ctx.workspaces, {
takeoverEnabled: () => store.getPrefs().interceptOpenPath !== false
&& store.getPrefs().tabsEnabled['editor'] !== false,
currentSessionId: () => ctx.sessions.list.getSnapshot().current,
openInSidebar: (path, sessionId) => { openSidebarFile(ctx, store, sessionId, path) },
})
}
+8
View File
@@ -23,6 +23,8 @@ import { toml } from '@codemirror/legacy-modes/mode/toml'
import { nginx } from '@codemirror/legacy-modes/mode/nginx'
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile'
import { properties } from '@codemirror/legacy-modes/mode/properties'
import { csharp, kotlin } from '@codemirror/legacy-modes/mode/clike'
import { swift } from '@codemirror/legacy-modes/mode/swift'
/** The lowercased file extension of a path ('' when none). */
export function extOf(path: string): string {
@@ -48,6 +50,9 @@ export function languageKeyForExt(ext: string): string | null {
case 'yaml': case 'yml': return 'yaml'
case 'sql': return 'sql'
case 'java': return 'java'
case 'cs': return 'csharp'
case 'kt': case 'kts': return 'kotlin'
case 'swift': return 'swift'
case 'c': case 'h': return 'c'
case 'cc': case 'cpp': case 'cxx': case 'hpp': case 'hh': case 'hxx': return 'cpp'
case 'rs': return 'rust'
@@ -76,6 +81,9 @@ const FACTORIES: Record<string, () => Language | LanguageSupport> = {
yaml: () => yaml(),
sql: () => sql(),
java: () => java(),
csharp: () => StreamLanguage.define(csharp),
kotlin: () => StreamLanguage.define(kotlin),
swift: () => StreamLanguage.define(swift),
c: () => cpp(),
cpp: () => cpp(),
rust: () => rust(),
+61 -11
View File
@@ -1,15 +1,22 @@
/**
* Layout push: when the sidebar is open it OCCUPIES the layout instead of
* Layout push: when a panel is open it OCCUPIES the layout instead of
* floating over it — the app shell (#root, the AppFrame three-column grid)
* gives up the panel's width on the right. Only the center column is
* flexible (1fr), so the squeeze lands exactly on the conversation output
* and the input bar, like a VSCode sidebar. The fixed panel covers the
* vacated strip, which looks seamless because #root's background is the
* theme base.
* gives up space. Only the center column is flexible (1fr), so the right
* panel's width squeeze (margin-right on #root) lands exactly on the
* conversation output and the input bar, like a VSCode sidebar.
*
* The width rides a CSS variable updated by the Sidebar shell (0 while
* collapsed); expand/collapse animates both the margin and the panel slide
* on the same theme duration. Width drags disable the transition so the
* The bottom panel squeezes ONLY the center column — it must not cover the
* app's own left sidebar or the right panel. DSH 0.1.x wraps slot hosts in
* [data-slot] containers, so the AppFrame grid lives one level deeper:
* #root > div[data-slot="root"] > div (the frame). Its grid items are
* (frame > div:nth-child(1..3)): sidebarCol, centerCol, detailsCol, so the
* vertical push lands on the center column alone. The fixed panels cover
* the vacated strips, which looks seamless because #root's background is
* the theme base.
*
* The sizes ride CSS variables updated by the Sidebar shell (0 while
* collapsed); expand/collapse animates both the margins and the panel
* slides on the same theme duration. Drags disable the transition so the
* layout tracks the pointer.
*/
#root {
@@ -17,12 +24,55 @@
transition: margin-right var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
body[data-dsh-sidebar-dragging] #root {
/* The AppFrame's grid items are sidebarCol, centerCol, detailsCol (children
1-3 of #root > div[data-slot="root"] > div) — nth-child(2) is the center
column. A stretched grid item shrinks by its margins, so the conversation
content (output + input bar) lifts without touching the sidebars. */
#root > div[data-slot="root"] > div > div:nth-child(2) {
margin-bottom: var(--dsh-sidebar-height, 0px);
transition: margin-bottom var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
/* When the sidebar is collapsed, the toggle cluster reclaims the top-right
corner. Push the DSH session header's right padding out so its right-aligned
utilities (the "Session log" download capsule) yield the corner instead of
hiding under the cluster. The header default right-pads 28px; the 2-button
cluster spans right 10→70px, so 78px clears it with an 8px gap. Anchor on
the header's slot host wrapper ([data-slot="conversation.session.header"])
rather than a positional path: DSH 0.1.x nests the header several levels
under the center column. The Sidebar shell toggles the body attribute with
the panel open state. */
body[data-dsh-sidebar-collapsed] [data-slot="conversation.session.header"] > header {
padding-right: 78px;
}
body[data-dsh-sidebar-dragging] #root,
body[data-dsh-sidebar-dragging] #root > div[data-slot="root"] > div > div:nth-child(2) {
transition: none;
}
/* DSH 0.1.x gives external settings sections a generic gear and exposes no
icon field in the settings.section contract. settings-nav-icon.ts marks
only this plugin's localized row; render the requested Lucide
gallery-horizontal-end SVG as a currentColor mask so it follows the native
nav hover/active colors without changing the shell's 16px icon rhythm. */
[data-dsh-better-sidebar-settings-nav] > svg:first-child {
display: none;
}
[data-dsh-better-sidebar-settings-nav]::before {
content: '';
flex: none;
width: 16px;
height: 16px;
background: currentColor;
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2 7v10'/%3E%3Cpath d='M6 5v14'/%3E%3Crect width='12' height='18' x='10' y='3' rx='2'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2 7v10'/%3E%3Cpath d='M6 5v14'/%3E%3Crect width='12' height='18' x='10' y='3' rx='2'/%3E%3C/svg%3E") center / contain no-repeat;
}
@media (prefers-reduced-motion: reduce) {
#root {
#root,
#root > div[data-slot="root"] > div > div:nth-child(2) {
transition: none;
}
}
+89
View File
@@ -0,0 +1,89 @@
/**
* Lazy chunk view wrapper: mounts a component that lives in a lazy chunk,
* showing a loading placeholder while the chunk script loads and an error +
* retry affordance on failure. Used by the built-in tab/viewer descriptors.
*
* Contract note: {@link lazyChunkComponent} returns a plain render-prop
* function — the descriptor contract is `component: (props) => ReactNode`,
* and the repo renders descriptors BOTH ways: Sidebar calls
* `descriptor.component(props)` directly, EditorHost renders it via
* `createElement`. The wrapper function body therefore contains no hooks;
* all state lives in the inner {@link LazyChunkView} component.
*/
import { createElement, useEffect, useState, type ComponentType, type ReactNode } from 'react'
import { loadChunk, type ChunkExports, type ChunkName } from './chunk-loader.ts'
import { t } from './locales.ts'
import css from './sidebar.module.css'
interface LazyChunkViewProps<P> {
chunk: ChunkName
/** Module-level-stable selector (an inline lambda would re-trigger the effect). */
pick: (mod: ChunkExports) => ComponentType<P> | undefined
props: P
}
function LazyChunkView<P>({ chunk, pick, props }: LazyChunkViewProps<P>): ReactNode {
const [attempt, setAttempt] = useState(0)
const [state, setState] = useState<
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'ready'; Comp: ComponentType<any> }
>({ status: 'loading' })
useEffect(() => {
let cancelled = false
setState({ status: 'loading' })
loadChunk(chunk).then((mod) => {
if (cancelled) return
const Comp = pick(mod)
if (Comp === undefined) {
setState({ status: 'error', message: `[dsh-better-sidebar] chunk "${chunk}" is missing its component` })
return
}
setState({ status: 'ready', Comp })
}).catch((error: unknown) => {
if (cancelled) return
setState({ status: 'error', message: error instanceof Error ? error.message : String(error) })
})
return () => { cancelled = true }
}, [chunk, pick, attempt])
if (state.status === 'loading') {
return <div className={css.editorPlaceholder}>{t('loading')}</div>
}
if (state.status === 'error') {
return (
<div className={css.editorError}>
<span>{state.message}</span>
<button
type="button"
className={css.terminalRetry}
onClick={() => { setAttempt(current => current + 1) }}
>
{t('terminalRetry')}
</button>
</div>
)
}
return createElement(state.Comp, props)
}
/**
* Build a descriptor-compatible lazy wrapper for a chunk-resident component.
* The returned function is the descriptor `component` itself: it returns an
* element and never calls hooks, so both invocation styles (plain function
* call and createElement/JSX render) work. `pick` must be a module-level
* function (stable identity) — an inline lambda would re-trigger the load
* effect on every render.
* @param chunk - the chunk name (see chunk-loader.ts).
* @param pick - select the component from the chunk's exports.
*/
export function lazyChunkComponent<P extends object>(
chunk: ChunkName,
pick: (mod: ChunkExports) => ComponentType<P> | undefined,
): (props: P) => ReactNode {
return (props: P) => createElement(
LazyChunkView as ComponentType<LazyChunkViewProps<P>>,
{ chunk, pick, props },
)
}
+73
View File
@@ -0,0 +1,73 @@
/**
* Chat/GUI external-link interception: clicking an http(s) link that points
* OUTSIDE the GUI (chat messages, tool rows, prose mentions) opens the
* sidebar instead of a new browser tab. Gated by the caller through
* `takeoverEnabled(url)` — the `browserInterceptLinks` master, the URL's
* protocol flag (`browserInterceptHttp` / `browserInterceptHttps`) and the
* target tab's enable switch — and a Ctrl/Cmd/Shift/Alt-modified click
* always bypasses the takeover so the user can still force a real browser
* tab.
*
* Only the GUI's OWN document is watched — links inside the browser tab's
* sandboxed iframe live in another document and never bubble here (and
* their clicks must keep working inside the sidebar).
*/
/** The pure decision: the URL to open in the sidebar, or null to let the
* click fall through. Extracted so the policy is unit-testable without a
* DOM. `anchorHref` must be the ABSOLUTE href (`<a>.href` already is).
* The protocol/same-origin policy lives HERE; the prefs gates (master +
* protocol flags + target enablement) live in the caller's
* `takeoverEnabled(url)` callback. */
export function shouldInterceptLink(anchorHref: string, selfOrigin: string): string | null {
let url: URL
try {
url = new URL(anchorHref)
} catch {
return null
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
// Same-origin links are GUI-internal navigation (settings pages, tool
// docs) — never routed into the sidebar.
try {
if (url.origin === new URL(selfOrigin).origin) return null
} catch {
// Unparsable selfOrigin (never in practice): intercept defensively.
}
return url.href
}
/** Whether a left-click may be taken over (unmodified left click only). */
export function isPlainLeftClick(event: { button: number; metaKey: boolean; ctrlKey: boolean; shiftKey: boolean; altKey: boolean }): boolean {
return event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey
}
/**
* Register the document-level click capture that funnels external links
* into the sidebar. Returns the disposer (HMR-safe).
*/
export function registerLinkInterception(opts: {
/** Whether the takeover may happen for THIS url (the caller's prefs
* gates: master switch, protocol flag, target enablement). */
takeoverEnabled: (url: URL) => boolean
/** Open the sidebar tab at `url` (the caller resolves the target type). */
openInSidebar: (url: string) => void
/** The GUI's own origin (window.location.origin at registration). */
selfOrigin: string
}): () => void {
const onClick = (event: MouseEvent): void => {
if (!isPlainLeftClick(event)) return
if (event.defaultPrevented) return
const target = event.target
if (target === null || typeof (target as Element).closest !== 'function') return
const anchor = (target as Element).closest('a[href]') as HTMLAnchorElement | null
if (anchor === null) return
const url = shouldInterceptLink(anchor.href, opts.selfOrigin)
if (url === null) return
if (!opts.takeoverEnabled(new URL(url))) return
event.preventDefault()
opts.openInSidebar(url)
}
document.addEventListener('click', onClick, true)
return () => { document.removeEventListener('click', onClick, true) }
}
+240 -45
View File
@@ -1,28 +1,37 @@
/**
* Minimal zh/en copy for the sidebar. The plugin renders outside the slot
* system's locale seat, so it reads the browser language directly (the DSH
* locale preference and the browser language agree in practice; both default
* from navigator.language anyway).
* Minimal zh/en copy for the sidebar. The copy follows the DSH i18n system:
* the client apply attaches the locale service (`ctx.locale`, provided by
* `@deepseek-ai/dsh-client-locale`) through {@link attachLocale}, and
* `t()`/`isZh()` resolve the active locale from it — the Host-backed
* `locale.preference` wins over the raw browser language and switches live.
* Without an attached service (standalone/test compositions) the browser
* language is used, matching the previous behavior. The dictionaries are
* also registered into the DSH locale registry under {@link LOCALE_NS}.
*/
const zh = {
/** The zh dictionary (also registered into the DSH locale registry under {@link LOCALE_NS}). */
export const zh = {
explorer: '资源管理器',
git: '源代码管理',
terminal: '终端',
editor: '编辑器',
newTab: '新建标签页',
openExplorer: '资源管理器',
brokenSymlink: '失效的软链接',
openGit: 'Git 面板',
newTerminal: '新终端',
terminalLimit: '终端数量已达上限 (3)',
close: '关闭',
collapse: '折叠侧边栏',
expand: '展开侧边栏',
expandFullscreen: '展开为全屏',
restoreFullscreen: '还原宽度',
collapseBottomPanel: '折叠底部面板',
expandBottomPanel: '展开底部面板',
terminalError: '终端连接失败',
terminalConnectFailed: '终端多次连接失败',
terminalRetry: '重试',
terminalDepsFailed: '终端依赖 node-pty 加载失败',
terminalDepsHint: '在 DSH 所在环境的终端或 cmd 中执行以下命令修复,然后点重试(node-pty 与 DSH 核心保持同一版本):',
terminalDepsProfile: '(检测到 profile:{profile})',
preview: '预览',
edit: '编辑',
refresh: '刷新',
@@ -96,31 +105,43 @@ const zh = {
openEditor: '打开编辑器',
gitDetail: '查看变更详情',
referenceFile: '@文件',
addToConversation: '添加到对话',
copyRelative: '复制相对地址',
copyAbsolute: '复制绝对地址',
download: '下载',
settingsNav: '侧边卡片',
settingsIntro: '管理侧边卡片的显示内容与默认行为',
settingsPopupDesc: '为「{feature}」配置相关选项',
settingsDone: '完成',
settingsOpenTitle: '新会话默认打开',
settingsOpenDesc: '新建会话时自动展开侧边卡片;已存在的会话保持各自布局',
settingsWidthTitle: '默认宽度占比',
settingsWidthDesc: '新建会话时侧边卡片占窗口宽度的百分比 (20–60)',
settingsWidthSuffix: '%',
settingsOpenPathTitle: '聊天区文件在侧边栏打开',
settingsOpenPathDesc: '在聊天里点击文件链接(工具行、产物列表、文件提及)时,在侧边栏编辑器中打开,不再调用系统默认应用',
settingsTitleBarTitle: '位置兼容模式',
settingsTitleBarDesc: '为 Windows 右上角的原生标题栏预留空间:侧边栏按钮与侧边栏内容整体下移,避免被标题栏遮挡',
settingsTitleBarStripTitle: '下移距离',
settingsTitleBarStripDesc: '标题栏条带高度:侧边栏按钮与内容下移的像素数(0–120,默认 40)',
settingsSaveFailed: '保存失败',
settingsConflict: '设置已被其他窗口修改,请重试',
binaryNoPreview: '此文件类型不支持预览',
downloadToView: '下载查看',
officeTooLarge: '文件过大,无法预览',
officeCorrupt: '文件损坏或格式无效',
officeEncrypted: '不支持加密文件',
officeLoadFailed: 'Office 预览组件加载失败',
previousSlide: '上一页',
nextSlide: '下一页',
zoom: '缩放',
zoomHint: 'Alt + 滚轮',
settingsSubagentTitle: '检测到子代理时自动展开子代理页面',
settingsSubagentDesc: '当前会话产生新的子代理时,自动展开侧边栏并打开子代理页面;关闭后需手动打开',
settingsSubagentTitle: '检测到子代理时自动展开任务管理页',
settingsSubagentDesc: '当前会话产生新的子代理时,自动展开侧边栏并打开任务管理页;关闭后需手动打开',
settingsJobsTitle: '有新后台任务时自动展开后台任务页',
settingsJobsDesc: '当前会话出现新的后台任务时,自动展开侧边栏并打开后台任务页(每个新任务都会触发);关闭后需手动打开',
settingsToolsTitle: '为模型注入终端工具',
settingsToolsDesc: '开启后,模型可通过 terminal_create 等 8 个工具创建并操作侧边栏终端(默认关闭)',
settingsBottomTerminalTitle: '底部面板首次展开自动开终端',
settingsBottomTerminalDesc: '每次会话中第一次展开底部面板时,尝试在底部面板自动打开一个新终端标签(终端数量上限仍会限制;默认开启)',
settingsFontFamilyTitle: '终端字体',
settingsFontFamilyDesc: '自定义终端字体族(CSS font-family,如 "JetBrains Mono", monospace;留空跟随主题等宽字体)',
settingsFontFamilyPlaceholder: '"JetBrains Mono", monospace',
settingsFontSizeTitle: '终端字号',
settingsFontSizeDesc: '终端字号(9–32,默认 13)',
settingsFontSizeSuffix: 'px',
settingsTabsTitle: '侧边栏内容',
settingsViewersTitle: '文件预览',
settingsGeneralTitle: '常规',
@@ -128,14 +149,42 @@ const zh = {
settingsViewerCatchAll: '兜底:任意文件',
viewerImage: '图片',
viewerPdf: 'PDF',
viewerDocx: 'Word 文档',
viewerXlsx: 'Excel 表格',
viewerPptx: 'PPT 演示',
viewerMarkdown: 'Markdown',
viewerCode: '代码',
viewerBinary: '二进制下载',
subagent: '子代理',
openSubagent: '子代理',
viewerHtml: 'HTML',
browser: '浏览器',
browserPlaceholder: '输入网址,例如 example.com',
browserGo: '前往',
browserBack: '后退',
browserForward: '前进',
browserStart: '输入网址开始浏览(沙箱模式)',
browserBlockedScheme: '已阻止:仅支持 http/https 链接',
browserBlockedLoopback: '已阻止:不允许在浏览器中访问本机或内部地址',
browserInvalid: '无效的网址',
browserNoSandboxWarning: '沙箱已关闭:当前页面与界面同源,拥有完整会话权限(可在设置中恢复)',
htmlNoSandboxWarning: '沙箱已关闭:此 HTML 与界面同源,可读取会话文件与内部接口(可在设置中恢复)',
sandboxStatusOn: '沙箱模式:已启用 · 页面无法访问界面数据与本地文件,登录态与第三方 Cookie 可能不可用',
sandboxUnlock: '临时解锁(不安全)',
sandboxRestore: '恢复沙箱',
settingsHtmlDefaultUnsafeTitle: 'HTML 预览默认以非沙箱模式打开(不安全)',
settingsHtmlDefaultUnsafeDesc: '开启后,每次打开 HTML 文件时预览默认处于非沙箱状态(与界面同源,可读取会话文件与内部接口);可在状态行临时恢复沙箱',
settingsHtmlSandboxTitle: '关闭 HTML 预览沙箱(不安全)',
settingsHtmlSandboxDesc: '关闭后,预览的 HTML 将与界面同源运行,可读取会话文件、本地存储并调用内部接口。仅对完全可信的文件开启',
settingsBrowserSandboxTitle: '关闭浏览器沙箱(不安全)',
settingsBrowserSandboxDesc: '关闭后,访问的任何网站都将与界面同源运行,可读取会话数据并冒充你的登录状态。仅对完全可信的站点开启',
settingsBrowserLinksTitle: '聊天区外链在侧边栏打开',
settingsBrowserLinksDesc: '开启后,点击聊天或界面中的外链时在侧边栏打开,不再弹出新窗口;HTTP 与 HTTPS 可分别通过下方开关控制;Ctrl/Cmd 点击可临时放行',
settingsBrowserHttpTitle: '侧边打开HTTP网页',
settingsBrowserHttpDesc: '开启后,点击聊天或界面中的 HTTP 外链时在侧边栏打开(声明了 urlTarget 的插件页面优先);Ctrl/Cmd 点击可临时放行',
settingsBrowserHttpsTitle: '侧边打开HTTPS网页',
settingsBrowserHttpsDesc: '开启后,点击聊天或界面中的 HTTPS 外链时在侧边栏打开。默认关闭:多数 HTTPS 站点拒绝被嵌入,走系统浏览器更顺畅',
browserOpenExternal: '在浏览器中打开',
browserEmbedBlocked: '{host} 拒绝了嵌入请求',
browserEmbedBlockedDesc: '该站点通过 X-Frame-Options / frame-ancestors 禁止在其它页面中显示,无法在侧边栏内加载。可在浏览器中直接打开',
browserEmbedAnyway: '仍然加载',
subagent: '任务管理',
openSubagent: '任务管理',
subagentMainAgent: '主代理',
subagentEmpty: '暂无子代理',
subagentEmptyDesc: '当前主代理派生的子代理将显示在这里',
@@ -149,26 +198,67 @@ const zh = {
subagentDiagUnsupported: '不支持的条目',
subagentDiagUnavailable: '不可用',
subagentThinking: '思考中…',
jobs: '后台任务',
jobsCount: '{count} 个后台任务',
jobsCountRunning: '{count} 个后台任务 · {running} 运行中',
jobStatusRunning: '运行中',
jobStatusStopping: '终止中',
jobStatusCompleted: '已完成',
jobStatusKilled: '已终止',
jobStatusFailed: '失败',
jobDurationSeconds: '{seconds} 秒',
jobDurationMinutes: '{minutes} 分 {seconds} 秒',
jobDurationHours: '{hours} 小时 {minutes} 分',
jobViewOutput: '查看输出',
jobHideOutput: '收起输出',
jobNoOutput: '暂无输出',
jobNotReadYet: '等待模型读取该任务的输出(模型执行 job_output 后,输出会显示在这里)',
jobOutputTruncated: '输出过长,已截断显示',
jobOutputError: '输出读取失败',
jobKill: '终止',
jobKillConfirm: '再次点击确认终止',
jobKillError: '终止失败',
addPluginsTabCard: '添加 Tab 插件',
addPluginsTabCardDesc: '注册新的侧边栏页面',
addPluginsViewerCard: '添加预览插件',
addPluginsViewerCardDesc: '注册新的文件类型预览',
addPluginsTabDesc: '侧边栏页面(Tab)可以由插件扩展。插件通过 ctx.betterSidebar 服务注册;点击「安装」复制安装命令,粘贴到 DSH 所在环境的终端执行。',
addPluginsViewerDesc: '文件预览器可以由插件扩展。插件通过 ctx.betterSidebar 服务注册;点击「安装」复制安装命令,粘贴到 DSH 所在环境的终端执行。',
addPluginsBrowseMore: '在 GitHub 上浏览更多插件(topic: dsh-better-sidebar)',
addPluginsRecommended: '推荐插件',
addPluginsEmpty: '暂未收录插件,欢迎在 GitHub topic 下发布你的插件',
openPlugin: '跳转',
copyInstall: '复制安装命令',
pluginOfficeDesc: '为 better-sidebar 编辑器提供 Office 三件套预览(.docx / .xlsx / .pptx),把重型 Office 渲染库拆出主包、按需安装',
pluginGitRemotesDesc: 'better-sidebar Git 远程 Tab:看分支/上游/ahead-behind,fetch(可 prune)、ff-only pull、确认后才 push。不替换内置 Git 的暂存/提交,也不提供 force-push 或模型自动推送',
pluginSentinelDesc: '条件驱动的 agent 唤醒系统:文件/进程/端口/HTTP/命令/webhook 传感器,条件达成自动唤醒休眠会话;注册「哨兵」Tab 展示服务器全局监控表',
pluginSidebarQaDesc: '基于 better-sidebar 的划选提问tab分页: 对话划选 → 右侧面板提问 → 同工作区独立追问会话(❓追问·主题):快速无思考模型压缩主对话上下文后与引文一起注入,不打断主对话;追问可嵌套、可继续、可归档',
pluginVideoPreviewDesc: '在 better-sidebar 编辑器内联预览视频文件(.mp4/.webm/.mov/.mkv/.avi 等),自带支持 HTTP Range(206)的 /video 宿主路由,可拖动进度条、不受 20MB mediaLimit 限制',
}
const en: Record<keyof typeof zh, string> = {
/** The en dictionary (key-set-equal to zh, enforced by the type annotation). */
export const en: Record<keyof typeof zh, string> = {
explorer: 'Explorer',
git: 'Source Control',
terminal: 'Terminal',
editor: 'Editor',
newTab: 'New tab',
openExplorer: 'Explorer',
brokenSymlink: 'Broken symlink',
openGit: 'Git panel',
newTerminal: 'New terminal',
terminalLimit: 'Terminal limit reached (3)',
close: 'Close',
collapse: 'Collapse sidebar',
expand: 'Expand sidebar',
expandFullscreen: 'Expand to full width',
restoreFullscreen: 'Restore width',
collapseBottomPanel: 'Collapse bottom panel',
expandBottomPanel: 'Expand bottom panel',
terminalError: 'Terminal connection failed',
terminalConnectFailed: 'Terminal failed to connect repeatedly',
terminalRetry: 'Retry',
terminalDepsFailed: 'Terminal dependency node-pty failed to load',
terminalDepsHint: 'Run the command below in a terminal or cmd on the DSH machine to repair it, then retry (node-pty stays in sync with the DSH core version):',
terminalDepsProfile: ' (detected profile: {profile})',
preview: 'Preview',
edit: 'Edit',
refresh: 'Refresh',
@@ -242,31 +332,43 @@ const en: Record<keyof typeof zh, string> = {
openEditor: 'Open editor',
gitDetail: 'View change details',
referenceFile: '@file',
addToConversation: 'Add to conversation',
copyRelative: 'Copy relative path',
copyAbsolute: 'Copy absolute path',
download: 'Download',
settingsNav: 'Side card',
settingsIntro: 'Manage what the side card shows and how it behaves',
settingsPopupDesc: 'Configure related options for {feature}',
settingsDone: 'Done',
settingsOpenTitle: 'Open by default for new conversations',
settingsOpenDesc: 'Expand the side card automatically for brand-new conversations; existing conversations keep their own layouts',
settingsWidthTitle: 'Default width share',
settingsWidthDesc: 'The side card\'s default share of the window width for new conversations (20–60)',
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'Open chat files in the sidebar',
settingsOpenPathDesc: 'Open file links in the chat (tool rows, produced files, mentions) in the sidebar editor instead of the system default app',
settingsTitleBarTitle: 'Position compatibility mode',
settingsTitleBarDesc: 'Reserve space for the native Windows title bar at the top-right so the sidebar buttons and content sit below it instead of underneath',
settingsTitleBarStripTitle: 'Shift distance',
settingsTitleBarStripDesc: 'Title-bar strip height: how far the sidebar buttons and content move down in px (0–120, default 40)',
settingsSaveFailed: 'Failed to save',
settingsConflict: 'The setting changed in another window — please retry',
binaryNoPreview: 'This file type cannot be previewed',
downloadToView: 'Download to view',
officeTooLarge: 'File too large to preview',
officeCorrupt: 'File is corrupt or in an invalid format',
officeEncrypted: 'Encrypted files are not supported',
officeLoadFailed: 'Office preview component failed to load',
previousSlide: 'Previous',
nextSlide: 'Next',
zoom: 'Zoom',
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',
settingsSubagentTitle: 'Auto-open the Tasks page when a subagent appears',
settingsSubagentDesc: 'Expand the side card and open the Tasks page when the current conversation spawns a new subagent; turn off to open it manually',
settingsJobsTitle: 'Auto-open the Jobs page on a new background job',
settingsJobsDesc: 'Expand the side card and open the Jobs page whenever a new background job appears for the current conversation (every new job triggers); 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)',
settingsBottomTerminalTitle: 'Auto-open a terminal on the bottom panel\'s first expansion',
settingsBottomTerminalDesc: 'When the bottom panel is expanded for the first time in a session, try to open a fresh terminal tab there (the terminal quota still applies; on by default)',
settingsFontFamilyTitle: 'Terminal font family',
settingsFontFamilyDesc: 'Custom terminal font family (a CSS font-family stack like "JetBrains Mono", monospace; leave empty to follow the theme\'s monospace font)',
settingsFontFamilyPlaceholder: '"JetBrains Mono", monospace',
settingsFontSizeTitle: 'Terminal font size',
settingsFontSizeDesc: 'Terminal font size in px (9–32, default 13)',
settingsFontSizeSuffix: 'px',
settingsTabsTitle: 'Sidebar content',
settingsViewersTitle: 'File viewers',
settingsGeneralTitle: 'General',
@@ -274,14 +376,42 @@ const en: Record<keyof typeof zh, string> = {
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',
viewerHtml: 'HTML',
browser: 'Browser',
browserPlaceholder: 'Enter a URL, e.g. example.com',
browserGo: 'Go',
browserBack: 'Back',
browserForward: 'Forward',
browserStart: 'Enter a URL to start browsing (sandbox mode)',
browserBlockedScheme: 'Blocked: only http/https URLs are allowed',
browserBlockedLoopback: 'Blocked: local and internal addresses cannot be browsed here',
browserInvalid: 'Invalid URL',
browserNoSandboxWarning: 'Sandbox off: the current page runs with full GUI privileges (re-enable in settings)',
htmlNoSandboxWarning: 'Sandbox off: this HTML runs with full GUI privileges (re-enable in settings)',
sandboxStatusOn: 'Sandbox mode: on · pages cannot access the GUI\'s data or local files; logins and third-party cookies may not work',
sandboxUnlock: 'Temporarily disable (unsafe)',
sandboxRestore: 'Restore sandbox',
settingsHtmlDefaultUnsafeTitle: 'Open HTML previews unsandboxed by default (unsafe)',
settingsHtmlDefaultUnsafeDesc: 'When on, every newly opened HTML preview starts in the unsandboxed state (same origin as the GUI — it can read session files and internal APIs); the status row still offers a one-tap restore',
settingsHtmlSandboxTitle: 'Disable HTML preview sandbox (unsafe)',
settingsHtmlSandboxDesc: 'With the sandbox off, previewed HTML runs with the same origin as the GUI: it can read session files, local storage and call internal APIs. Only enable for fully trusted files',
settingsBrowserSandboxTitle: 'Disable browser sandbox (unsafe)',
settingsBrowserSandboxDesc: 'With the sandbox off, any visited site runs with the same origin as the GUI: it can read session data and act as your logged-in session. Only enable for fully trusted sites',
settingsBrowserLinksTitle: 'Open chat external links in the sidebar',
settingsBrowserLinksDesc: 'When on, clicking an external link in the chat or GUI opens the sidebar instead of a new window; HTTP and HTTPS are controlled separately by the switches below; Ctrl/Cmd+click always bypasses',
settingsBrowserHttpTitle: 'Open HTTP pages in the sidebar',
settingsBrowserHttpDesc: 'When on, clicking an HTTP external link in the chat or GUI opens the sidebar (plugin pages declaring urlTarget win); Ctrl/Cmd+click always bypasses',
settingsBrowserHttpsTitle: 'Open HTTPS pages in the sidebar',
settingsBrowserHttpsDesc: 'When on, clicking an HTTPS external link in the chat or GUI opens the sidebar. Off by default: most HTTPS sites refuse to be embedded, so the system browser is the smoother default',
browserOpenExternal: 'Open in browser',
browserEmbedBlocked: '{host} refused to be embedded',
browserEmbedBlockedDesc: 'The site forbids being displayed inside other pages (X-Frame-Options / frame-ancestors), so it cannot load in the sidebar. Open it directly in your browser instead.',
browserEmbedAnyway: 'Load anyway',
subagent: 'Tasks',
openSubagent: 'Tasks',
subagentMainAgent: 'Main agent',
subagentEmpty: 'No subagents',
subagentEmptyDesc: 'Subagents spawned under the main agent will appear here',
@@ -295,15 +425,80 @@ const en: Record<keyof typeof zh, string> = {
subagentDiagUnsupported: 'Unsupported',
subagentDiagUnavailable: 'Unavailable',
subagentThinking: 'Thinking…',
jobs: 'Background jobs',
jobsCount: '{count} background jobs',
jobsCountRunning: '{count} background jobs · {running} running',
jobStatusRunning: 'Running',
jobStatusStopping: 'Stopping',
jobStatusCompleted: 'Completed',
jobStatusKilled: 'Killed',
jobStatusFailed: 'Failed',
jobDurationSeconds: '{seconds}s',
jobDurationMinutes: '{minutes}m {seconds}s',
jobDurationHours: '{hours}h {minutes}m',
jobViewOutput: 'View output',
jobHideOutput: 'Hide output',
jobNoOutput: 'No output yet',
jobNotReadYet: 'Waiting for the model to read this job; its output appears here once the model runs job_output',
jobOutputTruncated: 'Output truncated',
jobOutputError: 'Failed to read output',
jobKill: 'Kill',
jobKillConfirm: 'Click again to confirm kill',
jobKillError: 'Kill failed',
addPluginsTabCard: 'Add tab plugins',
addPluginsTabCardDesc: 'Register a new sidebar page',
addPluginsViewerCard: 'Add preview plugins',
addPluginsViewerCardDesc: 'Register a file-type preview',
addPluginsTabDesc: 'Sidebar pages (tabs) can be extended by plugins. Plugins register through the ctx.betterSidebar service; clicking Install copies the install command — paste it into a terminal where your DSH profile lives and run it.',
addPluginsViewerDesc: 'File previewers can be extended by plugins. Plugins register through the ctx.betterSidebar service; clicking Install copies the install command — paste it into a terminal where your DSH profile lives and run it.',
addPluginsBrowseMore: 'Browse more plugins on GitHub (topic: dsh-better-sidebar)',
addPluginsRecommended: 'Recommended plugins',
addPluginsEmpty: 'No plugins curated yet — publish yours under the GitHub topic',
openPlugin: 'Open',
copyInstall: 'Copy install command',
pluginOfficeDesc: 'Office-suite preview (.docx / .xlsx / .pptx) for the better-sidebar editor, keeping the heavy Office render libraries out of the core bundle',
pluginGitRemotesDesc: 'Git Remotes tab: branch/upstream/ahead-behind, fetch (optional prune), ff-only pull, and push only after an in-tab confirm. Does not replace the built-in Git stage/commit tab, and does not offer force-push or a model auto-push tool',
pluginSentinelDesc: 'Condition-driven agent wakeup: file/process/port/http/command/webhook sensors wake dormant sessions when conditions fire; registers a "Sentinel" tab with the server-wide watch table',
pluginSidebarQaDesc: 'Select-and-ask: Select conversation text → ask in the right-side panel → a dedicated follow-up session (❓追问) in the same workspace; a fast no-thinking model compresses the main context and injects it with the quote, without interrupting the main conversation. Follow-ups nest, continue, and archive',
pluginVideoPreviewDesc: 'Inline video preview (.mp4/.webm/.mov/.mkv/.avi etc.) for the better-sidebar editor, backed by a dedicated /video host route with HTTP Range (206) support — scrubbing works and files are not capped by the 20MB mediaLimit',
}
/** Translate a copy key in the browser's language (zh → zh, else en). */
/**
* The dictionary namespace this plugin owns in the DSH locale registry
* (`'sidebar'` is taken by DSH's own ui-sidebar, hence this distinct name).
*/
export const LOCALE_NS = 'betterSidebar'
/** The DSH locale service attached by the client apply (absent → browser detection). */
let localeService: { getSnapshot(): { active: string } } | undefined
/**
* Attach (or detach, with undefined) the DSH locale service. The sidebar
* mounts its own React root outside the slot system's locale seat, so the
* service rides this module-level holder: components keep calling the plain
* `t()` function, and the Sidebar root's locale subscription re-renders the
* whole tree on switches.
*/
export function attachLocale(service: { getSnapshot(): { active: string } } | undefined): void {
localeService = service
}
/**
* The active locale id ('zh' | 'en'): the DSH locale service's snapshot when
* attached, else the browser language.
*/
function activeLocale(): string {
return localeService?.getSnapshot().active
?? (typeof navigator !== 'undefined' ? navigator.language : '')
?? 'en'
}
/** Translate a copy key in the active locale (zh → zh, else en). */
export type CopyKey = keyof typeof zh
/** Translate a copy key; `{name}` placeholders interpolate from `params`. */
export function t(key: CopyKey, params?: Record<string, string | number>): string {
const lang = typeof navigator !== 'undefined' ? navigator.language : 'en'
const dict = lang.toLowerCase().startsWith('zh') ? zh : en
const dict = activeLocale().toLowerCase().startsWith('zh') ? zh : en
let text = dict[key]
if (params !== undefined) {
for (const [name, value] of Object.entries(params)) {
@@ -313,9 +508,9 @@ export function t(key: CopyKey, params?: Record<string, string | number>): strin
return text
}
/** Whether the browser language is Chinese (used for selectors). */
/** Whether the active locale is Chinese (used for selectors). */
export function isZh(): boolean {
return typeof navigator !== 'undefined' && navigator.language.toLowerCase().startsWith('zh')
return activeLocale().toLowerCase().startsWith('zh')
}
/** Format an ISO 8601 author date relative to now (刚刚 / N 分钟前 / N 小时前 / 昨天 / date). */
-48
View File
@@ -1,48 +0,0 @@
/**
* Office file type dispatch — pure functions mapping file extensions to
* 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
* {@link ../index.ts} (readText) tags every Office file as binary, and the
* /sidebar/file media route serves raw bytes for any extension. This module
* is the client's sole source of truth for "is this previewable here".
*/
/** Extensions rendered with docx-preview (preserved styles/images/tables). */
export const OFFICE_DOCX_EXT = '.docx' as const
/** Extensions rendered with Univer (sheets: data + formulas + formatting). */
export const OFFICE_XLSX_EXT = '.xlsx' as const
/** Extensions rendered with the browser-native PPTX viewer. */
export const OFFICE_PPTX_EXT = '.pptx' as const
/** Extensions that get a download-only affordance (no client-side renderer). */
export const OFFICE_DOWNLOAD_ONLY_EXT = ['.doc', '.xls', '.ppt'] as readonly string[]
/**
* The preview strategy for one path's extension.
* - `'docx'` — render via docx-preview.
* - `'xlsx'` — render via Univer.
* - `'pptx'` — render via pptx-renderer.
* - `'download-only'` — show the binary download button (no preview renderer
* covers legacy OLE .doc/.xls/.ppt on the client).
* - `null` — not an Office file; the caller's existing image / md / text / binary
* pipeline handles it.
*/
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 (with leading dot) (`.docx`,
* `.xlsx`, `.pptx`, `.doc`, `.xls`, `.ppt`, …); `''` when the path has none.
*/
export function officeKindForExt(ext: string): OfficeKind {
if (ext === OFFICE_DOCX_EXT) return 'docx'
if (ext === OFFICE_XLSX_EXT) return 'xlsx'
if (ext === OFFICE_PPTX_EXT) return 'pptx'
if (OFFICE_DOWNLOAD_ONLY_EXT.includes(ext)) return 'download-only'
return null
}
-249
View File
@@ -1,249 +0,0 @@
/**
* Office preview components: {@link DocxView} renders .docx via docx-preview
* (preserved styles/images/tables), {@link XlsxView} renders .xlsx via the
* Univer sheets preset (data + formulas + formatting). Both load the file
* bytes through the existing /sidebar/file media route and own their library
* lifecycle (dispose on unmount so canvases/workers don't leak — mirrors the
* TerminalView dispose discipline).
*
* Errors degrade to the same download-button affordance the binary
* placeholder uses, so a corrupted / encrypted / oversized file always
* leaves the user with a way to get the file.
*
* Both libraries pull in several MB; tsdown's `codeSplitting: false` inlines
* them into the single client bundle (the CJS __ModuleLoader__ wrapper's
* `require` cannot load relative chunk URLs in the browser, so dynamic
* import() collapses to a synchronous inline — see tsdown.config.ts).
*/
import { useEffect, useRef, useState } from 'react'
import { downloadUrl, mediaUrl, type SessionScope } from './api.ts'
import { t } from './locales.ts'
import { xlsxWorkbookToUniver } from './xlsx-to-univer.ts'
import css from './sidebar.module.css'
// Univer's stylesheets ride the same dsh-css-inline pipeline as xterm's CSS
// (one <style data-plugin-css> tag, idempotent). Static import so the styles
// are present before the first .xlsx opens.
import '@univerjs/preset-sheets-core/lib/index.css'
/** Loading / ready / error state shared by both views. */
type LoadState =
| { status: 'loading' }
| { status: 'ready' }
| { status: 'error'; message: string }
/** Shared props. */
interface OfficeViewProps {
scope: SessionScope
path: string
title: string
}
/**
* Render a .docx file via docx-preview. The library renders into a container
* div (no canvas); images and styles are inlined. Unmounting clears the
* container's innerHTML — docx-preview has no dispose API, but tearing down
* the DOM is enough.
*/
export function DocxView(props: OfficeViewProps): JSX.Element {
const { scope, path, title } = props
const viewportRef = useRef<HTMLDivElement>(null)
const wrapRef = useRef<HTMLDivElement>(null)
const [load, setLoad] = useState<LoadState>({ status: 'loading' })
const [zoom, setZoom] = useState(100)
useEffect(() => {
let cancelled = false
const container = viewportRef.current
const wrap = wrapRef.current
if (container === null || wrap === null) return
setZoom(100)
void (async () => {
try {
const response = await fetch(mediaUrl(scope, path))
if (cancelled) return
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const buf = await response.arrayBuffer()
if (cancelled) return
// docx-preview ships its own CSS through the className option; the
// wrapper div scopes its render output.
const { renderAsync } = await import('docx-preview')
await renderAsync(buf, wrap, undefined, {
className: 'docx',
inWrapper: true,
ignoreWidth: false,
ignoreHeight: false,
breakPages: true,
experimental: false,
})
if (!cancelled) setLoad({ status: 'ready' })
} catch (error) {
if (!cancelled) {
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
}
}
})()
return () => {
cancelled = true
// Tear down the rendered DOM so a reopen starts clean.
if (wrap !== null) wrap.innerHTML = ''
}
}, [scope.sessionId, scope.cwd, path])
useEffect(() => {
const viewport = viewportRef.current
if (viewport === null) return
const onWheel = (event: WheelEvent): void => {
if (!event.altKey) return
event.preventDefault()
const delta = event.deltaY < 0 ? 10 : -10
setZoom(current => Math.max(50, Math.min(200, current + delta)))
}
viewport.addEventListener('wheel', onWheel, { passive: false })
return () => { viewport.removeEventListener('wheel', onWheel) }
}, [])
return (
<div className={css.editorDocx}>
<div className={css.editorDocxViewport} ref={viewportRef}>
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
{load.status === 'error' && <BinaryFallback scope={scope} path={path} message={load.message} />}
{load.status !== 'error' && (
<div
className={css.editorDocxWrap}
ref={wrapRef}
aria-label={title}
style={{ zoom: zoom / 100 }}
/>
)}
</div>
<div className={css.editorDocxZoom}>
<span className={css.editorDocxZoomHint}>{t('zoomHint')}</span>
<input
className={css.editorDocxZoomRange}
type="range"
min={50}
max={200}
step={10}
value={zoom}
aria-label={t('zoom')}
onChange={(event) => { setZoom(Number(event.currentTarget.value)) }}
/>
<span className={css.editorDocxZoomValue}>{zoom}%</span>
</div>
</div>
)
}
/**
* Render a .xlsx file via Univer. The sheets preset creates a canvas-based
* spreadsheet (formula bar, sheet tabs, formula engine) sized to its
* container, so the host fills the pane. Unmounting calls `univer.dispose()`
* — without it the canvas, workers, and DOM listeners leak (mirrors the
* xterm dispose discipline in TerminalView).
*/
export function XlsxView(props: OfficeViewProps): JSX.Element {
const { scope, path, title } = props
const hostRef = useRef<HTMLDivElement>(null)
const univerRef = useRef<{ dispose: () => void } | null>(null)
const [load, setLoad] = useState<LoadState>({ status: 'loading' })
useEffect(() => {
let cancelled = false
const host = hostRef.current
if (host === null) return
void (async () => {
try {
const response = await fetch(mediaUrl(scope, path))
if (cancelled) return
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const buf = await response.arrayBuffer()
if (cancelled) return
// Dynamic imports — collapsed into the bundle by codeSplitting:false.
// The dynamic form keeps the source readable: each lib is only pulled
// in when an .xlsx is actually opened (semantically; the bundle still
// contains all of them).
const XLSX = await import('xlsx')
const { createUniver, LocaleType, mergeLocales } = await import('@univerjs/presets')
const { UniverSheetsCorePreset } = await import('@univerjs/preset-sheets-core')
// Locales pick the browser language; falls back to en-US.
const isZh = typeof navigator !== 'undefined' && navigator.language.toLowerCase().startsWith('zh')
const localePack = await (isZh
? import('@univerjs/preset-sheets-core/locales/zh-CN').then(m => m.default).catch(() => null)
: import('@univerjs/preset-sheets-core/locales/en-US').then(m => m.default).catch(() => null))
const wb = XLSX.read(buf, { type: 'array' })
const locale = isZh ? LocaleType.ZH_CN : LocaleType.EN_US
const workbookData = xlsxWorkbookToUniver(wb, '0.25.1', locale)
if (cancelled) return
const { univer, univerAPI } = createUniver({
locale,
locales: localePack !== null ? { [locale]: mergeLocales(localePack) } : {},
presets: [UniverSheetsCorePreset({ container: host })],
})
univerRef.current = univer
univerAPI.createWorkbook(workbookData)
if (!cancelled) setLoad({ status: 'ready' })
} catch (error) {
if (!cancelled) {
try {
univerRef.current?.dispose()
} catch {
// Partially initialized instance — ignore disposal errors.
}
univerRef.current = null
host.innerHTML = ''
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
}
}
})()
return () => {
cancelled = true
// Critical: dispose the Univer instance (canvas + workers + listeners).
try {
univerRef.current?.dispose()
} catch {
// Already torn down — ignore.
}
univerRef.current = null
// Clear the host in case dispose left DOM behind.
if (host !== null) host.innerHTML = ''
}
}, [scope.sessionId, scope.cwd, path])
return (
<div className={css.editorXlsx} aria-label={title}>
{/* Univer exclusively owns this element's descendants. React only owns
the sibling overlay, so Univer cannot remove a React child. */}
<div className={css.editorUniverHost} ref={hostRef} />
{load.status !== 'ready' && (
<div className={css.editorOfficeOverlay}>
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
{load.status === 'error' && <BinaryFallback scope={scope} path={path} message={load.message} />}
</div>
)}
</div>
)
}
/**
* 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 download link.
*/
function BinaryFallback(props: { scope: SessionScope; path: string; message: string }): JSX.Element {
const { scope, path, message } = props
return (
<div className={css.editorBinary}>
<span className={css.editorBinaryNotice}>{message}</span>
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
{t('downloadToView')}
</a>
</div>
)
}
+43
View File
@@ -0,0 +1,43 @@
/**
* Deferred one-shot open for hosts that may not have a real size yet.
*
* xterm's `Terminal.open()` must not run in a zero-size container: the
* renderer creation fails there (the DomRenderer is built from the host's
* dimensions), leaving the render service's renderer `undefined`, and the
* next Viewport refresh crashes reading `.dimensions` off it. WebKit-based
* hosts (WKWebView) reliably report zero while the bottom panel's expand
* slide is in flight; any `display:none`-hidden ancestor does the same.
*
* The caller's `open` callback (open + fit + resize) is invoked exactly
* once, on the first frame where the host reports a real size. While the
* host stays zero-sized the polling continues every frame; it stops when
* the host leaves the document (`isConnected`), so a pending open never
* fires after unmount. The returned cancel function drops a pending frame
* immediately (idempotent).
*
* `raf`/`caf` are injectable so tests can drive the polling deterministically.
*/
export function openWhenSized(
host: HTMLElement,
open: () => void,
raf: (cb: FrameRequestCallback) => number = requestAnimationFrame,
caf: (id: number) => void = cancelAnimationFrame,
): () => void {
let frame: number | null = null
const step = (): void => {
frame = null
if (!host.isConnected) return
if (host.clientWidth > 0 && host.clientHeight > 0) {
open()
return
}
frame = raf(step)
}
frame = raf(step)
return () => {
if (frame !== null) {
caf(frame)
frame = null
}
}
}
+62
View File
@@ -0,0 +1,62 @@
/**
* Interception of the chat's file-open funnel. The client runtime's
* `ctx.workspaces.openPath` is the SINGLE door every chat-side file open goes
* through — ui-conversation's apply.ts resolves the path against the session
* cwd and calls it for tool-row path links, the produced-files row, and
* prose file mentions alike (verified against the DSH source:
* `packages/client/ui-conversation/src/client/apply.ts` is the only
* production caller). Wrapping that one method reroutes those opens into the
* sidebar editor instead of the Host OS — no DSH modification needed.
*
* The wrapper is dependency-free by design (no React / ui-primitives), so
* the takeover logic is unit-testable and the file stays importable from the
* test runtime.
*/
/** The one service method the wrapper replaces (mirror of the runtime IWorkspaces). */
export interface OpenPathService {
openPath(path: string): Promise<void>
}
/** Per-call decisions the wrapper needs (wired to the store + ctx in the client half). */
export interface OpenPathInterceptDeps {
/**
* Whether to take over this call: the `interceptOpenPath` pref AND the
* editor tab's own enable switch must both be on (an editor that cannot
* open must not swallow opens — they fall through to the Host).
*/
takeoverEnabled(): boolean
/** The session whose scope the sidebar editor loads the file in (current session). */
currentSessionId(): string | undefined
/** Route the open into the sidebar editor (the established openSidebarFile). */
openInSidebar(path: string, sessionId: string): void
}
/**
* Wrap `workspaces.openPath`: intercepted calls open the file in the sidebar
* editor instead of the Host OS and resolve as success (the original's
* callers ignore the result); anything that declines falls through to the
* original method untouched.
* @param workspaces - the client workspaces service to wrap.
* @param deps - per-call takeover decisions.
* @returns the disposer restoring the original method (HMR-safe).
*/
export function wrapOpenPath(workspaces: OpenPathService, deps: OpenPathInterceptDeps): () => void {
// The RAW method reference (never a bound copy): restore must put back the
// exact original so a chain of wrappers (other plugins wrapping the same
// method) keeps working across disposals in any order.
const original = workspaces.openPath
workspaces.openPath = (path: string): Promise<void> => {
if (deps.takeoverEnabled()) {
const sessionId = deps.currentSessionId()
if (sessionId !== undefined) {
deps.openInSidebar(path, sessionId)
return Promise.resolve()
}
}
return original.call(workspaces, path)
}
return () => {
workspaces.openPath = original
}
}
+19
View File
@@ -3,8 +3,27 @@
* the session cwd (for the @-reference button and "copy relative path").
* The fs-tree joins with '/' even on Windows, so both separators normalize
* to '/' before comparison.
*
* This module is dependency-free (no node:path in the client bundle): the
* host is the authority for path semantics, so this mirror deliberately
* accepts a SUPERSET of absolute forms — anything a Windows host would emit
* (drive letters, UNC) plus POSIX roots. A form the host would reject
* (e.g. a backslash UNC path on a POSIX host) passes through here and then
* fails loudly in the host's requireAbsolute instead of being silently
* joined onto the cwd.
*/
/**
* Mirror of the host's absolute-path notion (see fs-tree.requireAbsolute):
* POSIX roots, Windows drive letters, and Windows UNC network shares in
* both backslash (`\\server\share\...`) and forward-slash
* (`//server/share/...`) form. Deliberately a superset — see the module
* comment — so a produced UNC path is never joined onto the cwd.
*/
export function isAbsolutePath(path: string): boolean {
return path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path) || /^[\\/]{2}[^\\/]/.test(path)
}
/**
* The path relative to the session's working directory.
* @param cwd - the explorer root (absolute).
+26
View File
@@ -0,0 +1,26 @@
/**
* Shared vocabulary of the recommended plugin catalogs: the entry shape and
* the GitHub topic URL. The two catalogs live in sibling modules —
* `plugins-tabs.ts` (tab registrations) and `plugins-viewers.ts` (file
* previewer registrations) — and are shown in the two "add plugin" modals
* (Side card settings → the dashed cards at the end of the 侧边栏内容 /
* 文件预览 grids).
*/
/** The GitHub topic page listing every repo tagged `dsh-better-sidebar`. */
export const PLUGIN_TOPIC_URL = 'https://github.com/topics/dsh-better-sidebar'
/** One curated plugin entry (name / url / description / install script). */
export interface PluginEntry {
/** Unique id (the npm package name). */
id: string
/** Short display name. */
name: string
/** GitHub repository URL. */
url: string
/** One-line description (i18n friendly: string or () => string). */
description: string | (() => string)
/** The full shell command pre-filled into the install terminal (not
* executed until the user presses Enter). */
install: string
}
+43
View File
@@ -0,0 +1,43 @@
/**
* The built-in catalog of TAB-registration plugins (sidebar pages),
* shown in the "add tab plugin" modal (Side card settings → 侧边栏内容 grid
* → the dashed card). Adding an entry: append one object here (unique
* `id` = npm package name, `url` = GitHub repo, `description` =
* i18n-friendly (add a `pluginXxxDesc` key in locales.ts), `install` = the
* full one-line install script — it starts with `cd ~/.dsh` so the install
* runs with the DSH home as the working directory). Data integrity is
* guarded by `tests/plugin-list.spec.ts`.
*/
import { t } from './locales.ts'
import type { PluginEntry } from './plugins-shared.ts'
/** Tab-registration plugins (alphabetical order). */
export const builtinTabPlugins: readonly PluginEntry[] = [
{
id: '@dsh-external/dsh-sentinel',
name: 'dsh-sentinel 唤醒系统',
url: 'https://github.com/fuhefei/dsh-sentinel',
description: () => t('pluginSentinelDesc'),
// The official one-line bundle-channel install (git source, build
// artifacts committed — no build step needed). The `github:…` form is
// the upstream's documented command, `cd ~/.dsh` keeps the profile
// context consistent with the other entries.
install: 'cd ~/.dsh && dsh plugin --profile web add "github:fuhefei/dsh-sentinel#v0.7.0"',
},
{
id: 'dsh-git-remotes',
name: 'dsh-git-remotes Git 远程',
url: 'https://github.com/yq04/dsh-git-remotes',
description: () => t('pluginGitRemotesDesc'),
install: 'cd ~/.dsh && dsh plugin --profile web add dsh-better-sidebar && dsh plugin --profile web add git+https://github.com/yq04/dsh-git-remotes.git',
},
{
id: 'dsh-sidebar-qa',
name: 'dsh-sidebar-qa 划选追问',
url: 'https://github.com/ChenRuoT/dsh-sidebar-qa',
description: () => t('pluginSidebarQaDesc'),
// dsh-sidebar-qa hard-depends on dsh-better-sidebar (required peer), so
// the install line installs the prerequisite first, then the plugin.
install: 'cd ~/.dsh && dsh plugin --profile web add dsh-better-sidebar && dsh plugin --profile web add git+https://github.com/ChenRuoT/dsh-sidebar-qa.git',
},
]
+30
View File
@@ -0,0 +1,30 @@
/**
* The built-in catalog of FILE-PREVIEWER plugins (file-type previewers),
* shown in the "add preview plugin" modal (Side card settings → 文件预览
* grid → the dashed card). Adding an entry: append one object here (unique
* `id` = npm package name, `url` = GitHub repo, `description` =
* i18n-friendly, `install` = the full shell command pre-filled into the
* install terminal — it starts with `cd ~/.dsh` so the install runs with
* the DSH home as the working directory). Data integrity is guarded by
* `tests/plugin-list.spec.ts`.
*/
import { t } from './locales.ts'
import type { PluginEntry } from './plugins-shared.ts'
/** File-previewer plugins (alphabetical order). */
export const builtinViewerPlugins: readonly PluginEntry[] = [
{
id: '@huanlin/dsh-plugin-better-sidebar-plugin-office',
name: 'Office 预览插件',
url: 'https://github.com/HuanLinOTO/dsh-plugin-better-sidebar-plugin-office',
description: () => t('pluginOfficeDesc'),
install: 'cd ~/.dsh && dsh plugin --profile web add @huanlin/dsh-plugin-better-sidebar-plugin-office',
},
{
id: 'dsh-video-preview',
name: '视频预览插件',
url: 'https://github.com/zemul/dsh-video-preview',
description: () => t('pluginVideoPreviewDesc'),
install: 'cd ~/.dsh && dsh plugin --profile web add dsh-video-preview',
},
]
+60 -1
View File
@@ -10,12 +10,14 @@
*/
import type { api } from './api.ts'
import {
clampTerminalFontSize,
clampTitleBarStrip,
clampWidthPercent,
SIDEBAR_PREFS_DEFAULTS,
type SidebarPrefs,
} from '../prefs-shared.ts'
export { SIDEBAR_PREFS_DEFAULTS, clampWidthPercent }
export { SIDEBAR_PREFS_DEFAULTS, clampTerminalFontSize, clampTitleBarStrip, clampWidthPercent }
export type { SidebarPrefs }
/** The settings wire face the preferences need (a subset of the plugin api). */
@@ -40,14 +42,71 @@ export function parsePrefs(value: unknown): SidebarPrefs {
autoOpenSubagent: typeof record.autoOpenSubagent === 'boolean'
? record.autoOpenSubagent
: SIDEBAR_PREFS_DEFAULTS.autoOpenSubagent,
autoOpenJobs: typeof record.autoOpenJobs === 'boolean'
? record.autoOpenJobs
: SIDEBAR_PREFS_DEFAULTS.autoOpenJobs,
agentTerminalTools: typeof record.agentTerminalTools === 'boolean'
? record.agentTerminalTools
: SIDEBAR_PREFS_DEFAULTS.agentTerminalTools,
bottomPanelAutoTerminal: typeof record.bottomPanelAutoTerminal === 'boolean'
? record.bottomPanelAutoTerminal
: SIDEBAR_PREFS_DEFAULTS.bottomPanelAutoTerminal,
terminalFontFamily: typeof record.terminalFontFamily === 'string'
? record.terminalFontFamily
: SIDEBAR_PREFS_DEFAULTS.terminalFontFamily,
terminalFontSize: typeof record.terminalFontSize === 'number' && Number.isFinite(record.terminalFontSize)
? clampTerminalFontSize(record.terminalFontSize)
: SIDEBAR_PREFS_DEFAULTS.terminalFontSize,
interceptOpenPath: typeof record.interceptOpenPath === 'boolean'
? record.interceptOpenPath
: SIDEBAR_PREFS_DEFAULTS.interceptOpenPath,
titleBarCompat: typeof record.titleBarCompat === 'boolean'
? record.titleBarCompat
: SIDEBAR_PREFS_DEFAULTS.titleBarCompat,
titleBarStripPx: typeof record.titleBarStripPx === 'number' && Number.isFinite(record.titleBarStripPx)
? clampTitleBarStrip(record.titleBarStripPx)
: SIDEBAR_PREFS_DEFAULTS.titleBarStripPx,
htmlViewerNoSandbox: typeof record.htmlViewerNoSandbox === 'boolean'
? record.htmlViewerNoSandbox
: SIDEBAR_PREFS_DEFAULTS.htmlViewerNoSandbox,
htmlViewerDefaultUnsafe: typeof record.htmlViewerDefaultUnsafe === 'boolean'
? record.htmlViewerDefaultUnsafe
: SIDEBAR_PREFS_DEFAULTS.htmlViewerDefaultUnsafe,
browserNoSandbox: typeof record.browserNoSandbox === 'boolean'
? record.browserNoSandbox
: SIDEBAR_PREFS_DEFAULTS.browserNoSandbox,
browserInterceptLinks: typeof record.browserInterceptLinks === 'boolean'
? record.browserInterceptLinks
: SIDEBAR_PREFS_DEFAULTS.browserInterceptLinks,
browserInterceptHttp: typeof record.browserInterceptHttp === 'boolean'
? record.browserInterceptHttp
: SIDEBAR_PREFS_DEFAULTS.browserInterceptHttp,
browserInterceptHttps: typeof record.browserInterceptHttps === 'boolean'
? record.browserInterceptHttps
: SIDEBAR_PREFS_DEFAULTS.browserInterceptHttps,
tabsEnabled: booleanMapOf(record.tabsEnabled),
viewersEnabled: booleanMapOf(record.viewersEnabled),
pluginSettings: pluginSettingsMapOf(record.pluginSettings),
}
}
/**
* Validate the plugin-owned settings map (v0.12.0+): `{ descriptorId: { key:
* value } }`, nested open maps. Any non-object value (or a malformed whole)
* falls back to the empty map — the schema defaults already guard the wire
* shape, this is the client's second line.
*/
function pluginSettingsMapOf(value: unknown): Record<string, Record<string, unknown>> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return {}
const out: Record<string, Record<string, unknown>> = {}
for (const [id, blob] of Object.entries(value as Record<string, unknown>)) {
if (blob !== null && typeof blob === 'object' && !Array.isArray(blob)) {
out[id] = blob as Record<string, unknown>
}
}
return out
}
/**
* 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 /
+7 -3
View File
@@ -6,6 +6,7 @@
* Kept dependency-free so the takeover logic is unit-testable and the
* replica is easy to diff against upstream when it drifts.
*/
import { isAbsolutePath } from './paths.ts'
/** Paths a tool-result view reports as produced, by render intent. */
export function producedPaths(view: unknown): readonly string[] {
@@ -76,10 +77,13 @@ export function selectProducedFiles(owner: unknown): readonly string[] | null {
return paths.length === 0 ? null : paths
}
/** Resolve a (possibly relative) path against the session cwd for the sidebar. */
/**
* Resolve a (possibly relative) path against the session cwd for the sidebar.
* Absolute detection mirrors the host (see client/paths.isAbsolutePath):
* POSIX roots, drive letters and UNC shares must not be joined onto the cwd.
*/
export function resolveSidebarPath(cwd: string | undefined, path: string): string {
const absolute = path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path)
if (absolute) return path
if (isAbsolutePath(path)) return path
const base = cwd ?? ''
if (base === '') return path
const separator = base.includes('\\') ? '\\' : '/'
+86
View File
@@ -0,0 +1,86 @@
/**
* Pure payload builders for the "add selection to conversation" popup in the
* text viewers (markdown preview + the catch-all code viewer). Everything
* here is string math — no React, no ctx — so the unit tests cover it
* directly.
*
* Insert shape (agreed with the product owner):
* - Selection ≤ SELECTION_LIMIT characters: a fenced code block whose info
* line is `相对路径:起止行` and whose body is the selected text.
* - Selection over the limit: a single plain-text line `相对路径:起止行`
* (no fence, no content).
* - The path is relative to the session cwd (the same projection the
* explorer's @ button uses); an unknown cwd falls back to the absolute
* path.
* - Line numbers: single-line selections write `path:12`, multi-line write
* `path:12-15`. The markdown preview cannot map rendered DOM back to
* source lines directly, so it reverse-searches the selected text in the
* source and only reports lines on an unambiguous hit (see
* {@link linesOfSelection}).
*/
import { relativeTo } from './paths.ts'
/** Max inserted selection length (UTF-16 code units, i.e. JS `.length`). */
export const SELECTION_LIMIT = 500
/** The source line span a selection maps to (1-based, inclusive). */
export interface SelectionLines {
start: number
end: number
}
/**
* The fence info line: `rel[:start[-end]]` — lines are omitted entirely
* when unknown (the preview reverse-search missed).
*/
export function headerOf(path: string, cwd: string | undefined, lines?: SelectionLines): string {
const rel = cwd !== undefined ? relativeTo(cwd, path) : path
if (lines === undefined) return rel
if (lines.end > lines.start) return `${rel}:${lines.start}-${lines.end}`
return `${rel}:${lines.start}`
}
/**
* The full text appended to the composer draft for one selection.
* Over the limit the content is dropped: the plain path line is the whole
* payload (an empty fenced block would just occupy the draft).
*/
export function buildSelectionInsert(
path: string,
cwd: string | undefined,
lines: SelectionLines | undefined,
selected: string,
): string {
const header = headerOf(path, cwd, lines)
if (selected.length > SELECTION_LIMIT) return header
return `\`\`\`${header}\n${selected}\n\`\`\``
}
/** 1-based line number of a character index in a text. */
function lineAt(source: string, index: number): number {
let line = 1
for (let i = 0; i < index && i < source.length; i++) {
if (source[i] === '\n') line++
}
return line
}
/**
* Reverse-map a rendered-DOM selection back to source line numbers. The
* preview selection is plain text (block boundaries come out as `\n`), so
* this is a best-effort substring search: a single trailing newline is
* stripped first (DOM block selections tend to carry one), and only an
* EXACTLY-ONE occurrence yields lines — an ambiguous or missing match
* returns null (the header then carries the path without line numbers).
*/
export function linesOfSelection(source: string, selected: string): SelectionLines | null {
const text = selected.endsWith('\n') ? selected.slice(0, -1) : selected
if (text === '') return null
const at = source.indexOf(text)
if (at === -1) return null
if (source.indexOf(text, at + 1) !== -1) return null
return {
start: lineAt(source, at),
end: lineAt(source, at + Math.max(text.length - 1, 0)),
}
}
+443 -36
View File
@@ -22,14 +22,40 @@
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,
activateTab as activateTabReducer, allLeaves, closeTab as closeTabReducer, leafWithTab,
openTabInActivePane, patchTab, tabOpenIn, togglePanel, treeOf,
type SidebarSnapshot, type SidebarState, type SidebarStore, type SidebarTab,
} from './state.ts'
import { isNarrowWidth } from './breakpoints.ts'
import type { SessionScope } from './api.ts'
import type { SidebarPrefs } from '../prefs-shared.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"). */
/**
* Public state vocabulary re-exported for consumers (type-only; the values
* stay internal). External plugins name these types in their descriptors —
* e.g. `dedupeKey: (tab: SidebarTab) => tab.id`, `createTab: (state: SidebarState) => …`,
* or `badge: (…, state: SidebarState) => …`.
*/
export type {
SidebarTab,
SidebarState,
SidebarStore,
SidebarSnapshot,
SidebarDiffRef,
TabType,
} from './state.ts'
export type { SessionScope } from './api.ts'
export type { SidebarPrefs } from '../prefs-shared.ts'
/** The row control a declarative setting renders as in the settings popup. */
export type SidebarSettingToggleType = 'switch' | 'text' | 'number'
/** One declarative setting of a tab/viewer, rendered as a nested row in the
* Side card settings page (e.g. the Subagent page's "auto-open when a
* subagent appears" switch, or the terminal's custom font rows). `type`
* selects the control: 'switch' (default) renders the custom switch,
* 'text' a free-form input committed on blur/Enter, 'number' a numeric
* input clamped to `min`/`max`. */
export interface SidebarSettingToggle {
/** The SidebarPrefs field this toggle reads and writes ('autoOpenSubagent'). */
key: string
@@ -37,17 +63,56 @@ export interface SidebarSettingToggle {
title: string | (() => string)
/** Row description (i18n friendly). */
desc?: string | (() => string)
/** Row control type; defaults to 'switch' (backward compatible). */
type?: SidebarSettingToggleType
/** Lower bound for `type: 'number'` rows (clamped on commit). */
min?: number
/** Upper bound for `type: 'number'` rows (clamped on commit). */
max?: number
/** Input placeholder for `type: 'text'` rows. */
placeholder?: string
/** Unit suffix rendered after the input (e.g. 'px' for a size row). */
unit?: string
}
/** Props of a descriptor's custom settings panel (`settings.render`). */
export interface SidebarSettingsRenderProps {
store: SidebarStore
service: BetterSidebarService
prefs: SidebarPrefs
/** This descriptor's own persisted settings blob (from `pluginSettings[id]`). */
pluginSettings: Record<string, unknown>
/** Persist one plugin-owned setting of this descriptor. */
updatePluginSetting(key: string, value: unknown): void
/** Close the settings popup. */
close(): void
}
/** Declarative settings of one registered tab or file viewer. */
export interface SidebarSettingsDeclaration {
/**
* Extra boolean toggles rendered under the feature's own row in the
* Extra settings rows 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.
* 'agentTerminalTools', 'terminalFontFamily'); unknown keys are dropped
* by the settings seam.
*/
toggles?: readonly SidebarSettingToggle[]
/**
* Plugin-owned settings rows (v0.12.0+): same row controls as `toggles`
* (switch/text/number), but the keys are plugin-local and persisted in
* the sidebar's own prefs document under `pluginSettings[<descriptor id>]`
* — no host PrefsSchema field needed. Values must be JSON-serializable
* (the row controls produce strings / numbers / booleans).
*/
pluginToggles?: readonly SidebarSettingToggle[]
/**
* Custom settings panel (v0.12.0+): when given, the gear popup renders
* this instead of the row lists (`toggles` / `pluginToggles`). Receives
* the shared store/service, the live prefs, the descriptor's own
* `pluginSettings` blob, and a persistence helper.
*/
render?: (props: SidebarSettingsRenderProps) => ReactNode
}
/** Props every tab component receives (builtins and external alike). */
@@ -104,6 +169,22 @@ export interface TabDescriptor {
* When omitted, a default `{ id, type, title }` tab is created.
*/
createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null
/**
* External-link target claim (v0.13.0+): when a GUI external-link click
* is taken over (the `browserInterceptLinks` master AND the URL's
* protocol flag — `browserInterceptHttp` / `browserInterceptHttps` —
* are on), the first registered tab whose `urlTarget(url)` returns true
* is opened with `openTab({ type, url, title: hostname })` — the URL is
* the whole payload (the tab reads it from `tab.path`). Registration
* order wins (first claim first served); a disabled tab type is skipped;
* a throwing predicate is swallowed (console.error, the type is skipped).
* The built-in browser tab declares NO urlTarget — it stays the implicit
* fallback target, so plugins can never be shadowed by it. To host more
* than one URL at a time, mint per-URL ids through `createTab` (the
* browser builtin's pattern); otherwise the id safety net focuses the
* existing tab of the same type and the new URL is not applied.
*/
urlTarget?: (url: URL) => boolean
/**
* Declarative settings shown in the Side card settings page: every
* registered tab gets an enable/disable switch (icon + title + id), and
@@ -111,6 +192,27 @@ export interface TabDescriptor {
* (e.g. the subagent tab's 'autoOpenSubagent').
*/
settings?: SidebarSettingsDeclaration
/**
* Tab-strip badge (v0.12.0+): a small pill rendered on the tab next to
* the icon — a number renders as a count (99+ capped), a string renders
* as-is, null/undefined hides the badge. Called on every tab-bar render,
* so keep it cheap; a throw is swallowed (no badge shown).
*/
badge?: (ctx: Context, scope: SessionScope, state: SidebarState) => string | number | null | undefined
/**
* Lifecycle callbacks (v0.12.0+). Fired by the SERVICE paths only:
* `onOpen` when an open actually creates a tab (a dedupe/id-safety-net
* focus is NOT an open — it fires `onActivate` instead), `onActivate`
* when a tab is focused (dedupe focus, id-safety-net focus, or the
* tab-bar activation), `onClose` when a tab is closed through
* `closeTab`. Builtin-only flows that mutate state directly (the diff
* split placement, agent-terminal reconcile) never touch external tabs
* and fire no callbacks. A throwing callback is logged and never breaks
* the open/close/activate flow.
*/
onOpen?: (tab: SidebarTab, scope: SessionScope) => void
onActivate?: (tab: SidebarTab, scope: SessionScope) => void
onClose?: (tab: SidebarTab, scope: SessionScope) => void
component: (props: TabComponentProps) => ReactNode
}
@@ -158,8 +260,9 @@ export interface FileViewerDescriptor {
* 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>
/** fetchStrategy='custom' loader. `signal` (v0.12.0+) aborts on viewer
* teardown / re-match; loaders that ignore it keep working. */
load?: (path: string, scope: SessionScope, signal?: AbortSignal) => Promise<unknown>
/**
* Declarative settings shown in the Side card settings page: every
* registered viewer gets an enable/disable switch (icon + title + exts).
@@ -168,7 +271,26 @@ export interface FileViewerDescriptor {
component: (props: FileViewerProps) => ReactNode
}
/** The registry service published as `ctx.betterSidebar`. */
/** One `openTab` request. */
export interface OpenTabSeed {
type: string
/** Overrides the descriptor's title when given (the editor tab shows the file name). */
title?: string
/** A file path (the editor tab's content seed). */
path?: string
/** A diff reference (the diff tab's content seed). */
diff?: SidebarTab['diff']
/** Explicit tab id (defaults to the type). */
id?: string
/** A URL the tab navigates to on mount (the browser tab's seed). */
url?: string
/** JSON-serializable custom state carried on the minted tab (persisted across reloads; v0.12.0+). */
meta?: unknown
}
/**
* The registry service published as `ctx.betterSidebar`.
*/
export interface BetterSidebarService {
registerTab(descriptor: TabDescriptor): () => void
registerFileViewer(descriptor: FileViewerDescriptor): () => void
@@ -194,13 +316,63 @@ export interface BetterSidebarService {
* Open a tab (used by external tabs and the + menu). `title` overrides
* the descriptor's title when given (the editor tab shows the file name);
* when the descriptor provides `createTab` it mints the tab itself and
* `title`/`path`/`id` are ignored. A disabled tab type is a no-op.
* `title`/`path`/`id` are ignored. `url` lands the tab with its `path`
* pre-set to the URL (the browser tab's navigation seed; the caller
* usually pairs it with a hostname `title`). A disabled tab type is a
* no-op.
*
* `scope` (v0.12.0+) targets a specific session: when given, the open
* lands in THAT session's sidebar state (loading it if it has none yet)
* without switching the UI's active session; when absent the open lands
* in the currently active session (the pre-0.12 behavior).
*
* A CONTENT open (a `path` or `url` seed) must land in sight: when the
* panel hosting the landing pane is collapsed, it is expanded
* automatically (the right panel by default, the bottom panel when the
* active pane lives there; on narrow viewports the merged drawer opens).
* Type-only opens (the + menu, agent-terminal auto-tabs) never expand —
* the panel behavior is their caller's business.
*
* Note: `available` gates the + menu's disabled state only — it does NOT
* refuse `openTab` (only the settings disable switch does).
*/
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void
/** Close a tab by id. */
closeTab(tabId: string): void
openTab(seed: OpenTabSeed, scope?: SessionScope): void
/**
* Close a tab by id (fires descriptor.onClose). An unknown tab id is a
* strict no-op (no state churn, no callbacks). `scope` (v0.12.0+) rides
* to the callback (its optional cwd included); absent, the callback gets
* `{ sessionId }` of the active session.
*/
closeTab(tabId: string, scope?: SessionScope): void
/** Subscribe to registry changes (register/dispose). */
subscribe(listener: () => void): () => void
/** The plugin version this service instance was built from ('0.12.0'). */
readonly version: string
/**
* Monotonic capability list (v0.12.0+): 'badge' | 'tabLifecycle' |
* 'updateTab' | 'openFile' | 'targetedOpen' | 'stateSubscription' |
* 'tabMeta' | 'pluginSettings'. Features are never removed — consumers
* gate new API usage on membership.
*/
readonly features: readonly string[]
/**
* The current sidebar snapshot: the active session id, its state (panel
* geometry, open tabs, expansions), and the side card prefs (v0.12.0+).
* `state`/`sessionId` are undefined until a session becomes active.
*/
getSnapshot(): SidebarSnapshot
/** Subscribe to snapshot changes (session switch, state changes, prefs changes). Returns the disposer. */
subscribeState(listener: () => void): () => void
/** Update an open tab's display fields (title / path / meta); a missing tab id is a no-op. */
updateTab(tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void
/**
* Activate an open tab (the tab-bar activation path; fires
* descriptor.onActivate). An unknown tab id is a strict no-op. `scope`
* (v0.12.0+) rides to the callback like `closeTab`'s.
*/
activateTab(tabId: string, scope?: SessionScope): void
/** Open a file in the sidebar editor of `scope`'s session (title defaults to the file name). */
openFile(scope: SessionScope, path: string, title?: string): void
}
/** Extract the lowercase extension without leading dot from a path. */
@@ -211,6 +383,79 @@ function extOfPath(path: string): string {
return base.includes('/') || base.includes('\\') ? '' : base
}
/** The file name of a path (both separators). */
function baseNameOf(path: string): string {
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
return at === -1 ? path : path.slice(at + 1)
}
/**
* Find the tab type that claims an intercepted external-link URL (v0.13.0+).
* Walks the descriptors in REGISTRATION order and returns the first one
* that declares `urlTarget` and matches `url`; a throwing predicate is
* swallowed (console.error, type skipped) so one broken plugin can never
* break the whole link pipeline. The caller passes the ENABLED tab
* descriptors (enablement is the caller's prefs domain — filter
* `service.getTabs()` through `tabsEnabled` before matching) and falls
* back to the built-in browser tab when nothing claims the URL (the
* browser never declares `urlTarget` itself, so it can never shadow a
* plugin claim).
*/
export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDescriptor | undefined {
for (const tab of tabs) {
if (tab.urlTarget === undefined) continue
let claimed = false
try {
claimed = tab.urlTarget(url) === true
} catch (error) {
console.error('[dsh-better-sidebar] urlTarget error:', error)
continue
}
if (claimed) return tab
}
return undefined
}
/**
* The plugin version this service instance reports. Keep in lockstep with
* `package.json`'s version — `tests/service.spec.ts` asserts the pair.
*/
export const SIDEBAR_SERVICE_VERSION = '0.12.3'
/**
* Monotonic capability list consumers use to gate new API usage (features
* are never removed). Each string names a v0.12.0+ capability:
* - 'badge': TabDescriptor.badge
* - 'tabLifecycle': TabDescriptor.onOpen/onActivate/onClose
* - 'updateTab': BetterSidebarService.updateTab
* - 'openFile': BetterSidebarService.openFile
* - 'targetedOpen': BetterSidebarService.openTab(seed, scope?)
* - 'stateSubscription': getSnapshot/subscribeState
* - 'tabMeta': SidebarTab.meta (seeds, createTab, updateTab, persistence)
* - 'pluginSettings': SidebarSettingsDeclaration.pluginToggles/render
* - 'urlTarget' (v0.13.0): TabDescriptor.urlTarget (external-link claims)
*/
export const SIDEBAR_FEATURES = [
'badge',
'tabLifecycle',
'updateTab',
'openFile',
'targetedOpen',
'stateSubscription',
'tabMeta',
'pluginSettings',
'urlTarget',
] as const
/** Run one plugin callback; a throw is logged and never breaks the caller. */
function safeCall(fn: () => void): void {
try {
fn()
} catch (error) {
console.error('[dsh-better-sidebar] plugin callback error:', error)
}
}
/**
* Create one BetterSidebar service bound to a store. The service owns the
* tab/viewer registries (Map + listener set) and proxies openTab/closeTab
@@ -297,7 +542,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
return undefined
}
const openTab = (seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void => {
const openTab = (seed: OpenTabSeed, scope?: SessionScope): 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.
@@ -305,35 +550,188 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
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
const descriptor = tabs.get(seed.type)
if (descriptor === undefined) return
// A scope targets another session: the open lands in THAT session's
// state (loaded on demand) without switching the UI's active session.
const targetSessionId = scope?.sessionId ?? store.getSnapshot().sessionId
if (targetSessionId === undefined) return
const callbackScope: SessionScope = scope ?? { sessionId: targetSessionId }
// Whether this open targets a session that is NOT the one on screen: a
// targeted open must not auto-expand panels the user cannot see (the
// expansion is about landing "in sight" for the CURRENT viewer).
const activeSessionId = store.getSnapshot().sessionId
const targetsInactiveSession = scope !== undefined && scope.sessionId !== activeSessionId
// Lifecycle capture: `created` when the open minted a NEW tab (a
// dedupe/id-safety-net focus is an ACTIVATION, not an open).
let created: SidebarTab | undefined
let activated: SidebarTab | undefined
const reducer = (state: SidebarState): SidebarState => {
// Let the descriptor mint the tab (terminal's nextTerminal bump, etc.).
let tab: SidebarTab
let next: SidebarState
if (descriptor.createTab !== undefined) {
const result = descriptor.createTab(state)
if (result === null) return state
const next = applyDedupe(state, result.tab, descriptor)
return result.patch !== undefined ? { ...next, ...result.patch } : next
tab = result.tab
next = applyDedupe(state, result.tab, descriptor)
if (result.patch !== undefined) next = { ...next, ...result.patch }
} else {
tab = {
id: seed.id ?? seed.type,
type: seed.type,
// A caller-provided title wins (the editor shows the file name);
// otherwise the descriptor's (possibly i18n) title is the default.
title: seed.title ?? (typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title),
...(seed.path !== undefined ? { path: seed.path } : {}),
...(seed.diff !== undefined ? { diff: seed.diff } : {}),
...(seed.meta !== undefined ? { meta: seed.meta } : {}),
}
next = applyDedupe(state, tab, descriptor)
}
const tab: SidebarTab = {
id: seed.id ?? seed.type,
type: seed.type,
// A caller-provided title wins (the editor shows the file name);
// otherwise the descriptor's (possibly i18n) title is the default.
title: seed.title ?? (typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title),
...(seed.path !== undefined ? { path: seed.path } : {}),
...(seed.diff !== undefined ? { diff: seed.diff } : {}),
// Classify the landing against the INPUT state FIRST: a FOCUS fires
// onActivate with the tab that is active NOW; a real creation fires
// onOpen with the minted tab. Both the dedupeKey match AND the id
// match count as a focus — a descriptor deduping by key (e.g. editor
// by path) can focus an existing tab for a NEW requested id, and
// classifying that as a creation would fire onOpen with a phantom
// tab that never closes.
const dedupeKey = descriptor.dedupeKey ?? (descriptor.single === true ? () => descriptor.id : undefined)
const key = dedupeKey?.(tab)
const inputTabs = allLeaves(state.splits).concat(allLeaves(state.bottomSplits)).flatMap(leaf => leaf.tabs)
const existedByKey = key !== undefined
&& inputTabs.some(candidate => candidate.type === tab.type && dedupeKey!(candidate) === key)
const existedById = tabOpenIn(state, tab.id)
const isCreation = !existedByKey && !existedById
// A URL seed pre-fills a NEWLY CREATED tab's path (the browser tab
// navigates to it on mount); a FOCUS must never have its path
// overwritten. An explicit seed.title still wins over a createTab-
// minted default title (e.g. the sidebar-browser's hostname title).
let landed: SidebarState = next
if (seed.url !== undefined && isCreation) {
landed = patchTab(next, tab.id, {
path: seed.url,
...(seed.title !== undefined ? { title: seed.title } : {}),
})
}
return applyDedupe(state, tab, descriptor)
})
// Lifecycle capture (before the auto-expand block, which early-returns).
if (isCreation) {
// Resolve the ACTUAL landed tab — the url patch mints a new object,
// so the callback must see the tab that was really inserted.
const landedTabs = allLeaves(landed.splits).concat(allLeaves(landed.bottomSplits)).flatMap(leaf => leaf.tabs)
created = landedTabs.find(candidate => candidate.id === tab.id) ?? tab
} else {
// A focus happened: resolve the tab that is actually active now and
// report THAT to onActivate (never the caller's un-inserted seed).
const candidates = allLeaves(landed.splits).concat(allLeaves(landed.bottomSplits)).flatMap(leaf => leaf.tabs)
activated = key !== undefined
? candidates.find(candidate => candidate.type === tab.type && dedupeKey!(candidate) === key)
: candidates.find(candidate => candidate.id === tab.id)
activated ??= tab
}
// A CONTENT open (file / browser) must land in sight: when the panel
// hosting the landing pane is collapsed, expand it. On narrow
// viewports the two workbenches merge into one drawer, so the drawer
// (panelOpen) is the only lever; on wide viewports the landing pane's
// own panel opens — the bottom panel when the active pane lives in the
// bottom tree, else the right panel. Type-only opens (+ menu,
// agent-terminal auto-tabs) never expand (the panel behavior is their
// caller's business). The check runs on the post-dedupe state, so a
// content open that merely FOCUSES an existing tab expands the panel
// too — the open must never land out of sight. Opens targeted at an
// INACTIVE session never expand (nothing is in sight for the user).
if (
!targetsInactiveSession
&& typeof window !== 'undefined'
&& (seed.path !== undefined || seed.url !== undefined)
) {
if (isNarrowWidth(window.innerWidth)) {
if (!landed.panelOpen) return togglePanel(landed)
} else {
const hostKey = treeOf(landed, landed.activePane ?? '')
if (hostKey === 'bottomSplits') {
if (!landed.bottomOpen) return { ...landed, bottomOpen: true }
} else if (!landed.panelOpen) {
return togglePanel(landed)
}
}
}
return landed
}
// A scope targeting ANOTHER session lands the open there without
// switching the UI; a scope naming the active session (or no scope)
// takes the regular reduce path so the UI notifies and re-renders.
if (targetsInactiveSession) {
store.reduceFor(scope.sessionId, reducer)
} else {
store.reduce(reducer)
}
if (created !== undefined) safeCall(() => descriptor.onOpen?.(created!, callbackScope))
else if (activated !== undefined) safeCall(() => descriptor.onActivate?.(activated!, callbackScope))
}
const closeTab = (tabId: string): void => {
const closeTab = (tabId: string, scope?: SessionScope): void => {
let closed: SidebarTab | undefined
store.reduce((state) => {
// Unknown tab ids are a strict no-op: no state churn, no notify, no
// pointless localStorage rewrite (mirrors updateTab's short-circuit).
if (!tabOpenIn(state, tabId)) return state
const paneId = findPaneIdOf(state, tabId)
if (paneId === '') return state
const leaf = leafWithTab(state[treeOf(state, paneId)], tabId)
closed = leaf?.tabs.find(tab => tab.id === tabId)
return closeTabReducer(state, paneId, tabId)
})
if (closed !== undefined) {
const sessionId = scope?.sessionId ?? store.getSnapshot().sessionId
if (sessionId !== undefined) {
const descriptor = tabs.get(closed.type)
// An explicit scope (with its optional cwd) rides to the callback.
safeCall(() => descriptor?.onClose?.(closed!, scope ?? { sessionId }))
}
}
}
/** The snapshot the store publishes (state/prefs carry the active session). */
const getSnapshot = (): SidebarSnapshot => store.getSnapshot()
/** Store changes: session switch, state mutations, prefs writes. */
const subscribeState = (listener: () => void): (() => void) => store.subscribe(listener)
/** Patch an open tab's display fields (a missing tab id is a no-op). */
const updateTab = (tabId: string, patch: { title?: string; path?: string; meta?: unknown }): void => {
store.reduce((state) => patchTab(state, tabId, {
...(patch.title !== undefined ? { title: patch.title } : {}),
...(patch.path !== undefined ? { path: patch.path } : {}),
...(patch.meta !== undefined ? { meta: patch.meta } : {}),
}))
}
/** Activate an open tab (the tab-bar activation path; fires onActivate). */
const activateTab = (tabId: string, scope?: SessionScope): void => {
let activated: SidebarTab | undefined
store.reduce((state) => {
// Unknown tab ids are a strict no-op (no state churn / notify).
if (!tabOpenIn(state, tabId)) return state
const paneId = findPaneIdOf(state, tabId)
const leaf = leafWithTab(state[treeOf(state, paneId)], tabId)
activated = leaf?.tabs.find(tab => tab.id === tabId)
return activateTabReducer(state, paneId, tabId)
})
if (activated !== undefined) {
const sessionId = scope?.sessionId ?? store.getSnapshot().sessionId
if (sessionId !== undefined) {
const descriptor = tabs.get(activated.type)
// An explicit scope (with its optional cwd) rides to the callback.
safeCall(() => descriptor?.onActivate?.(activated!, scope ?? { sessionId }))
}
}
}
/** Open a file in the sidebar editor of `scope`'s session (title defaults
* to the file name; the tab id is path-derived, like the internal
* open-path interception, so distinct files open side by side). */
const openFile = (scope: SessionScope, path: string, title?: string): void => {
openTab({ type: 'editor', title: title ?? baseNameOf(path), path, id: `editor:${path}` }, scope)
}
return {
@@ -348,6 +746,13 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
openTab,
closeTab,
subscribe,
version: SIDEBAR_SERVICE_VERSION,
features: SIDEBAR_FEATURES,
getSnapshot,
subscribeState,
updateTab,
activateTab,
openFile,
}
}
@@ -362,17 +767,19 @@ function applyDedupe(state: SidebarState, tab: SidebarTab, descriptor: TabDescri
const dedupeKey = descriptor.dedupeKey ?? (descriptor.single === true ? () => descriptor.id : undefined)
const key = dedupeKey?.(tab)
if (key !== undefined) {
for (const leaf of allLeaves(state.splits)) {
// The scan covers BOTH trees: opening a single-instance tab from the
// bottom panel focuses an existing instance wherever it lives.
for (const leaf of allLeaves(state.splits).concat(allLeaves(state.bottomSplits))) {
const existing = leaf.tabs.find(t => t.type === tab.type && dedupeKey!(t) === key)
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
if (existing !== undefined) return activateTabReducer(state, leaf.id, existing.id)
}
}
return openTabInActivePane(state, tab)
}
/** Find which pane hosts a tab id ('' if none). */
/** Find which pane hosts a tab id ('' if none). Either tree is searched. */
function findPaneIdOf(state: SidebarState, tabId: string): string {
for (const leaf of allLeaves(state.splits)) {
for (const leaf of allLeaves(state.splits).concat(allLeaves(state.bottomSplits))) {
if (leaf.tabs.some(t => t.id === tabId)) return leaf.id
}
return state.activePane ?? ''
+45
View File
@@ -0,0 +1,45 @@
/**
* Mark this plugin's row in the DSH settings navigation so its bundled CSS
* can replace the shell's fallback gear with the Side card glyph.
*
* DSH 0.1.x projects only `id`, `order`, and `label` from a
* `settings.section` registration, then chooses icons inside the settings
* shell from a closed list of built-in ids. Until that public contract grows
* an icon field, the plugin identifies only its own localized row after the
* dialog mounts. The marker owns no shell structure and is removed on fiber
* disposal, so the adaptation remains HMR-safe.
*/
export const SETTINGS_NAV_MARKER = 'data-dsh-better-sidebar-settings-nav'
/**
* Keep the marker on the settings-nav button whose visible text is this
* plugin's current localized section label.
* @param label - locale-aware label resolver used by the section registration.
* @returns disposer that disconnects observation and removes owned markers.
*/
export function registerSettingsNavIcon(label: () => string): () => void {
let disposed = false
const sync = (): void => {
if (disposed) return
const currentLabel = label().trim()
const buttons = document.querySelectorAll<HTMLButtonElement>('[role="dialog"] nav button')
for (const button of buttons) {
const matches = currentLabel.length > 0 && button.textContent?.trim() === currentLabel
if (matches) button.setAttribute(SETTINGS_NAV_MARKER, '')
else button.removeAttribute(SETTINGS_NAV_MARKER)
}
}
sync()
const observer = new MutationObserver(sync)
observer.observe(document.body, { childList: true, subtree: true, characterData: true })
return () => {
disposed = true
observer.disconnect()
document.querySelectorAll(`[${SETTINGS_NAV_MARKER}]`)
.forEach(element => { element.removeAttribute(SETTINGS_NAV_MARKER) })
}
}
+537 -184
View File
@@ -10,27 +10,52 @@
/* ── Shell ─────────────────────────────────────────────────────────────── */
.toggleRail {
/* Skinning contract (token-driven): every surface here consumes DSH's
generic tokens (`--dsw-alias-*`), NEVER `--dsw-specific-sidebar-fill` —
skin systems override that token to restyle the APP's own left-nav column
(dsh-web-ui skins make it translucent glass or theme colors, Aqua makes it
transparent), and a panel consuming it would lose its fill or clash with
the label tokens. The panels use the generic card surface
`--dsw-alias-bg-layer-1`, which every dsh-web-ui skin overrides — so
switching skins in the dsh-web-ui skin center re-skins the sidebar panels
automatically (see AGENTS.md §8). */
/* The persistent expand/collapse cluster at the top-right corner: two rail
controls side by side (bottom panel's glyph LEFT of the right panel's).
Always pinned to the viewport corner — inside the right panel's top-right
while it is open, sitting flush in the 34px tab strip (28px buttons at
top: 3) whose right end it really squeezes. z-index 45: above the panels
(40) but below the app's overlay stack (menus/tooltips/modals sit at
100+), so a cordis approval popup is never hidden behind the workbench
(issue #52). */
.toggleCluster {
position: fixed;
right: 0;
top: 50%;
transform: translateY(-50%);
z-index: 55;
top: 3px;
right: 10px;
z-index: 45;
display: flex;
align-items: center;
/* Native rail inset: the 36px control sits 10px off the column edge. */
padding: 0 10px;
flex-direction: row;
gap: 4px;
}
/* The closed-state affordance is the native rail icon control: a plain 36px
circle, transparent fill, secondary ink; hover raises the fill. No border,
no shadow — the icon alone is the affordance (SidebarRoot .iconButton). */
/* The tab strip of the OPEN right panel reserves the cluster's width at its
right end, so the tabs and the + menu genuinely yield space to the cluster
(never hiding under it). */
.panel:not(.panelHidden) .tabBar {
padding-right: 72px;
}
/* The closed-state affordance is the native rail icon control: a plain
28px circle (the app's icon-button size — the cluster must stay inside
the 34px tab-strip band so it never covers the pane headers below),
transparent fill, secondary ink; hover raises the fill. No border, no
shadow — the icon alone is the affordance. */
.toggleButton {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
width: 28px;
height: 28px;
border: none;
border-radius: 50%;
background: transparent;
@@ -55,13 +80,21 @@
position: fixed;
top: 0;
right: 0;
/* Full height: the bottom panel squeezes only the center column, so the
right panel never gives up any vertical position. */
bottom: 0;
z-index: 50;
/* z-index 40: above the app's in-flow content, below the app's overlay
stack (100+), so menus/tooltips/modals always win over the panels
(issue #52). The toggle cluster (45) sits above it; the corner handle
is a child and stacks within the panel. */
z-index: 40;
display: flex;
flex-direction: column;
/* The native sidebar surface: bluish-50 over white / bluish-900 over dark,
exactly like the app's own sidebar column (SidebarRoot .root). */
background: var(--dsw-specific-sidebar-fill);
/* The generic card surface (see the root contract above). Never
`--dsw-specific-sidebar-fill` — skin plugins restyle that token for the
app's own left nav and a panel consuming it loses its fill or clashes
with labels (#60/#105/#106). */
background: var(--dsw-alias-bg-layer-1);
border-left: 1px solid var(--dsw-alias-border-l2);
/* Expand/collapse slides the panel; fullscreen animates the width. */
transition:
@@ -87,6 +120,8 @@
.panelResize {
position: absolute;
/* Centered on the panel edge (an 8px hit strip straddling the border, the
same pattern as the host's own frame handles). */
left: -4px;
top: 0;
bottom: 0;
@@ -100,25 +135,6 @@
background: var(--dsw-alias-interactive-bg-hover-accent);
}
.panelHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
height: 36px;
padding: 0 8px 0 12px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
flex: none;
}
.panelTitle {
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.panelBody {
flex: 1;
min-height: 0;
@@ -126,6 +142,143 @@
display: flex;
}
/* ── Bottom panel ──────────────────────────────────────────────────────── */
/* The bottom panel: an independent workbench at the bottom of the CENTER
column only (the Sidebar shell writes left/right inline: from the app's
own left sidebar to the right panel's left edge) — the sidebars never
give up any position. Same surface as the right panel, with a top
hairline instead of the left one. z-index 40 mirrors the right panel
(issue #52: below the app's overlay stack so popups win over the
workbench). */
.bottomPanel {
position: fixed;
bottom: 0;
z-index: 40;
display: flex;
flex-direction: column;
background: var(--dsw-alias-bg-layer-1);
border-top: 1px solid var(--dsw-alias-border-l2);
/* Expand/collapse slides the panel; height drags animate the height. */
transition:
transform var(--ds-transition-duration-slow) var(--ds-ease-in-out),
height var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
/* Collapsed: slid off-screen, interactive nothing, hidden after the slide. */
.bottomPanelHidden {
transform: translateY(102%);
pointer-events: none;
visibility: hidden;
transition:
transform var(--ds-transition-duration-slow) var(--ds-ease-in-out),
height var(--ds-transition-duration-slow) var(--ds-ease-in-out),
visibility 0s linear var(--ds-transition-duration-slow);
}
/* Height drags write at pointer cadence; easing would detach the edge. */
.bottomPanel[data-dragging] {
transition: none;
}
/* The bottom panel's height drag: the top edge strip (mirror of the right
panel's width strip), centered on the border like the width strip. */
.bottomResize {
position: absolute;
left: 0;
right: 0;
top: -4px;
height: 8px;
cursor: row-resize;
z-index: 2;
touch-action: none;
}
.bottomResizeActive {
background: var(--dsw-alias-interactive-bg-hover-accent);
}
/* The bottom panel's close control: the app's icon-button pattern (28px
circle), pinned to the tab strip's right end — one tap collapses the
panel. The tab strips below reserve its width so the + menu never hides
under it. */
.bottomClose {
position: absolute;
top: 3px;
right: 6px;
z-index: 4;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
flex: none;
}
.bottomClose:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
/* The bottom panel's tab strips reserve the close control's width at their
right end, so the + menu never hides under it. */
.bottomPanel .tabBar {
padding-right: 40px;
}
/* ── Position compatibility mode (Windows native title bar) ────────────── */
/* Windows frameless / hidden-title-bar windows draw the native caption
strip (minimize / maximize / close) at the window's top-right corner,
OVER the web content. When the user enables the "position compatibility"
pref, the sidebar yields the strip: the toggle cluster drops below it and
the right panel's content starts below it — the panel's surface still
spans the full height, so the caption buttons float over empty sidebar
fill (the VSCode hidden-title-bar look). The strip height is user-tunable
(`--dsh-title-bar-strip`, written by the Sidebar shell from
`titleBarStripPx`; 40px fallback). The bottom panel is unaffected: it
sits at the window's bottom, never under the top strip. */
:global(body[data-dsh-title-bar-compat]) .toggleCluster {
top: calc(var(--dsh-title-bar-strip, 40px) + 3px);
}
:global(body[data-dsh-title-bar-compat]) .panel {
/* The caption strip is drawn over the panel's own top edge: keep the
surface full-height and push the content (the tab strip) below the
strip instead. .panelResize is absolutely positioned and unaffected. */
padding-top: var(--dsh-title-bar-strip, 40px);
}
/* The shared corner (both panels open): the intersection of the right
panel's left edge and the bottom panel's top edge. Horizontal drags
resize the width, vertical drags the height — the two panels drag
against each other. Rendered INSIDE the right panel and positioned
relative to it (left edge + the bottom panel's height from the layout
variable), so no JS-written viewport coordinates are involved. The
z-index is panel-local (above the panel's content; the panel itself sits
at 40, below the app's overlay stack). */
.cornerHandle {
position: absolute;
left: -6px;
bottom: calc(var(--dsh-sidebar-height, 0px) + 6px);
width: 12px;
height: 12px;
z-index: 2;
cursor: nwse-resize;
touch-action: none;
}
.cornerHandle:hover,
.cornerHandle[data-dragging] {
background: var(--dsw-alias-interactive-bg-hover-accent);
}
/* Icon button: the app's own icon-button pattern (ui-workspace) — a 28px
circle, secondary label, hover fill; no border so the icon alone is the
affordance. */
@@ -317,28 +470,35 @@
display: none;
}
/* An empty pane's welcome cards: a responsive grid of compact cards — as
many per row as the pane width allows; rows that cannot fit the pane
height are clipped (never scrolled or squeezed smaller). */
.paneEmptyCards {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
justify-content: center;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
align-content: start;
gap: 8px;
padding: 16px;
padding: 12px;
overflow: hidden;
}
.paneCard {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 10px 12px;
justify-content: center;
gap: 6px;
min-width: 0;
padding: 12px 8px;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 8px;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xxs-strong-12);
cursor: pointer;
text-align: left;
text-align: center;
}
.paneCard:hover:not(:disabled) {
@@ -414,6 +574,21 @@
white-space: nowrap;
}
/* One tab's descriptor badge (a count capped at 99+, or a short text). */
.tabBadge {
flex: none;
min-width: 16px;
height: 15px;
padding: 0 4px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
font: var(--dsw-font-xxxs-strong-11);
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-brand-primary);
}
.tabClose {
display: inline-flex;
align-items: center;
@@ -528,6 +703,18 @@
opacity: 0.45;
}
/* The link badge on symlink rows (folder and file rows alike). */
.explorerSymlink {
flex: none;
color: var(--dsw-alias-label-tertiary);
}
/* A symlink whose target is missing: error ink on the name (the row stays
clickable — the editor surfaces the read failure). */
.explorerBroken .explorerName {
color: var(--dsw-alias-state-error-primary);
}
.explorerName {
overflow: hidden;
text-overflow: ellipsis;
@@ -649,7 +836,7 @@
overflow-wrap: anywhere;
}
/* Binary / download-only files (pptx, OLE .doc/.xls/.ppt, unknown binaries):
/* Binary / download-only files (legacy OLE .doc/.xls/.ppt, unknown binaries):
a centered notice plus a download link — the user always gets the file. */
.editorBinary {
flex: 1;
@@ -703,6 +890,186 @@
background: var(--dsw-alias-state-warn-tertiary);
}
/* Live sandbox status row (HTML preview / browser tab): GREEN while the
sandbox is on (with the one-tap temporary unlock), RED while it is off
(global setting or temporary unlock) — the red state is the persistent
warning that the page runs on the GUI's own origin. */
.sandboxStatus {
flex: none;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 10px;
font: var(--dsw-font-xxxs-11);
}
.sandboxStatusOn {
color: var(--dsw-alias-label-secondary);
background: var(--dsw-alias-bg-layer-1);
border-bottom: 1px solid var(--dsw-alias-border-l1);
}
.sandboxStatusOff {
color: var(--dsw-alias-state-error-primary);
background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);
border-bottom: 1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent);
}
.sandboxDot {
flex: none;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--dsw-alias-state-success-primary);
}
.sandboxStatusOff .sandboxDot {
background: var(--dsw-alias-state-error-primary);
}
.sandboxStatusText {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sandboxAction {
flex: none;
padding: 2px 8px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 6px;
background: transparent;
font: inherit;
color: inherit;
cursor: pointer;
}
.sandboxAction:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
/* The HTML preview iframe: fills the editor body. Route-src (never srcdoc)
so the frame is cross-origin by construction even if the sandbox
attribute is ever dropped; sandboxed by default (see TextEditor). */
.editorHtml {
flex: 1;
min-height: 0;
width: 100%;
border: none;
background: var(--dsw-alias-bg-base);
}
/* ── Browser tab ──────────────────────────────────────────────────────── */
.browser {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.browserBar {
flex: none;
display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
}
.browserInput {
flex: 1;
min-width: 0;
height: 28px;
padding: 0 10px;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 6px;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxs-12);
}
.browserInput:focus {
outline: none;
border-color: var(--dsw-alias-border-l2);
}
.browserMessage {
flex: none;
padding: 4px 12px;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-state-warn-label);
background: var(--dsw-alias-state-warn-tertiary);
}
.browserFrame {
flex: 1;
min-height: 0;
width: 100%;
border: none;
background: var(--dsw-alias-bg-base);
}
.browserStart {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
text-align: center;
font: var(--dsw-font-xs-13);
color: var(--dsw-alias-label-tertiary);
}
/* Embed-refusal panel (X-Frame-Options / frame-ancestors): replaces the
blank refused iframe with the reason + open-in-browser actions. */
.browserBlocked {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 24px;
text-align: center;
color: var(--dsw-alias-state-warn-primary);
}
.browserBlockedTitle {
font: var(--dsw-font-xxs-strong-12);
color: var(--dsw-alias-label-primary);
}
.browserBlockedDesc {
max-width: 280px;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-secondary);
}
.browserBlockedActions {
display: flex;
gap: 8px;
margin-top: 6px;
}
.browserBlockedButton {
padding: 4px 12px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 6px;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxxs-11);
cursor: pointer;
}
.browserBlockedButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.editorCm {
flex: 1;
min-height: 0;
@@ -779,62 +1146,32 @@
font: var(--dsw-font-xs-13);
}
/* Word preview: the document scrolls independently while the zoom control
stays pinned to the bottom edge. */
.editorDocx {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--dsw-alias-bg-base);
}
.editorDocxViewport {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: auto;
background: var(--dsw-alias-bg-base);
}
/* docx-preview renders into a wrapper div with .docx className; the host
above scrolls while the wrapper holds the page flow. */
.editorDocxWrap {
flex: none;
padding: 16px;
}
.editorDocxZoom {
flex: none;
display: flex;
/* The floating "add to conversation" button anchored above a text
selection in the preview/code viewers. Portalled to document.body and
position:fixed, so the editor's overflow clips cannot crop it; flat
chrome like the rest of the sidebar (hairline border, layer-2 fill,
hover fill — no shadow). */
.selectionPopup {
position: fixed;
z-index: 60;
transform: translate(-50%, calc(-100% - 8px));
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 34px;
padding: 4px 10px;
border-top: 1px solid var(--dsw-alias-border-l1);
background: var(--dsw-alias-bg-layer-1);
}
.editorDocxZoomHint,
.editorDocxZoomValue {
flex: none;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
}
.editorDocxZoomValue {
width: 36px;
text-align: right;
}
.editorDocxZoomRange {
flex: 1;
min-width: 72px;
accent-color: var(--dsw-alias-brand-primary);
height: 28px;
padding: 0 10px;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 6px;
background: var(--dsw-alias-bg-layer-2);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxxs-strong-11);
white-space: nowrap;
cursor: pointer;
}
.selectionPopup:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.editorPdf {
flex: 1;
min-height: 0;
@@ -893,94 +1230,6 @@
pointer-events: auto !important;
}
.editorXlsx {
position: relative;
flex: 1;
min-height: 0;
overflow: hidden;
background: var(--dsw-alias-bg-base);
}
.editorUniverHost {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.editorOfficeOverlay {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
background: var(--dsw-alias-bg-base);
}
.editorPptx {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--dsw-alias-bg-base);
}
.editorPptxToolbar {
flex: none;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 6px 8px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
}
.editorPptxToolbar .editorDownloadLink {
margin-left: auto;
}
.editorPptxButton {
height: 28px;
padding: 0 10px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 6px;
background: var(--dsw-alias-bg-layer-2);
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xxs-strong-12);
cursor: pointer;
}
.editorPptxButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.editorPptxButton:disabled {
opacity: 0.4;
cursor: default;
}
.editorPptxPosition {
min-width: 64px;
text-align: center;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
}
.editorPptxStage {
position: relative;
flex: 1;
min-height: 0;
overflow: hidden;
}
.editorPptxHost {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: auto;
}
/* ── Terminal ──────────────────────────────────────────────────────────── */
.terminalWrap {
@@ -1056,6 +1305,81 @@
color: var(--dsw-alias-label-primary);
}
/* The node-pty dependency failure banner (issue #140): a taller warn block
carrying the pasteable repair command (bash / cmd / PowerShell). */
.terminalDepsBanner {
flex: none;
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-state-warn-label);
background: var(--dsw-alias-state-warn-tertiary);
}
.terminalDepsTitle {
font: var(--dsw-font-xxs-strong-12);
color: var(--dsw-alias-state-warn-primary);
}
.terminalDepsHint {
opacity: 0.9;
}
.terminalDepsCommandRow {
display: flex;
align-items: flex-start;
gap: 8px;
}
/* The command the user pastes into a terminal / cmd. Selectable, wrapping,
and scrollable when long (Windows paths can get very long). */
.terminalRepairCommand {
flex: 1;
min-width: 0;
margin: 0;
padding: 6px 8px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
user-select: text;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-bg-layer-2);
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 4px;
max-height: 160px;
overflow: auto;
}
.terminalDepsNote {
opacity: 0.85;
}
.terminalDepsActions {
display: flex;
align-items: center;
gap: 8px;
}
/* The PER-TAB error strip: fills one tab's content area (unlike
.boundaryError, the full-height fixed rail for shell-level crashes), so a
crashing viewer/editor never hides the toggle cluster or the other tabs. */
.tabBoundaryError {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-start;
padding: 12px 16px;
overflow: auto;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-state-error-primary);
}
/* ── Git ───────────────────────────────────────────────────────────────── */
.git {
@@ -1533,6 +1857,7 @@
/* Keyboard focus stays visible on every interactive control (the styling
policy requires focus visibility; the accent token doubles as the ring). */
.toggleButton:focus-visible,
.bottomClose:focus-visible,
.iconButton:focus-visible,
.tab:focus-visible,
.tabClose:focus-visible,
@@ -1559,6 +1884,9 @@
@media (prefers-reduced-motion: reduce) {
.panel,
.panelHidden,
.bottomPanel,
.bottomPanelHidden,
.toggleCluster,
.toggleButton,
.tab,
.tabBarPlus,
@@ -1572,3 +1900,28 @@
animation: none;
}
}
/* ── Narrow (mobile) viewport ──────────────────────────────────────────── */
/* Pairs with NARROW_MAX_WIDTH = 768 in src/client/breakpoints.ts: widths
strictly below 768px (i.e. max-width: 767px) switch the sidebar to the
mobile layout — one full-screen drawer (the bottom panel's tabs were
migrated into the right tree), one toggle button. The structural
switches (which panels render, the migration) are JS-driven via
useNarrowViewport; this block only adapts the pure style details. */
@media (max-width: 767px) {
/* The toggle cluster holds a single button on narrow viewports (the
bottom panel button is not rendered), so the open panel's tab strip
reserves 40px instead of the desktop 72px — the tabs and + menu still
yield space to the one remaining control. */
.panel:not(.panelHidden) .tabBar {
padding-right: 40px;
}
/* Narrower tabs: a phone-width drawer fits more open tabs before the
strip scrolls. */
.tab {
min-width: 48px;
max-width: 128px;
}
}
+16 -5
View File
@@ -123,8 +123,9 @@ function LeafView(props: {
onNewTab: (optionId: string) => void
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
getTabIcon?: (tab: SidebarTab) => ReactNode
getTabBadge?: (tab: SidebarTab) => ReactNode
}) {
const { leaf, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props
const { leaf, newTabOptions, actions, onNewTab, renderTab, getTabIcon, getTabBadge } = props
const [dropZone, setDropZone] = useState<DropZone | null>(null)
const activeTab = leaf.tabs.find(tab => tab.id === leaf.active) ?? leaf.tabs[leaf.tabs.length - 1]
@@ -178,6 +179,7 @@ function LeafView(props: {
onNewTab={onNewTab}
newTabOptions={newTabOptions}
getTabIcon={getTabIcon}
getTabBadge={getTabBadge}
onDropTab={(payload, before) => {
if (before === null) actions.moveTabToEdge(payload, leaf.id, 'center')
else actions.moveTabBefore(payload, leaf.id, before)
@@ -217,8 +219,9 @@ function NodeView(props: {
onNewTab: (optionId: string) => void
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
getTabIcon?: (tab: SidebarTab) => ReactNode
getTabBadge?: (tab: SidebarTab) => ReactNode
}) {
const { node, state, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props
const { node, state, newTabOptions, actions, onNewTab, renderTab, getTabIcon, getTabBadge } = props
if (node.kind === 'leaf') {
return (
<LeafView
@@ -228,6 +231,7 @@ function NodeView(props: {
onNewTab={onNewTab}
renderTab={renderTab}
getTabIcon={getTabIcon}
getTabBadge={getTabBadge}
/>
)
}
@@ -254,6 +258,7 @@ function NodeView(props: {
onNewTab={onNewTab}
renderTab={renderTab}
getTabIcon={getTabIcon}
getTabBadge={getTabBadge}
/>
</div>
</Fragment>
@@ -262,26 +267,32 @@ function NodeView(props: {
)
}
/** The workbench: the split tree filling the sidebar body. */
/** The workbench: the split tree filling the sidebar body. `tree` selects
* which tree renders (the right panel's by default; the bottom panel passes
* `state.bottomSplits` — the actions route by pane id, so one action set
* serves both). */
export function Workbench(props: {
state: SidebarState
tree?: SplitNode
newTabOptions: NewTabOption[]
actions: WorkbenchActions
onNewTab: (optionId: string) => void
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
getTabIcon?: (tab: SidebarTab) => ReactNode
getTabBadge?: (tab: SidebarTab) => ReactNode
}) {
const { state, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props
const { state, tree, newTabOptions, actions, onNewTab, renderTab, getTabIcon, getTabBadge } = props
return (
<div className={css.workbench}>
<NodeView
node={state.splits}
node={tree ?? state.splits}
state={state}
newTabOptions={newTabOptions}
actions={actions}
onNewTab={onNewTab}
renderTab={renderTab}
getTabIcon={getTabIcon}
getTabBadge={getTabBadge}
/>
</div>
)
+335 -26
View File
@@ -10,6 +10,7 @@
* operations are pure functions over the node, unit-tested in tests/state.spec.ts.
*/
import { SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs } from '../prefs-shared.ts'
import { isNarrowWidth } from './breakpoints.ts'
/**
* Tab type identifier. Builtins register their ids (explorer / git / editor
@@ -25,13 +26,17 @@ export type SidebarDiffRef =
| { 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. */
* `diff` carries the change a diff tab shows; `meta` (v0.12.0+) carries
* plugin-owned JSON-serializable state, preserved across reloads. */
export interface SidebarTab {
id: string
type: TabType
title: string
path?: string
diff?: SidebarDiffRef
/** Plugin-owned state (v0.12.0+): MUST be JSON-serializable — it is
* persisted with the layout and restored verbatim on reload. */
meta?: unknown
}
/** A tab group. */
@@ -57,19 +62,41 @@ export type SplitNode = SidebarLeaf | SidebarSplit
export interface SidebarState {
panelOpen: boolean
width: number
/** The pane receiving newly opened tabs (last pane the user touched). */
/** The pane receiving newly opened tabs (last pane the user touched).
* Pane ids are globally unique across BOTH trees (shared uid counter), so
* one field resolves into either tree — see {@link treeOf}. */
activePane: string | null
/** Monotonic terminal tab counter (ids survive reloads). */
nextTerminal: number
/** Monotonic browser tab counter (ids survive reloads; mirrors nextTerminal). */
nextBrowser: number
/** Explorer expansion set (absolute directory paths). */
expanded: string[]
/** The right sidebar's split tree (the original workbench). */
splits: SplitNode
/** Whether the bottom panel (a second, independent workbench) is open. */
bottomOpen: boolean
/** The bottom panel's height (clamped to the contract range). */
bottomHeight: number
/**
* Whether the bottom panel has been expanded at least once in this
* session — the FIRST expansion tries to auto-open a terminal tab (gated
* on the bottomPanelAutoTerminal pref); later expansions never do.
*/
bottomOpenedOnce: boolean
/** The bottom panel's own split tree (panes/tabs live only in ONE tree;
* tabs never cross panels — the two panels only share panel-size drags). */
bottomSplits: SplitNode
}
export const PANEL_MIN = 280
export const PANEL_MAX = 640
export const PANEL_DEFAULT = 400
export const TAB_MAX_WIDTH = 160
/** Bottom panel geometry contract (mirrors the width contract; the upper
* bound is the viewport, enforced by {@link setBottomHeight}). */
export const BOTTOM_MIN = 120
export const BOTTOM_DEFAULT = 220
let nextIdCounter = 0
/** Unique pane/tab id within one state instance. */
@@ -108,6 +135,7 @@ function maxCounterId(parsed: unknown): number {
}
}
walk((parsed as Record<string, unknown> | null)?.splits)
walk((parsed as Record<string, unknown> | null)?.bottomSplits)
return max
}
@@ -124,16 +152,40 @@ export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seedEx
leaf.tabs = [{ id: uid('tab'), type: 'explorer', title: 'Explorer' }]
leaf.active = leaf.tabs[0]!.id
}
// The bottom panel starts closed with an empty pane (its welcome cards
// offer the openable types on first use).
const bottomLeaf: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
return {
panelOpen,
width,
activePane: leaf.id,
nextTerminal: 1,
nextBrowser: 1,
expanded: [],
splits: leaf,
bottomOpen: false,
bottomHeight: BOTTOM_DEFAULT,
bottomOpenedOnce: false,
bottomSplits: bottomLeaf,
}
}
/** Whether a tree node (or any descendant) carries the given pane/split id. */
function treeHasId(node: SplitNode, id: string): boolean {
if (node.id === id) return true
if (node.kind === 'split') return node.children.some(child => treeHasId(child, id))
return false
}
/** Which tree owns a pane/split id: 'bottomSplits' when the id lives in the
* bottom panel's tree, else 'splits' (the right panel's tree). Ids are
* globally unique (the shared uid counter), so an id in neither tree falls
* back to the right tree, where tree operations no-op on a missing node —
* the pre-bottom-panel behavior. */
export function treeOf(state: SidebarState, id: string): 'splits' | 'bottomSplits' {
return treeHasId(state.bottomSplits, id) ? 'bottomSplits' : 'splits'
}
/** Walk the tree and apply `visit` to the leaf with the given id. */
export function mapLeaf(node: SplitNode, paneId: string, visit: (leaf: SidebarLeaf) => void): SplitNode {
if (node.kind === 'leaf') {
@@ -158,6 +210,44 @@ export function firstLeaf(node: SplitNode): SidebarLeaf {
return firstLeaf(node.children[0]!)
}
/** Empty every leaf of a tree (the bottom tree after its tabs migrate out). */
function clearAllTabs(node: SplitNode): SplitNode {
if (node.kind === 'leaf') return { ...node, tabs: [], active: null }
return { ...node, children: node.children.map(clearAllTabs) }
}
/**
* Narrow-viewport migration: the bottom panel's tabs are thrown INTO the
* right sidebar — the "merged display" on mobile is the right panel alone,
* whose tab strips now carry the bottom tree's tabs (depth-first order,
* appended to the right tree's FIRST leaf). The bottom tree is emptied (its
* structure stays — the desktop bottom panel re-renders its welcome cards)
* and the panel closes. The active pane moves to the right tree's first
* leaf so every new tab lands in the visible panel.
*
* Idempotent: a bottom tree with no tabs and a closed panel returns the
* same reference. Runs when the viewport enters narrow (see the Sidebar
* shell); migrating is permanent for the session — the tabs now live in the
* right tree, exactly like the user "threw them in".
*/
export function migrateBottomTabs(state: SidebarState): SidebarState {
const bottomTabs = allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs)
const activeInBottom = state.activePane !== null && treeHasId(state.bottomSplits, state.activePane)
if (bottomTabs.length === 0 && !state.bottomOpen && !activeInBottom) return state
const target = firstLeaf(state.splits)
return {
...state,
activePane: target.id,
bottomOpen: false,
splits: bottomTabs.length > 0
? mapLeaf(state.splits, target.id, leaf => {
leaf.tabs = [...leaf.tabs, ...bottomTabs]
})
: state.splits,
bottomSplits: bottomTabs.length > 0 ? clearAllTabs(state.bottomSplits) : state.bottomSplits,
}
}
/** Find the leaf containing a tab id, if any. */
export function leafWithTab(node: SplitNode, tabId: string): SidebarLeaf | undefined {
if (node.kind === 'leaf') {
@@ -176,9 +266,10 @@ export function allLeaves(node: SplitNode): SidebarLeaf[] {
return node.children.flatMap(allLeaves)
}
/** Whether a tab exists anywhere in a state tree (any pane). */
/** Whether a tab exists anywhere in a state (either tree, any pane). */
export function tabOpenIn(state: SidebarState, tabId: string): boolean {
return allLeaves(state.splits).some(leaf => leaf.tabs.some(tab => tab.id === tabId))
|| allLeaves(state.bottomSplits).some(leaf => leaf.tabs.some(tab => tab.id === tabId))
}
/** Replace a leaf with a split of it plus a fresh empty leaf. */
@@ -234,6 +325,9 @@ export type DropZone = 'left' | 'right' | 'up' | 'down' | 'center'
* The VSCode drag gesture: move a tab out of its pane and either merge it
* into the target pane (center) or split the target pane with the tab in a
* fresh leaf (edge). The source pane collapses when it empties.
*
* The panes may live in DIFFERENT trees (dragging a tab between the two
* panels): the tab then leaves its own tree and lands in the other one.
*/
export function moveTabToEdge(
state: SidebarState,
@@ -246,11 +340,43 @@ export function moveTabToEdge(
// Dropped back onto its own pane's center: reorder to the end.
return moveTab(state, fromPane, tabId, toPane, -1)
}
const source = leafWithTab(state.splits, tabId)
const key = treeOf(state, fromPane)
const toKey = treeOf(state, toPane)
if (key !== toKey) {
// Cross-panel drop: remove the tab from its own tree, then merge (center)
// or split (edge) a pane of the OTHER tree with the tab.
const source = leafWithTab(state[key], tabId)
if (source === undefined) return state
const tab = source.tabs.find(candidate => candidate.id === tabId)!
let emptied = false
let sourceNode = mapLeaf(state[key], source.id, (leaf) => {
leaf.tabs = leaf.tabs.filter(candidate => candidate.id !== tabId)
if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
if (leaf.tabs.length === 0) emptied = true
})
if (emptied) sourceNode = removeLeafAt(sourceNode, source.id)
let targetNode = state[toKey]
let activePane: string
if (zone === 'center') {
targetNode = mapLeaf(targetNode, toPane, (leaf) => {
leaf.tabs = [...leaf.tabs, tab]
leaf.active = tab.id
})
activePane = toPane
} else {
const dir = zone === 'left' || zone === 'right' ? 'row' : 'col'
const result = insertLeafAt(targetNode, toPane, dir, tab, zone === 'left' || zone === 'up')
targetNode = result.node
activePane = result.leafId
}
return { ...state, [key]: sourceNode, [toKey]: targetNode, activePane }
}
const node = state[key]
const source = leafWithTab(node, tabId)
if (source === undefined) return state
const tab = source.tabs.find(candidate => candidate.id === tabId)!
let emptied = false
let splits = mapLeaf(state.splits, source.id, (leaf) => {
let splits = mapLeaf(node, source.id, (leaf) => {
leaf.tabs = leaf.tabs.filter(candidate => candidate.id !== tabId)
if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
if (leaf.tabs.length === 0) emptied = true
@@ -261,11 +387,11 @@ export function moveTabToEdge(
leaf.tabs = [...leaf.tabs, tab]
leaf.active = tab.id
})
return { ...state, splits, activePane: toPane }
return { ...state, [key]: splits, activePane: toPane }
}
const dir = zone === 'left' || zone === 'right' ? 'row' : 'col'
const result = insertLeafAt(splits, toPane, dir, tab, zone === 'left' || zone === 'up')
return { ...state, splits: result.node, activePane: result.leafId }
return { ...state, [key]: result.node, activePane: result.leafId }
}
/**
@@ -288,26 +414,60 @@ export function removeLeafAt(node: SplitNode, paneId: string): SplitNode {
/** Close a tab; an emptied leaf is removed (unless it is the only pane). */
export function closeTab(state: SidebarState, paneId: string, tabId: string): SidebarState {
const key = treeOf(state, paneId)
let emptied = false
const splits = mapLeaf(state.splits, paneId, (leaf) => {
const splits = mapLeaf(state[key], paneId, (leaf) => {
leaf.tabs = leaf.tabs.filter(tab => tab.id !== tabId)
if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
if (leaf.tabs.length === 0) emptied = true
})
return { ...state, splits: emptied ? removeLeafAt(splits, paneId) : splits }
return { ...state, [key]: emptied ? removeLeafAt(splits, paneId) : splits }
}
/** Activate a tab in its pane. */
/** Activate a tab in its pane (the pane's own tree). */
export function activateTab(state: SidebarState, paneId: string, tabId: string): SidebarState {
const key = treeOf(state, paneId)
return {
...state,
activePane: paneId,
splits: mapLeaf(state.splits, paneId, (leaf) => {
[key]: mapLeaf(state[key], paneId, (leaf) => {
if (leaf.tabs.some(tab => tab.id === tabId)) leaf.active = tabId
}),
}
}
/** Update the display fields of one open tab (title / path / meta) without
* re-opening it. The browser tab persists its current URL and hostname
* title through this reducer so a reload restores the visited page. A
* missing tab id is a no-op. The tab may live in either tree. */
export function patchTab(
state: SidebarState,
tabId: string,
patch: { title?: string; path?: string; meta?: unknown },
): SidebarState {
let changed = false
const walk = (node: SplitNode): SplitNode => {
if (node.kind === 'leaf') {
const tabs = node.tabs.map(tab => {
if (tab.id !== tabId) return tab
changed = true
return {
...tab,
...(patch.title !== undefined ? { title: patch.title } : {}),
...(patch.path !== undefined ? { path: patch.path } : {}),
...(patch.meta !== undefined ? { meta: patch.meta } : {}),
}
})
return tabs === node.tabs ? node : { ...node, tabs }
}
const children = node.children.map(walk)
return children === node.children ? node : { ...node, children }
}
const splits = walk(state.splits)
const bottomSplits = walk(state.bottomSplits)
return changed ? { ...state, splits, bottomSplits } : state
}
/**
* Land a tab in the active pane (or focus its existing instance by id).
* Dedup strategies (single-instance, per-path, per-change) are owned by the
@@ -316,34 +476,68 @@ export function activateTab(state: SidebarState, paneId: string, tabId: string):
* openDiffTab already check existence before calling) and the landing
* itself — the service's dedupe path delegates here after its dedupeKey
* check misses.
*
* The active pane may live in EITHER tree (pane ids are globally unique):
* a stale id that survives in neither tree falls back to the right tree's
* first pane instead of swallowing the open.
*/
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)) {
// open: fall back to the first pane of the right tree instead of dropping
// the tab.
if (!allLeaves(state[treeOf(state, targetId)]).some(leaf => leaf.id === targetId)) {
targetId = firstLeaf(state.splits).id
}
const targetKey = treeOf(state, targetId)
// Id-based safety net: if a tab with the same id exists, focus it.
for (const leaf of allLeaves(state.splits)) {
for (const leaf of allLeaves(state.splits).concat(allLeaves(state.bottomSplits))) {
const existing = leaf.tabs.find(candidate => candidate.id === tab.id)
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
}
return {
...state,
activePane: targetId,
splits: mapLeaf(state.splits, targetId, (leaf) => {
[targetKey]: mapLeaf(state[targetKey], targetId, (leaf) => {
leaf.tabs = [...leaf.tabs, tab]
leaf.active = tab.id
}),
}
}
/** Move a tab from one pane to another (insert at index; -1 appends). */
/** Move a tab from one pane to another (insert at index; -1 appends).
* The panes may live in DIFFERENT trees — dragging a tab between the two
* panels removes it from its own tree and lands it in the other one. */
export function moveTab(state: SidebarState, fromPane: string, tabId: string, toPane: string, index = -1): SidebarState {
const fromKey = treeOf(state, fromPane)
const toKey = treeOf(state, toPane)
if (fromKey !== toKey) {
let moved: SidebarTab | undefined
let emptied = false
const source = mapLeaf(state[fromKey], fromPane, (leaf) => {
const found = leaf.tabs.find(tab => tab.id === tabId)
if (found === undefined) return
moved = found
leaf.tabs = leaf.tabs.filter(tab => tab.id !== tabId)
if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
if (leaf.tabs.length === 0) emptied = true
})
if (moved === undefined) return state
const target = mapLeaf(state[toKey], toPane, (leaf) => {
const insertAt = index >= 0 && index <= leaf.tabs.length ? index : leaf.tabs.length
leaf.tabs = [...leaf.tabs.slice(0, insertAt), moved!, ...leaf.tabs.slice(insertAt)]
leaf.active = moved!.id
})
return {
...state,
[fromKey]: emptied ? removeLeafAt(source, fromPane) : source,
[toKey]: target,
activePane: toPane,
}
}
let moved: SidebarTab | undefined
let emptied = false
let splits = mapLeaf(state.splits, fromPane, (leaf) => {
let splits = mapLeaf(state[fromKey], fromPane, (leaf) => {
const found = leaf.tabs.find(tab => tab.id === tabId)
if (found === undefined) return
moved = found
@@ -358,13 +552,14 @@ export function moveTab(state: SidebarState, fromPane: string, tabId: string, to
leaf.tabs = [...leaf.tabs.slice(0, insertAt), moved!, ...leaf.tabs.slice(insertAt)]
leaf.active = moved!.id
})
return { ...state, splits, activePane: toPane }
return { ...state, [fromKey]: splits, activePane: toPane }
}
/** Split the active pane (or the pane containing the active tab). */
export function splitPane(state: SidebarState, dir: 'row' | 'col'): SidebarState {
const paneId = state.activePane ?? firstLeaf(state.splits).id
return { ...state, splits: splitLeafAt(state.splits, paneId, dir) }
const key = treeOf(state, paneId)
return { ...state, [key]: splitLeafAt(state[key], paneId, dir) }
}
/**
@@ -410,6 +605,11 @@ export function togglePanel(state: SidebarState): SidebarState {
return { ...state, panelOpen: !state.panelOpen }
}
/** Toggle the bottom panel open/closed (independent of the right panel). */
export function toggleBottomPanel(state: SidebarState): SidebarState {
return { ...state, bottomOpen: !state.bottomOpen }
}
/** Set the panel width (clamped to the contract range; the upper bound is
* the viewport so the fullscreen expansion can fill the window). */
export function setWidth(state: SidebarState, width: number): SidebarState {
@@ -417,6 +617,16 @@ export function setWidth(state: SidebarState, width: number): SidebarState {
return { ...state, width: Math.min(max, Math.max(PANEL_MIN, Math.round(width))) }
}
/** Set the bottom panel height (clamped to the contract range). The upper
* bound leaves the center column (the agent output area) at least PANEL_MIN
* tall — without the cap the bottom panel could swallow the whole viewport
* and squeeze the conversation to zero height. */
export function setBottomHeight(state: SidebarState, height: number): SidebarState {
const viewport = typeof window !== 'undefined' ? window.innerHeight : Infinity
const max = Math.max(BOTTOM_MIN, viewport - PANEL_MIN)
return { ...state, bottomHeight: Math.min(max, Math.max(BOTTOM_MIN, Math.round(height))) }
}
/** Toggle a directory in the explorer expansion set. */
export function toggleExpanded(state: SidebarState, path: string): SidebarState {
const expanded = state.expanded.includes(path)
@@ -443,6 +653,13 @@ export function resizeSplit(node: SplitNode, splitId: string, index: number, del
}
}
/** State-level {@link resizeSplit} route: the divider may live in either
* tree (split ids are globally unique). */
export function resizeSplitIn(state: SidebarState, splitId: string, index: number, delta: number): SidebarState {
const key = treeOf(state, splitId)
return { ...state, [key]: resizeSplit(state[key], splitId, index, delta) }
}
/** Prefix marking a tab id as an agent-owned terminal (suffix is the uuid). */
export const AGENT_TAB_PREFIX = 'agent:'
@@ -477,7 +694,7 @@ export function reconcileAgentTerminals(
state: SidebarState,
agentTerminals: ReadonlyArray<{ uuid: string; title: string }>,
): SidebarState {
const existingTabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
const existingTabs = allLeaves(state.splits).concat(allLeaves(state.bottomSplits)).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))
@@ -548,12 +765,18 @@ function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
// chosen percent of the window (clamped to the panel floor and the
// 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.
// tab is skipped when the user disabled the explorer tab type. On a
// NARROW viewport a brand-new session starts collapsed instead — the
// panel is a full-screen drawer there, and auto-opening it on first
// paint would cover the conversation before the user asked. Only the
// first seeding is affected: once the user expands the drawer,
// `panelOpen: true` persists like any other state.
const viewport = typeof window !== 'undefined' ? window.innerWidth : undefined
const width = viewport === undefined
? PANEL_DEFAULT
: defaultWidthFor(viewport, prefs.defaultWidthPercent)
return makeDefaultState(width, prefs.openByDefault, prefs.tabsEnabled['explorer'] !== false)
const openByDefault = prefs.openByDefault && (viewport === undefined || !isNarrowWidth(viewport))
return makeDefaultState(width, openByDefault, prefs.tabsEnabled['explorer'] !== false)
}
/**
@@ -572,11 +795,37 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined {
if (typeof record.nextTerminal !== 'number' || !Number.isInteger(record.nextTerminal) || record.nextTerminal < 1) {
return undefined
}
// nextBrowser arrived in a later build; a missing or malformed value on an
// OLDER persisted state defaults to 1 so existing layouts keep loading
// (unlike nextTerminal, which is strict — it predates the v1 shape).
const nextBrowser = typeof record.nextBrowser === 'number' && Number.isInteger(record.nextBrowser) && record.nextBrowser >= 1
? record.nextBrowser
: 1
if (typeof record.activePane !== 'string' && record.activePane !== null) return undefined
if (!Array.isArray(record.expanded) || record.expanded.some(item => typeof item !== 'string')) return undefined
// The seen/reid maps are SHARED across both trees: pane/split ids must be
// globally unique (the runtime uid counter is shared too), so a duplicate
// seen first in the right tree gets a fresh id when it reappears in the
// bottom tree.
const seen = new Set<string>()
const reid = new Map<string, string>()
const splits = sanitizeNode(record.splits, new Set(), reid)
const splits = sanitizeNode(record.splits, seen, reid)
if (splits === undefined) return undefined
// Bottom-panel fields arrived in a later build: a missing or malformed
// value on an OLDER persisted state defaults (closed / default height /
// empty pane) so existing layouts keep loading, like nextBrowser.
const bottomOpen = record.bottomOpen === true
// Cap the persisted height so the center column (the agent output area)
// keeps at least PANEL_MIN tall (a stale full-height bottom panel from an
// older build must never squeeze the conversation to zero).
const maxHeight = typeof window !== 'undefined' ? window.innerHeight : Infinity
const bottomCap = Math.max(BOTTOM_MIN, maxHeight - PANEL_MIN)
const rawHeight = typeof record.bottomHeight === 'number' && Number.isFinite(record.bottomHeight)
? record.bottomHeight
: BOTTOM_DEFAULT
const bottomHeight = Math.min(bottomCap, Math.max(BOTTOM_MIN, Math.round(rawHeight)))
const bottomSplits = sanitizeNode(record.bottomSplits, seen, reid)
?? { kind: 'leaf' as const, id: uid('pane'), tabs: [], active: null }
const maxWidth = typeof window !== 'undefined' ? window.innerWidth : Infinity
return {
panelOpen: record.panelOpen,
@@ -585,8 +834,16 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined {
// new tabs still land in the pane the user was using.
activePane: typeof record.activePane === 'string' ? (reid.get(record.activePane) ?? record.activePane) : null,
nextTerminal: record.nextTerminal,
nextBrowser,
expanded: record.expanded as string[],
splits,
bottomOpen,
bottomHeight,
// An older persisted state never expanded the bottom panel (the field
// arrived later): defaulting to false gives it the first-expansion
// auto-terminal exactly once after the upgrade.
bottomOpenedOnce: record.bottomOpenedOnce === true,
bottomSplits,
}
}
@@ -634,11 +891,15 @@ function sanitizeNode(node: unknown, seen: Set<string>, reid: Map<string, string
// 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
// `meta` is plugin-owned JSON-serializable state (v0.12.0+): the
// persisted value already went through JSON.parse, so it is inherently
// serializable — carry it through verbatim (absent on older states).
tabs.push({
id: candidate.id,
type: candidate.type,
title: candidate.title,
...(typeof candidate.path === 'string' ? { path: candidate.path } : {}),
...(candidate.meta !== undefined ? { meta: candidate.meta } : {}),
})
}
const active = typeof record.active === 'string' ? record.active : null
@@ -677,7 +938,9 @@ export class SidebarStore {
prefs: { ...SIDEBAR_PREFS_DEFAULTS },
}
private readonly listeners = new Set<() => void>()
private persistTimer: number | undefined
/** Per-session persist debounce timers (v0.12.0+: one per session, so a
* targeted open never cancels another session's pending write). */
private readonly persistTimers = new Map<string, number>()
/** User-facing side card prefs seeding brand-new session states (defaults until the settings RPC resolves). */
private prefs: SidebarPrefs = { ...SIDEBAR_PREFS_DEFAULTS }
@@ -762,21 +1025,67 @@ export class SidebarStore {
const state = this.snapshot.state
if (sessionId === undefined || state === undefined) return
const next = reducer(state)
// A reducer returning the SAME reference means "no change": skip the
// persist + notify entirely — strict no-op paths (unknown tab ids,
// patchTab on a missing tab) must not churn the state or rewrite
// localStorage.
if (next === state) return
this.bySession.set(sessionId, next)
this.snapshot = { sessionId, state: next, prefs: this.prefs }
this.schedulePersist(sessionId, next)
this.notify()
}
/**
* Apply a pure reducer to a TARGET session's state (not the active one),
* loading it on demand and persisting the result — WITHOUT switching the
* active snapshot or notifying (the UI must not follow along). Used by the
* service's targeted `openTab(seed, scope)`: the open lands in the target
* session's layout and is visible whenever the user switches to it.
*/
reduceFor(sessionId: string, reducer: (state: SidebarState) => SidebarState): void {
// The uid counter is SHARED across sessions, and the ACTIVE session's
// safety requires it to never drop below the ids IT minted. Seeding it
// from the target's max may LOWER it (a cached target older than the
// active session): restoring the pre-call level afterwards keeps the
// active session's next mint collision-free — ids minted for the target
// only need to exceed the target's own max, which the seed guaranteed.
const counterBefore = nextIdCounter
let state = this.bySession.get(sessionId)
if (state === undefined) {
state = loadState(sessionId, this.prefs)
this.bySession.set(sessionId, state)
} else {
// Re-seed the uid counter past THIS session's persisted ids, exactly
// like setSession's cache-hit path.
nextIdCounter = maxCounterId(state)
}
const next = reducer(state)
// Same-reference result = no change: keep the counter restore (it may
// have been seeded down) but skip the write.
nextIdCounter = Math.max(nextIdCounter, counterBefore)
if (next === state) return
this.bySession.set(sessionId, next)
this.schedulePersist(sessionId, next)
}
private schedulePersist(sessionId: string, state: SidebarState): void {
window.clearTimeout(this.persistTimer)
this.persistTimer = window.setTimeout(() => {
// Per-session debounce timers: one session's pending write must never
// cancel another's (targeted opens schedule writes for INACTIVE
// sessions while the active session may already have one pending —
// a shared timer would drop the earlier write and the reload would
// lose that session's layout).
const existing = this.persistTimers.get(sessionId)
if (existing !== undefined) window.clearTimeout(existing)
const timer = window.setTimeout(() => {
this.persistTimers.delete(sessionId)
try {
localStorage.setItem(`${STORAGE_PREFIX}:${sessionId}`, JSON.stringify(state))
} catch {
// Storage full or unavailable: layout memory is best-effort.
}
}, 200)
this.persistTimers.set(sessionId, timer)
}
private notify(): void {
+161
View File
@@ -0,0 +1,161 @@
/**
* Pure derivations for the Subagent page's background-job section. Kept
* framework-free so the node test environment can unit-test them: the job
* rows arrive through the harness `session/jobs` push mirror
* (`jobsBySession` in the sessions list feed) — nothing here issues
* requests, and the row ordering / status mapping mirror the official
* ui-jobs header list.
*/
import type {
SidebarSessionList,
SidebarSessionSummary,
SidebarJobStatus,
SidebarJobView,
} from '../context-types.ts'
import type { CopyKey } from './locales.ts'
/** One row of the jobs section: the job plus its owning session's title. */
export interface TreeJob {
ownerSessionId: string
ownerTitle: string
job: SidebarJobView
}
/** Whether the registry still holds the job open (its duration ticks). */
export function isJobLive(job: SidebarJobView): boolean {
return job.status === 'running' || job.status === 'stopping'
}
/**
* Every session id of the topology tree rooted at `rootId` (the root plus
* each session whose uninterrupted subagent-origin chain reaches it — same
* lineage semantics as {@link countSubagentDescendants}; cycles fail soft).
* Sessions outside the tree (orphans, other trees) are excluded, so the
* jobs section never shows foreign work.
*/
export function treeSessionIds(
byId: SidebarSessionList['byId'],
rootId: string | undefined,
): Set<string> {
const ids = new Set<string>()
if (rootId === undefined) return ids
for (const summary of Object.values(byId)) {
const seen = new Set<string>()
let current: SidebarSessionSummary | undefined = summary
let reachesRoot = false
while (current !== undefined && !seen.has(current.id)) {
seen.add(current.id)
if (current.id === rootId) {
reachesRoot = true
break
}
if (current.origin !== 'subagent' || current.parentId === undefined) break
current = byId[current.parentId]
}
if (reachesRoot) ids.add(summary.id)
}
return ids
}
/**
* Whether a NEW background job appeared for one session between two
* consecutive list snapshots (a job id the previous snapshot lacked).
* Unlike the subagent auto-open (0 → N only), ANY new job id triggers: the
* agent may start several jobs over a session, and each new one should
* surface the Jobs page (a fresh page load never triggers — its baseline
* starts at the current snapshot).
*/
export function detectNewJob(
prev: SidebarSessionList,
next: SidebarSessionList,
sessionId: string,
): boolean {
const prevIds = new Set((prev.jobsBySession?.[sessionId] ?? []).map(job => job.id))
return (next.jobsBySession?.[sessionId] ?? []).some(job => !prevIds.has(job.id))
}
/**
* Collect the background jobs of the whole current tree, owner-labeled.
* Sessions without a mirror entry contribute nothing; an absent mirror
* (runtime older than the jobs feed) yields an empty list.
*/
export function collectTreeJobs(
byId: SidebarSessionList['byId'],
jobsBySession: Readonly<Record<string, readonly SidebarJobView[]>> | undefined,
rootId: string | undefined,
): TreeJob[] {
const rows: TreeJob[] = []
if (jobsBySession === undefined) return rows
for (const sessionId of treeSessionIds(byId, rootId)) {
const jobs = jobsBySession[sessionId]
if (jobs === undefined || jobs.length === 0) continue
const ownerTitle = byId[sessionId]?.displayTitle ?? sessionId
for (const job of jobs) rows.push({ ownerSessionId: sessionId, ownerTitle, job })
}
return rows
}
/**
* Live rows first in start order, then settled rows newest-first (mirror of
* the official ui-jobs ordering); a tie falls back to start order so the
* sort never depends on the host's map iteration.
*/
export function orderJobs(rows: readonly TreeJob[]): TreeJob[] {
return [...rows].sort((left, right) => {
const liveLeft = isJobLive(left.job)
if (liveLeft !== isJobLive(right.job)) return liveLeft ? -1 : 1
if (liveLeft) return left.job.startedAt - right.job.startedAt
const finished = (right.job.finishedAt ?? right.job.startedAt) - (left.job.finishedAt ?? left.job.startedAt)
return finished !== 0 ? finished : left.job.startedAt - right.job.startedAt
})
}
/** The sidebar's StateDot states for the five wire statuses. */
export type JobDotState = 'ongoing' | 'warning' | 'done' | 'error'
/**
* Status marker semantics. `stopping` and `killed` share the attention
* color: both mean the work ended (or is ending) on request rather than on
* its own.
*/
export function jobDotState(status: SidebarJobStatus): JobDotState {
switch (status) {
case 'running': return 'ongoing'
case 'stopping': return 'warning'
case 'completed': return 'done'
case 'killed': return 'warning'
case 'failed': return 'error'
}
}
/** Human status word of one wire status (localized through the passed translator). */
export function jobStatusLabel(
status: SidebarJobStatus,
t: (key: CopyKey, params?: Record<string, string | number>) => string,
): string {
switch (status) {
case 'running': return t('jobStatusRunning')
case 'stopping': return t('jobStatusStopping')
case 'completed': return t('jobStatusCompleted')
case 'killed': return t('jobStatusKilled')
case 'failed': return t('jobStatusFailed')
}
}
/**
* Elapsed time in at most two adjacent units (mirror of the official
* ui-jobs duration wording). A background job that outlives an hour is
* already exceptional, so hours is the widest unit.
*/
export function formatJobDuration(
elapsedMs: number,
t: (key: CopyKey, params?: Record<string, string | number>) => string,
): string {
const total = Math.max(0, Math.floor(elapsedMs / 1_000))
const seconds = total % 60
const minutes = Math.floor(total / 60) % 60
const hours = Math.floor(total / 3_600)
if (hours > 0) return t('jobDurationHours', { hours, minutes })
if (minutes > 0) return t('jobDurationMinutes', { minutes, seconds })
return t('jobDurationSeconds', { seconds })
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Terminal font resolution: the user's custom font prefs (SidebarPrefs,
* configured under the terminal card's secondary settings) turned into the
* xterm options. Kept as a pure module (no DOM, no xterm) so the fallback
* chain and clamping are unit-testable without mounting a terminal.
*/
import { clampTerminalFontSize, type SidebarPrefs } from '../prefs-shared.ts'
/** The built-in fallback stack when neither the user nor the theme sets one. */
export const DEFAULT_TERMINAL_FONT_FAMILY = '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace'
/**
* Resolve the xterm font options for the given prefs.
* @param prefs - the current side card preferences.
* @param themeFontFamily - the app's theme code font (`--ds-font-family-code`
* token value, read live by the caller); undefined when the token is absent.
* @returns the `fontFamily` / `fontSize` xterm options.
*/
export function resolveTerminalFont(
prefs: SidebarPrefs,
themeFontFamily: string | undefined,
): { fontFamily: string; fontSize: number } {
const custom = prefs.terminalFontFamily.trim()
return {
fontFamily: custom !== '' ? custom : (themeFontFamily || DEFAULT_TERMINAL_FONT_FAMILY),
fontSize: clampTerminalFontSize(prefs.terminalFontSize),
}
}
+66
View File
@@ -30,6 +30,72 @@ export function tokenValue(name: string): string {
return getComputedStyle(document.body).getPropertyValue(name).trim()
}
/** Minimal alpha for a token color to count as effectively opaque. Skin
* systems turn `--dsw-alias-bg-base` translucent for glass panels (the
* dsh-web-ui skins use rgba 0.16–0.7; `transparent` is 0); below this
* floor a text surface (terminal, editor) would render over the skin's
* backdrop art, so callers fall back to an opaque color. Values at or
* above the floor (e.g. a skin's scoped 0.96 porcelain) pass through —
* the skin still controls the surface. */
const OPAQUE_ALPHA_MIN = 0.9
/** The alpha channel of a computed CSS color, or null when the format is
* not parseable (named colors, `color()`… — treated as opaque). Handles
* the shapes getComputedStyle actually returns: the rgb()/rgba() and
* hsl()/hsla() function forms (comma or space syntax, with or without the
* `/ alpha` slot) and the #rgb/#rgba/#rrggbb/#rrggbbaa hex family. */
export function colorAlpha(color: string): number | null {
const s = color.trim()
// #rgb / #rgba / #rrggbb / #rrggbbaa
const hex = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(s)
if (hex !== null) {
const digits = hex[1]!
if (digits.length === 3 || digits.length === 4) {
const a = digits.length === 4 ? digits[3]! : 'f'
return parseInt(a + a, 16) / 255
}
const alphaHex = digits.length === 8 ? digits.slice(6) : 'ff'
return parseInt(alphaHex, 16) / 255
}
// rgb(r g b) / rgb(r g b / a) / rgba(r, g, b, a) — the trailing slot is alpha.
const fn = /^(rgba?|hsla?)\(([^)]+)\)$/i.exec(s)
if (fn !== null) {
const parts = fn[2]!.split(/[,\s/]+/).filter(Boolean)
const alphaPart = parts[3]
if (alphaPart === undefined) return 1
const alpha = Number.parseFloat(alphaPart)
return Number.isFinite(alpha) ? alpha : 1
}
return null
}
/**
* A token value that actually PAINTS something — the guard for text
* surfaces (issue #90). Skin systems routinely set global tokens to
* `transparent` (glass skins) or translucent glass values (`rgba(…,0.16–0.7)`,
* e.g. the dsh-web-ui skins) — both are truthy strings, so callers using
* `|| fallback` never fire and the terminal/editor goes see-through over
* the skin's backdrop. This returns '' for visually inert values (unset
* keywords, transparent, and any color below the opacity floor) so the
* caller's fallback chain engages; effectively opaque values pass through.
*/
export function effectiveTokenValue(name: string): string {
const raw = tokenValue(name)
switch (raw) {
case '':
case 'transparent':
case 'initial':
case 'inherit':
case 'unset':
return ''
default: {
const alpha = colorAlpha(raw)
if (alpha !== null && alpha < OPAQUE_ALPHA_MIN) return ''
return raw
}
}
}
/**
* Subscribe to color-scheme flips (the presenter toggles the body
* attribute). The callback fires after the attribute changed; re-read the
-260
View File
@@ -1,260 +0,0 @@
/**
* Convert a SheetJS (xlsx) workbook into Univer's {@link IWorkbookData} so
* {@link XlsxView} can render it through the Univer sheets preset.
*
* v1 scope (matches docs/plans/2026-08-10-office-preview-design.md §3.6):
* - Sheet names + order
* - Cell values, typed (string / number / boolean / error → force-string)
* - Cell formulas (text form; Univer's formula engine computes them)
* - Merged cells (!merges → mergeData)
* - Column widths (!cols → columnData) and row heights (!rows → rowData)
*
* Out of scope for v1 (SheetJS community edition does not parse them and the
* Pro edition is commercial): cell styles (font/fill/border/alignment),
* conditional formatting, charts. Users who need style fidelity get the
* download button on the binary placeholder.
*
* The function is pure (no DOM, no Univer imports) so it is unit-testable
* without jsdom or canvas.
*/
import type * as XLSX from 'xlsx'
// Types come from @univerjs/presets (which re-exports @univerjs/core's type
// surface). The enum values BooleanNumber / CellValueType are imported as
// runtime values from the same module — the bundler inlines them, and the
// test environment has @univerjs/presets available as a dependency.
import { BooleanNumber, CellValueType, type LocaleType, type ICellData, type IColumnData, type IRowData, type IWorkbookData, type IWorksheetData, type IObjectArrayPrimitiveType, type IObjectMatrixPrimitiveType, type IRange } from '@univerjs/presets'
/** SheetJS workbook (the slice of the module we use). */
type XLSXWorkbook = XLSX.WorkBook
/**
* Build a Univer workbook snapshot from a SheetJS workbook.
*
* @param wb - the parsed workbook from `XLSX.read(buf, { type: 'array' })`
* @param appVersion - the Univer package version, written into `appVersion`
* (Univer requires the field but does not gate on it).
* @param locale - the locale Univer should render in.
*/
export function xlsxWorkbookToUniver(
wb: XLSXWorkbook,
appVersion: string,
locale: LocaleType,
): IWorkbookData {
const sheetOrder: string[] = []
const sheets: Record<string, Partial<IWorksheetData>> = {}
wb.SheetNames.forEach((name, index) => {
const sheetId = `sheet-${index}`
sheetOrder.push(sheetId)
sheets[sheetId] = worksheetToUniver(wb.Sheets[name] ?? {}, name, sheetId)
})
// Univer requires at least one sheet; an empty workbook gets a placeholder.
if (sheetOrder.length === 0) {
const fallbackId = 'sheet-0'
sheetOrder.push(fallbackId)
sheets[fallbackId] = {
id: fallbackId,
name: 'Sheet1',
rowCount: 20,
columnCount: 26,
cellData: {},
rowData: {},
columnData: {},
mergeData: [],
}
}
return {
id: 'workbook',
name: wb.Props?.Title || 'Workbook',
appVersion,
locale,
styles: {},
sheetOrder,
sheets,
}
}
/** Convert one SheetJS worksheet to a Univer IWorksheetData. */
function worksheetToUniver(
ws: XLSX.WorkSheet,
name: string,
sheetId: string,
): Partial<IWorksheetData> {
const cellData: IObjectMatrixPrimitiveType<ICellData> = {}
const merges: IRange[] = []
// Cell values + formulas: iterate every cell address (skip the !-prefixed
// special keys SheetJS uses for sheet-level metadata).
for (const addr of Object.keys(ws)) {
if (addr.startsWith('!')) continue
const cell = ws[addr] as XLSX.CellObject | undefined
if (cell === undefined) continue
const { r, c } = decodeAddr(addr)
const converted = convertCell(cell)
if (converted === null) continue
let row = cellData[r]
if (row === undefined) {
row = {}
cellData[r] = row
}
row[c] = converted
}
// Merged cells: SheetJS's Range uses { s: { r, c }, e: { r, c } } (0-indexed),
// which is exactly Univer's IRange shape — copy verbatim.
if (Array.isArray(ws['!merges'])) {
for (const m of ws['!merges']!) {
if (m === undefined || m === null) continue
merges.push({ startRow: m.s.r, endRow: m.e.r, startColumn: m.s.c, endColumn: m.e.c })
}
}
const { rowCount, columnCount } = dimensions(ws, cellData)
return {
id: sheetId,
name,
tabColor: '',
hidden: BooleanNumber.FALSE,
freeze: { startRow: -1, startColumn: -1, xSplit: 0, ySplit: 0 },
rowCount,
columnCount,
defaultColumnWidth: 96,
defaultRowHeight: 22,
mergeData: merges,
cellData,
rowData: convertRows(ws['!rows']),
columnData: convertCols(ws['!cols']),
rowHeader: { width: 46 },
columnHeader: { height: 20 },
showGridlines: BooleanNumber.TRUE,
rightToLeft: BooleanNumber.FALSE,
}
}
/** Map a SheetJS CellObject onto Univer's ICellData (or null for empty). */
function convertCell(cell: XLSX.CellObject): ICellData | null {
const out: ICellData = {}
// Type mapping: b→BOOLEAN, n→NUMBER, s→STRING, d→STRING, e→FORCE_STRING, z→skip
switch (cell.t) {
case 'b':
out.t = CellValueType.BOOLEAN
out.v = cell.v === true
break
case 'n':
out.t = CellValueType.NUMBER
out.v = typeof cell.v === 'number' ? cell.v : Number(cell.v)
break
case 'e':
// Excel error strings like #REF! — display verbatim, no formula engine.
out.t = CellValueType.FORCE_STRING
out.v = String(cell.w ?? cell.v ?? '')
break
case 's':
case 'd':
case 'z':
default:
// String / date / stub. SheetJS renders dates as ISO strings in `w`
// when cellDates is on; otherwise `v` is the serial number. Use `w`
// (formatted text) when present so users see what Excel showed.
out.t = CellValueType.STRING
out.v = cell.w ?? (cell.v !== undefined ? String(cell.v) : '')
break
}
// Formula: keep as text; Univer's formula engine computes on load.
if (typeof cell.f === 'string' && cell.f !== '') {
out.f = cell.f
}
return out
}
/** Convert SheetJS `!cols` (ColInfo[]) to Univer columnData (sparse, by index). */
function convertCols(cols: XLSX.ColInfo[] | undefined): IObjectArrayPrimitiveType<Partial<IColumnData>> {
if (!Array.isArray(cols)) return {}
const out: IObjectArrayPrimitiveType<Partial<IColumnData>> = {}
cols.forEach((info, index) => {
if (info === undefined || info === null) return
const w = info.wpx ?? (info.width !== undefined ? Math.round(info.width * 7) : undefined)
out[index] = {
...(w !== undefined ? { w } : {}),
...(info.hidden === true ? { hd: BooleanNumber.TRUE } : {}),
}
})
return out
}
/** Convert SheetJS `!rows` (RowInfo[]) to Univer rowData (sparse, by index). */
function convertRows(rows: XLSX.RowInfo[] | undefined): IObjectArrayPrimitiveType<Partial<IRowData>> {
if (!Array.isArray(rows)) return {}
const out: IObjectArrayPrimitiveType<Partial<IRowData>> = {}
rows.forEach((info, index) => {
if (info === undefined || info === null) return
const h = info.hpx ?? info.hpt
out[index] = {
...(h !== undefined ? { h: Math.round(h) } : {}),
...(info.hidden === true ? { hd: BooleanNumber.TRUE } : {}),
}
})
return out
}
/**
* Resolve the worksheet dimensions. SheetJS's `!ref` is authoritative when
* present; otherwise fall back to the max row/col we actually wrote, with a
* small floor so the canvas has somewhere to paint.
*/
function dimensions(
ws: XLSX.WorkSheet,
cellData: IObjectMatrixPrimitiveType<ICellData>,
): { rowCount: number; columnCount: number } {
const ref = ws['!ref']
if (typeof ref === 'string' && ref !== '') {
const range = decodeRange(ref)
return {
rowCount: Math.max(range.endRow + 1, 20),
columnCount: Math.max(range.endColumn + 1, 10),
}
}
let maxRow = -1
let maxCol = -1
for (const r of Object.keys(cellData)) {
const rowNum = Number(r)
if (rowNum > maxRow) maxRow = rowNum
const rowObj = cellData[rowNum]!
for (const c of Object.keys(rowObj)) {
const colNum = Number(c)
if (colNum > maxCol) maxCol = colNum
}
}
return {
rowCount: Math.max(maxRow + 1, 20),
columnCount: Math.max(maxCol + 1, 10),
}
}
/** Decode an A1-style address (e.g. "AB12") to 0-indexed {row, col}. */
function decodeAddr(addr: string): { r: number; c: number } {
let col = 0
let i = 0
while (i < addr.length) {
const ch = addr.charCodeAt(i)
if (ch >= 65 && ch <= 90) {
col = col * 26 + (ch - 64)
i += 1
} else {
break
}
}
const row = Number(addr.slice(i)) - 1
return { r: row, c: col - 1 }
}
/** Decode an A1-style range (e.g. "A1:AB12") to 0-indexed bounds. */
function decodeRange(range: string): { startRow: number; endRow: number; startColumn: number; endColumn: number } {
const at = range.indexOf(':')
const start = decodeAddr(at === -1 ? range : range.slice(0, at))
const end = at === -1 ? start : decodeAddr(range.slice(at + 1))
return { startRow: start.r, endRow: end.r, startColumn: start.c, endColumn: end.c }
}
+41
View File
@@ -9,6 +9,12 @@ import z from 'schemastery'
import {
SIDEBAR_PREFS_DEFAULTS,
SIDEBAR_PREFS_NS,
TERMINAL_FONT_SIZE_DEFAULT,
TERMINAL_FONT_SIZE_MAX,
TERMINAL_FONT_SIZE_MIN,
TITLE_BAR_STRIP_DEFAULT,
TITLE_BAR_STRIP_MAX,
TITLE_BAR_STRIP_MIN,
WIDTH_PERCENT_DEFAULT,
WIDTH_PERCENT_MAX,
WIDTH_PERCENT_MIN,
@@ -18,6 +24,12 @@ import {
export {
SIDEBAR_PREFS_DEFAULTS,
SIDEBAR_PREFS_NS,
TERMINAL_FONT_SIZE_DEFAULT,
TERMINAL_FONT_SIZE_MAX,
TERMINAL_FONT_SIZE_MIN,
TITLE_BAR_STRIP_DEFAULT,
TITLE_BAR_STRIP_MAX,
TITLE_BAR_STRIP_MIN,
WIDTH_PERCENT_DEFAULT,
WIDTH_PERCENT_MAX,
WIDTH_PERCENT_MIN,
@@ -36,6 +48,14 @@ export interface SidebarConfig {
terminalsPerSession?: number
/** How long a disconnected terminal process survives awaiting a reconnect. */
reconnectGraceMs?: number
/**
* Terminal shell (absolute path or bare executable name) for BOTH the UI
* terminal tabs and the model-facing `terminal_*` tools. Empty = auto:
* POSIX follows `$SHELL` then the account login shell; Windows follows
* `DSH_SIDEBAR_SHELL`, then probes for `pwsh.exe`, then falls back to the
* inbox `powershell.exe` (5.1).
*/
shell?: string
}
/** Schemastery schema for the plugin configuration. */
@@ -45,6 +65,7 @@ export const Config: z<SidebarConfig> = z.object({
listLimit: z.number().step(1).min(1).default(1000),
terminalsPerSession: z.number().step(1).min(1).default(3),
reconnectGraceMs: z.number().step(1).min(0).default(30_000),
shell: z.string().default(''),
})
/** Fully defaulted sidebar host settings. */
@@ -54,6 +75,7 @@ export interface ResolvedSidebarConfig {
listLimit: number
terminalsPerSession: number
reconnectGraceMs: number
shell: string
}
/**
@@ -69,6 +91,7 @@ export function resolveSidebarConfig(config: SidebarConfig | undefined): Resolve
listLimit: config?.listLimit ?? 1000,
terminalsPerSession: config?.terminalsPerSession ?? 3,
reconnectGraceMs: config?.reconnectGraceMs ?? 30_000,
shell: config?.shell?.trim() ?? '',
}
}
@@ -79,10 +102,28 @@ 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),
autoOpenJobs: z.boolean().default(true),
agentTerminalTools: z.boolean().default(false),
bottomPanelAutoTerminal: z.boolean().default(true),
terminalFontFamily: z.string().default(''),
terminalFontSize: z.number().step(1).min(TERMINAL_FONT_SIZE_MIN).max(TERMINAL_FONT_SIZE_MAX).default(TERMINAL_FONT_SIZE_DEFAULT),
interceptOpenPath: z.boolean().default(true),
titleBarCompat: z.boolean().default(false),
titleBarStripPx: z.number().step(1).min(TITLE_BAR_STRIP_MIN).max(TITLE_BAR_STRIP_MAX).default(TITLE_BAR_STRIP_DEFAULT),
htmlViewerNoSandbox: z.boolean().default(false),
htmlViewerDefaultUnsafe: z.boolean().default(false),
browserNoSandbox: z.boolean().default(false),
browserInterceptLinks: z.boolean().default(true),
browserInterceptHttp: z.boolean().default(true),
browserInterceptHttps: 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({}),
// Plugin-owned settings blobs (v0.12.0+) are an OPEN nested map: any
// descriptor id may carry any JSON-serializable values. This is the
// "settings seam" opening — without it the seam would drop third-party
// keys as unknown schema fields.
pluginSettings: z.dict(z.dict(z.any())).default({}),
})
+175 -24
View File
@@ -6,37 +6,70 @@
* the npm cordis package does not declare the DSH-vendored runtime members
* (`ctx.effect`, service properties). The members below mirror the actual
* runtime shapes this plugin touches:
* - httpServer: @deepseek-ai/dsh-host-webserver
* - webServer: @deepseek-ai/dsh-host-webserver (the WebServer)
* - sessions: host side @deepseek-ai/dsh-session (SessionStore), client
* side the runtime ISessions list feed
* - conversation: client side ui-conversation's IConversation (composer
* draft), read lazily through `ctx.get` — cross-plugin service reads need
* an inject declaration, so the direct property is never typed here
* - loader: @cordisjs/plugin-loader (entry options)
* - slots: the client runtime SlotsService
* - webRuntime: @deepseek-ai/dsh-web-app (bind-derived trusted hosts)
* - slots: the client runtime SlotRegistry
* - effect: the DSH-vendored cordis lifecycle helper
* Drift from upstream is contained to this file.
*
* This file must stay FREE of Node.js types (`node:http`, `node:stream`,
* `Buffer`): it is part of the CLIENT-reachable declaration graph (the
* `Context` in `TabComponentProps` and the `declare module` augmentation),
* so a Node import here would leak into browser-only consumer builds. The
* webServer faces below are therefore structural mirrors with plain
* interfaces (the host casts to real Node types at the few boundaries that
* need them — e.g. the `ws` upgrade hook in src/index.ts).
*/
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'
/** The request face route handlers see (structural subset of node's
* IncomingMessage: the URL/method/header reads and the async body
* iteration `readJsonBody` uses). */
export interface SidebarHttpRequest {
url?: string
method?: string
headers: Record<string, string | string[] | undefined>
[Symbol.asyncIterator](): AsyncIterator<string | Uint8Array>
}
/** The response face route handlers write to (structural subset of node's
* ServerResponse: the status/header/body writes the routes use). */
export interface SidebarHttpResponse {
statusCode: number
writeHead(status: number, headers?: Record<string, string>): void
end(body?: string | Uint8Array): void
}
/** The upgrade socket face (structural subset: the destroy the fences use). */
export interface SidebarUpgradeSocket {
destroy(): void
}
/** The upgrade head bytes (Buffer at runtime; typed as bytes so no Node
* global leaks into the declaration graph). */
export type SidebarUpgradeHead = Uint8Array
/** One named webserver route (mirror of the host-webserver WebRoute). */
export interface SidebarWebRoute {
kind: 'exact' | 'prefix'
path: string
handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
handler: (req: SidebarHttpRequest, res: SidebarHttpResponse) => void | Promise<void>
}
/** One exact-path HTTP upgrade registration (mirror of WebUpgradeRoute). */
export interface SidebarWebUpgradeRoute {
path: string
handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise<void>
handler: (req: SidebarHttpRequest, socket: SidebarUpgradeSocket, head: SidebarUpgradeHead) => void | Promise<void>
}
/** The httpServer service face this plugin uses. */
export interface SidebarHttpServer {
/** The webServer service face this plugin uses. */
export interface SidebarWebServer {
register(route: SidebarWebRoute): () => void
registerUpgrade(route: SidebarWebUpgradeRoute): () => void
}
@@ -48,17 +81,25 @@ export interface SidebarSessionHeader {
/** The host session store face (`ctx.sessions.get(id)` returns the live session). */
export interface SidebarSessionStore {
get(id: string): { header: SidebarSessionHeader } | undefined
get(id: string): {
header: SidebarSessionHeader
/**
* The live session's append-only event log (immutable snapshot; absent
* on sessions the runtime has not hydrated). Read-only access — the
* jobs.output route replays `job_output` tool/result rows from it.
*/
events?: readonly SidebarSessionEvent[]
} | undefined
}
/** One loader entry's options slice (the connection row's resolved config). */
export interface SidebarLoaderEntry {
options: { name: string; config?: unknown }
}
/** The loader face used to read the connection row's trustedHosts config. */
export interface SidebarLoader {
entries(): Iterable<SidebarLoaderEntry>
/**
* The web runtime service face (mirror of @deepseek-ai/dsh-web-app's
* WebRuntimeValues): the bind-derived trust list the /api gateway's fence
* accepts — LAN IP literals sampled when the server binds all interfaces,
* plus explicit `--trusted-host` authorities.
*/
export interface SidebarWebRuntime {
trustedHosts: readonly string[]
}
/** Registration options the sidebar passes to `ctx.slots.register` (subset of the real options). */
@@ -83,7 +124,7 @@ export interface SidebarSlotsService {
register(options: SidebarSlotRegisterOptions, component: unknown): () => void
/**
* Run a callback for each declaration lifetime of a slot (the runtime
* SlotsService.inject): a no-op while the slot is undeclared, so the
* SlotRegistry.inject): a no-op while the slot is undeclared, so the
* settings section registration waits for the settings shell.
*/
inject(key: string, callback: () => () => void): () => void
@@ -150,6 +191,42 @@ export interface SidebarHistoryEntry {
view?: unknown
}
/** Lifecycle status set of one background job (closed wire union). */
export type SidebarJobStatus = 'running' | 'stopping' | 'completed' | 'killed' | 'failed'
/**
* One background job as the client mirror sees it (wire `JobView` shape:
* id/kind/label/status/detail?/startedAt/finishedAt?).
*/
export interface SidebarJobView {
/** Registry-issued `<kind>-N` identity, stable for the job's whole life. */
id: string
/** Producer kind (`bash`, `pwsh`, `subagent`, …; open string by design). */
kind: string
/** Producer-supplied one-line label: the command, or the delegation description. */
label: string
/** Current lifecycle state. */
status: SidebarJobStatus
/** Kind-specific status detail ('exit code: 3'), present once supplied. */
detail?: string
/** Epoch ms when the job was registered. */
startedAt: number
/** Epoch ms when the job settled; absent while live. */
finishedAt?: number
}
/** The host jobs registry face the sidebar routes touch (structural mirror of `JobRegistry`). */
export interface SidebarJobsService {
/** Request cancellation; throws for an unknown or foreign job. */
kill(id: string, caller?: SidebarAgent, reason?: string): 'requested' | 'already-finished'
}
/** The host agent registry face (structural mirror of the runtime `ctx.agents`). */
export interface SidebarAgentsService {
/** The live agent registered under a session id, or undefined when not live. */
get(id: string): SidebarAgent | undefined
}
/** RPC result slot mirror (`RpcResult<T>` on the wire). */
export type SidebarRpcResult<T> = { ok: true; value: T } | { ok: false; error: { code: string; message: string } }
@@ -177,6 +254,12 @@ export interface SidebarSessionList {
byId: Record<string, SidebarSessionSummary>
/** Direct durable catalogs keyed by their selected parent address. */
subagentsByParent?: Readonly<Record<string, SidebarSubagentCatalog>>
/**
* Background jobs per session, last-wins from the harness's `session/jobs`
* push (a missing key is an empty set). Absent on runtime snapshots older
* than the jobs mirror — the sidebar simply shows no job rows.
*/
jobsBySession?: Readonly<Record<string, readonly SidebarJobView[]>>
}
/** The client sessions service face (only the list feed is needed). */
@@ -215,6 +298,23 @@ export interface SidebarSessionsService {
refreshSubagents?(parentSessionId: string): Promise<void>
}
/**
* The client locale service face (mirror of @deepseek-ai/dsh-client-locale's
* LocaleRuntime — only the slices the sidebar touches). The sidebar follows
* the DSH i18n system: the active locale is the Host-backed preference
* (`locale.preference` in settings.yaml) rather than the raw browser
* language, and the sidebar's zh/en dictionaries register into the service's
* namespace registry under `betterSidebar`.
*/
export interface SidebarLocaleService {
/** Current immutable locale snapshot (uSES-safe; `active` is 'zh' | 'en' today). */
getSnapshot(): { active: string }
/** Subscribe to snapshot changes (locale switch or dictionary registration). */
subscribe(fn: () => void): () => void
/** Register one locale's dictionary for a namespace; returns the disposer. */
register(ns: string, locale: string, dict: Record<string, string>): () => void
}
/** The composer draft face the sidebar reaches through `ctx.conversation.input`. */
export interface SidebarSessionInput {
/** The live input store (draft read for append). */
@@ -232,9 +332,20 @@ export interface SidebarConversation {
}
}
/**
* The client workspaces service face (mirror of the runtime IWorkspaces). Only
* the chat's file-open funnel is touched: `openPath` hands an absolute path
* to the Host OS's default application, and every chat-side file open
* (tool rows, produced-files, prose mentions) funnels through it.
*/
export interface SidebarWorkspacesService {
/** Open a filesystem path with the Host operating system's default application. */
openPath(path: string): Promise<void>
}
/**
* The invariant service face (mirror of @deepseek-ai/dsh-invariants'
* InvariantService). The upstream augmentation does not reach this Context
* InvariantRegistry). The upstream augmentation does not reach this Context
* (dual-cordis-instance resolution), so the register signature is restated
* structurally, exactly like the other service faces above.
*/
@@ -246,7 +357,7 @@ export interface SidebarInvariantsService {
): () => void
}
/** The settings service face (mirror of @deepseek-ai/dsh-settings' Settings). */
/** The settings service face (mirror of @deepseek-ai/dsh-settings' SettingsProvider). */
export interface SidebarSettingsService {
/**
* Register one namespace schema (the resolved value layers schema defaults,
@@ -276,7 +387,7 @@ export interface SidebarSettingsService {
}
/**
* The tools service face (mirror of @deepseek-ai/dsh-tools' ToolRegistry).
* The tools service face (mirror of @deepseek-ai/dsh-tools' ToolRuntime).
* The host half registers model-facing tools here; the registry attaches the
* returned disposer to the contributing fiber so unloading unregisters them.
*/
@@ -302,20 +413,46 @@ export interface SidebarAgent {
declare module 'cordis' {
interface Context {
httpServer: SidebarHttpServer
webServer: SidebarWebServer
sessions: SidebarSessionStore & SidebarSessionsService
connection: SidebarConnectionHandle
loader: SidebarLoader
webRuntime: SidebarWebRuntime
slots: SidebarSlotsService
workspaces: SidebarWorkspacesService
settings: SidebarSettingsService
invariants: SidebarInvariantsService
tools: SidebarToolsService
/**
* The client locale service (`@deepseek-ai/dsh-client-locale`): the
* sidebar's copy follows its active locale and registers its
* dictionaries under the `betterSidebar` namespace. Client side only.
*/
locale: SidebarLocaleService
/**
* The host background-job registry (`ctx.get('jobs')`; optional — the
* sidebar routes degrade to a 503 when the deployment lacks it).
*/
jobs: SidebarJobsService
/**
* The host live-agent registry (`ctx.get('agents')`; optional — used to
* resolve the caller the jobs fence compares against).
*/
agents: SidebarAgentsService
/**
* 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
/**
* Subscribe to the session append feed (mirror of the cordis event API):
* the listener receives every appended session event with the LIVE
* Session instance that appended it. The api-proxy pushes the same feed
* to browsers; the sidebar uses it to mirror job_output events the
* session store's own log can lag behind (restart divergence). Returns
* the disposer.
*/
on(event: string, listener: (session: unknown, event: SidebarSessionEvent) => void): () => void
/**
* Register a lifecycle callback (DSH-vendored cordis): runs at plugin
* activation; its returned cleanup runs at disposal.
@@ -324,4 +461,18 @@ declare module 'cordis' {
}
}
/**
* Dual cordis-scope augmentation: DSH's runtime (and the public packages)
* resolve against the vendored `@deepseek-ai/cordis` scope, which DSH's own
* packages already augment with `effect`/`on`/service members. This side only
* adds the sidebar service member so consumers importing `Context` from
* `@deepseek-ai/cordis` (instead of the public `cordis` above) still see
* `ctx.betterSidebar`.
*/
declare module '@deepseek-ai/cordis' {
interface Context {
betterSidebar: BetterSidebarService
}
}
export type { Context }
+51 -8
View File
@@ -2,11 +2,13 @@
* Single-level directory listing for the sidebar explorer. Streams the level
* with opendir, sorts directories first then names (case-insensitive), and
* marks POSIX-hidden entries (dot-prefixed) for dimmed display. Symlinks are
* reported as files without probing their target — the explorer shows what
* dirent says, keeping the read cheap for arbitrarily large levels.
* stat'ed once to expose their target kind — a symlink to a directory
* expands like a directory — and dangling links are flagged broken. The
* probe runs only for entries that are actually symlinks, so levels without
* links stay as cheap as before.
*/
import { opendir } from 'node:fs/promises'
import { basename, dirname, join, resolve } from 'node:path'
import { opendir, stat } from 'node:fs/promises'
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
import { SidebarError } from './wire.ts'
/** One explorer row. */
@@ -15,6 +17,10 @@ export interface SidebarFsEntry {
path: string
isDir: boolean
hidden: boolean
/** Whether the row is a symlink; `isDir` then describes the link's target. */
isSymlink: boolean
/** For symlinks: the target is missing or unreadable (stat failed). */
broken: boolean
}
/** One listed level. */
@@ -52,22 +58,53 @@ export async function listDirectory(path: string, maxEntries = 1000): Promise<Si
overflow += 1
continue
}
// Platform join: on Windows the level path uses '\' — a hardcoded '/'
// would leak mixed separators into every row's path.
rows.push({
name: dirent.name,
// Platform join: on Windows the level path uses '\' — a hardcoded '/'
// would leak mixed separators into every row's path.
path: join(path, dirent.name),
isDir: dirent.isDirectory(),
isSymlink: dirent.isSymbolicLink(),
broken: false,
hidden: dirent.name.startsWith('.'),
})
}
} catch (error) {
throw new SidebarError('fs-error', `cannot list "${path}": ${messageOf(error)}`, 400)
}
// Probe symlink targets AFTER the readdir stream closes, with bounded
// concurrency: a symlink-heavy level (UNC/network targets) would otherwise
// serialize up to maxEntries stat calls and stall the explorer. Non-symlink
// rows are skipped by the probe, so levels without links stay as cheap as
// before.
await probeSymlinkTargets(rows)
rows.sort(compareEntries)
return { path, entries: rows, truncated: overflow > 0 }
}
/** How many symlink target stats run in flight during one level listing. */
const SYMLINK_PROBE_CONCURRENCY = 32
/** Probe each symlink row's target once (bounded concurrency, order-preserving). */
async function probeSymlinkTargets(rows: SidebarFsEntry[], concurrency = SYMLINK_PROBE_CONCURRENCY): Promise<void> {
let next = 0
const workers = Array.from({ length: Math.min(concurrency, rows.length) }, async () => {
for (;;) {
const index = next
next += 1
if (index >= rows.length) return
const row = rows[index]!
if (!row.isSymlink) continue
// stat follows the chain; any failure (missing target, ELOOP, permission)
// leaves the row as a broken file-shaped link the editor refuses to read.
const info = await stat(row.path).catch(() => undefined)
row.isDir = info !== undefined ? info.isDirectory() : row.isDir
row.broken = info === undefined
}
})
await Promise.all(workers)
}
/** The root row label of a listing: the last path segment (or the full path at the filesystem root). */
export function rootLabel(path: string): string {
const base = basename(path)
@@ -80,9 +117,15 @@ export function parentOf(path: string): string | undefined {
return parent === path ? undefined : parent
}
/** Normalize a caller-supplied path to an absolute, resolved path or throw fs-error. */
/**
* Normalize a caller-supplied path to an absolute, resolved path or throw
* fs-error. `path.isAbsolute()` is the OS's own notion of absolute: POSIX
* roots (`/...`), Windows drive letters (`C:\...`) and — on win32 — UNC
* network shares (`\\server\share\...`); drive-relative forms (`C:foo`)
* stay rejected.
*/
export function requireAbsolute(path: string): string {
if (!path.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(path)) {
if (!isAbsolute(path)) {
throw new SidebarError('fs-error', `"${path}" is not an absolute path`, 400)
}
return resolve(path)
+106
View File
@@ -0,0 +1,106 @@
/**
* Pure URL vocabulary of the /sidebar/html route (HTML previewer).
*
* Why path-encoded parameters instead of a query string: the previewed
* page resolves its relative assets (./style.css, img/x.png) against the
* document URL, and the WHATWG URL algorithm DROPS the query of a
* path-relative reference — `/sidebar/html?a=1&path=/a/b/` + `./style.css`
* would lose the session scope and the route would reject the asset.
* Encoding everything into the URL path keeps relative resolution inside
* the same route with every request self-contained:
*
* /sidebar/html/<sessionId>/<absolute-path segments, encodeURIComponent'd>
* /sidebar/html/S/Users/me/proj/index.html
* + ./style.css → /sidebar/html/S/Users/me/proj/style.css
* Windows: C:\Users\me\a.html → /sidebar/html/S/C%3A/Users/me/a.html
* UNC (\\server\share\... or //server/share/...):
* → /sidebar/html/S//server/share/proj/a.html ('//' right after the
* sessionId marks the UNC prefix; the WHATWG URL keeps '//' intact so
* relative assets still resolve inside the same route)
*
* The decoder rebuilds the marker as a forward-slash `//server/share/...`
* path. That form is intentionally platform-neutral: `node:path` resolves it
* to `\\server\share\...` on win32 and `/server/share/...` on POSIX, so the
* host's existing requireAbsolute + isWithin fence needs no platform signal
* (a leading `//` is a legal POSIX absolute path, so no data is lost on
* either platform).
*
* This module is intentionally dependency-free (no node imports, no wire
* helpers) so the client bundle can import `encodeHtmlUrl` without tripping
* the build-time purity gate; the host converts decode failures into
* SidebarError responses at the route boundary.
*/
/** One decoded route reference. */
export interface HtmlRouteRef {
sessionId: string
/** Absolute file path (leading slash; Windows drives keep their colon). */
path: string
}
/** Decode outcome: the reference, or a client-error description. */
export type HtmlDecodeResult =
| { ok: true; ref: HtmlRouteRef }
| { ok: false; status: 400 | 404; message: string }
/** The route prefix both encoders/decoders agree on. */
export const HTML_ROUTE_PREFIX = '/sidebar/html/'
/** Build the route URL for one absolute file path (client + tests). */
export function encodeHtmlUrl(sessionId: string, path: string): string {
const unc = /^[\\/]{2}[^\\/]/.test(path)
const segments = path.split(/[\\/]+/).filter(segment => segment !== '')
return `${HTML_ROUTE_PREFIX}${encodeURIComponent(sessionId)}/${unc ? '/' : ''}${segments.map(encodeURIComponent).join('/')}`
}
/**
* Decode a route pathname into the session + absolute file path. Rejects
* a wrong prefix (404), an empty path, malformed percent encoding, and a
* missing sessionId or file path (400). The caller still must bound the
* decoded path with requireAbsolute + isWithin(cwd) — a decoded `..`
* segment resolves outside the cwd and is refused there.
*/
export function decodeHtmlUrl(pathname: string): HtmlDecodeResult {
if (!pathname.startsWith(HTML_ROUTE_PREFIX)) {
return { ok: false, status: 404, message: 'not an html route' }
}
const rest = pathname.slice(HTML_ROUTE_PREFIX.length)
if (rest === '') {
return { ok: false, status: 400, message: 'invalid html route path' }
}
let segments: string[]
try {
segments = rest.split('/').map(segment => decodeURIComponent(segment))
} catch {
return { ok: false, status: 400, message: 'malformed URL encoding' }
}
const [sessionId, ...pathSegments] = segments
if (sessionId === undefined || sessionId === '') {
return { ok: false, status: 400, message: 'sessionId and file path are required' }
}
// An empty FIRST path segment is the UNC marker (encodeHtmlUrl emits
// '<sid>//server/share/...' for UNC paths); the encoder filters empty
// segments everywhere else, so an empty segment can only be the marker or
// a malformed URL — both handled here.
const unc = pathSegments[0] === ''
const tail = unc ? pathSegments.slice(1) : pathSegments
if (tail.length === 0 || tail.some(segment => segment === '')) {
return { ok: false, status: 400, message: 'sessionId and file path are required' }
}
let path: string
if (unc) {
// Rebuild the platform-neutral forward-slash form `//server/share/...`;
// requireAbsolute() resolves it to the platform's own UNC/POSIX spelling.
path = `//${tail.join('/')}`
} else if (/^[A-Za-z]:$/.test(tail[0] ?? '')) {
// A Windows drive segment ('D:') is the FIRST path segment of an encoded
// drive path. Rejoining it with a leading slash would yield '/D:/work/...'
// which node's path.resolve() mangles into 'D:\D:\work\...' on Windows —
// the html route's isWithin(cwd) fence would then reject every drive path.
// Keep the drive form slash-free so requireAbsolute() resolves it verbatim.
path = tail.join('/')
} else {
path = `/${tail.join('/')}`
}
return { ok: true, ref: { sessionId, path } }
}
+232 -48
View File
@@ -1,11 +1,13 @@
/**
* dsh-better-sidebar host half: the /sidebar JSON API (explorer listing, file
* read/write, git), the /sidebar/file media route (images), and the terminal
* WebSocket upgrade. Every route passes the same browser-trust fence as the
* /api gateway — Host-header loopback or the connection row's `trustedHosts`
* (the `dsh web` launcher derives LAN IP literals per boot) — with the
* trustedHosts read live from the connection loader row so the fence never
* drifts from the deployment's.
* read/write, git), the /sidebar/file media route (images), the /sidebar/html
* preview route, the /sidebar/bundle lazy-chunk route (client code splits),
* and the terminal WebSocket upgrade. Every route passes the same
* browser-trust fence as the /api gateway — Host-header loopback or the
* web runtime's `trustedHosts` (LAN IP literals sampled at boot plus
* `--trusted-host` authorities), read per request from the live service
* value so the fence tracks the same trust source the /api gateway derives
* its list from.
*
* All operations are conversation-scoped: requests carry a sessionId, the
* session's authoritative cwd comes from the session store, and terminal
@@ -14,8 +16,9 @@
import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
import { basename, dirname, extname, isAbsolute, join } from 'node:path'
import type { IncomingMessage } from 'node:http'
import type { Duplex } from 'node:stream'
import { WebSocket, WebSocketServer } from 'ws'
import type { Context } from './context-types.ts'
import type { Context, SidebarHttpRequest } from './context-types.ts'
import {
Config,
PrefsSchema,
@@ -26,12 +29,22 @@ import {
type SidebarPrefs,
} from './config.ts'
import { isWithin, parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts'
import { isTrustedApiRequest } from './trust-fence.ts'
import { decodeHtmlUrl } from './html-route.ts'
import { extractFrameAncestors } from './browser-probe.ts'
import { isTrustedApiRequest, isLoopbackHostname } from './trust-fence.ts'
import { registerBundleRoute } from './bundle-route.ts'
import * as git from './git.ts'
import { SettingsConflictError, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
import { defaultShell, ensureSpawnHelper, PtyManager } from './pty-manager.ts'
import { AgentPtyRegistry, clampDims, type AgentTerminalHandle } from './agent-pty.ts'
import {
DSH_NODE_PTY_RANGE,
depsStatus,
loadNodePty,
PTY_DEPS_MISSING,
} from './pty-deps.ts'
import { registerTools } from './tools.ts'
import { buildJobsApi, type SidebarJobsRoutes } from './jobs-routes.ts'
import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
export { Config }
@@ -53,8 +66,8 @@ export type {
/** Plugin identity for cordis.yml rows. */
export const name = 'dsh-better-sidebar'
/** 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']
/** Services required before mounting: the webserver routes, the session store, the web runtime's trusted hosts, and the tool registry. */
export const inject = ['webServer', 'sessions', 'webRuntime', 'tools']
/** Content types for the media route, by extension. */
const MEDIA_TYPES: Record<string, string> = {
@@ -68,6 +81,8 @@ const MEDIA_TYPES: Record<string, string> = {
'.ico': 'image/x-icon',
'.avif': 'image/avif',
'.pdf': 'application/pdf',
'.html': 'text/html',
'.htm': 'text/html',
}
/** Content type served by /sidebar/file (binary-safe fallback for unknowns). */
@@ -75,17 +90,6 @@ export function mediaTypeForPath(path: string): string {
return MEDIA_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream'
}
/** The connection row's resolved trustedHosts (live read; the /api fence's own list). */
function trustedHostsOf(ctx: Context): string[] {
for (const entry of ctx.loader.entries()) {
if (entry.options.name === 'connection') {
const config = entry.options.config as { trustedHosts?: string[] } | undefined
return config?.trustedHosts ?? []
}
}
return []
}
/**
* Resolve a session's authoritative working directory. The attached session
* header wins; while the session is still hydrating from persistence (the
@@ -181,8 +185,8 @@ export interface SidebarSettingsFace {
/** 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,
ptyManager: PtyManager | null,
agentPtyRegistry: AgentPtyRegistry | null,
resolved: ResolvedSidebarConfig,
getSettings: () => SidebarSettingsFace | undefined,
): Record<string, ApiMethod> {
@@ -192,6 +196,12 @@ function buildApi(
const clientCwd = typeof record?.cwd === 'string' && record.cwd !== '' ? record.cwd : undefined
return { sessionId, cwd: sessionCwdOf(ctx, sessionId, clientCwd) }
}
// Background jobs: the LIST rides the harness's `session/jobs` push
// mirror, so these routes only replay output the model has read (from the
// session's own event log — no DSH source is touched, the model's
// job_output cursor is never consumed) and kill (the registry's stock
// API). A deployment without the jobs registry downgrades kill to a 503.
const jobsApi: SidebarJobsRoutes = buildJobsApi(ctx, resolved.readLimit)
return {
'session.cwd': (payload) => {
const { sessionId, cwd } = cwdOf(payload)
@@ -309,7 +319,9 @@ function buildApi(
'pty.close': (payload) => {
const sessionId = requireString(payload, 'sessionId')
const tab = requireString(payload, 'tab')
ptyManager.close(`${sessionId}:${tab}`)
// Degraded mode (node-pty unavailable): no live pty can exist, so a
// no-op ok is the honest answer — never an error the client must show.
ptyManager?.close(`${sessionId}:${tab}`)
return { ok: true }
},
// Release an agent terminal by uuid. The WS close frame already does
@@ -318,9 +330,22 @@ function buildApi(
// tab never leaves a zombie pty behind. Idempotent.
'agent-pty.close': (payload) => {
const uuid = requireString(payload, 'uuid')
agentPtyRegistry.close(uuid)
agentPtyRegistry?.close(uuid)
return { ok: true }
},
// Terminal dependency status (issue #140): after a WS close 1011 with
// reason `pty-deps-missing` the client fetches the full repair details
// here — the close reason itself is capped at 123 bytes, too small for
// the pasteable command.
'terminal.deps': () => depsStatus(),
// Background jobs: read one job's output (a REPLAY of what the model
// has read so far, from the owner session's event log — the model's
// job_output cursor is never touched, so the human pane can never steal
// the agent's bytes), and kill one job. The job LIST itself arrives
// through the harness's session/jobs push mirror, so no list route
// exists. Kill is fenced to the owning session by the jobs registry.
'jobs.output': (payload) => jobsApi.output(payload),
'jobs.kill': (payload) => jobsApi.kill(payload),
// The 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
@@ -350,12 +375,61 @@ function buildApi(
throw new SidebarError('settings-rejected', error instanceof Error ? error.message : String(error), 400)
}
},
// Probe a URL's RESPONSE HEADERS so the sidebar browser can explain an
// iframe refusal: X-Frame-Options / CSP frame-ancestors are exactly the
// signals the browser enforces when it refuses to embed a site. The
// probe is display-only (headers back to the caller), restricted to
// http(s) non-loopback URLs with a hard timeout, and gated by the same
// trust fence as every other route — a cross-site page cannot reach it.
'browser.probe': async (payload) => {
const raw = requireString(payload, 'url')
let parsed: URL
try {
parsed = new URL(raw)
} catch {
throw new SidebarError('bad-request', 'invalid url', 400)
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new SidebarError('bad-request', 'only http/https urls can be probed', 400)
}
// Mirror the browser tab's address-bar policy: loopback stays unreachable
// from the sidebar, so probing it would leak nothing the tab could use.
if (isLoopbackHostname(parsed.hostname)) {
throw new SidebarError('bad-request', 'local addresses are not probed', 400)
}
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 8000)
try {
let response = await fetch(parsed, { method: 'HEAD', redirect: 'follow', signal: controller.signal })
// Some servers answer HEAD with 405/501; retry once as GET (the
// body is discarded — only the headers matter).
if (response.status === 405 || response.status === 501) {
response = await fetch(parsed, { method: 'GET', redirect: 'follow', signal: controller.signal })
}
const csp = response.headers.get('content-security-policy')
const frameAncestors = extractFrameAncestors(csp)
const xFrameOptions = response.headers.get('x-frame-options')
return {
reachable: true,
url: response.url,
status: response.status,
...(xFrameOptions !== null ? { xFrameOptions } : {}),
...(frameAncestors !== undefined ? { frameAncestors } : {}),
}
} catch {
// DNS / TLS / connection / timeout: nothing to judge — the client
// keeps the plain iframe.
return { reachable: false }
} finally {
clearTimeout(timer)
}
},
}
}
/**
* Plugin body: mount the fenced routes and the pty lifecycle.
* @param ctx - host plugin context (httpServer, sessions, loader).
* @param ctx - host plugin context (webServer, sessions, webRuntime).
* @param config - deployment-provided limits; the Loader validates against
* {@link Config} and fills defaults, direct callers get them from
* {@link resolveSidebarConfig}.
@@ -365,15 +439,35 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
// restore it before any terminal can spawn (idempotent).
ensureSpawnHelper()
const resolved = resolveSidebarConfig(config)
const trustedHosts = trustedHostsOf(ctx)
const fence = (req: IncomingMessage): boolean => isTrustedApiRequest(req, trustedHosts)
const ptyManager = new PtyManager(defaultShell(), resolved.terminalsPerSession)
// One shell resolution feeds BOTH terminal surfaces: the UI tabs and the
// model-facing terminal_* tools. They must stay in lockstep, otherwise a
// configured shell fixes one surface and silently leaves the other on the
// platform default.
const terminalShell = defaultShell({ explicit: resolved.shell })
// The web runtime's bind-derived trust list (boot-sampled LAN literals
// plus --trusted-host authorities) — the authoritative source the /api
// gateway fence derives its list from. Read per request from the live
// service value; a replaced list takes effect without a plugin restart.
const fence = (req: SidebarHttpRequest): boolean => isTrustedApiRequest(req, ctx.webRuntime.trustedHosts)
// node-pty is loaded lazily, never at module top level (issue #140): a
// missing or broken install must degrade THIS plugin — terminal tab shows
// a repair command, agent terminal tools stay unregistered — instead of
// failing the plugin load and taking the whole `dsh web` server down.
const nodePty = loadNodePty()
if (nodePty === null) {
const status = depsStatus()
const detail = status.ok
? 'unknown cause'
: `${status.cause}. Repair: ${status.command}`
ctx.logger?.warn(`[dsh-better-sidebar] node-pty (${DSH_NODE_PTY_RANGE}) failed to load: ${detail}`)
}
const ptyManager = nodePty !== null ? new PtyManager(terminalShell, resolved.terminalsPerSession, nodePty) : null
// 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())
const agentPtyRegistry = nodePty !== null ? new AgentPtyRegistry(terminalShell, nodePty) : null
// ── User-facing "Side card" preferences ──────────────────────────────────
// Register the namespace with the settings provider so the Settings page
@@ -392,6 +486,9 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
const syncToolsGate = (scope: { get(): SidebarPrefs }): void => {
if (scope.get().agentTerminalTools) {
if (toolsDisposers === null) {
// Degraded mode (node-pty unavailable): never register the terminal
// tools — every one of them would fail at spawn time.
if (agentPtyRegistry === null) return
toolsDisposers = registerTools(ctx, agentPtyRegistry, (sessionId) => sessionCwdOf(ctx, sessionId))
}
} else if (toolsDisposers !== null) {
@@ -400,7 +497,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
// 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()
agentPtyRegistry?.disposeAll()
}
}
ctx.inject(['settings'], (sctx) => {
@@ -433,7 +530,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
// ── JSON API ────────────────────────────────────────────────────────────
const api = buildApi(ctx, ptyManager, agentPtyRegistry, resolved, () => settingsFace)
ctx.effect(() => ctx.httpServer.register({
ctx.effect(() => ctx.webServer.register({
kind: 'prefix',
path: '/sidebar/api',
handler: async (req, res) => {
@@ -464,8 +561,14 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
},
}), 'dsh-better-sidebar: /sidebar/api routes')
// ── Lazy chunk route (client bundle splits) ─────────────────────────────
// Serves the client half's split bundles (lib/client-<name>.js) so the
// heavy preview/terminal libraries load on first use, not at page start
// (see bundle-route.ts / src/client/chunk-loader.ts).
ctx.effect(() => registerBundleRoute(ctx, fence), 'dsh-better-sidebar: /sidebar/bundle chunk route')
// ── Media route (images for the editor) ─────────────────────────────────
ctx.effect(() => ctx.httpServer.register({
ctx.effect(() => ctx.webServer.register({
kind: 'prefix',
path: '/sidebar/file',
handler: async (req, res) => {
@@ -513,6 +616,70 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
},
}), 'dsh-better-sidebar: /sidebar/file media route')
// ── HTML preview route (sandboxed HTML + its relative assets) ───────────
// Serves files under the session cwd for the built-in HTML previewer. The
// URL is path-encoded (see html-route.ts) so the previewed page's relative
// assets (./style.css, img/x.png) resolve back into this route with the
// session scope intact — a query-encoded URL would drop the scope when the
// browser resolves relatives. Every response carries the CSP `sandbox`
// directive: inside the editor's iframe the sandbox ATTRIBUTE is the
// boundary, this header is defense-in-depth so even a top-level load of
// the URL (e.g. a popup opened by a previewed page) stays in an opaque
// origin with no same-origin access to the GUI.
ctx.effect(() => ctx.webServer.register({
kind: 'prefix',
path: '/sidebar/html',
handler: async (req, res) => {
if (!fence(req)) {
res.writeHead(403)
res.end('forbidden')
return
}
if (req.method !== 'GET') {
res.writeHead(405)
res.end()
return
}
try {
const url = new URL(req.url ?? '/', 'http://dsh.internal')
const decoded = decodeHtmlUrl(url.pathname)
if (!decoded.ok) {
writeError(res, new SidebarError('bad-request', decoded.message, decoded.status))
return
}
const { sessionId, path } = decoded.ref
// The session's authoritative cwd (client cwd cannot ride in the URL
// — the path encoding has no query; a detached first request falls
// back to the process cwd and is normally refused by isWithin, same
// semantics as the media route's fallback).
const cwd = sessionCwdOf(ctx, sessionId)
const absolute = requireAbsolute(path)
if (!isWithin(cwd, absolute)) {
throw new SidebarError('fs-error', 'html path outside the session working directory', 403)
}
const info = await stat(absolute)
if (!info.isFile() || info.size > resolved.mediaLimit) {
throw new SidebarError('fs-error', 'not a file or too large', 400)
}
const type = mediaTypeForPath(absolute)
const body = await readFile(absolute)
res.writeHead(200, {
'content-type': type,
'cache-control': 'no-cache',
'x-content-type-options': 'nosniff',
'referrer-policy': 'no-referrer',
// The sandbox directive (no allow-same-origin → opaque origin) is
// the previewer's security boundary even for top-level loads;
// object-src 'none' blocks plugin embeds.
'content-security-policy': "sandbox allow-scripts allow-popups allow-downloads allow-modals; object-src 'none'",
})
res.end(body)
} catch (error) {
writeError(res, error)
}
},
}), 'dsh-better-sidebar: /sidebar/html preview route')
// ── Terminal WebSocket ──────────────────────────────────────────────────
// One upgrade endpoint serves both UI-tab terminals (?tab=...) and
// agent-owned terminals (?uuid=...). The two paths attach to different
@@ -522,14 +689,16 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
// 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({
ctx.effect(() => ctx.webServer.registerUpgrade({
path: '/sidebar/ws/terminal',
handler: (req, socket, head) => {
if (!fence(req)) {
socket.destroy()
return
}
wss.handleUpgrade(req, socket, head, (ws) => {
// The structural request/socket/head faces satisfy the shared fence;
// the `ws` package wants the real Node types — cast at this boundary.
wss.handleUpgrade(req as unknown as IncomingMessage, socket as unknown as Duplex, head as Buffer, (ws) => {
void attachTerminal(ctx, ptyManager, agentPtyRegistry, ws, req, resolved)
})
},
@@ -544,14 +713,14 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
// 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({
ctx.effect(() => ctx.webServer.registerUpgrade({
path: '/sidebar/ws/agent-terminals',
handler: (req, socket, head) => {
if (!fence(req)) {
socket.destroy()
return
}
agentListWss.handleUpgrade(req, socket, head, (ws) => {
agentListWss.handleUpgrade(req as unknown as IncomingMessage, socket as unknown as Duplex, head as Buffer, (ws) => {
void attachAgentList(agentPtyRegistry, ws, req)
})
},
@@ -559,8 +728,8 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
ctx.effect(() => () => {
toolsDisposers?.()
ptyManager.disposeAll()
agentPtyRegistry.disposeAll()
ptyManager?.disposeAll()
agentPtyRegistry?.disposeAll()
wss.close()
agentListWss.close()
}, 'dsh-better-sidebar: teardown')
@@ -568,9 +737,9 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
/** Push the live agent-terminal list for one session to a connected sidebar view. */
async function attachAgentList(
registry: AgentPtyRegistry,
registry: AgentPtyRegistry | null,
ws: WebSocket,
req: IncomingMessage,
req: SidebarHttpRequest,
): Promise<void> {
try {
const url = new URL(req.url ?? '/', 'http://dsh.internal')
@@ -581,13 +750,15 @@ async function attachAgentList(
}
const send = (): void => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(registry.list(sessionId)))
// Degraded mode (node-pty unavailable): no agent terminal can exist,
// so the honest push is the empty list.
ws.send(JSON.stringify(registry?.list(sessionId) ?? []))
}
}
send()
const unsubscribe = registry.subscribe(send)
ws.on('close', () => { unsubscribe() })
ws.on('error', () => { unsubscribe() })
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))
}
@@ -607,16 +778,22 @@ async function attachAgentList(
*/
async function attachTerminal(
ctx: Context,
ptyManager: PtyManager,
agentPtyRegistry: AgentPtyRegistry,
ptyManager: PtyManager | null,
agentPtyRegistry: AgentPtyRegistry | null,
ws: WebSocket,
req: IncomingMessage,
req: SidebarHttpRequest,
resolved: ResolvedSidebarConfig,
): Promise<void> {
try {
const url = new URL(req.url ?? '/', 'http://dsh.internal')
const uuid = url.searchParams.get('uuid')
if (uuid !== null) {
// Degraded mode (node-pty unavailable): no agent terminal can exist,
// so the lookup behaves exactly like a missing uuid.
if (agentPtyRegistry === null) {
ws.close(1011, `agent terminal "${uuid}" not found`)
return
}
const handle = agentPtyRegistry.get(uuid)
if (handle === undefined) {
ws.close(1011, `agent terminal "${uuid}" not found`)
@@ -631,6 +808,13 @@ async function attachTerminal(
ws.close(1008, 'either ?uuid or ?sessionId+?tab are required')
return
}
if (ptyManager === null) {
// Degraded mode (issue #140): node-pty unavailable. The close reason
// is a SHORT marker — a WS close reason is capped at 123 bytes, so the
// client fetches the full repair command from /sidebar/api/terminal.deps.
ws.close(1011, PTY_DEPS_MISSING)
return
}
const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
const handle = ptyManager.open(sessionId, tabId, cwd, 80, 24)
// Replay the transcript, then follow live output.
+1 -1
View File
@@ -15,7 +15,7 @@ export const inject = ['invariants']
/**
* No runtime invariant: the sidebar owns no service state or event protocol
* of its own — every route is mounted under the host's httpServer fence, the
* of its own — every route is mounted under the host's webServer fence, the
* pty lifecycle is exercised by the smoke spec, and the panel's store is a
* plain snapshot registry asserted by the behavior specs. The route fence,
* pty quota, and store semantics are each observed through their seams.
+257
View File
@@ -0,0 +1,257 @@
/**
* Background-job routes of the /sidebar JSON API ('jobs.output' /
* 'jobs.kill'). The job LIST needs no route: it arrives through the
* harness's `session/jobs` push mirror (`jobsBySession` in the sessions
* list feed). The routes:
*
* - 'jobs.output' — REPLAYS the output the MODEL has read so far for one
* job. The source is the owner session's own event log: `tool/call` rows
* of `job_output` name the job via `arguments.job_id`, and the paired
* `tool/result` rows carry the finalized content the model received.
* Because the session store's in-memory log can lag the live append feed
* after a host restart (the store session stays frozen at its
* rehydration boundary), the plugin ALSO mirrors job_output events from
* the live `session/event` feed and merges both sources (deduped by seq).
* This touches NO DSH source: the model's `job_output` cursor is never
* consumed, and the pane stays empty until the agent reads the job.
* - 'jobs.kill' — the registry's stock `kill` (a pristine DSH API),
* fenced by the owning session via the live agent caller. Absent registry
* → 503, mirroring the settings routes' optional-service downgrade.
*/
import type { Context, SidebarSessionEvent } from './context-types.ts'
import { requireString, SidebarError } from './wire.ts'
/** The two background-job routes of the sidebar API. */
export interface SidebarJobsRoutes {
/** The output the model has read so far for one job (event replay, capped). */
output(payload: unknown): { text: string; truncated: boolean; read: boolean }
/** Request cancellation of one job (live jobs flip to stopping). */
kill(payload: unknown): { ok: true; outcome: 'requested' | 'already-finished' }
}
/** The 'tool/result' message envelope inside a session event's data. */
interface ToolResultMessageLike {
source?: { kind?: unknown; callId?: unknown }
content?: unknown
}
/** One 'tool-result' content block (the inner blocks carry the text). */
interface ToolResultBlockLike {
type?: unknown
content?: unknown
isError?: unknown
}
/**
* Extract the plain text of a finalized tool result: the text blocks inside
* the 'tool-result' block, joined with newlines. Error results and
* non-text blocks contribute nothing.
*/
function resultText(message: ToolResultMessageLike): string | undefined {
if (!Array.isArray(message.content)) return undefined
const parts: string[] = []
for (const block of message.content) {
if (block === null || typeof block !== 'object') continue
const candidate = block as ToolResultBlockLike
if (candidate.type !== 'tool-result') continue
const inner = candidate.content
if (!Array.isArray(inner)) continue
for (const item of inner) {
if (item === null || typeof item !== 'object') continue
const textItem = item as { type?: unknown; text?: unknown }
if (textItem.type === 'text' && typeof textItem.text === 'string') {
parts.push(textItem.text)
}
}
}
return parts.length > 0 ? parts.join('\n') : undefined
}
/** Whether a tool/result is an error result (the inner block's isError flag). */
function resultIsError(message: ToolResultMessageLike): boolean {
if (!Array.isArray(message.content)) return false
return message.content.some((block) => {
if (block === null || typeof block !== 'object') return false
return (block as ToolResultBlockLike).type === 'tool-result'
&& (block as ToolResultBlockLike).isError === true
})
}
/** Whether a job_output result carries no new output — the controller's
* model-facing "(no new output)" body, noise for the human pane. */
function isNoNewOutput(text: string): boolean {
return text.startsWith('(no new output)')
}
/** One compact job_output trace (a tool/call or its paired tool/result). */
interface JobOutputTrace {
seq: number
kind: 'call' | 'result'
/** The tool call identity pairing the two rows. */
callId: string
/** tool/call: the job id parsed from the model arguments. */
jobId?: string
/** tool/result: the finalized text the model received. */
text?: string
/** tool/result: whether the result was an error (read counts, text skipped). */
isError?: boolean
}
/** Extract the job_output trace of one raw session event (undefined = unrelated). */
function traceOf(event: SidebarSessionEvent): JobOutputTrace | undefined {
if (event.type === 'tool/call') {
const data = event.data as { name?: unknown; callId?: unknown; arguments?: unknown }
if (data.name !== 'job_output' || typeof data.callId !== 'string') return undefined
let jobId: string | undefined
try {
const args = JSON.parse(typeof data.arguments === 'string' ? data.arguments : '') as { job_id?: unknown }
if (typeof args.job_id === 'string') jobId = args.job_id
} catch {
// Malformed model arguments: not a job_output pair.
}
if (jobId === undefined) return undefined
return { seq: event.seq, kind: 'call', callId: data.callId, jobId }
}
if (event.type === 'tool/result') {
const message = (event.data as { message?: unknown }).message as ToolResultMessageLike | undefined
if (message === undefined) return undefined
const callId = message.source?.callId
if (typeof callId !== 'string') return undefined
return {
seq: event.seq,
kind: 'result',
callId,
text: resultText(message),
isError: resultIsError(message),
}
}
return undefined
}
/** Per-session cap of mirrored live traces (a bounded, lossy ring). */
const MIRROR_MAX_ENTRIES = 200
/**
* The live job_output mirror: subscribes to the session append feed and
* caches the job_output traces the session store's own log can lag behind
* (after a host restart the store session stays frozen at its rehydration
* boundary, so `session.events` misses everything appended since — the very
* reads the pane exists to show). Zero DSH writes: the api-proxy pushes the
* same feed to browsers.
*/
function createJobOutputMirror(ctx: Context): { entries(sessionId: string): readonly JobOutputTrace[] } {
const perSession = new Map<string, JobOutputTrace[]>()
// tool/call identities per session, so unrelated tool/result rows are
// never cached (only job_output results pair with a cached call).
const callIds = new Map<string, Set<string>>()
if (typeof ctx.on !== 'function') {
// Test doubles without the event API degrade to seed-only replay.
return { entries: () => [] }
}
const dispose = ctx.on('session/event', (session, event) => {
const sessionId = (session as { id?: unknown } | null)?.id
if (typeof sessionId !== 'string') return
if (event.type === 'tool/call') {
const trace = traceOf(event)
if (trace?.kind !== 'call') return
let ids = callIds.get(sessionId)
if (ids === undefined) callIds.set(sessionId, ids = new Set())
ids.add(trace.callId)
push(sessionId, trace)
} else if (event.type === 'tool/result') {
const trace = traceOf(event)
if (trace?.kind !== 'result') return
if (!callIds.get(sessionId)?.has(trace.callId)) return
push(sessionId, trace)
}
})
ctx.effect(() => dispose, 'dsh-better-sidebar: job-output event mirror')
const push = (sessionId: string, trace: JobOutputTrace): void => {
let list = perSession.get(sessionId)
if (list === undefined) perSession.set(sessionId, list = [])
list.push(trace)
if (list.length > MIRROR_MAX_ENTRIES) {
const removed = list.splice(0, list.length - MIRROR_MAX_ENTRIES)
const ids = callIds.get(sessionId)
if (ids !== undefined) {
for (const entry of removed) {
if (entry.kind === 'call') ids.delete(entry.callId)
}
if (ids.size === 0) callIds.delete(sessionId)
}
}
}
return { entries: (sessionId) => perSession.get(sessionId) ?? [] }
}
/**
* Build the jobs routes bound to the plugin context. `output` merges the
* owner session's own event log with the live job_output mirror; `kill`
* reads the jobs/agents services lazily and degrades to a 503 when the
* deployment lacks the registry.
* @param ctx - host plugin context.
* @param outputLimit - response cap for one output replay in bytes; longer
* texts are sliced and flagged `truncated` (mirrors the fs.read cap).
*/
export function buildJobsApi(ctx: Context, outputLimit: number): SidebarJobsRoutes {
const jobs = ctx.get('jobs')
const agents = ctx.get('agents')
const mirror = createJobOutputMirror(ctx)
/** The live caller whose session id the registry fence compares against. */
const callerOf = (sessionId: string) => agents?.get(sessionId)
/** Registry refusals become a 404 job-error; unknown and foreign ids are indistinguishable. */
const registryError = (error: unknown): SidebarError =>
new SidebarError('job-error', error instanceof Error ? error.message : String(error), 404)
return {
output(payload) {
const sessionId = requireString(payload, 'sessionId')
const id = requireString(payload, 'id')
// Merge the store's event log (durable seed + whatever it received)
// with the live mirror, deduped by seq — a trace never double-counts.
const bySeq = new Map<number, JobOutputTrace>()
for (const event of ctx.sessions.get(sessionId)?.events ?? []) {
const trace = traceOf(event)
if (trace !== undefined) bySeq.set(trace.seq, trace)
}
for (const trace of mirror.entries(sessionId)) bySeq.set(trace.seq, trace)
// Pair calls with results in seq order: the model's reads, oldest first.
const jobOf = new Map<string, string>()
const parts: string[] = []
let read = false
for (const trace of [...bySeq.values()].sort((left, right) => left.seq - right.seq)) {
if (trace.kind === 'call') {
if (trace.jobId !== undefined) jobOf.set(trace.callId, trace.jobId)
} else if (jobOf.get(trace.callId) === id) {
read = true
if (trace.isError !== true && trace.text !== undefined && !isNoNewOutput(trace.text)) {
parts.push(trace.text)
}
}
}
const text = parts.join('\n')
return {
text: text.length > outputLimit ? text.slice(0, outputLimit) : text,
truncated: text.length > outputLimit,
read,
}
},
kill(payload) {
if (jobs === undefined) {
throw new SidebarError('job-error', 'the background-job registry is not mounted in this deployment', 503)
}
const sessionId = requireString(payload, 'sessionId')
const id = requireString(payload, 'id')
const record = payload as { reason?: unknown } | null
const reason = typeof record?.reason === 'string' && record.reason !== ''
? record.reason
: 'user requested via sidebar'
try {
return { ok: true, outcome: jobs.kill(id, callerOf(sessionId), reason) }
} catch (error) {
throw registryError(error)
}
},
}
}
+141
View File
@@ -20,6 +20,12 @@ export interface SidebarPrefs {
* Subagent page when the current conversation spawns a new subagent.
*/
autoOpenSubagent: boolean
/**
* Whether the sidebar auto-activates (opens the panel) and expands the
* Jobs page when a NEW background job appears for the current
* conversation (any new job id, not just the first one).
*/
autoOpenJobs: boolean
/**
* Whether the model-facing agent terminal tools (terminal_create / list /
* send / read / wait_for / resize / signal / close) are injected into the
@@ -27,6 +33,98 @@ export interface SidebarPrefs {
* user explicitly enables it in the side card settings.
*/
agentTerminalTools: boolean
/**
* Custom terminal font-family stack (a CSS font-family value, e.g.
* `'JetBrains Mono', monospace`). Empty string follows the app's theme
* monospace font (`--ds-font-family-code`). Applied live to every
* terminal tab; configured under the terminal card's secondary settings.
*/
terminalFontFamily: string
/**
* Custom terminal font size in px (9–32). Applied live to every terminal
* tab; configured under the terminal card's secondary settings.
*/
terminalFontSize: number
/**
* Whether expanding the bottom panel for the FIRST time in a session tries
* to open a fresh terminal tab there (the terminal quota/type still gates
* the attempt). On by default; the switch lives under the terminal tab's
* row in the Side card settings.
*/
bottomPanelAutoTerminal: boolean
/**
* Whether chat-side file opens (tool-row path links, the produced-files
* row, prose file mentions — every path that funnels through the client
* runtime's `ctx.workspaces.openPath`) open in the sidebar editor instead
* of the Host OS's default application. On by default; the editor tab's
* own enable switch gates it too (both must be on for the takeover).
*/
interceptOpenPath: boolean
/**
* Position compatibility mode: reserves space at the top for the native
* Windows title bar (drawn at the window's top-right corner over the web
* content in frameless/hidden-title-bar windows). When on, the toggle
* cluster drops below the strip and the right panel's content starts
* below it. Off by default — the sidebar layout is untouched.
*/
titleBarCompat: boolean
/**
* The reserved top strip height in px when `titleBarCompat` is on
* (0–120, default 40). Drives the `--dsh-title-bar-strip` CSS variable:
* the toggle cluster drops `strip + 3px` and the right panel's content
* starts `strip` px below its top edge.
*/
titleBarStripPx: number
/**
* Whether the HTML previewer drops its sandboxed iframe. Sandbox ON (the
* default) renders previewed HTML in an opaque-origin iframe that cannot
* touch the GUI; turning it OFF runs the previewed page with the GUI's
* own origin — full read/write access to session files and internal
* APIs. Only for trusted local content; the setting copy warns.
*/
htmlViewerNoSandbox: boolean
/**
* Whether a newly opened HTML preview starts UNSANDBOXED (the per-surface
* temporary unlock pre-applied). Off by default: previews open sandboxed
* and the status row offers the one-tap unlock; when on, previews open
* in the red unsandboxed state and the status row offers a one-tap
* restore for the current file.
*/
htmlViewerDefaultUnsafe: boolean
/**
* Whether the browser tab drops its sandboxed iframe. Sandbox ON (the
* default) keeps browsed sites in an opaque origin with no GUI access;
* turning it OFF runs any visited site with the GUI's own origin — it
* can read session data and act as the logged-in GUI. Only for trusted
* sites; the setting copy warns.
*/
browserNoSandbox: boolean
/**
* MASTER switch: whether clicking an EXTERNAL link in the GUI (chat
* messages, tool rows, prose mentions) is taken over into the sidebar at
* all. On by default; the per-protocol granularity lives in
* `browserInterceptHttp` / `browserInterceptHttps` (the protocol flag
* must also be on), and the target tab's own enable switch gates it too.
* Ctrl/Cmd+click always bypasses the takeover. Kept as the master so old
* documents keep their meaning with no migration (an explicit `false`
* stays "never take over").
*/
browserInterceptLinks: boolean
/**
* Whether clicking an http EXTERNAL link in the GUI opens the sidebar
* (the built-in browser tab, or a plugin tab that declares `urlTarget`)
* instead of a new browser tab. On by default; gated on the
* `browserInterceptLinks` master and the target tab's own enable switch.
*/
browserInterceptHttp: boolean
/**
* Whether clicking an https EXTERNAL link in the GUI opens the sidebar
* instead of a new browser tab. OFF by default — most https sites (e.g.
* GitHub) refuse iframe embedding, so the system browser is the smoother
* default; gated on the `browserInterceptLinks` master and the target
* tab's own enable switch.
*/
browserInterceptHttps: boolean
/**
* Per-tab enable switches, keyed by tab descriptor id (`'explorer'`,
* `'my-plugin:db'`). An ABSENT key means enabled — only an explicit
@@ -44,6 +142,15 @@ export interface SidebarPrefs {
* next matching viewer (or the download button when none match).
*/
viewersEnabled: Record<string, boolean>
/**
* Plugin-owned settings blobs (v0.12.0+), keyed by descriptor id: each
* registered tab/viewer that declares `settings.pluginToggles` (or writes
* through `settings.render`'s `updatePluginSetting`) persists its values
* here — an open map, so third-party keys need no host PrefsSchema field.
* Values are JSON-serializable (the row controls produce strings /
* numbers / booleans; custom panels are responsible for their own).
*/
pluginSettings: Record<string, Record<string, unknown>>
}
/** Range contract of {@link SidebarPrefs.defaultWidthPercent}. */
@@ -51,17 +158,51 @@ export const WIDTH_PERCENT_MIN = 20
export const WIDTH_PERCENT_MAX = 60
export const WIDTH_PERCENT_DEFAULT = 30
/** Range contract of {@link SidebarPrefs.terminalFontSize}. */
export const TERMINAL_FONT_SIZE_MIN = 9
export const TERMINAL_FONT_SIZE_MAX = 32
export const TERMINAL_FONT_SIZE_DEFAULT = 13
/** Range contract of {@link SidebarPrefs.titleBarStripPx}. */
export const TITLE_BAR_STRIP_MIN = 0
export const TITLE_BAR_STRIP_MAX = 120
export const TITLE_BAR_STRIP_DEFAULT = 40
/** Fallback prefs used whenever the settings document is unreachable or malformed. */
export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
openByDefault: true,
defaultWidthPercent: WIDTH_PERCENT_DEFAULT,
autoOpenSubagent: true,
autoOpenJobs: true,
agentTerminalTools: false,
bottomPanelAutoTerminal: true,
terminalFontFamily: '',
terminalFontSize: TERMINAL_FONT_SIZE_DEFAULT,
interceptOpenPath: true,
titleBarCompat: false,
titleBarStripPx: TITLE_BAR_STRIP_DEFAULT,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
browserNoSandbox: false,
browserInterceptLinks: true,
browserInterceptHttp: true,
browserInterceptHttps: false,
tabsEnabled: {},
viewersEnabled: {},
pluginSettings: {},
}
/** Clamp one width percent into the contract range (shared by schema and client reads). */
export function clampWidthPercent(value: number): number {
return Math.min(WIDTH_PERCENT_MAX, Math.max(WIDTH_PERCENT_MIN, Math.round(value)))
}
/** Clamp one terminal font size into the contract range (shared by schema and client reads). */
export function clampTerminalFontSize(value: number): number {
return Math.min(TERMINAL_FONT_SIZE_MAX, Math.max(TERMINAL_FONT_SIZE_MIN, Math.round(value)))
}
/** Clamp one title-bar strip height into the contract range (shared by schema and client reads). */
export function clampTitleBarStrip(value: number): number {
return Math.min(TITLE_BAR_STRIP_MAX, Math.max(TITLE_BAR_STRIP_MIN, Math.round(value)))
}
+240
View File
@@ -0,0 +1,240 @@
/**
* node-pty dependency loading for the host half (issue #140, plugin side).
*
* The terminal surfaces (UI tabs + model-facing terminal_* tools) need
* node-pty, but the package must NEVER be imported statically at module
* top level: a missing or broken install (pnpm 11's strict-dep-builds
* skipping node-pty's install script, a pruned store entry, a failed
* prebuilt-binary download…) would then fail the plugin module load and —
* because a loader entry apply failure aborts the boot — take the whole
* `dsh web` server down with it.
*
* Instead the host half loads node-pty lazily (synchronously, via
* createRequire — the same resolution `ensureSpawnHelper` already uses in
* production). When the load fails the plugin stays mounted in a degraded
* state: the terminal tab shows a friendly error carrying a pasteable
* repair command (see scripts/install.sh / install.ps1 `--repair`), and the
* agent terminal tools are simply not registered.
*
* Version contract: the plugin must stay in sync with DSH core —
* `@deepseek-ai/dsh-subprocess-local` declares `"node-pty": "^1.1.0"` in
* its `dependencies`. Both sides then resolve the SAME pnpm store entry
* (same range, same integrity → one native binding, no drift). Do NOT
* switch to a fork (e.g. @lydell/node-pty) or a different range without
* re-checking the core declaration.
*/
import { existsSync, readFileSync, realpathSync } from 'node:fs'
import { createRequire } from 'node:module'
import { homedir } from 'node:os'
import { basename, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type * as nodePtyNs from 'node-pty'
import { SidebarError } from './wire.ts'
/** The node-pty module surface the registries consume (spawn/kill/resize/…). */
export type NodePtyModule = typeof nodePtyNs
/**
* The node-pty version range this plugin ships. MUST stay identical to the
* range DSH core declares (`@deepseek-ai/dsh-subprocess-local`): the same
* range keeps pnpm resolving both to one physical package.
*/
export const DSH_NODE_PTY_RANGE = '^1.1.0'
/**
* The WebSocket close-code-1011 reason the host sends when node-pty is
* unavailable. The client recognizes this exact marker and fetches the full
* repair details from `/sidebar/api/terminal.deps` (a WS close reason is
* capped at 123 bytes, so the command itself cannot ride the close frame).
*/
export const PTY_DEPS_MISSING = 'pty-deps-missing'
/** A require-compatible loader, injectable for tests. */
export type NodePtyRequire = (id: string) => unknown
const defaultRequire: NodePtyRequire = createRequire(import.meta.url)
type LoadResult = { ok: true; module: NodePtyModule } | { ok: false; cause: unknown }
let cached: LoadResult | undefined
/**
* Load node-pty once (synchronously) and cache the outcome. Returns null
* when the package or its native binding cannot be loaded; the cause stays
* queryable through {@link nodePtyLoadCause}. Never throws.
*/
export function loadNodePty(requireImpl: NodePtyRequire = defaultRequire): NodePtyModule | null {
if (cached === undefined) {
try {
cached = { ok: true, module: requireImpl('node-pty') as NodePtyModule }
} catch (cause) {
cached = { ok: false, cause }
}
}
return cached.ok ? cached.module : null
}
/** The recorded load failure (undefined when the load succeeded or never ran). */
export function nodePtyLoadCause(): unknown {
return cached !== undefined && !cached.ok ? cached.cause : undefined
}
/** Forget the cached outcome (tests only — a real reload is otherwise one-shot). */
export function resetNodePtyCache(): void {
cached = undefined
}
/** Load node-pty or throw the canonical degraded-mode error (class-constructor default). */
export function loadRequiredNodePty(): NodePtyModule {
const module = loadNodePty()
if (module === null) {
const cause = describeCause(nodePtyLoadCause())
throw new SidebarError(
'pty-deps-missing',
`node-pty (${DSH_NODE_PTY_RANGE}) failed to load: ${cause} — run the repair command shown in the terminal tab`,
503,
)
}
return module
}
/** Resolve a directory to its physical location (symlinked/link: installs). */
function realDir(file: string): string {
try {
return dirname(realpathSync(file))
} catch {
return dirname(file)
}
}
/** Walk up from `dir` looking for a DSH profile root (package.json + pnpm-workspace.yaml). */
function walkUp(dir: string, isRoot: (dir: string) => boolean): string | null {
let current = dir
for (let depth = 0; depth < 16; depth += 1) {
if (isRoot(current)) return current
const parent = dirname(current)
if (parent === current) break
current = parent
}
return null
}
/** Whether `dir` looks like a DSH profile root (the plugin lives under its node_modules). */
function isProfileRoot(dir: string): boolean {
return existsSync(join(dir, 'package.json')) && existsSync(join(dir, 'pnpm-workspace.yaml'))
}
/**
* Detect the DSH profile directory this plugin is installed into: the
* nearest ancestor of the plugin module that carries both `package.json`
* and `pnpm-workspace.yaml` (the profile root; the plugin resolves from the
* profile's node_modules). Falls back to `$DSH_HOME/profiles/web` (the
* standard web profile), then null.
*/
export function findProfileDir(fromFile: string = fileURLToPath(import.meta.url)): string | null {
const detected = walkUp(realDir(fromFile), isProfileRoot)
if (detected !== null) return detected
const home = process.env.DSH_HOME !== undefined && process.env.DSH_HOME.trim() !== ''
? process.env.DSH_HOME
: join(homedir(), '.dsh')
const web = join(home, 'profiles', 'web')
return isProfileRoot(web) ? realpathSync(web) : null
}
/** Whether `dir`'s package.json declares this plugin's name. */
function isPluginRoot(dir: string): boolean {
const file = join(dir, 'package.json')
if (!existsSync(file)) return false
try {
const parsed = JSON.parse(readFileSync(file, 'utf8')) as { name?: unknown }
return parsed.name === 'dsh-better-sidebar'
} catch {
return false
}
}
/** The plugin package root (walk-up from the module; works for lib/ and src/ layouts). */
export function findPluginRoot(fromFile: string = fileURLToPath(import.meta.url)): string | null {
return walkUp(realDir(fromFile), isPluginRoot)
}
/** Options for {@link buildRepairCommand}. */
export interface RepairCommandOptions {
/** The plugin package root (where scripts/install.sh / install.ps1 live). */
pluginRoot: string | null
/** The detected profile directory (null → the standard `web` profile). */
profileDir: string | null
/** Platform override for tests; defaults to the live process. */
platform?: NodeJS.Platform
}
/**
* The pasteable repair command for a broken node-pty install: rerun the
* plugin's own installer in `--repair` mode (idempotent: it re-writes the
* profile's `allowBuilds: node-pty: true` and re-installs/rebuilds the
* dependency). Falls back to DSH's plugin command when the scripts are not
* shipped (exotic layouts).
*/
export function buildRepairCommand(options: RepairCommandOptions): { command: string; note?: string } {
const { pluginRoot, profileDir } = options
const platform = options.platform ?? process.platform
const profileName = profileDir !== null ? basename(profileDir) : null
const profileArg = profileName !== null
? (platform === 'win32' ? ` -Profile "${profileName}"` : ` --profile "${profileName}"`)
: ''
if (pluginRoot !== null) {
if (platform === 'win32') {
const script = join(pluginRoot, 'scripts', 'install.ps1')
if (existsSync(script)) {
return { command: `powershell -ExecutionPolicy Bypass -File "${script}" -Repair${profileArg}` }
}
} else {
const script = join(pluginRoot, 'scripts', 'install.sh')
if (existsSync(script)) {
return { command: `bash "${script}" --repair${profileArg}` }
}
}
}
const name = profileName ?? 'web'
return {
command: `dsh plugin --profile "${name}" install`,
note: 'If pnpm 11 blocked node-pty\'s build script, ensure `allowBuilds: node-pty: true` in the profile\'s pnpm-workspace.yaml (the plugin\'s scripts/install.sh / install.ps1 --repair does this automatically).',
}
}
/** One-line human description of the recorded load cause. */
function describeCause(cause: unknown): string {
if (cause instanceof Error) return cause.message
return String(cause)
}
/** Structured status served by the `/sidebar/api/terminal.deps` endpoint. */
export type NodePtyDepsStatus =
| { ok: true }
| {
ok: false
/** The require-time error message (module missing, native binding broken…). */
cause: string
/** The pasteable repair command (terminal/cmd). */
command: string
/** The detected profile name (null when undetected → the command defaults to web). */
profile: string | null
/** Optional supplementary hint (fallback command only). */
note?: string
}
/** Current node-pty dependency status (loaded vs degraded + repair info). */
export function depsStatus(options: { fromFile?: string } = {}): NodePtyDepsStatus {
const module = loadNodePty()
if (module !== null) return { ok: true }
const pluginRoot = findPluginRoot(options.fromFile)
const profileDir = findProfileDir(options.fromFile)
const { command, note } = buildRepairCommand({ pluginRoot, profileDir })
return {
ok: false,
cause: describeCause(nodePtyLoadCause()),
command,
profile: profileDir !== null ? basename(profileDir) : null,
...(note !== undefined ? { note } : {}),
}
}
+114 -9
View File
@@ -9,7 +9,9 @@
import { chmodSync, existsSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { createRequire } from 'node:module'
import * as nodePty from 'node-pty'
import { userInfo } from 'node:os'
import type { IPty } from 'node-pty'
import { loadRequiredNodePty, type NodePtyModule } from './pty-deps.ts'
import { SidebarError } from './wire.ts'
/** Per-terminal transcript bound (bytes kept for replay). */
@@ -19,7 +21,7 @@ const TRANSCRIPT_LIMIT = 1 << 20
* Restore the executable bit pnpm strips from node-pty's prebuilt
* spawn-helper (the macOS helper that forks and sets up the pty). Without it
* every spawn fails with `posix_spawnp failed`. Idempotent; mirrors
* @deepseek-ai/dsh-pty-local's ensure-spawn-helper postinstall, run at
* @deepseek-ai/dsh-terminal-bash's ensure-spawn-helper postinstall, run at
* plugin activation so link-installed deployments get the fix too.
*/
export function ensureSpawnHelper(): void {
@@ -51,7 +53,7 @@ export interface SidebarPty {
* the page-load hydrate race can attach the real cwd after the first
* connect, and a shell in the wrong directory must not linger). */
cwd: string
pty: nodePty.IPty
pty: IPty
/** Output accumulated since spawn (bounded; head dropped when over the limit). */
transcript: string
/** Whether the top-level process exited (transcript stays replayable). */
@@ -70,6 +72,8 @@ export class PtyManager {
constructor(
private readonly shell: string,
private readonly maxPerSession: number,
/** The loaded node-pty module (injected so a broken install degrades instead of crashing the plugin). */
private readonly nodePty: NodePtyModule = loadRequiredNodePty(),
) {}
/** All live terminal keys of one session. */
@@ -118,7 +122,7 @@ export class PtyManager {
sessionId,
tabId,
cwd,
pty: nodePty.spawn(this.shell, [], {
pty: this.nodePty.spawn(this.shell, shellSpawnArgs(), {
name: 'xterm-256color',
cols: Math.max(2, Math.floor(cols)),
rows: Math.max(2, Math.floor(rows)),
@@ -191,9 +195,110 @@ export class PtyManager {
}
}
/** The interactive shell for this platform (empty SHELL falls back). */
export function defaultShell(): string {
if (process.platform === 'win32') return 'powershell.exe'
const shell = process.env.SHELL
return shell !== undefined && shell.trim() !== '' ? shell : '/bin/bash'
/**
* Inputs for {@link defaultShell} resolution. Every field is optional and
* defaults to the live process, which keeps the no-argument call sites
* working while tests (and exotic embedders) can pin the platform, the
* environment, and the existence probe independently — the Windows chain
* never executes on the ubuntu CI runners, so it is only testable through
* these injection points.
*/
export interface ShellResolutionOptions {
/** Platform override (defaults to `process.platform`). */
platform?: NodeJS.Platform
/** Environment override; the resolver only reads SHELL, DSH_SIDEBAR_SHELL, PATH, ProgramW6432, ProgramFiles, LOCALAPPDATA. */
env?: NodeJS.ProcessEnv
/** Explicitly configured shell (the `shell` config field); wins over every automatic source. Empty means unset. */
explicit?: string
/** File-existence probe override (defaults to `existsSync`). */
exists?: (path: string) => boolean
}
/**
* Candidate directories that may contain a `pwsh.exe` on Windows: PATH
* entries first, then the well-known machine/user install locations
* (including preview channels and per-user MSI/portable layouts). The
* machine-scope search reads both `ProgramW6432` and `ProgramFiles` so a
* 32-bit Node process — whose `ProgramFiles` points at `(x86)` — still
* finds a 64-bit PowerShell 7 install. De-duped while preserving priority
* order.
*/
function windowsPwshCandidateDirs(env: NodeJS.ProcessEnv): string[] {
const dirs: string[] = []
const pathEntries = env.PATH
if (pathEntries !== undefined) {
// The win32 branch always uses the Windows PATH separator; hardcoding it
// keeps the function testable from POSIX runners without a delimiter
// injection point.
for (const entry of pathEntries.split(';')) {
const trimmed = entry.trim()
if (trimmed !== '') dirs.push(trimmed)
}
}
for (const programFiles of [env.ProgramW6432, env.ProgramFiles]) {
if (programFiles === undefined || programFiles.trim() === '') continue
dirs.push(join(programFiles, 'PowerShell', '7'))
dirs.push(join(programFiles, 'PowerShell', '7-preview'))
}
const localAppData = env.LOCALAPPDATA
if (localAppData !== undefined && localAppData.trim() !== '') {
dirs.push(join(localAppData, 'Microsoft', 'PowerShell', '7'))
dirs.push(join(localAppData, 'Microsoft', 'PowerShell', '7-preview'))
dirs.push(join(localAppData, 'Programs', 'PowerShell', '7'))
dirs.push(join(localAppData, 'Programs', 'PowerShell', '7-preview'))
}
return [...new Set(dirs)]
}
/**
* The interactive shell for this platform, resolved like a terminal
* emulator: an explicitly configured shell (the `shell` config field) wins,
* then `$SHELL` on POSIX (deployment override), then the account's login
* shell from passwd, then `/bin/bash`. The passwd step matters because
* service managers and container inits often start dsh without `SHELL`, and
* the tab should still open the user's login shell (e.g. zsh) instead of
* silently degrading to bash.
*
* Windows previously short-circuited to `powershell.exe` (the inbox 5.1)
* before any resolution, so PowerShell 7 users always got a legacy shell
* without `??`/`?.`/ternary and with poor ANSI/UTF-8 defaults. The Windows
* chain is now: explicit shell → `DSH_SIDEBAR_SHELL` env override → first
* `pwsh.exe` found on PATH or in a known install directory → the 5.1
* fallback (machines without PowerShell 7 keep working).
*/
export function defaultShell(options: ShellResolutionOptions = {}): string {
const platform = options.platform ?? process.platform
const env = options.env ?? process.env
const exists = options.exists ?? existsSync
const explicit = options.explicit
if (explicit !== undefined && explicit.trim() !== '') return explicit.trim()
if (platform === 'win32') {
const envShell = env.DSH_SIDEBAR_SHELL
if (envShell !== undefined && envShell.trim() !== '') return envShell.trim()
for (const dir of windowsPwshCandidateDirs(env)) {
const candidate = join(dir, 'pwsh.exe')
if (exists(candidate)) return candidate
}
return 'powershell.exe'
}
const envShell = env.SHELL
if (envShell !== undefined && envShell.trim() !== '') return envShell.trim()
// userInfo() throws when the uid has no passwd entry (rare chroots);
// without a login shell there is nothing better than the bash default.
try {
const loginShell = userInfo().shell
if (typeof loginShell === 'string' && loginShell.trim() !== '') return loginShell
} catch {
// no passwd entry: fall through to /bin/bash
}
return '/bin/bash'
}
/**
* Spawn arguments that make the shell behave like a terminal-emulator tab:
* POSIX shells start as login shells (`-l`) so they read the profile files
* (`~/.profile`, `~/.zprofile`); Windows PowerShell takes no login flag.
*/
export function shellSpawnArgs(): string[] {
return process.platform === 'win32' ? [] : ['-l']
}

Some files were not shown because too many files have changed in this diff Show More