@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,151 @@
1
+ // The pre-install review — everything a user needs to decide whether to trust
2
+ // and install a plugin: its identity + signature/domain-verified status, the
3
+ // capabilities it wants (each explained, admin-only ones flagged), what it
4
+ // contributes, and its settings. Capabilities are opt-in checkboxes; admin-only
5
+ // ones are disabled for non-admins. No plugin code has run at this point.
6
+
7
+ import { dd } from '../../runtime.js';
8
+ import { icon } from '../icon.js';
9
+ import { bytes as fmtBytes } from '../format.js';
10
+
11
+ const { div, span, button, h3, p, label, input } = dd;
12
+
13
+ // Grant selection persists across re-renders, keyed to the dialog instance.
14
+ let sel = { ref: null, grants: null };
15
+
16
+ export function pluginReview(d, ui) {
17
+ const wb = ui.platform.workbench;
18
+ const s = d.summary;
19
+ if (sel.ref !== d) {
20
+ // Default: request everything the user is allowed to grant.
21
+ sel = { ref: d, grants: new Set(s.capabilities.filter((c) => !c.adminOnly || d.isAdmin).map((c) => c.id)) };
22
+ }
23
+ const toggle = (cap) => { sel.grants.has(cap) ? sel.grants.delete(cap) : sel.grants.add(cap); ui.rerender?.(); };
24
+
25
+ return div({},
26
+ div({ className: 'scrim' }).on({ click: () => wb.closeDialog() }),
27
+ div({ className: 'dialog review', $styling: { width: 'min(560px, 96vw)' } },
28
+ header(s),
29
+ div({ className: 'review-body' },
30
+ s.description ? p({ className: 'review-desc' }, s.description) : null,
31
+ section('Capabilities it requests', s.capabilities.length
32
+ ? div({ className: 'cap-list' }, ...s.capabilities.map((c) => capRow(c, d.isAdmin, sel.grants.has(c.id), () => toggle(c.id))))
33
+ : muted('None — this plugin only runs in its sandbox.')),
34
+ (s.network || []).length ? section('Network access', div({ className: 'contrib-list' }, ...s.network.map(endpointRow))) : null,
35
+ (s.commands || []).length
36
+ ? section('Commands it can run', div({ className: 'contrib-list' }, ...s.commands.map((id) => commandRow(id, ui))))
37
+ : null,
38
+ s.storage ? section('Storage', storageRows(s.storage)) : null,
39
+ s.contributions.length ? section('What it adds', div({ className: 'contrib-list' }, ...s.contributions.map(contribRow))) : null,
40
+ s.settings.length ? section('Settings', div({ className: 'contrib-list' }, ...s.settings.map(settingRow))) : null,
41
+ div({ className: 'review-meta' }, `${s.fileCount} files · ${fmtBytes(s.sizeBytes)} · id ${s.id}`),
42
+ ),
43
+ div({ className: 'row-actions' },
44
+ button({ className: 'btn' }, 'Cancel').on({ click: () => wb.closeDialog() }),
45
+ button({ className: 'btn primary' }, icon('plug', { size: 15 }), 'Install').on({ click: () => d.onInstall([...sel.grants]) }),
46
+ ),
47
+ ),
48
+ );
49
+ }
50
+
51
+ function header(s) {
52
+ const t = s.trust || { status: 'unverified' };
53
+ return div({ className: 'review-head' },
54
+ div({ className: 'avatar' }, (s.name || '?')[0].toUpperCase()),
55
+ div({ className: 'rh-main' },
56
+ div({ className: 'rh-name' }, s.name, span({ className: 'rh-ver' }, 'v' + s.version)),
57
+ div({ className: 'rh-author' }, 'by ' + s.author),
58
+ ),
59
+ trustBadge(t),
60
+ );
61
+ }
62
+
63
+ function trustBadge(t) {
64
+ if (t.status === 'verified') {
65
+ return span({ className: 'trust verified', title: `Signed by a key published at ${t.domain}` }, icon('check', { size: 13 }), 'Verified · ' + t.domain);
66
+ }
67
+ if (t.status === 'signed') {
68
+ return span({ className: 'trust signed', title: t.reason || 'Signed, but the domain does not vouch for the key' }, icon('info', { size: 13 }), 'Signed');
69
+ }
70
+ if (t.status === 'invalid') {
71
+ return span({ className: 'trust invalid', title: t.reason || 'Invalid signature — the package may have been tampered with' }, icon('warn', { size: 13 }), 'Invalid signature');
72
+ }
73
+ return span({ className: 'trust unverified', title: t.reason || 'This plugin is not signed' }, icon('warn', { size: 13 }), 'Unverified');
74
+ }
75
+
76
+ function capRow(cap, isAdmin, checked, onToggle) {
77
+ const blocked = cap.adminOnly && !isAdmin;
78
+ return label({ className: `cap-row ${blocked ? 'blocked' : ''}` },
79
+ input({ type: 'checkbox', checked: checked && !blocked, disabled: blocked }).on({ change: () => !blocked && onToggle() }),
80
+ div({ className: 'cap-info' },
81
+ div({ className: 'cap-name' }, cap.id, cap.adminOnly ? span({ className: 'pf-badge', $styling: { color: 'var(--warn)', 'margin-left': '6px' } }, 'admin only') : null),
82
+ div({ className: 'cap-desc' }, cap.description + (blocked ? ' — requires an administrator' : '')),
83
+ ),
84
+ );
85
+ }
86
+
87
+ function contribRow(c) {
88
+ const kindIcon = { command: 'command', opener: 'file', indexer: 'search', statusItem: 'info', register: 'info', keymap: 'command' }[c.kind] || 'command';
89
+ return div({ className: 'contrib-row' },
90
+ icon(kindIcon, { size: 13 }),
91
+ span({ className: 'cr-title' }, c.title),
92
+ c.detail ? span({ className: 'cr-detail' }, c.detail) : null,
93
+ c.offline ? span({ className: 'pf-badge offline-ok' }, 'offline') : null,
94
+ span({ className: 'pf-kind' }, c.kind),
95
+ );
96
+ }
97
+
98
+ // One command the plugin may ask the host to run. Resolved against the live registry
99
+ // so the user sees the human title and where it comes from (built-in vs another
100
+ // plugin) — an id like "explorer.delete" alone doesn't convey what it does.
101
+ function commandRow(id, ui) {
102
+ const cmd = ui?.platform?.contributions?.get?.(id);
103
+ const source = cmd?.pluginId ? 'plugin' : cmd ? 'built-in' : 'not installed';
104
+ return div({ className: `contrib-row ${cmd ? '' : 'blocked'}` },
105
+ icon('command', { size: 13 }),
106
+ span({ className: 'cr-title' }, cmd?.title || id),
107
+ span({ className: 'cr-detail' }, id),
108
+ span({ className: 'pf-kind' }, source),
109
+ );
110
+ }
111
+
112
+ function endpointRow(ep) {
113
+ return div({ className: 'contrib-row' },
114
+ icon('plug', { size: 13 }),
115
+ span({ className: 'cr-title' }, ep.host),
116
+ ep.path ? span({ className: 'cr-detail' }, ep.path) : null,
117
+ span({ className: 'pf-kind' }, ep.scheme),
118
+ );
119
+ }
120
+
121
+ function storageRows(st) {
122
+ const rows = [];
123
+ if (st.plugin) rows.push(storageRow('Private database', 'A SQLite store just for this plugin (server + this device).', false));
124
+ if (st.domain) rows.push(storageRow('Shared database', `Shared with other plugins from ${'its domain'} (SQLite).`, st.domainBlocked));
125
+ return div({ className: 'contrib-list' }, ...rows);
126
+ }
127
+ function storageRow(title, detail, blocked) {
128
+ return div({ className: `contrib-row ${blocked ? 'blocked' : ''}` },
129
+ icon('plug', { size: 13 }),
130
+ span({ className: 'cr-title' }, title),
131
+ span({ className: 'cr-detail' }, blocked ? detail + ' — needs a verified domain' : detail),
132
+ blocked ? span({ className: 'pf-badge', $styling: { color: 'var(--warn)' } }, 'unavailable') : null,
133
+ );
134
+ }
135
+
136
+ function settingRow(st) {
137
+ return div({ className: 'contrib-row' },
138
+ icon('gear', { size: 13 }),
139
+ span({ className: 'cr-title' }, st.title),
140
+ st.secret
141
+ ? span({ className: 'pf-badge', $styling: { color: 'var(--warn)' } }, 'secret')
142
+ : span({ className: 'pf-kind' }, st.type),
143
+ );
144
+ }
145
+
146
+ function section(title, content) {
147
+ return div({ className: 'review-section' }, div({ className: 'rs-title' }, title), content);
148
+ }
149
+ function muted(t) {
150
+ return div({ className: 'muted', $styling: { 'font-size': '12.5px' } }, t);
151
+ }
@@ -0,0 +1,120 @@
1
+ import { dd } from '../../runtime.js';
2
+ import { icon } from '../icon.js';
3
+
4
+ const { div, span, p, button, h2, input, label } = dd;
5
+
6
+ export default function pluginsView(state, ui) {
7
+ const plugins = state.plugins || [];
8
+ return div({ className: 'editor' },
9
+ div({ className: 'stage' },
10
+ div({ className: 'plugins' },
11
+ h2('Plugins'),
12
+ p({ className: 'sub', $styling: { color: 'var(--text-dim)', margin: '0 0 8px' } },
13
+ 'Plugins are sandboxed packages you install from a file or URL. They run in an isolated iframe and reach Trove only through a message channel, using the capabilities you granted.'),
14
+ div({ className: 'plugin-install' },
15
+ button({ className: 'btn primary' }, icon('upload', { size: 15 }), 'Install from file…')
16
+ .on({ click: () => ui.exec('plugins.installFromFile') }),
17
+ button({ className: 'btn' }, icon('plug', { size: 15 }), 'Install from URL…')
18
+ .on({ click: () => ui.exec('plugins.installFromUrl') }),
19
+ ),
20
+ plugins.length
21
+ ? div({ $styling: { display: 'flex', 'flex-direction': 'column', gap: '12px', 'margin-top': '14px' } }, ...plugins.map((pl) => installedCard(pl, state, ui)))
22
+ : div({ className: 'empty', $styling: { padding: '48px' } }, icon('plug', { size: 30 }), span('No plugins installed yet.')),
23
+ ),
24
+ ),
25
+ );
26
+ }
27
+
28
+ function installedCard(pl, state, ui) {
29
+ const connectivity = !pl.responsive ? 'not responding' : pl.manifest?.online === false ? 'offline' : 'connected';
30
+ const features = pl.features || [];
31
+ return div({ className: 'plugin-card' },
32
+ div({ className: 'top' },
33
+ div({ className: 'avatar' }, (pl.name || '?')[0].toUpperCase()),
34
+ div({ $styling: { flex: '1' } },
35
+ div({ className: 'name' }, pl.name, pl.version ? span({ $styling: { color: 'var(--text-faint)', 'font-weight': '400', 'margin-left': '6px' } }, 'v' + pl.version) : null),
36
+ div({ $styling: { 'font-size': '11px', color: 'var(--text-faint)' } }, pl.id),
37
+ ),
38
+ trustBadge(pl.trust),
39
+ span({ className: `status ${pl.status}` }, pl.status),
40
+ ),
41
+ pl.error ? div({ className: 'desc', $styling: { color: 'var(--danger)' } }, pl.error) : null,
42
+ div({ className: 'caps' }, ...(pl.capabilities || []).map((c) => span({ className: 'cap' }, c))),
43
+ (pl.endpoints || []).length
44
+ ? div({ className: 'plugin-endpoints', $styling: { 'font-size': '11px', color: 'var(--text-faint)', 'margin-top': '2px' } },
45
+ icon('plug', { size: 11 }), ' Network: ', (pl.endpoints || []).map((e) => e.host).join(', '))
46
+ : null,
47
+ pl.status === 'active'
48
+ ? div({ className: 'plugin-features' },
49
+ div({ className: 'pf-head' }, span(`Features · ${connectivity}`), span({ className: 'muted' }, `${features.filter((f) => f.available).length}/${features.length} available`)),
50
+ features.length
51
+ ? div({ className: 'pf-list' }, ...features.map(featureRow))
52
+ : div({ className: 'muted', $styling: { 'font-size': '12px' } }, pl.responsive ? 'No contributions announced.' : 'No manifest received — the plugin may not be running.'),
53
+ )
54
+ : null,
55
+ (pl.settingsSchema || []).length ? settingsSection(pl, ui) : null,
56
+ div({ className: 'actions' },
57
+ pl.hasUi ? button({ className: 'btn' }, icon('command', { size: 14 }), 'Open panel').on({ click: () => ui.platform.workbench.openPluginPanel(pl.id) }) : null,
58
+ button({ className: 'btn' }, icon('refresh', { size: 14 }), 'Refresh').on({ click: () => ui.platform.plugins.refresh(pl.id) }),
59
+ button({ className: 'btn danger' }, 'Uninstall').on({
60
+ click: () => ui.platform.workbench.showDialog({
61
+ kind: 'confirm', title: `Uninstall ${pl.name}?`, danger: true, confirmLabel: 'Uninstall',
62
+ body: 'The plugin and all data it stored will be removed.',
63
+ onConfirm: () => { ui.platform.workbench.closeDialog(); ui.uninstallPlugin(pl.id); },
64
+ }),
65
+ }),
66
+ ),
67
+ );
68
+ }
69
+
70
+ function settingsSection(pl, ui) {
71
+ return div({ className: 'plugin-features' },
72
+ div({ className: 'pf-head' }, span('Settings')),
73
+ div({ $styling: { display: 'flex', 'flex-direction': 'column', gap: '8px' } },
74
+ ...pl.settingsSchema.map((s) => settingRow(pl, s, ui)),
75
+ ),
76
+ );
77
+ }
78
+
79
+ function settingRow(pl, s, ui) {
80
+ const key = `${pl.id}.${s.key}`;
81
+ const value = s.secret ? '' : (ui.platform.settings.get(key) ?? s.default ?? '');
82
+ return div({ className: 'setting', $styling: { padding: '6px 0' } },
83
+ div({ className: 'info' }, div({ className: 't' }, s.title || s.key), s.description ? div({ className: 'd' }, s.description) : null),
84
+ div({ className: 'control' },
85
+ s.secret
86
+ ? input({ className: 'input', type: 'password', placeholder: 'Set secret…' }).on({
87
+ change: (e) => { if (e.target.value) { ui.platform.plugins.setSecret(pl.id, s.key, e.target.value); e.target.value = ''; e.target.placeholder = 'Saved ✓'; } },
88
+ })
89
+ : s.type === 'boolean'
90
+ ? label({ className: 'switch' }, input({ type: 'checkbox', checked: !!value }).on({ change: (e) => ui.platform.settings.set(key, e.target.checked) }), span({ className: 'track' }))
91
+ : input({ className: 'input', value }).on({ change: (e) => ui.platform.settings.set(key, e.target.value) }),
92
+ ),
93
+ );
94
+ }
95
+
96
+ function trustBadge(t) {
97
+ if (!t) return null;
98
+ if (t.status === 'verified') return span({ className: 'trust verified', title: 'Signed by ' + t.domain }, icon('check', { size: 12 }), t.domain);
99
+ if (t.status === 'signed') return span({ className: 'trust signed' }, icon('info', { size: 12 }), 'signed');
100
+ // A BROKEN signature is not the same as no signature. The install dialog says so in
101
+ // red; this list used to fall through to the same amber "unverified" an ordinary
102
+ // unsigned plugin gets, so a package whose signature failed to verify — the one case
103
+ // that means someone tampered with it — looked like the common, benign one.
104
+ if (t.status === 'invalid') {
105
+ return span({ className: 'trust invalid', title: t.reason || 'The signature did not verify — this package may have been altered' },
106
+ icon('warn', { size: 12 }), 'invalid signature');
107
+ }
108
+ return span({ className: 'trust unverified' }, icon('warn', { size: 12 }), 'unverified');
109
+ }
110
+
111
+ const KIND_ICON = { command: 'command', opener: 'file', indexer: 'search', statusItem: 'info' };
112
+ function featureRow(f) {
113
+ return div({ className: `pf-row ${f.available ? '' : 'off'}` },
114
+ icon(KIND_ICON[f.kind] || 'command', { size: 13 }),
115
+ span({ className: 'pf-title' }, f.title),
116
+ span({ className: 'pf-kind' }, f.kind),
117
+ f.offline ? span({ className: 'pf-badge offline-ok', title: 'Works offline' }, 'offline ✓') : null,
118
+ span({ className: `pf-dot ${f.available ? 'on' : 'no'}`, title: f.available ? 'Available now' : 'Unavailable now' }),
119
+ );
120
+ }
@@ -0,0 +1,258 @@
1
+ import { dd } from '../../runtime.js';
2
+ import { prettyKey, eventToKey } from '../../platform/keybindings.js';
3
+ import { icon } from '../icon.js';
4
+ import { listAssociations, rememberOpener } from '../../bl/openers.js';
5
+
6
+ const { div, h2, h3, p, span, select, option, input, label, button } = dd;
7
+
8
+ export default function settingsView(state, ui) {
9
+ const groups = ui.platform.settings.grouped();
10
+ return div({ className: 'editor' },
11
+ div({ className: 'stage' },
12
+ div({ className: 'settings' },
13
+ h2('Settings'),
14
+ p({ className: 'sub' }, 'Preferences are stored in this browser. Plugins contribute their own settings here too.'),
15
+ ...groups.map((g) => group(g, ui)),
16
+ mcpSection(ui),
17
+ openersSection(ui),
18
+ keybindingsSection(ui),
19
+ ),
20
+ ),
21
+ );
22
+ }
23
+
24
+ function group(g, ui) {
25
+ return div({ className: 'group' },
26
+ h3(g.category),
27
+ ...g.items.map((s) => setting(s, ui)),
28
+ );
29
+ }
30
+
31
+ function setting(s, ui) {
32
+ return div({ className: 'setting' },
33
+ div({ className: 'info' },
34
+ div({ className: 't' }, s.title || s.key),
35
+ s.description ? div({ className: 'd' }, s.description) : null,
36
+ ),
37
+ div({ className: 'control' }, control(s, ui)),
38
+ );
39
+ }
40
+
41
+ function control(s, ui) {
42
+ const set = (v) => ui.platform.settings.set(s.key, v);
43
+ if (s.type === 'boolean') {
44
+ return label({ className: 'switch' },
45
+ input({ type: 'checkbox', checked: !!s.value }).on({ change: (e) => set(e.target.checked) }),
46
+ span({ className: 'track' }),
47
+ );
48
+ }
49
+ if (s.type === 'enum') {
50
+ return select({ className: 'input' },
51
+ ...s.enum.map((opt, i) =>
52
+ option({ value: opt, selected: s.value === opt }, (s.enumLabels && s.enumLabels[i]) || opt),
53
+ ),
54
+ ).on({ change: (e) => set(e.target.value) });
55
+ }
56
+ if (s.type === 'number') {
57
+ // Guard against a cleared field (Number('') === 0) or a paste yielding NaN, and
58
+ // clamp to the declared range so downstream consumers never see an invalid value.
59
+ const commit = (e) => {
60
+ let n = Number(e.target.value);
61
+ if (e.target.value === '' || Number.isNaN(n)) { e.target.value = s.value; return; }
62
+ if (s.minimum != null) n = Math.max(s.minimum, n);
63
+ if (s.maximum != null) n = Math.min(s.maximum, n);
64
+ e.target.value = n;
65
+ set(n);
66
+ };
67
+ return input({
68
+ className: 'input', type: 'number', value: s.value,
69
+ $attrs: { min: s.minimum ?? '', max: s.maximum ?? '' },
70
+ }).on({ change: commit });
71
+ }
72
+ return input({ className: 'input', value: s.value ?? '' }).on({ change: (e) => set(e.target.value) });
73
+ }
74
+ // Connecting an AI agent to this drive.
75
+ //
76
+ // Read-only, and deliberately so. Everything here is DEPLOYMENT configuration — which
77
+ // identity provider issues the tokens, where the endpoint lives — supplied by the
78
+ // environment or by whoever constructed the server. Pointing the drive at a different
79
+ // authorization server changes who can reach every file in it, which is a `docker run`
80
+ // decision rather than a preference, and a settings field would make it look otherwise.
81
+ //
82
+ // What this section is FOR is the two questions a self-hoster has and currently cannot
83
+ // answer: what URL do I paste into my assistant, and why does it say 401. The second is
84
+ // why `needsAuthorizationServer` is called out as a problem — a drive with auth on and
85
+ // no authorization server configured looks perfectly set up and cannot work.
86
+ //
87
+ // It reads straight off `capabilities`, which the app already fetched at startup, so
88
+ // there is no loading state and nothing to go stale.
89
+ function mcpSection(ui) {
90
+ const caps = ui.platform.capabilities;
91
+ if (!caps) return null; // capabilities never arrived; the shell has bigger problems
92
+ const mcp = caps.mcp;
93
+ const auth = caps.auth || { authorizationServers: [], source: 'none' };
94
+ if (!mcp?.enabled) {
95
+ return div({ className: 'group' }, h3('AI agents (MCP)'),
96
+ p({ className: 'sub' }, 'MCP is switched off on this server. Set TROVE_MCP=on to enable it.'));
97
+ }
98
+
99
+ const copy = (text) => () => {
100
+ navigator.clipboard?.writeText(text)
101
+ .then(() => ui.platform.notifications.success('Copied'))
102
+ // Clipboard access is denied often enough that failing silently would look like a
103
+ // broken button; showing the value still gets it into the user's hands.
104
+ .catch(() => ui.platform.notifications.info(text, { sticky: true }));
105
+ };
106
+
107
+ const servers = auth.authorizationServers || [];
108
+ // Where the value came from matters when it is wrong: "we took this from your JWT
109
+ // issuer" points at a different file than "you set this".
110
+ const provenance = auth.source === 'jwt-issuer' ? 'from TROVE_JWT_ISSUER'
111
+ : auth.source === 'configured' ? 'from TROVE_AUTH_SERVER' : null;
112
+
113
+ return div({ className: 'group' },
114
+ h3('AI agents (MCP)'),
115
+ p({ className: 'sub' }, 'Point an AI assistant at this drive. It signs in as you and sees exactly the collections you can see.'),
116
+
117
+ div({ className: 'setting' },
118
+ div({ className: 'info' },
119
+ div({ className: 't' }, 'Server URL'),
120
+ div({ className: 'd' }, 'Paste this into your assistant\u2019s MCP settings.'),
121
+ ),
122
+ div({ className: 'control mcp-url' },
123
+ span({ className: 'mono' }, mcp.endpoint),
124
+ button({ className: 'btn small', title: 'Copy' }, icon('link', { size: 13 })).on({ click: copy(mcp.endpoint) }),
125
+ ),
126
+ ),
127
+
128
+ div({ className: 'setting' },
129
+ div({ className: 'info' },
130
+ div({ className: 't' }, 'Sign-in required'),
131
+ div({ className: 'd' }, mcp.requiresAuth
132
+ ? 'Agents must present a token, the same one this browser uses.'
133
+ : 'This drive is open, so agents connect without a token \u2014 exactly like the web app does.'),
134
+ ),
135
+ div({ className: 'control' }, span({ className: 'mono' }, mcp.requiresAuth ? 'Yes' : 'No')),
136
+ ),
137
+
138
+ // The failure that looks like success. Before the value, because it is the answer
139
+ // to "I set this up and my agent just says 401".
140
+ mcp.needsAuthorizationServer
141
+ ? div({ className: 'mcp-warn' }, icon('warn', { size: 15 }),
142
+ span('This drive requires a token but no authorization server is set, so an agent has '
143
+ + 'nowhere to sign in. Set TROVE_AUTH_SERVER (or TROVE_JWT_ISSUER) to the issuer URL '
144
+ + 'of your identity provider and restart.'))
145
+ : null,
146
+
147
+ div({ className: 'setting' },
148
+ div({ className: 'info' },
149
+ div({ className: 't' }, 'Authorization server'),
150
+ div({ className: 'd' }, 'Where clients are sent to sign in \u2014 for the whole drive, not just for agents. '
151
+ + 'Set with TROVE_AUTH_SERVER; defaults to TROVE_JWT_ISSUER when that is set.'),
152
+ ),
153
+ div({ className: 'control' },
154
+ servers.length
155
+ ? span({ className: 'mono' }, servers.join(', ') + (provenance ? ` (${provenance})` : ''))
156
+ : span({ className: 'mono muted' }, 'not set'),
157
+ ),
158
+ ),
159
+
160
+ div({ className: 'setting' },
161
+ div({ className: 'info' },
162
+ div({ className: 't' }, 'Discovery document'),
163
+ div({ className: 'd' }, 'What a client reads to find out how to authenticate (RFC 9728).'),
164
+ ),
165
+ div({ className: 'control mcp-url' }, span({ className: 'mono small' }, auth.metadataUrl)),
166
+ ),
167
+ );
168
+ }
169
+
170
+ // Default openers per file type — the "always use this" choices from the opener
171
+ // chooser. Each row shows the type → viewer; the × forgets it (so the next open of
172
+ // that type asks again). Empty when the user hasn't set any defaults.
173
+ function openersSection(ui) {
174
+ const rows = listAssociations(ui.platform);
175
+ return div({ className: 'group' },
176
+ h3('Default Openers'),
177
+ rows.length
178
+ ? div({},
179
+ ...rows.map((r) =>
180
+ div({ className: 'setting' },
181
+ div({ className: 'info' },
182
+ div({ className: 't' }, r.typeKey),
183
+ div({ className: 'd' }, r.missing ? `${r.openerTitle} (no longer installed)` : `Opens with ${r.openerTitle}`),
184
+ ),
185
+ div({ className: 'control' },
186
+ button({ className: 'iconbtn', title: 'Forget this default' }, icon('close', { size: 14 }))
187
+ .on({ click: () => rememberOpener(ui.platform, r.typeKey, null) }),
188
+ ),
189
+ ),
190
+ ),
191
+ )
192
+ : p({ className: 'sub', $styling: { margin: 0 } }, 'No default openers set yet. When a file type has more than one viewer, you can pick one and check “Always use this”.'),
193
+ );
194
+ }
195
+
196
+ // Which shortcut is currently listening for its new chord. Module-level because the
197
+ // settings view re-renders from scratch and this is transient UI state, not a setting.
198
+ let capturing = null;
199
+
200
+ function keybindingsSection(ui) {
201
+ const kb = ui.platform.keybindings;
202
+ const bindings = kb.resolved();
203
+ const cmds = ui.platform.contributions;
204
+ const overrides = kb.overrides();
205
+ const stop = () => { capturing = null; ui.rerender?.(); };
206
+ // Which chords more than one command answers to. Nothing rejects a collision, and
207
+ // `#matchFor` scans in reverse so the LAST registration wins — bind Delete onto
208
+ // ⌘P and Quick Open stops opening, with no hint anywhere that that is why.
209
+ const byKey = new Map();
210
+ for (const b of bindings) byKey.set(b.key, (byKey.get(b.key) || 0) + 1);
211
+ return div({ className: 'group' },
212
+ h3('Keyboard Shortcuts'),
213
+ p({ className: 'sub' }, 'Click a shortcut to record a new one. Esc cancels; Backspace clears it.'),
214
+ // Every binding, not the first 40. The cap was arbitrary and silent — with no "N
215
+ // more" anywhere, the shortcuts past it simply did not exist as far as anyone could
216
+ // tell, and one plugin keymap was enough to push real ones off the end.
217
+ ...bindings.map((b) => {
218
+ const cmd = cmds.get(b.command);
219
+ const listening = capturing === b.bindingId;
220
+ const custom = !!overrides[b.bindingId];
221
+ const clash = byKey.get(b.key) > 1;
222
+ return div({ className: 'setting' },
223
+ div({ className: 'info' },
224
+ div({ className: 't' }, cmd?.title || b.command),
225
+ div({ className: 'd' }, b.command),
226
+ ),
227
+ div({ className: 'control' },
228
+ clash && !listening
229
+ ? span({ className: 'kbd-clash', title: 'Another command answers to this shortcut too — the one registered last wins' },
230
+ icon('warn', { size: 12 }))
231
+ : null,
232
+ button({ className: `kbd-edit ${listening ? 'listening' : ''} ${clash ? 'clash' : ''}`, title: listening ? 'Press the new shortcut' : 'Click to rebind' },
233
+ listening ? span('Press keys…') : dd.h('kbd', prettyKey(b.key)))
234
+ .on({
235
+ click: () => { capturing = listening ? null : b.bindingId; ui.rerender?.(); },
236
+ blur: () => { if (listening) stop(); },
237
+ keydown: (e) => {
238
+ if (!listening) return;
239
+ e.preventDefault();
240
+ e.stopPropagation();
241
+ if (e.key === 'Escape') return stop();
242
+ if (e.key === 'Backspace') { kb.rebind(b, null); return stop(); }
243
+ // A bare modifier isn't a chord yet — wait for the key it modifies.
244
+ if (['Control', 'Meta', 'Alt', 'Shift'].includes(e.key)) return;
245
+ kb.rebind(b, eventToKey(e));
246
+ stop();
247
+ },
248
+ $attach: (el) => { if (listening) queueMicrotask(() => el.focus()); },
249
+ }),
250
+ custom
251
+ ? button({ className: 'c-link', title: 'Back to the default' }, 'reset')
252
+ .on({ click: () => { kb.rebind(b, null); ui.rerender?.(); } })
253
+ : null,
254
+ ),
255
+ );
256
+ }),
257
+ );
258
+ }