@uniqbit/mate-core 0.17.0-canary.9 → 0.17.1
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/package.json +11 -5
- package/src/cli/commands/artifact/finish/index.ts +0 -9
- package/src/cli/commands/artifact/finish/openspec.ts +2 -2
- package/src/cli/commands/artifact/finish/registry.ts +1 -1
- package/src/cli/commands/artifact/pending/discovery.ts +1 -1
- package/src/cli/commands/artifact/pending/index.ts +0 -16
- package/src/cli/commands/cap/graphify.ts +1 -1
- package/src/cli/commands/cap/index-cmd.ts +1 -1
- package/src/cli/commands/cap/tokensave.ts +4 -3
- package/src/cli/commands/companion/companion.ts +4 -0
- package/src/cli/commands/companion/hub.ts +2 -1
- package/src/cli/commands/companion/list.ts +2 -1
- package/src/cli/commands/companion/prepare.ts +2 -2
- package/src/cli/commands/companion/update.ts +334 -0
- package/src/cli/commands/doctor.ts +2 -1
- package/src/cli/commands/install.ts +59 -5
- package/src/cli/commands/launch/shared.ts +2 -1
- package/src/cli/commands/plugin/plugin.ts +4 -0
- package/src/cli/commands/plugin/verify.ts +25 -0
- package/src/cli/commands/report/highlight.ts +1 -4
- package/src/cli/commands/report/renderer.ts +0 -2
- package/src/cli/commands/studio/access.ts +201 -0
- package/src/cli/commands/studio/index.ts +207 -26
- package/src/cli/commands/studio/openspec-cli.ts +1 -1
- package/src/cli/commands/studio/selection.ts +127 -11
- package/src/cli/commands/studio/server.ts +355 -64
- package/src/cli/commands/studio/terminal-assets.ts +50 -0
- package/src/cli/commands/studio/terminal-launch.ts +71 -0
- package/src/cli/commands/studio/terminal.ts +593 -0
- package/src/cli/commands/studio/vault.ts +320 -75
- package/src/cli/commands/studio/views/client.ts +90 -9
- package/src/cli/commands/studio/views/companion-picker.tsx +9 -5
- package/src/cli/commands/studio/views/companion-selector.tsx +11 -7
- package/src/cli/commands/studio/views/document.tsx +411 -107
- package/src/cli/commands/studio/views/model.ts +14 -1
- package/src/cli/commands/studio/views/selection-fields.tsx +14 -0
- package/src/cli/commands/studio/views/styles.ts +106 -13
- package/src/cli/commands/studio/views/terminal.tsx +426 -0
- package/src/cli/commands/studio/views/vault/state.ts +42 -0
- package/src/cli/commands/studio/views/workflow/steps.ts +10 -3
- package/src/cli/commands/update.ts +11 -8
- package/src/cli/companion-link-wizard.tsx +1 -1
- package/src/cli/companion-selector.tsx +1 -1
- package/src/cli/launch-selector.tsx +1 -1
- package/src/cli/main.ts +2 -1
- package/src/cli/usage.ts +7 -3
- package/src/cli/write-json-stdout.ts +2 -2
- package/src/lib/components/spinner.tsx +2 -2
- package/src/lib/components/startup-progress.tsx +1 -1
- package/src/lib/context-mode-package.ts +1 -1
- package/src/lib/opencode-plugin-package.ts +3 -0
- package/src/lib/orchestrator/adapters/opencode.ts +53 -13
- package/src/lib/orchestrator/companion-hub.ts +3 -3
- package/src/lib/orchestrator/companion-registry-store.ts +1 -1
- package/src/lib/orchestrator/config-store.ts +2 -2
- package/src/lib/orchestrator/editor.ts +4 -12
- package/src/lib/orchestrator/engine-guard.ts +9 -1
- package/src/lib/orchestrator/projection-entries.ts +1 -1
- package/src/lib/orchestrator/projection-record.ts +1 -1
- package/src/lib/orchestrator/projection-types.ts +0 -4
- package/src/lib/orchestrator/repo-local-registry.ts +0 -1
- package/src/lib/orchestrator/root-context.ts +1 -1
- package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
- package/src/lib/orchestrator/setup-preflight.ts +1 -1
- package/src/lib/orchestrator/types.ts +1 -1
- package/src/lib/orchestrator/working-repo-projection.ts +1 -1
- package/src/lib/orchestrator/workspace-inventory.ts +2 -2
- package/src/lib/package-paths.ts +1 -11
- package/src/lib/package-root.ts +43 -0
- package/src/lib/prebuilt-workspace.ts +1 -1
- package/src/lib/public-npm.ts +1 -1
- package/src/opencode/companion-hooks.ts +131 -153
- package/src/opencode/index.ts +12 -6
- package/src/opencode/tui.tsx +46 -49
- package/src/playbooks/companion-guidance.ts +1 -28
- package/src/runtime/companion-sync.ts +0 -3
- package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
- package/src/templates/providers/opencode/opencode.json +6 -4
- package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +500 -408
- package/src/tools/setup/capabilities/context-mode.ts +45 -25
- package/src/tools/setup/capabilities/graphify-shared.ts +3 -3
- package/src/tools/setup/capabilities/graphify.ts +2 -2
- package/src/tools/setup/capabilities/openspec.ts +2 -7
- package/src/tools/setup/capabilities/react-doctor.ts +1 -1
- package/src/tools/setup/capabilities/rtk.ts +2 -2
- package/src/tools/setup/capabilities/tokensave.ts +98 -12
- package/src/tools/setup/context-services.ts +3 -6
- package/src/tools/setup/dynamic-plugins/declarations.ts +47 -0
- package/src/tools/setup/dynamic-plugins/frozen.ts +215 -0
- package/src/tools/setup/dynamic-plugins/hydrate.ts +1 -40
- package/src/tools/setup/dynamic-plugins/loader.ts +16 -0
- package/src/tools/setup/dynamic-plugins/policy.ts +55 -0
- package/src/tools/setup/dynamic-plugins/staging.ts +53 -0
- package/src/tools/setup/dynamic-plugins/verify.ts +70 -0
- package/src/tools/setup/mate.ts +1 -1
- package/src/tools/setup/providers/claude-format.ts +1 -1
- package/src/tools/setup/providers/claude.ts +1 -1
- package/src/tools/setup/providers/opencode-format.ts +220 -25
- package/src/tools/setup/providers/opencode.ts +63 -21
- package/src/tools/setup/runtime-documents.ts +9 -11
- package/src/tools/setup.ts +0 -4
- package/src/cli/commands/studio/views/pairings.tsx +0 -38
- package/src/templates/providers/opencode/tui.json +0 -3
|
@@ -2,10 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
2
2
|
import fs, { type Dirent, type FSWatcher, type Stats } from "node:fs";
|
|
3
3
|
import * as fsp from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import {
|
|
6
|
-
import { promisify } from "node:util";
|
|
7
|
-
|
|
8
|
-
const execFile = promisify(execFileCallback);
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
9
6
|
|
|
10
7
|
export interface VaultTreeNode {
|
|
11
8
|
name: string;
|
|
@@ -68,9 +65,30 @@ export interface VaultWatcher {
|
|
|
68
65
|
close(): void;
|
|
69
66
|
}
|
|
70
67
|
|
|
71
|
-
export interface
|
|
68
|
+
export interface VaultGitResult {
|
|
69
|
+
code: number;
|
|
70
|
+
stdout: string;
|
|
71
|
+
stderr: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Rejects only when Git cannot be started; a non-zero exit resolves with its code. */
|
|
75
|
+
export type VaultGitRunner = (args: string[], input?: string) => Promise<VaultGitResult>;
|
|
76
|
+
|
|
77
|
+
export interface VaultListingFs {
|
|
78
|
+
realpath(target: string): Promise<string>;
|
|
79
|
+
stat(target: string): Promise<Stats>;
|
|
80
|
+
readdir(target: string): Promise<Dirent[]>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface VaultListingDeps {
|
|
84
|
+
git?: VaultGitRunner;
|
|
85
|
+
fs?: Partial<VaultListingFs>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface VaultDeps extends VaultListingDeps {
|
|
89
|
+
/** Called once per directory; the watch is not recursive. */
|
|
72
90
|
watch?: (
|
|
73
|
-
|
|
91
|
+
directory: string,
|
|
74
92
|
listener: (event: string, filename: string | Buffer | null) => void,
|
|
75
93
|
) => VaultWatcher;
|
|
76
94
|
beforeWrite?: (absolutePath: string) => Promise<void> | void;
|
|
@@ -147,16 +165,57 @@ export async function resolveVaultPath(
|
|
|
147
165
|
return { root, absolute, relative: path.relative(root, absolute) || requestedPath };
|
|
148
166
|
}
|
|
149
167
|
|
|
150
|
-
|
|
168
|
+
function runGit(args: string[], input?: string): Promise<VaultGitResult> {
|
|
169
|
+
return new Promise((resolve, reject) => {
|
|
170
|
+
const child = spawn("git", args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
171
|
+
const stdout: Buffer[] = [];
|
|
172
|
+
const stderr: Buffer[] = [];
|
|
173
|
+
child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk));
|
|
174
|
+
child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk));
|
|
175
|
+
child.stdin.on("error", () => {});
|
|
176
|
+
child.on("error", reject);
|
|
177
|
+
child.on("close", (code) =>
|
|
178
|
+
resolve({
|
|
179
|
+
code: code ?? 1,
|
|
180
|
+
stdout: Buffer.concat(stdout).toString("utf8"),
|
|
181
|
+
stderr: Buffer.concat(stderr).toString("utf8"),
|
|
182
|
+
}),
|
|
183
|
+
);
|
|
184
|
+
child.stdin.end(input ?? "");
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const defaultListingFs: VaultListingFs = {
|
|
189
|
+
realpath: (target) => fsp.realpath(target),
|
|
190
|
+
stat: (target) => fsp.stat(target),
|
|
191
|
+
readdir: (target) => fsp.readdir(target, { withFileTypes: true }),
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
async function ignored(git: VaultGitRunner, root: string, relative: string): Promise<boolean> {
|
|
195
|
+
try {
|
|
196
|
+
const result = await git(["-C", root, "check-ignore", "--no-index", "--quiet", "--", relative]);
|
|
197
|
+
return result.code === 0;
|
|
198
|
+
} catch {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function containedFile(
|
|
204
|
+
vaultFs: VaultListingFs,
|
|
205
|
+
root: string,
|
|
206
|
+
relative: string,
|
|
207
|
+
): Promise<boolean> {
|
|
151
208
|
try {
|
|
152
|
-
await
|
|
153
|
-
return
|
|
209
|
+
const resolved = await vaultFs.realpath(path.join(root, relative));
|
|
210
|
+
return inside(root, resolved) && (await vaultFs.stat(resolved)).isFile();
|
|
154
211
|
} catch {
|
|
155
212
|
return false;
|
|
156
213
|
}
|
|
157
214
|
}
|
|
158
215
|
|
|
159
|
-
|
|
216
|
+
/** Only used outside a Git checkout, where nothing counts as ignored. */
|
|
217
|
+
async function walkFiles(
|
|
218
|
+
vaultFs: VaultListingFs,
|
|
160
219
|
root: string,
|
|
161
220
|
current: string,
|
|
162
221
|
relativeDir: string,
|
|
@@ -164,34 +223,84 @@ async function collectFiles(
|
|
|
164
223
|
): Promise<void> {
|
|
165
224
|
let entries: Dirent[];
|
|
166
225
|
try {
|
|
167
|
-
entries = await
|
|
226
|
+
entries = await vaultFs.readdir(current);
|
|
168
227
|
} catch {
|
|
169
228
|
return;
|
|
170
229
|
}
|
|
171
|
-
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
172
230
|
for (const entry of entries) {
|
|
173
231
|
if (entry.name === ".git") continue;
|
|
174
232
|
const relative = path.join(relativeDir, entry.name);
|
|
175
|
-
if (await ignored(root, relative)) continue;
|
|
176
|
-
const absolute = path.join(current, entry.name);
|
|
177
233
|
let resolved: string;
|
|
178
|
-
try {
|
|
179
|
-
resolved = await fsp.realpath(absolute);
|
|
180
|
-
if (!inside(root, resolved)) continue;
|
|
181
|
-
} catch {
|
|
182
|
-
continue;
|
|
183
|
-
}
|
|
184
234
|
let stats: Stats;
|
|
185
235
|
try {
|
|
186
|
-
|
|
236
|
+
resolved = await vaultFs.realpath(path.join(current, entry.name));
|
|
237
|
+
if (!inside(root, resolved)) continue;
|
|
238
|
+
stats = await vaultFs.stat(resolved);
|
|
187
239
|
} catch {
|
|
188
240
|
continue;
|
|
189
241
|
}
|
|
190
|
-
if (stats.isDirectory()) await
|
|
242
|
+
if (stats.isDirectory()) await walkFiles(vaultFs, root, resolved, relative, output);
|
|
191
243
|
else if (stats.isFile() && isMarkdown(relative)) output.push(relative);
|
|
192
244
|
}
|
|
193
245
|
}
|
|
194
246
|
|
|
247
|
+
function notACheckout(error: unknown, result?: VaultGitResult): boolean {
|
|
248
|
+
if (error) return (error as NodeJS.ErrnoException).code === "ENOENT";
|
|
249
|
+
return result !== undefined && /not a git repository/i.test(result.stderr);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Two Git processes per listing, whatever the repository size. `--no-index`
|
|
254
|
+
* keeps tracked files that match an ignore pattern hidden.
|
|
255
|
+
*/
|
|
256
|
+
async function listMarkdownFiles(root: string, deps: VaultListingDeps): Promise<string[]> {
|
|
257
|
+
const git = deps.git ?? runGit;
|
|
258
|
+
const vaultFs = { ...defaultListingFs, ...deps.fs };
|
|
259
|
+
let listed: VaultGitResult;
|
|
260
|
+
try {
|
|
261
|
+
listed = await git([
|
|
262
|
+
"-C",
|
|
263
|
+
root,
|
|
264
|
+
"ls-files",
|
|
265
|
+
"-z",
|
|
266
|
+
"--cached",
|
|
267
|
+
"--others",
|
|
268
|
+
"--exclude-standard",
|
|
269
|
+
]);
|
|
270
|
+
} catch (error) {
|
|
271
|
+
if (!notACheckout(error)) throw error;
|
|
272
|
+
const files: string[] = [];
|
|
273
|
+
await walkFiles(vaultFs, root, root, "", files);
|
|
274
|
+
return files;
|
|
275
|
+
}
|
|
276
|
+
if (listed.code !== 0) {
|
|
277
|
+
if (notACheckout(null, listed)) {
|
|
278
|
+
const files: string[] = [];
|
|
279
|
+
await walkFiles(vaultFs, root, root, "", files);
|
|
280
|
+
return files;
|
|
281
|
+
}
|
|
282
|
+
throw new Error(`git ls-files failed: ${listed.stderr.trim() || `exit ${listed.code}`}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const candidates = [...new Set(listed.stdout.split("\0").filter(Boolean))].filter(isMarkdown);
|
|
286
|
+
if (candidates.length === 0) return [];
|
|
287
|
+
const checked = await git(
|
|
288
|
+
["-C", root, "check-ignore", "--no-index", "-z", "--stdin"],
|
|
289
|
+
`${candidates.join("\0")}\0`,
|
|
290
|
+
);
|
|
291
|
+
if (checked.code !== 0 && checked.code !== 1)
|
|
292
|
+
throw new Error(`git check-ignore failed: ${checked.stderr.trim() || `exit ${checked.code}`}`);
|
|
293
|
+
const ignoredPaths = new Set(checked.stdout.split("\0").filter(Boolean));
|
|
294
|
+
|
|
295
|
+
const relatives = candidates.flatMap((candidate) =>
|
|
296
|
+
ignoredPaths.has(candidate) ? [] : [path.join(...candidate.split("/"))],
|
|
297
|
+
);
|
|
298
|
+
const kept = await Promise.all(
|
|
299
|
+
relatives.map((relative) => containedFile(vaultFs, root, relative)),
|
|
300
|
+
);
|
|
301
|
+
return relatives.filter((_relative, index) => kept[index]);
|
|
302
|
+
}
|
|
303
|
+
|
|
195
304
|
function asTree(paths: string[]): VaultTreeNode[] {
|
|
196
305
|
const roots: VaultTreeNode[] = [];
|
|
197
306
|
for (const filePath of paths.sort((a, b) => a.localeCompare(b))) {
|
|
@@ -217,11 +326,12 @@ function asTree(paths: string[]): VaultTreeNode[] {
|
|
|
217
326
|
}
|
|
218
327
|
|
|
219
328
|
/** Collects every non-ignored markdown file under a companion root. */
|
|
220
|
-
export async function collectMarkdownTree(
|
|
329
|
+
export async function collectMarkdownTree(
|
|
330
|
+
companionRoot: string,
|
|
331
|
+
deps: VaultListingDeps = {},
|
|
332
|
+
): Promise<VaultTreeNode[]> {
|
|
221
333
|
const root = await fsp.realpath(companionRoot);
|
|
222
|
-
|
|
223
|
-
await collectFiles(root, root, "", files);
|
|
224
|
-
return asTree(files);
|
|
334
|
+
return asTree(await listMarkdownFiles(root, deps));
|
|
225
335
|
}
|
|
226
336
|
|
|
227
337
|
/** The token is opaque to callers and changes whenever the bytes change. */
|
|
@@ -229,25 +339,35 @@ export function versionToken(content: string): string {
|
|
|
229
339
|
return createHash("sha256").update(content).digest("hex");
|
|
230
340
|
}
|
|
231
341
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
export async function readVaultFile(
|
|
235
|
-
companionRoot: string,
|
|
236
|
-
requestedPath: string,
|
|
237
|
-
): Promise<VaultFile> {
|
|
342
|
+
async function readVaultFile(companionRoot: string, requestedPath: string): Promise<VaultFile> {
|
|
238
343
|
const resolved = await resolveVaultPath(companionRoot, requestedPath);
|
|
239
344
|
const content = await fsp.readFile(resolved.absolute, "utf8");
|
|
240
345
|
return { path: resolved.relative, content, token: versionToken(content) };
|
|
241
346
|
}
|
|
242
347
|
|
|
243
348
|
function defaultWatcher(
|
|
244
|
-
|
|
349
|
+
directory: string,
|
|
245
350
|
listener: (event: string, filename: string | Buffer | null) => void,
|
|
246
351
|
): VaultWatcher {
|
|
247
|
-
const watcher: FSWatcher = fs.watch(
|
|
352
|
+
const watcher: FSWatcher = fs.watch(directory, listener);
|
|
353
|
+
/** A removed directory errors its watcher; the next listing drops it. */
|
|
354
|
+
watcher.on("error", () => watcher.close());
|
|
248
355
|
return { close: () => watcher.close() };
|
|
249
356
|
}
|
|
250
357
|
|
|
358
|
+
/** The root, every directory holding a listed file, and their ancestors. */
|
|
359
|
+
function watchedDirectories(root: string, files: string[]): Set<string> {
|
|
360
|
+
const directories = new Set([root]);
|
|
361
|
+
for (const file of files) {
|
|
362
|
+
let current = path.dirname(path.join(root, file));
|
|
363
|
+
while (current !== root && !directories.has(current)) {
|
|
364
|
+
directories.add(current);
|
|
365
|
+
current = path.dirname(current);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return directories;
|
|
369
|
+
}
|
|
370
|
+
|
|
251
371
|
interface SaveRecord {
|
|
252
372
|
path: string;
|
|
253
373
|
content: string;
|
|
@@ -264,6 +384,8 @@ interface Subscriber {
|
|
|
264
384
|
|
|
265
385
|
export interface VaultManager {
|
|
266
386
|
tree(companionRoot: string, refresh?: boolean): Promise<VaultTreeResult>;
|
|
387
|
+
/** The cached tree, or `null` after starting (or joining) a listing in the background. */
|
|
388
|
+
prefetch(companionRoot: string, refresh?: boolean): Promise<VaultTreeResult | null>;
|
|
267
389
|
open(companionRoot: string, requestedPath: string): Promise<VaultFile>;
|
|
268
390
|
save(
|
|
269
391
|
companionRoot: string,
|
|
@@ -282,12 +404,19 @@ export interface VaultManager {
|
|
|
282
404
|
getRecovery(companionRoot: string, requestedPath: string): VaultWatchEvent | null;
|
|
283
405
|
}
|
|
284
406
|
|
|
285
|
-
/** Owns one tree cache and one
|
|
407
|
+
/** Owns one tree cache and one set of per-directory watchers per selected companion process. */
|
|
286
408
|
export function createVaultManager(deps: VaultDeps = {}): VaultManager {
|
|
287
409
|
const watch = deps.watch ?? defaultWatcher;
|
|
410
|
+
const git = deps.git ?? runGit;
|
|
288
411
|
const trees = new Map<string, VaultTreeNode[]>();
|
|
412
|
+
const generations = new Map<string, number>();
|
|
413
|
+
const listings = new Map<string, Promise<VaultTreeNode[]>>();
|
|
414
|
+
const listed = new Map<string, string[]>();
|
|
289
415
|
const warnings = new Map<string, string | null>();
|
|
290
|
-
const watchers = new Map<string, VaultWatcher
|
|
416
|
+
const watchers = new Map<string, Map<string, VaultWatcher>>();
|
|
417
|
+
const reconciles = new Map<string, Set<string>>();
|
|
418
|
+
/** Directories created since the last listing; they hold no listed file yet. */
|
|
419
|
+
const adopted = new Map<string, Set<string>>();
|
|
291
420
|
let activeRoot: string | null = null;
|
|
292
421
|
const subscribers = new Map<string, Set<Subscriber>>();
|
|
293
422
|
const pendingEvents = new Map<string, ReturnType<typeof setTimeout>>();
|
|
@@ -299,6 +428,7 @@ export function createVaultManager(deps: VaultDeps = {}): VaultManager {
|
|
|
299
428
|
|
|
300
429
|
const invalidate = (root: string) => {
|
|
301
430
|
trees.delete(root);
|
|
431
|
+
generations.set(root, (generations.get(root) ?? 0) + 1);
|
|
302
432
|
};
|
|
303
433
|
|
|
304
434
|
const notify = async (
|
|
@@ -368,47 +498,149 @@ export function createVaultManager(deps: VaultDeps = {}): VaultManager {
|
|
|
368
498
|
}
|
|
369
499
|
};
|
|
370
500
|
|
|
501
|
+
const closeWatchers = (root: string) => {
|
|
502
|
+
for (const watcher of watchers.get(root)?.values() ?? []) watcher.close();
|
|
503
|
+
watchers.delete(root);
|
|
504
|
+
reconciles.delete(root);
|
|
505
|
+
adopted.delete(root);
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
const unavailable = (root: string, error: unknown) => {
|
|
509
|
+
closeWatchers(root);
|
|
510
|
+
warnings.set(
|
|
511
|
+
root,
|
|
512
|
+
`Live updates are unavailable: ${error instanceof Error ? error.message : String(error)}`,
|
|
513
|
+
);
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
const onDirectoryEvent = (root: string, directory: string, filename: string | Buffer | null) => {
|
|
517
|
+
if (!filename) {
|
|
518
|
+
invalidate(root);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const absolute = path.resolve(directory, String(filename));
|
|
522
|
+
const relative = path.relative(root, absolute);
|
|
523
|
+
if (!relative || relative.startsWith("..") || relative.split(path.sep).includes(".git")) return;
|
|
524
|
+
if (!isMarkdown(relative)) {
|
|
525
|
+
if (watchers.get(root)?.has(absolute)) {
|
|
526
|
+
invalidate(root);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
void adoptDirectory(root, absolute).catch(() => {});
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
const observed = observeNow(root, relative);
|
|
533
|
+
const saveWasActive = recentSaves.get(`${root}\0${relative}`)?.windowOpen === true;
|
|
534
|
+
void ignored(git, root, relative).then((isIgnored) => {
|
|
535
|
+
if (isIgnored) return;
|
|
536
|
+
invalidate(root);
|
|
537
|
+
void notify(root, relative, observed, saveWasActive);
|
|
538
|
+
});
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
const watchDirectory = (root: string, directory: string) =>
|
|
542
|
+
watch(directory, (_event, filename) => onDirectoryEvent(root, directory, filename));
|
|
543
|
+
|
|
544
|
+
/** Watches a new directory and its subdirectories at once, so files written into them are seen. */
|
|
545
|
+
const adoptDirectory = async (root: string, absolute: string): Promise<void> => {
|
|
546
|
+
if (!(await fsp.stat(absolute)).isDirectory()) return;
|
|
547
|
+
const relative = path.relative(root, absolute);
|
|
548
|
+
if (await ignored(git, root, `${relative}${path.sep}`)) return;
|
|
549
|
+
invalidate(root);
|
|
550
|
+
const current = watchers.get(root);
|
|
551
|
+
if (!current || activeRoot !== root || current.has(absolute)) return;
|
|
552
|
+
try {
|
|
553
|
+
current.set(absolute, watchDirectory(root, absolute));
|
|
554
|
+
} catch (error) {
|
|
555
|
+
unavailable(root, error);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
const set = adopted.get(root) ?? new Set<string>();
|
|
559
|
+
set.add(absolute);
|
|
560
|
+
adopted.set(root, set);
|
|
561
|
+
const entries = await fsp.readdir(absolute, { withFileTypes: true });
|
|
562
|
+
await Promise.all(
|
|
563
|
+
entries.flatMap((entry) =>
|
|
564
|
+
entry.isDirectory() && entry.name !== ".git"
|
|
565
|
+
? [adoptDirectory(root, path.join(absolute, entry.name))]
|
|
566
|
+
: [],
|
|
567
|
+
),
|
|
568
|
+
);
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
/** Synchronous for the root only, so a request learns at once whether it is watched. */
|
|
371
572
|
const startWatching = (root: string) => {
|
|
372
573
|
if (activeRoot && activeRoot !== root) {
|
|
373
|
-
|
|
374
|
-
watchers.delete(activeRoot);
|
|
574
|
+
closeWatchers(activeRoot);
|
|
375
575
|
warnings.delete(root);
|
|
376
576
|
}
|
|
377
577
|
activeRoot = root;
|
|
378
578
|
if (watchers.has(root) || warnings.has(root)) return;
|
|
379
579
|
try {
|
|
380
|
-
watchers.set(
|
|
381
|
-
root,
|
|
382
|
-
watch(root, (_event, filename) => {
|
|
383
|
-
if (!filename) return;
|
|
384
|
-
const value = String(filename);
|
|
385
|
-
const relative = path.relative(
|
|
386
|
-
root,
|
|
387
|
-
path.isAbsolute(value) ? value : path.resolve(root, value),
|
|
388
|
-
);
|
|
389
|
-
if (
|
|
390
|
-
!relative ||
|
|
391
|
-
relative.startsWith("..") ||
|
|
392
|
-
!isMarkdown(relative) ||
|
|
393
|
-
relative.split(path.sep).includes(".git")
|
|
394
|
-
)
|
|
395
|
-
return;
|
|
396
|
-
const observed = observeNow(root, relative);
|
|
397
|
-
const saveWasActive = recentSaves.get(`${root}\0${relative}`)?.windowOpen === true;
|
|
398
|
-
void ignored(root, relative).then((isIgnored) => {
|
|
399
|
-
if (isIgnored) return;
|
|
400
|
-
invalidate(root);
|
|
401
|
-
void notify(root, relative, observed, saveWasActive);
|
|
402
|
-
});
|
|
403
|
-
}),
|
|
404
|
-
);
|
|
580
|
+
watchers.set(root, new Map([[root, watchDirectory(root, root)]]));
|
|
405
581
|
warnings.set(root, null);
|
|
406
582
|
} catch (error) {
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
583
|
+
unavailable(root, error);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
const files = listed.get(root);
|
|
587
|
+
if (files) scheduleReconcile(root, files);
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
const reconcile = (root: string) => {
|
|
591
|
+
const desired = reconciles.get(root);
|
|
592
|
+
reconciles.delete(root);
|
|
593
|
+
const current = watchers.get(root);
|
|
594
|
+
if (!desired || !current || activeRoot !== root) return;
|
|
595
|
+
const kept = adopted.get(root);
|
|
596
|
+
for (const [directory, watcher] of current) {
|
|
597
|
+
if (desired.has(directory)) {
|
|
598
|
+
kept?.delete(directory);
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
if (kept?.has(directory) && fs.existsSync(directory)) continue;
|
|
602
|
+
kept?.delete(directory);
|
|
603
|
+
watcher.close();
|
|
604
|
+
current.delete(directory);
|
|
411
605
|
}
|
|
606
|
+
for (const directory of desired) {
|
|
607
|
+
if (current.has(directory)) continue;
|
|
608
|
+
try {
|
|
609
|
+
current.set(directory, watchDirectory(root, directory));
|
|
610
|
+
} catch (error) {
|
|
611
|
+
unavailable(root, error);
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
const scheduleReconcile = (root: string, files: string[]) => {
|
|
618
|
+
if (!watchers.has(root)) return;
|
|
619
|
+
const pending = reconciles.has(root);
|
|
620
|
+
reconciles.set(root, watchedDirectories(root, files));
|
|
621
|
+
if (!pending) setImmediate(() => reconcile(root));
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
/** A change seen while listing leaves the cache empty, so the next request lists again. */
|
|
625
|
+
const listTree = (root: string): Promise<VaultTreeNode[]> => {
|
|
626
|
+
const inFlight = listings.get(root);
|
|
627
|
+
if (inFlight) return inFlight;
|
|
628
|
+
const generation = generations.get(root) ?? 0;
|
|
629
|
+
const listing = (async () => {
|
|
630
|
+
try {
|
|
631
|
+
const files = await listMarkdownFiles(root, deps);
|
|
632
|
+
listed.set(root, files);
|
|
633
|
+
startWatching(root);
|
|
634
|
+
scheduleReconcile(root, files);
|
|
635
|
+
const tree = asTree(files);
|
|
636
|
+
if ((generations.get(root) ?? 0) === generation) trees.set(root, tree);
|
|
637
|
+
return tree;
|
|
638
|
+
} finally {
|
|
639
|
+
listings.delete(root);
|
|
640
|
+
}
|
|
641
|
+
})();
|
|
642
|
+
listings.set(root, listing);
|
|
643
|
+
return listing;
|
|
412
644
|
};
|
|
413
645
|
|
|
414
646
|
const readConflict = async (resolved: VaultPath, reason: string): Promise<VaultConflict> => {
|
|
@@ -527,14 +759,24 @@ export function createVaultManager(deps: VaultDeps = {}): VaultManager {
|
|
|
527
759
|
return {
|
|
528
760
|
async tree(companionRoot, refresh = false) {
|
|
529
761
|
const root = await rootKey(companionRoot);
|
|
762
|
+
const tree = !refresh && trees.get(root);
|
|
763
|
+
const result = tree || (await listTree(root));
|
|
530
764
|
startWatching(root);
|
|
531
|
-
if (refresh || !trees.has(root)) trees.set(root, await collectMarkdownTree(root));
|
|
532
765
|
return {
|
|
533
|
-
tree:
|
|
766
|
+
tree: result,
|
|
534
767
|
watching: watchers.has(root),
|
|
535
768
|
warning: warnings.get(root) ?? null,
|
|
536
769
|
};
|
|
537
770
|
},
|
|
771
|
+
async prefetch(companionRoot, refresh = false) {
|
|
772
|
+
const root = await rootKey(companionRoot);
|
|
773
|
+
if (refresh) invalidate(root);
|
|
774
|
+
startWatching(root);
|
|
775
|
+
const tree = trees.get(root);
|
|
776
|
+
if (tree) return { tree, watching: watchers.has(root), warning: warnings.get(root) ?? null };
|
|
777
|
+
void listTree(root).catch(() => {});
|
|
778
|
+
return null;
|
|
779
|
+
},
|
|
538
780
|
async open(companionRoot, requestedPath) {
|
|
539
781
|
const root = await rootKey(companionRoot);
|
|
540
782
|
startWatching(root);
|
|
@@ -548,9 +790,13 @@ export function createVaultManager(deps: VaultDeps = {}): VaultManager {
|
|
|
548
790
|
const set = subscribers.get(key) ?? new Set<Subscriber>();
|
|
549
791
|
set.add(subscriber);
|
|
550
792
|
subscribers.set(key, set);
|
|
793
|
+
const watchListed = (root: string) => {
|
|
794
|
+
startWatching(root);
|
|
795
|
+
if (!trees.has(root)) void listTree(root).catch(() => {});
|
|
796
|
+
};
|
|
551
797
|
void rootKey(companionRoot).then((root) => {
|
|
552
798
|
if (!active || root === path.resolve(companionRoot)) {
|
|
553
|
-
if (active)
|
|
799
|
+
if (active) watchListed(root);
|
|
554
800
|
return;
|
|
555
801
|
}
|
|
556
802
|
const oldSet = subscribers.get(key);
|
|
@@ -560,7 +806,7 @@ export function createVaultManager(deps: VaultDeps = {}): VaultManager {
|
|
|
560
806
|
const newSet = subscribers.get(key) ?? new Set<Subscriber>();
|
|
561
807
|
newSet.add(subscriber);
|
|
562
808
|
subscribers.set(key, newSet);
|
|
563
|
-
|
|
809
|
+
watchListed(root);
|
|
564
810
|
});
|
|
565
811
|
return () => {
|
|
566
812
|
active = false;
|
|
@@ -575,16 +821,15 @@ export function createVaultManager(deps: VaultDeps = {}): VaultManager {
|
|
|
575
821
|
},
|
|
576
822
|
deactivate() {
|
|
577
823
|
if (activeRoot) {
|
|
578
|
-
|
|
579
|
-
watchers.delete(activeRoot);
|
|
824
|
+
closeWatchers(activeRoot);
|
|
580
825
|
warnings.delete(activeRoot);
|
|
581
826
|
}
|
|
582
827
|
activeRoot = null;
|
|
583
828
|
subscribers.clear();
|
|
584
829
|
},
|
|
585
830
|
stop() {
|
|
586
|
-
for (const
|
|
587
|
-
|
|
831
|
+
for (const root of watchers.keys()) closeWatchers(root);
|
|
832
|
+
reconciles.clear();
|
|
588
833
|
activeRoot = null;
|
|
589
834
|
for (const timer of pendingEvents.values()) clearTimeout(timer);
|
|
590
835
|
for (const record of recentSaves.values()) clearTimeout(record.expires);
|
|
@@ -2,6 +2,7 @@ import { COMPANION_DIGEST_PATTERN, COMPANION_PARAM } from "../selection";
|
|
|
2
2
|
|
|
3
3
|
export const THEME_STORAGE_KEY = "mate-studio-theme";
|
|
4
4
|
export const COMPANION_STORAGE_KEY = "mate-studio-companion";
|
|
5
|
+
export const VAULT_TREE_STORAGE_KEY = "mate-studio-vault-tree";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Runs in `<head>` before first paint. A server-rendered document arrives with
|
|
@@ -20,6 +21,9 @@ export const STUDIO_PREPAINT_SCRIPT = `(function () {
|
|
|
20
21
|
if (chosen === "dark" || chosen === "light") {
|
|
21
22
|
document.documentElement.setAttribute("data-theme", chosen);
|
|
22
23
|
}
|
|
24
|
+
if (localStorage.getItem(${JSON.stringify(VAULT_TREE_STORAGE_KEY)}) === "hidden") {
|
|
25
|
+
document.documentElement.setAttribute("data-vault-tree", "hidden");
|
|
26
|
+
}
|
|
23
27
|
} catch (error) {
|
|
24
28
|
/* a blocked web store only costs the preference, never the page */
|
|
25
29
|
}
|
|
@@ -38,9 +42,9 @@ export const STUDIO_PREPAINT_SCRIPT = `(function () {
|
|
|
38
42
|
|
|
39
43
|
/**
|
|
40
44
|
* The only browser code Studio ships: cycling the appearance, remembering the
|
|
41
|
-
* companion on screen, switching workflow schema tabs,
|
|
42
|
-
*
|
|
43
|
-
*
|
|
45
|
+
* companion on screen, switching workflow schema tabs, copying a prompt, the
|
|
46
|
+
* vault's editor, filter and tree toggle, and swapping in a deferred vault tree.
|
|
47
|
+
* Each needs an API the server does not have; all markup comes from the server.
|
|
44
48
|
*/
|
|
45
49
|
export const STUDIO_CLIENT_SCRIPT = `(function () {
|
|
46
50
|
var THEME_KEY = ${JSON.stringify(THEME_STORAGE_KEY)};
|
|
@@ -248,8 +252,8 @@ export const STUDIO_CLIENT_SCRIPT = `(function () {
|
|
|
248
252
|
if (submitter && submitter.name && submitter.value) target.searchParams.set(submitter.name, submitter.value);
|
|
249
253
|
return target.toString();
|
|
250
254
|
}
|
|
251
|
-
function wireNavigation() {
|
|
252
|
-
document.querySelectorAll('form[method="get"]').forEach(function (form) {
|
|
255
|
+
function wireNavigation(scope) {
|
|
256
|
+
(scope || document).querySelectorAll('form[method="get"]').forEach(function (form) {
|
|
253
257
|
form.addEventListener("submit", function (event) {
|
|
254
258
|
event.preventDefault();
|
|
255
259
|
navigate(navigationUrl(form, event.submitter));
|
|
@@ -299,10 +303,9 @@ export const STUDIO_CLIENT_SCRIPT = `(function () {
|
|
|
299
303
|
if (recover) recover.addEventListener("click", function () {
|
|
300
304
|
acceptIncoming({ content: recover.getAttribute("data-vault-content") || "", token: recover.getAttribute("data-vault-token") || "" });
|
|
301
305
|
});
|
|
302
|
-
var
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
var events = new EventSource("/api/vault/events?companion=" + encodeURIComponent(companion.getAttribute("data-companion") || "") + "&path=" + encodeURIComponent(path));
|
|
306
|
+
var eventsUrl = node.getAttribute("data-vault-events");
|
|
307
|
+
if (eventsUrl && typeof EventSource !== "undefined") {
|
|
308
|
+
var events = new EventSource(eventsUrl);
|
|
306
309
|
events.onmessage = function (message) {
|
|
307
310
|
var event;
|
|
308
311
|
try { event = JSON.parse(message.data); } catch (error) { return; }
|
|
@@ -332,6 +335,84 @@ export const STUDIO_CLIENT_SCRIPT = `(function () {
|
|
|
332
335
|
event.preventDefault();
|
|
333
336
|
event.returnValue = "";
|
|
334
337
|
});
|
|
338
|
+
function filterTree(query) {
|
|
339
|
+
var needle = query.trim().toLowerCase();
|
|
340
|
+
document.querySelectorAll("[data-vault-entry]").forEach(function (entry) {
|
|
341
|
+
entry.hidden = !!needle && (entry.getAttribute("data-vault-entry") || "").toLowerCase().indexOf(needle) === -1;
|
|
342
|
+
});
|
|
343
|
+
document.querySelectorAll("[data-vault-dir]").forEach(function (folder) {
|
|
344
|
+
if (!needle) {
|
|
345
|
+
folder.hidden = false;
|
|
346
|
+
folder.open = folder.hasAttribute("data-vault-expanded");
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
var match = folder.querySelector("[data-vault-entry]:not([hidden])");
|
|
350
|
+
folder.hidden = !match;
|
|
351
|
+
if (match) folder.open = true;
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
function showTree(shown) {
|
|
355
|
+
var root = document.documentElement;
|
|
356
|
+
if (shown) root.removeAttribute("data-vault-tree");
|
|
357
|
+
else root.setAttribute("data-vault-tree", "hidden");
|
|
358
|
+
var toggle = document.getElementById("vault-tree-toggle");
|
|
359
|
+
if (toggle) {
|
|
360
|
+
toggle.textContent = shown ? "Hide files" : "Show files";
|
|
361
|
+
toggle.setAttribute("aria-expanded", shown ? "true" : "false");
|
|
362
|
+
}
|
|
363
|
+
try {
|
|
364
|
+
localStorage.setItem(${JSON.stringify(VAULT_TREE_STORAGE_KEY)}, shown ? "shown" : "hidden");
|
|
365
|
+
} catch (error) {
|
|
366
|
+
/* a blocked web store only costs the preference, never the page */
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/** Moves server-rendered parts into place; builds no markup of its own. */
|
|
370
|
+
function swapVaultSlots(markup) {
|
|
371
|
+
var parsed = new DOMParser().parseFromString(markup, "text/html");
|
|
372
|
+
parsed.querySelectorAll("[data-vault-slot]").forEach(function (slot) {
|
|
373
|
+
var target = document.querySelector('[data-vault-slot="' + slot.getAttribute("data-vault-slot") + '"]');
|
|
374
|
+
if (!target) return;
|
|
375
|
+
var node = document.importNode(slot, true);
|
|
376
|
+
target.replaceWith(node);
|
|
377
|
+
wireNavigation(node);
|
|
378
|
+
});
|
|
379
|
+
var filter = document.getElementById("vault-filter");
|
|
380
|
+
if (filter && filter.value) filterTree(filter.value);
|
|
381
|
+
}
|
|
382
|
+
function failVaultSlots(message) {
|
|
383
|
+
document.querySelectorAll("[data-vault-slot][aria-busy]").forEach(function (slot) {
|
|
384
|
+
slot.removeAttribute("aria-busy");
|
|
385
|
+
slot.textContent = message;
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
function wireVaultBrowser() {
|
|
389
|
+
var layout = document.querySelector("[data-vault-layout]");
|
|
390
|
+
if (!layout) return;
|
|
391
|
+
var filter = document.getElementById("vault-filter");
|
|
392
|
+
if (filter) {
|
|
393
|
+
filter.hidden = false;
|
|
394
|
+
filter.addEventListener("input", function () { filterTree(filter.value); });
|
|
395
|
+
}
|
|
396
|
+
var toggle = document.getElementById("vault-tree-toggle");
|
|
397
|
+
if (toggle) {
|
|
398
|
+
toggle.hidden = false;
|
|
399
|
+
var hidden = document.documentElement.getAttribute("data-vault-tree") === "hidden";
|
|
400
|
+
toggle.textContent = hidden ? "Show files" : "Hide files";
|
|
401
|
+
toggle.setAttribute("aria-expanded", hidden ? "false" : "true");
|
|
402
|
+
toggle.addEventListener("click", function () {
|
|
403
|
+
showTree(document.documentElement.getAttribute("data-vault-tree") === "hidden");
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
var deferred = layout.getAttribute("data-vault-view");
|
|
407
|
+
if (!deferred) return;
|
|
408
|
+
fetch(deferred, { headers: { accept: "text/html" } }).then(function (response) {
|
|
409
|
+
if (!response.ok) throw new Error("status " + response.status);
|
|
410
|
+
return response.text();
|
|
411
|
+
}).then(swapVaultSlots).catch(function (error) {
|
|
412
|
+
failVaultSlots("The files could not be listed: " + (error && error.message ? error.message : String(error)));
|
|
413
|
+
});
|
|
414
|
+
}
|
|
335
415
|
wireNavigation();
|
|
336
416
|
wireVault();
|
|
417
|
+
wireVaultBrowser();
|
|
337
418
|
})();`;
|