dsh-wsl-desktop 0.2.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.
- package/LICENSE +21 -0
- package/README.md +222 -0
- package/cordis.patch.yml +9 -0
- package/lib/client.js +709 -0
- package/lib/http-admission.js +102 -0
- package/lib/index.js +1067 -0
- package/lib/wsl/confinement.js +424 -0
- package/lib/wsl/dsh-wsl-confine.sh +80 -0
- package/lib/wsl/fence.js +121 -0
- package/lib/wsl/fs.js +324 -0
- package/lib/wsl/host-refs.js +38 -0
- package/lib/wsl/paths.js +95 -0
- package/lib/wsl/preset.js +456 -0
- package/lib/wsl/pty.js +366 -0
- package/lib/wsl/shell.js +462 -0
- package/lib/wsl/subprocess.js +195 -0
- package/lib/wsl/terminal-bridge.py +278 -0
- package/lib/wsl/world.js +458 -0
- package/package.json +35 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser half of `dsh-wsl-desktop`.
|
|
3
|
+
*
|
|
4
|
+
* Adds one control beside the shipped "add workspace" button in the sidebar
|
|
5
|
+
* section header: a `W` button opening this plugin's WSL workspace picker.
|
|
6
|
+
*
|
|
7
|
+
* The shipped button keeps its own behaviour: this half registers no slot
|
|
8
|
+
* occupant, because `sidebar.workspaces.directoryFlow` is `kind: 'single'` and
|
|
9
|
+
* any occupant shadows the deployment's host folder chooser. The header itself
|
|
10
|
+
* declares no extension point, so the companion button is attached to the
|
|
11
|
+
* shipped trigger's node and re-attached when React replaces that subtree.
|
|
12
|
+
* Discovery matches the trigger icon's path data, which is locale-independent.
|
|
13
|
+
*
|
|
14
|
+
* The harness fixes a session's agent preset at creation, so this half names
|
|
15
|
+
* the WSL preset IN the create request it sends: it asks the host's
|
|
16
|
+
* `wslPresetFor` for the variant id and passes `agentPreset` to
|
|
17
|
+
* `ctx.sessions.create`. A preset cannot be selected afterwards on a session
|
|
18
|
+
* that has already taken a turn, so there is no other race-free seam.
|
|
19
|
+
*/
|
|
20
|
+
window.__ModuleLoader__.load({
|
|
21
|
+
id: 'dsh-wsl-desktop',
|
|
22
|
+
factory() {
|
|
23
|
+
const ENDPOINT = '/wsl-desktop/api'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Known first path commands of the shipped "add workspace" icon across
|
|
27
|
+
* desktop generations — the only places the app renders it. The trigger
|
|
28
|
+
* matches by prefix, so the list covers the pre-refactor
|
|
29
|
+
* `IconProjectAddOutline16` geometry and the post-refactor
|
|
30
|
+
* `ProjectAddOutlineArtwork` geometry; a desktop running either
|
|
31
|
+
* generation keeps the W button alive.
|
|
32
|
+
*/
|
|
33
|
+
const TRIGGER_ICON_PATHS = [
|
|
34
|
+
'M3.55246 0L3.55246 2.44252', // pre-refactor IconProjectAddOutline16
|
|
35
|
+
'M5.54492 2.06738', // post-refactor ProjectAddOutlineArtwork
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
/** Accessible name and tooltip of the companion button. */
|
|
39
|
+
const BUTTON_LABEL = '添加 WSL 工作区'
|
|
40
|
+
|
|
41
|
+
/** Theme tokens of the running composition. */
|
|
42
|
+
const T = {
|
|
43
|
+
surface: 'var(--dsw-alias-bg-overlay, #26262b)',
|
|
44
|
+
layer: 'var(--dsw-alias-bg-layer-2, #2f2f35)',
|
|
45
|
+
border: 'var(--dsw-alias-border-l1, rgba(128,128,128,0.35))',
|
|
46
|
+
label: 'var(--dsw-alias-label-primary, #eaeaea)',
|
|
47
|
+
dim: 'var(--dsw-alias-label-secondary, rgba(234,234,234,0.66))',
|
|
48
|
+
hover: 'var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.14))',
|
|
49
|
+
accent: 'var(--dsw-alias-brand-primary, #4f8cff)',
|
|
50
|
+
error: 'var(--dsw-alias-state-error-primary, #ff8a80)',
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const BUTTON_STYLE = {
|
|
54
|
+
padding: '6px 14px',
|
|
55
|
+
borderRadius: '6px',
|
|
56
|
+
border: `1px solid ${T.border}`,
|
|
57
|
+
background: 'transparent',
|
|
58
|
+
color: T.label,
|
|
59
|
+
font: 'inherit',
|
|
60
|
+
cursor: 'pointer',
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const PRIMARY_STYLE = { ...BUTTON_STYLE, borderColor: T.accent, color: T.accent, fontWeight: '600' }
|
|
64
|
+
|
|
65
|
+
const FIELD_STYLE = {
|
|
66
|
+
flex: '1',
|
|
67
|
+
padding: '6px 10px',
|
|
68
|
+
borderRadius: '6px',
|
|
69
|
+
border: `1px solid ${T.border}`,
|
|
70
|
+
background: T.layer,
|
|
71
|
+
color: T.label,
|
|
72
|
+
font: 'inherit',
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const OVERLAY_STYLE = {
|
|
76
|
+
position: 'fixed',
|
|
77
|
+
inset: '0',
|
|
78
|
+
zIndex: '9999',
|
|
79
|
+
display: 'flex',
|
|
80
|
+
alignItems: 'center',
|
|
81
|
+
justifyContent: 'center',
|
|
82
|
+
background: 'rgba(0,0,0,0.45)',
|
|
83
|
+
color: T.label,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const CARD_STYLE = {
|
|
87
|
+
width: '560px',
|
|
88
|
+
maxWidth: '90vw',
|
|
89
|
+
maxHeight: '76vh',
|
|
90
|
+
display: 'flex',
|
|
91
|
+
flexDirection: 'column',
|
|
92
|
+
gap: '12px',
|
|
93
|
+
padding: '20px',
|
|
94
|
+
borderRadius: '12px',
|
|
95
|
+
border: `1px solid ${T.border}`,
|
|
96
|
+
background: T.surface,
|
|
97
|
+
color: T.label,
|
|
98
|
+
font: 'inherit',
|
|
99
|
+
boxShadow: '0 18px 48px rgba(0,0,0,0.45)',
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Perform one host call and unwrap the envelope.
|
|
104
|
+
* @param {string} method - host method name.
|
|
105
|
+
* @param {object} [params] - method payload.
|
|
106
|
+
* @returns {Promise<any>} the unwrapped value.
|
|
107
|
+
*/
|
|
108
|
+
async function call(method, params = {}) {
|
|
109
|
+
const response = await fetch(ENDPOINT, {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
headers: { 'content-type': 'application/json' },
|
|
112
|
+
body: JSON.stringify({ method, params }),
|
|
113
|
+
})
|
|
114
|
+
// The route answers non-JSON for a refused or malformed request (401 from
|
|
115
|
+
// the trust fence, 415 for a bad media type, 405 for a wrong method), so
|
|
116
|
+
// the status is read before the body is parsed.
|
|
117
|
+
let envelope
|
|
118
|
+
try {
|
|
119
|
+
envelope = await response.json()
|
|
120
|
+
} catch {
|
|
121
|
+
throw new Error(`${method} 被宿主拒绝(HTTP ${response.status})`)
|
|
122
|
+
}
|
|
123
|
+
if (!envelope.ok) throw new Error(envelope.error ?? `${method} 失败(HTTP ${response.status})`)
|
|
124
|
+
return envelope.value
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Readable text of an unknown rejection. */
|
|
128
|
+
function message(failure) {
|
|
129
|
+
return failure?.message ?? String(failure)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Parent of an absolute Linux path. */
|
|
133
|
+
function parentOf(path) {
|
|
134
|
+
const trimmed = path.replace(/\/+$/, '')
|
|
135
|
+
if (trimmed === '' || trimmed === '/') return null
|
|
136
|
+
return trimmed.slice(0, trimmed.lastIndexOf('/')) || '/'
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Join one Linux segment onto a directory. */
|
|
140
|
+
function joinLinux(path, name) {
|
|
141
|
+
return path === '/' ? `/${name}` : `${path.replace(/\/+$/, '')}/${name}`
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Build one element.
|
|
146
|
+
* @param {string} tag - element name.
|
|
147
|
+
* @param {object} [style] - inline style declarations.
|
|
148
|
+
* @param {...(Node|string|null|false|undefined)} children - text or elements to append.
|
|
149
|
+
* @returns {HTMLElement} the built element.
|
|
150
|
+
*/
|
|
151
|
+
function el(tag, style, ...children) {
|
|
152
|
+
const node = document.createElement(tag)
|
|
153
|
+
if (style !== undefined) Object.assign(node.style, style)
|
|
154
|
+
for (const child of children) {
|
|
155
|
+
if (child === null || child === undefined || child === false) continue
|
|
156
|
+
node.append(typeof child === 'string' ? document.createTextNode(child) : child)
|
|
157
|
+
}
|
|
158
|
+
return node
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The open picker's teardown, or null while none is open. */
|
|
162
|
+
let teardown = null
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Open the WSL workspace picker and adopt the chosen directory.
|
|
166
|
+
* @param {object} ctx - the client plugin context (`workspaces`, `uiWorkspace`).
|
|
167
|
+
*/
|
|
168
|
+
function openPicker(ctx) {
|
|
169
|
+
if (teardown !== null) return
|
|
170
|
+
const state = {
|
|
171
|
+
distros: null,
|
|
172
|
+
distro: null,
|
|
173
|
+
/** The gate: pick the distribution user before any browsing happens. */
|
|
174
|
+
phase: 'pick-user',
|
|
175
|
+
userDraft: '',
|
|
176
|
+
pending: false,
|
|
177
|
+
/** The confirmed distribution user and their home, once resolved. */
|
|
178
|
+
user: null,
|
|
179
|
+
home: null,
|
|
180
|
+
path: '/',
|
|
181
|
+
draft: '/',
|
|
182
|
+
listing: null,
|
|
183
|
+
error: null,
|
|
184
|
+
working: false,
|
|
185
|
+
closed: false,
|
|
186
|
+
token: 0,
|
|
187
|
+
}
|
|
188
|
+
const overlay = el('div', OVERLAY_STYLE)
|
|
189
|
+
const card = el('div', CARD_STYLE)
|
|
190
|
+
overlay.setAttribute('role', 'dialog')
|
|
191
|
+
overlay.setAttribute('aria-modal', 'true')
|
|
192
|
+
overlay.setAttribute('aria-label', BUTTON_LABEL)
|
|
193
|
+
overlay.append(card)
|
|
194
|
+
overlay.addEventListener('click', () => { close() })
|
|
195
|
+
card.addEventListener('click', (event) => { event.stopPropagation() })
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Close the picker and release its listeners.
|
|
199
|
+
* A commit in flight owns the outcome: closing mid-commit would let the
|
|
200
|
+
* workspace be created and a session opened after the operator cancelled.
|
|
201
|
+
*/
|
|
202
|
+
function close() {
|
|
203
|
+
if (state.closed || state.working) return
|
|
204
|
+
state.closed = true
|
|
205
|
+
state.token += 1
|
|
206
|
+
document.removeEventListener('keydown', onKey)
|
|
207
|
+
overlay.remove()
|
|
208
|
+
teardown = null
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Close the picker regardless of an in-flight commit, once it settles. */
|
|
212
|
+
function forceClose() {
|
|
213
|
+
state.closed = true
|
|
214
|
+
state.token += 1
|
|
215
|
+
document.removeEventListener('keydown', onKey)
|
|
216
|
+
overlay.remove()
|
|
217
|
+
teardown = null
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function onKey(event) {
|
|
221
|
+
if (event.key === 'Escape') close()
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Load the current directory of the selected distribution. */
|
|
225
|
+
async function load() {
|
|
226
|
+
if (state.distro === null) return
|
|
227
|
+
const token = state.token += 1
|
|
228
|
+
state.listing = null
|
|
229
|
+
state.error = null
|
|
230
|
+
render()
|
|
231
|
+
try {
|
|
232
|
+
const listing = await call('listDir', { distro: state.distro, path: state.path })
|
|
233
|
+
if (token !== state.token) return
|
|
234
|
+
state.listing = listing
|
|
235
|
+
} catch (failure) {
|
|
236
|
+
if (token !== state.token) return
|
|
237
|
+
state.error = message(failure)
|
|
238
|
+
}
|
|
239
|
+
render()
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Navigate to a path and keep the text field in step. */
|
|
243
|
+
function go(path) {
|
|
244
|
+
state.path = path
|
|
245
|
+
state.draft = path
|
|
246
|
+
void load()
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Select a distribution and gate on the user to enter as.
|
|
251
|
+
*
|
|
252
|
+
* Switching distros re-prompts, carrying the last confirmed user as a
|
|
253
|
+
* starting point; an empty field means the distribution's default user.
|
|
254
|
+
* No resolution happens here: the gate's confirm button is the one place
|
|
255
|
+
* `resolveHome` runs.
|
|
256
|
+
*/
|
|
257
|
+
function chooseDistro(name) {
|
|
258
|
+
// A commit in flight owns the outcome, exactly like `close()`: a
|
|
259
|
+
// mid-commit re-pick would repaint the gate only for the settling
|
|
260
|
+
// commit to tear it down and open the session.
|
|
261
|
+
if (state.working) return
|
|
262
|
+
state.distro = name
|
|
263
|
+
state.phase = 'pick-user'
|
|
264
|
+
// Carry the last confirmed user; with none confirmed yet, preserve
|
|
265
|
+
// whatever the operator typed while discovery was settling instead of
|
|
266
|
+
// wiping it.
|
|
267
|
+
state.userDraft = state.user ?? (state.userDraft || '')
|
|
268
|
+
state.listing = null
|
|
269
|
+
state.error = null
|
|
270
|
+
// A re-pick supersedes any in-flight home resolution; without this the
|
|
271
|
+
// abandoned confirm's `pending` would stay true and the fresh gate
|
|
272
|
+
// would render permanently disabled (its result is dropped by the
|
|
273
|
+
// token marker below).
|
|
274
|
+
state.pending = false
|
|
275
|
+
state.token += 1
|
|
276
|
+
render()
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Resolve the entered user's home and enter the browse phase at it.
|
|
281
|
+
*
|
|
282
|
+
* The user is answered by the distribution's own user database, so an
|
|
283
|
+
* unknown name fails right here in the gate with the host's error —
|
|
284
|
+
* browsing never opens on a guess. The path is not asked for: it is the
|
|
285
|
+
* user's home, editable afterwards like any other path.
|
|
286
|
+
*/
|
|
287
|
+
async function confirmUser() {
|
|
288
|
+
if (state.pending || state.distro === null) return
|
|
289
|
+
const name = state.userDraft.trim()
|
|
290
|
+
state.pending = true
|
|
291
|
+
state.error = null
|
|
292
|
+
const marker = state.token += 1
|
|
293
|
+
render()
|
|
294
|
+
try {
|
|
295
|
+
const resolved = await call('resolveHome', { distro: state.distro, ...(name !== '' ? { username: name } : {}) })
|
|
296
|
+
if (state.closed || marker !== state.token) return
|
|
297
|
+
state.user = resolved.user
|
|
298
|
+
state.home = resolved.home
|
|
299
|
+
state.pending = false
|
|
300
|
+
state.phase = 'browse'
|
|
301
|
+
go(resolved.home)
|
|
302
|
+
} catch (failure) {
|
|
303
|
+
if (state.closed || marker !== state.token) return
|
|
304
|
+
state.error = message(failure)
|
|
305
|
+
state.pending = false
|
|
306
|
+
render()
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Validate the chosen directory, register the workspace, open its session. */
|
|
311
|
+
async function commit() {
|
|
312
|
+
if (state.working || state.distro === null) return
|
|
313
|
+
// The field is the operator's intent; navigating only moves `path`.
|
|
314
|
+
const target = state.draft
|
|
315
|
+
state.working = true
|
|
316
|
+
state.error = null
|
|
317
|
+
render()
|
|
318
|
+
try {
|
|
319
|
+
const facts = await call('checkPath', { distro: state.distro, path: target })
|
|
320
|
+
if (facts.isDirectory !== true) throw new Error(`${target} 不是一个存在的目录`)
|
|
321
|
+
const workspace = await ctx.workspaces.create({ path: facts.uncPath })
|
|
322
|
+
// The harness fixes a session's preset at creation, so the execution
|
|
323
|
+
// world has to be named in the create request: binding afterwards is
|
|
324
|
+
// refused for any session that has already taken a turn, and the
|
|
325
|
+
// session then runs in the host world while looking correct.
|
|
326
|
+
const preset = await call('wslPresetFor', {})
|
|
327
|
+
const sessionId = await ctx.sessions.create({
|
|
328
|
+
workspaceId: workspace.workspaceId,
|
|
329
|
+
agentPreset: preset.agentPreset,
|
|
330
|
+
})
|
|
331
|
+
if (state.closed) return
|
|
332
|
+
forceClose()
|
|
333
|
+
ctx.uiWorkspace.openSession(sessionId)
|
|
334
|
+
} catch (failure) {
|
|
335
|
+
if (state.closed) return
|
|
336
|
+
state.error = message(failure)
|
|
337
|
+
state.working = false
|
|
338
|
+
render()
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** One distribution pill. */
|
|
343
|
+
function pill(name) {
|
|
344
|
+
const active = name === state.distro
|
|
345
|
+
const node = el('button', {
|
|
346
|
+
...BUTTON_STYLE,
|
|
347
|
+
padding: '4px 10px',
|
|
348
|
+
borderColor: active ? T.accent : T.border,
|
|
349
|
+
color: active ? T.accent : T.label,
|
|
350
|
+
background: active ? T.layer : 'transparent',
|
|
351
|
+
// Same ownership rule as `close()`: while a commit is in flight the
|
|
352
|
+
// outcome belongs to it, so re-picking is refused at the source.
|
|
353
|
+
opacity: state.working ? '0.6' : '1',
|
|
354
|
+
}, name)
|
|
355
|
+
node.type = 'button'
|
|
356
|
+
node.disabled = state.working
|
|
357
|
+
node.addEventListener('click', () => { chooseDistro(name) })
|
|
358
|
+
return node
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** A flat text button inside the breadcrumb row. */
|
|
362
|
+
function crumb(label, onClick) {
|
|
363
|
+
const node = el('button', {
|
|
364
|
+
border: 'none',
|
|
365
|
+
background: 'transparent',
|
|
366
|
+
color: 'inherit',
|
|
367
|
+
cursor: 'pointer',
|
|
368
|
+
padding: '0',
|
|
369
|
+
}, label)
|
|
370
|
+
node.type = 'button'
|
|
371
|
+
node.addEventListener('click', onClick)
|
|
372
|
+
return node
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** The phase whose primary field last took focus. */
|
|
376
|
+
let renderedPhase = null
|
|
377
|
+
/** The last error the rendered state carried, so a fresh error re-focuses. */
|
|
378
|
+
let renderedError = null
|
|
379
|
+
|
|
380
|
+
/** Repaint the whole card from `state`. */
|
|
381
|
+
function render() {
|
|
382
|
+
const body = []
|
|
383
|
+
|
|
384
|
+
body.push(el('div', { fontSize: '15px', fontWeight: '600' }, '添加 WSL 工作区'))
|
|
385
|
+
body.push(el('div', { fontSize: '12px', color: T.dim },
|
|
386
|
+
'新会话的 bash 与文件工具都会在该发行版里工作;Windows 文件可在 /mnt/<盘符> 下访问。'))
|
|
387
|
+
|
|
388
|
+
const distros = el('div', { display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' })
|
|
389
|
+
distros.append(el('span', { fontSize: '12px', color: T.dim }, '发行版'))
|
|
390
|
+
if (state.distros === null) {
|
|
391
|
+
distros.append(el('span', { fontSize: '12px', color: T.dim }, '读取中…'))
|
|
392
|
+
} else if (state.distros.length === 0) {
|
|
393
|
+
distros.append(el('span', { fontSize: '12px', color: T.error }, '没有可用的 WSL 发行版'))
|
|
394
|
+
} else {
|
|
395
|
+
for (const name of state.distros) distros.append(pill(name))
|
|
396
|
+
}
|
|
397
|
+
body.push(distros)
|
|
398
|
+
|
|
399
|
+
if (state.phase === 'pick-user') {
|
|
400
|
+
// The user gate: no browsing until the distribution user is named.
|
|
401
|
+
// An empty field means the distribution's default user; an unknown
|
|
402
|
+
// user fails right here instead of opening the browser on a guess.
|
|
403
|
+
body.push(el('div', { fontSize: '12px', color: T.dim },
|
|
404
|
+
'输入要进入该发行版的用户;确认后直接落在其主目录。'))
|
|
405
|
+
const userField = el('input', FIELD_STYLE)
|
|
406
|
+
userField.value = state.userDraft
|
|
407
|
+
userField.placeholder = '留空 = 发行版默认用户'
|
|
408
|
+
userField.addEventListener('input', () => { state.userDraft = userField.value })
|
|
409
|
+
userField.addEventListener('keydown', (event) => {
|
|
410
|
+
if (event.key === 'Enter' && state.distro !== null) void confirmUser()
|
|
411
|
+
})
|
|
412
|
+
const enter = el('button', {
|
|
413
|
+
...PRIMARY_STYLE,
|
|
414
|
+
opacity: state.pending ? '0.6' : '1',
|
|
415
|
+
}, state.pending ? '解析中…' : '确认进入')
|
|
416
|
+
enter.type = 'button'
|
|
417
|
+
enter.disabled = state.pending || state.distro === null
|
|
418
|
+
enter.addEventListener('click', () => { void confirmUser() })
|
|
419
|
+
body.push(el('div', { display: 'flex', gap: '8px' }, userField, enter))
|
|
420
|
+
|
|
421
|
+
if (state.error !== null) body.push(el('div', { fontSize: '12px', color: T.error }, state.error))
|
|
422
|
+
|
|
423
|
+
const cancel = el('button', BUTTON_STYLE, '取消')
|
|
424
|
+
cancel.type = 'button'
|
|
425
|
+
cancel.addEventListener('click', () => { close() })
|
|
426
|
+
body.push(el('div', { display: 'flex', justifyContent: 'flex-end' }, cancel))
|
|
427
|
+
} else {
|
|
428
|
+
// The confirmed user and their home: provenance of the prefill.
|
|
429
|
+
if (state.user !== null && state.home !== null) {
|
|
430
|
+
body.push(el('div', { fontSize: '12px', color: T.dim }, `用户 ${state.user} · 主目录 ${state.home}(可直接修改)`))
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const input = el('input', FIELD_STYLE)
|
|
434
|
+
input.value = state.draft
|
|
435
|
+
input.placeholder = '/home/用户名/项目'
|
|
436
|
+
input.addEventListener('input', () => { state.draft = input.value })
|
|
437
|
+
input.addEventListener('keydown', (event) => {
|
|
438
|
+
if (event.key === 'Enter' && state.distro !== null) go(state.draft)
|
|
439
|
+
})
|
|
440
|
+
const goButton = el('button', BUTTON_STYLE, '前往')
|
|
441
|
+
goButton.type = 'button'
|
|
442
|
+
goButton.disabled = state.distro === null
|
|
443
|
+
goButton.addEventListener('click', () => { go(state.draft) })
|
|
444
|
+
body.push(el('div', { display: 'flex', gap: '8px' }, input, goButton))
|
|
445
|
+
|
|
446
|
+
const crumbs = el('div', { display: 'flex', flexWrap: 'wrap', gap: '4px', fontSize: '12px', color: T.dim })
|
|
447
|
+
crumbs.append(crumb('/', () => { go('/') }))
|
|
448
|
+
let accumulated = ''
|
|
449
|
+
for (const segment of state.path.split('/').filter((part) => part.length > 0)) {
|
|
450
|
+
accumulated += `/${segment}`
|
|
451
|
+
const target = accumulated
|
|
452
|
+
crumbs.append(el('span', { opacity: '0.45' }, ' › '))
|
|
453
|
+
crumbs.append(crumb(segment, () => { go(target) }))
|
|
454
|
+
}
|
|
455
|
+
body.push(crumbs)
|
|
456
|
+
|
|
457
|
+
const list = el('div', {
|
|
458
|
+
flex: '1',
|
|
459
|
+
minHeight: '180px',
|
|
460
|
+
overflow: 'auto',
|
|
461
|
+
padding: '6px',
|
|
462
|
+
borderRadius: '6px',
|
|
463
|
+
border: `1px solid ${T.border}`,
|
|
464
|
+
background: T.layer,
|
|
465
|
+
})
|
|
466
|
+
const directories = (state.listing?.entries ?? []).filter((entry) => entry.kind === 'directory')
|
|
467
|
+
if (state.listing === null) {
|
|
468
|
+
list.append(el('div', { fontSize: '12px', color: T.dim, padding: '6px' }, '读取目录…'))
|
|
469
|
+
} else if (directories.length === 0) {
|
|
470
|
+
list.append(el('div', { fontSize: '12px', color: T.dim, padding: '6px' }, '没有子目录'))
|
|
471
|
+
} else {
|
|
472
|
+
for (const entry of directories) {
|
|
473
|
+
const item = el('button', {
|
|
474
|
+
display: 'block',
|
|
475
|
+
width: '100%',
|
|
476
|
+
textAlign: 'left',
|
|
477
|
+
border: 'none',
|
|
478
|
+
background: 'transparent',
|
|
479
|
+
color: T.label,
|
|
480
|
+
font: 'inherit',
|
|
481
|
+
fontSize: '13px',
|
|
482
|
+
padding: '4px 6px',
|
|
483
|
+
borderRadius: '4px',
|
|
484
|
+
cursor: 'pointer',
|
|
485
|
+
}, `📁 ${entry.name}`)
|
|
486
|
+
item.type = 'button'
|
|
487
|
+
item.addEventListener('mouseenter', () => { item.style.background = T.hover })
|
|
488
|
+
item.addEventListener('mouseleave', () => { item.style.background = 'transparent' })
|
|
489
|
+
item.addEventListener('click', () => { go(joinLinux(state.path, entry.name)) })
|
|
490
|
+
list.append(item)
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
body.push(list)
|
|
494
|
+
|
|
495
|
+
const parent = parentOf(state.path)
|
|
496
|
+
if (parent !== null) {
|
|
497
|
+
const up = el('button', { ...BUTTON_STYLE, alignSelf: 'flex-start', padding: '4px 10px' }, '.. 上级目录')
|
|
498
|
+
up.type = 'button'
|
|
499
|
+
up.addEventListener('click', () => { go(parent) })
|
|
500
|
+
body.push(up)
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (state.error !== null) body.push(el('div', { fontSize: '12px', color: T.error }, state.error))
|
|
504
|
+
|
|
505
|
+
const cancel = el('button', BUTTON_STYLE, '取消')
|
|
506
|
+
cancel.type = 'button'
|
|
507
|
+
cancel.addEventListener('click', () => { close() })
|
|
508
|
+
const confirm = el('button', {
|
|
509
|
+
...PRIMARY_STYLE,
|
|
510
|
+
opacity: state.working ? '0.6' : '1',
|
|
511
|
+
}, state.working ? '处理中…' : '创建并打开')
|
|
512
|
+
confirm.type = 'button'
|
|
513
|
+
confirm.disabled = state.working || state.distro === null
|
|
514
|
+
confirm.addEventListener('click', () => { void commit() })
|
|
515
|
+
body.push(el('div', { display: 'flex', justifyContent: 'flex-end', gap: '8px' }, cancel, confirm))
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
card.replaceChildren(...body)
|
|
519
|
+
// Focus the phase's primary field when the phase changes or a FRESH
|
|
520
|
+
// error appears — replaceChildren destroys the focused input, and the
|
|
521
|
+
// most common repair (a typo'd username, a bad path) starts in that
|
|
522
|
+
// field. Routine repaints never steal focus: that would yank the
|
|
523
|
+
// caret while the operator types.
|
|
524
|
+
if (renderedPhase !== state.phase || (state.error !== null && state.error !== renderedError)) {
|
|
525
|
+
renderedPhase = state.phase
|
|
526
|
+
card.querySelector('input')?.focus()
|
|
527
|
+
}
|
|
528
|
+
renderedError = state.error
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
teardown = close
|
|
532
|
+
document.addEventListener('keydown', onKey)
|
|
533
|
+
hostElement().append(overlay)
|
|
534
|
+
render()
|
|
535
|
+
|
|
536
|
+
call('listDistros').then(async (names) => {
|
|
537
|
+
if (state.closed) return
|
|
538
|
+
state.distros = names
|
|
539
|
+
const fallback = await call('defaultDistro').catch(() => ({ distro: null }))
|
|
540
|
+
if (state.closed) return
|
|
541
|
+
const selected = names.includes(fallback.distro) ? fallback.distro : names[0] ?? null
|
|
542
|
+
if (selected === null) {
|
|
543
|
+
render()
|
|
544
|
+
return
|
|
545
|
+
}
|
|
546
|
+
chooseDistro(selected)
|
|
547
|
+
}, (failure) => {
|
|
548
|
+
if (state.closed) return
|
|
549
|
+
state.error = message(failure)
|
|
550
|
+
render()
|
|
551
|
+
})
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** The injected services the picker and its trigger read. */
|
|
555
|
+
const companion = { button: null, after: null, observer: null, scheduled: false }
|
|
556
|
+
|
|
557
|
+
/** Find the shipped "add workspace" trigger by its icon geometry. */
|
|
558
|
+
function findTrigger() {
|
|
559
|
+
const matches = []
|
|
560
|
+
for (const path of document.querySelectorAll('svg > path[d]')) {
|
|
561
|
+
const d = path.getAttribute('d')
|
|
562
|
+
if (!TRIGGER_ICON_PATHS.some((geometry) => d.startsWith(geometry))) continue
|
|
563
|
+
const trigger = path.closest('button')
|
|
564
|
+
if (trigger !== null && !matches.includes(trigger)) matches.push(trigger)
|
|
565
|
+
}
|
|
566
|
+
if (matches.length <= 1) return matches[0] ?? null
|
|
567
|
+
// More than one control carries the geometry. The shipped trigger is the
|
|
568
|
+
// one inside a `*_headerActions` cluster — the CSS-module local name is
|
|
569
|
+
// stable across builds — and anchoring to a stranger would put the
|
|
570
|
+
// companion beside an unrelated control, so an unrecognised duplicate
|
|
571
|
+
// makes the companion stand down instead.
|
|
572
|
+
return matches.find((trigger) => hasHeaderActionsParent(trigger)) ?? null
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Whether one trigger sits in the shipped trailing action cluster.
|
|
577
|
+
* @param {HTMLElement} trigger - a candidate trigger button.
|
|
578
|
+
* @returns {boolean} true when its parent is the cluster.
|
|
579
|
+
*/
|
|
580
|
+
function hasHeaderActionsParent(trigger) {
|
|
581
|
+
const tokens = String(trigger.parentElement?.className ?? '').split(/\s+/)
|
|
582
|
+
return tokens.some((token) => /(^|_)headerActions$/.test(token))
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* The action cluster holding the shipped trigger.
|
|
587
|
+
*
|
|
588
|
+
* The companion is inserted after this cluster — beside it, inside the
|
|
589
|
+
* header row — because the cluster caps its own width (the shipped sheet
|
|
590
|
+
* gives it `max-width: 60px` for exactly two 28px buttons) and clips a
|
|
591
|
+
* third child.
|
|
592
|
+
* @param {HTMLElement} trigger - the shipped add-workspace button.
|
|
593
|
+
* @returns {Element} the node the companion follows.
|
|
594
|
+
*/
|
|
595
|
+
function actionCluster(trigger) {
|
|
596
|
+
return trigger.parentElement ?? trigger
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Re-attach the companion button when React replaced or reordered its anchor.
|
|
601
|
+
*
|
|
602
|
+
* Every branch here is idempotent. The one `remove()` is guarded by
|
|
603
|
+
* `isConnected`, so a settled "no anchor" state re-runs to a no-op instead
|
|
604
|
+
* of feeding the observer a fresh mutation; hiding while connected is
|
|
605
|
+
* expressed as `display`, which is not a childList mutation at all.
|
|
606
|
+
*/
|
|
607
|
+
function sync() {
|
|
608
|
+
companion.scheduled = false
|
|
609
|
+
const { button } = companion
|
|
610
|
+
if (button === null) return
|
|
611
|
+
const trigger = findTrigger()
|
|
612
|
+
if (trigger === null) {
|
|
613
|
+
if (button.isConnected) button.remove()
|
|
614
|
+
companion.after = null
|
|
615
|
+
return
|
|
616
|
+
}
|
|
617
|
+
const target = actionCluster(trigger)
|
|
618
|
+
if (!button.isConnected || button.parentElement !== target.parentElement
|
|
619
|
+
|| button.previousElementSibling !== target) {
|
|
620
|
+
// Adopt the trigger's own class so the companion renders as one of the
|
|
621
|
+
// header's icon buttons in every sidebar width.
|
|
622
|
+
button.className = trigger.className
|
|
623
|
+
button.style.display = ''
|
|
624
|
+
target.after(button)
|
|
625
|
+
companion.after = target
|
|
626
|
+
}
|
|
627
|
+
// Measure with the button rendered, then decide. The header row clips its
|
|
628
|
+
// own overflow, and the shipped action cluster hides itself while the
|
|
629
|
+
// inline search is expanded — the companion follows it there.
|
|
630
|
+
button.style.display = ''
|
|
631
|
+
const row = button.parentElement?.getBoundingClientRect()
|
|
632
|
+
const box = button.getBoundingClientRect()
|
|
633
|
+
const fits = row === undefined || box.right <= row.right + 1
|
|
634
|
+
const triggerVisible = getComputedStyle(trigger).visibility !== 'hidden'
|
|
635
|
+
button.style.display = fits && triggerVisible ? '' : 'none'
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* The document element a client plugin may already touch.
|
|
640
|
+
*
|
|
641
|
+
* A client plugin applies while the document is still being parsed, so
|
|
642
|
+
* `document.body` can be null here — observing it would throw and leave the
|
|
643
|
+
* trigger permanently unattached. The document element always exists.
|
|
644
|
+
* @returns {HTMLElement} the body once it exists, otherwise the document element.
|
|
645
|
+
*/
|
|
646
|
+
function hostElement() {
|
|
647
|
+
return document.body ?? document.documentElement
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Install the companion button and keep it attached.
|
|
652
|
+
* @param {object} ctx - the client plugin context.
|
|
653
|
+
* @returns {() => void} teardown releasing the button, observer, and dialog.
|
|
654
|
+
*/
|
|
655
|
+
function install(ctx) {
|
|
656
|
+
const button = document.createElement('button')
|
|
657
|
+
button.type = 'button'
|
|
658
|
+
button.dataset.wslDesktop = 'workspace-trigger'
|
|
659
|
+
button.setAttribute('aria-label', BUTTON_LABEL)
|
|
660
|
+
button.title = BUTTON_LABEL
|
|
661
|
+
button.append(el('span', {
|
|
662
|
+
fontWeight: '600',
|
|
663
|
+
fontSize: '13px',
|
|
664
|
+
lineHeight: '1',
|
|
665
|
+
letterSpacing: '0.02em',
|
|
666
|
+
}, 'W'))
|
|
667
|
+
button.addEventListener('click', (event) => {
|
|
668
|
+
event.preventDefault()
|
|
669
|
+
event.stopPropagation()
|
|
670
|
+
openPicker(ctx)
|
|
671
|
+
})
|
|
672
|
+
companion.button = button
|
|
673
|
+
|
|
674
|
+
// `sync` is idempotent and never removes the node it manages, so its own
|
|
675
|
+
// work cannot re-trigger the observer it runs from.
|
|
676
|
+
const observer = new MutationObserver(() => {
|
|
677
|
+
if (companion.scheduled) return
|
|
678
|
+
companion.scheduled = true
|
|
679
|
+
requestAnimationFrame(sync)
|
|
680
|
+
})
|
|
681
|
+
companion.observer = observer
|
|
682
|
+
sync()
|
|
683
|
+
observer.observe(document.documentElement, { childList: true, subtree: true })
|
|
684
|
+
const warning = setTimeout(() => {
|
|
685
|
+
if (button.isConnected) return
|
|
686
|
+
// Distinguishes "no mount point" from "deliberately hidden": only the
|
|
687
|
+
// former means the sidebar has no room beside the trigger at all.
|
|
688
|
+
console.warn('dsh-wsl-desktop: 侧栏未出现"添加工作区"入口,W 按钮没有挂载点')
|
|
689
|
+
}, 5000)
|
|
690
|
+
|
|
691
|
+
return () => {
|
|
692
|
+
clearTimeout(warning)
|
|
693
|
+
observer.disconnect()
|
|
694
|
+
button.remove()
|
|
695
|
+
companion.button = null
|
|
696
|
+
companion.after = null
|
|
697
|
+
companion.observer = null
|
|
698
|
+
teardown?.()
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
return {
|
|
703
|
+
inject: ['workspaces', 'uiWorkspace', 'sessions'],
|
|
704
|
+
apply(ctx) {
|
|
705
|
+
ctx.effect(() => install(ctx), 'wsl-desktop: sidebar WSL workspace trigger')
|
|
706
|
+
},
|
|
707
|
+
}
|
|
708
|
+
},
|
|
709
|
+
})
|