@3sln/trove 0.0.7 → 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 (88) hide show
  1. package/package.json +1 -1
  2. package/packages/core/src/collections/index.js +146 -1
  3. package/packages/core/src/encryption/envelope.js +483 -0
  4. package/packages/core/src/encryption/exposure.js +101 -0
  5. package/packages/core/src/encryption/keys.js +88 -0
  6. package/packages/core/src/encryption/policy.js +112 -0
  7. package/packages/core/src/encryption/rotation.js +432 -0
  8. package/packages/core/src/index.js +17 -1
  9. package/packages/core/src/links.js +85 -0
  10. package/packages/core/src/metadata/memory.js +3 -1
  11. package/packages/core/src/metadata/sqlite.js +27 -7
  12. package/packages/core/src/scan.js +35 -1
  13. package/packages/core/src/storage/cost.js +228 -0
  14. package/packages/core/src/uploads.js +302 -19
  15. package/packages/core/src/vfs.js +161 -10
  16. package/packages/server/src/adapters/staticAssets.js +14 -5
  17. package/packages/server/src/engine/index.js +1 -1
  18. package/packages/server/src/engine/providers/core.js +21 -2
  19. package/packages/server/src/index.js +45 -2
  20. package/packages/server/src/routes.js +62 -1
  21. package/packages/web/dist/assets/main-jg5vmp8f.js +356 -0
  22. package/packages/web/dist/assets/main-jg5vmp8f.js.map +118 -0
  23. package/packages/web/dist/assets/styles-e5gk19rn.css +1 -0
  24. package/packages/web/dist/index.html +9 -3
  25. package/packages/web/dist/sw.js +1 -1
  26. package/packages/web/src/bl/actions.js +1471 -138
  27. package/packages/web/src/bl/commands.js +103 -284
  28. package/packages/web/src/bl/context.js +69 -0
  29. package/packages/web/src/bl/index.js +136 -40
  30. package/packages/web/src/bl/intern.js +129 -0
  31. package/packages/web/src/bl/launcher.js +251 -0
  32. package/packages/web/src/bl/links.js +1 -1
  33. package/packages/web/src/bl/match.js +71 -0
  34. package/packages/web/src/bl/mentions.js +39 -0
  35. package/packages/web/src/bl/offline.js +0 -4
  36. package/packages/web/src/bl/openers.js +65 -33
  37. package/packages/web/src/bl/pluginInstall.js +42 -21
  38. package/packages/web/src/bl/queries.js +669 -0
  39. package/packages/web/src/bl/services.js +141 -145
  40. package/packages/web/src/bl/social.js +3 -1
  41. package/packages/web/src/bl/state.js +144 -0
  42. package/packages/web/src/bl/status.js +96 -0
  43. package/packages/web/src/bl/trust.js +109 -0
  44. package/packages/web/src/bl/viewState.js +60 -0
  45. package/packages/web/src/bl/views.js +64 -0
  46. package/packages/web/src/bl/watchQuery.js +73 -0
  47. package/packages/web/src/platform/api.js +63 -11
  48. package/packages/web/src/platform/commands.js +49 -9
  49. package/packages/web/src/platform/context.js +146 -51
  50. package/packages/web/src/platform/index.js +27 -20
  51. package/packages/web/src/platform/navigation.js +34 -4
  52. package/packages/web/src/platform/pickers.js +46 -0
  53. package/packages/web/src/platform/pluginHost.js +43 -10
  54. package/packages/web/src/platform/pluginRpc.js +6 -2
  55. package/packages/web/src/platform/spatialNav.js +0 -2
  56. package/packages/web/src/platform/viewport.js +1 -6
  57. package/packages/web/src/platform/voiceSearch.js +1 -3
  58. package/packages/web/src/platform/whenclause.js +22 -3
  59. package/packages/web/src/styles.css +77 -4
  60. package/packages/web/src/ui/activate.js +22 -0
  61. package/packages/web/src/ui/components/activityBar.js +7 -3
  62. package/packages/web/src/ui/components/activityPanel.js +10 -11
  63. package/packages/web/src/ui/components/adminView.js +191 -0
  64. package/packages/web/src/ui/components/collectionGate.js +4 -3
  65. package/packages/web/src/ui/components/commandPalette.js +24 -55
  66. package/packages/web/src/ui/components/editorArea.js +16 -13
  67. package/packages/web/src/ui/components/launcher.js +39 -225
  68. package/packages/web/src/ui/components/openers/index.js +7 -4
  69. package/packages/web/src/ui/components/openers/markdown.js +5 -3
  70. package/packages/web/src/ui/components/overlays.js +169 -43
  71. package/packages/web/src/ui/components/phoneChrome.js +35 -33
  72. package/packages/web/src/ui/components/pluginReview.js +62 -23
  73. package/packages/web/src/ui/components/pluginsView.js +30 -28
  74. package/packages/web/src/ui/components/settingsView.js +151 -58
  75. package/packages/web/src/ui/components/social.js +42 -62
  76. package/packages/web/src/ui/components/statusBar.js +30 -74
  77. package/packages/web/src/ui/components/views/grid.js +3 -2
  78. package/packages/web/src/ui/components/views/index.js +7 -62
  79. package/packages/web/src/ui/components/views/list.js +3 -2
  80. package/packages/web/src/ui/components/views/parts.js +15 -4
  81. package/packages/web/src/ui/compositions/workbench.js +166 -58
  82. package/packages/web/src/ui/region.js +53 -0
  83. package/packages/web/src/workbench.js +17 -15
  84. package/packages/web/dist/assets/main-f0f2tfhp.js +0 -356
  85. package/packages/web/dist/assets/main-f0f2tfhp.js.map +0 -104
  86. package/packages/web/dist/assets/styles-d3cyysgp.css +0 -1
  87. package/packages/web/src/platform/overlay.js +0 -81
  88. package/packages/web/src/platform/workbench.js +0 -156
@@ -0,0 +1,669 @@
1
+ // The drive's state, as engine queries.
2
+ //
3
+ // The web layer grew a second reactive system beside the engine: services holding cells, a
4
+ // `platform` bag of subsystems, an `app` bag of stores, and one derived snapshot threaded
5
+ // through every component. ngin already had all of it — see queries.js in the package:
6
+ // `QueryController` is "one live realization of a query instance, shared by all of its
7
+ // observers", `boot`/`kill` bracket a query's life, and a lease holds the providers it needs
8
+ // for exactly that long.
9
+ //
10
+ // This is step one of moving onto it (docs/tickets/009). Each query here wraps the service
11
+ // that exists today, so nothing in the UI has to change yet and the two describe the same
12
+ // state rather than competing to. The services are deleted in a later phase, at which point
13
+ // these stop wrapping and simply hold.
14
+ //
15
+ // INSTANCE IDENTITY IS THE SHARING KEY. ngin keys live realizations by the query INSTANCE —
16
+ // `#controllers` is a Map keyed on the object — and nothing anywhere looks at the class or
17
+ // the fields. So `new Explorer()` twice is two realizations, two boots and two subscriptions
18
+ // to the same underlying cell, quietly, because nothing fails.
19
+ //
20
+ // Two instances carrying the SAME arguments are still two instances. `new MediaUrl('n1')`
21
+ // twice mints two URLs and holds two leases for one file. That is the trap, and "remember to
22
+ // memoise" is not a defence: forgetting is silent, and it is the parameterised queries —
23
+ // exactly the ones worth sharing — that need it.
24
+ //
25
+ // So a parameterised query declares `static of = queryOf(TheClass)` and is asked for rather
26
+ // than constructed: the same arguments give back the same instance, which makes identity
27
+ // mean logical equality and lets both ngin's cache and watchQuery's work. See bl/intern.js.
28
+ // Parameterless queries are exported as singletons below. Either way the CLASSES stay
29
+ // private to this module and only instances leave it — which is also what stops anyone
30
+ // calling `new` on a shared query and quietly getting a second realization.
31
+
32
+ import { Query } from '@3sln/ngin';
33
+ import { queryOf } from './intern.js';
34
+ import { selectedNodesOf, draftScopesOf, collectionMenuOf } from './services.js';
35
+ import { ExecCommandAction, LoadSidecarAction, ClearSidecarAction, LoadRotationAction } from './actions.js';
36
+ import { ASSOC_KEY, describeOpener } from './openers.js';
37
+ import { rankCommands } from './match.js';
38
+ import { statusFactsOf, driveConditionOf } from './status.js';
39
+ import { launcherGroupsOf, launcherMode, searchHelpOf } from './launcher.js';
40
+ import { pickView } from './views.js';
41
+ import { prettyKey } from '../platform/keybindings.js';
42
+
43
+ /**
44
+ * A live query over one of the existing cell-backed services.
45
+ *
46
+ * The service exposes dodo's Cell protocol — `onDirty(fn)` to learn it changed, `getValue()`
47
+ * to read it — and a query wants push. Bridging is two lines, so rather than write them per
48
+ * service this takes the cell as a function of the leased `app`.
49
+ *
50
+ * It reads the CELL, never the service's own `state` field. Most services keep both in step
51
+ * (`this.state = {...}; this.cell.setValue(this.state)`) so the two usually agree, but not
52
+ * all of them do — `settings`' cell holds `effective()`, the defaults merged with the
53
+ * overrides, and there is no `state` field at all. The existing snapshot passes cells to
54
+ * `derive`, which hands it their values; reading the same way is what makes these queries
55
+ * and the snapshot describe the same state rather than two subtly different ones.
56
+ */
57
+ class ServiceQuery extends Query {
58
+ /**
59
+ * @param {string} dep the engine resource this views
60
+ * @param {(resource: object) => {onDirty: Function, getValue: Function}} cellOf
61
+ */
62
+ /** Empty at the class level; the lease is per instance — see `deps` below. */
63
+ static deps = [];
64
+
65
+ /**
66
+ * @param {string} dep the engine resource this views
67
+ * @param {(resource: object) => {onDirty: Function, getValue: Function}} cellOf
68
+ * @param {(value: any, resource: object) => any} [project] shape the value into a view
69
+ */
70
+ constructor(dep, cellOf, project) {
71
+ super();
72
+ this.dep = dep;
73
+ this.cellOf = cellOf;
74
+ this.project = project;
75
+ }
76
+
77
+ /**
78
+ * Leased per instance rather than per class, because these all share one class and each
79
+ * views a different resource. ngin leases `constructor.deps` AND `this.deps`, so an
80
+ * instance can name its own.
81
+ */
82
+ get deps() {
83
+ return [this.dep, 'appState'];
84
+ }
85
+
86
+ boot(resources, { notify }) {
87
+ const cell = this.cellOf(resources[this.dep]);
88
+ // A service may not exist in every build — `plugins` is absent when the plugin host is
89
+ // not installed, which is why the snapshot has a `?? constant([])` beside it.
90
+ if (!cell) {
91
+ notify(null);
92
+ return;
93
+ }
94
+ const resource = resources[this.dep];
95
+ const read = () => (this.project ? this.project(cell.getValue(), resource) : cell.getValue());
96
+ // The current value first: a subscriber that arrives after the last change should not
97
+ // wait for the next one to find out what is true now.
98
+ notify(read());
99
+ hold(resources, this, cell.onDirty(() => notify(read())));
100
+ }
101
+
102
+ kill(resources) {
103
+ release(resources, this);
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Where a live query keeps its subscription.
109
+ *
110
+ * NOT on the query. These instances are module-level singletons, interned so that the same
111
+ * arguments give back the same object — which is what makes ngin share one realization per
112
+ * engine. Writing `this.off` on a shared instance is therefore writing per-REALIZATION
113
+ * state onto something that outlives any one of them: two engines observing the same
114
+ * instance boot it twice, the second overwrites the first's unsubscribe, and killing either
115
+ * releases the wrong one while the other leaks.
116
+ *
117
+ * `appState` is a provider, so it is per engine; keyed by the instance, an entry is per
118
+ * (engine, instance) — which is exactly one realization. The query stays a description of
119
+ * what to watch, and nothing about it changes when it is being watched.
120
+ */
121
+ function hold(resources, query, off) {
122
+ resources.appState.set(query, off);
123
+ }
124
+
125
+ function release(resources, query) {
126
+ const off = resources.appState?.get(query);
127
+ if (Array.isArray(off)) off.forEach((fn) => fn());
128
+ else off?.();
129
+ resources.appState?.delete(query);
130
+ }
131
+
132
+ // --- the drive -----------------------------------------------------------------
133
+
134
+ /**
135
+ * Items, selection, the open collection, and the gate — plus the nodes the selection
136
+ * actually refers to.
137
+ *
138
+ * `selectedNodes` is folded in rather than left as something to call, because resolving it
139
+ * is not trivial: ids alone only match the loaded page of the current collection, and the
140
+ * launcher's rows come from search across every readable one. A view that hands over the
141
+ * answer is the difference between a component knowing what is selected and a component
142
+ * knowing how selection resolution works.
143
+ */
144
+ export const explorer = new ServiceQuery('explorer', (r) => r.observe(), (v) => ({
145
+ ...v,
146
+ selectedNodes: selectedNodesOf(v),
147
+ // The collection switcher's rows. In the view because it is a question about state, and
148
+ // because the component that shows it could not otherwise see where it came from.
149
+ collectionMenu: collectionMenuOf(v,
150
+ (id) => new ExecCommandAction('collections.switch', id),
151
+ () => new ExecCommandAction('collections.create')),
152
+ }));
153
+ /** Query text, results, and the palette's file list. */
154
+ export const search = new ServiceQuery('search', (r) => r.observe());
155
+ /** Uploads and downloads in flight. */
156
+ export const transfers = new ServiceQuery('transfers', (r) => r.observe());
157
+ /** Running tasks and standing issues, both sides of the wire. */
158
+ export const activity = new ServiceQuery('activity', (r) => r.observe());
159
+ /** Conversations, tags and backlinks for the open item. */
160
+ export const social = new ServiceQuery('social', (r) => r.observe());
161
+ /**
162
+ * Online state, pinned files, and the queue waiting to sync.
163
+ *
164
+ * `pinnedIds` is folded in so a component can ask whether THIS file is pinned without
165
+ * calling a method on the service to find out. A Set rather than a repeated scan: the
166
+ * question is asked once per row.
167
+ */
168
+ export const offline = new ServiceQuery('offline', (r) => r.observe(),
169
+ (v) => ({ ...v, pinnedIds: new Set((v?.pins || []).map((p) => p.id)) }));
170
+ /**
171
+ * The open collection's key, and any rotation running over it.
172
+ *
173
+ * Keyed by collection, so switching collections is a different query rather than a change
174
+ * to notice. `bootAction` loads it when the settings screen first looks — the alternative,
175
+ * which the API-keys section still does, is to dispatch from inside a render behind a
176
+ * module-level "have I asked yet" flag, which is a render with a side effect.
177
+ *
178
+ * It POLLS while a rotation is running, because the work is happening on the server and
179
+ * there is nothing to push. The interval belongs to the realization: it starts when
180
+ * somebody looks and stops when they look away, so a settings screen nobody has open costs
181
+ * nothing.
182
+ */
183
+ export const rotationFor = (collectionId) => RotationView.of(collectionId);
184
+
185
+ class RotationView extends Query {
186
+ static deps = ['rotation', 'appState', 'engine'];
187
+ static of = queryOf(RotationView);
188
+
189
+ constructor(collectionId) {
190
+ super();
191
+ this.collectionId = collectionId;
192
+ this.bootAction = new LoadRotationAction();
193
+ }
194
+
195
+ boot(r, { notify }) {
196
+ const cell = r.rotation.observe();
197
+ notify(cell.getValue());
198
+ const off = cell.onDirty(() => notify(cell.getValue()));
199
+ const timer = setInterval(() => {
200
+ if (r.rotation.get().rotation?.status === 'running') r.engine.dispatch(new LoadRotationAction());
201
+ }, 2500);
202
+ hold(r, this, [off, () => clearInterval(timer)]);
203
+ }
204
+
205
+ kill(r) {
206
+ release(r, this);
207
+ }
208
+ }
209
+ /** The admin API-key list, with the unsubmitted draft resolved into what it would grant. */
210
+ export const apiKeys = new ServiceQuery('apiKeys', (r) => r.observe(),
211
+ (v) => ({ ...v, draftScopes: draftScopesOf(v) }));
212
+
213
+ // --- the shell -----------------------------------------------------------------
214
+
215
+ /** Which activity is showing, and the rest of the shell's own state. */
216
+ export const workbench = new ServiceQuery('workbench', (r) => r.observe());
217
+ /** The tab and panel stack. */
218
+ export const navigation = new ServiceQuery('navigation', (r) => r.observe());
219
+ /** Dialogs, menus and panels. */
220
+ export const overlay = new ServiceQuery('overlay', (r) => r.observe());
221
+ /** Toasts. */
222
+ export const notifications = new ServiceQuery('notifications', (r) => r.observe());
223
+ /** Settings, defaults merged with overrides. */
224
+ export const settings = new ServiceQuery('settings', (r) => r.observe());
225
+ /**
226
+ * The settings SCHEMA, grouped by category, for the screen that edits them.
227
+ *
228
+ * Separate from `settings` rather than folded in, because that one emits the effective
229
+ * values keyed by setting name — adding a `groups` key to it would put a made-up entry in
230
+ * among the real ones.
231
+ */
232
+ export const settingsGroups = new ServiceQuery('settings', (r) => r.observe(), (_v, r) => r.grouped());
233
+
234
+ /** The when-clause keys: what is selected, what is open, what is focused. */
235
+ export const context = new ServiceQuery('context', (r) => r.observe());
236
+ /** Phone, desktop or TV. */
237
+ export const viewport = new ServiceQuery('viewport', (r) => r.observe());
238
+ /**
239
+ * Whether this browser can transcribe on-device, and whether it is listening now.
240
+ *
241
+ * `canListen` is folded in: it is a question about the state, so a component should not
242
+ * have to call the service to find out.
243
+ */
244
+ export const voice = new ServiceQuery('voice', (r) => r.observe(),
245
+ (v, r) => ({ ...v, canListen: !!r.canListen?.() }));
246
+ /** Installed plugins; null where the plugin host is not installed. */
247
+ export const plugins = new ServiceQuery('plugins', (r) => r?.observe?.());
248
+ /** What the UI is in the middle of doing: drafts, captures, ticked boxes. See viewState.js. */
249
+ export const viewState = new ServiceQuery('viewState', (r) => r.observe());
250
+
251
+ /**
252
+ * Contributions of one type — status items, openers, views.
253
+ *
254
+ * Parameterised, so it MUST memoise: a fresh instance per render would boot a second live
255
+ * realization every frame and never share one. Keyed by the type, which is the only thing
256
+ * that distinguishes them.
257
+ */
258
+ export const contributionsOfType = (type) => ContributionsOfType.of(type);
259
+
260
+ class ContributionsOfType extends ServiceQuery {
261
+ static of = queryOf(ContributionsOfType);
262
+
263
+ constructor(type) {
264
+ super('contributions', (r) => r.observeType(type));
265
+ this.type = type;
266
+ }
267
+ }
268
+
269
+ // --- view queries ---------------------------------------------------------------
270
+ //
271
+ // The ones above hand a service's state through unchanged. These COMPOSE: they answer a
272
+ // question the UI actually asks, in plain renderable data, with every decision already made.
273
+ //
274
+ // The rule, and it is the important one: a query emits a VIEW, never a handle. A list of
275
+ // commands is a list of descriptions — id, title, whether it is enabled right now — not the
276
+ // command objects, and never a `run` function. Interaction goes the other way, as an action
277
+ // carrying the thing's id. So a component renders what it is given and dispatches an id; it
278
+ // does not reach into a service to find out whether to draw something, and it cannot reach
279
+ // into one to make something happen.
280
+ //
281
+ // That is what takes `platform` out of the components. The status bar used to ask
282
+ // `context.evaluate(item.when)` and `plugins.isAvailable(item)` per item, mid-render, which
283
+ // is why it needed the bag at all. Those are view decisions; they belong on this side.
284
+
285
+ /**
286
+ * A query composed from several services rather than passed through from one.
287
+ *
288
+ * `sources` names the cells whose changes should recompute it; `project` builds the value.
289
+ * Split apart because what a view DEPENDS on is usually wider than what it reads from —
290
+ * the palette's command list changes when a contribution is registered, but also when a
291
+ * context key flips, because that is what decides `enabled`.
292
+ */
293
+ class ViewQuery extends Query {
294
+ static deps = [];
295
+
296
+ /**
297
+ * @param {string[]} deps the engine resources this composes
298
+ * @param {(r: object) => Array<{onDirty: Function}>} sources
299
+ * @param {(r: object) => any} project must return plain data — see the note above
300
+ */
301
+ constructor(deps, sources, project) {
302
+ super();
303
+ this.deps = [...deps, 'appState'];
304
+ this.sources = sources;
305
+ this.project = project;
306
+ }
307
+
308
+ boot(r, { notify }) {
309
+ const emit = () => notify(this.project(r));
310
+ emit();
311
+ hold(r, this, this.sources(r).filter(Boolean).map((c) => c.onDirty(emit)));
312
+ }
313
+
314
+ kill(r) {
315
+ release(r, this);
316
+ }
317
+ }
318
+
319
+ /** Contributions, context keys and plugin health — what most of these views depend on. */
320
+ const REGISTRY_DEPS = ['contributions', 'context', 'plugins', 'settings', 'commands', 'keybindings'];
321
+ const registries = (r) => [
322
+ r.contributions.observe(),
323
+ r.context.observe(),
324
+ r.plugins?.observe?.(),
325
+ r.settings.observe(),
326
+ ];
327
+
328
+ /**
329
+ * The command palette's list: every palette command, with its keybinding label resolved
330
+ * and `enabled` already decided.
331
+ *
332
+ * No `when` expression and no handler. A component shows the title, greys out what is
333
+ * disabled, and dispatches `ExecCommandAction(id)`.
334
+ */
335
+ export const paletteCommands = new ViewQuery(REGISTRY_DEPS, registries, paletteCommandsOf);
336
+
337
+ function paletteCommandsOf(r) {
338
+ return r.commands.paletteCommands().map((c) => ({
339
+ id: c.id,
340
+ title: c.title ?? c.id,
341
+ category: c.category ?? null,
342
+ icon: c.icon ?? null,
343
+ keybinding: r.keybindings.labelFor(c.id),
344
+ // Two different reasons a command might not run, and they are labelled differently in
345
+ // the palette: `available` is the plugin behind it being reachable, `enabled` also
346
+ // folds in the when-clause. Collapsing them would tag a command disabled by context as
347
+ // "offline", which is a false explanation rather than a vague one.
348
+ available: r.commands.isAvailable(c),
349
+ enabled: r.commands.isEnabled(c.id),
350
+ }));
351
+ }
352
+
353
+ // The same list, narrowed to what was typed.
354
+ //
355
+ // Ranking used to happen mid-render, in two components, with two different algorithms (see
356
+ // bl/match.js). The typed term is engine state — the palette's lives in the overlay — so
357
+ // this is an ordinary query with no arguments, and the ranking is settled before a
358
+ // component sees anything. The launcher's `!` mode ranks the same way inside
359
+ // `launcherContent`, which already holds everything it needs.
360
+ const OVERLAY_DEPS = [...REGISTRY_DEPS, 'overlay', 'workbench'];
361
+ const withShell = (r) => [...registries(r), r.workbench.observe(), r.overlay.observe()];
362
+
363
+ /** What the command palette should list right now. */
364
+ export const paletteMatches = new ViewQuery(OVERLAY_DEPS, withShell, (r) =>
365
+ rankCommands(paletteCommandsOf(r), r.overlay.get().palette?.query || ''));
366
+
367
+
368
+ /**
369
+ * Plugin-contributed status bar slots, already filtered to the ones that should show.
370
+ *
371
+ * `when` and availability are resolved here rather than in the render, which is the whole
372
+ * reason the status bar had to carry `platform` at all. `html` is still untrusted plugin
373
+ * markup and is still sanitised at the point it becomes nodes — a query emitting plain data
374
+ * says nothing about that data being safe.
375
+ */
376
+ export const statusItems = new ViewQuery(REGISTRY_DEPS, registries, (r) => {
377
+ return r.contributions.ofType('statusItem')
378
+ .filter((i) => i.visible !== false && i.html)
379
+ .filter((i) => !i.when || r.context.evaluate(i.when))
380
+ .filter((i) => r.plugins?.isAvailable?.(i) ?? true)
381
+ .sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || String(a.name).localeCompare(String(b.name)))
382
+ .map((i) => ({
383
+ id: i.id,
384
+ slot: i.slot === 'left' ? 'left' : 'right',
385
+ html: i.html,
386
+ tooltip: i.tooltip ?? null,
387
+ command: i.command ?? null,
388
+ }));
389
+ });
390
+
391
+ /**
392
+ * The effective keybindings, for the settings view: what is bound to what, right now.
393
+ *
394
+ * Everything the UI decides from is decided here — the command's title, whether the binding
395
+ * is a user override, and whether another command answers to the same chord. That last one
396
+ * is why the view carries `clash`: nothing rejects a collision and `#matchFor` scans in
397
+ * reverse so the LAST registration wins, which means binding Delete onto ⌘P silently stops
398
+ * Quick Open from opening. Detecting that needs the whole list at once, which is exactly the
399
+ * sort of thing a component rendering one row cannot do and a view can.
400
+ */
401
+ export const keybindings = new ViewQuery(REGISTRY_DEPS, registries, (r) => {
402
+ const resolved = r.keybindings.resolved();
403
+ const overrides = r.keybindings.overrides();
404
+ const perKey = new Map();
405
+ for (const b of resolved) perKey.set(b.key, (perKey.get(b.key) || 0) + 1);
406
+ return resolved.map((b) => ({
407
+ bindingId: b.bindingId,
408
+ command: b.command,
409
+ title: r.contributions.get(b.command)?.title ?? b.command,
410
+ key: b.key,
411
+ label: prettyKey(b.key),
412
+ custom: !!overrides[b.bindingId],
413
+ clash: perKey.get(b.key) > 1,
414
+ }));
415
+ });
416
+
417
+ /**
418
+ * What the launcher is showing: its groups, the view drawing them, and the help offered
419
+ * when a search found nothing. See bl/launcher.js.
420
+ *
421
+ * Parameterised by `modal` — the double-shift overlay and the home screen are BOTH on
422
+ * screen when the overlay is up, so which instance is rendering is the one input that
423
+ * genuinely is not engine state. Two values, so interning gives at most two realizations.
424
+ *
425
+ * `view` is folded in because choosing it needs the items, and the items are right here.
426
+ * That was the last thing keeping `pickView` outside the engine.
427
+ */
428
+ export const launcherContent = (modal = false) => LauncherContent.of(!!modal);
429
+
430
+ const LAUNCHER_DEPS = [...REGISTRY_DEPS, 'explorer', 'search', 'workbench', 'navigation', 'offline', 'capabilities'];
431
+
432
+ class LauncherContent extends ViewQuery {
433
+ static of = queryOf(LauncherContent);
434
+
435
+ constructor(modal) {
436
+ super(
437
+ LAUNCHER_DEPS,
438
+ (r) => [
439
+ ...registries(r),
440
+ r.explorer.observe(), r.search.observe(),
441
+ r.workbench.observe(), r.navigation.observe(), r.offline.observe(),
442
+ // Capabilities arrive after the first render — the provider hands out a cell that
443
+ // fills in — so this has to be watched, not merely read, or the search help would
444
+ // never appear on a drive whose prompt the server supplies.
445
+ r.capabilities,
446
+ ],
447
+ (r) => {
448
+ const wb = r.workbench.get();
449
+ const off = r.offline.observe().getValue() || {};
450
+ const slices = {
451
+ ex: r.explorer.observe().getValue() || {},
452
+ se: r.search.observe().getValue() || {},
453
+ nav: r.navigation.observe().getValue() || {},
454
+ query: wb.launch.query || '',
455
+ commandMatches: rankCommands(paletteCommandsOf(r), (wb.launch.query || '').slice(1)),
456
+ pinnedIds: new Set((off.pins || []).map((p) => p.id)),
457
+ keys: commandKeysOf(r),
458
+ };
459
+ const groups = launcherGroupsOf(slices, this.modal);
460
+ const mode = launcherMode(slices.query);
461
+ return {
462
+ groups,
463
+ mode,
464
+ // Which view draws them. Needs the nodes on screen, which is exactly what this
465
+ // query has just worked out — see bl/views.js. This was the last thing keeping
466
+ // the choice outside the engine.
467
+ view: pickView(viewsOf(r), groups.flatMap((g) => g.items),
468
+ mode === 'search' ? slices.se.resolved?.view ?? null : null),
469
+ // What to offer when a search found nothing. Both halves are here now: whether
470
+ // an offer applies at all (a search ran, it succeeded, it found nothing) and
471
+ // what to suggest, which the server decides — see the `capabilities` resource.
472
+ help: searchHelpOf({
473
+ eligible: mode !== 'command'
474
+ && !!slices.se.ran && !slices.se.loading && !slices.se.error
475
+ && !(slices.se.results || []).length,
476
+ caps: r.capabilities.getValue(),
477
+ }),
478
+ };
479
+ },
480
+ );
481
+ this.modal = modal;
482
+ }
483
+ }
484
+
485
+ /**
486
+ * What the drive is doing and how full it is — see bl/status.js.
487
+ *
488
+ * Both shells render these, in different shapes. Derived once, here, because the last time
489
+ * each worked them out for itself the phone bar showed a raw `col_…` id where the desktop
490
+ * bar showed the collection's name.
491
+ */
492
+ export const statusFacts = new ViewQuery(
493
+ ['explorer', 'transfers', 'activity', 'offline'],
494
+ (r) => [r.explorer.observe(), r.transfers.observe(), r.activity.observe(), r.offline.observe()],
495
+ (r) => {
496
+ const facts = statusFactsOf({
497
+ ex: r.explorer.observe().getValue(),
498
+ tr: r.transfers.observe().getValue(),
499
+ act: r.activity.observe().getValue(),
500
+ off: r.offline.observe().getValue(),
501
+ });
502
+ // The single most urgent thing, for a shell with room for one glyph. Folded in rather
503
+ // than left to the phone, because deciding that offline outranks a standing problem is
504
+ // a claim about what someone needs to know.
505
+ return { ...facts, condition: driveConditionOf(facts) };
506
+ },
507
+ );
508
+
509
+ /**
510
+ * Every opener that could run right now — `when` and plugin availability already decided,
511
+ * and the plugin's display name resolved into `source`.
512
+ *
513
+ * The SELECTOR is left on, which is the whole trick: which openers exist is a question
514
+ * about engine state and belongs here, but which of them suits the file a component is
515
+ * drawing is pure matching against data that component already holds. So the query answers
516
+ * the reactive half and `openersFor` (a pure function, below) answers the rest — rather
517
+ * than the query being parameterised by a node, which would need one live realization per
518
+ * file on screen.
519
+ *
520
+ * Descriptors, not contributions: no `component`, no `entry`. Rendering resolves the
521
+ * callable by id — see ui/components/openers.
522
+ */
523
+ export const openers = new ViewQuery(REGISTRY_DEPS, registries, (r) =>
524
+ r.contributions.ofType('opener')
525
+ .filter((o) => !o.when || r.context.evaluate(o.when))
526
+ .filter((o) => r.plugins?.isAvailable?.(o) ?? true)
527
+ .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
528
+ .map((o) => describeOpener(o, r.plugins)));
529
+
530
+ /**
531
+ * How results can be drawn, and which one the user pinned.
532
+ *
533
+ * `saved` travels with the list because choosing between them needs both, and a component
534
+ * that had to fetch the setting separately would be reaching for `settings` mid-render —
535
+ * which is what this replaces. The CHOICE itself is `pickView`, a pure function, because it
536
+ * also depends on the items on screen, and items are not something to key a query by.
537
+ *
538
+ * `render` and `move` come along. The rule about a query emitting a view rather than a
539
+ * handle is about SIDE EFFECTS — a `run()` that mutates the drive is a back door around
540
+ * actions and the feed. A view's renderer is a pure vnode builder over the arguments it is
541
+ * given, so passing it is passing data; making the component look it up by id would have
542
+ * been the registry access this is trying to remove, relocated rather than deleted.
543
+ */
544
+ export const views = new ViewQuery(REGISTRY_DEPS, registries, viewsOf);
545
+
546
+ function viewsOf(r) {
547
+ return {
548
+ views: r.contributions.ofType('view')
549
+ .filter((v) => !v.when || r.context.evaluate(v.when))
550
+ .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
551
+ .map((v) => ({
552
+ id: v.id,
553
+ title: v.title ?? v.name ?? v.id,
554
+ icon: v.icon ?? null,
555
+ match: v.match ?? null,
556
+ render: v.render,
557
+ move: v.move,
558
+ })),
559
+ saved: r.settings.get('explorer.view') || null,
560
+ };
561
+ }
562
+
563
+ /**
564
+ * The saved "always open .pdf with…" choices, for the settings screen.
565
+ *
566
+ * `missing` is decided here: an association can name an opener that has since been
567
+ * uninstalled, and the row has to say so rather than showing a bare id.
568
+ */
569
+ export const openerAssociations = new ViewQuery(REGISTRY_DEPS, registries, (r) => {
570
+ const assoc = r.settings.get(ASSOC_KEY) || {};
571
+ return Object.entries(assoc).map(([typeKey, openerId]) => {
572
+ const opener = r.contributions.get(openerId);
573
+ return { typeKey, openerId, openerTitle: opener?.title || openerId, missing: !opener };
574
+ });
575
+ });
576
+
577
+ /**
578
+ * The conversation, tags and backlinks for one file.
579
+ *
580
+ * Keyed by node, and the KEY is what replaces a reaction. This used to be an `effect` in
581
+ * bl/index.js watching the nav stack with a module-scoped `let lastTab` for change
582
+ * detection, calling `social.loadSidecar` directly — a subscription outside the engine
583
+ * doing effect work, and the last place that still worked that way.
584
+ *
585
+ * ngin already brackets this. `bootAction` is dispatched when the first observer arrives
586
+ * and `killAction` when the last one leaves, so opening a file loads its conversation and
587
+ * closing it clears it, because the query is alive for exactly as long as something is
588
+ * looking. Switching files is not a change to detect: it is one instance dying and another
589
+ * booting.
590
+ *
591
+ * The clear is SCOPED to this node. Kill and boot are not ordered against each other, so
592
+ * switching from A to B can kill A after B has booted — an unscoped clear would then wipe
593
+ * the sidecar B just asked for. It is the same race the loading path already guards, and
594
+ * naming the node is what makes it impossible rather than unlikely.
595
+ */
596
+ export const sidecarFor = (nodeId) => Sidecar.of(nodeId);
597
+
598
+ class Sidecar extends ServiceQuery {
599
+ static of = queryOf(Sidecar);
600
+
601
+ constructor(nodeId) {
602
+ super('social', (r) => r.observe(), (v) => v?.sidecar ?? null);
603
+ this.nodeId = nodeId;
604
+ this.bootAction = new LoadSidecarAction(nodeId);
605
+ this.killAction = new ClearSidecarAction(nodeId);
606
+ }
607
+ }
608
+
609
+ /**
610
+ * A file's text, capped.
611
+ *
612
+ * The first query keyed by something unbounded, which is what `queryOf` interning and the
613
+ * weak eviction were built for: one instance per (node, cap), shared by every viewer
614
+ * looking at that file, and collected once nothing is. A viewer that scrolls out of the
615
+ * tree stops observing, ngin kills the realization, and the text goes with it — no cache
616
+ * to invalidate and nothing to remember to release.
617
+ *
618
+ * `size` is a hint the API uses to decide whether a range request is worth it. It is part
619
+ * of the key because it is part of the request, and it is derived from the node id anyway,
620
+ * so it cannot split one file into two entries.
621
+ *
622
+ * Capped at the TRANSFER, not just the render: a character limit in a viewer stops us
623
+ * laying out a huge document, but the whole file still crosses the wire without this.
624
+ */
625
+ export class FileText extends Query {
626
+ static deps = ['api'];
627
+
628
+ static of = queryOf(FileText);
629
+
630
+ constructor(nodeId, maxBytes, size) {
631
+ super();
632
+ this.nodeId = nodeId;
633
+ this.maxBytes = maxBytes;
634
+ this.size = size;
635
+ }
636
+
637
+ async boot({ api }, { notify }) {
638
+ notify(await api.readTextCapped(this.nodeId, { maxBytes: this.maxBytes, size: this.size }));
639
+ }
640
+
641
+ kill() {}
642
+ }
643
+
644
+ /**
645
+ * Command id → its keybinding label, for anything that shows a shortcut next to an action.
646
+ *
647
+ * Hardcoding "⌘⇧L" told a Windows or Linux user about a key their machine does not have,
648
+ * and told everyone the default even after they had rebound it. A map rather than a list
649
+ * because every caller is asking about one command it already knows the id of.
650
+ */
651
+ export const commandKeys = new ViewQuery(REGISTRY_DEPS, registries, commandKeysOf);
652
+
653
+ function commandKeysOf(r) {
654
+ return Object.fromEntries(r.keybindings.resolved()
655
+ .map((b) => [b.command, r.keybindings.labelFor(b.command)])
656
+ .filter(([, label]) => label));
657
+ }
658
+
659
+ /**
660
+ * What this deployment can do.
661
+ *
662
+ * A plain view over the `capabilities` resource — see bl/index.js, where the provider hands
663
+ * out a cell that fills in once the server answers. This used to be a query that kept the
664
+ * PROMISE on its own instance so a re-boot would not re-fetch, which is a cache with no
665
+ * invalidation living inside a view; and it needed `initial = null` to stop the shell going
666
+ * PENDING while the request was in flight. Both go away when the fetch belongs to the
667
+ * provider: the cell already holds null, and reading a cell is all this does.
668
+ */
669
+ export const capabilities = new ServiceQuery('capabilities', (r) => r);