dsh-artifact-linker 0.4.2 → 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,55 @@
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
+
3
53
  ## 0.4.2 — 2026-10-30
4
54
 
5
55
  ### 改:识别层重写为「存在性驱动」——不再靠扩展名/排除字符集猜边界,任意绝对路径、任意文件名都能打开
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.2";
32
+ var VERSION = "0.4.3";
33
33
  // 路由前缀必须与宿主的 ROUTE_PREFIX 一致;永远根相对,绝不拼宿主 origin
34
34
  var ROUTE = "/dsh-artifact-linker";
35
35
 
@@ -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;
@@ -693,31 +708,49 @@ window.__ModuleLoader__.load({
693
708
  }
694
709
  function hideHover() { if (hoverBox) hoverBox.style.display = "none"; }
695
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 决定。
696
720
  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);
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
+ });
721
754
  }
722
755
 
723
756
  // 菜单内的 mousedown 必须在捕获期拦下:宿主的 closeOutside 挂在 document 层,
@@ -795,7 +828,13 @@ window.__ModuleLoader__.load({
795
828
  // 问一次宿主是否在线(顺带取回 bases:排查「未解析」时直接看 api.host.bases)
796
829
  try {
797
830
  hostGet("/info").then(function (d) {
798
- 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
+ }
799
838
  });
800
839
  } catch (e) { /* 宿主没起来也不影响本地事件 */ }
801
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
 
@@ -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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-artifact-linker",
3
- "version": "0.4.2",
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",