@tobq/boardclip 0.0.0-stage → 2.1.107

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.
Files changed (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +170 -2
  3. package/assets/boardclip-icon.png +0 -0
  4. package/bin/boardclip.js +43 -0
  5. package/editor-preload.js +71 -0
  6. package/editor.html +220 -0
  7. package/icon.png +0 -0
  8. package/icon@2x.png +0 -0
  9. package/iconTemplate.png +0 -0
  10. package/iconTemplate@2x.png +0 -0
  11. package/index.html +1339 -0
  12. package/lib/appearance.js +183 -0
  13. package/lib/auto-update.js +306 -0
  14. package/lib/backup-worker.js +46 -0
  15. package/lib/backup.js +275 -0
  16. package/lib/blob-field-store.js +275 -0
  17. package/lib/blob-store.js +68 -0
  18. package/lib/build-info.js +89 -0
  19. package/lib/clip-blob-store.js +161 -0
  20. package/lib/clip-revision.js +81 -0
  21. package/lib/clipboard-capture.js +95 -0
  22. package/lib/clipboard-follow.js +98 -0
  23. package/lib/clipboard-model.js +1235 -0
  24. package/lib/cloud-accounts.js +250 -0
  25. package/lib/conflict-model.js +186 -0
  26. package/lib/control-client.js +87 -0
  27. package/lib/control-server.js +149 -0
  28. package/lib/diagnostics.js +121 -0
  29. package/lib/drag-files.js +48 -0
  30. package/lib/ensure-directory.js +56 -0
  31. package/lib/fork-names.js +35 -0
  32. package/lib/fs-probe.js +132 -0
  33. package/lib/history-feed.js +48 -0
  34. package/lib/hmac-auth.js +78 -0
  35. package/lib/macos-hotkey.js +297 -0
  36. package/lib/macos-paste.js +112 -0
  37. package/lib/mcp-core.js +270 -0
  38. package/lib/mcp-installers.js +284 -0
  39. package/lib/mcp-paths.js +99 -0
  40. package/lib/p2p-crypto.js +54 -0
  41. package/lib/p2p-discovery.js +191 -0
  42. package/lib/quick-paste.js +187 -0
  43. package/lib/retention.js +43 -0
  44. package/lib/sync-delta.js +227 -0
  45. package/lib/sync-journal.js +181 -0
  46. package/lib/sync-paths.js +73 -0
  47. package/lib/tailscale.js +80 -0
  48. package/lib/text-blob-store.js +38 -0
  49. package/lib/windows-clipboard.js +213 -0
  50. package/lib/windows-console.js +63 -0
  51. package/lib/windows-dwm.js +183 -0
  52. package/lib/windows-hook-worker.js +236 -0
  53. package/lib/windows-hook.js +91 -0
  54. package/lib/windows-paste.js +185 -0
  55. package/main.js +7540 -0
  56. package/mcp/boardclip-mcp.js +255 -0
  57. package/mcp-approval-preload.js +22 -0
  58. package/mcp-approval.html +218 -0
  59. package/package.json +55 -4
  60. package/preload.js +106 -0
  61. package/site/index.html +1624 -0
  62. package/site/shared/clip-search.js +2330 -0
  63. package/site/shared/clipboard-popup.css +1046 -0
  64. package/site/shared/clipboard-tokens.css +326 -0
  65. package/site/shared/clipboard-ui-core.js +6921 -0
  66. package/site/shared/clipboard-window.css +36 -0
  67. package/site/shared/vendor/cm5/README.md +52 -0
  68. package/site/shared/vendor/cm5/codemirror.css +344 -0
  69. package/site/shared/vendor/cm5/codemirror.js +9884 -0
  70. package/site/shared/vendor/cm5/diff-match-patch.js +2228 -0
  71. package/site/shared/vendor/cm5/merge.css +119 -0
  72. package/site/shared/vendor/cm5/merge.js +1182 -0
  73. package/site/shared/vendor/fonts/README.md +19 -0
  74. package/site/shared/vendor/fonts/material-symbols-rounded.woff2 +0 -0
  75. package/viewer-preload.js +51 -0
  76. package/viewer.html +129 -0
package/index.html ADDED
@@ -0,0 +1,1339 @@
1
+ <!DOCTYPE html>
2
+ <html><head><meta charset="utf-8"><title>Clipboard</title>
3
+ <script>
4
+ document.documentElement.dataset.theme = matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
5
+ </script>
6
+ <link rel="stylesheet" href="site/shared/clipboard-popup.css">
7
+ <link rel="stylesheet" href="site/shared/clipboard-window.css">
8
+ <style>
9
+ /* Tokens + every popup component style live in the shared clipboard-popup.css
10
+ (single source of truth with the website demo); the window base (reset,
11
+ font, background, glass scrim) in clipboard-window.css. Only the popup
12
+ shell's own layout stays here. */
13
+ body { display:flex; flex-direction:column; }
14
+ #popupShell { flex:1; min-height:0; height:100%; display:flex; flex-direction:column; overflow:hidden; }
15
+ </style></head><body>
16
+
17
+ <div id="popupShell"></div>
18
+
19
+ <!-- Confirm + prompt dialogs are created by the shared Core.createDialogs. -->
20
+ <div class="toast" id="toast">Copied</div>
21
+
22
+ <script src="site/shared/clip-search.js"></script>
23
+ <script src="site/shared/clipboard-ui-core.js"></script>
24
+ <script>
25
+ const Core = window.BoardClipCore;
26
+ document.getElementById('popupShell').innerHTML = Core.renderPopupShell({
27
+ settingsBodyHtml: Core.renderSettingsBody(),
28
+ headerActionsHtml: '<button class="icon-btn" id="newNoteBtn" type="button" title="New note" aria-label="New note"><span class="mi">edit_note</span></button>',
29
+ });
30
+ const listEl = document.getElementById('list');
31
+ const toast = document.getElementById('toast');
32
+ const searchEl = document.getElementById('search');
33
+ const countEl = document.getElementById('count');
34
+ let toastTimer, items = [], query = '';
35
+ let filtered = [];
36
+ let groups = [];
37
+ // The search box TEXT is the single source of truth. Facets (group:/is:/num:/since:…) live
38
+ // as tokens inside `query`; the chip bar's active/excluded state is DERIVED from it. No
39
+ // separate filter Sets (that dual-model was the drift Forge killed).
40
+ function parsedQuery() { return Core.search.parseQuery(query); }
41
+ function facetState() { return Core.search.facetState(parsedQuery()); }
42
+ function activeFilterSet() { return facetState().active; }
43
+ // Ranking override for the Best/Recent toggle: null = default (relevance while searching,
44
+ // history order when idle), 'best' | 'new' when the user has flipped it for this query.
45
+ let sortMode = null;
46
+ let controller = null;
47
+ let dataRevision = -1;
48
+ let diagnosticsEnabled = false;
49
+ // Per history revision (rebuildItemIndexes), never per keystroke: the search
50
+ // docs + lowercased haystacks for Core.filterItemIndexes.
51
+ let searchDocs = [], searchTextLower = [];
52
+ let itemLookup = new Map(), groupCounts = new Map(), builtinCounts = {}, numpadIdMap = {};
53
+
54
+ document.getElementById('newNoteBtn').addEventListener('click', () => {
55
+ window.api.newNote({ sourceGroups: Core.sourceGroupsFromFilters(activeFilterSet()) });
56
+ });
57
+
58
+ // Renderer exceptions used to vanish (a tray popup has no DevTools open): a thrown
59
+ // handler left the UI half-updated with nothing in the log. Route window errors +
60
+ // unhandled rejections into the diagnostics file (always written, not gated on the
61
+ // diagnostics toggle) so they sit next to the perf events.
62
+ Core.installRendererErrorReporting((details) => {
63
+ const promise = window.api.recordDiagnostics?.('error', details);
64
+ if (promise && promise.catch) promise.catch(() => {});
65
+ });
66
+
67
+ function recordRendererPerf(event, startedAt, details = {}, thresholdMs = 50) {
68
+ const ms = performance.now() - startedAt;
69
+ if (!diagnosticsEnabled && ms < thresholdMs) return ms;
70
+ const promise = window.api.recordDiagnostics?.(event, {
71
+ ...details,
72
+ ms: Math.round(ms),
73
+ items: items.length,
74
+ filtered: filtered.length,
75
+ query_length: query.length,
76
+ filters: (() => { const fs = facetState(); return fs.active.size + fs.excluded.size; })(),
77
+ slow: ms >= thresholdMs,
78
+ });
79
+ if (promise && promise.catch) promise.catch(() => {});
80
+ return ms;
81
+ }
82
+
83
+ let themeMode = 'system';
84
+ let systemDark = matchMedia('(prefers-color-scheme: dark)').matches;
85
+ function refreshTheme() {
86
+ Core.applyTheme(document.documentElement, themeMode, systemDark);
87
+ Core.setActiveThemeSeg(document.getElementById('themeMode'), themeMode);
88
+ }
89
+ function applyColorScheme(scheme) {
90
+ systemDark = scheme !== 'light';
91
+ refreshTheme();
92
+ }
93
+ function setThemeMode(mode) {
94
+ themeMode = (mode === 'light' || mode === 'dark') ? mode : 'system';
95
+ refreshTheme();
96
+ }
97
+ if (window.api.getColorScheme) window.api.getColorScheme().then(applyColorScheme).catch(() => {});
98
+ if (window.api.onColorSchemeChanged) window.api.onColorSchemeChanged(applyColorScheme);
99
+ // Appearance (surface, accent, density, corners, borders): the SAME payload
100
+ // main sends every window (appearanceVariantPayload: runtime_info.appearance at
101
+ // start, then 'appearance-changed'), so the popup can never render with other
102
+ // values than the editor / viewer / approval windows. Main already resolved it:
103
+ // the surface against OS support, borders gated on BOARDCLIP_DEBUG_VARIANTS
104
+ // (the tags-look-different-per-machine bug of 2026-09-02). All ride data-* on <html>
105
+ // through the shared Core.applyAppearance (the System / Custom accent included).
106
+ // A partial update (the surface alone) merges into the last full payload.
107
+ let currentLook = {};
108
+ let settingsUi = null; // Core.mountSettings, bound below
109
+ function applyAppearance(look) {
110
+ if (!look) return;
111
+ currentLook = { ...currentLook, ...look };
112
+ if (look.themeMode) setThemeMode(look.themeMode);
113
+ Core.applyAppearance(document.documentElement, currentLook);
114
+ if (settingsUi) settingsUi.update(appearanceControlsState(currentLook));
115
+ }
116
+ // The Settings > Appearance controls' state from a payload (the surface
117
+ // SETTING and glass support come from get-settings, see openSettings).
118
+ function appearanceControlsState(look) {
119
+ return { accentMode: look.accentMode, accentCustom: look.accentCustom, systemAccent: look.systemAccent, glassScope: look.glassScope, uiDensity: look.uiDensity, uiCorners: look.uiCorners };
120
+ }
121
+ if (window.api.getSettings) window.api.getSettings().then(s => {
122
+ if (!s) return;
123
+ if (s.theme_mode) setThemeMode(s.theme_mode);
124
+ const rt = s.runtime_info || {};
125
+ applyAppearance({ ...(rt.appearance || {}), surfaceStyle: rt.surface_style || 'solid' });
126
+ if (s.image_preview_height != null) imageZoom.load(s.image_preview_height);
127
+ searchBox.setOptionsHeight(s.options_panel_height);
128
+ // The saved Regex toggle and or / and apply from the first open.
129
+ searchBox.setRegex(!!s.regex_search, { load: true });
130
+ searchBox.setJoins(s.facet_joins);
131
+ }).catch(() => {});
132
+ if (window.api.onSurfaceChanged) window.api.onSurfaceChanged((style) => applyAppearance({ surfaceStyle: style === 'solid' ? 'solid' : 'glass' }));
133
+ if (window.api.onAppearanceChanged) window.api.onAppearanceChanged(applyAppearance);
134
+
135
+ // --- Helpers ---
136
+ function ago(ts) { return Core.ago(ts); }
137
+ function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
138
+ function providerName(acc) {
139
+ const provider = acc && acc.provider ? String(acc.provider) : 'cloud';
140
+ return provider.charAt(0).toUpperCase() + provider.slice(1);
141
+ }
142
+ function syncAccountLabel(acc) {
143
+ if (acc && acc.provider === 'custom') return `Custom folder · ${acc.label || acc.path}`;
144
+ return `${providerName(acc)} · ${acc.label || acc.email || acc.path}`;
145
+ }
146
+ // --- Pin model helpers ---
147
+ // item.pin: null/undefined = unpinned. Object = pinned. Shape: { number?: 1-9, groups?: string[] }
148
+ function isPinned(item) { return Core.isPinned(item); }
149
+ function numpadOf(item) { return Core.numpadOf(item); }
150
+ function groupsOf(item) { return Core.groupsOf(item); }
151
+ function itemId(item) { return Core.itemId(item); }
152
+ function itemById(id) { return itemLookup.get(id) || items.find(it => itemId(it) === id); }
153
+ function showToast(msg) { toast.textContent = msg||'Copied'; toast.classList.add('show'); clearTimeout(toastTimer); toastTimer = setTimeout(()=>toast.classList.remove('show'),1200); }
154
+
155
+ // Per-clip search data, keyed by the item OBJECT: a history delta keeps
156
+ // unchanged clips' objects, so only new or changed clips are lowercased and
157
+ // re-docced (a full rebuild lowercased all 70 MB, ~300 ms, on every refresh).
158
+ let searchIndexCache = new WeakMap();
159
+ function searchIndexFor(item) {
160
+ let entry = searchIndexCache.get(item);
161
+ if (!entry) {
162
+ const searchText = Core.itemSearchText(item);
163
+ entry = { hay: searchText.toLowerCase(), doc: Core.search.clipToDoc(item), bytes: searchText.length };
164
+ searchIndexCache.set(item, entry);
165
+ }
166
+ return entry;
167
+ }
168
+ function rebuildItemIndexes() {
169
+ const startedAt = performance.now();
170
+ searchDocs = [];
171
+ searchTextLower = [];
172
+ let searchTextBytes = 0;
173
+ let indexed = 0;
174
+ itemLookup = new Map();
175
+ groupCounts = new Map();
176
+ builtinCounts = { __pinned__: 0, __numbered__: 0, __images__: 0 };
177
+ numpadIdMap = {};
178
+
179
+ items.forEach((item) => {
180
+ const id = itemId(item);
181
+ if (id) itemLookup.set(id, item);
182
+ if (isPinned(item)) builtinCounts.__pinned__ += 1;
183
+ if (item && item.type === 'image') builtinCounts.__images__ += 1;
184
+ const slot = numpadOf(item);
185
+ if (slot != null) {
186
+ builtinCounts.__numbered__ += 1;
187
+ numpadIdMap[slot] = id;
188
+ }
189
+ groupsOf(item).forEach((group) => groupCounts.set(group, (groupCounts.get(group) || 0) + 1));
190
+ if (!searchIndexCache.has(item)) indexed += 1;
191
+ const entry = searchIndexFor(item);
192
+ searchTextBytes += entry.bytes;
193
+ searchTextLower.push(entry.hay);
194
+ searchDocs.push(entry.doc);
195
+ });
196
+ filterBarKey = ''; // counts may have changed
197
+ recordRendererPerf('indexes.rebuild', startedAt, {
198
+ search_text_bytes: searchTextBytes,
199
+ indexed,
200
+ groups: groupCounts.size,
201
+ numbered: builtinCounts.__numbered__ || 0,
202
+ images: builtinCounts.__images__ || 0,
203
+ }, 75);
204
+ }
205
+
206
+ // --- Numpad slot map ---
207
+ function numpadMap() {
208
+ return numpadIdMap;
209
+ }
210
+
211
+ // --- Group filter tags ---
212
+ // The bar depends on the facet tokens, whether there is any query text, the
213
+ // groups and the counts - NOT on the free text being typed, so typing a word
214
+ // no longer rebuilds it on every letter (~13 ms each on a 25-group bar).
215
+ // The availability census (which chips would show anything under the other
216
+ // filters) is cached on the filters, the history (searchDocs) and the minute:
217
+ // typing words never recomputes it. One cache for the chip bar AND the
218
+ // options panel.
219
+ const censusCache = Core.createCensusCache({ docs: () => searchDocs, groups: () => groups, joins: () => searchBox.getJoins(), onUpdate: () => renderGroupFilters() });
220
+ let filterBarKey = '';
221
+ let lastCensusId = 0;
222
+ function renderGroupFilters() {
223
+ const startedAt = performance.now();
224
+ const el = document.getElementById('groupFilters');
225
+ const fs = facetState();
226
+ const census = censusCache.get(parsedQuery());
227
+ // A new census (the history or the filters changed): the open options
228
+ // panel's chips follow it too.
229
+ if (census.id !== lastCensusId) { lastCensusId = census.id; queueMicrotask(() => { if (searchBox.isOptionsOpen()) searchBox.refresh(); }); }
230
+ const key = JSON.stringify([[...fs.active].sort(), [...fs.excluded].sort(), !!query, groups, census.id]);
231
+ if (key === filterBarKey) return;
232
+ filterBarKey = key;
233
+ // A chip that joins or leaves the selection glides to its place.
234
+ Core.flipChildren(el, () => { el.innerHTML = Core.renderFilterBar({ items, groups, activeFilters: fs.active, excludedFilters: fs.excluded, query, builtinCounts, groupCounts, census, joins: searchBox.getJoins() }); });
235
+ recordRendererPerf('filters.render_bar', startedAt, { groups: groups.length }, 40);
236
+ }
237
+
238
+ // The shared list keeps your place across every rebuild (Core.createClipList +
239
+ // the resolveListAnchor rules): typing, clearing, a sort flip, a new clip.
240
+ // searchRefineCache: the last result, so typing more of a term tests only the
241
+ // clips that matched before (Core.search.filterRankIndexes refinement rules).
242
+ // relaxCache: the same for the empty-result nudge (Core.search.bestRelaxation).
243
+ const searchRefineCache = {};
244
+ const relaxCache = {};
245
+ function applyFilter() {
246
+ const startedAt = performance.now();
247
+ const firstTerm = parsedQuery().terms.find(c => !c.regex && c.scope !== 'title');
248
+ previewTerm = firstTerm ? String(firstTerm.value).trim().toLowerCase() || null : null;
249
+ filtered = Core.filterItemIndexes(items, { query, docs: searchDocs, searchTextLower, sortMode, cache: searchRefineCache });
250
+ const rows = filtered;
251
+ const rowItems = items;
252
+ clipList.update({
253
+ ids: rows.map(ri => itemId(rowItems[ri])),
254
+ tsAt: (i) => Number(rowItems[rows[i]] && rowItems[rows[i]].ts) || 0,
255
+ mode: Core.search.rankMode(parsedQuery(), sortMode),
256
+ queryKey: query,
257
+ // Only when the list IS empty: the one filter whose removal brings clips back.
258
+ emptyHtml: () => Core.renderEmptyState({ total: items.length, query, groups, nudgeHtml: Core.renderRelaxNudge(Core.search.bestRelaxation(searchDocs, query, { searchTextLower, cache: relaxCache })) }),
259
+ });
260
+ recordRendererPerf('list.apply_filter', startedAt, { rendered: renderedCount(), regex: searchBox.getRegex() }, 60);
261
+ }
262
+ function renderedCount() { const w = clipList.window(); return w.end - w.start; }
263
+
264
+ let rerenderFrame = null;
265
+ let rerenderTimer = null;
266
+ function rerenderList() {
267
+ const startedAt = performance.now();
268
+ if (rerenderFrame) { cancelAnimationFrame(rerenderFrame); rerenderFrame = null; }
269
+ if (rerenderTimer) { clearTimeout(rerenderTimer); rerenderTimer = null; }
270
+ renderGroupFilters();
271
+ applyFilter();
272
+ updateCount();
273
+ updateSortButton(); // keeps the Best⇄Recent toggle in step with the query
274
+ recordRendererPerf('list.rerender', startedAt, { rendered: renderedCount() }, 90);
275
+ }
276
+
277
+ // Typing never waits for the search: the rebuild runs just AFTER the next
278
+ // paint (rAF, then a task), so the typed character is on screen first and the
279
+ // list follows a frame later. Keystrokes inside that window coalesce into one
280
+ // rebuild of the latest query.
281
+ function scheduleRerenderList() {
282
+ if (rerenderFrame || rerenderTimer) return;
283
+ rerenderFrame = requestAnimationFrame(() => {
284
+ rerenderFrame = null;
285
+ rerenderTimer = setTimeout(() => { rerenderTimer = null; rerenderList(); }, 0);
286
+ });
287
+ }
288
+
289
+ let relativeTimeTimer = null;
290
+ function scheduleRelativeTimeRefresh() {
291
+ if (relativeTimeTimer) clearTimeout(relativeTimeTimer);
292
+ const delay = Core.updateRelativeTimes(listEl);
293
+ if (delay) {
294
+ relativeTimeTimer = setTimeout(scheduleRelativeTimeRefresh, delay);
295
+ }
296
+ }
297
+
298
+ // Where the first search term sits in clip ri's body, read from its lowercase
299
+ // haystack (no scan of the body itself); null lets the preview search instead.
300
+ let previewTerm = null;
301
+ function previewMatchIndex(ri) {
302
+ if (!previewTerm || !searchDocs[ri]) return null;
303
+ return Core.search.bodyIndexOf(searchDocs[ri], searchTextLower[ri], previewTerm);
304
+ }
305
+ // A row's html (the shared list parses a whole batch of rows at once).
306
+ function buildItem(it, ri) {
307
+ // The selection state paints a full rebuild's cursor, checked, held and
308
+ // similar rows (it lives in the shared controller).
309
+ return Core.renderClipItem(it, {
310
+ imageSrc: item => `clip-img:///${esc(item.image)}`,
311
+ query,
312
+ matchIndex: previewMatchIndex(ri),
313
+ selection: controller ? controller.selection() : null,
314
+ actionsHtml: Core.renderClipActions(it),
315
+ });
316
+ }
317
+
318
+ function updateCount() {
319
+ const total = items.length;
320
+ const visible = filtered.length;
321
+ countEl.textContent = Core.itemCountLabel(total, visible, { query });
322
+ }
323
+
324
+ // The clear / sort / mode / options buttons slide in and out with the field's
325
+ // state (Core.attachSearchBox owns that); the app only keeps their titles.
326
+ function updateClearControls() {
327
+ searchClear.title = Core.hasActiveFilters({ query }) ? 'Clear search and filters' : 'Clear search';
328
+ }
329
+
330
+ function clearSearchAndFilters() {
331
+ query = ''; // clears content AND facet tokens (both live in the query)
332
+ sortMode = null;
333
+ if (typeof searchBox !== 'undefined' && searchBox) searchBox.setQuery('');
334
+ if (controller) controller.onQueryChange();
335
+ updateClearControls();
336
+ rerenderList();
337
+ }
338
+
339
+ window.focusSearch = function() {
340
+ searchEl.focus();
341
+ };
342
+
343
+ // --- Shared interaction controller ---
344
+ // The app drives the SAME Core.createClipController the website demo uses; the
345
+ // adapter maps each action to the exact window.api call the app already made, so
346
+ // behavior is preserved and the dispatch can no longer drift from the demo.
347
+ // Every clip mutation sends the rev of the item this popup rendered; main
348
+ // refuses it if the clip changed since (another device, window or AI edit).
349
+ const clipRevOf = (id) => { const it = itemById(id); return it ? it.rev : undefined; };
350
+ const revGuard = (run, fallback) => Core.guardRevision(run, { toast: showToast, refresh: () => refresh({ force: true }), fallback });
351
+ const appAdapter = {
352
+ itemById: (id) => itemById(id),
353
+ numpadMap: () => numpadMap(),
354
+ protectedGroups: () => new Set([aiGroupName]),
355
+ // The query TEXT is the source of truth: a chip click rewrites it through the
356
+ // controller (Search.applyFacet with the remembered or / and).
357
+ getQuery: () => query,
358
+ applyFacet: (token, intent) => searchBox.applyFacet(token, intent),
359
+ setConnective: (dim, conn) => searchBox.setConnective(dim, conn),
360
+ clearFilters: () => clearSearchAndFilters(),
361
+ // The empty-result nudge rewrites the whole query (one filter dropped).
362
+ setQuery: (next) => {
363
+ query = String(next || '');
364
+ if (typeof searchBox !== 'undefined' && searchBox) searchBox.setQuery(query);
365
+ updateClearControls();
366
+ if (controller) controller.onQueryChange();
367
+ },
368
+ focusSearch: () => searchEl.focus(),
369
+ closeSearchOptions: () => searchBox.closeOptions(),
370
+ pin: (id) => revGuard(() => window.api.pin(id, clipRevOf(id)), false),
371
+ numpadAssign: (id, slot) => revGuard(() => window.api.numpadAssign(id, slot, clipRevOf(id)), false),
372
+ numpadUnassign: (slot) => window.api.numpadUnassign(slot),
373
+ toggleGroup: (id, group) => revGuard(() => window.api.groupAssign(id, group, clipRevOf(id)), false), // applyGroupAssign toggles
374
+ createGroup: async (name) => { await window.api.groupCreate(name); await refreshGroups({ render: false }); },
375
+ deleteGroup: async (group) => {
376
+ // Drop every group: / -group: token of the deleted group from the query.
377
+ query = Core.search.stripFacet(query, { kind: 'group', value: group });
378
+ if (typeof searchBox !== 'undefined' && searchBox) searchBox.setQuery(query);
379
+ await window.api.groupDelete(group);
380
+ await refreshGroups({ render: false });
381
+ },
382
+ deleteClip: (id) => revGuard(() => window.api.deleteItem(id, clipRevOf(id)), false),
383
+ clearUnpinned: () => window.api.deleteAll(),
384
+ setGroupSharedAi: (group) => window.api.setGroupSharedAi(group, !aiSharedGroups.has(group)),
385
+ // Always the id-based path: window.api.copy() only takes a string, so it
386
+ // would silently drop the clip's html/rtf. paste(id) puts every captured
387
+ // format on the clipboard for both text and image clips.
388
+ copyNumpadSlot: (id) => { const it = itemById(id); if (!it) return; return window.api.paste(id); },
389
+ activateClip: (id) => window.api.pasteAndHide(id),
390
+ editClip: (id, _itemEl, options) => window.api.openEditor(id, { ...Core.editorFindFor(query), ...(options || {}) }),
391
+ setClipTitle: (id, title) => revGuard(() => window.api.setClipTitle(id, title, clipRevOf(id)), false),
392
+ openImage: (item, options) => window.api.openImage(itemId(item), options || {}),
393
+ openImageExternal: (item) => window.api.openImageExternal(itemId(item)),
394
+ // Image rows drag out as real files: cancel the page's own drag and let
395
+ // main run a native file drag (temporary copies named after the clips).
396
+ dragImages: (ids, event) => { event.preventDefault(); window.api.startDrag(ids); return true; },
397
+ saveImage: async (item) => { const d = await window.api.copyImagePath(itemId(item)); return d && d.path ? 'Path copied' : null; },
398
+ isSettingsOpen: () => document.getElementById('settingsView').classList.contains('show'),
399
+ closeSettings: () => closeSettings(),
400
+ hidePopup: () => window.api.hidePopup(),
401
+ // --- Multi-select contract (selection state lives in the controller) ---
402
+ visibleIds: () => clipList.ids(),
403
+ firstVisibleId: () => clipList.firstVisibleId(),
404
+ allItems: () => items,
405
+ // Similar clips: a clip too big to normalise is searched through its
406
+ // lowercase search haystack (already built for search, no second copy).
407
+ lowerOf: (item) => searchIndexFor(item).hay,
408
+ groupNames: () => groups,
409
+ // The shared list renders the focused row on demand, then paints.
410
+ renderSelection: (state, opts) => clipList.paintSelection(state, opts),
411
+ deleteClips: (ids) => revGuard(() => window.api.deleteItems(Core.revisionTargets(ids, clipRevOf)), []), // returns restorable snapshots
412
+ restoreClips: (snaps) => window.api.restoreItems(snaps), // re-adds + clears tombstones
413
+ groupAssignMany: (ids, group, shouldHave) => revGuard(() => window.api.groupAssignMany(Core.revisionTargets(ids, clipRevOf), group, shouldHave), false),
414
+ pasteMany: (ids) => window.api.pasteMany(ids),
415
+ startUnify: (ids) => window.api.startUnify(ids),
416
+ offerUndo: ({ count, undo }) => Core.showActionToast(toast, {
417
+ message: `Deleted ${count} clip${count === 1 ? '' : 's'}`,
418
+ actionLabel: 'Undo',
419
+ onAction: undo,
420
+ }),
421
+ toast: (msg) => showToast(msg),
422
+ render: () => rerenderList(), // view-only re-render (expand/filter): no IPC
423
+ refresh: async () => { await refresh({ force: true }); if (document.getElementById('settingsView').classList.contains('show')) openSettings(); }, // after a mutation: re-fetch history THEN re-render settings if open (await so the settings panel reads rebuilt indexes, not stale ones)
424
+ };
425
+ controller = Core.createClipController(appAdapter);
426
+ const appDialogs = controller.dialogs;
427
+ const clipList = Core.createClipList({
428
+ listEl,
429
+ newestEl: document.getElementById('listNewest'),
430
+ barEl: document.getElementById('selectionBar'),
431
+ renderRow: (i) => buildItem(items[filtered[i]], filtered[i]),
432
+ controller,
433
+ onRendered: () => scheduleRelativeTimeRefresh(),
434
+ });
435
+ // Image preview height: Ctrl+wheel / Ctrl+=/-/0 (routed by the controller) and
436
+ // the Settings row; saved per machine.
437
+ const imageZoom = Core.createImageZoom({
438
+ root: document.documentElement,
439
+ clipList,
440
+ save: (px) => window.api.saveSettings({ image_preview_height: px }),
441
+ toast: (msg) => showToast(msg),
442
+ });
443
+ appAdapter.imageZoom = imageZoom;
444
+ if (window.api.onImageZoomKey) window.api.onImageZoomKey((act) => imageZoom.applyKey(act));
445
+
446
+ document.addEventListener('click', (e) => controller.onClick(e));
447
+ document.addEventListener('mousedown', (e) => controller.onMousedown(e));
448
+ document.addEventListener('mouseup', (e) => controller.onMouseup(e));
449
+ document.addEventListener('auxclick', (e) => controller.onAuxclick(e));
450
+ document.addEventListener('contextmenu', (e) => controller.onContextmenu(e));
451
+ document.addEventListener('wheel', (e) => controller.onWheel(e), { passive: false });
452
+ document.addEventListener('dragstart', (e) => controller.onDragstart(e));
453
+ document.addEventListener('mouseover', (e) => controller.onMouseover(e));
454
+ document.addEventListener('mouseout', (e) => controller.onMouseout(e));
455
+
456
+ // Popup reset (called by the main process when the window is shown).
457
+ window.resetPopupState = function() {
458
+ appDialogs.dismiss();
459
+ controller.closeMenu(); // a popover left open at blur-hide would eat keys on re-show
460
+ controller.clearSelection({ paint: false });
461
+ controller.forgetPointer(); // no stale similar-clip tint from the last hover
462
+ clipList.scrollToTop(); // reopening always starts at the newest clip
463
+ document.getElementById('settingsView')?.classList.remove('show');
464
+ document.getElementById('mainView')?.classList.remove('hidden');
465
+ document.body.style.pointerEvents = '';
466
+ searchBox.closeOptions({ instant: true }); // every open starts with the options panel shut
467
+ window.focusSearch();
468
+ };
469
+
470
+ // Global numpad hotkey from the main process: assign the focused row's clip
471
+ // (inherits the shared replace-confirm via the controller).
472
+ window.assignNumpad = function(slot) {
473
+ const id = controller.focusedId();
474
+ if (id) controller.tryAssignNumpad(id, slot);
475
+ };
476
+
477
+ // Settings: the Appearance controls + the body's scroll fade, shared with the
478
+ // demo. Writes go through save-settings (validated in main, then broadcast
479
+ // back to every window as appearance-changed); a dragged hue previews here.
480
+ settingsUi = Core.mountSettings(document.getElementById('settingsView'), {
481
+ save: (body) => window.api.saveSettings(body),
482
+ preview: (look) => Core.applyAppearance(document.documentElement, { ...currentLook, ...look }),
483
+ });
484
+ // Clear all (Settings)
485
+ document.getElementById('clearAll').addEventListener('click', () => controller.clearAll());
486
+ document.getElementById('copyDiagnostics').addEventListener('click', async () => {
487
+ const diagnostics = await window.api.getSyncDiagnostics();
488
+ await window.api.copy(JSON.stringify(diagnostics, null, 2));
489
+ showToast('Diagnostics copied');
490
+ });
491
+
492
+ // --- Search ---
493
+ searchEl.addEventListener('keydown', e => {
494
+ if (query === '' && !e.altKey && !e.ctrlKey && !e.metaKey && e.key >= '1' && e.key <= '9') {
495
+ const n = +e.key;
496
+ const nmap = numpadMap();
497
+ if (n in nmap) {
498
+ e.preventDefault();
499
+ window.api.numpadPasteAndHide(n);
500
+ return;
501
+ }
502
+ }
503
+ });
504
+ const searchClear = document.getElementById('searchClear');
505
+ // Shared query-state change path (typed input AND autocomplete-applied tokens).
506
+ function onQueryChanged(v) {
507
+ query = v;
508
+ // The Best / Recent override ends with the search (an emptied box, however it was emptied).
509
+ if (!v.trim()) sortMode = null;
510
+ if (controller) controller.onQueryChange();
511
+ updateClearControls();
512
+ updateSortButton();
513
+ scheduleRerenderList();
514
+ }
515
+ // The shared search box (Core.attachSearchBox): live syntax highlighting, autocomplete, the
516
+ // options panel and the Regex toggle (it types /.../ for you); every change of the query
517
+ // reaches onQueryChanged, and the app hands it changes with searchBox.setQuery.
518
+ const searchBox = Core.attachSearchBox(searchEl, {
519
+ getGroups: () => groups,
520
+ getCensus: (parsed) => censusCache.get(parsed),
521
+ onChange: onQueryChanged,
522
+ // The Regex toggle and the remembered or / and: per device (local-only, never synced).
523
+ saveRegex: (on) => window.api.saveSettings({ regex_search: on }),
524
+ saveJoins: (joins) => window.api.saveSettings({ facet_joins: joins }),
525
+ // The options panel's height: per machine (dragged on its bottom edge).
526
+ saveOptionsHeight: (px) => window.api.saveSettings({ options_panel_height: px }),
527
+ });
528
+ searchClear.addEventListener('click', () => { clearSearchAndFilters(); searchEl.focus(); });
529
+
530
+ // --- Best⇄Recent sort override (visible only while searching; Best is the default) ---
531
+ const sortBtn = document.getElementById('sortBtn');
532
+ function effectiveSort() { return sortMode || 'best'; }
533
+ function updateSortButton() {
534
+ if (!query.trim()) return; // hidden without a query (Core.attachSearchBox)
535
+ Core.paintSortButton(sortBtn, effectiveSort());
536
+ }
537
+ sortBtn.addEventListener('click', () => {
538
+ sortMode = effectiveSort() === 'best' ? 'new' : 'best';
539
+ rerenderList(); // repaints the toggle via updateSortButton
540
+ });
541
+
542
+ // --- Keyboard nav (Escape cascade + arrows + Enter + multi-select, via the
543
+ // shared controller which now owns the selection/focus state) ---
544
+ document.addEventListener('keydown', (e) => controller.onKeydown(e));
545
+
546
+ // --- Settings ---
547
+ // macOS only: show close buttons (no reliable click-away on macOS)
548
+ if (window.api.platform === 'darwin') {
549
+ document.getElementById('closeBtn').classList.remove('hidden');
550
+ document.getElementById('settingsCloseBtn').classList.remove('hidden');
551
+ }
552
+ document.getElementById('settingsCloseBtn').addEventListener('click', () => window.api.hidePopup());
553
+ document.getElementById('closeBtn').addEventListener('click', () => window.api.hidePopup());
554
+ document.getElementById('settingsBtn').addEventListener('click', openSettings);
555
+ document.getElementById('settingsBack').addEventListener('click', closeSettings);
556
+ Core.attachWindowDrag(document.querySelector('#mainView .sticky'), { onClick: () => searchEl.focus(), move: window.api.windowDrag });
557
+ Core.attachWindowDrag(document.querySelector('#settingsView .settings-hdr'), { move: window.api.windowDrag });
558
+
559
+ let shortcutRecording = null;
560
+ let manualSyncInFlight = false;
561
+
562
+ function setManualSyncButtons(syncing) {
563
+ const buttons = [document.getElementById('syncHeaderBtn'), document.getElementById('syncNow')].filter(Boolean);
564
+ for (const button of buttons) {
565
+ button.disabled = syncing;
566
+ button.title = syncing ? 'Syncing...' : 'Sync now';
567
+ button.setAttribute('aria-label', button.title);
568
+ }
569
+ }
570
+
571
+ function manualSyncResultText(result) {
572
+ const ms = result && typeof result.ms === 'number' ? result.ms : 0;
573
+ const elapsed = ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
574
+ const changed = result && result.local_changed ? 'updated local' : result && result.wrote_remotes ? 'wrote cloud' : 'up to date';
575
+ return `Synced: ${changed} (${elapsed})`;
576
+ }
577
+
578
+ // The switch shows on / off; the help line says only what it adds (the peers
579
+ // are listed as rows under it).
580
+ function p2pStatusText(status) {
581
+ if (!status || !status.enabled) return '';
582
+ if (!status.running) return 'Starting...';
583
+ const peers = Array.isArray(status.peers) ? status.peers : [];
584
+ return peers.length ? `${peers.length} device${peers.length === 1 ? '' : 's'} nearby` : 'Looking for nearby BoardClip devices';
585
+ }
586
+
587
+ function renderP2PStatus(status) {
588
+ Core.setSettingHelp(document.getElementById('p2pStatus'), p2pStatusText(status), status && status.enabled && status.running ? 'active' : '');
589
+ renderP2PPeers(status);
590
+ }
591
+
592
+ function agoText(at) {
593
+ if (!at) return 'never';
594
+ const s = Math.max(0, Math.round((Date.now() - at) / 1000));
595
+ if (s < 60) return `${s}s ago`;
596
+ if (s < 3600) return `${Math.round(s / 60)}m ago`;
597
+ return `${Math.round(s / 3600)}h ago`;
598
+ }
599
+
600
+ // Per-peer rows (name, transport, address, last exchange, build) + the tailnet
601
+ // line, so "is the Mac paired, and over what?" is visible without a log.
602
+ function renderP2PPeers(status) {
603
+ const list = document.getElementById('p2pPeers');
604
+ if (!list) return;
605
+ const peers = status && Array.isArray(status.peers) ? status.peers : [];
606
+ list.innerHTML = peers.map((peer) => Core.renderSettingsItem({
607
+ title: peer.deviceName || 'Device',
608
+ meta: `${peer.transport === 'tailnet' ? 'Tailscale' : 'LAN'}${(peer.protocol || 1) >= 2 ? '' : ', older build (full-state sync)'}`,
609
+ sub: `${peer.host}:${peer.port} \u00b7 seen ${agoText(peer.lastSeen)}${peer.build ? ' \u00b7 ' + peer.build : ''}`,
610
+ subMono: true,
611
+ })).join('');
612
+ const tail = document.getElementById('p2pTailnet');
613
+ if (tail) {
614
+ const tailnet = status && status.tailnet;
615
+ if (!status || !status.enabled) tail.textContent = '';
616
+ else if (tailnet && tailnet.available) {
617
+ const online = (tailnet.peers || []).filter(node => node.online);
618
+ tail.textContent = `Tailscale: ${online.length} other device${online.length === 1 ? '' : 's'} online${online.length ? ' (' + online.slice(0, 3).map(node => node.name).join(', ') + ')' : ''}`;
619
+ } else tail.textContent = 'Tailscale CLI not found: LAN, synced addresses and pinned peers only';
620
+ }
621
+ }
622
+
623
+ let p2pStatusTimer = null;
624
+ function startP2PStatusRefresh() {
625
+ if (p2pStatusTimer) clearInterval(p2pStatusTimer);
626
+ p2pStatusTimer = setInterval(async () => {
627
+ if (!document.getElementById('settingsView').classList.contains('show')) { clearInterval(p2pStatusTimer); p2pStatusTimer = null; return; }
628
+ try { renderP2PStatus(await window.api.getP2PStatus()); } catch {}
629
+ }, 5000);
630
+ }
631
+
632
+ // Live coarse progress for long syncs (first sync after a pause can hydrate
633
+ // thousands of cloud files). Provider-level only — calm, no per-file spam.
634
+ function syncProviderLabel(p) {
635
+ const s = String(p || '');
636
+ if (/OneDrive/i.test(s)) return 'OneDrive';
637
+ if (/CloudDocs|iCloud/i.test(s)) return 'iCloud';
638
+ if (/GoogleDrive|My Drive|\\My Drive|Drive/i.test(s)) return 'Google Drive';
639
+ return 'cloud folder';
640
+ }
641
+
642
+ // The buttons (header + Settings) carry the live step in their tooltip and
643
+ // spin; the Settings help line under "Cloud folders" says only "Syncing..."
644
+ // while a pass runs, then goes back to its standing status (or "Sync failed").
645
+ let syncStandingStatus = null; // [text, state] the Settings render last wrote
646
+ function setSyncStanding(text, state) {
647
+ syncStandingStatus = [text, state];
648
+ Core.setSettingHelp(document.getElementById('syncStatus'), text, state);
649
+ }
650
+ window.api.onSyncProgress && window.api.onSyncProgress((p) => {
651
+ const buttons = [document.getElementById('syncHeaderBtn'), document.getElementById('syncNow')].filter(Boolean);
652
+ const syncStatus = document.getElementById('syncStatus');
653
+ const setButtons = (text, spinning) => {
654
+ for (const button of buttons) {
655
+ button.title = text;
656
+ button.setAttribute('aria-label', text);
657
+ button.classList.toggle('syncing', !!spinning);
658
+ }
659
+ };
660
+ // Background passes run every 30 s: the help line only moves while a cloud
661
+ // folder is on (its standing status is then 'active').
662
+ const showsSync = syncStatus && syncStandingStatus && syncStandingStatus[1] === 'active';
663
+ if (p.phase === 'start') setButtons('Syncing...', true);
664
+ else if (p.phase === 'read') setButtons(`Syncing ${p.index} of ${p.total}: ${syncProviderLabel(p.path)}...`, true);
665
+ else if (p.phase === 'write') setButtons('Syncing: writing the cloud folders...', true);
666
+ else if (p.phase === 'done') setButtons(p.ok ? 'Sync now' : 'Sync failed - retry', false);
667
+ if (!showsSync || manualSyncInFlight) return; // a manual sync writes its own result
668
+ if (p.phase === 'done') {
669
+ if (p.ok) Core.setSettingHelp(syncStatus, syncStandingStatus[0], syncStandingStatus[1]);
670
+ else Core.setSettingHelp(syncStatus, 'Sync failed', 'error');
671
+ } else if (p.phase === 'start') Core.setSettingHelp(syncStatus, 'Syncing...', 'active');
672
+ });
673
+
674
+ async function runManualSync({ showSettingsStatus = false } = {}) {
675
+ if (manualSyncInFlight) return;
676
+ manualSyncInFlight = true;
677
+ setManualSyncButtons(true);
678
+ const syncStatus = document.getElementById('syncStatus');
679
+ if (showSettingsStatus && syncStatus) {
680
+ Core.setSettingHelp(syncStatus, 'Syncing...', 'active');
681
+ }
682
+ try {
683
+ const result = await window.api.syncNow();
684
+ await refresh();
685
+ const statusText = manualSyncResultText(result);
686
+ if (showSettingsStatus && syncStatus) {
687
+ Core.setSettingHelp(syncStatus, statusText, 'active');
688
+ setTimeout(() => openSettings(), 500);
689
+ } else {
690
+ showToast(statusText);
691
+ }
692
+ } catch (error) {
693
+ if (showSettingsStatus && syncStatus) {
694
+ Core.setSettingHelp(syncStatus, 'Sync failed', 'error');
695
+ } else {
696
+ showToast('Sync failed');
697
+ }
698
+ } finally {
699
+ manualSyncInFlight = false;
700
+ setManualSyncButtons(false);
701
+ }
702
+ }
703
+
704
+ function shortcutLabel(accelerator) {
705
+ return String(accelerator || '')
706
+ .replace(/Globe|Fn/g, 'Globe')
707
+ .replace(/CommandOrControl/g, window.api.platform === 'darwin' ? 'Cmd/Ctrl' : 'Ctrl')
708
+ .replace(/Command/g, 'Cmd')
709
+ .replace(/Control/g, 'Ctrl')
710
+ .replace(/Super/g, window.api.platform === 'darwin' ? 'Cmd' : 'Win')
711
+ .replace(/\+/g, ' + ');
712
+ }
713
+
714
+ function shortcutStatusEl(kind) {
715
+ return document.getElementById(kind === 'quickPaste' ? 'quickPasteStatus' : 'shortcutStatus');
716
+ }
717
+
718
+ function shortcutRecordEl(kind) {
719
+ return document.getElementById(kind === 'quickPaste' ? 'quickPasteRecord' : 'shortcutRecord');
720
+ }
721
+
722
+ function shortcutResetEl(kind) {
723
+ return document.getElementById(kind === 'quickPaste' ? 'quickPasteReset' : 'shortcutReset');
724
+ }
725
+
726
+ function setShortcutStatus(kind, message, isError = false) {
727
+ Core.setSettingHelp(shortcutStatusEl(kind), message, isError ? 'error' : '');
728
+ }
729
+
730
+ function stopShortcutRecording({ resume = true } = {}) {
731
+ const kind = shortcutRecording;
732
+ shortcutRecording = null;
733
+ for (const button of [shortcutRecordEl('show'), shortcutRecordEl('quickPaste')]) {
734
+ if (button) button.classList.remove('recording');
735
+ }
736
+ if (resume && kind && window.api.resumeShortcuts) {
737
+ window.api.resumeShortcuts().catch(() => {});
738
+ }
739
+ return kind;
740
+ }
741
+
742
+ function renderShortcutSettings(settings, { clearStatus = true } = {}) {
743
+ const info = settings.shortcut_info || { show: 'CommandOrControl+Shift+V', custom: false };
744
+ const record = shortcutRecordEl('show');
745
+ const reset = shortcutResetEl('show');
746
+ record.textContent = shortcutLabel(info.show);
747
+ record.title = info.show;
748
+ record.classList.remove('recording');
749
+ reset.style.visibility = info.custom ? 'visible' : 'hidden';
750
+ const quickRecord = shortcutRecordEl('quickPaste');
751
+ const quickReset = shortcutResetEl('quickPaste');
752
+ const quickShortcut = info.quickPaste || '';
753
+ if (quickShortcut) {
754
+ quickRecord.textContent = `${shortcutLabel(quickShortcut).replace(/\s\+\s[1-9]$/, '')} + 1-9`;
755
+ quickRecord.title = `${quickShortcut.replace(/[1-9]$/, '1')} ... ${quickShortcut.replace(/[1-9]$/, '9')}`;
756
+ } else {
757
+ quickRecord.textContent = 'Not set';
758
+ quickRecord.title = 'Record quick paste shortcut';
759
+ }
760
+ quickRecord.classList.toggle('unset', !quickShortcut);
761
+ quickRecord.classList.remove('recording');
762
+ quickReset.style.visibility = info.quickPasteCustom ? 'visible' : 'hidden';
763
+ if (clearStatus) {
764
+ setShortcutStatus('show', '');
765
+ setShortcutStatus('quickPaste', '');
766
+ }
767
+ }
768
+
769
+ function shortcutKeyFromEvent(e) {
770
+ const modifierKeys = new Set(['Alt', 'AltGraph', 'Control', 'Meta', 'Shift']);
771
+ if (modifierKeys.has(e.key)) return '';
772
+ if (/^Key[A-Z]$/.test(e.code)) return e.code.slice(3);
773
+ if (/^Digit[0-9]$/.test(e.code)) return e.code.slice(5);
774
+ if (/^Numpad[0-9]$/.test(e.code)) return `num${e.code.slice(6)}`;
775
+ if (/^F([1-9]|1[0-9]|2[0-4])$/.test(e.key)) return e.key.toUpperCase();
776
+ const byCode = {
777
+ Minus: 'Minus',
778
+ Equal: e.shiftKey ? 'Plus' : '=',
779
+ BracketLeft: '[',
780
+ BracketRight: ']',
781
+ Backslash: 'Backslash',
782
+ Semicolon: ';',
783
+ Quote: "'",
784
+ Backquote: '`',
785
+ Comma: ',',
786
+ Period: '.',
787
+ Slash: '/',
788
+ NumpadAdd: 'Plus',
789
+ NumpadSubtract: 'Minus',
790
+ NumpadMultiply: '*',
791
+ NumpadDivide: '/',
792
+ NumpadDecimal: '.',
793
+ };
794
+ if (byCode[e.code]) return byCode[e.code];
795
+ const byKey = {
796
+ ' ': 'Space',
797
+ Spacebar: 'Space',
798
+ Tab: 'Tab',
799
+ Enter: 'Enter',
800
+ Return: 'Enter',
801
+ Backspace: 'Backspace',
802
+ Delete: 'Delete',
803
+ Insert: 'Insert',
804
+ Home: 'Home',
805
+ End: 'End',
806
+ PageUp: 'PageUp',
807
+ PageDown: 'PageDown',
808
+ ArrowUp: 'Up',
809
+ ArrowDown: 'Down',
810
+ ArrowLeft: 'Left',
811
+ ArrowRight: 'Right',
812
+ CapsLock: 'Capslock',
813
+ NumLock: 'Numlock',
814
+ ScrollLock: 'Scrolllock',
815
+ '+': 'Plus',
816
+ '-': 'Minus',
817
+ };
818
+ return byKey[e.key] || '';
819
+ }
820
+
821
+ function shortcutFromEvent(e) {
822
+ const mods = [];
823
+ if (e.metaKey) mods.push(window.api.platform === 'darwin' ? 'Command' : 'Super');
824
+ if (e.ctrlKey) mods.push('Control');
825
+ if (e.altKey) mods.push('Alt');
826
+ if (e.shiftKey) mods.push('Shift');
827
+ const key = shortcutKeyFromEvent(e);
828
+ if (!key || !mods.length) return '';
829
+ return [...mods, key].join('+');
830
+ }
831
+
832
+ async function applyShortcut(kind, shortcut) {
833
+ stopShortcutRecording({ resume: false });
834
+ const result = kind === 'quickPaste'
835
+ ? await window.api.setQuickPasteShortcut(shortcut)
836
+ : await window.api.setShowShortcut(shortcut);
837
+ if (!result || !result.ok) {
838
+ const settings = await window.api.getSettings().catch(() => null);
839
+ if (settings) renderShortcutSettings(settings, { clearStatus: false });
840
+ setShortcutStatus(kind, result && result.error ? result.error : 'Shortcut was not saved.', true);
841
+ return;
842
+ }
843
+ const settings = await window.api.getSettings();
844
+ renderShortcutSettings(settings);
845
+ }
846
+
847
+ async function startShortcutRecording(kind) {
848
+ stopShortcutRecording();
849
+ if (window.api.suspendShortcuts) {
850
+ await window.api.suspendShortcuts().catch(() => {});
851
+ }
852
+ shortcutRecording = kind;
853
+ const record = shortcutRecordEl(kind);
854
+ record.classList.add('recording');
855
+ record.textContent = 'Press keys';
856
+ setShortcutStatus(kind, '');
857
+ }
858
+
859
+ document.getElementById('shortcutRecord').addEventListener('click', () => startShortcutRecording('show'));
860
+ document.getElementById('quickPasteRecord').addEventListener('click', () => startShortcutRecording('quickPaste'));
861
+ document.getElementById('shortcutReset').addEventListener('click', () => applyShortcut('show', ''));
862
+ document.getElementById('quickPasteReset').addEventListener('click', () => applyShortcut('quickPaste', ''));
863
+
864
+ document.addEventListener('keydown', async (e) => {
865
+ if (!shortcutRecording) return;
866
+ const kind = shortcutRecording;
867
+ e.preventDefault();
868
+ e.stopPropagation();
869
+ if (e.key === 'Escape') {
870
+ stopShortcutRecording();
871
+ const settings = await window.api.getSettings().catch(() => null);
872
+ if (settings) renderShortcutSettings(settings);
873
+ return;
874
+ }
875
+ const shortcut = shortcutFromEvent(e);
876
+ if (!shortcut) {
877
+ setShortcutStatus(kind, shortcutKeyFromEvent(e) ? 'Add a shortcut modifier.' : 'Press a non-modifier key.', true);
878
+ return;
879
+ }
880
+ const resolved = kind === 'show' && window.api.resolveShowShortcut
881
+ ? await window.api.resolveShowShortcut(shortcut)
882
+ : shortcut;
883
+ await applyShortcut(kind, resolved);
884
+ }, true);
885
+
886
+ document.getElementById('addGroupBtn').addEventListener('click', () => controller.addGroup(null));
887
+ document.getElementById('syncAccounts').addEventListener('change', async (e) => {
888
+ const toggle = e.target.closest('[data-sync-path]');
889
+ if (!toggle) return;
890
+ await window.api.setSyncPathEnabled(toggle.dataset.syncPath, toggle.checked);
891
+ await refresh();
892
+ openSettings();
893
+ });
894
+ document.getElementById('syncHeaderBtn').addEventListener('click', () => runManualSync());
895
+ document.getElementById('syncNow').addEventListener('click', () => runManualSync({ showSettingsStatus: true }));
896
+ document.getElementById('addSyncFolder').addEventListener('click', async () => {
897
+ const syncStatus = document.getElementById('syncStatus');
898
+ Core.setSettingHelp(syncStatus, 'Choose a folder...');
899
+ const result = await window.api.chooseSyncFolder();
900
+ if (!result || result.canceled) {
901
+ if (syncStandingStatus) Core.setSettingHelp(syncStatus, syncStandingStatus[0], syncStandingStatus[1]);
902
+ return;
903
+ }
904
+ await refresh();
905
+ openSettings();
906
+ });
907
+
908
+ function shortCommit(sha) {
909
+ return sha ? String(sha).slice(0, 7) : '';
910
+ }
911
+
912
+ function updateUnavailableText(reason) {
913
+ switch (reason) {
914
+ case 'dirty-checkout':
915
+ return 'Source checkout has local changes; run the public update command';
916
+ case 'npm-install':
917
+ return 'Installed with npm; update with npm update -g @tobq/boardclip';
918
+ case 'not-git-checkout':
919
+ return 'Packaged build; source auto-update unavailable';
920
+ case 'missing-update-script':
921
+ return 'Source update script missing';
922
+ case 'unknown-build':
923
+ return 'Build commit unknown';
924
+ default:
925
+ return 'Source auto-update unavailable';
926
+ }
927
+ }
928
+
929
+ function updateStatusText(result) {
930
+ const latest = shortCommit(result && result.latest);
931
+ switch (result && result.status) {
932
+ case 'current':
933
+ return 'Up to date';
934
+ case 'updating':
935
+ return latest ? `Updating to ${latest}...` : 'Updating...';
936
+ case 'reloaded':
937
+ return latest ? `Updated UI to ${latest}` : 'Updated UI';
938
+ case 'relaunching':
939
+ return latest ? `Updated to ${latest}; relaunching...` : 'Updated; relaunching...';
940
+ case 'unsupported':
941
+ return updateUnavailableText(result && result.reason);
942
+ case 'busy':
943
+ return 'Update check already running';
944
+ case 'error':
945
+ return result.error ? `Update check failed: ${result.error}` : 'Update check failed';
946
+ default:
947
+ return 'Could not determine latest version';
948
+ }
949
+ }
950
+
951
+ function setUpdateStatus(message, state = '') {
952
+ Core.setSettingHelp(document.getElementById('updateStatus'), message, state);
953
+ }
954
+
955
+ function renderUpdateSettings(settings) {
956
+ const support = settings.runtime_info && settings.runtime_info.update_support || {};
957
+ const supported = !!(settings.runtime_info && settings.runtime_info.auto_update);
958
+ const updateBuild = document.getElementById('updateBuild');
959
+ const updateDetail = document.getElementById('updateDetail');
960
+ const updateNow = document.getElementById('updateNow');
961
+ const label = settings.build_info && settings.build_info.label ? settings.build_info.label : 'unknown';
962
+ updateBuild.textContent = `Build ${label}`;
963
+ updateDetail.textContent = supported
964
+ ? 'Updates itself from Git at startup and every 4 h'
965
+ : updateUnavailableText(support.reason);
966
+ updateNow.disabled = !supported;
967
+ // Developer checkouts: the update mode, one more settings row under the build.
968
+ const updatesRow = updateBuild.closest('.setting-row');
969
+ let modeHost = document.getElementById('developerUpdateMode');
970
+ const rt = settings.runtime_info || {};
971
+ const visible = !!rt.update_mode_visible;
972
+ if (visible && !modeHost && updatesRow) {
973
+ modeHost = document.createElement('div');
974
+ modeHost.id = 'developerUpdateMode';
975
+ modeHost.className = 'setting-row';
976
+ updatesRow.after(modeHost);
977
+ }
978
+ if (modeHost) {
979
+ modeHost.classList.toggle('hidden', !visible);
980
+ modeHost.innerHTML = visible ? `
981
+ <span class="setting-text"><span class="setting-label">Update mode</span>
982
+ <span class="setting-help">${rt.update_mode === 'development' ? 'Dirty source checkouts update with autostash' : 'Local tracked edits block updates'}</span></span>
983
+ <span class="setting-control"><span class="seg" role="group" aria-label="Update mode">
984
+ <button type="button" class="seg-btn${rt.update_mode === 'production' ? ' active' : ''}" data-update-mode="production">Production</button>
985
+ <button type="button" class="seg-btn${rt.update_mode === 'development' ? ' active' : ''}" data-update-mode="development">Development</button>
986
+ </span></span>` : '';
987
+ modeHost.querySelectorAll('[data-update-mode]').forEach(button => {
988
+ button.onclick = async () => {
989
+ await window.api.setUpdateMode(button.dataset.updateMode);
990
+ openSettings();
991
+ };
992
+ });
993
+ }
994
+ setUpdateStatus('', '');
995
+ }
996
+
997
+ document.getElementById('updateNow').addEventListener('click', async () => {
998
+ const updateNow = document.getElementById('updateNow');
999
+ updateNow.disabled = true;
1000
+ setUpdateStatus('Checking...', 'active');
1001
+ try {
1002
+ const result = await window.api.checkForUpdates();
1003
+ const state = result.ok || result.status === 'busy' ? 'active' : 'error';
1004
+ setUpdateStatus(updateStatusText(result), state);
1005
+ if (result.status !== 'updating') updateNow.disabled = false;
1006
+ } catch (error) {
1007
+ setUpdateStatus(`Update check failed: ${error && error.message ? error.message : 'unknown error'}`, 'error');
1008
+ updateNow.disabled = false;
1009
+ }
1010
+ });
1011
+
1012
+ // The audit-only Borders axis (BOARDCLIP_DEBUG_VARIANTS=1 only): the shared
1013
+ // switcher, saving ui_borders (per machine); main answers with
1014
+ // 'appearance-changed'. Accent, surface, density and corners are real settings
1015
+ // (Core.mountSettings binds them, for the app and the demo alike).
1016
+ function renderAppearanceVariants(s) {
1017
+ const host = document.getElementById('appearanceVariants');
1018
+ if (!host) return;
1019
+ host.innerHTML = '';
1020
+ const rt = s.runtime_info || {};
1021
+ if (!rt.debug_variants) return;
1022
+ const switcher = Core.createVariantSwitcher({
1023
+ fields: ['uiBorders'],
1024
+ initial: { uiBorders: (rt.appearance || {}).uiBorders || 'bordered' },
1025
+ onChange: (next) => window.api.saveSettings({ ui_borders: next.uiBorders }),
1026
+ });
1027
+ host.appendChild(switcher.el);
1028
+ }
1029
+
1030
+ async function openSettings() {
1031
+ const s = await window.api.getSettings();
1032
+ const rt = s.runtime_info || {};
1033
+ const autoLaunchEl = document.getElementById('autoLaunch');
1034
+ autoLaunchEl.checked = await window.api.getAutoLaunch();
1035
+ autoLaunchEl.onchange = (e) => window.api.setAutoLaunch(e.target.checked);
1036
+ setThemeMode(s.theme_mode || themeMode);
1037
+ document.getElementById('themeMode').onclick = (e) => {
1038
+ const btn = e.target.closest('[data-theme-mode]');
1039
+ if (!btn) return;
1040
+ setThemeMode(btn.dataset.themeMode);
1041
+ window.api.saveSettings({ theme_mode: btn.dataset.themeMode });
1042
+ };
1043
+ if (rt.appearance) currentLook = { ...currentLook, ...rt.appearance };
1044
+ settingsUi.update({ ...appearanceControlsState(currentLook), surfaceStyle: s.surface_style || 'auto', glassSupported: rt.surface_supported !== false });
1045
+ renderAppearanceVariants(s);
1046
+ renderShortcutSettings(s);
1047
+ const maxAgeEl = document.getElementById('maxAge');
1048
+ const maxSizeEl = document.getElementById('maxSize');
1049
+ imageZoom.bindInput(document.getElementById('imagePreviewHeight'), document.getElementById('imagePreviewHeightReset'));
1050
+ maxAgeEl.value = s.max_age_days;
1051
+ maxSizeEl.value = s.max_size_gb;
1052
+ maxAgeEl.onchange = () => window.api.saveSettings({ max_age_days: parseInt(maxAgeEl.value) || 7 });
1053
+ maxSizeEl.onchange = () => window.api.saveSettings({ max_size_gb: parseFloat(maxSizeEl.value) || 10 });
1054
+ const restoreEl = document.getElementById('quickPasteRestore');
1055
+ restoreEl.checked = s.quick_paste_restore !== false;
1056
+ restoreEl.onchange = () => window.api.saveSettings({ quick_paste_restore: restoreEl.checked });
1057
+ const diagnosticsEl = document.getElementById('diagnosticsEnabled');
1058
+ const diagnosticsStatus = document.getElementById('diagnosticsStatus');
1059
+ const diagnosticsFile = rt.diagnostics_file || '';
1060
+ const updateDiagnosticsStatus = () => {
1061
+ Core.setSettingHelp(diagnosticsStatus, diagnosticsEnabled ? 'Writing performance log' : '', diagnosticsEnabled ? 'active' : '');
1062
+ diagnosticsStatus.title = diagnosticsEnabled ? diagnosticsFile : '';
1063
+ };
1064
+ diagnosticsEnabled = !!s.diagnostics_enabled;
1065
+ diagnosticsEl.checked = diagnosticsEnabled;
1066
+ updateDiagnosticsStatus();
1067
+ diagnosticsEl.onchange = async (e) => {
1068
+ diagnosticsEnabled = !!e.target.checked;
1069
+ await window.api.saveSettings({ diagnostics_enabled: diagnosticsEnabled });
1070
+ updateDiagnosticsStatus();
1071
+ };
1072
+ const mb = ((s.storage_bytes || 0) / 1048576).toFixed(1);
1073
+ Core.setSettingHelp(document.getElementById('usage'), `${s.item_count} clip${s.item_count === 1 ? '' : 's'}, ${mb} MB used`);
1074
+
1075
+ // Native cloud providers are default-on; the switches are local opt-outs.
1076
+ const syncList = document.getElementById('syncAccounts');
1077
+ const accounts = await window.api.getCloudAccounts();
1078
+ syncList.innerHTML = accounts.map((acc) => Core.renderSettingsItem({
1079
+ title: syncAccountLabel(acc), sub: acc.path, subMono: true,
1080
+ toggle: { checked: !!acc.enabled, label: `Sync with ${syncAccountLabel(acc)}`, attrs: { 'data-sync-path': acc.path } },
1081
+ })).join('');
1082
+ const enabledAccounts = accounts.filter(acc => acc.enabled);
1083
+ setSyncStanding(accounts.length
1084
+ ? `${enabledAccounts.length} of ${accounts.length} on, syncs on every change and every 30 s`
1085
+ : 'No cloud folders found', enabledAccounts.length ? 'active' : '');
1086
+ const p2pEnabledEl = document.getElementById('p2pEnabled');
1087
+ const p2pRuntime = rt.p2p || { enabled: !!s.p2p_enabled };
1088
+ p2pEnabledEl.checked = !!p2pRuntime.enabled;
1089
+ renderP2PStatus(p2pRuntime);
1090
+ p2pEnabledEl.onchange = async (event) => {
1091
+ const nextEnabled = !!event.target.checked;
1092
+ renderP2PStatus({ enabled: nextEnabled, running: false, peers: [] });
1093
+ const status = await window.api.setP2PEnabled(nextEnabled);
1094
+ renderP2PStatus(status);
1095
+ };
1096
+ const pinnedEl = document.getElementById('p2pPinned');
1097
+ if (pinnedEl) {
1098
+ pinnedEl.value = (Array.isArray(s.p2p_pinned_peers) ? s.p2p_pinned_peers : []).join(', ');
1099
+ pinnedEl.onchange = async () => {
1100
+ const pins = pinnedEl.value.split(/[,\s]+/).map(v => v.trim()).filter(Boolean);
1101
+ await window.api.saveSettings({ p2p_pinned_peers: pins });
1102
+ renderP2PStatus(await window.api.getP2PStatus());
1103
+ };
1104
+ }
1105
+ startP2PStatusRefresh();
1106
+ renderUpdateSettings(s);
1107
+ const buildInfoEl = document.getElementById('buildInfo');
1108
+ buildInfoEl.textContent = rt.app_dir || '';
1109
+ buildInfoEl.title = rt.app_dir || '';
1110
+
1111
+ // Numpad slots: the assigned ones (.np-remove unassigns, a row click copies:
1112
+ // the shared controller's settings dispatch).
1113
+ const nmap = numpadMap();
1114
+ document.getElementById('numpadSlots').innerHTML = Core.renderNumpadSlotRows(nmap, itemById);
1115
+ document.getElementById('numpadHelp').classList.toggle('hidden', Object.keys(nmap).length >= 9);
1116
+
1117
+ // AI Access (renders before groups so the shared-group set is ready)
1118
+ await renderAiAccess();
1119
+ await renderConflicts();
1120
+
1121
+ // Groups (.gp-share toggles AI sharing, .gp-del deletes with the shared
1122
+ // confirm: the shared controller's settings dispatch).
1123
+ document.getElementById('groupSlots').innerHTML = Core.renderGroupRows(groups, { counts: groupCounts, shared: aiSharedGroups, aiGroup: aiGroupName });
1124
+
1125
+ document.getElementById('mainView').classList.add('hidden');
1126
+ document.getElementById('settingsView').classList.add('show');
1127
+ }
1128
+
1129
+ async function renderConflicts() {
1130
+ const section = document.getElementById('conflictsSection');
1131
+ const wrap = document.getElementById('conflictSlots');
1132
+ if (!section || !wrap || !window.api.getConflicts) return;
1133
+ const conflicts = await window.api.getConflicts().catch(() => []);
1134
+ section.classList.toggle('hidden', !conflicts.length);
1135
+ const short = (side) => Core.titleOf(side) || (side && side.text || '').replace(/\s+/g, ' ').trim().slice(0, 44);
1136
+ wrap.innerHTML = conflicts.map((conflict) => {
1137
+ const kind = String(conflict.kind || 'merge');
1138
+ return Core.renderSettingsItem({
1139
+ title: `${kind.charAt(0).toUpperCase()}${kind.slice(1)} conflict`,
1140
+ sub: `${short(conflict.left) || 'Current'} ↔ ${short(conflict.right) || 'Incoming'}`,
1141
+ controlHtml: `<button class="btn" type="button" data-conflict-id="${esc(conflict.id)}">Resolve</button>`,
1142
+ });
1143
+ }).join('');
1144
+ wrap.onclick = (event) => {
1145
+ const btn = event.target.closest('[data-conflict-id]');
1146
+ if (!btn) return;
1147
+ window.api.openConflict(btn.dataset.conflictId);
1148
+ };
1149
+ }
1150
+
1151
+ function closeSettings() {
1152
+ document.getElementById('settingsView').classList.remove('show');
1153
+ document.getElementById('mainView').classList.remove('hidden');
1154
+ searchEl.focus();
1155
+ }
1156
+
1157
+ // --- AI Access (MCP) ---
1158
+ let aiSharedGroups = new Set();
1159
+ let aiGroupName = 'AI';
1160
+
1161
+ async function renderAiAccess() {
1162
+ const st = await window.api.getAiAccess().catch(() => null);
1163
+ if (!st) return;
1164
+ applyAiAccess(st);
1165
+ document.getElementById('aiAccessEnabled').onchange = async (e) => {
1166
+ Core.setSettingHelp(document.getElementById('aiAccessStatus'), e.target.checked ? 'Starting...' : 'Turning off...');
1167
+ const next = await window.api.setAiAccessEnabled(e.target.checked).catch(() => null);
1168
+ if (next) { await refreshGroupsAndList(); openSettings(); }
1169
+ };
1170
+ document.getElementById('aiTimeout').onchange = async (e) => {
1171
+ const next = await window.api.setAiApprovalTimeout(parseInt(e.target.value) || 60).catch(() => null);
1172
+ if (next) applyAiAccess(next);
1173
+ };
1174
+ document.getElementById('aiMoreClients').onclick = () => {
1175
+ document.getElementById('aiClientsMore').classList.toggle('hidden');
1176
+ renderAiMoreLabel();
1177
+ };
1178
+ // Install / remove BoardClip in one client: one delegated listener per list.
1179
+ for (const id of ['aiClients', 'aiClientsMore']) {
1180
+ document.getElementById(id).onchange = async (e) => {
1181
+ const input = e.target.closest('[data-ai-client]');
1182
+ if (!input) return;
1183
+ const next = await window.api.setMcpClientEnabled(input.dataset.aiClient, input.checked).catch(() => null);
1184
+ if (next) applyAiAccess(next);
1185
+ };
1186
+ }
1187
+ document.getElementById('aiAlwaysAllow').onclick = async (e) => {
1188
+ const btn = e.target.closest('[data-revoke]');
1189
+ if (!btn) return;
1190
+ const next = await window.api.revokeAiAlwaysAllow(btn.dataset.revoke).catch(() => null);
1191
+ if (next) applyAiAccess(next);
1192
+ };
1193
+ }
1194
+
1195
+ // The switch shows on / off; the help says what it does while off and how it
1196
+ // is doing while on.
1197
+ function applyAiAccess(st) {
1198
+ aiSharedGroups = new Set(st.sharedGroups || []);
1199
+ aiGroupName = st.aiGroup || 'AI';
1200
+ document.getElementById('aiAccessEnabled').checked = !!st.enabled;
1201
+ document.getElementById('aiAccessBody').classList.toggle('hidden', !st.enabled);
1202
+ Core.setSettingHelp(document.getElementById('aiAccessStatus'),
1203
+ !st.enabled ? 'Reads clips in shared groups; deletes and edits ask you first' : st.running ? 'Running, assistants can connect' : 'Starting...',
1204
+ st.enabled && st.running ? 'active' : '');
1205
+ document.getElementById('aiTimeout').value = st.timeoutSec;
1206
+ renderAiClients(st);
1207
+ renderAiAlwaysAllow(st);
1208
+ }
1209
+
1210
+ function aiClientRowHtml(c) {
1211
+ return Core.renderSettingsItem({
1212
+ title: c.label, dim: !c.detected, sub: c.detected ? '' : 'Not detected on this machine',
1213
+ toggle: { checked: !!c.enabled, label: `${c.enabled ? 'Remove BoardClip from' : 'Install BoardClip in'} ${c.label}`, attrs: { 'data-ai-client': c.id } },
1214
+ });
1215
+ }
1216
+
1217
+ function renderAiMoreLabel() {
1218
+ const more = document.getElementById('aiClientsMore');
1219
+ const btn = document.getElementById('aiMoreClients');
1220
+ btn.textContent = more.classList.contains('hidden') ? `Show all clients (${more.children.length} more)` : 'Show fewer clients';
1221
+ }
1222
+
1223
+ function renderAiClients(st) {
1224
+ const clients = st.clients || [];
1225
+ const detected = clients.filter(c => c.detected);
1226
+ const undetected = clients.filter(c => !c.detected);
1227
+ document.getElementById('aiClients').innerHTML = detected.length
1228
+ ? detected.map(aiClientRowHtml).join('')
1229
+ : Core.renderSettingsItem({ title: 'No AI clients found on this machine', dim: true });
1230
+ const moreWrap = document.getElementById('aiClientsMore');
1231
+ moreWrap.innerHTML = undetected.map(aiClientRowHtml).join('');
1232
+ if (!undetected.length) moreWrap.classList.add('hidden');
1233
+ document.getElementById('aiMoreClients').classList.toggle('hidden', !undetected.length);
1234
+ renderAiMoreLabel();
1235
+ }
1236
+
1237
+ function renderAiAlwaysAllow(st) {
1238
+ const grants = st.alwaysAllow || [];
1239
+ document.getElementById('aiAlwaysHead').classList.toggle('hidden', !grants.length);
1240
+ document.getElementById('aiAlwaysAllow').innerHTML = grants.map((tool) => Core.renderSettingsItem({
1241
+ title: tool, titleMono: true,
1242
+ actionHtml: `<button class="icon-btn" type="button" data-revoke="${esc(tool)}" title="Revoke: ask again next time" aria-label="Revoke ${esc(tool)}"><span class="mi">close</span></button>`,
1243
+ })).join('');
1244
+ }
1245
+
1246
+ if (window.api && window.api.onAiAccessChanged) {
1247
+ window.api.onAiAccessChanged(() => {
1248
+ if (document.getElementById('settingsView').classList.contains('show')) renderAiAccess();
1249
+ });
1250
+ }
1251
+
1252
+
1253
+ // --- Groups refresh ---
1254
+ async function refreshGroups({render = true} = {}) {
1255
+ const startedAt = performance.now();
1256
+ try {
1257
+ const s = await window.api.getSettings();
1258
+ groups = s.groups || [];
1259
+ diagnosticsEnabled = !!s.diagnostics_enabled;
1260
+ if (render) renderGroupFilters();
1261
+ recordRendererPerf('settings.refresh_groups', startedAt, { groups: groups.length, render }, 75);
1262
+ } catch {}
1263
+ }
1264
+
1265
+ // Refreshes coalesce: a burst of history-changed events plus the show/focus
1266
+ // refresh used to stack five full refreshes (~7s of renderer work after three
1267
+ // quick captures, measured 2026-09-02). Now ONE runs and at most ONE more is
1268
+ // queued behind it, carrying the strongest `force`.
1269
+ let refreshInFlight = null;
1270
+ let refreshQueued = null;
1271
+ function refreshGroupsAndList({force = true} = {}) {
1272
+ if (refreshInFlight) {
1273
+ refreshQueued = { force: !!(refreshQueued && refreshQueued.force) || force };
1274
+ return refreshInFlight;
1275
+ }
1276
+ refreshInFlight = (async () => {
1277
+ try {
1278
+ await refreshGroups({render: false});
1279
+ await refresh({force});
1280
+ } finally {
1281
+ refreshInFlight = null;
1282
+ if (refreshQueued) {
1283
+ const next = refreshQueued;
1284
+ refreshQueued = null;
1285
+ refreshGroupsAndList(next);
1286
+ }
1287
+ }
1288
+ })();
1289
+ return refreshInFlight;
1290
+ }
1291
+
1292
+ // --- Data refresh ---
1293
+ // History fetches run one at a time, each from the revision the previous one
1294
+ // left: two overlapping fetches carried the same revision, so the second could
1295
+ // only be answered with a FULL snapshot (~1 s on the popup thread) instead of a
1296
+ // delta. Every caller (coalesced refreshes, post-mutation, manual sync) queues.
1297
+ let refreshChain = Promise.resolve();
1298
+ function refresh(options) {
1299
+ const run = refreshChain.then(() => refreshNow(options));
1300
+ refreshChain = run.catch(() => {});
1301
+ return run;
1302
+ }
1303
+ async function refreshNow({force = false} = {}) {
1304
+ const startedAt = performance.now();
1305
+ try {
1306
+ // Hand main the revision we already hold: unchanged history comes back as a
1307
+ // revision-only reply, not a ~10k-item clone.
1308
+ const state = window.api.getHistoryState ? await window.api.getHistoryState(dataRevision) : {revision: Date.now(), items: await window.api.getHistory()};
1309
+ const changed = !state.unchanged && state.revision !== dataRevision;
1310
+ if (changed) {
1311
+ // A delta (only the changed clips + the order) once we hold a snapshot.
1312
+ items = Core.applyHistoryDelta(items, state);
1313
+ dataRevision = state.revision;
1314
+ rebuildItemIndexes();
1315
+ }
1316
+ if (changed || force) rerenderList();
1317
+ recordRendererPerf('history.refresh', startedAt, { changed, force, revision: state.revision, delta: !!state.delta, sent_items: state.items ? state.items.length : 0 }, 100);
1318
+ } catch {}
1319
+ }
1320
+
1321
+ // Initial load + poll
1322
+ refreshGroupsAndList();
1323
+ if (window.api.onHistoryChanged) {
1324
+ window.api.onHistoryChanged(() => refreshGroupsAndList());
1325
+ } else {
1326
+ setInterval(refresh, 1000);
1327
+ }
1328
+
1329
+ // --- Focus/blur integration ---
1330
+ window.addEventListener('focus', () => {
1331
+ // Not forced: an unchanged history needs no rebuild (it cost ~60 ms on every
1332
+ // popup show, right when typing starts).
1333
+ refreshGroupsAndList({ force: false });
1334
+ setTimeout(() => searchEl.focus(), 50);
1335
+ });
1336
+ window.addEventListener('blur', () => {
1337
+ // Popup auto-hides via Electron's blur handler on the BrowserWindow
1338
+ });
1339
+ </script></body></html>