@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.
Files changed (103) hide show
  1. package/package.json +11 -5
  2. package/src/cli/commands/artifact/finish/index.ts +0 -9
  3. package/src/cli/commands/artifact/finish/openspec.ts +2 -2
  4. package/src/cli/commands/artifact/finish/registry.ts +1 -1
  5. package/src/cli/commands/artifact/pending/discovery.ts +1 -1
  6. package/src/cli/commands/artifact/pending/index.ts +0 -16
  7. package/src/cli/commands/cap/graphify.ts +1 -1
  8. package/src/cli/commands/cap/index-cmd.ts +1 -1
  9. package/src/cli/commands/cap/tokensave.ts +4 -3
  10. package/src/cli/commands/companion/companion.ts +4 -0
  11. package/src/cli/commands/companion/hub.ts +2 -1
  12. package/src/cli/commands/companion/list.ts +2 -1
  13. package/src/cli/commands/companion/prepare.ts +2 -2
  14. package/src/cli/commands/companion/update.ts +334 -0
  15. package/src/cli/commands/doctor.ts +2 -1
  16. package/src/cli/commands/install.ts +59 -5
  17. package/src/cli/commands/launch/shared.ts +2 -1
  18. package/src/cli/commands/plugin/plugin.ts +4 -0
  19. package/src/cli/commands/plugin/verify.ts +25 -0
  20. package/src/cli/commands/report/highlight.ts +1 -4
  21. package/src/cli/commands/report/renderer.ts +0 -2
  22. package/src/cli/commands/studio/access.ts +201 -0
  23. package/src/cli/commands/studio/index.ts +207 -26
  24. package/src/cli/commands/studio/openspec-cli.ts +1 -1
  25. package/src/cli/commands/studio/selection.ts +127 -11
  26. package/src/cli/commands/studio/server.ts +355 -64
  27. package/src/cli/commands/studio/terminal-assets.ts +50 -0
  28. package/src/cli/commands/studio/terminal-launch.ts +71 -0
  29. package/src/cli/commands/studio/terminal.ts +593 -0
  30. package/src/cli/commands/studio/vault.ts +320 -75
  31. package/src/cli/commands/studio/views/client.ts +90 -9
  32. package/src/cli/commands/studio/views/companion-picker.tsx +9 -5
  33. package/src/cli/commands/studio/views/companion-selector.tsx +11 -7
  34. package/src/cli/commands/studio/views/document.tsx +411 -107
  35. package/src/cli/commands/studio/views/model.ts +14 -1
  36. package/src/cli/commands/studio/views/selection-fields.tsx +14 -0
  37. package/src/cli/commands/studio/views/styles.ts +106 -13
  38. package/src/cli/commands/studio/views/terminal.tsx +426 -0
  39. package/src/cli/commands/studio/views/vault/state.ts +42 -0
  40. package/src/cli/commands/studio/views/workflow/steps.ts +10 -3
  41. package/src/cli/commands/update.ts +11 -8
  42. package/src/cli/companion-link-wizard.tsx +1 -1
  43. package/src/cli/companion-selector.tsx +1 -1
  44. package/src/cli/launch-selector.tsx +1 -1
  45. package/src/cli/main.ts +2 -1
  46. package/src/cli/usage.ts +7 -3
  47. package/src/cli/write-json-stdout.ts +2 -2
  48. package/src/lib/components/spinner.tsx +2 -2
  49. package/src/lib/components/startup-progress.tsx +1 -1
  50. package/src/lib/context-mode-package.ts +1 -1
  51. package/src/lib/opencode-plugin-package.ts +3 -0
  52. package/src/lib/orchestrator/adapters/opencode.ts +53 -13
  53. package/src/lib/orchestrator/companion-hub.ts +3 -3
  54. package/src/lib/orchestrator/companion-registry-store.ts +1 -1
  55. package/src/lib/orchestrator/config-store.ts +2 -2
  56. package/src/lib/orchestrator/editor.ts +4 -12
  57. package/src/lib/orchestrator/engine-guard.ts +9 -1
  58. package/src/lib/orchestrator/projection-entries.ts +1 -1
  59. package/src/lib/orchestrator/projection-record.ts +1 -1
  60. package/src/lib/orchestrator/projection-types.ts +0 -4
  61. package/src/lib/orchestrator/repo-local-registry.ts +0 -1
  62. package/src/lib/orchestrator/root-context.ts +1 -1
  63. package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
  64. package/src/lib/orchestrator/setup-preflight.ts +1 -1
  65. package/src/lib/orchestrator/types.ts +1 -1
  66. package/src/lib/orchestrator/working-repo-projection.ts +1 -1
  67. package/src/lib/orchestrator/workspace-inventory.ts +2 -2
  68. package/src/lib/package-paths.ts +1 -11
  69. package/src/lib/package-root.ts +43 -0
  70. package/src/lib/prebuilt-workspace.ts +1 -1
  71. package/src/lib/public-npm.ts +1 -1
  72. package/src/opencode/companion-hooks.ts +131 -153
  73. package/src/opencode/index.ts +12 -6
  74. package/src/opencode/tui.tsx +46 -49
  75. package/src/playbooks/companion-guidance.ts +1 -28
  76. package/src/runtime/companion-sync.ts +0 -3
  77. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
  78. package/src/templates/providers/opencode/opencode.json +6 -4
  79. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +500 -408
  80. package/src/tools/setup/capabilities/context-mode.ts +45 -25
  81. package/src/tools/setup/capabilities/graphify-shared.ts +3 -3
  82. package/src/tools/setup/capabilities/graphify.ts +2 -2
  83. package/src/tools/setup/capabilities/openspec.ts +2 -7
  84. package/src/tools/setup/capabilities/react-doctor.ts +1 -1
  85. package/src/tools/setup/capabilities/rtk.ts +2 -2
  86. package/src/tools/setup/capabilities/tokensave.ts +98 -12
  87. package/src/tools/setup/context-services.ts +3 -6
  88. package/src/tools/setup/dynamic-plugins/declarations.ts +47 -0
  89. package/src/tools/setup/dynamic-plugins/frozen.ts +215 -0
  90. package/src/tools/setup/dynamic-plugins/hydrate.ts +1 -40
  91. package/src/tools/setup/dynamic-plugins/loader.ts +16 -0
  92. package/src/tools/setup/dynamic-plugins/policy.ts +55 -0
  93. package/src/tools/setup/dynamic-plugins/staging.ts +53 -0
  94. package/src/tools/setup/dynamic-plugins/verify.ts +70 -0
  95. package/src/tools/setup/mate.ts +1 -1
  96. package/src/tools/setup/providers/claude-format.ts +1 -1
  97. package/src/tools/setup/providers/claude.ts +1 -1
  98. package/src/tools/setup/providers/opencode-format.ts +220 -25
  99. package/src/tools/setup/providers/opencode.ts +63 -21
  100. package/src/tools/setup/runtime-documents.ts +9 -11
  101. package/src/tools/setup.ts +0 -4
  102. package/src/cli/commands/studio/views/pairings.tsx +0 -38
  103. 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 { execFile as execFileCallback } from "node:child_process";
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 VaultDeps {
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
- root: string,
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
- async function ignored(root: string, relative: string): Promise<boolean> {
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 execFile("git", ["-C", root, "check-ignore", "--no-index", "--quiet", "--", relative]);
153
- return true;
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
- async function collectFiles(
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 fsp.readdir(current, { withFileTypes: true });
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
- stats = await fsp.stat(resolved);
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 collectFiles(root, resolved, relative, output);
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(companionRoot: string): Promise<VaultTreeNode[]> {
329
+ export async function collectMarkdownTree(
330
+ companionRoot: string,
331
+ deps: VaultListingDeps = {},
332
+ ): Promise<VaultTreeNode[]> {
221
333
  const root = await fsp.realpath(companionRoot);
222
- const files: string[] = [];
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
- export const createVersionToken = versionToken;
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
- root: string,
349
+ directory: string,
245
350
  listener: (event: string, filename: string | Buffer | null) => void,
246
351
  ): VaultWatcher {
247
- const watcher: FSWatcher = fs.watch(root, { recursive: true }, listener);
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 recursive watcher per selected companion process. */
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
- watchers.get(activeRoot)?.close();
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
- warnings.set(
408
- root,
409
- `Live updates are unavailable: ${error instanceof Error ? error.message : String(error)}`,
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: trees.get(root) ?? [],
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) startWatching(root);
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
- startWatching(root);
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
- watchers.get(activeRoot)?.close();
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 watcher of watchers.values()) watcher.close();
587
- watchers.clear();
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, and copying a prompt.
42
- * Each needs an API the server does not have, and nothing else here renders,
43
- * fetches, or holds state.
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 companion = document.querySelector("[data-companion]");
303
- var path = node.getAttribute("data-vault-path");
304
- if (companion && path && typeof EventSource !== "undefined") {
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
  })();`;