@3sln/trove 0.0.2

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 (162) hide show
  1. package/README.md +1227 -0
  2. package/package.json +75 -0
  3. package/packages/core/src/collections/index.js +249 -0
  4. package/packages/core/src/errors.js +186 -0
  5. package/packages/core/src/identity/discovery.js +210 -0
  6. package/packages/core/src/identity/index.js +188 -0
  7. package/packages/core/src/identity/jwt.js +199 -0
  8. package/packages/core/src/index.js +104 -0
  9. package/packages/core/src/indexers/contribution.js +115 -0
  10. package/packages/core/src/indexers/registry.js +162 -0
  11. package/packages/core/src/indexing.js +340 -0
  12. package/packages/core/src/issues.js +150 -0
  13. package/packages/core/src/kv.js +0 -0
  14. package/packages/core/src/links.js +141 -0
  15. package/packages/core/src/metadata/cursor.js +73 -0
  16. package/packages/core/src/metadata/interface.js +244 -0
  17. package/packages/core/src/metadata/memory.js +270 -0
  18. package/packages/core/src/metadata/sqlite.js +412 -0
  19. package/packages/core/src/notifications/index.js +139 -0
  20. package/packages/core/src/notifications/webpush.js +217 -0
  21. package/packages/core/src/plugins/contributions.js +177 -0
  22. package/packages/core/src/plugins/identity.js +98 -0
  23. package/packages/core/src/plugins/index.js +225 -0
  24. package/packages/core/src/plugins/indexers.js +142 -0
  25. package/packages/core/src/plugins/installStore.js +134 -0
  26. package/packages/core/src/plugins/package.js +102 -0
  27. package/packages/core/src/plugins/packageStore.js +61 -0
  28. package/packages/core/src/plugins/runtime.js +101 -0
  29. package/packages/core/src/plugins/sql.js +52 -0
  30. package/packages/core/src/retry.js +74 -0
  31. package/packages/core/src/scan.js +302 -0
  32. package/packages/core/src/search/embeddings.js +128 -0
  33. package/packages/core/src/search/index.js +200 -0
  34. package/packages/core/src/search/keywordStore.js +107 -0
  35. package/packages/core/src/search/sqliteStores.js +455 -0
  36. package/packages/core/src/search/tagMatch.js +59 -0
  37. package/packages/core/src/search/transformer.js +195 -0
  38. package/packages/core/src/search/vectorStore.js +274 -0
  39. package/packages/core/src/search/vectorize.js +249 -0
  40. package/packages/core/src/sidecar/document.js +213 -0
  41. package/packages/core/src/sidecar/index.js +174 -0
  42. package/packages/core/src/sidecar/manager.js +239 -0
  43. package/packages/core/src/sidecar/store.js +46 -0
  44. package/packages/core/src/signedUrls.js +170 -0
  45. package/packages/core/src/sqlite-d1.js +162 -0
  46. package/packages/core/src/sqlite-driver.js +42 -0
  47. package/packages/core/src/sqlite.js +162 -0
  48. package/packages/core/src/storage/filesystem.js +283 -0
  49. package/packages/core/src/storage/interface.js +222 -0
  50. package/packages/core/src/storage/memory.js +113 -0
  51. package/packages/core/src/storage/prefixed.js +75 -0
  52. package/packages/core/src/storage/s3.js +316 -0
  53. package/packages/core/src/storage/s3sigv4.js +185 -0
  54. package/packages/core/src/tasks.js +228 -0
  55. package/packages/core/src/uploads.js +386 -0
  56. package/packages/core/src/util.js +125 -0
  57. package/packages/core/src/vfs.js +666 -0
  58. package/packages/plugin-sdk/src/browser.js +316 -0
  59. package/packages/plugin-sdk/src/index.js +32 -0
  60. package/packages/plugin-sdk/src/protocol.js +59 -0
  61. package/packages/plugin-sdk/src/rpc.js +95 -0
  62. package/packages/server/src/adapters/bun.js +78 -0
  63. package/packages/server/src/adapters/node.js +115 -0
  64. package/packages/server/src/adapters/staticAssets.js +123 -0
  65. package/packages/server/src/adapters/webDist.js +70 -0
  66. package/packages/server/src/adapters/worker-tasks.js +206 -0
  67. package/packages/server/src/adapters/worker.js +159 -0
  68. package/packages/server/src/cachePolicy.js +34 -0
  69. package/packages/server/src/engine/README.md +88 -0
  70. package/packages/server/src/engine/actions/scanCollection.js +114 -0
  71. package/packages/server/src/engine/index.js +95 -0
  72. package/packages/server/src/engine/lazy.js +25 -0
  73. package/packages/server/src/engine/providers/access.js +363 -0
  74. package/packages/server/src/engine/providers/core.js +405 -0
  75. package/packages/server/src/engine/providers/scan.js +67 -0
  76. package/packages/server/src/index.js +698 -0
  77. package/packages/server/src/manifest.js +98 -0
  78. package/packages/server/src/mcp/auth.js +40 -0
  79. package/packages/server/src/mcp/index.js +213 -0
  80. package/packages/server/src/mcp/protocol.js +181 -0
  81. package/packages/server/src/mcp/tools.js +351 -0
  82. package/packages/server/src/router.js +229 -0
  83. package/packages/server/src/routes.js +1066 -0
  84. package/packages/server/src/scope.js +43 -0
  85. package/packages/web/dist/assets/chunk-4xqbzebh.js +5 -0
  86. package/packages/web/dist/assets/chunk-4xqbzebh.js.map +9 -0
  87. package/packages/web/dist/assets/chunk-h05bxfbs.js +5 -0
  88. package/packages/web/dist/assets/chunk-h05bxfbs.js.map +10 -0
  89. package/packages/web/dist/assets/main-4cxs7prw.js +356 -0
  90. package/packages/web/dist/assets/main-4cxs7prw.js.map +103 -0
  91. package/packages/web/dist/assets/styles-kcx1x337.css +1 -0
  92. package/packages/web/dist/icon.svg +11 -0
  93. package/packages/web/dist/index.html +16 -0
  94. package/packages/web/dist/sql-wasm.wasm +0 -0
  95. package/packages/web/dist/sw.js +186 -0
  96. package/packages/web/src/bl/actions.js +410 -0
  97. package/packages/web/src/bl/activity.js +306 -0
  98. package/packages/web/src/bl/commands.js +274 -0
  99. package/packages/web/src/bl/fileType.js +49 -0
  100. package/packages/web/src/bl/index.js +70 -0
  101. package/packages/web/src/bl/links.js +54 -0
  102. package/packages/web/src/bl/offline.js +268 -0
  103. package/packages/web/src/bl/openers.js +71 -0
  104. package/packages/web/src/bl/pluginInstall.js +59 -0
  105. package/packages/web/src/bl/services.js +143 -0
  106. package/packages/web/src/bl/social.js +234 -0
  107. package/packages/web/src/bl/tagQuery.js +44 -0
  108. package/packages/web/src/main.js +10 -0
  109. package/packages/web/src/platform/api.js +529 -0
  110. package/packages/web/src/platform/commands.js +89 -0
  111. package/packages/web/src/platform/context.js +77 -0
  112. package/packages/web/src/platform/contributions.js +156 -0
  113. package/packages/web/src/platform/index.js +150 -0
  114. package/packages/web/src/platform/keybindings.js +199 -0
  115. package/packages/web/src/platform/mediaUrls.js +137 -0
  116. package/packages/web/src/platform/navigation.js +131 -0
  117. package/packages/web/src/platform/notifications.js +50 -0
  118. package/packages/web/src/platform/overlay.js +81 -0
  119. package/packages/web/src/platform/pluginClientDb.js +132 -0
  120. package/packages/web/src/platform/pluginDock.js +141 -0
  121. package/packages/web/src/platform/pluginFrames.js +194 -0
  122. package/packages/web/src/platform/pluginHost.js +648 -0
  123. package/packages/web/src/platform/pluginMedia.js +62 -0
  124. package/packages/web/src/platform/pluginModules.js +90 -0
  125. package/packages/web/src/platform/pluginNet.js +71 -0
  126. package/packages/web/src/platform/pluginPackage.js +247 -0
  127. package/packages/web/src/platform/pluginRpc.js +377 -0
  128. package/packages/web/src/platform/pluginSigning.js +168 -0
  129. package/packages/web/src/platform/pluginStore.js +67 -0
  130. package/packages/web/src/platform/settings.js +101 -0
  131. package/packages/web/src/platform/spatialNav.js +286 -0
  132. package/packages/web/src/platform/viewport.js +123 -0
  133. package/packages/web/src/platform/voice.js +133 -0
  134. package/packages/web/src/platform/voiceSearch.js +155 -0
  135. package/packages/web/src/platform/whenclause.js +162 -0
  136. package/packages/web/src/platform/workbench.js +156 -0
  137. package/packages/web/src/runtime.js +73 -0
  138. package/packages/web/src/styles.css +1382 -0
  139. package/packages/web/src/ui/components/activityBar.js +35 -0
  140. package/packages/web/src/ui/components/activityPanel.js +132 -0
  141. package/packages/web/src/ui/components/commandPalette.js +154 -0
  142. package/packages/web/src/ui/components/editorArea.js +75 -0
  143. package/packages/web/src/ui/components/launcher.js +392 -0
  144. package/packages/web/src/ui/components/openers/index.js +212 -0
  145. package/packages/web/src/ui/components/openers/markdown.js +222 -0
  146. package/packages/web/src/ui/components/overlays.js +255 -0
  147. package/packages/web/src/ui/components/phoneChrome.js +188 -0
  148. package/packages/web/src/ui/components/pluginReview.js +151 -0
  149. package/packages/web/src/ui/components/pluginsView.js +120 -0
  150. package/packages/web/src/ui/components/settingsView.js +258 -0
  151. package/packages/web/src/ui/components/social.js +290 -0
  152. package/packages/web/src/ui/components/statusBar.js +198 -0
  153. package/packages/web/src/ui/components/views/grid.js +115 -0
  154. package/packages/web/src/ui/components/views/index.js +155 -0
  155. package/packages/web/src/ui/components/views/list.js +50 -0
  156. package/packages/web/src/ui/components/views/parts.js +58 -0
  157. package/packages/web/src/ui/compositions/workbench.js +125 -0
  158. package/packages/web/src/ui/format.js +33 -0
  159. package/packages/web/src/ui/icon.js +81 -0
  160. package/packages/web/src/ui/media.js +114 -0
  161. package/packages/web/src/ui/sanitize.js +86 -0
  162. package/packages/web/src/workbench.js +205 -0
@@ -0,0 +1,290 @@
1
+ // Social UI: the notification bell (title bar) and the info panel (a right rail
2
+ // with the active file's tags and threaded conversation). Both render from the
3
+ // reactive SocialService; actions call back into it.
4
+
5
+ import { dd } from '../../runtime.js';
6
+ import { icon } from '../icon.js';
7
+ import { relativeDate } from '../format.js';
8
+ import { OpenFileAction } from '../../bl/actions.js';
9
+ import { troveUri } from '@3sln/trove/core/links.js';
10
+
11
+ const { div, span, button, textarea, input, p } = dd;
12
+
13
+ const REACTIONS = ['👍', '❤️', '🎉', '👀', '🚀'];
14
+
15
+ // ---- title-bar principal chip + bell ---------------------------------------
16
+
17
+ export function principalChip(state) {
18
+ const me = state.so.me;
19
+ // No identity, no profile. On a deployment with no login the server hands back a
20
+ // shared anonymous stand-in; rendering an avatar and a name for it would describe a
21
+ // user who does not exist, and imply an account there is no way to sign in or out of.
22
+ if (!me || me.anonymous) return null;
23
+ const initial = (me.name || me.id || '?')[0].toUpperCase();
24
+ return div({ className: 'principal', title: me.email || me.name || me.id },
25
+ me.picture ? dd.img({ src: me.picture, alt: '', className: 'avatar-img' }) : span({ className: 'avatar-txt' }, initial),
26
+ );
27
+ }
28
+
29
+ export function notificationBell(state, ui) {
30
+ const n = state.so.notifications;
31
+ const social = ui.app.social;
32
+ return div({ className: 'bell-wrap' },
33
+ button({ className: 'iconbtn bell', title: 'Notifications' },
34
+ icon('bell', { size: 19 }),
35
+ n.unread ? span({ className: 'bell-badge' }, String(n.unread > 9 ? '9+' : n.unread)) : null,
36
+ ).on({ click: () => social.toggleInbox() }),
37
+ state.so.inboxOpen ? inboxDropdown(state, ui) : null,
38
+ );
39
+ }
40
+
41
+ function inboxDropdown(state, ui) {
42
+ const items = state.so.notifications.items;
43
+ const social = ui.app.social;
44
+ return div({},
45
+ div({ className: 'scrim', $styling: { background: 'transparent', 'z-index': '54' } }).on({ click: () => social.toggleInbox(false) }),
46
+ div({ className: 'inbox' },
47
+ div({ className: 'inbox-head' },
48
+ span('Notifications'),
49
+ state.so.pushSupported && !state.so.pushEnabled
50
+ ? button({ className: 'link' }, 'Enable push').on({ click: () => social.enablePush() })
51
+ : state.so.pushEnabled ? span({ className: 'muted' }, icon('check', { size: 13 })) : null,
52
+ ),
53
+ items.length
54
+ ? div({ className: 'inbox-items' }, ...items.slice(0, 30).map((note) => inboxItem(note, ui)))
55
+ : div({ className: 'inbox-empty' }, icon('info', { size: 24 }), span('You’re all caught up.')),
56
+ ),
57
+ );
58
+ }
59
+
60
+ function inboxItem(note, ui) {
61
+ const first = note.items?.[0];
62
+ return div({ className: `inbox-item ${note.read ? '' : 'unread'}` },
63
+ div({ className: 'ii-title' }, note.title),
64
+ first?.excerpt ? div({ className: 'ii-excerpt' }, '“' + first.excerpt + '”') : null,
65
+ div({ className: 'ii-time' }, relativeDate(note.createdAt)),
66
+ ).on({
67
+ 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);
83
+ },
84
+ });
85
+ }
86
+
87
+ // ---- info panel (tags + conversation) --------------------------------------
88
+
89
+ export function infoPanel(state, ui) {
90
+ const nav = state.nav;
91
+ const active = nav.activeFile ? { id: nav.activeTabId, node: nav.activeFile } : null;
92
+ const sc = state.so.sidecar;
93
+ return div({ className: 'infopanel' },
94
+ div({ className: 'ip-head' },
95
+ icon('info', { size: 15 }),
96
+ span('Details'),
97
+ button({ className: 'iconbtn', title: 'Close' }, icon('close', { size: 14 })).on({ click: () => ui.platform.workbench.toggleInfoPanel(false) }),
98
+ ),
99
+ !active
100
+ ? 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
+ ),
107
+ );
108
+ }
109
+
110
+ function fileHeader(node, state, ui) {
111
+ const pinned = ui.app.offline.isPinned(node.id);
112
+ return div({ className: 'ip-file' },
113
+ div({ className: 'ip-name' }, node.name),
114
+ div({ className: 'ip-path' }, node.contentType || ''),
115
+ // Secondary, not primary. Pinning a file for offline is a useful thing to be able
116
+ // to do and not the thing you came to this panel for — as the brightest element on
117
+ // screen it out-shouted the file's own links, tags and conversation.
118
+ button({ className: `btn small ${pinned ? 'on' : ''}`, $styling: { 'margin-top': '10px' } },
119
+ icon(pinned ? 'check' : 'download', { size: 14 }),
120
+ pinned ? 'Available offline' : 'Make available offline',
121
+ ).on({ click: () => (pinned ? ui.exec('offline.unpin', node) : ui.exec('offline.pin', node)) }),
122
+ );
123
+ }
124
+
125
+ /**
126
+ * The item's own `trove:` link, and what links to it.
127
+ *
128
+ * With no folders, backlinks are the answer to "where does this live?" — the documents
129
+ * that gather it up. A load failure says so rather than rendering as an empty list,
130
+ * which would read as a fact about the drive instead of about the request.
131
+ */
132
+ function linkSection(node, state, ui) {
133
+ const bl = state.so.backlinks;
134
+ const uri = node.collectionId ? troveUri(node) : null;
135
+ const mine = bl && bl.nodeId === node.id ? bl : null;
136
+ return div({ className: 'ip-section' },
137
+ div({ className: 'ip-label' }, 'Links'),
138
+ uri
139
+ ? button({ className: 'ip-uri', title: 'Copy this item’s link' }, icon('link', { size: 12 }), span(uri))
140
+ .on({ click: () => copyLink(ui, uri) })
141
+ : null,
142
+ div({ className: 'ip-label', $styling: { 'margin-top': '10px' } }, 'Linked from'),
143
+ mine?.loading
144
+ ? div({ className: 'ip-muted' }, 'Loading…')
145
+ : mine?.error
146
+ ? div({ className: 'ip-muted error' }, `Couldn’t load links: ${mine.error}`)
147
+ : mine?.items?.length
148
+ ? div({ className: 'ip-backlinks' }, ...mine.items.map((n) =>
149
+ button({ className: 'ip-backlink', title: n.name }, icon('file-text', { size: 12 }), span(n.name))
150
+ .on({ click: () => ui.go(new OpenFileAction(n)) })))
151
+ : div({ className: 'ip-muted' }, 'Nothing links here yet.'),
152
+ );
153
+ }
154
+
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
+ }
162
+ }
163
+
164
+ function tagSection(sc, ui) {
165
+ const tags = sc?.tags || [];
166
+ return div({ className: 'ip-section' },
167
+ div({ className: 'ip-label' }, 'Tags'),
168
+ div({ className: 'tags' },
169
+ ...tags.map((t) =>
170
+ span({ className: 'tag' },
171
+ 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) }),
173
+ ),
174
+ ),
175
+ input({ className: 'tag-input', placeholder: '+ tag', value: '' }).on({
176
+ keydown: (e) => {
177
+ if (e.key === 'Enter' && e.target.value.trim()) {
178
+ const raw = e.target.value.trim();
179
+ const [name, ...rest] = raw.split(':');
180
+ ui.app.social.addTag(name.trim(), rest.join(':').trim() || undefined);
181
+ e.target.value = '';
182
+ }
183
+ },
184
+ }),
185
+ ),
186
+ );
187
+ }
188
+
189
+ function conversationSection(state, sc, ui) {
190
+ if (sc?.loading) return div({ className: 'ip-section' }, div({ className: 'spinner' }));
191
+ const comments = sc?.comments || [];
192
+ // On a load failure, don't render a false "No comments yet" — say it couldn't load
193
+ // and offer a retry, so the user doesn't post into what looks like an empty thread.
194
+ const body = sc?.error
195
+ ? div({ className: 'conv-empty' },
196
+ 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) }))
198
+ : comments.length
199
+ ? div({ className: 'thread' }, ...comments.map((c) => commentNode(c, state, ui, 0)))
200
+ : div({ className: 'conv-empty' }, 'No comments yet. Start the discussion — use @ to mention someone.');
201
+ return div({ className: 'ip-section conv' },
202
+ div({ className: 'ip-label' }, `Conversation${sc?.commentCount ? ` · ${sc.commentCount}` : ''}`),
203
+ body,
204
+ sc?.error ? null : composer(state, ui),
205
+ );
206
+ }
207
+
208
+ function commentNode(c, state, ui, depth) {
209
+ const me = state.so.me;
210
+ const social = ui.app.social;
211
+ const mine = me && c.author?.id === me.id;
212
+ return div({ className: 'comment', $styling: depth ? { marginLeft: '18px' } : {} },
213
+ div({ className: 'c-head' },
214
+ span({ className: 'c-avatar' }, (c.author?.name || '?')[0].toUpperCase()),
215
+ span({ className: 'c-author' }, c.author?.name || c.author?.id || 'Someone'),
216
+ span({ className: 'c-time' }, relativeDate(c.createdAt), c.edited ? ' · edited' : ''),
217
+ ),
218
+ c.deleted
219
+ ? div({ className: 'c-body deleted' }, 'comment deleted')
220
+ : div({ className: 'c-body' }, renderBody(c.body)),
221
+ !c.deleted ? div({ className: 'c-actions' },
222
+ ...REACTIONS.map((emoji) => {
223
+ const users = c.reactions?.[emoji] || [];
224
+ 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) });
226
+ }),
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,
229
+ ) : null,
230
+ (c.replies || []).length ? div({ className: 'replies' }, ...c.replies.map((r) => commentNode(r, state, ui, depth + 1))) : null,
231
+ ).key(c.id);
232
+ }
233
+
234
+ function composer(state, ui) {
235
+ const social = ui.app.social;
236
+ const me = state.so.me;
237
+ const replyTo = state.so.replyTo;
238
+ // `me` is null only when /api/me didn't answer — offline, or refused. It is NOT the
239
+ // anonymous case (an anonymous deployment returns a real anonymous principal), so
240
+ // "Sign in to join the conversation" was shown to people with nowhere to sign in,
241
+ // and to people who were merely offline. Say which it is, and offer the one action
242
+ // that helps: Trove ships no login of its own, so signing in means letting the
243
+ // identity proxy in front of it redirect a fresh page load.
244
+ if (!me) {
245
+ if (!state.off?.online) {
246
+ return div({ className: 'conv-signin' }, 'You’re offline. Conversations come back when you reconnect.');
247
+ }
248
+ return div({ className: 'conv-signin' },
249
+ span('You’re signed out, so you can read this conversation but not add to it.'),
250
+ button({ className: 'c-link' }, 'Reload to sign in').on({ click: () => window.location.reload() }),
251
+ );
252
+ }
253
+ return div({ className: 'composer' },
254
+ replyTo ? div({ className: 'replying' }, `Replying to ${replyTo.author?.name || 'comment'}`,
255
+ button({ className: 'c-link' }, 'cancel').on({ click: () => social.setReplyTo(null) })) : null,
256
+ textarea({ className: 'composer-input', placeholder: 'Write a comment… @mention someone', rows: 2, value: '' }).on({
257
+ keydown: (e) => {
258
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
259
+ social.comment(e.target.value);
260
+ e.target.value = '';
261
+ }
262
+ },
263
+ }),
264
+ div({ className: 'composer-actions' },
265
+ span({ className: 'muted' }, '⌘/Ctrl + Enter'),
266
+ button({ className: 'btn primary', disabled: state.so.posting }, 'Comment').on({
267
+ click: (e) => {
268
+ const box = e.target.closest('.composer').querySelector('.composer-input');
269
+ social.comment(box.value);
270
+ box.value = '';
271
+ },
272
+ }),
273
+ ),
274
+ );
275
+ }
276
+
277
+ // Render @[Name](id) tokens and bare @handles as highlighted mentions.
278
+ 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;
290
+ }
@@ -0,0 +1,198 @@
1
+ import { dd } from '../../runtime.js';
2
+ import { icon } from '../icon.js';
3
+ import { bytes } from '../format.js';
4
+ import { sanitizedVNodes, htmlToText } from '../sanitize.js';
5
+
6
+ const { div, button, span } = dd;
7
+
8
+ /**
9
+ * A plugin's status slot. The manifest declares the slot (name + side); the plugin
10
+ * pushes content into it at runtime over RPC. The content is untrusted HTML from a
11
+ * sandboxed frame, so it goes through the allowlist sanitizer before it becomes nodes.
12
+ * A slot with nothing in it, or one whose `when` doesn't hold, renders nothing at all
13
+ * rather than an empty chip.
14
+ */
15
+ 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
+ const content = sanitizedVNodes(item.html, dd);
20
+ if (!content.length) return null;
21
+ const title = item.tooltip ? htmlToText(item.tooltip) : '';
22
+ return item.command
23
+ ? button({ className: 'seg', title }, ...content).on({ click: () => ui.exec(item.command) })
24
+ : span({ className: 'seg', title }, ...content);
25
+ }
26
+
27
+ /**
28
+ * The two things that make background work and standing problems discoverable at all.
29
+ *
30
+ * Running work shows only while it is running — a status bar that always carries an
31
+ * "Activity" button teaches people to ignore it. The attention badge is the opposite:
32
+ * it stays until the problem is actually fixed, because that is the whole point of an
33
+ * issue as against a toast that scrolls away.
34
+ */
35
+ export function activityChips(state, ui) {
36
+ const act = state.act || { tasks: [], issues: [] };
37
+ const running = act.tasks.filter((t) => t.status === 'running');
38
+ const open = () => ui.app.activity.togglePanel(true);
39
+ const chips = [];
40
+
41
+ if (running.length) {
42
+ const first = running[0];
43
+ const determinate = first.total != null && first.total > 0;
44
+ const pct = determinate ? Math.round(((first.done || 0) / first.total) * 100) : null;
45
+ chips.push(button({
46
+ className: 'seg sb-activity',
47
+ title: running.map((t) => t.title).join('\n'),
48
+ },
49
+ div({ className: 'spinner', $styling: { width: '11px', height: '11px' } }),
50
+ span(running.length > 1
51
+ ? `${running.length} running`
52
+ : `${first.title}${pct == null ? '' : ` ${pct}%`}`)).on({ click: open }));
53
+ }
54
+
55
+ if (act.issues.length) {
56
+ chips.push(button({
57
+ className: 'seg sb-attention',
58
+ title: act.issues.map((i) => i.title).join('\n'),
59
+ }, icon('info', { size: 12 }), span(`${act.issues.length} need${act.issues.length === 1 ? 's' : ''} attention`))
60
+ .on({ click: open }));
61
+ }
62
+ return chips;
63
+ }
64
+
65
+ /**
66
+ * How full the disk is — but only when the backend actually knows.
67
+ *
68
+ * A filesystem or NAS can answer exactly, and that is where it matters: a disk fills
69
+ * up and every upload starts failing with no warning that anything was coming. An
70
+ * object store has no equivalent number, so this renders nothing at all rather than a
71
+ * meter that means nothing. The bar turns amber under 10% free and red under 5%, which
72
+ * is early enough to act on.
73
+ */
74
+ export function usageChip(ex) {
75
+ const u = ex.usage;
76
+ if (!u?.total) return null;
77
+ const freeRatio = u.available / u.total;
78
+ const pct = Math.min(100, Math.round((u.used / u.total) * 100));
79
+ const level = freeRatio < 0.05 ? 'critical' : freeRatio < 0.1 ? 'low' : '';
80
+ return span({
81
+ className: `seg sb-usage ${level}`,
82
+ title: `${bytes(u.used)} used of ${bytes(u.total)} — ${bytes(u.available)} free on this volume`,
83
+ },
84
+ div({ className: 'usage-bar' }, div({ className: 'usage-fill', $styling: { width: `${pct}%` } })),
85
+ span(`${bytes(u.available)} free`));
86
+ }
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
+ collectionId: ex.collectionId || 'default',
101
+ // The COLLECTION's totals when the server could give them, not the page's. Summing
102
+ // what happens to be loaded reports a 3,000-file drive as 500 files — a wrong number,
103
+ // not a rounded one. Falls back to the page only when the server didn't say.
104
+ totalItems: ex.stats?.items ?? items.length,
105
+ // Whether that total is the COLLECTION's or just the page we happen to hold. With
106
+ // more pages waiting, the page length is a floor, not a total, and must read as one.
107
+ totalKnown: ex.stats?.items != null,
108
+ totalBytes: ex.stats?.bytes ?? items.reduce((n, i) => n + (i.size || 0), 0),
109
+ shown: items.length,
110
+ partial: !!ex.nextCursor,
111
+ usage: ex.usage,
112
+ uploading: state.tr.items.filter((t) => t.status === 'active'),
113
+ running: act.tasks.filter((t) => t.status === 'running'),
114
+ issues: act.issues,
115
+ off: state.off || { online: true, pins: [], queued: 0, syncing: false },
116
+ caps: ui.platform.capabilities,
117
+ };
118
+ }
119
+
120
+ export default function statusBar(state, ui) {
121
+ const ex = state.ex;
122
+ const items = ex.items || [];
123
+ const f = statusFacts(state, ui);
124
+ const { totalItems, totalBytes, caps } = f;
125
+ const active = f.uploading;
126
+
127
+ // Plugin-contributed status slots, ordered by their declared `order` then name.
128
+ const slots = [...(state.statusItems || [])]
129
+ .sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.name.localeCompare(b.name));
130
+ const left = slots.filter((s) => s.slot === 'left').map((s) => statusSlot(s, ui));
131
+ const right = slots.filter((s) => s.slot !== 'left').map((s) => statusSlot(s, ui));
132
+
133
+ const off = f.off;
134
+ return div({ className: 'statusbar' },
135
+ !off.online
136
+ ? span({ className: 'seg offline-badge', title: 'You are offline — pinned files and cached data are available' }, icon('info', { size: 12 }), span('Offline'))
137
+ : off.syncing
138
+ ? span({ className: 'seg', title: 'Syncing offline changes' }, div({ className: 'spinner', $styling: { width: '11px', height: '11px' } }), span('Syncing…'))
139
+ : null,
140
+ off.queued ? span({ className: 'seg', title: 'Changes waiting to sync' }, `${off.queued} queued`) : null,
141
+ // The pinned files themselves. There is no offline-files view anywhere, so a chip
142
+ // counting them that only dropped you on the home screen was a label pretending to
143
+ // be a control — and unpinning meant finding each file again through search.
144
+ off.pins.length
145
+ ? button({ className: 'seg', title: `${off.pins.length} file(s) available offline` },
146
+ icon('download', { size: 12 }), span(`${off.pins.length} offline`), icon('chevron-down', { size: 11 }))
147
+ .on({ click: (e) => {
148
+ const items = off.pins.slice(0, 12).flatMap((p) => [
149
+ { label: p.name, icon: 'file-text', run: () => ui.exec('explorer.open', p) },
150
+ { label: `Remove “${p.name}” from offline`, icon: 'close', run: () => ui.exec('offline.unpin', p) },
151
+ { sep: true },
152
+ ]);
153
+ if (off.pins.length > 12) items.push({ label: `…and ${off.pins.length - 12} more`, run: () => {} });
154
+ const r = e.currentTarget.getBoundingClientRect();
155
+ ui.platform.workbench.showContextMenu(r.left, r.top - 8 - items.length * 34, items);
156
+ } })
157
+ : null,
158
+ button({ className: 'seg', title: 'Switch collection' },
159
+ icon('files', { size: 13 }), span(ex.collectionId || 'default'),
160
+ (ex.collections || []).length > 1 || ex.canCreateCollection ? icon('chevron-down', { size: 11 }) : null)
161
+ .on({ click: (e) => {
162
+ const items = ui.app.collectionMenu?.() || [];
163
+ // With nowhere else to go, the segment is a label, not a dead menu.
164
+ if (items.length > 1 || ex.canCreateCollection) {
165
+ const r = e.currentTarget.getBoundingClientRect();
166
+ ui.platform.workbench.showContextMenu(r.left, r.top - 8 - items.length * 34, items);
167
+ } else ui.exec('workbench.view.home');
168
+ } }),
169
+ active.length
170
+ ? button({ className: 'seg', title: 'Active uploads — click to cancel' }, div({ className: 'spinner', $styling: { width: '11px', height: '11px' } }), span(`${active.length} uploading`))
171
+ .on({ click: (e) => ui.platform.workbench.showContextMenu(e.clientX, e.clientY, active.map((t) => ({
172
+ label: `Cancel ${t.name} (${Math.round((t.ratio || 0) * 100)}%)`, icon: 'close', danger: true,
173
+ run: () => ui.app.transfers.cancel(t.id),
174
+ }))) })
175
+ : span({ className: 'seg', title: ex.nextCursor ? `Showing ${items.length} of ${f.totalKnown ? totalItems : 'more'}` : '' },
176
+ `${totalItems.toLocaleString()}${f.totalKnown || !f.partial ? '' : '+'} item${totalItems === 1 ? '' : 's'}`),
177
+ ...left,
178
+ div({ className: 'spacer' }),
179
+ ...activityChips(state, ui),
180
+ ...right,
181
+ // Same qualifier as the item count beside it. Without it the two numbers in one bar
182
+ // disagreed about their own honesty: "500+ items" next to a byte figure that was
183
+ // only the loaded page's, presented as the collection's.
184
+ span({
185
+ className: 'seg',
186
+ title: f.totalKnown || !f.partial
187
+ ? 'Total size of this collection'
188
+ : `Size of the ${f.shown.toLocaleString()} items loaded so far — this server doesn’t report a collection total`,
189
+ }, `${bytes(totalBytes)}${f.totalKnown || !f.partial ? '' : '+'}`),
190
+ usageChip(ex),
191
+ caps ? span({ className: 'seg', title: 'Storage backend capabilities' },
192
+ icon(caps.storage?.presignDownload ? 'download' : 'files', { size: 12 }),
193
+ span(caps.storage?.presignDownload ? 'S3 direct' : 'proxied'),
194
+ ) : null,
195
+ caps?.features?.semanticSearch ? button({ className: 'seg', title: 'Semantic search enabled' }, icon('star', { size: 12 }), span('semantic'))
196
+ .on({ click: () => ui.exec('workbench.view.home') }) : null,
197
+ );
198
+ }
@@ -0,0 +1,115 @@
1
+ // The grid view: tiles, with a picture on them where there is a picture to show.
2
+ //
3
+ // This is the second way to look at a drive, and it exists as much to prove the view
4
+ // contract as to be useful — a contribution type with one implementation is a guess. It
5
+ // draws the SAME groups the list does, headers and all, because the group header is
6
+ // where Upload, Empty trash and Retry live: a view that dropped it would take those off
7
+ // the screen the moment someone switched to tiles.
8
+ //
9
+ // On thumbnails, honestly: there is no thumbnail service yet, so a tile showing a
10
+ // photograph is showing the photograph, scaled down by the browser. That is affordable
11
+ // only because `loading="lazy"` means the ones below the fold are never fetched — the
12
+ // grid asks for what is on screen and nothing else. A hosted build that generates real
13
+ // thumbnails should register its own view rather than patch this one; that is the whole
14
+ // point of the contribution.
15
+
16
+ import { dd } from '../../../runtime.js';
17
+ import { icon, iconForNode } from '../../icon.js';
18
+ import { groupHeader, menuButton, openRowMenu } from './parts.js';
19
+ import { attachMedia } from '../../media.js';
20
+
21
+ const { div, span, img } = dd;
22
+
23
+ export function gridView({ groups, index, handlers, ui }) {
24
+ let gi = -1;
25
+ return div({ className: 'launch-view view-grid' },
26
+ ...groups.map((group) => div({ className: 'launch-group' },
27
+ groupHeader(group),
28
+ group.items.length
29
+ ? div({ className: 'grid-list' }, ...group.items.map((it) => {
30
+ const at = ++gi;
31
+ return tile(it, at === index, {
32
+ hover: () => handlers.hover(at),
33
+ select: () => handlers.select(at),
34
+ }, ui);
35
+ }))
36
+ : div({ className: 'launch-empty' }, group.empty || 'Nothing here.'),
37
+ )),
38
+ );
39
+ }
40
+
41
+ /**
42
+ * Which way to move for an arrow key.
43
+ *
44
+ * The launcher owns keyboard navigation and thinks in one flat list, which is right for
45
+ * rows and wrong for tiles: down should mean the tile below, not the tile beside. The
46
+ * view is asked, gets to answer in the launcher's own terms (a delta), and the launcher
47
+ * keeps the wrapping, the selection and the scrolling.
48
+ *
49
+ * The column count is measured rather than assumed, because it comes from
50
+ * `auto-fill` — it depends on the window width, the shell, and whether the sidebar
51
+ * is open, none of which this file should be trying to predict.
52
+ *
53
+ * Left and right are only ours when the search box is empty. With a query typed in it,
54
+ * those keys belong to the caret: someone fixing a typo must not find the highlight
55
+ * walking across the grid instead.
56
+ */
57
+ export function gridMove({ key, textual }) {
58
+ if (key === 'ArrowLeft') return textual ? null : -1;
59
+ if (key === 'ArrowRight') return textual ? null : 1;
60
+ const cols = columns();
61
+ if (key === 'ArrowUp') return -cols;
62
+ if (key === 'ArrowDown') return cols;
63
+ return null;
64
+ }
65
+
66
+ function columns() {
67
+ // Tiles on the same row share an offsetTop. One row's worth is the answer; a grid with
68
+ // a single row (or none on screen) falls back to 1, which is the list's behaviour and
69
+ // never worse than a guess.
70
+ const tiles = document.querySelectorAll('.grid-list .grid-tile');
71
+ if (tiles.length < 2) return 1;
72
+ const top = tiles[0].offsetTop;
73
+ let n = 0;
74
+ for (const el of tiles) {
75
+ if (el.offsetTop !== top) break;
76
+ n++;
77
+ }
78
+ return n || 1;
79
+ }
80
+
81
+ function tile(it, active, { hover, select }, ui) {
82
+ const node = it.node;
83
+ const pictorial = (node?.contentType || '').startsWith('image/');
84
+ return div({ className: `grid-tile ${active ? 'active' : ''}`, title: it.detail ? `${it.title} — ${it.detail}` : it.title },
85
+ div({ className: 'gt-media' },
86
+ // The icon is drawn underneath, always. When the image loads it covers it; when
87
+ // the image fails — a deleted object, a format the browser won't decode, a 401 —
88
+ // hiding the <img> uncovers exactly what the list would have shown, rather than
89
+ // leaving the browser's broken-image glyph in a gallery.
90
+ icon(node ? iconForNode(node) : it.icon, { size: 26 }),
91
+ // The `src` is minted rather than built — a tile fetches without our Authorization
92
+ // header, so the URL has to carry its own grant. Minting is batched (see
93
+ // platform/mediaUrls.js), so a wall of tiles costs one request, not one each.
94
+ pictorial
95
+ ? img({ className: 'gt-img', alt: '', loading: 'lazy', decoding: 'async' }).on({
96
+ $attach: (dom) => {
97
+ dom._detachMedia = attachMedia(dom, node, ui, {
98
+ // Uncovering the icon underneath is exactly what the list would have shown,
99
+ // and better than the browser's broken-image glyph in a gallery.
100
+ onError: () => { dom.style.display = 'none'; },
101
+ });
102
+ },
103
+ $detach: (dom) => { dom._detachMedia?.(); dom._detachMedia = null; },
104
+ }).opaque()
105
+ : null,
106
+ it.badge ? span({ className: 'gt-badge' }, it.badge) : null,
107
+ menuButton(it, ui, select),
108
+ ),
109
+ div({ className: 'gt-name' }, it.title),
110
+ ).on({
111
+ click: it.run,
112
+ mouseenter: hover,
113
+ ...(it.menu ? { contextmenu: (e) => { e.preventDefault(); openRowMenu(e.currentTarget, it, ui, e, select); } } : {}),
114
+ });
115
+ }