@3sln/trove 0.0.7 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/package.json +1 -1
  2. package/packages/core/src/collections/index.js +146 -1
  3. package/packages/core/src/encryption/envelope.js +483 -0
  4. package/packages/core/src/encryption/exposure.js +101 -0
  5. package/packages/core/src/encryption/keys.js +88 -0
  6. package/packages/core/src/encryption/policy.js +112 -0
  7. package/packages/core/src/encryption/rotation.js +432 -0
  8. package/packages/core/src/index.js +17 -1
  9. package/packages/core/src/links.js +85 -0
  10. package/packages/core/src/metadata/memory.js +3 -1
  11. package/packages/core/src/metadata/sqlite.js +27 -7
  12. package/packages/core/src/scan.js +35 -1
  13. package/packages/core/src/storage/cost.js +228 -0
  14. package/packages/core/src/uploads.js +302 -19
  15. package/packages/core/src/vfs.js +161 -10
  16. package/packages/server/src/adapters/staticAssets.js +14 -5
  17. package/packages/server/src/engine/index.js +1 -1
  18. package/packages/server/src/engine/providers/core.js +21 -2
  19. package/packages/server/src/index.js +45 -2
  20. package/packages/server/src/routes.js +62 -1
  21. package/packages/web/dist/assets/main-jg5vmp8f.js +356 -0
  22. package/packages/web/dist/assets/main-jg5vmp8f.js.map +118 -0
  23. package/packages/web/dist/assets/styles-e5gk19rn.css +1 -0
  24. package/packages/web/dist/index.html +9 -3
  25. package/packages/web/dist/sw.js +1 -1
  26. package/packages/web/src/bl/actions.js +1471 -138
  27. package/packages/web/src/bl/commands.js +103 -284
  28. package/packages/web/src/bl/context.js +69 -0
  29. package/packages/web/src/bl/index.js +136 -40
  30. package/packages/web/src/bl/intern.js +129 -0
  31. package/packages/web/src/bl/launcher.js +251 -0
  32. package/packages/web/src/bl/links.js +1 -1
  33. package/packages/web/src/bl/match.js +71 -0
  34. package/packages/web/src/bl/mentions.js +39 -0
  35. package/packages/web/src/bl/offline.js +0 -4
  36. package/packages/web/src/bl/openers.js +65 -33
  37. package/packages/web/src/bl/pluginInstall.js +42 -21
  38. package/packages/web/src/bl/queries.js +669 -0
  39. package/packages/web/src/bl/services.js +141 -145
  40. package/packages/web/src/bl/social.js +3 -1
  41. package/packages/web/src/bl/state.js +144 -0
  42. package/packages/web/src/bl/status.js +96 -0
  43. package/packages/web/src/bl/trust.js +109 -0
  44. package/packages/web/src/bl/viewState.js +60 -0
  45. package/packages/web/src/bl/views.js +64 -0
  46. package/packages/web/src/bl/watchQuery.js +73 -0
  47. package/packages/web/src/platform/api.js +63 -11
  48. package/packages/web/src/platform/commands.js +49 -9
  49. package/packages/web/src/platform/context.js +146 -51
  50. package/packages/web/src/platform/index.js +27 -20
  51. package/packages/web/src/platform/navigation.js +34 -4
  52. package/packages/web/src/platform/pickers.js +46 -0
  53. package/packages/web/src/platform/pluginHost.js +43 -10
  54. package/packages/web/src/platform/pluginRpc.js +6 -2
  55. package/packages/web/src/platform/spatialNav.js +0 -2
  56. package/packages/web/src/platform/viewport.js +1 -6
  57. package/packages/web/src/platform/voiceSearch.js +1 -3
  58. package/packages/web/src/platform/whenclause.js +22 -3
  59. package/packages/web/src/styles.css +77 -4
  60. package/packages/web/src/ui/activate.js +22 -0
  61. package/packages/web/src/ui/components/activityBar.js +7 -3
  62. package/packages/web/src/ui/components/activityPanel.js +10 -11
  63. package/packages/web/src/ui/components/adminView.js +191 -0
  64. package/packages/web/src/ui/components/collectionGate.js +4 -3
  65. package/packages/web/src/ui/components/commandPalette.js +24 -55
  66. package/packages/web/src/ui/components/editorArea.js +16 -13
  67. package/packages/web/src/ui/components/launcher.js +39 -225
  68. package/packages/web/src/ui/components/openers/index.js +7 -4
  69. package/packages/web/src/ui/components/openers/markdown.js +5 -3
  70. package/packages/web/src/ui/components/overlays.js +169 -43
  71. package/packages/web/src/ui/components/phoneChrome.js +35 -33
  72. package/packages/web/src/ui/components/pluginReview.js +62 -23
  73. package/packages/web/src/ui/components/pluginsView.js +30 -28
  74. package/packages/web/src/ui/components/settingsView.js +151 -58
  75. package/packages/web/src/ui/components/social.js +42 -62
  76. package/packages/web/src/ui/components/statusBar.js +30 -74
  77. package/packages/web/src/ui/components/views/grid.js +3 -2
  78. package/packages/web/src/ui/components/views/index.js +7 -62
  79. package/packages/web/src/ui/components/views/list.js +3 -2
  80. package/packages/web/src/ui/components/views/parts.js +15 -4
  81. package/packages/web/src/ui/compositions/workbench.js +166 -58
  82. package/packages/web/src/ui/region.js +53 -0
  83. package/packages/web/src/workbench.js +17 -15
  84. package/packages/web/dist/assets/main-f0f2tfhp.js +0 -356
  85. package/packages/web/dist/assets/main-f0f2tfhp.js.map +0 -104
  86. package/packages/web/dist/assets/styles-d3cyysgp.css +0 -1
  87. package/packages/web/src/platform/overlay.js +0 -81
  88. package/packages/web/src/platform/workbench.js +0 -156
@@ -1,154 +1,42 @@
1
- // Reactive data services the workbench renders from. These hold *data* state
2
- // (the current collection's items, search results, in-flight transfers) as
3
- // opposed to the shell's UI state (WorkbenchService). ngin Actions mutate them;
4
- // the UI `watch`es them. Each is a plain cell wrapper so the render layer stays
5
- // declarative.
1
+ // What is left of the data services, plus the derivations that were methods on them.
2
+ //
3
+ // The explorer, search and API-key services were state bags a `state`, a `cell`, and a
4
+ // `set` that wrote both and are slices now (bl/state.js). TransfersService stays,
5
+ // because it holds things a state snapshot cannot: AbortControllers, per-file retry
6
+ // thunks closing over the File itself, and a projection into the activity list.
7
+ //
8
+ // The functions below were METHODS on those bags. They are derivations, so they belong to
9
+ // whoever is asking rather than to whatever happens to hold the data.
6
10
 
7
11
  import { cell } from '../runtime.js';
8
12
 
9
- export class ExplorerService {
10
- constructor(settings) {
11
- this.settings = settings;
12
- this.state = {
13
- items: [], loading: false, error: null,
14
- selection: [], sort: settings.get('explorer.sort'), order: settings.get('explorer.sortOrder'),
15
- // No collection until one is chosen or created. `gate` is 'create' | 'choose' | null
16
- // — when set, it is the ONLY thing the workbench shows, because every request needs
17
- // a collection and there is nothing sensible to render without one.
18
- collectionId: null, collections: [], canCreateCollection: false, gate: null,
19
- // `stats` is the whole collection; `items` is the page on screen. Keeping both
20
- // is what lets the UI say "500 of 3,006" instead of quietly claiming 500.
21
- stats: null, usage: null, nextCursor: null, loadingMore: false, trash: null,
22
- };
23
- this.cell = cell(this.state);
24
- }
25
- observe() {
26
- return this.cell;
27
- }
28
- set(patch) {
29
- this.state = { ...this.state, ...patch };
30
- this.cell.setValue(this.state);
31
- }
32
- /**
33
- * @param {string[]} ids
34
- * @param {{additive?: boolean, nodes?: object[]}} [opts] `nodes` is the caller's own
35
- * copy of what it selected — pass it whenever you have it (see selectedNodes).
36
- */
37
- select(ids, { additive = false, nodes = null } = {}) {
38
- const next = additive ? Array.from(new Set([...this.state.selection, ...ids])) : ids;
39
- // Selecting what is already selected must not emit. The launcher syncs the
40
- // highlighted row into here on every mouseenter, and a state push per mouse move
41
- // would re-render the list under the pointer.
42
- const same = next.length === this.state.selection.length
43
- && next.every((id, i) => id === this.state.selection[i]);
44
- if (same) return;
45
- this.set({ selection: next, selectionNodes: nodes && !additive ? nodes : null });
46
- }
47
- /**
48
- * The nodes the selection refers to.
49
- *
50
- * Resolving ids against `items` only finds rows on the LOADED PAGE of the CURRENT
51
- * collection — and the launcher's rows come from search (which the server scopes to
52
- * every readable collection) and from recents (which survive a collection switch).
53
- * So every row reached by searching resolved to nothing, and rename / move-to-trash /
54
- * copy-link returned silently while `explorer.hasSelection` said there was a
55
- * selection. Preferring the nodes the selecting caller already held fixes the whole
56
- * class; the `items` lookup stays for callers that only have ids.
57
- */
58
- selectedNodes() {
59
- const held = this.state.selectionNodes;
60
- if (held?.length) return held.filter((n) => this.state.selection.includes(n.id));
61
- return this.state.items.filter((i) => this.state.selection.includes(i.id));
62
- }
63
- }
64
13
 
65
14
  /**
66
- * API keys, for the admin screen that manages them.
15
+ * The nodes the selection refers to a pure function of explorer state.
67
16
  *
68
- * Its own service rather than a corner of ExplorerService because it is a different
69
- * lifetime: keys are read when someone opens Settings and never again, so they should not
70
- * be part of the state every render of the file list walks over.
17
+ * It was a method, which meant every caller reached for the service to ask a question about
18
+ * data it could already see. As a function it can be used two ways without either being a
19
+ * back door: the explorer QUERY folds it into the view, so the UI is handed the answer; and
20
+ * an effect that needs it right now computes it from the state it already holds.
71
21
  *
72
- * `minted` holds the one secret a mint returns. It lives here, in memory, and is dropped
73
- * the moment the admin dismisses it — the server cannot show it again, so the UI is the
74
- * only place it ever exists, and it should not persist anywhere that outlives the tab.
22
+ * Resolving ids against `items` only finds rows on the LOADED PAGE of the CURRENT
23
+ * collection and the launcher's rows come from search (which the server scopes to every
24
+ * readable collection) and from recents (which survive a collection switch). So every row
25
+ * reached by searching resolved to nothing, and rename / move-to-trash / copy-link returned
26
+ * silently while `explorer.hasSelection` said there was a selection. Preferring the nodes
27
+ * the selecting caller already held fixes the whole class; the `items` lookup stays for
28
+ * callers that only have ids.
75
29
  */
76
- export class ApiKeysService {
77
- constructor() {
78
- this.state = {
79
- keys: [], loading: false, loaded: false, error: null, minted: null, busy: null,
80
- // The mint form. Held here rather than in the DOM so the section stays a pure
81
- // function of state and so a half-filled form survives a re-render caused by
82
- // something else on the settings screen.
83
- draft: null,
84
- };
85
- this.cell = cell(this.state);
86
- }
87
-
88
- /** Open the mint form, empty. */
89
- startDraft() {
90
- this.set({ draft: { name: '', expiresInDays: '', caps: {} }, error: null });
91
- }
92
- cancelDraft() {
93
- this.set({ draft: null });
94
- }
95
- patchDraft(patch) {
96
- if (!this.state.draft) return;
97
- this.set({ draft: { ...this.state.draft, ...patch } });
98
- }
99
-
100
- /**
101
- * Toggle one capability on one collection in the draft.
102
- *
103
- * `admin` is not treated specially here — it is offered as itself and the server
104
- * expands it. Pre-ticking read/write/delete when admin is chosen would suggest they
105
- * are separable afterwards, and they are not.
106
- */
107
- toggleCap(collectionId, capability) {
108
- if (!this.state.draft) return;
109
- const caps = { ...this.state.draft.caps };
110
- const held = new Set(caps[collectionId] || []);
111
- if (held.has(capability)) held.delete(capability);
112
- else held.add(capability);
113
- if (held.size) caps[collectionId] = [...held];
114
- else delete caps[collectionId];
115
- this.patchDraft({ caps });
116
- }
117
-
118
- /** The draft as the API wants it, or null when it would grant nothing. */
119
- draftScopes() {
120
- const caps = this.state.draft?.caps || {};
121
- const scopes = Object.entries(caps)
122
- .filter(([, list]) => list.length)
123
- .map(([collectionId, capabilities]) => ({ collectionId, capabilities }));
124
- return scopes.length ? scopes : null;
125
- }
126
- observe() {
127
- return this.cell;
128
- }
129
- set(patch) {
130
- this.state = { ...this.state, ...patch };
131
- this.cell.setValue(this.state);
132
- }
133
- /** Forget the freshly minted secret. Called on dismiss, and after a copy. */
134
- clearMinted() {
135
- if (this.state.minted) this.set({ minted: null });
136
- }
30
+ export function selectedNodesOf(state) {
31
+ // Tolerant of a partial state: a view is computed on every emission, including the first
32
+ // one, and a projection that throws takes the whole query down rather than the field.
33
+ const selection = state?.selection || [];
34
+ const held = state?.selectionNodes;
35
+ if (held?.length) return held.filter((n) => selection.includes(n.id));
36
+ return (state?.items || []).filter((i) => selection.includes(i.id));
137
37
  }
138
38
 
139
- export class SearchClientService {
140
- constructor() {
141
- this.state = { query: '', mode: 'hybrid', results: [], loading: false, error: null, ran: false, paletteFiles: [], paletteQuery: '', paletteLoading: false, paletteError: null };
142
- this.cell = cell(this.state);
143
- }
144
- observe() {
145
- return this.cell;
146
- }
147
- set(patch) {
148
- this.state = { ...this.state, ...patch };
149
- this.cell.setValue(this.state);
150
- }
151
- }
39
+
152
40
 
153
41
  /**
154
42
  * Uploads in flight.
@@ -165,11 +53,19 @@ export class SearchClientService {
165
53
  export class TransfersService {
166
54
  /** @param {import('./activity.js').ActivityService} [activity] */
167
55
  constructor(activity = null) {
168
- this.state = { items: [] }; // { id, name, direction, ratio, loaded, total, status, error }
56
+ this.state = { items: [] }; // { id, name, direction, ratio, loaded, total, status, error, retryable }
169
57
  this.cell = cell(this.state);
170
58
  this._controllers = new Map();
171
59
  this.activity = activity;
172
60
  this._tasks = new Map(); // transfer id -> activity task handle
61
+ // How to run this transfer again. Held here rather than in state because it closes
62
+ // over the File itself, which is not something a state snapshot should carry.
63
+ //
64
+ // It exists because automatic retry cannot cover everything: a lost upload session is
65
+ // `notFound`, which is correctly classified non-retryable and will never succeed on
66
+ // its own no matter how many times it is tried. Someone has to decide to start over,
67
+ // and the alternative is asking the user to find the file and drag it in again.
68
+ this._retries = new Map();
173
69
  }
174
70
  observe() {
175
71
  return this.cell;
@@ -177,16 +73,62 @@ export class TransfersService {
177
73
  #emit() {
178
74
  this.cell.setValue(this.state);
179
75
  }
180
- start(id, name, total, controller) {
76
+ /**
77
+ * @param {{retry?: () => Promise<any>}} [opts] how to run this transfer again, if it can be
78
+ */
79
+ start(id, name, total, controller, { retry = null } = {}) {
181
80
  this._controllers.set(id, controller);
182
- this.state = { items: [...this.state.items, { id, name, direction: 'up', ratio: 0, loaded: 0, total, status: 'active', error: null }] };
81
+ if (retry) this._retries.set(id, retry);
82
+ this.state = {
83
+ items: [...this.state.items, {
84
+ id, name, direction: 'up', ratio: 0, loaded: 0, total, status: 'active', error: null,
85
+ // Surfaced in state so the tray renders from a snapshot rather than interrogating
86
+ // the service — it is a fact about the row, like `status`.
87
+ retryable: !!retry,
88
+ }],
89
+ };
183
90
  this.#emit();
91
+ this.#task(id, name, total);
92
+ }
93
+
94
+ #task(id, name, total) {
184
95
  const task = this.activity?.start({
185
96
  kind: 'transfer', title: `Uploading ${name}`, total: total || null, unit: 'bytes',
186
97
  onCancel: () => this.cancel(id),
187
98
  });
188
99
  if (task) this._tasks.set(id, task);
189
100
  }
101
+
102
+ /**
103
+ * Run a failed transfer again, in place.
104
+ *
105
+ * The same row rather than a new one: a retry is another attempt at the thing the user
106
+ * already asked for, and a tray that grew an entry per attempt would report one upload
107
+ * as four.
108
+ */
109
+ retry(id) {
110
+ const again = this._retries.get(id);
111
+ if (!again) return null;
112
+ const item = this.state.items.find((t) => t.id === id);
113
+ if (!item || item.status === 'active') return null;
114
+ return again();
115
+ }
116
+
117
+ /** Put an existing row back into flight — see `retry`. */
118
+ restart(id, controller) {
119
+ 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
123
+ ? { ...t, status: 'active', error: null, ratio: 0, loaded: 0 }
124
+ : t)),
125
+ };
126
+ this.#emit();
127
+ // A fresh activity task: the previous one already ended as failed, and reporting
128
+ // progress into a finished task would leave the panel showing a failure that is
129
+ // actively being retried.
130
+ this.#task(id, item?.name || 'file', item?.total || null);
131
+ }
190
132
  progress(id, { loaded, total, ratio }) {
191
133
  this.state = { items: this.state.items.map((t) => (t.id === id ? { ...t, loaded, total, ratio } : t)) };
192
134
  this.#emit();
@@ -211,10 +153,64 @@ export class TransfersService {
211
153
  }
212
154
  dismiss(id) {
213
155
  this.state = { items: this.state.items.filter((t) => t.id !== id) };
156
+ this._retries.delete(id);
214
157
  this.#emit();
215
158
  }
216
159
  clearDone() {
217
- this.state = { items: this.state.items.filter((t) => t.status === 'active') };
160
+ const kept = this.state.items.filter((t) => t.status === 'active');
161
+ const keptIds = new Set(kept.map((t) => t.id));
162
+ // Drop the retry thunks of the rows that just left, so a dismissed upload does not
163
+ // hold its File alive for the rest of the session.
164
+ for (const id of [...this._retries.keys()]) if (!keptIds.has(id)) this._retries.delete(id);
165
+ this.state = { items: kept };
218
166
  this.#emit();
219
167
  }
220
168
  }
169
+
170
+
171
+ /**
172
+ * The API-key draft as the API wants it, or null when it would grant nothing.
173
+ *
174
+ * A pure function rather than a method, for the same reason as `selectedNodesOf`: it is a
175
+ * derivation, so it belongs to the view that shows it and to the action that submits it,
176
+ * not to the resource that happens to hold the draft.
177
+ */
178
+ export function draftScopesOf(state) {
179
+ const caps = state?.draft?.caps || {};
180
+ const scopes = Object.entries(caps)
181
+ .filter(([, list]) => list.length)
182
+ .map(([collectionId, capabilities]) => ({ collectionId, capabilities }));
183
+ return scopes.length ? scopes : null;
184
+ }
185
+
186
+
187
+ /**
188
+ * The collection switcher's menu, derived from what the explorer knows.
189
+ *
190
+ * A pure function for the same reason as `selectedNodesOf`: it answers a question about
191
+ * state, so it belongs to the view that shows it. It used to be a closure hung on `app` by
192
+ * `registerCommands` — reachable only through that one field, and impossible to see from
193
+ * the component that rendered it.
194
+ *
195
+ * Items carry `actions`, like every other menu item; see ui/activate.js.
196
+ *
197
+ * @param {object} state explorer state
198
+ * @param {(id?: string) => object} switchTo builds the action for picking a collection
199
+ * @param {() => object} create builds the action for making a new one
200
+ */
201
+ export function collectionMenuOf(state, switchTo, create) {
202
+ // No fallback: this only decides which row gets a tick, and with nothing open the answer
203
+ // is that none of them do. `|| 'default'` ticked a collection the user had not chosen,
204
+ // and on a drive with one actually called "default", the wrong one.
205
+ const current = state?.collectionId;
206
+ const items = (state?.collections || []).map((c) => ({
207
+ label: c.name || c.id,
208
+ icon: c.id === current ? 'check' : 'files',
209
+ actions: [switchTo(c.id)],
210
+ }));
211
+ if (state?.canCreateCollection) {
212
+ if (items.length) items.push({ sep: true });
213
+ items.push({ label: 'New collection…', icon: 'plus', actions: [create()] });
214
+ }
215
+ return items;
216
+ }
@@ -8,7 +8,9 @@
8
8
  import { cell } from '../runtime.js';
9
9
 
10
10
  export class SocialService {
11
- constructor(platform) {
11
+ constructor(platform, offline = null) {
12
+ // How a comment or tag written while disconnected reaches the server later.
13
+ this.offline = offline;
12
14
  this.platform = platform;
13
15
  this.api = platform.api;
14
16
  this.state = {
@@ -0,0 +1,144 @@
1
+ // The drive's own state, as named slices.
2
+ //
3
+ // These were classes: an `ExplorerService`, a `SearchClientService`, an `ApiKeysService`,
4
+ // each holding a `state` field, a `cell`, and a `set` that wrote both. Nothing else. A
5
+ // service should cover something complex and offer it through a narrow door — these
6
+ // covered an object.
7
+ //
8
+ // Two things came of that shape and both are gone with it.
9
+ //
10
+ // TWO DOORS. Actions read `.state` and queries read `.cell`, and the two are only equal by
11
+ // habit: most services wrote `this.state = {...}; this.cell.setValue(this.state)`, but not
12
+ // all did, and `settings` still has a cell holding `effective()` and no `state` field at
13
+ // all. A slice has one value and one way to read it.
14
+ //
15
+ // LOGIC IN THE HOLDER. `toggleCap` computed a capability set inside the service while an
16
+ // action stood in front of it forwarding two arguments — and dropped one of them, which is
17
+ // how a minted key came to be rejected by the server. Arithmetic over state belongs to the
18
+ // action that decides to change it; the slice only holds.
19
+ //
20
+ // Each slice is still its OWN provider, so `static deps = ['explorer']` keeps meaning what
21
+ // it says. A single `appState` covering everything would put the whole drive back behind
22
+ // one lease, which is what naming the resources individually was for.
23
+
24
+ import { cell } from '../runtime.js';
25
+
26
+ /**
27
+ * One named piece of the drive's state.
28
+ *
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.
32
+ *
33
+ * @param {object} initial
34
+ */
35
+ export function slice(initial = {}) {
36
+ const held = cell(initial);
37
+ return {
38
+ /** The cell, for a query to watch. */
39
+ observe: () => held,
40
+ /** The value, for an action about to decide something from it. */
41
+ get: () => held.getValue(),
42
+ /** Merge a patch in. A new object every time — a cell compares with Object.is. */
43
+ 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
+ };
47
+ }
48
+
49
+ /** Items, selection, the open collection, and the gate. */
50
+ export const explorerState = (settings) => slice({
51
+ items: [], loading: false, error: null,
52
+ selection: [], sort: settings.get('explorer.sort'), order: settings.get('explorer.sortOrder'),
53
+ // No collection until one is chosen or created. `gate` is 'create' | 'choose' | null —
54
+ // when set, it is the ONLY thing the workbench shows, because every request needs a
55
+ // collection and there is nothing sensible to render without one.
56
+ collectionId: null, collections: [], canCreateCollection: false, gate: null,
57
+ // `stats` is the whole collection; `items` is the page on screen. Keeping both is what
58
+ // lets the UI say "500 of 3,006" instead of quietly claiming 500.
59
+ stats: null, usage: null, nextCursor: null, loadingMore: false, trash: null,
60
+ });
61
+
62
+ /** Query text, results, and the palette's separate file list. */
63
+ export const searchState = () => slice({
64
+ query: '', mode: 'hybrid', results: [], loading: false, error: null, ran: false,
65
+ paletteFiles: [], paletteQuery: '', paletteLoading: false, paletteError: null,
66
+ });
67
+
68
+ /**
69
+ * The admin API-key list.
70
+ *
71
+ * `minted` holds the one secret a mint returns. It lives here, in memory, and is dropped
72
+ * the moment the admin dismisses it — the server cannot show it again, so the UI is the
73
+ * only place it ever exists and it must not outlive the tab.
74
+ *
75
+ * `draft` is the mint form. Held in state rather than in the DOM so the section stays a
76
+ * pure function of state, and so a half-filled form survives a re-render caused by
77
+ * something else on the settings screen.
78
+ */
79
+ export const apiKeysState = () => slice({
80
+ keys: [], loading: false, loaded: false, error: null, minted: null, busy: null, draft: null,
81
+ });
82
+
83
+ /**
84
+ * What the UI is in the middle of doing — a keybinding mid-capture, text typed into a
85
+ * dialog that has not been submitted, the boxes ticked in a plugin review.
86
+ *
87
+ * None of it is drive state; all of it decides what is on screen, which is what makes it
88
+ * engine state. Keyed by component, so `set` here replaces one key rather than merging a
89
+ * patch — see SetViewStateAction.
90
+ */
91
+ export const viewState = () => slice({});
92
+
93
+ /**
94
+ * The shell's transient overlays: the command palette, a modal dialog, the right-click
95
+ * menu, and the plugin popup panel.
96
+ *
97
+ * Four things that are independent of the panel stack and of each other, which is why they
98
+ * were split out of the workbench in the first place. What they are NOT is complex enough
99
+ * to need a service — the class was four setters and a `wrapIndex`, with an action standing
100
+ * in front of each one. The wrapping now lives in the action that moves a cursor, which is
101
+ * where it was needed: MovePaletteAction lost the count argument once already, precisely
102
+ * because deciding and writing were on opposite sides of a forwarding layer.
103
+ */
104
+ export const overlayState = () => slice({
105
+ palette: null, // { mode: 'commands'|'files', query, index } when open
106
+ dialog: null,
107
+ contextMenu: null,
108
+ pluginPanel: null,
109
+ });
110
+
111
+ /**
112
+ * The shell itself: which activity is showing, and the launcher's own cursor.
113
+ *
114
+ * The panel stack is NOT here — that is NavigationService, which stays a service because it
115
+ * mirrors the stack into browser history and persists recents, neither of which a state bag
116
+ * can do.
117
+ */
118
+ export const workbenchState = () => slice({
119
+ activity: 'home', // home (stack) | plugins | settings
120
+ sidebarVisible: true,
121
+ launch: { query: '', index: 0 },
122
+ searchModal: false, // the double-shift modal search overlay
123
+ // Phone chrome: which bottom sheet is up, if any ('status' | 'more'). A phone has no room
124
+ // for a permanent status bar or a left rail, so both fold into a sheet pulled up on demand.
125
+ sheet: null,
126
+ infoPanel: false,
127
+ });
128
+
129
+ /** Wrap a list cursor by `delta` within `[0, count)` — the palette and the launcher share it. */
130
+ export function wrapIndex(index, delta, count) {
131
+ return (index + delta + count) % count;
132
+ }
133
+
134
+ /**
135
+ * What the open collection's key is doing.
136
+ *
137
+ * Rotation is long-running and lives on the server, so this is a local view of it kept in
138
+ * step by polling while the settings screen is open — see `RotationQuery`. It is state
139
+ * rather than a fetch-per-render because two things read it (the progress line and whether
140
+ * Start is offered) and they must not disagree.
141
+ */
142
+ export const rotationState = () => slice({
143
+ collectionId: null, rotation: null, estimate: null, loading: false, error: null, busy: false,
144
+ });
@@ -0,0 +1,96 @@
1
+ // What the drive is doing and how full it is — one answer, for every surface that says so.
2
+ //
3
+ // This lived in the status bar and was imported from there by the phone chrome, which is
4
+ // how it was already admitting to being shared: two shells rendering the same facts in
5
+ // different shapes. It is a derivation over four resources, so it belongs here, and it is
6
+ // reached as the `statusFacts` query rather than recomputed per render.
7
+ //
8
+ // The reason it has to be derived ONCE rather than twice is on the record: the phone bar
9
+ // rendered `collectionId` raw and showed an opaque `col_…` where the desktop bar showed the
10
+ // collection's name — the same fact, said two ways, because each surface worked it out.
11
+
12
+ /**
13
+ * The facts every shell reports.
14
+ *
15
+ * @param {{ex: object, tr: object, act: object, off: object}} slices
16
+ */
17
+ export function statusFactsOf({ ex = {}, tr = {}, act = {}, off = {} } = {}) {
18
+ const items = ex.items || [];
19
+ const tasks = act.tasks || [];
20
+ return {
21
+ // `null` with nothing open — `collectionLabelOf` renders that as "no collection". The
22
+ // old fallback made the bar name a collection that may not exist, on a drive where the
23
+ // user had not yet chosen one.
24
+ collectionId: ex.collectionId ?? null,
25
+ // What to CALL it, so the phone shell and the desktop bar cannot end up saying
26
+ // different things.
27
+ collectionLabel: collectionLabelOf(ex),
28
+ // The COLLECTION's totals when the server could give them, not the page's. Summing what
29
+ // happens to be loaded reports a 3,000-file drive as 500 files — a wrong number, not a
30
+ // rounded one. Falls back to the page only when the server didn't say.
31
+ totalItems: ex.stats?.items ?? items.length,
32
+ // Whether that total is the COLLECTION's or just the page we happen to hold. With more
33
+ // pages waiting, the page length is a floor, not a total, and must read as one.
34
+ totalKnown: ex.stats?.items != null,
35
+ totalBytes: ex.stats?.bytes ?? items.reduce((n, i) => n + (i.size || 0), 0),
36
+ shown: items.length,
37
+ partial: !!ex.nextCursor,
38
+ usage: ex.usage ?? null,
39
+ usageLevel: usageLevelOf(ex.usage),
40
+ uploading: (tr.items || []).filter((t) => t.status === 'active'),
41
+ running: tasks.filter((t) => t.status === 'running'),
42
+ issues: act.issues || [],
43
+ off: { online: true, pins: [], queued: 0, syncing: false, ...off },
44
+ };
45
+ }
46
+
47
+ /** The open collection's name, or the plainest true thing when there isn't one. */
48
+ export function collectionLabelOf(ex) {
49
+ if (!ex?.collectionId) return 'no collection';
50
+ const match = (ex.collections || []).find((c) => c.id === ex.collectionId);
51
+ return match?.name || ex.collectionId;
52
+ }
53
+
54
+ /**
55
+ * How worried to be about space, or '' when there is nothing to say.
56
+ *
57
+ * A filesystem or NAS can answer exactly, and that is where it matters: a disk fills up and
58
+ * every upload starts failing with no warning that anything was coming. An object store has
59
+ * no equivalent number, so this answers '' and the meter is not drawn at all rather than
60
+ * being drawn meaninglessly.
61
+ *
62
+ * The thresholds are a judgement about when someone still has time to act, which is why
63
+ * they are here and not in whichever bar happens to be on screen.
64
+ */
65
+ export function usageLevelOf(usage) {
66
+ if (!usage?.total) return '';
67
+ const free = usage.available / usage.total;
68
+ return free < 0.05 ? 'critical' : free < 0.1 ? 'low' : '';
69
+ }
70
+
71
+ /**
72
+ * The one thing worth saying about the drive right now, most urgent first.
73
+ *
74
+ * The phone has room for a single glyph, so something has to decide what outranks what:
75
+ * being offline beats a standing problem, a standing problem beats work in progress, and
76
+ * anything beats running low on space. That ordering is a claim about which condition a
77
+ * person most needs to know, which makes it a decision rather than a rendering.
78
+ *
79
+ * Answers a KIND, not an icon. Which glyph draws a kind is the shell's business.
80
+ */
81
+ export function driveConditionOf(facts) {
82
+ const f = facts || {};
83
+ if (!f.off?.online) return { kind: 'offline', label: 'Offline' };
84
+ if (f.issues?.length) {
85
+ return {
86
+ kind: 'issues',
87
+ label: `${f.issues.length} need${f.issues.length === 1 ? 's' : ''} attention`,
88
+ count: f.issues.length,
89
+ };
90
+ }
91
+ if (f.running?.length || f.uploading?.length || f.off?.syncing) {
92
+ return { kind: 'working', label: 'Working…', count: (f.running?.length || 0) + (f.uploading?.length || 0) };
93
+ }
94
+ if (f.usageLevel) return { kind: 'lowSpace', label: 'Low on space' };
95
+ return { kind: 'idle', label: 'Status' };
96
+ }