@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,222 @@
1
+ // The markdown opener. Markdown is load-bearing now: with no folder hierarchy, a
2
+ // document that links its sources IS the grouping, so these files have to render as
3
+ // documents with working links rather than as a wall of `<pre>`.
4
+ //
5
+ // A `trove:` link navigates in-app (see ../../../bl/links.js for resolution); an http(s)
6
+ // link opens in a new tab with `rel="noopener noreferrer"`; anything else — `javascript:`,
7
+ // `data:` — is rendered as inert text, because a document is untrusted content that any
8
+ // user of the drive can write.
9
+ //
10
+ // The parser is deliberately small: headings, lists, block quotes, fenced code, inline
11
+ // code/emphasis/links, and paragraphs. It builds VDOM nodes directly and never produces
12
+ // an HTML string, so there is no innerHTML to get wrong.
13
+ //
14
+ // It is also BOUNDED. The input is a file someone uploaded, so its size and shape are
15
+ // not ours to choose: a document with tens of thousands of emphasis spans used to
16
+ // recurse once per span and overflow the stack mid-render, which left the viewer on a
17
+ // spinner forever with nothing said. Inline scanning is now iterative, and both the
18
+ // document length and the node count are capped — a truncated document with a visible
19
+ // notice beats a hung one.
20
+
21
+ import { dd, fromAsync } from '../../../runtime.js';
22
+ import { icon } from '../../icon.js';
23
+ import { parseTroveUri } from '@3sln/trove/core/links.js';
24
+ import { openTroveLink } from '../../../bl/links.js';
25
+
26
+ const { div, span, h1, h2, h3, p, ul, ol, li, pre, code, a, em, strong, blockquote, hr, br } = dd;
27
+
28
+ export function markdownOpener(node, ui) {
29
+ // Bounded at the TRANSFER, not just the render: MAX_CHARS below stops us laying out a
30
+ // huge document, but without this the whole file is still pulled into the tab first.
31
+ const src = fromAsync(() => ui.platform.api.readTextCapped(node.id, { maxBytes: MAX_CHARS, size: node.size }).then((r) => r.text));
32
+ return dd.alias(() =>
33
+ ui.platform.reactive.watch(
34
+ src,
35
+ // A throw in here is NOT covered by the `error:` handler below — that one is for
36
+ // the observable failing, not for the mapper. Rendering untrusted content is
37
+ // exactly where a throw is plausible, and an uncaught one leaves the placeholder
38
+ // on screen forever, so it is caught where it happens.
39
+ (text) => {
40
+ try {
41
+ return div({ className: 'viewer markdown' }, div({ className: 'md' }, ...renderMarkdown(text, node, ui)));
42
+ } catch (err) {
43
+ console.error('markdown render failed', err);
44
+ return errorView(`This document couldn’t be displayed: ${err.message}`);
45
+ }
46
+ },
47
+ {
48
+ placeholder: () => div({ className: 'viewer' }, div({ className: 'loading' }, div({ className: 'spinner' }), span('Loading…'))),
49
+ error: (e) => errorView(e.message),
50
+ },
51
+ ),
52
+ )();
53
+ }
54
+
55
+ function errorView(message) {
56
+ return div({ className: 'viewer' }, div({ className: 'fallback' }, icon('warn', { size: 40 }), span(message)));
57
+ }
58
+
59
+ // Bounds. A document past these renders as much as fits, with a notice — the parse is
60
+ // linear, so these are about the size of the resulting DOM, not about parse time.
61
+ const MAX_CHARS = 512 * 1024;
62
+ const MAX_BLOCKS = 5_000;
63
+ const MAX_INLINE_NODES = 2_000; // per block
64
+
65
+ // --- block level -------------------------------------------------------------
66
+
67
+ export function renderMarkdown(text, node, ui) {
68
+ const raw = String(text ?? '');
69
+ const truncated = raw.length > MAX_CHARS;
70
+ const lines = (truncated ? raw.slice(0, MAX_CHARS) : raw).split(/\r?\n/);
71
+ const out = [];
72
+ let i = 0;
73
+
74
+ while (i < lines.length) {
75
+ if (out.length >= MAX_BLOCKS) return [...out, tooLarge()];
76
+ const line = lines[i];
77
+
78
+ // Fenced code — taken verbatim, including any markdown inside it.
79
+ const fence = /^\s*(```+|~~~+)(.*)$/.exec(line);
80
+ if (fence) {
81
+ const marker = fence[1][0].repeat(3);
82
+ const body = [];
83
+ i++;
84
+ while (i < lines.length && !lines[i].trimStart().startsWith(marker)) body.push(lines[i++]);
85
+ i++; // closing fence (or EOF — an unterminated fence still renders)
86
+ out.push(pre({ className: 'md-code' }, code(body.join('\n'))));
87
+ continue;
88
+ }
89
+
90
+ if (!line.trim()) { i++; continue; }
91
+
92
+ const heading = /^(#{1,6})\s+(.*)$/.exec(line);
93
+ if (heading) {
94
+ const level = heading[1].length;
95
+ const factory = level === 1 ? h1 : level === 2 ? h2 : h3;
96
+ out.push(factory({ className: `md-h md-h${level}` }, ...inline(heading[2], node, ui)));
97
+ i++;
98
+ continue;
99
+ }
100
+
101
+ if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { out.push(hr({ className: 'md-hr' })); i++; continue; }
102
+
103
+ if (/^\s*>/.test(line)) {
104
+ const body = [];
105
+ while (i < lines.length && /^\s*>/.test(lines[i])) body.push(lines[i++].replace(/^\s*>\s?/, ''));
106
+ out.push(blockquote({ className: 'md-quote' }, ...renderMarkdown(body.join('\n'), node, ui)));
107
+ continue;
108
+ }
109
+
110
+ const bullet = /^\s*([-*+]|\d+[.)])\s+/.exec(line);
111
+ if (bullet) {
112
+ const ordered = /\d/.test(bullet[1]);
113
+ const items = [];
114
+ while (i < lines.length) {
115
+ const m = /^\s*([-*+]|\d+[.)])\s+(.*)$/.exec(lines[i]);
116
+ if (!m || /\d/.test(m[1]) !== ordered) break;
117
+ items.push(li({ className: 'md-li' }, ...inline(m[2], node, ui)));
118
+ i++;
119
+ }
120
+ out.push((ordered ? ol : ul)({ className: 'md-list' }, ...items));
121
+ continue;
122
+ }
123
+
124
+ // Paragraph: consecutive non-blank lines that don't start another block.
125
+ const para = [];
126
+ while (i < lines.length && lines[i].trim() && !startsBlock(lines[i])) para.push(lines[i++]);
127
+ const joined = para.join('\n');
128
+ const kids = [];
129
+ joined.split('\n').forEach((l, n) => {
130
+ if (n) kids.push(br());
131
+ kids.push(...inline(l, node, ui));
132
+ });
133
+ out.push(p({ className: 'md-p' }, ...kids));
134
+ }
135
+ return truncated ? [...out, tooLarge()] : out;
136
+ }
137
+
138
+ // Say what happened, rather than silently rendering a prefix as if it were the whole
139
+ // document — a reader who can't tell it was cut off will believe the rest isn't there.
140
+ function tooLarge() {
141
+ return p({ className: 'md-truncated' },
142
+ 'This document is too large to display in full. Download it to read the rest.');
143
+ }
144
+
145
+ function startsBlock(line) {
146
+ return /^(#{1,6}\s|\s*>|\s*([-*+]|\d+[.)])\s|\s*(```|~~~))/.test(line)
147
+ || /^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line);
148
+ }
149
+
150
+ // --- inline level ------------------------------------------------------------
151
+
152
+ // Ordered by precedence: code spans win over emphasis (so `*` inside backticks is
153
+ // literal), and explicit links win over bare autolinks.
154
+ const INLINE = [
155
+ { re: /`([^`]+)`/, node: (m) => code({ className: 'md-inline-code' }, m[1]) },
156
+ { re: /\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/, node: (m, node, ui) => link(m[2], m[1] || m[2], node, ui) },
157
+ { re: /(\*\*|__)(.+?)\1/, node: (m, node, ui, d) => strong(...inline(m[2], node, ui, d + 1)) },
158
+ { re: /(\*|_)(.+?)\1/, node: (m, node, ui, d) => em(...inline(m[2], node, ui, d + 1)) },
159
+ // Bare URLs, so a pasted trove: reference is clickable without link syntax.
160
+ { re: /(trove:[^\s<>"'`)\]}]+|https?:\/\/[^\s<>"'`)\]}]+)/, node: (m, node, ui) => link(trimTrailing(m[1]), trimTrailing(m[1]), node, ui) },
161
+ ];
162
+
163
+ /**
164
+ * Scan one line into inline nodes. Iterative on purpose: the obvious recursive form
165
+ * (split on the first match, recurse on both sides) descends once per span, so a line
166
+ * with thousands of them overflows the stack — and the input is a file someone
167
+ * uploaded, so "thousands of them" is their choice, not ours.
168
+ *
169
+ * Emphasis still nests, via a bounded recursion on the span's *contents* only. That
170
+ * depth is limited by how deeply markers are nested rather than by how many spans a
171
+ * line contains, so it stays shallow for anything a person would write, and `depth`
172
+ * stops a pathological document regardless.
173
+ */
174
+ function inline(text, node, ui, depth = 0) {
175
+ if (!text) return [];
176
+ if (depth > 12) return [text]; // deeper than any real markup; render it literally
177
+ const out = [];
178
+ let rest = text;
179
+ while (rest) {
180
+ if (out.length >= MAX_INLINE_NODES) { out.push(rest); break; }
181
+ let best = null;
182
+ for (const rule of INLINE) {
183
+ const m = rule.re.exec(rest);
184
+ if (m && (best === null || m.index < best.m.index)) best = { m, rule };
185
+ }
186
+ if (!best) { out.push(rest); break; }
187
+ const { m, rule } = best;
188
+ if (m.index) out.push(rest.slice(0, m.index));
189
+ out.push(rule.node(m, node, ui, depth));
190
+ rest = rest.slice(m.index + m[0].length);
191
+ }
192
+ return out;
193
+ }
194
+
195
+ function trimTrailing(url) {
196
+ return url.replace(/[.,;:!?]+$/, '');
197
+ }
198
+
199
+ /**
200
+ * One link. A `trove:` reference navigates inside the app and is resolved lazily on
201
+ * click — resolving every link up front would mean a request per link on every render,
202
+ * and a link's target can appear or disappear between renders anyway.
203
+ *
204
+ * Only trove: and http(s) are rendered as links at all. A `javascript:` or `data:` URL
205
+ * is shown as plain text: markdown here is untrusted content written by whoever can
206
+ * write to the drive, and an allowlist is the only way to be sure about a scheme.
207
+ */
208
+ function link(href, label, node, ui) {
209
+ const trove = parseTroveUri(href);
210
+ if (trove) {
211
+ return a({ className: 'md-link md-trove', href: '#', title: href }, label).on({
212
+ click: (e) => {
213
+ e.preventDefault();
214
+ openTroveLink(ui, href, { from: node });
215
+ },
216
+ });
217
+ }
218
+ if (/^https?:\/\//i.test(href)) {
219
+ return a({ className: 'md-link', href, target: '_blank', rel: 'noopener noreferrer', title: href }, label);
220
+ }
221
+ return span({ className: 'md-deadlink', title: `Unsupported link: ${href}` }, label);
222
+ }
@@ -0,0 +1,255 @@
1
+ // Small transient overlays: modal dialog (prompt/confirm), right-click context
2
+ // menu, toast notifications, the upload/transfer tray, and the plugin popup
3
+ // panel (which hosts a plugin's own iframe, Chrome-extension style).
4
+
5
+ import { dd } from '../../runtime.js';
6
+ import { icon, iconForNode } from '../icon.js';
7
+ import { bytes } from '../format.js';
8
+ import { pluginReview } from './pluginReview.js';
9
+ import { typeKeyFor, typeLabelFor, rememberOpener, openerSource } from '../../bl/openers.js';
10
+
11
+ const { div, span, button, input, h3, p, select, option, label, textarea } = dd;
12
+
13
+ // ---- Dialog ----------------------------------------------------------------
14
+ export function dialog(state, ui) {
15
+ const d = state.overlay.dialog;
16
+ if (!d) return null;
17
+ if (d.kind === 'collection') return collectionDialog(d, ui);
18
+ if (d.kind === 'plugin-review') return pluginReview(d, ui);
19
+ if (d.kind === 'opener-chooser') return openerChooserDialog(d, ui);
20
+ const wb = ui.platform.workbench;
21
+ let value = d.value ?? '';
22
+ const submit = () => (d.kind === 'confirm' ? d.onConfirm?.() : d.onSubmit?.(value));
23
+ return div({},
24
+ div({ className: 'scrim' }).on({ click: () => wb.closeDialog() }),
25
+ div({ className: 'dialog' },
26
+ h3(d.title),
27
+ d.body ? div({ className: 'body' }, d.body) : null,
28
+ d.kind === 'prompt'
29
+ ? div({ className: 'field' },
30
+ d.label ? span({ $styling: { 'font-size': '12px', color: 'var(--text-dim)' } }, d.label) : null,
31
+ input({ className: 'input', value: d.value ?? '', placeholder: d.placeholder || '', autofocus: true })
32
+ .on({
33
+ input: (e) => { value = e.target.value; },
34
+ keydown: (e) => { if (e.key === 'Enter') submit(); if (e.key === 'Escape') wb.closeDialog(); },
35
+ $attach: (el) => queueMicrotask(() => { el.focus(); el.select(); }),
36
+ }),
37
+ )
38
+ : null,
39
+ div({ className: 'row-actions' },
40
+ button({ className: 'btn' }, 'Cancel').on({ click: () => wb.closeDialog() }),
41
+ button({ className: `btn ${d.danger ? 'danger' : 'primary'}` }, d.confirmLabel || 'OK').on({ click: submit }),
42
+ ),
43
+ ),
44
+ );
45
+ }
46
+
47
+ // Choose which viewer opens a file when several openers match. Reactive via the
48
+ // workbench dialog state (updateDialog), so radio/checkbox selection survives
49
+ // re-renders. "Remember" persists a per-file-type association in settings.
50
+ function openerChooserDialog(d, ui) {
51
+ const wb = ui.platform.workbench;
52
+ const platform = ui.platform;
53
+ const selected = d.openerId || d.current || d.openers[0]?.id;
54
+ const remember = !!d.remember;
55
+ const confirm = () => {
56
+ if (remember && selected) rememberOpener(platform, typeKeyFor(d.node), selected);
57
+ wb.closeDialog();
58
+ wb.openFile(d.node, selected, { reset: !!d.reset });
59
+ };
60
+ return div({},
61
+ div({ className: 'scrim' }).on({ click: () => wb.closeDialog() }),
62
+ div({ className: 'dialog opener-chooser' },
63
+ h3(`Open “${d.node.name}” with…`),
64
+ div({ className: 'opener-list' },
65
+ ...d.openers.map((o) =>
66
+ label({ className: `opener-opt ${o.id === selected ? 'sel' : ''}` },
67
+ input({ type: 'radio', name: 'opener-choice', checked: o.id === selected })
68
+ .on({ change: () => wb.updateDialog({ openerId: o.id }) }),
69
+ icon(o.icon || iconForNode(d.node), { size: 18 }),
70
+ div({ className: 'oo-main' },
71
+ span({ className: 'oo-title' }, o.title || o.id),
72
+ span({ className: 'oo-src' }, openerSource(platform, o)),
73
+ ),
74
+ ).on({ click: () => wb.updateDialog({ openerId: o.id }) }),
75
+ ),
76
+ ),
77
+ label({ className: 'opener-remember' },
78
+ input({ type: 'checkbox', checked: remember }).on({ change: (e) => wb.updateDialog({ remember: e.target.checked }) }),
79
+ span(`Always use this for ${typeLabelFor(d.node)}`),
80
+ ),
81
+ div({ className: 'row-actions' },
82
+ button({ className: 'btn' }, 'Cancel').on({ click: () => wb.closeDialog() }),
83
+ button({ className: 'btn primary' }, 'Open').on({ click: confirm }),
84
+ ),
85
+ ),
86
+ );
87
+ }
88
+
89
+ // A collection is a backing-store config. This form collects the driver + its
90
+ // fields (filesystem path, or S3 bucket/prefix/keys) so a user with the create
91
+ // capability can provision a new collection dynamically. The form persists
92
+ // across re-renders (keyed to the dialog instance) so switching driver keeps it.
93
+ let colState = { ref: null, form: null };
94
+ function collectionDialog(d, ui) {
95
+ const wb = ui.platform.workbench;
96
+ if (colState.ref !== d) {
97
+ colState = { ref: d, form: { name: '', description: '', driver: 'filesystem', root: '', bucket: '', prefix: '', region: 'auto', endpoint: '', accessKeyId: '', secretAccessKey: '' } };
98
+ }
99
+ const form = colState.form;
100
+ const set = (k) => (e) => { form[k] = e.target.value; if (k === 'driver') ui.rerender?.(); };
101
+ const submit = () => {
102
+ const store = { driver: form.driver };
103
+ if (form.driver === 'filesystem') store.root = form.root;
104
+ if (form.driver === 's3') {
105
+ store.s3 = { bucket: form.bucket, region: form.region, endpoint: form.endpoint || undefined, accessKeyId: form.accessKeyId, secretAccessKey: form.secretAccessKey, forcePathStyle: !!form.endpoint };
106
+ if (form.prefix) store.prefix = form.prefix;
107
+ }
108
+ if (form.driver === 'filesystem' && form.prefix) store.prefix = form.prefix;
109
+ d.onSubmit?.({ name: form.name, description: form.description, store });
110
+ };
111
+ const field = (lbl, k, ph = '') => div({ className: 'field', $styling: { 'margin-bottom': '10px' } },
112
+ label(lbl), input({ className: 'input', placeholder: ph }).on({ input: set(k) }));
113
+ return div({},
114
+ div({ className: 'scrim' }).on({ click: () => wb.closeDialog() }),
115
+ div({ className: 'dialog', $styling: { width: 'min(480px, 94vw)' } },
116
+ h3('New collection'),
117
+ div({ className: 'body' }, 'A collection is a backing store you own. Configure where its files live.'),
118
+ field('Name', 'name', 'Team Vault'),
119
+ div({ className: 'field', $styling: { 'margin-bottom': '10px' } },
120
+ label('Backing store'),
121
+ select({ className: 'input' },
122
+ option({ value: 'filesystem', selected: form.driver === 'filesystem' }, 'Filesystem / NAS'),
123
+ option({ value: 's3', selected: form.driver === 's3' }, 'S3-compatible (S3 · R2 · MinIO)'),
124
+ option({ value: 'memory', selected: form.driver === 'memory' }, 'Memory (ephemeral)'),
125
+ ).on({ change: set('driver') })),
126
+ storeFields(form, set),
127
+ div({ className: 'row-actions' },
128
+ button({ className: 'btn' }, 'Cancel').on({ click: () => wb.closeDialog() }),
129
+ button({ className: 'btn primary' }, 'Create collection').on({ click: submit }),
130
+ ),
131
+ ),
132
+ );
133
+ }
134
+ function storeFields(form, set) {
135
+ const f = (lbl, k, ph = '', type = 'text') => div({ className: 'field', $styling: { 'margin-bottom': '10px' } },
136
+ label(lbl), input({ className: 'input', placeholder: ph, type, autocomplete: 'off' }).on({ input: set(k) }));
137
+ if (form.driver === 'filesystem') return div({}, f('Root directory', 'root', './data/team'), f('Prefix (optional)', 'prefix'));
138
+ if (form.driver === 's3') {
139
+ return div({},
140
+ f('Bucket', 'bucket', 'my-bucket'),
141
+ f('Prefix (optional)', 'prefix', 'team-a/'),
142
+ f('Region', 'region', 'auto'),
143
+ f('Endpoint (R2/MinIO; blank for AWS)', 'endpoint', 'https://<acct>.r2.cloudflarestorage.com'),
144
+ f('Access key id', 'accessKeyId'),
145
+ f('Secret access key', 'secretAccessKey', '', 'password'),
146
+ );
147
+ }
148
+ return div({ className: 'body', $styling: { 'font-size': '12px' } }, 'Ephemeral — data is lost on restart. Good for testing.');
149
+ }
150
+
151
+ // ---- Context menu ----------------------------------------------------------
152
+ export function contextMenu(state, ui) {
153
+ const m = state.overlay.contextMenu;
154
+ if (!m || !m.items?.length) return null;
155
+ const wb = ui.platform.workbench;
156
+ // Clamp on both edges. Anchoring a menu ABOVE its trigger (the status bar opens
157
+ // upward) produces a negative top, and only the far edge used to be clamped.
158
+ // The height is whatever the menu needs OR whatever the window allows, whichever is
159
+ // smaller — past that the menu scrolls (see `.menu`), so pushing it further up buys
160
+ // nothing and would just leave a gap at the bottom.
161
+ const wanted = Math.min(m.items.length * 34 + 20, window.innerHeight - 24);
162
+ const x = Math.max(8, Math.min(m.x, window.innerWidth - 220));
163
+ const y = Math.max(8, Math.min(m.y, window.innerHeight - wanted));
164
+ return div({},
165
+ div({ className: 'scrim', $styling: { background: 'transparent' } }).on({ click: () => wb.closeContextMenu(), contextmenu: (e) => { e.preventDefault(); wb.closeContextMenu(); } }),
166
+ div({ className: 'menu', $styling: { left: x + 'px', top: y + 'px' } },
167
+ ...m.items.map((it, i) =>
168
+ it.sep
169
+ ? div({ className: 'sep' })
170
+ // A real <button>, not a click-handling div: menu entries were unreachable by
171
+ // keyboard and invisible to the TV shell's spatial navigation, which only
172
+ // considers focusable elements.
173
+ : button({ className: `mi ${it.danger ? 'danger' : ''}`, autofocus: i === 0 },
174
+ it.icon ? icon(it.icon, { size: 15 }) : null,
175
+ span(it.label),
176
+ it.kbd ? span({ className: 'kbd' }, it.kbd) : null,
177
+ ).on({
178
+ click: () => { wb.closeContextMenu(); it.run?.(); },
179
+ // The first item is focused on open, so Escape has to get you back out.
180
+ keydown: (e) => { if (e.key === 'Escape') { e.preventDefault(); wb.closeContextMenu(); } },
181
+ $attach: (el) => { if (i === 0) queueMicrotask(() => el.focus()); },
182
+ }),
183
+ ),
184
+ ),
185
+ );
186
+ }
187
+
188
+ // ---- Toasts ----------------------------------------------------------------
189
+ export function toasts(state, ui) {
190
+ const list = state.notif || [];
191
+ if (!list.length) return null;
192
+ return div({ className: 'toasts' },
193
+ ...list.slice(-5).map((n) =>
194
+ div({ className: `toast ${n.level}` },
195
+ div({ className: 'bar' }),
196
+ div({ className: 'msg' }, n.message),
197
+ button({ className: 'x' }, icon('close', { size: 14 })).on({ click: () => ui.platform.notifications.dismiss(n.id) }),
198
+ ).key(n.id),
199
+ ),
200
+ );
201
+ }
202
+
203
+ // ---- Transfer tray ---------------------------------------------------------
204
+ export function transferTray(state, ui) {
205
+ const items = state.tr.items;
206
+ if (!items.length) return null;
207
+ return div({ className: 'tray' },
208
+ div({ className: 'head' },
209
+ icon('upload', { size: 14 }),
210
+ span({ $styling: { 'margin-left': '6px' } }, 'Transfers'),
211
+ div({ className: 'actions' },
212
+ button({ className: 'iconbtn', title: 'Clear finished' }, icon('check', { size: 14 }))
213
+ .on({ click: () => ui.app.transfers.clearDone() }),
214
+ ),
215
+ ),
216
+ div({ className: 'items' },
217
+ ...items.map((t) =>
218
+ div({ className: `xfer ${t.status}` },
219
+ div({ className: 'top' },
220
+ icon(t.status === 'error' ? 'warn' : t.status === 'done' ? 'check' : 'upload', { size: 14 }),
221
+ span({ className: 'name' }, t.name),
222
+ t.status === 'active'
223
+ ? button({ className: 'iconbtn', title: 'Cancel' }, icon('close', { size: 13 })).on({ click: () => ui.app.transfers.cancel(t.id) })
224
+ : span({ className: 'pct' }, t.status === 'done' ? bytes(t.total) : t.status),
225
+ ),
226
+ t.status === 'active'
227
+ ? div({ className: 'progress' }, div({ $styling: { width: `${Math.round(t.ratio * 100)}%` } }))
228
+ : t.error ? div({ $styling: { 'font-size': '11px', color: 'var(--danger)', 'margin-top': '4px' } }, t.error) : null,
229
+ t.status === 'active' ? div({ className: 'pct', $styling: { 'margin-top': '4px' } }, `${bytes(t.loaded)} / ${bytes(t.total)}`) : null,
230
+ ).key(t.id),
231
+ ),
232
+ ),
233
+ );
234
+ }
235
+
236
+ // ---- Plugin popup panel ----------------------------------------------------
237
+ export function pluginPanel(state, ui) {
238
+ const pid = state.overlay.pluginPanel;
239
+ if (!pid) return null;
240
+ const plugin = (state.plugins || []).find((p) => p.id === pid);
241
+ return div({ className: 'plugin-panel', $styling: { width: '380px' } },
242
+ div({ className: 'head' },
243
+ icon('plug', { size: 14 }),
244
+ span(plugin?.name || pid),
245
+ button({ className: 'iconbtn x' }, icon('close', { size: 14 })).on({ click: () => ui.platform.workbench.closePluginPanel() }),
246
+ ),
247
+ // The host mounts the plugin's iframe into this element on attach.
248
+ div({ className: 'host' }).on({
249
+ $attach: (el) => {
250
+ el._detach = ui.platform.plugins.mountPanel(pid, el, { width: 380, height: 460 });
251
+ },
252
+ $detach: (el) => el._detach?.(),
253
+ }),
254
+ );
255
+ }
@@ -0,0 +1,188 @@
1
+ // The phone shell: a thin top bar, a bottom tab bar, and two sheets.
2
+ //
3
+ // A left rail costs 52px of a 390px-wide screen and a status bar costs a row of chrome
4
+ // that is unreadable at that size anyway — on a phone both are worse than useless. So
5
+ // the rail's destinations become bottom tabs (thumb-reachable, which the top of the
6
+ // screen is not), everything past the first few becomes one "More" sheet, and the whole
7
+ // status bar folds behind a single icon in the top bar.
8
+ //
9
+ // The icon is not decoration: it changes to say what is happening. Work running shows a
10
+ // spinner, a standing problem shows a badge, and neither is something the user has to go
11
+ // looking for. Everything else — item counts, free space, backend — is a tap away rather
12
+ // than permanently on screen, because on a phone it is reference material, not status.
13
+
14
+ import { dd } from '../../runtime.js';
15
+ import { icon } from '../icon.js';
16
+ import { bytes } from '../format.js';
17
+ import { statusFacts, usageChip } from './statusBar.js';
18
+
19
+ const { div, button, span, img } = dd;
20
+
21
+ // The tabs that earn a permanent slot. Everything else lives in More — a bottom bar with
22
+ // six items is a bottom bar nobody can hit.
23
+ const TABS = [
24
+ { id: 'home', icon: 'search', label: 'Files', command: 'workbench.view.home' },
25
+ { id: 'upload', icon: 'upload', label: 'Upload', command: 'explorer.upload' },
26
+ { id: 'plugins', icon: 'plug', label: 'Plugins', command: 'workbench.view.plugins' },
27
+ ];
28
+
29
+ /** Which single glyph best describes the drive's state right now. */
30
+ function statusGlyph(f) {
31
+ if (!f.off.online) return { icon: 'info', tone: 'warn', label: 'Offline' };
32
+ if (f.issues.length) return { icon: 'warn', tone: 'danger', label: `${f.issues.length} need attention`, badge: f.issues.length };
33
+ if (f.running.length || f.uploading.length || f.off.syncing) return { spinner: true, tone: '', label: 'Working…' };
34
+ if (f.usage?.total && f.usage.available / f.usage.total < 0.1) return { icon: 'info', tone: 'warn', label: 'Low on space' };
35
+ return { icon: 'info', tone: '', label: 'Status' };
36
+ }
37
+
38
+ export function phoneTopBar(state, ui) {
39
+ const f = statusFacts(state, ui);
40
+ const g = statusGlyph(f);
41
+ // Where you are, at the coarsest level. Deliberately NOT the open file's name: the
42
+ // viewer's own breadcrumb sits directly beneath this bar and already says that, and two
43
+ // rows of chrome repeating "notes.txt" costs a fifth of the screen to say it twice.
44
+ const title = state.wb.activity === 'settings' ? 'Settings'
45
+ : state.wb.activity === 'plugins' ? 'Plugins'
46
+ : f.collectionId;
47
+ return div({ className: 'phonebar top' },
48
+ button({ className: 'pb-brand', title: 'Trove — home' }, img({ src: '/icon.svg', alt: 'Trove' }))
49
+ .on({ click: () => ui.exec('workbench.view.home') }),
50
+ div({ className: 'pb-title' }, title),
51
+ button({ className: `pb-status ${g.tone}`, title: g.label, $attrs: { 'aria-label': g.label } },
52
+ g.spinner
53
+ ? div({ className: 'spinner', $styling: { width: '15px', height: '15px' } })
54
+ : icon(g.icon, { size: 18 }),
55
+ g.badge ? span({ className: 'pb-badge' }, String(g.badge > 9 ? '9+' : g.badge)) : null,
56
+ ).on({ click: () => ui.platform.workbench.openSheet('status') }),
57
+ );
58
+ }
59
+
60
+ export function phoneBottomBar(state, ui) {
61
+ const active = state.wb.activity;
62
+ const sheet = state.wb.sheet;
63
+ const unread = state.so.notifications.unread;
64
+ return div({ className: 'phonebar bottom' },
65
+ ...TABS.map((t) => button({
66
+ className: `pb-tab ${!sheet && active === t.id ? 'active' : ''}`,
67
+ $attrs: { 'aria-label': t.label },
68
+ }, icon(t.icon, { size: 21 }), span({ className: 'pb-label' }, t.label))
69
+ .on({ click: () => { ui.platform.workbench.closeSheet(); ui.exec(t.command); } })),
70
+ // Settings is reached FROM the More sheet, so while you are in it "More" is where
71
+ // you are — otherwise all four tabs read as inactive and the bar claims you are
72
+ // nowhere.
73
+ button({
74
+ className: `pb-tab ${sheet === 'more' || (!sheet && !TABS.some((t) => t.id === active)) ? 'active' : ''}`,
75
+ $attrs: { 'aria-label': 'More' },
76
+ },
77
+ icon('dots', { size: 21 }),
78
+ unread ? span({ className: 'pb-badge' }, String(unread > 9 ? '9+' : unread)) : null,
79
+ span({ className: 'pb-label' }, 'More'),
80
+ ).on({ click: () => ui.platform.workbench.openSheet('more') }),
81
+ );
82
+ }
83
+
84
+ /** A row in a sheet: an icon, a label, and either a value or a chevron. */
85
+ function sheetRow({ icon: name, label, value, danger, onClick }) {
86
+ const body = [
87
+ icon(name, { size: 18 }),
88
+ span({ className: 'sr-label' }, label),
89
+ value != null ? span({ className: 'sr-value' }, value) : null,
90
+ onClick ? icon('chevron-right', { size: 16, className: 'sr-go' }) : null,
91
+ ];
92
+ return onClick
93
+ ? button({ className: `sheet-row ${danger ? 'danger' : ''}` }, ...body).on({ click: onClick })
94
+ : div({ className: 'sheet-row static' }, ...body);
95
+ }
96
+
97
+ export function phoneSheet(state, ui) {
98
+ const which = state.wb.sheet;
99
+ if (!which) return null;
100
+ const wb = ui.platform.workbench;
101
+ return div({ className: 'sheet-wrap' },
102
+ div({ className: 'scrim' }).on({ click: () => wb.closeSheet() }),
103
+ div({ className: 'sheet' },
104
+ div({ className: 'sheet-grip' }).on({ click: () => wb.closeSheet() }),
105
+ which === 'status' ? statusSheet(state, ui) : moreSheet(state, ui),
106
+ ),
107
+ );
108
+ }
109
+
110
+ function statusSheet(state, ui) {
111
+ const f = statusFacts(state, ui);
112
+ const wb = ui.platform.workbench;
113
+ const go = (cmd) => () => { wb.closeSheet(); ui.exec(cmd); };
114
+ return div({ className: 'sheet-body' },
115
+ div({ className: 'sheet-title' }, f.collectionId),
116
+
117
+ // Problems and running work first: they are the reason someone opened this.
118
+ f.issues.length
119
+ ? sheetRow({
120
+ icon: 'warn', danger: true,
121
+ label: `${f.issues.length} need${f.issues.length === 1 ? 's' : ''} attention`,
122
+ onClick: () => { wb.closeSheet(); ui.app.activity.togglePanel(true); },
123
+ })
124
+ : null,
125
+ f.running.length || f.uploading.length
126
+ ? sheetRow({
127
+ icon: 'refresh',
128
+ label: `${f.running.length + f.uploading.length} running`,
129
+ onClick: () => { wb.closeSheet(); ui.app.activity.togglePanel(true); },
130
+ })
131
+ : null,
132
+ !f.off.online ? sheetRow({ icon: 'info', label: 'Offline', value: `${f.off.pins.length} pinned` }) : null,
133
+ f.off.queued ? sheetRow({ icon: 'upload', label: 'Waiting to sync', value: String(f.off.queued) }) : null,
134
+
135
+ sheetRow({
136
+ icon: 'files',
137
+ label: 'Items',
138
+ value: f.partial
139
+ ? `${f.shown.toLocaleString()} of ${f.totalKnown ? f.totalItems.toLocaleString() : 'more'}`
140
+ : f.totalItems.toLocaleString(),
141
+ }),
142
+ // `+` where the figure covers only what has loaded, matching the desktop bar.
143
+ sheetRow({ icon: 'file', label: 'Size', value: `${bytes(f.totalBytes)}${f.totalKnown || !f.partial ? '' : '+'}` }),
144
+ // Only where the backend can actually answer — see usageChip.
145
+ f.usage?.total
146
+ ? div({ className: 'sheet-row static usage' },
147
+ icon('download', { size: 18 }),
148
+ span({ className: 'sr-label' }, 'Free space'),
149
+ usageChip({ usage: f.usage }))
150
+ : null,
151
+ f.caps ? sheetRow({
152
+ icon: f.caps.storage?.presignDownload ? 'download' : 'files',
153
+ label: 'Transfers',
154
+ value: f.caps.storage?.presignDownload ? 'Direct to storage' : 'Through the server',
155
+ }) : null,
156
+ f.caps?.features?.semanticSearch ? sheetRow({ icon: 'star', label: 'Search', value: 'Semantic + keyword' }) : null,
157
+
158
+ div({ className: 'sheet-actions' },
159
+ button({ className: 'btn small ghost' }, icon('refresh', { size: 13 }), span('Scan for changes')).on({ click: go('workbench.scanCollection') }),
160
+ button({ className: 'btn small ghost' }, icon('refresh', { size: 13 }), span('Activity'))
161
+ .on({ click: () => { ui.platform.workbench.closeSheet(); ui.app.activity.togglePanel(true); } }),
162
+ ),
163
+ );
164
+ }
165
+
166
+ function moreSheet(state, ui) {
167
+ const wb = ui.platform.workbench;
168
+ const go = (cmd) => () => { wb.closeSheet(); ui.exec(cmd); };
169
+ const me = state.so.me;
170
+ const unread = state.so.notifications.unread;
171
+ return div({ className: 'sheet-body' },
172
+ // No identity, no profile row — the same rule the desktop rail follows. An anonymous
173
+ // deployment has no account to show and nothing to sign into.
174
+ me && !me.anonymous
175
+ ? div({ className: 'sheet-me' },
176
+ me.picture ? img({ src: me.picture, alt: '', className: 'avatar-img' }) : span({ className: 'avatar-txt' }, (me.name || me.id || '?')[0].toUpperCase()),
177
+ div({}, div({ className: 'sm-name' }, me.name || me.id), me.email ? div({ className: 'sm-sub' }, me.email) : null))
178
+ : null,
179
+
180
+ sheetRow({ icon: 'bell', label: unread ? `Notifications (${unread})` : 'Notifications', onClick: () => { wb.closeSheet(); ui.app.social.toggleInbox(true); } }),
181
+ sheetRow({ icon: 'refresh', label: 'Activity & problems', onClick: () => { wb.closeSheet(); ui.app.activity.togglePanel(true); } }),
182
+ sheetRow({ icon: 'info', label: 'Details & conversation', onClick: go('workbench.toggleInfoPanel') }),
183
+ sheetRow({ icon: 'trash', label: 'Trash', onClick: go('explorer.showTrash') }),
184
+ sheetRow({ icon: 'refresh', label: 'Refresh', onClick: go('explorer.refresh') }),
185
+ sheetRow({ icon: 'command', label: 'All commands', onClick: go('workbench.showCommandPalette') }),
186
+ sheetRow({ icon: 'gear', label: 'Settings', onClick: go('workbench.openSettings') }),
187
+ );
188
+ }