dsh-workspace-plus 0.2.0 → 0.4.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.
Files changed (4) hide show
  1. package/README.md +135 -38
  2. package/lib/client.js +1867 -1653
  3. package/lib/index.js +790 -707
  4. package/package.json +65 -65
package/lib/client.js CHANGED
@@ -1,1653 +1,1867 @@
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
- * Five 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
- * `@` trigger source the composer side. Typing `@` in a session offers that
49
- * workspace's labels as their own group; a pick inserts a
50
- * reference chip that serializes to the directory's
51
- * absolute path, so the model receives the path even when
52
- * the injected table is not in context.
53
- *
54
- * Only the shell's frozen platform module table may be `require`d here (react,
55
- * react-dom, react/jsx-runtime, @deepseek-ai/cordis,
56
- * @deepseek-ai/dsh-client-store, @deepseek-ai/dsh-client-ui-slots,
57
- * @deepseek-ai/dsh-client-ui-primitives, @deepseek-ai/dsh-client-ui-dockkit).
58
- * Any other specifier needs a matching `dsh.client.external` entry in
59
- * package.json, which this plugin does not use.
60
- */
61
- window.__ModuleLoader__.load({
62
- id: 'dsh-workspace-plus',
63
- factory: (require) => {
64
- var module = { exports: {} }
65
- var exports = module.exports
66
- Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
67
-
68
- const React = require('react')
69
- const { Button, Modal, Switch } = require('@deepseek-ai/dsh-client-ui-primitives')
70
-
71
- /** Id of the directory-dialog fallback entry, and the main key that backs it. */
72
- const WORKDIRS_PANEL_ID = 'workdirs'
73
-
74
- /** Sidebar row label of the directory-dialog fallback entry. */
75
- const WORKDIRS_PANEL_LABEL = 'workdirs'
76
-
77
- /**
78
- * Position among the global panel rows. The shipped entries use 0 (Plugins)
79
- * and 10 (Scheduled tasks); 30 keeps this one after them.
80
- */
81
- const WORKDIRS_PANEL_ORDER = 30
82
-
83
- /**
84
- * The settings page. `settings.section` occupants ship at -10 (account),
85
- * 0 (general), 10 (models), 15 (plugins) and 20 (agent presets); 25 puts
86
- * `workspace+` at the end of the product sections and before nothing else.
87
- */
88
- const SETTINGS_SECTION_ID = 'workspaceplus'
89
- const SETTINGS_SECTION_LABEL = 'workspace+'
90
- const SETTINGS_SECTION_ORDER = 25
91
-
92
- /** The settings key this page owns. */
93
- const DEFAULT_WORKSPACE_SETTING = 'defaultWorkspaceEnabled'
94
-
95
- /** Host JSON endpoint, resolved against the page so it survives any mount path. */
96
- const API_PATH = 'workspaceplus/api'
97
-
98
- /** Identity of the injected stylesheet, used to keep exactly one copy. */
99
- const STYLE_ID = 'dsh-workspace-plus/panel.css'
100
-
101
- /**
102
- * Row anchors. The shell emits `data-slot="<slot key>"` on every slot outlet
103
- * and `data-row-key="workspace:<workspaceId>"` on a workspace group row, and
104
- * a row's last element child is its action cluster.
105
- */
106
- const SIDEBAR_SLOT_SELECTOR = '[data-slot="sidebar.workspaces"]'
107
- const WORKSPACE_ROW_SELECTOR = '[data-row-key^="workspace:"]'
108
- const ROW_KEY_PREFIX = 'workspace:'
109
-
110
- /** Class of the button this half appends to a workspace row. */
111
- const ROW_BUTTON_CLASS = 'dsh-workspace-plus-rowbtn'
112
-
113
- /** Label of the injected row button. */
114
- const ROW_BUTTON_LABEL = '工作区目录设置'
115
-
116
- /**
117
- * The shell's own folder-open artwork, copied verbatim from
118
- * `@deepseek-ai/dsh-client-ui-primitives` (`IconFolderOpenArtwork`). It is
119
- * static path data rather than a runtime import, so the injected button is
120
- * pixel-identical to the row icons the shell draws beside it without adding a
121
- * dependency on an icon export name.
122
- */
123
- const FOLDER_ARTWORK = [
124
- {
125
- 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',
126
- opacity: '0.16',
127
- },
128
- {
129
- 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',
130
- },
131
- {
132
- 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',
133
- },
134
- ]
135
-
136
- /** Glyph of the injected row button, built from the official artwork above. */
137
- const ROW_BUTTON_GLYPH =
138
- '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" ' +
139
- 'aria-hidden="true" focusable="false">' +
140
- FOLDER_ARTWORK.map(
141
- (path) => `<path d="${path.d}" fill="currentColor"${path.opacity === undefined ? '' : ` opacity="${path.opacity}"`}/>`,
142
- ).join('') +
143
- '</svg>'
144
-
145
- /**
146
- * How long the fallback entry waits before deciding that no workspace-row
147
- * button can exist, so a healthy shell never flashes it.
148
- */
149
- const FALLBACK_SETTLE_MS = 1200
150
-
151
- /**
152
- * Panel styles. Everything is namespaced under `dsh-workspace-plus-` and
153
- * colored through the shell's theme tokens, so the surfaces follow the active
154
- * light/dark theme instead of hard-coding colors. Fallbacks after each token
155
- * keep them readable on a shell that predates a given token.
156
- */
157
- const CSS = `
158
- .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}
159
- .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}
160
- .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)}
161
- .dsh-workspace-plus-glyph{display:block}
162
- .dsh-workspace-plus-title{margin:0;font-size:24px;font-weight:600;letter-spacing:.2px}
163
- .dsh-workspace-plus-subtitle{margin:0;max-width:42ch;color:var(--dsw-alias-label-secondary)}
164
- .dsh-workspace-plus-actions{display:flex;align-items:center;gap:12px;margin-top:6px}
165
- .dsh-workspace-plus-settings{flex-direction:column;width:100%;display:flex}
166
- .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}
167
- .dsh-workspace-plus-settings-text{min-width:0}
168
- .dsh-workspace-plus-settings-title{font-size:14px;line-height:20px}
169
- .dsh-workspace-plus-settings-description{color:var(--dsw-alias-label-secondary);margin-top:4px;font-size:12px;line-height:18px}
170
- .dsh-workspace-plus-settings-error{color:var(--dsw-alias-state-error-primary);margin-top:8px;font-size:12px;line-height:18px}
171
- .dsh-workspace-plus-settings-ok{color:var(--dsw-alias-state-success-primary);margin-top:8px;font-size:12px;line-height:18px}
172
- .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)}
173
- .dsh-workspace-plus-rowbtn:hover{color:var(--dsw-alias-label-primary)}
174
- .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}
175
- .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}
176
- .dsh-workspace-plus-toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
177
- .dsh-workspace-plus-label{color:var(--dsw-alias-label-secondary);flex:none}
178
- .dsh-workspace-plus-locked{font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
179
- .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}
180
- .dsh-workspace-plus-select{max-width:100%;flex:1 1 240px}
181
- .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}
182
- .dsh-workspace-plus-input-path{flex:1 1 260px;font-family:var(--dsw-font-mono,ui-monospace,SFMono-Regular,Menlo,monospace)}
183
- .dsh-workspace-plus-input-label{flex:0 1 140px}
184
- .dsh-workspace-plus-input-note{flex:1 1 180px}
185
- .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)}
186
- .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)}
187
- .dsh-workspace-plus-row:first-child{border-top:none}
188
- .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}
189
- .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)}
190
- .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}
191
- .dsh-workspace-plus-row-outside{flex:none;color:var(--dsw-alias-state-warn-primary);font-size:12px}
192
- .dsh-workspace-plus-form{display:flex;flex-direction:column;gap:8px;width:100%}
193
- .dsh-workspace-plus-form-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;width:100%}
194
- .dsh-workspace-plus-empty{padding:14px 12px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-1)}
195
- .dsh-workspace-plus-note{color:var(--dsw-alias-label-secondary);font-size:13px}
196
- .dsh-workspace-plus-error{color:var(--dsw-alias-state-error-primary)}
197
- .dsh-workspace-plus-ok{color:var(--dsw-alias-state-success-primary)}
198
- .dsh-workspace-plus-mono{font-family:var(--dsw-font-mono,ui-monospace,SFMono-Regular,Menlo,monospace);word-break:break-all}
199
- @keyframes dsh-workspace-plus-in{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
200
- @media (prefers-reduced-motion:reduce){.dsh-workspace-plus-card{animation:none}}
201
- `
202
-
203
- /**
204
- * Append the panel stylesheet once per document. The factory body runs at
205
- * materialization, so this is a one-time boot-time side effect; the
206
- * `data-plugin-css` marker both deduplicates it and lets the shell's HMR
207
- * teardown find the tag as plugin-owned.
208
- */
209
- function ensureStyles() {
210
- if (typeof document === 'undefined') return
211
- if (document.querySelector('style[data-plugin-css=' + JSON.stringify(STYLE_ID) + ']') !== null) return
212
- const tag = document.createElement('style')
213
- tag.dataset.plugin = 'dsh-workspace-plus'
214
- tag.dataset.pluginCss = STYLE_ID
215
- tag.textContent = CSS
216
- document.head.appendChild(tag)
217
- }
218
-
219
- ensureStyles()
220
-
221
- // -----------------------------------------------------------------------
222
- // The workspace-directory feature
223
- // -----------------------------------------------------------------------
224
-
225
- /**
226
- * The directory glyph, shared by the fallback sidebar entry and the hint
227
- * panel. It draws the same official artwork as the injected row button, so
228
- * every entry point to this feature looks like the shell's own icons.
229
- *
230
- * @param props - sidebar icon share, plus the optional click handler.
231
- * @returns the icon element.
232
- */
233
- function WorkdirsPanelIcon({ size = 16, onClick }) {
234
- return React.createElement(
235
- 'svg',
236
- {
237
- className: 'dsh-workspace-plus-glyph',
238
- width: size,
239
- height: size,
240
- viewBox: '0 0 16 16',
241
- fill: 'none',
242
- xmlns: 'http://www.w3.org/2000/svg',
243
- 'aria-hidden': 'true',
244
- focusable: 'false',
245
- ...(onClick === undefined ? {} : { onClick }),
246
- },
247
- FOLDER_ARTWORK.map((path, index) =>
248
- React.createElement('path', {
249
- key: index,
250
- d: path.d,
251
- fill: 'currentColor',
252
- ...(path.opacity === undefined ? {} : { opacity: path.opacity }),
253
- }),
254
- ),
255
- )
256
- }
257
-
258
- /**
259
- * The fallback entry's glyph. Its click is stopped before the shell's panel
260
- * button sees it, so the sidebar row opens the dialog instead of switching
261
- * `main`; the hint panel behind that key is what a user sees if this ever
262
- * stops working.
263
- *
264
- * @param props - sidebar icon share.
265
- * @returns the icon element.
266
- */
267
- function WorkdirsEntryIcon(props) {
268
- return WorkdirsPanelIcon({
269
- ...props,
270
- onClick: (event) => {
271
- event.preventDefault()
272
- event.stopPropagation()
273
- openWorkdirsDialog(null)
274
- },
275
- })
276
- }
277
-
278
- /**
279
- * Bundle-local open state for the dialog. Its two triggers live in different
280
- * React trees from the dialog (one is not React at all), so they meet on this
281
- * tiny external store rather than on a shared parent.
282
- */
283
- let dialogSnapshot = { open: false, workspaceId: null }
284
- const dialogListeners = new Set()
285
-
286
- /** @returns the current dialog snapshot (referentially stable until it changes). */
287
- function readDialog() {
288
- return dialogSnapshot
289
- }
290
-
291
- /**
292
- * Subscribe to dialog open-state changes.
293
- *
294
- * @param listener - called after each change.
295
- * @returns the unsubscribe function.
296
- */
297
- function subscribeDialog(listener) {
298
- dialogListeners.add(listener)
299
- return () => {
300
- dialogListeners.delete(listener)
301
- }
302
- }
303
-
304
- /**
305
- * Replace the dialog snapshot and notify subscribers.
306
- *
307
- * @param next - the next `{ open, workspaceId }`.
308
- */
309
- function writeDialog(next) {
310
- const previous = dialogSnapshot
311
- if (previous.open === next.open && previous.workspaceId === next.workspaceId) return
312
- dialogSnapshot = next
313
- for (const listener of [...dialogListeners]) listener()
314
- }
315
-
316
- /**
317
- * Open the dialog, optionally focused on one workspace.
318
- *
319
- * @param workspaceId - the workspace to preselect, or null to keep the current one.
320
- */
321
- function openWorkdirsDialog(workspaceId = null) {
322
- writeDialog({ open: true, workspaceId })
323
- }
324
-
325
- /** Close the dialog. */
326
- function closeWorkdirsDialog() {
327
- writeDialog({ open: false, workspaceId: null })
328
- }
329
-
330
- /** The dialog's state before the first host response arrives. */
331
- const WORKDIRS_IDLE = {
332
- status: 'loading',
333
- message: '',
334
- ok: null,
335
- registryAvailable: true,
336
- workspaces: [],
337
- selected: null,
338
- locked: false,
339
- busy: false,
340
- }
341
-
342
- /**
343
- * Resolve the host endpoint against the page, so the same code works under a
344
- * root mount, under a nested mount, and under the desktop shell's
345
- * `dsh-app://app/` origin (which forwards every non-asset path to the Host).
346
- *
347
- * @returns the resolved endpoint URL.
348
- */
349
- function apiUrl() {
350
- if (typeof document === 'undefined') return API_PATH
351
- try {
352
- return new URL(API_PATH, document.baseURI).toString()
353
- } catch {
354
- return API_PATH
355
- }
356
- }
357
-
358
- /**
359
- * Read the workspace list plus every registered directory from the host.
360
- *
361
- * @param signal - optional abort signal.
362
- * @returns the parsed payload, or `{ ok: false, message }`.
363
- */
364
- async function fetchWorkspaces(signal) {
365
- const response = await fetch(apiUrl(), {
366
- method: 'GET',
367
- headers: { accept: 'application/json' },
368
- credentials: 'same-origin',
369
- signal,
370
- })
371
- if (!response.ok) return { ok: false, message: 'host responded ' + response.status }
372
- return await response.json()
373
- }
374
-
375
- /**
376
- * Send one mutation to the host.
377
- *
378
- * @param payload - `{ op, workspace, label, path, note }`.
379
- * @returns the parsed payload, or `{ ok: false, message }`.
380
- */
381
- async function postOperation(payload) {
382
- const response = await fetch(apiUrl(), {
383
- method: 'POST',
384
- headers: { 'content-type': 'application/json', accept: 'application/json' },
385
- credentials: 'same-origin',
386
- body: JSON.stringify(payload),
387
- })
388
- if (!response.ok) return { ok: false, message: 'host responded ' + response.status }
389
- return await response.json()
390
- }
391
-
392
- /**
393
- * The last host payload this page has seen, shared by the three client
394
- * surfaces: the settings page reads the settings and the default-workspace
395
- * identity from it, the row installer consults the per-workspace `enabled`
396
- * flag, and the dialog refreshes it after every mutation.
397
- */
398
- let hostState = {
399
- known: false,
400
- settings: { [DEFAULT_WORKSPACE_SETTING]: false },
401
- defaultWorkspaceId: null,
402
- defaultWorkspacePath: null,
403
- workspaces: [],
404
- }
405
- const hostStateListeners = new Set()
406
-
407
- /** @returns the current host snapshot (referentially stable until it changes). */
408
- function readHostState() {
409
- return hostState
410
- }
411
-
412
- /**
413
- * Subscribe to host-snapshot changes.
414
- *
415
- * @param listener - called after each change.
416
- * @returns the unsubscribe function.
417
- */
418
- function subscribeHostState(listener) {
419
- hostStateListeners.add(listener)
420
- return () => {
421
- hostStateListeners.delete(listener)
422
- }
423
- }
424
-
425
- /**
426
- * Fold a host payload into the shared snapshot. Unusable payloads leave the
427
- * previous snapshot alone.
428
- *
429
- * @param payload - a parsed `/workspaceplus/api` response.
430
- * @returns whether the payload was usable.
431
- */
432
- function publishHostPayload(payload) {
433
- if (payload === null || typeof payload !== 'object' || payload.ok !== true) return false
434
- hostState = {
435
- known: true,
436
- settings: { [DEFAULT_WORKSPACE_SETTING]: payload.settings?.[DEFAULT_WORKSPACE_SETTING] === true },
437
- defaultWorkspaceId: typeof payload.defaultWorkspaceId === 'string' ? payload.defaultWorkspaceId : null,
438
- defaultWorkspacePath: typeof payload.defaultWorkspacePath === 'string' ? payload.defaultWorkspacePath : null,
439
- workspaces: Array.isArray(payload.workspaces) ? payload.workspaces : [],
440
- }
441
- for (const listener of [...hostStateListeners]) listener()
442
- return true
443
- }
444
-
445
- /**
446
- * Whether the feature runs for one workspace id. A row whose workspace is not
447
- * in the snapshot yet keeps its entry point: hiding it on a guess would remove
448
- * the UI from a perfectly ordinary workspace.
449
- *
450
- * @param workspaceId - the id taken from the row's `data-row-key`.
451
- * @returns true when the row should carry the directory button.
452
- */
453
- function isEnabledWorkspace(workspaceId) {
454
- const workspace = hostState.workspaces.find((candidate) => candidate.id === workspaceId)
455
- return workspace === undefined ? true : workspace.enabled !== false
456
- }
457
-
458
- /**
459
- * Refresh the shared snapshot from the host.
460
- *
461
- * @returns whether a usable payload arrived.
462
- */
463
- async function refreshHostState() {
464
- try {
465
- return publishHostPayload(await fetchWorkspaces())
466
- } catch {
467
- return false
468
- }
469
- }
470
-
471
- // -----------------------------------------------------------------------
472
- // The composer's `@` source
473
- // -----------------------------------------------------------------------
474
-
475
- /**
476
- * Source name of the `@` group that offers this workspace's labels. It must be
477
- * unique for its trigger (`ui-reference` owns `@reference`): the input pipeline
478
- * routes a chip's serializer by source name, and a chip whose source has no
479
- * registered codec rejects the whole submit.
480
- */
481
- const SOURCE_NAME = 'workspace-plus'
482
-
483
- /** Section heading of the group's rows, standing in for the pipeline's group title. */
484
- const SOURCE_SECTION = '工作区目录'
485
-
486
- /** The client session list, captured for the `@` source's cwd lookup. */
487
- let sessionsService = null
488
-
489
- /**
490
- * Compare two directory paths the way the host does — separator- and
491
- * case-insensitively on Windows.
492
- *
493
- * @param left - one path.
494
- * @param right - the other path.
495
- * @returns whether they name the same directory.
496
- */
497
- function samePath(left, right) {
498
- const fold = (value) =>
499
- String(value)
500
- .replace(/[\\/]+$/, '')
501
- .replace(/\//g, '\\')
502
- .toLowerCase()
503
- return fold(left) === fold(right)
504
- }
505
-
506
- /**
507
- * The session's working directory, which is the workspace key. `sessions.list`
508
- * is the same projection `ui-reference` and `ui-session` read; a composition
509
- * without it simply yields no candidates.
510
- *
511
- * @param sessionId - the id from the trigger pipeline's session handle.
512
- * @returns the cwd, or null.
513
- */
514
- function sessionCwd(sessionId) {
515
- if (sessionsService === null || sessionId === undefined || sessionId === null) return null
516
- try {
517
- const cwd = sessionsService.list?.getSnapshot?.()?.byId?.[sessionId]?.cwd
518
- return typeof cwd === 'string' && cwd !== '' ? cwd : null
519
- } catch {
520
- return null
521
- }
522
- }
523
-
524
- /**
525
- * The workspace a session belongs to, or null when it cannot be resolved. Used
526
- * to order the `@` menu, never to decide whether it has anything to offer.
527
- *
528
- * @param session - the pipeline's `{ sessionId }` handle.
529
- * @returns the host snapshot's workspace entry, or null.
530
- */
531
- function workspaceForSession(session) {
532
- if (!hostState.known) return null
533
- const cwd = sessionCwd(session?.sessionId)
534
- if (cwd === null) return null
535
- return hostState.workspaces.find((candidate) => samePath(candidate.path, cwd)) ?? null
536
- }
537
-
538
- /**
539
- * Read back one candidate's opaque payload.
540
- *
541
- * @param value - the candidate's `value` string.
542
- * @returns `{ label, path }`, or undefined when the payload is unusable.
543
- */
544
- function parseLabelValue(value) {
545
- if (typeof value !== 'string') return undefined
546
- try {
547
- const parsed = JSON.parse(value)
548
- if (parsed === null || typeof parsed !== 'object') return undefined
549
- if (typeof parsed.label !== 'string' || typeof parsed.path !== 'string') return undefined
550
- return { label: parsed.label, path: parsed.path }
551
- } catch {
552
- return undefined
553
- }
554
- }
555
-
556
- /**
557
- * Build the `@` source that offers registered directory labels.
558
- *
559
- * A pick inserts a reference chip whose `ref` is the directory's ABSOLUTE PATH,
560
- * so the submitted prompt carries the path itself rather than a name the model
561
- * must resolve — the label routing then holds even if the injected table was
562
- * compacted away. The chip shows the label, and `appearance: 'folder'` draws
563
- * the shell's folder glyph without marking the chip openable.
564
- *
565
- * Candidates come from EVERY enabled workspace, grouped by workspace title,
566
- * with the calling session's workspace first when its cwd can be read. The
567
- * session lookup is an ordering refinement, never a requirement: keying the
568
- * menu on it meant one unresolved cwd turned the whole group into a silent
569
- * empty list, which is unfalsifiable from the outside.
570
- *
571
- * @returns the trigger source.
572
- */
573
- function createLabelSource() {
574
- return {
575
- trigger: '@',
576
- name: SOURCE_NAME,
577
- // The pipeline's group title would be an untranslated key; each row
578
- // carries its own section heading instead.
579
- showGroupTitle: false,
580
- // MUST be async: the pipeline calls `source.candidates(...).then(...)`, so
581
- // a synchronous array return throws inside its per-hit fan-out.
582
- async candidates(session, { query, signal }) {
583
- if (signal !== undefined && signal.aborted) return []
584
- const offered = hostState.workspaces.filter(
585
- (workspace) => workspace.enabled !== false && Array.isArray(workspace.dirs) && workspace.dirs.length > 0,
586
- )
587
- if (offered.length === 0) return []
588
- const cwd = sessionCwd(session?.sessionId)
589
- const currentIndex =
590
- cwd === null ? -1 : offered.findIndex((workspace) => samePath(workspace.path, cwd))
591
- const ordered = currentIndex <= 0 ? offered : [offered[currentIndex], ...offered.filter((_, index) => index !== currentIndex)]
592
- const needle = String(query ?? '').trim().toLowerCase()
593
- const matches = (dir) =>
594
- needle === '' ||
595
- dir.label.toLowerCase().includes(needle) ||
596
- (dir.note ?? '').toLowerCase().includes(needle) ||
597
- dir.path.toLowerCase().includes(needle)
598
- return ordered.flatMap((workspace) =>
599
- workspace.dirs.filter(matches).map((dir) => ({
600
- name: dir.label,
601
- label: dir.label,
602
- description: dir.note === undefined ? dir.path : dir.note + ' · ' + dir.path,
603
- icon: 'folder',
604
- section: workspace.title === undefined || workspace.title === '' ? workspace.path : workspace.title,
605
- value: JSON.stringify({ label: dir.label, path: dir.path }),
606
- })),
607
- )
608
- },
609
- onPick({ candidate }) {
610
- const value = parseLabelValue(candidate?.value)
611
- if (value === undefined) return undefined
612
- return {
613
- insert: {
614
- source: SOURCE_NAME,
615
- ref: value.path,
616
- label: value.label,
617
- appearance: 'folder',
618
- clipboardText: value.path,
619
- },
620
- }
621
- },
622
- codec: {
623
- clipboardText: (ref) => ref,
624
- // The model-facing form IS the absolute path.
625
- serialize: (ref) => Promise.resolve(ref),
626
- },
627
- }
628
- }
629
-
630
- /**
631
- * Whether the `@` source is currently registered, and how many labels it would
632
- * offer. The settings page shows this so "the menu has no group" has a visible
633
- * cause instead of being unfalsifiable.
634
- */
635
- let labelSourceRegistered = false
636
-
637
- /**
638
- * Summarize the `@` source for the settings page.
639
- *
640
- * @returns `{ registered, workspaces, labels }`.
641
- */
642
- function labelSourceStatus() {
643
- const offered = hostState.workspaces.filter(
644
- (workspace) => workspace.enabled !== false && Array.isArray(workspace.dirs) && workspace.dirs.length > 0,
645
- )
646
- return {
647
- registered: labelSourceRegistered,
648
- workspaces: offered.length,
649
- labels: offered.reduce((total, workspace) => total + workspace.dirs.length, 0),
650
- }
651
- }
652
-
653
- /**
654
- * Pick the workspace the dialog shows: the requested id when it still exists,
655
- * otherwise whatever was selected, otherwise the first one.
656
- *
657
- * @param workspaces - the host's workspace list.
658
- * @param requestedId - the id the trigger asked for.
659
- * @param currentPath - the path currently selected.
660
- * @returns the resolved workspace path, or null.
661
- */
662
- function selectWorkspace(workspaces, requestedId, currentPath) {
663
- const requested = requestedId === null || requestedId === undefined
664
- ? undefined
665
- : workspaces.find((workspace) => workspace.id === requestedId)
666
- if (requested !== undefined) return requested.path
667
- if (currentPath !== null && currentPath !== undefined && workspaces.some((workspace) => workspace.path === currentPath)) {
668
- return currentPath
669
- }
670
- return workspaces[0]?.path ?? null
671
- }
672
-
673
- /**
674
- * The shell's directory picker (`ctx.uiWorkspace`), captured while the plugin
675
- * is mounted. It is injected optionally rather than declared in `inject`, so a
676
- * composition without ui-workspace keeps the whole plugin working — the dialog
677
- * simply loses its browse button.
678
- */
679
- let directoryPicker = null
680
-
681
- /**
682
- * The last path segment of a directory path, for either separator.
683
- *
684
- * @param path - any directory path.
685
- * @returns the final segment, or an empty string.
686
- */
687
- function basenameOf(path) {
688
- const parts = String(path).replace(/[\\/]+$/, '').split(/[\\/]/)
689
- return parts[parts.length - 1] ?? ''
690
- }
691
-
692
- /**
693
- * Suggest a label for a picked directory. The host's label grammar is
694
- * `[A-Za-z0-9][A-Za-z0-9._-]{0,31}`, so anything else is folded to dashes and
695
- * dropped when nothing valid survives (a CJK directory name yields no guess
696
- * rather than a label the host would reject).
697
- *
698
- * @param path - the picked directory.
699
- * @returns a valid label, or an empty string when nothing sensible survives.
700
- */
701
- function suggestLabel(path) {
702
- const cleaned = basenameOf(path)
703
- .replace(/[^A-Za-z0-9._-]+/g, '-')
704
- .replace(/^[^A-Za-z0-9]+/, '')
705
- .slice(0, 32)
706
- return /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/.test(cleaned) ? cleaned : ''
707
- }
708
-
709
- /**
710
- * Fold a picked directory into the draft: the path always follows the picker,
711
- * and an empty label is filled from the directory's own name.
712
- *
713
- * @param draft - the current `{ label, path, note }`.
714
- * @param pickedPath - the directory the picker returned.
715
- * @returns the next draft.
716
- */
717
- function draftAfterPick(draft, pickedPath) {
718
- return {
719
- ...draft,
720
- path: pickedPath,
721
- label: draft.label.trim() === '' ? suggestLabel(pickedPath) : draft.label,
722
- }
723
- }
724
-
725
- /**
726
- * Message shown when the shell's picker fails. A cancelled pick resolves to
727
- * `null` and never reaches here; this path is a real failure (no picker
728
- * backend, or the browse flow erroring), so it must be visible.
729
- *
730
- * @param error - whatever the picker rejected with.
731
- * @returns the user-facing message.
732
- */
733
- function pickErrorMessage(error) {
734
- return '打开目录选择器失败:' + String((error && error.message) || error)
735
- }
736
-
737
- /**
738
- * Whether a registered directory sits outside its workspace root. Such a
739
- * directory is readable everywhere but only writable when the session's file
740
- * policy is `danger-full-access`, so the dialog marks it instead of letting
741
- * the user discover that from a denied write.
742
- *
743
- * @param workspacePath - the workspace root directory.
744
- * @param dirPath - the registered directory.
745
- * @returns true when the directory is outside the workspace root.
746
- */
747
- function isOutside(workspacePath, dirPath) {
748
- const fold = (value) => String(value).replace(/[\\/]+$/, '').toLowerCase()
749
- const root = fold(workspacePath)
750
- const target = fold(dirPath)
751
- return target !== root && !target.startsWith(root + '\\') && !target.startsWith(root + '/')
752
- }
753
-
754
- /**
755
- * One registered directory row.
756
- *
757
- * @param props - the row's directory, its workspace root, and the remove handler.
758
- * @returns the row element.
759
- */
760
- function DirectoryRow({ dir, workspacePath, onRemove, disabled }) {
761
- const outside = isOutside(workspacePath, dir.path)
762
- return React.createElement(
763
- 'div',
764
- { className: 'dsh-workspace-plus-row' },
765
- React.createElement('span', { className: 'dsh-workspace-plus-row-tag' }, dir.label),
766
- React.createElement('span', { className: 'dsh-workspace-plus-row-path', title: dir.path }, dir.path),
767
- dir.note === undefined ? null : React.createElement('span', { className: 'dsh-workspace-plus-row-note' }, dir.note),
768
- outside ? React.createElement('span', { className: 'dsh-workspace-plus-row-outside' }, '工作区根目录之外') : null,
769
- React.createElement(
770
- Button,
771
- { variant: 'outline', size: 'sm', disabled, onClick: () => onRemove(dir.label) },
772
- '删除',
773
- ),
774
- )
775
- }
776
-
777
- /**
778
- * The dialog entry registered into `shell.overlay`.
779
- *
780
- * `props.initial` and `props.dialog` exist only as a test seam — the shell
781
- * renders overlay entries with its own standard props and no owner props.
782
- *
783
- * @param props - optional initial state for tests.
784
- * @returns the dialog element (or null while closed).
785
- */
786
- function WorkdirsDialog(props) {
787
- const dialog = React.useSyncExternalStore(subscribeDialog, readDialog)
788
- const [state, setState] = React.useState(props?.initial ?? WORKDIRS_IDLE)
789
- const [draft, setDraft] = React.useState({ label: '', path: '', note: '' })
790
- const [picking, setPicking] = React.useState(false)
791
-
792
- const patch = (changes) => setState((current) => ({ ...current, ...changes }))
793
-
794
- /** Fold one successful host payload into the dialog state. */
795
- const applyPayload = (payload, requestedId) => {
796
- publishHostPayload(payload)
797
- if (payload === null || typeof payload !== 'object' || payload.ok !== true) {
798
- setState((current) => ({
799
- ...current,
800
- status: 'error',
801
- busy: false,
802
- ok: false,
803
- message: (payload && payload.message) || 'host returned an unusable payload',
804
- }))
805
- return
806
- }
807
- const workspaces = Array.isArray(payload.workspaces) ? payload.workspaces : []
808
- // The trigger names the workspace, so the dialog edits THAT one and shows
809
- // no picker. Only a trigger without a workspace (the sidebar fallback)
810
- // leaves the choice open.
811
- const locked = requestedId === null || requestedId === undefined
812
- ? null
813
- : workspaces.find((workspace) => workspace.id === requestedId) ?? null
814
- setState((current) => ({
815
- ...current,
816
- status: 'ready',
817
- busy: false,
818
- ok: null,
819
- message: '',
820
- workspaces,
821
- registryAvailable: payload.registryAvailable !== false,
822
- locked: locked !== null,
823
- selected: locked !== null ? locked.path : selectWorkspace(workspaces, requestedId, current.selected),
824
- }))
825
- }
826
-
827
- const load = async (requestedId) => {
828
- patch({ busy: true })
829
- try {
830
- applyPayload(await fetchWorkspaces(), requestedId)
831
- } catch (error) {
832
- patch({ status: 'error', busy: false, ok: false, message: String((error && error.message) || error) })
833
- }
834
- }
835
-
836
- const submit = async (payload) => {
837
- patch({ busy: true })
838
- try {
839
- const result = await postOperation(payload)
840
- if (result === null || typeof result !== 'object') {
841
- patch({ busy: false, ok: false, message: 'host returned an unusable payload' })
842
- return false
843
- }
844
- publishHostPayload(result)
845
- const workspaces = Array.isArray(result.workspaces) ? result.workspaces : state.workspaces
846
- setState((current) => ({
847
- ...current,
848
- status: 'ready',
849
- busy: false,
850
- ok: result.ok === true,
851
- message: String(result.message ?? ''),
852
- workspaces,
853
- registryAvailable: result.registryAvailable !== false,
854
- selected: selectWorkspace(workspaces, null, current.selected),
855
- }))
856
- return result.ok === true
857
- } catch (error) {
858
- patch({ status: 'error', busy: false, ok: false, message: String((error && error.message) || error) })
859
- return false
860
- }
861
- }
862
-
863
- // Refetch whenever the dialog opens, and whenever a trigger names a
864
- // different workspace, so the table is never stale.
865
- const open = dialog.open
866
- const requestedId = dialog.workspaceId
867
- React.useEffect(() => {
868
- if (!open) return undefined
869
- let cancelled = false
870
- const controller = typeof AbortController === 'undefined' ? undefined : new AbortController()
871
- ;(async () => {
872
- try {
873
- const payload = await fetchWorkspaces(controller?.signal)
874
- if (cancelled) return
875
- applyPayload(payload, requestedId)
876
- } catch (error) {
877
- if (cancelled) return
878
- setState((current) => ({
879
- ...current,
880
- status: 'error',
881
- busy: false,
882
- ok: false,
883
- message: String((error && error.message) || error),
884
- }))
885
- }
886
- })()
887
- return () => {
888
- cancelled = true
889
- controller?.abort()
890
- }
891
- }, [open, requestedId])
892
-
893
- const selected = state.workspaces.find((workspace) => workspace.path === state.selected) ?? null
894
- const dirs = selected === null ? [] : selected.dirs
895
-
896
- const addDirectory = async () => {
897
- if (selected === null) return
898
- const added = await submit({
899
- op: 'add',
900
- workspace: selected.path,
901
- label: draft.label,
902
- path: draft.path,
903
- note: draft.note,
904
- })
905
- if (added) setDraft({ label: '', path: '', note: '' })
906
- }
907
-
908
- const removeDirectory = async (label) => {
909
- if (selected === null) return
910
- await submit({ op: 'remove', workspace: selected.path, label })
911
- }
912
-
913
- /**
914
- * Ask the shell's directory picker for a directory. A cancelled picker
915
- * resolves to `null` and changes nothing; a failed one reports the reason
916
- * where the add result goes, so the dialog never fails silently.
917
- */
918
- const browse = async () => {
919
- if (directoryPicker === null || directoryPicker === undefined) return
920
- setPicking(true)
921
- try {
922
- const picked = await directoryPicker.pickDirectory()
923
- if (typeof picked === 'string' && picked !== '') setDraft((current) => draftAfterPick(current, picked))
924
- } catch (error) {
925
- patch({ ok: false, message: pickErrorMessage(error) })
926
- } finally {
927
- setPicking(false)
928
- }
929
- }
930
-
931
- const toolbar = React.createElement(
932
- 'div',
933
- { className: 'dsh-workspace-plus-toolbar' },
934
- React.createElement('span', { className: 'dsh-workspace-plus-label' }, '工作区'),
935
- state.locked
936
- ? React.createElement('span', { className: 'dsh-workspace-plus-locked' }, selected?.title || selected?.path || '')
937
- : React.createElement(
938
- 'select',
939
- {
940
- className: 'dsh-workspace-plus-select',
941
- value: state.selected ?? '',
942
- disabled: state.workspaces.length === 0,
943
- 'aria-label': '选择工作区',
944
- onChange: (event) => patch({ selected: event.target.value === '' ? null : event.target.value }),
945
- },
946
- state.workspaces.length === 0
947
- ? React.createElement('option', { value: '' }, '(没有可用工作区)')
948
- : state.workspaces.map((workspace) =>
949
- React.createElement(
950
- 'option',
951
- { key: workspace.id, value: workspace.path },
952
- (workspace.title || workspace.path) + ' — ' + workspace.path,
953
- ),
954
- ),
955
- ),
956
- state.locked
957
- ? React.createElement('span', { className: 'dsh-workspace-plus-note dsh-workspace-plus-mono' }, selected?.path ?? '')
958
- : null,
959
- )
960
-
961
- const body = (() => {
962
- if (state.status === 'loading') {
963
- return React.createElement(
964
- 'div',
965
- { className: 'dsh-workspace-plus-list' },
966
- React.createElement('div', { className: 'dsh-workspace-plus-empty' }, '正在读取 host…'),
967
- )
968
- }
969
- if (state.status === 'error') {
970
- const unauthorized = state.message.includes('401')
971
- const unrouted = state.message.includes('404')
972
- /**
973
- * The three failures need different advice:
974
- * - 401: this document never completed the token exchange, so the
975
- * connection layer has no authority to admit the request.
976
- * - 404: the request reached the Host and nothing claimed the path.
977
- * The browser half hot-reloads from the served bundle, the host half
978
- * does not, so a stale Host process is the ordinary cause.
979
- * - anything else: the transport itself (a page with no HTTP carrier).
980
- */
981
- const diagnosis = unauthorized
982
- ? '这个页面没有带授权的会话凭据。请用 `dsh` 启动时打印的带 token 的地址重新打开 GUI(它会写入 cookie,然后跳转到干净地址)。'
983
- : unrouted
984
- ? '请求到达了 DSH 宿主进程,但宿主里没有注册 ' +
985
- API_PATH +
986
- ' 这条路由——通常意味着宿主进程还在跑旧版插件代码。浏览器半会随 bundle 热更新,宿主半不会:重启 dsh 即可。' +
987
- '重启后终端会出现 [dsh-workspace-plus] host half loaded 一行;若没有,请到 设置 → 插件 查看该插件行的状态。'
988
- : '这个面板通过 ' +
989
- API_PATH +
990
- ' 与宿主进程通信,而当前页面没有可用的 HTTP 通道(例如页面不是 dsh 提供的、或宿主进程还没起来)。'
991
- return React.createElement(
992
- 'div',
993
- { className: 'dsh-workspace-plus-list' },
994
- React.createElement(
995
- 'div',
996
- { className: 'dsh-workspace-plus-empty' },
997
- React.createElement('div', { className: 'dsh-workspace-plus-error' }, '无法连接 host 接口:' + state.message),
998
- React.createElement('div', { className: 'dsh-workspace-plus-note' }, diagnosis),
999
- React.createElement(
1000
- 'div',
1001
- { className: 'dsh-workspace-plus-note' },
1002
- '宿主半加载之后,映射也可以在对话里用 workspace_dirs 工具管理。',
1003
- ),
1004
- ),
1005
- )
1006
- }
1007
- if (!state.registryAvailable) {
1008
- return React.createElement(
1009
- 'div',
1010
- { className: 'dsh-workspace-plus-list' },
1011
- React.createElement(
1012
- 'div',
1013
- { className: 'dsh-workspace-plus-empty' },
1014
- '宿主进程没有加载 workspace 注册表,无法列出工作区。请在对话里用 workspace_dirs 工具按绝对路径管理标签。',
1015
- ),
1016
- )
1017
- }
1018
- if (selected === null) {
1019
- return React.createElement(
1020
- 'div',
1021
- { className: 'dsh-workspace-plus-list' },
1022
- React.createElement('div', { className: 'dsh-workspace-plus-empty' }, '还没有任何工作区。先在左侧边栏新建一个工作区,再回到这里登记目录。'),
1023
- )
1024
- }
1025
- return React.createElement(
1026
- 'div',
1027
- { className: 'dsh-workspace-plus-list' },
1028
- dirs.length === 0
1029
- ? React.createElement('div', { className: 'dsh-workspace-plus-empty' }, '这个工作区还没有登记额外目录。用下面的表单添加第一个标签。')
1030
- : dirs.map((dir) =>
1031
- React.createElement(DirectoryRow, {
1032
- key: dir.label,
1033
- dir,
1034
- workspacePath: selected.path,
1035
- disabled: state.busy,
1036
- onRemove: (label) => void removeDirectory(label),
1037
- }),
1038
- ),
1039
- )
1040
- })()
1041
-
1042
- const form = React.createElement(
1043
- 'div',
1044
- { className: 'dsh-workspace-plus-form' },
1045
- React.createElement(
1046
- 'div',
1047
- { className: 'dsh-workspace-plus-form-row' },
1048
- React.createElement('input', {
1049
- className: 'dsh-workspace-plus-input dsh-workspace-plus-input-label',
1050
- placeholder: '标签,如 backend',
1051
- 'aria-label': '标签',
1052
- value: draft.label,
1053
- disabled: selected === null || state.busy,
1054
- onChange: (event) => setDraft({ ...draft, label: event.target.value }),
1055
- }),
1056
- React.createElement('input', {
1057
- className: 'dsh-workspace-plus-input dsh-workspace-plus-input-note',
1058
- placeholder: '说明(可选)',
1059
- 'aria-label': '说明',
1060
- value: draft.note,
1061
- disabled: selected === null || state.busy,
1062
- onChange: (event) => setDraft({ ...draft, note: event.target.value }),
1063
- }),
1064
- ),
1065
- React.createElement(
1066
- 'div',
1067
- { className: 'dsh-workspace-plus-form-row' },
1068
- React.createElement('input', {
1069
- className: 'dsh-workspace-plus-input dsh-workspace-plus-input-path',
1070
- placeholder: '绝对路径,如 D:\\proj\\backend',
1071
- 'aria-label': '目录绝对路径',
1072
- value: draft.path,
1073
- disabled: selected === null || state.busy,
1074
- onChange: (event) => setDraft({ ...draft, path: event.target.value }),
1075
- }),
1076
- React.createElement(
1077
- Button,
1078
- {
1079
- variant: 'outline',
1080
- disabled: selected === null || state.busy || picking || directoryPicker === null,
1081
- onClick: () => void browse(),
1082
- },
1083
- picking ? '选择中…' : '浏览…',
1084
- ),
1085
- React.createElement(
1086
- Button,
1087
- { variant: 'primary', disabled: selected === null || state.busy, onClick: () => void addDirectory() },
1088
- '添加',
1089
- ),
1090
- ),
1091
- )
1092
-
1093
- const status = React.createElement(
1094
- 'div',
1095
- { className: 'dsh-workspace-plus-toolbar' },
1096
- state.message === ''
1097
- ? null
1098
- : React.createElement(
1099
- 'span',
1100
- { className: state.ok === false ? 'dsh-workspace-plus-error' : 'dsh-workspace-plus-ok' },
1101
- state.message,
1102
- ),
1103
- // The locked header already names the root; only the unlocked picker needs it repeated.
1104
- state.locked || selected === null
1105
- ? null
1106
- : React.createElement(
1107
- 'span',
1108
- { className: 'dsh-workspace-plus-note' },
1109
- '根目录:',
1110
- React.createElement('span', { className: 'dsh-workspace-plus-mono' }, selected.path),
1111
- ),
1112
- )
1113
-
1114
- const footnote = React.createElement(
1115
- 'div',
1116
- { className: 'dsh-workspace-plus-note' },
1117
- '标签映射会自动注入该工作区对话的 agent 上下文,所以直接在对话里说「改 backend」就够了。',
1118
- '注意:DSH 沙箱把会话的可写范围限制在工作区根目录内;会话权限不是 danger-full-access 时,',
1119
- '根目录之外的目录只能读,这里会把这类目录标注出来。',
1120
- )
1121
-
1122
- return React.createElement(
1123
- Modal,
1124
- {
1125
- open,
1126
- onClose: closeWorkdirsDialog,
1127
- title: '工作区目录设置',
1128
- closeLabel: '关闭',
1129
- description:
1130
- '一个工作区可以登记 N 个目录,每个目录带一个标签。这份映射会自动注入该工作区对话的 agent 上下文,' +
1131
- 'agent 用标签对应的绝对路径,不会因为目录名相似而改错文件。',
1132
- footer: React.createElement(
1133
- React.Fragment,
1134
- null,
1135
- React.createElement(
1136
- Button,
1137
- { variant: 'outline', disabled: state.busy, onClick: () => void load(requestedId) },
1138
- '刷新',
1139
- ),
1140
- React.createElement(Button, { variant: 'primary', onClick: closeWorkdirsDialog }, '完成'),
1141
- ),
1142
- },
1143
- React.createElement('div', { className: 'dsh-workspace-plus-dialog' }, toolbar, status, body, form, footnote),
1144
- )
1145
- }
1146
-
1147
- /**
1148
- * The `workspace+` settings page, registered into `settings.section`.
1149
- *
1150
- * It owns exactly one preference: whether label routing runs for the default
1151
- * (first-use) Workspace. Off by default, because that Workspace belongs to the
1152
- * product rather than to this plugin.
1153
- *
1154
- * @param props - optional initial state for tests.
1155
- * @returns the settings section element.
1156
- */
1157
- function WorkspacePlusSettings(props) {
1158
- const host = React.useSyncExternalStore(subscribeHostState, readHostState)
1159
- const [state, setState] = React.useState(props?.initial ?? { status: 'loading', message: '', ok: null, busy: false })
1160
-
1161
- const patch = (changes) => setState((current) => ({ ...current, ...changes }))
1162
-
1163
- React.useEffect(() => {
1164
- let cancelled = false
1165
- const controller = typeof AbortController === 'undefined' ? undefined : new AbortController()
1166
- ;(async () => {
1167
- try {
1168
- const payload = await fetchWorkspaces(controller?.signal)
1169
- if (cancelled) return
1170
- if (!publishHostPayload(payload)) {
1171
- setState((current) => ({
1172
- ...current,
1173
- status: 'error',
1174
- message: (payload && payload.message) || 'host returned an unusable payload',
1175
- }))
1176
- return
1177
- }
1178
- setState((current) => ({ ...current, status: 'ready', message: '' }))
1179
- } catch (error) {
1180
- if (cancelled) return
1181
- setState((current) => ({
1182
- ...current,
1183
- status: 'error',
1184
- message: String((error && error.message) || error),
1185
- }))
1186
- }
1187
- })()
1188
- return () => {
1189
- cancelled = true
1190
- controller?.abort()
1191
- }
1192
- }, [])
1193
-
1194
- const toggle = async (next) => {
1195
- patch({ busy: true })
1196
- try {
1197
- const result = await postOperation({ op: 'settings', settings: { [DEFAULT_WORKSPACE_SETTING]: next } })
1198
- if (result === null || typeof result !== 'object') {
1199
- patch({ busy: false, ok: false, message: 'host returned an unusable payload' })
1200
- return
1201
- }
1202
- publishHostPayload(result)
1203
- patch({ busy: false, ok: result.ok === true, message: String(result.message ?? '') })
1204
- } catch (error) {
1205
- patch({ busy: false, ok: false, message: String((error && error.message) || error) })
1206
- }
1207
- }
1208
-
1209
- const enabled = host.settings[DEFAULT_WORKSPACE_SETTING] === true
1210
- const unavailable = state.status === 'error'
1211
-
1212
- const row = React.createElement(
1213
- 'div',
1214
- { className: 'dsh-workspace-plus-settings-row' },
1215
- React.createElement(
1216
- 'div',
1217
- { className: 'dsh-workspace-plus-settings-text' },
1218
- React.createElement('div', { className: 'dsh-workspace-plus-settings-title' }, '对默认工作区启用'),
1219
- React.createElement(
1220
- 'div',
1221
- { className: 'dsh-workspace-plus-settings-description' },
1222
- '默认工作区是 DSH 首次启动时自动创建的那个,它默认不启用 workspace+:',
1223
- '不在它的工作区行显示目录按钮,也不会把标签表注入它的会话上下文。',
1224
- '其它工作区不受这个开关影响,始终启用。',
1225
- ),
1226
- ),
1227
- React.createElement(Switch, {
1228
- checked: enabled,
1229
- disabled: state.busy || unavailable,
1230
- label: '对默认工作区启用',
1231
- onChange: (next) => void toggle(next),
1232
- }),
1233
- )
1234
-
1235
- const target = React.createElement(
1236
- 'div',
1237
- { className: 'dsh-workspace-plus-settings-description' },
1238
- '当前默认工作区:',
1239
- host.defaultWorkspacePath === null
1240
- ? state.status === 'loading'
1241
- ? '正在读取…'
1242
- : '未识别(这个部署还没有默认工作区记录)'
1243
- : React.createElement('span', { className: 'dsh-workspace-plus-mono' }, host.defaultWorkspacePath),
1244
- )
1245
-
1246
- const status = (() => {
1247
- // A failed READ needs the transport diagnosis; a rejected WRITE only needs
1248
- // the host's own message.
1249
- if (state.status === 'error') {
1250
- const unrouted = state.message.includes('404')
1251
- return React.createElement(
1252
- 'div',
1253
- { className: 'dsh-workspace-plus-settings-error' },
1254
- '无法读取宿主设置:' + state.message,
1255
- unrouted
1256
- ? '(宿主里没有 ' + API_PATH + ' 这条路由,通常意味着 dsh 还没重启)'
1257
- : '',
1258
- )
1259
- }
1260
- if (state.message === '') return null
1261
- return React.createElement(
1262
- 'div',
1263
- { className: state.ok === false ? 'dsh-workspace-plus-settings-error' : 'dsh-workspace-plus-settings-ok' },
1264
- state.message,
1265
- )
1266
- })()
1267
-
1268
- const sourceStatus = labelSourceStatus()
1269
- const atLine = React.createElement(
1270
- 'div',
1271
- { className: 'dsh-workspace-plus-settings-description' },
1272
- '输入框 `@` 候选:',
1273
- sourceStatus.registered
1274
- ? sourceStatus.labels === 0
1275
- ? '候选源已注册,但还没有可用的标签 —— 先在工作区行里登记目录。'
1276
- : '候选源已注册,共 ' +
1277
- String(sourceStatus.labels) +
1278
- ' 个标签(分布在 ' +
1279
- String(sourceStatus.workspaces) +
1280
- ' 个工作区)。'
1281
- : '候选源未注册 —— 当前页面可能还是旧的插件 bundle,重启 dsh 并强制刷新页面。',
1282
- )
1283
-
1284
- return React.createElement(
1285
- 'div',
1286
- { className: 'dsh-workspace-plus-settings' },
1287
- row,
1288
- target,
1289
- atLine,
1290
- status,
1291
- React.createElement(
1292
- 'div',
1293
- { className: 'dsh-workspace-plus-settings-description' },
1294
- '标签本身在每个工作区的「工作区目录设置」弹窗里维护:悬停左侧工作区那一行的目录按钮即可打开。',
1295
- '关掉这个开关不会删除已登记的标签,只是让默认工作区在启用前保持原样。',
1296
- ),
1297
- )
1298
- }
1299
-
1300
- /**
1301
- * The `main` panel that holds the fallback entry's key. The shell's sidebar
1302
- * button always calls `layout.selectPanel(id)` — which throws for an
1303
- * unregistered key — so this key must exist even though the intended path is
1304
- * the dialog.
1305
- *
1306
- * @returns the hint panel element.
1307
- */
1308
- function WorkdirsHintPanel() {
1309
- return React.createElement(
1310
- 'div',
1311
- { className: 'dsh-workspace-plus-panel' },
1312
- React.createElement(
1313
- 'div',
1314
- { className: 'dsh-workspace-plus-card' },
1315
- React.createElement(
1316
- 'div',
1317
- { className: 'dsh-workspace-plus-badge' },
1318
- React.createElement(WorkdirsPanelIcon, { size: 28 }),
1319
- ),
1320
- React.createElement('h1', { className: 'dsh-workspace-plus-title' }, '工作区目录设置'),
1321
- React.createElement(
1322
- 'p',
1323
- { className: 'dsh-workspace-plus-subtitle' },
1324
- '这个功能以弹窗形式提供:点击侧边栏工作区那一行的目录按钮即可打开。',
1325
- ),
1326
- React.createElement(Button, { variant: 'primary', onClick: () => openWorkdirsDialog(null) }, '现在打开'),
1327
- ),
1328
- )
1329
- }
1330
-
1331
- /**
1332
- * Append the directory button to every workspace row and keep it there.
1333
- *
1334
- * The shell's workspace row menu is hardcoded to Rename/Delete and exposes no
1335
- * slot, so the trigger is appended to the row's action cluster instead. The
1336
- * anchors are shell-emitted data attributes rather than hashed class names:
1337
- * `data-slot="sidebar.workspaces"` on the browser outlet and
1338
- * `data-row-key="workspace:<workspaceId>"` on a group row (that id is
1339
- * `Workspace.id` from the host registry).
1340
- *
1341
- * The button is appended last in the cluster, where React's own child
1342
- * reconciliation never has to insert before it. A MutationObserver reconciles
1343
- * additions and removals, because React re-renders the row without knowing
1344
- * this node exists.
1345
- *
1346
- * A workspace whose `enabled` flag is false (the default Workspace before the
1347
- * user opts in) gets no button, and loses one if it is disabled while mounted —
1348
- * `refresh()` re-runs the reconcile after a settings change.
1349
- *
1350
- * @param options - the availability callback, fired when the injected count changes.
1351
- * @returns `{ dispose, available, refresh }`.
1352
- */
1353
- function installWorkspaceRowButtons({ onAvailability } = {}) {
1354
- if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') {
1355
- return { dispose: () => {}, available: () => false, refresh: () => {} }
1356
- }
1357
- const injected = new Map()
1358
- let scheduled = false
1359
- let stopped = false
1360
- let lastReported = null
1361
-
1362
- const report = () => {
1363
- const available = injected.size > 0
1364
- if (available === lastReported) return
1365
- lastReported = available
1366
- if (typeof onAvailability === 'function') onAvailability(available)
1367
- }
1368
-
1369
- const reconcile = () => {
1370
- scheduled = false
1371
- if (stopped) return
1372
- const container = document.querySelector(SIDEBAR_SLOT_SELECTOR)
1373
- // Nothing is mounted yet: reporting here would flash the fallback entry.
1374
- if (container === null) return
1375
- const rows = container.querySelectorAll(WORKSPACE_ROW_SELECTOR)
1376
- const live = new Set(rows)
1377
- for (const [row, button] of [...injected]) {
1378
- const workspaceId = (row.getAttribute('data-row-key') ?? '').slice(ROW_KEY_PREFIX.length)
1379
- const stillWanted = live.has(row) && row.isConnected && workspaceId !== '' && isEnabledWorkspace(workspaceId)
1380
- if (stillWanted) continue
1381
- button.remove()
1382
- injected.delete(row)
1383
- }
1384
- for (const row of rows) {
1385
- const workspaceId = (row.getAttribute('data-row-key') ?? '').slice(ROW_KEY_PREFIX.length)
1386
- // The synthetic "ungrouped" row carries the empty key and owns no workspace.
1387
- if (workspaceId === '') continue
1388
- // The default Workspace stays untouched until the user opts in.
1389
- if (!isEnabledWorkspace(workspaceId)) continue
1390
- const existing = injected.get(row)
1391
- if (existing !== undefined && existing.isConnected) continue
1392
- const actions = row.lastElementChild
1393
- if (actions === null || actions === undefined) continue
1394
- const button = document.createElement('button')
1395
- button.type = 'button'
1396
- button.className = ROW_BUTTON_CLASS
1397
- button.title = ROW_BUTTON_LABEL
1398
- button.setAttribute('aria-label', ROW_BUTTON_LABEL)
1399
- button.innerHTML = ROW_BUTTON_GLYPH
1400
- button.addEventListener('click', (event) => {
1401
- // Keep the click off the row, whose own handler toggles the group.
1402
- event.preventDefault()
1403
- event.stopPropagation()
1404
- openWorkdirsDialog(workspaceId)
1405
- })
1406
- actions.appendChild(button)
1407
- injected.set(row, button)
1408
- }
1409
- report()
1410
- }
1411
-
1412
- const schedule = () => {
1413
- if (scheduled || stopped) return
1414
- scheduled = true
1415
- if (typeof requestAnimationFrame === 'function') requestAnimationFrame(reconcile)
1416
- else setTimeout(reconcile, 16)
1417
- }
1418
-
1419
- const observer = new MutationObserver(schedule)
1420
- observer.observe(document.body, { childList: true, subtree: true })
1421
- reconcile()
1422
-
1423
- return {
1424
- available: () => injected.size > 0,
1425
- refresh: () => {
1426
- if (stopped) return
1427
- reconcile()
1428
- },
1429
- dispose: () => {
1430
- stopped = true
1431
- observer.disconnect()
1432
- for (const button of injected.values()) button.remove()
1433
- injected.clear()
1434
- },
1435
- }
1436
- }
1437
-
1438
- // -----------------------------------------------------------------------
1439
- // Plugin
1440
- // -----------------------------------------------------------------------
1441
-
1442
- /**
1443
- * Services this browser plugin needs. `slots` is required to contribute
1444
- * anything at all; the sidebar buttons and the panel selection are owned by
1445
- * the shell, so this half never touches `ctx.layout` itself.
1446
- */
1447
- const inject = ['slots']
1448
-
1449
- /**
1450
- * Contribute the `workspace+` settings page, the directory dialog, its
1451
- * fallback entry, and the workspace-row buttons that open it.
1452
- *
1453
- * Every registration goes through `ctx.slots.inject(key, callback)`: the
1454
- * callback runs as soon as the shell declares that slot (immediately when it
1455
- * already exists), and its returned disposer is owned by this plugin's fiber,
1456
- * so unloading the plugin removes the entry again.
1457
- *
1458
- * @param ctx - the browser plugin context.
1459
- */
1460
- function apply(ctx) {
1461
- // The settings page owns the default-Workspace switch.
1462
- ctx.effect(
1463
- () =>
1464
- ctx.slots.inject('settings.section', () =>
1465
- ctx.slots.register(
1466
- {
1467
- name: 'settings.section',
1468
- id: SETTINGS_SECTION_ID,
1469
- order: SETTINGS_SECTION_ORDER,
1470
- label: SETTINGS_SECTION_LABEL,
1471
- },
1472
- WorkspacePlusSettings,
1473
- ),
1474
- ),
1475
- 'dsh-workspace-plus: settings section',
1476
- )
1477
-
1478
- // The dialog lives in the frame-wide overlay: the same seat the shell's own
1479
- // workspace Rename/Delete dialogs occupy.
1480
- ctx.effect(
1481
- () =>
1482
- ctx.slots.inject('shell.overlay', () =>
1483
- ctx.slots.register({ name: 'shell.overlay', id: 'workdirs-dialog', order: 50 }, WorkdirsDialog),
1484
- ),
1485
- 'dsh-workspace-plus: workdirs dialog',
1486
- )
1487
-
1488
- // The fallback entry's main key always exists; the entry itself is
1489
- // registered only while no workspace-row button could be installed.
1490
- ctx.effect(
1491
- () =>
1492
- ctx.slots.inject('main', () =>
1493
- ctx.slots.register({ name: 'main', key: WORKDIRS_PANEL_ID }, WorkdirsHintPanel),
1494
- ),
1495
- 'dsh-workspace-plus: workdirs fallback panel',
1496
- )
1497
-
1498
- // The dialog's browse button asks the shell's own picker. Injected
1499
- // optionally: a composition without ui-workspace still loads this plugin,
1500
- // the button just stays disabled.
1501
- ctx.effect(
1502
- () =>
1503
- ctx.inject(['uiWorkspace'], (uiScope) => {
1504
- directoryPicker = uiScope.uiWorkspace
1505
- return () => {
1506
- directoryPicker = null
1507
- }
1508
- }),
1509
- 'dsh-workspace-plus: directory picker',
1510
- )
1511
-
1512
- // The `@` source and the session list it keys off. Both are injected
1513
- // optionally: without them the composer simply offers no label group.
1514
- ctx.inject(['sessions'], (sessionScope) => {
1515
- sessionScope.effect(
1516
- () => {
1517
- sessionsService = sessionScope.sessions
1518
- return () => {
1519
- sessionsService = null
1520
- }
1521
- },
1522
- 'dsh-workspace-plus: session list',
1523
- )
1524
- })
1525
-
1526
- ctx.inject(['inputTriggers'], (triggerScope) => {
1527
- triggerScope.effect(
1528
- () => {
1529
- const dispose = triggerScope.inputTriggers.registerSource(createLabelSource())
1530
- labelSourceRegistered = true
1531
- return () => {
1532
- labelSourceRegistered = false
1533
- dispose()
1534
- }
1535
- },
1536
- 'dsh-workspace-plus: @ label source',
1537
- )
1538
- })
1539
-
1540
- let registerEntry = null
1541
- let entryDispose = null
1542
- let rowEntryAvailable = false
1543
- let settled = false
1544
-
1545
- const syncEntry = () => {
1546
- if (registerEntry === null) return
1547
- const wantFallback = !rowEntryAvailable && settled
1548
- if (wantFallback && entryDispose === null) entryDispose = registerEntry()
1549
- if (!wantFallback && entryDispose !== null) {
1550
- entryDispose()
1551
- entryDispose = null
1552
- }
1553
- }
1554
-
1555
- ctx.effect(
1556
- () =>
1557
- ctx.slots.inject('sidebar.panellist', () => {
1558
- registerEntry = () =>
1559
- ctx.slots.register(
1560
- {
1561
- name: 'sidebar.panellist',
1562
- id: WORKDIRS_PANEL_ID,
1563
- order: WORKDIRS_PANEL_ORDER,
1564
- label: WORKDIRS_PANEL_LABEL,
1565
- },
1566
- WorkdirsEntryIcon,
1567
- )
1568
- syncEntry()
1569
- return () => {
1570
- registerEntry = null
1571
- if (entryDispose !== null) {
1572
- entryDispose()
1573
- entryDispose = null
1574
- }
1575
- }
1576
- }),
1577
- 'dsh-workspace-plus: workdirs fallback entry',
1578
- )
1579
-
1580
- const settleTimer = setTimeout(() => {
1581
- settled = true
1582
- syncEntry()
1583
- }, FALLBACK_SETTLE_MS)
1584
-
1585
- const rowButtons = installWorkspaceRowButtons({
1586
- onAvailability: (available) => {
1587
- rowEntryAvailable = available
1588
- settled = true
1589
- syncEntry()
1590
- },
1591
- })
1592
-
1593
- // Warm the shared snapshot at boot and keep the row button in step with the
1594
- // settings: a switch flipped in the settings page adds or removes it without
1595
- // a reload.
1596
- const offHostState = subscribeHostState(() => rowButtons.refresh())
1597
- void refreshHostState()
1598
-
1599
- ctx.effect(
1600
- () => () => {
1601
- clearTimeout(settleTimer)
1602
- offHostState()
1603
- rowButtons.dispose()
1604
- },
1605
- 'dsh-workspace-plus: workspace row buttons',
1606
- )
1607
- }
1608
-
1609
- exports.WORKDIRS_PANEL_ID = WORKDIRS_PANEL_ID
1610
- exports.WORKDIRS_PANEL_LABEL = WORKDIRS_PANEL_LABEL
1611
- exports.SETTINGS_SECTION_ID = SETTINGS_SECTION_ID
1612
- exports.SETTINGS_SECTION_LABEL = SETTINGS_SECTION_LABEL
1613
- exports.SETTINGS_SECTION_ORDER = SETTINGS_SECTION_ORDER
1614
- exports.DEFAULT_WORKSPACE_SETTING = DEFAULT_WORKSPACE_SETTING
1615
- exports.API_PATH = API_PATH
1616
- exports.ROW_BUTTON_CLASS = ROW_BUTTON_CLASS
1617
- exports.ROW_BUTTON_LABEL = ROW_BUTTON_LABEL
1618
- exports.SIDEBAR_SLOT_SELECTOR = SIDEBAR_SLOT_SELECTOR
1619
- exports.WORKSPACE_ROW_SELECTOR = WORKSPACE_ROW_SELECTOR
1620
- exports.FALLBACK_SETTLE_MS = FALLBACK_SETTLE_MS
1621
- exports.readDialog = readDialog
1622
- exports.subscribeDialog = subscribeDialog
1623
- exports.openWorkdirsDialog = openWorkdirsDialog
1624
- exports.closeWorkdirsDialog = closeWorkdirsDialog
1625
- exports.readHostState = readHostState
1626
- exports.subscribeHostState = subscribeHostState
1627
- exports.publishHostPayload = publishHostPayload
1628
- exports.isEnabledWorkspace = isEnabledWorkspace
1629
- exports.SOURCE_NAME = SOURCE_NAME
1630
- exports.SOURCE_SECTION = SOURCE_SECTION
1631
- exports.samePath = samePath
1632
- exports.sessionCwd = sessionCwd
1633
- exports.workspaceForSession = workspaceForSession
1634
- exports.parseLabelValue = parseLabelValue
1635
- exports.createLabelSource = createLabelSource
1636
- exports.labelSourceStatus = labelSourceStatus
1637
- exports.selectWorkspace = selectWorkspace
1638
- exports.isOutside = isOutside
1639
- exports.basenameOf = basenameOf
1640
- exports.suggestLabel = suggestLabel
1641
- exports.draftAfterPick = draftAfterPick
1642
- exports.pickErrorMessage = pickErrorMessage
1643
- exports.installWorkspaceRowButtons = installWorkspaceRowButtons
1644
- exports.WorkdirsPanelIcon = WorkdirsPanelIcon
1645
- exports.WorkdirsEntryIcon = WorkdirsEntryIcon
1646
- exports.WorkdirsDialog = WorkdirsDialog
1647
- exports.WorkdirsHintPanel = WorkdirsHintPanel
1648
- exports.WorkspacePlusSettings = WorkspacePlusSettings
1649
- exports.apply = apply
1650
- exports.inject = inject
1651
- return module.exports
1652
- },
1653
- })
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
+ * Five 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 path field is
28
+ * a control rather than an input: clicking it opens the
29
+ * shell's own directory picker through
30
+ * `ctx.uiWorkspace.pickDirectory()`.
31
+ *
32
+ * workspace row button the primary trigger. The shell's workspace row menu is
33
+ * hardcoded to Rename/Delete with no plugin slot, so the
34
+ * trigger is a button this half appends to the row's
35
+ * action cluster, located through shell-emitted
36
+ * `data-row-key="workspace:<id>"` anchors and kept in
37
+ * place by a MutationObserver. Rows the host reports as
38
+ * disabled get no button.
39
+ *
40
+ * `sidebar.panellist` the fallback trigger, registered only while no row
41
+ * button could be installed (a shell whose row DOM
42
+ * changed, or a sidebar with no workspaces yet).
43
+ * Registering it also requires a `main` key: the shell's
44
+ * panel button calls `layout.selectPanel` unconditionally
45
+ * and that throws for an unregistered key, so a hint
46
+ * panel holds the key and the glyph stops the click from
47
+ * ever reaching it.
48
+ *
49
+ * `@` trigger source the composer side. Typing `@` in a session offers that
50
+ * workspace's labels as their own group; a pick inserts a
51
+ * reference chip that serializes to the directory's
52
+ * absolute path, so the model receives the path even when
53
+ * the injected table is not in context.
54
+ *
55
+ * Only the shell's frozen platform module table may be `require`d here (react,
56
+ * react-dom, react/jsx-runtime, @deepseek-ai/cordis,
57
+ * @deepseek-ai/dsh-client-store, @deepseek-ai/dsh-client-ui-slots,
58
+ * @deepseek-ai/dsh-client-ui-primitives, @deepseek-ai/dsh-client-ui-dockkit).
59
+ * Any other specifier needs a matching `dsh.client.external` entry in
60
+ * package.json, which this plugin does not use.
61
+ */
62
+ window.__ModuleLoader__.load({
63
+ id: 'dsh-workspace-plus',
64
+ factory: (require) => {
65
+ var module = { exports: {} }
66
+ var exports = module.exports
67
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
68
+
69
+ const React = require('react')
70
+ const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
71
+ const { Button, Modal, Switch } = primitives
72
+
73
+ /**
74
+ * The shell's hover tooltip. The dialog's "outside the workspace root" badge
75
+ * says what it means through this rather than through a sentence in the row,
76
+ * so a shell that predates the export degrades to the still-labelled badge
77
+ * instead of crashing the dialog.
78
+ */
79
+ const Tooltip = typeof primitives.Tooltip === 'function' ? primitives.Tooltip : null
80
+
81
+ /** Id of the directory-dialog fallback entry, and the main key that backs it. */
82
+ const WORKDIRS_PANEL_ID = 'workdirs'
83
+
84
+ /** Sidebar row label of the directory-dialog fallback entry. */
85
+ const WORKDIRS_PANEL_LABEL = 'workdirs'
86
+
87
+ /**
88
+ * Position among the global panel rows. The shipped entries use 0 (Plugins)
89
+ * and 10 (Scheduled tasks); 30 keeps this one after them.
90
+ */
91
+ const WORKDIRS_PANEL_ORDER = 30
92
+
93
+ /**
94
+ * The settings page. `settings.section` occupants ship at -10 (account),
95
+ * 0 (general), 10 (models), 15 (plugins) and 20 (agent presets); 25 puts
96
+ * `workspace+` at the end of the product sections and before nothing else.
97
+ */
98
+ const SETTINGS_SECTION_ID = 'workspaceplus'
99
+ const SETTINGS_SECTION_LABEL = 'workspace+'
100
+ const SETTINGS_SECTION_ORDER = 25
101
+
102
+ /**
103
+ * Settings keys this page owns.
104
+ *
105
+ * `WORKSPACE_ENABLED_SETTING` is the per-workspace layer the settings page
106
+ * actually writes: one switch per workspace, keyed by the workspace key the
107
+ * host publishes in its snapshot. `DEFAULT_WORKSPACE_SETTING` is the default
108
+ * that layer falls back to for the default Workspace, and is read only.
109
+ */
110
+ const DEFAULT_WORKSPACE_SETTING = 'defaultWorkspaceEnabled'
111
+ const WORKSPACE_ENABLED_SETTING = 'workspaceEnabled'
112
+
113
+ /** Host JSON endpoint, resolved against the page so it survives any mount path. */
114
+ const API_PATH = 'workspaceplus/api'
115
+
116
+ /** Identity of the injected stylesheet, used to keep exactly one copy. */
117
+ const STYLE_ID = 'dsh-workspace-plus/panel.css'
118
+
119
+ /**
120
+ * Row anchors. The shell emits `data-slot="<slot key>"` on every slot outlet
121
+ * and `data-row-key="workspace:<workspaceId>"` on a workspace group row, and
122
+ * a row's last element child is its action cluster.
123
+ */
124
+ const SIDEBAR_SLOT_SELECTOR = '[data-slot="sidebar.workspaces"]'
125
+ const WORKSPACE_ROW_SELECTOR = '[data-row-key^="workspace:"]'
126
+ const ROW_KEY_PREFIX = 'workspace:'
127
+
128
+ /** Class of the button this half appends to a workspace row. */
129
+ const ROW_BUTTON_CLASS = 'dsh-workspace-plus-rowbtn'
130
+
131
+ /** Label of the injected row button. */
132
+ const ROW_BUTTON_LABEL = '工作区目录设置'
133
+
134
+ /**
135
+ * The shell's own folder-open artwork, copied verbatim from
136
+ * `@deepseek-ai/dsh-client-ui-primitives` (`IconFolderOpenArtwork`). It is
137
+ * static path data rather than a runtime import, so the injected button is
138
+ * pixel-identical to the row icons the shell draws beside it without adding a
139
+ * dependency on an icon export name.
140
+ */
141
+ const FOLDER_ARTWORK = [
142
+ {
143
+ 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',
144
+ opacity: '0.16',
145
+ },
146
+ {
147
+ 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',
148
+ },
149
+ {
150
+ 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',
151
+ },
152
+ ]
153
+
154
+ /** Glyph of the injected row button, built from the official artwork above. */
155
+ const ROW_BUTTON_GLYPH =
156
+ '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" ' +
157
+ 'aria-hidden="true" focusable="false">' +
158
+ FOLDER_ARTWORK.map(
159
+ (path) => `<path d="${path.d}" fill="currentColor"${path.opacity === undefined ? '' : ` opacity="${path.opacity}"`}/>`,
160
+ ).join('') +
161
+ '</svg>'
162
+
163
+ /**
164
+ * The shell's shield contour, copied verbatim from
165
+ * `@deepseek-ai/dsh-client-ui-primitives` (`SHIELD_OUTLINE_PATH`, the artwork
166
+ * behind `IconShieldOutlineRegular`). Same reasoning as the folder artwork
167
+ * above: the badge is the product's own warning shape without depending on an
168
+ * export name. It strokes with `currentColor`, so the row picks the color (the
169
+ * theme's warn token) rather than the artwork.
170
+ */
171
+ const SHIELD_PATH =
172
+ 'M6.80132 2.14853C7.70663 1.80917 8.70422 1.80919 9.60952 2.14859L14.1296 3.84317V7.11961C14.1296 11.6089 ' +
173
+ '10.7615 13.5975 8.20543 14.5779C5.64931 13.5975 2.28052 11.6089 2.28052 7.11961V3.84317L6.80132 2.14853Z'
174
+
175
+ /**
176
+ * How long the fallback entry waits before deciding that no workspace-row
177
+ * button can exist, so a healthy shell never flashes it.
178
+ */
179
+ const FALLBACK_SETTLE_MS = 1200
180
+
181
+ /**
182
+ * Panel styles. Everything is namespaced under `dsh-workspace-plus-` and
183
+ * colored through the shell's theme tokens, so the surfaces follow the active
184
+ * light/dark theme instead of hard-coding colors. Fallbacks after each token
185
+ * keep them readable on a shell that predates a given token.
186
+ */
187
+ const CSS = `
188
+ .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}
189
+ .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}
190
+ .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)}
191
+ .dsh-workspace-plus-glyph{display:block}
192
+ .dsh-workspace-plus-title{margin:0;font-size:24px;font-weight:600;letter-spacing:.2px}
193
+ .dsh-workspace-plus-subtitle{margin:0;max-width:42ch;color:var(--dsw-alias-label-secondary)}
194
+ .dsh-workspace-plus-actions{display:flex;align-items:center;gap:12px;margin-top:6px}
195
+ .dsh-workspace-plus-settings{flex-direction:column;width:100%;display:flex}
196
+ .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}
197
+ .dsh-workspace-plus-settings-text{min-width:0}
198
+ .dsh-workspace-plus-settings-title{font-size:14px;line-height:20px}
199
+ .dsh-workspace-plus-settings-tag{margin-left:8px;padding:0 6px;border-radius:999px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);font-size:11px;line-height:16px;vertical-align:1px}
200
+ .dsh-workspace-plus-settings-description{color:var(--dsw-alias-label-secondary);margin-top:4px;font-size:12px;line-height:18px}
201
+ .dsh-workspace-plus-settings-error{color:var(--dsw-alias-state-error-primary);margin-top:8px;font-size:12px;line-height:18px}
202
+ .dsh-workspace-plus-settings-ok{color:var(--dsw-alias-state-success-primary);margin-top:8px;font-size:12px;line-height:18px}
203
+ .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)}
204
+ .dsh-workspace-plus-rowbtn:hover{color:var(--dsw-alias-label-primary)}
205
+ .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}
206
+ .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}
207
+ .dsh-workspace-plus-dialog-card.dsh-workspace-plus-dialog-card{width:min(760px,100%)}
208
+ .dsh-workspace-plus-toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
209
+ .dsh-workspace-plus-label{color:var(--dsw-alias-label-secondary);flex:none}
210
+ .dsh-workspace-plus-locked{font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
211
+ .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}
212
+ .dsh-workspace-plus-select{max-width:100%;flex:1 1 240px}
213
+ .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}
214
+ .dsh-workspace-plus-input-label{flex:0 1 180px}
215
+ .dsh-workspace-plus-input-note{flex:1 1 220px}
216
+ .dsh-workspace-plus-path{box-sizing:border-box;display:flex;align-items:center;gap:8px;width:100%;height:32px;padding:0 10px;font:inherit;text-align:left;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);cursor:pointer}
217
+ .dsh-workspace-plus-path:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);border-color:var(--dsw-alias-border-l3,var(--dsw-alias-border-l2))}
218
+ .dsh-workspace-plus-path:disabled{cursor:not-allowed;color:var(--dsw-alias-label-tertiary)}
219
+ .dsh-workspace-plus-path:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-brand-primary));outline-offset:1px}
220
+ .dsh-workspace-plus-path-glyph{display:inline-flex;flex:none;align-items:center;color:var(--dsw-alias-label-secondary)}
221
+ .dsh-workspace-plus-path-empty{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary)}
222
+ .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)}
223
+ .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)}
224
+ .dsh-workspace-plus-row:first-child{border-top:none}
225
+ .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}
226
+ .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)}
227
+ .dsh-workspace-plus-row-note{flex:0 1 auto;color:var(--dsw-alias-label-secondary);max-width:28ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
228
+ .dsh-workspace-plus-row-outside{flex:none;display:inline-flex;align-items:center;color:var(--dsw-alias-state-warn-primary)}
229
+ .dsh-workspace-plus-row-outside:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-brand-primary));outline-offset:1px;border-radius:var(--dsw-radius-xs,4px)}
230
+ .dsh-workspace-plus-form{display:flex;flex-direction:column;gap:8px;width:100%}
231
+ .dsh-workspace-plus-form-row{display:flex;align-items:center;gap:10px;width:100%}
232
+ .dsh-workspace-plus-submit{flex:none}
233
+ .dsh-workspace-plus-empty{padding:14px 12px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-1)}
234
+ .dsh-workspace-plus-note{color:var(--dsw-alias-label-secondary);font-size:13px}
235
+ .dsh-workspace-plus-error{color:var(--dsw-alias-state-error-primary)}
236
+ .dsh-workspace-plus-ok{color:var(--dsw-alias-state-success-primary)}
237
+ .dsh-workspace-plus-mono{font-family:var(--dsw-font-mono,ui-monospace,SFMono-Regular,Menlo,monospace);word-break:break-all}
238
+ @keyframes dsh-workspace-plus-in{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
239
+ @media (prefers-reduced-motion:reduce){.dsh-workspace-plus-card{animation:none}}
240
+ `
241
+
242
+ /**
243
+ * Install the panel stylesheet, exactly one tag per document. The factory body
244
+ * runs at materialization, and a client-module reload re-runs it inside the
245
+ * page that is already open: rewriting the existing tag (rather than skipping
246
+ * when one is present) keeps the count at one AND keeps it current, so a
247
+ * stylesheet edit never pairs the new markup with the previous CSS. The
248
+ * `data-plugin-css` marker is what finds that tag again.
249
+ */
250
+ function ensureStyles() {
251
+ if (typeof document === 'undefined') return
252
+ const existing = document.querySelector('style[data-plugin-css=' + JSON.stringify(STYLE_ID) + ']')
253
+ if (existing !== null) {
254
+ existing.textContent = CSS
255
+ return
256
+ }
257
+ const tag = document.createElement('style')
258
+ tag.dataset.plugin = 'dsh-workspace-plus'
259
+ tag.dataset.pluginCss = STYLE_ID
260
+ tag.textContent = CSS
261
+ document.head.appendChild(tag)
262
+ }
263
+
264
+ ensureStyles()
265
+
266
+ // -----------------------------------------------------------------------
267
+ // The workspace-directory feature
268
+ // -----------------------------------------------------------------------
269
+
270
+ /**
271
+ * The folder-open artwork as an element, so the fallback sidebar entry, the
272
+ * hint panel, and the dialog's directory picker all draw the same icon the
273
+ * shell uses on its workspace row.
274
+ *
275
+ * @param props - optional free-form class, and the click handler when the
276
+ * glyph is itself a control.
277
+ * @returns the icon element.
278
+ */
279
+ function FolderGlyph({ size = 16, className = 'dsh-workspace-plus-glyph', onClick }) {
280
+ return React.createElement(
281
+ 'svg',
282
+ {
283
+ className,
284
+ width: size,
285
+ height: size,
286
+ viewBox: '0 0 16 16',
287
+ fill: 'none',
288
+ xmlns: 'http://www.w3.org/2000/svg',
289
+ 'aria-hidden': 'true',
290
+ focusable: 'false',
291
+ ...(onClick === undefined ? {} : { onClick }),
292
+ },
293
+ FOLDER_ARTWORK.map((path, index) =>
294
+ React.createElement('path', {
295
+ key: index,
296
+ d: path.d,
297
+ fill: 'currentColor',
298
+ ...(path.opacity === undefined ? {} : { opacity: path.opacity }),
299
+ }),
300
+ ),
301
+ )
302
+ }
303
+
304
+ /**
305
+ * The directory glyph, shared by the fallback sidebar entry and the hint
306
+ * panel. It draws the same official artwork as the injected row button, so
307
+ * every entry point to this feature looks like the shell's own icons.
308
+ *
309
+ * @param props - sidebar icon share, plus the optional click handler.
310
+ * @returns the icon element.
311
+ */
312
+ function WorkdirsPanelIcon({ size = 16, onClick }) {
313
+ return React.createElement(FolderGlyph, { size, onClick })
314
+ }
315
+
316
+ /**
317
+ * The fallback entry's glyph. Its click is stopped before the shell's panel
318
+ * button sees it, so the sidebar row opens the dialog instead of switching
319
+ * `main`; the hint panel behind that key is what a user sees if this ever
320
+ * stops working.
321
+ *
322
+ * @param props - sidebar icon share.
323
+ * @returns the icon element.
324
+ */
325
+ function WorkdirsEntryIcon(props) {
326
+ return WorkdirsPanelIcon({
327
+ ...props,
328
+ onClick: (event) => {
329
+ event.preventDefault()
330
+ event.stopPropagation()
331
+ openWorkdirsDialog(null)
332
+ },
333
+ })
334
+ }
335
+
336
+ /**
337
+ * Bundle-local open state for the dialog. Its two triggers live in different
338
+ * React trees from the dialog (one is not React at all), so they meet on this
339
+ * tiny external store rather than on a shared parent.
340
+ */
341
+ let dialogSnapshot = { open: false, workspaceId: null }
342
+ const dialogListeners = new Set()
343
+
344
+ /** @returns the current dialog snapshot (referentially stable until it changes). */
345
+ function readDialog() {
346
+ return dialogSnapshot
347
+ }
348
+
349
+ /**
350
+ * Subscribe to dialog open-state changes.
351
+ *
352
+ * @param listener - called after each change.
353
+ * @returns the unsubscribe function.
354
+ */
355
+ function subscribeDialog(listener) {
356
+ dialogListeners.add(listener)
357
+ return () => {
358
+ dialogListeners.delete(listener)
359
+ }
360
+ }
361
+
362
+ /**
363
+ * Replace the dialog snapshot and notify subscribers.
364
+ *
365
+ * @param next - the next `{ open, workspaceId }`.
366
+ */
367
+ function writeDialog(next) {
368
+ const previous = dialogSnapshot
369
+ if (previous.open === next.open && previous.workspaceId === next.workspaceId) return
370
+ dialogSnapshot = next
371
+ for (const listener of [...dialogListeners]) listener()
372
+ }
373
+
374
+ /**
375
+ * Open the dialog, optionally focused on one workspace.
376
+ *
377
+ * @param workspaceId - the workspace to preselect, or null to keep the current one.
378
+ */
379
+ function openWorkdirsDialog(workspaceId = null) {
380
+ writeDialog({ open: true, workspaceId })
381
+ }
382
+
383
+ /** Close the dialog. */
384
+ function closeWorkdirsDialog() {
385
+ writeDialog({ open: false, workspaceId: null })
386
+ }
387
+
388
+ /** The dialog's state before the first host response arrives. */
389
+ const WORKDIRS_IDLE = {
390
+ status: 'loading',
391
+ message: '',
392
+ ok: null,
393
+ registryAvailable: true,
394
+ workspaces: [],
395
+ selected: null,
396
+ locked: false,
397
+ busy: false,
398
+ }
399
+
400
+ /**
401
+ * Resolve the host endpoint against the page, so the same code works under a
402
+ * root mount, under a nested mount, and under the desktop shell's
403
+ * `dsh-app://app/` origin (which forwards every non-asset path to the Host).
404
+ *
405
+ * @returns the resolved endpoint URL.
406
+ */
407
+ function apiUrl() {
408
+ if (typeof document === 'undefined') return API_PATH
409
+ try {
410
+ return new URL(API_PATH, document.baseURI).toString()
411
+ } catch {
412
+ return API_PATH
413
+ }
414
+ }
415
+
416
+ /**
417
+ * Read the workspace list plus every registered directory from the host.
418
+ *
419
+ * @param signal - optional abort signal.
420
+ * @returns the parsed payload, or `{ ok: false, message }`.
421
+ */
422
+ async function fetchWorkspaces(signal) {
423
+ const response = await fetch(apiUrl(), {
424
+ method: 'GET',
425
+ headers: { accept: 'application/json' },
426
+ credentials: 'same-origin',
427
+ signal,
428
+ })
429
+ if (!response.ok) return { ok: false, message: 'host responded ' + response.status }
430
+ return await response.json()
431
+ }
432
+
433
+ /**
434
+ * Send one mutation to the host.
435
+ *
436
+ * @param payload - `{ op, workspace, label, path, note }`.
437
+ * @returns the parsed payload, or `{ ok: false, message }`.
438
+ */
439
+ async function postOperation(payload) {
440
+ const response = await fetch(apiUrl(), {
441
+ method: 'POST',
442
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
443
+ credentials: 'same-origin',
444
+ body: JSON.stringify(payload),
445
+ })
446
+ if (!response.ok) return { ok: false, message: 'host responded ' + response.status }
447
+ return await response.json()
448
+ }
449
+
450
+ /**
451
+ * The last host payload this page has seen, shared by the three client
452
+ * surfaces: the settings page renders one switch per workspace from it, the
453
+ * row installer consults the per-workspace `enabled` flag, and the dialog
454
+ * refreshes it after every mutation.
455
+ */
456
+ let hostState = {
457
+ known: false,
458
+ settings: { [DEFAULT_WORKSPACE_SETTING]: false, [WORKSPACE_ENABLED_SETTING]: {} },
459
+ defaultWorkspaceId: null,
460
+ defaultWorkspacePath: null,
461
+ workspaces: [],
462
+ }
463
+ const hostStateListeners = new Set()
464
+
465
+ /** @returns the current host snapshot (referentially stable until it changes). */
466
+ function readHostState() {
467
+ return hostState
468
+ }
469
+
470
+ /**
471
+ * Subscribe to host-snapshot changes.
472
+ *
473
+ * @param listener - called after each change.
474
+ * @returns the unsubscribe function.
475
+ */
476
+ function subscribeHostState(listener) {
477
+ hostStateListeners.add(listener)
478
+ return () => {
479
+ hostStateListeners.delete(listener)
480
+ }
481
+ }
482
+
483
+ /**
484
+ * Fold a host payload into the shared snapshot. Unusable payloads leave the
485
+ * previous snapshot alone.
486
+ *
487
+ * @param payload - a parsed `/workspaceplus/api` response.
488
+ * @returns whether the payload was usable.
489
+ */
490
+ function publishHostPayload(payload) {
491
+ if (payload === null || typeof payload !== 'object' || payload.ok !== true) return false
492
+ const rawOverrides = payload.settings?.[WORKSPACE_ENABLED_SETTING]
493
+ const overrides = {}
494
+ if (rawOverrides !== null && typeof rawOverrides === 'object' && !Array.isArray(rawOverrides)) {
495
+ for (const [key, value] of Object.entries(rawOverrides)) {
496
+ if (typeof value === 'boolean') overrides[key] = value
497
+ }
498
+ }
499
+ hostState = {
500
+ known: true,
501
+ settings: {
502
+ [DEFAULT_WORKSPACE_SETTING]: payload.settings?.[DEFAULT_WORKSPACE_SETTING] === true,
503
+ [WORKSPACE_ENABLED_SETTING]: overrides,
504
+ },
505
+ defaultWorkspaceId: typeof payload.defaultWorkspaceId === 'string' ? payload.defaultWorkspaceId : null,
506
+ defaultWorkspacePath: typeof payload.defaultWorkspacePath === 'string' ? payload.defaultWorkspacePath : null,
507
+ workspaces: Array.isArray(payload.workspaces) ? payload.workspaces : [],
508
+ }
509
+ for (const listener of [...hostStateListeners]) listener()
510
+ return true
511
+ }
512
+
513
+ /**
514
+ * Whether the feature runs for one workspace id. A row whose workspace is not
515
+ * in the snapshot yet keeps its entry point: hiding it on a guess would remove
516
+ * the UI from a perfectly ordinary workspace.
517
+ *
518
+ * @param workspaceId - the id taken from the row's `data-row-key`.
519
+ * @returns true when the row should carry the directory button.
520
+ */
521
+ function isEnabledWorkspace(workspaceId) {
522
+ const workspace = hostState.workspaces.find((candidate) => candidate.id === workspaceId)
523
+ return workspace === undefined ? true : workspace.enabled !== false
524
+ }
525
+
526
+ /**
527
+ * Refresh the shared snapshot from the host.
528
+ *
529
+ * @returns whether a usable payload arrived.
530
+ */
531
+ async function refreshHostState() {
532
+ try {
533
+ return publishHostPayload(await fetchWorkspaces())
534
+ } catch {
535
+ return false
536
+ }
537
+ }
538
+
539
+ // -----------------------------------------------------------------------
540
+ // The composer's `@` source
541
+ // -----------------------------------------------------------------------
542
+
543
+ /**
544
+ * Source name of the `@` group that offers this workspace's labels. It must be
545
+ * unique for its trigger (`ui-reference` owns `@reference`): the input pipeline
546
+ * routes a chip's serializer by source name, and a chip whose source has no
547
+ * registered codec rejects the whole submit.
548
+ */
549
+ const SOURCE_NAME = 'workspace-plus'
550
+
551
+ /** Section heading of the group's rows, standing in for the pipeline's group title. */
552
+ const SOURCE_SECTION = '工作区目录'
553
+
554
+ /** The client session list, captured for the `@` source's cwd lookup. */
555
+ let sessionsService = null
556
+
557
+ /**
558
+ * Compare two directory paths the way the host does — separator- and
559
+ * case-insensitively on Windows.
560
+ *
561
+ * @param left - one path.
562
+ * @param right - the other path.
563
+ * @returns whether they name the same directory.
564
+ */
565
+ function samePath(left, right) {
566
+ const fold = (value) =>
567
+ String(value)
568
+ .replace(/[\\/]+$/, '')
569
+ .replace(/\//g, '\\')
570
+ .toLowerCase()
571
+ return fold(left) === fold(right)
572
+ }
573
+
574
+ /**
575
+ * The session's working directory, which is the workspace key. `sessions.list`
576
+ * is the same projection `ui-reference` and `ui-session` read; a composition
577
+ * without it simply yields no candidates.
578
+ *
579
+ * @param sessionId - the id from the trigger pipeline's session handle.
580
+ * @returns the cwd, or null.
581
+ */
582
+ function sessionCwd(sessionId) {
583
+ if (sessionsService === null || sessionId === undefined || sessionId === null) return null
584
+ try {
585
+ const cwd = sessionsService.list?.getSnapshot?.()?.byId?.[sessionId]?.cwd
586
+ return typeof cwd === 'string' && cwd !== '' ? cwd : null
587
+ } catch {
588
+ return null
589
+ }
590
+ }
591
+
592
+ /**
593
+ * The workspace a session belongs to, or null when it cannot be resolved. Used
594
+ * to order the `@` menu, never to decide whether it has anything to offer.
595
+ *
596
+ * @param session - the pipeline's `{ sessionId }` handle.
597
+ * @returns the host snapshot's workspace entry, or null.
598
+ */
599
+ function workspaceForSession(session) {
600
+ if (!hostState.known) return null
601
+ const cwd = sessionCwd(session?.sessionId)
602
+ if (cwd === null) return null
603
+ return hostState.workspaces.find((candidate) => samePath(candidate.path, cwd)) ?? null
604
+ }
605
+
606
+ /**
607
+ * Read back one candidate's opaque payload.
608
+ *
609
+ * @param value - the candidate's `value` string.
610
+ * @returns `{ label, path }`, or undefined when the payload is unusable.
611
+ */
612
+ function parseLabelValue(value) {
613
+ if (typeof value !== 'string') return undefined
614
+ try {
615
+ const parsed = JSON.parse(value)
616
+ if (parsed === null || typeof parsed !== 'object') return undefined
617
+ if (typeof parsed.label !== 'string' || typeof parsed.path !== 'string') return undefined
618
+ return { label: parsed.label, path: parsed.path }
619
+ } catch {
620
+ return undefined
621
+ }
622
+ }
623
+
624
+ /**
625
+ * Build the `@` source that offers registered directory labels.
626
+ *
627
+ * A pick inserts a reference chip whose `ref` is the directory's ABSOLUTE PATH,
628
+ * so the submitted prompt carries the path itself rather than a name the model
629
+ * must resolve — the label routing then holds even if the injected table was
630
+ * compacted away. The chip shows the label, and `appearance: 'folder'` draws
631
+ * the shell's folder glyph without marking the chip openable.
632
+ *
633
+ * Candidates come from EVERY enabled workspace, grouped by workspace title,
634
+ * with the calling session's workspace first when its cwd can be read. The
635
+ * session lookup is an ordering refinement, never a requirement: keying the
636
+ * menu on it meant one unresolved cwd turned the whole group into a silent
637
+ * empty list, which is unfalsifiable from the outside.
638
+ *
639
+ * @returns the trigger source.
640
+ */
641
+ function createLabelSource() {
642
+ return {
643
+ trigger: '@',
644
+ name: SOURCE_NAME,
645
+ // The pipeline's group title would be an untranslated key; each row
646
+ // carries its own section heading instead.
647
+ showGroupTitle: false,
648
+ // MUST be async: the pipeline calls `source.candidates(...).then(...)`, so
649
+ // a synchronous array return throws inside its per-hit fan-out.
650
+ async candidates(session, { query, signal }) {
651
+ if (signal !== undefined && signal.aborted) return []
652
+ const offered = hostState.workspaces.filter(
653
+ (workspace) => workspace.enabled !== false && Array.isArray(workspace.dirs) && workspace.dirs.length > 0,
654
+ )
655
+ if (offered.length === 0) return []
656
+ const cwd = sessionCwd(session?.sessionId)
657
+ const currentIndex =
658
+ cwd === null ? -1 : offered.findIndex((workspace) => samePath(workspace.path, cwd))
659
+ const ordered = currentIndex <= 0 ? offered : [offered[currentIndex], ...offered.filter((_, index) => index !== currentIndex)]
660
+ const needle = String(query ?? '').trim().toLowerCase()
661
+ const matches = (dir) =>
662
+ needle === '' ||
663
+ dir.label.toLowerCase().includes(needle) ||
664
+ (dir.note ?? '').toLowerCase().includes(needle) ||
665
+ dir.path.toLowerCase().includes(needle)
666
+ return ordered.flatMap((workspace) =>
667
+ workspace.dirs.filter(matches).map((dir) => ({
668
+ name: dir.label,
669
+ label: dir.label,
670
+ description: dir.note === undefined ? dir.path : dir.note + ' · ' + dir.path,
671
+ icon: 'folder',
672
+ section: workspace.title === undefined || workspace.title === '' ? workspace.path : workspace.title,
673
+ value: JSON.stringify({ label: dir.label, path: dir.path }),
674
+ })),
675
+ )
676
+ },
677
+ onPick({ candidate }) {
678
+ const value = parseLabelValue(candidate?.value)
679
+ if (value === undefined) return undefined
680
+ return {
681
+ insert: {
682
+ source: SOURCE_NAME,
683
+ ref: value.path,
684
+ label: value.label,
685
+ appearance: 'folder',
686
+ clipboardText: value.path,
687
+ },
688
+ }
689
+ },
690
+ codec: {
691
+ clipboardText: (ref) => ref,
692
+ // The model-facing form IS the absolute path.
693
+ serialize: (ref) => Promise.resolve(ref),
694
+ },
695
+ }
696
+ }
697
+
698
+ /**
699
+ * Whether the `@` source is currently registered, and how many labels it would
700
+ * offer. The settings page shows this so "the menu has no group" has a visible
701
+ * cause instead of being unfalsifiable.
702
+ */
703
+ let labelSourceRegistered = false
704
+
705
+ /**
706
+ * Summarize the `@` source for the settings page.
707
+ *
708
+ * @returns `{ registered, workspaces, labels }`.
709
+ */
710
+ function labelSourceStatus() {
711
+ const offered = hostState.workspaces.filter(
712
+ (workspace) => workspace.enabled !== false && Array.isArray(workspace.dirs) && workspace.dirs.length > 0,
713
+ )
714
+ return {
715
+ registered: labelSourceRegistered,
716
+ workspaces: offered.length,
717
+ labels: offered.reduce((total, workspace) => total + workspace.dirs.length, 0),
718
+ }
719
+ }
720
+
721
+ /**
722
+ * Pick the workspace the dialog shows: the requested id when it still exists,
723
+ * otherwise whatever was selected, otherwise the first one.
724
+ *
725
+ * @param workspaces - the host's workspace list.
726
+ * @param requestedId - the id the trigger asked for.
727
+ * @param currentPath - the path currently selected.
728
+ * @returns the resolved workspace path, or null.
729
+ */
730
+ function selectWorkspace(workspaces, requestedId, currentPath) {
731
+ const requested = requestedId === null || requestedId === undefined
732
+ ? undefined
733
+ : workspaces.find((workspace) => workspace.id === requestedId)
734
+ if (requested !== undefined) return requested.path
735
+ if (currentPath !== null && currentPath !== undefined && workspaces.some((workspace) => workspace.path === currentPath)) {
736
+ return currentPath
737
+ }
738
+ return workspaces[0]?.path ?? null
739
+ }
740
+
741
+ /**
742
+ * The shell's directory picker (`ctx.uiWorkspace`), captured while the plugin
743
+ * is mounted. It is injected optionally rather than declared in `inject`, so a
744
+ * composition without ui-workspace keeps the whole plugin working — the dialog
745
+ * simply loses its browse button.
746
+ */
747
+ let directoryPicker = null
748
+
749
+ /**
750
+ * The last path segment of a directory path, for either separator.
751
+ *
752
+ * @param path - any directory path.
753
+ * @returns the final segment, or an empty string.
754
+ */
755
+ function basenameOf(path) {
756
+ const parts = String(path).replace(/[\\/]+$/, '').split(/[\\/]/)
757
+ return parts[parts.length - 1] ?? ''
758
+ }
759
+
760
+ /**
761
+ * Suggest a label for a picked directory. The host's label grammar is
762
+ * `[A-Za-z0-9][A-Za-z0-9._-]{0,31}`, so anything else is folded to dashes and
763
+ * dropped when nothing valid survives (a CJK directory name yields no guess
764
+ * rather than a label the host would reject).
765
+ *
766
+ * @param path - the picked directory.
767
+ * @returns a valid label, or an empty string when nothing sensible survives.
768
+ */
769
+ function suggestLabel(path) {
770
+ const cleaned = basenameOf(path)
771
+ .replace(/[^A-Za-z0-9._-]+/g, '-')
772
+ .replace(/^[^A-Za-z0-9]+/, '')
773
+ .slice(0, 32)
774
+ return /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/.test(cleaned) ? cleaned : ''
775
+ }
776
+
777
+ /**
778
+ * Fold a picked directory into the draft: the path always follows the picker,
779
+ * and an empty label is filled from the directory's own name.
780
+ *
781
+ * @param draft - the current `{ label, path, note }`.
782
+ * @param pickedPath - the directory the picker returned.
783
+ * @returns the next draft.
784
+ */
785
+ function draftAfterPick(draft, pickedPath) {
786
+ return {
787
+ ...draft,
788
+ path: pickedPath,
789
+ label: draft.label.trim() === '' ? suggestLabel(pickedPath) : draft.label,
790
+ }
791
+ }
792
+
793
+ /**
794
+ * Message shown when the shell's picker fails. A cancelled pick resolves to
795
+ * `null` and never reaches here; this path is a real failure (no picker
796
+ * backend, or the browse flow erroring), so it must be visible.
797
+ *
798
+ * @param error - whatever the picker rejected with.
799
+ * @returns the user-facing message.
800
+ */
801
+ function pickErrorMessage(error) {
802
+ return '打开目录选择器失败:' + String((error && error.message) || error)
803
+ }
804
+
805
+ /**
806
+ * Whether a registered directory sits outside its workspace root. Such a
807
+ * directory is readable everywhere but only writable when the session's file
808
+ * policy is `danger-full-access`, so the dialog marks it instead of letting
809
+ * the user discover that from a denied write.
810
+ *
811
+ * @param workspacePath - the workspace root directory.
812
+ * @param dirPath - the registered directory.
813
+ * @returns true when the directory is outside the workspace root.
814
+ */
815
+ function isOutside(workspacePath, dirPath) {
816
+ const fold = (value) => String(value).replace(/[\\/]+$/, '').toLowerCase()
817
+ const root = fold(workspacePath)
818
+ const target = fold(dirPath)
819
+ return target !== root && !target.startsWith(root + '\\') && !target.startsWith(root + '/')
820
+ }
821
+
822
+ /**
823
+ * Why a directory outside the workspace root is called out at all. It is the
824
+ * tooltip's payload, not row copy: the row carries an icon and the sentence
825
+ * arrives on hover or keyboard focus.
826
+ */
827
+ const OUTSIDE_HINT =
828
+ '该目录在工作区根目录之外。DSH 沙箱只把工作区根目录当作可写根:会话权限不是 danger-full-access 时,' +
829
+ '这里只能读,写入需要逐次批准(agent 会按工具提示发起升级)。'
830
+
831
+ /**
832
+ * The warning badge of a directory outside its workspace root: the shell's
833
+ * shield in the theme's warn color, with the explanation on a hover/focus
834
+ * tooltip. It is focusable and labelled, so the warning is not hover-only.
835
+ *
836
+ * @returns the badge element, wrapped in a tooltip when the shell has one.
837
+ */
838
+ function OutsideBadge() {
839
+ const badge = React.createElement(
840
+ 'span',
841
+ {
842
+ className: 'dsh-workspace-plus-row-outside',
843
+ role: 'img',
844
+ tabIndex: 0,
845
+ 'aria-label': OUTSIDE_HINT,
846
+ },
847
+ React.createElement(
848
+ 'svg',
849
+ {
850
+ className: 'dsh-workspace-plus-glyph',
851
+ width: 16,
852
+ height: 16,
853
+ viewBox: '0 0 16 16',
854
+ fill: 'none',
855
+ xmlns: 'http://www.w3.org/2000/svg',
856
+ 'aria-hidden': 'true',
857
+ focusable: 'false',
858
+ },
859
+ React.createElement('path', {
860
+ d: SHIELD_PATH,
861
+ stroke: 'currentColor',
862
+ strokeWidth: 1,
863
+ strokeLinejoin: 'round',
864
+ }),
865
+ ),
866
+ )
867
+ if (Tooltip === null) return badge
868
+ // The bubble is portaled out of the dialog: the modal card clips its own
869
+ // overflow, and its stacking context would cap an in-flow bubble's z-index.
870
+ return React.createElement(
871
+ Tooltip,
872
+ { label: OUTSIDE_HINT, side: 'top', portal: true, maxWidth: 320, delayMs: 120 },
873
+ badge,
874
+ )
875
+ }
876
+
877
+ /**
878
+ * One registered directory row.
879
+ *
880
+ * @param props - the row's directory, its workspace root, and the remove handler.
881
+ * @returns the row element.
882
+ */
883
+ function DirectoryRow({ dir, workspacePath, onRemove, disabled }) {
884
+ const outside = isOutside(workspacePath, dir.path)
885
+ return React.createElement(
886
+ 'div',
887
+ { className: 'dsh-workspace-plus-row' },
888
+ React.createElement('span', { className: 'dsh-workspace-plus-row-tag' }, dir.label),
889
+ React.createElement('span', { className: 'dsh-workspace-plus-row-path', title: dir.path }, dir.path),
890
+ dir.note === undefined ? null : React.createElement('span', { className: 'dsh-workspace-plus-row-note' }, dir.note),
891
+ outside ? React.createElement(OutsideBadge) : null,
892
+ React.createElement(
893
+ Button,
894
+ { variant: 'outline', size: 'sm', disabled, onClick: () => onRemove(dir.label) },
895
+ '删除',
896
+ ),
897
+ )
898
+ }
899
+
900
+ /**
901
+ * The dialog entry registered into `shell.overlay`.
902
+ *
903
+ * `props.initial` and `props.dialog` exist only as a test seam — the shell
904
+ * renders overlay entries with its own standard props and no owner props.
905
+ *
906
+ * @param props - optional initial state for tests.
907
+ * @returns the dialog element (or null while closed).
908
+ */
909
+ function WorkdirsDialog(props) {
910
+ const dialog = React.useSyncExternalStore(subscribeDialog, readDialog)
911
+ const [state, setState] = React.useState(props?.initial ?? WORKDIRS_IDLE)
912
+ const [draft, setDraft] = React.useState({ label: '', path: '', note: '' })
913
+ const [picking, setPicking] = React.useState(false)
914
+
915
+ const patch = (changes) => setState((current) => ({ ...current, ...changes }))
916
+
917
+ /** Fold one successful host payload into the dialog state. */
918
+ const applyPayload = (payload, requestedId) => {
919
+ publishHostPayload(payload)
920
+ if (payload === null || typeof payload !== 'object' || payload.ok !== true) {
921
+ setState((current) => ({
922
+ ...current,
923
+ status: 'error',
924
+ busy: false,
925
+ ok: false,
926
+ message: (payload && payload.message) || 'host returned an unusable payload',
927
+ }))
928
+ return
929
+ }
930
+ const workspaces = Array.isArray(payload.workspaces) ? payload.workspaces : []
931
+ // The trigger names the workspace, so the dialog edits THAT one and shows
932
+ // no picker. Only a trigger without a workspace (the sidebar fallback)
933
+ // leaves the choice open.
934
+ const locked = requestedId === null || requestedId === undefined
935
+ ? null
936
+ : workspaces.find((workspace) => workspace.id === requestedId) ?? null
937
+ setState((current) => ({
938
+ ...current,
939
+ status: 'ready',
940
+ busy: false,
941
+ ok: null,
942
+ message: '',
943
+ workspaces,
944
+ registryAvailable: payload.registryAvailable !== false,
945
+ locked: locked !== null,
946
+ selected: locked !== null ? locked.path : selectWorkspace(workspaces, requestedId, current.selected),
947
+ }))
948
+ }
949
+
950
+ const load = async (requestedId) => {
951
+ patch({ busy: true })
952
+ try {
953
+ applyPayload(await fetchWorkspaces(), requestedId)
954
+ } catch (error) {
955
+ patch({ status: 'error', busy: false, ok: false, message: String((error && error.message) || error) })
956
+ }
957
+ }
958
+
959
+ const submit = async (payload) => {
960
+ patch({ busy: true })
961
+ try {
962
+ const result = await postOperation(payload)
963
+ if (result === null || typeof result !== 'object') {
964
+ patch({ busy: false, ok: false, message: 'host returned an unusable payload' })
965
+ return false
966
+ }
967
+ publishHostPayload(result)
968
+ const workspaces = Array.isArray(result.workspaces) ? result.workspaces : state.workspaces
969
+ setState((current) => ({
970
+ ...current,
971
+ status: 'ready',
972
+ busy: false,
973
+ ok: result.ok === true,
974
+ message: String(result.message ?? ''),
975
+ workspaces,
976
+ registryAvailable: result.registryAvailable !== false,
977
+ selected: selectWorkspace(workspaces, null, current.selected),
978
+ }))
979
+ return result.ok === true
980
+ } catch (error) {
981
+ patch({ status: 'error', busy: false, ok: false, message: String((error && error.message) || error) })
982
+ return false
983
+ }
984
+ }
985
+
986
+ // Refetch whenever the dialog opens, and whenever a trigger names a
987
+ // different workspace, so the table is never stale.
988
+ const open = dialog.open
989
+ const requestedId = dialog.workspaceId
990
+ React.useEffect(() => {
991
+ if (!open) return undefined
992
+ let cancelled = false
993
+ const controller = typeof AbortController === 'undefined' ? undefined : new AbortController()
994
+ ;(async () => {
995
+ try {
996
+ const payload = await fetchWorkspaces(controller?.signal)
997
+ if (cancelled) return
998
+ applyPayload(payload, requestedId)
999
+ } catch (error) {
1000
+ if (cancelled) return
1001
+ setState((current) => ({
1002
+ ...current,
1003
+ status: 'error',
1004
+ busy: false,
1005
+ ok: false,
1006
+ message: String((error && error.message) || error),
1007
+ }))
1008
+ }
1009
+ })()
1010
+ return () => {
1011
+ cancelled = true
1012
+ controller?.abort()
1013
+ }
1014
+ }, [open, requestedId])
1015
+
1016
+ const selected = state.workspaces.find((workspace) => workspace.path === state.selected) ?? null
1017
+ const dirs = selected === null ? [] : selected.dirs
1018
+ /** Without `ctx.uiWorkspace` there is no picker, and the path has no manual entry. */
1019
+ const pickerAvailable = directoryPicker !== null && directoryPicker !== undefined
1020
+
1021
+ const addDirectory = async () => {
1022
+ if (selected === null) return
1023
+ const added = await submit({
1024
+ op: 'add',
1025
+ workspace: selected.path,
1026
+ label: draft.label,
1027
+ path: draft.path,
1028
+ note: draft.note,
1029
+ })
1030
+ if (added) setDraft({ label: '', path: '', note: '' })
1031
+ }
1032
+
1033
+ const removeDirectory = async (label) => {
1034
+ if (selected === null) return
1035
+ await submit({ op: 'remove', workspace: selected.path, label })
1036
+ }
1037
+
1038
+ /**
1039
+ * Ask the shell's directory picker for a directory. A cancelled picker
1040
+ * resolves to `null` and changes nothing; a failed one reports the reason
1041
+ * where the add result goes, so the dialog never fails silently.
1042
+ */
1043
+ const browse = async () => {
1044
+ if (directoryPicker === null || directoryPicker === undefined) return
1045
+ setPicking(true)
1046
+ try {
1047
+ const picked = await directoryPicker.pickDirectory()
1048
+ if (typeof picked === 'string' && picked !== '') setDraft((current) => draftAfterPick(current, picked))
1049
+ } catch (error) {
1050
+ patch({ ok: false, message: pickErrorMessage(error) })
1051
+ } finally {
1052
+ setPicking(false)
1053
+ }
1054
+ }
1055
+
1056
+ const toolbar = React.createElement(
1057
+ 'div',
1058
+ { className: 'dsh-workspace-plus-toolbar' },
1059
+ React.createElement('span', { className: 'dsh-workspace-plus-label' }, '工作区'),
1060
+ state.locked
1061
+ ? React.createElement('span', { className: 'dsh-workspace-plus-locked' }, selected?.title || selected?.path || '')
1062
+ : React.createElement(
1063
+ 'select',
1064
+ {
1065
+ className: 'dsh-workspace-plus-select',
1066
+ value: state.selected ?? '',
1067
+ disabled: state.workspaces.length === 0,
1068
+ 'aria-label': '选择工作区',
1069
+ onChange: (event) => patch({ selected: event.target.value === '' ? null : event.target.value }),
1070
+ },
1071
+ state.workspaces.length === 0
1072
+ ? React.createElement('option', { value: '' }, '(没有可用工作区)')
1073
+ : state.workspaces.map((workspace) =>
1074
+ React.createElement(
1075
+ 'option',
1076
+ { key: workspace.id, value: workspace.path },
1077
+ (workspace.title || workspace.path) + ' — ' + workspace.path,
1078
+ ),
1079
+ ),
1080
+ ),
1081
+ state.locked
1082
+ ? React.createElement('span', { className: 'dsh-workspace-plus-note dsh-workspace-plus-mono' }, selected?.path ?? '')
1083
+ : null,
1084
+ )
1085
+
1086
+ const body = (() => {
1087
+ if (state.status === 'loading') {
1088
+ return React.createElement(
1089
+ 'div',
1090
+ { className: 'dsh-workspace-plus-list' },
1091
+ React.createElement('div', { className: 'dsh-workspace-plus-empty' }, '正在读取 host…'),
1092
+ )
1093
+ }
1094
+ if (state.status === 'error') {
1095
+ const unauthorized = state.message.includes('401')
1096
+ const unrouted = state.message.includes('404')
1097
+ /**
1098
+ * The three failures need different advice:
1099
+ * - 401: this document never completed the token exchange, so the
1100
+ * connection layer has no authority to admit the request.
1101
+ * - 404: the request reached the Host and nothing claimed the path.
1102
+ * The browser half hot-reloads from the served bundle, the host half
1103
+ * does not, so a stale Host process is the ordinary cause.
1104
+ * - anything else: the transport itself (a page with no HTTP carrier).
1105
+ */
1106
+ const diagnosis = unauthorized
1107
+ ? '这个页面没有带授权的会话凭据。请用 `dsh` 启动时打印的带 token 的地址重新打开 GUI(它会写入 cookie,然后跳转到干净地址)。'
1108
+ : unrouted
1109
+ ? '请求到达了 DSH 宿主进程,但宿主里没有注册 ' +
1110
+ API_PATH +
1111
+ ' 这条路由——通常意味着宿主进程还在跑旧版插件代码。浏览器半会随 bundle 热更新,宿主半不会:重启 dsh 即可。' +
1112
+ '重启后终端会出现 [dsh-workspace-plus] host half loaded 一行;若没有,请到 设置 → 插件 查看该插件行的状态。'
1113
+ : '这个面板通过 ' +
1114
+ API_PATH +
1115
+ ' 与宿主进程通信,而当前页面没有可用的 HTTP 通道(例如页面不是 dsh 提供的、或宿主进程还没起来)。'
1116
+ return React.createElement(
1117
+ 'div',
1118
+ { className: 'dsh-workspace-plus-list' },
1119
+ React.createElement(
1120
+ 'div',
1121
+ { className: 'dsh-workspace-plus-empty' },
1122
+ React.createElement('div', { className: 'dsh-workspace-plus-error' }, '无法连接 host 接口:' + state.message),
1123
+ React.createElement('div', { className: 'dsh-workspace-plus-note' }, diagnosis),
1124
+ React.createElement(
1125
+ 'div',
1126
+ { className: 'dsh-workspace-plus-note' },
1127
+ '宿主半加载之后,映射也可以在对话里用 workspace_dirs 工具管理。',
1128
+ ),
1129
+ ),
1130
+ )
1131
+ }
1132
+ if (!state.registryAvailable) {
1133
+ return React.createElement(
1134
+ 'div',
1135
+ { className: 'dsh-workspace-plus-list' },
1136
+ React.createElement(
1137
+ 'div',
1138
+ { className: 'dsh-workspace-plus-empty' },
1139
+ '宿主进程没有加载 workspace 注册表,无法列出工作区。请在对话里用 workspace_dirs 工具按绝对路径管理标签。',
1140
+ ),
1141
+ )
1142
+ }
1143
+ if (selected === null) {
1144
+ return React.createElement(
1145
+ 'div',
1146
+ { className: 'dsh-workspace-plus-list' },
1147
+ React.createElement('div', { className: 'dsh-workspace-plus-empty' }, '还没有任何工作区。先在左侧边栏新建一个工作区,再回到这里登记目录。'),
1148
+ )
1149
+ }
1150
+ return React.createElement(
1151
+ 'div',
1152
+ { className: 'dsh-workspace-plus-list' },
1153
+ dirs.length === 0
1154
+ ? React.createElement('div', { className: 'dsh-workspace-plus-empty' }, '这个工作区还没有登记额外目录。用下面的表单添加第一个标签。')
1155
+ : dirs.map((dir) =>
1156
+ React.createElement(DirectoryRow, {
1157
+ key: dir.label,
1158
+ dir,
1159
+ workspacePath: selected.path,
1160
+ disabled: state.busy,
1161
+ onRemove: (label) => void removeDirectory(label),
1162
+ }),
1163
+ ),
1164
+ )
1165
+ })()
1166
+
1167
+ /**
1168
+ * The path field is a control, not an input: there is no typing path into
1169
+ * the mapping, only the shell's directory picker. Clicking the field IS the
1170
+ * picker, the chosen directory is shown in it, and the label row below
1171
+ * collects the name that the conversation will use.
1172
+ */
1173
+ const pathField = React.createElement(
1174
+ 'button',
1175
+ {
1176
+ type: 'button',
1177
+ className: 'dsh-workspace-plus-path',
1178
+ disabled: selected === null || state.busy || picking || !pickerAvailable,
1179
+ 'aria-label': '选择目录',
1180
+ title: pickerAvailable
1181
+ ? '打开目录选择器'
1182
+ : '当前页面没有挂载 ui-workspace,目录选择器不可用;可在对话里用 workspace_dirs 登记',
1183
+ onClick: () => void browse(),
1184
+ },
1185
+ React.createElement(
1186
+ 'span',
1187
+ { className: 'dsh-workspace-plus-path-glyph' },
1188
+ React.createElement(FolderGlyph, { size: 16 }),
1189
+ ),
1190
+ React.createElement(
1191
+ 'span',
1192
+ { className: draft.path === '' ? 'dsh-workspace-plus-path-empty' : 'dsh-workspace-plus-row-path' },
1193
+ picking ? '选择中…' : draft.path === '' ? '选择目录…' : draft.path,
1194
+ ),
1195
+ )
1196
+
1197
+ const form = React.createElement(
1198
+ 'div',
1199
+ { className: 'dsh-workspace-plus-form' },
1200
+ pathField,
1201
+ React.createElement(
1202
+ 'div',
1203
+ { className: 'dsh-workspace-plus-form-row' },
1204
+ React.createElement('input', {
1205
+ className: 'dsh-workspace-plus-input dsh-workspace-plus-input-label',
1206
+ placeholder: '标签,如 backend',
1207
+ 'aria-label': '标签',
1208
+ value: draft.label,
1209
+ disabled: selected === null || state.busy,
1210
+ onChange: (event) => setDraft({ ...draft, label: event.target.value }),
1211
+ }),
1212
+ React.createElement('input', {
1213
+ className: 'dsh-workspace-plus-input dsh-workspace-plus-input-note',
1214
+ placeholder: '说明(可选)',
1215
+ 'aria-label': '说明',
1216
+ value: draft.note,
1217
+ disabled: selected === null || state.busy,
1218
+ onChange: (event) => setDraft({ ...draft, note: event.target.value }),
1219
+ }),
1220
+ React.createElement(
1221
+ Button,
1222
+ {
1223
+ variant: 'primary',
1224
+ className: 'dsh-workspace-plus-submit',
1225
+ disabled: selected === null || state.busy || draft.path === '',
1226
+ onClick: () => void addDirectory(),
1227
+ },
1228
+ '添加',
1229
+ ),
1230
+ ),
1231
+ )
1232
+
1233
+ const status = React.createElement(
1234
+ 'div',
1235
+ { className: 'dsh-workspace-plus-toolbar' },
1236
+ state.message === ''
1237
+ ? null
1238
+ : React.createElement(
1239
+ 'span',
1240
+ { className: state.ok === false ? 'dsh-workspace-plus-error' : 'dsh-workspace-plus-ok' },
1241
+ state.message,
1242
+ ),
1243
+ // The locked header already names the root; only the unlocked picker needs it repeated.
1244
+ state.locked || selected === null
1245
+ ? null
1246
+ : React.createElement(
1247
+ 'span',
1248
+ { className: 'dsh-workspace-plus-note' },
1249
+ '根目录:',
1250
+ React.createElement('span', { className: 'dsh-workspace-plus-mono' }, selected.path),
1251
+ ),
1252
+ )
1253
+
1254
+ return React.createElement(
1255
+ Modal,
1256
+ {
1257
+ open,
1258
+ onClose: closeWorkdirsDialog,
1259
+ title: '工作区目录设置',
1260
+ closeLabel: '关闭',
1261
+ // The card is wider than the shell's 380px default: a row carries a
1262
+ // label, an absolute path, a note and a badge, and the add form wants
1263
+ // two fields plus its action on one line.
1264
+ className: 'dsh-workspace-plus-dialog-card',
1265
+ footer: React.createElement(
1266
+ React.Fragment,
1267
+ null,
1268
+ React.createElement(
1269
+ Button,
1270
+ { variant: 'outline', disabled: state.busy, onClick: () => void load(requestedId) },
1271
+ '刷新',
1272
+ ),
1273
+ React.createElement(Button, { variant: 'primary', onClick: closeWorkdirsDialog }, '完成'),
1274
+ ),
1275
+ },
1276
+ React.createElement('div', { className: 'dsh-workspace-plus-dialog' }, toolbar, status, body, form),
1277
+ )
1278
+ }
1279
+
1280
+ /**
1281
+ * The `workspace+` settings page, registered into `settings.section`.
1282
+ *
1283
+ * It owns one preference per workspace: whether the feature runs there. That
1284
+ * single switch covers all three surfaces at once — the workspace row button,
1285
+ * the label table injected into that workspace's conversations, and the
1286
+ * `workspace_dirs` tool.
1287
+ *
1288
+ * Every workspace defaults to on except the default (first-use) one, which
1289
+ * belongs to the product rather than to this plugin and therefore starts off.
1290
+ * A switch writes an explicit override for that workspace; nothing is deleted
1291
+ * when it is turned off, so a workspace can be switched back on at any time
1292
+ * without losing the labels registered in it.
1293
+ *
1294
+ * @param props - optional initial state for tests.
1295
+ * @returns the settings section element.
1296
+ */
1297
+ function WorkspacePlusSettings(props) {
1298
+ const host = React.useSyncExternalStore(subscribeHostState, readHostState)
1299
+ const [state, setState] = React.useState(props?.initial ?? { status: 'loading', message: '', ok: null, busy: false })
1300
+
1301
+ const patch = (changes) => setState((current) => ({ ...current, ...changes }))
1302
+
1303
+ React.useEffect(() => {
1304
+ let cancelled = false
1305
+ const controller = typeof AbortController === 'undefined' ? undefined : new AbortController()
1306
+ ;(async () => {
1307
+ try {
1308
+ const payload = await fetchWorkspaces(controller?.signal)
1309
+ if (cancelled) return
1310
+ if (!publishHostPayload(payload)) {
1311
+ setState((current) => ({
1312
+ ...current,
1313
+ status: 'error',
1314
+ message: (payload && payload.message) || 'host returned an unusable payload',
1315
+ }))
1316
+ return
1317
+ }
1318
+ setState((current) => ({ ...current, status: 'ready', message: '' }))
1319
+ } catch (error) {
1320
+ if (cancelled) return
1321
+ setState((current) => ({
1322
+ ...current,
1323
+ status: 'error',
1324
+ message: String((error && error.message) || error),
1325
+ }))
1326
+ }
1327
+ })()
1328
+ return () => {
1329
+ cancelled = true
1330
+ controller?.abort()
1331
+ }
1332
+ }, [])
1333
+
1334
+ /**
1335
+ * Flip one workspace's switch. The key is the one the host published for
1336
+ * that workspace, so the two halves cannot disagree about path spelling.
1337
+ *
1338
+ * @param key - the workspace's settings key.
1339
+ * @param next - the requested state.
1340
+ */
1341
+ const toggle = async (key, next) => {
1342
+ patch({ busy: true })
1343
+ try {
1344
+ const result = await postOperation({
1345
+ op: 'settings',
1346
+ settings: { [WORKSPACE_ENABLED_SETTING]: { [key]: next } },
1347
+ })
1348
+ if (result === null || typeof result !== 'object') {
1349
+ patch({ busy: false, ok: false, message: 'host returned an unusable payload' })
1350
+ return
1351
+ }
1352
+ publishHostPayload(result)
1353
+ patch({ busy: false, ok: result.ok === true, message: String(result.message ?? '') })
1354
+ } catch (error) {
1355
+ patch({ busy: false, ok: false, message: String((error && error.message) || error) })
1356
+ }
1357
+ }
1358
+
1359
+ const unavailable = state.status === 'error'
1360
+
1361
+ /**
1362
+ * A workspace can only be switched from the key the host published for it.
1363
+ * Guessing one from the path is not an option: the host folds case on
1364
+ * Windows, so a guessed key would be accepted, stored, and then never match
1365
+ * — a silent no-op. A payload without keys means a stale host half, which
1366
+ * the page reports instead.
1367
+ */
1368
+ const keyOf = (workspace) => (typeof workspace.key === 'string' && workspace.key !== '' ? workspace.key : null)
1369
+ const staleHost = host.known && host.workspaces.some((workspace) => keyOf(workspace) === null)
1370
+
1371
+ const workspaceRows = host.workspaces.map((workspace) => {
1372
+ const key = keyOf(workspace)
1373
+ return React.createElement(
1374
+ 'div',
1375
+ { key: workspace.id, className: 'dsh-workspace-plus-settings-row' },
1376
+ React.createElement(
1377
+ 'div',
1378
+ { className: 'dsh-workspace-plus-settings-text' },
1379
+ React.createElement(
1380
+ 'div',
1381
+ { className: 'dsh-workspace-plus-settings-title' },
1382
+ workspace.title || workspace.path,
1383
+ workspace.isDefault === true
1384
+ ? React.createElement('span', { className: 'dsh-workspace-plus-settings-tag' }, '默认工作区')
1385
+ : null,
1386
+ ),
1387
+ React.createElement(
1388
+ 'div',
1389
+ { className: 'dsh-workspace-plus-settings-description' },
1390
+ React.createElement('span', { className: 'dsh-workspace-plus-mono' }, workspace.path),
1391
+ workspace.isDefault === true
1392
+ ? '(默认关闭,打开后它的对话才会看到标签表)'
1393
+ : null,
1394
+ ),
1395
+ ),
1396
+ React.createElement(Switch, {
1397
+ checked: workspace.enabled !== false,
1398
+ disabled: state.busy || unavailable || key === null,
1399
+ label: (workspace.title || workspace.path) + ' 启用 workspace+',
1400
+ onChange: (next) => void toggle(key, next),
1401
+ }),
1402
+ )
1403
+ })
1404
+
1405
+ const workspaceList = (() => {
1406
+ if (state.status === 'loading' && host.workspaces.length === 0) {
1407
+ return React.createElement('div', { className: 'dsh-workspace-plus-settings-description' }, '正在读取工作区…')
1408
+ }
1409
+ if (host.workspaces.length === 0) {
1410
+ return React.createElement(
1411
+ 'div',
1412
+ { className: 'dsh-workspace-plus-settings-description' },
1413
+ '没有可用的工作区。在左侧边栏新建一个工作区后,这里会出现它自己的开关。',
1414
+ )
1415
+ }
1416
+ return workspaceRows
1417
+ })()
1418
+
1419
+ const status = (() => {
1420
+ if (staleHost) {
1421
+ return React.createElement(
1422
+ 'div',
1423
+ { className: 'dsh-workspace-plus-settings-error' },
1424
+ '宿主返回的工作区数据里没有 key,无法按工作区切换 —— 通常是宿主进程还在跑旧版插件代码。重启 dsh 即可。',
1425
+ )
1426
+ }
1427
+ // A failed READ needs the transport diagnosis; a rejected WRITE only needs
1428
+ // the host's own message.
1429
+ if (state.status === 'error') {
1430
+ const unrouted = state.message.includes('404')
1431
+ return React.createElement(
1432
+ 'div',
1433
+ { className: 'dsh-workspace-plus-settings-error' },
1434
+ '无法读取宿主设置:' + state.message,
1435
+ unrouted
1436
+ ? '(宿主里没有 ' + API_PATH + ' 这条路由,通常意味着 dsh 还没重启)'
1437
+ : '',
1438
+ )
1439
+ }
1440
+ if (state.message === '') return null
1441
+ return React.createElement(
1442
+ 'div',
1443
+ { className: state.ok === false ? 'dsh-workspace-plus-settings-error' : 'dsh-workspace-plus-settings-ok' },
1444
+ state.message,
1445
+ )
1446
+ })()
1447
+
1448
+ const sourceStatus = labelSourceStatus()
1449
+ const atLine = React.createElement(
1450
+ 'div',
1451
+ { className: 'dsh-workspace-plus-settings-description' },
1452
+ '输入框 `@` 候选:',
1453
+ sourceStatus.registered
1454
+ ? sourceStatus.labels === 0
1455
+ ? '候选源已注册,但还没有可用的标签 —— 先在工作区行里登记目录。'
1456
+ : '候选源已注册,共 ' +
1457
+ String(sourceStatus.labels) +
1458
+ ' 个标签(分布在 ' +
1459
+ String(sourceStatus.workspaces) +
1460
+ ' 个工作区)。'
1461
+ : '候选源未注册 —— 当前页面可能还是旧的插件 bundle,重启 dsh 并强制刷新页面。',
1462
+ )
1463
+
1464
+ return React.createElement(
1465
+ 'div',
1466
+ { className: 'dsh-workspace-plus-settings' },
1467
+ React.createElement(
1468
+ 'div',
1469
+ { className: 'dsh-workspace-plus-settings-description' },
1470
+ '按工作区启用:开关控制这个工作区的行内目录按钮、注入它对话上下文的标签表,以及 ',
1471
+ React.createElement('span', { className: 'dsh-workspace-plus-mono' }, 'workspace_dirs'),
1472
+ ' 工具。关掉不会删除已登记的标签。',
1473
+ ),
1474
+ workspaceList,
1475
+ atLine,
1476
+ status,
1477
+ React.createElement(
1478
+ 'div',
1479
+ { className: 'dsh-workspace-plus-settings-description' },
1480
+ '标签本身在每个工作区的「工作区目录设置」弹窗里维护:悬停左侧工作区那一行的目录按钮即可打开。',
1481
+ ),
1482
+ )
1483
+ }
1484
+
1485
+ /**
1486
+ * The `main` panel that holds the fallback entry's key. The shell's sidebar
1487
+ * button always calls `layout.selectPanel(id)` — which throws for an
1488
+ * unregistered key — so this key must exist even though the intended path is
1489
+ * the dialog.
1490
+ *
1491
+ * @returns the hint panel element.
1492
+ */
1493
+ function WorkdirsHintPanel() {
1494
+ return React.createElement(
1495
+ 'div',
1496
+ { className: 'dsh-workspace-plus-panel' },
1497
+ React.createElement(
1498
+ 'div',
1499
+ { className: 'dsh-workspace-plus-card' },
1500
+ React.createElement(
1501
+ 'div',
1502
+ { className: 'dsh-workspace-plus-badge' },
1503
+ React.createElement(WorkdirsPanelIcon, { size: 28 }),
1504
+ ),
1505
+ React.createElement('h1', { className: 'dsh-workspace-plus-title' }, '工作区目录设置'),
1506
+ React.createElement(
1507
+ 'p',
1508
+ { className: 'dsh-workspace-plus-subtitle' },
1509
+ '这个功能以弹窗形式提供:点击侧边栏工作区那一行的目录按钮即可打开。',
1510
+ ),
1511
+ React.createElement(Button, { variant: 'primary', onClick: () => openWorkdirsDialog(null) }, '现在打开'),
1512
+ ),
1513
+ )
1514
+ }
1515
+
1516
+ /**
1517
+ * Put the directory button in its seat inside a row's action cluster, and
1518
+ * keep it there across React re-renders.
1519
+ *
1520
+ * The shell renders that cluster as `[「更多」menu, 「新会话」button]` and this
1521
+ * plugin's button belongs between them, so the row reads
1522
+ * 「更多 · 工作区目录设置 · 新会话」and the new-session button stays the last
1523
+ * one. Inserting BEFORE the cluster's last element child is what achieves
1524
+ * that; `insertBefore(node, null)` appends, so a cluster with a single child
1525
+ * degrades to "after the menu" instead of splitting a pair that is not there.
1526
+ *
1527
+ * React re-renders the row without knowing this node exists, and it may
1528
+ * re-insert its own children around ours — so the position is re-asserted on
1529
+ * every reconcile pass rather than set once at creation.
1530
+ *
1531
+ * @param actions - the row's action cluster.
1532
+ * @param button - this plugin's button.
1533
+ */
1534
+ function seatRowButton(actions, button) {
1535
+ const last = actions.lastElementChild
1536
+ if (last === button) return
1537
+ const anchor = actions.children.length > 1 ? last : null
1538
+ actions.insertBefore(button, anchor)
1539
+ }
1540
+
1541
+ /**
1542
+ * Insert the directory button into every workspace row and keep it there.
1543
+ *
1544
+ * The shell's workspace row menu is hardcoded to Rename/Delete and exposes no
1545
+ * slot, so the trigger is injected into the row's action cluster instead. The
1546
+ * anchors are shell-emitted data attributes rather than hashed class names:
1547
+ * `data-slot="sidebar.workspaces"` on the browser outlet and
1548
+ * `data-row-key="workspace:<workspaceId>"` on a group row (that id is
1549
+ * `Workspace.id` from the host registry).
1550
+ *
1551
+ * A MutationObserver reconciles additions, removals, and position, because
1552
+ * React re-renders the row without knowing this node exists.
1553
+ *
1554
+ * A workspace whose `enabled` flag is false (the default Workspace before the
1555
+ * user turns its switch on) gets no button, and loses one if it is disabled
1556
+ * while mounted — `refresh()` re-runs the reconcile after a settings change.
1557
+ *
1558
+ * @param options - the availability callback, fired when the injected count changes.
1559
+ * @returns `{ dispose, available, refresh }`.
1560
+ */
1561
+ function installWorkspaceRowButtons({ onAvailability } = {}) {
1562
+ if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') {
1563
+ return { dispose: () => {}, available: () => false, refresh: () => {} }
1564
+ }
1565
+ const injected = new Map()
1566
+ let scheduled = false
1567
+ let stopped = false
1568
+ let lastReported = null
1569
+
1570
+ const report = () => {
1571
+ const available = injected.size > 0
1572
+ if (available === lastReported) return
1573
+ lastReported = available
1574
+ if (typeof onAvailability === 'function') onAvailability(available)
1575
+ }
1576
+
1577
+ const reconcile = () => {
1578
+ scheduled = false
1579
+ if (stopped) return
1580
+ const container = document.querySelector(SIDEBAR_SLOT_SELECTOR)
1581
+ // Nothing is mounted yet: reporting here would flash the fallback entry.
1582
+ if (container === null) return
1583
+ const rows = container.querySelectorAll(WORKSPACE_ROW_SELECTOR)
1584
+ const live = new Set(rows)
1585
+ for (const [row, button] of [...injected]) {
1586
+ const workspaceId = (row.getAttribute('data-row-key') ?? '').slice(ROW_KEY_PREFIX.length)
1587
+ const stillWanted = live.has(row) && row.isConnected && workspaceId !== '' && isEnabledWorkspace(workspaceId)
1588
+ if (stillWanted) continue
1589
+ button.remove()
1590
+ injected.delete(row)
1591
+ }
1592
+ for (const row of rows) {
1593
+ const workspaceId = (row.getAttribute('data-row-key') ?? '').slice(ROW_KEY_PREFIX.length)
1594
+ // The synthetic "ungrouped" row carries the empty key and owns no workspace.
1595
+ if (workspaceId === '') continue
1596
+ // A switched-off workspace gets no entry point at all.
1597
+ if (!isEnabledWorkspace(workspaceId)) continue
1598
+ const actions = row.lastElementChild
1599
+ if (actions === null || actions === undefined) continue
1600
+ const existing = injected.get(row)
1601
+ if (existing !== undefined && existing.isConnected) {
1602
+ // Already there: only its seat may have moved.
1603
+ seatRowButton(actions, existing)
1604
+ continue
1605
+ }
1606
+ const button = document.createElement('button')
1607
+ button.type = 'button'
1608
+ button.className = ROW_BUTTON_CLASS
1609
+ button.title = ROW_BUTTON_LABEL
1610
+ button.setAttribute('aria-label', ROW_BUTTON_LABEL)
1611
+ button.innerHTML = ROW_BUTTON_GLYPH
1612
+ button.addEventListener('click', (event) => {
1613
+ // Keep the click off the row, whose own handler toggles the group.
1614
+ event.preventDefault()
1615
+ event.stopPropagation()
1616
+ openWorkdirsDialog(workspaceId)
1617
+ })
1618
+ seatRowButton(actions, button)
1619
+ injected.set(row, button)
1620
+ }
1621
+ report()
1622
+ }
1623
+
1624
+ const schedule = () => {
1625
+ if (scheduled || stopped) return
1626
+ scheduled = true
1627
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(reconcile)
1628
+ else setTimeout(reconcile, 16)
1629
+ }
1630
+
1631
+ const observer = new MutationObserver(schedule)
1632
+ observer.observe(document.body, { childList: true, subtree: true })
1633
+ reconcile()
1634
+
1635
+ return {
1636
+ available: () => injected.size > 0,
1637
+ refresh: () => {
1638
+ if (stopped) return
1639
+ reconcile()
1640
+ },
1641
+ dispose: () => {
1642
+ stopped = true
1643
+ observer.disconnect()
1644
+ for (const button of injected.values()) button.remove()
1645
+ injected.clear()
1646
+ },
1647
+ }
1648
+ }
1649
+
1650
+ // -----------------------------------------------------------------------
1651
+ // Plugin
1652
+ // -----------------------------------------------------------------------
1653
+
1654
+ /**
1655
+ * Services this browser plugin needs. `slots` is required to contribute
1656
+ * anything at all; the sidebar buttons and the panel selection are owned by
1657
+ * the shell, so this half never touches `ctx.layout` itself.
1658
+ */
1659
+ const inject = ['slots']
1660
+
1661
+ /**
1662
+ * Contribute the `workspace+` settings page, the directory dialog, its
1663
+ * fallback entry, and the workspace-row buttons that open it.
1664
+ *
1665
+ * Every registration goes through `ctx.slots.inject(key, callback)`: the
1666
+ * callback runs as soon as the shell declares that slot (immediately when it
1667
+ * already exists), and its returned disposer is owned by this plugin's fiber,
1668
+ * so unloading the plugin removes the entry again.
1669
+ *
1670
+ * @param ctx - the browser plugin context.
1671
+ */
1672
+ function apply(ctx) {
1673
+ // The settings page owns the default-Workspace switch.
1674
+ ctx.effect(
1675
+ () =>
1676
+ ctx.slots.inject('settings.section', () =>
1677
+ ctx.slots.register(
1678
+ {
1679
+ name: 'settings.section',
1680
+ id: SETTINGS_SECTION_ID,
1681
+ order: SETTINGS_SECTION_ORDER,
1682
+ label: SETTINGS_SECTION_LABEL,
1683
+ },
1684
+ WorkspacePlusSettings,
1685
+ ),
1686
+ ),
1687
+ 'dsh-workspace-plus: settings section',
1688
+ )
1689
+
1690
+ // The dialog lives in the frame-wide overlay: the same seat the shell's own
1691
+ // workspace Rename/Delete dialogs occupy.
1692
+ ctx.effect(
1693
+ () =>
1694
+ ctx.slots.inject('shell.overlay', () =>
1695
+ ctx.slots.register({ name: 'shell.overlay', id: 'workdirs-dialog', order: 50 }, WorkdirsDialog),
1696
+ ),
1697
+ 'dsh-workspace-plus: workdirs dialog',
1698
+ )
1699
+
1700
+ // The fallback entry's main key always exists; the entry itself is
1701
+ // registered only while no workspace-row button could be installed.
1702
+ ctx.effect(
1703
+ () =>
1704
+ ctx.slots.inject('main', () =>
1705
+ ctx.slots.register({ name: 'main', key: WORKDIRS_PANEL_ID }, WorkdirsHintPanel),
1706
+ ),
1707
+ 'dsh-workspace-plus: workdirs fallback panel',
1708
+ )
1709
+
1710
+ // The dialog's browse button asks the shell's own picker. Injected
1711
+ // optionally: a composition without ui-workspace still loads this plugin,
1712
+ // the button just stays disabled.
1713
+ ctx.effect(
1714
+ () =>
1715
+ ctx.inject(['uiWorkspace'], (uiScope) => {
1716
+ directoryPicker = uiScope.uiWorkspace
1717
+ return () => {
1718
+ directoryPicker = null
1719
+ }
1720
+ }),
1721
+ 'dsh-workspace-plus: directory picker',
1722
+ )
1723
+
1724
+ // The `@` source and the session list it keys off. Both are injected
1725
+ // optionally: without them the composer simply offers no label group.
1726
+ ctx.inject(['sessions'], (sessionScope) => {
1727
+ sessionScope.effect(
1728
+ () => {
1729
+ sessionsService = sessionScope.sessions
1730
+ return () => {
1731
+ sessionsService = null
1732
+ }
1733
+ },
1734
+ 'dsh-workspace-plus: session list',
1735
+ )
1736
+ })
1737
+
1738
+ ctx.inject(['inputTriggers'], (triggerScope) => {
1739
+ triggerScope.effect(
1740
+ () => {
1741
+ const dispose = triggerScope.inputTriggers.registerSource(createLabelSource())
1742
+ labelSourceRegistered = true
1743
+ return () => {
1744
+ labelSourceRegistered = false
1745
+ dispose()
1746
+ }
1747
+ },
1748
+ 'dsh-workspace-plus: @ label source',
1749
+ )
1750
+ })
1751
+
1752
+ let registerEntry = null
1753
+ let entryDispose = null
1754
+ let rowEntryAvailable = false
1755
+ let settled = false
1756
+
1757
+ const syncEntry = () => {
1758
+ if (registerEntry === null) return
1759
+ const wantFallback = !rowEntryAvailable && settled
1760
+ if (wantFallback && entryDispose === null) entryDispose = registerEntry()
1761
+ if (!wantFallback && entryDispose !== null) {
1762
+ entryDispose()
1763
+ entryDispose = null
1764
+ }
1765
+ }
1766
+
1767
+ ctx.effect(
1768
+ () =>
1769
+ ctx.slots.inject('sidebar.panellist', () => {
1770
+ registerEntry = () =>
1771
+ ctx.slots.register(
1772
+ {
1773
+ name: 'sidebar.panellist',
1774
+ id: WORKDIRS_PANEL_ID,
1775
+ order: WORKDIRS_PANEL_ORDER,
1776
+ label: WORKDIRS_PANEL_LABEL,
1777
+ },
1778
+ WorkdirsEntryIcon,
1779
+ )
1780
+ syncEntry()
1781
+ return () => {
1782
+ registerEntry = null
1783
+ if (entryDispose !== null) {
1784
+ entryDispose()
1785
+ entryDispose = null
1786
+ }
1787
+ }
1788
+ }),
1789
+ 'dsh-workspace-plus: workdirs fallback entry',
1790
+ )
1791
+
1792
+ const settleTimer = setTimeout(() => {
1793
+ settled = true
1794
+ syncEntry()
1795
+ }, FALLBACK_SETTLE_MS)
1796
+
1797
+ const rowButtons = installWorkspaceRowButtons({
1798
+ onAvailability: (available) => {
1799
+ rowEntryAvailable = available
1800
+ settled = true
1801
+ syncEntry()
1802
+ },
1803
+ })
1804
+
1805
+ // Warm the shared snapshot at boot and keep the row button in step with the
1806
+ // settings: a switch flipped in the settings page adds or removes it without
1807
+ // a reload.
1808
+ const offHostState = subscribeHostState(() => rowButtons.refresh())
1809
+ void refreshHostState()
1810
+
1811
+ ctx.effect(
1812
+ () => () => {
1813
+ clearTimeout(settleTimer)
1814
+ offHostState()
1815
+ rowButtons.dispose()
1816
+ },
1817
+ 'dsh-workspace-plus: workspace row buttons',
1818
+ )
1819
+ }
1820
+
1821
+ exports.WORKDIRS_PANEL_ID = WORKDIRS_PANEL_ID
1822
+ exports.WORKDIRS_PANEL_LABEL = WORKDIRS_PANEL_LABEL
1823
+ exports.SETTINGS_SECTION_ID = SETTINGS_SECTION_ID
1824
+ exports.SETTINGS_SECTION_LABEL = SETTINGS_SECTION_LABEL
1825
+ exports.SETTINGS_SECTION_ORDER = SETTINGS_SECTION_ORDER
1826
+ exports.DEFAULT_WORKSPACE_SETTING = DEFAULT_WORKSPACE_SETTING
1827
+ exports.WORKSPACE_ENABLED_SETTING = WORKSPACE_ENABLED_SETTING
1828
+ exports.API_PATH = API_PATH
1829
+ exports.ROW_BUTTON_CLASS = ROW_BUTTON_CLASS
1830
+ exports.ROW_BUTTON_LABEL = ROW_BUTTON_LABEL
1831
+ exports.SIDEBAR_SLOT_SELECTOR = SIDEBAR_SLOT_SELECTOR
1832
+ exports.WORKSPACE_ROW_SELECTOR = WORKSPACE_ROW_SELECTOR
1833
+ exports.FALLBACK_SETTLE_MS = FALLBACK_SETTLE_MS
1834
+ exports.readDialog = readDialog
1835
+ exports.subscribeDialog = subscribeDialog
1836
+ exports.openWorkdirsDialog = openWorkdirsDialog
1837
+ exports.closeWorkdirsDialog = closeWorkdirsDialog
1838
+ exports.readHostState = readHostState
1839
+ exports.subscribeHostState = subscribeHostState
1840
+ exports.publishHostPayload = publishHostPayload
1841
+ exports.isEnabledWorkspace = isEnabledWorkspace
1842
+ exports.SOURCE_NAME = SOURCE_NAME
1843
+ exports.SOURCE_SECTION = SOURCE_SECTION
1844
+ exports.samePath = samePath
1845
+ exports.sessionCwd = sessionCwd
1846
+ exports.workspaceForSession = workspaceForSession
1847
+ exports.parseLabelValue = parseLabelValue
1848
+ exports.createLabelSource = createLabelSource
1849
+ exports.labelSourceStatus = labelSourceStatus
1850
+ exports.selectWorkspace = selectWorkspace
1851
+ exports.isOutside = isOutside
1852
+ exports.basenameOf = basenameOf
1853
+ exports.suggestLabel = suggestLabel
1854
+ exports.draftAfterPick = draftAfterPick
1855
+ exports.pickErrorMessage = pickErrorMessage
1856
+ exports.installWorkspaceRowButtons = installWorkspaceRowButtons
1857
+ exports.seatRowButton = seatRowButton
1858
+ exports.WorkdirsPanelIcon = WorkdirsPanelIcon
1859
+ exports.WorkdirsEntryIcon = WorkdirsEntryIcon
1860
+ exports.WorkdirsDialog = WorkdirsDialog
1861
+ exports.WorkdirsHintPanel = WorkdirsHintPanel
1862
+ exports.WorkspacePlusSettings = WorkspacePlusSettings
1863
+ exports.apply = apply
1864
+ exports.inject = inject
1865
+ return module.exports
1866
+ },
1867
+ })