@ryuu-64/dsh-find-all 0.1.4 → 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/lib/client.js CHANGED
@@ -21,19 +21,19 @@
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.
35
- // * When the API is unavailable, the bar degrades to the legacy
36
- // window.find() calls (no count, still functional).
35
+ // * Without CSS Highlights, navigation still scrolls only scoped ranges.
36
+ // Browser-wide window.find() is deliberately never used.
37
37
  //
38
38
  // Keyboard:
39
39
  // Cmd/Ctrl+F open (prefilled with the current selection)
@@ -43,7 +43,7 @@
43
43
 
44
44
  window.__ModuleLoader__.load({
45
45
  id: "@ryuu-64/dsh-find-all",
46
- factory: () => {
46
+ factory: (require) => {
47
47
  var module = { exports: {} };
48
48
  var exports = module.exports;
49
49
 
@@ -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,11 +82,14 @@ 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
- noService: "取不到会话接口,只能搜当前页面"
88
+ noService: "会话接口不可用,仅保留已显示的部分结果",
89
+ noTarget: "请先选择可见的会话,再打开查找",
90
+ noRoot: "当前会话的搜索范围不可用",
91
+ incomplete: "历史尚未搜完,保留部分结果",
92
+ find: "查找会话"
80
93
  } : {
81
94
  placeholder: "Find in conversation…",
82
95
  prev: "Previous match",
@@ -88,31 +101,42 @@ window.__ModuleLoader__.load({
88
101
  scopeToPage: "Scope: the whole conversation. Click to search only the rendered page",
89
102
  scopeToWhole: "Scope: the rendered page only. Click to search the whole conversation",
90
103
  loading: "Loading earlier history… {n} pages",
91
- allLoaded: "Whole conversation loaded",
92
- capped: "Page cap reached ({n}), stopped loading",
93
- 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})",
94
106
  stopTip: "Click to stop loading",
95
- noService: "Session service unavailable — rendered page only"
107
+ noService: "Session service unavailable; rendered results only",
108
+ noTarget: "Select a visible conversation before opening find",
109
+ noRoot: "Search scope unavailable for this conversation",
110
+ incomplete: "History search incomplete; partial results retained",
111
+ find: "Find in conversation"
96
112
  };
97
113
 
98
114
  //#region pure helpers (exported for tests)
99
115
 
100
- // All non-overlapping case-insensitive start indices of `query` in
101
- // `text`; empty array for an empty query or no hits.
102
- 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) {
103
120
  if (!query) return [];
104
- var haystack = String(text).toLowerCase();
105
- 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");
106
128
  var out = [];
107
- var i = haystack.indexOf(needle);
108
- while (i !== -1) {
109
- out.push(i);
110
- if (out.length >= MAX_MATCHES) break;
111
- 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 });
112
132
  }
113
133
  return out;
114
134
  }
115
135
 
136
+ function findIndices(text, query) {
137
+ return findMatches(text, query).map(function (match) { return match.start; });
138
+ }
139
+
116
140
  // Keyboard routing, isolated so tests can drive it with spies.
117
141
  function createHandlers(ops) {
118
142
  return function onKeydown(event) {
@@ -121,7 +145,7 @@ window.__ModuleLoader__.load({
121
145
  if (meta && (key === "f" || key === "F")) {
122
146
  event.preventDefault();
123
147
  event.stopPropagation();
124
- ops.open();
148
+ ops.open(event.target);
125
149
  return;
126
150
  }
127
151
  if (!ops.isOpen()) return;
@@ -143,41 +167,44 @@ window.__ModuleLoader__.load({
143
167
  };
144
168
  }
145
169
 
146
- /**
147
- * Page older history in until the conversation stops growing.
148
- *
149
- * Every dependency is injected, so the loop is testable without a DOM or
150
- * a host: the caller supplies how to read `hasMore`, how to load a page,
151
- * how to measure the rendered history, and how to wait for that measure
152
- * to change.
153
- *
154
- * @param opts - { maxPages, isCancelled, hasMore, loadOlder, signature,
155
- * waitChange, onProgress }.
156
- * `hasMore` returns true/false/null (null = the host cannot say, so the
157
- * pager keeps going until a page changes nothing).
158
- * @returns { pages, reason } where reason is one of done | cancelled |
159
- * 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.
160
173
  */
161
174
  async function runPageIn(opts) {
162
175
  var pages = 0;
163
- var reason = "done";
176
+ function result(reason) { return { pages: pages, reason: reason }; }
164
177
  for (;;) {
165
- if (opts.isCancelled()) { reason = "cancelled"; break; }
166
- if (opts.hasMore() === false) { reason = "done"; break; }
167
- 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");
168
184
  var before = opts.signature();
169
- try {
170
- await opts.loadOlder();
171
- } catch (error) {
172
- reason = "error";
173
- 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);
174
203
  }
175
- if (opts.isCancelled()) { reason = "cancelled"; break; }
176
- if (!(await opts.waitChange(before))) { reason = "stalled"; break; }
177
- pages += 1;
178
- 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");
179
207
  }
180
- return { pages: pages, reason: reason };
181
208
  }
182
209
  //#endregion
183
210
 
@@ -213,8 +240,8 @@ window.__ModuleLoader__.load({
213
240
  return typeof CSS !== "undefined" && CSS.highlights && typeof Highlight === "function";
214
241
  }
215
242
 
216
- // Collect text nodes under `root`, descending into shadow roots and
217
- // 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.
218
245
  function collectTextNodes(root, into) {
219
246
  if (!root) return into;
220
247
  var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
@@ -239,17 +266,62 @@ window.__ModuleLoader__.load({
239
266
  return into;
240
267
  }
241
268
 
242
- function collectRanges(query) {
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
+
307
+ function collectRanges(query, root) {
243
308
  var ranges = [];
244
- if (!query || typeof document === "undefined" || document.body === null) return ranges;
245
- var nodes = collectTextNodes(document.body, []);
246
- for (var i = 0; i < nodes.length && ranges.length < MAX_MATCHES; i++) {
247
- var node = nodes[i];
248
- var indices = findIndices(node.data, query);
249
- for (var j = 0; j < indices.length && ranges.length < MAX_MATCHES; j++) {
309
+ if (!query || !root || typeof document === "undefined") return ranges;
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];
250
322
  var range = document.createRange();
251
- range.setStart(node, indices[j]);
252
- 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);
253
325
  ranges.push(range);
254
326
  }
255
327
  }
@@ -270,56 +342,232 @@ window.__ModuleLoader__.load({
270
342
  }
271
343
  }
272
344
 
273
- /** Cheap proxy for "how much history is rendered" — used to notice a page landing. */
274
- function domSignature() {
275
- if (typeof document === "undefined" || document.body === null) return 0;
276
- return collectTextNodes(document.body, []).length;
345
+ //#region host adapter
346
+ // Official session-scoped header utility props supply identity. DOM selectors
347
+ // are intentionally isolated here: see docs/compatibility.md for tag sources.
348
+ // No global current-session state and no page-wide search fallback.
349
+ function visible(element) {
350
+ if (!element || !element.isConnected || !element.getClientRects().length) return false;
351
+ for (var node = element; node; node = node.parentElement) {
352
+ if (node.hidden || node.getAttribute("aria-hidden") === "true") return false;
353
+ var css = window.getComputedStyle(node);
354
+ if (css.display === "none" || css.visibility === "hidden" || css.visibility === "collapse" || css.opacity === "0") return false;
355
+ }
356
+ return true;
357
+ }
358
+
359
+ function conversationPanel(element) {
360
+ // The host composer has its own unrelated data-phase. A conversation
361
+ // root additionally owns the scrollport (official startup fixture).
362
+ for (var node = element; node && node !== document.body; node = node.parentElement) {
363
+ if (!node.hasAttribute || !node.hasAttribute("data-phase")) continue;
364
+ var phase = node.getAttribute("data-phase");
365
+ // Preserve even a broken declared panel boundary; never walk through
366
+ // it to a different ancestor conversation because its scroll is absent.
367
+ if (phase === "active" || phase === "hero" || phase === "settling" || node.querySelector('[data-conversation-scroll]')) return node;
368
+ }
369
+ return null;
277
370
  }
278
371
 
279
- /** Resolve once the rendered history differs from `before`, or the page times out. */
280
- function waitForChange(before) {
372
+ function resolveScope(anchor, sessionId) {
373
+ if (!anchor || typeof sessionId !== "string" || !sessionId || !visible(anchor)) return null;
374
+ var panel = conversationPanel(anchor);
375
+ if (!panel || panel.getAttribute("data-phase") !== "active" || panel === document.documentElement) return null;
376
+ var anchors = panel.querySelectorAll('[data-find-all-session]');
377
+ if (anchors.length !== 1 || anchors[0] !== anchor) return null;
378
+ var scrolls = panel.querySelectorAll('[data-conversation-scroll]');
379
+ if (scrolls.length !== 1) return null;
380
+ var content = scrolls[0].closest('[data-conversation-content]');
381
+ // 0.1.6-alpha.2 has the content wrapper but no identity attribute.
382
+ // Identity still comes from the unique session-scoped header anchor.
383
+ if (content && content.hasAttribute('data-conversation-session') && content.getAttribute('data-conversation-session') !== sessionId) return null;
384
+ var flows = scrolls[0].querySelectorAll('[data-chat-flow]');
385
+ if (flows.length !== 1 || !visible(flows[0])) return null;
386
+ return { panel: panel, root: flows[0] };
387
+ }
388
+
389
+ function createHostAdapter(ctx, changed) {
390
+ var entries = new Map();
391
+ var scopeUnavailable = false;
392
+ // Only a never-selected adapter may discover an initial neutral context.
393
+ // Explicit rejection or loss of a selection must never select a substitute.
394
+ var selectionState = "pristine";
395
+ function candidates() {
396
+ var out = [];
397
+ entries.forEach(function (id, anchor) {
398
+ // Keep unsupported/invisible instances in the identity roster. Dropping
399
+ // them before routing could silently choose a different valid session.
400
+ out.push({ id: id, anchor: anchor, panel: conversationPanel(anchor), scope: resolveScope(anchor, id) });
401
+ });
402
+ return out;
403
+ }
404
+ function choose(entry) {
405
+ if (!entry) return null;
406
+ if (!entry.scope) { selectionState = "rejected"; scopeUnavailable = true; return null; }
407
+ selectionState = "selected";
408
+ return { id: entry.id, anchor: entry.anchor, panel: entry.scope.panel, root: entry.scope.root };
409
+ }
410
+
411
+ function visibleEntry(entry) {
412
+ var container = entry.panel || entry.anchor.closest('[data-phase], [data-conversation-content]');
413
+ return visible(entry.anchor) || visible(container);
414
+ }
415
+
416
+ function discoverInitial(all) {
417
+ var shown = all.filter(visibleEntry);
418
+ var nodes = Array.prototype.filter.call(document.querySelectorAll('[data-phase], [data-conversation-content], [data-conversation-scroll], [data-chat-flow]'), visible);
419
+ scopeUnavailable = false;
420
+ if (shown.length === 0) {
421
+ // An empty resident Hero is not a session choice. A known active
422
+ // or identified but unregistered view is unavailable, not pristine.
423
+ if (nodes.some(function (node) { return node.getAttribute('data-phase') === 'active' || node.getAttribute('data-content-phase') === 'active' || node.hasAttribute('data-conversation-session') || node.hasAttribute('data-chat-flow'); })) {
424
+ selectionState = "rejected"; scopeUnavailable = true;
425
+ }
426
+ return null;
427
+ }
428
+ if (shown.length !== 1) { selectionState = "rejected"; return null; }
429
+ var entry = shown[0], scope = entry.scope;
430
+ if (!scope) return choose(entry);
431
+ var content = scope.root.closest('[data-conversation-content]');
432
+ var scroll = scope.root.closest('[data-conversation-scroll]');
433
+ // One main panel and its own content/scroll/flow count as one view.
434
+ // Any other visible known container, including a headerless embedded
435
+ // sidebar, makes neutral discovery ambiguous even if unsupported.
436
+ var other = nodes.some(function (node) {
437
+ if (node.hasAttribute('data-conversation-content') && node !== content) return true;
438
+ if (node.hasAttribute('data-conversation-scroll') && node !== scroll) return true;
439
+ if (node.hasAttribute('data-chat-flow') && node !== scope.root) return true;
440
+ return node.hasAttribute('data-phase') && conversationPanel(node) === node && node !== scope.panel;
441
+ });
442
+ if (other) { selectionState = "rejected"; return null; }
443
+ return choose(entry);
444
+ }
445
+
446
+ function refresh(entry) {
447
+ if (!entry) return null;
448
+ // Passive checks and repeated shortcuts may only retain this exact
449
+ // registered instance and identity. They never discover a substitute.
450
+ if (entries.get(entry.anchor) !== entry.id) { selectionState = "rejected"; scopeUnavailable = true; return null; }
451
+ scopeUnavailable = false;
452
+ return choose({ id: entry.id, anchor: entry.anchor, scope: resolveScope(entry.anchor, entry.id) });
453
+ }
454
+
455
+ return {
456
+ mount: function (anchor, id) {
457
+ entries.set(anchor, id);
458
+ changed();
459
+ return function () { entries.delete(anchor); changed(); };
460
+ },
461
+ refresh: refresh,
462
+ select: function (target, previous, retainNeutral) {
463
+ var all = candidates();
464
+ if (!target || !target.nodeType) target = document.activeElement;
465
+ if (target && state.bar && state.bar.contains(target)) return refresh(previous);
466
+ if (retainNeutral && (target === document.body || target === document.documentElement)) {
467
+ if (previous || selectionState !== "pristine") return refresh(previous);
468
+ return discoverInitial(all);
469
+ }
470
+ scopeUnavailable = false;
471
+ var direct = all.filter(function (entry) { return target && entry.anchor.contains(target); });
472
+ if (direct.length === 1) return choose(direct[0]);
473
+ var content = target && target.closest ? target.closest('[data-conversation-content]') : null;
474
+ var panel = conversationPanel(target) || content;
475
+ var hit = all.filter(function (entry) { return panel && entry.panel === panel; });
476
+ if (hit.length === 1) {
477
+ if (content && hit[0].scope && hit[0].scope.root.closest('[data-conversation-content]') !== content) {
478
+ selectionState = "rejected"; scopeUnavailable = true; return null;
479
+ }
480
+ return choose(hit[0]);
481
+ }
482
+ if (panel) {
483
+ // Before any session is selected, focus in the resident unbound
484
+ // Hero/settling composer is not a rejected conversation choice.
485
+ var phase = panel.getAttribute('data-phase') || panel.getAttribute('data-content-phase');
486
+ var unbound = !panel.hasAttribute('data-conversation-session') && !panel.querySelector('[data-conversation-session]');
487
+ if (selectionState === "pristine" && !previous && unbound && !all.some(visibleEntry) && (phase === "hero" || phase === "settling")) return null;
488
+ selectionState = "rejected"; scopeUnavailable = true; return null;
489
+ }
490
+ if (previous) selectionState = "rejected";
491
+ return null;
492
+ },
493
+ scopeUnavailable: function () { return scopeUnavailable; },
494
+ valid: function (entry) {
495
+ if (!entry || entries.get(entry.anchor) !== entry.id) return false;
496
+ var scope = resolveScope(entry.anchor, entry.id);
497
+ return !!scope && scope.root === entry.root;
498
+ },
499
+ face: function (entry) {
500
+ try {
501
+ if (!this.valid(entry) || !ctx.sessions || typeof ctx.sessions.binding !== "function") return null;
502
+ var binding = ctx.sessions.binding(entry.id);
503
+ var face = binding && binding.session;
504
+ return face && typeof face.loadOlder === "function" ? face : null;
505
+ } catch (error) { return null; }
506
+ },
507
+ clear: function () { entries.clear(); }
508
+ };
509
+ }
510
+
511
+ function domSignature(root) {
512
+ return root ? collectTextNodes(root, []).length : 0;
513
+ }
514
+
515
+ function waitForPage(check, isCancelled) {
281
516
  return new Promise(function (resolve) {
282
517
  var started = Date.now();
518
+ function finish(value) {
519
+ clearTimeout(state.settleTimer);
520
+ state.settleTimer = 0;
521
+ state.settleCancel = null;
522
+ resolve(value);
523
+ }
524
+ state.settleCancel = function () { finish({ reason: "cancelled" }); };
283
525
  (function poll() {
284
- if (domSignature() !== before) { resolve(true); return; }
285
- if (Date.now() - started >= PAGE_SETTLE_MS) { resolve(false); return; }
286
- setTimeout(poll, PAGE_POLL_MS);
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; }
530
+ state.settleTimer = setTimeout(poll, PAGE_POLL_MS);
287
531
  })();
288
532
  });
289
533
  }
290
534
 
291
- //#region session bridge
292
- //
293
- // The `sessions` client service is provided by
294
- // @deepseek-ai/dsh-api-session-controller; the per-session face it binds
295
- // exposes loadOlder() plus a snapshot carrying hasMore/loadingOlder.
296
- // Everything here is defensive: a host that changes shape must degrade to
297
- // page-only search, never break the bar.
298
-
299
- function resolveFace(ctx) {
535
+ function facePageState(face) {
300
536
  try {
301
- var sessions = ctx && ctx.sessions;
302
- if (!sessions || !sessions.list || typeof sessions.list.getSnapshot !== "function") return null;
303
- var id = sessions.list.getSnapshot().current;
304
- if (!id) return null;
305
- if (typeof sessions.binding !== "function") return null;
306
- var binding = sessions.binding(id);
307
- var face = binding && binding.session ? binding.session : null;
308
- if (!face || typeof face.loadOlder !== "function") return null;
309
- return { id: id, face: face };
310
- } catch (error) {
311
- return null;
312
- }
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" }; }
313
546
  }
314
547
 
315
- function faceHasMore(face) {
316
- try {
317
- if (typeof face.getSnapshot !== "function") return null;
318
- var snapshot = face.getSnapshot();
319
- return snapshot && typeof snapshot.hasMore === "boolean" ? snapshot.hasMore : null;
320
- } catch (error) {
321
- return null;
322
- }
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);
323
571
  }
324
572
  //#endregion
325
573
 
@@ -327,6 +575,10 @@ window.__ModuleLoader__.load({
327
575
 
328
576
  var state = {
329
577
  ctx: null,
578
+ adapter: null,
579
+ target: null,
580
+ settleTimer: 0,
581
+ settleCancel: null,
330
582
  bar: null,
331
583
  input: null,
332
584
  count: null,
@@ -335,6 +587,7 @@ window.__ModuleLoader__.load({
335
587
  query: "",
336
588
  ranges: [],
337
589
  index: -1,
590
+ selection: null,
338
591
  capped: false,
339
592
  observer: null,
340
593
  debounce: 0,
@@ -370,6 +623,7 @@ window.__ModuleLoader__.load({
370
623
  function setStatus(text, busy) {
371
624
  if (!state.status) return;
372
625
  state.status.textContent = text || "";
626
+ state.status.title = busy ? L.stopTip : (text || "");
373
627
  if (busy) state.status.setAttribute("data-busy", "1");
374
628
  else state.status.removeAttribute("data-busy");
375
629
  updateCount();
@@ -384,32 +638,122 @@ window.__ModuleLoader__.load({
384
638
  else state.scopeBtn.removeAttribute("data-whole");
385
639
  }
386
640
 
641
+ function unavailableStatus() {
642
+ return state.target || (state.adapter && state.adapter.scopeUnavailable()) ? L.noRoot : L.noTarget;
643
+ }
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
+
387
733
  function runSearch(keepIndex) {
388
- state.ranges = collectRanges(state.query);
734
+ var valid = state.adapter && state.adapter.valid(state.target);
735
+ state.ranges = valid ? collectRanges(state.query, state.target.root) : [];
736
+ if (!valid) setStatus(unavailableStatus(), false);
389
737
  state.capped = state.ranges.length >= MAX_MATCHES;
390
- 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();
391
743
  paint();
392
744
  updateCount();
393
- if (state.index >= 0 && !highlightsSupported()) legacyFind(state.query, false);
745
+
394
746
  }
395
747
 
396
748
  function goTo(delta) {
749
+ if (!state.adapter || !state.adapter.valid(state.target)) { runSearch(false); return; }
397
750
  var n = state.ranges.length;
398
751
  if (n === 0) return;
399
- if (highlightsSupported()) {
400
- state.index = ((state.index + delta) % n + n) % n;
401
- paint();
402
- updateCount();
403
- scrollToRange(state.ranges[state.index]);
404
- } else {
405
- legacyFind(state.query, delta < 0);
406
- }
407
- }
408
-
409
- function legacyFind(query, backwards) {
410
- if (typeof window.find === "function") {
411
- try { window.find(query, false, backwards, true); } catch (error) {}
412
- }
752
+ state.index = ((state.index + delta) % n + n) % n;
753
+ rememberSelection();
754
+ paint();
755
+ updateCount();
756
+ scrollToRange(state.ranges[state.index]);
413
757
  }
414
758
 
415
759
  function scheduleRescan() {
@@ -418,14 +762,14 @@ window.__ModuleLoader__.load({
418
762
  if (state.paging) return;
419
763
  clearTimeout(state.debounce);
420
764
  state.debounce = setTimeout(function () {
421
- if (state.bar) runSearch(true);
765
+ if (isOpen()) runSearch(true);
422
766
  }, 250);
423
767
  }
424
768
 
425
769
  function startObserver() {
426
- if (state.observer || typeof MutationObserver === "undefined") return;
770
+ if (state.observer || !state.target || typeof MutationObserver === "undefined") return;
427
771
  state.observer = new MutationObserver(scheduleRescan);
428
- state.observer.observe(document.documentElement, { childList: true, subtree: true, characterData: true });
772
+ state.observer.observe(state.target.root, { childList: true, subtree: true, characterData: true });
429
773
  }
430
774
 
431
775
  function stopObserver() {
@@ -438,6 +782,8 @@ window.__ModuleLoader__.load({
438
782
 
439
783
  function cancelPageIn() {
440
784
  state.pageToken += 1;
785
+ if (state.settleCancel) state.settleCancel();
786
+ clearTimeout(state.settleTimer);
441
787
  clearTimeout(state.pageTimer);
442
788
  state.pageTimer = 0;
443
789
  state.paging = false;
@@ -453,57 +799,91 @@ window.__ModuleLoader__.load({
453
799
 
454
800
  function startPageIn() {
455
801
  if (state.scope !== "whole" || !state.query || !isOpen()) return;
456
- var token = ++state.pageToken;
457
- var resolved = resolveFace(state.ctx);
458
- if (!resolved) {
459
- setStatus(L.noService, false);
802
+ cancelPageIn();
803
+ var token = state.pageToken;
804
+ var target = state.target;
805
+ if (!state.adapter || !state.adapter.valid(target)) {
806
+ setStatus(unavailableStatus(), false);
460
807
  return;
461
808
  }
462
- state.sessionId = resolved.id;
463
- if (faceHasMore(resolved.face) === false) {
464
- setStatus(L.allLoaded, false);
809
+ var face = state.adapter.face(target);
810
+ if (!face) {
811
+ setStatus(L.noService, false);
465
812
  return;
466
813
  }
814
+ state.sessionId = target.id;
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);
825
+ }
467
826
  state.paging = true;
468
827
  state.pages = 0;
469
828
  setStatus(L.loading.replace("{n}", "0"), true);
470
829
  runPageIn({
471
830
  maxPages: MAX_PAGES,
472
- isCancelled: function () { return token !== state.pageToken || !isOpen(); },
473
- hasMore: function () { return faceHasMore(resolved.face); },
474
- loadOlder: function () { return Promise.resolve(resolved.face.loadOlder()); },
475
- signature: domSignature,
476
- waitChange: waitForChange,
831
+ isCancelled: isCancelled,
832
+ waitReady: waitReady,
833
+ loadOlder: function () { return loadPage(face, isCancelled); },
834
+ signature: function () { return domSignature(target.root); },
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
+ },
477
841
  onProgress: function (pages) {
478
- if (token !== state.pageToken) return;
842
+ if (isCancelled()) return;
479
843
  state.pages = pages;
480
844
  setStatus(L.loading.replace("{n}", String(pages)), true);
481
845
  runSearch(true);
482
846
  }
483
847
  }).then(function (result) {
484
- 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";
485
855
  state.paging = false;
486
856
  state.pages = result.pages;
487
857
  if (result.reason === "capped") setStatus(L.capped.replace("{n}", String(result.pages)), false);
488
- else if (result.reason === "stalled" || result.reason === "error") setStatus(L.stalled, false);
489
- else setStatus(result.pages > 0 ? L.allLoaded : "", false);
858
+ else if (result.reason !== "done") setStatus(L.incomplete, false);
859
+ else setStatus(L.available, false);
490
860
  runSearch(true);
491
861
  });
492
862
  }
493
863
 
494
- /** Drop everything paged for the previous session when the user switches. */
864
+ function syncTarget(target, passive, retainNeutral) {
865
+ if (!state.adapter) return;
866
+ var selected = passive ? state.adapter.refresh(state.target) : state.adapter.select(target || document.activeElement, state.target, retainNeutral);
867
+ if (selected && state.target && selected.id === state.target.id && selected.anchor === state.target.anchor && selected.root === state.target.root) return;
868
+ if (!selected && !state.target) {
869
+ if (isOpen()) runSearch(false);
870
+ return;
871
+ }
872
+ cancelPageIn();
873
+ stopObserver();
874
+ state.target = selected;
875
+ state.sessionId = selected ? selected.id : null;
876
+ if (!isOpen()) return;
877
+ setStatus(selected ? "" : unavailableStatus(), false);
878
+ runSearch(false);
879
+ startObserver();
880
+ if (selected && state.scope === "whole" && state.query) schedulePageIn();
881
+ }
882
+
495
883
  function watchSession() {
496
884
  clearInterval(state.sessionTimer);
497
885
  state.sessionTimer = setInterval(function () {
498
- if (!isOpen()) return;
499
- var resolved = resolveFace(state.ctx);
500
- var id = resolved ? resolved.id : null;
501
- if (id === state.sessionId) return;
502
- state.sessionId = id;
503
- cancelPageIn();
504
- setStatus("", false);
505
- runSearch(false);
506
- if (state.scope === "whole" && state.query) schedulePageIn();
886
+ if (isOpen()) syncTarget(null, true);
507
887
  }, SESSION_POLL_MS);
508
888
  }
509
889
 
@@ -582,7 +962,7 @@ window.__ModuleLoader__.load({
582
962
  status.addEventListener("click", function () {
583
963
  if (!state.paging) return;
584
964
  cancelPageIn();
585
- setStatus(L.stalled, false);
965
+ setStatus(L.incomplete, false);
586
966
  });
587
967
 
588
968
  bar.appendChild(input);
@@ -602,16 +982,17 @@ window.__ModuleLoader__.load({
602
982
  document.body.appendChild(bar);
603
983
  }
604
984
 
605
- function open() {
985
+ function open(target) {
606
986
  if (typeof document === "undefined" || document.body === null) return;
987
+ syncTarget(target || document.activeElement, false, true);
607
988
  if (!state.bar) buildBar();
608
989
  state.bar.style.display = "flex";
609
990
  var selection = "";
610
991
  try { selection = String(window.getSelection()).trim(); } catch (error) {}
611
- if (selection && selection.length <= 200 && selection !== state.query) {
612
- state.input.value = selection;
992
+ if (state.target && selection && selection.length <= 200 && selection !== state.query) {
613
993
  state.query = selection;
614
994
  }
995
+ state.input.value = state.query;
615
996
  state.input.focus();
616
997
  state.input.select();
617
998
  runSearch(false);
@@ -624,9 +1005,9 @@ window.__ModuleLoader__.load({
624
1005
  cancelPageIn();
625
1006
  stopWatchingSession();
626
1007
  if (state.bar) state.bar.style.display = "none";
627
- state.query = "";
628
1008
  state.ranges = [];
629
1009
  state.index = -1;
1010
+ state.selection = null;
630
1011
  setStatus("", false);
631
1012
  paint(); // clears registered highlights
632
1013
  updateCount();
@@ -638,21 +1019,61 @@ window.__ModuleLoader__.load({
638
1019
  }
639
1020
 
640
1021
  function apply(ctx) {
1022
+ var react = require("react");
641
1023
  state.ctx = ctx;
642
- injectCss();
643
- var ops = {
644
- open: open,
645
- close: close,
646
- goTo: goTo,
647
- isOpen: isOpen,
648
- isBarInput: function (target) { return target === state.input; }
649
- };
650
- window.addEventListener("keydown", createHandlers(ops), true);
1024
+ state.adapter = createHostAdapter(ctx, function () {
1025
+ syncTarget(null, true);
1026
+ });
1027
+ function SessionFind(props) {
1028
+ var anchor = react.useRef(null);
1029
+ react.useEffect(function () {
1030
+ if (!anchor.current || !props.sessionId) return;
1031
+ return state.adapter.mount(anchor.current, props.sessionId);
1032
+ }, [props.sessionId]);
1033
+ return react.createElement("button", {
1034
+ ref: anchor, type: "button", title: L.find, "aria-label": L.find,
1035
+ "data-find-all-session": props.sessionId,
1036
+ onClick: function (event) { open(event.currentTarget); }
1037
+ }, "⌕");
1038
+ }
1039
+ ctx.slots.inject("conversation.session.header.utilities", function () {
1040
+ return ctx.slots.register({ name: "conversation.session.header.utilities", id: "dsh-find-all" }, SessionFind);
1041
+ });
1042
+ ctx.effect(function () {
1043
+ injectCss();
1044
+ var handler = createHandlers({
1045
+ open: open, close: close, goTo: goTo, isOpen: isOpen,
1046
+ isBarInput: function (target) { return target === state.input; }
1047
+ });
1048
+ function focus(event) {
1049
+ if (!state.bar || !state.bar.contains(event.target)) syncTarget(event.target);
1050
+ }
1051
+ window.addEventListener("keydown", handler, true);
1052
+ window.addEventListener("focusin", focus, true);
1053
+ window.addEventListener("pointerdown", focus, true);
1054
+ return function () {
1055
+ window.removeEventListener("keydown", handler, true);
1056
+ window.removeEventListener("focusin", focus, true);
1057
+ window.removeEventListener("pointerdown", focus, true);
1058
+ close();
1059
+ state.query = "";
1060
+ if (state.bar) state.bar.remove();
1061
+ var style = document.querySelector('style[data-plugin-css="dsh-find-all/bar.css"]');
1062
+ if (style) style.remove();
1063
+ state.bar = state.input = state.count = state.status = state.scopeBtn = null;
1064
+ state.adapter.clear();
1065
+ state.target = state.ctx = null;
1066
+ };
1067
+ }, "find-all: browser lifecycle");
651
1068
  }
652
1069
 
653
1070
  exports.apply = apply;
654
- exports.inject = ["sessions"];
1071
+ exports.inject = ["sessions", "slots"];
1072
+ exports.resolveScope = resolveScope;
1073
+ exports.createHostAdapter = createHostAdapter;
655
1074
  exports.findIndices = findIndices; // for tests
1075
+ exports.findMatches = findMatches; // for tests
1076
+ exports.collectRanges = collectRanges; // for tests
656
1077
  exports.createHandlers = createHandlers; // for tests
657
1078
  exports.runPageIn = runPageIn; // for tests
658
1079
  return module.exports;