@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.
- package/README.md +13 -1
- package/package.json +2 -1
- package/packages/core/src/collections/index.js +71 -10
- package/packages/core/src/encryption/keys.js +4 -3
- package/packages/core/src/encryption/policy.js +14 -7
- package/packages/core/src/encryption/rotation.js +46 -12
- package/packages/core/src/errors.js +8 -0
- package/packages/core/src/index.js +8 -2
- package/packages/core/src/indexers/contribution.js +18 -17
- package/packages/core/src/indexing.js +8 -4
- package/packages/core/src/issues.js +8 -0
- package/packages/core/src/links.js +21 -9
- package/packages/core/src/metadata/interface.js +22 -0
- package/packages/core/src/metadata/memory.js +15 -0
- package/packages/core/src/metadata/sqlite.js +23 -1
- package/packages/core/src/plugins/contributions.js +13 -0
- package/packages/core/src/rateLimit.js +253 -0
- package/packages/core/src/scan.js +44 -16
- package/packages/core/src/sidecar/document.js +18 -14
- package/packages/core/src/sidecar/index.js +9 -10
- package/packages/core/src/sidecar/manager.js +10 -15
- package/packages/core/src/uploads.js +40 -15
- package/packages/core/src/vfs.js +59 -34
- package/packages/plugin-sdk/src/browser.js +155 -0
- package/packages/plugin-sdk/src/protocol.js +13 -1
- package/packages/server/src/access/externalEvaluation.js +17 -0
- package/packages/server/src/adapters/bun.js +1 -2
- package/packages/server/src/adapters/node.js +1 -2
- package/packages/server/src/engine/index.js +1 -1
- package/packages/server/src/engine/providers/access.js +17 -34
- package/packages/server/src/engine/providers/core.js +112 -14
- package/packages/server/src/index.js +160 -105
- package/packages/server/src/mcp/index.js +7 -12
- package/packages/server/src/mcp/tools.js +24 -29
- package/packages/server/src/router.js +31 -8
- package/packages/server/src/routes.js +74 -113
- package/packages/server/src/scope.js +78 -0
- package/packages/web/dist/assets/main-828yzsr7.js +511 -0
- package/packages/web/dist/assets/main-828yzsr7.js.map +120 -0
- package/packages/web/dist/index.html +1 -1
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/src/bl/actions.js +226 -104
- package/packages/web/src/bl/activity.js +32 -82
- package/packages/web/src/bl/index.js +23 -11
- package/packages/web/src/bl/launcher.js +2 -9
- package/packages/web/src/bl/links.js +31 -18
- package/packages/web/src/bl/offline.js +24 -10
- package/packages/web/src/bl/pluginInstall.js +29 -26
- package/packages/web/src/bl/queries.js +76 -68
- package/packages/web/src/bl/services.js +22 -14
- package/packages/web/src/bl/social.js +39 -25
- package/packages/web/src/bl/state.js +22 -4
- package/packages/web/src/bl/viewState.js +9 -0
- package/packages/web/src/bl/watchQuery.js +1 -8
- package/packages/web/src/dispatch.js +26 -0
- package/packages/web/src/platform/api.js +32 -0
- package/packages/web/src/platform/commands.js +14 -15
- package/packages/web/src/platform/context.js +3 -58
- package/packages/web/src/platform/contributions.js +1 -35
- package/packages/web/src/platform/fileChunks.js +281 -0
- package/packages/web/src/platform/index.js +8 -9
- package/packages/web/src/platform/mediaUrls.js +10 -0
- package/packages/web/src/platform/navigation.js +35 -21
- package/packages/web/src/platform/pickers.js +39 -19
- package/packages/web/src/platform/pluginHost.js +4 -1
- package/packages/web/src/platform/pluginRpc.js +47 -2
- package/packages/web/src/platform/spatialNav.js +1 -1
- package/packages/web/src/platform/viewport.js +14 -6
- package/packages/web/src/platform/voiceSearch.js +17 -9
- package/packages/web/src/platform/whenclause.js +9 -19
- package/packages/web/src/runtime.js +2 -37
- package/packages/web/src/ui/activate.js +18 -2
- package/packages/web/src/ui/components/activityPanel.js +1 -1
- package/packages/web/src/ui/components/adminView.js +4 -7
- package/packages/web/src/ui/components/editorArea.js +10 -1
- package/packages/web/src/ui/components/launcher.js +29 -13
- package/packages/web/src/ui/components/openers/index.js +17 -22
- package/packages/web/src/ui/components/openers/markdown.js +2 -2
- package/packages/web/src/ui/components/overlays.js +31 -5
- package/packages/web/src/ui/components/pluginReview.js +7 -4
- package/packages/web/src/ui/components/pluginsView.js +1 -1
- package/packages/web/src/ui/components/settingsView.js +30 -27
- package/packages/web/src/ui/components/statusBar.js +8 -6
- package/packages/web/src/ui/components/views/grid.js +2 -2
- package/packages/web/src/ui/components/views/parts.js +1 -1
- package/packages/web/src/ui/compositions/workbench.js +20 -6
- package/packages/web/src/ui/sanitize.js +0 -5
- package/packages/web/src/workbench.js +37 -19
- package/packages/web/dist/assets/main-c9dnnnc6.js +0 -356
- 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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
|
59
|
+
ui.engine.dispatch(new SearchAction('')); // empty query resets results/ran/error
|
|
45
60
|
}
|
|
46
|
-
function runFilter(ui, filters, text) {
|
|
47
|
-
|
|
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
|
-
//
|
|
181
|
-
//
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
195
|
-
onError: (msg) => status
|
|
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 {
|
|
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
|
-
|
|
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 *
|
|
305
|
-
const x =
|
|
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
|
|
18
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
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
|
|
126
|
+
installDragAndDrop(engine);
|
|
115
127
|
|
|
116
128
|
// --- initial load ---------------------------------------------------------
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
|
199
|
+
function installDragAndDrop(engine) {
|
|
182
200
|
let dragDepth = 0;
|
|
183
201
|
window.addEventListener('dragenter', (e) => {
|
|
184
202
|
if (!e.dataTransfer?.types.includes('Files')) return;
|