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/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
+ })