Compare commits

..
28 Commits
Author SHA1 Message Date
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
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
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
41 changed files with 4135 additions and 1460 deletions
+3
View File
@@ -146,3 +146,6 @@ lib/
# plugin-registry install staging (assembled by scripts/package-registry.mjs) # plugin-registry install staging (assembled by scripts/package-registry.mjs)
registry/ registry/
.npmrc .npmrc
# stray npm cache scratch dir
.npm-cache-tmp/
+19 -7
View File
@@ -127,15 +127,27 @@ interface TabDescriptor {
createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null
/** /**
* 声明式设置(v0.4.1+):每个注册的 tab 都会在 Side card 设置页获得一行 * 声明式设置(v0.4.1+):每个注册的 tab 都会在 Side card 设置页获得一行
* 开关(图标 + 标题 + 类型 id),`settings.toggles` 在其行下追加嵌套开关, * 开关(图标 + 标题 + 类型 id),`settings.toggles` 在其行下追加嵌套设置行,
* 绑定 SidebarPrefs 字段。嵌套开关仅父级启用时显示。 * 绑定 SidebarPrefs 字段。嵌套设置仅父级启用时显示(v0.11.0 起行控件不限于
* 布尔开关:`type: 'switch' | 'text' | 'number'`,缺省 'switch';text/number
* 行 blur/Enter 提交,number 行按 min/max 钳制,unit 渲染单位后缀)。
*/ */
settings?: { settings?: {
toggles?: readonly { toggles?: readonly {
/** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools' / 'htmlViewerNoSandbox' / 'htmlViewerDefaultUnsafe' / 'browserNoSandbox' / 'browserInterceptLinks') */ /** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools' / 'terminalFontFamily' / 'htmlViewerNoSandbox' / 'htmlViewerDefaultUnsafe' / 'browserNoSandbox' / 'browserInterceptLinks') */
key: string key: string
title: string | (() => string) title: string | (() => string)
desc?: string | (() => string) desc?: string | (() => string)
/** 行控件类型;缺省 'switch'(向后兼容)。 */
type?: 'switch' | 'text' | 'number'
/** number 行的提交钳制下限。 */
min?: number
/** number 行的提交钳制上限。 */
max?: number
/** text 行的输入占位符。 */
placeholder?: string
/** 输入框后的单位后缀(如 'px')。 */
unit?: string
}[] }[]
} }
/** 渲染函数 */ /** 渲染函数 */
@@ -384,7 +396,7 @@ interface BetterSidebarService {
} }
``` ```
> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,勾选徽标钉在卡片最右端,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`)通过卡片右下角的齿轮按钮在**原生弹窗**中编辑(复选框行),父级卡片关闭时齿轮隐藏;**key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks`),外部插件的自定义键会被 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+)——父级卡片关闭时齿轮隐藏;**key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `terminalFontFamily` / `terminalFontSize` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks`),外部插件的自定义键会被 settings seam 丢弃。
--- ---
@@ -407,7 +419,7 @@ interface BetterSidebarService {
| **host 半无此服务** | `ctx.betterSidebar` 只在 client 侧存在;host 半需要 better-sidebar 数据走 `/sidebar/api/*` HTTP 路由 | | **host 半无此服务** | `ctx.betterSidebar` 只在 client 侧存在;host 半需要 better-sidebar 数据走 `/sidebar/api/*` HTTP 路由 |
| **portal 限制** | 整面板 slot 由 ui-layout 独占,外部 tab 只能进入 better-sidebar 的 portal 内部,无法全屏替换 | | **portal 限制** | 整面板 slot 由 ui-layout 独占,外部 tab 只能进入 better-sidebar 的 portal 内部,无法全屏替换 |
| **id 冲突** | `registerTab` / `registerFileViewer` 对重复 id 抛错;建议用包前缀(`my-plugin:xxx`) | | **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 友好) | | **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** | 内置重依赖(Univer/xterm/CodeMirror/pptx/docx)在独立 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` 纯渲染函数)不变 | | **懒加载 chunk** | 内置重依赖(Univer/xterm/CodeMirror/pptx/docx)在独立 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` 纯渲染函数)不变 |
--- ---
@@ -484,7 +496,7 @@ function CsvGrid(props: { rows: string[][]; path: string }): React.ReactNode { /
function parseCsv(text: string): string[][] { /* ... */ } 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 半改动需要重启)。
--- ---
@@ -494,7 +506,7 @@ better-sidebar 自己的内置 tab 和 viewer 就是参考实现("吃狗粮"
- **`src/client/builtins/`**:7 个内置 tab(explorer/git/subagent/terminal/browser/editor/diff)+ 9 个内置 viewer(image/pdf/docx/xlsx/pptx/markdown/html/code/binary-download)的注册代码(tabs.tsx / viewers.tsx / index.ts) - **`src/client/builtins/`**:7 个内置 tab(explorer/git/subagent/terminal/browser/editor/diff)+ 9 个内置 viewer(image/pdf/docx/xlsx/pptx/markdown/html/code/binary-download)的注册代码(tabs.tsx / viewers.tsx / index.ts)
- **`src/client/service.ts`**:`BetterSidebarService` 接口 + `createBetterSidebarService` 工厂实现 - **`src/client/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/service.spec.ts`**:注册表生命周期 / 匹配算法 / dedupe / createTab / 启用态 gating 测试
- **`tests/builtins.spec.ts`**:内置注册清单断言(7 tab + 9 viewer + 声明式元数据) - **`tests/builtins.spec.ts`**:内置注册清单断言(7 tab + 9 viewer + 声明式元数据)
- **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`**:设计文档(含实施偏差记录) - **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`**:设计文档(含实施偏差记录)
+187 -67
View File
@@ -1,31 +1,194 @@
# dsh-better-sidebar # dsh-better-sidebar
> 一个插件,一套完整工作台: <!-- 头部介绍区(HTML 排版) -->
> <div align="center">
> 文件管理、编辑预览、内嵌浏览器、真实终端、Git 面板、后台任务页——**右侧栏 + 底部面板双工作台**,一个插件全部搞定。 <b style="font-size: 1.15em;">一个插件,一套完整工作台</b><br /><br />
> <code>文件管理</code> <code>编辑预览</code> <code>内嵌浏览器</code> <code>真实终端</code> <code>Git 面板</code> <code>后台任务页</code><br /><br />
> 支持Tab窗口随意拖拽,支持三方拓展注册新Tab页面和文件预览 <b>右侧栏 + 底部面板双工作台</b>,一个插件全部搞定。<br />
<small>支持 Tab 窗口随意拖拽,支持三方拓展注册新 Tab 页面和文件预览</small>
</div>
<div align="center">
🌏 <a href="./README.md"><b>中文</b></a> · <a href="./README_EN.md">English</a>
</div>
https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86 https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86
<img width="2630" height="1794" alt="6c4293e1bec2e935031bf0e986d6ec65" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" /> <img width="2630" height="1794" alt="6c4293e1bec2e935031bf0e986d6ec65" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" />
## 🆕 最近更新
- **终端自定义字体**:设置页终端卡片新增字体族 + 9–32px 字号设置,实时生效
- **侧边卡片设置页重构**:分组容器卡片、计数徽标、自定义开关、加宽设置弹窗
- **修复**:WKWebView 下底部面板展开后终端空白(xterm 零尺寸容器初始化崩溃,[#25](https://github.com/omdsh-dev/DSH-better-sidebar/issues/25))
- **文档**:client 改动硬刷新即可生效,无需重启 DSH
## ✨ 功能一览 ## ✨ 功能一览
- **🗂️ 资源管理器**:懒加载目录树(根 = 会话 cwd)、点击在侧边栏打开、行尾 `@文件` 引用到输入框、右键复制路径 - **🗂️ 资源管理器**:懒加载目录树(根 = 会话 cwd),点击打开、`@文件` 引用、右键复制路径
- **📝 编辑与预览**:CodeMirror 6 多语言高亮 + Ctrl/Cmd+S 原子保存;图片 / Markdown(预览/编辑切换)/ HTML(沙箱 iframe 预览,相对资源可加载)/ PDF / Word / Excel / PPT 内联预览,切换 Tab 不丢草稿 - **📝 编辑与预览**:CodeMirror 6 编辑(Ctrl/Cmd+S 原子保存,切 Tab 不丢草稿);图片 / Markdown / HTML / PDF / Word / Excel / PPT 内联预览(HTML 走沙箱 iframe)
- **⚡ 客户端懒加载**:Office / 终端 / 代码编辑器等重依赖按需分块加载——启动只拉 ~325KB 核心,打开 .xlsx 才拉 Univer(~20MB)、打开 .docx 只拉 docx 预览器、打开终端才拉 xterm;首次打开短暂 loading 后即用(详见 `docs/plans/2026-08-12-lazy-chunks-design.md`) - **⚡ 客户端懒加载**:启动只拉 ~325KB 核心,Office / 终端 / 编辑器等重依赖用到才按需拉取(详见 `docs/plans/2026-08-12-lazy-chunks-design.md`)
- **🌐 浏览器**:内嵌网页浏览 tab(多开),后退/前进/刷新 +「在浏览器中打开」;页面在**沙箱 iframe** 中运行(不透明源:无法访问界面数据与本地文件,拒绝 localhost 等本机地址),界面实时显示沙箱状态、可临时解锁(关闭时红色警示);被站点拒绝嵌入(X-Frame-Options)时显示原因面板;聊天/界面里的 http(s) 外链默认在侧边栏打开(侧边栏折叠时自动展开面板) - **🌐 浏览器**:多开内嵌网页 tab,后退/前进/刷新;内容运行在沙箱 iframe(无法访问界面数据与本地文件,拒绝本机地址),可临时解锁(红色警示);被站点拒绝嵌入时显示原因;外链默认在侧边栏打开
- **💻 终端**:xterm.js + node-pty 真实 shell(每会话 3 个 UI 上限)、Tab 保活重连回放;可选为模型注入 8 个 `terminal_*` 工具 - **💻 终端**:xterm.js + node-pty 真实 shell、断线重连回放;可选为模型注入 `terminal_*` 工具;支持自定义字体(字体族 + 9–32px 字号,实时生效)
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、懒加载历史、右键暂存/放弃/提交/还原/捡取 - **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存/提交/还原等
- **🧩 后台任务页**:主会话完整 agent 拓扑、点击直达执行记录、实时工具调用轮询、新子代理自动展开;**同页显示后台任务**(当前树全部后台任务,bash/pwsh 类型徽标 + 退出码,点击查看实时输出——自动跟随底部、非消费 peek,不干扰模型的 `job_output`;两击确认可强制终止) - **🧩 后台任务页**:主会话 agent 拓扑、点击直达执行记录;同页显示后台任务(类型徽标 + 退出码、实时输出 peek、可强制终止)
- **🪟 底部面板**:独立的第二个工作台(与右侧栏同类的标签页),只挤占中间 Agent 输出区、不覆盖左右侧边栏;**首次展开自动开一个新终端**(终端卡片二级设置可关);右上角 x 一键折叠 - **🪟 底部面板**:第二个独立工作台,只挤占中间输出区;首次展开自动开终端(可关)
- **📱 移动端**:视口 < 768px(真正的移动端宽度,不对齐宿主 1024 断点)时只显示右侧栏——进入窄屏时底部面板的标签页**直接并入右侧栏标签条**,右上角只剩一枚开关,面板为全宽抽屉;新会话默认收起,聊天里点文件/外链自动展开,不挤压对话区 - **📱 移动端**:窄屏(<768px)自动合并为全宽抽屉,底部面板标签页并入右侧栏;点文件/外链自动展开
- **🔧 分栏工作台**:拖 Tab 拆分/合并分栏(可**跨面板拖 Tab**)、分隔线调比例;右上角持久按钮簇(底栏 + 侧拉 glyph)折叠/展开两个面板;两面板共享拐角双向拖动调节尺寸,拖动 rAF 直写 DOM 保持流畅 - **🔧 分栏工作台**:拖 Tab 拆分/合并(可跨面板)、分隔线调比例;右上角按钮一键折叠/展开面板
- **🔁 会话隔离**:布局/分栏/Tab/两面板状态按会话持久化(localStorage),陈旧状态自动净化;聊天「产出文件」改在侧边栏打开(面板折叠时自动展开) - **🔁 会话隔离**:布局 / Tab / 面板状态按会话持久化,陈旧状态自动净化;「产出文件」在侧边栏打开
- **⚙️ 声明式设置**:设置页「侧边卡片」分区按注册表渲染功能清单(小卡片网格,高亮 = 启用),每项可独立开/关;二级设置(子代理自动展开、终端工具、底部面板首展自动开终端、沙箱开关等)经齿轮按钮在原生弹窗中编辑 - **⚙️ 声明式设置**:设置页「侧边卡片」按注册表渲染开关网格,逐项独立开/关;二级设置(自动展开、终端工具、沙箱等)经齿轮弹窗编辑
- **🔌 服务化**:暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏 tab 与文件预览器(内置 7 tab + 9 viewer 也走同一服务,详见 [AGENTS.md](./AGENTS.md)) - **🔌 服务化**:暴露 `ctx.betterSidebar`,其他插件可注册 tab 与文件预览器(内置 7 tab + 9 viewer 同走一服务,见 [AGENTS.md](./AGENTS.md))
- **🌏 多语言**:界面文案跟随 DSH 的语言设置(zh/en)实时切换——Host 偏好优先于浏览器语言,词典注册进 DSH 的 i18n 命名空间;切换语言无需刷新 - **🌏 多语言**:界面文案跟随 DSH 语言(zh/en)实时切换,无需刷新
## 🚀 安装
**前置**:已装好 DSH(`dsh web` 能正常运行),Node.js ≥ 20、pnpm ≥ 10。
**macOS / Linux**(Windows 装了 Git Bash 或 WSL 也可):
```sh
curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash
```
**Windows(PowerShell 5.1+ / pwsh)**:
```powershell
irm https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1 | iex
```
装完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可看到侧边栏(DSH 对 client 改动热加载,无需重启;仅 host 半更新时需要重启)。
<details>
<summary><b>指定版本 / 装完自动重启(可选)</b></summary>
```sh
# macOS / Linux
curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash -s 0.10.3 --restart
# Windows PowerShell
& ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.10.3 -Restart
```
不确定的话,可先加 `--dry-run`(PowerShell 用 `-DryRun`)预览步骤再执行。
</details>
<details>
<summary><b>手动安装(逐步命令,想看清每一步)</b></summary>
与一键脚本等价。**第 ③ 步可重复执行;①② 只需做一次。**
**macOS / Linux(bash)**:
```sh
cd ~/.dsh/profiles/web
# ① 放行 node-pty / protobufjs 的构建脚本(pnpm 11 默认拦截;pnpm 10 可跳过)
pnpm approve-builds --all
# ② 放行「发布不足 24h」的新版本(装老版本可跳过;若已有该键,把下面那行并入其下即可)
cat >> pnpm-workspace.yaml <<'EOF'
minimumReleaseAgeExclude:
- dsh-better-sidebar
EOF
# ③ 安装并自动挂载(不带 @版本 = npm 的 latest;固定版本写 dsh-better-sidebar@0.10.3)
npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar
```
**Windows(PowerShell)**:
```powershell
cd ~\.dsh\profiles\web
# ① 放行构建脚本
pnpm approve-builds --all
# ② 放行新版本(一次性;若已有该键,把 - dsh-better-sidebar 并入其下即可)
Add-Content -Path pnpm-workspace.yaml -Value "`nminimumReleaseAgeExclude:`n - dsh-better-sidebar"
# ③ 安装并自动挂载
npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar
```
</details>
<details>
<summary><b>脚本内部做了什么(技术细节)</b></summary>
一键脚本自动完成 4 件事(全部幂等,可安全重复执行):
1. 预写 `allowBuilds`(node-pty / protobufjs),规避 pnpm 11 的构建脚本拦截;
2. 预写 `minimumReleaseAgeExclude`,放行「发布不足 24 小时」的新版本;
3. 执行 `dsh plugin --profile web add dsh-better-sidebar`:登记依赖 → 识别包内 `dsh.bundle.patch` → 自动注册进 `dsh.profile.bundles` 挂载;
4. 清理旧版残留的手动挂载行,避免「双挂载」(页面出现两个侧边栏)。
`curl | bash` / `irm | iex` 会执行远程代码——脚本已随仓库开源(`scripts/install.sh` / `scripts/install.ps1`),可先下载审阅。插件以 npm 包 `dsh-better-sidebar@0.10.3` 发布,通过 `dsh.bundle.patch`(随包的 `cordis.patch.yml`)由官方 CLI 自动挂载,**不修改 DSH 源码**。
</details>
<details>
<summary><b>更新</b></summary>
```sh
dsh plugin --profile web add dsh-better-sidebar
```
或重跑一次一键脚本;也可把 `~/.dsh/profiles/web/package.json` 里的版本号改高后 `pnpm install`。改完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可(client 改动无需重启 DSH)。
</details>
<details>
<summary><b>常见问题</b></summary>
| 现象 | 原因与解决 |
|---|---|
| 报 `Ignored build scripts` | pnpm 11 拦截构建脚本。跑 `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 版本一般已有预编译。 |
| Windows 没有 bash / curl | 直接用 PowerShell 一键命令;或安装 Git Bash / WSL 再跑 bash 命令。 |
</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.10.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>
## ⌨️ 快捷键 ## ⌨️ 快捷键
@@ -38,51 +201,6 @@ https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86
| 引用文件到输入框 | 悬浮行尾 `@文件` 按钮 | | 引用文件到输入框 | 悬浮行尾 `@文件` 按钮 |
| 复制文件路径 | 右键行 → 复制相对/绝对地址 | | 复制文件路径 | 右键行 → 复制相对/绝对地址 |
## 🚀 安装
前置:已安装 DSH(`dsh web` 可运行),Node.js ≥ 20、pnpm ≥ 10。把下面提示词**整段**发给 DSH 即可自动完成克隆、构建、注册与安装:
```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
(@deepseek-ai/* 从 npm 解析(0.0.1-rc.? 预发布版);若 401/404,用安装者自己的只读令牌配置 ~/.npmrc,或把 devDependencies 改回指向自己 ~/.dsh/source/current 的 link:,不要继续)
2. 注册到 web profile:
a. ~/.dsh/profiles/web/package.json 的 dependencies 加 "dsh-better-sidebar": "link:<第 1 步克隆目录的绝对路径>"
b. ~/.dsh/profiles/web/cordis.patch.yml 追加:
- insert:
- id: better-sidebar
name: 'dsh-better-sidebar'
3. 在 ~/.dsh/profiles/web 执行 pnpm install
4. 重启 DSH 并硬刷新(Cmd/Ctrl+Shift+R)验证
```
> 安装 = 依赖登记(等价 `dsh plugin --profile web add link:<路径>`)+ 一行挂载行。**DSH 以 npm 包启动(如 `npx -p @deepseek-ai/dsh@0.0.1-rc.1 dsh web`)同样可用**(v0.4.3 起实测验证)。
### 更新
```text
1. cd ~/Code/DSH-better-sidebar && git pull && pnpm install && pnpm build(401/404 处理同上)
2. 核对注册仍有效(缺失才补):profile package.json 的 link: 依赖 + cordis.patch.yml 挂载行
3. 仅 client(src/client/*)→ 硬刷新即可;含 host(src/index.ts、src/config.ts 等)→ 重启 DSH + 硬刷新
```
### 通过 plugin-registry 安装(可选,与上述二选一)
前置:DSH 已集成 [plugin-registry](https://github.com/dsh-external/plugin-registry)(`dsh registry` 可用)。**同时启用两个通道会双挂载**(Node 半挂两次、页面两个侧边栏)。
```sh
git clone https://github.com/dsh-external/DSH-better-sidebar.git && cd DSH-better-sidebar
pnpm install && pnpm build
node scripts/package-registry.mjs # 组装 registry/ 暂存(含清单 + 产物 + README,不入库)
dsh registry install ./registry # 安装(默认禁用)
dsh registry enable dsh-external/dsh-better-sidebar
```
更新:`git pull && pnpm install && pnpm build` → `node scripts/package-registry.mjs` → `dsh registry uninstall/install/enable`。切换通道前先移除另一通道的挂载。
## 🔌 服务化:注册 tab 与文件预览器 ## 🔌 服务化:注册 tab 与文件预览器
从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 7 tab + 9 viewer 也走同一服务,吃自己的狗粮): 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 7 tab + 9 viewer 也走同一服务,吃自己的狗粮):
@@ -102,9 +220,7 @@ export function apply(ctx: Context) {
## 🛠️ 开发与构建 ## 🛠️ 开发与构建
```sh ```sh
pnpm install # @deepseek-ai/* 从 npm 解析(^0.0.1-rc.1),需安装者自己的只读令牌: pnpm install # @deepseek-ai/* 已发布到 npm(^0.1.0-rc.6),直接解析、无需令牌
# ~/.npmrc 配 //registry.npmjs.org/:_authToken=<自己的令牌>(勿写进仓库),
# 或改 devDependencies 为 link: 指向自己的 ~/.dsh/source/current
pnpm typecheck # tsc --noEmit pnpm typecheck # tsc --noEmit
pnpm build # → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types pnpm build # → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types
pnpm test # vitest(含 manifest 一致性守卫,需先 build) pnpm test # vitest(含 manifest 一致性守卫,需先 build)
@@ -131,3 +247,7 @@ pnpm watch # tsdown --watch
## 🖥️ 平台支持 ## 🖥️ 平台支持
Windows / Linux / macOS 三平台适配(macOS 日常验证;其余经单元测试覆盖);`node-pty` 优先预编译二进制,失败需编译工具链(Windows VS Build Tools / Linux make+g+++python3 / macOS Xcode CLT)。 Windows / Linux / macOS 三平台适配(macOS 日常验证;其余经单元测试覆盖);`node-pty` 优先预编译二进制,失败需编译工具链(Windows VS Build Tools / Linux make+g+++python3 / macOS Xcode CLT)。
## 🔗 友情链接
- [dsh-tianshu-tui](https://github.com/huiliyi37/dsh-tianshu-tui):DeepSeek Harness 交互式终端 UI 插件(渲染核心由自研 harness agent Tianshu-Tui 演进而来),在官方基础上增加 TDD 与证据门等工作流
+253
View File
@@ -0,0 +1,253 @@
# dsh-better-sidebar
<!-- Hero (HTML layout) -->
<div align="center">
<b style="font-size: 1.15em;">One plugin, one complete workbench</b><br /><br />
<code>File management</code> <code>Edit &amp; preview</code> <code>Embedded browser</code> <code>Real terminal</code> <code>Git panel</code> <code>Background tasks</code><br /><br />
<b>Dual workbench: right sidebar + bottom panel</b>, all in one plugin.<br />
<small>Tabs are freely draggable; third-party plugins can register new tab pages and file viewers</small>
</div>
<div align="center">
🌏 <a href="./README.md">中文</a> · <a href="./README_EN.md"><b>English</b></a>
</div>
https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86
<img width="2630" height="1794" alt="6c4293e1bec2e935031bf0e986d6ec65" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" />
## 🆕 Recent Updates
- **Custom terminal fonts**: font-family + 9–32px size settings on the terminal card, applied live
- **Side card settings redesign**: grouped container cards, count badges, custom toggles, wider settings dialog
- **Fix**: blank terminal after expanding the bottom panel on WKWebView (xterm initialized in a zero-size container, [#25](https://github.com/omdsh-dev/DSH-better-sidebar/issues/25))
- **Docs**: client changes take effect on a hard refresh — no DSH restart needed
## ✨ Features
- **🗂️ File Explorer**: lazy-loading directory tree (root = session cwd), click to open, `@file` reference, right-click to copy path
- **📝 Edit & Preview**: CodeMirror 6 editing (Ctrl/Cmd+S atomic save, drafts survive tab switches); inline preview for images / Markdown / HTML / PDF / Word / Excel / PPT (HTML in a sandboxed iframe)
- **⚡ Client-side Lazy Loading**: only ~325KB core at startup; heavy deps (Office / terminal / editor) load on demand (see `docs/plans/2026-08-12-lazy-chunks-design.md`)
- **🌐 Browser**: multiple embedded web tabs, back/forward/refresh; pages run in a sandboxed iframe (no access to UI data or local files, rejects local addresses), temporarily unlockable (red warning); sites refusing embedding show a reason panel; external links open in the sidebar by default
- **💻 Terminal**: xterm.js + node-pty real shell, reconnect with transcript replay; optionally injects `terminal_*` tools for the model; custom font (family + 9–32px size, applied live)
- **🌿 Git Panel**: real diff + VSCode-style diff tabs, history, right-click to stage/commit/revert etc.
- **🧩 Background Tasks**: main-session agent topology with click-through to execution records; background tasks on the same page (type badges + exit codes, live-output peek, force-kill)
- **🪟 Bottom Panel**: an independent second workbench that only squeezes the center output area; auto-opens a terminal on first expand (toggleable)
- **📱 Mobile**: narrow viewports (<768px) merge into a full-width drawer with the bottom panel's tabs folded into the sidebar; files/links auto-expand the panel
- **🔧 Split-pane Workbench**: drag tabs to split/merge panes (cross-panel supported), divider to adjust ratios; one-click collapse/expand both panels from the top-right buttons
- **🔁 Session Isolation**: layout / tabs / panel states persisted per session, stale state auto-purged; "produced files" open in the sidebar
- **⚙️ Declarative Settings**: the "Side Cards" settings section renders a registry-driven toggle grid, each item independently switchable; secondary settings (auto-expand, terminal tools, sandbox, etc.) edited in a native dialog via the gear button
- **🔌 Service API**: exposes `ctx.betterSidebar` — other plugins can register tabs and file viewers (the 7 built-in tabs + 9 viewers share the same service, see [AGENTS.md](./AGENTS.md))
- **🌏 i18n**: UI text follows DSH's language setting (zh/en) with live switching, no refresh needed
## 🚀 Installation
**Prerequisites**: DSH installed (`dsh web` boots), Node.js ≥ 20, pnpm ≥ 10.
**macOS / Linux** (also works in Git Bash / WSL on Windows):
```sh
curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash
```
**Windows (PowerShell 5.1+ / pwsh)**:
```powershell
irm https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1 | iex
```
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>Pin a version / auto-restart (optional)</b></summary>
```sh
# macOS / Linux
curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash -s 0.10.3 --restart
# Windows PowerShell
& ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.10.3 -Restart
```
Not sure? Add `--dry-run` (`-DryRun` in PowerShell) to preview before running.
</details>
<details>
<summary><b>Manual install (step by step)</b></summary>
Equivalent to the one-click script. **Step ③ is repeatable; ①② only need to run once.**
**macOS / Linux (bash)**:
```sh
cd ~/.dsh/profiles/web
# ① Allow node-pty / protobufjs build scripts (pnpm 11 blocks them by default; skip on pnpm 10)
pnpm approve-builds --all
# ② Allow versions published less than 24h ago (skip for older releases; if the key already exists, merge the line under it instead)
cat >> pnpm-workspace.yaml <<'EOF'
minimumReleaseAgeExclude:
- dsh-better-sidebar
EOF
# ③ Install and auto-mount (no @version = npm's latest; pin with dsh-better-sidebar@0.10.3)
npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar
```
**Windows (PowerShell)**:
```powershell
cd ~\.dsh\profiles\web
# ① Allow build scripts
pnpm approve-builds --all
# ② Allow fresh releases (once; if the key already exists, merge - dsh-better-sidebar under it instead)
Add-Content -Path pnpm-workspace.yaml -Value "`nminimumReleaseAgeExclude:`n - dsh-better-sidebar"
# ③ Install and auto-mount
npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar
```
</details>
<details>
<summary><b>What the script does (technical details)</b></summary>
The one-click script does four things, all idempotent (safe to re-run):
1. Pre-writes `allowBuilds` (node-pty / protobufjs) to dodge pnpm 11's build-script block;
2. Pre-writes `minimumReleaseAgeExclude` to allow versions younger than 24 hours;
3. Runs `dsh plugin --profile web add dsh-better-sidebar`: registers the dependency → detects `dsh.bundle.patch` → auto-appends the plugin to `dsh.profile.bundles`;
4. Removes any leftover hand-written mount line to avoid double-mounting (two sidebars on the page).
`curl | bash` / `irm | iex` executes remote code — the scripts are open source in the repo (`scripts/install.sh` / `scripts/install.ps1`); download and review them first if you prefer. The plugin ships as npm package `dsh-better-sidebar@0.10.3` and mounts via `dsh.bundle.patch` (the shipped `cordis.patch.yml`), so the DSH source is never modified.
</details>
<details>
<summary><b>Updating</b></summary>
```sh
dsh plugin --profile web add dsh-better-sidebar
```
or re-run the one-click script; or bump the version in `~/.dsh/profiles/web/package.json` (e.g. `"^0.10.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` (the one-click script handles it). |
| `minimum release age` / version `< 24h` | The release is younger than 24 hours. Wait, or re-run once (pnpm auto-adds `minimumReleaseAgeExclude`); the one-click script handles it. |
| "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 (the one-click script cleans 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. |
| No bash / curl on Windows | Use the PowerShell one-click command, or install Git Bash / WSL and run the bash commands. |
</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.10.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 + 9 viewers go through the same service, eating our own dog food):
```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} />,
}))
}
```
Full integration docs (`TabDescriptor` / `FileViewerDescriptor` full fields, matching algorithm, HMR pitfalls, declarative settings): see [`AGENTS.md`](./AGENTS.md).
## 🛠️ 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)
- `.xlsx` preview does not preserve cell styles (SheetJS community-edition limitation); Office/PPTX preview is inlined into the client bundle (~23MB), slower on first load
- 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
+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'
+1 -1
View File
@@ -598,7 +598,7 @@ function parseCsv(text: string): string[][] { /* ... */ }
1. `~/.dsh/profiles/web/package.json` 的 `dependencies` 加 `"my-plugin": "link:<你的插件路径>"`; 1. `~/.dsh/profiles/web/package.json` 的 `dependencies` 加 `"my-plugin": "link:<你的插件路径>"`;
2. `~/.dsh/profiles/web/cordis.patch.yml` 追加挂载行(`- insert: - id: my-plugin / name: 'my-plugin'`); 2. `~/.dsh/profiles/web/cordis.patch.yml` 追加挂载行(`- insert: - id: my-plugin / name: 'my-plugin'`);
3. 在 profile 目录 `pnpm install`; 3. 在 profile 目录 `pnpm install`;
4. 重启 `dsh web` + 浏览器硬刷新(Cmd/Ctrl+Shift+R)。 4. 浏览器硬刷新(Cmd/Ctrl+Shift+R)即可看到效果(DSH 对 client 改动热加载,无需重启 `dsh web`;仅 host 半改动需要重启)。
--- ---
@@ -50,6 +50,11 @@ DSH 的 i18n 系统(`@deepseek-ai/dsh-client-locale`):
- 无。计划与实施一致。 - 无。计划与实施一致。
- 版本号随 v0.8.2 发版提交(`package.json` + `dsh.plugin.json`)。 - 版本号随 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 跟随。
## 验收 ## 验收
@@ -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. 实施偏差记录
无(按设计实施)。
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"id": "dsh-external/dsh-better-sidebar", "id": "dsh-external/dsh-better-sidebar",
"version": "0.9.0", "version": "0.11.0",
"main": "./lib/index.js", "main": "./lib/index.js",
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器", "description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
"engines": { "engines": {
+53 -31
View File
@@ -1,9 +1,15 @@
{ {
"name": "dsh-better-sidebar", "name": "dsh-better-sidebar",
"version": "0.9.0", "version": "0.11.0",
"description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.", "description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.",
"private": true,
"type": "module", "type": "module",
"repository": {
"type": "git",
"url": "https://github.com/omdsh-dev/DSH-better-sidebar"
},
"publishConfig": {
"access": "public"
},
"main": "lib/index.js", "main": "lib/index.js",
"types": "lib/types/index.d.ts", "types": "lib/types/index.d.ts",
"exports": { "exports": {
@@ -34,6 +40,9 @@
"node": ">=20" "node": ">=20"
}, },
"dsh": { "dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": { "client": {
"inject": [ "inject": [
"@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-runtime",
@@ -55,10 +64,17 @@
"lib/client-terminal.js", "lib/client-terminal.js",
"lib/client-editor.js", "lib/client-editor.js",
"lib/types/**/*.d.ts", "lib/types/**/*.d.ts",
"src" "src",
"scripts/install.sh",
"scripts/install.ps1",
"cordis.patch.yml",
"README.md",
"README_EN.md",
"LICENSE"
], ],
"scripts": { "scripts": {
"build": "rm -rf lib && tsc -p tsconfig.build.json && tsdown", "build": "rm -rf lib && tsc -p tsconfig.build.json && tsdown",
"prepublishOnly": "pnpm build",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"bundle": "tsdown", "bundle": "tsdown",
"watch": "tsdown --watch", "watch": "tsdown --watch",
@@ -67,24 +83,29 @@
}, },
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1-rc.1", "@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1-rc.1", "@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-schema-form": "^0.0.1-rc.1", "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1-rc.1", "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1-rc.1", "@deepseek-ai/dsh-client-schema-form": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-settings": "^0.0.1-rc.1", "@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1-rc.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-web-react": "^0.0.1-rc.1", "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
"@deepseek-ai/dsh-host-webserver": "^0.0.1-rc.1", "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
"@deepseek-ai/dsh-invariants": "^0.0.1-rc.1", "@deepseek-ai/dsh-client-web-react": "^0.1.0-rc.6",
"@deepseek-ai/dsh-llm": "^0.0.1-rc.1", "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
"@deepseek-ai/dsh-session": "^0.0.1-rc.1", "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
"@deepseek-ai/dsh-settings": "^0.0.1-rc.1", "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
"@deepseek-ai/dsh-tools": "^0.0.1-rc.1", "@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", "cordis": "^4.0.0-rc.7",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0" "react-dom": "^18.2.0"
}, },
"peerDependenciesMeta": {
"cordis": { "optional": true }
},
"dependencies": { "dependencies": {
"@aiden0z/pptx-renderer": "^1.2.4", "@aiden0z/pptx-renderer": "^1.2.4",
"@codemirror/commands": "^6.10.4", "@codemirror/commands": "^6.10.4",
@@ -121,20 +142,21 @@
"xterm": "^5.3.0" "xterm": "^5.3.0"
}, },
"devDependencies": { "devDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1-rc.1", "@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-runtime": "^0.0.1-rc.1", "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-schema-form": "^0.0.1-rc.1", "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1-rc.1", "@deepseek-ai/dsh-client-schema-form": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1-rc.1", "@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-settings": "^0.0.1-rc.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1-rc.1", "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-web-react": "^0.0.1-rc.1", "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
"@deepseek-ai/dsh-host-webserver": "^0.0.1-rc.1", "@deepseek-ai/dsh-client-web-react": "^0.1.0-rc.6",
"@deepseek-ai/dsh-invariants": "^0.0.1-rc.1", "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
"@deepseek-ai/dsh-llm": "^0.0.1-rc.1", "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
"@deepseek-ai/dsh-session": "^0.0.1-rc.1", "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
"@deepseek-ai/dsh-settings": "^0.0.1-rc.1", "@deepseek-ai/dsh-session": "^0.1.0-rc.6",
"@deepseek-ai/dsh-tools": "^0.0.1-rc.1", "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/react": "~18.3.1", "@types/react": "~18.3.1",
"@types/react-dom": "~18.3.1", "@types/react-dom": "~18.3.1",
+1334 -1109
View File
File diff suppressed because it is too large Load Diff
+63
View File
@@ -65,3 +65,66 @@ minimumReleaseAgeExclude:
- '@deepseek-ai/dsh-user-interaction@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/dsh-workspace@0.0.1-rc.1'
- '@deepseek-ai/schemastery@3.18.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'
+242
View File
@@ -0,0 +1,242 @@
# =============================================================================
# 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
#
# 参数:
# -Version npm 版本号/范围,缺省 latest(自动解析为最新)。
# -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
)
$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\web'
$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
}
# 前置校验
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(请先初始化 web profile)"
}
$SPEC = Resolve-Spec $Version
$CLI = Get-DshCli
if (-not $CLI) {
Die '未找到 dsh 或 npx。请先安装 DSH(并确保 Node/npm 可用),或用 DSH_CMD 指定。'
}
Say "目标:$CLI plugin --profile web 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 web 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 不拦截构建、放行本插件新版本
$wsScript = @'
const fs = require("fs");
const p = process.argv[1];
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");
'@
$wsOut = node -e $wsScript "$WS_YML" 2>&1
$wsCode = $LASTEXITCODE
$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 设置已就绪,跳过'
}
# 步骤 2:官方 CLI 安装 + bundle 自动注册(含挂载)
if ($CLI -eq 'dsh') {
$cliArgs = @('plugin', '--profile', 'web', 'add', "$PKG@$SPEC")
} else {
$cliArgs = @('-y', '--package', '@deepseek-ai/dsh', 'dsh', 'plugin', '--profile', 'web', 'add', "$PKG@$SPEC")
}
Say "执行 $CLI plugin --profile web 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[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");
}
'@
$mountOut = node -e $mountScript "$PATCH_YML" 2>&1
$mountCode = $LASTEXITCODE
$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(会短暂断开当前页面会话)'
}
}
+246
View File
@@ -0,0 +1,246 @@
#!/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]
#
# 版本 npm 版本号/范围,缺省为 latest(自动解析为 ^<最新>)。
# 示例:0.10.2、^0.10.2、~0.10.2、latest
# --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]
版本 npm 版本号/范围,缺省 latest(自动解析为最新)。示例:0.10.2、^0.10.2、latest
--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}"
PROFILE_DIR="$DSH_HOME/profiles/web"
WS_YML="$PROFILE_DIR/pnpm-workspace.yaml"
PATCH_YML="$PROFILE_DIR/cordis.patch.yml"
REGISTRY="${REGISTRY:-https://registry.npmjs.org}"
PKG="dsh-better-sidebar"
DSH_CMD="${DSH_CMD:-dsh}"
RESTART=false
DRY_RUN=false
VERSION_SPEC=""
for arg in "$@"; do
case "$arg" in
--restart) RESTART=true ;;
--dry-run) DRY_RUN=true ;;
-h|--help) : ;; # 已在上面处理
-*) echo "未知参数: ${arg}(用 -h 查看用法)" >&2; exit 2 ;;
*) VERSION_SPEC="$arg" ;;
esac
done
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; }
# 解析用户给的版本 -> 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}(请先初始化 web profile)"
SPEC="$(resolve_spec "$VERSION_SPEC")"
CLI="$(dsh_cli)"
say "目标:$CLI plugin --profile web 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 web 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 不拦截构建、不放行旧版本策略
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 设置已就绪,跳过"
# 步骤 2:官方 CLI 安装 + bundle 自动注册(含挂载)
say "执行 $CLI plugin --profile web add $PKG@$SPEC ..."
if ! $CLI plugin --profile web 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
+220 -70
View File
@@ -1,20 +1,28 @@
/** /**
* Side card settings section chrome (DSH-native tokens throughout): * 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 * - the feature inventories are responsive GRIDS of small toggle cards
* (auto-fill columns — several cards per row, wrapping); * (auto-fill columns — several cards per row, wrapping): icon chip +
* - a card's on/off state IS its visual state: enabled = brand border + * title + type id, the card's on/off state IS its visual state
* tinted fill + a check badge pinned to the far right of the top row, * (enabled = brand border + tinted fill + circular check badge,
* disabled = neutral hairline + dimmed text; * disabled = neutral hairline + dimmed);
* - features that declare related settings carry a gear corner button * - features that declare related settings carry a gear corner button
* opening a native Modal whose rows are the DSH settings-row recipe * opening a native Modal (wider than the primitive default) whose rows
* (title/desc left + native accent checkbox, hairline separators); * 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). * - the width input stays a native settings row (a value, not a switch).
*/ */
.section { .section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 14px;
width: 100%; width: 100%;
/* Tall inventories (7 tab cards + 9 viewer cards + general rows) must /* Tall inventories (7 tab cards + 9 viewer cards + general rows) must
never be clipped by the settings shell: fill the shell's options area never be clipped by the settings shell: fill the shell's options area
@@ -24,15 +32,55 @@
overflow-y: auto; overflow-y: auto;
} }
.section > :last-child { /* One-line section intro (the DSH section heading+intro recipe). */
border-bottom: none; .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. */ /* Responsive inventory: as many small cards per row as the width allows. */
.grid { .grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
gap: 8px; gap: 10px;
} }
/* One small toggle card (visual chrome; the interactive part is .cardMain). */ /* One small toggle card (visual chrome; the interactive part is .cardMain). */
@@ -40,7 +88,7 @@
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border-radius: 8px; border-radius: 12px;
border: 1px solid var(--dsw-alias-border-l2); border: 1px solid var(--dsw-alias-border-l2);
background: transparent; background: transparent;
font: inherit; font: inherit;
@@ -52,6 +100,7 @@
/* Hover affordance on disabled cards only (enabled cards are already lit). */ /* Hover affordance on disabled cards only (enabled cards are already lit). */
.card:not(.cardOn):hover { .card:not(.cardOn):hover {
background: var(--dsw-alias-interactive-bg-hover); background: var(--dsw-alias-interactive-bg-hover);
border-color: var(--dsw-alias-label-dimmed);
} }
/* Enabled = highlighted: brand border + tinted fill. */ /* Enabled = highlighted: brand border + tinted fill. */
@@ -64,9 +113,9 @@
.cardMain { .cardMain {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 6px;
width: 100%; width: 100%;
padding: 10px 12px; padding: 12px;
border: 0; border: 0;
border-radius: inherit; border-radius: inherit;
background: transparent; background: transparent;
@@ -85,18 +134,28 @@
.cardTop { .cardTop {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 8px;
min-width: 0; 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; display: inline-flex;
align-items: center; align-items: center;
justify-content: center;
flex: none; 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); 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); color: var(--dsw-alias-button-primary-fill);
} }
@@ -105,6 +164,7 @@
min-width: 0; min-width: 0;
font-size: 13px; font-size: 13px;
line-height: 20px; line-height: 20px;
font-weight: 600;
color: var(--dsw-alias-label-secondary); color: var(--dsw-alias-label-secondary);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@@ -115,12 +175,18 @@
color: var(--dsw-alias-label-primary); 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 { .cardCheck {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center;
flex: none; 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 { .cardDesc {
@@ -136,24 +202,28 @@
color: var(--dsw-alias-label-secondary); color: var(--dsw-alias-label-secondary);
} }
/* The gear corner button: bottom-right of cards that declare related /* Cards with a settings gear keep the desc clear of it: the gear sits in
settings; the desc gets right padding so the ellipsis never runs under it. */ the desc row, sharing the check badge's right edge (a vertical column of
controls at the card's right side). */
.cardWithGear .cardDesc { .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 { .cardGear {
position: absolute; position: absolute;
right: 6px; right: 12px;
bottom: 6px; top: 46px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 20px; width: 16px;
height: 20px; height: 16px;
padding: 0; padding: 0;
border: 0; border: 0;
border-radius: 6px; border-radius: 50%;
background: transparent; background: transparent;
color: var(--dsw-alias-label-tertiary); color: var(--dsw-alias-label-tertiary);
cursor: pointer; cursor: pointer;
@@ -164,26 +234,21 @@
background: var(--dsw-alias-interactive-bg-hover); background: var(--dsw-alias-interactive-bg-hover);
} }
/* Inventory section heading (常规 / 侧边栏内容 / 文件预览). */ /* Settings-row recipe (the DSH General section rows): title/desc left,
.sectionHeading { control right, hairline separators (stripped on the last row). */
padding: 14px 0 0;
font-size: 12px;
line-height: 17px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--dsw-alias-label-secondary);
}
/* Native settings row recipe (the width input row + popup rows). */
.row { .row {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 16px;
padding: 12px 0; padding: 12px 2px;
border-bottom: 1px solid var(--dsw-alias-border-l2); border-bottom: 1px solid var(--dsw-alias-border-l2);
} }
.row:last-child {
border-bottom: none;
}
.rowText { .rowText {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -199,45 +264,66 @@
.desc { .desc {
font-size: 12px; font-size: 12px;
line-height: 17px; line-height: 18px;
color: var(--dsw-alias-label-secondary); color: var(--dsw-alias-label-tertiary);
} }
/* Native checkbox: 16px, brand accent (the settings-row recipe). */ /* Custom switch: a real checkbox (native semantics + focus, visually
.toggle { hidden) driving a styled 36x20 track with a sliding thumb. */
.switch {
position: relative;
display: inline-flex;
flex: none; flex: none;
width: 16px; cursor: pointer;
height: 16px; }
.switchInput {
position: absolute;
width: 1px;
height: 1px;
margin: 0; margin: 0;
accent-color: var(--dsw-alias-button-primary-fill); opacity: 0;
cursor: pointer;
} }
.toggle:focus-visible { .switchTrack {
outline: 2px solid var(--dsw-alias-border-l4); display: inline-flex;
outline-offset: 2px;
}
/* Secondary settings popup body: stacked native rows, trailing separator
stripped by the trailing-child rule. */
.popupRows {
display: flex;
flex-direction: column;
width: 100%;
}
.popupRow {
display: flex;
align-items: center; align-items: center;
justify-content: space-between; width: 36px;
gap: 16px; height: 20px;
padding: 16px 0; padding: 2px;
border-bottom: 1px solid var(--dsw-alias-border-l2); box-sizing: border-box;
cursor: pointer; 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 { .switchThumb {
border-bottom: none; 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 { .control {
@@ -251,12 +337,74 @@
width: 76px; 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 { .suffix {
font-size: 14px; font-size: 14px;
line-height: 22px; line-height: 22px;
color: var(--dsw-alias-label-secondary); 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: the same settings-row recipe with switch controls. */
.popupRows {
display: flex;
flex-direction: column;
width: 100%;
}
.popupRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 2px;
border-bottom: 1px solid var(--dsw-alias-border-l2);
}
.popupRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.popupRows > :last-child {
border-bottom: none;
}
/* 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 { .error {
padding: 10px 0 2px; padding: 10px 0 2px;
font-size: 12px; font-size: 12px;
@@ -265,7 +413,9 @@
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.card { .card,
.switchTrack,
.switchThumb {
transition: none; transition: none;
} }
} }
+269 -114
View File
@@ -4,21 +4,30 @@
* *
* The section is DECLARATIVE — it renders the enable/disable inventory from * The section is DECLARATIVE — it renders the enable/disable inventory from
* the sidebar service's registries instead of hardcoding rows: * the sidebar service's registries instead of hardcoding rows:
* - 常规: new conversations open the panel by default (a toggle card), and * - 常规: new conversations open the panel by default (a toggle row), the
* the default panel width as a percent of the window (number input row). * 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 * - 侧边栏内容: one SMALL CARD per REGISTERED tab type (built-ins and
* external plugins alike), laid out in a responsive grid that wraps * external plugins alike), laid out in a responsive grid that wraps
* several cards per row — icon + title + type id, clicked to toggle the * several cards per row — icon chip + title + type id, clicked to toggle
* switch persisted in `prefs.tabsEnabled[id]`. * the switch persisted in `prefs.tabsEnabled[id]`.
* - 文件预览: one SMALL CARD per REGISTERED file viewer — icon + title + * - 文件预览: one SMALL CARD per REGISTERED file viewer — icon chip + title
* the extensions it covers, clicked to toggle `prefs.viewersEnabled[id]`. * + 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 * 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), * border + tinted fill + a circular check badge pinned to the card's far
* disabled = neutral and dimmed. Features that declare `settings.toggles` * right), disabled = neutral and dimmed. Features that declare
* carry a gear corner button that opens a native Modal with the related * `settings.toggles` carry a gear corner button that opens a native Modal
* settings as native checkbox rows (e.g. the Subagent page's "auto-open * (wider than the primitive default) with the related settings as
* when a subagent appears", the Terminal page's model terminal tools). * 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 * 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 * settings seam in-process — the DSH settings RPC domain does not serve
@@ -32,8 +41,6 @@
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react' import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
import { import {
IconCheckOutline16, IconCheckOutline16,
IconCodeOutline16,
IconPanelLeftOutline16,
IconSettingsOutline16, IconSettingsOutline16,
Input, Input,
Modal, Modal,
@@ -106,40 +113,133 @@ function prefBool(prefs: SidebarPrefs, key: string): boolean {
} }
/** /**
* The body of a feature's secondary settings popup: one native checkbox row * The custom switch: a real checkbox (hidden, native semantics and focus)
* per declared toggle. Extracted so the rows are testable without opening * driving a styled track/thumb. Used by the general toggle rows and the
* the Modal (the Modal portal renders only while open). * 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: { export function FeatureSettingsRows(props: {
toggles: readonly SidebarSettingToggle[] toggles: readonly SidebarSettingToggle[]
prefs: SidebarPrefs prefs: SidebarPrefs
onToggle: (toggle: SidebarSettingToggle, next: boolean) => void 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
}) { }) {
const { toggles, prefs, onToggle } = props const { toggles, prefs, onToggle, onCommit } = props
return ( return (
<div className={css.popupRows}> <div className={css.popupRows}>
{toggles.map(toggle => { {toggles.map(toggle => {
const title = textOf(toggle.title) 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={prefBool(prefs, toggle.key)}
onChange={(next) => { onToggle(toggle, next) }}
/>
</div>
)
}
const value = String((prefs as unknown as Record<string, unknown>)[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 ( return (
<label key={toggle.key} className={css.popupRow}> <TypedRow
<span className={css.rowText}> key={`${toggle.key}:${value}`}
<span className={css.title}>{title}</span> toggle={toggle}
{textOf(toggle.desc) !== '' && <span className={css.desc}>{textOf(toggle.desc)}</span>} title={title}
</span> value={value}
<input onCommit={onCommit}
type="checkbox" />
className={css.toggle}
checked={prefBool(prefs, toggle.key)}
aria-label={title}
onChange={event => { onToggle(toggle, event.currentTarget.checked) }}
/>
</label>
) )
})} })}
</div> </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>
)
}
/** /**
* Render the Side card preferences section. * Render the Side card preferences section.
* @param props - composed slot props (runtime share + injected store/service). * @param props - composed slot props (runtime share + injected store/service).
@@ -219,8 +319,8 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
setWidthDraft(String(settled.defaultWidthPercent)) setWidthDraft(String(settled.defaultWidthPercent))
} }
/** Optimistically flip one boolean pref, then commit (revert on failure). */ /** Optimistically apply one pref patch, then commit (revert on failure). */
const togglePref = (patch: Record<string, unknown>): void => { const applyPref = (patch: Record<string, unknown>): void => {
const previous = prefs const previous = prefs
setPrefs({ ...previous, ...patch } as SidebarPrefs) setPrefs({ ...previous, ...patch } as SidebarPrefs)
setError(null) setError(null)
@@ -228,22 +328,44 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
} }
const onToggle = (next: boolean): void => { const onToggle = (next: boolean): void => {
togglePref({ openByDefault: next }) applyPref({ openByDefault: next })
} }
/** Flip one per-tab enable switch (merge into the tabsEnabled map). */ /** Flip one per-tab enable switch (merge into the tabsEnabled map). */
const onToggleTab = (id: string, next: boolean): void => { const onToggleTab = (id: string, next: boolean): void => {
togglePref({ tabsEnabled: { ...prefs.tabsEnabled, [id]: next } }) applyPref({ tabsEnabled: { ...prefs.tabsEnabled, [id]: next } })
} }
/** Flip one per-viewer enable switch (merge into the viewersEnabled map). */ /** Flip one per-viewer enable switch (merge into the viewersEnabled map). */
const onToggleViewer = (id: string, next: boolean): void => { const onToggleViewer = (id: string, next: boolean): void => {
togglePref({ viewersEnabled: { ...prefs.viewersEnabled, [id]: next } }) applyPref({ viewersEnabled: { ...prefs.viewersEnabled, [id]: next } })
} }
/** Flip one declaratively-declared toggle (a SidebarPrefs boolean field). */ /** Flip one declaratively-declared toggle (a SidebarPrefs boolean field). */
const onToggleSetting = (toggle: SidebarSettingToggle, next: boolean): void => { 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
} }
const commitWidth = (): void => { const commitWidth = (): void => {
@@ -262,9 +384,10 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
/** /**
* One SMALL toggle card for the responsive inventory grid: the card's main * 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 * area is the switch (click to flips, visual state IS the state), the icon
* check badge sits at the far right, and a feature that declares related * sits in a rounded chip, the check badge pins to the far right, and a
* settings carries a gear corner button opening its settings popup. * feature that declares related settings carries a gear corner button
* opening its settings popup.
*/ */
const renderCard = (props: { const renderCard = (props: {
title: string title: string
@@ -289,12 +412,12 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
> >
<span className={css.cardTop}> <span className={css.cardTop}>
{props.icon !== null && props.icon !== undefined && ( {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> <span className={css.cardTitle}>{props.title}</span>
{props.enabled && ( {props.enabled && (
<span className={css.cardCheck}> <span className={css.cardCheck}>
<IconCheckOutline16 size={14} /> <IconCheckOutline16 size={12} />
</span> </span>
)} )}
</span> </span>
@@ -308,7 +431,7 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
title={t('settingsPopup')} title={t('settingsPopup')}
onClick={props.onOpenSettings} onClick={props.onOpenSettings}
> >
<IconSettingsOutline16 size={14} /> <IconSettingsOutline16 size={12} />
</button> </button>
)} )}
</div> </div>
@@ -317,101 +440,133 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
return ( return (
<div className={css.section}> <div className={css.section}>
<div className={css.sectionHeading}>{t('settingsGeneralTitle')}</div> <p className={css.intro}>{t('settingsIntro')}</p>
{renderCard({
title: t('settingsOpenTitle'), {/* 常规: the DSH settings-row recipe — title/desc left, control right. */}
desc: t('settingsOpenDesc'), <div className={css.group}>
icon: <IconPanelLeftOutline16 size={16} />, <div className={css.groupHeading}>{t('settingsGeneralTitle')}</div>
enabled: prefs.openByDefault, <div className={css.row}>
onToggle, <span className={css.rowText}>
})} <span className={css.title}>{t('settingsOpenTitle')}</span>
<div className={css.row}> <span className={css.desc}>{t('settingsOpenDesc')}</span>
<span className={css.rowText}> </span>
<span className={css.title}>{t('settingsWidthTitle')}</span> <Switch
<span className={css.desc}>{t('settingsWidthDesc')}</span> label={t('settingsOpenTitle')}
</span> checked={prefs.openByDefault}
<span className={css.control}> onChange={onToggle}
<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> </div>
</span> <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> </div>
{renderCard({
title: t('settingsOpenPathTitle'),
desc: t('settingsOpenPathDesc'),
icon: <IconCodeOutline16 size={16} />,
enabled: prefs.interceptOpenPath,
onToggle: (next) => { togglePref({ interceptOpenPath: next }) },
})}
{/* 侧边栏内容: one small card per registered tab type in a responsive {/* 侧边栏内容: one small card per registered tab type in a responsive
grid; features declaring `settings.toggles` open their settings in grid; features declaring `settings.toggles` open their settings in
the popup (gear corner button) instead of nested inline rows. */} the popup (gear corner button) instead of nested inline rows. */}
<div className={css.sectionHeading}>{t('settingsTabsTitle')}</div> <div className={css.group}>
<div className={css.grid}> <div className={css.groupHeading}>
{tabs.map(tab => ( <span>{t('settingsTabsTitle')}</span>
<Fragment key={tab.id}> <span className={css.count}>{tabs.length}</span>
{renderCard({ </div>
title: textOf(tab.title), <div className={css.grid}>
desc: tab.id, {tabs.map(tab => (
icon: iconOf(tab.icon, 16), <Fragment key={tab.id}>
enabled: prefs.tabsEnabled[tab.id] !== false, {renderCard({
onToggle: (next) => { onToggleTab(tab.id, next) }, title: textOf(tab.title),
// The settings gear only while the feature is enabled: its desc: tab.id,
// related settings are dormant while the feature is off. icon: iconOf(tab.icon, 16),
onOpenSettings: prefs.tabsEnabled[tab.id] !== false enabled: prefs.tabsEnabled[tab.id] !== false,
&& (tab.settings?.toggles?.length ?? 0) > 0 onToggle: (next) => { onToggleTab(tab.id, next) },
? () => { setSettingsFor(tab) } // The settings gear only while the feature is enabled: its
: undefined, // related settings are dormant while the feature is off.
})} onOpenSettings: prefs.tabsEnabled[tab.id] !== false
</Fragment> && (tab.settings?.toggles?.length ?? 0) > 0
))} ? () => { setSettingsFor(tab) }
: undefined,
})}
</Fragment>
))}
</div>
</div> </div>
{/* 文件预览: one small card per registered file viewer. */} {/* 文件预览: one small card per registered file viewer. */}
<div className={css.sectionHeading}>{t('settingsViewersTitle')}</div> <div className={css.group}>
<div className={css.grid}> <div className={css.groupHeading}>
{viewers.map(viewer => ( <span>{t('settingsViewersTitle')}</span>
<Fragment key={viewer.id}> <span className={css.count}>{viewers.length}</span>
{renderCard({ </div>
title: textOf(viewer.title) || viewer.id, <div className={css.grid}>
desc: viewer.exts.length === 0 ? t('settingsViewerCatchAll') : viewer.exts.join(' · '), {viewers.map(viewer => (
icon: iconOf(viewer.icon, 16), <Fragment key={viewer.id}>
enabled: prefs.viewersEnabled[viewer.id] !== false, {renderCard({
onToggle: (next) => { onToggleViewer(viewer.id, next) }, title: textOf(viewer.title) || viewer.id,
})} desc: viewer.exts.length === 0 ? t('settingsViewerCatchAll') : viewer.exts.join(' · '),
</Fragment> icon: iconOf(viewer.icon, 16),
))} enabled: prefs.viewersEnabled[viewer.id] !== false,
onToggle: (next) => { onToggleViewer(viewer.id, next) },
})}
</Fragment>
))}
</div>
</div> </div>
{/* The secondary settings popup: a feature's declared related settings {/* The secondary settings popup: a feature's declared related settings
as native checkbox rows (Modal chrome is the app's own). Mounted as title/desc + switch rows in a wider-than-default Modal with a
only while a feature is open — the Modal primitive runs hooks Done footer (Modal chrome is the app's own). Mounted only while a
unconditionally, so a closed-but-mounted Modal would break SSR feature is open — the Modal primitive runs hooks unconditionally,
(and the renderToString spec) under the test dual-react split. */} so a closed-but-mounted Modal would break SSR (and the
renderToString spec) under the test dual-react split. */}
{settingsFor !== null && ( {settingsFor !== null && (
<Modal <Modal
open open
onClose={() => { setSettingsFor(null) }} onClose={() => { setSettingsFor(null) }}
title={textOf(settingsFor.title)} title={textOf(settingsFor.title)}
description={t('settingsPopupDesc', { feature: textOf(settingsFor.title) })}
closeLabel={t('close')} closeLabel={t('close')}
className={css.popupDialog}
footer={(
<button type="button" className={css.done} onClick={() => { setSettingsFor(null) }}>
{t('settingsDone')}
</button>
)}
> >
<FeatureSettingsRows <FeatureSettingsRows
toggles={settingsFor.settings?.toggles ?? []} toggles={settingsFor.settings?.toggles ?? []}
prefs={prefs} prefs={prefs}
onToggle={onToggleSetting} onToggle={onToggleSetting}
onCommit={onCommitSetting}
/> />
</Modal> </Modal>
)} )}
+15 -11
View File
@@ -311,8 +311,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// ("squeezes the agent output area") — it starts at the app sidebar's // ("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 // right edge and ends at the details column's left edge (the details
// column sits between the center and the right panel). Measured directly // column sits between the center and the right panel). Measured directly
// from the AppFrame's center column DOM (children[1], the layout.css // from the AppFrame's center column DOM (the parent of the
// nth-child(2) column) so the bottom panel tracks the column's real // [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 // horizontal edges — including the animated margin-right push while the
// right panel opens/closes; a frame that never appears keeps the initial // right panel opens/closes; a frame that never appears keeps the initial
// zero-size fallback (the panel renders at 0 width until measured). // zero-size fallback (the panel renders at 0 width until measured).
@@ -340,17 +341,20 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
useEffect(() => { useEffect(() => {
let disposed = false let disposed = false
let observer: ResizeObserver | undefined let observer: ResizeObserver | undefined
// Locate the AppFrame's center column (children[1] of the frame div — // Locate the AppFrame's center column. DSH 0.1.x wraps slot hosts in
// layout.css's nth-child(2)). The shell swaps the boot page for the // [data-slot] containers: the conversation slot wrapper
// AppFrame only AFTER boot settles, so the first query may miss it. // ([data-slot="conversation"]) sits directly inside the center column,
// Never give up: watch #root's children (the swap mutates them) and // so its parent IS that column — no hashed-class or positional
// re-run this locator — querying once and bailing would strand the // dependency (layout.css uses the same anchor). The shell swaps the
// panel at the zero-size fallback forever (observed: a 1px sliver at // boot page for the AppFrame only AFTER boot settles, so the first
// the viewport's left edge). // 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 => { const locate = (): void => {
if (disposed) return if (disposed) return
const frame = document.querySelector('#root > div') const col = document.querySelector('#root [data-slot="conversation"]')
const col = frame?.children[1] as HTMLElement | undefined ?.parentElement as HTMLElement | undefined
if (col === undefined) { if (col === undefined) {
if (centerColRef.current !== null) { if (centerColRef.current !== null) {
centerColRef.current = null centerColRef.current = null
+48 -4
View File
@@ -24,9 +24,11 @@ import { Terminal, type ITheme } from 'xterm'
import { FitAddon } from '@xterm/addon-fit' import { FitAddon } from '@xterm/addon-fit'
import 'xterm/css/xterm.css' import 'xterm/css/xterm.css'
import { t } from './locales.ts' import { t } from './locales.ts'
import { openWhenSized } from './open-when-sized.ts'
import type { SessionScope } from './api.ts' import type { SessionScope } from './api.ts'
import { agentUuidOf, isAgentTabId, type SidebarStore } from './state.ts' import { agentUuidOf, isAgentTabId, type SidebarStore } from './state.ts'
import { isDarkScheme, subscribeColorScheme, tokenValue } from './theme.ts' import { isDarkScheme, subscribeColorScheme, tokenValue } from './theme.ts'
import { resolveTerminalFont } from './terminal-font.ts'
import css from './sidebar.module.css' import css from './sidebar.module.css'
/** How many consecutive unreasoned failures before showing the error banner. */ /** How many consecutive unreasoned failures before showing the error banner. */
@@ -82,10 +84,13 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
useEffect(() => { useEffect(() => {
const host = hostRef.current const host = hostRef.current
if (host === null) return 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({ const term = new Terminal({
cursorBlink: true, cursorBlink: true,
fontSize: 13, fontSize: font.fontSize,
fontFamily: tokenValue('--ds-font-family-code') || '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace', fontFamily: font.fontFamily,
allowTransparency: true, allowTransparency: true,
convertEol: false, convertEol: false,
scrollback: 4000, scrollback: 4000,
@@ -93,8 +98,6 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
}) })
const fit = new FitAddon() const fit = new FitAddon()
term.loadAddon(fit) term.loadAddon(fit)
term.open(host)
fit.fit()
// Re-theme in place when the app's scheme flips (tokens + palette). // Re-theme in place when the app's scheme flips (tokens + palette).
const applyTheme = (): void => { const applyTheme = (): void => {
term.options.theme = xtermTheme() term.options.theme = xtermTheme()
@@ -185,11 +188,52 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
}) })
observer.observe(host) 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() connect()
return () => { return () => {
closed = true closed = true
cancelOpen()
window.clearTimeout(retry) window.clearTimeout(retry)
observer.disconnect() observer.disconnect()
fontSub()
schemeSub() schemeSub()
inputSub.dispose() inputSub.dispose()
// The close frame tells the host the owning tab is GONE (immediate // The close frame tells the host the owning tab is GONE (immediate
+9 -1
View File
@@ -329,7 +329,15 @@ export function TextEditor(props: FileViewerProps) {
onMouseUp={handlePreviewMouseUp} onMouseUp={handlePreviewMouseUp}
onScroll={hidePopup} onScroll={hidePopup}
> >
<MarkdownText text={draft ?? content ?? ''} /> {/* 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> </div>
)} )}
{html && mode === 'preview' && ( {html && mode === 'preview' && (
+20 -4
View File
@@ -19,6 +19,7 @@ import { DiffTab } from '../DiffTab.tsx'
import { SubagentView } from '../SubagentView.tsx' import { SubagentView } from '../SubagentView.tsx'
import { BrowserView } from '../BrowserView.tsx' import { BrowserView } from '../BrowserView.tsx'
import { IconTerminalOutline16, IconDiffOutline16, IconGlobeOutline16 } from '../icons.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 { ComponentType } from 'react'
import type { SessionScope } from '../api.ts' import type { SessionScope } from '../api.ts'
import type { SidebarStore } from '../state.ts' import type { SidebarStore } from '../state.ts'
@@ -137,10 +138,11 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
icon: (size: number) => <IconTerminalOutline16 size={size} />, icon: (size: number) => <IconTerminalOutline16 size={size} />,
order: 40, order: 40,
available: (_ctx, _scope, state) => uiTerminalCount(state) < TERMINAL_LIMIT, available: (_ctx, _scope, state) => uiTerminalCount(state) < TERMINAL_LIMIT,
// Declarative settings: the model-facing terminal tools switch and the // Declarative settings: the model-facing terminal tools switch, the
// bottom-panel first-expansion auto-terminal switch render under this // bottom-panel first-expansion auto-terminal switch, and the custom
// row in the Side card settings page (the Terminal page's own related // font family/size rows render under this card in the Side card
// settings; the host gates the toolset on the tools one independently). // settings page (the host gates the toolset on the tools one
// independently; the font rows apply live to every terminal).
settings: { settings: {
toggles: [{ toggles: [{
key: 'agentTerminalTools', key: 'agentTerminalTools',
@@ -150,6 +152,20 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
key: 'bottomPanelAutoTerminal', key: 'bottomPanelAutoTerminal',
title: () => t('settingsBottomTerminalTitle'), title: () => t('settingsBottomTerminalTitle'),
desc: () => t('settingsBottomTerminalDesc'), 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) => { createTab: (state) => {
+16 -3
View File
@@ -57,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 { 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', name: 'conversation.chat.turnTail',
// Decline the takeover while the editor tab type is disabled in the side // Decline the takeover while the editor tab type is disabled in the side
// card settings: the produced-files row falls back to the default // card settings: the produced-files row falls back to the default
@@ -73,7 +86,7 @@ export function registerTurnTailInterception(ctx: Context, store: SidebarStore):
inject: (sessionId: string) => ({ inject: (sessionId: string) => ({
openInSidebar: (path: string) => { openSidebarFile(ctx, store, sessionId, path) }, openInSidebar: (path: string) => { openSidebarFile(ctx, store, sessionId, path) },
}), }),
}, SidebarProducedFiles) }, SidebarProducedFiles))
} }
/** /**
+19 -14
View File
@@ -6,11 +6,13 @@
* conversation output and the input bar, like a VSCode sidebar. * conversation output and the input bar, like a VSCode sidebar.
* *
* The bottom panel squeezes ONLY the center column — it must not cover the * The bottom panel squeezes ONLY the center column — it must not cover the
* app's own left sidebar or the right panel. The AppFrame's grid items are * app's own left sidebar or the right panel. DSH 0.1.x wraps slot hosts in
* (#root > div > div:nth-child(2)): sidebarCol, centerCol, detailsCol, so * [data-slot] containers, so the AppFrame grid lives one level deeper:
* the vertical push lands on the center column alone. The fixed panels * #root > div[data-slot="root"] > div (the frame). Its grid items are
* cover the vacated strips, which looks seamless because #root's background * (frame > div:nth-child(1..3)): sidebarCol, centerCol, detailsCol, so the
* is the theme base. * 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 * The sizes ride CSS variables updated by the Sidebar shell (0 while
* collapsed); expand/collapse animates both the margins and the panel * collapsed); expand/collapse animates both the margins and the panel
@@ -23,10 +25,10 @@
} }
/* The AppFrame's grid items are sidebarCol, centerCol, detailsCol (children /* The AppFrame's grid items are sidebarCol, centerCol, detailsCol (children
1-3 of #root > div) — nth-child(2) is the center column. A stretched grid 1-3 of #root > div[data-slot="root"] > div) — nth-child(2) is the center
item shrinks by its margins, so the conversation content (output + input column. A stretched grid item shrinks by its margins, so the conversation
bar) lifts without touching the sidebars. */ content (output + input bar) lifts without touching the sidebars. */
#root > div > div:nth-child(2) { #root > div[data-slot="root"] > div > div:nth-child(2) {
margin-bottom: var(--dsh-sidebar-height, 0px); margin-bottom: var(--dsh-sidebar-height, 0px);
transition: margin-bottom var(--ds-transition-duration-slow) var(--ds-ease-in-out); transition: margin-bottom var(--ds-transition-duration-slow) var(--ds-ease-in-out);
} }
@@ -35,20 +37,23 @@
corner. Push the DSH session header's right padding out so its right-aligned 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 utilities (the "Session log" download capsule) yield the corner instead of
hiding under the cluster. The header default right-pads 28px; the 2-button 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. The cluster spans right 10→70px, so 78px clears it with an 8px gap. Anchor on
Sidebar shell toggles the body attribute with the panel open state. */ the header's slot host wrapper ([data-slot="conversation.session.header"])
body[data-dsh-sidebar-collapsed] #root > div > div:nth-child(2) 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; padding-right: 78px;
} }
body[data-dsh-sidebar-dragging] #root, body[data-dsh-sidebar-dragging] #root,
body[data-dsh-sidebar-dragging] #root > div > div:nth-child(2) { body[data-dsh-sidebar-dragging] #root > div[data-slot="root"] > div > div:nth-child(2) {
transition: none; transition: none;
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
#root, #root,
#root > div > div:nth-child(2) { #root > div[data-slot="root"] > div > div:nth-child(2) {
transition: none; transition: none;
} }
} }
+18
View File
@@ -106,6 +106,9 @@ export const zh = {
copyAbsolute: '复制绝对地址', copyAbsolute: '复制绝对地址',
download: '下载', download: '下载',
settingsNav: '侧边卡片', settingsNav: '侧边卡片',
settingsIntro: '管理侧边卡片的显示内容与默认行为',
settingsPopupDesc: '为「{feature}」配置相关选项',
settingsDone: '完成',
settingsOpenTitle: '新会话默认打开', settingsOpenTitle: '新会话默认打开',
settingsOpenDesc: '新建会话时自动展开侧边卡片;已存在的会话保持各自布局', settingsOpenDesc: '新建会话时自动展开侧边卡片;已存在的会话保持各自布局',
settingsWidthTitle: '默认宽度占比', settingsWidthTitle: '默认宽度占比',
@@ -133,6 +136,12 @@ export const zh = {
settingsToolsDesc: '开启后,模型可通过 terminal_create 等 8 个工具创建并操作侧边栏终端(默认关闭)', settingsToolsDesc: '开启后,模型可通过 terminal_create 等 8 个工具创建并操作侧边栏终端(默认关闭)',
settingsBottomTerminalTitle: '底部面板首次展开自动开终端', settingsBottomTerminalTitle: '底部面板首次展开自动开终端',
settingsBottomTerminalDesc: '每次会话中第一次展开底部面板时,尝试在底部面板自动打开一个新终端标签(终端数量上限仍会限制;默认开启)', settingsBottomTerminalDesc: '每次会话中第一次展开底部面板时,尝试在底部面板自动打开一个新终端标签(终端数量上限仍会限制;默认开启)',
settingsFontFamilyTitle: '终端字体',
settingsFontFamilyDesc: '自定义终端字体族(CSS font-family,如 "JetBrains Mono", monospace;留空跟随主题等宽字体)',
settingsFontFamilyPlaceholder: '"JetBrains Mono", monospace',
settingsFontSizeTitle: '终端字号',
settingsFontSizeDesc: '终端字号(9–32,默认 13)',
settingsFontSizeSuffix: 'px',
settingsTabsTitle: '侧边栏内容', settingsTabsTitle: '侧边栏内容',
settingsViewersTitle: '文件预览', settingsViewersTitle: '文件预览',
settingsGeneralTitle: '常规', settingsGeneralTitle: '常规',
@@ -307,6 +316,9 @@ export const en: Record<keyof typeof zh, string> = {
copyAbsolute: 'Copy absolute path', copyAbsolute: 'Copy absolute path',
download: 'Download', download: 'Download',
settingsNav: 'Side card', 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', settingsOpenTitle: 'Open by default for new conversations',
settingsOpenDesc: 'Expand the side card automatically for brand-new conversations; existing conversations keep their own layouts', settingsOpenDesc: 'Expand the side card automatically for brand-new conversations; existing conversations keep their own layouts',
settingsWidthTitle: 'Default width share', settingsWidthTitle: 'Default width share',
@@ -334,6 +346,12 @@ export const en: Record<keyof typeof zh, string> = {
settingsToolsDesc: 'When enabled, the model can create and drive sidebar terminals through the 8 terminal_* tools (off by default)', 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', 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)', 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', settingsTabsTitle: 'Sidebar content',
settingsViewersTitle: 'File viewers', settingsViewersTitle: 'File viewers',
settingsGeneralTitle: 'General', settingsGeneralTitle: 'General',
+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
}
}
}
+8 -1
View File
@@ -10,12 +10,13 @@
*/ */
import type { api } from './api.ts' import type { api } from './api.ts'
import { import {
clampTerminalFontSize,
clampWidthPercent, clampWidthPercent,
SIDEBAR_PREFS_DEFAULTS, SIDEBAR_PREFS_DEFAULTS,
type SidebarPrefs, type SidebarPrefs,
} from '../prefs-shared.ts' } from '../prefs-shared.ts'
export { SIDEBAR_PREFS_DEFAULTS, clampWidthPercent } export { SIDEBAR_PREFS_DEFAULTS, clampTerminalFontSize, clampWidthPercent }
export type { SidebarPrefs } export type { SidebarPrefs }
/** The settings wire face the preferences need (a subset of the plugin api). */ /** The settings wire face the preferences need (a subset of the plugin api). */
@@ -49,6 +50,12 @@ export function parsePrefs(value: unknown): SidebarPrefs {
bottomPanelAutoTerminal: typeof record.bottomPanelAutoTerminal === 'boolean' bottomPanelAutoTerminal: typeof record.bottomPanelAutoTerminal === 'boolean'
? record.bottomPanelAutoTerminal ? record.bottomPanelAutoTerminal
: SIDEBAR_PREFS_DEFAULTS.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' interceptOpenPath: typeof record.interceptOpenPath === 'boolean'
? record.interceptOpenPath ? record.interceptOpenPath
: SIDEBAR_PREFS_DEFAULTS.interceptOpenPath, : SIDEBAR_PREFS_DEFAULTS.interceptOpenPath,
+22 -5
View File
@@ -28,9 +28,15 @@ import {
import { isNarrowWidth } from './breakpoints.ts' import { isNarrowWidth } from './breakpoints.ts'
import type { SessionScope } from './api.ts' import type { SessionScope } from './api.ts'
/** One declarative boolean setting of a tab/viewer, rendered as a nested /** The row control a declarative setting renders as in the settings popup. */
* switch row in the Side card settings page (e.g. the Subagent page's export type SidebarSettingToggleType = 'switch' | 'text' | 'number'
* "auto-open when a subagent appears"). */
/** 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 { export interface SidebarSettingToggle {
/** The SidebarPrefs field this toggle reads and writes ('autoOpenSubagent'). */ /** The SidebarPrefs field this toggle reads and writes ('autoOpenSubagent'). */
key: string key: string
@@ -38,15 +44,26 @@ export interface SidebarSettingToggle {
title: string | (() => string) title: string | (() => string)
/** Row description (i18n friendly). */ /** Row description (i18n friendly). */
desc?: string | (() => string) 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
} }
/** Declarative settings of one registered tab or file viewer. */ /** Declarative settings of one registered tab or file viewer. */
export interface SidebarSettingsDeclaration { 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 * settings page (only while the feature is enabled). Keys must be fields
* of the host's PrefsSchema (built-ins: 'autoOpenSubagent', * 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[] toggles?: readonly SidebarSettingToggle[]
} }
+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),
}
}
+8
View File
@@ -9,6 +9,9 @@ import z from 'schemastery'
import { import {
SIDEBAR_PREFS_DEFAULTS, SIDEBAR_PREFS_DEFAULTS,
SIDEBAR_PREFS_NS, SIDEBAR_PREFS_NS,
TERMINAL_FONT_SIZE_DEFAULT,
TERMINAL_FONT_SIZE_MAX,
TERMINAL_FONT_SIZE_MIN,
WIDTH_PERCENT_DEFAULT, WIDTH_PERCENT_DEFAULT,
WIDTH_PERCENT_MAX, WIDTH_PERCENT_MAX,
WIDTH_PERCENT_MIN, WIDTH_PERCENT_MIN,
@@ -18,6 +21,9 @@ import {
export { export {
SIDEBAR_PREFS_DEFAULTS, SIDEBAR_PREFS_DEFAULTS,
SIDEBAR_PREFS_NS, SIDEBAR_PREFS_NS,
TERMINAL_FONT_SIZE_DEFAULT,
TERMINAL_FONT_SIZE_MAX,
TERMINAL_FONT_SIZE_MIN,
WIDTH_PERCENT_DEFAULT, WIDTH_PERCENT_DEFAULT,
WIDTH_PERCENT_MAX, WIDTH_PERCENT_MAX,
WIDTH_PERCENT_MIN, WIDTH_PERCENT_MIN,
@@ -82,6 +88,8 @@ export const PrefsSchema: z<SidebarPrefs> = z.object({
autoOpenJobs: z.boolean().default(true), autoOpenJobs: z.boolean().default(true),
agentTerminalTools: z.boolean().default(false), agentTerminalTools: z.boolean().default(false),
bottomPanelAutoTerminal: z.boolean().default(true), 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), interceptOpenPath: z.boolean().default(true),
htmlViewerNoSandbox: z.boolean().default(false), htmlViewerNoSandbox: z.boolean().default(false),
htmlViewerDefaultUnsafe: z.boolean().default(false), htmlViewerDefaultUnsafe: z.boolean().default(false),
+14
View File
@@ -428,4 +428,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 } export type { Context }
+24
View File
@@ -33,6 +33,18 @@ export interface SidebarPrefs {
* user explicitly enables it in the side card settings. * user explicitly enables it in the side card settings.
*/ */
agentTerminalTools: boolean 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 * 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 * to open a fresh terminal tab there (the terminal quota/type still gates
@@ -104,6 +116,11 @@ export const WIDTH_PERCENT_MIN = 20
export const WIDTH_PERCENT_MAX = 60 export const WIDTH_PERCENT_MAX = 60
export const WIDTH_PERCENT_DEFAULT = 30 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
/** Fallback prefs used whenever the settings document is unreachable or malformed. */ /** Fallback prefs used whenever the settings document is unreachable or malformed. */
export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = { export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
openByDefault: true, openByDefault: true,
@@ -112,6 +129,8 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
autoOpenJobs: true, autoOpenJobs: true,
agentTerminalTools: false, agentTerminalTools: false,
bottomPanelAutoTerminal: true, bottomPanelAutoTerminal: true,
terminalFontFamily: '',
terminalFontSize: TERMINAL_FONT_SIZE_DEFAULT,
interceptOpenPath: true, interceptOpenPath: true,
htmlViewerNoSandbox: false, htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false, htmlViewerDefaultUnsafe: false,
@@ -125,3 +144,8 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
export function clampWidthPercent(value: number): number { export function clampWidthPercent(value: number): number {
return Math.min(WIDTH_PERCENT_MAX, Math.max(WIDTH_PERCENT_MIN, Math.round(value))) 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)))
}
+14 -2
View File
@@ -47,10 +47,22 @@ describe('built-in tab registrations', () => {
expect(toggles.map(t => t.key)).toEqual(['autoOpenSubagent', 'autoOpenJobs']) expect(toggles.map(t => t.key)).toEqual(['autoOpenSubagent', 'autoOpenJobs'])
}) })
it('the terminal tab declares the model terminal-tools related setting', () => { it('the terminal tab declares the model terminal-tools, auto-terminal and custom-font settings', () => {
const { service } = setup() const { service } = setup()
const toggles = service.getTab('terminal')?.settings?.toggles ?? [] const toggles = service.getTab('terminal')?.settings?.toggles ?? []
expect(toggles.map(t => t.key)).toEqual(['agentTerminalTools', 'bottomPanelAutoTerminal']) expect(toggles.map(t => t.key)).toEqual(['agentTerminalTools', 'bottomPanelAutoTerminal', 'terminalFontFamily', 'terminalFontSize'])
// The font rows are text/number inputs (not switches), with the size
// row bounded by the shared 9–32 contract.
expect(toggles[2]?.type).toBe('text')
expect(toggles[2]?.title).toBeDefined()
expect(toggles[2]?.placeholder).toBeDefined()
expect(toggles[3]?.type).toBe('number')
expect(toggles[3]?.min).toBe(9)
expect(toggles[3]?.max).toBe(32)
expect(toggles[3]?.unit).toBe('px')
// The first two rows stay plain boolean switches.
expect(toggles[0]?.type ?? 'switch').toBe('switch')
expect(toggles[1]?.type ?? 'switch').toBe('switch')
}) })
it('the browser tab declares its sandbox and link-takeover related settings', () => { it('the browser tab declares its sandbox and link-takeover related settings', () => {
+73
View File
@@ -0,0 +1,73 @@
/**
* Markdown preview copy-label spec: the fence copy buttons ("复制" / "Copy")
* must follow the DSH locale service through `codeLabels` — the DSH
* MarkdownText/CodeBlock are cordis-free and fall back to HARDCODED Chinese
* when the caller omits the labels, so TextEditor must pass its own
* dictionary copy (`t('copy')` / `t('copied')`), re-evaluated per render.
* Rendered with renderToString (the initial preview mode never mounts
* CodeMirror, so no DOM/effects are needed).
*/
import { describe, expect, it, afterEach } from 'vitest'
import { renderToString } from 'react-dom/server'
import { createElement } from 'react'
import './browser-globals.ts'
import { TextEditor } from '../src/client/TextEditor.tsx'
import { createSidebarStore } from '../src/client/state.ts'
import { attachLocale } from '../src/client/locales.ts'
import type { FileViewerProps } from '../src/client/service.ts'
/** Minimal structural fake of the DSH LocaleService face the sidebar uses. */
class FakeLocale {
active: string = 'zh'
getSnapshot(): { active: string } {
return { active: this.active }
}
subscribe(_fn: () => void): () => void {
return () => {}
}
register(_ns: string, _locale: string, _dict: Record<string, string>): () => void {
return () => {}
}
}
const CTX = {} as Parameters<typeof TextEditor>[0]['ctx']
/** A markdown source with one fenced code block (the copy-button surface). */
const MD_WITH_FENCE = '```ts\nconst a = 1\n```'
function viewerProps(overrides: Partial<FileViewerProps> = {}): FileViewerProps {
return {
ctx: CTX,
store: createSidebarStore(),
scope: { sessionId: 's1', cwd: '/p' },
path: '/p/a/README.md',
title: 'README.md',
viewerId: 'markdown',
content: MD_WITH_FENCE,
...overrides,
}
}
afterEach(() => {
attachLocale(undefined)
})
describe('markdown preview code-block copy labels (DSH i18n following)', () => {
it('renders the fence copy button with the zh dictionary label by default', () => {
const locale = new FakeLocale()
locale.active = 'zh'
attachLocale(locale)
const html = renderToString(createElement(TextEditor, viewerProps()))
expect(html).toContain('复制')
expect(html).not.toContain('Copy')
})
it('follows the attached locale service live: en renders "Copy" instead of the zh label', () => {
const locale = new FakeLocale()
locale.active = 'en'
attachLocale(locale)
const html = renderToString(createElement(TextEditor, viewerProps()))
expect(html).toContain('Copy')
expect(html).not.toContain('复制')
})
})
+138
View File
@@ -0,0 +1,138 @@
/**
* openWhenSized tests: the deferred one-shot open guard for hosts that may
* not have a real size yet (xterm crashes when opened in a zero-size
* container — the WKWebView bottom-panel blank-terminal bug, issue #25).
* The polling must open exactly once, only once the host reports a real
* size, stop when the host leaves the document, and cancel cleanly.
*/
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import { openWhenSized } from '../src/client/open-when-sized.ts'
/** A manually-stepped raf/caf pair so the polling is deterministic. */
function makeScheduler(): {
raf: (cb: FrameRequestCallback) => number
caf: (id: number) => void
tick: () => void
pending: () => number
} {
let nextId = 0
const frames = new Map<number, FrameRequestCallback>()
return {
raf: (cb) => { const id = ++nextId; frames.set(id, cb); return id },
caf: (id) => { frames.delete(id) },
tick: () => {
const cbs = [...frames.values()]
frames.clear()
for (const cb of cbs) cb(0)
},
pending: () => frames.size,
}
}
/** A host whose reported size can be changed between ticks. */
function makeHost(width: number, height: number): {
el: HTMLElement
setSize: (w: number, h: number) => void
} {
const el = document.createElement('div')
document.body.appendChild(el)
const setSize = (w: number, h: number): void => {
Object.defineProperty(el, 'clientWidth', { value: w, configurable: true })
Object.defineProperty(el, 'clientHeight', { value: h, configurable: true })
}
setSize(width, height)
return { el, setSize }
}
describe('openWhenSized', () => {
it('opens on the first tick when the host already has a size, then stops', () => {
const { raf, caf, tick, pending } = makeScheduler()
const host = makeHost(320, 200)
let opened = 0
const cancel = openWhenSized(host.el, () => { opened += 1 }, raf, caf)
tick()
expect(opened).toBe(1)
expect(pending()).toBe(0)
tick()
expect(opened).toBe(1)
cancel()
})
it('defers while the host is zero-sized and opens exactly once once sized', () => {
const { raf, caf, tick, pending } = makeScheduler()
const host = makeHost(0, 0)
let opened = 0
const cancel = openWhenSized(host.el, () => { opened += 1 }, raf, caf)
tick()
tick()
tick()
expect(opened).toBe(0)
expect(pending()).toBe(1)
host.setSize(320, 200)
tick()
expect(opened).toBe(1)
expect(pending()).toBe(0)
tick()
expect(opened).toBe(1)
cancel()
})
it('opens when only one dimension was missing', () => {
const { raf, caf, tick } = makeScheduler()
const host = makeHost(320, 0)
let opened = 0
const cancel = openWhenSized(host.el, () => { opened += 1 }, raf, caf)
tick()
expect(opened).toBe(0)
host.setSize(320, 120)
tick()
expect(opened).toBe(1)
cancel()
})
it('cancels a pending open and leaves no scheduled frames', () => {
const { raf, caf, tick, pending } = makeScheduler()
const host = makeHost(0, 0)
let opened = 0
const cancel = openWhenSized(host.el, () => { opened += 1 }, raf, caf)
tick()
cancel()
expect(pending()).toBe(0)
host.setSize(320, 200)
tick()
expect(opened).toBe(0)
// Idempotent: cancelling again is a no-op.
cancel()
})
it('stops polling when the host leaves the document', () => {
const { raf, caf, tick, pending } = makeScheduler()
const host = makeHost(0, 0)
let opened = 0
const cancel = openWhenSized(host.el, () => { opened += 1 }, raf, caf)
tick()
host.el.remove()
tick()
expect(opened).toBe(0)
expect(pending()).toBe(0)
// Even if the host somehow got a size after detaching, nothing opens.
host.setSize(320, 200)
tick()
expect(opened).toBe(0)
cancel()
})
it('does not swallow exceptions from open (the caller owns error handling)', () => {
const { raf, caf, tick } = makeScheduler()
const host = makeHost(320, 200)
let calls = 0
const cancel = openWhenSized(host.el, () => {
calls += 1
throw new Error('boom')
}, raf, caf)
expect(() => tick()).toThrow('boom')
expect(calls).toBe(1)
cancel()
})
})
+5 -1
View File
@@ -50,6 +50,10 @@ describe('dsh-better-sidebar plugin export shape', () => {
// The terminal tools default OFF (the feature is dormant until the user // The terminal tools default OFF (the feature is dormant until the user
// enables it in the side card settings). // enables it in the side card settings).
expect(resolved.agentTerminalTools).toBe(false) expect(resolved.agentTerminalTools).toBe(false)
// The terminal font customizations default to the theme (empty family)
// and 13px.
expect(resolved.terminalFontFamily).toBe('')
expect(resolved.terminalFontSize).toBe(13)
// The enable-switch maps resolve to {} (everything on) for old documents. // The enable-switch maps resolve to {} (everything on) for old documents.
expect(resolved.tabsEnabled).toEqual({}) expect(resolved.tabsEnabled).toEqual({})
expect(resolved.viewersEnabled).toEqual({}) expect(resolved.viewersEnabled).toEqual({})
@@ -59,6 +63,6 @@ describe('dsh-better-sidebar plugin export shape', () => {
const overridden = (PrefsSchema as unknown as { const overridden = (PrefsSchema as unknown as {
(input: Record<string, unknown> | undefined): Record<string, unknown> (input: Record<string, unknown> | undefined): Record<string, unknown>
})({ openByDefault: false, defaultWidthPercent: 45 }) })({ openByDefault: false, defaultWidthPercent: 45 })
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} }) expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
}) })
}) })
+175
View File
@@ -0,0 +1,175 @@
/**
* Interactive tests for the settings popup's text/number rows: the draft is
* local state committed on blur through the parent's onCommit; the parent's
* canonical return is adopted (clamped numbers, stored value for invalid
* input), and the row remounts when the committed pref value changes (a
* failed commit reverts prefs → the stored value reappears).
*
* Rendered with createRoot + act() in jsdom (the SSR specs stay in
* side-card-section.spec.tsx; this file exercises the event paths).
*/
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import { createElement, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
// The act() environment flag (React 18.2 reads it before flushing effects).
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
import type { SidebarSettingToggle } from '../src/client/service.ts'
import { FeatureSettingsRows } from '../src/client/SideCardSection.tsx'
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
/** Render the rows into a detached container under React's act(). */
function mount(node: ReactNode): { container: HTMLDivElement; rerender: (node: ReactNode) => void; unmount: () => void } {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
act(() => { root.render(node) })
return {
container,
rerender: (next) => { act(() => { root.render(next) }) },
unmount: () => {
act(() => { root.unmount() })
container.remove()
},
}
}
/** Type into an input and commit it via blur (React 18: input event +
* focusout). The native setter bypasses React's value tracker so the
* change is actually seen. */
function typeAndBlur(input: HTMLInputElement, value: string): void {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!
act(() => {
setter.call(input, value)
input.dispatchEvent(new Event('input', { bubbles: true }))
input.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))
})
}
const prefs = { ...SIDEBAR_PREFS_DEFAULTS }
describe('FeatureSettingsRows typed rows (interactive)', () => {
it('commits the raw text on blur and adopts the canonical return', () => {
const commits: Array<[string, string]> = []
const toggle: SidebarSettingToggle = {
key: 'terminalFontFamily',
type: 'text',
title: () => 'Font family',
}
const { container, unmount } = mount(createElement(FeatureSettingsRows, {
toggles: [toggle],
prefs,
onToggle: () => {},
onCommit: (t, raw) => {
commits.push([t.key, raw])
return raw
},
}))
const input = container.querySelector('input')!
typeAndBlur(input, 'Monaco')
expect(commits).toEqual([['terminalFontFamily', 'Monaco']])
// The canonical return is adopted into the draft.
expect(input.value).toBe('Monaco')
unmount()
})
it('clamps numbers into the declared bounds on commit', () => {
const commits: Array<[string, number]> = []
const toggle: SidebarSettingToggle = {
key: 'terminalFontSize',
type: 'number',
title: () => 'Font size',
min: 9,
max: 32,
}
const { container, unmount } = mount(createElement(FeatureSettingsRows, {
toggles: [toggle],
prefs: { ...prefs, terminalFontSize: 13 },
onToggle: () => {},
onCommit: (t, raw) => {
const parsed = Number(raw)
const clamped = Math.min(32, Math.max(9, Math.round(parsed)))
commits.push([t.key, clamped])
return String(clamped)
},
}))
const input = container.querySelector('input')!
typeAndBlur(input, '40')
expect(commits).toEqual([['terminalFontSize', 32]])
expect(input.value).toBe('32')
unmount()
})
it('clamps an emptied number input to the lower bound on commit (width-row precedent)', () => {
const commits: Array<[string, number]> = []
const toggle: SidebarSettingToggle = {
key: 'terminalFontSize',
type: 'number',
title: () => 'Font size',
min: 9,
max: 32,
}
const { container, unmount } = mount(createElement(FeatureSettingsRows, {
toggles: [toggle],
prefs: { ...prefs, terminalFontSize: 13 },
onToggle: () => {},
// The parent mirrors the real handler: an emptied number parses to 0
// and clamps into the bounds (a browser number input never holds a
// non-numeric string — the draft can only be empty or numeric).
onCommit: (t, raw) => {
const clamped = Math.min(32, Math.max(9, Math.round(Number(raw))))
commits.push([t.key, clamped])
return String(clamped)
},
}))
const input = container.querySelector('input')!
typeAndBlur(input, '')
expect(commits).toEqual([['terminalFontSize', 9]])
expect(input.value).toBe('9')
unmount()
})
it('reverts the draft to the committed value after a failed commit reverts prefs', () => {
const toggle: SidebarSettingToggle = {
key: 'terminalFontFamily',
type: 'text',
title: () => 'Font family',
}
// The parent mirrors the real handler: the optimistic commit adopts the
// typed value, and a FAILED write reverts prefs to the stored one.
let prefsNow = { ...prefs, terminalFontFamily: 'Old' }
const { container, rerender, unmount } = mount(createElement(FeatureSettingsRows, {
toggles: [toggle],
prefs: prefsNow,
onToggle: () => {},
onCommit: (_t, raw) => raw,
}))
const input = container.querySelector('input')!
typeAndBlur(input, 'New')
// Optimistic commit: the draft adopts the typed value.
expect(input.value).toBe('New')
// The optimistic pref lands (parent state): the key changes, the row
// remounts with the same value — no visible reset while editing.
prefsNow = { ...prefsNow, terminalFontFamily: 'New' }
rerender(createElement(FeatureSettingsRows, {
toggles: [toggle],
prefs: prefsNow,
onToggle: () => {},
onCommit: (_t, raw) => raw,
}))
expect(container.querySelector('input')!.value).toBe('New')
// The write fails: prefs revert to the stored value and the row
// remounts with it (the stale draft is gone).
prefsNow = { ...prefsNow, terminalFontFamily: 'Old' }
rerender(createElement(FeatureSettingsRows, {
toggles: [toggle],
prefs: prefsNow,
onToggle: () => {},
onCommit: (_t, raw) => raw,
}))
expect(container.querySelector('input')!.value).toBe('Old')
unmount()
})
})
+71 -10
View File
@@ -5,9 +5,11 @@
* copy. The toggles are CARDS in a responsive grid: the card's main area is * copy. The toggles are CARDS in a responsive grid: the card's main area is
* the switch, the visual state IS the state (highlighted = enabled), * the switch, the visual state IS the state (highlighted = enabled),
* announced via `aria-pressed`, and the check badge sits at the far right. * announced via `aria-pressed`, and the check badge sits at the far right.
* Features that declare related settings carry a gear corner button whose * The general rows follow the DSH settings-row recipe with custom SWITCHES
* popup rows (native checkboxes) are tested through the extracted * (real checkboxes driving a styled track). Features that declare related
* FeatureSettingsRows component (the Modal portal renders only while open). * settings carry a gear corner button whose popup rows (switch controls)
* are tested through the extracted FeatureSettingsRows component (the Modal
* portal renders only while open).
* *
* Rendered with renderToString (mount effects — the settings RPC sync — do * Rendered with renderToString (mount effects — the settings RPC sync — do
* not run in SSR; the initial store prefs are the render input). * not run in SSR; the initial store prefs are the render input).
@@ -78,14 +80,27 @@ describe('SideCardSection declarative inventory', () => {
expect(html).toContain('>explorer<') expect(html).toContain('>explorer<')
expect(html).toContain('data-icon="subagent"') expect(html).toContain('data-icon="subagent"')
expect(html).toContain('>Subagents<') expect(html).toContain('>Subagents<')
// Default prefs: openByDefault + interceptOpenPath + both tabs + the // Default prefs: openByDefault + interceptOpenPath switches checked, and
// image viewer are all enabled → 5 cards pressed, none pressed=false. // both tabs + the image viewer cards pressed (3 aria-pressed cards).
// The nested auto-open toggle is NOT an inline card (it lives in the popup). // The nested auto-open toggle is NOT an inline card (it lives in the popup).
expect(pressedCount(html, 'true')).toBe(5) expect(pressedCount(html, 'true')).toBe(3)
expect(pressedCount(html, 'false')).toBe(0) expect(pressedCount(html, 'false')).toBe(0)
// The general toggles are custom switches (real checkboxes, checked).
expect(html.match(/checked=""/g)?.length).toBe(2)
expect(html).not.toContain('Auto-open Subagents') expect(html).not.toContain('Auto-open Subagents')
}) })
it('renders the section intro and group headings with inventory counts', () => {
const { store, service } = mount()
const html = renderSection(store, service)
expect(html).toContain('Manage what the side card shows and how it behaves')
// Group headings carry the inventory count badge (2 tabs, 1 viewer).
expect(html).toContain('>Sidebar content</span><span')
expect(html).toContain('>File viewers</span><span')
expect(html).toContain('>2</span>')
expect(html).toContain('>1</span>')
})
it('renders one small card per registered viewer: icon + title + exts', () => { it('renders one small card per registered viewer: icon + title + exts', () => {
const { store, service } = mount() const { store, service } = mount()
const html = renderSection(store, service) const html = renderSection(store, service)
@@ -111,8 +126,9 @@ describe('SideCardSection declarative inventory', () => {
expect(html).toContain('>Subagents<') expect(html).toContain('>Subagents<')
expect(html).toContain('>Image<') expect(html).toContain('>Image<')
expect(pressedCount(html, 'false')).toBe(2) expect(pressedCount(html, 'false')).toBe(2)
// openByDefault + interceptOpenPath + the explorer stay pressed. // The explorer card stays pressed; the general switches stay checked.
expect(pressedCount(html, 'true')).toBe(3) expect(pressedCount(html, 'true')).toBe(1)
expect(html.match(/checked=""/g)?.length).toBe(2)
}) })
it('hides the gear of a disabled feature (its related settings are dormant)', () => { it('hides the gear of a disabled feature (its related settings are dormant)', () => {
@@ -134,7 +150,7 @@ describe('FeatureSettingsRows (the secondary settings popup body)', () => {
desc: () => 'Expand on new subagent', desc: () => 'Expand on new subagent',
}] }]
it('renders one native checkbox row per declared toggle with its current value', () => { it('renders one switch row per declared toggle with its current value', () => {
const html = renderToString(createElement(FeatureSettingsRows, { const html = renderToString(createElement(FeatureSettingsRows, {
toggles, toggles,
prefs, prefs,
@@ -142,7 +158,9 @@ describe('FeatureSettingsRows (the secondary settings popup body)', () => {
})) }))
expect(html).toContain('Auto-open Subagents') expect(html).toContain('Auto-open Subagents')
expect(html).toContain('Expand on new subagent') expect(html).toContain('Expand on new subagent')
// The checkbox reflects the prefs value (false here → unchecked). // The row's switch is a real checkbox (aria-label = the toggle title)
// reflecting the prefs value (false here → unchecked).
expect(html).toContain('aria-label="Auto-open Subagents"')
expect(html).not.toContain('checked=""') expect(html).not.toContain('checked=""')
}) })
@@ -154,4 +172,47 @@ describe('FeatureSettingsRows (the secondary settings popup body)', () => {
})) }))
expect(html).toContain('checked=""') expect(html).toContain('checked=""')
}) })
it('renders a text row as an input seeded with the pref value (empty = theme default)', () => {
const html = renderToString(createElement(FeatureSettingsRows, {
toggles: [{
key: 'terminalFontFamily',
type: 'text',
title: () => 'Font family',
desc: () => 'CSS stack',
placeholder: '"JetBrains Mono", monospace',
}],
prefs: { ...prefs, terminalFontFamily: '"JetBrains Mono", monospace' },
onToggle: () => {},
onCommit: () => '',
}))
expect(html).toContain('Font family')
expect(html).toContain('placeholder="&quot;JetBrains Mono&quot;, monospace"')
// The input carries the pref value (no switch for text rows).
expect(html).toContain('value="&quot;JetBrains Mono&quot;, monospace"')
expect(html).not.toContain('type="checkbox"')
})
it('renders a number row with the pref value, the declared bounds and a unit suffix', () => {
const html = renderToString(createElement(FeatureSettingsRows, {
toggles: [{
key: 'terminalFontSize',
type: 'number',
title: () => 'Font size',
min: 9,
max: 32,
unit: 'px',
}],
prefs: { ...prefs, terminalFontSize: 18 },
onToggle: () => {},
onCommit: () => '18',
}))
expect(html).toContain('Font size')
expect(html).toContain('type="number"')
expect(html).toContain('value="18"')
expect(html).toContain('min="9"')
expect(html).toContain('max="32"')
expect(html).toContain('px')
expect(html).not.toContain('type="checkbox"')
})
}) })
+2
View File
@@ -517,6 +517,8 @@ describe('side card settings routes', () => {
autoOpenJobs: true, autoOpenJobs: true,
agentTerminalTools: false, agentTerminalTools: false,
bottomPanelAutoTerminal: true, bottomPanelAutoTerminal: true,
terminalFontFamily: '',
terminalFontSize: 13,
interceptOpenPath: true, interceptOpenPath: true,
htmlViewerNoSandbox: false, htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false, htmlViewerDefaultUnsafe: false,
+42
View File
@@ -0,0 +1,42 @@
/**
* Terminal font resolution tests: the custom font prefs (side card settings,
* terminal card) resolve into xterm options with a strict fallback chain —
* user family > theme code font > the built-in monospace stack, and the size
* clamped into the 9–32 contract.
*/
import { describe, expect, it } from 'vitest'
import { SIDEBAR_PREFS_DEFAULTS, clampTerminalFontSize, TERMINAL_FONT_SIZE_MAX, TERMINAL_FONT_SIZE_MIN } from '../src/prefs-shared.ts'
import { DEFAULT_TERMINAL_FONT_FAMILY, resolveTerminalFont } from '../src/client/terminal-font.ts'
describe('clampTerminalFontSize', () => {
it('rounds and clamps into the 9–32 contract', () => {
expect(clampTerminalFontSize(13)).toBe(13)
expect(clampTerminalFontSize(15.6)).toBe(16)
expect(clampTerminalFontSize(5)).toBe(TERMINAL_FONT_SIZE_MIN)
expect(clampTerminalFontSize(40)).toBe(TERMINAL_FONT_SIZE_MAX)
})
})
describe('resolveTerminalFont', () => {
it('falls back to the theme code font when the family pref is empty', () => {
const { fontFamily, fontSize } = resolveTerminalFont(SIDEBAR_PREFS_DEFAULTS, 'var-theme-font')
expect(fontFamily).toBe('var-theme-font')
expect(fontSize).toBe(13)
})
it('falls back to the built-in monospace stack when neither the pref nor the theme provides one', () => {
expect(resolveTerminalFont(SIDEBAR_PREFS_DEFAULTS, undefined).fontFamily).toBe(DEFAULT_TERMINAL_FONT_FAMILY)
// A whitespace-only pref counts as empty (theme default).
expect(resolveTerminalFont({ ...SIDEBAR_PREFS_DEFAULTS, terminalFontFamily: ' ' }, undefined).fontFamily)
.toBe(DEFAULT_TERMINAL_FONT_FAMILY)
})
it('prefers the custom family verbatim and clamps the size', () => {
const { fontFamily, fontSize } = resolveTerminalFont(
{ ...SIDEBAR_PREFS_DEFAULTS, terminalFontFamily: '"JetBrains Mono", monospace', terminalFontSize: 40 },
'var-theme-font',
)
expect(fontFamily).toBe('"JetBrains Mono", monospace')
expect(fontSize).toBe(TERMINAL_FONT_SIZE_MAX)
})
})
+181
View File
@@ -0,0 +1,181 @@
/**
* Turn-tail interception registration spec (issue #15): `registerTurnTailInterception`
* must go through `ctx.slots.inject` — the slot is a CHILD slot the host's
* ui-conversation declares in its `conversation.chat.node` children table, so a
* direct `slots.register` races the declaration and the ui-slots core throws
* "not declared (a parent entry's children table must declare it)".
*
* The fake `slots` mirrors SlotRegistry.inject's semantics: run the callback
* synchronously when the slot is already declared; otherwise wait and run it
* when the declaration commits; the returned disposer cancels a pending wait
* and disposes any active registration; the register disposer is idempotent.
*/
import { describe, expect, it, vi } from 'vitest'
import { createSidebarStore } from '../src/client/state.ts'
import { registerTurnTailInterception } from '../src/client/intercept.tsx'
import type { Context } from '../src/context-types.ts'
interface RegisteredSlot {
options: Record<string, unknown>
component: unknown
}
/**
* A structural fake of the client slots service. `declared` selects the
* timing: already-on-ledger (callback runs synchronously) vs. pending
* (callback runs on `declare()`, unless the controller was disposed first).
*/
const fakeSlots = (declared: boolean) => {
const registered: RegisteredSlot[] = []
const disposals: number[] = []
const pendings: Array<() => void> = []
return {
registered,
disposals,
pendings,
slots: {
register: (options: Record<string, unknown>, component: unknown) => {
registered.push({ options, component })
return () => { disposals.push(1) }
},
inject: (key: string, callback: () => () => void) => {
if (key !== 'conversation.chat.turnTail') {
throw new Error(`unexpected injected key "${key}"`)
}
let active: (() => void) | undefined
let stopped = false
const run = (): void => {
if (stopped || active !== undefined) return
active = callback()
}
if (declared) run()
else pendings.push(run)
return () => {
stopped = true
active?.()
active = undefined
}
},
},
}
}
/** A produced-files owner currency: one closing assistant seq + its nodes. */
const producedOwner = (paths: string[]): unknown => ({
nodes: [
{ kind: 'assistant', seq: 1, turn: 1 },
...paths.map(path => ({
kind: 'tool-result', isError: false, callView: { card: 'diff', locations: [{ path }] },
})),
{ kind: 'assistant', seq: 2, turn: 1 },
],
seq: 2,
})
const emptyOwner = (): unknown => ({ nodes: [{ kind: 'assistant', seq: 1, turn: 1 }], seq: 1 })
/** The minimal client-context fake the registration (and its seats) touch. */
const clientCtx = (slots: unknown): Context => ({
slots,
sessions: {
list: { getSnapshot: () => ({ current: 's1', byId: { s1: { id: 's1', cwd: '/w', displayTitle: 's1' } } }) },
},
betterSidebar: { openTab: vi.fn() },
} as unknown as Context)
describe('turn-tail interception registration (issue #15)', () => {
it('registers through slots.inject and lands once the slot is already declared', () => {
const fake = fakeSlots(true)
const store = createSidebarStore()
const restore = registerTurnTailInterception(clientCtx(fake.slots), store)
// Exactly one registration, with the takeover descriptor.
expect(fake.registered).toHaveLength(1)
const { options, component } = fake.registered[0]!
expect(options.name).toBe('conversation.chat.turnTail')
expect(options.priority).toBe(-1)
expect(options.registrant).toBe('dsh-better-sidebar')
expect(options.select).toBeTypeOf('function')
expect(options.inject).toBeTypeOf('function')
expect(component).toBeTypeOf('function')
// Disposal removes the registration; the disposer is idempotent.
restore()
expect(fake.disposals).toHaveLength(1)
restore()
expect(fake.disposals).toHaveLength(1)
})
it('waits for the host declaration instead of throwing (the pre-fix race)', () => {
const fake = fakeSlots(false)
const store = createSidebarStore()
const restore = registerTurnTailInterception(clientCtx(fake.slots), store)
// The slot is undeclared: nothing registered yet, no error.
expect(fake.registered).toHaveLength(0)
// The host's ui-conversation commits the declaration → the entry lands.
expect(fake.pendings).toHaveLength(1)
fake.pendings[0]!()
expect(fake.registered).toHaveLength(1)
// A later re-declaration does not double-register while the entry is live.
fake.pendings[0]!()
expect(fake.registered).toHaveLength(1)
restore()
expect(fake.disposals).toHaveLength(1)
})
it('a disposal before the declaration cancels the wait permanently', () => {
const fake = fakeSlots(false)
const store = createSidebarStore()
const restore = registerTurnTailInterception(clientCtx(fake.slots), store)
restore()
// The declaration arrives after the controller was disposed: ignored.
fake.pendings[0]!()
expect(fake.registered).toHaveLength(0)
})
it('declines the takeover while the editor tab is disabled in the settings', () => {
const fake = fakeSlots(true)
const store = createSidebarStore()
const restore = registerTurnTailInterception(clientCtx(fake.slots), store)
const select = fake.registered[0]!.options.select as (owner: unknown) => unknown
// Enabled (default): a produced turn claims the chain; an empty one declines.
expect(select(producedOwner(['a.ts', 'b.ts']))).toEqual(['a.ts', 'b.ts'])
expect(select(emptyOwner())).toBeNull()
// Editor tab disabled: even a produced turn falls back to the default
// deliverables row (chips that cannot open must not be offered).
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { editor: false } })
expect(select(producedOwner(['a.ts']))).toBeNull()
restore()
})
it('wires the openInSidebar seat to the sidebar editor tab', () => {
const fake = fakeSlots(true)
const ctx = clientCtx(fake.slots)
const store = createSidebarStore()
const restore = registerTurnTailInterception(ctx, store)
const inject = fake.registered[0]!.options.inject as (sessionId: string) => {
openInSidebar: (path: string) => void
}
// The seat hands the session-scoped opener to the chips row.
const seat = inject('s1')
expect(seat.openInSidebar).toBeTypeOf('function')
seat.openInSidebar('/w/src/a.ts')
expect(ctx.betterSidebar.openTab).toHaveBeenCalledWith({
type: 'editor',
title: 'a.ts',
path: '/w/src/a.ts',
id: 'editor:/w/src/a.ts',
})
restore()
})
})
+26 -4
View File
@@ -1153,6 +1153,8 @@ describe('side card preferences', () => {
autoOpenJobs: true, autoOpenJobs: true,
agentTerminalTools: true, agentTerminalTools: true,
bottomPanelAutoTerminal: true, bottomPanelAutoTerminal: true,
terminalFontFamily: '',
terminalFontSize: 13,
interceptOpenPath: true, interceptOpenPath: true,
htmlViewerNoSandbox: false, htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false, htmlViewerDefaultUnsafe: false,
@@ -1172,6 +1174,8 @@ describe('side card preferences', () => {
autoOpenJobs: true, autoOpenJobs: true,
agentTerminalTools: false, agentTerminalTools: false,
bottomPanelAutoTerminal: true, bottomPanelAutoTerminal: true,
terminalFontFamily: '',
terminalFontSize: 13,
interceptOpenPath: true, interceptOpenPath: true,
htmlViewerNoSandbox: false, htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false, htmlViewerDefaultUnsafe: false,
@@ -1191,6 +1195,8 @@ describe('side card preferences', () => {
autoOpenJobs: true, autoOpenJobs: true,
agentTerminalTools: false, agentTerminalTools: false,
bottomPanelAutoTerminal: true, bottomPanelAutoTerminal: true,
terminalFontFamily: '',
terminalFontSize: 13,
interceptOpenPath: true, interceptOpenPath: true,
htmlViewerNoSandbox: false, htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false, htmlViewerDefaultUnsafe: false,
@@ -1225,6 +1231,22 @@ describe('side card preferences', () => {
expect((await loadPrefs(wire({ interceptOpenPath: true }))).interceptOpenPath).toBe(true) expect((await loadPrefs(wire({ interceptOpenPath: true }))).interceptOpenPath).toBe(true)
}) })
it('resolves the terminal font prefs (family passthrough, size clamp)', async () => {
// Absent → theme default (empty family) + default size.
expect((await loadPrefs(wire({}))).terminalFontFamily).toBe('')
expect((await loadPrefs(wire({}))).terminalFontSize).toBe(13)
// A custom family survives verbatim; a malformed one falls back.
expect((await loadPrefs(wire({ terminalFontFamily: '"JetBrains Mono", monospace' }))).terminalFontFamily)
.toBe('"JetBrains Mono", monospace')
expect((await loadPrefs(wire({ terminalFontFamily: 42 }))).terminalFontFamily).toBe('')
// The size clamps into 9–32 (rounded); non-numbers fall back.
expect((await loadPrefs(wire({ terminalFontSize: 5 }))).terminalFontSize).toBe(9)
expect((await loadPrefs(wire({ terminalFontSize: 40 }))).terminalFontSize).toBe(32)
expect((await loadPrefs(wire({ terminalFontSize: 15.6 }))).terminalFontSize).toBe(16)
expect((await loadPrefs(wire({ terminalFontSize: 'big' }))).terminalFontSize).toBe(13)
expect((await loadPrefs(wire({ terminalFontSize: 18 }))).terminalFontSize).toBe(18)
})
it('validates the per-tab / per-viewer enable maps (absent keys mean enabled)', async () => { it('validates the per-tab / per-viewer enable maps (absent keys mean enabled)', async () => {
// A non-object map falls back to {} (everything enabled). // A non-object map falls back to {} (everything enabled).
expect((await loadPrefs(wire({ tabsEnabled: 'nope' }))).tabsEnabled).toEqual({}) expect((await loadPrefs(wire({ tabsEnabled: 'nope' }))).tabsEnabled).toEqual({})
@@ -1242,9 +1264,9 @@ describe('side card preferences', () => {
const store = createSidebarStore() const store = createSidebarStore()
// Node environment: no window → the width falls back to PANEL_DEFAULT, // Node environment: no window → the width falls back to PANEL_DEFAULT,
// while the open flag still follows the preference. // while the open flag still follows the preference.
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} }) store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
store.setSession('fresh-session') store.setSession('fresh-session')
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} }) expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
const snapshot = store.getSnapshot() const snapshot = store.getSnapshot()
expect(snapshot.sessionId).toBe('fresh-session') expect(snapshot.sessionId).toBe('fresh-session')
expect(snapshot.state?.panelOpen).toBe(false) expect(snapshot.state?.panelOpen).toBe(false)
@@ -1280,7 +1302,7 @@ describe('side card preferences', () => {
it('skips the default explorer tab when the explorer type is disabled', () => { it('skips the default explorer tab when the explorer type is disabled', () => {
const store = createSidebarStore() const store = createSidebarStore()
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: { explorer: false }, viewersEnabled: {} }) store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: { explorer: false }, viewersEnabled: {} })
store.setSession('no-explorer') store.setSession('no-explorer')
const state = store.getSnapshot().state! const state = store.getSnapshot().state!
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs) const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
@@ -1288,7 +1310,7 @@ describe('side card preferences', () => {
expect(state.splits.kind).toBe('leaf') expect(state.splits.kind).toBe('leaf')
// Re-enabling seeds the explorer tab again. // Re-enabling seeds the explorer tab again.
const openStore = createSidebarStore() const openStore = createSidebarStore()
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} }) openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
openStore.setSession('with-explorer') openStore.setSession('with-explorer')
const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs) const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
expect(openTabs.map(tab => tab.type)).toEqual(['explorer']) expect(openTabs.map(tab => tab.type)).toEqual(['explorer'])