dsh-workspace-plus 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js ADDED
@@ -0,0 +1,1412 @@
1
+ /**
2
+ * dsh-workspace-plus — browser half.
3
+ *
4
+ * This file is the package's `./client` export: a self-contained, factory-form
5
+ * CommonJS bundle registered with the shell's client module loader. The host
6
+ * serves it at `/plugins/dsh-workspace-plus/client.js`; the browser loads the
7
+ * script during boot but only *materializes* it (runs the factory body) when the
8
+ * plugin is first needed, so every side effect here — the injected stylesheet
9
+ * included — happens at materialization time, never at script-parse time.
10
+ *
11
+ * The feature — workspace directories, as a DIALOG rather than a page.
12
+ * A DSH workspace keeps its one root directory; this registers N more project
13
+ * folders against it, each with a short label, and the host injects that table
14
+ * into the agent's context on every request — so "改 backend" in conversation
15
+ * resolves to an absolute path instead of a guess.
16
+ *
17
+ * Four seats cooperate:
18
+ *
19
+ * `settings.section` the `workspace+` settings page. It owns one preference:
20
+ * whether the feature runs for the default (first-use)
21
+ * Workspace, which is off until the user turns it on
22
+ * because that Workspace belongs to the product.
23
+ *
24
+ * `shell.overlay` the dialog itself, the same frame-wide layer the
25
+ * shell's own workspace Rename/Delete dialogs use (the
26
+ * Modal portals to <body>, so the entry owns only the
27
+ * open state, not the stack position). Its browse control
28
+ * calls the shell's own directory picker through
29
+ * `ctx.uiWorkspace.pickDirectory()`.
30
+ *
31
+ * workspace row button the primary trigger. The shell's workspace row menu is
32
+ * hardcoded to Rename/Delete with no plugin slot, so the
33
+ * trigger is a button this half appends to the row's
34
+ * action cluster, located through shell-emitted
35
+ * `data-row-key="workspace:<id>"` anchors and kept in
36
+ * place by a MutationObserver. Rows the host reports as
37
+ * disabled get no button.
38
+ *
39
+ * `sidebar.panellist` the fallback trigger, registered only while no row
40
+ * button could be installed (a shell whose row DOM
41
+ * changed, or a sidebar with no workspaces yet).
42
+ * Registering it also requires a `main` key: the shell's
43
+ * panel button calls `layout.selectPanel` unconditionally
44
+ * and that throws for an unregistered key, so a hint
45
+ * panel holds the key and the glyph stops the click from
46
+ * ever reaching it.
47
+ *
48
+ * Only the shell's frozen platform module table may be `require`d here (react,
49
+ * react-dom, react/jsx-runtime, @deepseek-ai/cordis,
50
+ * @deepseek-ai/dsh-client-store, @deepseek-ai/dsh-client-ui-slots,
51
+ * @deepseek-ai/dsh-client-ui-primitives, @deepseek-ai/dsh-client-ui-dockkit).
52
+ * Any other specifier needs a matching `dsh.client.external` entry in
53
+ * package.json, which this plugin does not use.
54
+ */
55
+ window.__ModuleLoader__.load({
56
+ id: 'dsh-workspace-plus',
57
+ factory: (require) => {
58
+ var module = { exports: {} }
59
+ var exports = module.exports
60
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
61
+
62
+ const React = require('react')
63
+ const { Button, Modal, Switch } = require('@deepseek-ai/dsh-client-ui-primitives')
64
+
65
+ /** Id of the directory-dialog fallback entry, and the main key that backs it. */
66
+ const WORKDIRS_PANEL_ID = 'workdirs'
67
+
68
+ /** Sidebar row label of the directory-dialog fallback entry. */
69
+ const WORKDIRS_PANEL_LABEL = 'workdirs'
70
+
71
+ /**
72
+ * Position among the global panel rows. The shipped entries use 0 (Plugins)
73
+ * and 10 (Scheduled tasks); 30 keeps this one after them.
74
+ */
75
+ const WORKDIRS_PANEL_ORDER = 30
76
+
77
+ /**
78
+ * The settings page. `settings.section` occupants ship at -10 (account),
79
+ * 0 (general), 10 (models), 15 (plugins) and 20 (agent presets); 25 puts
80
+ * `workspace+` at the end of the product sections and before nothing else.
81
+ */
82
+ const SETTINGS_SECTION_ID = 'workspaceplus'
83
+ const SETTINGS_SECTION_LABEL = 'workspace+'
84
+ const SETTINGS_SECTION_ORDER = 25
85
+
86
+ /** The settings key this page owns. */
87
+ const DEFAULT_WORKSPACE_SETTING = 'defaultWorkspaceEnabled'
88
+
89
+ /** Host JSON endpoint, resolved against the page so it survives any mount path. */
90
+ const API_PATH = 'workspaceplus/api'
91
+
92
+ /** Identity of the injected stylesheet, used to keep exactly one copy. */
93
+ const STYLE_ID = 'dsh-workspace-plus/panel.css'
94
+
95
+ /**
96
+ * Row anchors. The shell emits `data-slot="<slot key>"` on every slot outlet
97
+ * and `data-row-key="workspace:<workspaceId>"` on a workspace group row, and
98
+ * a row's last element child is its action cluster.
99
+ */
100
+ const SIDEBAR_SLOT_SELECTOR = '[data-slot="sidebar.workspaces"]'
101
+ const WORKSPACE_ROW_SELECTOR = '[data-row-key^="workspace:"]'
102
+ const ROW_KEY_PREFIX = 'workspace:'
103
+
104
+ /** Class of the button this half appends to a workspace row. */
105
+ const ROW_BUTTON_CLASS = 'dsh-workspace-plus-rowbtn'
106
+
107
+ /** Label of the injected row button. */
108
+ const ROW_BUTTON_LABEL = '工作区目录设置'
109
+
110
+ /**
111
+ * The shell's own folder-open artwork, copied verbatim from
112
+ * `@deepseek-ai/dsh-client-ui-primitives` (`IconFolderOpenArtwork`). It is
113
+ * static path data rather than a runtime import, so the injected button is
114
+ * pixel-identical to the row icons the shell draws beside it without adding a
115
+ * dependency on an icon export name.
116
+ */
117
+ const FOLDER_ARTWORK = [
118
+ {
119
+ d: 'M2.55912 7.93683C2.67584 7.49906 3.0723 7.19446 3.52536 7.19446H13.6491C14.3061 7.19446 14.7846 7.81725 14.6153 8.45209L13.4411 12.856C13.3244 13.2938 12.9279 13.5984 12.4748 13.5984H2.35113C1.69411 13.5984 1.21562 12.9756 1.38489 12.3407L2.55912 7.93683Z',
120
+ opacity: '0.16',
121
+ },
122
+ {
123
+ d: 'M13.6491 6.69446C14.6346 6.69453 15.3522 7.62895 15.0983 8.58118L13.9245 12.9845C13.7494 13.6412 13.1539 14.0988 12.4743 14.0988H2.35126C1.36574 14.0988 0.648153 13.1643 0.902044 12.212L2.07587 7.80774C2.25102 7.15128 2.84567 6.69455 3.52509 6.69446H13.6491ZM3.52509 7.69446C3.29865 7.69455 3.10004 7.84674 3.04169 8.06555L1.86786 12.4698C1.78345 12.7872 2.02285 13.0988 2.35126 13.0988H12.4743C12.7007 13.0988 12.8992 12.9463 12.9577 12.7277L14.1325 8.32336C14.2171 8.00598 13.9776 7.69453 13.6491 7.69446H3.52509Z',
124
+ },
125
+ {
126
+ d: 'M4.7666 1.90137C5.13227 1.90144 5.48571 2.03525 5.75977 2.27734L7.27246 3.61328C7.36379 3.69382 7.48174 3.73828 7.60352 3.73828H12.3994C13.2276 3.73841 13.8993 4.41005 13.8994 5.23828V6.7168C13.8183 6.70327 13.735 6.69436 13.6494 6.69434H12.8994V5.23828C12.8993 4.96233 12.6754 4.73841 12.3994 4.73828H7.60352C7.23781 4.73828 6.88446 4.60438 6.61035 4.3623L5.09766 3.02637C5.00636 2.94576 4.88838 2.90144 4.7666 2.90137H2.0498C1.77366 2.90137 1.5498 3.12523 1.5498 3.40137V9.78223L0.902344 12.2119C0.648452 13.1642 1.36604 14.0986 2.35156 14.0986H2.0498C1.2214 14.0986 0.549838 13.427 0.549805 12.5986V3.40137C0.549805 2.57294 1.22138 1.90137 2.0498 1.90137H4.7666Z',
127
+ },
128
+ ]
129
+
130
+ /** Glyph of the injected row button, built from the official artwork above. */
131
+ const ROW_BUTTON_GLYPH =
132
+ '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" ' +
133
+ 'aria-hidden="true" focusable="false">' +
134
+ FOLDER_ARTWORK.map(
135
+ (path) => `<path d="${path.d}" fill="currentColor"${path.opacity === undefined ? '' : ` opacity="${path.opacity}"`}/>`,
136
+ ).join('') +
137
+ '</svg>'
138
+
139
+ /**
140
+ * How long the fallback entry waits before deciding that no workspace-row
141
+ * button can exist, so a healthy shell never flashes it.
142
+ */
143
+ const FALLBACK_SETTLE_MS = 1200
144
+
145
+ /**
146
+ * Panel styles. Everything is namespaced under `dsh-workspace-plus-` and
147
+ * colored through the shell's theme tokens, so the surfaces follow the active
148
+ * light/dark theme instead of hard-coding colors. Fallbacks after each token
149
+ * keep them readable on a shell that predates a given token.
150
+ */
151
+ const CSS = `
152
+ .dsh-workspace-plus-panel{box-sizing:border-box;display:flex;align-items:center;justify-content:center;flex:1 1 auto;height:100%;min-height:0;padding:32px 24px;overflow-y:auto;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font-size:14px;line-height:1.6}
153
+ .dsh-workspace-plus-card{box-sizing:border-box;width:min(520px,100%);display:flex;flex-direction:column;align-items:center;gap:14px;padding:36px 32px 30px;border:1px solid var(--dsw-alias-border-l1);border-radius:var(--dsw-radius-lg,14px);background:var(--dsw-alias-bg-layer-1);text-align:center;animation:dsh-workspace-plus-in .18s var(--ds-ease-in-out,ease-out) both}
154
+ .dsh-workspace-plus-badge{display:inline-flex;align-items:center;justify-content:center;width:56px;height:56px;border-radius:50%;color:var(--dsw-alias-brand-primary);background:var(--dsw-alias-bg-layer-2)}
155
+ .dsh-workspace-plus-glyph{display:block}
156
+ .dsh-workspace-plus-title{margin:0;font-size:24px;font-weight:600;letter-spacing:.2px}
157
+ .dsh-workspace-plus-subtitle{margin:0;max-width:42ch;color:var(--dsw-alias-label-secondary)}
158
+ .dsh-workspace-plus-actions{display:flex;align-items:center;gap:12px;margin-top:6px}
159
+ .dsh-workspace-plus-settings{flex-direction:column;width:100%;display:flex}
160
+ .dsh-workspace-plus-settings-row{border-bottom:.5px solid var(--dsw-alias-border-l2);justify-content:space-between;align-items:center;gap:24px;padding:16px 0;display:flex}
161
+ .dsh-workspace-plus-settings-text{min-width:0}
162
+ .dsh-workspace-plus-settings-title{font-size:14px;line-height:20px}
163
+ .dsh-workspace-plus-settings-description{color:var(--dsw-alias-label-secondary);margin-top:4px;font-size:12px;line-height:18px}
164
+ .dsh-workspace-plus-settings-error{color:var(--dsw-alias-state-error-primary);margin-top:8px;font-size:12px;line-height:18px}
165
+ .dsh-workspace-plus-settings-ok{color:var(--dsw-alias-state-success-primary);margin-top:8px;font-size:12px;line-height:18px}
166
+ .dsh-workspace-plus-rowbtn{display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;padding:0;border:none;flex:none;cursor:pointer;border-radius:var(--dsw-radius-xs,4px);background:0 0;color:var(--dsw-alias-label-tertiary)}
167
+ .dsh-workspace-plus-rowbtn:hover{color:var(--dsw-alias-label-primary)}
168
+ .dsh-workspace-plus-rowbtn:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-brand-primary));outline-offset:-2px}
169
+ .dsh-workspace-plus-dialog{box-sizing:border-box;display:flex;flex-direction:column;gap:12px;color:var(--dsw-alias-label-primary);font-size:14px;line-height:1.6}
170
+ .dsh-workspace-plus-toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
171
+ .dsh-workspace-plus-label{color:var(--dsw-alias-label-secondary);flex:none}
172
+ .dsh-workspace-plus-locked{font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
173
+ .dsh-workspace-plus-select,.dsh-workspace-plus-input{box-sizing:border-box;font:inherit;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-l1));border-radius:var(--dsw-radius-md,8px);padding:6px 9px;min-width:0}
174
+ .dsh-workspace-plus-select{max-width:100%;flex:1 1 240px}
175
+ .dsh-workspace-plus-input:focus-visible,.dsh-workspace-plus-select:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-brand-primary));outline-offset:1px}
176
+ .dsh-workspace-plus-input-path{flex:1 1 260px;font-family:var(--dsw-font-mono,ui-monospace,SFMono-Regular,Menlo,monospace)}
177
+ .dsh-workspace-plus-input-label{flex:0 1 140px}
178
+ .dsh-workspace-plus-input-note{flex:1 1 180px}
179
+ .dsh-workspace-plus-list{box-sizing:border-box;width:100%;max-height:34vh;overflow-y:auto;border:1px solid var(--dsw-alias-border-l1);border-radius:var(--dsw-radius-md,8px)}
180
+ .dsh-workspace-plus-row{display:flex;align-items:center;gap:12px;padding:9px 12px;background:var(--dsw-alias-bg-layer-1);border-top:1px solid var(--dsw-alias-border-l1)}
181
+ .dsh-workspace-plus-row:first-child{border-top:none}
182
+ .dsh-workspace-plus-row-tag{flex:none;min-width:76px;padding:1px 8px;border-radius:999px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-brand-primary);font-weight:600;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
183
+ .dsh-workspace-plus-row-path{min-width:0;flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--dsw-font-mono,ui-monospace,SFMono-Regular,Menlo,monospace)}
184
+ .dsh-workspace-plus-row-note{flex:0 1 auto;color:var(--dsw-alias-label-secondary);max-width:24ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
185
+ .dsh-workspace-plus-row-outside{flex:none;color:var(--dsw-alias-state-warn-primary);font-size:12px}
186
+ .dsh-workspace-plus-form{display:flex;flex-direction:column;gap:8px;width:100%}
187
+ .dsh-workspace-plus-form-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;width:100%}
188
+ .dsh-workspace-plus-empty{padding:14px 12px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-1)}
189
+ .dsh-workspace-plus-note{color:var(--dsw-alias-label-secondary);font-size:13px}
190
+ .dsh-workspace-plus-error{color:var(--dsw-alias-state-error-primary)}
191
+ .dsh-workspace-plus-ok{color:var(--dsw-alias-state-success-primary)}
192
+ .dsh-workspace-plus-mono{font-family:var(--dsw-font-mono,ui-monospace,SFMono-Regular,Menlo,monospace);word-break:break-all}
193
+ @keyframes dsh-workspace-plus-in{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
194
+ @media (prefers-reduced-motion:reduce){.dsh-workspace-plus-card{animation:none}}
195
+ `
196
+
197
+ /**
198
+ * Append the panel stylesheet once per document. The factory body runs at
199
+ * materialization, so this is a one-time boot-time side effect; the
200
+ * `data-plugin-css` marker both deduplicates it and lets the shell's HMR
201
+ * teardown find the tag as plugin-owned.
202
+ */
203
+ function ensureStyles() {
204
+ if (typeof document === 'undefined') return
205
+ if (document.querySelector('style[data-plugin-css=' + JSON.stringify(STYLE_ID) + ']') !== null) return
206
+ const tag = document.createElement('style')
207
+ tag.dataset.plugin = 'dsh-workspace-plus'
208
+ tag.dataset.pluginCss = STYLE_ID
209
+ tag.textContent = CSS
210
+ document.head.appendChild(tag)
211
+ }
212
+
213
+ ensureStyles()
214
+
215
+ // -----------------------------------------------------------------------
216
+ // The workspace-directory feature
217
+ // -----------------------------------------------------------------------
218
+
219
+ /**
220
+ * The directory glyph, shared by the fallback sidebar entry and the hint
221
+ * panel. It draws the same official artwork as the injected row button, so
222
+ * every entry point to this feature looks like the shell's own icons.
223
+ *
224
+ * @param props - sidebar icon share, plus the optional click handler.
225
+ * @returns the icon element.
226
+ */
227
+ function WorkdirsPanelIcon({ size = 16, onClick }) {
228
+ return React.createElement(
229
+ 'svg',
230
+ {
231
+ className: 'dsh-workspace-plus-glyph',
232
+ width: size,
233
+ height: size,
234
+ viewBox: '0 0 16 16',
235
+ fill: 'none',
236
+ xmlns: 'http://www.w3.org/2000/svg',
237
+ 'aria-hidden': 'true',
238
+ focusable: 'false',
239
+ ...(onClick === undefined ? {} : { onClick }),
240
+ },
241
+ FOLDER_ARTWORK.map((path, index) =>
242
+ React.createElement('path', {
243
+ key: index,
244
+ d: path.d,
245
+ fill: 'currentColor',
246
+ ...(path.opacity === undefined ? {} : { opacity: path.opacity }),
247
+ }),
248
+ ),
249
+ )
250
+ }
251
+
252
+ /**
253
+ * The fallback entry's glyph. Its click is stopped before the shell's panel
254
+ * button sees it, so the sidebar row opens the dialog instead of switching
255
+ * `main`; the hint panel behind that key is what a user sees if this ever
256
+ * stops working.
257
+ *
258
+ * @param props - sidebar icon share.
259
+ * @returns the icon element.
260
+ */
261
+ function WorkdirsEntryIcon(props) {
262
+ return WorkdirsPanelIcon({
263
+ ...props,
264
+ onClick: (event) => {
265
+ event.preventDefault()
266
+ event.stopPropagation()
267
+ openWorkdirsDialog(null)
268
+ },
269
+ })
270
+ }
271
+
272
+ /**
273
+ * Bundle-local open state for the dialog. Its two triggers live in different
274
+ * React trees from the dialog (one is not React at all), so they meet on this
275
+ * tiny external store rather than on a shared parent.
276
+ */
277
+ let dialogSnapshot = { open: false, workspaceId: null }
278
+ const dialogListeners = new Set()
279
+
280
+ /** @returns the current dialog snapshot (referentially stable until it changes). */
281
+ function readDialog() {
282
+ return dialogSnapshot
283
+ }
284
+
285
+ /**
286
+ * Subscribe to dialog open-state changes.
287
+ *
288
+ * @param listener - called after each change.
289
+ * @returns the unsubscribe function.
290
+ */
291
+ function subscribeDialog(listener) {
292
+ dialogListeners.add(listener)
293
+ return () => {
294
+ dialogListeners.delete(listener)
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Replace the dialog snapshot and notify subscribers.
300
+ *
301
+ * @param next - the next `{ open, workspaceId }`.
302
+ */
303
+ function writeDialog(next) {
304
+ const previous = dialogSnapshot
305
+ if (previous.open === next.open && previous.workspaceId === next.workspaceId) return
306
+ dialogSnapshot = next
307
+ for (const listener of [...dialogListeners]) listener()
308
+ }
309
+
310
+ /**
311
+ * Open the dialog, optionally focused on one workspace.
312
+ *
313
+ * @param workspaceId - the workspace to preselect, or null to keep the current one.
314
+ */
315
+ function openWorkdirsDialog(workspaceId = null) {
316
+ writeDialog({ open: true, workspaceId })
317
+ }
318
+
319
+ /** Close the dialog. */
320
+ function closeWorkdirsDialog() {
321
+ writeDialog({ open: false, workspaceId: null })
322
+ }
323
+
324
+ /** The dialog's state before the first host response arrives. */
325
+ const WORKDIRS_IDLE = {
326
+ status: 'loading',
327
+ message: '',
328
+ ok: null,
329
+ registryAvailable: true,
330
+ workspaces: [],
331
+ selected: null,
332
+ locked: false,
333
+ busy: false,
334
+ }
335
+
336
+ /**
337
+ * Resolve the host endpoint against the page, so the same code works under a
338
+ * root mount, under a nested mount, and under the desktop shell's
339
+ * `dsh-app://app/` origin (which forwards every non-asset path to the Host).
340
+ *
341
+ * @returns the resolved endpoint URL.
342
+ */
343
+ function apiUrl() {
344
+ if (typeof document === 'undefined') return API_PATH
345
+ try {
346
+ return new URL(API_PATH, document.baseURI).toString()
347
+ } catch {
348
+ return API_PATH
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Read the workspace list plus every registered directory from the host.
354
+ *
355
+ * @param signal - optional abort signal.
356
+ * @returns the parsed payload, or `{ ok: false, message }`.
357
+ */
358
+ async function fetchWorkspaces(signal) {
359
+ const response = await fetch(apiUrl(), {
360
+ method: 'GET',
361
+ headers: { accept: 'application/json' },
362
+ credentials: 'same-origin',
363
+ signal,
364
+ })
365
+ if (!response.ok) return { ok: false, message: 'host responded ' + response.status }
366
+ return await response.json()
367
+ }
368
+
369
+ /**
370
+ * Send one mutation to the host.
371
+ *
372
+ * @param payload - `{ op, workspace, label, path, note }`.
373
+ * @returns the parsed payload, or `{ ok: false, message }`.
374
+ */
375
+ async function postOperation(payload) {
376
+ const response = await fetch(apiUrl(), {
377
+ method: 'POST',
378
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
379
+ credentials: 'same-origin',
380
+ body: JSON.stringify(payload),
381
+ })
382
+ if (!response.ok) return { ok: false, message: 'host responded ' + response.status }
383
+ return await response.json()
384
+ }
385
+
386
+ /**
387
+ * The last host payload this page has seen, shared by the three client
388
+ * surfaces: the settings page reads the settings and the default-workspace
389
+ * identity from it, the row installer consults the per-workspace `enabled`
390
+ * flag, and the dialog refreshes it after every mutation.
391
+ */
392
+ let hostState = {
393
+ known: false,
394
+ settings: { [DEFAULT_WORKSPACE_SETTING]: false },
395
+ defaultWorkspaceId: null,
396
+ defaultWorkspacePath: null,
397
+ workspaces: [],
398
+ }
399
+ const hostStateListeners = new Set()
400
+
401
+ /** @returns the current host snapshot (referentially stable until it changes). */
402
+ function readHostState() {
403
+ return hostState
404
+ }
405
+
406
+ /**
407
+ * Subscribe to host-snapshot changes.
408
+ *
409
+ * @param listener - called after each change.
410
+ * @returns the unsubscribe function.
411
+ */
412
+ function subscribeHostState(listener) {
413
+ hostStateListeners.add(listener)
414
+ return () => {
415
+ hostStateListeners.delete(listener)
416
+ }
417
+ }
418
+
419
+ /**
420
+ * Fold a host payload into the shared snapshot. Unusable payloads leave the
421
+ * previous snapshot alone.
422
+ *
423
+ * @param payload - a parsed `/workspaceplus/api` response.
424
+ * @returns whether the payload was usable.
425
+ */
426
+ function publishHostPayload(payload) {
427
+ if (payload === null || typeof payload !== 'object' || payload.ok !== true) return false
428
+ hostState = {
429
+ known: true,
430
+ settings: { [DEFAULT_WORKSPACE_SETTING]: payload.settings?.[DEFAULT_WORKSPACE_SETTING] === true },
431
+ defaultWorkspaceId: typeof payload.defaultWorkspaceId === 'string' ? payload.defaultWorkspaceId : null,
432
+ defaultWorkspacePath: typeof payload.defaultWorkspacePath === 'string' ? payload.defaultWorkspacePath : null,
433
+ workspaces: Array.isArray(payload.workspaces) ? payload.workspaces : [],
434
+ }
435
+ for (const listener of [...hostStateListeners]) listener()
436
+ return true
437
+ }
438
+
439
+ /**
440
+ * Whether the feature runs for one workspace id. A row whose workspace is not
441
+ * in the snapshot yet keeps its entry point: hiding it on a guess would remove
442
+ * the UI from a perfectly ordinary workspace.
443
+ *
444
+ * @param workspaceId - the id taken from the row's `data-row-key`.
445
+ * @returns true when the row should carry the directory button.
446
+ */
447
+ function isEnabledWorkspace(workspaceId) {
448
+ const workspace = hostState.workspaces.find((candidate) => candidate.id === workspaceId)
449
+ return workspace === undefined ? true : workspace.enabled !== false
450
+ }
451
+
452
+ /**
453
+ * Refresh the shared snapshot from the host.
454
+ *
455
+ * @returns whether a usable payload arrived.
456
+ */
457
+ async function refreshHostState() {
458
+ try {
459
+ return publishHostPayload(await fetchWorkspaces())
460
+ } catch {
461
+ return false
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Pick the workspace the dialog shows: the requested id when it still exists,
467
+ * otherwise whatever was selected, otherwise the first one.
468
+ *
469
+ * @param workspaces - the host's workspace list.
470
+ * @param requestedId - the id the trigger asked for.
471
+ * @param currentPath - the path currently selected.
472
+ * @returns the resolved workspace path, or null.
473
+ */
474
+ function selectWorkspace(workspaces, requestedId, currentPath) {
475
+ const requested = requestedId === null || requestedId === undefined
476
+ ? undefined
477
+ : workspaces.find((workspace) => workspace.id === requestedId)
478
+ if (requested !== undefined) return requested.path
479
+ if (currentPath !== null && currentPath !== undefined && workspaces.some((workspace) => workspace.path === currentPath)) {
480
+ return currentPath
481
+ }
482
+ return workspaces[0]?.path ?? null
483
+ }
484
+
485
+ /**
486
+ * The shell's directory picker (`ctx.uiWorkspace`), captured while the plugin
487
+ * is mounted. It is injected optionally rather than declared in `inject`, so a
488
+ * composition without ui-workspace keeps the whole plugin working — the dialog
489
+ * simply loses its browse button.
490
+ */
491
+ let directoryPicker = null
492
+
493
+ /**
494
+ * The last path segment of a directory path, for either separator.
495
+ *
496
+ * @param path - any directory path.
497
+ * @returns the final segment, or an empty string.
498
+ */
499
+ function basenameOf(path) {
500
+ const parts = String(path).replace(/[\\/]+$/, '').split(/[\\/]/)
501
+ return parts[parts.length - 1] ?? ''
502
+ }
503
+
504
+ /**
505
+ * Suggest a label for a picked directory. The host's label grammar is
506
+ * `[A-Za-z0-9][A-Za-z0-9._-]{0,31}`, so anything else is folded to dashes and
507
+ * dropped when nothing valid survives (a CJK directory name yields no guess
508
+ * rather than a label the host would reject).
509
+ *
510
+ * @param path - the picked directory.
511
+ * @returns a valid label, or an empty string when nothing sensible survives.
512
+ */
513
+ function suggestLabel(path) {
514
+ const cleaned = basenameOf(path)
515
+ .replace(/[^A-Za-z0-9._-]+/g, '-')
516
+ .replace(/^[^A-Za-z0-9]+/, '')
517
+ .slice(0, 32)
518
+ return /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/.test(cleaned) ? cleaned : ''
519
+ }
520
+
521
+ /**
522
+ * Fold a picked directory into the draft: the path always follows the picker,
523
+ * and an empty label is filled from the directory's own name.
524
+ *
525
+ * @param draft - the current `{ label, path, note }`.
526
+ * @param pickedPath - the directory the picker returned.
527
+ * @returns the next draft.
528
+ */
529
+ function draftAfterPick(draft, pickedPath) {
530
+ return {
531
+ ...draft,
532
+ path: pickedPath,
533
+ label: draft.label.trim() === '' ? suggestLabel(pickedPath) : draft.label,
534
+ }
535
+ }
536
+
537
+ /**
538
+ * Message shown when the shell's picker fails. A cancelled pick resolves to
539
+ * `null` and never reaches here; this path is a real failure (no picker
540
+ * backend, or the browse flow erroring), so it must be visible.
541
+ *
542
+ * @param error - whatever the picker rejected with.
543
+ * @returns the user-facing message.
544
+ */
545
+ function pickErrorMessage(error) {
546
+ return '打开目录选择器失败:' + String((error && error.message) || error)
547
+ }
548
+
549
+ /**
550
+ * Whether a registered directory sits outside its workspace root. Such a
551
+ * directory is readable everywhere but only writable when the session's file
552
+ * policy is `danger-full-access`, so the dialog marks it instead of letting
553
+ * the user discover that from a denied write.
554
+ *
555
+ * @param workspacePath - the workspace root directory.
556
+ * @param dirPath - the registered directory.
557
+ * @returns true when the directory is outside the workspace root.
558
+ */
559
+ function isOutside(workspacePath, dirPath) {
560
+ const fold = (value) => String(value).replace(/[\\/]+$/, '').toLowerCase()
561
+ const root = fold(workspacePath)
562
+ const target = fold(dirPath)
563
+ return target !== root && !target.startsWith(root + '\\') && !target.startsWith(root + '/')
564
+ }
565
+
566
+ /**
567
+ * One registered directory row.
568
+ *
569
+ * @param props - the row's directory, its workspace root, and the remove handler.
570
+ * @returns the row element.
571
+ */
572
+ function DirectoryRow({ dir, workspacePath, onRemove, disabled }) {
573
+ const outside = isOutside(workspacePath, dir.path)
574
+ return React.createElement(
575
+ 'div',
576
+ { className: 'dsh-workspace-plus-row' },
577
+ React.createElement('span', { className: 'dsh-workspace-plus-row-tag' }, dir.label),
578
+ React.createElement('span', { className: 'dsh-workspace-plus-row-path', title: dir.path }, dir.path),
579
+ dir.note === undefined ? null : React.createElement('span', { className: 'dsh-workspace-plus-row-note' }, dir.note),
580
+ outside ? React.createElement('span', { className: 'dsh-workspace-plus-row-outside' }, '工作区根目录之外') : null,
581
+ React.createElement(
582
+ Button,
583
+ { variant: 'outline', size: 'sm', disabled, onClick: () => onRemove(dir.label) },
584
+ '删除',
585
+ ),
586
+ )
587
+ }
588
+
589
+ /**
590
+ * The dialog entry registered into `shell.overlay`.
591
+ *
592
+ * `props.initial` and `props.dialog` exist only as a test seam — the shell
593
+ * renders overlay entries with its own standard props and no owner props.
594
+ *
595
+ * @param props - optional initial state for tests.
596
+ * @returns the dialog element (or null while closed).
597
+ */
598
+ function WorkdirsDialog(props) {
599
+ const dialog = React.useSyncExternalStore(subscribeDialog, readDialog)
600
+ const [state, setState] = React.useState(props?.initial ?? WORKDIRS_IDLE)
601
+ const [draft, setDraft] = React.useState({ label: '', path: '', note: '' })
602
+ const [picking, setPicking] = React.useState(false)
603
+
604
+ const patch = (changes) => setState((current) => ({ ...current, ...changes }))
605
+
606
+ /** Fold one successful host payload into the dialog state. */
607
+ const applyPayload = (payload, requestedId) => {
608
+ publishHostPayload(payload)
609
+ if (payload === null || typeof payload !== 'object' || payload.ok !== true) {
610
+ setState((current) => ({
611
+ ...current,
612
+ status: 'error',
613
+ busy: false,
614
+ ok: false,
615
+ message: (payload && payload.message) || 'host returned an unusable payload',
616
+ }))
617
+ return
618
+ }
619
+ const workspaces = Array.isArray(payload.workspaces) ? payload.workspaces : []
620
+ // The trigger names the workspace, so the dialog edits THAT one and shows
621
+ // no picker. Only a trigger without a workspace (the sidebar fallback)
622
+ // leaves the choice open.
623
+ const locked = requestedId === null || requestedId === undefined
624
+ ? null
625
+ : workspaces.find((workspace) => workspace.id === requestedId) ?? null
626
+ setState((current) => ({
627
+ ...current,
628
+ status: 'ready',
629
+ busy: false,
630
+ ok: null,
631
+ message: '',
632
+ workspaces,
633
+ registryAvailable: payload.registryAvailable !== false,
634
+ locked: locked !== null,
635
+ selected: locked !== null ? locked.path : selectWorkspace(workspaces, requestedId, current.selected),
636
+ }))
637
+ }
638
+
639
+ const load = async (requestedId) => {
640
+ patch({ busy: true })
641
+ try {
642
+ applyPayload(await fetchWorkspaces(), requestedId)
643
+ } catch (error) {
644
+ patch({ status: 'error', busy: false, ok: false, message: String((error && error.message) || error) })
645
+ }
646
+ }
647
+
648
+ const submit = async (payload) => {
649
+ patch({ busy: true })
650
+ try {
651
+ const result = await postOperation(payload)
652
+ if (result === null || typeof result !== 'object') {
653
+ patch({ busy: false, ok: false, message: 'host returned an unusable payload' })
654
+ return false
655
+ }
656
+ publishHostPayload(result)
657
+ const workspaces = Array.isArray(result.workspaces) ? result.workspaces : state.workspaces
658
+ setState((current) => ({
659
+ ...current,
660
+ status: 'ready',
661
+ busy: false,
662
+ ok: result.ok === true,
663
+ message: String(result.message ?? ''),
664
+ workspaces,
665
+ registryAvailable: result.registryAvailable !== false,
666
+ selected: selectWorkspace(workspaces, null, current.selected),
667
+ }))
668
+ return result.ok === true
669
+ } catch (error) {
670
+ patch({ status: 'error', busy: false, ok: false, message: String((error && error.message) || error) })
671
+ return false
672
+ }
673
+ }
674
+
675
+ // Refetch whenever the dialog opens, and whenever a trigger names a
676
+ // different workspace, so the table is never stale.
677
+ const open = dialog.open
678
+ const requestedId = dialog.workspaceId
679
+ React.useEffect(() => {
680
+ if (!open) return undefined
681
+ let cancelled = false
682
+ const controller = typeof AbortController === 'undefined' ? undefined : new AbortController()
683
+ ;(async () => {
684
+ try {
685
+ const payload = await fetchWorkspaces(controller?.signal)
686
+ if (cancelled) return
687
+ applyPayload(payload, requestedId)
688
+ } catch (error) {
689
+ if (cancelled) return
690
+ setState((current) => ({
691
+ ...current,
692
+ status: 'error',
693
+ busy: false,
694
+ ok: false,
695
+ message: String((error && error.message) || error),
696
+ }))
697
+ }
698
+ })()
699
+ return () => {
700
+ cancelled = true
701
+ controller?.abort()
702
+ }
703
+ }, [open, requestedId])
704
+
705
+ const selected = state.workspaces.find((workspace) => workspace.path === state.selected) ?? null
706
+ const dirs = selected === null ? [] : selected.dirs
707
+
708
+ const addDirectory = async () => {
709
+ if (selected === null) return
710
+ const added = await submit({
711
+ op: 'add',
712
+ workspace: selected.path,
713
+ label: draft.label,
714
+ path: draft.path,
715
+ note: draft.note,
716
+ })
717
+ if (added) setDraft({ label: '', path: '', note: '' })
718
+ }
719
+
720
+ const removeDirectory = async (label) => {
721
+ if (selected === null) return
722
+ await submit({ op: 'remove', workspace: selected.path, label })
723
+ }
724
+
725
+ /**
726
+ * Ask the shell's directory picker for a directory. A cancelled picker
727
+ * resolves to `null` and changes nothing; a failed one reports the reason
728
+ * where the add result goes, so the dialog never fails silently.
729
+ */
730
+ const browse = async () => {
731
+ if (directoryPicker === null || directoryPicker === undefined) return
732
+ setPicking(true)
733
+ try {
734
+ const picked = await directoryPicker.pickDirectory()
735
+ if (typeof picked === 'string' && picked !== '') setDraft((current) => draftAfterPick(current, picked))
736
+ } catch (error) {
737
+ patch({ ok: false, message: pickErrorMessage(error) })
738
+ } finally {
739
+ setPicking(false)
740
+ }
741
+ }
742
+
743
+ const toolbar = React.createElement(
744
+ 'div',
745
+ { className: 'dsh-workspace-plus-toolbar' },
746
+ React.createElement('span', { className: 'dsh-workspace-plus-label' }, '工作区'),
747
+ state.locked
748
+ ? React.createElement('span', { className: 'dsh-workspace-plus-locked' }, selected?.title || selected?.path || '')
749
+ : React.createElement(
750
+ 'select',
751
+ {
752
+ className: 'dsh-workspace-plus-select',
753
+ value: state.selected ?? '',
754
+ disabled: state.workspaces.length === 0,
755
+ 'aria-label': '选择工作区',
756
+ onChange: (event) => patch({ selected: event.target.value === '' ? null : event.target.value }),
757
+ },
758
+ state.workspaces.length === 0
759
+ ? React.createElement('option', { value: '' }, '(没有可用工作区)')
760
+ : state.workspaces.map((workspace) =>
761
+ React.createElement(
762
+ 'option',
763
+ { key: workspace.id, value: workspace.path },
764
+ (workspace.title || workspace.path) + ' — ' + workspace.path,
765
+ ),
766
+ ),
767
+ ),
768
+ state.locked
769
+ ? React.createElement('span', { className: 'dsh-workspace-plus-note dsh-workspace-plus-mono' }, selected?.path ?? '')
770
+ : null,
771
+ )
772
+
773
+ const body = (() => {
774
+ if (state.status === 'loading') {
775
+ return React.createElement(
776
+ 'div',
777
+ { className: 'dsh-workspace-plus-list' },
778
+ React.createElement('div', { className: 'dsh-workspace-plus-empty' }, '正在读取 host…'),
779
+ )
780
+ }
781
+ if (state.status === 'error') {
782
+ const unauthorized = state.message.includes('401')
783
+ const unrouted = state.message.includes('404')
784
+ /**
785
+ * The three failures need different advice:
786
+ * - 401: this document never completed the token exchange, so the
787
+ * connection layer has no authority to admit the request.
788
+ * - 404: the request reached the Host and nothing claimed the path.
789
+ * The browser half hot-reloads from the served bundle, the host half
790
+ * does not, so a stale Host process is the ordinary cause.
791
+ * - anything else: the transport itself (a page with no HTTP carrier).
792
+ */
793
+ const diagnosis = unauthorized
794
+ ? '这个页面没有带授权的会话凭据。请用 `dsh` 启动时打印的带 token 的地址重新打开 GUI(它会写入 cookie,然后跳转到干净地址)。'
795
+ : unrouted
796
+ ? '请求到达了 DSH 宿主进程,但宿主里没有注册 ' +
797
+ API_PATH +
798
+ ' 这条路由——通常意味着宿主进程还在跑旧版插件代码。浏览器半会随 bundle 热更新,宿主半不会:重启 dsh 即可。' +
799
+ '重启后终端会出现 [dsh-workspace-plus] host half loaded 一行;若没有,请到 设置 → 插件 查看该插件行的状态。'
800
+ : '这个面板通过 ' +
801
+ API_PATH +
802
+ ' 与宿主进程通信,而当前页面没有可用的 HTTP 通道(例如页面不是 dsh 提供的、或宿主进程还没起来)。'
803
+ return React.createElement(
804
+ 'div',
805
+ { className: 'dsh-workspace-plus-list' },
806
+ React.createElement(
807
+ 'div',
808
+ { className: 'dsh-workspace-plus-empty' },
809
+ React.createElement('div', { className: 'dsh-workspace-plus-error' }, '无法连接 host 接口:' + state.message),
810
+ React.createElement('div', { className: 'dsh-workspace-plus-note' }, diagnosis),
811
+ React.createElement(
812
+ 'div',
813
+ { className: 'dsh-workspace-plus-note' },
814
+ '宿主半加载之后,映射也可以在对话里用 workspace_dirs 工具管理。',
815
+ ),
816
+ ),
817
+ )
818
+ }
819
+ if (!state.registryAvailable) {
820
+ return React.createElement(
821
+ 'div',
822
+ { className: 'dsh-workspace-plus-list' },
823
+ React.createElement(
824
+ 'div',
825
+ { className: 'dsh-workspace-plus-empty' },
826
+ '宿主进程没有加载 workspace 注册表,无法列出工作区。请在对话里用 workspace_dirs 工具按绝对路径管理标签。',
827
+ ),
828
+ )
829
+ }
830
+ if (selected === null) {
831
+ return React.createElement(
832
+ 'div',
833
+ { className: 'dsh-workspace-plus-list' },
834
+ React.createElement('div', { className: 'dsh-workspace-plus-empty' }, '还没有任何工作区。先在左侧边栏新建一个工作区,再回到这里登记目录。'),
835
+ )
836
+ }
837
+ return React.createElement(
838
+ 'div',
839
+ { className: 'dsh-workspace-plus-list' },
840
+ dirs.length === 0
841
+ ? React.createElement('div', { className: 'dsh-workspace-plus-empty' }, '这个工作区还没有登记额外目录。用下面的表单添加第一个标签。')
842
+ : dirs.map((dir) =>
843
+ React.createElement(DirectoryRow, {
844
+ key: dir.label,
845
+ dir,
846
+ workspacePath: selected.path,
847
+ disabled: state.busy,
848
+ onRemove: (label) => void removeDirectory(label),
849
+ }),
850
+ ),
851
+ )
852
+ })()
853
+
854
+ const form = React.createElement(
855
+ 'div',
856
+ { className: 'dsh-workspace-plus-form' },
857
+ React.createElement(
858
+ 'div',
859
+ { className: 'dsh-workspace-plus-form-row' },
860
+ React.createElement('input', {
861
+ className: 'dsh-workspace-plus-input dsh-workspace-plus-input-label',
862
+ placeholder: '标签,如 backend',
863
+ 'aria-label': '标签',
864
+ value: draft.label,
865
+ disabled: selected === null || state.busy,
866
+ onChange: (event) => setDraft({ ...draft, label: event.target.value }),
867
+ }),
868
+ React.createElement('input', {
869
+ className: 'dsh-workspace-plus-input dsh-workspace-plus-input-note',
870
+ placeholder: '说明(可选)',
871
+ 'aria-label': '说明',
872
+ value: draft.note,
873
+ disabled: selected === null || state.busy,
874
+ onChange: (event) => setDraft({ ...draft, note: event.target.value }),
875
+ }),
876
+ ),
877
+ React.createElement(
878
+ 'div',
879
+ { className: 'dsh-workspace-plus-form-row' },
880
+ React.createElement('input', {
881
+ className: 'dsh-workspace-plus-input dsh-workspace-plus-input-path',
882
+ placeholder: '绝对路径,如 D:\\proj\\backend',
883
+ 'aria-label': '目录绝对路径',
884
+ value: draft.path,
885
+ disabled: selected === null || state.busy,
886
+ onChange: (event) => setDraft({ ...draft, path: event.target.value }),
887
+ }),
888
+ React.createElement(
889
+ Button,
890
+ {
891
+ variant: 'outline',
892
+ disabled: selected === null || state.busy || picking || directoryPicker === null,
893
+ onClick: () => void browse(),
894
+ },
895
+ picking ? '选择中…' : '浏览…',
896
+ ),
897
+ React.createElement(
898
+ Button,
899
+ { variant: 'primary', disabled: selected === null || state.busy, onClick: () => void addDirectory() },
900
+ '添加',
901
+ ),
902
+ ),
903
+ )
904
+
905
+ const status = React.createElement(
906
+ 'div',
907
+ { className: 'dsh-workspace-plus-toolbar' },
908
+ state.message === ''
909
+ ? null
910
+ : React.createElement(
911
+ 'span',
912
+ { className: state.ok === false ? 'dsh-workspace-plus-error' : 'dsh-workspace-plus-ok' },
913
+ state.message,
914
+ ),
915
+ // The locked header already names the root; only the unlocked picker needs it repeated.
916
+ state.locked || selected === null
917
+ ? null
918
+ : React.createElement(
919
+ 'span',
920
+ { className: 'dsh-workspace-plus-note' },
921
+ '根目录:',
922
+ React.createElement('span', { className: 'dsh-workspace-plus-mono' }, selected.path),
923
+ ),
924
+ )
925
+
926
+ const footnote = React.createElement(
927
+ 'div',
928
+ { className: 'dsh-workspace-plus-note' },
929
+ '标签映射会自动注入该工作区对话的 agent 上下文,所以直接在对话里说「改 backend」就够了。',
930
+ '注意:DSH 沙箱把会话的可写范围限制在工作区根目录内;会话权限不是 danger-full-access 时,',
931
+ '根目录之外的目录只能读,这里会把这类目录标注出来。',
932
+ )
933
+
934
+ return React.createElement(
935
+ Modal,
936
+ {
937
+ open,
938
+ onClose: closeWorkdirsDialog,
939
+ title: '工作区目录设置',
940
+ closeLabel: '关闭',
941
+ description:
942
+ '一个工作区可以登记 N 个目录,每个目录带一个标签。这份映射会自动注入该工作区对话的 agent 上下文,' +
943
+ 'agent 用标签对应的绝对路径,不会因为目录名相似而改错文件。',
944
+ footer: React.createElement(
945
+ React.Fragment,
946
+ null,
947
+ React.createElement(
948
+ Button,
949
+ { variant: 'outline', disabled: state.busy, onClick: () => void load(requestedId) },
950
+ '刷新',
951
+ ),
952
+ React.createElement(Button, { variant: 'primary', onClick: closeWorkdirsDialog }, '完成'),
953
+ ),
954
+ },
955
+ React.createElement('div', { className: 'dsh-workspace-plus-dialog' }, toolbar, status, body, form, footnote),
956
+ )
957
+ }
958
+
959
+ /**
960
+ * The `workspace+` settings page, registered into `settings.section`.
961
+ *
962
+ * It owns exactly one preference: whether label routing runs for the default
963
+ * (first-use) Workspace. Off by default, because that Workspace belongs to the
964
+ * product rather than to this plugin.
965
+ *
966
+ * @param props - optional initial state for tests.
967
+ * @returns the settings section element.
968
+ */
969
+ function WorkspaceplusSettings(props) {
970
+ const host = React.useSyncExternalStore(subscribeHostState, readHostState)
971
+ const [state, setState] = React.useState(props?.initial ?? { status: 'loading', message: '', ok: null, busy: false })
972
+
973
+ const patch = (changes) => setState((current) => ({ ...current, ...changes }))
974
+
975
+ React.useEffect(() => {
976
+ let cancelled = false
977
+ const controller = typeof AbortController === 'undefined' ? undefined : new AbortController()
978
+ ;(async () => {
979
+ try {
980
+ const payload = await fetchWorkspaces(controller?.signal)
981
+ if (cancelled) return
982
+ if (!publishHostPayload(payload)) {
983
+ setState((current) => ({
984
+ ...current,
985
+ status: 'error',
986
+ message: (payload && payload.message) || 'host returned an unusable payload',
987
+ }))
988
+ return
989
+ }
990
+ setState((current) => ({ ...current, status: 'ready', message: '' }))
991
+ } catch (error) {
992
+ if (cancelled) return
993
+ setState((current) => ({
994
+ ...current,
995
+ status: 'error',
996
+ message: String((error && error.message) || error),
997
+ }))
998
+ }
999
+ })()
1000
+ return () => {
1001
+ cancelled = true
1002
+ controller?.abort()
1003
+ }
1004
+ }, [])
1005
+
1006
+ const toggle = async (next) => {
1007
+ patch({ busy: true })
1008
+ try {
1009
+ const result = await postOperation({ op: 'settings', settings: { [DEFAULT_WORKSPACE_SETTING]: next } })
1010
+ if (result === null || typeof result !== 'object') {
1011
+ patch({ busy: false, ok: false, message: 'host returned an unusable payload' })
1012
+ return
1013
+ }
1014
+ publishHostPayload(result)
1015
+ patch({ busy: false, ok: result.ok === true, message: String(result.message ?? '') })
1016
+ } catch (error) {
1017
+ patch({ busy: false, ok: false, message: String((error && error.message) || error) })
1018
+ }
1019
+ }
1020
+
1021
+ const enabled = host.settings[DEFAULT_WORKSPACE_SETTING] === true
1022
+ const unavailable = state.status === 'error'
1023
+
1024
+ const row = React.createElement(
1025
+ 'div',
1026
+ { className: 'dsh-workspace-plus-settings-row' },
1027
+ React.createElement(
1028
+ 'div',
1029
+ { className: 'dsh-workspace-plus-settings-text' },
1030
+ React.createElement('div', { className: 'dsh-workspace-plus-settings-title' }, '对默认工作区启用'),
1031
+ React.createElement(
1032
+ 'div',
1033
+ { className: 'dsh-workspace-plus-settings-description' },
1034
+ '默认工作区是 DSH 首次启动时自动创建的那个,它默认不启用 workspace+:',
1035
+ '不在它的工作区行显示目录按钮,也不会把标签表注入它的会话上下文。',
1036
+ '其它工作区不受这个开关影响,始终启用。',
1037
+ ),
1038
+ ),
1039
+ React.createElement(Switch, {
1040
+ checked: enabled,
1041
+ disabled: state.busy || unavailable,
1042
+ label: '对默认工作区启用',
1043
+ onChange: (next) => void toggle(next),
1044
+ }),
1045
+ )
1046
+
1047
+ const target = React.createElement(
1048
+ 'div',
1049
+ { className: 'dsh-workspace-plus-settings-description' },
1050
+ '当前默认工作区:',
1051
+ host.defaultWorkspacePath === null
1052
+ ? state.status === 'loading'
1053
+ ? '正在读取…'
1054
+ : '未识别(这个部署还没有默认工作区记录)'
1055
+ : React.createElement('span', { className: 'dsh-workspace-plus-mono' }, host.defaultWorkspacePath),
1056
+ )
1057
+
1058
+ const status = (() => {
1059
+ // A failed READ needs the transport diagnosis; a rejected WRITE only needs
1060
+ // the host's own message.
1061
+ if (state.status === 'error') {
1062
+ const unrouted = state.message.includes('404')
1063
+ return React.createElement(
1064
+ 'div',
1065
+ { className: 'dsh-workspace-plus-settings-error' },
1066
+ '无法读取宿主设置:' + state.message,
1067
+ unrouted
1068
+ ? '(宿主里没有 ' + API_PATH + ' 这条路由,通常意味着 dsh 还没重启)'
1069
+ : '',
1070
+ )
1071
+ }
1072
+ if (state.message === '') return null
1073
+ return React.createElement(
1074
+ 'div',
1075
+ { className: state.ok === false ? 'dsh-workspace-plus-settings-error' : 'dsh-workspace-plus-settings-ok' },
1076
+ state.message,
1077
+ )
1078
+ })()
1079
+
1080
+ return React.createElement(
1081
+ 'div',
1082
+ { className: 'dsh-workspace-plus-settings' },
1083
+ row,
1084
+ target,
1085
+ status,
1086
+ React.createElement(
1087
+ 'div',
1088
+ { className: 'dsh-workspace-plus-settings-description' },
1089
+ '标签本身在每个工作区的「工作区目录设置」弹窗里维护:悬停左侧工作区那一行的目录按钮即可打开。',
1090
+ '关掉这个开关不会删除已登记的标签,只是让默认工作区在启用前保持原样。',
1091
+ ),
1092
+ )
1093
+ }
1094
+
1095
+ /**
1096
+ * The `main` panel that holds the fallback entry's key. The shell's sidebar
1097
+ * button always calls `layout.selectPanel(id)` — which throws for an
1098
+ * unregistered key — so this key must exist even though the intended path is
1099
+ * the dialog.
1100
+ *
1101
+ * @returns the hint panel element.
1102
+ */
1103
+ function WorkdirsHintPanel() {
1104
+ return React.createElement(
1105
+ 'div',
1106
+ { className: 'dsh-workspace-plus-panel' },
1107
+ React.createElement(
1108
+ 'div',
1109
+ { className: 'dsh-workspace-plus-card' },
1110
+ React.createElement(
1111
+ 'div',
1112
+ { className: 'dsh-workspace-plus-badge' },
1113
+ React.createElement(WorkdirsPanelIcon, { size: 28 }),
1114
+ ),
1115
+ React.createElement('h1', { className: 'dsh-workspace-plus-title' }, '工作区目录设置'),
1116
+ React.createElement(
1117
+ 'p',
1118
+ { className: 'dsh-workspace-plus-subtitle' },
1119
+ '这个功能以弹窗形式提供:点击侧边栏工作区那一行的目录按钮即可打开。',
1120
+ ),
1121
+ React.createElement(Button, { variant: 'primary', onClick: () => openWorkdirsDialog(null) }, '现在打开'),
1122
+ ),
1123
+ )
1124
+ }
1125
+
1126
+ /**
1127
+ * Append the directory button to every workspace row and keep it there.
1128
+ *
1129
+ * The shell's workspace row menu is hardcoded to Rename/Delete and exposes no
1130
+ * slot, so the trigger is appended to the row's action cluster instead. The
1131
+ * anchors are shell-emitted data attributes rather than hashed class names:
1132
+ * `data-slot="sidebar.workspaces"` on the browser outlet and
1133
+ * `data-row-key="workspace:<workspaceId>"` on a group row (that id is
1134
+ * `Workspace.id` from the host registry).
1135
+ *
1136
+ * The button is appended last in the cluster, where React's own child
1137
+ * reconciliation never has to insert before it. A MutationObserver reconciles
1138
+ * additions and removals, because React re-renders the row without knowing
1139
+ * this node exists.
1140
+ *
1141
+ * A workspace whose `enabled` flag is false (the default Workspace before the
1142
+ * user opts in) gets no button, and loses one if it is disabled while mounted —
1143
+ * `refresh()` re-runs the reconcile after a settings change.
1144
+ *
1145
+ * @param options - the availability callback, fired when the injected count changes.
1146
+ * @returns `{ dispose, available, refresh }`.
1147
+ */
1148
+ function installWorkspaceRowButtons({ onAvailability } = {}) {
1149
+ if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') {
1150
+ return { dispose: () => {}, available: () => false, refresh: () => {} }
1151
+ }
1152
+ const injected = new Map()
1153
+ let scheduled = false
1154
+ let stopped = false
1155
+ let lastReported = null
1156
+
1157
+ const report = () => {
1158
+ const available = injected.size > 0
1159
+ if (available === lastReported) return
1160
+ lastReported = available
1161
+ if (typeof onAvailability === 'function') onAvailability(available)
1162
+ }
1163
+
1164
+ const reconcile = () => {
1165
+ scheduled = false
1166
+ if (stopped) return
1167
+ const container = document.querySelector(SIDEBAR_SLOT_SELECTOR)
1168
+ // Nothing is mounted yet: reporting here would flash the fallback entry.
1169
+ if (container === null) return
1170
+ const rows = container.querySelectorAll(WORKSPACE_ROW_SELECTOR)
1171
+ const live = new Set(rows)
1172
+ for (const [row, button] of [...injected]) {
1173
+ const workspaceId = (row.getAttribute('data-row-key') ?? '').slice(ROW_KEY_PREFIX.length)
1174
+ const stillWanted = live.has(row) && row.isConnected && workspaceId !== '' && isEnabledWorkspace(workspaceId)
1175
+ if (stillWanted) continue
1176
+ button.remove()
1177
+ injected.delete(row)
1178
+ }
1179
+ for (const row of rows) {
1180
+ const workspaceId = (row.getAttribute('data-row-key') ?? '').slice(ROW_KEY_PREFIX.length)
1181
+ // The synthetic "ungrouped" row carries the empty key and owns no workspace.
1182
+ if (workspaceId === '') continue
1183
+ // The default Workspace stays untouched until the user opts in.
1184
+ if (!isEnabledWorkspace(workspaceId)) continue
1185
+ const existing = injected.get(row)
1186
+ if (existing !== undefined && existing.isConnected) continue
1187
+ const actions = row.lastElementChild
1188
+ if (actions === null || actions === undefined) continue
1189
+ const button = document.createElement('button')
1190
+ button.type = 'button'
1191
+ button.className = ROW_BUTTON_CLASS
1192
+ button.title = ROW_BUTTON_LABEL
1193
+ button.setAttribute('aria-label', ROW_BUTTON_LABEL)
1194
+ button.innerHTML = ROW_BUTTON_GLYPH
1195
+ button.addEventListener('click', (event) => {
1196
+ // Keep the click off the row, whose own handler toggles the group.
1197
+ event.preventDefault()
1198
+ event.stopPropagation()
1199
+ openWorkdirsDialog(workspaceId)
1200
+ })
1201
+ actions.appendChild(button)
1202
+ injected.set(row, button)
1203
+ }
1204
+ report()
1205
+ }
1206
+
1207
+ const schedule = () => {
1208
+ if (scheduled || stopped) return
1209
+ scheduled = true
1210
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(reconcile)
1211
+ else setTimeout(reconcile, 16)
1212
+ }
1213
+
1214
+ const observer = new MutationObserver(schedule)
1215
+ observer.observe(document.body, { childList: true, subtree: true })
1216
+ reconcile()
1217
+
1218
+ return {
1219
+ available: () => injected.size > 0,
1220
+ refresh: () => {
1221
+ if (stopped) return
1222
+ reconcile()
1223
+ },
1224
+ dispose: () => {
1225
+ stopped = true
1226
+ observer.disconnect()
1227
+ for (const button of injected.values()) button.remove()
1228
+ injected.clear()
1229
+ },
1230
+ }
1231
+ }
1232
+
1233
+ // -----------------------------------------------------------------------
1234
+ // Plugin
1235
+ // -----------------------------------------------------------------------
1236
+
1237
+ /**
1238
+ * Services this browser plugin needs. `slots` is required to contribute
1239
+ * anything at all; the sidebar buttons and the panel selection are owned by
1240
+ * the shell, so this half never touches `ctx.layout` itself.
1241
+ */
1242
+ const inject = ['slots']
1243
+
1244
+ /**
1245
+ * Contribute the `workspace+` settings page, the directory dialog, its
1246
+ * fallback entry, and the workspace-row buttons that open it.
1247
+ *
1248
+ * Every registration goes through `ctx.slots.inject(key, callback)`: the
1249
+ * callback runs as soon as the shell declares that slot (immediately when it
1250
+ * already exists), and its returned disposer is owned by this plugin's fiber,
1251
+ * so unloading the plugin removes the entry again.
1252
+ *
1253
+ * @param ctx - the browser plugin context.
1254
+ */
1255
+ function apply(ctx) {
1256
+ // The settings page owns the default-Workspace switch.
1257
+ ctx.effect(
1258
+ () =>
1259
+ ctx.slots.inject('settings.section', () =>
1260
+ ctx.slots.register(
1261
+ {
1262
+ name: 'settings.section',
1263
+ id: SETTINGS_SECTION_ID,
1264
+ order: SETTINGS_SECTION_ORDER,
1265
+ label: SETTINGS_SECTION_LABEL,
1266
+ },
1267
+ WorkspaceplusSettings,
1268
+ ),
1269
+ ),
1270
+ 'dsh-workspace-plus: settings section',
1271
+ )
1272
+
1273
+ // The dialog lives in the frame-wide overlay: the same seat the shell's own
1274
+ // workspace Rename/Delete dialogs occupy.
1275
+ ctx.effect(
1276
+ () =>
1277
+ ctx.slots.inject('shell.overlay', () =>
1278
+ ctx.slots.register({ name: 'shell.overlay', id: 'workdirs-dialog', order: 50 }, WorkdirsDialog),
1279
+ ),
1280
+ 'dsh-workspace-plus: workdirs dialog',
1281
+ )
1282
+
1283
+ // The fallback entry's main key always exists; the entry itself is
1284
+ // registered only while no workspace-row button could be installed.
1285
+ ctx.effect(
1286
+ () =>
1287
+ ctx.slots.inject('main', () =>
1288
+ ctx.slots.register({ name: 'main', key: WORKDIRS_PANEL_ID }, WorkdirsHintPanel),
1289
+ ),
1290
+ 'dsh-workspace-plus: workdirs fallback panel',
1291
+ )
1292
+
1293
+ // The dialog's browse button asks the shell's own picker. Injected
1294
+ // optionally: a composition without ui-workspace still loads this plugin,
1295
+ // the button just stays disabled.
1296
+ ctx.effect(
1297
+ () =>
1298
+ ctx.inject(['uiWorkspace'], (uiScope) => {
1299
+ directoryPicker = uiScope.uiWorkspace
1300
+ return () => {
1301
+ directoryPicker = null
1302
+ }
1303
+ }),
1304
+ 'dsh-workspace-plus: directory picker',
1305
+ )
1306
+
1307
+ let registerEntry = null
1308
+ let entryDispose = null
1309
+ let rowEntryAvailable = false
1310
+ let settled = false
1311
+
1312
+ const syncEntry = () => {
1313
+ if (registerEntry === null) return
1314
+ const wantFallback = !rowEntryAvailable && settled
1315
+ if (wantFallback && entryDispose === null) entryDispose = registerEntry()
1316
+ if (!wantFallback && entryDispose !== null) {
1317
+ entryDispose()
1318
+ entryDispose = null
1319
+ }
1320
+ }
1321
+
1322
+ ctx.effect(
1323
+ () =>
1324
+ ctx.slots.inject('sidebar.panellist', () => {
1325
+ registerEntry = () =>
1326
+ ctx.slots.register(
1327
+ {
1328
+ name: 'sidebar.panellist',
1329
+ id: WORKDIRS_PANEL_ID,
1330
+ order: WORKDIRS_PANEL_ORDER,
1331
+ label: WORKDIRS_PANEL_LABEL,
1332
+ },
1333
+ WorkdirsEntryIcon,
1334
+ )
1335
+ syncEntry()
1336
+ return () => {
1337
+ registerEntry = null
1338
+ if (entryDispose !== null) {
1339
+ entryDispose()
1340
+ entryDispose = null
1341
+ }
1342
+ }
1343
+ }),
1344
+ 'dsh-workspace-plus: workdirs fallback entry',
1345
+ )
1346
+
1347
+ const settleTimer = setTimeout(() => {
1348
+ settled = true
1349
+ syncEntry()
1350
+ }, FALLBACK_SETTLE_MS)
1351
+
1352
+ const rowButtons = installWorkspaceRowButtons({
1353
+ onAvailability: (available) => {
1354
+ rowEntryAvailable = available
1355
+ settled = true
1356
+ syncEntry()
1357
+ },
1358
+ })
1359
+
1360
+ // Warm the shared snapshot at boot and keep the row button in step with the
1361
+ // settings: a switch flipped in the settings page adds or removes it without
1362
+ // a reload.
1363
+ const offHostState = subscribeHostState(() => rowButtons.refresh())
1364
+ void refreshHostState()
1365
+
1366
+ ctx.effect(
1367
+ () => () => {
1368
+ clearTimeout(settleTimer)
1369
+ offHostState()
1370
+ rowButtons.dispose()
1371
+ },
1372
+ 'dsh-workspace-plus: workspace row buttons',
1373
+ )
1374
+ }
1375
+
1376
+ exports.WORKDIRS_PANEL_ID = WORKDIRS_PANEL_ID
1377
+ exports.WORKDIRS_PANEL_LABEL = WORKDIRS_PANEL_LABEL
1378
+ exports.SETTINGS_SECTION_ID = SETTINGS_SECTION_ID
1379
+ exports.SETTINGS_SECTION_LABEL = SETTINGS_SECTION_LABEL
1380
+ exports.SETTINGS_SECTION_ORDER = SETTINGS_SECTION_ORDER
1381
+ exports.DEFAULT_WORKSPACE_SETTING = DEFAULT_WORKSPACE_SETTING
1382
+ exports.API_PATH = API_PATH
1383
+ exports.ROW_BUTTON_CLASS = ROW_BUTTON_CLASS
1384
+ exports.ROW_BUTTON_LABEL = ROW_BUTTON_LABEL
1385
+ exports.SIDEBAR_SLOT_SELECTOR = SIDEBAR_SLOT_SELECTOR
1386
+ exports.WORKSPACE_ROW_SELECTOR = WORKSPACE_ROW_SELECTOR
1387
+ exports.FALLBACK_SETTLE_MS = FALLBACK_SETTLE_MS
1388
+ exports.readDialog = readDialog
1389
+ exports.subscribeDialog = subscribeDialog
1390
+ exports.openWorkdirsDialog = openWorkdirsDialog
1391
+ exports.closeWorkdirsDialog = closeWorkdirsDialog
1392
+ exports.readHostState = readHostState
1393
+ exports.subscribeHostState = subscribeHostState
1394
+ exports.publishHostPayload = publishHostPayload
1395
+ exports.isEnabledWorkspace = isEnabledWorkspace
1396
+ exports.selectWorkspace = selectWorkspace
1397
+ exports.isOutside = isOutside
1398
+ exports.basenameOf = basenameOf
1399
+ exports.suggestLabel = suggestLabel
1400
+ exports.draftAfterPick = draftAfterPick
1401
+ exports.pickErrorMessage = pickErrorMessage
1402
+ exports.installWorkspaceRowButtons = installWorkspaceRowButtons
1403
+ exports.WorkdirsPanelIcon = WorkdirsPanelIcon
1404
+ exports.WorkdirsEntryIcon = WorkdirsEntryIcon
1405
+ exports.WorkdirsDialog = WorkdirsDialog
1406
+ exports.WorkdirsHintPanel = WorkdirsHintPanel
1407
+ exports.WorkspaceplusSettings = WorkspaceplusSettings
1408
+ exports.apply = apply
1409
+ exports.inject = inject
1410
+ return module.exports
1411
+ },
1412
+ })