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