@ryuu-64/dsh-find-all 0.1.5 → 0.1.7

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,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.7
4
+
5
+ - Use the host-native icon button for the conversation search entry, including theme, hover and keyboard-focus styles.
6
+ - Clarify that search covers loaded conversation content and can continue loading available earlier history; status text no longer implies complete history when the host cannot provide it.
7
+ - Retain the current match during history loading when its identity can still be verified.
8
+
9
+ The runtime code is unchanged from the accepted PR #17 merge (`f945fb9ccd581c808bc7f50128c3a7d7d2fca6da`). The existing 195 checks, release-artifact verification and RC2 Web acceptance passed; the user also confirmed their real-device test passed before this release. The exact supported host-version list is unchanged.
10
+
11
+ ## 0.1.6
12
+
13
+ Fix text matching, history feedback and search state after the 0.1.5 compatibility release.
14
+
15
+ - Match phrases across inline text and syntax-highlighting spans within one text block, while keeping separate messages, block elements, `<br>` elements and excluded content apart.
16
+ - Preserve original UTF-16 offsets during case-insensitive Unicode matching, so characters such as `İ` no longer shift later highlights. Matching remains literal, without locale-specific folding or normalization.
17
+ - Report “已搜索当前可用历史” when the host stops offering earlier pages. Recheck readiness and the final results before showing that status; it does not guarantee that the host's history is complete.
18
+ - Keep the last query in the active plugin instance so reopening the bar can restore it. A valid new text selection takes priority; disabling the plugin, refreshing or restarting clears the query.
19
+ - Preserve a verifiable current match when history is prepended or the page rescans, without scrolling automatically. Restore it across replaced text nodes only when the original block text is unchanged; otherwise clamp the previous result index. A new query or conversation selects the first result.
20
+ - Add regression coverage for observer cleanup, conversation and embedded-panel isolation, cross-node text matching, history feedback, query retention and stable selection.
21
+
22
+ The supported host-version list is unchanged. This patch does not add new Web or Desktop host-matrix acceptance evidence; the 0.1.5 compatibility notes and their documented limits still apply.
23
+
3
24
  ## 0.1.5
4
25
 
5
26
  Fix compatibility with newer DSH hosts while preserving the existing find controls and matching behavior.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-find-all
2
2
 
3
- DSH Desktop 的 **⌘F / Ctrl+F 查找**插件。它有一个和浏览器查找不一样的地方:**搜的是整段会话,不只是屏幕上已经显示出来的那一截。**
3
+ DSH Desktop 的 **⌘F / Ctrl+F 查找**插件。它有一个和浏览器查找不一样的地方:**会向宿主请求更早的历史,不只搜索当前已经显示出来的部分。**
4
4
 
5
5
  ## 为什么需要它
6
6
 
@@ -8,17 +8,19 @@ DSH 桌面窗口是 Electron 壳,本身没有浏览器那种查找栏。而会
8
8
 
9
9
  所以拿普通查找条搜一个较早的词,结果永远是 0,哪怕这段会话里确实有。
10
10
 
11
- 这个插件在你输入关键词之后,通过会话服务把更早的历史一页页加载进来,每加载一页就重搜一次,直到整段会话都在页面里。这样命中计数和高亮覆盖的是**整个会话**。
11
+ 这个插件在你输入关键词之后,通过会话服务把更早的历史一页页加载进来,每加载一页就重搜一次。宿主不再提供更早页时,最后重搜并提示“已搜索当前可用历史”。宿主公开接口不能证明历史完整性,插件不会把终止信号当作“全部搜完”。
12
12
 
13
13
  ## 功能
14
14
 
15
- - **⌘F / Ctrl+F** 唤起查找栏,自动带入当前选中的文字
16
- - **范围切换**:`整段`(默认)↔ `本页`,点那个胶囊按钮切换
17
- - **边搜边加载**:整段模式下自动分页加载历史,高亮与计数随加载实时更新
15
+ - **⌘F / Ctrl+F** 唤起查找栏,优先带入有效的选中文字;没有新选词时恢复上次关键词。关键词仅保留在当前插件实例内存中,禁用、刷新或重启后清空
16
+ - **范围切换**:`包含更早内容`(默认)↔ `已加载内容`,点那个胶囊按钮切换
17
+ - **已加载内容**:只搜索当前会话中已经加载到页面的内容,包括滚动区域外的内容;不会继续加载历史,也不会移除先前已经加载的内容
18
+ - **边搜边加载**:“包含更早内容”模式下自动分页加载历史,高亮与计数随加载实时更新
18
19
  - **进度可见**:显示「正在加载更早的历史… 已 3 页」,**点一下这段文字即可停止加载**
19
20
  - **安全上限**:最多加载 400 页(约 2 万条消息);历史不再增长时会自行停止
20
21
  - **导航**:Enter / Shift+Enter 上下一个;⌘G(Ctrl+G)/ F3 下一个,加 Shift 反向;Esc 关闭查找栏并清除高亮
21
- - 切换会话时自动重置,并在新会话里重新开始搜索
22
+ - 切换会话时清除旧结果,并在新会话里重搜同一关键词
23
+ - 后台重扫首次找到结果时选中第一项;前插历史时优先保留可确认的当前命中,不自动滚动。原节点可对应,或同一正文块仍在且原文完全未变时,可在 Text/span 换树后恢复当前项;无法确认时把旧序号夹到新结果范围,换词或换会话则选第一项
22
24
 
23
25
  ## 安装
24
26
 
@@ -53,7 +55,7 @@ dsh plugin --profile desktop remove dsh-find-bar
53
55
  | 打开查找栏 | `⌘F` / `Ctrl+F` |
54
56
  | 下一个 / 上一个命中 | `Enter` / `Shift+Enter` |
55
57
  | 下一个 / 上一个命中(另一套) | `⌘G`(`Ctrl+G`)/ `F3`,加 `Shift` 反向 |
56
- | 切换范围 `整段` ↔ `本页` | 点查找栏上的胶囊按钮 |
58
+ | 切换范围 `包含更早内容` ↔ `已加载内容` | 点查找栏上的胶囊按钮 |
57
59
  | 停止加载更早的历史 | 点「正在加载更早的历史…」那行文字 |
58
60
  | 关闭并清除高亮 | `Esc` |
59
61
 
@@ -62,9 +64,11 @@ dsh plugin --profile desktop remove dsh-find-bar
62
64
  ## 已知限制
63
65
 
64
66
  - 大小写不敏感,没有「区分大小写」开关
65
- - 单次搜索最多记 5000 个命中
67
+ - 单次搜索仅显示前 5000 处匹配
66
68
  - 折叠起来的工具调用和思考块,正文不在页面里,搜不到——先展开再搜
67
- - 整段模式要真的翻历史,所以很长的会话首次搜索需要等几秒
69
+ - “包含更早内容”模式要真的翻历史,所以很长的会话首次搜索需要等几秒
70
+ - 宿主尚未就绪、公开报错、无进展或到达上限时,保留已找到的结果并提示未完成;再次打开查找或输入关键词可手动重试。停止、关闭、换词或切换会话会取消插件任务,但不会中断宿主已经发出的请求
71
+ - “已搜索当前可用历史”不保证历史完整。宿主可能在正常末页和不连续页错误时都不再提供更早页;此时重新查找也不保证恢复缺失历史
68
72
 
69
73
  ## 卸载 / 回退
70
74
 
@@ -79,7 +83,7 @@ dsh plugin --profile desktop add dsh-find-bar # 想换回上游那
79
83
 
80
84
  ## 关于这个项目
81
85
 
82
- 本插件 fork 自 [secyborg/dsh-find-bar](https://github.com/secyborg/dsh-find-bar)(作者 secyborg,MIT),在上游的基础上加了「整段会话」搜索。上游原有的查找条界面、高亮、`n/N` 计数和键盘导航都保留了下来。
86
+ 本插件 fork 自 [secyborg/dsh-find-bar](https://github.com/secyborg/dsh-find-bar)(作者 secyborg,MIT),在上游的基础上加了自动加载更早内容的会话搜索。上游原有的查找条界面、高亮、`n/N` 计数和键盘导航都保留了下来。
83
87
 
84
88
  版权与许可为 MIT 双署名:`Copyright (c) 2026 secyborg` 与 `Copyright (c) 2026 Ryuu-64`。上游 README 原样保存在 `README.fork-origin.md`,改动清单与致谢见 `NOTICE.md`。
85
89
 
package/lib/client.js CHANGED
@@ -17,18 +17,18 @@
17
17
  // nothing, even though the conversation demonstrably contains it.
18
18
  //
19
19
  // What this plugin adds:
20
- // * A scope toggle in the bar: 「整段 / Whole」 (default) and 「本页 / Page」.
20
+ // * A scope toggle in the bar: 「包含更早内容 / Include earlier content」 (default) and 「已加载内容 / Loaded content」.
21
21
  // * In whole-conversation mode a search pages the older history in through
22
22
  // the session service (ctx.sessions.binding(id).session.loadOlder()),
23
23
  // re-running the match pass after every page — so the count and the
24
- // highlight set end up covering the entire session.
24
+ // highlight set cover the history the host currently makes available.
25
25
  // * Progress is visible ("正在加载更早的历史… 已 3 页"), clickable to stop,
26
26
  // capped at MAX_PAGES, and it stops by itself when the history stops
27
- // growing (which also covers hosts that expose no `hasMore`).
27
+ // growing. Host termination does not establish historical completeness.
28
28
  //
29
29
  // How it works without fighting React (unchanged from the fork):
30
30
  // * Matches are collected as DOM Range objects (start/end offsets inside
31
- // text nodes) via a TreeWalker that also descends into shadow roots.
31
+ // text nodes) from block-local text snapshots, including shadow roots.
32
32
  // * Highlights use the CSS Custom Highlight API (CSS.highlights + Highlight)
33
33
  // which paints ranges WITHOUT mutating the DOM — React keeps full
34
34
  // ownership of the tree. Chromium/Electron support this since 105.
@@ -59,44 +59,52 @@ window.__ModuleLoader__.load({
59
59
  /** How often the open bar notices that the user switched session. */
60
60
  var SESSION_POLL_MS = 600;
61
61
  var SKIP_TAGS = { SCRIPT: 1, STYLE: 1, NOSCRIPT: 1 };
62
+ // Structural boundaries, not a computed-style visibility policy. In
63
+ // particular, host message divs and code-content/toolbars stay separate.
64
+ var BLOCK_TAGS = {
65
+ ADDRESS: 1, ARTICLE: 1, ASIDE: 1, BLOCKQUOTE: 1, DD: 1, DETAILS: 1,
66
+ DIALOG: 1, DIV: 1, DL: 1, DT: 1, FIELDSET: 1, FIGCAPTION: 1, FIGURE: 1,
67
+ FOOTER: 1, FORM: 1, H1: 1, H2: 1, H3: 1, H4: 1, H5: 1, H6: 1,
68
+ HEADER: 1, HR: 1, LI: 1, MAIN: 1, NAV: 1, OL: 1, P: 1, PRE: 1,
69
+ SECTION: 1, SUMMARY: 1, TABLE: 1, TBODY: 1, TD: 1, TFOOT: 1,
70
+ TH: 1, THEAD: 1, TR: 1, UL: 1
71
+ };
62
72
 
63
73
  var ZH = typeof navigator !== "undefined" && /^zh/i.test(navigator.language || "");
64
74
  var L = ZH ? {
65
- placeholder: "查找整段会话…",
75
+ placeholder: "在此会话中查找…",
66
76
  prev: "上一个匹配",
67
77
  next: "下一个匹配",
68
78
  close: "关闭查找",
69
- cap: "已达匹配上限,仅显示前 {n} 条",
70
- scopeWhole: "整段",
71
- scopePage: "本页",
72
- scopeToPage: "搜索范围:整段会话。点击改为只搜当前页面",
73
- scopeToWhole: "搜索范围:仅当前页面。点击改为搜索整段会话",
79
+ cap: "仅显示前 {n} 处匹配",
80
+ scopeWhole: "包含更早内容",
81
+ scopePage: "已加载内容",
82
+ scopeToPage: "搜索当前会话中已加载的内容,并继续加载更早内容。点击改为仅搜索已加载内容",
83
+ scopeToWhole: "仅搜索当前会话中已加载的内容,不再加载更早内容。点击改为包含更早内容",
74
84
  loading: "正在加载更早的历史… 已 {n} 页",
75
- allLoaded: "整段会话已就绪",
76
- capped: "已到加载上限({n} 页),停止加载",
77
- stalled: "没有更早的历史了",
85
+ available: "已搜索当前可用历史;宿主未提供更早页,无法确认历史完整性",
86
+ capped: "搜索未完成:已到更早内容的加载上限({n} 页),已保留找到的结果",
78
87
  stopTip: "点击停止加载",
79
- noService: "会话接口不可用,仅保留已显示的部分结果",
88
+ noService: "搜索未完成:会话接口不可用,已保留找到的结果",
80
89
  noTarget: "请先选择可见的会话,再打开查找",
81
90
  noRoot: "当前会话的搜索范围不可用",
82
- incomplete: "历史尚未搜完,保留部分结果",
91
+ incomplete: "搜索未完成,已保留找到的结果",
83
92
  find: "查找会话"
84
93
  } : {
85
- placeholder: "Find in conversation…",
94
+ placeholder: "Find in this conversation…",
86
95
  prev: "Previous match",
87
96
  next: "Next match",
88
97
  close: "Close find",
89
- cap: "Match limit reached, showing first {n}",
90
- scopeWhole: "Whole",
91
- scopePage: "Page",
92
- scopeToPage: "Scope: the whole conversation. Click to search only the rendered page",
93
- scopeToWhole: "Scope: the rendered page only. Click to search the whole conversation",
98
+ cap: "Showing only the first {n} matches",
99
+ scopeWhole: "Include earlier content",
100
+ scopePage: "Loaded content",
101
+ scopeToPage: "Search loaded content in this conversation and load earlier content. Click to search loaded content only",
102
+ scopeToWhole: "Search only content already loaded in this conversation, without loading more. Click to include earlier content",
94
103
  loading: "Loading earlier history… {n} pages",
95
- allLoaded: "Whole conversation loaded",
96
- capped: "Page cap reached ({n}), stopped loading",
97
- stalled: "No earlier history",
104
+ available: "Searched currently available history; the host offers no earlier pages, so completeness cannot be confirmed",
105
+ capped: "History search incomplete: loading limit reached ({n} pages); partial results retained",
98
106
  stopTip: "Click to stop loading",
99
- noService: "Session service unavailable; rendered results only",
107
+ noService: "History search incomplete: session service unavailable; partial results retained",
100
108
  noTarget: "Select a visible conversation before opening find",
101
109
  noRoot: "Search scope unavailable for this conversation",
102
110
  incomplete: "History search incomplete; partial results retained",
@@ -105,22 +113,30 @@ window.__ModuleLoader__.load({
105
113
 
106
114
  //#region pure helpers (exported for tests)
107
115
 
108
- // All non-overlapping case-insensitive start indices of `query` in
109
- // `text`; empty array for an empty query or no hits.
110
- function findIndices(text, query) {
116
+ // Literal, locale-independent Unicode simple folding on the ORIGINAL
117
+ // text. RegExp returns UTF-16 offsets, exactly as DOM Range requires.
118
+ // No normalization, full case folding, or transformed-string offsets.
119
+ function findMatches(text, query) {
111
120
  if (!query) return [];
112
- var haystack = String(text).toLowerCase();
113
- var needle = String(query).toLowerCase();
121
+ // This is the entire pattern (not interpolated into another regex).
122
+ // Escape regex syntax only; '-' and '/' are literal in this context.
123
+ // Do not require the newer RegExp.escape() API in supported hosts.
124
+ var literal = String(query).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
125
+ if (!literal) return [];
126
+ var haystack = String(text);
127
+ var matcher = new RegExp(literal, "giu");
114
128
  var out = [];
115
- var i = haystack.indexOf(needle);
116
- while (i !== -1) {
117
- out.push(i);
118
- if (out.length >= MAX_MATCHES) break;
119
- i = haystack.indexOf(needle, i + needle.length);
129
+ var match;
130
+ while (out.length < MAX_MATCHES && (match = matcher.exec(haystack)) !== null) {
131
+ out.push({ start: match.index, end: match.index + match[0].length });
120
132
  }
121
133
  return out;
122
134
  }
123
135
 
136
+ function findIndices(text, query) {
137
+ return findMatches(text, query).map(function (match) { return match.start; });
138
+ }
139
+
124
140
  // Keyboard routing, isolated so tests can drive it with spies.
125
141
  function createHandlers(ops) {
126
142
  return function onKeydown(event) {
@@ -151,41 +167,44 @@ window.__ModuleLoader__.load({
151
167
  };
152
168
  }
153
169
 
154
- /**
155
- * Page older history in until the conversation stops growing.
156
- *
157
- * Every dependency is injected, so the loop is testable without a DOM or
158
- * a host: the caller supplies how to read `hasMore`, how to load a page,
159
- * how to measure the rendered history, and how to wait for that measure
160
- * to change.
161
- *
162
- * @param opts - { maxPages, isCancelled, hasMore, loadOlder, signature,
163
- * waitChange, onProgress }.
164
- * `hasMore` returns true/false/null (null = the host cannot say, so the
165
- * pager keeps going until a page changes nothing).
166
- * @returns { pages, reason } where reason is one of done | cancelled |
167
- * capped | stalled | error.
170
+ /** Page only while the public host state is ready; false is not completeness.
171
+ * Dependencies supply cancellable, bounded readiness/load/DOM waits.
172
+ * @returns { pages, reason } with done, cancelled, capped, stalled, unknown or error.
168
173
  */
169
174
  async function runPageIn(opts) {
170
175
  var pages = 0;
171
- var reason = "done";
176
+ function result(reason) { return { pages: pages, reason: reason }; }
172
177
  for (;;) {
173
- if (opts.isCancelled()) { reason = "cancelled"; break; }
174
- if (opts.hasMore() === false) { reason = "done"; break; }
175
- if (pages >= opts.maxPages) { reason = "capped"; break; }
178
+ if (opts.isCancelled()) return result("cancelled");
179
+ var ready = await opts.waitReady();
180
+ if (opts.isCancelled()) return result("cancelled");
181
+ if (ready.reason) return result(ready.reason);
182
+ if (ready.hasMore === false) return result("done");
183
+ if (pages >= opts.maxPages) return result("capped");
176
184
  var before = opts.signature();
177
- try {
178
- await opts.loadOlder();
179
- } catch (error) {
180
- reason = "error";
181
- break;
185
+ var loaded;
186
+ try { loaded = await opts.loadOlder(); }
187
+ catch (error) { loaded = { reason: "error" }; }
188
+ if (opts.isCancelled()) return result("cancelled");
189
+ ready = await opts.waitReady(!!(loaded && loaded.reason));
190
+ if (opts.isCancelled()) return result("cancelled");
191
+ if (loaded && loaded.reason) return result(loaded.reason);
192
+ if (ready.reason) return result(ready.reason);
193
+ if (loaded && loaded.deferred) continue;
194
+ if (ready.hasMore !== false) await opts.waitChange(before);
195
+ if (opts.isCancelled()) return result("cancelled");
196
+ ready = await opts.waitReady();
197
+ if (opts.isCancelled()) return result("cancelled");
198
+ if (ready.reason) return result(ready.reason);
199
+ var changed = opts.signature() !== before;
200
+ if (changed) {
201
+ pages += 1;
202
+ if (opts.onProgress) opts.onProgress(pages);
182
203
  }
183
- if (opts.isCancelled()) { reason = "cancelled"; break; }
184
- if (!(await opts.waitChange(before))) { reason = "stalled"; break; }
185
- pages += 1;
186
- if (opts.onProgress) opts.onProgress(pages);
204
+ if (opts.isCancelled()) return result("cancelled");
205
+ if (ready.hasMore === false) return result("done");
206
+ if (!changed) return result("stalled");
187
207
  }
188
- return { pages: pages, reason: reason };
189
208
  }
190
209
  //#endregion
191
210
 
@@ -221,8 +240,8 @@ window.__ModuleLoader__.load({
221
240
  return typeof CSS !== "undefined" && CSS.highlights && typeof Highlight === "function";
222
241
  }
223
242
 
224
- // Collect text nodes under `root`, descending into shadow roots and
225
- // skipping the find bar itself plus invisible metadata tags.
243
+ // Keep the existing pager signature collector unchanged. Matching below
244
+ // uses separate block snapshots; page-settlement semantics are out of scope.
226
245
  function collectTextNodes(root, into) {
227
246
  if (!root) return into;
228
247
  var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
@@ -247,17 +266,62 @@ window.__ModuleLoader__.load({
247
266
  return into;
248
267
  }
249
268
 
269
+ // Join inline Text nodes only within one structural block and DOM root.
270
+ // These snapshots live for one scan. Preserve every original code unit,
271
+ // including whitespace-only nodes and code newlines; never trim or pad.
272
+ function collectTextBlocks(root) {
273
+ var blocks = [];
274
+ var text = "";
275
+ var segments = [];
276
+ function flush() {
277
+ if (segments.length) blocks.push({ text: text, segments: segments });
278
+ text = "";
279
+ segments = [];
280
+ }
281
+ function visit(node) {
282
+ if (node.nodeType === 3) {
283
+ if (node.data.length) {
284
+ segments.push({ node: node, start: text.length, end: text.length + node.data.length });
285
+ text += node.data;
286
+ }
287
+ return;
288
+ }
289
+ if (node.nodeType !== 1 && node.nodeType !== 11) return;
290
+ if (SKIP_TAGS[node.tagName] || node.id === BAR_ID || node.tagName === "BR") {
291
+ // Excluded subtrees and br are hard boundaries, not empty text.
292
+ flush();
293
+ return;
294
+ }
295
+ var boundary = BLOCK_TAGS[node.tagName] || node.nodeType === 11 || node.shadowRoot;
296
+ if (boundary) flush();
297
+ for (var child = node.firstChild; child; child = child.nextSibling) visit(child);
298
+ if (boundary) flush();
299
+ // No Range may span a shadow root and its surrounding light DOM.
300
+ if (node.shadowRoot) visit(node.shadowRoot);
301
+ }
302
+ visit(root);
303
+ flush();
304
+ return blocks;
305
+ }
306
+
250
307
  function collectRanges(query, root) {
251
308
  var ranges = [];
252
309
  if (!query || !root || typeof document === "undefined") return ranges;
253
- var nodes = collectTextNodes(root, []);
254
- for (var i = 0; i < nodes.length && ranges.length < MAX_MATCHES; i++) {
255
- var node = nodes[i];
256
- var indices = findIndices(node.data, query);
257
- for (var j = 0; j < indices.length && ranges.length < MAX_MATCHES; j++) {
310
+ var blocks = collectTextBlocks(root);
311
+ for (var i = 0; i < blocks.length && ranges.length < MAX_MATCHES; i++) {
312
+ var block = blocks[i];
313
+ var matches = findMatches(block.text, query);
314
+ var start = 0, end = 0;
315
+ for (var j = 0; j < matches.length && ranges.length < MAX_MATCHES; j++) {
316
+ var match = matches[j];
317
+ // At a node junction, start at the following node and end at
318
+ // the preceding node. Non-overlapping matches move forwards.
319
+ while (match.start >= block.segments[start].end) start++;
320
+ while (match.end > block.segments[end].end) end++;
321
+ var first = block.segments[start], last = block.segments[end];
258
322
  var range = document.createRange();
259
- range.setStart(node, indices[j]);
260
- range.setEnd(node, indices[j] + query.length);
323
+ range.setStart(first.node, match.start - first.start);
324
+ range.setEnd(last.node, match.end - last.start);
261
325
  ranges.push(range);
262
326
  }
263
327
  }
@@ -448,7 +512,7 @@ window.__ModuleLoader__.load({
448
512
  return root ? collectTextNodes(root, []).length : 0;
449
513
  }
450
514
 
451
- function waitForChange(before, root, token) {
515
+ function waitForPage(check, isCancelled) {
452
516
  return new Promise(function (resolve) {
453
517
  var started = Date.now();
454
518
  function finish(value) {
@@ -457,24 +521,53 @@ window.__ModuleLoader__.load({
457
521
  state.settleCancel = null;
458
522
  resolve(value);
459
523
  }
460
- state.settleCancel = function () { finish(false); };
524
+ state.settleCancel = function () { finish({ reason: "cancelled" }); };
461
525
  (function poll() {
462
- if (token !== state.pageToken || !state.adapter.valid(state.target)) { finish(false); return; }
463
- if (domSignature(root) !== before) { finish(true); return; }
464
- if (Date.now() - started >= PAGE_SETTLE_MS) { finish(false); return; }
526
+ if (isCancelled()) { finish({ reason: "cancelled" }); return; }
527
+ var value = check();
528
+ if (value) { finish(value); return; }
529
+ if (Date.now() - started >= PAGE_SETTLE_MS) { finish({ reason: "stalled" }); return; }
465
530
  state.settleTimer = setTimeout(poll, PAGE_POLL_MS);
466
531
  })();
467
532
  });
468
533
  }
469
534
 
470
- function faceHasMore(face) {
535
+ function facePageState(face) {
471
536
  try {
472
- if (typeof face.getSnapshot !== "function") return null;
473
- var snapshot = face.getSnapshot();
474
- return snapshot && typeof snapshot.hasMore === "boolean" ? snapshot.hasMore : null;
475
- } catch (error) {
476
- return null;
477
- }
537
+ var snapshot = typeof face.getSnapshot === "function" && face.getSnapshot();
538
+ if (!snapshot) return { reason: "unknown" };
539
+ if (snapshot.openError != null || snapshot.openState === "error") return { reason: "error" };
540
+ if (snapshot.openState === "cold" || snapshot.openState === "loading") return null;
541
+ if (snapshot.openState !== "open" || snapshot.openError !== null) return { reason: "unknown" };
542
+ if (snapshot.loadingOlder === true) return null;
543
+ if (snapshot.loadingOlder !== false) return { reason: "unknown" };
544
+ return { hasMore: typeof snapshot.hasMore === "boolean" ? snapshot.hasMore : null };
545
+ } catch (error) { return { reason: "unknown" }; }
546
+ }
547
+
548
+ // A cancelled UI task cannot abort SessionFace.loadOlder(). Keep its record
549
+ // until settlement so a later query/enable never starts a concurrent request.
550
+ var pendingLoads = new WeakMap();
551
+ async function loadPage(face, isCancelled) {
552
+ var status = facePageState(face);
553
+ if (!status || status.reason || status.hasMore === false || pendingLoads.has(face)) return { deferred: true };
554
+ var pending = { done: false, reason: null };
555
+ pendingLoads.set(face, pending);
556
+ var promise;
557
+ try { promise = Promise.resolve(face.loadOlder()); }
558
+ catch (error) { promise = Promise.reject(error); }
559
+ promise.then(function () {
560
+ pending.done = true;
561
+ pendingLoads.delete(face);
562
+ }, function () {
563
+ pending.done = true;
564
+ pending.reason = "error";
565
+ pendingLoads.delete(face);
566
+ });
567
+ await Promise.resolve();
568
+ return waitForPage(function () {
569
+ return pending.done ? { reason: pending.reason } : null;
570
+ }, isCancelled);
478
571
  }
479
572
  //#endregion
480
573
 
@@ -494,6 +587,7 @@ window.__ModuleLoader__.load({
494
587
  query: "",
495
588
  ranges: [],
496
589
  index: -1,
590
+ selection: null,
497
591
  capped: false,
498
592
  observer: null,
499
593
  debounce: 0,
@@ -524,11 +618,16 @@ window.__ModuleLoader__.load({
524
618
  if (!state.query) state.count.textContent = "";
525
619
  else if (n === 0) state.count.textContent = state.paging ? "…" : "0/0";
526
620
  else state.count.textContent = (state.index + 1) + "/" + n + (state.capped ? "+" : "");
621
+ var cap = state.query && n > 0 && state.capped ? L.cap.replace("{n}", String(MAX_MATCHES)) : "";
622
+ state.count.title = cap;
623
+ if (cap) state.count.setAttribute("aria-label", state.count.textContent + "; " + cap);
624
+ else state.count.removeAttribute("aria-label");
527
625
  }
528
626
 
529
627
  function setStatus(text, busy) {
530
628
  if (!state.status) return;
531
629
  state.status.textContent = text || "";
630
+ state.status.title = busy ? L.stopTip : (text || "");
532
631
  if (busy) state.status.setAttribute("data-busy", "1");
533
632
  else state.status.removeAttribute("data-busy");
534
633
  updateCount();
@@ -547,12 +646,104 @@ window.__ModuleLoader__.load({
547
646
  return state.target || (state.adapter && state.adapter.scopeUnavailable()) ? L.noRoot : L.noTarget;
548
647
  }
549
648
 
649
+ // The code content wrapper survives the host's plain-pre -> Shiki tree
650
+ // swap (0.2.0-rc.2 CodeBlock). Otherwise use the nearest block boundary.
651
+ function selectionBlock(range, root) {
652
+ var nearest = null;
653
+ for (var node = range.startContainer.parentNode; node; node = node.parentNode) {
654
+ if (node === root || node.nodeType === 11) return nearest || node;
655
+ if (node.hasAttribute && node.hasAttribute("data-code-block-content")) return node;
656
+ if (!nearest && BLOCK_TAGS[node.tagName]) nearest = node;
657
+ }
658
+ return nearest;
659
+ }
660
+
661
+ function positionInBlocks(range, blocks) {
662
+ for (var i = 0; i < blocks.length; i++) {
663
+ var start = null, end = null;
664
+ for (var j = 0; j < blocks[i].segments.length; j++) {
665
+ var segment = blocks[i].segments[j];
666
+ if (segment.node === range.startContainer) start = segment.start + range.startOffset;
667
+ if (segment.node === range.endContainer) end = segment.start + range.endOffset;
668
+ }
669
+ if (start !== null && end !== null) return { run: i, start: start, end: end };
670
+ }
671
+ return null;
672
+ }
673
+
674
+ function matchNodes(blocks, position) {
675
+ if (!position) return [];
676
+ return blocks[position.run].segments.filter(function (segment) {
677
+ return segment.end > position.start && segment.start < position.end;
678
+ }).map(function (segment) {
679
+ return {
680
+ node: segment.node, text: segment.node.data,
681
+ start: Math.max(position.start, segment.start) - segment.start,
682
+ end: Math.min(position.end, segment.end) - segment.start
683
+ };
684
+ });
685
+ }
686
+
687
+ function rememberSelection() {
688
+ var range = state.ranges[state.index];
689
+ state.selection = null;
690
+ if (!range || !state.target) return;
691
+ var block = selectionBlock(range, state.target.root);
692
+ var blocks = block ? collectTextBlocks(block) : [];
693
+ var position = positionInBlocks(range, blocks);
694
+ var nodes = matchNodes(blocks, position);
695
+ state.selection = {
696
+ target: state.target, query: state.query, root: state.target.root,
697
+ startNode: range.startContainer, startOffset: range.startOffset,
698
+ endNode: range.endContainer, endOffset: range.endOffset,
699
+ block: block, position: position, nodes: nodes,
700
+ texts: blocks.map(function (item) { return item.text; })
701
+ };
702
+ }
703
+
704
+ function retainedIndex(saved) {
705
+ if (!saved || saved.target !== state.target || saved.query !== state.query || saved.root !== state.target.root) return -1;
706
+ if (!saved.block || !saved.block.isConnected || !saved.position) return -1;
707
+ var blocks = collectTextBlocks(saved.block);
708
+ var i, range;
709
+ for (i = 0; i < state.ranges.length; i++) {
710
+ range = state.ranges[i];
711
+ if (range.startContainer !== saved.startNode || range.startOffset !== saved.startOffset ||
712
+ range.endContainer !== saved.endNode || range.endOffset !== saved.endOffset) continue;
713
+ // Compare the new match's actual ordered participants. Old nodes can
714
+ // remain connected with unchanged text after moving outside this hit.
715
+ var nodes = matchNodes(blocks, positionInBlocks(range, blocks));
716
+ if (nodes.length > 0 && nodes.length === saved.nodes.length && nodes.every(function (item, index) {
717
+ var previous = saved.nodes[index];
718
+ return item.node === previous.node && item.text === previous.text && item.start === previous.start && item.end === previous.end;
719
+ })) return i;
720
+ }
721
+ if (blocks.length !== saved.texts.length || blocks.some(function (item, index) { return item.text !== saved.texts[index]; })) return -1;
722
+ var segments = blocks[saved.position.run].segments;
723
+ var first = null, last = null;
724
+ for (i = 0; i < segments.length; i++) {
725
+ if (saved.position.start >= segments[i].start && saved.position.start < segments[i].end) first = segments[i];
726
+ if (saved.position.end > segments[i].start && saved.position.end <= segments[i].end) last = segments[i];
727
+ }
728
+ if (!first || !last) return -1;
729
+ for (i = 0; i < state.ranges.length; i++) {
730
+ range = state.ranges[i];
731
+ if (range.startContainer === first.node && range.startOffset === saved.position.start - first.start &&
732
+ range.endContainer === last.node && range.endOffset === saved.position.end - last.start) return i;
733
+ }
734
+ return -1;
735
+ }
736
+
550
737
  function runSearch(keepIndex) {
551
738
  var valid = state.adapter && state.adapter.valid(state.target);
552
739
  state.ranges = valid ? collectRanges(state.query, state.target.root) : [];
553
740
  if (!valid) setStatus(unavailableStatus(), false);
554
741
  state.capped = state.ranges.length >= MAX_MATCHES;
555
- if (!keepIndex || state.index >= state.ranges.length) state.index = state.ranges.length > 0 ? 0 : -1;
742
+ var retained = keepIndex && valid ? retainedIndex(state.selection) : -1;
743
+ if (!state.ranges.length) state.index = -1;
744
+ else if (!keepIndex) state.index = 0;
745
+ else state.index = retained >= 0 ? retained : Math.max(0, Math.min(state.index, state.ranges.length - 1));
746
+ rememberSelection();
556
747
  paint();
557
748
  updateCount();
558
749
 
@@ -563,6 +754,7 @@ window.__ModuleLoader__.load({
563
754
  var n = state.ranges.length;
564
755
  if (n === 0) return;
565
756
  state.index = ((state.index + delta) % n + n) % n;
757
+ rememberSelection();
566
758
  paint();
567
759
  updateCount();
568
760
  scrollToRange(state.ranges[state.index]);
@@ -611,7 +803,8 @@ window.__ModuleLoader__.load({
611
803
 
612
804
  function startPageIn() {
613
805
  if (state.scope !== "whole" || !state.query || !isOpen()) return;
614
- var token = ++state.pageToken;
806
+ cancelPageIn();
807
+ var token = state.pageToken;
615
808
  var target = state.target;
616
809
  if (!state.adapter || !state.adapter.valid(target)) {
617
810
  setStatus(unavailableStatus(), false);
@@ -623,33 +816,51 @@ window.__ModuleLoader__.load({
623
816
  return;
624
817
  }
625
818
  state.sessionId = target.id;
626
- if (faceHasMore(face) === false) {
627
- setStatus(L.allLoaded, false);
628
- return;
819
+ function isCancelled() {
820
+ return token !== state.pageToken || !isOpen() || state.target !== target || !state.adapter || state.adapter.face(target) !== face;
821
+ }
822
+ function waitReady(failFast) {
823
+ return waitForPage(function () {
824
+ var status = facePageState(face);
825
+ if (status && status.reason) return status;
826
+ if (pendingLoads.has(face) || !status) return failFast ? { reason: "stalled" } : null;
827
+ return status;
828
+ }, isCancelled);
629
829
  }
630
830
  state.paging = true;
631
831
  state.pages = 0;
632
832
  setStatus(L.loading.replace("{n}", "0"), true);
633
833
  runPageIn({
634
834
  maxPages: MAX_PAGES,
635
- isCancelled: function () { return token !== state.pageToken || !isOpen() || !state.adapter.valid(target); },
636
- hasMore: function () { return faceHasMore(face); },
637
- loadOlder: function () { return Promise.resolve(face.loadOlder()); },
835
+ isCancelled: isCancelled,
836
+ waitReady: waitReady,
837
+ loadOlder: function () { return loadPage(face, isCancelled); },
638
838
  signature: function () { return domSignature(target.root); },
639
- waitChange: function (before) { return waitForChange(before, target.root, token); },
839
+ waitChange: function (before) {
840
+ return waitForPage(function () {
841
+ var status = facePageState(face);
842
+ return domSignature(target.root) !== before || !status || status.reason || status.hasMore === false ? {} : null;
843
+ }, isCancelled);
844
+ },
640
845
  onProgress: function (pages) {
641
- if (token !== state.pageToken) return;
846
+ if (isCancelled()) return;
642
847
  state.pages = pages;
643
848
  setStatus(L.loading.replace("{n}", String(pages)), true);
644
849
  runSearch(true);
645
850
  }
646
851
  }).then(function (result) {
647
- if (token !== state.pageToken) return;
852
+ if (isCancelled()) {
853
+ // Face replacement cancels this task even if the DOM target survived.
854
+ if (token === state.pageToken) { state.paging = false; setStatus(L.incomplete, false); }
855
+ return;
856
+ }
857
+ var terminal = facePageState(face);
858
+ if (result.reason === "done" && (!terminal || terminal.reason || terminal.hasMore !== false || pendingLoads.has(face))) result.reason = "stalled";
648
859
  state.paging = false;
649
860
  state.pages = result.pages;
650
861
  if (result.reason === "capped") setStatus(L.capped.replace("{n}", String(result.pages)), false);
651
862
  else if (result.reason !== "done") setStatus(L.incomplete, false);
652
- else setStatus(result.pages > 0 ? L.allLoaded : "", false);
863
+ else setStatus(L.available, false);
653
864
  runSearch(true);
654
865
  });
655
866
  }
@@ -747,6 +958,7 @@ window.__ModuleLoader__.load({
747
958
 
748
959
  var count = document.createElement("span");
749
960
  count.className = "count";
961
+ count.setAttribute("role", "status");
750
962
  count.setAttribute("aria-live", "polite");
751
963
 
752
964
  var status = document.createElement("span");
@@ -783,9 +995,9 @@ window.__ModuleLoader__.load({
783
995
  var selection = "";
784
996
  try { selection = String(window.getSelection()).trim(); } catch (error) {}
785
997
  if (state.target && selection && selection.length <= 200 && selection !== state.query) {
786
- state.input.value = selection;
787
998
  state.query = selection;
788
999
  }
1000
+ state.input.value = state.query;
789
1001
  state.input.focus();
790
1002
  state.input.select();
791
1003
  runSearch(false);
@@ -798,10 +1010,9 @@ window.__ModuleLoader__.load({
798
1010
  cancelPageIn();
799
1011
  stopWatchingSession();
800
1012
  if (state.bar) state.bar.style.display = "none";
801
- state.query = "";
802
- if (state.input) state.input.value = "";
803
1013
  state.ranges = [];
804
1014
  state.index = -1;
1015
+ state.selection = null;
805
1016
  setStatus("", false);
806
1017
  paint(); // clears registered highlights
807
1018
  updateCount();
@@ -814,6 +1025,8 @@ window.__ModuleLoader__.load({
814
1025
 
815
1026
  function apply(ctx) {
816
1027
  var react = require("react");
1028
+ var ui = require("@deepseek-ai/dsh-client-ui-primitives");
1029
+ var SearchIcon = ui.IconSearchOutlineRegular || ui.IconSearchOutline16;
817
1030
  state.ctx = ctx;
818
1031
  state.adapter = createHostAdapter(ctx, function () {
819
1032
  syncTarget(null, true);
@@ -822,13 +1035,18 @@ window.__ModuleLoader__.load({
822
1035
  var anchor = react.useRef(null);
823
1036
  react.useEffect(function () {
824
1037
  if (!anchor.current || !props.sessionId) return;
825
- return state.adapter.mount(anchor.current, props.sessionId);
1038
+ // Older supported Button exports do not forward refs. Always
1039
+ // register the real button rather than the sizing-neutral wrapper.
1040
+ var button = anchor.current.querySelector("button[data-find-all-session]");
1041
+ if (button) return state.adapter.mount(button, props.sessionId);
826
1042
  }, [props.sessionId]);
827
- return react.createElement("button", {
828
- ref: anchor, type: "button", title: L.find, "aria-label": L.find,
1043
+ return react.createElement("span", { ref: anchor, style: { display: "inline-flex", flex: "none" } },
1044
+ react.createElement(ui.Button, {
1045
+ type: "button", size: "sm", variant: "ghost", title: L.find, "aria-label": L.find,
1046
+ style: { flex: "none", width: 28, padding: 0, color: "var(--dsw-alias-label-secondary)" },
829
1047
  "data-find-all-session": props.sessionId,
830
1048
  onClick: function (event) { open(event.currentTarget); }
831
- }, "⌕");
1049
+ }, react.createElement(SearchIcon, { size: 15 })));
832
1050
  }
833
1051
  ctx.slots.inject("conversation.session.header.utilities", function () {
834
1052
  return ctx.slots.register({ name: "conversation.session.header.utilities", id: "dsh-find-all" }, SessionFind);
@@ -850,6 +1068,7 @@ window.__ModuleLoader__.load({
850
1068
  window.removeEventListener("focusin", focus, true);
851
1069
  window.removeEventListener("pointerdown", focus, true);
852
1070
  close();
1071
+ state.query = "";
853
1072
  if (state.bar) state.bar.remove();
854
1073
  var style = document.querySelector('style[data-plugin-css="dsh-find-all/bar.css"]');
855
1074
  if (style) style.remove();
@@ -865,6 +1084,8 @@ window.__ModuleLoader__.load({
865
1084
  exports.resolveScope = resolveScope;
866
1085
  exports.createHostAdapter = createHostAdapter;
867
1086
  exports.findIndices = findIndices; // for tests
1087
+ exports.findMatches = findMatches; // for tests
1088
+ exports.collectRanges = collectRanges; // for tests
868
1089
  exports.createHandlers = createHandlers; // for tests
869
1090
  exports.runPageIn = runPageIn; // for tests
870
1091
  return module.exports;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ryuu-64/dsh-find-all",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Ctrl+F find bar for the DSH desktop app: it searches the whole conversation, not just the messages already on screen.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -38,7 +38,8 @@
38
38
  "peerDependencies": {
39
39
  "@deepseek-ai/dsh-api-session-controller": "0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.6-alpha.1 || 0.1.6-alpha.2 || 0.1.7-alpha.1 || 0.1.7-alpha.2 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2",
40
40
  "@deepseek-ai/dsh-client-ui-chat": "0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.6-alpha.1 || 0.1.6-alpha.2 || 0.1.7-alpha.1 || 0.1.7-alpha.2 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2",
41
- "@deepseek-ai/dsh-client-ui-conversation": "0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.6-alpha.1 || 0.1.6-alpha.2 || 0.1.7-alpha.1 || 0.1.7-alpha.2 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2"
41
+ "@deepseek-ai/dsh-client-ui-conversation": "0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.6-alpha.1 || 0.1.6-alpha.2 || 0.1.7-alpha.1 || 0.1.7-alpha.2 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2",
42
+ "@deepseek-ai/dsh-client-ui-primitives": "0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.6-alpha.1 || 0.1.6-alpha.2 || 0.1.7-alpha.1 || 0.1.7-alpha.2 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2"
42
43
  },
43
44
  "scripts": {
44
45
  "test": "node --test \"test/**/*.test.mjs\"",