@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,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);
|