Compare commits

..
70 Commits
Author SHA1 Message Date
Atlas Zeng 6c891514b5 feat(injection): 统一面板宿主注入重构——含块层+几何写路径+chunk缓存+锚点弱化+桌面/移动适配 (#232)
* feat(injection): 统一面板宿主——固定含块层 + 挂载自检降级 + body 锚点守卫

- Sidebar 渲染根包 [data-dsh-panel-host](fixed inset-0 pointer-events:none)
- 面板/开关簇由 fixed 改 absolute(层内视口坐标等价),免疫套壳中间层 transform 劫持
- 挂载后一次性几何自检:页面级 transform 时降级 absolute + rAF 视口同步
- MutationObserver(body childList)守卫锚点被宿主清空时重挂

* perf(injection): 几何写路径统一为 writeGeometry + 面板 layout 隔离

- 拖拽帧与打开态共用单一推挤写入器(--dsh-sidebar-width/height)
- .panel/.bottomPanel contain: layout style——内部重排不外泄
- 拖拽期(body[data-dsh-sidebar-dragging])才挂 will-change: transform

* perf(chunks): HMR 激活保留未变 chunk 的已解析 exports + ETag HEAD 重验证

- 新增 revalidateChunksOnReactivate():激活时 HEAD 每个已加载 chunk 对比 ETag,
  未变保留内存缓存(跳过重注入/重执行),变更或不可达则 fail-open 重取
- 测试夹具与孤儿缓存条目不跨激活存活
- index.tsx 激活路径由 resetChunks() 改为 revalidateChunksOnReactivate()

* fix(layout): 推挤锚点改 data 属性 + #root calc 宽度防桌面壳溢出

- nth-child(2) → #root [data-dsh-frame] > [data-pane="conversation"](实证同元素)
- #root 宽度 calc(100% - var) 与 margin 同步过渡:web 视觉不变(实测 1140+300=1440 无溢出),
  Desktop 壳(#208)width:100% 的加性溢出根因消除

* feat(desktop): 桌面信号自动探测 + win32 顶栏 overlay 自动避让 + 键盘/safe-area 适配

- 新增 desktop-env.ts:解析 dsh-desktop-mode/platform URL 信号与 preload 标记
- win32 advanced 壳自动启用 titleBarCompat(32px overlay),手动 pref 仍可覆盖
- visualViewport 键盘 inset:底部面板/窄屏抽屉键盘弹起时上移
- 面板与开关簇 safe-area-inset 适配(env() 桌面端为 0)
- 单测 7 例(含 URLSearchParams 前导 ? 剥除修复)

* docs+e2e: 注入方案实施记录(路线B关闭、偏差记录、基线数据)+ 契约同步 + e2e 新断言

- 设计文档:路线 B 标记关闭、§8.1 改单 PR 步骤(含 commit 对照)、拖拽基线数据、实施偏差记录
- AGENTS.md §8:data-dsh-panel-host 含块层、推挤锚点契约(禁 nth-child)、桌面信号
- mount.e2e:data-dsh-panel-host 断言 + win32 advanced 桌面信号场景

* fix(cr): 响应 #232 CR 三条 findings

- P1 降级同步:按未修正几何判定(跟踪自身补偿量),祖先 transform 消失才退出循环——
  修正后矩形不再被误判为无需修正(自清除缺陷)
- P1 chunk 重验证成为 loadChunk 屏障:重验证挂起期间不提供缓存,杜绝 HMR 竞态陈旧 exports;
  测试夹具加载不再被误记为生产加载
- P2 键盘 inset:innerHeight - (vv.height + vv.offsetTop),并监听 scroll(offsetTop 变化)
- 新增屏障单测 1 例;设计文档偏差记录更新

* fix(chunks): 屏障 HEAD 加超时兜底 + resetChunks 清 revalidation

- revalidateChunksOnReactivate 的 HEAD 与 recordEtag 均带
  AbortSignal.timeout(5s):路由挂起时 fail-open(drop + 重取),
  屏障不再可能无限期阻塞所有懒加载 chunk(CR #232 P1 修复的健壮性缺口)
- resetChunks 同步清 revalidation:重验证挂起期间重置后,
  loadChunk 不再等待已丢弃状态的孤儿任务(finally 身份判断 no-op)
- 单测 +2:HEAD 挂起超时 fail-open(fake AbortSignal.timeout 触发中止)、
  resetChunks 清屏障后 loadChunk 立即放行

* fix(deps): align @deepseek-ai/* peers with DSH 0.1.0-rc.8

- peer/devDependencies: 13 个 @deepseek-ai/* 从 ^0.1.0-rc.7 升至
  ^0.1.0-rc.8;删除随 rc.8 消失的 dsh-client-web-react / dsh-client-schema-form
  (无 rc.8 发布、rc.8 依赖树与壳 bundle 零引用、插件 src 零引用);
  cordis peer/devDep 同步升 ^4.0.0-rc.8
- 版本 0.13.1 -> 0.14.0(package.json / dsh.plugin.json /
  SIDEBAR_SERVICE_VERSION 三处同步)
- CHUNK_EXTERNALS / CLIENT_EXTERNALS / CLIENT_REQUIRE_ALLOWED 三处镜像
  同步删除两个死包(primitives/slots 仍是 rc.8 壳静态模块表种子,保留)
- CI 挂载冒烟钉版 @deepseek-ai/dsh@0.1.0-rc.7 -> rc.8
- pnpm clean --lockfile 全量重解析:lockfile 内 @deepseek-ai/* rc.7 残留
  清零(传递链同升 rc.8,杜绝 #206 式混用树);pnpm-workspace.yaml
  minimumReleaseAgeExclude 按 #207 模式整体换为 rc.8 列表
- 文档同步:README 安装说明、AGENTS.md CI 钉版、外部插件指南与设计文档
  cordis 示例、issue 模板版本示例

* feat(prefs): editorExplorer 默认独立(关)——文件各自新开 tab

- SIDEBAR_PREFS_DEFAULTS 与 host PrefsSchema 的 editorExplorer 默认
  true -> false:新会话树点击/打开文件按 path 新开 tab,无路径窗口即
  纯资源管理器;合并模式保留为可选手动开启(持久化过的用户设置不受影响)
- 测试同步:prefs.spec 默认断言翻转、plugin-shape.spec 与 smoke.spec
  默认对象翻转;editor-host 合并模式用例显式开启 editorExplorer(split
  用例本已显式);side-card-section-rows 的 select 行用例固定选中 Merged
- e2e 重写:mount.e2e 文件打开流程从 in-place 断言改为独立模式
  (新开 hello.txt tab、Files home tab 保留),文件头注释同步
- 契约文档同步:AGENTS.md §3.4 editor 行默认描述翻转 + CI 冒烟描述、
  README/README_EN v0.13.0 特性描述 + v0.14.0 changelog

* fix(client): rc.8 模块系统经 ctx.modules 注入 + chunk 副本全局共享

rc.8 不再暴露 window.__DSH_MODULES__ 页面全局(改由 ctx.modules 服务
提供),chunk 外部依赖解析全面失效(terminal/editor/mermaid 全部
"client module system unavailable")。修复:

- src/client/index.tsx: inject 增加 'modules'(Cordis 服务访问守卫,
  此前直接读 ctx.modules 抛 "cannot get property modules without
  inject");apply() 激活时 setChunkModuleSystem(ctx.modules)
- package.json dsh.client.inject 增加 @deepseek-ai/dsh-client-modules
- chunk-loader: 注入写插件自有页面全局 __dshSidebarModuleSystem__——
  懒加载 chunk bundle 各自内联一份 loader(rc.7 靠官方全局回退工作,
  rc.8 全局消失后副本注入变量恒空);moduleSystem() 按
  注入变量 → 插件全局 → rc.7 全局回退解析
- context-types: Context 镜像补 modules 服务声明(client half)
- e2e: assertNoCrash 失败时输出错误条文本;独立模式断言修正
  (Files 数量按存在性、mermaid 流程先切回 Files、md 预览 locator 去掉
  重复的 [data-dsh-better-sidebar] 嵌套)

验证:typecheck / 639 测试全绿;本地 rc.8 CLI + Chromium 挂载冒烟 3/3
通过(drag-layout、mount 深扫含 editor/mermaid chunk、桌面信号)

* feat(settings): 终端 shell/shellArgs 可在设置页配置(bash 管理)

- SidebarPrefs 新增 terminalShell / terminalShellArgs(默认空 = 保持
  yaml config.shell → $SHELL / 登录 shell / powershell.exe 的既有解析)
- 终端卡齿轮二级页面新增两行 text 配置(与字体行同列)
- host 侧生效链路:UI 终端 WS 打开与 agent terminal_create 均读取
  设置文档覆盖(ptyManager.open / AgentPtyRegistry.create 增加可选
  shell 覆盖参数;shellOverridesOf 解析设置文档);设置页写入即时
  影响之后打开的终端(既有 pty 保持原 shell)
- locales zh/en 文案;parsePrefs 校验新字段;测试同步

* feat(settings): 侧边卡片设置页加 DSH-better-sidebar 版本徽标

- 设置页 intro 下新增身份徽标:名称 + v{service.version}(服务实例
  版本,与 package.json 由 tests/service.spec.ts 保持同步)
- token 驱动样式(--dsw-alias-*),名称/版本 pill 布局

* fix(drag): 快速拖动提交最后位置 + HMR 后中心列重定位兜底

快速释放(浏览器合并/丢失 pointermove 突发,超快速 flick 甚至可能
完全无 move)时的回退问题:
- pointerup 优先采用最后一次已知拖动值(rAF pending),而非 up 位置
  (事件合并时可能等于按下位置)
- pointercancel / lostpointercapture 中断时同样提交最后已知位置;
  无 move 时用中断事件自身的坐标计算(clamp 后提交)——只有真正
  未产生位移的拖动才回滚
- commitDrag 统一 flush + 同值提交 + 立即重测中心列(消除提交后
  React 用旧 centerRect 覆盖底栏 right 的中间帧)

HMR 重激活后底栏空白(输入框被推挤上移、强制刷新恢复):
- 根因:新组件实例重跑 locate 时壳的 centerCol 尚未就绪,而壳重建
  复用 #root 子节点(childList 观察器不触发)→ centerRect 卡 {0,0}
  → 底栏 visibility:hidden 永久空白
- 修复:额外观察 <html> 的 style 属性(layout push 写回 --dsh-sidebar-*
  时重定位重测);底栏打开时 effect 重跑 locate/measure 兜底
- 底栏未测量时由「不渲染」改为 visibility:hidden(渲染链不被几何
  门控,jsdom 测试不回归)

e2e 新增:极快拖动(单 move 立即 up)提交拖动位置、稳定后不回退

* feat(plugins): 添加插件目录支持搜索/分组/独立滚动(多插件场景)

- PluginListBody 顶部加实时搜索框(按名称/id/描述过滤,空结果提示)
- PluginEntry 增加可选 category 分组字段(i18n 友好),目录条目按组
  渲染(未分类条目保持平铺);数量徽标跟随过滤结果
- 条目列表 max-height + overflow-y 独立滚动,插件多时弹窗不再无限
  增长;渲染数量由搜索天然收敛
- locales zh/en(搜索占位、无匹配);测试文件固定英文环境
  (vitest 4.1.11+ jsdom 跟随系统 locale,本机 zh-CN 导致英文文案
  断言漂移)

* chore(deps): 适配 pnpm 11.8 supply-chain 校验(exclude 通配)

pnpm 11.8.0 起对 lockfile 既有条目强制 minimumReleaseAge 校验,
且精确 exclude(@scope/pkg@version)对带 peer 依赖后缀的解析条目
匹配失效(44 条误报违规,CI 全红)。@deepseek-ai/* scope 为官方
可信 scope,minimumReleaseAgeExclude 改为 '@deepseek-ai/*' 通配
(保留 rc.1 历史与 stable 条目);通配经实测通过全部 504 条校验。

* docs: v0.14.0 changelog 补全(自 v0.13.1 全部更改 + 特别声明 rc.8 适配)

新增条目:终端 shell/shellArgs 设置页配置、设置页版本徽标、
添加插件目录搜索/分组/滚动、rc.8 模块系统迁移、拖拽健壮性修复;
版本说明块醒目声明本版适配 DSH 0.1.0-rc.8(rc.7 及更早无法解析依赖)
2026-08-20 02:09:36 +08:00
Menghuan1918 14ef8e4df8 docs(plans): 注入方案实证核对——真实页面探针确认 data-pane 弱锚点、推挤无溢出、host 本体 static 2026-08-19 15:46:54 +08:00
Menghuan1918 27ea096f82 docs(plans): 注入方案设计打磨到实施就绪——文件级改动清单、现状已具备标注、上游 issue 草案 2026-08-19 15:45:01 +08:00
Menghuan1918 659bfadb47 docs(plans): 侧边栏面板注入方案设计——统一面板宿主(路线A)+ 官方槽位迁移(路线B) 2026-08-19 15:43:17 +08:00
Menghuan1918 4a407d78b8 docs: add bilingual issue report template (environment / category / description) 2026-08-19 01:15:12 +08:00
SolitudeandMenghuan1918 bcd60234ac fix(sidebar): unstick interrupted panel drags so the bottom panel keeps tracking layout (#183)
* fix(sidebar): unstick interrupted panel drags so the bottom panel keeps tracking layout

A width/height/corner drag whose pointer stream ends without pointerup
(pointercancel, or pointer capture lost prematurely — e.g. a trackpad
gesture interrupting the drag) left draggingWidth/draggingBottom/
draggingCorner stuck true: onPointerUp guards on hasPointerCapture and
never ran, and no onPointerCancel/onLostPointerCapture handler existed.

The stuck drag state pauses center-column measurement (draggingRef)
permanently, so centerRect goes stale and the bottom panel keeps frozen
inline left/right edges — it no longer expands when the right sidebar
collapses or the app rail toggles until a full reload.

Fix: mark each drag uncommitted on pointerdown and committed in
pointerup before releasing capture; the new pointercancel /
lostpointercapture handlers abort an uncommitted drag by reverting the
DOM and layout variables to the store's committed sizes and clearing
the dragging state, which resumes measurement.

* fix(sidebar): clamp abort-drag rollback to collapsed/narrow geometry (P2 review)

abortDrag wrote the committed width even when the right panel is
collapsed or the viewport is narrow, where the layout-push effect had
already written 0 to --dsh-sidebar-width — an interrupted drag then left
the app shell squeezed by an invisible sidebar. Mirror the layout
effect's clamping: write 0 when narrow or the panel is closed.

---------

Co-authored-by: Menghuan1918 <menghuan2003@outlook.com>
2026-08-18 23:08:54 +08:00
Cold Cloud 76aacddbb9 fix(tab-bar): middle-click close lost to autoscroll / release drift (#145)
The close settled on auxclick delivery, which the browser redirects to the
nearest common ancestor of the press/release targets when they differ — any
drift, middle-button autoscroll scroll, or tab-list reflow between press and
release swallowed the close (the strip hides its scrollbar, so Chrome's
autoscroll indicator is inert here and only added confusion).

Now the press target is recorded on middle mousedown (preventDefaulted to
disarm autoscroll at the source) and the close settles on the first middle
mouseup OVER that same tab — release-position semantics matching VS Code
(microsoft/vscode#101028) and user expectations for Chrome tabs
(crbug/40679924): pressing on a tab and releasing elsewhere cancels.
Left-button activate/drag and wheel scrolling are untouched
(tests/tab-bar-middle-click.spec.tsx).
2026-08-18 23:04:29 +08:00
Menghuan1918 0bcafd476d docs(readme): complete the v0.13.1 changelog (mermaid safe render, terminal shell, aggregate double-mount, rc.7 peers) 2026-08-18 21:46:06 +08:00
Atlas Zeng e8757026a1 fix(deps): align @deepseek-ai/* peers with DSH 0.1.0-rc.7 (fixes #206) (#207) 2026-08-18 21:41:28 +08:00
Atlas Zeng b5e8665499 fix(bundle): 聚合双挂载自动退让 —— 已有同包名启用挂载时不再挂自己 (#200)
* fix(bundle): 聚合双挂载自动退让 —— 已有同包名启用挂载时不再挂自己

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

P2(fence 匹配):支持 3+ 反引号与波浪号开 fence;闭 fence 须同字符且
长度不小于开 fence;反引号 info string 含反引号不视为开 fence;修复
带缩进开 fence 的 info 提取。blocks 单测 +3 例。
2026-08-18 01:22:28 +08:00
Atlas Zeng 9bea6c5539 Update README.md 2026-08-18 01:15:55 +08:00
Menghuan1918 af637b4f1b docs(readme): complete the v0.13.0 changelog (editor-explorer merge, select rows, mutual exclusion) 2026-08-18 00:55:29 +08:00
Atlas Zeng 9ad0a49b8a feat(settings): mutual exclusion with the dsh-web-ui aionui-panel provider (v0.13.0) (#181)
Reads the aionui-panel settings namespace's rightPanel provider choice: while
it resolves to 'aionui-panel', the whole sidebar does not mount (host
settings.get reports externalDisable, the client mount gate skips mounting
and the link/open-path/turn-tail takeovers step aside). Live via the
settings/document-updated push (best effort; deployments without the
'remote' service fall back to boot-time evaluation). No new user-facing
setting — the aionui panel card remains the single source of truth.
2026-08-18 00:44:22 +08:00
Atlas Zeng 6ee1ba11d0 feat(editor): 文件窗口与资源管理器二合一 + 声明式设置 select 行 (#151)
* feat(editor): merged explorer mode — path input, docked file tree, global name search

New editorExplorer pref (default on, editor card gear): the editor tab gains
a path-input header and a toggleable right-docked tree panel (per-tab state
in tab.meta.treeOpen; file tabs default closed, the seeded path-less home
tab defaults open). Fresh sessions seed that empty editor tab instead of
the explorer tab when merged mode is on; the standalone explorer tab stays
in the + menu unchanged.

- Extract FileTree.tsx from ExplorerView (shared by both hosts)
- Host fs.search route: recursive case-insensitive name search rooted at
  the session cwd, budget-capped (200 matches / 100k visited), skips .git,
  never descends symlink dirs
- EditorHost reads the pref reactively (boolean snapshot) and keeps the
  pre-merge layout verbatim when off

* feat(settings): select row type for declarative settings; editorExplorer as iconed select

- SidebarSettingToggle gains type 'select' with options {value,title,desc,icon}
  and multi (default false; stores the picked values as an array in options
  order). Any option carrying an icon renders big-icon option cards in the
  dropdown and the icon in the closed anchor; icon-less rows are one-line text.
- SelectRow in SideCardSection rides the primitives Menu (portal); single-pick
  commits and closes, multi toggles and stays open. Wired for both host-prefs
  toggles and plugin-owned pluginToggles via the new onSelectValue seam.
- The editorExplorer merged-mode pref is now an iconed select (merged vs
  separate) instead of a switch; the stored value stays boolean.
- Advertise the capability as 'settingSelect' in SIDEBAR_FEATURES.

* feat(editor): unify explorer into the editor surface, resizable tree dock, hoisted toolbar

- One tree implementation, two presentations: the shared TreePanel (global
  name search + refresh + FileTree) docks as the editor tab's right panel
  and — in merged mode — fills the explorer tab's window; the pref-off
  explorer keeps its legacy header shell.
- The docked panel drag-resizes from its left edge (160-480px clamp),
  persisted as tab.meta.treeWidth alongside treeOpen.
- The text editor's preview/edit/save toolbar moves into the path-input
  header row: FileViewerProps gains internal toolbar fields ('host' mode
  skips the viewer's own row, reports EditorToolbarState and registers
  EditorToolbarControls); non-merged mode is byte-identical to before.

* feat(editor): single files window with in-place switching; explorer descriptor removed

- editor tab replaces explorer on the + menu (order 10, Files); persisted
  explorer tabs migrate to the editor home tab via sanitizeState
- editorExplorer now only controls open behavior: merged = in-place
  updateTab, independent = openSidebarFile; both modes share the merged
  chrome (path input + hoisted preview/edit/save toolbar + tree toggle)
- file tree context menu gains 'open in new tab' / 'open to the side'
  (mintTabId avoids the id safety net; insertLeafAt splits right)
- tree dock resize uses pointer capture (touch-action none, 6px handle)
- defaults: openByDefault off, sidebar width 35%
- file tree clips horizontal overflow (no sideways scroll)
- e2e: mount lane arms the editor-chunk wait before goto and clicks the
  tree row's left edge (hover @-reference button sits mid-row); drag lane
  expands the panel first (collapsed push writes 0px), waits for the
  slide-in to settle, and picks the leftmost col-resize strip; 'Files'
  title assertions require draggable to skip the bottom panel's welcome
  cards

* feat(editor): split mode turns the path-less window into the standalone explorer

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

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

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

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

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

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

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

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

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

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

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

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

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

Refs: #46

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

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

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

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

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

Fixes omdsh-dev/DSH-better-sidebar#68
2026-08-14 10:15:34 -07:00
Atlas Zeng acd4a8313c Update README.md 2026-08-15 01:14:29 +08:00
Menghuan1918 cb6f2cdc8f docs: README 服务化定位前置、精简功能一览并修正内置 viewer 计数
- 开头(Hero)声明服务化框架定位:ctx.betterSidebar 服务 + registerTab/registerFileViewer,内置 tab/viewer 与外部插件走同一套服务
- Hero 附 GitHub topic dsh-better-sidebar,说明生态位插件
- 修复展示视频(裸 URL → <video> 内嵌)与截图排版
- 功能一览 15 条精简为 10 条,突出重点;新增「我们的态度」引言
- 最近更新保留截图列,整理为可点击缩略图
- 修正内置 viewer 计数 9 → 6(Office 预览已迁至推荐插件)
- 同步英文 README
2026-08-15 01:09:42 +08:00
Atlas Zeng 3825fced25 Update README.md 2026-08-15 00:49:59 +08:00
Logan 1f9af78c3c fix: enable selection-to-conversation popup in the markdown editor
The "add to conversation" selection popup was registered only for the
catch-all code viewer (viewerId === 'code'), so selecting text in the
CodeMirror editor of a markdown file produced no popup; insertion only
worked in markdown preview mode via the separate DOM-selection handler.

Extend the CodeMirror selection popup to the markdown editor
(viewerId === 'markdown') so line-exact selections can be injected into
the composer draft (\`\`\`rel/path:start-end\`\`\` payload), matching
preview-mode behavior and the code viewer.
2026-08-14 12:59:17 +08:00
127 changed files with 13675 additions and 3845 deletions
+101
View File
@@ -0,0 +1,101 @@
name: 📋 问题报告 / Issue Report
description: 报告 Bug、功能请求或建议(中英双语)| Report a bug, feature request or suggestion (bilingual)
body:
- type: markdown
attributes:
value: |
## 🙏 感谢反馈 / Thanks for reporting!
请尽量完整填写以下信息,能显著加快定位与处理速度。
Please fill out the following fields as completely as possible.
- type: checkboxes
id: checklist
attributes:
label: 提交前确认 / Pre-submit checklist
description: 请逐项确认 | Please confirm each item
options:
- label: 我已搜索现有 issue,确认没有**完全相同**的条目(只有完全一致才算重复)| I searched existing issues and confirmed there is no **exact duplicate** (only exactly identical issues count as duplicates)
required: true
- label: 我已确认使用最新版本 dsh-better-sidebar / I confirmed I am on the latest dsh-better-sidebar version
required: false
- type: dropdown
id: environment-type
attributes:
label: 运行环境 / Environment
description: 你使用的是哪种运行方式?| Which runtime are you using?
multiple: false
options:
- DSH Web(浏览器访问)/ DSH Web (browser access)
- Desktop 实现(如 deepseek-harness-desktop)/ Desktop build (e.g. deepseek-harness-desktop)
- 其他 / 不确定 (Other / Not sure)
validations:
required: true
- type: input
id: dsh-web-version
attributes:
label: DSH Web 版本 + 浏览器 / DSH Web version + Browser
description: |
例:DSH 0.1.0-rc.8 + Chrome 151(macOS)。
Example: DSH 0.1.0-rc.8 + Chrome 151 (macOS).
placeholder: "DSH 版本 / version + 浏览器 / browser(如 0.1.0-rc.8 + Chrome 151)"
validations:
required: false
- type: input
id: desktop-repo
attributes:
label: Desktop 实现仓库 / Desktop repo(源头 / upstream)
description: |
若使用 Desktop 实现,请附上源头仓库链接,方便对照宿主行为。
If using a Desktop build, please link the upstream Desktop repo for host-behavior reference.
placeholder: "https://github.com/anywhere-labs/deepseek-harness-desktop"
validations:
required: false
- type: input
id: plugin-version
attributes:
label: 插件版本 / Plugin version
description: dsh-better-sidebar 的版本号 | The dsh-better-sidebar version
placeholder: "如 0.13.1(`dsh plugin --profile web add dsh-better-sidebar@latest` 安装的版本)"
validations:
required: false
- type: dropdown
id: category
attributes:
label: 类别 / Category
description: 请选择本 issue 的类型 | Please choose the type of this issue
multiple: false
options:
- 🐛 Bug(功能异常 / Something is broken)
- ✨ 功能请求(新功能 / Feature request)
- 💡 建议(体验改进 / Suggestion & improvement)
validations:
required: true
- type: textarea
id: description
attributes:
label: 描述 / Description
description: |
请详细描述:现象、期望行为、(Bug 必填)复现步骤、截图/日志等。
Describe in detail: what happened, expected behavior, reproduction steps (required for bugs), screenshots/logs, etc.
placeholder: |
现象 / What happened:
期望行为 / Expected behavior:
复现步骤(Bug 必填)/ Steps to reproduce (required for bugs):
截图或日志 / Screenshots or logs:
validations:
required: true
- type: textarea
id: additional
attributes:
label: 补充信息 / Additional context
description: 其他有助于定位的信息(可选)| Anything else that may help (optional)
placeholder: "操作系统 / OS、配置文件、报错堆栈、临时 workaround 等 / OS, config files, error stack, temporary workarounds, etc."
validations:
required: false
+71
View File
@@ -4,6 +4,14 @@ on:
push:
branches: [main]
pull_request:
workflow_dispatch:
# Cancel a superseded PR run (it is on a stale commit); never cancel a
# push run — it is already producing the post-merge signal. The mount job
# boots a real DSH + Chromium, so the savings matter there most.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
ci:
@@ -41,3 +49,66 @@ jobs:
# shipped lib/types. Runs AFTER build (it reads the built output).
- name: Check consumer type surface
run: pnpm check:consumer-types
# Pack the plugin as an npm tarball, mount it into a REAL DSH instance
# through the official `dsh plugin --profile web add` channel, and render
# the DSH web UI headlessly (Playwright Chromium) to prove the plugin
# mounts without crashing the shell. Drives `pnpm test:mount`, which boots
# a keyless `dsh web` against a scratch profile and sweeps every built-in
# sidebar tab. No API key needed — the whole lane is deterministic.
plugin-mount:
runs-on: ubuntu-latest
timeout-minutes: 30
# CI runs must never report to the production telemetry endpoint baked
# into the DSH composition (same guard DSH's own CI uses).
env:
DSH_TELEMETRY_DISABLED: '1'
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
with:
version: 11
- uses: actions/setup-node@v7
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Pin the DSH CLI the lane mounts into; the profile's bundle stack
# (dsh-base / dsh-web-app) resolves from this installation. Keep in
# step with the plugin's @deepseek-ai/* peer range when bumping.
- name: Install dsh CLI (pinned)
run: npm install -g @deepseek-ai/dsh@0.1.0-rc.8
# lib/ is gitignored; the tarball is produced from this build.
- name: Build and pack the npm tarball
run: pnpm build && pnpm pack
- name: Install Playwright Chromium
run: pnpm exec playwright install --with-deps chromium
- name: Mount + headless-render smoke
run: pnpm test:mount
# Aggregate double-mount regression: a fixture aggregate bundle mounts
# dsh-better-sidebar under its own entry id BEFORE the plugin's bundle
# row; the plugin must back off (no duplicate /sidebar/api, boot OK).
# Uses the pinned DSH CLI installed above.
- name: Aggregate double-mount regression
env:
DSH_CMD: dsh
run: bash scripts/e2e-aggregate-mount.sh
- name: Upload Playwright report on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: |
playwright-report/
test-results/
if-no-files-found: ignore
+97
View File
@@ -0,0 +1,97 @@
name: Release
# Publishes `dsh-better-sidebar` to npm via npm Trusted Publishing (OIDC) —
# no NPM_TOKEN secret required, provenance is attached to the tarball.
#
# Trigger: a GitHub Release is published (tag vX.Y.Z). The tag MUST match
# package.json `version`; the workflow verifies this before publishing.
#
# One-time npm-side setup (manual, on npmjs.com):
# 1. package `dsh-better-sidebar` -> Settings -> Publishing access
# -> Trusted Publishers -> "Add Trusted Publisher".
# 2. Provider: GitHub Actions
# Organization or user: omdsh-dev
# Repository: DSH-better-sidebar
# Workflow filename: release.yml
# Environment: (blank)
# 3. Save. Do NOT add an NPM_TOKEN secret — Trusted Publishing replaces it.
# Canonical reference: https://docs.npmjs.com/trusted-publishers
on:
release:
types: [published]
workflow_dispatch:
inputs:
dry_run:
description: Pack and verify everything, but do not publish
type: boolean
default: false
permissions:
contents: read
id-token: write # mint the OIDC token npm Trusted Publishing exchanges
concurrency:
group: npm-release
cancel-in-progress: false
jobs:
publish:
name: build · verify · publish
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
with:
# Tests run git log against this repository and need full history.
fetch-depth: 0
- uses: pnpm/action-setup@v6
with:
version: 11
- uses: actions/setup-node@v7
with:
node-version: 22
registry-url: https://registry.npmjs.org
cache: pnpm
# The plugin's git revert/cherry-pick spawn git without an identity
# (see tests/smoke.spec.ts); the runner has none by default.
- name: Configure git identity for tests
run: |
git config --global user.name "dsh-better-sidebar-ci"
git config --global user.email "ci@dsh.invalid"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Verify release tag matches package.json version
if: github.event_name == 'release'
env:
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
expected="v$(node -p "require('./package.json').version")"
echo "package.json version: ${expected#v}"
echo "release tag: ${RELEASE_TAG}"
if [ "${RELEASE_TAG}" != "${expected}" ]; then
echo "::error::Release tag ${RELEASE_TAG} does not match package.json version ${expected#v} (expected ${expected})."
exit 1
fi
- name: Build
run: pnpm build
- name: Typecheck
run: pnpm typecheck
- name: Test
run: pnpm test
- name: Dry-run publish (validation only)
if: github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'true'
run: pnpm publish --dry-run --no-git-checks
- name: Publish to npm
if: github.event_name == 'release' || github.event.inputs.dry_run != 'true'
run: pnpm publish --provenance --access public --no-git-checks
+5
View File
@@ -149,3 +149,8 @@ registry/
# stray npm cache scratch dir
.npm-cache-tmp/
# Playwright headless-render lane (tests/e2e) artifacts
playwright-report/
test-results/
+115 -18
View File
@@ -9,9 +9,29 @@ better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务(Cordis context
## 0. 仓库硬约束(所有本仓库插件必须遵守)
- **禁止修改 DeepSeek Harness (DSH) 源码**:对官方源码 checkout(`~/.dsh/source/current`)零写入——不得改 harness 包、不得把 harness 改动提交到它的分支。
- **代码改动必须走 PR**:功能 / 修复 / 测试等非文档改动一律在分支上开发(`feat/*` / `fix/*`),用 `gh pr create` 发起 PR,review 合并后才进 main;**仅纯文档类改动**(README / AGENTS.md / docs/ 等)允许直接推送到 main。
- **挂载只走 `cordis.patch.yml` + profile 机制**(`~/.dsh/profiles/<profile>/`),插件永远作为独立包被 profile 引用,不反向侵入 DSH。
- 需要 harness 没有的能力时,用 DSH **现成的只读/公开 API** 或插件自有路由实现(参考 §7 的 `jobs.output` 事件回放:读会话事件日志而非动注册表);如果确实做不到,先向用户说明取舍,而不是直接改 DSH。
### CI 挂载冒烟(`plugin-mount` job / `pnpm test:mount`)
仓库的 CI 有一条「npm 打包 → 真实挂载 → 无头渲染」门禁(`.github/workflows/ci.yml` 的 `plugin-mount` job),证明**打包产物**在真实 DSH 上挂载后无头渲染不会 crash:
1. `pnpm build && pnpm pack` 产出 tarball(与发布产物一致)。
2. `scripts/e2e-mount.sh` 用官方 CLI 把它装进一个**全新 scratch profile**(`dsh plugin --profile web add file:<tarball>`,触发 `dsh.profile.bundles` 协调),然后启动真实 `dsh web`(keyless,`--port 0`)。
3. `tests/e2e/mount.e2e.ts`(Playwright Chromium)加载页面,断言外壳与 `[data-dsh-better-sidebar]` 挂载、无 `dsh-better-sidebar:` 错误条、无 pageerror/插件 console 错误,显式展开面板(`openByDefault` 默认关)后通过「+ 菜单」逐个打开内置 tab(含终端懒加载 chunk)深扫,再经 Files 文件窗口的树打开 seed 文件强制加载 editor 懒加载 chunk(`client-editor.js`,独立模式:每个文件新开 tab,seed 的 home tab 保持资源管理器)——缺失的内置 tab 或 chunk 都会使门禁变红。
本地跑:`pnpm build && pnpm pack && pnpm exec playwright install chromium && pnpm test:mount`(需 PATH 上有 `dsh` 或可经 npx 拉取)。DSH CLI 版本在 CI 钉住 `@deepseek-ai/dsh@0.1.0-rc.8`(与插件 peer 范围同步)。`tests/e2e` 的 spec 命名 `*.e2e.ts` + vitest `exclude` 双保险与 vitest 隔离;**改动 vitest `exclude` 时必须保留默认排除项**(`exclude` 会整体替换默认值)。
### npm 发版(GitHub Release → npm publish)
`.github/workflows/release.yml` 在 GitHub Release 发布(tag `vX.Y.Z`)时自动发版到 npm:
1. **发版前置**:`package.json` 版本号 bump 到 `X.Y.Z`(manifest 一致性守卫会校验其它副本),CI 全绿后打 tag `vX.Y.Z` 并发布 GitHub Release。tag 必须与 package.json 版本一致——workflow 在发布前校验,不匹配直接失败、不发版。
2. **流程**:workflow 依次执行 `pnpm build` / `pnpm typecheck` / `pnpm test`,校验 tag,然后 `pnpm publish --provenance --access public`(`prepublishOnly` 会重建产物;产物带 provenance 签名)。
3. **认证**:走 npm **Trusted Publishing(OIDC)**,不需要也不应配置 `NPM_TOKEN` secret。一次性手动配置(在 npmjs.com 完成):package `dsh-better-sidebar` → Settings → Publishing access → Trusted Publishers → Add Trusted Publisher,字段为 Provider `GitHub Actions`、Organization `omdsh-dev`、Repository `DSH-better-sidebar`、**Workflow filename `release.yml`**、Environment 留空。未配置前发布会失败(OIDC 交换报错),配置后无需改 workflow。
4. **调试**:`workflow_dispatch` 手动触发 + `dry_run=true` 只打包验证、不发版。
---
## 1. 服务定位
@@ -33,7 +53,7 @@ better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务(Cordis context
{
"name": "my-plugin",
"peerDependencies": {
"cordis": "^4.0.0-rc.7",
"cordis": "^4.0.0-rc.8",
"dsh-better-sidebar": "workspace:*"
},
"peerDependenciesMeta": {
@@ -111,7 +131,7 @@ interface TabDescriptor {
title: string | (() => string)
/** 图标:ReactNode 或 (size: number) => ReactNode */
icon?: ReactNode | ((size: number) => ReactNode)
/** + 菜单排序(升序);默认 100。内置:explorer=10, git=20, subagent=30, terminal=40 */
/** + 菜单排序(升序);默认 100。内置:editor=10, git=20, subagent=30, terminal=40, browser=50 */
order?: number
/** 从 + 菜单隐藏(editor/diff 用:由其他流程触发打开,不在菜单里) */
hidden?: boolean
@@ -125,7 +145,7 @@ interface TabDescriptor {
/**
* 去重键:openTab 时若已存在 dedupeKey 相同的 tab,则聚焦而非新开。
* 返回 undefined 表示不去重(每次都新开,但同 id 会被 id 安全网聚焦)。
* 内置策略:explorer/git/subagent 用 single: true;editor 用 tab => tab.path;diff 用 tab => tab.id。
* 内置策略:git/subagent 用 single: true;editor 用 tab => tab.path;diff 用 tab => tab.id。
*/
dedupeKey?: (tab: SidebarTab) => string | undefined // 必须保持纯函数:每次 open 会求值两次,抛错会向外传播
/**
@@ -134,24 +154,38 @@ interface TabDescriptor {
* 省略时用默认 { id, type, title } + seed 里的 path/diff。
*/
createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null
/**
* 外链点击目标认领(v0.13.0+):聊天/界面外链被接管(`browserInterceptLinks`
* 总闸 + URL 对应协议开关均开)时,第一个 `urlTarget(url)` 命中且未被设置
* 禁用的注册 tab 类型以 `openTab({ type, url, title: hostname })` 打开——
* URL 即全部载荷(tab 从 `tab.path` 读取)。注册顺序先到先得;谓词抛错被
* 吞掉(跳过该类型)。内置 browser 不声明 urlTarget,永远是隐式兜底,不会
* 遮蔽插件声明。要同时容纳多个 URL 需用 createTab 铸造 per-URL id(browser
* 同款模式);否则同类型二次点击被 id 安全网聚焦,新 URL 不会覆写既有 path。
*/
urlTarget?: (url: URL) => boolean
/**
* 声明式设置(v0.4.1+):每个注册的 tab 都会在 Side card 设置页获得一行
* 开关(图标 + 标题 + 类型 id),`settings.toggles` 在其行下追加嵌套设置行,
* 绑定 SidebarPrefs 字段。嵌套设置仅父级启用时显示(v0.11.0 起行控件不限于
* 布尔开关:`type: 'switch' | 'text' | 'number'`,缺省 'switch';text/number
* 行 blur/Enter 提交,number 行按 min/max 钳制,unit 渲染单位后缀)。
* 布尔开关:`type: 'switch' | 'text' | 'number' | 'select'`,缺省 'switch';
* text/number 行 blur/Enter 提交,number 行按 min/max 钳制,unit 渲染单位后缀;
* select 行是下拉选择:options 声明 `{ value, title, desc?, icon? }`,任一
* 选项带 icon 时下拉渲染大图标选项卡、收起态同样显示图标,否则单行文本;
* `multi: true`(缺省 false)多选,存储值为选中 value 的数组,按 options
* 顺序提交)。
* v0.12.0 起增加两个插件自有扩展(详见 §5 声明式设置):
* `pluginToggles`(插件自有 key,持久化在 pluginSettings[id],无需宿主 schema 字段)
* 与 `render`(自定义设置面板,替代行列表)。
*/
settings?: {
toggles?: readonly {
/** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools' / 'terminalFontFamily' / 'htmlViewerNoSandbox' / 'htmlViewerDefaultUnsafe' / 'browserNoSandbox' / 'browserInterceptLinks') */
/** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools' / 'terminalFontFamily' / 'editorExplorer' / 'htmlViewerNoSandbox' / 'htmlViewerDefaultUnsafe' / 'browserNoSandbox' / 'browserInterceptLinks' / 'browserInterceptHttp' / 'browserInterceptHttps') */
key: string
title: string | (() => string)
desc?: string | (() => string)
/** 行控件类型;缺省 'switch'(向后兼容)。 */
type?: 'switch' | 'text' | 'number'
type?: 'switch' | 'text' | 'number' | 'select'
/** number 行的提交钳制下限。 */
min?: number
/** number 行的提交钳制上限。 */
@@ -160,6 +194,15 @@ interface TabDescriptor {
placeholder?: string
/** 输入框后的单位后缀(如 'px')。 */
unit?: string
/** select 行的选项(value 为提交值:string | number | boolean)。 */
options?: readonly {
value: string | number | boolean
title: string | (() => string)
desc?: string | (() => string)
icon?: ReactNode | ((size: number) => ReactNode)
}[]
/** select 行是否多选(缺省 false;存储值为 value 数组)。 */
multi?: boolean
}[]
/** 插件自有设置行(v0.12.0+):形状同 toggles,但 key 是插件局部的,
* 持久化在 `pluginSettings[<descriptor id>]`——不需要宿主 PrefsSchema 字段。 */
@@ -167,11 +210,18 @@ interface TabDescriptor {
key: string
title: string | (() => string)
desc?: string | (() => string)
type?: 'switch' | 'text' | 'number'
type?: 'switch' | 'text' | 'number' | 'select'
min?: number
max?: number
placeholder?: string
unit?: string
options?: readonly {
value: string | number | boolean
title: string | (() => string)
desc?: string | (() => string)
icon?: ReactNode | ((size: number) => ReactNode)
}[]
multi?: boolean
}[]
/** 自定义设置面板(v0.12.0+):给出时齿轮弹窗渲染它而非行列表。
* props 含 store/service/prefs、本 descriptor 的 pluginSettings blob、
@@ -221,7 +271,7 @@ interface TabComponentProps {
tab: SidebarTab // 当前 tab 实例(含 id/type/title/path?/diff?)
visible: boolean // 是否是当前激活 tab 且面板打开(不可见时暂停轮询等)
// 以下由内置 tab 使用,外部 tab 可忽略:
expanded?: string[] // explorer 的展开目录集
expanded?: string[] // 文件树的展开目录集
onToggleDir?: (path: string) => void
onReferenceFile?: (path: string) => void
onOpenFile?: (path: string) => void
@@ -278,12 +328,29 @@ ctx.effect(() =>
)
```
**认领外链点击**(v0.13.0+,`features.includes('urlTarget')` gate):聊天/界面里被拦截的 HTTP(S) 外链(协议开关开启时)路由到第一个 `urlTarget` 命中的 tab 类型,URL 作为 seed 预填 `tab.path`;内置浏览器是隐式兜底。多 URL 并存需用 `createTab` 铸造 per-URL id(browser 同款模式),否则同类型二次点击聚焦既有 tab、不覆写 path:
```ts
ctx.effect(() => {
if (!ctx.betterSidebar.features.includes('urlTarget')) return
return ctx.betterSidebar.registerTab({
id: 'my-plugin:web-docs',
title: () => 'Docs',
order: 80,
urlTarget: (url) => url.hostname === 'docs.my-site.com', // 只有该域名被点击时认领
createTab: (state) => ({
tab: { id: `my-plugin:web-docs:${state.nextBrowser}`, type: 'my-plugin:web-docs', title: 'Docs' },
patch: { nextBrowser: state.nextBrowser + 1 },
}),
component: ({ tab, scope }) => <WebDocsView url={tab.path} sessionId={scope.sessionId} />,
})
})
```
### 3.4 内置 tab(不可重复注册)
| id | order | single | hidden | 用途 |
|---|---|---|---|---|
| `editor` | -1 | 否(按 path 去重) | 是 | 文件编辑/预览(由 openSidebarFile 触发) |
| `explorer` | 10 | 是 | 否 | 文件资源管理器 |
| `editor` | 10 | 否(按 path 去重) | 否 | 唯一的「文件窗口」(文件编辑/预览 + 文件资源管理):文件 tab(有 path)在两种 `editorExplorer` 模式下 chrome 恒为合并形态——头部路径输入框 + 文本编辑器预览/编辑/保存控件 + 可开关的内嵌文件树面板(含全局文件名搜索,走 host `fs.search` 路由;左缘拖拽调宽),状态存 `tab.meta.treeOpen` / `tab.meta.treeWidth`;pref 控制**打开行为与无路径窗口形态**:关(默认,独立)= 走 `openSidebarFile` 按 path 新开,**无路径窗口即独立资源管理器——只渲染文件树面板**(搜索 + FileTree 撑满全窗,无编辑器 chrome);开(合并)= 树点击/输入框 Enter 原地切换当前 tab(`updateTab` 重写 path/title,id 与 meta 不变),无路径窗口 = 带 chrome 的空文件窗口(树默认展开)。树右键菜单提供「在新 Tab 中打开」「在侧边打开」(后者在当前 pane 右侧 split 出新 editor tab)。新会话在两种模式下都默认 seed 空文件窗口(`title: 'Files'`,无 path,树面板展开);持久化的旧 `explorer` tab 经 `sanitizeState` 迁移为该 home tab |
| `git` | 20 | 是 | 否 | Git 面板 |
| `subagent` | 30 | 是 | 否 | 子代理拓扑 |
| `terminal` | 40 | 否 | 否 | 终端(nextTerminal 自增) |
@@ -338,6 +405,11 @@ interface FileViewerProps {
truncated?: boolean // fetchStrategy='fsRead' 时
mediaUrl?: string // fetchStrategy='mediaUrl' 时
customData?: unknown // fetchStrategy='custom' 时(load() 的返回值)
// 以下三个为内置文本编辑器与 EditorHost 的内部协作字段(合并模式把
// 预览/编辑/保存工具栏上移到路径输入框行);外部 viewer 忽略即可:
toolbar?: 'self' | 'host'
onToolbarState?: (state: EditorToolbarState) => void
onToolbarControls?: (controls: EditorToolbarControls | null) => void
}
```
@@ -462,7 +534,7 @@ interface BetterSidebarService {
readonly version: string
/** 单调能力清单(只增不删):'badge' | 'tabLifecycle' | 'updateTab' |
* 'openFile' | 'targetedOpen' | 'stateSubscription' | 'tabMeta' |
* 'pluginSettings'——消费插件用 `features.includes('xxx')` 按能力 gate。 */
* 'pluginSettings' | 'urlTarget' | 'settingSelect'——消费插件用 `features.includes('xxx')` 按能力 gate。 */
readonly features: readonly string[]
/** 当前快照:激活 sessionId + 其状态(面板几何/打开的 tabs/展开集)+ prefs。
* session 未激活时 state/sessionId 为 undefined。 */
@@ -475,7 +547,10 @@ interface BetterSidebarService {
* 未知 id 严格 no-op);scope(v0.12.0+)随回调传递,同 closeTab */
activateTab(tabId: string, scope?: SessionScope): void
/** 在 scope.sessionId 的侧边栏编辑器打开一个文件(title 缺省为文件名;
* id 按路径派生,与内置 open-path 拦截一致,不同文件可并排打开) */
* id 按路径派生(`editor:` + path),与内置 open-path 拦截一致,不同文件
* 可并排打开。注意:path 派生 id 只对 openFile/openSidebarFile 的打开成立——
* 文件窗口的原地切换(editorExplorer 开)经 updateTab 重写 path/title,
* tab id 保持稳定、不再与 path 对应) */
openFile(scope: SessionScope, path: string, title?: string): void
}
@@ -493,7 +568,7 @@ interface OpenTabSeed {
}
```
> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,勾选徽标钉在卡片最右端,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`、终端的 `terminalFontFamily`/`terminalFontSize`)通过卡片右下角的齿轮按钮在**原生弹窗**中编辑——`type: 'switch'` 行是复选框,`type: 'text'`/`'number'` 行是输入框(v0.11.0+)——父级卡片关闭时齿轮隐藏;`settings.toggles` 的 **key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `terminalFontFamily` / `terminalFontSize` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks`)。**v0.12.0 起设置 seam 已开放**:外部插件用 `settings.pluginToggles`(同款行控件,key 插件局部)或 `settings.render`(自定义面板)声明自己的设置,值持久化在 prefs 文档的 `pluginSettings[<descriptor id>]`(开放 map,宿主 schema 已有字段,无需注册)——齿轮弹窗对 tab 与 viewer 都可用(viewer 卡片 v0.12.0 起也有齿轮)。
> **声明式设置(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+),`type: 'select'` 行是下拉选择(options 带 icon 时为大图标选项卡,`multi: true` 多选存 value 数组)——父级卡片关闭时齿轮隐藏;`settings.toggles` 的 **key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `terminalFontFamily` / `terminalFontSize` / `editorExplorer` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks` / `browserInterceptHttp` / `browserInterceptHttps`)。**v0.12.0 起设置 seam 已开放**:外部插件用 `settings.pluginToggles`(同款行控件,key 插件局部)或 `settings.render`(自定义面板)声明自己的设置,值持久化在 prefs 文档的 `pluginSettings[<descriptor id>]`(开放 map,宿主 schema 已有字段,无需注册)——齿轮弹窗对 tab 与 viewer 都可用(viewer 卡片 v0.12.0 起也有齿轮)。
---
@@ -516,12 +591,33 @@ interface OpenTabSeed {
| **host 半无此服务** | `ctx.betterSidebar` 只在 client 侧存在;host 半需要 better-sidebar 数据走 `/sidebar/api/*` HTTP 路由 |
| **portal 限制** | 整面板 slot 由 ui-layout 独占,外部 tab 只能进入 better-sidebar 的 portal 内部,无法全屏替换 |
| **id 冲突** | `registerTab` / `registerFileViewer` 对重复 id 抛错;建议用包前缀(`my-plugin:xxx`) |
| **家族右面板互斥(v0.13.0+)** | 读取 `aionui-panel` 设置命名空间的 `rightPanel`:解析为 `'aionui-panel'` 时整个侧边栏不挂载(`settings.get` 路由返回 `externalDisable: true`,客户端挂载门 + 各类接管一并停用;`settings/document-updated` 推送实时生效,无 `remote` 服务的部署回退为启动时判定)。未安装 aionui 或提供方为其他值时不受影响 |
| **i18n 跟随** | 侧边栏界面文案跟随 DSH 的 `ctx.locale`(`@deepseek-ai/dsh-client-locale`):词典注册在 `betterSidebar` 命名空间,语言偏好(Host-backed `locale.preference`)与浏览器语言不一致时以 DSH 为准并实时切换;locale 服务缺失时回退浏览器语言。插件自身的 `t()`(`src/client/locales.ts`)由 `apply()` 挂接服务;消费插件**不要**依赖此内部函数——标题等字段传字符串或 `() => string` 即可(i18n 友好)。⚠️ 渲染 DSH 的 `MarkdownText` 时必须传 `codeLabels={{ copyLabel: t('copy'), copiedLabel: t('copied') }}`——该组件 cordis-free,漏传则代码块复制按钮回退硬编码中文 |
| **懒加载 chunk** | 内置重依赖(xterm/CodeMirror)在独立 bundle(`lib/client-<name>.js`)中,经 `/sidebar/bundle` 路由按需下发;每个脚本把 factory 赋到插件自有全局注册表 `globalThis.__dshChunks__[<name>]`,由 `src/client/chunk-loader.ts` 用自定义 require(externals 经 `__DSH_MODULES__` seed 分支解析)物化——**不经过** `__ModuleLoader__` 注册;**核心 bundle 禁止静态 import `src/client/chunks/*`**(会把库拖回启动路径);对消费插件透明——懒加载只作用于内置 descriptor,`component` 契约(`(props) => ReactNode` 纯渲染函数)不变 |
---
## 8. 完整最小示例
## 8. 皮肤兼容(令牌驱动)
> 侧边栏的皮肤兼容是**令牌驱动**的:所有视觉值都消费 DSH 的 `--dsw-alias-*` / `--dsw-font-*` / `--ds-*` 令牌(无硬编码颜色),皮肤系统覆盖什么令牌我们就跟随什么——**不做任何每皮肤适配**。当前已与 dsh-web-ui 皮肤中心兼容:其 10 款皮肤全部覆盖 `--dsw-alias-*` 层,换肤后面板自动跟随(测试:`tests/e2e/mount.e2e.ts` 断言布局变量随面板挂载生效;`tests/theme.spec.ts` 守护令牌读取)。
### 8.1 规则
- **面板表面**:右/底面板背景 = `var(--dsw-alias-bg-layer-1)`(通用卡片表面)。**绝不消费 `--dsw-specific-sidebar-fill`**——那是宿主左侧导航列专属令牌,皮肤系统按左导航语义覆盖它(dsh-web-ui 皮肤把它做成半透明玻璃或主题色,Aqua 设成 `transparent`),面板消费它会失去填充或与标签令牌冲突。皮肤要整体换面板表面:覆写 `--dsw-alias-bg-layer-1` 即可(dsh-web-ui 10 款皮肤都已覆盖,无需任何额外工作)。
- **终端/编辑器表面**:经 `effectiveTokenValue` 读取 `--dsw-alias-bg-base`——`transparent` 与 alpha < 0.9 的半透明玻璃值(dsh-web-ui 皮肤用 rgba 0.16–0.7)一律回退不透明底色,文字永不叠在皮肤背景画上滚动(issue #90);≥ 0.9 的近不透明值(如皮肤作用域内 0.96 的瓷器玻璃)放行,皮肤仍能控制终端表面。
- **根锚点**:宿主 div 带 `data-dsh-better-sidebar` 属性(append 到 `document.body`)。其内是**统一面板宿主层** `[data-dsh-panel-host]`(`position:fixed; inset:0; z-index:40; pointer-events:none`,v0.13.1+):面板/开关簇在其内 **absolute** 定位(层 inset:0 即视口坐标),免疫桌面套壳中间层 transform 对 fixed 含块的劫持;页面级 transform(罕见)触发 `data-dsh-panel-host-degraded` 降级同步。皮肤若要做作用域覆盖(deep-whale 的做法),限定在 `[data-dsh-better-sidebar]` 内即可,避免全局改写影响宿主。
- **布局变量**(写在 `<html>` 上,面板打开时有效):`--dsh-sidebar-width` / `--dsh-sidebar-height`(面板几何;拖拽期间逐帧更新)。宿主推挤 = `#root` 的 `margin-right/width: calc(100% - var)`(v0.13.1+ 防桌面壳加性溢出)与 centerCol 的 `margin-bottom`;推挤锚点是 **`#root [data-dsh-frame] > [data-pane="conversation"]`**(稳定 data 属性,**禁止**退回 `nth-child` 位置锚定)。
- **桌面信号**(v0.13.1+):DSH Desktop 壳在 URL 注入 `dsh-desktop-mode` / `dsh-desktop-platform`(preload 另暴露 `__DSH_DESKTOP_FILE_PATH__`);win32 `advanced` 壳右上角有 32px 窗口控制 overlay,插件自动启用标题栏兼容(`body[data-dsh-title-bar-compat]` + `--dsh-title-bar-strip: 32px`),手动 pref 可覆盖;`compatibility` 模式与无信号环境不避让。
- **z-index**:面板宿主层 40、折叠按钮簇 45(角手柄在面板内层叠,z-index 2 仅面板内有效)——全部低于 DSH 浮层栈(100/1000+),任何浮层天然盖住侧边栏。
### 8.2 注意事项
- 类名是 CSS Modules 哈希(`[hash]_[local]`),**不是契约**——皮肤不要依赖类名寻址;需要精确命中单表面时,用 `[data-dsh-better-sidebar]` 属性选择器配合子串类名(如 `[class*='panel']`)或 DOM 结构。
- 改动本契约(面板表面令牌、透明度阈值、z-index 层级)必须同步更新本文档、设计文档与 `tests/theme.spec.ts`。
---
## 9. 完整最小示例
> 假设插件 `my-plugin` 要加一个"Database 浏览器" tab + `.csv` 文件预览器。
@@ -536,7 +632,7 @@ interface OpenTabSeed {
"./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }
},
"peerDependencies": {
"cordis": "^4.0.0-rc.7",
"cordis": "^4.0.0-rc.8",
"dsh-better-sidebar": "workspace:*",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"react": "^18.2.0"
@@ -597,16 +693,17 @@ function parseCsv(text: string): string[][] { /* ... */ }
---
## 9. 参考实现
## 10. 参考实现
better-sidebar 自己的内置 tab 和 viewer 就是参考实现("吃狗粮"):
- **`src/client/builtins/`**:7 个内置 tab(explorer/git/subagent/terminal/browser/editor/diff)+ 6 个内置 viewer(image/pdf/markdown/html/code/binary-download)的注册代码(tabs.tsx / viewers.tsx / index.ts;Office 预览已迁至推荐插件,见 plugins-viewers.ts)
- **`src/client/builtins/`**:6 个内置 tab(editor/git/subagent/terminal/browser/diff)+ 6 个内置 viewer(image/pdf/markdown/html/code/binary-download)的注册代码(tabs.tsx / viewers.tsx / index.ts;Office 预览已迁至推荐插件,见 plugins-viewers.ts)
- **`src/client/service.ts`**:`BetterSidebarService` 接口 + `createBetterSidebarService` 工厂实现
- **`src/client/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + `settings.toggles` 嵌套设置行:switch/text/number + 持久化)
- **`tests/service.spec.ts`**:注册表生命周期 / 匹配算法 / dedupe / createTab / 启用态 gating 测试
- **`tests/builtins.spec.ts`**:内置注册清单断言(7 tab + 6 viewer + 声明式元数据)
- **`src/client/plugins-tabs.ts`** / **`src/client/plugins-viewers.ts`**:推荐插件目录(名字/url/简介/安装脚本,分别对应 Tab 注册与文件预览注册),在设置页两个「添加插件」弹窗展示(共享类型在 `plugins-shared.ts`);插件作者可按扩展点加一条数据(弹窗内「跳转」直达仓库、「复制」把安装命令写入剪贴板,粘贴到 DSH 所在环境的终端执行)——数据完整性由 `tests/plugin-list.spec.ts` 守护
- **`src/client/FileTree.tsx`** / **`src/client/TreePanel.tsx`** / **`src/fs-search.ts`**:受控文件树组件(纯树体,文件行右键菜单含「在新 Tab 中打开」「在侧边打开」,仅宿主编排提供回调时渲染)/ 树面板(搜索框 + 刷新 + FileTree,文件窗口的内嵌 dock 使用)与 host 侧递归文件名搜索(`fs.search` 路由,预算兜底 + 跳过 `.git`/symlink 目录;测试 `tests/fs-search.spec.ts`、组件测试 `tests/editor-host.spec.tsx`)
- **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`** / **`docs/plans/2026-08-14-add-plugins-modal-design.md`**:设计文档(含实施偏差记录)
调试时直接读这些文件即可看到所有 API 的真实用法。
+106 -122
View File
@@ -1,145 +1,122 @@
# dsh-better-sidebar
<!-- 头部介绍区(HTML 排版) -->
<!-- Hero -->
<div align="center">
<b style="font-size: 1.15em;">一个插件,一套完整工作台</b><br /><br />
<code>文件管理</code> <code>编辑预览</code> <code>内嵌浏览器</code> <code>真实终端</code> <code>Git 面板</code> <code>后台任务页</code><br /><br />
<b>右侧栏 + 底部面板双工作台</b>,一个插件全部搞定。<br />
<small>支持 Tab 窗口随意拖拽,支持三方拓展注册新 Tab 页面和文件预览</small>
<b style="font-size: 1.15em;">一个服务化的侧边栏框架,一套开箱即用的完整工作台</b><br /><br />
<a href="https://opensource.org/licenses/MIT"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-yellow.svg" /></a>
<a href="https://dshfind.com/zh/plugins/omdsh-dev/DSH-better-sidebar?ref=badge"><img alt="dshfind" src="https://dshfind.com/api/badge/omdsh-dev/DSH-better-sidebar?lang=zh" /></a><br /><br />
<img alt="文件管理" src="https://img.shields.io/badge/-文件管理-4d6bfe" /> <img alt="编辑预览" src="https://img.shields.io/badge/-编辑预览-4d6bfe" /> <img alt="内嵌浏览器" src="https://img.shields.io/badge/-内嵌浏览器-4d6bfe" /> <img alt="真实终端" src="https://img.shields.io/badge/-真实终端-4d6bfe" /> <img alt="Git 面板" src="https://img.shields.io/badge/-Git%20面板-4d6bfe" /> <img alt="后台任务" src="https://img.shields.io/badge/-后台任务-4d6bfe" /> <img alt="插件接入" src="https://img.shields.io/badge/-插件接入-4d6bfe" /><br /><br />
<b>右侧栏 + 底部面板双工作台</b>,并把 <code>ctx.betterSidebar</code> 服务开放给所有插件——<br />
通过 <code>registerTab</code> / <code>registerFileViewer</code> 注册新的侧边栏页面与文件预览器。
</div>
<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
<img width="2630" height="1794" alt="6c4293e1bec2e935031bf0e986d6ec65" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" />
## 🆕 最近更新
<small>v0.12.1</small>
| 功能 | 说明 | 截图 |
|---|---|---|
| 🔌 服务化基座 | 完整类型导出 + `version`/`features` 能力探测、状态订阅(`getSnapshot`/`subscribeState`)、tab 角标、`onOpen`/`onActivate`/`onClose` 生命周期回调、`updateTab`/`activateTab`/`openFile`、定向打开、`meta` 跨刷新持久化、插件自有设置(`pluginToggles`/`render`) | |
| ➕ 添加插件 | 设置页「推荐插件目录」+ 一键复制安装命令;内置 Office 预览迁至推荐插件 | |
| 🖱️ 标签页滚轮 | 标签页栏支持鼠标滚轮横向滚动 | |
| 🐛 修复 | 远程访问 403(信任栅栏改用 `trustedHosts`)、侧边栏崩溃 [#31](https://github.com/omdsh-dev/DSH-better-sidebar/issues/31)、Windows 下 HTML 预览盘符路径 | |
<div align="center">
<img alt="dsh-better-sidebar 工作台截图" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" />
<video src="https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86" muted autoplay loop playsinline controls width="100%"></video>
</div>
## ✨ 功能一览
- **🗂️ 资源管理器**:懒加载目录树(根 = 会话 cwd),点击打开、`@文件` 引用、右键复制路径
- **📝 编辑与预览**:CodeMirror 6 编辑(Ctrl/Cmd+S 原子保存,切 Tab 不丢草稿);图片 / Markdown / HTML / PDF / Word / Excel / PPT 内联预览(HTML 走沙箱 iframe)
- **⚡ 客户端懒加载**:启动只拉 ~325KB 核心,终端 / 编辑器等重依赖用到才按需拉取(详见 `docs/plans/2026-08-12-lazy-chunks-design.md`)
- **🌐 浏览器**:多开内嵌网页 tab,后退/前进/刷新;内容运行在沙箱 iframe(无法访问界面数据与本地文件,拒绝本机地址),可临时解锁(红色警示);被站点拒绝嵌入时显示原因;外链默认在侧边栏打开
- **💻 终端**:xterm.js + node-pty 真实 shell、断线重连回放;可选为模型注入 `terminal_*` 工具;支持自定义字体(字体族 + 9–32px 字号,实时生效)
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存/提交/还原等
- **🧩 后台任务页**:主会话 agent 拓扑、点击直达执行记录;同页显示后台任务(类型徽标 + 退出码、实时输出 peek、可强制终止)
- **🪟 底部面板**:第二个独立工作台,只挤占中间输出区;首次展开自动开终端(可关)
- **📱 移动端**:窄屏(<768px)自动合并为全宽抽屉,底部面板标签页并入右侧栏;点文件/外链自动展开
- **🔧 分栏工作台**:拖 Tab 拆分/合并(可跨面板)、分隔线调比例;右上角按钮一键折叠/展开面板
- **🔁 会话隔离**:布局 / Tab / 面板状态按会话持久化,陈旧状态自动净化;「产出文件」在侧边栏打开
- **⚙️ 声明式设置**:设置页「侧边卡片」按注册表渲染开关网格,逐项独立开/关;二级设置(自动展开、终端工具、沙箱等)经齿轮弹窗编辑
- **🔌 服务化(基座)**:暴露 `ctx.betterSidebar`,其他插件可注册 tab 与文件预览器(内置 7 tab + 9 viewer 同走一服务);v0.12.1 起支持角标/生命周期回调/状态订阅/定向打开/插件自有设置(见 [AGENTS.md](./AGENTS.md) 与 [外部插件接入指南](./docs/external-plugin-guide.md))
- **➕ 添加插件**:设置页「侧边卡片」两个网格(侧边栏内容 / 文件预览)末尾的虚线卡片分别打开 Tab / 预览插件弹窗:声明扩展点、「在 GitHub 上浏览更多插件」按钮、推荐插件目录(「跳转」直达仓库、「复制」安装命令到剪贴板);弹窗不打开终端、无失败路径,不阻塞启动与侧边栏
- **🌏 多语言**:界面文案跟随 DSH 语言(zh/en)实时切换,无需刷新
- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown(含 Mermaid 图表,strict 安全渲染 + 点击放大)/ HTML / PDF / Office 内联预览
- **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整)
- **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存 / 提交 / 还原
- **🧩 后台任务页**:subagent 拓扑 + 后台任务(退出码 / 实时输出 / 强制终止)
- **🪟 双工作台**:右侧栏 + 底部面板;拖 Tab 拆分 / 合并分栏(可跨面板),移动端自动合并全宽抽屉
- **🔁 会话隔离**:布局 / Tab / 面板按会话持久化,陈旧状态自动净化
- **⚙️ 声明式设置**:设置页「侧边卡片」逐项独立开关,二级设置经齿轮弹窗
- **⚡ 按需加载**:启动只拉 ~325KB 核心,终端 / 编辑器 / Mermaid 图表等重依赖用到才按需拉取([设计文档](docs/plans/2026-08-12-lazy-chunks-design.md))
- **🌏 多语言**:界面文案跟随 DSH 语言(zh / en)实时切换
> 🔌 **核心理念**:服务优先——内置的 7 tab + 6 viewer 与第三方插件通过同一套 `ctx.betterSidebar` API 注册,能力完全对等;官方不再内置、可由生态提供的功能,交由生态插件实现。接入文档见下方「🔌 服务化」与 [外部插件接入指南](./docs/external-plugin-guide.md)。
## 🆕 最近更新
<div align="center">
<a href="https://github.com/user-attachments/assets/d2aea86b-a776-4f01-a6b8-b26b27314336"><img width="33%" alt="侧边栏" src="https://github.com/user-attachments/assets/d2aea86b-a776-4f01-a6b8-b26b27314336" /></a>
<a href="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0"><img width="33%" alt="服务化基座截图" src="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0" /></a>
<a href="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e"><img width="33%" alt="添加插件截图" src="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e" /></a>
</div>
### v0.14.0
> ⚠️ **本版适配 DSH 0.1.0-rc.8**:全部 `@deepseek-ai/*` peer / devDependencies 升至 `^0.1.0-rc.8`(含传递依赖,lockfile 零 rc.7 残留),`cordis` 同步 `^4.0.0-rc.8`,CI 挂载冒烟钉版 `@deepseek-ai/dsh@0.1.0-rc.8`。**rc.7 及更早的 DSH 环境将无法解析本版依赖,请先升级 DSH**。自 v0.13.1 以来的全部更改:
**✨ 新功能**
- 🖼️ **统一面板宿主注入重构**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):面板/开关簇迁入 `[data-dsh-panel-host]` 固定含块层(`fixed inset-0 z-40`),免疫桌面套壳中间层 transform 对 fixed 含块的劫持;挂载自检(页面级 transform → `data-dsh-panel-host-degraded` 降级同步,按未修正几何判定、祖先变换消失才退出);推挤锚点改 `#root [data-dsh-frame] > [data-pane="conversation"]` + `#root` calc 宽度防桌面壳加性溢出;chunk 激活重验证(HEAD+ETag 保留未变 chunk,5s 超时兜底 fail-open);桌面信号自动探测(win32 advanced 标题栏兼容 32px 避让,手动 pref 可覆盖);`visualViewport` 键盘 inset + `env(safe-area-inset-*)` 移动端适配
- 📂 **文件打开方式默认独立**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):`editorExplorer` 默认从「合并」改为「独立」——新会话树点击 / 打开文件按路径**新开**文件 tab,无路径窗口即纯资源管理器;合并模式保留为可选手动开启
- 🖥️ **终端 shell / shellArgs 设置页可配**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):终端卡齿轮二级页面新增「Shell 路径」「Shell 参数」两行配置(此前只能通过 `cordis.patch.yml` 配置)——设置页写入后对**之后打开的** UI 终端与模型终端(`terminal_create`)即时生效;留空保持 yaml → `$SHELL` / 登录 shell / `powershell.exe` 的既有解析顺序
- 🏷️ **设置页版本徽标**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):侧边卡片设置页顶部新增 `DSH-better-sidebar v0.14.0` 身份徽标(版本与服务实例同步,由测试守护)
- 🔍 **添加插件目录搜索 / 分组 / 独立滚动**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):为插件生态增长做准备——目录列表顶部加实时搜索(按名称 / id / 描述过滤),条目支持可选 `category` 分组渲染,列表独立滚动(弹窗不再随条目数无限增长)
**🐛 修复**
- 🔧 **适配 DSH 0.1.0-rc.8**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):13 个 `@deepseek-ai/*` peer / devDependencies 升至 `^0.1.0-rc.8`(含传递链,lockfile 零 rc.7 残留),`cordis` 同步 `^4.0.0-rc.8`;移除随 rc.8 消失的 `dsh-client-web-react` / `dsh-client-schema-form`(壳模块表不再提供、插件零引用);CI 挂载冒烟钉版 `@deepseek-ai/dsh@0.1.0-rc.8`;pnpm 11.8 supply-chain 校验适配
- 🧩 **rc.8 模块系统迁移**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):rc.8 不再暴露 `window.__DSH_MODULES__` 页面全局(改由 `ctx.modules` 服务提供),懒加载 chunk 的外部依赖解析全面失效——client 注入 `modules` 服务 + 插件自有全局共享给 chunk 副本(终端 / 编辑器 / Mermaid 恢复正常按需加载)
- 🧩 **chunk 重验证屏障健壮性**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):HEAD 重验证加 5s 超时兜底(路由挂起时 fail-open 重取,屏障不再可能无限期阻塞懒加载);`resetChunks` 清挂起的重验证屏障
- 🖱️ **拖拽健壮性**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):快速释放(浏览器合并 / 丢失 pointermove 突发)时提交最后已知拖动位置而非回退;`pointercancel` / 捕获丢失中断同样保留拖动结果;提交后立即重测中心列(消除底栏宽度中间帧抖动);HMR 重激活后中心列重定位兜底(`<html>` 样式观察 + 底栏打开重测),修复热更新后底栏空白 / 输入框位移
### v0.13.1
**✨ 新功能**
- 📊 **Markdown 预览安全渲染 Mermaid 图表**([#164](https://github.com/omdsh-dev/DSH-better-sidebar/pull/164)):预览的 md 含 mermaid fence 时按需下发 `client-mermaid.js` chunk(~7MB,无 mermaid 文件零加载);纵深防御渲染——`securityLevel: 'strict'` + `htmlLabels: false`(节点文字走真实 SVG `<text>`)+ SVG 注入前二次清洗(删 `foreignObject`/`script`/外来 HTML 元素、剥 `@*`/`on*`/`href` 属性);点击图表在弹窗中放大(滚轮以鼠标为中心缩放、拖拽平移、工具栏与快捷键),深浅色跟随重渲、解析失败回退原码
- 🖥️ **终端 shell 与 shellArgs 可配置**([#125](https://github.com/omdsh-dev/DSH-better-sidebar/pull/125)):`cordis.patch.yml` 的 `better-sidebar.config` 可指定 `shell` / `shellArgs`(`shellArgs` 非空时完全替换默认参数;未配置维持自动解析 `$SHELL` / 登录 shell / `powershell.exe` 原行为),UI 终端与 agent 终端(`terminal_create`)同时生效;终端 tab 标题改用 shell 名(bash / zsh / powershell),内部标识改 UUID,同 shell 可开多个终端
**🐛 修复**
- 🔗 **聚合双挂载自动退让**([#200](https://github.com/omdsh-dev/DSH-better-sidebar/pull/200)):聚合包(如 dsh-web-ui-all)以独立条目 id 挂载同包时,`cordis.patch.yml` 的守卫表达式自动禁用自身 `better-sidebar` 行,不再重复注册 `/sidebar/api` 导致 `duplicate prefix route` 整个插件树启动失败(`dsh web` 崩溃);独立安装行为不变
- 🔧 **peer 依赖对齐 DSH 0.1.0-rc.7**([#207](https://github.com/omdsh-dev/DSH-better-sidebar/pull/207),修复 [#206](https://github.com/omdsh-dev/DSH-better-sidebar/issues/206)):全部 `@deepseek-ai/*` peer / devDependencies 从 `^0.1.0-rc.6` 升至 `^0.1.0-rc.7`,CI 挂载冒烟同步钉版——消除主框架升至 rc.7 后 rc.6 / rc.7 混用依赖树导致的 `agent-presets: refusing to compose an unscoped context`(选模型 / 发消息报错)
### v0.13.0
**✨ 新功能**
- 📁 **文件窗口与资源管理器二合一**([#151](https://github.com/omdsh-dev/DSH-better-sidebar/pull/151)):新 `editorExplorer` 设置(编辑器卡齿轮)——文件 tab 增加路径输入框头部 + 可开关的右侧停靠文件树(每 tab 记忆展开/宽度,左缘拖拽调宽 160~480px,全局文件名搜索走 host `fs.search` 路由,预算封顶并跳过 `.git` / 符号链接目录);独立模式(默认)树点击 / 输入框 Enter **按路径新开**文件 tab,合并模式**原地切换**当前 tab;新会话默认 seed 空文件窗口(`Files`)替代 explorer tab,无路径窗口在独立模式为纯资源管理器、合并模式为带 chrome 的空文件窗口;树右键提供「在新 Tab 中打开」「在侧边打开」(split)
- 🎛️ **声明式设置 select 行**([#151](https://github.com/omdsh-dev/DSH-better-sidebar/pull/151)):设置项新增 `type: 'select'`(`options` 支持 value/title/desc/icon,`multi` 多选存数组);带图标的选项渲染大图标选项卡、收起态同样显示图标;`editorExplorer` 改为图标化下拉(合并 / 独立);能力清单新增 `settingSelect`
- 🔀 **与 dsh-web-ui 家族右侧面板互斥**([#181](https://github.com/omdsh-dev/DSH-better-sidebar/pull/181)):读取 `aionui-panel` 设置命名空间的提供方选择——当选择「使用 aionui-panel」时,整个 better-sidebar(右侧栏 / 底部面板 / 浮动入口 / 各类接管)不再挂载;选择 DSH-better-sidebar(或未安装 aionui)时正常。设置页保存后实时生效(settings-document 推送),无需刷新
### v0.12.3
**✨ 新功能**
- 🎨 **皮肤兼容(令牌驱动)**:全面消费 DSH 设计令牌,与 dsh-web-ui 皮肤中心 10 款皮肤兼容,换肤自动跟随;终端/编辑器表面在透明/半透明玻璃值下回退不透明底色,文字不叠在皮肤背景上([#110](https://github.com/omdsh-dev/DSH-better-sidebar/pull/110),修复 #106 #105 #90 #60,附带 #52 #57 #92)
- 🗂️ **统一路径处理**:UNC 路径 / 软链接分类(目录软链接可展开、失效链接标红)、HTML 路由平台守卫([#134](https://github.com/omdsh-dev/DSH-better-sidebar/pull/134),#65 #67 #43 #79 #115)
- 🖥️ **终端 shell 可配置**:设置项自定义 shell,Windows 自动探测 pwsh([#95](https://github.com/omdsh-dev/DSH-better-sidebar/pull/95))
- 📝 **编辑器新增语言**:C# / Kotlin / Swift 语法高亮([#120](https://github.com/omdsh-dev/DSH-better-sidebar/pull/120))
- 🧭 **设置页导航图标**:设置页导航图标与布局优化([#114](https://github.com/omdsh-dev/DSH-better-sidebar/pull/114))
- ➕ **推荐插件目录新增**:`dsh-git-remotes`——Git 远程 Tab(分支/上游/ahead-behind、fetch 可 prune、ff-only pull、确认后才 push,不替换内置暂存/提交)([#91](https://github.com/omdsh-dev/DSH-better-sidebar/pull/91));`dsh-video-preview`——视频内联预览(.mp4/.webm/.mov/.mkv/.avi 等,自带 /video 宿主路由支持 HTTP Range 206 拖进度条,不受 20MB mediaLimit 限制)([#126](https://github.com/omdsh-dev/DSH-better-sidebar/pull/126))
**🐛 修复**
- 🔧 **xterm 依赖迁移**:弃用的 xterm 迁移至 `@xterm/xterm`(Closes [#122](https://github.com/omdsh-dev/DSH-better-sidebar/issues/122),[#128](https://github.com/omdsh-dev/DSH-better-sidebar/pull/128))
- 📝 **Markdown 编辑器**:选区转对话弹窗恢复可用([#24](https://github.com/omdsh-dev/DSH-better-sidebar/pull/24))
- 🐛 **node-pty 加载失败不再拖垮 server**([#140](https://github.com/omdsh-dev/DSH-better-sidebar/issues/140)):宿主半改为懒加载 node-pty,缺失时插件照常挂载,终端以修复提示横幅(可复制命令 + 重试按钮)呈现,agent 终端工具自动跳过
- 🧪 测试工程:单元测试拆分(#141)+ smoke 偶发失败修复
## 🚀 安装
**前置**:已装好 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
dsh plugin --profile web add dsh-better-sidebar@latest
```
装完**硬刷新浏览器**(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.12.1 --restart
# Windows PowerShell
& ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.12.1 -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.12.1)
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.12.1` 发布,通过 `dsh.bundle.patch`(随包的 `cordis.patch.yml`)由官方 CLI 自动挂载,**不修改 DSH 源码**。
</details>
<details>
<summary><b>更新</b></summary>
```sh
dsh plugin --profile web add dsh-better-sidebar
dsh plugin --profile web add dsh-better-sidebar@latest
```
或重跑一次一键脚本;也可把 `~/.dsh/profiles/web/package.json` 里的版本号改高后 `pnpm install`。改完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可(client 改动无需重启 DSH)。
也可把 `~/.dsh/profiles/web/package.json` 里的版本号改高后 `pnpm install`。改完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可(client 改动无需重启 DSH)。
</details>
@@ -148,12 +125,13 @@ dsh plugin --profile web add dsh-better-sidebar
| 现象 | 原因与解决 |
|---|---|
| 报 `Ignored build scripts` | pnpm 11 拦截构建脚本。跑 `pnpm approve-builds --all`(一键脚本已自动处理)。 |
| 报 `minimum release age` / 版本不足 24h | 装的版本发布不足 24 小时。等 24h 或重跑一次(pnpm 会自动补 `minimumReleaseAgeExclude`);一键脚本已自动处理。 |
| 报 `Ignored build scripts` | pnpm 11 拦截构建脚本。在 profile 目录(`~/.dsh/profiles/web`)跑 `pnpm approve-builds --all`。 |
| 报 `minimum release age` / 版本不足 24h | 装的版本发布不足 24 小时。等 24h 或重跑一次(pnpm 会自动补 `minimumReleaseAgeExclude`)。 |
| 报「找不到 profile 目录」 | 先跑一次 `dsh web`,让它初始化 `~/.dsh/profiles/web`。 |
| 页面出现**两个侧边栏** | 双挂载:`~/.dsh/profiles/web/cordis.patch.yml` 还留着旧的手动挂载行,删掉那段 `- insert: ... better-sidebar ...`(一键脚本会自动清)。 |
| 页面出现**两个侧边栏** | 双挂载。旧的手动挂载行:`~/.dsh/profiles/web/cordis.patch.yml` 还留着 `- insert: ... better-sidebar ...`,删掉那段(同 id 重复挂载 loader 会直接报 `duplicate loader entry id`)。聚合包(如 `@linxin666/dsh-web-ui-all`)以**不同 id** 挂载本包时,0.13.x 起插件自身 bundle patch 会自动退让(检测到已有启用中的同包名挂载就不挂自己),无需手动处理;若仍双挂载,先确认聚合包的 bundle 顺序在 `dsh-better-sidebar` 之前。 |
| Windows 下终端无法使用 | `node-pty` 依赖预编译二进制;若当前 Node 版本没有对应产物,需装编译工具链(VS Build Tools)。主流 Node 版本一般已有预编译。 |
| Windows 没有 bash / curl | 直接用 PowerShell 一键命令;或安装 Git Bash / WSL 再跑 bash 命令。 |
| 终端提示「node-pty 加载失败」 | `node-pty` 安装缺失/损坏(如 pnpm 拦截了构建脚本)。终端横幅会给出修复命令:复制到 DSH 所在环境的终端/cmd 执行(在 `~/.dsh/profiles/web` 下 `pnpm approve-builds --all && pnpm rebuild node-pty`),完成后重启 DSH 并点重试。插件与 DSH 核心使用同一 `node-pty@^1.1.0`,修复后两者同步恢复。 |
| 提示 `dsh: command not found` | 先安装 DSH;或直接用 `npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar@latest`。 |
</details>
@@ -166,15 +144,20 @@ dsh plugin --profile web add dsh-better-sidebar
1. git clone https://github.com/omdsh-dev/DSH-better-sidebar.git ~/Code/DSH-better-sidebar
cd ~/Code/DSH-better-sidebar && pnpm install && pnpm build
2. ~/.dsh/profiles/web/package.json 的 dependencies 写 "dsh-better-sidebar": "link:<克隆目录绝对路径>"
3. ~/.dsh/profiles/web/cordis.patch.yml 追加挂载行:
3. ~/.dsh/profiles/web/cordis.patch.yml 追加挂载行(需要指定终端 shell 时,在行内加 `config.shell`;`config.shellArgs` 可带参启动,非空时替换默认的 `-l`。不填则自动解析 `$SHELL` / 登录 shell / powershell.exe):
- insert:
- id: better-sidebar
name: 'dsh-better-sidebar'
config:
shell: /bin/zsh
shellArgs:
- --noprofile
- --no-rc
4. 在 ~/.dsh/profiles/web 执行 pnpm install
5. 硬刷新浏览器(Cmd/Ctrl+Shift+R)即可看到效果(client 改动无需重启 DSH;host 半改动才需重启)
```
更新:`git pull && pnpm install && pnpm build` → 硬刷新浏览器即可(client 改动热加载生效,无需重启 DSH;host 半改动才需重启)。切回 npm 通道时,把依赖改回 `"dsh-better-sidebar": "^0.12.1"` 再 `pnpm install`。
更新:`git pull && pnpm install && pnpm build` → 硬刷新浏览器即可(client 改动热加载生效,无需重启 DSH;host 半改动才需重启)。切回 npm 通道时,把依赖改回 `"dsh-better-sidebar": "^0.13.0"` 再 `pnpm install`。
</details>
@@ -208,7 +191,7 @@ dsh registry enable dsh-external/dsh-better-sidebar
## 🔌 服务化:注册 tab 与文件预览器
从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 7 tab + 9 viewer 也走同一服务,吃自己的狗粮):
从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 7 tab + 6 viewer 亦通过同一服务注册):
```ts
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
@@ -235,7 +218,7 @@ v0.12.1 补齐基座能力(完整类型导出、能力探测、状态订阅、
## 🛠️ 开发与构建
```sh
pnpm install # @deepseek-ai/* 已发布到 npm(^0.1.0-rc.6),直接解析、无需令牌
pnpm install # @deepseek-ai/* 已发布到 npm(^0.1.0-rc.8),直接解析、无需令牌
pnpm typecheck # tsc --noEmit
pnpm build # → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types
pnpm test # vitest(含 manifest 一致性守卫,需先 build)
@@ -268,3 +251,4 @@ Windows / Linux / macOS 三平台适配(macOS 日常验证;其余经单元
- [dsh-tianshu-tui](https://github.com/huiliyi37/dsh-tianshu-tui):DeepSeek Harness 交互式终端 UI 插件(渲染核心由自研 harness agent Tianshu-Tui 演进而来),在官方基础上增加 TDD 与证据门等工作流
- [dsh-TUI](https://github.com/ccch1mneyyy/dsh-TUI):Claude Code 风格全屏交互终端插件——像素鲸鱼顶栏、实时工作状态行、思考流式展开、双击 Esc 回滚、上下文进度条 + TPS 仪表,npm 一键安装
- [dshfind 插件超市](https://dshfind.com/zh/plugins):三方插件市场——GitHub topic `dsh-plugin` 下的公开仓库清单,每日同步 star、贡献者与增长数据
- [DeepSeek Harness Desktop](https://github.com/anywhere-labs/deepseek-harness-desktop):为 DeepSeek Harness 生态打造的现代化桌面端——无需配置 Node.js 或执行命令即可启动和管理本地 Harness 服务;[官网](https://www.dshdesktop.cn)
+137 -122
View File
@@ -1,145 +1,153 @@
# dsh-better-sidebar
<!-- Hero (HTML layout) -->
<!-- Hero -->
<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>
<b style="font-size: 1.15em;">A service-oriented sidebar framework, and a complete workbench out of the box</b><br /><br />
<a href="https://opensource.org/licenses/MIT"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-yellow.svg" /></a>
<a href="https://dshfind.com/en/plugins/omdsh-dev/DSH-better-sidebar?ref=badge"><img alt="dshfind" src="https://dshfind.com/api/badge/omdsh-dev/DSH-better-sidebar?lang=en" /></a><br /><br />
<img alt="File management" src="https://img.shields.io/badge/-File%20management-4d6bfe" /> <img alt="Edit &amp; preview" src="https://img.shields.io/badge/-Edit%20%26%20preview-4d6bfe" /> <img alt="Embedded browser" src="https://img.shields.io/badge/-Embedded%20browser-4d6bfe" /> <img alt="Real terminal" src="https://img.shields.io/badge/-Real%20terminal-4d6bfe" /> <img alt="Git panel" src="https://img.shields.io/badge/-Git%20panel-4d6bfe" /> <img alt="Background tasks" src="https://img.shields.io/badge/-Background%20tasks-4d6bfe" /> <img alt="Plugin integration" src="https://img.shields.io/badge/-Plugin%20integration-4d6bfe" /><br /><br />
<b>A dual workbench (right sidebar + bottom panel)</b> that opens its <code>ctx.betterSidebar</code> service to every plugin —<br />
register new sidebar pages and file viewers via <code>registerTab</code> / <code>registerFileViewer</code>.
</div>
<div align="center">
🌏 <a href="./README.md">中文</a> · <a href="./README_EN.md"><b>English</b></a>
</div>
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
<small>v0.12.1</small>
| Feature | Description | Screenshot |
|---|---|---|
| 🔌 Service API base | Complete type exports + `version`/`features` capability detection, state subscription (`getSnapshot`/`subscribeState`), tab `badge`, `onOpen`/`onActivate`/`onClose` lifecycle callbacks, `updateTab`/`activateTab`/`openFile`, targeted open, `meta` persisted across reloads, plugin-owned settings (`pluginToggles`/`render`) | |
| ➕ Add Plugins | Recommended plugin catalog in settings + one-click copy install command; built-in Office preview moved to the recommended plugin | |
| 🖱️ Tab-bar scroll | Mouse-wheel horizontal scrolling on the tab bar | |
| 🐛 Fixes | Remote access 403 (trust fence now uses `trustedHosts`), sidebar crash [#31](https://github.com/omdsh-dev/DSH-better-sidebar/issues/31), Windows HTML-preview drive-path | |
<div align="center">
<video src="https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86" muted autoplay loop playsinline controls width="100%"></video>
<img alt="dsh-better-sidebar workbench" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" />
</div>
## ✨ Features
- **🗂️ File 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 (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) and the [external plugin guide](./docs/external-plugin-guide.md))
- **➕ Add Plugins**: dashed cards at the end of both settings grids ("Sidebar content" / "File viewers") open tab / previewer plugin modals: the extension point declaration, a "Browse more plugins on GitHub" BUTTON, and the recommended plugin catalog ("Open" jumps to the repo, "Copy" puts the install command on the clipboard); no terminal is opened, nothing can fail — never blocking startup or the sidebar
- **🌏 i18n**: UI text follows DSH's language setting (zh/en) with live switching, no refresh needed
- **🗂️ File Workbench**: file explorer (lazy-loading tree; symlinks show their target kind — directory links expand, dangling links flagged) + CodeMirror editor; inline preview for images / Markdown (incl. Mermaid diagrams, strict-mode safe rendering + click-to-zoom) / HTML / PDF / Office
- **🌐 Embedded Browser**: multiple web tabs with back / forward / refresh; content runs in a sandboxed iframe; external links are routed by protocol by default — HTTP opens in the sidebar, HTTPS goes to the system browser (both adjustable in settings)
- **💻 Real Terminal**: xterm.js + node-pty real shell, reconnect with transcript replay; optionally injects `terminal_*` tools for the model
- **🌿 Git Panel**: real diff + VSCode-style diff tabs, history, right-click to stage / commit / revert
- **🧩 Background Tasks**: agent topology + background tasks (exit codes / live output / force-kill)
- **🪟 Dual Workbench**: right sidebar + bottom panel; drag tabs to split / merge panes (cross-panel), mobile auto-merges into a full-width drawer
- **🔁 Session Isolation**: layout / tabs / panels persisted per session, stale state auto-purged
- **⚙️ Declarative Settings**: per-item toggles in the "Side Cards" settings section, secondary settings via the gear dialog
- **⚡ On-demand Loading**: only ~325KB core at startup; heavy deps (terminal / editor / mermaid diagrams) load on demand ([design](docs/plans/2026-08-12-lazy-chunks-design.md))
- **🌏 i18n**: UI text follows DSH's language (zh / en) with live switching
> 🔌 **Core principle**: service-first — the 7 built-in tabs + 6 viewers register through the same `ctx.betterSidebar` API as third-party plugins, with fully equal capabilities; anything the ecosystem can provide better is delegated to ecosystem plugins. See the "🔌 Service" section below and the [external plugin guide](./docs/external-plugin-guide.md).
## 🆕 Recent Updates
<div align="center">
<a href="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0"><img width="45%" alt="Service API base screenshot" src="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0" /></a>
<a href="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e"><img width="45%" alt="Add Plugins screenshot" src="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e" /></a>
</div>
### v0.14.0
> ⚠️ **This release adapts to DSH 0.1.0-rc.8**: every `@deepseek-ai/*` peer/devDependency is raised to `^0.1.0-rc.8` (transitives included — zero rc.7 leftovers in the lockfile), `cordis` to `^4.0.0-rc.8`, and the CI mount lane pins `@deepseek-ai/dsh@0.1.0-rc.8`. **DSH environments on rc.7 or earlier can no longer resolve this release's dependencies — upgrade DSH first.** All changes since v0.13.1:
**✨ New features**
- 🖼️ **Unified panel-host injection refactor** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): panels/toggle clusters moved into a `[data-dsh-panel-host]` fixed containing block (`fixed inset-0 z-40`), immune to desktop-shell intermediate transforms hijacking `fixed`; mount self-check (page-level transform → `data-dsh-panel-host-degraded` degraded sync, judged on uncorrected geometry, exits only when the ancestor transform is gone); push anchor switched to `#root [data-dsh-frame] > [data-pane="conversation"]` + `#root` calc width against desktop-shell additive overflow; chunk revalidation on activation (HEAD+ETag keeps unchanged chunks, 5s timeout fails open); desktop signal auto-detection (win32 advanced title-bar compat 32px avoidance, manual pref overrides); `visualViewport` keyboard inset + `env(safe-area-inset-*)` mobile adaptation
- 📂 **Separate file windows by default** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): `editorExplorer` now defaults to **separate** — tree clicks / file opens create a new tab per path and the path-less window is a pure file manager; merged mode stays available as an opt-in
- 🖥️ **Terminal shell / shellArgs configurable from the settings page** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): the terminal card's gear popup gains "Shell path" and "Shell arguments" rows (previously yaml-only via `cordis.patch.yml`) — saved values take effect immediately for terminals opened afterwards (UI terminals and model `terminal_create` alike); empty keeps the existing yaml → `$SHELL` / login shell / `powershell.exe` resolution order
- 🏷️ **Version badge on the settings page** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): the side-card settings section now opens with a `DSH-better-sidebar v0.14.0` identity badge (version synced with the service instance, test-guarded)
- 🔍 **Add-plugin catalog: search / grouping / independent scroll** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): built for a growing plugin ecosystem — a live search box (filters by name / id / description), optional `category` grouping for entries, and an independently scrolling list (the modal no longer grows unbounded with catalog size)
**🐛 Fixes**
- 🔧 **Adapted to DSH 0.1.0-rc.8** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): all 13 `@deepseek-ai/*` peer/devDependencies raised to `^0.1.0-rc.8` (transitives included; zero rc.7 leftovers in the lockfile), `cordis` to `^4.0.0-rc.8`; `dsh-client-web-react` / `dsh-client-schema-form` removed (no rc.8 publish, no longer in the shell module table, zero plugin references); CI mount lane pins `@deepseek-ai/dsh@0.1.0-rc.8`; pnpm 11.8 supply-chain verification adapted
- 🧩 **rc.8 module-system migration** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): rc.8 no longer exposes the `window.__DSH_MODULES__` page global (it moved to the `ctx.modules` service), which broke every lazy chunk's externals resolution — the client now injects the `modules` service and shares it with chunk-bundle copies through a plugin-owned global (terminal / editor / Mermaid on-demand loading restored)
- 🧩 **Chunk revalidation barrier hardening** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): HEAD revalidation gains a 5s timeout (fails open on a stuck route so the barrier can never wedge lazy loads); `resetChunks` clears a pending revalidation barrier
- 🖱️ **Drag robustness** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): fast releases (browsers merge/lose pointermove bursts) commit the last known dragged position instead of rolling back; `pointercancel` / lost-capture interruptions keep the drag result too; the center column is re-measured right after commit (no mid-frame bottom-panel width jump); HMR re-activation re-locates the center column via an `<html>` style observer plus a retry when the bottom panel opens (fixes the blank bottom panel / shifted input bar after a hot reload)
### v0.13.1### v0.13.1
**✨ New features**
- 📊 **Safe Mermaid rendering in the Markdown preview** ([#164](https://github.com/omdsh-dev/DSH-better-sidebar/pull/164)): when a previewed md file contains mermaid fences, a `client-mermaid.js` chunk (~7MB) is served on demand (zero load without mermaid); defense-in-depth rendering — `securityLevel: 'strict'` + `htmlLabels: false` (node labels use real SVG `<text>`) + a second sanitize pass before SVG injection (foreignObject/script/foreign HTML elements removed, `@*`/`on*`/`href` attributes stripped); click a diagram to zoom in a modal overlay (wheel zoom centered on the cursor, drag pan, toolbar & shortcuts), re-renders with light/dark theme, falls back to the raw code block on parse failure
- 🖥️ **Configurable terminal shell & shellArgs** ([#125](https://github.com/omdsh-dev/DSH-better-sidebar/pull/125)): `cordis.patch.yml` `better-sidebar.config` can set `shell` / `shellArgs` (a non-empty `shellArgs` fully replaces the defaults; unset keeps the previous auto-resolution of `$SHELL` / login shell / `powershell.exe`), applied to both UI terminals and agent terminals (`terminal_create`); terminal tab titles now show the shell name (bash/zsh/powershell) and internal tab ids use UUIDs so the same shell can open multiple terminals
**🐛 Fixes**
- 🔗 **Aggregate double-mount auto-yield** ([#200](https://github.com/omdsh-dev/DSH-better-sidebar/pull/200)): when an aggregate package (e.g. dsh-web-ui-all) mounts the same package under its own entry id, the guard expression in `cordis.patch.yml` disables the plugin's own `better-sidebar` row so `/sidebar/api` is no longer registered twice (`duplicate prefix route` crashing the whole plugin tree / `dsh web`); standalone installs behave as before
- 🔧 **Peer dependencies aligned to DSH 0.1.0-rc.7** ([#207](https://github.com/omdsh-dev/DSH-better-sidebar/pull/207), fixes [#206](https://github.com/omdsh-dev/DSH-better-sidebar/issues/206)): all `@deepseek-ai/*` peer/devDependencies raised from `^0.1.0-rc.6` to `^0.1.0-rc.7`, and the CI mount lane pins the same version — eliminating the mixed rc.6/rc.7 dependency tree (after the main framework moved to rc.7) that surfaced as `agent-presets: refusing to compose an unscoped context` when picking a model / sending a message
### v0.13.0
**✨ New features**
- 📁 **Files window merged with the explorer** ([#151](https://github.com/omdsh-dev/DSH-better-sidebar/pull/151)): new `editorExplorer` setting (editor card gear) — file tabs gain a path-input header plus a toggleable right-docked file tree (per-tab open/width memory, drag-resize 160–480px from the left edge, global filename search via the host `fs.search` route with a hard budget, skipping `.git` and symlink dirs); in separate mode (default) tree clicks / Enter in the path input open each file **in its own new tab**, merged mode switches the current tab **in place**; fresh sessions seed an empty Files window instead of the explorer tab, and a path-less window is a bare file manager in separate mode / a chrome'd empty file window in merged mode; the tree context menu offers "Open in new tab" and "Open to the side" (split)
- 🎛️ **Select rows for declarative settings** ([#151](https://github.com/omdsh-dev/DSH-better-sidebar/pull/151)): settings rows gain `type: 'select'` (`options` with value/title/desc/icon, `multi` stores the picked values as an array); options with icons render big-icon option cards and keep the icon in the closed anchor; `editorExplorer` became an iconed select (merged vs separate); the capability list gained `settingSelect`
- 🔀 **Mutual exclusion with the dsh-web-ui family right panel** ([#181](https://github.com/omdsh-dev/DSH-better-sidebar/pull/181)): reads the `aionui-panel` settings namespace's provider choice — when "Use aionui-panel" is selected, the whole better-sidebar (right sidebar / bottom panel / floating entry / all takeovers) does not mount; with DSH-better-sidebar (or no aionui installed) it behaves as before. Takes effect live after a settings save (settings-document push), no reload needed
**📝 Other**
- Install / update commands unified to `dsh-better-sidebar@latest` (bilingual README sync)
### v0.12.3
**✨ New features**
- 🎨 **Skin compatibility (token-driven)**: fully consumes DSH design tokens and follows the dsh-web-ui skin center's 10 skins automatically; terminal/editor surfaces fall back to opaque backgrounds under transparent/translucent-glass token values so text never scrolls over the skin art ([#110](https://github.com/omdsh-dev/DSH-better-sidebar/pull/110), fixes #106 #105 #90 #60, also #52 #57 #92)
- 🗂️ **Unified path handling**: UNC / symlink classification (directory symlinks expandable, broken links highlighted) + HTML-route platform guards ([#134](https://github.com/omdsh-dev/DSH-better-sidebar/pull/134), #65 #67 #43 #79 #115)
- 🖥️ **Configurable terminal shell**: custom shell setting with Windows pwsh auto-probe ([#95](https://github.com/omdsh-dev/DSH-better-sidebar/pull/95))
- 📝 **Editor languages**: C# / Kotlin / Swift syntax highlighting ([#120](https://github.com/omdsh-dev/DSH-better-sidebar/pull/120))
- 🧭 **Settings nav icon**: settings-page navigation icon and layout polish ([#114](https://github.com/omdsh-dev/DSH-better-sidebar/pull/114))
- ➕ **Recommended-plugin catalog**: added `dsh-git-remotes` — Git Remotes tab (branches/upstream/ahead-behind, fetch with prune, ff-only pull, confirm-before-push; does not replace the built-in stage/commit tab) ([#91](https://github.com/omdsh-dev/DSH-better-sidebar/pull/91)); and `dsh-video-preview` — inline video preview (.mp4/.webm/.mov/.mkv/.avi etc.) backed by a /video host route with HTTP Range (206) scrubbing, not capped by the 20MB mediaLimit ([#126](https://github.com/omdsh-dev/DSH-better-sidebar/pull/126))
**🐛 Fixes**
- 🔧 **xterm migration**: deprecated xterm dependency migrated to `@xterm/xterm` (Closes [#122](https://github.com/omdsh-dev/DSH-better-sidebar/issues/122), [#128](https://github.com/omdsh-dev/DSH-better-sidebar/pull/128))
- 📝 **Markdown editor**: selection-to-conversation popup restored ([#24](https://github.com/omdsh-dev/DSH-better-sidebar/pull/24))
- 🐛 **node-pty load failure no longer crashes the server** ([#140](https://github.com/omdsh-dev/DSH-better-sidebar/issues/140)): the host half now lazy-loads node-pty — when it is missing the plugin still mounts, the terminal shows a repair banner (copyable command + Retry button), and agent terminal tools are skipped
- 🧪 Test engineering: unit spec split (#141) + flaky smoke cleanup fix
**🚀 Engineering**
- npm publishing wired to GitHub Releases (Trusted Publishing, provenance-attached tarballs); tagging a release publishes automatically ([#148](https://github.com/omdsh-dev/DSH-better-sidebar/pull/148))
### v0.12.2
- 📐 **Position compat mode**: new "Position compatibility mode" setting: reserves top space for the native Windows title bar (top-right) so the sidebar buttons and content sit below it (off by default); the shift distance is customizable in the gear popup (0–120px)
- 🔌 **Service API base**: complete type exports + `version`/`features` capability detection, state subscription (`getSnapshot`/`subscribeState`), tab `badge`, `onOpen`/`onActivate`/`onClose` lifecycle callbacks, `updateTab`/`activateTab`/`openFile`, targeted open, `meta` persisted across reloads, plugin-owned settings (`pluginToggles`/`render`), external-link claim (`urlTarget`)
- ➕ **Add Plugins**: recommended plugin catalog in settings + one-click copy install command; built-in Office preview moved to the recommended plugin
- 🖱️ **Tab-bar scroll**: mouse-wheel horizontal scrolling on the tab bar
- 🐛 **Fixes**: remote access 403 (trust fence now uses `trustedHosts`), sidebar crash [#31](https://github.com/omdsh-dev/DSH-better-sidebar/issues/31), Windows HTML-preview drive-path
### v0.12.1
- 🔌 **Service API base**: complete type exports + `version`/`features` capability detection, state subscription (`getSnapshot`/`subscribeState`), tab `badge`, `onOpen`/`onActivate`/`onClose` lifecycle callbacks, `updateTab`/`activateTab`/`openFile`, targeted open, `meta` persisted across reloads, plugin-owned settings (`pluginToggles`/`render`)
- ➕ **Add Plugins**: recommended plugin catalog in settings + one-click copy install command; built-in Office preview moved to the recommended plugin
- 🖱️ **Tab-bar scroll**: mouse-wheel horizontal scrolling on the tab bar
- 🐛 **Fixes**: remote access 403 (trust fence now uses `trustedHosts`), sidebar crash [#31](https://github.com/omdsh-dev/DSH-better-sidebar/issues/31), Windows HTML-preview drive-path
> 📝 Note: the 0.12.0 final could not be reused (npm reported the version as already published), so the public release became 0.12.1 — both carry identical content.
### v0.12.0
- 🔌 **Service API base**: complete type exports + `version`/`features` capability detection, state subscription, tab badges, lifecycle callbacks, targeted open, `meta` persisted across reloads, plugin-owned settings
- ➕ **Add Plugins**: recommended plugin catalog in settings + one-click copy install command; built-in Office preview moved to the recommended plugin
- 🖱️ **Tab-bar scroll**: mouse-wheel horizontal scrolling on the tab bar
- 🐛 **Fixes**: remote access 403 (trust fence now uses `trustedHosts`), sidebar crash [#31](https://github.com/omdsh-dev/DSH-better-sidebar/issues/31), Windows HTML-preview drive-path
## 🚀 Installation
**Prerequisites**: DSH installed (`dsh web` boots), Node.js ≥ 20, pnpm ≥ 10.
**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
dsh plugin --profile web add dsh-better-sidebar@latest
```
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.12.1 --restart
# Windows PowerShell
& ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.12.1 -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.12.1)
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.12.1` 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
dsh plugin --profile web add dsh-better-sidebar@latest
```
or re-run the one-click script; or bump the version in `~/.dsh/profiles/web/package.json` (e.g. `"^0.12.1"`) and run `pnpm install`. Then hard-refresh the browser (Cmd/Ctrl+Shift+R) — client changes do not need a DSH restart.
or bump the version in `~/.dsh/profiles/web/package.json` (e.g. `"^0.13.0"`) and run `pnpm install`. Then hard-refresh the browser (Cmd/Ctrl+Shift+R) — client changes do not need a DSH restart.
</details>
@@ -148,12 +156,13 @@ or re-run the one-click script; or bump the version in `~/.dsh/profiles/web/pack
| 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. |
| `Ignored build scripts` | pnpm 11 blocked build scripts. Run `pnpm approve-builds --all` in the profile directory (`~/.dsh/profiles/web`). |
| `minimum release age` / version `< 24h` | The release is younger than 24 hours. Wait, or re-run once (pnpm auto-adds `minimumReleaseAgeExclude`). |
| "profile directory not found" | Run `dsh web` once so it initializes `~/.dsh/profiles/web`. |
| Two sidebars on the page | Double-mount: `~/.dsh/profiles/web/cordis.patch.yml` still has the old hand-written `- insert: ... better-sidebar ...` line — delete it (the one-click script cleans it). |
| Two sidebars on the page | Double-mount. Old hand-written line: `~/.dsh/profiles/web/cordis.patch.yml` still has `- insert: ... better-sidebar ...` — delete it (a same-id duplicate mount makes the loader fail loudly with `duplicate loader entry id`). When an aggregate bundle (e.g. `@linxin666/dsh-web-ui-all`) mounts this package under a **different** id, the plugin's own bundle patch backs off automatically since 0.13.x (it detects an already-enabled mount of the same package name and does not mount itself) — no manual fix needed; if it still double-mounts, make sure the aggregate bundle precedes `dsh-better-sidebar` in `dsh.profile.bundles`. |
| Terminal fails on Windows | `node-pty` relies on prebuilt binaries; if none match your Node version, install a build toolchain (VS Build Tools). Mainstream Node versions are usually covered. |
| No bash / curl on Windows | Use the PowerShell one-click command, or install Git Bash / WSL and run the bash commands. |
| Terminal shows "node-pty failed to load" | The `node-pty` install is missing or broken (e.g. pnpm skipped its build script). The terminal banner shows a repair command — copy it into a terminal/cmd on the DSH machine and run it (in `~/.dsh/profiles/web`: `pnpm approve-builds --all && pnpm rebuild node-pty`), then restart DSH and click Retry. The plugin and DSH core share the same `node-pty@^1.1.0`, so the repair restores both. |
| `dsh: command not found` | Install DSH first, or run `npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar@latest`. |
</details>
@@ -166,15 +175,20 @@ To debug local changes or track the dev branch, point the dependency at a local
1. git clone https://github.com/omdsh-dev/DSH-better-sidebar.git ~/Code/DSH-better-sidebar
cd ~/Code/DSH-better-sidebar && pnpm install && pnpm build
2. In ~/.dsh/profiles/web/package.json dependencies write "dsh-better-sidebar": "link:<absolute path of the clone>"
3. Append this mount line to ~/.dsh/profiles/web/cordis.patch.yml:
3. Append this mount line to ~/.dsh/profiles/web/cordis.patch.yml (to pick the terminal shell, add `config.shell`; `config.shellArgs` starts it with explicit args — when non-empty they replace the default `-l`. When omitted the host resolves `$SHELL` / the login shell / powershell.exe):
- insert:
- id: better-sidebar
name: 'dsh-better-sidebar'
config:
shell: /bin/zsh
shellArgs:
- --noprofile
- --no-rc
4. Run pnpm install in ~/.dsh/profiles/web
5. Restart DSH and hard-refresh
```
Update: `git pull && pnpm install && pnpm build` → just hard-refresh the browser (client changes hot-reload; only host-half changes need a DSH restart). To switch back to the npm channel, restore `"dsh-better-sidebar": "^0.12.1"` and re-run `pnpm install`.
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.13.0"` and re-run `pnpm install`.
</details>
@@ -208,7 +222,7 @@ Update: `git pull && pnpm install && pnpm build` → `node scripts/package-regis
## 🔌 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):
Since v0.4.0 the plugin exposes the `ctx.betterSidebar` service — other plugins can register sidebar pages and file viewers (the 7 built-in tabs + 6 viewers register through the same service):
```ts
import type {} from 'dsh-better-sidebar' // triggers the ctx.betterSidebar type merge
@@ -235,7 +249,7 @@ The dashed cards at the end of the "Sidebar content" / "File viewers" grids in t
## 🛠️ Development & Build
```sh
pnpm install # @deepseek-ai/* resolved from npm (^0.1.0-rc.6, published) — no token needed
pnpm install # @deepseek-ai/* resolved from npm (^0.1.0-rc.8, 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)
@@ -268,3 +282,4 @@ Windows / Linux / macOS (macOS validated daily; the rest covered by unit tests);
- [dsh-tianshu-tui](https://github.com/huiliyi37/dsh-tianshu-tui): an interactive terminal UI plugin for DeepSeek Harness (its rendering core evolved from the self-developed harness agent Tianshu-Tui), adding TDD and evidence-gate workflows on top of the official harness
- [dsh-TUI](https://github.com/ccch1mneyyy/dsh-TUI): a Claude Code-style fullscreen interactive TUI plugin — pixel-whale top bar, live working-status row, streaming thought expansion, double-Esc rollback, context progress bar + TPS meter; one-command npm install
- [dshfind Plugin Market](https://dshfind.com/zh/plugins): a third-party plugin marketplace — a listing of public repos under the GitHub topic `dsh-plugin`, with stars, contributors and growth data synced daily
- [DeepSeek Harness Desktop](https://github.com/anywhere-labs/deepseek-harness-desktop): a modern desktop client for the DeepSeek Harness ecosystem — start and manage a local Harness service without configuring Node.js or running commands; [official site](https://www.dshdesktop.cn)
+30
View File
@@ -14,6 +14,36 @@
# If the profile still carries the old manual mount line in its own
# cordis.patch.yml, remove it before switching to the bundle channel to
# avoid double-mounting (two Node halves, two sidebars).
#
# To choose the terminal shell, add `config.shell` to the inserted plugin
# row (or to the profile's manual cordis.patch.yml mount line). When omitted,
# the host keeps resolving $SHELL / the login shell / powershell.exe.
# `config.shellArgs` optionally supplies explicit startup arguments; when
# non-empty they replace the automatic POSIX `-l` login flag:
#
# - insert:
# - id: better-sidebar
# name: 'dsh-better-sidebar'
# config:
# shell: /bin/zsh
# shellArgs:
# - --noprofile
# - --no-rc
#
# Aggregate double-mount guard: aggregate bundles (e.g. @linxin666/dsh-web-ui-all)
# may already mount this package under their own entry id (e.g.
# `web-ui-better-sidebar`). Two mounts register /sidebar/api twice and fail the
# whole plugin tree at boot ("duplicate prefix route"). The `!!js` disabled
# expression backs THIS row off when another *enabled* entry already mounts
# `dsh-better-sidebar`; the aggregate instance then owns the sidebar. The
# loader evaluates it when this row is processed (entry-list order, only rows
# before this one are visible), so the aggregate bundle must precede this one
# in `dsh.profile.bundles` — the standard install order, since `dsh plugin add`
# appends new bundles last. The reverse order (this bundle before the
# aggregate) is a known limitation: the guard cannot see rows that come after
# it, and no runtime singleton guard is added. Same-id manual duplicates still
# fail loudly at the loader as before.
- insert:
- id: better-sidebar
name: 'dsh-better-sidebar'
disabled: !!js "[...ctx.loader.entries()].some((e) => e.options.name === 'dsh-better-sidebar' && e.options.id !== 'better-sidebar' && !e.disabled)"
+5 -3
View File
@@ -40,7 +40,7 @@ import type {} from 'dsh-better-sidebar' // 触发 declare module 'cordis' 类
{
"name": "my-plugin",
"peerDependencies": {
"cordis": "^4.0.0-rc.7",
"cordis": "^4.0.0-rc.8",
"dsh-better-sidebar": "workspace:*"
},
"peerDependenciesMeta": {
@@ -480,7 +480,7 @@ const { value } = await res.json() // 错误时 { ok: false, error: { code, me
| 方法 | 说明 |
|---|---|
| `session.cwd` | 会话权威 cwd(`{ cwd, root, parent }`) |
| `fs.tree` | 目录列表(`{ path, entries: FsEntry[], truncated }`) |
| `fs.tree` | 目录列表(`{ path, entries: FsEntry[], truncated }`;FsEntry 含 `isSymlink`/`broken`,目录软链接的 `isDir` 按目标类型) |
| `fs.read` | 读文件:文本返回 `{ kind: 'text', content, truncated }`;二进制返回 `{ kind: 'binary', size, truncated, head }`(head = base64 前 4KB) |
| `fs.write` | 原子写文件 |
| `git.status` / `git.diff` / `git.log` 等 | 全套 Git 只读 + 写操作 |
@@ -690,7 +690,7 @@ ctx.effect(() =>
"./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }
},
"peerDependencies": {
"cordis": "^4.0.0-rc.7",
"cordis": "^4.0.0-rc.8",
"dsh-better-sidebar": "workspace:*",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"react": "^18.2.0"
@@ -783,4 +783,6 @@ better-sidebar 的内置 tab 和 viewer 就是参考实现("吃狗粮"):
- **类型处理**:未 value-import `dsh-better-sidebar`,构建零耦合;与 §2 的 `import type {}` 方案可互换;
- **实测**:v0.3.0 起,真实 web profile 验证通过。
通过 `ctx.betterSidebar` 的三方插件[dsh-sidebar-qa](https://github.com/ChenRuoT/dsh-sidebar-qa) —— 基于 better-sidebar 的划选提问。tab分页: 对话划选 → 右侧面板提问 → 同工作区独立追问会话(❓追问·主题):快速无思考模型压缩主对话上下文后与引文一起注入,不打断主对话;追问可嵌套、可继续、可归档
更多插件接入后欢迎在此登记(一句话 + 链接)。
@@ -260,7 +260,9 @@ if (officeKind === 'docx' || officeKind === 'xlsx') {
- `xlsx`(SheetJS,~400KB,xlsx 导入)
- `@univerjs/core` + `@univerjs/sheets` + `@univerjs/sheets-ui` + `@univerjs/sheets-formula` + `@univerjs/sheets-conditional-formatting` + `@univerjs/sheets-chart` + `@univerjs/docs-ui` + `@univerjs/design` + `@univerjs/engine-render`(~5MB 合计)
**不放 `peerDependencies`**:这些是插件自有运行时依赖,不由 web profile 提供(对比现有 `node-pty`/`xterm`/`CodeMirror` 也在 `dependencies`)。
**不放 `peerDependencies`**:这些是插件自有运行时依赖,不由 web profile 提供(对比现有 `node-pty`/`CodeMirror` 也在 `dependencies`)。
> **实施偏差记录(#122,2026-08)**:上文"`xterm` 也在 `dependencies`"已过时——`xterm` 与 `@xterm/addon-fit` 自 v0.13.0 起从 `dependencies` 迁至 `devDependencies`:二者始终内联进 `lib/client-terminal.js`(tsdown `noExternal` 全内联 + CSS 构建期解析),运行时零依赖;同时代码与开发依赖已从已弃用的 `xterm` 迁移到官方新包名 `@xterm/xterm@^5.5.0`(addon-fit@0.10.0 的 peer 约束锁 `^5.0.0`,不用 6.x),消除用户安装时的 deprecated 与 missing-peer 警告。Office 预览若落地,其依赖按同一原则处理。
### 5.2 tsdown.config.ts 调整
@@ -110,6 +110,7 @@ URL 采用**路径编码**(无 query)——WHATSG URL 解析对纯路径相
- 设计初期考虑过 srcdoc + `<base>` 注入以实现"预览反映未保存草稿",因 srcdoc 无沙箱时继承父源的致命风险与 base 注入的脆弱性,改采 route-src(预览 = 已保存文件);相对资源经路径编码 URL 天然解析,无需内容改写。
- `normalizeBrowserUrl` 的 scheme 判别:朴素正则会把 `example.com:8080` 误判为 scheme,最终采用「`://` 完整 URL + 危险 scheme 黑名单 + 解析后协议兜底」三段式。
- `nextBrowser` 在 sanitize 中为宽松字段(缺失/畸形回退 1),避免旧持久化布局整体失效(与严格校验的 `nextTerminal` 不同)。
- **UNC 路径编码标记(PR #134 整合 #79/#115 时)**:`/sidebar/html` 对 `\\server\share\...` / `//server/share/...` 采用 sessionId 后的 `//` 标记,且解码统一重建为**前斜杠** `//server/share/...` 而非反斜杠——`node:path` 在 win32/POSIX 上分别解析为 `\\server\share\...` / `/server/share/...`,因此无需任何平台信号;原「400 双斜杠」约束改为「首个空段仅作 UNC 标记,其余空段仍 400」。
## 8.1 追加需求(2026-08-11,v0.5.0 内)
@@ -267,7 +267,7 @@ return <viewer.component {...props} />
```jsonc
{
"peerDependencies": {
"cordis": "^4.0.0-rc.7",
"cordis": "^4.0.0-rc.8",
"dsh-better-sidebar": "workspace:*"
},
"peerDependenciesMeta": {
@@ -46,6 +46,7 @@ chunk 脚本**不经过** `window.__ModuleLoader__` / `ClientModuleSystem.import
| `pptx` | `src/client/chunks/pptx.tsx` | `PptxView` + pptx-renderer | lib/client-pptx.js(~2.4MB) | 打开 .pptx |
| `terminal` | `src/client/chunks/terminal.tsx` | `TerminalView` + xterm + addon-fit + xterm.css | lib/client-terminal.js(~450KB) | 打开终端 tab |
| `editor` | `src/client/chunks/editor.tsx` | `TextEditor` + @codemirror/* + lang.ts + cm-themes | lib/client-editor.js(~1.6MB) | 打开 code/md/html 文件 |
| `mermaid` | `src/client/chunks/mermaid.tsx` | `MermaidBlocks` + mermaid + d3/dagre/cytoscape | lib/client-mermaid.js(~7MB) | 预览含 mermaid fence 的 md |
| 核心 | `src/client/index.tsx` | 其余全部 | lib/client.js(~325KB) | 启动 |
关键拆分:**`office-view.tsx` 拆为 `docx-view.tsx` + `xlsx-view.tsx` + `office-shared.tsx`**(共享 `LoadState`/`BinaryFallback`)。若不拆,docx chunk 会经共享文件静态拖入 `xlsx-to-univer` → Univer 14.5MB。原路径 `office-view.tsx` 保留为 re-export 垫片,兼容 `./src/*` 深导入。
@@ -46,6 +46,8 @@
- 插件侧不可消除:不改 DSH 源码、不 patch node-pty 依赖是仓库硬约束(见 AGENTS.md §0),且 `IPty.kill()` 是 node-pty 的唯一 kill 通道;
- 建议:保持现状(无功能影响),上游如提供无控制台规避选项再跟进;reporter 若在意日志噪音,可反馈 microsoft/node-pty。
> 交叉引用(issue #140):与本文档「仅刷日志」不同,**依赖缺失/损坏**(node-pty 安装不完整导致模块加载失败)已有插件侧降级处理——宿主半懒加载 node-pty,失败时终端 tab 显示可复制的修复命令(`scripts/install.sh --repair` / `install.ps1 -Repair`),详见 `src/pty-deps.ts` 与 `tests/pty-deps.spec.ts`。
## 4. 验证
- 全量测试:`pnpm test` → 34 文件 / 430 例全部通过(基线 425 + 同期 PR #43 的 html 盘符修复 1 例 + 本次新增 4 例);
@@ -0,0 +1,67 @@
# 皮肤兼容(令牌驱动):面板表面、终端透明度回退与独立 CSS 修复
**日期**:2026-08-15
**状态**:已实施(本文档含实施偏差记录)
**目标版本**:随发布 bump(本 PR 不改版本号、不更新 README——发布流程统一处理)
## 1. 目标
让 better-sidebar 与 **dsh-web-ui 的皮肤中心**兼容:用户切换皮肤时侧边栏自动跟随换肤,零每皮肤适配(KISS:不做 aionui 式"每款皮肤 remap 一套令牌"的重活)。附带修复同批 CSS 问题([#52](https://github.com/omdsh-dev/DSH-better-sidebar/issues/52) 弹出框遮挡、[#57](https://github.com/omdsh-dev/DSH-better-sidebar/issues/57) 前半图标尺寸、[#92](https://github.com/omdsh-dev/DSH-better-sidebar/issues/92) 拖拽回归防护),并覆盖 [#60](https://github.com/omdsh-dev/DSH-better-sidebar/issues/60)/[#105](https://github.com/omdsh-dev/DSH-better-sidebar/issues/105)/[#90](https://github.com/omdsh-dev/DSH-better-sidebar/issues/90) 的相关诉求。
## 2. 调研结论
dsh-web-ui(`zhu1090093659/dsh-web-ui`)的皮肤机制:
- 皮肤 = cordis 插件(`ui-skin-<id>`),皮肤中心通过 `cordis.patch.yml` 切换激活;皮肤插件 client half 在 `document.body` 上打 `data-dsh-<skin>` 属性并注入 CSS。
- **10 款皮肤全部覆盖两层令牌**:`--dsw-static-*` 基础色板 + `--dsw-alias-*` 语义层(`--dsw-alias-bg-layer-1`、`--dsw-alias-bg-base` 等做成半透明玻璃 rgba 0.16–0.7,`--dsw-specific-sidebar-fill` 也全部覆盖)。
- dsh-web-ui 自家右侧面板(aionui)用独立 `--aion-*` 令牌,每款皮肤单独 remap——**重**。
结论:better-sidebar 只要**消费 DSH 标准令牌**(alias 层),换肤就自动跟随,不需要任何每皮肤适配。此前围绕第三方皮肤插件(Aqua/deep-whale)设计的类名改名与 `data-bs-*` 语义钩子按 KISS 原则**全部回退**(见 §6)。
## 3. 设计
### 3.1 面板表面(`sidebar.module.css`)
```css
.panel { background: var(--dsw-alias-bg-layer-1); }
.bottomPanel { background: var(--dsw-alias-bg-layer-1); }
```
- 直接消费通用卡片表面令牌,**不新增自有令牌、不加属性钩子**。
- **绝不消费 `--dsw-specific-sidebar-fill`**:那是宿主左侧导航列专属令牌,皮肤系统按左导航语义覆盖它(dsh-web-ui 半透明玻璃/主题色、Aqua `transparent`),面板消费它会失去填充或与标签令牌冲突。
- stock 外观色阶变化仅 ±8 RGB(bluish-50→75 / 900→875),视觉无差;dsh-web-ui 换肤后面板获得与宿主一致的玻璃效果。
### 3.2 终端/编辑器透明度回退(`theme.ts` / `TerminalView.tsx`)
`effectiveTokenValue` 现在把以下值视为"未设置"(触发调用方 `||` 回退):
- 空串与 CSS 重置关键字(`transparent`/`initial`/`inherit`/`unset`);
- **alpha < 0.9 的半透明颜色**(dsh-web-ui 皮肤的 `--dsw-alias-bg-base` 是 rgba 0.16–0.7 玻璃)。
新增 `colorAlpha()` 解析 computed 颜色(rgb()/rgba()/hsl()/hsla() 逗号与空格语法、`#rgb/#rgba/#rrggbb/#rrggbbaa`);无法解析的格式(命名色、`color()`)视为不透明放行。阈值 0.9:≥ 0.9 的近不透明值(如皮肤作用域内 0.96 瓷器玻璃)放行,皮肤仍能控制终端表面。
### 3.3 独立修复(与皮肤兼容无关,保留)
- **z-index(#52)**:`.toggleCluster` 55→45、`.panel`/`.bottomPanel` 50→40;角手柄移入面板后为面板内局部 `z-index: 2`。依据:DSH 浮层栈为 100(Menu/HoverCard/Tooltip/PopupSelect/Modal=1000),30–99 无任何 DSH 元素。
- **角手柄几何 CSS 化**:移入右面板 DOM,`position: absolute; left: -6px; bottom: calc(var(--dsh-sidebar-height, 0px) + 6px)`——复用拖拽逐帧写入的布局变量,删除 `cornerRef` JS 内联坐标。
- **刷新图标统一 `size={14}`(#57 前半)**:Explorer/Git/Diff 的 `IconRefreshOutline16` 显式 14px,与 Subagent/Browser 一致。
- **拖拽逐帧回归 e2e(#92)**:`tests/e2e/drag-layout.e2e.ts` rAF 采样,断言拖拽期间 `data-dsh-sidebar-dragging` 存在、`#root` transition 为 none、会话列与面板边单调 1:1 跟随(实测通过)。拖条以 `cursor: col-resize` 语义定位(无属性钩子可依赖)。
## 4. 测试
- `tests/theme.spec.ts`:`colorAlpha`(hex/rgb/hsl/不可解析)与 `effectiveTokenValue`(transparent/关键词/半透明回退/0.96 放行/缺失)。
- `tests/e2e/mount.e2e.ts`:追加 `--dsh-sidebar-width` 布局变量随面板挂载生效的断言(令牌驱动契约的挂载级守护)。
- `tests/e2e/drag-layout.e2e.ts`:拖拽逐帧回归。
- 门禁:`pnpm typecheck && pnpm test && pnpm build && pnpm pack && pnpm test:mount`。
## 5. 文档
- AGENTS.md §8「皮肤兼容(令牌驱动)」:面板表面令牌、透明度阈值、z-index 层级、根锚点 `[data-dsh-better-sidebar]`、类名非契约。
- README 不在本批更新(发布时随版本条目更新)。
## 6. 实施偏差记录
- **首版契约(已回退)**:初版围绕第三方皮肤插件设计了 `--dsh-sidebar-surface`/`--dsh-resize-strip-offset` 自有令牌(review 后已删)、panel 家族类名 kebab-case 改名(breaking)与 `data-bs-*` 语义钩子。确认主目标是 dsh-web-ui 皮肤中心后,按 KISS 全部回退:令牌驱动下这些都不需要,改名还引入无谓 breaking。最终契约 = 消费标准令牌 + 透明度阈值,无自有概念。
- **角手柄 `z-index` 语义变化**:移入面板后 z-index 为面板内层叠(2,与拖条一致),对外绝对层级由面板(40)决定;两面板同开时手柄盒与底面板上沿无重叠(≥6px 间隙),行为不变。
- **拖拽禁用断言修正**:`transition: none` 的 computed 值为 `transition-property: none`(非 `all`),已按实测修正。
- **面板默认表面色阶微调**:±8 RGB,未做像素级补偿(e2e 不依赖具体颜色)。
@@ -0,0 +1,89 @@
# 外链点击路由到插件 tab(urlTarget)+ 浏览器 HTTP/HTTPS 二级设置
**日期**:2026-08-15
**状态**:已实施(本文档含实施偏差记录)
**目标版本**:v0.13.0(API 兼容层,版本号未随本变更 bump,见 §7)
## 1. 目标
基于 [PR #74](https://github.com/omdsh-dev/DSH-better-sidebar/pull/74)(聊天区外链开关颗粒度讨论)向前兼容地拓展服务化 API:
1. **插件声明「点击打开的类型」**:`TabDescriptor` 新增可选 `urlTarget?: (url: URL) => boolean`——聊天区外链被拦截时,路由到第一个声明的、命中的、未被设置禁用的插件 tab 类型(`openTab({ type, url, title: hostname })`,URL 即全部载荷),内置浏览器 tab 退居隐式兜底。
2. **浏览器链接设置改为二级菜单**(浏览器卡片齿轮弹窗内):
- 侧边打开 HTTP 网页(默认开)
- 侧边打开 HTTPS 网页(默认关)
- `browserInterceptLinks` 保留为**总闸**(默认开,零迁移:旧文档显式 false 语义完整保留)。
## 2. 非目标
- 不实施 PR #74 的「外链开关提升到常规区」(用户确认:二级设置留在浏览器卡片齿轮弹窗,常规区不加行)。
- 不做 per-host / per-domain 路由规则(只有 per-protocol 开关 + 插件谓词)。
- 不改 + 菜单、不改手动打开的浏览器 tab、不改同源/修饰键点击放行规则。
- 不改 host 半逻辑(仅 PrefsSchema 加字段);不修改 DSH 源码。
- 不做版本号 bump(发布时决定;向前兼容机制 = `SIDEBAR_FEATURES` 追加 `'urlTarget'`)。
## 3. 现状回顾
- `link-intercept.ts` 的 `registerLinkInterception` 无参 `takeoverEnabled()` 只做布尔门;`index.tsx` 硬编码 `openTab({ type: 'browser', url, title: hostname })`。
- `browserInterceptLinks`(默认 true)与 `browserNoSandbox` 并列于浏览器卡片的 `settings.toggles`;拦截 gating = 该 pref && `tabsEnabled['browser']`。
- `openTab` seed 已有 `url` 字段(新建 tab 的 path 预填),插件 tab 无需新渲染管道。
## 4. 设计
### 4.1 Prefs(`prefs-shared.ts` / `config.ts` / `client/prefs.ts`)
```ts
interface SidebarPrefs {
// …
browserInterceptLinks: boolean // 总闸,默认 true(保留,零迁移)
browserInterceptHttp: boolean // HTTP 外链侧边栏接管,默认 true
browserInterceptHttps: boolean // HTTPS 外链侧边栏接管,默认 false
}
```
三处同步维护(类型 + schemastery 默认 + parsePrefs 校验),旧文档无需迁移:总闸保留使显式关闭过的用户升级后仍不拦截。
### 4.2 服务 API(`service.ts`)
```ts
interface TabDescriptor {
// …
urlTarget?: (url: URL) => boolean
}
/** 注册顺序先到先得;谓词抛错吞掉(console.error)继续;返回 undefined 由调用方回退 browser。 */
export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDescriptor | undefined
```
- 匹配函数为**模块级纯函数**(不进 `BetterSidebarService` 接口,KISS:不扩公开面);启用态过滤由调用方(`index.tsx`)按 `tabsEnabled` 过滤后传入。
- 内置 browser 不声明 `urlTarget`,永远不可能遮蔽插件声明;插件多 URL 并存需用 `createTab` 铸造 per-URL id(browser 同款模式),否则 id 安全网聚焦既有 tab 不覆写 path。
- `SIDEBAR_FEATURES` 追加 `'urlTarget'`(消费者按能力 gate)。
### 4.3 拦截管线(`link-intercept.ts` / `index.tsx`)
- `shouldInterceptLink` 签名不变(协议/同源纯判定);`takeoverEnabled` 改为 `(url: URL) => boolean`,onClick 顺序:纯判定 → gate(持 URL)→ preventDefault → open。
- `index.tsx` gate:总闸 `browserInterceptLinks` → 协议开关(https 读 `browserInterceptHttps`,否则读 `browserInterceptHttp`)→ 目标可开(插件命中即目标,否则 browser 的 `tabsEnabled`)。open 时再次解析目标并 `openTab({ type, url, title: hostname })`。
### 4.4 设置 UI(`builtins/tabs.tsx` / `locales.ts`)
浏览器卡片 `settings.toggles` 收敛为四行:`browserNoSandbox` / `browserInterceptLinks`(总闸)/ `browserInterceptHttp` / `browserInterceptHttps`;新增 zh/en 文案(HTTP 默认开、HTTPS 默认关及理由)。
## 5. 行为变化
- 默认设置下:HTTP 外链 → 侧边栏(不变);**HTTPS 外链 → 系统浏览器新窗口(变化,符合「默认关闭」)**。
- 声明 `urlTarget` 的插件 tab 认领被拦截的链接(优先于浏览器兜底);插件被禁用/谓词抛错时跳过。
- 总闸关闭 → 一律不拦截(旧文档语义保留)。
## 6. 测试
- `tests/builtins.spec.ts`:浏览器 toggles 断言收敛为四行 + 各行 title/desc 存在。
- `tests/service.spec.ts`:`matchUrlTarget`(无声明 → undefined / 注册顺序 / 抛错吞掉 / browser 永不命中)。
- `tests/link-intercept.spec.ts`(新增 jsdom):`takeoverEnabled(url)` 收到解析后的 URL、gate 决定是否接管、同源/非 http(s) 不 consult gate、修饰键绕过。
- `tests/unit.spec.ts`:parsePrefs 新字段默认值(http true / https false / 非法回退);总闸与协议开关相互独立。
- `tests/plugin-shape.spec.ts` / `tests/smoke.spec.ts`:全默认字面量补两个字段。
## 7. 实施偏差记录
- 计划曾考虑把 `matchUrlTarget` 作为 `BetterSidebarService` 接口方法(镜像 `matchFileViewer`);按 KISS 收敛为**模块级导出纯函数**,启用态过滤由调用方完成(匹配函数不感知 prefs),公开 API 面只剩 `urlTarget` 字段 + `'urlTarget'` feature 字符串。
- 版本号未 bump(保持 0.12.1):`SIDEBAR_SERVICE_VERSION` 与 `package.json` 的配对断言继续成立;`'urlTarget'` feature 已入 `SIDEBAR_FEATURES`,发布时再决定版本。
- PR #74 的 diff(常规区移动 + 其测试改动)被本方案取代,未合入。
@@ -0,0 +1,82 @@
# Markdown 预览 Mermaid 安全渲染设计
> 日期:2026-08-17
> 状态:已实现
> 范围:`dsh-better-sidebar` 插件,Markdown 预览 + 新 `mermaid` 懒加载 chunk
> 基线:社区 PR #99(`feat/mermaid-md-preview`)的架构与安全姿势;借鉴 PR #75(`feat/mermaid-rendering`)的点击放大交互
## 1. 背景与问题
Markdown 预览(`TextEditor.tsx` 的 preview 模式)直接把全文交给 DSH 的 `MarkdownText`(`@deepseek-ai/dsh-client-ui-primitives`)渲染。该渲染器的 fenced code 一律走 `CodeBlock`(Shiki 高亮 + 复制按钮),且**不支持自定义 fence 渲染器**——```` ```mermaid ```` 块只会显示成一段无高亮的代码,不会渲染成图(DSH 宿主内 `mermaid` 字样出现 0 次,Shiki 别名表也无此语言)。
社区出现过两个 mermaid PR,安全姿势差异极大:
| | PR #75 | PR #99 |
|---|---|---|
| 加载 | 运行时 jsdelivr CDN(第三方供应链、离线不可用、无版本锁) | 自有懒加载 chunk(mermaid 11.16.1 本地打包,`/sidebar/bundle` 同源下发) |
| 安全 | `securityLevel: 'loose'`(标签可注入原始 HTML)+ 无清洗 + `innerHTML` 直插 | `securityLevel: 'strict'` + `htmlLabels: false` + SVG 二次清洗 + 不应用 `bindFunctions` |
| 测试/文档 | 无 | 单测 + e2e + 设计文档 |
| 亮点 | 点击放大弹窗(滚轮缩放/拖拽/快捷键) | 主题跟随、错误回退、CodeBlock 同款 chrome |
**结论:以 #99 为基线**。md 文件来自工作区(可能来自克隆仓库/下载),不可信;`loose` + 无清洗在 DSH GUI origin 下就是 XSS(可读会话数据)。**仅借鉴 #75 的点击放大交互**,在 #99 的已清洗 SVG 之上用 React 重写(克隆节点无事件面,弹窗不新增攻击面)。本地图片引用重写(#99 顺带功能)不在本 PR 范围,另行独立 PR。
## 2. 方案
### 2.1 检测:纯函数按 fence 切块
新增 `src/client/mermaid-blocks.ts`(`splitMermaidBlocks`,纯函数、无依赖),职责是**检测**预览是否需要 mermaid chunk:
- 逐行扫描,命中 info string 为 `mermaid` 的围栏(大小写不敏感,支持 `mermaid{...}` 属性后缀,缩进 ≤3 空格,CommonMark 语义)即"提出"为 `{ kind: 'mermaid', code }` 块;
- 其余行原样保留在 `{ kind: 'markdown', text }` 块中(含非 mermaid 围栏);
- 未闭合的 mermaid fence 吞掉文件剩余部分(与 CommonMark 对开 fence 的恢复一致);
- **CommonMark fence 规则完整**:开 fence 为 3+ 反引号**或波浪号**;闭 fence 必须同字符且长度 ≥ 开 fence;反引号 fence 的 info string 含反引号时该行不是开 fence。
`TextEditor` 预览时先 `splitMermaidBlocks`,**无 mermaid 块则走原 `MarkdownText` 直渲路径(零行为变化)**;有则渲染 `MermaidMarkdown`(mermaid chunk 内)。
### 2.2 渲染架构:单次解析 + 占位替换(CR 第 3 轮 P1 修正)
早期方案把文档切成多个独立 `MarkdownText` 片段,会破坏跨 fence 的 Markdown 语义(引用式链接定义在 fence 之后、脚注、有序列表连续性)。当前实现:
- **整篇文档只走一次 `MarkdownText`**(`MermaidMarkdown`),跨 fence 语义完整;
- 布局阶段(`useLayoutEffect`)扫描容器内 `.md-code-block`,识别 mermaid 块(双通道:shiki 路径的 `code.language-mermaid` 类,或 plain 路径的 banner infostring 文本 === `mermaid`——mermaid 无 shiki 语法必走 plain,语言只显示在 infostring 里),用 `createRoot` 把 `MermaidDiagram` 挂进替换后的子节点;**React 管理的 `.md-code-block` 宿主节点保留在树中**(仅替换其 children,CSS `display: contents` 抑制代码块自身盒子),React 协调不会丢失宿主;
- 块内容变化(编辑后重新预览)按源码 diff 重渲;块不再是 mermaid fence 时还原原 children(普通代码块恢复显示);fence 消失时卸载对应 root;
- DSH `MarkdownText` 无代码块渲染扩展入口(且仓库硬约束禁止改 DSH),这是约束内的最小侵入方案。
### 2.2 懒加载 chunk:`mermaid`
mermaid 及其图依赖(d3 / dagre-d3-es / cytoscape 等)打包进新的 `lib/client-mermaid.js`(~7MB),复用既有 chunk 机制(tsdown `CHUNKS` + `/sidebar/bundle` 路由 + `globalThis.__dshChunks__` 注册表 + `lazyChunkComponent` 懒包装),仅当预览的 md 文件实际含 mermaid fence 时才下载/执行。chunk 名在 4 处同步:`tsdown.config.ts` `CHUNKS`、`src/bundle-route.ts` `CHUNK_NAMES`、`src/client/chunk-loader.ts` `ChunkName`、`package.json` `files`(及各 spec 的硬编码清单)。构建侧对 mermaid chunk 注入 `mermaidChunkAliases`:把裸 `uuid` 说明符解析到浏览器入口(`uuid/dist/index.js`,Web Crypto 实现),绕过其 node 入口的 `node:crypto` 触发客户端纯度门。
### 2.3 渲染与安全(纵深防御)
- `mermaid.render(id, code)` 客户端渲染 → SVG 字符串注入;每个渲染调用用单调递增 id(文档内唯一);
- `mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', htmlLabels: false, theme })`——strict 下标签被转义、`click` 指令失效;`htmlLabels: false` 强制节点文字走真实 SVG `<text>`(默认的 `foreignObject` HTML 标签通道会被清洗器整体删除,导致文字丢失),同时把 HTML 标签注入面关死;`bindFunctions` **不**应用(静态图,无点击交互);订阅 `subscribeColorScheme`,深浅色切换自动按新主题重渲;
- **SVG 注入前二次清洗**(`src/client/mermaid-sanitize.ts`,独立纯函数、可单测):`DOMParser`(`image/svg+xml`)解析,删除 `foreignObject` 与 `script`/外来 HTML 元素(`img`/`iframe`/`object`/`embed`/`video`/`audio`/`input`/`button`/`form`/`link`/`meta`/`base`),剥离 `@*`/`on*` 属性,**移除全部 `href`/`xlink:href`**(静态图不需要链接,杜绝 javascript:/data: 乃至普通外链把整个 GUI 导航走);仅接受根元素为 `<svg>` 的文档;解析失败(parsererror/非 SVG 根)返回空串,调用方显示错误回退,**原始字符串永不过清洗器**;
- 渲染失败(语法错误/清洗拒绝)展示错误条 + 原始代码块(便于对照修改),错误全文挂 tooltip;空 fence 不渲染;
- 块头保留与 `CodeBlock` 一致的 chrome:info string「mermaid」+ 复制按钮(`writeClipboard`,沿用 `copy`/`copied` 字典)。块体带稳定锚点 `data-mermaid-diagram`(e2e 断言用)。
### 2.4 点击放大弹窗(借鉴 PR #75,React 重写)
点击 `[data-mermaid-diagram]` 内 SVG → 克隆节点(**已清洗**,无事件属性)→ portalled fixed overlay(`data-mermaid-modal`):
- 滚轮缩放(以鼠标位置为不动点,被动标志关闭防止页面滚动);
- 拖拽平移(mousedown/mousemove/mouseup on window);
- 工具栏按钮:− / + / ⟳ / ✕(i18n title);
- 键盘:`+`/`=` 放大、`-` 缩小、`0` 重置、`Esc` 关闭;
- 背景点击关闭;卸载时移除全部监听;
- 样式全部走 CSS Module + DSH 令牌(遮罩 `--dsw-alias-bg-mask-1` + `blur(2px)`,与 DSH `Modal` 一致;z-index 1000 落在 DSH 浮层栈)。
## 3. 验证
- 单测:`tests/mermaid-blocks.spec.ts`(14 例:fence 识别大小写/属性/缩进、4 反引号/波浪号、同字符且不短于开 fence 的闭 fence、非 mermaid 围栏不动、交错顺序、开 fence 恢复、空文件);`tests/mermaid-sanitize.spec.ts`(10 例 XSS:foreignObject/script/外来元素整节点剥离(含混合大小写元素名与属性名)、`on*`/`@*` 剥离、href/xlink:href 全删、畸形 XML → 空串、非 SVG 根 → 空串、良性图保真);`tests/mermaid-markdown.spec.tsx`(3 例架构回归:mermaid 块被交换为图、**跨 fence 引用式链接在单次解析下解析**、非 mermaid 围栏不动);
- 产物契约:`chunk-artifact.spec.ts`(mermaid chunk 执行 + registry 槽位 + 平台 externals require,`addEventListener` stub)/ `manifest-consistency.spec.ts` / `bundle-route.spec.ts` 的 chunk 清单同步加入 `mermaid`;
- 挂载冒烟:`tests/e2e/mount.e2e.ts` 新增 seed `diagram.md`,打开后强制 `client-mermaid.js` 往返、断言 `[data-mermaid-diagram] svg` 含节点文字(真实 `<text>`)、**跨 fence 引用式链接解析为真实锚点(单次解析证明)**、点击弹窗出现且 Esc 关闭、预览模式编辑器隐藏;缺 chunk、文字丢失、语义破坏、弹窗失效任一即红;
- 现有 `markdown-copy-labels.spec.tsx` 不回归(无 mermaid 文件走原路径)。
## 4. 风险与取舍
- 编辑器 chunk 之外新增 ~7MB 懒 chunk(mermaid + d3/dagre/cytoscape/katex 图依赖);仅预览含 mermaid fence 的 md 时下载,启动与普通文本打开路径零成本;
- `splitMermaidBlocks` 对"mermaid 围栏嵌在其他代码块内"的极边缘情况会误提(只影响是否走 mermaid 路径,不影响渲染正确性)——预览级工具接受该取舍(与常见 md 渲染器行为一致);
- 占位替换依赖 DSH `MarkdownText` 的 DOM 输出结构(`.md-code-block` + `language-mermaid`):宿主节点保留在 React 树中、仅换 children,协调安全;若未来 DSH 渲染器结构变化,`data-mermaid-processed` 扫描点需要同步(有 e2e 兜底);
- 清洗器删除全部 `href`/`xlink:href`:`<use href>` 类图标引用可能不显示(纯装饰性损失),换取确定性的无导航/无脚本面;
- mermaid 官方核心包持续演化,锁 `^11.16.1`,升级靠 Renovate 常规流程;
- 回滚:删除 chunk 三处声明 + `files` 条目 + `TextEditor` 分支即可回到纯代码块展示。
@@ -0,0 +1,260 @@
# 侧边栏面板注入方案设计:统一面板宿主(插件侧) + 官方槽位路线(上游侧)
**日期**:2026-08-19
**状态**:评审中(设计文档先行,实现待批准)
**作者**:DSH 工程模式会话
**当前版本**:v0.13.0(main)
**目标版本**:v0.13.x(不 bump 大版本)
## 1. 目标
1. 解决侧边栏面板的**注入方法**问题:找到一个同时兼容「纯浏览器 / Electron 套壳(DSH Desktop)/ 各类 Tauri/WebView 套壳 / 移动 webview」且**性能良好**的统一注入方案。
2. 消灭现有症状级补丁 PR 簇(#226 desktop 布局推挤、#135 dsh-web-mobile、#82/#108/#111/#138/#153 桌面标题栏)背后的结构性根因:**插件选择器深入宿主 DOM 形状(nth-child 锚点)+ 桌面壳依赖手动设置(titleBarCompat)+ 几何写路径分散(拖拽/状态两条)+ 懒加载缓存每次激活清空(重注入重执行)**。
3. 探索并文档化「官方槽位路线」:参考 DSH 内置插件 `ui-sidebar`(官方左侧边栏本身就是以插件方式占用 `sidebar` 槽),评估外部插件接管官方面板槽的可行性与代价,作为中期北极星;上游未落地前以插件侧方案为主线。
## 2. 非目标(Out of Scope)
- **不修改 DSH 官方源码**(仓库硬约束,`~/.dsh/source/current` 零写入);官方槽位路线只产出 issue/设计建议给上游。
- 不做 shadow DOM / iframe 全隔离注入(令牌穿透、事件与 portal 代价高,仅在宿主样式污染实测出现时作为兜底启用)。
- 不重构 tab/viewer 扩展点本身(`ctx.betterSidebar` 服务契约不变)。
- 不承诺禁用官方 `ui-sidebar` 的行为由插件自动完成——那是 **profile 层用户决策**,插件只文档化。
- 不处理 #69(browser 探测,独立域)。
## 3. 已验证事实(证据先行,2026-08-19 核对)
### 3.1 当前注入实现(本仓库)
| 事实 | 证据 |
|---|---|
| 注入 = body 直下 `div[data-dsh-better-sidebar]` + **第二个 React root**(非 portal) | `src/client/index.tsx:120-141` |
| 宿主推挤 = html 上 `--dsh-sidebar-width/height` + `#root{margin-right}` + **`#root > [data-slot=root] > div > div:nth-child(2)` 锚点** | `src/client/layout.css:22-32` |
| 拖拽:pointer 事件 + rAF 单帧写面板尺寸与 html 变量(无强制 reflow 读) | `src/client/Sidebar.tsx:494-518` |
| 懒加载 chunk:每次激活 `resetChunks()` 清内存缓存 → 下次懒打开**重注入+重执行**脚本;HTTP 层已有 ETag 304(**不重下载**) | `src/client/index.tsx:120` 注释、`chunk-loader.ts` |
| 会话切换:只换 snapshot,面板 DOM 不重建(保持) | `Sidebar.tsx` store 驱动 |
| **已具备(实施勿重做)**:拖拽 rAF 批处理(`scheduleDrag` 单帧一次 `applyDrag`,无强制 reflow 读);窄屏不推挤宿主(narrow 时宽/高变量写 0);`measureCenter` ResizeObserver 已 rAF 节流;header 推挤已用弱锚点 `[data-slot="conversation.session.header"]` | `Sidebar.tsx:368-424,487-533,563-585`、`layout.css:45` |
### 3.2 DSH 官方架构(`~/.dsh/source/current`,只读)
| 事实 | 证据 |
|---|---|
| 外壳 = `#root` 单挂载点 + `__DSH_BOOT__` 引导;真实 UI 树全挂在 `root` 槽 | `apps/web/index.html:10-12`、`packages/client/web/src/app.tsx:38-43` |
| `ui-layout` 声明 `root` 槽的 children:`sidebar`/`conversation`/`details` 三个 **single 独占槽**;single 槽重复声明**抛错** | `ui-layout/src/client/index.ts:80-96`、`ui-slots/src/index.ts:747` |
| **官方左侧边栏 = 内置插件 `ui-sidebar`**:注册 `name:'sidebar'`,渲染在 AppFrame 的 grid 列内;几何经 layout store → `gridTemplateColumns` 内联传导(非 CSS 变量);**官方不持久化宽度/折叠**;折叠按钮在 SidebarRoot 内自渲染并调 `ctx.layout.toggleSidebar()`;拖拽把手由 AppFrame 免费提供 | `ui-sidebar/src/client/index.ts:39`、`ui-layout/src/client/AppFrame.tsx:164-196` |
| `details` 槽(右侧面板)被 `ui-conversation` 的 DetailsPanel 占用;禁 ui-conversation = 会话列整体空置,代价极高 | `ui-conversation/src/client/apply.ts:445-458` |
| 官方浮层(Menu/Modal/Toast/HoverCard)全部 `createPortal(document.body)`——**body 级 portal 是官方姿势** | `ui-primitives/Menu.tsx:299`、`Modal.tsx:55-85` |
| 槽位注入消费者经 `slots.inject` 注入;槽未声明时**静默跳过**(禁用 ui-sidebar 不会炸树,但接管者需自实现会话/设置入口) | `runtime/src/client/slots.ts:130-192` |
| profile 可禁用内置插件行:`cordis.patch.yml` `- id: <plugin-id>` + `disabled: true`(皮肤 10 款禁用先例) | `~/.dsh/profiles/web/cordis.patch.yml` |
| 性能参考:拖拽 rAF 合并最新 delta 单帧应用 + `[data-dragging]` 禁过渡 + 纯函数让步结算 + 「关闭=宽 0 保活不卸载」 | `AppFrame.tsx:59-62`、`AppFrame.module.css:8-17`、`columns.ts:62-77` |
### 3.4 实证核对(真实 dsh web 页面,Playwright 探针,2026-08-19)
对 http://127.0.0.1:3080/(本机 profile,better-sidebar 已挂载)实测 DOM 与推挤行为:
| 事实 | 实测结果 |
|---|---|
| 外壳 DOM 链 | `#root > div[data-slot="root"]`(**display:contents**)`> div[data-dsh-frame]`(grid,CSS module 哈希类名) |
| frame 子项 | **5 个**:`[data-pane=sidebar]` / `[data-pane=conversation]` / `[data-pane=details]` / `[data-shell-overlay]`(浮层)/ 拖拽 handle——`nth-child(2)` 当前仍命中 centerCol,但**位置脆弱性实锤**(浮层/handle 已混入子项序列) |
| 稳定锚点 | centerCol 带 `data-pane="conversation"`;frame 带 `data-dsh-frame` → 弱锚点 = `#root [data-dsh-frame] > [data-pane="conversation"]` |
| ~~`[data-slot="conversation"]` 锚点候选~~ | **作废**:该元素 `display: contents`(无盒,margin 无效,rect 0×0) |
| 推挤无溢出(web) | 设 `--dsh-sidebar-width:300px` 后:`#root` margin-right 生效(过渡中 92px),`scrollWidth == clientWidth == 1440`,**无水平溢出** → `width: calc(100% - var)` 改动在 web 无副作用;桌面壳溢出修复(#226 目标)保留并标注「web 已验证无害、Desktop 待实测」 |
| 当前 host 本体 | `[data-dsh-better-sidebar]` 为 `position: static` 的普通块(fixed 在**内层面板**上)→ 新增 `data-dsh-panel-host` 固定含块层是宿主内新元素,外部契约不变 |
| 页面错误 | 0 个 pageerror(探针期间) |
### 3.3 桌面套壳(本机 DSH Desktop.app,Electron)
| 事实 | 证据 |
|---|---|
| 渲染 URL 注入信号:`?dsh-desktop-mode=<compatibility\|advanced>&dsh-desktop-platform=<…>` | `app.asar.unpacked/lib/src-B5373ech.js` |
| win32:`titleBarStyle:"hidden"` + `titleBarOverlay({height:32})`(右上角 32px 为窗口控制钮) | `electron-runtime-C_i38SKA.js:338-348` |
| darwin:`titleBarStyle:"hiddenInset"` + `trafficLightPosition(16,16)`(左上红绿灯区 ~78px) | 同文件 `:325-333` |
| 安全基线:`contextIsolation/sandbox:true`、`nodeIntegration:false`;preload 仅暴露 `__DSH_DESKTOP_FILE_PATH__` | `preload.cjs:5-9` |
| 社区 Tauri 套壳(kyorakuyk/dsh-desktop、Lotus-c/DSH-Desktop)用**原生系统标题栏**,fixed 语义与纯浏览器一致 | 子代理调研(来源 URL 见 §10) |
| 套壳最大注入风险:祖先 `transform/will-change` 劫持 fixed 包含块、标题栏 overlay 遮顶、drag 区吞事件、移动键盘盖 `bottom:0` | 子代理调研 |
## 4. 方案总览:双路线
```
路线 A(主线,本仓库可控)┐
统一面板宿主:body 固定含块包裹层 ├─ 现在 → v0.13.x(已实施,分支 feat/injection-unified-host)
+ 桌面信号自动适配 + 几何写路径统一 │
路线 B(已关闭)┘
ui-sidebar 模式:占用官方槽位(grid 内渲染) ─ 2026-08-19 用户决策关闭:无法向官方提 issue,
纯插件侧自己优化注入方式(调研结论保留于 §6)
```
- **路线 A 已实施**:不依赖 DSH 新能力,纯插件侧,直接吸收桌面/移动/性能问题。
- **路线 B 已关闭**(2026-08-19 用户决策):不提交上游 issue;§6 的槽位可行性调研保留为后续参考,但不再推进。
## 5. 路线 A 详细设计:统一面板宿主
### 5.1 挂载层(兼容性根因修复)
**A1.1 固定含块包裹层**:保留 body 级锚点(官方 portal 姿势),但结构升级为:
```
document.body
└─ div[data-dsh-better-sidebar] ← 唯一锚点(属性不变,兼容既有契约/皮肤)
└─ div[data-dsh-panel-host] ← 新增:position:fixed; inset:0; pointer-events:none; z-index:40
├─ 右侧面板(absolute; right:0; pointer-events:auto)
└─ 底部面板(absolute; bottom:0; pointer-events:auto)
```
- 面板在**自己的固定含块**内 absolute 定位 → 宿主中间层(套壳注入的 wrapper)transform 不再劫持面板 fixed 语义。
- 折叠按钮簇 z-index 45 不变(skin 契约 §8 同步)。
- **挂载自检(一次)**:挂载后对比 `getBoundingClientRect()` 与 `window.innerWidth/Height`;若 html/body 本身被套壳 transform/偏移(罕见),自动降级为「absolute + 每帧 rAF 同步宿主 rect」模式并 console.warn 一次。
- **MutationObserver 守卫(默认开,可关)**:body childList 变化检测锚点被宿主清空/挪位时自动重挂(SPA 导航/套壳重排兜底);观察器廉价(仅 childList、无 subtree)。
**A1.2 桌面信号自动探测**(替代手动 titleBarCompat):
- 信号源:URL `dsh-desktop-mode` / `dsh-desktop-platform`(Electron 官方注入)+ `window.__DSH_DESKTOP_FILE_PATH__` 存在性(preload 标记)+ UA 兜底。
- 适配表:
| 探测结果 | 自动适配 |
|---|---|
| win32 + advanced | 右上角保留 32px 标题栏 overlay 区:折叠按钮簇/设置按钮下移,面板顶部内边距 +32px |
| darwin | 左上角保留 ~78px 红绿灯区:左侧贴边控件(若存在)避让 |
| compatibility / 无信号 | 原生 frame,不避让(现状行为) |
| 移动 webview | 窄屏 overlay 抽屉 + `visualViewport` 键盘监听 + `env(safe-area-inset-*)` 内边距 |
- 保留现有手动开关(titleBarCompat)作为覆盖位,但默认值改为「自动」。
**A1.3 移动端**:窄屏(断点沿用 `breakpoints.ts`)面板从「推挤宿主」切「overlay 抽屉」:不写 `#root` margin、面板 transform 滑入滑出;`visualViewport` 变化(键盘弹起)时底部面板上移。
### 5.2 几何层(性能)
**A2.1 单一几何写入器**:现状有两条写路径——拖拽的 `applyDrag`(Sidebar.tsx:487-505)与打开态 effect(:563-585),都写同一组变量但互不感知。统一为 `writeGeometry({width,height})` 单函数,两条路径共用(拖拽仍走 `scheduleDrag` 的 rAF 单帧批写);折叠/会话恢复等非拖拽变更也收敛到它。**不改变对外契约**:`--dsh-sidebar-width/height` 变量与 `#root` margin 推挤机制不变。
**A2.2 拖拽**(现状已达标,微调即可):
- pointer capture + 最新 delta + 单帧 rAF 应用(**已具备**,保留);
- 拖拽期 `body[data-dsh-sidebar-dragging]` 禁过渡(**已具备**,`layout.css:49`);
- 新增:拖拽期面板根挂 `will-change: transform`;面板根常驻 `contain: layout style`(CSS 一处,隔离内部布局回流)。
**A2.3 chunk 缓存精确化**:HTTP 层已 304 不重下载;缺口只在「激活后内存缓存清空 → 重注入+重执行」。改为:缓存键 = chunk 名 + 脚本内容哈希;激活时若哈希未变则保留内存缓存(跳过重注入),仅 HMR 核心 bundle 变化(版本变化)才清。保留失败路径(失败清缓存重试)。
**A2.4 保活策略(保持现状并文档化)**:面板 DOM 跨会话不重建;tab 内容按 `visible` prop 懒挂载/卸载;树/终端等重组件不随会话切换重挂。
### 5.3 契约层(可维护性)
- **锚点弱化**:`layout.css:31` 的 `#root > [data-slot=root] > div > div:nth-child(2)` 替换为 `#root [data-dsh-frame] > [data-pane="conversation"]`(稳定 data 属性锚定,实证见 §3.4;`[data-slot="conversation"]` 为 display:contents 不可用);推挤逻辑收敛进几何写入器(一处实现)。
- **推挤可关闭**:`SidebarPrefs` 新增(或复用既有)「宿主推挤」开关,默认开;桌面套壳/移动端自动切 overlay 时关闭推挤(修 #226 根因:不再依赖 `#root{width:100%}` 之外的宿主形状)。
- **契约文档化**:挂载点(`data-dsh-better-sidebar` / `data-dsh-panel-host`)、z-index 栈、CSS 变量(`--dsh-sidebar-width/height` 保持对外)、桌面信号(URL/全局标记)、推挤锚点策略 —— 同步 AGENTS.md §8 与本文档。
- **服务契约不变**:`ctx.betterSidebar` API 零变化;#135 的 service 读取问题(dsh-web-mobile 下 `ctx.betterSidebar` 直接读抛错)由「注入层与服务的解耦」顺带收敛(面板渲染不再假设服务存在即可读)。
### 5.4 性能清单(验收口径 + 实测基线)
基线(2026-08-19 真实页面探针,本机 3080,拖拽 12 步 ≈192ms):**40 帧、帧均 8.36ms / p95 10.2ms、1 帧超 16ms;62 次 `setProperty`(5 种几何属性:panel width / bottom height / bottom right / 两个 html 变量)**——即每帧 5 次写同一组元素,浏览器合并为单次样式重算。
| 指标 | 现状 | 目标(v0.13.1 实施后) |
|---|---|---|
| 拖拽期宿主 layout 次数 | 每帧 1 次批写(`applyDrag` rAF),帧均 8.36ms | 保持帧率 ≥ 基线;面板 `contain: layout style` 隔离内部重排;拖拽期 `will-change` 提升合成层 |
| 几何写路径 | 拖拽(`applyDrag`)与打开态(effect)两条路径写同一组变量 | 统一 `writeGeometry` 单写入器(已实施) |
| 懒加载 chunk 二次打开 | 不重下载(304)但重注入+重执行 | ETag 未变 → 内存缓存命中,0 脚本执行(已实施) |
| 会话切换 | 面板 DOM 不重建(已满足) | 保持 + 重组件懒挂载 |
| 面板显隐动画 | transform+width/margin 过渡 | transform 优先(overlay 模式),推挤模式过渡保留(`#root` width/margin 锁步过渡) |
| 观察器 | ResizeObserver 已 rAF 节流 | 保持;挂载自检/MutationObserver 守卫均为一次性廉价检查 |
## 6. 路线 B 详细设计:官方槽位迁移(ui-sidebar 模式)
### 6.1 可行性结论(已验证)
- **`sidebar` 槽:可接管**。条件:profile 层 `cordis.patch.yml` 禁用 `ui-sidebar`(`disabled: true`),`ui-layout` 必须保留;接管者需自实现 `sidebar.workspaces`/`sidebar.settings` 子槽内容或留空(`slots.inject` 静默跳过,不炸树);折叠按钮自渲染 + `ctx.layout.toggleSidebar()`;宽度/折叠持久化**自做**(官方不持久化)。
- **`details` 槽:不可行(当前)**。唯一占用者 ui-conversation 的 DetailsPanel 与其会话 UI 同包,禁用即空置会话列;需上游把 DetailsPanel 拆为可替换槽位或提供优先级机制。
- **几何传导**:槽位渲染在 AppFrame grid 列内,官方拖拽把手/折叠/让步结算免费生效;session scope 槽的 sessionId 由框架注入为标准 prop。
### 6.2 上游 issue 建议(本仓库只出建议,不改源码)
1. 将 `details` 槽的占用者改为**可替换**(如 single 槽支持「替换注册」或 DetailsPanel 拆独立包),使外部插件能接管右侧面板。
2. 新增(或声明)`bottom-panel` 槽,供底部面板类插件使用。
3. ui-sidebar 宽度/折叠持久化下沉到 layout store(官方侧目前不持久化)。
**issue 草案(PR3 提交时使用,已备好)**:
> **Title**: External plugins cannot take over the `details` panel slot
> **Repo**: deepseek-ai/dsh(或官方插件仓库)
> **Body**:
> - Context: `ui-layout` declares `root` → `{sidebar, conversation, details}` as single-exclusive child slots. `ui-conversation`'s DetailsPanel is the sole occupant of `details`; an external panel plugin (e.g. dsh-better-sidebar) cannot register into it without disabling `ui-conversation` (which empties the conversation column). Meanwhile `ui-sidebar` (the official left sidebar) is itself a plugin and CAN be replaced from the profile layer (`cordis.patch.yml` `disabled: true`), which proves the pattern.
> - Ask: make the `details` occupant replaceable — either (a) allow single-slot "replacement registration" with explicit opt-out by the default occupant, (b) extract DetailsPanel into a standalone optional plugin so profiles can disable it without losing the conversation column, or (c) declare a new `bottom-panel` slot for bottom panel plugins.
> - Reference implementation to mirror: `ui-sidebar/src/client/index.ts:39` (register `name:'sidebar'`), `ui-layout/src/client/AppFrame.tsx:164-196` (grid geometry + drag handles come free), `runtime/src/client/slots.ts:130-192` (`slots.inject` consumers skip silently when a slot is undeclared).
### 6.3 迁移路径(上游落地后)
1. better-sidebar 增加「槽位渲染后端」:注册 `details`/`bottom-panel` 槽,`Sidebar` 树渲染进 grid(同一套 store/组件,仅挂载点不同)。
2. body 浮层收敛为纯浮层:菜单、弹窗、右键、拖出浮窗(与官方 ui-primitives portal 同姿势)。
3. 保留路线 A 的固定含块层作为浮层宿主;推挤/几何写入器退役(grid 接管)。
### 6.4 收益与代价
| 收益 | 代价 |
|---|---|
| 零 body 注入、零宿主 DOM 依赖 | 契约绑定上游 SlotMap,DSH 升级需跟随 |
| 官方几何/拖拽/折叠/让步结算免费 | 官方不持久化宽度,需自做持久化 |
| 任何套壳天然一致(grid 与套壳无关) | 上游排期不可控(issue 周期) |
| 拖拽性能与官方同源 | 迁移期间双后端并存(维护成本) |
## 7. 兼容矩阵(注入策略 × 场景)
| 场景 | body 固定含块(路线 A) | 官方槽位(路线 B) | shadow DOM | iframe |
|---|---|---|---|---|
| 纯浏览器 | ✅ 推荐 | ✅ | ✅(令牌穿透) | ⚠️ 令牌/交互断裂 |
| Electron 套壳(DSH Desktop) | ✅ + 信号自动避让 | ✅ | ✅ | ⚠️ |
| Tauri 套壳(原生标题栏) | ✅ | ✅ | ✅ | ⚠️ |
| 移动 webview | ✅ + overlay 抽屉/visualViewport | 需上游槽位 | ✅ | ⚠️ |
| 页面内嵌 iframe | ⚠️ 需宿主放行 | ❌ | ✅ | ✅(天然隔离) |
| 祖先 transform 劫持 | ✅ 自检降级 | ✅ 天然免疫 | ✅ | ✅ |
| 性能(拖拽/显隐) | rAF 批写 + contain | 官方 grid | 中 | 低 |
## 8. 实施拆分与验证
### 8.1 拆分(文件级改动清单)
**实施方式(2026-08-19 定稿)**:单分支 `feat/injection-unified-host`、按步骤顺序提交(每步 CI 绿);原 PR1/PR2 拆分为内部步骤,合并为**一个 PR** 交付(改动彼此耦合,且路线 B 关闭后无外部依赖)。各步骤:
1. **挂载层**(`src/client/index.tsx`、`src/client/Sidebar.tsx`、`src/client/sidebar.module.css`):`Sidebar` 渲染根包 `data-dsh-panel-host`(`position:fixed; inset:0; pointer-events:none; z-index:40`);`.panel/.toggleCluster/.bottomPanel` 由 fixed 改 absolute(层 inset:0 即视口坐标)+ `pointer-events:auto`;挂载后一次性几何自检(偏差 >8px → `data-dsh-panel-host-degraded` + rAF 视口同步 + warn 一次);`MutationObserver`(body childList)守卫锚点被清空时重挂。✅ 已实施(commit 9a4c160)
2. **几何层**(`Sidebar.tsx`、`sidebar.module.css`):`writeGeometry` 统一拖拽/打开态两条推挤写路径;面板 `contain: layout style`;拖拽期(`body[data-dsh-sidebar-dragging]`)`will-change: transform`。✅ 已实施(commit 553e650)
3. **chunk 缓存**(`chunk-loader.ts`、`index.tsx`):`revalidateChunksOnReactivate()`——激活时 HEAD 每个已加载 chunk 对比 ETag,未变保留已解析 exports(跳过重注入/重执行),变更/不可达 fail-open 重取;测试夹具与孤儿缓存不跨激活存活。✅ 已实施(commit 0264625)
4. **锚点与防溢出**(`layout.css`):底部推挤锚点 `nth-child(2)` → `#root [data-dsh-frame] > [data-pane="conversation"]`(§3.4 实证);`#root` 加 `width: calc(100% - var(...))` 与 margin 锁步过渡(实测 1140+300=1440 无溢出;#226 桌面溢出根因吸收)。✅ 已实施(commit 9c9515d)
5. **桌面/移动适配**(新增 `desktop-env.ts`、`Sidebar.tsx`、CSS):解析 `dsh-desktop-mode/platform` + `__DSH_DESKTOP_FILE_PATH__`;win32 advanced 自动 `titleBarCompat`(32px overlay,手动 pref 覆盖);`visualViewport` 键盘 inset(底部面板/窄屏抽屉上移);`env(safe-area-inset-*)`。✅ 已实施(commit a1906c5,单测 7 例)
6. **测试与文档**:`tests/desktop-env.spec.ts`(7 例)、chunk-loader 重验证 4 例、`mount.e2e.ts` 增 `data-dsh-panel-host` 断言 + 桌面参数场景、AGENTS.md §8 契约同步、本文档更新。🔄 进行中
7. **交付**:CI 全绿(含 plugin-mount)→ `gh pr create`。⏳ 待执行
### 8.2 验证
- **单元**:几何写入器 rAF 批处理(fake timers 断言单帧单写);桌面信号解析(URL param × platform × 全局标记);挂载自检降级路径;chunk 缓存命中/失效。
- **e2e**:`tests/e2e/mount.e2e.ts` 深扫保持不变并新增断言:`data-dsh-panel-host` 存在、锚点属性不变;新增 desktop-mode URL 参数场景(模拟 win32 advanced → 顶栏避让类生效)。
- **skin 契约**:`tests/theme.spec.ts` 令牌读取不变(§8 契约同步守护)。
- **CI**:全绿门禁(build/typecheck/test/consumer-types/plugin-mount);#180 的 plugin-mount flake 与本次改动无关,重跑确认。
- **度量**:拖拽期 PerformanceObserver 记录 layout 次数;懒加载二次打开网络计数为 0。
## 9. 风险与回滚
| 风险 | 缓解 |
|---|---|
| 固定含块改变既有 fixed 行为(罕见宿主) | 挂载自检 + 降级模式;锚点属性/CSS 变量不变,可一键回退旧实现 |
| 几何写入器集中化引入回归 | PR1 内保留旧写入路径开关(feature-flag),e2e 深扫覆盖 |
| 桌面自动探测误判(套壳改 URL 格式) | 信号解析白名单 + 手动覆盖位保留 |
| 路线 B 上游不落地 | 路线 A 完整自洽,不阻塞 |
| 禁用 ui-sidebar 属 profile 用户决策 | 插件仅文档化,不自动执行 |
## 10. 参考来源
- DSH 源码:`~/.dsh/source/current`(只读):`ui-layout/src/client/{index.ts,AppFrame.tsx,columns.ts,stores.ts,AppFrame.module.css}`、`ui-slots/src/index.ts`、`ui-sidebar/src/client/index.ts`、`ui-conversation/src/client/apply.ts`、`ui-primitives/Menu.tsx`、`runtime/src/client/slots.ts`
- DSH Desktop 运行时(本机 .app,只读):`lib/src-B5373ech.js`、`lib/electron-runtime-C_i38SKA.js`、`lib/preload.cjs`
- 社区套壳:kyorakuyk/dsh-desktop(https://github.com/kyorakuyk/dsh-desktop)、Lotus-c/DSH-Desktop(https://github.com/Lotus-c/DSH-Desktop)
- 本仓库:`src/client/index.tsx`、`src/client/Sidebar.tsx`、`src/client/layout.css`、`docs/plans/2026-08-15-skin-compat-design.md`
- 相关 PR:#226、#135、#82、#108、#111、#138、#153(症状级补丁,本方案吸收其目标);#208/#90(issue)
## 11. 决策记录与实施偏差记录
- 2026-08-19(第一轮):方案定稿——路线 A(统一面板宿主)为主线、路线 B(官方槽位)并行提 issue;先出设计文档,实现拆 PR1/PR2 待批准。
- 2026-08-19(第二轮,实施就绪打磨):对照源码核实现状——拖拽 rAF 批处理/窄屏不推挤/观察器节流/header 弱锚点**已具备**(§3.1 已标注「实施勿重做」);chunk 缺口精确为「激活后内存缓存清空重注入」(HTTP 已 304);§8.1 落到文件级改动清单;§6.2 备好上游 issue 草案。
- 2026-08-19(第三轮,实证核对):真实 dsh web 页面 Playwright 探针(§3.4)——frame 子项已含 overlay/handle(nth-child 脆弱性实锤);`data-pane="conversation"` 为新弱锚点(`[data-slot=conversation]` display:contents 作废);推挤在 web 无溢出(calc 改动无害);host 本体 static(fixed 在内层面板,含块层为新元素)。
- 2026-08-19(第四轮,用户决策):**路线 B 关闭**——无法向官方提 issue,纯插件侧自己优化注入方式;实现合并为单 PR(分支 `feat/injection-unified-host`),按 §8.1 步骤顺序落地。
- **实施偏差记录**:
- 挂载自检降级实现为「`data-dsh-panel-host-degraded` 属性 + rAF 视口同步」(非独立 absolute 模式,见 §5.1 注释)。
- chunk 缓存实现为 **ETag HEAD 重验证**而非内容哈希(bundle 路由已发 ETag,HEAD 更廉价);`resetChunks()` 保留(测试隔离),激活路径改用 `revalidateChunksOnReactivate()`。
- `#root` 防溢出采用 `width: calc(100% - var)` + width/margin **锁步过渡**(避免动画断裂),并实测 1140+300=1440。
- 桌面避让复用既有 `titleBarCompat/titleBarStripPx` 通道(未新增 `--dsh-desktop-top-inset` 变量);darwin 红绿灯区仅预留(当前无左侧贴边控件)。
- 测试环境为 Node + 手写 shim(非 jsdom):`browser-globals.ts` 补 `location`;desktop-env 剥除 `location.search` 前导 `?`(真实 bug,顺带修复)。
- 本地 Node 24 跑 `test:mount` 需 `node --expose-internals` 启动 dsh CLI(HMR 服务要求,CI Node 22 无此问题)——本地用 DSH_CMD 包装,不改仓库脚本。
- **CR #232 修复(2026-08-19)**:① 降级同步改为按**未修正几何**判定(跟踪自身补偿量,祖先 transform 消失才退出循环);② chunk 重验证成为 `loadChunk` 屏障(重验证挂起期间不提供缓存,杜绝 HMR 竞态陈旧 exports);③ 键盘 inset 公式补 `visualViewport.offsetTop` 并监听 `scroll`。
+1 -1
View File
@@ -1,6 +1,6 @@
{
"id": "dsh-external/dsh-better-sidebar",
"version": "0.12.1",
"version": "0.14.0",
"main": "./lib/index.js",
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
"engines": {
+39 -38
View File
@@ -1,6 +1,6 @@
{
"name": "dsh-better-sidebar",
"version": "0.12.1",
"version": "0.14.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.",
"type": "module",
"repository": {
@@ -48,7 +48,8 @@
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-slots",
"@deepseek-ai/dsh-client-ui-conversation"
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-modules"
],
"platform": "web"
}
@@ -60,6 +61,7 @@
"lib/client-registry.js",
"lib/client-terminal.js",
"lib/client-editor.js",
"lib/client-mermaid.js",
"lib/types/**/*.d.ts",
"src",
"scripts/install.sh",
@@ -77,27 +79,26 @@
"watch": "tsdown --watch",
"prepare": "tsdown",
"test": "vitest run",
"test:mount": "bash scripts/e2e-mount.sh",
"check:consumer-types": "bash scripts/check-consumer-types.sh"
},
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-schema-form": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-web-react": "^0.1.0-rc.6",
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
"cordis": "^4.0.0-rc.7",
"@deepseek-ai/dsh-agent": "^0.1.0-rc.8",
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.8",
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.8",
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.8",
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.8",
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.8",
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.8",
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.8",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.8",
"@deepseek-ai/dsh-session": "^0.1.0-rc.8",
"@deepseek-ai/dsh-settings": "^0.1.0-rc.8",
"@deepseek-ai/dsh-tools": "^0.1.0-rc.8",
"cordis": "^4.0.0-rc.8",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
@@ -128,36 +129,36 @@
"@codemirror/state": "^6.7.1",
"@codemirror/view": "^6.43.8",
"@lezer/highlight": "^1.2.3",
"@xterm/addon-fit": "^0.10.0",
"clsx": "^2.1.1",
"mermaid": "^11.16.1",
"node-pty": "^1.1.0",
"rxjs": "^7.8.2",
"schemastery": "^3.18.0",
"ws": "^8.18.0",
"xterm": "^5.3.0"
"ws": "^8.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-schema-form": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-web-react": "^0.1.0-rc.6",
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-agent": "0.1.0-rc.8",
"@deepseek-ai/dsh-client-locale": "0.1.0-rc.8",
"@deepseek-ai/dsh-client-runtime": "0.1.0-rc.8",
"@deepseek-ai/dsh-client-ui-conversation": "0.1.0-rc.8",
"@deepseek-ai/dsh-client-ui-primitives": "0.1.0-rc.8",
"@deepseek-ai/dsh-client-ui-settings": "0.1.0-rc.8",
"@deepseek-ai/dsh-client-ui-slots": "0.1.0-rc.8",
"@deepseek-ai/dsh-host-webserver": "0.1.0-rc.8",
"@deepseek-ai/dsh-invariants": "0.1.0-rc.8",
"@deepseek-ai/dsh-llm": "0.1.0-rc.8",
"@deepseek-ai/dsh-session": "0.1.0-rc.8",
"@deepseek-ai/dsh-settings": "0.1.0-rc.8",
"@deepseek-ai/dsh-tools": "0.1.0-rc.8",
"@playwright/test": "^1.62.1",
"@types/node": "^24.0.0",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.1",
"@types/ws": "^8.5.10",
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"cordis": "^4.0.0-rc.7",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"cordis": "^4.0.0-rc.8",
"jsdom": "^29.1.1",
"lightningcss": "^1.32.0",
"react": "^18.2.0",
+39
View File
@@ -0,0 +1,39 @@
/**
* Playwright config for the headless-render mount lane (tests/e2e).
*
* This lane does NOT spawn the server itself: `scripts/e2e-mount.sh` boots a
* real `dsh web` instance (with the npm-packed plugin mounted through the
* official `dsh plugin add` channel) and injects the base URL through
* `DSH_E2E_URL`. The spec's job is to render that URL in a headless Chromium
* and prove the plugin mounts without crashing the shell.
*
* Specs are named `*.e2e.ts` (not `*.spec.ts`) so vitest's default include
* never collects them; vitest.config.ts excludes the directory as well.
*/
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
// The lane's specs are named *.e2e.ts (so vitest's default include never
// collects them); tell Playwright to pick exactly that shape.
testMatch: '**/*.e2e.ts',
// A deterministic gate: no retries, one worker, one browser. The whole lane
// is a crash probe against one server instance, so serial execution keeps
// the assertions meaningful (a crash would trip the very next check).
retries: 0,
workers: 1,
fullyParallel: false,
timeout: 120_000,
reporter: [
['list'],
// Local debugging artifact; CI uploads the report dir on failure.
['html', { open: 'never' }],
],
use: {
browserName: 'chromium',
headless: true,
viewport: { width: 1440, height: 900 },
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
},
})
+2071 -1080
View File
File diff suppressed because it is too large Load Diff
+1 -58
View File
@@ -9,6 +9,7 @@ allowBuilds:
protobufjs: true
minimumReleaseAgeExclude:
- '@deepseek-ai/*'
- '@deepseek-ai/cordis-plugin-include@1.0.5-rc.1'
- '@deepseek-ai/cordis-plugin-loader@1.0.1-rc.1'
- '@deepseek-ai/cordis@4.0.1-rc.1'
@@ -69,62 +70,4 @@ minimumReleaseAgeExclude:
- '@deepseek-ai/cordis-plugin-loader@1.0.2'
- '@deepseek-ai/cordis@4.0.1'
- '@deepseek-ai/cosmokit@1.8.2'
- '@deepseek-ai/dsh-agent-default-model@0.1.0-rc.6'
- '@deepseek-ai/dsh-agent-presets@0.1.0-rc.6'
- '@deepseek-ai/dsh-agent@0.1.0-rc.6'
- '@deepseek-ai/dsh-api-gateway@0.1.0-rc.6'
- '@deepseek-ai/dsh-api-remotes@0.1.0-rc.6'
- '@deepseek-ai/dsh-atomic-write@0.1.0-rc.6'
- '@deepseek-ai/dsh-attachment@0.1.0-rc.6'
- '@deepseek-ai/dsh-brand@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-connection@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-locale@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-runtime@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-schema-form@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-ui-attachment@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-ui-conversation@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-ui-input-trigger@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-ui-primitives@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-ui-settings@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-ui-slots@0.1.0-rc.6'
- '@deepseek-ai/dsh-client-web-react@0.1.0-rc.6'
- '@deepseek-ai/dsh-code-runtime@0.1.0-rc.6'
- '@deepseek-ai/dsh-commands@0.1.0-rc.6'
- '@deepseek-ai/dsh-compaction@0.1.0-rc.6'
- '@deepseek-ai/dsh-cordis-host-runner@0.1.0-rc.6'
- '@deepseek-ai/dsh-credentials@0.1.0-rc.6'
- '@deepseek-ai/dsh-goal@0.1.0-rc.6'
- '@deepseek-ai/dsh-home-paths@0.1.0-rc.6'
- '@deepseek-ai/dsh-host-apiproxy@0.1.0-rc.6'
- '@deepseek-ai/dsh-host-directory-picker@0.1.0-rc.6'
- '@deepseek-ai/dsh-host-plugin-inventory@0.1.0-rc.6'
- '@deepseek-ai/dsh-host-webserver@0.1.0-rc.6'
- '@deepseek-ai/dsh-invariants@0.1.0-rc.6'
- '@deepseek-ai/dsh-jobs@0.1.0-rc.6'
- '@deepseek-ai/dsh-llm-retry@0.1.0-rc.6'
- '@deepseek-ai/dsh-llm@0.1.0-rc.6'
- '@deepseek-ai/dsh-message-feedback@0.1.0-rc.6'
- '@deepseek-ai/dsh-native-command@0.1.0-rc.6'
- '@deepseek-ai/dsh-scope@0.1.0-rc.6'
- '@deepseek-ai/dsh-session-persistence@0.1.0-rc.6'
- '@deepseek-ai/dsh-session-projection-cache@0.1.0-rc.6'
- '@deepseek-ai/dsh-session-projection@0.1.0-rc.6'
- '@deepseek-ai/dsh-session-query@0.1.0-rc.6'
- '@deepseek-ai/dsh-session-stats@0.1.0-rc.6'
- '@deepseek-ai/dsh-session-title@0.1.0-rc.6'
- '@deepseek-ai/dsh-session@0.1.0-rc.6'
- '@deepseek-ai/dsh-settings@0.1.0-rc.6'
- '@deepseek-ai/dsh-skill@0.1.0-rc.6'
- '@deepseek-ai/dsh-storage-domain@0.1.0-rc.6'
- '@deepseek-ai/dsh-storage@0.1.0-rc.6'
- '@deepseek-ai/dsh-subagent@0.1.0-rc.6'
- '@deepseek-ai/dsh-system-prompt@0.1.0-rc.6'
- '@deepseek-ai/dsh-timeout@0.1.0-rc.6'
- '@deepseek-ai/dsh-token-meter@0.1.0-rc.6'
- '@deepseek-ai/dsh-tools@0.1.0-rc.6'
- '@deepseek-ai/dsh-typert-protocol@0.1.0-rc.6'
- '@deepseek-ai/dsh-typert-registry@0.1.0-rc.6'
- '@deepseek-ai/dsh-user-approval@0.1.0-rc.6'
- '@deepseek-ai/dsh-user-questions@0.1.0-rc.6'
- '@deepseek-ai/dsh-workspace@0.1.0-rc.6'
- '@deepseek-ai/schemastery@3.18.1'
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env bash
# =============================================================================
# dsh-better-sidebar 聚合双挂载冒烟(CI + 本地):
#
# 场景:一个聚合 bundle(tests/fixtures/aggregate-better-sidebar)先以独立
# 条目 id 挂载 dsh-better-sidebar,插件自身的 bundle patch 后到 —— 旧行为
# 两个实例都注册 /sidebar/api,整个插件树启动失败(duplicate prefix
# route);新行为插件行检测到已有启用中的同包名挂载后自动 disabled,由
# 聚合行接管 sidebar。
#
# 流程:
# 1. 打包 fixture 聚合包;
# 2. 用官方 CLI 把 fixture 先装进全新 scratch profile,再把插件 tarball
# 装进同一 profile(复刻真实安装顺序:聚合先行、dsh plugin add 后到);
# 3. 启动真实 `dsh web`(keyless,--port 0);
# 4. 断言:日志出现启动 URL、无 "duplicate prefix route"、/sidebar/api
# 路由存活(POST 未知方法应返回 404 JSON 而非崩溃)。
#
# 用法:
# bash scripts/e2e-aggregate-mount.sh
#
# 环境变量(均可省略):
# DSH_CMD dsh 命令;缺省 `npx -y --package @deepseek-ai/dsh dsh`
# (与 pm2 启动器同源,避免依赖可能失效的 PATH dsh)
# TARBALL 插件 tarball;缺省仓库根 dsh-better-sidebar-*.tgz(须已 pack)
# PORT 固定端口(默认 0 = OS 分配,从日志解析 URL)
# DSH_HOME_BASE scratch 根目录(默认系统临时目录)。脚本始终在其下新建
# 本调用拥有的独立子目录,只写入/删除该子目录;调用方提供
# 的目录本身(可能是真实 ~/.dsh)绝不写入或删除。
# KEEP_HOME 非空时保留 scratch 目录(调试用)
#
# 退出码 = 0 通过;非 0 失败。服务器与 scratch 目录由 trap 兜底清理。
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
FIXTURE_DIR="$ROOT/tests/fixtures/aggregate-better-sidebar"
DSH_CMD="${DSH_CMD:-npx -y --package @deepseek-ai/dsh dsh}"
TARBALL="${TARBALL:-}"
PORT="${PORT:-0}"
KEEP_HOME="${KEEP_HOME:-}"
say() { printf '\033[32m[e2e-aggregate-mount]\033[0m %s\n' "$*"; }
warn() { printf '\033[33m[e2e-aggregate-mount]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[31m[e2e-aggregate-mount]\033[0m %s\n' "$*" >&2; exit 1; }
command -v node >/dev/null 2>&1 || die "未找到 node(DSH 运行需要 Node.js >= 20)"
command -v pnpm >/dev/null 2>&1 || die "未找到 pnpm(dsh plugin 转发给 pnpm)"
command -v npm >/dev/null 2>&1 || die "未找到 npm(打包 fixture 需要)"
command -v curl >/dev/null 2>&1 || die "未找到 curl"
if [ -z "$TARBALL" ]; then
TARBALL="$(ls "$ROOT"/dsh-better-sidebar-*.tgz 2>/dev/null | head -1 || true)"
fi
[ -n "$TARBALL" ] && [ -f "$TARBALL" ] || die "未找到插件 tarball:先在本仓库跑 pnpm build && npm pack,或用 TARBALL 指定"
# ── scratch home ────────────────────────────────────────────────────────────
# 始终在本调用拥有的全新目录里运行:调用方给了 DSH_HOME_BASE(可能是真实
# ~/.dsh)时,只在其下新建子目录并只删除该子目录;缺省时直接用系统临时
# 目录。调用方提供的目录本身绝不写入或删除。
DSH_HOME_BASE="${DSH_HOME_BASE:-}"
if [ -n "$DSH_HOME_BASE" ]; then
SCRATCH="$(mktemp -d "$DSH_HOME_BASE/dsh-e2e-agg.XXXXXX")"
else
SCRATCH="$(mktemp -d /tmp/dsh-e2e-agg.XXXXXX)"
fi
export DSH_HOME="$SCRATCH"
LOG_DIR="$DSH_HOME/logs"; mkdir -p "$LOG_DIR"
OUT_LOG="$LOG_DIR/dsh-web.out.log"; ERR_LOG="$LOG_DIR/dsh-web.err.log"
SERVER_PID=""
TMP_DIR=""
cleanup() {
if [ -n "$SERVER_PID" ] && kill -0 "$SERVER_PID" 2>/dev/null; then
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
fi
if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then
rm -rf "$TMP_DIR"
fi
if [ -z "$KEEP_HOME" ] && [ -d "$SCRATCH" ]; then
rm -rf "$SCRATCH"
else
warn "KEEP_HOME 已设置,保留 $SCRATCH"
fi
}
trap cleanup EXIT
# ── 打包 fixture 聚合包 ─────────────────────────────────────────────────────
TMP_DIR="$(mktemp -d)"
( cd "$FIXTURE_DIR" && npm pack --silent --pack-destination "$TMP_DIR" >/dev/null )
FIXTURE_TGZ="$(ls "$TMP_DIR"/*.tgz 2>/dev/null | head -1 || true)"
[ -n "$FIXTURE_TGZ" ] || die "fixture 打包失败"
# ── 引导 scratch profile(web 模板)────────────────────────────────────────
# 先写 pnpm-workspace.yaml 的 allowBuilds / minimumReleaseAgeExclude,避免
# pnpm 11 strict-dep-builds 拦截 node-pty/protobufjs——同 e2e-mount.sh。
PROFILE_DIR="$DSH_HOME/profiles/web"
mkdir -p "$PROFILE_DIR"
cat > "$PROFILE_DIR/package.json" <<EOF
{
"name": "dsh-profile-web",
"private": true,
"dependencies": {},
"dsh": {
"profile": {
"bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"]
}
}
}
EOF
printf '[]\n' > "$PROFILE_DIR/cordis.patch.yml"
cat > "$PROFILE_DIR/pnpm-workspace.yaml" <<'EOF'
packages:
- .
nodeLinker: hoisted
autoInstallPeers: false
allowBuilds:
node-pty: true
protobufjs: true
minimumReleaseAgeExclude:
- dsh-better-sidebar
EOF
# ── 组装 profile:聚合先行,插件后到 ──────────────────────────────────────
say "安装 fixture 聚合包(先行)…"
$DSH_CMD plugin --profile web add "file:$FIXTURE_TGZ"
say "安装插件 tarball(后到)…"
$DSH_CMD plugin --profile web add "file:$TARBALL"
# ── 启动真实 dsh web ───────────────────────────────────────────────────────
say "启动 dsh web(--port ${PORT},日志 ${LOG_DIR})…"
$DSH_CMD web --port "$PORT" >"$OUT_LOG" 2>"$ERR_LOG" &
SERVER_PID=$!
# 等待启动 URL 或进程退出(最多 120s)
URL=""
for _ in $(seq 1 120); do
if ! kill -0 "$SERVER_PID" 2>/dev/null; then break; fi
URL="$(grep -oE 'http://127\.0\.0\.1:[0-9]+' "$OUT_LOG" | tail -1 || true)"
[ -n "$URL" ] && break
sleep 1
done
if [ -z "$URL" ]; then
warn "out log 尾部:$(tail -5 "$OUT_LOG" 2>/dev/null || true)"
warn "err log 尾部:$(tail -5 "$ERR_LOG" 2>/dev/null || true)"
die "dsh web 未在 120s 内启动"
fi
say "已启动:$URL"
# ── 断言 ────────────────────────────────────────────────────────────────────
if grep -q "duplicate prefix route" "$ERR_LOG" "$OUT_LOG" 2>/dev/null; then
die "检测到 duplicate prefix route(双挂载未退让)"
fi
# 真实方法探活:terminal.deps 是插件自己的 handler(writeOk → HTTP 200 +
# {"ok":true,...})。成功响应证明至少一个实例真正注册了 /sidebar/api 路由
# ——generic missing-route 404 无法冒充(P2: Probe a real sidebar API method)。
DEPS="$(curl -s -X POST "$URL/sidebar/api/terminal.deps" 2>/dev/null || true)"
if ! printf '%s' "$DEPS" | grep -q '"ok":true'; then
die "/sidebar/api/terminal.deps 未返回 ok:true(响应:$(printf '%s' "$DEPS" | head -c 200))"
fi
say "/sidebar/api/terminal.deps → ok:true(真实 handler 存活)"
STATUS="$(curl -s -o /dev/null -w '%{http_code}' -X POST "$URL/sidebar/api/__e2e_unknown__" 2>/dev/null || true)"
say "/sidebar/api POST 未知方法 → HTTP ${STATUS}(期望 404)"
[ "$STATUS" = "404" ] || die "/sidebar/api 未知方法未按预期返回 404(HTTP ${STATUS})"
say "通过:聚合双挂载场景下插件自动退让,侧边栏由聚合行接管。"
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env bash
# =============================================================================
# dsh-better-sidebar 挂载冒烟编排(CI + 本地):
#
# 1. 用官方 CLI 把 npm 打包产物(tarball)真实挂载进一个全新 scratch
# profile(`dsh plugin --profile web add file:<tarball>`,触发
# dsh.profile.bundles 协调,与用户安装路径一致);
# 2. 启动真实 `dsh web`(keyless,--port 0 取 OS 分配端口);
# 3. 运行 tests/e2e 无头渲染 lane(Playwright Chromium):断言外壳与
# 插件挂载、无崩溃标记,并驱动内置 tab 深扫。
#
# 用法:
# bash scripts/e2e-mount.sh [--grep <playwright-filter>]
#
# 环境变量(均可省略):
# DSH_CMD dsh 命令;缺省 PATH 上的 `dsh`,回退 npx 拉官方包
# TARBALL 插件 tarball;缺省仓库根 dsh-better-sidebar-*.tgz(须已 pack)
# PORT 固定端口(默认 0 = OS 分配,从日志解析 URL)
# DSH_HOME_BASE 覆盖 scratch 根目录(默认系统临时目录)。脚本始终在其下
# 新建本调用拥有的独立子目录,只写入/删除该子目录;调用方
# 提供的目录本身(可能是真实 ~/.dsh)绝不写入或删除。
# KEEP_HOME 非空时保留 scratch home(调试用)
#
# 退出码 = playwright 的退出码;服务器与 scratch 目录由 trap 兜底清理。
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
DSH_CMD="${DSH_CMD:-dsh}"
PORT="${PORT:-0}"
TARBALL="${TARBALL:-}"
GREP_FILTER=""
if [ "${1:-}" = "--grep" ]; then GREP_FILTER="${2:?--grep 需要参数}"; fi
say() { printf '\033[32m[e2e-mount]\033[0m %s\n' "$*"; }
warn() { printf '\033[33m[e2e-mount]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[31m[e2e-mount]\033[0m %s\n' "$*" >&2; exit 1; }
command -v node >/dev/null 2>&1 || die "未找到 node(DSH 运行需要 Node.js >= 20)"
command -v pnpm >/dev/null 2>&1 || die "未找到 pnpm(dsh plugin 转发给 pnpm)"
# dsh CLI 解析:PATH 上的 dsh 优先,否则 npx 拉官方包(同 scripts/install.sh)
if ! command -v "$DSH_CMD" >/dev/null 2>&1; then
if command -v npx >/dev/null 2>&1; then
say "PATH 上无 $DSH_CMD,回退 npx -y --package @deepseek-ai/dsh"
DSH_CMD="npx -y --package @deepseek-ai/dsh dsh"
else
die "未找到 $DSH_CMD 或 npx;请先安装 DSH CLI(npm i -g @deepseek-ai/dsh)或用 DSH_CMD 指定"
fi
fi
# tarball 解析
if [ -z "$TARBALL" ]; then
TARBALL="$(ls "$ROOT"/dsh-better-sidebar-*.tgz 2>/dev/null | head -1 || true)"
fi
[ -n "$TARBALL" ] && [ -f "$TARBALL" ] || die "找不到 tarball(TARBALL 或 \$ROOT/dsh-better-sidebar-*.tgz)——先运行 pnpm build && pnpm pack"
TARBALL="$(cd "$(dirname "$TARBALL")" && pwd)/$(basename "$TARBALL")"
say "tarball: $TARBALL"
# scratch home(每次全新,绝不触碰真实 ~/.dsh):调用方给了 DSH_HOME_BASE
# 时,只在其下新建本调用拥有的子目录并只删除该子目录;缺省时直接用系统
# 临时目录。
if [ -n "${DSH_HOME_BASE:-}" ]; then
SCRATCH="$(mktemp -d "$DSH_HOME_BASE/dsh-e2e-mount.XXXXXX")"
else
SCRATCH="$(mktemp -d /tmp/dsh-e2e-mount.XXXXXX)"
fi
export DSH_HOME="$SCRATCH/home"
WORKSPACE_DIR="$SCRATCH/workspace"
LOG_DIR="$SCRATCH"
WEB_LOG="$LOG_DIR/web.log"
mkdir -p "$DSH_HOME/profiles/web" "$WORKSPACE_DIR"
say "scratch home: ${DSH_HOME}(DSH_HOME=${DSH_HOME})"
SERVER_PID=""
cleanup() {
local code=$?
if [ -n "$SERVER_PID" ] && kill -0 "$SERVER_PID" 2>/dev/null; then
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
fi
if [ -z "${KEEP_HOME:-}" ]; then
rm -rf "$SCRATCH"
else
warn "KEEP_HOME 已设置,保留 $SCRATCH"
fi
exit "$code"
}
trap cleanup EXIT
# 步骤 1:引导 scratch profile(web 模板,镜像 dsh initProfile;先写
# pnpm-workspace.yaml 的 allowBuilds / minimumReleaseAgeExclude,避免 pnpm 11
# strict-dep-builds 拦截 node-pty/protobufjs 或拒绝 <24h 新版本——同 install.sh)
PROFILE_DIR="$DSH_HOME/profiles/web"
cat > "$PROFILE_DIR/package.json" <<EOF
{
"name": "dsh-profile-web",
"private": true,
"dependencies": {},
"dsh": {
"profile": {
"bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"]
}
}
}
EOF
printf '[]\n' > "$PROFILE_DIR/cordis.patch.yml"
cat > "$PROFILE_DIR/pnpm-workspace.yaml" <<'EOF'
packages:
- .
nodeLinker: hoisted
autoInstallPeers: false
allowBuilds:
node-pty: true
protobufjs: true
minimumReleaseAgeExclude:
- dsh-better-sidebar
EOF
# 步骤 2:官方 CLI 安装 tarball + bundle 协调(真实挂载路径)
say "执行 dsh plugin --profile web add file:$TARBALL ..."
$DSH_CMD plugin --profile web add "file:$TARBALL"
# 步骤 3:校验挂载生效(dsh.profile.bundles 含 dsh-better-sidebar)
if ! node -e '
const fs = require("fs");
const p = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
const bundles = p.dsh?.profile?.bundles ?? [];
process.exit(bundles.includes("dsh-better-sidebar") ? 0 : 1);
' "$PROFILE_DIR/package.json"; then
warn "dsh-better-sidebar 未出现在 dsh.profile.bundles 中——挂载未注册"
cat "$PROFILE_DIR/package.json"
exit 1
fi
say "挂载已注册:dsh.profile.bundles 包含 dsh-better-sidebar"
# 步骤 4:启动 dsh web(--port 0 = OS 分配,避免端口冲突;keyless 可起)
say "启动 dsh web(port=${PORT})..."
$DSH_CMD web --port "$PORT" > "$WEB_LOG" 2>&1 &
SERVER_PID=$!
URL=""
for _ in $(seq 1 120); do
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
echo "=== dsh web 提前退出,日志尾部 ===" >&2
tail -30 "$WEB_LOG" >&2 || true
exit 1
fi
if URL="$(grep -oE 'dsh web: http://127\.0\.0\.1:[0-9]+' "$WEB_LOG" | head -1 | awk '{print $3}')" && [ -n "$URL" ]; then
break
fi
sleep 1
done
[ -n "$URL" ] || { echo "=== 120s 内未等到 dsh web 就绪,日志尾部 ===" >&2; tail -40 "$WEB_LOG" >&2 || true; exit 1; }
say "dsh web 就绪:${URL}(pid ${SERVER_PID})"
# 步骤 5:运行无头渲染 lane
say "运行 Playwright 无头渲染 lane..."
DSH_E2E_URL="$URL" DSH_E2E_WORKSPACE="$WORKSPACE_DIR" \
pnpm exec playwright test ${GREP_FILTER:+--grep "$GREP_FILTER"}
say "通过:插件挂载到真实 DSH 后无头渲染未崩溃"
+87 -49
View File
@@ -1,4 +1,4 @@
# =============================================================================
## =============================================================================
# dsh-better-sidebar 一键安装脚本(官方 CLI 方式,Windows PowerShell 5.1+ / pwsh)
#
# 通过 DSH 官方插件命令安装 npm 包并自动挂载:
@@ -16,9 +16,15 @@
# & ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.10.2 -Restart
# # 本地保存后运行
# powershell -ExecutionPolicy Bypass -File install.ps1 -Version 0.10.2 -DryRun
# # 修复 node-pty 依赖(终端提示「node-pty 加载失败」时用,见 issue #140)
# powershell -ExecutionPolicy Bypass -File install.ps1 -Repair
#
# 参数:
# -Version npm 版本号/范围,缺省 latest(自动解析为最新)。
# -Repair 修复模式:不重装插件,只确保 profile 的 pnpm-workspace.yaml
# 放行 node-pty 构建脚本,然后重跑 pnpm install + pnpm rebuild
# node-pty。
# -Profile 目标 profile 名(缺省 web);安装与修复模式均适用。
# -Restart 装完后尝试 `pm2 restart dsh-web`(无 pm2 时仅提示)。会断开当前页面会话。
# -DryRun 只打印将要执行的操作,不写任何文件。
#
@@ -39,7 +45,9 @@
param(
[string]$Version = '',
[switch]$Restart,
[switch]$DryRun
[switch]$DryRun,
[switch]$Repair,
[string]$Profile = 'web'
)
$PKG = 'dsh-better-sidebar'
@@ -53,7 +61,7 @@ if ($env:DSH_HOME) {
} else {
$DSH_HOME = Join-Path $HOME '.dsh'
}
$PROFILE_DIR = Join-Path $DSH_HOME 'profiles\web'
$PROFILE_DIR = Join-Path $DSH_HOME "profiles\$Profile"
$WS_YML = Join-Path $PROFILE_DIR 'pnpm-workspace.yaml'
$PATCH_YML = Join-Path $PROFILE_DIR 'cordis.patch.yml'
@@ -88,35 +96,10 @@ function Get-DshCli {
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 = @'
# 步骤 1(安装与修复共用):预写 workspace 设置(幂等),保证 pnpm 不拦截
# node-pty/protobufjs 构建脚本、放行本插件新版本
function Ensure-WorkspaceSettings {
$wsScript = @'
const fs = require("fs");
const p = process.argv[2];
let t = fs.readFileSync(p, "utf8");
@@ -141,29 +124,84 @@ if (!/^\s*-\s+dsh-better-sidebar\s*$/m.test(t)) {
if (t !== before) fs.writeFileSync(p, t);
console.log(t === before ? "unchanged" : "updated");
'@
# PowerShell 5.1 把含内嵌双引号的多行 JS 作为参数传给 `node -e` 时,引号会被
# Windows 命令行解析吞掉,导致 JS 语法错误(Expected ',', got ':')。
# 改用临时文件方式,兼容 PS 5.1 与 pwsh 7。
$wsJs = Join-Path $env:TEMP ("dshbs-ws-" + [guid]::NewGuid().ToString("N") + ".js")
Set-Content -LiteralPath $wsJs -Value $wsScript -Encoding UTF8
$wsOut = node $wsJs "$WS_YML" 2>&1
$wsCode = $LASTEXITCODE
Remove-Item -LiteralPath $wsJs -Force -ErrorAction SilentlyContinue
$wsResult = (($wsOut | Out-String)).Trim()
if ($wsCode -ne 0) { Die "处理 $WS_YML 失败(node 退出码 $wsCode):$wsResult" }
if ($wsResult -eq 'updated') {
Say "已确保 $WS_YML:allowBuilds(node-pty/protobufjs: true)+ minimumReleaseAgeExclude($PKG)"
} else {
Say 'workspace 设置已就绪,跳过'
# PowerShell 5.1 把含内嵌双引号的多行 JS 作为参数传给 `node -e` 时,引号会被
# Windows 命令行解析吞掉,导致 JS 语法错误(Expected ',', got ':')。
# 改用临时文件方式,兼容 PS 5.1 与 pwsh 7。
$wsJs = Join-Path $env:TEMP ("dshbs-ws-" + [guid]::NewGuid().ToString("N") + ".js")
Set-Content -LiteralPath $wsJs -Value $wsScript -Encoding UTF8
$wsOut = node $wsJs "$WS_YML" 2>&1
$wsCode = $LASTEXITCODE
Remove-Item -LiteralPath $wsJs -Force -ErrorAction SilentlyContinue
$wsResult = (($wsOut | Out-String)).Trim()
if ($wsCode -ne 0) { Die "处理 $WS_YML 失败(node 退出码 $wsCode):$wsResult" }
if ($wsResult -eq 'updated') {
Say "已确保 $WS_YML:allowBuilds(node-pty/protobufjs: true)+ minimumReleaseAgeExclude($PKG)"
} else {
Say 'workspace 设置已就绪,跳过'
}
}
# 前置校验
if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
Die '未找到 node(DSH 运行需要 Node.js >= 20),请先安装 Node.js 并加入 PATH。'
}
if (-not (Test-Path $PROFILE_DIR)) {
Die "找不到 profile 目录:$PROFILE_DIR(请先安装并运行过一次 dsh web)"
}
if (-not (Test-Path $WS_YML)) {
Die "找不到 $WS_YML(请先初始化 $Profile profile)"
}
# ── 修复模式(issue #140):不重装插件,只修复 node-pty 依赖 ────────────
# 终端提示「node-pty 加载失败」时运行:确保 allowBuilds 后重跑
# pnpm install + pnpm rebuild node-pty(重放被 pnpm 11 拦截的构建脚本)。
if ($Repair) {
if ($DryRun) {
Say "[dry-run] 修复:确保 $WS_YML 含 allowBuilds(node-pty: true)"
Say "[dry-run] 修复:cd $PROFILE_DIR; pnpm install; pnpm rebuild node-pty"
exit 0
}
Say "修复模式:重装 node-pty(profile: $Profile,$PROFILE_DIR)..."
Ensure-WorkspaceSettings
Push-Location $PROFILE_DIR
try {
pnpm install
if ($LASTEXITCODE -ne 0) { Die "pnpm install 失败(退出码 $LASTEXITCODE)。请确认 pnpm 在 PATH 上、网络可用,然后重试。" }
pnpm rebuild node-pty
if ($LASTEXITCODE -ne 0) { Die "pnpm rebuild node-pty 失败(退出码 $LASTEXITCODE)。请确认 pnpm 在 PATH 上,然后重试。" }
} finally {
Pop-Location
}
Say '修复完成:node-pty 已重装(与 DSH 核心保持同一版本)。请重启 DSH 后重试终端。'
exit 0
}
$SPEC = Resolve-Spec $Version
$CLI = Get-DshCli
if (-not $CLI) {
Die '未找到 dsh 或 npx。请先安装 DSH(并确保 Node/npm 可用),或用 DSH_CMD 指定。'
}
Say "目标:$CLI plugin --profile $Profile add $PKG@$SPEC(profile: $PROFILE_DIR)"
if ($DryRun) {
Say "[dry-run] 步骤 1:确保 $WS_YML 含 allowBuilds(node-pty/protobufjs: true)与 minimumReleaseAgeExclude($PKG)"
Say "[dry-run] 步骤 2:执行 $CLI plugin --profile $Profile add $PKG@$SPEC(安装 + bundle 自动注册)"
Say "[dry-run] 步骤 3:校验 dsh.profile.bundles 含 $PKG"
Say "[dry-run] 步骤 4:幂等移除 $PATCH_YML 里旧的 better-sidebar 手动挂载行(避免双挂载)"
if ($Restart) { Say '[dry-run] 步骤 5:pm2 restart dsh-web' } else { Say '[dry-run] 步骤 5:提示用户手动重启 DSH' }
exit 0
}
# 步骤 1:预写 workspace 设置(幂等),保证 pnpm 不拦截构建、放行本插件新版本
Ensure-WorkspaceSettings
# 步骤 2:官方 CLI 安装 + bundle 自动注册(含挂载)
if ($CLI -eq 'dsh') {
$cliArgs = @('plugin', '--profile', 'web', 'add', "$PKG@$SPEC")
$cliArgs = @('plugin', '--profile', $Profile, 'add', "$PKG@$SPEC")
} else {
$cliArgs = @('-y', '--package', '@deepseek-ai/dsh', 'dsh', 'plugin', '--profile', 'web', 'add', "$PKG@$SPEC")
$cliArgs = @('-y', '--package', '@deepseek-ai/dsh', 'dsh', 'plugin', '--profile', $Profile, 'add', "$PKG@$SPEC")
}
Say "执行 $CLI plugin --profile web add $PKG@$SPEC ..."
Say "执行 $CLI plugin --profile $Profile add $PKG@$SPEC ..."
$addOut = & $CLI @cliArgs 2>&1
$addCode = $LASTEXITCODE
$addOut | ForEach-Object { $_ }
+85 -44
View File
@@ -12,9 +12,14 @@
#
# 用法:
# bash scripts/install.sh [版本] [--restart] [--dry-run]
# bash scripts/install.sh --repair [--profile <名>] [--dry-run]
#
# 版本 npm 版本号/范围,缺省为 latest(自动解析为 ^<最新>)。
# 示例:0.10.2、^0.10.2、~0.10.2、latest
# --repair 修复模式:不重装插件,只确保 profile 的 pnpm-workspace.yaml
# 放行 node-pty 构建脚本,然后重跑 pnpm install + pnpm rebuild
# node-pty(终端提示「node-pty 加载失败」时用它,见 issue #140)。
# --profile 目标 profile 名(缺省 web);安装与修复模式均适用。
# --restart 装完后尝试 `pm2 restart dsh-web`(无 pm2 时仅打印提示)。
# 注意:重启会断开当前 DSH 页面会话,默认不自动重启。
# --dry-run 只打印将要执行的操作,不写任何文件。
@@ -42,11 +47,15 @@ set -euo pipefail
for arg in "$@"; do
if [ "$arg" = "-h" ] || [ "$arg" = "--help" ]; then
cat <<'EOF'
dsh-better-sidebar 一键安装脚本
dsh-better-sidebar 一键安装 / 依赖修复脚本
用法:bash scripts/install.sh [版本] [--restart] [--dry-run]
用法:
bash scripts/install.sh [版本] [--restart] [--dry-run] [--profile <名>]
bash scripts/install.sh --repair [--profile <名>] [--dry-run]
版本 npm 版本号/范围,缺省 latest(自动解析为最新)。示例:0.10.2、^0.10.2、latest
--repair 修复模式:确保 profile 放行 node-pty 构建脚本并重装 node-pty(终端提示依赖加载失败时用)
--profile 目标 profile 名(缺省 web)
--restart 装完后尝试 `pm2 restart dsh-web`(无 pm2 时仅提示)
--dry-run 只打印将要执行的操作,不写任何文件
@@ -57,30 +66,73 @@ EOF
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
REPAIR=false
VERSION_SPEC=""
for arg in "$@"; do
case "$arg" in
PROFILE_NAME="web"
while [ $# -gt 0 ]; do
case "$1" in
--restart) RESTART=true ;;
--dry-run) DRY_RUN=true ;;
--repair) REPAIR=true ;;
--profile)
if [ $# -lt 2 ]; then echo "--profile 需要一个 profile 名(如 web)" >&2; exit 2; fi
PROFILE_NAME="$2"; shift ;;
-h|--help) : ;; # 已在上面处理
-*) echo "未知参数: ${arg}(用 -h 查看用法)" >&2; exit 2 ;;
*) VERSION_SPEC="$arg" ;;
-*) echo "未知参数: ${1}(用 -h 查看用法)" >&2; exit 2 ;;
*) VERSION_SPEC="$1" ;;
esac
shift
done
PROFILE_DIR="$DSH_HOME/profiles/$PROFILE_NAME"
WS_YML="$PROFILE_DIR/pnpm-workspace.yaml"
PATCH_YML="$PROFILE_DIR/cordis.patch.yml"
say() { printf '\033[32m[install]\033[0m %s\n' "$*"; }
warn() { printf '\033[33m[warn]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[31m[error]\033[0m %s\n' "$*" >&2; exit 1; }
# 步骤 1(安装与修复共用):预写 workspace 设置(幂等),保证 pnpm 不拦截
# node-pty/protobufjs 构建脚本、放行本插件新版本
ensure_workspace_settings() {
WS_RESULT="$(node -e '
const fs = require("fs");
const p = process.argv[1];
let t = fs.readFileSync(p, "utf8");
const before = t;
// allowBuilds:把 node-pty/protobufjs 归位为 true
t = t.replace(/^(\s*)(node-pty|protobufjs):.*$/gm, "$1$2: true");
if (!/^\s*allowBuilds:\s*$/m.test(t)) {
t += "\nallowBuilds:\n node-pty: true\n protobufjs: true\n";
} else {
for (const k of ["node-pty", "protobufjs"]) {
if (!new RegExp("^\\s*" + k + ":\\s*true\\s*$", "m").test(t)) {
t = t.replace(/^(\s*allowBuilds:\s*)$/m, "$1\n " + k + ": true");
}
}
}
// minimumReleaseAgeExclude:放行本插件(版本无关),避免 <24h 新版本被拒
if (!/^\s*-\s+dsh-better-sidebar\s*$/m.test(t)) {
if (/^\s*minimumReleaseAgeExclude:\s*$/m.test(t)) {
t = t.replace(/^(\s*minimumReleaseAgeExclude:\s*)$/m, "$1\n - dsh-better-sidebar");
} else {
t += "\nminimumReleaseAgeExclude:\n - dsh-better-sidebar\n";
}
}
if (t !== before) fs.writeFileSync(p, t);
console.log(t === before ? "unchanged" : "updated");
' "$WS_YML")"
[ "$WS_RESULT" = "updated" ] \
&& say "已确保 ${WS_YML}:allowBuilds(node-pty/protobufjs: true)+ minimumReleaseAgeExclude(${PKG})" \
|| say "workspace 设置已就绪,跳过"
}
# 解析用户给的版本 -> CLI 要用的 npm spec("x.y.z" / "^x.y.z" / latest)
resolve_spec() {
local given="${1:-latest}"
@@ -120,15 +172,33 @@ dsh_cli() {
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)"
[ -f "$WS_YML" ] || die "找不到 ${WS_YML}(请先初始化 ${PROFILE_NAME} profile)"
# ── 修复模式(issue #140):不重装插件,只修复 node-pty 依赖 ────────────
# 终端提示「node-pty 加载失败」时运行:确保 allowBuilds 后重跑
# pnpm install + pnpm rebuild node-pty(重放被 pnpm 11 拦截的构建脚本)。
if [ "$REPAIR" = true ]; then
if [ "$DRY_RUN" = true ]; then
say "[dry-run] 修复:确保 $WS_YML 含 allowBuilds(node-pty: true)"
say "[dry-run] 修复:cd $PROFILE_DIR && pnpm install && pnpm rebuild node-pty"
exit 0
fi
say "修复模式:重装 node-pty(profile: ${PROFILE_NAME},${PROFILE_DIR})..."
ensure_workspace_settings
if ! (cd "$PROFILE_DIR" && pnpm install && pnpm rebuild node-pty); then
die "修复失败:pnpm install / pnpm rebuild node-pty 非零退出。请确认 pnpm 在 PATH 上、网络可用,然后重试。"
fi
say "修复完成:node-pty 已重装(与 DSH 核心保持同一版本)。请重启 DSH 后重试终端。"
exit 0
fi
SPEC="$(resolve_spec "$VERSION_SPEC")"
CLI="$(dsh_cli)"
say "目标:$CLI plugin --profile web add $PKG@${SPEC}(profile: ${PROFILE_DIR})"
say "目标:$CLI plugin --profile $PROFILE_NAME add $PKG@${SPEC}(profile: ${PROFILE_DIR})"
if [ "$DRY_RUN" = true ]; then
say "[dry-run] 步骤 1:确保 $WS_YML 含 allowBuilds(node-pty/protobufjs: true)与 minimumReleaseAgeExclude(${PKG})"
say "[dry-run] 步骤 2:执行 $CLI plugin --profile web add $PKG@${SPEC}(安装 + bundle 自动注册)"
say "[dry-run] 步骤 2:执行 $CLI plugin --profile $PROFILE_NAME add $PKG@${SPEC}(安装 + bundle 自动注册)"
say "[dry-run] 步骤 3:校验 dsh.profile.bundles 含 $PKG"
say "[dry-run] 步骤 4:幂等移除 $PATCH_YML 里旧的 better-sidebar 手动挂载行(避免双挂载)"
if [ "$RESTART" = true ]; then say "[dry-run] 步骤 5:pm2 restart dsh-web"; else say "[dry-run] 步骤 5:提示用户手动重启 DSH"; fi
@@ -136,40 +206,11 @@ if [ "$DRY_RUN" = true ]; then
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 设置已就绪,跳过"
ensure_workspace_settings
# 步骤 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
say "执行 $CLI plugin --profile $PROFILE_NAME add $PKG@$SPEC ..."
if ! $CLI plugin --profile "$PROFILE_NAME" add "$PKG@$SPEC" 2>&1 | tail -n +1; then
warn "dsh plugin add 失败。已预写 allowBuilds 与 minimumReleaseAgeExclude,仍失败的可能原因:"
warn " - 网络/登录问题:npm registry 不可达或需要登录。"
warn " - 依赖安装冲突:可手动重试 cd $PROFILE_DIR && pnpm install。"
+13 -5
View File
@@ -12,8 +12,9 @@
* Both registries share the same shell resolver and spawn-helper fix.
*/
import { randomUUID } from 'node:crypto'
import * as nodePty from 'node-pty'
import { ensureSpawnHelper } from './pty-manager.ts'
import type { IPty } from 'node-pty'
import { ensureSpawnHelper, shellSpawnArgs } from './pty-manager.ts'
import { loadRequiredNodePty, type NodePtyModule } from './pty-deps.ts'
import { SidebarError } from './wire.ts'
/** Per-agent-terminal transcript bound (bytes kept for replay and reads). */
@@ -106,7 +107,7 @@ export interface AgentTerminalHandle {
/** The working directory the process was spawned with. */
cwd: string
/** The live pty process. */
pty: nodePty.IPty
pty: IPty
/** Output accumulated since spawn (bounded; head dropped when over the limit). */
transcript: string
/** Whether the top-level process exited (transcript stays replayable). */
@@ -189,7 +190,12 @@ export class AgentPtyRegistry {
private readonly sessions = new Map<string, AgentTerminalHandle>()
private readonly changeListeners = new Set<() => void>()
constructor(private readonly shell: string) {
constructor(
private readonly shell: string,
private readonly shellArgs: string[] = [],
/** The loaded node-pty module (injected so a broken install degrades instead of crashing the plugin). */
private readonly nodePty: NodePtyModule = loadRequiredNodePty(),
) {
ensureSpawnHelper()
}
@@ -208,10 +214,12 @@ export class AgentPtyRegistry {
cwd: string,
cols = 80,
rows = 24,
shell?: string,
shellArgs?: string[],
): string {
const uuid = randomUUID()
const dims = clampDims(cols, rows)
const pty = nodePty.spawn(this.shell, [], {
const pty = this.nodePty.spawn(shell ?? this.shell, shellSpawnArgs(shellArgs ?? this.shellArgs), {
name: 'xterm-256color',
cols: dims.cols,
rows: dims.rows,
+1 -1
View File
@@ -19,7 +19,7 @@ import { fileURLToPath } from 'node:url'
import type { Context, SidebarHttpRequest, SidebarHttpResponse } from './context-types.ts'
/** The chunk names the client may request (mirror of src/client/chunk-loader.ts). */
export const CHUNK_NAMES = ['terminal', 'editor'] as const
export const CHUNK_NAMES = ['terminal', 'editor', 'mermaid'] as const
export type ChunkName = (typeof CHUNK_NAMES)[number]
/** Directory of this host-half module (lib/ — the chunk scripts live next to it). */
+1 -1
View File
@@ -90,7 +90,7 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
title={t('refresh')}
onClick={refresh}
>
<IconRefreshOutline16 />
<IconRefreshOutline16 size={14} />
</button>
</div>
{loading && <div className={css.gitPlaceholder}>{t('loading')}</div>}
+341 -23
View File
@@ -1,22 +1,43 @@
/**
* The editor tab host: resolves a file's previewer through the sidebar
* registry (`matchFileViewer`), fetches bytes per the matched viewer's
* fetch strategy, and renders its component — or the shared download pane
* when nothing can render the file. The header shows the file title; the
* editable code/markdown viewers render their own toolbar below it.
* The editor tab host: the single FILES WINDOW. It resolves a file's
* previewer through the sidebar registry (`matchFileViewer`), fetches bytes
* per the matched viewer's fetch strategy, and renders its component — or
* the shared download pane when nothing can render the file. A tab without
* a path (the seeded "Files" home) renders an empty-state hint instead of
* the viewer loading flow; that path-less window IS the file explorer.
*
* The chrome depends on the `editorExplorer` mode (read reactively so
* toggling it re-renders without a reload):
* - merged (in-place): tree click / path-input Enter switch the CURRENT
* tab in place (updateTab rewrites path/title; the tab keeps its id and
* meta, so treeOpen/treeWidth survive the switch);
* - split: they open through `openSidebarFile` (a per-path dedupe tab),
* and a PATH-LESS window is the standalone explorer — it renders ONLY
* the tree panel (search + FileTree, full-window), no editor chrome.
* Editor tabs (with a path) keep the full chrome in both modes.
* The tree's context menu offers the explicit escapes in both modes: open
* in a new tab (per-path dedupe) or to the side (a fresh tab in a fresh
* rightward split of the current pane).
*
* The strategy dispatch is pure (planFirstMatch / planFsReadOutcome in
* editor-load.ts); this component only wires it to the host APIs.
*/
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
import { createElement } from 'react'
import clsx from 'clsx'
import { IconCheckOutline16, IconFolderOpen16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../context-types.ts'
import { api, mediaUrl, type SessionScope } from './api.ts'
import { BinaryDownload } from './binary-download.tsx'
import { planFirstMatch, planFsReadOutcome, type EditorLoadAction } from './editor-load.ts'
import { baseName } from './FileTree.tsx'
import { openSidebarFile } from './intercept.tsx'
import { TreePanel } from './TreePanel.tsx'
import { t } from './locales.ts'
import type { FileViewerDescriptor } from './service.ts'
import type { SidebarStore } from './state.ts'
import { relativeTo } from './paths.ts'
import { resolveSidebarPath } from './produced-files.ts'
import type { EditorToolbarControls, EditorToolbarState, FileViewerDescriptor } from './service.ts'
import { firstLeaf, insertLeafAt, leafWithTab, mintTabId, treeOf, type SidebarStore, type SidebarTab } from './state.ts'
import css from './sidebar.module.css'
type EditorLoad =
@@ -25,11 +46,156 @@ type EditorLoad =
| { status: 'ready'; viewer: FileViewerDescriptor; content?: string; truncated?: boolean; mediaUrl?: string; customData?: unknown }
| { status: 'binary' }
export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: SessionScope; path: string; title: string }) {
const { ctx, store, scope, path, title } = props
/** The docked tree panel's width bounds (drag-resize clamps into them). */
const TREE_WIDTH_DEFAULT = 240
const TREE_WIDTH_MIN = 160
const TREE_WIDTH_MAX = 480
/** The tab's persisted meta object (a malformed meta reads as empty). */
function metaOf(tab: SidebarTab): Record<string, unknown> {
return tab.meta !== null && typeof tab.meta === 'object' && !Array.isArray(tab.meta)
? tab.meta as Record<string, unknown>
: {}
}
/** Read the persisted tree-panel flag of one editor tab: an explicit
* boolean meta wins; otherwise path-less tabs (the seeded home) default
* open and file tabs default closed. */
function treeOpenOf(tab: SidebarTab): boolean {
const treeOpen = metaOf(tab).treeOpen
return typeof treeOpen === 'boolean' ? treeOpen : (tab.path === undefined || tab.path === '')
}
/** Read the persisted tree-panel width (clamped; default 240). */
function treeWidthOf(tab: SidebarTab): number {
const width = metaOf(tab).treeWidth
return typeof width === 'number' && Number.isFinite(width)
? Math.min(TREE_WIDTH_MAX, Math.max(TREE_WIDTH_MIN, Math.round(width)))
: TREE_WIDTH_DEFAULT
}
/** Merge a patch into the tab's persisted meta (rides the layout). */
function patchMeta(ctx: Context, tab: SidebarTab, patch: Record<string, unknown>): void {
ctx.betterSidebar?.updateTab(tab.id, { meta: { ...metaOf(tab), ...patch } })
}
/** Clamp one dock width into the contract range. */
function clampTreeWidth(value: number): number {
return Math.min(TREE_WIDTH_MAX, Math.max(TREE_WIDTH_MIN, Math.round(value)))
}
export function EditorHost(props: {
ctx: Context
store: SidebarStore
scope: SessionScope
tab: SidebarTab
expanded: string[]
onToggleDir: (path: string) => void
onReferenceFile: (path: string) => void
}) {
const { ctx, store, scope, tab, expanded, onToggleDir, onReferenceFile } = props
const path = tab.path ?? ''
const title = tab.title
const [load, setLoad] = useState<EditorLoad>({ status: 'loading' })
// Reactive prefs read: flipping editorExplorer re-renders this tab with no
// reload. The snapshot is the bare boolean so unrelated store churn never
// re-renders the editor.
const inPlace = useSyncExternalStore(
useCallback((callback: () => void) => store.subscribe(callback), [store]),
useCallback(() => store.getSnapshot().prefs.editorExplorer, [store]),
)
// A path-less tab shows the empty-state hint in merged mode — and in split
// mode it is the standalone explorer (tree-only, see the render below).
const showEmpty = path === ''
const treeOnly = showEmpty && !inPlace
/**
* Open a file from THIS window (tree click / search row / path input):
* merged mode switches this tab in place (stable id, meta survives);
* split mode opens a per-path dedupe tab through openSidebarFile.
*/
const openFile = (absolute: string): void => {
if (inPlace) {
ctx.betterSidebar?.updateTab(tab.id, { path: absolute, title: baseName(absolute) })
} else {
openSidebarFile(ctx, store, scope.sessionId, absolute)
}
}
/** The context menu's explicit "new tab" escape (per-path dedupe). */
const openFileNewTab = (absolute: string): void => {
openSidebarFile(ctx, store, scope.sessionId, absolute)
}
/**
* The context menu's "open to the side": a fresh editor tab (uid id — the
* `'editor:' + path` convention would clash with the id safety net on a
* second side-open of the same file) in a rightward split of THIS pane.
*/
const openFileSide = (absolute: string): void => {
store.reduce((state) => {
const key = treeOf(state, tab.id)
const pane = leafWithTab(state[key], tab.id) ?? firstLeaf(state[key])
const fresh: SidebarTab = {
id: mintTabId(),
type: 'editor',
title: baseName(absolute),
path: absolute,
meta: { treeOpen: false },
}
const { node, leafId } = insertLeafAt(state[key], pane.id, 'row', fresh, false)
return { ...state, [key]: node, activePane: leafId }
})
}
// The viewer's toolbar, hoisted into THIS header: the text editor reports
// its state and registers its commands (both null/absent for viewers
// without a toolbar — image, pdf, binary download).
const [toolbar, setToolbar] = useState<EditorToolbarState | null>(null)
const controlsRef = useRef<EditorToolbarControls | null>(null)
const onToolbarState = useCallback((next: EditorToolbarState) => {
setToolbar(prev => prev !== null && JSON.stringify(prev) === JSON.stringify(next) ? prev : next)
}, [])
const onToolbarControls = useCallback((controls: EditorToolbarControls | null) => {
controlsRef.current = controls
}, [])
// The docked panel's drag-resize: pointer capture on the handle itself
// (no window listeners — the captured pointer keeps tracking even off the
// handle). Local width while dragging, persisted into meta.treeWidth on
// release. The panel docks right, so dragging LEFT widens it.
const [dragWidth, setDragWidth] = useState<number | null>(null)
const dragRef = useRef<{ startX: number; startWidth: number } | null>(null)
const treeWidth = dragWidth ?? treeWidthOf(tab)
const onResizeStart = (event: React.PointerEvent): void => {
event.preventDefault()
// jsdom lacks setPointerCapture — the tests dispatch plain MouseEvents.
event.currentTarget.setPointerCapture?.(event.pointerId)
dragRef.current = { startX: event.clientX, startWidth: treeWidth }
}
const onResizeMove = (event: React.PointerEvent): void => {
const drag = dragRef.current
if (drag === null) return
setDragWidth(clampTreeWidth(drag.startWidth + (drag.startX - event.clientX)))
}
const onResizeEnd = (event: React.PointerEvent): void => {
const drag = dragRef.current
if (drag === null) return
dragRef.current = null
setDragWidth(null)
const finalWidth = clampTreeWidth(drag.startWidth + (drag.startX - event.clientX))
if (finalWidth !== treeWidthOf(tab)) patchMeta(ctx, tab, { treeWidth: finalWidth })
}
useEffect(() => {
// A (re)load or a path-less tab clears any hoisted toolbar state — the
// fresh viewer re-registers its own.
setToolbar(null)
// The seeded home tab (no path) never loads a viewer — the empty-state
// hint renders until the user picks a file.
if (showEmpty) return
let cancelled = false
// Aborts the matched viewer's `load` when the editor tears down (tab
// closed, path changed, session switched) or re-matches the viewer.
@@ -81,24 +247,176 @@ export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: Se
}
apply(planFirstMatch(ctx.betterSidebar?.matchFileViewer(path), mediaUrlOf))
return () => { cancelled = true; controller.abort() }
}, [scope.sessionId, scope.cwd, path, ctx])
}, [scope.sessionId, scope.cwd, path, ctx, showEmpty])
const treeOpen = treeOpenOf(tab)
/** Persist the panel flag on the tab (survives reloads with the layout). */
const toggleTree = (): void => { patchMeta(ctx, tab, { treeOpen: !treeOpen }) }
const saveLabel = toolbar === null ? ''
: toolbar.saveState === 'saving' ? t('loading')
: toolbar.saveState === 'saved' ? t('saved')
: toolbar.saveState === 'failed' ? t('saveFailed') : ''
// Split mode: the path-less window IS the standalone explorer — the tree
// panel fills the whole tab (search + FileTree, full form), no editor
// chrome. File opens land in new per-path tabs through openFile above.
if (treeOnly) {
return (
<div className={css.editor}>
<TreePanel
full
sessionId={scope.sessionId}
cwd={scope.cwd}
expanded={expanded}
onToggle={onToggleDir}
onOpenFile={openFile}
onOpenFileNewTab={openFileNewTab}
onOpenFileSide={openFileSide}
onReferenceFile={onReferenceFile}
/>
</div>
)
}
return (
<div className={css.editor}>
<div className={css.editorHeader}>
<span className={css.editorTitle} title={path}>{title}</span>
<EditorPathInput key={path} path={path} cwd={scope.cwd} onOpen={openFile} />
{toolbar?.modes === true && (
<div className={css.editorModeToggle}>
<button
type="button"
className={clsx(css.editorModeButton, toolbar.mode === 'preview' && css.editorModeActive)}
onClick={() => { controlsRef.current?.setMode('preview') }}
>
{t('preview')}
</button>
<button
type="button"
className={clsx(css.editorModeButton, toolbar.mode === 'edit' && css.editorModeActive)}
onClick={() => { controlsRef.current?.setMode('edit') }}
>
{t('edit')}
</button>
</div>
)}
{toolbar?.dirty === true && <span className={css.dirtyDot} title={t('unsaved')} />}
{toolbar?.editable === true && (
<button
type="button"
className={css.iconButton}
aria-label={t('save')}
title={`${t('save')} (Ctrl/Cmd+S)`}
onClick={() => { controlsRef.current?.save() }}
>
<IconCheckOutline16 size={14} />
</button>
)}
{saveLabel !== '' && (
<span className={clsx(css.editorStatus, toolbar?.saveState === 'failed' && css.editorStatusError)}>{saveLabel}</span>
)}
<button
type="button"
className={clsx(css.iconButton, treeOpen && css.editorTreeToggleActive)}
aria-label={t('editorTreeToggle')}
title={t('editorTreeToggle')}
aria-pressed={treeOpen}
onClick={toggleTree}
>
<IconFolderOpen16 size={14} />
</button>
</div>
<div className={css.editorBody}>
<div className={css.editorMain}>
{showEmpty && <div className={css.editorPlaceholder}>{t('editorEmptyHint')}</div>}
{!showEmpty && load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
{!showEmpty && load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
{!showEmpty && load.status === 'binary' && <BinaryDownload scope={scope} path={path} />}
{!showEmpty && load.status === 'ready' && createElement(load.viewer.component, {
ctx, store, scope, path, title,
viewerId: load.viewer.id,
content: load.content,
truncated: load.truncated,
mediaUrl: load.mediaUrl,
customData: load.customData,
// The viewer's toolbar always hoists into this host's header.
toolbar: 'host',
onToolbarState,
onToolbarControls,
})}
</div>
{treeOpen && (
<div className={css.editorTreeDock} style={{ width: treeWidth }}>
<div
className={css.editorTreeResize}
role="separator"
aria-orientation="vertical"
aria-label={t('editorTreeToggle')}
onPointerDown={onResizeStart}
onPointerMove={onResizeMove}
onPointerUp={onResizeEnd}
onPointerCancel={onResizeEnd}
/>
<TreePanel
sessionId={scope.sessionId}
cwd={scope.cwd}
expanded={expanded}
onToggle={onToggleDir}
onOpenFile={openFile}
onOpenFileNewTab={openFileNewTab}
onOpenFileSide={openFileSide}
onReferenceFile={onReferenceFile}
/>
</div>
)}
</div>
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
{load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
{load.status === 'binary' && <BinaryDownload scope={scope} path={path} />}
{load.status === 'ready' && createElement(load.viewer.component, {
ctx, store, scope, path, title,
viewerId: load.viewer.id,
content: load.content,
truncated: load.truncated,
mediaUrl: load.mediaUrl,
customData: load.customData,
})}
</div>
)
}
/**
* The header's path input: shows the current file relative to the session
* cwd (absolute when outside it). Enter resolves the typed path (relative
* input joins onto the cwd — the same resolution `openSidebarFile` uses)
* and opens it through the parent's mode-aware open (in-place switch or a
* per-path dedupe tab); Escape/blur restores the current value. The parent
* keys it by `path` so an in-place switch remounts and reseeds the draft.
*/
function EditorPathInput(props: { path: string; cwd: string | undefined; onOpen: (path: string) => void }) {
const { path, cwd, onOpen } = props
const display = path === '' ? '' : relativeTo(cwd ?? '', path)
const [value, setValue] = useState(display)
const commit = (): void => {
const input = value.trim()
if (input === '' || input === display) {
setValue(display)
return
}
onOpen(resolveSidebarPath(cwd, input))
// Split mode: the open lands in a NEW/deduped editor tab — THIS tab's
// path stays, so the input falls back to its own display value. (Merged
// mode remounts this input on the new path; the reset is harmless.)
setValue(display)
}
return (
<input
className={css.editorPathInput}
value={value}
placeholder={t('editorPathPlaceholder')}
title={path}
spellCheck={false}
onChange={(event) => { setValue(event.target.value) }}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
commit()
} else if (event.key === 'Escape') {
setValue(display)
}
}}
onBlur={() => { setValue(display) }}
/>
)
}
@@ -1,20 +1,25 @@
/**
* The file explorer: a lazy VSCode-style tree rooted at the session's
* working directory. Levels load on expansion (one API call per directory),
* directories sort first, hidden entries render dimmed, and the expansion
* set lives in the per-session state. Clicking a file opens an editor tab.
* The controlled file tree behind the files window's tree panel (TreePanel
* wraps it with the search box): a lazy VSCode-style tree rooted at the
* session's working directory. Levels load on expansion (one API call per
* directory), directories sort first, hidden entries render dimmed. The
* expansion set lives in the per-session state (owned by the caller); the
* caller also owns the refresh affordance — a `refreshTick` bump wipes the
* level cache so the visible set reloads.
*
* Row actions: hovering a row reveals an @-reference button on the far
* right (appends `@<relative path>` to the composer draft), and right-click
* opens a context menu to copy the relative or absolute path (with a brief
* "copied" label replacing the button after a successful write); file rows
* also offer a download action (the host serves raw bytes, binary-safe).
* opens a context menu: file rows offer the caller's open escapes
* (new tab / to the side, only when the callbacks exist) and a download
* action (the host serves raw bytes, binary-safe); every row can copy the
* relative or absolute path (with a brief "copied" label replacing the
* button after a successful write).
*/
import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import {
IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16, IconFolderClose16, IconFolderOpen16,
IconRefreshOutline16, Menu, writeClipboard,
IconLinkOutline16, Menu, writeClipboard,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { api, downloadUrl, type FsEntry } from './api.ts'
import { relativeTo } from './paths.ts'
@@ -27,7 +32,7 @@ interface LevelData {
}
/** Root label: the last path segment (mirror of the host rootLabel). */
function baseName(path: string): string {
export function baseName(path: string): string {
const trimmed = path.replace(/[\\/]+$/, '')
const at = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
return at === -1 ? trimmed : trimmed.slice(at + 1)
@@ -36,19 +41,24 @@ function baseName(path: string): string {
/** How long the row's "copied" label stays after a successful write. */
const COPIED_MS = 1200
export function ExplorerView(props: {
export function FileTree(props: {
sessionId: string
cwd: string | undefined
expanded: string[]
onToggle: (path: string) => void
onOpenFile: (path: string) => void
/** Context-menu "open in a new tab" (file rows; absent → no entry). */
onOpenFileNewTab?: (path: string) => void
/** Context-menu "open to the side" (file rows; absent → no entry). */
onOpenFileSide?: (path: string) => void
/** Insert `@<relative path>` into the composer draft. */
onReferenceFile: (path: string) => void
/** Bump to wipe the level cache and reload the visible set. */
refreshTick: number
}) {
const { sessionId, cwd, expanded, onToggle, onOpenFile, onReferenceFile } = props
const { sessionId, cwd, expanded, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, onReferenceFile, refreshTick } = props
const [data, setData] = useState<Record<string, LevelData>>({})
const dataRef = useRef(data)
const [refreshTick, setRefreshTick] = useState(0)
/** The row whose path was just copied ("copied" label replaces its button). */
const [copiedPath, setCopiedPath] = useState<string | null>(null)
/** Open context menu: the row path (and whether it is a directory) plus the cursor position. */
@@ -69,9 +79,19 @@ export function ExplorerView(props: {
})
}, [sessionId, cwd, storeLevel])
// The caller's refresh tick wipes the cache (declared BEFORE the load
// effect so the reload below sees the empty cache).
const lastTick = useRef(refreshTick)
useEffect(() => {
if (lastTick.current === refreshTick) return
lastTick.current = refreshTick
dataRef.current = {}
setData({})
}, [refreshTick])
useEffect(() => {
// Load the visible set; already-loaded levels (kept in the cache) are
// not refetched. Only the refresh button wipes the cache.
// not refetched. Only the refresh tick wipes the cache.
const root = cwd
if (root === undefined) return
loadDir(root)
@@ -163,6 +183,7 @@ export function ExplorerView(props: {
>
{isOpen ? <IconFolderOpen16 size={14} /> : <IconFolderClose16 size={14} />}
<span className={css.explorerName}>{entry.name}</span>
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
{rowActions(entry)}
</div>
{isOpen && renderLevel(entry.path, depth + 1)}
@@ -174,9 +195,9 @@ export function ExplorerView(props: {
key={entry.path}
role="button"
tabIndex={0}
className={clsx(css.explorerRow, entry.hidden && css.explorerHidden)}
className={clsx(css.explorerRow, entry.hidden && css.explorerHidden, entry.broken && css.explorerBroken)}
style={{ paddingLeft: depth * 22 + 6 }}
title={entry.path}
title={entry.broken ? `${entry.path} — ${t('brokenSymlink')}` : entry.path}
onClick={() => { onOpenFile(entry.path) }}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
@@ -188,6 +209,7 @@ export function ExplorerView(props: {
>
<IconCodeOutline16 size={14} />
<span className={css.explorerName}>{entry.name}</span>
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
{rowActions(entry)}
</div>
)
@@ -195,64 +217,53 @@ export function ExplorerView(props: {
}
return (
<div className={css.explorer}>
<div className={css.explorerHeader}>
<span className={css.explorerRoot} title={root}>{root === undefined ? t('noSession') : baseName(root)}</span>
<button
type="button"
className={css.iconButton}
aria-label={t('refresh')}
title={t('refresh')}
onClick={() => {
dataRef.current = {}
setData({})
setRefreshTick(tick => tick + 1)
}}
>
<IconRefreshOutline16 />
</button>
</div>
<div className={css.explorerBody}>
{root === undefined ? (
<div className={css.explorerEmpty}>{t('noSession')}</div>
) : (
<>
<div
className={css.explorerRow}
style={{ paddingLeft: 6 }}
onContextMenu={(event) => { openRowMenu(event, root, true) }}
>
<IconFolderOpen16 size={14} />
<span className={css.explorerName}>{baseName(root)}</span>
{copiedPath === root
? <span className={css.explorerCopied}>{t('copied')}</span>
: (
<button
type="button"
className={css.explorerRef}
aria-label={t('referenceFile')}
title={t('referenceFile')}
onClick={(event) => {
event.stopPropagation()
onReferenceFile(root)
}}
>
{t('referenceFile')}
</button>
)}
</div>
{data[root] !== undefined && renderLevel(root, 1)}
</>
)}
</div>
<div className={css.explorerBody}>
{root === undefined ? (
<div className={css.explorerEmpty}>{t('noSession')}</div>
) : (
<>
<div
className={css.explorerRow}
style={{ paddingLeft: 6 }}
onContextMenu={(event) => { openRowMenu(event, root, true) }}
>
<IconFolderOpen16 size={14} />
<span className={css.explorerName}>{baseName(root)}</span>
{copiedPath === root
? <span className={css.explorerCopied}>{t('copied')}</span>
: (
<button
type="button"
className={css.explorerRef}
aria-label={t('referenceFile')}
title={t('referenceFile')}
onClick={(event) => {
event.stopPropagation()
onReferenceFile(root)
}}
>
{t('referenceFile')}
</button>
)}
</div>
{data[root] !== undefined && renderLevel(root, 1)}
</>
)}
{/*
The one shared context menu, positioned at the right-click cursor
(portal so the explorer's overflow clip cannot crop it).
(portal so the tree's overflow clip cannot crop it).
*/}
<Menu
open={rowMenu !== null}
onClose={() => { setRowMenu(null) }}
items={[
// The open escapes head the FILE menu (dirs only get copy).
...(rowMenu?.isDir === false && onOpenFileNewTab !== undefined
? [{ id: 'open-new-tab', label: t('openFileNewTab'), icon: <IconCodeOutline16 size={14} /> }]
: []),
...(rowMenu?.isDir === false && onOpenFileSide !== undefined
? [{ id: 'open-side', label: t('openFileSide'), icon: <IconFolderOpen16 size={14} /> }]
: []),
// Download applies to files only (the host route refuses directories).
...(rowMenu?.isDir === false
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutline16 size={14} /> }]
@@ -264,6 +275,14 @@ export function ExplorerView(props: {
const target = rowMenu
if (target === null) return
setRowMenu(null)
if (id === 'open-new-tab') {
onOpenFileNewTab?.(target.path)
return
}
if (id === 'open-side') {
onOpenFileSide?.(target.path)
return
}
if (id === 'download') {
downloadFile(target.path)
return
+1 -1
View File
@@ -297,7 +297,7 @@ export function GitView(props: {
title={t('refresh')}
onClick={() => { void refresh() }}
>
<IconRefreshOutline16 />
<IconRefreshOutline16 size={14} />
</button>
</div>
+174 -15
View File
@@ -24,12 +24,11 @@
flex-direction: column;
gap: 14px;
width: 100%;
/* Tall inventories (7 tab cards + 9 viewer cards + general rows) must
never be clipped by the settings shell: fill the shell's options area
and scroll inside the section when the content outgrows the panel. */
height: 100%;
min-height: 0;
overflow-y: auto;
max-width: 760px;
/* The DSH settings shell's `.options` area owns vertical scrolling. Keep
this page a plain content column like Plugins → Plugin list so its
scrollbar stays at the shell edge instead of being inset by the options
area's right padding. */
}
/* One-line section intro (the DSH section heading+intro recipe). */
@@ -126,7 +125,8 @@
}
.cardMain:focus-visible,
.cardGear:focus-visible {
.cardGear:focus-visible,
.rowGear:focus-visible {
outline: 2px solid var(--dsw-alias-border-l4);
outline-offset: 2px;
}
@@ -266,6 +266,29 @@
background: var(--dsw-alias-interactive-bg-hover);
}
/* The gear icon button on a general settings ROW (the position-compat
row's "customize the strip height" entry): the same 16px icon-button
recipe as the card gear, sitting in the row's control area. */
.rowGear {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
flex: none;
}
.rowGear:hover {
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-interactive-bg-hover);
}
/* Settings-row recipe (the DSH General section rows): title/desc left,
control right, hairline separators (stripped on the last row). */
.row {
@@ -380,6 +403,59 @@
width: 76px;
}
/* Select rows: the closed anchor is a compact button (selected option's
icon when the options carry icons, plus title and a chevron); the dropdown
itself is the primitives Menu (portal). */
.selectAnchor {
display: flex;
align-items: center;
gap: 6px;
max-width: 220px;
padding: 4px 8px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
background: var(--dsw-alias-bg-layer-3);
color: var(--dsw-alias-label-primary);
font-size: 13px;
line-height: 20px;
cursor: pointer;
}
.selectAnchor:hover {
border-color: var(--dsw-alias-label-dimmed);
}
.selectAnchorIcon {
display: inline-flex;
flex: none;
}
.selectAnchorText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* One big-icon option card inside the select dropdown. */
.selectOption {
display: flex;
align-items: center;
gap: 10px;
min-width: 200px;
}
.selectOptionIcon {
display: inline-flex;
flex: none;
color: var(--dsw-alias-label-secondary);
}
.selectOptionText {
display: flex;
flex-direction: column;
min-width: 0;
}
.suffix {
font-size: 14px;
line-height: 22px;
@@ -393,10 +469,13 @@
width: min(460px, 100%);
}
/* Popup rows: the same settings-row recipe with switch controls. */
/* Popup rows: each setting gets the same outer chrome as a plugin-list
PluginCard (l2 hairline, 12px radius, layer-3 fill). The row's title,
description and control geometry stay unchanged inside that frame. */
.popupRows {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
@@ -405,16 +484,16 @@
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 2px;
border-bottom: 1px solid var(--dsw-alias-border-l2);
min-width: 0;
padding: 14px 16px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
background: var(--dsw-alias-bg-layer-3);
transition: border-color 0.16s, background 0.16s;
}
.popupRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.popupRows > :last-child {
border-bottom: none;
border-color: var(--dsw-alias-label-dimmed);
}
/* The popup's Done button: the settings primary-button recipe
@@ -634,3 +713,83 @@
transition: none;
}
}
/* The managing plugin's identity badge: name + version pill, token-driven
(labels via --dsw-alias-label-*, surface via --dsw-alias-bg-layer-2). */
.versionBadge {
display: inline-flex;
align-items: center;
gap: 8px;
align-self: flex-start;
padding: 4px 12px 4px 14px;
border-radius: 999px;
border: 1px solid var(--dsw-alias-border-l2);
background: var(--dsw-alias-bg-layer-2);
font-size: 12px;
line-height: 18px;
}
.versionBadgeName {
color: var(--dsw-alias-label-primary);
font-weight: 600;
}
.versionBadgeTag {
padding: 1px 8px;
border-radius: 999px;
background: var(--dsw-alias-accent-soft, var(--dsw-alias-border-l2));
color: var(--dsw-alias-label-secondary);
font-variant-numeric: tabular-nums;
}
/* Catalog search input: a compact text field above the list (token-driven).
The entries list below it scrolls INDEPENDENTLY — with many plugins the
modal stays navigable instead of growing unbounded. */
.pluginSearch {
width: 100%;
box-sizing: border-box;
appearance: none;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
padding: 6px 10px;
font: inherit;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-bg-layer-1);
}
.pluginSearch::placeholder {
color: var(--dsw-alias-label-tertiary);
}
.pluginSearch:focus-visible {
outline: 2px solid var(--dsw-alias-border-l4);
outline-offset: 1px;
}
/* The entries area scrolls within the modal once the catalog grows past
the viewport (max-height + own scrollbar keeps the modal shell fixed). */
.pluginEntries {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 46vh;
overflow-y: auto;
padding-right: 2px;
}
/* One catalog group: optional category heading above its stacked entries. */
.pluginGroup {
display: flex;
flex-direction: column;
gap: 8px;
}
.pluginGroupHeading {
font-size: 12px;
line-height: 18px;
font-weight: 600;
color: var(--dsw-alias-label-secondary);
padding: 2px 2px 0;
}
+202 -2
View File
@@ -41,9 +41,11 @@
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
import {
IconCheckOutline16,
IconChevronDownOutline14,
IconPlusOutline16,
IconSettingsOutline16,
Input,
Menu,
Modal,
} from '@deepseek-ai/dsh-client-ui-primitives'
import clsx from 'clsx'
@@ -52,6 +54,8 @@ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import {
clampWidthPercent,
TITLE_BAR_STRIP_MAX,
TITLE_BAR_STRIP_MIN,
WIDTH_PERCENT_MAX,
WIDTH_PERCENT_MIN,
type SidebarPrefs,
@@ -207,6 +211,10 @@ export function FeatureSettingsRows(props: {
* 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
/** Commit one select row: the picked option's value (single) or the array
* of picked values (`multi: true`). Optional: rows with no handler are
* display-only. */
onSelectValue?: (toggle: SidebarSettingToggle, next: unknown) => void
/** Explicit value source (v0.12.0+): when given, rows read their values
* from it instead of the `prefs` face — plugin-owned rows read their
* own blob, so a plugin key can never collide with (or silently read)
@@ -214,12 +222,23 @@ export function FeatureSettingsRows(props: {
* the latter collides with the inherited Object.prototype.valueOf.) */
valueSource?: (key: string) => unknown
}) {
const { toggles, prefs, onToggle, onCommit, valueSource } = props
const { toggles, prefs, onToggle, onCommit, onSelectValue, valueSource } = props
const read = valueSource ?? ((key: string): unknown => (prefs as unknown as Record<string, unknown>)[key])
return (
<div className={css.popupRows}>
{toggles.map(toggle => {
const title = textOf(toggle.title)
if (toggle.type === 'select') {
return (
<SelectRow
key={toggle.key}
toggle={toggle}
title={title}
value={read(toggle.key)}
onSelectValue={onSelectValue}
/>
)
}
if ((toggle.type ?? 'switch') === 'switch') {
return (
<div key={toggle.key} className={css.popupRow}>
@@ -300,6 +319,99 @@ function TypedRow(props: {
)
}
/**
* One select row: a dropdown over the toggle's declared `options`. When any
* option carries an icon, the dropdown renders big-icon option cards (icon +
* title + desc) and the closed anchor shows the selected option's icon as
* well; without icons both are a single line of text. Single-pick commits the
* option's value and closes; `multi` toggles membership, commits the picked
* values as an array (in options order), and stays open.
*/
function SelectRow(props: {
toggle: SidebarSettingToggle
title: string
value: unknown
onSelectValue?: (toggle: SidebarSettingToggle, next: unknown) => void
}) {
const { toggle, title, value, onSelectValue } = props
const options = toggle.options ?? []
const multi = toggle.multi === true
const [open, setOpen] = useState(false)
const hasIcons = options.some(option => option.icon !== undefined)
const picked: readonly unknown[] = multi ? (Array.isArray(value) ? value : []) : [value]
const selected = options.filter(option => picked.includes(option.value))
/** Commit one picked option (toggle semantics under multi). */
const pick = (index: number): void => {
const option = options[index]
if (option === undefined) return
if (!multi) {
onSelectValue?.(toggle, option.value)
setOpen(false)
return
}
const current = Array.isArray(value) ? [...value] : []
const at = current.indexOf(option.value)
if (at >= 0) current.splice(at, 1)
else current.push(option.value)
// Stable wire order: follow the declared options order, not pick order.
onSelectValue?.(toggle, options.filter(o => current.includes(o.value)).map(o => o.value))
}
const anchor = (
<button
type="button"
className={css.selectAnchor}
aria-label={title}
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => { setOpen(now => !now) }}
>
{!multi && hasIcons && selected[0] !== undefined && (
<span className={css.selectAnchorIcon}>{iconOf(selected[0].icon, 16)}</span>
)}
<span className={css.selectAnchorText}>
{selected.length === 0 ? '—' : selected.map(option => textOf(option.title)).join(', ')}
</span>
<IconChevronDownOutline14 size={12} />
</button>
)
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}>
<Menu
open={open}
anchor={anchor}
items={options.map((option, index) => ({
id: String(index),
label: hasIcons
? (
<span className={css.selectOption}>
<span className={css.selectOptionIcon}>{iconOf(option.icon, 24)}</span>
<span className={css.selectOptionText}>
<span className={css.title}>{textOf(option.title)}</span>
{textOf(option.desc) !== '' && <span className={css.desc}>{textOf(option.desc)}</span>}
</span>
</span>
)
: textOf(option.title),
}))}
selectedId={!multi && selected[0] !== undefined ? String(options.indexOf(selected[0])) : undefined}
selectedIds={multi ? selected.map(option => String(options.indexOf(option))) : undefined}
onSelect={(id) => { pick(Number(id)) }}
onClose={() => { setOpen(false) }}
portal
/>
</span>
</div>
)
}
/**
* The secondary settings popup body of one feature (tab or viewer):
* - `settings.render` (custom panel) when declared — rendered with the
@@ -316,12 +428,14 @@ export function SettingsBody(props: {
service: BetterSidebarService
onToggle: (toggle: SidebarSettingToggle, next: boolean) => void
onCommit: (toggle: SidebarSettingToggle, raw: string) => string
onSelectValue: (toggle: SidebarSettingToggle, next: unknown) => void
onPluginToggle: (toggle: SidebarSettingToggle, next: boolean) => void
onPluginCommit: (toggle: SidebarSettingToggle, raw: string) => string
onPluginSelectValue: (toggle: SidebarSettingToggle, next: unknown) => void
onPluginWrite: (key: string, value: unknown) => void
onClose: () => void
}) {
const { feature, prefs, store, service, onToggle, onCommit, onPluginToggle, onPluginCommit, onPluginWrite, onClose } = props
const { feature, prefs, store, service, onToggle, onCommit, onSelectValue, onPluginToggle, onPluginCommit, onPluginSelectValue, onPluginWrite, onClose } = props
const render = feature.settings?.render
if (render !== undefined) {
return (
@@ -354,6 +468,7 @@ export function SettingsBody(props: {
prefs={prefs}
onToggle={onToggle}
onCommit={onCommit}
onSelectValue={onSelectValue}
/>
)}
{pluginToggles.length > 0 && (
@@ -362,6 +477,7 @@ export function SettingsBody(props: {
prefs={prefs}
onToggle={onPluginToggle}
onCommit={onPluginCommit}
onSelectValue={onPluginSelectValue}
valueSource={(key) => pluginBlob[key]}
/>
)}
@@ -380,6 +496,8 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
const [error, setError] = useState<string | null>(null)
// Which feature's secondary settings popup is open (null = closed).
const [settingsFor, setSettingsFor] = useState<TabDescriptor | FileViewerDescriptor | null>(null)
// Whether the position-compat strip popup (the gear on the 常规 row) is open.
const [stripSettingsOpen, setStripSettingsOpen] = useState(false)
// Whether the "add plugin" modal (a dashed card at the end of the
// 侧边栏内容 / 文件预览 grids) is open, and for which extension point
// (null = closed).
@@ -489,6 +607,12 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
applyPref({ [toggle.key]: next })
}
/** Commit one declaratively-declared select row (the option's value, or an
* array of values under `multi`). */
const onSelectSetting = (toggle: SidebarSettingToggle, next: unknown): void => {
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
@@ -612,6 +736,15 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
<div className={css.section}>
<p className={css.intro}>{t('settingsIntro')}</p>
{/* The managing plugin's own identity: name + version badge, so the
section is attributable at a glance (the version is the service
instance's, kept in lockstep with package.json by
tests/service.spec.ts). */}
<div className={css.versionBadge}>
<span className={css.versionBadgeName}>DSH-better-sidebar</span>
<span className={css.versionBadgeTag}>v{service.version}</span>
</div>
{/* 常规: the DSH settings-row recipe — title/desc left, control right. */}
<div className={css.group}>
<div className={css.groupHeading}>{t('settingsGeneralTitle')}</div>
@@ -660,6 +793,36 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
onChange={(next) => { applyPref({ interceptOpenPath: next }) }}
/>
</div>
<div className={css.row}>
<span className={css.rowText}>
<span className={css.title}>{t('settingsTitleBarTitle')}</span>
<span className={css.desc}>{t('settingsTitleBarDesc')}</span>
</span>
<span className={css.control}>
{/*
The position-compat row's gear (same popup pattern as the
feature cards): opens a Modal with the strip-height number row.
Hidden while the mode is off — its related setting is dormant
then (the feature-card convention).
*/}
{prefs.titleBarCompat && (
<button
type="button"
className={css.rowGear}
aria-label={`${t('settingsTitleBarTitle')} ${t('settingsPopup')}`}
title={t('settingsPopup')}
onClick={() => { setStripSettingsOpen(true) }}
>
<IconSettingsOutline16 size={14} />
</button>
)}
<Switch
label={t('settingsTitleBarTitle')}
checked={prefs.titleBarCompat}
onChange={(next) => { applyPref({ titleBarCompat: next }) }}
/>
</span>
</div>
</div>
{/* 侧边栏内容: one small card per registered tab type in a responsive
@@ -773,8 +936,10 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
prefs={prefs}
onToggle={onToggleSetting}
onCommit={onCommitSetting}
onSelectValue={onSelectSetting}
onPluginToggle={(toggle, next) => { onPluginToggle(settingsFor.id, toggle, next) }}
onPluginCommit={(toggle, raw) => onPluginCommitSetting(settingsFor.id, toggle, raw)}
onPluginSelectValue={(toggle, next) => { applyPluginSetting(settingsFor.id, toggle.key, next) }}
onPluginWrite={(key, value) => { applyPluginSetting(settingsFor.id, key, value) }}
onClose={() => { setSettingsFor(null) }}
store={store}
@@ -783,6 +948,41 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
</Modal>
)}
{/* The position-compat strip popup (opened by the gear on the 常规
row): one number row for the reserved strip height in px. Same
Modal chrome and row machinery as the feature popups — mounted
only while open (the Modal SSR rule above). */}
{stripSettingsOpen && (
<Modal
open
onClose={() => { setStripSettingsOpen(false) }}
title={t('settingsTitleBarTitle')}
description={t('settingsPopupDesc', { feature: t('settingsTitleBarTitle') })}
closeLabel={t('close')}
className={css.popupDialog}
footer={(
<button type="button" className={css.done} onClick={() => { setStripSettingsOpen(false) }}>
{t('settingsDone')}
</button>
)}
>
<FeatureSettingsRows
toggles={[{
key: 'titleBarStripPx',
type: 'number',
title: () => t('settingsTitleBarStripTitle'),
desc: () => t('settingsTitleBarStripDesc'),
min: TITLE_BAR_STRIP_MIN,
max: TITLE_BAR_STRIP_MAX,
unit: 'px',
}]}
prefs={prefs}
onToggle={onToggleSetting}
onCommit={onCommitSetting}
/>
</Modal>
)}
{/* The "add plugin" modal (opened by the dashed cards above): declares
the extension point of the clicked kind, opens the GitHub topic,
and lists the matching recommended plugin catalog with per-entry
+316 -83
View File
@@ -1,16 +1,19 @@
/**
* The sidebar shell: fixed-position panels portalled onto document.body
* (the core AppFrame owns the left sidebar / center / details columns and
* has no right-side hole for plugins). The right panel hosts the original
* workbench; the bottom panel hosts a second, independent workbench. The
* bottom panel squeezes ONLY the center column (the agent output area): it
* spans from the app shell's own left sidebar to the right panel's left
* edge, so neither sidebar gives up any position (the right panel keeps its
* full height). A persistent two-button cluster at the top-right corner
* toggles each panel; the right panel's width drags from its left edge, the
* bottom panel's height from its top edge, and the shared corner drags both
* at once. The whole layout lives in the per-session store, so switching
* conversations swaps the sidebar.
* The sidebar shell: panels mounted inside the unified panel host — a
* fixed, viewport-sized containing block ([data-dsh-panel-host]) appended
* to document.body — instead of individual fixed-position elements, so a
* desktop shell's intermediate wrapper transforms can never hijack the
* panels' fixed containing block (the core AppFrame owns the left sidebar /
* center / details columns and has no right-side hole for plugins). The
* right panel hosts the original workbench; the bottom panel hosts a
* second, independent workbench. The bottom panel squeezes ONLY the center
* column (the agent output area): it spans from the app shell's own left
* sidebar to the right panel's left edge, so neither sidebar gives up any
* position (the right panel keeps its full height). A persistent two-button
* cluster at the top-right corner toggles each panel; the right panel's
* width drags from its left edge, the bottom panel's height from its top
* edge, and the shared corner drags both at once. The whole layout lives in
* the per-session store, so switching conversations swaps the sidebar.
*
* The shell binds the workbench actions to the store and dispatches tab
* content to the views. New tabs come from the + menu (explorer / git /
@@ -40,6 +43,7 @@ import {
import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx'
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
import { useNarrowViewport } from './breakpoints.ts'
import { parseDesktopEnv } from './desktop-env.ts'
import type { NewTabOption } from './TabBar.tsx'
import type { TabDragPayload } from './TabBar.tsx'
import { relativeTo } from './paths.ts'
@@ -137,6 +141,37 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// keep living in the right tree.
const narrow = useNarrowViewport()
// On-screen keyboard / visual-viewport inset (mobile, split-screen, …):
// when the visual viewport shrinks below the layout viewport, bottom-
// anchored panels would hide under the keyboard. Track the inset and
// offset the bottom-anchored surfaces by it. The obscured bottom strip is
// innerHeight − (vv.height + vv.offsetTop): offsetTop is nonzero while
// the visual viewport is scrolled/zoomed under browser chrome, so
// omitting it would over-lift the panels (CR #232 P2). offsetTop changes
// through the viewport's scroll event too, so both events are listened.
// Guarded: browsers without visualViewport (older WebViews, jsdom) stay
// at 0. rAF-throttled, same pattern as useNarrowViewport.
const [keyboardInset, setKeyboardInset] = useState(0)
useEffect(() => {
const vv = window.visualViewport
if (vv === null || vv === undefined) return
let frame: number | null = null
const measure = (): void => {
frame = null
const inset = Math.max(0, window.innerHeight - (vv.height + vv.offsetTop))
setKeyboardInset(inset > 1 ? Math.round(inset) : 0)
}
const onResize = (): void => { if (frame === null) frame = requestAnimationFrame(measure) }
vv.addEventListener('resize', onResize)
vv.addEventListener('scroll', onResize)
measure()
return () => {
vv.removeEventListener('resize', onResize)
vv.removeEventListener('scroll', onResize)
if (frame !== null) cancelAnimationFrame(frame)
}
}, [])
// Current conversation (the sessions list feed).
const sessionList = useSyncExternalStore(
useMemo(() => (callback: () => void) => ctx.sessions.list.subscribe(callback), [ctx]),
@@ -167,6 +202,40 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
return () => { document.body.removeAttribute('data-dsh-sidebar-collapsed') }
}, [collapsed])
// Position compatibility mode (titleBarCompat pref): Windows frameless
// windows draw the native title bar (minimize/maximize/close) at the
// window's top-right corner, OVER the web content. When enabled, the body
// attribute lets sidebar.module.css drop the toggle cluster below the
// strip and push the right panel's content below it. Auto-enabled in the
// win32 advanced desktop shell (the shell stamps the URL — see
// desktop-env.ts — and reserves a 32px overlay for the window controls);
// the manual pref and its strip height still win when the user sets them,
// and the manual switch only ever ADDS compat. The strip height rides a
// CSS variable so the rules stay declarative; the attribute rides the
// snapshot's prefs, so flipping the setting re-renders and re-applies
// immediately; the cleanup removes both on unmount/boundary swap so a
// crashed sidebar never leaves them behind.
const desktopEnv = parseDesktopEnv()
const autoTitleBarCompat = desktopEnv.win32OverlayTop > 0
const titleBarCompat = snapshot.prefs.titleBarCompat || autoTitleBarCompat
const titleBarStrip = snapshot.prefs.titleBarCompat
? snapshot.prefs.titleBarStripPx
: desktopEnv.win32OverlayTop
useEffect(() => {
const root = document.documentElement
if (titleBarCompat) {
document.body.setAttribute('data-dsh-title-bar-compat', '')
root.style.setProperty('--dsh-title-bar-strip', `${titleBarStrip}px`)
} else {
document.body.removeAttribute('data-dsh-title-bar-compat')
root.style.removeProperty('--dsh-title-bar-strip')
}
return () => {
document.body.removeAttribute('data-dsh-title-bar-compat')
root.style.removeProperty('--dsh-title-bar-strip')
}
}, [titleBarCompat, titleBarStrip])
/**
* Bottom-panel merge on narrow viewports: whenever a session is current
* while narrow (mount, session switch, or a desktop→narrow transition),
@@ -388,13 +457,28 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
const watcher = new MutationObserver(locate)
const root = document.getElementById('root')
if (root !== null) watcher.observe(root, { childList: true })
// The layout push writes --dsh-sidebar-* on <html>. A HMR re-activation
// clears those variables on teardown and re-writes them on setup — and
// that is also the moment the shell may have re-created the center
// column under a REUSED #root child (React swaps nodes in place, so
// #root's childList never changes and the watcher above never fires).
// Watching <html>'s style attribute catches that re-sync: the push
// rewrite re-locates and re-measures, so the bottom panel recovers
// instead of staying hidden on a stale {0,0} center rect.
const htmlStyleWatcher = new MutationObserver(locate)
htmlStyleWatcher.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] })
return () => {
disposed = true
observer?.disconnect()
watcher.disconnect()
htmlStyleWatcher.disconnect()
centerColRef.current = null
}
}, [measureCenter])
// Opening the bottom panel re-runs the whole locate/measure chain: a
// panel opened before the center column was ever found must not stay
// invisible forever (the HMR recovery path depends on the observers
// above, this is the belt-and-braces retry for the open moment itself).
}, [measureCenter, state?.bottomOpen])
/**
* Bottom-panel first-expansion auto terminal: the FIRST time the user
@@ -435,7 +519,6 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// pointer up (clamping + persistence).
const panelRef = useRef<HTMLDivElement | null>(null)
const bottomRef = useRef<HTMLDivElement | null>(null)
const cornerRef = useRef<HTMLDivElement | null>(null)
const widthDrag = useRef({ startX: 0, startWidth: 0 })
const [draggingWidth, setDraggingWidth] = useState(false)
const bottomDrag = useRef({ startY: 0, startHeight: 0 })
@@ -460,10 +543,24 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
const clampHeight = (height: number): number =>
Math.min(Math.max(BOTTOM_MIN, Math.round(height)), Math.max(BOTTOM_MIN, window.innerHeight - PANEL_MIN))
/** Single writer for the layout-push variables: the app shell gives up
* the panel's width/height while open (0 while collapsed) through
* layout.css's margins. Every size change — drag frames and committed
* state — flows through here so the push never forks between paths. */
const writeGeometry = (width: number, height: number): void => {
document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
// The corner handle positions itself relative to the panel (CSS
// `bottom: calc(var(--dsh-sidebar-height) + 6px)`), so these two layout
// variables are all it needs — no viewport coordinates written here
// (issue #106: skins that inset the panels must not fight JS coords).
}
/** Apply a drag size to the DOM without touching React state or the store.
* The bottom panel's right edge tracks the right panel's left edge HERE
* too — React state only updates on release, so the inline right must be
* written directly or the bottom panel would lag the sidebar mid-drag. */
* written directly or the bottom panel would lag the sidebar mid-drag.
* The layout push rides the shared writer (writeGeometry). */
const applyDrag = (width: number, height: number): void => {
panelRef.current?.style.setProperty('width', `${width}px`)
bottomRef.current?.style.setProperty('height', `${height}px`)
@@ -472,12 +569,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// `width + detailsWidth` — derived from the measured column, keeping the
// drag write-only (no React re-render mid-drag).
bottomRef.current?.style.setProperty('right', `${(window.innerWidth - centerRect.right) + (width - (state?.width ?? 0))}px`)
document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
if (cornerRef.current !== null) {
cornerRef.current.style.left = `${window.innerWidth - width - 6}px`
cornerRef.current.style.top = `${window.innerHeight - height - 6}px`
}
writeGeometry(width, height)
}
// Drags write at most once per frame: pointer events fire several times
@@ -509,6 +601,91 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
pendingDrag.current = null
}
/**
* Finalize a drag on pointer up: flush the LAST drag frame to the DOM
* synchronously, then commit the SAME clamped values to the store. A fast
* release cancels the rAF before it ran — without the flush the DOM would
* sit at the pre-drag size until React re-renders with the committed
* value, and a value that never made it into a move handler would never
* be applied at all. The measurement pause ends here too: the center
* column is re-measured BEFORE the committed re-render lands, so the
* bottom panel's React-rendered right edge already reflects the new
* width (otherwise the re-render would re-apply the stale rect — the
* bottom panel visibly jumps for one frame).
*/
const commitDrag = (
width: number,
height: number,
reduce: (state: SidebarState) => SidebarState,
): void => {
stopDragScheduling()
applyDrag(width, height)
draggingRef.current = false
measureCenter()
store.reduce(reduce)
}
/** Set once a drag's pointerup handler commits — premature capture loss
* (pointercancel / lostpointercapture without pointerup) must then be told
* apart from a normal release. */
const dragCommitted = useRef(false)
/**
* Abort a drag whose pointer stream was interrupted (pointercancel, or
* capture lost before pointerup): no pointerup will arrive, so without
* this the dragging state would stick true and center-column measurement
* would stay paused forever — the bottom panel freezes at stale edges and
* stops tracking sidebar/app-rail layout changes.
*
* A FAST release is the common trigger: browsers merge pointermove bursts,
* and an ultra-fast flick can cancel the stream before ANY move lands.
* The commit order is therefore: the LAST KNOWN dragged size (the rAF
* pending value) first, then the interrupting event's own pointer
* position (pointercancel / lostpointercapture still carry coordinates),
* and only a drag that produced neither (pure down+up at the same spot)
* reverts to the store's committed sizes.
*/
const abortDrag = (reset: () => void, event?: { clientX: number; clientY: number }): void => {
if (dragCommitted.current) return
const pending = pendingDrag.current
let width: number | undefined
let height: number | undefined
if (pending !== null) {
width = pending.width
height = pending.height
} else if (event !== undefined) {
// No move ever landed: the cancel/lost-capture position is all we
// have — commit it (clamped) instead of rolling back the flick.
if (draggingWidth) {
width = clampWidth(widthDrag.current.startWidth + (widthDrag.current.startX - event.clientX))
height = state?.bottomOpen === true ? Math.min(state.bottomHeight, window.innerHeight) : 0
} else if (draggingBottom) {
width = Math.min(state?.width ?? 0, window.innerWidth)
height = clampHeight(bottomDrag.current.startHeight + (bottomDrag.current.startY - event.clientY))
} else if (draggingCorner) {
width = clampWidth(cornerDrag.current.startWidth + (cornerDrag.current.startX - event.clientX))
height = clampHeight(cornerDrag.current.startHeight + (cornerDrag.current.startY - event.clientY))
}
}
if (width !== undefined && height !== undefined) {
pendingDrag.current = null
if (dragFrame.current !== null) {
cancelAnimationFrame(dragFrame.current)
dragFrame.current = null
}
applyDrag(width, height)
draggingRef.current = false
measureCenter()
store.reduce(s => setBottomHeight(setWidth(s, width), height))
} else {
stopDragScheduling()
applyDrag(
!narrow && state?.panelOpen === true ? Math.min(state?.width ?? 0, window.innerWidth) : 0,
!narrow && state?.bottomOpen === true ? Math.min(state.bottomHeight, window.innerHeight) : 0,
)
}
reset()
}
// Layout push: the app shell gives up the panel's width/height while the
// panels are open (0 while collapsed), so the conversation and input bar
// are squeezed instead of covered. The margins are capped at the viewport
@@ -523,8 +700,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
const height = !narrow && snapshot.state?.bottomOpen === true
? Math.min(snapshot.state.bottomHeight, window.innerHeight)
: 0
document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
writeGeometry(width, height)
// Unmount must release the push (issue #31): when the boundary swaps the
// whole sidebar after a render crash (or the plugin fiber is disposed /
// HMR), the CSS variables would otherwise stay on <html> and layout.css
@@ -614,19 +790,21 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
if (state === undefined || sessionId === undefined) {
return (
<div className={css.toggleCluster}>
{!narrow && (
<div data-dsh-panel-host>
<div className={css.toggleCluster}>
{!narrow && (
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
<IconPanelBottomOutline16 />
</button>
</Tooltip>
)}
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
<IconPanelBottomOutline16 />
<IconPanelRightOutline16 />
</button>
</Tooltip>
)}
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
<IconPanelRightOutline16 />
</button>
</Tooltip>
</div>
</div>
)
}
@@ -698,7 +876,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
)
return (
<>
<div data-dsh-panel-host>
{/*
The persistent toggle cluster at the top-right corner: the bottom
panel's button (bottom glyph) LEFT of the right panel's (side glyph).
@@ -747,15 +925,24 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
<div
ref={panelRef}
className={clsx(css.panel, !state.panelOpen && css.panelHidden)}
style={{ width: narrow ? '100vw' : Math.min(state.width, window.innerWidth) }}
style={{
width: narrow ? '100vw' : Math.min(state.width, window.innerWidth),
// Narrow drawer: keep the bottom-anchored sheet above the on-screen
// keyboard (visualViewport inset); desktop panels are full-height
// and unaffected.
bottom: narrow && keyboardInset > 0 ? `${keyboardInset}px` : undefined,
}}
data-dragging={anyDragging || undefined}
>
{!narrow && (
<div
className={clsx(css.panelResize, draggingWidth && css.panelResizeActive)}
onPointerDown={(event) => {
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
dragCommitted.current = false
widthDrag.current = { startX: event.clientX, startWidth: state.width }
setDraggingWidth(true)
}}
@@ -768,12 +955,23 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
dragCommitted.current = true
event.currentTarget.releasePointerCapture(event.pointerId)
const { startX, startWidth } = widthDrag.current
stopDragScheduling()
store.reduce(s => setWidth(s, startWidth + (startX - event.clientX)))
// A very fast release can merge/lose pointermove bursts:
// prefer the LAST KNOWN dragged width (already clamped by
// the move handler) over the up position, which may equal
// the down position when no move landed.
const pending = pendingDrag.current
const width = pending !== null
? pending.width
: clampWidth(startWidth + (startX - event.clientX))
const height = state.bottomOpen ? Math.min(state.bottomHeight, window.innerHeight) : 0
commitDrag(width, height, s => setWidth(s, width))
setDraggingWidth(false)
}}
onPointerCancel={() => { abortDrag(() => setDraggingWidth(false)) }}
onLostPointerCapture={() => { abortDrag(() => setDraggingWidth(false)) }}
/>
)}
<div className={css.panelBody}>
@@ -787,6 +985,61 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
getTabBadge={tabBadgeOf}
/>
</div>
{/*
The shared corner (only while BOTH panels are open): the
intersection of the right panel's left edge and the bottom panel's
top edge. Horizontal drags resize the right panel's width, vertical
drags the bottom panel's height — the two panels drag against each
other. Rendered INSIDE the right panel and positioned by CSS
relative to it (left edge + the bottom panel's height via the
--dsh-sidebar-height layout variable) — no JS-written viewport
coordinates to keep in sync. (Never on narrow viewports: the
bottom panel does not exist there.)
*/}
{!narrow && state.panelOpen && state.bottomOpen && (
<div
className={css.cornerHandle}
data-dragging={draggingCorner || undefined}
onPointerDown={(event) => {
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
dragCommitted.current = false
cornerDrag.current = {
startX: event.clientX,
startY: event.clientY,
startWidth: state.width,
startHeight: state.bottomHeight,
}
setDraggingCorner(true)
}}
onPointerMove={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
const { startX, startY, startWidth, startHeight } = cornerDrag.current
const width = clampWidth(startWidth + (startX - event.clientX))
const height = clampHeight(startHeight + (startY - event.clientY))
scheduleDrag(width, height)
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
dragCommitted.current = true
event.currentTarget.releasePointerCapture(event.pointerId)
const { startX, startY, startWidth, startHeight } = cornerDrag.current
// Last known dragged size wins over the up position (fast
// releases can merge/lose pointermove bursts).
const pending = pendingDrag.current
const width = pending !== null
? pending.width
: clampWidth(startWidth + (startX - event.clientX))
const height = pending !== null
? pending.height
: clampHeight(startHeight + (startY - event.clientY))
commitDrag(width, height, s => setBottomHeight(setWidth(s, width), height))
setDraggingCorner(false)
}}
onPointerCancel={() => { abortDrag(() => setDraggingCorner(false)) }}
onLostPointerCapture={() => { abortDrag(() => setDraggingCorner(false)) }}
/>
)}
</div>
{/*
The bottom panel: a second, independent workbench. It squeezes ONLY
@@ -797,6 +1050,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
down like the right panel. On NARROW viewports it does not exist —
the bottom workbench lives inside the drawer (MobileWorkbench).
*/}
{/* The bottom panel only becomes VISIBLE once the center column is
measured: before that, `centerRect` is the {0,0} fallback and
`right` computes to the full viewport width — the panel (and its
overflow content) would flash full-width for a frame until the
first measurement lands. Rendering stays unconditional so the
mount/render chain (auto-terminal etc.) is never gated on
geometry. */}
{!narrow && (
<div
ref={bottomRef}
@@ -804,6 +1064,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
style={{
height: Math.min(state.bottomHeight, window.innerHeight),
left: centerRect.left,
// Keep the panel above the on-screen keyboard when the visual
// viewport shrinks (see the keyboardInset effect).
bottom: keyboardInset > 0 ? `${keyboardInset}px` : undefined,
// Direct from the center column's measured right edge: the bottom
// panel spans ONLY the center column, ending exactly at the
// details column's left edge (the details column sits between the
@@ -814,14 +1077,20 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// (the right panel's border-left alone is covered by this panel's
// fill — without it the corner looks cut off).
borderRight: state.panelOpen ? '1px solid var(--dsw-alias-border-l2)' : undefined,
// Unmeasured center column → keep the panel invisible (zero-size
// geometry would flash full-width overflow instead).
visibility: centerRect.right > 0 ? undefined : 'hidden',
}}
data-dragging={(draggingBottom || draggingCorner) || undefined}
>
<div
className={clsx(css.bottomResize, draggingBottom && css.bottomResizeActive)}
onPointerDown={(event) => {
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
dragCommitted.current = false
bottomDrag.current = { startY: event.clientY, startHeight: state.bottomHeight }
setDraggingBottom(true)
}}
@@ -833,12 +1102,20 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
dragCommitted.current = true
event.currentTarget.releasePointerCapture(event.pointerId)
const { startY, startHeight } = bottomDrag.current
stopDragScheduling()
store.reduce(s => setBottomHeight(s, startHeight + (startY - event.clientY)))
// Last known dragged height wins over the up position (fast
// releases can merge/lose pointermove bursts).
const pending = pendingDrag.current
const height = pending !== null
? pending.height
: clampHeight(startHeight + (startY - event.clientY))
commitDrag(Math.min(state.width, window.innerWidth), height, s => setBottomHeight(s, height))
setDraggingBottom(false)
}}
onPointerCancel={() => { abortDrag(() => setDraggingBottom(false)) }}
onLostPointerCapture={() => { abortDrag(() => setDraggingBottom(false)) }}
/>
{/*
The bottom panel's own close control at its tab strip's right end
@@ -869,50 +1146,6 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
</div>
</div>
)}
{/*
The shared corner (only while BOTH panels are open): the intersection
of the right panel's left edge and the bottom panel's top edge.
Horizontal drags resize the right panel's width, vertical drags the
bottom panel's height — the two panels drag against each other.
(Never on narrow viewports: the bottom panel does not exist there.)
*/}
{!narrow && state.panelOpen && state.bottomOpen && (
<div
ref={cornerRef}
className={css.cornerHandle}
style={{
left: window.innerWidth - state.width - 6,
top: window.innerHeight - state.bottomHeight - 6,
}}
data-dragging={draggingCorner || undefined}
onPointerDown={(event) => {
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
cornerDrag.current = {
startX: event.clientX,
startY: event.clientY,
startWidth: state.width,
startHeight: state.bottomHeight,
}
setDraggingCorner(true)
}}
onPointerMove={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
const { startX, startY, startWidth, startHeight } = cornerDrag.current
const width = clampWidth(startWidth + (startX - event.clientX))
const height = clampHeight(startHeight + (startY - event.clientY))
scheduleDrag(width, height)
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
event.currentTarget.releasePointerCapture(event.pointerId)
const { startX, startY, startWidth, startHeight } = cornerDrag.current
stopDragScheduling()
store.reduce(s => setBottomHeight(setWidth(s, startWidth + (startX - event.clientX)), startHeight + (startY - event.clientY)))
setDraggingCorner(false)
}}
/>
)}
</>
</div>
)
}
+39 -3
View File
@@ -74,6 +74,38 @@ export function TabBar(props: {
const [dragOver, setDragOver] = useState(false)
const listRef = useRef<HTMLDivElement>(null)
// Middle-click close: the press target is recorded on middle mousedown
// (preventDefaulted to disarm Chrome's middle-click autoscroll — its
// indicator is inert here because the strip hides its scrollbar and only
// the wheel handler scrolls) and the close settles on the first middle
// mouseup OVER that same tab. Release-position semantics match VS Code
// (microsoft/vscode#101028) and what users expect from Chrome tabs
// (crbug/40679924): pressing on a tab and releasing elsewhere cancels the
// close. The browser dispatches auxclick to the nearest common ancestor of
// the press/release targets when they differ, so any drift, autoscroll
// scroll, or tab-list reflow between press and release would otherwise
// swallow the close; settling on the recorded press target at mouseup
// keeps release semantics without depending on auxclick delivery.
const onCloseRef = useRef(onClose)
const middlePressed = useRef<{ id: string; node: HTMLElement } | null>(null)
useEffect(() => {
onCloseRef.current = onClose
})
useEffect(() => {
const onMouseUp = (event: MouseEvent): void => {
if (event.button !== 1) return
const pressed = middlePressed.current
middlePressed.current = null
// Close only when the release lands on the pressed tab; a drag-away
// release cancels the press (one-shot per press).
if (pressed !== null && pressed.node.isConnected && pressed.node.contains(event.target as Node)) {
onCloseRef.current(pressed.id)
}
}
window.addEventListener('mouseup', onMouseUp)
return () => { window.removeEventListener('mouseup', onMouseUp) }
}, [])
// Wheel over the strip scrolls the tab row horizontally (a plain mouse
// wheel emits deltaY, which overflow-x alone never consumes). Bound as a
// native NON-passive listener: React registers onWheel passively at the
@@ -151,11 +183,15 @@ export function TabBar(props: {
if (payload !== null) onDropTab(payload, tab.id)
}}
onClick={() => { onActivate(tab.id) }}
onAuxClick={(event) => {
// Middle-click closes the tab (and suppresses autoscroll).
onMouseDown={(event) => {
// Middle-click close: record the press target and disarm
// Chrome's middle-click autoscroll (its indicator is inert
// here — the strip scrolls via the wheel handler only). The
// close itself settles on the first middle mouseup over this
// same tab (window-level), keeping release semantics.
if (event.button === 1) {
event.preventDefault()
onClose(tab.id)
middlePressed.current = { id: tab.id, node: event.currentTarget }
}
}}
>
+88 -7
View File
@@ -20,20 +20,32 @@
* reconnect grace.
*/
import { useEffect, useRef, useState } from 'react'
import { Terminal, type ITheme } from 'xterm'
import { Terminal, type ITheme } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import 'xterm/css/xterm.css'
import { writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
import '@xterm/xterm/css/xterm.css'
import { t } from './locales.ts'
import { openWhenSized } from './open-when-sized.ts'
import type { SessionScope } from './api.ts'
import { api, type SessionScope, type TerminalDepsStatus } from './api.ts'
import { agentUuidOf, isAgentTabId, type SidebarStore } from './state.ts'
import { isDarkScheme, subscribeColorScheme, tokenValue } from './theme.ts'
import { isDarkScheme, subscribeColorScheme, effectiveTokenValue, tokenValue } from './theme.ts'
import { resolveTerminalFont } from './terminal-font.ts'
import css from './sidebar.module.css'
/** How many consecutive unreasoned failures before showing the error banner. */
const FAILURE_LIMIT = 3
/**
* The WS close-code-1011 reason the host sends when node-pty is unavailable
* (mirror of the host's PTY_DEPS_MISSING; the value is a wire contract, so
* the two sides keep the literal in lockstep). The view then fetches the
* full repair details from /sidebar/api/terminal.deps.
*/
const PTY_DEPS_MISSING = 'pty-deps-missing'
/** The degraded-mode payload rendered by {@link TerminalDepsBanner}. */
type TerminalDepsInfo = Extract<TerminalDepsStatus, { ok: false }>
/**
* Curated ANSI palettes for the terminal. The surface colors (background,
* foreground, cursor, selection) ride the theme tokens so the terminal
@@ -61,8 +73,14 @@ const ANSI_LIGHT: Record<string, string> = {
/** The xterm theme for the current scheme (surface from tokens, ANSI curated). */
function xtermTheme(): ITheme {
const dark = isDarkScheme()
const background = tokenValue('--dsw-alias-bg-base') || (dark ? '#111114' : '#ffffff')
const foreground = tokenValue('--dsw-alias-label-primary') || (dark ? '#e6e6e6' : '#1a1a1a')
// Skin systems set --dsw-alias-bg-base to `transparent` or translucent
// glass values (the dsh-web-ui skins use rgba 0.16–0.7); effectiveTokenValue
// treats those as unset below the opacity floor, so the opaque fallback
// engages and the terminal never renders see-through over the skin's
// backdrop (issue #90). Effectively opaque scoped surfaces (e.g. a skin's
// 0.96 porcelain) pass through — the skin still controls the terminal.
const background = effectiveTokenValue('--dsw-alias-bg-base') || (dark ? '#111114' : '#ffffff')
const foreground = effectiveTokenValue('--dsw-alias-label-primary') || (dark ? '#e6e6e6' : '#1a1a1a')
return {
background,
foreground,
@@ -78,6 +96,7 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
const hostRef = useRef<HTMLDivElement>(null)
const [connected, setConnected] = useState(false)
const [fatal, setFatal] = useState<string | null>(null)
const [depsFatal, setDepsFatal] = useState<TerminalDepsInfo | null>(null)
const [lastUrl, setLastUrl] = useState<string | null>(null)
const connectRef = useRef<(() => void) | null>(null)
@@ -151,6 +170,25 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
}
socket.onclose = (event) => {
setConnected(false)
// node-pty dependency missing/broken (issue #140): the host closed
// with the short marker. Fetch the full repair details over HTTP —
// a WS close reason is capped at 123 bytes, too small for the
// pasteable command. A failed fetch falls back to the plain banner.
if (event.code === 1011 && event.reason === PTY_DEPS_MISSING) {
void api.terminalDeps().then((status) => {
if (status.ok) {
// The host recovered between the close and the fetch — the
// plain banner with a retry is the honest state.
setFatal(t('terminalDepsFailed'))
return
}
setFatal(null)
setDepsFatal(status)
}).catch(() => {
setFatal(t('terminalDepsFailed'))
})
return
}
// A server-side refusal carries a close code + reason; retrying it
// forever would only spin the banner, so surface it with a retry.
if (event.code === 1011 && event.reason !== '') {
@@ -257,6 +295,9 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
return (
<div className={css.terminalWrap}>
{depsFatal !== null && (
<TerminalDepsBanner deps={depsFatal} onRetry={() => { setDepsFatal(null); connectRef.current?.() }} />
)}
{fatal !== null && (
<div className={css.terminalBanner}>
{t('terminalError')}: {fatal}
@@ -270,8 +311,48 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
</button>
</div>
)}
{fatal === null && !connected && <div className={css.terminalBanner}>{t('disconnected')}</div>}
{fatal === null && depsFatal === null && !connected && <div className={css.terminalBanner}>{t('disconnected')}</div>}
<div ref={hostRef} className={css.terminal} />
</div>
)
}
/**
* The node-pty dependency failure banner (issue #140): explains that the
* terminal's native dependency failed to load and shows the PASTEABLE repair
* command (bash / cmd / PowerShell) with a copy button — the user pastes it
* into a terminal where their DSH profile lives and runs it, then retries.
* Extracted as a standalone component for direct testing.
*/
export function TerminalDepsBanner(props: { deps: TerminalDepsInfo; onRetry: () => void }) {
const { deps, onRetry } = props
const [copied, setCopied] = useState(false)
const copy = async (): Promise<void> => {
const written = await writeClipboard(deps.command)
if (written) {
setCopied(true)
window.setTimeout(() => setCopied(false), 2000)
}
}
return (
<div className={css.terminalDepsBanner}>
<div className={css.terminalDepsTitle}>{t('terminalDepsFailed')}</div>
<div className={css.terminalDepsHint}>
{t('terminalDepsHint')}
{deps.profile !== null ? t('terminalDepsProfile', { profile: deps.profile }) : ''}
</div>
<div className={css.terminalDepsCommandRow}>
<pre className={css.terminalRepairCommand}>{deps.command}</pre>
<button type="button" className={css.terminalRetry} onClick={() => { void copy() }} aria-label={t('copy')}>
{copied ? t('copied') : t('copy')}
</button>
</div>
{deps.note !== undefined && <div className={css.terminalDepsNote}>{deps.note}</div>}
<div className={css.terminalDepsActions}>
<button type="button" className={css.terminalRetry} onClick={onRetry}>
{t('terminalRetry')}
</button>
</div>
</div>
)
}
+67 -11
View File
@@ -7,9 +7,14 @@
* so this component never fetches or dispatches — it only edits.
*
* The toolbar (mode toggle / dirty dot / save / status) renders as its own
* row below the host's title bar, VSCode-style.
* row below the host's title bar, VSCode-style — unless the host passes
* `toolbar: 'host'` (the merged editor-explorer mode), in which case this
* component skips the row and reports state + registers commands through
* the FileViewerProps toolbar callbacks so the host's path-input header
* renders the controls instead.
*/
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { ComponentType } from 'react'
import { createPortal } from 'react-dom'
import clsx from 'clsx'
import { EditorState } from '@codemirror/state'
@@ -23,8 +28,10 @@ import { isDarkScheme, subscribeColorScheme } from './theme.ts'
import { SandboxStatusBar } from './SandboxStatusBar.tsx'
import { appendToDraft } from './conversation-draft.ts'
import { buildSelectionInsert, linesOfSelection } from './selection-payload.ts'
import { lazyChunkComponent } from './lazy-chunk.tsx'
import { splitMermaidBlocks, type MermaidMarkdownProps } from './mermaid-blocks.ts'
import { t } from './locales.ts'
import type { FileViewerProps } from './service.ts'
import type { EditorToolbarState, FileViewerProps } from './service.ts'
import css from './sidebar.module.css'
/** Previewable files (rendered output vs source editing). */
@@ -37,6 +44,16 @@ interface SelectionPopup {
top: number
}
/**
* The chunk-resident markdown preview renderer (mermaid lazy chunk): one
* MarkdownText pass over the whole source, with rendered mermaid fences
* swapped for diagrams. Module-level `pick` keeps the load effect stable.
*/
const LazyMermaidMarkdown = lazyChunkComponent<MermaidMarkdownProps>(
'mermaid',
(mod) => mod.MermaidMarkdown as ComponentType<MermaidMarkdownProps> | undefined,
)
/**
* The sandbox tokens of the HTML preview iframe. NO allow-same-origin (the
* preview must stay in an opaque origin — with the route's own origin it
@@ -141,11 +158,11 @@ export function TextEditor(props: FileViewerProps) {
...defaultKeymap,
...historyKeymap,
]),
// Selection popup (the catch-all code viewer only): a non-empty
// Selection popup (the code and markdown editors): a non-empty
// selection anchors the floating "add to conversation" button above
// its head. Scrolling (geometry/viewport change) or losing focus
// hides it; typing collapses the selection and hides it too.
...(viewerId === 'code' ? [
...(viewerId === 'code' || viewerId === 'markdown' ? [
CodeMirrorView.updateListener.of((update) => {
if (update.geometryChanged || update.viewportChanged) {
hidePopup()
@@ -233,6 +250,19 @@ export function TextEditor(props: FileViewerProps) {
const markdown = viewerId === 'markdown'
const html = viewerId === 'html'
/** The markdown source the preview renders (draft wins over saved content). */
const mdText = draft ?? content ?? ''
/** md/mermaid block split for the preview (mermaid fences lift out). Split
* only in preview mode: edit-mode keystrokes must not re-scan the source. */
const mdBlocks = useMemo(
() => (markdown && mode === 'preview' ? splitMermaidBlocks(mdText) : []),
[markdown, mode, mdText],
)
const hasMermaid = useMemo(
() => mdBlocks.some(block => block.kind === 'mermaid'),
[mdBlocks],
)
const codeLabels = { copyLabel: t('copy'), copiedLabel: t('copied') }
/**
* Selection popup for the markdown preview: a mouse-up inside the preview
@@ -259,7 +289,7 @@ export function TextEditor(props: FileViewerProps) {
return
}
const rect = sel.getRangeAt(0).getBoundingClientRect()
const lines = linesOfSelection(draft ?? content ?? '', text)
const lines = linesOfSelection(mdText, text)
showPopup(
buildSelectionInsert(path, scope.cwd, lines ?? undefined, text),
rect.left + rect.width / 2,
@@ -278,8 +308,31 @@ export function TextEditor(props: FileViewerProps) {
const [localUnlock, setLocalUnlock] = useState(() => props.store?.getPrefs().htmlViewerDefaultUnsafe === true)
const htmlNoSandbox = props.store?.getPrefs().htmlViewerNoSandbox === true || localUnlock
// Host-toolbar mode (the merged editor header renders the controls): skip
// the own toolbar row, report the state after every relevant render (the
// JSON key guards redundant calls), and register the commands on mount.
const hostToolbar = props.toolbar === 'host'
const lastToolbarRef = useRef('')
useEffect(() => {
if (!hostToolbar) return
const state: EditorToolbarState = { modes: markdown || html, mode, dirty, editable, saveState }
const key = JSON.stringify(state)
if (lastToolbarRef.current === key) return
lastToolbarRef.current = key
props.onToolbarState?.(state)
})
useEffect(() => {
if (!hostToolbar) return
// `save` reads live refs only, and `setMode` is the stable state setter —
// registering this render's closures is safe for the mount's lifetime.
props.onToolbarControls?.({ setMode, save })
return () => { props.onToolbarControls?.(null) }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hostToolbar])
return (
<>
{!hostToolbar && (
<div className={css.editorHeader}>
{(markdown || html) && (
<div className={css.editorModeToggle}>
@@ -313,6 +366,7 @@ export function TextEditor(props: FileViewerProps) {
)}
{saveLabel !== '' && <span className={clsx(css.editorStatus, saveState === 'failed' && css.editorStatusError)}>{saveLabel}</span>}
</div>
)}
{editable && (
<>
{truncated === true && mode === 'edit' && <div className={css.editorBanner}>{t('truncation')}</div>}
@@ -333,11 +387,13 @@ export function TextEditor(props: FileViewerProps) {
dictionary: the DSH MarkdownText/CodeBlock are cordis-free and
fall back to hardcoded Chinese otherwise (same pattern as the
chat's AssistantMarkdown). Render-time t() keeps them following
the active locale on live switches. */}
<MarkdownText
text={draft ?? content ?? ''}
codeLabels={{ copyLabel: t('copy'), copiedLabel: t('copied') }}
/>
the active locale on live switches. Mermaid fences hand the
whole document to the mermaid lazy chunk (single markdown
parse; cross-fence references/footnotes stay intact); files
without one render exactly as before. */}
{hasMermaid
? <LazyMermaidMarkdown text={mdText} codeLabels={codeLabels} />
: <MarkdownText text={mdText} codeLabels={codeLabels} />}
</div>
)}
{html && mode === 'preview' && (
+121
View File
@@ -0,0 +1,121 @@
/**
* The files window's tree surface: a global file-name search box on top
* (300ms debounce; an in-flight search is aborted by the next keystroke)
* over either the shared controlled FileTree (empty query) or the flat
* result list (relative paths; click opens through the caller's mode-aware
* open). Owns its refresh tick: the icon next to the search input clears
* the tree cache. EditorHost docks it as the tab's right panel (wrapped in
* a drag-resize handle) and provides the file context-menu open escapes.
*/
import { useEffect, useState } from 'react'
import clsx from 'clsx'
import { IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { api } from './api.ts'
import { FileTree } from './FileTree.tsx'
import { t } from './locales.ts'
import { resolveSidebarPath } from './produced-files.ts'
import css from './sidebar.module.css'
export function TreePanel(props: {
sessionId: string
cwd: string | undefined
expanded: string[]
onToggle: (path: string) => void
onOpenFile: (path: string) => void
/** File context-menu "open in a new tab" (passed through to FileTree). */
onOpenFileNewTab?: (path: string) => void
/** File context-menu "open to the side" (passed through to FileTree). */
onOpenFileSide?: (path: string) => void
onReferenceFile: (path: string) => void
/** Full-window presentation: the panel fills its host instead of docking
* at a fixed width. */
full?: boolean
}) {
const { sessionId, cwd, expanded, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, onReferenceFile, full } = props
const [query, setQuery] = useState('')
const [results, setResults] = useState<{ matches: string[]; truncated: boolean } | null>(null)
const [error, setError] = useState<string | null>(null)
const [refreshTick, setRefreshTick] = useState(0)
const needle = query.trim()
useEffect(() => {
if (needle === '') {
setResults(null)
setError(null)
return
}
const controller = new AbortController()
const timer = window.setTimeout(() => {
api.fsSearch({ sessionId, cwd }, needle, controller.signal).then((found) => {
setResults(found)
setError(null)
}).catch((failure: unknown) => {
if (controller.signal.aborted) return
setResults(null)
setError(failure instanceof Error ? failure.message : String(failure))
})
}, 300)
return () => {
window.clearTimeout(timer)
controller.abort()
}
}, [sessionId, cwd, needle])
return (
<div className={clsx(css.editorTreePanel, full === true && css.editorTreePanelFull)}>
<div className={css.editorTreeSearch}>
<input
className={css.editorSearchInput}
value={query}
placeholder={t('editorSearchPlaceholder')}
spellCheck={false}
onChange={(event) => { setQuery(event.target.value) }}
/>
<button
type="button"
className={css.iconButton}
aria-label={t('refresh')}
title={t('refresh')}
onClick={() => { setRefreshTick(tick => tick + 1) }}
>
<IconRefreshOutline16 size={14} />
</button>
</div>
{needle === '' ? (
<FileTree
sessionId={sessionId}
cwd={cwd}
expanded={expanded}
onToggle={onToggle}
onOpenFile={onOpenFile}
onOpenFileNewTab={onOpenFileNewTab}
onOpenFileSide={onOpenFileSide}
onReferenceFile={onReferenceFile}
refreshTick={refreshTick}
/>
) : (
<div className={css.explorerBody}>
{error !== null && <div className={clsx(css.editorSearchHint, css.editorError)}>{error}</div>}
{error === null && results === null && <div className={css.editorSearchHint}>{t('loading')}</div>}
{error === null && results !== null && results.matches.length === 0 && (
<div className={css.editorSearchHint}>{t('editorSearchNoResults')}</div>
)}
{error === null && results !== null && results.matches.map(rel => (
<button
key={rel}
type="button"
className={css.editorSearchResult}
title={rel}
onClick={() => { onOpenFile(resolveSidebarPath(cwd, rel)) }}
>
{rel}
</button>
))}
{error === null && results?.truncated === true && (
<div className={css.editorSearchHint}>{t('editorSearchTruncated')}</div>
)}
</div>
)}
</div>
)
}
+86 -41
View File
@@ -19,7 +19,7 @@
* Modal must never be renderToString'd (same rule as the settingsFor popup
* in SideCardSection); the modal itself mounts only while open.
*/
import { useState } from 'react'
import { useState, type ReactNode } from 'react'
import { Modal, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
import type { BetterSidebarService } from './service.ts'
import { PLUGIN_TOPIC_URL, type PluginEntry } from './plugins-shared.ts'
@@ -46,6 +46,31 @@ export function PluginListBody(props: { service: BetterSidebarService; kind: Plu
const { service, kind } = props
// Which entry's copy button currently shows the "已复制" feedback.
const [copiedId, setCopiedId] = useState<string | null>(null)
// Live catalog filter (name / id / description). A free-text search keeps
// the list usable once the catalogs grow beyond a handful of entries:
// every keystroke narrows the rendered rows, so a long catalog never
// becomes a wall of scrolling.
const [query, setQuery] = useState('')
const catalog = catalogOf(kind)
const needle = query.trim().toLowerCase()
const matches = (entry: PluginEntry): boolean => {
if (needle === '') return true
const description = typeof entry.description === 'function' ? entry.description() : entry.description
return entry.name.toLowerCase().includes(needle)
|| entry.id.toLowerCase().includes(needle)
|| description.toLowerCase().includes(needle)
}
const filtered = catalog.filter(matches)
// Group by the optional category (stable order: category order of first
// appearance, uncategorized entries last under the plain list).
const groups = new Map<string | undefined, PluginEntry[]>()
for (const entry of filtered) {
const key = entry.category === undefined ? undefined : (typeof entry.category === 'function' ? entry.category() : entry.category)
const list = groups.get(key)
if (list === undefined) groups.set(key, [entry])
else list.push(entry)
}
/** Copy the entry's install script to the clipboard and flash the button's
* "已复制" label for a moment. The feedback ONLY appears after a
@@ -68,6 +93,48 @@ export function PluginListBody(props: { service: BetterSidebarService; kind: Plu
window.open(entry.url, '_blank', 'noopener')
}
/** One catalog row (extracted so the group render stays flat). */
const renderEntry = (entry: PluginEntry): ReactNode => (
<div key={entry.id} className={css.pluginEntry}>
<div className={css.pluginEntryHead}>
{/* The name is a BUTTON on the same window.open path as the
jump button: as an anchor it would be caught by the
document-capture link takeover (which ignores
target=_blank) and land in the sidebar browser. */}
<button
type="button"
className={css.pluginName}
aria-label={`${t('openPlugin')}: ${entry.name}`}
onClick={() => { jump(entry) }}
>
{entry.name}
</button>
<span className={css.pluginEntryActions}>
<button
type="button"
className={css.pluginJumpBtn}
aria-label={`${t('openPlugin')}: ${entry.name}`}
onClick={() => { jump(entry) }}
>
{t('openPlugin')}
</button>
<button
type="button"
className={css.pluginCopyBtn}
aria-label={`${t('copyInstall')}: ${entry.name}`}
onClick={() => { copy(entry) }}
>
{copiedId === entry.id ? t('copied') : t('copy')}
</button>
</span>
</div>
<div className={css.pluginDesc}>
{typeof entry.description === 'function' ? entry.description() : entry.description}
</div>
<code className={css.pluginInstall}>{entry.install}</code>
</div>
)
return (
<div className={css.pluginList}>
<button
@@ -77,52 +144,30 @@ export function PluginListBody(props: { service: BetterSidebarService; kind: Plu
>
{t('addPluginsBrowseMore')}
</button>
<input
type="search"
className={css.pluginSearch}
placeholder={t('addPluginsSearch')}
aria-label={t('addPluginsSearch')}
value={query}
onChange={event => { setQuery(event.currentTarget.value) }}
/>
<div className={css.groupHeading}>
<span>{t('addPluginsRecommended')}</span>
<span className={css.count}>{catalogOf(kind).length}</span>
<span className={css.count}>{filtered.length}</span>
</div>
{catalogOf(kind).length === 0 ? (
{catalog.length === 0 ? (
<div className={css.pluginEmpty}>{t('addPluginsEmpty')}</div>
) : filtered.length === 0 ? (
<div className={css.pluginEmpty}>{t('addPluginsNoMatch')}</div>
) : (
<div className={css.pluginEntries}>
{catalogOf(kind).map(entry => (
<div key={entry.id} className={css.pluginEntry}>
<div className={css.pluginEntryHead}>
{/* The name is a BUTTON on the same window.open path as the
jump button: as an anchor it would be caught by the
document-capture link takeover (which ignores
target=_blank) and land in the sidebar browser. */}
<button
type="button"
className={css.pluginName}
aria-label={`${t('openPlugin')}: ${entry.name}`}
onClick={() => { jump(entry) }}
>
{entry.name}
</button>
<span className={css.pluginEntryActions}>
<button
type="button"
className={css.pluginJumpBtn}
aria-label={`${t('openPlugin')}: ${entry.name}`}
onClick={() => { jump(entry) }}
>
{t('openPlugin')}
</button>
<button
type="button"
className={css.pluginCopyBtn}
aria-label={`${t('copyInstall')}: ${entry.name}`}
onClick={() => { copy(entry) }}
>
{copiedId === entry.id ? t('copied') : t('copy')}
</button>
</span>
</div>
<div className={css.pluginDesc}>
{typeof entry.description === 'function' ? entry.description() : entry.description}
</div>
<code className={css.pluginInstall}>{entry.install}</code>
{[...groups.entries()].map(([category, entries]) => (
<div key={category ?? '\u0000'} className={css.pluginGroup}>
{category !== undefined && (
<div className={css.pluginGroupHeading}>{category}</div>
)}
{entries.map(renderEntry)}
</div>
))}
</div>
+40 -4
View File
@@ -25,6 +25,10 @@ export interface FsEntry {
path: string
isDir: boolean
hidden: boolean
/** Whether the row is a symlink; `isDir` then describes the link's target. */
isSymlink: boolean
/** For symlinks: the target is missing or unreadable (stat failed). */
broken: boolean
}
/** Git status entry (host git shape). */
@@ -75,6 +79,21 @@ export interface JobOutputResult {
read: boolean
}
/** Terminal dependency status (mirror of the host's depsStatus; issue #140). */
export type TerminalDepsStatus =
| { ok: true }
| {
ok: false
/** The require-time error message (module missing, native binding broken…). */
cause: string
/** The pasteable repair command (terminal/cmd). */
command: string
/** The detected profile name (null when undetected → the command defaults to web). */
profile: string | null
/** Optional supplementary hint (fallback command only). */
note?: string
}
async function call<T>(method: string, payload: Record<string, unknown>, signal?: AbortSignal): Promise<T> {
let response: Response
try {
@@ -116,6 +135,10 @@ export const api = {
call<{ sessionId: string; cwd: string; root: string; parent: string | null }>('session.cwd', scopePayload(scope, {}), signal),
fsTree: (scope: SessionScope, path: string, signal?: AbortSignal) =>
call<{ path: string; entries: FsEntry[]; truncated: boolean }>('fs.tree', scopePayload(scope, { path }), signal),
/** Global recursive file-name search rooted at the session cwd (the editor
* side panel's search box); matches are cwd-relative '/'-separated paths. */
fsSearch: (scope: SessionScope, query: string, signal?: AbortSignal) =>
call<{ matches: string[]; truncated: boolean }>('fs.search', scopePayload(scope, { query }), signal),
fsRead: (scope: SessionScope, path: string, signal?: AbortSignal) =>
call<FsTextResult | FsBinaryResult>('fs.read', scopePayload(scope, { path }), signal),
fsWrite: (scope: SessionScope, path: string, content: string) =>
@@ -160,6 +183,11 @@ export const api = {
/** Release an agent terminal by uuid (tab closed while WS was down). */
agentPtyClose: (uuid: string) =>
call<{ ok: true }>('agent-pty.close', { uuid }),
/** Terminal dependency status (issue #140): after a WS close 1011 with
* reason `pty-deps-missing` the view fetches the full repair details here
* (the close reason itself is capped at 123 bytes). */
terminalDeps: () =>
call<TerminalDepsStatus>('terminal.deps', {}),
/**
* The output the model has read so far for one background job (replayed
* from the owner session's event log — never the model's job_output
@@ -173,9 +201,12 @@ export const api = {
id,
...(reason !== undefined ? { reason } : {}),
})),
/** The effective terminal shell and its display name (plugin-global). */
shellGet: () =>
call<{ shell: string; name: string }>('shell.get', {}),
/** Read the side card preferences (plugin-global, no session scope). */
settingsGet: () =>
call<{ value?: unknown; revision?: number }>('settings.get', {}),
call<{ value?: unknown; revision?: number; externalDisable?: boolean }>('settings.get', {}),
/** Merge a patch into the side card preferences (revision-guarded). */
settingsUpdate: (patch: Record<string, unknown>, expectedRevision?: number) =>
call<{ value?: unknown; revision?: number }>('settings.update', {
@@ -207,9 +238,14 @@ function fileUrl(scope: SessionScope, path: string, download: boolean): string {
return `/sidebar/file?${params.toString()}`
}
/** Absolute URL of the HTML preview route (see html-route.ts): the path is
* fully encoded so the previewed page's relative assets resolve back into
* the same route with the session scope intact. */
/**
* Absolute URL of the HTML preview route (see html-route.ts): the path is
* fully encoded so the previewed page's relative assets resolve back into
* the same route with the session scope intact. The UNC marker is
* platform-neutral — the host's requireAbsolute resolves the decoded
* forward-slash `//server/share/...` form on both win32 and POSIX — so no
* client-side platform signal is needed.
*/
export function htmlUrl(scope: SessionScope, path: string): string {
return encodeHtmlUrl(scope.sessionId, path)
}
+7 -3
View File
@@ -8,7 +8,7 @@
*/
import type { Context } from '../../context-types.ts'
import type { BetterSidebarService } from '../service.ts'
import { builtinTabs } from './tabs.tsx'
import { builtinTabs, type BuiltinTabOptions } from './tabs.tsx'
import { builtinViewers } from './viewers.tsx'
/**
@@ -17,9 +17,13 @@ import { builtinViewers } from './viewers.tsx'
* disposal). The `ctx` is threaded into tab descriptors that need it
* (EditorHost reads `ctx.betterSidebar` for file-viewer matching).
*/
export function registerBuiltins(ctx: Context, service: BetterSidebarService): () => void {
export function registerBuiltins(
ctx: Context,
service: BetterSidebarService,
options: BuiltinTabOptions = {},
): () => void {
const disposers: (() => void)[] = []
for (const tab of builtinTabs(ctx)) {
for (const tab of builtinTabs(ctx, options)) {
disposers.push(service.registerTab(tab))
}
for (const viewer of builtinViewers()) {
+88 -32
View File
@@ -1,17 +1,17 @@
/**
* The 7 built-in tab descriptors: the plugin registers its own pages
* (explorer / git / terminal / browser / subagent / editor / diff) through
* The 6 built-in tab descriptors: the plugin registers its own pages
* (editor / git / terminal / browser / subagent / diff) through
* the same {@link BetterSidebarService} external plugins use — eating its
* own dogfood. The terminal descriptor owns its quota (`TERMINAL_LIMIT`)
* and mints `terminal:<n>` ids through `createTab`; the browser mints
* `browser:<n>` the same way (no quota).
* and mints `terminal:<uuid>` ids through `createTab`; the browser mints
* `browser:<n>` the same way (no quota). The editor IS the files window
* (the old standalone explorer merged into it).
*/
import { IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconPanelLeftOutline16, IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../../context-types.ts'
import { allLeaves, isAgentTabId, type SidebarState } from '../state.ts'
import { t } from '../locales.ts'
import { openSidebarFile } from '../intercept.tsx'
import { ExplorerView } from '../ExplorerView.tsx'
import { EditorHost } from '../EditorHost.tsx'
import { lazyChunkComponent } from '../lazy-chunk.tsx'
import { GitView } from '../GitView.tsx'
@@ -52,6 +52,20 @@ interface TerminalViewProps {
/** How many UI-owned terminals may be open at once (agent-owned ones are uncapped). */
export const TERMINAL_LIMIT = 3
/** Optional per-registration builtin behavior (currently terminal title). */
export interface BuiltinTabOptions {
/** Returns the display title for newly opened terminal tabs. */
terminalTitle?: () => string
}
/** A client-side uuid for terminal tab identity (not shown in the UI). */
function terminalUuid(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `t${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`
}
/** Count UI-owned terminals (agent:` tabs excluded — they are the model's). */
function uiTerminalCount(state: SidebarState): number {
return allLeaves(state.splits)
@@ -59,33 +73,52 @@ function uiTerminalCount(state: SidebarState): number {
.filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length
}
/** The 7 built-in tab descriptors. */
export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
/** The 6 built-in tab descriptors. */
export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): readonly TabDescriptor[] {
return [
{
id: 'editor',
title: () => t('editor'),
icon: (size: number) => <IconCodeOutline16 size={size} />,
order: -1,
hidden: true,
dedupeKey: (tab) => tab.path,
component: ({ ctx, store, scope, tab }) => (
<EditorHost ctx={ctx} store={store} scope={scope} path={tab.path ?? ''} title={tab.title} />
),
},
{
id: 'explorer',
title: () => t('explorer'),
// The single files window: an editor tab with no path IS the file
// explorer (empty hint + docked tree); with a path it previews/edits
// the file. Visible in the + menu in the explorer's old slot.
title: () => t('files'),
icon: (size: number) => <IconFolderOpen16 size={size} />,
order: 10,
single: true,
component: ({ ctx, store, scope, expanded, onToggleDir, onReferenceFile }) => (
<ExplorerView
sessionId={scope.sessionId}
cwd={scope.cwd}
hidden: false,
dedupeKey: (tab) => tab.path,
// Declarative settings: the file-open behavior picker (in-place switch
// vs per-path windows) renders as an iconed select row under the
// editor card's gear in the Side card settings page.
settings: {
toggles: [{
key: 'editorExplorer',
type: 'select',
title: () => t('editorExplorer'),
desc: () => t('editorExplorerDesc'),
options: [
{
value: true,
icon: (size: number) => <IconPanelLeftOutline16 size={size} />,
title: () => t('editorExplorerMerged'),
desc: () => t('editorExplorerMergedDesc'),
},
{
value: false,
icon: (size: number) => <IconCodeOutline16 size={size} />,
title: () => t('editorExplorerSplit'),
desc: () => t('editorExplorerSplitDesc'),
},
],
}],
},
component: ({ ctx, store, scope, tab, expanded, onToggleDir, onReferenceFile }) => (
<EditorHost
ctx={ctx}
store={store}
scope={scope}
tab={tab}
expanded={expanded ?? []}
onToggle={onToggleDir ?? (() => { /* no-op */ })}
onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }}
onToggleDir={onToggleDir ?? (() => { /* no-op */ })}
onReferenceFile={onReferenceFile ?? (() => { /* no-op */ })}
/>
),
@@ -152,6 +185,18 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
key: 'bottomPanelAutoTerminal',
title: () => t('settingsBottomTerminalTitle'),
desc: () => t('settingsBottomTerminalDesc'),
}, {
key: 'terminalShell',
type: 'text',
title: () => t('settingsShellTitle'),
desc: () => t('settingsShellDesc'),
placeholder: t('settingsShellPlaceholder'),
}, {
key: 'terminalShellArgs',
type: 'text',
title: () => t('settingsShellArgsTitle'),
desc: () => t('settingsShellArgsDesc'),
placeholder: t('settingsShellArgsPlaceholder'),
}, {
key: 'terminalFontFamily',
type: 'text',
@@ -173,10 +218,12 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
if (count >= TERMINAL_LIMIT) return null
return {
tab: {
id: `terminal:${state.nextTerminal}`,
id: `terminal:${terminalUuid()}`,
type: 'terminal',
title: `${t('terminal')} ${state.nextTerminal}`,
title: options.terminalTitle?.() ?? t('terminal'),
},
// Keep the legacy counter advancing for compatibility with older
// persisted states; new ids no longer use it.
patch: { nextTerminal: state.nextTerminal + 1 },
}
},
@@ -187,9 +234,10 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
title: () => t('browser'),
icon: (size: number) => <IconGlobeOutline16 size={size} />,
order: 50,
// Declarative settings: the sandbox escape hatch and the link
// takeover render under this tab's row in the Side card settings
// page (the sandbox one is warned on).
// Declarative settings: the sandbox escape hatch, the link-takeover
// MASTER switch, and the per-protocol takeover switches (http on /
// https off by default) render under this tab's row in the Side card
// settings page (the sandbox one is warned on).
settings: {
toggles: [{
key: 'browserNoSandbox',
@@ -199,6 +247,14 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
key: 'browserInterceptLinks',
title: () => t('settingsBrowserLinksTitle'),
desc: () => t('settingsBrowserLinksDesc'),
}, {
key: 'browserInterceptHttp',
title: () => t('settingsBrowserHttpTitle'),
desc: () => t('settingsBrowserHttpDesc'),
}, {
key: 'browserInterceptHttps',
title: () => t('settingsBrowserHttpsTitle'),
desc: () => t('settingsBrowserHttpsDesc'),
}],
},
createTab: (state) => ({
+172 -19
View File
@@ -29,7 +29,10 @@
* Caching contract (three layers, each with a failure path):
* - In-memory: one in-flight promise per chunk, memoized until
* {@link resetChunks}; a failed load removes its entry so the next call
* retries from scratch.
* retries from scratch. HMR re-activation keeps the resolved exports of
* unchanged chunks (ETag revalidation via
* {@link revalidateChunksOnReactivate}) — the next lazy open skips the
* re-inject / re-execute.
* - Script execution: each re-execution overwrites the global registry slot
* (assignment, never registration) — no "duplicate factory registration"
* class of errors; a failed materialization clears the cache so the retry
@@ -38,13 +41,15 @@
* no-cache` + ETag, 304 when unchanged), so page refreshes and HMR
* re-activations never re-download a multi-MB chunk that did not change.
*
* HMR: each plugin activation calls {@link resetChunks}, which drops the
* in-memory cache (and any test registry), so a hot-reloaded core bundle
* re-fetches and re-executes the current chunk scripts on the next lazy
* open. Chunk-only source edits still need a manual page refresh (the HMR
* poll watches only client.js).
* HMR: each plugin activation calls {@link revalidateChunksOnReactivate},
* which HEADs every loaded chunk against the bundle route and keeps the
* in-memory cache for the ones whose ETag is unchanged (a hot-reloaded core
* bundle therefore does not re-execute multi-MB chunk scripts). Chunk-only
* source edits still need a manual page refresh (the HMR poll watches only
* client.js); an edit that does land while a core HMR happens is caught by
* the ETag comparison on the next activation.
*/
export type ChunkName = 'terminal' | 'editor'
export type ChunkName = 'terminal' | 'editor' | 'mermaid'
/** The module exports a chunk factory provides (namespace-ish record). */
export type ChunkExports = Record<string, unknown>
@@ -56,7 +61,12 @@ type ChunkFactory = (require: (spec: string) => unknown) => ChunkExports
* The platform externals a chunk bundle may require (mirror of
* CLIENT_EXTERNALS in tsdown.config.ts — the chunk builds keep these
* external and the loader resolves them here). A superset is safe: the
* require only answers what the chunk actually asks for.
* require only answers what the chunk actually asks for. The shell's static
* module table seeds React, Cordis, and the UI libraries (primitives/slots);
* `dsh-client-runtime/client` normalizes onto the runtime package row
* (stripClientSuffix). dsh-client-web-react / dsh-client-schema-form were
* dropped in DSH 0.1.0-rc.8 (no rc.8 publish, nothing requires them) — the
* chunks never asked for them, so they no longer belong here.
*/
export const CHUNK_EXTERNALS: readonly string[] = [
'react',
@@ -65,23 +75,61 @@ export const CHUNK_EXTERNALS: readonly string[] = [
'react-dom/client',
'cordis',
'@deepseek-ai/dsh-client-ui-slots',
'@deepseek-ai/dsh-client-web-react',
'@deepseek-ai/dsh-client-ui-primitives',
'@deepseek-ai/dsh-client-schema-form',
'@deepseek-ai/dsh-client-runtime/client',
]
/** Chunk script endpoint served by the plugin host half (src/bundle-route.ts). */
const CHUNK_URL = (name: ChunkName): string => `/sidebar/bundle/${name}.js`
/** The client module system surface this loader needs (window.__DSH_MODULES__). */
interface ChunkModuleSystem {
/** Bound on the revalidation HEAD round-trip. A timeout fails open (drop +
* re-fetch on the next open) so a stuck bundle route can never wedge lazy
* chunk loads behind the revalidation barrier. */
const CHUNK_REVALIDATE_TIMEOUT_MS = 5_000
/**
* The client module system surface this loader needs to resolve externals.
* DSH 0.1.0-rc.8 provides it as the `ctx.modules` service (no page global
* anymore); the plugin injects it at activation via
* {@link setChunkModuleSystem}. The rc.7-era `window.__DSH_MODULES__` global
* remains as a fallback so older hosts and the test harness keep working.
*/
export interface ChunkModuleSystem {
import(specifier: string): Promise<unknown>
}
/** Resolve the shell-installed module system (set before any plugin activates). */
/** The module system injected by the client half at activation (rc.8+). */
let injectedModuleSystem: ChunkModuleSystem | undefined
/**
* Plugin-owned page global carrying the injected module system across
* bundle copies: the lazy chunk bundles (client-editor.js etc.) inline their
* own chunk-loader instance, and rc.8 no longer exposes the shell module
* system as a page global — so the core bundle's injection must be visible
* to the chunk copies through a namespace of our own.
*/
const MODULE_SYSTEM_GLOBAL = '__dshSidebarModuleSystem__'
/**
* Inject the client module system the chunk externals resolve through.
* Called by the client half's apply() with `ctx.modules` (rc.8+); pass
* undefined to clear (tests). Survives {@link resetChunks} — the module
* system is shell state, not chunk state, and stays live across HMR.
*/
export function setChunkModuleSystem(system: ChunkModuleSystem | undefined): void {
injectedModuleSystem = system
const g = globalThis as Record<string, unknown>
if (system === undefined) delete g[MODULE_SYSTEM_GLOBAL]
else g[MODULE_SYSTEM_GLOBAL] = system
}
/** Resolve the shell-installed module system (injected, then the plugin
* global shared with chunk-bundle copies, then the rc.7 page global). */
function moduleSystem(): ChunkModuleSystem | undefined {
return (globalThis as { __DSH_MODULES__?: ChunkModuleSystem }).__DSH_MODULES__
const g = globalThis as Record<string, unknown>
return injectedModuleSystem
?? g[MODULE_SYSTEM_GLOBAL] as ChunkModuleSystem | undefined
?? (g as { __DSH_MODULES__?: ChunkModuleSystem }).__DSH_MODULES__
}
/** The plugin-owned chunk factory registry the chunk scripts populate. */
@@ -155,6 +203,35 @@ async function buildExternalsRequire(modules: ChunkModuleSystem): Promise<(spec:
/** In-flight/memoized chunk loads; a failure removes its entry so a retry re-fetches. */
const cache = new Map<ChunkName, Promise<ChunkExports>>()
/** Chunk names whose exports are currently cached (loaded successfully). */
const loadedChunks = new Set<ChunkName>()
/** ETags observed for loaded chunks (HEAD revalidation, see
* {@link revalidateChunksOnReactivate}). */
const chunkEtags = new Map<ChunkName, string>()
/** Pending revalidation barrier: while set, {@link loadChunk} awaits it
* before serving cache (see revalidateChunksOnReactivate). */
let revalidation: Promise<void> | null = null
/** Best-effort ETag capture for revalidation. The script tag itself exposes
* no response headers, so after a successful load we HEAD the bundle route
* once. Failures (including a stuck route — bounded by the timeout) are
* ignored — revalidation then fails open (re-fetch). */
async function recordEtag(name: ChunkName): Promise<void> {
try {
const res = await fetch(CHUNK_URL(name), {
method: 'HEAD',
cache: 'no-cache',
signal: AbortSignal.timeout(CHUNK_REVALIDATE_TIMEOUT_MS),
})
const etag = res.headers.get('etag')
if (etag !== null && etag !== '') chunkEtags.set(name, etag)
} catch {
chunkEtags.delete(name)
}
}
/**
* Load (once) and materialize a lazy chunk, returning its module exports.
* Concurrent callers share one in-flight load; a failure clears the cache
@@ -162,10 +239,14 @@ const cache = new Map<ChunkName, Promise<ChunkExports>>()
* global registry slot — assignments are idempotent).
* @param name - the chunk to load.
*/
export function loadChunk(name: ChunkName): Promise<ChunkExports> {
export async function loadChunk(name: ChunkName): Promise<ChunkExports> {
// Barrier: never serve a cache entry that a pending revalidation is about
// to inspect — a stale chunk could otherwise render mid-HMR (CR #232 P1).
if (revalidation !== null) await revalidation
const cached = cache.get(name)
if (cached !== undefined) return cached
const task = (async (): Promise<ChunkExports> => {
let task: Promise<ChunkExports>
task = (async (): Promise<ChunkExports> => {
const test = testLoaders.get(name)
if (test !== undefined) return test()
const modules = moduleSystem()
@@ -178,10 +259,23 @@ export function loadChunk(name: ChunkName): Promise<ChunkExports> {
throw new Error(`[dsh-better-sidebar] chunk "${name}" script did not register its factory`)
}
const require = await buildExternalsRequire(modules)
return factory(require)
const exports = factory(require)
// Only track production loads whose cache entry survived (a revalidation
// sweep may have dropped it mid-flight; the caller still gets these
// exports, they just are not memoized for the next open). Test-registry
// loads return above and never reach this tracking.
if (cache.get(name) !== undefined) {
loadedChunks.add(name)
void recordEtag(name)
}
return exports
})()
cache.set(name, task)
void task.catch(() => { cache.delete(name) })
void task.catch(() => {
cache.delete(name)
loadedChunks.delete(name)
chunkEtags.delete(name)
})
return task
}
@@ -189,10 +283,69 @@ export function loadChunk(name: ChunkName): Promise<ChunkExports> {
* Drop all chunk state for a fresh plugin activation (HMR-safe): clear the
* in-memory cache and any test-registry entries, so the next lazy open
* re-fetches and re-executes the current chunk scripts (the registry slots
* are overwritten by the re-execution — no cleanup needed).
* are overwritten by the re-execution — no cleanup needed). A pending
* revalidation barrier is cleared too: it was only guarding the cache reads
* of the state being dropped, so the next load must not wait on it (the
* orphaned task still settles and its identity-guarded `finally` no-ops).
*/
export function resetChunks(): void {
cache.clear()
loadedChunks.clear()
chunkEtags.clear()
testLoaders.clear()
externalsRequire = undefined
revalidation = null
}
/**
* HMR-safe re-activation hook (index.tsx calls this instead of a full
* reset): keep the resolved exports of every loaded chunk and drop only the
* ones whose script changed on disk — the bundle route revalidates every
* request (cache-control: no-cache + ETag), so an unchanged chunk keeps its
* memory cache and the next lazy open skips the re-inject / re-execute.
* Fail-open: an unreachable, ETag-less, or timed-out chunk is dropped
* (re-fetch on next open). Test-registry entries are always cleared
* (per-test fixtures).
* A page refresh remains the authoritative reset (the HMR poll watches only
* client.js; chunk-only edits surface here on the next core re-activation).
*
* The returned promise is also a BARRIER for {@link loadChunk}: while a
* revalidation is pending, every chunk load awaits it before serving cache,
* so a lazy tab opening mid-revalidation can never render stale exports
* that the sweep is about to invalidate (CR #232 P1).
*/
export function revalidateChunksOnReactivate(): Promise<void> {
testLoaders.clear()
const task = (async (): Promise<void> => {
// Entries not tracked as production-loaded (test fixtures, orphans) never
// survive a re-activation — their resolved exports came from per-test
// stubs, not from the bundle route.
for (const name of [...cache.keys()]) {
if (!loadedChunks.has(name)) cache.delete(name)
}
if (loadedChunks.size === 0) return
const stale: ChunkName[] = []
await Promise.all([...loadedChunks].map(async (name) => {
try {
const res = await fetch(CHUNK_URL(name), {
method: 'HEAD',
cache: 'no-cache',
signal: AbortSignal.timeout(CHUNK_REVALIDATE_TIMEOUT_MS),
})
const etag = res.headers.get('etag')
if (etag !== null && etag !== '' && chunkEtags.get(name) === etag) return
} catch {
// Fail open below (network errors and timeouts alike).
}
stale.push(name)
}))
for (const name of stale) {
cache.delete(name)
loadedChunks.delete(name)
chunkEtags.delete(name)
}
})()
revalidation = task
void task.finally(() => { if (revalidation === task) revalidation = null })
return task
}
+10
View File
@@ -0,0 +1,10 @@
/**
* Lazy chunk entry: the mermaid diagram renderer for the markdown preview
* (mermaid + its d3/dagre/cytoscape graph deps). Built as
* lib/client-mermaid.js and registered under the `mermaid` global chunk
* slot — fetched only when a previewed markdown file contains a mermaid
* fence (see chunk-loader.ts and the mermaid markdown preview design doc).
* Never import this module from the core bundle: it pulls mermaid into the
* startup path.
*/
export { MermaidMarkdown } from '../mermaid.tsx'
+58
View File
@@ -0,0 +1,58 @@
/**
* Desktop-shell detection for the sidebar. The official DSH Desktop shell
* (Electron) stamps every render URL with `dsh-desktop-mode` and
* `dsh-desktop-platform` and exposes `window.__DSH_DESKTOP_FILE_PATH__`
* through its preload; community Tauri shells keep the native system frame
* (no stamps — plain-browser semantics). Parsed once per page and memoized
* (the URL never changes mid-session); `resetDesktopEnvForTests` clears the
* memo for unit tests.
*
* Geometry facts the sidebar adapts to (from the Electron runtime):
* - win32 `advanced` mode draws the window controls in a 32px overlay strip
* at the top-right of the web content (`titleBarOverlay: { height: 32 }`)
* — exactly where the toggle cluster sits, so it must drop below the
* strip and the right panel's content must clear it.
* - darwin `advanced` mode keeps the traffic lights at (16,16) in the
* top-left; nothing on the plugin's side touches that corner today, but
* the platform is still reported so left-edge controls can avoid it.
* - `compatibility` mode keeps the native frame — no adaptation needed.
*/
export interface DesktopEnv {
/** Running inside a desktop shell (any URL stamp or preload marker). */
readonly desktop: boolean
/** `advanced` = frameless/custom-titlebar shell; `compatibility` = native frame. */
readonly mode: 'compatibility' | 'advanced' | null
/** Shell platform stamp ('darwin' | 'win32' | …), lowercased, or null. */
readonly platform: string | null
/** Pixels the win32 advanced shell reserves at the top-right for window
* controls (0 elsewhere). */
readonly win32OverlayTop: number
}
let cached: DesktopEnv | undefined
/** Read the shell's desktop stamps (memoized per page). */
export function parseDesktopEnv(): DesktopEnv {
if (cached !== undefined) return cached
// location.search includes the leading '?', which URLSearchParams does NOT
// strip (it would become part of the first key) — drop it explicitly.
const params = new URLSearchParams(window.location.search.replace(/^\?/, ''))
const modeParam = params.get('dsh-desktop-mode')
const mode = modeParam === 'compatibility' || modeParam === 'advanced' ? modeParam : null
const platformParam = params.get('dsh-desktop-platform')
const platform = platformParam !== null && platformParam !== '' ? platformParam.toLowerCase() : null
const desktop = mode !== null
|| typeof (window as { __DSH_DESKTOP_FILE_PATH__?: unknown }).__DSH_DESKTOP_FILE_PATH__ !== 'undefined'
cached = {
desktop,
mode,
platform,
win32OverlayTop: desktop && mode === 'advanced' && platform === 'win32' ? 32 : 0,
}
return cached
}
/** Test hook: drop the memo so the next parse re-reads the URL/globals. */
export function resetDesktopEnvForTests(): void {
cached = undefined
}
+187 -30
View File
@@ -11,16 +11,17 @@
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import type { Context } from '../context-types.ts'
import { createSidebarStore } from './state.ts'
import { createBetterSidebarService } from './service.ts'
import { resetChunks } from './chunk-loader.ts'
import { allLeaves, createSidebarStore, isAgentTabId } from './state.ts'
import { createBetterSidebarService, matchUrlTarget } from './service.ts'
import { revalidateChunksOnReactivate, setChunkModuleSystem } from './chunk-loader.ts'
import { registerBuiltins } from './builtins/index.ts'
import { Sidebar } from './Sidebar.tsx'
import { RenderBoundary } from './RenderBoundary.tsx'
import { registerOpenPathInterception, registerTurnTailInterception } from './intercept.tsx'
import { registerLinkInterception } from './link-intercept.ts'
import { registerImeGuard } from './ime-guard.ts'
import { loadPrefs } from './prefs.ts'
import { registerSettingsNavIcon } from './settings-nav-icon.ts'
import { loadExternalDisable, loadPrefs } from './prefs.ts'
import { SideCardSection } from './SideCardSection.tsx'
import { api } from './api.ts'
import { LOCALE_NS, attachLocale, t, zh, en } from './locales.ts'
@@ -28,8 +29,10 @@ import css from './sidebar.module.css'
import './layout.css'
/** Services required before mounting (provided by the client runtime; the
* locale service backs the sidebar's copy — see locales.ts). */
export const inject = ['slots', 'sessions', 'connection', 'workspaces', 'locale']
* locale service backs the sidebar's copy — see locales.ts). `modules`
* (rc.8+) is the client module system the chunk loader resolves its
* externals through — Cordis guards service access without inject. */
export const inject = ['slots', 'sessions', 'connection', 'workspaces', 'locale', 'modules']
/**
* Error boundary over the sidebar tree (root scope): a render error in the
@@ -65,11 +68,31 @@ export function apply(ctx: Context): void {
// are ready by the time the sidebar renders.
const service = createBetterSidebarService(sidebarStore)
ctx.provide('betterSidebar', service)
// Terminal tab titles use the host's effective shell name (e.g. bash/zsh)
// instead of "Terminal 1". Start with a safe fallback and replace it as
// soon as the host shell info resolves. Tabs created before the response
// arrives keep the fallback title, so also retitle any already-open UI
// terminal tabs that still carry it.
const fallbackTitle = t('terminal')
let terminalTitle = fallbackTitle
void api.shellGet().then(({ name }) => {
terminalTitle = name
const snapshot = service.getSnapshot()
if (snapshot.state === undefined) return
const tabs = allLeaves(snapshot.state.splits)
.concat(allLeaves(snapshot.state.bottomSplits))
.flatMap(leaf => leaf.tabs)
for (const tab of tabs) {
if (tab.type === 'terminal' && !isAgentTabId(tab.id) && tab.title === fallbackTitle) {
service.updateTab(tab.id, { title: name })
}
}
}).catch(() => { /* keep fallback */ })
// Register the plugin's own built-in tabs and viewers through the same
// service (eating our own dogfood). The disposer unregisters them on
// fiber disposal (HMR-safe).
ctx.effect(
() => registerBuiltins(ctx, service),
() => registerBuiltins(ctx, service, { terminalTitle: () => terminalTitle }),
'dsh-better-sidebar: register built-in tabs and viewers',
)
// A failure anywhere in the client lifecycle must never take the app down
@@ -89,15 +112,113 @@ export function apply(ctx: Context): void {
}
}
try {
// Fresh chunk state for this activation: invalidate any chunk factories
// registered by a previous fiber (HMR) and drop the in-memory load cache
// so the next lazy open re-fetches the current chunk scripts.
resetChunks()
// rc.8+ exposes the client module system as the `ctx.modules` service
// (no window.__DSH_MODULES__ page global anymore); the chunk loader needs
// it to resolve its externals, so inject it before anything can load a
// lazy chunk. The loader falls back to the rc.7 global when absent.
setChunkModuleSystem(ctx.modules)
// Fresh chunk state for this activation: drop per-test fixtures and
// revalidate loaded chunk scripts against the bundle route's ETags —
// unchanged chunks keep their resolved exports (no re-inject /
// re-execute on HMR), changed ones are dropped for a clean re-fetch.
void revalidateChunksOnReactivate()
ctx.effect(() => {
let disposed = false
let root: Root | undefined
let host: HTMLDivElement | undefined
void (async () => {
let mounted = false
let bodyObserver: MutationObserver | undefined
let hostCheckFrame: number | null = null
const unmount = (): void => {
if (!mounted) return
mounted = false
bodyObserver?.disconnect()
bodyObserver = undefined
if (hostCheckFrame !== null) {
cancelAnimationFrame(hostCheckFrame)
hostCheckFrame = null
}
root?.unmount()
root = undefined
host?.remove()
host = undefined
}
/** Re-attach the host if the page (a desktop shell wrapper, SPA
* navigation, …) ever removes it from <body>. Cheap: childList only,
* no subtree, no attribute filtering. */
const guardAnchor = (): void => {
if (bodyObserver !== undefined) return
bodyObserver = new MutationObserver(() => {
if (host !== undefined && !document.body.contains(host)) {
document.body.appendChild(host)
}
})
bodyObserver.observe(document.body, { childList: true })
}
/** One-shot geometry self-check: if the host page transforms
* <html>/<body> itself (exotic shells), a fixed panel host would
* track the transformed box instead of the viewport. Flip the
* degraded mode and pin the host to the viewport every frame until
* the ancestor transform is actually gone. The normal path (no
* page-level transform) never runs the sync loop. */
const scheduleHostCheck = (): void => {
hostCheckFrame ??= requestAnimationFrame(() => {
hostCheckFrame = null
const layer = host?.querySelector<HTMLElement>('[data-dsh-panel-host]')
if (layer === null || layer === undefined) return
const rect = layer.getBoundingClientRect()
const mismatched = Math.abs(rect.left) > 8 || Math.abs(rect.top) > 8
|| Math.abs(rect.width - window.innerWidth) > 8 || Math.abs(rect.height - window.innerHeight) > 8
if (!mismatched) {
layer.removeAttribute('data-dsh-panel-host-degraded')
layer.style.transform = ''
return
}
layer.setAttribute('data-dsh-panel-host-degraded', '')
console.warn('[dsh-better-sidebar] panel host geometry mismatch — a page-level transform was detected; using degraded viewport sync')
// Track our own compensating translation so the loop judges the
// UNCORRECTED geometry: clearing degraded mode must wait for the
// ancestor transform to actually disappear — the frame right after
// our correction applies would otherwise look "fixed" and the
// offset would return immediately (CR #232 P1).
let applied = { x: 0, y: 0 }
const sync = (): void => {
const r = layer.getBoundingClientRect()
const rawLeft = r.left - applied.x
const rawTop = r.top - applied.y
if (Math.abs(rawLeft) <= 1 && Math.abs(rawTop) <= 1
&& Math.abs(r.width - window.innerWidth) <= 1 && Math.abs(r.height - window.innerHeight) <= 1) {
layer.removeAttribute('data-dsh-panel-host-degraded')
layer.style.transform = ''
return
}
const next = { x: -rawLeft, y: -rawTop }
if (next.x !== applied.x || next.y !== applied.y) {
applied = next
layer.style.transform = `translate(${applied.x}px, ${applied.y}px)`
}
hostCheckFrame = requestAnimationFrame(sync)
}
hostCheckFrame = requestAnimationFrame(sync)
})
}
const mount = (): void => {
if (mounted || disposed) return
try {
host = document.createElement('div')
host.setAttribute('data-dsh-better-sidebar', '')
document.body.appendChild(host)
root = createRoot(host)
root.render(createElement(RenderBoundary, { className: css.boundaryError }, createElement(Sidebar, { ctx, store: sidebarStore })))
mounted = true
guardAnchor()
scheduleHostCheck()
} catch (error) {
fail('mount', error)
}
}
const sync = async (): Promise<void> => {
if (disposed) return
// Resolve the user's side card prefs BEFORE the first session seeds,
// so a brand-new conversation opens (or stays closed) at the chosen
// width from first paint. A settings route failure falls back to the
@@ -109,20 +230,26 @@ export function apply(ctx: Context): void {
])
if (prefs !== null) sidebarStore.setPrefs(prefs)
if (disposed) return
try {
host = document.createElement('div')
host.setAttribute('data-dsh-better-sidebar', '')
document.body.appendChild(host)
root = createRoot(host)
root.render(createElement(RenderBoundary, { className: css.boundaryError }, createElement(Sidebar, { ctx, store: sidebarStore })))
} catch (error) {
fail('mount', error)
}
})()
// Mutual exclusion with the dsh-web-ui family right panel: while the
// aionui-panel provider is selected, the sidebar must not mount at
// all. Re-evaluated on every settings-document update (live switch).
const suspended = await loadExternalDisable(api)
if (disposed) return
sidebarStore.setSuspended(suspended)
if (suspended) unmount()
else mount()
}
void sync()
// Live re-evaluation: the runtime broadcasts settings-document updates
// (the aionui card saves through the same document). Best effort —
// deployments without the 'remote' service fall back to boot-time
// evaluation only.
const remote = ctx.get('remote') as { $on?: (event: string, listener: () => void) => () => void } | undefined
const offRemote = remote?.$on?.('settings/document-updated', () => { void sync() })
return () => {
disposed = true
root?.unmount()
host?.remove()
offRemote?.()
unmount()
}
}, 'dsh-better-sidebar: sidebar mount')
@@ -153,16 +280,36 @@ export function apply(ctx: Context): void {
ctx.effect(
() => {
try {
// External http(s) links in the chat/GUI open the sidebar browser
// instead of a new window (gated on the browserInterceptLinks pref
// AND the browser tab's enable switch; Ctrl/Cmd+click bypasses).
// External http(s) links in the chat/GUI open the sidebar instead
// of a new window. Gated on the browserInterceptLinks MASTER pref,
// the URL's protocol flag (browserInterceptHttp / Https — https
// defaults OFF: most https sites refuse iframe embedding), and the
// target tab's enable switch; Ctrl/Cmd+click always bypasses. The
// target is the first registered tab whose `urlTarget` claims the
// URL (enabled tabs only), else the built-in browser tab.
const urlTargetOf = (url: URL): string | undefined => {
const prefs = sidebarStore.getPrefs()
const enabled = service.getTabs().filter(tab => prefs.tabsEnabled[tab.id] !== false)
return matchUrlTarget(enabled, url)?.id
}
return registerLinkInterception({
takeoverEnabled: () => sidebarStore.getPrefs().browserInterceptLinks !== false
&& sidebarStore.getPrefs().tabsEnabled['browser'] !== false,
takeoverEnabled: (url) => {
if (sidebarStore.getSuspended()) return false
const prefs = sidebarStore.getPrefs()
if (prefs.browserInterceptLinks === false) return false
const protocolOn = url.protocol === 'https:'
? prefs.browserInterceptHttps !== false
: prefs.browserInterceptHttp !== false
if (!protocolOn) return false
// A plugin claim is the target (already enabled-filtered);
// otherwise the built-in browser must be enabled.
return urlTargetOf(url) !== undefined || prefs.tabsEnabled['browser'] !== false
},
openInSidebar: (url) => {
let title: string | undefined
try { title = new URL(url).hostname } catch { /* keep the default title */ }
ctx.betterSidebar?.openTab({ type: 'browser', url, title })
const type = urlTargetOf(new URL(url)) ?? 'browser'
ctx.betterSidebar?.openTab({ type, url, title })
},
selfOrigin: window.location.origin,
})
@@ -194,6 +341,16 @@ export function apply(ctx: Context): void {
'dsh-better-sidebar: IME composition guard',
)
// DSH 0.1.x does not yet carry an icon through the settings.section
// registration contract: its shell renders a generic gear for every
// external section. Mark only this plugin's localized nav row so
// layout.css can paint the requested Side card SVG; the disposer clears
// the marker for HMR / plugin disable.
ctx.effect(
() => registerSettingsNavIcon(() => t('settingsNav')),
'dsh-better-sidebar: settings navigation icon',
)
// The "Side card" settings section: appears in the DSH Settings shell
// once the shell's declaration is on the ledger (slots.inject waits for
// it); the section reads/writes the prefs through the plugin's own
+5 -2
View File
@@ -76,8 +76,10 @@ export function registerTurnTailInterception(ctx: Context, store: SidebarStore):
name: 'conversation.chat.turnTail',
// Decline the takeover while the editor tab type is disabled in the side
// card settings: the produced-files row falls back to the default
// deliverables behavior instead of offering chips that cannot open.
// deliverables behavior instead of offering chips that cannot open. Also
// while the sidebar is externally disabled (aionui-panel chosen).
select: (owner) => {
if (store.getSuspended()) return null
if (store.getPrefs().tabsEnabled['editor'] === false) return null
return selectProducedFiles(owner)
},
@@ -99,7 +101,8 @@ export function registerTurnTailInterception(ctx: Context, store: SidebarStore):
*/
export function registerOpenPathInterception(ctx: Context, store: SidebarStore): () => void {
return wrapOpenPath(ctx.workspaces, {
takeoverEnabled: () => store.getPrefs().interceptOpenPath !== false
takeoverEnabled: () => !store.getSuspended()
&& store.getPrefs().interceptOpenPath !== false
&& store.getPrefs().tabsEnabled['editor'] !== false,
currentSessionId: () => ctx.sessions.list.getSnapshot().current,
openInSidebar: (path, sessionId) => { openSidebarFile(ctx, store, sessionId, path) },
+8
View File
@@ -23,6 +23,8 @@ import { toml } from '@codemirror/legacy-modes/mode/toml'
import { nginx } from '@codemirror/legacy-modes/mode/nginx'
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile'
import { properties } from '@codemirror/legacy-modes/mode/properties'
import { csharp, kotlin } from '@codemirror/legacy-modes/mode/clike'
import { swift } from '@codemirror/legacy-modes/mode/swift'
/** The lowercased file extension of a path ('' when none). */
export function extOf(path: string): string {
@@ -48,6 +50,9 @@ export function languageKeyForExt(ext: string): string | null {
case 'yaml': case 'yml': return 'yaml'
case 'sql': return 'sql'
case 'java': return 'java'
case 'cs': return 'csharp'
case 'kt': case 'kts': return 'kotlin'
case 'swift': return 'swift'
case 'c': case 'h': return 'c'
case 'cc': case 'cpp': case 'cxx': case 'hpp': case 'hh': case 'hxx': return 'cpp'
case 'rs': return 'rust'
@@ -76,6 +81,9 @@ const FACTORIES: Record<string, () => Language | LanguageSupport> = {
yaml: () => yaml(),
sql: () => sql(),
java: () => java(),
csharp: () => StreamLanguage.define(csharp),
kotlin: () => StreamLanguage.define(kotlin),
swift: () => StreamLanguage.define(swift),
c: () => cpp(),
cpp: () => cpp(),
rust: () => rust(),
+42 -15
View File
@@ -5,14 +5,22 @@
* panel's width squeeze (margin-right on #root) lands exactly on the
* conversation output and the input bar, like a VSCode sidebar.
*
* The width rides `calc(100% - var(...))` instead of a bare margin on a
* full-width box: some desktop shells (DSH Desktop, #208) set #root to
* width:100%, where a margin would overflow the viewport additively —
* the calc keeps the box at exactly 100% minus the push in every shell.
* Width and margin transition in lockstep (same variable, same duration
* and easing), so expand/collapse animates the content width exactly as
* the bare-margin version did.
*
* The bottom panel squeezes ONLY the center column — it must not cover the
* app's own left sidebar or the right panel. DSH 0.1.x wraps slot hosts in
* [data-slot] containers, so the AppFrame grid lives one level deeper:
* #root > div[data-slot="root"] > div (the frame). Its grid items are
* (frame > div:nth-child(1..3)): sidebarCol, centerCol, detailsCol, so the
* vertical push lands on the center column alone. The fixed panels cover
* the vacated strips, which looks seamless because #root's background is
* the theme base.
* app's own left sidebar or the right panel. The anchor is the frame's
* center grid item via its stable data attribute ([data-pane="conversation"]
* on the centerCol inside [data-dsh-frame]), not a positional path: the
* frame's child order also contains an overlay layer and drag handles
* (verified against a live DSH 0.1.x page), so nth-child is brittle. A
* stretched grid item shrinks by its margins, so the conversation content
* (output + input bar) lifts without touching the sidebars.
*
* The sizes ride CSS variables updated by the Sidebar shell (0 while
* collapsed); expand/collapse animates both the margins and the panel
@@ -21,14 +29,14 @@
*/
#root {
margin-right: var(--dsh-sidebar-width, 0px);
transition: margin-right var(--ds-transition-duration-slow) var(--ds-ease-in-out);
width: calc(100% - var(--dsh-sidebar-width, 0px));
transition:
margin-right var(--ds-transition-duration-slow) var(--ds-ease-in-out),
width var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
/* The AppFrame's grid items are sidebarCol, centerCol, detailsCol (children
1-3 of #root > div[data-slot="root"] > div) — nth-child(2) is the center
column. A stretched grid item shrinks by its margins, so the conversation
content (output + input bar) lifts without touching the sidebars. */
#root > div[data-slot="root"] > div > div:nth-child(2) {
/* The AppFrame's center column, anchored by data attributes (see above). */
#root [data-dsh-frame] > [data-pane="conversation"] {
margin-bottom: var(--dsh-sidebar-height, 0px);
transition: margin-bottom var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
@@ -47,13 +55,32 @@ body[data-dsh-sidebar-collapsed] [data-slot="conversation.session.header"] > hea
}
body[data-dsh-sidebar-dragging] #root,
body[data-dsh-sidebar-dragging] #root > div[data-slot="root"] > div > div:nth-child(2) {
body[data-dsh-sidebar-dragging] #root [data-dsh-frame] > [data-pane="conversation"] {
transition: none;
}
/* DSH 0.1.x gives external settings sections a generic gear and exposes no
icon field in the settings.section contract. settings-nav-icon.ts marks
only this plugin's localized row; render the requested Lucide
gallery-horizontal-end SVG as a currentColor mask so it follows the native
nav hover/active colors without changing the shell's 16px icon rhythm. */
[data-dsh-better-sidebar-settings-nav] > svg:first-child {
display: none;
}
[data-dsh-better-sidebar-settings-nav]::before {
content: '';
flex: none;
width: 16px;
height: 16px;
background: currentColor;
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2 7v10'/%3E%3Cpath d='M6 5v14'/%3E%3Crect width='12' height='18' x='10' y='3' rx='2'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2 7v10'/%3E%3Cpath d='M6 5v14'/%3E%3Crect width='12' height='18' x='10' y='3' rx='2'/%3E%3C/svg%3E") center / contain no-repeat;
}
@media (prefers-reduced-motion: reduce) {
#root,
#root > div[data-slot="root"] > div > div:nth-child(2) {
#root [data-dsh-frame] > [data-pane="conversation"] {
transition: none;
}
}
+17 -10
View File
@@ -1,10 +1,12 @@
/**
* Chat/GUI external-link interception: clicking an http(s) link that points
* OUTSIDE the GUI (chat messages, tool rows, prose mentions) opens the
* sidebar browser instead of a new browser tab. Gated by BOTH the
* `browserInterceptLinks` pref and the browser tab's enable switch; a
* Ctrl/Cmd/Shift/Alt-modified click always bypasses the takeover so the
* user can still force a real browser tab.
* sidebar instead of a new browser tab. Gated by the caller through
* `takeoverEnabled(url)` — the `browserInterceptLinks` master, the URL's
* protocol flag (`browserInterceptHttp` / `browserInterceptHttps`) and the
* target tab's enable switch — and a Ctrl/Cmd/Shift/Alt-modified click
* always bypasses the takeover so the user can still force a real browser
* tab.
*
* Only the GUI's OWN document is watched — links inside the browser tab's
* sandboxed iframe live in another document and never bubble here (and
@@ -13,7 +15,10 @@
/** The pure decision: the URL to open in the sidebar, or null to let the
* click fall through. Extracted so the policy is unit-testable without a
* DOM. `anchorHref` must be the ABSOLUTE href (`<a>.href` already is). */
* DOM. `anchorHref` must be the ABSOLUTE href (`<a>.href` already is).
* The protocol/same-origin policy lives HERE; the prefs gates (master +
* protocol flags + target enablement) live in the caller's
* `takeoverEnabled(url)` callback. */
export function shouldInterceptLink(anchorHref: string, selfOrigin: string): string | null {
let url: URL
try {
@@ -23,7 +28,7 @@ export function shouldInterceptLink(anchorHref: string, selfOrigin: string): str
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
// Same-origin links are GUI-internal navigation (settings pages, tool
// docs) — never routed into the sidebar browser.
// docs) — never routed into the sidebar.
try {
if (url.origin === new URL(selfOrigin).origin) return null
} catch {
@@ -39,11 +44,13 @@ export function isPlainLeftClick(event: { button: number; metaKey: boolean; ctrl
/**
* Register the document-level click capture that funnels external links
* into the sidebar browser. Returns the disposer (HMR-safe).
* into the sidebar. Returns the disposer (HMR-safe).
*/
export function registerLinkInterception(opts: {
takeoverEnabled: () => boolean
/** Open the sidebar browser tab at `url` (the caller resolves the session). */
/** Whether the takeover may happen for THIS url (the caller's prefs
* gates: master switch, protocol flag, target enablement). */
takeoverEnabled: (url: URL) => boolean
/** Open the sidebar tab at `url` (the caller resolves the target type). */
openInSidebar: (url: string) => void
/** The GUI's own origin (window.location.origin at registration). */
selfOrigin: string
@@ -51,13 +58,13 @@ export function registerLinkInterception(opts: {
const onClick = (event: MouseEvent): void => {
if (!isPlainLeftClick(event)) return
if (event.defaultPrevented) return
if (!opts.takeoverEnabled()) return
const target = event.target
if (target === null || typeof (target as Element).closest !== 'function') return
const anchor = (target as Element).closest('a[href]') as HTMLAnchorElement | null
if (anchor === null) return
const url = shouldInterceptLink(anchor.href, opts.selfOrigin)
if (url === null) return
if (!opts.takeoverEnabled(new URL(url))) return
event.preventDefault()
opts.openInSidebar(url)
}
+88 -2
View File
@@ -11,12 +11,28 @@
/** The zh dictionary (also registered into the DSH locale registry under {@link LOCALE_NS}). */
export const zh = {
files: '文件',
explorer: '资源管理器',
git: '源代码管理',
terminal: '终端',
editor: '编辑器',
editorExplorer: '文件打开方式',
editorExplorerDesc: '控制文件打开方式',
editorExplorerMerged: '合并',
editorExplorerMergedDesc: '文件在同一窗口内原地切换;新窗口默认展开文件树',
editorExplorerSplit: '独立',
editorExplorerSplitDesc: '无路径窗口即资源管理器(仅文件树);文件各自新开窗口(带文件树,默认收起)',
editorTreeToggle: '文件树面板',
editorPathPlaceholder: '输入文件路径(相对会话目录或绝对路径),Enter 打开',
editorSearchPlaceholder: '按文件名搜索…',
editorSearchNoResults: '无匹配文件',
editorSearchTruncated: '结果过多,仅显示部分匹配',
editorEmptyHint: '从右侧文件树或上方路径输入框选择文件开始预览',
openFileNewTab: '在新 Tab 中打开',
openFileSide: '在侧边打开',
newTab: '新建标签页',
openExplorer: '资源管理器',
brokenSymlink: '失效的软链接',
openGit: 'Git 面板',
newTerminal: '新终端',
terminalLimit: '终端数量已达上限 (3)',
@@ -28,8 +44,16 @@ export const zh = {
terminalError: '终端连接失败',
terminalConnectFailed: '终端多次连接失败',
terminalRetry: '重试',
terminalDepsFailed: '终端依赖 node-pty 加载失败',
terminalDepsHint: '在 DSH 所在环境的终端或 cmd 中执行以下命令修复,然后点重试(node-pty 与 DSH 核心保持同一版本):',
terminalDepsProfile: '(检测到 profile:{profile})',
preview: '预览',
edit: '编辑',
mermaidError: 'Mermaid 渲染失败',
mermaidZoomIn: '放大',
mermaidZoomOut: '缩小',
mermaidZoomReset: '重置',
mermaidZoomHint: '滚轮缩放 · 拖拽平移 · Esc 关闭',
refresh: '刷新',
save: '保存',
saved: '已保存',
@@ -116,6 +140,10 @@ export const zh = {
settingsWidthSuffix: '%',
settingsOpenPathTitle: '聊天区文件在侧边栏打开',
settingsOpenPathDesc: '在聊天里点击文件链接(工具行、产物列表、文件提及)时,在侧边栏编辑器中打开,不再调用系统默认应用',
settingsTitleBarTitle: '位置兼容模式',
settingsTitleBarDesc: '为 Windows 右上角的原生标题栏预留空间:侧边栏按钮与侧边栏内容整体下移,避免被标题栏遮挡',
settingsTitleBarStripTitle: '下移距离',
settingsTitleBarStripDesc: '标题栏条带高度:侧边栏按钮与内容下移的像素数(0–120,默认 40)',
settingsSaveFailed: '保存失败',
settingsConflict: '设置已被其他窗口修改,请重试',
binaryNoPreview: '此文件类型不支持预览',
@@ -134,6 +162,12 @@ export const zh = {
settingsFontSizeTitle: '终端字号',
settingsFontSizeDesc: '终端字号(9–32,默认 13)',
settingsFontSizeSuffix: 'px',
settingsShellTitle: 'Shell 路径',
settingsShellDesc: 'UI 与模型终端启动的 shell(绝对路径或可执行名)。留空按既有顺序解析:yaml 的 config.shell → $SHELL / 登录 shell / Windows 的 powershell.exe。对之后打开的终端生效',
settingsShellPlaceholder: '如 /bin/zsh(留空自动解析)',
settingsShellArgsTitle: 'Shell 参数',
settingsShellArgsDesc: '显式 shell 启动参数,空格分隔;非空时完全替换默认参数(与 yaml 的 shellArgs 契约一致)',
settingsShellArgsPlaceholder: '如 -l(留空用默认参数)',
settingsTabsTitle: '侧边栏内容',
settingsViewersTitle: '文件预览',
settingsGeneralTitle: '常规',
@@ -166,7 +200,11 @@ export const zh = {
settingsBrowserSandboxTitle: '关闭浏览器沙箱(不安全)',
settingsBrowserSandboxDesc: '关闭后,访问的任何网站都将与界面同源运行,可读取会话数据并冒充你的登录状态。仅对完全可信的站点开启',
settingsBrowserLinksTitle: '聊天区外链在侧边栏打开',
settingsBrowserLinksDesc: '开启后,点击聊天或界面中的 http/https 外链时在侧边栏浏览器中打开,不再弹出新窗口;Ctrl/Cmd 点击可临时放行',
settingsBrowserLinksDesc: '开启后,点击聊天或界面中的外链时在侧边栏打开,不再弹出新窗口;HTTP 与 HTTPS 可分别通过下方开关控制;Ctrl/Cmd 点击可临时放行',
settingsBrowserHttpTitle: '侧边打开HTTP网页',
settingsBrowserHttpDesc: '开启后,点击聊天或界面中的 HTTP 外链时在侧边栏打开(声明了 urlTarget 的插件页面优先);Ctrl/Cmd 点击可临时放行',
settingsBrowserHttpsTitle: '侧边打开HTTPS网页',
settingsBrowserHttpsDesc: '开启后,点击聊天或界面中的 HTTPS 外链时在侧边栏打开。默认关闭:多数 HTTPS 站点拒绝被嵌入,走系统浏览器更顺畅',
browserOpenExternal: '在浏览器中打开',
browserEmbedBlocked: '{host} 拒绝了嵌入请求',
browserEmbedBlockedDesc: '该站点通过 X-Frame-Options / frame-ancestors 禁止在其它页面中显示,无法在侧边栏内加载。可在浏览器中直接打开',
@@ -213,22 +251,43 @@ export const zh = {
addPluginsTabDesc: '侧边栏页面(Tab)可以由插件扩展。插件通过 ctx.betterSidebar 服务注册;点击「安装」复制安装命令,粘贴到 DSH 所在环境的终端执行。',
addPluginsViewerDesc: '文件预览器可以由插件扩展。插件通过 ctx.betterSidebar 服务注册;点击「安装」复制安装命令,粘贴到 DSH 所在环境的终端执行。',
addPluginsBrowseMore: '在 GitHub 上浏览更多插件(topic: dsh-better-sidebar)',
addPluginsSearch: '搜索插件名称 / 描述…',
addPluginsNoMatch: '没有匹配的插件',
addPluginsRecommended: '推荐插件',
addPluginsEmpty: '暂未收录插件,欢迎在 GitHub topic 下发布你的插件',
openPlugin: '跳转',
copyInstall: '复制安装命令',
pluginOfficeDesc: '为 better-sidebar 编辑器提供 Office 三件套预览(.docx / .xlsx / .pptx),把重型 Office 渲染库拆出主包、按需安装',
pluginGitRemotesDesc: 'better-sidebar Git 远程 Tab:看分支/上游/ahead-behind,fetch(可 prune)、ff-only pull、确认后才 push。不替换内置 Git 的暂存/提交,也不提供 force-push 或模型自动推送',
pluginSentinelDesc: '条件驱动的 agent 唤醒系统:文件/进程/端口/HTTP/命令/webhook 传感器,条件达成自动唤醒休眠会话;注册「哨兵」Tab 展示服务器全局监控表',
pluginSidebarQaDesc: '基于 better-sidebar 的划选提问tab分页: 对话划选 → 右侧面板提问 → 同工作区独立追问会话(❓追问·主题):快速无思考模型压缩主对话上下文后与引文一起注入,不打断主对话;追问可嵌套、可继续、可归档',
pluginVideoPreviewDesc: '在 better-sidebar 编辑器内联预览视频文件(.mp4/.webm/.mov/.mkv/.avi 等),自带支持 HTTP Range(206)的 /video 宿主路由,可拖动进度条、不受 20MB mediaLimit 限制',
}
/** The en dictionary (key-set-equal to zh, enforced by the type annotation). */
export const en: Record<keyof typeof zh, string> = {
files: 'Files',
explorer: 'Explorer',
git: 'Source Control',
terminal: 'Terminal',
editor: 'Editor',
editorExplorer: 'File open behavior',
editorExplorerDesc: 'Controls how files open',
editorExplorerMerged: 'Merged',
editorExplorerMergedDesc: 'Files switch in place in the same window; new windows start with the tree open',
editorExplorerSplit: 'Separate',
editorExplorerSplitDesc: 'Path-less windows are the standalone explorer (tree only); each file opens its own window (tree docked, closed by default)',
editorTreeToggle: 'File tree panel',
editorPathPlaceholder: 'File path (relative to the session directory or absolute), Enter to open',
editorSearchPlaceholder: 'Search files by name…',
editorSearchNoResults: 'No matching files',
editorSearchTruncated: 'Too many results — showing a partial list',
editorEmptyHint: 'Pick a file from the tree panel or the path input above to start previewing',
openFileNewTab: 'Open in New Tab',
openFileSide: 'Open to the Side',
newTab: 'New tab',
openExplorer: 'Explorer',
brokenSymlink: 'Broken symlink',
openGit: 'Git panel',
newTerminal: 'New terminal',
terminalLimit: 'Terminal limit reached (3)',
@@ -240,8 +299,16 @@ export const en: Record<keyof typeof zh, string> = {
terminalError: 'Terminal connection failed',
terminalConnectFailed: 'Terminal failed to connect repeatedly',
terminalRetry: 'Retry',
terminalDepsFailed: 'Terminal dependency node-pty failed to load',
terminalDepsHint: 'Run the command below in a terminal or cmd on the DSH machine to repair it, then retry (node-pty stays in sync with the DSH core version):',
terminalDepsProfile: ' (detected profile: {profile})',
preview: 'Preview',
edit: 'Edit',
mermaidError: 'Mermaid render failed',
mermaidZoomIn: 'Zoom in',
mermaidZoomOut: 'Zoom out',
mermaidZoomReset: 'Reset',
mermaidZoomHint: 'Scroll to zoom · drag to pan · Esc to close',
refresh: 'Refresh',
save: 'Save',
saved: 'Saved',
@@ -328,6 +395,10 @@ export const en: Record<keyof typeof zh, string> = {
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'Open chat files in the sidebar',
settingsOpenPathDesc: 'Open file links in the chat (tool rows, produced files, mentions) in the sidebar editor instead of the system default app',
settingsTitleBarTitle: 'Position compatibility mode',
settingsTitleBarDesc: 'Reserve space for the native Windows title bar at the top-right so the sidebar buttons and content sit below it instead of underneath',
settingsTitleBarStripTitle: 'Shift distance',
settingsTitleBarStripDesc: 'Title-bar strip height: how far the sidebar buttons and content move down in px (0–120, default 40)',
settingsSaveFailed: 'Failed to save',
settingsConflict: 'The setting changed in another window — please retry',
binaryNoPreview: 'This file type cannot be previewed',
@@ -344,6 +415,12 @@ export const en: Record<keyof typeof zh, string> = {
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',
settingsShellTitle: 'Shell path',
settingsShellDesc: 'Shell spawned for UI and model terminals (absolute path or bare executable). Empty keeps the legacy order: yaml config.shell → $SHELL / login shell / Windows powershell.exe. Applies to terminals opened afterwards',
settingsShellPlaceholder: 'e.g. /bin/zsh (empty = auto)',
settingsShellArgsTitle: 'Shell arguments',
settingsShellArgsDesc: 'Explicit shell arguments, space-separated; when non-empty they fully replace the defaults (same contract as the yaml shellArgs)',
settingsShellArgsPlaceholder: 'e.g. -l (empty = defaults)',
settingsFontSizeDesc: 'Terminal font size in px (9–32, default 13)',
settingsFontSizeSuffix: 'px',
settingsTabsTitle: 'Sidebar content',
@@ -378,7 +455,11 @@ export const en: Record<keyof typeof zh, string> = {
settingsBrowserSandboxTitle: 'Disable browser sandbox (unsafe)',
settingsBrowserSandboxDesc: 'With the sandbox off, any visited site runs with the same origin as the GUI: it can read session data and act as your logged-in session. Only enable for fully trusted sites',
settingsBrowserLinksTitle: 'Open chat external links in the sidebar',
settingsBrowserLinksDesc: 'When on, clicking an http/https external link in the chat or GUI opens the sidebar browser instead of a new window; Ctrl/Cmd+click always bypasses',
settingsBrowserLinksDesc: 'When on, clicking an external link in the chat or GUI opens the sidebar instead of a new window; HTTP and HTTPS are controlled separately by the switches below; Ctrl/Cmd+click always bypasses',
settingsBrowserHttpTitle: 'Open HTTP pages in the sidebar',
settingsBrowserHttpDesc: 'When on, clicking an HTTP external link in the chat or GUI opens the sidebar (plugin pages declaring urlTarget win); Ctrl/Cmd+click always bypasses',
settingsBrowserHttpsTitle: 'Open HTTPS pages in the sidebar',
settingsBrowserHttpsDesc: 'When on, clicking an HTTPS external link in the chat or GUI opens the sidebar. Off by default: most HTTPS sites refuse to be embedded, so the system browser is the smoother default',
browserOpenExternal: 'Open in browser',
browserEmbedBlocked: '{host} refused to be embedded',
browserEmbedBlockedDesc: 'The site forbids being displayed inside other pages (X-Frame-Options / frame-ancestors), so it cannot load in the sidebar. Open it directly in your browser instead.',
@@ -425,12 +506,17 @@ export const en: Record<keyof typeof zh, string> = {
addPluginsTabDesc: 'Sidebar pages (tabs) can be extended by plugins. Plugins register through the ctx.betterSidebar service; clicking Install copies the install command — paste it into a terminal where your DSH profile lives and run it.',
addPluginsViewerDesc: 'File previewers can be extended by plugins. Plugins register through the ctx.betterSidebar service; clicking Install copies the install command — paste it into a terminal where your DSH profile lives and run it.',
addPluginsBrowseMore: 'Browse more plugins on GitHub (topic: dsh-better-sidebar)',
addPluginsSearch: 'Search by plugin name or description…',
addPluginsNoMatch: 'No plugins match',
addPluginsRecommended: 'Recommended plugins',
addPluginsEmpty: 'No plugins curated yet — publish yours under the GitHub topic',
openPlugin: 'Open',
copyInstall: 'Copy install command',
pluginOfficeDesc: 'Office-suite preview (.docx / .xlsx / .pptx) for the better-sidebar editor, keeping the heavy Office render libraries out of the core bundle',
pluginGitRemotesDesc: 'Git Remotes tab: branch/upstream/ahead-behind, fetch (optional prune), ff-only pull, and push only after an in-tab confirm. Does not replace the built-in Git stage/commit tab, and does not offer force-push or a model auto-push tool',
pluginSentinelDesc: 'Condition-driven agent wakeup: file/process/port/http/command/webhook sensors wake dormant sessions when conditions fire; registers a "Sentinel" tab with the server-wide watch table',
pluginSidebarQaDesc: 'Select-and-ask: Select conversation text → ask in the right-side panel → a dedicated follow-up session (❓追问) in the same workspace; a fast no-thinking model compresses the main context and injects it with the quote, without interrupting the main conversation. Follow-ups nest, continue, and archive',
pluginVideoPreviewDesc: 'Inline video preview (.mp4/.webm/.mov/.mkv/.avi etc.) for the better-sidebar editor, backed by a dedicated /video host route with HTTP Range (206) support — scrubbing works and files are not capped by the 20MB mediaLimit',
}
/**
+110
View File
@@ -0,0 +1,110 @@
/**
* Markdown/mermaid fence detection for the markdown preview. The preview
* renders the WHOLE document through one DSH `MarkdownText` pass (so
* cross-fence semantics — reference-style links, footnotes, list
* continuity — stay intact) and the mermaid lazy chunk then swaps the
* rendered `language-mermaid` code blocks for diagrams. This module's pure
* splitter exists to detect whether the source contains a mermaid fence at
* all, so the mermaid chunk is only fetched when needed (unit-tested in
* tests/mermaid-blocks.spec.ts).
*/
/** One fenced mermaid diagram lifted out of the markdown source. */
export interface MermaidBlock {
kind: 'mermaid'
/** The raw diagram source between the fences (info string stripped). */
code: string
}
/** A span of plain markdown source (may itself contain non-mermaid fences). */
export interface MarkdownBlock {
kind: 'markdown'
text: string
}
export type MdBlock = MarkdownBlock | MermaidBlock
/** Props of the chunk-resident `MermaidMarkdown` component (shared contract). */
export interface MermaidMarkdownProps {
/** The full markdown source (rendered in a single MarkdownText pass). */
text: string
codeLabels: { copyLabel: string; copiedLabel: string }
}
/** CommonMark opening fence: 0-3 spaces indent + a run of 3+ backticks or tildes. */
const OPEN_FENCE_RE = /^ {0,3}(`{3,}|~{3,})/
/** A closing-fence line: 0-3 spaces indent + 3+ backticks/tildes + trailing spaces only. */
const CLOSE_FENCE_RE = /^ {0,3}(`{3,}|~{3,})[ \t]*$/
/** Parse the info string from the line tail after the fence run; null when invalid. */
function fenceInfo(rest: string, fence: string): string | null {
const info = rest.trimStart().split(/\s+/)[0] ?? ''
// CommonMark: a backtick fence's info string may not contain backticks —
// such a line is not an opening fence at all.
if (fence.charAt(0) === '`' && info.includes('`')) return null
return info
}
/** True when the fence info string names mermaid (bare or `mermaid{...}`). */
function isMermaidInfo(info: string): boolean {
const word = info.toLowerCase()
return word === 'mermaid' || word.startsWith('mermaid{')
}
/**
* Split markdown source into md/mermaid blocks for detection: only fences
* whose info string names mermaid are lifted; every other line stays in the
* markdown stream untouched. CommonMark fence rules are honored — opening
* fences of 3+ backticks OR tildes, and a closing fence must use the same
* character with at least as many characters as the opening fence. An
* unterminated mermaid fence swallows the rest of the file (the same
* recovery CommonMark applies to open fences).
*/
export function splitMermaidBlocks(text: string): MdBlock[] {
if (text === '') return []
const lines = text.split('\n')
const blocks: MdBlock[] = []
let markdown: string[] = []
let index = 0
const flushMarkdown = (): void => {
if (markdown.length === 0) return
blocks.push({ kind: 'markdown', text: markdown.join('\n') })
markdown = []
}
while (index < lines.length) {
const line = lines[index] ?? ''
const fenceMatch = OPEN_FENCE_RE.exec(line)
if (fenceMatch === null) {
markdown.push(line)
index += 1
continue
}
const fence = fenceMatch[1]!
// The line tail after the whole matched prefix (indent + fence run).
const rest = line.slice(fenceMatch.index + fenceMatch[0].length)
const info = fenceInfo(rest, fence)
if (info === null || !isMermaidInfo(info)) {
markdown.push(line)
index += 1
continue
}
flushMarkdown()
const char = fence.charAt(0)
const length = fence.length
const code: string[] = []
index += 1
while (index < lines.length) {
const candidate = lines[index] ?? ''
const close = CLOSE_FENCE_RE.exec(candidate)
if (close !== null && close[1]!.charAt(0) === char && close[1]!.length >= length) break
code.push(candidate)
index += 1
}
// Consume the closing fence (or run off the end on an open fence).
index += 1
blocks.push({ kind: 'mermaid', code: code.join('\n') })
}
flushMarkdown()
return blocks
}
+93
View File
@@ -0,0 +1,93 @@
/**
* SVG sanitization for mermaid-rendered diagrams (markdown preview). The
* diagrams come from untrusted markdown sources, so the emitted SVG is
* re-sanitized before it reaches dangerouslySetInnerHTML — defense in depth
* on top of mermaid's own `securityLevel: 'strict'` (labels escaped, click
* directives inert) and `htmlLabels: false` (labels as real SVG <text>).
*
* What is stripped, and why:
* - `foreignObject`: the only channel that can carry raw HTML inside an
* SVG document — with it gone, a hostile label cannot smuggle an
* <img onerror> / <iframe> through the XML parse (any non-SVG element
* outside foreignObject makes the XML parse fail and the whole diagram
* is rejected).
* - `script` and foreign HTML elements (`img`/`iframe`/`object`/`embed`/
* `video`/`audio`/`input`/`button`/`form`/`link`/`meta`/`base`): belt and
* braces — real browsers reject these outside foreignObject at XML parse
* time (→ '') while lenient parsers keep them, so they are stripped
* explicitly instead of relying on the parser alone. Element names are
* matched case-insensitively (XML preserves their case; the output is
* re-parsed as HTML whose parser normalizes tag casing, so `<sCrIpT>` /
* `<foreignobject>` would otherwise come back as script/foreignObject).
* - `@*` / `on*` attributes: event-handler channels (Vue-style directives
* and native listeners), matched case-insensitively — the output is
* re-parsed as HTML whose parser normalizes attribute casing, so
* `oNload`/`OnClick` must not survive either.
* - ALL `href` / `xlink:href` attributes: the diagrams are static (no
* bindFunctions, no click navigation), so links carry no value — and an
* attacker-controlled URL (even an http(s) one) could otherwise navigate
* the whole GUI away from the sidebar. Removing every href keeps the
* surface deterministic.
*
* Parsing happens with `image/svg+xml`, so a malformed SVG fails the XML
* parse and the function returns '' (the caller surfaces the error
* fallback) instead of ever passing the raw string through; only a document
* whose root is an `<svg>` element is accepted.
*/
/** Element local names stripped case-insensitively (all lowercase). */
const STRIP_ELEMENTS = new Set([
'foreignobject',
'script',
'img',
'iframe',
'object',
'embed',
'video',
'audio',
'input',
'button',
'form',
'link',
'meta',
'base',
])
/** A parse failure keeps nothing of the input: the caller shows the error. */
export function sanitizeSvg(svg: string): string {
if (typeof DOMParser === 'undefined' || typeof XMLSerializer === 'undefined') return ''
let doc: Document
try {
doc = new DOMParser().parseFromString(svg, 'image/svg+xml')
} catch {
return ''
}
if (doc.querySelector('parsererror') !== null) return ''
if (doc.documentElement === null || doc.documentElement.localName !== 'svg') return ''
doc.querySelectorAll('*').forEach((node) => {
// XML element names preserve case; the string is later re-parsed as HTML
// whose parser normalizes tag casing — so mixed-case spellings of the
// strip list (e.g. `<sCrIpT>`, `<foreignobject>`) must be caught the
// same way as their canonical forms.
if (STRIP_ELEMENTS.has(node.localName.toLowerCase())) {
node.remove()
return
}
for (const attribute of [...node.attributes]) {
const name = attribute.name
// XML attribute names preserve case; the string is later re-parsed as
// HTML (dangerouslySetInnerHTML), whose parser normalizes attribute
// casing — so a mixed-case `oNload`/`HREF` must be caught the same way
// as its lowercase form. Compare on the lowercased name, remove with
// the original.
const normalized = name.toLowerCase()
if (normalized.startsWith('@') || normalized.startsWith('on')) {
node.removeAttribute(name)
continue
}
if (normalized === 'href' || normalized === 'xlink:href') {
node.removeAttribute(name)
}
}
})
return new XMLSerializer().serializeToString(doc.documentElement)
}
+401
View File
@@ -0,0 +1,401 @@
/**
* Mermaid diagram renderer for the markdown preview, resident in the
* `mermaid` lazy chunk (src/client/chunks/mermaid.tsx): the mermaid library
* and its transitive graph deps (d3/dagre/cytoscape) are inlined into
* lib/client-mermaid.js and fetched only when a previewed markdown file
* actually contains a mermaid fence (see mermaid-blocks.ts).
*
* Rendering architecture: the whole document is rendered ONCE through the
* DSH `MarkdownText` (so cross-fence semantics — reference-style links,
* footnotes, ordered-list continuity — stay intact), then a layout effect
* swaps every rendered `language-mermaid` CodeBlock for a diagram. The
* swap keeps the React-managed `.md-code-block` host node in the tree and
* only replaces its children (display:contents suppresses the code-block
* chrome), so React reconciliation never loses the host; when a swapped
* block stops being a mermaid fence, the original children are restored.
*
* Security: mermaid.render → sanitized SVG injected into the block.
* `bindFunctions` is intentionally NOT applied (static diagrams; no
* flowchart click handlers), `securityLevel` stays 'strict' (labels are
* escaped, no raw-HTML foreignObject), `htmlLabels: false` keeps node text
* as real SVG <text>, and the emitted SVG is re-sanitized (see
* mermaid-sanitize.ts) before it reaches dangerouslySetInnerHTML. Clicking
* a rendered diagram opens a zoom/pan modal (borrowed interaction design
* from the mermaid PR #75, reimplemented on top of the sanitized SVG —
* the clone carries no event surface, so the modal adds no attack surface).
*/
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
import { createPortal } from 'react-dom'
import { createRoot, type Root } from 'react-dom/client'
import mermaid from 'mermaid'
import { IconCopyOutline16, MarkdownText, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
import { isDarkScheme, subscribeColorScheme } from './theme.ts'
import { t } from './locales.ts'
import { sanitizeSvg } from './mermaid-sanitize.ts'
import type { MermaidMarkdownProps } from './mermaid-blocks.ts'
import css from './sidebar.module.css'
/** Monotonic id seed: every render call gets a fresh, document-unique id. */
let mermaidSeq = 0
/** Configure mermaid for the current color scheme (idempotent). */
function configureMermaid(): void {
mermaid.initialize({
startOnLoad: false,
securityLevel: 'strict',
// Labels as real SVG <text>: mermaid's default htmlLabels renders node
// text inside <foreignObject>, which the sanitizer strips wholesale —
// forcing pure SVG text keeps labels visible and the HTML label channel
// closed (strict already escapes label content).
htmlLabels: false,
theme: isDarkScheme() ? 'dark' : 'default',
})
}
/** First lines of a mermaid error (its dumps are huge; the head explains). */
function summarizeError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error)
return message.split('\n').slice(0, 6).join('\n')
}
/** The zoom/pan modal for one rendered diagram (click-to-enlarge). */
function MermaidZoomModal({ svg, onClose }: { svg: SVGSVGElement; onClose: () => void }): React.ReactNode {
const overlayRef = useRef<HTMLDivElement>(null)
const stageRef = useRef<HTMLDivElement>(null)
const svgRef = useRef<SVGSVGElement | null>(null)
const dragRef = useRef({ active: false, startX: 0, startY: 0 })
const zoomRef = useRef({ scale: 1, tx: 0, ty: 0 })
const applyTransform = (): void => {
const node = svgRef.current
if (node === null) return
const { scale, tx, ty } = zoomRef.current
node.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`
}
/** Zoom by `delta` keeping the stage point (centerX/centerY) fixed. */
const zoom = useCallback((delta: number, centerX?: number, centerY?: number): void => {
const stage = stageRef.current
if (stage === null) return
const rect = stage.getBoundingClientRect()
const cx = centerX ?? rect.width / 2
const cy = centerY ?? rect.height / 2
const current = zoomRef.current
const newScale = Math.min(8, Math.max(0.2, current.scale * delta))
// The svg is flex-centered in the stage, so its center sits at
// (rect.width/2, rect.height/2). Solve for the translate that keeps the
// mouse point stationary in stage coordinates across the scale change.
const sx = rect.width / 2
const sy = rect.height / 2
const ratio = newScale / current.scale
current.tx = cx - sx - (cx - sx - current.tx) * ratio
current.ty = cy - sy - (cy - sy - current.ty) * ratio
current.scale = newScale
applyTransform()
}, [])
const reset = useCallback((): void => {
zoomRef.current = { scale: 1, tx: 0, ty: 0 }
applyTransform()
}, [])
const close = useCallback((): void => { onClose() }, [onClose])
// Mount the caller-provided (sanitized) svg clone imperatively: React
// types don't accept a raw DOM node as a child, and the modal owns the
// node's lifetime (removed on unmount; the preview copy is untouched).
useEffect(() => {
const stage = stageRef.current
if (stage === null) return
svgRef.current = svg
stage.appendChild(svg)
return () => {
svg.remove()
svgRef.current = null
}
}, [svg])
useEffect(() => {
const stage = stageRef.current
const node = svgRef.current
const overlay = overlayRef.current
if (stage === null || node === null || overlay === null) return
const onWheel = (event: WheelEvent): void => {
event.preventDefault()
const rect = stage.getBoundingClientRect()
zoom(event.deltaY < 0 ? 1.1 : 1 / 1.1, event.clientX - rect.left, event.clientY - rect.top)
}
const onKey = (event: KeyboardEvent): void => {
if (event.key === 'Escape') { close(); return }
if (event.key === '+' || event.key === '=') zoom(1.2)
else if (event.key === '-') zoom(1 / 1.2)
else if (event.key === '0') reset()
}
const onMouseDown = (event: MouseEvent): void => {
event.preventDefault()
dragRef.current = {
active: true,
startX: event.clientX - zoomRef.current.tx,
startY: event.clientY - zoomRef.current.ty,
}
}
const onMouseMove = (event: MouseEvent): void => {
if (!dragRef.current.active) return
zoomRef.current.tx = event.clientX - dragRef.current.startX
zoomRef.current.ty = event.clientY - dragRef.current.startY
applyTransform()
}
const onMouseUp = (): void => { dragRef.current.active = false }
const onOverlayClick = (event: MouseEvent): void => {
if (event.target === overlay) close()
}
// React's synthetic wheel is passive; a native listener is required to
// preventDefault (the page must not scroll while zooming the modal).
stage.addEventListener('wheel', onWheel, { passive: false })
node.addEventListener('mousedown', onMouseDown)
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
window.addEventListener('keydown', onKey)
overlay.addEventListener('click', onOverlayClick)
return () => {
stage.removeEventListener('wheel', onWheel)
node.removeEventListener('mousedown', onMouseDown)
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
window.removeEventListener('keydown', onKey)
overlay.removeEventListener('click', onOverlayClick)
}
}, [zoom, reset, close])
return createPortal(
<div className={css.mermaidModal} data-mermaid-modal ref={overlayRef}>
<div className={css.mermaidModalToolbar}>
<button
type="button"
className={css.mermaidModalButton}
title={t('mermaidZoomOut')}
onClick={() => zoom(1 / 1.2)}
>
−
</button>
<button
type="button"
className={css.mermaidModalButton}
title={t('mermaidZoomIn')}
onClick={() => zoom(1.2)}
>
+
</button>
<button
type="button"
className={css.mermaidModalButton}
title={t('mermaidZoomReset')}
onClick={reset}
>
⟳
</button>
<button
type="button"
className={css.mermaidModalButton}
title={t('close')}
onClick={close}
>
✕
</button>
</div>
<div className={css.mermaidModalStage} ref={stageRef} />
<div className={css.mermaidModalHint}>{t('mermaidZoomHint')}</div>
</div>,
document.body,
)
}
/** One rendered mermaid fence: header chrome + diagram (or error + source). */
function MermaidDiagram({ code }: { code: string }): React.ReactNode {
const [svg, setSvg] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
/** Scheme state: a flip re-renders the diagram with the matching theme. */
const [dark, setDark] = useState(() => isDarkScheme())
/** The cloned svg shown in the zoom modal (null = closed). */
const [zoomSvg, setZoomSvg] = useState<SVGSVGElement | null>(null)
const copyTimer = useRef<number | undefined>(undefined)
useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), [])
useEffect(() => {
let cancelled = false
setSvg(null)
setError(null)
if (code.trim() === '') return () => { cancelled = true }
configureMermaid()
const id = `dsh-md-mermaid-${mermaidSeq += 1}`
mermaid.render(id, code)
.then(({ svg: rendered }) => {
if (cancelled) return
const clean = sanitizeSvg(rendered)
if (clean === '') {
// Parse/sanitize rejection: never pass the raw string through.
setError(t('mermaidError'))
return
}
setSvg(clean)
})
.catch((reason: unknown) => {
if (cancelled) return
setError(summarizeError(reason))
})
return () => { cancelled = true }
}, [code, dark])
const onCopy = useCallback(() => {
if (copied) return
writeClipboard(code).then((ok) => {
if (!ok) return
setCopied(true)
window.clearTimeout(copyTimer.current)
copyTimer.current = window.setTimeout(() => { setCopied(false) }, 1000)
})
}, [code, copied])
/** Clicking the diagram opens the zoom modal with a sanitized clone. */
const onBodyClick = (event: ReactMouseEvent<HTMLDivElement>): void => {
const svgEl = (event.target as Element).closest('svg')
if (svgEl === null) return
const clone = svgEl.cloneNode(true) as SVGSVGElement
// The modal owns sizing/transform; drop the preview's inline geometry.
clone.removeAttribute('style')
clone.removeAttribute('width')
clone.removeAttribute('height')
setZoomSvg(clone)
}
return (
<div className={css.mermaidWrap}>
<div className={css.mermaidHeader}>
<span className={css.mermaidInfo}>mermaid</span>
<button
type="button"
className={css.mermaidCopy}
onClick={onCopy}
aria-label={t('copy')}
title={t('copy')}
>
<IconCopyOutline16 />
<span>{copied ? t('copied') : t('copy')}</span>
</button>
</div>
{error !== null && <div className={css.mermaidError} title={error}>{t('mermaidError')}</div>}
{svg !== null && (
<div
className={css.mermaidBody}
data-mermaid-diagram
onClick={onBodyClick}
dangerouslySetInnerHTML={{ __html: svg }}
/>
)}
{error !== null && <pre className={css.mermaidCode}><code>{code}</code></pre>}
{zoomSvg !== null && <MermaidZoomModal svg={zoomSvg} onClose={() => { setZoomSvg(null) }} />}
</div>
)
}
/** One swapped mount: the diagram root + the CodeBlock children it displaced. */
interface MermaidMount {
root: Root
source: string
removed: ChildNode[]
}
/**
* True when a rendered CodeBlock is a mermaid fence. The DSH CodeBlock has
* two bodies: the shiki path carries the `language-*` class on generated
* <code> elements, while the plain path (which is always the one mermaid
* takes — no shiki grammar) only shows the language in the banner
* infostring (first element of the banner row; CSS-module classes are
* hashed, so that match is structural).
*/
function isMermaidBlock(block: HTMLElement): boolean {
const code = block.querySelector('code')
if (code !== null && [...code.classList].some(c => c.startsWith('language-mermaid'))) return true
const infostring = block.firstElementChild?.firstElementChild?.firstElementChild
return infostring !== null
&& infostring !== undefined
&& (infostring.textContent ?? '').trim() === 'mermaid'
}
/**
* The chunk-resident markdown preview renderer: ONE MarkdownText pass over
* the full source (cross-fence reference/footnote/list semantics intact),
* then every rendered `language-mermaid` code block is swapped for a
* `MermaidDiagram`. The `.md-code-block` host stays in the React tree —
* only its children are replaced — so reconciliation never loses the host;
* a block that stops being a mermaid fence gets its original children back.
* Only mounted when the source contains at least one mermaid fence (see
* TextEditor.tsx).
*/
export function MermaidMarkdown({ text, codeLabels }: MermaidMarkdownProps): React.ReactNode {
const containerRef = useRef<HTMLDivElement>(null)
const mountsRef = useRef(new Map<HTMLElement, MermaidMount>())
useLayoutEffect(() => {
const container = containerRef.current
if (container === null) return
const mounts = mountsRef.current
const seen = new Set<HTMLElement>()
for (const block of container.querySelectorAll<HTMLElement>('.md-code-block')) {
const mount = mounts.get(block)
const isMermaid = isMermaidBlock(block)
if (!isMermaid) {
// A previously swapped block that is no longer a mermaid fence:
// restore the CodeBlock children React still manages, so the plain
// fence renders normally again.
if (mount !== undefined) {
mount.root.unmount()
block.replaceChildren(...mount.removed)
block.removeAttribute('data-mermaid-processed')
mounts.delete(block)
}
continue
}
seen.add(block)
// The plain body always carries the fence source in <code>.
const source = block.querySelector('code')?.textContent ?? ''
if (mount !== undefined && mount.source === source) continue
if (mount === undefined) {
const host = document.createElement('div')
const removed = [...block.childNodes]
block.replaceChildren(host)
block.setAttribute('data-mermaid-processed', 'true')
const root = createRoot(host)
mounts.set(block, { root, source, removed })
root.render(<MermaidDiagram code={source} />)
} else {
mount.source = source
mount.root.render(<MermaidDiagram code={source} />)
}
}
// Drop mounts whose code block left the tree (fence removed, document
// restructured): the host node is gone with it.
for (const [block, mount] of mounts) {
if (seen.has(block)) continue
mount.root.unmount()
mounts.delete(block)
}
}, [text])
useEffect(() => () => {
for (const { root } of mountsRef.current.values()) root.unmount()
mountsRef.current.clear()
}, [])
return (
<div className={css.mermaidMarkdown} ref={containerRef}>
<MarkdownText text={text} codeLabels={codeLabels} />
</div>
)
}
+19
View File
@@ -3,8 +3,27 @@
* the session cwd (for the @-reference button and "copy relative path").
* The fs-tree joins with '/' even on Windows, so both separators normalize
* to '/' before comparison.
*
* This module is dependency-free (no node:path in the client bundle): the
* host is the authority for path semantics, so this mirror deliberately
* accepts a SUPERSET of absolute forms — anything a Windows host would emit
* (drive letters, UNC) plus POSIX roots. A form the host would reject
* (e.g. a backslash UNC path on a POSIX host) passes through here and then
* fails loudly in the host's requireAbsolute instead of being silently
* joined onto the cwd.
*/
/**
* Mirror of the host's absolute-path notion (see fs-tree.requireAbsolute):
* POSIX roots, Windows drive letters, and Windows UNC network shares in
* both backslash (`\\server\share\...`) and forward-slash
* (`//server/share/...`) form. Deliberately a superset — see the module
* comment — so a produced UNC path is never joined onto the cwd.
*/
export function isAbsolutePath(path: string): boolean {
return path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path) || /^[\\/]{2}[^\\/]/.test(path)
}
/**
* The path relative to the session's working directory.
* @param cwd - the explorer root (absolute).
+3
View File
@@ -20,6 +20,9 @@ export interface PluginEntry {
url: string
/** One-line description (i18n friendly: string or () => string). */
description: string | (() => string)
/** Optional catalog group heading (i18n friendly); entries without one
* render under the plain list (a flat catalog stays flat). */
category?: string | (() => string)
/** The full shell command pre-filled into the install terminal (not
* executed until the user presses Enter). */
install: string
+16
View File
@@ -24,4 +24,20 @@ export const builtinTabPlugins: readonly PluginEntry[] = [
// context consistent with the other entries.
install: 'cd ~/.dsh && dsh plugin --profile web add "github:fuhefei/dsh-sentinel#v0.7.0"',
},
{
id: 'dsh-git-remotes',
name: 'dsh-git-remotes Git 远程',
url: 'https://github.com/yq04/dsh-git-remotes',
description: () => t('pluginGitRemotesDesc'),
install: 'cd ~/.dsh && dsh plugin --profile web add dsh-better-sidebar && dsh plugin --profile web add git+https://github.com/yq04/dsh-git-remotes.git',
},
{
id: 'dsh-sidebar-qa',
name: 'dsh-sidebar-qa 划选追问',
url: 'https://github.com/ChenRuoT/dsh-sidebar-qa',
description: () => t('pluginSidebarQaDesc'),
// dsh-sidebar-qa hard-depends on dsh-better-sidebar (required peer), so
// the install line installs the prerequisite first, then the plugin.
install: 'cd ~/.dsh && dsh plugin --profile web add dsh-better-sidebar && dsh plugin --profile web add git+https://github.com/ChenRuoT/dsh-sidebar-qa.git',
},
]
+7
View File
@@ -20,4 +20,11 @@ export const builtinViewerPlugins: readonly PluginEntry[] = [
description: () => t('pluginOfficeDesc'),
install: 'cd ~/.dsh && dsh plugin --profile web add @huanlin/dsh-plugin-better-sidebar-plugin-office',
},
{
id: 'dsh-video-preview',
name: '视频预览插件',
url: 'https://github.com/zemul/dsh-video-preview',
description: () => t('pluginVideoPreviewDesc'),
install: 'cd ~/.dsh && dsh plugin --profile web add dsh-video-preview',
},
]
+42 -1
View File
@@ -11,12 +11,13 @@
import type { api } from './api.ts'
import {
clampTerminalFontSize,
clampTitleBarStrip,
clampWidthPercent,
SIDEBAR_PREFS_DEFAULTS,
type SidebarPrefs,
} from '../prefs-shared.ts'
export { SIDEBAR_PREFS_DEFAULTS, clampTerminalFontSize, clampWidthPercent }
export { SIDEBAR_PREFS_DEFAULTS, clampTerminalFontSize, clampTitleBarStrip, clampWidthPercent }
export type { SidebarPrefs }
/** The settings wire face the preferences need (a subset of the plugin api). */
@@ -53,12 +54,27 @@ export function parsePrefs(value: unknown): SidebarPrefs {
terminalFontFamily: typeof record.terminalFontFamily === 'string'
? record.terminalFontFamily
: SIDEBAR_PREFS_DEFAULTS.terminalFontFamily,
terminalShell: typeof record.terminalShell === 'string'
? record.terminalShell
: SIDEBAR_PREFS_DEFAULTS.terminalShell,
terminalShellArgs: typeof record.terminalShellArgs === 'string'
? record.terminalShellArgs
: SIDEBAR_PREFS_DEFAULTS.terminalShellArgs,
terminalFontSize: typeof record.terminalFontSize === 'number' && Number.isFinite(record.terminalFontSize)
? clampTerminalFontSize(record.terminalFontSize)
: SIDEBAR_PREFS_DEFAULTS.terminalFontSize,
interceptOpenPath: typeof record.interceptOpenPath === 'boolean'
? record.interceptOpenPath
: SIDEBAR_PREFS_DEFAULTS.interceptOpenPath,
editorExplorer: typeof record.editorExplorer === 'boolean'
? record.editorExplorer
: SIDEBAR_PREFS_DEFAULTS.editorExplorer,
titleBarCompat: typeof record.titleBarCompat === 'boolean'
? record.titleBarCompat
: SIDEBAR_PREFS_DEFAULTS.titleBarCompat,
titleBarStripPx: typeof record.titleBarStripPx === 'number' && Number.isFinite(record.titleBarStripPx)
? clampTitleBarStrip(record.titleBarStripPx)
: SIDEBAR_PREFS_DEFAULTS.titleBarStripPx,
htmlViewerNoSandbox: typeof record.htmlViewerNoSandbox === 'boolean'
? record.htmlViewerNoSandbox
: SIDEBAR_PREFS_DEFAULTS.htmlViewerNoSandbox,
@@ -71,6 +87,12 @@ export function parsePrefs(value: unknown): SidebarPrefs {
browserInterceptLinks: typeof record.browserInterceptLinks === 'boolean'
? record.browserInterceptLinks
: SIDEBAR_PREFS_DEFAULTS.browserInterceptLinks,
browserInterceptHttp: typeof record.browserInterceptHttp === 'boolean'
? record.browserInterceptHttp
: SIDEBAR_PREFS_DEFAULTS.browserInterceptHttp,
browserInterceptHttps: typeof record.browserInterceptHttps === 'boolean'
? record.browserInterceptHttps
: SIDEBAR_PREFS_DEFAULTS.browserInterceptHttps,
tabsEnabled: booleanMapOf(record.tabsEnabled),
viewersEnabled: booleanMapOf(record.viewersEnabled),
pluginSettings: pluginSettingsMapOf(record.pluginSettings),
@@ -123,3 +145,22 @@ export async function loadPrefs(settings: SidebarSettingsClient): Promise<Sideba
return { ...SIDEBAR_PREFS_DEFAULTS }
}
}
/**
* Read the external-disable flag from the same settings route: the
* dsh-web-ui family's aionui-panel provider choice. True only when the host
* resolved `aionui-panel.rightPanel` to 'aionui-panel' — while true the
* sidebar must not mount (the two right panels are mutually exclusive). Any
* failure (route rejected, aionui absent, malformed response) reads false,
* so a missing family never hides the sidebar.
* @param settings - the settings wire face (the plugin api by default).
* @returns the external-disable flag (false on any failure).
*/
export async function loadExternalDisable(settings: SidebarSettingsClient): Promise<boolean> {
try {
const view = await settings.settingsGet()
return view.externalDisable === true
} catch {
return false
}
}
+7 -3
View File
@@ -6,6 +6,7 @@
* Kept dependency-free so the takeover logic is unit-testable and the
* replica is easy to diff against upstream when it drifts.
*/
import { isAbsolutePath } from './paths.ts'
/** Paths a tool-result view reports as produced, by render intent. */
export function producedPaths(view: unknown): readonly string[] {
@@ -76,10 +77,13 @@ export function selectProducedFiles(owner: unknown): readonly string[] | null {
return paths.length === 0 ? null : paths
}
/** Resolve a (possibly relative) path against the session cwd for the sidebar. */
/**
* Resolve a (possibly relative) path against the session cwd for the sidebar.
* Absolute detection mirrors the host (see client/paths.isAbsolutePath):
* POSIX roots, drive letters and UNC shares must not be joined onto the cwd.
*/
export function resolveSidebarPath(cwd: string | undefined, path: string): string {
const absolute = path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path)
if (absolute) return path
if (isAbsolutePath(path)) return path
const base = cwd ?? ''
if (base === '') return path
const separator = base.includes('\\') ? '\\' : '/'
+100 -3
View File
@@ -48,14 +48,32 @@ export type { SessionScope } from './api.ts'
export type { SidebarPrefs } from '../prefs-shared.ts'
/** The row control a declarative setting renders as in the settings popup. */
export type SidebarSettingToggleType = 'switch' | 'text' | 'number'
export type SidebarSettingToggleType = 'switch' | 'text' | 'number' | 'select'
/** One option of a `type: 'select'` setting row. */
export interface SidebarSettingSelectOption {
/** The value written to the setting key when this option is picked
* (JSON-serializable: string / number / boolean). */
value: string | number | boolean
/** Option title (i18n friendly: string or () => string). */
title: string | (() => string)
/** Option description (i18n friendly); rendered under the title in the
* icon dropdown. */
desc?: string | (() => string)
/** Option icon: when ANY option declares one, the dropdown renders
* big-icon option cards and the closed control shows the selected
* option's icon too; without icons both are a single line of text. */
icon?: ReactNode | ((size: number) => ReactNode)
}
/** 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`. */
* input clamped to `min`/`max`, 'select' a dropdown over the declared
* `options` (single-pick writes the option's value; `multi: true` writes
* the array of picked values and defaults to false). */
export interface SidebarSettingToggle {
/** The SidebarPrefs field this toggle reads and writes ('autoOpenSubagent'). */
key: string
@@ -73,6 +91,11 @@ export interface SidebarSettingToggle {
placeholder?: string
/** Unit suffix rendered after the input (e.g. 'px' for a size row). */
unit?: string
/** Options of a `type: 'select'` row. */
options?: readonly SidebarSettingSelectOption[]
/** Whether a `type: 'select'` row allows picking several options (the
* stored value is then an array of option values); defaults to false. */
multi?: boolean
}
/** Props of a descriptor's custom settings panel (`settings.render`). */
@@ -169,6 +192,22 @@ export interface TabDescriptor {
* When omitted, a default `{ id, type, title }` tab is created.
*/
createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null
/**
* External-link target claim (v0.13.0+): when a GUI external-link click
* is taken over (the `browserInterceptLinks` master AND the URL's
* protocol flag — `browserInterceptHttp` / `browserInterceptHttps` —
* are on), the first registered tab whose `urlTarget(url)` returns true
* is opened with `openTab({ type, url, title: hostname })` — the URL is
* the whole payload (the tab reads it from `tab.path`). Registration
* order wins (first claim first served); a disabled tab type is skipped;
* a throwing predicate is swallowed (console.error, the type is skipped).
* The built-in browser tab declares NO urlTarget — it stays the implicit
* fallback target, so plugins can never be shadowed by it. To host more
* than one URL at a time, mint per-URL ids through `createTab` (the
* browser builtin's pattern); otherwise the id safety net focuses the
* existing tab of the same type and the new URL is not applied.
*/
urlTarget?: (url: URL) => boolean
/**
* Declarative settings shown in the Side card settings page: every
* registered tab gets an enable/disable switch (icon + title + id), and
@@ -224,6 +263,33 @@ export interface FileViewerProps {
mediaUrl?: string
/** custom load() return value (fetchStrategy='custom'). */
customData?: unknown
/** Internal (built-in text editor): 'host' asks the viewer to skip its own
* toolbar row — the editor host's merged-mode header renders it instead,
* fed through the two callbacks below. Viewers that ignore these fields
* render exactly as before. */
toolbar?: 'self' | 'host'
/** Internal: the viewer reports its toolbar state (mode/dirty/save). */
onToolbarState?: (state: EditorToolbarState) => void
/** Internal: the viewer registers its toolbar commands on mount (null on
* unmount). */
onToolbarControls?: (controls: EditorToolbarControls | null) => void
}
/** The toolbar state a text editor reports to the host's merged-mode header. */
export interface EditorToolbarState {
/** Whether the preview/edit mode toggle applies (markdown/html). */
modes: boolean
mode: 'preview' | 'edit'
dirty: boolean
/** Whether saving applies (text content loaded). */
editable: boolean
saveState: 'idle' | 'saving' | 'saved' | 'failed'
}
/** The commands the host's merged-mode header sends back to the viewer. */
export interface EditorToolbarControls {
setMode(mode: 'preview' | 'edit'): void
save(): void
}
/** Describes one file previewer (builtins register themselves too). */
@@ -373,11 +439,38 @@ function baseNameOf(path: string): string {
return at === -1 ? path : path.slice(at + 1)
}
/**
* Find the tab type that claims an intercepted external-link URL (v0.13.0+).
* Walks the descriptors in REGISTRATION order and returns the first one
* that declares `urlTarget` and matches `url`; a throwing predicate is
* swallowed (console.error, type skipped) so one broken plugin can never
* break the whole link pipeline. The caller passes the ENABLED tab
* descriptors (enablement is the caller's prefs domain — filter
* `service.getTabs()` through `tabsEnabled` before matching) and falls
* back to the built-in browser tab when nothing claims the URL (the
* browser never declares `urlTarget` itself, so it can never shadow a
* plugin claim).
*/
export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDescriptor | undefined {
for (const tab of tabs) {
if (tab.urlTarget === undefined) continue
let claimed = false
try {
claimed = tab.urlTarget(url) === true
} catch (error) {
console.error('[dsh-better-sidebar] urlTarget error:', error)
continue
}
if (claimed) return tab
}
return undefined
}
/**
* The plugin version this service instance reports. Keep in lockstep with
* `package.json`'s version — `tests/service.spec.ts` asserts the pair.
*/
export const SIDEBAR_SERVICE_VERSION = '0.12.1'
export const SIDEBAR_SERVICE_VERSION = '0.14.0'
/**
* Monotonic capability list consumers use to gate new API usage (features
@@ -390,6 +483,8 @@ export const SIDEBAR_SERVICE_VERSION = '0.12.1'
* - 'stateSubscription': getSnapshot/subscribeState
* - 'tabMeta': SidebarTab.meta (seeds, createTab, updateTab, persistence)
* - 'pluginSettings': SidebarSettingsDeclaration.pluginToggles/render
* - 'urlTarget' (v0.13.0): TabDescriptor.urlTarget (external-link claims)
* - 'settingSelect': SidebarSettingToggle type 'select' (options/multi)
*/
export const SIDEBAR_FEATURES = [
'badge',
@@ -400,6 +495,8 @@ export const SIDEBAR_FEATURES = [
'stateSubscription',
'tabMeta',
'pluginSettings',
'urlTarget',
'settingSelect',
] as const
/** Run one plugin callback; a throw is logged and never breaks the caller. */
+45
View File
@@ -0,0 +1,45 @@
/**
* Mark this plugin's row in the DSH settings navigation so its bundled CSS
* can replace the shell's fallback gear with the Side card glyph.
*
* DSH 0.1.x projects only `id`, `order`, and `label` from a
* `settings.section` registration, then chooses icons inside the settings
* shell from a closed list of built-in ids. Until that public contract grows
* an icon field, the plugin identifies only its own localized row after the
* dialog mounts. The marker owns no shell structure and is removed on fiber
* disposal, so the adaptation remains HMR-safe.
*/
export const SETTINGS_NAV_MARKER = 'data-dsh-better-sidebar-settings-nav'
/**
* Keep the marker on the settings-nav button whose visible text is this
* plugin's current localized section label.
* @param label - locale-aware label resolver used by the section registration.
* @returns disposer that disconnects observation and removes owned markers.
*/
export function registerSettingsNavIcon(label: () => string): () => void {
let disposed = false
const sync = (): void => {
if (disposed) return
const currentLabel = label().trim()
const buttons = document.querySelectorAll<HTMLButtonElement>('[role="dialog"] nav button')
for (const button of buttons) {
const matches = currentLabel.length > 0 && button.textContent?.trim() === currentLabel
if (matches) button.setAttribute(SETTINGS_NAV_MARKER, '')
else button.removeAttribute(SETTINGS_NAV_MARKER)
}
}
sync()
const observer = new MutationObserver(sync)
observer.observe(document.body, { childList: true, subtree: true, characterData: true })
return () => {
disposed = true
observer.disconnect()
document.querySelectorAll(`[${SETTINGS_NAV_MARKER}]`)
.forEach(element => { element.removeAttribute(SETTINGS_NAV_MARKER) })
}
}
+502 -17
View File
@@ -10,19 +10,57 @@
/* ── Shell ─────────────────────────────────────────────────────────────── */
/* Skinning contract (token-driven): every surface here consumes DSH's
generic tokens (`--dsw-alias-*`), NEVER `--dsw-specific-sidebar-fill` —
skin systems override that token to restyle the APP's own left-nav column
(dsh-web-ui skins make it translucent glass or theme colors, Aqua makes it
transparent), and a panel consuming it would lose its fill or clash with
the label tokens. The panels use the generic card surface
`--dsw-alias-bg-layer-1`, which every dsh-web-ui skin overrides — so
switching skins in the dsh-web-ui skin center re-skins the sidebar panels
automatically (see AGENTS.md §8). */
/* The unified panel host: a fixed, viewport-sized, click-transparent
containing block for every panel element. Panels are ABSOLUTE within it
(not fixed), so a desktop shell's intermediate wrapper transform can
never hijack their containing block — the host itself is the only fixed
element, appended directly to document.body. The layer sits at z-index 40
(below the app's overlay stack at 100+); each panel re-enables pointer
events on itself. Degraded mode ([data-dsh-panel-host-degraded]) covers
the rare shell that transforms <html>/<body> itself: the host becomes
absolute and the mount self-check pins it to the viewport every frame. */
:global([data-dsh-panel-host]) {
position: fixed;
inset: 0;
z-index: 40;
pointer-events: none;
}
:global([data-dsh-panel-host][data-dsh-panel-host-degraded]) {
position: absolute;
top: 0;
left: 0;
}
/* The persistent expand/collapse cluster at the top-right corner: two rail
controls side by side (bottom panel's glyph LEFT of the right panel's).
Always pinned to the viewport corner — inside the right panel's top-right
while it is open, sitting flush in the 34px tab strip (28px buttons at
top: 3) whose right end it really squeezes. */
top: 3) whose right end it really squeezes. z-index 45: above the panels
(40) but below the app's overlay stack (menus/tooltips/modals sit at
100+), so a cordis approval popup is never hidden behind the workbench
(issue #52). Absolute within the panel host (the host's inset: 0 makes
viewport coordinates identical). */
.toggleCluster {
position: fixed;
top: 3px;
position: absolute;
/* env() resolves to 0 on devices without a top inset (desktops). */
top: calc(3px + env(safe-area-inset-top));
right: 10px;
z-index: 55;
z-index: 45;
display: flex;
flex-direction: row;
gap: 4px;
pointer-events: auto;
}
/* The tab strip of the OPEN right panel reserves the cluster's width at its
@@ -64,19 +102,30 @@
}
.panel {
position: fixed;
position: absolute;
top: 0;
right: 0;
/* Full height: the bottom panel squeezes only the center column, so the
right panel never gives up any vertical position. */
bottom: 0;
z-index: 50;
/* z-index 40: above the app's in-flow content, below the app's overlay
stack (100+), so menus/tooltips/modals always win over the panels
(issue #52). The toggle cluster (45) sits above it; the corner handle
is a child and stacks within the panel. */
z-index: 40;
display: flex;
flex-direction: column;
/* The native sidebar surface: bluish-50 over white / bluish-900 over dark,
exactly like the app's own sidebar column (SidebarRoot .root). */
background: var(--dsw-specific-sidebar-fill);
/* Re-enable interaction inside the click-transparent panel host. */
pointer-events: auto;
/* The generic card surface (see the root contract above). Never
`--dsw-specific-sidebar-fill` — skin plugins restyle that token for the
app's own left nav and a panel consuming it loses its fill or clashes
with labels (#60/#105/#106). */
background: var(--dsw-alias-bg-layer-1);
border-left: 1px solid var(--dsw-alias-border-l2);
/* Bottom safe-area (notched phones): the drawer's bottom content clears
the home indicator; 0 on desktops. */
padding-bottom: env(safe-area-inset-bottom);
/* Expand/collapse slides the panel; fullscreen animates the width. */
transition:
transform var(--ds-transition-duration-slow) var(--ds-ease-in-out),
@@ -101,6 +150,8 @@
.panelResize {
position: absolute;
/* Centered on the panel edge (an 8px hit strip straddling the border, the
same pattern as the host's own frame handles). */
left: -4px;
top: 0;
bottom: 0;
@@ -127,15 +178,21 @@
column only (the Sidebar shell writes left/right inline: from the app's
own left sidebar to the right panel's left edge) — the sidebars never
give up any position. Same surface as the right panel, with a top
hairline instead of the left one. */
hairline instead of the left one. z-index 40 mirrors the right panel
(issue #52: below the app's overlay stack so popups win over the
workbench). */
.bottomPanel {
position: fixed;
position: absolute;
bottom: 0;
z-index: 50;
z-index: 40;
display: flex;
flex-direction: column;
background: var(--dsw-specific-sidebar-fill);
background: var(--dsw-alias-bg-layer-1);
border-top: 1px solid var(--dsw-alias-border-l2);
/* Re-enable interaction inside the click-transparent panel host. */
pointer-events: auto;
/* Bottom safe-area (notched phones); 0 on desktops. */
padding-bottom: env(safe-area-inset-bottom);
/* Expand/collapse slides the panel; height drags animate the height. */
transition:
transform var(--ds-transition-duration-slow) var(--ds-ease-in-out),
@@ -158,8 +215,24 @@
transition: none;
}
/* Layout isolation: panel-internal reflows (tabs, editors, terminals) never
leak into the app shell's layout, and the app shell's layout changes
never cascade through the panel subtree — the drag stays confined.
will-change: transform is only live while a drag is (the body attribute
toggles with the drag state), promoting the panels to their own layer
without paying the compositing cost at rest. */
.panel,
.bottomPanel {
contain: layout style;
}
body[data-dsh-sidebar-dragging] .panel,
body[data-dsh-sidebar-dragging] .bottomPanel {
will-change: transform;
}
/* The bottom panel's height drag: the top edge strip (mirror of the right
panel's width strip). */
panel's width strip), centered on the border like the width strip. */
.bottomResize {
position: absolute;
left: 0;
@@ -209,15 +282,44 @@
padding-right: 40px;
}
/* ── Position compatibility mode (Windows native title bar) ────────────── */
/* Windows frameless / hidden-title-bar windows draw the native caption
strip (minimize / maximize / close) at the window's top-right corner,
OVER the web content. When the user enables the "position compatibility"
pref, the sidebar yields the strip: the toggle cluster drops below it and
the right panel's content starts below it — the panel's surface still
spans the full height, so the caption buttons float over empty sidebar
fill (the VSCode hidden-title-bar look). The strip height is user-tunable
(`--dsh-title-bar-strip`, written by the Sidebar shell from
`titleBarStripPx`; 40px fallback). The bottom panel is unaffected: it
sits at the window's bottom, never under the top strip. */
:global(body[data-dsh-title-bar-compat]) .toggleCluster {
top: calc(var(--dsh-title-bar-strip, 40px) + 3px);
}
:global(body[data-dsh-title-bar-compat]) .panel {
/* The caption strip is drawn over the panel's own top edge: keep the
surface full-height and push the content (the tab strip) below the
strip instead. .panelResize is absolutely positioned and unaffected. */
padding-top: var(--dsh-title-bar-strip, 40px);
}
/* The shared corner (both panels open): the intersection of the right
panel's left edge and the bottom panel's top edge. Horizontal drags
resize the width, vertical drags the height — the two panels drag
against each other. */
against each other. Rendered INSIDE the right panel and positioned
relative to it (left edge + the bottom panel's height from the layout
variable), so no JS-written viewport coordinates are involved. The
z-index is panel-local (above the panel's content; the panel itself sits
at 40, below the app's overlay stack). */
.cornerHandle {
position: fixed;
position: absolute;
left: -6px;
bottom: calc(var(--dsh-sidebar-height, 0px) + 6px);
width: 12px;
height: 12px;
z-index: 52;
z-index: 2;
cursor: nwse-resize;
touch-action: none;
}
@@ -614,6 +716,8 @@
flex: 1;
min-height: 0;
overflow-y: auto;
/* Deep trees truncate their labels instead of scrolling horizontally. */
overflow-x: hidden;
padding: 2px 6px 8px;
}
@@ -625,7 +729,12 @@
display: flex;
align-items: center;
gap: 6px;
/* border-box keeps the inline indent padding (depth * 22 + 6) INSIDE the
row's 100% width — without it deep rows overflow and force a horizontal
scrollbar on the tree body. */
box-sizing: border-box;
width: 100%;
max-width: 100%;
height: 34px;
padding: 0 8px;
border: none;
@@ -651,9 +760,25 @@
opacity: 0.45;
}
/* The link badge on symlink rows (folder and file rows alike). */
.explorerSymlink {
flex: none;
color: var(--dsw-alias-label-tertiary);
}
/* A symlink whose target is missing: error ink on the name (the row stays
clickable — the editor surfaces the read failure). */
.explorerBroken .explorerName {
color: var(--dsw-alias-state-error-primary);
}
.explorerName {
/* min-width: 0 lets the flex item shrink below its content so the label
truncates with an ellipsis instead of widening the row. */
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* The hover-revealed @-reference button at the row's far right (and the
@@ -735,6 +860,139 @@
white-space: nowrap;
}
/* Merged mode (the editorExplorer pref): the header's path input replaces
the plain title, and a toggleable file tree docks at the tab's right edge. */
.editorPathInput {
flex: 1;
min-width: 0;
height: 28px;
padding: 0 10px;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 6px;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxs-12);
}
.editorPathInput:focus {
outline: none;
border-color: var(--dsw-alias-border-l2);
}
/* The tree-panel toggle in the header: active = panel open (the selected
fill of the tab strip). */
.editorTreeToggleActive {
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-interactive-bg-active);
}
.editorBody {
flex: 1;
min-height: 0;
display: flex;
}
.editorMain {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
/* The docked file tree: the wrapper owns the (drag-adjustable) width and
the hairline separation; the resize handle is a wider invisible strip on
its left edge with a hover affordance. */
.editorTreeDock {
position: relative;
flex: none;
min-height: 0;
display: flex;
border-left: 1px solid var(--dsw-alias-border-l1);
}
.editorTreeResize {
position: absolute;
top: 0;
bottom: 0;
/* Inside the dock (left edge): pointer capture on the handle keeps the
drag alive even when the pointer leaves the strip. */
left: 0;
width: 6px;
cursor: col-resize;
touch-action: none;
z-index: 3;
}
.editorTreeResize:hover {
background: var(--dsw-alias-border-l2);
}
.editorTreePanel {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
/* The tree panel's full-window form (the `full` prop): fill the tab body. */
.editorTreePanelFull {
flex: 1;
}
.editorTreeSearch {
flex: none;
display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
}
.editorSearchInput {
flex: 1;
min-width: 0;
height: 26px;
padding: 0 10px;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 6px;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxs-12);
}
.editorSearchInput:focus {
outline: none;
border-color: var(--dsw-alias-border-l2);
}
.editorSearchHint {
padding: 8px 12px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
}
.editorSearchResult {
display: block;
width: 100%;
padding: 4px 8px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxs-12);
text-align: left;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.editorSearchResult:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.editorStatus {
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
@@ -1082,6 +1340,174 @@
font: var(--dsw-font-xs-13);
}
/* ── Mermaid diagram block (markdown preview, mermaid lazy chunk) ────────
Mirrors the md-code-block chrome the DSH CodeBlock renders, so a diagram
reads as a peer of the surrounding fences: hairline border, layer-2
header with the info string + copy action, layer-1 body. The SVG body is
static (no bindFunctions) and sanitized (no foreignObject/script/@attrs/
hrefs), so it carries no event surface beyond the copy button; the body
click opens the zoom modal on a sanitized clone. */
.mermaidWrap {
margin: 6px 0;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 6px;
background: var(--dsw-alias-bg-layer-1);
overflow: hidden;
}
.mermaidHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 4px 8px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
background: var(--dsw-alias-bg-layer-2);
}
.mermaidInfo {
font: var(--dsw-font-xxxs-strong-11);
color: var(--dsw-alias-label-tertiary);
}
.mermaidCopy {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0 6px;
height: 20px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xxxs-11);
cursor: pointer;
}
.mermaidCopy:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.mermaidBody {
display: flex;
justify-content: center;
padding: 10px;
overflow: auto;
cursor: zoom-in;
}
.mermaidBody :global(svg) {
max-width: 100%;
height: auto;
}
.mermaidError {
padding: 6px 10px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
color: var(--dsw-alias-state-error-primary);
font: var(--dsw-font-xxxs-11);
}
/* The diagram source shown under a render error (helps fixing the fence). */
.mermaidCode {
margin: 0;
padding: 8px 10px;
overflow: auto;
font: var(--dsw-font-xxxs-11);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
/* The mermaid markdown container (single MarkdownText pass): a swapped
code block keeps its React-managed .md-code-block wrapper in the tree
(reconciliation safety), but the wrapper's own box is suppressed so only
the diagram chrome renders; the swapped-in host div flows as a block. */
.mermaidMarkdown :global(.md-code-block[data-mermaid-processed]) {
display: contents;
}
/* ── Mermaid zoom modal (click-to-enlarge; portalled to document.body) ──
Fixed overlay above the DSH overlay stack (1000+), token-driven surface.
The stage centers the (sanitized, cloned) svg and the modal owns the
transform; the drag/zoom listeners live in the component, nothing here
targets global class names. */
.mermaidModal {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--dsw-alias-bg-mask-1);
backdrop-filter: blur(2px);
}
.mermaidModalToolbar {
position: absolute;
top: 16px;
right: 16px;
display: flex;
gap: 8px;
z-index: 10;
}
.mermaidModalButton {
width: 36px;
height: 36px;
border-radius: 8px;
border: 1px solid var(--dsw-alias-border-l1);
background: var(--dsw-alias-bg-layer-2);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xs-strong-13);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.mermaidModalButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.mermaidModalStage {
width: 90vw;
height: 80vh;
overflow: hidden;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.mermaidModalStage :global(svg) {
max-width: none;
max-height: none;
cursor: grab;
transform-origin: center center;
user-select: none;
-webkit-user-drag: none;
background: var(--dsw-alias-bg-layer-1);
padding: 16px;
border-radius: 12px;
}
.mermaidModalStage :global(svg):active {
cursor: grabbing;
}
.mermaidModalHint {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
color: var(--dsw-alias-label-tertiary);
font: var(--dsw-font-xxxs-11);
pointer-events: none;
}
/* The floating "add to conversation" button anchored above a text
selection in the preview/code viewers. Portalled to document.body and
position:fixed, so the editor's overflow clips cannot crop it; flat
@@ -1241,6 +1667,65 @@
color: var(--dsw-alias-label-primary);
}
/* The node-pty dependency failure banner (issue #140): a taller warn block
carrying the pasteable repair command (bash / cmd / PowerShell). */
.terminalDepsBanner {
flex: none;
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-state-warn-label);
background: var(--dsw-alias-state-warn-tertiary);
}
.terminalDepsTitle {
font: var(--dsw-font-xxs-strong-12);
color: var(--dsw-alias-state-warn-primary);
}
.terminalDepsHint {
opacity: 0.9;
}
.terminalDepsCommandRow {
display: flex;
align-items: flex-start;
gap: 8px;
}
/* The command the user pastes into a terminal / cmd. Selectable, wrapping,
and scrollable when long (Windows paths can get very long). */
.terminalRepairCommand {
flex: 1;
min-width: 0;
margin: 0;
padding: 6px 8px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
user-select: text;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-bg-layer-2);
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 4px;
max-height: 160px;
overflow: auto;
}
.terminalDepsNote {
opacity: 0.85;
}
.terminalDepsActions {
display: flex;
align-items: center;
gap: 8px;
}
/* The PER-TAB error strip: fills one tab's content area (unlike
.boundaryError, the full-height fixed rail for shell-level crashes), so a
crashing viewer/editor never hides the toggle cluster or the other tabs. */
+69 -18
View File
@@ -13,8 +13,8 @@ import { SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs } from '../prefs-shared.ts'
import { isNarrowWidth } from './breakpoints.ts'
/**
* Tab type identifier. Builtins register their ids (explorer / git / editor
* / terminal / subagent / diff) through the sidebar service; external
* Tab type identifier. Builtins register their ids (editor / git / terminal
* / subagent / browser / diff) through the sidebar service; external
* plugins register their own (e.g. `'my-plugin:db'`). Kept as `string` so
* the registry stays open.
*/
@@ -25,7 +25,7 @@ export type SidebarDiffRef =
| { kind: 'worktree'; path: string; staged: boolean; untracked?: boolean }
| { kind: 'commit'; hash: string; hashFull: string; subject: string }
/** One open tab. `path` carries the file (editor) or is absent (explorer/git);
/** One open tab. `path` carries the file (editor) or is absent (git/terminal);
* `diff` carries the change a diff tab shows; `meta` (v0.12.0+) carries
* plugin-owned JSON-serializable state, preserved across reloads. */
export interface SidebarTab {
@@ -105,6 +105,13 @@ function uid(prefix: string): string {
return `${prefix}:${nextIdCounter}`
}
/** Mint a fresh uid-based tab id. The `'editor:' + path` convention only
* covers openSidebarFile opens (per-path dedupe); opens that must not
* dedupe (the tree's "open to the side") mint through here. */
export function mintTabId(): string {
return uid('tab')
}
/**
* The largest numeric suffix across a raw persisted state's counter ids
* (`pane:N` / `tab:N` / `split:N`). The uid counter is module-global and
@@ -139,17 +146,24 @@ function maxCounterId(parsed: unknown): number {
return max
}
/** A fresh default state: one explorer tab in one pane, open per the caller's
/** The default tab a fresh session seeds. */
export type DefaultSeed = 'editor-home' | 'none'
/** A fresh default state: one seeded tab in one pane, open per the caller's
* preference. `width` is the caller's preferred panel width (default
* PANEL_DEFAULT) and `panelOpen` whether the panel starts expanded (default
* true); the store seeds new sessions from the user's side card prefs.
* `seedExplorer` places the default explorer tab — the store passes false
* when the user disabled the explorer tab type in settings, so a fresh
* session starts with an empty pane instead of a tab they turned off. */
export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seedExplorer = true): SidebarState {
* `seed` picks the seeded tab: 'editor-home' places the EMPTY files window
* (an editor tab with no path whose tree panel starts open,
* `meta.treeOpen: true`) — in BOTH editorExplorer modes that window is the
* file explorer page — and 'none' starts with an empty pane (the store
* passes it when the user disabled the editor tab type in settings). */
export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seed: DefaultSeed = 'editor-home'): SidebarState {
const leaf: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
if (seedExplorer) {
leaf.tabs = [{ id: uid('tab'), type: 'explorer', title: 'Explorer' }]
if (seed === 'editor-home') {
// No path: the editor host renders its empty-state hint and the docked
// tree panel (treeOpen defaults open for path-less tabs; meta pins it).
leaf.tabs = [{ id: uid('tab'), type: 'editor', title: 'Files', meta: { treeOpen: true } }]
leaf.active = leaf.tabs[0]!.id
}
// The bottom panel starts closed with an empty pane (its welcome cards
@@ -764,19 +778,21 @@ function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
// New sessions seed from the user's side card prefs: the width is the
// chosen percent of the window (clamped to the panel floor and the
// viewport so a huge percent can never crush the app shell), the panel
// starts open only when the preference says so, and the default explorer
// tab is skipped when the user disabled the explorer tab type. On a
// NARROW viewport a brand-new session starts collapsed instead — the
// panel is a full-screen drawer there, and auto-opening it on first
// paint would cover the conversation before the user asked. Only the
// first seeding is affected: once the user expands the drawer,
// `panelOpen: true` persists like any other state.
// starts open only when the preference says so, and the seed tab is the
// empty files window (tree panel open) in BOTH editorExplorer modes — a
// disabled editor type seeds nothing. On a NARROW viewport a brand-new
// session starts collapsed instead — the panel is a full-screen drawer
// there, and auto-opening it on first paint would cover the conversation
// before the user asked. Only the first seeding is affected: once the
// user expands the drawer, `panelOpen: true` persists like any other
// state.
const viewport = typeof window !== 'undefined' ? window.innerWidth : undefined
const width = viewport === undefined
? PANEL_DEFAULT
: defaultWidthFor(viewport, prefs.defaultWidthPercent)
const openByDefault = prefs.openByDefault && (viewport === undefined || !isNarrowWidth(viewport))
return makeDefaultState(width, openByDefault, prefs.tabsEnabled['explorer'] !== false)
const seed: DefaultSeed = prefs.tabsEnabled['editor'] === false ? 'none' : 'editor-home'
return makeDefaultState(width, openByDefault, seed)
}
/**
@@ -891,6 +907,21 @@ function sanitizeNode(node: unknown, seen: Set<string>, reid: Map<string, string
// accept any string type here — an unregistered type renders an
// <OrphanedTab/> at view time and recovers if its plugin loads later.
if (typeof candidate.type !== 'string') return undefined
// The standalone explorer tab type merged INTO the editor (the single
// files window): a persisted explorer tab reopens as an editor home
// tab — no path, tree panel open (an existing meta object survives).
if (candidate.type === 'explorer') {
const meta = candidate.meta !== null && typeof candidate.meta === 'object' && !Array.isArray(candidate.meta)
? candidate.meta as Record<string, unknown>
: undefined
tabs.push({
id: candidate.id,
type: 'editor',
title: 'Files',
meta: { treeOpen: true, ...meta },
})
continue
}
// `meta` is plugin-owned JSON-serializable state (v0.12.0+): the
// persisted value already went through JSON.parse, so it is inherently
// serializable — carry it through verbatim (absent on older states).
@@ -943,6 +974,26 @@ export class SidebarStore {
private readonly persistTimers = new Map<string, number>()
/** User-facing side card prefs seeding brand-new session states (defaults until the settings RPC resolves). */
private prefs: SidebarPrefs = { ...SIDEBAR_PREFS_DEFAULTS }
/**
* External disable (the dsh-web-ui family's aionui-panel provider choice):
* while true the sidebar must not mount at all. Not part of the snapshot —
* nothing renders on it; the mount gate and the intercept predicates read
* it directly.
*/
private suspended = false
/**
* Set the external-disable flag (from the settings route) and remember it
* for the mount gate and the intercept predicates.
*/
setSuspended(suspended: boolean): void {
this.suspended = suspended
}
/** Whether the sidebar is externally disabled (aionui-panel chosen). */
getSuspended(): boolean {
return this.suspended
}
/**
* Replace the side card prefs (the settings RPC result / settings page
+66
View File
@@ -30,6 +30,72 @@ export function tokenValue(name: string): string {
return getComputedStyle(document.body).getPropertyValue(name).trim()
}
/** Minimal alpha for a token color to count as effectively opaque. Skin
* systems turn `--dsw-alias-bg-base` translucent for glass panels (the
* dsh-web-ui skins use rgba 0.16–0.7; `transparent` is 0); below this
* floor a text surface (terminal, editor) would render over the skin's
* backdrop art, so callers fall back to an opaque color. Values at or
* above the floor (e.g. a skin's scoped 0.96 porcelain) pass through —
* the skin still controls the surface. */
const OPAQUE_ALPHA_MIN = 0.9
/** The alpha channel of a computed CSS color, or null when the format is
* not parseable (named colors, `color()`… — treated as opaque). Handles
* the shapes getComputedStyle actually returns: the rgb()/rgba() and
* hsl()/hsla() function forms (comma or space syntax, with or without the
* `/ alpha` slot) and the #rgb/#rgba/#rrggbb/#rrggbbaa hex family. */
export function colorAlpha(color: string): number | null {
const s = color.trim()
// #rgb / #rgba / #rrggbb / #rrggbbaa
const hex = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(s)
if (hex !== null) {
const digits = hex[1]!
if (digits.length === 3 || digits.length === 4) {
const a = digits.length === 4 ? digits[3]! : 'f'
return parseInt(a + a, 16) / 255
}
const alphaHex = digits.length === 8 ? digits.slice(6) : 'ff'
return parseInt(alphaHex, 16) / 255
}
// rgb(r g b) / rgb(r g b / a) / rgba(r, g, b, a) — the trailing slot is alpha.
const fn = /^(rgba?|hsla?)\(([^)]+)\)$/i.exec(s)
if (fn !== null) {
const parts = fn[2]!.split(/[,\s/]+/).filter(Boolean)
const alphaPart = parts[3]
if (alphaPart === undefined) return 1
const alpha = Number.parseFloat(alphaPart)
return Number.isFinite(alpha) ? alpha : 1
}
return null
}
/**
* A token value that actually PAINTS something — the guard for text
* surfaces (issue #90). Skin systems routinely set global tokens to
* `transparent` (glass skins) or translucent glass values (`rgba(…,0.16–0.7)`,
* e.g. the dsh-web-ui skins) — both are truthy strings, so callers using
* `|| fallback` never fire and the terminal/editor goes see-through over
* the skin's backdrop. This returns '' for visually inert values (unset
* keywords, transparent, and any color below the opacity floor) so the
* caller's fallback chain engages; effectively opaque values pass through.
*/
export function effectiveTokenValue(name: string): string {
const raw = tokenValue(name)
switch (raw) {
case '':
case 'transparent':
case 'initial':
case 'inherit':
case 'unset':
return ''
default: {
const alpha = colorAlpha(raw)
if (alpha !== null && alpha < OPAQUE_ALPHA_MIN) return ''
return raw
}
}
}
/**
* Subscribe to color-scheme flips (the presenter toggles the body
* attribute). The callback fires after the attribute changed; re-read the
+38 -1
View File
@@ -12,6 +12,9 @@ import {
TERMINAL_FONT_SIZE_DEFAULT,
TERMINAL_FONT_SIZE_MAX,
TERMINAL_FONT_SIZE_MIN,
TITLE_BAR_STRIP_DEFAULT,
TITLE_BAR_STRIP_MAX,
TITLE_BAR_STRIP_MIN,
WIDTH_PERCENT_DEFAULT,
WIDTH_PERCENT_MAX,
WIDTH_PERCENT_MIN,
@@ -24,6 +27,9 @@ export {
TERMINAL_FONT_SIZE_DEFAULT,
TERMINAL_FONT_SIZE_MAX,
TERMINAL_FONT_SIZE_MIN,
TITLE_BAR_STRIP_DEFAULT,
TITLE_BAR_STRIP_MAX,
TITLE_BAR_STRIP_MIN,
WIDTH_PERCENT_DEFAULT,
WIDTH_PERCENT_MAX,
WIDTH_PERCENT_MIN,
@@ -42,6 +48,22 @@ export interface SidebarConfig {
terminalsPerSession?: number
/** How long a disconnected terminal process survives awaiting a reconnect. */
reconnectGraceMs?: number
/**
* Terminal shell (absolute path or bare executable name) for BOTH the UI
* terminal tabs and the model-facing `terminal_*` tools. Empty = auto:
* POSIX follows `$SHELL` then the account login shell; Windows follows
* `DSH_SIDEBAR_SHELL`, then probes for `pwsh.exe`, then falls back to the
* inbox `powershell.exe` (5.1). Set it from `cordis.patch.yml` / profile
* plugin config, e.g. `config: { shell: /bin/zsh }`.
*/
shell?: string
/**
* Optional arguments passed to the shell executable. When non-empty these
* REPLACE the automatic platform defaults (POSIX `-l` / Windows none), so
* the deployment has full control over how the shell starts. When omitted
* the existing default behavior is kept.
*/
shellArgs?: string[]
}
/** Schemastery schema for the plugin configuration. */
@@ -51,6 +73,8 @@ export const Config: z<SidebarConfig> = z.object({
listLimit: z.number().step(1).min(1).default(1000),
terminalsPerSession: z.number().step(1).min(1).default(3),
reconnectGraceMs: z.number().step(1).min(0).default(30_000),
shell: z.string().default(''),
shellArgs: z.array(z.string()).default([]),
})
/** Fully defaulted sidebar host settings. */
@@ -60,6 +84,10 @@ export interface ResolvedSidebarConfig {
listLimit: number
terminalsPerSession: number
reconnectGraceMs: number
/** The configured terminal shell; empty means the host auto-resolves it. */
shell: string
/** Explicit shell arguments; empty means use the platform defaults. */
shellArgs: string[]
}
/**
@@ -75,6 +103,8 @@ export function resolveSidebarConfig(config: SidebarConfig | undefined): Resolve
listLimit: config?.listLimit ?? 1000,
terminalsPerSession: config?.terminalsPerSession ?? 3,
reconnectGraceMs: config?.reconnectGraceMs ?? 30_000,
shell: config?.shell?.trim() ?? '',
shellArgs: config?.shellArgs ?? [],
}
}
@@ -82,7 +112,7 @@ export function resolveSidebarConfig(config: SidebarConfig | undefined): Resolve
/** Schemastery schema for the user-facing preferences (validated by the settings service). */
export const PrefsSchema: z<SidebarPrefs> = z.object({
openByDefault: z.boolean().default(true),
openByDefault: z.boolean().default(false),
defaultWidthPercent: z.number().step(1).min(WIDTH_PERCENT_MIN).max(WIDTH_PERCENT_MAX).default(WIDTH_PERCENT_DEFAULT),
autoOpenSubagent: z.boolean().default(true),
autoOpenJobs: z.boolean().default(true),
@@ -91,10 +121,17 @@ export const PrefsSchema: z<SidebarPrefs> = z.object({
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),
editorExplorer: z.boolean().default(false),
terminalShell: z.string().default(''),
terminalShellArgs: z.string().default(''),
titleBarCompat: z.boolean().default(false),
titleBarStripPx: z.number().step(1).min(TITLE_BAR_STRIP_MIN).max(TITLE_BAR_STRIP_MAX).default(TITLE_BAR_STRIP_DEFAULT),
htmlViewerNoSandbox: z.boolean().default(false),
htmlViewerDefaultUnsafe: z.boolean().default(false),
browserNoSandbox: z.boolean().default(false),
browserInterceptLinks: z.boolean().default(true),
browserInterceptHttp: z.boolean().default(true),
browserInterceptHttps: z.boolean().default(false),
// Per-feature enable switches are OPEN maps (any tab/viewer id, built-in or
// external): an absent key means enabled, so old documents resolve to {}
// (everything on) with no migration. Non-boolean values fail validation.
+7
View File
@@ -428,6 +428,13 @@ declare module 'cordis' {
* dictionaries under the `betterSidebar` namespace. Client side only.
*/
locale: SidebarLocaleService
/**
* The client module system (rc.8+): resolves module-table specifiers
* (seed words, graph rows) — the chunk loader's externals go through it
* (`ctx.modules.import`). rc.7 exposed the same surface as the
* `window.__DSH_MODULES__` page global; the loader keeps that fallback.
*/
modules: { import(specifier: string): Promise<unknown> }
/**
* The host background-job registry (`ctx.get('jobs')`; optional — the
* sidebar routes degrade to a 503 when the deployment lacks it).
+86
View File
@@ -0,0 +1,86 @@
/**
* Recursive file-name search for the editor's merged-mode side panel.
* Streams the tree with opendir and matches the query as a case-insensitive
* substring of each entry's NAME (paths stay relative to the search root —
* the client resolves them against the session cwd). No .gitignore semantics
* (this is a name lookup, not a code search), but `.git` directories are
* skipped outright (VCS internals are never useful results) and symlink
* directories are NOT descended (cycle safety).
*
* Two performance budgets bound the walk: `maxMatches` (the client renders
* the flat list) and `maxVisited` (a runaway tree — a home directory root,
* a node_modules forest — must not stall the host). Exceeding either stops
* early with `truncated: true`.
*/
import { opendir } from 'node:fs/promises'
import { join, relative, sep } from 'node:path'
/** One search: the relative paths of the matching entries (dirs included so
* the client can hint where matches live) plus the truncation flag. */
export interface FsSearchResult {
matches: string[]
truncated: boolean
}
/** Search budgets (both injectable for tests). */
export interface FsSearchOptions {
/** Row cap of the result list (default 200). */
maxMatches?: number
/** Total entries visited before the walk gives up (default 100_000). */
maxVisited?: number
}
const DEFAULT_MAX_MATCHES = 200
const DEFAULT_MAX_VISITED = 100_000
/**
* Search `root` recursively for entries whose name contains `query`
* (case-insensitive).
* @param root - absolute search root.
* @param query - the name substring; empty matches nothing.
* @param opts - budget overrides (tests).
* @returns the matching paths RELATIVE to `root` ('/'-separated), sorted,
* plus whether a budget cut the walk short. An unreadable level is skipped
* (permission errors never fail the whole search).
*/
export async function searchFiles(root: string, query: string, opts: FsSearchOptions = {}): Promise<FsSearchResult> {
const needle = query.trim().toLowerCase()
if (needle === '') return { matches: [], truncated: false }
const maxMatches = opts.maxMatches ?? DEFAULT_MAX_MATCHES
const maxVisited = opts.maxVisited ?? DEFAULT_MAX_VISITED
const matches: string[] = []
let visited = 0
let truncated = false
const walk = async (dir: string): Promise<void> => {
if (truncated) return
const level = await opendir(dir).catch(() => undefined)
if (level === undefined) return
for await (const dirent of level) {
visited += 1
if (visited > maxVisited) {
truncated = true
return
}
// .git is VCS-internal noise: never matched, never descended.
if (dirent.isDirectory() && dirent.name === '.git') continue
if (dirent.name.toLowerCase().includes(needle)) {
matches.push(join(relative(root, dir), dirent.name))
if (matches.length >= maxMatches) {
truncated = true
return
}
}
// Descend real directories only: a symlinked directory may point back
// up the tree (cycle).
if (dirent.isDirectory() && !dirent.isSymbolicLink()) {
await walk(join(dir, dirent.name))
if (truncated) return
}
}
}
await walk(root)
// '/' separators on every platform: the client joins onto the cwd itself.
return { matches: matches.sort().map(path => path.split(sep).join('/')), truncated }
}
+51 -8
View File
@@ -2,11 +2,13 @@
* Single-level directory listing for the sidebar explorer. Streams the level
* with opendir, sorts directories first then names (case-insensitive), and
* marks POSIX-hidden entries (dot-prefixed) for dimmed display. Symlinks are
* reported as files without probing their target — the explorer shows what
* dirent says, keeping the read cheap for arbitrarily large levels.
* stat'ed once to expose their target kind — a symlink to a directory
* expands like a directory — and dangling links are flagged broken. The
* probe runs only for entries that are actually symlinks, so levels without
* links stay as cheap as before.
*/
import { opendir } from 'node:fs/promises'
import { basename, dirname, join, resolve } from 'node:path'
import { opendir, stat } from 'node:fs/promises'
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
import { SidebarError } from './wire.ts'
/** One explorer row. */
@@ -15,6 +17,10 @@ export interface SidebarFsEntry {
path: string
isDir: boolean
hidden: boolean
/** Whether the row is a symlink; `isDir` then describes the link's target. */
isSymlink: boolean
/** For symlinks: the target is missing or unreadable (stat failed). */
broken: boolean
}
/** One listed level. */
@@ -52,22 +58,53 @@ export async function listDirectory(path: string, maxEntries = 1000): Promise<Si
overflow += 1
continue
}
// Platform join: on Windows the level path uses '\' — a hardcoded '/'
// would leak mixed separators into every row's path.
rows.push({
name: dirent.name,
// Platform join: on Windows the level path uses '\' — a hardcoded '/'
// would leak mixed separators into every row's path.
path: join(path, dirent.name),
isDir: dirent.isDirectory(),
isSymlink: dirent.isSymbolicLink(),
broken: false,
hidden: dirent.name.startsWith('.'),
})
}
} catch (error) {
throw new SidebarError('fs-error', `cannot list "${path}": ${messageOf(error)}`, 400)
}
// Probe symlink targets AFTER the readdir stream closes, with bounded
// concurrency: a symlink-heavy level (UNC/network targets) would otherwise
// serialize up to maxEntries stat calls and stall the explorer. Non-symlink
// rows are skipped by the probe, so levels without links stay as cheap as
// before.
await probeSymlinkTargets(rows)
rows.sort(compareEntries)
return { path, entries: rows, truncated: overflow > 0 }
}
/** How many symlink target stats run in flight during one level listing. */
const SYMLINK_PROBE_CONCURRENCY = 32
/** Probe each symlink row's target once (bounded concurrency, order-preserving). */
async function probeSymlinkTargets(rows: SidebarFsEntry[], concurrency = SYMLINK_PROBE_CONCURRENCY): Promise<void> {
let next = 0
const workers = Array.from({ length: Math.min(concurrency, rows.length) }, async () => {
for (;;) {
const index = next
next += 1
if (index >= rows.length) return
const row = rows[index]!
if (!row.isSymlink) continue
// stat follows the chain; any failure (missing target, ELOOP, permission)
// leaves the row as a broken file-shaped link the editor refuses to read.
const info = await stat(row.path).catch(() => undefined)
row.isDir = info !== undefined ? info.isDirectory() : row.isDir
row.broken = info === undefined
}
})
await Promise.all(workers)
}
/** The root row label of a listing: the last path segment (or the full path at the filesystem root). */
export function rootLabel(path: string): string {
const base = basename(path)
@@ -80,9 +117,15 @@ export function parentOf(path: string): string | undefined {
return parent === path ? undefined : parent
}
/** Normalize a caller-supplied path to an absolute, resolved path or throw fs-error. */
/**
* Normalize a caller-supplied path to an absolute, resolved path or throw
* fs-error. `path.isAbsolute()` is the OS's own notion of absolute: POSIX
* roots (`/...`), Windows drive letters (`C:\...`) and — on win32 — UNC
* network shares (`\\server\share\...`); drive-relative forms (`C:foo`)
* stay rejected.
*/
export function requireAbsolute(path: string): string {
if (!path.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(path)) {
if (!isAbsolute(path)) {
throw new SidebarError('fs-error', `"${path}" is not an absolute path`, 400)
}
return resolve(path)
+43 -16
View File
@@ -13,6 +13,17 @@
* /sidebar/html/S/Users/me/proj/index.html
* + ./style.css → /sidebar/html/S/Users/me/proj/style.css
* Windows: C:\Users\me\a.html → /sidebar/html/S/C%3A/Users/me/a.html
* UNC (\\server\share\... or //server/share/...):
* → /sidebar/html/S//server/share/proj/a.html ('//' right after the
* sessionId marks the UNC prefix; the WHATWG URL keeps '//' intact so
* relative assets still resolve inside the same route)
*
* The decoder rebuilds the marker as a forward-slash `//server/share/...`
* path. That form is intentionally platform-neutral: `node:path` resolves it
* to `\\server\share\...` on win32 and `/server/share/...` on POSIX, so the
* host's existing requireAbsolute + isWithin fence needs no platform signal
* (a leading `//` is a legal POSIX absolute path, so no data is lost on
* either platform).
*
* This module is intentionally dependency-free (no node imports, no wire
* helpers) so the client bundle can import `encodeHtmlUrl` without tripping
@@ -37,23 +48,24 @@ export const HTML_ROUTE_PREFIX = '/sidebar/html/'
/** Build the route URL for one absolute file path (client + tests). */
export function encodeHtmlUrl(sessionId: string, path: string): string {
const unc = /^[\\/]{2}[^\\/]/.test(path)
const segments = path.split(/[\\/]+/).filter(segment => segment !== '')
return `${HTML_ROUTE_PREFIX}${encodeURIComponent(sessionId)}/${segments.map(encodeURIComponent).join('/')}`
return `${HTML_ROUTE_PREFIX}${encodeURIComponent(sessionId)}/${unc ? '/' : ''}${segments.map(encodeURIComponent).join('/')}`
}
/**
* Decode a route pathname into the session + absolute file path. Rejects
* a wrong prefix (404), an empty or double-slash path, malformed percent
* encoding, and a missing sessionId or file path (400). The caller still
* must bound the decoded path with requireAbsolute + isWithin(cwd) — a
* decoded `..` segment resolves outside the cwd and is refused there.
* a wrong prefix (404), an empty path, malformed percent encoding, and a
* missing sessionId or file path (400). The caller still must bound the
* decoded path with requireAbsolute + isWithin(cwd) — a decoded `..`
* segment resolves outside the cwd and is refused there.
*/
export function decodeHtmlUrl(pathname: string): HtmlDecodeResult {
if (!pathname.startsWith(HTML_ROUTE_PREFIX)) {
return { ok: false, status: 404, message: 'not an html route' }
}
const rest = pathname.slice(HTML_ROUTE_PREFIX.length)
if (rest === '' || rest.includes('//')) {
if (rest === '') {
return { ok: false, status: 400, message: 'invalid html route path' }
}
let segments: string[]
@@ -63,17 +75,32 @@ export function decodeHtmlUrl(pathname: string): HtmlDecodeResult {
return { ok: false, status: 400, message: 'malformed URL encoding' }
}
const [sessionId, ...pathSegments] = segments
if (sessionId === undefined || sessionId === '' || pathSegments.length === 0 || pathSegments.some(segment => segment === '')) {
if (sessionId === undefined || sessionId === '') {
return { ok: false, status: 400, message: 'sessionId and file path are required' }
}
// A Windows drive segment ('D:') is the FIRST path segment of an encoded
// drive path. Rejoining it with a leading slash would yield '/D:/work/...'
// which node's path.resolve() mangles into 'D:\D:\work\...' on Windows —
// the html route's isWithin(cwd) fence would then reject every drive path.
// Keep the drive form slash-free so requireAbsolute() resolves it verbatim.
const first = pathSegments[0] ?? ''
const path = /^[A-Za-z]:$/.test(first)
? pathSegments.join('/')
: `/${pathSegments.join('/')}`
// An empty FIRST path segment is the UNC marker (encodeHtmlUrl emits
// '<sid>//server/share/...' for UNC paths); the encoder filters empty
// segments everywhere else, so an empty segment can only be the marker or
// a malformed URL — both handled here.
const unc = pathSegments[0] === ''
const tail = unc ? pathSegments.slice(1) : pathSegments
if (tail.length === 0 || tail.some(segment => segment === '')) {
return { ok: false, status: 400, message: 'sessionId and file path are required' }
}
let path: string
if (unc) {
// Rebuild the platform-neutral forward-slash form `//server/share/...`;
// requireAbsolute() resolves it to the platform's own UNC/POSIX spelling.
path = `//${tail.join('/')}`
} else if (/^[A-Za-z]:$/.test(tail[0] ?? '')) {
// A Windows drive segment ('D:') is the FIRST path segment of an encoded
// drive path. Rejoining it with a leading slash would yield '/D:/work/...'
// which node's path.resolve() mangles into 'D:\D:\work\...' on Windows —
// the html route's isWithin(cwd) fence would then reject every drive path.
// Keep the drive form slash-free so requireAbsolute() resolves it verbatim.
path = tail.join('/')
} else {
path = `/${tail.join('/')}`
}
return { ok: true, ref: { sessionId, path } }
}
+136 -23
View File
@@ -29,14 +29,21 @@ import {
type SidebarPrefs,
} from './config.ts'
import { isWithin, parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts'
import { searchFiles } from './fs-search.ts'
import { decodeHtmlUrl } from './html-route.ts'
import { extractFrameAncestors } from './browser-probe.ts'
import { isTrustedApiRequest, isLoopbackHostname } from './trust-fence.ts'
import { registerBundleRoute } from './bundle-route.ts'
import * as git from './git.ts'
import { SettingsConflictError, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
import { defaultShell, ensureSpawnHelper, PtyManager } from './pty-manager.ts'
import { defaultShell, ensureSpawnHelper, PtyManager, shellDisplayName } from './pty-manager.ts'
import { AgentPtyRegistry, clampDims, type AgentTerminalHandle } from './agent-pty.ts'
import {
DSH_NODE_PTY_RANGE,
depsStatus,
loadNodePty,
PTY_DEPS_MISSING,
} from './pty-deps.ts'
import { registerTools } from './tools.ts'
import { buildJobsApi, type SidebarJobsRoutes } from './jobs-routes.ts'
import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
@@ -172,16 +179,45 @@ type ApiMethod = (payload: unknown) => Promise<unknown> | unknown
export interface SidebarSettingsFace {
/** The current resolved value + revision (undefined while the settings service is absent). */
get(): { value?: unknown; revision?: number }
/**
* Whether the dsh-web-ui family's aionui-panel has been selected as the
* right-panel provider (the `aionui-panel` settings namespace resolves
* `rightPanel: 'aionui-panel'`). While true the sidebar must not mount —
* the two right panels are mutually exclusive. False when the namespace is
* absent (no aionui installed) or the provider is anything else.
*/
externalDisable(): boolean
/** Merge a patch (revision-guarded) and return the fresh resolved view. */
update(patch: Record<string, unknown>, expectedRevision?: number): Promise<{ value?: unknown; revision?: number }>
}
/** Build the API method table bound to the plugin context, pty manager, agent pty registry, and resolved config. */
/** Build the API method table bound to the plugin context, pty manager, agent pty registry, resolved config, and effective terminal shell. */
/**
* Resolve the settings-page terminal shell overrides (the terminal card's
* gear rows). Empty fields mean "unset": keep the yaml `config.shell` /
* `shellArgs` (or the platform auto-resolution). The settings page is the
* runtime complement to the boot-time yaml — same contract, later binding:
* the values here win for terminals opened afterwards.
*/
function shellOverridesOf(getSettings: () => SidebarSettingsFace | undefined): { shell?: string; shellArgs?: string[] } {
const settings = getSettings()
const value = settings?.get().value
if (value === null || typeof value !== 'object') return {}
const record = value as Record<string, unknown>
const shell = typeof record.terminalShell === 'string' ? record.terminalShell.trim() : ''
const args = typeof record.terminalShellArgs === 'string' ? record.terminalShellArgs.trim() : ''
return {
shell: shell === '' ? undefined : shell,
shellArgs: args === '' ? undefined : args.split(/\s+/).filter(Boolean),
}
}
function buildApi(
ctx: Context,
ptyManager: PtyManager,
agentPtyRegistry: AgentPtyRegistry,
ptyManager: PtyManager | null,
agentPtyRegistry: AgentPtyRegistry | null,
resolved: ResolvedSidebarConfig,
terminalShell: string,
getSettings: () => SidebarSettingsFace | undefined,
): Record<string, ApiMethod> {
const cwdOf = (payload: unknown): { sessionId: string; cwd: string } => {
@@ -207,6 +243,14 @@ function buildApi(
const target = record.path === undefined ? cwd : requireAbsolute(requireString(payload, 'path'))
return listDirectory(target, resolved.listLimit)
},
'fs.search': async (payload) => {
// The editor side panel's global name search: rooted at the session
// cwd (not caller-targetable — the walk is unbounded by design and
// must never escape the workspace), budgeted inside searchFiles.
const { cwd } = cwdOf(payload)
const query = requireString(payload, 'query')
return searchFiles(cwd, query)
},
'fs.read': async (payload) => {
const { cwd } = cwdOf(payload)
// Relative paths are git-derived (status/diff report repo-root-relative
@@ -313,7 +357,9 @@ function buildApi(
'pty.close': (payload) => {
const sessionId = requireString(payload, 'sessionId')
const tab = requireString(payload, 'tab')
ptyManager.close(`${sessionId}:${tab}`)
// Degraded mode (node-pty unavailable): no live pty can exist, so a
// no-op ok is the honest answer — never an error the client must show.
ptyManager?.close(`${sessionId}:${tab}`)
return { ok: true }
},
// Release an agent terminal by uuid. The WS close frame already does
@@ -322,9 +368,14 @@ function buildApi(
// tab never leaves a zombie pty behind. Idempotent.
'agent-pty.close': (payload) => {
const uuid = requireString(payload, 'uuid')
agentPtyRegistry.close(uuid)
agentPtyRegistry?.close(uuid)
return { ok: true }
},
// Terminal dependency status (issue #140): after a WS close 1011 with
// reason `pty-deps-missing` the client fetches the full repair details
// here — the close reason itself is capped at 123 bytes, too small for
// the pasteable command.
'terminal.deps': () => depsStatus(),
// Background jobs: read one job's output (a REPLAY of what the model
// has read so far, from the owner session's event log — the model's
// job_output cursor is never touched, so the human pane can never steal
@@ -333,6 +384,11 @@ function buildApi(
// exists. Kill is fenced to the owning session by the jobs registry.
'jobs.output': (payload) => jobsApi.output(payload),
'jobs.kill': (payload) => jobsApi.kill(payload),
// The effective terminal shell and its display name. The client uses
// this to title terminal tabs with the shell name instead of a numbered
// "Terminal N" label; the shell itself is configured through
// `cordis.patch.yml` (`config.shell`) or resolved by the host default.
'shell.get': () => ({ shell: terminalShell, name: shellDisplayName(terminalShell) }),
// The side card preferences. The settings service is optional in the
// composition; while absent the routes report undefined and the client
// keeps the schema defaults. Writes are revision-guarded: a stale editor
@@ -340,7 +396,9 @@ function buildApi(
// silently overwritten (mirror of the settings seam's own guard).
'settings.get': () => {
const settings = getSettings()
return settings?.get() ?? { value: undefined, revision: undefined }
return settings === undefined
? { value: undefined, revision: undefined, externalDisable: false }
: { ...settings.get(), externalDisable: settings.externalDisable() }
},
'settings.update': async (payload) => {
const settings = getSettings()
@@ -426,18 +484,39 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
// restore it before any terminal can spawn (idempotent).
ensureSpawnHelper()
const resolved = resolveSidebarConfig(config)
// One shell resolution feeds BOTH terminal surfaces: the UI tabs and the
// model-facing terminal_* tools. They must stay in lockstep, otherwise a
// configured shell fixes one surface and silently leaves the other on the
// platform default.
const terminalShell = defaultShell({ explicit: resolved.shell })
// The web runtime's bind-derived trust list (boot-sampled LAN literals
// plus --trusted-host authorities) — the authoritative source the /api
// gateway fence derives its list from. Read per request from the live
// service value; a replaced list takes effect without a plugin restart.
const fence = (req: SidebarHttpRequest): boolean => isTrustedApiRequest(req, ctx.webRuntime.trustedHosts)
const ptyManager = new PtyManager(defaultShell(), resolved.terminalsPerSession)
// node-pty is loaded lazily, never at module top level (issue #140): a
// missing or broken install must degrade THIS plugin — terminal tab shows
// a repair command, agent terminal tools stay unregistered — instead of
// failing the plugin load and taking the whole `dsh web` server down.
const nodePty = loadNodePty()
if (nodePty === null) {
const status = depsStatus()
const detail = status.ok
? 'unknown cause'
: `${status.cause}. Repair: ${status.command}`
ctx.logger?.warn(`[dsh-better-sidebar] node-pty (${DSH_NODE_PTY_RANGE}) failed to load: ${detail}`)
}
const ptyManager = nodePty !== null
? new PtyManager(terminalShell, resolved.terminalsPerSession, resolved.shellArgs, nodePty)
: null
// The agent-owned terminal registry: parallel to the UI-tab ptyManager,
// keyed by uuid (the model's opaque handle) instead of `${sessionId}:${tabId}`,
// uncapped, and torn down with the plugin. The model creates terminals here
// through the terminal_create tool; the sidebar view attaches through the
// same /sidebar/ws/terminal upgrade with ?uuid=... instead of ?tab=...
const agentPtyRegistry = new AgentPtyRegistry(defaultShell())
const agentPtyRegistry = nodePty !== null
? new AgentPtyRegistry(terminalShell, resolved.shellArgs, nodePty)
: null
// ── User-facing "Side card" preferences ──────────────────────────────────
// Register the namespace with the settings provider so the Settings page
@@ -456,7 +535,10 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
const syncToolsGate = (scope: { get(): SidebarPrefs }): void => {
if (scope.get().agentTerminalTools) {
if (toolsDisposers === null) {
toolsDisposers = registerTools(ctx, agentPtyRegistry, (sessionId) => sessionCwdOf(ctx, sessionId))
// Degraded mode (node-pty unavailable): never register the terminal
// tools — every one of them would fail at spawn time.
if (agentPtyRegistry === null) return
toolsDisposers = registerTools(ctx, agentPtyRegistry, (sessionId) => sessionCwdOf(ctx, sessionId), () => shellOverridesOf(() => settingsFace))
}
} else if (toolsDisposers !== null) {
toolsDisposers()
@@ -464,7 +546,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
// The feature is off: release every agent terminal the model created
// while it was on (they are only reachable through the tools). The
// registry change fires the push, so the sidebar reconciles them away.
agentPtyRegistry.disposeAll()
agentPtyRegistry?.disposeAll()
}
}
ctx.inject(['settings'], (sctx) => {
@@ -482,8 +564,20 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
? { value: undefined, revision: undefined }
: { value: descriptor.value, revision: descriptor.revision }
}
// Mutual exclusion with the dsh-web-ui family right panel: the aionui
// panel's provider choice (`aionui-panel.rightPanel`) is the authority.
// While it resolves to 'aionui-panel', this sidebar must not mount. The
// namespace is read through the settings seam like any other registered
// section; absent namespace (no aionui installed) = not disabled.
const externalDisable = (): boolean => {
const descriptor = sctx.settings.describe({ redactSecrets: true })
.find(candidate => candidate.ns === 'aionui-panel')
const value = descriptor?.value as { rightPanel?: unknown } | undefined
return value?.rightPanel === 'aionui-panel'
}
settingsFace = {
get: viewOf,
externalDisable,
update: async (patch, expectedRevision) => {
await sctx.settings.update(ns, patch, expectedRevision)
return viewOf()
@@ -496,7 +590,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
})
// ── JSON API ────────────────────────────────────────────────────────────
const api = buildApi(ctx, ptyManager, agentPtyRegistry, resolved, () => settingsFace)
const api = buildApi(ctx, ptyManager, agentPtyRegistry, resolved, terminalShell, () => settingsFace)
ctx.effect(() => ctx.webServer.register({
kind: 'prefix',
path: '/sidebar/api',
@@ -666,7 +760,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
// The structural request/socket/head faces satisfy the shared fence;
// the `ws` package wants the real Node types — cast at this boundary.
wss.handleUpgrade(req as unknown as IncomingMessage, socket as unknown as Duplex, head as Buffer, (ws) => {
void attachTerminal(ctx, ptyManager, agentPtyRegistry, ws, req, resolved)
void attachTerminal(ctx, ptyManager, agentPtyRegistry, ws, req, resolved, () => settingsFace)
})
},
}), 'dsh-better-sidebar: terminal WebSocket')
@@ -695,8 +789,8 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
ctx.effect(() => () => {
toolsDisposers?.()
ptyManager.disposeAll()
agentPtyRegistry.disposeAll()
ptyManager?.disposeAll()
agentPtyRegistry?.disposeAll()
wss.close()
agentListWss.close()
}, 'dsh-better-sidebar: teardown')
@@ -704,7 +798,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
/** Push the live agent-terminal list for one session to a connected sidebar view. */
async function attachAgentList(
registry: AgentPtyRegistry,
registry: AgentPtyRegistry | null,
ws: WebSocket,
req: SidebarHttpRequest,
): Promise<void> {
@@ -717,13 +811,15 @@ async function attachAgentList(
}
const send = (): void => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(registry.list(sessionId)))
// Degraded mode (node-pty unavailable): no agent terminal can exist,
// so the honest push is the empty list.
ws.send(JSON.stringify(registry?.list(sessionId) ?? []))
}
}
send()
const unsubscribe = registry.subscribe(send)
ws.on('close', () => { unsubscribe() })
ws.on('error', () => { unsubscribe() })
const unsubscribe = registry?.subscribe(send)
ws.on('close', () => { unsubscribe?.() })
ws.on('error', () => { unsubscribe?.() })
} catch (error) {
ws.close(1011, error instanceof Error ? error.message : String(error))
}
@@ -743,16 +839,23 @@ async function attachAgentList(
*/
async function attachTerminal(
ctx: Context,
ptyManager: PtyManager,
agentPtyRegistry: AgentPtyRegistry,
ptyManager: PtyManager | null,
agentPtyRegistry: AgentPtyRegistry | null,
ws: WebSocket,
req: SidebarHttpRequest,
resolved: ResolvedSidebarConfig,
getSettings: () => SidebarSettingsFace | undefined,
): Promise<void> {
try {
const url = new URL(req.url ?? '/', 'http://dsh.internal')
const uuid = url.searchParams.get('uuid')
if (uuid !== null) {
// Degraded mode (node-pty unavailable): no agent terminal can exist,
// so the lookup behaves exactly like a missing uuid.
if (agentPtyRegistry === null) {
ws.close(1011, `agent terminal "${uuid}" not found`)
return
}
const handle = agentPtyRegistry.get(uuid)
if (handle === undefined) {
ws.close(1011, `agent terminal "${uuid}" not found`)
@@ -767,8 +870,18 @@ async function attachTerminal(
ws.close(1008, 'either ?uuid or ?sessionId+?tab are required')
return
}
if (ptyManager === null) {
// Degraded mode (issue #140): node-pty unavailable. The close reason
// is a SHORT marker — a WS close reason is capped at 123 bytes, so the
// client fetches the full repair command from /sidebar/api/terminal.deps.
ws.close(1011, PTY_DEPS_MISSING)
return
}
const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
const handle = ptyManager.open(sessionId, tabId, cwd, 80, 24)
// Settings-page shell overrides win over the yaml/auto shell for
// terminals opened from now on (existing pty handles keep their shell).
const overrides = shellOverridesOf(getSettings)
const handle = ptyManager.open(sessionId, tabId, cwd, 80, 24, overrides.shell, overrides.shellArgs)
// Replay the transcript, then follow live output.
if (handle.transcript !== '') ws.send(handle.transcript)
const onData = (data: string): void => {
+81 -7
View File
@@ -60,6 +60,45 @@ export interface SidebarPrefs {
* own enable switch gates it too (both must be on for the takeover).
*/
interceptOpenPath: boolean
/**
* Whether the editor tab runs in merged mode: a path input replaces the
* plain header and a toggleable file-tree panel (with a global name
* search) docks at the tab's right edge. On by default; also makes brand
* new sessions seed an empty editor tab (tree panel open) instead of the
* explorer tab. The switch lives under the editor card's gear in the
* Side card settings; off restores the pre-merge editor exactly.
*/
editorExplorer: boolean
/**
* The shell the UI and agent terminals spawn (absolute path or bare
* executable name). Empty (default) keeps the legacy resolution order:
* `cordis.patch.yml` `config.shell`, then `$SHELL` / login shell /
* `powershell.exe` on Windows. Set it from the terminal card's gear in
* the Side card settings (or the yaml) to pin a specific shell — takes
* effect for terminals opened afterwards.
*/
terminalShell: string
/**
* Explicit arguments for `terminalShell`, space-separated (empty keeps
* the platform defaults; when set, they fully replace them — same
* contract as the yaml `shellArgs`).
*/
terminalShellArgs: string
/**
* Position compatibility mode: reserves space at the top for the native
* Windows title bar (drawn at the window's top-right corner over the web
* content in frameless/hidden-title-bar windows). When on, the toggle
* cluster drops below the strip and the right panel's content starts
* below it. Off by default — the sidebar layout is untouched.
*/
titleBarCompat: boolean
/**
* The reserved top strip height in px when `titleBarCompat` is on
* (0–120, default 40). Drives the `--dsh-title-bar-strip` CSS variable:
* the toggle cluster drops `strip + 3px` and the right panel's content
* starts `strip` px below its top edge.
*/
titleBarStripPx: number
/**
* Whether the HTML previewer drops its sandboxed iframe. Sandbox ON (the
* default) renders previewed HTML in an opaque-origin iframe that cannot
@@ -85,13 +124,31 @@ export interface SidebarPrefs {
*/
browserNoSandbox: boolean
/**
* Whether clicking an http(s) EXTERNAL link in the GUI (chat messages,
* tool rows, prose mentions) opens the sidebar browser instead of a new
* browser tab. On by default; gated on the browser tab's own enable
* switch (both must be on for the takeover). Ctrl/Cmd+click always
* bypasses the takeover.
* MASTER switch: whether clicking an EXTERNAL link in the GUI (chat
* messages, tool rows, prose mentions) is taken over into the sidebar at
* all. On by default; the per-protocol granularity lives in
* `browserInterceptHttp` / `browserInterceptHttps` (the protocol flag
* must also be on), and the target tab's own enable switch gates it too.
* Ctrl/Cmd+click always bypasses the takeover. Kept as the master so old
* documents keep their meaning with no migration (an explicit `false`
* stays "never take over").
*/
browserInterceptLinks: boolean
/**
* Whether clicking an http EXTERNAL link in the GUI opens the sidebar
* (the built-in browser tab, or a plugin tab that declares `urlTarget`)
* instead of a new browser tab. On by default; gated on the
* `browserInterceptLinks` master and the target tab's own enable switch.
*/
browserInterceptHttp: boolean
/**
* Whether clicking an https EXTERNAL link in the GUI opens the sidebar
* instead of a new browser tab. OFF by default — most https sites (e.g.
* GitHub) refuse iframe embedding, so the system browser is the smoother
* default; gated on the `browserInterceptLinks` master and the target
* tab's own enable switch.
*/
browserInterceptHttps: boolean
/**
* Per-tab enable switches, keyed by tab descriptor id (`'explorer'`,
* `'my-plugin:db'`). An ABSENT key means enabled — only an explicit
@@ -123,16 +180,21 @@ export interface SidebarPrefs {
/** Range contract of {@link SidebarPrefs.defaultWidthPercent}. */
export const WIDTH_PERCENT_MIN = 20
export const WIDTH_PERCENT_MAX = 60
export const WIDTH_PERCENT_DEFAULT = 30
export const WIDTH_PERCENT_DEFAULT = 35
/** Range contract of {@link SidebarPrefs.terminalFontSize}. */
export const TERMINAL_FONT_SIZE_MIN = 9
export const TERMINAL_FONT_SIZE_MAX = 32
export const TERMINAL_FONT_SIZE_DEFAULT = 13
/** Range contract of {@link SidebarPrefs.titleBarStripPx}. */
export const TITLE_BAR_STRIP_MIN = 0
export const TITLE_BAR_STRIP_MAX = 120
export const TITLE_BAR_STRIP_DEFAULT = 40
/** Fallback prefs used whenever the settings document is unreachable or malformed. */
export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
openByDefault: true,
openByDefault: false,
defaultWidthPercent: WIDTH_PERCENT_DEFAULT,
autoOpenSubagent: true,
autoOpenJobs: true,
@@ -141,10 +203,17 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
terminalFontFamily: '',
terminalFontSize: TERMINAL_FONT_SIZE_DEFAULT,
interceptOpenPath: true,
editorExplorer: false,
terminalShell: '',
terminalShellArgs: '',
titleBarCompat: false,
titleBarStripPx: TITLE_BAR_STRIP_DEFAULT,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
browserNoSandbox: false,
browserInterceptLinks: true,
browserInterceptHttp: true,
browserInterceptHttps: false,
tabsEnabled: {},
viewersEnabled: {},
pluginSettings: {},
@@ -159,3 +228,8 @@ export function clampWidthPercent(value: number): number {
export function clampTerminalFontSize(value: number): number {
return Math.min(TERMINAL_FONT_SIZE_MAX, Math.max(TERMINAL_FONT_SIZE_MIN, Math.round(value)))
}
/** Clamp one title-bar strip height into the contract range (shared by schema and client reads). */
export function clampTitleBarStrip(value: number): number {
return Math.min(TITLE_BAR_STRIP_MAX, Math.max(TITLE_BAR_STRIP_MIN, Math.round(value)))
}
+240
View File
@@ -0,0 +1,240 @@
/**
* node-pty dependency loading for the host half (issue #140, plugin side).
*
* The terminal surfaces (UI tabs + model-facing terminal_* tools) need
* node-pty, but the package must NEVER be imported statically at module
* top level: a missing or broken install (pnpm 11's strict-dep-builds
* skipping node-pty's install script, a pruned store entry, a failed
* prebuilt-binary download…) would then fail the plugin module load and —
* because a loader entry apply failure aborts the boot — take the whole
* `dsh web` server down with it.
*
* Instead the host half loads node-pty lazily (synchronously, via
* createRequire — the same resolution `ensureSpawnHelper` already uses in
* production). When the load fails the plugin stays mounted in a degraded
* state: the terminal tab shows a friendly error carrying a pasteable
* repair command (see scripts/install.sh / install.ps1 `--repair`), and the
* agent terminal tools are simply not registered.
*
* Version contract: the plugin must stay in sync with DSH core —
* `@deepseek-ai/dsh-subprocess-local` declares `"node-pty": "^1.1.0"` in
* its `dependencies`. Both sides then resolve the SAME pnpm store entry
* (same range, same integrity → one native binding, no drift). Do NOT
* switch to a fork (e.g. @lydell/node-pty) or a different range without
* re-checking the core declaration.
*/
import { existsSync, readFileSync, realpathSync } from 'node:fs'
import { createRequire } from 'node:module'
import { homedir } from 'node:os'
import { basename, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type * as nodePtyNs from 'node-pty'
import { SidebarError } from './wire.ts'
/** The node-pty module surface the registries consume (spawn/kill/resize/…). */
export type NodePtyModule = typeof nodePtyNs
/**
* The node-pty version range this plugin ships. MUST stay identical to the
* range DSH core declares (`@deepseek-ai/dsh-subprocess-local`): the same
* range keeps pnpm resolving both to one physical package.
*/
export const DSH_NODE_PTY_RANGE = '^1.1.0'
/**
* The WebSocket close-code-1011 reason the host sends when node-pty is
* unavailable. The client recognizes this exact marker and fetches the full
* repair details from `/sidebar/api/terminal.deps` (a WS close reason is
* capped at 123 bytes, so the command itself cannot ride the close frame).
*/
export const PTY_DEPS_MISSING = 'pty-deps-missing'
/** A require-compatible loader, injectable for tests. */
export type NodePtyRequire = (id: string) => unknown
const defaultRequire: NodePtyRequire = createRequire(import.meta.url)
type LoadResult = { ok: true; module: NodePtyModule } | { ok: false; cause: unknown }
let cached: LoadResult | undefined
/**
* Load node-pty once (synchronously) and cache the outcome. Returns null
* when the package or its native binding cannot be loaded; the cause stays
* queryable through {@link nodePtyLoadCause}. Never throws.
*/
export function loadNodePty(requireImpl: NodePtyRequire = defaultRequire): NodePtyModule | null {
if (cached === undefined) {
try {
cached = { ok: true, module: requireImpl('node-pty') as NodePtyModule }
} catch (cause) {
cached = { ok: false, cause }
}
}
return cached.ok ? cached.module : null
}
/** The recorded load failure (undefined when the load succeeded or never ran). */
export function nodePtyLoadCause(): unknown {
return cached !== undefined && !cached.ok ? cached.cause : undefined
}
/** Forget the cached outcome (tests only — a real reload is otherwise one-shot). */
export function resetNodePtyCache(): void {
cached = undefined
}
/** Load node-pty or throw the canonical degraded-mode error (class-constructor default). */
export function loadRequiredNodePty(): NodePtyModule {
const module = loadNodePty()
if (module === null) {
const cause = describeCause(nodePtyLoadCause())
throw new SidebarError(
'pty-deps-missing',
`node-pty (${DSH_NODE_PTY_RANGE}) failed to load: ${cause} — run the repair command shown in the terminal tab`,
503,
)
}
return module
}
/** Resolve a directory to its physical location (symlinked/link: installs). */
function realDir(file: string): string {
try {
return dirname(realpathSync(file))
} catch {
return dirname(file)
}
}
/** Walk up from `dir` looking for a DSH profile root (package.json + pnpm-workspace.yaml). */
function walkUp(dir: string, isRoot: (dir: string) => boolean): string | null {
let current = dir
for (let depth = 0; depth < 16; depth += 1) {
if (isRoot(current)) return current
const parent = dirname(current)
if (parent === current) break
current = parent
}
return null
}
/** Whether `dir` looks like a DSH profile root (the plugin lives under its node_modules). */
function isProfileRoot(dir: string): boolean {
return existsSync(join(dir, 'package.json')) && existsSync(join(dir, 'pnpm-workspace.yaml'))
}
/**
* Detect the DSH profile directory this plugin is installed into: the
* nearest ancestor of the plugin module that carries both `package.json`
* and `pnpm-workspace.yaml` (the profile root; the plugin resolves from the
* profile's node_modules). Falls back to `$DSH_HOME/profiles/web` (the
* standard web profile), then null.
*/
export function findProfileDir(fromFile: string = fileURLToPath(import.meta.url)): string | null {
const detected = walkUp(realDir(fromFile), isProfileRoot)
if (detected !== null) return detected
const home = process.env.DSH_HOME !== undefined && process.env.DSH_HOME.trim() !== ''
? process.env.DSH_HOME
: join(homedir(), '.dsh')
const web = join(home, 'profiles', 'web')
return isProfileRoot(web) ? realpathSync(web) : null
}
/** Whether `dir`'s package.json declares this plugin's name. */
function isPluginRoot(dir: string): boolean {
const file = join(dir, 'package.json')
if (!existsSync(file)) return false
try {
const parsed = JSON.parse(readFileSync(file, 'utf8')) as { name?: unknown }
return parsed.name === 'dsh-better-sidebar'
} catch {
return false
}
}
/** The plugin package root (walk-up from the module; works for lib/ and src/ layouts). */
export function findPluginRoot(fromFile: string = fileURLToPath(import.meta.url)): string | null {
return walkUp(realDir(fromFile), isPluginRoot)
}
/** Options for {@link buildRepairCommand}. */
export interface RepairCommandOptions {
/** The plugin package root (where scripts/install.sh / install.ps1 live). */
pluginRoot: string | null
/** The detected profile directory (null → the standard `web` profile). */
profileDir: string | null
/** Platform override for tests; defaults to the live process. */
platform?: NodeJS.Platform
}
/**
* The pasteable repair command for a broken node-pty install: rerun the
* plugin's own installer in `--repair` mode (idempotent: it re-writes the
* profile's `allowBuilds: node-pty: true` and re-installs/rebuilds the
* dependency). Falls back to DSH's plugin command when the scripts are not
* shipped (exotic layouts).
*/
export function buildRepairCommand(options: RepairCommandOptions): { command: string; note?: string } {
const { pluginRoot, profileDir } = options
const platform = options.platform ?? process.platform
const profileName = profileDir !== null ? basename(profileDir) : null
const profileArg = profileName !== null
? (platform === 'win32' ? ` -Profile "${profileName}"` : ` --profile "${profileName}"`)
: ''
if (pluginRoot !== null) {
if (platform === 'win32') {
const script = join(pluginRoot, 'scripts', 'install.ps1')
if (existsSync(script)) {
return { command: `powershell -ExecutionPolicy Bypass -File "${script}" -Repair${profileArg}` }
}
} else {
const script = join(pluginRoot, 'scripts', 'install.sh')
if (existsSync(script)) {
return { command: `bash "${script}" --repair${profileArg}` }
}
}
}
const name = profileName ?? 'web'
return {
command: `dsh plugin --profile "${name}" install`,
note: 'If pnpm 11 blocked node-pty\'s build script, ensure `allowBuilds: node-pty: true` in the profile\'s pnpm-workspace.yaml (the plugin\'s scripts/install.sh / install.ps1 --repair does this automatically).',
}
}
/** One-line human description of the recorded load cause. */
function describeCause(cause: unknown): string {
if (cause instanceof Error) return cause.message
return String(cause)
}
/** Structured status served by the `/sidebar/api/terminal.deps` endpoint. */
export type NodePtyDepsStatus =
| { ok: true }
| {
ok: false
/** The require-time error message (module missing, native binding broken…). */
cause: string
/** The pasteable repair command (terminal/cmd). */
command: string
/** The detected profile name (null when undetected → the command defaults to web). */
profile: string | null
/** Optional supplementary hint (fallback command only). */
note?: string
}
/** Current node-pty dependency status (loaded vs degraded + repair info). */
export function depsStatus(options: { fromFile?: string } = {}): NodePtyDepsStatus {
const module = loadNodePty()
if (module !== null) return { ok: true }
const pluginRoot = findPluginRoot(options.fromFile)
const profileDir = findProfileDir(options.fromFile)
const { command, note } = buildRepairCommand({ pluginRoot, profileDir })
return {
ok: false,
cause: describeCause(nodePtyLoadCause()),
command,
profile: profileDir !== null ? basename(profileDir) : null,
...(note !== undefined ? { note } : {}),
}
}
+139 -9
View File
@@ -9,7 +9,9 @@
import { chmodSync, existsSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { createRequire } from 'node:module'
import * as nodePty from 'node-pty'
import { userInfo } from 'node:os'
import type { IPty } from 'node-pty'
import { loadRequiredNodePty, type NodePtyModule } from './pty-deps.ts'
import { SidebarError } from './wire.ts'
/** Per-terminal transcript bound (bytes kept for replay). */
@@ -51,7 +53,7 @@ export interface SidebarPty {
* the page-load hydrate race can attach the real cwd after the first
* connect, and a shell in the wrong directory must not linger). */
cwd: string
pty: nodePty.IPty
pty: IPty
/** Output accumulated since spawn (bounded; head dropped when over the limit). */
transcript: string
/** Whether the top-level process exited (transcript stays replayable). */
@@ -70,6 +72,9 @@ export class PtyManager {
constructor(
private readonly shell: string,
private readonly maxPerSession: number,
private readonly shellArgs: string[] = [],
/** The loaded node-pty module (injected so a broken install degrades instead of crashing the plugin). */
private readonly nodePty: NodePtyModule = loadRequiredNodePty(),
) {}
/** All live terminal keys of one session. */
@@ -99,7 +104,15 @@ export class PtyManager {
* @returns the live handle.
* @throws {SidebarError} pty-error when the per-session cap is reached.
*/
open(sessionId: string, tabId: string, cwd: string, cols: number, rows: number): SidebarPty {
open(
sessionId: string,
tabId: string,
cwd: string,
cols: number,
rows: number,
shell?: string,
shellArgs?: string[],
): SidebarPty {
const key = `${sessionId}:${tabId}`
this.cancelClose(key)
const existing = this.sessions.get(key)
@@ -118,7 +131,7 @@ export class PtyManager {
sessionId,
tabId,
cwd,
pty: nodePty.spawn(this.shell, [], {
pty: this.nodePty.spawn(shell ?? this.shell, shellSpawnArgs(shellArgs ?? this.shellArgs), {
name: 'xterm-256color',
cols: Math.max(2, Math.floor(cols)),
rows: Math.max(2, Math.floor(rows)),
@@ -191,9 +204,126 @@ export class PtyManager {
}
}
/** The interactive shell for this platform (empty SHELL falls back). */
export function defaultShell(): string {
if (process.platform === 'win32') return 'powershell.exe'
const shell = process.env.SHELL
return shell !== undefined && shell.trim() !== '' ? shell : '/bin/bash'
/**
* Inputs for {@link defaultShell} resolution. Every field is optional and
* defaults to the live process, which keeps the no-argument call sites
* working while tests (and exotic embedders) can pin the platform, the
* environment, and the existence probe independently — the Windows chain
* never executes on the ubuntu CI runners, so it is only testable through
* these injection points.
*/
export interface ShellResolutionOptions {
/** Platform override (defaults to `process.platform`). */
platform?: NodeJS.Platform
/** Environment override; the resolver only reads SHELL, DSH_SIDEBAR_SHELL, PATH, ProgramW6432, ProgramFiles, LOCALAPPDATA. */
env?: NodeJS.ProcessEnv
/** Explicitly configured shell (the `shell` config field); wins over every automatic source. Empty means unset. */
explicit?: string
/** File-existence probe override (defaults to `existsSync`). */
exists?: (path: string) => boolean
}
/**
* Candidate directories that may contain a `pwsh.exe` on Windows: PATH
* entries first, then the well-known machine/user install locations
* (including preview channels and per-user MSI/portable layouts). The
* machine-scope search reads both `ProgramW6432` and `ProgramFiles` so a
* 32-bit Node process — whose `ProgramFiles` points at `(x86)` — still
* finds a 64-bit PowerShell 7 install. De-duped while preserving priority
* order.
*/
function windowsPwshCandidateDirs(env: NodeJS.ProcessEnv): string[] {
const dirs: string[] = []
const pathEntries = env.PATH
if (pathEntries !== undefined) {
// The win32 branch always uses the Windows PATH separator; hardcoding it
// keeps the function testable from POSIX runners without a delimiter
// injection point.
for (const entry of pathEntries.split(';')) {
const trimmed = entry.trim()
if (trimmed !== '') dirs.push(trimmed)
}
}
for (const programFiles of [env.ProgramW6432, env.ProgramFiles]) {
if (programFiles === undefined || programFiles.trim() === '') continue
dirs.push(join(programFiles, 'PowerShell', '7'))
dirs.push(join(programFiles, 'PowerShell', '7-preview'))
}
const localAppData = env.LOCALAPPDATA
if (localAppData !== undefined && localAppData.trim() !== '') {
dirs.push(join(localAppData, 'Microsoft', 'PowerShell', '7'))
dirs.push(join(localAppData, 'Microsoft', 'PowerShell', '7-preview'))
dirs.push(join(localAppData, 'Programs', 'PowerShell', '7'))
dirs.push(join(localAppData, 'Programs', 'PowerShell', '7-preview'))
}
return [...new Set(dirs)]
}
/**
* The interactive shell for this platform, resolved like a terminal
* emulator: an explicitly configured shell (the `shell` config field) wins,
* then `$SHELL` on POSIX (deployment override), then the account's login
* shell from passwd, then `/bin/bash`. The passwd step matters because
* service managers and container inits often start dsh without `SHELL`, and
* the tab should still open the user's login shell (e.g. zsh) instead of
* silently degrading to bash.
*
* Windows previously short-circuited to `powershell.exe` (the inbox 5.1)
* before any resolution, so PowerShell 7 users always got a legacy shell
* without `??`/`?.`/ternary and with poor ANSI/UTF-8 defaults. The Windows
* chain is now: explicit shell → `DSH_SIDEBAR_SHELL` env override → first
* `pwsh.exe` found on PATH or in a known install directory → the 5.1
* fallback (machines without PowerShell 7 keep working).
*/
export function defaultShell(options: ShellResolutionOptions = {}): string {
const platform = options.platform ?? process.platform
const env = options.env ?? process.env
const exists = options.exists ?? existsSync
const explicit = options.explicit
if (explicit !== undefined && explicit.trim() !== '') return explicit.trim()
if (platform === 'win32') {
const envShell = env.DSH_SIDEBAR_SHELL
if (envShell !== undefined && envShell.trim() !== '') return envShell.trim()
for (const dir of windowsPwshCandidateDirs(env)) {
const candidate = join(dir, 'pwsh.exe')
if (exists(candidate)) return candidate
}
return 'powershell.exe'
}
const envShell = env.SHELL
if (envShell !== undefined && envShell.trim() !== '') return envShell.trim()
// userInfo() throws when the uid has no passwd entry (rare chroots);
// without a login shell there is nothing better than the bash default.
try {
const loginShell = userInfo().shell
if (typeof loginShell === 'string' && loginShell.trim() !== '') return loginShell
} catch {
// no passwd entry: fall through to /bin/bash
}
return '/bin/bash'
}
/**
* A short display name for a shell executable, used as the terminal tab
* title. `/bin/zsh` → `zsh`, `C:\...\powershell.exe` → `powershell`.
* Falls back to the raw value when no basename can be derived.
*/
export function shellDisplayName(shell: string): string {
const normalized = shell.replace(/\\/g, '/')
const base = normalized.slice(normalized.lastIndexOf('/') + 1)
if (base === '') return shell
return base.replace(/\.(exe|cmd|bat)$/i, '')
}
/**
* Spawn arguments that make the shell behave like a terminal-emulator tab:
* POSIX shells start as login shells (`-l`) so they read the profile files
* (`~/.profile`, `~/.zprofile`); Windows PowerShell takes no login flag.
*
* When explicit `configured` args are supplied they REPLACE the platform
* defaults entirely, giving deployments full control over shell startup.
*/
export function shellSpawnArgs(configured: string[] = []): string[] {
if (configured.length > 0) return [...configured]
return process.platform === 'win32' ? [] : ['-l']
}
+3 -1
View File
@@ -81,6 +81,7 @@ export function registerTools(
ctx: Context,
registry: AgentPtyRegistry,
resolveCwd: (sessionId: string) => string,
readShellOverrides: () => { shell?: string; shellArgs?: string[] },
): () => void {
const disposers: Array<() => void> = []
const register = (tool: ReturnType<typeof defineTool>): void => {
@@ -127,7 +128,8 @@ export function registerTools(
exec.signal.throwIfAborted()
const sessionId = sessionIdOf(exec)
const cwd = resolveCwd(sessionId)
const uuid = registry.create(sessionId, args.title, args.command, cwd, 80, 24)
const { shell, shellArgs } = readShellOverrides()
const uuid = registry.create(sessionId, args.title, args.command, cwd, 80, 24, shell, shellArgs)
return Promise.resolve({ uuid, title: args.title })
},
}))
+1
View File
@@ -15,6 +15,7 @@ export type SidebarErrorCode =
| 'fs-error'
| 'git-error'
| 'pty-error'
| 'pty-deps-missing'
| 'job-error'
| 'settings-rejected'
| 'settings-conflict'
+15 -8
View File
@@ -155,7 +155,7 @@ describe('PluginListBody copy click (interactive)', () => {
vi.restoreAllMocks()
})
it('both the name button and the jump button open the repo in a NEW browser tab (window.open, not a link)', () => {
it('the name and jump buttons open each catalog entry in a NEW browser tab (window.open, not a link)', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const opened: Array<[string, string, string]> = []
@@ -171,14 +171,21 @@ describe('PluginListBody copy click (interactive)', () => {
act(() => {
root.render(createElement(PluginListBody, { service, kind: 'viewer' }))
})
// Two window.open buttons per catalog entry — the name button and the
// jump button. The expected count is catalog-driven so adding or
// removing recommended plugins keeps the test honest instead of
// pinning a hardcoded total.
const jumpButtons = [...container.querySelectorAll('button[aria-label^="Open:"]')]
expect(jumpButtons).toHaveLength(2) // the name + the jump button
act(() => { (jumpButtons[0] as HTMLButtonElement).click() })
act(() => { (jumpButtons[1] as HTMLButtonElement).click() })
expect(opened).toEqual([
[officeEntry.url, '_blank', 'noopener'],
[officeEntry.url, '_blank', 'noopener'],
])
expect(jumpButtons).toHaveLength(builtinViewerPlugins.length * 2)
for (const button of jumpButtons) {
act(() => { (button as HTMLButtonElement).click() })
}
expect(opened).toEqual(
builtinViewerPlugins.flatMap((entry) => [
[entry.url, '_blank', 'noopener'],
[entry.url, '_blank', 'noopener'],
]),
)
act(() => { root.unmount() })
container.remove()
} finally {
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import {
allLeaves, makeDefaultState, openTabInActivePane, reconcileAgentTerminals,
tabOpenIn, toggleBottomPanel,
} from '../src/client/state.ts'
describe('agent terminal reconciliation', () => {
it('adds tabs for new agent terminals', () => {
let s = makeDefaultState(400, true)
s = reconcileAgentTerminals(s, [
{ uuid: 'aaa-111', title: 'dev server' },
{ uuid: 'bbb-222', title: 'python repl' },
])
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true)
expect(tabOpenIn(s, 'agent:bbb-222')).toBe(true)
// The titles are preserved.
const tabs = allLeaves(s.splits).flatMap(leaf => leaf.tabs)
const agentTab = tabs.find(t => t.id === 'agent:aaa-111')
expect(agentTab?.title).toBe('dev server')
expect(agentTab?.type).toBe('terminal')
})
it('removes tabs for agent terminals that vanished from the server list', () => {
let s = makeDefaultState(400, true)
s = reconcileAgentTerminals(s, [
{ uuid: 'aaa-111', title: 'keep' },
{ uuid: 'bbb-222', title: 'remove' },
])
expect(tabOpenIn(s, 'agent:bbb-222')).toBe(true)
// Next push drops bbb-222.
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'keep' }])
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true)
expect(tabOpenIn(s, 'agent:bbb-222')).toBe(false)
})
it('is a no-op (same reference) when the lists already match', () => {
let s = makeDefaultState(400, true)
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'stable' }])
const next = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'stable' }])
expect(next).toBe(s)
})
it('does not touch non-agent terminal tabs', () => {
let s = makeDefaultState(400, true)
s = openTabInActivePane(s, { id: 'terminal:1', type: 'terminal', title: 'UI Tab' })
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'agent' }])
expect(tabOpenIn(s, 'terminal:1')).toBe(true)
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true)
})
it('handles an empty server list (removes all agent tabs)', () => {
let s = makeDefaultState(400, true)
s = reconcileAgentTerminals(s, [
{ uuid: 'aaa-111', title: 'a' },
{ uuid: 'bbb-222', title: 'b' },
])
s = reconcileAgentTerminals(s, [])
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(false)
expect(tabOpenIn(s, 'agent:bbb-222')).toBe(false)
})
it('lands new agent terminals in the active tree (bottom panel pane)', () => {
let s = makeDefaultState(400, true)
s = toggleBottomPanel(s)
const bottomPane = (s.bottomSplits as { id: string }).id
s = { ...s, activePane: bottomPane }
s = reconcileAgentTerminals(s, [{ uuid: 'aaa-111', title: 'dev server' }])
expect(tabOpenIn(s, 'agent:aaa-111')).toBe(true)
expect(allLeaves(s.bottomSplits).flatMap(l => l.tabs).some(t => t.id === 'agent:aaa-111')).toBe(true)
expect(allLeaves(s.splits).flatMap(l => l.tabs).some(t => t.id === 'agent:aaa-111')).toBe(false)
})
})
+3
View File
@@ -55,6 +55,9 @@ if (g.window === undefined) {
innerWidth: 1024,
innerHeight: 768,
getComputedStyle: () => ({ getPropertyValue: () => '' }),
// desktop-env.ts reads the shell's URL stamps from location.search
// (Sidebar renders with parseDesktopEnv even in specs).
location: { search: '' },
}
}
+75 -22
View File
@@ -1,5 +1,5 @@
/**
* Built-in registration tests: the plugin registers 7 tabs and 6 file
* Built-in registration tests: the plugin registers 6 tabs and 6 file
* viewers through the same service external plugins use (dogfooding);
* the catch-all `code` viewer, the NUL-sniffing `binary-download` viewer,
* and the html sandbox settings pin the registry's behavior. (Office
@@ -15,30 +15,35 @@ import { createBetterSidebarService } from '../src/client/service.ts'
import { createSidebarStore } from '../src/client/state.ts'
import { allLeaves } from '../src/client/state.ts'
import { registerBuiltins } from '../src/client/builtins/index.ts'
import type { BuiltinTabOptions } from '../src/client/builtins/tabs.tsx'
import { t } from '../src/client/locales.ts'
function setup(): { service: ReturnType<typeof createBetterSidebarService>; store: ReturnType<typeof createSidebarStore>; dispose: () => void } {
function setup(options: BuiltinTabOptions = {}): { service: ReturnType<typeof createBetterSidebarService>; store: ReturnType<typeof createSidebarStore>; dispose: () => void } {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const dispose = registerBuiltins({} as Context, service)
const dispose = registerBuiltins({} as Context, service, options)
return { service, store, dispose }
}
describe('built-in tab registrations', () => {
it('registers the 7 built-in tabs', () => {
it('registers the 6 built-in tabs', () => {
const { service } = setup()
expect(service.getTabs().map(t => t.id).sort()).toEqual(
['browser', 'diff', 'editor', 'explorer', 'git', 'subagent', 'terminal'],
['browser', 'diff', 'editor', 'git', 'subagent', 'terminal'],
)
})
it('editor and diff are hidden from the + menu (opened by file-open / git view)', () => {
it('only diff is hidden from the + menu; editor is the visible files window (order 10)', () => {
const { service } = setup()
expect(service.getTabs().filter(t => t.hidden).map(t => t.id).sort()).toEqual(['diff', 'editor'])
expect(service.getTabs().filter(t => t.hidden).map(t => t.id)).toEqual(['diff'])
const editor = service.getTab('editor')
expect(editor?.hidden).toBe(false)
expect(editor?.order).toBe(10)
})
it('single-instance tabs use the single sugar', () => {
const { service } = setup()
for (const id of ['explorer', 'git', 'subagent']) {
for (const id of ['git', 'subagent']) {
expect(service.getTab(id)?.single).toBe(true)
}
})
@@ -49,32 +54,56 @@ describe('built-in tab registrations', () => {
expect(toggles.map(t => t.key)).toEqual(['autoOpenSubagent', 'autoOpenJobs'])
})
it('the terminal tab declares the model terminal-tools, auto-terminal and custom-font settings', () => {
it('the editor tab declares its merged-mode (embedded file tree) setting', () => {
const { service } = setup()
const toggles = service.getTab('editor')?.settings?.toggles ?? []
expect(toggles.map(t => t.key)).toEqual(['editorExplorer'])
expect(toggles[0]?.title).toBeDefined()
expect(toggles[0]?.desc).toBeDefined()
// The merged mode is an iconed select (merged vs separate), not a switch.
expect(toggles[0]?.type).toBe('select')
const options = toggles[0]?.options ?? []
expect(options.map(o => o.value)).toEqual([true, false])
expect(options.every(o => o.icon !== undefined && o.title !== undefined)).toBe(true)
})
it('the terminal tab declares the model terminal-tools, auto-terminal, shell and custom-font settings', () => {
const { service } = setup()
const toggles = service.getTab('terminal')?.settings?.toggles ?? []
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.map(t => t.key)).toEqual(['agentTerminalTools', 'bottomPanelAutoTerminal', 'terminalShell', 'terminalShellArgs', 'terminalFontFamily', 'terminalFontSize'])
// The shell rows are text inputs (empty = yaml/auto resolution), the
// font rows 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')
expect(toggles[3]?.type).toBe('text')
expect(toggles[3]?.title).toBeDefined()
expect(toggles[4]?.type).toBe('text')
expect(toggles[4]?.title).toBeDefined()
expect(toggles[4]?.placeholder).toBeDefined()
expect(toggles[5]?.type).toBe('number')
expect(toggles[5]?.min).toBe(9)
expect(toggles[5]?.max).toBe(32)
expect(toggles[5]?.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, link-takeover master and per-protocol settings', () => {
const { service } = setup()
const toggles = service.getTab('browser')?.settings?.toggles ?? []
expect(toggles.map(t => t.key)).toEqual(['browserNoSandbox', 'browserInterceptLinks'])
expect(toggles[0]?.title).toBeDefined()
expect(toggles[0]?.desc).toBeDefined()
expect(toggles[1]?.title).toBeDefined()
expect(toggles[1]?.desc).toBeDefined()
expect(toggles.map(t => t.key)).toEqual([
'browserNoSandbox',
'browserInterceptLinks',
'browserInterceptHttp',
'browserInterceptHttps',
])
for (const toggle of toggles) {
expect(toggle.title).toBeDefined()
expect(toggle.desc).toBeDefined()
}
})
it('the browser createTab mints browser:<n> ids and bumps nextBrowser', () => {
@@ -90,6 +119,30 @@ describe('built-in tab registrations', () => {
expect(state.nextBrowser).toBe(3)
})
it('the terminal createTab uses shell-name titles and hidden uuid ids, allowing duplicates', () => {
const { service, store } = setup({ terminalTitle: () => 'bash' })
store.setSession('s1')
service.openTab({ type: 'terminal' })
service.openTab({ type: 'terminal' })
const state = store.getSnapshot().state!
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs).filter(t => t.type === 'terminal')
expect(tabs).toHaveLength(2)
expect(tabs[0]!.title).toBe('bash')
expect(tabs[1]!.title).toBe('bash')
expect(tabs[0]!.id).toMatch(/^terminal:[0-9a-f-]{36}$/)
expect(tabs[1]!.id).toMatch(/^terminal:[0-9a-f-]{36}$/)
expect(tabs[0]!.id).not.toBe(tabs[1]!.id)
})
it('the terminal createTab falls back to the localized terminal label before shell info resolves', () => {
const { service, store } = setup()
store.setSession('s1')
service.openTab({ type: 'terminal' })
const state = store.getSnapshot().state!
const tab = allLeaves(state.splits).flatMap(leaf => leaf.tabs).find(t => t.type === 'terminal')
expect(tab?.title).toBe(t('terminal'))
})
it('every built-in tab carries the settings-surface icon', () => {
const { service } = setup()
for (const tab of service.getTabs()) {
+1 -1
View File
@@ -145,6 +145,6 @@ describe('/sidebar/bundle route', () => {
})
it('exports the chunk allowlist (mirror of src/client/chunk-loader.ts)', () => {
expect([...CHUNK_NAMES]).toEqual(['terminal', 'editor'])
expect([...CHUNK_NAMES]).toEqual(['terminal', 'editor', 'mermaid'])
})
})
+6 -1
View File
@@ -15,11 +15,16 @@ import { CHUNK_EXTERNALS } from '../src/client/chunk-loader.ts'
const g = globalThis as Record<string, unknown>
const CHUNKS = ['terminal', 'editor']
const CHUNKS = ['terminal', 'editor', 'mermaid']
describe('built chunk artifacts', () => {
it('each chunk assigns its global registry slot when executed as a script', () => {
g.window = g // classic-script globals
// mermaid's core hooks window.addEventListener('load') at module scope
// (its startOnLoad wiring); the Node global lacks the API, so stub it
// exactly like browser-globals.ts does for its window stub.
if (typeof g.addEventListener !== 'function') g.addEventListener = () => {}
if (typeof g.removeEventListener !== 'function') g.removeEventListener = () => {}
for (const name of CHUNKS) {
const code = readFileSync(`lib/client-${name}.js`, 'utf8')
// eslint-disable-next-line no-new-func
+216 -3
View File
@@ -18,13 +18,15 @@ import {
CHUNK_EXTERNALS,
loadChunk,
registerChunkForTests,
revalidateChunksOnReactivate,
resetChunks,
setChunkModuleSystem,
setChunkScriptLoaderForTests,
} from '../src/client/chunk-loader.ts'
import type { ChunkExports } from '../src/client/chunk-loader.ts'
interface FakeModuleSystem {
import: ReturnType<typeof vi.fn>
import: (specifier: string) => Promise<unknown>
}
function installModuleSystem(): FakeModuleSystem {
@@ -53,6 +55,7 @@ function simulateScript(name: string, factory: (require: (spec: string) => unkno
beforeEach(() => {
removeModuleSystem()
setChunkModuleSystem(undefined)
delete (globalThis as Record<string, unknown>).__dshChunks__
resetChunks()
setChunkScriptLoaderForTests(null)
@@ -89,6 +92,34 @@ describe('test-registry path (vitest / jsdom-less environments)', () => {
})
describe('production path (script injection + global registry + externals require)', () => {
it('resolves externals through an injected ctx.modules system (rc.8 — no page global)', async () => {
const modules = installModuleSystem()
// rc.8 drops window.__DSH_MODULES__; the client half injects ctx.modules.
removeModuleSystem()
setChunkModuleSystem(modules)
const loaded: string[] = []
setChunkScriptLoaderForTests(async (src) => {
loaded.push(src)
simulateScript('editor', (require) => ({ TextEditor: `view:${String(require('react'))}` }))
})
const exports = await loadChunk('editor')
expect(loaded).toEqual(['/sidebar/bundle/editor.js'])
expect(exports).toEqual({ TextEditor: 'view:[object Object]' })
expect(modules.import).toHaveBeenCalledTimes(CHUNK_EXTERNALS.length)
// The injection also lands on a plugin-owned global so chunk-bundle
// copies of this loader (which inline their own module instance and
// never run apply()) can resolve externals too — rc.8 has no shell
// page global anymore.
expect((globalThis as Record<string, unknown>).__dshSidebarModuleSystem__).toBe(modules)
// The injection survives resetChunks (shell state, not chunk state).
resetChunks()
setChunkScriptLoaderForTests(async () => {
simulateScript('editor', () => ({ TextEditor: 'editor-view' }))
})
await loadChunk('editor')
expect(modules.import).toHaveBeenCalledTimes(CHUNK_EXTERNALS.length * 2)
})
it('injects the chunk script, then materializes the factory with externals from the module table', async () => {
const modules = installModuleSystem()
const loaded: string[] = []
@@ -192,6 +223,190 @@ describe('production path (script injection + global registry + externals requir
})
})
describe('revalidateChunksOnReactivate (HMR re-activation keeps unchanged chunks)', () => {
/** Stub the bundle route's HEAD endpoint with per-name ETags (Error = unreachable). */
function stubBundleHead(etagFor: (name: string) => string | null | Error): void {
vi.stubGlobal('fetch', vi.fn(async (input: unknown) => {
const url = String(input)
const name = url.endsWith('terminal.js') ? 'terminal' : 'editor'
const etag = etagFor(name)
if (etag instanceof Error) throw etag
return {
headers: { get: (key: string) => (key.toLowerCase() === 'etag' ? etag : null) },
} as unknown as Response
}))
}
/** Let the fire-and-forget ETag recorder (recordEtag) settle. */
const settleEtag = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 5))
it('keeps the resolved exports of an unchanged chunk — no re-inject / re-execute', async () => {
installModuleSystem()
let scriptCalls = 0
setChunkScriptLoaderForTests(async () => {
scriptCalls += 1
simulateScript('editor', () => ({ TextEditor: 'editor-view' }))
})
stubBundleHead(() => '"v1"')
const first = await loadChunk('editor')
expect(scriptCalls).toBe(1)
await settleEtag()
await revalidateChunksOnReactivate()
const second = await loadChunk('editor')
expect(second).toBe(first)
expect(scriptCalls).toBe(1)
})
it('drops a chunk whose ETag changed on disk — the next open re-injects and re-executes', async () => {
installModuleSystem()
let scriptCalls = 0
setChunkScriptLoaderForTests(async () => {
scriptCalls += 1
simulateScript('editor', () => ({ TextEditor: `editor-view:${scriptCalls}` }))
})
let etag = '"v1"'
stubBundleHead(() => etag)
const first = await loadChunk('editor')
await settleEtag()
etag = '"v2"'
await revalidateChunksOnReactivate()
const second = await loadChunk('editor')
expect(second).not.toBe(first)
expect(scriptCalls).toBe(2)
})
it('fails open when revalidation cannot reach the bundle route', async () => {
installModuleSystem()
let scriptCalls = 0
setChunkScriptLoaderForTests(async () => {
scriptCalls += 1
simulateScript('terminal', () => ({ TerminalView: 'tv' }))
})
stubBundleHead(() => new Error('HEAD unreachable'))
await loadChunk('terminal')
await settleEtag()
await revalidateChunksOnReactivate()
await loadChunk('terminal')
expect(scriptCalls).toBe(2)
})
it('loadChunk is a barrier: a pending revalidation never serves stale exports (CR P1)', async () => {
installModuleSystem()
let scriptCalls = 0
setChunkScriptLoaderForTests(async () => {
scriptCalls += 1
simulateScript('editor', () => ({ TextEditor: `editor-view:${scriptCalls}` }))
})
// First load: etag "v1", recorded after settlement.
let etag = '"v1"'
stubBundleHead(() => etag)
const first = await loadChunk('editor')
await settleEtag()
expect(scriptCalls).toBe(1)
// The chunk changed on disk; the revalidation HEAD is gated (pending).
etag = '"v2"'
let release: (() => void) | undefined
const gate = new Promise<void>((resolve) => { release = resolve })
vi.stubGlobal('fetch', vi.fn(async () => {
await gate
return { headers: { get: () => etag } } as unknown as Response
}))
const revalidating = revalidateChunksOnReactivate()
// A lazy open DURING the pending revalidation must NOT get the old cache.
let resolved = false
let pendingLoad: Promise<ChunkExports> | null = null
pendingLoad = loadChunk('editor').then((exports) => { resolved = true; return exports })
await new Promise((resolve) => setTimeout(resolve, 5))
expect(resolved, 'loadChunk must await the pending revalidation').toBe(false)
// Release the HEAD; the barrier lifts and the load re-injects fresh exports.
release?.()
await revalidating
const after = await pendingLoad
expect(after).not.toBe(first)
expect(scriptCalls).toBe(2)
})
it('a HEAD that never answers is bounded by a timeout signal and fails open (barrier cannot wedge loads forever)', async () => {
installModuleSystem()
let scriptCalls = 0
setChunkScriptLoaderForTests(async () => {
scriptCalls += 1
simulateScript('editor', () => ({ TextEditor: `editor-view:${scriptCalls}` }))
})
// First load settles an ETag under a healthy HEAD route.
stubBundleHead(() => '"v1"')
const first = await loadChunk('editor')
await settleEtag()
expect(scriptCalls).toBe(1)
// The revalidation HEAD now hangs. The loader must hand the fetch a
// timeout signal and, when it fires, fail open (drop + re-fetch) — the
// barrier must never block lazy loads indefinitely.
const controller = new AbortController()
const timeoutSpy = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(controller.signal)
vi.stubGlobal('fetch', vi.fn(async (_input, init?: RequestInit) => {
await new Promise<void>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => reject(new DOMException('timed out', 'AbortError')))
})
}))
const revalidating = revalidateChunksOnReactivate()
let resolved = false
const pendingLoad = loadChunk('editor').then((exports) => { resolved = true; return exports })
await new Promise((resolve) => setTimeout(resolve, 5))
expect(resolved, 'loadChunk must await the pending revalidation').toBe(false)
controller.abort() // the timeout fires
await revalidating
const after = await pendingLoad
expect(after).not.toBe(first)
expect(scriptCalls).toBe(2)
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Number))
timeoutSpy.mockRestore()
})
it('resetChunks clears a pending revalidation barrier (the next load does not wait on the orphaned task)', async () => {
installModuleSystem()
let scriptCalls = 0
setChunkScriptLoaderForTests(async () => {
scriptCalls += 1
simulateScript('editor', () => ({ TextEditor: `editor-view:${scriptCalls}` }))
})
let etag = '"v1"'
stubBundleHead(() => etag)
await loadChunk('editor')
await settleEtag()
// Gate the revalidation HEAD so the barrier stays pending.
let release: (() => void) | undefined
const gate = new Promise<void>((resolve) => { release = resolve })
vi.stubGlobal('fetch', vi.fn(async () => {
await gate
return { headers: { get: () => etag } } as unknown as Response
}))
const revalidating = revalidateChunksOnReactivate()
// resetChunks mid-revalidation: the barrier guarded only the state being
// dropped, so the next load must proceed without waiting for the HEAD.
resetChunks()
const after = await loadChunk('editor')
expect(after).toEqual({ TextEditor: 'editor-view:2' })
expect(scriptCalls).toBe(2)
// The orphaned task still settles; its identity-guarded finally no-ops.
// (Its sweep may later drop the freshly loaded entry — chunkEtags was
// cleared by resetChunks, so the old ETag no longer matches. That is the
// fail-safe direction: never serve stale, at worst one redundant fetch.)
release?.()
await revalidating
})
it('clears test-registry fixtures on re-activation (per-test stubs never leak)', async () => {
let testCalls = 0
registerChunkForTests('editor', async () => { testCalls += 1; return { TextEditor: 'test-view' } })
await loadChunk('editor')
expect(testCalls).toBe(1)
await revalidateChunksOnReactivate()
// Test fixture gone: the production path runs and fails fast (no module system).
await expect(loadChunk('editor')).rejects.toThrow('client module system unavailable')
expect(testCalls).toBe(1)
})
})
describe('externals contract', () => {
it('the loader resolves exactly the platform externals the chunk builds keep external', () => {
expect(CHUNK_EXTERNALS).toEqual([
@@ -201,9 +416,7 @@ describe('externals contract', () => {
'react-dom/client',
'cordis',
'@deepseek-ai/dsh-client-ui-slots',
'@deepseek-ai/dsh-client-web-react',
'@deepseek-ai/dsh-client-ui-primitives',
'@deepseek-ai/dsh-client-schema-form',
'@deepseek-ai/dsh-client-runtime/client',
])
})
+72
View File
@@ -0,0 +1,72 @@
/**
* Desktop-shell detection tests: URL stamps (dsh-desktop-mode/platform)
* from the official Electron shell, the preload marker
* (__DSH_DESKTOP_FILE_PATH__), and the win32 advanced overlay adaptation.
*/
import { beforeEach, describe, expect, it } from 'vitest'
import './browser-globals.ts'
import { parseDesktopEnv, resetDesktopEnvForTests } from '../src/client/desktop-env.ts'
function setSearch(search: string): void {
;(window.location as { search: string }).search = search
}
beforeEach(() => {
resetDesktopEnvForTests()
delete (window as unknown as Record<string, unknown>).__DSH_DESKTOP_FILE_PATH__
setSearch('/')
})
describe('parseDesktopEnv', () => {
it('reports a plain browser page as non-desktop', () => {
expect(parseDesktopEnv()).toEqual({ desktop: false, mode: null, platform: null, win32OverlayTop: 0 })
})
it('parses win32 advanced stamps with the 32px window-control overlay', () => {
setSearch('?dsh-desktop-mode=advanced&dsh-desktop-platform=win32')
const env = parseDesktopEnv()
expect(env.desktop).toBe(true)
expect(env.mode).toBe('advanced')
expect(env.platform).toBe('win32')
expect(env.win32OverlayTop).toBe(32)
})
it('parses darwin advanced stamps without an overlay (traffic lights are top-left)', () => {
setSearch('?dsh-desktop-mode=advanced&dsh-desktop-platform=darwin')
const env = parseDesktopEnv()
expect(env.desktop).toBe(true)
expect(env.platform).toBe('darwin')
expect(env.win32OverlayTop).toBe(0)
})
it('parses compatibility mode as desktop with the native frame (no adaptation)', () => {
setSearch('?dsh-desktop-mode=compatibility&dsh-desktop-platform=win32')
const env = parseDesktopEnv()
expect(env.desktop).toBe(true)
expect(env.mode).toBe('compatibility')
expect(env.win32OverlayTop).toBe(0)
})
it('detects the desktop preload marker even without URL stamps', () => {
;(window as unknown as Record<string, unknown>).__DSH_DESKTOP_FILE_PATH__ = { getPathForFile: () => '' }
const env = parseDesktopEnv()
expect(env.desktop).toBe(true)
expect(env.mode).toBeNull()
expect(env.win32OverlayTop).toBe(0)
})
it('ignores unknown mode values (exotic shells keep plain-browser semantics)', () => {
setSearch('?dsh-desktop-mode=weird&dsh-desktop-platform=win32')
expect(parseDesktopEnv().mode).toBeNull()
expect(parseDesktopEnv().desktop).toBe(false)
})
it('memoizes across calls until the test hook resets', () => {
setSearch('?dsh-desktop-mode=advanced&dsh-desktop-platform=win32')
const first = parseDesktopEnv()
setSearch('/')
expect(parseDesktopEnv()).toBe(first)
resetDesktopEnvForTests()
expect(parseDesktopEnv().desktop).toBe(false)
})
})
+361
View File
@@ -0,0 +1,361 @@
/**
* Drag-layout lane: the width drag must track the app shell 1:1 — the
* regression test for issue #92 ("主会话框左右抖动").
*
* The layout push squeezes `#root` via `margin-right: var(--dsh-sidebar-width)`
* (layout.css), and layout.css disables that margin's transition while a drag
* is live via `body[data-dsh-sidebar-dragging]`. If the transition stays
* active during the drag (or the conversation lags the panel edge), the
* conversation visibly shakes at pointer cadence. This spec drives a real
* pointer drag on the width strip while a requestAnimationFrame sampler
* records, per frame:
*
* - the strip's x (the panel edge),
* - the conversation column's right edge (`#root`'s margin push lands
* exactly there),
* - whether `body[data-dsh-sidebar-dragging]` is set,
* - `#root`'s computed transition property/duration.
*
* Then it asserts the drag contract:
* 1. the dragging attribute is present during the drag;
* 2. `#root`'s transition is disabled (none) during the drag;
* 3. the conversation edge follows the panel edge monotonically (no
* oscillation) and 1:1 (total travel within a rounding epsilon).
*
* The server is booted by scripts/e2e-mount.sh; this spec only loads the
* page (same contract as mount.e2e.ts, using its own workspace so the two
* lanes never race on seeding).
*/
import { mkdirSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test, expect, request, type APIRequestContext } from '@playwright/test'
const BASE_URL = process.env.DSH_E2E_URL
if (!BASE_URL) {
throw new Error('DSH_E2E_URL is not set — boot a DSH web instance with the plugin mounted and point this lane at it (see scripts/e2e-mount.sh)')
}
/** This lane's own workspace (distinct from mount.e2e.ts's, lanes run serially
* but against the same server — never share seed paths). */
const WORKSPACE_PATH = process.env.DSH_E2E_DRAG_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-drag-workspace')
let api: APIRequestContext
/** Seed one workspace + one session through the host's unary RPC surface. */
async function seedSession(): Promise<void> {
mkdirSync(WORKSPACE_PATH, { recursive: true })
writeFileSync(join(WORKSPACE_PATH, 'seed.txt'), 'drag lane\n')
const workspace = await api.post(`${BASE_URL}/api/workspace.create`, {
data: { type: 'client-request', rpcId: 'e2e-drag-workspace', method: 'workspace.create', payload: { path: WORKSPACE_PATH } },
})
expect(workspace.ok(), `workspace.create: ${workspace.status()} ${await workspace.text()}`).toBe(true)
const workspaceBody = (await workspace.json()) as {
result: { ok: true; value: { workspace: { workspaceId: string } } } | { ok: false; error: unknown }
}
expect(workspaceBody.result.ok).toBe(true)
const workspaceId = (workspaceBody.result as { value: { workspace: { workspaceId: string } } }).value.workspace.workspaceId
const session = await api.post(`${BASE_URL}/api/session.create`, {
data: { type: 'client-request', rpcId: 'e2e-drag-session', method: 'session.create', payload: { workspaceId } },
})
expect(session.ok(), `session.create: ${session.status()} ${await session.text()}`).toBe(true)
}
test.beforeAll(async () => {
api = await request.newContext({ baseURL: BASE_URL })
await seedSession()
})
test.afterAll(async () => {
await api?.dispose()
})
interface FrameSample {
t: number
stripX: number
convoRight: number
dragging: boolean
transitionProperty: string
transitionDuration: string
}
test('width drag tracks the shell 1:1 with transitions disabled (issue #92)', async ({ page }) => {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
const sidebar = page.locator('[data-dsh-better-sidebar]')
await expect(sidebar).toBeAttached({ timeout: 90_000 })
// Dismiss whatever onboarding takeover is present (same dance as the mount
// lane), so the pointer can reach the strip without a masking overlay.
try {
await expect
.poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 })
.toBeGreaterThan(0)
} catch {
console.warn('[e2e-drag] no onboarding takeover appeared; proceeding')
}
for (let round = 0; round < 8; round++) {
let dismissed = false
for (const name of ['Continue', 'Configure later']) {
const button = page.getByRole('button', { name, exact: true }).first()
if ((await button.count()) === 0) continue
try {
await button.click({ timeout: 4_000 })
dismissed = true
await page.waitForTimeout(1_000)
} catch {
// Masked by a takeover stacked above; retry in the next round.
}
}
if (!dismissed) break
}
// openByDefault defaults OFF: a fresh session's panel starts collapsed, and
// the collapsed layout push still writes `--dsh-sidebar-width: 0px` — so the
// geometry checks below are meaningless until the panel is expanded through
// the toggle cluster.
const expandButton = sidebar.getByRole('button', { name: 'Expand sidebar' })
await expect(expandButton, 'the collapsed toggle cluster must offer the expand button').toHaveCount(1)
await expandButton.click()
// The layout push variable becomes live (non-zero) once the panel opens
// (session activation lags the shell render).
await expect
.poll(async () => {
const value = await page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
return value !== '' && value !== '0px'
}, { timeout: 90_000 })
.toBe(true)
// The width drag strip is the panel's left-edge hit strip. There is no
// dedicated hook (the skinning contract is token-driven), so locate it
// semantically among the sidebar's `cursor: col-resize` elements — the files
// window's tree-dock handle matches too, but the panel strip is always the
// LEFTMOST one (the dock sits at the panel's right edge).
const locateStrip = `(() => {
const host = document.querySelector('[data-dsh-better-sidebar]')
if (host === null) return null
const boxes = [...host.querySelectorAll('*')]
.filter(el => getComputedStyle(el).cursor === 'col-resize')
.map(el => el.getBoundingClientRect())
.filter(r => r.width > 0 && r.height > 0)
.sort((a, b) => a.x - b.x)
const r = boxes[0]
if (r === undefined) return null
const varWidth = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
return {
x: r.x, y: r.y, width: r.width, height: r.height,
varWidth: Number.isNaN(varWidth) ? 0 : varWidth,
innerWidth: window.innerWidth,
}
})()`
type StripBox = { x: number; y: number; width: number; height: number; varWidth: number; innerWidth: number }
// The panel SLIDES IN from the right on expand: locating the strip before
// the open transition settles captures a mid-animation box at the viewport
// edge, and the drag lands off-panel. Wait until the strip sits at the
// pushed layout edge (its right edge ≈ innerWidth - the push variable).
await expect
.poll(async () => {
const box = await page.evaluate<StripBox | null>(locateStrip)
if (box === null) return false
return Math.abs((box.x + box.width) - (box.innerWidth - box.varWidth)) <= 8
}, { timeout: 30_000 })
.toBe(true)
const stripBox = await page.evaluate<StripBox | null>(locateStrip)
expect(stripBox, 'the width drag strip must be present (cursor: col-resize)').not.toBeNull()
// Instrument a per-frame sampler BEFORE the drag begins.
await page.evaluate(() => {
type Sample = FrameSample
const samples: Sample[] = []
// Same rule as the strip locator above: the LEFTMOST col-resize element
// inside the sidebar host (the tree-dock handle is further right).
const host = document.querySelector('[data-dsh-better-sidebar]')
const strip = host === null
? null
: [...host.querySelectorAll<HTMLElement>('*')]
.filter(el => getComputedStyle(el).cursor === 'col-resize')
.sort((a, b) => a.getBoundingClientRect().x - b.getBoundingClientRect().x)[0]
// The conversation column: the grid item the layout push squeezes (the
// same nth-child(2) layout.css targets for the vertical push; its right
// edge is where the width push lands).
const center = document.querySelector('#root > div[data-slot="root"] > div > div:nth-child(2)')
const root = document.querySelector('#root') as HTMLElement
const loop = (): void => {
const s = strip?.getBoundingClientRect() ?? { left: 0 }
const c = center?.getBoundingClientRect() ?? { left: 0, right: 0 }
const cs = getComputedStyle(root)
samples.push({
t: performance.now(),
stripX: s.left,
convoRight: c.right,
dragging: document.body.hasAttribute('data-dsh-sidebar-dragging'),
transitionProperty: cs.transitionProperty,
transitionDuration: cs.transitionDuration,
})
requestAnimationFrame(loop)
}
requestAnimationFrame(loop)
;(window as unknown as { __dragSamples: Sample[] }).__dragSamples = samples
})
const startX = stripBox!.x + stripBox!.width / 2
const startY = stripBox!.y + Math.min(120, stripBox!.height / 2 + 60)
await page.mouse.move(startX, startY)
await page.mouse.down()
// Drag LEFT in steps (widens the panel): the conversation edge must move
// LEFT in lockstep with the panel edge, monotonically, no oscillation.
for (let i = 1; i <= 14; i++) {
await page.mouse.move(startX - i * 10, startY, { steps: 2 })
await page.waitForTimeout(40)
}
await page.mouse.up()
await page.waitForTimeout(400)
const samples = await page.evaluate(
() => (window as unknown as { __dragSamples: FrameSample[] }).__dragSamples,
)
expect(samples.length, 'the frame sampler must have collected frames').toBeGreaterThan(20)
// The drag must actually have moved the panel (sanity: the store committed).
const first = samples.find(s => s.dragging)
const last = [...samples].reverse().find(s => s.dragging)
expect(first, 'the dragging attribute must appear during the drag').toBeDefined()
expect(last!.stripX, 'the panel edge must have moved during the drag').toBeLessThan(first!.stripX - 40)
// The conversation-column selector must have matched (the push lands on it).
expect(last!.convoRight, 'the conversation edge must have moved with the drag').toBeLessThan(first!.convoRight - 40)
// Contract 1 + 2: while dragging, the body attribute is set and #root's
// margin transition is disabled (computed `transition: none` reads as
// transition-property "none" with 0s duration; the non-dragging rule would
// compute to "margin-right" with the theme duration).
const draggingSamples = samples.filter(s => s.dragging)
expect(draggingSamples.length).toBeGreaterThan(5)
for (const sample of draggingSamples) {
expect(sample.transitionProperty, 'the margin transition must be off while dragging').toBe('none')
expect(sample.transitionDuration, 'the margin transition must be off while dragging').toBe('0s')
}
// Contract 3: monotonic, 1:1 tracking. During a leftward drag both the
// strip x and the conversation right edge decrease; allow one frame of
// rAF-batching staleness (0 delta), never a reversal.
const tracked = draggingSamples.filter(s => s.t > first!.t)
for (let i = 1; i < tracked.length; i++) {
const stripDelta = tracked[i]!.stripX - tracked[i - 1]!.stripX
const convoDelta = tracked[i]!.convoRight - tracked[i - 1]!.convoRight
expect(stripDelta, 'the strip must move left during the drag').toBeLessThanOrEqual(2)
expect(
convoDelta,
`conversation edge reversed while dragging (jitter): strip ${stripDelta}px, conversation ${convoDelta}px`,
).toBeLessThanOrEqual(2)
}
// Total travel in lockstep (rounding + one-frame staleness tolerance).
const stripTravel = first!.stripX - last!.stripX
const convoTravel = first!.convoRight - last!.convoRight
expect(Math.abs(convoTravel - stripTravel), 'conversation must track the panel edge 1:1').toBeLessThanOrEqual(8)
})
test('a very fast width drag still commits the dragged position (no rollback on quick release)', async ({ page }) => {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
const sidebar = page.locator('[data-dsh-better-sidebar]')
await expect(sidebar).toBeAttached({ timeout: 90_000 })
// Dismiss whatever onboarding takeover is present (same dance as the
// main lane) so the pointer can reach the strip.
try {
await expect
.poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 })
.toBeGreaterThan(0)
} catch {
console.warn('[e2e-drag-fast] no onboarding takeover appeared; proceeding')
}
for (let round = 0; round < 8; round++) {
let dismissed = false
for (const name of ['Continue', 'Configure later']) {
const button = page.getByRole('button', { name, exact: true }).first()
if ((await button.count()) === 0) continue
try {
await button.click({ timeout: 4_000 })
dismissed = true
await page.waitForTimeout(1_000)
} catch {
// Masked by a takeover stacked above; retry in the next round.
}
}
if (!dismissed) break
}
const expandButton = sidebar.getByRole('button', { name: 'Expand sidebar' })
await expect(expandButton, 'the collapsed toggle cluster must offer the expand button').toHaveCount(1)
await expandButton.click()
await expect
.poll(async () => {
const value = await page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
return value !== '' && value !== '0px'
}, { timeout: 90_000 })
.toBe(true)
const readWidth = (): Promise<number> => page.evaluate(() => {
const value = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
return Number.isNaN(value) ? 0 : value
})
const widthBefore = await readWidth()
const stripBox = await page.evaluate(() => {
const host = document.querySelector('[data-dsh-better-sidebar]')
if (host === null) return null
const boxes = [...host.querySelectorAll('*')]
.filter(el => getComputedStyle(el).cursor === 'col-resize')
.map(el => el.getBoundingClientRect())
.filter(r => r.width > 0 && r.height > 0)
.sort((a, b) => a.x - b.x)
const r = boxes[0]
if (r === undefined) return null
const varWidth = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
return {
x: r.x, y: r.y, width: r.width, height: r.height,
varWidth: Number.isNaN(varWidth) ? 0 : varWidth,
innerWidth: window.innerWidth,
}
})
expect(stripBox, 'the width drag strip must be present').not.toBeNull()
await expect
.poll(async () => {
const box = await page.evaluate(() => {
const host = document.querySelector('[data-dsh-better-sidebar]')
const el = [...host!.querySelectorAll('*')]
.filter(e => getComputedStyle(e).cursor === 'col-resize')
.map(e => e.getBoundingClientRect())
.filter(r => r.width > 0 && r.height > 0)
.sort((a, b) => a.x - b.x)[0]
return el === undefined ? null : { x: el.x + el.width, innerWidth: window.innerWidth }
})
if (box === null) return false
const varWidth = await page.evaluate(() => parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width')) || 0)
return Math.abs(box.x - (box.innerWidth - varWidth)) <= 8
}, { timeout: 30_000 })
.toBe(true)
const startX = stripBox!.x + stripBox!.width / 2
const startY = stripBox!.y + Math.min(120, stripBox!.height / 2 + 60)
// FAST release: a single move and an immediate up — no per-frame waits,
// so the last pointermove may never reach a requestAnimationFrame before
// the release (the exact quick-flick that used to roll back to the
// pre-drag width).
await page.mouse.move(startX, startY)
await page.mouse.down()
await page.mouse.move(startX - 140, startY, { steps: 1 })
await page.mouse.up()
// The committed width must reflect the drag — and stay there (no rollback
// once React settles).
await expect
.poll(async () => await readWidth(), { timeout: 10_000 })
.toBeGreaterThan(widthBefore + 100)
await page.waitForTimeout(600)
expect(await readWidth(), 'the fast drag must not roll back after settling').toBeGreaterThan(widthBefore + 100)
})
+360
View File
@@ -0,0 +1,360 @@
/**
* Headless-render mount lane: prove the npm-packed plugin mounts into a real
* `dsh web` instance and renders without crashing the shell.
*
* The server is NOT started here — `scripts/e2e-mount.sh` boots `dsh web`
* (with the plugin mounted through the official `dsh plugin add` channel) and
* injects the base URL via `DSH_E2E_URL`. This spec:
*
* 1. seeds one workspace + one session through the host's own RPC surface
* (the same `workspace.create` / `session.create` calls the UI makes),
* so the sidebar has a real session to render;
* 2. loads the page in headless Chromium and asserts the shell and the
* plugin's `[data-dsh-better-sidebar]` host mount;
* 3. asserts the plugin's crash markers never appear (no RenderBoundary /
* fail() strips, no `pageerror`, no plugin-prefixed console errors);
* 4. expands the collapsed panel (openByDefault defaults off), sweeps every
* built-in tab (Files / Source Control / Tasks / Terminal / Browser) —
* including the lazily-fetched terminal chunk — and then opens seeded
* files through the Files window's tree (separate mode: each file opens
* its own new tab, the seeded home "Files" tab stays the explorer),
* while response waits armed before goto prove the lazily-fetched editor
* chunk (client-editor.js) and the mermaid chunk (client-mermaid.js,
* rendered SVG diagram + zoom modal) loaded.
*
* Deterministic by construction: every wait is on a DOM/network marker, the
* suite is serial (one server instance), and any crash trips the very next
* assertion.
*/
import { mkdirSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test, expect, request, type APIRequestContext } from '@playwright/test'
const BASE_URL = process.env.DSH_E2E_URL
if (!BASE_URL) {
throw new Error('DSH_E2E_URL is not set — boot a DSH web instance with the plugin mounted and point this lane at it (see scripts/e2e-mount.sh)')
}
/** Workspace the sidebar renders against (created by the lane's seeding). */
const WORKSPACE_PATH = process.env.DSH_E2E_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-workspace')
/** A file seeded into the workspace, opened through the Files window's tree to
* exercise the file-open path (editor chunk = client-editor.js). */
const SEEDED_FILE = 'hello.txt'
/** A markdown file with a mermaid fence, opened through the Files window's
* tree to force the lazily-packed mermaid chunk (client-mermaid.js) to load
* and render a sanitized SVG diagram. */
const SEEDED_MD_FILE = 'diagram.md'
/**
* The plugin's crash markers. The client mounts inside an error boundary that
* renders a strip whose text starts with these prefixes instead of crashing
* (see src/client/index.tsx `fail()` and src/client/RenderBoundary.tsx).
*/
const CRASH_STRIP_PATTERNS = [/^dsh-better-sidebar:/, /^\[dsh-better-sidebar\]/]
/** Built-in tab titles the sweep drives (en-US copy; follows DSH locale). */
const BUILTIN_TABS = ['Files', 'Source Control', 'Tasks', 'Terminal', 'Browser']
let api: APIRequestContext
/** Seed one workspace + one session (plus files for the editor/mermaid-chunk
* probes) through the host's unary RPC surface. */
async function seedSession(): Promise<void> {
mkdirSync(WORKSPACE_PATH, { recursive: true })
writeFileSync(join(WORKSPACE_PATH, SEEDED_FILE), 'hello from the mount lane\n')
// The mermaid-chunk probe file: a markdown doc whose preview must fetch
// client-mermaid.js and render the fence into an SVG diagram. The
// reference-style link's definition sits AFTER the fence: it only
// resolves when the preview is one single markdown parse (the mermaid
// path must not split the document into independent MarkdownText blocks).
writeFileSync(join(WORKSPACE_PATH, SEEDED_MD_FILE), [
'# Diagram',
'',
'[before][shared]',
'',
'```mermaid',
'graph TD',
' A[Hello] --> B[World]',
'```',
'',
'[shared]: https://example.com',
'',
'tail text',
'',
].join('\n'))
const workspace = await api.post(`${BASE_URL}/api/workspace.create`, {
data: { type: 'client-request', rpcId: 'e2e-workspace', method: 'workspace.create', payload: { path: WORKSPACE_PATH } },
})
expect(workspace.ok(), `workspace.create: ${workspace.status()} ${await workspace.text()}`).toBe(true)
const workspaceBody = (await workspace.json()) as {
result: { ok: true; value: { workspace: { workspaceId: string } } } | { ok: false; error: unknown }
}
expect(workspaceBody.result.ok).toBe(true)
const workspaceId = (workspaceBody.result as { value: { workspace: { workspaceId: string } } }).value.workspace.workspaceId
const session = await api.post(`${BASE_URL}/api/session.create`, {
data: { type: 'client-request', rpcId: 'e2e-session', method: 'session.create', payload: { workspaceId } },
})
expect(session.ok(), `session.create: ${session.status()} ${await session.text()}`).toBe(true)
}
test.beforeAll(async () => {
api = await request.newContext({ baseURL: BASE_URL })
await seedSession()
})
test.afterAll(async () => {
await api?.dispose()
})
test('plugin mounts into the DSH shell and survives a built-in tab sweep', async ({ page }) => {
const pageErrors: string[] = []
const consoleErrors: string[] = []
page.on('pageerror', (error) => pageErrors.push(String(error)))
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push(message.text())
})
// Load the shell. The app renders into #root; the plugin appends its own
// [data-dsh-better-sidebar] host once its client half activates.
//
// The editor chunk (client-editor.js) loads as soon as ANY files-window tab
// renders — the seeded home tab mounts the moment the panel expands, long
// before the tree click below — so the response wait must be armed BEFORE
// goto, or it misses the fetch and times out.
const editorChunk = page.waitForResponse(
(response) => response.url().includes('/sidebar/bundle/editor.js'),
{ timeout: 120_000 },
)
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
const sidebar = page.locator('[data-dsh-better-sidebar]')
await expect(sidebar).toBeAttached({ timeout: 90_000 })
// The unified panel host: the fixed containing block every panel lives in
// (data-dsh-panel-host). Its presence is part of the injection contract.
await expect(page.locator('[data-dsh-panel-host]')).toBeAttached({ timeout: 90_000 })
// A keyless boot stacks onboarding takeovers that mask the whole shell: a
// versioned welcome notice ("Continue", persists its acknowledgement to
// settings) and, once that is acknowledged, a provider-config dialog
// ("Configure later", session-only — always present while no credential is
// configured). Both mount only after the settings join resolves. Wait
// (bounded) for one to appear; a DSH build without onboarding proceeds
// straight to the sweep.
try {
await expect
.poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 })
.toBeGreaterThan(0)
} catch {
console.warn('[e2e] no onboarding takeover appeared; proceeding without dismissal')
}
// Dismiss whatever takeover is present, in any stacking order, until none
// remain — a masked click is retried next round instead of failing.
for (let round = 0; round < 8; round++) {
let dismissed = false
for (const name of ['Continue', 'Configure later']) {
const button = page.getByRole('button', { name, exact: true }).first()
if ((await button.count()) === 0) continue
try {
await button.click({ timeout: 4_000 })
dismissed = true
await page.waitForTimeout(1_000)
} catch {
// Masked by the takeover stacked above it; the next round tries the
// other button first.
}
}
if (!dismissed) break
}
// The seeded session must give the sidebar a session scope: without it the
// shell renders a disabled toggle cluster and the tab sweep is impossible.
const tabBar = sidebar.locator('[title]')
await expect(tabBar.first()).toBeAttached({ timeout: 90_000 })
// openByDefault defaults OFF: a fresh session's panel starts collapsed.
// Expand it through the toggle cluster before the layout push can apply.
const expandButton = sidebar.getByRole('button', { name: 'Expand sidebar' })
await expect(expandButton, 'the collapsed toggle cluster must offer the expand button').toHaveCount(1)
await expandButton.click()
// The skinning contract is token-driven (AGENTS.md §8): the panels consume
// `--dsw-alias-bg-layer-1`, so switching a skin re-skins the sidebar with
// no per-skin code. The layout push variable must be live once the panel
// mounts (its absence would mean the panel never opened with the session).
await expect
.poll(async () => (
await page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
), { timeout: 90_000 })
.not.toBe('')
// Crash-marker assertions shared by every step.
const assertNoCrash = async (): Promise<void> => {
await expect
.poll(async () => pageErrors, { timeout: 5_000 })
.toEqual([])
// Fail with the actual strip text so a regression is diagnosable from
// the test report alone (a strip renders the client fail() message).
const stripTexts = await sidebar.locator('div').evaluateAll(
(nodes, patterns) => nodes.filter((node) => {
const text = (node.textContent ?? '').trim()
return patterns.some((pattern) => pattern.test(text))
}).map((node) => (node.textContent ?? '').trim()),
CRASH_STRIP_PATTERNS,
)
expect(stripTexts, 'a dsh-better-sidebar error strip is present in the sidebar').toEqual([])
}
// Sweep every built-in tab through the "+" menu (the sidebar's own open-tab
// affordance, reachable from any pane state). Each open may fetch a lazy
// chunk (/sidebar/bundle/client-terminal.js / client-editor.js) and mount a
// real viewer — the highest-risk crash surfaces. The pinned plugin must
// offer every listed built-in: a missing or renamed descriptor is a real
// regression and fails the lane loudly instead of silently narrowing the
// sweep. A failure anywhere surfaces as a pageerror or a console error,
// both of which the next assertion sees.
const newTabButton = sidebar.getByRole('button', { name: 'New tab' }).first()
for (const title of BUILTIN_TABS) {
await newTabButton.click()
const item = page.getByRole('menuitem', { name: title }).first()
await expect(item, `built-in tab "${title}" is not offered by the + menu — descriptor removed or its label changed`).toHaveCount(1)
await item.click()
// Let the activation commit (including any lazy-chunk fetch) before the
// crash assertions run.
await page.waitForTimeout(1_500)
await assertNoCrash()
}
// The editor chunk (client-editor.js) only loads when a files-window tab
// renders. Exercise the file-open path explicitly through the Files window's
// own tree: the seeded home tab ("Files") is already open with its tree
// panel pinned — activate it from the tab strip, open the seeded file, and
// require the chunk round-trip (armed before goto), so a missing/corrupt
// editor chunk fails the lane.
// Tab-strip tabs carry `draggable="true"`; the always-mounted (hidden)
// bottom panel's empty-pane welcome cards repeat the + menu labels with
// `title="Files"`, so a bare `[title="Files"]` match is ambiguous.
const filesTab = sidebar.locator('[title="Files"][draggable="true"]').first()
await expect(filesTab, 'the seeded files-window home tab must be in the tab strip').toHaveCount(1)
await filesTab.click()
// Inactive tabs stay mounted (display:none); only the ACTIVE files
// window's tree is visible — match the visible row.
const fileRow = sidebar.locator(`[role="button"][title$="${SEEDED_FILE}"]:visible`)
await expect(fileRow, `the seeded "${SEEDED_FILE}" file must appear in the files window's tree`).toHaveCount(1, { timeout: 30_000 })
// Click near the row's LEFT edge: hovering reveals an @-reference button at
// the row's right end, and a center click on a narrow dock lands on it
// (referencing the file into the composer instead of opening it).
await fileRow.click({ position: { x: 8, y: 8 } })
await editorChunk
// Separate-mode default (editorExplorer off): the tree click OPENS A NEW
// file tab (openSidebarFile, id `editor:<path>`) instead of rewriting the
// home tab in place. The seeded "Files" home tab stays put — it is the
// standalone explorer now, not a file window.
await expect(
sidebar.locator(`[title="${SEEDED_FILE}"][draggable="true"]`),
'separate mode opens a new file tab for the tree click',
).toHaveCount(1)
// The seeded home tab survives (separate mode never rewrites it). The
// sweep's + menu opened a SECOND path-less Files window (each is its own
// explorer in separate mode), so assert presence, not an exact count.
await expect(
sidebar.locator('[title="Files"][draggable="true"]').first(),
'the seeded files-window home tab must survive the file open',
).toHaveCount(1)
const pathInput = sidebar.locator('input[placeholder^="File path"]:visible')
await expect(pathInput, 'the file tab header path input shows the opened file').toHaveValue(new RegExp(`${SEEDED_FILE}$`))
await page.waitForTimeout(1_500)
await assertNoCrash()
// The mermaid chunk (client-mermaid.js) only loads when a previewed
// markdown file contains a mermaid fence. Open the seeded diagram file
// from the files window's tree and require the full round-trip: chunk
// fetch + sanitized SVG diagram in the preview, so a missing/corrupt
// mermaid chunk or a broken render fails the lane. In separate mode the
// tree click above activated the hello.txt tab, so switch back to the
// Files explorer first (its tree is the only one visible while active).
const mermaidChunk = page.waitForResponse(
(response) => response.url().includes('/sidebar/bundle/mermaid.js'),
{ timeout: 30_000 },
)
await sidebar.locator('[title="Files"][draggable="true"]').first().click()
const mdRow = sidebar.locator(`[role="button"][title$="${SEEDED_MD_FILE}"]:visible`)
await expect(mdRow, `the seeded "${SEEDED_MD_FILE}" file must appear in the files window's tree`).toHaveCount(1, { timeout: 30_000 })
await mdRow.click({ position: { x: 8, y: 8 } })
// Separate mode: the md file opens its own tab (like hello.txt above).
await expect(
sidebar.locator(`[title="${SEEDED_MD_FILE}"][draggable="true"]`),
'separate mode opens a new tab for the markdown file',
).toHaveCount(1, { timeout: 30_000 })
// The markdown PREVIEW must render before the mermaid chunk can be
// requested — this assertion separates a preview/render regression from a
// chunk-loading one. (sidebar is already scoped to [data-dsh-better-sidebar].)
await expect(
sidebar.getByText('tail text'),
'the markdown preview must render the seeded document',
).toHaveCount(1, { timeout: 30_000 })
await mermaidChunk
await expect(
sidebar.locator('[data-mermaid-diagram] svg'),
'the mermaid fence must render into an SVG diagram in the markdown preview',
).toHaveCount(1, { timeout: 30_000 })
// Labels must survive as real SVG <text> (htmlLabels stays off so the
// sanitizer's foreignObject strip cannot eat the node text).
await expect(
sidebar.locator('[data-mermaid-diagram]').first(),
'the diagram node labels must render inside the SVG',
).toContainText('Hello', { timeout: 30_000 })
// Cross-fence semantics: the reference-style link [before][shared] must
// resolve to the definition that sits AFTER the fence — proof that the
// preview is a single markdown parse and not per-fence fragments.
await expect(
sidebar.locator('a[href="https://example.com"]').first(),
'reference-style links with definitions across a mermaid fence must resolve',
).toContainText('before', { timeout: 30_000 })
// Click-to-enlarge: clicking the diagram opens the zoom modal (portalled
// to document.body), Esc closes it again.
const modal = page.locator('[data-mermaid-modal]')
await sidebar.locator('[data-mermaid-diagram] svg').first().click()
await expect(modal, 'clicking the diagram must open the zoom modal').toHaveCount(1, { timeout: 10_000 })
await page.keyboard.press('Escape')
await expect(modal, 'Esc must close the zoom modal').toHaveCount(0, { timeout: 10_000 })
// The preview/edit toggle is mutually exclusive: in preview mode the
// CodeMirror surface must be hidden (regression guard — a stale css copy
// in the page made the editor stay visible under the preview, breaking
// the toggle semantics).
await expect(
sidebar.locator('.cm-editor').first(),
'preview mode must hide the CodeMirror editor (mutually exclusive toggle)',
).toBeHidden()
await assertNoCrash()
// The plugin's own console prefix must never appear in errors, and no
// unhandled rejection may escape the sweep.
const pluginErrors = consoleErrors.filter((text) => /dsh-better-sidebar|Unhandled/.test(text))
expect(pluginErrors, 'plugin-prefixed or unhandled console errors during the sweep').toEqual([])
expect(pageErrors, 'pageerrors during the sweep').toEqual([])
// Final screenshot: the rendered panel with a session is the lane's proof.
await page.screenshot({ path: 'test-results/mount-final.png' })
})
test('desktop shell stamps auto-enable the win32 title-bar compatibility mode', async ({ page }) => {
// The official DSH Desktop shell stamps every render URL with
// dsh-desktop-mode / dsh-desktop-platform (and exposes a preload marker).
// The win32 advanced shell reserves a 32px overlay for the window
// controls where the toggle cluster sits — the sidebar must auto-drop
// below it (body[data-dsh-title-bar-compat]) without any manual pref.
await page.goto(`${BASE_URL}?dsh-desktop-mode=advanced&dsh-desktop-platform=win32`, { waitUntil: 'domcontentloaded' })
await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 90_000 })
await expect(
page.locator('body[data-dsh-title-bar-compat]'),
'win32 advanced shell must auto-enable title-bar compatibility',
).toBeAttached({ timeout: 90_000 })
// The strip variable must be sized to the shell's 32px overlay.
await expect
.poll(() => page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-title-bar-strip')))
.toBe('32px')
})
+291
View File
@@ -0,0 +1,291 @@
/**
* EditorHost (the files window): in merged (in-place) mode a path-less tab
* renders the empty-state hint with the tree dock open, and the header's
* tree toggle persists its flag through ctx.betterSidebar.updateTab
* (meta.treeOpen rides the tab's persisted layout). The editorExplorer pref
* controls FILE-OPEN behavior — in-place rewrites the current tab via
* updateTab, split opens a per-path dedupe tab via openSidebarFile — and in
* split mode a PATH-LESS window becomes the standalone explorer (tree panel
* only, no editor chrome); file tabs keep the full chrome in both modes.
*/
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import { createElement, useEffect } from 'react'
import { createRoot } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import type { Context } from '../src/context-types.ts'
import { EditorHost } from '../src/client/EditorHost.tsx'
import { createBetterSidebarService } from '../src/client/service.ts'
import { allLeaves, createSidebarStore, type SidebarTab } from '../src/client/state.ts'
// The act() environment flag (React 18.2 reads it before flushing effects).
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
/** A store with the seeded editor-home tab (default prefs: separate mode;
* merged-mode scenarios re-enable editorExplorer explicitly). */
function setup(): {
store: ReturnType<typeof createSidebarStore>
ctx: Context
homeTab: () => SidebarTab
} {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
// The openTab path needs a registered editor descriptor (dedupe by path).
service.registerTab({ id: 'editor', title: 'Editor', dedupeKey: (tab) => tab.path, component: () => null })
store.setSession('editor-home-session')
const homeTab = (): SidebarTab =>
allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
.find(tab => tab.type === 'editor' && tab.path === undefined)!
// openSidebarFile reads the session cwd from ctx.sessions.
const sessionsSnapshot = { byId: { 'editor-home-session': { cwd: '/tmp' } }, current: 'editor-home-session' }
const ctx = {
betterSidebar: service,
sessions: { list: { subscribe: () => () => {}, getSnapshot: () => sessionsSnapshot } },
} as unknown as Context
return { store, ctx, homeTab }
}
/** Mount the host for one tab; returns the container and an unmount helper. */
function mountHost(ctx: Context, store: ReturnType<typeof createSidebarStore>, tab: () => SidebarTab): {
container: HTMLDivElement
rerender: () => void
unmount: () => void
} {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
const render = (): void => {
root.render(createElement(EditorHost, {
ctx,
store,
scope: { sessionId: 'editor-home-session' },
tab: tab(),
expanded: [],
onToggleDir: () => {},
onReferenceFile: () => {},
}))
}
act(render)
return {
container,
// The real app re-renders the host with the fresh tab on every store
// change (Sidebar subscribes); mirror that after mutating the store.
rerender: () => { act(render) },
unmount: () => {
act(() => { root.unmount() })
container.remove()
},
}
}
/** Type into the controlled path input (native setter) and press Enter. */
function typeAndCommit(input: HTMLInputElement, value: string): void {
act(() => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, value)
input.dispatchEvent(new Event('input', { bubbles: true }))
})
act(() => {
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
})
}
describe('EditorHost (files window)', () => {
it('a path-less tab renders the empty-state hint with the tree panel open', () => {
const { store, ctx, homeTab } = setup()
store.setPrefs({ ...store.getPrefs(), editorExplorer: true })
const { container, unmount } = mountHost(ctx, store, homeTab)
try {
const html = container.innerHTML
// The empty-state hint renders instead of the viewer loading flow.
expect(html).toContain('Pick a file from the tree panel')
expect(html).not.toContain('Loading…')
// The header carries the path input and the pressed tree toggle; the
// docked panel (search box) is open by default for path-less tabs.
expect(container.querySelector('input')).not.toBeNull()
const toggle = container.querySelector('button[aria-pressed]')
expect(toggle?.getAttribute('aria-pressed')).toBe('true')
// No cwd: the embedded tree renders its no-session placeholder
// instead of touching the network.
expect(html).toContain('Select a conversation')
} finally {
unmount()
}
})
it('the tree toggle persists meta.treeOpen through updateTab', () => {
const { store, ctx, homeTab } = setup()
store.setPrefs({ ...store.getPrefs(), editorExplorer: true })
expect(homeTab().meta).toEqual({ treeOpen: true })
const { container, rerender, unmount } = mountHost(ctx, store, homeTab)
try {
act(() => {
container.querySelector('button[aria-pressed]')!
.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(homeTab().meta).toEqual({ treeOpen: false })
// The store change re-renders the host with the fresh tab (Sidebar's
// subscription in the real app); the second click flips it back.
rerender()
expect(container.querySelector('button[aria-pressed]')?.getAttribute('aria-pressed')).toBe('false')
act(() => {
container.querySelector('button[aria-pressed]')!
.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
expect(homeTab().meta).toEqual({ treeOpen: true })
} finally {
unmount()
}
})
it('in-place mode: the path input Enter switches the CURRENT tab (stable id, meta kept)', () => {
const { store, ctx, homeTab } = setup()
store.setPrefs({ ...store.getPrefs(), editorExplorer: true })
const { container, unmount } = mountHost(ctx, store, homeTab)
try {
const before = homeTab()
typeAndCommit(container.querySelector('input')!, '/tmp/a.ts')
// The same tab id now carries the file (homeTab's path-less finder no
// longer matches — look the tab up by id).
const after = allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
.find(tab => tab.id === before.id)!
expect(after.id).toBe(before.id)
expect(after.path).toBe('/tmp/a.ts')
expect(after.title).toBe('a.ts')
expect(after.meta).toEqual({ treeOpen: true })
// No new tab landed.
expect(allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)).toHaveLength(1)
} finally {
unmount()
}
})
it('split mode: a file tab\'s path input Enter opens a NEW per-path tab; the source tab keeps its path', () => {
const { store, ctx } = setup()
store.setPrefs({ ...store.getPrefs(), editorExplorer: false })
ctx.betterSidebar.openTab({ type: 'editor', title: 'a.ts', path: '/tmp/a.ts', id: 'editor:/tmp/a.ts' })
const fileTab = (): SidebarTab =>
allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
.find(tab => tab.path === '/tmp/a.ts')!
const { container, unmount } = mountHost(ctx, store, fileTab)
try {
typeAndCommit(container.querySelector('input[placeholder^="File path"]')!, '/tmp/b.ts')
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
// home + a.ts + b.ts
expect(tabs).toHaveLength(3)
expect(fileTab().path).toBe('/tmp/a.ts')
const opened = tabs.find(tab => tab.path === '/tmp/b.ts')!
expect(opened.type).toBe('editor')
expect(opened.title).toBe('b.ts')
expect(opened.id).toBe('editor:/tmp/b.ts')
} finally {
unmount()
}
})
it('split mode: the path-less window is the standalone explorer (tree only, no chrome)', () => {
const { store, ctx, homeTab } = setup()
store.setPrefs({ ...store.getPrefs(), editorExplorer: false })
const { container, unmount } = mountHost(ctx, store, homeTab)
try {
// No editor chrome: no path input, no tree toggle, no resize handle.
expect(container.querySelector('input[placeholder^="File path"]')).toBeNull()
expect(container.querySelector('button[aria-pressed]')).toBeNull()
expect(container.querySelector('[role="separator"]')).toBeNull()
// The tree panel fills the whole window — its search box is the only
// input, and (no cwd) the tree shows its no-session placeholder.
expect(container.querySelector('input[placeholder^="Search files"]')).not.toBeNull()
expect(container.innerHTML).toContain('Select a conversation')
} finally {
unmount()
}
})
it('split mode: a file tab keeps the full chrome (path input + tree toggle + dock)', () => {
const { store, ctx } = setup()
store.setPrefs({ ...store.getPrefs(), editorExplorer: false })
ctx.betterSidebar.openTab({
type: 'editor', title: 'a.ts', path: '/tmp/a.ts', id: 'editor:/tmp/a.ts', meta: { treeOpen: true },
})
const fileTab = (): SidebarTab =>
allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
.find(tab => tab.path === '/tmp/a.ts')!
const { container, unmount } = mountHost(ctx, store, fileTab)
try {
expect(container.querySelector('input[placeholder^="File path"]')).not.toBeNull()
expect(container.querySelector('button[aria-pressed]')?.getAttribute('aria-pressed')).toBe('true')
expect(container.querySelector('[role="separator"]')).not.toBeNull()
} finally {
unmount()
}
})
it('dragging the panel edge resizes the dock and persists meta.treeWidth on release', () => {
const { store, ctx, homeTab } = setup()
store.setPrefs({ ...store.getPrefs(), editorExplorer: true })
const { container, unmount } = mountHost(ctx, store, homeTab)
try {
const handle = container.querySelector('[role="separator"]')!
expect(handle).not.toBeNull()
// The dock starts at the default width.
const dock = handle.parentElement!
expect(dock.style.width).toBe('240px')
// Drag the left edge LEFT by 100px → the right-docked panel widens.
// Pointer capture keeps move/up on the handle (jsdom: MouseEvent with
// pointer* type names; setPointerCapture is absent and skipped).
act(() => {
handle.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 300 }))
handle.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 200 }))
})
expect(dock.style.width).toBe('340px')
// Release: the drag state clears and the width persists on the tab.
act(() => { handle.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 200 })) })
expect(homeTab().meta).toEqual({ treeOpen: true, treeWidth: 340 })
} finally {
unmount()
}
})
it('the header hosts the viewer toolbar (mode toggle / dirty dot / save)', () => {
const { store, ctx } = setup()
const service = ctx.betterSidebar
const calls: string[] = []
// A viewer with a hoisted toolbar (the TextEditor contract): register
// commands and report the state once on mount.
service.registerFileViewer({
id: 'test:fake',
exts: ['fake'],
fetchStrategy: 'none',
component: (viewerProps) => {
useEffect(() => {
viewerProps.onToolbarControls?.({
setMode: (next) => { calls.push(`mode:${next}`) },
save: () => { calls.push('save') },
})
viewerProps.onToolbarState?.({ modes: true, mode: 'preview', dirty: true, editable: true, saveState: 'idle' })
return () => { viewerProps.onToolbarControls?.(null) }
}, [])
return null
},
})
service.openTab({ type: 'editor', title: 'x.fake', path: '/tmp/x.fake', id: 'editor:/tmp/x.fake' })
const fileTab = (): SidebarTab =>
allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
.find(tab => tab.path === '/tmp/x.fake')!
const { container, unmount } = mountHost(ctx, store, fileTab)
try {
// Mode toggle + dirty dot + save button sit in the header row.
const header = container.querySelector('input')!.parentElement!
const buttons = [...header.querySelectorAll('button')]
expect(buttons.map(b => b.textContent)).toContain('Preview')
expect(buttons.map(b => b.textContent)).toContain('Edit')
expect(header.querySelector('button[aria-label="Save"]')).not.toBeNull()
expect(header.querySelector('[title="Unsaved"]')).not.toBeNull()
// The header commands reach the viewer's registered controls.
act(() => { buttons.find(b => b.textContent === 'Edit')!.click() })
act(() => { header.querySelector<HTMLButtonElement>('button[aria-label="Save"]')!.click() })
expect(calls).toEqual(['mode:edit', 'save'])
} finally {
unmount()
}
})
})
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { isImageExt } from '../src/client/image-types.ts'
import { extOf, languageKeyForExt } from '../src/client/lang.ts'
import { isPdfExt } from '../src/client/pdf-types.ts'
describe('editor language mapping', () => {
it('derives extensions from paths', () => {
expect(extOf('/a/b/main.tsx')).toBe('tsx')
expect(extOf('README.MD')).toBe('md')
expect(extOf('/a/b/.gitignore')).toBe('gitignore')
expect(extOf('noext')).toBe('')
})
it('maps common extensions to languages and falls back to plain text', () => {
expect(languageKeyForExt('tsx')).toBe('tsx')
expect(languageKeyForExt('js')).toBe('js')
expect(languageKeyForExt('py')).toBe('python')
expect(languageKeyForExt('yaml')).toBe('yaml')
expect(languageKeyForExt('sh')).toBe('shell')
expect(languageKeyForExt('md')).toBe('md')
expect(languageKeyForExt('cs')).toBe('csharp')
expect(languageKeyForExt('kt')).toBe('kotlin')
expect(languageKeyForExt('swift')).toBe('swift')
expect(languageKeyForExt('txt')).toBeNull()
expect(languageKeyForExt('log')).toBeNull()
expect(languageKeyForExt('')).toBeNull()
})
})
describe('pdf preview kind', () => {
it('routes only .pdf to the browser-native preview', () => {
expect(isPdfExt('.pdf')).toBe(true)
expect(isPdfExt('.PDF')).toBe(false)
expect(isPdfExt('.docx')).toBe(false)
expect(isPdfExt('')).toBe(false)
})
})
describe('image preview kind', () => {
it('routes supported image extensions before binary probing', () => {
expect(isImageExt('.png')).toBe(true)
expect(isImageExt('.jpg')).toBe(true)
expect(isImageExt('.svg')).toBe(true)
expect(isImageExt('.avif')).toBe(true)
expect(isImageExt('.pdf')).toBe(false)
expect(isImageExt('')).toBe(false)
})
})
@@ -0,0 +1,7 @@
# Fixture aggregate bundle: mounts dsh-better-sidebar under its own entry id,
# the way real aggregate bundles (e.g. @linxin666/dsh-web-ui-all) do. Used by
# scripts/e2e-aggregate-mount.sh to prove the plugin's own bundle patch backs
# off when another enabled entry already mounts the same package.
- insert:
- id: aggregate-better-sidebar
name: 'dsh-better-sidebar'
+4
View File
@@ -0,0 +1,4 @@
// No-op plugin body. This fixture only carries a patch layer (see
// cordis.patch.yml); no entry ever references this package by name, but the
// main file keeps npm pack's manifest valid.
export function apply() {}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "fixture-aggregate-better-sidebar",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "lib/index.js",
"files": [
"lib",
"cordis.patch.yml"
],
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
}
}
}
+151
View File
@@ -0,0 +1,151 @@
/**
* fs-search: the host's recursive file-name search behind the editor side
* panel's search box. Matches are case-insensitive name substrings, reported
* RELATIVE to the root ('/'-separated); `.git` directories are skipped,
* symlinked directories are never descended (cycle safety), and the
* maxMatches/maxVisited budgets stop a runaway walk with `truncated: true`.
*/
import { describe, expect, it } from 'vitest'
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { searchFiles } from '../src/fs-search.ts'
/**
* Symlink creation needs extra privileges on Windows; the symlink case skips
* there rather than fails (mirror of the fs-tree symlink spec).
*/
const canSymlink = (() => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-search-probe-'))
try {
symlinkSync('target', join(dir, 'link'))
return true
} catch {
return false
} finally {
rmSync(dir, { recursive: true, force: true })
}
})()
/** A scratch tree: nested matches, a .git dir, and unrelated noise. */
function makeFixture(): string {
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-search-'))
mkdirSync(join(dir, 'src'))
mkdirSync(join(dir, 'docs'))
mkdirSync(join(dir, '.git'))
mkdirSync(join(dir, '.git', 'objects'))
writeFileSync(join(dir, 'README.md'), 'readme')
writeFileSync(join(dir, 'src', 'Index.TS'), 'code')
writeFileSync(join(dir, 'src', 'util.ts'), 'code')
writeFileSync(join(dir, 'docs', 'guide.md'), 'doc')
writeFileSync(join(dir, '.git', 'config'), 'git-internal')
writeFileSync(join(dir, '.git', 'objects', 'readme-pack'), 'git-internal')
return dir
}
describe('fs-search', () => {
it('matches name substrings and reports root-relative /-separated paths', async () => {
const dir = makeFixture()
try {
const result = await searchFiles(dir, 'util')
expect(result).toEqual({ matches: ['src/util.ts'], truncated: false })
// A multi-level match list is sorted and relative (never absolute).
const md = await searchFiles(dir, '.md')
expect(md.truncated).toBe(false)
expect(md.matches).toEqual(['README.md', 'docs/guide.md'])
for (const match of md.matches) {
expect(match.startsWith(dir)).toBe(false)
expect(match).not.toContain('\\')
}
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('matches case-insensitively on the entry name', async () => {
const dir = makeFixture()
try {
expect((await searchFiles(dir, 'index.ts')).matches).toEqual(['src/Index.TS'])
expect((await searchFiles(dir, 'INDEX.TS')).matches).toEqual(['src/Index.TS'])
// Directory names match too (the client can hint where matches live).
expect((await searchFiles(dir, 'SRC')).matches).toEqual(['src'])
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('never descends into .git directories', async () => {
const dir = makeFixture()
try {
// 'readme' would hit .git/objects/readme-pack if the walk entered .git.
expect((await searchFiles(dir, 'readme')).matches).toEqual(['README.md'])
expect((await searchFiles(dir, 'config')).matches).toEqual([])
expect((await searchFiles(dir, '.git')).matches).toEqual([])
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('an empty (or whitespace) query matches nothing without walking', async () => {
const dir = makeFixture()
try {
expect(await searchFiles(dir, '')).toEqual({ matches: [], truncated: false })
expect(await searchFiles(dir, ' ')).toEqual({ matches: [], truncated: false })
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it.skipIf(!canSymlink)('does not descend into symlinked directories (cycle safety)', async () => {
const dir = makeFixture()
try {
// A link back to the root would loop forever if descended; a link to
// src would duplicate its matches. Neither must be entered.
symlinkSync(dir, join(dir, 'loop'))
symlinkSync(join(dir, 'src'), join(dir, 'src-link'))
const result = await searchFiles(dir, 'util')
expect(result).toEqual({ matches: ['src/util.ts'], truncated: false })
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('stops with truncated: true when the match budget is exceeded', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-search-cap-'))
try {
for (let index = 0; index < 5; index += 1) {
writeFileSync(join(dir, `match-${index}.txt`), 'x')
}
const result = await searchFiles(dir, 'match', { maxMatches: 2 })
expect(result.truncated).toBe(true)
expect(result.matches.length).toBe(2)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('stops with truncated: true when the visited budget is exceeded', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-search-visited-'))
try {
for (let index = 0; index < 5; index += 1) {
writeFileSync(join(dir, `file-${index}.txt`), 'x')
}
// The walk visits more entries than the budget allows and gives up.
const result = await searchFiles(dir, 'nomatch', { maxVisited: 3 })
expect(result.truncated).toBe(true)
expect(result.matches).toEqual([])
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('an unreadable root yields no matches instead of throwing', async () => {
const dir = makeFixture()
try {
const missing = join(dir, 'does-not-exist')
expect(await searchFiles(missing, 'x')).toEqual({ matches: [], truncated: false })
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
})
+113
View File
@@ -0,0 +1,113 @@
/**
* fs-tree symlink listing: the explorer must show a symlink as what it
* points at (a symlink to a directory expands like a directory) and flag
* links whose target is missing. The host listing keeps the probe cheap:
* only entries that are actually symlinks are stat'ed.
*/
import { describe, expect, it } from 'vitest'
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { listDirectory } from '../src/fs-tree.ts'
/**
* Symlink creation needs extra privileges on Windows; skip the suite there
* rather than fail (mirror of the smoke spec's platform-tolerant fixtures).
*/
const canSymlink = (() => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-symlink-probe-'))
try {
symlinkSync('target', join(dir, 'link'))
return true
} catch {
return false
} finally {
rmSync(dir, { recursive: true, force: true })
}
})()
/** A scratch level with real entries plus three symlinks (dir/file/dangling). */
function makeFixture(): string {
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-symlink-'))
mkdirSync(join(dir, 'real-dir'))
writeFileSync(join(dir, 'real-file.txt'), 'content')
symlinkSync(join(dir, 'real-dir'), join(dir, 'link-to-dir'))
symlinkSync(join(dir, 'real-file.txt'), join(dir, 'link-to-file'))
symlinkSync(join(dir, 'missing-target'), join(dir, 'broken-link'))
return dir
}
describe.skipIf(!canSymlink)('fs-tree symlink listing', () => {
it('reports a symlink to a directory as an expandable directory', async () => {
const dir = makeFixture()
try {
const listing = await listDirectory(dir)
const row = listing.entries.find(entry => entry.name === 'link-to-dir')
expect(row).toMatchObject({ isDir: true, isSymlink: true, broken: false })
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('reports a symlink to a file as a file with a link badge', async () => {
const dir = makeFixture()
try {
const listing = await listDirectory(dir)
const row = listing.entries.find(entry => entry.name === 'link-to-file')
expect(row).toMatchObject({ isDir: false, isSymlink: true, broken: false })
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('flags a dangling symlink as broken', async () => {
const dir = makeFixture()
try {
const listing = await listDirectory(dir)
const row = listing.entries.find(entry => entry.name === 'broken-link')
expect(row).toMatchObject({ isDir: false, isSymlink: true, broken: true })
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('leaves regular entries unmarked and sorts symlinked dirs with the directories', async () => {
const dir = makeFixture()
try {
const listing = await listDirectory(dir)
const realDir = listing.entries.find(entry => entry.name === 'real-dir')
const realFile = listing.entries.find(entry => entry.name === 'real-file.txt')
expect(realDir).toMatchObject({ isDir: true, isSymlink: false, broken: false })
expect(realFile).toMatchObject({ isDir: false, isSymlink: false, broken: false })
// Directory-first ordering counts a symlinked directory as a directory.
const dirs = listing.entries.filter(entry => entry.isDir).map(entry => entry.name)
expect(dirs).toEqual(['link-to-dir', 'real-dir'])
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('classifies every row correctly across a symlink-heavy level (bounded probe)', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-symlink-many-'))
mkdirSync(join(dir, 'real-dir'))
writeFileSync(join(dir, 'real-file.txt'), 'content')
try {
// More links than the probe concurrency cap exercises the worker pool;
// every directory link must still classify as a directory and every
// file link as a file (order is already checked by the sort assertion).
for (let index = 0; index < 48; index += 1) {
symlinkSync(join(dir, 'real-dir'), join(dir, `dir-link-${index}`))
symlinkSync(join(dir, 'real-file.txt'), join(dir, `file-link-${index}`))
}
const listing = await listDirectory(dir)
for (let index = 0; index < 48; index += 1) {
expect(listing.entries.find(entry => entry.name === `dir-link-${index}`))
.toMatchObject({ isDir: true, isSymlink: true, broken: false })
expect(listing.entries.find(entry => entry.name === `file-link-${index}`))
.toMatchObject({ isDir: false, isSymlink: true, broken: false })
}
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
})
+98
View File
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest'
import { resolve } from 'node:path'
import { compareEntries, isWithin, parentOf, requireAbsolute, rootLabel } from '../src/fs-tree.ts'
import { isWin32 } from './platform.ts'
describe('fs-tree', () => {
it('sorts directories first, then names case-insensitively', () => {
const rows = [
{ name: 'b.txt', path: '/x/b.txt', isDir: false, hidden: false, isSymlink: false, broken: false },
{ name: 'A', path: '/x/A', isDir: true, hidden: false, isSymlink: false, broken: false },
{ name: 'a.txt', path: '/x/a.txt', isDir: false, hidden: false, isSymlink: false, broken: false },
{ name: '.hidden', path: '/x/.hidden', isDir: false, hidden: true, isSymlink: false, broken: false },
]
expect(rows.sort(compareEntries).map(row => row.name)).toEqual(['A', '.hidden', 'a.txt', 'b.txt'])
})
it('derives root labels and parents (POSIX-style)', () => {
// POSIX-style inputs behave identically on both platforms (win32 parses '/'
// as a separator), so these assertions are platform-independent.
expect(rootLabel('/Users/me/code')).toBe('code')
expect(rootLabel('/')).toBe('/')
expect(parentOf('/Users/me/code')).toBe('/Users/me')
expect(parentOf('/')).toBeUndefined()
})
it.skipIf(!isWin32)('derives root labels and parents for Windows drives', () => {
expect(rootLabel('C:\\')).toBe('C:\\')
expect(parentOf('C:\\')).toBeUndefined()
expect(rootLabel('C:\\Users\\me')).toBe('me')
expect(parentOf('C:\\Users\\me')).toBe('C:\\Users')
})
it('accepts POSIX absolute paths and rejects relative ones', () => {
// resolve() is platform-native: '/a/b' roots to the current drive on win32.
expect(requireAbsolute('/a/b')).toBe(resolve('/a/b'))
expect(() => requireAbsolute('a/b')).toThrow(/not an absolute path/)
expect(() => requireAbsolute('../a')).toThrow(/not an absolute path/)
})
// Windows-only path semantics — skipped (not silently passing) on POSIX:
// drive letters and UNC shares are absolute on win32, drive-relative 'C:foo'
// is not.
describe.skipIf(!isWin32)('win32 path semantics', () => {
it('accepts drive letters and normalizes their separators', () => {
expect(requireAbsolute('C:/proj')).toBe('C:\\proj')
expect(requireAbsolute('C:\\proj')).toBe('C:\\proj')
// A drive path resolves against its own drive, never a bare root.
expect(resolve('/a/b')).toMatch(/^[A-Za-z]:/)
})
it('accepts UNC network shares in both separator styles', () => {
expect(requireAbsolute('\\\\server\\share\\proj')).toBe('\\\\server\\share\\proj')
expect(requireAbsolute('//server/share/proj')).toBe('\\\\server\\share\\proj')
})
it('rejects drive-relative paths', () => {
expect(() => requireAbsolute('C:proj')).toThrow(/not an absolute path/)
})
})
// POSIX-only path semantics — the reverse branch of the win32 suite: forms
// the host can never access must be refused loudly instead of mangled.
describe.skipIf(isWin32)('POSIX path semantics', () => {
it('rejects Windows drive paths', () => {
expect(() => requireAbsolute('C:/proj')).toThrow(/not an absolute path/)
expect(() => requireAbsolute('C:\\proj')).toThrow(/not an absolute path/)
})
it('rejects backslash UNC paths (not absolute on POSIX)', () => {
expect(() => requireAbsolute('\\\\server\\share\\proj')).toThrow(/not an absolute path/)
})
})
it('isWithin tolerates separators and (on win32) letter case', () => {
expect(isWithin('/work/proj', '/work/proj/src/a.ts')).toBe(true)
expect(isWithin('/work/proj', '/work/proj')).toBe(true)
expect(isWithin('/work/proj', '/work/proj2/a.ts')).toBe(false)
expect(isWithin('/work/proj', '/other/a.ts')).toBe(false)
// Mixed separators normalize on every platform.
expect(isWithin('C:\\Users\\me', 'C:/Users/me/src/a.ts')).toBe(true)
// Case sensitivity follows the platform's filesystem semantics (the
// platform parameter makes both branches assertable on any host).
expect(isWithin('C:\\Users\\Me', 'c:/users/me/file.png', 'win32')).toBe(true)
expect(isWithin('/Users/Me', '/users/me/file.png', 'win32')).toBe(true)
expect(isWithin('/Users/Me', '/users/me/file.png', 'linux')).toBe(false)
expect(isWithin('/Users/Me', '/users/me/file.png', 'darwin')).toBe(false)
// Windows drive-root containment.
expect(isWithin('C:\\', 'C:\\Users\\me\\a.png', 'win32')).toBe(true)
expect(isWithin('c:\\users', 'C:/USERS/me/b.png', 'win32')).toBe(true)
// UNC network-share containment: the '//' share prefix must not defeat
// the prefix test, and a sibling share must stay outside. The platform
// parameter is injected, so these win32-semantics assertions run on
// every host without any platform guard.
expect(isWithin('\\\\server\\share\\proj', '\\\\server\\share\\proj\\src\\a.ts', 'win32')).toBe(true)
expect(isWithin('\\\\server\\share\\proj', '\\\\server\\share\\proj2\\a.ts', 'win32')).toBe(false)
expect(isWithin('\\\\server\\share\\proj', '\\\\other\\share\\a.ts', 'win32')).toBe(false)
})
})
+136
View File
@@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest'
import { parseUnifiedDiff } from '../src/client/DiffView.tsx'
import { parseLogLines, parsePorcelainZ } from '../src/git.ts'
describe('git parsing', () => {
it('parses porcelain -z entries including renames', () => {
const output = ['M src/a.ts', ' M src/b.ts', '?? src/c.ts', 'R src/new.ts', 'src/old.ts', ''].join('\0')
const entries = parsePorcelainZ(output)
expect(entries).toEqual([
{ path: 'src/a.ts', xy: 'M ' },
{ path: 'src/b.ts', xy: ' M' },
{ path: 'src/c.ts', xy: '??' },
{ path: 'src/new.ts', xy: 'R ' },
])
})
it('parses log rows with unit separators (full hash + refs)', () => {
const rows = parseLogLines(
'abc1234\x1fFirst subject\x1fAlice\x1f2024-01-01 10:00:00 +0800\x1fabc1234def5678abc1234def5678abc1234def5678\x1fHEAD -> main, origin/main\n'
+ 'def5678\x1fSecond subject\x1fBob\x1f2024-01-02 10:00:00 +0800\x1fdef5678abc1234def5678abc1234def5678abc1234\x1f\n',
)
expect(rows).toEqual([
{
hash: 'abc1234',
subject: 'First subject',
author: 'Alice',
date: '2024-01-01 10:00:00 +0800',
hashFull: 'abc1234def5678abc1234def5678abc1234def5678',
refs: 'HEAD -> main, origin/main',
},
{
hash: 'def5678',
subject: 'Second subject',
author: 'Bob',
date: '2024-01-02 10:00:00 +0800',
hashFull: 'def5678abc1234def5678abc1234def5678abc1234',
refs: '',
},
])
})
it('parses a multi-file unified diff with aligned line numbers', () => {
const diff = [
'diff --git a/src/a.ts b/src/a.ts',
'index 1234567..89abcde 100644',
'--- a/src/a.ts',
'+++ b/src/a.ts',
'@@ -1,4 +1,5 @@ section with @@ inside',
' line1',
'-line2',
'+line2b',
' context',
'+trailing',
'diff --git a/README.md b/README.md',
'new file mode 100644',
'index 0000000..1234567',
'--- /dev/null',
'+++ b/README.md',
'@@ -0,0 +1,2 @@',
'+hello',
'+world',
'',
].join('\n')
const parsed = parseUnifiedDiff(diff)
expect(parsed.files).toHaveLength(2)
const first = parsed.files[0]!
expect(first.oldPath).toBe('a/src/a.ts')
expect(first.newPath).toBe('b/src/a.ts')
expect(first.binary).toBe(false)
expect(first.hunks).toHaveLength(1)
expect(first.hunks[0]!.oldStart).toBe(1)
expect(first.hunks[0]!.newStart).toBe(1)
expect(first.hunks[0]!.header).toBe(' section with @@ inside')
expect(first.hunks[0]!.lines).toEqual([
{ kind: 'ctx', text: 'line1', oldNum: 1, newNum: 1 },
{ kind: 'del', text: 'line2', oldNum: 2, newNum: null },
{ kind: 'add', text: 'line2b', oldNum: null, newNum: 2 },
{ kind: 'ctx', text: 'context', oldNum: 3, newNum: 3 },
{ kind: 'add', text: 'trailing', oldNum: null, newNum: 4 },
])
const second = parsed.files[1]!
expect(second.oldPath).toBe('/dev/null')
expect(second.hunks[0]!.lines[0]).toEqual({ kind: 'add', text: 'hello', oldNum: null, newNum: 1 })
expect(second.hunks[0]!.lines[1]).toEqual({ kind: 'add', text: 'world', oldNum: null, newNum: 2 })
})
it('parses binary, deletion and no-newline markers', () => {
const diff = [
'diff --git a/img.png b/img.png',
'index 111..222 100644',
'Binary files a/img.png and b/img.png differ',
'diff --git a/gone.ts b/gone.ts',
'deleted file mode 100644',
'--- a/gone.ts',
'+++ /dev/null',
'@@ -1,2 +0,0 @@',
'-one',
'-two',
'\\ No newline at end of file',
'',
].join('\n')
const parsed = parseUnifiedDiff(diff)
expect(parsed.files).toHaveLength(2)
expect(parsed.files[0]!.binary).toBe(true)
expect(parsed.files[0]!.hunks).toHaveLength(0)
const gone = parsed.files[1]!
expect(gone.newPath).toBe('/dev/null')
expect(gone.hunks[0]!.lines).toEqual([
{ kind: 'del', text: 'one', oldNum: 1, newNum: null },
{ kind: 'del', text: 'two', oldNum: 2, newNum: null },
{ kind: 'meta', text: ' No newline at end of file', oldNum: null, newNum: null },
])
})
it('keeps mode/rename-only sections hunkless', () => {
const parsed = parseUnifiedDiff([
'diff --git a/run.sh b/run.sh',
'old mode 100644',
'new mode 100755',
'diff --git a/old.ts b/new.ts',
'similarity index 90%',
'rename from old.ts',
'rename to new.ts',
'',
].join('\n'))
expect(parsed.files).toHaveLength(2)
expect(parsed.files[0]!.oldPath).toBe('')
expect(parsed.files[0]!.hunks).toHaveLength(0)
expect(parsed.files[1]!.hunks).toHaveLength(0)
})
it('parses an empty or junk diff into no files', () => {
expect(parseUnifiedDiff('').files).toEqual([])
expect(parseUnifiedDiff('no diff here\n').files).toEqual([])
})
})
+55 -3
View File
@@ -8,6 +8,7 @@
import { describe, expect, it } from 'vitest'
import { resolve } from 'node:path'
import { decodeHtmlUrl, encodeHtmlUrl } from '../src/html-route.ts'
import { isWin32 } from './platform.ts'
describe('encodeHtmlUrl', () => {
it('encodes a POSIX absolute path into route segments', () => {
@@ -20,6 +21,21 @@ describe('encodeHtmlUrl', () => {
.toBe('/sidebar/html/sess-1/C%3A/Users/me/a.html')
})
it('encodes a UNC path with the // marker (backslash and forward-slash forms)', () => {
expect(encodeHtmlUrl('sess-1', '\\\\server\\share\\proj\\a.html'))
.toBe('/sidebar/html/sess-1//server/share/proj/a.html')
expect(encodeHtmlUrl('sess-1', '//server/share/proj/a.html'))
.toBe('/sidebar/html/sess-1//server/share/proj/a.html')
})
it('keeps a POSIX // path marker-encoded (the marker is platform-neutral)', () => {
// The decoder rebuilds '//server/share/...', which node:path resolves to
// '/server/share/...' on POSIX and '\\server\share\...' on win32 — so the
// leading double slash round-trips without any platform signal.
expect(encodeHtmlUrl('s-1', '//server/share/a.html'))
.toBe('/sidebar/html/s-1//server/share/a.html')
})
it('percent-encodes special characters in segments', () => {
expect(encodeHtmlUrl('s-1', '/a b/中文/100%.html'))
.toBe('/sidebar/html/s-1/a%20b/%E4%B8%AD%E6%96%87/100%25.html')
@@ -45,13 +61,37 @@ describe('decodeHtmlUrl', () => {
ok: true,
ref: { sessionId: 'sess-1', path: 'C:/Users/me/a.html' },
})
})
it.skipIf(!isWin32)('decoded drive paths resolve back verbatim on win32', () => {
// The host runs requireAbsolute() over the decoded path: with the old
// '/C:/...' form node's resolve() produced 'C:\C:\Users\...' (drive
// doubled) on Windows and the isWithin(cwd) fence rejected every drive
// path. The slash-free form must resolve back to the original path.
if (process.platform === 'win32') {
expect(resolve('C:/Users/me/a.html')).toBe('C:\\Users\\me\\a.html')
}
expect(resolve('C:/Users/me/a.html')).toBe('C:\\Users\\me\\a.html')
})
it('decodes a UNC round-trip back to the platform-neutral forward-slash form', () => {
const url = encodeHtmlUrl('sess-1', '\\\\server\\share\\proj\\a.html')
expect(decodeHtmlUrl(url)).toEqual({
ok: true,
ref: { sessionId: 'sess-1', path: '//server/share/proj/a.html' },
})
// The host's requireAbsolute resolves that form per-platform:
// '\\server\share\proj\a.html' on win32, '/server/share/proj/a.html' on POSIX.
})
it('round-trips a POSIX // path through the same marker', () => {
const url = encodeHtmlUrl('s-1', '//server/share/a.html')
expect(decodeHtmlUrl(url)).toEqual({
ok: true,
ref: { sessionId: 's-1', path: '//server/share/a.html' },
})
})
it('refuses a marker-only UNC URL and stray double slashes (400)', () => {
expect(decodeHtmlUrl('/sidebar/html/s//').ok).toBe(false)
expect(decodeHtmlUrl('/sidebar/html/s//server//x.html').ok).toBe(false)
})
it('decodes a lowercase-drive Windows path without a leading slash', () => {
@@ -122,4 +162,16 @@ describe('relative asset resolution stays in-route', () => {
expect(new URL('img/x.png', doc).pathname).toBe('/sidebar/html/s/a/b/img/x.png')
expect(new URL('../c.css', doc).pathname).toBe('/sidebar/html/s/a/c.css')
})
it('relative assets of a UNC document stay inside the same route', () => {
// The WHATWG URL preserves the '//' marker during relative resolution,
// so ./style.css lands back on the route with the UNC prefix intact.
const doc = `http://h${encodeHtmlUrl('s', '\\\\server\\share\\proj\\index.html')}`
expect(new URL('./style.css', doc).pathname)
.toBe('/sidebar/html/s//server/share/proj/style.css')
expect(decodeHtmlUrl(new URL('./style.css', doc).pathname)).toEqual({
ok: true,
ref: { sessionId: 's', path: '//server/share/proj/style.css' },
})
})
})
+68 -3
View File
@@ -1,10 +1,12 @@
// @vitest-environment jsdom
/**
* External-link interception policy tests: the pure decision (which http(s)
* external links are taken over into the sidebar browser) and the click
* shape (plain left clicks only — modified clicks always bypass).
* external links are taken over into the sidebar browser), the click shape
* (plain left clicks only — modified clicks always bypass), and the
* registered click capture (the caller's `takeoverEnabled(url)` gate).
*/
import { describe, expect, it } from 'vitest'
import { isPlainLeftClick, shouldInterceptLink } from '../src/client/link-intercept.ts'
import { isPlainLeftClick, registerLinkInterception, shouldInterceptLink } from '../src/client/link-intercept.ts'
const SELF = 'http://127.0.0.1:3080'
@@ -55,3 +57,66 @@ describe('isPlainLeftClick', () => {
expect(isPlainLeftClick({ ...plain, button: 2 })).toBe(false)
})
})
describe('registerLinkInterception', () => {
/** Dispatch one click on an anchor with the given absolute href. */
const clickAnchor = (href: string, init: MouseEventInit = {}): void => {
const anchor = document.createElement('a')
anchor.href = href
document.body.appendChild(anchor)
anchor.dispatchEvent(new MouseEvent('click', {
bubbles: true,
cancelable: true,
button: 0,
...init,
}))
anchor.remove()
}
it('passes the parsed URL to takeoverEnabled and only takes over when it returns true', () => {
const seen: string[] = []
const opened: string[] = []
const dispose = registerLinkInterception({
takeoverEnabled: (url) => {
seen.push(url.protocol)
return url.protocol === 'http:'
},
openInSidebar: (url) => { opened.push(url) },
selfOrigin: SELF,
})
// https → the gate refuses (the sidebar-takeover defaults: https off).
clickAnchor('https://example.com/page')
// http → the gate passes, the click is taken over with the absolute href.
clickAnchor('http://example.com/page')
expect(seen).toEqual(['https:', 'http:'])
expect(opened).toEqual(['http://example.com/page'])
dispose()
})
it('never consults the gate for same-origin or non-http(s) links', () => {
let gateCalls = 0
const dispose = registerLinkInterception({
takeoverEnabled: () => { gateCalls++; return true },
openInSidebar: () => { throw new Error('must not open') },
selfOrigin: SELF,
})
clickAnchor(`${SELF}/settings`)
clickAnchor('mailto:a@b.c')
expect(gateCalls).toBe(0)
dispose()
})
it('bypasses the takeover on modified clicks even when the gate passes', () => {
let gateCalls = 0
const dispose = registerLinkInterception({
takeoverEnabled: () => { gateCalls++; return true },
openInSidebar: () => { throw new Error('must not open') },
selfOrigin: SELF,
})
clickAnchor('http://example.com/', { ctrlKey: true })
clickAnchor('http://example.com/', { metaKey: true })
clickAnchor('http://example.com/', { button: 1 })
expect(gateCalls).toBe(0)
dispose()
})
})
+1 -3
View File
@@ -50,9 +50,7 @@ const CLIENT_REQUIRE_ALLOWED = new Set([
'react-dom/client',
'cordis',
'@deepseek-ai/dsh-client-ui-slots',
'@deepseek-ai/dsh-client-web-react',
'@deepseek-ai/dsh-client-ui-primitives',
'@deepseek-ai/dsh-client-schema-form',
'@deepseek-ai/dsh-client-runtime/client',
])
@@ -65,7 +63,7 @@ function bundleId(file: string): string {
}
/** The lazy chunk bundle names (mirror of src/bundle-route.ts CHUNK_NAMES). */
const CHUNK_FILES = ['terminal', 'editor'].map(name => `lib/client-${name}.js`)
const CHUNK_FILES = ['terminal', 'editor', 'mermaid'].map(name => `lib/client-${name}.js`)
/** The global registry slot a built chunk script assigns (its factory key). */
function chunkSlot(file: string): string {

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