Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9b8f15d9e | ||
|
|
22a5d372be | ||
|
|
f36afc0180 | ||
|
|
38a3d94410 | ||
|
|
71c5413e5c | ||
|
|
e867e27106 | ||
|
|
4148130aac | ||
|
|
924f377fa1 | ||
|
|
d3c0f22345 | ||
|
|
67271e54c8 | ||
|
|
9b4240449c | ||
|
|
ef8d3a9050 | ||
|
|
9a40164d1a | ||
|
|
29b8615bff | ||
|
|
b7aab48deb | ||
|
|
7dbd9b75e2 | ||
|
|
cdda072973 | ||
|
|
646c91cf70 | ||
|
|
55ad7eec8d | ||
|
|
d38a411f6d | ||
|
|
940d0a8928 |
@@ -176,7 +176,7 @@ interface TabDescriptor {
|
||||
* 顺序提交)。
|
||||
* v0.12.0 起增加两个插件自有扩展(详见 §5 声明式设置):
|
||||
* `pluginToggles`(插件自有 key,持久化在 pluginSettings[id],无需宿主 schema 字段)
|
||||
* 与 `render`(自定义设置面板,替代行列表)。
|
||||
* 与 `render`(自定义设置面板,追加渲染在行列表之后,与行列表共存)。
|
||||
*/
|
||||
settings?: {
|
||||
toggles?: readonly {
|
||||
@@ -223,7 +223,8 @@ interface TabDescriptor {
|
||||
}[]
|
||||
multi?: boolean
|
||||
}[]
|
||||
/** 自定义设置面板(v0.12.0+):给出时齿轮弹窗渲染它而非行列表。
|
||||
/** 自定义设置面板(v0.12.0+):给出时**追加**渲染在行列表(toggles /
|
||||
* pluginToggles)之后,与行列表共存;未声明行列表时单独渲染。
|
||||
* props 含 store/service/prefs、本 descriptor 的 pluginSettings blob、
|
||||
* updatePluginSetting(key, value) 与 close()。抛错会被吞掉并显示内联错误。 */
|
||||
render?: (props: {
|
||||
|
||||
@@ -3,9 +3,15 @@
|
||||
<!-- Hero -->
|
||||
<div align="center">
|
||||
<b style="font-size: 1.15em;">一个服务化的侧边栏框架,一套开箱即用的完整工作台</b><br /><br />
|
||||
<a href="https://www.npmjs.com/package/dsh-better-sidebar"><img alt="npm version" src="https://img.shields.io/npm/v/dsh-better-sidebar" /></a>
|
||||
<a href="https://www.npmjs.com/package/dsh-better-sidebar"><img alt="npm downloads" src="https://img.shields.io/npm/dm/dsh-better-sidebar" /></a>
|
||||
<a href="https://github.com/omdsh-dev/DSH-better-sidebar/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/omdsh-dev/DSH-better-sidebar/actions/workflows/ci.yml/badge.svg" /></a>
|
||||
<a href="https://github.com/omdsh-dev/DSH-better-sidebar/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/omdsh-dev/DSH-better-sidebar" /></a>
|
||||
<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 />
|
||||
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本:0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2" src="https://img.shields.io/badge/DSH-0.1.0--rc.8_%C2%B7_0.1.1--rc.1_%C2%B7_0.1.1--rc.2-4d6bfe" /></a>
|
||||
<a href="https://github.com/topics/dsh-better-sidebar"><img alt="插件生态:GitHub topic dsh-better-sidebar" src="https://img.shields.io/badge/%E6%8F%92%E4%BB%B6%E7%94%9F%E6%80%81-topic%20dsh--better--sidebar-4d6bfe" /></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" /> <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>
|
||||
@@ -19,9 +25,22 @@
|
||||
<video src="https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86" muted autoplay loop playsinline controls width="100%"></video>
|
||||
</div>
|
||||
|
||||
## 📑 目录
|
||||
|
||||
- [✨ 功能一览](#-功能一览)
|
||||
- [🚀 安装](#-安装)
|
||||
- [🖼️ 特性巡礼](#-特性巡礼)
|
||||
- [🌐 插件生态](#-插件生态)
|
||||
- [🆕 最近更新](#-最近更新)
|
||||
- [⌨️ 快捷键](#-快捷键)
|
||||
- [🔌 服务化扩展](#-服务化扩展)
|
||||
- [🛠️ 开发与构建](#-开发与构建)
|
||||
- [🔐 安全](#-安全) · [⚠️ 已知限制](#-已知限制) · [🖥️ 平台支持](#-平台支持)
|
||||
- [💬 社区](#-社区) · [🤝 参与贡献](#-参与贡献) · [⭐ Star History](#-star-history) · [🔗 友情链接](#-友情链接)
|
||||
|
||||
## ✨ 功能一览
|
||||
|
||||
- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown(含 Mermaid 图表,strict 安全渲染 + 点击放大)/ HTML / PDF / Office 内联预览
|
||||
- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown(含 Mermaid 图表,strict 安全渲染 + 点击放大)/ HTML / PDF
|
||||
- **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整)
|
||||
- **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具
|
||||
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存 / 提交 / 还原
|
||||
@@ -33,103 +52,34 @@
|
||||
- **⚡ 按需加载**:启动只拉 ~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.15.0
|
||||
|
||||
> ⚠️ **本版验证基线为 DSH 0.1.1-rc.2(@next)**:devDependencies / lockfile / CI 挂载冒烟基线升至 `0.1.1-rc.2`(lockfile 零 rc.8 残留);**peer 下限保持 `^0.1.0-rc.8`**——0.1.1-rc.x 为常规 rc 递进、与 rc.8 双向兼容,**rc.8 用户无需升级 DSH**。`cordis` 保持 `^4.0.0-rc.8`。自 v0.14.0 以来的全部更改:
|
||||
|
||||
**✨ 新功能**
|
||||
|
||||
- 💬 **侧边对话(beta) Tab**([#286](https://github.com/omdsh-dev/DSH-better-sidebar/pull/286)):Codex 风格的侧边线程,**每个对话一个独立 Tab**——子会话继承主会话完整上下文(已完成回合 + 未回答消息 + 进行中回合的 assistant 输出与工具调用,以「interrupted」冻结标记诚实继承);同组合创建(同 preset / provider / model)复用前缀输入缓存;线程对主会话列表不可见、零子代理目录噪音;线程内可持续追问(重启后自动冷恢复);一键「保存为新会话」提升为顶层会话([设计文档](docs/plans/2026-08-20-sidechat-tab-design.md))
|
||||
- 📤 **文件窗口上传**([#239](https://github.com/omdsh-dev/DSH-better-sidebar/pull/239)):头部「上传文件 / 上传文件夹」按钮 + 拖放上传(拖到树区 = 工作区根,目录行 = 进该目录,文件行 = 进其所在目录,对齐 VSCode);上传时全屏模糊进度弹层(文件级进度 + 取消 / Esc);上传中按钮禁用、成功后文件树自动刷新
|
||||
- 🧩 **桌面兼容四选项**([#284](https://github.com/omdsh-dev/DSH-better-sidebar/pull/284)):位置兼容模式改为**主行下拉**——**自动检测**(默认,保守:仅使用标准的 Window Controls Overlay 几何,32/36px 等各壳差异自动跟随、最大化/还原实时更新,网页环境零修改)/ **DSH官方Web**(显式零适配)/ **壳兼容方案**(内置预设,手动启用;只收录 issue/PR 中出现过且 100+ star 的壳,命中环境带「已检测」提示)/ **自定义方案**(自定义 CSS + 下移距离)。旧版本已有兼容配置的用户自动落到自定义方案;交互控件统一退出桌面拖拽区(`no-drag`);底栏推挤锚点复合选择器双保险(`[data-pane]` 与 `:has(> [data-slot])`)
|
||||
- 🎛️ **设置页 UI/UX 现代化**([#300](https://github.com/omdsh-dev/DSH-better-sidebar/pull/300)):侧边卡片二级设置入口改为卡片底部「功能设置」设置条(替代右下角隐形齿轮,可发现性提升);协调双色启用态(brand 激活强调 + success 绿勾选徽标);全部颜色仍为 `--dsw-alias-*` 令牌派生,皮肤体系自动跟随
|
||||
- ➕ **推荐插件目录新增**:`dsh-docs-panel` 全局文档面板([#230](https://github.com/omdsh-dev/DSH-better-sidebar/pull/230))、`dsh-flowglass`([#261](https://github.com/omdsh-dev/DSH-better-sidebar/pull/261))、`dsh-git-forge` 与 `dsh-ssh-tunnel`([#204](https://github.com/omdsh-dev/DSH-better-sidebar/pull/204))、`dsh-turn-review`([#102](https://github.com/omdsh-dev/DSH-better-sidebar/pull/102))
|
||||
|
||||
**🐛 修复**
|
||||
|
||||
- ⚡ **子代理页实时预览批量接口**([#298](https://github.com/omdsh-dev/DSH-better-sidebar/pull/298)):旧实现每个 running 子代理独立轮询 `subagents.history`,host 侧每次触发全量子代理枚举形成 O(N²) 放大、多子代理并发时页面卡顿——改为单个批量接口 `subagents.live`(一次枚举整棵子代理树)+ 客户端单轮询、单在途请求;展示逻辑与文案不变
|
||||
- 🖱️ **拖拽中断 / 快速释放不再回滚**([#249](https://github.com/omdsh-dev/DSH-better-sidebar/pull/249),关闭 [#247](https://github.com/omdsh-dev/DSH-better-sidebar/issues/247) [#248](https://github.com/omdsh-dev/DSH-better-sidebar/issues/248)):中断 / 快速释放提交最后已知位置;HMR 后中心列重定位兜底(修复热更新后底栏空白)
|
||||
- 📐 **推挤变量挂载期持续有效**([#259](https://github.com/omdsh-dev/DSH-better-sidebar/pull/259),修复 [#258](https://github.com/omdsh-dev/DSH-better-sidebar/issues/258)):拖拽松手后底边栏不再闪全宽
|
||||
- 🔧 **适配 DSH 0.1.1-rc.1 / rc.2(@next)**([#297](https://github.com/omdsh-dev/DSH-better-sidebar/pull/297) [#305](https://github.com/omdsh-dev/DSH-better-sidebar/pull/305)):devDependencies / lockfile / CI 验证基线平移;逐包比对所有既有契约(DOM 槽位、CSS 令牌、boot 协议、MarkdownText `codeLabels`、`webServer` 路由注册)零破坏,**无代码逻辑改动**;peer 下限保持 `^0.1.0-rc.8`
|
||||
- 🔒 **上传链路安全加固**([#239](https://github.com/omdsh-dev/DSH-better-sidebar/pull/239)):`relativePath` 空段 / 绝对路径显式拒绝;临时文件唯一命名(并发上传互不干扰、崩溃不阻塞);写流错误监听(磁盘失败不崩溃进程);客户端错误码与服务端统一、413 本地化
|
||||
|
||||
### 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);`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 偶发失败修复
|
||||
> 🔌 **核心理念**:服务优先——内置的 7 tab + 6 viewer 与第三方插件通过同一套 `ctx.betterSidebar` API 注册,能力完全对等;官方不再内置、可由生态提供的功能,交由生态插件实现(已有 **28+ 生态插件**,见下方「🌐 插件生态」)。接入文档见「🔌 服务化扩展」与 [外部插件接入指南](./docs/external-plugin-guide.md)。
|
||||
|
||||
## 🚀 安装
|
||||
|
||||
**前置**:已装好 DSH(`dsh web` 能正常运行),Node.js ≥ 20、pnpm ≥ 10。
|
||||
|
||||
**支持的 DSH 版本**:
|
||||
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本:0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2" src="https://img.shields.io/badge/DSH-0.1.0--rc.8_%C2%B7_0.1.1--rc.1_%C2%B7_0.1.1--rc.2-4d6bfe" /></a>
|
||||
|
||||
```sh
|
||||
dsh plugin --profile web add dsh-better-sidebar@latest
|
||||
dsh plugin --profile web add dsh-better-sidebar@latest # 首次会因 pnpm 11 拦截 node-pty 构建脚本而失败(依赖已写入)
|
||||
cd ~/.dsh/profiles/web && pnpm approve-builds --all # 放行构建脚本(自动重跑安装)
|
||||
dsh plugin --profile web add dsh-better-sidebar@latest # 重跑即成功
|
||||
```
|
||||
|
||||
装完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可看到侧边栏(DSH 对 client 改动热加载,无需重启;仅 host 半更新时需要重启)。
|
||||
|
||||
**方式二:让 DSH 自己装**——把下面这段提示词发给任意一个 DSH 会话:
|
||||
|
||||
```text
|
||||
帮我安装 dsh-better-sidebar 插件(DSH 侧边栏工作台),步骤:
|
||||
1. 执行 dsh plugin --profile web add dsh-better-sidebar@latest(首次会被 pnpm 11 拦截 node-pty 构建脚本而失败,属正常)
|
||||
2. 在 ~/.dsh/profiles/web 下执行 pnpm approve-builds --all(放行构建脚本,会自动重跑安装)
|
||||
3. 再次执行 dsh plugin --profile web add dsh-better-sidebar@latest
|
||||
4. 完成后提醒我硬刷新浏览器(Cmd/Ctrl+Shift+R)
|
||||
遇到报错先查 https://github.com/omdsh-dev/DSH-better-sidebar README 的常见问题表。
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>更新</b></summary>
|
||||
|
||||
@@ -178,7 +128,7 @@ dsh plugin --profile web add dsh-better-sidebar@latest
|
||||
5. 硬刷新浏览器(Cmd/Ctrl+Shift+R)即可看到效果(client 改动无需重启 DSH;host 半改动才需重启)
|
||||
```
|
||||
|
||||
更新:`git pull && pnpm install && pnpm build` → 硬刷新浏览器即可(client 改动热加载生效,无需重启 DSH;host 半改动才需重启)。切回 npm 通道时,把依赖改回 `"dsh-better-sidebar": "^0.13.0"` 再 `pnpm install`。
|
||||
更新:`git pull && pnpm install && pnpm build` → 硬刷新浏览器即可(client 改动热加载生效,无需重启 DSH;host 半改动才需重启)。切回 npm 通道时,把依赖改回 `"dsh-better-sidebar": "^0.15.0"` 再 `pnpm install`。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -199,20 +149,22 @@ dsh registry enable dsh-external/dsh-better-sidebar
|
||||
|
||||
</details>
|
||||
|
||||
## ⌨️ 快捷键
|
||||
## 🖼️ 特性巡礼
|
||||
|
||||
| 操作 | 按键 |
|
||||
> 以下均为真实界面实拍(每行两张,点击可放大)。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 保存编辑 | `Ctrl/Cmd + S` |
|
||||
| Git 提交 | `Ctrl + Enter` |
|
||||
| 关闭 Tab | 鼠标中键 |
|
||||
| 拆分/合并分栏 | 拖 Tab 到分栏边缘 / 中间 |
|
||||
| 引用文件到输入框 | 悬浮行尾 `@文件` 按钮 |
|
||||
| 复制文件路径 | 右键行 → 复制相对/绝对地址 |
|
||||
| **🗂️ 文件工作台:资源管理器**<br/><sub>支持两种格式的资源管理器:内嵌在文件预览中 / 独立显示文件树。懒加载目录树、软链接按目标类型展示(目录软链接可展开、失效链接标红)、全局文件名搜索、上传文件/文件夹与拖放上传、右键菜单(在新 Tab 打开 / 在侧边打开 / 复制路径)、悬浮 `@文件` 一键引用进输入框。</sub><br/><div align="center"><img width="420" alt="文件资源管理器" src="https://github.com/user-attachments/assets/a410bfd2-a8ba-43e6-873e-22417756e94d" /></div> | **📝 Markdown · 图片 · PDF 内联预览**<br/><sub>Markdown 预览支持 **Mermaid 图表**(`securityLevel: 'strict'` 安全渲染 + 二次清洗;点击图表弹窗放大、滚轮缩放、拖拽平移);图片 / PDF 走媒体路由内联展示;Office 三件套由生态插件补齐。</sub><br/><div align="center"><img width="420" alt="Markdown + Mermaid 预览" src="https://github.com/user-attachments/assets/fe0e5182-55bb-45cc-b98b-a2877c2bdd38" /></div> |
|
||||
| **🖥️ CodeMirror 代码编辑器**<br/><div align="center"><img width="420" alt="CodeMirror 代码编辑器" src="https://github.com/user-attachments/assets/b44b488e-568c-4ee0-b96c-e9c906598a77" /></div> | **🖼️ 图片内联预览**<br/><div align="center"><img width="420" alt="图片内联预览" src="https://github.com/user-attachments/assets/f9a58c30-5b7a-48b5-9e22-37d7e071f593" /></div> |
|
||||
| **💻 真实终端**<br/><sub>xterm.js + node-pty 真实 shell(不是模拟器):断线重连 transcript 回放、shell / shellArgs 可配置(设置页或 `cordis.patch.yml`)、可选为模型注入 `terminal_*` 工具(agent 可直接开终端跑命令)。</sub><br/><div align="center"><img width="420" alt="真实终端" src="https://github.com/user-attachments/assets/0dad6ad3-ff3f-4b5a-86d2-f832ce65323e" /></div> | **🌿 Git 面板**<br/><sub>暂存 / 取消暂存 / 提交(`Ctrl+Enter`)/ 还原,历史列表;点击改动文件打开 **VSCode 式 diff tab**(红绿行级对比)。</sub><br/><div align="center"><img width="420" alt="Git 面板" src="https://github.com/user-attachments/assets/e7fc1220-305f-4bca-8583-e77ab4f4fa78" /></div> |
|
||||
| **🌐 内嵌浏览器**<br/><sub>多开网页 tab:后退 / 前进 / 刷新 / 地址栏;内容运行在**不透明源沙箱 iframe**(界面实时显示沙箱状态,可按页面临时解锁);聊天里的外链点击可被接管到侧边栏打开(按协议分流,可配)。</sub><br/><div align="center"><img width="420" alt="内嵌浏览器" src="https://github.com/user-attachments/assets/9bc6b65a-64fc-4942-a685-76e391e55606" /></div> | **🧩 任务页:子代理拓扑 + 后台任务**<br/><sub>子代理树实时拓扑(运行状态、批量实时预览)+ 后台任务清单(退出码 / 实时输出 / 强制终止);新子代理 / 新任务可自动展开侧边栏(可关)。</sub><br/><div align="center"><img width="420" alt="任务页:子代理拓扑" src="https://github.com/user-attachments/assets/dcd8ed2f-59fa-405b-937b-2d250f5034dd" /></div> |
|
||||
| **💬 侧边对话(beta)**<br/><sub>Codex 风格侧边线程:**每个对话一个独立 Tab**;线程继承主会话完整上下文(含进行中回合,以 interrupted 诚实冻结)独立运行,不污染主会话;可持续追问、重启冷恢复;一键「保存为新会话」提升为顶层会话。</sub><br/><div align="center"><img width="420" alt="侧边对话(beta)" src="https://github.com/user-attachments/assets/3a338c36-f5de-4000-95f3-4b1cd04f60fc" /></div> | **🪟 双工作台:右侧栏 + 底部面板 + 分栏**<br/><sub>右侧栏与底部面板可同时展开;拖 Tab 到分栏边缘**拆分**、拖到中间**合并**(可跨面板);面板宽高左缘/上缘拖拽调节;移动端自动合并为全宽抽屉。</sub><br/><div align="center"><img width="420" alt="双工作台(右侧栏 + 底部面板)" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" /></div> |
|
||||
| **⚙️ 声明式设置**<br/><sub>设置页「侧边卡片」分区:每个 tab / 预览器一张小卡片,独立开关(高亮启用态 + 品牌开关滑块);二级设置经卡片底部「功能设置」条弹窗(开关 / 文本 / 数字 / 下拉);插件自有设置持久化在 `pluginSettings`。</sub><br/><div align="center"><img width="420" alt="声明式设置:侧边卡片" src="https://github.com/user-attachments/assets/0800ca64-621e-48da-b7df-aecfddc3ec29" /></div> | **📱 移动端**<br/><sub>窄屏(<768px)自动切换为全宽抽屉:底栏 tab 一次性并入右侧栏,触屏拖拽可调。</sub><br/><div align="center"><img width="360" alt="移动端全宽抽屉" src="https://github.com/user-attachments/assets/a82ba78a-f4cf-4d85-80e8-050a05beb144" /></div> |
|
||||
|
||||
## 🔌 服务化:注册 tab 与文件预览器
|
||||
## 🌐 插件生态
|
||||
|
||||
从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 7 tab + 6 viewer 亦通过同一服务注册):
|
||||
`ctx.betterSidebar` 服务向所有插件开放两个扩展点:**`registerTab`(注册侧边栏页面)** 与 **`registerFileViewer`(注册文件预览器)**。内置的 7 tab + 6 viewer 与第三方插件走同一套 API,能力完全对等。
|
||||
|
||||
```ts
|
||||
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
|
||||
@@ -221,10 +173,222 @@ export function apply(ctx: Context) {
|
||||
ctx.effect(() => ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:db', title: 'Database', component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
|
||||
}))
|
||||
ctx.effect(() => ctx.betterSidebar.registerFileViewer({
|
||||
id: 'my-plugin:csv', exts: ['csv'], fetchStrategy: 'custom',
|
||||
load: async (path, scope) => parseCsv(await fetchText(scope, path)),
|
||||
component: ({ customData }) => <CsvGrid rows={customData} />,
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
v0.12.1 补齐基座能力(完整类型导出、能力探测、状态订阅、tab 角标、生命周期回调、定向打开、插件自有设置等),详见下方接入文档。
|
||||
GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar) 下已有 **28+ 生态插件**(持续增长中):
|
||||
|
||||
<div align="center">
|
||||
<a href="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e"><img width="66%" alt="设置页「添加插件」弹窗:推荐插件目录 + 一键复制安装命令" src="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e" /></a><br />
|
||||
<i>设置页「侧边卡片」内置「添加插件」弹窗:推荐目录 + 一键复制安装命令 + 直达 GitHub topic</i>
|
||||
</div>
|
||||
|
||||
### 📑 Tab 插件(注册侧边栏页面)
|
||||
|
||||
<details>
|
||||
<summary><b>23 个插件(点击展开)</b></summary>
|
||||
|
||||
| 插件 | ⭐ | 简介 |
|
||||
|---|---|---|
|
||||
| [ChenRuoT/dsh-sidebar-qa](https://github.com/ChenRuoT/dsh-sidebar-qa) | <img alt="stars" src="https://img.shields.io/github/stars/ChenRuoT/dsh-sidebar-qa?style=flat&color=4d6bfe" /> | 划选追问侧边页:类 Codex 侧边提问 / Claude Code `/btw` |
|
||||
| [fuhefei/dsh-sentinel](https://github.com/fuhefei/dsh-sentinel) | <img alt="stars" src="https://img.shields.io/github/stars/fuhefei/dsh-sentinel?style=flat&color=4d6bfe" /> | 条件驱动唤醒系统:文件 / 命令 / HTTP / 进程 / Webhook 监视,到点唤醒 agent;dock + 侧栏分支 + 全局仪表盘 |
|
||||
| [jiuge2467/dsh-studio](https://github.com/jiuge2467/dsh-studio) | <img alt="stars" src="https://img.shields.io/github/stars/jiuge2467/dsh-studio?style=flat&color=4d6bfe" /> | 全栈增强工作台:多源 MCP 可视化调试中枢、视觉思考引擎 |
|
||||
| [Iwctwbh/dsh-flowglass](https://github.com/Iwctwbh/dsh-flowglass) | <img alt="stars" src="https://img.shields.io/github/stars/Iwctwbh/dsh-flowglass?style=flat&color=4d6bfe" /> | 流镜 Flowglass:会话流程图实时可视化(消息 / 工具组 / 子代理分支) |
|
||||
| [FeatherHunter/dsh-mattpocock-skills-deck](https://github.com/FeatherHunter/dsh-mattpocock-skills-deck) | <img alt="stars" src="https://img.shields.io/github/stars/FeatherHunter/dsh-mattpocock-skills-deck?style=flat&color=4d6bfe" /> | mattpocock/skills 游戏化任务系统:地图拨迷雾、任务栏推进 |
|
||||
| [GULI-lab/DSH-element-source](https://github.com/GULI-lab/DSH-element-source) | <img alt="stars" src="https://img.shields.io/github/stars/GULI-lab/DSH-element-source?style=flat&color=4d6bfe" /> | 点击页面任意 UI 元素直达 Vue / React / Svelte / Angular 源码并送入会话 |
|
||||
| [Lzh3070/dsh-file-review-tab](https://github.com/Lzh3070/dsh-file-review-tab) | <img alt="stars" src="https://img.shields.io/github/stars/Lzh3070/dsh-file-review-tab?style=flat&color=4d6bfe" /> | 文件改动审查页:行级红绿 diff + 撤销 + chat 行深链 |
|
||||
| [yq04/dsh-git-remotes](https://github.com/yq04/dsh-git-remotes) | <img alt="stars" src="https://img.shields.io/github/stars/yq04/dsh-git-remotes?style=flat&color=4d6bfe" /> | Git 远程页:分支 / 上游 / ahead-behind,fetch 可 prune、ff-only pull、确认后 push |
|
||||
| [ztyhehe/dsh-better-sidebar-svn](https://github.com/ztyhehe/dsh-better-sidebar-svn) | <img alt="stars" src="https://img.shields.io/github/stars/ztyhehe/dsh-better-sidebar-svn?style=flat&color=4d6bfe" /> | SVN 源码管理页:status / diff / log / commit / update / revert / 冲突解决,与内置 Git 面板对称 |
|
||||
| [Melody-max114/dsh-excel-panel](https://github.com/Melody-max114/dsh-excel-panel) | <img alt="stars" src="https://img.shields.io/github/stars/Melody-max114/dsh-excel-panel?style=flat&color=4d6bfe" /> | Excel 编辑页:xlsx 预览 / 编辑、公式实时计算、合并单元格、保存回原文件 |
|
||||
| [v587d/dsh-anysearch-refs](https://github.com/v587d/dsh-anysearch-refs) | <img alt="stars" src="https://img.shields.io/github/stars/v587d/dsh-anysearch-refs?style=flat&color=4d6bfe" /> | AnySearch 搜索结果引用卡片:搜索词、来源摘要、关键词高亮 |
|
||||
| [mlosun/dsh-docs-panel](https://github.com/mlosun/dsh-docs-panel) | <img alt="stars" src="https://img.shields.io/github/stars/mlosun/dsh-docs-panel?style=flat&color=4d6bfe" /> | 全局文档面板:随身 Markdown 笔记,任何工作区随时可读 |
|
||||
| [lnyuqian/dsh-skill-sidebar](https://github.com/lnyuqian/dsh-skill-sidebar) | <img alt="stars" src="https://img.shields.io/github/stars/lnyuqian/dsh-skill-sidebar?style=flat&color=4d6bfe" /> | 技能面板:扫描本机技能目录,4-6 字功能短语 + 一键复制调用 + 置顶 |
|
||||
| [g-yixuan/dsh-sidechat](https://github.com/g-yixuan/dsh-sidechat) | <img alt="stars" src="https://img.shields.io/github/stars/g-yixuan/dsh-sidechat?style=flat&color=4d6bfe" /> | Codex 风格侧边对话 + 划选引用注释(轻量消费插件) |
|
||||
| [thirsty5034/dsh-ssh-tunnel](https://github.com/thirsty5034/dsh-ssh-tunnel) | <img alt="stars" src="https://img.shields.io/github/stars/thirsty5034/dsh-ssh-tunnel?style=flat&color=4d6bfe" /> | 多主机 SSH 隧道 + SSH 管理器页 |
|
||||
| [thirsty5034/dsh-git-forge](https://github.com/thirsty5034/dsh-git-forge) | <img alt="stars" src="https://img.shields.io/github/stars/thirsty5034/dsh-git-forge?style=flat&color=4d6bfe" /> | GitHub / Gitea 账号、项目授权与推送策略 |
|
||||
| [YesSanSan/dsh-conversation-outline](https://github.com/YesSanSan/dsh-conversation-outline) | <img alt="stars" src="https://img.shields.io/github/stars/YesSanSan/dsh-conversation-outline?style=flat&color=4d6bfe" /> | 对话大纲页:按轮次结构化展示、一键跳转、LLM 一句话标题 |
|
||||
| [Wulabalabo/dsh-sidebar-Explorer-Plus](https://github.com/Wulabalabo/dsh-sidebar-Explorer-Plus) | <img alt="stars" src="https://img.shields.io/github/stars/Wulabalabo/dsh-sidebar-Explorer-Plus?style=flat&color=4d6bfe" /> | 文件管理页:上传 / 移动 / 删除 / 重命名 / 新建文件夹(补全写操作) |
|
||||
| [yq04/dsh-turn-review](https://github.com/yq04/dsh-turn-review) | <img alt="stars" src="https://img.shields.io/github/stars/yq04/dsh-turn-review?style=flat&color=4d6bfe" /> | 本轮审查:逐回合审查 agent 改动 |
|
||||
| [Ghz114514/dsh-refpics](https://github.com/Ghz114514/dsh-refpics) | <img alt="stars" src="https://img.shields.io/github/stars/Ghz114514/dsh-refpics?style=flat&color=4d6bfe" /> | Pinterest 风格参考图搜索:瀑布流、侧栏画板、下载与 Eagle 收藏 |
|
||||
| [yzlin499/dsh-yzlin499-easy-plugins](https://github.com/yzlin499/dsh-yzlin499-easy-plugins) | <img alt="stars" src="https://img.shields.io/github/stars/yzlin499/dsh-yzlin499-easy-plugins?style=flat&color=4d6bfe" /> | 实用小工具集(毛坯房 DSH 友好) |
|
||||
| [dong-victor/dsh-better-sidebar-starter](https://github.com/dong-victor/dsh-better-sidebar-starter) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-better-sidebar-starter?style=flat&color=4d6bfe" /> | 运行配置页:IDEA 式 Run/Debug 配置(npm / springboot / python / custom)——一键启动、历史保存、WebSocket 实时日志(ANSI 彩色)、多实例并行、进程树跨平台杀死 |
|
||||
| [baosfeng/my-dsh-plugins](https://github.com/baosfeng/my-dsh-plugins) | <img alt="stars" src="https://img.shields.io/github/stars/baosfeng/my-dsh-plugins?style=flat&color=4d6bfe" /> | 个人多插件合集(`dsh-file-activity`):侧边栏文件活动页——记录文件读取 / 新增 / 修改历史与统计,按文件夹平铺,点击用原生预览打开 |
|
||||
|
||||
</details>
|
||||
|
||||
### 🖼️ 预览插件(注册文件预览器)
|
||||
|
||||
<details>
|
||||
<summary><b>3 个插件(点击展开)</b></summary>
|
||||
|
||||
| 插件 | ⭐ | 简介 |
|
||||
|---|---|---|
|
||||
| [HuanLinOTO/dsh-plugin-better-sidebar-plugin-office](https://github.com/HuanLinOTO/dsh-plugin-better-sidebar-plugin-office) | <img alt="stars" src="https://img.shields.io/github/stars/HuanLinOTO/dsh-plugin-better-sidebar-plugin-office?style=flat&color=4d6bfe" /> | Office 三件套预览(.docx / .xlsx / .pptx),独立 bundle 瘦身主体(官方推荐目录收录) |
|
||||
| [zemul/dsh-video-preview](https://github.com/zemul/dsh-video-preview) | <img alt="stars" src="https://img.shields.io/github/stars/zemul/dsh-video-preview?style=flat&color=4d6bfe" /> | 视频内联预览:.mp4 / .webm / .mov / .mkv / .avi,自带 /video 路由支持 HTTP Range 拖进度条 |
|
||||
| [dong-victor/dsh-better-sidebar-jupyter](https://github.com/dong-victor/dsh-better-sidebar-jupyter) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-better-sidebar-jupyter?style=flat&color=4d6bfe" /> | `.ipynb` 可运行 Notebook 视图:懒启动 Python kernel、流式输出、保存回写 |
|
||||
|
||||
</details>
|
||||
|
||||
### 🧰 增强与工具
|
||||
|
||||
<details>
|
||||
<summary><b>2 个插件(点击展开)</b></summary>
|
||||
|
||||
| 插件 | ⭐ | 简介 |
|
||||
|---|---|---|
|
||||
| [dong-victor/dsh-better-sidebar-terminal-plus](https://github.com/dong-victor/dsh-better-sidebar-terminal-plus) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-better-sidebar-terminal-plus?style=flat&color=4d6bfe" /> | 终端增强:内嵌 Nerd Font 图标字体、修复 xterm 图标渲染、稳定终端 cwd |
|
||||
| [Max-Null/dsh-sidebar-preview-select](https://github.com/Max-Null/dsh-sidebar-preview-select) | <img alt="stars" src="https://img.shields.io/github/stars/Max-Null/dsh-sidebar-preview-select?style=flat&color=4d6bfe" /> | 预览划选增强:侧边栏预览里划选文本 → 浮动「发送到会话」 |
|
||||
|
||||
</details>
|
||||
|
||||
> 📣 **上架你的插件**:给仓库打上 `dsh-better-sidebar` topic 即出现在 [topic 页](https://github.com/topics/dsh-better-sidebar);再向 [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts) / [`src/client/plugins-viewers.ts`](./src/client/plugins-viewers.ts) 提一条 `PluginEntry` PR,即可进入设置页内置推荐目录(数据完整性由 `tests/plugin-list.spec.ts` 守护)。
|
||||
|
||||
## 🆕 最近更新
|
||||
|
||||
**支持的 DSH 版本**:<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本:0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2" src="https://img.shields.io/badge/DSH-0.1.0--rc.8_%C2%B7_0.1.1--rc.1_%C2%B7_0.1.1--rc.2-4d6bfe" /></a> · 完整发布历史见 [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases)
|
||||
|
||||
### v0.15.2
|
||||
|
||||
自 v0.15.1 以来的全部更改:
|
||||
|
||||
**✨ 新功能**
|
||||
|
||||
- 🗂️ **文件树「在应用中打开」子菜单**([#334](https://github.com/omdsh-dev/DSH-better-sidebar/pull/334)):文件树右键菜单新增「在应用中打开 >」子菜单——内置打开方式(资源管理器显示/选中、VS Code、Cursor、Zed),每行右侧图钉可固定为右键菜单顶层直达项(再点取消);配置可选 SSH host 后 VSCode 系条目改用 `vscode-remote/ssh-remote+<host>/<path>` 协议打开,本地专用条目自动隐藏;支持自定义编辑器(名称 + URL 模板 `{path}` + 是否 VSCode 系,配置入口在 Files 卡片齿轮弹窗)。打开动作经新宿主路由 `POST /sidebar/api/open.external`(argv 数组 spawn,无 shell 注入)([设计文档](docs/plans/2026-08-22-open-with-menu-design.md))
|
||||
- 📑 **Tab 右键菜单**([#331](https://github.com/omdsh-dev/DSH-better-sidebar/pull/331)):页签右键提供「关闭 / 关闭其他页签 / 关闭左侧页签 / 关闭右侧页签」,作用范围为当前 pane(标签组),无可关对象时置灰;仅打开菜单、不切换激活页签;批量关闭逐条走既有 `onClose` 路径,生命周期完整
|
||||
- 📄 **Diff 文件默认折叠**([#270](https://github.com/omdsh-dev/DSH-better-sidebar/pull/270)):改动文件头部改为可访问的展开/折叠控件;识别出的源文件默认展开,测试 / 文档 / 生成文件 / lockfile 与未知类型默认折叠;保留现有 500 行上限
|
||||
- 📖 **README 更新**:特性巡礼改为表格展示(每行两张图,节省空间);社区补全微信群 / QQ 群二维码([#325](https://github.com/omdsh-dev/DSH-better-sidebar/pull/325),QQ 群 577011007)
|
||||
|
||||
**🐛 修复**
|
||||
|
||||
- 🪟 **空分栏清理**([#268](https://github.com/omdsh-dev/DSH-better-sidebar/pull/268)):持久化的 split pane 在临时 diff tab 被清理后遗留全尺寸空分栏——`sanitizeState` 现在同时修剪空的 split leaf,并修复修剪后的失效激活 pane 指针;整个工作台为空时保留唯一空 pane
|
||||
- 🖥️ **Windows 下隐藏 Git 子进程窗口**([#301](https://github.com/omdsh-dev/DSH-better-sidebar/pull/301),关闭 [#124](https://github.com/omdsh-dev/DSH-better-sidebar/issues/124)):`runGit()` 统一加 `windowsHide: true`,仓库状态轮询与操作不再闪现控制台窗口(其他平台行为不变)
|
||||
- 📁 **未跟踪文件夹内文件差异**([#242](https://github.com/omdsh-dev/DSH-better-sidebar/pull/242)):`git status` 从 `--untracked-files=normal` 切换为 `--untracked-files=all`——新文件夹内每个文件独立成行、可正常加载差异(修正 `fs.read` 报 "is a directory",与 VSCode 默认行为一致)
|
||||
- ⚡ **开关/拖拽每帧 React 重渲染消除**(关闭 [#315](https://github.com/omdsh-dev/DSH-better-sidebar/issues/315)):centerRect 改 ref + 底栏 DOM 直写(零 React 渲染);TabContent memo(显式比较器);新增 frame-batcher 对 Divider/dock 拖拽按帧合并;拖拽期跳过无意义 locate。4x CPU 节流 A/B:开关 >17ms 帧 collapse 19→6 / expand 24→4~6,p95 21ms→15ms;拖拽不变(非回归)
|
||||
|
||||
<details>
|
||||
<summary><b>历史版本(v0.12.0 – v0.15.1)</b></summary>
|
||||
### v0.15.1
|
||||
|
||||
自 v0.15.0 以来的全部更改:
|
||||
|
||||
**✨ 新功能**
|
||||
|
||||
- 💬 **侧边对话 Codex 风格转录重构**([#314](https://github.com/omdsh-dev/DSH-better-sidebar/pull/314)):转录改为**折叠行**——工具调用 / 思考 / 上下文注入统一为安静的单行 chrome(chevron + 标签 + 单行参数摘要,展开为 hairline 缩进正文,无卡片无填充),流式标签与创建 shimmer(shimmer = 生成中)、失败工具 danger、`prefers-reduced-motion` 停帧;**首条问题不再被边界提示吞掉**——上下文注入与首问拆分交付(边界 + 快照经 `agent.inject` 排队、问题唤醒驱动),转录把注入映射为可折叠注入行、真实用户消息(**含首问**)渲染为用户气泡,旧线程的首问同样拆分为独立气泡
|
||||
- 📖 **README 重写**:功能导览(逐特性实机截图)、用户视角 DSH 兼容徽章、简化安装流程(`add` → `approve-builds` → `add`、node-pty 安全构建、粘贴到 DSH 安装提示)、插件生态 28+ 与分类折叠展示
|
||||
|
||||
**🐛 修复**
|
||||
|
||||
- 🖥️ **终端跨会话切换保活**([#323](https://github.com/omdsh-dev/DSH-better-sidebar/pull/323)):切到其他会话不再被当作瞬时掉线——客户端卸载时发送 `park` 控制帧,主机跳过 30s 重连宽限倒计时;切回会话(`open()` 取消 parked)或显式关闭恢复正常生命周期;agent 终端保持无限期存活
|
||||
- 📂 **文件树上传遮罩不再拦截 Tab 拖拽**([#317](https://github.com/omdsh-dev/DSH-better-sidebar/pull/317)):拖拽 Tab(重排 / 跨 pane split)经过资源管理器时不再弹上传遮罩、不吞事件——统一按 `dataTransfer.types` 含 `Files` 门控(与面板宿主 shield 一致),Tab 正常落下;OS 文件拖拽行为不变
|
||||
- 💬 **子代理自动展开去抖**([#314](https://github.com/omdsh-dev/DSH-better-sidebar/pull/314)):Side Chat 线程创建不再误弹任务页——0→N 触发 500ms 重臂并对实时快照按原基线重评估,标题过滤器识别线程后才放行;真实子代理依然自动展开
|
||||
|
||||
### v0.15.0
|
||||
|
||||
自 v0.14.0 以来的全部更改:
|
||||
|
||||
**✨ 新功能**
|
||||
|
||||
- 💬 **侧边对话(beta) Tab**([#286](https://github.com/omdsh-dev/DSH-better-sidebar/pull/286)):Codex 风格的侧边线程,**每个对话一个独立 Tab**——子会话继承主会话完整上下文(已完成回合 + 未回答消息 + 进行中回合的 assistant 输出与工具调用,以「interrupted」冻结标记诚实继承);同组合创建(同 preset / provider / model)复用前缀输入缓存;线程对主会话列表不可见、零子代理目录噪音;线程内可持续追问(重启后自动冷恢复);一键「保存为新会话」提升为顶层会话([设计文档](docs/plans/2026-08-20-sidechat-tab-design.md))
|
||||
- 📤 **文件窗口上传**([#239](https://github.com/omdsh-dev/DSH-better-sidebar/pull/239)):头部「上传文件 / 上传文件夹」按钮 + 拖放上传(拖到树区 = 工作区根,目录行 = 进该目录,文件行 = 进其所在目录,对齐 VSCode);上传时全屏模糊进度弹层(文件级进度 + 取消 / Esc);上传中按钮禁用、成功后文件树自动刷新
|
||||
- 🧩 **桌面兼容四选项**([#284](https://github.com/omdsh-dev/DSH-better-sidebar/pull/284)):位置兼容模式改为**主行下拉**——**自动检测**(默认,保守:仅使用标准的 Window Controls Overlay 几何,32/36px 等各壳差异自动跟随、最大化/还原实时更新,网页环境零修改)/ **DSH官方Web**(显式零适配)/ **壳兼容方案**(内置预设,手动启用;只收录 issue/PR 中出现过且 100+ star 的壳,命中环境带「已检测」提示)/ **自定义方案**(自定义 CSS + 下移距离)。旧版本已有兼容配置的用户自动落到自定义方案;交互控件统一退出桌面拖拽区(`no-drag`);底栏推挤锚点复合选择器双保险(`[data-pane]` 与 `:has(> [data-slot])`)
|
||||
- 🎛️ **设置页 UI/UX 现代化**([#300](https://github.com/omdsh-dev/DSH-better-sidebar/pull/300)):侧边卡片二级设置入口改为卡片底部「功能设置」设置条(替代右下角隐形齿轮,可发现性提升);协调双色启用态(brand 激活强调 + success 绿勾选徽标);全部颜色仍为 `--dsw-alias-*` 令牌派生,皮肤体系自动跟随
|
||||
- ➕ **推荐插件目录新增**:`dsh-docs-panel` 全局文档面板([#230](https://github.com/omdsh-dev/DSH-better-sidebar/pull/230))、`dsh-flowglass`([#261](https://github.com/omdsh-dev/DSH-better-sidebar/pull/261))、`dsh-git-forge` 与 `dsh-ssh-tunnel`([#204](https://github.com/omdsh-dev/DSH-better-sidebar/pull/204))、`dsh-turn-review`([#102](https://github.com/omdsh-dev/DSH-better-sidebar/pull/102))
|
||||
|
||||
**🐛 修复**
|
||||
|
||||
- ⚡ **子代理页实时预览批量接口**([#298](https://github.com/omdsh-dev/DSH-better-sidebar/pull/298)):旧实现每个 running 子代理独立轮询 `subagents.history`,host 侧每次触发全量子代理枚举形成 O(N²) 放大、多子代理并发时页面卡顿——改为单个批量接口 `subagents.live`(一次枚举整棵子代理树)+ 客户端单轮询、单在途请求;展示逻辑与文案不变
|
||||
- 🖱️ **拖拽中断 / 快速释放不再回滚**([#249](https://github.com/omdsh-dev/DSH-better-sidebar/pull/249),关闭 [#247](https://github.com/omdsh-dev/DSH-better-sidebar/issues/247) [#248](https://github.com/omdsh-dev/DSH-better-sidebar/issues/248)):中断 / 快速释放提交最后已知位置;HMR 后中心列重定位兜底(修复热更新后底栏空白)
|
||||
- 📐 **推挤变量挂载期持续有效**([#259](https://github.com/omdsh-dev/DSH-better-sidebar/pull/259),修复 [#258](https://github.com/omdsh-dev/DSH-better-sidebar/issues/258)):拖拽松手后底边栏不再闪全宽
|
||||
- 🔧 **适配 DSH 0.1.1-rc.1 / rc.2(@next)**([#297](https://github.com/omdsh-dev/DSH-better-sidebar/pull/297) [#305](https://github.com/omdsh-dev/DSH-better-sidebar/pull/305)):无代码逻辑改动
|
||||
- 🔒 **上传链路安全加固**([#239](https://github.com/omdsh-dev/DSH-better-sidebar/pull/239)):`relativePath` 空段 / 绝对路径显式拒绝;临时文件唯一命名(并发上传互不干扰、崩溃不阻塞);写流错误监听(磁盘失败不崩溃进程);客户端错误码与服务端统一、413 本地化
|
||||
|
||||
### v0.14.0
|
||||
|
||||
> ⚠️ 本版起需要 DSH ≥ 0.1.0-rc.8。自 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);`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` 分组渲染,列表独立滚动(弹窗不再随条目数无限增长)
|
||||
|
||||
**🐛 修复**
|
||||
|
||||
- 🧩 **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` 崩溃);独立安装行为不变
|
||||
- 🔧 **适配 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)):修复 DSH 主框架升至 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 偶发失败修复
|
||||
|
||||
</details>
|
||||
|
||||
## 💬 社区
|
||||
|
||||
推荐添加QQ群(577011007)
|
||||
|
||||
<div align="center">
|
||||
<img width="220" alt="微信群二维码" src="https://github.com/user-attachments/assets/39caafc7-9629-4b13-bb2b-eac17eab5b6a" />
|
||||
<img width="220" alt="QQ群二维码" src="https://github.com/user-attachments/assets/9be34629-26ef-4537-aad4-1393c147f81c" />
|
||||
</div>
|
||||
|
||||
## ⌨️ 快捷键
|
||||
|
||||
| 操作 | 按键 |
|
||||
|---|---|
|
||||
| 保存编辑 | `Ctrl/Cmd + S` |
|
||||
| Git 提交 | `Ctrl + Enter` |
|
||||
| 关闭 Tab | 鼠标中键 |
|
||||
| Tab 右键菜单 | 关闭 / 关闭其他页签 / 关闭左侧页签 / 关闭右侧页签(当前标签组) |
|
||||
| 拆分/合并分栏 | 拖 Tab 到分栏边缘 / 中间 |
|
||||
| 引用文件到输入框 | 悬浮行尾 `@文件` 按钮 |
|
||||
| 复制文件路径 | 右键行 → 复制相对/绝对地址 |
|
||||
|
||||
## 🔌 服务化扩展
|
||||
|
||||
从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 7 tab + 6 viewer 亦通过同一服务注册)。v0.12.1 补齐基座能力(完整类型导出、能力探测、状态订阅、tab 角标、生命周期回调、定向打开、插件自有设置等)。
|
||||
|
||||
完整接入文档:
|
||||
- **[`AGENTS.md`](./AGENTS.md)**——仓库内维护的接入文档(全字段、匹配算法、HMR 陷阱、声明式设置、版本探测);
|
||||
@@ -267,9 +431,39 @@ pnpm watch # tsdown --watch
|
||||
|
||||
Windows / Linux / macOS 三平台适配(macOS 日常验证;其余经单元测试覆盖);`node-pty` 优先预编译二进制,失败需编译工具链(Windows VS Build Tools / Linux make+g+++python3 / macOS Xcode CLT)。
|
||||
|
||||
## 🤝 参与贡献
|
||||
|
||||
- **代码改动走 PR**:`feat/*` / `fix/*` 分支开发 → `gh pr create`;纯文档改动可直接推 main
|
||||
- **收录生态插件**:给仓库打 `dsh-better-sidebar` topic + 向 [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts) / [`plugins-viewers.ts`](./src/client/plugins-viewers.ts) 提 PR
|
||||
- **提交前自检**:`pnpm typecheck && pnpm build && pnpm test`(CI 另有 npm 打包 → 真实挂载 → 无头渲染门禁 `pnpm test:mount`)
|
||||
- 仓库工作规范见 [`AGENTS.md`](./AGENTS.md)(含仓库硬约束与 CI 说明)
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
<a href="https://star-history.com/#omdsh-dev/DSH-better-sidebar&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=omdsh-dev/DSH-better-sidebar&type=Date&theme=dark" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=omdsh-dev/DSH-better-sidebar&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## 👥 贡献者
|
||||
|
||||
感谢每一位贡献者:
|
||||
|
||||
<a href="https://github.com/omdsh-dev/DSH-better-sidebar/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=omdsh-dev/DSH-better-sidebar" alt="贡献者" />
|
||||
</a>
|
||||
|
||||
## 🔗 友情链接
|
||||
|
||||
- [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)
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<sub>MIT License · Built for the <a href="https://github.com/deepseek-ai/deepseek-harness">DeepSeek Harness</a> ecosystem · 在 <a href="https://github.com/topics/dsh-better-sidebar">topic dsh-better-sidebar</a> 发现更多生态插件</sub>
|
||||
</div>
|
||||
|
||||
+306
-133
@@ -3,9 +3,15 @@
|
||||
<!-- Hero -->
|
||||
<div align="center">
|
||||
<b style="font-size: 1.15em;">A service-oriented sidebar framework, and a complete workbench out of the box</b><br /><br />
|
||||
<a href="https://www.npmjs.com/package/dsh-better-sidebar"><img alt="npm version" src="https://img.shields.io/npm/v/dsh-better-sidebar" /></a>
|
||||
<a href="https://www.npmjs.com/package/dsh-better-sidebar"><img alt="npm downloads" src="https://img.shields.io/npm/dm/dsh-better-sidebar" /></a>
|
||||
<a href="https://github.com/omdsh-dev/DSH-better-sidebar/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/omdsh-dev/DSH-better-sidebar/actions/workflows/ci.yml/badge.svg" /></a>
|
||||
<a href="https://github.com/omdsh-dev/DSH-better-sidebar/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/omdsh-dev/DSH-better-sidebar" /></a>
|
||||
<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 & 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 />
|
||||
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions: 0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2" src="https://img.shields.io/badge/DSH-0.1.0--rc.8_%C2%B7_0.1.1--rc.1_%C2%B7_0.1.1--rc.2-4d6bfe" /></a>
|
||||
<a href="https://github.com/topics/dsh-better-sidebar"><img alt="Plugin ecosystem: GitHub topic dsh-better-sidebar" src="https://img.shields.io/badge/plugin%20ecosystem-topic%20dsh--better--sidebar-4d6bfe" /></a><br /><br />
|
||||
<img alt="File management" src="https://img.shields.io/badge/-File%20management-4d6bfe" /> <img alt="Edit & 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="Side Chat" src="https://img.shields.io/badge/-Side%20Chat-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>
|
||||
@@ -19,9 +25,22 @@
|
||||
<img alt="dsh-better-sidebar workbench" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" />
|
||||
</div>
|
||||
|
||||
## 📑 Contents
|
||||
|
||||
- [✨ Features](#-features)
|
||||
- [🚀 Installation](#-installation)
|
||||
- [🖼️ Feature Tour](#-feature-tour)
|
||||
- [🌐 Plugin Ecosystem](#-plugin-ecosystem)
|
||||
- [🆕 Recent Updates](#-recent-updates)
|
||||
- [⌨️ Keyboard Shortcuts](#-keyboard-shortcuts)
|
||||
- [🔌 Service API](#-service-api)
|
||||
- [🛠️ Development & Build](#-development--build)
|
||||
- [🔐 Security](#-security) · [⚠️ Known Limitations](#-known-limitations) · [🖥️ Platform Support](#-platform-support)
|
||||
- [💬 Community](#-community) · [🤝 Contributing](#-contributing) · [⭐ Star History](#-star-history) · [🔗 Friends](#-friends)
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **🗂️ File Workbench**: file explorer (lazy-loading tree; symlinks show their target kind — directory links expand, dangling links flagged) + CodeMirror editor; inline preview for images / Markdown (incl. Mermaid diagrams, strict-mode safe rendering + click-to-zoom) / HTML / PDF / Office
|
||||
- **🗂️ 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
|
||||
- **🌐 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
|
||||
@@ -33,134 +52,34 @@
|
||||
- **⚡ 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.15.0
|
||||
|
||||
> ⚠️ **This release is verified against DSH 0.1.1-rc.2 (@next)**: devDependencies / lockfile / CI mount-smoke baseline raised to `0.1.1-rc.2` (zero rc.8 leftovers in the lockfile); **the peer floor stays at `^0.1.0-rc.8`** — 0.1.1-rc.x is a routine rc progression, mutually compatible with rc.8, so **rc.8 users don't need to upgrade DSH**. `cordis` stays at `^4.0.0-rc.8`. All changes since v0.14.0:
|
||||
|
||||
**✨ New features**
|
||||
|
||||
- 💬 **Side Chat (beta) tab** ([#286](https://github.com/omdsh-dev/DSH-better-sidebar/pull/286)): Codex-style side threads, **one independent tab per conversation** — the child inherits the parent's full context (completed turns + pending messages + the in-progress turn's assistant output and tool activity, honestly frozen with an "interrupted" marker); created with an identical composition (same preset / provider / model) so the first request reuses the parent's input prefix cache; threads stay invisible in the main session list with zero subagent-catalog noise; follow-ups survive DSH restarts (auto cold-resume); one-click "Save as new session" promotes the thread to a top-level session ([design](docs/plans/2026-08-20-sidechat-tab-design.md))
|
||||
- 📤 **Upload into the files window** ([#239](https://github.com/omdsh-dev/DSH-better-sidebar/pull/239)): header "upload file / upload folder" buttons plus drag-drop (drop on the tree body = workspace root, on a directory row = that directory, on a file row = its parent directory, VSCode semantics); full-window blurred progress overlay while uploading (per-file progress + cancel / Esc); buttons disabled while busy, tree refreshes after the upload settles
|
||||
- 🧩 **Desktop compatibility in four options** ([#284](https://github.com/omdsh-dev/DSH-better-sidebar/pull/284)): "Position compatibility mode" is now a main-row dropdown — **Auto-detect** (default, conservative: only the standard Window Controls Overlay geometry contributes; real 32/36px caption-overlay heights per shell, live on maximize/restore; zero modification on plain web) / **DSH official web** (explicitly no adaptation) / **Shell preset** (built-in, opt-in; only shells that appeared in this repo's issues/PRs with 100+ stars, "detected" badge when the environment matches) / **Custom** (free-form CSS + shift distance). Documents that already carried compatibility values migrate to the custom scheme; interactive chrome opts out of desktop drag regions (`no-drag`); the bottom-push anchor is a composite selector (`[data-pane]` and `:has(> [data-slot])`)
|
||||
- 🎛️ **Settings page UI/UX modernization** ([#300](https://github.com/omdsh-dev/DSH-better-sidebar/pull/300)): the side-card secondary-settings entry is now a full-width "Feature settings" strip at the card bottom (replacing the invisible corner gear — much easier to discover); coordinated two-tone enabled state (brand activation accent + success-green check badge); every color is still `--dsw-alias-*` token-derived so skins follow automatically
|
||||
- ➕ **New entries in the recommended-plugin catalog**: `dsh-docs-panel` (global docs, [#230](https://github.com/omdsh-dev/DSH-better-sidebar/pull/230)), `dsh-flowglass` ([#261](https://github.com/omdsh-dev/DSH-better-sidebar/pull/261)), `dsh-git-forge` and `dsh-ssh-tunnel` ([#204](https://github.com/omdsh-dev/DSH-better-sidebar/pull/204)), `dsh-turn-review` ([#102](https://github.com/omdsh-dev/DSH-better-sidebar/pull/102))
|
||||
|
||||
**🐛 Fixes**
|
||||
|
||||
- ⚡ **Batched live preview for the subagent page** ([#298](https://github.com/omdsh-dev/DSH-better-sidebar/pull/298)): the old implementation polled `subagents.history` per running subagent, each poll triggering a full host-side enumeration — an O(N²) amplification that stalled the page with many concurrent subagents; now a single batch route `subagents.live` (one enumeration of the whole tree) plus one client poller with a single in-flight request; display logic and copy unchanged
|
||||
- 🖱️ **Interrupted / fast-release drags no longer roll back** ([#249](https://github.com/omdsh-dev/DSH-better-sidebar/pull/249), closes [#247](https://github.com/omdsh-dev/DSH-better-sidebar/issues/247) [#248](https://github.com/omdsh-dev/DSH-better-sidebar/issues/248)): interrupted or fast-released drags commit the last known position; HMR re-activation re-locates the center column (fixes the blank bottom panel after a hot reload)
|
||||
- 📐 **Push variables stay effective while mounted** ([#259](https://github.com/omdsh-dev/DSH-better-sidebar/pull/259), fixes [#258](https://github.com/omdsh-dev/DSH-better-sidebar/issues/258)): the bottom panel no longer flashes full-width after a drag is released
|
||||
- 🔧 **Adapted to DSH 0.1.1-rc.1 / rc.2 (@next)** ([#297](https://github.com/omdsh-dev/DSH-better-sidebar/pull/297) [#305](https://github.com/omdsh-dev/DSH-better-sidebar/pull/305)): devDependencies / lockfile / CI verification baseline shifted; every consumed contract (DOM slot, CSS tokens, boot protocol, MarkdownText `codeLabels`, `webServer` route registration) compared package by package with zero breakage — **no code changes needed**; peer floor stays at `^0.1.0-rc.8`
|
||||
- 🔒 **Upload-chain hardening** ([#239](https://github.com/omdsh-dev/DSH-better-sidebar/pull/239)): empty and absolute `relativePath` segments are refused outright; uniquely named temp files (concurrent uploads stay independent, crashed processes never block later uploads); write-stream error listeners (a failing disk can no longer crash the host); client error codes unified with the wire (`too-large`), 413s localized
|
||||
|
||||
### v0.14.0
|
||||
|
||||
> ⚠️ **This release adapts to DSH 0.1.0-rc.8**: all `@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`, and the CI mount lane pins `@deepseek-ai/dsh@0.1.0-rc.8`. **DSH rc.7 and earlier cannot 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); `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
|
||||
|
||||
**✨ 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
|
||||
> 🔌 **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 (**28+ ecosystem plugins** already — see "🌐 Plugin Ecosystem" below). See "🔌 Service API" and the [external plugin guide](./docs/external-plugin-guide.md).
|
||||
|
||||
## 🚀 Installation
|
||||
|
||||
**Prerequisites**: DSH installed (`dsh web` boots), Node.js ≥ 20, pnpm ≥ 10.
|
||||
|
||||
**Supported DSH versions**:
|
||||
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions: 0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2" src="https://img.shields.io/badge/DSH-0.1.0--rc.8_%C2%B7_0.1.1--rc.1_%C2%B7_0.1.1--rc.2-4d6bfe" /></a>
|
||||
|
||||
```sh
|
||||
dsh plugin --profile web add dsh-better-sidebar@latest
|
||||
dsh plugin --profile web add dsh-better-sidebar@latest # first run fails: pnpm 11 blocks node-pty build scripts (the dependency is still written)
|
||||
cd ~/.dsh/profiles/web && pnpm approve-builds --all # allow the build scripts (re-runs the install automatically)
|
||||
dsh plugin --profile web add dsh-better-sidebar@latest # re-run succeeds
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
**Or let DSH install it for you** — paste this prompt into any DSH session:
|
||||
|
||||
```text
|
||||
Install the dsh-better-sidebar plugin (a sidebar workbench for DSH):
|
||||
1. Run: dsh plugin --profile web add dsh-better-sidebar@latest (the first run fails because pnpm 11 blocks node-pty build scripts — that's expected)
|
||||
2. In ~/.dsh/profiles/web run: pnpm approve-builds --all (allows the build scripts and re-runs the install)
|
||||
3. Run the add command again: dsh plugin --profile web add dsh-better-sidebar@latest
|
||||
4. When done, remind me to hard-refresh the browser (Cmd/Ctrl+Shift+R)
|
||||
If anything fails, check the troubleshooting table in the README at https://github.com/omdsh-dev/DSH-better-sidebar
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>Updating</b></summary>
|
||||
|
||||
@@ -168,7 +87,7 @@ Then **hard-refresh the browser** (Cmd/Ctrl+Shift+R) to see the sidebar (DSH hot
|
||||
dsh plugin --profile web add dsh-better-sidebar@latest
|
||||
```
|
||||
|
||||
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.
|
||||
or bump the version in `~/.dsh/profiles/web/package.json` (e.g. `"^0.15.0"`) and run `pnpm install`. Then hard-refresh the browser (Cmd/Ctrl+Shift+R) — client changes do not need a DSH restart.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -209,7 +128,7 @@ To debug local changes or track the dev branch, point the dependency at a local
|
||||
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.13.0"` 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.15.0"` and re-run `pnpm install`.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -230,20 +149,22 @@ Update: `git pull && pnpm install && pnpm build` → `node scripts/package-regis
|
||||
|
||||
</details>
|
||||
|
||||
## ⌨️ Keyboard Shortcuts
|
||||
## 🖼️ Feature Tour
|
||||
|
||||
| Action | Keys |
|
||||
> Below are real UI screenshots (two per row; click to zoom).
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Save edits | `Ctrl/Cmd + S` |
|
||||
| Git commit | `Ctrl + Enter` |
|
||||
| Close tab | Middle mouse button |
|
||||
| Split / merge panes | Drag tab to pane edge / middle |
|
||||
| Reference file to input | Hover the `@file` button at end of line |
|
||||
| Copy file path | Right-click row → copy relative/absolute path |
|
||||
| **🗂️ File Workbench: Explorer**<br/><sub>Two explorer modes: embedded in the file preview / standalone file tree. Lazy-loading directory tree, symlinks classified by target kind (directory links expand, dangling links flagged), global filename search, file/folder upload buttons plus drag-drop upload, context menu (open in new tab / open to the side / copy paths), and a hover `@file` button that references a file straight into the composer.</sub><br/><div align="center"><img width="420" alt="File explorer" src="https://github.com/user-attachments/assets/a410bfd2-a8ba-43e6-873e-22417756e94d" /></div> | **📝 Inline Preview: Markdown · Images · PDF**<br/><sub>The Markdown preview renders **Mermaid diagrams** (strict-mode safe rendering + a second sanitize pass; click a diagram for a zoom modal with wheel-zoom and drag-pan); images / PDFs display inline via the media route; the Office suite is covered by an ecosystem plugin.</sub><br/><div align="center"><img width="420" alt="Markdown + Mermaid preview" src="https://github.com/user-attachments/assets/fe0e5182-55bb-45cc-b98b-a2877c2bdd38" /></div> |
|
||||
| **🖥️ CodeMirror editor**<br/><div align="center"><img width="420" alt="CodeMirror editor" src="https://github.com/user-attachments/assets/b44b488e-568c-4ee0-b96c-e9c906598a77" /></div> | **🖼️ Inline image preview**<br/><div align="center"><img width="420" alt="Inline image preview" src="https://github.com/user-attachments/assets/f9a58c30-5b7a-48b5-9e22-37d7e071f593" /></div> |
|
||||
| **💻 Real Terminal**<br/><sub>xterm.js + node-pty real shell (not an emulator): transcript replay on reconnect, configurable shell / shellArgs (settings page or `cordis.patch.yml`), and optional `terminal_*` model tools so the agent can open terminals and run commands itself.</sub><br/><div align="center"><img width="420" alt="Real terminal" src="https://github.com/user-attachments/assets/0dad6ad3-ff3f-4b5a-86d2-f832ce65323e" /></div> | **🌿 Git Panel**<br/><sub>Stage / unstage / commit (`Ctrl+Enter`) / revert, plus a history list; clicking a changed file opens a **VSCode-style diff tab** (line-level red/green).</sub><br/><div align="center"><img width="420" alt="Git panel" src="https://github.com/user-attachments/assets/e7fc1220-305f-4bca-8583-e77ab4f4fa78" /></div> |
|
||||
| **🌐 Embedded Browser**<br/><sub>Multiple web tabs with back / forward / reload / address bar; content runs in an **opaque-origin sandboxed iframe** (live sandbox status in the UI, per-page temporary unlock available); external-link clicks in the chat can be taken over into the sidebar (protocol-based routing, configurable).</sub><br/><div align="center"><img width="420" alt="Embedded browser" src="https://github.com/user-attachments/assets/9bc6b65a-64fc-4942-a685-76e391e55606" /></div> | **🧩 Tasks: Agent Topology + Background Jobs**<br/><sub>Live subagent-tree topology (run states, batched live previews) plus the background-jobs list (exit codes / live output / force-kill); new subagents / jobs can auto-expand the sidebar (configurable).</sub><br/><div align="center"><img width="420" alt="Tasks: subagent topology" src="https://github.com/user-attachments/assets/dcd8ed2f-59fa-405b-937b-2d250f5034dd" /></div> |
|
||||
| **💬 Side Chat (beta)**<br/><sub>Codex-style side threads: **one independent tab per conversation**; the thread inherits the parent's full context (including the in-progress turn, honestly frozen as "interrupted") and runs independently without polluting the main session; follow-ups survive restarts; one click promotes the thread to a top-level session.</sub><br/><div align="center"><img width="420" alt="Side Chat (beta)" src="https://github.com/user-attachments/assets/3a338c36-f5de-4000-95f3-4b1cd04f60fc" /></div> | **🪟 Dual Workbench: Sidebar + Bottom Panel + Split Panes**<br/><sub>The right sidebar and the bottom panel can stay open together; drag a tab to a pane edge to **split**, to the middle to **merge** (works across panels); panel width/height drag from the left/top edge; on mobile everything merges into a full-width drawer.</sub><br/><div align="center"><img width="420" alt="Dual workbench (right sidebar + bottom panel)" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" /></div> |
|
||||
| **⚙️ Declarative Settings**<br/><sub>The "Side card" section in DSH settings: one small card per tab / viewer with an independent toggle (highlighted enabled state + brand switch); secondary settings open from the "Feature settings" strip at the card bottom (switch / text / number / select rows); plugin-owned settings persist under `pluginSettings`.</sub><br/><div align="center"><img width="420" alt="Declarative settings: side cards" src="https://github.com/user-attachments/assets/0800ca64-621e-48da-b7df-aecfddc3ec29" /></div> | **📱 Mobile**<br/><sub>On narrow screens (<768px) the panels become a full-width drawer: bottom-panel tabs merge into the sidebar once, with touch-friendly dragging.</sub><br/><div align="center"><img width="360" alt="Mobile full-width drawer" src="https://github.com/user-attachments/assets/a82ba78a-f4cf-4d85-80e8-050a05beb144" /></div> |
|
||||
|
||||
## 🔌 Service: register tabs & file viewers
|
||||
## 🌐 Plugin Ecosystem
|
||||
|
||||
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):
|
||||
The `ctx.betterSidebar` service opens two extension points to every plugin: **`registerTab` (sidebar pages)** and **`registerFileViewer` (file previewers)**. The 7 built-in tabs + 6 viewers register through the exact same API — fully equal capabilities.
|
||||
|
||||
```ts
|
||||
import type {} from 'dsh-better-sidebar' // triggers the ctx.betterSidebar type merge
|
||||
@@ -252,10 +173,219 @@ export function apply(ctx: Context) {
|
||||
ctx.effect(() => ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:db', title: 'Database', component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
|
||||
}))
|
||||
ctx.effect(() => ctx.betterSidebar.registerFileViewer({
|
||||
id: 'my-plugin:csv', exts: ['csv'], fetchStrategy: 'custom',
|
||||
load: async (path, scope) => parseCsv(await fetchText(scope, path)),
|
||||
component: ({ customData }) => <CsvGrid rows={customData} />,
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
v0.12.1+ base capabilities (complete type exports, capability detection, state subscription, tab badge, lifecycle callbacks, targeted open, plugin-owned settings, etc.) — see the integration docs below.
|
||||
The GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar) already hosts **28+ ecosystem plugins** (and growing):
|
||||
|
||||
<div align="center">
|
||||
<a href="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e"><img width="66%" alt="The built-in Add Plugins modal: recommended catalog + one-click install command" src="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e" /></a><br />
|
||||
<i>The built-in "Add plugins" modal in settings: recommended catalog + one-click install command + a direct link to the GitHub topic</i>
|
||||
</div>
|
||||
|
||||
### 📑 Tab Plugins (sidebar pages)
|
||||
|
||||
<details>
|
||||
<summary><b>23 plugins (click to expand)</b></summary>
|
||||
|
||||
| Plugin | ⭐ | Description |
|
||||
|---|---|---|
|
||||
| [ChenRuoT/dsh-sidebar-qa](https://github.com/ChenRuoT/dsh-sidebar-qa) | <img alt="stars" src="https://img.shields.io/github/stars/ChenRuoT/dsh-sidebar-qa?style=flat&color=4d6bfe" /> | Selection-based side Q&A — Codex-style side questions / Claude Code `/btw` |
|
||||
| [fuhefei/dsh-sentinel](https://github.com/fuhefei/dsh-sentinel) | <img alt="stars" src="https://img.shields.io/github/stars/fuhefei/dsh-sentinel?style=flat&color=4d6bfe" /> | Condition-driven wakeup: file / command / HTTP / process / webhook watches that wake the agent; dock + sidebar branch + global dashboard |
|
||||
| [jiuge2467/dsh-studio](https://github.com/jiuge2467/dsh-studio) | <img alt="stars" src="https://img.shields.io/github/stars/jiuge2467/dsh-studio?style=flat&color=4d6bfe" /> | Full-stack enhancement workbench: multi-source MCP visual debugging hub, visual thinking engine |
|
||||
| [Iwctwbh/dsh-flowglass](https://github.com/Iwctwbh/dsh-flowglass) | <img alt="stars" src="https://img.shields.io/github/stars/Iwctwbh/dsh-flowglass?style=flat&color=4d6bfe" /> | Flowglass: live session flowgraph (messages / tool groups / subagent branches) |
|
||||
| [FeatherHunter/dsh-mattpocock-skills-deck](https://github.com/FeatherHunter/dsh-mattpocock-skills-deck) | <img alt="stars" src="https://img.shields.io/github/stars/FeatherHunter/dsh-mattpocock-skills-deck?style=flat&color=4d6bfe" /> | Game-like mission system for mattpocock/skills: fog-of-war map + task bar |
|
||||
| [GULI-lab/DSH-element-source](https://github.com/GULI-lab/DSH-element-source) | <img alt="stars" src="https://img.shields.io/github/stars/GULI-lab/DSH-element-source?style=flat&color=4d6bfe" /> | Click any UI element on your dev page to jump to its Vue / React / Svelte / Angular source, straight into the chat |
|
||||
| [Lzh3070/dsh-file-review-tab](https://github.com/Lzh3070/dsh-file-review-tab) | <img alt="stars" src="https://img.shields.io/github/stars/Lzh3070/dsh-file-review-tab?style=flat&color=4d6bfe" /> | File-change review tab: line-level red/green diffs + undo + chat-line deep links |
|
||||
| [yq04/dsh-git-remotes](https://github.com/yq04/dsh-git-remotes) | <img alt="stars" src="https://img.shields.io/github/stars/yq04/dsh-git-remotes?style=flat&color=4d6bfe" /> | Git remotes tab: branches / upstream / ahead-behind, fetch with prune, ff-only pull, confirm-before-push |
|
||||
| [ztyhehe/dsh-better-sidebar-svn](https://github.com/ztyhehe/dsh-better-sidebar-svn) | <img alt="stars" src="https://img.shields.io/github/stars/ztyhehe/dsh-better-sidebar-svn?style=flat&color=4d6bfe" /> | SVN source-control tab: status / diff / log / commit / update / revert / conflict resolution — symmetric to the built-in Git panel |
|
||||
| [Melody-max114/dsh-excel-panel](https://github.com/Melody-max114/dsh-excel-panel) | <img alt="stars" src="https://img.shields.io/github/stars/Melody-max114/dsh-excel-panel?style=flat&color=4d6bfe" /> | Excel editing: xlsx preview/edit, live formula evaluation, merged cells, save back to the original file |
|
||||
| [v587d/dsh-anysearch-refs](https://github.com/v587d/dsh-anysearch-refs) | <img alt="stars" src="https://img.shields.io/github/stars/v587d/dsh-anysearch-refs?style=flat&color=4d6bfe" /> | AnySearch results as sidebar cards: query, source snippets, highlighted keywords |
|
||||
| [mlosun/dsh-docs-panel](https://github.com/mlosun/dsh-docs-panel) | <img alt="stars" src="https://img.shields.io/github/stars/mlosun/dsh-docs-panel?style=flat&color=4d6bfe" /> | Global docs panel: portable Markdown notes, readable from any workspace |
|
||||
| [lnyuqian/dsh-skill-sidebar](https://github.com/lnyuqian/dsh-skill-sidebar) | <img alt="stars" src="https://img.shields.io/github/stars/lnyuqian/dsh-skill-sidebar?style=flat&color=4d6bfe" /> | Skills panel: scans local skill directories, one-click invocation copy, pinning |
|
||||
| [g-yixuan/dsh-sidechat](https://github.com/g-yixuan/dsh-sidechat) | <img alt="stars" src="https://img.shields.io/github/stars/g-yixuan/dsh-sidechat?style=flat&color=4d6bfe" /> | Codex-style side chat + selection annotations (a thin consumer plugin) |
|
||||
| [thirsty5034/dsh-ssh-tunnel](https://github.com/thirsty5034/dsh-ssh-tunnel) | <img alt="stars" src="https://img.shields.io/github/stars/thirsty5034/dsh-ssh-tunnel?style=flat&color=4d6bfe" /> | Multi-host SSH tunnels + SSH manager tab |
|
||||
| [thirsty5034/dsh-git-forge](https://github.com/thirsty5034/dsh-git-forge) | <img alt="stars" src="https://img.shields.io/github/stars/thirsty5034/dsh-git-forge?style=flat&color=4d6bfe" /> | GitHub / Gitea accounts, project grants and push policy |
|
||||
| [YesSanSan/dsh-conversation-outline](https://github.com/YesSanSan/dsh-conversation-outline) | <img alt="stars" src="https://img.shields.io/github/stars/YesSanSan/dsh-conversation-outline?style=flat&color=4d6bfe" /> | Conversation outline tab: per-turn structure, quick jump, one-line LLM titles |
|
||||
| [Wulabalabo/dsh-sidebar-Explorer-Plus](https://github.com/Wulabalabo/dsh-sidebar-Explorer-Plus) | <img alt="stars" src="https://img.shields.io/github/stars/Wulabalabo/dsh-sidebar-Explorer-Plus?style=flat&color=4d6bfe" /> | File-manager tab: upload / move / delete / rename / new folder (write operations) |
|
||||
| [yq04/dsh-turn-review](https://github.com/yq04/dsh-turn-review) | <img alt="stars" src="https://img.shields.io/github/stars/yq04/dsh-turn-review?style=flat&color=4d6bfe" /> | Turn review: review agent changes turn by turn |
|
||||
| [Ghz114514/dsh-refpics](https://github.com/Ghz114514/dsh-refpics) | <img alt="stars" src="https://img.shields.io/github/stars/Ghz114514/dsh-refpics?style=flat&color=4d6bfe" /> | Pinterest-style reference-image search: masonry wall, sidebar board, downloads, save-to-Eagle |
|
||||
| [yzlin499/dsh-yzlin499-easy-plugins](https://github.com/yzlin499/dsh-yzlin499-easy-plugins) | <img alt="stars" src="https://img.shields.io/github/stars/yzlin499/dsh-yzlin499-easy-plugins?style=flat&color=4d6bfe" /> | A handy utility bundle for a bare-bones DSH |
|
||||
| [dong-victor/dsh-better-sidebar-starter](https://github.com/dong-victor/dsh-better-sidebar-starter) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-better-sidebar-starter?style=flat&color=4d6bfe" /> | Run-configurations tab: IDEA-style Run/Debug configs (npm / springboot / python / custom) — one-click launch, history, WebSocket live logs (ANSI colors), parallel instances, cross-platform process-tree kill |
|
||||
| [baosfeng/my-dsh-plugins](https://github.com/baosfeng/my-dsh-plugins) | <img alt="stars" src="https://img.shields.io/github/stars/baosfeng/my-dsh-plugins?style=flat&color=4d6bfe" /> | Personal multi-plugin collection (`dsh-file-activity`): a sidebar file-activity tab recording read / added / modified history and stats, flat-browsed by folder, opened with the native preview |
|
||||
|
||||
</details>
|
||||
|
||||
### 🖼️ Viewer Plugins (file previewers)
|
||||
|
||||
<details>
|
||||
<summary><b>3 plugins (click to expand)</b></summary>
|
||||
|
||||
| Plugin | ⭐ | Description |
|
||||
|---|---|---|
|
||||
| [HuanLinOTO/dsh-plugin-better-sidebar-plugin-office](https://github.com/HuanLinOTO/dsh-plugin-better-sidebar-plugin-office) | <img alt="stars" src="https://img.shields.io/github/stars/HuanLinOTO/dsh-plugin-better-sidebar-plugin-office?style=flat&color=4d6bfe" /> | Office-suite preview (.docx / .xlsx / .pptx) as a separate bundle to slim the core (in the official recommended catalog) |
|
||||
| [zemul/dsh-video-preview](https://github.com/zemul/dsh-video-preview) | <img alt="stars" src="https://img.shields.io/github/stars/zemul/dsh-video-preview?style=flat&color=4d6bfe" /> | Inline video preview: .mp4 / .webm / .mov / .mkv / .avi with a /video host route supporting HTTP Range scrubbing |
|
||||
| [dong-victor/dsh-better-sidebar-jupyter](https://github.com/dong-victor/dsh-better-sidebar-jupyter) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-better-sidebar-jupyter?style=flat&color=4d6bfe" /> | Runnable `.ipynb` notebook view: lazy-start Python kernel, streaming outputs, save-back |
|
||||
|
||||
</details>
|
||||
|
||||
### 🧰 Enhancements & Tools
|
||||
|
||||
<details>
|
||||
<summary><b>2 plugins (click to expand)</b></summary>
|
||||
|
||||
| Plugin | ⭐ | Description |
|
||||
|---|---|---|
|
||||
| [dong-victor/dsh-better-sidebar-terminal-plus](https://github.com/dong-victor/dsh-better-sidebar-terminal-plus) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-better-sidebar-terminal-plus?style=flat&color=4d6bfe" /> | Terminal enhancement: bundled Nerd Font icons, xterm glyph fixes, stable terminal cwd |
|
||||
| [Max-Null/dsh-sidebar-preview-select](https://github.com/Max-Null/dsh-sidebar-preview-select) | <img alt="stars" src="https://img.shields.io/github/stars/Max-Null/dsh-sidebar-preview-select?style=flat&color=4d6bfe" /> | Preview selection boost: select text in any sidebar preview → floating "send to session" |
|
||||
|
||||
</details>
|
||||
|
||||
> 📣 **List your plugin**: tag your repo with the `dsh-better-sidebar` topic to appear on the [topic page](https://github.com/topics/dsh-better-sidebar); then PR one `PluginEntry` into [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts) / [`src/client/plugins-viewers.ts`](./src/client/plugins-viewers.ts) to join the built-in recommended catalog (data integrity is guarded by `tests/plugin-list.spec.ts`).
|
||||
|
||||
## 🆕 Recent Updates
|
||||
|
||||
<div align="center">
|
||||
<a href="https://github.com/user-attachments/assets/d2aea86b-a776-4f01-a6b8-b26b27314336"><img width="33%" alt="Sidebar" 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="Service API base screenshot" src="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0" /></a>
|
||||
</div>
|
||||
|
||||
**Supported DSH versions**: <a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions: 0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2" src="https://img.shields.io/badge/DSH-0.1.0--rc.8_%C2%B7_0.1.1--rc.1_%C2%B7_0.1.1--rc.2-4d6bfe" /></a> · full release history on the [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases) page
|
||||
|
||||
### v0.15.2
|
||||
|
||||
All changes since v0.15.1:
|
||||
|
||||
**✨ New features**
|
||||
|
||||
- 🗂️ **"Open in app" submenu in the file tree** ([#334](https://github.com/omdsh-dev/DSH-better-sidebar/pull/334)): the file-tree context menu gains an "Open in app >" submenu — built-in openers (reveal/select in file manager, VS Code, Cursor, Zed), each with a pin button that promotes it to a top-level context-menu item (click again to unpin); with an optional SSH host configured, VSCode-family entries switch to the `vscode-remote/ssh-remote+<host>/<path>` protocol and local-only entries hide automatically; custom editors supported (name + URL template with `{path}` + "VSCode-family" flag, configured in the Files card gear popup). Open actions go through the new host route `POST /sidebar/api/open.external` (argv-array spawn, no shell injection) ([design](docs/plans/2026-08-22-open-with-menu-design.md))
|
||||
- 📑 **Tab context menu** ([#331](https://github.com/omdsh-dev/DSH-better-sidebar/pull/331)): right-clicking a tab offers **Close / Close others / Close to the left / Close to the right**, scoped to the current pane (tab group); items grey out when there is nothing to close; the menu only opens — it never switches the active tab; bulk closes go through the existing per-tab `onClose` path so lifecycle callbacks, pty release and agent-terminal shutdown all stay intact
|
||||
- 📄 **Diff files collapsed by default** ([#270](https://github.com/omdsh-dev/DSH-better-sidebar/pull/270)): each changed-file header is now an accessible expand/collapse control; recognized source files expand by default while tests, docs, generated files, lockfiles and unknown types stay collapsed; the existing 500-row cap for expanded content is preserved
|
||||
- 📖 **README update**: feature tour converted to a table (two screenshots per row, saves space); community section now shows the WeChat group and QQ group QR codes ([#325](https://github.com/omdsh-dev/DSH-better-sidebar/pull/325), QQ group 577011007)
|
||||
|
||||
**🐛 Fixes**
|
||||
|
||||
- 🪟 **Prune empty restored panes** ([#268](https://github.com/omdsh-dev/DSH-better-sidebar/pull/268)): persisted split panes left empty when ephemeral diff tabs were dropped no longer survive as full-size blanks — `sanitizeState` now also prunes the empty split leaf and repairs the stale active-pane pointer; a fully empty workbench keeps its single empty pane
|
||||
- 🖥️ **Hide spawned git windows on Windows** ([#301](https://github.com/omdsh-dev/DSH-better-sidebar/pull/301), fixes [#124](https://github.com/omdsh-dev/DSH-better-sidebar/issues/124)): the shared `runGit()` spawn options now set `windowsHide: true`, so repo-status polling and git actions no longer flash console windows on Windows (no behavior change on other platforms)
|
||||
- 📁 **Untracked files inside new folders** ([#242](https://github.com/omdsh-dev/DSH-better-sidebar/pull/242)): `git status` switched from `--untracked-files=normal` to `--untracked-files=all` — each file inside a new folder now shows as its own row and its diff can be loaded (no more `fs.read` "is a directory" error), matching VSCode's default behavior
|
||||
- ⚡ **Per-frame React re-renders eliminated for toggles/drags** (closes [#315](https://github.com/omdsh-dev/DSH-better-sidebar/issues/315)): `centerRect` moved to a ref with direct DOM writes to the bottom bar (zero React renders); `TabContent` memoized with an explicit comparator; a new frame-batcher coalesces Divider/dock drags per frame; meaningless locate passes skipped during drags. 4x CPU-throttled A/B: >17ms frames on toggle collapse 19→6 / expand 24→4~6, p95 21ms→15ms; drags unchanged (non-regression)
|
||||
|
||||
<details>
|
||||
<summary><b>Older releases (v0.12.0 – v0.15.1)</b></summary>
|
||||
|
||||
### v0.15.1
|
||||
|
||||
All changes since v0.15.0:
|
||||
|
||||
**✨ New features**
|
||||
|
||||
- 💬 **Codex-style transcript rework for Side Chat** ([#314](https://github.com/omdsh-dev/DSH-better-sidebar/pull/314)): transcript rows became **collapsible** — tool calls, thinking and context injections share one quiet single-line chrome (chevron + label + one-line argument summary) that expands into an indented body on a hairline thread, no cards or fills; streaming labels and a creating hero shimmer (shimmer = generating), failed tools go danger, `prefers-reduced-motion` stills every loop; **the first question is no longer swallowed by the boundary prompt** — context injection and first contact are delivered as separate events (boundary + parked snapshot ride `agent.inject`, the question wakes the driver), so the transcript maps injections onto a collapsible injection row while genuine user messages — the first one included — render as user bubbles; legacy threads' first message is split out into its own bubble too
|
||||
- 📖 **README rewrite**: feature tour (real UI screenshots per feature), user-facing DSH compatibility badges, simplified install (`add` → `approve-builds` → `add`, node-pty-safe build, paste-to-DSH install prompt), 28+ plugin ecosystem with per-category collapsed listings
|
||||
|
||||
**🐛 Fixes**
|
||||
|
||||
- 🖥️ **Terminal pty stays alive across conversation switches** ([#323](https://github.com/omdsh-dev/DSH-better-sidebar/pull/323)): switching sessions is no longer treated as a transient drop — the client sends a `park` control frame on unmount and the host skips the 30s reconnect-grace countdown; switching back (`open()` cancels parked) or explicitly closing the tab resumes the normal lifecycle; agent terminals keep their indefinite lifetime
|
||||
- 📂 **File-tree upload overlay no longer intercepts Tab drags** ([#317](https://github.com/omdsh-dev/DSH-better-sidebar/pull/317)): dragging tabs (reorder / cross-pane split) across the explorer no longer shows the upload overlay or swallows the event — gated on `dataTransfer.types` containing `Files` (consistent with the panel-host shield), so tabs land normally; OS file drags behave as before
|
||||
- 💬 **Subagent auto-open debounced** ([#314](https://github.com/omdsh-dev/DSH-better-sidebar/pull/314)): Side Chat thread creation no longer pops the task page — the 0→N trigger rearms for 500ms and re-evaluates the original baseline against the live snapshot, by which time the title filter recognizes the thread; genuine subagents still auto-open
|
||||
|
||||
### v0.15.0
|
||||
|
||||
All changes since v0.14.0:
|
||||
|
||||
**✨ New features**
|
||||
|
||||
- 💬 **Side Chat (beta) tab** ([#286](https://github.com/omdsh-dev/DSH-better-sidebar/pull/286)): Codex-style side threads, **one independent tab per conversation** — the child inherits the parent's full context (completed turns + pending messages + the in-progress turn's assistant output and tool activity, honestly frozen with an "interrupted" marker); created with an identical composition (same preset / provider / model) so the first request reuses the parent's input prefix cache; threads stay invisible in the main session list with zero subagent-catalog noise; follow-ups survive DSH restarts (auto cold-resume); one-click "Save as new session" promotes the thread to a top-level session ([design](docs/plans/2026-08-20-sidechat-tab-design.md))
|
||||
- 📤 **Upload into the files window** ([#239](https://github.com/omdsh-dev/DSH-better-sidebar/pull/239)): header "upload file / upload folder" buttons plus drag-drop (drop on the tree body = workspace root, on a directory row = that directory, on a file row = its parent directory, VSCode semantics); full-window blurred progress overlay while uploading (per-file progress + cancel / Esc); buttons disabled while busy, tree refreshes after the upload settles
|
||||
- 🧩 **Desktop compatibility in four options** ([#284](https://github.com/omdsh-dev/DSH-better-sidebar/pull/284)): "Position compatibility mode" is now a main-row dropdown — **Auto-detect** (default, conservative: only the standard Window Controls Overlay geometry contributes; real 32/36px caption-overlay heights per shell, live on maximize/restore; zero modification on plain web) / **DSH official web** (explicitly no adaptation) / **Shell preset** (built-in, opt-in; only shells that appeared in this repo's issues/PRs with 100+ stars, "detected" badge when the environment matches) / **Custom** (free-form CSS + shift distance). Documents that already carried compatibility values migrate to the custom scheme; interactive chrome opts out of desktop drag regions (`no-drag`); the bottom-push anchor is a composite selector (`[data-pane]` and `:has(> [data-slot])`)
|
||||
- 🎛️ **Settings page UI/UX modernization** ([#300](https://github.com/omdsh-dev/DSH-better-sidebar/pull/300)): the side-card secondary-settings entry is now a full-width "Feature settings" strip at the card bottom (replacing the invisible corner gear — much easier to discover); coordinated two-tone enabled state (brand activation accent + success-green check badge); every color is still `--dsw-alias-*` token-derived so skins follow automatically
|
||||
- ➕ **New entries in the recommended-plugin catalog**: `dsh-docs-panel` (global docs, [#230](https://github.com/omdsh-dev/DSH-better-sidebar/pull/230)), `dsh-flowglass` ([#261](https://github.com/omdsh-dev/DSH-better-sidebar/pull/261)), `dsh-git-forge` and `dsh-ssh-tunnel` ([#204](https://github.com/omdsh-dev/DSH-better-sidebar/pull/204)), `dsh-turn-review` ([#102](https://github.com/omdsh-dev/DSH-better-sidebar/pull/102))
|
||||
|
||||
**🐛 Fixes**
|
||||
|
||||
- ⚡ **Batched live preview for the subagent page** ([#298](https://github.com/omdsh-dev/DSH-better-sidebar/pull/298)): the old implementation polled `subagents.history` per running subagent, each poll triggering a full host-side enumeration — an O(N²) amplification that stalled the page with many concurrent subagents; now a single batch route `subagents.live` (one enumeration of the whole tree) plus one client poller with a single in-flight request; display logic and copy unchanged
|
||||
- 🖱️ **Interrupted / fast-release drags no longer roll back** ([#249](https://github.com/omdsh-dev/DSH-better-sidebar/pull/249), closes [#247](https://github.com/omdsh-dev/DSH-better-sidebar/issues/247) [#248](https://github.com/omdsh-dev/DSH-better-sidebar/issues/248)): interrupted or fast-released drags commit the last known position; HMR re-activation re-locates the center column (fixes the blank bottom panel after a hot reload)
|
||||
- 📐 **Push variables stay effective while mounted** ([#259](https://github.com/omdsh-dev/DSH-better-sidebar/pull/259), fixes [#258](https://github.com/omdsh-dev/DSH-better-sidebar/issues/258)): the bottom panel no longer flashes full-width after a drag is released
|
||||
- 🔧 **Adapted to DSH 0.1.1-rc.1 / rc.2 (@next)** ([#297](https://github.com/omdsh-dev/DSH-better-sidebar/pull/297) [#305](https://github.com/omdsh-dev/DSH-better-sidebar/pull/305)): no code changes needed
|
||||
- 🔒 **Upload-chain hardening** ([#239](https://github.com/omdsh-dev/DSH-better-sidebar/pull/239)): empty and absolute `relativePath` segments are refused outright; uniquely named temp files (concurrent uploads stay independent, crashed processes never block later uploads); write-stream error listeners (a failing disk can no longer crash the host); client error codes unified with the wire (`too-large`), 413s localized
|
||||
|
||||
### v0.14.0
|
||||
|
||||
> ⚠️ This release requires DSH ≥ 0.1.0-rc.8. 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); `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**
|
||||
|
||||
- 🧩 **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
|
||||
|
||||
**✨ 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
|
||||
- 🔧 **Adapted 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)): fixes the `agent-presets: refusing to compose an unscoped context` error when picking a model / sending a message after DSH moved to rc.7
|
||||
|
||||
### 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
|
||||
|
||||
### 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
|
||||
|
||||
</details>
|
||||
|
||||
## ⌨️ Keyboard Shortcuts
|
||||
|
||||
| Action | Keys |
|
||||
|---|---|
|
||||
| Save edits | `Ctrl/Cmd + S` |
|
||||
| Git commit | `Ctrl + Enter` |
|
||||
| Close tab | Middle mouse button |
|
||||
| Tab context menu (right-click) | Close / Close Other Tabs / Close Tabs to the Left / Close Tabs to the Right (current pane) |
|
||||
| Split / merge panes | Drag tab to pane edge / middle |
|
||||
| Reference file to input | Hover the `@file` button at end of line |
|
||||
| Copy file path | Right-click row → copy relative/absolute path |
|
||||
|
||||
## 🔌 Service API
|
||||
|
||||
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). v0.12.1 completed the base capabilities (complete type exports, capability detection, state subscription, tab badges, lifecycle callbacks, targeted open, plugin-owned settings, etc.).
|
||||
|
||||
Full integration docs:
|
||||
- **[`AGENTS.md`](./AGENTS.md)** — the in-repo integration doc (full fields, matching algorithm, HMR pitfalls, declarative settings, version detection);
|
||||
@@ -298,9 +428,52 @@ pnpm watch # tsdown --watch
|
||||
|
||||
Windows / Linux / macOS (macOS validated daily; the rest covered by unit tests); `node-pty` prefers prebuilt binaries, otherwise a build toolchain is required (Windows VS Build Tools / Linux make+g+++python3 / macOS Xcode CLT).
|
||||
|
||||
## 💬 Community
|
||||
|
||||
WeChat / QQ group QR codes will live here. After uploading the QR images (drag them into any issue/comment to get a `user-attachments` link), replace `src` below and uncomment:
|
||||
|
||||
<div align="center">
|
||||
<!-- WeChat group QR code
|
||||
<img width="220" alt="WeChat group QR code" src="https://github.com/user-attachments/assets/REPLACE_ME" />
|
||||
-->
|
||||
<!-- QQ group QR code
|
||||
<img width="220" alt="QQ group QR code" src="https://github.com/user-attachments/assets/REPLACE_ME" />
|
||||
-->
|
||||
</div>
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
- **Code changes go through PRs**: develop on a `feat/*` / `fix/*` branch, then `gh pr create`; docs-only changes may be pushed to main directly
|
||||
- **Curate an ecosystem plugin**: tag your repo with `dsh-better-sidebar` + PR a `PluginEntry` into [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts) / [`plugins-viewers.ts`](./src/client/plugins-viewers.ts)
|
||||
- **Before submitting**: `pnpm typecheck && pnpm build && pnpm test` (CI additionally gates on npm-pack → real-mount → headless-render via `pnpm test:mount`)
|
||||
- See [`AGENTS.md`](./AGENTS.md) for the repository rules (hard constraints, CI lanes, release flow)
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
<a href="https://star-history.com/#omdsh-dev/DSH-better-sidebar&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=omdsh-dev/DSH-better-sidebar&type=Date&theme=dark" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=omdsh-dev/DSH-better-sidebar&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## 👥 Contributors
|
||||
|
||||
Thanks to everyone who contributed:
|
||||
|
||||
<a href="https://github.com/omdsh-dev/DSH-better-sidebar/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=omdsh-dev/DSH-better-sidebar" alt="Contributors" />
|
||||
</a>
|
||||
|
||||
## 🔗 Friends
|
||||
|
||||
- [dsh-tianshu-tui](https://github.com/huiliyi37/dsh-tianshu-tui): an interactive terminal UI plugin for DeepSeek Harness (its rendering core evolved from the self-developed harness agent Tianshu-Tui), adding TDD and evidence-gate workflows on top of the official harness
|
||||
- [dsh-TUI](https://github.com/ccch1mneyyy/dsh-TUI): a Claude Code-style fullscreen interactive TUI plugin — pixel-whale top bar, live working-status row, streaming thought expansion, double-Esc rollback, context progress bar + TPS meter; one-command npm install
|
||||
- [dshfind Plugin Market](https://dshfind.com/zh/plugins): a third-party plugin marketplace — a listing of public repos under the GitHub topic `dsh-plugin`, with stars, contributors and growth data synced daily
|
||||
- [DeepSeek Harness Desktop](https://github.com/anywhere-labs/deepseek-harness-desktop): a modern desktop client for the DeepSeek Harness ecosystem — start and manage a local Harness service without configuring Node.js or running commands; [official site](https://www.dshdesktop.cn)
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<sub>MIT License · Built for the <a href="https://github.com/deepseek-ai/deepseek-harness">DeepSeek Harness</a> ecosystem · discover more on the <a href="https://github.com/topics/dsh-better-sidebar">dsh-better-sidebar topic</a></sub>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
# 文件树「打开方式」子菜单设计(2026-08-22)
|
||||
|
||||
> 状态:已实现(分支 `feat/open-with-menu`)。本文档记录功能设计、关键取舍与实施偏差。
|
||||
|
||||
## 目标
|
||||
|
||||
文件树(`FileTree`)右键菜单新增「在应用中打开 >」子菜单:
|
||||
|
||||
- 内置 **资源管理器(在文件管理器中显示)、VS Code、Cursor、Zed** 四个打开方式;每个子菜单行右侧有**图钉**,点击可把该方式固定为右键菜单的**顶层直达项**(再点取消固定)。
|
||||
- **远端 SSH**:设置中配置可选 SSH host(`user@host` 或 `~/.ssh/config` 别名)。非空即整个工作区视为远端:VSCode 系条目(VS Code / Cursor / 自定义且勾选"VSCode 系")改用 `<scheme>://vscode-remote/ssh-remote+<host>/<path>` 打开;本地专用条目(资源管理器 / Zed / 非 VSCode 系自定义)从菜单隐藏。
|
||||
- **自定义编辑器**:名称 + URL 模板(`{path}` 占位符,如 `cursor://file/{path}`)+ 「是否 VSCode 系」开关;配置入口在**侧边栏设置 Files 卡片齿轮**(与既有 `editorExplorer` 下拉同一弹窗)。
|
||||
- 打开动作经宿主新路由 `POST /sidebar/api/open.external` 执行(argv 数组 spawn,无 shell 注入),浏览器模式与 DSH Desktop 行为一致;**不经过** `ctx.workspaces.openPath`(该入口已被 `wrapOpenPath` 劫持到侧边栏编辑器,见 `src/client/openpath-intercept.ts`)。
|
||||
|
||||
## 调研结论(reuse gate)
|
||||
|
||||
- DSH checkout(`~/.dsh/source/current`)无"文件树 open-with"、无 SSH 远程会话、无 `vscode://` 开启器;`host.openPath` 只能"默认应用打开",无法指定编辑器、无 reveal/select 语义 → 需要新增。
|
||||
- dsh-external hub 无同类成品;`dsh-open-in-vscode` 是工作区行菜单、依赖外部 slot,不适用。
|
||||
- primitives `Menu` 原生支持 `submenu?: readonly MenuItem[]`(子项点击走 `onSelect(child.id)`),`label: ReactNode` 可内嵌图钉;无现成图钉/品牌图标 → 引入 `react-icons`(`vsc` codicons 图钉 `VscPin`/`VscPinned` 与文件树 `VscFile`/`VscFolder(Opened)`、`si` 品牌剪影 Cursor/Zed、菜单父行 `VscLinkExternal`),VS Code 品牌剪影因 simple-icons 后期版本受微软商标政策下架而内嵌自 simple-icons@11.0.0(CC0,`IconVscode16`);菜单行图标统一 16px 与 DSH 原生菜单一致,子菜单父行的 `>` chevron 由 label 内右对齐元素提供(基元不渲染)。⚠️ react-icons 的 exports 把 `require` 排在 `import` 前,tsdown 共享 conditionNames 会选中不可树摇的 CJS 入口(bundle +6.4MB)——`tsdown.config.ts` 用 `resolve.alias` 钉死 `si`/`vsc` 的 ESM 入口,树摇后仅 +12KB。
|
||||
|
||||
## 数据模型
|
||||
|
||||
持久化在 `pluginSettings['editor'].openWith`(宿主 schema 的开放 map,无需改 PrefsSchema):
|
||||
|
||||
```json
|
||||
{
|
||||
"openWith": {
|
||||
"sshHost": "",
|
||||
"customEditors": [
|
||||
{ "id": "custom:<uuid>", "name": "Windsurf", "urlTemplate": "windsurf://file/{path}", "isVscodeFamily": false }
|
||||
],
|
||||
"pinned": ["vscode", "custom:<uuid>"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `parseOpenWithConfig`(`src/client/open-with.ts`)容错解析:坏行丢弃、未知 pinned 在解析目标时剪枝;**结构合法但未填完的行(空 name/模板)保留**——设置面板正在编辑的行必须能在弹窗往返中存活,由菜单侧的 `isValidCustomEditor` 过滤。
|
||||
- 目标 id:内置 `explorer` / `vscode` / `cursor` / `zed`;自定义 `custom:<uuid>`。
|
||||
- URL 构建:
|
||||
- 本地:模板原文替换 `{path}`(`vscode://file//home/u/f.ts`——POSIX 绝对路径自带前导 `/`,与 VS Code 官方文档 `vscode://file/c:/…` 的约定一致;反斜杠归一化为 `/`)。
|
||||
- SSH(VSCode 系):`<scheme>://vscode-remote/ssh-remote+<host>/<path>`,路径保留前导 `/`(如 `…+dev/home/u/f.ts`,与社区约定一致)。
|
||||
- 模板无 `scheme://` 或无 `{path}` → 返回 undefined(不打开)。
|
||||
|
||||
## 菜单结构(文件行与目录行一致)
|
||||
|
||||
```
|
||||
在新 Tab 中打开 ← 既有
|
||||
在侧边打开 ← 既有
|
||||
----------------------------
|
||||
VS Code (SSH) ← pinned 直达项(按内置顺序 + 自定义追加)
|
||||
Cursor (SSH)
|
||||
----------------------------
|
||||
在应用中打开 > ← 子菜单父项(始终在 pinned 之后)
|
||||
资源管理器 📌
|
||||
VS Code 📌(激活)
|
||||
Cursor 📌(激活)
|
||||
Zed 📌
|
||||
Windsurf 📌
|
||||
下载 ← 既有(仅文件行)
|
||||
复制相对地址 / 复制绝对地址 ← 既有
|
||||
```
|
||||
|
||||
- 图钉是 `label` 内的 `<span role="button" tabIndex={-1}>`(Menu 行本身是 `<button>`,嵌套原生 button 不合法);`stopPropagation` + `preventDefault` 使其**不关闭菜单、不触发行选择**,点击后经响应式 blob 重渲染立即翻转。
|
||||
- 未接线(props 缺省)或 SSH 过滤后无目标 → 整个区块隐藏,旧行为/旧测试不变。
|
||||
|
||||
## 设置面板:`settings.render` seam 扩展
|
||||
|
||||
**实施偏差(与最初"render 独占"契约不同,需同步文档)**:`SettingsBody`(`src/client/SideCardSection.tsx`)原先"有 `render` 就整个替换行列表"。本功能需要**行列表(`editorExplorer` 下拉)与自定义面板共存**,因此把 seam 改为:
|
||||
|
||||
> `render` 给定时**追加**渲染在行列表(`toggles` / `pluginToggles`)之后;未声明行列表时与旧行为一致(单独渲染)。
|
||||
|
||||
- 仓库内无既有 render 使用者,`tests/side-card-section.spec.tsx` 无"render 独占"断言,风险可控;`AGENTS.md` §3.1/§5 与本文档同步更新。
|
||||
- 面板组件 `OpenWithSettings`:SSH host 输入 + 自定义编辑器行(名称/模板/VSCode 系/删除)+「添加」;每次变更整体提交 `updatePluginSetting('openWith', next)`(含 pinned 原样保留)。
|
||||
- 图钉写入走 `src/client/plugin-settings.ts` 的 `updatePluginSettings`:Promise 链串行化 + `api.settingsUpdate` 后 `store.setPrefs(parsePrefs(view.value))`,避免快速连点丢写。(设置弹窗内部有自己独立的串行 commit,二者极少重叠。)
|
||||
|
||||
## 宿主路由
|
||||
|
||||
`POST /sidebar/api/open.external`(`src/index.ts` `buildApi`,与其他方法同 fence + 错误包裹):
|
||||
|
||||
```ts
|
||||
{ action: 'reveal', path } // 在文件管理器中显示/选中
|
||||
{ action: 'url', url } // 交给注册的协议处理器
|
||||
```
|
||||
|
||||
- `src/open-external.ts`:纯函数 `revealCommand` / `urlCommand`(platform 可注入,单测覆盖三平台)+ `launchExternal`(`spawn(..., { detached: true, stdio: 'ignore' })` + `unref`)。
|
||||
- `reveal`:darwin `open -R`;win32 `explorer.exe /select,`;linux `xdg-open <父目录>`(无统一 select 协议,KISS 打开所在目录)。
|
||||
- `url`:darwin `open`;win32 `rundll32 url.dll,FileProtocolHandler`(备选 `cmd /c start ""`);linux `xdg-open`。
|
||||
- 校验:`reveal` 路径必须绝对(`requireAbsolute`);`url` 必须是 `scheme://` 自定义协议(拒绝 http/https)。
|
||||
- **Windows 平台命令为约定实现,需在 Windows 实机验证**(本机为 macOS;CI 覆盖 linux)。
|
||||
|
||||
## 已知限制
|
||||
|
||||
- 路径含 `#`/`?` 的文件名经 URL 打开可能被浏览器当作 fragment/query(不处理,注释说明)。
|
||||
- 编辑器未安装/协议未注册:由 OS 弹提示或静默失败,不做安装检测。
|
||||
- 自定义编辑器仅支持 URL 模板式,不支持 CLI 命令式;无 `{dir}` 占位符。
|
||||
- Linux reveal 退化为打开所在目录(精确 select 需要各文件管理器私有协议)。
|
||||
- SSH 模式为**全局**(非每会话):DSH 无远程会话概念,文件树路径即宿主路径;该模型对应"DSH 跑在远端开发机上"的场景。
|
||||
|
||||
## 测试
|
||||
|
||||
- `tests/open-with.spec.ts`:解析容错、目标解析与 SSH 过滤、URL 构建(本地/SSH/custom/坏模板)、校验器。
|
||||
- `tests/open-external.spec.ts`:三平台命令表、URL 校验、spawn 前校验。
|
||||
- `tests/file-tree-open-with.spec.tsx`(jsdom):右键 → 子菜单/图钉;pin 不选中不关闭;选子项回调关闭菜单;SSH 标签后缀;未接线隐藏。
|
||||
- `tests/open-with-settings.spec.tsx`:SSH 输入、添加/删除(删除剪枝 pinned)、无效提示、VSCode 系开关。
|
||||
- `tests/side-card-section.spec.tsx`:`SettingsBody` 行列表 + render 共存、仅 render 时不变。
|
||||
- `tests/builtins.spec.ts`:editor 描述符断言补 `settings.render`。
|
||||
- 全量 `pnpm test`(810 passed)、`pnpm typecheck`、`pnpm build` 全绿;未跑挂载冒烟(由 CI plugin-mount 门禁覆盖)。
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "dsh-external/dsh-better-sidebar",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.2",
|
||||
"main": "./lib/index.js",
|
||||
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
|
||||
"engines": {
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dsh-better-sidebar",
|
||||
"version": "0.15.0",
|
||||
"version": "0.15.2",
|
||||
"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": {
|
||||
@@ -96,8 +96,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-subagent": "^0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-settings": "^0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-subagent": "^0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-tools": "^0.1.0-rc.8",
|
||||
"cordis": "^4.0.0-rc.8",
|
||||
"react": "^18.2.0",
|
||||
@@ -133,6 +133,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"mermaid": "^11.16.1",
|
||||
"node-pty": "^1.1.0",
|
||||
"react-icons": "5.7.0",
|
||||
"rxjs": "^7.8.2",
|
||||
"schemastery": "^3.18.0",
|
||||
"ws": "^8.18.0"
|
||||
|
||||
Generated
+12
@@ -83,6 +83,9 @@ importers:
|
||||
node-pty:
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0
|
||||
react-icons:
|
||||
specifier: 5.7.0
|
||||
version: 5.7.0(react@18.2.0)
|
||||
rxjs:
|
||||
specifier: ^7.8.2
|
||||
version: 7.8.2
|
||||
@@ -2302,6 +2305,11 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^18.2.0
|
||||
|
||||
react-icons@5.7.0:
|
||||
resolution: {integrity: sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==}
|
||||
peerDependencies:
|
||||
react: '*'
|
||||
|
||||
react@18.2.0:
|
||||
resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -5068,6 +5076,10 @@ snapshots:
|
||||
react: 18.2.0
|
||||
scheduler: 0.23.0
|
||||
|
||||
react-icons@5.7.0(react@18.2.0):
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
|
||||
react@18.2.0:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
|
||||
@@ -22,9 +22,9 @@ import {
|
||||
IconChevronRightOutline14,
|
||||
IconLinkOutline14,
|
||||
IconRefreshOutline14,
|
||||
IconRightUpOutline16,
|
||||
IconWarningOutline16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { VscLinkExternal } from 'react-icons/vsc'
|
||||
import { api } from './api.ts'
|
||||
import { embeddabilityOf, normalizeBrowserUrl } from './browser.ts'
|
||||
import { patchTab } from './state.ts'
|
||||
@@ -186,7 +186,7 @@ export function BrowserView(props: TabComponentProps) {
|
||||
if (url !== undefined) window.open(url, '_blank', 'noopener')
|
||||
}}
|
||||
>
|
||||
<IconRightUpOutline16 size={15} />
|
||||
<VscLinkExternal size={15} />
|
||||
</button>
|
||||
</div>
|
||||
{message !== null && <div className={css.browserMessage}>{message}</div>}
|
||||
|
||||
+48
-9
@@ -9,7 +9,7 @@
|
||||
* The parser is a pure function (`parseUnifiedDiff`) so the interesting
|
||||
* cases are unit-tested without a DOM.
|
||||
*/
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
@@ -171,6 +171,25 @@ function fileTag(file: DiffFile): string | null {
|
||||
/** Cap the flattened rows like DiffBlock: head + tail, expand button between. */
|
||||
const MAX_DIFF_ROWS = 500
|
||||
|
||||
const TEST_PATH = /(^|\/)(?:__tests__|tests?|specs?|fixtures?|mocks?|snapshots?)(?:\/|$)|\.(?:test|spec)\.[^/]+$/i
|
||||
const DOC_PATH = /(^|\/)(?:docs?|documentation)(?:\/|$)|(^|\/)(?:readme|changelog|contributing|license|authors|notice)(?:\.[^/]*)?$/i
|
||||
const GENERATED_PATH = /(^|\/)(?:dist|build|coverage|generated|vendor|node_modules)(?:\/|$)|(^|\/)(?:package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?|composer\.lock|cargo\.lock|poetry\.lock)$/i
|
||||
const SOURCE_PATH = /\.(?:js|jsx|mjs|cjs|ts|tsx|mts|cts|py|pyw|rb|php|java|kt|kts|scala|go|rs|swift|c|h|cc|cpp|cxx|hpp|hh|hxx|cs|fs|fsx|vb|dart|lua|r|ex|exs|erl|hrl|clj|cljs|cljc|groovy|sh|bash|zsh|fish|ps1|sql|vue|svelte|astro|html|htm|css|scss|sass|less)$/i
|
||||
|
||||
/** Source files open by default; tests, docs, generated files and unknown types stay folded. */
|
||||
function defaultExpandedFiles(files: DiffFile[]): Set<number> {
|
||||
const expanded = new Set<number>()
|
||||
files.forEach((file, index) => {
|
||||
const path = displayPath(file.newPath === '/dev/null' ? file.oldPath : file.newPath)
|
||||
if (!file.binary && file.hunks.length > 0
|
||||
&& !TEST_PATH.test(path) && !DOC_PATH.test(path) && !GENERATED_PATH.test(path)
|
||||
&& SOURCE_PATH.test(path)) {
|
||||
expanded.add(index)
|
||||
}
|
||||
})
|
||||
return expanded
|
||||
}
|
||||
|
||||
export interface DiffViewProps {
|
||||
/** Unified diff text (`git.diff` or `git.commit-diff` payloads). */
|
||||
diff: string
|
||||
@@ -187,22 +206,25 @@ export function DiffView({ diff, untrackedPath, untrackedContent }: DiffViewProp
|
||||
return parseUnifiedDiff(diff)
|
||||
}, [diff, untrackedPath, untrackedContent])
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [expandedFiles, setExpandedFiles] = useState<Set<number>>(() => defaultExpandedFiles(parsed.files))
|
||||
|
||||
useEffect(() => { setExpandedFiles(defaultExpandedFiles(parsed.files)) }, [parsed])
|
||||
|
||||
// Flatten into display rows so the cap can slice a single list.
|
||||
const rows = useMemo(() => {
|
||||
const out: Array<{ key: string; file: DiffFile; type: 'path' | 'hunk' | 'line'; hunk?: DiffHunk; line?: DiffLine }> = []
|
||||
const out: Array<{ key: string; file: DiffFile; fileIndex: number; type: 'path' | 'hunk' | 'line'; hunk?: DiffHunk; line?: DiffLine }> = []
|
||||
parsed.files.forEach((file, fileIndex) => {
|
||||
out.push({ key: `f${fileIndex}`, file, type: 'path' })
|
||||
if (file.binary) return
|
||||
out.push({ key: `f${fileIndex}`, file, fileIndex, type: 'path' })
|
||||
if (file.binary || !expandedFiles.has(fileIndex)) return
|
||||
file.hunks.forEach((hunk, hunkIndex) => {
|
||||
out.push({ key: `f${fileIndex}h${hunkIndex}`, file, type: 'hunk', hunk })
|
||||
out.push({ key: `f${fileIndex}h${hunkIndex}`, file, fileIndex, type: 'hunk', hunk })
|
||||
hunk.lines.forEach((line, lineIndex) => {
|
||||
out.push({ key: `f${fileIndex}h${hunkIndex}l${lineIndex}`, file, type: 'line', hunk, line })
|
||||
out.push({ key: `f${fileIndex}h${hunkIndex}l${lineIndex}`, file, fileIndex, type: 'line', hunk, line })
|
||||
})
|
||||
})
|
||||
})
|
||||
return out
|
||||
}, [parsed])
|
||||
}, [parsed, expandedFiles])
|
||||
|
||||
const hidden = rows.length - MAX_DIFF_ROWS
|
||||
const capped = hidden > 0 && !expanded
|
||||
@@ -218,12 +240,29 @@ export function DiffView({ diff, untrackedPath, untrackedContent }: DiffViewProp
|
||||
const tag = fileTag(row.file)
|
||||
const from = displayPath(row.file.oldPath)
|
||||
const to = displayPath(row.file.newPath)
|
||||
const expandable = !row.file.binary && row.file.hunks.length > 0
|
||||
const fileExpanded = expandedFiles.has(row.fileIndex)
|
||||
return (
|
||||
<div key={row.key} className={css.gitDiffFile}>
|
||||
<button
|
||||
key={row.key}
|
||||
type="button"
|
||||
className={css.gitDiffFile}
|
||||
disabled={!expandable}
|
||||
aria-expanded={expandable ? fileExpanded : undefined}
|
||||
onClick={() => {
|
||||
setExpandedFiles(current => {
|
||||
const next = new Set(current)
|
||||
if (next.has(row.fileIndex)) next.delete(row.fileIndex)
|
||||
else next.add(row.fileIndex)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
>
|
||||
{expandable && <span aria-hidden="true" className={clsx(css.gitDiffFileChevron, fileExpanded && css.gitDiffFileChevronExpanded)}>›</span>}
|
||||
<span className={css.gitDiffFilePath}>{to}</span>
|
||||
{from !== to && <span className={css.gitDiffFileOld}>← {from}</span>}
|
||||
{tag !== null && <span className={css.gitDiffFileTag}>{tag}</span>}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
if (row.type === 'hunk') {
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* The strategy dispatch is pure (planFirstMatch / planFsReadOutcome in
|
||||
* editor-load.ts); this component only wires it to the host APIs.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import { createElement } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconCheckOutline16, IconFolderOpen16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
@@ -31,7 +31,10 @@ 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 { createFrameBatcher } from './frame-batcher.ts'
|
||||
import { openSidebarFile } from './intercept.tsx'
|
||||
import { openWithSshActive, openWithUrl, parseOpenWithConfig, resolveOpenWithTargets } from './open-with.ts'
|
||||
import { updatePluginSettings } from './plugin-settings.ts'
|
||||
import { TreePanel } from './TreePanel.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import { relativeTo } from './paths.ts'
|
||||
@@ -51,6 +54,10 @@ const TREE_WIDTH_DEFAULT = 240
|
||||
const TREE_WIDTH_MIN = 160
|
||||
const TREE_WIDTH_MAX = 480
|
||||
|
||||
/** Stable empty blob for the editor pluginSettings read (a fresh `?? {}`
|
||||
* would change identity every snapshot and loop useSyncExternalStore). */
|
||||
const EMPTY_PLUGIN_BLOB: Record<string, unknown> = {}
|
||||
|
||||
/** 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)
|
||||
@@ -105,6 +112,16 @@ export function EditorHost(props: {
|
||||
useCallback((callback: () => void) => store.subscribe(callback), [store]),
|
||||
useCallback(() => store.getSnapshot().prefs.editorExplorer, [store]),
|
||||
)
|
||||
// The file tree's "open with" configuration (pluginSettings['editor']): a
|
||||
// blob subscription, so a pin click or a settings-page edit re-renders the
|
||||
// menu immediately. The parsed config also drives which targets are shown
|
||||
// (SSH mode hides the host-local ones).
|
||||
const editorBlob = useSyncExternalStore(
|
||||
useCallback((callback: () => void) => store.subscribe(callback), [store]),
|
||||
useCallback(() => store.getSnapshot().prefs.pluginSettings['editor'] ?? EMPTY_PLUGIN_BLOB, [store]),
|
||||
)
|
||||
const openWithConfig = useMemo(() => parseOpenWithConfig(editorBlob.openWith), [editorBlob])
|
||||
const openWithTargets = useMemo(() => resolveOpenWithTargets(openWithConfig), [openWithConfig])
|
||||
// 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 === ''
|
||||
@@ -149,6 +166,39 @@ export function EditorHost(props: {
|
||||
})
|
||||
}
|
||||
|
||||
/** The context menu's "open with" action: reveal the path in the OS file
|
||||
* manager, or hand the target's URL (a local `file` URL, or the SSH-remote
|
||||
* form for VSCode-family editors in remote mode) to the host's external
|
||||
* opener. Failures are logged only — a missing handler is the OS's
|
||||
* dialog, not a sidebar error. */
|
||||
const openWith = (targetId: string, absolute: string): void => {
|
||||
const target = openWithTargets.find(item => item.id === targetId)
|
||||
if (target === undefined) return
|
||||
if (target.kind === 'reveal') {
|
||||
void api.openExternal({ action: 'reveal', path: absolute }).catch(
|
||||
(error: unknown) => { console.error('open external failed', error) },
|
||||
)
|
||||
return
|
||||
}
|
||||
const url = openWithUrl(target, absolute, openWithConfig)
|
||||
if (url === undefined) return
|
||||
void api.openExternal({ action: 'url', url }).catch(
|
||||
(error: unknown) => { console.error('open external failed', error) },
|
||||
)
|
||||
}
|
||||
|
||||
/** Toggle one target's pinned state. The write is serialized (see
|
||||
* plugin-settings.ts) and the menu re-renders when the store prefs land. */
|
||||
const toggleOpenWithPin = (targetId: string): void => {
|
||||
updatePluginSettings(store, 'editor', (blob) => {
|
||||
const config = parseOpenWithConfig(blob.openWith)
|
||||
const pinned = config.pinned.includes(targetId)
|
||||
? config.pinned.filter(id => id !== targetId)
|
||||
: [...config.pinned, targetId]
|
||||
return { ...blob, openWith: { ...config, pinned } }
|
||||
})
|
||||
}
|
||||
|
||||
// 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).
|
||||
@@ -165,8 +215,16 @@ export function EditorHost(props: {
|
||||
// (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.
|
||||
// Moves are BATCHED per frame (createFrameBatcher): applying every
|
||||
// pointermove is a setState that re-renders this host AND the editor
|
||||
// viewer below it (CodeMirror re-lays out on the width change) at event
|
||||
// cadence — the visible drag lag on slower CPUs (#315). The batch applies
|
||||
// the latest width once per frame; release flushes it and commits.
|
||||
const [dragWidth, setDragWidth] = useState<number | null>(null)
|
||||
const dragRef = useRef<{ startX: number; startWidth: number } | null>(null)
|
||||
const pendingWidthRef = useRef(0)
|
||||
const dragBatcher = useRef(createFrameBatcher()).current
|
||||
useEffect(() => () => dragBatcher.dispose(), [dragBatcher])
|
||||
const treeWidth = dragWidth ?? treeWidthOf(tab)
|
||||
|
||||
const onResizeStart = (event: React.PointerEvent): void => {
|
||||
@@ -178,11 +236,17 @@ export function EditorHost(props: {
|
||||
const onResizeMove = (event: React.PointerEvent): void => {
|
||||
const drag = dragRef.current
|
||||
if (drag === null) return
|
||||
setDragWidth(clampTreeWidth(drag.startWidth + (drag.startX - event.clientX)))
|
||||
pendingWidthRef.current = clampTreeWidth(drag.startWidth + (drag.startX - event.clientX))
|
||||
dragBatcher.schedule(() => setDragWidth(pendingWidthRef.current))
|
||||
}
|
||||
const onResizeEnd = (event: React.PointerEvent): void => {
|
||||
const drag = dragRef.current
|
||||
if (drag === null) return
|
||||
// Flush the last pending frame (a release can land with the final move
|
||||
// still queued; without the flush a stray frame would re-apply the
|
||||
// drag width AFTER the null below). Both setStates batch into this same
|
||||
// event, so the committed treeWidth wins visually.
|
||||
dragBatcher.flushNow()
|
||||
dragRef.current = null
|
||||
setDragWidth(null)
|
||||
const finalWidth = clampTreeWidth(drag.startWidth + (drag.startX - event.clientX))
|
||||
@@ -272,6 +336,11 @@ export function EditorHost(props: {
|
||||
onOpenFile={openFile}
|
||||
onOpenFileNewTab={openFileNewTab}
|
||||
onOpenFileSide={openFileSide}
|
||||
openWithTargets={openWithTargets}
|
||||
openWithPinned={openWithConfig.pinned}
|
||||
openWithSsh={openWithSshActive(openWithConfig)}
|
||||
onOpenWith={openWith}
|
||||
onToggleOpenWithPin={toggleOpenWithPin}
|
||||
onReferenceFile={onReferenceFile}
|
||||
/>
|
||||
</div>
|
||||
@@ -365,6 +434,11 @@ export function EditorHost(props: {
|
||||
onOpenFile={openFile}
|
||||
onOpenFileNewTab={openFileNewTab}
|
||||
onOpenFileSide={openFileSide}
|
||||
openWithTargets={openWithTargets}
|
||||
openWithPinned={openWithConfig.pinned}
|
||||
openWithSsh={openWithSshActive(openWithConfig)}
|
||||
onOpenWith={openWith}
|
||||
onToggleOpenWithPin={toggleOpenWithPin}
|
||||
onReferenceFile={onReferenceFile}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+128
-13
@@ -24,11 +24,14 @@ import { useCallback, useEffect, useRef, useState, type DragEvent, type MouseEve
|
||||
import { createPortal } from 'react-dom'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16, IconFolderClose16, IconFolderOpen16,
|
||||
IconLinkOutline16, Menu, writeClipboard,
|
||||
IconChevronRightOutline14, IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16,
|
||||
IconLinkOutline16, Menu, type MenuEntry, type MenuItem, writeClipboard,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { SiCursor, SiZedindustries } from 'react-icons/si'
|
||||
import { VscFile, VscFolder, VscFolderOpened, VscLinkExternal, VscPin, VscPinned } from 'react-icons/vsc'
|
||||
import { api, downloadUrl, type FsEntry } from './api.ts'
|
||||
import { IconUploadOutline16 } from './icons.tsx'
|
||||
import { IconUploadOutline16, IconVscode16 } from './icons.tsx'
|
||||
import type { OpenWithTarget } from './open-with.ts'
|
||||
import { relativeTo } from './paths.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { uploadItemsFromDrop, uploadItemsFromFiles, type UploadItem } from './upload.ts'
|
||||
@@ -52,6 +55,13 @@ function parentOf(path: string): string {
|
||||
return at <= 0 ? path : path.slice(0, at)
|
||||
}
|
||||
|
||||
/** Only OS file drags belong to the upload surface; in-app drags (tab reorder,
|
||||
* split zones) must pass through untouched to the pane's tab-drop handling
|
||||
* (mirror of Sidebar.tsx's panel-host shield gate). */
|
||||
function isFileDrag(event: DragEvent): boolean {
|
||||
return event.dataTransfer?.types.includes('Files') ?? false
|
||||
}
|
||||
|
||||
/** How long the row's "copied" label stays after a successful write. */
|
||||
const COPIED_MS = 1200
|
||||
|
||||
@@ -102,6 +112,19 @@ export function FileTree(props: {
|
||||
onOpenFileNewTab?: (path: string) => void
|
||||
/** Context-menu "open to the side" (file rows; absent → no entry). */
|
||||
onOpenFileSide?: (path: string) => void
|
||||
/**
|
||||
* The "open with" menu: resolved external targets (already SSH-filtered
|
||||
* and in menu order). Absent → the whole section is hidden.
|
||||
*/
|
||||
openWithTargets?: OpenWithTarget[]
|
||||
/** Ids of targets pinned to the menu's top level (subset of the ids). */
|
||||
openWithPinned?: string[]
|
||||
/** Whether the workspace is remote (appends the SSH hint to target labels). */
|
||||
openWithSsh?: boolean
|
||||
/** Open one target externally (reveal or URL — the caller decides). */
|
||||
onOpenWith?: (targetId: string, path: string) => void
|
||||
/** Toggle one target's pinned state (the submenu row's pushpin). */
|
||||
onToggleOpenWithPin?: (targetId: string) => void
|
||||
/** Insert `@<relative path>` into the composer draft. */
|
||||
onReferenceFile: (path: string) => void
|
||||
/** Bump to wipe the level cache and reload the visible set. */
|
||||
@@ -111,7 +134,7 @@ export function FileTree(props: {
|
||||
/** True while an upload is in flight (drops are ignored). */
|
||||
busy: boolean
|
||||
}) {
|
||||
const { sessionId, cwd, expanded, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, onReferenceFile, refreshTick, onUploadRequest, busy } = props
|
||||
const { sessionId, cwd, expanded, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, refreshTick, onUploadRequest, busy } = props
|
||||
const [data, setData] = useState<Record<string, LevelData>>({})
|
||||
const dataRef = useRef(data)
|
||||
/** The row whose path was just copied ("copied" label replaces its button). */
|
||||
@@ -160,12 +183,14 @@ export function FileTree(props: {
|
||||
})
|
||||
}
|
||||
const handleBodyDrop = (event: DragEvent): void => {
|
||||
if (!isFileDrag(event)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
resetDrop()
|
||||
if (cwd !== undefined) reportDrop(cwd, event.dataTransfer)
|
||||
}
|
||||
const handleDirDrop = (event: DragEvent, dir: string): void => {
|
||||
if (!isFileDrag(event)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
resetDrop()
|
||||
@@ -176,6 +201,7 @@ export function FileTree(props: {
|
||||
handleDirDrop(event, parentOf(path))
|
||||
}
|
||||
const handleBodyDragEnter = (event: DragEvent): void => {
|
||||
if (!isFileDrag(event)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
dropDepth.current += 1
|
||||
@@ -195,6 +221,7 @@ export function FileTree(props: {
|
||||
setDropRect(null)
|
||||
}
|
||||
const handleBodyDragOver = (event: DragEvent): void => {
|
||||
if (!isFileDrag(event)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.dataTransfer.dropEffect = busy ? 'none' : 'copy'
|
||||
@@ -205,6 +232,7 @@ export function FileTree(props: {
|
||||
setDropTarget(null)
|
||||
}
|
||||
const handleRowDragOver = (event: DragEvent, dir: string): void => {
|
||||
if (!isFileDrag(event)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.dataTransfer.dropEffect = busy ? 'none' : 'copy'
|
||||
@@ -295,6 +323,88 @@ export function FileTree(props: {
|
||||
anchor.remove()
|
||||
}
|
||||
|
||||
/** The menu label of one open target: a locale key for the built-ins, the
|
||||
* user's own name for custom editors, plus the SSH hint in remote mode. */
|
||||
const openWithLabelOf = (target: OpenWithTarget): string => {
|
||||
const name = target.nameKey !== undefined ? t(target.nameKey) : target.name
|
||||
return openWithSsh === true && !target.localOnly ? `${name}${t('openWithSshSuffix')}` : name
|
||||
}
|
||||
|
||||
/**
|
||||
* The "open with" menu entries: the pinned targets as DIRECT rows, then
|
||||
* the parent row with every target as a nested submenu. Both only render
|
||||
* when the caller wired the feature and at least one target is visible.
|
||||
*/
|
||||
const openWithEntries = (): MenuEntry[] => {
|
||||
if (openWithTargets === undefined || onOpenWith === undefined || openWithTargets.length === 0) return []
|
||||
const pinnedIds = openWithPinned ?? []
|
||||
/** Brand marks for the built-ins (monochrome silhouettes, currentColor);
|
||||
* reveal gets the folder glyph, custom editors a generic code mark. */
|
||||
const itemIcon = (target: OpenWithTarget): ReactNode => {
|
||||
if (target.kind === 'reveal') return <VscFolderOpened size={16} />
|
||||
if (target.id === 'vscode') return <IconVscode16 size={16} />
|
||||
if (target.id === 'cursor') return <SiCursor size={16} />
|
||||
if (target.id === 'zed') return <SiZedindustries size={16} />
|
||||
return <IconCodeOutline16 size={16} />
|
||||
}
|
||||
const pinned = openWithTargets
|
||||
.filter(target => pinnedIds.includes(target.id))
|
||||
.map<MenuItem>(target => ({
|
||||
id: `open-with:${target.id}`,
|
||||
label: openWithLabelOf(target),
|
||||
icon: itemIcon(target),
|
||||
}))
|
||||
const submenu = openWithTargets.map<MenuItem>(target => {
|
||||
const pinnedNow = pinnedIds.includes(target.id)
|
||||
return {
|
||||
id: `open-with:${target.id}`,
|
||||
label: (
|
||||
<span className={css.openWithLabel}>
|
||||
<span className={css.openWithName}>{openWithLabelOf(target)}</span>
|
||||
{/* The pushpin: a span (never a button — the Menu row itself is
|
||||
a button, so a nested interactive element would be invalid).
|
||||
Clicking it pins/unpins the target at the menu's top level
|
||||
WITHOUT selecting the row: the pin stops propagation, so the
|
||||
menu stays open and the icon flips on the next render. */}
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
className={clsx(css.openWithPin, pinnedNow && css.openWithPinActive)}
|
||||
aria-label={pinnedNow ? t('unpinOpenWith') : t('pinOpenWith')}
|
||||
title={pinnedNow ? t('unpinOpenWith') : t('pinOpenWith')}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onToggleOpenWithPin?.(target.id)
|
||||
}}
|
||||
>
|
||||
{pinnedNow ? <VscPinned size={14} /> : <VscPin size={14} />}
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
icon: itemIcon(target),
|
||||
}
|
||||
})
|
||||
return [
|
||||
...pinned,
|
||||
...(pinned.length > 0 ? [{ id: 'open-with-sep', type: 'separator' } as MenuEntry] : []),
|
||||
{
|
||||
id: 'open-with-menu',
|
||||
// The primitives Menu renders no chevron for submenu parents — the
|
||||
// trailing arrow is supplied inside the label (full-width flex row,
|
||||
// right-aligned), matching how the submenu rows right-align the pin.
|
||||
label: (
|
||||
<span className={css.openWithLabel}>
|
||||
<span className={css.openWithName}>{t('openWithMenu')}</span>
|
||||
<IconChevronRightOutline14 size={14} className={css.openWithChevron} aria-hidden />
|
||||
</span>
|
||||
),
|
||||
icon: <VscLinkExternal size={16} />,
|
||||
submenu,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const root = cwd
|
||||
|
||||
const renderLevel = (dir: string, depth: number): ReactNode => {
|
||||
@@ -334,7 +444,7 @@ export function FileTree(props: {
|
||||
onDrop={(event) => { handleDirDrop(event, entry.path) }}
|
||||
onContextMenu={(event) => { openRowMenu(event, entry.path, true) }}
|
||||
>
|
||||
{isOpen ? <IconFolderOpen16 size={14} /> : <IconFolderClose16 size={14} />}
|
||||
{isOpen ? <VscFolderOpened size={14} /> : <VscFolder size={14} />}
|
||||
<span className={css.explorerName}>{entry.name}</span>
|
||||
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
|
||||
{rowActions(entry)}
|
||||
@@ -365,7 +475,7 @@ export function FileTree(props: {
|
||||
onDrop={(event) => { handleFileDrop(event, entry.path) }}
|
||||
onContextMenu={(event) => { openRowMenu(event, entry.path, false) }}
|
||||
>
|
||||
<IconCodeOutline16 size={14} />
|
||||
<VscFile size={14} />
|
||||
<span className={css.explorerName}>{entry.name}</span>
|
||||
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
|
||||
{rowActions(entry)}
|
||||
@@ -394,7 +504,7 @@ export function FileTree(props: {
|
||||
onDrop={(event) => { handleDirDrop(event, root) }}
|
||||
onContextMenu={(event) => { openRowMenu(event, root, true) }}
|
||||
>
|
||||
<IconFolderOpen16 size={14} />
|
||||
<VscFolderOpened size={14} />
|
||||
<span className={css.explorerName}>{baseName(root)}</span>
|
||||
{copiedPath === root
|
||||
? <span className={css.explorerCopied}>{t('copied')}</span>
|
||||
@@ -486,21 +596,22 @@ export function FileTree(props: {
|
||||
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} /> }]
|
||||
? [{ id: 'open-new-tab', label: t('openFileNewTab'), icon: <IconCodeOutline16 size={16} /> }]
|
||||
: []),
|
||||
...(rowMenu?.isDir === false && onOpenFileSide !== undefined
|
||||
? [{ id: 'open-side', label: t('openFileSide'), icon: <IconFolderOpen16 size={14} /> }]
|
||||
? [{ id: 'open-side', label: t('openFileSide'), icon: <VscFolderOpened size={16} /> }]
|
||||
: []),
|
||||
...openWithEntries(),
|
||||
// Download applies to files only (the host route refuses directories).
|
||||
...(rowMenu?.isDir === false
|
||||
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutline16 size={14} /> }]
|
||||
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutline16 size={16} /> }]
|
||||
: []),
|
||||
// Upload into a directory (incl. the workspace root row).
|
||||
...(rowMenu?.isDir === true
|
||||
? [{ id: 'upload-here', label: t('uploadHere'), icon: <IconUploadOutline16 size={14} /> }]
|
||||
? [{ id: 'upload-here', label: t('uploadHere'), icon: <IconUploadOutline16 size={16} /> }]
|
||||
: []),
|
||||
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={14} /> },
|
||||
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={14} /> },
|
||||
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={16} /> },
|
||||
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={16} /> },
|
||||
]}
|
||||
onSelect={(id) => {
|
||||
const target = rowMenu
|
||||
@@ -514,6 +625,10 @@ export function FileTree(props: {
|
||||
onOpenFileSide?.(target.path)
|
||||
return
|
||||
}
|
||||
if (id.startsWith('open-with:')) {
|
||||
onOpenWith?.(id.slice('open-with:'.length), target.path)
|
||||
return
|
||||
}
|
||||
if (id === 'download') {
|
||||
downloadFile(target.path)
|
||||
return
|
||||
|
||||
@@ -893,3 +893,68 @@
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
padding: 2px 2px 0;
|
||||
}
|
||||
|
||||
/* Open-with settings panel: one custom editor row (name + URL template +
|
||||
VSCode-family checkbox + remove) and the row-validity hint. */
|
||||
.openWithEditorRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.5fr auto auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.openWithEditorInput,
|
||||
.openWithEditorTemplate {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 5px 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: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.openWithEditorInput:focus-visible,
|
||||
.openWithEditorTemplate:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.openWithFamily {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.openWithRemove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.openWithRemove:hover {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
}
|
||||
|
||||
.openWithHint {
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
@@ -492,12 +492,13 @@ function SelectRow(props: {
|
||||
|
||||
/**
|
||||
* The secondary settings popup body of one feature (tab or viewer):
|
||||
* - `settings.render` (custom panel) when declared — rendered with the
|
||||
* shared store/service, the live prefs, the descriptor's own plugin
|
||||
* settings blob, a persistence helper, and a close callback;
|
||||
* - otherwise the host-prefs `toggles` rows, then the plugin-owned
|
||||
* `pluginToggles` rows (their values live in `pluginSettings[feature.id]`,
|
||||
* projected onto the prefs face so the shared row renderer reads them).
|
||||
* - the host-prefs `toggles` rows, then the plugin-owned `pluginToggles`
|
||||
* rows (their values live in `pluginSettings[feature.id]`, projected onto
|
||||
* the prefs face so the shared row renderer reads them);
|
||||
* - `settings.render` (custom panel) AFTER those rows when declared — the
|
||||
* custom panel is an extension of the row list, not a replacement, so a
|
||||
* feature can keep its declarative rows (e.g. the editor's
|
||||
* open-behavior picker) and still ship a custom configuration area.
|
||||
*/
|
||||
export function SettingsBody(props: {
|
||||
feature: TabDescriptor | FileViewerDescriptor
|
||||
@@ -515,48 +516,50 @@ export function SettingsBody(props: {
|
||||
}) {
|
||||
const { feature, prefs, store, service, onToggle, onCommit, onSelectValue, onPluginToggle, onPluginCommit, onPluginSelectValue, onPluginWrite, onClose } = props
|
||||
const render = feature.settings?.render
|
||||
if (render !== undefined) {
|
||||
return (
|
||||
<SettingsRender
|
||||
render={render}
|
||||
renderProps={{
|
||||
store,
|
||||
service,
|
||||
prefs,
|
||||
pluginSettings: prefs.pluginSettings[feature.id] ?? {},
|
||||
updatePluginSetting: onPluginWrite,
|
||||
close: onClose,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const toggles = feature.settings?.toggles ?? []
|
||||
const pluginToggles = feature.settings?.pluginToggles ?? []
|
||||
if (toggles.length === 0 && pluginToggles.length === 0) return null
|
||||
if (render === undefined && toggles.length === 0 && pluginToggles.length === 0) return null
|
||||
// Plugin rows read their values from the descriptor's OWN blob through
|
||||
// an explicit value source — no projection onto the prefs face, so a
|
||||
// plugin key can never collide with (or silently read) a host pref of
|
||||
// the same name.
|
||||
const pluginBlob = prefs.pluginSettings[feature.id] ?? {}
|
||||
return (
|
||||
<div className={css.popupRows}>
|
||||
{toggles.length > 0 && (
|
||||
<FeatureSettingsRows
|
||||
toggles={toggles}
|
||||
prefs={prefs}
|
||||
onToggle={onToggle}
|
||||
onCommit={onCommit}
|
||||
onSelectValue={onSelectValue}
|
||||
/>
|
||||
<div>
|
||||
{(toggles.length > 0 || pluginToggles.length > 0) && (
|
||||
<div className={css.popupRows}>
|
||||
{toggles.length > 0 && (
|
||||
<FeatureSettingsRows
|
||||
toggles={toggles}
|
||||
prefs={prefs}
|
||||
onToggle={onToggle}
|
||||
onCommit={onCommit}
|
||||
onSelectValue={onSelectValue}
|
||||
/>
|
||||
)}
|
||||
{pluginToggles.length > 0 && (
|
||||
<FeatureSettingsRows
|
||||
toggles={pluginToggles}
|
||||
prefs={prefs}
|
||||
onToggle={onPluginToggle}
|
||||
onCommit={onPluginCommit}
|
||||
onSelectValue={onPluginSelectValue}
|
||||
valueSource={(key) => pluginBlob[key]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{pluginToggles.length > 0 && (
|
||||
<FeatureSettingsRows
|
||||
toggles={pluginToggles}
|
||||
prefs={prefs}
|
||||
onToggle={onPluginToggle}
|
||||
onCommit={onPluginCommit}
|
||||
onSelectValue={onPluginSelectValue}
|
||||
valueSource={(key) => pluginBlob[key]}
|
||||
{render !== undefined && (
|
||||
<SettingsRender
|
||||
render={render}
|
||||
renderProps={{
|
||||
store,
|
||||
service,
|
||||
prefs,
|
||||
pluginSettings: prefs.pluginSettings[feature.id] ?? {},
|
||||
updatePluginSetting: onPluginWrite,
|
||||
close: onClose,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1045,9 +1048,9 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
feature is open — the Modal primitive runs hooks unconditionally,
|
||||
so a closed-but-mounted Modal would break SSR (and the
|
||||
renderToString spec) under the test dual-react split.
|
||||
Content: `settings.render` (custom panel) when declared, else the
|
||||
host-prefs `toggles` rows followed by the plugin-owned
|
||||
`pluginToggles` rows (their values live in pluginSettings[id]). */}
|
||||
Content: the host-prefs `toggles` rows, the plugin-owned
|
||||
`pluginToggles` rows (their values live in pluginSettings[id]),
|
||||
then the custom `settings.render` panel when declared. */}
|
||||
{settingsFor !== null && (
|
||||
<Modal
|
||||
open
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
* language mirrors the main conversation pane: right-aligned user bubbles
|
||||
* on --dsw-specific-bubble, full-width assistant markdown, a capsule
|
||||
* composer card on --dsw-specific-input-major with a circular accent
|
||||
* send/stop button, and shimmer reserved for the running status line and
|
||||
* executing tools. Motion is crossfade-only (≤200ms entrances, no
|
||||
* springs); reduced-motion disables every loop. All values consume DSW
|
||||
* tokens (fallbacks point at older alias tokens for rc-era hosts). */
|
||||
* send/stop button. Tool calls, thinking and context injections share one
|
||||
* collapsible-line chrome (no cards); shimmer is reserved for generating
|
||||
* states (running status line, streaming rows, the creating hero). Motion
|
||||
* is crossfade-only (≤200ms entrances, no springs); reduced-motion disables
|
||||
* every loop. All values consume DSW tokens (fallbacks point at older alias
|
||||
* tokens for rc-era hosts). */
|
||||
.sidechat {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -172,82 +174,99 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.sidechatReasoning {
|
||||
/* ── collapsible context rows (tool / thinking / injection, Codex-style) ──
|
||||
* One quiet line — chevron + label + one-line summary — that expands into
|
||||
* an indented body hung on a hairline thread. No cards, no fills: hierarchy
|
||||
* rides ink weight alone. Streaming rows shimmer the label (shimmer =
|
||||
* generating); failed rows go danger; reduced-motion stills every loop. */
|
||||
.sidechatRow {
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.sidechatReasoningSummary {
|
||||
display: inline-block;
|
||||
.sidechatRowLine {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 1px 0;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.sidechatReasoningSummary::-webkit-details-marker {
|
||||
.sidechatRowSummary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sidechatRowSummary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidechatReasoningBody {
|
||||
margin-top: 2px;
|
||||
padding-left: 2px;
|
||||
.sidechatRowSummary:hover {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.sidechatRowStatic {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.sidechatRowChevron {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
transition: transform 100ms ease-out;
|
||||
}
|
||||
|
||||
.sidechatRow[open] .sidechatRowChevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.sidechatRowLabel {
|
||||
flex: none;
|
||||
max-width: 60%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.sidechatRowMono {
|
||||
font-family: var(--dsw-font-mono);
|
||||
}
|
||||
|
||||
.sidechatRowMeta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--dsw-font-mono);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.sidechatRowFailed .sidechatRowLabel,
|
||||
.sidechatRowFailed .sidechatRowMeta {
|
||||
color: var(--dsw-alias-danger);
|
||||
}
|
||||
|
||||
.sidechatRowBody {
|
||||
margin: 2px 0 4px 7px;
|
||||
padding: 2px 0 2px 10px;
|
||||
border-left: 1px solid var(--dsw-alias-hairline);
|
||||
}
|
||||
|
||||
.sidechatRowProse {
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidechatTool {
|
||||
align-self: stretch;
|
||||
border: 1px solid var(--dsw-alias-hairline);
|
||||
border-radius: 10px;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidechatToolSummary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.sidechatToolSummary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidechatToolName {
|
||||
font-family: var(--dsw-font-mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Running tool: a light sweep across the summary row (shimmer = generating). */
|
||||
.sidechatToolExecuting {
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
var(--dsw-alias-interactive-bg-hover) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: sidechatSweep 2.6s linear infinite;
|
||||
}
|
||||
|
||||
.sidechatToolFailed {
|
||||
color: var(--dsw-alias-danger);
|
||||
}
|
||||
|
||||
.sidechatToolArgs,
|
||||
.sidechatToolResult {
|
||||
.sidechatRowCode {
|
||||
margin: 0;
|
||||
padding: 6px 10px;
|
||||
border-top: 1px solid var(--dsw-alias-hairline);
|
||||
padding: 4px 0;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
font-family: var(--dsw-font-mono);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
@@ -257,6 +276,25 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidechatRowCode + .sidechatRowCode {
|
||||
border-top: 1px solid var(--dsw-alias-hairline);
|
||||
}
|
||||
|
||||
/* Shimmer sweep text: generating (streaming row labels, creating hero). */
|
||||
.sidechatShimmerText {
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
var(--dsw-alias-label-tertiary) 0%,
|
||||
var(--dsw-alias-label-primary) 50%,
|
||||
var(--dsw-alias-label-tertiary) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
animation: sidechatSweep 2.6s linear infinite;
|
||||
}
|
||||
|
||||
/* ── running status line (above the composer, main-pane style) ──────── */
|
||||
.sidechatStatus {
|
||||
flex: none;
|
||||
@@ -388,9 +426,12 @@
|
||||
animation: none;
|
||||
}
|
||||
.sidechatStatusText,
|
||||
.sidechatToolExecuting {
|
||||
.sidechatShimmerText {
|
||||
animation: none;
|
||||
background-image: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
.sidechatRowChevron {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
+103
-27
@@ -24,6 +24,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconChevronRightOutline14,
|
||||
IconNewChatOutline16,
|
||||
IconPlusOutline16,
|
||||
IconSendOutline16,
|
||||
@@ -43,7 +44,7 @@ import {
|
||||
threadTrailingPending,
|
||||
type SidechatThreadInfo,
|
||||
} from '../sidechat-core.ts'
|
||||
import { collectOwnEvents, transcriptRows, type SidechatTranscriptRow } from './sidechat-transcript.ts'
|
||||
import { collectOwnEvents, toolArgsSummary, transcriptRows, type SidechatTranscriptRow } from './sidechat-transcript.ts'
|
||||
import { api } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import type { SessionScope } from './api.ts'
|
||||
@@ -102,6 +103,7 @@ interface RowLabels {
|
||||
copyLabel: string
|
||||
copiedLabel: string
|
||||
thinkLabel: string
|
||||
injectionLabel: string
|
||||
}
|
||||
|
||||
/** Merge history entries by event seq (newest wins), log order preserved. */
|
||||
@@ -122,48 +124,112 @@ function threadDisplayTitle(title: string): string {
|
||||
return title.startsWith(SIDE_LABEL_PREFIX) ? title.slice(SIDE_LABEL_PREFIX.length) : title
|
||||
}
|
||||
|
||||
/**
|
||||
* One collapsible context row — the shared Codex-style chrome of tool
|
||||
* calls, thinking and context injections: a single quiet line (chevron +
|
||||
* label + one-line summary) that expands into an indented body hung on a
|
||||
* hairline thread. Rows with nothing to reveal render as a static line.
|
||||
*/
|
||||
function CollapsibleRow(props: {
|
||||
label: string
|
||||
meta?: string
|
||||
mono?: boolean
|
||||
streaming?: boolean
|
||||
failed?: boolean
|
||||
children?: React.ReactNode
|
||||
}): React.ReactNode {
|
||||
const label = (
|
||||
<span
|
||||
className={clsx(
|
||||
css.sidechatRowLabel,
|
||||
props.mono === true && css.sidechatRowMono,
|
||||
props.streaming === true && css.sidechatShimmerText,
|
||||
)}
|
||||
>
|
||||
{props.label}
|
||||
</span>
|
||||
)
|
||||
const meta = props.meta !== undefined && props.meta !== ''
|
||||
? <span className={css.sidechatRowMeta}>{props.meta}</span>
|
||||
: null
|
||||
if (props.children === undefined) {
|
||||
return (
|
||||
<div className={clsx(css.sidechatRowLine, css.sidechatRowStatic, props.failed === true && css.sidechatRowFailed)}>
|
||||
{label}
|
||||
{meta}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<details className={css.sidechatRow}>
|
||||
<summary
|
||||
className={clsx(
|
||||
css.sidechatRowLine,
|
||||
css.sidechatRowSummary,
|
||||
props.failed === true && css.sidechatRowFailed,
|
||||
)}
|
||||
>
|
||||
<span className={css.sidechatRowChevron}>
|
||||
<IconChevronRightOutline14 size={12} />
|
||||
</span>
|
||||
{label}
|
||||
{meta}
|
||||
</summary>
|
||||
<div className={css.sidechatRowBody}>{props.children}</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
/** One row renderer (React keys ride the source event seq). */
|
||||
function renderRow(row: SidechatTranscriptRow, labels: RowLabels): React.ReactNode {
|
||||
switch (row.kind) {
|
||||
case 'user':
|
||||
return (
|
||||
<div key={row.seq} className={css.sidechatUser}>
|
||||
<div key={`${row.kind}:${row.seq}`} className={css.sidechatUser}>
|
||||
<MarkdownText text={row.text} codeLabels={labels} />
|
||||
</div>
|
||||
)
|
||||
case 'assistant':
|
||||
return (
|
||||
<div key={row.seq} className={css.sidechatAssistant}>
|
||||
<div key={`${row.kind}:${row.seq}`} className={css.sidechatAssistant}>
|
||||
<MarkdownText text={row.text} codeLabels={labels} />
|
||||
</div>
|
||||
)
|
||||
case 'reasoning':
|
||||
return (
|
||||
<details key={row.seq} className={css.sidechatReasoning}>
|
||||
<summary className={css.sidechatReasoningSummary}>{labels.thinkLabel}</summary>
|
||||
<div className={css.sidechatReasoningBody}>{row.text}</div>
|
||||
</details>
|
||||
<CollapsibleRow
|
||||
key={`${row.kind}:${row.seq}`}
|
||||
label={labels.thinkLabel}
|
||||
streaming={!row.settled}
|
||||
>
|
||||
<div className={css.sidechatRowProse}>{row.text}</div>
|
||||
</CollapsibleRow>
|
||||
)
|
||||
case 'tool':
|
||||
case 'injection':
|
||||
return (
|
||||
<details key={row.seq} className={css.sidechatTool}>
|
||||
<summary
|
||||
className={clsx(
|
||||
css.sidechatToolSummary,
|
||||
row.executing === true && css.sidechatToolExecuting,
|
||||
row.failed && css.sidechatToolFailed,
|
||||
)}
|
||||
>
|
||||
<StateDot
|
||||
state={row.executing === true ? 'ongoing' : row.failed ? 'error' : 'done'}
|
||||
size={8}
|
||||
/>
|
||||
<span className={css.sidechatToolName}>{row.name}</span>
|
||||
</summary>
|
||||
{row.args !== undefined && <pre className={css.sidechatToolArgs}>{row.args}</pre>}
|
||||
{row.resultText !== undefined && <pre className={css.sidechatToolResult}>{row.resultText}</pre>}
|
||||
</details>
|
||||
<CollapsibleRow key={`${row.kind}:${row.seq}`} label={labels.injectionLabel}>
|
||||
<div className={css.sidechatRowProse}>{row.text}</div>
|
||||
</CollapsibleRow>
|
||||
)
|
||||
case 'tool': {
|
||||
const body = (
|
||||
<>
|
||||
{row.args !== undefined && <pre className={css.sidechatRowCode}>{row.args}</pre>}
|
||||
{row.resultText !== undefined && <pre className={css.sidechatRowCode}>{row.resultText}</pre>}
|
||||
</>
|
||||
)
|
||||
return (
|
||||
<CollapsibleRow
|
||||
key={`${row.kind}:${row.seq}`}
|
||||
label={row.name}
|
||||
meta={toolArgsSummary(row.args)}
|
||||
mono
|
||||
streaming={row.executing === true}
|
||||
failed={row.failed}
|
||||
{...(row.args === undefined && row.resultText === undefined ? {} : { children: body })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +242,12 @@ export function SideChatView(props: {
|
||||
}): React.ReactNode {
|
||||
const { ctx, scope, tab, visible } = props
|
||||
const rowLabels = useMemo<RowLabels>(
|
||||
() => ({ copyLabel: t('copy'), copiedLabel: t('copied'), thinkLabel: t('sideChatThink') }),
|
||||
() => ({
|
||||
copyLabel: t('copy'),
|
||||
copiedLabel: t('copied'),
|
||||
thinkLabel: t('sideChatThink'),
|
||||
injectionLabel: t('sideChatInjection'),
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
@@ -448,7 +519,12 @@ export function SideChatView(props: {
|
||||
<div className={css.sidechat}>
|
||||
<div className={css.sidechatHero}>
|
||||
<IconNewChatOutline16 />
|
||||
<div className={css.sidechatHeroTitle}>
|
||||
<div
|
||||
className={clsx(
|
||||
css.sidechatHeroTitle,
|
||||
busy === 'starting' && css.sidechatShimmerText,
|
||||
)}
|
||||
>
|
||||
{busy === 'starting' ? t('sideChatCreating') : t('sideChatEmpty')}
|
||||
</div>
|
||||
<div className={css.sidechatHeroDesc}>{t('sideChatEmptyDesc')}</div>
|
||||
|
||||
+102
-31
@@ -28,7 +28,7 @@
|
||||
* drawer floats). Widening does not migrate back: the tabs keep living in
|
||||
* the right tree.
|
||||
*/
|
||||
import { createElement, useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type ReactNode } from 'react'
|
||||
import { createElement, memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type ReactNode } from 'react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
@@ -52,6 +52,7 @@ import type { TabDragPayload } from './TabBar.tsx'
|
||||
import { relativeTo } from './paths.ts'
|
||||
import { OrphanedTab } from './OrphanedTab.tsx'
|
||||
import { RenderBoundary } from './RenderBoundary.tsx'
|
||||
import { tabContentCompare, type TabContentMemoKey } from './tab-content-memo.ts'
|
||||
import { detectNewDirectSubagent } from './subagent-detect.ts'
|
||||
import { detectNewJob } from './subagent-jobs.ts'
|
||||
import { t } from './locales.ts'
|
||||
@@ -62,6 +63,16 @@ import css from './sidebar.module.css'
|
||||
* (mirror of the terminal view's own cap; the loop restarts on session switch). */
|
||||
const FAILURE_LIMIT = 3
|
||||
|
||||
/**
|
||||
* Subagent auto-open debounce (ms). The host delivers a new child's origin
|
||||
* and its title in SEPARATE frames: a Side Chat thread's first visible
|
||||
* frame still shows a fallback title (no 'Side: ' prefix), so an immediate
|
||||
* 0→N decision mistakes it for a genuine subagent and pops the task page.
|
||||
* The trigger therefore re-evaluates against the live snapshot once the
|
||||
* title frame has had time to land.
|
||||
*/
|
||||
const AUTO_OPEN_DEBOUNCE_MS = 500
|
||||
|
||||
/**
|
||||
* OS file drags over the sidebar belong to the sidebar, not to the chat:
|
||||
* DSH's composer (InputBar) listens for file drags on the DOCUMENT and
|
||||
@@ -103,23 +114,25 @@ function injectUserCss(attr: string, id: string, cssText: string): HTMLStyleElem
|
||||
return tag
|
||||
}
|
||||
|
||||
/** Render the content of one tab (dispatched by type). */
|
||||
function TabContent(props: {
|
||||
tab: SidebarTab
|
||||
sessionId: string
|
||||
cwd: string | undefined
|
||||
expanded: string[]
|
||||
/** Props of one tab's content cell = the memo key (tab-content-memo.ts) plus
|
||||
* the runtime objects/callbacks the cell renders with. The memo comparator
|
||||
* is the pure `tabContentCompare`; anything in the key decides a re-render
|
||||
* must propagate, anything outside it must be a stable object (ctx/store)
|
||||
* or covered by a compared field (paneId covers onOpenDiff's captured
|
||||
* pane; sessionId/cwd cover onReferenceFile). */
|
||||
interface TabContentProps extends TabContentMemoKey {
|
||||
onToggleDir: (path: string) => void
|
||||
onReferenceFile: (path: string) => void
|
||||
ctx: Context
|
||||
store: SidebarStore
|
||||
/** Whether this tab is the active one AND the panel is open (live views pause otherwise). */
|
||||
visible: boolean
|
||||
/** Fired before a topology node jumps to its child session (see Sidebar). */
|
||||
onSubagentJump: (childSessionId: string) => void
|
||||
/** Open a diff tab from the git panel (placement handled by the store). */
|
||||
onOpenDiff: (tab: SidebarTab) => void
|
||||
}) {
|
||||
}
|
||||
|
||||
/** Render the content of one tab (dispatched by type). */
|
||||
const TabContent = memo(function TabContent(props: TabContentProps) {
|
||||
const { tab, sessionId, cwd, expanded, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump, onOpenDiff } = props
|
||||
const scope = { sessionId, cwd }
|
||||
const descriptor = ctx.betterSidebar?.getTab(tab.type)
|
||||
@@ -142,7 +155,7 @@ function TabContent(props: {
|
||||
onToggleDir, onReferenceFile, onOpenDiff, onSubagentJump,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}, tabContentCompare)
|
||||
|
||||
/** The + menu options for the current state, driven by the tab registry.
|
||||
* Hidden tabs (editor/diff) never show; `available` returning false shows
|
||||
@@ -175,6 +188,18 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
)
|
||||
void localeRevision
|
||||
|
||||
// Tab-registry revision: TabContent memo cells must pick up a descriptor
|
||||
// a plugin registers/disposes after mount (the + menu / icons already read
|
||||
// the registry at render). Rare events (plugin (un)mount), so one full
|
||||
// re-render per change is fine — this is what keeps the memoized cells
|
||||
// from going stale, mirroring the localeRevision mechanism above.
|
||||
const [tabsVersion, setTabsVersion] = useState(0)
|
||||
useEffect(() => {
|
||||
const service = ctx.betterSidebar
|
||||
if (service === undefined) return
|
||||
return service.subscribe(() => setTabsVersion(version => version + 1))
|
||||
}, [ctx])
|
||||
|
||||
// Narrow (mobile) viewports collapse the two panels into one: the right
|
||||
// panel becomes a full-width drawer holding BOTH workbenches, the bottom
|
||||
// panel (and its toggle button) disappears, and the layout push is
|
||||
@@ -395,23 +420,45 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
* Switching to a session that already has subagents never triggers — its
|
||||
* baseline starts at the current count — so a deliberate layout is never
|
||||
* fought.
|
||||
*
|
||||
* The decision is DEBOUNCED (AUTO_OPEN_DEBOUNCE_MS): a Side Chat thread
|
||||
* is also a subagent-origin child, and its 'Side: ' title lands one frame
|
||||
* after its origin — an immediate check would misread that first frame as
|
||||
* a new subagent and pop this page on every thread creation. The timer
|
||||
* re-evaluates the ORIGINAL baseline against the live snapshot; by then
|
||||
* the title filter (isSideThreadSummary) sees the settled label.
|
||||
*/
|
||||
const listBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
|
||||
const autoOpenPendingRef = useRef<{ baseline: SidebarSessionList; timer: number } | null>(null)
|
||||
useEffect(() => {
|
||||
const prev = listBaselineRef.current
|
||||
listBaselineRef.current = sessionList
|
||||
if (sessionId === undefined || prev === undefined) return
|
||||
if (autoOpenPendingRef.current !== null) return
|
||||
if (!detectNewDirectSubagent(prev, sessionList, sessionId)) return
|
||||
if (!store.getPrefs().autoOpenSubagent) return
|
||||
if (ctx.betterSidebar?.isTabEnabled('subagent') === false) return
|
||||
store.reduce(s => s.panelOpen ? s : togglePanel(s))
|
||||
// Pin the landing to the right panel: the auto-opened Subagent page must
|
||||
// appear where the panel just expanded, not in a bottom-panel pane the
|
||||
// user last touched.
|
||||
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
|
||||
ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
|
||||
const baseline = prev
|
||||
const timer = window.setTimeout(() => {
|
||||
autoOpenPendingRef.current = null
|
||||
if (!detectNewDirectSubagent(baseline, ctx.sessions.list.getSnapshot(), sessionId)) return
|
||||
if (!store.getPrefs().autoOpenSubagent) return
|
||||
if (ctx.betterSidebar?.isTabEnabled('subagent') === false) return
|
||||
store.reduce(s => s.panelOpen ? s : togglePanel(s))
|
||||
// Pin the landing to the right panel: the auto-opened Subagent page must
|
||||
// appear where the panel just expanded, not in a bottom-panel pane the
|
||||
// user last touched.
|
||||
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
|
||||
ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
|
||||
}, AUTO_OPEN_DEBOUNCE_MS)
|
||||
autoOpenPendingRef.current = { baseline, timer }
|
||||
}, [sessionList, sessionId, store, ctx])
|
||||
|
||||
// A session switch (or unmount) voids any armed auto-open recheck.
|
||||
useEffect(() => () => {
|
||||
const pending = autoOpenPendingRef.current
|
||||
if (pending !== null) window.clearTimeout(pending.timer)
|
||||
autoOpenPendingRef.current = null
|
||||
}, [sessionId])
|
||||
|
||||
/**
|
||||
* Job auto-activation: the moment a NEW background job appears for the
|
||||
* current conversation (a job id the previous snapshot lacked), the
|
||||
@@ -465,7 +512,16 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
// horizontal edges — including the animated margin-right push while the
|
||||
// right panel opens/closes; a frame that never appears keeps the initial
|
||||
// zero-size fallback (the panel renders at 0 width until measured).
|
||||
const [centerRect, setCenterRect] = useState({ left: 0, right: 0 })
|
||||
// The rect lives in a REF (not state): the open/close transition resizes
|
||||
// the center column EVERY frame for its duration, and reacting per frame
|
||||
// with setState re-renders the whole Sidebar (every mounted tab) at
|
||||
// animation cadence — the visible toggle jank (#315). measureCenter
|
||||
// writes the bottom panel's edges directly (same DOM-write pattern as
|
||||
// applyDrag), so the panel still tracks the column per frame with zero
|
||||
// React work; `centerMeasured` flips ONCE to gate the hidden→visible
|
||||
// first-paint fallback.
|
||||
const centerRectRef = useRef({ left: 0, right: 0 })
|
||||
const [centerMeasured, setCenterMeasured] = useState(false)
|
||||
// Refs keep the measure step stable across renders and let it skip work
|
||||
// mid-drag: during a width/corner drag the layout push resizes the center
|
||||
// column every frame, and reacting (setCenterRect → re-render) would
|
||||
@@ -486,13 +542,18 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
return
|
||||
}
|
||||
const rect = col.getBoundingClientRect()
|
||||
// The bottom panel only cares about the horizontal edges: a pure height
|
||||
// change (the bottom panel itself opening/closing) must not re-render,
|
||||
// so keep the previous object when left/right are unchanged.
|
||||
setCenterRect(prev =>
|
||||
prev.left === rect.left && prev.right === rect.right
|
||||
? prev
|
||||
: { left: rect.left, right: rect.right })
|
||||
// Ref + direct DOM write (see the centerRectRef comment): the bottom
|
||||
// panel keeps tracking the center column per frame during the right
|
||||
// panel's open/close animation without re-rendering the shell. The
|
||||
// one-shot measured flip renders the panel visible once (a stale
|
||||
// {0,0} fallback would flash full-width).
|
||||
centerRectRef.current = { left: rect.left, right: rect.right }
|
||||
const bottom = bottomRef.current
|
||||
if (bottom !== null) {
|
||||
bottom.style.setProperty('left', `${rect.left}px`)
|
||||
bottom.style.setProperty('right', `${window.innerWidth - rect.right}px`)
|
||||
}
|
||||
setCenterMeasured(prev => (prev ? prev : true))
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
@@ -539,6 +600,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
let locateFrame: number | null = null
|
||||
const scheduleLocate = (): void => {
|
||||
if (locateFrame !== null) return
|
||||
// Mid-drag every frame writes --dsh-sidebar-* on <html>'s style
|
||||
// attribute, which is the mutation this watcher observes — relocating
|
||||
// per drag frame is pointless (the center column node cannot change
|
||||
// while the pointer is captured) and adds a querySelector to every
|
||||
// frame's budget (#315). The 1.5s retry below still covers any node
|
||||
// swap that somehow lands mid-drag.
|
||||
if (draggingRef.current) return
|
||||
locateFrame = requestAnimationFrame(() => {
|
||||
locateFrame = null
|
||||
locate()
|
||||
@@ -676,7 +744,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
// width (innerWidth - state.width - detailsWidth), so this equals
|
||||
// `width + detailsWidth` — derived from the measured column, keeping the
|
||||
// drag write-only (no React re-render mid-drag).
|
||||
bottomRef.current?.style.setProperty('right', `${(window.innerWidth - centerRect.right) + (width - (state?.width ?? 0))}px`)
|
||||
bottomRef.current?.style.setProperty('right', `${(window.innerWidth - centerRectRef.current.right) + (width - (state?.width ?? 0))}px`)
|
||||
writeGeometry(width, height)
|
||||
}
|
||||
|
||||
@@ -1001,6 +1069,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
const renderTab = (tab: SidebarTab, active: boolean, paneId: string, bottom = false) => (
|
||||
<TabContent
|
||||
tab={tab}
|
||||
paneId={paneId}
|
||||
sessionId={sessionId}
|
||||
cwd={cwd}
|
||||
expanded={state.expanded}
|
||||
@@ -1011,6 +1080,8 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
visible={bottom ? state.bottomOpen && active : state.panelOpen && active}
|
||||
onSubagentJump={(childSessionId) => { subagentJumpRef.current = childSessionId }}
|
||||
onOpenDiff={(diffTab) => { store.reduce(s => openDiffTab(s, paneId, diffTab)) }}
|
||||
localeRevision={localeRevision}
|
||||
tabsVersion={tabsVersion}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1199,7 +1270,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
data-dsh-bottom-panel
|
||||
style={{
|
||||
height: Math.min(state.bottomHeight, window.innerHeight),
|
||||
left: centerRect.left,
|
||||
left: centerRectRef.current.left,
|
||||
// Keep the panel above the on-screen keyboard when the visual
|
||||
// viewport shrinks (see the keyboardInset effect).
|
||||
bottom: keyboardInset > 0 ? `${keyboardInset}px` : undefined,
|
||||
@@ -1208,14 +1279,14 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
// details column's left edge (the details column sits between the
|
||||
// center and the right panel, and the right panel's margin-right
|
||||
// push is already baked into centerRect.right).
|
||||
right: window.innerWidth - centerRect.right,
|
||||
right: window.innerWidth - centerRectRef.current.right,
|
||||
// The seam against the open right panel needs its own hairline
|
||||
// (the right panel's border-left alone is covered by this panel's
|
||||
// fill — without it the corner looks cut off).
|
||||
borderRight: state.panelOpen ? '1px solid var(--dsw-alias-border-l2)' : undefined,
|
||||
// Unmeasured center column → keep the panel invisible (zero-size
|
||||
// geometry would flash full-width overflow instead).
|
||||
visibility: centerRect.right > 0 ? undefined : 'hidden',
|
||||
visibility: centerMeasured ? undefined : 'hidden',
|
||||
}}
|
||||
|
||||
data-dragging={(draggingBottom || draggingCorner) || undefined}
|
||||
|
||||
+58
-2
@@ -3,7 +3,9 @@
|
||||
* overflow scrolls horizontally, a close button per tab, a four-way split
|
||||
* button cluster, and the + menu that opens new tabs (explorer / git /
|
||||
* terminal). Tabs are draggable; dropping onto another tab inserts before it,
|
||||
* dropping on the strip background appends to this pane.
|
||||
* dropping on the strip background appends to this pane. Right-clicking a
|
||||
* tab opens the tab context menu (close / close others / close to the left /
|
||||
* close to the right, all scoped to this pane).
|
||||
*/
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
@@ -71,8 +73,14 @@ export function TabBar(props: {
|
||||
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, getTabIcon, getTabBadge,
|
||||
} = props
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
// The tab right-click context menu: the target tab plus the cursor
|
||||
// position (the portaled Menu anchors there, following GitView/FileTree).
|
||||
const [tabMenu, setTabMenu] = useState<{ tabId: string; x: number; y: number } | null>(null)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
// The context target's index in the render-time tab snapshot; -1 when the
|
||||
// tab disappeared since the menu opened (the menu hides then).
|
||||
const tabMenuIndex = tabMenu === null ? -1 : tabs.findIndex(tab => tab.id === tabMenu.tabId)
|
||||
|
||||
// Middle-click close: the press target is recorded on middle mousedown
|
||||
// (preventDefaulted to disarm Chrome's middle-click autoscroll — its
|
||||
@@ -194,6 +202,14 @@ export function TabBar(props: {
|
||||
middlePressed.current = { id: tab.id, node: event.currentTarget }
|
||||
}
|
||||
}}
|
||||
onContextMenu={(event) => {
|
||||
// Take over the browser menu: the tab context menu offers the
|
||||
// close operations for this pane. Opening it also dismisses
|
||||
// the + menu (only one menu at a time).
|
||||
event.preventDefault()
|
||||
setMenuOpen(false)
|
||||
setTabMenu({ tabId: tab.id, x: event.clientX, y: event.clientY })
|
||||
}}
|
||||
>
|
||||
{getTabIcon?.(tab) ?? null}
|
||||
{getTabBadge?.(tab) ?? null}
|
||||
@@ -237,12 +253,52 @@ export function TabBar(props: {
|
||||
className={css.tabBarPlus}
|
||||
aria-label={t('newTab')}
|
||||
title={t('newTab')}
|
||||
onClick={() => { setMenuOpen(v => !v) }}
|
||||
onClick={() => { setMenuOpen(v => !v); setTabMenu(null) }}
|
||||
>
|
||||
<IconPlusOutline16 />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
{/*
|
||||
The tab context menu, positioned at the right-click cursor (portal
|
||||
so the panel's overflow clip cannot crop it). Close operations are
|
||||
scoped to THIS pane: "close others/left/right" walk the render-time
|
||||
tab snapshot and reuse the per-tab onClose path (which routes
|
||||
through the service and releases terminals), so the target tab is
|
||||
never closed and the pane never empties mid-loop.
|
||||
*/}
|
||||
<Menu
|
||||
open={tabMenu !== null && tabMenuIndex >= 0}
|
||||
onClose={() => { setTabMenu(null) }}
|
||||
items={[
|
||||
{ id: 'close', label: t('close') },
|
||||
{ id: 'closeOthers', label: t('closeOtherTabs'), ...(tabs.length <= 1 ? { disabled: true } : {}) },
|
||||
{ id: 'closeLeft', label: t('closeLeftTabs'), ...(tabMenuIndex <= 0 ? { disabled: true } : {}) },
|
||||
{ id: 'closeRight', label: t('closeRightTabs'), ...(tabMenuIndex >= tabs.length - 1 ? { disabled: true } : {}) },
|
||||
]}
|
||||
onSelect={(id) => {
|
||||
const target = tabMenu
|
||||
if (target === null) return
|
||||
setTabMenu(null)
|
||||
const index = tabs.findIndex(tab => tab.id === target.tabId)
|
||||
if (index < 0) return
|
||||
if (id === 'close') {
|
||||
onClose(target.tabId)
|
||||
} else if (id === 'closeOthers') {
|
||||
for (const tab of tabs) {
|
||||
if (tab.id !== target.tabId) onClose(tab.id)
|
||||
}
|
||||
} else if (id === 'closeLeft') {
|
||||
for (const tab of tabs.slice(0, index)) onClose(tab.id)
|
||||
} else if (id === 'closeRight') {
|
||||
for (const tab of tabs.slice(index + 1)) onClose(tab.id)
|
||||
}
|
||||
}}
|
||||
portal
|
||||
align="start"
|
||||
getAnchorRect={() => (tabMenu === null ? null : new DOMRect(tabMenu.x, tabMenu.y, 0, 0))}
|
||||
anchor={<span />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
+38
-13
@@ -8,16 +8,28 @@
|
||||
* retry, and repeated unreasoned failures surface the close code after three
|
||||
* attempts, so the banner never spins forever.
|
||||
*
|
||||
* Three control frames shape the pty lifecycle on unmount:
|
||||
* - `{type:'close'}` — the user closed the tab. The host kills the pty
|
||||
* immediately (quota released).
|
||||
* - `{type:'park'}` — the user switched to another conversation. The tab is
|
||||
* still open in its session's persisted state but its view unmounted; the
|
||||
* host keeps the pty alive indefinitely (no grace countdown), so switching
|
||||
* back reattaches the same shell instead of respawning one.
|
||||
* - bare socket drop (no frame) — page refresh, crash, plugin teardown, or a
|
||||
* same-session re-render. The host's reconnect grace keeps the shell alive
|
||||
* for a quick reconnect.
|
||||
*
|
||||
* Two attach modes share one upgrade endpoint:
|
||||
* - `tabId` starting with `agent:` is an agent-owned terminal (created by
|
||||
* the `terminal_create` tool). The uuid is the suffix after `agent:`; the
|
||||
* view connects with `?uuid=...`. A close frame kills the pty (the agent's
|
||||
* terminal closes when the user closes the tab); a bare socket drop
|
||||
* leaves the pty alive (the agent owns the lifetime).
|
||||
* leaves the pty alive (the agent owns the lifetime) — agent terminals
|
||||
* never send park (their lifetime is already indefinite on bare drop).
|
||||
* - Any other `tabId` is a UI-tab terminal (the user created it from the +
|
||||
* menu). The view connects with `?tab=...&sessionId=...&cwd=...`. A close
|
||||
* frame schedules a 0-ms close; a bare socket drop gets the host's
|
||||
* reconnect grace.
|
||||
* frame schedules a 0-ms close; a park frame marks the pty as parked; a
|
||||
* bare socket drop gets the host's reconnect grace.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Terminal, type ITheme } from '@xterm/xterm'
|
||||
@@ -274,18 +286,31 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
|
||||
fontSub()
|
||||
schemeSub()
|
||||
inputSub.dispose()
|
||||
// The close frame tells the host the owning tab is GONE (immediate
|
||||
// pty release). A bare unmount — conversation switch, re-render,
|
||||
// page unload — leaves the tab open, so the socket drop alone hands
|
||||
// the process to the host's reconnect grace: switching back or
|
||||
// refreshing reattaches the SAME shell instead of respawning one.
|
||||
// (The host respawns on its own when the authoritative cwd changed.)
|
||||
// Agent terminals follow the same rule: a close frame kills the pty
|
||||
// (the user closed the sidebar tab); a bare socket drop leaves it
|
||||
// alive (the agent owns the lifetime).
|
||||
if (!store.tabOpen(scope.sessionId, tabId)
|
||||
// Three unmount cases, distinguished by the store's tab/open state and
|
||||
// the active session id:
|
||||
// 1. The tab was closed by the user (NOT in its session's state): send
|
||||
// `{type:'close'}` — the host releases the pty immediately.
|
||||
// 2. The user switched to another conversation (the tab IS still open
|
||||
// in scope.sessionId's state, but the active session is now a
|
||||
// different one): send `{type:'park'}` — the host keeps the pty
|
||||
// alive indefinitely (no grace countdown), so switching back
|
||||
// reattaches the SAME shell. Without this, the bare socket drop
|
||||
// would start the 30s reconnect-grace countdown and kill the shell
|
||||
// while the user is still actively working in the other session.
|
||||
// 3. A same-session unmount (page refresh, crash, plugin teardown, a
|
||||
// re-render that re-mounts the view): bare socket drop — the host's
|
||||
// reconnect grace keeps the shell alive for a quick reconnect.
|
||||
// Agent terminals follow the close-frame rule; their lifetime is owned
|
||||
// by the agent, so a bare drop (case 3) already leaves them alive
|
||||
// indefinitely — no park frame needed.
|
||||
const tabStillOpen = store.tabOpen(scope.sessionId, tabId)
|
||||
const sessionSwitched = store.getSnapshot().sessionId !== scope.sessionId
|
||||
if (!tabStillOpen
|
||||
&& socket !== null && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'close' }))
|
||||
} else if (tabStillOpen && sessionSwitched && !isAgentTabId(tabId)
|
||||
&& socket !== null && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'park' }))
|
||||
}
|
||||
socket?.close()
|
||||
term.dispose()
|
||||
|
||||
@@ -21,6 +21,7 @@ import { IconFolderOpen16, IconRefreshOutline16 } from '@deepseek-ai/dsh-client-
|
||||
import { api } from './api.ts'
|
||||
import { FileTree } from './FileTree.tsx'
|
||||
import { IconUploadOutline16 } from './icons.tsx'
|
||||
import type { OpenWithTarget } from './open-with.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { resolveSidebarPath } from './produced-files.ts'
|
||||
import { UploadOverlay } from './UploadOverlay.tsx'
|
||||
@@ -50,12 +51,19 @@ export function TreePanel(props: {
|
||||
onOpenFileNewTab?: (path: string) => void
|
||||
/** File context-menu "open to the side" (passed through to FileTree). */
|
||||
onOpenFileSide?: (path: string) => void
|
||||
/** The "open with" menu surface (passed through to FileTree; absent →
|
||||
* the whole section is hidden). */
|
||||
openWithTargets?: OpenWithTarget[]
|
||||
openWithPinned?: string[]
|
||||
openWithSsh?: boolean
|
||||
onOpenWith?: (targetId: string, path: string) => void
|
||||
onToggleOpenWithPin?: (targetId: 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 { sessionId, cwd, expanded, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, full } = props
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<{ matches: string[]; truncated: boolean } | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -217,6 +225,11 @@ export function TreePanel(props: {
|
||||
onOpenFile={onOpenFile}
|
||||
onOpenFileNewTab={onOpenFileNewTab}
|
||||
onOpenFileSide={onOpenFileSide}
|
||||
openWithTargets={openWithTargets}
|
||||
openWithPinned={openWithPinned}
|
||||
openWithSsh={openWithSsh}
|
||||
onOpenWith={onOpenWith}
|
||||
onToggleOpenWithPin={onToggleOpenWithPin}
|
||||
onReferenceFile={onReferenceFile}
|
||||
refreshTick={refreshTick}
|
||||
onUploadRequest={startUpload}
|
||||
|
||||
@@ -289,6 +289,12 @@ export const api = {
|
||||
* check; see the host's browser.probe route). */
|
||||
browserProbe: (url: string, signal?: AbortSignal) =>
|
||||
call<BrowserProbeResult>('browser.probe', { url }, signal),
|
||||
/** External open for the file tree's "open with" menu: reveal a path in
|
||||
* the OS file manager, or hand a custom-scheme URL (vscode://, cursor://,
|
||||
* zed://, custom editors) to its registered handler. The host launches
|
||||
* the platform opener (argv, no shell). */
|
||||
openExternal: (payload: { action: 'reveal'; path: string } | { action: 'url'; url: string }) =>
|
||||
call<{ started: boolean }>('open.external', payload),
|
||||
}
|
||||
|
||||
/** Absolute URL of the media route for one path (images only). */
|
||||
|
||||
@@ -13,6 +13,7 @@ import { allLeaves, isAgentTabId, type SidebarState } from '../state.ts'
|
||||
import { t } from '../locales.ts'
|
||||
import { openSidebarFile } from '../intercept.tsx'
|
||||
import { EditorHost } from '../EditorHost.tsx'
|
||||
import { OpenWithSettings } from '../open-with-settings.tsx'
|
||||
import { lazyChunkComponent } from '../lazy-chunk.tsx'
|
||||
import { GitView } from '../GitView.tsx'
|
||||
import { DiffTab } from '../DiffTab.tsx'
|
||||
@@ -90,7 +91,9 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
|
||||
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.
|
||||
// editor card's gear in the Side card settings page; the "open with"
|
||||
// configuration (SSH host + custom editors) is the custom panel BELOW
|
||||
// those rows — the settings seam renders rows first, custom panel after.
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'editorExplorer',
|
||||
@@ -112,6 +115,9 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
|
||||
},
|
||||
],
|
||||
}],
|
||||
render: ({ pluginSettings, updatePluginSetting }) => (
|
||||
<OpenWithSettings pluginSettings={pluginSettings} updatePluginSetting={updatePluginSetting} />
|
||||
),
|
||||
},
|
||||
component: ({ ctx, store, scope, tab, expanded, onToggleDir, onReferenceFile }) => (
|
||||
<EditorHost
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Per-frame task coalescing for pointer-driven interactions.
|
||||
*
|
||||
* Pointer streams fire several times faster than the display refresh, and
|
||||
* applying every event (a React setState, a store reduce) re-renders whole
|
||||
* trees at event cadence — the visible drag lag on slower CPUs (#315). This
|
||||
* batcher keeps the LATEST scheduled task, runs it at most once per
|
||||
* animation frame, and lets the caller flush synchronously on release (so
|
||||
* the final pointer position is never lost to a cancelled frame).
|
||||
*
|
||||
* Callers compose the pending value in a ref before scheduling; the
|
||||
* scheduled task reads that ref, so replacing the task on every event is
|
||||
* cheap (one ref write + one rAF check).
|
||||
*/
|
||||
export interface FrameBatcher {
|
||||
/** Schedule (or replace) the per-frame task. Safe to call on every event. */
|
||||
schedule(task: () => void): void
|
||||
/** Cancel any pending frame and run the scheduled task synchronously
|
||||
* (no-op when nothing is scheduled). */
|
||||
flushNow(): void
|
||||
/** Cancel any pending frame and drop the task without running it. */
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
export function createFrameBatcher(): FrameBatcher {
|
||||
let frame: number | null = null
|
||||
let task: (() => void) | null = null
|
||||
|
||||
const run = (): void => {
|
||||
frame = null
|
||||
const current = task
|
||||
task = null
|
||||
current?.()
|
||||
}
|
||||
|
||||
return {
|
||||
schedule(next) {
|
||||
task = next
|
||||
if (frame === null) frame = requestAnimationFrame(run)
|
||||
},
|
||||
flushNow() {
|
||||
if (frame !== null) {
|
||||
cancelAnimationFrame(frame)
|
||||
frame = null
|
||||
}
|
||||
run()
|
||||
},
|
||||
dispose() {
|
||||
if (frame !== null) {
|
||||
cancelAnimationFrame(frame)
|
||||
frame = null
|
||||
}
|
||||
task = null
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -137,3 +137,16 @@ export const IconSaveOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<path d="M5.4 14.5v-4.2h5.2v4.2" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/**
|
||||
* Visual Studio Code brand mark for the file-tree "open with" menu. The
|
||||
* path is the Simple Icons `visualstudiocode` glyph (CC0 1.0,
|
||||
* simple-icons@11.0.0 — later releases dropped it over Microsoft's brand
|
||||
* policy, so it is inlined here rather than pulled from react-icons),
|
||||
* rendered monochrome via currentColor to follow the active skin.
|
||||
*/
|
||||
export const IconVscode16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M23.15 2.587L18.21.21a1.494 1.494 0 0 0-1.705.29l-9.46 8.63-4.12-3.128a.999.999 0 0 0-1.276.057L.327 7.261A1 1 0 0 0 .326 8.74L3.899 12 .326 15.26a1 1 0 0 0 .001 1.479L1.65 17.94a.999.999 0 0 0 1.276.057l4.12-3.128 9.46 8.63a1.492 1.492 0 0 0 1.704.29l4.942-2.377A1.5 1.5 0 0 0 24 20.06V3.939a1.5 1.5 0 0 0-.85-1.352zm-5.146 14.861L10.826 12l7.178-5.448v10.896z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
@@ -30,6 +30,26 @@ export const zh = {
|
||||
editorEmptyHint: '从右侧文件树或上方路径输入框选择文件开始预览',
|
||||
openFileNewTab: '在新 Tab 中打开',
|
||||
openFileSide: '在侧边打开',
|
||||
openWithMenu: '在应用中打开',
|
||||
openWithSshSuffix: ' (SSH)',
|
||||
pinOpenWith: '固定到菜单',
|
||||
unpinOpenWith: '取消固定',
|
||||
openWithExplorer: '资源管理器',
|
||||
openWithVscode: 'VS Code',
|
||||
openWithCursor: 'Cursor',
|
||||
openWithZed: 'Zed',
|
||||
openWithSettingsSshTitle: 'SSH 远端主机',
|
||||
openWithSettingsSshDesc: '留空为本地工作区;填入 user@host 或 SSH 别名后,VSCode 系打开方式将改用 vscode-remote/ssh-remote 协议,资源管理器 / Zed / 非 VSCode 系自定义编辑器将从菜单隐藏',
|
||||
openWithSettingsSshPlaceholder: 'user@host 或 SSH 别名',
|
||||
openWithSettingsCustomTitle: '自定义编辑器',
|
||||
openWithSettingsCustomDesc: '名称 + URL 模板({path} 占位符)+ 是否 VSCode 系;SSH 模式下仅 VSCode 系可打开远端',
|
||||
openWithSettingsAdd: '添加',
|
||||
openWithSettingsName: '名称',
|
||||
openWithSettingsTemplate: '如 cursor://file/{path}',
|
||||
openWithSettingsFamily: 'VSCode 系',
|
||||
openWithSettingsFamilyDesc: '该编辑器使用 VSCode 的 URL 协议(支持 SSH 远端打开)',
|
||||
openWithSettingsRemove: '删除',
|
||||
openWithSettingsInvalidHint: '名称或模板(需含 {path} 且以 scheme:// 开头)未填写的编辑器不会出现在菜单中',
|
||||
newTab: '新建标签页',
|
||||
openExplorer: '资源管理器',
|
||||
brokenSymlink: '失效的软链接',
|
||||
@@ -37,6 +57,9 @@ export const zh = {
|
||||
newTerminal: '新终端',
|
||||
terminalLimit: '终端数量已达上限 (3)',
|
||||
close: '关闭',
|
||||
closeOtherTabs: '关闭其他页签',
|
||||
closeLeftTabs: '关闭左侧页签',
|
||||
closeRightTabs: '关闭右侧页签',
|
||||
collapse: '折叠侧边栏',
|
||||
expand: '展开侧边栏',
|
||||
collapseBottomPanel: '折叠底部面板',
|
||||
@@ -264,6 +287,7 @@ export const zh = {
|
||||
sideChatComposerPlaceholder: '追问…',
|
||||
sideChatThinking: '正在深入…',
|
||||
sideChatThink: '思考过程',
|
||||
sideChatInjection: '已注入上下文',
|
||||
sideChatSend: '发送',
|
||||
sideChatCancel: '停止',
|
||||
sideChatCancelTitle: '中止当前回合(保留队列)',
|
||||
@@ -336,6 +360,26 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
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',
|
||||
openWithMenu: 'Open with',
|
||||
openWithSshSuffix: ' (SSH)',
|
||||
pinOpenWith: 'Pin to menu',
|
||||
unpinOpenWith: 'Unpin',
|
||||
openWithExplorer: 'File Manager',
|
||||
openWithVscode: 'VS Code',
|
||||
openWithCursor: 'Cursor',
|
||||
openWithZed: 'Zed',
|
||||
openWithSettingsSshTitle: 'SSH remote host',
|
||||
openWithSettingsSshDesc: 'Empty = local workspace; with a user@host or SSH alias, VSCode-family openers switch to the vscode-remote/ssh-remote protocol and the File Manager / Zed / non-VSCode-family custom editors are hidden from the menu',
|
||||
openWithSettingsSshPlaceholder: 'user@host or SSH alias',
|
||||
openWithSettingsCustomTitle: 'Custom editors',
|
||||
openWithSettingsCustomDesc: 'Name + URL template ({path} placeholder) + VSCode-family flag; in remote mode only VSCode-family editors can open a remote path',
|
||||
openWithSettingsAdd: 'Add',
|
||||
openWithSettingsName: 'Name',
|
||||
openWithSettingsTemplate: 'e.g. cursor://file/{path}',
|
||||
openWithSettingsFamily: 'VSCode-family',
|
||||
openWithSettingsFamilyDesc: 'This editor speaks the VSCode URL dialect (supports SSH-remote opens)',
|
||||
openWithSettingsRemove: 'Remove',
|
||||
openWithSettingsInvalidHint: 'Editors with a missing name or a template without {path} / scheme:// are not shown in the menu',
|
||||
newTab: 'New tab',
|
||||
openExplorer: 'Explorer',
|
||||
brokenSymlink: 'Broken symlink',
|
||||
@@ -343,6 +387,9 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
newTerminal: 'New terminal',
|
||||
terminalLimit: 'Terminal limit reached (3)',
|
||||
close: 'Close',
|
||||
closeOtherTabs: 'Close Other Tabs',
|
||||
closeLeftTabs: 'Close Tabs to the Left',
|
||||
closeRightTabs: 'Close Tabs to the Right',
|
||||
collapse: 'Collapse sidebar',
|
||||
expand: 'Expand sidebar',
|
||||
collapseBottomPanel: 'Collapse bottom panel',
|
||||
@@ -570,6 +617,7 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
sideChatComposerPlaceholder: 'Ask a follow-up…',
|
||||
sideChatThinking: 'Deep diving…',
|
||||
sideChatThink: 'Thinking',
|
||||
sideChatInjection: 'Context injected',
|
||||
sideChatSend: 'Send',
|
||||
sideChatCancel: 'Stop',
|
||||
sideChatCancelTitle: 'Abort the running turn (queued work is kept)',
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* The editor tab's custom settings panel ("打开方式"): the file tree's
|
||||
* "open with" configuration — the optional SSH host marking the workspace as
|
||||
* remote, and the user-defined editors (name + URL template with `{path}` +
|
||||
* whether they speak the VSCode URL dialect). Persisted as the editor
|
||||
* blob's `openWith` key through the settings popup's `updatePluginSetting`.
|
||||
*
|
||||
* The popup renders the declarative rows (the editorExplorer picker) ABOVE
|
||||
* this panel — SettingsBody renders the custom panel after the row list, so
|
||||
* this component owns only its own section.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { IconCloseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import {
|
||||
isValidCustomEditor,
|
||||
newCustomEditorId,
|
||||
parseOpenWithConfig,
|
||||
type CustomEditor,
|
||||
type OpenWithConfig,
|
||||
} from './open-with.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './SideCardSection.module.css'
|
||||
|
||||
export function OpenWithSettings(props: {
|
||||
pluginSettings: Record<string, unknown>
|
||||
updatePluginSetting: (key: string, value: unknown) => void
|
||||
}) {
|
||||
const { pluginSettings, updatePluginSetting } = props
|
||||
// Local draft seeded from the persisted blob (each commit re-renders the
|
||||
// popup with the round-tripped prefs; the local state stays authoritative
|
||||
// while the popup is open, so typing never fights an in-flight write).
|
||||
const [draft, setDraft] = useState<OpenWithConfig>(() => parseOpenWithConfig(pluginSettings.openWith))
|
||||
|
||||
const commit = (next: OpenWithConfig): void => {
|
||||
setDraft(next)
|
||||
updatePluginSetting('openWith', next)
|
||||
}
|
||||
|
||||
const setSshHost = (sshHost: string): void => commit({ ...draft, sshHost })
|
||||
|
||||
const patchCustom = (id: string, patch: Partial<CustomEditor>): void => {
|
||||
commit({
|
||||
...draft,
|
||||
customEditors: draft.customEditors.map(editor => editor.id === id ? { ...editor, ...patch } : editor),
|
||||
})
|
||||
}
|
||||
|
||||
const removeCustom = (id: string): void => {
|
||||
commit({
|
||||
...draft,
|
||||
customEditors: draft.customEditors.filter(editor => editor.id !== id),
|
||||
// A removed editor can never stay pinned (the menu prunes unknown ids
|
||||
// anyway; this keeps the blob clean).
|
||||
pinned: draft.pinned.filter(pinnedId => pinnedId !== `custom:${id}`),
|
||||
})
|
||||
}
|
||||
|
||||
const addCustom = (): void => {
|
||||
commit({
|
||||
...draft,
|
||||
customEditors: [...draft.customEditors, { id: newCustomEditorId(), name: '', urlTemplate: '', isVscodeFamily: false }],
|
||||
})
|
||||
}
|
||||
|
||||
const hasInvalid = draft.customEditors.some(editor => !isValidCustomEditor(editor))
|
||||
|
||||
return (
|
||||
<div className={css.popupRows}>
|
||||
<div className={css.popupRow}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('openWithSettingsSshTitle')}</span>
|
||||
<span className={css.desc}>{t('openWithSettingsSshDesc')}</span>
|
||||
</span>
|
||||
<input
|
||||
className={css.typedInput}
|
||||
value={draft.sshHost}
|
||||
placeholder={t('openWithSettingsSshPlaceholder')}
|
||||
spellCheck={false}
|
||||
onChange={(event) => { setSshHost(event.target.value) }}
|
||||
/>
|
||||
</div>
|
||||
<div className={css.popupRow}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('openWithSettingsCustomTitle')}</span>
|
||||
<span className={css.desc}>{t('openWithSettingsCustomDesc')}</span>
|
||||
</span>
|
||||
<span className={css.control}>
|
||||
<button type="button" className={css.done} onClick={addCustom}>
|
||||
{t('openWithSettingsAdd')}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
{draft.customEditors.map(editor => (
|
||||
<div key={editor.id} className={css.openWithEditorRow}>
|
||||
<input
|
||||
className={css.openWithEditorInput}
|
||||
value={editor.name}
|
||||
placeholder={t('openWithSettingsName')}
|
||||
spellCheck={false}
|
||||
onChange={(event) => { patchCustom(editor.id, { name: event.target.value }) }}
|
||||
/>
|
||||
<input
|
||||
className={css.openWithEditorTemplate}
|
||||
value={editor.urlTemplate}
|
||||
placeholder={t('openWithSettingsTemplate')}
|
||||
spellCheck={false}
|
||||
onChange={(event) => { patchCustom(editor.id, { urlTemplate: event.target.value }) }}
|
||||
/>
|
||||
<label className={css.openWithFamily} title={t('openWithSettingsFamilyDesc')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editor.isVscodeFamily}
|
||||
onChange={(event) => { patchCustom(editor.id, { isVscodeFamily: event.currentTarget.checked }) }}
|
||||
/>
|
||||
<span>{t('openWithSettingsFamily')}</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className={css.openWithRemove}
|
||||
aria-label={t('openWithSettingsRemove')}
|
||||
title={t('openWithSettingsRemove')}
|
||||
onClick={() => { removeCustom(editor.id) }}
|
||||
>
|
||||
<IconCloseOutline16 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{hasInvalid && (
|
||||
<div className={css.openWithHint} role="note">
|
||||
{t('openWithSettingsInvalidHint')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* The file tree's "open with" vocabulary: which external editors / file
|
||||
* managers the context menu can hand a row's path to, how the per-user
|
||||
* configuration (custom editors, SSH host, pinned ids) is parsed out of the
|
||||
* persisted blob, and how the external URL is built (local `file` URLs and
|
||||
* VSCode-family SSH-remote URLs).
|
||||
*
|
||||
* Pure by design (no React / no `api`), so the whole surface is unit-testable
|
||||
* without a DOM or a host route. The menu and the settings panel only consume
|
||||
* the exported types and functions.
|
||||
*/
|
||||
import type { CopyKey } from './locales.ts'
|
||||
|
||||
/** One user-configured editor (persisted in `pluginSettings['editor'].openWith`). */
|
||||
export interface CustomEditor {
|
||||
id: string
|
||||
/** Human label shown in the menu (e.g. "Windsurf"). */
|
||||
name: string
|
||||
/** URL template with a `{path}` placeholder (e.g. `cursor://file/{path}`). */
|
||||
urlTemplate: string
|
||||
/** Whether the editor speaks the VSCode URL dialect — the only editors
|
||||
* with an SSH-remote form (`<scheme>://vscode-remote/ssh-remote+<host>/…`). */
|
||||
isVscodeFamily: boolean
|
||||
}
|
||||
|
||||
/** The per-user "open with" configuration (one key of the editor blob). */
|
||||
export interface OpenWithConfig {
|
||||
/** SSH host (user@host or an ~/.ssh/config alias); '' = local workspace. */
|
||||
sshHost: string
|
||||
/** User-defined editors, appended after the built-ins. */
|
||||
customEditors: CustomEditor[]
|
||||
/** Ids of open-with targets pinned to the menu's top level
|
||||
* ('explorer' | 'vscode' | 'cursor' | 'zed' | 'custom:<id>'). */
|
||||
pinned: string[]
|
||||
}
|
||||
|
||||
/** One menu-visible open target (built-in or custom, SSH-filtered). */
|
||||
export interface OpenWithTarget {
|
||||
/** Stable id (`explorer` / `vscode` / `cursor` / `zed` / `custom:<id>`). */
|
||||
id: string
|
||||
/** Locale key of a built-in label (custom editors carry `name` instead). */
|
||||
nameKey?: CopyKey
|
||||
/** User-defined label (custom editors only; '' for built-ins). */
|
||||
name: string
|
||||
/** 'reveal' = show in the OS file manager; 'url' = open a URL. */
|
||||
kind: 'reveal' | 'url'
|
||||
/** URL template with `{path}`; undefined for reveal targets. */
|
||||
urlTemplate?: string
|
||||
/** Whether the editor talks the VSCode URL dialect. */
|
||||
isVscodeFamily: boolean
|
||||
/** Hidden in SSH mode (a host-local opener cannot reach a remote path). */
|
||||
localOnly: boolean
|
||||
}
|
||||
|
||||
/** The default open-with configuration (fresh documents). */
|
||||
export const OPEN_WITH_DEFAULTS: OpenWithConfig = {
|
||||
sshHost: '',
|
||||
customEditors: [],
|
||||
pinned: [],
|
||||
}
|
||||
|
||||
/** The built-in open targets, in menu order. */
|
||||
export const OPEN_WITH_BUILTINS: readonly OpenWithTarget[] = [
|
||||
{
|
||||
id: 'explorer',
|
||||
nameKey: 'openWithExplorer',
|
||||
name: '',
|
||||
kind: 'reveal',
|
||||
isVscodeFamily: false,
|
||||
localOnly: true,
|
||||
},
|
||||
{
|
||||
id: 'vscode',
|
||||
nameKey: 'openWithVscode',
|
||||
name: '',
|
||||
kind: 'url',
|
||||
urlTemplate: 'vscode://file/{path}',
|
||||
isVscodeFamily: true,
|
||||
localOnly: false,
|
||||
},
|
||||
{
|
||||
id: 'cursor',
|
||||
nameKey: 'openWithCursor',
|
||||
name: '',
|
||||
kind: 'url',
|
||||
urlTemplate: 'cursor://file/{path}',
|
||||
isVscodeFamily: true,
|
||||
localOnly: false,
|
||||
},
|
||||
{
|
||||
id: 'zed',
|
||||
nameKey: 'openWithZed',
|
||||
name: '',
|
||||
kind: 'url',
|
||||
urlTemplate: 'zed://file/{path}',
|
||||
isVscodeFamily: false,
|
||||
localOnly: true,
|
||||
},
|
||||
]
|
||||
|
||||
/** Whether a persisted value makes a structurally valid custom-editor row.
|
||||
* Name/template may be empty — the settings panel edits rows in place and
|
||||
* an in-progress row must survive the round-trip; the MENU hides rows that
|
||||
* fail the stricter {@link isValidCustomEditor} check. */
|
||||
function isCustomEditor(value: unknown): value is CustomEditor {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false
|
||||
const record = value as Record<string, unknown>
|
||||
return typeof record.id === 'string' && record.id !== ''
|
||||
&& typeof record.name === 'string'
|
||||
&& typeof record.urlTemplate === 'string'
|
||||
&& typeof record.isVscodeFamily === 'boolean'
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the persisted `openWith` blob (tolerant): malformed fields fall back
|
||||
* to the defaults, malformed custom-editor rows are dropped, and pinned ids
|
||||
* are kept verbatim (unknown ids are pruned when the targets are resolved —
|
||||
* the menu is the only consumer of the resolved list).
|
||||
*/
|
||||
export function parseOpenWithConfig(raw: unknown): OpenWithConfig {
|
||||
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return { ...OPEN_WITH_DEFAULTS }
|
||||
const record = raw as Record<string, unknown>
|
||||
const sshHost = typeof record.sshHost === 'string' ? record.sshHost : ''
|
||||
const customEditors = Array.isArray(record.customEditors)
|
||||
? record.customEditors.filter(isCustomEditor)
|
||||
: []
|
||||
const pinned = Array.isArray(record.pinned)
|
||||
? record.pinned.filter((id): id is string => typeof id === 'string' && id !== '')
|
||||
: []
|
||||
return { sshHost, customEditors, pinned }
|
||||
}
|
||||
|
||||
/** Whether a custom editor id belongs to this config (id prefix match). */
|
||||
function customIdOf(id: string): string {
|
||||
return `custom:${id}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The menu-visible open targets, in order (built-ins then custom editors).
|
||||
* In SSH mode the local-only targets (the OS file manager, Zed, custom
|
||||
* editors without the VSCode dialect) are dropped — they cannot reach a
|
||||
* remote path. Unknown pinned ids are pruned here too.
|
||||
*/
|
||||
export function resolveOpenWithTargets(config: OpenWithConfig): OpenWithTarget[] {
|
||||
const ssh = config.sshHost.trim() !== ''
|
||||
const targets: OpenWithTarget[] = [
|
||||
...OPEN_WITH_BUILTINS,
|
||||
...config.customEditors
|
||||
.filter(isValidCustomEditor)
|
||||
.map((editor): OpenWithTarget => ({
|
||||
id: customIdOf(editor.id),
|
||||
name: editor.name,
|
||||
kind: 'url',
|
||||
urlTemplate: editor.urlTemplate,
|
||||
isVscodeFamily: editor.isVscodeFamily,
|
||||
localOnly: !editor.isVscodeFamily,
|
||||
})),
|
||||
]
|
||||
return targets.filter(target => !(ssh && target.localOnly))
|
||||
}
|
||||
|
||||
/** The SSH hint appended to a target's label in remote mode. */
|
||||
export function openWithSshActive(config: OpenWithConfig): boolean {
|
||||
return config.sshHost.trim() !== ''
|
||||
}
|
||||
|
||||
/**
|
||||
* The URL to open for one resolved target, or undefined when the target has
|
||||
* no URL form (reveal) or the template is malformed. The path is inserted
|
||||
* RAW into the template (browsers percent-encode as needed; VSCode-family
|
||||
* URL parsers consume the absolute path with its leading slash, e.g.
|
||||
* `vscode://file//home/u/f.ts` or `vscode://file/C:/Users/u/f.ts`).
|
||||
*/
|
||||
export function openWithUrl(target: OpenWithTarget, path: string, config: OpenWithConfig): string | undefined {
|
||||
if (target.kind !== 'url' || target.urlTemplate === undefined) return undefined
|
||||
const normalized = normalizeUrlPath(path)
|
||||
const ssh = openWithSshActive(config)
|
||||
if (ssh && target.isVscodeFamily) {
|
||||
const scheme = schemeOf(target.urlTemplate)
|
||||
if (scheme === undefined) return undefined
|
||||
// `ssh-remote+<host>` owns NO slash of its own: the path keeps its
|
||||
// leading slash, so `/home/u/f.ts` lands as `…+host/home/u/f.ts`.
|
||||
return `${scheme}://vscode-remote/ssh-remote+${config.sshHost.trim()}${normalized}`
|
||||
}
|
||||
if (!target.urlTemplate.includes('{path}') || !hasUrlScheme(target.urlTemplate)) return undefined
|
||||
return target.urlTemplate.replace('{path}', normalized)
|
||||
}
|
||||
|
||||
/** Whether a template starts with a `scheme://` prefix (the only shape the
|
||||
* host's external opener accepts and the settings panel suggests). */
|
||||
function hasUrlScheme(template: string): boolean {
|
||||
return /^[a-z][a-z0-9+.-]*:\/\//i.test(template)
|
||||
}
|
||||
|
||||
/** The scheme of a URL template (the part before the first ':'), or undefined. */
|
||||
function schemeOf(template: string): string | undefined {
|
||||
const at = template.indexOf(':')
|
||||
if (at <= 0) return undefined
|
||||
const scheme = template.slice(0, at)
|
||||
return /^[a-z][a-z0-9+.-]*$/i.test(scheme) ? scheme : undefined
|
||||
}
|
||||
|
||||
/** Normalize a filesystem path for embedding in a URL (backslashes → '/'). */
|
||||
export function normalizeUrlPath(path: string): string {
|
||||
return path.replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
/** A fresh custom-editor id (uuid when available, time-based fallback). */
|
||||
export function newCustomEditorId(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`
|
||||
}
|
||||
|
||||
/** Validate one custom-editor row before the settings panel accepts it. */
|
||||
export function isValidCustomEditor(row: { name: string; urlTemplate: string }): boolean {
|
||||
return row.name.trim() !== ''
|
||||
&& row.urlTemplate.includes('{path}')
|
||||
&& /^[a-z][a-z0-9+.-]*:\/\//i.test(row.urlTemplate.trim())
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Pending-writes queue for the file tree's open-with config: pin toggles and
|
||||
* (outside the settings popup) config edits land in the sidebar prefs as
|
||||
* `pluginSettings['editor']`. Writes are serialized through one promise chain
|
||||
* so a quick burst of pin clicks can never read a stale pluginSettings map
|
||||
* and drop an earlier toggle; each write pushes the whole open map patch
|
||||
* through the revision-free settings route and adopts the returned document.
|
||||
*
|
||||
* (The settings popup has its own serialized commit — SideCardSection's —
|
||||
* so its rows and this helper rarely race; the shared route's last-write-wins
|
||||
* semantics cover the uncommon overlap.)
|
||||
*/
|
||||
import { api } from './api.ts'
|
||||
import { parsePrefs } from './prefs.ts'
|
||||
import type { SidebarStore } from './state.ts'
|
||||
|
||||
let queue: Promise<void> = Promise.resolve()
|
||||
|
||||
/**
|
||||
* Merge one plugin-owned settings blob of one descriptor and persist it.
|
||||
* @param store - the sidebar store (its prefs are replaced by the write result).
|
||||
* @param descriptorId - the descriptor whose blob is patched ('editor' here).
|
||||
* @param updater - pure patch function; receives a shallow copy of the blob.
|
||||
*/
|
||||
export function updatePluginSettings(
|
||||
store: SidebarStore,
|
||||
descriptorId: string,
|
||||
updater: (blob: Record<string, unknown>) => Record<string, unknown>,
|
||||
): void {
|
||||
queue = queue.then(async () => {
|
||||
const prefs = store.getPrefs()
|
||||
const blob = prefs.pluginSettings[descriptorId] ?? {}
|
||||
const next = updater({ ...blob })
|
||||
const view = await api.settingsUpdate({
|
||||
pluginSettings: { ...prefs.pluginSettings, [descriptorId]: next },
|
||||
})
|
||||
store.setPrefs(parsePrefs(view.value))
|
||||
}).catch((error: unknown) => {
|
||||
// The pin stays visually unchanged (no optimistic flip) and the menu
|
||||
// keeps working — the write failure is logged, not surfaced.
|
||||
console.error('open-with settings write failed', error)
|
||||
})
|
||||
}
|
||||
@@ -470,7 +470,7 @@ export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDes
|
||||
* 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.15.0'
|
||||
export const SIDEBAR_SERVICE_VERSION = '0.15.2'
|
||||
|
||||
/**
|
||||
* Monotonic capability list consumers use to gate new API usage (features
|
||||
|
||||
@@ -2132,10 +2132,34 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
|
||||
|
||||
/* One changed file's section: path header, hunk headers, aligned lines. */
|
||||
.gitDiffFile {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
padding: 8px 2px 2px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gitDiffFile:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.gitDiffFile:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.gitDiffFileChevron {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.gitDiffFileChevronExpanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.gitDiffFilePath {
|
||||
@@ -2441,6 +2465,7 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
|
||||
.gitCommitButton:focus-visible,
|
||||
.gitLogRow:focus-visible,
|
||||
.gitLogMore:focus-visible,
|
||||
.gitDiffFile:focus-visible,
|
||||
.gitDiffExpand:focus-visible,
|
||||
.terminalRetry:focus-visible,
|
||||
.editorModeButton:focus-visible,
|
||||
@@ -2496,3 +2521,54 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
|
||||
max-width: 128px;
|
||||
}
|
||||
}
|
||||
|
||||
/* The file tree "open with" menu rows: the label area is a BLOCK-level flex
|
||||
row that fills the primitives `.itemLabel` span (which is itself `flex: 1`
|
||||
inside the row), so the trailing control — the pushpin on submenu rows,
|
||||
the chevron on the submenu parent — docks at the row's far right, exactly
|
||||
like the trailing check on selected rows of every other menu. */
|
||||
.openWithLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.openWithName {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The trailing "expands into a submenu" chevron on the open-with parent row
|
||||
(the primitives Menu renders no arrow for submenu parents). */
|
||||
.openWithChevron {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* The pushpin toggle on an "open with" submenu row: brand ink once the
|
||||
target is pinned to the menu's top level. */
|
||||
.openWithPin {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 6px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.openWithPin:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.openWithPinActive {
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
*
|
||||
* A thread child's log starts with the ENTIRE inherited parent log as its
|
||||
* fork seed. The mapping therefore cuts everything up to the LAST
|
||||
* `session/end-seed` marker and drops the "Side conversation boundary"
|
||||
* prompt row, so the view shows only the thread's own conversation.
|
||||
* `session/end-seed` marker and maps context injections (the "Side
|
||||
* conversation boundary" prompt, plugin-sourced context) onto a collapsible
|
||||
* injection row, so the view shows only the thread's own conversation.
|
||||
*
|
||||
* Live streaming: `assistant/message` events only land when a step
|
||||
* completes, but `assistant/chunk` events stream token-level text and
|
||||
@@ -14,7 +15,7 @@
|
||||
* them with the assembled message once it lands (settled rows).
|
||||
*/
|
||||
import type { SidebarHistoryEntry } from '../context-types.ts'
|
||||
import { SIDE_BOUNDARY_PREFIX } from '../sidechat-core.ts'
|
||||
import { isContextInjectionMessage, SIDE_BOUNDARY_PROMPT } from '../sidechat-core.ts'
|
||||
|
||||
/** One compact transcript row rendered in the thread view. `seq` is the
|
||||
* source event's log sequence — stable row identity for React keys across
|
||||
@@ -22,6 +23,10 @@ import { SIDE_BOUNDARY_PREFIX } from '../sidechat-core.ts'
|
||||
* rows). */
|
||||
export type SidechatTranscriptRow =
|
||||
| { kind: 'user'; seq: number; text: string }
|
||||
/** A context injection (the side boundary prompt + the parked in-progress
|
||||
* snapshot, or any plugin-sourced context): rendered as one collapsible
|
||||
* row, never as a user bubble. */
|
||||
| { kind: 'injection'; seq: number; text: string }
|
||||
/** `settled` distinguishes an assembled message from a still-streaming
|
||||
* chunk accumulation (streaming rows are superseded by the settle). */
|
||||
| { kind: 'assistant'; seq: number; text: string; settled: boolean }
|
||||
@@ -54,6 +59,39 @@ export function blockText(content: readonly unknown[]): string {
|
||||
return text === '' ? '…' : text
|
||||
}
|
||||
|
||||
/** Cap for a tool row's one-line argument summary (display only). */
|
||||
const ARGS_SUMMARY_MAX = 80
|
||||
|
||||
/** The most identifying argument keys, in priority order (bash's command,
|
||||
* fs tools' paths, search's pattern, …). */
|
||||
const ARGS_SUMMARY_KEYS = ['command', 'file_path', 'path', 'pattern', 'query', 'url', 'prompt'] as const
|
||||
|
||||
function flatTruncate(text: string): string {
|
||||
const flat = text.replace(/\s+/g, ' ').trim()
|
||||
return flat.length > ARGS_SUMMARY_MAX ? `${flat.slice(0, ARGS_SUMMARY_MAX - 1)}…` : flat
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line summary of a tool call's raw arguments JSON for the collapsed
|
||||
* row: the first identifying string field when the JSON parses, else the
|
||||
* flattened raw text; empty when there is nothing worth showing.
|
||||
*/
|
||||
export function toolArgsSummary(args: string | undefined): string {
|
||||
if (args === undefined) return ''
|
||||
try {
|
||||
const parsed = JSON.parse(args) as Record<string, unknown> | null
|
||||
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
for (const key of ARGS_SUMMARY_KEYS) {
|
||||
const value = parsed[key]
|
||||
if (typeof value === 'string' && value.trim() !== '') return flatTruncate(value)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Raw text fallthrough.
|
||||
}
|
||||
return flatTruncate(args)
|
||||
}
|
||||
|
||||
/** The plain text of a tool/result message (text blocks inside its
|
||||
* `tool-result` content block). */
|
||||
function resultTextOf(data: Record<string, unknown>): string {
|
||||
@@ -141,12 +179,12 @@ export async function collectOwnEvents(
|
||||
|
||||
/**
|
||||
* Map a thread child's history rows onto compact transcript rows: the
|
||||
* inherited fork seed is cut at the last `session/end-seed`, the boundary
|
||||
* prompt row is dropped, `assistant/chunk` deltas accumulate into streaming
|
||||
* rows per (turn, step, block) and are superseded by the assembled
|
||||
* `assistant/message`, and tool invocations render one expandable line each
|
||||
* (arguments, paired result text, failure marker; a still-executing call is
|
||||
* marked until its result lands).
|
||||
* inherited fork seed is cut at the last `session/end-seed`, context
|
||||
* injections map onto a collapsible injection row, `assistant/chunk`
|
||||
* deltas accumulate into streaming rows per (turn, step, block) and are
|
||||
* superseded by the assembled `assistant/message`, and tool invocations
|
||||
* render one expandable line each (arguments, paired result text, failure
|
||||
* marker; a still-executing call is marked until its result lands).
|
||||
* @param entries - history rows (event + host-computed view) in seq order.
|
||||
* @returns display rows in log order.
|
||||
*/
|
||||
@@ -166,7 +204,26 @@ export function transcriptRows(entries: readonly SidebarHistoryEntry[]): Sidecha
|
||||
switch (event.type) {
|
||||
case 'user/message': {
|
||||
const text = blockText(Array.isArray(data.content) ? data.content : [])
|
||||
if (text.startsWith(SIDE_BOUNDARY_PREFIX)) break
|
||||
// Context injections (the boundary prompt + snapshot, plugin-sourced
|
||||
// context) collapse into an injection row; genuine user messages —
|
||||
// including the FIRST one, which the host now delivers as its own
|
||||
// event — render as user rows.
|
||||
if (isContextInjectionMessage(data)) {
|
||||
const source = data.source as { kind?: unknown } | undefined
|
||||
// Threads logged BEFORE the host split carry boundary(+snapshot)+
|
||||
// question in ONE 'user' message. The boundary prompt is a known
|
||||
// constant, so the message splits THERE: the injection row keeps
|
||||
// the prompt, the remainder (snapshot + question if any — pure
|
||||
// question in the common case) renders as the user's real message.
|
||||
if (source?.kind === 'user' && text.startsWith(`${SIDE_BOUNDARY_PROMPT}\n\n`)) {
|
||||
rows.push({ kind: 'injection', seq: event.seq, text: SIDE_BOUNDARY_PROMPT })
|
||||
const body = text.slice(SIDE_BOUNDARY_PROMPT.length + 2)
|
||||
if (body !== '') rows.push({ kind: 'user', seq: event.seq, text: body })
|
||||
break
|
||||
}
|
||||
rows.push({ kind: 'injection', seq: event.seq, text })
|
||||
break
|
||||
}
|
||||
rows.push({ kind: 'user', seq: event.seq, text })
|
||||
break
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import clsx from 'clsx'
|
||||
import type { SidebarState, SidebarTab, SplitNode } from './state.ts'
|
||||
import type { DropZone } from './state.ts'
|
||||
import { TabBar, type NewTabOption, parseDrag, type TabDragPayload } from './TabBar.tsx'
|
||||
import { createFrameBatcher } from './frame-batcher.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** Actions the workbench needs (bound to the store by the sidebar shell). */
|
||||
@@ -34,7 +35,18 @@ export interface WorkbenchActions {
|
||||
* Deltas are incremental — each move reports the displacement since the
|
||||
* previous move — because the store adds every reported delta to the pane
|
||||
* sizes; a cumulative (since-pointer-down) delta would be re-added on each
|
||||
* move and the divider would run away from the cursor. */
|
||||
* move and the divider would run away from the cursor.
|
||||
*
|
||||
* The moves are BATCHED per frame (createFrameBatcher): a pointer stream
|
||||
* fires faster than the display refresh, and applying each move is a store
|
||||
* reduce that re-renders both workbenches (terminals, editors, trees) per
|
||||
* event — the visible drag lag on slower CPUs (#315). The batch accumulates
|
||||
* the incremental deltas in a ref and applies the summed fraction at most
|
||||
* once per frame; the sum equals what the per-event application would have
|
||||
* produced (the reducer clamps each application, and at a settled position
|
||||
* a clamped sum is clamped to the same boundary), so the result is
|
||||
* indistinguishable at rest and at most one frame behind the cursor.
|
||||
*/
|
||||
function Divider(props: { dir: 'row' | 'col'; onResize: (deltaFrac: number) => void }) {
|
||||
const { dir, onResize } = props
|
||||
// `last` is the previous pointer position while dragging; `size` is the
|
||||
@@ -42,6 +54,9 @@ function Divider(props: { dir: 'row' | 'col'; onResize: (deltaFrac: number) => v
|
||||
// normalize the px delta into a fraction.
|
||||
const last = useRef({ x: 0, y: 0, size: 0 })
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const pendingDelta = useRef(0)
|
||||
const batcher = useRef(createFrameBatcher()).current
|
||||
useEffect(() => () => batcher.dispose(), [batcher])
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -60,12 +75,20 @@ function Divider(props: { dir: 'row' | 'col'; onResize: (deltaFrac: number) => v
|
||||
onPointerMove={(event) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||
const delta = dir === 'row' ? event.clientX - last.current.x : event.clientY - last.current.y
|
||||
onResize(delta / Math.max(1, last.current.size))
|
||||
pendingDelta.current += delta
|
||||
batcher.schedule(() => {
|
||||
const accumulated = pendingDelta.current
|
||||
pendingDelta.current = 0
|
||||
if (accumulated !== 0) onResize(accumulated / Math.max(1, last.current.size))
|
||||
})
|
||||
last.current.x = event.clientX
|
||||
last.current.y = event.clientY
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||
// Flush any pending baked delta so the final pointer position lands
|
||||
// (the release event is not a move; dropping it would lose the tail).
|
||||
batcher.flushNow()
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
setDragging(false)
|
||||
}}
|
||||
|
||||
+24
-5
@@ -825,8 +825,9 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined {
|
||||
// bottom tree.
|
||||
const seen = new Set<string>()
|
||||
const reid = new Map<string, string>()
|
||||
const splits = sanitizeNode(record.splits, seen, reid)
|
||||
if (splits === undefined) return undefined
|
||||
const restoredSplits = sanitizeNode(record.splits, seen, reid)
|
||||
if (restoredSplits === undefined) return undefined
|
||||
const splits = pruneEmptyPanes(restoredSplits)
|
||||
// Bottom-panel fields arrived in a later build: a missing or malformed
|
||||
// value on an OLDER persisted state defaults (closed / default height /
|
||||
// empty pane) so existing layouts keep loading, like nextBrowser.
|
||||
@@ -840,15 +841,23 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined {
|
||||
? record.bottomHeight
|
||||
: BOTTOM_DEFAULT
|
||||
const bottomHeight = Math.min(bottomCap, Math.max(BOTTOM_MIN, Math.round(rawHeight)))
|
||||
const bottomSplits = sanitizeNode(record.bottomSplits, seen, reid)
|
||||
?? { kind: 'leaf' as const, id: uid('pane'), tabs: [], active: null }
|
||||
const bottomSplits = pruneEmptyPanes(sanitizeNode(record.bottomSplits, seen, reid)
|
||||
?? { kind: 'leaf' as const, id: uid('pane'), tabs: [], active: null })
|
||||
const requestedActivePane = typeof record.activePane === 'string'
|
||||
? (reid.get(record.activePane) ?? record.activePane)
|
||||
: null
|
||||
const activePane = requestedActivePane === null
|
||||
? null
|
||||
: treeHasId(splits, requestedActivePane) || treeHasId(bottomSplits, requestedActivePane)
|
||||
? requestedActivePane
|
||||
: firstLeaf(splits).id
|
||||
const maxWidth = typeof window !== 'undefined' ? window.innerWidth : Infinity
|
||||
return {
|
||||
panelOpen: record.panelOpen,
|
||||
width: Math.max(PANEL_MIN, Math.min(record.width, maxWidth)),
|
||||
// A stale duplicate pane id may have been re-ided; follow the rename so
|
||||
// new tabs still land in the pane the user was using.
|
||||
activePane: typeof record.activePane === 'string' ? (reid.get(record.activePane) ?? record.activePane) : null,
|
||||
activePane,
|
||||
nextTerminal: record.nextTerminal,
|
||||
nextBrowser,
|
||||
expanded: record.expanded as string[],
|
||||
@@ -863,6 +872,16 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
/** Collapse persisted split panes left empty after ephemeral diff tabs are dropped. */
|
||||
function pruneEmptyPanes(node: SplitNode): SplitNode {
|
||||
const leaves = allLeaves(node)
|
||||
if (!leaves.some(leaf => leaf.tabs.length > 0)) return node
|
||||
return leaves.reduce(
|
||||
(tree, leaf) => leaf.tabs.length === 0 ? removeLeafAt(tree, leaf.id) : tree,
|
||||
node,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One tree node id, deduplicated against the ids already seen in this
|
||||
* state. Duplicates are exactly the pre-seeding counter-reset corruption
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* The memo comparator for one tab's content cell (TabContent in Sidebar.tsx).
|
||||
*
|
||||
* Kept as a pure module so the "what re-renders a tab cell" contract is unit
|
||||
* testable: geometry/store re-renders of the Sidebar shell must NOT reconcile
|
||||
* every mounted tab (xterm/CodeMirror/FileTree subtrees — issue #315), while
|
||||
* locale switches (localeRevision), tab-registry updates (tabsVersion), and
|
||||
* a tab MOVING BETWEEN PANES (paneId — onOpenDiff closes over the pane id,
|
||||
* and moveTab reuses the SAME tab object, so only the pane id identifies the
|
||||
* move) must re-render the cell.
|
||||
*/
|
||||
import type { SidebarTab } from './state.ts'
|
||||
|
||||
/** The fields whose change invalidates a tab cell's memo. Anything a cell's
|
||||
* render (or its callbacks) depends on must be listed here. */
|
||||
export interface TabContentMemoKey {
|
||||
tab: SidebarTab
|
||||
/** The pane this tab currently lives in. onOpenDiff closes over it, and
|
||||
* moveTab reuses the same tab object when the tab moves panes — without
|
||||
* comparing paneId the cell would keep the source pane's callback. */
|
||||
paneId: string
|
||||
sessionId: string
|
||||
cwd: string | undefined
|
||||
visible: boolean
|
||||
expanded: string[]
|
||||
localeRevision: string
|
||||
tabsVersion: number
|
||||
}
|
||||
|
||||
/** True when the cell may skip a re-render (all render-affecting fields
|
||||
* unchanged). Callback/context identities are deliberately ignored: their
|
||||
* captured dependencies are stable or covered by the compared fields
|
||||
* (onReferenceFile → sessionId/cwd, onSubagentJump/onToggleDir → stable
|
||||
* refs/closures, onOpenDiff → paneId). */
|
||||
export function tabContentCompare(prev: TabContentMemoKey, next: TabContentMemoKey): boolean {
|
||||
return (
|
||||
prev.tab === next.tab &&
|
||||
prev.paneId === next.paneId &&
|
||||
prev.sessionId === next.sessionId &&
|
||||
prev.cwd === next.cwd &&
|
||||
prev.visible === next.visible &&
|
||||
prev.expanded === next.expanded &&
|
||||
prev.localeRevision === next.localeRevision &&
|
||||
prev.tabsVersion === next.tabsVersion
|
||||
)
|
||||
}
|
||||
+10
-2
@@ -96,6 +96,7 @@ function runGit(cwd: string, args: string[], timeoutMs = 30_000): Promise<string
|
||||
return new Promise<string>((resolvePromise, reject) => {
|
||||
const child = spawn('git', full, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
|
||||
})
|
||||
let stdout = ''
|
||||
@@ -143,13 +144,20 @@ export async function currentBranch(cwd: string): Promise<string> {
|
||||
return out.trim()
|
||||
}
|
||||
|
||||
/** Working-tree status (untracked included). */
|
||||
/**
|
||||
* Working-tree status (untracked included). `--untracked-files=all` lists
|
||||
* the CONTENTS of new directories as individual entries (`?? newdir/a.ts`
|
||||
* rather than a collapsed `?? newdir/`), so every row in the source-control
|
||||
* panel is a real file whose diff tab can load. With `=normal`, git folds a
|
||||
* new folder into one trailing-slash entry that has no diff output and
|
||||
* cannot be read as a file.
|
||||
*/
|
||||
export async function status(cwd: string): Promise<GitStatusResult> {
|
||||
const repo = await isGitRepo(cwd)
|
||||
if (!repo) return { isRepo: false, entries: [] }
|
||||
const [branch, raw] = await Promise.all([
|
||||
currentBranch(cwd).catch(() => 'HEAD'),
|
||||
runGit(cwd, ['status', '--porcelain=v1', '-z', '--untracked-files=normal']),
|
||||
runGit(cwd, ['status', '--porcelain=v1', '-z', '--untracked-files=all']),
|
||||
])
|
||||
return { isRepo: true, branch, entries: parsePorcelainZ(raw) }
|
||||
}
|
||||
|
||||
+36
-4
@@ -35,6 +35,7 @@ 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 { launchExternal } from './open-external.ts'
|
||||
import * as git from './git.ts'
|
||||
import { SettingsConflictError, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { defaultShell, ensureSpawnHelper, PtyManager, shellDisplayName } from './pty-manager.ts'
|
||||
@@ -479,6 +480,19 @@ function buildApi(
|
||||
clearTimeout(timer)
|
||||
}
|
||||
},
|
||||
// External open for the file tree's "open with" menu: reveal a path in
|
||||
// the OS file manager, or hand a custom-scheme URL (vscode://,
|
||||
// cursor://, zed://, custom editors) to its registered handler. The
|
||||
// client is a browser renderer where raw scheme navigation is
|
||||
// unreliable, so the launch always goes through the host — the same
|
||||
// fence as every other route, argv-only (no shell interpolation).
|
||||
'open.external': (payload) => {
|
||||
const record = payload as { action?: unknown } | null
|
||||
const action = record?.action
|
||||
if (action === 'reveal') return launchExternal('reveal', requireString(payload, 'path'))
|
||||
if (action === 'url') return launchExternal('url', requireString(payload, 'url'))
|
||||
throw new SidebarError('bad-request', 'action must be "reveal" or "url"')
|
||||
},
|
||||
// Side Chat: create a side-thread child seeded with the parent's full
|
||||
// log up to now, deliver follow-ups (cold-resuming when the thread's
|
||||
// agent is gone), abort a running thread, and release a thread's agent.
|
||||
@@ -895,6 +909,10 @@ async function attachAgentList(
|
||||
* - `?tab=...&sessionId=...` attaches to a UI-tab terminal (the user
|
||||
* created it from the + menu). The close frame schedules a 0-ms close
|
||||
* (the host's reconnect grace keeps the shell alive across a refresh).
|
||||
* The park frame (sent when the user switches to another conversation)
|
||||
* marks the pty as parked so the upcoming bare socket drop does NOT start
|
||||
* the grace countdown — the tab is still open in its session's state, so
|
||||
* the shell must survive until the user switches back or closes the tab.
|
||||
*/
|
||||
async function attachTerminal(
|
||||
ctx: Context,
|
||||
@@ -971,6 +989,16 @@ async function attachTerminal(
|
||||
ptyManager.scheduleClose(handle.key, 0)
|
||||
return
|
||||
}
|
||||
if (control !== null && control.type === 'park') {
|
||||
// The user switched to another conversation: the tab is still open in
|
||||
// its session's persisted state, but its view unmounted. Park the pty
|
||||
// so the upcoming bare socket drop does NOT start the reconnect-grace
|
||||
// countdown — the pty stays alive until the user switches back (a
|
||||
// reconnecting view clears the parked state) or explicitly closes the
|
||||
// tab (a close frame's scheduleClose clears it).
|
||||
ptyManager.park(handle.key)
|
||||
return
|
||||
}
|
||||
if (handle.exited) return
|
||||
if (
|
||||
control !== null
|
||||
@@ -986,10 +1014,14 @@ async function attachTerminal(
|
||||
ws.on('close', () => {
|
||||
dataSub.dispose()
|
||||
exitSub.dispose()
|
||||
// A bare socket drop (refresh, tab switch) leaves the process alive
|
||||
// for a grace period so a quick reconnect keeps it; the reconnect's
|
||||
// open() cancels the pending close.
|
||||
ptyManager.scheduleClose(handle.key, resolved.reconnectGraceMs)
|
||||
// A parked pty (the user switched conversations and sent `{type:'park'}`)
|
||||
// stays alive indefinitely — do NOT start the grace countdown. A bare
|
||||
// socket drop without a prior park (refresh, crash) starts the grace
|
||||
// period so a quick reconnect keeps the process; the reconnect's open()
|
||||
// cancels the pending close.
|
||||
if (!ptyManager.isParked(handle.key)) {
|
||||
ptyManager.scheduleClose(handle.key, resolved.reconnectGraceMs)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
ws.close(1011, error instanceof Error ? error.message : String(error))
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* External open actions for the file tree's "open with" menu: hand a path to
|
||||
* the OS file manager (reveal/select) or launch a URL scheme's registered
|
||||
* handler (vscode://, cursor://, zed://, custom schemes).
|
||||
*
|
||||
* The client runs in a browser / DSH Desktop renderer where a raw `vscode://`
|
||||
* navigation is unreliable, so both actions fan out through this host route
|
||||
* and spawn the platform opener with an argv array (no shell interpolation).
|
||||
* The command builders are pure — the platform is injectable — so every
|
||||
* per-platform branch is unit-testable without spawning anything.
|
||||
*/
|
||||
import { spawn } from 'node:child_process'
|
||||
import { parentOf, requireAbsolute } from './fs-tree.ts'
|
||||
import { SidebarError } from './wire.ts'
|
||||
|
||||
/** The two external open actions the route accepts. */
|
||||
export type OpenExternalAction = 'reveal' | 'url'
|
||||
|
||||
/** One platform opener invocation (argv array — never a shell string). */
|
||||
export interface ExternalCommand {
|
||||
command: string
|
||||
args: string[]
|
||||
}
|
||||
|
||||
/** Reveal/select a path in the OS file manager. On Linux there is no common
|
||||
* select protocol — the containing directory is opened instead (KISS). */
|
||||
export function revealCommand(path: string, platform: NodeJS.Platform = process.platform): ExternalCommand {
|
||||
switch (platform) {
|
||||
case 'darwin':
|
||||
return { command: 'open', args: ['-R', path] }
|
||||
// `explorer /select,<path>` — the classic "show in folder" incantation.
|
||||
// Passed as separate argv entries (no shell); verify on Windows, with
|
||||
// `cmd /c start "" explorer.exe "/select,<path>"` as the fallback.
|
||||
case 'win32':
|
||||
return { command: 'explorer.exe', args: ['/select,', path] }
|
||||
default: {
|
||||
const parent = parentOf(path)
|
||||
return { command: 'xdg-open', args: [parent ?? path] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Hand a custom-scheme URL to the OS protocol handler. */
|
||||
export function urlCommand(url: string, platform: NodeJS.Platform = process.platform): ExternalCommand {
|
||||
switch (platform) {
|
||||
case 'darwin':
|
||||
return { command: 'open', args: [url] }
|
||||
// url.dll,FileProtocolHandler launches the registered protocol handler;
|
||||
// `cmd /c start "" <url>` is the fallback if rundll32 misbehaves.
|
||||
case 'win32':
|
||||
return { command: 'rundll32.exe', args: ['url.dll,FileProtocolHandler', url] }
|
||||
default:
|
||||
return { command: 'xdg-open', args: [url] }
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate a URL-scheme open target: a parseable custom-scheme URL (never
|
||||
* http/https — those would only dump the URL into a browser tab). */
|
||||
export function validateExternalUrl(raw: string): string {
|
||||
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) {
|
||||
throw new SidebarError('bad-request', 'url must be a custom-scheme URL')
|
||||
}
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(raw)
|
||||
} catch {
|
||||
throw new SidebarError('bad-request', 'invalid url')
|
||||
}
|
||||
if (url.protocol === 'http:' || url.protocol === 'https:') {
|
||||
throw new SidebarError('bad-request', 'only custom-scheme urls can be opened externally')
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch one external open action and return immediately (detached, no
|
||||
* stdio). Spawn failures are reported through the child's 'error' event —
|
||||
* by then the route already returned, so the event is swallowed (the OS
|
||||
* dialog about a missing handler is the user-visible outcome either way).
|
||||
*/
|
||||
export function launchExternal(action: OpenExternalAction, value: string): { started: true } {
|
||||
const platform = process.platform
|
||||
const spec = action === 'reveal'
|
||||
? revealCommand(requireAbsolute(value), platform)
|
||||
: urlCommand(validateExternalUrl(value), platform)
|
||||
const child = spawn(spec.command, spec.args, { detached: true, stdio: 'ignore' })
|
||||
child.on('error', () => { /* opener missing/denied: handled by the OS */ })
|
||||
child.unref()
|
||||
return { started: true }
|
||||
}
|
||||
+49
-2
@@ -64,10 +64,32 @@ export interface SidebarPty {
|
||||
/**
|
||||
* The terminal registry. `maxPerSession` bounds concurrent processes per
|
||||
* conversation (the client caps tabs at the same number).
|
||||
*
|
||||
* Lifecycle of a UI-tab pty when its WebSocket drops:
|
||||
* - **Close frame** (`{type:'close'}`): the user closed the tab → schedule a
|
||||
* 0-ms close (quota released immediately).
|
||||
* - **Park frame** (`{type:'park'}`): the user switched to another
|
||||
* conversation; the tab is still open in its session's persisted state but
|
||||
* its view unmounted → mark the pty as parked (no auto-close countdown).
|
||||
* The pty stays alive until the user switches back (a reconnecting view
|
||||
* calls `open()` which clears the parked state) or the tab is later closed
|
||||
* (a `{type:'close'}` frame from a fresh connection). Without `park`, a
|
||||
* bare socket drop would start the reconnect-grace countdown and kill the
|
||||
* shell after `reconnectGraceMs` — wrong for a session switch, where the
|
||||
* user is still actively using the app, just in another conversation.
|
||||
* - **Bare socket drop** (no frame): page refresh, crash, plugin teardown →
|
||||
* schedule a close after `reconnectGraceMs` so a quick reconnect reattaches
|
||||
* the same shell.
|
||||
*/
|
||||
export class PtyManager {
|
||||
private readonly sessions = new Map<string, SidebarPty>()
|
||||
private readonly pendingCloses = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
/** Tabs whose view unmounted because the user switched conversations — the
|
||||
* tab is still open in its session's state, so the pty must NOT enter the
|
||||
* reconnect-grace countdown. Cleared by `cancelClose` (a reconnecting
|
||||
* view's `open()` cancels it) or by `scheduleClose` (an explicit close
|
||||
* frame still kills a parked pty). */
|
||||
private readonly parked = new Set<string>()
|
||||
|
||||
constructor(
|
||||
private readonly shell: string,
|
||||
@@ -159,7 +181,9 @@ export class PtyManager {
|
||||
* Schedule the terminal's destruction after `delayMs`. A tab close sends
|
||||
* delay 0 (release the quota immediately); a bare socket drop (refresh,
|
||||
* crash) uses the grace period so a quick reconnect keeps the process.
|
||||
* `open()` cancels any pending close.
|
||||
* `open()` cancels any pending close. Clears the parked state — an explicit
|
||||
* close frame on a parked pty (the user switched back and closed the tab)
|
||||
* still kills it.
|
||||
*/
|
||||
scheduleClose(key: string, delayMs: number): void {
|
||||
const handle = this.sessions.get(key)
|
||||
@@ -169,13 +193,36 @@ export class PtyManager {
|
||||
this.pendingCloses.set(key, timer)
|
||||
}
|
||||
|
||||
/** Cancel a pending scheduled close (the terminal is being reopened). */
|
||||
/**
|
||||
* Park a terminal: the owning tab's view unmounted because the user
|
||||
* switched to another conversation, but the tab is still open in its
|
||||
* session's persisted state. Cancels any pending grace close and marks
|
||||
* the pty so the host's `ws.on('close')` handler does NOT start the
|
||||
* reconnect-grace countdown — the pty stays alive until the user switches
|
||||
* back (a reconnecting view's `open()` clears this) or explicitly closes
|
||||
* the tab (a `{type:'close'}` frame's `scheduleClose` clears this).
|
||||
*/
|
||||
park(key: string): void {
|
||||
if (this.sessions.get(key) === undefined) return
|
||||
this.cancelClose(key)
|
||||
this.parked.add(key)
|
||||
}
|
||||
|
||||
/** Whether this pty was parked (its view unmounted for a session switch). */
|
||||
isParked(key: string): boolean {
|
||||
return this.parked.has(key)
|
||||
}
|
||||
|
||||
/** Cancel a pending scheduled close (the terminal is being reopened).
|
||||
* Also clears the parked state — a reconnecting view reattaches a parked
|
||||
* pty and resumes normal lifecycle. */
|
||||
cancelClose(key: string): void {
|
||||
const timer = this.pendingCloses.get(key)
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer)
|
||||
this.pendingCloses.delete(key)
|
||||
}
|
||||
this.parked.delete(key)
|
||||
}
|
||||
|
||||
/** Resolve a live handle by key, or undefined. */
|
||||
|
||||
+32
-8
@@ -35,6 +35,11 @@ export const LABEL_MAX_CHARS = 48
|
||||
* the two plugins' threads render consistently in either UI). */
|
||||
export const SIDE_BOUNDARY_PREFIX = 'Side conversation boundary'
|
||||
|
||||
/** The plugin identity stamped on the source of context-injection messages
|
||||
* (boundary prompt + parked snapshot), so the transcript recognizes them
|
||||
* structurally — not by text prefix. */
|
||||
export const SIDE_INJECTION_PLUGIN = 'dsh-better-sidebar'
|
||||
|
||||
/**
|
||||
* The boundary prompt delivered as the thread's first user message: the
|
||||
* inherited seed is reference context only, never active instruction.
|
||||
@@ -365,18 +370,37 @@ export function sideLabel(question: string): string {
|
||||
export function boundaryDelivered(events: readonly SidechatLogEvent[]): boolean {
|
||||
for (const event of events) {
|
||||
if (event.type !== 'user/message') continue
|
||||
const content = dataOf(event).content
|
||||
const first = Array.isArray(content) ? content[0] : content
|
||||
const text = typeof first === 'string'
|
||||
? first
|
||||
: (typeof first === 'object' && first !== null && 'text' in first
|
||||
? String((first as { text: unknown }).text)
|
||||
: '')
|
||||
if (text.startsWith(SIDE_BOUNDARY_PREFIX)) return true
|
||||
if (messageLeadText(dataOf(event)).startsWith(SIDE_BOUNDARY_PREFIX)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** The leading text of a user/message's content (block array or bare string). */
|
||||
function messageLeadText(data: Record<string, unknown>): string {
|
||||
const content = data.content
|
||||
const first = Array.isArray(content) ? content[0] : content
|
||||
return typeof first === 'string'
|
||||
? first
|
||||
: (typeof first === 'object' && first !== null && 'text' in first
|
||||
? String((first as { text: unknown }).text)
|
||||
: '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a logged user/message is a CONTEXT INJECTION (the boundary prompt
|
||||
* plus the parked in-progress snapshot) rather than a real user message.
|
||||
* New threads deliver the injection via `agent.inject` stamped with a
|
||||
* non-'user' source kind; threads created before that split carry
|
||||
* boundary+question in ONE 'user' message, recognized by the boundary
|
||||
* prefix. Both render as one collapsible injection row — never as a user
|
||||
* bubble.
|
||||
*/
|
||||
export function isContextInjectionMessage(data: Record<string, unknown>): boolean {
|
||||
const source = data.source as { kind?: unknown } | null | undefined
|
||||
if (source?.kind !== undefined && source.kind !== 'user') return true
|
||||
return messageLeadText(data).startsWith(SIDE_BOUNDARY_PREFIX)
|
||||
}
|
||||
|
||||
/** The info the thread header shows (live runtime state + agent identity). */
|
||||
export interface SidechatThreadInfo {
|
||||
/** A live agent drives the thread right now (false = cold/persisted). */
|
||||
|
||||
+23
-4
@@ -37,6 +37,7 @@ import {
|
||||
buildSidechatInheritance,
|
||||
resolvePresetId,
|
||||
SIDE_BOUNDARY_PROMPT,
|
||||
SIDE_INJECTION_PLUGIN,
|
||||
SIDE_NEW_THREAD_TITLE,
|
||||
sideLabel,
|
||||
type SeedEvent,
|
||||
@@ -124,6 +125,26 @@ function admitFollowup(agent: Agent, blocks: ContentBlock[]): void {
|
||||
agent.followup(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver the thread's FIRST contact as TWO log-separated messages: the
|
||||
* boundary prompt (+ the parked in-progress snapshot) rides `agent.inject`
|
||||
* — queued model-facing context that does NOT wake the driver and is
|
||||
* claimed FIRST at the opening step (Inbox.claim drains next-step before
|
||||
* next-turn) — and the user's question is the follow-up that wakes it. The
|
||||
* log therefore records two user/message events (injection, then question)
|
||||
* instead of one wrapped blob: the transcript shows the question as a user
|
||||
* bubble and collapses the injection as a context row. The injection source
|
||||
* is stamped `kind: 'plugin'` so recognition is structural; its text still
|
||||
* opens with SIDE_BOUNDARY_PREFIX, keeping boundaryDelivered intact.
|
||||
*/
|
||||
function admitFirstContact(agent: Agent, injectionText: string, question: string): void {
|
||||
agent.inject(createUserMessage({
|
||||
content: textPrompt(injectionText),
|
||||
source: { kind: 'plugin', plugin: SIDE_INJECTION_PLUGIN },
|
||||
}))
|
||||
admitFollowup(agent, textPrompt(question))
|
||||
}
|
||||
|
||||
/** The live thread agent, or undefined (cold — the caller resumes). */
|
||||
function liveThreadAgent(ctx: Context, childId: string): Agent | undefined {
|
||||
const agents = ctx.get('agents') as { get(id: string): Agent | undefined } | undefined
|
||||
@@ -217,8 +238,7 @@ export function buildSidechatApi(ctx: Context): SidechatRoutes {
|
||||
} else {
|
||||
const promptParts = [SIDE_BOUNDARY_PROMPT]
|
||||
if (inheritance.snapshot !== null) promptParts.push(inheritance.snapshot)
|
||||
promptParts.push(question)
|
||||
admitFollowup(handle.agent, textPrompt(promptParts.join('\n\n')))
|
||||
admitFirstContact(handle.agent, promptParts.join('\n\n'), question)
|
||||
pinTitle(sideLabel(question))
|
||||
}
|
||||
return { childId }
|
||||
@@ -257,8 +277,7 @@ export function buildSidechatApi(ctx: Context): SidechatRoutes {
|
||||
const snapshot = pendingSnapshots.get(childId)
|
||||
pendingSnapshots.delete(childId)
|
||||
if (snapshot !== undefined) parts.push(snapshot)
|
||||
parts.push(text)
|
||||
admitFollowup(agent, textPrompt(parts.join('\n\n')))
|
||||
admitFirstContact(agent, parts.join('\n\n'), text)
|
||||
const titles = ctx.get('sessionTitle') as SidebarSessionTitleService | undefined
|
||||
if (titles !== undefined) {
|
||||
try {
|
||||
|
||||
@@ -94,6 +94,9 @@ describe('built-in tab registrations', () => {
|
||||
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)
|
||||
// The open-with configuration (SSH host + custom editors) is the custom
|
||||
// panel rendered below the declarative rows.
|
||||
expect(service.getTab('editor')?.settings?.render).toBeDefined()
|
||||
})
|
||||
|
||||
it('the terminal tab declares the model terminal-tools, auto-terminal, shell and custom-font settings', () => {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { DiffView } from '../src/client/DiffView.tsx'
|
||||
|
||||
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
const diff = [
|
||||
'diff --git a/src/a.ts b/src/a.ts',
|
||||
'--- a/src/a.ts',
|
||||
'+++ b/src/a.ts',
|
||||
'@@ -1 +1 @@',
|
||||
'-old-a',
|
||||
'+new-a',
|
||||
'diff --git a/tests/b.spec.ts b/tests/b.spec.ts',
|
||||
'--- a/tests/b.spec.ts',
|
||||
'+++ b/tests/b.spec.ts',
|
||||
'@@ -1 +1 @@',
|
||||
'-old-b',
|
||||
'+new-b',
|
||||
'diff --git a/README.md b/README.md',
|
||||
'--- a/README.md',
|
||||
'+++ b/README.md',
|
||||
'@@ -1 +1 @@',
|
||||
'-old-doc',
|
||||
'+new-doc',
|
||||
].join('\n')
|
||||
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
describe('DiffView file folding', () => {
|
||||
it('expands source by default while tests and docs stay collapsed', () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const root: Root = createRoot(container)
|
||||
try {
|
||||
act(() => { root.render(createElement(DiffView, { diff })) })
|
||||
const headers = [...container.querySelectorAll<HTMLButtonElement>('button[aria-expanded]')]
|
||||
expect(headers).toHaveLength(3)
|
||||
expect(headers.map(header => header.getAttribute('aria-expanded'))).toEqual(['true', 'false', 'false'])
|
||||
expect(container.textContent).toContain('new-a')
|
||||
expect(container.textContent).not.toContain('new-b')
|
||||
expect(container.textContent).not.toContain('new-doc')
|
||||
|
||||
act(() => { headers[1]!.click() })
|
||||
expect(headers[1]!.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(container.textContent).toContain('new-b')
|
||||
|
||||
act(() => { headers[0]!.click() })
|
||||
expect(headers[0]!.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(container.textContent).not.toContain('new-a')
|
||||
} finally {
|
||||
act(() => { root.unmount() })
|
||||
container.remove()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Toggle-layout lane (issue #315): while the right panel slides open/closed
|
||||
* with its 300ms layout push, the BOTTOM panel must keep tracking the center
|
||||
* column's horizontal edges frame by frame.
|
||||
*
|
||||
* The shell changed the bottom panel's geometry source from React state
|
||||
* (setCenterRect per ResizeObserver frame → full Sidebar re-render at
|
||||
* animation cadence) to a ref + direct inline writes, so the panel still
|
||||
* follows the animated center column with zero React work. This lane samples
|
||||
* the transition and asserts the bottom panel's edges stay glued to the
|
||||
* center column (left/right within a rounding + one-frame tolerance) — the
|
||||
* visual-equivalence proof for that change.
|
||||
*
|
||||
* The server is booted by scripts/e2e-mount.sh; this spec only loads the
|
||||
* page (own workspace, like drag-layout.e2e.ts).
|
||||
*/
|
||||
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)')
|
||||
}
|
||||
|
||||
const WORKSPACE_PATH = process.env.DSH_E2E_TOGGLE_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-toggle-workspace')
|
||||
|
||||
let api: APIRequestContext
|
||||
|
||||
async function seedSession(): Promise<void> {
|
||||
mkdirSync(WORKSPACE_PATH, { recursive: true })
|
||||
writeFileSync(join(WORKSPACE_PATH, 'seed.txt'), 'toggle lane\n')
|
||||
const workspace = await api.post(`${BASE_URL}/api/workspace.create`, {
|
||||
data: { type: 'client-request', rpcId: 'e2e-toggle-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-toggle-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
|
||||
centerLeft: number
|
||||
centerRight: number
|
||||
bottomLeft: number
|
||||
bottomRight: number
|
||||
}
|
||||
|
||||
test('bottom panel tracks the center column during the right-panel toggle transition (issue #315)', 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 onboarding (same dance as the drag lane).
|
||||
try {
|
||||
await expect
|
||||
.poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 })
|
||||
.toBeGreaterThan(0)
|
||||
} catch {
|
||||
console.warn('[e2e-toggle] 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(800)
|
||||
} catch {
|
||||
// Masked by a takeover stacked above; retry in the next round.
|
||||
}
|
||||
}
|
||||
if (!dismissed) break
|
||||
}
|
||||
|
||||
// Open the right panel (fresh sessions start collapsed).
|
||||
const expandButton = sidebar.getByRole('button', { name: 'Expand sidebar' })
|
||||
await expect(expandButton).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)
|
||||
|
||||
// Open the bottom panel too: the twin panels make the toggle exercise the
|
||||
// full geometry chain (bottom panel edges follow the animated push).
|
||||
const expandBottom = sidebar.getByRole('button', { name: 'Expand bottom panel' })
|
||||
await expect(expandBottom).toHaveCount(1)
|
||||
await expandBottom.click()
|
||||
// Wait until the bottom panel is REALLY tracking the center column (not
|
||||
// the hidden {0,0} fallback): the panel's box exists even while hidden, so
|
||||
// wait for the computed visibility + a matched right edge before sampling.
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => {
|
||||
const col = document.querySelector('#root [data-slot="conversation"]')?.parentElement
|
||||
const bottom = document.querySelector('[data-dsh-bottom-panel]')
|
||||
if (col === null || col === undefined || bottom === null) return false
|
||||
const cr = col.getBoundingClientRect()
|
||||
const br = bottom.getBoundingClientRect()
|
||||
const visible = getComputedStyle(bottom).visibility !== 'hidden'
|
||||
return cr.width > 100 && visible && Math.abs(br.right - cr.right) <= 3
|
||||
}), { timeout: 30_000 })
|
||||
.toBe(true)
|
||||
await page.waitForTimeout(500) // let both open transitions settle
|
||||
|
||||
// Per-frame sampler: center column (same anchor as layout.css) vs the
|
||||
// bottom panel's visible edges. The loop re-resolves
|
||||
// window.__toggleSamples every frame, so clearing it (sampleToggle) drops
|
||||
// old frames without detaching the loop.
|
||||
await page.evaluate(() => {
|
||||
const center = document.querySelector('#root [data-slot="conversation"]')?.parentElement
|
||||
const bottom = document.querySelector('[data-dsh-bottom-panel]')
|
||||
;(window as unknown as { __toggleSamples: FrameSample[] }).__toggleSamples = []
|
||||
const loop = (): void => {
|
||||
const c = center?.getBoundingClientRect() ?? { left: 0, right: 0 }
|
||||
const b = bottom?.getBoundingClientRect() ?? { left: 0, right: 0 }
|
||||
;(window as unknown as { __toggleSamples: FrameSample[] }).__toggleSamples.push({
|
||||
t: performance.now(), centerLeft: c.left, centerRight: c.right, bottomLeft: b.left, bottomRight: b.right,
|
||||
})
|
||||
requestAnimationFrame(loop)
|
||||
}
|
||||
requestAnimationFrame(loop)
|
||||
})
|
||||
|
||||
const collapseButton = sidebar.getByRole('button', { name: 'Collapse sidebar' })
|
||||
const expandButton2 = sidebar.getByRole('button', { name: 'Expand sidebar' })
|
||||
|
||||
async function sampleToggle(click: () => Promise<void>): Promise<FrameSample[]> {
|
||||
// Clear accumulated frames so the sample window starts at the click.
|
||||
await page.evaluate(() => {
|
||||
;(window as unknown as { __toggleSamples: FrameSample[] }).__toggleSamples = []
|
||||
})
|
||||
await click()
|
||||
await page.waitForTimeout(800)
|
||||
const samples = await page.evaluate(() => {
|
||||
const data = (window as unknown as { __toggleSamples: FrameSample[] }).__toggleSamples
|
||||
;(window as unknown as { __toggleSamples: FrameSample[] }).__toggleSamples = []
|
||||
return data
|
||||
})
|
||||
return samples
|
||||
}
|
||||
|
||||
function trackMiss(samples: FrameSample[], label: string): void {
|
||||
// The bottom panel's geometry rides the center column's ResizeObserver:
|
||||
// RO callbacks run AFTER layout, so a write there lands in the NEXT
|
||||
// frame's paint — the panel is exactly ONE animation frame behind the
|
||||
// column mid-transition (measured: bottom(t) === center(t-1)).
|
||||
// Anything more is a real desync; the settled frames must agree exactly.
|
||||
const valid = samples.filter(s => s.centerRight - s.centerLeft > 100)
|
||||
expect(valid.length, `${label}: sampler must have captured the transition`).toBeGreaterThan(3)
|
||||
|
||||
let maxOneFrame = 0
|
||||
for (let i = 1; i < valid.length; i++) {
|
||||
maxOneFrame = Math.max(
|
||||
maxOneFrame,
|
||||
Math.abs(valid[i]!.bottomLeft - valid[i - 1]!.centerLeft),
|
||||
Math.abs(valid[i]!.bottomRight - valid[i - 1]!.centerRight),
|
||||
)
|
||||
}
|
||||
const settled = valid.slice(-5)
|
||||
const settleMiss = Math.max(
|
||||
...settled.map(s => Math.max(
|
||||
Math.abs(s.bottomLeft - s.centerLeft),
|
||||
Math.abs(s.bottomRight - s.centerRight),
|
||||
)),
|
||||
)
|
||||
console.log(`[e2e-toggle] ${label}: ${valid.length} frames, max one-frame miss=${maxOneFrame.toFixed(1)}px, settle miss=${settleMiss.toFixed(1)}px`)
|
||||
expect(maxOneFrame, `${label}: bottom panel must track the center column one frame behind (RO cadence)`).toBeLessThanOrEqual(4)
|
||||
expect(settleMiss, `${label}: bottom panel must settle exactly on the center column`).toBeLessThanOrEqual(3)
|
||||
}
|
||||
|
||||
const collapseSamples = await sampleToggle(() => collapseButton.click())
|
||||
trackMiss(collapseSamples, 'collapse')
|
||||
|
||||
const expandSamples = await sampleToggle(() => expandButton2.click())
|
||||
trackMiss(expandSamples, 'expand')
|
||||
})
|
||||
@@ -219,7 +219,7 @@ describe('EditorHost (files window)', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('dragging the panel edge resizes the dock and persists meta.treeWidth on release', () => {
|
||||
it('dragging the panel edge resizes the dock and persists meta.treeWidth on release', async () => {
|
||||
const { store, ctx, homeTab } = setup()
|
||||
store.setPrefs({ ...store.getPrefs(), editorExplorer: true })
|
||||
const { container, unmount } = mountHost(ctx, store, homeTab)
|
||||
@@ -232,10 +232,15 @@ describe('EditorHost (files window)', () => {
|
||||
// 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).
|
||||
// Moves are batched to one application per frame (#315), so flush the
|
||||
// pending frame before asserting the width.
|
||||
act(() => {
|
||||
handle.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 300 }))
|
||||
handle.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 200 }))
|
||||
})
|
||||
await act(async () => {
|
||||
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()))
|
||||
})
|
||||
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 })) })
|
||||
|
||||
@@ -14,6 +14,7 @@ import { createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { FileTree } from '../src/client/FileTree.tsx'
|
||||
import { TAB_DRAG_TYPE } from '../src/client/TabBar.tsx'
|
||||
import type { UploadItem } from '../src/client/upload.ts'
|
||||
|
||||
// The act() environment flag (React 18.2 reads it before flushing effects).
|
||||
@@ -72,12 +73,12 @@ async function mountTree(busy = false): Promise<Harness> {
|
||||
}
|
||||
|
||||
/** A bubbling drag event carrying a stub dataTransfer (jsdom has no DragEvent). */
|
||||
function dragEvent(type: string): Event {
|
||||
function dragEvent(type: string, dataTypes: string[] = ['Files']): Event {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(event, 'dataTransfer', {
|
||||
// No entry API on the items, so drops fall back to the flat file list.
|
||||
value: {
|
||||
types: ['Files'],
|
||||
types: dataTypes,
|
||||
items: [],
|
||||
files: [new File(['x'], 'dropped.txt')],
|
||||
dropEffect: '',
|
||||
@@ -87,8 +88,8 @@ function dragEvent(type: string): Event {
|
||||
}
|
||||
|
||||
/** Dispatch inside act() so React flushes the state update. */
|
||||
function fire(target: Element, type: string): Event {
|
||||
const event = dragEvent(type)
|
||||
function fire(target: Element, type: string, dataTypes?: string[]): Event {
|
||||
const event = dragEvent(type, dataTypes)
|
||||
act(() => { target.dispatchEvent(event) })
|
||||
return event
|
||||
}
|
||||
@@ -179,4 +180,26 @@ describe('FileTree drag-drop surface', () => {
|
||||
expect(busyHarness.uploads).toHaveLength(0)
|
||||
busyHarness.unmount()
|
||||
})
|
||||
|
||||
it('ignores an in-app tab drag: no upload drop zone and events pass through', () => {
|
||||
const enter = fire(harness.body, 'dragenter', [TAB_DRAG_TYPE])
|
||||
expect(enter.defaultPrevented).toBe(false)
|
||||
expect(dropZone()).toBeNull()
|
||||
const over = fire(harness.body, 'dragover', [TAB_DRAG_TYPE])
|
||||
expect(over.defaultPrevented).toBe(false)
|
||||
expect(dropZone()).toBeNull()
|
||||
// A row hover must not retarget the drop directory either.
|
||||
const dir = rowByName(harness.container, 'src')
|
||||
const rowOver = fire(dir, 'dragover', [TAB_DRAG_TYPE])
|
||||
expect(rowOver.defaultPrevented).toBe(false)
|
||||
expect(dir.className).not.toContain('explorerRowDropTarget')
|
||||
})
|
||||
|
||||
it('passes an in-app tab drop through without reporting an upload', async () => {
|
||||
const drop = fire(harness.body, 'drop', [TAB_DRAG_TYPE])
|
||||
await act(async () => {})
|
||||
expect(drop.defaultPrevented).toBe(false)
|
||||
expect(harness.uploads).toHaveLength(0)
|
||||
expect(dropZone()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* FileTree "open with" context menu: a file row's right-click menu gains the
|
||||
* pinned direct rows and the parent submenu row; the submenu lists every
|
||||
* resolved target with a per-row pushpin, pinning never selects/closes the
|
||||
* menu, and selecting a child invokes the caller's open handler with the
|
||||
* row's absolute path. When the caller wires nothing, the section is absent.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { beforeAll, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { FileTree } from '../src/client/FileTree.tsx'
|
||||
import type { OpenWithTarget } from '../src/client/open-with.ts'
|
||||
|
||||
// The act() environment flag (React 18.2 reads it before flushing effects).
|
||||
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
// vitest 4.1.11+ follows the OS locale; pin en-US so menu copy is English.
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
|
||||
})
|
||||
|
||||
vi.mock('../src/client/api.ts', () => ({
|
||||
api: {
|
||||
fsTree: async () => ({
|
||||
entries: [{ name: 'a.ts', path: '/tmp/a.ts', isDir: false }],
|
||||
}),
|
||||
},
|
||||
downloadUrl: () => '/sidebar/file',
|
||||
}))
|
||||
|
||||
/** The resolved target list a caller (EditorHost) would hand FileTree. */
|
||||
const targets: OpenWithTarget[] = [
|
||||
{ id: 'explorer', nameKey: 'openWithExplorer', name: '', kind: 'reveal', isVscodeFamily: false, localOnly: true },
|
||||
{ id: 'vscode', nameKey: 'openWithVscode', name: '', kind: 'url', urlTemplate: 'vscode://file/{path}', isVscodeFamily: true, localOnly: false },
|
||||
{ id: 'cursor', nameKey: 'openWithCursor', name: '', kind: 'url', urlTemplate: 'cursor://file/{path}', isVscodeFamily: true, localOnly: false },
|
||||
{ id: 'zed', nameKey: 'openWithZed', name: '', kind: 'url', urlTemplate: 'zed://file/{path}', isVscodeFamily: false, localOnly: true },
|
||||
{ id: 'custom:w', name: 'Windsurf', kind: 'url', urlTemplate: 'windsurf://file/{path}', isVscodeFamily: false, localOnly: false },
|
||||
]
|
||||
|
||||
interface Harness {
|
||||
container: HTMLDivElement
|
||||
onOpenWith: ReturnType<typeof vi.fn>
|
||||
onToggleOpenWithPin: ReturnType<typeof vi.fn>
|
||||
unmount: () => void
|
||||
}
|
||||
|
||||
async function mountTree(overrides: {
|
||||
openWithTargets?: OpenWithTarget[]
|
||||
openWithPinned?: string[]
|
||||
openWithSsh?: boolean
|
||||
} = {}): Promise<Harness> {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const root: Root = createRoot(container)
|
||||
const onOpenWith = vi.fn()
|
||||
const onToggleOpenWithPin = vi.fn()
|
||||
// Property-presence semantics: an explicit `openWithTargets: undefined`
|
||||
// must reach FileTree (the section hides) instead of falling back.
|
||||
const has = (key: 'openWithTargets' | 'openWithPinned' | 'openWithSsh'): boolean =>
|
||||
Object.prototype.hasOwnProperty.call(overrides, key)
|
||||
await act(async () => {
|
||||
root.render(createElement(FileTree, {
|
||||
sessionId: 's1',
|
||||
cwd: '/tmp',
|
||||
expanded: [],
|
||||
onToggle: () => {},
|
||||
onOpenFile: () => {},
|
||||
onOpenFileNewTab: () => {},
|
||||
onOpenFileSide: () => {},
|
||||
openWithTargets: has('openWithTargets') ? overrides.openWithTargets : targets,
|
||||
openWithPinned: has('openWithPinned') ? overrides.openWithPinned : ['vscode'],
|
||||
openWithSsh: has('openWithSsh') ? overrides.openWithSsh : false,
|
||||
onOpenWith,
|
||||
onToggleOpenWithPin,
|
||||
onReferenceFile: () => {},
|
||||
refreshTick: 0,
|
||||
onUploadRequest: () => {},
|
||||
busy: false,
|
||||
}))
|
||||
})
|
||||
return {
|
||||
container,
|
||||
onOpenWith,
|
||||
onToggleOpenWithPin,
|
||||
unmount: () => { act(() => { root.unmount() }) },
|
||||
}
|
||||
}
|
||||
|
||||
/** The file row of the one-level tree (role="button" with the name span). */
|
||||
function fileRow(container: HTMLDivElement): HTMLElement {
|
||||
const row = [...container.querySelectorAll<HTMLElement>('[role="button"]')]
|
||||
.find(el => el.querySelector('[class*="explorerName"]')?.textContent === 'a.ts')
|
||||
if (row === undefined) throw new Error('file row not found')
|
||||
return row
|
||||
}
|
||||
|
||||
/** Open the row's context menu at a fixed cursor position. */
|
||||
function openMenu(container: HTMLDivElement): void {
|
||||
const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 20, clientY: 30 })
|
||||
act(() => { fileRow(container).dispatchEvent(event) })
|
||||
}
|
||||
|
||||
describe('FileTree open-with menu', () => {
|
||||
let harness: Harness
|
||||
afterEach(() => {
|
||||
harness.unmount()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('renders the pinned direct row and the submenu parent for a file row', async () => {
|
||||
harness = await mountTree()
|
||||
openMenu(harness.container)
|
||||
const items = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
|
||||
// The pinned VS Code sits at the top level (before the submenu parent).
|
||||
expect(items.map(item => item.textContent?.trim())).toContain('VS Code')
|
||||
const parent = items.find(item => item.getAttribute('aria-haspopup') === 'menu')
|
||||
expect(parent?.textContent).toContain('Open with')
|
||||
// The submenu parent carries the trailing chevron affordance (the
|
||||
// primitives Menu renders no arrow of its own), right-aligned inside a
|
||||
// full-width label row — the same structure the submenu children use.
|
||||
expect(parent?.querySelector('[class*="openWithChevron"]')).not.toBeNull()
|
||||
expect(parent?.querySelector('[class*="openWithLabel"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('opens the submenu on click and lists every target with pin toggles', async () => {
|
||||
harness = await mountTree()
|
||||
openMenu(harness.container)
|
||||
const parent = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
|
||||
.find(item => item.getAttribute('aria-haspopup') === 'menu')
|
||||
expect(parent).toBeDefined()
|
||||
act(() => { parent!.click() })
|
||||
// Sub child rows = menuitems inside the nested menu.
|
||||
const children = [...document.querySelectorAll<HTMLElement>('[role="menu"] [role="menu"] [role="menuitem"]')]
|
||||
expect(children.map(item => item.textContent?.trim())).toEqual([
|
||||
'File Manager', 'VS Code', 'Cursor', 'Zed', 'Windsurf',
|
||||
])
|
||||
expect(children.every(item => item.querySelector('[class*="openWithPin"]') !== null)).toBe(true)
|
||||
// Pinned state is announced per row (and swaps the pushpin glyph).
|
||||
const vscodeRow = children.find(item => item.textContent?.trim() === 'VS Code')
|
||||
const cursorRow0 = children.find(item => item.textContent?.trim() === 'Cursor')
|
||||
expect(vscodeRow?.querySelector('[aria-label="Unpin"]')).not.toBeNull()
|
||||
expect(cursorRow0?.querySelector('[aria-label="Pin to menu"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('pin click toggles without selecting the row or closing the menu', async () => {
|
||||
harness = await mountTree()
|
||||
openMenu(harness.container)
|
||||
const parent = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
|
||||
.find(item => item.getAttribute('aria-haspopup') === 'menu')
|
||||
act(() => { parent!.click() })
|
||||
const cursorRow = [...document.querySelectorAll<HTMLElement>('[role="menu"] [role="menu"] [role="menuitem"]')]
|
||||
.find(item => item.textContent?.trim() === 'Cursor')
|
||||
const pin = cursorRow!.querySelector<HTMLElement>('[class*="openWithPin"]')
|
||||
expect(pin).not.toBeNull()
|
||||
act(() => { pin!.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) })
|
||||
expect(harness.onToggleOpenWithPin).toHaveBeenCalledWith('cursor')
|
||||
expect(harness.onOpenWith).not.toHaveBeenCalled()
|
||||
// The menu (and the submenu) stayed open.
|
||||
expect(document.querySelector('[role="menu"] [role="menu"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('selecting a submenu child invokes onOpenWith with the row path and closes the menu', async () => {
|
||||
harness = await mountTree()
|
||||
openMenu(harness.container)
|
||||
const parent = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
|
||||
.find(item => item.getAttribute('aria-haspopup') === 'menu')
|
||||
act(() => { parent!.click() })
|
||||
const zedRow = [...document.querySelectorAll<HTMLElement>('[role="menu"] [role="menu"] [role="menuitem"]')]
|
||||
.find(item => item.textContent?.trim() === 'Zed')
|
||||
act(() => { zedRow!.click() })
|
||||
expect(harness.onOpenWith).toHaveBeenCalledWith('zed', '/tmp/a.ts')
|
||||
// Selecting closes the row menu entirely.
|
||||
expect(document.querySelector('[role="menu"] [role="menu"]')).toBeNull()
|
||||
expect(document.querySelector('[role="menuitem"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('appends the SSH hint to VSCode-family labels in remote mode', async () => {
|
||||
harness = await mountTree({
|
||||
openWithSsh: true,
|
||||
openWithTargets: targets.filter(target => !target.localOnly),
|
||||
openWithPinned: [],
|
||||
})
|
||||
openMenu(harness.container)
|
||||
const parent = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
|
||||
.find(item => item.getAttribute('aria-haspopup') === 'menu')
|
||||
act(() => { parent!.click() })
|
||||
const children = [...document.querySelectorAll<HTMLElement>('[role="menu"] [role="menu"] [role="menuitem"]')]
|
||||
expect(children.map(item => item.textContent?.trim())).toEqual([
|
||||
'VS Code (SSH)', 'Cursor (SSH)', 'Windsurf (SSH)',
|
||||
])
|
||||
})
|
||||
|
||||
it('hides the whole section when the caller wires no targets', async () => {
|
||||
harness = await mountTree({ openWithTargets: undefined })
|
||||
openMenu(harness.container)
|
||||
const items = [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
|
||||
expect(items.some(item => item.textContent?.includes('Open with'))).toBe(false)
|
||||
expect(items.some(item => item.getAttribute('aria-haspopup') === 'menu')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Frame-batcher: coalesce bursty schedules into one task per animation
|
||||
* frame, flush synchronously on release, and drop pending work on dispose.
|
||||
* Uses a deterministic rAF stub so the coalescing contract is exact.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createFrameBatcher } from '../src/client/frame-batcher.ts'
|
||||
|
||||
let frames: Map<number, () => void>
|
||||
let nextId: number
|
||||
|
||||
function stepFrame(): void {
|
||||
const callbacks = [...frames.values()]
|
||||
frames.clear()
|
||||
for (const callback of callbacks) callback()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
frames = new Map()
|
||||
nextId = 1
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: () => void) => {
|
||||
frames.set(nextId, callback)
|
||||
return nextId++
|
||||
})
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => {
|
||||
frames.delete(id)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('createFrameBatcher', () => {
|
||||
it('coalesces many schedules into one task per frame (latest wins)', () => {
|
||||
const batcher = createFrameBatcher()
|
||||
const calls: number[] = []
|
||||
let pending = 0
|
||||
const apply = (value: number): void => { calls.push(value) }
|
||||
|
||||
// Caller pattern: compose into a ref, schedule a reader of that ref.
|
||||
for (const value of [1, 2, 3, 4]) {
|
||||
pending = value
|
||||
batcher.schedule(() => apply(pending))
|
||||
}
|
||||
expect(calls).toEqual([])
|
||||
stepFrame()
|
||||
expect(calls).toEqual([4])
|
||||
expect(frames.size).toBe(0)
|
||||
|
||||
// The next burst starts a fresh frame.
|
||||
pending = 7
|
||||
batcher.schedule(() => apply(pending))
|
||||
stepFrame()
|
||||
expect(calls).toEqual([4, 7])
|
||||
})
|
||||
|
||||
it('flushNow runs the pending task synchronously and cancels the frame', () => {
|
||||
const batcher = createFrameBatcher()
|
||||
const calls: number[] = []
|
||||
batcher.schedule(() => calls.push(1))
|
||||
batcher.schedule(() => calls.push(2))
|
||||
expect(frames.size).toBe(1) // still one pending frame
|
||||
|
||||
batcher.flushNow()
|
||||
expect(calls).toEqual([2]) // latest task, synchronously
|
||||
expect(frames.size).toBe(0) // frame cancelled
|
||||
|
||||
stepFrame() // the cancelled frame must not fire again
|
||||
expect(calls).toEqual([2])
|
||||
|
||||
// flushNow when nothing is scheduled is a no-op.
|
||||
batcher.flushNow()
|
||||
expect(calls).toEqual([2])
|
||||
})
|
||||
|
||||
it('dispose drops the pending task without running it', () => {
|
||||
const batcher = createFrameBatcher()
|
||||
const calls: number[] = []
|
||||
batcher.schedule(() => calls.push(1))
|
||||
batcher.dispose()
|
||||
expect(frames.size).toBe(0)
|
||||
stepFrame()
|
||||
expect(calls).toEqual([])
|
||||
|
||||
// A disposed batcher accepts new schedules (React strict-mode remounts
|
||||
// reuse the same ref-created instance semantics).
|
||||
batcher.schedule(() => calls.push(2))
|
||||
stepFrame()
|
||||
expect(calls).toEqual([2])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() }))
|
||||
|
||||
vi.mock('node:child_process', () => ({ spawn: spawnMock }))
|
||||
|
||||
import { isGitRepo } from '../src/git.ts'
|
||||
|
||||
afterEach(() => {
|
||||
spawnMock.mockReset()
|
||||
})
|
||||
|
||||
describe('git subprocess spawning', () => {
|
||||
it('hides spawned git windows', async () => {
|
||||
spawnMock.mockImplementation(() => {
|
||||
const child = new EventEmitter()
|
||||
const stdout = new PassThrough()
|
||||
const stderr = new PassThrough()
|
||||
Object.assign(child, { stdout, stderr, kill: vi.fn() })
|
||||
|
||||
queueMicrotask(() => {
|
||||
stdout.end('true\n')
|
||||
stderr.end()
|
||||
child.emit('close', 0)
|
||||
})
|
||||
|
||||
return child
|
||||
})
|
||||
|
||||
await expect(isGitRepo('C:\\repo')).resolves.toBe(true)
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1)
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'git',
|
||||
['-C', 'C:\\repo', '--no-pager', '-c', 'color.ui=false', 'rev-parse', '--is-inside-work-tree'],
|
||||
expect.objectContaining({ windowsHide: true }),
|
||||
)
|
||||
})
|
||||
})
|
||||
+35
-1
@@ -1,6 +1,10 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseUnifiedDiff } from '../src/client/DiffView.tsx'
|
||||
import { parseLogLines, parsePorcelainZ } from '../src/git.ts'
|
||||
import { parseLogLines, parsePorcelainZ, status } from '../src/git.ts'
|
||||
|
||||
describe('git parsing', () => {
|
||||
it('parses porcelain -z entries including renames', () => {
|
||||
@@ -14,6 +18,36 @@ describe('git parsing', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps untracked files inside new directories as individual rows (status --untracked-files=all)', () => {
|
||||
// status() runs with --untracked-files=all, so a new folder must surface
|
||||
// as one entry PER FILE (?? newdir/a.ts), never a collapsed ?? newdir/
|
||||
// row that has no diff and cannot be read (regression: new folders showed
|
||||
// as a single folder row whose diff tab failed with "is a directory").
|
||||
const output = ['?? newdir/a.ts', '?? newdir/sub/b.ts', ''].join('\0')
|
||||
expect(parsePorcelainZ(output)).toEqual([
|
||||
{ path: 'newdir/a.ts', xy: '??' },
|
||||
{ path: 'newdir/sub/b.ts', xy: '??' },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps untracked files inside new directories as individual rows (real git)', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-git-status-'))
|
||||
try {
|
||||
execFileSync('git', ['init', '-q'], { cwd: root })
|
||||
execFileSync('git', ['config', 'user.email', 't@t'], { cwd: root })
|
||||
execFileSync('git', ['config', 'user.name', 't'], { cwd: root })
|
||||
execFileSync('git', ['commit', '-q', '--allow-empty', '-m', 'init'], { cwd: root })
|
||||
mkdirSync(join(root, 'newdir'))
|
||||
writeFileSync(join(root, 'newdir', 'a.ts'), 'x')
|
||||
const result = await status(root)
|
||||
const paths = result.entries.map(entry => entry.path)
|
||||
expect(paths).toContain('newdir/a.ts')
|
||||
expect(paths.some(path => path.endsWith('/'))).toBe(false)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
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'
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Host external-open helpers: per-platform opener commands (pure, injected
|
||||
* platform) and the URL validation that guards the spawn route. The actual
|
||||
* spawns are not exercised — the route runs in the DSH host process and the
|
||||
* OS outcome is not testable here.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { launchExternal, revealCommand, urlCommand, validateExternalUrl } from '../src/open-external.ts'
|
||||
import { SidebarError } from '../src/wire.ts'
|
||||
|
||||
describe('revealCommand', () => {
|
||||
it('darwin: `open -R <path>` selects the file in Finder', () => {
|
||||
expect(revealCommand('/a/b.txt', 'darwin')).toEqual({ command: 'open', args: ['-R', '/a/b.txt'] })
|
||||
})
|
||||
|
||||
it('win32: `explorer /select,<path>` selects the file', () => {
|
||||
expect(revealCommand('C:\\a\\b.txt', 'win32')).toEqual({ command: 'explorer.exe', args: ['/select,', 'C:\\a\\b.txt'] })
|
||||
})
|
||||
|
||||
it('linux: `xdg-open` opens the containing directory (no common select protocol)', () => {
|
||||
expect(revealCommand('/a/b.txt', 'linux')).toEqual({ command: 'xdg-open', args: ['/a'] })
|
||||
expect(revealCommand('/', 'linux')).toEqual({ command: 'xdg-open', args: ['/'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('urlCommand', () => {
|
||||
it('darwin: `open <url>` launches the registered protocol handler', () => {
|
||||
expect(urlCommand('vscode://file/x', 'darwin')).toEqual({ command: 'open', args: ['vscode://file/x'] })
|
||||
})
|
||||
|
||||
it('win32: rundll32 url.dll,FileProtocolHandler <url>', () => {
|
||||
expect(urlCommand('cursor://file/x', 'win32')).toEqual({
|
||||
command: 'rundll32.exe',
|
||||
args: ['url.dll,FileProtocolHandler', 'cursor://file/x'],
|
||||
})
|
||||
})
|
||||
|
||||
it('linux: `xdg-open <url>` launches the registered protocol handler', () => {
|
||||
expect(urlCommand('zed://file/x', 'linux')).toEqual({ command: 'xdg-open', args: ['zed://file/x'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateExternalUrl', () => {
|
||||
it('accepts custom-scheme URLs (incl. the SSH-remote form)', () => {
|
||||
expect(validateExternalUrl('vscode://vscode-remote/ssh-remote+dev/home/u/f.ts'))
|
||||
.toBe('vscode://vscode-remote/ssh-remote+dev/home/u/f.ts')
|
||||
expect(validateExternalUrl('myapp://file/{path}')).toBe('myapp://file/{path}')
|
||||
})
|
||||
|
||||
it('rejects http/https (only custom schemes make sense here)', () => {
|
||||
expect(() => validateExternalUrl('https://example.com')).toThrow(SidebarError)
|
||||
expect(() => validateExternalUrl('http://example.com')).toThrow(SidebarError)
|
||||
})
|
||||
|
||||
it('rejects non-URL / non-`scheme://` strings', () => {
|
||||
expect(() => validateExternalUrl('/home/u/f.ts')).toThrow(SidebarError)
|
||||
expect(() => validateExternalUrl('a:file/x')).toThrow(SidebarError)
|
||||
expect(() => validateExternalUrl('')).toThrow(SidebarError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('launchExternal validation (pre-spawn)', () => {
|
||||
it('rejects relative reveal paths before anything is spawned', () => {
|
||||
expect(() => launchExternal('reveal', 'relative/path')).toThrow(SidebarError)
|
||||
})
|
||||
|
||||
it('rejects invalid URLs before anything is spawned', () => {
|
||||
expect(() => launchExternal('url', 'https://example.com')).toThrow(SidebarError)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* The editor's "open with" settings panel: the SSH host row and the custom
|
||||
* editor list (name + URL template + VSCode-family flag, add/remove). Every
|
||||
* edit commits the whole `openWith` blob through the popup's
|
||||
* updatePluginSetting; removing an editor also prunes its pinned id.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { beforeAll, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { OpenWithSettings } from '../src/client/open-with-settings.tsx'
|
||||
|
||||
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
|
||||
})
|
||||
|
||||
interface Harness {
|
||||
container: HTMLDivElement
|
||||
update: ReturnType<typeof vi.fn>
|
||||
unmount: () => void
|
||||
}
|
||||
|
||||
function mountSettings(rawOpenWith: unknown): Harness {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const root: Root = createRoot(container)
|
||||
const update = vi.fn()
|
||||
act(() => {
|
||||
root.render(createElement(OpenWithSettings, {
|
||||
pluginSettings: { openWith: rawOpenWith },
|
||||
updatePluginSetting: update,
|
||||
}))
|
||||
})
|
||||
return {
|
||||
container,
|
||||
update,
|
||||
unmount: () => { act(() => { root.unmount() }) },
|
||||
}
|
||||
}
|
||||
|
||||
const baseConfig = {
|
||||
sshHost: 'dev',
|
||||
customEditors: [
|
||||
{ id: 'a', name: 'Windsurf', urlTemplate: 'windsurf://file/{path}', isVscodeFamily: false },
|
||||
],
|
||||
pinned: ['vscode', 'custom:a'],
|
||||
}
|
||||
|
||||
describe('OpenWithSettings', () => {
|
||||
let harness: Harness
|
||||
afterEach(() => {
|
||||
harness.unmount()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('seeds the SSH host input from the persisted blob and commits edits', () => {
|
||||
harness = mountSettings(baseConfig)
|
||||
const input = harness.container.querySelector<HTMLInputElement>('input:not([type])')
|
||||
expect(input?.value).toBe('dev')
|
||||
// The native value setter (React's value tracker must see the change).
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!
|
||||
act(() => {
|
||||
setter.call(input, 'prod')
|
||||
input!.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
})
|
||||
expect(harness.update.mock.calls[0]?.[0]).toBe('openWith')
|
||||
expect(harness.update.mock.calls[0]?.[1]).toMatchObject({ sshHost: 'prod' })
|
||||
})
|
||||
|
||||
it('adds a custom editor row and commits the extended list', () => {
|
||||
harness = mountSettings(baseConfig)
|
||||
const add = [...harness.container.querySelectorAll<HTMLButtonElement>('button')]
|
||||
.find(button => button.textContent?.trim() === 'Add')
|
||||
expect(add).toBeDefined()
|
||||
act(() => { add!.click() })
|
||||
const blob = harness.update.mock.calls[0]?.[1] as { customEditors: unknown[]; pinned: string[] }
|
||||
expect(blob.customEditors).toHaveLength(2)
|
||||
expect(blob.pinned).toEqual(['vscode', 'custom:a'])
|
||||
})
|
||||
|
||||
it('removes an editor and prunes its pinned id', () => {
|
||||
harness = mountSettings(baseConfig)
|
||||
const remove = harness.container.querySelector<HTMLButtonElement>('button[aria-label="Remove"]')
|
||||
expect(remove).toBeDefined()
|
||||
act(() => { remove!.click() })
|
||||
const blob = harness.update.mock.calls[0]?.[1] as { customEditors: unknown[]; pinned: string[] }
|
||||
expect(blob.customEditors).toHaveLength(0)
|
||||
expect(blob.pinned).toEqual(['vscode'])
|
||||
})
|
||||
|
||||
it('shows the validity hint when a row is incomplete', () => {
|
||||
harness = mountSettings({
|
||||
sshHost: '',
|
||||
customEditors: [{ id: 'x', name: 'Bad', urlTemplate: 'x://file/x', isVscodeFamily: true }],
|
||||
pinned: [],
|
||||
})
|
||||
expect(harness.container.textContent).toContain('are not shown in the menu')
|
||||
})
|
||||
|
||||
it('edits the VSCode-family flag of one row', () => {
|
||||
harness = mountSettings(baseConfig)
|
||||
const checkbox = harness.container.querySelector<HTMLInputElement>('input[type="checkbox"]')
|
||||
expect(checkbox?.checked).toBe(false)
|
||||
act(() => { checkbox!.click() })
|
||||
const blob = harness.update.mock.calls[0]?.[1] as { customEditors: { isVscodeFamily: boolean }[] }
|
||||
expect(blob.customEditors[0]?.isVscodeFamily).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* The file tree "open with" pure logic: config parsing (tolerant), target
|
||||
* resolution (built-ins + custom, SSH filtering), URL building (local file
|
||||
* URLs and VSCode-family SSH-remote URLs) and the custom-editor validators.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isValidCustomEditor,
|
||||
newCustomEditorId,
|
||||
normalizeUrlPath,
|
||||
openWithSshActive,
|
||||
openWithUrl,
|
||||
parseOpenWithConfig,
|
||||
resolveOpenWithTargets,
|
||||
type CustomEditor,
|
||||
type OpenWithTarget,
|
||||
} from '../src/client/open-with.ts'
|
||||
|
||||
describe('parseOpenWithConfig', () => {
|
||||
it('returns the defaults for null / non-object / array values', () => {
|
||||
expect(parseOpenWithConfig(null)).toEqual({ sshHost: '', customEditors: [], pinned: [] })
|
||||
expect(parseOpenWithConfig('junk')).toEqual({ sshHost: '', customEditors: [], pinned: [] })
|
||||
expect(parseOpenWithConfig([])).toEqual({ sshHost: '', customEditors: [], pinned: [] })
|
||||
})
|
||||
|
||||
it('keeps structurally valid custom editors (incl. in-progress rows) and drops junk', () => {
|
||||
const config = parseOpenWithConfig({
|
||||
sshHost: 'dev',
|
||||
customEditors: [
|
||||
{ id: 'a', name: 'Windsurf', urlTemplate: 'windsurf://file/{path}', isVscodeFamily: false },
|
||||
{ id: 'b', name: '', urlTemplate: 'b://file/{path}', isVscodeFamily: true },
|
||||
{ id: 'c', name: 'Trae', urlTemplate: 'trae://file/{path}', isVscodeFamily: true },
|
||||
'junk',
|
||||
null,
|
||||
42,
|
||||
],
|
||||
pinned: ['vscode', 'custom:a', 42, '', 'custom:c'],
|
||||
})
|
||||
expect(config.sshHost).toBe('dev')
|
||||
expect(config.customEditors.map(editor => editor.id)).toEqual(['a', 'b', 'c'])
|
||||
expect(config.pinned).toEqual(['vscode', 'custom:a', 'custom:c'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveOpenWithTargets', () => {
|
||||
const localCustom: CustomEditor = { id: 'e1', name: 'Windsurf', urlTemplate: 'windsurf://file/{path}', isVscodeFamily: false }
|
||||
const familyCustom: CustomEditor = { id: 'e2', name: 'Zed-like fork', urlTemplate: 'x-zed://file/{path}', isVscodeFamily: true }
|
||||
|
||||
it('lists built-ins first, then custom editors (local mode)', () => {
|
||||
const targets = resolveOpenWithTargets(parseOpenWithConfig({ customEditors: [localCustom] }))
|
||||
expect(targets.map(target => target.id)).toEqual(['explorer', 'vscode', 'cursor', 'zed', 'custom:e1'])
|
||||
expect(targets[0]?.kind).toBe('reveal')
|
||||
expect(targets[1]?.urlTemplate).toBe('vscode://file/{path}')
|
||||
})
|
||||
|
||||
it('excludes incomplete custom-editor rows from the menu (they stay in the blob)', () => {
|
||||
const targets = resolveOpenWithTargets(parseOpenWithConfig({
|
||||
customEditors: [
|
||||
{ id: 'e1', name: 'Windsurf', urlTemplate: 'windsurf://file/{path}', isVscodeFamily: false },
|
||||
{ id: 'e2', name: '', urlTemplate: 'incomplete://file/{path}', isVscodeFamily: true },
|
||||
],
|
||||
}))
|
||||
expect(targets.map(target => target.id)).toEqual(['explorer', 'vscode', 'cursor', 'zed', 'custom:e1'])
|
||||
})
|
||||
|
||||
it('drops local-only targets in SSH mode (file manager, Zed, non-VSCode custom)', () => {
|
||||
const config = parseOpenWithConfig({ sshHost: 'dev', customEditors: [localCustom, familyCustom] })
|
||||
const targets = resolveOpenWithTargets(config)
|
||||
expect(targets.map(target => target.id)).toEqual(['vscode', 'cursor', 'custom:e2'])
|
||||
})
|
||||
|
||||
it('prunes unknown pinned ids only when resolving (the parse keeps them)', () => {
|
||||
const config = parseOpenWithConfig({ pinned: ['ghost'] })
|
||||
expect(config.pinned).toEqual(['ghost'])
|
||||
expect(resolveOpenWithTargets(config).map(target => target.id)).toContain('vscode')
|
||||
})
|
||||
})
|
||||
|
||||
describe('openWithUrl', () => {
|
||||
const targets = resolveOpenWithTargets(parseOpenWithConfig({}))
|
||||
const byId = (id: string): OpenWithTarget => {
|
||||
const target = targets.find(item => item.id === id)
|
||||
if (target === undefined) throw new Error(`no target ${id}`)
|
||||
return target
|
||||
}
|
||||
|
||||
it('builds local file URLs with the raw path (backslashes normalized)', () => {
|
||||
expect(openWithUrl(byId('vscode'), '/home/u/f.ts', parseOpenWithConfig({}))).toBe('vscode://file//home/u/f.ts')
|
||||
expect(openWithUrl(byId('vscode'), 'C:\\Users\\u\\f.ts', parseOpenWithConfig({}))).toBe('vscode://file/C:/Users/u/f.ts')
|
||||
expect(openWithUrl(byId('zed'), '/x/y', parseOpenWithConfig({}))).toBe('zed://file//x/y')
|
||||
})
|
||||
|
||||
it('builds SSH-remote URLs for VSCode-family targets in SSH mode', () => {
|
||||
const config = parseOpenWithConfig({ sshHost: 'dev' })
|
||||
expect(openWithUrl(byId('vscode'), '/home/u/f.ts', config))
|
||||
.toBe('vscode://vscode-remote/ssh-remote+dev/home/u/f.ts')
|
||||
expect(openWithUrl(byId('cursor'), '/home/u/f.ts', config))
|
||||
.toBe('cursor://vscode-remote/ssh-remote+dev/home/u/f.ts')
|
||||
})
|
||||
|
||||
it('keeps the local URL form for non-VSCode-family targets even in SSH mode', () => {
|
||||
// (Such targets are hidden from the SSH menu; the URL builder stays honest.)
|
||||
expect(openWithUrl(byId('zed'), '/x', parseOpenWithConfig({ sshHost: 'dev' }))).toBe('zed://file//x')
|
||||
})
|
||||
|
||||
it('returns undefined for reveal targets and malformed templates', () => {
|
||||
expect(openWithUrl(byId('explorer'), '/x', parseOpenWithConfig({}))).toBeUndefined()
|
||||
const noScheme: OpenWithTarget = { id: 'x', name: '', kind: 'url', urlTemplate: 'not-a-url {path}', isVscodeFamily: false, localOnly: false }
|
||||
expect(openWithUrl(noScheme, '/x', parseOpenWithConfig({}))).toBeUndefined()
|
||||
const noSlash: OpenWithTarget = { id: 'y', name: '', kind: 'url', urlTemplate: 'a:file/{path}', isVscodeFamily: false, localOnly: false }
|
||||
expect(openWithUrl(noSlash, '/x', parseOpenWithConfig({}))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses the custom template scheme for SSH URLs of VSCode-family custom editors', () => {
|
||||
const config = parseOpenWithConfig({
|
||||
sshHost: 'dev',
|
||||
customEditors: [
|
||||
{ id: 'e', name: 'Fork', urlTemplate: 'myfork://file/{path}', isVscodeFamily: true },
|
||||
],
|
||||
})
|
||||
const targets = resolveOpenWithTargets(config)
|
||||
expect(openWithUrl(targets[targets.length - 1]!, '/r/f.ts', config))
|
||||
.toBe('myfork://vscode-remote/ssh-remote+dev/r/f.ts')
|
||||
})
|
||||
})
|
||||
|
||||
describe('helpers', () => {
|
||||
it('normalizeUrlPath converts backslashes to slashes', () => {
|
||||
expect(normalizeUrlPath('a\\b\\c')).toBe('a/b/c')
|
||||
})
|
||||
|
||||
it('openWithSshActive trims the host', () => {
|
||||
expect(openWithSshActive(parseOpenWithConfig({ sshHost: ' ' }))).toBe(false)
|
||||
expect(openWithSshActive(parseOpenWithConfig({ sshHost: ' dev ' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('isValidCustomEditor requires a name, a {path} placeholder and a scheme:// prefix', () => {
|
||||
expect(isValidCustomEditor({ name: 'A', urlTemplate: 'a://file/{path}' })).toBe(true)
|
||||
expect(isValidCustomEditor({ name: '', urlTemplate: 'a://file/{path}' })).toBe(false)
|
||||
expect(isValidCustomEditor({ name: 'A', urlTemplate: 'a://file/x' })).toBe(false)
|
||||
expect(isValidCustomEditor({ name: 'A', urlTemplate: 'a:file/{path}' })).toBe(false)
|
||||
})
|
||||
|
||||
it('newCustomEditorId yields unique non-empty ids', () => {
|
||||
const first = newCustomEditorId()
|
||||
const second = newCustomEditorId()
|
||||
expect(first).not.toBe(second)
|
||||
expect(first.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -28,8 +28,9 @@ beforeAll(() => {
|
||||
})
|
||||
})
|
||||
import { createBetterSidebarService, type BetterSidebarService } from '../src/client/service.ts'
|
||||
import type { TabDescriptor } from '../src/client/service.ts'
|
||||
import { FeatureSettingsRows, mergePluginSetting, SettingsBody, SideCardSection, type SideCardSectionProps } from '../src/client/SideCardSection.tsx'
|
||||
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
|
||||
import { FeatureSettingsRows, mergePluginSetting, SideCardSection, type SideCardSectionProps } from '../src/client/SideCardSection.tsx'
|
||||
|
||||
/** One tab + one viewer + the subagent-style nested toggle under a tab. */
|
||||
function mount(): { store: SidebarStore; service: BetterSidebarService } {
|
||||
@@ -347,3 +348,68 @@ describe('FeatureSettingsRows valueSource (v0.12.0, independent CR fix)', () =>
|
||||
expect(html).toContain('checked=""')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SettingsBody rows + custom render panel (open-with seam)', () => {
|
||||
it('renders the declarative rows AND the custom panel when both are declared', () => {
|
||||
const { store, service } = mount()
|
||||
const feature = {
|
||||
id: 'demo',
|
||||
title: () => 'Demo',
|
||||
component: () => null,
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'autoOpenSubagent',
|
||||
title: () => 'Auto row',
|
||||
desc: () => 'row description',
|
||||
}],
|
||||
render: () => createElement('div', { 'data-custom-panel': true }, 'custom panel'),
|
||||
},
|
||||
} as unknown as TabDescriptor
|
||||
const html = renderToString(createElement(SettingsBody, {
|
||||
feature,
|
||||
prefs: SIDEBAR_PREFS_DEFAULTS,
|
||||
store,
|
||||
service,
|
||||
onToggle: () => {},
|
||||
onCommit: () => '',
|
||||
onSelectValue: () => {},
|
||||
onPluginToggle: () => {},
|
||||
onPluginCommit: () => '',
|
||||
onPluginSelectValue: () => {},
|
||||
onPluginWrite: () => {},
|
||||
onClose: () => {},
|
||||
}))
|
||||
expect(html).toContain('Auto row')
|
||||
expect(html).toContain('row description')
|
||||
expect(html).toContain('data-custom-panel')
|
||||
expect(html).toContain('custom panel')
|
||||
})
|
||||
|
||||
it('renders ONLY the custom panel when no rows are declared (unchanged behavior)', () => {
|
||||
const { store, service } = mount()
|
||||
const feature = {
|
||||
id: 'demo',
|
||||
title: () => 'Demo',
|
||||
component: () => null,
|
||||
settings: {
|
||||
render: () => createElement('div', { 'data-custom-panel': true }, 'custom panel'),
|
||||
},
|
||||
} as unknown as TabDescriptor
|
||||
const html = renderToString(createElement(SettingsBody, {
|
||||
feature,
|
||||
prefs: SIDEBAR_PREFS_DEFAULTS,
|
||||
store,
|
||||
service,
|
||||
onToggle: () => {},
|
||||
onCommit: () => '',
|
||||
onSelectValue: () => {},
|
||||
onPluginToggle: () => {},
|
||||
onPluginCommit: () => '',
|
||||
onPluginSelectValue: () => {},
|
||||
onPluginWrite: () => {},
|
||||
onClose: () => {},
|
||||
}))
|
||||
expect(html).toContain('custom panel')
|
||||
expect(html).not.toContain('Auto row')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,12 +12,14 @@ import {
|
||||
buildOpenTurnSnapshot,
|
||||
buildSidechatInheritance,
|
||||
hasDanglingToolCall,
|
||||
isContextInjectionMessage,
|
||||
resolvePresetId,
|
||||
sideLabel,
|
||||
sideThreadRows,
|
||||
threadHasCompletedTurn,
|
||||
threadTrailingPending,
|
||||
SIDE_BOUNDARY_PROMPT,
|
||||
SIDE_INJECTION_PLUGIN,
|
||||
SIDE_LABEL_PREFIX,
|
||||
type SeedEvent,
|
||||
type SidechatLogEvent,
|
||||
@@ -333,3 +335,31 @@ describe('boundaryDelivered', () => {
|
||||
})])).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isContextInjectionMessage', () => {
|
||||
it('recognizes plugin-stamped sources structurally', () => {
|
||||
expect(isContextInjectionMessage({
|
||||
content: [{ type: 'text', text: 'runtime context' }],
|
||||
source: { kind: 'plugin', plugin: SIDE_INJECTION_PLUGIN },
|
||||
})).toBe(true)
|
||||
expect(isContextInjectionMessage({
|
||||
content: [{ type: 'text', text: 'q' }],
|
||||
source: { kind: 'user' },
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to the boundary prefix for pre-split logs and sourceless rows', () => {
|
||||
expect(isContextInjectionMessage({
|
||||
content: [{ type: 'text', text: `${SIDE_BOUNDARY_PROMPT}\n\nlegacy question` }],
|
||||
source: { kind: 'user' },
|
||||
})).toBe(true)
|
||||
expect(isContextInjectionMessage({
|
||||
content: `${SIDE_BOUNDARY_PROMPT}\n\nlegacy`,
|
||||
})).toBe(true)
|
||||
expect(isContextInjectionMessage({
|
||||
content: [{ type: 'text', text: 'ordinary question' }],
|
||||
source: { kind: 'user' },
|
||||
})).toBe(false)
|
||||
expect(isContextInjectionMessage({ content: [{ type: 'text', text: 'no source row' }] })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { buildSidechatApi } from '../src/sidechat-routes.ts'
|
||||
import { SidebarError } from '../src/wire.ts'
|
||||
import { SIDE_BOUNDARY_PROMPT, SIDE_NEW_THREAD_TITLE, sideLabel } from '../src/sidechat-core.ts'
|
||||
import { SIDE_BOUNDARY_PROMPT, SIDE_INJECTION_PLUGIN, SIDE_NEW_THREAD_TITLE, sideLabel } from '../src/sidechat-core.ts'
|
||||
import type { Context } from '../src/context-types.ts'
|
||||
|
||||
/** A fake live agent (followup/cancel spied). */
|
||||
/** A fake live agent (inject/followup/cancel spied). */
|
||||
function agent(id: string, over: { events?: unknown[]; header?: Record<string, unknown>; provider?: string; model?: string } = {}) {
|
||||
return {
|
||||
id,
|
||||
@@ -22,6 +22,7 @@ function agent(id: string, over: { events?: unknown[]; header?: Record<string, u
|
||||
header: { cwd: '/p', delegationDepth: 0, agentPreset: 'preset-a', ...over.header },
|
||||
events: over.events ?? [],
|
||||
},
|
||||
inject: vi.fn(),
|
||||
followup: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
}
|
||||
@@ -138,12 +139,17 @@ describe('sidechat.start', () => {
|
||||
agentProvider: 'test',
|
||||
agentModel: 'model-x',
|
||||
})
|
||||
// First contact is SPLIT: the boundary rides agent.inject (plugin-stamped
|
||||
// context, no wake), the question is the follow-up that wakes the driver.
|
||||
expect(child.inject).toHaveBeenCalledTimes(1)
|
||||
const injection = child.inject.mock.calls[0]![0] as { content: Array<{ type: string; text: string }>; source: { kind: string; plugin: string } }
|
||||
expect(injection.source).toEqual({ kind: 'plugin', plugin: SIDE_INJECTION_PLUGIN })
|
||||
expect(injection.content[0]!.text.startsWith(SIDE_BOUNDARY_PROMPT)).toBe(true)
|
||||
expect(injection.content[0]!.text).not.toContain('explain the event flow')
|
||||
expect(child.followup).toHaveBeenCalledTimes(1)
|
||||
const message = child.followup.mock.calls[0]![0] as { content: Array<{ type: string; text: string }>; source: { kind: string } }
|
||||
expect(message.source).toEqual({ kind: 'user' })
|
||||
const promptText = message.content[0]!.text
|
||||
expect(promptText.startsWith(SIDE_BOUNDARY_PROMPT)).toBe(true)
|
||||
expect(promptText).toContain('explain the event flow')
|
||||
expect(message.content[0]!.text).toBe('explain the event flow')
|
||||
expect(services.rename).toHaveBeenCalledWith(child.session, sideLabel('explain the event flow'))
|
||||
})
|
||||
|
||||
@@ -188,8 +194,11 @@ describe('sidechat.start', () => {
|
||||
const options = services.create.mock.calls[0]![0] as { seed: Array<{ type: string }> }
|
||||
// Cut BEFORE the open turn: only the pending user message is inherited.
|
||||
expect(options.seed.map(event => event.type)).toEqual(['user/message', 'subagent/descriptor'])
|
||||
// The snapshot rides the injection, not the follow-up question.
|
||||
const injection = child.inject.mock.calls[0]![0] as { content: Array<{ text: string }> }
|
||||
expect(injection.content[0]!.text).toContain('`bash` (executing)')
|
||||
const message = child.followup.mock.calls[0]![0] as { content: Array<{ text: string }> }
|
||||
expect(message.content[0]!.text).toContain('`bash` (executing)')
|
||||
expect(message.content[0]!.text).toBe('what now?')
|
||||
})
|
||||
|
||||
it('creates an EMPTY thread on a blank question (Codex-style immediate create)', async () => {
|
||||
@@ -209,18 +218,22 @@ describe('sidechat.start', () => {
|
||||
|
||||
// No prompt yet: the composer owns the first message; the placeholder
|
||||
// label is pinned and the in-progress snapshot is parked for it.
|
||||
expect(child.inject).not.toHaveBeenCalled()
|
||||
expect(child.followup).not.toHaveBeenCalled()
|
||||
expect(services.rename).toHaveBeenCalledWith(child.session, SIDE_NEW_THREAD_TITLE)
|
||||
|
||||
// The first prompt carries the boundary + the parked snapshot and earns
|
||||
// the thread its real label.
|
||||
// The first prompt injects the boundary + the parked snapshot (split
|
||||
// from the question) and earns the thread its real label.
|
||||
services.agents.get = vi.fn((id: unknown) => (id === childId ? child : parent))
|
||||
await api['sidechat.prompt']({ childId, text: 'explain the event flow' })
|
||||
expect(child.inject).toHaveBeenCalledTimes(1)
|
||||
const injection = child.inject.mock.calls[0]![0] as { content: Array<{ text: string }>; source: { kind: string } }
|
||||
expect(injection.source.kind).toBe('plugin')
|
||||
expect(injection.content[0]!.text.startsWith(SIDE_BOUNDARY_PROMPT)).toBe(true)
|
||||
expect(injection.content[0]!.text).toContain('`bash` (executing)')
|
||||
expect(child.followup).toHaveBeenCalledTimes(1)
|
||||
const message = child.followup.mock.calls[0]![0] as { content: Array<{ text: string }> }
|
||||
expect(message.content[0]!.text.startsWith(SIDE_BOUNDARY_PROMPT)).toBe(true)
|
||||
expect(message.content[0]!.text).toContain('`bash` (executing)')
|
||||
expect(message.content[0]!.text).toContain('explain the event flow')
|
||||
expect(message.content[0]!.text).toBe('explain the event flow')
|
||||
expect(services.rename).toHaveBeenCalledWith(child.session, sideLabel('explain the event flow'))
|
||||
})
|
||||
|
||||
@@ -256,6 +269,7 @@ describe('sidechat.prompt', () => {
|
||||
const api = buildSidechatApi(ctxWith(services))
|
||||
const result = await api['sidechat.prompt']({ childId: 'child', text: 'tell me more' })
|
||||
expect(result).toEqual({ accepted: true })
|
||||
expect(child.inject).not.toHaveBeenCalled()
|
||||
expect(child.followup).toHaveBeenCalledTimes(1)
|
||||
const message = child.followup.mock.calls[0]![0] as { content: Array<{ text: string }> }
|
||||
expect(message.content[0]!.text).toBe('tell me more')
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
/**
|
||||
* Unit tests for the Side Chat transcript mapping (src/client/sidechat-
|
||||
* transcript.ts): the seed cut at session/end-seed, the boundary-row drop,
|
||||
* chunk streaming accumulation superseded by assembled messages, tool
|
||||
* call/result pairing, and orphan failed results.
|
||||
* transcript.ts): the seed cut at session/end-seed, context-injection rows
|
||||
* (plugin-stamped sources and the legacy boundary-prefix blob), chunk
|
||||
* streaming accumulation superseded by assembled messages, tool call/result
|
||||
* pairing, and orphan failed results.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SidebarHistoryEntry, SidebarSessionEvent } from '../src/context-types.ts'
|
||||
import { SIDE_BOUNDARY_PREFIX } from '../src/sidechat-core.ts'
|
||||
import { collectOwnEvents, transcriptRows, type SidechatTranscriptRow } from '../src/client/sidechat-transcript.ts'
|
||||
import { SIDE_BOUNDARY_PREFIX, SIDE_BOUNDARY_PROMPT, SIDE_INJECTION_PLUGIN } from '../src/sidechat-core.ts'
|
||||
import { collectOwnEvents, toolArgsSummary, transcriptRows, type SidechatTranscriptRow } from '../src/client/sidechat-transcript.ts'
|
||||
|
||||
/** One history entry (event + optional view). */
|
||||
function entry(event: SidebarSessionEvent): SidebarHistoryEntry {
|
||||
@@ -29,15 +30,48 @@ function textBlocks(...texts: string[]): unknown[] {
|
||||
}
|
||||
|
||||
describe('transcriptRows', () => {
|
||||
it('cuts the inherited seed at the last end-seed and drops the boundary row', () => {
|
||||
it('cuts the inherited seed at the last end-seed and renders the boundary as an injection row', () => {
|
||||
const entries = [
|
||||
entry(ev('user/message', 0, { content: textBlocks('inherited'), source: { kind: 'user' } })),
|
||||
entry(ev('session/end-seed', 1)),
|
||||
entry(ev('user/message', 2, { content: textBlocks(`${SIDE_BOUNDARY_PREFIX}\n\nmode`), source: { kind: 'user' } })),
|
||||
entry(ev('user/message', 2, { content: textBlocks(`${SIDE_BOUNDARY_PREFIX}\n\nmode`), source: { kind: 'plugin', plugin: SIDE_INJECTION_PLUGIN } })),
|
||||
entry(ev('user/message', 3, { content: textBlocks('the side question'), source: { kind: 'user' } })),
|
||||
]
|
||||
const rows = transcriptRows(entries)
|
||||
expect(rows).toEqual([{ kind: 'user', seq: 3, text: 'the side question' }])
|
||||
expect(rows).toEqual([
|
||||
{ kind: 'injection', seq: 2, text: `${SIDE_BOUNDARY_PREFIX}\n\nmode` },
|
||||
{ kind: 'user', seq: 3, text: 'the side question' },
|
||||
])
|
||||
})
|
||||
|
||||
it('splits the LEGACY wrapped first message (boundary + question in one user row) at the boundary prompt', () => {
|
||||
const entries = [
|
||||
entry(ev('session/end-seed', 0)),
|
||||
entry(ev('user/message', 1, {
|
||||
content: textBlocks(`${SIDE_BOUNDARY_PROMPT}\n\nthe first question`),
|
||||
source: { kind: 'user' },
|
||||
})),
|
||||
entry(ev('user/message', 2, { content: textBlocks('follow-up'), source: { kind: 'user' } })),
|
||||
]
|
||||
const rows = transcriptRows(entries)
|
||||
expect(rows).toEqual([
|
||||
{ kind: 'injection', seq: 1, text: SIDE_BOUNDARY_PROMPT },
|
||||
{ kind: 'user', seq: 1, text: 'the first question' },
|
||||
{ kind: 'user', seq: 2, text: 'follow-up' },
|
||||
])
|
||||
})
|
||||
|
||||
it('renders any plugin-sourced context message as an injection row, boundary prefix or not', () => {
|
||||
const entries = [
|
||||
entry(ev('session/end-seed', 0)),
|
||||
entry(ev('user/message', 1, { content: textBlocks('runtime context'), source: { kind: 'plugin', plugin: 'other-plugin' } })),
|
||||
entry(ev('user/message', 2, { content: textBlocks('q'), source: { kind: 'user' } })),
|
||||
]
|
||||
const rows = transcriptRows(entries)
|
||||
expect(rows).toEqual([
|
||||
{ kind: 'injection', seq: 1, text: 'runtime context' },
|
||||
{ kind: 'user', seq: 2, text: 'q' },
|
||||
])
|
||||
})
|
||||
|
||||
it('accumulates chunk deltas per block and supersedes them on settle', () => {
|
||||
@@ -192,3 +226,23 @@ describe('collectOwnEvents', () => {
|
||||
expect(result.entries.map(e => e.event.seq)).toEqual([20, 21, 22, 23, 24, 25, 26, 27, 28, 29])
|
||||
})
|
||||
})
|
||||
|
||||
describe('toolArgsSummary', () => {
|
||||
it('picks the most identifying string field', () => {
|
||||
expect(toolArgsSummary('{"command":"ls -la","timeout":1000}')).toBe('ls -la')
|
||||
expect(toolArgsSummary('{"file_path":"/a/b.ts","old_string":"x"}')).toBe('/a/b.ts')
|
||||
expect(toolArgsSummary('{"pattern":"foo","path":"/repo"}')).toBe('/repo')
|
||||
})
|
||||
|
||||
it('flattens and truncates raw text when no known key parses', () => {
|
||||
expect(toolArgsSummary('{"custom":"v"}')).toBe('{"custom":"v"}')
|
||||
expect(toolArgsSummary('not json at all')).toBe('not json at all')
|
||||
expect(toolArgsSummary(`{"command":"${'x'.repeat(200)}"`)).toHaveLength(80)
|
||||
expect(toolArgsSummary(`{"command":"${'x'.repeat(200)}"`)).toMatch(/…$/)
|
||||
})
|
||||
|
||||
it('reads empty for missing or blank input', () => {
|
||||
expect(toolArgsSummary(undefined)).toBe('')
|
||||
expect(toolArgsSummary('{"command":" "}')).toBe('{"command":" "}')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -198,6 +198,60 @@ describe('host plugin smoke', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('pty manager: a parked pty survives past the reconnect grace (session switch)', async () => {
|
||||
const manager = new PtyManager(defaultShell(), 3)
|
||||
try {
|
||||
const handle = manager.open('s2', 't1', process.cwd(), 80, 24)
|
||||
manager.park(handle.key)
|
||||
expect(manager.isParked(handle.key)).toBe(true)
|
||||
// A parked pty does NOT enter the grace countdown — it stays alive
|
||||
// well past any realistic reconnectGraceMs.
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
expect(manager.get(handle.key)).toBeDefined()
|
||||
expect(manager.isParked(handle.key)).toBe(true)
|
||||
} finally {
|
||||
manager.disposeAll()
|
||||
}
|
||||
})
|
||||
|
||||
it('pty manager: a reconnecting view clears the parked state (switch back)', () => {
|
||||
const manager = new PtyManager(defaultShell(), 3)
|
||||
try {
|
||||
const handle = manager.open('s2', 't1', process.cwd(), 80, 24)
|
||||
manager.park(handle.key)
|
||||
expect(manager.isParked(handle.key)).toBe(true)
|
||||
// open() calls cancelClose(), which clears the parked state — the
|
||||
// user switched back to the session and the view reattached.
|
||||
manager.open('s2', 't1', process.cwd(), 80, 24)
|
||||
expect(manager.isParked(handle.key)).toBe(false)
|
||||
expect(manager.get(handle.key)).toBeDefined()
|
||||
} finally {
|
||||
manager.disposeAll()
|
||||
}
|
||||
})
|
||||
|
||||
it('pty manager: an explicit close frame on a parked pty still kills it', async () => {
|
||||
const manager = new PtyManager(defaultShell(), 3)
|
||||
try {
|
||||
const handle = manager.open('s2', 't1', process.cwd(), 80, 24)
|
||||
manager.park(handle.key)
|
||||
// The user switched back and closed the tab — scheduleClose (the
|
||||
// close-frame handler) clears the parked state and kills the pty.
|
||||
manager.scheduleClose(handle.key, 0)
|
||||
expect(manager.isParked(handle.key)).toBe(false)
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
expect(manager.get(handle.key)).toBeUndefined()
|
||||
} finally {
|
||||
manager.disposeAll()
|
||||
}
|
||||
})
|
||||
|
||||
it('pty manager: park on an unknown key is a no-op', () => {
|
||||
const manager = new PtyManager(defaultShell(), 3)
|
||||
expect(() => manager.park('s2:nonexistent')).not.toThrow()
|
||||
expect(manager.isParked('s2:nonexistent')).toBe(false)
|
||||
})
|
||||
|
||||
it('pty manager: reopening with a different cwd respawns in the new directory', async () => {
|
||||
const manager = new PtyManager(defaultShell(), 3)
|
||||
// A real second directory: os.tmpdir() exists on every platform ('/tmp'
|
||||
|
||||
+28
-5
@@ -188,6 +188,29 @@ describe('sidebar state', () => {
|
||||
expect((onlyDiff?.splits as { tabs: SidebarTab[] }).tabs).toEqual([])
|
||||
})
|
||||
|
||||
it('sanitize removes a pane emptied by ephemeral diff tabs', () => {
|
||||
const valid = sanitizeState({
|
||||
panelOpen: true,
|
||||
width: 400,
|
||||
nextTerminal: 1,
|
||||
activePane: 'pane:diff',
|
||||
expanded: [],
|
||||
splits: {
|
||||
kind: 'split',
|
||||
id: 'split:1',
|
||||
dir: 'col',
|
||||
sizes: [0.5, 0.5],
|
||||
children: [
|
||||
{ kind: 'leaf', id: 'pane:git', active: 'git', tabs: [{ id: 'git', type: 'git', title: 'Git' }] },
|
||||
{ kind: 'leaf', id: 'pane:diff', active: 'd1', tabs: [{ id: 'd1', type: 'diff', title: 'a.ts' }] },
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(valid?.splits.kind).toBe('leaf')
|
||||
expect((valid?.splits as { id: string; tabs: SidebarTab[] }).id).toBe('pane:git')
|
||||
expect(valid?.activePane).toBe('pane:git')
|
||||
})
|
||||
|
||||
it('dedupes the single-instance subagent tab (focuses instead of duplicating)', () => {
|
||||
let s = state()
|
||||
s = openTabInActivePane(s, { id: 'subagent', type: 'subagent', title: 'Subagents' })
|
||||
@@ -716,11 +739,11 @@ describe('persisted state sanitization', () => {
|
||||
const clean = sanitizeState(corrupted)
|
||||
expect(clean).toBeDefined()
|
||||
const leaves = allLeaves(clean!.splits)
|
||||
// The first occurrence keeps its id; the repeat gets a fresh unique one
|
||||
// (exact suffix depends on the module-level uid counter, so assert shape).
|
||||
expect(leaves[0]!.id).toBe('pane:1')
|
||||
expect(new Set(leaves.map(leaf => leaf.id)).size).toBe(2)
|
||||
expect(clean!.activePane).toBe(leaves[1]!.id)
|
||||
// The empty first occurrence is pruned; the populated repeat keeps its
|
||||
// fresh unique id and becomes active.
|
||||
expect(leaves).toHaveLength(1)
|
||||
expect(leaves[0]!.id).not.toBe('pane:1')
|
||||
expect(clean!.activePane).toBe(leaves[0]!.id)
|
||||
// And an open must land in exactly one pane of the healed tree.
|
||||
const opened = openTabInActivePane(clean!, { id: 'editor:/a.ts', type: 'editor', title: 'a.ts', path: '/a.ts' })
|
||||
const owners = allLeaves(opened.splits).filter(leaf => leaf.tabs.some(tab => tab.path === '/a.ts'))
|
||||
|
||||
@@ -98,6 +98,33 @@ describe('subagent detection over the sessions list feed', () => {
|
||||
expect(detectNewDirectSubagent(before, after, 'p2')).toBe(false)
|
||||
})
|
||||
|
||||
it('documents the title-frame race the Sidebar auto-open debounce absorbs', () => {
|
||||
// The host delivers a new child's origin and title in SEPARATE frames:
|
||||
// a Side Chat thread's FIRST visible frame still shows a fallback title
|
||||
// (the cwd basename — no 'Side: ' prefix), so an immediate 0→N check
|
||||
// misreads it as a genuine subagent. Sidebar.tsx therefore debounces
|
||||
// AUTO_OPEN_DEBOUNCE_MS and re-evaluates the ORIGINAL baseline against
|
||||
// the live snapshot; once the title frame has landed the same baseline
|
||||
// yields no trigger. These two assertions pin exactly that dependency.
|
||||
const baseline: SidebarSessionList = { current: 'p2', byId: { p2: { id: 'p2', displayTitle: 'P2' } } }
|
||||
const firstFrame: SidebarSessionList = {
|
||||
current: 'p2',
|
||||
byId: {
|
||||
p2: { id: 'p2', displayTitle: 'P2' },
|
||||
s2: { id: 's2', displayTitle: 'DSH-better-sidebar', origin: 'subagent', parentId: 'p2' },
|
||||
},
|
||||
}
|
||||
expect(detectNewDirectSubagent(baseline, firstFrame, 'p2')).toBe(true) // the race
|
||||
const settled: SidebarSessionList = {
|
||||
current: 'p2',
|
||||
byId: {
|
||||
p2: { id: 'p2', displayTitle: 'P2' },
|
||||
s2: { id: 's2', displayTitle: 'Side: New thread', origin: 'subagent', parentId: 'p2' },
|
||||
},
|
||||
}
|
||||
expect(detectNewDirectSubagent(baseline, settled, 'p2')).toBe(false) // after the debounce
|
||||
})
|
||||
|
||||
it('resolves the main-agent root of the current session tree', () => {
|
||||
const byId: SidebarSessionList['byId'] = {
|
||||
main: { id: 'main', displayTitle: 'Main' },
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Tab-strip right-click context-menu tests. Right-clicking a tab takes over
|
||||
* the browser menu (preventDefault) and shows the tab context menu with
|
||||
* exactly four items: close / close others / close to the left / close to
|
||||
* the right. All bulk operations are scoped to the CURRENT pane (the render
|
||||
* time tab snapshot) and reuse the per-tab onClose path, so the target tab
|
||||
* is never closed and the pane never empties mid-loop. The menu items gray
|
||||
* out when there is nothing to close (single tab → close others; leftmost →
|
||||
* close left; rightmost → close right). Opening the menu must not activate
|
||||
* the right-clicked tab.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
|
||||
// The act() environment flag (React 18.2 reads it before flushing effects).
|
||||
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
import { TabBar } from '../src/client/TabBar.tsx'
|
||||
import type { SidebarTab } from '../src/client/state.ts'
|
||||
|
||||
/** Point the browser-language fallback at Chinese so the menu labels assert. */
|
||||
function stubZh(): void {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: { language: 'zh-CN' },
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
|
||||
const MENU_LABELS = ['关闭', '关闭其他页签', '关闭左侧页签', '关闭右侧页签']
|
||||
|
||||
function mountBar(tabs: SidebarTab[]): {
|
||||
tabEls: HTMLElement[]
|
||||
onClose: ReturnType<typeof vi.fn>
|
||||
onActivate: ReturnType<typeof vi.fn>
|
||||
unmount: () => void
|
||||
} {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const onClose = vi.fn()
|
||||
const onActivate = vi.fn()
|
||||
const root: Root = createRoot(container)
|
||||
act(() => {
|
||||
root.render(createElement(TabBar, {
|
||||
paneId: 'pane:1',
|
||||
tabs,
|
||||
active: tabs[0]?.id ?? null,
|
||||
onActivate,
|
||||
onClose,
|
||||
onNewTab: () => {},
|
||||
newTabOptions: [],
|
||||
onDropTab: () => {},
|
||||
}))
|
||||
})
|
||||
const tabEls = [...container.querySelectorAll('[class*="tabList"] > [class*="tab"]')] as HTMLElement[]
|
||||
return {
|
||||
tabEls,
|
||||
onClose,
|
||||
onActivate,
|
||||
unmount: () => {
|
||||
act(() => { root.unmount() })
|
||||
container.remove()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function fourTabs(): SidebarTab[] {
|
||||
return [
|
||||
{ id: 't1', type: 'editor', title: 'Tab 1' },
|
||||
{ id: 't2', type: 'git', title: 'Tab 2' },
|
||||
{ id: 't3', type: 'terminal', title: 'Tab 3' },
|
||||
{ id: 't4', type: 'browser', title: 'Tab 4' },
|
||||
]
|
||||
}
|
||||
|
||||
/** Dispatch a native right-click (contextmenu) and return the event. */
|
||||
function rightClick(target: EventTarget): MouseEvent {
|
||||
const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 120, clientY: 40 })
|
||||
target.dispatchEvent(event)
|
||||
return event
|
||||
}
|
||||
|
||||
/** The portaled menu rows (empty when the menu is closed). */
|
||||
function menuItems(): HTMLElement[] {
|
||||
return [...document.querySelectorAll<HTMLElement>('[role="menuitem"]')]
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('TabBar right-click context menu', () => {
|
||||
it('opens the four-item menu at the cursor, prevents the browser menu, and does not activate', () => {
|
||||
stubZh()
|
||||
const { tabEls, onActivate, unmount } = mountBar(fourTabs())
|
||||
try {
|
||||
let event: MouseEvent | null = null
|
||||
act(() => { event = rightClick(tabEls[1]!) })
|
||||
expect(event!.defaultPrevented).toBe(true)
|
||||
expect(menuItems().map(item => item.textContent)).toEqual(MENU_LABELS)
|
||||
expect(onActivate).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('close closes only the target tab and closes the menu', () => {
|
||||
stubZh()
|
||||
const { tabEls, onClose, unmount } = mountBar(fourTabs())
|
||||
try {
|
||||
act(() => { rightClick(tabEls[1]!) })
|
||||
act(() => { menuItems()[0]!.click() })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(onClose).toHaveBeenCalledWith('t2')
|
||||
expect(menuItems()).toHaveLength(0)
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('close others closes every tab in the pane except the target, in visual order', () => {
|
||||
stubZh()
|
||||
const { tabEls, onClose, unmount } = mountBar(fourTabs())
|
||||
try {
|
||||
act(() => { rightClick(tabEls[1]!) })
|
||||
act(() => { menuItems()[1]!.click() })
|
||||
expect(onClose.mock.calls.map(call => call[0])).toEqual(['t1', 't3', 't4'])
|
||||
expect(onClose).not.toHaveBeenCalledWith('t2')
|
||||
expect(menuItems()).toHaveLength(0)
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('close left closes only the tabs to the left of the target', () => {
|
||||
stubZh()
|
||||
const { tabEls, onClose, unmount } = mountBar(fourTabs())
|
||||
try {
|
||||
act(() => { rightClick(tabEls[2]!) })
|
||||
act(() => { menuItems()[2]!.click() })
|
||||
expect(onClose.mock.calls.map(call => call[0])).toEqual(['t1', 't2'])
|
||||
expect(menuItems()).toHaveLength(0)
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('close right closes only the tabs to the right of the target', () => {
|
||||
stubZh()
|
||||
const { tabEls, onClose, unmount } = mountBar(fourTabs())
|
||||
try {
|
||||
act(() => { rightClick(tabEls[1]!) })
|
||||
act(() => { menuItems()[3]!.click() })
|
||||
expect(onClose.mock.calls.map(call => call[0])).toEqual(['t3', 't4'])
|
||||
expect(menuItems()).toHaveLength(0)
|
||||
} finally {
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('grays out close others on a single tab and close left/right at the strip ends', () => {
|
||||
stubZh()
|
||||
const single = mountBar([
|
||||
{ id: 'only', type: 'editor', title: 'Only' },
|
||||
])
|
||||
try {
|
||||
act(() => { rightClick(single.tabEls[0]!) })
|
||||
const items = menuItems()
|
||||
// The Menu renders each row as a disabled <button role="menuitem">.
|
||||
expect(items.map(item => (item as HTMLButtonElement).disabled)).toEqual([false, true, true, true])
|
||||
// Clicking the disabled row must not close anything.
|
||||
act(() => { items[1]!.click() })
|
||||
expect(single.onClose).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
single.unmount()
|
||||
}
|
||||
|
||||
const four = mountBar(fourTabs())
|
||||
try {
|
||||
act(() => { rightClick(four.tabEls[0]!) })
|
||||
expect(menuItems().map(item => (item as HTMLButtonElement).disabled)).toEqual([false, false, true, false])
|
||||
act(() => { rightClick(four.tabEls[3]!) })
|
||||
expect(menuItems().map(item => (item as HTMLButtonElement).disabled)).toEqual([false, false, false, true])
|
||||
} finally {
|
||||
four.unmount()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* The TabContent memo comparator: geometry/store re-renders of the Sidebar
|
||||
* shell must not reconcile every mounted tab (issue #315), while the fields
|
||||
* a cell's output or callbacks depend on MUST invalidate it. The pane-move
|
||||
* case (review P1): moveTab reuses the SAME tab object when a tab moves
|
||||
* panes, so paneId — not tab identity — is what identifies the move and
|
||||
* must be compared (onOpenDiff closes over the pane id).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { tabContentCompare, type TabContentMemoKey } from '../src/client/tab-content-memo.ts'
|
||||
import type { SidebarTab } from '../src/client/state.ts'
|
||||
|
||||
function makeKey(overrides: Partial<TabContentMemoKey> = {}): TabContentMemoKey {
|
||||
return {
|
||||
tab: { id: 'tab:1', type: 'git', title: 'Git' } as SidebarTab,
|
||||
paneId: 'pane:a',
|
||||
sessionId: 'session:1',
|
||||
cwd: '/workspace',
|
||||
visible: true,
|
||||
expanded: [],
|
||||
localeRevision: 'en-US',
|
||||
tabsVersion: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('tabContentCompare', () => {
|
||||
it('skips re-render when nothing render-affecting changed (callback identity alone is ignored)', () => {
|
||||
const key = makeKey()
|
||||
expect(tabContentCompare(key, { ...key })).toBe(true)
|
||||
})
|
||||
|
||||
it('invalidates on a tab moving between panes even though the tab OBJECT is reused (moveTab P1)', () => {
|
||||
const sameTab = { id: 'tab:1', type: 'git', title: 'Git' } as SidebarTab
|
||||
const before = makeKey({ tab: sameTab, paneId: 'pane:a' })
|
||||
const after = makeKey({ tab: sameTab, paneId: 'pane:b' }) // moveTab reinserts the same object
|
||||
expect(before.tab).toBe(after.tab)
|
||||
expect(tabContentCompare(before, after)).toBe(false)
|
||||
})
|
||||
|
||||
it('invalidates on locale switches (copy freshness)', () => {
|
||||
expect(tabContentCompare(makeKey(), makeKey({ localeRevision: 'zh-CN' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('invalidates on tab-registry updates (descriptor refresh)', () => {
|
||||
expect(tabContentCompare(makeKey(), makeKey({ tabsVersion: 1 }))).toBe(false)
|
||||
})
|
||||
|
||||
it('invalidates on visible/expanded/session/cwd changes', () => {
|
||||
expect(tabContentCompare(makeKey(), makeKey({ visible: false }))).toBe(false)
|
||||
expect(tabContentCompare(makeKey(), makeKey({ expanded: ['/workspace'] }))).toBe(false)
|
||||
expect(tabContentCompare(makeKey(), makeKey({ sessionId: 'session:2' }))).toBe(false)
|
||||
expect(tabContentCompare(makeKey(), makeKey({ cwd: '/other' }))).toBe(false)
|
||||
})
|
||||
})
|
||||
+14
-1
@@ -41,7 +41,7 @@
|
||||
* Types ship from lib/types (tsc -p tsconfig.build.json), not from tsdown.
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { basename, dirname, relative, resolve as resolvePath, sep } from 'node:path'
|
||||
import { basename, dirname, join, relative, resolve as resolvePath, sep } from 'node:path'
|
||||
import { builtinModules, createRequire } from 'node:module'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { UserConfig } from 'tsdown'
|
||||
@@ -67,6 +67,18 @@ const CLIENT_EXTERNALS = [
|
||||
'@deepseek-ai/dsh-client-runtime/client',
|
||||
]
|
||||
|
||||
/**
|
||||
* react-icons' exports map lists `require` BEFORE `import`, so the shared
|
||||
* conditionNames resolve the unshakeable CJS entry and the whole icon set
|
||||
* lands in the core bundle (~6.4 MB extra). Pin the two sets the client
|
||||
* uses to their ESM entries, which tree-shake down to the imported icons.
|
||||
*/
|
||||
const reactIconsRoot = dirname(dirname(require.resolve('react-icons/lib')))
|
||||
const REACT_ICONS_ESM_ALIAS = {
|
||||
'react-icons/si': join(reactIconsRoot, 'si/index.mjs'),
|
||||
'react-icons/vsc': join(reactIconsRoot, 'vsc/index.mjs'),
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire/type layers a client bundle may inline (mirror of the official
|
||||
* INLINE_SAFE list): browser-safe contract surfaces with no runtime identity
|
||||
@@ -140,6 +152,7 @@ function clientBundle(pluginId: string, entryFile: string): UserConfig {
|
||||
inputOptions: {
|
||||
resolve: {
|
||||
conditionNames: ['browser', 'import', 'require', 'default'],
|
||||
alias: REACT_ICONS_ESM_ALIAS,
|
||||
},
|
||||
},
|
||||
// External wins for module-table entries; every other dependency inlines.
|
||||
|
||||
Reference in New Issue
Block a user