@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
@@ -6,7 +6,10 @@ import { dd } from '../../runtime.js';
6
6
  import { icon, iconForNode } from '../icon.js';
7
7
  import { bytes } from '../format.js';
8
8
  import { pluginReview } from './pluginReview.js';
9
- import { typeKeyFor, typeLabelFor, rememberOpener, openerSource } from '../../bl/openers.js';
9
+ import { typeKeyFor, typeLabelFor } from '../../bl/openers.js';
10
+ import { draftFor, promptValueOf, PROMPT } from '../../bl/viewState.js';
11
+ import { CancelTransferAction, ClearFinishedTransfersAction, CloseContextMenuAction, CloseDialogAction, ClosePluginPanelAction, CreateCollectionFromFormAction, DismissNotificationAction, DismissTransferAction, OpenInPanelAction, PatchDraftAction, RememberOpenerAction, RetryTransferAction, SetViewStateAction, UpdateDialogAction } from '../../bl/actions.js';
12
+ import { activate } from '../activate.js';
10
13
 
11
14
  const { div, span, button, input, h3, p, select, option, label, textarea } = dd;
12
15
 
@@ -14,30 +17,38 @@ const { div, span, button, input, h3, p, select, option, label, textarea } = dd;
14
17
  export function dialog(state, ui) {
15
18
  const d = state.overlay.dialog;
16
19
  if (!d) return null;
17
- if (d.kind === 'collection') return collectionDialog(d, ui);
18
- if (d.kind === 'plugin-review') return pluginReview(d, ui);
20
+ if (d.kind === 'collection') return collectionDialog(d, ui, state.caps, state.view);
21
+ if (d.kind === 'plugin-review') return pluginReview(d, ui, state.view);
19
22
  if (d.kind === 'opener-chooser') return openerChooserDialog(d, ui);
20
- const wb = ui.platform.workbench;
21
- let value = d.value ?? '';
22
- const submit = () => (d.kind === 'confirm' ? d.onConfirm?.() : d.onSubmit?.(value));
23
+ // Both kinds carry ACTIONS — what happens if you say yes — rather than a callback. A
24
+ // prompt's typed value is engine state (see bl/viewState.js), so its actions read what
25
+ // was typed instead of being handed it, and the dialog spec holds no functions.
26
+ //
27
+ // A confirm closes itself here; a prompt's action closes first and then acts, because it
28
+ // has to read the value before the dialog goes.
29
+ const value = promptValueOf(state.view, d);
30
+ const submit = () => {
31
+ if (d.kind === 'confirm') ui.engine.dispatch(new CloseDialogAction());
32
+ activate(ui, { actions: d.confirmActions });
33
+ };
23
34
  return div({},
24
- div({ className: 'scrim' }).on({ click: () => wb.closeDialog() }),
35
+ div({ className: 'scrim' }).on({ click: () => ui.engine.dispatch(new CloseDialogAction()) }),
25
36
  div({ className: 'dialog' },
26
37
  h3(d.title),
27
38
  d.body ? div({ className: 'body' }, d.body) : null,
28
39
  d.kind === 'prompt'
29
40
  ? div({ className: 'field' },
30
41
  d.label ? span({ $styling: { 'font-size': '12px', color: 'var(--text-dim)' } }, d.label) : null,
31
- input({ className: 'input', value: d.value ?? '', placeholder: d.placeholder || '', autofocus: true })
42
+ input({ className: 'input', value, placeholder: d.placeholder || '', autofocus: true })
32
43
  .on({
33
- input: (e) => { value = e.target.value; },
34
- keydown: (e) => { if (e.key === 'Enter') submit(); if (e.key === 'Escape') wb.closeDialog(); },
44
+ input: (e) => ui.engine.dispatch(new SetViewStateAction(PROMPT, { ref: d, value: e.target.value })),
45
+ keydown: (e) => { if (e.key === 'Enter') submit(); if (e.key === 'Escape') ui.engine.dispatch(new CloseDialogAction()); },
35
46
  $attach: (el) => queueMicrotask(() => { el.focus(); el.select(); }),
36
47
  }),
37
48
  )
38
49
  : null,
39
50
  div({ className: 'row-actions' },
40
- button({ className: 'btn' }, 'Cancel').on({ click: () => wb.closeDialog() }),
51
+ button({ className: 'btn' }, 'Cancel').on({ click: () => ui.engine.dispatch(new CloseDialogAction()) }),
41
52
  button({ className: `btn ${d.danger ? 'danger' : 'primary'}` }, d.confirmLabel || 'OK').on({ click: submit }),
42
53
  ),
43
54
  ),
@@ -48,38 +59,41 @@ export function dialog(state, ui) {
48
59
  // workbench dialog state (updateDialog), so radio/checkbox selection survives
49
60
  // re-renders. "Remember" persists a per-file-type association in settings.
50
61
  function openerChooserDialog(d, ui) {
51
- const wb = ui.platform.workbench;
52
- const platform = ui.platform;
53
62
  const selected = d.openerId || d.current || d.openers[0]?.id;
54
63
  const remember = !!d.remember;
55
64
  const confirm = () => {
56
- if (remember && selected) rememberOpener(platform, typeKeyFor(d.node), selected);
57
- wb.closeDialog();
58
- wb.openFile(d.node, selected, { reset: !!d.reset });
65
+ if (remember && selected) {
66
+ ui.engine.dispatch(new RememberOpenerAction(typeKeyFor(d.node), selected));
67
+ }
68
+ ui.engine.dispatch(new CloseDialogAction());
69
+ ui.engine.dispatch(new OpenInPanelAction(d.node, selected, { reset: !!d.reset }));
59
70
  };
60
71
  return div({},
61
- div({ className: 'scrim' }).on({ click: () => wb.closeDialog() }),
72
+ div({ className: 'scrim' }).on({ click: () => ui.engine.dispatch(new CloseDialogAction()) }),
62
73
  div({ className: 'dialog opener-chooser' },
63
74
  h3(`Open “${d.node.name}” with…`),
64
75
  div({ className: 'opener-list' },
65
76
  ...d.openers.map((o) =>
66
77
  label({ className: `opener-opt ${o.id === selected ? 'sel' : ''}` },
67
78
  input({ type: 'radio', name: 'opener-choice', checked: o.id === selected })
68
- .on({ change: () => wb.updateDialog({ openerId: o.id }) }),
79
+ .on({ change: () => ui.engine.dispatch(new UpdateDialogAction({ openerId: o.id })) }),
69
80
  icon(o.icon || iconForNode(d.node), { size: 18 }),
70
81
  div({ className: 'oo-main' },
71
82
  span({ className: 'oo-title' }, o.title || o.id),
72
- span({ className: 'oo-src' }, openerSource(platform, o)),
83
+ // Resolved by whoever assembled the list — see describeOpener. Asking the
84
+ // plugin host for a display name mid-render was the last thing keeping
85
+ // `platform` in this dialog.
86
+ span({ className: 'oo-src' }, o.source),
73
87
  ),
74
- ).on({ click: () => wb.updateDialog({ openerId: o.id }) }),
88
+ ).on({ click: () => ui.engine.dispatch(new UpdateDialogAction({ openerId: o.id })) }),
75
89
  ),
76
90
  ),
77
91
  label({ className: 'opener-remember' },
78
- input({ type: 'checkbox', checked: remember }).on({ change: (e) => wb.updateDialog({ remember: e.target.checked }) }),
92
+ input({ type: 'checkbox', checked: remember }).on({ change: (e) => ui.engine.dispatch(new UpdateDialogAction({ remember: e.target.checked })) }),
79
93
  span(`Always use this for ${typeLabelFor(d.node)}`),
80
94
  ),
81
95
  div({ className: 'row-actions' },
82
- button({ className: 'btn' }, 'Cancel').on({ click: () => wb.closeDialog() }),
96
+ button({ className: 'btn' }, 'Cancel').on({ click: () => ui.engine.dispatch(new CloseDialogAction()) }),
83
97
  button({ className: 'btn primary' }, 'Open').on({ click: confirm }),
84
98
  ),
85
99
  ),
@@ -95,19 +109,35 @@ function openerChooserDialog(d, ui) {
95
109
  //
96
110
  // The form persists across re-renders (keyed to the dialog instance) so switching driver
97
111
  // keeps what has been typed.
98
- let colState = { ref: null, form: null };
99
- function collectionDialog(d, ui) {
100
- const wb = ui.platform.workbench;
101
- const drivers = ui.platform.capabilities?.storageDrivers || [];
112
+ // The collection dialog's unsubmitted form. Engine state, because changing the storage
113
+ // driver changes which fields are on screen — see bl/viewState.js.
114
+ const COL_FORM = 'collectionDialog';
115
+ function collectionDialog(d, ui, caps, view) {
116
+ const drivers = caps?.storageDrivers || [];
102
117
 
103
- if (colState.ref !== d) {
104
- colState = { ref: d, form: { name: '', description: '', driver: drivers[0]?.key || '' } };
105
- }
118
+ // Derived, not written. This used to install the empty form during the render that
119
+ // noticed the dialog had changed a render with a side effect. `draftFor` answers with
120
+ // the default when the held draft belongs to a different dialog instance, so nothing is
121
+ // written until the user types something.
122
+ const blankForm = {
123
+ name: '', description: '', driver: drivers[0]?.key || '',
124
+ // Off by default and deliberately so: encryption changes what the drive can do for you
125
+ // (see the exposure notes below) and should be a decision, not a default someone
126
+ // discovers later.
127
+ encrypt: false, encryptWhat: 'all', encryptList: '',
128
+ };
129
+ const colState = draftFor(view, COL_FORM, d, { form: blankForm });
106
130
  const form = colState.form;
107
131
  const driver = drivers.find((x) => x.key === form.driver) || drivers[0];
132
+ // Every field writes through the cell. Only the driver changes which fields are on
133
+ // screen, but writing them all the same way means there is no second rule to remember —
134
+ // and a text field that only mattered on submit was the reason `form` could be mutated
135
+ // in place at all.
136
+ // Merged against STORED state, not against the `form` this render closed over — see
137
+ // PatchDraftAction. Changing two fields before the next render used to lose one.
108
138
  const set = (k) => (e) => {
109
- form[k] = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
110
- if (k === 'driver') ui.rerender?.();
139
+ const value = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
140
+ ui.engine.dispatch(new PatchDraftAction(COL_FORM, colState.ref, { [k]: value }, blankForm));
111
141
  };
112
142
 
113
143
  const submit = () => {
@@ -118,17 +148,28 @@ function collectionDialog(d, ui) {
118
148
  const v = form[f.name];
119
149
  if (v !== undefined && v !== '') store[f.name] = f.type === 'number' ? Number(v) : v;
120
150
  }
121
- d.onSubmit?.({ name: form.name, description: form.description, store });
151
+ ui.engine.dispatch(new CreateCollectionFromFormAction({
152
+ name: form.name, description: form.description, store,
153
+ encryption: encryptionOf(form),
154
+ }));
122
155
  };
123
156
 
124
157
  const field = (lbl, k, ph = '') => div({ className: 'field', $styling: { 'margin-bottom': '10px' } },
125
158
  label(lbl), input({ className: 'input', placeholder: ph }).on({ input: set(k) }));
126
159
 
127
- const ready = !!form.name.trim() && !!form.driver
160
+ // The server refuses a configuration that would match nothing, and saying so here is the
161
+ // difference between a form that explains itself and an API error after the fact.
162
+ const encryptionProblem = form.encrypt && form.encryptWhat !== 'all' && !parseList(form.encryptList).length
163
+ ? (form.encryptWhat === 'extensions'
164
+ ? 'Name at least one extension, or choose every file.'
165
+ : 'Name at least one media type, or choose every file.')
166
+ : null;
167
+
168
+ const ready = !!form.name.trim() && !!form.driver && !encryptionProblem
128
169
  && (driver?.fields || []).every((f) => !f.required || String(form[f.name] ?? '').trim());
129
170
 
130
171
  return div({},
131
- div({ className: 'scrim' }).on({ click: () => wb.closeDialog() }),
172
+ div({ className: 'scrim' }).on({ click: () => ui.engine.dispatch(new CloseDialogAction()) }),
132
173
  div({ className: 'dialog', $styling: { width: 'min(480px, 94vw)' } },
133
174
  h3('New collection'),
134
175
  div({ className: 'body' }, 'A collection is a backing store you own. Configure where its files live.'),
@@ -143,8 +184,9 @@ function collectionDialog(d, ui) {
143
184
  // misconfiguration — saying so beats an empty dropdown.
144
185
  : div({ className: 'body' }, 'This server has no storage drivers registered, so a collection cannot be created.'),
145
186
  driver ? storeFields(driver, form, set) : null,
187
+ encryptionSection(form, set, caps, encryptionProblem),
146
188
  div({ className: 'row-actions' },
147
- button({ className: 'btn' }, 'Cancel').on({ click: () => wb.closeDialog() }),
189
+ button({ className: 'btn' }, 'Cancel').on({ click: () => ui.engine.dispatch(new CloseDialogAction()) }),
148
190
  button({ className: 'btn primary', $attrs: ready ? {} : { disabled: 'true' } }, 'Create collection')
149
191
  .on({ click: () => ready && submit() }),
150
192
  ),
@@ -152,6 +194,78 @@ function collectionDialog(d, ui) {
152
194
  );
153
195
  }
154
196
 
197
+ /** The rules as the API wants them, or null when encryption is off. */
198
+ function encryptionOf(form) {
199
+ if (!form.encrypt) return null;
200
+ const list = parseList(form.encryptList);
201
+ return {
202
+ enabled: true,
203
+ rules: form.encryptWhat === 'all'
204
+ ? { all: true }
205
+ : form.encryptWhat === 'extensions'
206
+ ? { extensions: list }
207
+ : { mimeTypes: list },
208
+ };
209
+ }
210
+
211
+ /** A comma or space separated list, emptied of blanks. */
212
+ function parseList(text) {
213
+ return String(text || '').split(/[\s,]+/).map((x) => x.trim()).filter(Boolean);
214
+ }
215
+
216
+ /**
217
+ * Turning encryption on, and saying what it does.
218
+ *
219
+ * The scope notes sit HERE rather than in a help page, because the moment someone enables
220
+ * this is the moment the scope matters. What it protects is the storage provider seeing
221
+ * file contents; what it does not protect against is anything that reads through the drive,
222
+ * which includes every indexer. Someone who believes they have just made their files
223
+ * unreadable to the server has been misled by omission.
224
+ */
225
+ function encryptionSection(form, set, caps, problem) {
226
+ const indexers = (caps?.indexers || []).filter((i) => i.readsContent !== false);
227
+ return div({ className: 'field enc-section', $styling: { 'margin-bottom': '10px' } },
228
+ label({ className: 'enc-toggle' },
229
+ input({ type: 'checkbox', checked: !!form.encrypt }).on({ change: set('encrypt') }),
230
+ span('Encrypt files in this collection'),
231
+ ),
232
+ !form.encrypt
233
+ ? div({ className: 'enc-hint' }, 'Files are stored exactly as uploaded.')
234
+ : div({ className: 'enc-body' },
235
+ div({ className: 'field' },
236
+ label('What to encrypt'),
237
+ select({ className: 'input' },
238
+ option({ value: 'all', selected: form.encryptWhat === 'all' }, 'Every file'),
239
+ option({ value: 'extensions', selected: form.encryptWhat === 'extensions' }, 'Only these extensions'),
240
+ option({ value: 'mimeTypes', selected: form.encryptWhat === 'mimeTypes' }, 'Only these media types'),
241
+ ).on({ change: set('encryptWhat') })),
242
+ form.encryptWhat !== 'all'
243
+ ? div({ className: 'field' },
244
+ input({
245
+ className: 'input', value: form.encryptList,
246
+ placeholder: form.encryptWhat === 'extensions' ? '.pdf, .docx, .key' : 'image, application/pdf',
247
+ }).on({ input: set('encryptList') }),
248
+ // A media type matches by its leading part too, which is how someone actually
249
+ // thinks about "encrypt my photos".
250
+ div({ className: 'enc-hint' }, form.encryptWhat === 'extensions'
251
+ ? 'Separated by commas. The leading dot is optional.'
252
+ : 'Separated by commas. “image” covers every image type.'))
253
+ : null,
254
+ problem ? div({ className: 'enc-problem' }, icon('warn', { size: 13 }), span(problem)) : null,
255
+ div({ className: 'enc-scope' },
256
+ div({ className: 'es-title' }, 'What this does'),
257
+ div({ className: 'es-line' }, 'Files are sealed in your browser before they are uploaded, so the storage provider only ever holds ciphertext.'),
258
+ div({ className: 'es-title' }, 'What it does not do'),
259
+ div({ className: 'es-line' }, 'It is not end-to-end. The drive holds the key and decrypts for anything that reads through it — previews, downloads, and search indexing.'),
260
+ indexers.length
261
+ ? div({ className: 'es-line' }, `These read file contents in the clear: ${indexers.map((i) => i.title || i.id).join(', ')}.`)
262
+ : null,
263
+ div({ className: 'es-line note' }, 'This applies to files uploaded from now on. Turning it off later does not decrypt anything already sealed.'),
264
+ ),
265
+ ),
266
+ );
267
+ }
268
+
155
269
  /** The chosen driver's declared fields, rendered from its descriptor. */
156
270
  function storeFields(driver, form, set) {
157
271
  if (!driver.fields.length) {
@@ -182,7 +296,6 @@ function storeFields(driver, form, set) {
182
296
  export function contextMenu(state, ui) {
183
297
  const m = state.overlay.contextMenu;
184
298
  if (!m || !m.items?.length) return null;
185
- const wb = ui.platform.workbench;
186
299
  // Clamp on both edges. Anchoring a menu ABOVE its trigger (the status bar opens
187
300
  // upward) produces a negative top, and only the far edge used to be clamped.
188
301
  // The height is whatever the menu needs OR whatever the window allows, whichever is
@@ -192,7 +305,7 @@ export function contextMenu(state, ui) {
192
305
  const x = Math.max(8, Math.min(m.x, window.innerWidth - 220));
193
306
  const y = Math.max(8, Math.min(m.y, window.innerHeight - wanted));
194
307
  return div({},
195
- div({ className: 'scrim', $styling: { background: 'transparent' } }).on({ click: () => wb.closeContextMenu(), contextmenu: (e) => { e.preventDefault(); wb.closeContextMenu(); } }),
308
+ div({ className: 'scrim', $styling: { background: 'transparent' } }).on({ click: () => ui.engine.dispatch(new CloseContextMenuAction()), contextmenu: (e) => { e.preventDefault(); ui.engine.dispatch(new CloseContextMenuAction()); } }),
196
309
  div({ className: 'menu', $styling: { left: x + 'px', top: y + 'px' } },
197
310
  ...m.items.map((it, i) =>
198
311
  it.sep
@@ -205,9 +318,9 @@ export function contextMenu(state, ui) {
205
318
  span(it.label),
206
319
  it.kbd ? span({ className: 'kbd' }, it.kbd) : null,
207
320
  ).on({
208
- click: () => { wb.closeContextMenu(); it.run?.(); },
321
+ click: () => { ui.engine.dispatch(new CloseContextMenuAction()); activate(ui, it); },
209
322
  // The first item is focused on open, so Escape has to get you back out.
210
- keydown: (e) => { if (e.key === 'Escape') { e.preventDefault(); wb.closeContextMenu(); } },
323
+ keydown: (e) => { if (e.key === 'Escape') { e.preventDefault(); ui.engine.dispatch(new CloseContextMenuAction()); } },
211
324
  $attach: (el) => { if (i === 0) queueMicrotask(() => el.focus()); },
212
325
  }),
213
326
  ),
@@ -224,7 +337,7 @@ export function toasts(state, ui) {
224
337
  div({ className: `toast ${n.level}` },
225
338
  div({ className: 'bar' }),
226
339
  div({ className: 'msg' }, n.message),
227
- button({ className: 'x' }, icon('close', { size: 14 })).on({ click: () => ui.platform.notifications.dismiss(n.id) }),
340
+ button({ className: 'x' }, icon('close', { size: 14 })).on({ click: () => ui.engine.dispatch(new DismissNotificationAction(n.id)) }),
228
341
  ).key(n.id),
229
342
  ),
230
343
  );
@@ -240,7 +353,7 @@ export function transferTray(state, ui) {
240
353
  span({ $styling: { 'margin-left': '6px' } }, 'Transfers'),
241
354
  div({ className: 'actions' },
242
355
  button({ className: 'iconbtn', title: 'Clear finished' }, icon('check', { size: 14 }))
243
- .on({ click: () => ui.app.transfers.clearDone() }),
356
+ .on({ click: () => ui.engine.dispatch(new ClearFinishedTransfersAction()) }),
244
357
  ),
245
358
  ),
246
359
  div({ className: 'items' },
@@ -250,13 +363,26 @@ export function transferTray(state, ui) {
250
363
  icon(t.status === 'error' ? 'warn' : t.status === 'done' ? 'check' : 'upload', { size: 14 }),
251
364
  span({ className: 'name' }, t.name),
252
365
  t.status === 'active'
253
- ? button({ className: 'iconbtn', title: 'Cancel' }, icon('close', { size: 13 })).on({ click: () => ui.app.transfers.cancel(t.id) })
366
+ ? button({ className: 'iconbtn', title: 'Cancel' }, icon('close', { size: 13 })).on({ click: () => ui.engine.dispatch(new CancelTransferAction(t.id)) })
254
367
  : span({ className: 'pct' }, t.status === 'done' ? bytes(t.total) : t.status),
255
368
  ),
256
369
  t.status === 'active'
257
370
  ? div({ className: 'progress' }, div({ $styling: { width: `${Math.round(t.ratio * 100)}%` } }))
258
371
  : t.error ? div({ $styling: { 'font-size': '11px', color: 'var(--danger)', 'margin-top': '4px' } }, t.error) : null,
259
372
  t.status === 'active' ? div({ className: 'pct', $styling: { 'margin-top': '4px' } }, `${bytes(t.loaded)} / ${bytes(t.total)}`) : null,
373
+ // Offered on anything that stopped short, cancelled included — the transport
374
+ // already retries what is worth retrying on its own, so a row that reached this
375
+ // state failed for a reason another automatic attempt would not fix. A lost
376
+ // upload session is the case in point: correctly non-retryable, and correctly
377
+ // something the user can choose to start over.
378
+ (t.status === 'error' || t.status === 'cancelled') && t.retryable
379
+ ? div({ className: 'xfer-actions' },
380
+ button({ className: 'btn small' }, icon('refresh', { size: 12 }), span('Retry'))
381
+ .on({ click: () => ui.engine.dispatch(new RetryTransferAction(t.id)) }),
382
+ button({ className: 'btn small ghost' }, 'Dismiss')
383
+ .on({ click: () => ui.engine.dispatch(new DismissTransferAction(t.id)) }),
384
+ )
385
+ : null,
260
386
  ).key(t.id),
261
387
  ),
262
388
  ),
@@ -272,7 +398,7 @@ export function pluginPanel(state, ui) {
272
398
  div({ className: 'head' },
273
399
  icon('plug', { size: 14 }),
274
400
  span(plugin?.name || pid),
275
- button({ className: 'iconbtn x' }, icon('close', { size: 14 })).on({ click: () => ui.platform.workbench.closePluginPanel() }),
401
+ button({ className: 'iconbtn x' }, icon('close', { size: 14 })).on({ click: () => ui.engine.dispatch(new ClosePluginPanelAction()) }),
276
402
  ),
277
403
  // The host mounts the plugin's iframe into this element on attach.
278
404
  div({ className: 'host' }).on({
@@ -14,7 +14,8 @@
14
14
  import { dd } from '../../runtime.js';
15
15
  import { icon } from '../icon.js';
16
16
  import { bytes } from '../format.js';
17
- import { statusFacts, usageChip } from './statusBar.js';
17
+ import { usageChip } from './statusBar.js';
18
+ import { CloseSheetAction, ExecCommandAction, OpenSheetAction, ToggleActivityPanelAction, ToggleInboxAction } from '../../bl/actions.js';
18
19
 
19
20
  const { div, button, span, img } = dd;
20
21
 
@@ -26,18 +27,22 @@ const TABS = [
26
27
  { id: 'plugins', icon: 'plug', label: 'Plugins', command: 'workbench.view.plugins' },
27
28
  ];
28
29
 
29
- /** Which single glyph best describes the drive's state right now. */
30
- function statusGlyph(f) {
31
- if (!f.off.online) return { icon: 'info', tone: 'warn', label: 'Offline' };
32
- if (f.issues.length) return { icon: 'warn', tone: 'danger', label: `${f.issues.length} need attention`, badge: f.issues.length };
33
- if (f.running.length || f.uploading.length || f.off.syncing) return { spinner: true, tone: '', label: 'Working…' };
34
- if (f.usage?.total && f.usage.available / f.usage.total < 0.1) return { icon: 'info', tone: 'warn', label: 'Low on space' };
35
- return { icon: 'info', tone: '', label: 'Status' };
36
- }
30
+ // How to DRAW each condition. Which condition is the one worth showing offline over a
31
+ // standing problem, a standing problem over work in progress — is decided by the
32
+ // `statusFacts` query, because that ordering is a claim about what a person most needs to
33
+ // know rather than a question about glyphs. See bl/status.js.
34
+ const GLYPHS = {
35
+ offline: { icon: 'info', tone: 'warn' },
36
+ issues: { icon: 'warn', tone: 'danger' },
37
+ working: { spinner: true, tone: '' },
38
+ lowSpace: { icon: 'info', tone: 'warn' },
39
+ idle: { icon: 'info', tone: '' },
40
+ };
37
41
 
38
42
  export function phoneTopBar(state, ui) {
39
- const f = statusFacts(state, ui);
40
- const g = statusGlyph(f);
43
+ const f = state.facts;
44
+ const c = f.condition;
45
+ const g = { ...(GLYPHS[c.kind] || GLYPHS.idle), label: c.label, badge: c.kind === 'issues' ? c.count : 0 };
41
46
  // Where you are, at the coarsest level. Deliberately NOT the open file's name: the
42
47
  // viewer's own breadcrumb sits directly beneath this bar and already says that, and two
43
48
  // rows of chrome repeating "notes.txt" costs a fifth of the screen to say it twice.
@@ -46,14 +51,14 @@ export function phoneTopBar(state, ui) {
46
51
  : f.collectionLabel;
47
52
  return div({ className: 'phonebar top' },
48
53
  button({ className: 'pb-brand', title: 'Trove — home' }, img({ src: '/icon.svg', alt: 'Trove' }))
49
- .on({ click: () => ui.exec('workbench.view.home') }),
54
+ .on({ click: () => ui.engine.dispatch(new ExecCommandAction('workbench.view.home')) }),
50
55
  div({ className: 'pb-title' }, title),
51
56
  button({ className: `pb-status ${g.tone}`, title: g.label, $attrs: { 'aria-label': g.label } },
52
57
  g.spinner
53
58
  ? div({ className: 'spinner', $styling: { width: '15px', height: '15px' } })
54
59
  : icon(g.icon, { size: 18 }),
55
60
  g.badge ? span({ className: 'pb-badge' }, String(g.badge > 9 ? '9+' : g.badge)) : null,
56
- ).on({ click: () => ui.platform.workbench.openSheet('status') }),
61
+ ).on({ click: () => ui.engine.dispatch(new OpenSheetAction('status')) }),
57
62
  );
58
63
  }
59
64
 
@@ -66,7 +71,7 @@ export function phoneBottomBar(state, ui) {
66
71
  className: `pb-tab ${!sheet && active === t.id ? 'active' : ''}`,
67
72
  $attrs: { 'aria-label': t.label },
68
73
  }, icon(t.icon, { size: 21 }), span({ className: 'pb-label' }, t.label))
69
- .on({ click: () => { ui.platform.workbench.closeSheet(); ui.exec(t.command); } })),
74
+ .on({ click: () => { ui.engine.dispatch(new CloseSheetAction()); ui.engine.dispatch(new ExecCommandAction(t.command)); } })),
70
75
  // Settings is reached FROM the More sheet, so while you are in it "More" is where
71
76
  // you are — otherwise all four tabs read as inactive and the bar claims you are
72
77
  // nowhere.
@@ -77,7 +82,7 @@ export function phoneBottomBar(state, ui) {
77
82
  icon('dots', { size: 21 }),
78
83
  unread ? span({ className: 'pb-badge' }, String(unread > 9 ? '9+' : unread)) : null,
79
84
  span({ className: 'pb-label' }, 'More'),
80
- ).on({ click: () => ui.platform.workbench.openSheet('more') }),
85
+ ).on({ click: () => ui.engine.dispatch(new OpenSheetAction('more')) }),
81
86
  );
82
87
  }
83
88
 
@@ -97,20 +102,18 @@ function sheetRow({ icon: name, label, value, danger, onClick }) {
97
102
  export function phoneSheet(state, ui) {
98
103
  const which = state.wb.sheet;
99
104
  if (!which) return null;
100
- const wb = ui.platform.workbench;
101
105
  return div({ className: 'sheet-wrap' },
102
- div({ className: 'scrim' }).on({ click: () => wb.closeSheet() }),
106
+ div({ className: 'scrim' }).on({ click: () => ui.engine.dispatch(new CloseSheetAction()) }),
103
107
  div({ className: 'sheet' },
104
- div({ className: 'sheet-grip' }).on({ click: () => wb.closeSheet() }),
108
+ div({ className: 'sheet-grip' }).on({ click: () => ui.engine.dispatch(new CloseSheetAction()) }),
105
109
  which === 'status' ? statusSheet(state, ui) : moreSheet(state, ui),
106
110
  ),
107
111
  );
108
112
  }
109
113
 
110
114
  function statusSheet(state, ui) {
111
- const f = statusFacts(state, ui);
112
- const wb = ui.platform.workbench;
113
- const go = (cmd) => () => { wb.closeSheet(); ui.exec(cmd); };
115
+ const f = state.facts;
116
+ const go = (cmd) => () => { ui.engine.dispatch(new CloseSheetAction()); ui.engine.dispatch(new ExecCommandAction(cmd)); };
114
117
  return div({ className: 'sheet-body' },
115
118
  div({ className: 'sheet-title' }, f.collectionLabel),
116
119
 
@@ -119,14 +122,14 @@ function statusSheet(state, ui) {
119
122
  ? sheetRow({
120
123
  icon: 'warn', danger: true,
121
124
  label: `${f.issues.length} need${f.issues.length === 1 ? 's' : ''} attention`,
122
- onClick: () => { wb.closeSheet(); ui.app.activity.togglePanel(true); },
125
+ onClick: () => { ui.engine.dispatch(new CloseSheetAction()); ui.engine.dispatch(new ToggleActivityPanelAction(true)); },
123
126
  })
124
127
  : null,
125
128
  f.running.length || f.uploading.length
126
129
  ? sheetRow({
127
130
  icon: 'refresh',
128
131
  label: `${f.running.length + f.uploading.length} running`,
129
- onClick: () => { wb.closeSheet(); ui.app.activity.togglePanel(true); },
132
+ onClick: () => { ui.engine.dispatch(new CloseSheetAction()); ui.engine.dispatch(new ToggleActivityPanelAction(true)); },
130
133
  })
131
134
  : null,
132
135
  !f.off.online ? sheetRow({ icon: 'info', label: 'Offline', value: `${f.off.pins.length} pinned` }) : null,
@@ -146,26 +149,25 @@ function statusSheet(state, ui) {
146
149
  ? div({ className: 'sheet-row static usage' },
147
150
  icon('download', { size: 18 }),
148
151
  span({ className: 'sr-label' }, 'Free space'),
149
- usageChip({ usage: f.usage }))
152
+ usageChip(f))
150
153
  : null,
151
- f.caps ? sheetRow({
152
- icon: f.caps.storage?.presignDownload ? 'download' : 'files',
154
+ state.caps ? sheetRow({
155
+ icon: state.caps.storage?.presignDownload ? 'download' : 'files',
153
156
  label: 'Transfers',
154
- value: f.caps.storage?.presignDownload ? 'Direct to storage' : 'Through the server',
157
+ value: state.caps.storage?.presignDownload ? 'Direct to storage' : 'Through the server',
155
158
  }) : null,
156
- f.caps?.features?.semanticSearch ? sheetRow({ icon: 'star', label: 'Search', value: 'Semantic + keyword' }) : null,
159
+ state.caps?.features?.semanticSearch ? sheetRow({ icon: 'star', label: 'Search', value: 'Semantic + keyword' }) : null,
157
160
 
158
161
  div({ className: 'sheet-actions' },
159
162
  button({ className: 'btn small ghost' }, icon('refresh', { size: 13 }), span('Scan for changes')).on({ click: go('workbench.scanCollection') }),
160
163
  button({ className: 'btn small ghost' }, icon('refresh', { size: 13 }), span('Activity'))
161
- .on({ click: () => { ui.platform.workbench.closeSheet(); ui.app.activity.togglePanel(true); } }),
164
+ .on({ click: () => { ui.engine.dispatch(new CloseSheetAction()); ui.engine.dispatch(new ToggleActivityPanelAction(true)); } }),
162
165
  ),
163
166
  );
164
167
  }
165
168
 
166
169
  function moreSheet(state, ui) {
167
- const wb = ui.platform.workbench;
168
- const go = (cmd) => () => { wb.closeSheet(); ui.exec(cmd); };
170
+ const go = (cmd) => () => { ui.engine.dispatch(new CloseSheetAction()); ui.engine.dispatch(new ExecCommandAction(cmd)); };
169
171
  const me = state.so.me;
170
172
  const unread = state.so.notifications.unread;
171
173
  return div({ className: 'sheet-body' },
@@ -177,8 +179,8 @@ function moreSheet(state, ui) {
177
179
  div({}, div({ className: 'sm-name' }, me.name || me.id), me.email ? div({ className: 'sm-sub' }, me.email) : null))
178
180
  : null,
179
181
 
180
- sheetRow({ icon: 'bell', label: unread ? `Notifications (${unread})` : 'Notifications', onClick: () => { wb.closeSheet(); ui.app.social.toggleInbox(true); } }),
181
- sheetRow({ icon: 'refresh', label: 'Activity & problems', onClick: () => { wb.closeSheet(); ui.app.activity.togglePanel(true); } }),
182
+ sheetRow({ icon: 'bell', label: unread ? `Notifications (${unread})` : 'Notifications', onClick: () => { ui.engine.dispatch(new CloseSheetAction()); ui.engine.dispatch(new ToggleInboxAction(true)); } }),
183
+ sheetRow({ icon: 'refresh', label: 'Activity & problems', onClick: () => { ui.engine.dispatch(new CloseSheetAction()); ui.engine.dispatch(new ToggleActivityPanelAction(true)); } }),
182
184
  sheetRow({ icon: 'info', label: 'Details & conversation', onClick: go('workbench.toggleInfoPanel') }),
183
185
  sheetRow({ icon: 'trash', label: 'Trash', onClick: go('explorer.showTrash') }),
184
186
  sheetRow({ icon: 'refresh', label: 'Refresh', onClick: go('explorer.refresh') }),