@3sln/trove 0.0.8 → 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 (78) hide show
  1. package/package.json +1 -1
  2. package/packages/core/src/encryption/envelope.js +46 -7
  3. package/packages/core/src/encryption/rotation.js +187 -68
  4. package/packages/core/src/uploads.js +130 -29
  5. package/packages/core/src/vfs.js +13 -1
  6. package/packages/server/src/adapters/staticAssets.js +14 -5
  7. package/packages/server/src/engine/index.js +1 -1
  8. package/packages/server/src/engine/providers/core.js +12 -0
  9. package/packages/server/src/index.js +45 -2
  10. package/packages/server/src/routes.js +59 -26
  11. package/packages/web/dist/assets/main-jg5vmp8f.js +356 -0
  12. package/packages/web/dist/assets/main-jg5vmp8f.js.map +118 -0
  13. package/packages/web/dist/assets/styles-e5gk19rn.css +1 -0
  14. package/packages/web/dist/index.html +2 -2
  15. package/packages/web/dist/sw.js +1 -1
  16. package/packages/web/src/bl/actions.js +1454 -137
  17. package/packages/web/src/bl/commands.js +103 -284
  18. package/packages/web/src/bl/context.js +69 -0
  19. package/packages/web/src/bl/index.js +136 -40
  20. package/packages/web/src/bl/intern.js +129 -0
  21. package/packages/web/src/bl/launcher.js +251 -0
  22. package/packages/web/src/bl/links.js +1 -1
  23. package/packages/web/src/bl/match.js +71 -0
  24. package/packages/web/src/bl/mentions.js +39 -0
  25. package/packages/web/src/bl/offline.js +0 -4
  26. package/packages/web/src/bl/openers.js +65 -33
  27. package/packages/web/src/bl/pluginInstall.js +42 -21
  28. package/packages/web/src/bl/queries.js +669 -0
  29. package/packages/web/src/bl/services.js +77 -141
  30. package/packages/web/src/bl/social.js +3 -1
  31. package/packages/web/src/bl/state.js +144 -0
  32. package/packages/web/src/bl/status.js +96 -0
  33. package/packages/web/src/bl/trust.js +109 -0
  34. package/packages/web/src/bl/viewState.js +60 -0
  35. package/packages/web/src/bl/views.js +64 -0
  36. package/packages/web/src/bl/watchQuery.js +73 -0
  37. package/packages/web/src/platform/api.js +31 -102
  38. package/packages/web/src/platform/commands.js +49 -9
  39. package/packages/web/src/platform/context.js +146 -51
  40. package/packages/web/src/platform/index.js +27 -20
  41. package/packages/web/src/platform/navigation.js +34 -4
  42. package/packages/web/src/platform/pickers.js +46 -0
  43. package/packages/web/src/platform/pluginHost.js +43 -10
  44. package/packages/web/src/platform/pluginRpc.js +6 -2
  45. package/packages/web/src/platform/spatialNav.js +0 -2
  46. package/packages/web/src/platform/viewport.js +1 -6
  47. package/packages/web/src/platform/voiceSearch.js +1 -3
  48. package/packages/web/src/platform/whenclause.js +22 -3
  49. package/packages/web/src/styles.css +74 -4
  50. package/packages/web/src/ui/activate.js +22 -0
  51. package/packages/web/src/ui/components/activityBar.js +7 -3
  52. package/packages/web/src/ui/components/activityPanel.js +10 -11
  53. package/packages/web/src/ui/components/adminView.js +191 -0
  54. package/packages/web/src/ui/components/collectionGate.js +4 -3
  55. package/packages/web/src/ui/components/commandPalette.js +24 -55
  56. package/packages/web/src/ui/components/editorArea.js +16 -13
  57. package/packages/web/src/ui/components/launcher.js +39 -225
  58. package/packages/web/src/ui/components/openers/index.js +7 -4
  59. package/packages/web/src/ui/components/openers/markdown.js +5 -3
  60. package/packages/web/src/ui/components/overlays.js +158 -45
  61. package/packages/web/src/ui/components/phoneChrome.js +35 -33
  62. package/packages/web/src/ui/components/pluginReview.js +62 -23
  63. package/packages/web/src/ui/components/pluginsView.js +30 -28
  64. package/packages/web/src/ui/components/settingsView.js +151 -58
  65. package/packages/web/src/ui/components/social.js +42 -62
  66. package/packages/web/src/ui/components/statusBar.js +30 -74
  67. package/packages/web/src/ui/components/views/grid.js +3 -2
  68. package/packages/web/src/ui/components/views/index.js +7 -62
  69. package/packages/web/src/ui/components/views/list.js +3 -2
  70. package/packages/web/src/ui/components/views/parts.js +15 -4
  71. package/packages/web/src/ui/compositions/workbench.js +166 -58
  72. package/packages/web/src/ui/region.js +53 -0
  73. package/packages/web/src/workbench.js +17 -15
  74. package/packages/web/dist/assets/main-y778bpte.js +0 -356
  75. package/packages/web/dist/assets/main-y778bpte.js.map +0 -106
  76. package/packages/web/dist/assets/styles-nfy8t3n1.css +0 -1
  77. package/packages/web/src/platform/overlay.js +0 -81
  78. 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.
@@ -278,3 +166,51 @@ export class TransfersService {
278
166
  this.#emit();
279
167
  }
280
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
+ }
@@ -0,0 +1,109 @@
1
+ // What a plugin package's signature actually means.
2
+ //
3
+ // This is a security classification, not a label. It decided the same four states in two
4
+ // different components — the installed-plugin list and the install-review dialog — with two
5
+ // different sets of words, and the distinction that matters most was the one that drifted:
6
+ //
7
+ // `invalid` is NOT `unverified`. Unverified means nobody signed it, which is the ordinary
8
+ // case for a plugin you got from a friend. Invalid means it WAS signed and the signature
9
+ // does not verify — the bytes are not the bytes that were signed. That is the one state
10
+ // implying someone altered the package, and the list once rendered it in the same amber as
11
+ // the benign case, because the two `if` ladders were maintained separately and one of them
12
+ // was missing a branch.
13
+ //
14
+ // So the classification lives here, once, and the components choose their own wording from
15
+ // `status`. What is a security judgement is in the business layer; how many characters fit
16
+ // in a badge is not.
17
+
18
+ /**
19
+ * Whether a package may be installed at all, and on what terms.
20
+ *
21
+ * Three answers, because the states differ in kind rather than in degree:
22
+ *
23
+ * - `invalid` is REFUSED. It means the package was signed and the signature does not
24
+ * verify — the bytes are not the bytes that were signed. No development workflow
25
+ * produces that; it means the package was altered in transit or at rest. There is
26
+ * nothing for a user to weigh, so they are not asked.
27
+ *
28
+ * - `unverified` is installable but only DELIBERATELY. Nobody signed it, which is the
29
+ * ordinary state of a plugin you are writing: you cannot sign a package you are still
30
+ * changing. So it stays possible, behind an acknowledgement that says what is being
31
+ * given up — no publisher, no proof the code is what its author shipped.
32
+ *
33
+ * - `signed` carries a real signature whose domain does not vouch for the key. That is a
34
+ * claim to a namespace rather than a proof of one, so it is said prominently and left
35
+ * to the user; refusing it would block a valid publisher whose assetlinks are merely
36
+ * misconfigured.
37
+ *
38
+ * This is also what closes namespace squatting. A package claiming another publisher's
39
+ * domain is unverified by construction — the real domain does not publish its key — so it
40
+ * cannot be installed without the user being told exactly that.
41
+ */
42
+ export function installPolicyFor(trust) {
43
+ const t = describeTrust(trust);
44
+ if (t.status === 'invalid') {
45
+ return {
46
+ status: t.status,
47
+ allowed: false,
48
+ requiresAcknowledgement: false,
49
+ headline: 'This package has been altered',
50
+ detail: `${t.explanation}. It was signed, and the signature does not match its contents — so it is not what its author published. Trove will not install it.`,
51
+ };
52
+ }
53
+ if (t.status === 'unverified') {
54
+ return {
55
+ status: t.status,
56
+ allowed: true,
57
+ requiresAcknowledgement: true,
58
+ headline: 'Unsigned — for development only',
59
+ detail: 'Nobody has signed this package, so there is no way to tell who wrote it or whether it has been altered since. Install it only if you built it yourself or you trust wherever you got it from.',
60
+ };
61
+ }
62
+ if (t.status === 'signed') {
63
+ return {
64
+ status: t.status,
65
+ allowed: true,
66
+ requiresAcknowledgement: false,
67
+ headline: 'Signed, but the domain does not vouch for the key',
68
+ detail: `${t.explanation}. The signature is real; what is unproven is that ${t.domain || 'the publisher'} authorised it.`,
69
+ };
70
+ }
71
+ return { status: t.status, allowed: true, requiresAcknowledgement: false, headline: null, detail: null };
72
+ }
73
+
74
+ /**
75
+ * @param {{status?: string, domain?: string, reason?: string}|null} trust
76
+ * @returns {{status: string, tone: string, icon: string, domain: string|null, explanation: string, severity: number}}
77
+ */
78
+ export function describeTrust(trust) {
79
+ const t = trust || {};
80
+ switch (t.status) {
81
+ case 'verified':
82
+ return {
83
+ status: 'verified', tone: 'verified', icon: 'check', domain: t.domain || null,
84
+ explanation: t.domain
85
+ ? `Signed by a key published at ${t.domain}`
86
+ : 'Signed by a key the publishing domain vouches for',
87
+ severity: 0,
88
+ };
89
+ case 'signed':
90
+ return {
91
+ status: 'signed', tone: 'signed', icon: 'info', domain: t.domain || null,
92
+ explanation: t.reason || 'Signed, but the domain does not vouch for the key',
93
+ severity: 1,
94
+ };
95
+ case 'invalid':
96
+ return {
97
+ status: 'invalid', tone: 'invalid', icon: 'warn', domain: t.domain || null,
98
+ // Said plainly. This is the only state that means tampering rather than absence.
99
+ explanation: t.reason || 'The signature did not verify — this package may have been altered',
100
+ severity: 3,
101
+ };
102
+ default:
103
+ return {
104
+ status: 'unverified', tone: 'unverified', icon: 'warn', domain: t.domain || null,
105
+ explanation: t.reason || 'This plugin is not signed',
106
+ severity: 2,
107
+ };
108
+ }
109
+ }
@@ -0,0 +1,60 @@
1
+ // View state: what the UI is in the middle of doing.
2
+ //
3
+ // Which keybinding is mid-capture, what has been typed into a dialog that has not been
4
+ // submitted, which capabilities are ticked in a plugin review. None of it is drive state —
5
+ // it is thrown away when the dialog closes — but all of it decides what is on screen, and
6
+ // anything that decides what is on screen is engine state. A component reads it through a
7
+ // query and writes it through an action, the same as everything else.
8
+ //
9
+ // The history is worth keeping because it went wrong twice in the same direction. It began
10
+ // as module-level `let`s mutated in place, with a `rerender()` hook threaded through
11
+ // fourteen modules so the leaf that changed one could poke the root into redrawing — data
12
+ // down, invalidation back up out of band. Moving it into a cell fixed the invalidation and
13
+ // left a second version of the same mistake: components still READ it by calling the module
14
+ // during their own render, and two of them WROTE to it there as well, to lazily initialise a
15
+ // default. A render that writes state is a render with a side effect.
16
+ //
17
+ // So this is a resource now, and the lazy-initialise trick is gone: where a component used
18
+ // to write a default on first render, it now computes one and writes nothing until the user
19
+ // does something. `ref` is what makes that possible — it records which dialog instance the
20
+ // value belongs to, so a stale entry is simply ignored rather than needing to be cleared.
21
+ //
22
+ // The holder itself is a slice in bl/state.js; what stays here is how to read one.
23
+
24
+ /**
25
+ * The slice for `key`, or `fallback` when it belongs to a different dialog instance.
26
+ *
27
+ * Two components keep a draft that must reset when the dialog is reopened. They used to do
28
+ * that by writing the default during the render that noticed the mismatch. Deriving it
29
+ * instead means the render stays a pure function of the state it was given, and the first
30
+ * write happens when the user actually changes something.
31
+ *
32
+ * @param {object} slice the view-state slice from the query
33
+ * @param {string} key
34
+ * @param {object} ref the dialog instance the value must belong to
35
+ * @param {any} fallback
36
+ */
37
+ export function draftFor(slice, key, ref, fallback) {
38
+ const held = slice?.[key];
39
+ return held && held.ref === ref ? held : { ref, ...fallback };
40
+ }
41
+
42
+ /** Where a prompt dialog's typed value lives while it is being typed. */
43
+ export const PROMPT = 'promptValue';
44
+
45
+ /**
46
+ * What has been typed into the open prompt, or its initial value.
47
+ *
48
+ * The prompt kept this in a `let` closed over by the render and mutated by the input
49
+ * handler, and handed it back through an `onSubmit` callback — which meant a FUNCTION was
50
+ * living in the dialog spec, and the dialog spec is engine state. Keeping the value here
51
+ * instead lets a prompt carry actions like everything else, and each action reads what was
52
+ * typed rather than being handed it.
53
+ *
54
+ * `ref` ties it to the dialog instance, so reopening starts from the new initial value
55
+ * rather than whatever the last prompt left behind.
56
+ */
57
+ export function promptValueOf(slice, dialog) {
58
+ const held = slice?.[PROMPT];
59
+ return held && held.ref === dialog ? held.value : (dialog?.value ?? '');
60
+ }