dsh-plugin-wsl-env 0.7.5 → 0.9.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.
Files changed (92) hide show
  1. package/CHANGELOG.md +345 -8
  2. package/CONTRIBUTING.md +221 -68
  3. package/README.md +50 -35
  4. package/SECURITY.md +18 -2
  5. package/SUPPORT.md +23 -7
  6. package/cordis.patch.yml +9 -0
  7. package/docs/ARCHITECTURE.md +200 -57
  8. package/docs/CONFIGURATION.md +180 -16
  9. package/docs/LIMITATIONS.md +91 -33
  10. package/docs/PARITY.md +66 -38
  11. package/docs/PEER-PARITY.md +95 -32
  12. package/docs/README.zh.md +49 -31
  13. package/docs/UPSTREAM-FSIO-EXPORT.md +159 -50
  14. package/docs/UPSTREAM-TERMINAL-TITLE.md +49 -14
  15. package/lib/agent-confined.d.ts +42 -0
  16. package/lib/agent-confined.js +25 -41
  17. package/lib/agent-errors.d.ts +31 -0
  18. package/lib/agent-errors.js +19 -16
  19. package/lib/agent-exec.d.ts +159 -0
  20. package/lib/agent-exec.js +151 -165
  21. package/lib/agent-protocol.d.ts +192 -0
  22. package/lib/agent-protocol.js +76 -100
  23. package/lib/agent-shared.d.ts +27 -0
  24. package/lib/agent-shared.js +12 -12
  25. package/lib/agent.d.ts +353 -0
  26. package/lib/agent.js +536 -552
  27. package/lib/auto-preset.d.ts +110 -0
  28. package/lib/auto-preset.js +157 -141
  29. package/lib/bwrap.d.ts +90 -0
  30. package/lib/bwrap.js +18 -18
  31. package/lib/file-reference-wsl.d.ts +160 -0
  32. package/lib/file-reference-wsl.js +192 -173
  33. package/lib/fs-decisions.d.ts +107 -0
  34. package/lib/fs-decisions.js +64 -60
  35. package/lib/fs-routing.d.ts +182 -0
  36. package/lib/fs-routing.js +173 -116
  37. package/lib/fs-substrate.d.ts +258 -0
  38. package/lib/fs-substrate.js +248 -248
  39. package/lib/fs.d.ts +10 -0
  40. package/lib/fs.js +5 -0
  41. package/lib/fsio-agent.d.ts +307 -0
  42. package/lib/fsio-agent.js +414 -410
  43. package/lib/fsio-text.d.ts +115 -0
  44. package/lib/fsio-text.js +104 -84
  45. package/lib/git-route.d.ts +100 -0
  46. package/lib/git-route.js +46 -47
  47. package/lib/index.d.ts +1003 -0
  48. package/lib/index.js +1106 -1003
  49. package/lib/listing.d.ts +114 -0
  50. package/lib/listing.js +68 -55
  51. package/lib/paths.d.ts +182 -0
  52. package/lib/paths.js +134 -109
  53. package/lib/picker.d.ts +266 -0
  54. package/lib/picker.js +371 -351
  55. package/lib/ports.d.ts +44 -0
  56. package/lib/ports.js +39 -29
  57. package/lib/preflight.d.ts +91 -0
  58. package/lib/preflight.js +94 -0
  59. package/lib/preset-choice.d.ts +33 -0
  60. package/lib/preset-choice.js +15 -7
  61. package/lib/probe-cache.d.ts +51 -0
  62. package/lib/probe-cache.js +43 -29
  63. package/lib/sandbox-core.d.ts +149 -0
  64. package/lib/sandbox-core.js +239 -87
  65. package/lib/sandbox.d.ts +48 -0
  66. package/lib/sandbox.js +4 -4
  67. package/lib/search-exec.d.ts +96 -0
  68. package/lib/search-exec.js +52 -79
  69. package/lib/search-route.d.ts +75 -0
  70. package/lib/search-route.js +30 -31
  71. package/lib/shell-env.d.ts +88 -0
  72. package/lib/shell-env.js +78 -74
  73. package/lib/shell.d.ts +10 -0
  74. package/lib/shell.js +5 -0
  75. package/lib/subprocess.d.ts +313 -0
  76. package/lib/subprocess.js +267 -262
  77. package/lib/terminal-activity.d.ts +98 -0
  78. package/lib/terminal-activity.js +29 -39
  79. package/lib/terminal-route.d.ts +27 -0
  80. package/lib/terminal-route.js +16 -8
  81. package/lib/watcher.d.ts +77 -0
  82. package/lib/watcher.js +120 -126
  83. package/lib/workspace-files-route.d.ts +132 -0
  84. package/lib/workspace-files-route.js +73 -81
  85. package/lib/workspace-files-wsl.d.ts +257 -0
  86. package/lib/workspace-files-wsl.js +311 -251
  87. package/lib/wsl.d.ts +266 -0
  88. package/lib/wsl.js +150 -155
  89. package/locale/preflight/en.json +6 -0
  90. package/locale/preflight/zh.json +6 -0
  91. package/package.json +78 -22
  92. package/scripts/diagnose.sh +92 -0
package/lib/picker.js CHANGED
@@ -41,9 +41,12 @@
41
41
  * `@deepseek-ai/dsh-client-ui-directory-picker-browse` dialog: no client code,
42
42
  * no wire change, no asar patch.
43
43
  *
44
+ * This is a TypeScript source built to `lib/picker.js`; edit THIS file and run
45
+ * `pnpm run build` — the artifact under `lib/` is generated, and `pnpm test`
46
+ * fails when it drifts.
47
+ *
44
48
  * @module dsh-plugin-wsl/picker
45
49
  */
46
-
47
50
  import { mkdir, opendir, stat } from "node:fs/promises";
48
51
  import { homedir } from "node:os";
49
52
  import { join, resolve } from "node:path";
@@ -54,69 +57,78 @@ import { breadcrumbs, boundedInsert, fullyQualified, lsListingArgv, parseLsListi
54
57
  import { listDistros, linuxHome, runCapture } from "./wsl.js";
55
58
  import { sharedAgent } from "./agent-shared.js";
56
59
  import { AgentUnavailableError } from "./agent-errors.js";
57
-
58
60
  /** How long a resolved distro list stays fresh before it is queried again. */
59
61
  const DEFAULT_DISTRO_CACHE_MS = 5_000;
60
-
61
62
  /**
62
63
  * Await `operation`, but reject with the signal's reason the moment it aborts.
63
64
  * Node's filesystem reads are not retractable, so the operation itself keeps
64
65
  * running against a handle the caller then closes; its late settlement is
65
66
  * swallowed so an abandoned read cannot surface as an unhandled rejection.
66
67
  *
68
+ * @template T
67
69
  * @param operation - the in-flight filesystem step.
68
70
  * @param signal - caller lifetime; absent means plain awaiting.
69
71
  * @returns the operation's value.
70
72
  */
71
73
  export function raceAbort(operation, signal) {
72
- if (signal === void 0) return operation;
73
- return new Promise((resolve, reject) => {
74
- const onAbort = () => {
75
- operation.catch(() => {});
76
- reject(signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason)));
77
- };
78
- if (signal.aborted) {
79
- onAbort();
80
- return;
81
- }
82
- signal.addEventListener("abort", onAbort, { once: true });
83
- operation.then(
84
- (value) => {
85
- signal.removeEventListener("abort", onAbort);
86
- resolve(value);
87
- },
88
- (reason) => {
89
- signal.removeEventListener("abort", onAbort);
90
- reject(reason instanceof Error ? reason : new Error(String(reason)));
91
- },
92
- );
93
- });
74
+ if (signal === void 0)
75
+ return operation;
76
+ return new Promise((resolve, reject) => {
77
+ const onAbort = () => {
78
+ operation.catch(() => { });
79
+ reject(signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason)));
80
+ };
81
+ if (signal.aborted) {
82
+ onAbort();
83
+ return;
84
+ }
85
+ signal.addEventListener("abort", onAbort, { once: true });
86
+ operation.then((value) => {
87
+ signal.removeEventListener("abort", onAbort);
88
+ resolve(value);
89
+ }, (reason) => {
90
+ signal.removeEventListener("abort", onAbort);
91
+ reject(reason instanceof Error ? reason : new Error(String(reason)));
92
+ });
93
+ });
94
94
  }
95
-
96
- /** Message text of an unknown thrown value. */
95
+ /**
96
+ * Message text of an unknown thrown value.
97
+ *
98
+ * @param error - the thrown value.
99
+ * @returns its message text.
100
+ */
97
101
  function messageOf(error) {
98
- return error instanceof Error ? error.message : String(error);
102
+ return error instanceof Error ? error.message : String(error);
99
103
  }
100
-
101
104
  /**
102
105
  * One listing row for a dirent, following symlinks to directories; null for
103
106
  * non-directories and broken or cyclic links, which cannot be entered.
107
+ *
108
+ * @param parent - the directory being listed.
109
+ * @param name - the dirent's base name.
110
+ * @param isDirectory - whether the dirent reports a directory.
111
+ * @param isSymbolicLink - whether the dirent is a symlink.
112
+ * @param signal - caller lifetime.
113
+ * @returns the row, or null when unenterable.
104
114
  */
105
115
  async function directoryRow(parent, name, isDirectory, isSymbolicLink, signal) {
106
- const path = join(parent, name);
107
- let enterable = isDirectory;
108
- if (!enterable && isSymbolicLink) {
109
- try {
110
- enterable = (await raceAbort(stat(path), signal)).isDirectory();
111
- } catch {
112
- if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason));
113
- return null;
116
+ const path = join(parent, name);
117
+ let enterable = isDirectory;
118
+ if (!enterable && isSymbolicLink) {
119
+ try {
120
+ enterable = (await raceAbort(stat(path), signal)).isDirectory();
121
+ }
122
+ catch {
123
+ if (signal?.aborted)
124
+ throw signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason));
125
+ return null;
126
+ }
114
127
  }
115
- }
116
- if (!enterable) return null;
117
- return { name, path, hidden: name.startsWith(".") };
128
+ if (!enterable)
129
+ return null;
130
+ return { name, path, hidden: name.startsWith(".") };
118
131
  }
119
-
120
132
  /**
121
133
  * The `ctx.directoryPicker` WSL implementation.
122
134
  *
@@ -124,331 +136,339 @@ async function directoryRow(parent, name, isDirectory, isSymbolicLink, signal) {
124
136
  * capture it across calls.
125
137
  */
126
138
  export class WslDirectoryPicker extends DirectoryPicker {
127
- static Config = z.object({
128
- /** Path to `wsl.exe`; overridable for a non-standard install. */
129
- wslPath: z.string().default("wsl.exe"),
139
+ static Config = z.object({
140
+ /** Path to `wsl.exe`; overridable for a non-standard install. */
141
+ wslPath: z.string().default("wsl.exe"),
142
+ /**
143
+ * Distro listed first and used for the Home affordance. Empty means WSL's
144
+ * own default distro.
145
+ */
146
+ preferredDistro: z.string().default(""),
147
+ /**
148
+ * Also offer the Windows home directory at the root level, so a normal
149
+ * host folder stays reachable without typing a path. The dialog's editable
150
+ * path zone accepts any Windows path either way.
151
+ */
152
+ includeHostHome: z.boolean().default(true),
153
+ /** Bound on the directory rows one `list` call may return; the level is flagged truncated. */
154
+ maxEntries: z.natural().min(1).default(1_000),
155
+ /** How long a resolved distro list stays fresh. */
156
+ distroCacheMs: z.natural().default(DEFAULT_DISTRO_CACHE_MS),
157
+ });
130
158
  /**
131
- * Distro listed first and used for the Home affordance. Empty means WSL's
132
- * own default distro.
159
+ * @param ctx - the owning context.
160
+ * @param config - the resolved {@link WslDirectoryPicker.Config}.
133
161
  */
134
- preferredDistro: z.string().default(""),
162
+ constructor(ctx, config) {
163
+ super(ctx);
164
+ this.config = config;
165
+ this.distroCache = undefined;
166
+ this.homeCache = new Map();
167
+ }
135
168
  /**
136
- * Also offer the Windows home directory at the root level, so a normal
137
- * host folder stays reachable without typing a path. The dialog's editable
138
- * path zone accepts any Windows path either way.
169
+ * The browse interaction capability.
170
+ *
171
+ * Deliberately `browse` and not a `wsl` kind: see the module comment. The
172
+ * distro listing is exposed as {@link WslDirectoryPicker.distros} for
173
+ * programmatic callers instead of through the capability union, because a
174
+ * capability kind the wire controller does not recognise would disable the
175
+ * verbs above rather than add a new one.
176
+ *
177
+ * @returns the stable `browse` capability object.
139
178
  */
140
- includeHostHome: z.boolean().default(true),
141
- /** Bound on the directory rows one `list` call may return; the level is flagged truncated. */
142
- maxEntries: z.natural().min(1).default(1_000),
143
- /** How long a resolved distro list stays fresh. */
144
- distroCacheMs: z.natural().default(DEFAULT_DISTRO_CACHE_MS),
145
- });
146
-
147
- constructor(ctx, config) {
148
- super(ctx);
149
- this.config = config;
150
- /** Last distro listing, so navigating to the root does not spawn `wsl.exe` every time. */
151
- this.distroCache = undefined;
152
- /** Per-distro Linux home, resolved once (a distro's default user does not change under a running session). */
153
- this.homeCache = new Map();
154
- }
155
-
156
- /**
157
- * The browse interaction capability.
158
- *
159
- * Deliberately `browse` and not a `wsl` kind: see the module comment. The
160
- * distro listing is exposed as {@link WslDirectoryPicker.distros} for
161
- * programmatic callers instead of through the capability union, because a
162
- * capability kind the wire controller does not recognise would disable the
163
- * verbs above rather than add a new one.
164
- *
165
- * @returns the stable `browse` capability object.
166
- */
167
- capability() {
168
- return this.browseCapability;
169
- }
170
-
171
- browseCapability = {
172
- kind: "browse",
173
- list: (path, signal) => this.list(path, signal),
174
- createDirectory: (path, name) => this.createDirectory(path, name),
175
- };
176
-
177
- /**
178
- * Installed distros, cached briefly.
179
- * @param signal - optional cancellation.
180
- * @returns distro names, default distro first.
181
- */
182
- async distros(signal) {
183
- const ttl = this.config.distroCacheMs;
184
- const now = Date.now();
185
- if (this.distroCache !== undefined && now - this.distroCache.at < ttl) return this.distroCache.value;
186
- const value = await listDistros({ wslPath: this.config.wslPath, signal });
187
- this.distroCache = { at: now, value };
188
- return value;
189
- }
190
-
191
- /**
192
- * The distro this picker leads with: the configured one when installed, else
193
- * WSL's default, else the first installed.
194
- * @param signal - optional cancellation.
195
- * @returns a distro name, or empty when none is installed.
196
- */
197
- async preferredDistro(signal) {
198
- const distros = await this.distros(signal);
199
- const configured = this.config.preferredDistro;
200
- if (configured.length > 0 && distros.includes(configured)) return configured;
201
- return distros[0] ?? "";
202
- }
203
-
204
- /**
205
- * A distro's Linux home directory as a UNC path, best effort: a distro that
206
- * cannot be queried (stopped, and unable to start) falls back to its root so
207
- * the dialog still opens somewhere real.
208
- *
209
- * @param distro - distro name.
210
- * @param signal - optional cancellation.
211
- * @returns the UNC home path.
212
- */
213
- async homeOf(distro, signal) {
214
- const cached = this.homeCache.get(distro);
215
- if (cached !== undefined) return cached;
216
- let home;
217
- try {
218
- home = await linuxHome(distro, { wslPath: this.config.wslPath, signal });
219
- } catch {
220
- home = distroRoot(distro);
221
- }
222
- this.homeCache.set(distro, home);
223
- return home;
224
- }
225
-
226
- /**
227
- * The root level: one row per installed distro, plus the Windows home when
228
- * configured. `path` is the preferred distro's root rather than the share
229
- * root, so adopting the level with nothing selected still yields a real
230
- * directory; the breadcrumb's "WSL" row carries the share root and returns
231
- * here.
232
- *
233
- * @param signal - optional cancellation.
234
- * @returns the root listing.
235
- */
236
- async rootLevel(signal) {
237
- const distros = await this.distros(signal);
238
- const preferred = await this.preferredDistro(signal);
239
- const entries = [];
240
- if (this.config.includeHostHome) {
241
- const hostHome = homedir();
242
- entries.push({ name: hostHome, path: hostHome, hidden: false });
179
+ capability() {
180
+ return this.browseCapability;
243
181
  }
244
- for (const distro of distros) entries.push({ name: distro, path: distroRoot(distro), hidden: false });
245
- const home = preferred.length > 0 ? await this.homeOf(preferred, signal) : homedir();
246
- return {
247
- path: preferred.length > 0 ? distroRoot(preferred) : homedir(),
248
- home,
249
- crumbs: [{ name: "WSL", path: UNC_PROVIDER_ROOT, hidden: false }],
250
- entries,
251
- truncated: false,
182
+ browseCapability = {
183
+ kind: "browse",
184
+ list: (path, signal) => this.list(path, signal),
185
+ createDirectory: (path, name) => this.createDirectory(path, name),
252
186
  };
253
- }
254
-
255
- /**
256
- * Breadcrumb rows for a level, headed by the WSL row.
257
- * @param target - the directory being listed.
258
- * @returns breadcrumb rows.
259
- */
260
- crumbsFor(target) {
261
- return breadcrumbs(target, UNC_PROVIDER_ROOT);
262
- }
263
-
264
- /**
265
- * List one directory level.
266
- * @param path - absolute directory; absent or the share root lists the distros.
267
- * @param signal - caller lifetime.
268
- * @returns the level's listing with its ancestry.
269
- * @throws DirectoryPickerError with code `directory-unreadable`.
270
- */
271
- async list(path, signal) {
272
- if (path === void 0 || isProviderRoot(path)) return this.rootLevel(signal);
273
- if (!fullyQualified(path)) {
274
- throw new DirectoryPickerError("directory-unreadable", path, `cannot list "${path}": not a fully qualified path`);
187
+ /**
188
+ * Installed distros, cached briefly.
189
+ *
190
+ * @param signal - optional cancellation.
191
+ * @returns distro names, default distro first.
192
+ */
193
+ async distros(signal) {
194
+ const ttl = this.config.distroCacheMs;
195
+ const now = Date.now();
196
+ if (this.distroCache !== undefined && now - this.distroCache.at < ttl)
197
+ return this.distroCache.value;
198
+ const value = await listDistros({ wslPath: this.config.wslPath, signal });
199
+ this.distroCache = { at: now, value };
200
+ return value;
275
201
  }
276
- const target = resolve(path);
277
- const preferred = await this.preferredDistro(signal);
278
- const level = isWslUnc(target) ? await this.listDistroLevel(target, signal) : await this.listHostLevel(target, signal);
279
- return {
280
- path: target,
281
- home: preferred.length > 0 ? await this.homeOf(preferred, signal) : homedir(),
282
- crumbs: this.crumbsFor(target),
283
- entries: level.rows,
284
- truncated: level.truncated,
285
- };
286
- }
287
-
288
- /**
289
- * List one level inside the distro, where the files live.
290
- *
291
- * The resident agent answers in one round trip; when it is out — distro
292
- * stopped, or the resident's rebuild budget spent — the same `ls` runs
293
- * through a one-shot `wsl.exe`. Neither path touches the 9p share, which is
294
- * what the host walk it replaces used: one metadata round trip per entry,
295
- * in the share's slow direction, plus one more per symlink.
296
- *
297
- * @param target - the absolute UNC directory to list.
298
- * @param signal - caller lifetime.
299
- * @returns the level's rows and truncation flag.
300
- * @throws DirectoryPickerError with code `directory-unreadable`.
301
- */
302
- async listDistroLevel(target, signal) {
303
- const parsed = uncToPosix(target);
304
- const linuxDir = parsed.linuxPath.length > 0 ? parsed.linuxPath : "/";
305
- let stdout;
306
- try {
307
- const result = await sharedAgent(parsed.distro).exec({
308
- cwd: linuxDir,
309
- argv: lsListingArgv(linuxDir),
310
- timeoutMs: 15_000,
311
- signal,
312
- });
313
- if (result.exitCode !== 0) {
314
- const detail = result.stderr.toString("utf8").trim();
315
- throw new DirectoryPickerError(
316
- "directory-unreadable",
317
- target,
318
- `cannot list ${target}: ${detail.length > 0 ? detail : `ls exited ${result.exitCode}`}`,
319
- );
320
- }
321
- stdout = result.stdout;
322
- } catch (error) {
323
- if (!(error instanceof AgentUnavailableError)) throw error;
324
- try {
325
- stdout = await runCapture(
326
- [this.config.wslPath, "-d", parsed.distro, "--cd", linuxDir, "--exec", ...lsListingArgv(linuxDir)],
327
- signal,
328
- );
329
- } catch (fallbackError) {
330
- throw new DirectoryPickerError("directory-unreadable", target, `cannot list ${target}: ${messageOf(fallbackError)}`);
331
- }
202
+ /**
203
+ * The distro this picker leads with: the configured one when installed, else
204
+ * WSL's default, else the first installed.
205
+ *
206
+ * @param signal - optional cancellation.
207
+ * @returns a distro name, or empty when none is installed.
208
+ */
209
+ async preferredDistro(signal) {
210
+ const distros = await this.distros(signal);
211
+ const configured = this.config.preferredDistro;
212
+ if (configured.length > 0 && distros.includes(configured))
213
+ return configured;
214
+ return distros[0] ?? "";
215
+ }
216
+ /**
217
+ * A distro's Linux home directory as a UNC path, best effort: a distro that
218
+ * cannot be queried (stopped, and unable to start) falls back to its root so
219
+ * the dialog still opens somewhere real.
220
+ *
221
+ * @param distro - distro name.
222
+ * @param signal - optional cancellation.
223
+ * @returns the UNC home path.
224
+ */
225
+ async homeOf(distro, signal) {
226
+ const cached = this.homeCache.get(distro);
227
+ if (cached !== undefined)
228
+ return cached;
229
+ let home;
230
+ try {
231
+ home = await linuxHome(distro, { wslPath: this.config.wslPath, signal });
232
+ }
233
+ catch {
234
+ home = distroRoot(distro);
235
+ }
236
+ this.homeCache.set(distro, home);
237
+ return home;
332
238
  }
333
- return parseLsListing(stdout, target, this.config.maxEntries);
334
- }
335
-
336
- /**
337
- * List one Windows level with the host's own walk — native I/O on a native
338
- * filesystem, unchanged from the shipped browse behaviour.
339
- * @param target - the absolute Windows directory to list.
340
- * @param signal - caller lifetime.
341
- * @returns the level's rows and truncation flag.
342
- * @throws DirectoryPickerError with code `directory-unreadable`.
343
- */
344
- async listHostLevel(target, signal) {
345
- const maxEntries = this.config.maxEntries;
346
- const keep = maxEntries + 1;
347
- const window = [];
348
- let evicted = false;
349
- let level;
350
- try {
351
- const opening = opendir(target);
352
- level = await raceAbort(opening, signal).catch((error) => {
353
- opening.then((dir) => dir.close().catch(() => {}), () => {});
354
- throw error;
355
- });
356
- for (;;) {
357
- const dirent = await raceAbort(level.read(), signal);
358
- if (dirent === null) break;
359
- if (!dirent.isDirectory() && !dirent.isSymbolicLink()) continue;
360
- if (boundedInsert(window, { name: dirent.name, isDirectory: dirent.isDirectory(), isSymbolicLink: dirent.isSymbolicLink() }, keep)) {
361
- evicted = true;
239
+ /**
240
+ * The root level: one row per installed distro, plus the Windows home when
241
+ * configured. `path` is the preferred distro's root rather than the share
242
+ * root, so adopting the level with nothing selected still yields a real
243
+ * directory; the breadcrumb's "WSL" row carries the share root and returns
244
+ * here.
245
+ *
246
+ * @param signal - optional cancellation.
247
+ * @returns the root listing.
248
+ */
249
+ async rootLevel(signal) {
250
+ const distros = await this.distros(signal);
251
+ const preferred = await this.preferredDistro(signal);
252
+ const entries = [];
253
+ if (this.config.includeHostHome) {
254
+ const hostHome = homedir();
255
+ entries.push({ name: hostHome, path: hostHome, hidden: false });
362
256
  }
363
- }
364
- } catch (error) {
365
- signal?.throwIfAborted();
366
- throw new DirectoryPickerError("directory-unreadable", target, `cannot list ${target}: ${messageOf(error)}`);
367
- } finally {
368
- if (level !== undefined) await level.close().catch(() => {});
257
+ for (const distro of distros)
258
+ entries.push({ name: distro, path: distroRoot(distro), hidden: false });
259
+ const home = preferred.length > 0 ? await this.homeOf(preferred, signal) : homedir();
260
+ return {
261
+ path: preferred.length > 0 ? distroRoot(preferred) : homedir(),
262
+ home,
263
+ crumbs: [{ name: "WSL", path: UNC_PROVIDER_ROOT, hidden: false }],
264
+ entries,
265
+ truncated: false,
266
+ };
267
+ }
268
+ /**
269
+ * Breadcrumb rows for a level, headed by the WSL row.
270
+ *
271
+ * @param target - the directory being listed.
272
+ * @returns breadcrumb rows.
273
+ */
274
+ crumbsFor(target) {
275
+ return breadcrumbs(target, UNC_PROVIDER_ROOT);
369
276
  }
370
-
371
- const rows = [];
372
- let truncated = evicted;
373
- for (const candidate of window) {
374
- signal?.throwIfAborted();
375
- const row = await directoryRow(target, candidate.name, candidate.isDirectory, candidate.isSymbolicLink, signal);
376
- if (row === null) continue;
377
- if (rows.length === maxEntries) {
378
- truncated = true;
379
- break;
380
- }
381
- rows.push(row);
277
+ /**
278
+ * List one directory level.
279
+ *
280
+ * @param path - absolute directory; absent or the share root lists the distros.
281
+ * @param signal - caller lifetime.
282
+ * @returns the level's listing with its ancestry.
283
+ * @throws DirectoryPickerError with code `directory-unreadable`.
284
+ */
285
+ async list(path, signal) {
286
+ if (path === void 0 || isProviderRoot(path))
287
+ return this.rootLevel(signal);
288
+ if (!fullyQualified(path)) {
289
+ throw new DirectoryPickerError("directory-unreadable", path, `cannot list "${path}": not a fully qualified path`);
290
+ }
291
+ const target = resolve(path);
292
+ const preferred = await this.preferredDistro(signal);
293
+ const level = isWslUnc(target) ? await this.listDistroLevel(target, signal) : await this.listHostLevel(target, signal);
294
+ return {
295
+ path: target,
296
+ home: preferred.length > 0 ? await this.homeOf(preferred, signal) : homedir(),
297
+ crumbs: this.crumbsFor(target),
298
+ entries: level.rows,
299
+ truncated: level.truncated,
300
+ };
382
301
  }
383
- return { rows, truncated };
384
- }
385
-
386
- /**
387
- * Create one child directory inside the listed level.
388
- * @param path - absolute existing parent directory.
389
- * @param name - single non-blank path segment.
390
- * @returns the created directory's absolute path.
391
- * @throws DirectoryPickerError with code `directory-exists` or `directory-create-failed`.
392
- */
393
- async createDirectory(path, name) {
394
- if (!fullyQualified(path)) {
395
- throw new DirectoryPickerError("directory-create-failed", path, `cannot create under "${path}": not a fully qualified parent path`);
302
+ /**
303
+ * List one level inside the distro, where the files live.
304
+ *
305
+ * The resident agent answers in one round trip; when it is out — distro
306
+ * stopped, or the resident's rebuild budget spent — the same `ls` runs
307
+ * through a one-shot `wsl.exe`. Neither path touches the 9p share, which is
308
+ * what the host walk it replaces used: one metadata round trip per entry,
309
+ * in the share's slow direction, plus one more per symlink.
310
+ *
311
+ * @param target - the absolute UNC directory to list.
312
+ * @param signal - caller lifetime.
313
+ * @returns the level's rows and truncation flag.
314
+ * @throws DirectoryPickerError with code `directory-unreadable`.
315
+ */
316
+ async listDistroLevel(target, signal) {
317
+ // The only caller gates on `isWslUnc`, so the parse cannot miss.
318
+ const parsed = uncToPosix(target);
319
+ const linuxDir = parsed.linuxPath.length > 0 ? parsed.linuxPath : "/";
320
+ let stdout;
321
+ try {
322
+ const result = await sharedAgent(parsed.distro).exec({
323
+ cwd: linuxDir,
324
+ argv: lsListingArgv(linuxDir),
325
+ timeoutMs: 15_000,
326
+ signal,
327
+ });
328
+ if (result.exitCode !== 0) {
329
+ const detail = result.stderr.toString("utf8").trim();
330
+ throw new DirectoryPickerError("directory-unreadable", target, `cannot list ${target}: ${detail.length > 0 ? detail : `ls exited ${result.exitCode}`}`);
331
+ }
332
+ stdout = result.stdout;
333
+ }
334
+ catch (error) {
335
+ if (!(error instanceof AgentUnavailableError))
336
+ throw error;
337
+ try {
338
+ stdout = await runCapture([this.config.wslPath, "-d", parsed.distro, "--cd", linuxDir, "--exec", ...lsListingArgv(linuxDir)], signal);
339
+ }
340
+ catch (fallbackError) {
341
+ throw new DirectoryPickerError("directory-unreadable", target, `cannot list ${target}: ${messageOf(fallbackError)}`);
342
+ }
343
+ }
344
+ return parseLsListing(stdout, target, this.config.maxEntries);
396
345
  }
397
- const parent = resolve(path);
398
- if (name.trim() === "" || name === "." || name === ".." || /[/\\]/.test(name)) {
399
- throw new DirectoryPickerError("directory-create-failed", join(parent, name), `"${name}" is not a single path segment`);
346
+ /**
347
+ * List one Windows level with the host's own walk — native I/O on a native
348
+ * filesystem, unchanged from the shipped browse behaviour.
349
+ *
350
+ * @param target - the absolute Windows directory to list.
351
+ * @param signal - caller lifetime.
352
+ * @returns the level's rows and truncation flag.
353
+ * @throws DirectoryPickerError with code `directory-unreadable`.
354
+ */
355
+ async listHostLevel(target, signal) {
356
+ const maxEntries = this.config.maxEntries;
357
+ const keep = maxEntries + 1;
358
+ const window = [];
359
+ let evicted = false;
360
+ let level;
361
+ try {
362
+ const opening = opendir(target);
363
+ level = await raceAbort(opening, signal).catch((error) => {
364
+ opening.then((dir) => dir.close().catch(() => { }), () => { });
365
+ throw error;
366
+ });
367
+ for (;;) {
368
+ const dirent = await raceAbort(level.read(), signal);
369
+ if (dirent === null)
370
+ break;
371
+ if (!dirent.isDirectory() && !dirent.isSymbolicLink())
372
+ continue;
373
+ if (boundedInsert(window, { name: dirent.name, isDirectory: dirent.isDirectory(), isSymbolicLink: dirent.isSymbolicLink() }, keep)) {
374
+ evicted = true;
375
+ }
376
+ }
377
+ }
378
+ catch (error) {
379
+ signal?.throwIfAborted();
380
+ throw new DirectoryPickerError("directory-unreadable", target, `cannot list ${target}: ${messageOf(error)}`);
381
+ }
382
+ finally {
383
+ if (level !== undefined)
384
+ await level.close().catch(() => { });
385
+ }
386
+ const rows = [];
387
+ let truncated = evicted;
388
+ for (const candidate of window) {
389
+ signal?.throwIfAborted();
390
+ const row = await directoryRow(target, candidate.name, candidate.isDirectory, candidate.isSymbolicLink, signal);
391
+ if (row === null)
392
+ continue;
393
+ if (rows.length === maxEntries) {
394
+ truncated = true;
395
+ break;
396
+ }
397
+ rows.push(row);
398
+ }
399
+ return { rows, truncated };
400
400
  }
401
- const target = join(parent, name);
402
- if (isWslUnc(parent)) return this.createDistroDirectory(parent, name, target);
403
- try {
404
- await mkdir(target);
405
- return target;
406
- } catch (error) {
407
- if (typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST") {
408
- throw new DirectoryPickerError("directory-exists", target, `${target} already exists`);
409
- }
410
- throw new DirectoryPickerError("directory-create-failed", target, `cannot create ${target}: ${messageOf(error)}`);
401
+ /**
402
+ * Create one child directory inside the listed level.
403
+ *
404
+ * @param path - absolute existing parent directory.
405
+ * @param name - single non-blank path segment.
406
+ * @returns the created directory's absolute path.
407
+ * @throws DirectoryPickerError with code `directory-exists` or `directory-create-failed`.
408
+ */
409
+ async createDirectory(path, name) {
410
+ if (!fullyQualified(path)) {
411
+ throw new DirectoryPickerError("directory-create-failed", path, `cannot create under "${path}": not a fully qualified parent path`);
412
+ }
413
+ const parent = resolve(path);
414
+ if (name.trim() === "" || name === "." || name === ".." || /[/\\]/.test(name)) {
415
+ throw new DirectoryPickerError("directory-create-failed", join(parent, name), `"${name}" is not a single path segment`);
416
+ }
417
+ const target = join(parent, name);
418
+ if (isWslUnc(parent))
419
+ return this.createDistroDirectory(parent, name, target);
420
+ try {
421
+ await mkdir(target);
422
+ return target;
423
+ }
424
+ catch (error) {
425
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST") {
426
+ throw new DirectoryPickerError("directory-exists", target, `${target} already exists`);
427
+ }
428
+ throw new DirectoryPickerError("directory-create-failed", target, `cannot create ${target}: ${messageOf(error)}`);
429
+ }
411
430
  }
412
- }
413
-
414
- /**
415
- * Create one child directory inside the distro — the resident agent's
416
- * `mkdir` when it is up, a one-shot `wsl.exe --exec mkdir` when it is out;
417
- * the host's `mkdir` over the 9p share is what this replaces.
418
- *
419
- * @param parent - the absolute UNC parent directory.
420
- * @param name - the validated single path segment to create.
421
- * @param target - the UNC path the caller receives on success.
422
- * @returns the created directory's absolute path.
423
- * @throws DirectoryPickerError with code `directory-exists` or `directory-create-failed`.
424
- */
425
- async createDistroDirectory(parent, name, target) {
426
- const parsed = uncToPosix(parent);
427
- const linuxParent = parsed.linuxPath.length > 0 ? parsed.linuxPath : "/";
428
- try {
429
- const result = await sharedAgent(parsed.distro).exec({ cwd: linuxParent, argv: ["mkdir", "--", name], timeoutMs: 10_000 });
430
- if (result.exitCode === 0) return target;
431
- const detail = result.stderr.toString("utf8");
432
- if (/File exists/i.test(detail)) throw new DirectoryPickerError("directory-exists", target, `${target} already exists`);
433
- throw new DirectoryPickerError(
434
- "directory-create-failed",
435
- target,
436
- `cannot create ${target}: ${detail.trim().length > 0 ? detail.trim() : `mkdir exited ${result.exitCode}`}`,
437
- );
438
- } catch (error) {
439
- if (error instanceof DirectoryPickerError) throw error;
440
- if (!(error instanceof AgentUnavailableError)) throw error;
441
- try {
442
- await runCapture([this.config.wslPath, "-d", parsed.distro, "--cd", linuxParent, "--exec", "mkdir", "--", name]);
443
- return target;
444
- } catch (fallbackError) {
445
- const message = messageOf(fallbackError);
446
- if (/File exists/i.test(message)) throw new DirectoryPickerError("directory-exists", target, `${target} already exists`);
447
- throw new DirectoryPickerError("directory-create-failed", target, `cannot create ${target}: ${message}`);
448
- }
431
+ /**
432
+ * Create one child directory inside the distro — the resident agent's
433
+ * `mkdir` when it is up, a one-shot `wsl.exe --exec mkdir` when it is out;
434
+ * the host's `mkdir` over the 9p share is what this replaces.
435
+ *
436
+ * @param parent - the absolute UNC parent directory.
437
+ * @param name - the validated single path segment to create.
438
+ * @param target - the UNC path the caller receives on success.
439
+ * @returns the created directory's absolute path.
440
+ * @throws DirectoryPickerError with code `directory-exists` or `directory-create-failed`.
441
+ */
442
+ async createDistroDirectory(parent, name, target) {
443
+ // The only caller gates on `isWslUnc`, so the parse cannot miss.
444
+ const parsed = uncToPosix(parent);
445
+ const linuxParent = parsed.linuxPath.length > 0 ? parsed.linuxPath : "/";
446
+ try {
447
+ const result = await sharedAgent(parsed.distro).exec({ cwd: linuxParent, argv: ["mkdir", "--", name], timeoutMs: 10_000 });
448
+ if (result.exitCode === 0)
449
+ return target;
450
+ const detail = result.stderr.toString("utf8");
451
+ if (/File exists/i.test(detail))
452
+ throw new DirectoryPickerError("directory-exists", target, `${target} already exists`);
453
+ throw new DirectoryPickerError("directory-create-failed", target, `cannot create ${target}: ${detail.trim().length > 0 ? detail.trim() : `mkdir exited ${result.exitCode}`}`);
454
+ }
455
+ catch (error) {
456
+ if (error instanceof DirectoryPickerError)
457
+ throw error;
458
+ if (!(error instanceof AgentUnavailableError))
459
+ throw error;
460
+ try {
461
+ await runCapture([this.config.wslPath, "-d", parsed.distro, "--cd", linuxParent, "--exec", "mkdir", "--", name]);
462
+ return target;
463
+ }
464
+ catch (fallbackError) {
465
+ const message = messageOf(fallbackError);
466
+ if (/File exists/i.test(message))
467
+ throw new DirectoryPickerError("directory-exists", target, `${target} already exists`);
468
+ throw new DirectoryPickerError("directory-create-failed", target, `cannot create ${target}: ${message}`);
469
+ }
470
+ }
449
471
  }
450
- }
451
472
  }
452
-
453
473
  export default WslDirectoryPicker;
454
474
  export { ancestryCrumbs, boundedInsert, breadcrumbs, fullyQualified } from "./listing.js";