@tobq/boardclip 2.1.110 → 2.1.111

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/index.html CHANGED
@@ -242,22 +242,54 @@ function renderGroupFilters() {
242
242
  // relaxCache: the same for the empty-result nudge (Core.search.bestRelaxation).
243
243
  const searchRefineCache = {};
244
244
  const relaxCache = {};
245
+ // A query with a /regex/ is searched on a worker (Core.createSearchWorker): the
246
+ // list keeps its rows, dimmed after a moment, until the answer arrives.
247
+ // shownQuery is the query the rows on screen were found with, so a row built
248
+ // meanwhile (a scroll) highlights against it, never against a pattern the
249
+ // worker has not run yet. regexFirst: where each regex match first sits in its
250
+ // body (the worker found it; the row never scans the body itself).
251
+ let shownQuery = '';
252
+ let regexFirst = null;
253
+ const searchWorker = Core.createSearchWorker({
254
+ onBusy: (busy) => { listEl.classList.toggle('searching', busy); listEl.setAttribute('aria-busy', busy ? 'true' : 'false'); updateCount(); },
255
+ onError: (message) => { const p = window.api.recordDiagnostics?.('renderer.search_worker_error', { message }); if (p && p.catch) p.catch(() => {}); },
256
+ });
245
257
  function applyFilter() {
246
258
  const startedAt = performance.now();
247
- const firstTerm = parsedQuery().terms.find(c => !c.regex && c.scope !== 'title');
259
+ if (Core.search.hasRegexTerm(parsedQuery())) {
260
+ const q = query;
261
+ const docs = searchDocs;
262
+ searchWorker.search({ docs, hays: searchTextLower, query: q, sortMode, now: Date.now() }, (r) => {
263
+ if (docs !== searchDocs) { scheduleRerenderList(); return; } // the history moved on: search again
264
+ regexFirst = new Map();
265
+ r.indexes.forEach((ri, k) => regexFirst.set(ri, r.first[k]));
266
+ showFiltered(q, r.indexes, r.relax);
267
+ updateCount();
268
+ recordRendererPerf('list.regex_search', startedAt, { rendered: renderedCount(), regex: true }, 400);
269
+ });
270
+ return;
271
+ }
272
+ searchWorker.cancel();
273
+ regexFirst = null;
274
+ showFiltered(query, Core.filterItemIndexes(items, { query, docs: searchDocs, searchTextLower, sortMode, cache: searchRefineCache }));
275
+ recordRendererPerf('list.apply_filter', startedAt, { rendered: renderedCount(), regex: searchBox.getRegex() }, 60);
276
+ }
277
+ // relax: the worker's empty-result nudge (a regex query), else worked out here.
278
+ function showFiltered(q, rows, relax) {
279
+ shownQuery = q;
280
+ const parsed = Core.search.parseQuery(q);
281
+ const firstTerm = parsed.terms.find(c => !c.regex && c.scope !== 'title');
248
282
  previewTerm = firstTerm ? String(firstTerm.value).trim().toLowerCase() || null : null;
249
- filtered = Core.filterItemIndexes(items, { query, docs: searchDocs, searchTextLower, sortMode, cache: searchRefineCache });
250
- const rows = filtered;
283
+ filtered = rows;
251
284
  const rowItems = items;
252
285
  clipList.update({
253
286
  ids: rows.map(ri => itemId(rowItems[ri])),
254
287
  tsAt: (i) => Number(rowItems[rows[i]] && rowItems[rows[i]].ts) || 0,
255
- mode: Core.search.rankMode(parsedQuery(), sortMode),
256
- queryKey: query,
288
+ mode: Core.search.rankMode(parsed, sortMode),
289
+ queryKey: q,
257
290
  // Only when the list IS empty: the one filter whose removal brings clips back.
258
- emptyHtml: () => Core.renderEmptyState({ total: items.length, query, groups, nudgeHtml: Core.renderRelaxNudge(Core.search.bestRelaxation(searchDocs, query, { searchTextLower, cache: relaxCache })) }),
291
+ emptyHtml: () => Core.renderEmptyState({ total: items.length, query: q, groups, nudgeHtml: Core.renderRelaxNudge(relax !== undefined ? relax : Core.search.bestRelaxation(searchDocs, q, { searchTextLower, cache: relaxCache })) }),
259
292
  });
260
- recordRendererPerf('list.apply_filter', startedAt, { rendered: renderedCount(), regex: searchBox.getRegex() }, 60);
261
293
  }
262
294
  function renderedCount() { const w = clipList.window(); return w.end - w.start; }
263
295
 
@@ -299,6 +331,7 @@ function scheduleRelativeTimeRefresh() {
299
331
  // haystack (no scan of the body itself); null lets the preview search instead.
300
332
  let previewTerm = null;
301
333
  function previewMatchIndex(ri) {
334
+ if (regexFirst) return regexFirst.has(ri) ? regexFirst.get(ri) : -1;
302
335
  if (!previewTerm || !searchDocs[ri]) return null;
303
336
  return Core.search.bodyIndexOf(searchDocs[ri], searchTextLower[ri], previewTerm);
304
337
  }
@@ -308,7 +341,7 @@ function buildItem(it, ri) {
308
341
  // similar rows (it lives in the shared controller).
309
342
  return Core.renderClipItem(it, {
310
343
  imageSrc: item => `clip-img:///${esc(item.image)}`,
311
- query,
344
+ query: shownQuery,
312
345
  matchIndex: previewMatchIndex(ri),
313
346
  selection: controller ? controller.selection() : null,
314
347
  actionsHtml: Core.renderClipActions(it),
@@ -318,7 +351,7 @@ function buildItem(it, ri) {
318
351
  function updateCount() {
319
352
  const total = items.length;
320
353
  const visible = filtered.length;
321
- countEl.textContent = Core.itemCountLabel(total, visible, { query });
354
+ countEl.textContent = searchWorker.busy() ? 'Searching...' : Core.itemCountLabel(total, visible, { query });
322
355
  }
323
356
 
324
357
  // The clear / sort / mode / options buttons slide in and out with the field's
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+
3
+ // search_clips for the MCP helper and the app's approved full search, with a
4
+ // query that holds a /regex/ run on a worker thread: one pattern can backtrack
5
+ // for minutes on a long line (12 Oct 2026, the popup froze on
6
+ // y.*t.*b.*\?.*t= against a 20,000-char line), and on the calling thread that
7
+ // would hang the helper, or the app's main process for an approved search.
8
+ // The worker ends when the caller gives up (its abort signal) or, as a
9
+ // backstop for a caller that never does, after MAX_MS. A query without a regex
10
+ // runs right here, as before (a literal scan is linear).
11
+ //
12
+ // This file is also the worker's entry (workerData.mcpSearch).
13
+
14
+ const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');
15
+ const mcpCore = require('./mcp-core');
16
+ const search = require('../site/shared/clip-search');
17
+
18
+ const MAX_MS = 2 * 60 * 1000;
19
+
20
+ if (!isMainThread && workerData && workerData.mcpSearch) {
21
+ const { history, settings, opts } = workerData.mcpSearch;
22
+ parentPort.postMessage(mcpCore.searchClips(history, settings, opts));
23
+ }
24
+
25
+ function hasRegex(opts) {
26
+ const o = opts || {};
27
+ return search.hasRegexTerm(search.parseQuery(o.regex ? search.legacyRegexQuery(o.query) : o.query));
28
+ }
29
+
30
+ // Resolves to searchClips' result; rejects with a message for the AI client
31
+ // when the pattern ran past the caller's patience.
32
+ function searchClipsOffThread(history, settings, opts, { signal, maxMs = MAX_MS } = {}) {
33
+ if (!hasRegex(opts)) return Promise.resolve(mcpCore.searchClips(history, settings, opts));
34
+ return new Promise((resolve, reject) => {
35
+ const worker = new Worker(__filename, { workerData: { mcpSearch: { history, settings, opts } } });
36
+ let done = false;
37
+ const finish = (fn, value) => {
38
+ if (done) return;
39
+ done = true;
40
+ clearTimeout(timer);
41
+ if (signal) signal.removeEventListener('abort', onAbort);
42
+ worker.terminate().catch(() => {});
43
+ fn(value);
44
+ };
45
+ const tooSlow = () => new Error('This regular expression took too long on the clips (a few .* in a row backtrack on long lines). Narrow it, e.g. anchor it with literal text.');
46
+ const onAbort = () => finish(reject, new Error('cancelled'));
47
+ const timer = setTimeout(() => finish(reject, tooSlow()), maxMs);
48
+ if (signal) {
49
+ if (signal.aborted) { onAbort(); return; }
50
+ signal.addEventListener('abort', onAbort);
51
+ }
52
+ worker.once('message', (result) => finish(resolve, result));
53
+ worker.once('error', (error) => finish(reject, error));
54
+ worker.once('exit', (code) => finish(reject, new Error(`search worker exited (${code})`)));
55
+ });
56
+ }
57
+
58
+ module.exports = { searchClipsOffThread, MAX_MS };
package/main.js CHANGED
@@ -41,6 +41,7 @@ const syncJournal = require('./lib/sync-journal');
41
41
  const fsProbe = require('./lib/fs-probe');
42
42
  const tailscale = require('./lib/tailscale');
43
43
  const mcpCore = require('./lib/mcp-core');
44
+ const mcpSearchThread = require('./lib/mcp-search-thread');
44
45
  const mcpPaths = require('./lib/mcp-paths');
45
46
  const mcpInstallers = require('./lib/mcp-installers');
46
47
  const { Worker } = require('worker_threads');
@@ -156,6 +157,9 @@ const HISTORY_BACKUP_MAX_BYTES = 1024 * 1024 * 1024;
156
157
  const HISTORY_BACKUP_MAX_MANIFESTS = 2000;
157
158
  const HISTORY_BACKUP_MIN_INTERVAL_MS = 60 * 1000;
158
159
  const IMAGE_ORPHAN_RECOVERY_WINDOW_MS = 30 * 60 * 1000;
160
+ // Once Chromium calls the popup renderer unresponsive, how long it may stay so
161
+ // before it is ended and reloaded (createPopup's 'unresponsive' backstop).
162
+ const POPUP_HANG_RELOAD_MS = 5000;
159
163
  const IMAGE_ORPHAN_RECOVERY_MAX_FILES = 20;
160
164
  const SYNC_REMOTE_WRITE_TIMEOUT_MS = 8000;
161
165
  const SYNC_PROVIDER_READ_TIMEOUT_MS = 12000;
@@ -4389,12 +4393,30 @@ function createPopup() {
4389
4393
  onFailed(null, null, error && error.message);
4390
4394
  }
4391
4395
  });
4396
+ // Backstop: a popup renderer that STAYS hung (a page script that never
4397
+ // returns, as a /regex/ on the page's thread did, 12 Oct 2026) used to sit
4398
+ // dead until someone killed it. It is ended now and the render-process-gone
4399
+ // recovery above reloads it; the popup holds no unsaved work. Every firing is
4400
+ // a bug report (popup.renderer_hung_reload).
4401
+ let popupHangTimer = null;
4392
4402
  win.webContents.on('unresponsive', () => {
4393
4403
  diagnostics.record('popup.renderer_unresponsive_native', {
4394
4404
  visible: isPopupOpen(),
4395
4405
  }, { forceFile: true });
4406
+ if (popupHangTimer) return;
4407
+ popupHangTimer = setTimeout(() => {
4408
+ popupHangTimer = null;
4409
+ if (!win || win.isDestroyed() || app.isQuitting) return;
4410
+ diagnostics.record('popup.renderer_hung_reload', { visible: isPopupOpen(), after_ms: POPUP_HANG_RELOAD_MS }, { forceFile: true });
4411
+ try {
4412
+ win.webContents.forcefullyCrashRenderer();
4413
+ } catch (error) {
4414
+ diagnostics.record('popup.renderer_hung_reload_failed', { error: error && error.message }, { forceFile: true });
4415
+ }
4416
+ }, POPUP_HANG_RELOAD_MS);
4396
4417
  });
4397
4418
  win.webContents.on('responsive', () => {
4419
+ if (popupHangTimer) { clearTimeout(popupHangTimer); popupHangTimer = null; }
4398
4420
  diagnostics.record('popup.renderer_responsive_native', {
4399
4421
  visible: isPopupOpen(),
4400
4422
  }, { forceFile: true });
@@ -6717,10 +6739,10 @@ async function mcpHandleRequest(reqPath, payload, ctx = {}) {
6717
6739
  // Re-check after the (possibly long) approval await: if AI access was toggled
6718
6740
  // off while the modal was open, refuse before performing the action.
6719
6741
  if (!settings.ai_access_enabled) throw new Error('ai_access_disabled');
6720
- return mcpExecute(tool, args);
6742
+ return mcpExecute(tool, args, ctx);
6721
6743
  }
6722
6744
 
6723
- function mcpExecute(tool, args) {
6745
+ function mcpExecute(tool, args, ctx = {}) {
6724
6746
  switch (tool) {
6725
6747
  case 'read_clip': {
6726
6748
  const item = findHistoryItem(args.id);
@@ -6731,7 +6753,8 @@ function mcpExecute(tool, args) {
6731
6753
  return { ...mcpCore.fullTextResult(item, sharedSet), viaApproval: true };
6732
6754
  }
6733
6755
  case 'search_all':
6734
- return mcpCore.searchClips(history, settings, { query: args.query, regex: !!args.regex, scope: 'all', limit: args.limit || mcpCore.DEFAULT_LIST_LIMIT });
6756
+ // A /regex/ runs on a worker thread: it must never hang this process.
6757
+ return mcpSearchThread.searchClipsOffThread(history, settings, { query: args.query, regex: !!args.regex, scope: 'all', limit: args.limit || mcpCore.DEFAULT_LIST_LIMIT }, { signal: ctx.signal });
6735
6758
  case 'image_path': {
6736
6759
  const item = findHistoryItem(args.id);
6737
6760
  if (!item || item.type !== 'image') throw new Error('not_an_image');
@@ -26,6 +26,7 @@ const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio
26
26
  const model = require('../lib/clipboard-model');
27
27
  const textBlobStore = require('../lib/text-blob-store');
28
28
  const mcpCore = require('../lib/mcp-core');
29
+ const { searchClipsOffThread } = require('../lib/mcp-search-thread');
29
30
  const mcpPaths = require('../lib/mcp-paths');
30
31
  const controlClient = require('../lib/control-client');
31
32
 
@@ -136,14 +137,18 @@ server.registerTool('search_clips', {
136
137
  include_unshared: z.boolean().optional(),
137
138
  limit: z.number().int().min(1).max(200).optional(),
138
139
  },
139
- }, async ({ query, regex, include_unshared, limit }) => {
140
+ }, async ({ query, regex, include_unshared, limit }, extra) => {
140
141
  if (include_unshared) {
141
142
  return runForward('search_all', { query, regex: !!regex, limit: limit || mcpCore.DEFAULT_LIST_LIMIT });
142
143
  }
143
- const res = mcpCore.searchClips(readHistory(), readSettings(), {
144
- query, regex: !!regex, limit: limit || mcpCore.DEFAULT_LIST_LIMIT,
145
- });
146
- return jsonResult(res);
144
+ try {
145
+ const res = await searchClipsOffThread(readHistory(), readSettings(), {
146
+ query, regex: !!regex, limit: limit || mcpCore.DEFAULT_LIST_LIMIT,
147
+ }, { signal: extra && extra.signal });
148
+ return jsonResult(res);
149
+ } catch (error) {
150
+ return errorResult(error && error.message ? error.message : String(error));
151
+ }
147
152
  });
148
153
 
149
154
  server.registerTool('get_clip', {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tobq/boardclip",
3
3
  "productName": "BoardClip",
4
- "version": "2.1.110",
4
+ "version": "2.1.111",
5
5
  "description": "Cross-platform clipboard history tray app with numpad quick-paste and native cloud sync",
6
6
  "keywords": [
7
7
  "clipboard",
package/site/index.html CHANGED
@@ -493,28 +493,54 @@
493
493
  // Rows render through the SAME shared list as the app (Core.createClipList,
494
494
  // created below): it keeps your place across every rebuild.
495
495
  let visibleRows = [];
496
+ // As in the app: a query with a /regex/ is searched on the shared worker
497
+ // (Core.createSearchWorker); the rows on screen keep the query they were
498
+ // found with (shownQuery) and where each regex match first sits (regexFirst).
499
+ let shownQuery = "";
500
+ let regexFirst = null;
501
+ const demoSearchWorker = Core.createSearchWorker({
502
+ onBusy: (busy) => { clipList.classList.toggle("searching", busy); clipList.setAttribute("aria-busy", busy ? "true" : "false"); if (busy) clipCount.textContent = "Searching..."; },
503
+ });
496
504
  function renderClipRow(clip) {
505
+ const id = Core.itemId(clip);
497
506
  return Core.renderClipItem(clip, {
498
507
  imageSrc: item => item.imageSrc,
499
- query: state.query,
508
+ query: shownQuery,
509
+ ...(regexFirst ? { matchIndex: regexFirst.has(id) ? regexFirst.get(id) : -1 } : {}),
500
510
  selection: controller ? controller.selection() : null,
501
511
  actionsHtml: Core.renderClipActions(clip),
502
512
  });
503
513
  }
504
514
  function renderClips() {
505
- const visible = Core.filterItems(clips, state);
506
- clipCount.textContent = Core.itemCountLabel(clips.length, visible.length, state);
507
515
  updateClearControls();
508
516
  updateDemoSortButton(); // defined below; renderClips first runs at the bottom of this script
509
517
  renderFilterChips();
510
518
  renderDemoSettings();
519
+ const q = state.query;
520
+ if (Core.search.hasRegexTerm(Core.search.parseQuery(q))) {
521
+ const list = clips;
522
+ demoSearchWorker.search({ docs: demoSearchDocs(), query: q, sortMode: state.sortMode, now: Date.now() }, (r) => {
523
+ if (list !== clips) { renderClips(); return; } // the clips moved on: search again
524
+ regexFirst = new Map(r.indexes.map((i, k) => [Core.itemId(list[i]), r.first[k]]));
525
+ showDemoRows(q, r.indexes.map((i) => list[i]), r.relax);
526
+ });
527
+ return;
528
+ }
529
+ demoSearchWorker.cancel();
530
+ regexFirst = null;
531
+ showDemoRows(q, Core.filterItems(clips, state));
532
+ }
533
+ // relax: the worker's empty-result nudge (a regex query), else worked out here.
534
+ function showDemoRows(q, visible, relax) {
535
+ shownQuery = q;
536
+ clipCount.textContent = Core.itemCountLabel(clips.length, visible.length, { query: q });
511
537
  visibleRows = visible;
512
538
  demoList.update({
513
539
  ids: visible.map((clip) => Core.itemId(clip)),
514
540
  tsAt: (i) => Number(visible[i] && visible[i].ts) || 0,
515
- mode: Core.search.rankMode(Core.search.parseQuery(state.query), state.sortMode),
516
- queryKey: state.query,
517
- emptyHtml: () => Core.renderEmptyState({ total: clips.length, query: state.query, groups: labels, nudgeHtml: Core.renderRelaxNudge(Core.search.bestRelaxation(demoSearchDocs(), state.query, { cache: demoRelaxCache })) }),
541
+ mode: Core.search.rankMode(Core.search.parseQuery(q), state.sortMode),
542
+ queryKey: q,
543
+ emptyHtml: () => Core.renderEmptyState({ total: clips.length, query: q, groups: labels, nudgeHtml: Core.renderRelaxNudge(relax !== undefined ? relax : Core.search.bestRelaxation(demoSearchDocs(), q, { cache: demoRelaxCache })) }),
518
544
  });
519
545
  }
520
546
 
@@ -1224,6 +1224,39 @@
1224
1224
  return scored.map((s) => s.i);
1225
1225
  }
1226
1226
 
1227
+ // Does the query hold a /regex/ anywhere (a top-level term, an excluded one,
1228
+ // or a leaf of a custom expression)? A regex is the one term whose cost has
1229
+ // no bound: y.*t.*b.*\?.*t= backtracked for minutes on a 20,000-char line
1230
+ // and froze the popup for good (12 Oct 2026), so such a query is searched
1231
+ // off the page's thread (searchJob in Core.createSearchWorker's worker).
1232
+ function hasRegexTerm(parsed) {
1233
+ const p = parsed || {};
1234
+ const inNode = (n) => !!n && (n.type === 'leaf' ? !!(n.leaf && n.leaf.regex) : (n.children || []).some(inNode));
1235
+ return (p.content || []).some((c) => c.regex) || (p.terms || []).some((c) => c.regex) || (p.compound || []).some(inNode);
1236
+ }
1237
+ // One whole search, as the worker runs it: the ranked ORIGINAL indexes, where
1238
+ // each match first sits in its body (the row's preview window; -1 = none),
1239
+ // and the empty-result nudge. It also marks every match's title and body
1240
+ // once (termSpans), so a pattern that takes too long on a matched clip
1241
+ // stalls HERE, off the page's thread; the row highlight then only marks a
1242
+ // short window of those same texts. req: { query, sortMode, now }.
1243
+ function searchJob(docs, hays, req) {
1244
+ const r = req || {};
1245
+ const parsed = parseQuery(r.query || '');
1246
+ const now = r.now || Date.now();
1247
+ const list = docs || [];
1248
+ const indexes = filterRankIndexes(null, parsed, { docs: list, searchTextLower: hays || null, now, sortMode: r.sortMode || null });
1249
+ const first = new Array(indexes.length);
1250
+ for (let k = 0; k < indexes.length; k += 1) {
1251
+ const doc = list[indexes[k]];
1252
+ first[k] = doc.type === 'image' ? -1 : firstMatchIndex(parsed, doc.body);
1253
+ termSpans(parsed, doc.title, 'title', 100);
1254
+ if (doc.type !== 'image') termSpans(parsed, doc.body, 'body', 100);
1255
+ }
1256
+ const relax = indexes.length ? null : bestRelaxation(list, parsed, { now, searchTextLower: hays || null });
1257
+ return { indexes, first, relax };
1258
+ }
1259
+
1227
1260
  // ── IntelliJ camel-hump fuzzy matcher (ported from Forge src/renderer/lib/fuzzy.ts,
1228
1261
  // React stripped). `sdi` -> "Sync Data-loss Incident". Returns {score, positions}|null. ──
1229
1262
  const F_BONUS_WORD_START = 24, F_BONUS_STRING_START = 12, F_BONUS_CONSECUTIVE = 16, F_BONUS_CASE = 2, F_PENALTY_GAP = -2, F_PENALTY_LEAD = -1, F_LEAD_CAP = -6;
@@ -2315,6 +2348,7 @@
2315
2348
  tokenizeQuery, quoteToken, parseQuery, scanQuery, serializeQuery, applyFacet, stripFacet, clearFacet, facetState, facetTokenState,
2316
2349
  anyFilterActive, hasSearchTerms, resolveTimeMs,
2317
2350
  matchDoc, relevanceScore, recencyScore, decayWeight, groupWeights, compareGroupUse, rankMode, filterRankIndexes, bodyIndexOf,
2351
+ hasRegexTerm, searchJob,
2318
2352
  compileTerm, escapeRegExp, termSpans, firstMatchIndex,
2319
2353
  regexTypingEdit, regexBackspaceEdit, toggleRegexAt, legacyRegexQuery, dimConnective, setDimConnective, normalizeJoins,
2320
2354
  promptOptionState, promptOptionLabel, FIELD_EXAMPLE,
@@ -352,6 +352,9 @@
352
352
  .bc-scroll-fade, Core.createClipList); scroll-padding keeps the keyboard
353
353
  cursor out of the fades when it is scrolled into view. */
354
354
  .list { flex: 1; overflow-y: auto; padding: 0 0 var(--sp-4); min-height: 0; scroll-padding-block: 36px; }
355
+ /* A /regex/ search still running on the search worker: the rows on screen are the last answer, not this one. */
356
+ .list { transition: opacity var(--dur) var(--ease); }
357
+ .list.searching { opacity: .55; }
355
358
  .item { padding: var(--sp-4) var(--gutter); cursor: pointer; transition: background-color var(--dur) var(--ease); position: relative; border-bottom: 1px solid var(--line-faint); color: inherit; }
356
359
  /* Row states: hover = the --hover overlay; the keyboard cursor (the row Enter
357
360
  pastes) = --accent-bg; a candidate duplicate of the hovered or cursor row
@@ -290,6 +290,176 @@
290
290
  const list = items || [];
291
291
  return filterItemIndexes(list, state).map((i) => list[i]);
292
292
  }
293
+ // A search that holds a /regex/ (and the find bar's regex) runs on a worker
294
+ // thread (search-worker.js beside this file): a pattern can backtrack for
295
+ // minutes on one long line (y.*t.*b.*\?.*t= froze the popup for good on a
296
+ // 20,000-char line, 12 Oct 2026), and nothing can interrupt a RegExp on the
297
+ // page's thread. The latest request wins. A worker still busy GRACE ms after
298
+ // its result stopped being wanted is ended (it may be stuck for hours) and
299
+ // the next request starts a fresh one. The worker holds its own copy of the
300
+ // docs, synced by doc OBJECT, so a new history revision sends only new or
301
+ // changed clips. opts: { url, graceMs, onBusy(busy) (a wanted result has been
302
+ // outstanding for busyMs), busyMs, onError(message) (the worker failed; the job
303
+ // then runs on the page, as before) }.
304
+ const SEARCH_WORKER_URL = (() => {
305
+ try { return new URL('search-worker.js', document.currentScript.src).href; } catch { return ''; }
306
+ })();
307
+ function createSearchWorker(opts) {
308
+ const o = opts || {};
309
+ const url = o.url || SEARCH_WORKER_URL;
310
+ const graceMs = o.graceMs != null ? o.graceMs : 250;
311
+ const busyMs = o.busyMs != null ? o.busyMs : 150;
312
+ const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
313
+ const keys = new WeakMap();
314
+ let nextKey = 1;
315
+ let worker = null;
316
+ let sent = new WeakSet();
317
+ let syncedDocs = null;
318
+ let running = null; // { job, startedAt }
319
+ let queued = null;
320
+ let wanted = 0;
321
+ let seq = 0;
322
+ let killTimer = null;
323
+ let busyTimer = null;
324
+ let busy = false;
325
+ let idleWaiters = [];
326
+ const setBusy = (next) => {
327
+ if (busy === next) return;
328
+ busy = next;
329
+ if (typeof o.onBusy === 'function') o.onBusy(next);
330
+ };
331
+ const settleIdle = () => {
332
+ if (wanted) return;
333
+ clearTimeout(busyTimer);
334
+ setBusy(false);
335
+ const waiters = idleWaiters;
336
+ idleWaiters = [];
337
+ waiters.forEach((fn) => fn());
338
+ };
339
+ function kill() {
340
+ clearTimeout(killTimer);
341
+ killTimer = null;
342
+ if (worker) worker.terminate();
343
+ worker = null;
344
+ running = null;
345
+ sent = new WeakSet();
346
+ syncedDocs = null;
347
+ }
348
+ function keyOf(doc) {
349
+ let k = keys.get(doc);
350
+ if (!k) { k = nextKey; nextKey += 1; keys.set(doc, k); }
351
+ return k;
352
+ }
353
+ function sync(docs, hays) {
354
+ if (docs === syncedDocs) return;
355
+ const list = docs || [];
356
+ const order = new Array(list.length);
357
+ const add = [];
358
+ for (let i = 0; i < list.length; i += 1) {
359
+ const doc = list[i];
360
+ if (!doc) { order[i] = 0; continue; }
361
+ const k = keyOf(doc);
362
+ order[i] = k;
363
+ if (!sent.has(doc)) { sent.add(doc); add.push([k, doc, hays ? hays[i] : null]); }
364
+ }
365
+ worker.postMessage({ type: 'sync', order, add });
366
+ syncedDocs = docs;
367
+ }
368
+ // No worker (it failed to load): the job runs here, as before.
369
+ function runHere(job) {
370
+ const m = job.msg;
371
+ if (m.type === 'search') return { type: 'search', id: m.id, ...Search.searchJob(job.docs, job.hays, m) };
372
+ const term = Search.compileTerm(m.query, { regex: !!m.regex, caseSensitive: !!m.caseSensitive });
373
+ return { type: 'find', id: m.id, matches: term.all(String(m.text || '')), valid: term.valid, error: term.error };
374
+ }
375
+ function deliver(job, result) {
376
+ if (job.id !== wanted) return;
377
+ wanted = 0;
378
+ try { job.cb(result); } finally { settleIdle(); }
379
+ }
380
+ function start(job) {
381
+ if (!worker && url && typeof Worker === 'function') {
382
+ try {
383
+ worker = new Worker(url);
384
+ worker.onmessage = onMessage;
385
+ worker.onerror = onError;
386
+ } catch { worker = null; }
387
+ }
388
+ if (!worker) { deliver(job, runHere(job)); return; }
389
+ if (job.msg.type === 'search') sync(job.docs, job.hays);
390
+ running = { job, startedAt: now() };
391
+ worker.postMessage(job.msg);
392
+ }
393
+ function next() {
394
+ const job = queued;
395
+ queued = null;
396
+ if (job) start(job);
397
+ }
398
+ function onMessage(event) {
399
+ const m = event.data || {};
400
+ if (!running || m.id !== running.job.id) return;
401
+ const job = running.job;
402
+ running = null;
403
+ clearTimeout(killTimer);
404
+ killTimer = null;
405
+ deliver(job, m);
406
+ next();
407
+ }
408
+ // The worker failed (it could not load, or ran out of memory): it is
409
+ // ended, and the job it held runs here so its answer still arrives.
410
+ function onError(event) {
411
+ if (event && typeof event.preventDefault === 'function') event.preventDefault();
412
+ if (typeof o.onError === 'function') o.onError(String(event && event.message || 'search worker failed'));
413
+ const job = running && running.job;
414
+ kill();
415
+ if (job) deliver(job, runHere(job));
416
+ next();
417
+ }
418
+ // The running job's result is not wanted any more: end its worker once it
419
+ // has had graceMs (a quick job just finishes and the queue moves on).
420
+ function armKill() {
421
+ if (!running || running.job.id === wanted) return;
422
+ clearTimeout(killTimer);
423
+ killTimer = setTimeout(() => {
424
+ killTimer = null;
425
+ if (!running || running.job.id === wanted) return;
426
+ kill();
427
+ next();
428
+ }, Math.max(0, graceMs - (now() - running.startedAt)));
429
+ }
430
+ function request(type, msg, cb, docs, hays) {
431
+ seq += 1;
432
+ const job = { id: seq, msg: { ...msg, type, id: seq }, cb, docs, hays };
433
+ wanted = job.id;
434
+ clearTimeout(busyTimer);
435
+ busyTimer = setTimeout(() => { if (wanted) setBusy(true); }, busyMs);
436
+ if (running) { queued = job; armKill(); } else start(job);
437
+ return job.id;
438
+ }
439
+ return {
440
+ // req: { docs, hays (the lowercased haystacks, or null), query, sortMode, now }
441
+ search(req, cb) {
442
+ const r = req || {};
443
+ return request('search', { query: r.query || '', sortMode: r.sortMode || null, now: r.now || Date.now() }, cb, r.docs || [], r.hays || null);
444
+ },
445
+ // req: { text, query, regex, caseSensitive }
446
+ find(req, cb) {
447
+ const r = req || {};
448
+ return request('find', { text: String(r.text || ''), query: String(r.query || ''), regex: !!r.regex, caseSensitive: !!r.caseSensitive }, cb);
449
+ },
450
+ // The outstanding result is not wanted any more.
451
+ cancel() {
452
+ wanted = 0;
453
+ queued = null;
454
+ armKill();
455
+ settleIdle();
456
+ },
457
+ busy: () => busy,
458
+ // Resolves once no wanted result is outstanding (QA drivers await it).
459
+ whenIdle: () => (wanted ? new Promise((resolve) => idleWaiters.push(resolve)) : Promise.resolve()),
460
+ dispose() { wanted = 0; queued = null; kill(); settleIdle(); },
461
+ };
462
+ }
293
463
  function itemCountLabel(total, visible, state) {
294
464
  const count = Number(total) || 0;
295
465
  const shown = Number(visible) || 0;
@@ -5784,14 +5954,30 @@
5784
5954
  findHl.innerHTML = `${html}\n`;
5785
5955
  syncHlScroll();
5786
5956
  }
5787
- function recomputeMatches() {
5788
- const term = findInput.value ? Search.compileTerm(findInput.value, { regex: findRegex, caseSensitive: findCase }) : null;
5789
- matches = term ? term.all(area.value) : [];
5790
- findCount.title = term && !term.valid ? `Not a valid regular expression: ${term.error}.` : '';
5791
- findBar.classList.toggle('bad-pattern', !!(term && !term.valid));
5792
- if (!matches.length) { findIdx = -1; findCount.textContent = term && !term.valid ? 'Invalid' : findInput.value ? '0/0' : ''; }
5793
- else if (findIdx < 0 || findIdx >= matches.length) findIdx = 0;
5794
- renderFindHighlights();
5957
+ // A regex runs on the search worker (createSearchWorker): one pattern can
5958
+ // backtrack for minutes on a long line, and the page's thread cannot stop
5959
+ // it. A plain word is a linear scan, done here. `after` runs once the
5960
+ // matches are in (at once for a word, on the worker's answer for a regex).
5961
+ let findWorker = null;
5962
+ function recomputeMatches(after) {
5963
+ const query = findInput.value;
5964
+ const term = query ? Search.compileTerm(query, { regex: findRegex, caseSensitive: findCase }) : null;
5965
+ const apply = (list) => {
5966
+ matches = list;
5967
+ findCount.title = term && !term.valid ? `Not a valid regular expression: ${term.error}.` : '';
5968
+ findBar.classList.toggle('bad-pattern', !!(term && !term.valid));
5969
+ if (!matches.length) { findIdx = -1; findCount.textContent = term && !term.valid ? 'Invalid' : query ? '0/0' : ''; }
5970
+ else if (findIdx < 0 || findIdx >= matches.length) findIdx = 0;
5971
+ renderFindHighlights();
5972
+ if (after) after();
5973
+ };
5974
+ if (term && term.valid && term.regex) {
5975
+ if (!findWorker) findWorker = createSearchWorker({ onBusy: (busy) => { if (busy) findCount.textContent = '...'; } });
5976
+ findWorker.find({ text: area.value, query, regex: true, caseSensitive: findCase }, (r) => apply(r.matches || []));
5977
+ return;
5978
+ }
5979
+ if (findWorker) findWorker.cancel();
5980
+ apply(term ? term.all(area.value) : []);
5795
5981
  }
5796
5982
  function step(dir) {
5797
5983
  if (!matches.length) return;
@@ -5805,8 +5991,7 @@
5805
5991
  if (opt.regex != null) findRegex = !!opt.regex;
5806
5992
  updateFindButtons();
5807
5993
  findIdx = -1;
5808
- recomputeMatches();
5809
- selectMatch({ preserveFocus: true });
5994
+ recomputeMatches(() => selectMatch({ preserveFocus: true }));
5810
5995
  findInput.focus();
5811
5996
  if (opt.select !== false) findInput.select();
5812
5997
  }
@@ -5817,8 +6002,7 @@
5817
6002
  if (sel && !sel.includes('\n')) findInput.value = sel.slice(0, 120);
5818
6003
  updateFindButtons();
5819
6004
  findIdx = -1;
5820
- recomputeMatches();
5821
- selectMatch({ preserveFocus: true });
6005
+ recomputeMatches(() => selectMatch({ preserveFocus: true }));
5822
6006
  findInput.focus();
5823
6007
  findInput.select();
5824
6008
  }
@@ -5843,12 +6027,12 @@
5843
6027
  else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'f') { e.preventDefault(); openFind(); }
5844
6028
  else if (e.key === 'Enter') { e.preventDefault(); commit(); area.focus(); } // done naming: back to the text
5845
6029
  });
5846
- findInput.addEventListener('input', () => { findIdx = -1; recomputeMatches(); selectMatch({ preserveFocus: true }); });
6030
+ findInput.addEventListener('input', () => { findIdx = -1; recomputeMatches(() => selectMatch({ preserveFocus: true })); });
5847
6031
  findInput.addEventListener('keydown', (e) => {
5848
6032
  if (e.key === 'Enter') { e.preventDefault(); step(e.shiftKey ? -1 : 1); }
5849
6033
  else if (e.key === 'Escape') { e.preventDefault(); closeFind(); }
5850
6034
  });
5851
- const refind = () => { updateFindButtons(); findIdx = -1; recomputeMatches(); selectMatch({ preserveFocus: true }); };
6035
+ const refind = () => { updateFindButtons(); findIdx = -1; recomputeMatches(() => selectMatch({ preserveFocus: true })); };
5852
6036
  // The shared chord toggles: Regex (Alt+R) and match case (Alt+C).
5853
6037
  const findRegexToggle = attachToggle(findRegexBtn, {
5854
6038
  label: 'Regex',
@@ -5923,7 +6107,7 @@
5923
6107
  revert,
5924
6108
  // A host that drops the editor without closing its window (the demo's
5925
6109
  // overlay) lets go of its chords.
5926
- destroy() { findRegexToggle.destroy(); findCaseToggle.destroy(); },
6110
+ destroy() { findRegexToggle.destroy(); findCaseToggle.destroy(); if (findWorker) findWorker.dispose(); },
5927
6111
  };
5928
6112
  }
5929
6113
  // Shared in-app IMAGE VIEWER — the image twin of createEditor, mounted by the
@@ -6920,6 +7104,7 @@
6920
7104
  createImageZoom,
6921
7105
  createClipController,
6922
7106
  findAllMatches,
7107
+ createSearchWorker,
6923
7108
  editorFindFor,
6924
7109
  flipChildren,
6925
7110
  attachToggle,
@@ -0,0 +1,33 @@
1
+ // BoardClip search worker: runs every search that holds a /regex/, and the
2
+ // editor find bar's regex, off the page's thread. Hosted by
3
+ // Core.createSearchWorker (clipboard-ui-core.js), which ends this worker when a
4
+ // pattern runs on after its result is no longer wanted. Messages:
5
+ // { type: 'sync', order: [key], add: [[key, doc, hay]] } the docs, by key
6
+ // { type: 'search', id, query, sortMode, now } -> { type, id, indexes, first, relax }
7
+ // { type: 'find', id, text, query, regex, caseSensitive } -> { type, id, matches, valid, error }
8
+ importScripts('clip-search.js');
9
+ const Search = self.BoardClipSearch;
10
+ const byKey = new Map();
11
+ let docs = [];
12
+ let hays = null;
13
+
14
+ self.onmessage = (event) => {
15
+ const m = event.data || {};
16
+ if (m.type === 'sync') {
17
+ for (const [key, doc, hay] of m.add) byKey.set(key, { doc, hay });
18
+ const keep = new Set(m.order);
19
+ for (const key of byKey.keys()) if (!keep.has(key)) byKey.delete(key);
20
+ const entries = m.order.map((key) => byKey.get(key) || { doc: null, hay: null });
21
+ docs = entries.map((e) => e.doc);
22
+ hays = entries.some((e) => e.hay != null) ? entries.map((e) => e.hay) : null;
23
+ return;
24
+ }
25
+ if (m.type === 'search') {
26
+ self.postMessage({ type: 'search', id: m.id, ...Search.searchJob(docs, hays, m) });
27
+ return;
28
+ }
29
+ if (m.type === 'find') {
30
+ const term = Search.compileTerm(m.query, { regex: !!m.regex, caseSensitive: !!m.caseSensitive });
31
+ self.postMessage({ type: 'find', id: m.id, matches: term.all(String(m.text || '')), valid: term.valid, error: term.error });
32
+ }
33
+ };