dsh-managed-workspaces 0.0.0-stage → 1.0.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/client.js ADDED
@@ -0,0 +1,740 @@
1
+ // dsh-managed-workspaces: Web client half.
2
+ //
3
+ // Registers a 工作区 action in the sidebar footer and one overlay dialog that
4
+ // manages the host-side workspace manifest through the plugin's HTTP routes.
5
+
6
+ window.__ModuleLoader__.load({
7
+ id: 'dsh-managed-workspaces',
8
+ factory(require) {
9
+ const React = require('react')
10
+ const h = React.createElement
11
+ const NS = 'managedWorkspaces'
12
+ const API = '/dsh-managed-workspaces/api/'
13
+
14
+ const zh = {
15
+ title: '托管工作区',
16
+ subtitle: 'DSH 在指定根目录下创建的工作区,可关联多个文件夹',
17
+ close: '关闭',
18
+ loading: '正在读取工作区状态…',
19
+ baseLabel: '工作区根目录',
20
+ baseHint: '新建的工作区会创建为该目录下的一个子文件夹;正在使用的工作区不会被移动。',
21
+ save: '保存',
22
+ browse: '浏览…',
23
+ createLabel: '新建工作区',
24
+ createPlaceholder: '例如:我的项目',
25
+ create: '新建',
26
+ createHint: '会创建 <根目录>/<名称>/,其中 _roots/ 存放关联文件夹的链接。',
27
+ workspacesLabel: '已托管的工作区',
28
+ workspacesEmpty: '还没有托管工作区。',
29
+ candidatesLabel: '已有工作区(未接入)',
30
+ candidatesHint: '侧边栏里已存在、但还没纳入插件管理的工作区。接入不会移动或新建任何文件。',
31
+ adopt: '接入',
32
+ adoptAll: '全部接入',
33
+ 'origin.adopted': '已接入',
34
+ 'status.registered': '已注册',
35
+ 'status.unregistered': '未注册',
36
+ 'status.missing': '目录缺失',
37
+ linksLabel: '关联文件夹',
38
+ linksEmpty: '还没有关联文件夹。',
39
+ 'health.ok': '正常',
40
+ 'health.target-missing': '目标不存在',
41
+ 'health.link-missing': '链接丢失',
42
+ 'health.conflict': '冲突',
43
+ register: '注册到侧边栏',
44
+ repair: '修复',
45
+ forget: '从插件移除',
46
+ forgetConfirm: '从插件移除工作区「{name}」?\n只会删除侧边栏注册与插件记录,不会删除任何文件夹或会话。',
47
+ forgetAdopted: '移出插件',
48
+ forgetAdoptedConfirm: '把工作区「{name}」移出插件管理?\n不会删除任何文件,也不会取消它在侧边栏的注册。',
49
+ detach: '解除关联',
50
+ attach: '关联',
51
+ linkFolder: '要关联的文件夹(绝对路径)',
52
+ linkAlias: '别名(可选,默认取文件夹名)',
53
+ rootsHint: '关联文件夹通过 {roots}/<别名> 出现在工作区内;解除关联只删除链接,不删除原文件夹。',
54
+ footerHint: '工作区出现在侧边栏后,在该行新建会话即可开始工作。',
55
+ }
56
+
57
+ const en = {
58
+ title: 'Managed workspaces',
59
+ subtitle: 'Workspaces DSH creates under a base directory, with linked folders',
60
+ close: 'Close',
61
+ loading: 'Reading workspace state…',
62
+ baseLabel: 'Workspace base directory',
63
+ baseHint: 'New workspaces are created as a subfolder of this directory; existing workspaces are never moved.',
64
+ save: 'Save',
65
+ browse: 'Browse…',
66
+ createLabel: 'New workspace',
67
+ createPlaceholder: 'For example: my project',
68
+ create: 'Create',
69
+ createHint: 'Creates <base>/<name>/; the _roots/ folder holds links to associated folders.',
70
+ workspacesLabel: 'Managed workspaces',
71
+ workspacesEmpty: 'No managed workspaces yet.',
72
+ candidatesLabel: 'Existing workspaces (not managed)',
73
+ candidatesHint: 'Workspaces already in the sidebar that this plugin does not manage yet. Adopting moves and creates nothing.',
74
+ adopt: 'Adopt',
75
+ adoptAll: 'Adopt all',
76
+ 'origin.adopted': 'Adopted',
77
+ 'status.registered': 'Registered',
78
+ 'status.unregistered': 'Not registered',
79
+ 'status.missing': 'Folder missing',
80
+ linksLabel: 'Linked folders',
81
+ linksEmpty: 'No linked folders yet.',
82
+ 'health.ok': 'OK',
83
+ 'health.target-missing': 'Target missing',
84
+ 'health.link-missing': 'Link missing',
85
+ 'health.conflict': 'Conflict',
86
+ register: 'Register in sidebar',
87
+ repair: 'Repair',
88
+ forget: 'Remove from plugin',
89
+ forgetConfirm: 'Remove workspace "{name}" from the plugin?\nOnly the sidebar registration and the plugin record are removed; no folder or session is deleted.',
90
+ forgetAdopted: 'Stop managing',
91
+ forgetAdoptedConfirm: 'Stop managing workspace "{name}"?\nNo file is deleted and its sidebar registration is kept.',
92
+ detach: 'Unlink',
93
+ attach: 'Link',
94
+ linkFolder: 'Folder to link (absolute path)',
95
+ linkAlias: 'Alias (optional, defaults to the folder name)',
96
+ rootsHint: 'A linked folder appears inside the workspace as {roots}/<alias>; unlinking removes only the link, never the folder.',
97
+ footerHint: 'Once the workspace appears in the sidebar, start a session from its row.',
98
+ }
99
+
100
+ const CSS = `
101
+ .dsh-mws-foot{display:inline-flex;align-items:center;justify-content:center;gap:6px;height:30px;padding:0 8px;border:0;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:var(--dsw-font-xs-13);cursor:pointer;}
102
+ .dsh-mws-foot:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);}
103
+ .dsh-mws-foot:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color);outline-offset:2px;}
104
+ .dsh-mws-foot-label{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
105
+ .dsh-mws-backdrop{position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(10,14,26,.42);}
106
+ .dsh-mws-dialog{display:flex;flex-direction:column;width:min(760px,100%);max-height:min(84vh,820px);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-xl);box-shadow:var(--dsw-elevation-panel);font-family:var(--dsw-font-family);overflow:hidden;outline:none;}
107
+ .dsh-mws-head{display:flex;align-items:flex-start;gap:12px;padding:16px 18px;border-bottom:1px solid var(--dsw-alias-border-l2);}
108
+ .dsh-mws-title{font-size:15px;font-weight:600;line-height:1.35;}
109
+ .dsh-mws-subtitle{margin-top:3px;font-size:12px;color:var(--dsw-alias-label-tertiary);}
110
+ .dsh-mws-close{margin-left:auto;width:28px;height:28px;border:0;border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-tertiary);font-size:18px;line-height:1;cursor:pointer;}
111
+ .dsh-mws-close:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);}
112
+ .dsh-mws-body{padding:14px 18px 18px;overflow:auto;}
113
+ .dsh-mws-content{display:flex;flex-direction:column;gap:16px;}
114
+ .dsh-mws-section{display:flex;flex-direction:column;gap:8px;}
115
+ .dsh-mws-label{font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary);}
116
+ .dsh-mws-row{display:flex;align-items:center;gap:8px;}
117
+ .dsh-mws-grow{flex:1;min-width:0;}
118
+ .dsh-mws-input{height:30px;padding:0 9px;border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;}
119
+ .dsh-mws-input:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color);outline-offset:0;border-color:transparent;}
120
+ .dsh-mws-btn{height:30px;padding:0 11px;border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;cursor:pointer;white-space:nowrap;}
121
+ .dsh-mws-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);}
122
+ .dsh-mws-btn:disabled{opacity:.5;cursor:default;}
123
+ .dsh-mws-btn:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color);outline-offset:1px;}
124
+ .dsh-mws-primary{background:var(--dsw-alias-state-business-primary);border-color:transparent;color:var(--dsw-alias-label-primary-inverted);}
125
+ .dsh-mws-primary:hover:not(:disabled){filter:brightness(1.05);background:var(--dsw-alias-state-business-primary);}
126
+ .dsh-mws-danger{color:var(--dsw-alias-state-error-primary);}
127
+ .dsh-mws-hint{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary);}
128
+ .dsh-mws-warn{font-size:12px;line-height:1.5;color:var(--dsw-alias-state-warn-primary);}
129
+ .dsh-mws-error{padding:9px 11px;border:1px solid var(--dsw-alias-state-error-primary);border-radius:var(--dsw-radius-sm);color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:1.5;margin-bottom:12px;}
130
+ .dsh-mws-card{border:1px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-2);padding:10px;}
131
+ .dsh-mws-card-head{display:flex;align-items:flex-start;gap:8px;}
132
+ .dsh-mws-disclosure{width:20px;height:20px;padding:0;border:0;background:transparent;color:var(--dsw-alias-label-tertiary);font-size:11px;cursor:pointer;}
133
+ .dsh-mws-card-text{flex:1;min-width:0;}
134
+ .dsh-mws-card-name{display:flex;align-items:center;gap:8px;font-size:13px;font-weight:600;}
135
+ .dsh-mws-path{font-size:12px;color:var(--dsw-alias-label-tertiary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
136
+ .dsh-mws-chip{padding:1px 7px;border-radius:999px;font-size:11px;font-weight:500;line-height:1.6;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);}
137
+ .dsh-mws-chip.ok{color:var(--dsw-alias-state-success-primary);}
138
+ .dsh-mws-chip.warn{color:var(--dsw-alias-state-warn-primary);}
139
+ .dsh-mws-chip.bad{color:var(--dsw-alias-state-error-primary);}
140
+ .dsh-mws-actions{display:flex;align-items:center;gap:6px;flex-wrap:wrap;justify-content:flex-end;}
141
+ .dsh-mws-card-body{display:flex;flex-direction:column;gap:8px;margin:10px 0 0 28px;}
142
+ .dsh-mws-link{display:flex;align-items:center;gap:8px;padding:6px 8px;border:1px solid var(--dsw-alias-border-l1);border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-bg-layer-1);font-size:12px;}
143
+ .dsh-mws-code{font-family:var(--dsw-font-family);font-size:12px;color:var(--dsw-alias-label-secondary);}
144
+ .dsh-mws-arrow{color:var(--dsw-alias-label-tertiary);}
145
+ .dsh-mws-addlink{display:flex;flex-direction:column;gap:6px;padding:8px;border:1px dashed var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-sm);}
146
+ .dsh-mws-candidate{display:flex;align-items:center;gap:8px;padding:8px 10px;border:1px solid var(--dsw-alias-border-l1);border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-bg-layer-1);}
147
+ @media (max-width:520px){.dsh-mws-backdrop{padding:0;}.dsh-mws-dialog{max-height:100vh;height:100vh;border-radius:0;}}
148
+ `
149
+
150
+ function apply(ctx) {
151
+ const translate = (key) => {
152
+ try {
153
+ return ctx.locale.bind(NS)(key)
154
+ } catch {
155
+ return key
156
+ }
157
+ }
158
+
159
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-managed-workspaces: locale')
160
+
161
+ ctx.effect(() => {
162
+ const style = document.createElement('style')
163
+ style.setAttribute('data-dsh-managed-workspaces', '')
164
+ style.textContent = CSS
165
+ document.head.appendChild(style)
166
+ return () => {
167
+ try {
168
+ style.remove()
169
+ } catch {
170
+ // The document may already be tearing down.
171
+ }
172
+ }
173
+ }, 'dsh-managed-workspaces: styles')
174
+
175
+ let snapshot = { open: false, loading: false, data: null, error: null, busy: null }
176
+ const listeners = new Set()
177
+ const getSnapshot = () => snapshot
178
+ const setSnapshot = (patch) => {
179
+ snapshot = { ...snapshot, ...patch }
180
+ for (const listener of listeners) listener()
181
+ }
182
+ const subscribe = (listener) => {
183
+ listeners.add(listener)
184
+ return () => listeners.delete(listener)
185
+ }
186
+ const messageOf = (error) => (error instanceof Error ? error.message : String(error))
187
+
188
+ async function call(action, payload) {
189
+ const response = await fetch(API + action, {
190
+ method: 'POST',
191
+ headers: { 'content-type': 'application/json', 'x-dsh-managed-workspaces': '1' },
192
+ body: JSON.stringify(payload ?? {}),
193
+ credentials: 'same-origin',
194
+ })
195
+ const text = await response.text()
196
+ let body
197
+ try {
198
+ body = JSON.parse(text)
199
+ } catch {
200
+ throw new Error(`HTTP ${response.status}`)
201
+ }
202
+ if (!response.ok || body === null || body.ok !== true) {
203
+ const detail = body !== null && body.error !== undefined ? body.error.message : undefined
204
+ throw new Error(detail ?? `HTTP ${response.status}`)
205
+ }
206
+ return body
207
+ }
208
+
209
+ async function refresh() {
210
+ setSnapshot({ loading: true, error: null })
211
+ try {
212
+ const body = await call('state')
213
+ setSnapshot({ data: body, loading: false })
214
+ } catch (error) {
215
+ setSnapshot({ loading: false, error: messageOf(error) })
216
+ }
217
+ }
218
+
219
+ async function mutate(action, payload) {
220
+ setSnapshot({ busy: action, error: null })
221
+ try {
222
+ await call(action, payload)
223
+ setSnapshot({ busy: null })
224
+ await refresh()
225
+ return true
226
+ } catch (error) {
227
+ setSnapshot({ busy: null, error: messageOf(error) })
228
+ return false
229
+ }
230
+ }
231
+
232
+ async function pickFolder() {
233
+ try {
234
+ const picker = ctx.get('remote')?.directoryPicker
235
+ if (picker === undefined || typeof picker.pick !== 'function') return null
236
+ const result = await picker.pick()
237
+ return result !== null && result !== undefined && result.ok === true && typeof result.value === 'string'
238
+ ? result.value
239
+ : null
240
+ } catch {
241
+ return null
242
+ }
243
+ }
244
+
245
+ function icon() {
246
+ return h(
247
+ 'svg',
248
+ { width: 15, height: 15, viewBox: '0 0 16 16', 'aria-hidden': true, focusable: 'false' },
249
+ h('path', {
250
+ d: 'M2 4.5A1.5 1.5 0 0 1 3.5 3h2.4l1.2 1.4h4.4A1.5 1.5 0 0 1 13 5.9v5.6A1.5 1.5 0 0 1 11.5 13h-8A1.5 1.5 0 0 1 2 11.5z',
251
+ fill: 'none',
252
+ stroke: 'currentColor',
253
+ 'stroke-width': '1.2',
254
+ 'stroke-linejoin': 'round',
255
+ }),
256
+ h('path', {
257
+ d: 'M8 7.2v3.6M6.2 9h3.6',
258
+ stroke: 'currentColor',
259
+ 'stroke-width': '1.2',
260
+ 'stroke-linecap': 'round',
261
+ }),
262
+ )
263
+ }
264
+
265
+ function FooterButton(props) {
266
+ const t = props.t ?? translate
267
+ const state = React.useSyncExternalStore(subscribe, getSnapshot)
268
+ const wide = props.wide === true
269
+ const open = () => {
270
+ setSnapshot({ open: true })
271
+ if (snapshot.data === null && snapshot.loading === false) refresh()
272
+ }
273
+ return h(
274
+ 'button',
275
+ {
276
+ type: 'button',
277
+ className: wide ? 'dsh-mws-foot dsh-mws-foot-wide' : 'dsh-mws-foot',
278
+ title: t('title'),
279
+ 'aria-label': t('title'),
280
+ 'aria-haspopup': 'dialog',
281
+ 'aria-expanded': state.open ? 'true' : 'false',
282
+ onClick: open,
283
+ },
284
+ icon(),
285
+ wide ? h('span', { className: 'dsh-mws-foot-label' }, t('title')) : null,
286
+ )
287
+ }
288
+
289
+ function Overlay(props) {
290
+ const t = props.t ?? translate
291
+ const state = React.useSyncExternalStore(subscribe, getSnapshot)
292
+ const dialogRef = React.useRef(null)
293
+ React.useEffect(() => {
294
+ if (!state.open) return undefined
295
+ const onKey = (event) => {
296
+ if (event.key === 'Escape') setSnapshot({ open: false })
297
+ }
298
+ window.addEventListener('keydown', onKey, true)
299
+ return () => window.removeEventListener('keydown', onKey, true)
300
+ }, [state.open])
301
+ React.useEffect(() => {
302
+ if (state.open && snapshot.data === null && snapshot.loading === false) refresh()
303
+ if (state.open && dialogRef.current !== null) {
304
+ try {
305
+ dialogRef.current.focus()
306
+ } catch {
307
+ // Focus is best effort.
308
+ }
309
+ }
310
+ }, [state.open])
311
+ if (!state.open) return null
312
+ return h(
313
+ 'div',
314
+ {
315
+ className: 'dsh-mws-backdrop',
316
+ onMouseDown: (event) => {
317
+ if (event.target === event.currentTarget) setSnapshot({ open: false })
318
+ },
319
+ },
320
+ h(
321
+ 'div',
322
+ {
323
+ className: 'dsh-mws-dialog',
324
+ role: 'dialog',
325
+ 'aria-modal': 'true',
326
+ 'aria-label': t('title'),
327
+ tabIndex: -1,
328
+ ref: dialogRef,
329
+ },
330
+ h(
331
+ 'div',
332
+ { className: 'dsh-mws-head' },
333
+ h(
334
+ 'div',
335
+ null,
336
+ h('div', { className: 'dsh-mws-title' }, t('title')),
337
+ h('div', { className: 'dsh-mws-subtitle' }, t('subtitle')),
338
+ ),
339
+ h(
340
+ 'button',
341
+ {
342
+ type: 'button',
343
+ className: 'dsh-mws-close',
344
+ 'aria-label': t('close'),
345
+ onClick: () => setSnapshot({ open: false }),
346
+ },
347
+ '×',
348
+ ),
349
+ ),
350
+ h(
351
+ 'div',
352
+ { className: 'dsh-mws-body' },
353
+ state.error !== null ? h('div', { className: 'dsh-mws-error', role: 'alert' }, state.error) : null,
354
+ state.loading && state.data === null ? h('div', { className: 'dsh-mws-hint' }, t('loading')) : null,
355
+ state.data !== null
356
+ ? h(Content, { t, data: state.data, busy: state.busy })
357
+ : null,
358
+ ),
359
+ ),
360
+ )
361
+ }
362
+
363
+ function Content(props) {
364
+ const t = props.t
365
+ const data = props.data
366
+ const busy = props.busy
367
+ const [base, setBase] = React.useState(data.baseDirectory)
368
+ React.useEffect(() => setBase(data.baseDirectory), [data.baseDirectory])
369
+ const candidates = data.candidates ?? []
370
+ return h(
371
+ 'div',
372
+ { className: 'dsh-mws-content' },
373
+ h(
374
+ 'section',
375
+ { className: 'dsh-mws-section' },
376
+ h('div', { className: 'dsh-mws-label' }, t('baseLabel')),
377
+ h(
378
+ 'div',
379
+ { className: 'dsh-mws-row' },
380
+ h('input', {
381
+ className: 'dsh-mws-input dsh-mws-grow',
382
+ value: base,
383
+ spellCheck: false,
384
+ onChange: (event) => setBase(event.target.value),
385
+ onKeyDown: (event) => {
386
+ if (event.key === 'Enter') mutate('config', { baseDirectory: base })
387
+ },
388
+ }),
389
+ h(
390
+ 'button',
391
+ {
392
+ className: 'dsh-mws-btn',
393
+ type: 'button',
394
+ disabled: busy !== null,
395
+ onClick: async () => {
396
+ const picked = await pickFolder()
397
+ if (picked !== null) setBase(picked)
398
+ },
399
+ },
400
+ t('browse'),
401
+ ),
402
+ h(
403
+ 'button',
404
+ {
405
+ className: 'dsh-mws-btn dsh-mws-primary',
406
+ type: 'button',
407
+ disabled: busy !== null,
408
+ onClick: () => mutate('config', { baseDirectory: base }),
409
+ },
410
+ t('save'),
411
+ ),
412
+ ),
413
+ h('div', { className: 'dsh-mws-hint' }, t('baseHint')),
414
+ data.warnings.length > 0 ? h('div', { className: 'dsh-mws-warn' }, data.warnings.join(' ')) : null,
415
+ ),
416
+ h(CreateForm, { t, busy }),
417
+ data.candidates !== undefined && candidates.length > 0
418
+ ? h(
419
+ 'section',
420
+ { className: 'dsh-mws-section' },
421
+ h(
422
+ 'div',
423
+ { className: 'dsh-mws-row' },
424
+ h('div', { className: 'dsh-mws-label dsh-mws-grow' }, t('candidatesLabel') + ' (' + candidates.length + ')'),
425
+ h(
426
+ 'button',
427
+ {
428
+ type: 'button',
429
+ className: 'dsh-mws-btn',
430
+ disabled: busy !== null,
431
+ onClick: () => mutate('adopt', { all: true }),
432
+ },
433
+ t('adoptAll'),
434
+ ),
435
+ ),
436
+ h('div', { className: 'dsh-mws-hint' }, t('candidatesHint')),
437
+ candidates.map((candidate) =>
438
+ h(
439
+ 'div',
440
+ { key: candidate.path, className: 'dsh-mws-candidate' },
441
+ h(
442
+ 'div',
443
+ { className: 'dsh-mws-card-text' },
444
+ h('div', { className: 'dsh-mws-card-name' }, candidate.title),
445
+ h('div', { className: 'dsh-mws-path', title: candidate.path }, candidate.path),
446
+ ),
447
+ h(
448
+ 'button',
449
+ {
450
+ type: 'button',
451
+ className: 'dsh-mws-btn',
452
+ disabled: busy !== null,
453
+ onClick: () => mutate('adopt', { folder: candidate.path }),
454
+ },
455
+ t('adopt'),
456
+ ),
457
+ ),
458
+ ),
459
+ )
460
+ : null,
461
+ h(
462
+ 'section',
463
+ { className: 'dsh-mws-section' },
464
+ h('div', { className: 'dsh-mws-label' }, t('workspacesLabel')),
465
+ data.workspaces.length === 0
466
+ ? h('div', { className: 'dsh-mws-hint' }, t('workspacesEmpty'))
467
+ : data.workspaces.map((workspace) => h(WorkspaceCard, { key: workspace.id, t, ws: workspace, busy })),
468
+ ),
469
+ h('div', { className: 'dsh-mws-hint' }, t('footerHint')),
470
+ )
471
+ }
472
+
473
+ function CreateForm(props) {
474
+ const t = props.t
475
+ const busy = props.busy
476
+ const [name, setName] = React.useState('')
477
+ const submit = async () => {
478
+ if (name.trim() === '') return
479
+ const ok = await mutate('create', { name })
480
+ if (ok) setName('')
481
+ }
482
+ return h(
483
+ 'section',
484
+ { className: 'dsh-mws-section' },
485
+ h('div', { className: 'dsh-mws-label' }, t('createLabel')),
486
+ h(
487
+ 'div',
488
+ { className: 'dsh-mws-row' },
489
+ h('input', {
490
+ className: 'dsh-mws-input dsh-mws-grow',
491
+ value: name,
492
+ placeholder: t('createPlaceholder'),
493
+ spellCheck: false,
494
+ onChange: (event) => setName(event.target.value),
495
+ onKeyDown: (event) => {
496
+ if (event.key === 'Enter') submit()
497
+ },
498
+ }),
499
+ h(
500
+ 'button',
501
+ {
502
+ className: 'dsh-mws-btn dsh-mws-primary',
503
+ type: 'button',
504
+ disabled: busy !== null || name.trim() === '',
505
+ onClick: submit,
506
+ },
507
+ t('create'),
508
+ ),
509
+ ),
510
+ h('div', { className: 'dsh-mws-hint' }, t('createHint')),
511
+ )
512
+ }
513
+
514
+ function WorkspaceCard(props) {
515
+ const t = props.t
516
+ const ws = props.ws
517
+ const busy = props.busy
518
+ const [expanded, setExpanded] = React.useState(false)
519
+ const statusKey =
520
+ ws.directory !== 'ok'
521
+ ? 'status.missing'
522
+ : ws.registered
523
+ ? 'status.registered'
524
+ : 'status.unregistered'
525
+ const tone = ws.directory !== 'ok' ? 'bad' : ws.registered ? 'ok' : 'warn'
526
+ return h(
527
+ 'div',
528
+ { className: 'dsh-mws-card' },
529
+ h(
530
+ 'div',
531
+ { className: 'dsh-mws-card-head' },
532
+ h(
533
+ 'button',
534
+ {
535
+ type: 'button',
536
+ className: 'dsh-mws-disclosure',
537
+ 'aria-expanded': expanded ? 'true' : 'false',
538
+ 'aria-label': ws.name,
539
+ onClick: () => setExpanded(!expanded),
540
+ },
541
+ expanded ? '▾' : '▸',
542
+ ),
543
+ h(
544
+ 'div',
545
+ { className: 'dsh-mws-card-text' },
546
+ h(
547
+ 'div',
548
+ { className: 'dsh-mws-card-name' },
549
+ ws.name,
550
+ ws.origin === 'adopted' ? h('span', { className: 'dsh-mws-chip' }, t('origin.adopted')) : null,
551
+ h('span', { className: 'dsh-mws-chip ' + tone }, t(statusKey)),
552
+ ),
553
+ h('div', { className: 'dsh-mws-path', title: ws.path }, ws.path),
554
+ ),
555
+ h(
556
+ 'div',
557
+ { className: 'dsh-mws-actions' },
558
+ !ws.registered
559
+ ? h(
560
+ 'button',
561
+ {
562
+ type: 'button',
563
+ className: 'dsh-mws-btn',
564
+ disabled: busy !== null || ws.directory !== 'ok',
565
+ onClick: () => mutate('register', { id: ws.id }),
566
+ },
567
+ t('register'),
568
+ )
569
+ : null,
570
+ h(
571
+ 'button',
572
+ {
573
+ type: 'button',
574
+ className: 'dsh-mws-btn',
575
+ disabled: busy !== null,
576
+ onClick: () => mutate('repair', { id: ws.id }),
577
+ },
578
+ t('repair'),
579
+ ),
580
+ h(
581
+ 'button',
582
+ {
583
+ type: 'button',
584
+ className: 'dsh-mws-btn dsh-mws-danger',
585
+ disabled: busy !== null,
586
+ onClick: () => {
587
+ const adopted = ws.origin === 'adopted'
588
+ const template = adopted ? t('forgetAdoptedConfirm') : t('forgetConfirm')
589
+ if (window.confirm(template.replace('{name}', ws.name))) mutate('forget', { id: ws.id })
590
+ },
591
+ },
592
+ ws.origin === 'adopted' ? t('forgetAdopted') : t('forget'),
593
+ ),
594
+ ),
595
+ ),
596
+ expanded
597
+ ? h(
598
+ 'div',
599
+ { className: 'dsh-mws-card-body' },
600
+ h('div', { className: 'dsh-mws-label' }, t('linksLabel')),
601
+ ws.links.length === 0
602
+ ? h('div', { className: 'dsh-mws-hint' }, t('linksEmpty'))
603
+ : ws.links.map((link) => h(LinkRow, { key: link.alias, t, ws, link, busy })),
604
+ h(AddLinkForm, { t, ws, busy }),
605
+ h('div', { className: 'dsh-mws-hint' }, t('rootsHint').replace('{roots}', '_roots')),
606
+ )
607
+ : null,
608
+ )
609
+ }
610
+
611
+ function LinkRow(props) {
612
+ const t = props.t
613
+ const ws = props.ws
614
+ const link = props.link
615
+ const busy = props.busy
616
+ return h(
617
+ 'div',
618
+ { className: 'dsh-mws-link' },
619
+ h('code', { className: 'dsh-mws-code' }, '_roots/' + link.alias),
620
+ h('span', { className: 'dsh-mws-arrow' }, '→'),
621
+ h('span', { className: 'dsh-mws-path dsh-mws-grow', title: link.target }, link.target),
622
+ h('span', { className: 'dsh-mws-chip ' + (link.health === 'ok' ? 'ok' : 'bad') }, t('health.' + link.health)),
623
+ h(
624
+ 'button',
625
+ {
626
+ type: 'button',
627
+ className: 'dsh-mws-btn',
628
+ disabled: busy !== null,
629
+ onClick: () => mutate('detach', { id: ws.id, alias: link.alias }),
630
+ },
631
+ t('detach'),
632
+ ),
633
+ )
634
+ }
635
+
636
+ function AddLinkForm(props) {
637
+ const t = props.t
638
+ const ws = props.ws
639
+ const busy = props.busy
640
+ const [folder, setFolder] = React.useState('')
641
+ const [alias, setAlias] = React.useState('')
642
+ const submit = async () => {
643
+ if (folder.trim() === '') return
644
+ const payload = { id: ws.id, folder }
645
+ if (alias.trim() !== '') payload.alias = alias
646
+ const ok = await mutate('attach', payload)
647
+ if (ok) {
648
+ setFolder('')
649
+ setAlias('')
650
+ }
651
+ }
652
+ return h(
653
+ 'div',
654
+ { className: 'dsh-mws-addlink' },
655
+ h(
656
+ 'div',
657
+ { className: 'dsh-mws-row' },
658
+ h('input', {
659
+ className: 'dsh-mws-input dsh-mws-grow',
660
+ value: folder,
661
+ placeholder: t('linkFolder'),
662
+ spellCheck: false,
663
+ onChange: (event) => setFolder(event.target.value),
664
+ onKeyDown: (event) => {
665
+ if (event.key === 'Enter') submit()
666
+ },
667
+ }),
668
+ h(
669
+ 'button',
670
+ {
671
+ type: 'button',
672
+ className: 'dsh-mws-btn',
673
+ onClick: async () => {
674
+ const picked = await pickFolder()
675
+ if (picked !== null) setFolder(picked)
676
+ },
677
+ },
678
+ t('browse'),
679
+ ),
680
+ ),
681
+ h(
682
+ 'div',
683
+ { className: 'dsh-mws-row' },
684
+ h('input', {
685
+ className: 'dsh-mws-input dsh-mws-grow',
686
+ value: alias,
687
+ placeholder: t('linkAlias'),
688
+ spellCheck: false,
689
+ onChange: (event) => setAlias(event.target.value),
690
+ onKeyDown: (event) => {
691
+ if (event.key === 'Enter') submit()
692
+ },
693
+ }),
694
+ h(
695
+ 'button',
696
+ {
697
+ type: 'button',
698
+ className: 'dsh-mws-btn dsh-mws-primary',
699
+ disabled: busy !== null || folder.trim() === '',
700
+ onClick: submit,
701
+ },
702
+ t('attach'),
703
+ ),
704
+ ),
705
+ )
706
+ }
707
+
708
+ ctx.slots.inject('sidebar.footer.action', () =>
709
+ ctx.slots.register(
710
+ {
711
+ name: 'sidebar.footer.action',
712
+ id: 'dsh-managed-workspaces',
713
+ order: 40,
714
+ label: translate('title'),
715
+ locale: NS,
716
+ },
717
+ FooterButton,
718
+ ),
719
+ )
720
+
721
+ ctx.slots.inject('shell.overlay', () =>
722
+ ctx.slots.register(
723
+ {
724
+ name: 'shell.overlay',
725
+ id: 'dsh-managed-workspaces',
726
+ order: 900,
727
+ locale: NS,
728
+ },
729
+ Overlay,
730
+ ),
731
+ )
732
+
733
+ return () => {
734
+ listeners.clear()
735
+ }
736
+ }
737
+
738
+ return { inject: ['slots', 'locale'], apply }
739
+ },
740
+ })