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

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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.6
4
+
5
+ Fix text matching, history feedback and search state after the 0.1.5 compatibility release.
6
+
7
+ - 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.
8
+ - 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.
9
+ - 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.
10
+ - 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.
11
+ - 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.
12
+ - Add regression coverage for observer cleanup, conversation and embedded-panel isolation, cross-node text matching, history feedback, query retention and stable selection.
13
+
14
+ 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.
15
+
3
16
  ## 0.1.5
4
17
 
5
18
  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,18 @@ DSH 桌面窗口是 Electron 壳,本身没有浏览器那种查找栏。而会
8
8
 
9
9
  所以拿普通查找条搜一个较早的词,结果永远是 0,哪怕这段会话里确实有。
10
10
 
11
- 这个插件在你输入关键词之后,通过会话服务把更早的历史一页页加载进来,每加载一页就重搜一次,直到整段会话都在页面里。这样命中计数和高亮覆盖的是**整个会话**。
11
+ 这个插件在你输入关键词之后,通过会话服务把更早的历史一页页加载进来,每加载一页就重搜一次。宿主不再提供更早页时,最后重搜并提示“已搜索当前可用历史”。宿主公开接口不能证明历史完整性,插件不会把终止信号当作“全部搜完”。
12
12
 
13
13
  ## 功能
14
14
 
15
- - **⌘F / Ctrl+F** 唤起查找栏,自动带入当前选中的文字
15
+ - **⌘F / Ctrl+F** 唤起查找栏,优先带入有效的选中文字;没有新选词时恢复上次关键词。关键词仅保留在当前插件实例内存中,禁用、刷新或重启后清空
16
16
  - **范围切换**:`整段`(默认)↔ `本页`,点那个胶囊按钮切换
17
17
  - **边搜边加载**:整段模式下自动分页加载历史,高亮与计数随加载实时更新
18
18
  - **进度可见**:显示「正在加载更早的历史… 已 3 页」,**点一下这段文字即可停止加载**
19
19
  - **安全上限**:最多加载 400 页(约 2 万条消息);历史不再增长时会自行停止
20
20
  - **导航**:Enter / Shift+Enter 上下一个;⌘G(Ctrl+G)/ F3 下一个,加 Shift 反向;Esc 关闭查找栏并清除高亮
21
- - 切换会话时自动重置,并在新会话里重新开始搜索
21
+ - 切换会话时清除旧结果,并在新会话里重搜同一关键词
22
+ - 后台重扫首次找到结果时选中第一项;前插历史时优先保留可确认的当前命中,不自动滚动。原节点可对应,或同一正文块仍在且原文完全未变时,可在 Text/span 换树后恢复当前项;无法确认时把旧序号夹到新结果范围,换词或换会话则选第一项
22
23
 
23
24
  ## 安装
24
25
 
@@ -65,6 +66,8 @@ dsh plugin --profile desktop remove dsh-find-bar
65
66
  - 单次搜索最多记 5000 个命中
66
67
  - 折叠起来的工具调用和思考块,正文不在页面里,搜不到——先展开再搜
67
68
  - 整段模式要真的翻历史,所以很长的会话首次搜索需要等几秒
69
+ - 宿主尚未就绪、公开报错、无进展或到达上限时,保留已找到的结果并提示未完成;再次打开查找或输入关键词可手动重试。停止、关闭、换词或切换会话会取消插件任务,但不会中断宿主已经发出的请求
70
+ - “已搜索当前可用历史”不保证历史完整。宿主可能在正常末页和不连续页错误时都不再提供更早页;此时重新查找也不保证恢复缺失历史
68
71
 
69
72
  ## 卸载 / 回退
70
73
 
package/lib/client.js CHANGED
@@ -21,14 +21,14 @@
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,6 +59,16 @@ 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 ? {
@@ -72,9 +82,8 @@ window.__ModuleLoader__.load({
72
82
  scopeToPage: "搜索范围:整段会话。点击改为只搜当前页面",
73
83
  scopeToWhole: "搜索范围:仅当前页面。点击改为搜索整段会话",
74
84
  loading: "正在加载更早的历史… 已 {n} 页",
75
- allLoaded: "整段会话已就绪",
76
- capped: "已到加载上限({n} 页),停止加载",
77
- stalled: "没有更早的历史了",
85
+ available: "已搜索当前可用历史;宿主未提供更早页,无法确认历史完整性",
86
+ capped: "历史尚未搜完:已到加载上限({n} 页)",
78
87
  stopTip: "点击停止加载",
79
88
  noService: "会话接口不可用,仅保留已显示的部分结果",
80
89
  noTarget: "请先选择可见的会话,再打开查找",
@@ -92,9 +101,8 @@ window.__ModuleLoader__.load({
92
101
  scopeToPage: "Scope: the whole conversation. Click to search only the rendered page",
93
102
  scopeToWhole: "Scope: the rendered page only. Click to search the whole conversation",
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: page cap reached ({n})",
98
106
  stopTip: "Click to stop loading",
99
107
  noService: "Session service unavailable; rendered results only",
100
108
  noTarget: "Select a visible conversation before opening find",
@@ -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,
@@ -529,6 +623,7 @@ window.__ModuleLoader__.load({
529
623
  function setStatus(text, busy) {
530
624
  if (!state.status) return;
531
625
  state.status.textContent = text || "";
626
+ state.status.title = busy ? L.stopTip : (text || "");
532
627
  if (busy) state.status.setAttribute("data-busy", "1");
533
628
  else state.status.removeAttribute("data-busy");
534
629
  updateCount();
@@ -547,12 +642,104 @@ window.__ModuleLoader__.load({
547
642
  return state.target || (state.adapter && state.adapter.scopeUnavailable()) ? L.noRoot : L.noTarget;
548
643
  }
549
644
 
645
+ // The code content wrapper survives the host's plain-pre -> Shiki tree
646
+ // swap (0.2.0-rc.2 CodeBlock). Otherwise use the nearest block boundary.
647
+ function selectionBlock(range, root) {
648
+ var nearest = null;
649
+ for (var node = range.startContainer.parentNode; node; node = node.parentNode) {
650
+ if (node === root || node.nodeType === 11) return nearest || node;
651
+ if (node.hasAttribute && node.hasAttribute("data-code-block-content")) return node;
652
+ if (!nearest && BLOCK_TAGS[node.tagName]) nearest = node;
653
+ }
654
+ return nearest;
655
+ }
656
+
657
+ function positionInBlocks(range, blocks) {
658
+ for (var i = 0; i < blocks.length; i++) {
659
+ var start = null, end = null;
660
+ for (var j = 0; j < blocks[i].segments.length; j++) {
661
+ var segment = blocks[i].segments[j];
662
+ if (segment.node === range.startContainer) start = segment.start + range.startOffset;
663
+ if (segment.node === range.endContainer) end = segment.start + range.endOffset;
664
+ }
665
+ if (start !== null && end !== null) return { run: i, start: start, end: end };
666
+ }
667
+ return null;
668
+ }
669
+
670
+ function matchNodes(blocks, position) {
671
+ if (!position) return [];
672
+ return blocks[position.run].segments.filter(function (segment) {
673
+ return segment.end > position.start && segment.start < position.end;
674
+ }).map(function (segment) {
675
+ return {
676
+ node: segment.node, text: segment.node.data,
677
+ start: Math.max(position.start, segment.start) - segment.start,
678
+ end: Math.min(position.end, segment.end) - segment.start
679
+ };
680
+ });
681
+ }
682
+
683
+ function rememberSelection() {
684
+ var range = state.ranges[state.index];
685
+ state.selection = null;
686
+ if (!range || !state.target) return;
687
+ var block = selectionBlock(range, state.target.root);
688
+ var blocks = block ? collectTextBlocks(block) : [];
689
+ var position = positionInBlocks(range, blocks);
690
+ var nodes = matchNodes(blocks, position);
691
+ state.selection = {
692
+ target: state.target, query: state.query, root: state.target.root,
693
+ startNode: range.startContainer, startOffset: range.startOffset,
694
+ endNode: range.endContainer, endOffset: range.endOffset,
695
+ block: block, position: position, nodes: nodes,
696
+ texts: blocks.map(function (item) { return item.text; })
697
+ };
698
+ }
699
+
700
+ function retainedIndex(saved) {
701
+ if (!saved || saved.target !== state.target || saved.query !== state.query || saved.root !== state.target.root) return -1;
702
+ if (!saved.block || !saved.block.isConnected || !saved.position) return -1;
703
+ var blocks = collectTextBlocks(saved.block);
704
+ var i, range;
705
+ for (i = 0; i < state.ranges.length; i++) {
706
+ range = state.ranges[i];
707
+ if (range.startContainer !== saved.startNode || range.startOffset !== saved.startOffset ||
708
+ range.endContainer !== saved.endNode || range.endOffset !== saved.endOffset) continue;
709
+ // Compare the new match's actual ordered participants. Old nodes can
710
+ // remain connected with unchanged text after moving outside this hit.
711
+ var nodes = matchNodes(blocks, positionInBlocks(range, blocks));
712
+ if (nodes.length > 0 && nodes.length === saved.nodes.length && nodes.every(function (item, index) {
713
+ var previous = saved.nodes[index];
714
+ return item.node === previous.node && item.text === previous.text && item.start === previous.start && item.end === previous.end;
715
+ })) return i;
716
+ }
717
+ if (blocks.length !== saved.texts.length || blocks.some(function (item, index) { return item.text !== saved.texts[index]; })) return -1;
718
+ var segments = blocks[saved.position.run].segments;
719
+ var first = null, last = null;
720
+ for (i = 0; i < segments.length; i++) {
721
+ if (saved.position.start >= segments[i].start && saved.position.start < segments[i].end) first = segments[i];
722
+ if (saved.position.end > segments[i].start && saved.position.end <= segments[i].end) last = segments[i];
723
+ }
724
+ if (!first || !last) return -1;
725
+ for (i = 0; i < state.ranges.length; i++) {
726
+ range = state.ranges[i];
727
+ if (range.startContainer === first.node && range.startOffset === saved.position.start - first.start &&
728
+ range.endContainer === last.node && range.endOffset === saved.position.end - last.start) return i;
729
+ }
730
+ return -1;
731
+ }
732
+
550
733
  function runSearch(keepIndex) {
551
734
  var valid = state.adapter && state.adapter.valid(state.target);
552
735
  state.ranges = valid ? collectRanges(state.query, state.target.root) : [];
553
736
  if (!valid) setStatus(unavailableStatus(), false);
554
737
  state.capped = state.ranges.length >= MAX_MATCHES;
555
- if (!keepIndex || state.index >= state.ranges.length) state.index = state.ranges.length > 0 ? 0 : -1;
738
+ var retained = keepIndex && valid ? retainedIndex(state.selection) : -1;
739
+ if (!state.ranges.length) state.index = -1;
740
+ else if (!keepIndex) state.index = 0;
741
+ else state.index = retained >= 0 ? retained : Math.max(0, Math.min(state.index, state.ranges.length - 1));
742
+ rememberSelection();
556
743
  paint();
557
744
  updateCount();
558
745
 
@@ -563,6 +750,7 @@ window.__ModuleLoader__.load({
563
750
  var n = state.ranges.length;
564
751
  if (n === 0) return;
565
752
  state.index = ((state.index + delta) % n + n) % n;
753
+ rememberSelection();
566
754
  paint();
567
755
  updateCount();
568
756
  scrollToRange(state.ranges[state.index]);
@@ -611,7 +799,8 @@ window.__ModuleLoader__.load({
611
799
 
612
800
  function startPageIn() {
613
801
  if (state.scope !== "whole" || !state.query || !isOpen()) return;
614
- var token = ++state.pageToken;
802
+ cancelPageIn();
803
+ var token = state.pageToken;
615
804
  var target = state.target;
616
805
  if (!state.adapter || !state.adapter.valid(target)) {
617
806
  setStatus(unavailableStatus(), false);
@@ -623,33 +812,51 @@ window.__ModuleLoader__.load({
623
812
  return;
624
813
  }
625
814
  state.sessionId = target.id;
626
- if (faceHasMore(face) === false) {
627
- setStatus(L.allLoaded, false);
628
- return;
815
+ function isCancelled() {
816
+ return token !== state.pageToken || !isOpen() || state.target !== target || !state.adapter || state.adapter.face(target) !== face;
817
+ }
818
+ function waitReady(failFast) {
819
+ return waitForPage(function () {
820
+ var status = facePageState(face);
821
+ if (status && status.reason) return status;
822
+ if (pendingLoads.has(face) || !status) return failFast ? { reason: "stalled" } : null;
823
+ return status;
824
+ }, isCancelled);
629
825
  }
630
826
  state.paging = true;
631
827
  state.pages = 0;
632
828
  setStatus(L.loading.replace("{n}", "0"), true);
633
829
  runPageIn({
634
830
  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()); },
831
+ isCancelled: isCancelled,
832
+ waitReady: waitReady,
833
+ loadOlder: function () { return loadPage(face, isCancelled); },
638
834
  signature: function () { return domSignature(target.root); },
639
- waitChange: function (before) { return waitForChange(before, target.root, token); },
835
+ waitChange: function (before) {
836
+ return waitForPage(function () {
837
+ var status = facePageState(face);
838
+ return domSignature(target.root) !== before || !status || status.reason || status.hasMore === false ? {} : null;
839
+ }, isCancelled);
840
+ },
640
841
  onProgress: function (pages) {
641
- if (token !== state.pageToken) return;
842
+ if (isCancelled()) return;
642
843
  state.pages = pages;
643
844
  setStatus(L.loading.replace("{n}", String(pages)), true);
644
845
  runSearch(true);
645
846
  }
646
847
  }).then(function (result) {
647
- if (token !== state.pageToken) return;
848
+ if (isCancelled()) {
849
+ // Face replacement cancels this task even if the DOM target survived.
850
+ if (token === state.pageToken) { state.paging = false; setStatus(L.incomplete, false); }
851
+ return;
852
+ }
853
+ var terminal = facePageState(face);
854
+ if (result.reason === "done" && (!terminal || terminal.reason || terminal.hasMore !== false || pendingLoads.has(face))) result.reason = "stalled";
648
855
  state.paging = false;
649
856
  state.pages = result.pages;
650
857
  if (result.reason === "capped") setStatus(L.capped.replace("{n}", String(result.pages)), false);
651
858
  else if (result.reason !== "done") setStatus(L.incomplete, false);
652
- else setStatus(result.pages > 0 ? L.allLoaded : "", false);
859
+ else setStatus(L.available, false);
653
860
  runSearch(true);
654
861
  });
655
862
  }
@@ -783,9 +990,9 @@ window.__ModuleLoader__.load({
783
990
  var selection = "";
784
991
  try { selection = String(window.getSelection()).trim(); } catch (error) {}
785
992
  if (state.target && selection && selection.length <= 200 && selection !== state.query) {
786
- state.input.value = selection;
787
993
  state.query = selection;
788
994
  }
995
+ state.input.value = state.query;
789
996
  state.input.focus();
790
997
  state.input.select();
791
998
  runSearch(false);
@@ -798,10 +1005,9 @@ window.__ModuleLoader__.load({
798
1005
  cancelPageIn();
799
1006
  stopWatchingSession();
800
1007
  if (state.bar) state.bar.style.display = "none";
801
- state.query = "";
802
- if (state.input) state.input.value = "";
803
1008
  state.ranges = [];
804
1009
  state.index = -1;
1010
+ state.selection = null;
805
1011
  setStatus("", false);
806
1012
  paint(); // clears registered highlights
807
1013
  updateCount();
@@ -850,6 +1056,7 @@ window.__ModuleLoader__.load({
850
1056
  window.removeEventListener("focusin", focus, true);
851
1057
  window.removeEventListener("pointerdown", focus, true);
852
1058
  close();
1059
+ state.query = "";
853
1060
  if (state.bar) state.bar.remove();
854
1061
  var style = document.querySelector('style[data-plugin-css="dsh-find-all/bar.css"]');
855
1062
  if (style) style.remove();
@@ -865,6 +1072,8 @@ window.__ModuleLoader__.load({
865
1072
  exports.resolveScope = resolveScope;
866
1073
  exports.createHostAdapter = createHostAdapter;
867
1074
  exports.findIndices = findIndices; // for tests
1075
+ exports.findMatches = findMatches; // for tests
1076
+ exports.collectRanges = collectRanges; // for tests
868
1077
  exports.createHandlers = createHandlers; // for tests
869
1078
  exports.runPageIn = runPageIn; // for tests
870
1079
  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.6",
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",