diff --git a/AGENTS.md b/AGENTS.md index f4ba2c8..24b7a9f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: { diff --git a/README.md b/README.md index 7258fc3..b1e0be5 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ - [🔌 服务化扩展](#-服务化扩展) - [🛠️ 开发与构建](#-开发与构建) - [🔐 安全](#-安全) · [⚠️ 已知限制](#-已知限制) · [🖥️ 平台支持](#-平台支持) -- [🤝 参与贡献](#-参与贡献) · [⭐ Star History](#-star-history) · [🔗 友情链接](#-友情链接) +- [💬 社区](#-社区) · [🤝 参与贡献](#-参与贡献) · [⭐ Star History](#-star-history) · [🔗 友情链接](#-友情链接) ## ✨ 功能一览 @@ -52,7 +52,7 @@ - **⚡ 按需加载**:启动只拉 ~325KB 核心,终端 / 编辑器 / Mermaid 图表等重依赖用到才按需拉取([设计文档](docs/plans/2026-08-12-lazy-chunks-design.md)) - **🌏 多语言**:界面文案跟随 DSH 语言(zh / en)实时切换 -> 🔌 **核心理念**:服务优先——内置的 7 tab + 6 viewer 与第三方插件通过同一套 `ctx.betterSidebar` API 注册,能力完全对等;官方不再内置、可由生态提供的功能,交由生态插件实现(已有 **26+ 生态插件**,见下方「🌐 插件生态」)。接入文档见「🔌 服务化扩展」与 [外部插件接入指南](./docs/external-plugin-guide.md)。 +> 🔌 **核心理念**:服务优先——内置的 7 tab + 6 viewer 与第三方插件通过同一套 `ctx.betterSidebar` API 注册,能力完全对等;官方不再内置、可由生态提供的功能,交由生态插件实现(已有 **28+ 生态插件**,见下方「🌐 插件生态」)。接入文档见「🔌 服务化扩展」与 [外部插件接入指南](./docs/external-plugin-guide.md)。 ## 🚀 安装 @@ -151,69 +151,16 @@ dsh registry enable dsh-external/dsh-better-sidebar ## 🖼️ 特性巡礼 -> 以下均为真实界面实拍。 +> 以下均为真实界面实拍(每行两张,点击可放大)。 -### 🗂️ 文件工作台:资源管理器 - -支持两种格式的资源管理器:内嵌在文件预览中 / 独立显示文件树。懒加载目录树、软链接按目标类型展示(目录软链接可展开、失效链接标红)、全局文件名搜索、上传文件/文件夹与拖放上传、右键菜单(在新 Tab 打开 / 在侧边打开 / 复制路径)、悬浮 `@文件` 一键引用进输入框。 - -
文件资源管理器
-
CodeMirror 代码编辑器
- -### 📝 Markdown · 图片 · PDF 内联预览 - -Markdown 预览支持 **Mermaid 图表**(`securityLevel: 'strict'` 安全渲染 + 二次清洗;点击图表弹窗放大、滚轮缩放、拖拽平移);图片 / PDF 走媒体路由内联展示;Office 三件套由生态插件补齐。 - -
Markdown + Mermaid 预览
-
图片内联预览
- -### 💻 真实终端 - -xterm.js + node-pty 真实 shell(不是模拟器):断线重连 transcript 回放、shell / shellArgs 可配置(设置页或 `cordis.patch.yml`)、可选为模型注入 `terminal_*` 工具(agent 可直接开终端跑命令)。 - -
真实终端
- -### 🌿 Git 面板 - -暂存 / 取消暂存 / 提交(`Ctrl+Enter`)/ 还原,历史列表;点击改动文件打开 **VSCode 式 diff tab**(红绿行级对比)。 - -
Git 面板
- -### 🌐 内嵌浏览器 - -多开网页 tab:后退 / 前进 / 刷新 / 地址栏;内容运行在**不透明源沙箱 iframe**(界面实时显示沙箱状态,可按页面临时解锁);聊天里的外链点击可被接管到侧边栏打开(按协议分流,可配)。 - -
内嵌浏览器
- -### 🧩 任务页:子代理拓扑 + 后台任务 - -子代理树实时拓扑(运行状态、批量实时预览)+ 后台任务清单(退出码 / 实时输出 / 强制终止);新子代理 / 新任务可自动展开侧边栏(可关)。 - -
任务页:子代理拓扑
- -### 💬 侧边对话(beta) - -Codex 风格侧边线程:**每个对话一个独立 Tab**;线程继承主会话完整上下文(含进行中回合,以 interrupted 诚实冻结)独立运行,不污染主会话;可持续追问、重启冷恢复;一键「保存为新会话」提升为顶层会话。 - -
侧边对话(beta)
- -### 🪟 双工作台:右侧栏 + 底部面板 + 分栏 - -右侧栏与底部面板可同时展开;拖 Tab 到分栏边缘**拆分**、拖到中间**合并**(可跨面板);面板宽高左缘/上缘拖拽调节;移动端自动合并为全宽抽屉。 - -
双工作台(右侧栏 + 底部面板)
- -### ⚙️ 声明式设置 - -设置页「侧边卡片」分区:每个 tab / 预览器一张小卡片,独立开关(高亮启用态 + 品牌开关滑块);二级设置经卡片底部「功能设置」条弹窗(开关 / 文本 / 数字 / 下拉);插件自有设置持久化在 `pluginSettings`。 - -
声明式设置:侧边卡片
- -### 📱 移动端 - -窄屏(<768px)自动切换为全宽抽屉:底栏 tab 一次性并入右侧栏,触屏拖拽可调。 - -
移动端全宽抽屉
+| | | +|---|---| +| **🗂️ 文件工作台:资源管理器**
支持两种格式的资源管理器:内嵌在文件预览中 / 独立显示文件树。懒加载目录树、软链接按目标类型展示(目录软链接可展开、失效链接标红)、全局文件名搜索、上传文件/文件夹与拖放上传、右键菜单(在新 Tab 打开 / 在侧边打开 / 复制路径)、悬浮 `@文件` 一键引用进输入框。
文件资源管理器
| **📝 Markdown · 图片 · PDF 内联预览**
Markdown 预览支持 **Mermaid 图表**(`securityLevel: 'strict'` 安全渲染 + 二次清洗;点击图表弹窗放大、滚轮缩放、拖拽平移);图片 / PDF 走媒体路由内联展示;Office 三件套由生态插件补齐。
Markdown + Mermaid 预览
| +| **🖥️ CodeMirror 代码编辑器**
CodeMirror 代码编辑器
| **🖼️ 图片内联预览**
图片内联预览
| +| **💻 真实终端**
xterm.js + node-pty 真实 shell(不是模拟器):断线重连 transcript 回放、shell / shellArgs 可配置(设置页或 `cordis.patch.yml`)、可选为模型注入 `terminal_*` 工具(agent 可直接开终端跑命令)。
真实终端
| **🌿 Git 面板**
暂存 / 取消暂存 / 提交(`Ctrl+Enter`)/ 还原,历史列表;点击改动文件打开 **VSCode 式 diff tab**(红绿行级对比)。
Git 面板
| +| **🌐 内嵌浏览器**
多开网页 tab:后退 / 前进 / 刷新 / 地址栏;内容运行在**不透明源沙箱 iframe**(界面实时显示沙箱状态,可按页面临时解锁);聊天里的外链点击可被接管到侧边栏打开(按协议分流,可配)。
内嵌浏览器
| **🧩 任务页:子代理拓扑 + 后台任务**
子代理树实时拓扑(运行状态、批量实时预览)+ 后台任务清单(退出码 / 实时输出 / 强制终止);新子代理 / 新任务可自动展开侧边栏(可关)。
任务页:子代理拓扑
| +| **💬 侧边对话(beta)**
Codex 风格侧边线程:**每个对话一个独立 Tab**;线程继承主会话完整上下文(含进行中回合,以 interrupted 诚实冻结)独立运行,不污染主会话;可持续追问、重启冷恢复;一键「保存为新会话」提升为顶层会话。
侧边对话(beta)
| **🪟 双工作台:右侧栏 + 底部面板 + 分栏**
右侧栏与底部面板可同时展开;拖 Tab 到分栏边缘**拆分**、拖到中间**合并**(可跨面板);面板宽高左缘/上缘拖拽调节;移动端自动合并为全宽抽屉。
双工作台(右侧栏 + 底部面板)
| +| **⚙️ 声明式设置**
设置页「侧边卡片」分区:每个 tab / 预览器一张小卡片,独立开关(高亮启用态 + 品牌开关滑块);二级设置经卡片底部「功能设置」条弹窗(开关 / 文本 / 数字 / 下拉);插件自有设置持久化在 `pluginSettings`。
声明式设置:侧边卡片
| **📱 移动端**
窄屏(<768px)自动切换为全宽抽屉:底栏 tab 一次性并入右侧栏,触屏拖拽可调。
移动端全宽抽屉
| ## 🌐 插件生态 @@ -234,7 +181,7 @@ export function apply(ctx: Context) { } ``` -GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar) 下已有 **26+ 生态插件**(持续增长中): +GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar) 下已有 **28+ 生态插件**(持续增长中):
设置页「添加插件」弹窗:推荐插件目录 + 一键复制安装命令
@@ -243,6 +190,9 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar ### 📑 Tab 插件(注册侧边栏页面) +
+23 个插件(点击展开) + | 插件 | ⭐ | 简介 | |---|---|---| | [ChenRuoT/dsh-sidebar-qa](https://github.com/ChenRuoT/dsh-sidebar-qa) | stars | 划选追问侧边页:类 Codex 侧边提问 / Claude Code `/btw` | @@ -266,33 +216,77 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar | [yq04/dsh-turn-review](https://github.com/yq04/dsh-turn-review) | stars | 本轮审查:逐回合审查 agent 改动 | | [Ghz114514/dsh-refpics](https://github.com/Ghz114514/dsh-refpics) | stars | Pinterest 风格参考图搜索:瀑布流、侧栏画板、下载与 Eagle 收藏 | | [yzlin499/dsh-yzlin499-easy-plugins](https://github.com/yzlin499/dsh-yzlin499-easy-plugins) | stars | 实用小工具集(毛坯房 DSH 友好) | +| [dong-victor/dsh-better-sidebar-starter](https://github.com/dong-victor/dsh-better-sidebar-starter) | stars | 运行配置页:IDEA 式 Run/Debug 配置(npm / springboot / python / custom)——一键启动、历史保存、WebSocket 实时日志(ANSI 彩色)、多实例并行、进程树跨平台杀死 | +| [baosfeng/my-dsh-plugins](https://github.com/baosfeng/my-dsh-plugins) | stars | 个人多插件合集(`dsh-file-activity`):侧边栏文件活动页——记录文件读取 / 新增 / 修改历史与统计,按文件夹平铺,点击用原生预览打开 | + +
### 🖼️ 预览插件(注册文件预览器) +
+3 个插件(点击展开) + | 插件 | ⭐ | 简介 | |---|---|---| | [HuanLinOTO/dsh-plugin-better-sidebar-plugin-office](https://github.com/HuanLinOTO/dsh-plugin-better-sidebar-plugin-office) | stars | Office 三件套预览(.docx / .xlsx / .pptx),独立 bundle 瘦身主体(官方推荐目录收录) | | [zemul/dsh-video-preview](https://github.com/zemul/dsh-video-preview) | stars | 视频内联预览:.mp4 / .webm / .mov / .mkv / .avi,自带 /video 路由支持 HTTP Range 拖进度条 | | [dong-victor/dsh-better-sidebar-jupyter](https://github.com/dong-victor/dsh-better-sidebar-jupyter) | stars | `.ipynb` 可运行 Notebook 视图:懒启动 Python kernel、流式输出、保存回写 | +
+ ### 🧰 增强与工具 +
+2 个插件(点击展开) + | 插件 | ⭐ | 简介 | |---|---|---| | [dong-victor/dsh-better-sidebar-terminal-plus](https://github.com/dong-victor/dsh-better-sidebar-terminal-plus) | stars | 终端增强:内嵌 Nerd Font 图标字体、修复 xterm 图标渲染、稳定终端 cwd | | [Max-Null/dsh-sidebar-preview-select](https://github.com/Max-Null/dsh-sidebar-preview-select) | stars | 预览划选增强:侧边栏预览里划选文本 → 浮动「发送到会话」 | +
+ > 📣 **上架你的插件**:给仓库打上 `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 版本**:支持的 DSH 版本:0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2 · 完整发布历史见 [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+/` 协议打开,本地专用条目自动隐藏;支持自定义编辑器(名称 + 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;拖拽不变(非回归) + +
+历史版本(v0.12.0 – v0.15.1) +### 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 以来的全部更改: @@ -313,9 +307,6 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar - 🔧 **适配 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.12.0 – v0.14.0) - ### v0.14.0 > ⚠️ 本版起需要 DSH ≥ 0.1.0-rc.8。自 v0.13.1 以来的全部更改: @@ -374,6 +365,15 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
+## 💬 社区 + +推荐添加QQ群(577011007) + +
+ 微信群二维码 + QQ群二维码 +
+ ## ⌨️ 快捷键 | 操作 | 按键 | @@ -381,6 +381,7 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar | 保存编辑 | `Ctrl/Cmd + S` | | Git 提交 | `Ctrl + Enter` | | 关闭 Tab | 鼠标中键 | +| Tab 右键菜单 | 关闭 / 关闭其他页签 / 关闭左侧页签 / 关闭右侧页签(当前标签组) | | 拆分/合并分栏 | 拖 Tab 到分栏边缘 / 中间 | | 引用文件到输入框 | 悬浮行尾 `@文件` 按钮 | | 复制文件路径 | 右键行 → 复制相对/绝对地址 | diff --git a/README_EN.md b/README_EN.md index 2d600cb..cbbd363 100644 --- a/README_EN.md +++ b/README_EN.md @@ -36,7 +36,7 @@ - [🔌 Service API](#-service-api) - [🛠️ Development & Build](#-development--build) - [🔐 Security](#-security) · [⚠️ Known Limitations](#-known-limitations) · [🖥️ Platform Support](#-platform-support) -- [🤝 Contributing](#-contributing) · [⭐ Star History](#-star-history) · [🔗 Friends](#-friends) +- [💬 Community](#-community) · [🤝 Contributing](#-contributing) · [⭐ Star History](#-star-history) · [🔗 Friends](#-friends) ## ✨ Features @@ -52,7 +52,7 @@ - **⚡ 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 (**26+ ecosystem plugins** already — see "🌐 Plugin Ecosystem" below). See "🔌 Service API" and the [external plugin guide](./docs/external-plugin-guide.md). +> 🔌 **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 @@ -151,69 +151,16 @@ Update: `git pull && pnpm install && pnpm build` → `node scripts/package-regis ## 🖼️ Feature Tour -> Below are real UI screenshots. +> Below are real UI screenshots (two per row; click to zoom). -### 🗂️ File Workbench: Explorer - -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. - -
File explorer
-
CodeMirror editor
- -### 📝 Inline Preview: Markdown · Images · PDF - -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. - -
Markdown + Mermaid preview
-
Inline image preview
- -### 💻 Real Terminal - -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. - -
Real terminal
- -### 🌿 Git Panel - -Stage / unstage / commit (`Ctrl+Enter`) / revert, plus a history list; clicking a changed file opens a **VSCode-style diff tab** (line-level red/green). - -
Git panel
- -### 🌐 Embedded Browser - -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). - -
Embedded browser
- -### 🧩 Tasks: Agent Topology + Background Jobs - -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). - -
Tasks: subagent topology
- -### 💬 Side Chat (beta) - -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. - -
Side Chat (beta)
- -### 🪟 Dual Workbench: Sidebar + Bottom Panel + Split Panes - -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. - -
Dual workbench (right sidebar + bottom panel)
- -### ⚙️ Declarative Settings - -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`. - -
Declarative settings: side cards
- -### 📱 Mobile - -On narrow screens (<768px) the panels become a full-width drawer: bottom-panel tabs merge into the sidebar once, with touch-friendly dragging. - -
Mobile full-width drawer
+| | | +|---|---| +| **🗂️ File Workbench: Explorer**
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.
File explorer
| **📝 Inline Preview: Markdown · Images · PDF**
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.
Markdown + Mermaid preview
| +| **🖥️ CodeMirror editor**
CodeMirror editor
| **🖼️ Inline image preview**
Inline image preview
| +| **💻 Real Terminal**
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.
Real terminal
| **🌿 Git Panel**
Stage / unstage / commit (`Ctrl+Enter`) / revert, plus a history list; clicking a changed file opens a **VSCode-style diff tab** (line-level red/green).
Git panel
| +| **🌐 Embedded Browser**
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).
Embedded browser
| **🧩 Tasks: Agent Topology + Background Jobs**
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).
Tasks: subagent topology
| +| **💬 Side Chat (beta)**
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.
Side Chat (beta)
| **🪟 Dual Workbench: Sidebar + Bottom Panel + Split Panes**
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.
Dual workbench (right sidebar + bottom panel)
| +| **⚙️ Declarative Settings**
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`.
Declarative settings: side cards
| **📱 Mobile**
On narrow screens (<768px) the panels become a full-width drawer: bottom-panel tabs merge into the sidebar once, with touch-friendly dragging.
Mobile full-width drawer
| ## 🌐 Plugin Ecosystem @@ -234,7 +181,7 @@ export function apply(ctx: Context) { } ``` -The GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar) already hosts **26+ ecosystem plugins** (and growing): +The GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar) already hosts **28+ ecosystem plugins** (and growing):
The built-in Add Plugins modal: recommended catalog + one-click install command
@@ -243,6 +190,9 @@ The GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sid ### 📑 Tab Plugins (sidebar pages) +
+23 plugins (click to expand) + | Plugin | ⭐ | Description | |---|---|---| | [ChenRuoT/dsh-sidebar-qa](https://github.com/ChenRuoT/dsh-sidebar-qa) | stars | Selection-based side Q&A — Codex-style side questions / Claude Code `/btw` | @@ -266,22 +216,36 @@ The GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sid | [yq04/dsh-turn-review](https://github.com/yq04/dsh-turn-review) | stars | Turn review: review agent changes turn by turn | | [Ghz114514/dsh-refpics](https://github.com/Ghz114514/dsh-refpics) | stars | 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) | stars | A handy utility bundle for a bare-bones DSH | +| [dong-victor/dsh-better-sidebar-starter](https://github.com/dong-victor/dsh-better-sidebar-starter) | stars | 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) | stars | 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 | + +
### 🖼️ Viewer Plugins (file previewers) +
+3 plugins (click to expand) + | Plugin | ⭐ | Description | |---|---|---| | [HuanLinOTO/dsh-plugin-better-sidebar-plugin-office](https://github.com/HuanLinOTO/dsh-plugin-better-sidebar-plugin-office) | stars | 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) | stars | 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) | stars | Runnable `.ipynb` notebook view: lazy-start Python kernel, streaming outputs, save-back | +
+ ### 🧰 Enhancements & Tools +
+2 plugins (click to expand) + | Plugin | ⭐ | Description | |---|---|---| | [dong-victor/dsh-better-sidebar-terminal-plus](https://github.com/dong-victor/dsh-better-sidebar-terminal-plus) | stars | 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) | stars | Preview selection boost: select text in any sidebar preview → floating "send to session" | +
+ > 📣 **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 @@ -293,6 +257,42 @@ The GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sid **Supported DSH versions**: Supported DSH versions: 0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2 · 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+/` 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) + +
+Older releases (v0.12.0 – v0.15.1) + +### 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: @@ -313,9 +313,6 @@ All changes since v0.14.0: - 🔧 **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 -
-Older releases (v0.12.0 – v0.14.0) - ### v0.14.0 > ⚠️ This release requires DSH ≥ 0.1.0-rc.8. All changes since v0.13.1: @@ -381,6 +378,7 @@ All changes since v0.14.0: | 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 | @@ -430,6 +428,19 @@ 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: + +
+ + +
+ ## 🤝 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 diff --git a/docs/plans/2026-08-22-open-with-menu-design.md b/docs/plans/2026-08-22-open-with-menu-design.md new file mode 100644 index 0000000..526d540 --- /dev/null +++ b/docs/plans/2026-08-22-open-with-menu-design.md @@ -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 系")改用 `://vscode-remote/ssh-remote+/` 打开;本地专用条目(资源管理器 / 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:", "name": "Windsurf", "urlTemplate": "windsurf://file/{path}", "isVscodeFamily": false } + ], + "pinned": ["vscode", "custom:"] + } +} +``` + +- `parseOpenWithConfig`(`src/client/open-with.ts`)容错解析:坏行丢弃、未知 pinned 在解析目标时剪枝;**结构合法但未填完的行(空 name/模板)保留**——设置面板正在编辑的行必须能在弹窗往返中存活,由菜单侧的 `isValidCustomEditor` 过滤。 +- 目标 id:内置 `explorer` / `vscode` / `cursor` / `zed`;自定义 `custom:`。 +- URL 构建: + - 本地:模板原文替换 `{path}`(`vscode://file//home/u/f.ts`——POSIX 绝对路径自带前导 `/`,与 VS Code 官方文档 `vscode://file/c:/…` 的约定一致;反斜杠归一化为 `/`)。 + - SSH(VSCode 系):`://vscode-remote/ssh-remote+/`,路径保留前导 `/`(如 `…+dev/home/u/f.ts`,与社区约定一致)。 + - 模板无 `scheme://` 或无 `{path}` → 返回 undefined(不打开)。 + +## 菜单结构(文件行与目录行一致) + +``` +在新 Tab 中打开 ← 既有 +在侧边打开 ← 既有 +---------------------------- +VS Code (SSH) ← pinned 直达项(按内置顺序 + 自定义追加) +Cursor (SSH) +---------------------------- +在应用中打开 > ← 子菜单父项(始终在 pinned 之后) + 资源管理器 📌 + VS Code 📌(激活) + Cursor 📌(激活) + Zed 📌 + Windsurf 📌 +下载 ← 既有(仅文件行) +复制相对地址 / 复制绝对地址 ← 既有 +``` + +- 图钉是 `label` 内的 ``(Menu 行本身是 `
{message !== null &&
{message}
} diff --git a/src/client/DiffView.tsx b/src/client/DiffView.tsx index 0edd3fc..e6705b7 100644 --- a/src/client/DiffView.tsx +++ b/src/client/DiffView.tsx @@ -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 { + const expanded = new Set() + 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>(() => 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 ( -
+
+ ) } if (row.type === 'hunk') { diff --git a/src/client/EditorHost.tsx b/src/client/EditorHost.tsx index 984a1d7..a732a9f 100644 --- a/src/client/EditorHost.tsx +++ b/src/client/EditorHost.tsx @@ -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 = {} + /** The tab's persisted meta object (a malformed meta reads as empty). */ function metaOf(tab: SidebarTab): Record { 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(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} />
@@ -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} /> diff --git a/src/client/FileTree.tsx b/src/client/FileTree.tsx index ebea5a7..a65122b 100644 --- a/src/client/FileTree.tsx +++ b/src/client/FileTree.tsx @@ -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' @@ -109,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 `@` into the composer draft. */ onReferenceFile: (path: string) => void /** Bump to wipe the level cache and reload the visible set. */ @@ -118,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>({}) const dataRef = useRef(data) /** The row whose path was just copied ("copied" label replaces its button). */ @@ -307,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 + if (target.id === 'vscode') return + if (target.id === 'cursor') return + if (target.id === 'zed') return + return + } + const pinned = openWithTargets + .filter(target => pinnedIds.includes(target.id)) + .map(target => ({ + id: `open-with:${target.id}`, + label: openWithLabelOf(target), + icon: itemIcon(target), + })) + const submenu = openWithTargets.map(target => { + const pinnedNow = pinnedIds.includes(target.id) + return { + id: `open-with:${target.id}`, + label: ( + + {openWithLabelOf(target)} + {/* 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. */} + { + event.preventDefault() + event.stopPropagation() + onToggleOpenWithPin?.(target.id) + }} + > + {pinnedNow ? : } + + + ), + 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: ( + + {t('openWithMenu')} + + + ), + icon: , + submenu, + }, + ] + } + const root = cwd const renderLevel = (dir: string, depth: number): ReactNode => { @@ -346,7 +444,7 @@ export function FileTree(props: { onDrop={(event) => { handleDirDrop(event, entry.path) }} onContextMenu={(event) => { openRowMenu(event, entry.path, true) }} > - {isOpen ? : } + {isOpen ? : } {entry.name} {entry.isSymlink && } {rowActions(entry)} @@ -377,7 +475,7 @@ export function FileTree(props: { onDrop={(event) => { handleFileDrop(event, entry.path) }} onContextMenu={(event) => { openRowMenu(event, entry.path, false) }} > - + {entry.name} {entry.isSymlink && } {rowActions(entry)} @@ -406,7 +504,7 @@ export function FileTree(props: { onDrop={(event) => { handleDirDrop(event, root) }} onContextMenu={(event) => { openRowMenu(event, root, true) }} > - + {baseName(root)} {copiedPath === root ? {t('copied')} @@ -498,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: }] + ? [{ id: 'open-new-tab', label: t('openFileNewTab'), icon: }] : []), ...(rowMenu?.isDir === false && onOpenFileSide !== undefined - ? [{ id: 'open-side', label: t('openFileSide'), icon: }] + ? [{ id: 'open-side', label: t('openFileSide'), icon: }] : []), + ...openWithEntries(), // Download applies to files only (the host route refuses directories). ...(rowMenu?.isDir === false - ? [{ id: 'download', label: t('download'), icon: }] + ? [{ id: 'download', label: t('download'), icon: }] : []), // Upload into a directory (incl. the workspace root row). ...(rowMenu?.isDir === true - ? [{ id: 'upload-here', label: t('uploadHere'), icon: }] + ? [{ id: 'upload-here', label: t('uploadHere'), icon: }] : []), - { id: 'relative', label: t('copyRelative'), icon: }, - { id: 'absolute', label: t('copyAbsolute'), icon: }, + { id: 'relative', label: t('copyRelative'), icon: }, + { id: 'absolute', label: t('copyAbsolute'), icon: }, ]} onSelect={(id) => { const target = rowMenu @@ -526,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 diff --git a/src/client/SideCardSection.module.css b/src/client/SideCardSection.module.css index 97262bd..ff19d7c 100644 --- a/src/client/SideCardSection.module.css +++ b/src/client/SideCardSection.module.css @@ -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; +} diff --git a/src/client/SideCardSection.tsx b/src/client/SideCardSection.tsx index 3ff1db6..7122451 100644 --- a/src/client/SideCardSection.tsx +++ b/src/client/SideCardSection.tsx @@ -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 ( - - ) - } 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 ( -
- {toggles.length > 0 && ( - +
+ {(toggles.length > 0 || pluginToggles.length > 0) && ( +
+ {toggles.length > 0 && ( + + )} + {pluginToggles.length > 0 && ( + pluginBlob[key]} + /> + )} +
)} - {pluginToggles.length > 0 && ( - pluginBlob[key]} + {render !== undefined && ( + )}
@@ -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 && ( 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) @@ -152,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 @@ -185,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 @@ -497,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 @@ -518,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 @@ -571,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 '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() @@ -708,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) } @@ -1033,6 +1069,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { const renderTab = (tab: SidebarTab, active: boolean, paneId: string, bottom = false) => ( { subagentJumpRef.current = childSessionId }} onOpenDiff={(diffTab) => { store.reduce(s => openDiffTab(s, paneId, diffTab)) }} + localeRevision={localeRevision} + tabsVersion={tabsVersion} /> ) @@ -1231,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, @@ -1240,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} diff --git a/src/client/TabBar.tsx b/src/client/TabBar.tsx index 8cd92bf..8fd8f39 100644 --- a/src/client/TabBar.tsx +++ b/src/client/TabBar.tsx @@ -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(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) }} > )} /> + {/* + 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. + */} + = 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={} + />
) diff --git a/src/client/TreePanel.tsx b/src/client/TreePanel.tsx index 1379d77..8bb3f38 100644 --- a/src/client/TreePanel.tsx +++ b/src/client/TreePanel.tsx @@ -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(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} diff --git a/src/client/api.ts b/src/client/api.ts index bbce123..8bb27f2 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -298,6 +298,12 @@ export const api = { * check; see the host's browser.probe route). */ browserProbe: (url: string, signal?: AbortSignal) => call('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). */ diff --git a/src/client/builtins/tabs.tsx b/src/client/builtins/tabs.tsx index 7fb0f0b..38579ce 100644 --- a/src/client/builtins/tabs.tsx +++ b/src/client/builtins/tabs.tsx @@ -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 }) => ( + + ), }, component: ({ ctx, store, scope, tab, expanded, onToggleDir, onReferenceFile }) => ( 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 + }, + } +} diff --git a/src/client/icons.tsx b/src/client/icons.tsx index e0db7aa..5f7b64b 100644 --- a/src/client/icons.tsx +++ b/src/client/icons.tsx @@ -137,3 +137,16 @@ export const IconSaveOutline16 = ({ size = 16, className }: IconProps) => ( ) + +/** + * 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) => ( + + + +) diff --git a/src/client/locales.ts b/src/client/locales.ts index fdffc7f..e868552 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -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: '折叠底部面板', @@ -337,6 +360,26 @@ export const en: Record = { 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', @@ -344,6 +387,9 @@ export const en: Record = { 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', diff --git a/src/client/open-with-settings.tsx b/src/client/open-with-settings.tsx new file mode 100644 index 0000000..a56d9fb --- /dev/null +++ b/src/client/open-with-settings.tsx @@ -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 + 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(() => 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): 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 ( +
+
+ + {t('openWithSettingsSshTitle')} + {t('openWithSettingsSshDesc')} + + { setSshHost(event.target.value) }} + /> +
+
+ + {t('openWithSettingsCustomTitle')} + {t('openWithSettingsCustomDesc')} + + + + +
+ {draft.customEditors.map(editor => ( +
+ { patchCustom(editor.id, { name: event.target.value }) }} + /> + { patchCustom(editor.id, { urlTemplate: event.target.value }) }} + /> + + +
+ ))} + {hasInvalid && ( +
+ {t('openWithSettingsInvalidHint')} +
+ )} +
+ ) +} diff --git a/src/client/open-with.ts b/src/client/open-with.ts new file mode 100644 index 0000000..b255630 --- /dev/null +++ b/src/client/open-with.ts @@ -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 (`://vscode-remote/ssh-remote+/…`). */ + 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:'). */ + pinned: string[] +} + +/** One menu-visible open target (built-in or custom, SSH-filtered). */ +export interface OpenWithTarget { + /** Stable id (`explorer` / `vscode` / `cursor` / `zed` / `custom:`). */ + 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 + 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 + 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+` 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()) +} diff --git a/src/client/plugin-settings.ts b/src/client/plugin-settings.ts new file mode 100644 index 0000000..db6658f --- /dev/null +++ b/src/client/plugin-settings.ts @@ -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 = 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) => Record, +): 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) + }) +} diff --git a/src/client/service.ts b/src/client/service.ts index 98b6b96..af9ac1d 100644 --- a/src/client/service.ts +++ b/src/client/service.ts @@ -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 diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index f648d4c..1c59197 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -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); +} diff --git a/src/client/split-pane.tsx b/src/client/split-pane.tsx index 5388d55..06af947 100644 --- a/src/client/split-pane.tsx +++ b/src/client/split-pane.tsx @@ -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 (
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) }} diff --git a/src/client/state.ts b/src/client/state.ts index d2d2783..9182334 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -825,8 +825,9 @@ export function sanitizeState(parsed: unknown): SidebarState | undefined { // bottom tree. const seen = new Set() const reid = new Map() - 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 diff --git a/src/client/tab-content-memo.ts b/src/client/tab-content-memo.ts new file mode 100644 index 0000000..50ef171 --- /dev/null +++ b/src/client/tab-content-memo.ts @@ -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 + ) +} diff --git a/src/git.ts b/src/git.ts index de4cdf6..d7e3ff1 100644 --- a/src/git.ts +++ b/src/git.ts @@ -101,6 +101,7 @@ function runGit(cwd: string, args: string[], timeoutMs = 30_000): Promise((resolvePromise, reject) => { const child = spawn('git', full, { stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' }, }) let stdout = '' @@ -177,14 +178,18 @@ export async function currentBranch(cwd: string): Promise { 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, while preserving + * repository discovery and explicit repository selection for workspace roots. + */ export async function status(cwd: string, selected?: string): Promise { const repositories = await repoRoots(cwd) if (repositories.length === 0) return { isRepo: false, entries: [], repositories: [] } const root = await repoRoot(cwd, selected) const [branch, raw] = await Promise.all([ currentBranch(root).catch(() => 'HEAD'), - runGit(root, ['status', '--porcelain=v1', '-z', '--untracked-files=normal']), + runGit(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all']), ]) return { isRepo: true, branch, entries: parsePorcelainZ(raw), root, repositories } } diff --git a/src/index.ts b/src/index.ts index 50e2f18..444d01a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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' @@ -489,6 +490,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. diff --git a/src/open-external.ts b/src/open-external.ts new file mode 100644 index 0000000..de0fabf --- /dev/null +++ b/src/open-external.ts @@ -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,` — the classic "show in folder" incantation. + // Passed as separate argv entries (no shell); verify on Windows, with + // `cmd /c start "" explorer.exe "/select,"` 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 "" ` 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 } +} diff --git a/tests/builtins.spec.ts b/tests/builtins.spec.ts index 49ed35e..e1baa80 100644 --- a/tests/builtins.spec.ts +++ b/tests/builtins.spec.ts @@ -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', () => { diff --git a/tests/diff-view-collapse.spec.tsx b/tests/diff-view-collapse.spec.tsx new file mode 100644 index 0000000..d6785e5 --- /dev/null +++ b/tests/diff-view-collapse.spec.tsx @@ -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).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('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() + } + }) +}) diff --git a/tests/e2e/toggle-layout.e2e.ts b/tests/e2e/toggle-layout.e2e.ts new file mode 100644 index 0000000..e391079 --- /dev/null +++ b/tests/e2e/toggle-layout.e2e.ts @@ -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 { + 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): Promise { + // 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') +}) diff --git a/tests/editor-host.spec.tsx b/tests/editor-host.spec.tsx index 1e060e7..34bdf7f 100644 --- a/tests/editor-host.spec.tsx +++ b/tests/editor-host.spec.tsx @@ -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(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 })) }) diff --git a/tests/file-tree-open-with.spec.tsx b/tests/file-tree-open-with.spec.tsx new file mode 100644 index 0000000..8ac1e21 --- /dev/null +++ b/tests/file-tree-open-with.spec.tsx @@ -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).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 + onToggleOpenWithPin: ReturnType + unmount: () => void +} + +async function mountTree(overrides: { + openWithTargets?: OpenWithTarget[] + openWithPinned?: string[] + openWithSsh?: boolean +} = {}): Promise { + 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('[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('[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('[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('[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('[role="menuitem"]')] + .find(item => item.getAttribute('aria-haspopup') === 'menu') + act(() => { parent!.click() }) + const cursorRow = [...document.querySelectorAll('[role="menu"] [role="menu"] [role="menuitem"]')] + .find(item => item.textContent?.trim() === 'Cursor') + const pin = cursorRow!.querySelector('[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('[role="menuitem"]')] + .find(item => item.getAttribute('aria-haspopup') === 'menu') + act(() => { parent!.click() }) + const zedRow = [...document.querySelectorAll('[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('[role="menuitem"]')] + .find(item => item.getAttribute('aria-haspopup') === 'menu') + act(() => { parent!.click() }) + const children = [...document.querySelectorAll('[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('[role="menuitem"]')] + expect(items.some(item => item.textContent?.includes('Open with'))).toBe(false) + expect(items.some(item => item.getAttribute('aria-haspopup') === 'menu')).toBe(false) + }) +}) diff --git a/tests/frame-batcher.spec.ts b/tests/frame-batcher.spec.ts new file mode 100644 index 0000000..f94bc58 --- /dev/null +++ b/tests/frame-batcher.spec.ts @@ -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 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]) + }) +}) diff --git a/tests/git-spawn.spec.ts b/tests/git-spawn.spec.ts new file mode 100644 index 0000000..eab01ec --- /dev/null +++ b/tests/git-spawn.spec.ts @@ -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 }), + ) + }) +}) diff --git a/tests/git.spec.ts b/tests/git.spec.ts index 06594ce..655bde4 100644 --- a/tests/git.spec.ts +++ b/tests/git.spec.ts @@ -1,4 +1,5 @@ -import { execFile } from 'node:child_process' +import { execFile, execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { mkdir, mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -44,6 +45,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' diff --git a/tests/open-external.spec.ts b/tests/open-external.spec.ts new file mode 100644 index 0000000..6585930 --- /dev/null +++ b/tests/open-external.spec.ts @@ -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 ` selects the file in Finder', () => { + expect(revealCommand('/a/b.txt', 'darwin')).toEqual({ command: 'open', args: ['-R', '/a/b.txt'] }) + }) + + it('win32: `explorer /select,` 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 ` launches the registered protocol handler', () => { + expect(urlCommand('vscode://file/x', 'darwin')).toEqual({ command: 'open', args: ['vscode://file/x'] }) + }) + + it('win32: rundll32 url.dll,FileProtocolHandler ', () => { + expect(urlCommand('cursor://file/x', 'win32')).toEqual({ + command: 'rundll32.exe', + args: ['url.dll,FileProtocolHandler', 'cursor://file/x'], + }) + }) + + it('linux: `xdg-open ` 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) + }) +}) diff --git a/tests/open-with-settings.spec.tsx b/tests/open-with-settings.spec.tsx new file mode 100644 index 0000000..06eabb3 --- /dev/null +++ b/tests/open-with-settings.spec.tsx @@ -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).IS_REACT_ACT_ENVIRONMENT = true + +beforeAll(() => { + Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true }) +}) + +interface Harness { + container: HTMLDivElement + update: ReturnType + 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('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('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('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('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) + }) +}) diff --git a/tests/open-with.spec.ts b/tests/open-with.spec.ts new file mode 100644 index 0000000..beb3d76 --- /dev/null +++ b/tests/open-with.spec.ts @@ -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) + }) +}) diff --git a/tests/side-card-section.spec.tsx b/tests/side-card-section.spec.tsx index 4126e2a..07cc983 100644 --- a/tests/side-card-section.spec.tsx +++ b/tests/side-card-section.spec.tsx @@ -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') + }) +}) diff --git a/tests/state.spec.ts b/tests/state.spec.ts index 50df04c..2d1f952 100644 --- a/tests/state.spec.ts +++ b/tests/state.spec.ts @@ -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')) diff --git a/tests/tab-bar-context-menu.spec.tsx b/tests/tab-bar-context-menu.spec.tsx new file mode 100644 index 0000000..af4e817 --- /dev/null +++ b/tests/tab-bar-context-menu.spec.tsx @@ -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).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 + onActivate: ReturnType + 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('[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