@3sln/trove 0.0.11 → 0.0.13

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 (90) hide show
  1. package/README.md +13 -1
  2. package/package.json +2 -1
  3. package/packages/core/src/collections/index.js +71 -10
  4. package/packages/core/src/encryption/keys.js +4 -3
  5. package/packages/core/src/encryption/policy.js +14 -7
  6. package/packages/core/src/encryption/rotation.js +46 -12
  7. package/packages/core/src/errors.js +8 -0
  8. package/packages/core/src/index.js +8 -2
  9. package/packages/core/src/indexers/contribution.js +18 -17
  10. package/packages/core/src/indexing.js +8 -4
  11. package/packages/core/src/issues.js +8 -0
  12. package/packages/core/src/links.js +21 -9
  13. package/packages/core/src/metadata/interface.js +22 -0
  14. package/packages/core/src/metadata/memory.js +15 -0
  15. package/packages/core/src/metadata/sqlite.js +23 -1
  16. package/packages/core/src/plugins/contributions.js +13 -0
  17. package/packages/core/src/rateLimit.js +253 -0
  18. package/packages/core/src/scan.js +44 -16
  19. package/packages/core/src/sidecar/document.js +18 -14
  20. package/packages/core/src/sidecar/index.js +9 -10
  21. package/packages/core/src/sidecar/manager.js +10 -15
  22. package/packages/core/src/uploads.js +40 -15
  23. package/packages/core/src/vfs.js +59 -34
  24. package/packages/plugin-sdk/src/browser.js +155 -0
  25. package/packages/plugin-sdk/src/protocol.js +13 -1
  26. package/packages/server/src/access/externalEvaluation.js +17 -0
  27. package/packages/server/src/adapters/bun.js +1 -2
  28. package/packages/server/src/adapters/node.js +1 -2
  29. package/packages/server/src/engine/index.js +1 -1
  30. package/packages/server/src/engine/providers/access.js +17 -34
  31. package/packages/server/src/engine/providers/core.js +112 -14
  32. package/packages/server/src/index.js +160 -105
  33. package/packages/server/src/mcp/index.js +7 -12
  34. package/packages/server/src/mcp/tools.js +24 -29
  35. package/packages/server/src/router.js +31 -8
  36. package/packages/server/src/routes.js +74 -113
  37. package/packages/server/src/scope.js +78 -0
  38. package/packages/web/dist/assets/main-828yzsr7.js +511 -0
  39. package/packages/web/dist/assets/main-828yzsr7.js.map +120 -0
  40. package/packages/web/dist/index.html +1 -1
  41. package/packages/web/dist/sw.js +1 -1
  42. package/packages/web/src/bl/actions.js +226 -104
  43. package/packages/web/src/bl/activity.js +32 -82
  44. package/packages/web/src/bl/index.js +23 -11
  45. package/packages/web/src/bl/launcher.js +2 -9
  46. package/packages/web/src/bl/links.js +31 -18
  47. package/packages/web/src/bl/offline.js +24 -10
  48. package/packages/web/src/bl/pluginInstall.js +29 -26
  49. package/packages/web/src/bl/queries.js +76 -68
  50. package/packages/web/src/bl/services.js +22 -14
  51. package/packages/web/src/bl/social.js +39 -25
  52. package/packages/web/src/bl/state.js +22 -4
  53. package/packages/web/src/bl/viewState.js +9 -0
  54. package/packages/web/src/bl/watchQuery.js +1 -8
  55. package/packages/web/src/dispatch.js +26 -0
  56. package/packages/web/src/platform/api.js +32 -0
  57. package/packages/web/src/platform/commands.js +14 -15
  58. package/packages/web/src/platform/context.js +3 -58
  59. package/packages/web/src/platform/contributions.js +1 -35
  60. package/packages/web/src/platform/fileChunks.js +281 -0
  61. package/packages/web/src/platform/index.js +8 -9
  62. package/packages/web/src/platform/mediaUrls.js +10 -0
  63. package/packages/web/src/platform/navigation.js +35 -21
  64. package/packages/web/src/platform/pickers.js +39 -19
  65. package/packages/web/src/platform/pluginHost.js +4 -1
  66. package/packages/web/src/platform/pluginRpc.js +47 -2
  67. package/packages/web/src/platform/spatialNav.js +1 -1
  68. package/packages/web/src/platform/viewport.js +14 -6
  69. package/packages/web/src/platform/voiceSearch.js +17 -9
  70. package/packages/web/src/platform/whenclause.js +9 -19
  71. package/packages/web/src/runtime.js +2 -37
  72. package/packages/web/src/ui/activate.js +18 -2
  73. package/packages/web/src/ui/components/activityPanel.js +1 -1
  74. package/packages/web/src/ui/components/adminView.js +4 -7
  75. package/packages/web/src/ui/components/editorArea.js +10 -1
  76. package/packages/web/src/ui/components/launcher.js +29 -13
  77. package/packages/web/src/ui/components/openers/index.js +17 -22
  78. package/packages/web/src/ui/components/openers/markdown.js +2 -2
  79. package/packages/web/src/ui/components/overlays.js +31 -5
  80. package/packages/web/src/ui/components/pluginReview.js +7 -4
  81. package/packages/web/src/ui/components/pluginsView.js +1 -1
  82. package/packages/web/src/ui/components/settingsView.js +30 -27
  83. package/packages/web/src/ui/components/statusBar.js +8 -6
  84. package/packages/web/src/ui/components/views/grid.js +2 -2
  85. package/packages/web/src/ui/components/views/parts.js +1 -1
  86. package/packages/web/src/ui/compositions/workbench.js +20 -6
  87. package/packages/web/src/ui/sanitize.js +0 -5
  88. package/packages/web/src/workbench.js +37 -19
  89. package/packages/web/dist/assets/main-c9dnnnc6.js +0 -356
  90. package/packages/web/dist/assets/main-c9dnnnc6.js.map +0 -118
@@ -33,19 +33,33 @@ function promptFor(caps, { compact = false, modal = false } = {}) {
33
33
  return compact || modal ? base : `${base} · ! run a command`;
34
34
  }
35
35
 
36
- let searchTimer = null;
37
- function runSearch(ui, query) {
36
+ // Debounce timers, one PER SURFACE. workbench.js mounts the home launcher and the modal
37
+ // one together, and a single module slot meant typing in the modal cancelled the home
38
+ // box's pending search — one keystroke in one component silently discarding another's.
39
+ // A timer is not application state (the engine has no use for a pending timeout), but it
40
+ // does belong to the surface that armed it.
41
+ const timers = new Map();
42
+ const arm = (key, fn, ms) => {
43
+ clearTimeout(timers.get(key));
44
+ timers.set(key, setTimeout(fn, ms));
45
+ };
46
+
47
+ function runSearch(ui, key, query) {
38
48
  // SearchAction owns search state; the launcher only dispatches (no direct .set).
39
- clearTimeout(searchTimer);
40
- searchTimer = setTimeout(() => ui.engine.dispatch(new SearchAction(query)), 240);
49
+ arm(key, () => ui.engine.dispatch(new SearchAction(query)), 240);
41
50
  }
42
- function clearSearch(ui) {
51
+ function clearSearch(ui, key) {
52
+ // The pending timer FIRST. Typing then pressing Escape inside 240ms let the search for
53
+ // what was typed land after the clear — the results list stayed put (it reads the launch
54
+ // query) while the resolved bar said "Searching …" over the home list, and `pickView`
55
+ // could switch the view under it. SearchAction now also refuses a superseded answer, so
56
+ // this is belt and braces on a race that produced a visibly wrong screen.
57
+ clearTimeout(timers.get(key));
43
58
  ui.engine.dispatch(new SetLaunchQueryAction(''));
44
- ui.engine.dispatch(new SearchAction('')); // empty query resets results/ran/error in the service
59
+ ui.engine.dispatch(new SearchAction('')); // empty query resets results/ran/error
45
60
  }
46
- function runFilter(ui, filters, text) {
47
- clearTimeout(searchTimer);
48
- searchTimer = setTimeout(() => ui.engine.dispatch(new FilterAction(filters, text)), 200);
61
+ function runFilter(ui, key, filters, text) {
62
+ arm(key, () => ui.engine.dispatch(new FilterAction(filters, text)), 200);
49
63
  }
50
64
 
51
65
  export default function launcher(state, ui, opts = {}) {
@@ -60,13 +74,15 @@ export default function launcher(state, ui, opts = {}) {
60
74
  const flat = groups.flatMap((g) => g.items);
61
75
  const idx = flat.length ? Math.min(state.wb.launch.index, flat.length - 1) : 0;
62
76
 
77
+ // Which surface this is, so the two launchers do not share one debounce slot.
78
+ const timerKey = modal ? 'modal' : 'home';
63
79
  const onInput = (e) => {
64
80
  const v = e.target.value;
65
81
  ui.engine.dispatch(new SetLaunchQueryAction(v));
66
82
  if (v.startsWith('!')) return; // command mode: no query dispatch
67
83
  const { text, filters } = parseTagQuery(v);
68
- if (filters.length) runFilter(ui, filters, text); // drive-wide tag/property query
69
- else runSearch(ui, text);
84
+ if (filters.length) runFilter(ui, timerKey, filters, text); // drive-wide tag/property query
85
+ else runSearch(ui, timerKey, text);
70
86
  };
71
87
  // Moving the highlight with the KEYBOARD is selecting, as far as the rest of the app
72
88
  // is concerned. Without this, `explorer.selectedNodes()` was permanently empty and
@@ -97,7 +113,7 @@ export default function launcher(state, ui, opts = {}) {
97
113
  else if (e.key === 'ArrowDown') { e.preventDefault(); move(1); }
98
114
  else if (e.key === 'ArrowUp') { e.preventDefault(); move(-1); }
99
115
  else if (e.key === 'Enter') { e.preventDefault(); activate(ui, flat[idx]); }
100
- else if (e.key === 'Escape' && q) { e.preventDefault(); clearSearch(ui); }
116
+ else if (e.key === 'Escape' && q) { e.preventDefault(); clearSearch(ui, timerKey); }
101
117
  // The row under the highlight is the subject of the row menu, so the key that opens
102
118
  // one on every other list opens this one too — without making the user leave the
103
119
  // search box to reach it.
@@ -124,7 +140,7 @@ export default function launcher(state, ui, opts = {}) {
124
140
  placeholder: promptFor(state.caps, { compact: state.vp?.mode === 'phone', modal }) })
125
141
  .on({ input: onInput, keydown: onKey }),
126
142
  q ? button({ className: 'launch-clear', title: 'Clear' }, icon('close', { size: 14 }))
127
- .on({ click: () => clearSearch(ui) }) : null,
143
+ .on({ click: () => clearSearch(ui, timerKey) }) : null,
128
144
  // Only where the browser can transcribe WITHOUT sending audio anywhere. A remote's
129
145
  // own mic needs no button from us — it dictates into this field once it is focused,
130
146
  // which is what `search.voice` is really for.
@@ -177,22 +177,27 @@ function pluginOpenerView(opener, node, ui) {
177
177
  // in the DOM would reload it). $attach hands .pv-host to mountViewer as the tracking
178
178
  // target; $detach lets it dock or tear down. `.opaque()` keeps dodo from reconciling
179
179
  // these (host-managed) nodes so their lifecycle hooks stay stable across re-renders.
180
- // A .pv-status overlay shows a spinner until the plugin opens the file (the iframe
181
- // is transparent while loading) and an error if it never does.
180
+ // The status overlay shows a spinner until the plugin opens the file (the iframe is
181
+ // transparent while loading) and an error if it never does. It is a CELL and a `watch`,
182
+ // the same shape `mediaWithError` above uses — it used to be functions stamped onto the
183
+ // status element (`el._ready`, `el._error`) and fetched back by the sibling with
184
+ // `el.parentElement?.querySelector('.pv-status')`. Every `?.` in that chain silently
185
+ // no-ops, so reordering or wrapping these two divs left the spinner up forever with
186
+ // nothing reporting it. `.pv-host` stays opaque and imperative because it genuinely is:
187
+ // re-parenting the iframe would reload it.
188
+ const status = cell({ ready: false, error: null });
182
189
  return div({ className: 'viewer plugin-viewer' },
183
- div({ className: 'pv-status' }).on({
184
- $attach: (el) => {
185
- el.innerHTML = '<div class="loading"><div class="spinner"></div><span>Opening…</span></div>';
186
- el._ready = () => { el.style.display = 'none'; };
187
- el._error = (msg) => { el.style.display = 'grid'; el.innerHTML = ''; el.appendChild(errorEl(msg)); };
188
- },
189
- }).opaque(),
190
+ watch(status, (st) => {
191
+ if (st.ready) return null;
192
+ return div({ className: 'pv-status' }, st.error
193
+ ? div({ className: 'fallback' }, icon('warn', { size: 40 }), span(st.error))
194
+ : div({ className: 'loading' }, div({ className: 'spinner' }), span('Opening…')));
195
+ }),
190
196
  div({ className: 'pv-host' }).on({
191
197
  $attach: (el) => {
192
- const status = el.parentElement?.querySelector('.pv-status');
193
198
  el._detach = ui.platform.plugins.mountViewer(opener.pluginId, el, node, opener.id, {
194
- onReady: () => status?._ready?.(),
195
- onError: (msg) => status?._error?.(msg || 'This viewer failed to load'),
199
+ onReady: () => status.setValue({ ready: true, error: null }),
200
+ onError: (msg) => status.setValue({ ready: false, error: msg || 'This viewer failed to load' }),
196
201
  });
197
202
  },
198
203
  $detach: (el) => { el._detach?.(); el._detach = null; },
@@ -200,16 +205,6 @@ function pluginOpenerView(opener, node, ui) {
200
205
  );
201
206
  }
202
207
 
203
- // A plain DOM error node for the imperatively-managed .pv-status overlay.
204
- function errorEl(message) {
205
- const wrap = document.createElement('div');
206
- wrap.className = 'fallback';
207
- const s = document.createElement('span');
208
- s.textContent = message || 'Failed to open';
209
- wrap.appendChild(s);
210
- return wrap;
211
- }
212
-
213
208
  function errorView(message) {
214
209
  return div({ className: 'viewer' }, div({ className: 'fallback' }, icon('warn', { size: 40 }), span(message || 'Failed to open')));
215
210
  }
@@ -21,7 +21,7 @@
21
21
  import { dd, mapCell } from '../../../runtime.js';
22
22
  import { icon } from '../../icon.js';
23
23
  import { parseTroveUri } from '@3sln/trove/core/links.js';
24
- import { openTroveLink } from '../../../bl/links.js';
24
+ import { OpenTroveLinkAction } from '../../../bl/links.js';
25
25
  import { FileText } from '../../../bl/queries.js';
26
26
  import { watchQuery } from '../../../bl/watchQuery.js';
27
27
 
@@ -213,7 +213,7 @@ function link(href, label, node, ui) {
213
213
  return a({ className: 'md-link md-trove', href: '#', title: href }, label).on({
214
214
  click: (e) => {
215
215
  e.preventDefault();
216
- openTroveLink(ui, href, { from: node });
216
+ ui.engine.dispatch(new OpenTroveLinkAction(href, node));
217
217
  },
218
218
  });
219
219
  }
@@ -293,17 +293,43 @@ function storeFields(driver, form, set) {
293
293
  }
294
294
 
295
295
  // ---- Context menu ----------------------------------------------------------
296
+ // The menu's own geometry, in the one place that can measure it. `MENU_ROW` must match the
297
+ // `.mi` height in the stylesheet; it used to be spelled out at the call sites too, so
298
+ // changing the CSS made status-bar menus overlap their trigger while the clamp below
299
+ // compensated by a different amount.
300
+ const MENU_WIDTH = 220;
301
+ const MENU_ROW = 34;
302
+ const MENU_PAD = 20;
303
+
304
+ /**
305
+ * Where a menu of `height` should sit, given a point, a rect to hang off, or nothing.
306
+ *
307
+ * Flipping and clamping are the same decision and belong together: anchoring ABOVE a
308
+ * trigger produces a negative top, which is exactly what the clamp is for.
309
+ */
310
+ function placeMenu(at, height) {
311
+ const w = window.innerWidth;
312
+ const h = window.innerHeight;
313
+ // No anchor — a keyboard-invoked menu has no geometry to hang off, so it is centred.
314
+ if (!at) return { x: Math.round(w / 2 - MENU_WIDTH / 2), y: Math.round(h / 3) };
315
+ const wantX = at.rect ? at.rect.left : at.x;
316
+ const wantY = at.rect
317
+ ? (at.prefer === 'up' ? at.rect.top - 8 - height : at.rect.bottom + 8)
318
+ : at.y;
319
+ return {
320
+ x: Math.max(8, Math.min(wantX, w - MENU_WIDTH)),
321
+ y: Math.max(8, Math.min(wantY, h - height)),
322
+ };
323
+ }
324
+
296
325
  export function contextMenu(state, ui) {
297
326
  const m = state.overlay.contextMenu;
298
327
  if (!m || !m.items?.length) return null;
299
- // Clamp on both edges. Anchoring a menu ABOVE its trigger (the status bar opens
300
- // upward) produces a negative top, and only the far edge used to be clamped.
301
328
  // The height is whatever the menu needs OR whatever the window allows, whichever is
302
329
  // smaller — past that the menu scrolls (see `.menu`), so pushing it further up buys
303
330
  // nothing and would just leave a gap at the bottom.
304
- const wanted = Math.min(m.items.length * 34 + 20, window.innerHeight - 24);
305
- const x = Math.max(8, Math.min(m.x, window.innerWidth - 220));
306
- const y = Math.max(8, Math.min(m.y, window.innerHeight - wanted));
331
+ const wanted = Math.min(m.items.length * MENU_ROW + MENU_PAD, window.innerHeight - 24);
332
+ const { x, y } = placeMenu(m.at, wanted);
307
333
  return div({},
308
334
  div({ className: 'scrim', $styling: { background: 'transparent' } }).on({ click: () => ui.engine.dispatch(new CloseContextMenuAction()), contextmenu: (e) => { e.preventDefault(); ui.engine.dispatch(new CloseContextMenuAction()); } }),
309
335
  div({ className: 'menu', $styling: { left: x + 'px', top: y + 'px' } },
@@ -7,15 +7,16 @@
7
7
  import { dd } from '../../runtime.js';
8
8
  import { icon } from '../icon.js';
9
9
  import { bytes as fmtBytes } from '../format.js';
10
- import { draftFor } from '../../bl/viewState.js';
10
+ import { draftFor, PLUGIN_REVIEW as KEY } from '../../bl/viewState.js';
11
11
  import { CloseDialogAction, SetViewStateAction } from '../../bl/actions.js';
12
+ import { activate } from '../activate.js';
12
13
  import { describeTrust } from '../../bl/trust.js';
13
14
 
14
15
  const { div, span, button, h3, p, label, input } = dd;
15
16
 
16
17
  // The ticked capabilities, keyed to the dialog instance. Engine state — it decides which
17
- // boxes render checked. See bl/viewState.js.
18
- const KEY = 'pluginReview';
18
+ // boxes render checked, and it is where InstallReviewedPluginAction reads the grants from.
19
+ // See bl/viewState.js.
19
20
 
20
21
  export function pluginReview(d, ui, view) {
21
22
  const s = d.summary;
@@ -61,7 +62,9 @@ export function pluginReview(d, ui, view) {
61
62
  button({ className: 'btn' }, 'Cancel').on({ click: () => ui.engine.dispatch(new CloseDialogAction()) }),
62
63
  button({ className: `btn primary ${blocked ? 'disabled' : ''}`, disabled: blocked },
63
64
  icon('plug', { size: 15 }), 'Install')
64
- .on({ click: () => !blocked && d.onInstall([...sel.grants]) }),
65
+ // The spec's own actions, run the way every other item's are. The install used
66
+ // to be a closure ON THE SPEC, which put an effect inside engine state.
67
+ .on({ click: () => !blocked && activate(ui, { actions: d.installActions }) }),
65
68
  ),
66
69
  ),
67
70
  );
@@ -1,6 +1,6 @@
1
1
  import { dd } from '../../runtime.js';
2
2
  import { icon } from '../icon.js';
3
- import { CloseDialogAction, ExecCommandAction, OpenPluginPanelAction, RefreshPluginAction, SetPluginSecretAction, SetSettingAction, ShowDialogAction, UninstallPluginAction } from '../../bl/actions.js';
3
+ import { ExecCommandAction, OpenPluginPanelAction, RefreshPluginAction, SetPluginSecretAction, SetSettingAction, ShowDialogAction, UninstallPluginAction } from '../../bl/actions.js';
4
4
  import { describeTrust } from '../../bl/trust.js';
5
5
 
6
6
  const { div, span, p, button, h2, input, label } = dd;
@@ -1,15 +1,13 @@
1
1
  import { dd } from '../../runtime.js';
2
2
  import { eventToKey } from '../../platform/keybindings.js';
3
3
  import { icon } from '../icon.js';
4
- import { region } from '../region.js';
5
- import * as q from '../../bl/queries.js';
6
4
  import { rotationFor } from '../../bl/queries.js';
7
5
  import { watchQuery } from '../../bl/watchQuery.js';
8
6
  import { BeginRotationAction, CancelRotationAction, CopyTextAction, ExecCommandAction, PatchApiKeyDraftAction, RebindKeyAction, RememberOpenerAction, SetSettingAction, SetViewStateAction, ToggleApiKeyCapAction } from '../../bl/actions.js';
9
7
 
10
8
  const { div, h2, h3, p, span, select, option, input, label, button, ul, li, code } = dd;
11
9
 
12
- export default function settingsView(state, ui) {
10
+ export default function settingsView(state, ui, regions) {
13
11
  const groups = state.settingsGroups || new Map();
14
12
  return div({ className: 'editor' },
15
13
  div({ className: 'stage' },
@@ -21,7 +19,7 @@ export default function settingsView(state, ui) {
21
19
  encryptionSection(state, ui),
22
20
  apiKeysSection(state, ui),
23
21
  openersSection(state.assoc, ui),
24
- keybindingsSection(ui),
22
+ regions.keybindings(),
25
23
  ),
26
24
  ),
27
25
  );
@@ -182,22 +180,28 @@ const CAPS = [
182
180
 
183
181
  const ANY = '*';
184
182
 
185
- /** One-shot guard so a render does not queue a second load while the first is in flight. */
186
- let keysRequested = false;
187
-
188
183
  function apiKeysSection(state, ui) {
189
184
  const keys = state.keys || {};
190
185
 
191
- // Loaded lazily, when Settings is first opened the list is admin-only and most
192
- // sessions never need it, so it is not worth a request at boot.
193
- if (!keysRequested && !keys.loaded && !keys.loading) {
194
- keysRequested = true;
195
- ui.engine.dispatch(new ExecCommandAction('keys.load'));
186
+ // Nothing to dispatch from here. The list is loaded by the query's `bootAction` it
187
+ // arrives because something is LOOKING at it, which is what makes it lazy without a
188
+ // module-level flag and a render with a side effect. See bl/queries.js.
189
+ //
190
+ // A non-admin's 403 is the correct answer rather than an error: the section simply is
191
+ // not part of their settings screen.
192
+ if (keys.forbidden) return null;
193
+ if (keys.error) {
194
+ return div({ className: 'group' },
195
+ h3('API keys'),
196
+ // Said out loud. This used to render nothing at all on any failure, because the
197
+ // catch set `loaded` as well as `error` — so one dropped request left a blank space
198
+ // where the section belongs, with no message and no way to try again.
199
+ p({ className: 'sub' }, `Couldn’t load the key list: ${keys.error}`),
200
+ div({ className: 'keys-actions' },
201
+ button({ className: 'btn' }, 'Try again')
202
+ .on({ click: () => ui.engine.dispatch(new ExecCommandAction('keys.load')) })),
203
+ );
196
204
  }
197
-
198
- // A non-admin gets a 403 here, which is the correct answer rather than an error worth
199
- // showing. The section simply is not part of their settings screen.
200
- if (keys.error && !keys.keys?.length) return null;
201
205
  if (!keys.loaded) return null;
202
206
 
203
207
  const collections = state.ex?.collections || [];
@@ -477,7 +481,7 @@ function openersSection(rows, ui) {
477
481
 
478
482
  // Which shortcut is listening for its new chord. Transient, and still engine state: it
479
483
  // decides whether a row renders a `<kbd>` or "Press keys…". See bl/viewState.js.
480
- const CAPTURE = 'keybindingCapture';
484
+ export const CAPTURE = 'keybindingCapture';
481
485
 
482
486
  // Its own region, over the keybindings view and the capture state.
483
487
  //
@@ -487,16 +491,15 @@ const CAPTURE = 'keybindingCapture';
487
491
  //
488
492
  // Rebinding dispatches with the binding ID. The component has never held a binding object,
489
493
  // only a description of one, which is the point.
490
- let kbRegion = null;
491
- function keybindingsSection(ui) {
492
- // Built once. A fresh region per render would rebuild its alias every pass, and dodo
493
- // identifies an alias by the function see ui/region.js.
494
- kbRegion ??= region(ui.engine, { bindings: q.keybindings, view: q.viewState },
495
- (st) => keybindingRows(st.bindings || [], ui, st.view?.[CAPTURE] ?? null));
496
- return kbRegion();
497
- }
498
-
499
- function keybindingRows(bindings, ui, capturing) {
494
+ // Built once and PASSED IN, beside the other regions in ui/compositions/workbench.js. It
495
+ // was built at module scope with `kbRegion ??= region(ui.engine, …)` — right that a region
496
+ // must be built once, wrong about where: at module scope it captures the FIRST `ui` and
497
+ // engine it ever sees and pins them for the module's life, which is the shape `watchQuery`
498
+ // deliberately avoids by keying its cache on the engine. Folding it into the parent
499
+ // settingsView region would also work and costs granularity — a chord capture would
500
+ // re-render the whole settings screen — so it is handed down instead.
501
+
502
+ export function keybindingRows(bindings, ui, capturing) {
500
503
  const setCapturing = (id) => ui.engine.dispatch(new SetViewStateAction(CAPTURE, id));
501
504
  const stop = () => setCapturing(null);
502
505
  const rebind = (bindingId, key) => ui.engine.dispatch(new RebindKeyAction(bindingId, key));
@@ -122,8 +122,9 @@ export default function statusBar(state, ui) {
122
122
  { sep: true },
123
123
  ]);
124
124
  if (off.pins.length > 12) items.push({ label: `…and ${off.pins.length - 12} more`, actions: [] });
125
- const r = e.currentTarget.getBoundingClientRect();
126
- ui.engine.dispatch(new ShowContextMenuAction(r.left, r.top - 8 - items.length * 34, items));
125
+ ui.engine.dispatch(new ShowContextMenuAction(items, {
126
+ rect: e.currentTarget.getBoundingClientRect(), prefer: 'up',
127
+ }));
127
128
  } })
128
129
  : null,
129
130
  button({ className: 'seg', title: 'Switch collection' },
@@ -135,16 +136,17 @@ export default function statusBar(state, ui) {
135
136
  const items = ex.collectionMenu || [];
136
137
  // With nowhere else to go, the segment is a label, not a dead menu.
137
138
  if (items.length > 1 || ex.canCreateCollection) {
138
- const r = e.currentTarget.getBoundingClientRect();
139
- ui.engine.dispatch(new ShowContextMenuAction(r.left, r.top - 8 - items.length * 34, items));
139
+ ui.engine.dispatch(new ShowContextMenuAction(items, {
140
+ rect: e.currentTarget.getBoundingClientRect(), prefer: 'up',
141
+ }));
140
142
  } else ui.engine.dispatch(new ExecCommandAction('workbench.view.home'));
141
143
  } }),
142
144
  active.length
143
145
  ? button({ className: 'seg', title: 'Active uploads — click to cancel' }, div({ className: 'spinner', $styling: { width: '11px', height: '11px' } }), span(`${active.length} uploading`))
144
- .on({ click: (e) => ui.engine.dispatch(new ShowContextMenuAction(e.clientX, e.clientY, active.map((t) => ({
146
+ .on({ click: (e) => ui.engine.dispatch(new ShowContextMenuAction(active.map((t) => ({
145
147
  label: `Cancel ${t.name} (${Math.round((t.ratio || 0) * 100)}%)`, icon: 'close', danger: true,
146
148
  actions: [new CancelTransferAction(t.id)],
147
- })))) })
149
+ })), { x: e.clientX, y: e.clientY })) })
148
150
  : span({ className: 'seg', title: ex.nextCursor ? `Showing ${items.length} of ${f.totalKnown ? totalItems : 'more'}` : '' },
149
151
  `${totalItems.toLocaleString()}${f.totalKnown || !f.partial ? '' : '+'} item${totalItems === 1 ? '' : 's'}`),
150
152
  ...left,
@@ -14,7 +14,7 @@
14
14
  // point of the contribution.
15
15
 
16
16
  import { dd } from '../../../runtime.js';
17
- import { icon, iconForNode } from '../../icon.js';
17
+ import { icon } from '../../icon.js';
18
18
  import { groupHeader, menuButton, openRowMenu } from './parts.js';
19
19
  import { attachMedia } from '../../media.js';
20
20
  import { activate } from '../../activate.js';
@@ -88,7 +88,7 @@ function tile(it, active, { hover, select }, ui) {
88
88
  // the image fails — a deleted object, a format the browser won't decode, a 401 —
89
89
  // hiding the <img> uncovers exactly what the list would have shown, rather than
90
90
  // leaving the browser's broken-image glyph in a gallery.
91
- icon(node ? iconForNode(node) : it.icon, { size: 26 }),
91
+ icon(it.icon, { size: 26 }),
92
92
  // The `src` is minted rather than built — a tile fetches without our Authorization
93
93
  // header, so the URL has to carry its own grant. Minting is batched (see
94
94
  // platform/mediaUrls.js), so a wall of tiles costs one request, not one each.
@@ -65,5 +65,5 @@ export function openRowMenu(anchor, it, ui, event, select) {
65
65
  // a detached node whose rect is all zeroes.
66
66
  const items = it.menu();
67
67
  select?.();
68
- ui.engine.dispatch(new ShowContextMenuAction(x, y, items));
68
+ ui.engine.dispatch(new ShowContextMenuAction(items, { x, y }));
69
69
  }
@@ -5,15 +5,15 @@
5
5
  // every QUERY at once — which is a different thing from what it used to know, when it
6
6
  // held the bag of services every component reached through.
7
7
 
8
- import { dd, derive, constant, watch } from '../../runtime.js';
8
+ import { dd, derive, watch } from '../../runtime.js';
9
9
  import { region } from '../region.js';
10
10
  import { watchQuery } from '../../bl/watchQuery.js';
11
11
  import * as q from '../../bl/queries.js';
12
- import { CloseSearchModalAction, ExecCommandAction, NavigateAction } from '../../bl/actions.js';
12
+ import { CloseSearchModalAction, NavigateAction } from '../../bl/actions.js';
13
13
  import activityBar from '../components/activityBar.js';
14
14
  import statusBar from '../components/statusBar.js';
15
15
  import launcher from '../components/launcher.js';
16
- import settingsView from '../components/settingsView.js';
16
+ import settingsView, { keybindingRows, CAPTURE } from '../components/settingsView.js';
17
17
  import collectionGate from '../components/collectionGate.js';
18
18
  import pluginsView from '../components/pluginsView.js';
19
19
  import adminView from '../components/adminView.js';
@@ -142,14 +142,28 @@ export default function workbench({ engine, platform }) {
142
142
  // or auto-dismissing used to rebuild the entire shell — including every row of the
143
143
  // file list — to add a line in the corner.
144
144
  toasts: region(engine, { notif: q.notifications }, (s) => toasts(s, ui)),
145
- activityPanel: region(engine, { act: q.activity }, (s) => activityPanel(s, ui)),
145
+ // `overlay` as well as `act`: the panel's open flag is overlay state like every other
146
+ // transient surface, and its contents are the poller's.
147
+ activityPanel: region(engine, { act: q.activity, overlay: q.overlay }, (s) => activityPanel(s, ui)),
148
+ // A region like its neighbours, and it has to be: it reads `plugins`, which the main
149
+ // snapshot does not contain — so `state.plugins` was permanently undefined, `|| []`
150
+ // swallowed it, and the panel header showed the raw plugin id where the display name
151
+ // belongs. It was the one component the region split left reading a key nobody
152
+ // supplies. Being a region also takes it off the main snapshot, which is the point.
153
+ pluginPanel: region(engine, { overlay: q.overlay, plugins: q.plugins },
154
+ (s) => pluginPanel(s, ui)),
146
155
  // Screens that are usually NOT on screen. Their state still invalidated the shell from
147
156
  // wherever they were: minting an API key or a plugin reporting in rebuilt the file
148
157
  // list. Each reads a couple of slices, so each is a cheap region.
149
158
  settingsView: region(engine, {
150
159
  settings: q.settings, settingsGroups: q.settingsGroups, keys: q.apiKeys,
151
160
  caps: q.capabilities, ex: q.explorer, assoc: q.openerAssociations,
152
- }, (s) => settingsView(s, ui)),
161
+ }, (s) => settingsView(s, ui, regions)),
162
+ // The keybinding table is its own region so a chord capture does not re-render the
163
+ // whole settings screen. Built HERE, with every other region — at module scope it
164
+ // pinned the first engine it ever saw.
165
+ keybindings: region(engine, { bindings: q.keybindings, view: q.viewState },
166
+ (s) => keybindingRows(s.bindings || [], ui, s.view?.[CAPTURE] ?? null)),
153
167
  pluginsView: region(engine, { plugins: q.plugins, settings: q.settings },
154
168
  (s) => pluginsView(s, ui)),
155
169
  // Gathers what already exists rather than fetching anything new — see adminView.js.
@@ -196,7 +210,7 @@ function view(state, ui, regions) {
196
210
  regions.commandPalette(),
197
211
  dialog(state, ui),
198
212
  contextMenu(state, ui),
199
- pluginPanel(state, ui),
213
+ regions.pluginPanel(),
200
214
  regions.toasts(),
201
215
  regions.transferTray(),
202
216
  regions.activityPanel(),
@@ -33,11 +33,6 @@ export function sanitizeFragment(html) {
33
33
  return out;
34
34
  }
35
35
 
36
- /** Sanitize `html` and set it as the only content of `el`. */
37
- export function setSanitizedHtml(el, html) {
38
- el.replaceChildren(sanitizeFragment(html));
39
- }
40
-
41
36
  /** The plain-text content of `html`, for tooltips and other attribute contexts. */
42
37
  export function htmlToText(html) {
43
38
  const frag = sanitizeFragment(html);
@@ -29,7 +29,10 @@
29
29
  import { dd, effect } from './runtime.js';
30
30
  import { createPlatform } from './platform/index.js';
31
31
  import { createApp } from './bl/index.js';
32
- import { NavigateAction, UploadFilesAction, OpenInitialCollectionAction, OpenSearchModalAction, OpenInPanelAction } from './bl/actions.js';
32
+ import {
33
+ NavigateAction, UploadFilesAction, OpenInitialCollectionAction, OpenSearchModalAction,
34
+ OpenInPanelAction, ShowContextMenuAction,
35
+ } from './bl/actions.js';
33
36
  import { parsePackage } from './platform/pluginPackage.js';
34
37
  import workbenchComposition from './ui/compositions/workbench.js';
35
38
  import { registerBuiltinOpeners } from './ui/components/openers/index.js';
@@ -48,6 +51,7 @@ import { attachMedia } from './ui/media.js';
48
51
  * @param {Array} [options.views] extra views: `{ id, title, icon?, match?, priority?, render, move? }`
49
52
  * @param {Array} [options.settings] extra setting schemas, for whatever those contribute
50
53
  * @param {boolean} [options.serviceWorker] register /sw.js (default true)
54
+ * @param {boolean} [options.debug] install `window.__trove` (default off — see below)
51
55
  * @returns {{platform: object, engine: object, app: object}}
52
56
  */
53
57
  export function createWorkbench({
@@ -57,6 +61,14 @@ export function createWorkbench({
57
61
  views = [],
58
62
  settings = [],
59
63
  serviceWorker = true,
64
+ // OFF unless someone asks. `window.__trove` is the one place the entire resource graph —
65
+ // engine, platform, app, plus a direct `plugins.install` from raw bytes that goes around
66
+ // the review dialog — is reachable without a dispatch, and it shipped unconditionally in
67
+ // the production bundle. The global is how automation asks: Playwright's `addInitScript`
68
+ // sets it before any page script runs, and a developer can set it and reload. Nothing an
69
+ // attacker controls can set a global in this origin, and a person with our console open
70
+ // can already do all of it.
71
+ debug = globalThis.__troveDebug === true,
60
72
  } = {}) {
61
73
  const platform = createPlatform({ baseUrl });
62
74
  const { engine, app } = createApp(platform);
@@ -111,24 +123,26 @@ export function createWorkbench({
111
123
  window.addEventListener('load', () => navigator.serviceWorker.register('/sw.js').catch(() => {}));
112
124
  }
113
125
 
114
- installDragAndDrop(engine, app);
126
+ installDragAndDrop(engine);
115
127
 
116
128
  // --- initial load ---------------------------------------------------------
117
- (async () => {
118
- try {
119
- // What the server can do is an engine query now see bl/queries.js. It used to be
120
- // assigned onto `platform` here and followed by `workbench.touch()`, a poke at an
121
- // unrelated store purely to make the shell redraw, because writing a plain field
122
- // invalidates nothing. The query has somewhere for the value to arrive, so the fetch
123
- // belongs to it and this no longer has to announce it.
124
- } catch (err) {
125
- platform.notifications.error(`Cannot reach the Trove server: ${err.message}`);
126
- }
127
- // Land on a collection the user can actually read — see OpenInitialCollectionAction.
128
- engine.dispatch(new OpenInitialCollectionAction());
129
- })();
129
+ // Land on a collection the user can actually read — see OpenInitialCollectionAction.
130
+ //
131
+ // This was an async IIFE whose `try` block held only a comment, so its `catch` was not
132
+ // merely dead but a CLAIM: a reader looking for where boot verifies the server is
133
+ // reachable found "Cannot reach the Trove server" and it could never fire. The capability
134
+ // fetch it once guarded belongs to the `capabilities` provider now, whose rejection arm
135
+ // deliberately swallows whether boot should report an unreachable server is that
136
+ // provider's decision to make, not a wrapper's around one synchronous call.
137
+ engine.dispatch(new OpenInitialCollectionAction());
138
+
139
+ if (debug) installDebugHandle({ platform, engine, app });
140
+
141
+ return { platform, engine, app };
142
+ }
130
143
 
131
- // Expose for debugging / e2e.
144
+ /** The automation handle. See the `debug` option — this is not installed by default. */
145
+ function installDebugHandle({ platform, engine, app }) {
132
146
  window.__trove = {
133
147
  platform, engine, app,
134
148
  // Test/automation hook: install a package from raw zip bytes.
@@ -137,13 +151,17 @@ export function createWorkbench({
137
151
  assessTrust: (pkg) => platform.plugins.assessTrust(pkg),
138
152
  install: (pkg, opts) => platform.plugins.install(pkg, opts),
139
153
  NavigateAction,
154
+ // The intents that take a SUBJECT, and so have no command to execute. Everything
155
+ // else a probe wants is `platform.commands.execute(id)` — the same path a click
156
+ // takes. These dispatch rather than writing state, so the engine sees a probe's
157
+ // actions exactly as it sees a person's.
158
+ open: (node, openerId) => platform.dispatch(new OpenInPanelAction(node, openerId)),
159
+ contextMenu: (items, at) => platform.dispatch(new ShowContextMenuAction(items, at)),
140
160
  // Keeping a media element pointed at a URL that still works is the half of signed
141
161
  // URLs that only shows up over time, so it is drivable from a probe.
142
162
  attachMedia,
143
163
  },
144
164
  };
145
-
146
- return { platform, engine, app };
147
165
  }
148
166
 
149
167
  /**
@@ -178,7 +196,7 @@ function installDoubleShift(engine) {
178
196
  }
179
197
 
180
198
  // Dropping files anywhere on the window uploads them to the open collection.
181
- function installDragAndDrop(engine, app) {
199
+ function installDragAndDrop(engine) {
182
200
  let dragDepth = 0;
183
201
  window.addEventListener('dragenter', (e) => {
184
202
  if (!e.dataTransfer?.types.includes('Files')) return;