@tobq/boardclip 2.1.109 → 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/clipboard-model.js +18 -3
- package/lib/mcp-search-thread.js +58 -0
- package/lib/notifications.js +83 -0
- package/main.js +145 -37
- 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
|
package/lib/clipboard-model.js
CHANGED
|
@@ -274,6 +274,13 @@ function conflictSnapshot(item, fallback = {}) {
|
|
|
274
274
|
};
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
+
// Two sides with the same text and title leave nothing to merge.
|
|
278
|
+
function withoutIdenticalSides(conflict) {
|
|
279
|
+
const { left, right } = conflict || {};
|
|
280
|
+
if (left && right && left.text === right.text && left.title === right.title) return null;
|
|
281
|
+
return conflict;
|
|
282
|
+
}
|
|
283
|
+
|
|
277
284
|
function titleConflict(localItem, remoteItem) {
|
|
278
285
|
if (!localItem || !remoteItem || localItem.type === 'image' || remoteItem.type === 'image') return false;
|
|
279
286
|
if (itemKey(localItem) !== itemKey(remoteItem)) return false;
|
|
@@ -890,7 +897,15 @@ function applyTextEdit(items, options = {}) {
|
|
|
890
897
|
ensureItemId(item);
|
|
891
898
|
}
|
|
892
899
|
|
|
893
|
-
|
|
900
|
+
let currentIndex = id ? history.findIndex(item => itemKey(item) === id) : -1;
|
|
901
|
+
// A base edited since this edit began (in another window or on another
|
|
902
|
+
// device) is gone under its old id. Its newest version is found through the
|
|
903
|
+
// edit lineage, so the fork below carries a conflict record against it and
|
|
904
|
+
// the two can be merged, instead of a second copy with nothing to merge.
|
|
905
|
+
if (currentIndex < 0 && id && options.supersedes) {
|
|
906
|
+
const headId = supersedeMap(options.supersedes).get(id);
|
|
907
|
+
if (headId) currentIndex = history.findIndex(item => item.type !== 'image' && itemKey(item) === headId);
|
|
908
|
+
}
|
|
894
909
|
const current = currentIndex >= 0 ? history[currentIndex] : null;
|
|
895
910
|
const originalTitle = titleEditRequested
|
|
896
911
|
? (options.originalTitle !== undefined ? cleanTitle(options.originalTitle) : titleOf(current))
|
|
@@ -949,13 +964,13 @@ function applyTextEdit(items, options = {}) {
|
|
|
949
964
|
dedupeNumpadSlots(history);
|
|
950
965
|
const titleMergeConflict = titleConflict(existingBefore, edited);
|
|
951
966
|
const conflict = current && (!canUpdateCurrent || titleDiverged || titleMergeConflict)
|
|
952
|
-
? {
|
|
967
|
+
? withoutIdenticalSides({
|
|
953
968
|
kind: titleDiverged || titleMergeConflict ? 'title' : 'editor',
|
|
954
969
|
base: { id, type: 'text', text: originalText, title: originalTitle },
|
|
955
970
|
left: titleMergeConflict ? existingBefore : (currentBefore || existingBefore),
|
|
956
971
|
right: conflictSnapshot(edited, { text: newText, title: newTitle }),
|
|
957
972
|
targetId: itemKey(existing),
|
|
958
|
-
}
|
|
973
|
+
})
|
|
959
974
|
: null;
|
|
960
975
|
return {
|
|
961
976
|
changed: true,
|
|
@@ -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,83 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// OS notifications with an optional action button, on Windows and macOS.
|
|
4
|
+
// - Windows: Electron's Notification takes a button only through custom toast
|
|
5
|
+
// XML. A foreground-activated button reaches the app as the notification's
|
|
6
|
+
// 'click' (Electron maps every activation to it), so the body and the button
|
|
7
|
+
// run the same action.
|
|
8
|
+
// - macOS: Electron's `actions` ('action' event). They show only when the app's
|
|
9
|
+
// notification style is Alerts; otherwise the body click is the way in.
|
|
10
|
+
// Clicking the notification runs the action everywhere: that is the generic
|
|
11
|
+
// path, and a button is the extra a platform adds on top.
|
|
12
|
+
|
|
13
|
+
function escapeXml(value) {
|
|
14
|
+
return String(value == null ? '' : value).replace(/[<>&"']/g, (ch) => ({
|
|
15
|
+
'<': '<', '>': '>', '&': '&', '"': '"', "'": ''',
|
|
16
|
+
})[ch]);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function toastXml({ title, body, action, silent = true }) {
|
|
20
|
+
return '<toast activationType="foreground" launch="open">'
|
|
21
|
+
+ '<visual><binding template="ToastGeneric">'
|
|
22
|
+
+ `<text>${escapeXml(title)}</text>`
|
|
23
|
+
+ (body ? `<text>${escapeXml(body)}</text>` : '')
|
|
24
|
+
+ '</binding></visual>'
|
|
25
|
+
+ (action ? `<actions><action content="${escapeXml(action)}" arguments="action" activationType="foreground"/></actions>` : '')
|
|
26
|
+
+ (silent ? '<audio silent="true"/>' : '')
|
|
27
|
+
+ '</toast>';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function notificationOptions({ title, body, action, silent = true, platform = process.platform } = {}) {
|
|
31
|
+
const options = { title: String(title || ''), body: String(body || ''), silent: !!silent };
|
|
32
|
+
if (action && platform === 'win32') options.toastXml = toastXml({ title, body, action, silent });
|
|
33
|
+
if (action && platform === 'darwin') options.actions = [{ type: 'button', text: String(action) }];
|
|
34
|
+
return options;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// show() keeps each notification referenced until it is acted on or closed:
|
|
38
|
+
// an unreferenced Notification can be garbage-collected before its click.
|
|
39
|
+
// A toast Windows rejects ('failed') is shown again without the button, so the
|
|
40
|
+
// message still arrives and its click still runs the action; onError hears of
|
|
41
|
+
// every failure (each one is a bug to fix, not a path to rely on).
|
|
42
|
+
function createNotifier({ Notification, platform = process.platform, onError = () => {} } = {}) {
|
|
43
|
+
const live = new Set();
|
|
44
|
+
const present = (options, act) => {
|
|
45
|
+
const notification = new Notification(options);
|
|
46
|
+
live.add(notification);
|
|
47
|
+
const done = () => { live.delete(notification); };
|
|
48
|
+
notification.on('click', () => { done(); act(); });
|
|
49
|
+
notification.on('action', () => { done(); act(); });
|
|
50
|
+
notification.on('close', done);
|
|
51
|
+
notification.on('failed', (_event, error) => {
|
|
52
|
+
done();
|
|
53
|
+
onError(new Error(String(error || 'notification failed')));
|
|
54
|
+
if (options.toastXml) {
|
|
55
|
+
const { toastXml, ...plain } = options;
|
|
56
|
+
try { present(plain, act); } catch (retryError) { onError(retryError); }
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
notification.show();
|
|
60
|
+
return notification;
|
|
61
|
+
};
|
|
62
|
+
return {
|
|
63
|
+
show({ title, body, action, onAction, silent = true } = {}) {
|
|
64
|
+
try {
|
|
65
|
+
if (!Notification || typeof Notification.isSupported !== 'function' || !Notification.isSupported()) return null;
|
|
66
|
+
const withAction = typeof onAction === 'function';
|
|
67
|
+
let acted = false;
|
|
68
|
+
const act = () => {
|
|
69
|
+
if (!withAction || acted) return;
|
|
70
|
+
acted = true;
|
|
71
|
+
try { onAction(); } catch (error) { onError(error); }
|
|
72
|
+
};
|
|
73
|
+
return present(notificationOptions({ title, body, action: withAction ? action : '', silent, platform }), act);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
onError(error);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
liveCount: () => live.size,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { escapeXml, toastXml, notificationOptions, createNotifier };
|
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');
|
|
@@ -49,6 +50,7 @@ const { ControlServer } = require('./lib/control-server');
|
|
|
49
50
|
const windowsConsole = require('./lib/windows-console');
|
|
50
51
|
const windowsDwm = require('./lib/windows-dwm');
|
|
51
52
|
const appearance = require('./lib/appearance');
|
|
53
|
+
const { createNotifier } = require('./lib/notifications');
|
|
52
54
|
|
|
53
55
|
function guardBrokenPipe(stream) {
|
|
54
56
|
try {
|
|
@@ -155,6 +157,9 @@ const HISTORY_BACKUP_MAX_BYTES = 1024 * 1024 * 1024;
|
|
|
155
157
|
const HISTORY_BACKUP_MAX_MANIFESTS = 2000;
|
|
156
158
|
const HISTORY_BACKUP_MIN_INTERVAL_MS = 60 * 1000;
|
|
157
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;
|
|
158
163
|
const IMAGE_ORPHAN_RECOVERY_MAX_FILES = 20;
|
|
159
164
|
const SYNC_REMOTE_WRITE_TIMEOUT_MS = 8000;
|
|
160
165
|
const SYNC_PROVIDER_READ_TIMEOUT_MS = 12000;
|
|
@@ -931,6 +936,7 @@ function applyExternalTextEdit({ id, originalText, originalTitle, sourceGroups,
|
|
|
931
936
|
newTitle,
|
|
932
937
|
sourceGroups,
|
|
933
938
|
groupTombstones: settings.group_tombstones,
|
|
939
|
+
supersedes: settings.supersedes,
|
|
934
940
|
now: Date.now(),
|
|
935
941
|
ignoreBlank: true,
|
|
936
942
|
});
|
|
@@ -4387,12 +4393,30 @@ function createPopup() {
|
|
|
4387
4393
|
onFailed(null, null, error && error.message);
|
|
4388
4394
|
}
|
|
4389
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;
|
|
4390
4402
|
win.webContents.on('unresponsive', () => {
|
|
4391
4403
|
diagnostics.record('popup.renderer_unresponsive_native', {
|
|
4392
4404
|
visible: isPopupOpen(),
|
|
4393
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);
|
|
4394
4417
|
});
|
|
4395
4418
|
win.webContents.on('responsive', () => {
|
|
4419
|
+
if (popupHangTimer) { clearTimeout(popupHangTimer); popupHangTimer = null; }
|
|
4396
4420
|
diagnostics.record('popup.renderer_responsive_native', {
|
|
4397
4421
|
visible: isPopupOpen(),
|
|
4398
4422
|
}, { forceFile: true });
|
|
@@ -5079,11 +5103,10 @@ function refreshTray() {
|
|
|
5079
5103
|
// prefix so they're retained but not resurrected).
|
|
5080
5104
|
// 5. Disk retention - finished drafts are kept (LRU-pruned) as a last resort.
|
|
5081
5105
|
//
|
|
5082
|
-
// editSessions: sessionId -> session
|
|
5083
|
-
//
|
|
5084
|
-
//
|
|
5106
|
+
// editSessions: sessionId -> session (one window per note: findEditSessionForClip).
|
|
5107
|
+
// A session re-anchors baseText/currentId after each commit so the chain stays
|
|
5108
|
+
// contiguous (in-place, not a fork).
|
|
5085
5109
|
const editSessions = new Map();
|
|
5086
|
-
const editWindowsByClip = new Map();
|
|
5087
5110
|
const conflictWindows = new Map();
|
|
5088
5111
|
const EDIT_DRAFT_RE = /^boardclip-edit-[0-9a-f]{12}-\d+(?:-\d+)?\.txt$/; // in-flight drafts only (legacy `-<ts>` and current `-<ts>-<seq>` names)
|
|
5089
5112
|
let editSessionSeq = 0;
|
|
@@ -5134,22 +5157,34 @@ function readDraftFile(sessionOrPath, fallback = {}) {
|
|
|
5134
5157
|
return editorPayloadFrom(raw, fallback);
|
|
5135
5158
|
}
|
|
5136
5159
|
|
|
5160
|
+
const notifier = createNotifier({
|
|
5161
|
+
Notification,
|
|
5162
|
+
onError: (error) => diagnostics.record('notification.error', { error: error && error.message }, { forceFile: true }),
|
|
5163
|
+
});
|
|
5164
|
+
|
|
5137
5165
|
function showEditNotification(title, body) {
|
|
5138
|
-
|
|
5139
|
-
if (Notification && Notification.isSupported && Notification.isSupported()) {
|
|
5140
|
-
new Notification({ title, body, silent: true }).show();
|
|
5141
|
-
}
|
|
5142
|
-
} catch {}
|
|
5166
|
+
notifier.show({ title, body });
|
|
5143
5167
|
}
|
|
5144
5168
|
|
|
5145
|
-
// Surface
|
|
5146
|
-
//
|
|
5169
|
+
// Surface a stale-based save so it is never mistaken for a clobber: the edit
|
|
5170
|
+
// was kept beside the note, and Merge (or a click on the notification) opens
|
|
5171
|
+
// the merge view that folds the two back into one note.
|
|
5147
5172
|
function notifyEditOutcome(result) {
|
|
5148
|
-
if (!result
|
|
5149
|
-
|
|
5150
|
-
|
|
5151
|
-
|
|
5152
|
-
|
|
5173
|
+
if (!result) return;
|
|
5174
|
+
const record = result.conflictRecord;
|
|
5175
|
+
if (record && record.kind === 'editor') {
|
|
5176
|
+
notifier.show({
|
|
5177
|
+
title: 'BoardClip - this note changed while you were editing',
|
|
5178
|
+
body: 'Your version is kept as a separate clip, so nothing is lost.',
|
|
5179
|
+
action: 'Merge',
|
|
5180
|
+
onAction: () => showConflict(record.id),
|
|
5181
|
+
});
|
|
5182
|
+
} else if (!record && result.reason === 'conflict_created') {
|
|
5183
|
+
showEditNotification(
|
|
5184
|
+
'BoardClip - saved as a new clip',
|
|
5185
|
+
'The clip you were editing was deleted, so your version is kept as a new clip.'
|
|
5186
|
+
);
|
|
5187
|
+
}
|
|
5153
5188
|
}
|
|
5154
5189
|
|
|
5155
5190
|
// --- Editor <-> clipboard follow (lib/clipboard-follow.js) ---
|
|
@@ -5218,6 +5253,7 @@ function commitEditSession(session, payload) {
|
|
|
5218
5253
|
notifyEditOutcome(result);
|
|
5219
5254
|
if (result.conflictRecord && session.win && !session.win.isDestroyed()) {
|
|
5220
5255
|
session.inConflict = true;
|
|
5256
|
+
session.conflictId = result.conflictRecord.id;
|
|
5221
5257
|
try { session.win.webContents.send('editor-conflict', conflictModel.normalizeConflictRecord(result.conflictRecord)); } catch {}
|
|
5222
5258
|
}
|
|
5223
5259
|
}
|
|
@@ -5415,9 +5451,6 @@ function createEditorWindow(session, presentOptions = {}) {
|
|
|
5415
5451
|
trackWindowBounds(editorWin, 'editor_bounds');
|
|
5416
5452
|
editorWin.on('closed', () => {
|
|
5417
5453
|
editSessions.delete(session.id);
|
|
5418
|
-
if (session.originalId != null && editWindowsByClip.get(session.originalId) === session.id) {
|
|
5419
|
-
editWindowsByClip.delete(session.originalId);
|
|
5420
|
-
}
|
|
5421
5454
|
// Safety net: commit the latest on-disk draft (the IPC commit may have raced
|
|
5422
5455
|
// the window teardown), then mark the draft finished so it's retained but not
|
|
5423
5456
|
// re-recovered, and prune. suppressCommit = the clip was deliberately deleted
|
|
@@ -5460,19 +5493,47 @@ function presentSecondaryWindow(targetWin, options = {}) {
|
|
|
5460
5493
|
hidePopup();
|
|
5461
5494
|
}
|
|
5462
5495
|
|
|
5496
|
+
// A note's id changes on every save (content-addressed), so its open editor is
|
|
5497
|
+
// found by the note's edit lineage, never by the id it opened with. Matching
|
|
5498
|
+
// only that id let a reopen start a second editor on the same note; the
|
|
5499
|
+
// first one's next save then left the second stale, and typing in it forked
|
|
5500
|
+
// the note.
|
|
5501
|
+
function findEditSessionForClip(id) {
|
|
5502
|
+
const key = String(id || '');
|
|
5503
|
+
if (!key) return null;
|
|
5504
|
+
const lineage = clipboardModel.supersedeMap(settings.supersedes);
|
|
5505
|
+
const head = (clipId) => lineage.get(clipId) || clipId;
|
|
5506
|
+
const target = head(key);
|
|
5507
|
+
for (const session of editSessions.values()) {
|
|
5508
|
+
if (!session.currentId || !session.win || session.win.isDestroyed()) continue;
|
|
5509
|
+
if (session.currentId === key || head(session.currentId) === target) return session;
|
|
5510
|
+
}
|
|
5511
|
+
return null;
|
|
5512
|
+
}
|
|
5513
|
+
|
|
5514
|
+
// The merge view of a conflict: the editor already showing it (an editor turns
|
|
5515
|
+
// into the merge view when its save conflicts), else a conflict window.
|
|
5516
|
+
function showConflict(conflictId) {
|
|
5517
|
+
const id = String(conflictId || '');
|
|
5518
|
+
for (const session of editSessions.values()) {
|
|
5519
|
+
if (session.conflictId === id && session.win && !session.win.isDestroyed()) {
|
|
5520
|
+
presentSecondaryWindow(session.win);
|
|
5521
|
+
return { ok: true };
|
|
5522
|
+
}
|
|
5523
|
+
}
|
|
5524
|
+
return openConflictWindow(id);
|
|
5525
|
+
}
|
|
5526
|
+
|
|
5463
5527
|
function openEditor(id, options = {}) {
|
|
5464
5528
|
if (id != null) {
|
|
5465
|
-
const
|
|
5466
|
-
if (
|
|
5467
|
-
|
|
5468
|
-
|
|
5469
|
-
|
|
5470
|
-
|
|
5471
|
-
|
|
5472
|
-
|
|
5473
|
-
} catch {}
|
|
5474
|
-
return;
|
|
5475
|
-
}
|
|
5529
|
+
const s = findEditSessionForClip(id);
|
|
5530
|
+
if (s) {
|
|
5531
|
+
try {
|
|
5532
|
+
presentSecondaryWindow(s.win, options);
|
|
5533
|
+
if (options && options.find) s.win.webContents.send('editor-find', { query: options.find, findRegex: !!options.findRegex });
|
|
5534
|
+
if (options && options.focusTitle) s.win.webContents.send('editor-find', { focusTitle: true });
|
|
5535
|
+
} catch {}
|
|
5536
|
+
return;
|
|
5476
5537
|
}
|
|
5477
5538
|
}
|
|
5478
5539
|
let baseText = '';
|
|
@@ -5522,7 +5583,6 @@ function openEditor(id, options = {}) {
|
|
|
5522
5583
|
session.follow.observe(editorNoteTexts(session));
|
|
5523
5584
|
writeDraftFile(session);
|
|
5524
5585
|
editSessions.set(session.id, session);
|
|
5525
|
-
if (originalId != null) editWindowsByClip.set(originalId, session.id);
|
|
5526
5586
|
createEditorWindow(session, options);
|
|
5527
5587
|
}
|
|
5528
5588
|
|
|
@@ -5649,6 +5709,51 @@ function clipWindowState(id) {
|
|
|
5649
5709
|
return { item, items, groups: [...(settings.groups || [])], aiGroup: mcpCore.AI_GROUP_NAME };
|
|
5650
5710
|
}
|
|
5651
5711
|
|
|
5712
|
+
// A clip by an id a conflict record holds, followed to its newest version
|
|
5713
|
+
// when it was edited since.
|
|
5714
|
+
function liveTextClip(id) {
|
|
5715
|
+
const key = String(id || '');
|
|
5716
|
+
if (!key) return null;
|
|
5717
|
+
const item = findHistoryItem(key) || findHistoryItem(clipboardModel.supersedeMap(settings.supersedes).get(key));
|
|
5718
|
+
return item && item.type !== 'image' ? item : null;
|
|
5719
|
+
}
|
|
5720
|
+
|
|
5721
|
+
// An editor conflict joins two clips: the note (left) and the edit kept beside
|
|
5722
|
+
// it (targetId). Resolving it makes them one note again.
|
|
5723
|
+
function conflictForkPair(record) {
|
|
5724
|
+
if (!record || record.kind !== 'editor') return null;
|
|
5725
|
+
const note = liveTextClip(record.left && record.left.id);
|
|
5726
|
+
const fork = liveTextClip(record.targetId || (record.right && record.right.id));
|
|
5727
|
+
return note && fork && note !== fork ? { note, fork } : null;
|
|
5728
|
+
}
|
|
5729
|
+
|
|
5730
|
+
// The resolved text went into the note, which keeps its pin, numpad key and
|
|
5731
|
+
// lineage. The fork folds into it like an edit: its groups (and a numpad key
|
|
5732
|
+
// the note lacks) join the note, and a supersede link sends a stale copy or
|
|
5733
|
+
// an open editor of it to the note.
|
|
5734
|
+
function foldForkInto(fork, note) {
|
|
5735
|
+
if (!fork || !note || fork === note) return;
|
|
5736
|
+
const forkIndex = history.indexOf(fork);
|
|
5737
|
+
if (forkIndex < 0) return; // the write already merged the two (same text)
|
|
5738
|
+
const forkId = itemKey(fork);
|
|
5739
|
+
const noteId = itemKey(note);
|
|
5740
|
+
const forkSlot = numpadSlotOf(fork);
|
|
5741
|
+
const noteGroups = groupsOf(note);
|
|
5742
|
+
const newGroups = groupsOf(fork).filter(group => !noteGroups.includes(group));
|
|
5743
|
+
if (newGroups.length) {
|
|
5744
|
+
const pin = ensurePin(note);
|
|
5745
|
+
pin.groups = [...(pin.groups || []), ...newGroups];
|
|
5746
|
+
touchPinGroups(note);
|
|
5747
|
+
}
|
|
5748
|
+
if (isPinned(fork) && !note.pin) ensurePin(note);
|
|
5749
|
+
deleteHistoryIndex(forkIndex, { tombstone: false });
|
|
5750
|
+
addTombstone(forkId, null);
|
|
5751
|
+
addSupersede({ from: forkId, to: noteId, updatedAt: Date.now() });
|
|
5752
|
+
saveSettingsFile();
|
|
5753
|
+
saveHistory();
|
|
5754
|
+
if (forkSlot != null && numpadSlotOf(note) == null) applyNumpadAssign(noteId, forkSlot, clipboardModel.clipRevision(note));
|
|
5755
|
+
}
|
|
5756
|
+
|
|
5652
5757
|
function applyConflictResolution(resolution) {
|
|
5653
5758
|
const payload = resolution && typeof resolution === 'object' ? resolution : {};
|
|
5654
5759
|
const conflictId = String(payload.id || '');
|
|
@@ -5661,9 +5766,11 @@ function applyConflictResolution(resolution) {
|
|
|
5661
5766
|
const text = write.text;
|
|
5662
5767
|
const title = snapshot ? titleOf(snapshot) : clipboardModel.titleOf({ title: write.title });
|
|
5663
5768
|
const targetId = record.targetId || snapshot && snapshot.id || record.right && record.right.id || record.left && record.left.id || '';
|
|
5664
|
-
const
|
|
5769
|
+
const fork = conflictForkPair(record);
|
|
5770
|
+
const target = fork ? fork.note : findHistoryItem(targetId);
|
|
5665
5771
|
if (target && target.type !== 'image') {
|
|
5666
|
-
applyTextEditToItem(target, { newText: text, newTitle: title });
|
|
5772
|
+
const written = applyTextEditToItem(target, { newText: text, newTitle: title });
|
|
5773
|
+
if (fork) foldForkInto(fork.fork, (written && written.item) || fork.note);
|
|
5667
5774
|
} else if (text.trim()) {
|
|
5668
5775
|
applyExternalTextEdit({
|
|
5669
5776
|
id: '',
|
|
@@ -6632,10 +6739,10 @@ async function mcpHandleRequest(reqPath, payload, ctx = {}) {
|
|
|
6632
6739
|
// Re-check after the (possibly long) approval await: if AI access was toggled
|
|
6633
6740
|
// off while the modal was open, refuse before performing the action.
|
|
6634
6741
|
if (!settings.ai_access_enabled) throw new Error('ai_access_disabled');
|
|
6635
|
-
return mcpExecute(tool, args);
|
|
6742
|
+
return mcpExecute(tool, args, ctx);
|
|
6636
6743
|
}
|
|
6637
6744
|
|
|
6638
|
-
function mcpExecute(tool, args) {
|
|
6745
|
+
function mcpExecute(tool, args, ctx = {}) {
|
|
6639
6746
|
switch (tool) {
|
|
6640
6747
|
case 'read_clip': {
|
|
6641
6748
|
const item = findHistoryItem(args.id);
|
|
@@ -6646,7 +6753,8 @@ function mcpExecute(tool, args) {
|
|
|
6646
6753
|
return { ...mcpCore.fullTextResult(item, sharedSet), viaApproval: true };
|
|
6647
6754
|
}
|
|
6648
6755
|
case 'search_all':
|
|
6649
|
-
|
|
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 });
|
|
6650
6758
|
case 'image_path': {
|
|
6651
6759
|
const item = findHistoryItem(args.id);
|
|
6652
6760
|
if (!item || item.type !== 'image') throw new Error('not_an_image');
|
|
@@ -6973,7 +7081,7 @@ function setupIPC() {
|
|
|
6973
7081
|
});
|
|
6974
7082
|
ipcMain.handle('resolve-conflict', (_, payload) => applyConflictResolution(payload));
|
|
6975
7083
|
ipcMain.handle('get-conflicts', () => conflictListForRenderer());
|
|
6976
|
-
ipcMain.handle('open-conflict', (_, id) =>
|
|
7084
|
+
ipcMain.handle('open-conflict', (_, id) => showConflict(id));
|
|
6977
7085
|
|
|
6978
7086
|
// Multi-select bulk operations.
|
|
6979
7087
|
ipcMain.handle('delete-items', (_, targets) => applyDeleteItems(targets));
|
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
|
+
};
|