@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
@@ -17,6 +17,6 @@
17
17
  </head>
18
18
  <body>
19
19
  <div class="workbench"></div>
20
- <script type="module" src="/assets/main-c9dnnnc6.js"></script>
20
+ <script type="module" src="/assets/main-828yzsr7.js"></script>
21
21
  </body>
22
22
  </html>
@@ -18,7 +18,7 @@
18
18
  // API and FILES deliberately do NOT rotate. API is data, not code, and FILES holds the
19
19
  // bytes of files the user pinned for offline use — naming it per build would throw away
20
20
  // someone's offline library every time the app was redeployed.
21
- const SHELL = 'trove-shell-10c4c8fa57';
21
+ const SHELL = 'trove-shell-fbe8c37315';
22
22
  const API = 'trove-api-v1';
23
23
  const FILES = 'trove-files-v1';
24
24
  const KEEP = new Set([SHELL, API, FILES]);
@@ -5,7 +5,8 @@
5
5
  // they update flow straight back into the UI via `watch`.
6
6
 
7
7
  import { Action } from '@3sln/ngin';
8
- import { PROMPT } from './viewState.js';
8
+ import { runAction } from '../dispatch.js';
9
+ import { PROMPT, PLUGIN_REVIEW } from './viewState.js';
9
10
  import { beginInstallFromUrl } from './pluginInstall.js';
10
11
  import { newId } from '@3sln/trove/core/util.js';
11
12
  import { matchesTagFilters } from './tagQuery.js';
@@ -59,7 +60,7 @@ export class NavigateAction extends Action {
59
60
  nextCursor: res.nextCursor || null,
60
61
  });
61
62
  // Remember where they were, so the next visit opens there rather than guessing.
62
- settings.set?.('explorer.lastCollection', collectionId);
63
+ settings.set('explorer.lastCollection', collectionId);
63
64
  explorer.set({ gate: null });
64
65
  // Explorer→context projection (collectionId/hasSelection) lives in bl/index.js
65
66
  // so it stays in sync with selection too — nothing to mirror here.
@@ -186,7 +187,9 @@ export class OpenInitialCollectionAction extends Action {
186
187
  }
187
188
 
188
189
  explorer.set({ gate: null });
189
- await engine.dispatch(new NavigateAction(shared.collection));
190
+ // Sequenced, so the panel this opens below lands on top of the collection rather than
191
+ // racing its load — see src/dispatch.js for why a bare `await` here did nothing.
192
+ await runAction(engine, new NavigateAction(shared.collection));
190
193
 
191
194
  let node;
192
195
  try {
@@ -275,7 +278,10 @@ export class CreateCollectionAction extends Action {
275
278
  try {
276
279
  const res = await r.api.createCollection(this.record);
277
280
  r.notifications.success(`Created collection “${res.collection.name}”`);
278
- await r.engine.dispatch(new LoadCollectionsAction());
281
+ // Sequenced: `NavigateAction` writes `collectionId` synchronously while the load
282
+ // writes `collections` after a round trip, so navigating first left the status bar's
283
+ // `collectionLabelOf` with nothing to look the id up in — it showed a raw `col_…`.
284
+ await runAction(r.engine, new LoadCollectionsAction());
279
285
  r.engine.dispatch(new NavigateAction(res.collection.id));
280
286
  } catch (err) {
281
287
  r.notifications.error(`Couldn’t create collection: ${err.message}`);
@@ -348,7 +354,7 @@ export class TrashAction extends Action {
348
354
  const { items } = await api.trash(collection);
349
355
  explorer.set({ trash: items });
350
356
  // Restoring puts something back in the drive, so the list on screen is now stale.
351
- if (this.op !== 'list') await r.engine.dispatch(new NavigateAction(collection));
357
+ if (this.op !== 'list') r.engine.dispatch(new NavigateAction(collection));
352
358
  } catch (err) {
353
359
  notifications.error(`Trash: ${err.message}`);
354
360
  }
@@ -518,7 +524,7 @@ export class FilterAction extends Action {
518
524
  return;
519
525
  }
520
526
  search.set({ query: this.text, loading: true, error: null, filtered: true });
521
- if (r.offline && !r.offline.state.online) {
527
+ if (r.offline && !r.offline.get().online) {
522
528
  const items = (r.explorer.get().items || []).filter((n) => matchesTagFilters(n, this.filters));
523
529
  search.set({ results: items.map((node) => ({ node })), loading: false, ran: true, filtered: true, offline: true });
524
530
  return;
@@ -583,14 +589,23 @@ export class SearchAction extends Action {
583
589
  return;
584
590
  }
585
591
  const mode = this.mode || settings.get('search.mode');
586
- search.set({ query: this.query, mode, loading: true, error: null, resolved: null });
592
+ search.set({ query: this.query, mode, loading: true, error: null, resolved: null, filtered: false });
593
+ // The query itself is the sequence token, the same guard QuickOpenAction uses and for
594
+ // the same reason: keystrokes outrun the network, and a slower request for an earlier
595
+ // query must not land on top of a newer one's results. It matters most for the query
596
+ // that was CLEARED — pressing Escape inside the debounce window used to let
597
+ // `SearchAction('contract')` land after the clear, leaving "Searching for contract"
598
+ // over the home list.
599
+ const superseded = () => search.get().query !== this.query;
587
600
  const offline = r.offline;
588
601
  // Offline (or server unreachable) → search the pinned corpus locally.
589
- if (offline && !offline.state.online) {
602
+ if (offline && !offline.get().online) {
590
603
  try {
591
604
  const results = await offline.searchOffline(q, { limit: 40 });
605
+ if (superseded()) return;
592
606
  search.set({ results, loading: false, ran: true, offline: true, resolved: null });
593
607
  } catch (err) {
608
+ if (superseded()) return;
594
609
  search.set({ results: [], loading: false, ran: true, offline: true, error: err.message });
595
610
  }
596
611
  return;
@@ -605,10 +620,13 @@ export class SearchAction extends Action {
605
620
  // content types. It can only name a view from this list.
606
621
  const views = contributions.ofType('view').map((v) => ({ id: v.id, title: v.title || v.id }));
607
622
  const res = await api.query(q, { mode, limit: 40, views });
623
+ if (superseded()) return;
608
624
  search.set({ results: res.results, resolved: res.resolved || null, loading: false, ran: true, offline: false });
609
625
  } catch (err) {
626
+ if (superseded()) return;
610
627
  if (offline) {
611
628
  const results = await offline.searchOffline(q, { limit: 40 });
629
+ if (superseded()) return;
612
630
  search.set({ results, loading: false, ran: true, offline: true, error: results.length ? null : err.message });
613
631
  } else {
614
632
  search.set({ loading: false, error: err.message, ran: true });
@@ -622,9 +640,14 @@ export class SearchAction extends Action {
622
640
  /**
623
641
  * Load the key list.
624
642
  *
625
- * Silent on failure rather than noisy: this fires when Settings opens, and a non-admin
626
- * opening Settings gets a 403 that is the correct answer, not an error worth a toast.
627
- * The section simply does not render.
643
+ * Silent on REFUSAL rather than noisy: this fires when Settings opens, and a non-admin
644
+ * opening Settings gets a 403 that is the correct answer, not an error worth a toast. That
645
+ * is `loaded` with an empty list — the section simply is not part of their settings screen.
646
+ *
647
+ * Any other failure sets `error` and leaves `loaded` alone, because they are different
648
+ * facts. Setting both meant one transient failure rendered the section blank with no
649
+ * message and no way back: `loaded` was true so the "not yet" branch passed, and the empty
650
+ * list was indistinguishable from a drive with no keys.
628
651
  */
629
652
  export class LoadApiKeysAction extends Action {
630
653
  static deps = ['api', 'apiKeys'];
@@ -635,7 +658,11 @@ export class LoadApiKeysAction extends Action {
635
658
  const res = await r.api.apiKeys();
636
659
  r.apiKeys.set({ keys: res.keys || [], loading: false, loaded: true, error: null });
637
660
  } catch (err) {
638
- r.apiKeys.set({ loading: false, loaded: true, error: err?.message || 'Could not load API keys' });
661
+ if (err?.status === 403 || err?.code === 'forbidden') {
662
+ r.apiKeys.set({ keys: [], loading: false, loaded: true, error: null, forbidden: true });
663
+ return;
664
+ }
665
+ r.apiKeys.set({ loading: false, error: err?.message || 'Could not load API keys' });
639
666
  }
640
667
  }
641
668
  }
@@ -752,21 +779,6 @@ export class CopyTextAction extends Action {
752
779
  }
753
780
  }
754
781
 
755
- /** Say something to the user. `kind` is one of info, success, warn, error. */
756
- export class NotifyAction extends Action {
757
- static deps = ['notifications'];
758
- constructor(kind, message, opts) {
759
- super();
760
- this.kind = kind;
761
- this.message = message;
762
- this.opts = opts;
763
- }
764
-
765
- async execute({ notifications: notes }) {
766
- (notes[this.kind] ?? notes.info).call(notes, this.message, this.opts);
767
- }
768
- }
769
-
770
782
  /** Dismiss one notification, by id. */
771
783
  export class DismissNotificationAction extends Action {
772
784
  static deps = ['notifications'];
@@ -791,11 +803,11 @@ export class DismissNotificationAction extends Action {
791
803
  // discriminated blob would make every one of them read as "ShellAction" and give back
792
804
  // exactly the observability this is for.
793
805
  //
794
- // NOT here yet, deliberately: `showDialog` and `showContextMenu`. Both take callbacks
795
- // a dialog carries `onConfirm`, a menu carries an item's `run`so converting them
796
- // mechanically would post a closure through the engine and call it an action. They want
797
- // the outcome to be an ACTION TO DISPATCH rather than a function to call, which is a real
798
- // change at each call site rather than a swap. See docs/tickets/009.
806
+ // `showDialog` and `showContextMenu` are here too, and the reason they arrived last is the
807
+ // reason they are worth having: both used to take callbacksa dialog carried `onConfirm`,
808
+ // a menu carried an item's `run` so converting them mechanically would have posted a
809
+ // closure through the engine and called it an action. Every outcome is an ACTION TO
810
+ // DISPATCH now, which is why nothing in a dialog spec or a menu item is callable.
799
811
 
800
812
  class ShellAction extends Action {
801
813
  static deps = ['workbench'];
@@ -967,9 +979,22 @@ class ActivityAction extends Action {
967
979
  static deps = ['activity'];
968
980
  async execute(r) { this.apply(r.activity); }
969
981
  }
970
- export class ToggleActivityPanelAction extends ActivityAction {
982
+ /**
983
+ * Show or hide the activity panel.
984
+ *
985
+ * The FLAG is overlay state like every other transient surface; the REFRESH is the
986
+ * poller's. Opening has to refresh because polling backs off to 60s when nothing is
987
+ * running, so a panel opened on an idle drive would otherwise show a list up to a minute
988
+ * stale — which is exactly the moment someone is looking to find out what just happened.
989
+ */
990
+ export class ToggleActivityPanelAction extends Action {
991
+ static deps = ['activity', 'overlay'];
971
992
  constructor(which) { super(); this.which = which; }
972
- apply(s) { s.togglePanel(this.which); }
993
+ async execute({ activity, overlay }) {
994
+ const open = this.which ?? !overlay.get().activityPanel;
995
+ overlay.set({ activityPanel: open });
996
+ if (open) await activity.refresh();
997
+ }
973
998
  }
974
999
  export class CancelTaskAction extends ActivityAction {
975
1000
  constructor(id) { super(); this.id = id; }
@@ -1100,37 +1125,6 @@ export class ToggleApiKeyCapAction extends ApiKeysAction {
1100
1125
  }
1101
1126
  }
1102
1127
 
1103
- /**
1104
- * Select items in the explorer.
1105
- *
1106
- * `opts` is `{ additive, nodes }`, matching the service — NOT a boolean. It was written as
1107
- * `additive` first, which worked at the one call site only because the options object passed
1108
- * through positionally unchanged. `new SelectItemsAction(ids, true)` would have destructured
1109
- * a boolean into `{additive = false, nodes = null}` and quietly not been additive at all.
1110
- *
1111
- * `nodes` matters when the selection cannot be resolved from the loaded page: the launcher's
1112
- * rows come from search, which is scoped across collections, so the node has to travel with
1113
- * the id.
1114
- */
1115
- export class SelectItemsAction extends Action {
1116
- static deps = ['explorer'];
1117
-
1118
- constructor(ids, opts = {}) { super(); this.ids = ids; this.opts = opts; }
1119
-
1120
- async execute({ explorer }) {
1121
- const { additive = false, nodes = null } = this.opts;
1122
- const current = explorer.get().selection;
1123
- const next = additive ? Array.from(new Set([...current, ...this.ids])) : this.ids;
1124
- // Selecting what is already selected must not emit. The launcher syncs the highlighted
1125
- // row into here on every mouseenter, and a state push per mouse move would re-render
1126
- // the list under the pointer. The rule lives with the mutation now rather than inside
1127
- // the resource, so the resource only holds and only `set` writes.
1128
- const same = next.length === current.length && next.every((id, i) => id === current[i]);
1129
- if (same) return;
1130
- explorer.set({ selection: next, selectionNodes: nodes && !additive ? nodes : null });
1131
- }
1132
- }
1133
-
1134
1128
  /**
1135
1129
  * Move the launcher's highlight and sync the selection to whatever it landed on.
1136
1130
  *
@@ -1166,7 +1160,19 @@ export class SelectLaunchAction extends Action {
1166
1160
  }
1167
1161
  }
1168
1162
 
1169
- /** Select exactly one node, or nothing. Carries the node itself — see SelectItemsAction. */
1163
+ /**
1164
+ * Select exactly one node, or nothing.
1165
+ *
1166
+ * `selectionNodes` carries the NODE and not just the id, because the selection often cannot
1167
+ * be resolved from the loaded page: the launcher's rows come from search, which is scoped
1168
+ * across collections, so the node has to travel with the id or `selectedNodesOf` finds
1169
+ * nothing and rename/trash/copy-link return quietly while `hasSelection` disagrees.
1170
+ *
1171
+ * Selecting what is already selected must not emit. The launcher syncs the highlighted row
1172
+ * into here on every mouseenter, and a state push per mouse move would re-render the list
1173
+ * under the pointer. The rule lives with the mutation rather than inside the resource, so
1174
+ * the resource only holds and only `set` writes.
1175
+ */
1170
1176
  function selectNode(explorer, node) {
1171
1177
  const ids = node?.id ? [node.id] : [];
1172
1178
  const current = explorer.get().selection;
@@ -1196,8 +1202,26 @@ export class SetViewStateAction extends Action {
1196
1202
  * the feed. See ui/activate.js.
1197
1203
  */
1198
1204
  export class ShowContextMenuAction extends OverlayAction {
1199
- constructor(x, y, items) { super(); this.x = x; this.y = y; this.items = items; }
1200
- apply(o) { o.set({ contextMenu: { x: this.x, y: this.y, items: this.items } }); }
1205
+ /**
1206
+ * @param {Array} items
1207
+ * @param {{x?: number, y?: number, rect?: DOMRect, prefer?: 'up'|'down'}|null} [at]
1208
+ * A POINT (a right-click) or a rect to hang off (a button), not a resolved position.
1209
+ * Callers used to resolve it themselves — `r.top - 8 - items.length * 34`, a row height
1210
+ * hardcoded in two files that had to agree with the CSS and did not have to agree with
1211
+ * the overlay's clamp, which compensated by a different amount. Only the overlay can
1212
+ * measure, so only the overlay decides. Omit `at` for a keyboard-invoked menu.
1213
+ */
1214
+ constructor(items, at = null) {
1215
+ super();
1216
+ this.items = items;
1217
+ // Flattened to plain numbers: a DOMRect is a live object with methods, and this lands
1218
+ // in engine state, where nothing is callable.
1219
+ this.at = at?.rect
1220
+ ? { prefer: at.prefer || 'down', rect: { left: at.rect.left, top: at.rect.top, right: at.rect.right, bottom: at.rect.bottom } }
1221
+ : (at ? { x: at.x, y: at.y } : null);
1222
+ }
1223
+
1224
+ apply(o) { o.set({ contextMenu: { items: this.items, at: this.at } }); }
1201
1225
  }
1202
1226
 
1203
1227
  /**
@@ -1279,22 +1303,30 @@ export class SetPluginSecretAction extends Action {
1279
1303
  * lost access to. Either way the click must say so rather than do nothing.
1280
1304
  */
1281
1305
  export class OpenNotificationTargetAction extends Action {
1282
- static deps = ['api', 'context', 'contributions', 'engine', 'explorer', 'notifications', 'plugins', 'settings', 'workbench'];
1306
+ // Five of these used to exist only to feed a hand-call of `OpenFileAction.execute(r)` —
1307
+ // the one `.execute(` in the package — and one of them, `explorer`, fed nothing at all.
1308
+ // The coupling was invisible: a dep added to OpenFileAction broke THIS action at runtime
1309
+ // with an `undefined`, the same failure mode as the missing `overlay` above. Dispatching
1310
+ // instead also puts the open on the feed, which it never was: opening from a notification
1311
+ // and opening from a row click are the same intent and the engine should see both.
1312
+ static deps = ['api', 'engine', 'notifications', 'workbench'];
1283
1313
 
1284
1314
  constructor(nodeId) { super(); this.nodeId = nodeId; }
1285
1315
 
1286
- async execute(r) {
1316
+ async execute({ api, engine, notifications, workbench }) {
1287
1317
  let node;
1288
1318
  try {
1289
- ({ node } = await r.api.stat(this.nodeId));
1319
+ ({ node } = await api.stat(this.nodeId));
1290
1320
  } catch (err) {
1291
- r.notifications.warn(err?.status === 403 || err?.code === 'forbidden'
1321
+ notifications.warn(err?.status === 403 || err?.code === 'forbidden'
1292
1322
  ? 'You no longer have access to that item.'
1293
1323
  : 'That item no longer exists.');
1294
1324
  return;
1295
1325
  }
1296
- await new OpenFileAction(node).execute(r);
1297
- r.workbench.set({ infoPanel: true });
1326
+ // `.next([...])`, because `dispatch` returns a feed and the panel must open AFTER the
1327
+ // file does — see platform/commands.js, which names this trap.
1328
+ await runAction(engine, new OpenFileAction(node));
1329
+ workbench.set({ infoPanel: true });
1298
1330
  }
1299
1331
  }
1300
1332
 
@@ -1321,7 +1353,42 @@ export class RenamePromptAction extends PromptAction {
1321
1353
  static deps = ['engine', 'overlay', 'viewState'];
1322
1354
  constructor(node) { super(); this.node = node; }
1323
1355
  async withValue(name, { engine }) {
1324
- if (name !== this.node.name) await engine.dispatch(new RenameAction(this.node.id, name));
1356
+ if (name !== this.node.name) engine.dispatch(new RenameAction(this.node.id, name));
1357
+ }
1358
+ }
1359
+
1360
+ /**
1361
+ * Install the package the review dialog is about, with the capabilities that were ticked.
1362
+ *
1363
+ * The dialog used to carry an `onInstall` closure that did all of this, which put an effect
1364
+ * inside engine state — and ran it after the dispatching action's lease had been released,
1365
+ * with nothing of it on the feed. Two docblocks assert that a dialog spec holds no
1366
+ * functions; this is what makes that true.
1367
+ *
1368
+ * `pkg` and `trust` ride on the instance because they are what the review WAS; the grants
1369
+ * are read from viewState, because they are what the user did to it while it was open.
1370
+ */
1371
+ export class InstallReviewedPluginAction extends Action {
1372
+ static deps = ['notifications', 'overlay', 'plugins', 'viewState', 'workbench'];
1373
+
1374
+ constructor(pkg, trust, label) { super(); this.pkg = pkg; this.trust = trust; this.label = label; }
1375
+
1376
+ async execute({ notifications, overlay, plugins, viewState, workbench }) {
1377
+ const grants = [...(viewState.get()[PLUGIN_REVIEW]?.grants || [])];
1378
+ overlay.set({ dialog: null });
1379
+ // Account installs upload the package and run a handshake that can take a while —
1380
+ // switch to the plugins view (which shows the plugin's loading state) and hold a sticky
1381
+ // "Installing…" toast so the user isn't left staring at nothing.
1382
+ workbench.set({ activity: 'plugins', sidebarVisible: true });
1383
+ const pending = notifications.info(`Installing “${this.label}”…`, { sticky: true });
1384
+ try {
1385
+ await plugins.install(this.pkg, { grants, trust: this.trust });
1386
+ notifications.dismiss(pending);
1387
+ notifications.success(`Installed “${this.label}”`);
1388
+ } catch (err) {
1389
+ notifications.dismiss(pending);
1390
+ notifications.error(`Install failed: ${err.message}`);
1391
+ }
1325
1392
  }
1326
1393
  }
1327
1394
 
@@ -1344,7 +1411,7 @@ export class CreateCollectionFromFormAction extends Action {
1344
1411
  constructor(record) { super(); this.record = record; }
1345
1412
  async execute({ engine, overlay }) {
1346
1413
  overlay.set({ dialog: null });
1347
- if (this.record) await engine.dispatch(new CreateCollectionAction(this.record));
1414
+ if (this.record) engine.dispatch(new CreateCollectionAction(this.record));
1348
1415
  }
1349
1416
  }
1350
1417
 
@@ -1431,8 +1498,8 @@ export class SetActivityAction extends ShellAction {
1431
1498
  * once, here, rather than distributed over whoever owns each overlay.
1432
1499
  */
1433
1500
  export class CloseOverlaysAction extends Action {
1434
- static deps = ['activity', 'navigation', 'overlay', 'workbench'];
1435
- async execute({ activity, navigation, overlay, workbench }) {
1501
+ static deps = ['navigation', 'overlay', 'workbench'];
1502
+ async execute({ navigation, overlay, workbench }) {
1436
1503
  const o = overlay.get();
1437
1504
  const wb = workbench.get();
1438
1505
  if (o.contextMenu) return overlay.set({ contextMenu: null });
@@ -1446,9 +1513,9 @@ export class CloseOverlaysAction extends Action {
1446
1513
  // plugin panel deliberately: it opens BY ITSELF when a storage check finishes, and
1447
1514
  // Escape should not close what you opened on purpose in order to dismiss what
1448
1515
  // appeared on its own.
1449
- if (activity.state?.open) return activity.togglePanel(false);
1516
+ if (o.activityPanel) return overlay.set({ activityPanel: false });
1450
1517
  // Nothing floating: Escape pops the top viewer panel instead.
1451
- if (navigation.state.stack.length > 1) navigation.back();
1518
+ if (navigation.get().stack.length > 1) navigation.back();
1452
1519
  }
1453
1520
  }
1454
1521
 
@@ -1479,8 +1546,20 @@ export class VoiceSearchAction extends Action {
1479
1546
 
1480
1547
  /** Reindex the whole drive. Reports as a task rather than blocking — it takes minutes. */
1481
1548
  export class RebuildIndexAction extends Action {
1482
- static deps = ['activity'];
1483
- async execute({ activity }) { return activity.rebuildIndex().catch(() => {}); }
1549
+ static deps = ['activity', 'api', 'engine', 'notifications'];
1550
+ async execute({ activity, api, engine, notifications }) {
1551
+ try {
1552
+ const res = await api.reindex();
1553
+ await activity.refreshTasks();
1554
+ // "…and show the user" belongs to the action that decided to do the work, not to
1555
+ // the service that holds the list. Three network verbs used to open the panel as a
1556
+ // side effect of a fetch, from inside a resource.
1557
+ engine.dispatch(new ToggleActivityPanelAction(true));
1558
+ if (res.alreadyRunning) notifications.info('A rebuild is already running');
1559
+ } catch (err) {
1560
+ notifications.error(`Couldn't rebuild the index: ${err.message}`);
1561
+ }
1562
+ }
1484
1563
  }
1485
1564
 
1486
1565
  /**
@@ -1491,11 +1570,19 @@ export class RebuildIndexAction extends Action {
1491
1570
  * a scan error.
1492
1571
  */
1493
1572
  export class ScanCollectionAction extends Action {
1494
- static deps = ['activity', 'explorer', 'notifications'];
1495
- async execute({ activity, explorer, notifications }) {
1573
+ static deps = ['activity', 'api', 'engine', 'explorer', 'notifications'];
1574
+ async execute({ activity, api, engine, explorer, notifications }) {
1496
1575
  const id = explorer.get().collectionId;
1497
1576
  if (!id) return notifications.info('Open a collection first — a scan is per collection.');
1498
- return activity.scanCollection(id).catch(() => {});
1577
+ try {
1578
+ const res = await api.scanCollection(id);
1579
+ await activity.refreshTasks();
1580
+ engine.dispatch(new ToggleActivityPanelAction(true));
1581
+ if (res.alreadyRunning) notifications.info('A scan of this collection is already running');
1582
+ activity.followUp();
1583
+ } catch (err) {
1584
+ notifications.error(`Couldn't scan “${id}”: ${err.message}`);
1585
+ }
1499
1586
  }
1500
1587
  }
1501
1588
 
@@ -1506,8 +1593,28 @@ export class ScanCollectionAction extends Action {
1506
1593
  * from here — the failure that makes every file open to a spinner.
1507
1594
  */
1508
1595
  export class CheckStorageAction extends Action {
1509
- static deps = ['activity'];
1510
- async execute({ activity }) { return activity.checkStorage().catch(() => {}); }
1596
+ static deps = ['activity', 'api', 'engine', 'notifications'];
1597
+ async execute({ activity, api, engine, notifications }) {
1598
+ try {
1599
+ const res = await api.checkStorage();
1600
+ await activity.refresh();
1601
+ engine.dispatch(new ToggleActivityPanelAction(true));
1602
+ const problems = (res.results || []).reduce((n, r) => n + (r.findings?.length || 0), 0);
1603
+ if (problems) {
1604
+ notifications.warn(`Found ${problems} storage problem${problems === 1 ? '' : 's'} — see Activity`);
1605
+ } else if (!res.checked) {
1606
+ notifications.info('There are no collections to check yet');
1607
+ } else if (!res.corsChecked) {
1608
+ // Honest about what was not checked. Reporting "all good" having skipped the check
1609
+ // that matters is how a diagnostic stops being believed.
1610
+ notifications.info('Stores are reachable. Browser access was not checked — this drive has no public URL configured.');
1611
+ } else {
1612
+ notifications.success('Stores are reachable and allow browser access');
1613
+ }
1614
+ } catch (err) {
1615
+ notifications.error(`Couldn't check the stores: ${err.message}`);
1616
+ }
1617
+ }
1511
1618
  }
1512
1619
 
1513
1620
  /** Ask for files, then upload them into the open collection. */
@@ -1515,7 +1622,10 @@ export class PickAndUploadAction extends Action {
1515
1622
  static deps = ['engine', 'explorer'];
1516
1623
  async execute({ engine, explorer }) {
1517
1624
  const collection = explorer.get().collectionId;
1518
- pickFiles((files) => files.length && engine.dispatch(new UploadFilesAction(files, collection)));
1625
+ // Awaited, so the feed's `complete` means the upload started rather than the dialog
1626
+ // opened — and so the leases below it are still held when the upload uses them.
1627
+ const files = await pickFiles();
1628
+ if (files?.length) await runAction(engine, new UploadFilesAction(files, collection));
1519
1629
  }
1520
1630
  }
1521
1631
 
@@ -1637,18 +1747,14 @@ export class PinAction extends Action {
1637
1747
 
1638
1748
  // --- API keys (admin) ------------------------------------------------------------
1639
1749
 
1640
- class ApiKeyDraftAction extends Action {
1641
- static deps = ['apiKeys'];
1642
- async execute({ apiKeys }) { this.apply(apiKeys); }
1643
- }
1644
- export class StartApiKeyDraftAction extends ApiKeyDraftAction {
1750
+ export class StartApiKeyDraftAction extends ApiKeysAction {
1645
1751
  apply(s) { s.set({ draft: { name: '', expiresInDays: '', caps: {} }, error: null }); }
1646
1752
  }
1647
- export class CancelApiKeyDraftAction extends ApiKeyDraftAction {
1753
+ export class CancelApiKeyDraftAction extends ApiKeysAction {
1648
1754
  apply(s) { s.set({ draft: null }); }
1649
1755
  }
1650
1756
  /** Forget the freshly minted secret. On dismiss, and after a copy. */
1651
- export class ClearMintedApiKeyAction extends ApiKeyDraftAction {
1757
+ export class ClearMintedApiKeyAction extends ApiKeysAction {
1652
1758
  apply(s) { if (s.get().minted) s.set({ minted: null }); }
1653
1759
  }
1654
1760
 
@@ -1696,8 +1802,10 @@ export class SwitchCollectionAction extends Action {
1696
1802
  (id) => new ExecCommandAction('collections.switch', id),
1697
1803
  () => new ExecCommandAction('collections.create'));
1698
1804
  if (!items.length) return notifications.info('This drive has one collection.');
1699
- const w = typeof window === 'undefined' ? 800 : window.innerWidth;
1700
- engine.dispatch(new ShowContextMenuAction(Math.max(12, Math.round(w / 2) - 110), 120, items));
1805
+ // No geometry: this comes from a command, not from a click, so the overlay centres it.
1806
+ // The arithmetic that used to be here needed `window` and carried a `typeof window`
1807
+ // guard precisely because layout has no business in the bl layer.
1808
+ engine.dispatch(new ShowContextMenuAction(items));
1701
1809
  }
1702
1810
  }
1703
1811
 
@@ -1722,9 +1830,10 @@ export class ToggleDetailsAction extends Action {
1722
1830
 
1723
1831
  /** Ask for a .zip and take it through the install review. */
1724
1832
  export class PickAndInstallPluginAction extends Action {
1725
- static deps = ['notifications', 'plugins', 'social', 'workbench'];
1833
+ static deps = ['notifications', 'overlay', 'plugins', 'social', 'workbench'];
1726
1834
  async execute(r) {
1727
- pickZip((file) => file && beginInstallFromFile(r, file));
1835
+ const file = await pickZip();
1836
+ if (file) await beginInstallFromFile(r, file);
1728
1837
  }
1729
1838
  }
1730
1839
 
@@ -1760,7 +1869,7 @@ export class ClearSidecarAction extends Action {
1760
1869
  static deps = ['social'];
1761
1870
  constructor(nodeId) { super(); this.nodeId = nodeId; }
1762
1871
  async execute({ social }) {
1763
- if (social.state.sidecar?.nodeId === this.nodeId) social.loadSidecar(null);
1872
+ if (social.get().sidecar?.nodeId === this.nodeId) social.loadSidecar(null);
1764
1873
  }
1765
1874
  }
1766
1875
 
@@ -1826,17 +1935,30 @@ export class SetGrantAction extends Action {
1826
1935
  notifications.error?.(err?.message || 'Could not change access');
1827
1936
  } finally {
1828
1937
  acl.set({ busy: false });
1829
- await engine.dispatch(new LoadGrantsAction(this.collectionId)).next(['complete', 'error', 'abort']);
1938
+ await runAction(engine, new LoadGrantsAction(this.collectionId));
1830
1939
  }
1831
1940
  }
1832
1941
  }
1833
1942
 
1834
1943
  export class LoadRotationAction extends Action {
1835
1944
  static deps = ['api', 'explorer', 'notifications', 'rotation'];
1945
+
1946
+ /** @param {string} [collectionId] which collection; defaults to the open one */
1947
+ constructor(collectionId = null) { super(); this.collectionId = collectionId; }
1948
+
1836
1949
  async execute({ api, explorer, notifications, rotation }) {
1837
- const collectionId = explorer.get().collectionId;
1950
+ const collectionId = this.collectionId || explorer.get().collectionId;
1838
1951
  if (!collectionId) return;
1839
- rotation.set({ collectionId, loading: true, error: null });
1952
+ // CLEARED when the collection changes, not merely re-stamped. `rotation` is one slice
1953
+ // for whichever collection the settings screen is looking at, and stamping the new id
1954
+ // while leaving the previous collection's `rotation` and `estimate` in place meant a
1955
+ // failed load — which sets only `{loading, error}` — left collection A's running
1956
+ // progress on screen under B's fingerprint, indefinitely, with a Start/Stop button
1957
+ // beside it.
1958
+ const switching = rotation.get().collectionId !== collectionId;
1959
+ rotation.set(switching
1960
+ ? { collectionId, rotation: null, estimate: null, loading: true, error: null }
1961
+ : { collectionId, loading: true, error: null });
1840
1962
  try {
1841
1963
  // Both together: the estimate is meaningless without knowing whether one is already
1842
1964
  // running, and a screen showing one without the other invites starting a second.
@@ -1868,7 +1990,7 @@ export class BeginRotationAction extends Action {
1868
1990
  // fingerprint on screen is already out of date. Reload the collections or it keeps
1869
1991
  // showing the key the collection is moving OFF, which is the one thing this field
1870
1992
  // exists to get right.
1871
- await engine.dispatch(new LoadCollectionsAction());
1993
+ engine.dispatch(new LoadCollectionsAction());
1872
1994
  notifications.info('Key rotation started. It runs in the background and survives a reload.');
1873
1995
  } catch (err) {
1874
1996
  rotation.set({ busy: false });