dsh-wsl-workspace 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.
@@ -0,0 +1,179 @@
1
+ import { i as joinUnc, n as isValidWslUsername, s as parseWslUnc } from "./paths-DBaSmi7x.js";
2
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { execFile, execFileSync } from "node:child_process";
6
+ import { promisify } from "node:util";
7
+ //#region src/shared/wsl-credentials.ts
8
+ /**
9
+ * Per-workspace WSL credentials (host side only). The dialog stores the
10
+ * optional Linux username of a WSL workspace under the harness home; the
11
+ * per-session env contributor and the WSL shell executor read it back so
12
+ * `wsl.exe -u <username>` can run commands as that user. Keys are canonical
13
+ * UNC workspace paths. This module touches node builtins, so the browser
14
+ * half never imports it.
15
+ * @module dsh-wsl-workspace/shared/wsl-credentials
16
+ */
17
+ /** The store file lives under the harness home so both host halves share it. */
18
+ function storePath() {
19
+ return join(process.env.DSH_HOME ?? join(homedir(), ".dsh"), "wsl-workspaces.json");
20
+ }
21
+ /** Read the store; a missing or corrupt file reads as empty (never throws). */
22
+ function readStore() {
23
+ try {
24
+ const parsed = JSON.parse(readFileSync(storePath(), "utf8"));
25
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return {};
26
+ return parsed;
27
+ } catch {
28
+ return {};
29
+ }
30
+ }
31
+ /**
32
+ * Canonicalize any accepted WSL UNC spelling into the store's key form.
33
+ * @param path - candidate workspace path (either UNC host form).
34
+ * @returns the canonical UNC path, or null when the path is not a WSL UNC.
35
+ */
36
+ function canonicalWslUnc(path) {
37
+ const parsed = parseWslUnc(path);
38
+ return parsed === null ? null : joinUnc(parsed.distro, parsed.linuxPath);
39
+ }
40
+ /**
41
+ * Read the stored username for a WSL workspace.
42
+ * @param uncPath - the workspace path (any accepted WSL UNC spelling).
43
+ * @returns the username, or undefined when none is stored.
44
+ */
45
+ function getWorkspaceUsername(uncPath) {
46
+ const key = canonicalWslUnc(uncPath);
47
+ if (key === null) return void 0;
48
+ const username = readStore()[key]?.username;
49
+ return username === void 0 || username === "" ? void 0 : username;
50
+ }
51
+ /**
52
+ * Store (or clear) the username of a WSL workspace.
53
+ * @param uncPath - the workspace path (any accepted WSL UNC spelling).
54
+ * @param username - the username; empty or undefined clears the stored value.
55
+ */
56
+ function setWorkspaceUsername(uncPath, username) {
57
+ const key = canonicalWslUnc(uncPath);
58
+ if (key === null) throw new Error("wsl-workspace: workspace path is not a WSL UNC path");
59
+ const store = readStore();
60
+ if (username === void 0 || username.trim() === "") delete store[key];
61
+ else {
62
+ const trimmed = username.trim();
63
+ if (!isValidWslUsername(trimmed)) throw new Error("wsl-workspace: username must match the Linux username pattern [A-Za-z_][A-Za-z0-9_.-]*");
64
+ store[key] = { username: trimmed };
65
+ }
66
+ const path = storePath();
67
+ mkdirSync(dirname(path), { recursive: true });
68
+ writeFileSync(path, JSON.stringify(store, null, 2) + "\n", "utf8");
69
+ }
70
+ //#endregion
71
+ //#region src/shared/wsl.ts
72
+ /**
73
+ * WSL discovery helpers (host side): enumerate installed distributions
74
+ * through `wsl.exe -l -q` and read the default distribution from the Lxss
75
+ * registry key. `wsl.exe` output is UTF-16LE on most builds, so decoding
76
+ * sniffs for NUL bytes before choosing an encoding.
77
+ * @module dsh-wsl-workspace/shared/wsl
78
+ */
79
+ const execFileAsync = promisify(execFile);
80
+ /** Executable timeout for the short discovery calls. */
81
+ const DISCOVERY_TIMEOUT_MS = 1e4;
82
+ const LXSS_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss";
83
+ /** Human text for an unknown rejection. */
84
+ function messageOf(value) {
85
+ return value instanceof Error ? value.message : String(value);
86
+ }
87
+ /**
88
+ * Decode `wsl.exe -l -q` output. Newer builds emit UTF-8; most emit UTF-16LE
89
+ * with NUL bytes interleaved — the NUL probe picks the right one.
90
+ * @param buffer - the raw captured output.
91
+ * @returns the decoded text.
92
+ */
93
+ function decodeWslOutput(buffer) {
94
+ return buffer.includes(0) ? buffer.toString("utf16le") : buffer.toString("utf8");
95
+ }
96
+ /**
97
+ * List installed WSL distributions in `wsl.exe` order.
98
+ * @param wslPath - the `wsl.exe` executable (absolute or PATH name).
99
+ * @returns distribution names, blank lines dropped.
100
+ */
101
+ async function listDistros(wslPath = "wsl.exe") {
102
+ let stdout;
103
+ try {
104
+ stdout = (await execFileAsync(wslPath, ["-l", "-q"], {
105
+ encoding: "buffer",
106
+ timeout: DISCOVERY_TIMEOUT_MS
107
+ })).stdout;
108
+ } catch (error) {
109
+ throw new Error(`wsl-workspace: cannot list WSL distributions (${messageOf(error)}); is WSL installed?`);
110
+ }
111
+ return decodeWslOutput(stdout).split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
112
+ }
113
+ /**
114
+ * Read the user's default distribution from the Lxss registry. Non-fatal:
115
+ * returns `undefined` when the value is absent or unreadable (the caller
116
+ * falls back to list order).
117
+ * @returns the default distribution name, or `undefined`.
118
+ */
119
+ async function defaultDistro() {
120
+ try {
121
+ const value = await execFileAsync("reg.exe", [
122
+ "query",
123
+ LXSS_KEY,
124
+ "/v",
125
+ "DefaultDistribution"
126
+ ], { timeout: DISCOVERY_TIMEOUT_MS });
127
+ const guid = /DefaultDistribution\s+REG_SZ\s+(\{[0-9a-fA-F-]+\})/i.exec(value.stdout)?.[1];
128
+ if (guid === void 0) return void 0;
129
+ const name = await execFileAsync("reg.exe", [
130
+ "query",
131
+ `${LXSS_KEY}\\${guid}`,
132
+ "/v",
133
+ "DistributionName"
134
+ ], { timeout: DISCOVERY_TIMEOUT_MS });
135
+ const distro = /DistributionName\s+REG_SZ\s+(.+)/i.exec(name.stdout)?.[1]?.trim();
136
+ return distro === void 0 || distro === "" ? void 0 : distro;
137
+ } catch {
138
+ return;
139
+ }
140
+ }
141
+ /** Module-level cache for {@link defaultDistroSync} (one registry read per process). */
142
+ let syncDefaultResolved = false;
143
+ let syncDefault;
144
+ /**
145
+ * Synchronous variant of {@link defaultDistro} for executors that must
146
+ * resolve a distribution inside a synchronous plan step. Cached after the
147
+ * first read; non-fatal (returns `undefined` when the registry is
148
+ * unreadable, letting the caller fail loud with its own message).
149
+ * @returns the default distribution name, or `undefined`.
150
+ */
151
+ function defaultDistroSync() {
152
+ if (syncDefaultResolved) return syncDefault;
153
+ syncDefaultResolved = true;
154
+ try {
155
+ const value = execFileSync("reg.exe", [
156
+ "query",
157
+ LXSS_KEY,
158
+ "/v",
159
+ "DefaultDistribution"
160
+ ], { timeout: DISCOVERY_TIMEOUT_MS });
161
+ const guid = /DefaultDistribution\s+REG_SZ\s+(\{[0-9a-fA-F-]+\})/i.exec(String(value))?.[1];
162
+ if (guid === void 0) return void 0;
163
+ const name = execFileSync("reg.exe", [
164
+ "query",
165
+ `${LXSS_KEY}\\${guid}`,
166
+ "/v",
167
+ "DistributionName"
168
+ ], { timeout: DISCOVERY_TIMEOUT_MS });
169
+ const distro = /DistributionName\s+REG_SZ\s+(.+)/i.exec(String(name))?.[1]?.trim();
170
+ syncDefault = distro === void 0 || distro === "" ? void 0 : distro;
171
+ } catch {
172
+ syncDefault = void 0;
173
+ }
174
+ return syncDefault;
175
+ }
176
+ //#endregion
177
+ export { getWorkspaceUsername as a, canonicalWslUnc as i, defaultDistroSync as n, setWorkspaceUsername as o, listDistros as r, defaultDistro as t };
178
+
179
+ //# sourceMappingURL=wsl-GjkUifnx.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wsl-GjkUifnx.js","names":[],"sources":["../src/shared/wsl-credentials.ts","../src/shared/wsl.ts"],"sourcesContent":["/**\n * Per-workspace WSL credentials (host side only). The dialog stores the\n * optional Linux username of a WSL workspace under the harness home; the\n * per-session env contributor and the WSL shell executor read it back so\n * `wsl.exe -u <username>` can run commands as that user. Keys are canonical\n * UNC workspace paths. This module touches node builtins, so the browser\n * half never imports it.\n * @module dsh-wsl-workspace/shared/wsl-credentials\n */\n\nimport { mkdirSync, readFileSync, writeFileSync } from 'node:fs'\nimport { homedir } from 'node:os'\nimport { dirname, join } from 'node:path'\nimport { isValidWslUsername, joinUnc, parseWslUnc } from './paths.ts'\n\n/** One workspace's stored credentials. */\ninterface WorkspaceEntry {\n /** The Linux user bash runs as inside the distribution (absent = distro default). */\n username?: string\n}\n\n/** The stored form: canonical UNC workspace path → credentials. */\ntype WorkspaceStore = Record<string, WorkspaceEntry>\n\n/** The store file lives under the harness home so both host halves share it. */\nfunction storePath(): string {\n const dshHome = process.env.DSH_HOME ?? join(homedir(), '.dsh')\n return join(dshHome, 'wsl-workspaces.json')\n}\n\n/** Read the store; a missing or corrupt file reads as empty (never throws). */\nfunction readStore(): WorkspaceStore {\n try {\n const parsed: unknown = JSON.parse(readFileSync(storePath(), 'utf8'))\n if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return {}\n return parsed as WorkspaceStore\n } catch {\n return {}\n }\n}\n\n/**\n * Canonicalize any accepted WSL UNC spelling into the store's key form.\n * @param path - candidate workspace path (either UNC host form).\n * @returns the canonical UNC path, or null when the path is not a WSL UNC.\n */\nexport function canonicalWslUnc(path: string): string | null {\n const parsed = parseWslUnc(path)\n return parsed === null ? null : joinUnc(parsed.distro, parsed.linuxPath)\n}\n\n/**\n * Read the stored username for a WSL workspace.\n * @param uncPath - the workspace path (any accepted WSL UNC spelling).\n * @returns the username, or undefined when none is stored.\n */\nexport function getWorkspaceUsername(uncPath: string): string | undefined {\n const key = canonicalWslUnc(uncPath)\n if (key === null) return undefined\n const username = readStore()[key]?.username\n return username === undefined || username === '' ? undefined : username\n}\n\n/**\n * Store (or clear) the username of a WSL workspace.\n * @param uncPath - the workspace path (any accepted WSL UNC spelling).\n * @param username - the username; empty or undefined clears the stored value.\n */\nexport function setWorkspaceUsername(uncPath: string, username: string | undefined): void {\n const key = canonicalWslUnc(uncPath)\n if (key === null) throw new Error('wsl-workspace: workspace path is not a WSL UNC path')\n const store = readStore()\n if (username === undefined || username.trim() === '') {\n delete store[key]\n } else {\n const trimmed = username.trim()\n if (!isValidWslUsername(trimmed)) {\n throw new Error('wsl-workspace: username must match the Linux username pattern [A-Za-z_][A-Za-z0-9_.-]*')\n }\n store[key] = { username: trimmed }\n }\n const path = storePath()\n mkdirSync(dirname(path), { recursive: true })\n writeFileSync(path, JSON.stringify(store, null, 2) + '\\n', 'utf8')\n}\n","/**\n * WSL discovery helpers (host side): enumerate installed distributions\n * through `wsl.exe -l -q` and read the default distribution from the Lxss\n * registry key. `wsl.exe` output is UTF-16LE on most builds, so decoding\n * sniffs for NUL bytes before choosing an encoding.\n * @module dsh-wsl-workspace/shared/wsl\n */\n\nimport { execFile, execFileSync } from 'node:child_process'\nimport { promisify } from 'node:util'\n\nconst execFileAsync = promisify(execFile)\n\n/** Executable timeout for the short discovery calls. */\nconst DISCOVERY_TIMEOUT_MS = 10_000\n\nconst LXSS_KEY = 'HKCU\\\\Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Lxss'\n\n/** Human text for an unknown rejection. */\nfunction messageOf(value: unknown): string {\n return value instanceof Error ? value.message : String(value)\n}\n\n/**\n * Decode `wsl.exe -l -q` output. Newer builds emit UTF-8; most emit UTF-16LE\n * with NUL bytes interleaved — the NUL probe picks the right one.\n * @param buffer - the raw captured output.\n * @returns the decoded text.\n */\nexport function decodeWslOutput(buffer: Buffer): string {\n return buffer.includes(0) ? buffer.toString('utf16le') : buffer.toString('utf8')\n}\n\n/**\n * List installed WSL distributions in `wsl.exe` order.\n * @param wslPath - the `wsl.exe` executable (absolute or PATH name).\n * @returns distribution names, blank lines dropped.\n */\nexport async function listDistros(wslPath = 'wsl.exe'): Promise<string[]> {\n let stdout: Buffer\n try {\n const result = await execFileAsync(wslPath, ['-l', '-q'], { encoding: 'buffer', timeout: DISCOVERY_TIMEOUT_MS })\n stdout = result.stdout as Buffer\n } catch (error) {\n throw new Error(`wsl-workspace: cannot list WSL distributions (${messageOf(error)}); is WSL installed?`)\n }\n return decodeWslOutput(stdout)\n .split(/\\r?\\n/)\n .map(line => line.trim())\n .filter(line => line.length > 0)\n}\n\n/**\n * Read the user's default distribution from the Lxss registry. Non-fatal:\n * returns `undefined` when the value is absent or unreadable (the caller\n * falls back to list order).\n * @returns the default distribution name, or `undefined`.\n */\nexport async function defaultDistro(): Promise<string | undefined> {\n try {\n const value = await execFileAsync('reg.exe', ['query', LXSS_KEY, '/v', 'DefaultDistribution'], {\n timeout: DISCOVERY_TIMEOUT_MS,\n })\n const guid = /DefaultDistribution\\s+REG_SZ\\s+(\\{[0-9a-fA-F-]+\\})/i.exec(value.stdout)?.[1]\n if (guid === undefined) return undefined\n const name = await execFileAsync('reg.exe', ['query', `${LXSS_KEY}\\\\${guid}`, '/v', 'DistributionName'], {\n timeout: DISCOVERY_TIMEOUT_MS,\n })\n const distro = /DistributionName\\s+REG_SZ\\s+(.+)/i.exec(name.stdout)?.[1]?.trim()\n return distro === undefined || distro === '' ? undefined : distro\n } catch {\n return undefined\n }\n}\n\n/** Module-level cache for {@link defaultDistroSync} (one registry read per process). */\nlet syncDefaultResolved = false\nlet syncDefault: string | undefined\n\n/**\n * Synchronous variant of {@link defaultDistro} for executors that must\n * resolve a distribution inside a synchronous plan step. Cached after the\n * first read; non-fatal (returns `undefined` when the registry is\n * unreadable, letting the caller fail loud with its own message).\n * @returns the default distribution name, or `undefined`.\n */\nexport function defaultDistroSync(): string | undefined {\n if (syncDefaultResolved) return syncDefault\n syncDefaultResolved = true\n try {\n const value = execFileSync('reg.exe', ['query', LXSS_KEY, '/v', 'DefaultDistribution'], {\n timeout: DISCOVERY_TIMEOUT_MS,\n })\n const guid = /DefaultDistribution\\s+REG_SZ\\s+(\\{[0-9a-fA-F-]+\\})/i.exec(String(value))?.[1]\n if (guid === undefined) return undefined\n const name = execFileSync('reg.exe', ['query', `${LXSS_KEY}\\\\${guid}`, '/v', 'DistributionName'], {\n timeout: DISCOVERY_TIMEOUT_MS,\n })\n const distro = /DistributionName\\s+REG_SZ\\s+(.+)/i.exec(String(name))?.[1]?.trim()\n syncDefault = distro === undefined || distro === '' ? undefined : distro\n } catch {\n syncDefault = undefined\n }\n return syncDefault\n}\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "dsh-wsl-workspace",
3
+ "description": "WSL workspace support for DeepSeek Harness: add a WSL workspace from the web GUI and run the whole agent session (bash + file tools) inside the WSL distribution, VS Code Remote-WSL style. No toolchain install inside WSL required.",
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/6Mikao9/dsh-wsl-workspace.git"
9
+ },
10
+ "main": "lib/index.js",
11
+ "exports": {
12
+ ".": "./lib/index.js",
13
+ "./shell": "./lib/shell.js",
14
+ "./fs": "./lib/fs.js",
15
+ "./client": "./lib/client.js",
16
+ "./package.json": "./package.json"
17
+ },
18
+ "dsh": {
19
+ "bundle": {
20
+ "patch": "./cordis.patch.yml"
21
+ },
22
+ "client": {
23
+ "platform": "web",
24
+ "inject": [
25
+ "@deepseek-ai/dsh-client-runtime",
26
+ "@deepseek-ai/dsh-client-locale",
27
+ "@deepseek-ai/dsh-client-ui-sidebar"
28
+ ]
29
+ }
30
+ },
31
+ "license": "MIT",
32
+ "keywords": [
33
+ "deepseek-harness",
34
+ "dsh",
35
+ "wsl",
36
+ "workspace",
37
+ "plugin",
38
+ "windows"
39
+ ],
40
+ "peerDependencies": {
41
+ "@deepseek-ai/cordis": "*",
42
+ "@deepseek-ai/dsh-fs": "*",
43
+ "@deepseek-ai/dsh-fs-local": "*",
44
+ "@deepseek-ai/dsh-shell": "*",
45
+ "@deepseek-ai/dsh-subprocess": "*",
46
+ "@deepseek-ai/dsh-timeout": "*"
47
+ },
48
+ "files": [
49
+ "lib",
50
+ "cordis.patch.yml",
51
+ "src",
52
+ "LICENSE",
53
+ "NOTICE",
54
+ "README.md",
55
+ "README.zh.md"
56
+ ]
57
+ }
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Sidebar footer action that opens the "Add WSL workspace" dialog. In the
3
+ * wide sidebar it renders a labeled row; in the 56px rail it collapses to a
4
+ * 36px icon button (both honor the shell's `{ wide }` owner share).
5
+ *
6
+ * Registration omits the typed `locale:` seat (the `wslWorkspace` namespace is
7
+ * not merged into `LocaleNamespaceMap`), so the injected face carries the
8
+ * bound translate function instead.
9
+ */
10
+
11
+ import type * as React from 'react'
12
+ import { useEffect, useRef, useState, type UIEventHandler } from 'react'
13
+ import { isAbsoluteLinuxPath, isValidWslUsername, joinUnc, normalizeLinuxPath } from '../shared/paths.ts'
14
+ import type { WslDirListing, WslPathCheck } from './api.ts'
15
+
16
+ /** Result-level shape of a browse/check/list call we surface uniformly. */
17
+ interface ApiCall<T> {
18
+ value: T
19
+ }
20
+
21
+ /** The inject face: plain data + callbacks the dialog drives. */
22
+ export interface AddWslWorkspaceInjected {
23
+ /**
24
+ * Confirm the deployment exposes a healthy `wsl` preset.
25
+ * @returns undefined when healthy, else a Chinese/English message to show.
26
+ */
27
+ checkPreset(): Promise<string | undefined>
28
+ /** List the WSL distros installed on the host. */
29
+ listDistros(): Promise<string[]>
30
+ /** List one Linux directory level inside a distro. */
31
+ listDir(distro: string, path: string): Promise<WslDirListing>
32
+ /** Check a Linux path's existence/directory facts. */
33
+ check(distro: string, path: string): Promise<WslPathCheck>
34
+ /**
35
+ * Register a workspace over a WSL UNC path and start a session in it.
36
+ * @param path - the `\\wsl.localhost\<distro>\...` UNC path.
37
+ * @param username - optional Linux user for the session (empty = distro default).
38
+ * @returns undefined on success, else a message to show.
39
+ */
40
+ createWorkspace(path: string, username: string): Promise<string | undefined>
41
+ /** Translate a `wslWorkspace` dictionary key. */
42
+ t: (key: string, params?: Record<string, unknown>) => string
43
+ }
44
+
45
+ /** Full component props: the owner share plus the injected face. */
46
+ export interface AddWslWorkspaceProps extends AddWslWorkspaceInjected {
47
+ /** Whether the sidebar renders wide content (false = 56px rail). */
48
+ wide: boolean
49
+ }
50
+
51
+ /**
52
+ * Build the Linux child path one level below a parent, for the breadcrumb/
53
+ * browse drill.
54
+ * @param parent - the currently listed absolute path (`/` for root).
55
+ * @param name - the child directory name.
56
+ * @returns the child's absolute Linux path.
57
+ */
58
+ export function dirChildPath(parent: string, name: string): string {
59
+ return parent === '/' ? `/${name}` : `${parent}/${name}`
60
+ }
61
+
62
+ /** A tiny inline terminal glyph for the dialog's directory rows. */
63
+ function WslGlyph({ size = 16 }: { size?: number }): React.ReactElement {
64
+ return (
65
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
66
+ <rect x="2.5" y="4.5" width="19" height="15" rx="2.5" stroke="currentColor" strokeWidth="1.6" />
67
+ <path d="M6 9l3.2 2.6L6 14" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
68
+ <path d="M12 14h5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
69
+ </svg>
70
+ )
71
+ }
72
+
73
+ /**
74
+ * The "Add WSL workspace…" footer action and its dialog.
75
+ * @param props - owner share + injected face.
76
+ */
77
+ export function AddWslWorkspace({ wide, t, checkPreset, listDistros, listDir, check, createWorkspace }: AddWslWorkspaceProps): React.ReactElement | null {
78
+ const [open, setOpen] = useState(false)
79
+ const [opening, setOpening] = useState(false)
80
+ const [distros, setDistros] = useState<string[]>([])
81
+ const [distro, setDistro] = useState('')
82
+ const [pathInput, setPathInput] = useState('/home/')
83
+ const [username, setUsername] = useState('')
84
+ const [listing, setListing] = useState<WslDirListing | null>(null)
85
+ const [browsePath, setBrowsePath] = useState('/')
86
+ const [browsing, setBrowsing] = useState(false)
87
+ const [error, setError] = useState<string | null>(null)
88
+ const [busy, setBusy] = useState(false)
89
+ // Monotone browse-request sequence: stale responses for a superseded browse are dropped.
90
+ const browseSeq = useRef(0)
91
+
92
+ const refreshBrowse = async (root: string, targetDistro: string): Promise<void> => {
93
+ const seq = ++browseSeq.current
94
+ setBrowsing(true)
95
+ setBrowsePath(root)
96
+ try {
97
+ const value = await listDir(targetDistro, root)
98
+ if (seq === browseSeq.current) setListing(value)
99
+ } catch {
100
+ // A failed browse (permission, missing dir) is non-fatal: keep old listing.
101
+ if (seq === browseSeq.current) {
102
+ setListing(null)
103
+ setError((previous) => previous ?? t('error.loadDir'))
104
+ }
105
+ } finally {
106
+ if (seq === browseSeq.current) setBrowsing(false)
107
+ }
108
+ }
109
+
110
+ useEffect(() => {
111
+ if (!open) return
112
+ let cancelled = false
113
+ setError(null)
114
+ setOpening(true)
115
+ void (async () => {
116
+ let presetIssue: string | undefined
117
+ try {
118
+ presetIssue = await checkPreset()
119
+ } catch {
120
+ presetIssue = t('error.loadDistros')
121
+ }
122
+ let names: string[]
123
+ try {
124
+ names = await listDistros()
125
+ } catch {
126
+ if (cancelled) return
127
+ setOpening(false)
128
+ setError(t('error.loadDistros'))
129
+ return
130
+ }
131
+ if (cancelled) return
132
+ setDistros(names)
133
+ const first = names[0] ?? ''
134
+ setDistro(first)
135
+ // The default browse root walks from `/`; the input defaults to `/home/`.
136
+ setBrowsing(true)
137
+ setOpening(false)
138
+ if (presetIssue !== undefined) setError(presetIssue)
139
+ if (first !== '') void refreshBrowse('/', first)
140
+ })()
141
+ return () => { cancelled = true }
142
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- run once per open against current t.
143
+ }, [open])
144
+
145
+ useEffect(() => {
146
+ if (!open) return
147
+ const onKey = (event: KeyboardEvent): void => {
148
+ if (event.key === 'Escape' && !busy) setOpen(false)
149
+ }
150
+ window.addEventListener('keydown', onKey)
151
+ return () => window.removeEventListener('keydown', onKey)
152
+ }, [open, busy])
153
+
154
+ if (!open) {
155
+ // A W-letter action beside Settings at the sidebar foot; the title
156
+ // carries the label in both wide and rail states.
157
+ return (
158
+ <button
159
+ type="button"
160
+ className={wide ? 'dww-action dww-action--wide' : 'dww-action dww-action--rail'}
161
+ title={t('action.title')}
162
+ aria-label={t('action.title')}
163
+ onClick={() => setOpen(true)}
164
+ >
165
+ <span className="dww-letter" aria-hidden="true">W</span>
166
+ </button>
167
+ )
168
+ }
169
+
170
+ const onDrill = (name: string): void => {
171
+ const next = dirChildPath(listing?.path ?? browsePath, name)
172
+ setPathInput(next)
173
+ void refreshBrowse(next, distro)
174
+ }
175
+
176
+ const onUp = (): void => {
177
+ const parent = listing?.parent ?? null
178
+ if (parent === null) return
179
+ setPathInput(parent)
180
+ void refreshBrowse(parent, distro)
181
+ }
182
+
183
+ const onDistroChange = (value: string): void => {
184
+ setDistro(value)
185
+ void refreshBrowse(browsePath, value)
186
+ }
187
+
188
+ const onCheck = async (): Promise<void> => {
189
+ const path = normalizeLinuxPath(pathInput)
190
+ setError(null)
191
+ if (!isAbsoluteLinuxPath(path) || path === '/') {
192
+ setError(t('error.invalidPath'))
193
+ return
194
+ }
195
+ let facts: WslPathCheck
196
+ try {
197
+ facts = await check(distro, path)
198
+ } catch {
199
+ setError(t('error.pathNotFound'))
200
+ return
201
+ }
202
+ if (!facts.exists || !facts.isDirectory) {
203
+ setError(t('error.pathNotFound'))
204
+ return
205
+ }
206
+ void refreshBrowse(path, distro)
207
+ }
208
+
209
+ const onConfirm = async (): Promise<void> => {
210
+ const path = normalizeLinuxPath(pathInput)
211
+ setError(null)
212
+ if (!isAbsoluteLinuxPath(path) || path === '/') {
213
+ // A workspace at the distribution root would make every session start
214
+ // at `/`; the check flow rejects it and confirm must agree.
215
+ setError(t('error.invalidPath'))
216
+ return
217
+ }
218
+ const user = username.trim()
219
+ if (user !== '' && !isValidWslUsername(user)) {
220
+ setError(t('error.invalidUsername'))
221
+ return
222
+ }
223
+ setBusy(true)
224
+ try {
225
+ let facts: WslPathCheck
226
+ try {
227
+ facts = await check(distro, path)
228
+ } catch {
229
+ setError(t('error.pathNotFound'))
230
+ return
231
+ }
232
+ if (!facts.exists || !facts.isDirectory) {
233
+ setError(t('error.pathNotFound'))
234
+ return
235
+ }
236
+ const failure = await createWorkspace(joinUnc(distro, path), user)
237
+ if (failure !== undefined) {
238
+ setError(failure)
239
+ return
240
+ }
241
+ setOpen(false)
242
+ } finally {
243
+ setBusy(false)
244
+ }
245
+ }
246
+
247
+ const children = (listing?.entries.filter(entry => entry.kind === 'directory') ?? []).map(entry => entry.name)
248
+ const maskClick = (): void => { if (!busy) setOpen(false) }
249
+ const listScroll: UIEventHandler<HTMLDivElement> = () => { /* scroll container handles overflow */ }
250
+
251
+ return (
252
+ <div className="dww-overlay">
253
+ <div className="dww-overlay-mask" onClick={maskClick} />
254
+ <div className="dww-card" role="dialog" aria-modal="true" aria-label={t('dialog.title')}>
255
+ <div className="dww-header">
256
+ <h2 className="dww-title">{t('dialog.title')}</h2>
257
+ <button type="button" className="dww-close" aria-label={t('dialog.cancel')} onClick={maskClick}>
258
+ ✕
259
+ </button>
260
+ </div>
261
+ <div className="dww-body">
262
+ {error !== null ? (
263
+ <div className="dww-error">
264
+ {error}
265
+ <button type="button" className="dww-retry" onClick={() => setError(null)}>{t('dialog.retry')}</button>
266
+ </div>
267
+ ) : null}
268
+ <div className="dww-field">
269
+ <label className="dww-field-label" htmlFor="dww-distro">{t('dialog.distro')}</label>
270
+ <select
271
+ id="dww-distro"
272
+ className="dww-select"
273
+ value={distro}
274
+ disabled={opening || busy}
275
+ onChange={event => onDistroChange(event.target.value)}
276
+ >
277
+ {distros.length === 0
278
+ ? <option value="">{opening ? t('dialog.loading') : ''}</option>
279
+ : distros.map(name => <option key={name} value={name}>{name}</option>)}
280
+ </select>
281
+ </div>
282
+ <div className="dww-field">
283
+ <label className="dww-field-label" htmlFor="dww-path">{t('dialog.path')}</label>
284
+ <div className="dww-input-row">
285
+ <input
286
+ id="dww-path"
287
+ className="dww-input"
288
+ value={pathInput}
289
+ placeholder={t('dialog.pathPlaceholder')}
290
+ disabled={opening || busy}
291
+ onChange={event => setPathInput(event.target.value)}
292
+ />
293
+ <button type="button" className="dww-check-btn" disabled={opening || busy} onClick={() => void onCheck()}>
294
+ {t('dialog.check')}
295
+ </button>
296
+ </div>
297
+ </div>
298
+ <div className="dww-field">
299
+ <label className="dww-field-label" htmlFor="dww-username">{t('dialog.username')}</label>
300
+ <input
301
+ id="dww-username"
302
+ className="dww-input"
303
+ value={username}
304
+ placeholder={t('dialog.usernamePlaceholder')}
305
+ disabled={opening || busy}
306
+ autoComplete="off"
307
+ spellCheck={false}
308
+ onChange={event => setUsername(event.target.value)}
309
+ />
310
+ </div>
311
+ <div className="dww-feedback">
312
+ <div className="dww-breadcrumb">{browsePath}</div>
313
+ <div className="dww-dirlist" onScroll={listScroll}>
314
+ {browsing ? <div className="dww-dir-empty">{t('dialog.loading')}</div> : (
315
+ listing?.parent !== null && listing !== null
316
+ ? (
317
+ <button type="button" className="dww-dir-row dww-dir-row--up" onClick={onUp}>
318
+ <WslGlyph size={14} />
319
+ <span>{t('dialog.upLevel')}</span>
320
+ </button>
321
+ )
322
+ : null
323
+ )}
324
+ {!browsing && (children.length === 0)
325
+ ? <div className="dww-dir-empty">{t('dialog.browseEmpty')}</div>
326
+ : children.map(name => (
327
+ <button type="button" key={name} className="dww-dir-row" onClick={() => onDrill(name)}>
328
+ <WslGlyph size={14} />
329
+ <span>{name}</span>
330
+ </button>
331
+ ))}
332
+ </div>
333
+ </div>
334
+ </div>
335
+ <div className="dww-actions">
336
+ <button type="button" className="dww-btn" disabled={busy} onClick={maskClick}>{t('dialog.cancel')}</button>
337
+ <button type="button" className="dww-btn dww-btn--primary" disabled={busy || opening} onClick={() => void onConfirm()}>
338
+ {busy ? t('dialog.loading') : t('dialog.confirm')}
339
+ </button>
340
+ </div>
341
+ </div>
342
+ </div>
343
+ )
344
+ }
345
+
346
+ export type { ApiCall }