@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,155 @@
|
|
|
1
|
+
// The "speak to search" surface, and the part of it that works with no API at all.
|
|
2
|
+
//
|
|
3
|
+
// Two jobs, and they are independent on purpose:
|
|
4
|
+
//
|
|
5
|
+
// 1. PUT THE SEARCH FIELD UNDER THE MICROPHONE. A TV remote's mic dictates into
|
|
6
|
+
// whatever text field the platform keyboard is attached to, and does nothing at
|
|
7
|
+
// all when there isn't one. So the command opens the search surface and focuses
|
|
8
|
+
// its input. On webOS and Tizen that is the entire feature — the platform does
|
|
9
|
+
// the listening, the text arrives as ordinary input events, and Trove never asks
|
|
10
|
+
// for a microphone.
|
|
11
|
+
//
|
|
12
|
+
// 2. TRANSCRIBE OURSELVES, where the browser can do it on-device (see voice.js).
|
|
13
|
+
// Additive: if it isn't available the field is still focused and the remote's own
|
|
14
|
+
// mic still works.
|
|
15
|
+
|
|
16
|
+
import { cell } from '../runtime.js';
|
|
17
|
+
import { canTranscribeLocally, localAvailability, installLocal, listen } from './voice.js';
|
|
18
|
+
|
|
19
|
+
export class VoiceSearchService {
|
|
20
|
+
/**
|
|
21
|
+
* @param {object} deps
|
|
22
|
+
* @param {import('./workbench.js').WorkbenchService} deps.workbench
|
|
23
|
+
* @param {object} deps.notifications
|
|
24
|
+
*/
|
|
25
|
+
constructor({ workbench, notifications, settings } = {}) {
|
|
26
|
+
this.workbench = workbench;
|
|
27
|
+
this.notifications = notifications;
|
|
28
|
+
this.settings = settings;
|
|
29
|
+
this.session = null;
|
|
30
|
+
this.state = { supported: canTranscribeLocally(), status: 'unknown', listening: false };
|
|
31
|
+
this.cell = cell(this.state);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
observe() {
|
|
35
|
+
return this.cell;
|
|
36
|
+
}
|
|
37
|
+
#set(patch) {
|
|
38
|
+
this.state = { ...this.state, ...patch };
|
|
39
|
+
this.cell.setValue(this.state);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Ask once whether an on-device language pack is ready, and remember the answer. */
|
|
43
|
+
async refresh() {
|
|
44
|
+
if (!this.state.supported) return 'unavailable';
|
|
45
|
+
const status = await localAvailability(this.#lang());
|
|
46
|
+
this.#set({ status });
|
|
47
|
+
return status;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
#lang() {
|
|
51
|
+
return this.settings?.get?.('search.voiceLanguage') || navigator.language || 'en-US';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Can we offer a microphone button right now? */
|
|
55
|
+
canListen() {
|
|
56
|
+
return this.state.supported && (this.state.status === 'available' || this.state.status === 'downloadable');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The command behind `search.voice`.
|
|
61
|
+
*
|
|
62
|
+
* `focus` is what makes this worth binding on a TV even when we cannot transcribe:
|
|
63
|
+
* it is the difference between the remote's mic having a target and being swallowed
|
|
64
|
+
* by the platform assistant.
|
|
65
|
+
*/
|
|
66
|
+
async run({ onText } = {}) {
|
|
67
|
+
this.openSearchSurface();
|
|
68
|
+
// Focus AFTER the surface has rendered — the input does not exist yet on the frame
|
|
69
|
+
// that opened it.
|
|
70
|
+
await this.focusInput();
|
|
71
|
+
if (!this.state.supported) return { listening: false, reason: 'unsupported' };
|
|
72
|
+
|
|
73
|
+
const status = this.state.status === 'unknown' ? await this.refresh() : this.state.status;
|
|
74
|
+
if (status === 'downloadable') {
|
|
75
|
+
// Pressing the button IS the consent to fetch it; nothing downloads before that.
|
|
76
|
+
this.notifications?.info('Downloading the on-device voice model — this happens once.');
|
|
77
|
+
const ok = await installLocal(this.#lang());
|
|
78
|
+
this.#set({ status: ok ? 'available' : 'unavailable' });
|
|
79
|
+
if (!ok) return { listening: false, reason: 'install-failed' };
|
|
80
|
+
} else if (status !== 'available') {
|
|
81
|
+
return { listening: false, reason: status };
|
|
82
|
+
}
|
|
83
|
+
return this.toggle({ onText });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Start listening, or stop if already. */
|
|
87
|
+
toggle({ onText } = {}) {
|
|
88
|
+
if (this.session) {
|
|
89
|
+
this.stop();
|
|
90
|
+
return { listening: false };
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
this.session = listen({
|
|
94
|
+
lang: this.#lang(),
|
|
95
|
+
onText,
|
|
96
|
+
onEnd: () => { this.session = null; this.#set({ listening: false }); },
|
|
97
|
+
onError: (err) => {
|
|
98
|
+
this.session = null;
|
|
99
|
+
this.#set({ listening: false });
|
|
100
|
+
// A refused microphone is a decision, not a fault worth shouting about.
|
|
101
|
+
if (!/not-allowed|service-not-allowed|denied/i.test(err.message)) {
|
|
102
|
+
this.notifications?.warn(`Couldn't listen: ${err.message}`);
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
this.#set({ listening: true });
|
|
107
|
+
return { listening: true };
|
|
108
|
+
} catch (err) {
|
|
109
|
+
this.notifications?.warn(`Couldn't listen: ${err.message}`);
|
|
110
|
+
return { listening: false, reason: 'error' };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
stop() {
|
|
115
|
+
this.session?.stop();
|
|
116
|
+
this.session = null;
|
|
117
|
+
this.#set({ listening: false });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Make sure a search field is on screen.
|
|
122
|
+
*
|
|
123
|
+
* Already on the launcher with nothing open → it is the search surface, use it. Any
|
|
124
|
+
* other view, or a file open over it → the modal, which is the one search box that
|
|
125
|
+
* can appear over anything.
|
|
126
|
+
*/
|
|
127
|
+
openSearchSurface() {
|
|
128
|
+
const wb = this.workbench;
|
|
129
|
+
if (!wb) return;
|
|
130
|
+
const onLauncher = wb.state.activity === 'home' && !wb.nav?.state?.activeTabId;
|
|
131
|
+
if (onLauncher) wb.closeSearchModal?.();
|
|
132
|
+
else wb.openSearchModal();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** @returns {Promise<boolean>} whether an input actually took focus */
|
|
136
|
+
focusInput({ tries = 12 } = {}) {
|
|
137
|
+
return new Promise((resolve) => {
|
|
138
|
+
let left = tries;
|
|
139
|
+
const attempt = () => {
|
|
140
|
+
const el = document.querySelector('.search-modal .launch-input') || document.querySelector('.launch-input');
|
|
141
|
+
if (el) {
|
|
142
|
+
el.focus();
|
|
143
|
+
// Put the caret at the end so dictation appends rather than overwriting a
|
|
144
|
+
// query someone already typed.
|
|
145
|
+
try { el.setSelectionRange(el.value.length, el.value.length); } catch { /* not a text input */ }
|
|
146
|
+
resolve(document.activeElement === el);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (--left <= 0) { resolve(false); return; }
|
|
150
|
+
requestAnimationFrame(attempt);
|
|
151
|
+
};
|
|
152
|
+
attempt();
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// A parser/evaluator for "when" clauses — the same conditional mini-language VS
|
|
2
|
+
// Code uses to gate commands, keybindings, and menu items on UI state. It reads
|
|
3
|
+
// a context (a flat key→value map from the ContextKeyService) and returns a
|
|
4
|
+
// boolean.
|
|
5
|
+
//
|
|
6
|
+
// Supported: bare keys (truthy), !key, ==, !=, >, <, >=, <=, =~ /re/, && , ||,
|
|
7
|
+
// parentheses, string/number/boolean literals. Expressions are parsed once into
|
|
8
|
+
// a predicate function and cached, so evaluation on every keydown is cheap.
|
|
9
|
+
//
|
|
10
|
+
// A key is either a core context key (`view.active`, `explorer.hasSelection`) or a
|
|
11
|
+
// contribution URI naming a plugin's `register` — `trove+contrib:acme.com/docs/busy`.
|
|
12
|
+
// Registers are addressed by their full URI precisely because they're contributions
|
|
13
|
+
// like any other: a plugin can only ever gate on a value someone declared and owns.
|
|
14
|
+
|
|
15
|
+
const cache = new Map();
|
|
16
|
+
|
|
17
|
+
export function compileWhen(expr) {
|
|
18
|
+
if (!expr) return () => true;
|
|
19
|
+
if (cache.has(expr)) return cache.get(expr);
|
|
20
|
+
let fn;
|
|
21
|
+
try {
|
|
22
|
+
fn = new Parser(expr).parseExpression();
|
|
23
|
+
} catch (err) {
|
|
24
|
+
console.warn(`Invalid when clause: "${expr}" — ${err.message}`);
|
|
25
|
+
fn = () => false;
|
|
26
|
+
}
|
|
27
|
+
cache.set(expr, fn);
|
|
28
|
+
return fn;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function evaluateWhen(expr, ctx) {
|
|
32
|
+
return compileWhen(expr)(ctx || {});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Note the `trove+contrib:` alternative comes before the regex-literal one: a URI's
|
|
36
|
+
// slashes would otherwise start a /…/ literal and swallow the rest of the expression.
|
|
37
|
+
const TOKEN = /\s*(=~|==|!=|>=|<=|&&|\|\||[()!<>]|trove\+contrib:[A-Za-z0-9_./-]+|\/(?:\\.|[^/])*\/|'(?:\\.|[^'])*'|"(?:\\.|[^"])*"|[A-Za-z0-9_.:-]+)/y;
|
|
38
|
+
|
|
39
|
+
class Parser {
|
|
40
|
+
constructor(src) {
|
|
41
|
+
this.src = src;
|
|
42
|
+
this.tokens = this.#lex(src);
|
|
43
|
+
this.pos = 0;
|
|
44
|
+
}
|
|
45
|
+
#lex(src) {
|
|
46
|
+
const out = [];
|
|
47
|
+
let i = 0;
|
|
48
|
+
TOKEN.lastIndex = 0;
|
|
49
|
+
while (i < src.length) {
|
|
50
|
+
TOKEN.lastIndex = i;
|
|
51
|
+
const m = TOKEN.exec(src);
|
|
52
|
+
if (!m || m.index == null) {
|
|
53
|
+
if (!src.slice(i).trim()) break;
|
|
54
|
+
throw new Error(`Unexpected "${src.slice(i)}"`);
|
|
55
|
+
}
|
|
56
|
+
out.push(m[1]);
|
|
57
|
+
i = TOKEN.lastIndex;
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
#peek() {
|
|
62
|
+
return this.tokens[this.pos];
|
|
63
|
+
}
|
|
64
|
+
#next() {
|
|
65
|
+
return this.tokens[this.pos++];
|
|
66
|
+
}
|
|
67
|
+
#eat(t) {
|
|
68
|
+
if (this.#peek() !== t) throw new Error(`Expected "${t}"`);
|
|
69
|
+
this.pos++;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
parseExpression() {
|
|
73
|
+
const fn = this.#or();
|
|
74
|
+
if (this.pos !== this.tokens.length) throw new Error(`Trailing "${this.#peek()}"`);
|
|
75
|
+
return fn;
|
|
76
|
+
}
|
|
77
|
+
#or() {
|
|
78
|
+
let left = this.#and();
|
|
79
|
+
while (this.#peek() === '||') {
|
|
80
|
+
this.#next();
|
|
81
|
+
const right = this.#and();
|
|
82
|
+
const l = left;
|
|
83
|
+
left = (c) => l(c) || right(c);
|
|
84
|
+
}
|
|
85
|
+
return left;
|
|
86
|
+
}
|
|
87
|
+
#and() {
|
|
88
|
+
let left = this.#unary();
|
|
89
|
+
while (this.#peek() === '&&') {
|
|
90
|
+
this.#next();
|
|
91
|
+
const right = this.#unary();
|
|
92
|
+
const l = left;
|
|
93
|
+
left = (c) => l(c) && right(c);
|
|
94
|
+
}
|
|
95
|
+
return left;
|
|
96
|
+
}
|
|
97
|
+
#unary() {
|
|
98
|
+
if (this.#peek() === '!') {
|
|
99
|
+
this.#next();
|
|
100
|
+
const operand = this.#unary();
|
|
101
|
+
return (c) => !operand(c);
|
|
102
|
+
}
|
|
103
|
+
return this.#comparison();
|
|
104
|
+
}
|
|
105
|
+
#comparison() {
|
|
106
|
+
const left = this.#primary();
|
|
107
|
+
const op = this.#peek();
|
|
108
|
+
if (['==', '!=', '>=', '<=', '>', '<', '=~'].includes(op)) {
|
|
109
|
+
this.#next();
|
|
110
|
+
if (op === '=~') {
|
|
111
|
+
const reTok = this.#next();
|
|
112
|
+
const re = parseRegex(reTok);
|
|
113
|
+
return (c) => re.test(String(left(c) ?? ''));
|
|
114
|
+
}
|
|
115
|
+
const right = this.#primary();
|
|
116
|
+
return (c) => compare(op, left(c), right(c));
|
|
117
|
+
}
|
|
118
|
+
// Bare value → truthiness.
|
|
119
|
+
return (c) => truthy(left(c));
|
|
120
|
+
}
|
|
121
|
+
#primary() {
|
|
122
|
+
const t = this.#next();
|
|
123
|
+
if (t === undefined) throw new Error('Unexpected end');
|
|
124
|
+
if (t === '(') {
|
|
125
|
+
const inner = this.#or();
|
|
126
|
+
this.#eat(')');
|
|
127
|
+
return inner;
|
|
128
|
+
}
|
|
129
|
+
if (t === 'true') return () => true;
|
|
130
|
+
if (t === 'false') return () => false;
|
|
131
|
+
if (/^-?\d+(\.\d+)?$/.test(t)) {
|
|
132
|
+
const n = Number(t);
|
|
133
|
+
return () => n;
|
|
134
|
+
}
|
|
135
|
+
if (t[0] === "'" || t[0] === '"') {
|
|
136
|
+
const s = t.slice(1, -1).replace(/\\(.)/g, '$1');
|
|
137
|
+
return () => s;
|
|
138
|
+
}
|
|
139
|
+
// A context key.
|
|
140
|
+
return (c) => c[t];
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function parseRegex(tok) {
|
|
145
|
+
const m = /^\/((?:\\.|[^/])*)\/([a-z]*)$/.exec(tok);
|
|
146
|
+
if (!m) throw new Error(`Bad regex ${tok}`);
|
|
147
|
+
return new RegExp(m[1], m[2]);
|
|
148
|
+
}
|
|
149
|
+
function truthy(v) {
|
|
150
|
+
return !(v === undefined || v === null || v === false || v === '' || v === 0);
|
|
151
|
+
}
|
|
152
|
+
function compare(op, a, b) {
|
|
153
|
+
switch (op) {
|
|
154
|
+
case '==': return a == b; // eslint-disable-line eqeqeq
|
|
155
|
+
case '!=': return a != b; // eslint-disable-line eqeqeq
|
|
156
|
+
case '>': return a > b;
|
|
157
|
+
case '<': return a < b;
|
|
158
|
+
case '>=': return a >= b;
|
|
159
|
+
case '<=': return a <= b;
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// WorkbenchService — the shell's ephemeral UI state (not the file data, which
|
|
2
|
+
// ngin owns). The main panel is a STACK of panels: the base is the root search
|
|
3
|
+
// (launcher); opening a file pushes a viewer panel; back pops. The stack is mirrored
|
|
4
|
+
// into the browser history (pushState/popState) so back/forward navigate it. A
|
|
5
|
+
// double-shift opens a modal search overlay; picking from it resets the stack.
|
|
6
|
+
// It also mirrors key bits into the ContextKeyService for when-clauses.
|
|
7
|
+
|
|
8
|
+
import { cell } from '../runtime.js';
|
|
9
|
+
import { OverlayService, wrapIndex } from './overlay.js';
|
|
10
|
+
import { NavigationService } from './navigation.js';
|
|
11
|
+
|
|
12
|
+
export class WorkbenchService {
|
|
13
|
+
constructor(context) {
|
|
14
|
+
this.context = context;
|
|
15
|
+
// Two focused sub-services own their own state + subject; the workbench delegates
|
|
16
|
+
// and coordinates the couplings across them (Esc close-order, opening a file).
|
|
17
|
+
this.overlay = new OverlayService(context);
|
|
18
|
+
this.nav = new NavigationService(context);
|
|
19
|
+
this.state = {
|
|
20
|
+
activity: 'home', // home (stack) | plugins | settings
|
|
21
|
+
sidebarVisible: true,
|
|
22
|
+
launch: { query: '', index: 0 }, // the launcher's query
|
|
23
|
+
searchModal: false, // the double-shift modal search overlay
|
|
24
|
+
infoPanel: false,
|
|
25
|
+
// Phone chrome: which bottom sheet is up, if any ('status' | 'more'). A phone has
|
|
26
|
+
// no room for a permanent status bar or a left rail, so both fold into a sheet
|
|
27
|
+
// that is pulled up on demand.
|
|
28
|
+
sheet: null,
|
|
29
|
+
};
|
|
30
|
+
this.cell = cell(this.state);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
observe() {
|
|
34
|
+
return this.cell;
|
|
35
|
+
}
|
|
36
|
+
observeOverlay() {
|
|
37
|
+
return this.overlay.observe();
|
|
38
|
+
}
|
|
39
|
+
observeNav() {
|
|
40
|
+
return this.nav.observe();
|
|
41
|
+
}
|
|
42
|
+
#set(patch) {
|
|
43
|
+
this.state = { ...this.state, ...patch };
|
|
44
|
+
this.cell.setValue(this.state);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Say that something the shell renders from changed without going through here.
|
|
49
|
+
*
|
|
50
|
+
* There is exactly one such thing — `platform.capabilities`, which arrives from the
|
|
51
|
+
* server after boot and is read straight off the platform by the status bar. A cell
|
|
52
|
+
* drops a write of the value it already holds, so re-pushing `state` is not a nudge;
|
|
53
|
+
* a fresh object is.
|
|
54
|
+
*/
|
|
55
|
+
touch() {
|
|
56
|
+
this.#set({});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// --- overlay delegations (state lives in OverlayService) -------------------
|
|
60
|
+
openPalette(mode, query) { this.overlay.openPalette(mode, query); }
|
|
61
|
+
setPaletteQuery(query) { this.overlay.setPaletteQuery(query); }
|
|
62
|
+
movePalette(delta, count) { this.overlay.movePalette(delta, count); }
|
|
63
|
+
closePalette() { this.overlay.closePalette(); }
|
|
64
|
+
showDialog(dialog) { this.overlay.showDialog(dialog); }
|
|
65
|
+
updateDialog(patch) { this.overlay.updateDialog(patch); }
|
|
66
|
+
closeDialog() { this.overlay.closeDialog(); }
|
|
67
|
+
showContextMenu(x, y, items) { this.overlay.showContextMenu(x, y, items); }
|
|
68
|
+
closeContextMenu() { this.overlay.closeContextMenu(); }
|
|
69
|
+
openPluginPanel(pluginId) { this.overlay.openPluginPanel(pluginId); }
|
|
70
|
+
closePluginPanel() { this.overlay.closePluginPanel(); }
|
|
71
|
+
|
|
72
|
+
// --- navigation delegations (state lives in NavigationService) -------------
|
|
73
|
+
activeTab() { return this.nav.activeTab(); }
|
|
74
|
+
back() { this.nav.back(); }
|
|
75
|
+
pop() { this.nav.pop(); }
|
|
76
|
+
closeTab(id) { this.nav.closeTab(id); }
|
|
77
|
+
updateTabNode(node) { this.nav.updateTabNode(node); }
|
|
78
|
+
onPopState(e) { this.nav.onPopState(e); }
|
|
79
|
+
|
|
80
|
+
setActivity(activity) {
|
|
81
|
+
this.#set({ activity, sidebarVisible: true });
|
|
82
|
+
this.context.set('view.active', activity);
|
|
83
|
+
}
|
|
84
|
+
/** Return to the launcher home (reset the panel stack to the base search). */
|
|
85
|
+
showHome() {
|
|
86
|
+
this.#set({ activity: 'home', searchModal: false });
|
|
87
|
+
this.nav.reset();
|
|
88
|
+
this.context.set('view.active', 'home');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// --- launcher --------------------------------------------------------------
|
|
92
|
+
setLaunchQuery(query) {
|
|
93
|
+
this.#set({ launch: { query, index: 0 } });
|
|
94
|
+
}
|
|
95
|
+
moveLaunch(delta, count) {
|
|
96
|
+
if (!count) return;
|
|
97
|
+
this.#set({ launch: { ...this.state.launch, index: wrapIndex(this.state.launch.index, delta, count) } });
|
|
98
|
+
}
|
|
99
|
+
/** The command palette's highlighted row. Missing entirely, so every `mouseenter`
|
|
100
|
+
* threw and the highlight never followed the pointer — hover row 5, press Enter,
|
|
101
|
+
* run row 0. */
|
|
102
|
+
setPaletteIndex(index) {
|
|
103
|
+
this.overlay.setPaletteIndex?.(index);
|
|
104
|
+
}
|
|
105
|
+
setLaunchIndex(index) {
|
|
106
|
+
this.#set({ launch: { ...this.state.launch, index } });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// --- modal search (double-shift) ------------------------------------------
|
|
110
|
+
openSearchModal() {
|
|
111
|
+
this.#set({ searchModal: true, launch: { query: '', index: 0 } });
|
|
112
|
+
this.context.set('searchModal.open', true);
|
|
113
|
+
}
|
|
114
|
+
closeSearchModal() {
|
|
115
|
+
this.#set({ searchModal: false });
|
|
116
|
+
this.context.set('searchModal.open', false);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Open a file into the viewer stack (delegates to NavigationService). Coordinates the
|
|
121
|
+
* shell state the nav service can't own: switch to the home activity and close the
|
|
122
|
+
* modal search overlay.
|
|
123
|
+
*/
|
|
124
|
+
openFile(node, openerId, opts = {}) {
|
|
125
|
+
this.#set({ activity: 'home', searchModal: false });
|
|
126
|
+
this.nav.openFile(node, openerId, opts);
|
|
127
|
+
}
|
|
128
|
+
/** Raise (or swap, or drop) the phone bottom sheet. Tapping the open one closes it. */
|
|
129
|
+
openSheet(name) {
|
|
130
|
+
this.#set({ sheet: this.state.sheet === name ? null : name });
|
|
131
|
+
this.context.set('sheet.open', this.state.sheet || '');
|
|
132
|
+
}
|
|
133
|
+
closeSheet() {
|
|
134
|
+
if (!this.state.sheet) return;
|
|
135
|
+
this.#set({ sheet: null });
|
|
136
|
+
this.context.set('sheet.open', '');
|
|
137
|
+
}
|
|
138
|
+
toggleInfoPanel(force) {
|
|
139
|
+
this.#set({ infoPanel: force ?? !this.state.infoPanel });
|
|
140
|
+
this.context.set('infoPanel.open', this.state.infoPanel);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Close any transient overlay (Esc), in priority order across overlay + stack.
|
|
144
|
+
* Returns true if something closed. */
|
|
145
|
+
closeOverlays() {
|
|
146
|
+
const o = this.overlay.state;
|
|
147
|
+
if (o.contextMenu) return this.overlay.closeContextMenu(), true;
|
|
148
|
+
if (o.dialog) return this.overlay.closeDialog(), true;
|
|
149
|
+
if (this.state.sheet) return this.closeSheet(), true;
|
|
150
|
+
if (this.state.searchModal) return this.closeSearchModal(), true;
|
|
151
|
+
if (o.palette) return this.overlay.closePalette(), true;
|
|
152
|
+
if (o.pluginPanel) return this.overlay.closePluginPanel(), true;
|
|
153
|
+
if (this.nav.state.stack.length > 1) return this.nav.back(), true; // pop the top viewer panel
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Wire the 3sln stack for Trove's web client.
|
|
2
|
+
//
|
|
3
|
+
// - dodo: the VDOM (h, reconcile, element helpers, alias/special)
|
|
4
|
+
// - dodo/reactive: cells, and the `watch` component that renders one
|
|
5
|
+
//
|
|
6
|
+
// bones is gone; what Trove used of it — `watch`, a writable subject, and a way
|
|
7
|
+
// to combine several of them — moved into dodo as the **Cell** protocol:
|
|
8
|
+
//
|
|
9
|
+
// { onDirty(fn) -> unsubscribe, getValue() -> any }
|
|
10
|
+
//
|
|
11
|
+
// That is a smaller contract than the observable it replaces, and the difference
|
|
12
|
+
// shows up in two places we care about. It is push-to-invalidate / pull-to-read,
|
|
13
|
+
// so any number of invalidations in one frame coalesce into a single render;
|
|
14
|
+
// and a cell always HAS a value, so a `watch` renders its store's current state
|
|
15
|
+
// on the first pass instead of waiting for an emission that already happened.
|
|
16
|
+
//
|
|
17
|
+
// `PENDING` is how a cell says "no value yet" — `watch` renders its placeholder
|
|
18
|
+
// instead of calling the builder. An error is reported by THROWING from
|
|
19
|
+
// `getValue()`, which `watch` renders as its error view. Both matter for
|
|
20
|
+
// `fromAsync` below, which is the only shape of ours the module does not ship.
|
|
21
|
+
|
|
22
|
+
import * as dodo from '@3sln/dodo';
|
|
23
|
+
import {
|
|
24
|
+
watch, cell, derive, constant, mapCell, connectable,
|
|
25
|
+
fromObservable, toObservable, effect, isCell, readCell, PENDING,
|
|
26
|
+
} from '@3sln/dodo/reactive';
|
|
27
|
+
|
|
28
|
+
export const dd = dodo;
|
|
29
|
+
|
|
30
|
+
export {
|
|
31
|
+
watch, cell, derive, constant, mapCell, connectable,
|
|
32
|
+
fromObservable, toObservable, effect, isCell, readCell, PENDING,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A cell backed by one async call: `PENDING` until it settles, the value after,
|
|
37
|
+
* and a throw from `getValue()` if it rejects — which is exactly the vocabulary
|
|
38
|
+
* `watch`'s `placeholder` and `error` options are written against.
|
|
39
|
+
*
|
|
40
|
+
* The work starts on the first listener, not at construction, so building one
|
|
41
|
+
* for a view that is never rendered costs nothing. It runs ONCE: a cell that
|
|
42
|
+
* refetched every time its last watcher went away and came back would reload a
|
|
43
|
+
* document on every remount.
|
|
44
|
+
*/
|
|
45
|
+
export function fromAsync(work) {
|
|
46
|
+
let state = { status: 'idle' };
|
|
47
|
+
return connectable(
|
|
48
|
+
(notify) => {
|
|
49
|
+
if (state.status === 'idle') {
|
|
50
|
+
state = { status: 'running' };
|
|
51
|
+
Promise.resolve()
|
|
52
|
+
.then(work)
|
|
53
|
+
.then(
|
|
54
|
+
(value) => { state = { status: 'done', value }; notify(); },
|
|
55
|
+
(error) => { state = { status: 'failed', error }; notify(); },
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
// Nothing to disconnect: a promise cannot be un-awaited, and the result is
|
|
59
|
+
// kept so a later listener reads it rather than starting again.
|
|
60
|
+
return () => {};
|
|
61
|
+
},
|
|
62
|
+
() => {
|
|
63
|
+
if (state.status === 'failed') throw state.error;
|
|
64
|
+
return state.status === 'done' ? state.value : PENDING;
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The reactive API as one object, for `platform.reactive` (and plugin-facing code). */
|
|
70
|
+
export const reactive = {
|
|
71
|
+
watch, cell, derive, constant, mapCell, connectable,
|
|
72
|
+
fromObservable, fromAsync, toObservable, effect, isCell, readCell, PENDING,
|
|
73
|
+
};
|