@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/LICENSE +21 -0
- package/README.md +165 -0
- package/dist/index.d.ts +1017 -0
- package/dist/index.js +2084 -0
- package/dist/paths-B3gr2EuI.js +282 -0
- package/dist/spawner-CqGITKbd.d.ts +150 -0
- package/dist/webcontainer/index.d.ts +67 -0
- package/dist/webcontainer/index.js +200 -0
- package/package.json +54 -0
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 };
|