@tobq/boardclip 2.1.110 → 2.1.112

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 };
@@ -0,0 +1,80 @@
1
+ 'use strict';
2
+
3
+ // Where a clip window (editor, viewer, unify, conflict) opens, as pure maths so
4
+ // it is unit-tested (test/window-placement.test.js); main.js supplies the saved
5
+ // bounds, the display's work area, the clip windows already showing and the
6
+ // popup's bounds when the popup stays up. Owner 2026-10-13: "spawns under BC
7
+ // itself" / "multi spawning they dont stack, it jsut all sits on top".
8
+ // - A clip window already open at the spot pushes the new one a cascade step on,
9
+ // wrapping inside the work area, like the OS cascades documents.
10
+ // - With the popup up (keepPopup opens) the window goes beside the popup, never
11
+ // under it: the popup is always on top. It fans out away from the popup.
12
+
13
+ const CASCADE_STEP = 28;
14
+ const POPUP_GAP = 8;
15
+ function rectsOverlap(a, b) {
16
+ return a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
17
+ }
18
+ function clampInto(rect, area) {
19
+ return {
20
+ ...rect,
21
+ x: Math.max(area.x, Math.min(rect.x, area.x + area.width - rect.width)),
22
+ y: Math.max(area.y, Math.min(rect.y, area.y + area.height - rect.height)),
23
+ };
24
+ }
25
+ // Pure placement (unit-tested): base = the saved bounds (x/y may be missing),
26
+ // area = the work area to stay in, taken = top-left corners of the clip windows
27
+ // already showing, avoid = the open popup's bounds when it stays up.
28
+ function placeClipWindowBounds(base, { area, taken = [], avoid = null } = {}) {
29
+ const width = Math.min(base.width, area.width);
30
+ const height = Math.min(base.height, area.height);
31
+ let start = Number.isFinite(base.x) && Number.isFinite(base.y)
32
+ ? clampInto({ x: base.x, y: base.y, width, height }, area)
33
+ : { x: Math.round(area.x + (area.width - width) / 2), y: Math.round(area.y + (area.height - height) / 2), width, height };
34
+ // Cascade direction: down and right, unless the window sits left of / above
35
+ // the popup, where it fans out away from the popup instead.
36
+ let dx = 1;
37
+ let dy = 1;
38
+ if (avoid && rectsOverlap(start, avoid)) {
39
+ const midY = avoid.y + Math.round((avoid.height - height) / 2);
40
+ const midX = avoid.x + Math.round((avoid.width - width) / 2);
41
+ const sides = [
42
+ { x: avoid.x + avoid.width + POPUP_GAP, y: midY, dx: 1, dy: 1 },
43
+ { x: avoid.x - POPUP_GAP - width, y: midY, dx: -1, dy: 1 },
44
+ { x: midX, y: avoid.y + avoid.height + POPUP_GAP, dx: 1, dy: 1 },
45
+ { x: midX, y: avoid.y - POPUP_GAP - height, dx: 1, dy: -1 },
46
+ ].map((s) => ({ ...s, rect: clampInto({ x: s.x, y: s.y, width, height }, area) }));
47
+ const overlapArea = (r) => Math.max(0, Math.min(r.x + width, avoid.x + avoid.width) - Math.max(r.x, avoid.x))
48
+ * Math.max(0, Math.min(r.y + height, avoid.y + avoid.height) - Math.max(r.y, avoid.y));
49
+ const best = sides.find((s) => !rectsOverlap(s.rect, avoid))
50
+ || sides.slice().sort((a, b) => overlapArea(a.rect) - overlapArea(b.rect))[0];
51
+ start = best.rect;
52
+ dx = best.dx;
53
+ dy = best.dy;
54
+ }
55
+ // The cascade walks diagonals (one step down and along per window); a
56
+ // diagonal that leaves the work area starts the next one a step further
57
+ // along at the area's top (or bottom, when fanning upward), like the OS
58
+ // cascade. The first spot no showing clip window holds wins.
59
+ const near = (p, q) => Math.abs(p.x - q.x) < 8 && Math.abs(p.y - q.y) < 8;
60
+ const fits = (p) => p.x >= area.x && p.y >= area.y && p.x + width <= area.x + area.width && p.y + height <= area.y + area.height;
61
+ // A wrapped spot never lands under the popup (unless no side had room for
62
+ // the window, when the least-covered spot is the best there is).
63
+ const besideFits = !(avoid && rectsOverlap(start, avoid));
64
+ const underPopup = (r) => !!avoid && besideFits && rectsOverlap(r, avoid);
65
+ const spanX = Math.max(1, area.width - width + 1);
66
+ const wrapX = (x) => area.x + ((((x - area.x) % spanX) + spanX) % spanX);
67
+ for (let column = 0; column < 64; column += 1) {
68
+ const x0 = wrapX(start.x + dx * column * CASCADE_STEP);
69
+ const y0 = column === 0 ? start.y : dy > 0 ? area.y : area.y + area.height - height;
70
+ for (let step = 0; step < 64; step += 1) {
71
+ const p = { x: x0 + dx * step * CASCADE_STEP, y: y0 + dy * step * CASCADE_STEP };
72
+ if (!fits(p)) break;
73
+ if (underPopup({ ...p, width, height })) continue;
74
+ if (!taken.some((t) => near(t, p))) return { x: p.x, y: p.y, width, height };
75
+ }
76
+ }
77
+ return { x: start.x, y: start.y, width, height };
78
+ }
79
+
80
+ module.exports = { placeClipWindowBounds, rectsOverlap, clampInto, CASCADE_STEP, POPUP_GAP };
package/main.js CHANGED
@@ -14,6 +14,7 @@ const macPaste = require('./lib/macos-paste');
14
14
  const winClipboard = require('./lib/windows-clipboard');
15
15
  const { createQuickPaster } = require('./lib/quick-paste');
16
16
  const { createClipboardFollower } = require('./lib/clipboard-follow');
17
+ const { placeClipWindowBounds } = require('./lib/window-placement');
17
18
  const { dragFileNames } = require('./lib/drag-files');
18
19
  const { createHistoryFeed } = require('./lib/history-feed');
19
20
  // Shared UI core (pure UMD, no DOM at load): main reuses its chord tests.
@@ -41,6 +42,7 @@ const syncJournal = require('./lib/sync-journal');
41
42
  const fsProbe = require('./lib/fs-probe');
42
43
  const tailscale = require('./lib/tailscale');
43
44
  const mcpCore = require('./lib/mcp-core');
45
+ const mcpSearchThread = require('./lib/mcp-search-thread');
44
46
  const mcpPaths = require('./lib/mcp-paths');
45
47
  const mcpInstallers = require('./lib/mcp-installers');
46
48
  const { Worker } = require('worker_threads');
@@ -156,6 +158,9 @@ const HISTORY_BACKUP_MAX_BYTES = 1024 * 1024 * 1024;
156
158
  const HISTORY_BACKUP_MAX_MANIFESTS = 2000;
157
159
  const HISTORY_BACKUP_MIN_INTERVAL_MS = 60 * 1000;
158
160
  const IMAGE_ORPHAN_RECOVERY_WINDOW_MS = 30 * 60 * 1000;
161
+ // Once Chromium calls the popup renderer unresponsive, how long it may stay so
162
+ // before it is ended and reloaded (createPopup's 'unresponsive' backstop).
163
+ const POPUP_HANG_RELOAD_MS = 5000;
159
164
  const IMAGE_ORPHAN_RECOVERY_MAX_FILES = 20;
160
165
  const SYNC_REMOTE_WRITE_TIMEOUT_MS = 8000;
161
166
  const SYNC_PROVIDER_READ_TIMEOUT_MS = 12000;
@@ -4389,12 +4394,30 @@ function createPopup() {
4389
4394
  onFailed(null, null, error && error.message);
4390
4395
  }
4391
4396
  });
4397
+ // Backstop: a popup renderer that STAYS hung (a page script that never
4398
+ // returns, as a /regex/ on the page's thread did, 12 Oct 2026) used to sit
4399
+ // dead until someone killed it. It is ended now and the render-process-gone
4400
+ // recovery above reloads it; the popup holds no unsaved work. Every firing is
4401
+ // a bug report (popup.renderer_hung_reload).
4402
+ let popupHangTimer = null;
4392
4403
  win.webContents.on('unresponsive', () => {
4393
4404
  diagnostics.record('popup.renderer_unresponsive_native', {
4394
4405
  visible: isPopupOpen(),
4395
4406
  }, { forceFile: true });
4407
+ if (popupHangTimer) return;
4408
+ popupHangTimer = setTimeout(() => {
4409
+ popupHangTimer = null;
4410
+ if (!win || win.isDestroyed() || app.isQuitting) return;
4411
+ diagnostics.record('popup.renderer_hung_reload', { visible: isPopupOpen(), after_ms: POPUP_HANG_RELOAD_MS }, { forceFile: true });
4412
+ try {
4413
+ win.webContents.forcefullyCrashRenderer();
4414
+ } catch (error) {
4415
+ diagnostics.record('popup.renderer_hung_reload_failed', { error: error && error.message }, { forceFile: true });
4416
+ }
4417
+ }, POPUP_HANG_RELOAD_MS);
4396
4418
  });
4397
4419
  win.webContents.on('responsive', () => {
4420
+ if (popupHangTimer) { clearTimeout(popupHangTimer); popupHangTimer = null; }
4398
4421
  diagnostics.record('popup.renderer_responsive_native', {
4399
4422
  visible: isPopupOpen(),
4400
4423
  }, { forceFile: true });
@@ -5337,6 +5360,7 @@ function windowBoundsFromSettings(key, defaults) {
5337
5360
  return out;
5338
5361
  }
5339
5362
  const saveBoundsTimers = new Map();
5363
+ const placedBounds = new WeakMap(); // clip window -> the bounds it was opened at
5340
5364
  function scheduleWindowBoundsSave(childWin, key) {
5341
5365
  if (!childWin || childWin.isDestroyed()) return;
5342
5366
  const prior = saveBoundsTimers.get(key);
@@ -5346,6 +5370,14 @@ function scheduleWindowBoundsSave(childWin, key) {
5346
5370
  if (!childWin || childWin.isDestroyed()) return;
5347
5371
  const maximized = childWin.isMaximized() || childWin.isFullScreen();
5348
5372
  const { x, y, width, height } = maximized ? childWin.getNormalBounds() : childWin.getBounds();
5373
+ // A window still where it was placed (a cascade step, beside the popup)
5374
+ // never becomes the saved spot: only a move or resize by the user does, so
5375
+ // untouched windows do not drift the next one's start.
5376
+ const placed = placedBounds.get(childWin);
5377
+ const savedNow = settings[key] || {};
5378
+ if (placed && placed.x === x && placed.y === y && placed.width === width && placed.height === height
5379
+ && maximized === (savedNow.maximized === true)) return;
5380
+ placedBounds.delete(childWin);
5349
5381
  settings[key] = { x, y, width: Math.max(360, width), height: Math.max(240, height), maximized };
5350
5382
  saveSettingsFile({ localOnly: true });
5351
5383
  }, 300);
@@ -5367,15 +5399,45 @@ function restoreWindowMaximized(childWin, key, options) {
5367
5399
  if (!saved || saved.maximized !== true || (options && options.keepPopup)) return;
5368
5400
  try { childWin.maximize(); } catch {}
5369
5401
  }
5370
- function editorBoundsFromSettings() { return windowBoundsFromSettings('editor_bounds', { width: 560, height: 520 }); }
5402
+ // Every clip window (editor, viewer, unify, conflict) opens at its kind's saved
5403
+ // bounds, then cascades past the clip windows already showing and, when the
5404
+ // popup stays up (keepPopup), goes beside the popup instead of under it
5405
+ // (lib/window-placement.js). The work area is the popup's display, else the
5406
+ // saved spot's, else the cursor's.
5407
+ const clipWindows = new Set();
5408
+ function trackClipWindow(childWin) {
5409
+ clipWindows.add(childWin);
5410
+ try { placedBounds.set(childWin, childWin.getBounds()); } catch {}
5411
+ childWin.once('closed', () => clipWindows.delete(childWin));
5412
+ }
5413
+ function clipWindowBoundsFromSettings(key, defaults, options) {
5414
+ const base = windowBoundsFromSettings(key, defaults);
5415
+ const keepPopup = !!(options && options.keepPopup) && isPopupOpen();
5416
+ const avoid = keepPopup && win && !win.isDestroyed() ? win.getBounds() : null;
5417
+ const anchor = avoid
5418
+ ? { x: avoid.x + Math.round(avoid.width / 2), y: avoid.y + Math.round(avoid.height / 2) }
5419
+ : Number.isFinite(base.x) ? { x: base.x + Math.round(base.width / 2), y: base.y + Math.round(base.height / 2) }
5420
+ : screen.getCursorScreenPoint();
5421
+ const area = screen.getDisplayNearestPoint(anchor).workArea;
5422
+ const taken = [];
5423
+ for (const other of clipWindows) {
5424
+ try {
5425
+ if (other.isDestroyed() || !other.isVisible() || other.isMinimized() || other.isMaximized()) continue;
5426
+ const b = other.getBounds();
5427
+ taken.push({ x: b.x, y: b.y });
5428
+ } catch {}
5429
+ }
5430
+ return placeClipWindowBounds(base, { area, taken, avoid });
5431
+ }
5432
+ function editorBoundsFromSettings(options) { return clipWindowBoundsFromSettings('editor_bounds', { width: 560, height: 520 }, options); }
5371
5433
  // The merge windows (unify, conflict) keep their own size: two or three panes
5372
5434
  // with a head each need width a plain editor does not (and a widened unify
5373
5435
  // must not leave the next editor wide).
5374
- function mergeBoundsFromSettings() { return windowBoundsFromSettings('merge_bounds', { width: 780, height: 560 }); }
5436
+ function mergeBoundsFromSettings(options) { return clipWindowBoundsFromSettings('merge_bounds', { width: 780, height: 560 }, options); }
5375
5437
 
5376
5438
  function createEditorWindow(session, presentOptions = {}) {
5377
5439
  const editorWin = new BrowserWindow({
5378
- ...editorBoundsFromSettings(),
5440
+ ...editorBoundsFromSettings(presentOptions),
5379
5441
  minWidth: 360,
5380
5442
  minHeight: 240,
5381
5443
  resizable: true,
@@ -5392,6 +5454,7 @@ function createEditorWindow(session, presentOptions = {}) {
5392
5454
  },
5393
5455
  });
5394
5456
  noteSecondarySurface(editorWin);
5457
+ trackClipWindow(editorWin);
5395
5458
  session.win = editorWin;
5396
5459
  editorWin.loadFile(path.join(SCRIPT_DIR, 'editor.html'));
5397
5460
  // Show + focus the editor, then dismiss the popup so it doesn't linger behind
@@ -5455,7 +5518,7 @@ function sourceGroupsForNewNote(options) {
5455
5518
  // Bring an editor/viewer window on screen. Default = a deliberate hand-off: the
5456
5519
  // window takes focus and the popup is dismissed (a popup lingering behind the
5457
5520
  // editor felt "sticky" - you had to click back to it + Escape). With
5458
- // options.keepPopup (mouse-driven opens: middle-click, alt+click, the row menu)
5521
+ // options.keepPopup (the "open several" gestures: middle-click, right-click on the open button, alt+click)
5459
5522
  // the window is shown WITHOUT activating and the popup stays where it is, so
5460
5523
  // several results can be opened one after another; the popup still hides on
5461
5524
  // its own blur the moment the user clicks into one of the windows.
@@ -5626,7 +5689,7 @@ function openImageViewer(id, options = {}) {
5626
5689
  return;
5627
5690
  }
5628
5691
  const viewerWin = new BrowserWindow({
5629
- ...windowBoundsFromSettings('viewer_bounds', { width: 720, height: 560 }),
5692
+ ...clipWindowBoundsFromSettings('viewer_bounds', { width: 720, height: 560 }, options),
5630
5693
  minWidth: 360,
5631
5694
  minHeight: 240,
5632
5695
  resizable: true,
@@ -5643,6 +5706,7 @@ function openImageViewer(id, options = {}) {
5643
5706
  },
5644
5707
  });
5645
5708
  noteSecondarySurface(viewerWin);
5709
+ trackClipWindow(viewerWin);
5646
5710
  viewerWindows.set(key, viewerWin);
5647
5711
  viewerWin.loadFile(path.join(SCRIPT_DIR, 'viewer.html'));
5648
5712
  viewerWin.once('ready-to-show', () => {
@@ -5799,6 +5863,7 @@ function openConflictWindow(conflictId) {
5799
5863
  },
5800
5864
  });
5801
5865
  noteSecondarySurface(conflictWin);
5866
+ trackClipWindow(conflictWin);
5802
5867
  conflictWindows.set(id, conflictWin);
5803
5868
  const sessionId = `conflict:${id}`;
5804
5869
  conflictWin.loadFile(path.join(SCRIPT_DIR, 'editor.html'));
@@ -5916,6 +5981,7 @@ function openUnifyWindow(session) {
5916
5981
  },
5917
5982
  });
5918
5983
  noteSecondarySurface(unifyWin);
5984
+ trackClipWindow(unifyWin);
5919
5985
  session.win = unifyWin;
5920
5986
  unifyWin.loadFile(path.join(SCRIPT_DIR, 'editor.html'));
5921
5987
  unifyWin.once('ready-to-show', () => {
@@ -6717,10 +6783,10 @@ async function mcpHandleRequest(reqPath, payload, ctx = {}) {
6717
6783
  // Re-check after the (possibly long) approval await: if AI access was toggled
6718
6784
  // off while the modal was open, refuse before performing the action.
6719
6785
  if (!settings.ai_access_enabled) throw new Error('ai_access_disabled');
6720
- return mcpExecute(tool, args);
6786
+ return mcpExecute(tool, args, ctx);
6721
6787
  }
6722
6788
 
6723
- function mcpExecute(tool, args) {
6789
+ function mcpExecute(tool, args, ctx = {}) {
6724
6790
  switch (tool) {
6725
6791
  case 'read_clip': {
6726
6792
  const item = findHistoryItem(args.id);
@@ -6731,7 +6797,8 @@ function mcpExecute(tool, args) {
6731
6797
  return { ...mcpCore.fullTextResult(item, sharedSet), viaApproval: true };
6732
6798
  }
6733
6799
  case 'search_all':
6734
- return mcpCore.searchClips(history, settings, { query: args.query, regex: !!args.regex, scope: 'all', limit: args.limit || mcpCore.DEFAULT_LIST_LIMIT });
6800
+ // A /regex/ runs on a worker thread: it must never hang this process.
6801
+ return mcpSearchThread.searchClipsOffThread(history, settings, { query: args.query, regex: !!args.regex, scope: 'all', limit: args.limit || mcpCore.DEFAULT_LIST_LIMIT }, { signal: ctx.signal });
6735
6802
  case 'image_path': {
6736
6803
  const item = findHistoryItem(args.id);
6737
6804
  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.112",
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,
@@ -316,7 +316,13 @@
316
316
  display: inline-flex; align-items: center; gap: var(--sp-1); vertical-align: top; font-family: inherit; }
317
317
  .filter-tag.icon-filter { min-width: var(--ctl-sm); height: var(--ctl-sm); justify-content: center; padding: 0 var(--sp-2); }
318
318
  .filter-tag:hover { background: var(--hover); color: var(--text); }
319
- .filter-tag.group-tag, .filter-tag.group-tag:hover { color: var(--accent-text); }
319
+ /* A group name reads in the accent text shade wherever it shows: its chip, a
320
+ clip's meta line and window bar, every group picker and chip dropdown row,
321
+ Settings > Groups (owner 2026-10-09 on the chips: "too hard to see before
322
+ selected"; 2026-10-13: "blue tags should be consistent everywhere"). Hover
323
+ keeps the colour (the wash says hover); a picked chip's fill and an excluded
324
+ name's strike override it. */
325
+ .filter-tag.group-tag, .filter-tag.group-tag:hover, .meta-tags > .meta-tag, .bc-menu-item.group-name > .bc-menu-label, .group-row .si-name { color: var(--accent-text); }
320
326
  .filter-tag.active, .filter-tag.active:hover { background: var(--accent); color: var(--active-fg); --icon-fill: 1; }
321
327
  .filter-tag.active:hover { background: var(--accent-hover); }
322
328
  .filter-tag.excluded { color: var(--text-dim); text-decoration: line-through; text-decoration-thickness: 1px; opacity: .6; }
@@ -352,6 +358,9 @@
352
358
  .bc-scroll-fade, Core.createClipList); scroll-padding keeps the keyboard
353
359
  cursor out of the fades when it is scrolled into view. */
354
360
  .list { flex: 1; overflow-y: auto; padding: 0 0 var(--sp-4); min-height: 0; scroll-padding-block: 36px; }
361
+ /* A /regex/ search still running on the search worker: the rows on screen are the last answer, not this one. */
362
+ .list { transition: opacity var(--dur) var(--ease); }
363
+ .list.searching { opacity: .55; }
355
364
  .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
365
  /* Row states: hover = the --hover overlay; the keyboard cursor (the row Enter
357
366
  pastes) = --accent-bg; a candidate duplicate of the hovered or cursor row
@@ -411,7 +420,7 @@
411
420
  /* A group chip's dropdown holds menu rows (renderTagTreeMenuNode, depth > 0):
412
421
  an excluded group is struck through like its chip, a greyed one dims in the
413
422
  chips' two tiers, and a parent row stays lit while its submenu is out. */
414
- .bc-menu-item.excluded > .bc-menu-label { text-decoration: line-through; }
423
+ .bc-menu-item.excluded > .bc-menu-label { color: var(--text-dim); text-decoration: line-through; }
415
424
  .bc-menu-item.is-disabled { opacity: .4; cursor: default; }
416
425
  .bc-menu-item.is-disabled.dis-structural { opacity: .25; }
417
426
  .bc-menu-item.is-disabled:hover { background: none; }
@@ -491,7 +500,8 @@
491
500
  at the last name and the + follows it instead of the box's far edge. */
492
501
  .item:hover .meta-tag.meta-cut, .item:focus-within .meta-tag.meta-cut, .item.actions-held .meta-tag.meta-cut { display: none; }
493
502
  .meta-tag { display: block; flex: 0 1 auto; min-width: 0; max-width: 140px; overflow: hidden; text-overflow: ellipsis; }
494
- .meta-np:hover, .meta-tag:hover, .meta-ghost:hover { color: var(--text); background: var(--hover); }
503
+ .meta-np:hover, .meta-ghost:hover { color: var(--text); }
504
+ .meta-np:hover, .meta-tag:hover, .meta-ghost:hover { background: var(--hover); }
495
505
  .meta-ghost { display: inline-flex; align-items: center; justify-content: center; width: var(--meta-h); padding: 0; }
496
506
  .meta-ghost .mi { font-size: var(--icon-sm); }
497
507
  .meta > .meta-reveal, .bc-tag-strip > .meta-reveal { margin-left: calc(-1 * var(--sp-3)); --reveal-gap: var(--sp-3); }
@@ -220,7 +220,7 @@
220
220
  check: state === 'include',
221
221
  label: node.label || group,
222
222
  hint: disabled ? '' : String(count),
223
- cls: `group-filter-row${state === 'exclude' ? ' excluded' : ''}${disabled ? ` is-disabled${v.kind === 'structural' ? ' dis-structural' : ''}` : ''}`,
223
+ cls: `group-filter-row group-name${state === 'exclude' ? ' excluded' : ''}${disabled ? ` is-disabled${v.kind === 'structural' ? ' dis-structural' : ''}` : ''}`,
224
224
  attrs: { 'data-group': group, title, 'aria-disabled': disabled ? 'true' : null },
225
225
  };
226
226
  return hasChildren
@@ -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;
@@ -996,7 +1166,7 @@
996
1166
  const group = normalizeTagName(node && node.name);
997
1167
  if (!group) return '';
998
1168
  const [icon, checked] = MEMBERSHIP_GLYPH[state(group)] || MEMBERSHIP_GLYPH.none;
999
- const row = { icon, check: true, label: node.label || group, role: 'menuitemcheckbox', attrs: { 'data-action': action, 'data-group': group, 'aria-checked': checked, title: group } };
1169
+ const row = { icon, check: true, label: node.label || group, cls: 'group-name', role: 'menuitemcheckbox', attrs: { 'data-action': action, 'data-group': group, 'aria-checked': checked, title: group } };
1000
1170
  return node.children && node.children.length
1001
1171
  ? submenuNodeHtml(row, renderGroupChecklist(node.children, state, action))
1002
1172
  : menuRowHtml(row);
@@ -4816,18 +4986,19 @@
4816
4986
  refresh();
4817
4987
  return true;
4818
4988
  }
4819
- // The row's primary open button is the "normal" open: a hand-off (the
4820
- // popup closes, like before). Only the detached "..."/right-click MENU's
4821
- // Open in editor / Open image keeps the popup, alongside middle/alt-click.
4822
- const fromMenu = !!t.closest('.bc-menu-item');
4989
+ // A plain click on any Open (the row's own open button or the clip menu's
4990
+ // Open in editor / Open image) is a hand-off: the window takes the focus
4991
+ // and the popup closes. Only the "open several" gestures keep the popup:
4992
+ // middle-click, right-click on the open button, Alt+click (owner
4993
+ // 2026-10-13: "when i click on note, bc doesnt close... inconsisntent").
4823
4994
  const openImg = t.closest('[data-action="open-img"]');
4824
- if (openImg) { event.stopPropagation(); await a.openImage(a.itemById(openImg.dataset.id), fromMenu ? KEEP_POPUP : undefined); return true; }
4995
+ if (openImg) { event.stopPropagation(); await a.openImage(a.itemById(openImg.dataset.id)); return true; }
4825
4996
  const openImgExt = t.closest('[data-action="open-img-ext"]');
4826
4997
  if (openImgExt) { event.stopPropagation(); if (a.openImageExternal) await a.openImageExternal(a.itemById(openImgExt.dataset.id)); return true; }
4827
4998
  const saveImg = t.closest('[data-action="save-img"]');
4828
4999
  if (saveImg) { event.stopPropagation(); toast(await a.saveImage(a.itemById(saveImg.dataset.id))); return true; }
4829
5000
  const edit = t.closest('[data-action="edit"]');
4830
- if (edit) { event.stopPropagation(); await a.editClip(edit.dataset.id, edit.closest('.item'), fromMenu ? KEEP_POPUP : undefined); return true; }
5001
+ if (edit) { event.stopPropagation(); await a.editClip(edit.dataset.id, edit.closest('.item')); return true; }
4831
5002
  const rename = t.closest('[data-action="rename"]');
4832
5003
  if (rename) { event.stopPropagation(); await renameClip(rename.dataset.id); return true; }
4833
5004
  const revert = t.closest('[data-action="revert"]');
@@ -5784,14 +5955,30 @@
5784
5955
  findHl.innerHTML = `${html}\n`;
5785
5956
  syncHlScroll();
5786
5957
  }
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();
5958
+ // A regex runs on the search worker (createSearchWorker): one pattern can
5959
+ // backtrack for minutes on a long line, and the page's thread cannot stop
5960
+ // it. A plain word is a linear scan, done here. `after` runs once the
5961
+ // matches are in (at once for a word, on the worker's answer for a regex).
5962
+ let findWorker = null;
5963
+ function recomputeMatches(after) {
5964
+ const query = findInput.value;
5965
+ const term = query ? Search.compileTerm(query, { regex: findRegex, caseSensitive: findCase }) : null;
5966
+ const apply = (list) => {
5967
+ matches = list;
5968
+ findCount.title = term && !term.valid ? `Not a valid regular expression: ${term.error}.` : '';
5969
+ findBar.classList.toggle('bad-pattern', !!(term && !term.valid));
5970
+ if (!matches.length) { findIdx = -1; findCount.textContent = term && !term.valid ? 'Invalid' : query ? '0/0' : ''; }
5971
+ else if (findIdx < 0 || findIdx >= matches.length) findIdx = 0;
5972
+ renderFindHighlights();
5973
+ if (after) after();
5974
+ };
5975
+ if (term && term.valid && term.regex) {
5976
+ if (!findWorker) findWorker = createSearchWorker({ onBusy: (busy) => { if (busy) findCount.textContent = '...'; } });
5977
+ findWorker.find({ text: area.value, query, regex: true, caseSensitive: findCase }, (r) => apply(r.matches || []));
5978
+ return;
5979
+ }
5980
+ if (findWorker) findWorker.cancel();
5981
+ apply(term ? term.all(area.value) : []);
5795
5982
  }
5796
5983
  function step(dir) {
5797
5984
  if (!matches.length) return;
@@ -5805,8 +5992,7 @@
5805
5992
  if (opt.regex != null) findRegex = !!opt.regex;
5806
5993
  updateFindButtons();
5807
5994
  findIdx = -1;
5808
- recomputeMatches();
5809
- selectMatch({ preserveFocus: true });
5995
+ recomputeMatches(() => selectMatch({ preserveFocus: true }));
5810
5996
  findInput.focus();
5811
5997
  if (opt.select !== false) findInput.select();
5812
5998
  }
@@ -5817,8 +6003,7 @@
5817
6003
  if (sel && !sel.includes('\n')) findInput.value = sel.slice(0, 120);
5818
6004
  updateFindButtons();
5819
6005
  findIdx = -1;
5820
- recomputeMatches();
5821
- selectMatch({ preserveFocus: true });
6006
+ recomputeMatches(() => selectMatch({ preserveFocus: true }));
5822
6007
  findInput.focus();
5823
6008
  findInput.select();
5824
6009
  }
@@ -5843,12 +6028,12 @@
5843
6028
  else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'f') { e.preventDefault(); openFind(); }
5844
6029
  else if (e.key === 'Enter') { e.preventDefault(); commit(); area.focus(); } // done naming: back to the text
5845
6030
  });
5846
- findInput.addEventListener('input', () => { findIdx = -1; recomputeMatches(); selectMatch({ preserveFocus: true }); });
6031
+ findInput.addEventListener('input', () => { findIdx = -1; recomputeMatches(() => selectMatch({ preserveFocus: true })); });
5847
6032
  findInput.addEventListener('keydown', (e) => {
5848
6033
  if (e.key === 'Enter') { e.preventDefault(); step(e.shiftKey ? -1 : 1); }
5849
6034
  else if (e.key === 'Escape') { e.preventDefault(); closeFind(); }
5850
6035
  });
5851
- const refind = () => { updateFindButtons(); findIdx = -1; recomputeMatches(); selectMatch({ preserveFocus: true }); };
6036
+ const refind = () => { updateFindButtons(); findIdx = -1; recomputeMatches(() => selectMatch({ preserveFocus: true })); };
5852
6037
  // The shared chord toggles: Regex (Alt+R) and match case (Alt+C).
5853
6038
  const findRegexToggle = attachToggle(findRegexBtn, {
5854
6039
  label: 'Regex',
@@ -5923,7 +6108,7 @@
5923
6108
  revert,
5924
6109
  // A host that drops the editor without closing its window (the demo's
5925
6110
  // overlay) lets go of its chords.
5926
- destroy() { findRegexToggle.destroy(); findCaseToggle.destroy(); },
6111
+ destroy() { findRegexToggle.destroy(); findCaseToggle.destroy(); if (findWorker) findWorker.dispose(); },
5927
6112
  };
5928
6113
  }
5929
6114
  // Shared in-app IMAGE VIEWER — the image twin of createEditor, mounted by the
@@ -6920,6 +7105,7 @@
6920
7105
  createImageZoom,
6921
7106
  createClipController,
6922
7107
  findAllMatches,
7108
+ createSearchWorker,
6923
7109
  editorFindFor,
6924
7110
  flipChildren,
6925
7111
  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
+ };