dsh-artifact-linker 0.2.6 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,154 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.3 — 2026-10-28
4
+
5
+ ### 修:先确认文件存在,才让它变成芯片
6
+
7
+ 之前的判定只看「文字形状」——正文里任何像文件名的片段都会被画成可点芯片(悬停变手型、蓝色虚线高亮、右键弹自己的菜单)。日志摘录、示例路径、报错原文全中招;点开只会得到一句「没解析到这个文件」。
8
+
9
+ 这条提示本身就是设计错误:**给出一个点不开的入口,比根本不认识这个文件更糟**。
10
+
11
+ 改法是加一道存在性闸门:
12
+
13
+ - 悬停命中候选后先异步 `resolve`,**只有文件真的存在**才变手型 + 画虚线;
14
+ - 右键时若已确认「不是产物」,直接**放行宿主原生菜单**,绝不 `preventDefault` 抢走;
15
+ - 左键同理,确认不是产物就什么都不做;
16
+ - 解析结果进 30 秒 TTL 缓存(`raw → {ok, abs}`),同一段文字反复悬停不重复请求,同一次事件里的并发查询合并成一次往返;
17
+ - 彻底删掉「没解析到这个文件」提示——静默收场,不装作成功也不打扰。
18
+
19
+ 副作用是好的:正文里的普通文字现在**看起来就是普通文字**,不会再有一堆点不动的伪芯片。
20
+
21
+ ## 0.3.2 — 2026-10-28
22
+
23
+ ### 修:「在文件夹中显示」改成 Explore 开窗 + SelectItem(29) 选中
24
+
25
+ 0.3.1 换成了 `SHOpenFolderAndSelectItems`,`hr = 0`、`SelectedItems()` 也真有值,但用户实测**窗口完全不弹**。这个 API 是「在已打开的窗口里定位」语义,没有现成窗口时什么也不显示,不能当「打开」入口用。
26
+
27
+ 同时回头查清了 0.2.8 的老毛病:`SelectItem($item, 1)` 只带 `SVSI_SELECT`,在窗口刚打开、目录项还没填充好的瞬间调用会静默无效——当时既没轮询也没校验 `SelectedItems()`,只看到脚本退出码 0 就记「成功」,于是「日志全绿、实际没选中」。
28
+
29
+ 现在分工明确:
30
+
31
+ - **开窗**交给 `Shell.Application.Explore($dir)`——实测唯一可靠的开窗入口;
32
+ - **选中**交给 `SelectItem($item, 29)`,`29 = SVSI_SELECT | SVSI_DESELECTOTHERS | SVSI_ENSUREVISIBLE | SVSI_FOCUSED`,缺 `ENSUREVISIBLE` 就不会滚到可视区;
33
+ - 两处都加轮询:`Windows()` 最多等 6 秒,`ParseName` 最多等 6 秒;
34
+ - **成功判据改为 `SelectedItems()` 非空**,不再是「脚本没报错」。
35
+
36
+ 实测(`powershell.exe` 5.1,与插件同款;跑前先关掉目标目录全部窗口):`window_found=yes`、`item_found=yes`、`sel_count=1`、`sel_item=目标文件`。
37
+
38
+ 降级链不变:拿不到窗口(exit 5)、拿不到目录项(exit 3)、选不中(exit 4)→ 退回 `explorer.exe <目录>`,保证至少能开。
39
+
40
+ ## 0.3.1 — 2026-10-28
41
+
42
+ ### 修:「在文件夹中显示」只打开目录、不定位到文件
43
+
44
+ 0.2.8 把 reveal 从 `explorer.exe /select` 换成了 `Shell.Application` COM(`Explore` + 轮询 `Windows()` + `SelectItem`)。脚本退出码 0、日志写「成功」,但用户实测**只打开了目录、没有自动定位到文件**。
45
+
46
+ 根因:跨进程 COM 里 `$w.Document.Folder.ParseName($name)` 拿到的 `FolderItem` 与目标窗口不保证落在同一 PIDL 空间,`SelectItem` 会**静默失败**——不报错,也不选中。日志之所以显示「成功」,是因为脚本确实正常退出了。
47
+
48
+ 改用 `shell32!SHOpenFolderAndSelectItems`(资源管理器自己执行「在文件夹中显示」时调的就是它):`ILCreateFromPath` 建 PIDL → 一次 API 调用完成「打开目录 + 选中项」→ `ILFree` 释放。不依赖窗口枚举,也没有跨进程 COM 的对象归属问题。
49
+
50
+ 实测:`hr = 0`,`SelectedItems()` 立即返回目标文件名。
51
+
52
+ 降级链保留:PIDL 建不出(exit 3)或 API 非 0(exit 4)→ 退回 `explorer.exe <dir>`,保证「至少能开」。
53
+
54
+ ## 0.3.0 — 2026-10-28
55
+
56
+ ### 新增:右键菜单补齐「搬运」和「喂给 AI」两类动作
57
+
58
+ 原来右键只有三项(打开 / 定位 / 复制路径),文件能看见、能打开,但**搬不走**。这次补三项,并按用途分组:
59
+
60
+ | 分组 | 菜单项 | 做什么 |
61
+ |---|---|---|
62
+ | 看 | 用默认程序打开 | `explorer.exe <file>`,交给系统默认关联程序 |
63
+ | 看 | 在文件夹中显示 | Shell COM 定位并选中(0.2.8 已修) |
64
+ | 搬运 | **另存为…** | 弹系统保存窗口,把文件复制到用户选的位置 |
65
+ | 搬运 | **复制文件** | 文件本身进剪贴板(`CF_HDROP`),可粘到任意文件夹 |
66
+ | 搬运 | 复制文件路径 | 只复制路径文字 |
67
+ | 喂 AI | **添加到对话框** | 把绝对路径插入上方聊天输入框 |
68
+
69
+ 菜单项之间加了 1px 分隔线,视觉上把三组分开。
70
+
71
+ ### 「复制文件」不是「复制路径」
72
+
73
+ 这两个必须分清,因为用户拿到的东西完全不同:
74
+
75
+ - **复制文件路径** → 剪贴板里是字符串,粘到记事本得到一行文字;
76
+ - **复制文件** → 剪贴板里是 `CF_HDROP`(文件列表),粘到资源管理器得到**文件副本本身**。
77
+
78
+ 实现走 `Set-Clipboard -LiteralPath`(PowerShell 5.1+ 能力)。本机实测:单个文件、含中文与空格的文件名(`路径 带空格.pdf`)都能正确写入并被 `Get-Clipboard -Format FileDropList` 读回。
79
+
80
+ ### 「另存为」的两个坑
81
+
82
+ Node 侧没有跨平台的保存对话框,Windows 只能用 WinForms `SaveFileDialog`,而它:
83
+
84
+ 1. **必须跑在 STA 线程** —— 默认 MTA 直接抛 `InvalidOperationException`;
85
+ 2. **不给 owner 窗口时会弹到主窗口背后** —— 用户以为点了没反应,这是最容易踩的一条。
86
+
87
+ 所以脚本里既加了 `-STA`,又造了一个 1×1、`Opacity=0`、`TopMost` 的隐形 owner form;对话框返回后 `finally` 里关掉它。超时给到 10 分钟(用户可能慢慢挑目录、当场新建文件夹)。
88
+
89
+ 用户点「取消」时 PowerShell `exit 1`,这**不是错误**,路由返回 `reason: "cancelled"`,客户端静默不弹红字。
90
+
91
+ 另外挡掉了一种会毁数据的输入:**目标路径与源路径相同**。`copyFileSync` 对同一路径会抛 `EINVAL`,极端情况可能把原文件截断,所以提前返回 `reason: "same-path"`。
92
+
93
+ ### 「添加到对话框」为什么要三种写法兜底
94
+
95
+ DSH 的输入框会随版本变(`textarea` / `contenteditable` / Lexical 都出现过),所以不写死单个选择器,而是:
96
+
97
+ 1. 一批候选选择器逐个试,按「宽度 ≥150px、可见、非只读」筛出最像输入区的那一个(从 DOM 末尾往前找,聊天输入框通常在最后);
98
+ 2. **优先 `document.execCommand("insertText")`** —— 走浏览器真实输入管线,React / Lexical 的受控状态能同步更新;
99
+ 3. 回退到原生 `value` + `input` 事件(textarea / input);
100
+ 4. 再回退到 `textContent` 追加 + `input` 事件(contenteditable)。
101
+
102
+ 三步全失败就明确提示「没找到聊天输入框」,**绝不假装成功**。
103
+
104
+ ### 安全边界
105
+
106
+ - 新增路由同样只接受 loopback 请求,非回环来源一律 403;
107
+ - `/saveas` `/copyfile` 是**有副作用**的文件操作,比 reveal/open 多挡三种输入:目录、不存在的文件、目标==源;
108
+ - 路径全部经 `statOne` 先行确认,参数走 `execFile` 数组不经 shell,无命令注入面。
109
+
110
+ ### 校验
111
+
112
+ 五套探针共 **105 项全绿**:
113
+
114
+ ```
115
+ probe-client-materialize 13/13
116
+ probe-host-apply 21/21 (路由断言同步到 8 条)
117
+ probe-regex 39/39
118
+ probe-batch2 22/22
119
+ probe-real-files 10/10
120
+ ```
121
+
122
+ ---
123
+
124
+ ## 0.2.8 — 2026-10-28
125
+
126
+ ### 修复:Windows「在资源管理器里揭示」点了没反应
127
+
128
+ `explorer.exe /select,"<路径>"` 在本机实测**根本不起作用**:命令确实发出、退出码 1、日志无异常,但资源管理器既不新开窗口、也不改变选中项。四种传参写法(整串带引号 / 裸路径 / 拆成两个 argv / 去掉 `windowsVerbatimArguments`)逐一试过,全部无效 —— 请求转交给已运行的 explorer 实例时,选中意图被丢掉了。
129
+
130
+ 改为走 **Shell COM**:`Shell.Application.Explore(dir)` 打开目录,再用 `Folder.ParseName(name)` + `Document.SelectItem(item, 1)` 精确选中文件。已验证 `SelectedItems()` 能返回目标文件,中文文件名与深层路径均正常。
131
+
132
+ 实现细节:
133
+
134
+ - PowerShell 脚本经 `-EncodedCommand` 以 **UTF-16LE Base64** 传入,绕开控制台代码页 —— 直接 `-Command` 传中文路径会变成 mojibake;
135
+ - `windowsHide` 只隐藏 `powershell.exe` 自身(不闪黑窗),COM 拉起的资源管理器窗口照常显示;
136
+ - 轮询等待目标窗口出现(最多约 3 秒);失败时降级为「打开父目录」,保证不会毫无反应。
137
+
138
+ `open`(默认程序打开)不受影响,仍是 `explorer.exe <file>`,实测对中文/空格路径有效。
139
+
140
+ ---
141
+
142
+ ## 0.2.7 — 2026-10-28
143
+
144
+ ### 文档:加上实机截图
145
+
146
+ README 中英双版各嵌入三张真实运行截图 —— 悬停高亮、右键菜单、复制路径 —— 放在仓库 `docs/` 下。
147
+
148
+ 截图用 `raw.githubusercontent.com` 绝对地址引用,GitHub 与 npm 页面都能正常渲染;`files` 白名单不含 `docs`,所以**图片不进 npm 包体**,包大小不变。
149
+
150
+ ---
151
+
3
152
  ## 0.2.6 — 2026-10-28
4
153
 
5
154
  ### 修掉两处「看不见的破窗」
package/README.md CHANGED
@@ -26,11 +26,25 @@ Two halves, one contract.
26
26
 
27
27
  | Half | Responsibility |
28
28
  | --- | --- |
29
- | **Host** (`lib/index.js`) | Registers a **resident system-prompt section** so "declare artifacts with full absolute paths" holds in *every* session and *every* preset — no skill needs to be loaded. Exposes six loopback-only routes for path resolution, `stat`, reveal and open. |
29
+ | **Host** (`lib/index.js`) | Registers a **resident system-prompt section** so "declare artifacts with full absolute paths" holds in *every* session and *every* preset — no skill needs to be loaded. Exposes eight loopback-only routes for path resolution, `stat`, reveal, open, save-as and copy-file. |
30
30
  | **Client** (`lib/client.js`) | Turns artifact names in the transcript into clickable targets: left click reveals, right click opens a menu. **No resident UI, no scanning, no timers.** |
31
31
 
32
32
  The host half closes the loop: even when the model only says `result.h5`, the route layer resolves it against the session workspace, then falls back to a shallow recursive basename search, and returns the real absolute path.
33
33
 
34
+ ## What it looks like
35
+
36
+ Hover an artifact name and it picks up a Codex-style blue dashed outline — that is the only visual hint, nothing is rewritten in your transcript:
37
+
38
+ ![Hover highlight](https://raw.githubusercontent.com/loyalchiiina/dsh-artifact-linker/main/docs/hover-highlight.png)
39
+
40
+ Right click for the three actions:
41
+
42
+ ![Context menu](https://raw.githubusercontent.com/loyalchiiina/dsh-artifact-linker/main/docs/context-menu.png)
43
+
44
+ "Copy file path" puts the real absolute path on your clipboard:
45
+
46
+ ![Copy path](https://raw.githubusercontent.com/loyalchiiina/dsh-artifact-linker/main/docs/copy-path.png)
47
+
34
48
  ## Interaction
35
49
 
36
50
  Click behaviour is decided by the modifier key — no menu, no first-time setup:
@@ -99,6 +113,7 @@ On `allowOutside`: artifacts almost always live on a project drive (`E:\`, `D:\`
99
113
 
100
114
  - **Never modifies a single DOM node.** React owns the transcript; mutating its nodes causes blank renders on re-render. Clickability comes from a capture-phase `click` listener plus `caretRangeFromPoint` to work out which word was hit.
101
115
  - **Never takes over markdown image or link syntax.** Rewriting `![](path)` into a chip would break the image.
116
+ - **Never draws a chip for a file that does not exist.** Chat text is full of filename-shaped fragments — log excerpts, example paths, error messages. Turning those into chips produces a clickable target that only ever answers "not resolved", which is worse than not recognising the file at all. So there is an existence gate: on hover the client resolves asynchronously and only becomes a pointer / dashed highlight when the file is really there. Confirmed non-artifacts stay plain text and the right-click falls through to the host's native menu. Results are cached for 30 s.
102
117
  - **Never writes a file, never phones home.** Zero telemetry. The only `spawn` calls are the explicit `/reveal` and `/open` routes, using `execFile` with an argument array (no shell, no injection surface), guarded by a loopback-origin check.
103
118
  - **Never renames or moves anything.** Read-only by construction.
104
119
  - **Does not unify with the official `present` card.** If an artifact was already delivered as a card, the injected rules tell the model not to repeat it.
@@ -121,7 +136,7 @@ dsh-artifact-linker/
121
136
  ├── package.json # dsh.bundle + dsh.client, peer >=0.2.1-rc.3
122
137
  ├── cordis.patch.yml # the patch entry (never duplicate this id)
123
138
  ├── lib/
124
- │ ├── index.js # host: prompt section + 6 loopback routes
139
+ │ ├── index.js # host: prompt section + 8 loopback routes
125
140
  │ └── client.js # client: __ModuleLoader__ bundle, read-only DOM
126
141
  ├── locale/{zh,en}.json # meta.title / meta.description
127
142
  ├── icon.svg
package/README.zh.md CHANGED
@@ -25,6 +25,20 @@ DSH 里预览器已经能开图片、代码、pdf、office。但**插件、脚
25
25
 
26
26
  第 1 条管「模型把路径写对」,第 2 条管「用户点得动」。缺前者,路径经常是猜的、点开是死链;缺后者,路径再对也要人去复制。
27
27
 
28
+ ## 长什么样
29
+
30
+ 悬停到产物名上,会出现 Codex 风格的蓝色虚线框——这是唯一的视觉提示,正文一个字都不会被改写:
31
+
32
+ ![悬停高亮](https://raw.githubusercontent.com/loyalchiiina/dsh-artifact-linker/main/docs/hover-highlight.png)
33
+
34
+ 右键是三个动作:
35
+
36
+ ![右键菜单](https://raw.githubusercontent.com/loyalchiiina/dsh-artifact-linker/main/docs/context-menu.png)
37
+
38
+ 「复制文件路径」把真实绝对路径放进剪贴板:
39
+
40
+ ![复制路径](https://raw.githubusercontent.com/loyalchiiina/dsh-artifact-linker/main/docs/copy-path.png)
41
+
28
42
  ---
29
43
 
30
44
  ## 交互
@@ -134,6 +148,7 @@ GET http://127.0.0.1:<port>/dsh-artifact-linker/info
134
148
  4. **不 spawn shell**。`reveal`/`open` 走 `execFile(cmd, args)` 参数数组,路径里的 `& | ; %VAR%` 全是字面量,没有命令注入面。
135
149
  5. **不猜多候选路径**。搜到多个同名文件时把选择权还给人。
136
150
  6. **不保证文件存在**。库里的文件随时可能被删、被移走、被别的进程占用——所以每次打开前都重新 stat,点开发现没了就明说「已不存在」,不会装作没事。
151
+ 7. **不给不存在的文件画芯片**。正文里长得像文件名的片段很多(日志摘录、示例路径、报错原文),只看「形状」就画成可点芯片,点开只会得到一句「没解析到」——那比根本不认识更糟。所以判定加了存在性闸门:悬停时先异步 `resolve`,**只有文件真的在**才变手型、画蓝色虚线;确认不是产物的就当普通文字,右键直接放行宿主原生菜单。解析结果进 30 秒 TTL 缓存,反复悬停不重复请求。
137
152
 
138
153
  ---
139
154
 
@@ -145,7 +160,7 @@ GET http://127.0.0.1:<port>/dsh-artifact-linker/info
145
160
  | 点开提示「解析不到绝对路径」 | 裸文件名不在 workspace 内,且 `allowOutside: false`。先确认文件真实路径,再决定是否放宽 |
146
161
  | 点开提示「文件已不存在」 | 路径是真的,文件被删/移了。清单里该行会打上删除线 |
147
162
  | 点产物名没反应 | ① 文件不在候选 base 下(访问宿主 `/info` 看 `bases`);② 扩展名不在白名单(`lib/client.js` 的 `EXT`);③ 直接看 `/resolve?p=<名字>` 返回的 `reason` |
148
- | 右键没出本插件菜单 | 该处文字没解析成文件(同上);**对非产物右键本来就不接管**,出的是宿主原生菜单 |
163
+ | 右键没出本插件菜单 | 该处文字没解析成文件(同上);**对非产物右键本来就不接管**,出的是宿主原生菜单。0.3.3 起会先确认文件存在——不存在的片段既不画虚线也不接管右键 |
149
164
  | 日志里出现 N 份相同输出 | 热重载堆叠了多个实例。**Ctrl+R 无效,必须完全重启 DSH**;同时确认 `window.__dshArtifactLinkerV1.dispose()` 守卫在 |
150
165
 
151
166
  ---
@@ -157,7 +172,7 @@ dsh-artifact-linker/
157
172
  ├── package.json # dsh.bundle + dsh.client(两个都要,缺 bundle 装不上)
158
173
  ├── cordis.patch.yml # 插件注册补丁(entry id = 包名,唯一)
159
174
  ├── lib/
160
- │ ├── index.js # 宿主:prompt 段 + 6 条回环路由 + 三源路径解析
175
+ │ ├── index.js # 宿主:prompt 段 + 8 条回环路由 + 多源路径解析
161
176
  │ └── client.js # 客户端:__ModuleLoader__.load bundle(id 必须 = 包名)
162
177
  ├── locale/{zh,en}.json
163
178
  ├── icon.svg
package/lib/client.js CHANGED
@@ -29,7 +29,7 @@ window.__ModuleLoader__.load({
29
29
  var exports = module.exports;
30
30
 
31
31
  var GUARD = "__dshArtifactLinkerV1";
32
- var VERSION = "0.2.6";
32
+ var VERSION = "0.3.3";
33
33
  // 路由前缀必须与宿主的 ROUTE_PREFIX 一致;永远根相对,绝不拼宿主 origin
34
34
  var ROUTE = "/dsh-artifact-linker";
35
35
 
@@ -190,6 +190,74 @@ window.__ModuleLoader__.load({
190
190
  menuEl = null;
191
191
  }
192
192
 
193
+ // ── 「添加到对话框」:把路径插进 DSH 的聊天输入框 ────────────────────────
194
+ // DSH 的输入框实现会随版本变(textarea / contenteditable / Lexical 都出现过),
195
+ // 所以这里不写死单个选择器,而是「一批候选 → 挑最像输入区的那个 → 三种写入方式兜底」。
196
+ // 任何一步都失败就返回 false,由调用方明确提示 —— 绝不假装成功。
197
+ var INPUT_SELECTORS = [
198
+ 'textarea[placeholder]',
199
+ '[contenteditable="true"]',
200
+ '[role="textbox"]',
201
+ '[data-lexical-editor="true"]',
202
+ '.composer [contenteditable]',
203
+ 'textarea'
204
+ ];
205
+
206
+ function findComposer() {
207
+ for (var s = 0; s < INPUT_SELECTORS.length; s++) {
208
+ var list;
209
+ try { list = document.querySelectorAll(INPUT_SELECTORS[s]); } catch (e) { continue; }
210
+ // 从后往前:聊天输入框通常位于 DOM 末尾
211
+ for (var k = list.length - 1; k >= 0; k--) {
212
+ var el = list[k];
213
+ if (!el) continue;
214
+ if (el.disabled || el.readOnly) continue;
215
+ var r = el.getBoundingClientRect();
216
+ // 太窄太矮多半是搜索框 / 标签栏;真正的输入区宽度都在 150px 以上
217
+ if (r.width < 150 || r.height < 18) continue;
218
+ var cs = window.getComputedStyle ? window.getComputedStyle(el) : null;
219
+ if (cs && (cs.display === "none" || cs.visibility === "hidden")) continue;
220
+ return el;
221
+ }
222
+ }
223
+ return null;
224
+ }
225
+
226
+ function quoteToComposer(abs) {
227
+ var el = findComposer();
228
+ if (!el) return false;
229
+ var text = " " + abs;
230
+ try { el.focus(); } catch (e) { }
231
+
232
+ // ① 首选 execCommand:走浏览器真实输入管线,React / Lexical 的受控状态
233
+ // 能同步更新;直接改 textContent 会让框架内部状态与 DOM 脱档。
234
+ try {
235
+ if (document.execCommand && document.execCommand("insertText", false, text)) return true;
236
+ } catch (e) { }
237
+
238
+ // ② 原生 value 控件(textarea / input)
239
+ try {
240
+ if (typeof el.value === "string") {
241
+ var a = el.selectionStart == null ? el.value.length : el.selectionStart;
242
+ var b = el.selectionEnd == null ? el.value.length : el.selectionEnd;
243
+ el.value = el.value.slice(0, a) + text + el.value.slice(b);
244
+ el.selectionStart = el.selectionEnd = a + text.length;
245
+ el.dispatchEvent(new Event("input", { bubbles: true }));
246
+ return true;
247
+ }
248
+ } catch (e) { }
249
+
250
+ // ③ 最后兜底:contenteditable 直接追加。极端情况下框架状态会不同步,
251
+ // 但用户至少看得见路径已经进去,可以自己删改,比「点了没反应」强。
252
+ try {
253
+ el.textContent = (el.textContent || "") + text;
254
+ el.dispatchEvent(new Event("input", { bubbles: true }));
255
+ return true;
256
+ } catch (e) { }
257
+
258
+ return false;
259
+ }
260
+
193
261
  function showMenu(x, y, abs) {
194
262
  hideMenu();
195
263
  if (!document || !document.body) return;
@@ -215,15 +283,35 @@ window.__ModuleLoader__.load({
215
283
  var ICON = {
216
284
  reveal: '<path d="M3 7.2A1.7 1.7 0 0 1 4.7 5.5h3.9l1.8 2.1h8.9A1.7 1.7 0 0 1 21 9.3v8A1.7 1.7 0 0 1 19.3 19H4.7A1.7 1.7 0 0 1 3 17.3Z"/><path d="M9 13.6h6"/>',
217
285
  open: '<path d="M14.5 4.5h5v5"/><path d="M19.7 4.3 11 13"/><path d="M18 13.8v4.5A1.7 1.7 0 0 1 16.3 20H5.7A1.7 1.7 0 0 1 4 18.3V7.7A1.7 1.7 0 0 1 5.7 6h4.4"/>',
218
- copy: '<rect x="9.2" y="9.2" width="10.8" height="10.8" rx="2.2"/><path d="M15 5.8A1.8 1.8 0 0 0 13.2 4H5.8A1.8 1.8 0 0 0 4 5.8v7.4A1.8 1.8 0 0 0 5.8 15"/>'
286
+ copy: '<rect x="9.2" y="9.2" width="10.8" height="10.8" rx="2.2"/><path d="M15 5.8A1.8 1.8 0 0 0 13.2 4H5.8A1.8 1.8 0 0 0 4 5.8v7.4A1.8 1.8 0 0 0 5.8 15"/>',
287
+ // 另存为:向下箭头入托盘
288
+ saveas: '<path d="M12 3.5v9.5"/><path d="m8.6 9.6 3.4 3.4 3.4-3.4"/><path d="M4.5 16.2v2.1a1.8 1.8 0 0 0 1.8 1.8h11.4a1.8 1.8 0 0 0 1.8-1.8v-2.1"/>',
289
+ // 复制文件:两张纸 + 中间的文件线,和「复制路径」区分开
290
+ copyfile: '<rect x="8.6" y="3.4" width="11" height="13" rx="2"/><path d="M5.4 7.4h-.7A1.7 1.7 0 0 0 3 9.1v9.4a1.7 1.7 0 0 0 1.7 1.7h8.4a1.7 1.7 0 0 0 1.7-1.7v-.7"/><path d="M11.6 8.2h5"/><path d="M11.6 11.4h5"/>',
291
+ // 添加到对话框:带横线的气泡
292
+ quote: '<path d="M20.5 11.6a8.3 8.3 0 0 1-8.3 8.3H7.4L3.5 21.9l1-4.1A8.3 8.3 0 1 1 20.5 11.6Z"/><path d="M9 11h6"/><path d="M9 14.2h3.6"/>'
219
293
  };
220
294
  // 文案要说人话:「资源管理器」这个词很多用户不认识,改成「文件夹窗口」。
295
+ // 顺序按「最常做的动作」排,并用 "sep" 占位分成三组:
296
+ // 看(打开 / 定位)→ 搬运(另存为 / 复制文件 / 复制路径)→ 喂给 AI(添加到对话框)
221
297
  var items = [
222
- ["reveal", "在文件夹中显示", "打开 Windows 文件夹窗口,并选中这个文件"],
223
298
  ["open", "用默认程序打开", "按文件类型用对应软件打开:PDF 用阅读器、表格用 Excel 那类"],
224
- ["copy", "复制文件路径", "把完整路径复制到剪贴板"]
299
+ ["reveal", "在文件夹中显示", "打开 Windows 文件夹窗口,并选中这个文件"],
300
+ ["sep", "", ""],
301
+ ["saveas", "另存为…", "另存一份到别的位置(会弹出系统保存窗口)"],
302
+ ["copyfile", "复制文件", "把文件本身放进剪贴板,可直接粘到任意文件夹"],
303
+ ["copy", "复制文件路径", "只复制路径文字,不复制文件本身"],
304
+ ["sep", "", ""],
305
+ ["quote", "添加到对话框", "把完整路径插入上方的聊天输入框"]
225
306
  ];
226
307
  for (var i = 0; i < items.length; i++) {
308
+ // 分隔线:一个 1px 的 div,左右留白对齐文字缩进,不加任何可点区域
309
+ if (items[i][0] === "sep") {
310
+ var hr = document.createElement("div");
311
+ hr.style.cssText = "height:1px;margin:4px 8px;background:#33363e";
312
+ box.appendChild(hr);
313
+ continue;
314
+ }
227
315
  (function (action, label, tip) {
228
316
  var row = document.createElement("div");
229
317
  row.title = tip;
@@ -241,6 +329,8 @@ window.__ModuleLoader__.load({
241
329
  ev.preventDefault();
242
330
  ev.stopPropagation();
243
331
  hideMenu();
332
+
333
+ // ① 只复制路径文字 —— 纯客户端,不必过宿主
244
334
  if (action === "copy") {
245
335
  try {
246
336
  navigator.clipboard.writeText(abs);
@@ -248,6 +338,41 @@ window.__ModuleLoader__.load({
248
338
  } catch (e) { toast("复制失败"); }
249
339
  return;
250
340
  }
341
+
342
+ // ② 添加到对话框 —— 也是纯客户端:找输入框、把路径插进去
343
+ if (action === "quote") {
344
+ if (quoteToComposer(abs)) toast("已添加到对话框");
345
+ else toast("没找到聊天输入框");
346
+ return;
347
+ }
348
+
349
+ // ③ 另存为 —— 宿主会弹系统保存窗,用户挑位置期间不能提前报成功
350
+ if (action === "saveas") {
351
+ toast("请在弹出窗口里选择保存位置…");
352
+ hostGet("/saveas", { p: abs }).then(function (r) {
353
+ if (!r || !r.ok) {
354
+ // 用户自己点取消不是失败,别弹红字打扰
355
+ if (r && r.reason === "cancelled") return;
356
+ if (r && r.reason === "same-path") { toast("不能存回原文件"); return; }
357
+ toast("另存为失败:" + ((r && r.reason) || "未知原因"));
358
+ return;
359
+ }
360
+ var short = String(r.dest).split(/[\\/]/).pop() || r.dest;
361
+ toast("已另存为 " + short);
362
+ });
363
+ return;
364
+ }
365
+
366
+ // ④ 复制文件本体(CF_HDROP),粘到文件夹里得到的是文件
367
+ if (action === "copyfile") {
368
+ hostGet("/copyfile", { p: abs }).then(function (r) {
369
+ if (!r || !r.ok) { toast("复制失败:" + ((r && r.reason) || "未知原因")); return; }
370
+ toast("已复制文件,可直接粘贴");
371
+ });
372
+ return;
373
+ }
374
+
375
+ // ⑤ 打开 / 在文件夹中显示
251
376
  hostGet("/" + action, { p: abs }).then(function (r) {
252
377
  if (!r || !r.ok) toast("打开失败:" + ((r && r.reason) || "未知原因"));
253
378
  });
@@ -267,22 +392,64 @@ window.__ModuleLoader__.load({
267
392
 
268
393
  // ── 事件处理 ────────────────────────────────────────────────────────────
269
394
 
270
- function act(raw, mode, x, y) {
395
+ // 产物存在性缓存:raw → { ok, abs, at, pending, waiters }
396
+ // 为什么需要这一层:正文里长得像文件名的片段太多了(日志摘录、示例路径、报错原文,
397
+ // 甚至我自己贴出来的乱码文件名)。只看「形状」就画成可点芯片,用户点开只会得到
398
+ // 一句「没解析到这个文件」——那比根本不认识更糟。现在改成:
399
+ // **先确认文件真的存在,才允许它变成芯片**。不存在的,就当普通文字,静默。
400
+ var vcache = Object.create(null);
401
+ var VCACHE_TTL = 30000;
402
+
403
+ function verify(raw, cb) {
404
+ var now = Date.now();
405
+ var c = vcache[raw];
406
+ if (c && c.pending) { c.waiters.push(cb); return; }
407
+ if (c && (now - c.at) < VCACHE_TTL) { cb(c); return; }
408
+
409
+ var rec = { ok: false, abs: null, at: now, pending: true, waiters: [cb] };
410
+ vcache[raw] = rec;
411
+ function settle(ok, abs) {
412
+ rec.ok = ok;
413
+ rec.abs = abs;
414
+ rec.at = Date.now();
415
+ rec.pending = false;
416
+ var ws = rec.waiters;
417
+ rec.waiters = [];
418
+ for (var i = 0; i < ws.length; i++) { try { ws[i](rec); } catch (e) { } }
419
+ }
271
420
  hostGet("/resolve", { p: raw }).then(function (d) {
421
+ var ok = !!(d && d.ok && d.abs);
422
+ settle(ok, ok ? d.abs : null);
423
+ }).catch(function () { settle(false, null); });
424
+ }
425
+
426
+ // 只有「已有结论且未过期」时才返回,pending 一律当没有 → 调用方自己决定等不等
427
+ function cached(raw) {
428
+ var c = vcache[raw];
429
+ if (!c || c.pending) return null;
430
+ if ((Date.now() - c.at) >= VCACHE_TTL) return null;
431
+ return c;
432
+ }
433
+
434
+ function setCursor(v) {
435
+ if (v === lastCursor) return;
436
+ lastCursor = v;
437
+ document.body.style.cursor = v;
438
+ }
439
+
440
+ function act(raw, mode, x, y) {
441
+ verify(raw, function (rec) {
272
442
  if (disposed) return;
273
- if (!d || !d.ok || !d.abs) {
274
- // 解析不到就当普通文字,绝不装作成功
275
- if (mode === "menu") toast("没解析到这个文件");
276
- return;
277
- }
443
+ // 解析不到就当普通文字,静默收场:不弹提示、不装作成功。
444
+ if (!rec.ok || !rec.abs) return;
278
445
  if (mode === "reveal") {
279
- hostGet("/reveal", { p: d.abs }).then(function (r) {
446
+ hostGet("/reveal", { p: rec.abs }).then(function (r) {
280
447
  if (disposed) return;
281
448
  if (!r || !r.ok) toast("打开失败:" + ((r && r.reason) || "未知原因"));
282
449
  });
283
450
  return;
284
451
  }
285
- showMenu(x, y, d.abs);
452
+ showMenu(x, y, rec.abs);
286
453
  });
287
454
  }
288
455
 
@@ -299,6 +466,9 @@ window.__ModuleLoader__.load({
299
466
  // (defaultPrevented 仍为 false)时才补一次 reveal。
300
467
  setTimeout(function () {
301
468
  if (ev.defaultPrevented) return; // 宿主已经处理(例如开了侧边栏预览)→ 让路,不抢
469
+ var c = cached(hit.raw);
470
+ // 已确认「根本不是产物」→ 什么都不做(别为一段普通文字去弹资源管理器)
471
+ if (c && !c.ok) return;
302
472
  act(hit.raw, "reveal", ev.clientX, ev.clientY);
303
473
  }, 0);
304
474
  }
@@ -312,8 +482,13 @@ window.__ModuleLoader__.load({
312
482
  }
313
483
  var hit = hitAt(ev.clientX, ev.clientY);
314
484
  if (!hit) return; // 非产物处右键 → 放行宿主原生菜单
485
+ var c = cached(hit.raw);
486
+ // 已确认不是产物 → 同样放行宿主原生菜单,绝不抢
487
+ if (c && !c.ok) return;
315
488
  ev.preventDefault();
316
489
  ev.stopPropagation();
490
+ // 有结论且确实存在 → 直接弹自己的菜单,省一次往返
491
+ if (c && c.ok) { showMenu(ev.clientX, ev.clientY, c.abs); return; }
317
492
  act(hit.raw, "menu", ev.clientX, ev.clientY);
318
493
  }
319
494
 
@@ -352,12 +527,15 @@ window.__ModuleLoader__.load({
352
527
  hoverTimer = null;
353
528
  if (disposed || !document || !document.body) return;
354
529
  var hit = hitAt(x, y);
355
- var want = hit ? "pointer" : "";
356
- if (want !== lastCursor) {
357
- lastCursor = want;
358
- document.body.style.cursor = want;
359
- }
360
- drawHover(hit && hit.rect);
530
+ if (!hit) { setCursor(""); hideHover(); return; }
531
+ // 先确认文件真的存在,再给「可点」的视觉信号:
532
+ // 不存在的片段不画虚线、不变手型,用户根本看不出它曾被识别过。
533
+ verify(hit.raw, function (rec) {
534
+ if (disposed) return;
535
+ if (!rec.ok) { setCursor(""); hideHover(); return; }
536
+ setCursor("pointer");
537
+ drawHover(hit.rect);
538
+ });
361
539
  }, 150);
362
540
  }
363
541
 
package/lib/index.js CHANGED
@@ -22,11 +22,11 @@
22
22
  // clientChips (默认 true) = 客户端是否启用芯片(此值由客户端自己读 /info 得知)
23
23
  // allowOutside (默认 false) = 是否允许解析 base 之外的绝对路径(安全闸)
24
24
 
25
- import { readFileSync, statSync, readdirSync } from "node:fs";
25
+ import { readFileSync, statSync, readdirSync, copyFileSync } from "node:fs";
26
26
  import { homedir } from "node:os";
27
27
  import { execFile } from "node:child_process";
28
28
  import { fileURLToPath } from "node:url";
29
- import { dirname, join, resolve as pathResolve, basename, sep } from "node:path";
29
+ import { dirname, join, resolve as pathResolve, basename, extname, sep } from "node:path";
30
30
 
31
31
  var PKG_VERSION = (function () {
32
32
  try {
@@ -344,6 +344,173 @@ function resolveOne(raw, base, allowOutside) {
344
344
  return { ok: false, reason: "not-found", raw: cleaned, abs: null, exists: false, kind: null, candidates: candidates };
345
345
  }
346
346
 
347
+ // ── Windows「揭示并选中」:Shell.Application Explore 开窗 + SelectItem 选中 ──
348
+ // 三代实现的历史(别再走回头路):
349
+ // ① explorer.exe /select,"path" —— 命令发出、退出码 1、日志正常,但资源管理器
350
+ // **既不新开窗口也不改选中项**(带引号/裸路径/拆 argv/不加 verbatim 四种全无效)。
351
+ // ② Shell.Application COM(Explore + 轮询 Windows() + SelectItem)——脚本 exit 0、
352
+ // 日志写「成功」,但**实际不选中**。跨进程 COM 里 Document.Folder.ParseName 拿到的
353
+ // FolderItem 与目标窗口不保证落在同一 PIDL 空间,SelectItem 会静默失败。
354
+ // (用户 2026-10-28 实测反馈:「打开了但是没有自动定位到文件位置」)
355
+ // ③ 试过 shell32!SHOpenFolderAndSelectItems:hr=0、SelectedItems() 也确实有值,
356
+ // 但**窗口根本不弹**(用户实测:「现在都不弹窗了」)。它走的是「复用已有窗口」
357
+ // 语义,没有现成窗口时什么都不显示 —— 所以它不能当「打开」的入口,
358
+ // 只有「已开着的窗口里定位」这一半能力。
359
+ // ④ 当前方案:Shell.Application.Explore 负责**开窗**(唯一可靠的开窗入口),
360
+ // 再 SelectItem(item, 29) 负责**选中**。两处易错点都补了轮询:
361
+ // · Explore 之后窗口与目录项都不是立刻可用的,ParseName 会先返回 $null;
362
+ // · SelectItem 的标志位必须带 SVSI_ENSUREVISIBLE|SVSI_FOCUSED(=29),
363
+ // 只传 SVSI_SELECT(1) 时部分窗口状态下静默无效。
364
+ // 正确性判据是 SelectedItems() 非空,不是「脚本没报错」。
365
+ // 实测(powershell.exe 5.1,与插件同款;先关掉目标目录全部窗口再跑):
366
+ // window_found=yes, item_found=yes, sel_count=1, sel_item=目标文件。
367
+ // 实现要点:
368
+ // · 全程只用 Shell.Application COM,不编译 C#(Add-Type 首次编译慢,且受运行环境影响);
369
+ // · 脚本用 -EncodedCommand 以 UTF-16LE Base64 传入 —— 绕开控制台代码页,
370
+ // 中文/空格路径不会变成 mojibake(直接 -Command 传中文必乱);
371
+ // · windowsHide 只作用于 powershell.exe 自身(不闪黑窗),shell 拉起的资源管理器
372
+ // 窗口照常显示;
373
+ // · 退出码:3 = 目录项取不到,4 = 选中失败,5 = 窗口没等出来;调用方见非 0
374
+ // 就降级为「只打开目录」,保证「至少能开」。
375
+ function psLiteral(s) {
376
+ return "'" + String(s).replace(/'/g, "''") + "'";
377
+ }
378
+
379
+ function revealViaShellCom(abs, done) {
380
+ var lines = [
381
+ "$ErrorActionPreference = 'Stop'",
382
+ "$dir = " + psLiteral(dirname(abs)),
383
+ "$nm = " + psLiteral(basename(abs)),
384
+ "$sh = New-Object -ComObject Shell.Application",
385
+ // Explore 是唯一能可靠「把窗口摆到用户眼前」的入口。
386
+ // SHOpenFolderAndSelectItems 能选中却不弹窗(它复用已有窗口,没有就什么都不显示),
387
+ // 所以开窗这一步必须交给 Explore,选中的事再交给 SelectItem。
388
+ "$sh.Explore($dir)",
389
+ "$w = $null",
390
+ "for ($i = 0; $i -lt 60; $i++) {",
391
+ " foreach ($x in @($sh.Windows())) {",
392
+ " try { if ($x.Document.Folder.Self.Path -eq $dir) { $w = $x; break } } catch { }",
393
+ " }",
394
+ " if ($w) { break }",
395
+ " Start-Sleep -Milliseconds 100",
396
+ "}",
397
+ "if (-not $w) { exit 5 }",
398
+ // 窗口刚 Explore 出来时目录可能还没填充完,ParseName 会返回 $null,必须轮询。
399
+ "$it = $null",
400
+ "for ($i = 0; $i -lt 60; $i++) {",
401
+ " $it = $w.Document.Folder.ParseName($nm)",
402
+ " if ($it) { break }",
403
+ " Start-Sleep -Milliseconds 100",
404
+ "}",
405
+ "if (-not $it) { exit 3 }",
406
+ // 29 = SVSI_SELECT(1) | SVSI_DESELECTOTHERS(4) | SVSI_ENSUREVISIBLE(8) | SVSI_FOCUSED(16)。
407
+ // 旧代码只传 1,在某些窗口状态下静默无效;带上 ENS|FOCUSED 才保证滚到可视区并高亮。
408
+ // 循环校验 SelectedItems(),选中了才 exit 0 —— 不靠「脚本没报错」自欺。
409
+ "for ($i = 0; $i -lt 25; $i++) {",
410
+ " $w.Document.SelectItem($it, 29)",
411
+ " Start-Sleep -Milliseconds 120",
412
+ " if (@($w.Document.SelectedItems()).Count -ge 1) { exit 0 }",
413
+ "}",
414
+ "exit 4",
415
+ ];
416
+ runPwshEncoded(lines, { timeout: 30000 }, done);
417
+ }
418
+
419
+ // ── 另存为 / 复制文件到剪贴板 ────────────────────────────────────────────────
420
+ // 这两件事都得借 PowerShell,原因各不相同:
421
+ //
422
+ // saveas —— Node 没有跨平台的「选个目标位置」原生接口。Windows 上只有
423
+ // WinForms 的 SaveFileDialog,且它有两个坑:
424
+ // (a) 必须跑在 STA 线程(默认 MTA 会抛 InvalidOperationException);
425
+ // (b) 不给 owner 窗口时对话框可能弹到主窗口背后,用户以为没反应。
426
+ // 所以下面既加了 -STA,又造了一个 1×1 的隐形 TopMost owner form。
427
+ //
428
+ // copyfile —— 「复制文件」不是把路径字符串塞进剪贴板,而是写 CF_HDROP
429
+ // (文件列表),这样粘到资源管理器里得到的是文件本身。
430
+ // PowerShell 5.1+ 的 Set-Clipboard -LiteralPath 正是干这个的。
431
+ //
432
+ // 两者统一用 -EncodedCommand + UTF-16LE 传脚本(与 revealViaShellCom 同款),
433
+ // 中文/空格路径不会因控制台代码页变成乱码。
434
+
435
+ function runPwshEncoded(lines, opts, done) {
436
+ var b64 = Buffer.from(lines.join("\n"), "utf16le").toString("base64");
437
+ var args = ["-NoProfile", "-EncodedCommand", b64];
438
+ if (opts && opts.sta) args.splice(1, 0, "-STA");
439
+ var pw = execFile(
440
+ "powershell.exe",
441
+ args,
442
+ {
443
+ windowsHide: true,
444
+ timeout: (opts && opts.timeout) || 30000,
445
+ maxBuffer: 1024 * 1024
446
+ },
447
+ function (err, stdout) { done(err, stdout); }
448
+ );
449
+ if (pw && typeof pw.unref === "function") pw.unref();
450
+ }
451
+
452
+ // 回调契约:done(err, dest)
453
+ // dest === null → 用户主动取消(不是错误,调用方应静默返回)
454
+ function saveAsViaDialog(abs, done) {
455
+ var dir = dirname(abs);
456
+ var name = basename(abs);
457
+ var ext = extname(name);
458
+ // 过滤器第一项锁定原扩展名,第二项兜底「所有文件」
459
+ var psFilter = ext
460
+ ? "*" + ext + "|*" + ext + "|所有文件 (*.*)|*.*"
461
+ : "所有文件 (*.*)|*.*";
462
+
463
+ var lines = [
464
+ "$ErrorActionPreference = 'Stop'",
465
+ "Add-Type -AssemblyName System.Windows.Forms",
466
+ "Add-Type -AssemblyName System.Drawing",
467
+ "$d = New-Object System.Windows.Forms.SaveFileDialog",
468
+ "$d.Title = " + psLiteral("另存为"),
469
+ "$d.FileName = " + psLiteral(name),
470
+ "$d.InitialDirectory = " + psLiteral(dir),
471
+ "$d.Filter = " + psLiteral(psFilter),
472
+ "$d.FilterIndex = 1",
473
+ "$d.OverwritePrompt = $true",
474
+ "$d.AddExtension = $false",
475
+ "$d.RestoreDirectory = $true",
476
+ // 隐形 owner:只为把对话框顶到前台。没有它,用户切到别的窗口再回来时会找不到框。
477
+ "$o = New-Object System.Windows.Forms.Form",
478
+ "$o.TopMost = $true",
479
+ "$o.ShowInTaskbar = $false",
480
+ "$o.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::None",
481
+ "$o.StartPosition = [System.Windows.Forms.FormStartPosition]::CenterScreen",
482
+ "$o.Width = 1",
483
+ "$o.Height = 1",
484
+ "$o.Opacity = 0",
485
+ "$o.Show()",
486
+ "try { $r = $d.ShowDialog($o) } finally { $o.Close(); $o.Dispose() }",
487
+ "if ($r -eq [System.Windows.Forms.DialogResult]::OK) {",
488
+ " [Console]::Out.Write($d.FileName)",
489
+ " exit 0",
490
+ "}",
491
+ "exit 1"
492
+ ];
493
+
494
+ // 超时给足 10 分钟:用户可能在对话框里慢慢挑目录、甚至去新建文件夹
495
+ runPwshEncoded(lines, { sta: true, timeout: 600000 }, function (err, stdout) {
496
+ var dest = String(stdout || "").trim();
497
+ if (dest) { done(null, dest); return; }
498
+ if (!err) { done(new Error("no-path")); return; }
499
+ // 退出码 1 = 用户点了「取消」,属于正常路径,绝不当失败弹红字
500
+ if (err.code === 1) { done(null, null); return; }
501
+ done(err);
502
+ });
503
+ }
504
+
505
+ function copyFileToClipboard(abs, done) {
506
+ var lines = [
507
+ "$ErrorActionPreference = 'Stop'",
508
+ "Set-Clipboard -LiteralPath " + psLiteral(abs),
509
+ "exit 0"
510
+ ];
511
+ runPwshEncoded(lines, { timeout: 20000 }, function (err) { done(err); });
512
+ }
513
+
347
514
  // ── 打开动作(唯一一处 spawn 进程的地方)─────────────────────────────────────
348
515
  // 安全设计:
349
516
  // 1) 只接受**绝对路径**且必须**当前存在**(先 statOne 确认,避免拿路径去撞文件系统);
@@ -358,15 +525,30 @@ function launchPath(abs, action, res) {
358
525
  return;
359
526
  }
360
527
  var isDir = st.kind === "dir";
528
+
529
+ // Windows 的 reveal 走 Shell COM,不经过 explorer 命令行(原因见 revealViaShellCom 上方注释)
530
+ if (process.platform === "win32" && action === "reveal") {
531
+ revealViaShellCom(abs, function (err) {
532
+ if (!err) {
533
+ try { console.log("[dsh-artifact-linker] reveal selected via shell COM -> " + abs); } catch (e) { }
534
+ return;
535
+ }
536
+ // 降级:COM 没成时至少把父目录打开,避免「完全没反应」
537
+ try { console.log("[dsh-artifact-linker] reveal COM failed (" + (err.code == null ? "exit-or-timeout" : err.code) + "); falling back to opening the folder"); } catch (e) { }
538
+ var fb = execFile("explorer.exe", [isDir ? abs : dirname(abs)], {}, function () { });
539
+ if (fb && typeof fb.unref === "function") fb.unref();
540
+ });
541
+ json(res, 200, { ok: true, abs: abs, action: action, kind: st.kind, method: "shell-com" });
542
+ return;
543
+ }
544
+
361
545
  var cmd;
362
546
  var args;
363
547
  if (process.platform === "win32") {
364
548
  cmd = "explorer.exe";
365
- // 🔴 /select,"path" 必须作为**一整串(含引号)**原样交给 explorer,配合下面的
366
- // windowsVerbatimArguments:true,Node 才不会自己决定要不要加引号。
367
- // 2026-10-28 实测:裸传中文/带空格路径(不加引号)时 explorer 解析不了,
368
- // 返回码非 0 却 stdout/stderr 全空 —— 这是「点了没反应」的真凶之一。
369
- args = (action === "reveal") ? ['/select,"' + abs + '"'] : [abs];
549
+ // 仅剩 open(reveal 已在上面由 COM 分支处理):explorer.exe <file> 实测对
550
+ // 中文/空格路径有效(会调起默认程序),退出码 1 是移交实例的常态,不是失败。
551
+ args = [abs];
370
552
  } else if (process.platform === "darwin") {
371
553
  cmd = "open";
372
554
  args = (action === "reveal" && !isDir) ? ["-R", abs] : [abs];
@@ -575,6 +757,91 @@ export function apply(ctx, config) {
575
757
  routes.push(ctx.webServer.register({ kind: "exact", path: ROUTE_PREFIX + "/reveal", handler: makeLauncher("reveal") }));
576
758
  routes.push(ctx.webServer.register({ kind: "exact", path: ROUTE_PREFIX + "/open", handler: makeLauncher("open") }));
577
759
 
760
+ // /saveas?p=<abs> —— 弹系统「另存为」对话框,把文件复制到用户选的位置
761
+ // /copyfile?p=<abs> —— 把文件本身(不是路径字符串)写入剪贴板,可直接粘到资源管理器
762
+ //
763
+ // 与 reveal/open 的区别:这两个是**有副作用**的文件操作,所以额外挡了三种输入:
764
+ // 目录(另存为目录无意义)、不存在的文件、以及目标路径 == 源路径(会自覆盖)。
765
+ function makeFileOp(op) {
766
+ return function (req, res) {
767
+ if (!isLoopback(req)) { res.writeHead(403); res.end(); return; }
768
+ var url;
769
+ try {
770
+ url = new URL(req.url || "/", "http://127.0.0.1");
771
+ } catch (e) {
772
+ json(res, 400, { ok: false, reason: "bad-url" });
773
+ return;
774
+ }
775
+ var abs = cleanRaw(url.searchParams.get("p") || "");
776
+ if (!abs || !looksAbsolute(abs)) {
777
+ json(res, 200, { ok: false, reason: "need-absolute-path" });
778
+ return;
779
+ }
780
+ abs = pathResolve(abs);
781
+ if (!allowOutside && !isInside(process.cwd(), abs)) {
782
+ json(res, 200, { ok: false, reason: "outside-base", abs: abs });
783
+ return;
784
+ }
785
+ var st = statOne(abs);
786
+ if (!st.exists) {
787
+ json(res, 200, { ok: false, reason: "not-found", abs: abs });
788
+ return;
789
+ }
790
+ if (st.kind === "dir") {
791
+ json(res, 200, { ok: false, reason: "is-directory", abs: abs });
792
+ return;
793
+ }
794
+ if (process.platform !== "win32") {
795
+ json(res, 200, { ok: false, reason: "unsupported-platform" });
796
+ return;
797
+ }
798
+
799
+ if (op === "copyfile") {
800
+ copyFileToClipboard(abs, function (err) {
801
+ if (err) {
802
+ try { console.log("[dsh-artifact-linker] copyfile failed -> " + (err.message || err)); } catch (e) { }
803
+ json(res, 200, { ok: false, reason: "clipboard-failed", message: String(err.message || err) });
804
+ return;
805
+ }
806
+ try { console.log("[dsh-artifact-linker] copyfile ok (CF_HDROP) -> " + abs); } catch (e) { }
807
+ json(res, 200, { ok: true, abs: abs, action: "copyfile" });
808
+ });
809
+ return;
810
+ }
811
+
812
+ saveAsViaDialog(abs, function (err, dest) {
813
+ if (err) {
814
+ try { console.log("[dsh-artifact-linker] saveas dialog failed -> " + (err.message || err)); } catch (e) { }
815
+ json(res, 200, { ok: false, reason: "dialog-failed", message: String(err.message || err) });
816
+ return;
817
+ }
818
+ if (!dest) {
819
+ // 用户取消:静默,客户端不该弹「失败」
820
+ json(res, 200, { ok: false, reason: "cancelled" });
821
+ return;
822
+ }
823
+ var target = pathResolve(dest);
824
+ // 同一路径复制到自己会抛 EINVAL / 把文件截断,必须提前拦
825
+ if (target.toLowerCase() === abs.toLowerCase()) {
826
+ json(res, 200, { ok: false, reason: "same-path", dest: target });
827
+ return;
828
+ }
829
+ try {
830
+ copyFileSync(abs, target);
831
+ } catch (e) {
832
+ json(res, 200, { ok: false, reason: "copy-failed", message: String(e && e.message || e) });
833
+ return;
834
+ }
835
+ var nst = statOne(target);
836
+ try { console.log("[dsh-artifact-linker] saveas ok -> " + target); } catch (e) { }
837
+ json(res, 200, { ok: true, abs: abs, dest: target, size: nst.size, action: "saveas" });
838
+ });
839
+ };
840
+ }
841
+
842
+ routes.push(ctx.webServer.register({ kind: "exact", path: ROUTE_PREFIX + "/saveas", handler: makeFileOp("saveas") }));
843
+ routes.push(ctx.webServer.register({ kind: "exact", path: ROUTE_PREFIX + "/copyfile", handler: makeFileOp("copyfile") }));
844
+
578
845
  return function () {
579
846
  for (var i = 0; i < routes.length; i++) {
580
847
  try { routes[i](); } catch (e) { /* ignore */ }
@@ -582,5 +849,5 @@ export function apply(ctx, config) {
582
849
  };
583
850
  }, "dsh-artifact-linker: loopback routes");
584
851
 
585
- ctx.logger.info("dsh-artifact-linker: routes registered at " + ROUTE_PREFIX + " (info/resolve/stat/flush-cache)");
852
+ ctx.logger.info("dsh-artifact-linker: routes registered at " + ROUTE_PREFIX + " (info/resolve/stat/flush-cache/reveal/open/saveas/copyfile)");
586
853
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-artifact-linker",
3
- "version": "0.2.6",
4
- "description": "DSH 产物路径联动器:常驻注入「产物路径铁律」让模型输出完整绝对路径,同时把聊天正文里的文件名/路径渲染成一键可点的产物芯片——左键预览、右键在资源管理器揭示、Ctrl 点击强制系统打开。宿主端提供 /resolve /stat /info 只读回环路由(相对路径与裸文件名三源兜底、批量 stat + TTL 缓存),客户端零侵入、不改 React 节点、不接管 markdown 图片语法。解决「产物要打开得先复制路径再粘到文件管理器」的反复割裂。Zero telemetry; loopback-only routes. / Turn declared artifact paths into one-click chips: resident system-prompt rules force absolute paths, the client half renders file names as clickable chips (preview / reveal in Explorer / open with system app), and the host exposes loopback-only /resolve,/stat,/info routes with bare-filename fallback and cached batch stat.",
3
+ "version": "0.3.3",
4
+ "description": "DSH 产物路径联动器:常驻注入「产物路径铁律」让模型输出完整绝对路径,同时把聊天正文里的文件名/路径渲染成一键可点的产物芯片——左键预览、右键菜单可打开/定位/另存为/复制文件/复制路径/添加到对话框。宿主端提供回环只读路由与 /saveas /copyfile 两个文件搬运动作(Shell COM 揭示、WinForms 保存对话框、CF_HDROP 剪贴板),客户端零侵入、不改 React 节点、不接管 markdown 图片语法。解决「产物要打开得先复制路径再粘到文件管理器」的反复割裂。/ Turn declared artifact paths into one-click chips: resident system-prompt rules force absolute paths, the client half renders file names as clickable chips, and the right-click menu offers open / reveal in Explorer / save-as / copy-file / copy-path / insert-into-composer. Host routes are loopback-only; zero telemetry.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "sideEffects": false,