@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,392 @@
1
+ // The main-panel launcher — search-first "home" that replaces the explorer
2
+ // sidebar. Empty query shows recents + everything in the collection; typing
3
+ // searches files; a leading `!` switches to command execution; a leading `#`
4
+ // filters by tag/property (parsed in the search layer). One keyboard-navigable
5
+ // list across whichever mode is active.
6
+
7
+ import { dd } from '../../runtime.js';
8
+ import { icon } from '../icon.js';
9
+ import { OpenFileAction, SearchAction, FilterAction } from '../../bl/actions.js';
10
+ import { parseTagQuery, filterLabel } from '../../bl/tagQuery.js';
11
+ import { activeView, renderView, viewSwitcher, viewMove } from './views/index.js';
12
+ import { openRowMenu } from './views/parts.js';
13
+
14
+ const { div, span, input, button } = dd;
15
+
16
+ // The keys a view may claim. Everything else the search box keeps.
17
+ const ARROWS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']);
18
+
19
+ // What to put in the search box, and how much of it fits.
20
+ //
21
+ // The server tells us, because the search TRANSFORMER decides what the box accepts and
22
+ // that is deployment configuration — swap in an LLM transformer and "# filter by tag"
23
+ // stops being the right advice. The fallback covers the moment before capabilities
24
+ // arrive and any server too old to say; `!` is ours either way, since running a command
25
+ // from here is a client convention the server knows nothing about.
26
+ function promptFor(ui, { compact = false, modal = false } = {}) {
27
+ const p = ui.platform.capabilities?.searchPrompt;
28
+ const base = (compact ? p?.short || p?.placeholder : p?.placeholder)
29
+ || (compact ? 'Search files' : 'Search files · # filter by tag');
30
+ // On a phone the box is ~300px wide; adding a second clause guarantees an ellipsis
31
+ // in the middle of the instructions, which reads as broken rather than terse.
32
+ return compact || modal ? base : `${base} · ! run a command`;
33
+ }
34
+
35
+ let searchTimer = null;
36
+ function runSearch(ui, query) {
37
+ // SearchAction owns search state; the launcher only dispatches (no direct .set).
38
+ clearTimeout(searchTimer);
39
+ searchTimer = setTimeout(() => ui.go(new SearchAction(query)), 240);
40
+ }
41
+ function clearSearch(ui) {
42
+ ui.platform.workbench.setLaunchQuery('');
43
+ ui.go(new SearchAction('')); // empty query resets results/ran/error in the service
44
+ }
45
+ function runFilter(ui, filters, text) {
46
+ clearTimeout(searchTimer);
47
+ searchTimer = setTimeout(() => ui.go(new FilterAction(filters, text)), 200);
48
+ }
49
+
50
+ export default function launcher(state, ui, opts = {}) {
51
+ const wb = ui.platform.workbench;
52
+ const modal = !!opts.modal; // rendered as the double-shift overlay → picks reset the stack
53
+ const q = state.wb.launch.query;
54
+ const mode = q.startsWith('!') ? 'command' : q.includes('#') ? 'filter' : 'search';
55
+
56
+ const groups = buildContent(state, ui, q, mode, modal);
57
+ const flat = groups.flatMap((g) => g.items);
58
+ const idx = flat.length ? Math.min(state.wb.launch.index, flat.length - 1) : 0;
59
+
60
+ const onInput = (e) => {
61
+ const v = e.target.value;
62
+ wb.setLaunchQuery(v);
63
+ if (v.startsWith('!')) return; // command mode: no query dispatch
64
+ const { text, filters } = parseTagQuery(v);
65
+ if (filters.length) runFilter(ui, filters, text); // drive-wide tag/property query
66
+ else runSearch(ui, text);
67
+ };
68
+ // Moving the highlight with the KEYBOARD is selecting, as far as the rest of the app
69
+ // is concerned. Without this, `explorer.selectedNodes()` was permanently empty and
70
+ // every command that works on "the selected item" — delete above all — silently did
71
+ // nothing.
72
+ //
73
+ // Hovering deliberately does not select. It moves the highlight (so Enter opens what
74
+ // the pointer is over) but leaves the selection alone: a second state push per mouse
75
+ // move re-renders the list under the pointer, and a row replaced between mousedown and
76
+ // mouseup never receives its click. A pointer user acts through the row's own menu,
77
+ // which selects explicitly before it opens.
78
+ const hoverAt = (at) => wb.setLaunchIndex(at);
79
+ const selectAt = (at) => { wb.setLaunchIndex(at); syncSelection(ui, flat[at]); };
80
+ const move = (delta) => {
81
+ wb.moveLaunch(delta, flat.length);
82
+ syncSelection(ui, flat[wb.state.launch.index]);
83
+ };
84
+ // How the results are drawn right now. The view gets a say in what an arrow key
85
+ // means — one row down is one tile down in a grid, not one tile across — but the
86
+ // index, the selection and the wrapping stay here, so up and down mean the same
87
+ // thing whichever view is showing.
88
+ //
89
+ // The search transformer may have suggested one. Only for an actual search: on the
90
+ // home list there is no sentence to have read.
91
+ const view = activeView(ui.platform, flat, mode === 'search' ? state.se.resolved?.view : null);
92
+ const onKey = (e) => {
93
+ // `textual`: there is text in the box, so left/right are the caret's and no view
94
+ // may claim them. Fixing a typo must not move the highlight.
95
+ const claimed = ARROWS.has(e.key)
96
+ ? viewMove(view, e.key, { index: idx, count: flat.length, textual: q.length > 0 })
97
+ : null;
98
+ if (claimed !== null) { e.preventDefault(); move(claimed); }
99
+ else if (e.key === 'ArrowDown') { e.preventDefault(); move(1); }
100
+ else if (e.key === 'ArrowUp') { e.preventDefault(); move(-1); }
101
+ else if (e.key === 'Enter') { e.preventDefault(); flat[idx]?.run(); }
102
+ else if (e.key === 'Escape' && q) { e.preventDefault(); clearSearch(ui); }
103
+ // The row under the highlight is the subject of the row menu, so the key that opens
104
+ // one on every other list opens this one too — without making the user leave the
105
+ // search box to reach it.
106
+ else if (e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10')) {
107
+ const it = flat[idx];
108
+ // Arrowing here already selected the row, so the menu acts on the right thing.
109
+ if (it?.menu) { e.preventDefault(); openRowMenu(document.querySelector('.launch-item.active, .grid-tile.active'), it, ui); }
110
+ }
111
+ };
112
+
113
+ // What the server actually searched (transformer output) — shown when it differs
114
+ // from what the user typed, so an LLM-assisted transform stays honest.
115
+ const resolved = mode === 'search' ? state.se.resolved : null;
116
+ const showResolved = resolved && (resolved.source === 'llm'
117
+ || (resolved.tagFilters && resolved.tagFilters.length)
118
+ || (resolved.semanticText || '').trim() !== q.trim());
119
+
120
+ const inner = div({ className: 'launcher' },
121
+ div({ className: 'launch-box' },
122
+ icon(mode === 'command' ? 'command' : mode === 'filter' ? 'tag' : 'search', { size: 18 }),
123
+ // `false`, not `'false'`: this is set as a property, and the string is truthy —
124
+ // so the search box had spellcheck ON, red-underlining every filename typed into it.
125
+ input({ className: 'launch-input', value: q, autofocus: true, spellcheck: false,
126
+ placeholder: promptFor(ui, { compact: state.vp?.mode === 'phone', modal }) })
127
+ .on({ input: onInput, keydown: onKey }),
128
+ q ? button({ className: 'launch-clear', title: 'Clear' }, icon('close', { size: 14 }))
129
+ .on({ click: () => clearSearch(ui) }) : null,
130
+ // Only where the browser can transcribe WITHOUT sending audio anywhere. A remote's
131
+ // own mic needs no button from us — it dictates into this field once it is focused,
132
+ // which is what `search.voice` is really for.
133
+ ui.platform.voice?.canListen()
134
+ ? button({
135
+ className: `launch-mic ${state.voice?.listening ? 'on' : ''}`,
136
+ title: state.voice?.listening ? 'Stop listening' : 'Search by voice',
137
+ 'aria-pressed': state.voice?.listening ? 'true' : 'false',
138
+ }, icon('mic', { size: 15 })).on({ click: () => ui.exec('search.voice') })
139
+ : null,
140
+ // Which way to look at the drive. Not in the modal search, where the answer is
141
+ // always "the one thing I am about to press Enter on".
142
+ modal ? null : viewSwitcher(ui.platform, view),
143
+ ),
144
+ showResolved ? resolvedBar(resolved) : null,
145
+ // The results belong to the active view — see ui/components/views. The launcher says
146
+ // WHAT is on screen (these groups, this highlight); the view says how it looks. That
147
+ // split is why a gallery is a contribution rather than another branch in here.
148
+ div({ className: 'launch-body' },
149
+ renderView(view, { groups, index: idx, handlers: { hover: hoverAt, select: selectAt }, state, ui }),
150
+ searchHelp(state, ui, mode),
151
+ ),
152
+ );
153
+ return modal ? inner : div({ className: 'editor' }, inner);
154
+ }
155
+
156
+ // An "under-bar" reflecting what the search actually ran (the transformer's output):
157
+ // the semantic text it searched for + any tag filters it applied. Read-only and
158
+ // honest — we don't rewrite the user's input, we just show what was dispatched.
159
+ function resolvedBar(r) {
160
+ return div({ className: 'launch-resolved' },
161
+ span({ className: 'rq-label' }, r.source === 'llm' ? 'Interpreted as' : 'Searching'),
162
+ (r.semanticText || '').trim() ? span({ className: 'rq-text' }, `“${r.semanticText.trim()}”`) : null,
163
+ ...(r.tagFilters || []).map((f) => span({ className: 'rq-chip' }, icon('tag', { size: 11 }), filterLabel(f))),
164
+ );
165
+ }
166
+
167
+ // Keep the drive's idea of "what is selected" in step with the highlighted row.
168
+ //
169
+ // The NODE goes along with the id. A search result or a recent can be from a collection
170
+ // that isn't loaded — or a page that isn't — so an id alone is something the explorer
171
+ // cannot resolve, and the commands that act on "the selection" then do nothing at all.
172
+ function syncSelection(ui, item) {
173
+ const node = item?.node;
174
+ ui.app.explorer.select(node?.id ? [node.id] : [], { nodes: node ? [node] : null });
175
+ }
176
+
177
+ /**
178
+ * How this search box works — shown when, and only when, a search came back empty.
179
+ *
180
+ * That is the one moment the syntax is worth explaining: the user asked for something
181
+ * and got nothing, and "you typed it wrong" is a real possibility they can act on.
182
+ * Showing it any earlier is a permanent instruction panel above an empty drive, and
183
+ * showing it when there ARE results would be explaining a thing that just worked.
184
+ *
185
+ * The text comes from the server's transformer, so it describes the grammar this
186
+ * deployment actually runs rather than the one this file was written against.
187
+ */
188
+ function searchHelp(state, ui, mode) {
189
+ if (mode === 'command') return null;
190
+ const se = state.se;
191
+ if (!se.ran || se.loading || se.error || (se.results || []).length) return null;
192
+ const p = ui.platform.capabilities?.searchPrompt;
193
+ if (!p?.hint && !p?.examples?.length) return null;
194
+ const wb = ui.platform.workbench;
195
+ const tryIt = (query) => () => {
196
+ wb.setLaunchQuery(query);
197
+ const { text, filters } = parseTagQuery(query);
198
+ if (filters.length) ui.go(new FilterAction(filters, text));
199
+ else ui.go(new SearchAction(text));
200
+ };
201
+ return div({ className: 'launch-help' },
202
+ p.hint ? div({ className: 'lh-hint' }, icon('info', { size: 13 }), span(p.hint)) : null,
203
+ p.examples?.length
204
+ ? div({ className: 'lh-examples' }, ...p.examples.map((ex) =>
205
+ button({ className: 'lh-example', title: ex.label || 'Try this search' },
206
+ span({ className: 'lh-q' }, ex.query),
207
+ ex.label ? span({ className: 'lh-label' }, ex.label) : null,
208
+ ).on({ click: tryIt(ex.query) })))
209
+ : null,
210
+ );
211
+ }
212
+
213
+ function buildContent(state, ui, q, mode, modal) {
214
+ const done = () => { if (modal) ui.platform.workbench.closeSearchModal(); };
215
+ if (mode === 'command') {
216
+ const term = q.slice(1).trim().toLowerCase();
217
+ const items = ui.platform.commands.paletteCommands()
218
+ .map((c) => ({ hay: `${c.category || ''} ${c.title}`.toLowerCase(), c }))
219
+ .map(({ hay, c }) => ({ s: score(hay, term), c }))
220
+ .filter((x) => term === '' || x.s > 0)
221
+ .sort((a, b) => b.s - a.s)
222
+ .slice(0, 40)
223
+ .map(({ c }) => ({ icon: 'command', title: c.title, detail: c.category, badge: 'command', run: () => { ui.exec(c.id); done(); } }));
224
+ return [{ title: 'Commands', items, empty: 'No matching commands.' }];
225
+ }
226
+
227
+ const { text, filters } = parseTagQuery(q);
228
+ const nodes = (state.se.results || []).map((r) => r.node);
229
+ const err = state.se.error;
230
+
231
+ // A one-click retry so a transient search/filter failure isn't a dead end.
232
+ const retryBtn = (action) => button({ className: 'launch-up', title: 'Retry' }, icon('refresh', { size: 13 }), 'Retry').on({ click: () => ui.go(action) });
233
+
234
+ // Drive-wide tag/property filter (server-side), optionally narrowed by free text.
235
+ if (filters.length) {
236
+ const label = filters.map(filterLabel).join(' ') + (text.trim() ? ` · "${text.trim()}"` : '');
237
+ return [{
238
+ title: state.se.loading ? 'Filtering…' : err ? 'Filter failed' : 'Filtered',
239
+ // Shown as typed: a tag is a value, not a heading.
240
+ verbatim: state.se.loading || err ? null : label,
241
+ action: err ? retryBtn(new FilterAction(filters, text)) : null,
242
+ items: nodes.map((n) => fileItem(n, ui, modal)),
243
+ empty: state.se.loading ? 'Filtering…' : err ? `Couldn’t filter: ${err}` : 'No files match those filters.',
244
+ }];
245
+ }
246
+
247
+ // Free-text search.
248
+ if (text.trim()) {
249
+ return [{
250
+ title: state.se.loading ? 'Searching…' : err ? 'Search failed' : 'Results',
251
+ action: err ? retryBtn(new SearchAction(q)) : null,
252
+ items: nodes.map((n) => fileItem(n, ui, modal)),
253
+ empty: state.se.loading ? 'Searching…' : err ? `Couldn’t search: ${err}` : 'No files match.',
254
+ }];
255
+ }
256
+
257
+ // Home: recents, then everything in the collection. There is nothing to descend
258
+ // into — this is the "show me everything" fallback for when search isn't the answer.
259
+ const groups = [];
260
+ const recents = (state.nav.recents || []).map((r) => fileItem(r, ui, modal));
261
+ if (recents.length) groups.push({ title: 'Recent', items: recents });
262
+
263
+ const ex = state.ex;
264
+ const shown = (ex.items || []).length;
265
+ // `stats` is the collection; `items` is the page. When the server didn't report stats
266
+ // we only know the page — and saying "500" while a next page exists is a claim about
267
+ // the drive that is false, so say "500+" instead.
268
+ const knownTotal = ex.stats?.items ?? null;
269
+ const total = knownTotal ?? shown;
270
+ const totalLabel = knownTotal != null ? knownTotal.toLocaleString() : `${shown.toLocaleString()}+`;
271
+ const items = (ex.items || []).map((n) => fileItem(n, ui, modal));
272
+ // A partial list must not be titled "All items" — that is a claim about the drive,
273
+ // and on a collection bigger than one page it is false. Say what is on screen, and
274
+ // offer the rest rather than leaving it unreachable.
275
+ if (ex.nextCursor) {
276
+ items.push({
277
+ icon: 'refresh',
278
+ title: ex.loadingMore ? 'Loading…'
279
+ : knownTotal != null ? `Show more (${(knownTotal - shown).toLocaleString()} more)` : 'Show more',
280
+ detail: 'or search to jump straight to something',
281
+ run: () => ui.exec('explorer.loadMore'),
282
+ });
283
+ }
284
+ // The trash, when it has been opened. Not shown by default: it is a place you go to
285
+ // recover a mistake, not part of browsing the drive.
286
+ if (ex.trash) {
287
+ groups.push({
288
+ // One line, not two: a header reading "0 items" above a body reading "the trash is
289
+ // empty" is the same sentence twice, permanently parked above the drive.
290
+ title: ex.trash.length
291
+ ? `Trash · ${ex.trash.length} item${ex.trash.length === 1 ? '' : 's'}`
292
+ : 'Trash',
293
+ action: div({ className: 'lh-actions' },
294
+ ex.trash.length
295
+ ? button({ className: 'launch-up', title: 'Destroy everything in the trash' },
296
+ icon('trash', { size: 13 }), 'Empty trash').on({ click: () => ui.exec('explorer.emptyTrash') })
297
+ : null,
298
+ // The way back out. Opening the trash used to be one-way until a page reload.
299
+ button({ className: 'launch-up', title: 'Hide the trash' },
300
+ icon('close', { size: 13 }), 'Close').on({ click: () => ui.exec('explorer.hideTrash') }),
301
+ ),
302
+ items: ex.trash.length
303
+ ? ex.trash.map((n) => ({
304
+ icon: 'trash',
305
+ title: n.name,
306
+ detail: `deleted ${new Date(n.deletedAt).toLocaleString()} — restore`,
307
+ run: () => ui.exec('explorer.restore', n.id),
308
+ menu: () => trashMenu(n, ui),
309
+ }))
310
+ : [],
311
+ empty: 'The trash is empty.',
312
+ });
313
+ }
314
+
315
+ groups.push({
316
+ title: ex.nextCursor ? `All items · showing ${shown.toLocaleString()} of ${totalLabel}` : 'All items',
317
+ // The empty state told people to upload a file, and on desktop and TV there was
318
+ // nothing anywhere that would let them. (Phone has the + in its bottom bar.)
319
+ action: modal ? null : button({ className: 'launch-up', title: 'Upload files to this collection' },
320
+ icon('upload', { size: 13 }), 'Upload').on({ click: () => ui.exec('explorer.upload') }),
321
+ items,
322
+ // Don't show a false "empty" when the load actually FAILED (e.g. server
323
+ // unreachable) — say so, so the user knows to retry rather than believing the
324
+ // collection is empty. (A toast also fires, but the persistent state must be honest.)
325
+ empty: ex.loading ? 'Loading…' : ex.error ? `Couldn’t load this collection: ${ex.error}` : 'Nothing here yet — upload a file to get started.',
326
+ });
327
+ return groups;
328
+ }
329
+
330
+ function fileItem(node, ui, modal) {
331
+ return {
332
+ icon: fileIcon(node),
333
+ title: node.name,
334
+ detail: node.contentType || '',
335
+ node,
336
+ // From the modal search, `reset` starts a fresh viewer stack; then close it.
337
+ run: () => {
338
+ ui.go(new OpenFileAction(node, { reset: !!modal }));
339
+ if (modal) ui.platform.workbench.closeSearchModal();
340
+ },
341
+ menu: () => fileMenu(node, ui),
342
+ };
343
+ }
344
+
345
+ function fileMenu(node, ui) {
346
+ const pinned = (ui.app.offline?.state?.pins || []).some((p) => p.id === node.id);
347
+ // Ask what the shortcut actually IS. Hardcoding "⌘⇧L" told a Windows or Linux user
348
+ // about a key their machine does not have, and told everyone the default even after
349
+ // they had rebound it.
350
+ const kbd = (id) => ui.platform.keybindings.labelFor(id) || undefined;
351
+ return [
352
+ { label: 'Open', icon: 'file-text', run: () => ui.exec('explorer.open', node) },
353
+ { label: 'Download', icon: 'download', run: () => ui.exec('explorer.download', node) },
354
+ { label: 'Copy link', icon: 'link', kbd: kbd('explorer.copyLink'), run: () => ui.exec('explorer.copyLink') },
355
+ { sep: true },
356
+ { label: 'Rename…', run: () => ui.exec('explorer.rename') },
357
+ pinned
358
+ ? { label: 'Remove from offline', icon: 'close', run: () => ui.exec('offline.unpin', node) }
359
+ : { label: 'Make available offline', icon: 'download', run: () => ui.exec('offline.pin', node) },
360
+ { sep: true },
361
+ { label: 'Move to trash', icon: 'trash', danger: true, kbd: kbd('explorer.delete'), run: () => ui.exec('explorer.delete') },
362
+ ];
363
+ }
364
+
365
+ // The trash is the one place "delete" means destroy, so its two verbs live together:
366
+ // put it back, or finish the job on this one item. Emptying the whole trash was
367
+ // previously the only way to purge anything.
368
+ function trashMenu(node, ui) {
369
+ return [
370
+ { label: 'Restore', icon: 'refresh', run: () => ui.exec('explorer.restore', node.id) },
371
+ { sep: true },
372
+ { label: 'Delete forever', icon: 'trash', danger: true, run: () => ui.exec('explorer.purgeOne', node.id) },
373
+ ];
374
+ }
375
+
376
+ function fileIcon(node) {
377
+ const t = node.contentType || '';
378
+ if (t.startsWith('image/')) return 'file-image';
379
+ if (t.startsWith('audio/')) return 'file-audio';
380
+ if (t.startsWith('video/')) return 'file-video';
381
+ return 'file-text';
382
+ }
383
+
384
+ // Substring-first, then subsequence fuzzy score (0 = no match).
385
+ function score(hay, term) {
386
+ if (!term) return 1;
387
+ const i = hay.indexOf(term);
388
+ if (i >= 0) return 1000 - i;
389
+ let ti = 0;
390
+ for (let hi = 0; hi < hay.length && ti < term.length; hi++) if (hay[hi] === term[ti]) ti++;
391
+ return ti === term.length ? 1 : 0;
392
+ }
@@ -0,0 +1,212 @@
1
+ // Built-in openers, registered through the same contribution system plugins use.
2
+ // Each declares a selector (extensions / mime) and a component(node, ui) → vnode.
3
+ // The editor area picks the highest-priority opener whose selector matches.
4
+
5
+ import { dd, cell, fromAsync, watch } from '../../../runtime.js';
6
+ import { icon } from '../../icon.js';
7
+ import { bytes } from '../../format.js';
8
+ import { markdownOpener } from './markdown.js';
9
+ import { attachMedia } from '../../media.js';
10
+
11
+ const { div, pre, img, span, button, video, audio } = dd;
12
+
13
+ // Built-ins register the same way a plugin's manifest declares an opener — same
14
+ // registry, same `match` selector, same priority ordering. The only difference is
15
+ // that a built-in supplies a `component` (it runs in the host) where a plugin
16
+ // supplies an `entry` module (it runs in its own sandboxed frame).
17
+ const BUILT_IN = {
18
+ 'core.audio': {
19
+ title: 'Audio Player', priority: 20,
20
+ match: { mime: ['audio/*'], ext: ['.mp3', '.flac', '.wav', '.opus', '.ogg', '.m4a', '.m4b'] },
21
+ component: audioOpener,
22
+ },
23
+ 'core.video': {
24
+ title: 'Video Player', priority: 20,
25
+ match: { mime: ['video/*'], ext: ['.mp4', '.webm', '.mkv', '.mov'] },
26
+ component: videoOpener,
27
+ },
28
+ 'core.image': {
29
+ title: 'Image Viewer', priority: 20,
30
+ match: { mime: ['image/*'], ext: ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.avif'] },
31
+ component: imageOpener,
32
+ },
33
+ // Markdown outranks the plain text viewer: a document that links its sources is how
34
+ // things are grouped now, so those links have to be clickable by default.
35
+ 'core.markdown': {
36
+ title: 'Markdown', priority: 30,
37
+ match: { ext: ['.md', '.markdown'], mime: ['text/markdown'] },
38
+ component: markdownOpener,
39
+ },
40
+ 'core.text': {
41
+ title: 'Text Viewer', priority: 10,
42
+ match: {
43
+ mime: ['text/*', 'application/json'],
44
+ ext: ['.txt', '.md', '.json', '.js', '.mjs', '.ts', '.jsx', '.tsx', '.css', '.html', '.xml', '.yaml', '.yml', '.toml', '.ini', '.log', '.csv', '.py', '.rb', '.go', '.rs', '.sh', '.c', '.h', '.cpp', '.java'],
45
+ },
46
+ component: textOpener,
47
+ },
48
+ };
49
+
50
+ export function registerBuiltinOpeners(platform) {
51
+ for (const [name, spec] of Object.entries(BUILT_IN)) {
52
+ platform.contributions.register(name, { type: 'opener', ...spec });
53
+ }
54
+ }
55
+
56
+ /** Render a node with a resolved opener; falls back gracefully. */
57
+ export function renderOpener(node, openerId, ui) {
58
+ const opener = ui.platform.contributions.get(openerId);
59
+ if (opener?.component) {
60
+ try {
61
+ return opener.component(node, ui);
62
+ } catch (err) {
63
+ return errorView(err.message);
64
+ }
65
+ }
66
+ if (opener?.pluginId) return pluginOpenerView(opener, node, ui);
67
+ return fallbackOpener(node, ui);
68
+ }
69
+
70
+ // ---- component openers -----------------------------------------------------
71
+
72
+ // How much of a text file the viewer will pull and draw. A drive holds logs, CSV
73
+ // exports and database dumps in the hundreds of megabytes; reading one whole just to
74
+ // show its beginning buffers the entire file in the tab and then asks the browser to
75
+ // lay out ten million characters. Both halves are bounded here — the Range request
76
+ // means the bytes never arrive, not merely that they aren't drawn.
77
+ const TEXT_VIEW_BYTES = 512 * 1024;
78
+
79
+ function textOpener(node, ui) {
80
+ const src = fromAsync(() => ui.platform.api.readTextCapped(node.id, { maxBytes: TEXT_VIEW_BYTES, size: node.size }));
81
+ return dd.alias(() =>
82
+ ui.platform.reactive.watch(
83
+ src,
84
+ ({ text, truncated, total }) => div({ className: 'viewer text' },
85
+ pre(text),
86
+ // Say what is missing rather than trailing off. A viewer that silently shows
87
+ // the first slice of a file is worse than one that refuses: the reader believes
88
+ // they have seen the end of it.
89
+ truncated
90
+ ? div({ className: 'md-truncated' },
91
+ `Showing the first ${Math.round(text.length / 1024)} KB${total ? ` of ${(total / 1048576).toFixed(1)} MB` : ''}. `,
92
+ span('Download the file to read all of it.'))
93
+ : null,
94
+ ),
95
+ {
96
+ placeholder: () => div({ className: 'viewer' }, div({ className: 'loading' }, div({ className: 'spinner' }), span('Loading…'))),
97
+ error: (e) => errorView(e.message),
98
+ },
99
+ ),
100
+ )();
101
+ }
102
+
103
+ // A media element (img/audio/video) sets its `src` to a download URL that can 404 or
104
+ // hold undecodable bytes. Those failures surface only as an async `error` event on the
105
+ // element itself (media error events don't bubble), which would otherwise leave a
106
+ // broken glyph / black box with no message. `mediaWithError` passes an `onError` the
107
+ // caller MUST wire onto the actual media element, and swaps in the download fallback
108
+ // (with a reason) the moment it fires.
109
+ const MEDIA_ERR = "This file couldn't be loaded — it may be missing or in an unsupported format.";
110
+ function mediaWithError(node, ui, makeEl) {
111
+ const state = cell({ error: null });
112
+ const onError = (msg) => state.setValue({ error: msg || MEDIA_ERR });
113
+ const el = makeEl(onError);
114
+ return dd.alias(() =>
115
+ watch(state, (s) => (s.error ? fallbackOpener(node, ui, s.error) : el)),
116
+ )();
117
+ }
118
+
119
+ /**
120
+ * The `src` is not set here.
121
+ *
122
+ * It is minted — a URL that carries its own grant, because a media element fetches
123
+ * without our Authorization header — and then KEPT current: it expires, and a paused
124
+ * film resumed the next morning has to swap its source without losing its place. All of
125
+ * that is `attachMedia`; see ui/media.js.
126
+ */
127
+ const sourced = (el, node, ui, onError) => el.on({
128
+ $attach: (dom) => { dom._detachMedia = attachMedia(dom, node, ui, { onError }); },
129
+ $detach: (dom) => { dom._detachMedia?.(); dom._detachMedia = null; },
130
+ }).opaque();
131
+
132
+ function imageOpener(node, ui) {
133
+ return mediaWithError(node, ui, (onError) =>
134
+ div({ className: 'viewer image' }, sourced(img({ alt: node.name }), node, ui, onError)),
135
+ );
136
+ }
137
+
138
+ function audioOpener(node, ui) {
139
+ return mediaWithError(node, ui, (onError) =>
140
+ div({ className: 'viewer', $styling: { display: 'grid', 'place-items': 'center', gap: '16px', padding: '40px' } },
141
+ icon('file-audio', { size: 48 }),
142
+ span({ $styling: { color: 'var(--text-dim)' } }, node.name),
143
+ sourced(audio({ controls: true, $styling: { width: 'min(520px, 90%)' } }), node, ui, onError),
144
+ ),
145
+ );
146
+ }
147
+
148
+ function videoOpener(node, ui) {
149
+ return mediaWithError(node, ui, (onError) =>
150
+ div({ className: 'viewer', $styling: { display: 'grid', 'place-items': 'center', background: '#000' } },
151
+ sourced(video({ controls: true, $styling: { 'max-width': '100%', 'max-height': '100%' } }), node, ui, onError),
152
+ ),
153
+ );
154
+ }
155
+
156
+ function fallbackOpener(node, ui, reason) {
157
+ return div({ className: 'viewer' },
158
+ div({ className: 'fallback' },
159
+ icon(reason ? 'warn' : 'file', { size: 44 }),
160
+ span({ $styling: { 'font-weight': 600 } }, node.name),
161
+ span(`${node.contentType || 'Unknown type'} · ${bytes(node.size)}`),
162
+ span({ $styling: { color: 'var(--text-faint)', 'max-width': '340px' } },
163
+ reason || 'No preview available for this file type. Install a plugin that handles it, or download the file.'),
164
+ button({ className: 'btn primary' }, icon('download', { size: 15 }), 'Download')
165
+ .on({ click: () => ui.exec('explorer.download', node) }),
166
+ ),
167
+ );
168
+ }
169
+
170
+ function pluginOpenerView(opener, node, ui) {
171
+ // A plugin opener renders in its OWN sandboxed iframe (spawned by mountViewer with
172
+ // the plugin's capabilities). That iframe is not a child here — it's a host-owned
173
+ // position:fixed overlay whose box tracks this .pv-host element (moving an <iframe>
174
+ // in the DOM would reload it). $attach hands .pv-host to mountViewer as the tracking
175
+ // target; $detach lets it dock or tear down. `.opaque()` keeps dodo from reconciling
176
+ // these (host-managed) nodes so their lifecycle hooks stay stable across re-renders.
177
+ // A .pv-status overlay shows a spinner until the plugin opens the file (the iframe
178
+ // is transparent while loading) and an error if it never does.
179
+ return div({ className: 'viewer plugin-viewer' },
180
+ div({ className: 'pv-status' }).on({
181
+ $attach: (el) => {
182
+ el.innerHTML = '<div class="loading"><div class="spinner"></div><span>Opening…</span></div>';
183
+ el._ready = () => { el.style.display = 'none'; };
184
+ el._error = (msg) => { el.style.display = 'grid'; el.innerHTML = ''; el.appendChild(errorEl(msg)); };
185
+ },
186
+ }).opaque(),
187
+ div({ className: 'pv-host' }).on({
188
+ $attach: (el) => {
189
+ const status = el.parentElement?.querySelector('.pv-status');
190
+ el._detach = ui.platform.plugins.mountViewer(opener.pluginId, el, node, opener.id, {
191
+ onReady: () => status?._ready?.(),
192
+ onError: (msg) => status?._error?.(msg || 'This viewer failed to load'),
193
+ });
194
+ },
195
+ $detach: (el) => { el._detach?.(); el._detach = null; },
196
+ }).opaque(),
197
+ );
198
+ }
199
+
200
+ // A plain DOM error node for the imperatively-managed .pv-status overlay.
201
+ function errorEl(message) {
202
+ const wrap = document.createElement('div');
203
+ wrap.className = 'fallback';
204
+ const s = document.createElement('span');
205
+ s.textContent = message || 'Failed to open';
206
+ wrap.appendChild(s);
207
+ return wrap;
208
+ }
209
+
210
+ function errorView(message) {
211
+ return div({ className: 'viewer' }, div({ className: 'fallback' }, icon('warn', { size: 40 }), span(message || 'Failed to open')));
212
+ }