@3sln/trove 0.0.4 → 0.0.7

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 (41) hide show
  1. package/README.md +6 -0
  2. package/package.json +1 -1
  3. package/packages/core/src/apiKeys.js +326 -0
  4. package/packages/core/src/collections/index.js +83 -13
  5. package/packages/core/src/index.js +14 -2
  6. package/packages/core/src/issues.js +4 -0
  7. package/packages/core/src/storage/diagnose.js +234 -0
  8. package/packages/core/src/storage/drivers.js +83 -0
  9. package/packages/core/src/storage/filesystem.js +22 -0
  10. package/packages/core/src/storage/registry.js +162 -0
  11. package/packages/server/src/adapters/bun.js +6 -0
  12. package/packages/server/src/adapters/node.js +6 -0
  13. package/packages/server/src/engine/index.js +1 -1
  14. package/packages/server/src/engine/providers/access.js +47 -5
  15. package/packages/server/src/engine/providers/core.js +105 -11
  16. package/packages/server/src/index.js +123 -11
  17. package/packages/server/src/mcp/tools.js +40 -8
  18. package/packages/server/src/router.js +1 -1
  19. package/packages/server/src/routes.js +135 -32
  20. package/packages/server/src/scope.js +2 -2
  21. package/packages/web/dist/assets/main-f0f2tfhp.js +356 -0
  22. package/packages/web/dist/assets/{main-4cxs7prw.js.map → main-f0f2tfhp.js.map} +17 -16
  23. package/packages/web/dist/assets/{styles-kcx1x337.css → styles-d3cyysgp.css} +1 -1
  24. package/packages/web/dist/index.html +2 -2
  25. package/packages/web/dist/sw.js +58 -9
  26. package/packages/web/src/bl/actions.js +112 -13
  27. package/packages/web/src/bl/activity.js +32 -0
  28. package/packages/web/src/bl/commands.js +41 -2
  29. package/packages/web/src/bl/index.js +9 -4
  30. package/packages/web/src/bl/services.js +78 -1
  31. package/packages/web/src/platform/api.js +57 -14
  32. package/packages/web/src/platform/pluginRpc.js +7 -4
  33. package/packages/web/src/styles.css +137 -0
  34. package/packages/web/src/ui/components/activityPanel.js +28 -1
  35. package/packages/web/src/ui/components/collectionGate.js +81 -0
  36. package/packages/web/src/ui/components/overlays.js +64 -34
  37. package/packages/web/src/ui/components/phoneChrome.js +2 -2
  38. package/packages/web/src/ui/components/settingsView.js +197 -1
  39. package/packages/web/src/ui/components/statusBar.js +19 -2
  40. package/packages/web/src/ui/compositions/workbench.js +9 -2
  41. package/packages/web/dist/assets/main-4cxs7prw.js +0 -356
@@ -0,0 +1,81 @@
1
+ // The only thing on screen until a collection is chosen.
2
+ //
3
+ // Every collection-scoped request names its collection in the path, so before one is
4
+ // known there is no file list to draw, nothing to search, and nowhere to upload to. The
5
+ // old behaviour was to pick something — the remembered one, else `default`, else the
6
+ // first in the list — and that is exactly the guess this replaces: on a shared drive it
7
+ // opened a collection plenty of people could not read, and presented the permission error
8
+ // as their drive.
9
+ //
10
+ // So there are two questions and no guesses. A drive with nothing in it asks for a
11
+ // collection to be made; a drive with several asks which one. Once answered, the choice is
12
+ // remembered in localStorage and this never appears again.
13
+
14
+ import { dd } from '../../runtime.js';
15
+ import { icon } from '../icon.js';
16
+
17
+ const { div, h2, p, button, span } = dd;
18
+
19
+ export default function collectionGate(state, ui) {
20
+ const ex = state.ex || {};
21
+ return div({ className: 'editor' },
22
+ div({ className: 'stage' },
23
+ div({ className: 'gate' },
24
+ ex.gate === 'create' ? createPrompt(ui) : choosePrompt(ex, ui),
25
+ ),
26
+ ),
27
+ );
28
+ }
29
+
30
+ /**
31
+ * A drive with no collections at all.
32
+ *
33
+ * Only shown to someone who may actually create one — `OpenInitialCollectionAction`
34
+ * sends everyone else to the "ask an administrator" message instead, because inviting
35
+ * someone to do a thing they are not allowed to do is worse than saying nothing.
36
+ */
37
+ function createPrompt(ui) {
38
+ return div({ className: 'gate-card' },
39
+ div({ className: 'gate-icon' }, icon('files', { size: 26 })),
40
+ h2('Make your first collection'),
41
+ p({ className: 'gate-sub' },
42
+ 'A collection is a backing store you own — a bucket, a directory, a mount. Files '
43
+ + 'live in one, and permissions are granted on one. Nothing can be uploaded until '
44
+ + 'there is somewhere to put it.'),
45
+ div({ className: 'gate-actions' },
46
+ button({ className: 'btn primary' }, icon('plus', { size: 14 }), 'New collection')
47
+ .on({ click: () => ui.exec('collections.create') }),
48
+ ),
49
+ );
50
+ }
51
+
52
+ /** Collections exist, but the user has not said which one they want. */
53
+ function choosePrompt(ex, ui) {
54
+ const collections = ex.collections || [];
55
+ return div({ className: 'gate-card' },
56
+ div({ className: 'gate-icon' }, icon('grid', { size: 26 })),
57
+ h2(collections.length === 1 ? 'Open your collection' : 'Choose a collection'),
58
+ p({ className: 'gate-sub' },
59
+ collections.length === 1
60
+ ? 'This is the one you have access to. Opening it will be remembered for next time.'
61
+ : 'Files, search and uploads all belong to one collection. Your choice is remembered '
62
+ + 'for next time, and you can switch from the status bar whenever you like.'),
63
+ div({ className: 'gate-list' },
64
+ ...collections.map((c) => button({ className: 'gate-choice' },
65
+ div({ className: 'gate-choice-main' },
66
+ span({ className: 'n' }, c.name || c.id),
67
+ span({ className: 'd' }, c.driver ? `${c.driver}${c.description ? ` · ${c.description}` : ''}` : (c.description || '')),
68
+ ),
69
+ // What they may do, from the server's own answer — so a read-only collection says
70
+ // so before they open it and find the Upload button missing.
71
+ span({ className: 'gate-caps' }, (c.capabilities || []).join(' · ')),
72
+ ).on({ click: () => ui.exec('collections.switch', c.id) })),
73
+ ),
74
+ ex.canCreateCollection
75
+ ? div({ className: 'gate-actions' },
76
+ button({ className: 'btn' }, icon('plus', { size: 14 }), 'New collection')
77
+ .on({ click: () => ui.exec('collections.create') }),
78
+ )
79
+ : null,
80
+ );
81
+ }
@@ -86,66 +86,96 @@ function openerChooserDialog(d, ui) {
86
86
  );
87
87
  }
88
88
 
89
- // A collection is a backing-store config. This form collects the driver + its
90
- // fields (filesystem path, or S3 bucket/prefix/keys) so a user with the create
91
- // capability can provision a new collection dynamically. The form persists
92
- // across re-renders (keyed to the dialog instance) so switching driver keeps it.
89
+ // A collection is a backing-store config, and which stores exist is the SERVER's answer.
90
+ //
91
+ // This form used to hardcode its own list Filesystem / NAS, S3, Memory — and the fields
92
+ // for each. On Cloudflare Workers that offered a filesystem the runtime cannot provide, so
93
+ // the form could produce a collection the server would refuse to build. Now the drivers and
94
+ // their fields come from /api/capabilities, which reports what was actually registered.
95
+ //
96
+ // The form persists across re-renders (keyed to the dialog instance) so switching driver
97
+ // keeps what has been typed.
93
98
  let colState = { ref: null, form: null };
94
99
  function collectionDialog(d, ui) {
95
100
  const wb = ui.platform.workbench;
101
+ const drivers = ui.platform.capabilities?.storageDrivers || [];
102
+
96
103
  if (colState.ref !== d) {
97
- colState = { ref: d, form: { name: '', description: '', driver: 'filesystem', root: '', bucket: '', prefix: '', region: 'auto', endpoint: '', accessKeyId: '', secretAccessKey: '' } };
104
+ colState = { ref: d, form: { name: '', description: '', driver: drivers[0]?.key || '' } };
98
105
  }
99
106
  const form = colState.form;
100
- const set = (k) => (e) => { form[k] = e.target.value; if (k === 'driver') ui.rerender?.(); };
107
+ const driver = drivers.find((x) => x.key === form.driver) || drivers[0];
108
+ const set = (k) => (e) => {
109
+ form[k] = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
110
+ if (k === 'driver') ui.rerender?.();
111
+ };
112
+
101
113
  const submit = () => {
114
+ // Only the fields this driver declared, so a leftover value from a driver the user
115
+ // switched away from is not smuggled into the store config.
102
116
  const store = { driver: form.driver };
103
- if (form.driver === 'filesystem') store.root = form.root;
104
- if (form.driver === 's3') {
105
- store.s3 = { bucket: form.bucket, region: form.region, endpoint: form.endpoint || undefined, accessKeyId: form.accessKeyId, secretAccessKey: form.secretAccessKey, forcePathStyle: !!form.endpoint };
106
- if (form.prefix) store.prefix = form.prefix;
117
+ for (const f of driver?.fields || []) {
118
+ const v = form[f.name];
119
+ if (v !== undefined && v !== '') store[f.name] = f.type === 'number' ? Number(v) : v;
107
120
  }
108
- if (form.driver === 'filesystem' && form.prefix) store.prefix = form.prefix;
109
121
  d.onSubmit?.({ name: form.name, description: form.description, store });
110
122
  };
123
+
111
124
  const field = (lbl, k, ph = '') => div({ className: 'field', $styling: { 'margin-bottom': '10px' } },
112
125
  label(lbl), input({ className: 'input', placeholder: ph }).on({ input: set(k) }));
126
+
127
+ const ready = !!form.name.trim() && !!form.driver
128
+ && (driver?.fields || []).every((f) => !f.required || String(form[f.name] ?? '').trim());
129
+
113
130
  return div({},
114
131
  div({ className: 'scrim' }).on({ click: () => wb.closeDialog() }),
115
132
  div({ className: 'dialog', $styling: { width: 'min(480px, 94vw)' } },
116
133
  h3('New collection'),
117
134
  div({ className: 'body' }, 'A collection is a backing store you own. Configure where its files live.'),
118
135
  field('Name', 'name', 'Team Vault'),
119
- div({ className: 'field', $styling: { 'margin-bottom': '10px' } },
120
- label('Backing store'),
121
- select({ className: 'input' },
122
- option({ value: 'filesystem', selected: form.driver === 'filesystem' }, 'Filesystem / NAS'),
123
- option({ value: 's3', selected: form.driver === 's3' }, 'S3-compatible (S3 · R2 · MinIO)'),
124
- option({ value: 'memory', selected: form.driver === 'memory' }, 'Memory (ephemeral)'),
125
- ).on({ change: set('driver') })),
126
- storeFields(form, set),
136
+ drivers.length
137
+ ? div({ className: 'field', $styling: { 'margin-bottom': '10px' } },
138
+ label('Backing store'),
139
+ select({ className: 'input' },
140
+ ...drivers.map((x) => option({ value: x.key, selected: form.driver === x.key }, x.label)),
141
+ ).on({ change: set('driver') }))
142
+ // No drivers at all means the deployment registered none, which is a server
143
+ // misconfiguration — saying so beats an empty dropdown.
144
+ : div({ className: 'body' }, 'This server has no storage drivers registered, so a collection cannot be created.'),
145
+ driver ? storeFields(driver, form, set) : null,
127
146
  div({ className: 'row-actions' },
128
147
  button({ className: 'btn' }, 'Cancel').on({ click: () => wb.closeDialog() }),
129
- button({ className: 'btn primary' }, 'Create collection').on({ click: submit }),
148
+ button({ className: 'btn primary', $attrs: ready ? {} : { disabled: 'true' } }, 'Create collection')
149
+ .on({ click: () => ready && submit() }),
130
150
  ),
131
151
  ),
132
152
  );
133
153
  }
134
- function storeFields(form, set) {
135
- const f = (lbl, k, ph = '', type = 'text') => div({ className: 'field', $styling: { 'margin-bottom': '10px' } },
136
- label(lbl), input({ className: 'input', placeholder: ph, type, autocomplete: 'off' }).on({ input: set(k) }));
137
- if (form.driver === 'filesystem') return div({}, f('Root directory', 'root', './data/team'), f('Prefix (optional)', 'prefix'));
138
- if (form.driver === 's3') {
139
- return div({},
140
- f('Bucket', 'bucket', 'my-bucket'),
141
- f('Prefix (optional)', 'prefix', 'team-a/'),
142
- f('Region', 'region', 'auto'),
143
- f('Endpoint (R2/MinIO; blank for AWS)', 'endpoint', 'https://<acct>.r2.cloudflarestorage.com'),
144
- f('Access key id', 'accessKeyId'),
145
- f('Secret access key', 'secretAccessKey', '', 'password'),
146
- );
154
+
155
+ /** The chosen driver's declared fields, rendered from its descriptor. */
156
+ function storeFields(driver, form, set) {
157
+ if (!driver.fields.length) {
158
+ return div({ className: 'body', $styling: { 'font-size': '12px' } },
159
+ driver.description || 'This store needs no configuration.');
147
160
  }
148
- return div({ className: 'body', $styling: { 'font-size': '12px' } }, 'Ephemeral — data is lost on restart. Good for testing.');
161
+ return div({},
162
+ driver.description
163
+ ? div({ className: 'body', $styling: { 'font-size': '12px', 'margin-bottom': '10px' } }, driver.description)
164
+ : null,
165
+ ...driver.fields.map((f) => (f.type === 'boolean'
166
+ ? div({ className: 'field', $styling: { 'margin-bottom': '10px' } },
167
+ label({}, input({ type: 'checkbox' }).on({ change: set(f.name) }), ` ${f.label}`),
168
+ f.help ? div({ className: 'body', $styling: { 'font-size': '11.5px' } }, f.help) : null)
169
+ : div({ className: 'field', $styling: { 'margin-bottom': '10px' } },
170
+ label(f.label + (f.required ? '' : ' (optional)')),
171
+ input({
172
+ className: 'input', placeholder: f.placeholder, autocomplete: 'off',
173
+ // A field the driver marked secret is a password field, so a shoulder or a
174
+ // screen share does not read an access key out of the form.
175
+ type: f.type === 'password' || f.secret ? 'password' : (f.type === 'number' ? 'number' : 'text'),
176
+ }).on({ input: set(f.name) }),
177
+ f.help ? div({ className: 'body', $styling: { 'font-size': '11.5px' } }, f.help) : null))),
178
+ );
149
179
  }
150
180
 
151
181
  // ---- Context menu ----------------------------------------------------------
@@ -43,7 +43,7 @@ export function phoneTopBar(state, ui) {
43
43
  // rows of chrome repeating "notes.txt" costs a fifth of the screen to say it twice.
44
44
  const title = state.wb.activity === 'settings' ? 'Settings'
45
45
  : state.wb.activity === 'plugins' ? 'Plugins'
46
- : f.collectionId;
46
+ : f.collectionLabel;
47
47
  return div({ className: 'phonebar top' },
48
48
  button({ className: 'pb-brand', title: 'Trove — home' }, img({ src: '/icon.svg', alt: 'Trove' }))
49
49
  .on({ click: () => ui.exec('workbench.view.home') }),
@@ -112,7 +112,7 @@ function statusSheet(state, ui) {
112
112
  const wb = ui.platform.workbench;
113
113
  const go = (cmd) => () => { wb.closeSheet(); ui.exec(cmd); };
114
114
  return div({ className: 'sheet-body' },
115
- div({ className: 'sheet-title' }, f.collectionId),
115
+ div({ className: 'sheet-title' }, f.collectionLabel),
116
116
 
117
117
  // Problems and running work first: they are the reason someone opened this.
118
118
  f.issues.length
@@ -3,7 +3,7 @@ import { prettyKey, eventToKey } from '../../platform/keybindings.js';
3
3
  import { icon } from '../icon.js';
4
4
  import { listAssociations, rememberOpener } from '../../bl/openers.js';
5
5
 
6
- const { div, h2, h3, p, span, select, option, input, label, button } = dd;
6
+ const { div, h2, h3, p, span, select, option, input, label, button, ul, li, code } = dd;
7
7
 
8
8
  export default function settingsView(state, ui) {
9
9
  const groups = ui.platform.settings.grouped();
@@ -14,6 +14,7 @@ export default function settingsView(state, ui) {
14
14
  p({ className: 'sub' }, 'Preferences are stored in this browser. Plugins contribute their own settings here too.'),
15
15
  ...groups.map((g) => group(g, ui)),
16
16
  mcpSection(ui),
17
+ apiKeysSection(state, ui),
17
18
  openersSection(ui),
18
19
  keybindingsSection(ui),
19
20
  ),
@@ -170,6 +171,201 @@ function mcpSection(ui) {
170
171
  // Default openers per file type — the "always use this" choices from the opener
171
172
  // chooser. Each row shows the type → viewer; the × forgets it (so the next open of
172
173
  // that type asks again). Empty when the user hasn't set any defaults.
174
+ // --- API keys -------------------------------------------------------------------
175
+
176
+ const CAPS = [
177
+ { id: 'read', label: 'Read', hint: 'list and download' },
178
+ { id: 'write', label: 'Write', hint: 'upload and rename' },
179
+ { id: 'delete', label: 'Delete', hint: 'trash and purge' },
180
+ { id: 'admin', label: 'Admin', hint: 'includes all of the above' },
181
+ ];
182
+
183
+ const ANY = '*';
184
+
185
+ /** One-shot guard so a render does not queue a second load while the first is in flight. */
186
+ let keysRequested = false;
187
+
188
+ function apiKeysSection(state, ui) {
189
+ const keys = state.keys || {};
190
+
191
+ // Loaded lazily, when Settings is first opened — the list is admin-only and most
192
+ // sessions never need it, so it is not worth a request at boot.
193
+ if (!keysRequested && !keys.loaded && !keys.loading) {
194
+ keysRequested = true;
195
+ ui.exec('keys.load');
196
+ }
197
+
198
+ // A non-admin gets a 403 here, which is the correct answer rather than an error worth
199
+ // showing. The section simply is not part of their settings screen.
200
+ if (keys.error && !keys.keys?.length) return null;
201
+ if (!keys.loaded) return null;
202
+
203
+ const collections = state.ex?.collections || [];
204
+
205
+ return div({ className: 'group' },
206
+ h3('API keys'),
207
+ p({ className: 'sub' },
208
+ 'Give a script or a service access without giving it an account. A key carries '
209
+ + 'capabilities and no identity, so anything it does is attributed to the key rather '
210
+ + 'than to a person \u2014 and it can only reach the collections you scope it to.'),
211
+
212
+ keys.minted ? mintedBanner(keys.minted, ui) : null,
213
+ keys.draft ? draftForm(keys, collections, ui) : null,
214
+
215
+ !keys.draft && !keys.minted
216
+ ? div({ className: 'keys-actions' },
217
+ button({ className: 'btn' }, icon('plus', { size: 14 }), 'New key')
218
+ .on({ click: () => ui.exec('keys.new') }))
219
+ : null,
220
+
221
+ keys.keys?.length
222
+ ? div({ className: 'keys-list' }, ...keys.keys.map((k) => keyRow(k, keys, collections, ui)))
223
+ : div({ className: 'keys-empty' }, 'No keys yet.'),
224
+ );
225
+ }
226
+
227
+ /**
228
+ * The secret, shown once.
229
+ *
230
+ * Deliberately loud and deliberately blocking the rest of the section: the server stored
231
+ * only a hash, so this is the only moment the value exists anywhere. A quiet row in a
232
+ * table would be dismissed without being copied.
233
+ */
234
+ function mintedBanner(minted, ui) {
235
+ const copy = () => {
236
+ navigator.clipboard?.writeText(minted.secret)
237
+ .then(() => ui.platform.notifications.success('Key copied'))
238
+ .catch(() => ui.platform.notifications.info(minted.secret, { sticky: true }));
239
+ };
240
+ return div({ className: 'key-minted' },
241
+ div({ className: 'key-minted-head' },
242
+ icon('warn', { size: 15 }),
243
+ span(`\u201c${minted.key.name}\u201d is ready \u2014 copy it now.`),
244
+ ),
245
+ p({ className: 'key-minted-note' },
246
+ 'This is the only time it can be shown. It is stored as a hash, so it cannot be '
247
+ + 'recovered \u2014 if it is lost, revoke the key and make another.'),
248
+ div({ className: 'key-secret' },
249
+ code({ className: 'mono' }, minted.secret),
250
+ button({ className: 'btn small', title: 'Copy' }, icon('link', { size: 13 })).on({ click: copy }),
251
+ ),
252
+ div({ className: 'keys-actions' },
253
+ button({ className: 'btn' }, 'Done').on({ click: () => ui.exec('keys.dismissMinted') }),
254
+ ),
255
+ );
256
+ }
257
+
258
+ function draftForm(keys, collections, ui) {
259
+ const draft = keys.draft;
260
+ const scopes = ui.app.apiKeys.draftScopes();
261
+ const ready = !!draft.name.trim() && !!scopes;
262
+
263
+ return div({ className: 'key-form' },
264
+ div({ className: 'setting' },
265
+ div({ className: 'info' },
266
+ div({ className: 't' }, 'Name'),
267
+ div({ className: 'd' }, 'What it is for. This is what the list shows, so make it recognisable.'),
268
+ ),
269
+ div({ className: 'control' },
270
+ input({ className: 'input', value: draft.name, $attrs: { placeholder: 'CI uploader' } })
271
+ .on({ input: (e) => ui.app.apiKeys.patchDraft({ name: e.target.value }) }),
272
+ ),
273
+ ),
274
+
275
+ div({ className: 'setting' },
276
+ div({ className: 'info' },
277
+ div({ className: 't' }, 'Expires after'),
278
+ div({ className: 'd' }, 'Days from now. Leave blank and it never expires.'),
279
+ ),
280
+ div({ className: 'control' },
281
+ input({
282
+ className: 'input', type: 'number', value: draft.expiresInDays,
283
+ $attrs: { min: '1', max: '3650', placeholder: 'never' },
284
+ }).on({ input: (e) => ui.app.apiKeys.patchDraft({ expiresInDays: e.target.value }) }),
285
+ ),
286
+ ),
287
+
288
+ div({ className: 'key-scopes' },
289
+ div({ className: 'key-scopes-head' },
290
+ div({ className: 't' }, 'What it may do'),
291
+ div({ className: 'd' },
292
+ 'Per collection. A key with nothing ticked grants nothing and cannot be created.'),
293
+ ),
294
+ scopeRow({ id: ANY, name: 'All collections', wildcard: true }, draft, ui),
295
+ ...collections.map((c) => scopeRow(c, draft, ui)),
296
+ ),
297
+
298
+ div({ className: 'keys-actions' },
299
+ button({ className: 'btn primary', $attrs: ready ? {} : { disabled: 'true' } },
300
+ keys.busy === 'mint' ? 'Creating\u2026' : 'Create key')
301
+ .on({ click: () => ready && ui.exec('keys.mint') }),
302
+ button({ className: 'btn' }, 'Cancel').on({ click: () => ui.exec('keys.cancel') }),
303
+ ),
304
+ );
305
+ }
306
+
307
+ function scopeRow(collection, draft, ui) {
308
+ const held = new Set(draft.caps[collection.id] || []);
309
+ return div({ className: `key-scope ${collection.wildcard ? 'wildcard' : ''}` },
310
+ div({ className: 'key-scope-name' },
311
+ icon(collection.wildcard ? 'grid' : 'files', { size: 14 }),
312
+ span(collection.name || collection.id),
313
+ ),
314
+ div({ className: 'key-scope-caps' },
315
+ ...CAPS.map((c) => label({ className: `cap ${held.has(c.id) ? 'on' : ''}`, title: c.hint },
316
+ input({ type: 'checkbox', checked: held.has(c.id) })
317
+ .on({ change: () => ui.app.apiKeys.toggleCap(collection.id, c.id) }),
318
+ span(c.label),
319
+ )),
320
+ ),
321
+ );
322
+ }
323
+
324
+ function keyRow(k, keys, collections, ui) {
325
+ const revoked = !!k.revokedAt;
326
+ const expired = k.expiresAt != null && k.expiresAt <= Date.now();
327
+ const dead = revoked || expired;
328
+ return div({ className: `key-row ${dead ? 'dead' : ''}` },
329
+ div({ className: 'key-row-main' },
330
+ div({ className: 'key-row-name' },
331
+ span({ className: 't' }, k.name),
332
+ revoked ? span({ className: 'key-tag' }, 'revoked')
333
+ : expired ? span({ className: 'key-tag' }, 'expired') : null,
334
+ ),
335
+ div({ className: 'key-row-scopes' }, ...scopeSummary(k, collections)),
336
+ div({ className: 'key-row-meta' }, metaLine(k)),
337
+ ),
338
+ !dead
339
+ ? button({
340
+ className: 'btn small danger',
341
+ $attrs: keys.busy === k.id ? { disabled: 'true' } : {},
342
+ }, keys.busy === k.id ? '\u2026' : 'Revoke').on({ click: () => ui.exec('keys.revoke', k.id) })
343
+ : null,
344
+ );
345
+ }
346
+
347
+ function scopeSummary(k, collections) {
348
+ // The name if we know it, the id if we do not. A key can outlive the collection it was
349
+ // scoped to, and showing a bare id then is more honest than showing nothing — it is
350
+ // also the thing you would search for to find out what happened to it.
351
+ const nameOf = (id) => collections.find((c) => c.id === id)?.name || id;
352
+ return (k.scopes || []).map((s) => span({ className: 'key-chip' },
353
+ span({ className: 'where' }, s.collectionId === ANY ? 'all collections' : nameOf(s.collectionId)),
354
+ span({ className: 'what' }, s.capabilities.join(' \u00b7 ')),
355
+ ));
356
+ }
357
+
358
+ function metaLine(k) {
359
+ const when = (ms) => new Date(ms).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
360
+ const bits = [`created ${when(k.createdAt)}`];
361
+ if (k.createdBy) bits.push(`by ${k.createdBy}`);
362
+ // "Never used" is the more useful of the two facts: it is how you find the key nobody
363
+ // needs and can safely revoke.
364
+ bits.push(k.lastUsedAt ? `last used ${when(k.lastUsedAt)}` : 'never used');
365
+ if (k.expiresAt) bits.push(`expires ${when(k.expiresAt)}`);
366
+ return bits.join(' \u00b7 ');
367
+ }
368
+
173
369
  function openersSection(ui) {
174
370
  const rows = listAssociations(ui.platform);
175
371
  return div({ className: 'group' },
@@ -97,7 +97,15 @@ export function statusFacts(state, ui) {
97
97
  const items = ex.items || [];
98
98
  const act = state.act || { tasks: [], issues: [] };
99
99
  return {
100
- collectionId: ex.collectionId || 'default',
100
+ // `null` with nothing open — `collectionLabel` renders that as "no collection". The
101
+ // old fallback made the bar name a collection that may not exist, on a drive where
102
+ // the user had not yet chosen one.
103
+ collectionId: ex.collectionId ?? null,
104
+ // What to CALL it, so the phone shell and the desktop bar cannot end up saying
105
+ // different things — which is the entire reason these facts are derived once. The
106
+ // phone rendered `collectionId` raw, which showed an opaque `col_…` id where the
107
+ // desktop showed the name.
108
+ collectionLabel: collectionLabel(ex),
101
109
  // The COLLECTION's totals when the server could give them, not the page's. Summing
102
110
  // what happens to be loaded reports a 3,000-file drive as 500 files — a wrong number,
103
111
  // not a rounded one. Falls back to the page only when the server didn't say.
@@ -117,6 +125,13 @@ export function statusFacts(state, ui) {
117
125
  };
118
126
  }
119
127
 
128
+ /** What to call the current collection: its name, its id, or an honest nothing. */
129
+ function collectionLabel(ex) {
130
+ if (!ex?.collectionId) return 'no collection';
131
+ const match = (ex.collections || []).find((c) => c.id === ex.collectionId);
132
+ return match?.name || ex.collectionId;
133
+ }
134
+
120
135
  export default function statusBar(state, ui) {
121
136
  const ex = state.ex;
122
137
  const items = ex.items || [];
@@ -156,7 +171,9 @@ export default function statusBar(state, ui) {
156
171
  } })
157
172
  : null,
158
173
  button({ className: 'seg', title: 'Switch collection' },
159
- icon('files', { size: 13 }), span(ex.collectionId || 'default'),
174
+ // The NAME, and nothing invented. `|| 'default'` used to sit here, which meant the
175
+ // status bar cheerfully named a collection on a drive that had none.
176
+ icon('files', { size: 13 }), span(collectionLabel(ex)),
160
177
  (ex.collections || []).length > 1 || ex.canCreateCollection ? icon('chevron-down', { size: 11 }) : null)
161
178
  .on({ click: (e) => {
162
179
  const items = ui.app.collectionMenu?.() || [];
@@ -10,6 +10,7 @@ import activityBar from '../components/activityBar.js';
10
10
  import statusBar from '../components/statusBar.js';
11
11
  import launcher from '../components/launcher.js';
12
12
  import settingsView from '../components/settingsView.js';
13
+ import collectionGate from '../components/collectionGate.js';
13
14
  import pluginsView from '../components/pluginsView.js';
14
15
  import editorArea from '../components/editorArea.js';
15
16
  import commandPalette from '../components/commandPalette.js';
@@ -43,6 +44,7 @@ export default function workbench({ engine, app, platform, plugins }) {
43
44
  platform.workbench.observeNav(),
44
45
  app.explorer.observe(),
45
46
  app.search.observe(),
47
+ app.apiKeys.observe(),
46
48
  app.transfers.observe(),
47
49
  platform.notifications.observe(),
48
50
  platform.context.observe(),
@@ -60,8 +62,8 @@ export default function workbench({ engine, app, platform, plugins }) {
60
62
  // shallow-equal to the last one, so a forced re-render that left no trace in the
61
63
  // object would be discarded as "nothing changed" — which is the opposite of what
62
64
  // asking for one means.
63
- (wb, overlay, nav, ex, se, tr, notif, ctx, settings, pluginList, statusItems, so, off, act, vp, voice, _bump) =>
64
- ({ wb, overlay, nav, ex, se, tr, notif, ctx, settings, plugins: pluginList, statusItems, so, off, act, vp, voice, _bump }),
65
+ (wb, overlay, nav, ex, se, keys, tr, notif, ctx, settings, pluginList, statusItems, so, off, act, vp, voice, _bump) =>
66
+ ({ wb, overlay, nav, ex, se, keys, tr, notif, ctx, settings, plugins: pluginList, statusItems, so, off, act, vp, voice, _bump }),
65
67
  );
66
68
 
67
69
  return alias(() => watch(combined, (state) => view(state, ui)));
@@ -94,6 +96,11 @@ function view(state, ui) {
94
96
  }
95
97
 
96
98
  function mainArea(state, ui) {
99
+ // Before a collection is known there is nothing to draw: every scoped request names one
100
+ // in its path, so a file list, a search box and an Upload button would all be lying.
101
+ // Settings stays reachable — an admin with no collections still needs to get at it.
102
+ if (state.ex?.gate && state.wb.activity !== 'settings') return collectionGate(state, ui);
103
+
97
104
  switch (state.wb.activity) {
98
105
  case 'settings': return settingsView(state, ui);
99
106
  case 'plugins': return pluginsView(state, ui);