@3sln/trove 0.0.11 → 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 (90) 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/access/externalEvaluation.js +17 -0
  27. package/packages/server/src/adapters/bun.js +1 -2
  28. package/packages/server/src/adapters/node.js +1 -2
  29. package/packages/server/src/engine/index.js +1 -1
  30. package/packages/server/src/engine/providers/access.js +17 -34
  31. package/packages/server/src/engine/providers/core.js +112 -14
  32. package/packages/server/src/index.js +160 -105
  33. package/packages/server/src/mcp/index.js +7 -12
  34. package/packages/server/src/mcp/tools.js +24 -29
  35. package/packages/server/src/router.js +31 -8
  36. package/packages/server/src/routes.js +74 -113
  37. package/packages/server/src/scope.js +78 -0
  38. package/packages/web/dist/assets/main-828yzsr7.js +511 -0
  39. package/packages/web/dist/assets/main-828yzsr7.js.map +120 -0
  40. package/packages/web/dist/index.html +1 -1
  41. package/packages/web/dist/sw.js +1 -1
  42. package/packages/web/src/bl/actions.js +226 -104
  43. package/packages/web/src/bl/activity.js +32 -82
  44. package/packages/web/src/bl/index.js +23 -11
  45. package/packages/web/src/bl/launcher.js +2 -9
  46. package/packages/web/src/bl/links.js +31 -18
  47. package/packages/web/src/bl/offline.js +24 -10
  48. package/packages/web/src/bl/pluginInstall.js +29 -26
  49. package/packages/web/src/bl/queries.js +76 -68
  50. package/packages/web/src/bl/services.js +22 -14
  51. package/packages/web/src/bl/social.js +39 -25
  52. package/packages/web/src/bl/state.js +22 -4
  53. package/packages/web/src/bl/viewState.js +9 -0
  54. package/packages/web/src/bl/watchQuery.js +1 -8
  55. package/packages/web/src/dispatch.js +26 -0
  56. package/packages/web/src/platform/api.js +32 -0
  57. package/packages/web/src/platform/commands.js +14 -15
  58. package/packages/web/src/platform/context.js +3 -58
  59. package/packages/web/src/platform/contributions.js +1 -35
  60. package/packages/web/src/platform/fileChunks.js +281 -0
  61. package/packages/web/src/platform/index.js +8 -9
  62. package/packages/web/src/platform/mediaUrls.js +10 -0
  63. package/packages/web/src/platform/navigation.js +35 -21
  64. package/packages/web/src/platform/pickers.js +39 -19
  65. package/packages/web/src/platform/pluginHost.js +4 -1
  66. package/packages/web/src/platform/pluginRpc.js +47 -2
  67. package/packages/web/src/platform/spatialNav.js +1 -1
  68. package/packages/web/src/platform/viewport.js +14 -6
  69. package/packages/web/src/platform/voiceSearch.js +17 -9
  70. package/packages/web/src/platform/whenclause.js +9 -19
  71. package/packages/web/src/runtime.js +2 -37
  72. package/packages/web/src/ui/activate.js +18 -2
  73. package/packages/web/src/ui/components/activityPanel.js +1 -1
  74. package/packages/web/src/ui/components/adminView.js +4 -7
  75. package/packages/web/src/ui/components/editorArea.js +10 -1
  76. package/packages/web/src/ui/components/launcher.js +29 -13
  77. package/packages/web/src/ui/components/openers/index.js +17 -22
  78. package/packages/web/src/ui/components/openers/markdown.js +2 -2
  79. package/packages/web/src/ui/components/overlays.js +31 -5
  80. package/packages/web/src/ui/components/pluginReview.js +7 -4
  81. package/packages/web/src/ui/components/pluginsView.js +1 -1
  82. package/packages/web/src/ui/components/settingsView.js +30 -27
  83. package/packages/web/src/ui/components/statusBar.js +8 -6
  84. package/packages/web/src/ui/components/views/grid.js +2 -2
  85. package/packages/web/src/ui/components/views/parts.js +1 -1
  86. package/packages/web/src/ui/compositions/workbench.js +20 -6
  87. package/packages/web/src/ui/sanitize.js +0 -5
  88. package/packages/web/src/workbench.js +37 -19
  89. package/packages/web/dist/assets/main-c9dnnnc6.js +0 -356
  90. package/packages/web/dist/assets/main-c9dnnnc6.js.map +0 -118
@@ -7,10 +7,14 @@
7
7
  // observers", `boot`/`kill` bracket a query's life, and a lease holds the providers it needs
8
8
  // for exactly that long.
9
9
  //
10
- // This is step one of moving onto it (docs/tickets/009). Each query here wraps the service
11
- // that exists today, so nothing in the UI has to change yet and the two describe the same
12
- // state rather than competing to. The services are deleted in a later phase, at which point
13
- // these stop wrapping and simply hold.
10
+ // That move is DONE. The state bags became slices (see bl/state.js) and most of these
11
+ // queries now view a slice rather than wrapping a service which is why the base class is
12
+ // `CellQuery`: one cell, viewed, whatever is behind it. `ViewQuery` is the other shape,
13
+ // composing several cells into an answer the UI actually asks for.
14
+ //
15
+ // Four resources stayed services because they cover something a state bag cannot — history
16
+ // and recents, polling, a queue that replays on reconnect — and they answer `get()` and
17
+ // `observe()` like everything else, so a query cannot tell which it is looking at.
14
18
  //
15
19
  // INSTANCE IDENTITY IS THE SHARING KEY. ngin keys live realizations by the query INSTANCE —
16
20
  // `#controllers` is a Map keyed on the object — and nothing anywhere looks at the class or
@@ -32,7 +36,10 @@
32
36
  import { Query } from '@3sln/ngin';
33
37
  import { queryOf } from './intern.js';
34
38
  import { selectedNodesOf, draftScopesOf, collectionMenuOf, collectionAdminOf } from './services.js';
35
- import { ExecCommandAction, LoadSidecarAction, ClearSidecarAction, LoadRotationAction, LoadGrantsAction, ShowCollectionAccessAction } from './actions.js';
39
+ import {
40
+ ExecCommandAction, LoadSidecarAction, ClearSidecarAction, LoadRotationAction, LoadGrantsAction,
41
+ LoadApiKeysAction, ShowCollectionAccessAction,
42
+ } from './actions.js';
36
43
  import { ASSOC_KEY, describeOpener } from './openers.js';
37
44
  import { rankCommands } from './match.js';
38
45
  import { statusFactsOf, driveConditionOf } from './status.js';
@@ -41,20 +48,22 @@ import { pickView } from './views.js';
41
48
  import { prettyKey } from '../platform/keybindings.js';
42
49
 
43
50
  /**
44
- * A live query over one of the existing cell-backed services.
45
- *
46
- * The service exposes dodo's Cell protocol `onDirty(fn)` to learn it changed, `getValue()`
47
- * to read it — and a query wants push. Bridging is two lines, so rather than write them per
48
- * service this takes the cell as a function of the leased `app`.
49
- *
50
- * It reads the CELL, never the service's own `state` field. Most services keep both in step
51
- * (`this.state = {...}; this.cell.setValue(this.state)`) so the two usually agree, but not
52
- * all of them do `settings`' cell holds `effective()`, the defaults merged with the
53
- * overrides, and there is no `state` field at all. The existing snapshot passes cells to
54
- * `derive`, which hands it their values; reading the same way is what makes these queries
55
- * and the snapshot describe the same state rather than two subtly different ones.
51
+ * A live query over ONE cell whatever holds it.
52
+ *
53
+ * Named for what it does rather than for what used to be behind it: most of these view a
54
+ * slice now, not a service, and `ServiceQuery` told a reader its dep was a service when
55
+ * usually it is not. `ViewQuery` below is the other shape, composing several cells.
56
+ *
57
+ * The resource exposes dodo's Cell protocol `onDirty(fn)` to learn it changed,
58
+ * `getValue()` to read it — and a query wants push. Bridging is two lines, so rather than
59
+ * write them per resource this takes the cell as a function of the leased one.
60
+ *
61
+ * It reads the CELL and nothing else. That is not a stylistic preference: `settings`' cell
62
+ * holds `effective()`, the defaults merged with the overrides, which no field anywhere
63
+ * holds. Reading the cell is what makes these queries and the shell's snapshot describe the
64
+ * same state rather than two subtly different ones.
56
65
  */
57
- class ServiceQuery extends Query {
66
+ class CellQuery extends Query {
58
67
  /**
59
68
  * @param {string} dep the engine resource this views
60
69
  * @param {(resource: object) => {onDirty: Function, getValue: Function}} cellOf
@@ -66,12 +75,15 @@ class ServiceQuery extends Query {
66
75
  * @param {string} dep the engine resource this views
67
76
  * @param {(resource: object) => {onDirty: Function, getValue: Function}} cellOf
68
77
  * @param {(value: any, resource: object) => any} [project] shape the value into a view
78
+ * @param {{bootAction?: object}} [opts] dispatched when the first observer arrives — for
79
+ * a value that has to be FETCHED before there is anything to view.
69
80
  */
70
- constructor(dep, cellOf, project) {
81
+ constructor(dep, cellOf, project, { bootAction = null } = {}) {
71
82
  super();
72
83
  this.dep = dep;
73
84
  this.cellOf = cellOf;
74
85
  this.project = project;
86
+ if (bootAction) this.bootAction = bootAction;
75
87
  }
76
88
 
77
89
  /**
@@ -141,7 +153,7 @@ function release(resources, query) {
141
153
  * answer is the difference between a component knowing what is selected and a component
142
154
  * knowing how selection resolution works.
143
155
  */
144
- export const explorer = new ServiceQuery('explorer', (r) => r.observe(), (v) => ({
156
+ export const explorer = new CellQuery('explorer', (r) => r.observe(), (v) => ({
145
157
  ...v,
146
158
  selectedNodes: selectedNodesOf(v),
147
159
  // The collection switcher's rows. In the view because it is a question about state, and
@@ -160,13 +172,13 @@ export const explorer = new ServiceQuery('explorer', (r) => r.observe(), (v) =>
160
172
  }),
161
173
  }));
162
174
  /** Query text, results, and the palette's file list. */
163
- export const search = new ServiceQuery('search', (r) => r.observe());
175
+ export const search = new CellQuery('search', (r) => r.observe());
164
176
  /** Uploads and downloads in flight. */
165
- export const transfers = new ServiceQuery('transfers', (r) => r.observe());
177
+ export const transfers = new CellQuery('transfers', (r) => r.observe());
166
178
  /** Running tasks and standing issues, both sides of the wire. */
167
- export const activity = new ServiceQuery('activity', (r) => r.observe());
179
+ export const activity = new CellQuery('activity', (r) => r.observe());
168
180
  /** Conversations, tags and backlinks for the open item. */
169
- export const social = new ServiceQuery('social', (r) => r.observe());
181
+ export const social = new CellQuery('social', (r) => r.observe());
170
182
  /**
171
183
  * Online state, pinned files, and the queue waiting to sync.
172
184
  *
@@ -174,7 +186,7 @@ export const social = new ServiceQuery('social', (r) => r.observe());
174
186
  * calling a method on the service to find out. A Set rather than a repeated scan: the
175
187
  * question is asked once per row.
176
188
  */
177
- export const offline = new ServiceQuery('offline', (r) => r.observe(),
189
+ export const offline = new CellQuery('offline', (r) => r.observe(),
178
190
  (v) => ({ ...v, pinnedIds: new Set((v?.pins || []).map((p) => p.id)) }));
179
191
  /**
180
192
  * The open collection's key, and any rotation running over it.
@@ -198,7 +210,9 @@ class RotationView extends Query {
198
210
  constructor(collectionId) {
199
211
  super();
200
212
  this.collectionId = collectionId;
201
- this.bootAction = new LoadRotationAction();
213
+ // The id is PASSED, so the load stops depending on `explorer` happening to agree with
214
+ // the query's key — which is what makes the key mean anything at all.
215
+ this.bootAction = new LoadRotationAction(collectionId);
202
216
  }
203
217
 
204
218
  boot(r, { notify }) {
@@ -206,7 +220,7 @@ class RotationView extends Query {
206
220
  notify(cell.getValue());
207
221
  const off = cell.onDirty(() => notify(cell.getValue()));
208
222
  const timer = setInterval(() => {
209
- if (r.rotation.get().rotation?.status === 'running') r.engine.dispatch(new LoadRotationAction());
223
+ if (r.rotation.get().rotation?.status === 'running') r.engine.dispatch(new LoadRotationAction(this.collectionId));
210
224
  }, 2500);
211
225
  hold(r, this, [off, () => clearInterval(timer)]);
212
226
  }
@@ -246,22 +260,33 @@ class GrantsView extends Query {
246
260
  }
247
261
  }
248
262
 
249
- /** The admin API-key list, with the unsubmitted draft resolved into what it would grant. */
250
- export const apiKeys = new ServiceQuery('apiKeys', (r) => r.observe(),
251
- (v) => ({ ...v, draftScopes: draftScopesOf(v) }));
263
+ /**
264
+ * The admin API-key list, with the unsubmitted draft resolved into what it would grant.
265
+ *
266
+ * `bootAction`, like `rotationFor` and `grantsFor`: it loads when the settings region is
267
+ * first realized and the list is admin-only, so most sessions never ask. The alternative
268
+ * was what this section used to do — dispatch from inside a render behind a module-level
269
+ * "have I asked yet" flag, which is a render with a side effect, and module state the
270
+ * engine cannot see or reset. A second `createWorkbench` on the page (a documented
271
+ * embedding entry point) found the flag already true against a fresh slice and never
272
+ * loaded at all.
273
+ */
274
+ export const apiKeys = new CellQuery('apiKeys', (r) => r.observe(),
275
+ (v) => ({ ...v, draftScopes: draftScopesOf(v) }),
276
+ { bootAction: new LoadApiKeysAction() });
252
277
 
253
278
  // --- the shell -----------------------------------------------------------------
254
279
 
255
280
  /** Which activity is showing, and the rest of the shell's own state. */
256
- export const workbench = new ServiceQuery('workbench', (r) => r.observe());
281
+ export const workbench = new CellQuery('workbench', (r) => r.observe());
257
282
  /** The tab and panel stack. */
258
- export const navigation = new ServiceQuery('navigation', (r) => r.observe());
283
+ export const navigation = new CellQuery('navigation', (r) => r.observe());
259
284
  /** Dialogs, menus and panels. */
260
- export const overlay = new ServiceQuery('overlay', (r) => r.observe());
285
+ export const overlay = new CellQuery('overlay', (r) => r.observe());
261
286
  /** Toasts. */
262
- export const notifications = new ServiceQuery('notifications', (r) => r.observe());
287
+ export const notifications = new CellQuery('notifications', (r) => r.observe());
263
288
  /** Settings, defaults merged with overrides. */
264
- export const settings = new ServiceQuery('settings', (r) => r.observe());
289
+ export const settings = new CellQuery('settings', (r) => r.observe());
265
290
  /**
266
291
  * The settings SCHEMA, grouped by category, for the screen that edits them.
267
292
  *
@@ -269,42 +294,24 @@ export const settings = new ServiceQuery('settings', (r) => r.observe());
269
294
  * values keyed by setting name — adding a `groups` key to it would put a made-up entry in
270
295
  * among the real ones.
271
296
  */
272
- export const settingsGroups = new ServiceQuery('settings', (r) => r.observe(), (_v, r) => r.grouped());
297
+ export const settingsGroups = new CellQuery('settings', (r) => r.observe(), (_v, r) => r.grouped());
273
298
 
274
299
  /** The when-clause keys: what is selected, what is open, what is focused. */
275
- export const context = new ServiceQuery('context', (r) => r.observe());
300
+ export const context = new CellQuery('context', (r) => r.observe());
276
301
  /** Phone, desktop or TV. */
277
- export const viewport = new ServiceQuery('viewport', (r) => r.observe());
302
+ export const viewport = new CellQuery('viewport', (r) => r.observe());
278
303
  /**
279
304
  * Whether this browser can transcribe on-device, and whether it is listening now.
280
305
  *
281
306
  * `canListen` is folded in: it is a question about the state, so a component should not
282
307
  * have to call the service to find out.
283
308
  */
284
- export const voice = new ServiceQuery('voice', (r) => r.observe(),
309
+ export const voice = new CellQuery('voice', (r) => r.observe(),
285
310
  (v, r) => ({ ...v, canListen: !!r.canListen?.() }));
286
311
  /** Installed plugins; null where the plugin host is not installed. */
287
- export const plugins = new ServiceQuery('plugins', (r) => r?.observe?.());
312
+ export const plugins = new CellQuery('plugins', (r) => r?.observe?.());
288
313
  /** What the UI is in the middle of doing: drafts, captures, ticked boxes. See viewState.js. */
289
- export const viewState = new ServiceQuery('viewState', (r) => r.observe());
290
-
291
- /**
292
- * Contributions of one type — status items, openers, views.
293
- *
294
- * Parameterised, so it MUST memoise: a fresh instance per render would boot a second live
295
- * realization every frame and never share one. Keyed by the type, which is the only thing
296
- * that distinguishes them.
297
- */
298
- export const contributionsOfType = (type) => ContributionsOfType.of(type);
299
-
300
- class ContributionsOfType extends ServiceQuery {
301
- static of = queryOf(ContributionsOfType);
302
-
303
- constructor(type) {
304
- super('contributions', (r) => r.observeType(type));
305
- this.type = type;
306
- }
307
- }
314
+ export const viewState = new CellQuery('viewState', (r) => r.observe());
308
315
 
309
316
  // --- view queries ---------------------------------------------------------------
310
317
  //
@@ -372,8 +379,6 @@ const registries = (r) => [
372
379
  * No `when` expression and no handler. A component shows the title, greys out what is
373
380
  * disabled, and dispatches `ExecCommandAction(id)`.
374
381
  */
375
- export const paletteCommands = new ViewQuery(REGISTRY_DEPS, registries, paletteCommandsOf);
376
-
377
382
  function paletteCommandsOf(r) {
378
383
  return r.commands.paletteCommands().map((c) => ({
379
384
  id: c.id,
@@ -381,12 +386,15 @@ function paletteCommandsOf(r) {
381
386
  category: c.category ?? null,
382
387
  icon: c.icon ?? null,
383
388
  keybinding: r.keybindings.labelFor(c.id),
384
- // Two different reasons a command might not run, and they are labelled differently in
385
- // the palette: `available` is the plugin behind it being reachable, `enabled` also
386
- // folds in the when-clause. Collapsing them would tag a command disabled by context as
387
- // "offline", which is a false explanation rather than a vague one.
389
+ // Only `available` the plugin behind this command being reachable. There was an
390
+ // `enabled` beside it, under a paragraph explaining that collapsing the two would tag a
391
+ // context-disabled command as "offline", and the code made the distinction impossible:
392
+ // `paletteCommands()` has already dropped every when-clause failure, so for every row
393
+ // that survives, `isEnabled` re-evaluates the same clause against the same registry and
394
+ // agrees with `isAvailable` by construction. Nothing rendered it. If greying a gated
395
+ // command out with a reason is wanted, do the other half — stop filtering in
396
+ // `paletteCommands()` — rather than keeping the label for a state that cannot occur.
388
397
  available: r.commands.isAvailable(c),
389
- enabled: r.commands.isEnabled(c.id),
390
398
  }));
391
399
  }
392
400
 
@@ -635,7 +643,7 @@ export const openerAssociations = new ViewQuery(REGISTRY_DEPS, registries, (r) =
635
643
  */
636
644
  export const sidecarFor = (nodeId) => Sidecar.of(nodeId);
637
645
 
638
- class Sidecar extends ServiceQuery {
646
+ class Sidecar extends CellQuery {
639
647
  static of = queryOf(Sidecar);
640
648
 
641
649
  constructor(nodeId) {
@@ -706,4 +714,4 @@ function commandKeysOf(r) {
706
714
  * PENDING while the request was in flight. Both go away when the fetch belongs to the
707
715
  * provider: the cell already holds null, and reading a cell is all this does.
708
716
  */
709
- export const capabilities = new ServiceQuery('capabilities', (r) => r);
717
+ export const capabilities = new CellQuery('capabilities', (r) => r);
@@ -51,10 +51,12 @@ export function selectedNodesOf(state) {
51
51
  * fact is how they drift.
52
52
  */
53
53
  export class TransfersService {
54
+ #state;
55
+
54
56
  /** @param {import('./activity.js').ActivityService} [activity] */
55
57
  constructor(activity = null) {
56
- this.state = { items: [] }; // { id, name, direction, ratio, loaded, total, status, error, retryable }
57
- this.cell = cell(this.state);
58
+ this.#state = { items: [] }; // { id, name, direction, ratio, loaded, total, status, error, retryable }
59
+ this.cell = cell(this.#state);
58
60
  this._controllers = new Map();
59
61
  this.activity = activity;
60
62
  this._tasks = new Map(); // transfer id -> activity task handle
@@ -67,11 +69,17 @@ export class TransfersService {
67
69
  // and the alternative is asking the user to find the file and drag it in again.
68
70
  this._retries = new Map();
69
71
  }
72
+ /** The value, for whoever is about to decide something from it. See bl/state.js: one
73
+ * value, one way to read it — a public `state` field is a second, writable door. */
74
+ get() {
75
+ return this.#state;
76
+ }
77
+
70
78
  observe() {
71
79
  return this.cell;
72
80
  }
73
81
  #emit() {
74
- this.cell.setValue(this.state);
82
+ this.cell.setValue(this.#state);
75
83
  }
76
84
  /**
77
85
  * @param {{retry?: () => Promise<any>}} [opts] how to run this transfer again, if it can be
@@ -79,8 +87,8 @@ export class TransfersService {
79
87
  start(id, name, total, controller, { retry = null } = {}) {
80
88
  this._controllers.set(id, controller);
81
89
  if (retry) this._retries.set(id, retry);
82
- this.state = {
83
- items: [...this.state.items, {
90
+ this.#state = {
91
+ items: [...this.#state.items, {
84
92
  id, name, direction: 'up', ratio: 0, loaded: 0, total, status: 'active', error: null,
85
93
  // Surfaced in state so the tray renders from a snapshot rather than interrogating
86
94
  // the service — it is a fact about the row, like `status`.
@@ -109,7 +117,7 @@ export class TransfersService {
109
117
  retry(id) {
110
118
  const again = this._retries.get(id);
111
119
  if (!again) return null;
112
- const item = this.state.items.find((t) => t.id === id);
120
+ const item = this.#state.items.find((t) => t.id === id);
113
121
  if (!item || item.status === 'active') return null;
114
122
  return again();
115
123
  }
@@ -117,9 +125,9 @@ export class TransfersService {
117
125
  /** Put an existing row back into flight — see `retry`. */
118
126
  restart(id, controller) {
119
127
  this._controllers.set(id, controller);
120
- const item = this.state.items.find((t) => t.id === id);
121
- this.state = {
122
- items: this.state.items.map((t) => (t.id === id
128
+ const item = this.#state.items.find((t) => t.id === id);
129
+ this.#state = {
130
+ items: this.#state.items.map((t) => (t.id === id
123
131
  ? { ...t, status: 'active', error: null, ratio: 0, loaded: 0 }
124
132
  : t)),
125
133
  };
@@ -130,12 +138,12 @@ export class TransfersService {
130
138
  this.#task(id, item?.name || 'file', item?.total || null);
131
139
  }
132
140
  progress(id, { loaded, total, ratio }) {
133
- this.state = { items: this.state.items.map((t) => (t.id === id ? { ...t, loaded, total, ratio } : t)) };
141
+ this.#state = { items: this.#state.items.map((t) => (t.id === id ? { ...t, loaded, total, ratio } : t)) };
134
142
  this.#emit();
135
143
  this._tasks.get(id)?.progress({ done: loaded, total: total || null });
136
144
  }
137
145
  finish(id, status = 'done', error = null) {
138
- this.state = { items: this.state.items.map((t) => (t.id === id ? { ...t, status, error, ratio: status === 'done' ? 1 : t.ratio } : t)) };
146
+ this.#state = { items: this.#state.items.map((t) => (t.id === id ? { ...t, status, error, ratio: status === 'done' ? 1 : t.ratio } : t)) };
139
147
  this._controllers.delete(id);
140
148
  this.#emit();
141
149
  const task = this._tasks.get(id);
@@ -152,17 +160,17 @@ export class TransfersService {
152
160
  this.finish(id, 'cancelled');
153
161
  }
154
162
  dismiss(id) {
155
- this.state = { items: this.state.items.filter((t) => t.id !== id) };
163
+ this.#state = { items: this.#state.items.filter((t) => t.id !== id) };
156
164
  this._retries.delete(id);
157
165
  this.#emit();
158
166
  }
159
167
  clearDone() {
160
- const kept = this.state.items.filter((t) => t.status === 'active');
168
+ const kept = this.#state.items.filter((t) => t.status === 'active');
161
169
  const keptIds = new Set(kept.map((t) => t.id));
162
170
  // Drop the retry thunks of the rows that just left, so a dismissed upload does not
163
171
  // hold its File alive for the rest of the session.
164
172
  for (const id of [...this._retries.keys()]) if (!keptIds.has(id)) this._retries.delete(id);
165
- this.state = { items: kept };
173
+ this.#state = { items: kept };
166
174
  this.#emit();
167
175
  }
168
176
  }
@@ -8,12 +8,14 @@
8
8
  import { cell } from '../runtime.js';
9
9
 
10
10
  export class SocialService {
11
+ #state;
12
+
11
13
  constructor(platform, offline = null) {
12
14
  // How a comment or tag written while disconnected reaches the server later.
13
15
  this.offline = offline;
14
16
  this.platform = platform;
15
17
  this.api = platform.api;
16
- this.state = {
18
+ this.#state = {
17
19
  me: null,
18
20
  notifications: { items: [], unread: 0 },
19
21
  inboxOpen: false,
@@ -24,16 +26,28 @@ export class SocialService {
24
26
  posting: false,
25
27
  replyTo: null, // { id, author } when composing a reply
26
28
  };
27
- this.cell = cell(this.state);
29
+ this.cell = cell(this.#state);
28
30
  this._pollTimer = null;
29
31
  }
30
32
 
33
+ /**
34
+ * The value, for whoever is about to decide something from it.
35
+ *
36
+ * The same door every slice offers. Actions read `.state` and queries read `.cell`, and
37
+ * the two are only equal by habit — bl/state.js says so, and says a resource has one
38
+ * value and one way to read it. `state` is also a public field on a service, which is one
39
+ * typo from `social.state.sidecar = null` bypassing the cell and notifying nothing.
40
+ */
41
+ get() {
42
+ return this.#state;
43
+ }
44
+
31
45
  observe() {
32
46
  return this.cell;
33
47
  }
34
48
  #set(patch) {
35
- this.state = { ...this.state, ...patch };
36
- this.cell.setValue(this.state);
49
+ this.#state = { ...this.#state, ...patch };
50
+ this.cell.setValue(this.#state);
37
51
  }
38
52
 
39
53
  async init() {
@@ -60,14 +74,14 @@ export class SocialService {
60
74
  }
61
75
 
62
76
  toggleInbox(open) {
63
- const next = open ?? !this.state.inboxOpen;
77
+ const next = open ?? !this.#state.inboxOpen;
64
78
  this.#set({ inboxOpen: next });
65
- if (next && this.state.notifications.unread) this.markAllRead();
79
+ if (next && this.#state.notifications.unread) this.markAllRead();
66
80
  }
67
81
  async markAllRead() {
68
82
  try {
69
83
  await this.api.markNotificationsRead();
70
- this.#set({ notifications: { ...this.state.notifications, unread: 0, items: this.state.notifications.items.map((i) => ({ ...i, read: true })) } });
84
+ this.#set({ notifications: { ...this.#state.notifications, unread: 0, items: this.#state.notifications.items.map((i) => ({ ...i, read: true })) } });
71
85
  } catch { /* ignore */ }
72
86
  }
73
87
 
@@ -83,10 +97,10 @@ export class SocialService {
83
97
  // same guard loadBacklinks already had. Without it, opening A then quickly opening
84
98
  // B let A resolve last, so `state.sidecar.nodeId` became A while B was on screen
85
99
  // and comment()/addTag()/removeTag() all wrote to A.
86
- if (this.state.sidecar?.nodeId !== nodeId) return;
100
+ if (this.#state.sidecar?.nodeId !== nodeId) return;
87
101
  this.#set({ sidecar: { ...view, loading: false } });
88
102
  } catch (err) {
89
- if (this.state.sidecar?.nodeId !== nodeId) return;
103
+ if (this.#state.sidecar?.nodeId !== nodeId) return;
90
104
  this.#set({ sidecar: { nodeId, loading: false, error: err.message, tags: [], comments: [] } });
91
105
  }
92
106
  }
@@ -102,26 +116,26 @@ export class SocialService {
102
116
  try {
103
117
  const res = await this.api.backlinks(nodeId);
104
118
  // A slower request for a previously-open item must not land on this one.
105
- if (this.state.backlinks?.nodeId !== nodeId) return;
119
+ if (this.#state.backlinks?.nodeId !== nodeId) return;
106
120
  this.#set({ backlinks: { nodeId, items: res.items || [], loading: false, error: null } });
107
121
  } catch (err) {
108
- if (this.state.backlinks?.nodeId !== nodeId) return;
122
+ if (this.#state.backlinks?.nodeId !== nodeId) return;
109
123
  this.#set({ backlinks: { nodeId, items: [], loading: false, error: err.message } });
110
124
  }
111
125
  }
112
126
  async #reload() {
113
- if (this.state.sidecar?.nodeId) await this.loadSidecar(this.state.sidecar.nodeId);
127
+ if (this.#state.sidecar?.nodeId) await this.loadSidecar(this.#state.sidecar.nodeId);
114
128
  }
115
129
 
116
130
  setReplyTo(target) {
117
131
  this.#set({ replyTo: target });
118
132
  }
119
133
  async comment(body) {
120
- const nodeId = this.state.sidecar?.nodeId;
134
+ const nodeId = this.#state.sidecar?.nodeId;
121
135
  if (!nodeId || !body.trim()) return;
122
- const parentId = this.state.replyTo?.id || null;
136
+ const parentId = this.#state.replyTo?.id || null;
123
137
  // Offline → queue the op; it replays (CRDT-merges) on reconnect.
124
- if (this.offline && !this.offline.state.online) {
138
+ if (this.offline && !this.offline.get().online) {
125
139
  await this.offline.queueOp({ method: 'POST', path: `/api/items/${encodeURIComponent(nodeId)}/comments`, body: { body, parentId } });
126
140
  this.#set({ replyTo: null });
127
141
  this.platform.notifications.info('Offline — your comment will post when you reconnect.');
@@ -139,14 +153,14 @@ export class SocialService {
139
153
  }
140
154
  }
141
155
  async deleteComment(cid) {
142
- const nodeId = this.state.sidecar?.nodeId;
156
+ const nodeId = this.#state.sidecar?.nodeId;
143
157
  await this.api.deleteComment(nodeId, cid).catch((e) => this.platform.notifications.error(e.message));
144
158
  await this.#reload();
145
159
  }
146
160
  async react(cid, emoji) {
147
- const nodeId = this.state.sidecar?.nodeId;
148
- const comment = findComment(this.state.sidecar?.comments || [], cid);
149
- const on = !(comment?.reactions?.[emoji] || []).includes(this.state.me?.id);
161
+ const nodeId = this.#state.sidecar?.nodeId;
162
+ const comment = findComment(this.#state.sidecar?.comments || [], cid);
163
+ const on = !(comment?.reactions?.[emoji] || []).includes(this.#state.me?.id);
150
164
  try {
151
165
  await this.api.reactComment(nodeId, cid, emoji, on);
152
166
  } catch (err) {
@@ -155,9 +169,9 @@ export class SocialService {
155
169
  await this.#reload();
156
170
  }
157
171
  async addTag(name, value) {
158
- const nodeId = this.state.sidecar?.nodeId;
172
+ const nodeId = this.#state.sidecar?.nodeId;
159
173
  if (!name.trim()) return;
160
- if (this.offline && !this.offline.state.online) {
174
+ if (this.offline && !this.offline.get().online) {
161
175
  await this.offline.queueOp({ method: 'POST', path: `/api/items/${encodeURIComponent(nodeId)}/tags`, body: { name: name.trim(), value } });
162
176
  this.platform.notifications.info('Offline — tag will sync when you reconnect.');
163
177
  return;
@@ -166,9 +180,9 @@ export class SocialService {
166
180
  await this.#reload();
167
181
  }
168
182
  async removeTag(name) {
169
- const nodeId = this.state.sidecar?.nodeId;
183
+ const nodeId = this.#state.sidecar?.nodeId;
170
184
  // Offline → queue like addTag so the removal isn't silently lost.
171
- if (this.offline && !this.offline.state.online) {
185
+ if (this.offline && !this.offline.get().online) {
172
186
  await this.offline.queueOp({ method: 'DELETE', path: `/api/items/${encodeURIComponent(nodeId)}/tags/${encodeURIComponent(name)}` });
173
187
  this.platform.notifications.info('Offline — tag removal will sync when you reconnect.');
174
188
  return;
@@ -184,7 +198,7 @@ export class SocialService {
184
198
  // --- web push --------------------------------------------------------------
185
199
 
186
200
  async #detectPush() {
187
- if (!this.state.pushSupported) return;
201
+ if (!this.#state.pushSupported) return;
188
202
  try {
189
203
  const reg = await navigator.serviceWorker.getRegistration();
190
204
  const sub = await reg?.pushManager?.getSubscription();
@@ -193,7 +207,7 @@ export class SocialService {
193
207
  }
194
208
 
195
209
  async enablePush() {
196
- if (!this.state.pushSupported) {
210
+ if (!this.#state.pushSupported) {
197
211
  this.platform.notifications.warn('Push notifications are not supported in this browser.');
198
212
  return;
199
213
  }
@@ -27,8 +27,9 @@ import { cell } from '../runtime.js';
27
27
  * One named piece of the drive's state.
28
28
  *
29
29
  * `set` merges, because every caller was already merging and doing it here removes the
30
- * chance of a caller replacing a slice by forgetting to spread it. `replace` exists for
31
- * the cases that genuinely mean "all of it" a fresh panel stack, an emptied list.
30
+ * chance of a caller replacing a slice by forgetting to spread it. There is no `replace`:
31
+ * one existed for "the cases that genuinely mean all of it" and no caller ever turned out
32
+ * to mean that, so a slice has exactly one way to be written.
32
33
  *
33
34
  * @param {object} initial
34
35
  */
@@ -41,8 +42,6 @@ export function slice(initial = {}) {
41
42
  get: () => held.getValue(),
42
43
  /** Merge a patch in. A new object every time — a cell compares with Object.is. */
43
44
  set: (patch) => held.setValue({ ...held.getValue(), ...patch }),
44
- /** Replace it outright, for when a patch would be a lie. */
45
- replace: (next) => held.setValue(next),
46
45
  };
47
46
  }
48
47
 
@@ -57,12 +56,21 @@ export const explorerState = (settings) => slice({
57
56
  // `stats` is the whole collection; `items` is the page on screen. Keeping both is what
58
57
  // lets the UI say "500 of 3,006" instead of quietly claiming 500.
59
58
  stats: null, usage: null, nextCursor: null, loadingMore: false, trash: null,
59
+ // The selected NODES, not just their ids — the primary read path in `selectedNodesOf`,
60
+ // with `items` as the fallback for when a selection was made somewhere `items` covers.
61
+ // That function exists to end the class of silent bug where rename/trash/copy-link
62
+ // returned quietly while `hasSelection` said there was something to act on.
63
+ selectionNodes: null,
60
64
  });
61
65
 
62
66
  /** Query text, results, and the palette's separate file list. */
63
67
  export const searchState = () => slice({
64
68
  query: '', mode: 'hybrid', results: [], loading: false, error: null, ran: false,
65
69
  paletteFiles: [], paletteQuery: '', paletteLoading: false, paletteError: null,
70
+ // `filtered` is a tag/type filter rather than a query; `offline` says the results came
71
+ // from the local pinned index; `resolved` is the query the results on screen are FOR,
72
+ // and it is what `pickView` reads to decide whether to show them at all.
73
+ filtered: false, offline: false, resolved: null,
66
74
  });
67
75
 
68
76
  /**
@@ -78,6 +86,10 @@ export const searchState = () => slice({
78
86
  */
79
87
  export const apiKeysState = () => slice({
80
88
  keys: [], loading: false, loaded: false, error: null, minted: null, busy: null, draft: null,
89
+ // `forbidden` is not `error`. A non-admin's 403 is the correct answer to this request, so
90
+ // the section is simply absent for them; anything else is a failure worth reporting, with
91
+ // a way to try again. Conflating the two rendered a blank space for both.
92
+ forbidden: false,
81
93
  });
82
94
 
83
95
  /**
@@ -106,6 +118,12 @@ export const overlayState = () => slice({
106
118
  dialog: null,
107
119
  contextMenu: null,
108
120
  pluginPanel: null,
121
+ // The activity panel. It lived inside ActivityService's state, beside `tasks` and
122
+ // `issues` — the one field there with no justification while its neighbours had five
123
+ // lines of it — which made CloseOverlaysAction lease a task/issue poller in order to
124
+ // close a panel, and broke form on the sixth rung of an Escape ladder whose other five
125
+ // go through here.
126
+ activityPanel: false,
109
127
  });
110
128
 
111
129
  /**
@@ -42,6 +42,15 @@ export function draftFor(slice, key, ref, fallback) {
42
42
  /** Where a prompt dialog's typed value lives while it is being typed. */
43
43
  export const PROMPT = 'promptValue';
44
44
 
45
+ /**
46
+ * Where the plugin review's ticked capabilities live.
47
+ *
48
+ * Named here rather than privately in the component, because the action that performs the
49
+ * install READS it — the dialog is data, so what the user ticked has to be state the action
50
+ * can find rather than something a callback closes over.
51
+ */
52
+ export const PLUGIN_REVIEW = 'pluginReview';
53
+
45
54
  /**
46
55
  * What has been typed into the open prompt, or its initial value.
47
56
  *
@@ -59,14 +59,7 @@ export function watchQuery(engine, queryInstance) {
59
59
  }
60
60
  let cell = forEngine.get(queryInstance);
61
61
  if (!cell) {
62
- // A query may declare what it means before it knows — `initial = null` for something
63
- // fetched over the network, say. Without one the cell is PENDING, and PENDING reaching
64
- // a `watch` renders its placeholder: fine for a region that can show an empty bar for a
65
- // frame, wrong for a snapshot feeding the whole shell, which would blank until the
66
- // request came back. The initial belongs to the query rather than to this call, so it
67
- // cannot differ between two call sites reading the same thing.
68
- const initial = 'initial' in queryInstance ? queryInstance.initial : PENDING;
69
- cell = fromObservable(engine.query(queryInstance), { initial });
62
+ cell = fromObservable(engine.query(queryInstance), { initial: PENDING });
70
63
  forEngine.set(queryInstance, cell);
71
64
  }
72
65
  return cell;