@3sln/trove 0.0.12 → 0.0.13

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 (89) hide show
  1. package/README.md +13 -1
  2. package/package.json +2 -1
  3. package/packages/core/src/collections/index.js +71 -10
  4. package/packages/core/src/encryption/keys.js +4 -3
  5. package/packages/core/src/encryption/policy.js +14 -7
  6. package/packages/core/src/encryption/rotation.js +46 -12
  7. package/packages/core/src/errors.js +8 -0
  8. package/packages/core/src/index.js +8 -2
  9. package/packages/core/src/indexers/contribution.js +18 -17
  10. package/packages/core/src/indexing.js +8 -4
  11. package/packages/core/src/issues.js +8 -0
  12. package/packages/core/src/links.js +21 -9
  13. package/packages/core/src/metadata/interface.js +22 -0
  14. package/packages/core/src/metadata/memory.js +15 -0
  15. package/packages/core/src/metadata/sqlite.js +23 -1
  16. package/packages/core/src/plugins/contributions.js +13 -0
  17. package/packages/core/src/rateLimit.js +253 -0
  18. package/packages/core/src/scan.js +44 -16
  19. package/packages/core/src/sidecar/document.js +18 -14
  20. package/packages/core/src/sidecar/index.js +9 -10
  21. package/packages/core/src/sidecar/manager.js +10 -15
  22. package/packages/core/src/uploads.js +40 -15
  23. package/packages/core/src/vfs.js +59 -34
  24. package/packages/plugin-sdk/src/browser.js +155 -0
  25. package/packages/plugin-sdk/src/protocol.js +13 -1
  26. package/packages/server/src/adapters/bun.js +1 -2
  27. package/packages/server/src/adapters/node.js +1 -2
  28. package/packages/server/src/engine/index.js +1 -1
  29. package/packages/server/src/engine/providers/access.js +17 -34
  30. package/packages/server/src/engine/providers/core.js +112 -14
  31. package/packages/server/src/index.js +145 -103
  32. package/packages/server/src/mcp/index.js +7 -12
  33. package/packages/server/src/mcp/tools.js +24 -29
  34. package/packages/server/src/router.js +31 -8
  35. package/packages/server/src/routes.js +74 -113
  36. package/packages/server/src/scope.js +78 -0
  37. package/packages/web/dist/assets/main-828yzsr7.js +511 -0
  38. package/packages/web/dist/assets/main-828yzsr7.js.map +120 -0
  39. package/packages/web/dist/index.html +1 -1
  40. package/packages/web/dist/sw.js +1 -1
  41. package/packages/web/src/bl/actions.js +226 -104
  42. package/packages/web/src/bl/activity.js +32 -82
  43. package/packages/web/src/bl/index.js +23 -11
  44. package/packages/web/src/bl/launcher.js +2 -9
  45. package/packages/web/src/bl/links.js +31 -18
  46. package/packages/web/src/bl/offline.js +24 -10
  47. package/packages/web/src/bl/pluginInstall.js +29 -26
  48. package/packages/web/src/bl/queries.js +76 -68
  49. package/packages/web/src/bl/services.js +22 -14
  50. package/packages/web/src/bl/social.js +39 -25
  51. package/packages/web/src/bl/state.js +22 -4
  52. package/packages/web/src/bl/viewState.js +9 -0
  53. package/packages/web/src/bl/watchQuery.js +1 -8
  54. package/packages/web/src/dispatch.js +26 -0
  55. package/packages/web/src/platform/api.js +32 -0
  56. package/packages/web/src/platform/commands.js +14 -15
  57. package/packages/web/src/platform/context.js +3 -58
  58. package/packages/web/src/platform/contributions.js +1 -35
  59. package/packages/web/src/platform/fileChunks.js +281 -0
  60. package/packages/web/src/platform/index.js +8 -9
  61. package/packages/web/src/platform/mediaUrls.js +10 -0
  62. package/packages/web/src/platform/navigation.js +35 -21
  63. package/packages/web/src/platform/pickers.js +39 -19
  64. package/packages/web/src/platform/pluginHost.js +4 -1
  65. package/packages/web/src/platform/pluginRpc.js +47 -2
  66. package/packages/web/src/platform/spatialNav.js +1 -1
  67. package/packages/web/src/platform/viewport.js +14 -6
  68. package/packages/web/src/platform/voiceSearch.js +17 -9
  69. package/packages/web/src/platform/whenclause.js +9 -19
  70. package/packages/web/src/runtime.js +2 -37
  71. package/packages/web/src/ui/activate.js +18 -2
  72. package/packages/web/src/ui/components/activityPanel.js +1 -1
  73. package/packages/web/src/ui/components/adminView.js +4 -7
  74. package/packages/web/src/ui/components/editorArea.js +10 -1
  75. package/packages/web/src/ui/components/launcher.js +29 -13
  76. package/packages/web/src/ui/components/openers/index.js +17 -22
  77. package/packages/web/src/ui/components/openers/markdown.js +2 -2
  78. package/packages/web/src/ui/components/overlays.js +31 -5
  79. package/packages/web/src/ui/components/pluginReview.js +7 -4
  80. package/packages/web/src/ui/components/pluginsView.js +1 -1
  81. package/packages/web/src/ui/components/settingsView.js +30 -27
  82. package/packages/web/src/ui/components/statusBar.js +8 -6
  83. package/packages/web/src/ui/components/views/grid.js +2 -2
  84. package/packages/web/src/ui/components/views/parts.js +1 -1
  85. package/packages/web/src/ui/compositions/workbench.js +20 -6
  86. package/packages/web/src/ui/sanitize.js +0 -5
  87. package/packages/web/src/workbench.js +37 -19
  88. package/packages/web/dist/assets/main-c9dnnnc6.js +0 -356
  89. package/packages/web/dist/assets/main-c9dnnnc6.js.map +0 -118
@@ -11,26 +11,40 @@ const RECENTS_KEY = 'trove.recents';
11
11
  const RECENTS_MAX = 12;
12
12
 
13
13
  export class NavigationService {
14
+ #state;
15
+
14
16
  constructor() {
15
- this.state = {
17
+ this.#state = {
16
18
  stack: [{ kind: 'search' }], // [{kind:'search'}, {kind:'file', id, node, openerId}, …]
17
19
  activeTabId: null, // top file panel id (or null)
18
20
  activeFile: null, // top file panel node (or null)
19
21
  recents: loadRecents(),
20
22
  };
21
- this.cell = cell(this.state);
23
+ this.cell = cell(this.#state);
24
+ }
25
+
26
+ /**
27
+ * The value, for whoever is about to decide something from it.
28
+ *
29
+ * The same door every slice offers. Actions read `.state` and queries read `.cell`, and
30
+ * the two are only equal by habit — bl/state.js says so, and says a resource has one
31
+ * value and one way to read it. `state` is also a public field on a service, which is one
32
+ * typo from `social.state.sidecar = null` bypassing the cell and notifying nothing.
33
+ */
34
+ get() {
35
+ return this.#state;
22
36
  }
23
37
 
24
38
  observe() {
25
39
  return this.cell;
26
40
  }
27
41
  #set(patch) {
28
- this.state = { ...this.state, ...patch };
29
- this.cell.setValue(this.state);
42
+ this.#state = { ...this.#state, ...patch };
43
+ this.cell.setValue(this.#state);
30
44
  }
31
45
 
32
46
  #top() {
33
- return this.state.stack[this.state.stack.length - 1];
47
+ return this.#state.stack[this.#state.stack.length - 1];
34
48
  }
35
49
  /** The active file panel (top of the stack, if it's a file). */
36
50
  activeTab() {
@@ -61,25 +75,25 @@ export class NavigationService {
61
75
  if (reset) {
62
76
  stack = [{ kind: 'search' }, panel];
63
77
  } else {
64
- const at = this.state.stack.findIndex((p) => p.kind === 'file' && p.id === node.id);
78
+ const at = this.#state.stack.findIndex((p) => p.kind === 'file' && p.id === node.id);
65
79
  if (at >= 0) {
66
80
  // Already open — jump back to it, but adopt the (possibly new) opener so
67
81
  // reopening with a different one actually switches the viewer.
68
- stack = this.state.stack.slice(0, at + 1);
82
+ stack = this.#state.stack.slice(0, at + 1);
69
83
  stack[at] = panel;
70
84
  } else {
71
- stack = [...this.state.stack, panel];
85
+ stack = [...this.#state.stack, panel];
72
86
  }
73
87
  }
74
88
  this.#pushRecent(node);
75
89
  this.#applyStack(stack);
76
90
  }
77
91
  back() {
78
- if (this.state.stack.length <= 1) return;
92
+ if (this.#state.stack.length <= 1) return;
79
93
  try { history.back(); } catch { this.pop(); }
80
94
  }
81
95
  pop() {
82
- if (this.state.stack.length > 1) this.#applyStack(this.state.stack.slice(0, -1), { history: false });
96
+ if (this.#state.stack.length > 1) this.#applyStack(this.#state.stack.slice(0, -1), { history: false });
83
97
  }
84
98
  /**
85
99
  * The node is GONE — drop it from everywhere this service holds a copy.
@@ -94,12 +108,12 @@ export class NavigationService {
94
108
  * round — a tile that leads nowhere is worse than one that is missing.
95
109
  */
96
110
  forget(id) {
97
- const recents = this.state.recents.filter((r) => r.id !== id);
98
- if (recents.length !== this.state.recents.length) {
111
+ const recents = this.#state.recents.filter((r) => r.id !== id);
112
+ if (recents.length !== this.#state.recents.length) {
99
113
  this.#set({ recents });
100
114
  saveRecents(recents);
101
115
  }
102
- const stack = this.state.stack.filter((p) => !(p.kind === 'file' && p.id === id));
116
+ const stack = this.#state.stack.filter((p) => !(p.kind === 'file' && p.id === id));
103
117
  this.#applyStack(stack.length ? stack : [{ kind: 'search' }], { history: false });
104
118
  }
105
119
  /**
@@ -111,14 +125,14 @@ export class NavigationService {
111
125
  * list, which reads as the rename half-failing.
112
126
  */
113
127
  updateTabNode(node) {
114
- const recents = this.state.recents.map((r) => (r.id === node.id
128
+ const recents = this.#state.recents.map((r) => (r.id === node.id
115
129
  ? { ...r, name: node.name, contentType: node.contentType || r.contentType }
116
130
  : r));
117
- if (recents.some((r, i) => r !== this.state.recents[i])) {
131
+ if (recents.some((r, i) => r !== this.#state.recents[i])) {
118
132
  this.#set({ recents });
119
133
  saveRecents(recents);
120
134
  }
121
- const stack = this.state.stack.map((p) => (p.kind === 'file' && p.id === node.id ? { ...p, node } : p));
135
+ const stack = this.#state.stack.map((p) => (p.kind === 'file' && p.id === node.id ? { ...p, node } : p));
122
136
  this.#applyStack(stack, { history: false });
123
137
  }
124
138
 
@@ -127,7 +141,7 @@ export class NavigationService {
127
141
  const top = this.#top();
128
142
  const n = top?.node;
129
143
  const entry = {
130
- troveDepth: this.state.stack.length,
144
+ troveDepth: this.#state.stack.length,
131
145
  node: n ? { id: n.id, name: n.name, contentType: n.contentType, collectionId: n.collectionId } : null,
132
146
  openerId: top?.openerId || null,
133
147
  };
@@ -137,17 +151,17 @@ export class NavigationService {
137
151
  onPopState(e) {
138
152
  const s = e?.state || {};
139
153
  const depth = s.troveDepth || 1;
140
- if (depth <= this.state.stack.length) {
141
- this.#applyStack(this.state.stack.slice(0, depth), { history: false });
154
+ if (depth <= this.#state.stack.length) {
155
+ this.#applyStack(this.#state.stack.slice(0, depth), { history: false });
142
156
  } else if (s.node) {
143
- this.#applyStack([...this.state.stack, { kind: 'file', id: s.node.id, node: s.node, openerId: s.openerId }], { history: false });
157
+ this.#applyStack([...this.#state.stack, { kind: 'file', id: s.node.id, node: s.node, openerId: s.openerId }], { history: false });
144
158
  }
145
159
  }
146
160
 
147
161
  #pushRecent(node) {
148
162
  if (!node) return;
149
163
  const entry = { id: node.id, name: node.name, contentType: node.contentType || '', collectionId: node.collectionId };
150
- const recents = [entry, ...this.state.recents.filter((r) => r.id !== node.id)].slice(0, RECENTS_MAX);
164
+ const recents = [entry, ...this.#state.recents.filter((r) => r.id !== node.id)].slice(0, RECENTS_MAX);
151
165
  this.#set({ recents });
152
166
  saveRecents(recents);
153
167
  }
@@ -8,31 +8,51 @@
8
8
  // They were private to bl/commands.js, where the command handlers that used them lived.
9
9
 
10
10
  /**
11
- * Open a native file picker.
11
+ * Open a native file picker. Resolves with the chosen files, or `null` if cancelled.
12
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.
13
+ * A PROMISE rather than a callback, because the action that opens the picker has to be able
14
+ * to wait for it. It could not before: `execute` returned while the OS dialog was still on
15
+ * screen, ngin released the leases in its `finally`, the feed emitted `complete` for an
16
+ * upload that had not begun, and the callback then used `engine` and `explorer` afterwards.
17
+ *
18
+ * SETTLING ON BOTH PATHS is the whole difficulty, and the reason this shape was not taken
19
+ * earlier: cancelling fires no `change` event at all. It is detected the only way the
20
+ * platform allows — the OS dialog returns focus to the window, and after a short grace the
21
+ * input still holds no files. Both paths go through `done`, and `resolve` is idempotent, so
22
+ * whichever happens first settles the promise and the other is a no-op. A picker that could
23
+ * fail to settle would hold its action's lease forever and never emit a terminal event,
24
+ * which is strictly worse than the early `complete` this replaces.
25
+ *
26
+ * The hidden `<input>` is removed on both paths too, so a cancelled picker does not leak
27
+ * one per attempt.
16
28
  */
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();
29
+ function pick(configure) {
30
+ return new Promise((resolve) => {
31
+ const input = document.createElement('input');
32
+ input.type = 'file';
33
+ configure(input);
34
+ input.style.display = 'none';
35
+ document.body.appendChild(input);
36
+ const done = (files) => {
37
+ resolve(files);
38
+ input.remove();
39
+ window.removeEventListener('focus', onFocus);
40
+ };
41
+ const onFocus = () => setTimeout(() => { if (!input.files.length) done(null); }, 300);
42
+ input.addEventListener('change', () => done(input.files), { once: true });
43
+ window.addEventListener('focus', onFocus);
44
+ input.click();
45
+ });
28
46
  }
29
47
 
30
- export function pickFiles(cb) {
31
- pick((files) => cb(files), (input) => { input.multiple = true; });
48
+ /** @returns {Promise<FileList|null>} */
49
+ export function pickFiles() {
50
+ return pick((input) => { input.multiple = true; });
32
51
  }
33
52
 
34
- export function pickZip(cb) {
35
- pick((files) => cb(files[0]), (input) => { input.accept = '.zip,application/zip'; });
53
+ /** @returns {Promise<File|null>} */
54
+ export function pickZip() {
55
+ return pick((input) => { input.accept = '.zip,application/zip'; }).then((files) => files?.[0] ?? null);
36
56
  }
37
57
 
38
58
  /** Hand a URL to the browser as a download. */
@@ -57,7 +57,10 @@ export class PluginHost {
57
57
  destroyFrame: (frame) => this.frames.destroy(frame),
58
58
  // Dispatched, not called: opening a file from a docked plugin frame is the same
59
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)),
60
+ // Not `dispatch?.()`. An unwired seam here silently drops a docked frame's
61
+ // open-file, which is exactly the shape server/src/index.js records as having turned
62
+ // a sweep into a permanent no-op.
63
+ openFile: (node, openerId) => this.platform.dispatch(new OpenInPanelAction(node, openerId)),
61
64
  onChange: () => this.#emit(),
62
65
  });
63
66
  this.frames = new FrameManager({ media: this.media, dock: this.dock });
@@ -11,6 +11,10 @@
11
11
  import { networkEndpoints, canExecuteCommand, displayName } from './pluginPackage.js';
12
12
  import { isAllowedUrl } from './pluginNet.js';
13
13
  import { isSourceModule } from './pluginModules.js';
14
+ // pluginHost.js already imports actions to build this router, so this widens an existing
15
+ // edge rather than creating one — and it removes the third seam assigned onto `platform`
16
+ // after construction, which is the shape that makes the resource graph not the whole story.
17
+ import { OpenPluginPanelAction } from '../bl/actions.js';
14
18
  import { contribUri, parseContribUri } from '@3sln/trove/core/plugins/identity.js';
15
19
  import { assertSafePluginSql } from '@3sln/trove/core/plugins/sql.js';
16
20
 
@@ -143,7 +147,48 @@ export class PluginRpcRouter {
143
147
  { sort: params.sort, order: params.order, limit: params.limit, cursor: params.cursor },
144
148
  );
145
149
  case 'files:stat': return cap('files'), this.platform.api.stat(params.id);
146
- case 'files:downloadUrl': return cap('files'), { url: this.platform.api.downloadUrl(params.id) };
150
+
151
+ // BYTES, by range. `files:read` answers text, which for an audiobook or any other
152
+ // container is not slow, it is corrupt — and `files:downloadUrl` used to hand out a
153
+ // bare route URL carrying no authorization, which an opaque-origin sandbox cannot
154
+ // make good on: no cookies, no `Authorization` header, a 401 on any drive that
155
+ // authenticates. So this is how a viewer gets bytes at all.
156
+ //
157
+ // Half-open `[start, end)`, and through `fileChunks` rather than the API directly:
158
+ // a pinned file answers from disk, a file being kept offline contributes what it
159
+ // fetches, and a file nobody asked to keep leaves nothing behind. See fileChunks.js.
160
+ case 'files:bytes': {
161
+ cap('files');
162
+ const r = await this.platform.fileChunks.read(params.id, { start: params.start, end: params.end });
163
+ // Transferred rather than copied: a 4 MiB chunk crossing by structured clone twice
164
+ // per read is the difference between a seek and a stutter.
165
+ return { bytes: r.bytes.buffer, etag: r.etag, total: r.total };
166
+ }
167
+
168
+ // Keeping a file offline, which is a DIFFERENT act from reading it — see
169
+ // fileChunks.js. Until `start` is called, ranging over a file stores nothing.
170
+ case 'files:offline:start': return cap('files'), this.platform.fileChunks.start(params.id);
171
+ case 'files:offline:status': return cap('files'), this.platform.fileChunks.status(params.id);
172
+ case 'files:offline:cancel': return cap('files'), (this.platform.fileChunks.cancel(params.id), { ok: true });
173
+ case 'files:offline:remove': return cap('files'), this.platform.fileChunks.remove(params.id).then(() => ({ ok: true }));
174
+
175
+ /**
176
+ * A URL a media element can load on its own.
177
+ *
178
+ * The one place a host URL deliberately crosses into the sandbox, and it is minted —
179
+ * it carries its own grant and expires, which is exactly what `platform/mediaUrls.js`
180
+ * exists for: "URLs for things that cannot send a header … an <img src>, a <video
181
+ * src>". The rule it bends is that plugins hold only opaque handles; the reason is
182
+ * that `<audio src>` is the only way to stream a progressive MP4 without a
183
+ * fragmenter, because MSE refuses one and a Blob has to be whole before it can
184
+ * become an object URL. A viewer that can range-read for chapters and stream for
185
+ * playback needs both, and this is the streaming half.
186
+ */
187
+ case 'files:mediaUrl': {
188
+ cap('files');
189
+ const { url, expiresAt } = await this.platform.mediaUrls.url(params.id, { op: params.op || 'media' });
190
+ return { url, expiresAt };
191
+ }
147
192
  case 'files:index': {
148
193
  cap('indexer');
149
194
  const ns = parseContribUri(params.indexerId) ? params.indexerId : contribUri(record.manifest, params.indexerId || 'default');
@@ -174,7 +219,7 @@ export class PluginRpcRouter {
174
219
  case 'ui:showPanel':
175
220
  cap('ui');
176
221
  record.hasUi = true;
177
- this.platform.openPluginPanel(pid);
222
+ this.platform.dispatch(new OpenPluginPanelAction(pid));
178
223
  this.onChange();
179
224
  return { ok: true };
180
225
 
@@ -59,7 +59,7 @@ export class SpatialNavigationService {
59
59
  /** Follow the viewport: arrows are remapped only while the TV layout is showing. */
60
60
  install() {
61
61
  if (this.viewport) effect(this.viewport.observe(), (vp) => this.setActive(vp.mode === 'tv'));
62
- this.setActive(this.viewport?.state?.mode === 'tv');
62
+ this.setActive(this.viewport?.get().mode === 'tv');
63
63
  return this;
64
64
  }
65
65
 
@@ -29,6 +29,8 @@ export function looksLikeTv(ua = '', width = 0) {
29
29
  }
30
30
 
31
31
  export class ViewportService {
32
+ #state;
33
+
32
34
  /**
33
35
  * @param {object} [deps]
34
36
  * @param {Window} [deps.window] the window to measure and listen to
@@ -40,11 +42,17 @@ export class ViewportService {
40
42
  // A URL override outranks the setting: it is how someone checks the phone layout on
41
43
  // a laptop, and how the e2e suite drives each shell without a device farm.
42
44
  this.urlOverride = readUrlOverride(win);
43
- this.state = this.#measure();
44
- this.cell = cell(this.state);
45
+ this.#state = this.#measure();
46
+ this.cell = cell(this.#state);
45
47
  this._onResize = () => this.refresh();
46
48
  }
47
49
 
50
+ /** The value, for whoever is about to decide something from it. See bl/state.js: one
51
+ * value, one way to read it — a public `state` field is a second, writable door. */
52
+ get() {
53
+ return this.#state;
54
+ }
55
+
48
56
  observe() {
49
57
  return this.cell;
50
58
  }
@@ -58,7 +66,7 @@ export class ViewportService {
58
66
  // stopped returning something with `.subscribe` it would have become a silent
59
67
  // no-op and the viewport would have stopped following its own setting.
60
68
  if (this.settings) effect(this.settings.observe(), () => this.refresh());
61
- this.#publish(this.state);
69
+ this.#publish(this.#state);
62
70
  return this;
63
71
  }
64
72
 
@@ -69,10 +77,10 @@ export class ViewportService {
69
77
 
70
78
  refresh() {
71
79
  const next = this.#measure();
72
- const same = next.mode === this.state.mode && next.width === this.state.width
73
- && next.height === this.state.height && next.coarse === this.state.coarse;
80
+ const same = next.mode === this.#state.mode && next.width === this.#state.width
81
+ && next.height === this.#state.height && next.coarse === this.#state.coarse;
74
82
  if (same) return;
75
- this.state = next;
83
+ this.#state = next;
76
84
  this.#publish(next);
77
85
  this.cell.setValue(next);
78
86
  }
@@ -17,6 +17,8 @@ import { cell } from '../runtime.js';
17
17
  import { canTranscribeLocally, localAvailability, installLocal, listen } from './voice.js';
18
18
 
19
19
  export class VoiceSearchService {
20
+ #state;
21
+
20
22
  /**
21
23
  * @param {object} deps
22
24
  * @param {object} deps.notifications
@@ -25,21 +27,27 @@ export class VoiceSearchService {
25
27
  this.notifications = notifications;
26
28
  this.settings = settings;
27
29
  this.session = null;
28
- this.state = { supported: canTranscribeLocally(), status: 'unknown', listening: false };
29
- this.cell = cell(this.state);
30
+ this.#state = { supported: canTranscribeLocally(), status: 'unknown', listening: false };
31
+ this.cell = cell(this.#state);
32
+ }
33
+
34
+ /** The value, for whoever is about to decide something from it. See bl/state.js: one
35
+ * value, one way to read it — a public `state` field is a second, writable door. */
36
+ get() {
37
+ return this.#state;
30
38
  }
31
39
 
32
40
  observe() {
33
41
  return this.cell;
34
42
  }
35
43
  #set(patch) {
36
- this.state = { ...this.state, ...patch };
37
- this.cell.setValue(this.state);
44
+ this.#state = { ...this.#state, ...patch };
45
+ this.cell.setValue(this.#state);
38
46
  }
39
47
 
40
48
  /** Ask once whether an on-device language pack is ready, and remember the answer. */
41
49
  async refresh() {
42
- if (!this.state.supported) return 'unavailable';
50
+ if (!this.#state.supported) return 'unavailable';
43
51
  const status = await localAvailability(this.#lang());
44
52
  this.#set({ status });
45
53
  return status;
@@ -51,7 +59,7 @@ export class VoiceSearchService {
51
59
 
52
60
  /** Can we offer a microphone button right now? */
53
61
  canListen() {
54
- return this.state.supported && (this.state.status === 'available' || this.state.status === 'downloadable');
62
+ return this.#state.supported && (this.#state.status === 'available' || this.#state.status === 'downloadable');
55
63
  }
56
64
 
57
65
  /**
@@ -66,9 +74,9 @@ export class VoiceSearchService {
66
74
  // Focus AFTER the surface has rendered — the input does not exist yet on the frame
67
75
  // that opened it.
68
76
  await this.focusInput();
69
- if (!this.state.supported) return { listening: false, reason: 'unsupported' };
77
+ if (!this.#state.supported) return { listening: false, reason: 'unsupported' };
70
78
 
71
- const status = this.state.status === 'unknown' ? await this.refresh() : this.state.status;
79
+ const status = this.#state.status === 'unknown' ? await this.refresh() : this.#state.status;
72
80
  if (status === 'downloadable') {
73
81
  // Pressing the button IS the consent to fetch it; nothing downloads before that.
74
82
  this.notifications?.info('Downloading the on-device voice model — this happens once.');
@@ -125,7 +133,7 @@ export class VoiceSearchService {
125
133
  openSearchSurface() {
126
134
  const wb = this.workbench;
127
135
  if (!wb) return;
128
- const onLauncher = wb.state.activity === 'home' && !wb.nav?.state?.activeTabId;
136
+ const onLauncher = wb.state.activity === 'home' && !wb.nav?.get?.().activeTabId;
129
137
  if (onLauncher) wb.closeSearchModal?.();
130
138
  else wb.openSearchModal();
131
139
  }
@@ -7,11 +7,14 @@
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.
10
+ // Every clause is evaluated against the WHOLE context snapshot, which is what the palette,
11
+ // the keybindings and the openers all do. A per-clause subscription layer lived here once —
12
+ // the parser collected the keys an expression named and ContextRegistry derived a cell over
13
+ // exactly those and nothing ever used it, while `evaluate` over the snapshot did all the
14
+ // work. If per-clause reactivity is ever wanted (a palette that becomes its own region is
15
+ // the case for it), the lexer already sees the key/literal distinction in one pass, so it
16
+ // is ~25 lines to bring back. Shipping both halves and using neither is the one thing that
17
+ // was not defensible.
15
18
  //
16
19
  // A key is either a core context key (`view.active`, `explorer.hasSelection`) or a
17
20
  // contribution URI naming a plugin's `register` — `trove+contrib:acme.com/docs/busy`.
@@ -25,16 +28,10 @@ export function compileWhen(expr) {
25
28
  if (cache.has(expr)) return cache.get(expr);
26
29
  let fn;
27
30
  try {
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]);
31
+ fn = new Parser(expr).parseExpression();
32
32
  } catch (err) {
33
33
  console.warn(`Invalid when clause: "${expr}" — ${err.message}`);
34
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([]);
38
35
  }
39
36
  cache.set(expr, fn);
40
37
  return fn;
@@ -44,11 +41,6 @@ export function evaluateWhen(expr, ctx) {
44
41
  return compileWhen(expr)(ctx || {});
45
42
  }
46
43
 
47
- /** Every context key `expr` names — `[]` for a constant or unparseable clause. */
48
- export function keysOf(expr) {
49
- return compileWhen(expr).keys ?? [];
50
- }
51
-
52
44
  // Note the `trove+contrib:` alternative comes before the regex-literal one: a URI's
53
45
  // slashes would otherwise start a /…/ literal and swallow the rest of the expression.
54
46
  const TOKEN = /\s*(=~|==|!=|>=|<=|&&|\|\||[()!<>]|trove\+contrib:[A-Za-z0-9_./-]+|\/(?:\\.|[^/])*\/[a-z]*|'(?:\\.|[^'])*'|"(?:\\.|[^"])*"|[A-Za-z0-9_.:-]+)/y;
@@ -58,7 +50,6 @@ class Parser {
58
50
  this.src = src;
59
51
  this.tokens = this.#lex(src);
60
52
  this.pos = 0;
61
- this.keys = new Set();
62
53
  }
63
54
  #lex(src) {
64
55
  const out = [];
@@ -155,7 +146,6 @@ class Parser {
155
146
  return () => s;
156
147
  }
157
148
  // A context key.
158
- this.keys.add(t);
159
149
  return (c) => c[t];
160
150
  }
161
151
  }
@@ -16,8 +16,7 @@
16
16
  //
17
17
  // `PENDING` is how a cell says "no value yet" — `watch` renders its placeholder
18
18
  // instead of calling the builder. An error is reported by THROWING from
19
- // `getValue()`, which `watch` renders as its error view. Both matter for
20
- // `fromAsync` below, which is the only shape of ours the module does not ship.
19
+ // `getValue()`, which `watch` renders as its error view.
21
20
 
22
21
  import * as dodo from '@3sln/dodo';
23
22
  import {
@@ -32,42 +31,8 @@ export {
32
31
  fromObservable, toObservable, effect, isCell, readCell, PENDING,
33
32
  };
34
33
 
35
- /**
36
- * A cell backed by one async call: `PENDING` until it settles, the value after,
37
- * and a throw from `getValue()` if it rejects — which is exactly the vocabulary
38
- * `watch`'s `placeholder` and `error` options are written against.
39
- *
40
- * The work starts on the first listener, not at construction, so building one
41
- * for a view that is never rendered costs nothing. It runs ONCE: a cell that
42
- * refetched every time its last watcher went away and came back would reload a
43
- * document on every remount.
44
- */
45
- export function fromAsync(work) {
46
- let state = { status: 'idle' };
47
- return connectable(
48
- (notify) => {
49
- if (state.status === 'idle') {
50
- state = { status: 'running' };
51
- Promise.resolve()
52
- .then(work)
53
- .then(
54
- (value) => { state = { status: 'done', value }; notify(); },
55
- (error) => { state = { status: 'failed', error }; notify(); },
56
- );
57
- }
58
- // Nothing to disconnect: a promise cannot be un-awaited, and the result is
59
- // kept so a later listener reads it rather than starting again.
60
- return () => {};
61
- },
62
- () => {
63
- if (state.status === 'failed') throw state.error;
64
- return state.status === 'done' ? state.value : PENDING;
65
- },
66
- );
67
- }
68
-
69
34
  /** The reactive API as one object, for `platform.reactive` (and plugin-facing code). */
70
35
  export const reactive = {
71
36
  watch, cell, derive, constant, mapCell, connectable,
72
- fromObservable, fromAsync, toObservable, effect, isCell, readCell, PENDING,
37
+ fromObservable, toObservable, effect, isCell, readCell, PENDING,
73
38
  };
@@ -13,10 +13,26 @@
13
13
  // file from the search modal opens it and then closes the modal. Sequencing them here keeps
14
14
  // that out of the item.
15
15
 
16
+ import { runAction } from '../dispatch.js';
17
+
16
18
  /**
19
+ * Run an item's actions, one genuinely finished before the next begins.
20
+ *
21
+ * The loop used to be a bare `ui.engine.dispatch(action)` under a comment claiming it
22
+ * sequenced them — `dispatch` returns a feed and schedules the body, so it fired them all
23
+ * at once. Nothing was visibly broken, because the lists touched independent slices, but
24
+ * bl/launcher.js already emits `[SetLaunchQueryAction, FilterAction]` and the comment was
25
+ * telling the next reader that a read-after-write pair here would be safe.
26
+ *
27
+ * The same shape and the same stop-on-failure rule as CommandService.execute, which is the
28
+ * other implementation of "run this item's list of actions".
29
+ *
17
30
  * @param {object} ui
18
31
  * @param {{actions?: object[]}} item
19
32
  */
20
- export function activate(ui, item) {
21
- for (const action of item?.actions || []) ui.engine.dispatch(action);
33
+ export async function activate(ui, item) {
34
+ for (const action of item?.actions || []) {
35
+ const settled = await runAction(ui.engine, action);
36
+ if (settled?.type !== 'complete') break;
37
+ }
22
38
  }
@@ -98,7 +98,7 @@ function issueRow(issue, ui) {
98
98
 
99
99
  export default function activityPanel(state, ui) {
100
100
  const act = state.act || { tasks: [], issues: [] };
101
- if (!act.open) return null;
101
+ if (!state.overlay.activityPanel) return null;
102
102
  const running = act.tasks.filter((t) => t.status === 'running');
103
103
  const recent = act.tasks.filter((t) => t.status !== 'running');
104
104
 
@@ -19,7 +19,7 @@
19
19
  import { dd } from '../../runtime.js';
20
20
  import { watchQuery } from '../../bl/watchQuery.js';
21
21
  import { grantsFor } from '../../bl/queries.js';
22
- import { icon } from '../icon.js';
22
+ import { activate } from '../activate.js';
23
23
  import { bytes } from '../format.js';
24
24
  import { ExecCommandAction, SetGrantAction } from '../../bl/actions.js';
25
25
 
@@ -97,12 +97,9 @@ function collectionsSection(state, ui) {
97
97
  // where the second only means anything after the first has landed. Fired concurrently,
98
98
  // "Rotate key…" opened Settings and was then thrown back to home by the switch that had
99
99
  // not finished yet. Same shape as platform/commands.js, which got this right first.
100
- const run = (actions) => async () => {
101
- for (const action of actions) {
102
- const settled = await ui.engine.dispatch(action).next(['complete', 'error', 'abort']);
103
- if (settled?.type !== 'complete') break;
104
- }
105
- };
100
+ // The same "run this item's list of actions" as every menu item and list row — one
101
+ // implementation, in ui/activate.js, rather than a third spelling of the loop here.
102
+ const run = (actions) => () => activate(ui, { actions });
106
103
  return div({ className: 'group' },
107
104
  h3('Collections'),
108
105
  p({ className: 'sub' }, 'What exists on this drive, where its bytes live, and which key seals it.'),
@@ -13,7 +13,16 @@ import { NavigateBackAction, OpenInPanelAction, ShowDialogAction, ShowHomeAction
13
13
 
14
14
  const { div, span, button } = dd;
15
15
 
16
- const openerFns = new Map(); // `${panelId}:${openerId}` -> stable render fn
16
+ /**
17
+ * `${panelId}:${openerId}` -> stable render fn.
18
+ *
19
+ * dodo identifies an alias by the FUNCTION, so a fresh one per render would rebuild it
20
+ * every pass and take the <audio> with it. Module scope is safe here in a way it was not
21
+ * for the keybindings region, and the difference is the key: a panel id is minted by
22
+ * `newId()` and is unique across every workbench on the page, so two shells cannot collide
23
+ * and hand each other's `ui` to a render. `pruneOpeners` bounds it to the live stack.
24
+ */
25
+ const openerFns = new Map();
17
26
 
18
27
  export default function editorArea(state, ui) {
19
28
  const wb = state.wb;