@hudhod/core 0.1.0

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/dist/index.js ADDED
@@ -0,0 +1,2084 @@
1
+ import { _ as DisposableStore, a as isSubPath, c as pathSegments, d as directoryNotEmpty, f as fileExists, g as notAFile, h as notADirectory, i as extname, l as relativePath, m as invalidPath, n as basename, o as joinPath, p as fileNotFound, r as dirname, s as normalizePath, t as ROOT, u as createError, v as NO_OP_DISPOSABLE, y as toDisposable } from "./paths-B3gr2EuI.js";
2
+ import { applyPatch, createTwoFilesPatch, diffArrays, diffLines } from "diff";
3
+ import { z } from "zod";
4
+ import picomatch from "picomatch";
5
+
6
+ //#region src/base/event.ts
7
+ /**
8
+ * Produces an {@link Event} and the means to fire it.
9
+ *
10
+ * Listener errors are isolated: one throwing listener never prevents the others
11
+ * from running. Errors are reported to `onListenerError` instead of
12
+ * propagating to the caller of {@link fire}, because an emitter's producer
13
+ * generally cannot do anything useful about a consumer's failure.
14
+ *
15
+ * @typeParam T - The payload delivered to listeners.
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * const emitter = new Emitter<string>();
20
+ * const sub = emitter.event((name) => console.log(name));
21
+ * emitter.fire("world");
22
+ * sub.dispose();
23
+ * ```
24
+ */
25
+ var Emitter = class {
26
+ #listeners = /* @__PURE__ */ new Set();
27
+ #onListenerError;
28
+ #disposed = false;
29
+ /**
30
+ * @param options.onListenerError - Called when a listener throws.
31
+ * Defaults to `console.error`.
32
+ */
33
+ constructor(options = {}) {
34
+ this.#onListenerError = options.onListenerError ?? ((error) => {
35
+ console.error("[hudhod] event listener threw", error);
36
+ });
37
+ }
38
+ /** Number of listeners currently subscribed. */
39
+ get listenerCount() {
40
+ return this.#listeners.size;
41
+ }
42
+ /**
43
+ * Subscribes to this emitter.
44
+ *
45
+ * Registering the same function twice yields a single subscription, matching
46
+ * `Set` semantics; disposing either handle removes it.
47
+ */
48
+ event = (listener) => {
49
+ if (this.#disposed) return toDisposable(() => {});
50
+ this.#listeners.add(listener);
51
+ return toDisposable(() => {
52
+ this.#listeners.delete(listener);
53
+ });
54
+ };
55
+ /**
56
+ * Delivers `value` to every current listener.
57
+ *
58
+ * Iterates a snapshot, so listeners added or removed during delivery do not
59
+ * affect the in-flight dispatch.
60
+ */
61
+ fire(value) {
62
+ if (this.#disposed) return;
63
+ for (const listener of Array.from(this.#listeners)) try {
64
+ listener(value);
65
+ } catch (error) {
66
+ this.#onListenerError(error);
67
+ }
68
+ }
69
+ /** Removes all listeners and blocks further subscription. */
70
+ dispose() {
71
+ this.#disposed = true;
72
+ this.#listeners.clear();
73
+ }
74
+ };
75
+
76
+ //#endregion
77
+ //#region src/base/cancellation.ts
78
+ /** A token that is never cancelled. Useful as a default parameter. */
79
+ const CancellationTokenNone = Object.freeze({
80
+ isCancellationRequested: false,
81
+ onCancellationRequested: () => NO_OP_DISPOSABLE
82
+ });
83
+ /**
84
+ * Creates a {@link CancellationToken} and controls when it fires.
85
+ *
86
+ * Listeners registered after cancellation are invoked immediately, so a late
87
+ * subscriber cannot miss the signal.
88
+ *
89
+ * @example
90
+ * ```ts
91
+ * const source = new CancellationTokenSource();
92
+ * const results = await search(query, source.token);
93
+ * source.cancel();
94
+ * ```
95
+ */
96
+ var CancellationTokenSource = class {
97
+ #emitter = new Emitter();
98
+ #cancelled = false;
99
+ /** The token to hand to cancellable operations. */
100
+ token;
101
+ constructor() {
102
+ const isCancelled = () => this.#cancelled;
103
+ const onCancellationRequested = this.#emitter.event;
104
+ this.token = {
105
+ get isCancellationRequested() {
106
+ return isCancelled();
107
+ },
108
+ onCancellationRequested: (listener) => {
109
+ if (isCancelled()) {
110
+ listener();
111
+ return NO_OP_DISPOSABLE;
112
+ }
113
+ return onCancellationRequested(listener);
114
+ }
115
+ };
116
+ }
117
+ /** Whether cancellation has been requested. */
118
+ get isCancellationRequested() {
119
+ return this.#cancelled;
120
+ }
121
+ /** Requests cancellation. Subsequent calls are no-ops. */
122
+ cancel() {
123
+ if (this.#cancelled) return;
124
+ this.#cancelled = true;
125
+ this.#emitter.fire();
126
+ }
127
+ /** Releases listeners without cancelling. */
128
+ dispose() {
129
+ this.#emitter.dispose();
130
+ }
131
+ };
132
+ /**
133
+ * Adapts an {@link AbortSignal} into a {@link CancellationToken}.
134
+ *
135
+ * Lets callers pass the platform-standard signal to hudhod APIs.
136
+ */
137
+ function tokenFromAbortSignal(signal) {
138
+ return {
139
+ get isCancellationRequested() {
140
+ return signal.aborted;
141
+ },
142
+ onCancellationRequested: (listener) => {
143
+ if (signal.aborted) {
144
+ listener();
145
+ return NO_OP_DISPOSABLE;
146
+ }
147
+ const onAbort = () => listener();
148
+ signal.addEventListener("abort", onAbort, { once: true });
149
+ return { dispose() {
150
+ signal.removeEventListener("abort", onAbort);
151
+ } };
152
+ }
153
+ };
154
+ }
155
+
156
+ //#endregion
157
+ //#region src/commands/command-registry.ts
158
+ /**
159
+ * Registers and invokes commands.
160
+ *
161
+ * @example
162
+ * ```ts
163
+ * const commands = new CommandRegistry();
164
+ * commands.registerCommand("demo.hello", () => "Hello", { title: "Say Hello" });
165
+ * await commands.executeCommand("demo.hello");
166
+ * ```
167
+ */
168
+ var CommandRegistry = class {
169
+ #commands = /* @__PURE__ */ new Map();
170
+ #changeEmitter = new Emitter();
171
+ /** Fires whenever the command catalog changes. */
172
+ onDidChangeCommands = (listener) => this.#changeEmitter.event(listener);
173
+ /**
174
+ * Registers a command handler.
175
+ *
176
+ * @throws A `CommandExists` error when `id` is already registered.
177
+ */
178
+ registerCommand(id, handler, options = {}) {
179
+ if (this.#commands.has(id)) throw createError("CommandExists", `Command is already registered: ${id}`);
180
+ const registered = {
181
+ descriptor: {
182
+ id,
183
+ ...options.title ? { title: options.title } : { title: id },
184
+ ...options.category ? { category: options.category } : {}
185
+ },
186
+ handler
187
+ };
188
+ this.#commands.set(id, registered);
189
+ this.#fireChange();
190
+ let disposed = false;
191
+ return { dispose: () => {
192
+ if (disposed) return;
193
+ disposed = true;
194
+ if (this.#commands.get(id) !== registered) return;
195
+ this.#commands.delete(id);
196
+ this.#fireChange();
197
+ } };
198
+ }
199
+ /**
200
+ * Invokes a registered command.
201
+ *
202
+ * @throws A `CommandNotFound` error when no handler is registered for `id`.
203
+ */
204
+ async executeCommand(id, ...args) {
205
+ const command = this.#commands.get(id);
206
+ if (!command) throw createError("CommandNotFound", `Command not found: ${id}`);
207
+ return await command.handler(...args);
208
+ }
209
+ /** Lists registered commands, alphabetically by title then id. */
210
+ async getCommands() {
211
+ return sortedBy$3(Array.from(this.#commands.values(), ({ descriptor }) => descriptor.category ? {
212
+ id: descriptor.id,
213
+ title: descriptor.title,
214
+ category: descriptor.category
215
+ } : {
216
+ id: descriptor.id,
217
+ title: descriptor.title
218
+ }), (left, right) => left.title.localeCompare(right.title) || left.id.localeCompare(right.id));
219
+ }
220
+ /** Removes all registered commands and listeners. */
221
+ dispose() {
222
+ const changed = this.#commands.size > 0;
223
+ this.#commands.clear();
224
+ if (changed) this.#fireChange();
225
+ this.#changeEmitter.dispose();
226
+ }
227
+ #fireChange() {
228
+ this.getCommands().then((commands) => this.#changeEmitter.fire(commands));
229
+ }
230
+ };
231
+ function sortedBy$3(values, compare) {
232
+ const result = [];
233
+ for (const value of values) {
234
+ const index = result.findIndex((candidate) => compare(value, candidate) < 0);
235
+ if (index === -1) result.push(value);
236
+ else result.splice(index, 0, value);
237
+ }
238
+ return result;
239
+ }
240
+
241
+ //#endregion
242
+ //#region src/keybindings/keybinding-parser.ts
243
+ /**
244
+ * Parses a keybinding string like `"ctrl+shift+p"` into components.
245
+ *
246
+ * @throws Error when the syntax is malformed.
247
+ * @example
248
+ * parseKeybinding("ctrl+n") => { ctrl: true, shift: false, alt: false, key: "n" }
249
+ * parseKeybinding("cmd+shift+k") => { ctrl: true, shift: true, alt: false, key: "k" }
250
+ */
251
+ function parseKeybinding(binding) {
252
+ const parts = binding.toLowerCase().split("+");
253
+ if (parts.length < 1) throw new Error(`Keybinding must have at least a key: ${binding}`);
254
+ const normalized = {
255
+ ctrl: false,
256
+ shift: false,
257
+ alt: false,
258
+ key: ""
259
+ };
260
+ for (let i = 0; i < parts.length - 1; i++) {
261
+ const mod = parts[i];
262
+ if (mod === "ctrl") normalized.ctrl = true;
263
+ else if (mod === "cmd" || mod === "meta") normalized.ctrl = true;
264
+ else if (mod === "shift") normalized.shift = true;
265
+ else if (mod === "alt") normalized.alt = true;
266
+ else throw new Error(`Unknown modifier '${mod}' in keybinding: ${binding}`);
267
+ }
268
+ const keyPart = parts[parts.length - 1];
269
+ if (!keyPart) throw new Error(`Keybinding must end with a key: ${binding}`);
270
+ normalized.key = keyPart;
271
+ return normalized;
272
+ }
273
+ /**
274
+ * Converts a {@link NormalizedKeybinding} back to a canonical string.
275
+ *
276
+ * @example
277
+ * keybindingToString({ ctrl: true, shift: true, alt: false, key: "p" }) => "ctrl+shift+p"
278
+ */
279
+ function keybindingToString(binding) {
280
+ const parts = [];
281
+ if (binding.ctrl) parts.push("ctrl");
282
+ if (binding.shift) parts.push("shift");
283
+ if (binding.alt) parts.push("alt");
284
+ parts.push(binding.key);
285
+ return parts.join("+");
286
+ }
287
+ /**
288
+ * Resolves a keyboard event to a canonical key string, or `undefined` if no binding.
289
+ *
290
+ * Returns a string like `"ctrl+shift+p"` that can be matched against registered bindings.
291
+ */
292
+ function keybindingFromEvent(event) {
293
+ const parts = [];
294
+ if (event.ctrlKey || event.metaKey) parts.push("ctrl");
295
+ if (event.shiftKey) parts.push("shift");
296
+ if (event.altKey) parts.push("alt");
297
+ parts.push(event.key.toLowerCase());
298
+ return parts.join("+");
299
+ }
300
+
301
+ //#endregion
302
+ //#region src/keybindings/keybinding-registry.ts
303
+ /**
304
+ * Registers and resolves keybindings.
305
+ *
306
+ * @example
307
+ * ```ts
308
+ * const keybindings = new KeybindingRegistry("other");
309
+ * keybindings.registerKeybinding({
310
+ * command: "demo.greet",
311
+ * key: "ctrl+n",
312
+ * mac: "cmd+n",
313
+ * });
314
+ * const binding = await keybindings.resolve({ key: "n", ctrlKey: true, ... });
315
+ * // => { key: "ctrl+n", command: "demo.greet", source: "extension" }
316
+ * ```
317
+ */
318
+ var KeybindingRegistry = class {
319
+ #stack = /* @__PURE__ */ new Map();
320
+ #changeEmitter = new Emitter();
321
+ #platform;
322
+ /** Fires whenever the keybinding catalog changes. */
323
+ onDidChangeKeybindings = (listener) => this.#changeEmitter.event(listener);
324
+ /**
325
+ * @param platform Platform identifier. Use `"mac"` for macOS; otherwise `"other"`.
326
+ */
327
+ constructor(platform = "other") {
328
+ this.#platform = platform;
329
+ }
330
+ /**
331
+ * Registers a keybinding.
332
+ *
333
+ * If the same key is already bound, this replaces it. Disposing the returned
334
+ * {@link Disposable} restores the previous binding.
335
+ *
336
+ * @throws invalidKeybinding when the key syntax is malformed.
337
+ */
338
+ registerKeybinding(binding, options) {
339
+ const keyNormalized = parseKeybinding(binding.key);
340
+ const macNormalized = binding.mac ? parseKeybinding(binding.mac) : void 0;
341
+ const keyStr = keybindingToString(this.#platform === "mac" && macNormalized ? macNormalized : keyNormalized);
342
+ const stacked = {
343
+ contribution: binding,
344
+ source: options?.source ?? "extension",
345
+ extensionId: options?.extensionId
346
+ };
347
+ if (!this.#stack.has(keyStr)) this.#stack.set(keyStr, []);
348
+ this.#stack.get(keyStr).push(stacked);
349
+ this.#fireChange();
350
+ let disposed = false;
351
+ return { dispose: () => {
352
+ if (disposed) return;
353
+ disposed = true;
354
+ const stack = this.#stack.get(keyStr);
355
+ if (!stack) return;
356
+ const idx = stack.indexOf(stacked);
357
+ if (idx >= 0) stack.splice(idx, 1);
358
+ if (stack.length === 0) this.#stack.delete(keyStr);
359
+ this.#fireChange();
360
+ } };
361
+ }
362
+ /**
363
+ * Resolves a keyboard event to a keybinding, if one is registered.
364
+ *
365
+ * @param event A keyboard event-like object with `key`, `ctrlKey`, `metaKey`, `shiftKey`, `altKey`.
366
+ * @returns The registered keybinding, or `undefined` if no match.
367
+ */
368
+ resolve(event) {
369
+ const keyStr = keybindingFromEvent(event);
370
+ const stack = this.#stack.get(keyStr);
371
+ if (!stack || stack.length === 0) return void 0;
372
+ const top = stack[stack.length - 1];
373
+ if (!top) return void 0;
374
+ return {
375
+ key: keyStr,
376
+ command: top.contribution.command,
377
+ source: top.source,
378
+ extensionId: top.extensionId
379
+ };
380
+ }
381
+ /** Lists all registered keybindings, with top-of-stack (most recent) entries first. */
382
+ async getKeybindings() {
383
+ const result = [];
384
+ for (const [keyStr, stack] of this.#stack) if (stack.length > 0) {
385
+ const top = stack[stack.length - 1];
386
+ if (!top) continue;
387
+ result.push({
388
+ key: keyStr,
389
+ command: top.contribution.command,
390
+ source: top.source,
391
+ extensionId: top.extensionId
392
+ });
393
+ }
394
+ return sortedBy$2(result, (left, right) => left.key.localeCompare(right.key));
395
+ }
396
+ /** Removes all registered keybindings and listeners. */
397
+ dispose() {
398
+ const changed = this.#stack.size > 0;
399
+ this.#stack.clear();
400
+ if (changed) this.#fireChange();
401
+ this.#changeEmitter.dispose();
402
+ }
403
+ #fireChange() {
404
+ this.getKeybindings().then((bindings) => this.#changeEmitter.fire(bindings));
405
+ }
406
+ };
407
+ function sortedBy$2(values, compare) {
408
+ const result = [];
409
+ for (const value of values) {
410
+ const index = result.findIndex((candidate) => compare(value, candidate) < 0);
411
+ if (index === -1) result.push(value);
412
+ else result.splice(index, 0, value);
413
+ }
414
+ return result;
415
+ }
416
+
417
+ //#endregion
418
+ //#region src/window/window-service.ts
419
+ /**
420
+ * Provides window and UI APIs to extensions.
421
+ *
422
+ * @example
423
+ * ```ts
424
+ * const provider = createMyWindowUiProvider();
425
+ * const window = new WindowService(provider);
426
+ * await window.showMessage("Hello!");
427
+ * ```
428
+ */
429
+ var WindowService = class {
430
+ #provider;
431
+ constructor(provider) {
432
+ this.#provider = provider;
433
+ }
434
+ async showMessage(message, severity) {
435
+ return this.#provider.showMessage(message, severity);
436
+ }
437
+ async showInputBox(options) {
438
+ return this.#provider.showInputBox(options);
439
+ }
440
+ async showQuickPick(items, options) {
441
+ return this.#provider.showQuickPick(items, options);
442
+ }
443
+ registerPanel(id, render, options) {
444
+ return this.#provider.registerPanel(id, render, options);
445
+ }
446
+ registerView(id, render, options) {
447
+ return this.#provider.registerView(id, render, options);
448
+ }
449
+ async openPanel(id) {
450
+ return this.#provider.openPanel(id);
451
+ }
452
+ async closePanel(id) {
453
+ return this.#provider.closePanel(id);
454
+ }
455
+ async openFile(path) {
456
+ return this.#provider.openFile(path);
457
+ }
458
+ get activeEditor() {
459
+ return this.#provider.activeEditor;
460
+ }
461
+ get onDidChangeActiveEditor() {
462
+ return this.#provider.onDidChangeActiveEditor;
463
+ }
464
+ dispose() {}
465
+ };
466
+
467
+ //#endregion
468
+ //#region src/diff/diff-service.ts
469
+ /** Default lines of context in a unified patch. */
470
+ const DEFAULT_CONTEXT = 3;
471
+ /**
472
+ * Compares text and applies patches.
473
+ *
474
+ * @example
475
+ * ```ts
476
+ * const diff = new DiffService(fs);
477
+ * const patch = await diff.createPatch("/a.ts", before, after);
478
+ * await diff.applyPatch("/a.ts", patch);
479
+ * ```
480
+ */
481
+ var DiffService = class {
482
+ #fs;
483
+ constructor(fileSystem) {
484
+ this.#fs = fileSystem;
485
+ }
486
+ async diffText(original, modified, options = {}) {
487
+ const parts = options.ignoreCase ? diffArrays(splitLines(original), splitLines(modified), { comparator: (left, right) => left.toLowerCase() === right.toLowerCase() }) : diffLines(original, modified, { ignoreWhitespace: options.ignoreWhitespace ?? false });
488
+ const changes = [];
489
+ for (const part of parts) {
490
+ const lines = Array.isArray(part.value) ? part.value : splitLines(part.value);
491
+ if (lines.length === 0) continue;
492
+ changes.push({
493
+ type: part.added ? "added" : part.removed ? "removed" : "unchanged",
494
+ lines
495
+ });
496
+ }
497
+ return changes;
498
+ }
499
+ async diffFiles(originalPath, modifiedPath, options = {}) {
500
+ const [original, modified] = await Promise.all([this.#fs.readTextFile(originalPath), this.#fs.readTextFile(modifiedPath)]);
501
+ return this.diffText(original, modified, options);
502
+ }
503
+ async diffStat(original, modified, options = {}) {
504
+ const changes = await this.diffText(original, modified, options);
505
+ let added = 0;
506
+ let removed = 0;
507
+ for (const change of changes) {
508
+ if (change.type === "added") added += change.lines.length;
509
+ if (change.type === "removed") removed += change.lines.length;
510
+ }
511
+ return {
512
+ added,
513
+ removed
514
+ };
515
+ }
516
+ async createPatch(path, original, modified, options = {}) {
517
+ return createTwoFilesPatch(path, path, original, modified, void 0, void 0, {
518
+ context: options.context ?? DEFAULT_CONTEXT,
519
+ ignoreWhitespace: options.ignoreWhitespace ?? false
520
+ });
521
+ }
522
+ /**
523
+ * Applies a unified diff to a file.
524
+ *
525
+ * @throws A `PatchFailed` error when the patch does not apply cleanly, which
526
+ * usually means the file changed after the patch was produced.
527
+ */
528
+ async applyPatch(path, patch) {
529
+ const result = applyPatch(await this.#fs.readTextFile(path), patch);
530
+ if (result === false) throw createError("PatchFailed", `Patch did not apply cleanly to ${path}`, { path });
531
+ await this.#fs.writeTextFile(path, result);
532
+ }
533
+ };
534
+ /**
535
+ * Splits a jsdiff chunk into lines, dropping the artefact empty string that a
536
+ * trailing newline produces.
537
+ */
538
+ function splitLines(value) {
539
+ if (value === "") return [];
540
+ const lines = value.split("\n");
541
+ if (lines.at(-1) === "") lines.pop();
542
+ return lines;
543
+ }
544
+
545
+ //#endregion
546
+ //#region src/extensions/manifest.ts
547
+ const extensionId = z.string().min(3).max(128).regex(/^[a-z0-9][a-z0-9-]*(?:\.[a-z0-9][a-z0-9-]*)+$/, "must be a dot-separated lowercase identifier, for example acme.todo-finder");
548
+ const commandContribution = z.object({
549
+ id: z.string().min(1).max(256),
550
+ title: z.string().min(1).max(256),
551
+ category: z.string().min(1).max(128).optional()
552
+ });
553
+ const panelContribution = z.object({
554
+ id: z.string().min(1).max(256),
555
+ title: z.string().min(1).max(256),
556
+ icon: z.unknown().optional(),
557
+ location: z.enum([
558
+ "left",
559
+ "right",
560
+ "bottom",
561
+ "center"
562
+ ]).optional()
563
+ });
564
+ const viewContainerContribution = z.object({
565
+ id: z.string().min(1).max(256),
566
+ title: z.string().min(1).max(256),
567
+ icon: z.unknown().optional(),
568
+ location: z.enum([
569
+ "left",
570
+ "right",
571
+ "bottom",
572
+ "center"
573
+ ]).optional()
574
+ });
575
+ const viewContribution = z.object({
576
+ id: z.string().min(1).max(256),
577
+ title: z.string().min(1).max(256),
578
+ container: z.string().min(1).max(256),
579
+ order: z.number().finite().optional()
580
+ });
581
+ const keybindingContribution = z.object({
582
+ command: z.string().min(1),
583
+ key: z.string().min(1).superRefine((val, ctx) => {
584
+ try {
585
+ parseKeybinding(val);
586
+ } catch {
587
+ ctx.addIssue({
588
+ code: "custom",
589
+ message: `Invalid keybinding syntax: ${val}`
590
+ });
591
+ }
592
+ }),
593
+ mac: z.string().min(1).superRefine((val, ctx) => {
594
+ try {
595
+ parseKeybinding(val);
596
+ } catch {
597
+ ctx.addIssue({
598
+ code: "custom",
599
+ message: `Invalid macOS keybinding syntax: ${val}`
600
+ });
601
+ }
602
+ }).optional()
603
+ });
604
+ const activationEvent = z.union([
605
+ z.literal("onStartup"),
606
+ z.string().regex(/^onCommand:[^\s]+$/),
607
+ z.string().regex(/^onFileOpen:.+$/),
608
+ z.string().regex(/^onView:[^\s]+$/)
609
+ ]);
610
+ /** Validates the serializable shape of an extension manifest. */
611
+ const extensionManifestSchema = z.object({
612
+ id: extensionId,
613
+ name: z.string().min(1).max(128),
614
+ version: z.string().regex(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/, "must be a semantic version"),
615
+ description: z.string().max(512).optional(),
616
+ activationEvents: z.array(activationEvent).min(1).optional(),
617
+ contributes: z.object({
618
+ commands: z.array(commandContribution).optional(),
619
+ panels: z.array(panelContribution).optional(),
620
+ viewContainers: z.array(viewContainerContribution).optional(),
621
+ views: z.array(viewContribution).optional(),
622
+ keybindings: z.array(keybindingContribution).optional()
623
+ }).optional()
624
+ }).superRefine((manifest, context) => {
625
+ const commandIds = manifest.contributes?.commands?.map((command) => command.id) ?? [];
626
+ const panelIds = manifest.contributes?.panels?.map((panel) => panel.id) ?? [];
627
+ const viewContainerIds = manifest.contributes?.viewContainers?.map((container) => container.id) ?? [];
628
+ const viewIds = (manifest.contributes?.views ?? []).map((view) => view.id);
629
+ const keybindings = manifest.contributes?.keybindings ?? [];
630
+ if (new Set(commandIds).size !== commandIds.length) context.addIssue({
631
+ code: "custom",
632
+ path: ["contributes", "commands"],
633
+ message: "command contribution ids must be unique"
634
+ });
635
+ if (new Set(panelIds).size !== panelIds.length) context.addIssue({
636
+ code: "custom",
637
+ path: ["contributes", "panels"],
638
+ message: "panel contribution ids must be unique"
639
+ });
640
+ if (new Set(viewContainerIds).size !== viewContainerIds.length) context.addIssue({
641
+ code: "custom",
642
+ path: ["contributes", "viewContainers"],
643
+ message: "view container contribution ids must be unique"
644
+ });
645
+ if (new Set(viewIds).size !== viewIds.length) context.addIssue({
646
+ code: "custom",
647
+ path: ["contributes", "views"],
648
+ message: "view contribution ids must be unique"
649
+ });
650
+ for (const [i, kb] of keybindings.entries()) if (!commandIds.includes(kb.command)) context.addIssue({
651
+ code: "custom",
652
+ path: [
653
+ "contributes",
654
+ "keybindings",
655
+ i,
656
+ "command"
657
+ ],
658
+ message: `Command '${kb.command}' is not defined in contributes.commands`
659
+ });
660
+ const seen = /* @__PURE__ */ new Set();
661
+ for (const [i, kb] of keybindings.entries()) {
662
+ const key = kb.key;
663
+ const pair = `${key}:${kb.command}`;
664
+ if (seen.has(pair)) context.addIssue({
665
+ code: "custom",
666
+ path: [
667
+ "contributes",
668
+ "keybindings",
669
+ i
670
+ ],
671
+ message: `Duplicate keybinding: key '${key}' for command '${kb.command}'`
672
+ });
673
+ seen.add(pair);
674
+ }
675
+ });
676
+ /**
677
+ * Validates a manifest or throws `ZodError` with field-level diagnostics.
678
+ *
679
+ * @example
680
+ * ```ts
681
+ * const manifest = parseExtensionManifest(rawJson);
682
+ * ```
683
+ */
684
+ function parseExtensionManifest(value) {
685
+ return extensionManifestSchema.parse(value);
686
+ }
687
+
688
+ //#endregion
689
+ //#region src/extensions/extension-host.ts
690
+ /**
691
+ * Loads and activates trusted, first-party extensions.
692
+ *
693
+ * The host deliberately does not sandbox code or impose permissions: hudhod's
694
+ * current extension model is curated and first-party. Activation is deduplicated
695
+ * so concurrent triggers only call an extension's `activate` method once.
696
+ *
697
+ * @example
698
+ * ```ts
699
+ * const host = new InProcessExtensionHost(hudhod, { panels, views });
700
+ * host.register(extension);
701
+ * await host.activateByEvent("onStartup");
702
+ * ```
703
+ */
704
+ var InProcessExtensionHost = class {
705
+ #hudhod;
706
+ #panels;
707
+ #views;
708
+ #extensions = /* @__PURE__ */ new Map();
709
+ #disposed = false;
710
+ constructor(hudhod, registries) {
711
+ this.#hudhod = hudhod;
712
+ this.#panels = registries.panels;
713
+ this.#views = registries.views;
714
+ }
715
+ /**
716
+ * Registers an extension without running its activation hook.
717
+ *
718
+ * Contributed keybindings and panels are registered immediately so they can be
719
+ * discovered before the extension's activate hook runs, enabling lazy activation.
720
+ *
721
+ * @throws `ZodError` when the manifest is malformed.
722
+ * @throws `Error` when another extension already owns the same id.
723
+ */
724
+ register(extension) {
725
+ this.#assertActive();
726
+ const manifest = parseExtensionManifest(extension.manifest);
727
+ if (this.#extensions.has(manifest.id)) throw new Error(`Extension is already registered: ${manifest.id}`);
728
+ const registered = {
729
+ extension,
730
+ manifest,
731
+ subscriptions: new DisposableStore(),
732
+ status: "registered"
733
+ };
734
+ const keybindings = manifest.contributes?.keybindings ?? [];
735
+ for (const kb of keybindings) {
736
+ const disp = this.#hudhod.keybindings.registerKeybinding(kb);
737
+ registered.subscriptions.add(disp);
738
+ }
739
+ const panels = manifest.contributes?.panels ?? [];
740
+ for (const panel of panels) {
741
+ const disp = this.#panels.registerPanel(panel, {
742
+ source: "extension",
743
+ extensionId: manifest.id
744
+ });
745
+ registered.subscriptions.add(disp);
746
+ }
747
+ const viewContainers = manifest.contributes?.viewContainers ?? [];
748
+ for (const container of viewContainers) {
749
+ const disp = this.#panels.registerPanel(container, {
750
+ source: "extension",
751
+ extensionId: manifest.id
752
+ });
753
+ registered.subscriptions.add(disp);
754
+ }
755
+ const views = manifest.contributes?.views ?? [];
756
+ for (const view of views) {
757
+ const disp = this.#views.registerView(view, {
758
+ source: "extension",
759
+ extensionId: manifest.id
760
+ });
761
+ registered.subscriptions.add(disp);
762
+ }
763
+ this.#extensions.set(manifest.id, registered);
764
+ let disposed = false;
765
+ return { dispose: () => {
766
+ if (disposed) return;
767
+ disposed = true;
768
+ if (registered.status === "registered") registered.subscriptions.dispose();
769
+ else this.deactivate(manifest.id);
770
+ this.#extensions.delete(manifest.id);
771
+ } };
772
+ }
773
+ /** Lists registered extensions without exposing mutable host state. */
774
+ getExtensions() {
775
+ return Array.from(this.#extensions.values(), (registered) => registered.error ? {
776
+ manifest: registered.manifest,
777
+ status: registered.status,
778
+ error: registered.error
779
+ } : {
780
+ manifest: registered.manifest,
781
+ status: registered.status
782
+ });
783
+ }
784
+ /** Activates every extension that declared `event`. */
785
+ async activateByEvent(event) {
786
+ this.#assertActive();
787
+ const matching = [...this.#extensions.values()].filter((registered) => (registered.manifest.activationEvents ?? ["onStartup"]).includes(event));
788
+ await Promise.all(matching.map((registered) => this.#activate(registered)));
789
+ }
790
+ /** Activates one extension by id. */
791
+ async activate(extensionId$1) {
792
+ this.#assertActive();
793
+ const registered = this.#extensions.get(extensionId$1);
794
+ if (!registered) throw new Error(`Extension is not registered: ${extensionId$1}`);
795
+ await this.#activate(registered);
796
+ }
797
+ /** Deactivates one extension and releases its registered resources. */
798
+ async deactivate(extensionId$1) {
799
+ const registered = this.#extensions.get(extensionId$1);
800
+ if (!registered || registered.status === "registered") return false;
801
+ try {
802
+ await registered.extension.deactivate?.();
803
+ } finally {
804
+ registered.subscriptions.dispose();
805
+ registered.status = "registered";
806
+ registered.error = void 0;
807
+ registered.activatePromise = void 0;
808
+ }
809
+ return true;
810
+ }
811
+ /** Deactivates every extension and blocks further registration. */
812
+ dispose() {
813
+ if (this.#disposed) return;
814
+ this.#disposed = true;
815
+ for (const [id] of this.#extensions) this.deactivate(id);
816
+ this.#extensions.clear();
817
+ }
818
+ async #activate(registered) {
819
+ if (registered.status === "active") return;
820
+ if (registered.activatePromise) return registered.activatePromise;
821
+ registered.status = "activating";
822
+ registered.activatePromise = (async () => {
823
+ try {
824
+ const subscriptions = [];
825
+ const push = subscriptions.push.bind(subscriptions);
826
+ subscriptions.push = (...items) => {
827
+ for (const item of items) registered.subscriptions.add(item);
828
+ return push(...items);
829
+ };
830
+ const context = {
831
+ manifest: registered.manifest,
832
+ subscriptions,
833
+ hudhod: this.#hudhod
834
+ };
835
+ await registered.extension.activate(context);
836
+ registered.status = "active";
837
+ } catch (error) {
838
+ registered.status = "failed";
839
+ registered.error = error instanceof Error ? error.message : String(error);
840
+ registered.subscriptions.dispose();
841
+ throw error;
842
+ } finally {
843
+ registered.activatePromise = void 0;
844
+ }
845
+ })();
846
+ return registered.activatePromise;
847
+ }
848
+ #assertActive() {
849
+ if (this.#disposed) throw new Error("Extension host is disposed");
850
+ }
851
+ };
852
+
853
+ //#endregion
854
+ //#region src/panels/panel-registry.ts
855
+ /**
856
+ * Registers and lists contributed panels.
857
+ *
858
+ * @example
859
+ * ```ts
860
+ * const panels = new PanelRegistry();
861
+ * const sub = panels.registerPanel(
862
+ * { id: "demo.logs", title: "Logs" },
863
+ * { extensionId: "demo" },
864
+ * );
865
+ * panels.getPanels();
866
+ * // => [{ id: "demo.logs", title: "Logs", location: "bottom", source: "extension", extensionId: "demo" }]
867
+ * sub.dispose();
868
+ * ```
869
+ */
870
+ var PanelRegistry = class {
871
+ #stack = /* @__PURE__ */ new Map();
872
+ #changeEmitter = new Emitter();
873
+ /** Fires whenever the panel catalog changes. */
874
+ onDidChangePanels = (listener) => this.#changeEmitter.event(listener);
875
+ /**
876
+ * Registers a panel contribution.
877
+ *
878
+ * If the same id is already registered, this replaces it. Disposing the returned
879
+ * {@link Disposable} restores the previous registration.
880
+ */
881
+ registerPanel(contribution, options) {
882
+ const info = {
883
+ id: contribution.id,
884
+ title: contribution.title,
885
+ icon: contribution.icon,
886
+ location: contribution.location ?? "bottom",
887
+ source: options?.source ?? "extension",
888
+ extensionId: options?.extensionId
889
+ };
890
+ let stack = this.#stack.get(info.id);
891
+ if (!stack) {
892
+ stack = [];
893
+ this.#stack.set(info.id, stack);
894
+ }
895
+ stack.push(info);
896
+ this.#fireChange();
897
+ let disposed = false;
898
+ return { dispose: () => {
899
+ if (disposed) return;
900
+ disposed = true;
901
+ const current = this.#stack.get(info.id);
902
+ if (!current) return;
903
+ const idx = current.indexOf(info);
904
+ if (idx >= 0) current.splice(idx, 1);
905
+ if (current.length === 0) this.#stack.delete(info.id);
906
+ this.#fireChange();
907
+ } };
908
+ }
909
+ /** Lists the active panel per id, sorted by id for stable UI ordering. */
910
+ getPanels() {
911
+ const result = [];
912
+ for (const stack of this.#stack.values()) {
913
+ const top = stack[stack.length - 1];
914
+ if (top) result.push(top);
915
+ }
916
+ return result.sort((a, b) => a.id.localeCompare(b.id));
917
+ }
918
+ /** Removes all registered panels and listeners. */
919
+ dispose() {
920
+ const changed = this.#stack.size > 0;
921
+ this.#stack.clear();
922
+ if (changed) this.#fireChange();
923
+ this.#changeEmitter.dispose();
924
+ }
925
+ #fireChange() {
926
+ this.#changeEmitter.fire(this.getPanels());
927
+ }
928
+ };
929
+
930
+ //#endregion
931
+ //#region src/views/view-registry.ts
932
+ /** Tracks contributed views independently from activity-bar containers. */
933
+ var ViewRegistry = class {
934
+ #stack = /* @__PURE__ */ new Map();
935
+ #changeEmitter = new Emitter();
936
+ #nextRegistrationOrder = 0;
937
+ onDidChangeViews = (listener) => this.#changeEmitter.event(listener);
938
+ registerView(contribution, options) {
939
+ const info = {
940
+ ...contribution,
941
+ source: options?.source ?? "extension",
942
+ extensionId: options?.extensionId,
943
+ registrationOrder: this.#nextRegistrationOrder++
944
+ };
945
+ const stack = this.#stack.get(info.id) ?? [];
946
+ this.#stack.set(info.id, stack);
947
+ stack.push(info);
948
+ this.#fireChange();
949
+ let disposed = false;
950
+ return { dispose: () => {
951
+ if (disposed) return;
952
+ disposed = true;
953
+ const current = this.#stack.get(info.id);
954
+ if (!current) return;
955
+ const index = current.indexOf(info);
956
+ if (index >= 0) current.splice(index, 1);
957
+ if (current.length === 0) this.#stack.delete(info.id);
958
+ this.#fireChange();
959
+ } };
960
+ }
961
+ getViews() {
962
+ return [...this.#stack.values()].map((stack) => stack.at(-1)).filter((view) => view !== void 0).sort((left, right) => {
963
+ if (left.order === void 0 && right.order !== void 0) return 1;
964
+ if (left.order !== void 0 && right.order === void 0) return -1;
965
+ if (left.order !== void 0 && right.order !== void 0 && left.order !== right.order) return left.order - right.order;
966
+ return left.registrationOrder - right.registrationOrder;
967
+ });
968
+ }
969
+ getViewsForContainer(containerId) {
970
+ return this.getViews().filter((view) => view.container === containerId);
971
+ }
972
+ dispose() {
973
+ const changed = this.#stack.size > 0;
974
+ this.#stack.clear();
975
+ if (changed) this.#fireChange();
976
+ this.#changeEmitter.dispose();
977
+ }
978
+ #fireChange() {
979
+ this.#changeEmitter.fire(this.getViews());
980
+ }
981
+ };
982
+
983
+ //#endregion
984
+ //#region src/base/glob.ts
985
+ /** Matches nothing. */
986
+ const MATCH_NONE = () => false;
987
+ /**
988
+ * Compiles glob patterns into a matcher.
989
+ *
990
+ * Patterns are matched against the path **without its leading slash**, so
991
+ * `src/**` and `**\/*.ts` behave the way authors expect. Matching is
992
+ * case-insensitive on the basename only where picomatch defaults apply; dotfiles
993
+ * are matched, unlike shell globbing, because hiding `.env` from a search would
994
+ * be surprising.
995
+ *
996
+ * An empty pattern list yields a matcher that never matches, so callers can
997
+ * treat "no excludes" as "exclude nothing" without a special case.
998
+ *
999
+ * @example
1000
+ * ```ts
1001
+ * const isExcluded = createMatcher(["**\/node_modules/**"]);
1002
+ * isExcluded("/node_modules/react/index"); // true
1003
+ * ```
1004
+ */
1005
+ function createMatcher(patterns) {
1006
+ if (patterns.length === 0) return MATCH_NONE;
1007
+ const isMatch = picomatch([...patterns], { dot: true });
1008
+ return (path) => {
1009
+ const relative = path.startsWith("/") ? path.slice(1) : path;
1010
+ if (relative.length === 0) return false;
1011
+ return isMatch(relative);
1012
+ };
1013
+ }
1014
+ /**
1015
+ * Compiles patterns into a matcher that *inverts* an empty pattern list.
1016
+ *
1017
+ * Used for include patterns, where "no filter" means "everything".
1018
+ */
1019
+ function createIncludeMatcher(patterns) {
1020
+ if (!patterns || patterns.length === 0) return () => true;
1021
+ return createMatcher(patterns);
1022
+ }
1023
+
1024
+ //#endregion
1025
+ //#region src/workspace/config.ts
1026
+ /**
1027
+ * Workspace-wide configuration.
1028
+ *
1029
+ * These defaults were previously hardcoded inside the file system layer, which
1030
+ * meant a caller could not search `node_modules` even when they wanted to.
1031
+ * Hoisting them here makes the policy explicit and overridable — per workspace
1032
+ * and per call.
1033
+ *
1034
+ * @packageDocumentation
1035
+ */
1036
+ /** Glob patterns excluded from directory listings and the file tree. */
1037
+ const DEFAULT_FILES_EXCLUDE = ["**/.git/**", "**/node_modules/**"];
1038
+ /** Glob patterns excluded from search. Broader than the tree exclusions. */
1039
+ const DEFAULT_SEARCH_EXCLUDE = [
1040
+ "**/.git/**",
1041
+ "**/node_modules/**",
1042
+ "**/dist/**",
1043
+ "**/build/**",
1044
+ "**/.next/**",
1045
+ "**/coverage/**",
1046
+ "**/*.lock",
1047
+ "**/pnpm-lock.yaml",
1048
+ "**/package-lock.json"
1049
+ ];
1050
+ /** Glob patterns whose changes are ignored by watchers. */
1051
+ const DEFAULT_WATCHER_EXCLUDE = [
1052
+ "**/.git/**",
1053
+ "**/node_modules/**",
1054
+ "**/.next/**"
1055
+ ];
1056
+ /**
1057
+ * Patterns omitted when snapshotting a workspace for storage.
1058
+ *
1059
+ * Everything here is either reproducible from `package.json` or machine-local.
1060
+ */
1061
+ const DEFAULT_SNAPSHOT_EXCLUDE = [
1062
+ "**/.git/**",
1063
+ "**/node_modules/**",
1064
+ "**/dist/**",
1065
+ "**/.next/**",
1066
+ "**/package-lock.json"
1067
+ ];
1068
+ /**
1069
+ * Builds a {@link WorkspaceConfig}, filling in defaults.
1070
+ *
1071
+ * @example
1072
+ * ```ts
1073
+ * // Search node_modules too, but keep every other default.
1074
+ * const config = createWorkspaceConfig({ searchExclude: ["**\/.git/**"] });
1075
+ * ```
1076
+ */
1077
+ function createWorkspaceConfig(overrides = {}) {
1078
+ return {
1079
+ rootPath: overrides.rootPath ?? "/",
1080
+ filesExclude: overrides.filesExclude ?? DEFAULT_FILES_EXCLUDE,
1081
+ searchExclude: overrides.searchExclude ?? DEFAULT_SEARCH_EXCLUDE,
1082
+ watcherExclude: overrides.watcherExclude ?? DEFAULT_WATCHER_EXCLUDE,
1083
+ snapshotExclude: overrides.snapshotExclude ?? DEFAULT_SNAPSHOT_EXCLUDE,
1084
+ maxSearchFileBytes: overrides.maxSearchFileBytes ?? 2 * 1024 * 1024
1085
+ };
1086
+ }
1087
+
1088
+ //#endregion
1089
+ //#region src/fs/file-system-service.ts
1090
+ const decoder = new TextDecoder();
1091
+ const encoder = new TextEncoder();
1092
+ /** How long to collect change events before delivering a batch. */
1093
+ const CHANGE_DEBOUNCE_MS = 20;
1094
+ /**
1095
+ * Reads and writes workspace files.
1096
+ *
1097
+ * @example
1098
+ * ```ts
1099
+ * const fs = new FileSystemService(new InMemoryFileSystemProvider());
1100
+ * await fs.writeTextFile("/src/a.ts", "export const a = 1;");
1101
+ * await fs.readTextFile("/src/a.ts");
1102
+ * ```
1103
+ */
1104
+ var FileSystemService = class {
1105
+ #provider;
1106
+ #config;
1107
+ #debounceMs;
1108
+ #store = new DisposableStore();
1109
+ #changeEmitter = new Emitter();
1110
+ #isWatcherExcluded;
1111
+ #isFileExcluded;
1112
+ #pending = [];
1113
+ #flushHandle;
1114
+ #rootWatch;
1115
+ constructor(provider, options = {}) {
1116
+ this.#provider = provider;
1117
+ this.#config = options.config ?? createWorkspaceConfig();
1118
+ this.#debounceMs = options.debounceMs ?? CHANGE_DEBOUNCE_MS;
1119
+ this.#isWatcherExcluded = createMatcher(this.#config.watcherExclude);
1120
+ this.#isFileExcluded = createMatcher(this.#config.filesExclude);
1121
+ this.#store.add(this.#changeEmitter);
1122
+ }
1123
+ /** The workspace policy in force. */
1124
+ get config() {
1125
+ return this.#config;
1126
+ }
1127
+ /**
1128
+ * Fires for every change in the workspace, after exclusion filtering and
1129
+ * debouncing.
1130
+ *
1131
+ * The underlying provider watch is established lazily on first subscription
1132
+ * and torn down when the last listener leaves, so an idle workspace does no
1133
+ * watching at all.
1134
+ */
1135
+ onDidChangeFile = (listener) => {
1136
+ this.#ensureRootWatch();
1137
+ const subscription = this.#changeEmitter.event(listener);
1138
+ return toDisposable(() => {
1139
+ subscription.dispose();
1140
+ if (this.#changeEmitter.listenerCount === 0) {
1141
+ this.#rootWatch?.dispose();
1142
+ this.#rootWatch = void 0;
1143
+ }
1144
+ });
1145
+ };
1146
+ async readFile(path) {
1147
+ return this.#provider.readFile(normalizePath(path));
1148
+ }
1149
+ async readTextFile(path) {
1150
+ return decoder.decode(await this.readFile(path));
1151
+ }
1152
+ async writeFile(path, data, options = {}) {
1153
+ const target = normalizePath(path);
1154
+ const exists = await this.exists(target);
1155
+ if (exists && options.overwrite === false) throw fileExists(target);
1156
+ if (!exists && options.create === false) throw fileNotFound(target);
1157
+ if (options.createParents !== false) await this.createDirectory(dirname(target));
1158
+ await this.#provider.writeFile(target, data);
1159
+ }
1160
+ async writeTextFile(path, content, options = {}) {
1161
+ await this.writeFile(path, encoder.encode(content), options);
1162
+ }
1163
+ async createFile(path, options = {}) {
1164
+ await this.writeFile(path, new Uint8Array(0), {
1165
+ overwrite: false,
1166
+ ...options
1167
+ });
1168
+ }
1169
+ /** Creates a directory and every missing ancestor. Succeeds if it exists. */
1170
+ async createDirectory(path) {
1171
+ const target = normalizePath(path);
1172
+ if (target === ROOT) return;
1173
+ const missing = [];
1174
+ for (let current = target; current !== ROOT; current = dirname(current)) {
1175
+ if (await this.exists(current)) break;
1176
+ missing.push(current);
1177
+ }
1178
+ for (let index = missing.length - 1; index >= 0; index -= 1) {
1179
+ const directory = missing[index];
1180
+ if (!directory) continue;
1181
+ await this.#provider.createDirectory(directory);
1182
+ }
1183
+ }
1184
+ async delete(path, options = {}) {
1185
+ await this.#provider.delete(normalizePath(path), { recursive: options.recursive ?? false });
1186
+ }
1187
+ async rename(from, to, options = {}) {
1188
+ const target = normalizePath(to);
1189
+ await this.createDirectory(dirname(target));
1190
+ await this.#provider.rename(normalizePath(from), target, { overwrite: options.overwrite ?? false });
1191
+ }
1192
+ async copy(from, to, options = {}) {
1193
+ const source = normalizePath(from);
1194
+ const target = normalizePath(to);
1195
+ if (!(options.overwrite ?? false) && await this.exists(target)) throw fileExists(target);
1196
+ if ((await this.stat(source)).type !== "directory") {
1197
+ await this.writeFile(target, await this.#provider.readFile(source), { overwrite: true });
1198
+ return;
1199
+ }
1200
+ await this.createDirectory(target);
1201
+ for (const entry of await this.#provider.readDirectory(source)) await this.copy(joinPath(source, entry.name), joinPath(target, entry.name), options);
1202
+ }
1203
+ async stat(path) {
1204
+ return this.#provider.stat(normalizePath(path));
1205
+ }
1206
+ async exists(path) {
1207
+ try {
1208
+ await this.#provider.stat(normalizePath(path));
1209
+ return true;
1210
+ } catch {
1211
+ return false;
1212
+ }
1213
+ }
1214
+ /**
1215
+ * Lists a directory, hiding entries matched by `filesExclude` and sorting
1216
+ * directories first, then by name.
1217
+ */
1218
+ async readDirectory(path) {
1219
+ return this.listDirectory(path, { applyExcludes: true });
1220
+ }
1221
+ /**
1222
+ * Lists a directory, optionally without applying `filesExclude`.
1223
+ *
1224
+ * `filesExclude` is a presentation policy — it governs what the file tree
1225
+ * shows. Search has its own `searchExclude`, so it must be able to walk the
1226
+ * unfiltered listing; otherwise a caller could never search `node_modules`
1227
+ * even by explicitly clearing the search exclusions.
1228
+ */
1229
+ async listDirectory(path, options) {
1230
+ const target = normalizePath(path);
1231
+ return sortedBy$1((await this.#provider.readDirectory(target)).map((entry) => ({
1232
+ name: entry.name,
1233
+ path: joinPath(target, entry.name),
1234
+ type: entry.type
1235
+ })).filter((entry) => !options.applyExcludes || !this.#isFileExcluded(entry.path)), compareEntries);
1236
+ }
1237
+ watch(path, listener, options = {}) {
1238
+ const target = normalizePath(path);
1239
+ const isExcluded = options.excludes ? createMatcher(options.excludes) : this.#isWatcherExcluded;
1240
+ return this.#provider.watch(target, { recursive: options.recursive ?? true }, (events) => {
1241
+ const relevant = events.filter((event) => !isExcluded(event.path));
1242
+ if (relevant.length > 0) listener(relevant);
1243
+ });
1244
+ }
1245
+ /** Stops watching and releases listeners. */
1246
+ dispose() {
1247
+ if (this.#flushHandle !== void 0) clearTimeout(this.#flushHandle);
1248
+ this.#rootWatch?.dispose();
1249
+ this.#rootWatch = void 0;
1250
+ this.#store.dispose();
1251
+ }
1252
+ /** Starts the workspace-wide watch, if it is not already running. */
1253
+ #ensureRootWatch() {
1254
+ if (this.#rootWatch) return;
1255
+ this.#rootWatch = this.#provider.watch(this.#config.rootPath, { recursive: true }, (events) => this.#queue(events));
1256
+ }
1257
+ /** Buffers events, filtering exclusions, and schedules a batched delivery. */
1258
+ #queue(events) {
1259
+ const relevant = events.filter((event) => !this.#isWatcherExcluded(event.path));
1260
+ if (relevant.length === 0) return;
1261
+ this.#pending.push(...relevant);
1262
+ if (this.#debounceMs <= 0) {
1263
+ this.#flush();
1264
+ return;
1265
+ }
1266
+ if (this.#flushHandle !== void 0) return;
1267
+ this.#flushHandle = setTimeout(() => this.#flush(), this.#debounceMs);
1268
+ }
1269
+ /** Delivers the buffered batch, collapsing repeats of the same path. */
1270
+ #flush() {
1271
+ if (this.#flushHandle !== void 0) {
1272
+ clearTimeout(this.#flushHandle);
1273
+ this.#flushHandle = void 0;
1274
+ }
1275
+ if (this.#pending.length === 0) return;
1276
+ const batch = dedupeChanges(this.#pending);
1277
+ this.#pending = [];
1278
+ this.#changeEmitter.fire(batch);
1279
+ }
1280
+ };
1281
+ /** Directories first, then case-insensitive name order. */
1282
+ function compareEntries(a, b) {
1283
+ if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
1284
+ return a.name.localeCompare(b.name, void 0, { sensitivity: "base" });
1285
+ }
1286
+ function sortedBy$1(values, compare) {
1287
+ const result = [];
1288
+ for (const value of values) {
1289
+ const index = result.findIndex((candidate) => compare(value, candidate) < 0);
1290
+ if (index === -1) result.push(value);
1291
+ else result.splice(index, 0, value);
1292
+ }
1293
+ return result;
1294
+ }
1295
+ /**
1296
+ * Collapses repeated events for one path, keeping the last.
1297
+ *
1298
+ * A create followed by a delete within the same tick means the file is gone;
1299
+ * reporting both would force every consumer to reason about ordering.
1300
+ */
1301
+ function dedupeChanges(events) {
1302
+ const latest = /* @__PURE__ */ new Map();
1303
+ for (const event of events) latest.set(event.path, event);
1304
+ return [...latest.values()];
1305
+ }
1306
+
1307
+ //#endregion
1308
+ //#region src/fs/in-memory-provider.ts
1309
+ /**
1310
+ * A complete file system held in a `Map`.
1311
+ *
1312
+ * @example
1313
+ * ```ts
1314
+ * const provider = new InMemoryFileSystemProvider();
1315
+ * await provider.createDirectory("/src");
1316
+ * await provider.writeFile("/src/a.ts", new TextEncoder().encode("export {};"));
1317
+ * ```
1318
+ */
1319
+ var InMemoryFileSystemProvider = class InMemoryFileSystemProvider {
1320
+ name = "in-memory";
1321
+ #nodes = /* @__PURE__ */ new Map();
1322
+ #watchers = /* @__PURE__ */ new Set();
1323
+ #now;
1324
+ constructor(options = {}) {
1325
+ this.#now = options.now ?? Date.now;
1326
+ this.#nodes.set(ROOT, {
1327
+ type: "directory",
1328
+ data: new Uint8Array(0),
1329
+ mtime: this.#now()
1330
+ });
1331
+ }
1332
+ /**
1333
+ * Seeds the provider from a plain object, for concise test fixtures.
1334
+ *
1335
+ * Parent directories are created automatically.
1336
+ *
1337
+ * @example
1338
+ * ```ts
1339
+ * const provider = InMemoryFileSystemProvider.from({
1340
+ * "/package.json": "{}",
1341
+ * "/src/index.ts": "export const a = 1;",
1342
+ * });
1343
+ * ```
1344
+ */
1345
+ static from(files, options = {}) {
1346
+ const provider = new InMemoryFileSystemProvider(options);
1347
+ const encoder$1 = new TextEncoder();
1348
+ for (const [path, contents] of Object.entries(files)) {
1349
+ provider.#ensureParents(path);
1350
+ provider.#nodes.set(path, {
1351
+ type: "file",
1352
+ data: encoder$1.encode(contents),
1353
+ mtime: provider.#now()
1354
+ });
1355
+ }
1356
+ return provider;
1357
+ }
1358
+ /** Every path currently stored, sorted. Intended for test assertions. */
1359
+ snapshot() {
1360
+ return sortedBy(Array.from(this.#nodes.keys()), (left, right) => left.localeCompare(right));
1361
+ }
1362
+ async readFile(path) {
1363
+ const node = this.#nodes.get(path);
1364
+ if (!node) throw fileNotFound(path);
1365
+ if (node.type === "directory") throw notAFile(path);
1366
+ return node.data.slice();
1367
+ }
1368
+ async writeFile(path, data) {
1369
+ const existing = this.#nodes.get(path);
1370
+ if (existing?.type === "directory") throw notAFile(path);
1371
+ const parent = dirname(path);
1372
+ const parentNode = this.#nodes.get(parent);
1373
+ if (!parentNode) throw fileNotFound(parent);
1374
+ if (parentNode.type !== "directory") throw notADirectory(parent);
1375
+ this.#nodes.set(path, {
1376
+ type: "file",
1377
+ data: data.slice(),
1378
+ mtime: this.#now()
1379
+ });
1380
+ this.#emit([{
1381
+ type: existing ? "changed" : "created",
1382
+ path
1383
+ }]);
1384
+ }
1385
+ async createDirectory(path) {
1386
+ const existing = this.#nodes.get(path);
1387
+ if (existing) {
1388
+ if (existing.type === "directory") return;
1389
+ throw fileExists(path);
1390
+ }
1391
+ const parent = dirname(path);
1392
+ const parentNode = this.#nodes.get(parent);
1393
+ if (!parentNode) throw fileNotFound(parent);
1394
+ if (parentNode.type !== "directory") throw notADirectory(parent);
1395
+ this.#nodes.set(path, {
1396
+ type: "directory",
1397
+ data: new Uint8Array(0),
1398
+ mtime: this.#now()
1399
+ });
1400
+ this.#emit([{
1401
+ type: "created",
1402
+ path
1403
+ }]);
1404
+ }
1405
+ async delete(path, options) {
1406
+ const node = this.#nodes.get(path);
1407
+ if (!node) throw fileNotFound(path);
1408
+ if (node.type === "directory") {
1409
+ const descendants = this.#descendantsOf(path);
1410
+ if (descendants.length > 0 && !options.recursive) throw directoryNotEmpty(path);
1411
+ const removed = [];
1412
+ for (const descendant of descendants) {
1413
+ this.#nodes.delete(descendant);
1414
+ removed.push({
1415
+ type: "deleted",
1416
+ path: descendant
1417
+ });
1418
+ }
1419
+ this.#nodes.delete(path);
1420
+ removed.push({
1421
+ type: "deleted",
1422
+ path
1423
+ });
1424
+ this.#emit(removed);
1425
+ return;
1426
+ }
1427
+ this.#nodes.delete(path);
1428
+ this.#emit([{
1429
+ type: "deleted",
1430
+ path
1431
+ }]);
1432
+ }
1433
+ async rename(from, to, options) {
1434
+ if (!this.#nodes.get(from)) throw fileNotFound(from);
1435
+ const destination = this.#nodes.get(to);
1436
+ if (destination && !options.overwrite) throw fileExists(to);
1437
+ const parent = dirname(to);
1438
+ const parentNode = this.#nodes.get(parent);
1439
+ if (!parentNode) throw fileNotFound(parent);
1440
+ if (parentNode.type !== "directory") throw notADirectory(parent);
1441
+ const events = [];
1442
+ if (destination) for (const descendant of this.#descendantsOf(to)) {
1443
+ this.#nodes.delete(descendant);
1444
+ events.push({
1445
+ type: "deleted",
1446
+ path: descendant
1447
+ });
1448
+ }
1449
+ const moves = [[from, to]];
1450
+ for (const descendant of this.#descendantsOf(from)) moves.push([descendant, to + descendant.slice(from.length)]);
1451
+ for (const [source, target] of moves) {
1452
+ const moved = this.#nodes.get(source);
1453
+ if (!moved) continue;
1454
+ this.#nodes.delete(source);
1455
+ this.#nodes.set(target, {
1456
+ ...moved,
1457
+ mtime: this.#now()
1458
+ });
1459
+ events.push({
1460
+ type: "deleted",
1461
+ path: source
1462
+ });
1463
+ events.push({
1464
+ type: "created",
1465
+ path: target
1466
+ });
1467
+ }
1468
+ this.#emit(events);
1469
+ }
1470
+ async stat(path) {
1471
+ const node = this.#nodes.get(path);
1472
+ if (!node) throw fileNotFound(path);
1473
+ return {
1474
+ type: node.type,
1475
+ size: node.type === "directory" ? 0 : node.data.byteLength,
1476
+ mtime: node.mtime
1477
+ };
1478
+ }
1479
+ async readDirectory(path) {
1480
+ const node = this.#nodes.get(path);
1481
+ if (!node) throw fileNotFound(path);
1482
+ if (node.type !== "directory") throw notADirectory(path);
1483
+ const prefix = path === ROOT ? ROOT : `${path}/`;
1484
+ const entries = [];
1485
+ for (const [candidate, child] of this.#nodes) {
1486
+ if (candidate === path) continue;
1487
+ if (!candidate.startsWith(prefix)) continue;
1488
+ if (candidate.slice(prefix.length).includes("/")) continue;
1489
+ entries.push({
1490
+ name: basename(candidate),
1491
+ type: child.type
1492
+ });
1493
+ }
1494
+ return entries;
1495
+ }
1496
+ watch(path, options, listener) {
1497
+ const watcher = {
1498
+ path,
1499
+ recursive: options.recursive,
1500
+ listener
1501
+ };
1502
+ this.#watchers.add(watcher);
1503
+ return toDisposable(() => {
1504
+ this.#watchers.delete(watcher);
1505
+ });
1506
+ }
1507
+ /** Creates any missing ancestor directories of `path`. */
1508
+ #ensureParents(path) {
1509
+ const parent = dirname(path);
1510
+ if (parent === ROOT || this.#nodes.has(parent)) return;
1511
+ this.#ensureParents(parent);
1512
+ this.#nodes.set(parent, {
1513
+ type: "directory",
1514
+ data: new Uint8Array(0),
1515
+ mtime: this.#now()
1516
+ });
1517
+ }
1518
+ /** Every path strictly beneath `path`, deepest first so deletion is safe. */
1519
+ #descendantsOf(path) {
1520
+ const prefix = path === ROOT ? ROOT : `${path}/`;
1521
+ return sortedBy(Array.from(this.#nodes.keys()).filter((candidate) => candidate !== path && candidate.startsWith(prefix)), (left, right) => right.length - left.length);
1522
+ }
1523
+ /** Delivers events to watchers whose scope covers them. */
1524
+ #emit(events) {
1525
+ if (events.length === 0) return;
1526
+ for (const watcher of this.#watchers) {
1527
+ const relevant = events.filter((event) => watcher.recursive ? isSubPath(watcher.path, event.path) : dirname(event.path) === watcher.path);
1528
+ if (relevant.length > 0) watcher.listener(relevant);
1529
+ }
1530
+ }
1531
+ };
1532
+ function sortedBy(values, compare) {
1533
+ const result = [];
1534
+ for (const value of values) {
1535
+ const index = result.findIndex((candidate) => compare(value, candidate) < 0);
1536
+ if (index === -1) result.push(value);
1537
+ else result.splice(index, 0, value);
1538
+ }
1539
+ return result;
1540
+ }
1541
+
1542
+ //#endregion
1543
+ //#region src/process/fake-spawner.ts
1544
+ /** Exit code reported for a killed process. */
1545
+ const KILLED_EXIT_CODE = 137;
1546
+ /**
1547
+ * A spawner that replays scripted behaviour.
1548
+ *
1549
+ * @example
1550
+ * ```ts
1551
+ * const spawner = new FakeProcessSpawner();
1552
+ * spawner.register("node", { output: ["v22.0.0\n"] });
1553
+ * const { output } = await new ProcessService(spawner).exec("node", ["-v"]);
1554
+ * ```
1555
+ */
1556
+ var FakeProcessSpawner = class {
1557
+ name = "fake";
1558
+ #behaviours = /* @__PURE__ */ new Map();
1559
+ #spawns = [];
1560
+ #fallback = {
1561
+ output: [],
1562
+ exitCode: 0
1563
+ };
1564
+ /** Every spawn that has been requested, in order. */
1565
+ get spawns() {
1566
+ return this.#spawns;
1567
+ }
1568
+ /** Scripts a command, keyed by executable name. */
1569
+ register(command, behaviour) {
1570
+ this.#behaviours.set(command, behaviour);
1571
+ return this;
1572
+ }
1573
+ /** Sets the behaviour used for unregistered commands. */
1574
+ setFallback(behaviour) {
1575
+ this.#fallback = behaviour;
1576
+ return this;
1577
+ }
1578
+ async spawn(command, args, options) {
1579
+ this.#spawns.push({
1580
+ command,
1581
+ args: [...args],
1582
+ options
1583
+ });
1584
+ const behaviour = this.#behaviours.get(command) ?? this.#fallback;
1585
+ let killed = false;
1586
+ let onKilled = () => {};
1587
+ const killedSignal = new Promise((resolve) => {
1588
+ onKilled = resolve;
1589
+ });
1590
+ const chunks = behaviour.output ?? [];
1591
+ const output = new ReadableStream({ async start(controller) {
1592
+ for (const chunk of chunks) {
1593
+ if (killed) break;
1594
+ controller.enqueue(chunk);
1595
+ }
1596
+ if (behaviour.neverExits && !killed) await killedSignal;
1597
+ controller.close();
1598
+ } });
1599
+ const exit = (async () => {
1600
+ if (behaviour.neverExits) {
1601
+ await killedSignal;
1602
+ return KILLED_EXIT_CODE;
1603
+ }
1604
+ if (behaviour.delayMs) await Promise.race([new Promise((resolve) => setTimeout(resolve, behaviour.delayMs)), killedSignal]);
1605
+ return killed ? KILLED_EXIT_CODE : behaviour.exitCode ?? 0;
1606
+ })();
1607
+ return {
1608
+ output,
1609
+ input: new WritableStream(),
1610
+ exit,
1611
+ kill() {
1612
+ if (killed) return;
1613
+ killed = true;
1614
+ onKilled();
1615
+ },
1616
+ resize() {}
1617
+ };
1618
+ }
1619
+ };
1620
+
1621
+ //#endregion
1622
+ //#region src/process/process-service.ts
1623
+ /** Default wall-clock limit for {@link ProcessService.exec}. */
1624
+ const DEFAULT_EXEC_TIMEOUT_MS = 6e4;
1625
+ /** Default output cap for {@link ProcessService.exec}, in bytes. */
1626
+ const DEFAULT_MAX_OUTPUT_BYTES = 1024 * 1024;
1627
+ /**
1628
+ * Spawns and supervises processes.
1629
+ *
1630
+ * @example
1631
+ * ```ts
1632
+ * const processes = new ProcessService(spawner);
1633
+ * const { exitCode, output } = await processes.exec("node", ["-v"]);
1634
+ * ```
1635
+ */
1636
+ var ProcessService = class {
1637
+ #spawner;
1638
+ #now;
1639
+ #tracked = /* @__PURE__ */ new Map();
1640
+ #store = new DisposableStore();
1641
+ #startEmitter = new Emitter();
1642
+ #exitEmitter = new Emitter();
1643
+ #nextId = 1;
1644
+ constructor(spawner, options = {}) {
1645
+ this.#spawner = spawner;
1646
+ this.#now = options.now ?? Date.now;
1647
+ this.#store.add(this.#startEmitter);
1648
+ this.#store.add(this.#exitEmitter);
1649
+ }
1650
+ /** Fires whenever a process starts. */
1651
+ onDidStartProcess = (listener) => this.#startEmitter.event(listener);
1652
+ /** Fires whenever a process exits, for any reason. */
1653
+ onDidExitProcess = (listener) => this.#exitEmitter.event(listener);
1654
+ async spawn(command, args = [], options = {}) {
1655
+ const process = await this.#spawner.spawn(command, args, options);
1656
+ const id = `p${this.#nextId++}`;
1657
+ const tracked = {
1658
+ info: {
1659
+ id,
1660
+ command,
1661
+ args: [...args],
1662
+ startedAt: this.#now(),
1663
+ status: "running"
1664
+ },
1665
+ process
1666
+ };
1667
+ this.#tracked.set(id, tracked);
1668
+ this.#startEmitter.fire({ ...tracked.info });
1669
+ process.exit.then((exitCode) => this.#settle(id, exitCode), () => this.#settle(id, -1));
1670
+ return this.#toHandle(tracked);
1671
+ }
1672
+ async exec(command, args = [], options = {}) {
1673
+ const timeout = options.timeout ?? DEFAULT_EXEC_TIMEOUT_MS;
1674
+ const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
1675
+ const startedAt = this.#now();
1676
+ const handle = await this.spawn(command, args, options);
1677
+ let output = "";
1678
+ let bytes = 0;
1679
+ let truncated = false;
1680
+ const encoder$1 = new TextEncoder();
1681
+ let timedOut = false;
1682
+ const timer = timeout === false ? void 0 : setTimeout(() => {
1683
+ timedOut = true;
1684
+ handle.kill();
1685
+ }, timeout);
1686
+ try {
1687
+ const reader = handle.output.getReader();
1688
+ try {
1689
+ for (;;) {
1690
+ const { done, value } = await reader.read();
1691
+ if (done) break;
1692
+ if (value === void 0) continue;
1693
+ output += value;
1694
+ if (maxOutputBytes !== false) {
1695
+ bytes += encoder$1.encode(value).length;
1696
+ if (bytes > maxOutputBytes) {
1697
+ truncated = true;
1698
+ handle.kill();
1699
+ break;
1700
+ }
1701
+ }
1702
+ }
1703
+ } finally {
1704
+ reader.releaseLock();
1705
+ }
1706
+ const exitCode = await handle.exit;
1707
+ if (timedOut) throw createError("ProcessTimeout", `Command "${describe(command, args)}" exceeded its ${String(timeout)}ms timeout and was killed`, { partialOutput: output });
1708
+ if (truncated) throw createError("OutputLimitExceeded", `Command "${describe(command, args)}" exceeded its ${String(maxOutputBytes)}-byte output limit and was killed`, { partialOutput: output });
1709
+ return {
1710
+ exitCode,
1711
+ output,
1712
+ truncated: false,
1713
+ durationMs: this.#now() - startedAt
1714
+ };
1715
+ } finally {
1716
+ if (timer !== void 0) clearTimeout(timer);
1717
+ }
1718
+ }
1719
+ async list() {
1720
+ return Array.from(this.#tracked.values(), ({ info }) => ({
1721
+ id: info.id,
1722
+ command: info.command,
1723
+ args: info.args,
1724
+ startedAt: info.startedAt,
1725
+ status: info.status,
1726
+ exitCode: info.exitCode
1727
+ }));
1728
+ }
1729
+ async kill(id) {
1730
+ const tracked = this.#tracked.get(id);
1731
+ if (!tracked || tracked.info.status !== "running") return false;
1732
+ tracked.info.status = "killed";
1733
+ tracked.process.kill();
1734
+ return true;
1735
+ }
1736
+ /** Kills every running process and releases listeners. */
1737
+ dispose() {
1738
+ for (const tracked of this.#tracked.values()) if (tracked.info.status === "running") tracked.process.kill();
1739
+ this.#tracked.clear();
1740
+ this.#store.dispose();
1741
+ }
1742
+ /** Records the exit and notifies listeners, at most once per process. */
1743
+ #settle(id, exitCode) {
1744
+ const tracked = this.#tracked.get(id);
1745
+ if (!tracked || tracked.info.status !== "running") {
1746
+ if (tracked && tracked.info.exitCode === void 0) {
1747
+ tracked.info.exitCode = exitCode;
1748
+ this.#exitEmitter.fire({ ...tracked.info });
1749
+ }
1750
+ return;
1751
+ }
1752
+ tracked.info.status = "exited";
1753
+ tracked.info.exitCode = exitCode;
1754
+ this.#exitEmitter.fire({ ...tracked.info });
1755
+ }
1756
+ /** Projects internal bookkeeping into the public handle shape. */
1757
+ #toHandle(tracked) {
1758
+ return {
1759
+ get id() {
1760
+ return tracked.info.id;
1761
+ },
1762
+ get command() {
1763
+ return tracked.info.command;
1764
+ },
1765
+ get args() {
1766
+ return tracked.info.args;
1767
+ },
1768
+ get startedAt() {
1769
+ return tracked.info.startedAt;
1770
+ },
1771
+ get status() {
1772
+ return tracked.info.status;
1773
+ },
1774
+ get exitCode() {
1775
+ return tracked.info.exitCode;
1776
+ },
1777
+ output: tracked.process.output,
1778
+ input: tracked.process.input,
1779
+ exit: tracked.process.exit,
1780
+ kill: () => {
1781
+ this.kill(tracked.info.id);
1782
+ },
1783
+ resize: (dimensions) => {
1784
+ tracked.process.resize(dimensions);
1785
+ }
1786
+ };
1787
+ }
1788
+ };
1789
+ /** Renders a command and its arguments for error messages. */
1790
+ function describe(command, args) {
1791
+ return [command, ...args].join(" ");
1792
+ }
1793
+
1794
+ //#endregion
1795
+ //#region src/search/search-service.ts
1796
+ /** Default cap on results, matching the SDK documentation. */
1797
+ const DEFAULT_MAX_RESULTS = 1e3;
1798
+ /** Characters with special meaning in a regular expression. */
1799
+ const REGEX_SPECIAL = /[.*+?^${}()|[\]\\]/g;
1800
+ /** Escapes a literal string for safe use inside a regular expression. */
1801
+ function escapeRegExp(value) {
1802
+ return value.replace(REGEX_SPECIAL, "\\$&");
1803
+ }
1804
+ /**
1805
+ * Builds the matcher used to scan file contents.
1806
+ *
1807
+ * Always global, so every match on a line is found rather than just the first.
1808
+ *
1809
+ * @throws A `TypeError` when `isRegex` is set and the pattern is malformed.
1810
+ */
1811
+ function buildQueryRegExp(query, options) {
1812
+ const source = options.isRegex ? query : escapeRegExp(query);
1813
+ const pattern = options.wholeWord ? `\\b(?:${source})\\b` : source;
1814
+ const flags = options.caseSensitive ? "g" : "gi";
1815
+ return new RegExp(pattern, flags);
1816
+ }
1817
+ /**
1818
+ * Finds files and searches their contents.
1819
+ *
1820
+ * @example
1821
+ * ```ts
1822
+ * const search = new SearchService(fs);
1823
+ * const paths = await search.findFiles("src/**\/*.ts");
1824
+ * const { matches } = await search.findInFiles("TODO");
1825
+ * ```
1826
+ */
1827
+ var SearchService = class {
1828
+ #fs;
1829
+ constructor(fileSystem) {
1830
+ this.#fs = fileSystem;
1831
+ }
1832
+ async findFiles(include, options = {}) {
1833
+ const isIncluded = createMatcher([include]);
1834
+ const isExcluded = createMatcher(options.exclude ?? this.#fs.config.searchExclude);
1835
+ const maxResults = options.maxResults ?? DEFAULT_MAX_RESULTS;
1836
+ const found = [];
1837
+ await this.#walk(this.#fs.config.rootPath, isExcluded, options, (path) => {
1838
+ if (!isIncluded(path)) return true;
1839
+ found.push(path);
1840
+ return found.length < maxResults;
1841
+ });
1842
+ return found;
1843
+ }
1844
+ async findInFiles(query, options = {}) {
1845
+ if (query.length === 0) return {
1846
+ matches: [],
1847
+ limitHit: false
1848
+ };
1849
+ const isIncluded = createIncludeMatcher(options.include);
1850
+ const isExcluded = createMatcher(options.exclude ?? this.#fs.config.searchExclude);
1851
+ const maxResults = options.maxResults ?? DEFAULT_MAX_RESULTS;
1852
+ const pattern = buildQueryRegExp(query, options);
1853
+ const matches = [];
1854
+ let limitHit = false;
1855
+ await this.#walk(this.#fs.config.rootPath, isExcluded, options, async (path) => {
1856
+ if (!isIncluded(path)) return true;
1857
+ const contents = await this.#readSearchable(path);
1858
+ if (contents === void 0) return true;
1859
+ for (const match of matchesIn(path, contents, pattern)) {
1860
+ matches.push(match);
1861
+ if (matches.length >= maxResults) {
1862
+ limitHit = true;
1863
+ return false;
1864
+ }
1865
+ }
1866
+ return true;
1867
+ });
1868
+ return {
1869
+ matches,
1870
+ limitHit
1871
+ };
1872
+ }
1873
+ async replaceInFiles(query, replacement, options = {}) {
1874
+ const { matches } = await this.findInFiles(query, options);
1875
+ const paths = [...new Set(matches.map((match) => match.path))];
1876
+ const pattern = buildQueryRegExp(query, options);
1877
+ for (const path of paths) {
1878
+ const contents = await this.#fs.readTextFile(path);
1879
+ pattern.lastIndex = 0;
1880
+ await this.#fs.writeTextFile(path, contents.replace(pattern, replacement));
1881
+ }
1882
+ return paths.length;
1883
+ }
1884
+ /**
1885
+ * Reads a file for searching, skipping anything unsuitable.
1886
+ *
1887
+ * Returns `undefined` for files that are too large or that appear to be
1888
+ * binary, so the caller can move on without special-casing either.
1889
+ */
1890
+ async #readSearchable(path) {
1891
+ if ((await this.#fs.stat(path)).size > this.#fs.config.maxSearchFileBytes) return void 0;
1892
+ const contents = await this.#fs.readTextFile(path);
1893
+ if (contents.slice(0, 8e3).includes("\0")) return void 0;
1894
+ return contents;
1895
+ }
1896
+ /**
1897
+ * Depth-first walk of every file beneath `directory`.
1898
+ *
1899
+ * `visit` returns `false` to stop the walk early. Directories matched by
1900
+ * `isExcluded` are skipped whole, so excluding `node_modules` costs one check
1901
+ * rather than a traversal of everything inside it.
1902
+ */
1903
+ async #walk(directory, isExcluded, options, visit) {
1904
+ if (options.token?.isCancellationRequested) return false;
1905
+ let entries;
1906
+ try {
1907
+ entries = await this.#fs.listDirectory(directory, { applyExcludes: false });
1908
+ } catch {
1909
+ return true;
1910
+ }
1911
+ for (const entry of entries) {
1912
+ if (options.token?.isCancellationRequested) return false;
1913
+ if (isExcluded(entry.path)) continue;
1914
+ if (entry.type === "directory") {
1915
+ if (!await this.#walk(entry.path, isExcluded, options, visit)) return false;
1916
+ continue;
1917
+ }
1918
+ if (!await visit(entry.path)) return false;
1919
+ }
1920
+ return true;
1921
+ }
1922
+ };
1923
+ /** Yields every match of `pattern` in `contents`, with line and column. */
1924
+ function* matchesIn(path, contents, pattern) {
1925
+ const lines = contents.split("\n");
1926
+ for (const [index, line] of lines.entries()) {
1927
+ pattern.lastIndex = 0;
1928
+ let match;
1929
+ while ((match = pattern.exec(line)) !== null) {
1930
+ yield {
1931
+ path,
1932
+ line: index + 1,
1933
+ column: match.index,
1934
+ length: match[0].length,
1935
+ preview: line
1936
+ };
1937
+ if (match[0].length === 0) pattern.lastIndex += 1;
1938
+ }
1939
+ }
1940
+ }
1941
+
1942
+ //#endregion
1943
+ //#region src/runtime/runtime.ts
1944
+ const unavailableWorkspace = {
1945
+ rootPath: "/",
1946
+ async applyEdit() {
1947
+ throw new Error("Workspace API is not configured");
1948
+ },
1949
+ async revertEdit() {
1950
+ throw new Error("Workspace API is not configured");
1951
+ },
1952
+ async editHistory() {
1953
+ return [];
1954
+ },
1955
+ onDidApplyEdit: () => ({ dispose() {} })
1956
+ };
1957
+ const unavailableTerminal = {
1958
+ async create() {
1959
+ throw new Error("Terminal API is not configured");
1960
+ },
1961
+ terminals: [],
1962
+ onDidOpenTerminal: () => ({ dispose() {} }),
1963
+ onDidCloseTerminal: () => ({ dispose() {} })
1964
+ };
1965
+ /** Creates an environment-agnostic Hudhod runtime from host-provided adapters. */
1966
+ function createHudhodRuntime(options) {
1967
+ const fs = new FileSystemService(options.fileSystemProvider);
1968
+ const process = new ProcessService(options.processSpawner);
1969
+ const commands = new CommandRegistry();
1970
+ const keybindings = new KeybindingRegistry(options.platform ?? "other");
1971
+ const panels = new PanelRegistry();
1972
+ const views = new ViewRegistry();
1973
+ const window = new WindowService(options.windowUiProvider);
1974
+ const search = new SearchService(fs);
1975
+ const diff = new DiffService(fs);
1976
+ const api = {
1977
+ version: options.version ?? "0.1.0",
1978
+ fs,
1979
+ workspace: options.workspace ?? unavailableWorkspace,
1980
+ search,
1981
+ diff,
1982
+ process,
1983
+ terminal: options.terminal ?? unavailableTerminal,
1984
+ commands,
1985
+ keybindings,
1986
+ window
1987
+ };
1988
+ const extensions = new InProcessExtensionHost(api, {
1989
+ panels,
1990
+ views
1991
+ });
1992
+ let disposed = false;
1993
+ return {
1994
+ fs,
1995
+ search,
1996
+ diff,
1997
+ process,
1998
+ commands,
1999
+ keybindings,
2000
+ panels,
2001
+ views,
2002
+ window,
2003
+ extensions,
2004
+ api,
2005
+ dispose() {
2006
+ if (disposed) return;
2007
+ disposed = true;
2008
+ extensions.dispose();
2009
+ window.dispose();
2010
+ views.dispose();
2011
+ panels.dispose();
2012
+ keybindings.dispose();
2013
+ commands.dispose();
2014
+ process.dispose();
2015
+ fs.dispose();
2016
+ }
2017
+ };
2018
+ }
2019
+
2020
+ //#endregion
2021
+ //#region src/services/service-registry.ts
2022
+ /** Creates a typed service identifier. */
2023
+ function createServiceIdentifier(description) {
2024
+ return {
2025
+ description,
2026
+ key: Symbol(description)
2027
+ };
2028
+ }
2029
+ /**
2030
+ * Owns workspace-scoped services.
2031
+ *
2032
+ * @example
2033
+ * ```ts
2034
+ * const fsId = createServiceIdentifier<FileSystemService>("fs");
2035
+ * const services = new ServiceRegistry();
2036
+ * services.register(fsId, () => new FileSystemService(provider));
2037
+ * const fs = services.get(fsId);
2038
+ * ```
2039
+ */
2040
+ var ServiceRegistry = class {
2041
+ #factories = /* @__PURE__ */ new Map();
2042
+ #instances = /* @__PURE__ */ new Map();
2043
+ #disposables = new DisposableStore();
2044
+ #disposed = false;
2045
+ /** Registers a lazy factory for a service. */
2046
+ register(identifier, factory) {
2047
+ this.#assertActive();
2048
+ if (this.#factories.has(identifier.key)) throw new Error(`Service already registered: ${identifier.description}`);
2049
+ this.#factories.set(identifier.key, factory);
2050
+ }
2051
+ /** Retrieves and lazily creates a registered service. */
2052
+ get(identifier) {
2053
+ this.#assertActive();
2054
+ const existing = this.#instances.get(identifier.key);
2055
+ if (existing !== void 0) return existing;
2056
+ const factory = this.#factories.get(identifier.key);
2057
+ if (!factory) throw new Error(`Service not registered: ${identifier.description}`);
2058
+ const instance = factory(this);
2059
+ this.#instances.set(identifier.key, instance);
2060
+ if (isDisposable(instance)) this.#disposables.add(instance);
2061
+ return instance;
2062
+ }
2063
+ /** Whether a service factory is registered. */
2064
+ has(identifier) {
2065
+ return this.#factories.has(identifier.key);
2066
+ }
2067
+ /** Releases every created disposable service in reverse creation order. */
2068
+ dispose() {
2069
+ if (this.#disposed) return;
2070
+ this.#disposed = true;
2071
+ this.#instances.clear();
2072
+ this.#factories.clear();
2073
+ this.#disposables.dispose();
2074
+ }
2075
+ #assertActive() {
2076
+ if (this.#disposed) throw new Error("Service registry is disposed");
2077
+ }
2078
+ };
2079
+ function isDisposable(value) {
2080
+ return typeof value === "object" && value !== null && "dispose" in value && typeof value.dispose === "function";
2081
+ }
2082
+
2083
+ //#endregion
2084
+ export { CancellationTokenNone, CancellationTokenSource, CommandRegistry, DEFAULT_EXEC_TIMEOUT_MS, DEFAULT_FILES_EXCLUDE, DEFAULT_MAX_OUTPUT_BYTES, DEFAULT_SEARCH_EXCLUDE, DEFAULT_SNAPSHOT_EXCLUDE, DEFAULT_WATCHER_EXCLUDE, DiffService, DisposableStore, Emitter, FakeProcessSpawner, FileSystemService, InMemoryFileSystemProvider, InProcessExtensionHost, KeybindingRegistry, NO_OP_DISPOSABLE, PanelRegistry, ProcessService, ROOT, SearchService, ServiceRegistry, ViewRegistry, WindowService, basename, createError, createHudhodRuntime, createServiceIdentifier, createWorkspaceConfig, directoryNotEmpty, dirname, extensionManifestSchema, extname, fileExists, fileNotFound, invalidPath, isSubPath, joinPath, keybindingFromEvent, keybindingToString, normalizePath, notADirectory, notAFile, parseExtensionManifest, parseKeybinding, pathSegments, relativePath, toDisposable, tokenFromAbortSignal };