dsh-artifact-linker 0.4.2 → 0.4.4

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,69 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.4 — 2026-11-05
4
+
5
+ ### 对齐官方交付卡片:左键改为侧栏预览,右键 8 项菜单
6
+
7
+ 本轮把交互收敛到「和官方一致、官方没有的才补」:
8
+
9
+ - **左键默认动作改为「在侧栏打开预览」**(对齐官方 present 交付卡片点文件名即预览的体验)。原先左键是「在资源管理器里揭示并选中」。完整绝对路径 **0 次 `/resolve` 直通**,侧栏认领不了的类型**自动退回系统默认程序打开**,不再「点了没反应」。
10
+ - **右键菜单 7 项 → 8 项**,新增**「在浮窗中打开」**(独立小窗预览,增强项);措辞对齐官方:「在侧边栏预览」→「在侧栏打开」。分组:看(在侧栏打开 / 在浮窗中打开 / 在文件夹中显示 / 用默认程序打开)· 搬运(另存为 / 复制文件 / 复制文件路径)· 喂回(添加到对话框)。
11
+ - **修饰键语义保持并与官方一致**:`Ctrl/Shift/Cmd + 左键` = 系统默认程序打开;`Alt + 左键` = 复制绝对路径。
12
+ - **点官方 present 交付卡片自动让路**:命中 `[data-tool="present"]` 时不接管,交给官方、避免双开。
13
+ - **宿主动作单次往返**:`/open /reveal /saveas /copyfile` 新增原始文本解析(`absFromParam`:先 `cleanRaw`,绝对路径直用,否则 `resolveAcross`),客户端可直接传原始文本,无需先单独打一发 `/resolve`。`/resolve` 路由仍保留供悬停/校验使用。
14
+
15
+ **验证**:`node --check` 两文件通过;DOM-stub 逻辑探针 15/15(绝对路径左键 0 次 `/resolve`、裸名恰好 1 次、Alt 复制路径、Shift 系统打开、apply 无常驻 DOM / dispose 摘净监听)。操作体验以 `/info` 与实机为准。
16
+
17
+ ## 0.4.3 — 2026-11-01
18
+
19
+ ### 修复:悬停高亮慢半拍、落点不准(issue #1)
20
+
21
+ 报告(@ZhJack28):悬停芯片延迟偏高,建议把节流降到 ~40ms,并且回调用最新坐标而不是排程那一刻的坐标。核实后**两条都成立**,而且第二条比第一条更影响手感。
22
+
23
+ **根因①:间隔写死 150ms。** `onMove` 末尾写的是 `}, 150);`。150ms ≈ 9 帧,已经落在「人眼能明确察觉"慢了一拍"」的区间里:鼠标划过一行字时,手型和高亮总是追在指针后面出现。这个值还**不能配**——官方硬规则 #5 要求一切 tunable 能从 cordis.yml 改,写死本身就是违规。
24
+
25
+ **根因②:坐标在 `setTimeout` 外面就取好了。**
26
+
27
+ ```js
28
+ function onMove(ev) {
29
+ if (disposed || hoverTimer) return;
30
+ var x = ev.clientX; // ← 排程这一刻取值
31
+ var y = ev.clientY;
32
+ hoverTimer = setTimeout(function () {
33
+ var hit = hitAt(x, y); // ← 150ms 后拿它判定
34
+ }, 150);
35
+ }
36
+ ```
37
+
38
+ 于是 150ms 后判定的是「150ms 之前鼠标在哪」。鼠标已经移开 → 高亮画在已经离开的那段文字上;鼠标还没移到 → 高亮落在半路。表现就是「落点不准 / 高亮跟文字对不上」。
39
+
40
+ **修法(`lib/client.js`)**
41
+
42
+ | 位置 | 旧 | 新 |
43
+ |---|---|---|
44
+ | `onMove` | 有 timer 就直接 return(本次移动被丢弃,坐标也就丢了) | **每次移动都记账**(`hoverX`/`hoverY`),只在没有 timer 时排程 |
45
+ | 判定回调 | 匿名闭包,读排程那一刻的 `x`/`y` | 提为具名 `hoverProbe()`,从 `hoverX`/`hoverY` 读**当前**坐标 |
46
+ | 间隔 | 字面量 `150` | `hoverDelayMs`,默认 `HOVER_DELAY_DEFAULT_MS = 40`(≈2.5 帧),硬上限 1000 |
47
+ | 来源 | 客户端自己写死 | 宿主 `config.hoverDelayMs` → `/info` 下发,客户端只做上下限兜底 |
48
+
49
+ **顺手堵一个同源竞态**:`verify()` 首次解析要走 `/resolve`(异步)。等它回来时鼠标可能早就动了,原实现会拿**旧 hit 的 rect** 画高亮。现在回调里按 `hoverX/hoverY` 重算一次 `hitAt`,命中换了就 `hideHover()` 收摊——绝不把高亮留在已经离开的文字上。
50
+
51
+ **配置(`cordis.patch.yml` 新增,默认值不变行为)**
52
+
53
+ ```yaml
54
+ config:
55
+ hoverDelayMs: 40 # 0 = 不节流;上限 1000
56
+ ```
57
+
58
+ **验证**
59
+
60
+ - 新增探针 `test/probe-hover.cjs`(Node 里搭最小 DOM 假环境,把 `lib/client.js` 原样加载后直接驱动 `mousemove`),**29 项断言全 PASS / 0 FAIL**,`node test/probe-hover.cjs` 一键跑。
61
+ - 断言覆盖:源码里已无 `}, 150)` 字面量、默认排程为 40ms、节流窗口内连续移动只排一次 timer、回调读到的是**最后一次**坐标(而非排程那一刻)、命中画在实测 rect 上(左-3 / 上-1)、移开恢复光标并隐藏浮层、**异步 verify 回来后鼠标已移走则不画**、鼠标停住时异步命中照样画出、不存在文件既不画也不变手型、`hoverDelayMs` 被 `/info` 覆盖后生效、越界值(负数 / 10000 / 非数字)被夹回合法区间、`dispose()` 清掉在途 timer 并摘掉监听、`VERSION` 与 `package.json` 一致。
62
+ - `node --check lib/client.js` / `lib/index.js` 均通过;`dsh-plugin-dev check .` = OK(9 passed / 0 failed)。
63
+ - 真机(Win11 / DSH 0.2.1-rc.5 / Node 24):悬停跟手 <50ms,落点与文字对齐;左键揭示、Ctrl+左键、右键菜单三项交互无回归。
64
+
65
+ ---
66
+
3
67
  ## 0.4.2 — 2026-10-30
4
68
 
5
69
  ### 改:识别层重写为「存在性驱动」——不再靠扩展名/排除字符集猜边界,任意绝对路径、任意文件名都能打开
package/README.en.md CHANGED
@@ -27,7 +27,7 @@ Two halves, one contract.
27
27
  | Half | Responsibility |
28
28
  | --- | --- |
29
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
- | **Client** (`lib/client.js`) | Turns artifact names in the transcript into clickable targets: left click reveals, right click opens a seven-item menu (sidebar preview, open, reveal, save-as, copy file, copy path, add to composer). **No resident UI, no scanning, no timers.** |
30
+ | **Client** (`lib/client.js`) | Turns artifact names in the transcript into clickable targets: **left click previews in the sidebar** (matching the official present card), right click opens an **eight-item** menu (open in sidebar, open in floating window, reveal in Explorer, open with default app, save-as, copy file, copy path, add to composer). **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
 
@@ -37,16 +37,18 @@ Hover an artifact name and it picks up a Codex-style blue dashed outline — tha
37
37
 
38
38
  ![Context menu](https://raw.githubusercontent.com/loyalchiiina/dsh-artifact-linker/main/docs/context-menu.png)
39
39
 
40
+ Want to see it in action? [Watch the demo video (0.4.4 right-click menu)](https://github.com/loyalchiiina/dsh-artifact-linker/releases/download/v0.4.4/demo-0.4.4.mp4).
41
+
40
42
  ## Interaction
41
43
 
42
44
  Click behaviour is decided by the modifier key — no first-time setup:
43
45
 
44
46
  | Action | Result |
45
47
  | --- | --- |
46
- | **Left click** on a file name / path | Reveal in File Explorer *with the file selected* |
48
+ | **Left click** on a file name / path | Preview in DSH's built-in right sidebar (same route as the official present card; absolute paths resolve in **zero round-trips**; unclaimed types fall back to the default app) |
47
49
  | **Ctrl / Shift / Cmd + click** | Open with the system's default application |
48
50
  | **Alt + click** | Copy the full absolute path to the clipboard |
49
- | **Right click** on an artifact | Inline menu — seven actions, listed below |
51
+ | **Right click** on an artifact | Inline menu — eight actions, listed below |
50
52
  | **Right click** on non-artifact text | Fully passed through, the host's native menu appears as usual |
51
53
  | **Hover** | Cursor becomes a pointer *only after* the file is confirmed to exist |
52
54
  | File missing / unresolvable | Silent pass-through or a one-shot toast. **Never pretends to succeed.** |
@@ -55,7 +57,8 @@ Click behaviour is decided by the modifier key — no first-time setup:
55
57
 
56
58
  | Item | What it does |
57
59
  | --- | --- |
58
- | **Preview in sidebar** | Opens the file in DSH's built-in right sidebar — the same route as clicking a file link in chat. **Since 0.4.0 this menu item is shown for every file type**, with no extension filtering: whatever viewer claims the address renders it (PDF / Word / Excel / PowerPoint / Markdown / plain text / CSV / images, plus audio and video once plugins such as video-preview / audio-preview are installed); anything unclaimed falls back to plain text or the default app. The trade-off is that a rare type may answer with a plain "the sidebar cannot open this" — accepted on purpose, so future viewer capabilities are picked up with zero changes here |
60
+ | **Open in sidebar** | Opens the file in DSH's built-in right sidebar — the same route as clicking a file link in chat. **Since 0.4.0 this menu item is shown for every file type**, with no extension filtering: whatever viewer claims the address renders it (PDF / Word / Excel / PowerPoint / Markdown / plain text / CSV / images, plus audio and video once plugins such as video-preview / audio-preview are installed); anything unclaimed falls back to plain text or the default app. The trade-off is that a rare type may answer with a plain "the sidebar cannot open this" — accepted on purpose, so future viewer capabilities are picked up with zero changes here |
61
+ | **Open in floating window** | Opens a preview in a separate floating window (enhancement; if a popup blocker or the host blocks it, use "Open in sidebar" instead) |
59
62
  | **Open with default app** | Hands the file to the OS association (Explorer / Excel / VLC / …) |
60
63
  | **Reveal in Explorer** | Opens the containing folder and selects the file |
61
64
  | **Save as…** | Copies the file to a location you pick |
@@ -100,8 +103,11 @@ All tunables live in `cordis.patch.yml` (or the profile-level patch). Changing t
100
103
  | `allowOutside` | `true` | Allow resolving paths outside the workspace |
101
104
  | `statTtlMs` | `10000` | Host-side `stat` cache lifetime |
102
105
  | `searchMaxDepth` | `2` (max 8) | Recursion depth for bare-filename lookup |
106
+ | `hoverDelayMs` | `40` | Hover-probe throttle in ms (ceiling 1000). `0` = no throttling |
103
107
  | `rulesText` | built-in 6 lines | Replace the injected rules wholesale (array or multi-line string) |
104
108
 
109
+ On `hoverDelayMs`: up to 0.4.2 this was hard-coded at `150ms`, which is long enough (~9 frames) that the cursor change and highlight visibly lagged behind the pointer while sweeping across a line. Worse, the probe used the coordinates captured when the timer was *scheduled*, not when it *fired*, so the highlight could also land in the wrong place. Since 0.4.3 it is configurable, defaults to `40ms` (~2.5 frames), and every `mousemove` refreshes the coordinates so the probe reads the current position.
110
+
105
111
  On `allowOutside`: artifacts almost always live on a project drive (`E:\`, `D:\`) rather than inside the DSH workspace, so a strict default would make the plugin useless on first run. **Set it to `false` when shipping to other people** — that is the gate which stops this plugin from becoming "the model names any path and your machine opens it".
106
112
 
107
113
  ## What it does NOT do
package/README.md CHANGED
@@ -21,7 +21,7 @@ DSH 里预览器已经能开图片、代码、pdf、office。但**插件、脚
21
21
  | 半边 | 做什么 |
22
22
  |---|---|
23
23
  | **宿主(提示词层)** | 常驻注入一条「产物路径铁律」,要求模型把产物写成**完整绝对路径**、而给用户看的正文**只写文件名**——路径有据可查,正文又不被长串路径污染 |
24
- | **客户端(渲染层)** | 正文里的产物名变成可点目标:左键揭示、右键七项菜单(侧边栏预览 / 默认程序打开 / 在文件夹中显示 / 另存为 / 复制文件 / 复制路径 / 添加到对话框)。**零常驻 UI、零扫描、零定时器** |
24
+ | **客户端(渲染层)** | 正文里的产物名变成可点目标:**左键在右侧边栏预览**(对齐官方交付卡片)、右键**八项**菜单(在侧栏打开 / 在浮窗中打开 / 在文件夹中显示 / 用默认程序打开 / 另存为 / 复制文件 / 复制文件路径 / 添加到对话框)。**零常驻 UI、零扫描、零定时器** |
25
25
 
26
26
  第 1 条管「模型把路径写对」,第 2 条管「用户点得动」。缺前者,路径经常是猜的、点开是死链;缺后者,路径再对也要人去复制。
27
27
 
@@ -31,6 +31,8 @@ DSH 里预览器已经能开图片、代码、pdf、office。但**插件、脚
31
31
 
32
32
  ![右键菜单](https://raw.githubusercontent.com/loyalchiiina/dsh-artifact-linker/main/docs/context-menu.png)
33
33
 
34
+ 想看实操录屏?[点这里看效果视频(0.4.4 右键菜单演示)](https://github.com/loyalchiiina/dsh-artifact-linker/releases/download/v0.4.4/demo-0.4.4.mp4)。
35
+
34
36
  ---
35
37
 
36
38
  ## 交互
@@ -39,10 +41,10 @@ DSH 里预览器已经能开图片、代码、pdf、office。但**插件、脚
39
41
 
40
42
  | 操作 | 结果 |
41
43
  |---|---|
42
- | **左键** | 在系统资源管理器里**揭示并选中**该文件(不是打开它,是把它亮在你面前) |
44
+ | **左键** | 在 DSH 右侧边栏打开预览(和官方交付卡片同一条通道;完整绝对路径 **0 次解析直通**,侧栏认领不了的类型自动退回系统默认程序打开) |
43
45
  | **Ctrl / Shift / Cmd + 左键** | 用系统默认关联程序打开(Excel、PS、播放器……) |
44
46
  | **Alt + 左键** | 复制完整绝对路径到剪贴板 |
45
- | **右键**产物名 | 插件菜单,共七项(见下表) |
47
+ | **右键**产物名 | 插件菜单,共八项(见下表) |
46
48
  | **右键**非产物文字 | 完全放行,宿主原生菜单照常出现 |
47
49
  | **悬停** | 只有**确认文件真的存在**才变手型、才画虚线 |
48
50
  | 文件不存在 / 解析不到 | 静默放行或一次性提示,**绝不假装成功** |
@@ -51,7 +53,8 @@ DSH 里预览器已经能开图片、代码、pdf、office。但**插件、脚
51
53
 
52
54
  | 菜单项 | 作用 |
53
55
  |---|---|
54
- | **在侧边栏预览** | 在 DSH 内置右侧边栏里打开,和聊天里点文件链接是同一条通道。**0.4.0 起对所有文件类型一律显示这一项**,不再按扩展名筛:能认领的查看器就渲染(PDF / Word / Excel / PowerPoint / Markdown / 纯文本 / CSV / 图片,装了 video-preview / audio-preview 等插件后还有音视频),认领不了的宿主会退回纯文本或默认程序。副作用是极少数类型点下去可能只得到一句「侧边栏打不开」——这是刻意接受的代价,为的是以后侧边栏补了新能力,本插件零改动自动跟上 |
56
+ | **在侧栏打开** | 在 DSH 内置右侧边栏里打开,和聊天里点文件链接是同一条通道。**0.4.0 起对所有文件类型一律显示这一项**,不再按扩展名筛:能认领的查看器就渲染(PDF / Word / Excel / PowerPoint / Markdown / 纯文本 / CSV / 图片,装了 video-preview / audio-preview 等插件后还有音视频),认领不了的宿主会退回纯文本或默认程序。副作用是极少数类型点下去可能只得到一句「侧边栏打不开」——这是刻意接受的代价,为的是以后侧边栏补了新能力,本插件零改动自动跟上 |
57
+ | **在浮窗中打开** | 在一个独立浮窗里打开预览(增强项;浏览器或宿主拦截弹窗时改用「在侧栏打开」) |
55
58
  | **用默认程序打开** | 交给系统关联(资源管理器 / Excel / 播放器……) |
56
59
  | **在文件夹中显示** | 打开所在目录并选中该文件 |
57
60
  | **另存为…** | 复制到你指定的位置 |
@@ -123,6 +126,9 @@ GET http://127.0.0.1:<port>/dsh-artifact-linker/info
123
126
  | `injectRules` | `true` | `false` = 不注入 prompt 段,模型不再被要求输出绝对路径 |
124
127
  | `clientChips` | `true` | `false` = 纯注入模式,不接管任何鼠标事件 |
125
128
  | `allowOutside` | `true` | `false` = 只允许打开 workspace 内的文件 |
129
+ | `hoverDelayMs` | `40` | 悬停判定的节流间隔(毫秒,上限 1000)。`0` = 不节流;调大 = 更省 IO,但手型/高亮更迟钝 |
130
+
131
+ **`hoverDelayMs` 说明**:0.4.2 及更早把这条写死成 `150ms`,鼠标划过一行文字时高亮会明显慢半拍(150ms ≈ 9 帧),而且判定用的是「排程那一刻」的坐标,不是判定那一刻的坐标,所以还会出现落点不准。0.4.3 起改为可配、默认 `40ms`(≈2.5 帧,肉眼已跟手),并且每次 `mousemove` 都会刷新坐标,判定时读最新值。
126
132
 
127
133
  **`allowOutside` 要特别说一句**:产物经常落在项目盘(`E:\…`、`D:\…`)而不是 DSH 的 workspace 里,所以默认放开。但它同时是安全闸——挡的是「模型说个 `C:\Windows\System32\xxx.exe` 就帮你点开」这类情况。**自己一个人用保持 `true` 更顺手;给别人用、或者接入不可信模型,改成 `false`。**
128
134
 
package/cordis.patch.yml CHANGED
@@ -36,6 +36,10 @@
36
36
  # 调大 = DOM 重渲染反复问同一路径时更省 IO
37
37
  # searchMaxDepth (默认 2,硬上限 8) 裸文件名反查的递归深度。调大能找回埋得更深的同名
38
38
  # 文件,但 /resolve 的目录遍历成本随之上升
39
+ # hoverDelayMs (默认 40) 客户端悬停判定的节流间隔(毫秒,硬上限 1000)。
40
+ # 调小 = 手型/高亮跟手更快;0 = 不节流(每次 mousemove 都判定)。
41
+ # 原来客户端把这条写死成 150ms,鼠标划过时高亮明显慢半拍,
42
+ # 现已改为可配、默认 40(≈2.5 帧)。
39
43
  # rulesText (默认内置 6 条) 整体替换注入的铁律文本,可写数组或一个多行字符串。
40
44
  # 想追加自己的规矩(例:「交付前先确认文件字节数非 0」)就写这里
41
45
  # —— 官方硬规则 #5 要求所有 tunable 必须能在 cordis.yml 改,
package/lib/client.js CHANGED
@@ -13,7 +13,7 @@
13
13
  // 交互(用户 2026-10-27 明确要求):
14
14
  // 左键点正文里的产物名 → 在资源管理器里揭示并选中
15
15
  // 右键点正文里的产物名 → 自建菜单(揭示 / 默认程序打开 / 复制完整路径)
16
- // 悬停命中时鼠标变手型 → 唯一的视觉提示,靠 mousemove 150ms 节流,几乎无开销
16
+ // 悬停命中时鼠标变手型 → 唯一的视觉提示,靠 mousemove 40ms 节流(可配),几乎无开销
17
17
  //
18
18
  // 硬约束(踩过的坑,别再犯):
19
19
  // * 必须有 exports.apply —— 只写 dispose/version 会让 cordis 判 invalid plugin,
@@ -29,7 +29,7 @@ window.__ModuleLoader__.load({
29
29
  var exports = module.exports;
30
30
 
31
31
  var GUARD = "__dshArtifactLinkerV1";
32
- var VERSION = "0.4.2";
32
+ var VERSION = "0.4.4";
33
33
  // 路由前缀必须与宿主的 ROUTE_PREFIX 一致;永远根相对,绝不拼宿主 origin
34
34
  var ROUTE = "/dsh-artifact-linker";
35
35
 
@@ -51,6 +51,12 @@ window.__ModuleLoader__.load({
51
51
  function isWinAbs(p) {
52
52
  return /^[A-Za-z]:[/\\]/.test(p) || p.indexOf("\\\\") === 0;
53
53
  }
54
+ // 绝对路径判定(与宿主 looksAbsolute 对齐):盘符 / UNC / POSIX 根。
55
+ // 命中即可「直通」——侧栏预览 0 次 /resolve,动作路由也由宿主内部解析。
56
+ function looksAbsoluteClient(p) {
57
+ if (isWinAbs(p)) return true;
58
+ return typeof p === "string" && p.charAt(0) === "/" && p.charAt(1) !== "/";
59
+ }
54
60
  function fileAddressFor(sessionId, cwd, p) {
55
61
  var normalized = p.replace(/\\/g, "/");
56
62
  if (!(normalized.charAt(0) === "/" || isWinAbs(normalized))) return sessionFileAddress(sessionId, normalized);
@@ -135,6 +141,21 @@ window.__ModuleLoader__.load({
135
141
  var mounted = false;
136
142
  var disposed = false;
137
143
  var hoverTimer = null;
144
+ // 悬停判定节流间隔(毫秒)。40ms ≈ 2.5 帧,肉眼已经「跟手」。
145
+ //
146
+ // 原来是写死的 150ms,用户反馈「悬停芯片延迟偏高」:150ms 相当于人眼能
147
+ // 明显察觉的滞后(约 9 帧),鼠标划过一行字时高亮总是慢半拍追上来。
148
+ // 现在改成默认 40ms,并且**可以从 cordis.yml 覆盖**(官方硬规则 #5:
149
+ // 一切 tunable 必须能配置,不许写死)。宿主半边的 `apply()` 读
150
+ // `config.hoverDelayMs`,再通过 /info 下发到这一侧。
151
+ var HOVER_DELAY_DEFAULT_MS = 40;
152
+ var HOVER_DELAY_MAX_MS = 1000;
153
+ var hoverDelayMs = HOVER_DELAY_DEFAULT_MS;
154
+ // 最近一次 mousemove 的坐标。节流期间**每次移动都要记账**,
155
+ // 否则回调只能拿「排程那一刻」的位置去判定:鼠标已经移走或还没移到位,
156
+ // 高亮就落在旧地方(用户反馈的第二个问题:落点不准)。
157
+ var hoverX = 0;
158
+ var hoverY = 0;
138
159
  var toastTimer = null;
139
160
  var toastEl = null;
140
161
  var menuEl = null;
@@ -404,6 +425,51 @@ window.__ModuleLoader__.load({
404
425
  }
405
426
  }
406
427
 
428
+ // 浮窗打开(增强项):取到当前会话的资源地址,用 window.open 开一个独立小窗。
429
+ // 浏览器/宿主若拦截弹窗,返回 false,调用方提示改用「在侧栏打开」——绝不静默当成功。
430
+ function openFloating(abs) {
431
+ try {
432
+ if (!rootCtx || typeof rootCtx.get !== "function") return false;
433
+ var sidebar = rootCtx.get("sidebarRight");
434
+ var sessions = rootCtx.get("sessions");
435
+ if (!sessions || !sessions.list || typeof sessions.list.getSnapshot !== "function") return false;
436
+ var snap = sessions.list.getSnapshot();
437
+ var byId = (snap && snap.byId) || {};
438
+ var sid = null;
439
+ var mounted = sidebar && sidebar.mounted;
440
+ if (mounted && typeof mounted.get === "function") {
441
+ var m = mounted.get();
442
+ if (m && m.sessionId) sid = m.sessionId;
443
+ } else if (mounted && mounted.sessionId) {
444
+ sid = mounted.sessionId;
445
+ }
446
+ if (!sid) {
447
+ for (var k in byId) { if (byId[k] && byId[k].cwd) { sid = k; break; } }
448
+ }
449
+ if (!sid) return false;
450
+ var cwd = byId[sid] ? byId[sid].cwd : undefined;
451
+ var addr = fileAddressFor(sid, cwd, abs);
452
+ if (!addr) return false;
453
+ var w = window.open(addr, "_blank", "width=960,height=720,noopener,noreferrer");
454
+ return !!w;
455
+ } catch (e) {
456
+ return false;
457
+ }
458
+ }
459
+
460
+ // 只复制路径文字(修饰键 / 菜单共用)。失败如实提示,不装作成功。
461
+ function copyPathText(text, okMsg) {
462
+ try {
463
+ if (navigator.clipboard && navigator.clipboard.writeText) {
464
+ navigator.clipboard.writeText(text);
465
+ toast(okMsg || "已复制路径");
466
+ return true;
467
+ }
468
+ } catch (e) { }
469
+ toast("复制失败");
470
+ return false;
471
+ }
472
+
407
473
  function showMenu(x, y, abs) {
408
474
  hideMenu();
409
475
  if (!document || !document.body) return;
@@ -437,7 +503,9 @@ window.__ModuleLoader__.load({
437
503
  // 添加到对话框:带横线的气泡
438
504
  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"/>',
439
505
  // 侧边栏预览:一块面板 + 右侧分栏
440
- preview: '<rect x="3.2" y="4.4" width="17.6" height="15.2" rx="2.2"/><path d="M14.4 4.4v15.2"/>'
506
+ preview: '<rect x="3.2" y="4.4" width="17.6" height="15.2" rx="2.2"/><path d="M14.4 4.4v15.2"/>',
507
+ // 浮窗:面板 + 向外箭头(弹出独立窗口)
508
+ float: '<rect x="3.2" y="4.6" width="13.4" height="11.2" rx="2"/><path d="M16.8 9.6v9h-5.4"/><path d="M13.8 6.4h4.8v4.8"/>'
441
509
  };
442
510
  // 文案要说人话:「资源管理器」这个词很多用户不认识,改成「文件夹窗口」。
443
511
  // 顺序按「最常做的动作」排,并用 "sep" 占位分成三组:
@@ -448,9 +516,10 @@ window.__ModuleLoader__.load({
448
516
  // 现在一律显示,点下去由宿主 sidebarRight.openResource 决定能不能认领:
449
517
  // 认领得了就渲染,认领不了 openInSidebar 返回 false,我们照实提示「打不开」。
450
518
  var items = [];
451
- items.push(["preview", "在侧边栏预览", "在右侧边栏里直接看,不用切出去;这个类型如果侧边栏渲染不了会如实提示"]);
452
- items.push(["open", "用默认程序打开", "按文件类型用对应软件打开:PDF 用阅读器、表格用 Excel 那类"]);
519
+ items.push(["preview", "在侧栏打开", "在右侧边栏里直接看,不用切出去;这个类型如果侧边栏渲染不了会如实提示"]);
520
+ items.push(["float", "在浮窗中打开", "在一个独立浮窗里打开预览(增强项;若浏览器拦截弹窗,请改用「在侧栏打开」)"]);
453
521
  items.push(["reveal", "在文件夹中显示", "打开 Windows 文件夹窗口,并选中这个文件"]);
522
+ items.push(["open", "用默认程序打开", "按文件类型用对应软件打开:PDF 用阅读器、表格用 Excel 那类"]);
454
523
  items.push(["sep", "", ""]);
455
524
  items.push(["saveas", "另存为…", "另存一份到别的位置(会弹出系统保存窗口)"]);
456
525
  items.push(["copyfile", "复制文件", "把文件本身放进剪贴板,可直接粘到任意文件夹"]);
@@ -483,6 +552,13 @@ window.__ModuleLoader__.load({
483
552
  ev.stopPropagation();
484
553
  hideMenu();
485
554
 
555
+ // ⓪ 浮窗打开 —— 增强项,纯客户端弹独立小窗;被拦截就如实提示改用侧栏
556
+ if (action === "float") {
557
+ if (openFloating(abs)) toast("已在浮窗中打开");
558
+ else toast("浮窗打开失败(可能被弹窗拦截),可改用「在侧栏打开」");
559
+ return;
560
+ }
561
+
486
562
  // ① 侧边栏预览 —— 纯客户端,走宿主导航入口
487
563
  if (action === "preview") {
488
564
  // 对所有类型都尝试,成败照实说。带扩展名,用户才知道是哪个类型没成。
@@ -604,6 +680,15 @@ window.__ModuleLoader__.load({
604
680
  if (disposed) return;
605
681
  // 解析不到就当普通文字,静默收场:不弹提示、不装作成功。
606
682
  if (!rec.ok || !rec.abs) return;
683
+ // 左键默认:在侧边栏打开(与官方 present 卡片语义统一);侧栏认领不了的类型再退化系统默认打开
684
+ if (mode === "preview") {
685
+ if (openInSidebar(rec.abs)) { toast("已在侧边栏打开"); return; }
686
+ hostGet("/open", { p: rec.abs }).then(function (r) {
687
+ if (disposed) return;
688
+ if (!r || !r.ok) toast("打开失败:" + ((r && r.reason) || "未知原因"));
689
+ });
690
+ return;
691
+ }
607
692
  if (mode === "reveal") {
608
693
  hostGet("/reveal", { p: rec.abs }).then(function (r) {
609
694
  if (disposed) return;
@@ -617,21 +702,66 @@ window.__ModuleLoader__.load({
617
702
 
618
703
  function onClick(ev) {
619
704
  if (disposed) return;
620
- if (ev.button !== 0 || ev.ctrlKey || ev.metaKey || ev.altKey || ev.shiftKey) return;
705
+ if (ev.button !== 0) return;
621
706
  if (menuEl && menuEl.contains && menuEl.contains(ev.target)) return;
622
707
  var hit = hitAt(ev.clientX, ev.clientY);
623
708
  if (!hit) return;
624
- // 🔴 这里**绝不 preventDefault / stopPropagation**:用户明确要求不能破坏宿主自带的
625
- // 「点文件路径 → 侧边栏预览」。上一版把它们拦了,结果宿主预览没了、我们的 explorer
626
- // 又弹不出窗口,两头落空 —— 表现就是「左键点了没反应」。
627
- // 现在改成让事件照常冒泡给宿主;只有等这一轮事件跑完、确认没人接管
628
- // (defaultPrevented 仍为 false)时才补一次 reveal。
709
+ // 官方 present 交付卡片本身已内置「点卡片 → 侧边栏预览」,交给官方自己开,避免双开
710
+ try {
711
+ var tEl = ev.target;
712
+ if (tEl && tEl.closest && tEl.closest('[data-tool="present"]')) return;
713
+ } catch (e2) { }
714
+ var clean = cleanRaw(hit.raw);
715
+
716
+ // ── 修饰键语义(与官方一致):Alt=复制绝对路径;Shift/Ctrl/Cmd=系统默认程序打开 ──
717
+ if (ev.altKey) {
718
+ ev.preventDefault();
719
+ ev.stopPropagation();
720
+ if (looksAbsoluteClient(clean)) { copyPathText(clean); return; }
721
+ verify(hit.raw, function (rec) {
722
+ if (disposed) return;
723
+ if (rec && rec.ok && rec.abs) copyPathText(rec.abs);
724
+ else toast("没解析到这个文件,无法复制路径");
725
+ });
726
+ return;
727
+ }
728
+ if (ev.ctrlKey || ev.metaKey || ev.shiftKey) {
729
+ ev.preventDefault();
730
+ ev.stopPropagation();
731
+ // 系统默认程序打开:直接把原始文本交给宿主,宿主内部解析(一次往返,不先 /resolve)
732
+ hostGet("/open", { p: hit.raw }).then(function (r) {
733
+ if (disposed) return;
734
+ if (!r || !r.ok) toast("打开失败:" + ((r && r.reason) || "未知原因"));
735
+ });
736
+ return;
737
+ }
738
+
739
+ // ── 普通左键:侧边栏预览 ──
740
+ // 🔴 绝不 preventDefault / stopPropagation:让事件照常冒泡给宿主;宿主若已接管(defaultPrevented)就让路。
629
741
  setTimeout(function () {
630
742
  if (ev.defaultPrevented) return; // 宿主已经处理(例如开了侧边栏预览)→ 让路,不抢
631
743
  var c = cached(hit.raw);
632
- // 已确认「根本不是产物」→ 什么都不做(别为一段普通文字去弹资源管理器)
633
- if (c && !c.ok) return;
634
- act(hit.raw, "reveal", ev.clientX, ev.clientY);
744
+ if (c && !c.ok) return; // 已确认「根本不是产物」→ 什么都不做
745
+ // 绝对路径直通:不经 /resolve,直接侧栏打开(验收①:Network 无 /resolve)
746
+ if (looksAbsoluteClient(clean)) {
747
+ if (openInSidebar(clean)) { toast("已在侧边栏打开"); return; }
748
+ hostGet("/open", { p: hit.raw }).then(function (r) {
749
+ if (disposed) return;
750
+ if (!r || !r.ok) toast("打开失败:" + ((r && r.reason) || "未知原因"));
751
+ });
752
+ return;
753
+ }
754
+ // 已有解析结论(裸名缓存命中)→ 直接侧栏开,省一次往返
755
+ if (c && c.ok) {
756
+ if (openInSidebar(c.abs)) { toast("已在侧边栏打开"); return; }
757
+ hostGet("/open", { p: c.abs }).then(function (r) {
758
+ if (disposed) return;
759
+ if (!r || !r.ok) toast("打开失败:" + ((r && r.reason) || "未知原因"));
760
+ });
761
+ return;
762
+ }
763
+ // 裸名首次:act 内部走一次 verify(/resolve) 再侧栏开(验收②:1 次解析)
764
+ act(hit.raw, "preview", ev.clientX, ev.clientY);
635
765
  }, 0);
636
766
  }
637
767
 
@@ -693,31 +823,49 @@ window.__ModuleLoader__.load({
693
823
  }
694
824
  function hideHover() { if (hoverBox) hoverBox.style.display = "none"; }
695
825
 
826
+ // 节流窗口内只排一次 timer,窗口结束时用**最新**坐标判定。
827
+ //
828
+ // 这里有两处曾经的毛病(issue #1 报的就是这两条):
829
+ // 1) 间隔写死 150ms → 高亮慢半拍,鼠标划过一行字时明显能看出滞后;
830
+ // 2) 坐标在 setTimeout **外面**就取好了(`var x = ev.clientX` 在排程那
831
+ // 一刻执行),于是 150ms 后判定的是「150ms 之前鼠标在哪」。鼠标已经
832
+ // 移开就画到旧位置,移得慢就还没到位 —— 落点不准。
833
+ // 现在:坐标在回调里从 hoverX/hoverY 读,永远是当前值;间隔由
834
+ // HOVER_DELAY_DEFAULT_MS(40ms)/ config.hoverDelayMs 决定。
696
835
  function onMove(ev) {
697
- if (disposed || hoverTimer) return;
698
- var x = ev.clientX;
699
- var y = ev.clientY;
700
- hoverTimer = setTimeout(function () {
701
- hoverTimer = null;
702
- if (disposed || !document || !document.body) return;
703
- var hit = hitAt(x, y);
704
- if (!hit) { setCursor(""); hideHover(); return; }
705
- // 先确认文件真的存在,再给「可点」的视觉信号:
706
- // 不存在的片段不画虚线、不变手型,用户根本看不出它曾被识别过。
707
- verify(hit.raw, function (rec) {
708
- if (disposed) return;
709
- if (!rec.ok) { setCursor(""); hideHover(); return; }
710
- setCursor("pointer");
711
- // 绝对路径通道可能把「路径 + 尾随说明」整段当候选,粗略 rect 会盖到说明文字上。
712
- // 命中后按宿主返回的真实 abs 长度把高亮收窄到路径本身(起点对齐,abs 是候选的前缀)。
713
- var rr = hit.rect;
714
- if (hit.node && rec.abs) {
715
- var precise = rectFor(hit.node, hit.absStart, hit.absStart + rec.abs.length);
716
- if (precise) rr = precise;
717
- }
718
- drawHover(rr);
719
- });
720
- }, 150);
836
+ if (disposed) return;
837
+ // 每一次移动都记账,哪怕这一格被节流掉:回调要读的就是这份最新账。
838
+ hoverX = ev.clientX;
839
+ hoverY = ev.clientY;
840
+ if (hoverTimer) return;
841
+ hoverTimer = setTimeout(hoverProbe, hoverDelayMs);
842
+ }
843
+
844
+ function hoverProbe() {
845
+ hoverTimer = null;
846
+ if (disposed || !document || !document.body) return;
847
+ var hit = hitAt(hoverX, hoverY);
848
+ if (!hit) { setCursor(""); hideHover(); return; }
849
+ // 先确认文件真的存在,再给「可点」的视觉信号:
850
+ // 不存在的片段不画虚线、不变手型,用户根本看不出它曾被识别过。
851
+ verify(hit.raw, function (rec) {
852
+ if (disposed) return;
853
+ if (!rec.ok) { setCursor(""); hideHover(); return; }
854
+ // 这一次 verify 可能是异步回来的(首次解析要走 /resolve),那时鼠标
855
+ // 早就动了 —— 按**此刻**的位置重算命中,命中换了就收摊,绝不把高亮
856
+ // 留在已经离开的那段文字上。
857
+ var liveHit = hitAt(hoverX, hoverY);
858
+ if (!liveHit || liveHit.raw !== hit.raw) { setCursor(""); hideHover(); return; }
859
+ setCursor("pointer");
860
+ // 绝对路径通道可能把「路径 + 尾随说明」整段当候选,粗略 rect 会盖到说明文字上。
861
+ // 命中后按宿主返回的真实 abs 长度把高亮收窄到路径本身(起点对齐,abs 是候选的前缀)。
862
+ var rr = liveHit.rect;
863
+ if (liveHit.node && rec.abs) {
864
+ var precise = rectFor(liveHit.node, liveHit.absStart, liveHit.absStart + rec.abs.length);
865
+ if (precise) rr = precise;
866
+ }
867
+ drawHover(rr);
868
+ });
721
869
  }
722
870
 
723
871
  // 菜单内的 mousedown 必须在捕获期拦下:宿主的 closeOutside 挂在 document 层,
@@ -795,7 +943,13 @@ window.__ModuleLoader__.load({
795
943
  // 问一次宿主是否在线(顺带取回 bases:排查「未解析」时直接看 api.host.bases)
796
944
  try {
797
945
  hostGet("/info").then(function (d) {
798
- if (!disposed && d && api) api.host = d;
946
+ if (disposed || !d || !api) return;
947
+ api.host = d;
948
+ // 悬停节流间隔由宿主半边下发(config.hoverDelayMs),这里只做
949
+ // 上下限兜底,绝不自己另立一个默认值。
950
+ if (typeof d.hoverDelayMs === "number" && isFinite(d.hoverDelayMs) && d.hoverDelayMs >= 0) {
951
+ hoverDelayMs = Math.min(Math.max(Math.floor(d.hoverDelayMs), 0), HOVER_DELAY_MAX_MS);
952
+ }
799
953
  });
800
954
  } catch (e) { /* 宿主没起来也不影响本地事件 */ }
801
955
 
package/lib/index.js CHANGED
@@ -126,6 +126,11 @@ var DEFAULT_SEARCH_MAX_DEPTH = 4;
126
126
  var searchMaxDepth = DEFAULT_SEARCH_MAX_DEPTH;
127
127
  var DEFAULT_INDEX_TTL_MS = 30000;
128
128
  var indexTtlMs = DEFAULT_INDEX_TTL_MS;
129
+ // 客户端悬停判定的节流间隔(毫秒)。40 ≈ 2.5 帧:肉眼已经跟手,
130
+ // 又不会让 mousemove 每次都触发一次 caretRangeFromPoint + /resolve。
131
+ // 原来客户端把这条写死成 150ms,用户反馈「悬停芯片延迟偏高」。
132
+ var DEFAULT_HOVER_DELAY_MS = 40;
133
+ var hoverDelayMs = DEFAULT_HOVER_DELAY_MS;
129
134
  var basenameIndexCache = new Map();
130
135
  var statCache = new Map();
131
136
 
@@ -731,6 +736,12 @@ export function apply(ctx, config) {
731
736
  if (config && typeof config.indexTtlMs === "number" && config.indexTtlMs >= 0) {
732
737
  indexTtlMs = config.indexTtlMs;
733
738
  }
739
+ // 悬停判定的节流间隔(毫秒)。客户端半边不写死默认值,一律以这里为准:
740
+ // 0 = 不节流(每次都判定,mousemove 频繁时 IO 会略多),默认 40(≈2.5 帧)。
741
+ // 上限 1000:再大就等于「悬停不响应」了,不是配置而是坏掉。
742
+ if (config && typeof config.hoverDelayMs === "number" && isFinite(config.hoverDelayMs) && config.hoverDelayMs >= 0) {
743
+ hoverDelayMs = Math.min(Math.floor(config.hoverDelayMs), 1000);
744
+ }
734
745
  // 铁律文本也可整体替换(治「用户想自己改措辞」的场景)
735
746
  var rulesText = RULES_TEXT;
736
747
  if (config && Array.isArray(config.rulesText) && config.rulesText.length > 0) {
@@ -771,6 +782,7 @@ export function apply(ctx, config) {
771
782
  clientChips: clientChips,
772
783
  injectRules: injectRules,
773
784
  allowOutside: allowOutside,
785
+ hoverDelayMs: hoverDelayMs,
774
786
  cwd: process.cwd(),
775
787
  bases: candidateBases()
776
788
  });
@@ -843,8 +855,20 @@ export function apply(ctx, config) {
843
855
  }
844
856
  }));
845
857
 
846
- // /reveal?p=<abs> —— 在系统资源管理器里揭示并选中(目录则直接打开)
847
- // /open?p=<abs> —— 用系统默认程序打开
858
+ // 5A:让动作路由既能接受绝对路径(旧行为),也能接受原始文本(裸名/相对路径),
859
+ // 内部自己 resolveAcross 解析——这样客户端「点击动作」可以一次 Host 调用直达,
860
+ // 不必先单独打一发 /resolve。绝对路径走精确 stat 分支,行为与旧版完全一致。
861
+ function absFromParam(pRaw, baseParam) {
862
+ var s = cleanRaw(pRaw || "");
863
+ if (!s) return null;
864
+ if (looksAbsolute(s)) return pathResolve(s);
865
+ var r = resolveAcross(s, baseParam, allowOutside);
866
+ if (r && r.ok && r.abs) return r.abs;
867
+ return null;
868
+ }
869
+
870
+ // /reveal?p=<abs|raw> —— 在系统资源管理器里揭示并选中(目录则直接打开)
871
+ // /open?p=<abs|raw> —— 用系统默认程序打开(也接受原始文本,宿主内部解析)
848
872
  function makeLauncher(action) {
849
873
  return function (req, res) {
850
874
  if (!isLoopback(req)) { res.writeHead(403); res.end(); return; }
@@ -855,12 +879,11 @@ export function apply(ctx, config) {
855
879
  json(res, 400, { ok: false, reason: "bad-url" });
856
880
  return;
857
881
  }
858
- var abs = cleanRaw(url.searchParams.get("p") || "");
859
- if (!abs || !looksAbsolute(abs)) {
860
- json(res, 200, { ok: false, reason: "need-absolute-path" });
882
+ var abs = absFromParam(url.searchParams.get("p") || "", url.searchParams.get("base") || "");
883
+ if (!abs) {
884
+ json(res, 200, { ok: false, reason: "unresolved" });
861
885
  return;
862
886
  }
863
- abs = pathResolve(abs);
864
887
  if (!insideAnyBase(abs, allowOutside)) {
865
888
  json(res, 200, { ok: false, reason: "outside-base", abs: abs });
866
889
  return;
@@ -891,12 +914,11 @@ export function apply(ctx, config) {
891
914
  json(res, 400, { ok: false, reason: "bad-url" });
892
915
  return;
893
916
  }
894
- var abs = cleanRaw(url.searchParams.get("p") || "");
895
- if (!abs || !looksAbsolute(abs)) {
896
- json(res, 200, { ok: false, reason: "need-absolute-path" });
917
+ var abs = absFromParam(url.searchParams.get("p") || "", url.searchParams.get("base") || "");
918
+ if (!abs) {
919
+ json(res, 200, { ok: false, reason: "unresolved" });
897
920
  return;
898
921
  }
899
- abs = pathResolve(abs);
900
922
  if (!insideAnyBase(abs, allowOutside)) {
901
923
  json(res, 200, { ok: false, reason: "outside-base", abs: abs });
902
924
  return;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-artifact-linker",
3
- "version": "0.4.2",
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.",
3
+ "version": "0.4.4",
4
+ "description": "DSH 产物路径联动器:常驻注入「产物路径铁律」让模型输出完整绝对路径,同时把聊天正文里的文件名/路径渲染成一键可点的产物芯片——左键在右侧边栏预览(对齐官方交付卡片,绝对路径 0 次解析直通,侧栏认领不了自动退回系统默认程序),Ctrl/Shift/Cmd+左键用系统默认程序打开,Alt+左键复制绝对路径,右键 8 项菜单(在侧栏打开 / 在浮窗中打开 / 在文件夹中显示 / 用默认程序打开 / 另存为… / 复制文件 / 复制文件路径 / 添加到对话框)。宿主端提供回环只读路由与 /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, left click previews in DSH's built-in sidebar (absolute paths resolve in 0 round-trips; unclaimed types fall back to the default app), Ctrl/Shift/Cmd+click opens with the system default app, Alt+click copies the absolute path, and the right-click menu offers eight actions (open in sidebar / open in floating window / reveal in Explorer / open with default app / 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,