dsh-open-any
给 DeepSeek Harness(DSH)的对话界面加「文件右键 → 打开方式」:对话里出现的文件,右键就能用本机任意应用打开。
┌──────────────────────────────┐
右键 a.ts │ a.ts │
──────▶ │ 用 VS Code 打开 │
│ 在文件资源管理器中打开 │ ← 一级菜单,不用展开
│ 打开方式 ▸ │──▶ VS Code / Cursor / Windows Terminal
│ ───────────────────────── │
│ 其他应用…(系统对话框) │ ← 本插件独有:可选出未注册关联的应用
│ ───────────────────────── │
│ 复制路径 │
└──────────────────────────────┘
它能做什么
| 菜单项 | 行为 |
|---|
| 用 <默认应用> 打开 | 用系统默认关联程序打开文件 |
| 在文件资源管理器中打开 | 一级菜单项;在文件管理器中定位该文件(Windows 用此措辞,macOS 为「在访达中显示」) |
| 打开方式 ▸ | 列出本机为该文件类型注册过关联的编辑器 / 终端(带真实图标与「(默认)」标记) |
| 其他应用…(系统对话框) | 唤起 Windows 原生「打开方式」对话框,可选未注册文件关联的应用(例如 Visual Studio) |
| 复制路径 | 复制绝对路径 |
识别范围:
| 元素 | DOM 特征 | 路径来源 |
|---|
| Markdown 文件链接 | button.fileLink | title 属性 |
输入框 / 消息里的 @文件 引用 | [data-ref-chip="file"] | title(@路径) |
| 路径标签(PathLabel) | [data-path-label] | title |
其它带 title 的元素(兜底) | 任意祖先的 title | title,仅当它「看着就是路径」时命中 |
相对路径会按当前正在查看的会话的工作目录补全成绝对路径(Host 侧只用进程 cwd 解析相对路径,所以必须在浏览器侧补全)。非文件位置右键仍弹浏览器原生菜单。
与同类插件的区别(诚实说明)
DSH 插件市场里已有几个同类插件(dsh-file-link-menu、dsh-right-click-menu、dsh-file-menu 等),本插件不是能力最全的那个。它只主张两件事:
- 「其他应用…(系统对话框)」:走
rundll32 shell32.dll,OpenAs_RunDLL,能选没有注册文件类型关联的应用。只列 workspacePathApplications 的插件永远够不到这类程序(例如 Visual Studio 不在 DSH 的应用目录里时)。
- 不拖垮启动的工程约束:client 插件在
apply 阶段绝不抛异常、绝不等待服务就绪,factory 外层还有一个永不抛错的兜底 apply。DSH 的 web boot 把任何非 active 的条目当致命错误(web boot: N entry did not activate),实测会让整个 GUI 打不开——本插件为此付出的是「服务全部延迟到事件发生时惰性解析」的设计。
如果你需要超链接右键、另存为、复制文件内容、文件树右键等能力,请直接用上表里更全的插件,不要重复造这个轮子。
安装
从 GitHub 安装(推荐)
在 DSH 的「设置 → 插件」或插件管理器的添加框里填:
github:xl0223/dsh-open-any
或用 DSH 自带 CLI(把 <profile> 换成你的 profile 名,例如 desktop):
dsh plugin --profile <profile> add github:xl0223/dsh-open-any
装完重启 DSH:client bundle 按文件 mtime 生成 rev 并版本化缓存,不重启不会重载。
本地源码调试
dsh plugin --profile <profile> add link:C:\path\to\dsh-open-any
改完 src/ 必须重新 node scripts/build.mjs(DSH 加载的是 lib/);改 lib/client.js 刷新页面即可,改 host 半侧要重启。
卸载
dsh plugin --profile <profile> remove dsh-open-any
或手工从 profile 的 package.json 里删掉 dsh.profile.bundles 与 dependencies 中的 dsh-open-any,重启。
兼容性
| 项 | 状态 |
|---|
| DSH 版本 | 0.2.0-rc.2 上完成真机验证(菜单、应用列表、打开、文案) |
| DSH 0.2.1-rc.2 | 已静态确认:外壳冻结的模块基座表里仍有 @deepseek-ai/dsh-client-ui-primitives(它被打包进外壳,不再有独立包目录);未做真机重验 |
| 平台 | Host 侧用 rundll32 / explorer / open / xdg-open,三平台都有实现,但只在 Windows 11 上实测过 |
| Node | 构建需要 Node ≥ 20;运行时只用 DSH 自带能力 |
开发
node scripts/build.mjs # 语法 + 契约校验 → lib/
node --test scripts/verify-client.test.mjs scripts/verify-host.test.mjs # 离线单测
node tools/check-host-contract.mjs # 与真实 app.asar 的模块/路由契约
node tools/probe-open-in-app.mjs # 真机探测:本机会列出哪些应用
一条命令跑全部:npm run check。
验证报告(真实执行)
平台:Windows 11;DSH D:\Ai_Agent\DeepSeek Harness(0.2.0-rc.2 真机 / 0.2.1-rc.2 静态)。
以下命令与数字均为实际执行结果。
1. 构建
$ node scripts/build.mjs
built ...\lib\client.js 34339 bytes sha256=f94b6dc6edee8545…
built ...\lib\index.js 15049 bytes sha256=131c5ddbfd27fb3f…
2. 离线单测:30 / 30 通过
覆盖:路径识别与清理、会话 cwd 补全、菜单模型(加载中 / 有应用 / 失败)、平台措辞、Session Remote 请求体、host 路由回退、词典命中/未命中、host 路由的栅栏与方法/媒体类型边界、假 DOM 端到端(右键 → 菜单 → 选「用 Cursor 打开」→ 断言请求体)、以及一组崩溃回归测试(apply 在六种恶劣上下文下必须不抛错且完成挂载)。
ℹ tests 30 ℹ pass 30 ℹ fail 0
3. 宿主契约(对真实 app.asar)
$ node tools/check-host-contract.mjs
基座模块: react/jsx-runtime, react-dom, react-dom/client, @deepseek-ai/cordis,
@tanstack/react-virtual, @deepseek-ai/dsh-client-store,
@deepseek-ai/dsh-client-ui-slots, @deepseek-ai/dsh-client-ui-primitives,
@deepseek-ai/dsh-client-ui-dockkit, react, zod
契约检查通过:所有 require 与导出名都存在。
4. 真机验证(0.2.0-rc.2)
右键对话里的文件链接 → 菜单弹出 → 应用列表来自本机 → 选应用后文件被打开;「其他应用…(系统对话框)」唤起 Windows 原生选择器(用 rundll32 实测进程出现)。
已知限制
- 0.2.1-rc.2 未真机重验:只确认了模块基座表,没有实际重启安装测试。
- 「其他应用…」仅 Windows 实现(
OpenAs_RunDLL);macOS / Linux 会返回 501。
- 图片类 Markdown 链接(有缩略图浮层、无
title)不命中。
- 只复制绝对路径,没有相对路径 / 文件名 / 文件内容 / 另存为。
- 不处理超链接、不处理文件树(侧边栏)内的右键。
- 未做
canOpenWorkspacePath 预检:文件不在工作区时,菜单仍会弹出、操作会失败并提示。
踩坑记录(给后来者)
- client 插件的
apply 绝不能抛异常,也不能等任何服务。
DSH 的 web boot 把非 active 条目当致命错误。静态 inject 会让 fiber 永远 pending;apply 里同步调 ctx.effect(...) + locale.register(...) 抛错则让 fiber failed —— 两者都会让整个 GUI 打不开(crash-*-web-boot.log: web boot: 1 entry did not activate / <插件>: failed)。正确姿势:factory 先挂一个 no-op exports.apply,真实实现包在 try 里;apply 只做 DOM/React,服务延迟到事件发生时惰性读取。
ctx.inject 要一个一个注。 把 ["slots","remote","sessions","locale"] 一次性交给一个 ctx.inject,只要有一个名字对不上就永远不回调,整条链路静默失效且不报错。改成每个服务一条 ctx.inject([name], cb) 后立刻正常。
locale.bind(ns)(key) 未命中时会原样返回 key。 照单全收就会把 open.default 这种裸 key 显示到菜单里。必须判断 返回值 !== key 再采用,否则回退内置字典。
- GUI 页面的 origin 是
dsh-app://app,不是 http://127.0.0.1:<port>。浏览器侧对相对路径的 fetch 不一定到得了 host 路由,排查时以 host 半侧写盘的证据为准。
- Host 侧路径必须先是绝对路径(它用
path.resolve 按进程 cwd 解析),相对路径要在浏览器侧按会话 cwd 补全。
文件结构
dsh-open-any/
├── package.json # bundle 清单:dsh.bundle.patch + dsh.client(platform: web)
├── cordis.patch.yml # 组合层:插入 open-any 这一行
├── icon.svg # 插件卡片图标
├── src/
│ ├── client.js # 浏览器半侧:右键监听 + 菜单 + Session Remote / host 路由调用
│ └── host.js # host 半侧:4 条带信任围栏的路由
├── lib/ # 构建产物(DSH 实际加载,随仓库提交)
├── scripts/
│ ├── build.mjs # 构建:语法 + 契约校验
│ ├── verify-client.test.mjs
│ └── verify-host.test.mjs
├── tools/
│ ├── check-host-contract.mjs # 对真实 app.asar 校验模块与导出名
│ └── probe-open-in-app.mjs # 真机探测本机应用目录
├── README.md / PROGRESS.md / AGENTS.md
└── LICENSE (MIT)
Host 侧路由(全部先过 connection.requestRejection 信任栅栏,再校验方法、媒体类型、64 KiB 请求体上限与路径存在性):
| 路由 | 用途 |
|---|
POST /dsh-open-any/open-as | 唤起系统「打开方式」对话框 |
POST /dsh-open-any/open-file | 用系统默认应用打开(Session Remote 不可用时的后备) |
POST /dsh-open-any/reveal | 在文件管理器中定位 |
POST /dsh-open-any/diag | 诊断上报落盘(%TEMP%\dsh-open-any-diag.log) |
License
MIT