把 JetBrains 式工作台"长"进 DSH 右侧栏 —— IDE Workbench 插件分享

IDE Workbench 概览

鱼油们好!给大家分享一个我自己折腾的 DeepSeek Harness 插件,目前已经在 npm 上发布了(dsh-ide-workbench@0.4.0,MIT 开源),欢迎大家试用、提Issue~

一句话版本:把 Project 文件树、内置编辑器、Commit、Git 历史、Terminal 五个东西做成一个整体工作台,塞进 DeepSeek Harness(下称 DSH)的右侧栏,始终和聊天同屏。

先跟 IDEA 老玩家报个喜:这插件基本就是照着 IntelliJ 的肌肉记忆做的——Alt+1 / Alt+0 / Alt+9 / Alt+F12 切工具窗,单击预览、双击固定标签,Alt+Insert 新建、F2 重命名、F5/F6 复制移动、Ctrl+K 写提交、Ctrl+Shift+K 推送,连文件树红(未跟踪)/ 绿(新增)/ 蓝(已修改)的状态色都是 JetBrains 那套。装上即用,键位一个都不用重新学;哪天想改,设置页里也能重绑。

再交代个底,把话说诚实了:这个插件的代码从头到尾都是 DSH 自己写的。我本人主要负责提需求、验收和日常使用反馈——验收环节还上过 Windows UI 自动化,让 DSH 对着真实的 Desktop 界面把操作流程一路点完。所以下面的"实现剖析",严格说是我盯着它写完之后记下的实录。就当这是一份 DSH 的 dogfooding 样本:AI 写一个完整插件,人该把什么关,文中如实分享。😂

image.png


快速开始:只想快点用上的看这里

前置:DSH ≥ 0.1.7(Desktop 0.2.x 或 Web profile)。装法三选一:

方式一 · Desktop(推荐):设置 → 插件 → 添加插件,输入 dsh-ide-workbench,装完点会话头部的 IDE 按钮(或侧栏底部常驻入口)打开。

方式二 · CLI:

▼
sh
复制代码
dsh plugin --profile web add dsh-ide-workbench@0.4.0

方式三 · 源码:

▼
sh
复制代码
git clone https://github.com/LiangshouX/dsh-ide-workbench.git dsh plugin --profile web add ./dsh-ide-workbench

装完最快的上手路径:Alt+1 聚焦文件树 → 双击打开文件 → Ctrl+Shift+F 搜代码 → Alt+0 写提交 → Alt+F12 开终端。IDEA 用户零学习成本,键位基本一致且可在设置里重绑。

常用快捷键速查:

动作快捷键
切换工具窗Alt+1 Project · Alt+0 Commit · Alt+9 Git · Alt+F12 Terminal
全项目查找 / 备用键Ctrl+Shift+F / Ctrl+Alt+F
快速打开文件Ctrl+P 或 Ctrl+Shift+N
聚焦提交 / 推送Ctrl+K / Ctrl+Shift+K
文件操作Alt+Insert 新建 · F2 重命名 · F5 复制 · F6 移动
全屏 / 停靠切换F11

装好了?下面聊聊它到底解决了什么问题、又是怎么实现的——赶时间的话,看到这里已经够用了。


一、为什么又做一个侧栏插件

DSH 的对话体验还不错,"边聊边改"本来就是它的主场。但真干起活来,画风是这样的:聊天里让 AI 改个文件 → 切到文件查看器确认 → 开终端跑下测试 → 再回聊天 → 又要看下 Git 状态……鼠标在四五个窗口之间反复横跳,上下文全丢在路上。

DSH 自带的 sidebar(包括社区里的 better-sidebar)已经能解决一部分问题:文件树能看了,点聊天里的文件链接也能开了。但用久了会发现,它们本质上是"一堆互相独立的标签页":

  • 文件树是一个标签页;
  • 每打开一个文件,又新开一个标签页——开十个文件,右侧栏就变成十个标签;
  • 终端是一个独立标签页;
  • 想看 Git,得再单独装一个 Git 插件;
  • 各标签页之间没有任何联动:文件树不知道 Git 状态,终端不知道你正在看哪个文件,Git 面板不知道编辑器开到哪了。

东西都在,但全是散的。这就像给你一堆积木,和给你一辆装好的车,零件数量差不多,体验差一个时代。

所以我做了 IDE Workbench:不做"又一个标签页",做"一个工作台"。

二、形态:一个容器,五个协同的窗

工作台在右侧栏只占一个原生 tab,内部是 Project / 编辑器 / Commit / Git / Terminal 四个工具窗围绕编辑器排布(Alt+1 / Alt+0 / Alt+9 / Alt+F12 一键切换,IDEA 手感)。关键不在于"有哪些窗",而在于它们之间是打通的:

联动具体表现
文件树 ↔ Git树上每个文件带 JetBrains 状态色(红 = 未跟踪、绿 = 新增、蓝 = 已修改),目录递归染色
Commit ↔ 编辑器Commit 面板勾选变更、看 Diff、行级/块级暂存全在原地;双击文件直接在编辑器里打开对比
Git 历史 ↔ 编辑器提交列表 + 泳道式 commit graph,双击出详情抽屉,点文件看逐文件 Diff
终端 ↔ 项目新终端的 cwd 自动落在项目根,工作目录跟随当前文件
搜索 ↔ 编辑器Ctrl+Shift+F 全项目搜索,结果按文件分组,点一行直接定位到编辑器那一行
工作台 ↔ 聊天聊天里的文件链接点开直接在工作台定位到 #L24;文件树/编辑器右键"发送到聊天框",自动 @ 好文件,选中代码连行号带代码块一起进输入框
工作台 ↔ 会话同一工作区的会话共享一份工作台状态:切会话(含新建)文件树、打开的标签、Git 上下文全在,终端不销毁

编辑器标签由工作台自己管理——单击预览、双击固定,所以开再多文件,右侧栏始终是那一排工具窗,永远不会变成标签页坟场。

Project 工具窗

三、实现剖析

先给个整体结构,插件分成两半:

image.png

客户端半边(React + CodeMirror 6 + xterm.js)跑在宿主页面里,宿主半边(Node)提供围栏化的 HTTP API 和终端 WebSocket。npm run build 用 esbuild 把客户端打成单个 dist/client.js(React 外置复用宿主的,xterm 内联)。

3.1 插件是怎么"长"在宿主上的

DSH 的插件体系给了 cordis 风格的 apply(ctx) 入口。工作台没有硬造轮子,全部通过官方注入点长在宿主上:

注入点用途
sidebarRightTabs.register(...)注册右侧栏 tab 类型 + 文件链接认领规则
sidebar.right.pane.tab工作台本体(原生 tab,和聊天并排)
conversation.session.header.utilities会话头部的打开按钮
conversation.hero.options新建会话页的入口(空白会话会隐藏头部工具组,得补一个)
sidebar.footer.action侧栏底部常驻入口(皮肤藏头部时的保底)
conversation.input.dock(聊天桥)见 3.2
settings.section设置页集成

主题方面更克制:只使用宿主的 --dsh-alias-* 令牌,一个硬编码颜色都没有,所以明暗切换、换皮肤全部自动跟随,Markdown 预览也直接复用宿主的 MarkdownText 组件(通过 ctx.get('modules').import('@deepseek-ai/dsh-client-ui-primitives') 异步拿,拿不到就退化到自带渲染器)。

聊天文件链接的"认领"竞争

DSH 里聊天中的文件链接是 dsh-resource://file/... 地址,宿主按注册的 pattern 匹配分发给各个插件。问题来了:better-sidebar 也注册了 dsh-resource://file/**,我后注册,怎么保证工作区内的文件链接被工作台打开而不是被它抢走?

答案是利用宿主的按匹配模式长度排序规则,与注册顺序无关:

▼
js
复制代码
ctx.sidebarRightTabs.register({ id: 'dsh-ide-workbench', kind: PANEL_ID, // 比 better-sidebar 的 `dsh-resource://file/**` 更长 → 稳定胜出 patterns: ['dsh-resource://file/session/**', 'dsh-resource://file/absolute/**'], priority: 'extension', canOpen: address => { /* 见下 */ }, })

canOpen 里则非常克制,只认领该归自己的链接:

  • 设置里明确选了"始终用 DSH 侧栏"→ 放弃;
  • 链接所属会话和当前挂载会话的工作区不一致 → 放弃;
  • 文件在 DSH 工作区之外(别的盘、临时文件、旁边的 checkout)→ 放弃,降级到 DSH 自带侧栏——那里的预览和路径规则本来就管着这些文件。

宁可少抢一个链接,不抢不属于自己的。工作台还会在同步放弃的瞬间临时压低自己一轮 canOpen,让宿主的默认打开逻辑正常选中自带侧栏,避免死循环。

3.2 聊天桥:怎么把代码 @ 进输入框

"发送到聊天框"的方向是工作台 → 聊天输入框。插件不能直接摸别的插件的组件,所以用了一个小技巧:往 conversation.input.dock 槽位塞一个不渲染任何东西的隐藏组件,由它接收宿主给聊天输入器发布的 action 接口(captureInsertion / insertText),然后注册到自己的桥接表里:

▼
jsx
复制代码
function ChatInputBridge({ sessionId, inputActions }) { useLayoutEffect(() => registerChatInput(sessionId, inputActions), [sessionId, inputActions]) return null // 隐藏注册,不碰 UI }

格式化规则做得比"贴个路径"讲究一些:

▼
text
复制代码
@src/client/store.js ← 文件:路径统一正斜杠 @"docs/设计 稿.md" ← 带空格自动加引号 @src/host/ ← 目录带尾部斜杠 @src/host/git.js (L120-L135) ← 选中代码:带行号范围 ```ts export function parseNumstat(output) { … ← 代码块,语言标签按扩展名推断 ```

细节:控制字符/引号的路径直接拒绝生成引用(宁可提示失败也不塞一个坏引用);代码超过 240 行或 16000 字符自动截断并留下 … (truncated) 标记;宿主没提供输入框接口时明确弹提示,绝不静默丢弃。

3.3 Git:为什么坚持用系统 CLI

实现 Git 面板有两条路:绑一个 git 库(isomorphic-git 之类),或者调系统 git。我选了后者,理由很实际:凭证管理器(Windows Credential Manager / macOS Keychain)、SSH agent、hooks、用户自己的 git config、企业内网的 insteadOf 规则,全都是"配好多年的东西",一个纯 JS 库很难继承这些。绑库意味着用户第一次 push 就要重新配一遍凭证——那不如不做。

代价是要自己解析文本输出,好在 git 的机器接口足够稳定:

▼
js
复制代码
// 状态快照:一次拿到分支 + 每个文件的暂存/未暂存状态 const args = ['status', '--porcelain=v2', '--branch', '-z'] // 再补两发 numstat 拿行数统计(暂存区 / 工作区各一次) await runGit(repo, ['diff', '--numstat', '-z', '--no-renames']) await runGit(repo, ['diff', '--cached', '--numstat', '-z', '--no-renames'])

几个刻意的选择:

  • 永远是 argv 数组,绝不拼 shell 字符串——路径里有空格、引号、$ 都不构成注入面;
  • -z(NUL 分隔)优先:换行分隔撞上文件名里有换行就炸,NUL 不会;rename 记录是"空路径 + 下一条带路径"的两段式,parseNumstat 里专门处理;
  • GitError 保留原始 stderr,UI 直接展示,不给你一个"git failed"了事;
  • numstat 挂了不致命——没有行数统计的状态快照照样能用,降级继续。

全项目搜索同理,也是能用 git 就用 git:仓库里走 git ls-files(索引)+ git grep -n -I --column --untracked(天然尊重 .gitignore、跳过二进制,万级文件的项目秒回);非仓库目录退化成一次有界的目录走查(跳过 node_modules/dist 等 18 类目录、单文件上限 2 MiB、总量 5 万文件/2000 条结果封顶,读前 8 KiB 探测 NUL 字节判二进制)。

3.4 会话连续性:工作区级共享状态

右侧栏的布局是按 Session 隔离的,但"我刚打开的文件树和标签页"不该每次切会话就丢。工作台的做法是用自己的 store 键控到工作区根而不是会话:

▼
js
复制代码
export function workspaceScopeKey(ctx, sessionId) { const cwd = sessionCwdOf(ctx, sessionId) // 宿主发布的会话 cwd return cwd === '' ? `session:${sessionId}` : `workspace:${normalizeScopePath(cwd)}` }

同一工作区的多个会话命中同一个 store,文件树、打开的标签、Git 上下文自然共享;终端进程也因此跨会话存活。会话切换时要不要自动重新打开工作台,由一个纯函数 shouldContinueIde 决定:离开的会话里工作台确实可见(不是后台挂着)+ 两个会话同一工作区,两个条件同时满足才带过去——不会出现"我在 A 会话关了 IDE,切到 B 会话它突然弹出来"的惊吓。

路径归一化(normalizeScopePath)把 Windows 大小写不敏感、盘符/UNC 前缀、两种分隔符都考虑进去了,不然一棵树会被拆成两个作用域。这些规则全是纯函数,不用起浏览器就能测。

还有一个小坑值得记一笔:宿主刚切换会话时,右侧栏的 store 可能还没来得及认领新会话,这时调 openTab 会静默 no-op。处理方式是排队 + 有界重试(25~30ms 间隔、最多 40 次确认),确认打开成功立即停止,不做任何无限轮询。

3.5 终端与传输层

终端是真 PTY(宿主侧 Node 起伪终端,WebSocket 转发,前端 xterm.js 渲染),不是网页模拟 shell;多会话、重命名、PowerShell / Git Bash / cmd profile、选中即复制、面板缩放后向 PTY 发 resize 都有。

传输层有个跨环境的坑:HTTP 请求骑在 document.baseURI 上(反向代理前缀、Desktop 的 dsh-app://app/ 转发都能正确拼接),但 WebSocket 不能——自定义 scheme 的页面挂不了 ws: 协议,所以终端的 WS 地址要走宿主注入的 __DSH_TRANSPORT__.streamBaseUrl。两个 URL 各走各的基准,这是 Desktop 和 Web 两个 profile 都能装的原因之一。

3.6 安全:围栏先行

插件拿到了读写文件和跑命令的能力,就必须把围栏扎紧:

▼
js
复制代码
/** 所有文件路径的唯一入口:越界直接 403 */ export function resolveInside(root, input) { const absolute = isAbsolute(input) ? resolve(input) : resolve(root, input) const rel = relative(root, absolute) if (rel.startsWith('..') || isAbsolute(rel)) { throw new ApiError('forbidden', 'path is outside the workspace', 403) } return absolute }
  • 文件读写、目录树、文件操作(新建/重命名/删除走回收站)全部经这一个围栏,先 resolve 再 relative,把 ..、绝对路径逃逸、符号衔接全堵死;
  • 每个 HTTP / WebSocket 路由都过与宿主 /api 相同的 Connection 准入围栏,不是自己裸奔一个端口;
  • git 只跑白名单子命令,argv 直传,无 shell;
  • 编辑器保存是原子写,并保留原文件的编码(UTF-8 / BOM / UTF-16)和换行符(CRLF/LF)——这是很多网页编辑器会悄悄毁掉的东西;外部程序改了同名文件会弹冲突条,可以"从磁盘重新加载"或"覆盖磁盘",自己保存成功的回写不会误报冲突。

3.7 编辑器与预览

编辑器是 CodeMirror 6,三档打开模式(自动 / 始终可编辑 / 始终只读),自动档下 1 MiB 以内的文本直接可编辑,超过则只读预览,防止误开大文件把标签页卡死。Markdown 文件默认进渲染预览(复用宿主 MarkdownText,排版和聊天里完全一致),带大纲跳转和滚动跟随高亮,"预览 / 源码 / 编辑"一键切换。

四、诚实的取舍:已知限制

推广帖只夸优点没意思,这些是当前版本明确不做/没做的:

  • 项目选择器只扫工作区一级子目录,不递归发现仓库(多仓 monorepo 请手动选根);
  • DSH 重启后终端不恢复(PTY 随进程消亡);
  • 不做:交互式 rebase、merge 冲突三方编辑、stash、blame、fetch/pull、分屏终端——这些留给真正的 Git 客户端,工作台管好"提交前后的高频动作"就够了;
  • Markdown 预览暂不重写相对图片路径(http(s) 外链正常);
  • Ctrl+Shift+F 可能被 DSH/浏览器外层截获,所以提供了备用键 Ctrl+Alt+F。

五、工程化

先坦白开发方式,这也是这个项目最有意思的部分:代码基本全部由 DSH 生成,人只管需求、验收和反馈。循环大致是——我提需求("Commit 面板勾选时列表不许跳动"这种粒度)→ DSH 实现 → 单元测试过关后,再用 Windows UI 自动化对着真实 Desktop 界面把操作流程点一遍 → 我日常真用、发现问题、提反馈 → 它改。仓库里专门备了个 qa-sandbox 小项目当测试沙箱(一个带 Git 历史的迷你 demo 仓库),README 里的截图也是对着它截的。

  • 测试:npm test 跑 180 条测试——宿主侧的 git/diff/graph 解析(对着真实仓库验证)、组件 SSR、客户端回归(快捷键、作用域、聊天桥格式化都有覆盖)。聊天桥的格式化函数、路径围栏、会话作用域全是纯函数,就是为了能离线测;
  • 构建:esbuild 单文件产物,React 外置复用宿主实例(不带第二份 React);
  • 设置:$DSH_HOME/ide-workbench/settings.json,改完约 300ms 自动落盘并显示"已保存",关面板前不丢最后一次改动。

六、写在最后

这个插件的出发点很朴素:DSH 让"和 AI 对话改代码"变得可行,那工具侧也应该跟上,让"改代码"这件事不用离开对话的上下文。 聊天负责思考,工作台负责动手,两边在同一块屏幕上。

踩到坑、有想法、想要新工具窗,都欢迎提 Issue,我改得挺快。😂


截图来自 DSH Desktop 0.2.1(Windows 11 亮色主题),示例项目为测试用 Git 沙箱。

0个评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
下载 APP