@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,77 @@
1
+ // ContextKeyService — the reactive bag of boolean/string/number flags that
2
+ // describe the workbench's current state (which view is focused, whether a file
3
+ // is open, its type, whether the palette is showing…). when-clauses evaluate
4
+ // against it, so commands/keybindings/menus light up and dim as state changes.
5
+ //
6
+ // It's a single cell of the whole context object, so any consumer can `watch` it and
7
+ // re-render, and keybinding resolution reads a plain snapshot.
8
+ // Plugins get a *scoped* setter (keys they set are namespaced under their id) so
9
+ // they can drive their own when-clauses without stomping core keys.
10
+
11
+ import { cell } from '../runtime.js';
12
+ import { evaluateWhen } from './whenclause.js';
13
+
14
+ export class ContextKeyService {
15
+ constructor(initial = {}) {
16
+ this.state = {
17
+ platform: navigatorPlatform(),
18
+ isMac: /mac/i.test(navigatorPlatform()),
19
+ ...initial,
20
+ };
21
+ this.cell = cell(this.state);
22
+ }
23
+
24
+ get(key) {
25
+ return this.state[key];
26
+ }
27
+ snapshot() {
28
+ return this.state;
29
+ }
30
+ observe() {
31
+ return this.cell;
32
+ }
33
+
34
+ set(key, value) {
35
+ if (this.state[key] === value) return;
36
+ this.state = { ...this.state, [key]: value };
37
+ this.cell.setValue(this.state);
38
+ }
39
+ setMany(obj) {
40
+ let changed = false;
41
+ const next = { ...this.state };
42
+ for (const [k, v] of Object.entries(obj)) {
43
+ if (next[k] !== v) {
44
+ next[k] = v;
45
+ changed = true;
46
+ }
47
+ }
48
+ if (changed) {
49
+ this.state = next;
50
+ this.cell.setValue(next);
51
+ }
52
+ }
53
+ remove(key) {
54
+ if (!(key in this.state)) return;
55
+ const { [key]: _drop, ...rest } = this.state;
56
+ this.state = rest;
57
+ this.cell.setValue(rest);
58
+ }
59
+
60
+ /** True if `whenExpr` holds against the current context. */
61
+ evaluate(whenExpr) {
62
+ return evaluateWhen(whenExpr, this.state);
63
+ }
64
+
65
+ /** A setter namespaced under a plugin id, so plugin keys can't collide. */
66
+ scopedFor(pluginId) {
67
+ const prefix = `${pluginId}.`;
68
+ return {
69
+ set: (key, value) => this.set(prefix + key, value),
70
+ remove: (key) => this.remove(prefix + key),
71
+ };
72
+ }
73
+ }
74
+
75
+ function navigatorPlatform() {
76
+ return typeof navigator !== 'undefined' ? navigator.platform || navigator.userAgent || '' : '';
77
+ }
@@ -0,0 +1,156 @@
1
+ // ContributionRegistry — ONE map of `uri -> contribution`, where every contribution
2
+ // carries its own `type` and that type's options.
3
+ //
4
+ // There used to be a separate collection per kind (commands, openers, statusItems…),
5
+ // which meant a name could mean different things in different collections and nothing
6
+ // tied a contribution back to a verifiable owner. Now there is a single address space:
7
+ //
8
+ // trove+contrib:acme.com/docs/pdfViewer { type: 'opener', match, entry }
9
+ // trove+contrib:acme.com/docs/status { type: 'statusItem', slot, render }
10
+ // trove+contrib:acme.com/docs/busy { type: 'register', default }
11
+ // trove+contrib:acme.com/docs/keys { type: 'keymap', bindings }
12
+ // trove+contrib:core/workbench/… built-ins, reserved `core` domain
13
+ //
14
+ // So a plugin's opener, status slot, register and command may all be called "status"
15
+ // without colliding — with each other, or with another plugin's.
16
+ //
17
+ // Contribution types (see @3sln/trove/core/plugins/contributions.js for the manifest form):
18
+ // command { title, category?, icon?, when?, offline?, palette? }
19
+ // opener { title, match:{ext,mime}, entry, priority?, offline?, dock? }
20
+ // indexer { title, match, entry } (declared here; the SERVER runs it)
21
+ // statusItem { slot:'left'|'right', render:'html', order?, when?, offline? }
22
+ // register { default?, description? } (a context value slot)
23
+ // keymap { bindings:[{key, command, when?, args?}] }
24
+ // view { title, icon?, match?, priority?, render, move? }
25
+
26
+ import { cell, derive } from '../runtime.js';
27
+ import { selectorMatches } from '@3sln/trove/core/util.js';
28
+ import { parseContribUri, coreUri, CONTRIB_SCHEME } from '@3sln/trove/core/plugins/identity.js';
29
+ import { CONTRIBUTION_TYPES as PACKAGE_TYPES } from '@3sln/trove/core/plugins/contributions.js';
30
+
31
+ // Everything a package can declare, plus the workbench's own. `view` — how a list of
32
+ // results is drawn — is the second kind: it is not part of the package format, so core
33
+ // (which is where the manifest is parsed) has no reason to know the word.
34
+ export const CONTRIBUTION_TYPES = [...PACKAGE_TYPES, 'view'];
35
+
36
+ /**
37
+ * Address normalization — the one rule: a bare name belongs to the host's reserved
38
+ * `core` domain, anything from a plugin is always fully qualified. So the workbench
39
+ * keeps saying `exec('explorer.delete')` while every contribution still has a real,
40
+ * unambiguous URI (`trove+contrib:core/workbench/explorer.delete`).
41
+ */
42
+ export function toUri(nameOrUri) {
43
+ return String(nameOrUri).startsWith(CONTRIB_SCHEME) ? nameOrUri : coreUri(nameOrUri);
44
+ }
45
+
46
+ export class ContributionRegistry {
47
+ constructor() {
48
+ this.items = new Map(); // uri -> { uri, type, id, name, pluginId, ...options }
49
+ this.cell = cell([]);
50
+ this.byType = new Map(); // type -> derived cell (lazily created)
51
+ }
52
+
53
+ #emit() {
54
+ this.cell.setValue([...this.items.values()]);
55
+ }
56
+
57
+ /**
58
+ * Register one contribution at its URI. Returns a dispose fn.
59
+ * @param {string} nameOrUri a bare core name, or trove+contrib:<domain>/<plugin>/<name>
60
+ * @param {{type: string}} contribution
61
+ */
62
+ register(nameOrUri, contribution) {
63
+ const type = contribution?.type;
64
+ if (!CONTRIBUTION_TYPES.includes(type)) {
65
+ throw new Error(`Unknown contribution type "${type}" for ${nameOrUri}`);
66
+ }
67
+ const uri = toUri(nameOrUri);
68
+ const parsed = parseContribUri(uri);
69
+ if (!parsed) throw new Error(`Not a contribution URI: ${uri}`);
70
+ const pluginId = contribution.pluginId ?? (parsed.domain === 'core' ? null : parsed.pluginId);
71
+ const entry = {
72
+ ...contribution, uri, type,
73
+ // `id` is the short name the workbench uses to address it (bare for built-ins,
74
+ // the URI for plugin contributions, which are only ever addressed fully).
75
+ id: parsed.domain === 'core' ? parsed.name : uri,
76
+ name: parsed.name,
77
+ pluginId,
78
+ };
79
+ this.items.set(uri, entry);
80
+ this.#emit();
81
+ return () => this.unregister(uri);
82
+ }
83
+
84
+ /** Merge new options into an existing contribution (a plugin driving its own slot). */
85
+ update(nameOrUri, patch) {
86
+ const uri = toUri(nameOrUri);
87
+ const cur = this.items.get(uri);
88
+ if (!cur) return false;
89
+ this.items.set(uri, { ...cur, ...patch });
90
+ this.#emit();
91
+ return true;
92
+ }
93
+
94
+ unregister(nameOrUri) {
95
+ if (this.items.delete(toUri(nameOrUri))) this.#emit();
96
+ }
97
+ /** Drop everything a plugin contributed (uninstall / reload). */
98
+ unregisterPlugin(pluginId) {
99
+ let changed = false;
100
+ for (const [uri, c] of this.items) if (c.pluginId === pluginId) { this.items.delete(uri); changed = true; }
101
+ if (changed) this.#emit();
102
+ }
103
+
104
+ get(nameOrUri) {
105
+ return nameOrUri ? this.items.get(toUri(nameOrUri)) || null : null;
106
+ }
107
+ all() {
108
+ return [...this.items.values()];
109
+ }
110
+ ofType(type) {
111
+ return this.all().filter((c) => c.type === type);
112
+ }
113
+ /** One plugin's contributions, optionally of a single type. */
114
+ ofPlugin(pluginId, type) {
115
+ return this.all().filter((c) => c.pluginId === pluginId && (!type || c.type === type));
116
+ }
117
+ observe() {
118
+ return this.cell;
119
+ }
120
+ /**
121
+ * A reactive view of one type (status items, openers, …).
122
+ *
123
+ * Derived rather than a second subject fanned out to by hand: `derive` recomputes
124
+ * from the one list, and a watcher of the result is only re-rendered when its own
125
+ * slice actually differs — so registering an opener no longer redraws the status bar.
126
+ */
127
+ observeType(type) {
128
+ let view = this.byType.get(type);
129
+ if (!view) {
130
+ view = derive([this.cell], (all) => all.filter((c) => c.type === type));
131
+ this.byType.set(type, view);
132
+ }
133
+ return view;
134
+ }
135
+
136
+ // --- typed lookups ---------------------------------------------------------
137
+
138
+ /** Every opener whose selector matches `node`, best (highest priority) first. */
139
+ openersFor(node) {
140
+ return this.ofType('opener')
141
+ .filter((o) => selectorMatches(o.match, node))
142
+ .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
143
+ }
144
+
145
+ /** Pick the best opener for a node, honouring `when` and availability. */
146
+ openerFor(node, evaluate, isAvailable) {
147
+ return this.openersFor(node)
148
+ .find((o) => (!o.when || evaluate(o.when)) && (!isAvailable || isAvailable(o))) || null;
149
+ }
150
+
151
+ /** Every keybinding from every registered keymap, in registration order. */
152
+ keybindings() {
153
+ return this.ofType('keymap').flatMap((k) =>
154
+ (k.bindings || []).map((b) => ({ ...b, keymap: k.uri, pluginId: k.pluginId })));
155
+ }
156
+ }
@@ -0,0 +1,150 @@
1
+ // createPlatform — assemble every service into one object the whole app shares.
2
+ // This is the "workbench platform": the reactive registries and services that
3
+ // core features and plugins both build on. It also registers the default
4
+ // settings schema and keybindings so the shell has sensible behaviour out of the
5
+ // box. UI-facing shell state (which activity is open, is the palette showing…)
6
+ // lives in WorkbenchService; data and mutations go through ngin (see ../bl).
7
+
8
+ import { reactive } from '../runtime.js';
9
+ import { ContributionRegistry } from './contributions.js';
10
+ import { ContextKeyService } from './context.js';
11
+ import { CommandService } from './commands.js';
12
+ import { KeybindingService } from './keybindings.js';
13
+ import { SettingsService } from './settings.js';
14
+ import { NotificationService } from './notifications.js';
15
+ import { TroveApiClient } from './api.js';
16
+ import { PluginHost } from './pluginHost.js';
17
+ import { WorkbenchService } from './workbench.js';
18
+ import { ViewportService } from './viewport.js';
19
+ import { SpatialNavigationService } from './spatialNav.js';
20
+ import { VoiceSearchService } from './voiceSearch.js';
21
+ import { MediaUrlService } from './mediaUrls.js';
22
+
23
+ /**
24
+ * The bearer token for this browser, if any.
25
+ *
26
+ * `localStorage` rather than a cookie because the token is presented as an
27
+ * Authorization header, not sent ambiently — which is what makes CSRF a non-issue here.
28
+ * Wrapped in try/catch because storage access throws outright in some privacy modes,
29
+ * and a drive that white-screens because it couldn't read a key that is usually absent
30
+ * would be a poor trade.
31
+ */
32
+ export function readToken() {
33
+ try { return localStorage.getItem('trove.token') || null; } catch { return null; }
34
+ }
35
+ /** Store (or clear, with null) the bearer token this browser presents. */
36
+ export function writeToken(token) {
37
+ try {
38
+ if (token) localStorage.setItem('trove.token', token);
39
+ else localStorage.removeItem('trove.token');
40
+ } catch { /* storage unavailable; the session is simply not persisted */ }
41
+ }
42
+
43
+ export function createPlatform({ baseUrl = '' } = {}) {
44
+ const contributions = new ContributionRegistry();
45
+ // 'home' is where the app actually starts (WorkbenchService's initial `activity`), and
46
+ // nothing writes this key until the user navigates. Seeding it with a view that no
47
+ // longer exists left every `when: view.active == 'home'` binding — the Delete
48
+ // shortcut among them — dead from boot until the first click on the rail, while the
49
+ // row menu cheerfully advertised "Del" as the way to delete.
50
+ const context = new ContextKeyService({ 'view.active': 'home', 'sidebar.visible': true });
51
+ const notifications = new NotificationService();
52
+ const commands = new CommandService(contributions, context, notifications);
53
+ const settings = new SettingsService();
54
+ const keybindings = new KeybindingService(contributions, commands, context, settings);
55
+ // A bearer token, when this deployment uses one. Most don't: an authenticating proxy
56
+ // (Cloudflare Access, oauth2-proxy) sets its own header and the browser sends nothing.
57
+ // But a deployment where the user HOLDS a token has no other way to present it, and
58
+ // reading it here — once, from one place — keeps that out of every call site.
59
+ const api = new TroveApiClient({ baseUrl, token: () => readToken() });
60
+ const workbench = new WorkbenchService(context);
61
+ // Which shell to render — phone, desktop, or TV. Constructed before the defaults are
62
+ // registered, so it reads the setting through `settings.get` once that exists.
63
+ const viewport = new ViewportService({ settings, context });
64
+ // Arrow keys → geometry, but only on a TV. Inert everywhere else.
65
+ const spatialNav = new SpatialNavigationService({ workbench, viewport });
66
+ // Speak to search. Mostly this just puts the search field under the remote's mic —
67
+ // see voiceSearch.js for why that is the whole feature on a TV.
68
+ const voice = new VoiceSearchService({ workbench, notifications, settings });
69
+
70
+ const platform = {
71
+ reactive,
72
+ contributions, context, commands, keybindings, settings, notifications, api, workbench,
73
+ viewport, spatialNav, voice,
74
+ capabilities: null,
75
+ openPluginPanel: null, // set by the workbench UI
76
+ };
77
+ platform.mediaUrls = new MediaUrlService({ api: platform.api, settings });
78
+ platform.plugins = new PluginHost(platform);
79
+ // Commands consult the plugin host to hide/disable plugin commands that aren't
80
+ // available right now (offline, or the plugin isn't responding).
81
+ commands.availability = (cmd) => platform.plugins.isAvailable(cmd);
82
+
83
+ registerDefaults(platform);
84
+ // Defaults are in place now, so re-measure: `workbench.layout` was unreadable a moment
85
+ // ago and a forced layout must not need a resize to take effect.
86
+ viewport.refresh();
87
+ return platform;
88
+ }
89
+
90
+ function registerDefaults(p) {
91
+ // --- default settings schema ----------------------------------------------
92
+ p.settings.register([
93
+ { key: 'workbench.theme', type: 'enum', enum: ['dark', 'light', 'midnight'], enumLabels: ['Dark', 'Light', 'Midnight'], default: 'dark', title: 'Color theme', category: 'Appearance', order: 1 },
94
+ { key: 'workbench.density', type: 'enum', enum: ['comfortable', 'compact'], default: 'comfortable', title: 'List density', category: 'Appearance', order: 2 },
95
+ // Auto gets it right for phones and desktops. TVs are the reason this is a setting:
96
+ // a browser on a set-top box usually looks like a large desktop, so someone using
97
+ // one with a remote needs a way to say so.
98
+ { key: 'workbench.layout', type: 'enum', enum: ['auto', 'desktop', 'phone', 'tv'],
99
+ enumLabels: ['Automatic', 'Desktop', 'Phone', 'TV / remote'], default: 'auto',
100
+ title: 'Layout', description: 'Automatic follows the screen size. Choose TV for d-pad remote navigation.',
101
+ category: 'Appearance', order: 3 },
102
+ { key: 'explorer.sort', type: 'enum', enum: ['name', 'size', 'updatedAt'], enumLabels: ['Name', 'Size', 'Modified'], default: 'name', title: 'Sort files by', category: 'Explorer', order: 1 },
103
+ { key: 'explorer.sortOrder', type: 'enum', enum: ['asc', 'desc'], default: 'asc', title: 'Sort order', category: 'Explorer', order: 2 },
104
+ { key: 'explorer.confirmDelete', type: 'boolean', default: true, title: 'Confirm before deleting', category: 'Explorer', order: 3 },
105
+ { key: 'search.mode', type: 'enum', enum: ['hybrid', 'semantic', 'keyword'], default: 'hybrid', title: 'Search mode', description: 'Hybrid blends semantic meaning with keyword matches.', category: 'Search', order: 1 },
106
+ { key: 'uploads.concurrency', type: 'number', minimum: 1, maximum: 8, default: 4, title: 'Parallel upload parts', category: 'Transfers', order: 1 },
107
+ // Which viewer opens which file type. When several openers match a file and there
108
+ // is no entry here, the user is asked (and can save their answer) — see bl/openers.
109
+ // Markdown ships with an answer already: it matches both the markdown renderer and
110
+ // the plain-text viewer, and since documents are how things are grouped now,
111
+ // prompting on every first .md would be friction over a decision that's obvious.
112
+ // It stays changeable in Settings, and the text viewer is still reachable through
113
+ // the opener switcher.
114
+ { key: 'openers.associations', type: 'object', hidden: true, title: 'Default viewers',
115
+ default: { '.md': 'core.markdown', '.markdown': 'core.markdown' } },
116
+ // How results are drawn (see ui/components/views). Hidden and with no default on
117
+ // purpose: unset means "you decide", and the launcher then offers the grid by itself
118
+ // for a collection full of pictures. Picking one in the switcher pins it.
119
+ { key: 'explorer.view', type: 'string', hidden: true, title: 'Results view' },
120
+ // Whether media fetches use a URL that carries its own grant (see
121
+ // platform/mediaUrls.js). `auto` mints only where the browser cannot authenticate
122
+ // itself, which is the right answer almost everywhere — hence hidden.
123
+ { key: 'media.signedUrls', type: 'enum', enum: ['auto', 'always', 'never'], default: 'auto',
124
+ hidden: true, title: 'Signed media URLs' },
125
+ ]);
126
+
127
+ // --- the built-in keymap (commands themselves are registered in ../bl) -----
128
+ // One `keymap` contribution, exactly like a plugin's — the host has no privileged
129
+ // path into the keybinding service.
130
+ p.contributions.register('keymap.default', {
131
+ type: 'keymap',
132
+ bindings: [
133
+ { key: 'mod+shift+p', command: 'workbench.showCommandPalette' },
134
+ { key: 'mod+p', command: 'workbench.quickOpen' },
135
+ { key: 'mod+shift+f', command: 'workbench.view.home' },
136
+ { key: 'mod+shift+e', command: 'workbench.view.home' },
137
+ { key: 'mod+,', command: 'workbench.openSettings' },
138
+ { key: 'mod+shift+l', command: 'explorer.copyLink' },
139
+ { key: 'mod+u', command: 'explorer.upload' },
140
+ { key: 'delete', command: 'explorer.delete', when: "view.active == 'home' && explorer.hasSelection" },
141
+ { key: 'escape', command: 'workbench.closeOverlays' },
142
+ { key: 'f5', command: 'explorer.refresh' },
143
+ { key: 'mod+shift+i', command: 'workbench.toggleInfoPanel' },
144
+ // Some remotes and keyboards send a dedicated search key; where they do, it lands
145
+ // on the one command that opens the search field ready for dictation.
146
+ { key: 'mod+shift+v', command: 'search.voice' },
147
+ { key: 'browsersearch', command: 'search.voice' },
148
+ ],
149
+ });
150
+ }
@@ -0,0 +1,199 @@
1
+ // KeybindingService — turns keystrokes into command executions, honouring
2
+ // when-clauses and chords (multi-key sequences like "ctrl+k ctrl+s").
3
+ //
4
+ // Bindings come from `keymap` CONTRIBUTIONS: the host contributes one built-in keymap,
5
+ // and a plugin contributes one by declaring `{ type: 'keymap', path: 'keymaps/x.json' }`
6
+ // in its manifest (the host reads and validates that file at install). So core and
7
+ // plugins share exactly one keymap and the shortcuts UI can render all of it.
8
+ // User rebinds live in settings under `keybindings.overrides` (command -> key) and win
9
+ // over whatever a keymap declared. Typing in an input is respected: only bindings with
10
+ // a modifier (or Escape) fire while editing.
11
+
12
+ export const OVERRIDES_KEY = 'keybindings.overrides';
13
+
14
+ export function normalizeKey(str) {
15
+ // Canonical form: sorted modifiers + key, lowercased. "Cmd+Shift+P" → "meta+shift+p"
16
+ const parts = str.trim().toLowerCase().split('+').map((p) => p.trim());
17
+ const mods = new Set();
18
+ let key = '';
19
+ for (const p of parts) {
20
+ if (['ctrl', 'control'].includes(p)) mods.add('ctrl');
21
+ else if (['cmd', 'meta', 'super', 'win'].includes(p)) mods.add('meta');
22
+ else if (p === 'alt' || p === 'option') mods.add('alt');
23
+ else if (p === 'shift') mods.add('shift');
24
+ else if (['ctrlcmd', 'mod'].includes(p)) mods.add(isMac() ? 'meta' : 'ctrl');
25
+ else key = p;
26
+ }
27
+ const order = ['ctrl', 'meta', 'alt', 'shift'];
28
+ return [...order.filter((m) => mods.has(m)), key].filter(Boolean).join('+');
29
+ }
30
+
31
+ export function eventToKey(e) {
32
+ const mods = [];
33
+ if (e.ctrlKey) mods.push('ctrl');
34
+ if (e.metaKey) mods.push('meta');
35
+ if (e.altKey) mods.push('alt');
36
+ if (e.shiftKey) mods.push('shift');
37
+ let key = e.key.toLowerCase();
38
+ const named = { ' ': 'space', escape: 'escape', enter: 'enter', arrowup: 'up', arrowdown: 'down', arrowleft: 'left', arrowright: 'right' };
39
+ key = named[key] || key;
40
+ return [...mods.filter((m) => key !== m), key].join('+');
41
+ }
42
+
43
+ export class KeybindingService {
44
+ /**
45
+ * @param {import('./contributions.js').ContributionRegistry} contributions
46
+ * @param {import('./commands.js').CommandService} commands
47
+ * @param {import('./context.js').ContextKeyService} context
48
+ * @param {import('./settings.js').SettingsService} [settings] source of user rebinds
49
+ */
50
+ constructor(contributions, commands, context, settings = null) {
51
+ this.contributions = contributions;
52
+ this.commands = commands;
53
+ this.context = context;
54
+ this.settings = settings;
55
+ this.chordPrefix = null;
56
+ this.chordTimer = null;
57
+ this._onKeyDown = this.#onKeyDown.bind(this);
58
+ }
59
+
60
+ install(target = window) {
61
+ target.addEventListener('keydown', this._onKeyDown);
62
+ return () => target.removeEventListener('keydown', this._onKeyDown);
63
+ }
64
+
65
+ /**
66
+ * User rebinds, keyed by BINDING rather than by command.
67
+ *
68
+ * Keyed by command, one shortcut could not be changed without changing the others:
69
+ * `workbench.view.home` ships bound twice (⌘⇧F and ⌘⇧E), so rebinding either
70
+ * collapsed both onto the new chord — two identical rows in Settings and one shortcut
71
+ * silently gone. A binding's identity is (command, the key it was DECLARED with),
72
+ * which is stable across restarts and unique per row.
73
+ */
74
+ overrides() {
75
+ return this.settings?.get(OVERRIDES_KEY) || {};
76
+ }
77
+
78
+ /** The override key for one declared binding. */
79
+ static bindingId(binding) {
80
+ return `${binding.command}\u0000${normalizeKey(binding.defaultKey || binding.key)}`;
81
+ }
82
+
83
+ /**
84
+ * Rebind one BINDING (null clears it back to what its keymap declared).
85
+ * @param {{command: string, defaultKey?: string, key: string}|string} binding
86
+ * a resolved binding, or a bare command id for the legacy single-binding case.
87
+ */
88
+ rebind(binding, key) {
89
+ if (!this.settings) return;
90
+ const id = typeof binding === 'string'
91
+ ? this.#idForCommand(binding)
92
+ : KeybindingService.bindingId(binding);
93
+ if (!id) return;
94
+ const next = { ...this.overrides() };
95
+ if (key) next[id] = key; else delete next[id];
96
+ this.settings.set(OVERRIDES_KEY, next);
97
+ }
98
+
99
+ /** Resolve a bare command id to its (single) binding id, for older callers. */
100
+ #idForCommand(command) {
101
+ const b = this.contributions.keybindings().find((x) => x?.command === command && x.key);
102
+ return b ? KeybindingService.bindingId({ ...b, defaultKey: b.key }) : null;
103
+ }
104
+
105
+ /** Current effective bindings (every keymap's, plus user overrides). */
106
+ resolved() {
107
+ const overrides = this.overrides();
108
+ return this.contributions.keybindings()
109
+ .filter((b) => b?.key && b.command)
110
+ .map((b) => {
111
+ // `defaultKey` is what the keymap declared — the row's stable identity, and what
112
+ // "reset" restores to.
113
+ const defaultKey = normalizeKey(b.key);
114
+ const id = KeybindingService.bindingId({ command: b.command, defaultKey });
115
+ return { ...b, defaultKey, bindingId: id, key: normalizeKey(overrides[id] || b.key) };
116
+ });
117
+ }
118
+
119
+ #matchFor(keyChord) {
120
+ const snapshot = this.context.snapshot();
121
+ // Later registrations win (plugins can override), so scan in reverse.
122
+ const list = this.resolved();
123
+ for (let i = list.length - 1; i >= 0; i--) {
124
+ const b = list[i];
125
+ if (b.key !== keyChord) continue;
126
+ if (b.when && !this.context.evaluate(b.when)) continue;
127
+ return b;
128
+ }
129
+ return null;
130
+ }
131
+
132
+ #onKeyDown(e) {
133
+ if (e.defaultPrevented) return;
134
+ const chord = eventToKey(e);
135
+ const hasMod = e.ctrlKey || e.metaKey || e.altKey;
136
+ const typing = isTyping(e.target);
137
+
138
+ // While typing, only modifier chords and Escape are eligible (don't eat text).
139
+ if (typing && !hasMod && e.key !== 'Escape') {
140
+ this.#resetChord();
141
+ return;
142
+ }
143
+
144
+ const full = this.chordPrefix ? `${this.chordPrefix} ${chord}` : chord;
145
+ const binding = this.#matchFor(full);
146
+
147
+ if (binding) {
148
+ e.preventDefault();
149
+ this.#resetChord();
150
+ this.commands.execute(binding.command, ...(binding.args || []));
151
+ return;
152
+ }
153
+
154
+ // Could this be the first key of a chord?
155
+ if (!this.chordPrefix && this.resolved().some((b) => b.key.startsWith(chord + ' '))) {
156
+ e.preventDefault();
157
+ this.chordPrefix = chord;
158
+ this.chordTimer = setTimeout(() => this.#resetChord(), 1500);
159
+ return;
160
+ }
161
+ this.#resetChord();
162
+ }
163
+
164
+ #resetChord() {
165
+ this.chordPrefix = null;
166
+ if (this.chordTimer) clearTimeout(this.chordTimer);
167
+ this.chordTimer = null;
168
+ }
169
+
170
+ /** Human-readable label for a command's binding, for menus/tooltips. */
171
+ labelFor(command) {
172
+ const b = this.resolved().find((x) => x.command === command);
173
+ return b ? prettyKey(b.key) : null;
174
+ }
175
+ }
176
+
177
+ function isTyping(el) {
178
+ if (!el) return false;
179
+ const tag = el.tagName;
180
+ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable;
181
+ }
182
+ function isMac() {
183
+ return typeof navigator !== 'undefined' && /mac/i.test(navigator.platform || navigator.userAgent || '');
184
+ }
185
+ export function prettyKey(key) {
186
+ const mac = isMac();
187
+ const map = mac
188
+ ? { ctrl: '⌃', meta: '⌘', alt: '⌥', shift: '⇧', enter: '↵', escape: 'esc', up: '↑', down: '↓', left: '←', right: '→', space: '␣' }
189
+ : { ctrl: 'Ctrl', meta: 'Win', alt: 'Alt', shift: 'Shift', enter: 'Enter', escape: 'Esc', up: '↑', down: '↓', left: '←', right: '→', space: 'Space' };
190
+ return key
191
+ .split(' ')
192
+ .map((chord) =>
193
+ chord
194
+ .split('+')
195
+ .map((p) => map[p] || (p.length === 1 ? p.toUpperCase() : p[0].toUpperCase() + p.slice(1)))
196
+ .join(mac ? '' : '+'),
197
+ )
198
+ .join(' ');
199
+ }