From 940d0a8928521c88d0e1c805ef9067603d020be8 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Sat, 22 Aug 2026 00:49:08 +0800 Subject: [PATCH 01/21] docs(readme): feature tour with screenshots, plugin ecosystem section, DSH compat badges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Hero badge wall: npm version/downloads, CI, stars, DSH compat (0.1.0-rc.8 / 0.1.1-rc.1 / 0.1.1-rc.2), ecosystem topic badge - New 🖼️ feature tour: one subsection per feature with real UI screenshots (10 captured from a scratch DSH instance; 4 slots left as commented placeholders for manual upload via GitHub web) - New 🌐 plugin ecosystem section: 26 topic plugins in three grouped tables (tab / viewer / enhancement) with live star badges, the two extension points, and curation instructions - Recent updates: keep v0.15.0 visible, collapse v0.12-v0.14 into
; add Releases link and per-version DSH support line - Add TOC, contributing section, star history chart, contributors wall - Bilingual README.md / README_EN.md kept in sync --- README.md | 428 +++++++++++++++++++++++++++++++++++---------- README_EN.md | 477 +++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 679 insertions(+), 226 deletions(-) diff --git a/README.md b/README.md index ddf4f61..4e28d28 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,14 @@
一个服务化的侧边栏框架,一套开箱即用的完整工作台

+ npm version + npm downloads + CI + GitHub stars License: MIT dshfind

+ 支持的 DSH 版本:0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2 + 插件生态:GitHub topic dsh-better-sidebar

文件管理 编辑预览 内嵌浏览器 真实终端 Git 面板 后台任务 插件接入

右侧栏 + 底部面板双工作台,并把 ctx.betterSidebar 服务开放给所有插件——
通过 registerTab / registerFileViewer 注册新的侧边栏页面与文件预览器。 @@ -19,6 +25,19 @@
+## 📑 目录 + +- [✨ 功能一览](#-功能一览) +- [🚀 安装](#-安装) +- [🖼️ 特性巡礼](#-特性巡礼) +- [🌐 插件生态](#-插件生态) +- [🆕 最近更新](#-最近更新) +- [⌨️ 快捷键](#-快捷键) +- [🔌 服务化扩展](#-服务化扩展) +- [🛠️ 开发与构建](#-开发与构建) +- [🔐 安全](#-安全) · [⚠️ 已知限制](#-已知限制) · [🖥️ 平台支持](#-平台支持) +- [🤝 参与贡献](#-参与贡献) · [⭐ Star History](#-star-history) · [🔗 友情链接](#-友情链接) + ## ✨ 功能一览 - **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown(含 Mermaid 图表,strict 安全渲染 + 点击放大)/ HTML / PDF / Office 内联预览 @@ -33,16 +52,295 @@ - **⚡ 按需加载**:启动只拉 ~325KB 核心,终端 / 编辑器 / Mermaid 图表等重依赖用到才按需拉取([设计文档](docs/plans/2026-08-12-lazy-chunks-design.md)) - **🌏 多语言**:界面文案跟随 DSH 语言(zh / en)实时切换 -> 🔌 **核心理念**:服务优先——内置的 7 tab + 6 viewer 与第三方插件通过同一套 `ctx.betterSidebar` API 注册,能力完全对等;官方不再内置、可由生态提供的功能,交由生态插件实现。接入文档见下方「🔌 服务化」与 [外部插件接入指南](./docs/external-plugin-guide.md)。 +> 🔌 **核心理念**:服务优先——内置的 7 tab + 6 viewer 与第三方插件通过同一套 `ctx.betterSidebar` API 注册,能力完全对等;官方不再内置、可由生态提供的功能,交由生态插件实现(已有 **26+ 生态插件**,见下方「🌐 插件生态」)。接入文档见「🔌 服务化扩展」与 [外部插件接入指南](./docs/external-plugin-guide.md)。 + +## 🚀 安装 + +**前置**:已装好 DSH(`dsh web` 能正常运行),Node.js ≥ 20、pnpm ≥ 10。 + +> ✅ **当前版本支持的 DSH**:`0.1.0-rc.8` · `0.1.1-rc.1` · `0.1.1-rc.2`(peer 下限 `^0.1.0-rc.8`;CI 挂载冒烟基线 `0.1.1-rc.2`) + +```sh +dsh plugin --profile web add dsh-better-sidebar@latest +``` + +装完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可看到侧边栏(DSH 对 client 改动热加载,无需重启;仅 host 半更新时需要重启)。 + +
+更新 + +```sh +dsh plugin --profile web add dsh-better-sidebar@latest +``` + +也可把 `~/.dsh/profiles/web/package.json` 里的版本号改高后 `pnpm install`。改完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可(client 改动无需重启 DSH)。 + +
+ +
+常见问题 + +| 现象 | 原因与解决 | +|---|---| +| 报 `Ignored build scripts` | pnpm 11 拦截构建脚本。在 profile 目录(`~/.dsh/profiles/web`)跑 `pnpm approve-builds --all`。 | +| 报 `minimum release age` / 版本不足 24h | 装的版本发布不足 24 小时。等 24h 或重跑一次(pnpm 会自动补 `minimumReleaseAgeExclude`)。 | +| 报「找不到 profile 目录」 | 先跑一次 `dsh web`,让它初始化 `~/.dsh/profiles/web`。 | +| 页面出现**两个侧边栏** | 双挂载。旧的手动挂载行:`~/.dsh/profiles/web/cordis.patch.yml` 还留着 `- insert: ... better-sidebar ...`,删掉那段(同 id 重复挂载 loader 会直接报 `duplicate loader entry id`)。聚合包(如 `@linxin666/dsh-web-ui-all`)以**不同 id** 挂载本包时,0.13.x 起插件自身 bundle patch 会自动退让(检测到已有启用中的同包名挂载就不挂自己),无需手动处理;若仍双挂载,先确认聚合包的 bundle 顺序在 `dsh-better-sidebar` 之前。 | +| Windows 下终端无法使用 | `node-pty` 依赖预编译二进制;若当前 Node 版本没有对应产物,需装编译工具链(VS Build Tools)。主流 Node 版本一般已有预编译。 | +| 终端提示「node-pty 加载失败」 | `node-pty` 安装缺失/损坏(如 pnpm 拦截了构建脚本)。终端横幅会给出修复命令:复制到 DSH 所在环境的终端/cmd 执行(在 `~/.dsh/profiles/web` 下 `pnpm approve-builds --all && pnpm rebuild node-pty`),完成后重启 DSH 并点重试。插件与 DSH 核心使用同一 `node-pty@^1.1.0`,修复后两者同步恢复。 | +| 提示 `dsh: command not found` | 先安装 DSH;或直接用 `npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar@latest`。 | + +
+ +
+从源码安装 / 开发(可选,替代 npm 方式) + +调试本地改动或跟随开发分支时,把依赖指向本地克隆并自行构建: + +```text +1. git clone https://github.com/omdsh-dev/DSH-better-sidebar.git ~/Code/DSH-better-sidebar + cd ~/Code/DSH-better-sidebar && pnpm install && pnpm build +2. ~/.dsh/profiles/web/package.json 的 dependencies 写 "dsh-better-sidebar": "link:<克隆目录绝对路径>" +3. ~/.dsh/profiles/web/cordis.patch.yml 追加挂载行(需要指定终端 shell 时,在行内加 `config.shell`;`config.shellArgs` 可带参启动,非空时替换默认的 `-l`。不填则自动解析 `$SHELL` / 登录 shell / powershell.exe): + - insert: + - id: better-sidebar + name: 'dsh-better-sidebar' + config: + shell: /bin/zsh + shellArgs: + - --noprofile + - --no-rc +4. 在 ~/.dsh/profiles/web 执行 pnpm install +5. 硬刷新浏览器(Cmd/Ctrl+Shift+R)即可看到效果(client 改动无需重启 DSH;host 半改动才需重启) +``` + +更新:`git pull && pnpm install && pnpm build` → 硬刷新浏览器即可(client 改动热加载生效,无需重启 DSH;host 半改动才需重启)。切回 npm 通道时,把依赖改回 `"dsh-better-sidebar": "^0.15.0"` 再 `pnpm install`。 + +
+ +
+通过 plugin-registry 安装(可选,与上述二选一) + +前置:DSH 已集成 [plugin-registry](https://github.com/dsh-external/plugin-registry)(`dsh registry` 可用)。**同时启用两个通道会双挂载**(Node 半挂两次、页面两个侧边栏)。 + +```sh +git clone https://github.com/omdsh-dev/DSH-better-sidebar.git && cd DSH-better-sidebar +pnpm install && pnpm build +node scripts/package-registry.mjs # 组装 registry/ 暂存(含清单 + 产物 + README,不入库) +dsh registry install ./registry # 安装(默认禁用) +dsh registry enable dsh-external/dsh-better-sidebar +``` + +更新:`git pull && pnpm install && pnpm build` → `node scripts/package-registry.mjs` → `dsh registry uninstall/install/enable`。切换通道前先移除另一通道的挂载。 + +
+ +## 🖼️ 特性巡礼 + +> 以下截图均为真实界面实拍(暗色主题)。占位图会陆续补齐。 + +### 🗂️ 文件工作台:资源管理器 + +懒加载目录树、软链接按目标类型展示(目录软链接可展开、失效链接标红)、全局文件名搜索、上传文件/文件夹与拖放上传、右键菜单(在新 Tab 打开 / 在侧边打开 / 复制路径)、悬浮 `@文件` 一键引用进输入框。 + + + +### ✍️ CodeMirror 编辑器 + +按路径新开或原地切换(可选模式)、预览/编辑/保存一体、20+ 语言语法高亮、`Ctrl/Cmd+S` 保存。 + + + +### 📝 Markdown · 图片 · PDF 内联预览 + +Markdown 预览支持 **Mermaid 图表**(`securityLevel: 'strict'` 安全渲染 + 二次清洗;点击图表弹窗放大、滚轮缩放、拖拽平移);图片 / PDF 走媒体路由内联展示;Office 三件套由生态插件补齐。 + +
+ +
+ +
+ +
+ +### 💻 真实终端 + +xterm.js + node-pty 真实 shell(不是模拟器):断线重连 transcript 回放、shell / shellArgs 可配置(设置页或 `cordis.patch.yml`)、可选为模型注入 `terminal_*` 工具(agent 可直接开终端跑命令)。 + + + +### 🌿 Git 面板 + +暂存 / 取消暂存 / 提交(`Ctrl+Enter`)/ 还原,历史列表;点击改动文件打开 **VSCode 式 diff tab**(红绿行级对比)。push/pull/fetch 由生态插件 [dsh-git-remotes](https://github.com/yq04/dsh-git-remotes) / [dsh-git-forge](https://github.com/thirsty5034/dsh-git-forge) 补齐。 + + + + + +### 🌐 内嵌浏览器 + +多开网页 tab:后退 / 前进 / 刷新 / 地址栏;内容运行在**不透明源沙箱 iframe**(界面实时显示沙箱状态,可按页面临时解锁);聊天里的外链点击可被接管到侧边栏打开(按协议分流,可配)。 + + + +### 🧩 任务页:子代理拓扑 + 后台任务 + +子代理树实时拓扑(运行状态、批量实时预览)+ 后台任务清单(退出码 / 实时输出 / 强制终止);新子代理 / 新任务可自动展开侧边栏(可关)。 + + + +### 💬 侧边对话(beta) + +Codex 风格侧边线程:**每个对话一个独立 Tab**;线程继承主会话完整上下文(含进行中回合,以 interrupted 诚实冻结)独立运行,不污染主会话;可持续追问、重启冷恢复;一键「保存为新会话」提升为顶层会话。 + + + +### 🪟 双工作台:右侧栏 + 底部面板 + 分栏 + +右侧栏与底部面板可同时展开;拖 Tab 到分栏边缘**拆分**、拖到中间**合并**(可跨面板);面板宽高左缘/上缘拖拽调节;移动端自动合并为全宽抽屉。 + + + + + +### ⚙️ 声明式设置 + +设置页「侧边卡片」分区:每个 tab / 预览器一张小卡片,独立开关(高亮启用态 + 品牌开关滑块);二级设置经卡片底部「功能设置」条弹窗(开关 / 文本 / 数字 / 下拉);插件自有设置持久化在 `pluginSettings`。 + + + +### 📱 移动端 + +窄屏(<768px)自动切换为全宽抽屉:底栏 tab 一次性并入右侧栏,触屏拖拽可调。 + +
+ +
+ +## 🌐 插件生态 + +`ctx.betterSidebar` 服务向所有插件开放两个扩展点:**`registerTab`(注册侧边栏页面)** 与 **`registerFileViewer`(注册文件预览器)**。内置的 7 tab + 6 viewer 与第三方插件走同一套 API,能力完全对等。 + +```ts +import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并 +export const inject = ['betterSidebar'] +export function apply(ctx: Context) { + ctx.effect(() => ctx.betterSidebar.registerTab({ + id: 'my-plugin:db', title: 'Database', component: ({ scope }) => , + })) + ctx.effect(() => ctx.betterSidebar.registerFileViewer({ + id: 'my-plugin:csv', exts: ['csv'], fetchStrategy: 'custom', + load: async (path, scope) => parseCsv(await fetchText(scope, path)), + component: ({ customData }) => , + })) +} +``` + +GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar) 下已有 **26+ 生态插件**(持续增长中): + +
+ 设置页「添加插件」弹窗:推荐插件目录 + 一键复制安装命令
+ 设置页「侧边卡片」内置「添加插件」弹窗:推荐目录 + 一键复制安装命令 + 直达 GitHub topic +
+ +### 📑 Tab 插件(注册侧边栏页面) + +| 插件 | ⭐ | 简介 | +|---|---|---| +| [ChenRuoT/dsh-sidebar-qa](https://github.com/ChenRuoT/dsh-sidebar-qa) | stars | 划选追问侧边页:类 Codex 侧边提问 / Claude Code `/btw` | +| [fuhefei/dsh-sentinel](https://github.com/fuhefei/dsh-sentinel) | stars | 条件驱动唤醒系统:文件 / 命令 / HTTP / 进程 / Webhook 监视,到点唤醒 agent;dock + 侧栏分支 + 全局仪表盘 | +| [jiuge2467/dsh-studio](https://github.com/jiuge2467/dsh-studio) | stars | 全栈增强工作台:多源 MCP 可视化调试中枢、视觉思考引擎 | +| [Iwctwbh/dsh-flowglass](https://github.com/Iwctwbh/dsh-flowglass) | stars | 流镜 Flowglass:会话流程图实时可视化(消息 / 工具组 / 子代理分支) | +| [FeatherHunter/dsh-mattpocock-skills-deck](https://github.com/FeatherHunter/dsh-mattpocock-skills-deck) | stars | mattpocock/skills 游戏化任务系统:地图拨迷雾、任务栏推进 | +| [GULI-lab/DSH-element-source](https://github.com/GULI-lab/DSH-element-source) | stars | 点击页面任意 UI 元素直达 Vue / React / Svelte / Angular 源码并送入会话 | +| [Lzh3070/dsh-file-review-tab](https://github.com/Lzh3070/dsh-file-review-tab) | stars | 文件改动审查页:行级红绿 diff + 撤销 + chat 行深链 | +| [yq04/dsh-git-remotes](https://github.com/yq04/dsh-git-remotes) | stars | Git 远程页:分支 / 上游 / ahead-behind,fetch 可 prune、ff-only pull、确认后 push | +| [ztyhehe/dsh-better-sidebar-svn](https://github.com/ztyhehe/dsh-better-sidebar-svn) | stars | SVN 源码管理页:status / diff / log / commit / update / revert / 冲突解决,与内置 Git 面板对称 | +| [Melody-max114/dsh-excel-panel](https://github.com/Melody-max114/dsh-excel-panel) | stars | Excel 编辑页:xlsx 预览 / 编辑、公式实时计算、合并单元格、保存回原文件 | +| [v587d/dsh-anysearch-refs](https://github.com/v587d/dsh-anysearch-refs) | stars | AnySearch 搜索结果引用卡片:搜索词、来源摘要、关键词高亮 | +| [mlosun/dsh-docs-panel](https://github.com/mlosun/dsh-docs-panel) | stars | 全局文档面板:随身 Markdown 笔记,任何工作区随时可读 | +| [lnyuqian/dsh-skill-sidebar](https://github.com/lnyuqian/dsh-skill-sidebar) | stars | 技能面板:扫描本机技能目录,4-6 字功能短语 + 一键复制调用 + 置顶 | +| [g-yixuan/dsh-sidechat](https://github.com/g-yixuan/dsh-sidechat) | stars | Codex 风格侧边对话 + 划选引用注释(轻量消费插件) | +| [thirsty5034/dsh-ssh-tunnel](https://github.com/thirsty5034/dsh-ssh-tunnel) | stars | 多主机 SSH 隧道 + SSH 管理器页 | +| [thirsty5034/dsh-git-forge](https://github.com/thirsty5034/dsh-git-forge) | stars | GitHub / Gitea 账号、项目授权与推送策略 | +| [YesSanSan/dsh-conversation-outline](https://github.com/YesSanSan/dsh-conversation-outline) | stars | 对话大纲页:按轮次结构化展示、一键跳转、LLM 一句话标题 | +| [Wulabalabo/dsh-sidebar-Explorer-Plus](https://github.com/Wulabalabo/dsh-sidebar-Explorer-Plus) | stars | 文件管理页:上传 / 移动 / 删除 / 重命名 / 新建文件夹(补全写操作) | +| [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 友好) | + +### 🖼️ 预览插件(注册文件预览器) + +| 插件 | ⭐ | 简介 | +|---|---|---| +| [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、流式输出、保存回写 | + +### 🧰 增强与工具 + +| 插件 | ⭐ | 简介 | +|---|---|---| +| [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` 守护)。 ## 🆕 最近更新
侧边栏 服务化基座截图 - 添加插件截图
+> ✅ **v0.15.0 支持的 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.0 > ⚠️ **本版验证基线为 DSH 0.1.1-rc.2(@next)**:devDependencies / lockfile / CI 挂载冒烟基线升至 `0.1.1-rc.2`(lockfile 零 rc.8 残留);**peer 下限保持 `^0.1.0-rc.8`**——0.1.1-rc.x 为常规 rc 递进、与 rc.8 双向兼容,**rc.8 用户无需升级 DSH**。`cordis` 保持 `^4.0.0-rc.8`。自 v0.14.0 以来的全部更改: @@ -63,6 +361,9 @@ - 🔧 **适配 DSH 0.1.1-rc.1 / rc.2(@next)**([#297](https://github.com/omdsh-dev/DSH-better-sidebar/pull/297) [#305](https://github.com/omdsh-dev/DSH-better-sidebar/pull/305)):devDependencies / lockfile / CI 验证基线平移;逐包比对所有既有契约(DOM 槽位、CSS 令牌、boot 协议、MarkdownText `codeLabels`、`webServer` 路由注册)零破坏,**无代码逻辑改动**;peer 下限保持 `^0.1.0-rc.8` - 🔒 **上传链路安全加固**([#239](https://github.com/omdsh-dev/DSH-better-sidebar/pull/239)):`relativePath` 空段 / 绝对路径显式拒绝;临时文件唯一命名(并发上传互不干扰、崩溃不阻塞);写流错误监听(磁盘失败不崩溃进程);客户端错误码与服务端统一、413 本地化 +
+历史版本(v0.12.0 – v0.14.0) + ### v0.14.0 > ⚠️ **本版适配 DSH 0.1.0-rc.8**:全部 `@deepseek-ai/*` peer / devDependencies 升至 `^0.1.0-rc.8`(含传递依赖,lockfile 零 rc.7 残留),`cordis` 同步 `^4.0.0-rc.8`,CI 挂载冒烟钉版 `@deepseek-ai/dsh@0.1.0-rc.8`。**rc.7 及更早的 DSH 环境将无法解析本版依赖,请先升级 DSH。** 自 v0.13.1 以来的全部更改: @@ -120,83 +421,6 @@ - 🐛 **node-pty 加载失败不再拖垮 server**([#140](https://github.com/omdsh-dev/DSH-better-sidebar/issues/140)):宿主半改为懒加载 node-pty,缺失时插件照常挂载,终端以修复提示横幅(可复制命令 + 重试按钮)呈现,agent 终端工具自动跳过 - 🧪 测试工程:单元测试拆分(#141)+ smoke 偶发失败修复 -## 🚀 安装 - -**前置**:已装好 DSH(`dsh web` 能正常运行),Node.js ≥ 20、pnpm ≥ 10。 - -```sh -dsh plugin --profile web add dsh-better-sidebar@latest -``` - -装完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可看到侧边栏(DSH 对 client 改动热加载,无需重启;仅 host 半更新时需要重启)。 - -
-更新 - -```sh -dsh plugin --profile web add dsh-better-sidebar@latest -``` - -也可把 `~/.dsh/profiles/web/package.json` 里的版本号改高后 `pnpm install`。改完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可(client 改动无需重启 DSH)。 - -
- -
-常见问题 - -| 现象 | 原因与解决 | -|---|---| -| 报 `Ignored build scripts` | pnpm 11 拦截构建脚本。在 profile 目录(`~/.dsh/profiles/web`)跑 `pnpm approve-builds --all`。 | -| 报 `minimum release age` / 版本不足 24h | 装的版本发布不足 24 小时。等 24h 或重跑一次(pnpm 会自动补 `minimumReleaseAgeExclude`)。 | -| 报「找不到 profile 目录」 | 先跑一次 `dsh web`,让它初始化 `~/.dsh/profiles/web`。 | -| 页面出现**两个侧边栏** | 双挂载。旧的手动挂载行:`~/.dsh/profiles/web/cordis.patch.yml` 还留着 `- insert: ... better-sidebar ...`,删掉那段(同 id 重复挂载 loader 会直接报 `duplicate loader entry id`)。聚合包(如 `@linxin666/dsh-web-ui-all`)以**不同 id** 挂载本包时,0.13.x 起插件自身 bundle patch 会自动退让(检测到已有启用中的同包名挂载就不挂自己),无需手动处理;若仍双挂载,先确认聚合包的 bundle 顺序在 `dsh-better-sidebar` 之前。 | -| Windows 下终端无法使用 | `node-pty` 依赖预编译二进制;若当前 Node 版本没有对应产物,需装编译工具链(VS Build Tools)。主流 Node 版本一般已有预编译。 | -| 终端提示「node-pty 加载失败」 | `node-pty` 安装缺失/损坏(如 pnpm 拦截了构建脚本)。终端横幅会给出修复命令:复制到 DSH 所在环境的终端/cmd 执行(在 `~/.dsh/profiles/web` 下 `pnpm approve-builds --all && pnpm rebuild node-pty`),完成后重启 DSH 并点重试。插件与 DSH 核心使用同一 `node-pty@^1.1.0`,修复后两者同步恢复。 | -| 提示 `dsh: command not found` | 先安装 DSH;或直接用 `npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar@latest`。 | - -
- -
-从源码安装 / 开发(可选,替代 npm 方式) - -调试本地改动或跟随开发分支时,把依赖指向本地克隆并自行构建: - -```text -1. git clone https://github.com/omdsh-dev/DSH-better-sidebar.git ~/Code/DSH-better-sidebar - cd ~/Code/DSH-better-sidebar && pnpm install && pnpm build -2. ~/.dsh/profiles/web/package.json 的 dependencies 写 "dsh-better-sidebar": "link:<克隆目录绝对路径>" -3. ~/.dsh/profiles/web/cordis.patch.yml 追加挂载行(需要指定终端 shell 时,在行内加 `config.shell`;`config.shellArgs` 可带参启动,非空时替换默认的 `-l`。不填则自动解析 `$SHELL` / 登录 shell / powershell.exe): - - insert: - - id: better-sidebar - name: 'dsh-better-sidebar' - config: - shell: /bin/zsh - shellArgs: - - --noprofile - - --no-rc -4. 在 ~/.dsh/profiles/web 执行 pnpm install -5. 硬刷新浏览器(Cmd/Ctrl+Shift+R)即可看到效果(client 改动无需重启 DSH;host 半改动才需重启) -``` - -更新:`git pull && pnpm install && pnpm build` → 硬刷新浏览器即可(client 改动热加载生效,无需重启 DSH;host 半改动才需重启)。切回 npm 通道时,把依赖改回 `"dsh-better-sidebar": "^0.13.0"` 再 `pnpm install`。 - -
- -
-通过 plugin-registry 安装(可选,与上述二选一) - -前置:DSH 已集成 [plugin-registry](https://github.com/dsh-external/plugin-registry)(`dsh registry` 可用)。**同时启用两个通道会双挂载**(Node 半挂两次、页面两个侧边栏)。 - -```sh -git clone https://github.com/omdsh-dev/DSH-better-sidebar.git && cd DSH-better-sidebar -pnpm install && pnpm build -node scripts/package-registry.mjs # 组装 registry/ 暂存(含清单 + 产物 + README,不入库) -dsh registry install ./registry # 安装(默认禁用) -dsh registry enable dsh-external/dsh-better-sidebar -``` - -更新:`git pull && pnpm install && pnpm build` → `node scripts/package-registry.mjs` → `dsh registry uninstall/install/enable`。切换通道前先移除另一通道的挂载。 -
## ⌨️ 快捷键 @@ -210,21 +434,9 @@ dsh registry enable dsh-external/dsh-better-sidebar | 引用文件到输入框 | 悬浮行尾 `@文件` 按钮 | | 复制文件路径 | 右键行 → 复制相对/绝对地址 | -## 🔌 服务化:注册 tab 与文件预览器 +## 🔌 服务化扩展 -从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 7 tab + 6 viewer 亦通过同一服务注册): - -```ts -import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并 -export const inject = ['betterSidebar'] -export function apply(ctx: Context) { - ctx.effect(() => ctx.betterSidebar.registerTab({ - id: 'my-plugin:db', title: 'Database', component: ({ scope }) => , - })) -} -``` - -v0.12.1 补齐基座能力(完整类型导出、能力探测、状态订阅、tab 角标、生命周期回调、定向打开、插件自有设置等),详见下方接入文档。 +从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 7 tab + 6 viewer 亦通过同一服务注册)。v0.12.1 补齐基座能力(完整类型导出、能力探测、状态订阅、tab 角标、生命周期回调、定向打开、插件自有设置等)。 完整接入文档: - **[`AGENTS.md`](./AGENTS.md)**——仓库内维护的接入文档(全字段、匹配算法、HMR 陷阱、声明式设置、版本探测); @@ -267,9 +479,39 @@ pnpm watch # tsdown --watch Windows / Linux / macOS 三平台适配(macOS 日常验证;其余经单元测试覆盖);`node-pty` 优先预编译二进制,失败需编译工具链(Windows VS Build Tools / Linux make+g+++python3 / macOS Xcode CLT)。 +## 🤝 参与贡献 + +- **代码改动走 PR**:`feat/*` / `fix/*` 分支开发 → `gh pr create`;纯文档改动可直接推 main +- **收录生态插件**:给仓库打 `dsh-better-sidebar` topic + 向 [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts) / [`plugins-viewers.ts`](./src/client/plugins-viewers.ts) 提 PR +- **提交前自检**:`pnpm typecheck && pnpm build && pnpm test`(CI 另有 npm 打包 → 真实挂载 → 无头渲染门禁 `pnpm test:mount`) +- 仓库工作规范见 [`AGENTS.md`](./AGENTS.md)(含仓库硬约束与 CI 说明) + +## ⭐ Star History + + + + + Star History Chart + + + +## 👥 贡献者 + +感谢每一位贡献者: + + + 贡献者 + + ## 🔗 友情链接 - [dsh-tianshu-tui](https://github.com/huiliyi37/dsh-tianshu-tui):DeepSeek Harness 交互式终端 UI 插件(渲染核心由自研 harness agent Tianshu-Tui 演进而来),在官方基础上增加 TDD 与证据门等工作流 - [dsh-TUI](https://github.com/ccch1mneyyy/dsh-TUI):Claude Code 风格全屏交互终端插件——像素鲸鱼顶栏、实时工作状态行、思考流式展开、双击 Esc 回滚、上下文进度条 + TPS 仪表,npm 一键安装 - [dshfind 插件超市](https://dshfind.com/zh/plugins):三方插件市场——GitHub topic `dsh-plugin` 下的公开仓库清单,每日同步 star、贡献者与增长数据 - [DeepSeek Harness Desktop](https://github.com/anywhere-labs/deepseek-harness-desktop):为 DeepSeek Harness 生态打造的现代化桌面端——无需配置 Node.js 或执行命令即可启动和管理本地 Harness 服务;[官网](https://www.dshdesktop.cn) + +--- + +
+ MIT License · Built for the DeepSeek Harness ecosystem · 在 topic dsh-better-sidebar 发现更多生态插件 +
diff --git a/README_EN.md b/README_EN.md index 3391b65..8171fcd 100644 --- a/README_EN.md +++ b/README_EN.md @@ -3,8 +3,14 @@
A service-oriented sidebar framework, and a complete workbench out of the box

+ npm version + npm downloads + CI + GitHub stars License: MIT dshfind

+ Supported DSH versions: 0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2 + Plugin ecosystem: GitHub topic dsh-better-sidebar

File management Edit & preview Embedded browser Real terminal Git panel Background tasks Plugin integration

A dual workbench (right sidebar + bottom panel) that opens its ctx.betterSidebar service to every plugin —
register new sidebar pages and file viewers via registerTab / registerFileViewer. @@ -19,6 +25,19 @@ dsh-better-sidebar workbench
+## 📑 Contents + +- [✨ Features](#-features) +- [🚀 Installation](#-installation) +- [🖼️ Feature Tour](#-feature-tour) +- [🌐 Plugin Ecosystem](#-plugin-ecosystem) +- [🆕 Recent Updates](#-recent-updates) +- [⌨️ Keyboard Shortcuts](#-keyboard-shortcuts) +- [🔌 Service API](#-service-api) +- [🛠️ Development & Build](#-development--build) +- [🔐 Security](#-security) · [⚠️ Known Limitations](#-known-limitations) · [🖥️ Platform Support](#-platform-support) +- [🤝 Contributing](#-contributing) · [⭐ Star History](#-star-history) · [🔗 Friends](#-friends) + ## ✨ Features - **🗂️ File Workbench**: file explorer (lazy-loading tree; symlinks show their target kind — directory links expand, dangling links flagged) + CodeMirror editor; inline preview for images / Markdown (incl. Mermaid diagrams, strict-mode safe rendering + click-to-zoom) / HTML / PDF / Office @@ -33,128 +52,14 @@ - **⚡ On-demand Loading**: only ~325KB core at startup; heavy deps (terminal / editor / mermaid diagrams) load on demand ([design](docs/plans/2026-08-12-lazy-chunks-design.md)) - **🌏 i18n**: UI text follows DSH's language (zh / en) with live switching -> 🔌 **Core principle**: service-first — the 7 built-in tabs + 6 viewers register through the same `ctx.betterSidebar` API as third-party plugins, with fully equal capabilities; anything the ecosystem can provide better is delegated to ecosystem plugins. See the "🔌 Service" section below and the [external plugin guide](./docs/external-plugin-guide.md). - -## 🆕 Recent Updates - -
- Service API base screenshot - Add Plugins screenshot -
- -### v0.15.0 - -> ⚠️ **This release is verified against DSH 0.1.1-rc.2 (@next)**: devDependencies / lockfile / CI mount-smoke baseline raised to `0.1.1-rc.2` (zero rc.8 leftovers in the lockfile); **the peer floor stays at `^0.1.0-rc.8`** — 0.1.1-rc.x is a routine rc progression, mutually compatible with rc.8, so **rc.8 users don't need to upgrade DSH**. `cordis` stays at `^4.0.0-rc.8`. All changes since v0.14.0: - -**✨ New features** - -- 💬 **Side Chat (beta) tab** ([#286](https://github.com/omdsh-dev/DSH-better-sidebar/pull/286)): Codex-style side threads, **one independent tab per conversation** — the child inherits the parent's full context (completed turns + pending messages + the in-progress turn's assistant output and tool activity, honestly frozen with an "interrupted" marker); created with an identical composition (same preset / provider / model) so the first request reuses the parent's input prefix cache; threads stay invisible in the main session list with zero subagent-catalog noise; follow-ups survive DSH restarts (auto cold-resume); one-click "Save as new session" promotes the thread to a top-level session ([design](docs/plans/2026-08-20-sidechat-tab-design.md)) -- 📤 **Upload into the files window** ([#239](https://github.com/omdsh-dev/DSH-better-sidebar/pull/239)): header "upload file / upload folder" buttons plus drag-drop (drop on the tree body = workspace root, on a directory row = that directory, on a file row = its parent directory, VSCode semantics); full-window blurred progress overlay while uploading (per-file progress + cancel / Esc); buttons disabled while busy, tree refreshes after the upload settles -- 🧩 **Desktop compatibility in four options** ([#284](https://github.com/omdsh-dev/DSH-better-sidebar/pull/284)): "Position compatibility mode" is now a main-row dropdown — **Auto-detect** (default, conservative: only the standard Window Controls Overlay geometry contributes; real 32/36px caption-overlay heights per shell, live on maximize/restore; zero modification on plain web) / **DSH official web** (explicitly no adaptation) / **Shell preset** (built-in, opt-in; only shells that appeared in this repo's issues/PRs with 100+ stars, "detected" badge when the environment matches) / **Custom** (free-form CSS + shift distance). Documents that already carried compatibility values migrate to the custom scheme; interactive chrome opts out of desktop drag regions (`no-drag`); the bottom-push anchor is a composite selector (`[data-pane]` and `:has(> [data-slot])`) -- 🎛️ **Settings page UI/UX modernization** ([#300](https://github.com/omdsh-dev/DSH-better-sidebar/pull/300)): the side-card secondary-settings entry is now a full-width "Feature settings" strip at the card bottom (replacing the invisible corner gear — much easier to discover); coordinated two-tone enabled state (brand activation accent + success-green check badge); every color is still `--dsw-alias-*` token-derived so skins follow automatically -- ➕ **New entries in the recommended-plugin catalog**: `dsh-docs-panel` (global docs, [#230](https://github.com/omdsh-dev/DSH-better-sidebar/pull/230)), `dsh-flowglass` ([#261](https://github.com/omdsh-dev/DSH-better-sidebar/pull/261)), `dsh-git-forge` and `dsh-ssh-tunnel` ([#204](https://github.com/omdsh-dev/DSH-better-sidebar/pull/204)), `dsh-turn-review` ([#102](https://github.com/omdsh-dev/DSH-better-sidebar/pull/102)) - -**🐛 Fixes** - -- ⚡ **Batched live preview for the subagent page** ([#298](https://github.com/omdsh-dev/DSH-better-sidebar/pull/298)): the old implementation polled `subagents.history` per running subagent, each poll triggering a full host-side enumeration — an O(N²) amplification that stalled the page with many concurrent subagents; now a single batch route `subagents.live` (one enumeration of the whole tree) plus one client poller with a single in-flight request; display logic and copy unchanged -- 🖱️ **Interrupted / fast-release drags no longer roll back** ([#249](https://github.com/omdsh-dev/DSH-better-sidebar/pull/249), closes [#247](https://github.com/omdsh-dev/DSH-better-sidebar/issues/247) [#248](https://github.com/omdsh-dev/DSH-better-sidebar/issues/248)): interrupted or fast-released drags commit the last known position; HMR re-activation re-locates the center column (fixes the blank bottom panel after a hot reload) -- 📐 **Push variables stay effective while mounted** ([#259](https://github.com/omdsh-dev/DSH-better-sidebar/pull/259), fixes [#258](https://github.com/omdsh-dev/DSH-better-sidebar/issues/258)): the bottom panel no longer flashes full-width after a drag is released -- 🔧 **Adapted to DSH 0.1.1-rc.1 / rc.2 (@next)** ([#297](https://github.com/omdsh-dev/DSH-better-sidebar/pull/297) [#305](https://github.com/omdsh-dev/DSH-better-sidebar/pull/305)): devDependencies / lockfile / CI verification baseline shifted; every consumed contract (DOM slot, CSS tokens, boot protocol, MarkdownText `codeLabels`, `webServer` route registration) compared package by package with zero breakage — **no code changes needed**; peer floor stays at `^0.1.0-rc.8` -- 🔒 **Upload-chain hardening** ([#239](https://github.com/omdsh-dev/DSH-better-sidebar/pull/239)): empty and absolute `relativePath` segments are refused outright; uniquely named temp files (concurrent uploads stay independent, crashed processes never block later uploads); write-stream error listeners (a failing disk can no longer crash the host); client error codes unified with the wire (`too-large`), 413s localized - -### v0.14.0 - -> ⚠️ **This release adapts to DSH 0.1.0-rc.8**: all `@deepseek-ai/*` peer/devDependencies raised to `^0.1.0-rc.8` (transitives included; zero rc.7 leftovers in the lockfile), `cordis` to `^4.0.0-rc.8`, and the CI mount lane pins `@deepseek-ai/dsh@0.1.0-rc.8`. **DSH rc.7 and earlier cannot resolve this release's dependencies — upgrade DSH first.** All changes since v0.13.1: - -**✨ New features** - -- 🖼️ **Unified panel-host injection refactor** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): panels/toggle clusters moved into a `[data-dsh-panel-host]` fixed containing block (`fixed inset-0 z-40`), immune to desktop-shell intermediate transforms hijacking `fixed`; mount self-check (page-level transform → `data-dsh-panel-host-degraded` degraded sync, judged on uncorrected geometry, exits only when the ancestor transform is gone); push anchor switched to `#root [data-dsh-frame] > [data-pane="conversation"]` + `#root` calc width against desktop-shell additive overflow; chunk revalidation on activation (HEAD+ETag keeps unchanged chunks, 5s timeout fails open); `visualViewport` keyboard inset + `env(safe-area-inset-*)` mobile adaptation -- 📂 **Separate file windows by default** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): `editorExplorer` now defaults to **separate** — tree clicks / file opens create a new tab per path and the path-less window is a pure file manager; merged mode stays available as an opt-in -- 🖥️ **Terminal shell / shellArgs configurable from the settings page** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): the terminal card's gear popup gains "Shell path" and "Shell arguments" rows (previously yaml-only via `cordis.patch.yml`) — saved values take effect immediately for terminals opened afterwards (UI terminals and model `terminal_create` alike); empty keeps the existing yaml → `$SHELL` / login shell / `powershell.exe` resolution order -- 🏷️ **Version badge on the settings page** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): the side-card settings section now opens with a `DSH-better-sidebar v0.14.0` identity badge (version synced with the service instance, test-guarded) -- 🔍 **Add-plugin catalog: search / grouping / independent scroll** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): built for a growing plugin ecosystem — a live search box (filters by name / id / description), optional `category` grouping for entries, and an independently scrolling list (the modal no longer grows unbounded with catalog size) - -**🐛 Fixes** - -- 🔧 **Adapted to DSH 0.1.0-rc.8** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): all 13 `@deepseek-ai/*` peer/devDependencies raised to `^0.1.0-rc.8` (transitives included; zero rc.7 leftovers in the lockfile), `cordis` to `^4.0.0-rc.8`; `dsh-client-web-react` / `dsh-client-schema-form` removed (no rc.8 publish, no longer in the shell module table, zero plugin references); CI mount lane pins `@deepseek-ai/dsh@0.1.0-rc.8`; pnpm 11.8 supply-chain verification adapted -- 🧩 **rc.8 module-system migration** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): rc.8 no longer exposes the `window.__DSH_MODULES__` page global (it moved to the `ctx.modules` service), which broke every lazy chunk's externals resolution — the client now injects the `modules` service and shares it with chunk-bundle copies through a plugin-owned global (terminal / editor / Mermaid on-demand loading restored) -- 🧩 **Chunk revalidation barrier hardening** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): HEAD revalidation gains a 5s timeout (fails open on a stuck route so the barrier can never wedge lazy loads); `resetChunks` clears a pending revalidation barrier -- 🖱️ **Drag robustness** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): fast releases (browsers merge/lose pointermove bursts) commit the last known dragged position instead of rolling back; `pointercancel` / lost-capture interruptions keep the drag result too; the center column is re-measured right after commit (no mid-frame bottom-panel width jump); HMR re-activation re-locates the center column via an `` style observer plus a retry when the bottom panel opens (fixes the blank bottom panel / shifted input bar after a hot reload) - -### v0.13.1 - -**✨ New features** - -- 📊 **Safe Mermaid rendering in the Markdown preview** ([#164](https://github.com/omdsh-dev/DSH-better-sidebar/pull/164)): when a previewed md file contains mermaid fences, a `client-mermaid.js` chunk (~7MB) is served on demand (zero load without mermaid); defense-in-depth rendering — `securityLevel: 'strict'` + `htmlLabels: false` (node labels use real SVG ``) + a second sanitize pass before SVG injection (foreignObject/script/foreign HTML elements removed, `@*`/`on*`/`href` attributes stripped); click a diagram to zoom in a modal overlay (wheel zoom centered on the cursor, drag pan, toolbar & shortcuts), re-renders with light/dark theme, falls back to the raw code block on parse failure -- 🖥️ **Configurable terminal shell & shellArgs** ([#125](https://github.com/omdsh-dev/DSH-better-sidebar/pull/125)): `cordis.patch.yml` `better-sidebar.config` can set `shell` / `shellArgs` (a non-empty `shellArgs` fully replaces the defaults; unset keeps the previous auto-resolution of `$SHELL` / login shell / `powershell.exe`), applied to both UI terminals and agent terminals (`terminal_create`); terminal tab titles now show the shell name (bash/zsh/powershell) and internal tab ids use UUIDs so the same shell can open multiple terminals - -**🐛 Fixes** - -- 🔗 **Aggregate double-mount auto-yield** ([#200](https://github.com/omdsh-dev/DSH-better-sidebar/pull/200)): when an aggregate package (e.g. dsh-web-ui-all) mounts the same package under its own entry id, the guard expression in `cordis.patch.yml` disables the plugin's own `better-sidebar` row so `/sidebar/api` is no longer registered twice (`duplicate prefix route` crashing the whole plugin tree / `dsh web`); standalone installs behave as before -- 🔧 **Peer dependencies aligned to DSH 0.1.0-rc.7** ([#207](https://github.com/omdsh-dev/DSH-better-sidebar/pull/207), fixes [#206](https://github.com/omdsh-dev/DSH-better-sidebar/issues/206)): all `@deepseek-ai/*` peer/devDependencies raised from `^0.1.0-rc.6` to `^0.1.0-rc.7`, and the CI mount lane pins the same version — eliminating the mixed rc.6/rc.7 dependency tree (after the main framework moved to rc.7) that surfaced as `agent-presets: refusing to compose an unscoped context` when picking a model / sending a message - -### v0.13.0 - -**✨ New features** - -- 📁 **Files window merged with the explorer** ([#151](https://github.com/omdsh-dev/DSH-better-sidebar/pull/151)): new `editorExplorer` setting (editor card gear) — file tabs gain a path-input header plus a toggleable right-docked file tree (per-tab open/width memory, drag-resize 160–480px from the left edge, global filename search via the host `fs.search` route with a hard budget, skipping `.git` and symlink dirs); in separate mode (default) tree clicks / Enter in the path input open each file **in its own new tab**, merged mode switches the current tab **in place**; fresh sessions seed an empty Files window instead of the explorer tab, and a path-less window is a bare file manager in separate mode / a chrome'd empty file window in merged mode; the tree context menu offers "Open in new tab" and "Open to the side" (split) -- 🎛️ **Select rows for declarative settings** ([#151](https://github.com/omdsh-dev/DSH-better-sidebar/pull/151)): settings rows gain `type: 'select'` (`options` with value/title/desc/icon, `multi` stores the picked values as an array); options with icons render big-icon option cards and keep the icon in the closed anchor; `editorExplorer` became an iconed select (merged vs separate); the capability list gained `settingSelect` -- 🔀 **Mutual exclusion with the dsh-web-ui family right panel** ([#181](https://github.com/omdsh-dev/DSH-better-sidebar/pull/181)): reads the `aionui-panel` settings namespace's provider choice — when "Use aionui-panel" is selected, the whole better-sidebar (right sidebar / bottom panel / floating entry / all takeovers) does not mount; with DSH-better-sidebar (or no aionui installed) it behaves as before. Takes effect live after a settings save (settings-document push), no reload needed - -**📝 Other** - -- Install / update commands unified to `dsh-better-sidebar@latest` (bilingual README sync) - -### v0.12.3 - -**✨ New features** - -- 🎨 **Skin compatibility (token-driven)**: fully consumes DSH design tokens and follows the dsh-web-ui skin center's 10 skins automatically; terminal/editor surfaces fall back to opaque backgrounds under transparent/translucent-glass token values so text never scrolls over the skin art ([#110](https://github.com/omdsh-dev/DSH-better-sidebar/pull/110), fixes #106 #105 #90 #60, also #52 #57 #92) -- 🗂️ **Unified path handling**: UNC / symlink classification (directory symlinks expandable, broken links highlighted) + HTML-route platform guards ([#134](https://github.com/omdsh-dev/DSH-better-sidebar/pull/134), #65 #67 #43 #79 #115) -- 🖥️ **Configurable terminal shell**: custom shell setting with Windows pwsh auto-probe ([#95](https://github.com/omdsh-dev/DSH-better-sidebar/pull/95)) -- 📝 **Editor languages**: C# / Kotlin / Swift syntax highlighting ([#120](https://github.com/omdsh-dev/DSH-better-sidebar/pull/120)) -- 🧭 **Settings nav icon**: settings-page navigation icon and layout polish ([#114](https://github.com/omdsh-dev/DSH-better-sidebar/pull/114)) -- ➕ **Recommended-plugin catalog**: added `dsh-git-remotes` — Git Remotes tab (branches/upstream/ahead-behind, fetch with prune, ff-only pull, confirm-before-push; does not replace the built-in stage/commit tab) ([#91](https://github.com/omdsh-dev/DSH-better-sidebar/pull/91)); and `dsh-video-preview` — inline video preview (.mp4/.webm/.mov/.mkv/.avi etc.) backed by a /video host route with HTTP Range (206) scrubbing, not capped by the 20MB mediaLimit ([#126](https://github.com/omdsh-dev/DSH-better-sidebar/pull/126)) - -**🐛 Fixes** - -- 🔧 **xterm migration**: deprecated xterm dependency migrated to `@xterm/xterm` (Closes [#122](https://github.com/omdsh-dev/DSH-better-sidebar/issues/122), [#128](https://github.com/omdsh-dev/DSH-better-sidebar/pull/128)) -- 📝 **Markdown editor**: selection-to-conversation popup restored ([#24](https://github.com/omdsh-dev/DSH-better-sidebar/pull/24)) -- 🐛 **node-pty load failure no longer crashes the server** ([#140](https://github.com/omdsh-dev/DSH-better-sidebar/issues/140)): the host half now lazy-loads node-pty — when it is missing the plugin still mounts, the terminal shows a repair banner (copyable command + Retry button), and agent terminal tools are skipped -- 🧪 Test engineering: unit spec split (#141) + flaky smoke cleanup fix - -**🚀 Engineering** - -- npm publishing wired to GitHub Releases (Trusted Publishing, provenance-attached tarballs); tagging a release publishes automatically ([#148](https://github.com/omdsh-dev/DSH-better-sidebar/pull/148)) - -### v0.12.2 - -- 📐 **Position compat mode**: new "Position compatibility mode" setting: reserves top space for the native Windows title bar (top-right) so the sidebar buttons and content sit below it (off by default); the shift distance is customizable in the gear popup (0–120px) -- 🔌 **Service API base**: complete type exports + `version`/`features` capability detection, state subscription (`getSnapshot`/`subscribeState`), tab `badge`, `onOpen`/`onActivate`/`onClose` lifecycle callbacks, `updateTab`/`activateTab`/`openFile`, targeted open, `meta` persisted across reloads, plugin-owned settings (`pluginToggles`/`render`), external-link claim (`urlTarget`) -- ➕ **Add Plugins**: recommended plugin catalog in settings + one-click copy install command; built-in Office preview moved to the recommended plugin -- 🖱️ **Tab-bar scroll**: mouse-wheel horizontal scrolling on the tab bar -- 🐛 **Fixes**: remote access 403 (trust fence now uses `trustedHosts`), sidebar crash [#31](https://github.com/omdsh-dev/DSH-better-sidebar/issues/31), Windows HTML-preview drive-path - -### v0.12.1 - -- 🔌 **Service API base**: complete type exports + `version`/`features` capability detection, state subscription (`getSnapshot`/`subscribeState`), tab `badge`, `onOpen`/`onActivate`/`onClose` lifecycle callbacks, `updateTab`/`activateTab`/`openFile`, targeted open, `meta` persisted across reloads, plugin-owned settings (`pluginToggles`/`render`) -- ➕ **Add Plugins**: recommended plugin catalog in settings + one-click copy install command; built-in Office preview moved to the recommended plugin -- 🖱️ **Tab-bar scroll**: mouse-wheel horizontal scrolling on the tab bar -- 🐛 **Fixes**: remote access 403 (trust fence now uses `trustedHosts`), sidebar crash [#31](https://github.com/omdsh-dev/DSH-better-sidebar/issues/31), Windows HTML-preview drive-path - -> 📝 Note: the 0.12.0 final could not be reused (npm reported the version as already published), so the public release became 0.12.1 — both carry identical content. - -### v0.12.0 - -- 🔌 **Service API base**: complete type exports + `version`/`features` capability detection, state subscription, tab badges, lifecycle callbacks, targeted open, `meta` persisted across reloads, plugin-owned settings -- ➕ **Add Plugins**: recommended plugin catalog in settings + one-click copy install command; built-in Office preview moved to the recommended plugin -- 🖱️ **Tab-bar scroll**: mouse-wheel horizontal scrolling on the tab bar -- 🐛 **Fixes**: remote access 403 (trust fence now uses `trustedHosts`), sidebar crash [#31](https://github.com/omdsh-dev/DSH-better-sidebar/issues/31), Windows HTML-preview drive-path +> 🔌 **Core principle**: service-first — the 7 built-in tabs + 6 viewers register through the same `ctx.betterSidebar` API as third-party plugins, with fully equal capabilities; anything the ecosystem can provide better is delegated to ecosystem plugins (**26+ ecosystem plugins** already — see "🌐 Plugin Ecosystem" below). See "🔌 Service API" and the [external plugin guide](./docs/external-plugin-guide.md). ## 🚀 Installation **Prerequisites**: DSH installed (`dsh web` boots), Node.js ≥ 20, pnpm ≥ 10. +> ✅ **Supported DSH versions**: `0.1.0-rc.8` · `0.1.1-rc.1` · `0.1.1-rc.2` (peer floor `^0.1.0-rc.8`; CI mount-smoke baseline `0.1.1-rc.2`) + ```sh dsh plugin --profile web add dsh-better-sidebar@latest ``` @@ -168,7 +73,7 @@ Then **hard-refresh the browser** (Cmd/Ctrl+Shift+R) to see the sidebar (DSH hot dsh plugin --profile web add dsh-better-sidebar@latest ``` -or bump the version in `~/.dsh/profiles/web/package.json` (e.g. `"^0.13.0"`) and run `pnpm install`. Then hard-refresh the browser (Cmd/Ctrl+Shift+R) — client changes do not need a DSH restart. +or bump the version in `~/.dsh/profiles/web/package.json` (e.g. `"^0.15.0"`) and run `pnpm install`. Then hard-refresh the browser (Cmd/Ctrl+Shift+R) — client changes do not need a DSH restart.
@@ -209,7 +114,7 @@ To debug local changes or track the dev branch, point the dependency at a local 5. Restart DSH and hard-refresh ``` -Update: `git pull && pnpm install && pnpm build` → just hard-refresh the browser (client changes hot-reload; only host-half changes need a DSH restart). To switch back to the npm channel, restore `"dsh-better-sidebar": "^0.13.0"` and re-run `pnpm install`. +Update: `git pull && pnpm install && pnpm build` → just hard-refresh the browser (client changes hot-reload; only host-half changes need a DSH restart). To switch back to the npm channel, restore `"dsh-better-sidebar": "^0.15.0"` and re-run `pnpm install`.
@@ -230,6 +135,294 @@ Update: `git pull && pnpm install && pnpm build` → `node scripts/package-regis +## 🖼️ Feature Tour + +> All screenshots below are taken from the real UI (dark theme). Placeholder slots will be filled in over time. + +### 🗂️ File Workbench: Explorer + +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. + + + +### ✍️ CodeMirror Editor + +Open per path (new tab) or switch in place (opt-in merged mode), preview / edit / save in one window, syntax highlighting for 20+ languages, `Ctrl/Cmd+S` to save. + + + +### 📝 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. + +
+ +
+ +
+ +
+ +### 💻 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. + + + +### 🌿 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). push / pull / fetch are covered by the ecosystem plugins [dsh-git-remotes](https://github.com/yq04/dsh-git-remotes) and [dsh-git-forge](https://github.com/thirsty5034/dsh-git-forge). + + + + + +### 🌐 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). + + + +### 🧩 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). + + + +### 💬 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. + + + +### 🪟 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. + + + + + +### ⚙️ 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`. + + + +### 📱 Mobile + +On narrow screens (<768px) the panels become a full-width drawer: bottom-panel tabs merge into the sidebar once, with touch-friendly dragging. + +
+ +
+ +## 🌐 Plugin Ecosystem + +The `ctx.betterSidebar` service opens two extension points to every plugin: **`registerTab` (sidebar pages)** and **`registerFileViewer` (file previewers)**. The 7 built-in tabs + 6 viewers register through the exact same API — fully equal capabilities. + +```ts +import type {} from 'dsh-better-sidebar' // triggers the ctx.betterSidebar type merge +export const inject = ['betterSidebar'] +export function apply(ctx: Context) { + ctx.effect(() => ctx.betterSidebar.registerTab({ + id: 'my-plugin:db', title: 'Database', component: ({ scope }) => , + })) + ctx.effect(() => ctx.betterSidebar.registerFileViewer({ + id: 'my-plugin:csv', exts: ['csv'], fetchStrategy: 'custom', + load: async (path, scope) => parseCsv(await fetchText(scope, path)), + component: ({ customData }) => , + })) +} +``` + +The GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar) already hosts **26+ ecosystem plugins** (and growing): + +
+ The built-in Add Plugins modal: recommended catalog + one-click install command
+ The built-in "Add plugins" modal in settings: recommended catalog + one-click install command + a direct link to the GitHub topic +
+ +### 📑 Tab Plugins (sidebar pages) + +| 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` | +| [fuhefei/dsh-sentinel](https://github.com/fuhefei/dsh-sentinel) | stars | Condition-driven wakeup: file / command / HTTP / process / webhook watches that wake the agent; dock + sidebar branch + global dashboard | +| [jiuge2467/dsh-studio](https://github.com/jiuge2467/dsh-studio) | stars | Full-stack enhancement workbench: multi-source MCP visual debugging hub, visual thinking engine | +| [Iwctwbh/dsh-flowglass](https://github.com/Iwctwbh/dsh-flowglass) | stars | Flowglass: live session flowgraph (messages / tool groups / subagent branches) | +| [FeatherHunter/dsh-mattpocock-skills-deck](https://github.com/FeatherHunter/dsh-mattpocock-skills-deck) | stars | Game-like mission system for mattpocock/skills: fog-of-war map + task bar | +| [GULI-lab/DSH-element-source](https://github.com/GULI-lab/DSH-element-source) | stars | Click any UI element on your dev page to jump to its Vue / React / Svelte / Angular source, straight into the chat | +| [Lzh3070/dsh-file-review-tab](https://github.com/Lzh3070/dsh-file-review-tab) | stars | File-change review tab: line-level red/green diffs + undo + chat-line deep links | +| [yq04/dsh-git-remotes](https://github.com/yq04/dsh-git-remotes) | stars | Git remotes tab: branches / upstream / ahead-behind, fetch with prune, ff-only pull, confirm-before-push | +| [ztyhehe/dsh-better-sidebar-svn](https://github.com/ztyhehe/dsh-better-sidebar-svn) | stars | SVN source-control tab: status / diff / log / commit / update / revert / conflict resolution — symmetric to the built-in Git panel | +| [Melody-max114/dsh-excel-panel](https://github.com/Melody-max114/dsh-excel-panel) | stars | Excel editing: xlsx preview/edit, live formula evaluation, merged cells, save back to the original file | +| [v587d/dsh-anysearch-refs](https://github.com/v587d/dsh-anysearch-refs) | stars | AnySearch results as sidebar cards: query, source snippets, highlighted keywords | +| [mlosun/dsh-docs-panel](https://github.com/mlosun/dsh-docs-panel) | stars | Global docs panel: portable Markdown notes, readable from any workspace | +| [lnyuqian/dsh-skill-sidebar](https://github.com/lnyuqian/dsh-skill-sidebar) | stars | Skills panel: scans local skill directories, one-click invocation copy, pinning | +| [g-yixuan/dsh-sidechat](https://github.com/g-yixuan/dsh-sidechat) | stars | Codex-style side chat + selection annotations (a thin consumer plugin) | +| [thirsty5034/dsh-ssh-tunnel](https://github.com/thirsty5034/dsh-ssh-tunnel) | stars | Multi-host SSH tunnels + SSH manager tab | +| [thirsty5034/dsh-git-forge](https://github.com/thirsty5034/dsh-git-forge) | stars | GitHub / Gitea accounts, project grants and push policy | +| [YesSanSan/dsh-conversation-outline](https://github.com/YesSanSan/dsh-conversation-outline) | stars | Conversation outline tab: per-turn structure, quick jump, one-line LLM titles | +| [Wulabalabo/dsh-sidebar-Explorer-Plus](https://github.com/Wulabalabo/dsh-sidebar-Explorer-Plus) | stars | File-manager tab: upload / move / delete / rename / new folder (write operations) | +| [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 | + +### 🖼️ Viewer Plugins (file previewers) + +| 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 + +| 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 + +
+ Sidebar + Service API base screenshot +
+ +> ✅ **v0.15.0 supports DSH**: `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.0 + +> ⚠️ **This release is verified against DSH 0.1.1-rc.2 (@next)**: devDependencies / lockfile / CI mount-smoke baseline raised to `0.1.1-rc.2` (zero rc.8 leftovers in the lockfile); **the peer floor stays at `^0.1.0-rc.8`** — 0.1.1-rc.x is a routine rc progression, mutually compatible with rc.8, so **rc.8 users don't need to upgrade DSH**. `cordis` stays at `^4.0.0-rc.8`. All changes since v0.14.0: + +**✨ New features** + +- 💬 **Side Chat (beta) tab** ([#286](https://github.com/omdsh-dev/DSH-better-sidebar/pull/286)): Codex-style side threads, **one independent tab per conversation** — the child inherits the parent's full context (completed turns + pending messages + the in-progress turn's assistant output and tool activity, honestly frozen with an "interrupted" marker); created with an identical composition (same preset / provider / model) so the first request reuses the parent's input prefix cache; threads stay invisible in the main session list with zero subagent-catalog noise; follow-ups survive DSH restarts (auto cold-resume); one-click "Save as new session" promotes the thread to a top-level session ([design](docs/plans/2026-08-20-sidechat-tab-design.md)) +- 📤 **Upload into the files window** ([#239](https://github.com/omdsh-dev/DSH-better-sidebar/pull/239)): header "upload file / upload folder" buttons plus drag-drop (drop on the tree body = workspace root, on a directory row = that directory, on a file row = its parent directory, VSCode semantics); full-window blurred progress overlay while uploading (per-file progress + cancel / Esc); buttons disabled while busy, tree refreshes after the upload settles +- 🧩 **Desktop compatibility in four options** ([#284](https://github.com/omdsh-dev/DSH-better-sidebar/pull/284)): "Position compatibility mode" is now a main-row dropdown — **Auto-detect** (default, conservative: only the standard Window Controls Overlay geometry contributes; real 32/36px caption-overlay heights per shell, live on maximize/restore; zero modification on plain web) / **DSH official web** (explicitly no adaptation) / **Shell preset** (built-in, opt-in; only shells that appeared in this repo's issues/PRs with 100+ stars, "detected" badge when the environment matches) / **Custom** (free-form CSS + shift distance). Documents that already carried compatibility values migrate to the custom scheme; interactive chrome opts out of desktop drag regions (`no-drag`); the bottom-push anchor is a composite selector (`[data-pane]` and `:has(> [data-slot])`) +- 🎛️ **Settings page UI/UX modernization** ([#300](https://github.com/omdsh-dev/DSH-better-sidebar/pull/300)): the side-card secondary-settings entry is now a full-width "Feature settings" strip at the card bottom (replacing the invisible corner gear — much easier to discover); coordinated two-tone enabled state (brand activation accent + success-green check badge); every color is still `--dsw-alias-*` token-derived so skins follow automatically +- ➕ **New entries in the recommended-plugin catalog**: `dsh-docs-panel` (global docs, [#230](https://github.com/omdsh-dev/DSH-better-sidebar/pull/230)), `dsh-flowglass` ([#261](https://github.com/omdsh-dev/DSH-better-sidebar/pull/261)), `dsh-git-forge` and `dsh-ssh-tunnel` ([#204](https://github.com/omdsh-dev/DSH-better-sidebar/pull/204)), `dsh-turn-review` ([#102](https://github.com/omdsh-dev/DSH-better-sidebar/pull/102)) + +**🐛 Fixes** + +- ⚡ **Batched live preview for the subagent page** ([#298](https://github.com/omdsh-dev/DSH-better-sidebar/pull/298)): the old implementation polled `subagents.history` per running subagent, each poll triggering a full host-side enumeration — an O(N²) amplification that stalled the page with many concurrent subagents; now a single batch route `subagents.live` (one enumeration of the whole tree) plus one client poller with a single in-flight request; display logic and copy unchanged +- 🖱️ **Interrupted / fast-release drags no longer roll back** ([#249](https://github.com/omdsh-dev/DSH-better-sidebar/pull/249), closes [#247](https://github.com/omdsh-dev/DSH-better-sidebar/issues/247) [#248](https://github.com/omdsh-dev/DSH-better-sidebar/issues/248)): interrupted or fast-released drags commit the last known position; HMR re-activation re-locates the center column (fixes the blank bottom panel after a hot reload) +- 📐 **Push variables stay effective while mounted** ([#259](https://github.com/omdsh-dev/DSH-better-sidebar/pull/259), fixes [#258](https://github.com/omdsh-dev/DSH-better-sidebar/issues/258)): the bottom panel no longer flashes full-width after a drag is released +- 🔧 **Adapted to DSH 0.1.1-rc.1 / rc.2 (@next)** ([#297](https://github.com/omdsh-dev/DSH-better-sidebar/pull/297) [#305](https://github.com/omdsh-dev/DSH-better-sidebar/pull/305)): devDependencies / lockfile / CI verification baseline shifted; every consumed contract (DOM slot, CSS tokens, boot protocol, MarkdownText `codeLabels`, `webServer` route registration) compared package by package with zero breakage — **no code changes needed**; peer floor stays at `^0.1.0-rc.8` +- 🔒 **Upload-chain hardening** ([#239](https://github.com/omdsh-dev/DSH-better-sidebar/pull/239)): empty and absolute `relativePath` segments are refused outright; uniquely named temp files (concurrent uploads stay independent, crashed processes never block later uploads); write-stream error listeners (a failing disk can no longer crash the host); client error codes unified with the wire (`too-large`), 413s localized + +
+Older releases (v0.12.0 – v0.14.0) + +### v0.14.0 + +> ⚠️ **This release adapts to DSH 0.1.0-rc.8**: all `@deepseek-ai/*` peer/devDependencies raised to `^0.1.0-rc.8` (transitives included; zero rc.7 leftovers in the lockfile), `cordis` to `^4.0.0-rc.8`, and the CI mount lane pins `@deepseek-ai/dsh@0.1.0-rc.8`. **DSH rc.7 and earlier cannot resolve this release's dependencies — upgrade DSH first.** All changes since v0.13.1: + +**✨ New features** + +- 🖼️ **Unified panel-host injection refactor** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): panels/toggle clusters moved into a `[data-dsh-panel-host]` fixed containing block (`fixed inset-0 z-40`), immune to desktop-shell intermediate transforms hijacking `fixed`; mount self-check (page-level transform → `data-dsh-panel-host-degraded` degraded sync, judged on uncorrected geometry, exits only when the ancestor transform is gone); push anchor switched to `#root [data-dsh-frame] > [data-pane="conversation"]` + `#root` calc width against desktop-shell additive overflow; chunk revalidation on activation (HEAD+ETag keeps unchanged chunks, 5s timeout fails open); `visualViewport` keyboard inset + `env(safe-area-inset-*)` mobile adaptation +- 📂 **Separate file windows by default** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): `editorExplorer` now defaults to **separate** — tree clicks / file opens create a new tab per path and the path-less window is a pure file manager; merged mode stays available as an opt-in +- 🖥️ **Terminal shell / shellArgs configurable from the settings page** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): the terminal card's gear popup gains "Shell path" and "Shell arguments" rows (previously yaml-only via `cordis.patch.yml`) — saved values take effect immediately for terminals opened afterwards (UI terminals and model `terminal_create` alike); empty keeps the existing yaml → `$SHELL` / login shell / `powershell.exe` resolution order +- 🏷️ **Version badge on the settings page** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): the side-card settings section now opens with a `DSH-better-sidebar v0.14.0` identity badge (version synced with the service instance, test-guarded) +- 🔍 **Add-plugin catalog: search / grouping / independent scroll** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): built for a growing plugin ecosystem — a live search box (filters by name / id / description), optional `category` grouping for entries, and an independently scrolling list (the modal no longer grows unbounded with catalog size) + +**🐛 Fixes** + +- 🔧 **Adapted to DSH 0.1.0-rc.8** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): all 13 `@deepseek-ai/*` peer/devDependencies raised to `^0.1.0-rc.8` (transitives included; zero rc.7 leftovers in the lockfile), `cordis` to `^4.0.0-rc.8`; `dsh-client-web-react` / `dsh-client-schema-form` removed (no rc.8 publish, no longer in the shell module table, zero plugin references); CI mount lane pins `@deepseek-ai/dsh@0.1.0-rc.8`; pnpm 11.8 supply-chain verification adapted +- 🧩 **rc.8 module-system migration** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): rc.8 no longer exposes the `window.__DSH_MODULES__` page global (it moved to the `ctx.modules` service), which broke every lazy chunk's externals resolution — the client now injects the `modules` service and shares it with chunk-bundle copies through a plugin-owned global (terminal / editor / Mermaid on-demand loading restored) +- 🧩 **Chunk revalidation barrier hardening** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): HEAD revalidation gains a 5s timeout (fails open on a stuck route so the barrier can never wedge lazy loads); `resetChunks` clears a pending revalidation barrier +- 🖱️ **Drag robustness** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): fast releases (browsers merge/lose pointermove bursts) commit the last known dragged position instead of rolling back; `pointercancel` / lost-capture interruptions keep the drag result too; the center column is re-measured right after commit (no mid-frame bottom-panel width jump); HMR re-activation re-locates the center column via an `` style observer plus a retry when the bottom panel opens (fixes the blank bottom panel / shifted input bar after a hot reload) + +### v0.13.1 + +**✨ New features** + +- 📊 **Safe Mermaid rendering in the Markdown preview** ([#164](https://github.com/omdsh-dev/DSH-better-sidebar/pull/164)): when a previewed md file contains mermaid fences, a `client-mermaid.js` chunk (~7MB) is served on demand (zero load without mermaid); defense-in-depth rendering — `securityLevel: 'strict'` + `htmlLabels: false` (node labels use real SVG ``) + a second sanitize pass before SVG injection (foreignObject/script/foreign HTML elements removed, `@*`/`on*`/`href` attributes stripped); click a diagram to zoom in a modal overlay (wheel zoom centered on the cursor, drag pan, toolbar & shortcuts), re-renders with light/dark theme, falls back to the raw code block on parse failure +- 🖥️ **Configurable terminal shell & shellArgs** ([#125](https://github.com/omdsh-dev/DSH-better-sidebar/pull/125)): `cordis.patch.yml` `better-sidebar.config` can set `shell` / `shellArgs` (a non-empty `shellArgs` fully replaces the defaults; unset keeps the previous auto-resolution of `$SHELL` / login shell / `powershell.exe`), applied to both UI terminals and agent terminals (`terminal_create`); terminal tab titles now show the shell name (bash/zsh/powershell) and internal tab ids use UUIDs so the same shell can open multiple terminals + +**🐛 Fixes** + +- 🔗 **Aggregate double-mount auto-yield** ([#200](https://github.com/omdsh-dev/DSH-better-sidebar/pull/200)): when an aggregate package (e.g. dsh-web-ui-all) mounts the same package under its own entry id, the guard expression in `cordis.patch.yml` disables the plugin's own `better-sidebar` row so `/sidebar/api` is no longer registered twice (`duplicate prefix route` crashing the whole plugin tree / `dsh web`); standalone installs behave as before +- 🔧 **Peer dependencies aligned to DSH 0.1.0-rc.7** ([#207](https://github.com/omdsh-dev/DSH-better-sidebar/pull/207), fixes [#206](https://github.com/omdsh-dev/DSH-better-sidebar/issues/206)): all `@deepseek-ai/*` peer/devDependencies raised from `^0.1.0-rc.6` to `^0.1.0-rc.7`, and the CI mount lane pins the same version — eliminating the mixed rc.6/rc.7 dependency tree (after the main framework moved to rc.7) that surfaced as `agent-presets: refusing to compose an unscoped context` when picking a model / sending a message + +### v0.13.0 + +**✨ New features** + +- 📁 **Files window merged with the explorer** ([#151](https://github.com/omdsh-dev/DSH-better-sidebar/pull/151)): new `editorExplorer` setting (editor card gear) — file tabs gain a path-input header plus a toggleable right-docked file tree (per-tab open/width memory, drag-resize 160–480px from the left edge, global filename search via the host `fs.search` route with a hard budget, skipping `.git` and symlink dirs); in separate mode (default) tree clicks / Enter in the path input open each file **in its own new tab**, merged mode switches the current tab **in place**; fresh sessions seed an empty Files window instead of the explorer tab, and a path-less window is a bare file manager in separate mode / a chrome'd empty file window in merged mode; the tree context menu offers "Open in new tab" and "Open to the side" (split) +- 🎛️ **Select rows for declarative settings** ([#151](https://github.com/omdsh-dev/DSH-better-sidebar/pull/151)): settings rows gain `type: 'select'` (`options` with value/title/desc/icon, `multi` stores the picked values as an array); options with icons render big-icon option cards and keep the icon in the closed anchor; `editorExplorer` became an iconed select (merged vs separate); the capability list gained `settingSelect` +- 🔀 **Mutual exclusion with the dsh-web-ui family right panel** ([#181](https://github.com/omdsh-dev/DSH-better-sidebar/pull/181)): reads the `aionui-panel` settings namespace's provider choice — when "Use aionui-panel" is selected, the whole better-sidebar (right sidebar / bottom panel / floating entry / all takeovers) does not mount; with DSH-better-sidebar (or no aionui installed) it behaves as before. Takes effect live after a settings save (settings-document push), no reload needed + +### v0.12.3 + +**✨ New features** + +- 🎨 **Skin compatibility (token-driven)**: fully consumes DSH design tokens and follows the dsh-web-ui skin center's 10 skins automatically; terminal/editor surfaces fall back to opaque backgrounds under transparent/translucent-glass token values so text never scrolls over the skin art ([#110](https://github.com/omdsh-dev/DSH-better-sidebar/pull/110), fixes #106 #105 #90 #60, also #52 #57 #92) +- 🗂️ **Unified path handling**: UNC / symlink classification (directory symlinks expandable, broken links highlighted) + HTML-route platform guards ([#134](https://github.com/omdsh-dev/DSH-better-sidebar/pull/134), #65 #67 #43 #79 #115) +- 🖥️ **Configurable terminal shell**: custom shell setting with Windows pwsh auto-probe ([#95](https://github.com/omdsh-dev/DSH-better-sidebar/pull/95)) +- 📝 **Editor languages**: C# / Kotlin / Swift syntax highlighting ([#120](https://github.com/omdsh-dev/DSH-better-sidebar/pull/120)) +- 🧭 **Settings nav icon**: settings-page navigation icon and layout polish ([#114](https://github.com/omdsh-dev/DSH-better-sidebar/pull/114)) +- ➕ **Recommended-plugin catalog**: added `dsh-git-remotes` — Git Remotes tab (branches/upstream/ahead-behind, fetch with prune, ff-only pull, confirm-before-push; does not replace the built-in stage/commit tab) ([#91](https://github.com/omdsh-dev/DSH-better-sidebar/pull/91)); and `dsh-video-preview` — inline video preview (.mp4/.webm/.mov/.mkv/.avi etc.) backed by a /video host route with HTTP Range (206) scrubbing, not capped by the 20MB mediaLimit ([#126](https://github.com/omdsh-dev/DSH-better-sidebar/pull/126)) + +**🐛 Fixes** + +- 🔧 **xterm migration**: deprecated xterm dependency migrated to `@xterm/xterm` (Closes [#122](https://github.com/omdsh-dev/DSH-better-sidebar/issues/122), [#128](https://github.com/omdsh-dev/DSH-better-sidebar/pull/128)) +- 📝 **Markdown editor**: selection-to-conversation popup restored ([#24](https://github.com/omdsh-dev/DSH-better-sidebar/pull/24)) +- 🐛 **node-pty load failure no longer crashes the server** ([#140](https://github.com/omdsh-dev/DSH-better-sidebar/issues/140)): the host half now lazy-loads node-pty — when it is missing the plugin still mounts, the terminal shows a repair banner (copyable command + Retry button), and agent terminal tools are skipped +- 🧪 Test engineering: unit spec split (#141) + flaky smoke cleanup fix + +
+ ## ⌨️ Keyboard Shortcuts | Action | Keys | @@ -241,21 +434,9 @@ Update: `git pull && pnpm install && pnpm build` → `node scripts/package-regis | Reference file to input | Hover the `@file` button at end of line | | Copy file path | Right-click row → copy relative/absolute path | -## 🔌 Service: register tabs & file viewers +## 🔌 Service API -Since v0.4.0 the plugin exposes the `ctx.betterSidebar` service — other plugins can register sidebar pages and file viewers (the 7 built-in tabs + 6 viewers register through the same service): - -```ts -import type {} from 'dsh-better-sidebar' // triggers the ctx.betterSidebar type merge -export const inject = ['betterSidebar'] -export function apply(ctx: Context) { - ctx.effect(() => ctx.betterSidebar.registerTab({ - id: 'my-plugin:db', title: 'Database', component: ({ scope }) => , - })) -} -``` - -v0.12.1+ base capabilities (complete type exports, capability detection, state subscription, tab badge, lifecycle callbacks, targeted open, plugin-owned settings, etc.) — see the integration docs below. +Since v0.4.0 the plugin exposes the `ctx.betterSidebar` service — other plugins can register sidebar pages and file viewers (the 7 built-in tabs + 6 viewers register through the same service). v0.12.1 completed the base capabilities (complete type exports, capability detection, state subscription, tab badges, lifecycle callbacks, targeted open, plugin-owned settings, etc.). Full integration docs: - **[`AGENTS.md`](./AGENTS.md)** — the in-repo integration doc (full fields, matching algorithm, HMR pitfalls, declarative settings, version detection); @@ -298,9 +479,39 @@ 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). +## 🤝 Contributing + +- **Code changes go through PRs**: develop on a `feat/*` / `fix/*` branch, then `gh pr create`; docs-only changes may be pushed to main directly +- **Curate an ecosystem plugin**: tag your repo with `dsh-better-sidebar` + PR a `PluginEntry` into [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts) / [`plugins-viewers.ts`](./src/client/plugins-viewers.ts) +- **Before submitting**: `pnpm typecheck && pnpm build && pnpm test` (CI additionally gates on npm-pack → real-mount → headless-render via `pnpm test:mount`) +- See [`AGENTS.md`](./AGENTS.md) for the repository rules (hard constraints, CI lanes, release flow) + +## ⭐ Star History + + + + + Star History Chart + + + +## 👥 Contributors + +Thanks to everyone who contributed: + + + Contributors + + ## 🔗 Friends - [dsh-tianshu-tui](https://github.com/huiliyi37/dsh-tianshu-tui): an interactive terminal UI plugin for DeepSeek Harness (its rendering core evolved from the self-developed harness agent Tianshu-Tui), adding TDD and evidence-gate workflows on top of the official harness - [dsh-TUI](https://github.com/ccch1mneyyy/dsh-TUI): a Claude Code-style fullscreen interactive TUI plugin — pixel-whale top bar, live working-status row, streaming thought expansion, double-Esc rollback, context progress bar + TPS meter; one-command npm install - [dshfind Plugin Market](https://dshfind.com/zh/plugins): a third-party plugin marketplace — a listing of public repos under the GitHub topic `dsh-plugin`, with stars, contributors and growth data synced daily - [DeepSeek Harness Desktop](https://github.com/anywhere-labs/deepseek-harness-desktop): a modern desktop client for the DeepSeek Harness ecosystem — start and manage a local Harness service without configuring Node.js or running commands; [official site](https://www.dshdesktop.cn) + +--- + +
+ MIT License · Built for the DeepSeek Harness ecosystem · discover more on the dsh-better-sidebar topic +
From d38a411f6d92c0bce07213f86e521a1128933947 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Sat, 22 Aug 2026 01:04:16 +0800 Subject: [PATCH 02/21] docs(readme): user-facing DSH compat badge, node-pty-safe install script, paste-to-DSH install prompt - Drop peer-floor / CI-baseline jargon everywhere; supported DSH versions are stated only via the shields badge - Install block: add 'pnpm approve-builds --all && pnpm rebuild node-pty' unconditionally so the terminal's prebuilt binary is always in place (pnpm 11 blocks build scripts by default) - Add a copy-paste prompt block that lets a DSH session install the plugin itself (mirrors the manual script + node-pty step) - Trim internal process detail from historical changelog bullets (v0.14.0 / v0.13.1), keep the user-facing symptoms and gates --- README.md | 25 ++++++++++++++++++------- README_EN.md | 25 ++++++++++++++++++------- 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 4e28d28..b26a424 100644 --- a/README.md +++ b/README.md @@ -58,14 +58,26 @@ **前置**:已装好 DSH(`dsh web` 能正常运行),Node.js ≥ 20、pnpm ≥ 10。 -> ✅ **当前版本支持的 DSH**:`0.1.0-rc.8` · `0.1.1-rc.1` · `0.1.1-rc.2`(peer 下限 `^0.1.0-rc.8`;CI 挂载冒烟基线 `0.1.1-rc.2`) +**支持的 DSH 版本**: +支持的 DSH 版本:0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2 ```sh dsh plugin --profile web add dsh-better-sidebar@latest +cd ~/.dsh/profiles/web && pnpm approve-builds --all && pnpm rebuild node-pty # 终端功能依赖 node-pty 的预编译二进制:放行构建脚本并确保装好 ``` 装完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可看到侧边栏(DSH 对 client 改动热加载,无需重启;仅 host 半更新时需要重启)。 +**方式二:让 DSH 自己装**——把下面这段提示词发给任意一个 DSH 会话: + +```text +帮我安装 dsh-better-sidebar 插件(DSH 侧边栏工作台),步骤: +1. 执行 dsh plugin --profile web add dsh-better-sidebar@latest +2. 在 ~/.dsh/profiles/web 下执行 pnpm approve-builds --all && pnpm rebuild node-pty(终端功能需要 node-pty 的预编译二进制) +3. 完成后提醒我硬刷新浏览器(Cmd/Ctrl+Shift+R) +遇到报错先查 https://github.com/omdsh-dev/DSH-better-sidebar README 的常见问题表。 +``` +
更新 @@ -339,11 +351,11 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar 服务化基座截图 -> ✅ **v0.15.0 支持的 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)。 +**支持的 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.0 -> ⚠️ **本版验证基线为 DSH 0.1.1-rc.2(@next)**:devDependencies / lockfile / CI 挂载冒烟基线升至 `0.1.1-rc.2`(lockfile 零 rc.8 残留);**peer 下限保持 `^0.1.0-rc.8`**——0.1.1-rc.x 为常规 rc 递进、与 rc.8 双向兼容,**rc.8 用户无需升级 DSH**。`cordis` 保持 `^4.0.0-rc.8`。自 v0.14.0 以来的全部更改: +自 v0.14.0 以来的全部更改: **✨ 新功能** @@ -358,7 +370,7 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar - ⚡ **子代理页实时预览批量接口**([#298](https://github.com/omdsh-dev/DSH-better-sidebar/pull/298)):旧实现每个 running 子代理独立轮询 `subagents.history`,host 侧每次触发全量子代理枚举形成 O(N²) 放大、多子代理并发时页面卡顿——改为单个批量接口 `subagents.live`(一次枚举整棵子代理树)+ 客户端单轮询、单在途请求;展示逻辑与文案不变 - 🖱️ **拖拽中断 / 快速释放不再回滚**([#249](https://github.com/omdsh-dev/DSH-better-sidebar/pull/249),关闭 [#247](https://github.com/omdsh-dev/DSH-better-sidebar/issues/247) [#248](https://github.com/omdsh-dev/DSH-better-sidebar/issues/248)):中断 / 快速释放提交最后已知位置;HMR 后中心列重定位兜底(修复热更新后底栏空白) - 📐 **推挤变量挂载期持续有效**([#259](https://github.com/omdsh-dev/DSH-better-sidebar/pull/259),修复 [#258](https://github.com/omdsh-dev/DSH-better-sidebar/issues/258)):拖拽松手后底边栏不再闪全宽 -- 🔧 **适配 DSH 0.1.1-rc.1 / rc.2(@next)**([#297](https://github.com/omdsh-dev/DSH-better-sidebar/pull/297) [#305](https://github.com/omdsh-dev/DSH-better-sidebar/pull/305)):devDependencies / lockfile / CI 验证基线平移;逐包比对所有既有契约(DOM 槽位、CSS 令牌、boot 协议、MarkdownText `codeLabels`、`webServer` 路由注册)零破坏,**无代码逻辑改动**;peer 下限保持 `^0.1.0-rc.8` +- 🔧 **适配 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 本地化
@@ -366,7 +378,7 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar ### v0.14.0 -> ⚠️ **本版适配 DSH 0.1.0-rc.8**:全部 `@deepseek-ai/*` peer / devDependencies 升至 `^0.1.0-rc.8`(含传递依赖,lockfile 零 rc.7 残留),`cordis` 同步 `^4.0.0-rc.8`,CI 挂载冒烟钉版 `@deepseek-ai/dsh@0.1.0-rc.8`。**rc.7 及更早的 DSH 环境将无法解析本版依赖,请先升级 DSH。** 自 v0.13.1 以来的全部更改: +> ⚠️ 本版起需要 DSH ≥ 0.1.0-rc.8。自 v0.13.1 以来的全部更改: **✨ 新功能** @@ -378,7 +390,6 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar **🐛 修复** -- 🔧 **适配 DSH 0.1.0-rc.8**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):13 个 `@deepseek-ai/*` peer / devDependencies 升至 `^0.1.0-rc.8`(含传递链,lockfile 零 rc.7 残留),`cordis` 同步 `^4.0.0-rc.8`;移除随 rc.8 消失的 `dsh-client-web-react` / `dsh-client-schema-form`(壳模块表不再提供、插件零引用);CI 挂载冒烟钉版 `@deepseek-ai/dsh@0.1.0-rc.8`;pnpm 11.8 supply-chain 校验适配 - 🧩 **rc.8 模块系统迁移**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):rc.8 不再暴露 `window.__DSH_MODULES__` 页面全局(改由 `ctx.modules` 服务提供),懒加载 chunk 的外部依赖解析全面失效——client 注入 `modules` 服务 + 插件自有全局共享给 chunk 副本(终端 / 编辑器 / Mermaid 恢复正常按需加载) - 🧩 **chunk 重验证屏障健壮性**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):HEAD 重验证加 5s 超时兜底(路由挂起时 fail-open 重取,屏障不再可能无限期阻塞懒加载);`resetChunks` 清挂起的重验证屏障 - 🖱️ **拖拽健壮性**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):快速释放(浏览器合并 / 丢失 pointermove 突发)时提交最后已知拖动位置而非回退;`pointercancel` / 捕获丢失中断同样保留拖动结果;提交后立即重测中心列(消除底栏宽度中间帧抖动);HMR 重激活后中心列重定位兜底(`` 样式观察 + 底栏打开重测),修复热更新后底栏空白 / 输入框位移 @@ -393,7 +404,7 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar **🐛 修复** - 🔗 **聚合双挂载自动退让**([#200](https://github.com/omdsh-dev/DSH-better-sidebar/pull/200)):聚合包(如 dsh-web-ui-all)以独立条目 id 挂载同包时,`cordis.patch.yml` 的守卫表达式自动禁用自身 `better-sidebar` 行,不再重复注册 `/sidebar/api` 导致 `duplicate prefix route` 整个插件树启动失败(`dsh web` 崩溃);独立安装行为不变 -- 🔧 **peer 依赖对齐 DSH 0.1.0-rc.7**([#207](https://github.com/omdsh-dev/DSH-better-sidebar/pull/207),修复 [#206](https://github.com/omdsh-dev/DSH-better-sidebar/issues/206)):全部 `@deepseek-ai/*` peer / devDependencies 从 `^0.1.0-rc.6` 升至 `^0.1.0-rc.7`,CI 挂载冒烟同步钉版——消除主框架升至 rc.7 后 rc.6 / rc.7 混用依赖树导致的 `agent-presets: refusing to compose an unscoped context`(选模型 / 发消息报错) +- 🔧 **适配 DSH 0.1.0-rc.7**([#207](https://github.com/omdsh-dev/DSH-better-sidebar/pull/207),修复 [#206](https://github.com/omdsh-dev/DSH-better-sidebar/issues/206)):修复 DSH 主框架升至 rc.7 后选模型 / 发消息报 `agent-presets: refusing to compose an unscoped context` 的问题 ### v0.13.0 diff --git a/README_EN.md b/README_EN.md index 8171fcd..332b51e 100644 --- a/README_EN.md +++ b/README_EN.md @@ -58,14 +58,26 @@ **Prerequisites**: DSH installed (`dsh web` boots), Node.js ≥ 20, pnpm ≥ 10. -> ✅ **Supported DSH versions**: `0.1.0-rc.8` · `0.1.1-rc.1` · `0.1.1-rc.2` (peer floor `^0.1.0-rc.8`; CI mount-smoke baseline `0.1.1-rc.2`) +**Supported DSH versions**: +Supported DSH versions: 0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2 ```sh dsh plugin --profile web add dsh-better-sidebar@latest +cd ~/.dsh/profiles/web && pnpm approve-builds --all && pnpm rebuild node-pty # the terminal needs node-pty's prebuilt binary: allow build scripts and make sure it is installed ``` Then **hard-refresh the browser** (Cmd/Ctrl+Shift+R) to see the sidebar (DSH hot-reloads client changes; only host-half updates need a restart). +**Or let DSH install it for you** — paste this prompt into any DSH session: + +```text +Install the dsh-better-sidebar plugin (a sidebar workbench for DSH): +1. Run: dsh plugin --profile web add dsh-better-sidebar@latest +2. In ~/.dsh/profiles/web run: pnpm approve-builds --all && pnpm rebuild node-pty (the terminal feature needs node-pty's prebuilt binary) +3. When done, remind me to hard-refresh the browser (Cmd/Ctrl+Shift+R) +If anything fails, check the troubleshooting table in the README at https://github.com/omdsh-dev/DSH-better-sidebar +``` +
Updating @@ -339,11 +351,11 @@ The GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sid Service API base screenshot -> ✅ **v0.15.0 supports DSH**: `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. +**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.0 -> ⚠️ **This release is verified against DSH 0.1.1-rc.2 (@next)**: devDependencies / lockfile / CI mount-smoke baseline raised to `0.1.1-rc.2` (zero rc.8 leftovers in the lockfile); **the peer floor stays at `^0.1.0-rc.8`** — 0.1.1-rc.x is a routine rc progression, mutually compatible with rc.8, so **rc.8 users don't need to upgrade DSH**. `cordis` stays at `^4.0.0-rc.8`. All changes since v0.14.0: +All changes since v0.14.0: **✨ New features** @@ -358,7 +370,7 @@ The GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sid - ⚡ **Batched live preview for the subagent page** ([#298](https://github.com/omdsh-dev/DSH-better-sidebar/pull/298)): the old implementation polled `subagents.history` per running subagent, each poll triggering a full host-side enumeration — an O(N²) amplification that stalled the page with many concurrent subagents; now a single batch route `subagents.live` (one enumeration of the whole tree) plus one client poller with a single in-flight request; display logic and copy unchanged - 🖱️ **Interrupted / fast-release drags no longer roll back** ([#249](https://github.com/omdsh-dev/DSH-better-sidebar/pull/249), closes [#247](https://github.com/omdsh-dev/DSH-better-sidebar/issues/247) [#248](https://github.com/omdsh-dev/DSH-better-sidebar/issues/248)): interrupted or fast-released drags commit the last known position; HMR re-activation re-locates the center column (fixes the blank bottom panel after a hot reload) - 📐 **Push variables stay effective while mounted** ([#259](https://github.com/omdsh-dev/DSH-better-sidebar/pull/259), fixes [#258](https://github.com/omdsh-dev/DSH-better-sidebar/issues/258)): the bottom panel no longer flashes full-width after a drag is released -- 🔧 **Adapted to DSH 0.1.1-rc.1 / rc.2 (@next)** ([#297](https://github.com/omdsh-dev/DSH-better-sidebar/pull/297) [#305](https://github.com/omdsh-dev/DSH-better-sidebar/pull/305)): devDependencies / lockfile / CI verification baseline shifted; every consumed contract (DOM slot, CSS tokens, boot protocol, MarkdownText `codeLabels`, `webServer` route registration) compared package by package with zero breakage — **no code changes needed**; peer floor stays at `^0.1.0-rc.8` +- 🔧 **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
@@ -366,7 +378,7 @@ The GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sid ### v0.14.0 -> ⚠️ **This release adapts to DSH 0.1.0-rc.8**: all `@deepseek-ai/*` peer/devDependencies raised to `^0.1.0-rc.8` (transitives included; zero rc.7 leftovers in the lockfile), `cordis` to `^4.0.0-rc.8`, and the CI mount lane pins `@deepseek-ai/dsh@0.1.0-rc.8`. **DSH rc.7 and earlier cannot resolve this release's dependencies — upgrade DSH first.** All changes since v0.13.1: +> ⚠️ This release requires DSH ≥ 0.1.0-rc.8. All changes since v0.13.1: **✨ New features** @@ -378,7 +390,6 @@ The GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sid **🐛 Fixes** -- 🔧 **Adapted to DSH 0.1.0-rc.8** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): all 13 `@deepseek-ai/*` peer/devDependencies raised to `^0.1.0-rc.8` (transitives included; zero rc.7 leftovers in the lockfile), `cordis` to `^4.0.0-rc.8`; `dsh-client-web-react` / `dsh-client-schema-form` removed (no rc.8 publish, no longer in the shell module table, zero plugin references); CI mount lane pins `@deepseek-ai/dsh@0.1.0-rc.8`; pnpm 11.8 supply-chain verification adapted - 🧩 **rc.8 module-system migration** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): rc.8 no longer exposes the `window.__DSH_MODULES__` page global (it moved to the `ctx.modules` service), which broke every lazy chunk's externals resolution — the client now injects the `modules` service and shares it with chunk-bundle copies through a plugin-owned global (terminal / editor / Mermaid on-demand loading restored) - 🧩 **Chunk revalidation barrier hardening** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): HEAD revalidation gains a 5s timeout (fails open on a stuck route so the barrier can never wedge lazy loads); `resetChunks` clears a pending revalidation barrier - 🖱️ **Drag robustness** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): fast releases (browsers merge/lose pointermove bursts) commit the last known dragged position instead of rolling back; `pointercancel` / lost-capture interruptions keep the drag result too; the center column is re-measured right after commit (no mid-frame bottom-panel width jump); HMR re-activation re-locates the center column via an `` style observer plus a retry when the bottom panel opens (fixes the blank bottom panel / shifted input bar after a hot reload) @@ -393,7 +404,7 @@ The GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sid **🐛 Fixes** - 🔗 **Aggregate double-mount auto-yield** ([#200](https://github.com/omdsh-dev/DSH-better-sidebar/pull/200)): when an aggregate package (e.g. dsh-web-ui-all) mounts the same package under its own entry id, the guard expression in `cordis.patch.yml` disables the plugin's own `better-sidebar` row so `/sidebar/api` is no longer registered twice (`duplicate prefix route` crashing the whole plugin tree / `dsh web`); standalone installs behave as before -- 🔧 **Peer dependencies aligned to DSH 0.1.0-rc.7** ([#207](https://github.com/omdsh-dev/DSH-better-sidebar/pull/207), fixes [#206](https://github.com/omdsh-dev/DSH-better-sidebar/issues/206)): all `@deepseek-ai/*` peer/devDependencies raised from `^0.1.0-rc.6` to `^0.1.0-rc.7`, and the CI mount lane pins the same version — eliminating the mixed rc.6/rc.7 dependency tree (after the main framework moved to rc.7) that surfaced as `agent-presets: refusing to compose an unscoped context` when picking a model / sending a message +- 🔧 **Adapted to DSH 0.1.0-rc.7** ([#207](https://github.com/omdsh-dev/DSH-better-sidebar/pull/207), fixes [#206](https://github.com/omdsh-dev/DSH-better-sidebar/issues/206)): fixes the `agent-presets: refusing to compose an unscoped context` error when picking a model / sending a message after DSH moved to rc.7 ### v0.13.0 From 55ad7eec8d6b7e3ad9b0b261944172ecafe4fb3d Mon Sep 17 00:00:00 2001 From: Atlas Zeng Date: Sat, 22 Aug 2026 01:29:06 +0800 Subject: [PATCH 03/21] Update README.md --- README.md | 88 +++++++++++++------------------------------------------ 1 file changed, 20 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index b26a424..99ab2b3 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ ## ✨ 功能一览 -- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown(含 Mermaid 图表,strict 安全渲染 + 点击放大)/ HTML / PDF / Office 内联预览 +- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown(含 Mermaid 图表,strict 安全渲染 + 点击放大)/ HTML / PDF - **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整) - **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具 - **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存 / 提交 / 还原 @@ -153,126 +153,78 @@ dsh registry enable dsh-external/dsh-better-sidebar ### 🗂️ 文件工作台:资源管理器 +支持两种格式的资源管理器:内嵌在文件预览中/独立显示文件树 懒加载目录树、软链接按目标类型展示(目录软链接可展开、失效链接标红)、全局文件名搜索、上传文件/文件夹与拖放上传、右键菜单(在新 Tab 打开 / 在侧边打开 / 复制路径)、悬浮 `@文件` 一键引用进输入框。 - +image +image -### ✍️ CodeMirror 编辑器 - -按路径新开或原地切换(可选模式)、预览/编辑/保存一体、20+ 语言语法高亮、`Ctrl/Cmd+S` 保存。 - - ### 📝 Markdown · 图片 · PDF 内联预览 Markdown 预览支持 **Mermaid 图表**(`securityLevel: 'strict'` 安全渲染 + 二次清洗;点击图表弹窗放大、滚轮缩放、拖拽平移);图片 / PDF 走媒体路由内联展示;Office 三件套由生态插件补齐。
- +image
- +image
### 💻 真实终端 xterm.js + node-pty 真实 shell(不是模拟器):断线重连 transcript 回放、shell / shellArgs 可配置(设置页或 `cordis.patch.yml`)、可选为模型注入 `terminal_*` 工具(agent 可直接开终端跑命令)。 - +image + ### 🌿 Git 面板 -暂存 / 取消暂存 / 提交(`Ctrl+Enter`)/ 还原,历史列表;点击改动文件打开 **VSCode 式 diff tab**(红绿行级对比)。push/pull/fetch 由生态插件 [dsh-git-remotes](https://github.com/yq04/dsh-git-remotes) / [dsh-git-forge](https://github.com/thirsty5034/dsh-git-forge) 补齐。 +暂存 / 取消暂存 / 提交(`Ctrl+Enter`)/ 还原,历史列表;点击改动文件打开 **VSCode 式 diff tab**(红绿行级对比)。 - - - +image ### 🌐 内嵌浏览器 多开网页 tab:后退 / 前进 / 刷新 / 地址栏;内容运行在**不透明源沙箱 iframe**(界面实时显示沙箱状态,可按页面临时解锁);聊天里的外链点击可被接管到侧边栏打开(按协议分流,可配)。 - +image + ### 🧩 任务页:子代理拓扑 + 后台任务 子代理树实时拓扑(运行状态、批量实时预览)+ 后台任务清单(退出码 / 实时输出 / 强制终止);新子代理 / 新任务可自动展开侧边栏(可关)。 - +image + ### 💬 侧边对话(beta) Codex 风格侧边线程:**每个对话一个独立 Tab**;线程继承主会话完整上下文(含进行中回合,以 interrupted 诚实冻结)独立运行,不污染主会话;可持续追问、重启冷恢复;一键「保存为新会话」提升为顶层会话。 - +image + ### 🪟 双工作台:右侧栏 + 底部面板 + 分栏 右侧栏与底部面板可同时展开;拖 Tab 到分栏边缘**拆分**、拖到中间**合并**(可跨面板);面板宽高左缘/上缘拖拽调节;移动端自动合并为全宽抽屉。 - - - +dsh-better-sidebar 工作台截图 ### ⚙️ 声明式设置 设置页「侧边卡片」分区:每个 tab / 预览器一张小卡片,独立开关(高亮启用态 + 品牌开关滑块);二级设置经卡片底部「功能设置」条弹窗(开关 / 文本 / 数字 / 下拉);插件自有设置持久化在 `pluginSettings`。 - +image + ### 📱 移动端 窄屏(<768px)自动切换为全宽抽屉:底栏 tab 一次性并入右侧栏,触屏拖拽可调。
- +image
## 🌐 插件生态 From 646c91cf70883e9a6cd343d176b0c4939c42bc2c Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Sat, 22 Aug 2026 02:03:25 +0800 Subject: [PATCH 04/21] docs(readme): sync EN with uploaded screenshots, normalize image sizes, add Side Chat badge - Mirror the zh feature tour (user-uploaded CDN images) into README_EN.md: real screenshots for explorer/editor, markdown, image, terminal, git, browser, tasks, side chat, settings, mobile; drop manual-shot placeholders (git-diff, bottom-panel) and the CodeMirror subsection (folded into the explorer section) - Normalize image display size to width=880 (mobile 360) across both languages; remove the stale 'placeholders to come' note - Add the missing Side Chat badge to the hero feature-chip row (zh + en) - Mirror user's trim: 'Office' removed from the features bullet (Office preview is an ecosystem plugin), Git panel ecosystem sentence dropped --- README.md | 44 ++++++++---------------- README_EN.md | 96 ++++++++++------------------------------------------ 2 files changed, 32 insertions(+), 108 deletions(-) diff --git a/README.md b/README.md index 99ab2b3..63e9663 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ dshfind

支持的 DSH 版本:0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2 插件生态:GitHub topic dsh-better-sidebar

- 文件管理 编辑预览 内嵌浏览器 真实终端 Git 面板 后台任务 插件接入

+ 文件管理 编辑预览 内嵌浏览器 真实终端 Git 面板 后台任务 侧边对话 插件接入

右侧栏 + 底部面板双工作台,并把 ctx.betterSidebar 服务开放给所有插件——
通过 registerTab / registerFileViewer 注册新的侧边栏页面与文件预览器。 @@ -149,83 +149,69 @@ dsh registry enable dsh-external/dsh-better-sidebar ## 🖼️ 特性巡礼 -> 以下截图均为真实界面实拍(暗色主题)。占位图会陆续补齐。 +> 以下均为真实界面实拍。 ### 🗂️ 文件工作台:资源管理器 -支持两种格式的资源管理器:内嵌在文件预览中/独立显示文件树 -懒加载目录树、软链接按目标类型展示(目录软链接可展开、失效链接标红)、全局文件名搜索、上传文件/文件夹与拖放上传、右键菜单(在新 Tab 打开 / 在侧边打开 / 复制路径)、悬浮 `@文件` 一键引用进输入框。 - -image -image +支持两种格式的资源管理器:内嵌在文件预览中 / 独立显示文件树。懒加载目录树、软链接按目标类型展示(目录软链接可展开、失效链接标红)、全局文件名搜索、上传文件/文件夹与拖放上传、右键菜单(在新 Tab 打开 / 在侧边打开 / 复制路径)、悬浮 `@文件` 一键引用进输入框。 +
文件资源管理器
+
CodeMirror 代码编辑器
### 📝 Markdown · 图片 · PDF 内联预览 Markdown 预览支持 **Mermaid 图表**(`securityLevel: 'strict'` 安全渲染 + 二次清洗;点击图表弹窗放大、滚轮缩放、拖拽平移);图片 / PDF 走媒体路由内联展示;Office 三件套由生态插件补齐。 -
-image -
- -
-image -
+
Markdown + Mermaid 预览
+
图片内联预览
### 💻 真实终端 xterm.js + node-pty 真实 shell(不是模拟器):断线重连 transcript 回放、shell / shellArgs 可配置(设置页或 `cordis.patch.yml`)、可选为模型注入 `terminal_*` 工具(agent 可直接开终端跑命令)。 -image - +
真实终端
### 🌿 Git 面板 暂存 / 取消暂存 / 提交(`Ctrl+Enter`)/ 还原,历史列表;点击改动文件打开 **VSCode 式 diff tab**(红绿行级对比)。 -image +
Git 面板
### 🌐 内嵌浏览器 多开网页 tab:后退 / 前进 / 刷新 / 地址栏;内容运行在**不透明源沙箱 iframe**(界面实时显示沙箱状态,可按页面临时解锁);聊天里的外链点击可被接管到侧边栏打开(按协议分流,可配)。 -image - +
内嵌浏览器
### 🧩 任务页:子代理拓扑 + 后台任务 子代理树实时拓扑(运行状态、批量实时预览)+ 后台任务清单(退出码 / 实时输出 / 强制终止);新子代理 / 新任务可自动展开侧边栏(可关)。 -image - +
任务页:子代理拓扑
### 💬 侧边对话(beta) Codex 风格侧边线程:**每个对话一个独立 Tab**;线程继承主会话完整上下文(含进行中回合,以 interrupted 诚实冻结)独立运行,不污染主会话;可持续追问、重启冷恢复;一键「保存为新会话」提升为顶层会话。 -image - +
侧边对话(beta)
### 🪟 双工作台:右侧栏 + 底部面板 + 分栏 右侧栏与底部面板可同时展开;拖 Tab 到分栏边缘**拆分**、拖到中间**合并**(可跨面板);面板宽高左缘/上缘拖拽调节;移动端自动合并为全宽抽屉。 -dsh-better-sidebar 工作台截图 +
双工作台(右侧栏 + 底部面板)
### ⚙️ 声明式设置 设置页「侧边卡片」分区:每个 tab / 预览器一张小卡片,独立开关(高亮启用态 + 品牌开关滑块);二级设置经卡片底部「功能设置」条弹窗(开关 / 文本 / 数字 / 下拉);插件自有设置持久化在 `pluginSettings`。 -image - +
声明式设置:侧边卡片
### 📱 移动端 窄屏(<768px)自动切换为全宽抽屉:底栏 tab 一次性并入右侧栏,触屏拖拽可调。 -
-image -
+
移动端全宽抽屉
## 🌐 插件生态 diff --git a/README_EN.md b/README_EN.md index 332b51e..4b6b17a 100644 --- a/README_EN.md +++ b/README_EN.md @@ -11,7 +11,7 @@ dshfind

Supported DSH versions: 0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2 Plugin ecosystem: GitHub topic dsh-better-sidebar

- File management Edit & preview Embedded browser Real terminal Git panel Background tasks Plugin integration

+ File management Edit & preview Embedded browser Real terminal Git panel Background tasks Side Chat Plugin integration

A dual workbench (right sidebar + bottom panel) that opens its ctx.betterSidebar service to every plugin —
register new sidebar pages and file viewers via registerTab / registerFileViewer. @@ -40,7 +40,7 @@ ## ✨ Features -- **🗂️ File Workbench**: file explorer (lazy-loading tree; symlinks show their target kind — directory links expand, dangling links flagged) + CodeMirror editor; inline preview for images / Markdown (incl. Mermaid diagrams, strict-mode safe rendering + click-to-zoom) / HTML / PDF / Office +- **🗂️ File Workbench**: file explorer (lazy-loading tree; symlinks show their target kind — directory links expand, dangling links flagged) + CodeMirror editor; inline preview for images / Markdown (incl. Mermaid diagrams, strict-mode safe rendering + click-to-zoom) / HTML / PDF - **🌐 Embedded Browser**: multiple web tabs with back / forward / refresh; content runs in a sandboxed iframe; external links are routed by protocol by default — HTTP opens in the sidebar, HTTPS goes to the system browser (both adjustable in settings) - **💻 Real Terminal**: xterm.js + node-pty real shell, reconnect with transcript replay; optionally injects `terminal_*` tools for the model - **🌿 Git Panel**: real diff + VSCode-style diff tabs, history, right-click to stage / commit / revert @@ -149,131 +149,69 @@ Update: `git pull && pnpm install && pnpm build` → `node scripts/package-regis ## 🖼️ Feature Tour -> All screenshots below are taken from the real UI (dark theme). Placeholder slots will be filled in over time. +> Below are real UI screenshots. ### 🗂️ File Workbench: Explorer -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. +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. - - -### ✍️ CodeMirror Editor - -Open per path (new tab) or switch in place (opt-in merged mode), preview / edit / save in one window, syntax highlighting for 20+ languages, `Ctrl/Cmd+S` to save. - - +
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). push / pull / fetch are covered by the ecosystem plugins [dsh-git-remotes](https://github.com/yq04/dsh-git-remotes) and [dsh-git-forge](https://github.com/thirsty5034/dsh-git-forge). +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 From cdda0729738740c4b88095a9e884d4ed237889ad Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Sat, 22 Aug 2026 12:06:32 +0800 Subject: [PATCH 05/21] docs(readme): make first-run install succeed (pre-write allowBuilds before dsh plugin add) pnpm 11's strict-dep-builds blocks node-pty/protobufjs build scripts and makes the FIRST 'dsh plugin add' exit non-zero (dsh aborts on ERR_PNPM_IGNORED_BUILDS), forcing a second run after approve-builds. The install block now pre-writes allowBuilds (node-pty/protobufjs) and minimumReleaseAgeExclude (dsh-better-sidebar) into the profile's pnpm-workspace.yaml with the same idempotent merge as scripts/install.sh ensure_workspace_settings, so the first add succeeds. Verified in a scratch DSH_HOME: preflight -> add exit 0 (node-pty postinstall ran), rerun unchanged (no duplicate keys), second add exit 0; merge branch tested when allowBuilds already exists. Paste-to-DSH prompt updated to match. --- README.md | 29 ++++++++++++++++++++++++++--- README_EN.md | 30 +++++++++++++++++++++++++++--- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 63e9663..3676f73 100644 --- a/README.md +++ b/README.md @@ -62,8 +62,31 @@ 支持的 DSH 版本:0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2 ```sh +# pnpm 11 默认拦截构建脚本(node-pty / protobufjs),会让首次 add 失败; +# 先把构建许可预写进 profile 的 pnpm-workspace.yaml(幂等,可反复执行) +node -e ' +const f = require("fs"), p = process.argv[1]; +let t = f.readFileSync(p, "utf8"); +t = t.replace(/^(\s*)(node-pty|protobufjs):.*$/gm, "$1$2: true"); +if (!/^\s*allowBuilds:\s*$/m.test(t)) { + t += "\nallowBuilds:\n node-pty: true\n protobufjs: true\n"; +} else { + for (const k of ["node-pty", "protobufjs"]) { + if (!new RegExp("^\\s*" + k + ":\\s*true\\s*$", "m").test(t)) { + t = t.replace(/^(\s*allowBuilds:\s*)$/m, "$1\n " + k + ": true"); + } + } +} +if (!/^\s*-\s+dsh-better-sidebar\s*$/m.test(t)) { + if (/^\s*minimumReleaseAgeExclude:\s*$/m.test(t)) { + t = t.replace(/^(\s*minimumReleaseAgeExclude:\s*)$/m, "$1\n - dsh-better-sidebar"); + } else { + t += "\nminimumReleaseAgeExclude:\n - dsh-better-sidebar\n"; + } +} +f.writeFileSync(p, t); +' ~/.dsh/profiles/web/pnpm-workspace.yaml dsh plugin --profile web add dsh-better-sidebar@latest -cd ~/.dsh/profiles/web && pnpm approve-builds --all && pnpm rebuild node-pty # 终端功能依赖 node-pty 的预编译二进制:放行构建脚本并确保装好 ``` 装完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可看到侧边栏(DSH 对 client 改动热加载,无需重启;仅 host 半更新时需要重启)。 @@ -72,8 +95,8 @@ cd ~/.dsh/profiles/web && pnpm approve-builds --all && pnpm rebuild node-pty # ```text 帮我安装 dsh-better-sidebar 插件(DSH 侧边栏工作台),步骤: -1. 执行 dsh plugin --profile web add dsh-better-sidebar@latest -2. 在 ~/.dsh/profiles/web 下执行 pnpm approve-builds --all && pnpm rebuild node-pty(终端功能需要 node-pty 的预编译二进制) +1. 先用 node 把 node-pty / protobufjs 的构建许可预写进 ~/.dsh/profiles/web/pnpm-workspace.yaml(allowBuilds),保证首次安装不被 pnpm 11 拦截(可参考仓库 scripts/install.sh 的 ensure_workspace_settings) +2. 再执行 dsh plugin --profile web add dsh-better-sidebar@latest 3. 完成后提醒我硬刷新浏览器(Cmd/Ctrl+Shift+R) 遇到报错先查 https://github.com/omdsh-dev/DSH-better-sidebar README 的常见问题表。 ``` diff --git a/README_EN.md b/README_EN.md index 4b6b17a..1d38d99 100644 --- a/README_EN.md +++ b/README_EN.md @@ -62,8 +62,32 @@ Supported DSH versions: 0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2 ```sh +# pnpm 11 blocks build scripts (node-pty / protobufjs) by default, which makes +# the first `dsh plugin add` fail; pre-write the build approvals into the +# profile's pnpm-workspace.yaml (idempotent, safe to re-run) +node -e ' +const f = require("fs"), p = process.argv[1]; +let t = f.readFileSync(p, "utf8"); +t = t.replace(/^(\s*)(node-pty|protobufjs):.*$/gm, "$1$2: true"); +if (!/^\s*allowBuilds:\s*$/m.test(t)) { + t += "\nallowBuilds:\n node-pty: true\n protobufjs: true\n"; +} else { + for (const k of ["node-pty", "protobufjs"]) { + if (!new RegExp("^\\s*" + k + ":\\s*true\\s*$", "m").test(t)) { + t = t.replace(/^(\s*allowBuilds:\s*)$/m, "$1\n " + k + ": true"); + } + } +} +if (!/^\s*-\s+dsh-better-sidebar\s*$/m.test(t)) { + if (/^\s*minimumReleaseAgeExclude:\s*$/m.test(t)) { + t = t.replace(/^(\s*minimumReleaseAgeExclude:\s*)$/m, "$1\n - dsh-better-sidebar"); + } else { + t += "\nminimumReleaseAgeExclude:\n - dsh-better-sidebar\n"; + } +} +f.writeFileSync(p, t); +' ~/.dsh/profiles/web/pnpm-workspace.yaml dsh plugin --profile web add dsh-better-sidebar@latest -cd ~/.dsh/profiles/web && pnpm approve-builds --all && pnpm rebuild node-pty # the terminal needs node-pty's prebuilt binary: allow build scripts and make sure it is installed ``` Then **hard-refresh the browser** (Cmd/Ctrl+Shift+R) to see the sidebar (DSH hot-reloads client changes; only host-half updates need a restart). @@ -72,8 +96,8 @@ Then **hard-refresh the browser** (Cmd/Ctrl+Shift+R) to see the sidebar (DSH hot ```text Install the dsh-better-sidebar plugin (a sidebar workbench for DSH): -1. Run: dsh plugin --profile web add dsh-better-sidebar@latest -2. In ~/.dsh/profiles/web run: pnpm approve-builds --all && pnpm rebuild node-pty (the terminal feature needs node-pty's prebuilt binary) +1. First pre-write build approvals for node-pty / protobufjs into ~/.dsh/profiles/web/pnpm-workspace.yaml (allowBuilds) so the first install is not blocked by pnpm 11 (see ensure_workspace_settings in the repo's scripts/install.sh) +2. Then run: dsh plugin --profile web add dsh-better-sidebar@latest 3. When done, remind me to hard-refresh the browser (Cmd/Ctrl+Shift+R) If anything fails, check the troubleshooting table in the README at https://github.com/omdsh-dev/DSH-better-sidebar ``` From 7dbd9b75e2fd65758d4e55f750319399b91255a2 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Sat, 22 Aug 2026 12:10:24 +0800 Subject: [PATCH 06/21] docs(readme): simplify install to add -> approve-builds -> add The node one-liner prewrite was overkill for the README. Plain double 'add' does NOT work (both runs exit 1 on ERR_PNPM_IGNORED_BUILDS, verified in scratch); the working minimal flow is add (fails, dep written) -> pnpm approve-builds --all (re-runs install scripts) -> add (succeeds, bundle registered). Verified end-to-end in a scratch profile. --- README.md | 35 +++++++---------------------------- README_EN.md | 36 +++++++----------------------------- 2 files changed, 14 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 3676f73..7258fc3 100644 --- a/README.md +++ b/README.md @@ -62,31 +62,9 @@ 支持的 DSH 版本:0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2 ```sh -# pnpm 11 默认拦截构建脚本(node-pty / protobufjs),会让首次 add 失败; -# 先把构建许可预写进 profile 的 pnpm-workspace.yaml(幂等,可反复执行) -node -e ' -const f = require("fs"), p = process.argv[1]; -let t = f.readFileSync(p, "utf8"); -t = t.replace(/^(\s*)(node-pty|protobufjs):.*$/gm, "$1$2: true"); -if (!/^\s*allowBuilds:\s*$/m.test(t)) { - t += "\nallowBuilds:\n node-pty: true\n protobufjs: true\n"; -} else { - for (const k of ["node-pty", "protobufjs"]) { - if (!new RegExp("^\\s*" + k + ":\\s*true\\s*$", "m").test(t)) { - t = t.replace(/^(\s*allowBuilds:\s*)$/m, "$1\n " + k + ": true"); - } - } -} -if (!/^\s*-\s+dsh-better-sidebar\s*$/m.test(t)) { - if (/^\s*minimumReleaseAgeExclude:\s*$/m.test(t)) { - t = t.replace(/^(\s*minimumReleaseAgeExclude:\s*)$/m, "$1\n - dsh-better-sidebar"); - } else { - t += "\nminimumReleaseAgeExclude:\n - dsh-better-sidebar\n"; - } -} -f.writeFileSync(p, t); -' ~/.dsh/profiles/web/pnpm-workspace.yaml -dsh plugin --profile web add dsh-better-sidebar@latest +dsh plugin --profile web add dsh-better-sidebar@latest # 首次会因 pnpm 11 拦截 node-pty 构建脚本而失败(依赖已写入) +cd ~/.dsh/profiles/web && pnpm approve-builds --all # 放行构建脚本(自动重跑安装) +dsh plugin --profile web add dsh-better-sidebar@latest # 重跑即成功 ``` 装完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可看到侧边栏(DSH 对 client 改动热加载,无需重启;仅 host 半更新时需要重启)。 @@ -95,9 +73,10 @@ dsh plugin --profile web add dsh-better-sidebar@latest ```text 帮我安装 dsh-better-sidebar 插件(DSH 侧边栏工作台),步骤: -1. 先用 node 把 node-pty / protobufjs 的构建许可预写进 ~/.dsh/profiles/web/pnpm-workspace.yaml(allowBuilds),保证首次安装不被 pnpm 11 拦截(可参考仓库 scripts/install.sh 的 ensure_workspace_settings) -2. 再执行 dsh plugin --profile web add dsh-better-sidebar@latest -3. 完成后提醒我硬刷新浏览器(Cmd/Ctrl+Shift+R) +1. 执行 dsh plugin --profile web add dsh-better-sidebar@latest(首次会被 pnpm 11 拦截 node-pty 构建脚本而失败,属正常) +2. 在 ~/.dsh/profiles/web 下执行 pnpm approve-builds --all(放行构建脚本,会自动重跑安装) +3. 再次执行 dsh plugin --profile web add dsh-better-sidebar@latest +4. 完成后提醒我硬刷新浏览器(Cmd/Ctrl+Shift+R) 遇到报错先查 https://github.com/omdsh-dev/DSH-better-sidebar README 的常见问题表。 ``` diff --git a/README_EN.md b/README_EN.md index 1d38d99..2d600cb 100644 --- a/README_EN.md +++ b/README_EN.md @@ -62,32 +62,9 @@ Supported DSH versions: 0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2 ```sh -# pnpm 11 blocks build scripts (node-pty / protobufjs) by default, which makes -# the first `dsh plugin add` fail; pre-write the build approvals into the -# profile's pnpm-workspace.yaml (idempotent, safe to re-run) -node -e ' -const f = require("fs"), p = process.argv[1]; -let t = f.readFileSync(p, "utf8"); -t = t.replace(/^(\s*)(node-pty|protobufjs):.*$/gm, "$1$2: true"); -if (!/^\s*allowBuilds:\s*$/m.test(t)) { - t += "\nallowBuilds:\n node-pty: true\n protobufjs: true\n"; -} else { - for (const k of ["node-pty", "protobufjs"]) { - if (!new RegExp("^\\s*" + k + ":\\s*true\\s*$", "m").test(t)) { - t = t.replace(/^(\s*allowBuilds:\s*)$/m, "$1\n " + k + ": true"); - } - } -} -if (!/^\s*-\s+dsh-better-sidebar\s*$/m.test(t)) { - if (/^\s*minimumReleaseAgeExclude:\s*$/m.test(t)) { - t = t.replace(/^(\s*minimumReleaseAgeExclude:\s*)$/m, "$1\n - dsh-better-sidebar"); - } else { - t += "\nminimumReleaseAgeExclude:\n - dsh-better-sidebar\n"; - } -} -f.writeFileSync(p, t); -' ~/.dsh/profiles/web/pnpm-workspace.yaml -dsh plugin --profile web add dsh-better-sidebar@latest +dsh plugin --profile web add dsh-better-sidebar@latest # first run fails: pnpm 11 blocks node-pty build scripts (the dependency is still written) +cd ~/.dsh/profiles/web && pnpm approve-builds --all # allow the build scripts (re-runs the install automatically) +dsh plugin --profile web add dsh-better-sidebar@latest # re-run succeeds ``` Then **hard-refresh the browser** (Cmd/Ctrl+Shift+R) to see the sidebar (DSH hot-reloads client changes; only host-half updates need a restart). @@ -96,9 +73,10 @@ Then **hard-refresh the browser** (Cmd/Ctrl+Shift+R) to see the sidebar (DSH hot ```text Install the dsh-better-sidebar plugin (a sidebar workbench for DSH): -1. First pre-write build approvals for node-pty / protobufjs into ~/.dsh/profiles/web/pnpm-workspace.yaml (allowBuilds) so the first install is not blocked by pnpm 11 (see ensure_workspace_settings in the repo's scripts/install.sh) -2. Then run: dsh plugin --profile web add dsh-better-sidebar@latest -3. When done, remind me to hard-refresh the browser (Cmd/Ctrl+Shift+R) +1. Run: dsh plugin --profile web add dsh-better-sidebar@latest (the first run fails because pnpm 11 blocks node-pty build scripts — that's expected) +2. In ~/.dsh/profiles/web run: pnpm approve-builds --all (allows the build scripts and re-runs the install) +3. Run the add command again: dsh plugin --profile web add dsh-better-sidebar@latest +4. When done, remind me to hard-refresh the browser (Cmd/Ctrl+Shift+R) If anything fails, check the troubleshooting table in the README at https://github.com/omdsh-dev/DSH-better-sidebar ``` From b7aab48deb17b7dd92094c84441783fccaf51561 Mon Sep 17 00:00:00 2001 From: Atlas Zeng Date: Sat, 22 Aug 2026 15:34:05 +0800 Subject: [PATCH 07/21] =?UTF-8?q?feat(sidechat):=20Codex=20=E9=A3=8E?= =?UTF-8?q?=E6=A0=BC=E8=BD=AC=E5=BD=95=E9=87=8D=E6=9E=84=20=E2=80=94=20?= =?UTF-8?q?=E9=A6=96=E9=97=AE=E6=98=BE=E7=A4=BA=20/=20=E6=B3=A8=E5=85=A5?= =?UTF-8?q?=E6=8B=86=E5=88=86=20/=20=E6=8A=98=E5=8F=A0=E8=A1=8C=20/=20?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=E5=8E=BB=E6=8A=96=20(#314)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sidechat): split context injection from the first user message The first composer message rode the same user/message event as the side boundary prompt (+ parked snapshot), and the transcript dropped that whole row by its boundary prefix — swallowing the user's first question. Deliver first contact as TWO messages: the boundary+snapshot rides agent.inject (queued next-step context, claimed first at the opening step, no wake) stamped with source kind 'plugin', and the question is the follow-up that wakes the driver. The log records injection and question as separate events; the transcript maps injections (plugin-sourced, or the legacy boundary-prefix blob of pre-split threads) onto a collapsible injection row and renders genuine user messages — the first one included — as user rows. boundaryDelivered still recognizes the injection by its unchanged boundary-prefix opening. * feat(sidechat): codex-style collapsible rows and creating shimmer Tool calls, thinking and context injections now share one quiet single-line chrome (chevron + label + a one-line argument summary) that expands into an indented body on a hairline thread — no cards or fills. Streaming labels and the creating hero shimmer (shimmer = generating); failed tools go danger; reduced-motion stills every loop. * fix(sidebar): debounce subagent auto-open past the title frame The host delivers a new child's origin and title in separate frames, so a Side Chat thread's first visible frame lacks its 'Side: ' label and tripped the 0→N auto-open — every thread creation popped the task page. The trigger now 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, half a second later. * fix(sidechat): render legacy threads' first message as its own bubble Threads created before the host split still log the boundary (+snapshot) and the question as ONE 'user' message; the transcript mapped that row wholly onto the injection row, so the user's first question only appeared inside the expansion. The boundary prompt is a known constant, so the legacy row now splits THERE: the injection row keeps the prompt, the remainder renders as the user's real message (a pre-split snapshot, when present, rides along — the common case is a pure question). Row React keys become kind:seq so the split's two rows never collide. --- src/client/SideChatView.module.css | 169 ++++++++++++++++++----------- src/client/SideChatView.tsx | 130 +++++++++++++++++----- src/client/Sidebar.tsx | 48 ++++++-- src/client/locales.ts | 2 + src/client/sidechat-transcript.ts | 77 +++++++++++-- src/sidechat-core.ts | 40 +++++-- src/sidechat-routes.ts | 27 ++++- tests/sidechat-core.spec.ts | 30 +++++ tests/sidechat-routes.spec.ts | 36 ++++-- tests/sidechat-transcript.spec.ts | 70 ++++++++++-- tests/subagent-detect.spec.ts | 27 +++++ 11 files changed, 516 insertions(+), 140 deletions(-) diff --git a/src/client/SideChatView.module.css b/src/client/SideChatView.module.css index d3178f8..040a9cd 100644 --- a/src/client/SideChatView.module.css +++ b/src/client/SideChatView.module.css @@ -2,10 +2,12 @@ * language mirrors the main conversation pane: right-aligned user bubbles * on --dsw-specific-bubble, full-width assistant markdown, a capsule * composer card on --dsw-specific-input-major with a circular accent - * send/stop button, and shimmer reserved for the running status line and - * executing tools. Motion is crossfade-only (≤200ms entrances, no - * springs); reduced-motion disables every loop. All values consume DSW - * tokens (fallbacks point at older alias tokens for rc-era hosts). */ + * send/stop button. Tool calls, thinking and context injections share one + * collapsible-line chrome (no cards); shimmer is reserved for generating + * states (running status line, streaming rows, the creating hero). Motion + * is crossfade-only (≤200ms entrances, no springs); reduced-motion disables + * every loop. All values consume DSW tokens (fallbacks point at older alias + * tokens for rc-era hosts). */ .sidechat { flex: 1; min-height: 0; @@ -172,82 +174,99 @@ overflow-wrap: anywhere; } -.sidechatReasoning { +/* ── collapsible context rows (tool / thinking / injection, Codex-style) ── + * One quiet line — chevron + label + one-line summary — that expands into + * an indented body hung on a hairline thread. No cards, no fills: hierarchy + * rides ink weight alone. Streaming rows shimmer the label (shimmer = + * generating); failed rows go danger; reduced-motion stills every loop. */ +.sidechatRow { align-self: stretch; } -.sidechatReasoningSummary { - display: inline-block; +.sidechatRowLine { + display: flex; + align-items: center; + gap: 6px; + padding: 1px 0; font: var(--dsw-font-xxs-12); color: var(--dsw-alias-label-tertiary); - cursor: pointer; - list-style: none; } -.sidechatReasoningSummary::-webkit-details-marker { +.sidechatRowSummary { + cursor: pointer; + list-style: none; + user-select: none; +} + +.sidechatRowSummary::-webkit-details-marker { display: none; } -.sidechatReasoningBody { - margin-top: 2px; - padding-left: 2px; +.sidechatRowSummary:hover { + color: var(--dsw-alias-label-secondary); +} + +.sidechatRowStatic { + cursor: default; +} + +.sidechatRowChevron { + flex: none; + display: inline-flex; + align-items: center; + transition: transform 100ms ease-out; +} + +.sidechatRow[open] .sidechatRowChevron { + transform: rotate(90deg); +} + +.sidechatRowLabel { + flex: none; + max-width: 60%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--dsw-alias-label-secondary); +} + +.sidechatRowMono { + font-family: var(--dsw-font-mono); +} + +.sidechatRowMeta { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--dsw-font-mono); + color: var(--dsw-alias-label-tertiary); +} + +.sidechatRowFailed .sidechatRowLabel, +.sidechatRowFailed .sidechatRowMeta { + color: var(--dsw-alias-danger); +} + +.sidechatRowBody { + margin: 2px 0 4px 7px; + padding: 2px 0 2px 10px; + border-left: 1px solid var(--dsw-alias-hairline); +} + +.sidechatRowProse { font: var(--dsw-font-xxs-12); color: var(--dsw-alias-label-tertiary); white-space: pre-wrap; overflow-wrap: anywhere; + max-height: 240px; + overflow-y: auto; } -.sidechatTool { - align-self: stretch; - border: 1px solid var(--dsw-alias-hairline); - border-radius: 10px; - background: var(--dsw-alias-bg-base); - overflow: hidden; -} - -.sidechatToolSummary { - display: flex; - align-items: center; - gap: 6px; - padding: 5px 10px; - font: var(--dsw-font-xxs-12); - color: var(--dsw-alias-label-secondary); - cursor: pointer; - list-style: none; -} - -.sidechatToolSummary::-webkit-details-marker { - display: none; -} - -.sidechatToolName { - font-family: var(--dsw-font-mono); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -/* Running tool: a light sweep across the summary row (shimmer = generating). */ -.sidechatToolExecuting { - background-image: linear-gradient( - 90deg, - transparent 0%, - var(--dsw-alias-interactive-bg-hover) 50%, - transparent 100% - ); - background-size: 200% 100%; - animation: sidechatSweep 2.6s linear infinite; -} - -.sidechatToolFailed { - color: var(--dsw-alias-danger); -} - -.sidechatToolArgs, -.sidechatToolResult { +.sidechatRowCode { margin: 0; - padding: 6px 10px; - border-top: 1px solid var(--dsw-alias-hairline); + padding: 4px 0; font: var(--dsw-font-xxs-12); font-family: var(--dsw-font-mono); color: var(--dsw-alias-label-secondary); @@ -257,6 +276,25 @@ overflow-y: auto; } +.sidechatRowCode + .sidechatRowCode { + border-top: 1px solid var(--dsw-alias-hairline); +} + +/* Shimmer sweep text: generating (streaming row labels, creating hero). */ +.sidechatShimmerText { + background-image: linear-gradient( + 90deg, + var(--dsw-alias-label-tertiary) 0%, + var(--dsw-alias-label-primary) 50%, + var(--dsw-alias-label-tertiary) 100% + ); + background-size: 200% 100%; + -webkit-background-clip: text; + background-clip: text; + color: transparent; + animation: sidechatSweep 2.6s linear infinite; +} + /* ── running status line (above the composer, main-pane style) ──────── */ .sidechatStatus { flex: none; @@ -388,9 +426,12 @@ animation: none; } .sidechatStatusText, - .sidechatToolExecuting { + .sidechatShimmerText { animation: none; background-image: none; color: var(--dsw-alias-label-tertiary); } + .sidechatRowChevron { + transition: none; + } } diff --git a/src/client/SideChatView.tsx b/src/client/SideChatView.tsx index 446b366..5fe9f0f 100644 --- a/src/client/SideChatView.tsx +++ b/src/client/SideChatView.tsx @@ -24,6 +24,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useSyncExternalStore } from 'react' import clsx from 'clsx' import { + IconChevronRightOutline14, IconNewChatOutline16, IconPlusOutline16, IconSendOutline16, @@ -43,7 +44,7 @@ import { threadTrailingPending, type SidechatThreadInfo, } from '../sidechat-core.ts' -import { collectOwnEvents, transcriptRows, type SidechatTranscriptRow } from './sidechat-transcript.ts' +import { collectOwnEvents, toolArgsSummary, transcriptRows, type SidechatTranscriptRow } from './sidechat-transcript.ts' import { api } from './api.ts' import { t } from './locales.ts' import type { SessionScope } from './api.ts' @@ -102,6 +103,7 @@ interface RowLabels { copyLabel: string copiedLabel: string thinkLabel: string + injectionLabel: string } /** Merge history entries by event seq (newest wins), log order preserved. */ @@ -122,48 +124,112 @@ function threadDisplayTitle(title: string): string { return title.startsWith(SIDE_LABEL_PREFIX) ? title.slice(SIDE_LABEL_PREFIX.length) : title } +/** + * One collapsible context row — the shared Codex-style chrome of tool + * calls, thinking and context injections: a single quiet line (chevron + + * label + one-line summary) that expands into an indented body hung on a + * hairline thread. Rows with nothing to reveal render as a static line. + */ +function CollapsibleRow(props: { + label: string + meta?: string + mono?: boolean + streaming?: boolean + failed?: boolean + children?: React.ReactNode +}): React.ReactNode { + const label = ( + + {props.label} + + ) + const meta = props.meta !== undefined && props.meta !== '' + ? {props.meta} + : null + if (props.children === undefined) { + return ( +
+ {label} + {meta} +
+ ) + } + return ( +
+ + + + + {label} + {meta} + +
{props.children}
+
+ ) +} + /** One row renderer (React keys ride the source event seq). */ function renderRow(row: SidechatTranscriptRow, labels: RowLabels): React.ReactNode { switch (row.kind) { case 'user': return ( -
+
) case 'assistant': return ( -
+
) case 'reasoning': return ( -
- {labels.thinkLabel} -
{row.text}
-
+ +
{row.text}
+
) - case 'tool': + case 'injection': return ( -
- - - {row.name} - - {row.args !== undefined &&
{row.args}
} - {row.resultText !== undefined &&
{row.resultText}
} -
+ +
{row.text}
+
) + case 'tool': { + const body = ( + <> + {row.args !== undefined &&
{row.args}
} + {row.resultText !== undefined &&
{row.resultText}
} + + ) + return ( + + ) + } } } @@ -176,7 +242,12 @@ export function SideChatView(props: { }): React.ReactNode { const { ctx, scope, tab, visible } = props const rowLabels = useMemo( - () => ({ copyLabel: t('copy'), copiedLabel: t('copied'), thinkLabel: t('sideChatThink') }), + () => ({ + copyLabel: t('copy'), + copiedLabel: t('copied'), + thinkLabel: t('sideChatThink'), + injectionLabel: t('sideChatInjection'), + }), [], ) @@ -448,7 +519,12 @@ export function SideChatView(props: {
-
+
{busy === 'starting' ? t('sideChatCreating') : t('sideChatEmpty')}
{t('sideChatEmptyDesc')}
diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 5684db7..b613254 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -62,6 +62,16 @@ import css from './sidebar.module.css' * (mirror of the terminal view's own cap; the loop restarts on session switch). */ const FAILURE_LIMIT = 3 +/** + * Subagent auto-open debounce (ms). The host delivers a new child's origin + * and its title in SEPARATE frames: a Side Chat thread's first visible + * frame still shows a fallback title (no 'Side: ' prefix), so an immediate + * 0→N decision mistakes it for a genuine subagent and pops the task page. + * The trigger therefore re-evaluates against the live snapshot once the + * title frame has had time to land. + */ +const AUTO_OPEN_DEBOUNCE_MS = 500 + /** * OS file drags over the sidebar belong to the sidebar, not to the chat: * DSH's composer (InputBar) listens for file drags on the DOCUMENT and @@ -395,23 +405,45 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { * Switching to a session that already has subagents never triggers — its * baseline starts at the current count — so a deliberate layout is never * fought. + * + * The decision is DEBOUNCED (AUTO_OPEN_DEBOUNCE_MS): a Side Chat thread + * is also a subagent-origin child, and its 'Side: ' title lands one frame + * after its origin — an immediate check would misread that first frame as + * a new subagent and pop this page on every thread creation. The timer + * re-evaluates the ORIGINAL baseline against the live snapshot; by then + * the title filter (isSideThreadSummary) sees the settled label. */ const listBaselineRef = useRef(undefined) + const autoOpenPendingRef = useRef<{ baseline: SidebarSessionList; timer: number } | null>(null) useEffect(() => { const prev = listBaselineRef.current listBaselineRef.current = sessionList if (sessionId === undefined || prev === undefined) return + if (autoOpenPendingRef.current !== null) return if (!detectNewDirectSubagent(prev, sessionList, sessionId)) return - if (!store.getPrefs().autoOpenSubagent) return - if (ctx.betterSidebar?.isTabEnabled('subagent') === false) return - store.reduce(s => s.panelOpen ? s : togglePanel(s)) - // Pin the landing to the right panel: the auto-opened Subagent page must - // appear where the panel just expanded, not in a bottom-panel pane the - // user last touched. - store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id })) - ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') }) + const baseline = prev + const timer = window.setTimeout(() => { + autoOpenPendingRef.current = null + if (!detectNewDirectSubagent(baseline, ctx.sessions.list.getSnapshot(), sessionId)) return + if (!store.getPrefs().autoOpenSubagent) return + if (ctx.betterSidebar?.isTabEnabled('subagent') === false) return + store.reduce(s => s.panelOpen ? s : togglePanel(s)) + // Pin the landing to the right panel: the auto-opened Subagent page must + // appear where the panel just expanded, not in a bottom-panel pane the + // user last touched. + store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id })) + ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') }) + }, AUTO_OPEN_DEBOUNCE_MS) + autoOpenPendingRef.current = { baseline, timer } }, [sessionList, sessionId, store, ctx]) + // A session switch (or unmount) voids any armed auto-open recheck. + useEffect(() => () => { + const pending = autoOpenPendingRef.current + if (pending !== null) window.clearTimeout(pending.timer) + autoOpenPendingRef.current = null + }, [sessionId]) + /** * Job auto-activation: the moment a NEW background job appears for the * current conversation (a job id the previous snapshot lacked), the diff --git a/src/client/locales.ts b/src/client/locales.ts index 8a9bbc7..fdffc7f 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -264,6 +264,7 @@ export const zh = { sideChatComposerPlaceholder: '追问…', sideChatThinking: '正在深入…', sideChatThink: '思考过程', + sideChatInjection: '已注入上下文', sideChatSend: '发送', sideChatCancel: '停止', sideChatCancelTitle: '中止当前回合(保留队列)', @@ -570,6 +571,7 @@ export const en: Record = { sideChatComposerPlaceholder: 'Ask a follow-up…', sideChatThinking: 'Deep diving…', sideChatThink: 'Thinking', + sideChatInjection: 'Context injected', sideChatSend: 'Send', sideChatCancel: 'Stop', sideChatCancelTitle: 'Abort the running turn (queued work is kept)', diff --git a/src/client/sidechat-transcript.ts b/src/client/sidechat-transcript.ts index dbef065..37ddbb1 100644 --- a/src/client/sidechat-transcript.ts +++ b/src/client/sidechat-transcript.ts @@ -5,8 +5,9 @@ * * A thread child's log starts with the ENTIRE inherited parent log as its * fork seed. The mapping therefore cuts everything up to the LAST - * `session/end-seed` marker and drops the "Side conversation boundary" - * prompt row, so the view shows only the thread's own conversation. + * `session/end-seed` marker and maps context injections (the "Side + * conversation boundary" prompt, plugin-sourced context) onto a collapsible + * injection row, so the view shows only the thread's own conversation. * * Live streaming: `assistant/message` events only land when a step * completes, but `assistant/chunk` events stream token-level text and @@ -14,7 +15,7 @@ * them with the assembled message once it lands (settled rows). */ import type { SidebarHistoryEntry } from '../context-types.ts' -import { SIDE_BOUNDARY_PREFIX } from '../sidechat-core.ts' +import { isContextInjectionMessage, SIDE_BOUNDARY_PROMPT } from '../sidechat-core.ts' /** One compact transcript row rendered in the thread view. `seq` is the * source event's log sequence — stable row identity for React keys across @@ -22,6 +23,10 @@ import { SIDE_BOUNDARY_PREFIX } from '../sidechat-core.ts' * rows). */ export type SidechatTranscriptRow = | { kind: 'user'; seq: number; text: string } + /** A context injection (the side boundary prompt + the parked in-progress + * snapshot, or any plugin-sourced context): rendered as one collapsible + * row, never as a user bubble. */ + | { kind: 'injection'; seq: number; text: string } /** `settled` distinguishes an assembled message from a still-streaming * chunk accumulation (streaming rows are superseded by the settle). */ | { kind: 'assistant'; seq: number; text: string; settled: boolean } @@ -54,6 +59,39 @@ export function blockText(content: readonly unknown[]): string { return text === '' ? '…' : text } +/** Cap for a tool row's one-line argument summary (display only). */ +const ARGS_SUMMARY_MAX = 80 + +/** The most identifying argument keys, in priority order (bash's command, + * fs tools' paths, search's pattern, …). */ +const ARGS_SUMMARY_KEYS = ['command', 'file_path', 'path', 'pattern', 'query', 'url', 'prompt'] as const + +function flatTruncate(text: string): string { + const flat = text.replace(/\s+/g, ' ').trim() + return flat.length > ARGS_SUMMARY_MAX ? `${flat.slice(0, ARGS_SUMMARY_MAX - 1)}…` : flat +} + +/** + * One-line summary of a tool call's raw arguments JSON for the collapsed + * row: the first identifying string field when the JSON parses, else the + * flattened raw text; empty when there is nothing worth showing. + */ +export function toolArgsSummary(args: string | undefined): string { + if (args === undefined) return '' + try { + const parsed = JSON.parse(args) as Record | null + if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) { + for (const key of ARGS_SUMMARY_KEYS) { + const value = parsed[key] + if (typeof value === 'string' && value.trim() !== '') return flatTruncate(value) + } + } + } catch { + // Raw text fallthrough. + } + return flatTruncate(args) +} + /** The plain text of a tool/result message (text blocks inside its * `tool-result` content block). */ function resultTextOf(data: Record): string { @@ -141,12 +179,12 @@ export async function collectOwnEvents( /** * Map a thread child's history rows onto compact transcript rows: the - * inherited fork seed is cut at the last `session/end-seed`, the boundary - * prompt row is dropped, `assistant/chunk` deltas accumulate into streaming - * rows per (turn, step, block) and are superseded by the assembled - * `assistant/message`, and tool invocations render one expandable line each - * (arguments, paired result text, failure marker; a still-executing call is - * marked until its result lands). + * inherited fork seed is cut at the last `session/end-seed`, context + * injections map onto a collapsible injection row, `assistant/chunk` + * deltas accumulate into streaming rows per (turn, step, block) and are + * superseded by the assembled `assistant/message`, and tool invocations + * render one expandable line each (arguments, paired result text, failure + * marker; a still-executing call is marked until its result lands). * @param entries - history rows (event + host-computed view) in seq order. * @returns display rows in log order. */ @@ -166,7 +204,26 @@ export function transcriptRows(entries: readonly SidebarHistoryEntry[]): Sidecha switch (event.type) { case 'user/message': { const text = blockText(Array.isArray(data.content) ? data.content : []) - if (text.startsWith(SIDE_BOUNDARY_PREFIX)) break + // Context injections (the boundary prompt + snapshot, plugin-sourced + // context) collapse into an injection row; genuine user messages — + // including the FIRST one, which the host now delivers as its own + // event — render as user rows. + if (isContextInjectionMessage(data)) { + const source = data.source as { kind?: unknown } | undefined + // Threads logged BEFORE the host split carry boundary(+snapshot)+ + // question in ONE 'user' message. The boundary prompt is a known + // constant, so the message splits THERE: the injection row keeps + // the prompt, the remainder (snapshot + question if any — pure + // question in the common case) renders as the user's real message. + if (source?.kind === 'user' && text.startsWith(`${SIDE_BOUNDARY_PROMPT}\n\n`)) { + rows.push({ kind: 'injection', seq: event.seq, text: SIDE_BOUNDARY_PROMPT }) + const body = text.slice(SIDE_BOUNDARY_PROMPT.length + 2) + if (body !== '') rows.push({ kind: 'user', seq: event.seq, text: body }) + break + } + rows.push({ kind: 'injection', seq: event.seq, text }) + break + } rows.push({ kind: 'user', seq: event.seq, text }) break } diff --git a/src/sidechat-core.ts b/src/sidechat-core.ts index e7fecf5..1171dd3 100644 --- a/src/sidechat-core.ts +++ b/src/sidechat-core.ts @@ -35,6 +35,11 @@ export const LABEL_MAX_CHARS = 48 * the two plugins' threads render consistently in either UI). */ export const SIDE_BOUNDARY_PREFIX = 'Side conversation boundary' +/** The plugin identity stamped on the source of context-injection messages + * (boundary prompt + parked snapshot), so the transcript recognizes them + * structurally — not by text prefix. */ +export const SIDE_INJECTION_PLUGIN = 'dsh-better-sidebar' + /** * The boundary prompt delivered as the thread's first user message: the * inherited seed is reference context only, never active instruction. @@ -365,18 +370,37 @@ export function sideLabel(question: string): string { export function boundaryDelivered(events: readonly SidechatLogEvent[]): boolean { for (const event of events) { if (event.type !== 'user/message') continue - const content = dataOf(event).content - const first = Array.isArray(content) ? content[0] : content - const text = typeof first === 'string' - ? first - : (typeof first === 'object' && first !== null && 'text' in first - ? String((first as { text: unknown }).text) - : '') - if (text.startsWith(SIDE_BOUNDARY_PREFIX)) return true + if (messageLeadText(dataOf(event)).startsWith(SIDE_BOUNDARY_PREFIX)) return true } return false } +/** The leading text of a user/message's content (block array or bare string). */ +function messageLeadText(data: Record): string { + const content = data.content + const first = Array.isArray(content) ? content[0] : content + return typeof first === 'string' + ? first + : (typeof first === 'object' && first !== null && 'text' in first + ? String((first as { text: unknown }).text) + : '') +} + +/** + * Whether a logged user/message is a CONTEXT INJECTION (the boundary prompt + * plus the parked in-progress snapshot) rather than a real user message. + * New threads deliver the injection via `agent.inject` stamped with a + * non-'user' source kind; threads created before that split carry + * boundary+question in ONE 'user' message, recognized by the boundary + * prefix. Both render as one collapsible injection row — never as a user + * bubble. + */ +export function isContextInjectionMessage(data: Record): boolean { + const source = data.source as { kind?: unknown } | null | undefined + if (source?.kind !== undefined && source.kind !== 'user') return true + return messageLeadText(data).startsWith(SIDE_BOUNDARY_PREFIX) +} + /** The info the thread header shows (live runtime state + agent identity). */ export interface SidechatThreadInfo { /** A live agent drives the thread right now (false = cold/persisted). */ diff --git a/src/sidechat-routes.ts b/src/sidechat-routes.ts index 0535db4..c718de9 100644 --- a/src/sidechat-routes.ts +++ b/src/sidechat-routes.ts @@ -37,6 +37,7 @@ import { buildSidechatInheritance, resolvePresetId, SIDE_BOUNDARY_PROMPT, + SIDE_INJECTION_PLUGIN, SIDE_NEW_THREAD_TITLE, sideLabel, type SeedEvent, @@ -124,6 +125,26 @@ function admitFollowup(agent: Agent, blocks: ContentBlock[]): void { agent.followup(message) } +/** + * Deliver the thread's FIRST contact as TWO log-separated messages: the + * boundary prompt (+ the parked in-progress snapshot) rides `agent.inject` + * — queued model-facing context that does NOT wake the driver and is + * claimed FIRST at the opening step (Inbox.claim drains next-step before + * next-turn) — and the user's question is the follow-up that wakes it. The + * log therefore records two user/message events (injection, then question) + * instead of one wrapped blob: the transcript shows the question as a user + * bubble and collapses the injection as a context row. The injection source + * is stamped `kind: 'plugin'` so recognition is structural; its text still + * opens with SIDE_BOUNDARY_PREFIX, keeping boundaryDelivered intact. + */ +function admitFirstContact(agent: Agent, injectionText: string, question: string): void { + agent.inject(createUserMessage({ + content: textPrompt(injectionText), + source: { kind: 'plugin', plugin: SIDE_INJECTION_PLUGIN }, + })) + admitFollowup(agent, textPrompt(question)) +} + /** The live thread agent, or undefined (cold — the caller resumes). */ function liveThreadAgent(ctx: Context, childId: string): Agent | undefined { const agents = ctx.get('agents') as { get(id: string): Agent | undefined } | undefined @@ -217,8 +238,7 @@ export function buildSidechatApi(ctx: Context): SidechatRoutes { } else { const promptParts = [SIDE_BOUNDARY_PROMPT] if (inheritance.snapshot !== null) promptParts.push(inheritance.snapshot) - promptParts.push(question) - admitFollowup(handle.agent, textPrompt(promptParts.join('\n\n'))) + admitFirstContact(handle.agent, promptParts.join('\n\n'), question) pinTitle(sideLabel(question)) } return { childId } @@ -257,8 +277,7 @@ export function buildSidechatApi(ctx: Context): SidechatRoutes { const snapshot = pendingSnapshots.get(childId) pendingSnapshots.delete(childId) if (snapshot !== undefined) parts.push(snapshot) - parts.push(text) - admitFollowup(agent, textPrompt(parts.join('\n\n'))) + admitFirstContact(agent, parts.join('\n\n'), text) const titles = ctx.get('sessionTitle') as SidebarSessionTitleService | undefined if (titles !== undefined) { try { diff --git a/tests/sidechat-core.spec.ts b/tests/sidechat-core.spec.ts index 5ea39c4..1516d2a 100644 --- a/tests/sidechat-core.spec.ts +++ b/tests/sidechat-core.spec.ts @@ -12,12 +12,14 @@ import { buildOpenTurnSnapshot, buildSidechatInheritance, hasDanglingToolCall, + isContextInjectionMessage, resolvePresetId, sideLabel, sideThreadRows, threadHasCompletedTurn, threadTrailingPending, SIDE_BOUNDARY_PROMPT, + SIDE_INJECTION_PLUGIN, SIDE_LABEL_PREFIX, type SeedEvent, type SidechatLogEvent, @@ -333,3 +335,31 @@ describe('boundaryDelivered', () => { })])).toBe(true) }) }) + +describe('isContextInjectionMessage', () => { + it('recognizes plugin-stamped sources structurally', () => { + expect(isContextInjectionMessage({ + content: [{ type: 'text', text: 'runtime context' }], + source: { kind: 'plugin', plugin: SIDE_INJECTION_PLUGIN }, + })).toBe(true) + expect(isContextInjectionMessage({ + content: [{ type: 'text', text: 'q' }], + source: { kind: 'user' }, + })).toBe(false) + }) + + it('falls back to the boundary prefix for pre-split logs and sourceless rows', () => { + expect(isContextInjectionMessage({ + content: [{ type: 'text', text: `${SIDE_BOUNDARY_PROMPT}\n\nlegacy question` }], + source: { kind: 'user' }, + })).toBe(true) + expect(isContextInjectionMessage({ + content: `${SIDE_BOUNDARY_PROMPT}\n\nlegacy`, + })).toBe(true) + expect(isContextInjectionMessage({ + content: [{ type: 'text', text: 'ordinary question' }], + source: { kind: 'user' }, + })).toBe(false) + expect(isContextInjectionMessage({ content: [{ type: 'text', text: 'no source row' }] })).toBe(false) + }) +}) diff --git a/tests/sidechat-routes.spec.ts b/tests/sidechat-routes.spec.ts index 6f18879..749ed97 100644 --- a/tests/sidechat-routes.spec.ts +++ b/tests/sidechat-routes.spec.ts @@ -8,10 +8,10 @@ import { describe, expect, it, vi } from 'vitest' import { buildSidechatApi } from '../src/sidechat-routes.ts' import { SidebarError } from '../src/wire.ts' -import { SIDE_BOUNDARY_PROMPT, SIDE_NEW_THREAD_TITLE, sideLabel } from '../src/sidechat-core.ts' +import { SIDE_BOUNDARY_PROMPT, SIDE_INJECTION_PLUGIN, SIDE_NEW_THREAD_TITLE, sideLabel } from '../src/sidechat-core.ts' import type { Context } from '../src/context-types.ts' -/** A fake live agent (followup/cancel spied). */ +/** A fake live agent (inject/followup/cancel spied). */ function agent(id: string, over: { events?: unknown[]; header?: Record; provider?: string; model?: string } = {}) { return { id, @@ -22,6 +22,7 @@ function agent(id: string, over: { events?: unknown[]; header?: Record { agentProvider: 'test', agentModel: 'model-x', }) + // First contact is SPLIT: the boundary rides agent.inject (plugin-stamped + // context, no wake), the question is the follow-up that wakes the driver. + expect(child.inject).toHaveBeenCalledTimes(1) + const injection = child.inject.mock.calls[0]![0] as { content: Array<{ type: string; text: string }>; source: { kind: string; plugin: string } } + expect(injection.source).toEqual({ kind: 'plugin', plugin: SIDE_INJECTION_PLUGIN }) + expect(injection.content[0]!.text.startsWith(SIDE_BOUNDARY_PROMPT)).toBe(true) + expect(injection.content[0]!.text).not.toContain('explain the event flow') expect(child.followup).toHaveBeenCalledTimes(1) const message = child.followup.mock.calls[0]![0] as { content: Array<{ type: string; text: string }>; source: { kind: string } } expect(message.source).toEqual({ kind: 'user' }) - const promptText = message.content[0]!.text - expect(promptText.startsWith(SIDE_BOUNDARY_PROMPT)).toBe(true) - expect(promptText).toContain('explain the event flow') + expect(message.content[0]!.text).toBe('explain the event flow') expect(services.rename).toHaveBeenCalledWith(child.session, sideLabel('explain the event flow')) }) @@ -188,8 +194,11 @@ describe('sidechat.start', () => { const options = services.create.mock.calls[0]![0] as { seed: Array<{ type: string }> } // Cut BEFORE the open turn: only the pending user message is inherited. expect(options.seed.map(event => event.type)).toEqual(['user/message', 'subagent/descriptor']) + // The snapshot rides the injection, not the follow-up question. + const injection = child.inject.mock.calls[0]![0] as { content: Array<{ text: string }> } + expect(injection.content[0]!.text).toContain('`bash` (executing)') const message = child.followup.mock.calls[0]![0] as { content: Array<{ text: string }> } - expect(message.content[0]!.text).toContain('`bash` (executing)') + expect(message.content[0]!.text).toBe('what now?') }) it('creates an EMPTY thread on a blank question (Codex-style immediate create)', async () => { @@ -209,18 +218,22 @@ describe('sidechat.start', () => { // No prompt yet: the composer owns the first message; the placeholder // label is pinned and the in-progress snapshot is parked for it. + expect(child.inject).not.toHaveBeenCalled() expect(child.followup).not.toHaveBeenCalled() expect(services.rename).toHaveBeenCalledWith(child.session, SIDE_NEW_THREAD_TITLE) - // The first prompt carries the boundary + the parked snapshot and earns - // the thread its real label. + // The first prompt injects the boundary + the parked snapshot (split + // from the question) and earns the thread its real label. services.agents.get = vi.fn((id: unknown) => (id === childId ? child : parent)) await api['sidechat.prompt']({ childId, text: 'explain the event flow' }) + expect(child.inject).toHaveBeenCalledTimes(1) + const injection = child.inject.mock.calls[0]![0] as { content: Array<{ text: string }>; source: { kind: string } } + expect(injection.source.kind).toBe('plugin') + expect(injection.content[0]!.text.startsWith(SIDE_BOUNDARY_PROMPT)).toBe(true) + expect(injection.content[0]!.text).toContain('`bash` (executing)') expect(child.followup).toHaveBeenCalledTimes(1) const message = child.followup.mock.calls[0]![0] as { content: Array<{ text: string }> } - expect(message.content[0]!.text.startsWith(SIDE_BOUNDARY_PROMPT)).toBe(true) - expect(message.content[0]!.text).toContain('`bash` (executing)') - expect(message.content[0]!.text).toContain('explain the event flow') + expect(message.content[0]!.text).toBe('explain the event flow') expect(services.rename).toHaveBeenCalledWith(child.session, sideLabel('explain the event flow')) }) @@ -256,6 +269,7 @@ describe('sidechat.prompt', () => { const api = buildSidechatApi(ctxWith(services)) const result = await api['sidechat.prompt']({ childId: 'child', text: 'tell me more' }) expect(result).toEqual({ accepted: true }) + expect(child.inject).not.toHaveBeenCalled() expect(child.followup).toHaveBeenCalledTimes(1) const message = child.followup.mock.calls[0]![0] as { content: Array<{ text: string }> } expect(message.content[0]!.text).toBe('tell me more') diff --git a/tests/sidechat-transcript.spec.ts b/tests/sidechat-transcript.spec.ts index 5def836..06c8164 100644 --- a/tests/sidechat-transcript.spec.ts +++ b/tests/sidechat-transcript.spec.ts @@ -1,13 +1,14 @@ /** * Unit tests for the Side Chat transcript mapping (src/client/sidechat- - * transcript.ts): the seed cut at session/end-seed, the boundary-row drop, - * chunk streaming accumulation superseded by assembled messages, tool - * call/result pairing, and orphan failed results. + * transcript.ts): the seed cut at session/end-seed, context-injection rows + * (plugin-stamped sources and the legacy boundary-prefix blob), chunk + * streaming accumulation superseded by assembled messages, tool call/result + * pairing, and orphan failed results. */ import { describe, expect, it } from 'vitest' import type { SidebarHistoryEntry, SidebarSessionEvent } from '../src/context-types.ts' -import { SIDE_BOUNDARY_PREFIX } from '../src/sidechat-core.ts' -import { collectOwnEvents, transcriptRows, type SidechatTranscriptRow } from '../src/client/sidechat-transcript.ts' +import { SIDE_BOUNDARY_PREFIX, SIDE_BOUNDARY_PROMPT, SIDE_INJECTION_PLUGIN } from '../src/sidechat-core.ts' +import { collectOwnEvents, toolArgsSummary, transcriptRows, type SidechatTranscriptRow } from '../src/client/sidechat-transcript.ts' /** One history entry (event + optional view). */ function entry(event: SidebarSessionEvent): SidebarHistoryEntry { @@ -29,15 +30,48 @@ function textBlocks(...texts: string[]): unknown[] { } describe('transcriptRows', () => { - it('cuts the inherited seed at the last end-seed and drops the boundary row', () => { + it('cuts the inherited seed at the last end-seed and renders the boundary as an injection row', () => { const entries = [ entry(ev('user/message', 0, { content: textBlocks('inherited'), source: { kind: 'user' } })), entry(ev('session/end-seed', 1)), - entry(ev('user/message', 2, { content: textBlocks(`${SIDE_BOUNDARY_PREFIX}\n\nmode`), source: { kind: 'user' } })), + entry(ev('user/message', 2, { content: textBlocks(`${SIDE_BOUNDARY_PREFIX}\n\nmode`), source: { kind: 'plugin', plugin: SIDE_INJECTION_PLUGIN } })), entry(ev('user/message', 3, { content: textBlocks('the side question'), source: { kind: 'user' } })), ] const rows = transcriptRows(entries) - expect(rows).toEqual([{ kind: 'user', seq: 3, text: 'the side question' }]) + expect(rows).toEqual([ + { kind: 'injection', seq: 2, text: `${SIDE_BOUNDARY_PREFIX}\n\nmode` }, + { kind: 'user', seq: 3, text: 'the side question' }, + ]) + }) + + it('splits the LEGACY wrapped first message (boundary + question in one user row) at the boundary prompt', () => { + const entries = [ + entry(ev('session/end-seed', 0)), + entry(ev('user/message', 1, { + content: textBlocks(`${SIDE_BOUNDARY_PROMPT}\n\nthe first question`), + source: { kind: 'user' }, + })), + entry(ev('user/message', 2, { content: textBlocks('follow-up'), source: { kind: 'user' } })), + ] + const rows = transcriptRows(entries) + expect(rows).toEqual([ + { kind: 'injection', seq: 1, text: SIDE_BOUNDARY_PROMPT }, + { kind: 'user', seq: 1, text: 'the first question' }, + { kind: 'user', seq: 2, text: 'follow-up' }, + ]) + }) + + it('renders any plugin-sourced context message as an injection row, boundary prefix or not', () => { + const entries = [ + entry(ev('session/end-seed', 0)), + entry(ev('user/message', 1, { content: textBlocks('runtime context'), source: { kind: 'plugin', plugin: 'other-plugin' } })), + entry(ev('user/message', 2, { content: textBlocks('q'), source: { kind: 'user' } })), + ] + const rows = transcriptRows(entries) + expect(rows).toEqual([ + { kind: 'injection', seq: 1, text: 'runtime context' }, + { kind: 'user', seq: 2, text: 'q' }, + ]) }) it('accumulates chunk deltas per block and supersedes them on settle', () => { @@ -192,3 +226,23 @@ describe('collectOwnEvents', () => { expect(result.entries.map(e => e.event.seq)).toEqual([20, 21, 22, 23, 24, 25, 26, 27, 28, 29]) }) }) + +describe('toolArgsSummary', () => { + it('picks the most identifying string field', () => { + expect(toolArgsSummary('{"command":"ls -la","timeout":1000}')).toBe('ls -la') + expect(toolArgsSummary('{"file_path":"/a/b.ts","old_string":"x"}')).toBe('/a/b.ts') + expect(toolArgsSummary('{"pattern":"foo","path":"/repo"}')).toBe('/repo') + }) + + it('flattens and truncates raw text when no known key parses', () => { + expect(toolArgsSummary('{"custom":"v"}')).toBe('{"custom":"v"}') + expect(toolArgsSummary('not json at all')).toBe('not json at all') + expect(toolArgsSummary(`{"command":"${'x'.repeat(200)}"`)).toHaveLength(80) + expect(toolArgsSummary(`{"command":"${'x'.repeat(200)}"`)).toMatch(/…$/) + }) + + it('reads empty for missing or blank input', () => { + expect(toolArgsSummary(undefined)).toBe('') + expect(toolArgsSummary('{"command":" "}')).toBe('{"command":" "}') + }) +}) diff --git a/tests/subagent-detect.spec.ts b/tests/subagent-detect.spec.ts index 2debf2c..2fe4e06 100644 --- a/tests/subagent-detect.spec.ts +++ b/tests/subagent-detect.spec.ts @@ -98,6 +98,33 @@ describe('subagent detection over the sessions list feed', () => { expect(detectNewDirectSubagent(before, after, 'p2')).toBe(false) }) + it('documents the title-frame race the Sidebar auto-open debounce absorbs', () => { + // The host delivers a new child's origin and title in SEPARATE frames: + // a Side Chat thread's FIRST visible frame still shows a fallback title + // (the cwd basename — no 'Side: ' prefix), so an immediate 0→N check + // misreads it as a genuine subagent. Sidebar.tsx therefore debounces + // AUTO_OPEN_DEBOUNCE_MS and re-evaluates the ORIGINAL baseline against + // the live snapshot; once the title frame has landed the same baseline + // yields no trigger. These two assertions pin exactly that dependency. + const baseline: SidebarSessionList = { current: 'p2', byId: { p2: { id: 'p2', displayTitle: 'P2' } } } + const firstFrame: SidebarSessionList = { + current: 'p2', + byId: { + p2: { id: 'p2', displayTitle: 'P2' }, + s2: { id: 's2', displayTitle: 'DSH-better-sidebar', origin: 'subagent', parentId: 'p2' }, + }, + } + expect(detectNewDirectSubagent(baseline, firstFrame, 'p2')).toBe(true) // the race + const settled: SidebarSessionList = { + current: 'p2', + byId: { + p2: { id: 'p2', displayTitle: 'P2' }, + s2: { id: 's2', displayTitle: 'Side: New thread', origin: 'subagent', parentId: 'p2' }, + }, + } + expect(detectNewDirectSubagent(baseline, settled, 'p2')).toBe(false) // after the debounce + }) + it('resolves the main-agent root of the current session tree', () => { const byId: SidebarSessionList['byId'] = { main: { id: 'main', displayTitle: 'Main' }, From 29b8615bffcdca57322b7479b15938420f004075 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=9A=E7=8E=A9=E5=B9=BB=E7=81=B5qwq?= Date: Sat, 22 Aug 2026 17:21:27 +0800 Subject: [PATCH 08/21] fix(terminal): keep pty alive across conversation switches via park frame (#323) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching from session A to B unmounted A's TerminalView, whose socket close the host treated as a transient drop and started the 30s reconnect- grace countdown — leaving the user in B for >30s killed A's shell, so switching back respawned a fresh one instead of reattaching. Add a third control frame {type:'park'} distinct from refresh/crash drops: the client sends it on unmount when the tab is still open in its session's state but the active session has moved on; the host marks the pty as parked and skips the grace countdown. Switching back (open() cancels the parked state) or explicitly closing the tab (close frame's scheduleClose clears it) resume the normal lifecycle. Agent terminals skip park — their lifetime is already indefinite on a bare drop. --- src/client/TerminalView.tsx | 51 ++++++++++++++++++++++++++--------- src/index.ts | 26 +++++++++++++++--- src/pty-manager.ts | 51 +++++++++++++++++++++++++++++++++-- tests/smoke.spec.ts | 54 +++++++++++++++++++++++++++++++++++++ 4 files changed, 163 insertions(+), 19 deletions(-) diff --git a/src/client/TerminalView.tsx b/src/client/TerminalView.tsx index 2fcbdfa..a226fc9 100644 --- a/src/client/TerminalView.tsx +++ b/src/client/TerminalView.tsx @@ -8,16 +8,28 @@ * retry, and repeated unreasoned failures surface the close code after three * attempts, so the banner never spins forever. * + * Three control frames shape the pty lifecycle on unmount: + * - `{type:'close'}` — the user closed the tab. The host kills the pty + * immediately (quota released). + * - `{type:'park'}` — the user switched to another conversation. The tab is + * still open in its session's persisted state but its view unmounted; the + * host keeps the pty alive indefinitely (no grace countdown), so switching + * back reattaches the same shell instead of respawning one. + * - bare socket drop (no frame) — page refresh, crash, plugin teardown, or a + * same-session re-render. The host's reconnect grace keeps the shell alive + * for a quick reconnect. + * * Two attach modes share one upgrade endpoint: * - `tabId` starting with `agent:` is an agent-owned terminal (created by * the `terminal_create` tool). The uuid is the suffix after `agent:`; the * view connects with `?uuid=...`. A close frame kills the pty (the agent's * terminal closes when the user closes the tab); a bare socket drop - * leaves the pty alive (the agent owns the lifetime). + * leaves the pty alive (the agent owns the lifetime) — agent terminals + * never send park (their lifetime is already indefinite on bare drop). * - Any other `tabId` is a UI-tab terminal (the user created it from the + * menu). The view connects with `?tab=...&sessionId=...&cwd=...`. A close - * frame schedules a 0-ms close; a bare socket drop gets the host's - * reconnect grace. + * frame schedules a 0-ms close; a park frame marks the pty as parked; a + * bare socket drop gets the host's reconnect grace. */ import { useEffect, useRef, useState } from 'react' import { Terminal, type ITheme } from '@xterm/xterm' @@ -274,18 +286,31 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store: fontSub() schemeSub() inputSub.dispose() - // The close frame tells the host the owning tab is GONE (immediate - // pty release). A bare unmount — conversation switch, re-render, - // page unload — leaves the tab open, so the socket drop alone hands - // the process to the host's reconnect grace: switching back or - // refreshing reattaches the SAME shell instead of respawning one. - // (The host respawns on its own when the authoritative cwd changed.) - // Agent terminals follow the same rule: a close frame kills the pty - // (the user closed the sidebar tab); a bare socket drop leaves it - // alive (the agent owns the lifetime). - if (!store.tabOpen(scope.sessionId, tabId) + // Three unmount cases, distinguished by the store's tab/open state and + // the active session id: + // 1. The tab was closed by the user (NOT in its session's state): send + // `{type:'close'}` — the host releases the pty immediately. + // 2. The user switched to another conversation (the tab IS still open + // in scope.sessionId's state, but the active session is now a + // different one): send `{type:'park'}` — the host keeps the pty + // alive indefinitely (no grace countdown), so switching back + // reattaches the SAME shell. Without this, the bare socket drop + // would start the 30s reconnect-grace countdown and kill the shell + // while the user is still actively working in the other session. + // 3. A same-session unmount (page refresh, crash, plugin teardown, a + // re-render that re-mounts the view): bare socket drop — the host's + // reconnect grace keeps the shell alive for a quick reconnect. + // Agent terminals follow the close-frame rule; their lifetime is owned + // by the agent, so a bare drop (case 3) already leaves them alive + // indefinitely — no park frame needed. + const tabStillOpen = store.tabOpen(scope.sessionId, tabId) + const sessionSwitched = store.getSnapshot().sessionId !== scope.sessionId + if (!tabStillOpen && socket !== null && socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify({ type: 'close' })) + } else if (tabStillOpen && sessionSwitched && !isAgentTabId(tabId) + && socket !== null && socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: 'park' })) } socket?.close() term.dispose() diff --git a/src/index.ts b/src/index.ts index 2deb39f..c5c98b9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -895,6 +895,10 @@ async function attachAgentList( * - `?tab=...&sessionId=...` attaches to a UI-tab terminal (the user * created it from the + menu). The close frame schedules a 0-ms close * (the host's reconnect grace keeps the shell alive across a refresh). + * The park frame (sent when the user switches to another conversation) + * marks the pty as parked so the upcoming bare socket drop does NOT start + * the grace countdown — the tab is still open in its session's state, so + * the shell must survive until the user switches back or closes the tab. */ async function attachTerminal( ctx: Context, @@ -971,6 +975,16 @@ async function attachTerminal( ptyManager.scheduleClose(handle.key, 0) return } + if (control !== null && control.type === 'park') { + // The user switched to another conversation: the tab is still open in + // its session's persisted state, but its view unmounted. Park the pty + // so the upcoming bare socket drop does NOT start the reconnect-grace + // countdown — the pty stays alive until the user switches back (a + // reconnecting view clears the parked state) or explicitly closes the + // tab (a close frame's scheduleClose clears it). + ptyManager.park(handle.key) + return + } if (handle.exited) return if ( control !== null @@ -986,10 +1000,14 @@ async function attachTerminal( ws.on('close', () => { dataSub.dispose() exitSub.dispose() - // A bare socket drop (refresh, tab switch) leaves the process alive - // for a grace period so a quick reconnect keeps it; the reconnect's - // open() cancels the pending close. - ptyManager.scheduleClose(handle.key, resolved.reconnectGraceMs) + // A parked pty (the user switched conversations and sent `{type:'park'}`) + // stays alive indefinitely — do NOT start the grace countdown. A bare + // socket drop without a prior park (refresh, crash) starts the grace + // period so a quick reconnect keeps the process; the reconnect's open() + // cancels the pending close. + if (!ptyManager.isParked(handle.key)) { + ptyManager.scheduleClose(handle.key, resolved.reconnectGraceMs) + } }) } catch (error) { ws.close(1011, error instanceof Error ? error.message : String(error)) diff --git a/src/pty-manager.ts b/src/pty-manager.ts index 67e553a..0b0a068 100644 --- a/src/pty-manager.ts +++ b/src/pty-manager.ts @@ -64,10 +64,32 @@ export interface SidebarPty { /** * The terminal registry. `maxPerSession` bounds concurrent processes per * conversation (the client caps tabs at the same number). + * + * Lifecycle of a UI-tab pty when its WebSocket drops: + * - **Close frame** (`{type:'close'}`): the user closed the tab → schedule a + * 0-ms close (quota released immediately). + * - **Park frame** (`{type:'park'}`): the user switched to another + * conversation; the tab is still open in its session's persisted state but + * its view unmounted → mark the pty as parked (no auto-close countdown). + * The pty stays alive until the user switches back (a reconnecting view + * calls `open()` which clears the parked state) or the tab is later closed + * (a `{type:'close'}` frame from a fresh connection). Without `park`, a + * bare socket drop would start the reconnect-grace countdown and kill the + * shell after `reconnectGraceMs` — wrong for a session switch, where the + * user is still actively using the app, just in another conversation. + * - **Bare socket drop** (no frame): page refresh, crash, plugin teardown → + * schedule a close after `reconnectGraceMs` so a quick reconnect reattaches + * the same shell. */ export class PtyManager { private readonly sessions = new Map() private readonly pendingCloses = new Map>() + /** Tabs whose view unmounted because the user switched conversations — the + * tab is still open in its session's state, so the pty must NOT enter the + * reconnect-grace countdown. Cleared by `cancelClose` (a reconnecting + * view's `open()` cancels it) or by `scheduleClose` (an explicit close + * frame still kills a parked pty). */ + private readonly parked = new Set() constructor( private readonly shell: string, @@ -159,7 +181,9 @@ export class PtyManager { * Schedule the terminal's destruction after `delayMs`. A tab close sends * delay 0 (release the quota immediately); a bare socket drop (refresh, * crash) uses the grace period so a quick reconnect keeps the process. - * `open()` cancels any pending close. + * `open()` cancels any pending close. Clears the parked state — an explicit + * close frame on a parked pty (the user switched back and closed the tab) + * still kills it. */ scheduleClose(key: string, delayMs: number): void { const handle = this.sessions.get(key) @@ -169,13 +193,36 @@ export class PtyManager { this.pendingCloses.set(key, timer) } - /** Cancel a pending scheduled close (the terminal is being reopened). */ + /** + * Park a terminal: the owning tab's view unmounted because the user + * switched to another conversation, but the tab is still open in its + * session's persisted state. Cancels any pending grace close and marks + * the pty so the host's `ws.on('close')` handler does NOT start the + * reconnect-grace countdown — the pty stays alive until the user switches + * back (a reconnecting view's `open()` clears this) or explicitly closes + * the tab (a `{type:'close'}` frame's `scheduleClose` clears this). + */ + park(key: string): void { + if (this.sessions.get(key) === undefined) return + this.cancelClose(key) + this.parked.add(key) + } + + /** Whether this pty was parked (its view unmounted for a session switch). */ + isParked(key: string): boolean { + return this.parked.has(key) + } + + /** Cancel a pending scheduled close (the terminal is being reopened). + * Also clears the parked state — a reconnecting view reattaches a parked + * pty and resumes normal lifecycle. */ cancelClose(key: string): void { const timer = this.pendingCloses.get(key) if (timer !== undefined) { clearTimeout(timer) this.pendingCloses.delete(key) } + this.parked.delete(key) } /** Resolve a live handle by key, or undefined. */ diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index 17ec67e..ba6ffc7 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -198,6 +198,60 @@ describe('host plugin smoke', () => { } }) + it('pty manager: a parked pty survives past the reconnect grace (session switch)', async () => { + const manager = new PtyManager(defaultShell(), 3) + try { + const handle = manager.open('s2', 't1', process.cwd(), 80, 24) + manager.park(handle.key) + expect(manager.isParked(handle.key)).toBe(true) + // A parked pty does NOT enter the grace countdown — it stays alive + // well past any realistic reconnectGraceMs. + await new Promise(resolve => setTimeout(resolve, 300)) + expect(manager.get(handle.key)).toBeDefined() + expect(manager.isParked(handle.key)).toBe(true) + } finally { + manager.disposeAll() + } + }) + + it('pty manager: a reconnecting view clears the parked state (switch back)', () => { + const manager = new PtyManager(defaultShell(), 3) + try { + const handle = manager.open('s2', 't1', process.cwd(), 80, 24) + manager.park(handle.key) + expect(manager.isParked(handle.key)).toBe(true) + // open() calls cancelClose(), which clears the parked state — the + // user switched back to the session and the view reattached. + manager.open('s2', 't1', process.cwd(), 80, 24) + expect(manager.isParked(handle.key)).toBe(false) + expect(manager.get(handle.key)).toBeDefined() + } finally { + manager.disposeAll() + } + }) + + it('pty manager: an explicit close frame on a parked pty still kills it', async () => { + const manager = new PtyManager(defaultShell(), 3) + try { + const handle = manager.open('s2', 't1', process.cwd(), 80, 24) + manager.park(handle.key) + // The user switched back and closed the tab — scheduleClose (the + // close-frame handler) clears the parked state and kills the pty. + manager.scheduleClose(handle.key, 0) + expect(manager.isParked(handle.key)).toBe(false) + await new Promise(resolve => setTimeout(resolve, 50)) + expect(manager.get(handle.key)).toBeUndefined() + } finally { + manager.disposeAll() + } + }) + + it('pty manager: park on an unknown key is a no-op', () => { + const manager = new PtyManager(defaultShell(), 3) + expect(() => manager.park('s2:nonexistent')).not.toThrow() + expect(manager.isParked('s2:nonexistent')).toBe(false) + }) + it('pty manager: reopening with a different cwd respawns in the new directory', async () => { const manager = new PtyManager(defaultShell(), 3) // A real second directory: os.tmpdir() exists on every platform ('/tmp' From 9a40164d1a69dbc960585d8789ee0d849231979b Mon Sep 17 00:00:00 2001 From: Atlas Zeng Date: Sat, 22 Aug 2026 17:22:10 +0800 Subject: [PATCH 09/21] =?UTF-8?q?fix(explorer):=20=E9=9D=9E=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E6=8B=96=E6=8B=BD=E4=B8=8D=E5=86=8D=E8=A7=A6=E5=8F=91?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E6=A0=91=E4=B8=8A=E4=BC=A0=E9=81=AE=E7=BD=A9?= =?UTF-8?q?=20(#317)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 拖动 Tab(重排/跨 pane split)经过资源管理器文件树时,FileTree 的 dragenter/dragover/drop 此前对任何拖拽都响应:显示上传遮罩并吞掉 事件,导致 pane 根的 Tab-drop 处理收不到、Tab 无法落下。 统一按 dataTransfer.types 含 'Files' 门控(与 Sidebar.tsx 面板宿主 shield 一致):非文件拖拽原样冒泡给 pane 根,OS 文件拖拽行为不变。 回归测试:应用内 Tab 拖拽不出现 uploadDropZone、不改 dropTarget、 不 preventDefault、不触发 onUploadRequest。 --- src/client/FileTree.tsx | 12 ++++++++++++ tests/file-tree-drop.spec.tsx | 31 +++++++++++++++++++++++++++---- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/client/FileTree.tsx b/src/client/FileTree.tsx index 2079499..ebea5a7 100644 --- a/src/client/FileTree.tsx +++ b/src/client/FileTree.tsx @@ -52,6 +52,13 @@ function parentOf(path: string): string { return at <= 0 ? path : path.slice(0, at) } +/** Only OS file drags belong to the upload surface; in-app drags (tab reorder, + * split zones) must pass through untouched to the pane's tab-drop handling + * (mirror of Sidebar.tsx's panel-host shield gate). */ +function isFileDrag(event: DragEvent): boolean { + return event.dataTransfer?.types.includes('Files') ?? false +} + /** How long the row's "copied" label stays after a successful write. */ const COPIED_MS = 1200 @@ -160,12 +167,14 @@ export function FileTree(props: { }) } const handleBodyDrop = (event: DragEvent): void => { + if (!isFileDrag(event)) return event.preventDefault() event.stopPropagation() resetDrop() if (cwd !== undefined) reportDrop(cwd, event.dataTransfer) } const handleDirDrop = (event: DragEvent, dir: string): void => { + if (!isFileDrag(event)) return event.preventDefault() event.stopPropagation() resetDrop() @@ -176,6 +185,7 @@ export function FileTree(props: { handleDirDrop(event, parentOf(path)) } const handleBodyDragEnter = (event: DragEvent): void => { + if (!isFileDrag(event)) return event.preventDefault() event.stopPropagation() dropDepth.current += 1 @@ -195,6 +205,7 @@ export function FileTree(props: { setDropRect(null) } const handleBodyDragOver = (event: DragEvent): void => { + if (!isFileDrag(event)) return event.preventDefault() event.stopPropagation() event.dataTransfer.dropEffect = busy ? 'none' : 'copy' @@ -205,6 +216,7 @@ export function FileTree(props: { setDropTarget(null) } const handleRowDragOver = (event: DragEvent, dir: string): void => { + if (!isFileDrag(event)) return event.preventDefault() event.stopPropagation() event.dataTransfer.dropEffect = busy ? 'none' : 'copy' diff --git a/tests/file-tree-drop.spec.tsx b/tests/file-tree-drop.spec.tsx index f45dffb..c76e661 100644 --- a/tests/file-tree-drop.spec.tsx +++ b/tests/file-tree-drop.spec.tsx @@ -14,6 +14,7 @@ import { createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { act } from 'react-dom/test-utils' import { FileTree } from '../src/client/FileTree.tsx' +import { TAB_DRAG_TYPE } from '../src/client/TabBar.tsx' import type { UploadItem } from '../src/client/upload.ts' // The act() environment flag (React 18.2 reads it before flushing effects). @@ -72,12 +73,12 @@ async function mountTree(busy = false): Promise { } /** A bubbling drag event carrying a stub dataTransfer (jsdom has no DragEvent). */ -function dragEvent(type: string): Event { +function dragEvent(type: string, dataTypes: string[] = ['Files']): Event { const event = new Event(type, { bubbles: true, cancelable: true }) Object.defineProperty(event, 'dataTransfer', { // No entry API on the items, so drops fall back to the flat file list. value: { - types: ['Files'], + types: dataTypes, items: [], files: [new File(['x'], 'dropped.txt')], dropEffect: '', @@ -87,8 +88,8 @@ function dragEvent(type: string): Event { } /** Dispatch inside act() so React flushes the state update. */ -function fire(target: Element, type: string): Event { - const event = dragEvent(type) +function fire(target: Element, type: string, dataTypes?: string[]): Event { + const event = dragEvent(type, dataTypes) act(() => { target.dispatchEvent(event) }) return event } @@ -179,4 +180,26 @@ describe('FileTree drag-drop surface', () => { expect(busyHarness.uploads).toHaveLength(0) busyHarness.unmount() }) + + it('ignores an in-app tab drag: no upload drop zone and events pass through', () => { + const enter = fire(harness.body, 'dragenter', [TAB_DRAG_TYPE]) + expect(enter.defaultPrevented).toBe(false) + expect(dropZone()).toBeNull() + const over = fire(harness.body, 'dragover', [TAB_DRAG_TYPE]) + expect(over.defaultPrevented).toBe(false) + expect(dropZone()).toBeNull() + // A row hover must not retarget the drop directory either. + const dir = rowByName(harness.container, 'src') + const rowOver = fire(dir, 'dragover', [TAB_DRAG_TYPE]) + expect(rowOver.defaultPrevented).toBe(false) + expect(dir.className).not.toContain('explorerRowDropTarget') + }) + + it('passes an in-app tab drop through without reporting an upload', async () => { + const drop = fire(harness.body, 'drop', [TAB_DRAG_TYPE]) + await act(async () => {}) + expect(drop.defaultPrevented).toBe(false) + expect(harness.uploads).toHaveLength(0) + expect(dropZone()).toBeNull() + }) }) From ef8d3a90505f90e15e810d35d8c9c14bc6894fdb Mon Sep 17 00:00:00 2001 From: Atlas Zeng Date: Sat, 22 Aug 2026 17:46:26 +0800 Subject: [PATCH 10/21] chore(release): 0.15.1 (#324) * chore(release): 0.15.1 - bump version: package.json / dsh.plugin.json / SIDEBAR_SERVICE_VERSION - README / README_EN: add v0.15.1 changelog (sidechat Codex-style transcript rework, terminal park-frame pty keep-alive, file-tree drag gating, README rewrite); plugin ecosystem 28+ with two new entries (dsh-better-sidebar-starter, my-dsh-plugins) and per-category
collapsible listings * docs(readme): add community section placeholder for WeChat/QQ group QR codes --- README.md | 51 ++++++++++++++++++++++++++++++++++++++++--- README_EN.md | 51 ++++++++++++++++++++++++++++++++++++++++--- dsh.plugin.json | 2 +- package.json | 2 +- src/client/service.ts | 2 +- 5 files changed, 99 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 7258fc3..c235849 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)。 ## 🚀 安装 @@ -234,7 +234,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 +243,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,22 +269,36 @@ 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` 守护)。 ## 🆕 最近更新 @@ -293,6 +310,21 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar **支持的 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.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 以来的全部更改: @@ -430,6 +462,19 @@ pnpm watch # tsdown --watch Windows / Linux / macOS 三平台适配(macOS 日常验证;其余经单元测试覆盖);`node-pty` 优先预编译二进制,失败需编译工具链(Windows VS Build Tools / Linux make+g+++python3 / macOS Xcode CLT)。 +## 💬 社区 + +微信 / QQ 交流群二维码将在此处展示。上传二维码图片(拖入任意 issue / comment 即可获得 `user-attachments` 链接)后,替换下方 `src` 并取消注释: + +
+ + +
+ ## 🤝 参与贡献 - **代码改动走 PR**:`feat/*` / `fix/*` 分支开发 → `gh pr create`;纯文档改动可直接推 main diff --git a/README_EN.md b/README_EN.md index 2d600cb..42f2795 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 @@ -234,7 +234,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 +243,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 +269,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 +310,21 @@ 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.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: @@ -430,6 +462,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/dsh.plugin.json b/dsh.plugin.json index ccb5d59..f067d21 100644 --- a/dsh.plugin.json +++ b/dsh.plugin.json @@ -1,6 +1,6 @@ { "id": "dsh-external/dsh-better-sidebar", - "version": "0.15.0", + "version": "0.15.1", "main": "./lib/index.js", "description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器", "engines": { diff --git a/package.json b/package.json index 816de23..590422a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-better-sidebar", - "version": "0.15.0", + "version": "0.15.1", "description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.", "type": "module", "repository": { diff --git a/src/client/service.ts b/src/client/service.ts index 98b6b96..c16e67d 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.1' /** * Monotonic capability list consumers use to gate new API usage (features From 9b4240449cd5d26771d0f673e03d081aed710c4f Mon Sep 17 00:00:00 2001 From: Atlas Zeng Date: Sat, 22 Aug 2026 17:59:20 +0800 Subject: [PATCH 11/21] Update README.md (#325) --- README.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c235849..8be1cb2 100644 --- a/README.md +++ b/README.md @@ -464,14 +464,12 @@ Windows / Linux / macOS 三平台适配(macOS 日常验证;其余经单元 ## 💬 社区 -微信 / QQ 交流群二维码将在此处展示。上传二维码图片(拖入任意 issue / comment 即可获得 `user-attachments` 链接)后,替换下方 `src` 并取消注释: -
From 67271e54c82d27db6fb06ae34a2015e061696b86 Mon Sep 17 00:00:00 2001 From: Atlas Zeng Date: Sat, 22 Aug 2026 18:00:24 +0800 Subject: [PATCH 12/21] Clean up community section in README Removed commented-out QR code sections for WeChat and QQ from the community section. --- README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/README.md b/README.md index 8be1cb2..e8df579 100644 --- a/README.md +++ b/README.md @@ -465,12 +465,8 @@ Windows / Linux / macOS 三平台适配(macOS 日常验证;其余经单元 ## 💬 社区
- -
## 🤝 参与贡献 From d3c0f223452355dbb107ec94f6a86abd3d5a9cd3 Mon Sep 17 00:00:00 2001 From: Atlas Zeng Date: Sat, 22 Aug 2026 19:57:34 +0800 Subject: [PATCH 13/21] feat(tab-bar): add right-click close context menu on tabs (#331) Right-clicking a tab now takes over the browser menu and shows four close operations scoped to the current pane: - Close - Close Other Tabs - Close Tabs to the Left - Close Tabs to the Right Items gray out when nothing is eligible (single tab / strip ends). Bulk closes reuse the per-tab onClose path, so descriptor onClose callbacks and terminal pty release still run for every close. Labels are i18n'd (zh/en). --- README.md | 1 + README_EN.md | 1 + src/client/TabBar.tsx | 60 ++++++++- src/client/locales.ts | 6 + tests/tab-bar-context-menu.spec.tsx | 191 ++++++++++++++++++++++++++++ 5 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 tests/tab-bar-context-menu.spec.tsx diff --git a/README.md b/README.md index e8df579..c9ae6d8 100644 --- a/README.md +++ b/README.md @@ -413,6 +413,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 42f2795..4412e2b 100644 --- a/README_EN.md +++ b/README_EN.md @@ -413,6 +413,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 | 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/locales.ts b/src/client/locales.ts index fdffc7f..cd045c3 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -37,6 +37,9 @@ export const zh = { newTerminal: '新终端', terminalLimit: '终端数量已达上限 (3)', close: '关闭', + closeOtherTabs: '关闭其他页签', + closeLeftTabs: '关闭左侧页签', + closeRightTabs: '关闭右侧页签', collapse: '折叠侧边栏', expand: '展开侧边栏', collapseBottomPanel: '折叠底部面板', @@ -344,6 +347,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/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
+ ) } if (row.type === 'hunk') { diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index f648d4c..2d4befa 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, 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() + } + }) +}) From 22a5d372be3a4c7f21be489f9e919da984fc5772 Mon Sep 17 00:00:00 2001 From: Atlas Zeng Date: Sat, 22 Aug 2026 23:19:17 +0800 Subject: [PATCH 20/21] =?UTF-8?q?feat(editor):=20=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E6=A0=91=E3=80=8C=E5=9C=A8=E5=BA=94=E7=94=A8=E4=B8=AD=E6=89=93?= =?UTF-8?q?=E5=BC=80=E3=80=8D=E5=AD=90=E8=8F=9C=E5=8D=95=EF=BC=88=E5=A4=96?= =?UTF-8?q?=E9=83=A8=E7=BC=96=E8=BE=91=E5=99=A8/=E5=9B=BE=E9=92=89?= =?UTF-8?q?=E5=9B=BA=E5=AE=9A/SSH=20=E8=BF=9C=E7=AB=AF/=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=E7=BC=96=E8=BE=91=E5=99=A8=EF=BC=89=20(#334)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(editor): file tree 'open with' submenu (external editors, pin, SSH-remote, custom editors) The file tree right-click menu gains an 'Open with >' submenu with the resource manager (reveal in file manager), VS Code, Cursor and Zed, plus user-defined custom editors (name + URL template + VSCode-family flag). Each submenu row carries a pushpin that promotes the target to a direct top-level menu item. A configurable SSH host (settings: Files card gear) switches VSCode-family openers to ://vscode-remote/ssh-remote+/ and hides host-local targets (file manager / Zed / non-VSCode custom editors). External opens run through the new fenced /sidebar/api/open.external route which spawns the platform opener with an argv array (no shell); the client never navigates raw scheme URLs itself. The settings seam's render panel now appends after the declarative rows instead of replacing them. * feat(editor): polish open-with menu UI — brand icons, pushpin, chevron, row alignment - Submenu rows: the custom label is now a block-level flex row filling the primitives itemLabel, so the pushpin docks at the row's far right (matching the trailing check of every other menu); menu row icons normalized to 16px (DSH native menus use 16, ours were 14). - The submenu parent row gains a right-aligned chevron (the primitives Menu renders no arrow for submenu parents). - Icons via react-icons: codicons VscPin/VscPinned pushpins (replacing the hand-drawn pin), VscFile/VscFolder/VscFolderOpened tree glyphs (replacing the file mark), VscLinkExternal for the open-with parent and the browser tab's open-external button (globally), SiCursor/SiZedindustries brand silhouettes; the VS Code mark is inlined from simple-icons@11.0.0 (CC0) as IconVscode16 since later simple-icons releases dropped it. All monochrome currentColor to follow the active skin. - tsdown: pin react-icons si/vsc to their ESM entries via resolve.alias — the package's exports map lists require before import, so the shared conditionNames picked the unshakeable CJS entry and bloated the core bundle by ~6.4 MB; tree-shaken it now adds only ~12 KB. --- AGENTS.md | 5 +- .../plans/2026-08-22-open-with-menu-design.md | 106 +++++++++ package.json | 3 +- pnpm-lock.yaml | 12 + src/client/BrowserView.tsx | 4 +- src/client/EditorHost.tsx | 61 ++++- src/client/FileTree.tsx | 129 ++++++++-- src/client/SideCardSection.module.css | 65 ++++++ src/client/SideCardSection.tsx | 87 +++---- src/client/TreePanel.tsx | 15 +- src/client/api.ts | 6 + src/client/builtins/tabs.tsx | 8 +- src/client/icons.tsx | 13 ++ src/client/locales.ts | 40 ++++ src/client/open-with-settings.tsx | 135 +++++++++++ src/client/open-with.ts | 221 ++++++++++++++++++ src/client/plugin-settings.ts | 43 ++++ src/client/sidebar.module.css | 51 ++++ src/index.ts | 14 ++ src/open-external.ts | 90 +++++++ tests/builtins.spec.ts | 3 + tests/file-tree-open-with.spec.tsx | 202 ++++++++++++++++ tests/open-external.spec.ts | 70 ++++++ tests/open-with-settings.spec.tsx | 111 +++++++++ tests/open-with.spec.ts | 150 ++++++++++++ tests/side-card-section.spec.tsx | 68 +++++- tsdown.config.ts | 15 +- 27 files changed, 1662 insertions(+), 65 deletions(-) create mode 100644 docs/plans/2026-08-22-open-with-menu-design.md create mode 100644 src/client/open-with-settings.tsx create mode 100644 src/client/open-with.ts create mode 100644 src/client/plugin-settings.ts create mode 100644 src/open-external.ts create mode 100644 tests/file-tree-open-with.spec.tsx create mode 100644 tests/open-external.spec.ts create mode 100644 tests/open-with-settings.spec.tsx create mode 100644 tests/open-with.spec.ts 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/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/EditorHost.tsx b/src/client/EditorHost.tsx index c66a847..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' @@ -33,6 +33,8 @@ import { planFirstMatch, planFsReadOutcome, type EditorLoadAction } from './edit 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' @@ -52,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) @@ -106,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 === '' @@ -150,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). @@ -287,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} />
@@ -380,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 /** 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 23c8d8a..89bff2a 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -289,6 +289,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 }) => ( ( ) + +/** + * 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 cd045c3..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: '失效的软链接', @@ -340,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', 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/sidebar.module.css b/src/client/sidebar.module.css index 2d4befa..1c59197 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -2521,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/index.ts b/src/index.ts index c5c98b9..067add6 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' @@ -479,6 +480,19 @@ function buildApi( clearTimeout(timer) } }, + // External open for the file tree's "open with" menu: reveal a path in + // the OS file manager, or hand a custom-scheme URL (vscode://, + // cursor://, zed://, custom editors) to its registered handler. The + // client is a browser renderer where raw scheme navigation is + // unreliable, so the launch always goes through the host — the same + // fence as every other route, argv-only (no shell interpolation). + 'open.external': (payload) => { + const record = payload as { action?: unknown } | null + const action = record?.action + if (action === 'reveal') return launchExternal('reveal', requireString(payload, 'path')) + if (action === 'url') return launchExternal('url', requireString(payload, 'url')) + throw new SidebarError('bad-request', 'action must be "reveal" or "url"') + }, // Side Chat: create a side-thread child seeded with the parent's full // log up to now, deliver follow-ups (cold-resuming when the thread's // agent is gone), abort a running thread, and release a thread's agent. 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/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/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/tsdown.config.ts b/tsdown.config.ts index e0376a1..50c3701 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -41,7 +41,7 @@ * Types ship from lib/types (tsc -p tsconfig.build.json), not from tsdown. */ import { readFile } from 'node:fs/promises' -import { basename, dirname, relative, resolve as resolvePath, sep } from 'node:path' +import { basename, dirname, join, relative, resolve as resolvePath, sep } from 'node:path' import { builtinModules, createRequire } from 'node:module' import { fileURLToPath } from 'node:url' import type { UserConfig } from 'tsdown' @@ -67,6 +67,18 @@ const CLIENT_EXTERNALS = [ '@deepseek-ai/dsh-client-runtime/client', ] +/** + * react-icons' exports map lists `require` BEFORE `import`, so the shared + * conditionNames resolve the unshakeable CJS entry and the whole icon set + * lands in the core bundle (~6.4 MB extra). Pin the two sets the client + * uses to their ESM entries, which tree-shake down to the imported icons. + */ +const reactIconsRoot = dirname(dirname(require.resolve('react-icons/lib'))) +const REACT_ICONS_ESM_ALIAS = { + 'react-icons/si': join(reactIconsRoot, 'si/index.mjs'), + 'react-icons/vsc': join(reactIconsRoot, 'vsc/index.mjs'), +} + /** * Wire/type layers a client bundle may inline (mirror of the official * INLINE_SAFE list): browser-safe contract surfaces with no runtime identity @@ -140,6 +152,7 @@ function clientBundle(pluginId: string, entryFile: string): UserConfig { inputOptions: { resolve: { conditionNames: ['browser', 'import', 'require', 'default'], + alias: REACT_ICONS_ESM_ALIAS, }, }, // External wins for module-table entries; every other dependency inlines. From d9b8f15d9eab018742f97d67e54b2398504894cd Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Sat, 22 Aug 2026 23:30:19 +0800 Subject: [PATCH 21/21] chore(release): 0.15.2 - bump version: package.json / dsh.plugin.json / SIDEBAR_SERVICE_VERSION - README / README_EN: feature tour converted to a table (two screenshots per row, saves space); add v0.15.2 changelog (file-tree open-in-app menu #334, tab context menu #331, diff collapse by default #270, prune empty restored panes #268, hide git windows on Windows #301, untracked files inside new folders #242, toggle/drag per-frame re-render perf #315); community QR codes landed in #325 --- README.md | 93 +++++++++++++----------------------------- README_EN.md | 95 ++++++++++++++----------------------------- dsh.plugin.json | 2 +- package.json | 2 +- src/client/service.ts | 2 +- 5 files changed, 62 insertions(+), 132 deletions(-) diff --git a/README.md b/README.md index 52353c1..b1e0be5 100644 --- a/README.md +++ b/README.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 一次性并入右侧栏,触屏拖拽可调。
移动端全宽抽屉
| ## 🌐 插件生态 @@ -305,6 +252,26 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar **支持的 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 以来的全部更改: @@ -320,8 +287,6 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar - 📂 **文件树上传遮罩不再拦截 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.12.0 – v0.14.0) ### v0.15.0 自 v0.14.0 以来的全部更改: diff --git a/README_EN.md b/README_EN.md index 4412e2b..cbbd363 100644 --- a/README_EN.md +++ b/README_EN.md @@ -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 @@ -310,6 +257,27 @@ 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: @@ -345,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: diff --git a/dsh.plugin.json b/dsh.plugin.json index f067d21..a230943 100644 --- a/dsh.plugin.json +++ b/dsh.plugin.json @@ -1,6 +1,6 @@ { "id": "dsh-external/dsh-better-sidebar", - "version": "0.15.1", + "version": "0.15.2", "main": "./lib/index.js", "description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器", "engines": { diff --git a/package.json b/package.json index 8dce638..a923848 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-better-sidebar", - "version": "0.15.1", + "version": "0.15.2", "description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.", "type": "module", "repository": { diff --git a/src/client/service.ts b/src/client/service.ts index c16e67d..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.1' +export const SIDEBAR_SERVICE_VERSION = '0.15.2' /** * Monotonic capability list consumers use to gate new API usage (features