@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,74 +1,169 @@
1
- // ContextKeyService — the reactive bag of boolean/string/number flags that
2
- // describe the workbench's current state (which view is focused, whether a file
3
- // is open, its type, whether the palette is showing…). when-clauses evaluate
4
- // against it, so commands/keybindings/menus light up and dim as state changes.
1
+ // ContextRegistry — the named facts a when-clause is evaluated against.
5
2
  //
6
- // It's a single cell of the whole context object, so any consumer can `watch` it and
7
- // re-render, and keybinding resolution reads a plain snapshot.
8
- // Plugins get a *scoped* setter (keys they set are namespaced under their id) so
9
- // they can drive their own when-clauses without stomping core keys.
3
+ // A REGISTRY of cells, not a bag of values, and the difference is the whole design:
4
+ //
5
+ // - Every key is backed by a cell somebody OWNS. The registry never hands the cell out,
6
+ // only its value, so a key can be read by anyone and written by exactly one thing.
7
+ // - A contributor registers while it exists and unregisters when it is disposed, so the
8
+ // set of keys is a fact about what is currently installed rather than a pile that only
9
+ // grows.
10
+ // - Because the backings are cells, a derived layer sits on top for free: a built-in key
11
+ // is a `derive` over the resource it summarises, and so cannot go stale.
12
+ //
13
+ // That last point is why this replaced a plain map with setters. Context keys are DERIVED
14
+ // state — what is selected, what is open, what is focused — and they were maintained by
15
+ // pushing: OverlayService wrote `palette.open` as a side effect of opening the palette,
16
+ // NavigationService wrote three `editor.*` keys, WorkbenchService four more, ViewportService
17
+ // three, and an effect in bl/index.js the explorer's two. Five writers for one derivation,
18
+ // and the bug that shape produces is on the record: selecting a file never flipped
19
+ // `explorer.hasSelection`, so the Delete keybinding silently did nothing, because only
20
+ // NavigateAction had been taught to set it. The fix at the time was to add another writer.
21
+ //
22
+ // Nothing sets a built-in key now. They are derived — see bl/context.js.
23
+
24
+ import { cell, derive } from '../runtime.js';
25
+ import { evaluateWhen, compileWhen } from './whenclause.js';
10
26
 
11
- import { cell } from '../runtime.js';
12
- import { evaluateWhen } from './whenclause.js';
27
+ /** A cell whose value never changes — for facts about the machine rather than the drive. */
28
+ const constant = (value) => ({ onDirty: () => () => {}, getValue: () => value });
29
+
30
+ export class ContextRegistry {
31
+ #cells = new Map(); // key -> a Cell its owner holds
32
+ #offs = new Map(); // key -> unsubscribe
33
+ #slots = new Map(); // key -> a stable cell that outlives any one owner
34
+ #snapshot = cell({});
13
35
 
14
- export class ContextKeyService {
15
36
  constructor(initial = {}) {
16
- this.state = {
37
+ const fixed = {
17
38
  platform: navigatorPlatform(),
18
39
  isMac: /mac/i.test(navigatorPlatform()),
19
40
  ...initial,
20
41
  };
21
- this.cell = cell(this.state);
42
+ for (const [key, value] of Object.entries(fixed)) this.register(key, constant(value));
22
43
  }
23
44
 
24
- get(key) {
25
- return this.state[key];
45
+ /**
46
+ * Back `key` with a cell. Returns the unregister function.
47
+ *
48
+ * The caller keeps the cell, and that is what makes it the owner: writing means writing
49
+ * something the registry cannot reach. Registering a key twice is refused rather than
50
+ * silently taken over — two owners for one fact is exactly the confusion this shape
51
+ * exists to prevent, so it should be loud.
52
+ *
53
+ * @param {string} key
54
+ * @param {{onDirty: Function, getValue: Function}} source
55
+ * @returns {() => void} unregister
56
+ */
57
+ register(key, source) {
58
+ if (this.#cells.has(key)) {
59
+ throw new Error(`Context key "${key}" already has an owner`);
60
+ }
61
+ this.#cells.set(key, source);
62
+ this.#offs.set(key, source.onDirty(() => this.#changed(key)));
63
+ this.#changed(key); // gaining an owner is itself a change
64
+ return () => this.unregister(key);
26
65
  }
27
- snapshot() {
28
- return this.state;
66
+
67
+ unregister(key) {
68
+ const off = this.#offs.get(key);
69
+ if (!off) return;
70
+ off();
71
+ this.#offs.delete(key);
72
+ this.#cells.delete(key);
73
+ this.#changed(key); // and so is losing one
29
74
  }
30
- observe() {
31
- return this.cell;
75
+
76
+ /**
77
+ * A cell for one key, whether or not anything owns it yet.
78
+ *
79
+ * The indirection is the point. A keymap naming a plugin's register is parsed long before
80
+ * that plugin installs, and the plugin may later be uninstalled — so a watcher cannot
81
+ * hold the OWNER's cell, which does not exist at either end. It holds this slot, which
82
+ * reads through to whoever currently owns the key and goes dirty when ownership changes
83
+ * as well as when the value does. Unowned reads as `undefined`, which every clause
84
+ * already treats as falsy.
85
+ *
86
+ * Slots are kept once created. They are bounded by the number of distinct keys any
87
+ * when-clause has ever named, which is small and does not grow with use.
88
+ */
89
+ cellFor(key) {
90
+ let slot = this.#slots.get(key);
91
+ if (!slot) {
92
+ const listeners = new Set();
93
+ slot = {
94
+ listeners,
95
+ onDirty: (fn) => { listeners.add(fn); return () => listeners.delete(fn); },
96
+ getValue: () => this.#cells.get(key)?.getValue(),
97
+ };
98
+ this.#slots.set(key, slot);
99
+ }
100
+ return slot;
32
101
  }
33
102
 
34
- set(key, value) {
35
- if (this.state[key] === value) return;
36
- this.state = { ...this.state, [key]: value };
37
- this.cell.setValue(this.state);
103
+ /**
104
+ * A when-clause as a live boolean.
105
+ *
106
+ * Derived over exactly the keys the expression NAMES (the parser collects them — see
107
+ * whenclause.js), so a clause about `view.active` does not recompute when the selection
108
+ * changes. That is the difference from `evaluate`, which reads the whole snapshot: a
109
+ * consumer holding many clauses — the palette holds one per registered command — stops
110
+ * re-running all of them on every unrelated change.
111
+ *
112
+ * A clause naming nothing derives over nothing and is computed once, which is right: a
113
+ * constant cannot become false.
114
+ */
115
+ watch(expr) {
116
+ const predicate = compileWhen(expr);
117
+ const keys = predicate.keys ?? [];
118
+ return derive(keys.map((key) => this.cellFor(key)), (...values) => {
119
+ const ctx = {};
120
+ for (let i = 0; i < keys.length; i++) ctx[keys[i]] = values[i];
121
+ return predicate(ctx);
122
+ });
38
123
  }
39
- setMany(obj) {
40
- let changed = false;
41
- const next = { ...this.state };
42
- for (const [k, v] of Object.entries(obj)) {
43
- if (next[k] !== v) {
44
- next[k] = v;
45
- changed = true;
46
- }
47
- }
48
- if (changed) {
49
- this.state = next;
50
- this.cell.setValue(next);
51
- }
124
+
125
+ #changed(key) {
126
+ this.#recompute();
127
+ const slot = this.#slots.get(key);
128
+ if (slot) for (const fn of [...slot.listeners]) fn();
52
129
  }
53
- remove(key) {
54
- if (!(key in this.state)) return;
55
- const { [key]: _drop, ...rest } = this.state;
56
- this.state = rest;
57
- this.cell.setValue(rest);
130
+
131
+ /**
132
+ * Own a key outright: the registry makes the cell and hands back the writer.
133
+ *
134
+ * For the facts that genuinely are pushed rather than derived — a plugin setting its own
135
+ * declared register over RPC. The write capability IS the returned `set`, so holding a
136
+ * reference to the registry is not enough to change anything.
137
+ */
138
+ own(key, initial = undefined) {
139
+ const held = cell(initial);
140
+ const dispose = this.register(key, held);
141
+ return { set: (value) => held.setValue(value), dispose };
58
142
  }
59
143
 
60
- /** True if `whenExpr` holds against the current context. */
61
- evaluate(whenExpr) {
62
- return evaluateWhen(whenExpr, this.state);
144
+ #recompute() {
145
+ const next = {};
146
+ for (const [key, source] of this.#cells) next[key] = source.getValue();
147
+ this.#snapshot.setValue(next);
63
148
  }
64
149
 
65
- /** A setter namespaced under a plugin id, so plugin keys can't collide. */
66
- scopedFor(pluginId) {
67
- const prefix = `${pluginId}.`;
68
- return {
69
- set: (key, value) => this.set(prefix + key, value),
70
- remove: (key) => this.remove(prefix + key),
71
- };
150
+ /** Every key and its current value. */
151
+ snapshot() {
152
+ return this.#snapshot.getValue();
153
+ }
154
+ get(key) {
155
+ return this.#cells.get(key)?.getValue();
156
+ }
157
+ has(key) {
158
+ return this.#cells.has(key);
159
+ }
160
+ observe() {
161
+ return this.#snapshot;
162
+ }
163
+
164
+ /** True if `whenExpr` holds against the current context. */
165
+ evaluate(whenExpr) {
166
+ return evaluateWhen(whenExpr, this.snapshot());
72
167
  }
73
168
  }
74
169
 
@@ -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;