@chronosun/dsh-wsl 0.0.0-stage → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js ADDED
@@ -0,0 +1,645 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-wsl',
3
+ factory(require) {
4
+ const React = require('react')
5
+ const h = React.createElement
6
+ const { useCallback, useEffect, useRef, useState } = React
7
+
8
+ const base = '/dsh-wsl'
9
+ const text = 'var(--dsw-text-primary, #1f2328)'
10
+ const muted = 'var(--dsw-text-secondary, #6b7280)'
11
+ const line = 'var(--dsw-border-subtle, rgba(128,128,128,.25))'
12
+ const card = 'var(--dsw-surface-raised, rgba(127,127,127,.08))'
13
+ const accent = 'var(--dsw-accent, #2563eb)'
14
+
15
+ async function call(path, init) {
16
+ const response = await fetch(base + path, { headers: { 'content-type': 'application/json' }, ...init })
17
+ const body = await response.json().catch(() => ({}))
18
+ if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`)
19
+ return body
20
+ }
21
+
22
+ // The panel is mounted only after the user opens the WSL page, but the
23
+ // client plugin itself loads at app start. Cache the last known list/status
24
+ // so the tab bar paints immediately instead of waiting for `/distros` and
25
+ // `/state` (each may spawn `wsl.exe`).
26
+ const CACHE_KEY = 'dsh-wsl.panel-cache.v1'
27
+ function readPanelCache() {
28
+ const empty = { distros: [], brokers: [], settings: { users: {}, defaultDistro: '' }, idleDays: 5 }
29
+ try {
30
+ if (typeof localStorage === 'undefined') return empty
31
+ const value = JSON.parse(localStorage.getItem(CACHE_KEY) || 'null')
32
+ if (!value || typeof value !== 'object') return empty
33
+ return {
34
+ distros: Array.isArray(value.distros) ? value.distros : [],
35
+ brokers: Array.isArray(value.brokers) ? value.brokers : [],
36
+ settings: value.settings && typeof value.settings === 'object'
37
+ ? { users: value.settings.users || {}, defaultDistro: value.settings.defaultDistro || '' }
38
+ : empty.settings,
39
+ idleDays: Number.isFinite(Number(value.idleDays)) ? Number(value.idleDays) : 5,
40
+ }
41
+ } catch {
42
+ return empty
43
+ }
44
+ }
45
+ const panelCache = readPanelCache()
46
+ function savePanelCache() {
47
+ try {
48
+ if (typeof localStorage === 'undefined') return
49
+ localStorage.setItem(CACHE_KEY, JSON.stringify(panelCache))
50
+ } catch {}
51
+ }
52
+ let preloadStarted = false
53
+ function preloadPanel() {
54
+ if (preloadStarted || typeof window === 'undefined') return
55
+ preloadStarted = true
56
+ call('/distros').then((data) => {
57
+ if (Array.isArray(data.distros)) {
58
+ panelCache.distros = data.distros
59
+ savePanelCache()
60
+ }
61
+ }).catch(() => {})
62
+ call('/state').then((data) => {
63
+ if (Array.isArray(data.brokers)) panelCache.brokers = data.brokers
64
+ if (data.settings) panelCache.settings = { users: data.settings.users || {}, defaultDistro: data.settings.defaultDistro || '' }
65
+ if (Number.isFinite(Number(data.idleDays))) panelCache.idleDays = Number(data.idleDays)
66
+ savePanelCache()
67
+ }).catch(() => {})
68
+ }
69
+
70
+ const toUnc = (distro, linuxPath) => `\\\\wsl.localhost\\${distro}${linuxPath.replace(/\//g, '\\')}`
71
+ const isWslUnc = (value) => /^[\\/]{2}\??(?:wsl\.localhost|wsl\$)[\\/]/i.test(String(value ?? ''))
72
+
73
+ const dot = (state) => h('span', {
74
+ style: {
75
+ display: 'inline-block', width: 8, height: 8, borderRadius: 4, marginRight: 6, flex: '0 0 auto',
76
+ background: state === 'ready' ? '#22c55e'
77
+ : state === 'starting' ? '#f59e0b'
78
+ : state === 'error' ? '#ef4444' : '#9ca3af',
79
+ },
80
+ })
81
+
82
+ const btn = {
83
+ fontSize: 12, padding: '4px 10px', borderRadius: 6, cursor: 'pointer',
84
+ border: `1px solid ${line}`, background: 'transparent', color: text,
85
+ }
86
+ const primary = { ...btn, background: '#247bbf', borderColor: '#247bbf', color: '#fff' }
87
+ const input = {
88
+ width: '100%', boxSizing: 'border-box', padding: '6px 9px', borderRadius: 6, fontSize: 12,
89
+ border: `1px solid ${line}`, background: 'transparent', color: text, fontFamily: 'inherit',
90
+ }
91
+ const label = { fontSize: 12, fontWeight: 600, color: muted, textTransform: 'uppercase', letterSpacing: '.04em', margin: '0 0 6px' }
92
+
93
+ function Icon({ size = 20 }) {
94
+ return h('svg', {
95
+ viewBox: '0 0 24 24', width: size, height: size, 'aria-hidden': true,
96
+ style: { display: 'block' }, fill: 'none', stroke: 'currentColor',
97
+ strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round',
98
+ },
99
+ h('path', { d: 'M12 2.5c-2.3 0-4.1 1.9-4.1 4.2 0 1.4.6 2.6 1.6 3.4-1.1 1.2-1.9 2.9-2.2 4.7-.4 2.5.1 4.6 1.8 5.5 1.6.9 4.3.9 5.9 0 1.7-.9 2.2-3 1.8-5.5-.3-1.8-1.1-3.5-2.2-4.7 1-.8 1.6-2 1.6-3.4 0-2.3-1.8-4.2-4.2-4.2Z', fill: '#ffffff' }),
100
+ h('path', { d: 'M7.4 11.3c-1.5 1-2.4 2.7-2.3 4.5.1 1 .6 1.7 1.3 2.1' }),
101
+ h('path', { d: 'M16.6 11.3c1.5 1 2.4 2.7 2.3 4.5-.1 1-.6 1.7-1.3 2.1' }),
102
+ h('circle', { cx: 10.2, cy: 7.8, r: 0.7, fill: 'currentColor', stroke: 'none' }),
103
+ h('circle', { cx: 13.8, cy: 7.8, r: 0.7, fill: 'currentColor', stroke: 'none' }),
104
+ h('path', { d: 'M10.9 9.1l1.1 1.5 1.1-1.5z', fill: 'currentColor', stroke: 'none' }),
105
+ h('path', { d: 'M8.4 21.2c-.8 0-1.5.3-2 .8M15.6 21.2c.8 0 1.5.3 2 .8' }))
106
+ }
107
+
108
+ /** Browse one distro directory at a time and pick it as a workspace root. */
109
+ function WslFolderDialog({ initialDistro = '', onClose, onCreated, workspaces, uiWorkspace }) {
110
+ const [distros, setDistros] = useState([])
111
+ const [distro, setDistro] = useState(initialDistro)
112
+ const [view, setView] = useState({ path: '', parent: '/', home: '', dirs: [] })
113
+ const [loading, setLoading] = useState(false)
114
+ const [error, setError] = useState('')
115
+
116
+ const browse = useCallback(async (nextDistro, path) => {
117
+ setLoading(true)
118
+ try {
119
+ const query = `?distro=${encodeURIComponent(nextDistro)}&path=${encodeURIComponent(path || '')}`
120
+ const data = await call('/browse' + query)
121
+ setView(data)
122
+ setDistro(nextDistro)
123
+ setError('')
124
+ } catch (e) {
125
+ setError(String(e.message || e))
126
+ } finally {
127
+ setLoading(false)
128
+ }
129
+ }, [])
130
+
131
+ useEffect(() => {
132
+ call('/distros').then((d) => {
133
+ const list = d.distros || []
134
+ setDistros(list)
135
+ const first = list.includes(initialDistro) ? initialDistro : list[0]
136
+ if (first) browse(first, '')
137
+ }).catch((e) => setError(String(e.message || e)))
138
+ }, [browse, initialDistro])
139
+
140
+ const create = async () => {
141
+ setLoading(true)
142
+ try {
143
+ const unc = toUnc(distro || (view.path.split('/')[1] ?? ''), view.path)
144
+ const workspace = await workspaces.create({ path: unc })
145
+ const workspaceId = workspace?.id ?? workspace?.workspaceId ?? workspace?.workspace?.id
146
+ // The workspace row has no icon slot, so the WSL marker lives in the
147
+ // title itself. Renaming right after creation keeps the sidebar
148
+ // list visually distinguishable at a glance.
149
+ const folder = view.path.split('/').filter(Boolean).pop() ?? 'WSL'
150
+ try { if (workspaceId) await workspaces.rename(workspaceId, `🐧 ${folder}`) } catch {}
151
+ onCreated?.({ distro, path: view.path, unc, workspaceId })
152
+ if (workspaceId && uiWorkspace?.connectWorkspace) {
153
+ await uiWorkspace.connectWorkspace(workspaceId, { agentPreset: 'wsl' })
154
+ } else if (workspaceId && uiWorkspace?.startSession) {
155
+ uiWorkspace.startSession(workspaceId, { agentPreset: 'wsl' })
156
+ }
157
+ onClose()
158
+ } catch (e) {
159
+ setError(String(e.message || e))
160
+ } finally {
161
+ setLoading(false)
162
+ }
163
+ }
164
+
165
+ const row = (entry) => h('div', {
166
+ key: entry.path,
167
+ onClick: () => browse(distro, entry.path),
168
+ style: { padding: '6px 8px', borderRadius: 6, cursor: 'pointer', fontSize: 12, color: text },
169
+ onMouseEnter: (e) => { e.currentTarget.style.background = card },
170
+ onMouseLeave: (e) => { e.currentTarget.style.background = 'transparent' },
171
+ }, '📁 ' + entry.name)
172
+
173
+ return h('div', {
174
+ style: {
175
+ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,.35)',
176
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
177
+ },
178
+ onClick: (e) => { if (e.target === e.currentTarget) onClose() },
179
+ },
180
+ h('div', {
181
+ style: {
182
+ width: 460, maxHeight: '70vh', display: 'flex', flexDirection: 'column',
183
+ background: 'var(--dsw-surface-base, #fff)', color: text, borderRadius: 10,
184
+ border: `1px solid ${line}`, padding: 16, gap: 10,
185
+ boxShadow: '0 12px 40px rgba(0,0,0,.3)',
186
+ },
187
+ },
188
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 8 } },
189
+ h(Icon, { size: 16 }),
190
+ h('b', { style: { fontSize: 13 } }, '添加 WSL 工作区'),
191
+ h('span', { style: { flex: 1 } }),
192
+ h('button', { type: 'button', style: btn, onClick: onClose }, '关闭')),
193
+
194
+ h('div', null,
195
+ h('div', { style: label }, '发行版'),
196
+ h('select', {
197
+ value: distro,
198
+ onChange: (e) => browse(e.target.value, ''),
199
+ style: { ...input, padding: '5px 8px' },
200
+ }, distros.map((name) => h('option', { key: name, value: name }, name)))),
201
+
202
+ h('div', null,
203
+ h('div', { style: label }, '目录'),
204
+ h('div', { style: { display: 'flex', gap: 6, alignItems: 'center', marginBottom: 6 } },
205
+ h('button', { type: 'button', style: btn, onClick: () => browse(distro, view.parent) }, '↑'),
206
+ h('button', { type: 'button', style: btn, onClick: () => browse(distro, view.home) }, 'home'),
207
+ h('code', { style: { fontSize: 11, color: muted, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, view.path || '…')),
208
+ h('div', {
209
+ style: { height: 190, overflow: 'auto', border: `1px solid ${line}`, borderRadius: 8, padding: 4 },
210
+ }, loading
211
+ ? h('div', { style: { fontSize: 12, color: muted, padding: 8 } }, '读取中…')
212
+ : view.dirs.length === 0
213
+ ? h('div', { style: { fontSize: 12, color: muted, padding: 8 } }, '(没有子目录,可以直接用当前目录)')
214
+ : view.dirs.map(row))),
215
+
216
+ error ? h('div', { style: { fontSize: 12, color: '#b91c1c' } }, error) : null,
217
+
218
+ h('div', { style: { display: 'flex', justifyContent: 'flex-end', gap: 8 } },
219
+ h('button', { type: 'button', style: btn, onClick: onClose }, '取消'),
220
+ h('button', { type: 'button', style: primary, disabled: loading || !view.path, onClick: create }, '使用此目录'))))
221
+ }
222
+
223
+ /** Dark split button: add a workspace, or pick the distro from the chevron. */
224
+ function AddWorkspaceButton({ distros, defaultDistro, onPick }) {
225
+ const [open, setOpen] = useState(false)
226
+ const rootRef = useRef(null)
227
+ useEffect(() => {
228
+ if (!open) return undefined
229
+ const closeOutside = (event) => {
230
+ if (rootRef.current && !rootRef.current.contains(event.target)) setOpen(false)
231
+ }
232
+ const closeEscape = (event) => { if (event.key === 'Escape') setOpen(false) }
233
+ document.addEventListener('pointerdown', closeOutside)
234
+ document.addEventListener('keydown', closeEscape)
235
+ return () => {
236
+ document.removeEventListener('pointerdown', closeOutside)
237
+ document.removeEventListener('keydown', closeEscape)
238
+ }
239
+ }, [open])
240
+ const plus = h('svg', {
241
+ viewBox: '0 0 16 16', width: 14, height: 14, fill: 'none', stroke: 'currentColor',
242
+ strokeWidth: 1.6, strokeLinecap: 'round', 'aria-hidden': true,
243
+ }, h('path', { d: 'M8 3v10M3 8h10' }))
244
+ const chevron = h('svg', {
245
+ viewBox: '0 0 16 16', width: 13, height: 13, fill: 'none', stroke: 'currentColor',
246
+ strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true,
247
+ }, h('path', { d: 'M4 6l4 4 4-4' }))
248
+ const choose = (distro) => {
249
+ setOpen(false)
250
+ onPick(distro || defaultDistro || distros[0] || '')
251
+ }
252
+ return h('div', { ref: rootRef, style: { position: 'relative', display: 'inline-flex' } },
253
+ h('div', {
254
+ style: {
255
+ display: 'inline-flex', alignItems: 'stretch', height: 32, borderRadius: 999,
256
+ background: text, color: 'var(--dsw-surface-base, #fff)',
257
+ boxShadow: '0 1px 2px rgba(0,0,0,.12)',
258
+ },
259
+ },
260
+ h('button', {
261
+ type: 'button',
262
+ onClick: () => choose(defaultDistro),
263
+ style: {
264
+ border: 0, background: 'transparent', color: 'inherit', cursor: 'pointer',
265
+ display: 'inline-flex', alignItems: 'center', gap: 7,
266
+ padding: '0 12px 0 14px', borderRadius: '999px 0 0 999px',
267
+ fontSize: 12, fontWeight: 500, whiteSpace: 'nowrap',
268
+ },
269
+ }, plus, '添加 WSL 工作区'),
270
+ h('span', { style: { width: 1, margin: '6px 0', background: 'currentColor', opacity: 0.28 } }),
271
+ h('button', {
272
+ type: 'button',
273
+ 'aria-label': '选择发行版',
274
+ 'aria-haspopup': 'menu',
275
+ 'aria-expanded': open,
276
+ onClick: () => setOpen((value) => !value),
277
+ style: {
278
+ border: 0, background: 'transparent', color: 'inherit', cursor: 'pointer',
279
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
280
+ padding: '0 10px', borderRadius: '0 999px 999px 0',
281
+ },
282
+ }, chevron)),
283
+ open ? h('div', {
284
+ style: {
285
+ position: 'absolute', top: 38, right: 0, zIndex: 30, minWidth: 220,
286
+ padding: 6, borderRadius: 9, border: `1px solid ${line}`,
287
+ background: 'var(--dsw-surface-base, #fff)',
288
+ boxShadow: '0 12px 32px rgba(0,0,0,.22)',
289
+ },
290
+ },
291
+ h('div', { style: { padding: '4px 8px 6px', fontSize: 11, color: muted } }, '选择发行版'),
292
+ distros.length === 0
293
+ ? h('div', { style: { padding: '6px 8px', fontSize: 12, color: muted } }, '没有检测到 WSL 发行版')
294
+ : distros.map((distro) => h('button', {
295
+ key: distro,
296
+ type: 'button',
297
+ onClick: () => choose(distro),
298
+ style: {
299
+ display: 'flex', width: '100%', alignItems: 'center', gap: 8,
300
+ border: 0, background: 'transparent', color: text, cursor: 'pointer',
301
+ padding: '8px', borderRadius: 6, fontSize: 12, textAlign: 'left',
302
+ },
303
+ onMouseEnter: (event) => { event.currentTarget.style.background = card },
304
+ onMouseLeave: (event) => { event.currentTarget.style.background = 'transparent' },
305
+ },
306
+ h('span', { style: { flex: 1 } }, distro),
307
+ distro === defaultDistro ? h('span', { style: { fontSize: 11, color: muted } }, '默认') : null))) : null)
308
+ }
309
+
310
+ /** Read-only doctor snapshot plus the guided fixes that need no TTY. */
311
+ function DoctorCard({ doctor, busy, onFix }) {
312
+ const checks = doctor.checks || []
313
+ const wslVersion = (doctor.wsl?.version?.lines || []).join(' · ')
314
+ const network = doctor.network || {}
315
+ return h('div', {
316
+ style: { border: `1px solid ${line}`, borderRadius: 8, padding: 12, marginBottom: 14, background: card },
317
+ },
318
+ h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 10, fontSize: 12, color: muted, marginBottom: 8 } },
319
+ h('b', { style: { color: text } }, '诊断'),
320
+ h('span', null, wslVersion || 'WSL 版本未知'),
321
+ h('span', null, `网络 ${network.networkingMode || '?'}`),
322
+ h('span', null, `localhostForwarding ${network.localhostForwarding || '?'}`),
323
+ h('span', null, doctor.settings?.defaultDistro ? `默认发行版 ${doctor.settings.defaultDistro}` : '默认发行版未设置')),
324
+ ...checks.map((check) => h('div', {
325
+ key: check.distro,
326
+ style: { padding: '8px 0', borderTop: `1px solid ${line}` },
327
+ },
328
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
329
+ dot(check.error ? 'error' : check.root ? 'starting' : 'ready'),
330
+ h('b', { style: { fontSize: 13, color: text } }, check.distro),
331
+ h('span', { style: { fontSize: 12, color: muted } }, `${check.user || '?'}@${check.home || '?'}`),
332
+ h('span', { style: { fontSize: 12, color: check.node?.path ? muted : '#b91c1c' } }, check.node?.version ? `node ${check.node.version}` : 'node 缺失'),
333
+ h('span', { style: { fontSize: 12, color: muted } }, check.git?.version ? 'git ok' : 'git 缺失'),
334
+ h('span', { style: { fontSize: 12, color: muted } }, `SSH keys ${check.keys ?? 0}`),
335
+ h('span', { style: { fontSize: 12, color: check.path?.mntEntries > 0 ? '#b45309' : muted } }, `PATH /mnt ${check.path?.loginMntEntries ?? 0} → ${check.path?.mntEntries ?? 0}`)),
336
+ check.root ? h('div', { style: { marginTop: 4, fontSize: 11, color: '#b45309' } }, '⚠ 当前以 root 运行;建议在下方设置里给该发行版指定普通用户。') : null,
337
+ check.error ? h('div', { style: { marginTop: 4, fontSize: 11, color: '#b91c1c' } }, check.error) : null,
338
+ h('div', { style: { display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 6 } },
339
+ !check.node?.path ? h('button', { type: 'button', style: btn, disabled: busy, onClick: () => onFix(check.distro, 'node') }, busy ? '…' : '安装 Node') : null,
340
+ (check.keys ?? 0) === 0 ? h('button', { type: 'button', style: btn, disabled: busy, onClick: () => onFix(check.distro, 'ssh-key') }, busy ? '…' : '生成 SSH key') : null,
341
+ !check.sshConfig ? h('button', { type: 'button', style: btn, disabled: busy, onClick: () => onFix(check.distro, 'ssh-config') }, busy ? '…' : '写 SSH 配置') : null,
342
+ check.root && !(check.users ?? []).length
343
+ ? h('button', { type: 'button', style: btn, disabled: busy, onClick: () => onFix(check.distro, 'user') }, busy ? '…' : '创建普通用户')
344
+ : null,
345
+ check.node?.path && check.keys > 0 && check.sshConfig ? h('span', { style: { fontSize: 11, color: '#15803d' } }, 'SSH ready') : null))))
346
+ }
347
+
348
+ return {
349
+ inject: ['slots', 'workspaces', 'uiWorkspace'],
350
+ apply(ctx) {
351
+ preloadPanel()
352
+ function Panel() {
353
+ const [distros, setDistros] = useState(panelCache.distros)
354
+ const [state, setState] = useState({ brokers: panelCache.brokers, idleDays: panelCache.idleDays })
355
+ const [busy, setBusy] = useState('')
356
+ const [error, setError] = useState('')
357
+ const [notice, setNotice] = useState('')
358
+ const [picking, setPicking] = useState(false)
359
+ const [pickerDistro, setPickerDistro] = useState('')
360
+ const [settings, setSettings] = useState(panelCache.settings)
361
+ const [selectedDistro, setSelectedDistro] = useState(panelCache.settings.defaultDistro || panelCache.distros[0] || '')
362
+ const [loaded, setLoaded] = useState(panelCache.distros.length > 0)
363
+ const [doctor, setDoctor] = useState(null)
364
+ const [doctorBusy, setDoctorBusy] = useState(false)
365
+ const [doctorMsg, setDoctorMsg] = useState('')
366
+
367
+ const refresh = useCallback(async () => {
368
+ try {
369
+ const [d, s] = await Promise.all([call('/distros'), call('/state')])
370
+ const list = d.distros || []
371
+ const idleDays = s.idleDays ?? s.settings?.idleDays ?? 5
372
+ const nextSettings = s.settings
373
+ ? { users: s.settings.users || {}, defaultDistro: s.settings.defaultDistro || '' }
374
+ : panelCache.settings
375
+ setDistros(list)
376
+ setSelectedDistro((prev) => {
377
+ if (list.includes(prev)) return prev
378
+ const preferred = s.settings?.defaultDistro
379
+ if (preferred && list.includes(preferred)) return preferred
380
+ return list[0] || ''
381
+ })
382
+ setState({ ...s, brokers: s.brokers || [], idleDays })
383
+ setSettings(nextSettings)
384
+ panelCache.distros = list
385
+ panelCache.brokers = s.brokers || []
386
+ panelCache.settings = nextSettings
387
+ panelCache.idleDays = idleDays
388
+ savePanelCache()
389
+ setLoaded(true)
390
+ setError('')
391
+ } catch (e) {
392
+ setError(String(e.message || e))
393
+ }
394
+ }, [])
395
+
396
+ useEffect(() => {
397
+ refresh()
398
+ const timer = setInterval(refresh, 3000)
399
+ return () => clearInterval(timer)
400
+ }, [refresh])
401
+
402
+ const act = async (kind, distro) => {
403
+ setBusy(kind + ':' + distro)
404
+ try {
405
+ await call('/' + kind, { method: 'POST', body: JSON.stringify({ distro }) })
406
+ await refresh()
407
+ } catch (e) {
408
+ setError(String(e.message || e))
409
+ } finally {
410
+ setBusy('')
411
+ }
412
+ }
413
+
414
+ const runDoctor = useCallback(async () => {
415
+ setDoctorBusy(true)
416
+ setDoctorMsg('')
417
+ try {
418
+ const result = await call('/doctor')
419
+ setDoctor(result)
420
+ if (result.settings) setSettings({ users: result.settings.users || {}, defaultDistro: result.settings.defaultDistro || '' })
421
+ } catch (e) {
422
+ setDoctorMsg(String(e.message || e))
423
+ } finally {
424
+ setDoctorBusy(false)
425
+ }
426
+ }, [])
427
+
428
+ const fix = async (distro, action) => {
429
+ setDoctorBusy(true)
430
+ setDoctorMsg(action === 'node' ? `正在为 ${distro} 安装 Node…` : action === 'user' ? `正在为 ${distro} 创建普通用户…` : `正在处理 ${action}…`)
431
+ try {
432
+ const result = await call('/doctor/fix', { method: 'POST', body: JSON.stringify({ distro, action }) })
433
+ if (action === 'ssh-key') setDoctorMsg(`公钥(把它加到设备):\n${result.out || result.err || '(没有输出)'}`)
434
+ else if (action === 'ssh-config') setDoctorMsg(`SSH 配置:\n${result.out || result.err || '(没有输出)'}`)
435
+ else if (action === 'user') setDoctorMsg(`已创建 ${result.name || 'chronos'}:\n${result.out || result.err || '(没有输出)'}`)
436
+ else setDoctorMsg(result.out || result.err || `exit ${result.code}`)
437
+ const next = await call('/doctor')
438
+ setDoctor(next)
439
+ if (next.settings) setSettings({ users: next.settings.users || {}, defaultDistro: next.settings.defaultDistro || '' })
440
+ } catch (e) {
441
+ setDoctorMsg(String(e.message || e))
442
+ } finally {
443
+ setDoctorBusy(false)
444
+ }
445
+ }
446
+
447
+ const saveSettings = async (patch) => {
448
+ const next = {
449
+ users: { ...settings.users, ...(patch.users || {}) },
450
+ defaultDistro: patch.defaultDistro === undefined ? settings.defaultDistro : patch.defaultDistro,
451
+ }
452
+ setSettings(next)
453
+ try {
454
+ const saved = await call('/settings', { method: 'POST', body: JSON.stringify(next) })
455
+ setSettings({ users: saved.users || {}, defaultDistro: saved.defaultDistro || '' })
456
+ setNotice('设置已保存')
457
+ } catch (e) {
458
+ setError(String(e.message || e))
459
+ }
460
+ }
461
+
462
+ const brokerFor = (name) => {
463
+ const candidates = state.brokers.filter((b) => (b.distro || '') === name)
464
+ const user = settings.users[name] ?? ''
465
+ const exact = candidates.filter((b) => (b.user || '') === user)
466
+ return exact.find((b) => b.state === 'ready' || b.state === 'starting')
467
+ ?? exact[0]
468
+ ?? candidates.find((b) => b.state === 'ready' || b.state === 'starting')
469
+ ?? candidates[0]
470
+ }
471
+ const selectedName = selectedDistro || settings.defaultDistro || distros[0] || ''
472
+ const selectedBroker = brokerFor(selectedName)
473
+ const selectedState = selectedBroker?.state ?? 'detached'
474
+ const selectedCheck = doctor?.checks?.find((entry) => entry.distro === selectedName)
475
+ const selectedHello = selectedBroker?.hello ?? (selectedCheck
476
+ ? { user: selectedCheck.user, home: selectedCheck.home, node: selectedCheck.node?.path }
477
+ : null)
478
+ const selectedWorking = busy.endsWith(':' + selectedName)
479
+
480
+ const tab = (name) => {
481
+ const active = name === selectedName
482
+ return h('button', {
483
+ key: name,
484
+ type: 'button',
485
+ onClick: () => setSelectedDistro(name),
486
+ style: {
487
+ border: 0, cursor: 'pointer', padding: '7px 0 8px', marginBottom: -1,
488
+ background: 'transparent',
489
+ color: active ? accent : muted, fontSize: 13, fontWeight: active ? 600 : 400,
490
+ borderBottom: active ? `2px solid ${accent}` : '2px solid transparent',
491
+ whiteSpace: 'nowrap',
492
+ },
493
+ }, name)
494
+ }
495
+
496
+ return h('div', { style: { padding: '18px 24px', width: '100%', maxWidth: 1180, boxSizing: 'border-box', color: text, fontSize: 13 } },
497
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 } },
498
+ h(Icon, { size: 20 }),
499
+ h('h2', { style: { margin: 0, fontSize: 15, fontWeight: 600, flex: 1 } }, 'WSL'),
500
+ h('button', {
501
+ type: 'button', style: btn,
502
+ onClick: async () => {
503
+ setError('')
504
+ setNotice('')
505
+ try {
506
+ const { workspaces: list } = await call('/workspaces')
507
+ let marked = 0
508
+ for (const w of list) {
509
+ if (!isWslUnc(w.path)) continue
510
+ if (!w.id || String(w.title ?? '').startsWith('🐧')) continue
511
+ const folder = String(w.path).replace(/[\\/]+$/, '').split(/[\\/]/).pop() || 'WSL'
512
+ await ctx.workspaces.rename(w.id, `🐧 ${folder}`)
513
+ marked += 1
514
+ }
515
+ setNotice(marked > 0 ? `已标记 ${marked} 个 WSL 工作区` : '所有 WSL 工作区都已标记,无需操作。')
516
+ } catch (e) {
517
+ setError(String(e.message || e))
518
+ }
519
+ },
520
+ }, '标记已有'),
521
+ h('button', {
522
+ type: 'button',
523
+ 'aria-label': '帮助',
524
+ title: [
525
+ '选一个发行版里的目录作为工作区;WSL 模式会话会在该发行版里执行 bash、后台任务和终端。',
526
+ state.idleDays === 0 ? '不会因空闲自动断开 helper,发行版本身保持运行。' : `空闲 ${state.idleDays} 天后自动断开 helper,发行版本身保持运行。`,
527
+ state.presetDefault?.installed
528
+ ? 'WSL 工作区(\\\\wsl.localhost\\...)里新建的会话默认使用 WSL 模式,显式选择其他模式时除外;Windows 工作区不受影响。'
529
+ : '默认预设注入未生效:若新建会话仍不是 WSL 模式,请重启 DSH 后再试。',
530
+ ].join('\n'),
531
+ style: { ...btn, width: 28, height: 28, padding: 0, borderRadius: 999, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' },
532
+ }, '?'),
533
+ h(AddWorkspaceButton, {
534
+ distros,
535
+ defaultDistro: settings.defaultDistro,
536
+ onPick: (distro) => { setPickerDistro(distro); setPicking(true) },
537
+ })),
538
+
539
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, margin: '0 0 12px', flexWrap: 'wrap' } },
540
+ h('button', { type: 'button', style: btn, disabled: doctorBusy, onClick: runDoctor }, doctorBusy ? '诊断中…' : '运行诊断'),
541
+ h('span', { style: { fontSize: 12, color: muted } }, '检查 Node / SSH / PATH / 网络'),
542
+ state.capabilities && Object.values(state.capabilities).some((ok) => !ok)
543
+ ? h('span', { style: { fontSize: 12, color: '#b45309' } }, '部分宿主能力缺失(见诊断)')
544
+ : null),
545
+
546
+ h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 24, alignItems: 'flex-start' } },
547
+ h('div', { style: { flex: '1 1 340px', minWidth: 300, maxWidth: 430, display: 'flex', flexDirection: 'column' } },
548
+ h('h3', { style: label }, '设置'),
549
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 14 } },
550
+ h('label', { style: { display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: muted } },
551
+ '默认发行版',
552
+ h('select', {
553
+ value: settings.defaultDistro || '',
554
+ onChange: (event) => saveSettings({ defaultDistro: event.target.value }),
555
+ style: { ...input, width: 180, padding: '4px 8px' },
556
+ },
557
+ h('option', { value: '' }, '(未设置)'),
558
+ distros.map((name) => h('option', { key: name, value: name }, name)))),
559
+
560
+ error ? h('div', {
561
+ style: { marginBottom: 12, padding: '8px 10px', borderRadius: 6, fontSize: 12, color: '#b91c1c', background: 'rgba(239,68,68,.08)' },
562
+ }, error) : null,
563
+
564
+ notice ? h('div', {
565
+ style: { marginBottom: 12, padding: '8px 10px', borderRadius: 6, fontSize: 12, color: '#15803d', background: 'rgba(34,197,94,.10)' },
566
+ }, notice) : null,
567
+
568
+ h('h3', { style: label }, '发行版'),
569
+ distros.length === 0
570
+ ? h('div', { style: { display: 'flex', alignItems: 'center', gap: 24, minHeight: 34, borderBottom: `1px solid ${line}`, marginBottom: 14 } },
571
+ h('span', { style: { fontSize: 13, color: muted, padding: '7px 0 8px' } }, loaded ? '没有检测到 WSL 发行版' : '加载发行版…'))
572
+ : h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 24, borderBottom: `1px solid ${line}`, marginBottom: 14 } }, distros.map(tab)),
573
+ selectedName && distros.length > 0 ? h('div', {
574
+ style: { border: `1px solid ${line}`, borderRadius: 8, padding: 12, background: card },
575
+ },
576
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 8 } },
577
+ dot(selectedState),
578
+ h('b', { style: { fontSize: 13, color: text } }, selectedName),
579
+ h('span', { style: { fontSize: 12, color: muted } }, selectedState),
580
+ h('span', { style: { flex: 1 } }),
581
+ h('button', {
582
+ type: 'button',
583
+ disabled: selectedWorking || !selectedName,
584
+ onClick: () => act(selectedState === 'detached' || selectedState === 'error' ? 'connect' : 'disconnect', selectedName),
585
+ style: btn,
586
+ }, selectedWorking ? '…' : (selectedState === 'detached' || selectedState === 'error' ? '连接' : '断开'))),
587
+ h('div', { style: { marginTop: 8, fontSize: 12, color: muted, lineHeight: 1.8 } },
588
+ h('div', null, selectedHello ? `${selectedHello.user}@${selectedHello.home}` : '未连接'),
589
+ h('div', null, 'node: ', selectedHello?.node || '—'),
590
+ h('div', null, '引用计数: ', String(selectedBroker?.refcount ?? 0))),
591
+ selectedBroker?.lastError ? h('div', {
592
+ style: { marginTop: 6, fontSize: 11, lineHeight: 1.5, color: '#b91c1c', wordBreak: 'break-word' },
593
+ }, selectedBroker.lastError) : null,
594
+ !doctor ? h('div', { style: { marginTop: 8, fontSize: 11, color: muted } }, '点击“运行诊断”查看 Node / SSH / PATH / 网络。') : null) : null,
595
+
596
+ h('div', { style: { marginTop: 18, paddingTop: 14, borderTop: `1px solid ${line}` } },
597
+ h('label', { style: { display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: muted } },
598
+ '空闲断开(天)',
599
+ h('input', {
600
+ type: 'number', min: 0, step: 1, value: state.idleDays,
601
+ onChange: async (event) => {
602
+ const days = Number(event.target.value)
603
+ setState((prev) => ({ ...prev, idleDays: days }))
604
+ try {
605
+ const saved = await call('/idle', { method: 'POST', body: JSON.stringify({ days }) })
606
+ setState((prev) => ({ ...prev, idleDays: saved.idleDays }))
607
+ } catch (e) { setError(String(e.message || e)) }
608
+ },
609
+ style: { ...input, width: 72 },
610
+ }),
611
+ '0 = 永不自动断开')),
612
+
613
+ ),
614
+ h('div', { style: { flex: '2 1 560px', minWidth: 0 } },
615
+ h('h3', { style: label }, '诊断与修复'),
616
+ doctorMsg ? h('pre', {
617
+ style: {
618
+ margin: '0 0 14px', padding: '8px 10px', borderRadius: 6, fontSize: 11, lineHeight: 1.5,
619
+ background: card, border: `1px solid ${line}`, color: text, whiteSpace: 'pre-wrap', maxHeight: 260, overflow: 'auto',
620
+ },
621
+ }, doctorMsg) : null,
622
+ doctor ? h(DoctorCard, { doctor, busy: doctorBusy, onFix: fix })
623
+ : h('div', {
624
+ style: { border: `1px solid ${line}`, borderRadius: 8, padding: 18, background: card, color: muted, fontSize: 12, lineHeight: 1.7 },
625
+ }, '点击“运行诊断”查看每个发行版的 Node、SSH、PATH、网络状态和修复按钮;结果会显示在这里。'))),
626
+
627
+ picking ? h(WslFolderDialog, {
628
+ initialDistro: pickerDistro,
629
+ onClose: () => { setPicking(false); setPickerDistro('') },
630
+ onCreated: () => refresh(),
631
+ workspaces: ctx.workspaces,
632
+ uiWorkspace: ctx.uiWorkspace,
633
+ }) : null))
634
+ }
635
+
636
+ ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
637
+ name: 'sidebar.panellist', id: 'wsl', order: 20, label: 'WSL',
638
+ }, Icon))
639
+ ctx.slots.inject('main', () => ctx.slots.register({
640
+ name: 'main', key: 'wsl',
641
+ }, Panel))
642
+ },
643
+ }
644
+ },
645
+ })