@3sln/trove 0.0.8 → 0.0.9
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/package.json +1 -1
- package/packages/core/src/encryption/envelope.js +46 -7
- package/packages/core/src/encryption/rotation.js +187 -68
- package/packages/core/src/uploads.js +130 -29
- package/packages/core/src/vfs.js +13 -1
- package/packages/server/src/adapters/staticAssets.js +14 -5
- package/packages/server/src/engine/index.js +1 -1
- package/packages/server/src/engine/providers/core.js +12 -0
- package/packages/server/src/index.js +45 -2
- package/packages/server/src/routes.js +59 -26
- package/packages/web/dist/assets/main-jg5vmp8f.js +356 -0
- package/packages/web/dist/assets/main-jg5vmp8f.js.map +118 -0
- package/packages/web/dist/assets/styles-e5gk19rn.css +1 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/src/bl/actions.js +1454 -137
- package/packages/web/src/bl/commands.js +103 -284
- package/packages/web/src/bl/context.js +69 -0
- package/packages/web/src/bl/index.js +136 -40
- package/packages/web/src/bl/intern.js +129 -0
- package/packages/web/src/bl/launcher.js +251 -0
- package/packages/web/src/bl/links.js +1 -1
- package/packages/web/src/bl/match.js +71 -0
- package/packages/web/src/bl/mentions.js +39 -0
- package/packages/web/src/bl/offline.js +0 -4
- package/packages/web/src/bl/openers.js +65 -33
- package/packages/web/src/bl/pluginInstall.js +42 -21
- package/packages/web/src/bl/queries.js +669 -0
- package/packages/web/src/bl/services.js +77 -141
- package/packages/web/src/bl/social.js +3 -1
- package/packages/web/src/bl/state.js +144 -0
- package/packages/web/src/bl/status.js +96 -0
- package/packages/web/src/bl/trust.js +109 -0
- package/packages/web/src/bl/viewState.js +60 -0
- package/packages/web/src/bl/views.js +64 -0
- package/packages/web/src/bl/watchQuery.js +73 -0
- package/packages/web/src/platform/api.js +31 -102
- package/packages/web/src/platform/commands.js +49 -9
- package/packages/web/src/platform/context.js +146 -51
- package/packages/web/src/platform/index.js +27 -20
- package/packages/web/src/platform/navigation.js +34 -4
- package/packages/web/src/platform/pickers.js +46 -0
- package/packages/web/src/platform/pluginHost.js +43 -10
- package/packages/web/src/platform/pluginRpc.js +6 -2
- package/packages/web/src/platform/spatialNav.js +0 -2
- package/packages/web/src/platform/viewport.js +1 -6
- package/packages/web/src/platform/voiceSearch.js +1 -3
- package/packages/web/src/platform/whenclause.js +22 -3
- package/packages/web/src/styles.css +74 -4
- package/packages/web/src/ui/activate.js +22 -0
- package/packages/web/src/ui/components/activityBar.js +7 -3
- package/packages/web/src/ui/components/activityPanel.js +10 -11
- package/packages/web/src/ui/components/adminView.js +191 -0
- package/packages/web/src/ui/components/collectionGate.js +4 -3
- package/packages/web/src/ui/components/commandPalette.js +24 -55
- package/packages/web/src/ui/components/editorArea.js +16 -13
- package/packages/web/src/ui/components/launcher.js +39 -225
- package/packages/web/src/ui/components/openers/index.js +7 -4
- package/packages/web/src/ui/components/openers/markdown.js +5 -3
- package/packages/web/src/ui/components/overlays.js +158 -45
- package/packages/web/src/ui/components/phoneChrome.js +35 -33
- package/packages/web/src/ui/components/pluginReview.js +62 -23
- package/packages/web/src/ui/components/pluginsView.js +30 -28
- package/packages/web/src/ui/components/settingsView.js +151 -58
- package/packages/web/src/ui/components/social.js +42 -62
- package/packages/web/src/ui/components/statusBar.js +30 -74
- package/packages/web/src/ui/components/views/grid.js +3 -2
- package/packages/web/src/ui/components/views/index.js +7 -62
- package/packages/web/src/ui/components/views/list.js +3 -2
- package/packages/web/src/ui/components/views/parts.js +15 -4
- package/packages/web/src/ui/compositions/workbench.js +166 -58
- package/packages/web/src/ui/region.js +53 -0
- package/packages/web/src/workbench.js +17 -15
- package/packages/web/dist/assets/main-y778bpte.js +0 -356
- package/packages/web/dist/assets/main-y778bpte.js.map +0 -106
- package/packages/web/dist/assets/styles-nfy8t3n1.css +0 -1
- package/packages/web/src/platform/overlay.js +0 -81
- package/packages/web/src/platform/workbench.js +0 -156
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Which of the registered views should draw the results.
|
|
2
|
+
//
|
|
3
|
+
// The reactive half — which views exist, their when-clauses, which one the user pinned —
|
|
4
|
+
// is the `views` query. This is the rest: arithmetic over the items on screen.
|
|
5
|
+
//
|
|
6
|
+
// Still a plain function rather than part of that query only because the launcher assembles
|
|
7
|
+
// the items mid-render. They are themselves derived from the explorer and search state, so
|
|
8
|
+
// this folds into a query as soon as that assembly does.
|
|
9
|
+
//
|
|
10
|
+
// It lived in ui/components/views/index.js, which was half a job: the registry reads moved
|
|
11
|
+
// into the engine and the DECISION stayed in the render layer. Choosing a layout from what
|
|
12
|
+
// a collection contains is a claim about the drive, not about markup.
|
|
13
|
+
|
|
14
|
+
/** Where a pinned view choice is remembered. */
|
|
15
|
+
export const SETTING = 'explorer.view';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The view to draw with, in order of how much someone meant it.
|
|
19
|
+
*
|
|
20
|
+
* 1. A saved choice — they pressed a button. Nothing infers its way past that.
|
|
21
|
+
* 2. The search transformer's hint, when there is a search on screen. It read the
|
|
22
|
+
* sentence: "photos from the trip last summer" is a request for a gallery as much as
|
|
23
|
+
* it is a query, and nothing downstream can recover that from a list of content types.
|
|
24
|
+
* 3. A view whose `match` suits what is actually there — a collection of photographs
|
|
25
|
+
* opens as a grid without anyone asking.
|
|
26
|
+
* 4. The highest priority, which is the list.
|
|
27
|
+
*
|
|
28
|
+
* A hint naming a view this build doesn't have is ignored, not an error: the transformer
|
|
29
|
+
* is deployment configuration and may outlive the build it was written against.
|
|
30
|
+
*
|
|
31
|
+
* PURE. It used to take `platform` and read the contribution registry, the context keys and
|
|
32
|
+
* the settings mid-render — three reads that nothing invalidated on, so the launcher only
|
|
33
|
+
* kept up because the shell's snapshot was coarse enough to redraw it anyway. The reactive
|
|
34
|
+
* half is the `views` query now; what is left is arithmetic over the items already on
|
|
35
|
+
* screen, which is the one input that cannot be a query (there is no sensible key for "these
|
|
36
|
+
* forty search results").
|
|
37
|
+
*
|
|
38
|
+
* @param {{views: Array, saved: string|null}} slice from the `views` query
|
|
39
|
+
* @param {Array} items the rows being drawn, `{ node }`-shaped
|
|
40
|
+
* @param {string|null} hint the search transformer's suggestion, if this is a search
|
|
41
|
+
*/
|
|
42
|
+
export function pickView({ views = [], saved = null } = {}, items = [], hint = null) {
|
|
43
|
+
if (!views.length) return null;
|
|
44
|
+
const chosen = saved && views.find((v) => v.id === saved);
|
|
45
|
+
if (chosen) return chosen;
|
|
46
|
+
const suggested = hint && views.find((v) => v.id === hint);
|
|
47
|
+
if (suggested) return suggested;
|
|
48
|
+
const nodes = items.map((i) => i.node).filter(Boolean);
|
|
49
|
+
if (nodes.length >= 3) {
|
|
50
|
+
const suits = (v) => v.match && Object.keys(v.match).length
|
|
51
|
+
&& nodes.filter((n) => matchesView(v, n)).length / nodes.length > 0.6;
|
|
52
|
+
const fitted = views.find(suits);
|
|
53
|
+
if (fitted) return fitted;
|
|
54
|
+
}
|
|
55
|
+
return views[0];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function matchesView(view, node) {
|
|
59
|
+
const ct = node.contentType || '';
|
|
60
|
+
const name = (node.name || '').toLowerCase();
|
|
61
|
+
const ext = name.includes('.') ? name.slice(name.lastIndexOf('.')) : '';
|
|
62
|
+
if ((view.match.ext || []).includes(ext)) return true;
|
|
63
|
+
return (view.match.mime || []).some((m) => (m.endsWith('/*') ? ct.startsWith(m.slice(0, -1)) : ct === m));
|
|
64
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Reading an engine query from the render layer.
|
|
2
|
+
//
|
|
3
|
+
// Mostly this exists to be the ONE place that knows how a query reaches a component. The
|
|
4
|
+
// bridging itself is a single call; the value is that it is not re-derived at fifteen call
|
|
5
|
+
// sites, and that if it ever needs an initial value or an error view, there is somewhere to
|
|
6
|
+
// put it.
|
|
7
|
+
//
|
|
8
|
+
// `engine.query(q)` answers an observable — `subscribe({next, error, complete})` — and dodo's
|
|
9
|
+
// `watch` takes a Cell — `onDirty(fn)` plus `getValue()`. `fromObservable` already bridges
|
|
10
|
+
// exactly those two shapes, so this is thin on purpose: the point is that there is ONE place
|
|
11
|
+
// that knows how a query reaches a component, rather than the adapter being re-derived at
|
|
12
|
+
// every call site.
|
|
13
|
+
//
|
|
14
|
+
// The subscription belongs to the CELL, not to the component: dodo connects a cell when its
|
|
15
|
+
// first watcher attaches and disconnects when the last one leaves, which is the same
|
|
16
|
+
// lifetime ngin gives a query realization. So a region that scrolls out of the tree stops
|
|
17
|
+
// observing, the query's `kill` runs, and whatever it was holding is released — without any
|
|
18
|
+
// component writing teardown.
|
|
19
|
+
//
|
|
20
|
+
// See docs/tickets/009.
|
|
21
|
+
|
|
22
|
+
import { fromObservable, PENDING } from '../runtime.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Cache the bridged cell per (engine, query instance).
|
|
26
|
+
*
|
|
27
|
+
* Not, as this first claimed, because two cells would "undo ngin's sharing" — they would
|
|
28
|
+
* not. Measured: two independent `fromObservable(engine.query(q))` over one instance boot
|
|
29
|
+
* the query ONCE and kill it once, because ngin shares the realization by instance and does
|
|
30
|
+
* not care how many observers arrive or through what. The sharing was never at risk.
|
|
31
|
+
*
|
|
32
|
+
* What the cache actually buys is smaller and worth stating accurately:
|
|
33
|
+
*
|
|
34
|
+
* - **Idempotence.** `watchQuery` can be called freely and returns the same cell, so it
|
|
35
|
+
* is safe in a render. Without it, `watch` — which compares its source by identity and
|
|
36
|
+
* resubscribes when it changes — would tear down and re-establish every pass.
|
|
37
|
+
* - **One fan-out per change** instead of one per bridge. Each extra cell is another
|
|
38
|
+
* observer on the controller and another invalidation to propagate.
|
|
39
|
+
*
|
|
40
|
+
* Neither is correctness, so this is an optimisation and a convenience, not a safeguard.
|
|
41
|
+
* The safeguard against duplicate realizations is interning the query instance — see
|
|
42
|
+
* bl/intern.js, which is where that problem is actually solved.
|
|
43
|
+
*
|
|
44
|
+
* Weak on the engine so a disposed engine takes its cells with it, and weak on the query so
|
|
45
|
+
* an interned instance that falls out of use does too.
|
|
46
|
+
*/
|
|
47
|
+
const cells = new WeakMap();
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {object} engine
|
|
51
|
+
* @param {object} queryInstance an INTERNED instance — see bl/intern.js
|
|
52
|
+
* @returns {object} a dodo Cell, PENDING until the query produces its first value
|
|
53
|
+
*/
|
|
54
|
+
export function watchQuery(engine, queryInstance) {
|
|
55
|
+
let forEngine = cells.get(engine);
|
|
56
|
+
if (!forEngine) {
|
|
57
|
+
forEngine = new WeakMap();
|
|
58
|
+
cells.set(engine, forEngine);
|
|
59
|
+
}
|
|
60
|
+
let cell = forEngine.get(queryInstance);
|
|
61
|
+
if (!cell) {
|
|
62
|
+
// A query may declare what it means before it knows — `initial = null` for something
|
|
63
|
+
// fetched over the network, say. Without one the cell is PENDING, and PENDING reaching
|
|
64
|
+
// a `watch` renders its placeholder: fine for a region that can show an empty bar for a
|
|
65
|
+
// frame, wrong for a snapshot feeding the whole shell, which would blank until the
|
|
66
|
+
// request came back. The initial belongs to the query rather than to this call, so it
|
|
67
|
+
// cannot differ between two call sites reading the same thing.
|
|
68
|
+
const initial = 'initial' in queryInstance ? queryInstance.initial : PENDING;
|
|
69
|
+
cell = fromObservable(engine.query(queryInstance), { initial });
|
|
70
|
+
forEngine.set(queryInstance, cell);
|
|
71
|
+
}
|
|
72
|
+
return cell;
|
|
73
|
+
}
|
|
@@ -15,25 +15,6 @@
|
|
|
15
15
|
|
|
16
16
|
import { withRetry } from '@3sln/trove/core/retry.js';
|
|
17
17
|
import { TroveError } from '@3sln/trove/core/errors.js';
|
|
18
|
-
// Imported from the module rather than the barrel so a page that never uploads to an
|
|
19
|
-
// encrypted collection still does not pull the rest of core in with it.
|
|
20
|
-
import { encrypt, encryptStream, cipherSize } from '@3sln/trove/core/encryption/envelope.js';
|
|
21
|
-
import { fromHex } from '@3sln/trove/core/encryption/keys.js';
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Seal a whole file into an envelope.
|
|
25
|
-
*
|
|
26
|
-
* Only used where the plan already decided the STORED size fits a single PUT, so the
|
|
27
|
-
* buffering is bounded by that limit rather than by the file.
|
|
28
|
-
*/
|
|
29
|
-
async function sealWhole(file, enc) {
|
|
30
|
-
const plain = new Uint8Array(await file.arrayBuffer());
|
|
31
|
-
const sealed = await encrypt(fromHex(enc.key), plain, {
|
|
32
|
-
fingerprint: fromHex(enc.fingerprint),
|
|
33
|
-
chunkSize: enc.chunkSize,
|
|
34
|
-
});
|
|
35
|
-
return new Blob([sealed], { type: file.type || 'application/octet-stream' });
|
|
36
|
-
}
|
|
37
18
|
|
|
38
19
|
export class TroveApiClient {
|
|
39
20
|
/**
|
|
@@ -257,6 +238,28 @@ export class TroveApiClient {
|
|
|
257
238
|
return this.request('POST', '/api/collections', { body });
|
|
258
239
|
}
|
|
259
240
|
|
|
241
|
+
/** Change a collection's settings — including turning encryption on. */
|
|
242
|
+
updateCollection(id, patch) {
|
|
243
|
+
return this.request('PATCH', `/api/collections/${encodeURIComponent(id)}`, { body: patch });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// --- key rotation ---------------------------------------------------------
|
|
247
|
+
//
|
|
248
|
+
// What a rotation would cost before anyone starts one: on a metered store it is a real
|
|
249
|
+
// bill, and the button should not be the first place that becomes apparent.
|
|
250
|
+
rotationEstimate(id) {
|
|
251
|
+
return this.request('GET', `/api/collections/${encodeURIComponent(id)}/rotate/estimate`);
|
|
252
|
+
}
|
|
253
|
+
rotationState(id) {
|
|
254
|
+
return this.request('GET', `/api/collections/${encodeURIComponent(id)}/rotate`);
|
|
255
|
+
}
|
|
256
|
+
beginRotation(id) {
|
|
257
|
+
return this.request('POST', `/api/collections/${encodeURIComponent(id)}/rotate`);
|
|
258
|
+
}
|
|
259
|
+
cancelRotation(id) {
|
|
260
|
+
return this.request('DELETE', `/api/collections/${encodeURIComponent(id)}/rotate`);
|
|
261
|
+
}
|
|
262
|
+
|
|
260
263
|
// --- conversations, tags, sidecar ------------------------------------------
|
|
261
264
|
sidecar(id) {
|
|
262
265
|
return this.request('GET', `/api/items/${encodeURIComponent(id)}/sidecar`);
|
|
@@ -432,26 +435,24 @@ export class TroveApiClient {
|
|
|
432
435
|
// (otherwise a multipart upload leaks server + storage state).
|
|
433
436
|
if (plan.uploadId) opts.onStart?.(plan.uploadId);
|
|
434
437
|
|
|
435
|
-
// Progress is measured against what TRAVELS, which
|
|
436
|
-
//
|
|
437
|
-
//
|
|
438
|
-
const progress = new ProgressAggregator(
|
|
438
|
+
// Progress is measured against what TRAVELS, which is now the file itself even for an
|
|
439
|
+
// encrypted collection: the drive seals it on the way to the store, so nothing larger
|
|
440
|
+
// than the file ever leaves this tab.
|
|
441
|
+
const progress = new ProgressAggregator(size, opts.onProgress);
|
|
439
442
|
const t = plan.transfer || {};
|
|
440
443
|
const completeUrl = plan.endpoints?.complete || `/api/uploads/${plan.uploadId}/complete`;
|
|
441
444
|
|
|
442
445
|
if (plan.strategy === 'single') {
|
|
443
|
-
// One presigned PUT straight to storage (bytes never touch our server).
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
|
|
447
|
-
// stored fits under the single-PUT limit.
|
|
448
|
-
const body = plan.encryption ? await sealWhole(file, plan.encryption) : file;
|
|
446
|
+
// One presigned PUT straight to storage (bytes never touch our server). The plan
|
|
447
|
+
// only ever chooses this for an UNENCRYPTED collection now — a presigned PUT of a
|
|
448
|
+
// file this client has not sealed would put plaintext in the bucket.
|
|
449
|
+
const body = file;
|
|
449
450
|
await step(() => xhrPut(t.url || plan.url, body, { headers: t.requiredHeaders, signal: opts.signal, onProgress: (l) => progress.set('single', l) }));
|
|
450
451
|
const done = await step(() => this.request('POST', completeUrl, { body: {}, signal: opts.signal }));
|
|
451
452
|
return done.node;
|
|
452
453
|
}
|
|
453
454
|
if (plan.strategy === 'direct-single') {
|
|
454
|
-
const body =
|
|
455
|
+
const body = file;
|
|
455
456
|
await step(() => xhrPut(this.baseUrl + this.#partUrl(plan, 1), body, {
|
|
456
457
|
// Our own server, so it needs our bearer token; `t.authHeaders` lets the plan
|
|
457
458
|
// add its own and wins on a clash.
|
|
@@ -462,21 +463,6 @@ export class TroveApiClient {
|
|
|
462
463
|
return done.node;
|
|
463
464
|
}
|
|
464
465
|
|
|
465
|
-
// Multipart, encrypted: sequential, and streamed.
|
|
466
|
-
//
|
|
467
|
-
// Two things stop the ordinary path working here. The parts have to be slices of the
|
|
468
|
-
// ENVELOPE rather than of the file, and the envelope is produced in order — chunk n
|
|
469
|
-
// cannot be sealed before chunk n-1, because the nonce is derived from its position. So
|
|
470
|
-
// parts are produced and sent one at a time, and the concurrency the plaintext path
|
|
471
|
-
// enjoys is not available.
|
|
472
|
-
//
|
|
473
|
-
// Streamed rather than assembled: building the whole envelope before slicing it would
|
|
474
|
-
// hold a second copy of the file in a browser tab, which for the large files that
|
|
475
|
-
// reach this path is exactly what it cannot afford.
|
|
476
|
-
if (plan.encryption) {
|
|
477
|
-
return this.#uploadSealed(file, plan, { ...opts, progress, step, completeUrl });
|
|
478
|
-
}
|
|
479
|
-
|
|
480
466
|
// Multipart (presign or direct).
|
|
481
467
|
const partSize = plan.partSize;
|
|
482
468
|
const partCount = plan.partCount ?? Math.ceil(size / partSize);
|
|
@@ -552,63 +538,6 @@ export class TroveApiClient {
|
|
|
552
538
|
}
|
|
553
539
|
|
|
554
540
|
|
|
555
|
-
/**
|
|
556
|
-
* A multipart upload of an encrypted object, one part at a time.
|
|
557
|
-
*
|
|
558
|
-
* Parts are gathered to the plan's part size because every part but the last has to clear
|
|
559
|
-
* the backend's floor, and an envelope chunk is far smaller than that.
|
|
560
|
-
*/
|
|
561
|
-
async #uploadSealed(file, plan, { signal, progress, step, completeUrl, onRetry }) {
|
|
562
|
-
const enc = plan.encryption;
|
|
563
|
-
const sealed = await encryptStream(fromHex(enc.key), file.stream(), {
|
|
564
|
-
fingerprint: fromHex(enc.fingerprint),
|
|
565
|
-
plaintextSize: file.size,
|
|
566
|
-
chunkSize: enc.chunkSize,
|
|
567
|
-
});
|
|
568
|
-
const reader = sealed.getReader();
|
|
569
|
-
const parts = [];
|
|
570
|
-
let held = [];
|
|
571
|
-
let size = 0;
|
|
572
|
-
let n = 1;
|
|
573
|
-
let sent = 0;
|
|
574
|
-
|
|
575
|
-
const flush = async () => {
|
|
576
|
-
const body = new Uint8Array(size);
|
|
577
|
-
let at = 0;
|
|
578
|
-
for (const p of held) { body.set(p, at); at += p.length; }
|
|
579
|
-
held = [];
|
|
580
|
-
size = 0;
|
|
581
|
-
const partNumber = n++;
|
|
582
|
-
const base = sent;
|
|
583
|
-
const etag = await withRetry(
|
|
584
|
-
() => this.#uploadPart(plan, partNumber, new Blob([body]), {
|
|
585
|
-
signal, onProgress: (l) => progress.set(partNumber, l),
|
|
586
|
-
}),
|
|
587
|
-
{
|
|
588
|
-
signal,
|
|
589
|
-
retries: 4,
|
|
590
|
-
onRetry: ({ attempt, delayMs, error }) => onRetry?.({ attempt, delayMs, part: partNumber, message: error.message }),
|
|
591
|
-
},
|
|
592
|
-
);
|
|
593
|
-
sent = base + body.length;
|
|
594
|
-
progress.set(partNumber, body.length);
|
|
595
|
-
parts.push({ partNumber, etag });
|
|
596
|
-
};
|
|
597
|
-
|
|
598
|
-
for (;;) {
|
|
599
|
-
const { value, done } = await reader.read();
|
|
600
|
-
if (done) break;
|
|
601
|
-
held.push(value);
|
|
602
|
-
size += value.length;
|
|
603
|
-
if (size >= plan.partSize) await flush();
|
|
604
|
-
}
|
|
605
|
-
// The final part may be under the floor, and only the final part may be.
|
|
606
|
-
if (size || parts.length === 0) await flush();
|
|
607
|
-
|
|
608
|
-
const finished = await step(() => this.request('POST', completeUrl, { body: { parts }, signal }));
|
|
609
|
-
return finished.node;
|
|
610
|
-
}
|
|
611
|
-
|
|
612
541
|
async #uploadPart(plan, n, blob, { signal, onProgress }) {
|
|
613
542
|
const t = plan.transfer || {};
|
|
614
543
|
if (plan.strategy === 'presign') {
|
|
@@ -15,16 +15,30 @@ export class CommandService {
|
|
|
15
15
|
this.contributions = contributions;
|
|
16
16
|
this.context = context;
|
|
17
17
|
this.notifications = notifications;
|
|
18
|
-
this.handlers = new Map(); // id ->
|
|
18
|
+
this.handlers = new Map(); // id -> (...args) => Action | Action[] | null
|
|
19
|
+
// How a resolved command reaches the engine. Filled in by createApp, because this
|
|
20
|
+
// service is built with the rest of the platform and the engine does not exist yet.
|
|
21
|
+
//
|
|
22
|
+
// It is a seam rather than a smell: keystrokes and plugin RPC arrive from outside the
|
|
23
|
+
// engine and something has to carry them in. Until it is set, a command says so instead
|
|
24
|
+
// of silently doing nothing.
|
|
25
|
+
this.dispatch = () => {
|
|
26
|
+
throw new Error('CommandService has no dispatcher yet — see createApp');
|
|
27
|
+
};
|
|
19
28
|
}
|
|
20
29
|
|
|
21
30
|
/**
|
|
22
|
-
* Register a command. Accepts either register(id,
|
|
23
|
-
*
|
|
31
|
+
* Register a command. Accepts either register(id, actions) or a full spec object
|
|
32
|
+
* { id, title, actions, category, icon, when, palette }.
|
|
33
|
+
*
|
|
34
|
+
* `actions` is a PURE FACTORY: `(...args) => Action | Action[] | null`. It is not a
|
|
35
|
+
* handler, and the difference is the point — a handler did the work, which meant every
|
|
36
|
+
* menu item, keybinding and palette entry a person triggered went around the engine. A
|
|
37
|
+
* factory only says what should be dispatched, and `execute` dispatches it.
|
|
24
38
|
*/
|
|
25
|
-
register(idOrSpec,
|
|
26
|
-
const spec = typeof idOrSpec === 'string' ? { id: idOrSpec,
|
|
27
|
-
if (spec.
|
|
39
|
+
register(idOrSpec, actions) {
|
|
40
|
+
const spec = typeof idOrSpec === 'string' ? { id: idOrSpec, actions } : idOrSpec;
|
|
41
|
+
if (spec.actions) this.handlers.set(spec.id, spec.actions);
|
|
28
42
|
const dispose = this.contributions.register(spec.id, {
|
|
29
43
|
type: 'command',
|
|
30
44
|
title: spec.title ?? spec.id,
|
|
@@ -58,9 +72,20 @@ export class CommandService {
|
|
|
58
72
|
return this.availability ? this.availability(cmd) : true;
|
|
59
73
|
}
|
|
60
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Run a command: resolve what it means, then dispatch it.
|
|
77
|
+
*
|
|
78
|
+
* The gating stays here rather than moving into an action, because it is the same three
|
|
79
|
+
* questions for every command however it was triggered — does it exist, is the plugin
|
|
80
|
+
* behind it answering, does its when-clause hold — and answering them in one place is
|
|
81
|
+
* what keeps a keybinding and a palette entry behaving identically.
|
|
82
|
+
*
|
|
83
|
+
* What is NOT here any more is the work. `actions` returns descriptions and this
|
|
84
|
+
* dispatches them, so the engine sees the command AND everything it causes.
|
|
85
|
+
*/
|
|
61
86
|
async execute(id, ...args) {
|
|
62
|
-
const
|
|
63
|
-
if (!
|
|
87
|
+
const actionsFor = this.handlers.get(id);
|
|
88
|
+
if (!actionsFor) {
|
|
64
89
|
this.notifications.error(`Command not found: ${id}`);
|
|
65
90
|
return;
|
|
66
91
|
}
|
|
@@ -71,7 +96,22 @@ export class CommandService {
|
|
|
71
96
|
}
|
|
72
97
|
if (!this.isEnabled(id)) return; // gated by when-clause
|
|
73
98
|
try {
|
|
74
|
-
|
|
99
|
+
const actions = actionsFor(...args);
|
|
100
|
+
if (!actions) return;
|
|
101
|
+
// One at a time, each genuinely finished before the next begins.
|
|
102
|
+
//
|
|
103
|
+
// `dispatch` answers an event FEED, not a promise for the work — awaiting it resolves
|
|
104
|
+
// immediately and the action runs afterwards, so a bare `await dispatch(a)` in a loop
|
|
105
|
+
// would start all of them at once while looking like it sequenced them. `next` on the
|
|
106
|
+
// terminal events is the actual completion signal.
|
|
107
|
+
//
|
|
108
|
+
// It resolves with whichever of complete/error/abort fired rather than rejecting, so
|
|
109
|
+
// a failing action does not throw here — but it does stop the rest, because a command
|
|
110
|
+
// whose first step failed rarely wants its second.
|
|
111
|
+
for (const action of [].concat(actions)) {
|
|
112
|
+
const settled = await this.dispatch(action).next(['complete', 'error', 'abort']);
|
|
113
|
+
if (settled?.type !== 'complete') break;
|
|
114
|
+
}
|
|
75
115
|
} catch (err) {
|
|
76
116
|
console.error(`Command ${id} failed`, err);
|
|
77
117
|
this.notifications.error(err?.message || `Command failed: ${id}`);
|
|
@@ -1,74 +1,169 @@
|
|
|
1
|
-
//
|
|
2
|
-
// describe the workbench's current state (which view is focused, whether a file
|
|
3
|
-
// is open, its type, whether the palette is showing…). when-clauses evaluate
|
|
4
|
-
// against it, so commands/keybindings/menus light up and dim as state changes.
|
|
1
|
+
// ContextRegistry — the named facts a when-clause is evaluated against.
|
|
5
2
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
3
|
+
// A REGISTRY of cells, not a bag of values, and the difference is the whole design:
|
|
4
|
+
//
|
|
5
|
+
// - Every key is backed by a cell somebody OWNS. The registry never hands the cell out,
|
|
6
|
+
// only its value, so a key can be read by anyone and written by exactly one thing.
|
|
7
|
+
// - A contributor registers while it exists and unregisters when it is disposed, so the
|
|
8
|
+
// set of keys is a fact about what is currently installed rather than a pile that only
|
|
9
|
+
// grows.
|
|
10
|
+
// - Because the backings are cells, a derived layer sits on top for free: a built-in key
|
|
11
|
+
// is a `derive` over the resource it summarises, and so cannot go stale.
|
|
12
|
+
//
|
|
13
|
+
// That last point is why this replaced a plain map with setters. Context keys are DERIVED
|
|
14
|
+
// state — what is selected, what is open, what is focused — and they were maintained by
|
|
15
|
+
// pushing: OverlayService wrote `palette.open` as a side effect of opening the palette,
|
|
16
|
+
// NavigationService wrote three `editor.*` keys, WorkbenchService four more, ViewportService
|
|
17
|
+
// three, and an effect in bl/index.js the explorer's two. Five writers for one derivation,
|
|
18
|
+
// and the bug that shape produces is on the record: selecting a file never flipped
|
|
19
|
+
// `explorer.hasSelection`, so the Delete keybinding silently did nothing, because only
|
|
20
|
+
// NavigateAction had been taught to set it. The fix at the time was to add another writer.
|
|
21
|
+
//
|
|
22
|
+
// Nothing sets a built-in key now. They are derived — see bl/context.js.
|
|
23
|
+
|
|
24
|
+
import { cell, derive } from '../runtime.js';
|
|
25
|
+
import { evaluateWhen, compileWhen } from './whenclause.js';
|
|
10
26
|
|
|
11
|
-
|
|
12
|
-
|
|
27
|
+
/** A cell whose value never changes — for facts about the machine rather than the drive. */
|
|
28
|
+
const constant = (value) => ({ onDirty: () => () => {}, getValue: () => value });
|
|
29
|
+
|
|
30
|
+
export class ContextRegistry {
|
|
31
|
+
#cells = new Map(); // key -> a Cell its owner holds
|
|
32
|
+
#offs = new Map(); // key -> unsubscribe
|
|
33
|
+
#slots = new Map(); // key -> a stable cell that outlives any one owner
|
|
34
|
+
#snapshot = cell({});
|
|
13
35
|
|
|
14
|
-
export class ContextKeyService {
|
|
15
36
|
constructor(initial = {}) {
|
|
16
|
-
|
|
37
|
+
const fixed = {
|
|
17
38
|
platform: navigatorPlatform(),
|
|
18
39
|
isMac: /mac/i.test(navigatorPlatform()),
|
|
19
40
|
...initial,
|
|
20
41
|
};
|
|
21
|
-
|
|
42
|
+
for (const [key, value] of Object.entries(fixed)) this.register(key, constant(value));
|
|
22
43
|
}
|
|
23
44
|
|
|
24
|
-
|
|
25
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Back `key` with a cell. Returns the unregister function.
|
|
47
|
+
*
|
|
48
|
+
* The caller keeps the cell, and that is what makes it the owner: writing means writing
|
|
49
|
+
* something the registry cannot reach. Registering a key twice is refused rather than
|
|
50
|
+
* silently taken over — two owners for one fact is exactly the confusion this shape
|
|
51
|
+
* exists to prevent, so it should be loud.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} key
|
|
54
|
+
* @param {{onDirty: Function, getValue: Function}} source
|
|
55
|
+
* @returns {() => void} unregister
|
|
56
|
+
*/
|
|
57
|
+
register(key, source) {
|
|
58
|
+
if (this.#cells.has(key)) {
|
|
59
|
+
throw new Error(`Context key "${key}" already has an owner`);
|
|
60
|
+
}
|
|
61
|
+
this.#cells.set(key, source);
|
|
62
|
+
this.#offs.set(key, source.onDirty(() => this.#changed(key)));
|
|
63
|
+
this.#changed(key); // gaining an owner is itself a change
|
|
64
|
+
return () => this.unregister(key);
|
|
26
65
|
}
|
|
27
|
-
|
|
28
|
-
|
|
66
|
+
|
|
67
|
+
unregister(key) {
|
|
68
|
+
const off = this.#offs.get(key);
|
|
69
|
+
if (!off) return;
|
|
70
|
+
off();
|
|
71
|
+
this.#offs.delete(key);
|
|
72
|
+
this.#cells.delete(key);
|
|
73
|
+
this.#changed(key); // and so is losing one
|
|
29
74
|
}
|
|
30
|
-
|
|
31
|
-
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A cell for one key, whether or not anything owns it yet.
|
|
78
|
+
*
|
|
79
|
+
* The indirection is the point. A keymap naming a plugin's register is parsed long before
|
|
80
|
+
* that plugin installs, and the plugin may later be uninstalled — so a watcher cannot
|
|
81
|
+
* hold the OWNER's cell, which does not exist at either end. It holds this slot, which
|
|
82
|
+
* reads through to whoever currently owns the key and goes dirty when ownership changes
|
|
83
|
+
* as well as when the value does. Unowned reads as `undefined`, which every clause
|
|
84
|
+
* already treats as falsy.
|
|
85
|
+
*
|
|
86
|
+
* Slots are kept once created. They are bounded by the number of distinct keys any
|
|
87
|
+
* when-clause has ever named, which is small and does not grow with use.
|
|
88
|
+
*/
|
|
89
|
+
cellFor(key) {
|
|
90
|
+
let slot = this.#slots.get(key);
|
|
91
|
+
if (!slot) {
|
|
92
|
+
const listeners = new Set();
|
|
93
|
+
slot = {
|
|
94
|
+
listeners,
|
|
95
|
+
onDirty: (fn) => { listeners.add(fn); return () => listeners.delete(fn); },
|
|
96
|
+
getValue: () => this.#cells.get(key)?.getValue(),
|
|
97
|
+
};
|
|
98
|
+
this.#slots.set(key, slot);
|
|
99
|
+
}
|
|
100
|
+
return slot;
|
|
32
101
|
}
|
|
33
102
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
103
|
+
/**
|
|
104
|
+
* A when-clause as a live boolean.
|
|
105
|
+
*
|
|
106
|
+
* Derived over exactly the keys the expression NAMES (the parser collects them — see
|
|
107
|
+
* whenclause.js), so a clause about `view.active` does not recompute when the selection
|
|
108
|
+
* changes. That is the difference from `evaluate`, which reads the whole snapshot: a
|
|
109
|
+
* consumer holding many clauses — the palette holds one per registered command — stops
|
|
110
|
+
* re-running all of them on every unrelated change.
|
|
111
|
+
*
|
|
112
|
+
* A clause naming nothing derives over nothing and is computed once, which is right: a
|
|
113
|
+
* constant cannot become false.
|
|
114
|
+
*/
|
|
115
|
+
watch(expr) {
|
|
116
|
+
const predicate = compileWhen(expr);
|
|
117
|
+
const keys = predicate.keys ?? [];
|
|
118
|
+
return derive(keys.map((key) => this.cellFor(key)), (...values) => {
|
|
119
|
+
const ctx = {};
|
|
120
|
+
for (let i = 0; i < keys.length; i++) ctx[keys[i]] = values[i];
|
|
121
|
+
return predicate(ctx);
|
|
122
|
+
});
|
|
38
123
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
next[k] = v;
|
|
45
|
-
changed = true;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
if (changed) {
|
|
49
|
-
this.state = next;
|
|
50
|
-
this.cell.setValue(next);
|
|
51
|
-
}
|
|
124
|
+
|
|
125
|
+
#changed(key) {
|
|
126
|
+
this.#recompute();
|
|
127
|
+
const slot = this.#slots.get(key);
|
|
128
|
+
if (slot) for (const fn of [...slot.listeners]) fn();
|
|
52
129
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Own a key outright: the registry makes the cell and hands back the writer.
|
|
133
|
+
*
|
|
134
|
+
* For the facts that genuinely are pushed rather than derived — a plugin setting its own
|
|
135
|
+
* declared register over RPC. The write capability IS the returned `set`, so holding a
|
|
136
|
+
* reference to the registry is not enough to change anything.
|
|
137
|
+
*/
|
|
138
|
+
own(key, initial = undefined) {
|
|
139
|
+
const held = cell(initial);
|
|
140
|
+
const dispose = this.register(key, held);
|
|
141
|
+
return { set: (value) => held.setValue(value), dispose };
|
|
58
142
|
}
|
|
59
143
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
144
|
+
#recompute() {
|
|
145
|
+
const next = {};
|
|
146
|
+
for (const [key, source] of this.#cells) next[key] = source.getValue();
|
|
147
|
+
this.#snapshot.setValue(next);
|
|
63
148
|
}
|
|
64
149
|
|
|
65
|
-
/**
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
150
|
+
/** Every key and its current value. */
|
|
151
|
+
snapshot() {
|
|
152
|
+
return this.#snapshot.getValue();
|
|
153
|
+
}
|
|
154
|
+
get(key) {
|
|
155
|
+
return this.#cells.get(key)?.getValue();
|
|
156
|
+
}
|
|
157
|
+
has(key) {
|
|
158
|
+
return this.#cells.has(key);
|
|
159
|
+
}
|
|
160
|
+
observe() {
|
|
161
|
+
return this.#snapshot;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** True if `whenExpr` holds against the current context. */
|
|
165
|
+
evaluate(whenExpr) {
|
|
166
|
+
return evaluateWhen(whenExpr, this.snapshot());
|
|
72
167
|
}
|
|
73
168
|
}
|
|
74
169
|
|