@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,306 @@
|
|
|
1
|
+
// ActivityService — the client's answer to "what is going on, and what is stuck".
|
|
2
|
+
//
|
|
3
|
+
// It holds two lists that look similar and mean different things, and keeping them
|
|
4
|
+
// distinct is the whole design:
|
|
5
|
+
//
|
|
6
|
+
// TASKS work happening now. Ephemeral. A task disappears when it finishes (after a
|
|
7
|
+
// moment on screen) and when the process running it stops. That is correct:
|
|
8
|
+
// the work stopped too.
|
|
9
|
+
// ISSUES a standing problem left behind by something that already failed. Durable on
|
|
10
|
+
// the server. It stays until the thing it complains about actually works —
|
|
11
|
+
// not until it is acknowledged, and not until a reload.
|
|
12
|
+
//
|
|
13
|
+
// WHY ONE SERVICE FOR BOTH SIDES. Work happens in two places — an upload runs in this
|
|
14
|
+
// browser, a reindex runs on the server — and a user does not care which. So this holds
|
|
15
|
+
// one list, with each task tagged by where it lives:
|
|
16
|
+
//
|
|
17
|
+
// local started here, driven here. The owner (TransfersService) reports progress
|
|
18
|
+
// directly, and cancelling calls a function.
|
|
19
|
+
// server a read-only MIRROR of the server's registry. Nothing here drives it;
|
|
20
|
+
// cancelling posts, and the next poll shows the result. Treating a mirror as
|
|
21
|
+
// writable is how two sources of truth get invented.
|
|
22
|
+
//
|
|
23
|
+
// WHY POLLING. There is no streaming transport in this server, and adding SSE through
|
|
24
|
+
// three runtime adapters to move a progress bar is not a trade worth making yet. So:
|
|
25
|
+
// poll fast (1s) only while something is running, drop to nothing when the drive is
|
|
26
|
+
// idle, and re-check when the tab regains focus. At rest this costs one request when
|
|
27
|
+
// the app loads. If a real-time channel ever exists, only `#poll` changes.
|
|
28
|
+
|
|
29
|
+
import { cell } from '../runtime.js';
|
|
30
|
+
|
|
31
|
+
const ACTIVE_MS = 1000; // while something is running, we want a live-feeling bar
|
|
32
|
+
const IDLE_MS = 60_000; // otherwise just enough to notice a task someone else started
|
|
33
|
+
|
|
34
|
+
export class ActivityService {
|
|
35
|
+
constructor(platform) {
|
|
36
|
+
this.platform = platform;
|
|
37
|
+
this.api = platform.api;
|
|
38
|
+
this.state = {
|
|
39
|
+
tasks: [], // merged: local first, then the server mirror
|
|
40
|
+
issues: [],
|
|
41
|
+
issuesLoading: false,
|
|
42
|
+
// A load failure is state, not a shrug: "no problems" and "we couldn't ask" look
|
|
43
|
+
// identical on screen unless one of them says so. TWO fields, not one, because
|
|
44
|
+
// these are independent requests — a tasks poll that succeeds must not erase the
|
|
45
|
+
// news that the issues poll failed, which is exactly the bug one shared field
|
|
46
|
+
// produced.
|
|
47
|
+
tasksError: null,
|
|
48
|
+
issuesError: null,
|
|
49
|
+
open: false, // the activity panel
|
|
50
|
+
};
|
|
51
|
+
this.cell = cell(this.state);
|
|
52
|
+
this._local = new Map(); // id -> local task (this browser owns these)
|
|
53
|
+
this._server = [];
|
|
54
|
+
this._timer = null;
|
|
55
|
+
this._pollMs = null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
observe() {
|
|
59
|
+
return this.cell;
|
|
60
|
+
}
|
|
61
|
+
#set(patch) {
|
|
62
|
+
this.state = { ...this.state, ...patch };
|
|
63
|
+
this.cell.setValue(this.state);
|
|
64
|
+
}
|
|
65
|
+
#merge() {
|
|
66
|
+
this.#set({ tasks: [...this._local.values(), ...this._server] });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// --- local tasks -----------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Register work happening in this browser. Returns a handle with the same shape the
|
|
73
|
+
* server's registry hands its callers, so code that reports progress doesn't need to
|
|
74
|
+
* know which side it is running on.
|
|
75
|
+
*
|
|
76
|
+
* @param {{title: string, kind?: string, detail?: string, total?: number|null,
|
|
77
|
+
* unit?: string, onCancel?: () => void}} spec
|
|
78
|
+
*/
|
|
79
|
+
start(spec = {}) {
|
|
80
|
+
const id = spec.id || `local_${Math.random().toString(36).slice(2, 10)}`;
|
|
81
|
+
const task = {
|
|
82
|
+
id,
|
|
83
|
+
source: 'local',
|
|
84
|
+
kind: spec.kind || 'general',
|
|
85
|
+
title: spec.title || 'Working',
|
|
86
|
+
detail: spec.detail ?? null,
|
|
87
|
+
status: 'running',
|
|
88
|
+
done: 0,
|
|
89
|
+
// No total means indeterminate, and that is a legitimate answer. A caller that
|
|
90
|
+
// doesn't know how much work there is must not invent a number: a progress bar
|
|
91
|
+
// that lies is worse than a spinner that admits it doesn't know.
|
|
92
|
+
total: spec.total ?? null,
|
|
93
|
+
unit: spec.unit || null,
|
|
94
|
+
cancellable: !!spec.onCancel,
|
|
95
|
+
startedAt: Date.now(),
|
|
96
|
+
endedAt: null,
|
|
97
|
+
error: null,
|
|
98
|
+
};
|
|
99
|
+
this._local.set(id, task);
|
|
100
|
+
this._cancels ||= new Map();
|
|
101
|
+
if (spec.onCancel) this._cancels.set(id, spec.onCancel);
|
|
102
|
+
this.#merge();
|
|
103
|
+
this.#schedule();
|
|
104
|
+
|
|
105
|
+
const patch = (p) => {
|
|
106
|
+
const cur = this._local.get(id);
|
|
107
|
+
if (!cur || cur.status !== 'running') return; // finished tasks are final
|
|
108
|
+
this._local.set(id, { ...cur, ...p });
|
|
109
|
+
this.#merge();
|
|
110
|
+
};
|
|
111
|
+
return {
|
|
112
|
+
id,
|
|
113
|
+
progress: (p = {}) => patch({
|
|
114
|
+
done: p.done ?? this._local.get(id)?.done ?? 0,
|
|
115
|
+
total: p.total === undefined ? this._local.get(id)?.total : p.total,
|
|
116
|
+
unit: p.unit ?? this._local.get(id)?.unit,
|
|
117
|
+
detail: p.detail ?? this._local.get(id)?.detail,
|
|
118
|
+
}),
|
|
119
|
+
succeed: (detail) => { patch({ status: 'done', endedAt: Date.now(), detail: detail ?? this._local.get(id)?.detail }); this.#expire(id); },
|
|
120
|
+
fail: (err) => { patch({ status: 'failed', endedAt: Date.now(), error: err?.message || String(err || 'failed') }); },
|
|
121
|
+
cancel: () => { patch({ status: 'cancelled', endedAt: Date.now() }); this.#expire(id); },
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A finished task lingers briefly so it is seen to finish, then goes. */
|
|
126
|
+
#expire(id, after = 5000) {
|
|
127
|
+
setTimeout(() => {
|
|
128
|
+
this._local.delete(id);
|
|
129
|
+
this._cancels?.delete(id);
|
|
130
|
+
this.#merge();
|
|
131
|
+
}, after);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async cancel(id) {
|
|
135
|
+
const local = this._local.get(id);
|
|
136
|
+
if (local) {
|
|
137
|
+
this._cancels?.get(id)?.();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
// A server task is a mirror: ask, then let the next poll tell the truth.
|
|
141
|
+
try {
|
|
142
|
+
await this.api.cancelTask(id);
|
|
143
|
+
await this.refreshTasks();
|
|
144
|
+
} catch (err) {
|
|
145
|
+
this.#set({ tasksError: err.message });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
dismiss(id) {
|
|
150
|
+
if (this._local.delete(id)) { this.#merge(); return; }
|
|
151
|
+
this.api.dismissTask(id).catch(() => {});
|
|
152
|
+
this._server = this._server.filter((t) => t.id !== id);
|
|
153
|
+
this.#merge();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// --- server mirror + issues ------------------------------------------------
|
|
157
|
+
|
|
158
|
+
async init() {
|
|
159
|
+
await this.refresh();
|
|
160
|
+
this.#schedule();
|
|
161
|
+
// Coming back to the tab is the moment a stale list is most obvious.
|
|
162
|
+
window.addEventListener('focus', () => this.refresh());
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async refresh() {
|
|
166
|
+
await Promise.all([this.refreshTasks(), this.refreshIssues()]);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async refreshTasks() {
|
|
170
|
+
const wasRunning = this.running.length > 0;
|
|
171
|
+
try {
|
|
172
|
+
const { tasks } = await this.api.tasks();
|
|
173
|
+
this._server = (tasks || []).map((t) => ({ ...t, source: 'server' }));
|
|
174
|
+
this.#set({ tasksError: null });
|
|
175
|
+
this.#merge();
|
|
176
|
+
} catch (err) {
|
|
177
|
+
// Offline or a server that doesn't have the endpoint: stop claiming to know.
|
|
178
|
+
this._server = [];
|
|
179
|
+
this.#set({ tasksError: err.message });
|
|
180
|
+
this.#merge();
|
|
181
|
+
}
|
|
182
|
+
// The moment work STOPS is the moment an issue is most likely to have been fixed,
|
|
183
|
+
// so re-read them then. Without this, a retry that succeeds in 50 ms leaves the
|
|
184
|
+
// problem on screen until the next idle poll — up to a minute of the user being
|
|
185
|
+
// told something is broken that isn't.
|
|
186
|
+
if (wasRunning && !this.running.length) this.refreshIssues();
|
|
187
|
+
this.#schedule();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async refreshIssues() {
|
|
191
|
+
this.#set({ issuesLoading: true });
|
|
192
|
+
try {
|
|
193
|
+
const { issues } = await this.api.issues();
|
|
194
|
+
this.#set({ issues: issues || [], issuesLoading: false, issuesError: null });
|
|
195
|
+
} catch (err) {
|
|
196
|
+
// Deliberately NOT clearing `issues`: the last known list is better than an empty
|
|
197
|
+
// one that reads as "nothing is wrong".
|
|
198
|
+
this.#set({ issuesLoading: false, issuesError: err.message });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async retryIssue(id) {
|
|
203
|
+
try {
|
|
204
|
+
await this.api.retryIssue(id);
|
|
205
|
+
// The fix runs as a task; adopt it now so the click visibly did something, and
|
|
206
|
+
// let the poll clear the issue when the work actually succeeds. The retry may
|
|
207
|
+
// finish faster than a poll interval, so re-read the issues directly too — a
|
|
208
|
+
// Retry button that leaves the problem sitting there reads as a broken button.
|
|
209
|
+
await this.refreshTasks();
|
|
210
|
+
// A retry can finish faster than one poll interval — in which case there is no
|
|
211
|
+
// running→idle transition to notice and the fixed problem would sit on screen
|
|
212
|
+
// until the next idle poll. A couple of bounded follow-ups cover that window
|
|
213
|
+
// without turning this into a busy loop.
|
|
214
|
+
this.#followUp();
|
|
215
|
+
this.platform.notifications?.info?.('Retrying — watch its progress in Activity');
|
|
216
|
+
} catch (err) {
|
|
217
|
+
this.platform.notifications?.error?.(`Couldn't retry: ${err.message}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async dismissIssue(id) {
|
|
222
|
+
const before = this.state.issues;
|
|
223
|
+
this.#set({ issues: before.filter((i) => i.id !== id) });
|
|
224
|
+
try {
|
|
225
|
+
await this.api.dismissIssue(id);
|
|
226
|
+
} catch (err) {
|
|
227
|
+
this.#set({ issues: before }); // put it back rather than pretend
|
|
228
|
+
this.platform.notifications?.error?.(`Couldn't dismiss: ${err.message}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Two bounded re-reads after an action that may have resolved something. */
|
|
233
|
+
#followUp() {
|
|
234
|
+
for (const ms of [400, 1500]) {
|
|
235
|
+
const t = setTimeout(() => { this.refreshTasks(); this.refreshIssues(); }, ms);
|
|
236
|
+
t?.unref?.();
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Rebuild the whole search index. Surfaced as a command; reports as a task. */
|
|
241
|
+
async rebuildIndex() {
|
|
242
|
+
try {
|
|
243
|
+
const res = await this.api.reindex();
|
|
244
|
+
await this.refreshTasks();
|
|
245
|
+
this.togglePanel(true);
|
|
246
|
+
if (res.alreadyRunning) this.platform.notifications?.info?.('A rebuild is already running');
|
|
247
|
+
return res;
|
|
248
|
+
} catch (err) {
|
|
249
|
+
this.platform.notifications?.error?.(`Couldn't rebuild the index: ${err.message}`);
|
|
250
|
+
throw err;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Reconcile a collection with its object store — the way a drive notices files added,
|
|
256
|
+
* replaced, or removed by anything that isn't Trove. Reports as a task, like a rebuild.
|
|
257
|
+
*/
|
|
258
|
+
async scanCollection(collectionId) {
|
|
259
|
+
try {
|
|
260
|
+
const res = await this.api.scanCollection(collectionId);
|
|
261
|
+
await this.refreshTasks();
|
|
262
|
+
this.togglePanel(true);
|
|
263
|
+
if (res.alreadyRunning) this.platform.notifications?.info?.('A scan of this collection is already running');
|
|
264
|
+
this.#followUp();
|
|
265
|
+
return res;
|
|
266
|
+
} catch (err) {
|
|
267
|
+
this.platform.notifications?.error?.(`Couldn't scan “${collectionId}”: ${err.message}`);
|
|
268
|
+
throw err;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
togglePanel(open) {
|
|
273
|
+
this.#set({ open: open ?? !this.state.open });
|
|
274
|
+
if (this.state.open) this.refresh();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
get running() {
|
|
278
|
+
return this.state.tasks.filter((t) => t.status === 'running');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Poll fast while something is running, slowly otherwise. Rescheduled rather than
|
|
283
|
+
* fixed, so an idle drive costs one request a minute and a running rebuild feels
|
|
284
|
+
* live — without a second transport to maintain.
|
|
285
|
+
*/
|
|
286
|
+
#schedule() {
|
|
287
|
+
const want = this.running.length ? ACTIVE_MS : IDLE_MS;
|
|
288
|
+
if (this._timer && this._pollMs === want) return;
|
|
289
|
+
clearTimeout(this._timer);
|
|
290
|
+
this._pollMs = want;
|
|
291
|
+
this._timer = setTimeout(() => {
|
|
292
|
+
this._timer = null;
|
|
293
|
+
this.refreshTasks().then(() => {
|
|
294
|
+
// Issues change when tasks end, so re-read them then rather than on their own
|
|
295
|
+
// clock — a retry that fixed something should stop being listed promptly.
|
|
296
|
+
if (!this.running.length) this.refreshIssues();
|
|
297
|
+
});
|
|
298
|
+
}, want);
|
|
299
|
+
this._timer?.unref?.();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
dispose() {
|
|
303
|
+
clearTimeout(this._timer);
|
|
304
|
+
this._timer = null;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// Register the workbench's built-in commands. Commands are the single entry
|
|
2
|
+
// point for every action — palette, keybindings, buttons, and menus all call
|
|
3
|
+
// `commands.execute(id)`. Handlers here dispatch ngin Actions for anything that
|
|
4
|
+
// touches data, and poke the reactive shell services for pure-UI toggles.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
NavigateAction, RefreshAction, DeleteAction, RenameAction,
|
|
8
|
+
UploadFilesAction, OpenFileAction, CreateCollectionAction, LoadMoreAction, TrashAction,
|
|
9
|
+
SearchAction,
|
|
10
|
+
} from './actions.js';
|
|
11
|
+
import { beginInstallFromFile, beginInstallFromUrl } from './pluginInstall.js';
|
|
12
|
+
import { troveUri } from '@3sln/trove/core/links.js';
|
|
13
|
+
|
|
14
|
+
export function registerCommands(app) {
|
|
15
|
+
const { platform, engine, explorer } = app;
|
|
16
|
+
const { commands, workbench } = platform;
|
|
17
|
+
const go = (a) => engine.dispatch(a);
|
|
18
|
+
|
|
19
|
+
const cmd = (id, title, handler, extra = {}) => commands.register({ id, title, handler, ...extra });
|
|
20
|
+
|
|
21
|
+
// --- navigation / views ----------------------------------------------------
|
|
22
|
+
cmd('workbench.showCommandPalette', 'Show All Commands', () => workbench.openPalette('commands'), { category: 'View', icon: 'command' });
|
|
23
|
+
cmd('workbench.quickOpen', 'Go to File…', () => workbench.openPalette('files'), { category: 'File', icon: 'search' });
|
|
24
|
+
cmd('workbench.view.home', 'Go Home (search & browse)', () => workbench.showHome(), { category: 'View', icon: 'search' });
|
|
25
|
+
cmd('workbench.view.plugins', 'Show Plugins', () => workbench.setActivity('plugins'), { category: 'View' });
|
|
26
|
+
cmd('workbench.openSettings', 'Open Settings', () => workbench.setActivity('settings'), { category: 'Preferences', icon: 'gear' });
|
|
27
|
+
cmd('workbench.closeOverlays', 'Close', () => workbench.closeOverlays(), { palette: false });
|
|
28
|
+
|
|
29
|
+
// Speak to search.
|
|
30
|
+
//
|
|
31
|
+
// On a TV this is mostly NOT about recognising anything: a remote's mic dictates into
|
|
32
|
+
// whatever text field the platform keyboard is attached to, and is swallowed by the
|
|
33
|
+
// system assistant when there isn't one. So the command's first job is to put the
|
|
34
|
+
// search field on screen and focused, which is a feature on every TV whether or not
|
|
35
|
+
// the browser can transcribe. Where it can — on-device only, see platform/voice.js —
|
|
36
|
+
// it also starts listening and types what it hears.
|
|
37
|
+
cmd('search.voice', 'Search by Voice', async () => {
|
|
38
|
+
await platform.voice.run({
|
|
39
|
+
onText: (text, { final }) => {
|
|
40
|
+
if (!text) return;
|
|
41
|
+
workbench.setLaunchQuery(text);
|
|
42
|
+
// Interim results keep the box in step with the speaker; only the settled
|
|
43
|
+
// transcript is worth a round trip to the server.
|
|
44
|
+
if (final) go(new SearchAction(text));
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
}, { category: 'View', icon: 'search' });
|
|
48
|
+
|
|
49
|
+
// --- background work + standing problems -----------------------------------
|
|
50
|
+
cmd('workbench.showActivity', 'Show Activity (running work & problems)',
|
|
51
|
+
() => app.activity.togglePanel(true), { category: 'View', icon: 'refresh' });
|
|
52
|
+
// The manual scan the drive had no way to ask for. Reports as a task rather than
|
|
53
|
+
// blocking, because on a large drive it takes minutes.
|
|
54
|
+
cmd('workbench.rebuildIndex', 'Rebuild Search Index',
|
|
55
|
+
() => app.activity.rebuildIndex().catch(() => {}), { category: 'View', icon: 'refresh' });
|
|
56
|
+
// Trove is not the only thing that can write to the bucket. This is how files added,
|
|
57
|
+
// replaced, or removed by something else get picked up.
|
|
58
|
+
cmd('workbench.scanCollection', 'Scan Collection for Outside Changes',
|
|
59
|
+
() => app.activity.scanCollection(explorer.state.collectionId || 'default').catch(() => {}),
|
|
60
|
+
{ category: 'Explorer', icon: 'refresh' });
|
|
61
|
+
|
|
62
|
+
// --- explorer --------------------------------------------------------------
|
|
63
|
+
cmd('explorer.refresh', 'Refresh', () => go(new RefreshAction()), { category: 'Explorer', icon: 'refresh' });
|
|
64
|
+
cmd('explorer.loadMore', 'Show More Items', () => go(new LoadMoreAction()), { palette: false });
|
|
65
|
+
cmd('explorer.upload', 'Upload Files…', () => {
|
|
66
|
+
pickFiles((files) => files.length && go(new UploadFilesAction(files, explorer.state.collectionId)));
|
|
67
|
+
}, { category: 'Explorer', icon: 'upload' });
|
|
68
|
+
|
|
69
|
+
// Copy the item's trove: link, which is how one item references another in markdown.
|
|
70
|
+
// "Nothing is selected" is a real answer, and it has to be said out loud: these used
|
|
71
|
+
// to return silently, which is indistinguishable from a broken command.
|
|
72
|
+
const subject = (fallbackNode) => {
|
|
73
|
+
const node = fallbackNode || explorer.selectedNodes()[0] || workbench.activeTab()?.node;
|
|
74
|
+
if (!node) platform.notifications.info('Pick a file first — highlight one in the list, or open it.');
|
|
75
|
+
return node;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
cmd('explorer.copyLink', 'Copy Link to Item', async () => {
|
|
79
|
+
const node = subject();
|
|
80
|
+
if (!node) return;
|
|
81
|
+
const uri = troveUri(node);
|
|
82
|
+
try {
|
|
83
|
+
await navigator.clipboard.writeText(uri);
|
|
84
|
+
platform.notifications.success(`Copied ${uri}`);
|
|
85
|
+
} catch {
|
|
86
|
+
// Clipboard access can be denied; showing the link is still useful.
|
|
87
|
+
platform.notifications.info(uri, { sticky: true });
|
|
88
|
+
}
|
|
89
|
+
}, { category: 'Explorer', icon: 'link' });
|
|
90
|
+
|
|
91
|
+
cmd('explorer.rename', 'Rename', () => {
|
|
92
|
+
const node = subject();
|
|
93
|
+
if (!node) return;
|
|
94
|
+
workbench.showDialog({
|
|
95
|
+
kind: 'prompt', title: 'Rename', label: 'New name', value: node.name, confirmLabel: 'Rename',
|
|
96
|
+
onSubmit: (name) => {
|
|
97
|
+
workbench.closeDialog();
|
|
98
|
+
if (name?.trim() && name !== node.name) go(new RenameAction(node.id, name.trim()));
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
}, { category: 'Explorer' });
|
|
102
|
+
|
|
103
|
+
cmd('explorer.delete', 'Delete', () => {
|
|
104
|
+
const nodes = explorer.selectedNodes();
|
|
105
|
+
const fallback = nodes.length ? null : workbench.activeTab()?.node;
|
|
106
|
+
if (fallback) nodes.push(fallback); // deleting the file you have open is the obvious intent
|
|
107
|
+
if (!nodes.length) {
|
|
108
|
+
platform.notifications.info('Pick a file first — highlight one in the list, or open it.');
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const doDelete = () => go(new DeleteAction(nodes.map((n) => n.id)));
|
|
112
|
+
if (platform.settings.get('explorer.confirmDelete')) {
|
|
113
|
+
workbench.showDialog({
|
|
114
|
+
kind: 'confirm', title: `Move ${nodes.length} item${nodes.length > 1 ? 's' : ''} to the trash?`,
|
|
115
|
+
// Say what actually happens. Telling someone a file will be "permanently
|
|
116
|
+
// deleted" when it goes to the trash trains them to fear a safe action; the
|
|
117
|
+
// reverse — promising recovery that doesn't exist — is worse.
|
|
118
|
+
body: nodes.length === 1
|
|
119
|
+
? `"${nodes[0].name}" leaves the drive but is kept, and can be restored from the trash.`
|
|
120
|
+
: 'They leave the drive but are kept, and can be restored from the trash.',
|
|
121
|
+
confirmLabel: 'Move to trash',
|
|
122
|
+
onConfirm: () => {
|
|
123
|
+
workbench.closeDialog();
|
|
124
|
+
doDelete();
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
} else doDelete();
|
|
128
|
+
}, { category: 'Explorer', icon: 'trash' });
|
|
129
|
+
|
|
130
|
+
cmd('explorer.showTrash', 'Show Trash', () => {
|
|
131
|
+
go(new TrashAction('list'));
|
|
132
|
+
workbench.showHome();
|
|
133
|
+
}, { category: 'Explorer', icon: 'trash' });
|
|
134
|
+
cmd('explorer.hideTrash', 'Hide Trash', () => go(new TrashAction('hide')), { palette: false });
|
|
135
|
+
cmd('explorer.restore', 'Restore from Trash', (id) => id && go(new TrashAction('restore', id)), { palette: false });
|
|
136
|
+
cmd('explorer.purgeOne', 'Delete Forever', (id) => id && go(new TrashAction('purge', id)), { palette: false });
|
|
137
|
+
cmd('explorer.emptyTrash', 'Empty Trash', () => {
|
|
138
|
+
workbench.showDialog({
|
|
139
|
+
kind: 'confirm', title: 'Empty the trash?',
|
|
140
|
+
body: 'Everything in the trash will be destroyed. This cannot be undone.',
|
|
141
|
+
danger: true, confirmLabel: 'Delete forever',
|
|
142
|
+
onConfirm: () => { workbench.closeDialog(); go(new TrashAction('empty')); },
|
|
143
|
+
});
|
|
144
|
+
}, { category: 'Explorer', icon: 'trash' });
|
|
145
|
+
|
|
146
|
+
cmd('explorer.open', 'Open', (node) => go(new OpenFileAction(node || explorer.selectedNodes()[0])), { palette: false });
|
|
147
|
+
cmd('explorer.download', 'Download', async (node) => {
|
|
148
|
+
const target = node || explorer.selectedNodes()[0] || workbench.activeTab()?.node;
|
|
149
|
+
if (!target?.id) return;
|
|
150
|
+
try {
|
|
151
|
+
const { url, revoke } = await platform.api.download(target.id, target.name);
|
|
152
|
+
triggerDownload(url, target.name);
|
|
153
|
+
// A blob URL pins the bytes until it is released; the click has already happened
|
|
154
|
+
// by the time this runs.
|
|
155
|
+
if (revoke) setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
platform.notifications.error(`Couldn't download ${target.name}: ${err.message}`);
|
|
158
|
+
}
|
|
159
|
+
}, { category: 'Explorer', icon: 'download' });
|
|
160
|
+
|
|
161
|
+
// --- offline ---------------------------------------------------------------
|
|
162
|
+
cmd('offline.pin', 'Make Available Offline', (node) => {
|
|
163
|
+
const target = node || explorer.selectedNodes()[0] || workbench.activeTab()?.node;
|
|
164
|
+
if (target?.id) app.offline.pin(target);
|
|
165
|
+
}, { category: 'Offline', icon: 'download' });
|
|
166
|
+
cmd('offline.unpin', 'Remove from Offline', (node) => {
|
|
167
|
+
const target = node || explorer.selectedNodes()[0] || workbench.activeTab()?.node;
|
|
168
|
+
if (target) app.offline.unpin(target.id);
|
|
169
|
+
}, { palette: false });
|
|
170
|
+
|
|
171
|
+
// --- collections -----------------------------------------------------------
|
|
172
|
+
// The menu of "where else could I be". Shared by the palette command and the status
|
|
173
|
+
// bar's collection segment, so both offer the same list.
|
|
174
|
+
const collectionMenu = () => {
|
|
175
|
+
const current = explorer.state.collectionId || 'default';
|
|
176
|
+
const items = (explorer.state.collections || []).map((c) => ({
|
|
177
|
+
label: c.name || c.id,
|
|
178
|
+
icon: c.id === current ? 'check' : 'files',
|
|
179
|
+
run: () => commands.execute('collections.switch', c.id),
|
|
180
|
+
}));
|
|
181
|
+
if (explorer.state.canCreateCollection) {
|
|
182
|
+
if (items.length) items.push({ sep: true });
|
|
183
|
+
items.push({ label: 'New collection…', icon: 'plus', run: () => commands.execute('collections.create') });
|
|
184
|
+
}
|
|
185
|
+
return items;
|
|
186
|
+
};
|
|
187
|
+
app.collectionMenu = collectionMenu;
|
|
188
|
+
|
|
189
|
+
// `NavigateAction` takes a single collectionId. This passed ('/', cid) — a leftover
|
|
190
|
+
// from a path+collection signature — so every switch navigated to a collection
|
|
191
|
+
// literally named "/" and failed to load. It was also unreachable: no caller, and
|
|
192
|
+
// hidden from the palette.
|
|
193
|
+
cmd('collections.switch', 'Switch Collection…', (cid) => {
|
|
194
|
+
if (cid) {
|
|
195
|
+
go(new NavigateAction(cid));
|
|
196
|
+
workbench.showHome();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
// From the palette or a keybinding there is no pointer to anchor a menu to.
|
|
200
|
+
const items = collectionMenu();
|
|
201
|
+
if (!items.length) return platform.notifications.info('This drive has one collection.');
|
|
202
|
+
const w = typeof window === 'undefined' ? 800 : window.innerWidth;
|
|
203
|
+
workbench.showContextMenu(Math.max(12, Math.round(w / 2) - 110), 120, items);
|
|
204
|
+
}, { category: 'Collections', icon: 'files' });
|
|
205
|
+
cmd('collections.create', 'New Collection…', () => {
|
|
206
|
+
workbench.showDialog({
|
|
207
|
+
kind: 'collection', title: 'New collection',
|
|
208
|
+
onSubmit: (record) => {
|
|
209
|
+
workbench.closeDialog();
|
|
210
|
+
if (record) go(new CreateCollectionAction(record));
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
}, { category: 'Collections', icon: 'files' });
|
|
214
|
+
|
|
215
|
+
// --- conversations & notifications -----------------------------------------
|
|
216
|
+
// The details panel is a view OF a file. With nothing open there is nothing for it to
|
|
217
|
+
// show, and flipping the flag silently was indistinguishable from a broken command.
|
|
218
|
+
cmd('workbench.toggleInfoPanel', 'Toggle Details & Conversation', () => {
|
|
219
|
+
if (!workbench.activeTab()) {
|
|
220
|
+
platform.notifications.info('Open a file to see its details and conversation.');
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
workbench.toggleInfoPanel();
|
|
224
|
+
}, { category: 'View', icon: 'info' });
|
|
225
|
+
cmd('notifications.show', 'Show Notifications', () => app.social.toggleInbox(true), { category: 'View' });
|
|
226
|
+
cmd('notifications.enablePush', 'Enable Push Notifications', () => app.social.enablePush(), { category: 'Notifications' });
|
|
227
|
+
|
|
228
|
+
// --- plugins ---------------------------------------------------------------
|
|
229
|
+
cmd('plugins.installFromUrl', 'Install Plugin from URL…', () => {
|
|
230
|
+
workbench.showDialog({
|
|
231
|
+
kind: 'prompt', title: 'Install plugin from URL', label: 'Plugin package (.zip) URL',
|
|
232
|
+
placeholder: 'https://example.com/plugin.zip', confirmLabel: 'Fetch',
|
|
233
|
+
onSubmit: (url) => { workbench.closeDialog(); if (url?.trim()) beginInstallFromUrl(app, url.trim()); },
|
|
234
|
+
});
|
|
235
|
+
}, { category: 'Plugins', icon: 'plug' });
|
|
236
|
+
cmd('plugins.installFromFile', 'Install Plugin from File…', () => {
|
|
237
|
+
pickZip((file) => file && beginInstallFromFile(app, file));
|
|
238
|
+
}, { category: 'Plugins', icon: 'plug' });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// --- helpers ----------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
// Open a native file picker. The hidden <input> is removed on selection AND on
|
|
244
|
+
// cancel — cancelling fires no `change`, so we also clean up on the next window
|
|
245
|
+
// focus (which the OS dialog returns) to avoid leaking a growing pile of inputs.
|
|
246
|
+
function pick(cb, configure) {
|
|
247
|
+
const input = document.createElement('input');
|
|
248
|
+
input.type = 'file';
|
|
249
|
+
configure(input);
|
|
250
|
+
input.style.display = 'none';
|
|
251
|
+
document.body.appendChild(input);
|
|
252
|
+
const cleanup = () => { input.remove(); window.removeEventListener('focus', onFocus); };
|
|
253
|
+
const onFocus = () => setTimeout(() => { if (!input.files.length) cleanup(); }, 300);
|
|
254
|
+
input.addEventListener('change', () => { cb(input.files); cleanup(); }, { once: true });
|
|
255
|
+
window.addEventListener('focus', onFocus);
|
|
256
|
+
input.click();
|
|
257
|
+
}
|
|
258
|
+
function pickFiles(cb) {
|
|
259
|
+
pick((files) => cb(files), (input) => { input.multiple = true; });
|
|
260
|
+
}
|
|
261
|
+
function pickZip(cb) {
|
|
262
|
+
pick((files) => cb(files[0]), (input) => { input.accept = '.zip,application/zip'; });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function triggerDownload(url, name) {
|
|
266
|
+
const a = document.createElement('a');
|
|
267
|
+
a.href = url;
|
|
268
|
+
a.download = name || '';
|
|
269
|
+
document.body.appendChild(a);
|
|
270
|
+
a.click();
|
|
271
|
+
a.remove();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export { pickFiles, triggerDownload };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// One place that knows how to classify a file by type — extension extraction and a
|
|
2
|
+
// single format table. Previously the ext/mime lists were re-derived (and drifted)
|
|
3
|
+
// across openers.extOf, contributions.matchesSelector, iconForNode, and offline.isTexty;
|
|
4
|
+
// now the icon shown, the "is this text?" decision, and the type label all agree.
|
|
5
|
+
|
|
6
|
+
/** The extension (".pdf") for a node, lowercased, or '' if none. */
|
|
7
|
+
export function extOf(node) {
|
|
8
|
+
const name = (node?.name || '').toLowerCase();
|
|
9
|
+
return name.includes('.') ? name.slice(name.lastIndexOf('.')) : '';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Category → { mime: prefixes/exact it matches, ext: extensions }. Order matters:
|
|
13
|
+
// kindOf checks these top-down.
|
|
14
|
+
const CATEGORIES = [
|
|
15
|
+
{ kind: 'audio', icon: 'file-audio', mime: ['audio/'], ext: ['.mp3', '.flac', '.wav', '.opus', '.ogg', '.m4a', '.m4b'] },
|
|
16
|
+
{ kind: 'image', icon: 'file-image', mime: ['image/'], ext: ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.avif'] },
|
|
17
|
+
{ kind: 'video', icon: 'file-video', mime: ['video/'], ext: ['.mp4', '.webm', '.mkv', '.mov'] },
|
|
18
|
+
{
|
|
19
|
+
kind: 'text', icon: 'file-text', mime: ['text/', 'application/json'],
|
|
20
|
+
ext: ['.txt', '.md', '.markdown', '.json', '.js', '.mjs', '.ts', '.jsx', '.tsx', '.css', '.html',
|
|
21
|
+
'.xml', '.yaml', '.yml', '.toml', '.ini', '.log', '.csv', '.py', '.rb', '.go', '.rs', '.sh', '.c', '.h', '.cpp', '.java'],
|
|
22
|
+
},
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
function matchesCategory(cat, ext, ct) {
|
|
26
|
+
if (cat.ext.includes(ext)) return true;
|
|
27
|
+
// A mime entry ending in '/' is a prefix (audio/), otherwise an exact type.
|
|
28
|
+
return cat.mime.some((m) => (m.endsWith('/') ? ct.startsWith(m) : ct === m));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Classify an item → 'audio'|'image'|'video'|'text'|'file'. */
|
|
32
|
+
export function kindOf(node) {
|
|
33
|
+
const ext = extOf(node);
|
|
34
|
+
const ct = node?.contentType || '';
|
|
35
|
+
for (const cat of CATEGORIES) if (matchesCategory(cat, ext, ct)) return cat.kind;
|
|
36
|
+
return 'file';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const ICONS = { ...Object.fromEntries(CATEGORIES.map((c) => [c.kind, c.icon])), file: 'file' };
|
|
40
|
+
|
|
41
|
+
/** The icon name for a node, derived from its kind. */
|
|
42
|
+
export function iconForKind(node) {
|
|
43
|
+
return ICONS[kindOf(node)] || 'file';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Whether a node holds indexable text (drives offline text extraction). */
|
|
47
|
+
export function isTexty(node) {
|
|
48
|
+
return kindOf(node) === 'text';
|
|
49
|
+
}
|