@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.
Files changed (78) hide show
  1. package/package.json +1 -1
  2. package/packages/core/src/encryption/envelope.js +46 -7
  3. package/packages/core/src/encryption/rotation.js +187 -68
  4. package/packages/core/src/uploads.js +130 -29
  5. package/packages/core/src/vfs.js +13 -1
  6. package/packages/server/src/adapters/staticAssets.js +14 -5
  7. package/packages/server/src/engine/index.js +1 -1
  8. package/packages/server/src/engine/providers/core.js +12 -0
  9. package/packages/server/src/index.js +45 -2
  10. package/packages/server/src/routes.js +59 -26
  11. package/packages/web/dist/assets/main-jg5vmp8f.js +356 -0
  12. package/packages/web/dist/assets/main-jg5vmp8f.js.map +118 -0
  13. package/packages/web/dist/assets/styles-e5gk19rn.css +1 -0
  14. package/packages/web/dist/index.html +2 -2
  15. package/packages/web/dist/sw.js +1 -1
  16. package/packages/web/src/bl/actions.js +1454 -137
  17. package/packages/web/src/bl/commands.js +103 -284
  18. package/packages/web/src/bl/context.js +69 -0
  19. package/packages/web/src/bl/index.js +136 -40
  20. package/packages/web/src/bl/intern.js +129 -0
  21. package/packages/web/src/bl/launcher.js +251 -0
  22. package/packages/web/src/bl/links.js +1 -1
  23. package/packages/web/src/bl/match.js +71 -0
  24. package/packages/web/src/bl/mentions.js +39 -0
  25. package/packages/web/src/bl/offline.js +0 -4
  26. package/packages/web/src/bl/openers.js +65 -33
  27. package/packages/web/src/bl/pluginInstall.js +42 -21
  28. package/packages/web/src/bl/queries.js +669 -0
  29. package/packages/web/src/bl/services.js +77 -141
  30. package/packages/web/src/bl/social.js +3 -1
  31. package/packages/web/src/bl/state.js +144 -0
  32. package/packages/web/src/bl/status.js +96 -0
  33. package/packages/web/src/bl/trust.js +109 -0
  34. package/packages/web/src/bl/viewState.js +60 -0
  35. package/packages/web/src/bl/views.js +64 -0
  36. package/packages/web/src/bl/watchQuery.js +73 -0
  37. package/packages/web/src/platform/api.js +31 -102
  38. package/packages/web/src/platform/commands.js +49 -9
  39. package/packages/web/src/platform/context.js +146 -51
  40. package/packages/web/src/platform/index.js +27 -20
  41. package/packages/web/src/platform/navigation.js +34 -4
  42. package/packages/web/src/platform/pickers.js +46 -0
  43. package/packages/web/src/platform/pluginHost.js +43 -10
  44. package/packages/web/src/platform/pluginRpc.js +6 -2
  45. package/packages/web/src/platform/spatialNav.js +0 -2
  46. package/packages/web/src/platform/viewport.js +1 -6
  47. package/packages/web/src/platform/voiceSearch.js +1 -3
  48. package/packages/web/src/platform/whenclause.js +22 -3
  49. package/packages/web/src/styles.css +74 -4
  50. package/packages/web/src/ui/activate.js +22 -0
  51. package/packages/web/src/ui/components/activityBar.js +7 -3
  52. package/packages/web/src/ui/components/activityPanel.js +10 -11
  53. package/packages/web/src/ui/components/adminView.js +191 -0
  54. package/packages/web/src/ui/components/collectionGate.js +4 -3
  55. package/packages/web/src/ui/components/commandPalette.js +24 -55
  56. package/packages/web/src/ui/components/editorArea.js +16 -13
  57. package/packages/web/src/ui/components/launcher.js +39 -225
  58. package/packages/web/src/ui/components/openers/index.js +7 -4
  59. package/packages/web/src/ui/components/openers/markdown.js +5 -3
  60. package/packages/web/src/ui/components/overlays.js +158 -45
  61. package/packages/web/src/ui/components/phoneChrome.js +35 -33
  62. package/packages/web/src/ui/components/pluginReview.js +62 -23
  63. package/packages/web/src/ui/components/pluginsView.js +30 -28
  64. package/packages/web/src/ui/components/settingsView.js +151 -58
  65. package/packages/web/src/ui/components/social.js +42 -62
  66. package/packages/web/src/ui/components/statusBar.js +30 -74
  67. package/packages/web/src/ui/components/views/grid.js +3 -2
  68. package/packages/web/src/ui/components/views/index.js +7 -62
  69. package/packages/web/src/ui/components/views/list.js +3 -2
  70. package/packages/web/src/ui/components/views/parts.js +15 -4
  71. package/packages/web/src/ui/compositions/workbench.js +166 -58
  72. package/packages/web/src/ui/region.js +53 -0
  73. package/packages/web/src/workbench.js +17 -15
  74. package/packages/web/dist/assets/main-y778bpte.js +0 -356
  75. package/packages/web/dist/assets/main-y778bpte.js.map +0 -106
  76. package/packages/web/dist/assets/styles-nfy8t3n1.css +0 -1
  77. package/packages/web/src/platform/overlay.js +0 -81
  78. 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 for an encrypted upload is the
436
- // envelope larger than the file by a header and a tag per chunk. Measured against the
437
- // file, the bar would pass 100% and sit there.
438
- const progress = new ProgressAggregator(plan.encryption?.storedSize || size, opts.onProgress);
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
- // Sealed here, before a byte leaves the browser, so the bucket only ever receives
445
- // ciphertext while the PUT still goes straight to it. The whole envelope is built in
446
- // memory, which is fine on this path: the plan only chooses `single` when what is
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 = plan.encryption ? await sealWhole(file, plan.encryption) : file;
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 -> fn
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, handler) or a full spec
23
- * object { id, title, handler, category, icon, when, palette }.
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, handler) {
26
- const spec = typeof idOrSpec === 'string' ? { id: idOrSpec, handler } : idOrSpec;
27
- if (spec.handler) this.handlers.set(spec.id, spec.handler);
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 handler = this.handlers.get(id);
63
- if (!handler) {
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
- return await handler(...args);
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
- // ContextKeyService — the reactive bag of boolean/string/number flags that
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
- // It's a single cell of the whole context object, so any consumer can `watch` it and
7
- // re-render, and keybinding resolution reads a plain snapshot.
8
- // Plugins get a *scoped* setter (keys they set are namespaced under their id) so
9
- // they can drive their own when-clauses without stomping core keys.
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
- import { cell } from '../runtime.js';
12
- import { evaluateWhen } from './whenclause.js';
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
- this.state = {
37
+ const fixed = {
17
38
  platform: navigatorPlatform(),
18
39
  isMac: /mac/i.test(navigatorPlatform()),
19
40
  ...initial,
20
41
  };
21
- this.cell = cell(this.state);
42
+ for (const [key, value] of Object.entries(fixed)) this.register(key, constant(value));
22
43
  }
23
44
 
24
- get(key) {
25
- return this.state[key];
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
- snapshot() {
28
- return this.state;
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
- observe() {
31
- return this.cell;
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
- set(key, value) {
35
- if (this.state[key] === value) return;
36
- this.state = { ...this.state, [key]: value };
37
- this.cell.setValue(this.state);
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
- setMany(obj) {
40
- let changed = false;
41
- const next = { ...this.state };
42
- for (const [k, v] of Object.entries(obj)) {
43
- if (next[k] !== v) {
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
- remove(key) {
54
- if (!(key in this.state)) return;
55
- const { [key]: _drop, ...rest } = this.state;
56
- this.state = rest;
57
- this.cell.setValue(rest);
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
- /** True if `whenExpr` holds against the current context. */
61
- evaluate(whenExpr) {
62
- return evaluateWhen(whenExpr, this.state);
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
- /** A setter namespaced under a plugin id, so plugin keys can't collide. */
66
- scopedFor(pluginId) {
67
- const prefix = `${pluginId}.`;
68
- return {
69
- set: (key, value) => this.set(prefix + key, value),
70
- remove: (key) => this.remove(prefix + key),
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