@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
@@ -1,17 +1,22 @@
1
- // Root composition. Assembles the `ui` helper (stable for the app's life),
2
- // zips every reactive slice the shell needs into one snapshot, and renders the
3
- // full workbench from it. Components stay pure — they receive (state, ui) and
4
- // either call ui.exec(commandId) or ui.go(action). This is the only module that
5
- // knows about every service at once.
1
+ // Root composition. Assembles the `ui` helper (stable for the app's life), zips every
2
+ // query the shell reads into one snapshot, and renders the workbench from it.
3
+ //
4
+ // Components are pure: they receive (state, ui) and dispatch. This module knows about
5
+ // every QUERY at once — which is a different thing from what it used to know, when it
6
+ // held the bag of services every component reached through.
6
7
 
7
- import { dd, cell, derive, constant } from '../../runtime.js';
8
- import { NavigateAction } from '../../bl/actions.js';
8
+ import { dd, derive, constant, watch } from '../../runtime.js';
9
+ import { region } from '../region.js';
10
+ import { watchQuery } from '../../bl/watchQuery.js';
11
+ import * as q from '../../bl/queries.js';
12
+ import { CloseSearchModalAction, ExecCommandAction, NavigateAction } from '../../bl/actions.js';
9
13
  import activityBar from '../components/activityBar.js';
10
14
  import statusBar from '../components/statusBar.js';
11
15
  import launcher from '../components/launcher.js';
12
16
  import settingsView from '../components/settingsView.js';
13
17
  import collectionGate from '../components/collectionGate.js';
14
18
  import pluginsView from '../components/pluginsView.js';
19
+ import adminView from '../components/adminView.js';
15
20
  import editorArea from '../components/editorArea.js';
16
21
  import commandPalette from '../components/commandPalette.js';
17
22
  import { dialog, contextMenu, toasts, transferTray, pluginPanel } from '../components/overlays.js';
@@ -21,89 +26,192 @@ import { phoneTopBar, phoneBottomBar, phoneSheet } from '../components/phoneChro
21
26
 
22
27
  const { alias, div } = dd;
23
28
 
24
- export default function workbench({ engine, app, platform, plugins }) {
25
- // A counter rather than a timestamp: a cell drops a write of the value it already
26
- // holds, and two `rerender()` calls in the same millisecond would have compared equal.
27
- const bump = cell(0);
29
+ export default function workbench({ engine, platform }) {
30
+ // What a component is handed, and it is nearly nothing now.
31
+ //
32
+ // `engine` is the thing components talk to: `dispatch` for an action, `query` for a
33
+ // question. It was removed once for being unreferenced, which was true and backwards —
34
+ // nothing referenced it precisely BECAUSE `go` and `exec` stood in for it, and `exec`
35
+ // called the command service directly, so every menu item, keybinding and palette entry
36
+ // a person triggered was invisible to the engine.
37
+ //
38
+ // Both are gone. `go(action)` was `engine.dispatch(action)` with a shorter name, and a
39
+ // shorter name for the one door is how the door stops being obvious. Call sites say
40
+ // `ui.engine.dispatch(new SomeAction(...))` now: longer, and it names what happens.
41
+ //
42
+ // `platform` is still passed, for four uses that are genuinely imperative: two plugin
43
+ // iframe mounts, the media URL machinery, and the opener lookup in openers/index.js —
44
+ // which stays because an opener may resolve to a plugin iframe rather than to a render
45
+ // function, and mounting one is an effect, not a view.
46
+ //
47
+ // Note what is NOT a reason: carrying a function. A query hands over the view renderers
48
+ // (see the `views` query) because a renderer is pure — it builds a vnode from what it is
49
+ // given. The rule is about SIDE EFFECTS escaping through a callable, not about callables.
50
+ //
51
+ // See docs/tickets/009. `app` used to be here too — the whole bag of services — and is
52
+ // gone: nothing in `ui/` reads a service directly any more.
28
53
  const ui = {
29
- engine, app, platform,
30
- go: (action) => engine.dispatch(action),
31
- exec: (id, ...args) => platform.commands.execute(id, ...args),
32
- rerender: () => bump.update((n) => n + 1),
33
- uninstallPlugin: (id) => plugins?.uninstall(id),
54
+ engine, platform,
55
+ // Rendering a cell is a UI concern, not a platform subsystem to reach through.
56
+ watch,
34
57
  };
35
58
 
36
- const { watch } = platform.reactive;
37
59
  // One derived snapshot of every slice the shell reads. `derive` invalidates when any
38
60
  // of them does and recomputes once on the next read, so fifteen changes in a frame
39
61
  // still cost one render.
40
62
  const combined = derive(
63
+ // EVERY input is a query. Half of these used to read the service cell directly, which
64
+ // looked equivalent and was not: a query may PROJECT, and the projection is where
65
+ // `offline.pinnedIds` and `voice.canListen` come from. Reading the raw cell got the
66
+ // service's state without them, so a pinned file's menu still offered to pin it and the
67
+ // microphone button never appeared — silently, because an undefined field just reads as
68
+ // false. Going through the engine uniformly is what makes "the same slice" mean the
69
+ // same thing everywhere.
41
70
  [
42
- platform.workbench.observe(),
43
- platform.workbench.observeOverlay(),
44
- platform.workbench.observeNav(),
45
- app.explorer.observe(),
46
- app.search.observe(),
47
- app.apiKeys.observe(),
48
- app.transfers.observe(),
49
- platform.notifications.observe(),
50
- platform.context.observe(),
51
- platform.settings.observe(),
52
- platform.plugins.observe() ?? constant([]),
53
- platform.contributions.observeType('statusItem'),
54
- app.social.observe(),
55
- app.offline.observe(),
56
- app.activity.observe(),
57
- platform.viewport.observe(),
58
- platform.voice.observe(),
59
- bump,
71
+ watchQuery(engine, q.workbench),
72
+ watchQuery(engine, q.overlay),
73
+ watchQuery(engine, q.navigation),
74
+ watchQuery(engine, q.explorer),
75
+ watchQuery(engine, q.search),
76
+ watchQuery(engine, q.context),
77
+ watchQuery(engine, q.settings),
78
+ watchQuery(engine, q.offline),
79
+ watchQuery(engine, q.viewport),
80
+ watchQuery(engine, q.voice),
81
+ // What the UI is in the middle of doing — see bl/viewState.js.
82
+ watchQuery(engine, q.viewState),
83
+ // What the server can do. Its resource hands out a cell that starts null and fills
84
+ // in, so this is a normal value while the request is in flight rather than turning
85
+ // the whole snapshot PENDING and blanking the shell.
86
+ watchQuery(engine, q.capabilities),
87
+ // Command id -> its shortcut label, for anything that shows one next to an action.
88
+ watchQuery(engine, q.commandKeys),
89
+ // What the launcher shows, twice: the home screen and the double-shift overlay are
90
+ // both mounted when the overlay is up, and they differ in what picking a row does —
91
+ // the overlay resets the viewer stack and then dismisses itself. That is the only
92
+ // input that is about which INSTANCE is rendering, so it is the query's one
93
+ // parameter, and interning means at most two live realizations.
94
+ watchQuery(engine, q.launcherContent(false)),
95
+ watchQuery(engine, q.launcherContent(true)),
96
+ // How results can be drawn, and every opener that could run. Both used to be worked
97
+ // out mid-render from `platform` — three registry reads that nothing invalidated on,
98
+ // so the launcher and the viewer nav only kept up because this snapshot is coarse
99
+ // enough to redraw them anyway. As a query the dependency is declared, which is what
100
+ // makes it safe to give either of them a region later.
101
+ watchQuery(engine, q.views),
102
+ watchQuery(engine, q.openers),
60
103
  ],
61
- // `_bump` is IN the snapshot, unlike before. `watch` skips a render whose value is
62
- // shallow-equal to the last one, so a forced re-render that left no trace in the
63
- // object would be discarded as "nothing changed" which is the opposite of what
64
- // asking for one means.
65
- (wb, overlay, nav, ex, se, keys, tr, notif, ctx, settings, pluginList, statusItems, so, off, act, vp, voice, _bump) =>
66
- ({ wb, overlay, nav, ex, se, keys, tr, notif, ctx, settings, plugins: pluginList, statusItems, so, off, act, vp, voice, _bump }),
104
+ // No `_bump` any more. It existed to defeat `watch`'s shallow-equality check for a
105
+ // forced re-render that left no trace in the snapshot — a counter smuggled into the
106
+ // state to get past an optimisation designed to skip pointless renders. With the state
107
+ // that needed it in a cell, every render has a reason again.
108
+ (wb, overlay, nav, ex, se, ctx, settings, off, vp, voice, view, caps, commandKeys,
109
+ content, modalContent, views, openers) =>
110
+ ({ wb, overlay, nav, ex, se, ctx, settings, off, vp, voice, view, caps, commandKeys,
111
+ content, modalContent, views, openers }),
67
112
  );
68
113
 
69
- return alias(() => watch(combined, (state) => view(state, ui)));
114
+ // The chrome, subscribed to what it reads instead of to everything.
115
+ //
116
+ // These four render the same facts in different shapes — `statusFacts` is shared between
117
+ // them — so they share a query set. Splitting them out is what takes `tr` off the main
118
+ // snapshot: an upload progress tick used to invalidate the one watch that builds the whole
119
+ // shell, so moving one number in the transfer tray reconstructed every row of the file
120
+ // list beneath it. Now it reaches the chrome and the tray, and nothing else.
121
+ const chrome = {
122
+ ex: q.explorer, tr: q.transfers, act: q.activity, off: q.offline,
123
+ so: q.social, wb: q.workbench, statusItems: q.statusItems, caps: q.capabilities,
124
+ // The numbers and the one-line verdict both shells report. Derived by the query rather
125
+ // than by the status bar and imported from there by the phone chrome, which is what it
126
+ // was — a business-layer derivation living in a component. See bl/status.js.
127
+ facts: q.statusFacts,
128
+ };
129
+ // A query boots asynchronously — it awaits a container lease first — so a region is
130
+ // PENDING for the first frame or two and `watch` renders its placeholder. For a bar with
131
+ // a fixed height that has to be an empty bar rather than nothing, or the layout jumps
132
+ // once on load and settles.
133
+ const regions = {
134
+ statusBar: region(engine, chrome, (s) => statusBar(s, ui), { placeholder: () => div({ className: 'statusbar' }) }),
135
+ phoneTopBar: region(engine, chrome, (s) => phoneTopBar(s, ui), { placeholder: () => div({ className: 'phonebar top' }) }),
136
+ phoneBottomBar: region(engine, chrome, (s) => phoneBottomBar(s, ui), { placeholder: () => div({ className: 'phonebar bottom' }) }),
137
+ // Both render nothing when there is nothing to show, so an absent first frame is what
138
+ // they would have drawn anyway.
139
+ phoneSheet: region(engine, chrome, (s) => phoneSheet(s, ui)),
140
+ transferTray: region(engine, { tr: q.transfers }, (s) => transferTray(s, ui)),
141
+ // Both read one slice each, and both change often enough to matter: a toast arriving
142
+ // or auto-dismissing used to rebuild the entire shell — including every row of the
143
+ // file list — to add a line in the corner.
144
+ toasts: region(engine, { notif: q.notifications }, (s) => toasts(s, ui)),
145
+ activityPanel: region(engine, { act: q.activity }, (s) => activityPanel(s, ui)),
146
+ // Screens that are usually NOT on screen. Their state still invalidated the shell from
147
+ // wherever they were: minting an API key or a plugin reporting in rebuilt the file
148
+ // list. Each reads a couple of slices, so each is a cheap region.
149
+ settingsView: region(engine, {
150
+ settings: q.settings, settingsGroups: q.settingsGroups, keys: q.apiKeys,
151
+ caps: q.capabilities, ex: q.explorer, assoc: q.openerAssociations,
152
+ }, (s) => settingsView(s, ui)),
153
+ pluginsView: region(engine, { plugins: q.plugins, settings: q.settings },
154
+ (s) => pluginsView(s, ui)),
155
+ // Gathers what already exists rather than fetching anything new — see adminView.js.
156
+ adminView: region(engine, {
157
+ caps: q.capabilities, ex: q.explorer, plugins: q.plugins, act: q.activity, so: q.social,
158
+ }, (s) => adminView(s, ui)),
159
+ infoPanel: region(engine, { so: q.social, off: q.offline, nav: q.navigation },
160
+ (s) => infoPanel(s, ui)),
161
+ // The rail. It renders the notification bell and the identity chip, both of which read
162
+ // the social slice — which is how `so` was still reaching the shell after infoPanel
163
+ // moved out. Missed by looking at infoPanel alone; the bell is imported INTO the bar
164
+ // from social.js, so a grep for the component name does not find it.
165
+ activityBar: region(engine, { wb: q.workbench, plugins: q.plugins, so: q.social },
166
+ (s) => activityBar(s, ui)),
167
+ // The palette is the first thing to read a composed view rather than a service's state:
168
+ // `commands` arrives with keybinding labels resolved and availability decided, so the
169
+ // component stopped asking the keybinding and command services anything mid-render.
170
+ // `matches` is the list already ranked against what has been typed — the palette does
171
+ // no scoring of its own any more. See bl/match.js for why there was more than one.
172
+ commandPalette: region(engine, { overlay: q.overlay, se: q.search, matches: q.paletteMatches },
173
+ (s) => commandPalette(s, ui)),
174
+ };
175
+
176
+ return alias(() => watch(combined, (state) => view(state, ui, regions)));
70
177
  }
71
178
 
72
- function view(state, ui) {
179
+ function view(state, ui, regions) {
73
180
  const mode = state.vp?.mode || 'desktop';
74
181
  const phone = mode === 'phone';
75
182
  return div({ className: `shell ${mode} ${state.settings['workbench.density'] === 'compact' ? 'compact' : ''}` },
76
183
  // On a phone the rail becomes a bottom bar and the status bar folds behind one icon
77
184
  // in a top bar — see phoneChrome. Everything between them is identical, which is the
78
185
  // point: only the chrome changes shape, not the app.
79
- phone ? phoneTopBar(state, ui) : null,
186
+ phone ? regions.phoneTopBar() : null,
80
187
  div({ className: 'body' },
81
- phone ? null : activityBar(state, ui),
82
- mainArea(state, ui),
188
+ phone ? null : regions.activityBar(),
189
+ mainArea(state, ui, regions),
83
190
  ),
84
- phone ? phoneBottomBar(state, ui) : statusBar(state, ui),
85
- phone ? phoneSheet(state, ui) : null,
191
+ phone ? regions.phoneBottomBar() : regions.statusBar(),
192
+ phone ? regions.phoneSheet() : null,
86
193
  // Overlays.
87
194
  searchModal(state, ui),
88
- commandPalette(state, ui),
195
+ regions.commandPalette(),
89
196
  dialog(state, ui),
90
197
  contextMenu(state, ui),
91
198
  pluginPanel(state, ui),
92
- toasts(state, ui),
93
- transferTray(state, ui),
94
- activityPanel(state, ui),
199
+ regions.toasts(),
200
+ regions.transferTray(),
201
+ regions.activityPanel(),
95
202
  );
96
203
  }
97
204
 
98
- function mainArea(state, ui) {
205
+ function mainArea(state, ui, regions) {
99
206
  // Before a collection is known there is nothing to draw: every scoped request names one
100
207
  // in its path, so a file list, a search box and an Upload button would all be lying.
101
208
  // Settings stays reachable — an admin with no collections still needs to get at it.
102
209
  if (state.ex?.gate && state.wb.activity !== 'settings') return collectionGate(state, ui);
103
210
 
104
211
  switch (state.wb.activity) {
105
- case 'settings': return settingsView(state, ui);
106
- case 'plugins': return pluginsView(state, ui);
212
+ case 'settings': return regions.settingsView();
213
+ case 'plugins': return regions.pluginsView();
214
+ case 'admin': return regions.adminView();
107
215
  default: {
108
216
  // Home: the top of the panel stack — the launcher (base search) or, once a
109
217
  // file is open, its opener full-width (optionally split with the info panel).
@@ -113,8 +221,8 @@ function mainArea(state, ui) {
113
221
  // details panel takes the whole panel instead — it has its own close button, so
114
222
  // there is still a way back to the file.
115
223
  return state.vp?.mode === 'phone'
116
- ? infoPanel(state, ui)
117
- : div({ className: 'editor-split' }, editorArea(state, ui), infoPanel(state, ui));
224
+ ? regions.infoPanel()
225
+ : div({ className: 'editor-split' }, editorArea(state, ui), regions.infoPanel());
118
226
  }
119
227
  }
120
228
  }
@@ -124,7 +232,7 @@ function mainArea(state, ui) {
124
232
  function searchModal(state, ui) {
125
233
  if (!state.wb.searchModal) return div();
126
234
  return div({ className: 'search-modal' },
127
- div({ className: 'scrim' }).on({ click: () => ui.platform.workbench.closeSearchModal() }),
235
+ div({ className: 'scrim' }).on({ click: () => ui.engine.dispatch(new CloseSearchModalAction()) }),
128
236
  div({ className: 'search-modal-panel' }, launcher(state, ui, { modal: true })),
129
237
  );
130
238
  }
@@ -0,0 +1,53 @@
1
+ // A region: one part of the shell, subscribed to only the queries it reads.
2
+ //
3
+ // The workbench is a single `watch` over a `derive` of eighteen cells, so any change to any
4
+ // of them rebuilds the whole VDOM — activity bar, status bar, every overlay, the file list.
5
+ // dodo reconciles, so the DOM update stays small; the REBUILD does not. An upload progress
6
+ // tick reconstructs all 500 rows of a file list to move one number in the transfer tray.
7
+ //
8
+ // A region fixes that by narrowing the subscription instead of the render: it watches the
9
+ // queries it actually reads, and a change to anything else never reaches it. That also makes
10
+ // the dependency legible — the region's queries are its argument list, rather than something
11
+ // you infer by reading which snapshot keys the body touches.
12
+ //
13
+ // The teardown matters as much as the render. A region that leaves the tree drops its
14
+ // watchers; dodo disconnects the cells; ngin kills the query realizations, releasing their
15
+ // leases and whatever they held open. That is what makes a query the right home for
16
+ // something like a minted media URL — alive while a region is looking at it, gone when it
17
+ // stops — with no component writing cleanup.
18
+ //
19
+ // See docs/tickets/009.
20
+
21
+ import { dd, watch, derive } from '../runtime.js';
22
+ import { watchQuery } from '../bl/watchQuery.js';
23
+
24
+ /**
25
+ * @param {object} engine
26
+ * @param {Record<string, object>} queries name → SHARED query instance (see bl/queries.js)
27
+ * @param {(state: Record<string, any>) => any} build the region's view; close over `ui`
28
+ * @param {{placeholder?: () => any, error?: (err: Error) => any}} [opts]
29
+ * @returns {() => any} a component, built ONCE — see below for why that matters
30
+ */
31
+ export function region(engine, queries, build, opts) {
32
+ const names = Object.keys(queries);
33
+ // Resolved at composition, not per render. `watchQuery` caches per (engine, query) so
34
+ // these would be the same cells either way, but doing it here says the subscription
35
+ // belongs to the region rather than being re-established on every pass.
36
+ const cells = names.map((name) => watchQuery(engine, queries[name]));
37
+ // `derive` coalesces: several of these changing in one frame still costs one render.
38
+ const combined = derive(cells, (...values) =>
39
+ Object.fromEntries(names.map((name, i) => [name, values[i]])));
40
+
41
+ // ONE alias function, and one options object, for the life of the app.
42
+ //
43
+ // dodo identifies an alias by the FUNCTION: "swapping the alias function ... is a
44
+ // different component, and reusing the state would skip the old one's detach and the new
45
+ // one's attach". So `alias(...)` built inside the render would detach and re-attach the
46
+ // watch — dropping the subscription, killing the query realization and booting a fresh
47
+ // one — on every pass of the parent. That is the precise opposite of what a region is
48
+ // for, and it would have looked like it worked.
49
+ //
50
+ // `build` and `opts` are hoisted for the same reason one level down: `watch` compares its
51
+ // arguments, and a builder closure rebuilt per render is a new argument every time.
52
+ return dd.alias(() => watch(combined, build, opts));
53
+ }
@@ -29,7 +29,7 @@
29
29
  import { dd, effect } from './runtime.js';
30
30
  import { createPlatform } from './platform/index.js';
31
31
  import { createApp } from './bl/index.js';
32
- import { NavigateAction, UploadFilesAction, OpenInitialCollectionAction } from './bl/actions.js';
32
+ import { NavigateAction, UploadFilesAction, OpenInitialCollectionAction, OpenSearchModalAction, OpenInPanelAction } from './bl/actions.js';
33
33
  import { parsePackage } from './platform/pluginPackage.js';
34
34
  import workbenchComposition from './ui/compositions/workbench.js';
35
35
  import { registerBuiltinOpeners } from './ui/components/openers/index.js';
@@ -75,10 +75,8 @@ export function createWorkbench({
75
75
  // restore any that were installed on this device, then run.
76
76
  platform.plugins.restore();
77
77
 
78
- const plugins = { uninstall: (id) => platform.plugins.uninstall(id) };
79
-
80
78
  // --- mount ----------------------------------------------------------------
81
- const App = workbenchComposition({ engine, app, platform, plugins });
79
+ const App = workbenchComposition({ engine, platform });
82
80
  dd.reconcile(root, [App()]);
83
81
 
84
82
  // --- theme ----------------------------------------------------------------
@@ -103,8 +101,10 @@ export function createWorkbench({
103
101
  platform.keybindings.install(window);
104
102
 
105
103
  // --- viewer stack ↔ browser history ---------------------------------------
106
- window.addEventListener('popstate', (e) => platform.workbench.onPopState(e));
107
- installDoubleShift(platform);
104
+ // Browser back/forward. The panel stack is the only thing that mirrors itself into
105
+ // history, so this reaches it directly rather than through a shell object.
106
+ window.addEventListener('popstate', (e) => app.navigation.onPopState(e));
107
+ installDoubleShift(engine);
108
108
 
109
109
  // --- service worker (offline shell + pinned files + push) -----------------
110
110
  if (serviceWorker && 'serviceWorker' in navigator) {
@@ -116,12 +116,11 @@ export function createWorkbench({
116
116
  // --- initial load ---------------------------------------------------------
117
117
  (async () => {
118
118
  try {
119
- platform.capabilities = await platform.api.capabilities();
120
- // What the server can do is read straight off `platform`, not held in a store, so
121
- // nothing invalidated when it arrived. Re-pushing the same state object will not
122
- // do it either: a cell compares with Object.is and drops a write of what it
123
- // already holds. Saying "this changed under you" is the store's job.
124
- platform.workbench.touch(); // the status bar reads capabilities
119
+ // What the server can do is an engine query now — see bl/queries.js. It used to be
120
+ // assigned onto `platform` here and followed by `workbench.touch()`, a poke at an
121
+ // unrelated store purely to make the shell redraw, because writing a plain field
122
+ // invalidates nothing. The query has somewhere for the value to arrive, so the fetch
123
+ // belongs to it and this no longer has to announce it.
125
124
  } catch (err) {
126
125
  platform.notifications.error(`Cannot reach the Trove server: ${err.message}`);
127
126
  }
@@ -165,12 +164,12 @@ function register(platform, type, list) {
165
164
 
166
165
  // Double-shift → modal search overlay (Raycast-style). Two Shift presses within 400ms,
167
166
  // with no other key between, open it.
168
- function installDoubleShift(platform) {
167
+ function installDoubleShift(engine) {
169
168
  let lastShift = 0;
170
169
  window.addEventListener('keydown', (e) => {
171
170
  if (e.key === 'Shift' && !e.repeat) {
172
171
  const now = Date.now();
173
- if (now - lastShift < 400) { platform.workbench.openSearchModal(); lastShift = 0; }
172
+ if (now - lastShift < 400) { engine.dispatch(new OpenSearchModalAction()); lastShift = 0; }
174
173
  else lastShift = now;
175
174
  } else if (e.key !== 'Shift') {
176
175
  lastShift = 0;
@@ -200,6 +199,9 @@ function installDragAndDrop(engine, app) {
200
199
  e.preventDefault();
201
200
  dragDepth = 0;
202
201
  document.body.classList.remove('dragging-file');
203
- engine.dispatch(new UploadFilesAction(e.dataTransfer.files, app.explorer.state.collectionId));
202
+ // No collection named: UploadFilesAction resolves the open one itself and refuses
203
+ // visibly when there isn't one. Reading it here only to hand it straight back was the
204
+ // drop target knowing where files go, which is the action's business.
205
+ engine.dispatch(new UploadFilesAction(e.dataTransfer.files));
204
206
  });
205
207
  }