@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
@@ -5,8 +5,11 @@
5
5
  import { dd } from '../../runtime.js';
6
6
  import { icon } from '../icon.js';
7
7
  import { relativeDate } from '../format.js';
8
- import { OpenFileAction } from '../../bl/actions.js';
8
+ import { AddTagAction, CopyTextAction, DeleteCommentAction, EnablePushAction, ExecCommandAction, LoadSidecarAction, OpenFileAction, OpenNotificationTargetAction, PostCommentAction, ReactToCommentAction, RemoveTagAction, SetReplyToAction, ToggleInboxAction, ToggleInfoPanelAction } from '../../bl/actions.js';
9
9
  import { troveUri } from '@3sln/trove/core/links.js';
10
+ import { parseMentions } from '../../bl/mentions.js';
11
+ import { sidecarFor } from '../../bl/queries.js';
12
+ import { watchQuery } from '../../bl/watchQuery.js';
10
13
 
11
14
  const { div, span, button, textarea, input, p } = dd;
12
15
 
@@ -28,26 +31,24 @@ export function principalChip(state) {
28
31
 
29
32
  export function notificationBell(state, ui) {
30
33
  const n = state.so.notifications;
31
- const social = ui.app.social;
32
34
  return div({ className: 'bell-wrap' },
33
35
  button({ className: 'iconbtn bell', title: 'Notifications' },
34
36
  icon('bell', { size: 19 }),
35
37
  n.unread ? span({ className: 'bell-badge' }, String(n.unread > 9 ? '9+' : n.unread)) : null,
36
- ).on({ click: () => social.toggleInbox() }),
38
+ ).on({ click: () => ui.engine.dispatch(new ToggleInboxAction()) }),
37
39
  state.so.inboxOpen ? inboxDropdown(state, ui) : null,
38
40
  );
39
41
  }
40
42
 
41
43
  function inboxDropdown(state, ui) {
42
44
  const items = state.so.notifications.items;
43
- const social = ui.app.social;
44
45
  return div({},
45
- div({ className: 'scrim', $styling: { background: 'transparent', 'z-index': '54' } }).on({ click: () => social.toggleInbox(false) }),
46
+ div({ className: 'scrim', $styling: { background: 'transparent', 'z-index': '54' } }).on({ click: () => ui.engine.dispatch(new ToggleInboxAction(false)) }),
46
47
  div({ className: 'inbox' },
47
48
  div({ className: 'inbox-head' },
48
49
  span('Notifications'),
49
50
  state.so.pushSupported && !state.so.pushEnabled
50
- ? button({ className: 'link' }, 'Enable push').on({ click: () => social.enablePush() })
51
+ ? button({ className: 'link' }, 'Enable push').on({ click: () => ui.engine.dispatch(new EnablePushAction()) })
51
52
  : state.so.pushEnabled ? span({ className: 'muted' }, icon('check', { size: 13 })) : null,
52
53
  ),
53
54
  items.length
@@ -65,21 +66,10 @@ function inboxItem(note, ui) {
65
66
  div({ className: 'ii-time' }, relativeDate(note.createdAt)),
66
67
  ).on({
67
68
  click: () => {
68
- if (first?.nodeId) {
69
- ui.platform.api.stat(first.nodeId).then((r) => {
70
- ui.go(new OpenFileAction(r.node));
71
- ui.platform.workbench.toggleInfoPanel(true);
72
- }).catch((err) => {
73
- // The item a notification points at can be deleted, or live somewhere the
74
- // reader lost access to. Either way the click must not just do nothing.
75
- ui.platform.notifications.warn(
76
- err?.status === 403 || err?.code === 'forbidden'
77
- ? 'You no longer have access to that item.'
78
- : 'That item no longer exists.',
79
- );
80
- });
81
- }
82
- ui.app.social.toggleInbox(false);
69
+ // One intent, one action — including what to say when the item a notification
70
+ // points at is gone. See OpenNotificationTargetAction.
71
+ if (first?.nodeId) ui.engine.dispatch(new OpenNotificationTargetAction(first.nodeId));
72
+ ui.engine.dispatch(new ToggleInboxAction(false));
83
73
  },
84
74
  });
85
75
  }
@@ -89,26 +79,31 @@ function inboxItem(note, ui) {
89
79
  export function infoPanel(state, ui) {
90
80
  const nav = state.nav;
91
81
  const active = nav.activeFile ? { id: nav.activeTabId, node: nav.activeFile } : null;
92
- const sc = state.so.sidecar;
93
82
  return div({ className: 'infopanel' },
94
83
  div({ className: 'ip-head' },
95
84
  icon('info', { size: 15 }),
96
85
  span('Details'),
97
- button({ className: 'iconbtn', title: 'Close' }, icon('close', { size: 14 })).on({ click: () => ui.platform.workbench.toggleInfoPanel(false) }),
86
+ button({ className: 'iconbtn', title: 'Close' }, icon('close', { size: 14 })).on({ click: () => ui.engine.dispatch(new ToggleInfoPanelAction(false)) }),
98
87
  ),
99
88
  !active
100
89
  ? div({ className: 'ip-empty' }, span('Open a file to see its tags and conversation.'))
101
- : div({ className: 'ip-body' },
102
- fileHeader(active.node, state, ui),
103
- linkSection(active.node, state, ui),
104
- tagSection(sc, ui),
105
- conversationSection(state, sc, ui),
106
- ),
90
+ // Watching `sidecarFor(id)` is what LOADS it: the query's bootAction fetches when the
91
+ // first observer arrives and its killAction clears when the last one leaves. Nothing
92
+ // watches the nav stack to notice a file changed — a different file is a different
93
+ // query. See bl/queries.js.
94
+ : ui.watch(watchQuery(ui.engine, sidecarFor(active.node.id)), (sc) => div({ className: 'ip-body' },
95
+ fileHeader(active.node, state, ui),
96
+ linkSection(active.node, state, ui),
97
+ tagSection(sc, ui),
98
+ conversationSection(state, sc, ui),
99
+ )),
107
100
  );
108
101
  }
109
102
 
110
103
  function fileHeader(node, state, ui) {
111
- const pinned = ui.app.offline.isPinned(node.id);
104
+ // From the offline view, which resolves the pin set — the component asks whether this
105
+ // file is pinned without knowing that pins are a list to be scanned.
106
+ const pinned = state.off?.pinnedIds?.has(node.id) ?? false;
112
107
  return div({ className: 'ip-file' },
113
108
  div({ className: 'ip-name' }, node.name),
114
109
  div({ className: 'ip-path' }, node.contentType || ''),
@@ -118,7 +113,7 @@ function fileHeader(node, state, ui) {
118
113
  button({ className: `btn small ${pinned ? 'on' : ''}`, $styling: { 'margin-top': '10px' } },
119
114
  icon(pinned ? 'check' : 'download', { size: 14 }),
120
115
  pinned ? 'Available offline' : 'Make available offline',
121
- ).on({ click: () => (pinned ? ui.exec('offline.unpin', node) : ui.exec('offline.pin', node)) }),
116
+ ).on({ click: () => (pinned ? ui.engine.dispatch(new ExecCommandAction('offline.unpin', node)) : ui.engine.dispatch(new ExecCommandAction('offline.pin', node))) }),
122
117
  );
123
118
  }
124
119
 
@@ -147,18 +142,13 @@ function linkSection(node, state, ui) {
147
142
  : mine?.items?.length
148
143
  ? div({ className: 'ip-backlinks' }, ...mine.items.map((n) =>
149
144
  button({ className: 'ip-backlink', title: n.name }, icon('file-text', { size: 12 }), span(n.name))
150
- .on({ click: () => ui.go(new OpenFileAction(n)) })))
145
+ .on({ click: () => ui.engine.dispatch(new OpenFileAction(n)) })))
151
146
  : div({ className: 'ip-muted' }, 'Nothing links here yet.'),
152
147
  );
153
148
  }
154
149
 
155
- async function copyLink(ui, uri) {
156
- try {
157
- await navigator.clipboard.writeText(uri);
158
- ui.platform.notifications.success(`Copied ${uri}`);
159
- } catch {
160
- ui.platform.notifications.info(uri, { sticky: true });
161
- }
150
+ function copyLink(ui, uri) {
151
+ ui.engine.dispatch(new CopyTextAction(uri, `Copied ${uri}`));
162
152
  }
163
153
 
164
154
  function tagSection(sc, ui) {
@@ -169,7 +159,7 @@ function tagSection(sc, ui) {
169
159
  ...tags.map((t) =>
170
160
  span({ className: 'tag' },
171
161
  t.value ? `${t.name}: ${t.value}` : t.name,
172
- button({ className: 'tag-x' }, icon('close', { size: 11 })).on({ click: () => ui.app.social.removeTag(t.name) }),
162
+ button({ className: 'tag-x' }, icon('close', { size: 11 })).on({ click: () => ui.engine.dispatch(new RemoveTagAction(t.name)) }),
173
163
  ),
174
164
  ),
175
165
  input({ className: 'tag-input', placeholder: '+ tag', value: '' }).on({
@@ -177,7 +167,7 @@ function tagSection(sc, ui) {
177
167
  if (e.key === 'Enter' && e.target.value.trim()) {
178
168
  const raw = e.target.value.trim();
179
169
  const [name, ...rest] = raw.split(':');
180
- ui.app.social.addTag(name.trim(), rest.join(':').trim() || undefined);
170
+ ui.engine.dispatch(new AddTagAction(name.trim(), rest.join(':').trim() || undefined));
181
171
  e.target.value = '';
182
172
  }
183
173
  },
@@ -194,7 +184,7 @@ function conversationSection(state, sc, ui) {
194
184
  const body = sc?.error
195
185
  ? div({ className: 'conv-empty' },
196
186
  span(`Couldn't load the conversation: ${sc.error}`),
197
- button({ className: 'btn', $styling: { 'margin-top': '8px' } }, 'Retry').on({ click: () => ui.app.social.loadSidecar(sc.nodeId) }))
187
+ button({ className: 'btn', $styling: { 'margin-top': '8px' } }, 'Retry').on({ click: () => ui.engine.dispatch(new LoadSidecarAction(sc.nodeId)) }))
198
188
  : comments.length
199
189
  ? div({ className: 'thread' }, ...comments.map((c) => commentNode(c, state, ui, 0)))
200
190
  : div({ className: 'conv-empty' }, 'No comments yet. Start the discussion — use @ to mention someone.');
@@ -207,7 +197,6 @@ function conversationSection(state, sc, ui) {
207
197
 
208
198
  function commentNode(c, state, ui, depth) {
209
199
  const me = state.so.me;
210
- const social = ui.app.social;
211
200
  const mine = me && c.author?.id === me.id;
212
201
  return div({ className: 'comment', $styling: depth ? { marginLeft: '18px' } : {} },
213
202
  div({ className: 'c-head' },
@@ -222,17 +211,16 @@ function commentNode(c, state, ui, depth) {
222
211
  ...REACTIONS.map((emoji) => {
223
212
  const users = c.reactions?.[emoji] || [];
224
213
  return button({ className: `react ${users.includes(me?.id) ? 'on' : ''}` }, emoji, users.length ? span({ className: 'rc' }, String(users.length)) : null)
225
- .on({ click: () => social.react(c.id, emoji) });
214
+ .on({ click: () => ui.engine.dispatch(new ReactToCommentAction(c.id, emoji)) });
226
215
  }),
227
- button({ className: 'c-link' }, 'Reply').on({ click: () => social.setReplyTo({ id: c.id, author: c.author }) }),
228
- mine ? button({ className: 'c-link danger' }, 'Delete').on({ click: () => social.deleteComment(c.id) }) : null,
216
+ button({ className: 'c-link' }, 'Reply').on({ click: () => ui.engine.dispatch(new SetReplyToAction({ id: c.id, author: c.author })) }),
217
+ mine ? button({ className: 'c-link danger' }, 'Delete').on({ click: () => ui.engine.dispatch(new DeleteCommentAction(c.id)) }) : null,
229
218
  ) : null,
230
219
  (c.replies || []).length ? div({ className: 'replies' }, ...c.replies.map((r) => commentNode(r, state, ui, depth + 1))) : null,
231
220
  ).key(c.id);
232
221
  }
233
222
 
234
223
  function composer(state, ui) {
235
- const social = ui.app.social;
236
224
  const me = state.so.me;
237
225
  const replyTo = state.so.replyTo;
238
226
  // `me` is null only when /api/me didn't answer — offline, or refused. It is NOT the
@@ -252,11 +240,11 @@ function composer(state, ui) {
252
240
  }
253
241
  return div({ className: 'composer' },
254
242
  replyTo ? div({ className: 'replying' }, `Replying to ${replyTo.author?.name || 'comment'}`,
255
- button({ className: 'c-link' }, 'cancel').on({ click: () => social.setReplyTo(null) })) : null,
243
+ button({ className: 'c-link' }, 'cancel').on({ click: () => ui.engine.dispatch(new SetReplyToAction(null)) })) : null,
256
244
  textarea({ className: 'composer-input', placeholder: 'Write a comment… @mention someone', rows: 2, value: '' }).on({
257
245
  keydown: (e) => {
258
246
  if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
259
- social.comment(e.target.value);
247
+ ui.engine.dispatch(new PostCommentAction(e.target.value));
260
248
  e.target.value = '';
261
249
  }
262
250
  },
@@ -266,7 +254,7 @@ function composer(state, ui) {
266
254
  button({ className: 'btn primary', disabled: state.so.posting }, 'Comment').on({
267
255
  click: (e) => {
268
256
  const box = e.target.closest('.composer').querySelector('.composer-input');
269
- social.comment(box.value);
257
+ ui.engine.dispatch(new PostCommentAction(box.value));
270
258
  box.value = '';
271
259
  },
272
260
  }),
@@ -274,17 +262,9 @@ function composer(state, ui) {
274
262
  );
275
263
  }
276
264
 
277
- // Render @[Name](id) tokens and bare @handles as highlighted mentions.
265
+ // WHERE the mentions are is parsed in bl/mentions.js, beside the `#tag` parser it is a
266
+ // sibling of. All that is left here is the decision to draw one as a chip.
278
267
  function renderBody(text) {
279
- const out = [];
280
- const re = /@\[([^\]]+)\]\(([^)]+)\)|(?:^|\s)@([a-zA-Z0-9._@-]{2,})/g;
281
- let last = 0;
282
- let m;
283
- while ((m = re.exec(text))) {
284
- if (m.index > last) out.push(text.slice(last, m.index + (m[3] ? (m[0].startsWith(' ') ? 1 : 0) : 0)));
285
- out.push(span({ className: 'mention' }, '@' + (m[1] || m[3])));
286
- last = re.lastIndex;
287
- }
288
- if (last < text.length) out.push(text.slice(last));
289
- return out;
268
+ return parseMentions(text).map((part) =>
269
+ (part.type === 'mention' ? span({ className: 'mention' }, `@${part.name}`) : part.value));
290
270
  }
@@ -2,6 +2,7 @@ import { dd } from '../../runtime.js';
2
2
  import { icon } from '../icon.js';
3
3
  import { bytes } from '../format.js';
4
4
  import { sanitizedVNodes, htmlToText } from '../sanitize.js';
5
+ import { CancelTransferAction, ExecCommandAction, ShowContextMenuAction, ToggleActivityPanelAction } from '../../bl/actions.js';
5
6
 
6
7
  const { div, button, span } = dd;
7
8
 
@@ -12,15 +13,17 @@ const { div, button, span } = dd;
12
13
  * A slot with nothing in it, or one whose `when` doesn't hold, renders nothing at all
13
14
  * rather than an empty chip.
14
15
  */
16
+ // The item arrives already decided — see the statusItems view query. Whether it should
17
+ // show at all (`visible`, its `when` clause, whether the plugin behind it is responding) is
18
+ // resolved on the query side now, which is what let this stop reaching into `platform`
19
+ // mid-render. `html` is still untrusted plugin markup: a query emitting plain data says
20
+ // nothing about that data being safe, so it is still sanitised here.
15
21
  function statusSlot(item, ui) {
16
- if (item.visible === false || !item.html) return null;
17
- if (item.when && !ui.platform.context.evaluate(item.when)) return null;
18
- if (!ui.platform.plugins.isAvailable(item)) return null;
19
22
  const content = sanitizedVNodes(item.html, dd);
20
23
  if (!content.length) return null;
21
24
  const title = item.tooltip ? htmlToText(item.tooltip) : '';
22
25
  return item.command
23
- ? button({ className: 'seg', title }, ...content).on({ click: () => ui.exec(item.command) })
26
+ ? button({ className: 'seg', title }, ...content).on({ click: () => ui.engine.dispatch(new ExecCommandAction(item.command)) })
24
27
  : span({ className: 'seg', title }, ...content);
25
28
  }
26
29
 
@@ -35,7 +38,7 @@ function statusSlot(item, ui) {
35
38
  export function activityChips(state, ui) {
36
39
  const act = state.act || { tasks: [], issues: [] };
37
40
  const running = act.tasks.filter((t) => t.status === 'running');
38
- const open = () => ui.app.activity.togglePanel(true);
41
+ const open = () => ui.engine.dispatch(new ToggleActivityPanelAction(true));
39
42
  const chips = [];
40
43
 
41
44
  if (running.length) {
@@ -71,12 +74,9 @@ export function activityChips(state, ui) {
71
74
  * meter that means nothing. The bar turns amber under 10% free and red under 5%, which
72
75
  * is early enough to act on.
73
76
  */
74
- export function usageChip(ex) {
75
- const u = ex.usage;
77
+ export function usageChip({ usage: u, usageLevel: level = '' }) {
76
78
  if (!u?.total) return null;
77
- const freeRatio = u.available / u.total;
78
79
  const pct = Math.min(100, Math.round((u.used / u.total) * 100));
79
- const level = freeRatio < 0.05 ? 'critical' : freeRatio < 0.1 ? 'low' : '';
80
80
  return span({
81
81
  className: `seg sb-usage ${level}`,
82
82
  title: `${bytes(u.used)} used of ${bytes(u.total)} — ${bytes(u.available)} free on this volume`,
@@ -85,63 +85,19 @@ export function usageChip(ex) {
85
85
  span(`${bytes(u.available)} free`));
86
86
  }
87
87
 
88
- /**
89
- * Everything the status bar reports, derived once.
90
- *
91
- * Two shells render these: the desktop as a row of segments along the bottom, the phone
92
- * as rows in a sheet behind a single icon. Deriving them in one place is what stops the
93
- * two from quietly telling the user different things about the same drive.
94
- */
95
- export function statusFacts(state, ui) {
96
- const ex = state.ex;
97
- const items = ex.items || [];
98
- const act = state.act || { tasks: [], issues: [] };
99
- return {
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),
109
- // The COLLECTION's totals when the server could give them, not the page's. Summing
110
- // what happens to be loaded reports a 3,000-file drive as 500 files — a wrong number,
111
- // not a rounded one. Falls back to the page only when the server didn't say.
112
- totalItems: ex.stats?.items ?? items.length,
113
- // Whether that total is the COLLECTION's or just the page we happen to hold. With
114
- // more pages waiting, the page length is a floor, not a total, and must read as one.
115
- totalKnown: ex.stats?.items != null,
116
- totalBytes: ex.stats?.bytes ?? items.reduce((n, i) => n + (i.size || 0), 0),
117
- shown: items.length,
118
- partial: !!ex.nextCursor,
119
- usage: ex.usage,
120
- uploading: state.tr.items.filter((t) => t.status === 'active'),
121
- running: act.tasks.filter((t) => t.status === 'running'),
122
- issues: act.issues,
123
- off: state.off || { online: true, pins: [], queued: 0, syncing: false },
124
- caps: ui.platform.capabilities,
125
- };
126
- }
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
-
135
88
  export default function statusBar(state, ui) {
136
89
  const ex = state.ex;
137
90
  const items = ex.items || [];
138
- const f = statusFacts(state, ui);
139
- const { totalItems, totalBytes, caps } = f;
91
+ // Derived by the `statusFacts` query — see bl/status.js. These used to be worked out
92
+ // here and imported INTO the phone chrome from this module, which is a business-layer
93
+ // derivation living in a component and being shared out of it.
94
+ const f = state.facts;
95
+ const { totalItems, totalBytes } = f;
96
+ const caps = state.caps;
140
97
  const active = f.uploading;
141
98
 
142
- // Plugin-contributed status slots, ordered by their declared `order` then name.
143
- const slots = [...(state.statusItems || [])]
144
- .sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.name.localeCompare(b.name));
99
+ // Plugin-contributed status slots. Already filtered and ordered by the query.
100
+ const slots = state.statusItems || [];
145
101
  const left = slots.filter((s) => s.slot === 'left').map((s) => statusSlot(s, ui));
146
102
  const right = slots.filter((s) => s.slot !== 'left').map((s) => statusSlot(s, ui));
147
103
 
@@ -161,34 +117,34 @@ export default function statusBar(state, ui) {
161
117
  icon('download', { size: 12 }), span(`${off.pins.length} offline`), icon('chevron-down', { size: 11 }))
162
118
  .on({ click: (e) => {
163
119
  const items = off.pins.slice(0, 12).flatMap((p) => [
164
- { label: p.name, icon: 'file-text', run: () => ui.exec('explorer.open', p) },
165
- { label: `Remove “${p.name}” from offline`, icon: 'close', run: () => ui.exec('offline.unpin', p) },
120
+ { label: p.name, icon: 'file-text', actions: [new ExecCommandAction('explorer.open', p)] },
121
+ { label: `Remove “${p.name}” from offline`, icon: 'close', actions: [new ExecCommandAction('offline.unpin', p)] },
166
122
  { sep: true },
167
123
  ]);
168
- if (off.pins.length > 12) items.push({ label: `…and ${off.pins.length - 12} more`, run: () => {} });
124
+ if (off.pins.length > 12) items.push({ label: `…and ${off.pins.length - 12} more`, actions: [] });
169
125
  const r = e.currentTarget.getBoundingClientRect();
170
- ui.platform.workbench.showContextMenu(r.left, r.top - 8 - items.length * 34, items);
126
+ ui.engine.dispatch(new ShowContextMenuAction(r.left, r.top - 8 - items.length * 34, items));
171
127
  } })
172
128
  : null,
173
129
  button({ className: 'seg', title: 'Switch collection' },
174
130
  // The NAME, and nothing invented. `|| 'default'` used to sit here, which meant the
175
131
  // status bar cheerfully named a collection on a drive that had none.
176
- icon('files', { size: 13 }), span(collectionLabel(ex)),
132
+ icon('files', { size: 13 }), span(f.collectionLabel),
177
133
  (ex.collections || []).length > 1 || ex.canCreateCollection ? icon('chevron-down', { size: 11 }) : null)
178
134
  .on({ click: (e) => {
179
- const items = ui.app.collectionMenu?.() || [];
135
+ const items = ex.collectionMenu || [];
180
136
  // With nowhere else to go, the segment is a label, not a dead menu.
181
137
  if (items.length > 1 || ex.canCreateCollection) {
182
138
  const r = e.currentTarget.getBoundingClientRect();
183
- ui.platform.workbench.showContextMenu(r.left, r.top - 8 - items.length * 34, items);
184
- } else ui.exec('workbench.view.home');
139
+ ui.engine.dispatch(new ShowContextMenuAction(r.left, r.top - 8 - items.length * 34, items));
140
+ } else ui.engine.dispatch(new ExecCommandAction('workbench.view.home'));
185
141
  } }),
186
142
  active.length
187
143
  ? button({ className: 'seg', title: 'Active uploads — click to cancel' }, div({ className: 'spinner', $styling: { width: '11px', height: '11px' } }), span(`${active.length} uploading`))
188
- .on({ click: (e) => ui.platform.workbench.showContextMenu(e.clientX, e.clientY, active.map((t) => ({
144
+ .on({ click: (e) => ui.engine.dispatch(new ShowContextMenuAction(e.clientX, e.clientY, active.map((t) => ({
189
145
  label: `Cancel ${t.name} (${Math.round((t.ratio || 0) * 100)}%)`, icon: 'close', danger: true,
190
- run: () => ui.app.transfers.cancel(t.id),
191
- }))) })
146
+ actions: [new CancelTransferAction(t.id)],
147
+ })))) })
192
148
  : span({ className: 'seg', title: ex.nextCursor ? `Showing ${items.length} of ${f.totalKnown ? totalItems : 'more'}` : '' },
193
149
  `${totalItems.toLocaleString()}${f.totalKnown || !f.partial ? '' : '+'} item${totalItems === 1 ? '' : 's'}`),
194
150
  ...left,
@@ -204,12 +160,12 @@ export default function statusBar(state, ui) {
204
160
  ? 'Total size of this collection'
205
161
  : `Size of the ${f.shown.toLocaleString()} items loaded so far — this server doesn’t report a collection total`,
206
162
  }, `${bytes(totalBytes)}${f.totalKnown || !f.partial ? '' : '+'}`),
207
- usageChip(ex),
163
+ usageChip(f),
208
164
  caps ? span({ className: 'seg', title: 'Storage backend capabilities' },
209
165
  icon(caps.storage?.presignDownload ? 'download' : 'files', { size: 12 }),
210
166
  span(caps.storage?.presignDownload ? 'S3 direct' : 'proxied'),
211
167
  ) : null,
212
168
  caps?.features?.semanticSearch ? button({ className: 'seg', title: 'Semantic search enabled' }, icon('star', { size: 12 }), span('semantic'))
213
- .on({ click: () => ui.exec('workbench.view.home') }) : null,
169
+ .on({ click: () => ui.engine.dispatch(new ExecCommandAction('workbench.view.home')) }) : null,
214
170
  );
215
171
  }
@@ -17,6 +17,7 @@ import { dd } from '../../../runtime.js';
17
17
  import { icon, iconForNode } from '../../icon.js';
18
18
  import { groupHeader, menuButton, openRowMenu } from './parts.js';
19
19
  import { attachMedia } from '../../media.js';
20
+ import { activate } from '../../activate.js';
20
21
 
21
22
  const { div, span, img } = dd;
22
23
 
@@ -24,7 +25,7 @@ export function gridView({ groups, index, handlers, ui }) {
24
25
  let gi = -1;
25
26
  return div({ className: 'launch-view view-grid' },
26
27
  ...groups.map((group) => div({ className: 'launch-group' },
27
- groupHeader(group),
28
+ groupHeader(group, ui),
28
29
  group.items.length
29
30
  ? div({ className: 'grid-list' }, ...group.items.map((it) => {
30
31
  const at = ++gi;
@@ -108,7 +109,7 @@ function tile(it, active, { hover, select }, ui) {
108
109
  ),
109
110
  div({ className: 'gt-name' }, it.title),
110
111
  ).on({
111
- click: it.run,
112
+ click: () => activate(ui, it),
112
113
  mouseenter: hover,
113
114
  ...(it.menu ? { contextmenu: (e) => { e.preventDefault(); openRowMenu(e.currentTarget, it, ui, e, select); } } : {}),
114
115
  });
@@ -20,6 +20,8 @@
20
20
 
21
21
  import { dd } from '../../../runtime.js';
22
22
  import { icon } from '../../icon.js';
23
+ import { SetSettingAction } from '../../../bl/actions.js';
24
+ import { SETTING } from '../../../bl/views.js';
23
25
  import { listView } from './list.js';
24
26
  import { gridView, gridMove } from './grid.js';
25
27
 
@@ -44,76 +46,19 @@ export function registerBuiltinViews(platform) {
44
46
  }
45
47
  }
46
48
 
47
- const SETTING = 'explorer.view';
48
49
 
49
- /**
50
- * Every view that could draw these results, best first.
51
- *
52
- * No availability check, unlike openers: a view is host code, so there is no "its
53
- * provider isn't answering" state to be in. A when-clause is the only gate, and it is
54
- * the same one every other contribution answers to.
55
- */
56
- export function availableViews(platform) {
57
- return platform.contributions
58
- .ofType('view')
59
- .filter((v) => !v.when || platform.context.evaluate(v.when))
60
- .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
61
- }
62
-
63
- /**
64
- * The view to draw with, in order of how much someone meant it.
65
- *
66
- * 1. A saved choice — they pressed a button. Nothing infers its way past that.
67
- * 2. The search transformer's hint, when there is a search on screen. It read the
68
- * sentence: "photos from the trip last summer" is a request for a gallery as much as
69
- * it is a query, and nothing downstream can recover that from a list of content types.
70
- * 3. A view whose `match` suits what is actually there — a collection of photographs
71
- * opens as a grid without anyone asking.
72
- * 4. The highest priority, which is the list.
73
- *
74
- * A hint naming a view this build doesn't have is ignored, not an error: the transformer
75
- * is deployment configuration and may outlive the build it was written against.
76
- */
77
- export function activeView(platform, items = [], hint = null) {
78
- const views = availableViews(platform);
79
- if (!views.length) return null;
80
- const saved = platform.settings.get(SETTING);
81
- const chosen = saved && views.find((v) => v.id === saved);
82
- if (chosen) return chosen;
83
- const suggested = hint && views.find((v) => v.id === hint);
84
- if (suggested) return suggested;
85
- const nodes = items.map((i) => i.node).filter(Boolean);
86
- if (nodes.length >= 3) {
87
- const suits = (v) => v.match && Object.keys(v.match).length
88
- && nodes.filter((n) => matchesView(v, n)).length / nodes.length > 0.6;
89
- const fitted = views.find(suits);
90
- if (fitted) return fitted;
91
- }
92
- return views[0];
93
- }
94
-
95
- function matchesView(view, node) {
96
- const ct = node.contentType || '';
97
- const name = (node.name || '').toLowerCase();
98
- const ext = name.includes('.') ? name.slice(name.lastIndexOf('.')) : '';
99
- if ((view.match.ext || []).includes(ext)) return true;
100
- return (view.match.mime || []).some((m) => (m.endsWith('/*') ? ct.startsWith(m.slice(0, -1)) : ct === m));
101
- }
102
-
103
- export function chooseView(platform, viewId) {
104
- platform.settings.set(SETTING, viewId || undefined);
105
- }
106
50
 
107
51
  /** The switcher, shown only when there is more than one way to look at this. */
108
- export function viewSwitcher(platform, current) {
109
- const views = availableViews(platform);
52
+ export function viewSwitcher(slice, current, ui) {
53
+ const views = slice?.views || [];
110
54
  if (views.length < 2) return null;
111
55
  return div({ className: 'view-switch' }, ...views.map((v) =>
112
56
  button({
113
57
  className: `vs-btn ${v.id === current?.id ? 'on' : ''}`,
114
- title: `${v.title || v.name} view`,
58
+ title: `${v.title} view`,
115
59
  'aria-pressed': v.id === current?.id ? 'true' : 'false',
116
- }, icon(v.icon || 'list', { size: 14 })).on({ click: () => chooseView(platform, v.id) })));
60
+ }, icon(v.icon || 'list', { size: 14 }))
61
+ .on({ click: () => ui.engine.dispatch(new SetSettingAction(SETTING, v.id)) })));
117
62
  }
118
63
 
119
64
  /**
@@ -8,6 +8,7 @@
8
8
  import { dd } from '../../../runtime.js';
9
9
  import { icon } from '../../icon.js';
10
10
  import { groupHeader, menuButton, openRowMenu } from './parts.js';
11
+ import { activate } from '../../activate.js';
11
12
 
12
13
  const { div, span } = dd;
13
14
 
@@ -15,7 +16,7 @@ export function listView({ groups, index, handlers, ui }) {
15
16
  let gi = -1;
16
17
  return div({ className: 'launch-view view-list' },
17
18
  ...groups.map((group) => div({ className: 'launch-group' },
18
- groupHeader(group),
19
+ groupHeader(group, ui),
19
20
  group.items.length
20
21
  ? div({ className: 'launch-list' }, ...group.items.map((it) => {
21
22
  const at = ++gi;
@@ -43,7 +44,7 @@ function itemRow(it, active, { hover, select }, ui) {
43
44
  // which is how adding `contextmenu` silently removed `click` and stopped every file
44
45
  // in the drive from opening.
45
46
  ).on({
46
- click: it.run,
47
+ click: () => activate(ui, it),
47
48
  mouseenter: hover,
48
49
  ...(it.menu ? { contextmenu: (e) => { e.preventDefault(); openRowMenu(e.currentTarget, it, ui, e, select); } } : {}),
49
50
  });
@@ -8,6 +8,8 @@
8
8
 
9
9
  import { dd } from '../../../runtime.js';
10
10
  import { icon } from '../../icon.js';
11
+ import { ShowContextMenuAction } from '../../../bl/actions.js';
12
+ import { activate } from '../../activate.js';
11
13
 
12
14
  const { div, span, button } = dd;
13
15
 
@@ -18,14 +20,23 @@ const { div, span, button } = dd;
18
20
  * the user typed — it turns their `#draft` into `#DRAFT`, a tag that isn't what they
19
21
  * wrote. So a group can carry a verbatim half.
20
22
  */
21
- export function groupHeader(group) {
23
+ export function groupHeader(group, ui) {
24
+ const controls = group.controls || [];
22
25
  return div({ className: 'launch-h' },
23
26
  // Label and value together on the left; `.launch-h` is space-between, so an
24
27
  // ungrouped value gets flung to the far edge away from the label it belongs to.
25
28
  div({ className: 'lh-title' },
26
29
  span(group.title),
27
30
  group.verbatim ? span({ className: 'lh-verbatim' }, group.verbatim) : null),
28
- group.action || null,
31
+ // Descriptions, not a pre-rendered button. A group used to carry a vnode in `action`,
32
+ // which meant the thing deciding WHAT a header offers had to be able to draw one — the
33
+ // same reason menu items stopped carrying `run` closures. See ui/activate.js.
34
+ controls.length
35
+ ? div({ className: 'lh-actions' }, ...controls.map((c) =>
36
+ button({ className: 'launch-up', title: c.title || c.label },
37
+ c.icon ? icon(c.icon, { size: 13 }) : null, c.label)
38
+ .on({ click: () => activate(ui, c) })))
39
+ : null,
29
40
  );
30
41
  }
31
42
 
@@ -52,7 +63,7 @@ export function openRowMenu(anchor, it, ui, event, select) {
52
63
  const y = event?.clientY ?? (r ? r.bottom : 0);
53
64
  // Read the coordinates BEFORE selecting: selecting re-renders, and `anchor` is then
54
65
  // a detached node whose rect is all zeroes.
55
- const items = it.menu(ui);
66
+ const items = it.menu();
56
67
  select?.();
57
- ui.platform.workbench.showContextMenu(x, y, items);
68
+ ui.engine.dispatch(new ShowContextMenuAction(x, y, items));
58
69
  }