dsh-wsl-workspace 0.4.2 → 0.4.3

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.
@@ -1,349 +1,375 @@
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, 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 WSL workspace and start a session in it. `/mnt/<drive>`
36
- * paths register under their Windows drive spelling (9P cannot serve
37
- * drvfs); ext4 paths register as `\\wsl.localhost\<distro>\...` UNC.
38
- * @param linuxPath - the absolute Linux workspace path.
39
- * @param username - optional Linux user for the session (empty = distro default).
40
- * @param distro - the WSL distribution the path belongs to.
41
- * @returns undefined on success, else a message to show.
42
- */
43
- createWorkspace(linuxPath: string, username: string, distro: string): Promise<string | undefined>
44
- /** Translate a `wslWorkspace` dictionary key (follows the DeepSeek Harness locale). */
45
- t: (key: string, params?: Record<string, unknown>) => string
46
- }
47
-
48
- /** Full component props: the owner share plus the injected face. */
49
- export interface AddWslWorkspaceProps extends AddWslWorkspaceInjected {
50
- /** Whether the sidebar renders wide content (false = 56px rail). */
51
- wide: boolean
52
- }
53
-
54
- /**
55
- * Build the Linux child path one level below a parent, for the breadcrumb/
56
- * browse drill.
57
- * @param parent - the currently listed absolute path (`/` for root).
58
- * @param name - the child directory name.
59
- * @returns the child's absolute Linux path.
60
- */
61
- export function dirChildPath(parent: string, name: string): string {
62
- return parent === '/' ? `/${name}` : `${parent}/${name}`
63
- }
64
-
65
- /** A tiny inline terminal glyph for the dialog's directory rows. */
66
- function WslGlyph({ size = 16 }: { size?: number }): React.ReactElement {
67
- return (
68
- <svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
69
- <rect x="2.5" y="4.5" width="19" height="15" rx="2.5" stroke="currentColor" strokeWidth="1.6" />
70
- <path d="M6 9l3.2 2.6L6 14" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
71
- <path d="M12 14h5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
72
- </svg>
73
- )
74
- }
75
-
76
- /**
77
- * The "Add WSL workspace…" footer action and its dialog.
78
- * @param props - owner share + injected face.
79
- */
80
- export function AddWslWorkspace({ wide, t, checkPreset, listDistros, listDir, check, createWorkspace }: AddWslWorkspaceProps): React.ReactElement | null {
81
- const [open, setOpen] = useState(false)
82
- const [opening, setOpening] = useState(false)
83
- const [distros, setDistros] = useState<string[]>([])
84
- const [distro, setDistro] = useState('')
85
- const [pathInput, setPathInput] = useState('/home/')
86
- const [username, setUsername] = useState('')
87
- const [listing, setListing] = useState<WslDirListing | null>(null)
88
- const [browsePath, setBrowsePath] = useState('/')
89
- const [browsing, setBrowsing] = useState(false)
90
- const [error, setError] = useState<string | null>(null)
91
- const [busy, setBusy] = useState(false)
92
- // Monotone browse-request sequence: stale responses for a superseded browse are dropped.
93
- const browseSeq = useRef(0)
94
-
95
- const refreshBrowse = async (root: string, targetDistro: string): Promise<void> => {
96
- const seq = ++browseSeq.current
97
- setBrowsing(true)
98
- setBrowsePath(root)
99
- try {
100
- const value = await listDir(targetDistro, root)
101
- if (seq === browseSeq.current) setListing(value)
102
- } catch {
103
- // A failed browse (permission, missing dir) is non-fatal: keep old listing.
104
- if (seq === browseSeq.current) {
105
- setListing(null)
106
- setError((previous) => previous ?? t('error.loadDir'))
107
- }
108
- } finally {
109
- if (seq === browseSeq.current) setBrowsing(false)
110
- }
111
- }
112
-
113
- useEffect(() => {
114
- if (!open) return
115
- let cancelled = false
116
- setError(null)
117
- setOpening(true)
118
- void (async () => {
119
- let presetIssue: string | undefined
120
- try {
121
- presetIssue = await checkPreset()
122
- } catch {
123
- presetIssue = t('error.loadDistros')
124
- }
125
- let names: string[]
126
- try {
127
- names = await listDistros()
128
- } catch {
129
- if (cancelled) return
130
- setOpening(false)
131
- setError(t('error.loadDistros'))
132
- return
133
- }
134
- if (cancelled) return
135
- setDistros(names)
136
- const first = names[0] ?? ''
137
- setDistro(first)
138
- // The default browse root walks from `/`; the input defaults to `/home/`.
139
- setBrowsing(true)
140
- setOpening(false)
141
- if (presetIssue !== undefined) setError(presetIssue)
142
- if (first !== '') void refreshBrowse('/', first)
143
- })()
144
- return () => { cancelled = true }
145
- // eslint-disable-next-line react-hooks/exhaustive-deps -- run once per open against current t.
146
- }, [open])
147
-
148
- useEffect(() => {
149
- if (!open) return
150
- const onKey = (event: KeyboardEvent): void => {
151
- if (event.key === 'Escape' && !busy) setOpen(false)
152
- }
153
- window.addEventListener('keydown', onKey)
154
- return () => window.removeEventListener('keydown', onKey)
155
- }, [open, busy])
156
-
157
- if (!open) {
158
- // A W-letter action beside Settings at the sidebar foot; the title
159
- // carries the label in both wide and rail states.
160
- return (
161
- <button
162
- type="button"
163
- className={wide ? 'dww-action dww-action--wide' : 'dww-action dww-action--rail'}
164
- title={t('action.title')}
165
- aria-label={t('action.title')}
166
- onClick={() => setOpen(true)}
167
- >
168
- <span className="dww-letter" aria-hidden="true">W</span>
169
- </button>
170
- )
171
- }
172
-
173
- const onDrill = (name: string): void => {
174
- const next = dirChildPath(listing?.path ?? browsePath, name)
175
- setPathInput(next)
176
- void refreshBrowse(next, distro)
177
- }
178
-
179
- const onUp = (): void => {
180
- const parent = listing?.parent ?? null
181
- if (parent === null) return
182
- setPathInput(parent)
183
- void refreshBrowse(parent, distro)
184
- }
185
-
186
- const onDistroChange = (value: string): void => {
187
- setDistro(value)
188
- void refreshBrowse(browsePath, value)
189
- }
190
-
191
- const onCheck = async (): Promise<void> => {
192
- const path = normalizeLinuxPath(pathInput)
193
- setError(null)
194
- if (!isAbsoluteLinuxPath(path) || path === '/') {
195
- setError(t('error.invalidPath'))
196
- return
197
- }
198
- let facts: WslPathCheck
199
- try {
200
- facts = await check(distro, path)
201
- } catch {
202
- setError(t('error.pathNotFound'))
203
- return
204
- }
205
- if (!facts.exists || !facts.isDirectory) {
206
- setError(t('error.pathNotFound'))
207
- return
208
- }
209
- void refreshBrowse(path, distro)
210
- }
211
-
212
- const onConfirm = async (): Promise<void> => {
213
- const path = normalizeLinuxPath(pathInput)
214
- setError(null)
215
- if (!isAbsoluteLinuxPath(path) || path === '/') {
216
- // A workspace at the distribution root would make every session start
217
- // at `/`; the check flow rejects it and confirm must agree.
218
- setError(t('error.invalidPath'))
219
- return
220
- }
221
- const user = username.trim()
222
- if (user !== '' && !isValidWslUsername(user)) {
223
- setError(t('error.invalidUsername'))
224
- return
225
- }
226
- setBusy(true)
227
- try {
228
- let facts: WslPathCheck
229
- try {
230
- facts = await check(distro, path)
231
- } catch {
232
- setError(t('error.pathNotFound'))
233
- return
234
- }
235
- if (!facts.exists || !facts.isDirectory) {
236
- setError(t('error.pathNotFound'))
237
- return
238
- }
239
- const failure = await createWorkspace(path, user, distro)
240
- if (failure !== undefined) {
241
- setError(failure)
242
- return
243
- }
244
- setOpen(false)
245
- } finally {
246
- setBusy(false)
247
- }
248
- }
249
-
250
- const children = (listing?.entries.filter(entry => entry.kind === 'directory') ?? []).map(entry => entry.name)
251
- const maskClick = (): void => { if (!busy) setOpen(false) }
252
- const listScroll: UIEventHandler<HTMLDivElement> = () => { /* scroll container handles overflow */ }
253
-
254
- return (
255
- <div className="dww-overlay">
256
- <div className="dww-overlay-mask" onClick={maskClick} />
257
- <div className="dww-card" role="dialog" aria-modal="true" aria-label={t('dialog.title')}>
258
- <div className="dww-header">
259
- <h2 className="dww-title">{t('dialog.title')}</h2>
260
- <button type="button" className="dww-close" aria-label={t('dialog.cancel')} onClick={maskClick}>
261
- ✕
262
- </button>
263
- </div>
264
- <div className="dww-body">
265
- {error !== null ? (
266
- <div className="dww-error">
267
- {error}
268
- <button type="button" className="dww-retry" onClick={() => setError(null)}>{t('dialog.retry')}</button>
269
- </div>
270
- ) : null}
271
- <div className="dww-field">
272
- <label className="dww-field-label" htmlFor="dww-distro">{t('dialog.distro')}</label>
273
- <select
274
- id="dww-distro"
275
- className="dww-select"
276
- value={distro}
277
- disabled={opening || busy}
278
- onChange={event => onDistroChange(event.target.value)}
279
- >
280
- {distros.length === 0
281
- ? <option value="">{opening ? t('dialog.loading') : ''}</option>
282
- : distros.map(name => <option key={name} value={name}>{name}</option>)}
283
- </select>
284
- </div>
285
- <div className="dww-field">
286
- <label className="dww-field-label" htmlFor="dww-path">{t('dialog.path')}</label>
287
- <div className="dww-input-row">
288
- <input
289
- id="dww-path"
290
- className="dww-input"
291
- value={pathInput}
292
- placeholder={t('dialog.pathPlaceholder')}
293
- disabled={opening || busy}
294
- onChange={event => setPathInput(event.target.value)}
295
- />
296
- <button type="button" className="dww-check-btn" disabled={opening || busy} onClick={() => void onCheck()}>
297
- {t('dialog.check')}
298
- </button>
299
- </div>
300
- </div>
301
- <div className="dww-field">
302
- <label className="dww-field-label" htmlFor="dww-username">{t('dialog.username')}</label>
303
- <input
304
- id="dww-username"
305
- className="dww-input"
306
- value={username}
307
- placeholder={t('dialog.usernamePlaceholder')}
308
- disabled={opening || busy}
309
- autoComplete="off"
310
- spellCheck={false}
311
- onChange={event => setUsername(event.target.value)}
312
- />
313
- </div>
314
- <div className="dww-feedback">
315
- <div className="dww-breadcrumb">{browsePath}</div>
316
- <div className="dww-dirlist" onScroll={listScroll}>
317
- {browsing ? <div className="dww-dir-empty">{t('dialog.loading')}</div> : (
318
- listing?.parent !== null && listing !== null
319
- ? (
320
- <button type="button" className="dww-dir-row dww-dir-row--up" onClick={onUp}>
321
- <WslGlyph size={14} />
322
- <span>{t('dialog.upLevel')}</span>
323
- </button>
324
- )
325
- : null
326
- )}
327
- {!browsing && (children.length === 0)
328
- ? <div className="dww-dir-empty">{t('dialog.browseEmpty')}</div>
329
- : children.map(name => (
330
- <button type="button" key={name} className="dww-dir-row" onClick={() => onDrill(name)}>
331
- <WslGlyph size={14} />
332
- <span>{name}</span>
333
- </button>
334
- ))}
335
- </div>
336
- </div>
337
- </div>
338
- <div className="dww-actions">
339
- <button type="button" className="dww-btn" disabled={busy} onClick={maskClick}>{t('dialog.cancel')}</button>
340
- <button type="button" className="dww-btn dww-btn--primary" disabled={busy || opening} onClick={() => void onConfirm()}>
341
- {busy ? t('dialog.loading') : t('dialog.confirm')}
342
- </button>
343
- </div>
344
- </div>
345
- </div>
346
- )
347
- }
348
-
349
- export type { ApiCall }
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, normalizeLinuxPath } from '../shared/paths.ts'
14
+ import type { WslDirListing, WslPathCheck, WslSelfDescription } from './api.ts'
15
+ import { WslHelp } from './help.tsx'
16
+
17
+ /** Result-level shape of a browse/check/list call we surface uniformly. */
18
+ interface ApiCall<T> {
19
+ value: T
20
+ }
21
+
22
+ /** The inject face: plain data + callbacks the dialog drives. */
23
+ export interface AddWslWorkspaceInjected {
24
+ /**
25
+ * Confirm the deployment exposes a healthy `wsl` preset.
26
+ * @returns undefined when healthy, else a Chinese/English message to show.
27
+ */
28
+ checkPreset(): Promise<string | undefined>
29
+ /**
30
+ * Read this build's version and its declared DSH compatibility matrix,
31
+ * for the help panel. A host that cannot answer resolves to a fallback
32
+ * description rather than failing the dialog.
33
+ */
34
+ describe(): Promise<WslSelfDescription>
35
+ /** List the WSL distros installed on the host. */
36
+ listDistros(): Promise<string[]>
37
+ /** List one Linux directory level inside a distro. */
38
+ listDir(distro: string, path: string): Promise<WslDirListing>
39
+ /** Check a Linux path's existence/directory facts. */
40
+ check(distro: string, path: string): Promise<WslPathCheck>
41
+ /**
42
+ * Register a WSL workspace and start a session in it. `/mnt/<drive>`
43
+ * paths register under their Windows drive spelling (9P cannot serve
44
+ * drvfs); ext4 paths register as `\\wsl.localhost\<distro>\...` UNC.
45
+ * @param linuxPath - the absolute Linux workspace path.
46
+ * @param username - optional Linux user for the session (empty = distro default).
47
+ * @param distro - the WSL distribution the path belongs to.
48
+ * @returns undefined on success, else a message to show.
49
+ */
50
+ createWorkspace(linuxPath: string, username: string, distro: string): Promise<string | undefined>
51
+ /** Translate a `wslWorkspace` dictionary key (follows the DeepSeek Harness locale). */
52
+ t: (key: string, params?: Record<string, unknown>) => string
53
+ }
54
+
55
+ /** Full component props: the owner share plus the injected face. */
56
+ export interface AddWslWorkspaceProps extends AddWslWorkspaceInjected {
57
+ /** Whether the sidebar renders wide content (false = 56px rail). */
58
+ wide: boolean
59
+ }
60
+
61
+ /**
62
+ * Build the Linux child path one level below a parent, for the breadcrumb/
63
+ * browse drill.
64
+ * @param parent - the currently listed absolute path (`/` for root).
65
+ * @param name - the child directory name.
66
+ * @returns the child's absolute Linux path.
67
+ */
68
+ export function dirChildPath(parent: string, name: string): string {
69
+ return parent === '/' ? `/${name}` : `${parent}/${name}`
70
+ }
71
+
72
+ /** A tiny inline terminal glyph for the dialog's directory rows. */
73
+ function WslGlyph({ size = 16 }: { size?: number }): React.ReactElement {
74
+ return (
75
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
76
+ <rect x="2.5" y="4.5" width="19" height="15" rx="2.5" stroke="currentColor" strokeWidth="1.6" />
77
+ <path d="M6 9l3.2 2.6L6 14" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
78
+ <path d="M12 14h5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
79
+ </svg>
80
+ )
81
+ }
82
+
83
+ /**
84
+ * The "Add WSL workspace…" footer action and its dialog.
85
+ * @param props - owner share + injected face.
86
+ */
87
+ export function AddWslWorkspace({ wide, t, describe, checkPreset, listDistros, listDir, check, createWorkspace }: AddWslWorkspaceProps): React.ReactElement | null {
88
+ const [open, setOpen] = useState(false)
89
+ const [opening, setOpening] = useState(false)
90
+ const [distros, setDistros] = useState<string[]>([])
91
+ const [distro, setDistro] = useState('')
92
+ const [pathInput, setPathInput] = useState('/home/')
93
+ const [username, setUsername] = useState('')
94
+ const [listing, setListing] = useState<WslDirListing | null>(null)
95
+ const [browsePath, setBrowsePath] = useState('/')
96
+ const [browsing, setBrowsing] = useState(false)
97
+ const [error, setError] = useState<string | null>(null)
98
+ const [busy, setBusy] = useState(false)
99
+ // The "?" panel: help text plus the compatibility list the host reports.
100
+ const [helpOpen, setHelpOpen] = useState(false)
101
+ const [selfDescription, setSelfDescription] = useState<WslSelfDescription | null>(null)
102
+ // Monotone browse-request sequence: stale responses for a superseded browse are dropped.
103
+ const browseSeq = useRef(0)
104
+
105
+ const refreshBrowse = async (root: string, targetDistro: string): Promise<void> => {
106
+ const seq = ++browseSeq.current
107
+ setBrowsing(true)
108
+ setBrowsePath(root)
109
+ try {
110
+ const value = await listDir(targetDistro, root)
111
+ if (seq === browseSeq.current) setListing(value)
112
+ } catch {
113
+ // A failed browse (permission, missing dir) is non-fatal: keep old listing.
114
+ if (seq === browseSeq.current) {
115
+ setListing(null)
116
+ setError((previous) => previous ?? t('error.loadDir'))
117
+ }
118
+ } finally {
119
+ if (seq === browseSeq.current) setBrowsing(false)
120
+ }
121
+ }
122
+
123
+ useEffect(() => {
124
+ if (!open) return
125
+ let cancelled = false
126
+ setError(null)
127
+ // Advisory data for the help panel: never fatal, never blocking the form.
128
+ void describe().then(value => { if (!cancelled) setSelfDescription(value) }).catch(() => { if (!cancelled) setSelfDescription(null) })
129
+ setOpening(true)
130
+ void (async () => {
131
+ let presetIssue: string | undefined
132
+ try {
133
+ presetIssue = await checkPreset()
134
+ } catch {
135
+ presetIssue = t('error.loadDistros')
136
+ }
137
+ let names: string[]
138
+ try {
139
+ names = await listDistros()
140
+ } catch {
141
+ if (cancelled) return
142
+ setOpening(false)
143
+ setError(t('error.loadDistros'))
144
+ return
145
+ }
146
+ if (cancelled) return
147
+ setDistros(names)
148
+ const first = names[0] ?? ''
149
+ setDistro(first)
150
+ // The default browse root walks from `/`; the input defaults to `/home/`.
151
+ setBrowsing(true)
152
+ setOpening(false)
153
+ if (presetIssue !== undefined) setError(presetIssue)
154
+ if (first !== '') void refreshBrowse('/', first)
155
+ })()
156
+ return () => { cancelled = true }
157
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- run once per open against current t.
158
+ }, [open])
159
+
160
+ useEffect(() => {
161
+ if (!open) return
162
+ const onKey = (event: KeyboardEvent): void => {
163
+ if (event.key === 'Escape' && !busy) setOpen(false)
164
+ }
165
+ window.addEventListener('keydown', onKey)
166
+ return () => window.removeEventListener('keydown', onKey)
167
+ }, [open, busy])
168
+
169
+ if (!open) {
170
+ // A W-letter action beside Settings at the sidebar foot; the title
171
+ // carries the label in both wide and rail states.
172
+ return (
173
+ <button
174
+ type="button"
175
+ className={wide ? 'dww-action dww-action--wide' : 'dww-action dww-action--rail'}
176
+ title={t('action.title')}
177
+ aria-label={t('action.title')}
178
+ onClick={() => setOpen(true)}
179
+ >
180
+ <span className="dww-letter" aria-hidden="true">W</span>
181
+ </button>
182
+ )
183
+ }
184
+
185
+ const onDrill = (name: string): void => {
186
+ const next = dirChildPath(listing?.path ?? browsePath, name)
187
+ setPathInput(next)
188
+ void refreshBrowse(next, distro)
189
+ }
190
+
191
+ const onUp = (): void => {
192
+ const parent = listing?.parent ?? null
193
+ if (parent === null) return
194
+ setPathInput(parent)
195
+ void refreshBrowse(parent, distro)
196
+ }
197
+
198
+ const onDistroChange = (value: string): void => {
199
+ setDistro(value)
200
+ void refreshBrowse(browsePath, value)
201
+ }
202
+
203
+ const onCheck = async (): Promise<void> => {
204
+ const path = normalizeLinuxPath(pathInput)
205
+ setError(null)
206
+ if (!isAbsoluteLinuxPath(path) || path === '/') {
207
+ setError(t('error.invalidPath'))
208
+ return
209
+ }
210
+ let facts: WslPathCheck
211
+ try {
212
+ facts = await check(distro, path)
213
+ } catch {
214
+ setError(t('error.pathNotFound'))
215
+ return
216
+ }
217
+ if (!facts.exists || !facts.isDirectory) {
218
+ setError(t('error.pathNotFound'))
219
+ return
220
+ }
221
+ void refreshBrowse(path, distro)
222
+ }
223
+
224
+ const onConfirm = async (): Promise<void> => {
225
+ const path = normalizeLinuxPath(pathInput)
226
+ setError(null)
227
+ if (!isAbsoluteLinuxPath(path) || path === '/') {
228
+ // A workspace at the distribution root would make every session start
229
+ // at `/`; the check flow rejects it and confirm must agree.
230
+ setError(t('error.invalidPath'))
231
+ return
232
+ }
233
+ const user = username.trim()
234
+ if (user !== '' && !isValidWslUsername(user)) {
235
+ setError(t('error.invalidUsername'))
236
+ return
237
+ }
238
+ setBusy(true)
239
+ try {
240
+ let facts: WslPathCheck
241
+ try {
242
+ facts = await check(distro, path)
243
+ } catch {
244
+ setError(t('error.pathNotFound'))
245
+ return
246
+ }
247
+ if (!facts.exists || !facts.isDirectory) {
248
+ setError(t('error.pathNotFound'))
249
+ return
250
+ }
251
+ const failure = await createWorkspace(path, user, distro)
252
+ if (failure !== undefined) {
253
+ setError(failure)
254
+ return
255
+ }
256
+ setOpen(false)
257
+ } finally {
258
+ setBusy(false)
259
+ }
260
+ }
261
+
262
+ const children = (listing?.entries.filter(entry => entry.kind === 'directory') ?? []).map(entry => entry.name)
263
+ const maskClick = (): void => { if (!busy) setOpen(false) }
264
+ const listScroll: UIEventHandler<HTMLDivElement> = () => { /* scroll container handles overflow */ }
265
+
266
+ return (
267
+ <div className="dww-overlay">
268
+ <div className="dww-overlay-mask" onClick={maskClick} />
269
+ <div className="dww-card" role="dialog" aria-modal="true" aria-label={t('dialog.title')}>
270
+ <div className="dww-header">
271
+ <h2 className="dww-title">{t('dialog.title')}</h2>
272
+ <button
273
+ type="button"
274
+ className="dww-help-btn"
275
+ title={t('help.button')}
276
+ aria-label={t('help.button')}
277
+ aria-pressed={helpOpen}
278
+ onClick={() => setHelpOpen(value => !value)}
279
+ >
280
+ ?
281
+ </button>
282
+ <button type="button" className="dww-close" aria-label={t('dialog.cancel')} onClick={maskClick}>
283
+ ✕
284
+ </button>
285
+ </div>
286
+ <div className="dww-body">
287
+ {helpOpen ? (
288
+ <WslHelp t={t} description={selfDescription} />
289
+ ) : (<>
290
+ {error !== null ? (
291
+ <div className="dww-error">
292
+ {error}
293
+ <button type="button" className="dww-retry" onClick={() => setError(null)}>{t('dialog.retry')}</button>
294
+ </div>
295
+ ) : null}
296
+ <div className="dww-field">
297
+ <label className="dww-field-label" htmlFor="dww-distro">{t('dialog.distro')}</label>
298
+ <select
299
+ id="dww-distro"
300
+ className="dww-select"
301
+ value={distro}
302
+ disabled={opening || busy}
303
+ onChange={event => onDistroChange(event.target.value)}
304
+ >
305
+ {distros.length === 0
306
+ ? <option value="">{opening ? t('dialog.loading') : ''}</option>
307
+ : distros.map(name => <option key={name} value={name}>{name}</option>)}
308
+ </select>
309
+ </div>
310
+ <div className="dww-field">
311
+ <label className="dww-field-label" htmlFor="dww-path">{t('dialog.path')}</label>
312
+ <div className="dww-input-row">
313
+ <input
314
+ id="dww-path"
315
+ className="dww-input"
316
+ value={pathInput}
317
+ placeholder={t('dialog.pathPlaceholder')}
318
+ disabled={opening || busy}
319
+ onChange={event => setPathInput(event.target.value)}
320
+ />
321
+ <button type="button" className="dww-check-btn" disabled={opening || busy} onClick={() => void onCheck()}>
322
+ {t('dialog.check')}
323
+ </button>
324
+ </div>
325
+ </div>
326
+ <div className="dww-field">
327
+ <label className="dww-field-label" htmlFor="dww-username">{t('dialog.username')}</label>
328
+ <input
329
+ id="dww-username"
330
+ className="dww-input"
331
+ value={username}
332
+ placeholder={t('dialog.usernamePlaceholder')}
333
+ disabled={opening || busy}
334
+ autoComplete="off"
335
+ spellCheck={false}
336
+ onChange={event => setUsername(event.target.value)}
337
+ />
338
+ </div>
339
+ <div className="dww-feedback">
340
+ <div className="dww-breadcrumb">{browsePath}</div>
341
+ <div className="dww-dirlist" onScroll={listScroll}>
342
+ {browsing ? <div className="dww-dir-empty">{t('dialog.loading')}</div> : (
343
+ listing?.parent !== null && listing !== null
344
+ ? (
345
+ <button type="button" className="dww-dir-row dww-dir-row--up" onClick={onUp}>
346
+ <WslGlyph size={14} />
347
+ <span>{t('dialog.upLevel')}</span>
348
+ </button>
349
+ )
350
+ : null
351
+ )}
352
+ {!browsing && (children.length === 0)
353
+ ? <div className="dww-dir-empty">{t('dialog.browseEmpty')}</div>
354
+ : children.map(name => (
355
+ <button type="button" key={name} className="dww-dir-row" onClick={() => onDrill(name)}>
356
+ <WslGlyph size={14} />
357
+ <span>{name}</span>
358
+ </button>
359
+ ))}
360
+ </div>
361
+ </div>
362
+ </>)}
363
+ </div>
364
+ <div className="dww-actions">
365
+ <button type="button" className="dww-btn" disabled={busy} onClick={maskClick}>{t('dialog.cancel')}</button>
366
+ <button type="button" className="dww-btn dww-btn--primary" disabled={busy || opening} onClick={() => void onConfirm()}>
367
+ {busy ? t('dialog.loading') : t('dialog.confirm')}
368
+ </button>
369
+ </div>
370
+ </div>
371
+ </div>
372
+ )
373
+ }
374
+
375
+ export type { ApiCall }