@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 +42 -9
- package/lib/mcp-search-thread.js +58 -0
- package/main.js +26 -3
- package/mcp/boardclip-mcp.js +10 -5
- package/package.json +1 -1
- package/site/index.html +32 -6
- package/site/shared/clip-search.js +34 -0
- package/site/shared/clipboard-popup.css +3 -0
- package/site/shared/clipboard-ui-core.js +200 -15
- package/site/shared/search-worker.js +33 -0
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
|
-
|
|
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 =
|
|
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(
|
|
256
|
-
queryKey:
|
|
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,
|
|
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
|
-
|
|
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');
|
package/mcp/boardclip-mcp.js
CHANGED
|
@@ -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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
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:
|
|
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(
|
|
516
|
-
queryKey:
|
|
517
|
-
emptyHtml: () => Core.renderEmptyState({ total: clips.length, query:
|
|
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
|
-
|
|
5788
|
-
|
|
5789
|
-
|
|
5790
|
-
|
|
5791
|
-
|
|
5792
|
-
|
|
5793
|
-
|
|
5794
|
-
|
|
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()
|
|
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()
|
|
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
|
+
};
|