@3sln/trove 0.0.8 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/package.json +1 -1
  2. package/packages/core/src/encryption/envelope.js +46 -7
  3. package/packages/core/src/encryption/rotation.js +187 -68
  4. package/packages/core/src/uploads.js +130 -29
  5. package/packages/core/src/vfs.js +13 -1
  6. package/packages/server/src/adapters/staticAssets.js +14 -5
  7. package/packages/server/src/engine/index.js +1 -1
  8. package/packages/server/src/engine/providers/core.js +12 -0
  9. package/packages/server/src/index.js +45 -2
  10. package/packages/server/src/routes.js +59 -26
  11. package/packages/web/dist/assets/main-jg5vmp8f.js +356 -0
  12. package/packages/web/dist/assets/main-jg5vmp8f.js.map +118 -0
  13. package/packages/web/dist/assets/styles-e5gk19rn.css +1 -0
  14. package/packages/web/dist/index.html +2 -2
  15. package/packages/web/dist/sw.js +1 -1
  16. package/packages/web/src/bl/actions.js +1454 -137
  17. package/packages/web/src/bl/commands.js +103 -284
  18. package/packages/web/src/bl/context.js +69 -0
  19. package/packages/web/src/bl/index.js +136 -40
  20. package/packages/web/src/bl/intern.js +129 -0
  21. package/packages/web/src/bl/launcher.js +251 -0
  22. package/packages/web/src/bl/links.js +1 -1
  23. package/packages/web/src/bl/match.js +71 -0
  24. package/packages/web/src/bl/mentions.js +39 -0
  25. package/packages/web/src/bl/offline.js +0 -4
  26. package/packages/web/src/bl/openers.js +65 -33
  27. package/packages/web/src/bl/pluginInstall.js +42 -21
  28. package/packages/web/src/bl/queries.js +669 -0
  29. package/packages/web/src/bl/services.js +77 -141
  30. package/packages/web/src/bl/social.js +3 -1
  31. package/packages/web/src/bl/state.js +144 -0
  32. package/packages/web/src/bl/status.js +96 -0
  33. package/packages/web/src/bl/trust.js +109 -0
  34. package/packages/web/src/bl/viewState.js +60 -0
  35. package/packages/web/src/bl/views.js +64 -0
  36. package/packages/web/src/bl/watchQuery.js +73 -0
  37. package/packages/web/src/platform/api.js +31 -102
  38. package/packages/web/src/platform/commands.js +49 -9
  39. package/packages/web/src/platform/context.js +146 -51
  40. package/packages/web/src/platform/index.js +27 -20
  41. package/packages/web/src/platform/navigation.js +34 -4
  42. package/packages/web/src/platform/pickers.js +46 -0
  43. package/packages/web/src/platform/pluginHost.js +43 -10
  44. package/packages/web/src/platform/pluginRpc.js +6 -2
  45. package/packages/web/src/platform/spatialNav.js +0 -2
  46. package/packages/web/src/platform/viewport.js +1 -6
  47. package/packages/web/src/platform/voiceSearch.js +1 -3
  48. package/packages/web/src/platform/whenclause.js +22 -3
  49. package/packages/web/src/styles.css +74 -4
  50. package/packages/web/src/ui/activate.js +22 -0
  51. package/packages/web/src/ui/components/activityBar.js +7 -3
  52. package/packages/web/src/ui/components/activityPanel.js +10 -11
  53. package/packages/web/src/ui/components/adminView.js +191 -0
  54. package/packages/web/src/ui/components/collectionGate.js +4 -3
  55. package/packages/web/src/ui/components/commandPalette.js +24 -55
  56. package/packages/web/src/ui/components/editorArea.js +16 -13
  57. package/packages/web/src/ui/components/launcher.js +39 -225
  58. package/packages/web/src/ui/components/openers/index.js +7 -4
  59. package/packages/web/src/ui/components/openers/markdown.js +5 -3
  60. package/packages/web/src/ui/components/overlays.js +158 -45
  61. package/packages/web/src/ui/components/phoneChrome.js +35 -33
  62. package/packages/web/src/ui/components/pluginReview.js +62 -23
  63. package/packages/web/src/ui/components/pluginsView.js +30 -28
  64. package/packages/web/src/ui/components/settingsView.js +151 -58
  65. package/packages/web/src/ui/components/social.js +42 -62
  66. package/packages/web/src/ui/components/statusBar.js +30 -74
  67. package/packages/web/src/ui/components/views/grid.js +3 -2
  68. package/packages/web/src/ui/components/views/index.js +7 -62
  69. package/packages/web/src/ui/components/views/list.js +3 -2
  70. package/packages/web/src/ui/components/views/parts.js +15 -4
  71. package/packages/web/src/ui/compositions/workbench.js +166 -58
  72. package/packages/web/src/ui/region.js +53 -0
  73. package/packages/web/src/workbench.js +17 -15
  74. package/packages/web/dist/assets/main-y778bpte.js +0 -356
  75. package/packages/web/dist/assets/main-y778bpte.js.map +0 -106
  76. package/packages/web/dist/assets/styles-nfy8t3n1.css +0 -1
  77. package/packages/web/src/platform/overlay.js +0 -81
  78. package/packages/web/src/platform/workbench.js +0 -156
@@ -1,20 +1,21 @@
1
1
  // createPlatform — assemble every service into one object the whole app shares.
2
- // This is the "workbench platform": the reactive registries and services that
3
- // core features and plugins both build on. It also registers the default
4
- // settings schema and keybindings so the shell has sensible behaviour out of the
5
- // box. UI-facing shell state (which activity is open, is the palette showing…)
6
- // lives in WorkbenchService; data and mutations go through ngin (see ../bl).
2
+ // This is the "workbench platform": the reactive registries and services that core
3
+ // features and plugins both build on. It also registers the default settings schema and
4
+ // keybindings so the shell has sensible behaviour out of the box.
5
+ //
6
+ // It does NOT hold the shell's state. Which activity is open, whether the palette is
7
+ // showing, what is on the panel stack — those are engine resources, built by createApp
8
+ // (see ../bl/state.js). The platform is the machinery underneath them.
7
9
 
8
10
  import { reactive } from '../runtime.js';
9
11
  import { ContributionRegistry } from './contributions.js';
10
- import { ContextKeyService } from './context.js';
12
+ import { ContextRegistry } from './context.js';
11
13
  import { CommandService } from './commands.js';
12
14
  import { KeybindingService } from './keybindings.js';
13
15
  import { SettingsService } from './settings.js';
14
16
  import { NotificationService } from './notifications.js';
15
17
  import { TroveApiClient } from './api.js';
16
18
  import { PluginHost } from './pluginHost.js';
17
- import { WorkbenchService } from './workbench.js';
18
19
  import { ViewportService } from './viewport.js';
19
20
  import { SpatialNavigationService } from './spatialNav.js';
20
21
  import { VoiceSearchService } from './voiceSearch.js';
@@ -42,12 +43,18 @@ export function writeToken(token) {
42
43
 
43
44
  export function createPlatform({ baseUrl = '' } = {}) {
44
45
  const contributions = new ContributionRegistry();
45
- // 'home' is where the app actually starts (WorkbenchService's initial `activity`), and
46
- // nothing writes this key until the user navigates. Seeding it with a view that no
47
- // longer exists left every `when: view.active == 'home'` binding the Delete
48
- // shortcut among them — dead from boot until the first click on the rail, while the
49
- // row menu cheerfully advertised "Del" as the way to delete.
50
- const context = new ContextKeyService({ 'view.active': 'home', 'sidebar.visible': true });
46
+ // Only the fixed facts are seeded. Everything else is registered by whoever owns it —
47
+ // the built-in keys are derived from the shell and the drive (see bl/context.js), so
48
+ // there is nothing sensible to pre-fill them with and nothing that would go stale if
49
+ // there were.
50
+ //
51
+ // It used to be seeded with `view.active: 'home'` for a reason worth remembering: nothing
52
+ // wrote that key until the user navigated, so every `when: view.active == 'home'`
53
+ // binding — the Delete shortcut among them — was dead from boot until the first click on
54
+ // the rail, while the row menu cheerfully advertised "Del" as the way to delete. A
55
+ // derived key is never unwritten, so the seed is not needed and cannot drift from the
56
+ // state it was standing in for.
57
+ const context = new ContextRegistry();
51
58
  const notifications = new NotificationService();
52
59
  const commands = new CommandService(contributions, context, notifications);
53
60
  const settings = new SettingsService();
@@ -57,22 +64,22 @@ export function createPlatform({ baseUrl = '' } = {}) {
57
64
  // But a deployment where the user HOLDS a token has no other way to present it, and
58
65
  // reading it here — once, from one place — keeps that out of every call site.
59
66
  const api = new TroveApiClient({ baseUrl, token: () => readToken() });
60
- const workbench = new WorkbenchService(context);
61
67
  // Which shell to render — phone, desktop, or TV. Constructed before the defaults are
62
68
  // registered, so it reads the setting through `settings.get` once that exists.
63
- const viewport = new ViewportService({ settings, context });
69
+ const viewport = new ViewportService({ settings });
64
70
  // Arrow keys → geometry, but only on a TV. Inert everywhere else.
65
- const spatialNav = new SpatialNavigationService({ workbench, viewport });
71
+ const spatialNav = new SpatialNavigationService({ viewport });
66
72
  // Speak to search. Mostly this just puts the search field under the remote's mic —
67
73
  // see voiceSearch.js for why that is the whole feature on a TV.
68
- const voice = new VoiceSearchService({ workbench, notifications, settings });
74
+ const voice = new VoiceSearchService({ notifications, settings });
69
75
 
70
76
  const platform = {
71
77
  reactive,
72
- contributions, context, commands, keybindings, settings, notifications, api, workbench,
78
+ contributions, context, commands, keybindings, settings, notifications, api,
73
79
  viewport, spatialNav, voice,
74
- capabilities: null,
75
- openPluginPanel: null, // set by the workbench UI
80
+ // The shell's own state is the engine's, not the platform's — createApp builds it and
81
+ // hands back what the few imperative edges below still need.
82
+ openPluginPanel: null,
76
83
  };
77
84
  platform.mediaUrls = new MediaUrlService({ api: platform.api, settings });
78
85
  platform.plugins = new PluginHost(platform);
@@ -11,8 +11,7 @@ const RECENTS_KEY = 'trove.recents';
11
11
  const RECENTS_MAX = 12;
12
12
 
13
13
  export class NavigationService {
14
- constructor(context) {
15
- this.context = context;
14
+ constructor() {
16
15
  this.state = {
17
16
  stack: [{ kind: 'search' }], // [{kind:'search'}, {kind:'file', id, node, openerId}, …]
18
17
  activeTabId: null, // top file panel id (or null)
@@ -43,7 +42,6 @@ export class NavigationService {
43
42
  const top = stack[stack.length - 1];
44
43
  const file = top && top.kind === 'file' ? top : null;
45
44
  this.#set({ stack, activeTabId: file ? file.id : null, activeFile: file ? file.node : null });
46
- this.context.setMany({ 'editor.open': !!file, 'editor.openerId': file?.openerId || '', 'editor.contentType': file?.node.contentType || '' });
47
45
  if (history) this.#pushHistory();
48
46
  }
49
47
 
@@ -83,11 +81,43 @@ export class NavigationService {
83
81
  pop() {
84
82
  if (this.state.stack.length > 1) this.#applyStack(this.state.stack.slice(0, -1), { history: false });
85
83
  }
86
- closeTab(id) {
84
+ /**
85
+ * The node is GONE — drop it from everywhere this service holds a copy.
86
+ *
87
+ * Named for what happened rather than for the stack, because it was `closeTab` and so
88
+ * only closed the panel: a deleted file kept its recent tile, and clicking that tile
89
+ * opened nothing. Recents are a snapshot, not a reference, so nothing else was ever
90
+ * going to notice.
91
+ *
92
+ * Trashing is reversible and this is not, quite: restoring a file does not put it back
93
+ * in recents, it just stops being listed until opened again. That is the right way
94
+ * round — a tile that leads nowhere is worse than one that is missing.
95
+ */
96
+ forget(id) {
97
+ const recents = this.state.recents.filter((r) => r.id !== id);
98
+ if (recents.length !== this.state.recents.length) {
99
+ this.#set({ recents });
100
+ saveRecents(recents);
101
+ }
87
102
  const stack = this.state.stack.filter((p) => !(p.kind === 'file' && p.id === id));
88
103
  this.#applyStack(stack.length ? stack : [{ kind: 'search' }], { history: false });
89
104
  }
105
+ /**
106
+ * A node changed underneath us — a rename — so every copy of it here has to change.
107
+ *
108
+ * BOTH the stack and the recents list, because recents hold a SNAPSHOT (id, name,
109
+ * contentType) rather than a reference: updating only the stack renamed the open panel's
110
+ * title and left the recent tile showing the old name until it aged off the end of the
111
+ * list, which reads as the rename half-failing.
112
+ */
90
113
  updateTabNode(node) {
114
+ const recents = this.state.recents.map((r) => (r.id === node.id
115
+ ? { ...r, name: node.name, contentType: node.contentType || r.contentType }
116
+ : r));
117
+ if (recents.some((r, i) => r !== this.state.recents[i])) {
118
+ this.#set({ recents });
119
+ saveRecents(recents);
120
+ }
91
121
  const stack = this.state.stack.map((p) => (p.kind === 'file' && p.id === node.id ? { ...p, node } : p));
92
122
  this.#applyStack(stack, { history: false });
93
123
  }
@@ -0,0 +1,46 @@
1
+ // The two things only the browser can do: ask for a file, and save one.
2
+ //
3
+ // These are effects, so they are reached from actions rather than from a render — but they
4
+ // are effects on the DOCUMENT rather than on the drive, which is why they live in
5
+ // `platform` and not in `bl`. An action that needs a file from the user calls in here; the
6
+ // action is still the thing that decides what happens to it.
7
+ //
8
+ // They were private to bl/commands.js, where the command handlers that used them lived.
9
+
10
+ /**
11
+ * Open a native file picker.
12
+ *
13
+ * The hidden `<input>` is removed on selection AND on cancel — cancelling fires no
14
+ * `change`, so cleanup also hangs off the next window focus (which the OS dialog returns)
15
+ * to avoid leaking a growing pile of inputs.
16
+ */
17
+ function pick(cb, configure) {
18
+ const input = document.createElement('input');
19
+ input.type = 'file';
20
+ configure(input);
21
+ input.style.display = 'none';
22
+ document.body.appendChild(input);
23
+ const cleanup = () => { input.remove(); window.removeEventListener('focus', onFocus); };
24
+ const onFocus = () => setTimeout(() => { if (!input.files.length) cleanup(); }, 300);
25
+ input.addEventListener('change', () => { cb(input.files); cleanup(); }, { once: true });
26
+ window.addEventListener('focus', onFocus);
27
+ input.click();
28
+ }
29
+
30
+ export function pickFiles(cb) {
31
+ pick((files) => cb(files), (input) => { input.multiple = true; });
32
+ }
33
+
34
+ export function pickZip(cb) {
35
+ pick((files) => cb(files[0]), (input) => { input.accept = '.zip,application/zip'; });
36
+ }
37
+
38
+ /** Hand a URL to the browser as a download. */
39
+ export function triggerDownload(url, name) {
40
+ const a = document.createElement('a');
41
+ a.href = url;
42
+ a.download = name || '';
43
+ document.body.appendChild(a);
44
+ a.click();
45
+ a.remove();
46
+ }
@@ -27,6 +27,7 @@ import { pluginId, contribUri } from '@3sln/trove/core/plugins/identity.js';
27
27
  import { endpointSummary } from './pluginNet.js';
28
28
  import { MediaController } from './pluginMedia.js';
29
29
  import { FrameDock } from './pluginDock.js';
30
+ import { InvokePluginCommandAction, OpenInPanelAction } from '../bl/actions.js';
30
31
  import { FrameManager } from './pluginFrames.js';
31
32
  import { PluginRpcRouter } from './pluginRpc.js';
32
33
  // The canonical capability list lives in core (the server's authority); import it so
@@ -54,7 +55,9 @@ export class PluginHost {
54
55
  this.media = new MediaController();
55
56
  this.dock = new FrameDock({
56
57
  destroyFrame: (frame) => this.frames.destroy(frame),
57
- openFile: (node, openerId) => this.platform.workbench.openFile(node, openerId),
58
+ // Dispatched, not called: opening a file from a docked plugin frame is the same
59
+ // intent as opening one from the drive, and the engine should see both.
60
+ openFile: (node, openerId) => this.platform.dispatch?.(new OpenInPanelAction(node, openerId)),
58
61
  onChange: () => this.#emit(),
59
62
  });
60
63
  this.frames = new FrameManager({ media: this.media, dock: this.dock });
@@ -107,15 +110,19 @@ export class PluginHost {
107
110
  if (runtime.grants.includes('opener')) keep(reg.register(c.uri, base));
108
111
  break;
109
112
 
110
- // Declared, implemented by the plugin's primary frame. The handler proxies
111
- // over RPC using the contribution's short name — inside its own frame a
112
- // plugin addresses its commands by name, not by URI.
113
+ // Declared, implemented by the plugin's primary frame. Addressed over RPC by the
114
+ // contribution's short NAME — inside its own frame a plugin addresses its commands
115
+ // by name, not by URI.
113
116
  case 'command':
114
117
  keep(this.platform.commands.register({
115
118
  id: c.uri, title: c.title || `${label}: ${c.name}`,
116
119
  category: c.category || label, icon: c.icon,
117
120
  when: c.when, offline: c.offline, palette: c.palette, pluginId: pid,
118
- handler: (...args) => runtime.channel?.call('command:execute', { id: c.name, args }),
121
+ // A description, like every other command. It used to be a closure over
122
+ // `runtime` proxying straight to the channel, which left a plugin command
123
+ // running invisible to the engine — the same hole ExecCommandAction closed for
124
+ // the host's own commands, still open for everyone else's.
125
+ actions: (...args) => new InvokePluginCommandAction(pid, c.name, args),
119
126
  }));
120
127
  break;
121
128
 
@@ -133,13 +140,20 @@ export class PluginHost {
133
140
  break;
134
141
  }
135
142
 
136
- // A context value slot. Seed the context with its declared default so
137
- // when-clauses referencing it evaluate sensibly before the plugin runs.
138
- case 'register':
143
+ // A context value slot the one kind of key that genuinely is PUSHED rather than
144
+ // derived, because only the plugin knows what it means. Owned here, on the
145
+ // plugin's behalf: the registry hands back a writer, which goes on the runtime
146
+ // record so `context:setRegister` can reach it and nothing else can.
147
+ //
148
+ // Seeded with its declared default so when-clauses referencing it evaluate
149
+ // sensibly before the plugin has run at all.
150
+ case 'register': {
139
151
  keep(reg.register(c.uri, base));
140
- this.platform.context.set(c.uri, c.default);
141
- keep(() => this.platform.context.remove(c.uri));
152
+ const owned = this.platform.context.own(c.uri, c.default);
153
+ runtime.registers.set(c.uri, owned.set);
154
+ keep(() => { runtime.registers.delete(c.uri); owned.dispose(); });
142
155
  break;
156
+ }
143
157
 
144
158
  // A keymap JSON file inside the package. Read + validated here, at register
145
159
  // time, so a malformed keymap is a visible install-time problem rather than a
@@ -373,6 +387,10 @@ export class PluginHost {
373
387
  if (this.plugins.get(record.id)?.status === 'active') return;
374
388
  const runtime = {
375
389
  ...record, iframe: null, status: 'loading', error: null, disposers: [], channel: null,
390
+ // Writers for the context registers this plugin DECLARED, keyed by contribution URI.
391
+ // Holding them here is what makes "a plugin may only set its own registers" structural
392
+ // rather than a check: there is no setter for a key it did not declare.
393
+ registers: new Map(),
376
394
  hasUi: false, responsive: false, frame: null, frames: new Set(),
377
395
  // Storage scopes (defaulted for records saved before this field existed).
378
396
  storage: record.storage || (record.grants?.includes('storage') ? grantedStorageScopes(record.manifest, record.trust) : { plugin: false, domain: false }),
@@ -445,6 +463,21 @@ export class PluginHost {
445
463
  this._probing = false;
446
464
  }
447
465
  }
466
+ /**
467
+ * Run one of a plugin's own commands, in its frame.
468
+ *
469
+ * Public because `InvokePluginCommandAction` is what reaches it now, rather than a closure
470
+ * captured at registration. Addressed by short NAME: the URI is the host's way of keeping
471
+ * two plugins' `status` commands apart and means nothing on the other side.
472
+ *
473
+ * A plugin that is not running answers undefined rather than throwing. Its commands are
474
+ * already filtered out of the palette by `isAvailable`, so getting here at all means a
475
+ * stale reference, which is not worth an error.
476
+ */
477
+ invokeCommand(pluginId, name, args = []) {
478
+ return this.plugins.get(pluginId)?.channel?.call('command:execute', { id: name, args });
479
+ }
480
+
448
481
  setHeartbeat(ms) {
449
482
  this.heartbeatMs = ms;
450
483
  this.#stopHeartbeat();
@@ -112,7 +112,11 @@ export class PluginRpcRouter {
112
112
  // can read, addressed by its contribution URI.
113
113
  case 'context:setRegister': {
114
114
  const slot = this.#ownContribution(record, params.name, 'register');
115
- this.platform.context.set(slot.uri, params.value);
115
+ // The writer for a register the plugin declared. There is no path to a key it did
116
+ // not declare, because it was never handed a setter for one.
117
+ const write = record.registers?.get(slot.uri);
118
+ if (!write) throw new Error(`Register "${params.name}" is not available`);
119
+ write(params.value);
116
120
  return { ok: true };
117
121
  }
118
122
  // Package resources — opaque byte handles (transferred, no host URLs). Code
@@ -170,7 +174,7 @@ export class PluginRpcRouter {
170
174
  case 'ui:showPanel':
171
175
  cap('ui');
172
176
  record.hasUi = true;
173
- this.platform.openPluginPanel?.(pid);
177
+ this.platform.openPluginPanel(pid);
174
178
  this.onChange();
175
179
  return { ok: true };
176
180
 
@@ -43,12 +43,10 @@ const ACTIVATES_ITSELF = new Set(['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA'])
43
43
  export class SpatialNavigationService {
44
44
  /**
45
45
  * @param {object} deps
46
- * @param {import('./workbench.js').WorkbenchService} deps.workbench
47
46
  * @param {import('./viewport.js').ViewportService} deps.viewport
48
47
  * @param {Window} [deps.window]
49
48
  */
50
49
  constructor({ workbench, viewport, window: win = globalThis }) {
51
- this.workbench = workbench;
52
50
  this.viewport = viewport;
53
51
  this.window = win;
54
52
  this.active = false;
@@ -33,12 +33,10 @@ export class ViewportService {
33
33
  * @param {object} [deps]
34
34
  * @param {Window} [deps.window] the window to measure and listen to
35
35
  * @param {import('./settings.js').SettingsService} [deps.settings]
36
- * @param {import('./context.js').ContextKeyService} [deps.context]
37
36
  */
38
- constructor({ window: win = globalThis, settings = null, context = null } = {}) {
37
+ constructor({ window: win = globalThis, settings = null } = {}) {
39
38
  this.window = win;
40
39
  this.settings = settings;
41
- this.context = context;
42
40
  // A URL override outranks the setting: it is how someone checks the phone layout on
43
41
  // a laptop, and how the e2e suite drives each shell without a device farm.
44
42
  this.urlOverride = readUrlOverride(win);
@@ -82,9 +80,6 @@ export class ViewportService {
82
80
  #publish(vp) {
83
81
  // Context keys so plugin `when` clauses and keybindings can target a form factor
84
82
  // the same way they target a view.
85
- this.context?.set('viewport.mode', vp.mode);
86
- this.context?.set('viewport.phone', vp.mode === 'phone');
87
- this.context?.set('viewport.tv', vp.mode === 'tv');
88
83
  // The root element carries it too, so CSS can respond without every rule needing a
89
84
  // media query that would disagree with the JS branch above it.
90
85
  const el = this.window.document?.documentElement;
@@ -19,11 +19,9 @@ import { canTranscribeLocally, localAvailability, installLocal, listen } from '.
19
19
  export class VoiceSearchService {
20
20
  /**
21
21
  * @param {object} deps
22
- * @param {import('./workbench.js').WorkbenchService} deps.workbench
23
22
  * @param {object} deps.notifications
24
23
  */
25
- constructor({ workbench, notifications, settings } = {}) {
26
- this.workbench = workbench;
24
+ constructor({ notifications, settings } = {}) {
27
25
  this.notifications = notifications;
28
26
  this.settings = settings;
29
27
  this.session = null;
@@ -3,10 +3,16 @@
3
3
  // a context (a flat key→value map from the ContextKeyService) and returns a
4
4
  // boolean.
5
5
  //
6
- // Supported: bare keys (truthy), !key, ==, !=, >, <, >=, <=, =~ /re/, && , ||,
6
+ // Supported: bare keys (truthy), !key, ==, !=, >, <, >=, <=, =~ /re/flags, && , ||,
7
7
  // parentheses, string/number/boolean literals. Expressions are parsed once into
8
8
  // a predicate function and cached, so evaluation on every keydown is cheap.
9
9
  //
10
+ // The compiled predicate carries `.keys`: every context key the expression names. That is
11
+ // what lets a clause be WATCHED rather than polled — see ContextRegistry#watch, which
12
+ // derives a cell over exactly those keys, so a clause about `view.active` stops recomputing
13
+ // every time the selection changes. Collected while parsing because that is the only place
14
+ // the distinction between a key and a literal is known.
15
+ //
10
16
  // A key is either a core context key (`view.active`, `explorer.hasSelection`) or a
11
17
  // contribution URI naming a plugin's `register` — `trove+contrib:acme.com/docs/busy`.
12
18
  // Registers are addressed by their full URI precisely because they're contributions
@@ -19,10 +25,16 @@ export function compileWhen(expr) {
19
25
  if (cache.has(expr)) return cache.get(expr);
20
26
  let fn;
21
27
  try {
22
- fn = new Parser(expr).parseExpression();
28
+ const parser = new Parser(expr);
29
+ fn = parser.parseExpression();
30
+ // The keys this clause reads. Frozen: it is shared by every caller through the cache.
31
+ fn.keys = Object.freeze([...parser.keys]);
23
32
  } catch (err) {
24
33
  console.warn(`Invalid when clause: "${expr}" — ${err.message}`);
25
34
  fn = () => false;
35
+ // A clause that could not be parsed depends on nothing, so watching it yields a cell
36
+ // that is constantly false rather than one that never settles.
37
+ fn.keys = Object.freeze([]);
26
38
  }
27
39
  cache.set(expr, fn);
28
40
  return fn;
@@ -32,15 +44,21 @@ export function evaluateWhen(expr, ctx) {
32
44
  return compileWhen(expr)(ctx || {});
33
45
  }
34
46
 
47
+ /** Every context key `expr` names — `[]` for a constant or unparseable clause. */
48
+ export function keysOf(expr) {
49
+ return compileWhen(expr).keys ?? [];
50
+ }
51
+
35
52
  // Note the `trove+contrib:` alternative comes before the regex-literal one: a URI's
36
53
  // slashes would otherwise start a /…/ literal and swallow the rest of the expression.
37
- const TOKEN = /\s*(=~|==|!=|>=|<=|&&|\|\||[()!<>]|trove\+contrib:[A-Za-z0-9_./-]+|\/(?:\\.|[^/])*\/|'(?:\\.|[^'])*'|"(?:\\.|[^"])*"|[A-Za-z0-9_.:-]+)/y;
54
+ const TOKEN = /\s*(=~|==|!=|>=|<=|&&|\|\||[()!<>]|trove\+contrib:[A-Za-z0-9_./-]+|\/(?:\\.|[^/])*\/[a-z]*|'(?:\\.|[^'])*'|"(?:\\.|[^"])*"|[A-Za-z0-9_.:-]+)/y;
38
55
 
39
56
  class Parser {
40
57
  constructor(src) {
41
58
  this.src = src;
42
59
  this.tokens = this.#lex(src);
43
60
  this.pos = 0;
61
+ this.keys = new Set();
44
62
  }
45
63
  #lex(src) {
46
64
  const out = [];
@@ -137,6 +155,7 @@ class Parser {
137
155
  return () => s;
138
156
  }
139
157
  // A context key.
158
+ this.keys.add(t);
140
159
  return (c) => c[t];
141
160
  }
142
161
  }
@@ -458,12 +458,23 @@ kbd {
458
458
  .scrim { position: fixed; inset: 0; background: #0006; backdrop-filter: blur(1.5px); z-index: 40; }
459
459
 
460
460
  .palette {
461
- position: fixed; top: 12vh; left: 50%; transform: translateX(-50%);
461
+ position: fixed; top: 12vh; left: 50%; translate: -50% 0;
462
462
  width: min(640px, 92vw); background: var(--bg-elevated); border: 1px solid var(--border-strong);
463
463
  border-radius: 12px; box-shadow: var(--shadow); z-index: 50; overflow: hidden;
464
464
  animation: pop .13s var(--ease);
465
465
  }
466
- @keyframes pop { from { opacity: 0; transform: translate(-50%, -6px) scale(.98); } to { opacity: 1; transform: translate(-50%, 0) scale(1); } }
466
+ /* Transform-neutral on purpose.
467
+
468
+ This used to end at `translate(-50%, 0)` — the command palette's horizontal centring,
469
+ baked into a shared animation. `animation-fill-mode` is `none`, so when the animation
470
+ finished the transform reverted to the element's own and anything that did not happen to
471
+ want exactly that offset jumped by the difference: the context menu, plugin panel and
472
+ inbox each snapped right by half their width, and the dialog dropped half its height
473
+ because the keyframe kept the X centring and lost the Y.
474
+
475
+ Elements that need centring now do it with the standalone `translate` property, which
476
+ composes with `transform` instead of competing for the same slot. */
477
+ @keyframes pop { from { opacity: 0; transform: translateY(-6px) scale(.98); } to { opacity: 1; transform: none; } }
467
478
  .palette .search { display: flex; align-items: center; gap: 9px; padding: 12px 14px; border-bottom: 1px solid var(--border); }
468
479
  .palette .search input { flex: 1; background: transparent; border: 0; outline: none; font-size: 15px; color: var(--text); }
469
480
  .palette .results { max-height: 52vh; overflow-y: auto; padding: 6px; }
@@ -501,7 +512,7 @@ kbd {
501
512
  .palette .none .spinner { width: 13px; height: 13px; }
502
513
 
503
514
  .dialog {
504
- position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
515
+ position: fixed; top: 50%; left: 50%; translate: -50% -50%;
505
516
  width: min(440px, 92vw); background: var(--bg-elevated); border: 1px solid var(--border-strong);
506
517
  border-radius: 12px; box-shadow: var(--shadow); z-index: 50; padding: 20px; animation: pop .13s var(--ease);
507
518
  }
@@ -535,7 +546,11 @@ kbd {
535
546
  border: 1px solid var(--border-strong); border-radius: 9px; box-shadow: var(--shadow); padding: 5px;
536
547
  animation: pop .1s var(--ease);
537
548
  }
538
- .menu .mi { display: flex; align-items: center; gap: 10px; padding: 7px 9px; border-radius: 6px; cursor: default; }
549
+ /* `width: 100%` because these are <button>s, and a form control computes `width: auto` as
550
+ fit-content rather than stretching the way a block does — so the row, and with it the
551
+ hover target, was only as wide as its label. `.kbd`'s `margin-left: auto` below is the
552
+ evidence of the intent: pushing a shortcut to the far right needs a full-width row. */
553
+ .menu .mi { display: flex; width: 100%; box-sizing: border-box; align-items: center; gap: 10px; padding: 7px 9px; border-radius: 6px; cursor: default; }
539
554
  .menu .mi:hover { background: var(--bg-active); }
540
555
  .menu .mi.danger:hover { background: color-mix(in srgb, var(--danger) 16%, transparent); color: var(--danger); }
541
556
  .menu .mi .kbd { margin-left: auto; color: var(--text-faint); }
@@ -664,6 +679,61 @@ kbd {
664
679
  .review-meta { color: var(--text-faint); font-size: 11px; margin-top: 6px; }
665
680
  .dialog.review .row-actions { padding: 14px 18px; border-top: 1px solid var(--border); margin: 0; }
666
681
 
682
+ /* The admin console's action rows: several small buttons side by side, which the settings
683
+ `.control` column is not otherwise shaped for. */
684
+ /* A row whose control is a GROUP of buttons rather than one input.
685
+ `.setting .control` is a fixed 200px column, which is right for a select or a toggle and
686
+ far too narrow for three buttons — they wrapped 2-then-1 and sat jammed against the
687
+ edge. Such a row wraps instead, giving the buttons their own full-width line under the
688
+ description, so the longest label stops squeezing the group. */
689
+ .setting.stacked { flex-wrap: wrap; }
690
+ .setting.stacked .info { flex: 1 1 100%; }
691
+ .setting.stacked .control { width: 100%; margin-top: 12px; }
692
+ .admin .control.admin-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
693
+ .admin .control.admin-actions .btn { white-space: nowrap; }
694
+ .admin .control .mono.muted { color: var(--text-faint); }
695
+
696
+ /* The rotation bill, itemised. Only where a provider actually charges for the traffic. */
697
+ .rot-bill { margin: 8px 0 10px; padding: 8px 10px; border-radius: 8px;
698
+ background: color-mix(in srgb, var(--text) 4%, transparent); }
699
+ .rot-bill .rb-line { display: flex; justify-content: space-between; gap: 12px;
700
+ font-size: 12px; color: var(--text-dim); padding: 2px 0; }
701
+ .rot-bill .rb-line.total { border-top: 1px solid var(--border); margin-top: 4px; padding-top: 5px; color: var(--text); }
702
+ .rot-bill .rb-asof { font-size: 11px; color: var(--text-faint); margin-top: 5px; }
703
+
704
+ /* ---- Encryption, on the collection form --------------------------------- */
705
+ .enc-section { border-top: 1px solid var(--border); padding-top: 12px; margin-top: 4px; }
706
+ .enc-toggle { display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer; }
707
+ .enc-hint { color: var(--text-faint); font-size: 11.5px; margin-top: 4px; }
708
+ .enc-body { margin-top: 10px; }
709
+ .enc-body .field { margin-bottom: 8px; }
710
+ .enc-problem { display: flex; align-items: center; gap: 6px; color: var(--warn); font-size: 12px; margin: 6px 0; }
711
+ /* What it does and does not do, next to the toggle rather than in a help page: the moment
712
+ someone turns this on is the moment the scope matters. */
713
+ .enc-scope { margin-top: 10px; padding: 10px 12px; border-radius: 8px;
714
+ background: color-mix(in srgb, var(--text) 4%, transparent); }
715
+ .enc-scope .es-title { font-size: 11px; font-weight: 650; text-transform: uppercase;
716
+ letter-spacing: 0.04em; color: var(--text-dim); margin-bottom: 3px; }
717
+ .enc-scope .es-title + .es-line { margin-bottom: 8px; }
718
+ .enc-scope .es-line { font-size: 12px; line-height: 1.5; color: var(--text-dim); }
719
+ .enc-scope .es-line.note { margin-top: 8px; color: var(--text-faint); }
720
+
721
+ /* Provenance warning. Sits between the review and the Install button rather than beside
722
+ the name, because a badge up in the header is something you read past. */
723
+ .review-warning { padding: 12px 18px; border-top: 1px solid var(--border);
724
+ background: color-mix(in srgb, var(--warn) 10%, transparent); }
725
+ .review-warning .rw-head { display: flex; align-items: center; gap: 7px; color: var(--warn);
726
+ font-weight: 600; font-size: 13px; }
727
+ .review-warning .rw-detail { color: var(--text-dim); font-size: 12.5px; line-height: 1.5; margin-top: 5px; }
728
+ .review-warning .rw-ack { display: flex; align-items: flex-start; gap: 8px; margin-top: 10px;
729
+ font-size: 12.5px; color: var(--text); cursor: pointer; }
730
+ .review-warning .rw-ack input { margin-top: 2px; }
731
+ /* An unsigned package is the one someone is most likely to wave through, so it is the
732
+ loudest; the others are informational. */
733
+ .review-warning.unverified { background: color-mix(in srgb, var(--danger) 10%, transparent); }
734
+ .review-warning.unverified .rw-head { color: var(--danger); }
735
+ .btn.primary.disabled, .btn.primary:disabled { opacity: 0.45; cursor: not-allowed; }
736
+
667
737
  /* ---- Media / audiobook opener ------------------------------------------- */
668
738
  .viewer { height: 100%; display: flex; flex-direction: column; }
669
739
  .viewer.text pre { margin: 0; padding: 18px 20px; font-family: var(--font-mono); font-size: 12.5px; line-height: 1.65; white-space: pre-wrap; word-break: break-word; color: var(--text); }
@@ -0,0 +1,22 @@
1
+ // Running a menu item or a list row.
2
+ //
3
+ // These used to carry a `run` closure — `run: () => ui.engine.dispatch(new ExecCommandAction('explorer.open', node))` — which
4
+ // made a menu item a piece of behaviour wearing a data shape. Nothing could inspect one,
5
+ // nothing could contribute one from outside the module that built it, and the engine saw
6
+ // only whatever the closure happened to do.
7
+ //
8
+ // They carry `actions` now: a list of Action instances, which is what an item IS — a label,
9
+ // an icon, and what happens if you pick it. Almost all of them turned out to be exactly one
10
+ // `ExecCommandAction`, which is the shape the statusItem view already used.
11
+ //
12
+ // A LIST rather than a single action because a few items genuinely do two things: opening a
13
+ // file from the search modal opens it and then closes the modal. Sequencing them here keeps
14
+ // that out of the item.
15
+
16
+ /**
17
+ * @param {object} ui
18
+ * @param {{actions?: object[]}} item
19
+ */
20
+ export function activate(ui, item) {
21
+ for (const action of item?.actions || []) ui.engine.dispatch(action);
22
+ }
@@ -6,12 +6,16 @@
6
6
  import { dd } from '../../runtime.js';
7
7
  import { icon } from '../icon.js';
8
8
  import { notificationBell, principalChip } from './social.js';
9
+ import { ExecCommandAction } from '../../bl/actions.js';
9
10
 
10
11
  const { div, button, span, img } = dd;
11
12
 
12
13
  const ITEMS = [
13
14
  { id: 'home', icon: 'search', title: 'Search & files', command: 'workbench.view.home' },
14
15
  { id: 'plugins', icon: 'plug', title: 'Plugins', command: 'workbench.view.plugins' },
16
+ // Where an administrator goes. A destination rather than a route because this app does
17
+ // not put its state in the address bar — see adminView.js.
18
+ { id: 'admin', icon: 'book', title: 'Administration', command: 'workbench.view.admin' },
15
19
  ];
16
20
 
17
21
  export default function activityBar(state, ui) {
@@ -19,17 +23,17 @@ export default function activityBar(state, ui) {
19
23
  const pluginCount = state.plugins?.filter((p) => p.status === 'active').length || 0;
20
24
  return div({ className: 'activitybar' },
21
25
  button({ className: 'brand-mark', title: 'Trove — home' }, img({ src: '/icon.svg', alt: 'Trove' }))
22
- .on({ click: () => ui.exec('workbench.view.home') }),
26
+ .on({ click: () => ui.engine.dispatch(new ExecCommandAction('workbench.view.home')) }),
23
27
  ...ITEMS.map((it) =>
24
28
  button({ className: `item ${active === it.id ? 'active' : ''}`, title: it.title },
25
29
  icon(it.icon, { size: 22 }),
26
30
  it.id === 'plugins' && pluginCount ? span({ className: 'badge' }, String(pluginCount)) : null,
27
- ).on({ click: () => ui.exec(it.command) }),
31
+ ).on({ click: () => ui.engine.dispatch(new ExecCommandAction(it.command)) }),
28
32
  ),
29
33
  div({ className: 'spacer' }),
30
34
  notificationBell(state, ui),
31
35
  button({ className: `item ${active === 'settings' ? 'active' : ''}`, title: 'Settings' }, icon('gear', { size: 21 }))
32
- .on({ click: () => ui.exec('workbench.openSettings') }),
36
+ .on({ click: () => ui.engine.dispatch(new ExecCommandAction('workbench.openSettings')) }),
33
37
  principalChip(state),
34
38
  );
35
39
  }