dsh-artifact-linker 0.4.0 → 0.4.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,99 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.3 — 2026-11-01
4
+
5
+ ### 修复:悬停高亮慢半拍、落点不准(issue #1)
6
+
7
+ 报告(@ZhJack28):悬停芯片延迟偏高,建议把节流降到 ~40ms,并且回调用最新坐标而不是排程那一刻的坐标。核实后**两条都成立**,而且第二条比第一条更影响手感。
8
+
9
+ **根因①:间隔写死 150ms。** `onMove` 末尾写的是 `}, 150);`。150ms ≈ 9 帧,已经落在「人眼能明确察觉"慢了一拍"」的区间里:鼠标划过一行字时,手型和高亮总是追在指针后面出现。这个值还**不能配**——官方硬规则 #5 要求一切 tunable 能从 cordis.yml 改,写死本身就是违规。
10
+
11
+ **根因②:坐标在 `setTimeout` 外面就取好了。**
12
+
13
+ ```js
14
+ function onMove(ev) {
15
+ if (disposed || hoverTimer) return;
16
+ var x = ev.clientX; // ← 排程这一刻取值
17
+ var y = ev.clientY;
18
+ hoverTimer = setTimeout(function () {
19
+ var hit = hitAt(x, y); // ← 150ms 后拿它判定
20
+ }, 150);
21
+ }
22
+ ```
23
+
24
+ 于是 150ms 后判定的是「150ms 之前鼠标在哪」。鼠标已经移开 → 高亮画在已经离开的那段文字上;鼠标还没移到 → 高亮落在半路。表现就是「落点不准 / 高亮跟文字对不上」。
25
+
26
+ **修法(`lib/client.js`)**
27
+
28
+ | 位置 | 旧 | 新 |
29
+ |---|---|---|
30
+ | `onMove` | 有 timer 就直接 return(本次移动被丢弃,坐标也就丢了) | **每次移动都记账**(`hoverX`/`hoverY`),只在没有 timer 时排程 |
31
+ | 判定回调 | 匿名闭包,读排程那一刻的 `x`/`y` | 提为具名 `hoverProbe()`,从 `hoverX`/`hoverY` 读**当前**坐标 |
32
+ | 间隔 | 字面量 `150` | `hoverDelayMs`,默认 `HOVER_DELAY_DEFAULT_MS = 40`(≈2.5 帧),硬上限 1000 |
33
+ | 来源 | 客户端自己写死 | 宿主 `config.hoverDelayMs` → `/info` 下发,客户端只做上下限兜底 |
34
+
35
+ **顺手堵一个同源竞态**:`verify()` 首次解析要走 `/resolve`(异步)。等它回来时鼠标可能早就动了,原实现会拿**旧 hit 的 rect** 画高亮。现在回调里按 `hoverX/hoverY` 重算一次 `hitAt`,命中换了就 `hideHover()` 收摊——绝不把高亮留在已经离开的文字上。
36
+
37
+ **配置(`cordis.patch.yml` 新增,默认值不变行为)**
38
+
39
+ ```yaml
40
+ config:
41
+ hoverDelayMs: 40 # 0 = 不节流;上限 1000
42
+ ```
43
+
44
+ **验证**
45
+
46
+ - 新增探针 `test/probe-hover.cjs`(Node 里搭最小 DOM 假环境,把 `lib/client.js` 原样加载后直接驱动 `mousemove`),**29 项断言全 PASS / 0 FAIL**,`node test/probe-hover.cjs` 一键跑。
47
+ - 断言覆盖:源码里已无 `}, 150)` 字面量、默认排程为 40ms、节流窗口内连续移动只排一次 timer、回调读到的是**最后一次**坐标(而非排程那一刻)、命中画在实测 rect 上(左-3 / 上-1)、移开恢复光标并隐藏浮层、**异步 verify 回来后鼠标已移走则不画**、鼠标停住时异步命中照样画出、不存在文件既不画也不变手型、`hoverDelayMs` 被 `/info` 覆盖后生效、越界值(负数 / 10000 / 非数字)被夹回合法区间、`dispose()` 清掉在途 timer 并摘掉监听、`VERSION` 与 `package.json` 一致。
48
+ - `node --check lib/client.js` / `lib/index.js` 均通过;`dsh-plugin-dev check .` = OK(9 passed / 0 failed)。
49
+ - 真机(Win11 / DSH 0.2.1-rc.5 / Node 24):悬停跟手 <50ms,落点与文字对齐;左键揭示、Ctrl+左键、右键菜单三项交互无回归。
50
+
51
+ ---
52
+
53
+ ## 0.4.2 — 2026-10-30
54
+
55
+ ### 改:识别层重写为「存在性驱动」——不再靠扩展名/排除字符集猜边界,任意绝对路径、任意文件名都能打开
56
+
57
+ 病根:0.4.1 仍是「正则按扩展名 + 排除字符集猜文件边界」。而 Windows 合法文件名字符几乎覆盖所有可见字符(括号、`【】`、中文标点、含空格、无扩展名、名字里带 `.数字`),排除法必然有漏;每漏一类就得再打一次补丁,不可收敛。真正的文件边界只有**磁盘真实存在性**能裁决。
58
+
59
+ ### 改(`lib/client.js` 识别层 + `lib/index.js` 解析层)
60
+
61
+ | 位置 | 旧 | 新 |
62
+ |---|---|---|
63
+ | 客户端识别(`hitAt`) | 用带扩展名+排除字符集的正则猜文件边界 | 新增绝对路径通道:`ABS_ANCHOR`(盘符 `X:\` 或 UNC `\\host\share\`)锁定起点 → 贪心截到**本行行尾**,不再猜扩展名;返回 `absStart` 供精确高亮;识别层不去尾(真实文件可能以 `)` `】` `。` 结尾) |
64
+ | 高亮(`onMove`) | 用整块 rect | 命中后用后端返回的真实文件名 `rec.abs.length` 收窄精确高亮 |
65
+ | 宿主解析(`resolveAcross`) | 整串 → 从左按空白剥 | ⓪原样整串精确 stat(救「文件名真以 (2024) 结尾」)→ 整串 tryAll → ①从右逐字符收缩 `shrinkRightAbs`(**不跨最后一个路径分隔符**、下界 `lastSep+2`、只做绝对精确 stat,避免砍成父目录/同名误报,`source:"absolute-shrunk"`)→ ②从左按空白剥兜底 |
66
+ | `VERSION` | `0.4.1` | `0.4.2` |
67
+
68
+ ### 验证(探针 `probe_v042.cjs`,复刻真实 resolveAcross + shrinkRightAbs + clientHitAt)
69
+
70
+ - 24 项断言全 PASS / 0 FAIL;`node --check` 双文件 exit 0;无 BOM。
71
+ - 覆盖:上轮 146 字 `inratio1.8D` 原样命中、路径紧跟中文说明(无空格)、路径+空格+说明、文件名含 `(最终)【v2】`、无扩展名 `README`、多级 `.cas.h5`+说明、文件名真以 `(2024)` 结尾(+说明)、前置说明词、**防误报(不存在文件不退化成父目录 `D:\a`)**、正文假路径不命中、相对裸名不误判;`hitAt` 取全路径吃到行尾、`absStart` 指向盘符、行尾不跨行、无盘符不进入绝对通道、无扩展名绝对路径可识别。
72
+ - 回归:host `/resolve` 契约未变;旧版识别不到的路径变可识别,已能识别的仍可识别。
73
+
74
+ ## 0.4.1 — 2026-10-30
75
+
76
+ ### 修:目录名里带「.数字/字母」(如 `inratio1.8D`)时,路径芯片只识别到该级目录、后半段文件丢失
77
+
78
+ 病根:`lib/client.js` 路径识别正则的中间段是**非贪婪**匹配(`{1,200}?` 后紧跟一个点),一遇到目录名里的点(`.../7-VTD-qianzhuijiao15-19-inratio1.8D/...`)就当作文件扩展名边界,把 `inratio1.8D` 里的 `.8D` 误判成扩展名 → 路径被截断在 `...inratio1.8D`(第 3 级目录),其后的 `comparison\...\*.png` 全部丢失,表现为用户所说「只能选到上几级的路径」。
79
+
80
+ ### 改(`lib/client.js` 三处 + `VERSION`)
81
+
82
+ | 位置 | 旧 | 新 |
83
+ |---|---|---|
84
+ | RE 末尾前瞻 | 只排除字母数字下划线 | 增加两条排除:①扩展名后紧跟路径分隔符(斜杠/反斜杠)②扩展名后又接「点+扩展名」段。强迫正则跨过目录名里的伪扩展名,一路吃到真正的文件扩展名 |
85
+ | 中间段长度上限 | `{1,200}?` | `{1,1024}?` |
86
+ | `hitAt` 取词窗口 | 鼠标前后各 200 字 | 鼠标前后各 1024 字(长路径整体落入匹配窗口) |
87
+ | `VERSION` 常量 | `0.4.0` | `0.4.1` |
88
+
89
+ ### 验证(从真实修复文件提取 RE + hitAt 窗口探针)
90
+
91
+ - 用户路径 146 字 `...inratio1.8D\comparison\...\VTD-...-静压.png` → 整段匹配;hitAt 鼠标落在偏移 0/20/74/90/120/146 均返回完整路径
92
+ - 不退化:`data.cas.h5` / `a.tar.gz` / `case.dat.h5` / 带空格中文名 `季度报告 最终版.pdf` / UNC 路径
93
+ - 多点目录 `2024.10.30\file.png`、`run.v1.3.2d\a.png` 均完整(旧版会截断)
94
+ - 不误报:散文 `版本 v1.2 已发布` → 无匹配
95
+ - `node --check` 通过;文件无 BOM
96
+
3
97
  ## 0.4.0 — 2026-10-30
4
98
 
5
99
  ### 改:彻底去掉扩展名白名单,所有文件类型都能识别、都能走侧边栏预览
package/README.en.md CHANGED
@@ -100,8 +100,11 @@ All tunables live in `cordis.patch.yml` (or the profile-level patch). Changing t
100
100
  | `allowOutside` | `true` | Allow resolving paths outside the workspace |
101
101
  | `statTtlMs` | `10000` | Host-side `stat` cache lifetime |
102
102
  | `searchMaxDepth` | `2` (max 8) | Recursion depth for bare-filename lookup |
103
+ | `hoverDelayMs` | `40` | Hover-probe throttle in ms (ceiling 1000). `0` = no throttling |
103
104
  | `rulesText` | built-in 6 lines | Replace the injected rules wholesale (array or multi-line string) |
104
105
 
106
+ 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.
107
+
105
108
  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
109
 
107
110
  ## What it does NOT do
package/README.md CHANGED
@@ -123,6 +123,9 @@ GET http://127.0.0.1:<port>/dsh-artifact-linker/info
123
123
  | `injectRules` | `true` | `false` = 不注入 prompt 段,模型不再被要求输出绝对路径 |
124
124
  | `clientChips` | `true` | `false` = 纯注入模式,不接管任何鼠标事件 |
125
125
  | `allowOutside` | `true` | `false` = 只允许打开 workspace 内的文件 |
126
+ | `hoverDelayMs` | `40` | 悬停判定的节流间隔(毫秒,上限 1000)。`0` = 不节流;调大 = 更省 IO,但手型/高亮更迟钝 |
127
+
128
+ **`hoverDelayMs` 说明**:0.4.2 及更早把这条写死成 `150ms`,鼠标划过一行文字时高亮会明显慢半拍(150ms ≈ 9 帧),而且判定用的是「排程那一刻」的坐标,不是判定那一刻的坐标,所以还会出现落点不准。0.4.3 起改为可配、默认 `40ms`(≈2.5 帧,肉眼已跟手),并且每次 `mousemove` 都会刷新坐标,判定时读最新值。
126
129
 
127
130
  **`allowOutside` 要特别说一句**:产物经常落在项目盘(`E:\…`、`D:\…`)而不是 DSH 的 workspace 里,所以默认放开。但它同时是安全闸——挡的是「模型说个 `C:\Windows\System32\xxx.exe` 就帮你点开」这类情况。**自己一个人用保持 `true` 更顺手;给别人用、或者接入不可信模型,改成 `false`。**
128
131
 
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.0";
32
+ var VERSION = "0.4.3";
33
33
  // 路由前缀必须与宿主的 ROUTE_PREFIX 一致;永远根相对,绝不拼宿主 origin
34
34
  var ROUTE = "/dsh-artifact-linker";
35
35
 
@@ -119,14 +119,14 @@ window.__ModuleLoader__.load({
119
119
  // 解析必然失败 → 点上去毫无反应。这是「带空格文件名点了没反应」的真正根因。)
120
120
  var RE = new RegExp(
121
121
  "(?:[A-Za-z]:[\\\\/]|\\\\\\\\[^\\\\/\\s]+[\\\\/][^\\\\/\\s]+)?" +
122
- "[^\\t\\r\\n`\"'<>|*?()()【】\\[\\]{},;、]{1,200}?\\." +
122
+ "[^\\t\\r\\n`\"'<>|*?()()【】\\[\\]{},;、]{1,1024}?\\." +
123
123
  // 扩展名两种写法都收:
124
124
  // 字母开头 1–12 位(pdf / mp4 / catproduct / properties…)
125
125
  // 数字开头 2–12 位(7z / 3mf / 3gp / 8bf…)—— 必须 ≥2 位,否则
126
126
  // 散文里的 v1.2 会贡献一个「扩展名 2」,虽然最后也会被存在性闸门静默掉,
127
127
  // 但没必要让它进候选池。
128
128
  "([A-Za-z][A-Za-z0-9_]{0,11}|[0-9][A-Za-z0-9_]{1,11})" +
129
- "(?:\\.[A-Za-z0-9]{1,12})*(?![A-Za-z0-9_])",
129
+ "(?:\\.[A-Za-z0-9]{1,12})*(?![A-Za-z0-9_\\\\/]|\\.[A-Za-z0-9])",
130
130
  "gi"
131
131
  );
132
132
  // URL 里天然带 .png/.pdf,全部要排除,否则网页链接会被当成文件产物。
@@ -135,6 +135,21 @@ window.__ModuleLoader__.load({
135
135
  var mounted = false;
136
136
  var disposed = false;
137
137
  var hoverTimer = null;
138
+ // 悬停判定节流间隔(毫秒)。40ms ≈ 2.5 帧,肉眼已经「跟手」。
139
+ //
140
+ // 原来是写死的 150ms,用户反馈「悬停芯片延迟偏高」:150ms 相当于人眼能
141
+ // 明显察觉的滞后(约 9 帧),鼠标划过一行字时高亮总是慢半拍追上来。
142
+ // 现在改成默认 40ms,并且**可以从 cordis.yml 覆盖**(官方硬规则 #5:
143
+ // 一切 tunable 必须能配置,不许写死)。宿主半边的 `apply()` 读
144
+ // `config.hoverDelayMs`,再通过 /info 下发到这一侧。
145
+ var HOVER_DELAY_DEFAULT_MS = 40;
146
+ var HOVER_DELAY_MAX_MS = 1000;
147
+ var hoverDelayMs = HOVER_DELAY_DEFAULT_MS;
148
+ // 最近一次 mousemove 的坐标。节流期间**每次移动都要记账**,
149
+ // 否则回调只能拿「排程那一刻」的位置去判定:鼠标已经移走或还没移到位,
150
+ // 高亮就落在旧地方(用户反馈的第二个问题:落点不准)。
151
+ var hoverX = 0;
152
+ var hoverY = 0;
138
153
  var toastTimer = null;
139
154
  var toastEl = null;
140
155
  var menuEl = null;
@@ -208,11 +223,50 @@ window.__ModuleLoader__.load({
208
223
  var text = range.startContainer.nodeValue || "";
209
224
  if (!text) return null;
210
225
  var off = range.startOffset;
211
- var from = Math.max(0, off - 200);
212
- var to = Math.min(text.length, off + 200);
226
+ var from = Math.max(0, off - 1024);
227
+ var to = Math.min(text.length, off + 1024);
213
228
  var win = text.slice(from, to);
214
229
  var rel = off - from;
215
230
  var urls = urlRanges(win);
231
+
232
+ // ★ 绝对路径通道(v0.4.2):只要鼠标位置左侧有「盘符 X:\ 或 UNC \\host\share\」起点,
233
+ // 就从该起点一路**贪心吃到本行行尾**,不再用「扩展名 + 排除字符集」去猜边界。
234
+ // 这样任何名称都敢认:带空格、带 ()()[]【】{}、中文标点、无扩展名、超长、
235
+ // 目录名里带点(inratio1.8D / 2024.10.30)全都不影响 —— 真正的边界交给宿主
236
+ // resolveAcross 的「原样 stat → 从右逐字符收缩(不跨分隔符)」按真实磁盘裁决。
237
+ // 识别层刻意**不做 cleanRaw 去尾**:真实文件也可能以 ) 】 。 结尾,不能被误剥。
238
+ var ABS_ANCHOR = /(?:[A-Za-z]:[\\/]|\\\\[^\\/\r\n]+[\\/][^\\/\r\n]+[\\/]?)/g;
239
+ var lastStart = -1;
240
+ var ma;
241
+ ABS_ANCHOR.lastIndex = 0;
242
+ while ((ma = ABS_ANCHOR.exec(win))) {
243
+ if (ma.index > rel) break; // 只看鼠标左侧(含)的起点
244
+ lastStart = ma.index;
245
+ }
246
+ if (lastStart >= 0) {
247
+ var nl = win.indexOf("\n", lastStart);
248
+ var lineEnd = nl < 0 ? win.length : nl;
249
+ if (rel <= lineEnd) {
250
+ var seg = win.slice(lastStart, lineEnd).replace(/\s+$/, "");
251
+ if (seg && seg.length >= 4 && !inRanges(urls, lastStart, lastStart + seg.length)) {
252
+ var exts = seg.match(/\.[A-Za-z0-9]{1,12}/g) || [];
253
+ var isExec = false;
254
+ for (var ei = 0; ei < exts.length; ei++) {
255
+ if (EXEC_EXT.test(exts[ei].slice(1))) { isExec = true; break; }
256
+ }
257
+ if (!isExec) {
258
+ return {
259
+ raw: seg,
260
+ rect: rectFor(range.startContainer, from + lastStart, from + lineEnd),
261
+ node: range.startContainer,
262
+ absStart: from + lastStart
263
+ };
264
+ }
265
+ }
266
+ }
267
+ }
268
+
269
+ // ── 兜底:裸文件名 / 相对路径(无盘符),仍用扩展名形状识别,交后端 ①②③ 兜 ──
216
270
  var m;
217
271
  RE.lastIndex = 0;
218
272
  while ((m = RE.exec(win))) {
@@ -654,24 +708,49 @@ window.__ModuleLoader__.load({
654
708
  }
655
709
  function hideHover() { if (hoverBox) hoverBox.style.display = "none"; }
656
710
 
711
+ // 节流窗口内只排一次 timer,窗口结束时用**最新**坐标判定。
712
+ //
713
+ // 这里有两处曾经的毛病(issue #1 报的就是这两条):
714
+ // 1) 间隔写死 150ms → 高亮慢半拍,鼠标划过一行字时明显能看出滞后;
715
+ // 2) 坐标在 setTimeout **外面**就取好了(`var x = ev.clientX` 在排程那
716
+ // 一刻执行),于是 150ms 后判定的是「150ms 之前鼠标在哪」。鼠标已经
717
+ // 移开就画到旧位置,移得慢就还没到位 —— 落点不准。
718
+ // 现在:坐标在回调里从 hoverX/hoverY 读,永远是当前值;间隔由
719
+ // HOVER_DELAY_DEFAULT_MS(40ms)/ config.hoverDelayMs 决定。
657
720
  function onMove(ev) {
658
- if (disposed || hoverTimer) return;
659
- var x = ev.clientX;
660
- var y = ev.clientY;
661
- hoverTimer = setTimeout(function () {
662
- hoverTimer = null;
663
- if (disposed || !document || !document.body) return;
664
- var hit = hitAt(x, y);
665
- if (!hit) { setCursor(""); hideHover(); return; }
666
- // 先确认文件真的存在,再给「可点」的视觉信号:
667
- // 不存在的片段不画虚线、不变手型,用户根本看不出它曾被识别过。
668
- verify(hit.raw, function (rec) {
669
- if (disposed) return;
670
- if (!rec.ok) { setCursor(""); hideHover(); return; }
671
- setCursor("pointer");
672
- drawHover(hit.rect);
673
- });
674
- }, 150);
721
+ if (disposed) return;
722
+ // 每一次移动都记账,哪怕这一格被节流掉:回调要读的就是这份最新账。
723
+ hoverX = ev.clientX;
724
+ hoverY = ev.clientY;
725
+ if (hoverTimer) return;
726
+ hoverTimer = setTimeout(hoverProbe, hoverDelayMs);
727
+ }
728
+
729
+ function hoverProbe() {
730
+ hoverTimer = null;
731
+ if (disposed || !document || !document.body) return;
732
+ var hit = hitAt(hoverX, hoverY);
733
+ if (!hit) { setCursor(""); hideHover(); return; }
734
+ // 先确认文件真的存在,再给「可点」的视觉信号:
735
+ // 不存在的片段不画虚线、不变手型,用户根本看不出它曾被识别过。
736
+ verify(hit.raw, function (rec) {
737
+ if (disposed) return;
738
+ if (!rec.ok) { setCursor(""); hideHover(); return; }
739
+ // 这一次 verify 可能是异步回来的(首次解析要走 /resolve),那时鼠标
740
+ // 早就动了 —— 按**此刻**的位置重算命中,命中换了就收摊,绝不把高亮
741
+ // 留在已经离开的那段文字上。
742
+ var liveHit = hitAt(hoverX, hoverY);
743
+ if (!liveHit || liveHit.raw !== hit.raw) { setCursor(""); hideHover(); return; }
744
+ setCursor("pointer");
745
+ // 绝对路径通道可能把「路径 + 尾随说明」整段当候选,粗略 rect 会盖到说明文字上。
746
+ // 命中后按宿主返回的真实 abs 长度把高亮收窄到路径本身(起点对齐,abs 是候选的前缀)。
747
+ var rr = liveHit.rect;
748
+ if (liveHit.node && rec.abs) {
749
+ var precise = rectFor(liveHit.node, liveHit.absStart, liveHit.absStart + rec.abs.length);
750
+ if (precise) rr = precise;
751
+ }
752
+ drawHover(rr);
753
+ });
675
754
  }
676
755
 
677
756
  // 菜单内的 mousedown 必须在捕获期拦下:宿主的 closeOutside 挂在 document 层,
@@ -749,7 +828,13 @@ window.__ModuleLoader__.load({
749
828
  // 问一次宿主是否在线(顺带取回 bases:排查「未解析」时直接看 api.host.bases)
750
829
  try {
751
830
  hostGet("/info").then(function (d) {
752
- if (!disposed && d && api) api.host = d;
831
+ if (disposed || !d || !api) return;
832
+ api.host = d;
833
+ // 悬停节流间隔由宿主半边下发(config.hoverDelayMs),这里只做
834
+ // 上下限兜底,绝不自己另立一个默认值。
835
+ if (typeof d.hoverDelayMs === "number" && isFinite(d.hoverDelayMs) && d.hoverDelayMs >= 0) {
836
+ hoverDelayMs = Math.min(Math.max(Math.floor(d.hoverDelayMs), 0), HOVER_DELAY_MAX_MS);
837
+ }
753
838
  });
754
839
  } catch (e) { /* 宿主没起来也不影响本地事件 */ }
755
840
 
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
 
@@ -260,14 +265,51 @@ function resolveAcross(raw, extraBase, allowOutside) {
260
265
  return best;
261
266
  }
262
267
 
268
+ var rawTrim = String(raw || "").trim();
269
+
270
+ // 只对绝对路径做「精确 stat」的小工具:不走 basename 反查,避免把目录/同名文件误认。
271
+ function statAbsExact(s) {
272
+ if (!looksAbsolute(s)) return null;
273
+ var abs = pathResolve(s);
274
+ if (!allowOutside) {
275
+ var okb = false;
276
+ for (var bi = 0; bi < bases.length; bi++) { try { if (isInside(bases[bi], abs)) { okb = true; break; } } catch (e) { } }
277
+ if (!okb) return null;
278
+ }
279
+ var st = statOne(abs);
280
+ if (!st.exists) return null;
281
+ return { ok: true, raw: s, abs: abs, exists: true, kind: st.kind, size: st.size, mtimeMs: st.mtimeMs, source: "absolute" };
282
+ }
283
+
284
+ // ⓪ 原样整串精确 stat(不经 cleanRaw):真实文件名允许以 ) 】 。 结尾、也允许带空格中文,
285
+ // cleanRaw 会误剥这些尾部,所以先拿 raw 原样撞一次,命中即用。
286
+ var exact = statAbsExact(rawTrim);
287
+ if (exact) return exact;
288
+
263
289
  var first = tryAll(raw);
264
290
  if (first && first.ok) return first;
265
291
  var best = first;
266
292
 
267
- // 🔴 从左收缩重试(配合客户端正则允许空格的做法)
268
- // 客户端为了支持「带空格的文件名」,现在会把左边几个词一起吞进候选,
269
- // 于是「详见 报告.pdf」这种会整串送过来。这里先按整串试,不行就剥掉最左一段再试,
270
- // 直到只剩纯文件名。这样既保住空格文件名,又不会因为多吞了几个词就解析不到。
293
+ // ① 绝对路径:从右逐字符收缩,**不跨过最后一个路径分隔符**(避免把文件名砍光后退化成父目录误报)。
294
+ // 专治「路径后紧跟中文说明/尾部标点且中间无空格」:D:\a\b.png这个图 → D:\a\b.png。
295
+ function shrinkRightAbs(s) {
296
+ var t = String(s || "").trim();
297
+ if (!looksAbsolute(t)) return null;
298
+ var lastSep = Math.max(t.lastIndexOf("\\"), t.lastIndexOf("/"));
299
+ var lowBound = lastSep + 2; // 最后一个文件名至少保留 1 个字符
300
+ var limit = Math.min(t.length, 600);
301
+ for (var cut = t.length - 1; cut >= lowBound && (t.length - cut) <= limit; cut--) {
302
+ var cand = t.slice(0, cut);
303
+ var o = statAbsExact(cand);
304
+ if (o && o.ok) { o.raw = cand; o.shrunkFrom = s; o.source = "absolute-shrunk"; return o; }
305
+ }
306
+ return null;
307
+ }
308
+ var shrunkAbs = shrinkRightAbs(rawTrim);
309
+ if (shrunkAbs && shrunkAbs.ok) return shrunkAbs;
310
+ if (!best && shrunkAbs) best = shrunkAbs;
311
+
312
+ // ② 从左按空白剥(前置说明词 + 空格 + 路径)
271
313
  var segs = String(raw || "").trim().split(/\s+/);
272
314
  for (var j = 1; j < segs.length; j++) {
273
315
  var shrunk = segs.slice(j).join(" ");
@@ -694,6 +736,12 @@ export function apply(ctx, config) {
694
736
  if (config && typeof config.indexTtlMs === "number" && config.indexTtlMs >= 0) {
695
737
  indexTtlMs = config.indexTtlMs;
696
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
+ }
697
745
  // 铁律文本也可整体替换(治「用户想自己改措辞」的场景)
698
746
  var rulesText = RULES_TEXT;
699
747
  if (config && Array.isArray(config.rulesText) && config.rulesText.length > 0) {
@@ -734,6 +782,7 @@ export function apply(ctx, config) {
734
782
  clientChips: clientChips,
735
783
  injectRules: injectRules,
736
784
  allowOutside: allowOutside,
785
+ hoverDelayMs: hoverDelayMs,
737
786
  cwd: process.cwd(),
738
787
  bases: candidateBases()
739
788
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-artifact-linker",
3
- "version": "0.4.0",
3
+ "version": "0.4.3",
4
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",