@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.
- package/README.md +1227 -0
- package/package.json +75 -0
- package/packages/core/src/collections/index.js +249 -0
- package/packages/core/src/errors.js +186 -0
- package/packages/core/src/identity/discovery.js +210 -0
- package/packages/core/src/identity/index.js +188 -0
- package/packages/core/src/identity/jwt.js +199 -0
- package/packages/core/src/index.js +104 -0
- package/packages/core/src/indexers/contribution.js +115 -0
- package/packages/core/src/indexers/registry.js +162 -0
- package/packages/core/src/indexing.js +340 -0
- package/packages/core/src/issues.js +150 -0
- package/packages/core/src/kv.js +0 -0
- package/packages/core/src/links.js +141 -0
- package/packages/core/src/metadata/cursor.js +73 -0
- package/packages/core/src/metadata/interface.js +244 -0
- package/packages/core/src/metadata/memory.js +270 -0
- package/packages/core/src/metadata/sqlite.js +412 -0
- package/packages/core/src/notifications/index.js +139 -0
- package/packages/core/src/notifications/webpush.js +217 -0
- package/packages/core/src/plugins/contributions.js +177 -0
- package/packages/core/src/plugins/identity.js +98 -0
- package/packages/core/src/plugins/index.js +225 -0
- package/packages/core/src/plugins/indexers.js +142 -0
- package/packages/core/src/plugins/installStore.js +134 -0
- package/packages/core/src/plugins/package.js +102 -0
- package/packages/core/src/plugins/packageStore.js +61 -0
- package/packages/core/src/plugins/runtime.js +101 -0
- package/packages/core/src/plugins/sql.js +52 -0
- package/packages/core/src/retry.js +74 -0
- package/packages/core/src/scan.js +302 -0
- package/packages/core/src/search/embeddings.js +128 -0
- package/packages/core/src/search/index.js +200 -0
- package/packages/core/src/search/keywordStore.js +107 -0
- package/packages/core/src/search/sqliteStores.js +455 -0
- package/packages/core/src/search/tagMatch.js +59 -0
- package/packages/core/src/search/transformer.js +195 -0
- package/packages/core/src/search/vectorStore.js +274 -0
- package/packages/core/src/search/vectorize.js +249 -0
- package/packages/core/src/sidecar/document.js +213 -0
- package/packages/core/src/sidecar/index.js +174 -0
- package/packages/core/src/sidecar/manager.js +239 -0
- package/packages/core/src/sidecar/store.js +46 -0
- package/packages/core/src/signedUrls.js +170 -0
- package/packages/core/src/sqlite-d1.js +162 -0
- package/packages/core/src/sqlite-driver.js +42 -0
- package/packages/core/src/sqlite.js +162 -0
- package/packages/core/src/storage/filesystem.js +283 -0
- package/packages/core/src/storage/interface.js +222 -0
- package/packages/core/src/storage/memory.js +113 -0
- package/packages/core/src/storage/prefixed.js +75 -0
- package/packages/core/src/storage/s3.js +316 -0
- package/packages/core/src/storage/s3sigv4.js +185 -0
- package/packages/core/src/tasks.js +228 -0
- package/packages/core/src/uploads.js +386 -0
- package/packages/core/src/util.js +125 -0
- package/packages/core/src/vfs.js +666 -0
- package/packages/plugin-sdk/src/browser.js +316 -0
- package/packages/plugin-sdk/src/index.js +32 -0
- package/packages/plugin-sdk/src/protocol.js +59 -0
- package/packages/plugin-sdk/src/rpc.js +95 -0
- package/packages/server/src/adapters/bun.js +78 -0
- package/packages/server/src/adapters/node.js +115 -0
- package/packages/server/src/adapters/staticAssets.js +123 -0
- package/packages/server/src/adapters/webDist.js +70 -0
- package/packages/server/src/adapters/worker-tasks.js +206 -0
- package/packages/server/src/adapters/worker.js +159 -0
- package/packages/server/src/cachePolicy.js +34 -0
- package/packages/server/src/engine/README.md +88 -0
- package/packages/server/src/engine/actions/scanCollection.js +114 -0
- package/packages/server/src/engine/index.js +95 -0
- package/packages/server/src/engine/lazy.js +25 -0
- package/packages/server/src/engine/providers/access.js +363 -0
- package/packages/server/src/engine/providers/core.js +405 -0
- package/packages/server/src/engine/providers/scan.js +67 -0
- package/packages/server/src/index.js +698 -0
- package/packages/server/src/manifest.js +98 -0
- package/packages/server/src/mcp/auth.js +40 -0
- package/packages/server/src/mcp/index.js +213 -0
- package/packages/server/src/mcp/protocol.js +181 -0
- package/packages/server/src/mcp/tools.js +351 -0
- package/packages/server/src/router.js +229 -0
- package/packages/server/src/routes.js +1066 -0
- package/packages/server/src/scope.js +43 -0
- package/packages/web/dist/assets/chunk-4xqbzebh.js +5 -0
- package/packages/web/dist/assets/chunk-4xqbzebh.js.map +9 -0
- package/packages/web/dist/assets/chunk-h05bxfbs.js +5 -0
- package/packages/web/dist/assets/chunk-h05bxfbs.js.map +10 -0
- package/packages/web/dist/assets/main-4cxs7prw.js +356 -0
- package/packages/web/dist/assets/main-4cxs7prw.js.map +103 -0
- package/packages/web/dist/assets/styles-kcx1x337.css +1 -0
- package/packages/web/dist/icon.svg +11 -0
- package/packages/web/dist/index.html +16 -0
- package/packages/web/dist/sql-wasm.wasm +0 -0
- package/packages/web/dist/sw.js +186 -0
- package/packages/web/src/bl/actions.js +410 -0
- package/packages/web/src/bl/activity.js +306 -0
- package/packages/web/src/bl/commands.js +274 -0
- package/packages/web/src/bl/fileType.js +49 -0
- package/packages/web/src/bl/index.js +70 -0
- package/packages/web/src/bl/links.js +54 -0
- package/packages/web/src/bl/offline.js +268 -0
- package/packages/web/src/bl/openers.js +71 -0
- package/packages/web/src/bl/pluginInstall.js +59 -0
- package/packages/web/src/bl/services.js +143 -0
- package/packages/web/src/bl/social.js +234 -0
- package/packages/web/src/bl/tagQuery.js +44 -0
- package/packages/web/src/main.js +10 -0
- package/packages/web/src/platform/api.js +529 -0
- package/packages/web/src/platform/commands.js +89 -0
- package/packages/web/src/platform/context.js +77 -0
- package/packages/web/src/platform/contributions.js +156 -0
- package/packages/web/src/platform/index.js +150 -0
- package/packages/web/src/platform/keybindings.js +199 -0
- package/packages/web/src/platform/mediaUrls.js +137 -0
- package/packages/web/src/platform/navigation.js +131 -0
- package/packages/web/src/platform/notifications.js +50 -0
- package/packages/web/src/platform/overlay.js +81 -0
- package/packages/web/src/platform/pluginClientDb.js +132 -0
- package/packages/web/src/platform/pluginDock.js +141 -0
- package/packages/web/src/platform/pluginFrames.js +194 -0
- package/packages/web/src/platform/pluginHost.js +648 -0
- package/packages/web/src/platform/pluginMedia.js +62 -0
- package/packages/web/src/platform/pluginModules.js +90 -0
- package/packages/web/src/platform/pluginNet.js +71 -0
- package/packages/web/src/platform/pluginPackage.js +247 -0
- package/packages/web/src/platform/pluginRpc.js +377 -0
- package/packages/web/src/platform/pluginSigning.js +168 -0
- package/packages/web/src/platform/pluginStore.js +67 -0
- package/packages/web/src/platform/settings.js +101 -0
- package/packages/web/src/platform/spatialNav.js +286 -0
- package/packages/web/src/platform/viewport.js +123 -0
- package/packages/web/src/platform/voice.js +133 -0
- package/packages/web/src/platform/voiceSearch.js +155 -0
- package/packages/web/src/platform/whenclause.js +162 -0
- package/packages/web/src/platform/workbench.js +156 -0
- package/packages/web/src/runtime.js +73 -0
- package/packages/web/src/styles.css +1382 -0
- package/packages/web/src/ui/components/activityBar.js +35 -0
- package/packages/web/src/ui/components/activityPanel.js +132 -0
- package/packages/web/src/ui/components/commandPalette.js +154 -0
- package/packages/web/src/ui/components/editorArea.js +75 -0
- package/packages/web/src/ui/components/launcher.js +392 -0
- package/packages/web/src/ui/components/openers/index.js +212 -0
- package/packages/web/src/ui/components/openers/markdown.js +222 -0
- package/packages/web/src/ui/components/overlays.js +255 -0
- package/packages/web/src/ui/components/phoneChrome.js +188 -0
- package/packages/web/src/ui/components/pluginReview.js +151 -0
- package/packages/web/src/ui/components/pluginsView.js +120 -0
- package/packages/web/src/ui/components/settingsView.js +258 -0
- package/packages/web/src/ui/components/social.js +290 -0
- package/packages/web/src/ui/components/statusBar.js +198 -0
- package/packages/web/src/ui/components/views/grid.js +115 -0
- package/packages/web/src/ui/components/views/index.js +155 -0
- package/packages/web/src/ui/components/views/list.js +50 -0
- package/packages/web/src/ui/components/views/parts.js +58 -0
- package/packages/web/src/ui/compositions/workbench.js +125 -0
- package/packages/web/src/ui/format.js +33 -0
- package/packages/web/src/ui/icon.js +81 -0
- package/packages/web/src/ui/media.js +114 -0
- package/packages/web/src/ui/sanitize.js +86 -0
- 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
|
+
}
|