dsh-wsl-workspace 0.2.2 → 0.2.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,346 +1,349 @@
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 (follows the DeepSeek Harness locale). */
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 }
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 }