dsh-codespace-workspace 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js ADDED
@@ -0,0 +1,3648 @@
1
+ /**
2
+ * dsh-codespace-workspace — client half.
3
+ *
4
+ * Self-contained lazy-CJS bundle: `window.__ModuleLoader__.load({ id, factory })`
5
+ * with a factory that requires only `react`. No build step, no JSX, no
6
+ * TypeScript, and no other DSH package — `@deepseek-ai/dsh-client-ui-primitives`
7
+ * is deliberately NOT required (a throwing import blanks the whole slot entry);
8
+ * the markup and CSS it would have supplied are copied here and styled with
9
+ * `--dsw-alias-*` tokens.
10
+ *
11
+ * Two surfaces, both registered through `ctx.slots`:
12
+ * 1. `settings.section` — the **Codespace** page (id `dsh-codespace-workspace`, order 70).
13
+ * 2. `shell.overlay` — the single multi-step create window and the delete confirmation.
14
+ *
15
+ * Plus one DOM augmentation module, because the sidebar's own controls have no
16
+ * slot at all (see docs/CONTRACT.md §2, "no workspace-row slot"). It injects:
17
+ * - the new-cloud-workspace launcher, as a peer of the host's icons in the
18
+ * workspace section header, immediately left of 添加工作区;
19
+ * - the cloud marker and the start/stop button on each managed workspace row;
20
+ * - the hover card's location line and the row menu's 删除 Codespace… item.
21
+ * It follows the discipline `dsh-pet` uses for the hardcoded settings-nav icon —
22
+ * one coalesced `MutationObserver`, inject only, never remove or replace a
23
+ * React-owned node, tag everything `data-dsh-codespace-*`, undo fully on dispose.
24
+ * The same module holds the store's owner claim, so the 4 s `workspaces` poll
25
+ * lives exactly as long as the workspace section is on screen.
26
+ *
27
+ * Wire contract literals are repeated from `lib/shared.js` (the bundle cannot
28
+ * import it). Keep the two in sync.
29
+ *
30
+ * @module dsh-codespace-workspace/client
31
+ */
32
+
33
+ window.__ModuleLoader__.load({
34
+ id: 'dsh-codespace-workspace',
35
+ factory: (require) => {
36
+ const module = { exports: {} }
37
+ const React = require('react')
38
+ const h = React.createElement
39
+
40
+ /* ------------------------------------------------------------------ *
41
+ * 1. Wire contract — mirror of lib/shared.js
42
+ * ------------------------------------------------------------------ */
43
+
44
+ /** Package name; also the client module-loader id and the settings namespace. */
45
+ const PLUGIN_ID = 'dsh-codespace-workspace'
46
+
47
+ /** Exact host route the browser posts RPC calls to. */
48
+ const RPC_ROUTE = '/api/dsh-codespace-workspace/rpc'
49
+
50
+ /** Every RPC action name (mirror of `ACTIONS` in lib/shared.js). */
51
+ const ACTIONS = {
52
+ STATUS: 'status',
53
+ GET_SETTINGS: 'get-settings',
54
+ SET_SETTINGS: 'set-settings',
55
+ LIST_REPOS: 'list-repos',
56
+ LIST_CODESPACES: 'list-codespaces',
57
+ MACHINES: 'machines',
58
+ PREBUILD: 'prebuild',
59
+ CREATE: 'create',
60
+ START: 'start',
61
+ STOP: 'stop',
62
+ REMOVE: 'remove',
63
+ PAUSE_CANCEL: 'pause-cancel',
64
+ PAUSE_NOW: 'pause-now',
65
+ WORKSPACES: 'workspaces',
66
+ CREATE_WORKSPACE: 'create-workspace',
67
+ REMOVE_WORKSPACE: 'remove-workspace',
68
+ }
69
+
70
+ /** Default settings values (mirror of `SETTINGS_DEFAULTS` in lib/shared.js). */
71
+ const SETTINGS_DEFAULTS = {
72
+ githubUsername: '',
73
+ defaultBranch: 'main',
74
+ autoPauseMinutes: 30,
75
+ sshKeyPath: '',
76
+ alwaysShowCloudButton: false,
77
+ autoInitEmptyRepo: true,
78
+ readmeContent: '',
79
+ }
80
+
81
+ /** The setting that is never rendered back to the user, only marked as set. */
82
+ const SECRET_KEYS = ['sshKeyPath', 'githubToken']
83
+
84
+ /** Sidebar/workspace poll cadence, per docs/CONTRACT.md §6. */
85
+ const POLL_MS = 4000
86
+
87
+ /**
88
+ * How long a single click on a running row button waits for a second one.
89
+ *
90
+ * A running Codespace's button means two things — one click pauses it, two
91
+ * clicks pause it NOW — so the single-click action is deferred by this much
92
+ * and cancelled if a `dblclick` arrives first. 320 ms is short enough to
93
+ * feel immediate and long enough for a real double-click.
94
+ */
95
+ const CLICK_WINDOW_MS = 320
96
+
97
+ /** Locale namespace for every string this bundle renders. */
98
+ const LOCALE_NS = 'codespace'
99
+
100
+ /* States whose normalized phase gates the row button. Mirrors
101
+ * `codespacePhase()` in lib/shared.js.
102
+ * VERIFY: the exact GitHub state strings were not exercised against the live
103
+ * API in this build (docs/CONTRACT.md §3 flags the same gap). The mapping is
104
+ * deliberately tolerant: unknown states normalize to `pending`, which
105
+ * disables the control instead of offering a start the API would reject. */
106
+ const RUNNING_STATES = ['available']
107
+ const STOPPED_STATES = ['shutdown', 'shut_down', 'archived', 'stopped']
108
+ const PENDING_STATES = [
109
+ 'created', 'queued', 'provisioning', 'starting', 'shuttingdown', 'shutting_down',
110
+ 'exporting', 'updating', 'rebuilding', 'pending',
111
+ ]
112
+ const ERROR_STATES = ['failed', 'unavailable', 'deleted', 'moved', 'unknown']
113
+
114
+ /**
115
+ * Normalize a raw Codespace state onto a UI phase.
116
+ * @param {unknown} state - raw `state` field.
117
+ * @returns {'running'|'stopped'|'pending'|'error'} the phase.
118
+ */
119
+ function codespacePhase(state) {
120
+ if (typeof state !== 'string') return 'pending'
121
+ const key = state.trim().toLowerCase().replace(/[\s-]+/g, '_')
122
+ if (RUNNING_STATES.indexOf(key) >= 0) return 'running'
123
+ if (STOPPED_STATES.indexOf(key) >= 0) return 'stopped'
124
+ if (PENDING_STATES.indexOf(key) >= 0) return 'pending'
125
+ if (ERROR_STATES.indexOf(key) >= 0) return 'error'
126
+ return 'pending'
127
+ }
128
+
129
+ /** Patterns that must never reach the screen. Mirror of shared.js `redact()`. */
130
+ const SECRET_PATTERNS = [
131
+ /\bgh[pousr]_[A-Za-z0-9]{16,}\b/g,
132
+ /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
133
+ /\bBearer\s+[A-Za-z0-9._-]{20,}/gi,
134
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
135
+ ]
136
+
137
+ /**
138
+ * Strip anything credential-shaped out of a string before it is rendered.
139
+ * The host already redacts; this is belt-and-braces for messages that never
140
+ * passed through it (transport errors, exceptions thrown in this bundle).
141
+ * @param {string} text - candidate text.
142
+ * @returns {string} the same text with credential-shaped runs replaced.
143
+ */
144
+ function redact(text) {
145
+ let out = text
146
+ for (const pattern of SECRET_PATTERNS) out = out.replace(pattern, '[redacted]')
147
+ return out
148
+ }
149
+
150
+ /* ------------------------------------------------------------------ *
151
+ * 2. Locale dictionaries (also written to locale/zh.json, locale/en.json)
152
+ * ------------------------------------------------------------------ */
153
+
154
+ /** Simplified Chinese dictionary — the key-set source of truth. */
155
+ const MESSAGES_ZH = {
156
+ nav: 'Codespace',
157
+ 'page.title': 'Codespace',
158
+ 'page.subtitle': '把 GitHub Codespace 当作 DSH 工作区:文件与命令工具在云端 Codespace 里运行。',
159
+
160
+ 'status.heading': '状态',
161
+ 'status.refresh': '刷新',
162
+ 'status.checking': '检查中…',
163
+ 'status.gh': 'GitHub CLI (gh)',
164
+ 'status.gh.installed': '已安装 · {version}',
165
+ 'status.gh.missing': '未安装',
166
+ 'status.gh.missingNote': '没有检测到 GitHub CLI。这不影响使用:插件会改用你在下面配置的 SSH 连接方式(推荐填 ~/.ssh/config 里的主机别名,例如 codespace-mybox)。安装 gh 后可以获得更完整的能力。',
167
+ 'status.token': 'GitHub 令牌',
168
+ 'status.token.settings': '来自插件设置',
169
+ 'status.token.env': '来自环境变量 GITHUB_TOKEN',
170
+ 'status.token.gh': '来自 gh auth token',
171
+ 'status.token.none': '未找到',
172
+ 'status.token.noneNote': '未找到令牌:仓库列表与 Codespace 生命周期操作不可用。',
173
+ 'status.login': 'GitHub 登录',
174
+ 'status.loggedOut': '未登录',
175
+ 'status.configured': '配置',
176
+ 'status.configured.yes': '已配置',
177
+ 'status.configured.no': '未配置',
178
+ 'status.failed': '状态读取失败:{message}',
179
+
180
+ 'settings.heading': '设置',
181
+ 'field.githubUsername': 'GitHub 用户名',
182
+ 'field.githubUsername.hint': '留空表示使用令牌所属的账号。',
183
+ 'field.defaultBranch': '默认分支',
184
+ 'field.autoPauseMinutes': '自动暂停等待时间(分钟)',
185
+ 'field.autoPauseMinutes.hint': '一个回合结束后开始计时,期间收到新消息会取消。填 0 表示不自动暂停。双击工作区行上的时钟图标可以立即暂停。',
186
+ 'field.sshKeyPath': 'SSH 密钥(可选)',
187
+ 'field.sshKeyPath.hint': '没有 gh 时用它连接 Codespace。填 ~/.ssh/config 里的主机别名,例如 codespace-mybox(私钥请在 Host 条目里用 IdentityFile 指定——只填密钥路径无法确定要连接哪台主机)。',
188
+ 'field.sshKeyPath.set': '已设置',
189
+ 'field.sshKeyPath.unset': '未设置',
190
+ 'field.sshKeyPath.write': '输入新的值(保存后不再显示)',
191
+ 'field.sshKeyPath.save': '保存',
192
+ 'field.sshKeyPath.clear': '清除',
193
+ 'field.alwaysShowCloudButton': '始终显示云端工作区按钮',
194
+ 'field.alwaysShowCloudButton.hint': '关闭时,按钮只在鼠标悬停该工作区行时出现。开启后该行的整组操作按钮会常驻显示(CSS 无法只显示其中的一个子元素)。',
195
+ 'field.autoInitEmptyRepo': '自动初始化空仓库',
196
+ 'field.autoInitEmptyRepo.hint': '仓库没有任何提交时,先提交一个 README 再创建 Codespace。',
197
+ 'field.readmeContent': 'README 初始内容',
198
+ 'field.readmeContent.hint': '留空则提交一个空的 README.md。',
199
+ 'save': '保存',
200
+ 'saving': '保存中…',
201
+ 'saved': '已保存',
202
+ 'saveFailed': '保存失败:{message}',
203
+
204
+ 'managed.heading': '云端工作区',
205
+ 'managed.empty': '还没有由本插件管理的 Codespace 工作区。',
206
+ 'managed.loading': '正在读取…',
207
+ 'managed.new': '新建云端工作区',
208
+ 'managed.delete': '删除 Codespace…',
209
+ 'managed.deleteFallbackNote': '侧边栏工作区行的「⋯」菜单里也有「删除 Codespace…」。如果那里没有出现,可以用这里的按钮。',
210
+
211
+ 'window.title': '新建云端工作区',
212
+ 'step.repo': '选择仓库',
213
+ 'step.codespace': '选择 Codespace',
214
+ 'step.machine': '选择机型',
215
+ 'step.apply': '创建',
216
+ 'step.done': '完成',
217
+ 'repo.search': '搜索仓库',
218
+ 'repo.loading': '正在读取仓库…',
219
+ 'repo.empty': '没有匹配的仓库。',
220
+ 'repo.failed': '仓库读取失败:{message}',
221
+ 'repo.private': '私有',
222
+ 'repo.isEmpty': '空仓库',
223
+ 'repo.count': '共 {n} 个',
224
+ 'cs.heading': '该仓库已有 Codespace',
225
+ 'cs.pick': '选择一个 Codespace',
226
+ 'cs.none': '该仓库还没有 Codespace,将新建一个。',
227
+ 'cs.loading': '正在读取 Codespace…',
228
+ 'cs.failed': 'Codespace 读取失败:{message}',
229
+ 'cs.state': '状态:{state}',
230
+ 'cs.createNew': '新建一个 Codespace',
231
+ 'prebuild.checking': '正在检查预构建…',
232
+ 'prebuild.missing': '未配置预构建,创建可能较慢',
233
+ 'prebuild.unknown': '无法确认预构建状态,创建可能需要 1–3 分钟',
234
+ 'prebuild.cancel': '取消',
235
+ 'prebuild.proceed': '直接创建',
236
+ 'prebuild.ok': '该仓库已配置预构建。',
237
+ 'machine.heading': '选择机型',
238
+ 'machine.loading': '正在读取机型…',
239
+ 'machine.failed': '机型读取失败:{message}',
240
+ 'machine.empty': '该仓库没有可用的机型。',
241
+ 'machine.default': '默认最小',
242
+ 'machine.cpus': '{n} 核',
243
+ 'machine.memory': '{n} GB 内存',
244
+ 'machine.storage': '{n} GB 存储',
245
+ 'machine.gpu': 'GPU',
246
+ 'seed.note': '仓库为空:将先提交 README.md,再创建 Codespace。',
247
+ 'seed.note.off': '仓库为空,但「自动初始化空仓库」已关闭,创建可能失败。',
248
+ 'apply.creating': '正在创建 Codespace…',
249
+ 'apply.creatingHint': '创建通常需要 1–3 分钟。',
250
+ 'apply.starting': '正在启动 Codespace…',
251
+ 'apply.registering': '正在登记 DSH 工作区…',
252
+ 'apply.failed': '操作失败:{message}',
253
+ 'retry': '重试',
254
+ 'back': '上一步',
255
+ 'next': '下一步',
256
+ 'cancel': '取消',
257
+ 'close': '关闭',
258
+ 'success.title': 'Codespace 已就绪',
259
+ 'success.body': 'DSH 工作区已登记,会话的工作目录是云端 Codespace。',
260
+ 'success.open': '在此打开会话',
261
+ 'success.opening': '正在打开…',
262
+ 'success.openFailed': '侧边栏里还没有这一行,请展开侧边栏后点击该工作区右侧的「新建会话」。',
263
+ 'success.done': '完成',
264
+
265
+ 'row.cloud': '云端 Codespace 工作区',
266
+ 'row.hoverRepo': '{repo} · {name}',
267
+ 'row.start': '启动 Codespace',
268
+ 'row.stop': '暂停 Codespace',
269
+ 'row.countdown': '还剩 {n} 分钟自动暂停',
270
+ 'row.countdownSoon': '即将自动暂停',
271
+ 'row.pending': '正在切换状态…',
272
+ 'row.error': '状态异常:{state}',
273
+ 'row.actionFailed': '操作失败:{message}',
274
+
275
+ 'menu.delete': '删除 Codespace…',
276
+ 'delete.title': '删除 Codespace',
277
+ 'delete.body': '将永久删除云端 Codespace「{name}」。其中未推送的改动会一起丢失,且无法恢复。DSH 里保留的工作区记录只包含一个空的占位目录。',
278
+ 'delete.confirm': '永久删除',
279
+ 'delete.working': '正在删除…',
280
+ 'delete.failed': '删除失败:{message}',
281
+
282
+ 'launcher.label': '新建云端工作区',
283
+ }
284
+
285
+ /** English dictionary, complete against the zh key set. */
286
+ const MESSAGES_EN = {
287
+ nav: 'Codespace',
288
+ 'page.title': 'Codespace',
289
+ 'page.subtitle': 'Use a GitHub Codespace as a DSH workspace: file and command tools run inside the cloud Codespace.',
290
+
291
+ 'status.heading': 'Status',
292
+ 'status.refresh': 'Refresh',
293
+ 'status.checking': 'Checking…',
294
+ 'status.gh': 'GitHub CLI (gh)',
295
+ 'status.gh.installed': 'installed · {version}',
296
+ 'status.gh.missing': 'not installed',
297
+ 'status.gh.missingNote': 'The GitHub CLI was not found. That does not block the plugin: it connects to the Codespace through the SSH setting below instead (a host alias from ~/.ssh/config, such as codespace-mybox, is the recommended value). Installing gh enables the fuller capability set.',
298
+ 'status.token': 'GitHub token',
299
+ 'status.token.settings': 'from plugin settings',
300
+ 'status.token.env': 'from the GITHUB_TOKEN environment variable',
301
+ 'status.token.gh': 'from gh auth token',
302
+ 'status.token.none': 'not found',
303
+ 'status.token.noneNote': 'No token found: the repository list and the Codespace lifecycle actions are unavailable.',
304
+ 'status.login': 'GitHub login',
305
+ 'status.loggedOut': 'not signed in',
306
+ 'status.configured': 'Configuration',
307
+ 'status.configured.yes': 'configured',
308
+ 'status.configured.no': 'not configured',
309
+ 'status.failed': 'Could not read the status: {message}',
310
+
311
+ 'settings.heading': 'Settings',
312
+ 'field.githubUsername': 'GitHub username',
313
+ 'field.githubUsername.hint': 'Leave empty to use the account the token belongs to.',
314
+ 'field.defaultBranch': 'Default branch',
315
+ 'field.autoPauseMinutes': 'Auto-pause delay (minutes)',
316
+ 'field.autoPauseMinutes.hint': 'Counts down after a turn ends; a new message cancels it. 0 disables auto-pause. Double-click the clock on a workspace row to pause immediately.',
317
+ 'field.sshKeyPath': 'SSH key (optional)',
318
+ 'field.sshKeyPath.hint': 'Used to reach the Codespace when gh is unavailable. Put a host alias from ~/.ssh/config here (such as codespace-mybox); name the private key with IdentityFile in that Host entry — a key path alone does not say which host to reach.',
319
+ 'field.sshKeyPath.set': 'Set',
320
+ 'field.sshKeyPath.unset': 'Not set',
321
+ 'field.sshKeyPath.write': 'Enter a new value (never shown again)',
322
+ 'field.sshKeyPath.save': 'Save',
323
+ 'field.sshKeyPath.clear': 'Clear',
324
+ 'field.alwaysShowCloudButton': 'Always show the cloud workspace button',
325
+ 'field.alwaysShowCloudButton.hint': 'When off, the button appears only while the pointer is over that workspace row. When on, the row’s whole action cluster stays visible (CSS cannot reveal one child of a hidden container).',
326
+ 'field.autoInitEmptyRepo': 'Initialize empty repositories',
327
+ 'field.autoInitEmptyRepo.hint': 'Commit a README first when the repository has no commits.',
328
+ 'field.readmeContent': 'Initial README content',
329
+ 'field.readmeContent.hint': 'Leave empty to commit an empty README.md.',
330
+ 'save': 'Save',
331
+ 'saving': 'Saving…',
332
+ 'saved': 'Saved',
333
+ 'saveFailed': 'Save failed: {message}',
334
+
335
+ 'managed.heading': 'Cloud workspaces',
336
+ 'managed.empty': 'No Codespace workspace is managed by this plugin yet.',
337
+ 'managed.loading': 'Loading…',
338
+ 'managed.new': 'New cloud workspace',
339
+ 'managed.delete': 'Delete Codespace…',
340
+ 'managed.deleteFallbackNote': 'The workspace row’s “⋯” menu also offers “Delete Codespace…”. If it does not appear there, use the button here.',
341
+
342
+ 'window.title': 'New cloud workspace',
343
+ 'step.repo': 'Repository',
344
+ 'step.codespace': 'Codespace',
345
+ 'step.machine': 'Machine',
346
+ 'step.apply': 'Create',
347
+ 'step.done': 'Done',
348
+ 'repo.search': 'Search repositories',
349
+ 'repo.loading': 'Loading repositories…',
350
+ 'repo.empty': 'No repository matches.',
351
+ 'repo.failed': 'Could not load repositories: {message}',
352
+ 'repo.private': 'private',
353
+ 'repo.isEmpty': 'empty',
354
+ 'repo.count': '{n} total',
355
+ 'cs.heading': 'This repository already has Codespaces',
356
+ 'cs.pick': 'Pick one',
357
+ 'cs.none': 'This repository has no Codespace yet; one will be created.',
358
+ 'cs.loading': 'Loading Codespaces…',
359
+ 'cs.failed': 'Could not load Codespaces: {message}',
360
+ 'cs.state': 'state: {state}',
361
+ 'cs.createNew': 'Create a new Codespace',
362
+ 'prebuild.checking': 'Checking for a prebuild…',
363
+ 'prebuild.missing': 'No prebuild configured — creating may be slow',
364
+ 'prebuild.unknown': 'Prebuild status could not be read — creating may take 1–3 minutes',
365
+ 'prebuild.cancel': 'Cancel',
366
+ 'prebuild.proceed': 'Create anyway',
367
+ 'prebuild.ok': 'This repository has a prebuild configured.',
368
+ 'machine.heading': 'Machine type',
369
+ 'machine.loading': 'Loading machine types…',
370
+ 'machine.failed': 'Could not load machine types: {message}',
371
+ 'machine.empty': 'No machine type is available for this repository.',
372
+ 'machine.default': 'smallest default',
373
+ 'machine.cpus': '{n} CPUs',
374
+ 'machine.memory': '{n} GB RAM',
375
+ 'machine.storage': '{n} GB storage',
376
+ 'machine.gpu': 'GPU',
377
+ 'seed.note': 'The repository is empty: README.md is committed first, then the Codespace is created.',
378
+ 'seed.note.off': 'The repository is empty and “Initialize empty repositories” is off, so creation may fail.',
379
+ 'apply.creating': 'Creating the Codespace…',
380
+ 'apply.creatingHint': 'Creation usually takes 1–3 minutes.',
381
+ 'apply.starting': 'Starting the Codespace…',
382
+ 'apply.registering': 'Registering the DSH workspace…',
383
+ 'apply.failed': 'The operation failed: {message}',
384
+ 'retry': 'Retry',
385
+ 'back': 'Back',
386
+ 'next': 'Next',
387
+ 'cancel': 'Cancel',
388
+ 'close': 'Close',
389
+ 'success.title': 'The Codespace is ready',
390
+ 'success.body': 'The DSH workspace is registered; the session works inside the cloud Codespace.',
391
+ 'success.open': 'Open a session here',
392
+ 'success.opening': 'Opening…',
393
+ 'success.openFailed': 'That row is not in the sidebar yet. Expand the sidebar and use the “New session” button on the workspace row.',
394
+ 'success.done': 'Done',
395
+
396
+ 'row.cloud': 'Cloud Codespace workspace',
397
+ 'row.hoverRepo': '{repo} · {name}',
398
+ 'row.start': 'Start Codespace',
399
+ 'row.stop': 'Pause Codespace',
400
+ 'row.countdown': 'Auto-pause in {n} min',
401
+ 'row.countdownSoon': 'Auto-pausing soon',
402
+ 'row.pending': 'Changing state…',
403
+ 'row.error': 'Unhealthy state: {state}',
404
+ 'row.actionFailed': 'The action failed: {message}',
405
+
406
+ 'menu.delete': 'Delete Codespace…',
407
+ 'delete.title': 'Delete Codespace',
408
+ 'delete.body': 'The cloud Codespace “{name}” is deleted permanently. Unpushed work inside it is lost and cannot be recovered. The DSH workspace record stays behind as an empty placeholder directory.',
409
+ 'delete.confirm': 'Delete permanently',
410
+ 'delete.working': 'Deleting…',
411
+ 'delete.failed': 'Delete failed: {message}',
412
+
413
+ 'launcher.label': 'New cloud workspace',
414
+ }
415
+
416
+ /** Replace `{name}` placeholders. */
417
+ function interpolate(template, params) {
418
+ if (params === undefined || params === null) return template
419
+ return String(template).replace(/\{(\w+)\}/g, (match, name) => (name in params ? String(params[name]) : match))
420
+ }
421
+
422
+ /** Translator used before (and without) the locale service. */
423
+ function fallbackT(key, params) {
424
+ return interpolate(MESSAGES_ZH[key] !== undefined ? MESSAGES_ZH[key] : key, params)
425
+ }
426
+
427
+ /* ------------------------------------------------------------------ *
428
+ * 3. Styles — one <style> element, injected inside ctx.effect.
429
+ *
430
+ * Only `--dsw-alias-*` / `--dsw-radius-*` / `--dsw-focus-ring-*` tokens are
431
+ * referenced; every token carries a fallback so a renamed token degrades the
432
+ * appearance instead of breaking it. Literal colors appear nowhere.
433
+ * ------------------------------------------------------------------ */
434
+
435
+ /**
436
+ * Class-name suffixes of the host's hashed CSS modules.
437
+ *
438
+ * VERIFY: the hashed prefix is per build (currently `hIlkoa_` for the rows
439
+ * module and `_9lTDKa_` for the workspace browser), so every lookup matches
440
+ * the *suffix* through an attribute-contains selector. If a future build
441
+ * renames a suffix, the matching surface is skipped (with a single console
442
+ * warning) and the rest of the plugin keeps working — the workspace row and
443
+ * the section header are both the host's own DOM and are never required for
444
+ * anything else here.
445
+ *
446
+ * Declared before `CSS` because the stylesheet is built from them.
447
+ */
448
+ const ROW_ACTIONS_SUFFIX = '_rowActions'
449
+ const MENU_OPEN_SUFFIX = '_menuOpen'
450
+ const HOVER_PATH_SUFFIX = '_hoverPath'
451
+ const SECTION_HEADER_SUFFIX = '_sectionHeader'
452
+ const HEADER_ACTIONS_SUFFIX = '_headerActions'
453
+ const HEADER_HIDDEN_SUFFIX = '_headerActionsHidden'
454
+ const RAIL_SUFFIX = '_rail'
455
+
456
+ /**
457
+ * The cloud glyph, drawn to the same spec as the host's own icons.
458
+ *
459
+ * The controls this sits among are `Icon*OutlineRegular` from
460
+ * `@deepseek-ai/dsh-client-ui-primitives`, and their artwork has a fixed
461
+ * shape: `<svg width height viewBox="0 0 16 16" fill="none" aria-hidden
462
+ * stroke-width={1}>` with `stroke="currentColor"` on each stroked path. The
463
+ * `Regular` weight is **1**; the host's `Medium` weight is 1.3
464
+ * (`ICON_MEDIUM_STROKE`).
465
+ *
466
+ * This glyph used to declare `stroke-width: 1.3` on the path itself, which
467
+ * made it visibly heavier than the "添加工作区" button beside it — that is
468
+ * the mismatch this constant fixes. Keep the stroke width here (one place,
469
+ * consumed by both the React and the DOM renderer) rather than on a path.
470
+ *
471
+ * The primitives set has no cloud artwork (there is no `Cloud` symbol in its
472
+ * bundle), so the path is ours; it is drawn for a 1px stroke on the 16x16
473
+ * grid, so the two renderers cannot drift apart.
474
+ */
475
+ const CLOUD_PATH = 'M11.667 12.667H6a4.667 4.667 0 1 1 4.473-6h1.193a3 3 0 1 1 0 6Z'
476
+ const ICON_STROKE_WIDTH = '1'
477
+
478
+ /**
479
+ * The row button's four states, using the host's own artwork.
480
+ *
481
+ * Emoji used to stand in here (`⏸️`/`🕐`/`⚠️`/`▶️`) and it looked wrong for a
482
+ * concrete reason: an emoji is a **colour glyph** rendered by the platform
483
+ * font at its own optical size and weight, so it sat among
484
+ * `Icon*OutlineRegular` neighbours — 16px, 1px `currentColor` strokes that
485
+ * inherit `--dsw-alias-label-tertiary` — as a heavier, full-colour square that
486
+ * ignored the theme entirely and rendered differently on every OS.
487
+ *
488
+ * These are copied VERBATIM from `@deepseek-ai/dsh-client-ui-primitives` so
489
+ * the mark is pixel-identical to the host's own; only the choice of glyph is
490
+ * ours. Each entry keeps its artwork's own `viewBox` (the alarm clock really
491
+ * is drawn on a 17x17 grid) and its own line-cap settings.
492
+ *
493
+ * - `stop` -> `IconPauseOutlineRegular` (auto-pause / stop now)
494
+ * - `countdown` -> `IconAlarmClockOutlineRegular` (the same alarm the settings
495
+ * page names)
496
+ * - `error` -> `IconWarningTriangleOutlineRegular`
497
+ * - `start` -> `IconPlayOutlineRegular`
498
+ */
499
+ const ACTION_ICONS = {
500
+ stop: {
501
+ viewBox: '0 0 16 16',
502
+ paths: [
503
+ 'M8 14.5C11.5899 14.5 14.5 11.5899 14.5 8C14.5 4.41015 11.5899 1.5 8 1.5C4.41015 1.5 1.5 4.41015 1.5 8C1.5 11.5899 4.41015 14.5 8 14.5Z',
504
+ 'M6.5 5V11',
505
+ 'M9.5 5V11',
506
+ ],
507
+ },
508
+ countdown: {
509
+ viewBox: '0 0 17 17',
510
+ paths: [
511
+ 'M4.09372 11.9895L3.11865 14.0387',
512
+ 'M12.1392 11.9895L13.1143 14.0387',
513
+ 'M8.11646 4.78442V8.03442L10.6165 9.53442',
514
+ 'M8.11646 13.4094C11.154 13.4094 13.6165 10.947 13.6165 7.90942C13.6165 4.87186 11.154 2.40942 8.11646 2.40942C5.07889 2.40942 2.61646 4.87186 2.61646 7.90942C2.61646 10.947 5.07889 13.4094 8.11646 13.4094Z',
515
+ 'M1.75952 4.74323C2.30657 3.65639 3.12646 2.73047 4.12926 2.05542',
516
+ 'M14.3345 4.74323C13.7874 3.65639 12.9675 2.73047 11.9647 2.05542',
517
+ ],
518
+ },
519
+ error: {
520
+ viewBox: '0 0 16 16',
521
+ linecap: 'round',
522
+ linejoin: 'round',
523
+ paths: [
524
+ 'M6.87 2.6a1.33 1.33 0 0 1 2.26 0l5.34 9.33A1.33 1.33 0 0 1 13.33 14H2.67a1.33 1.33 0 0 1-1.14-2.07Z',
525
+ 'M8 6v3m0 2.33h.01',
526
+ ],
527
+ },
528
+ start: {
529
+ viewBox: '0 0 16 16',
530
+ paths: [
531
+ 'M8 14.5C11.5899 14.5 14.5 11.5899 14.5 8C14.5 4.41015 11.5899 1.5 8 1.5C4.41015 1.5 1.5 4.41015 1.5 8C1.5 11.5899 4.41015 14.5 8 14.5Z',
532
+ 'M10.3329 7.91346C10.3996 7.95195 10.3996 8.04818 10.3329 8.08667L6.78304 10.1362C6.71638 10.1747 6.63304 10.1266 6.63304 10.0496L6.63304 5.95055C6.63304 5.87357 6.71638 5.82546 6.78304 5.86395L10.3329 7.91346Z',
533
+ ],
534
+ },
535
+ /**
536
+ * The window's close control. Same rule as the four lifecycle states: this
537
+ * was a `✕` text glyph standing in for an icon, and the host's own is
538
+ * `IconCloseOutlineRegular`.
539
+ */
540
+ close: {
541
+ viewBox: '0 0 16 16',
542
+ paths: ['M2.5 2.5L13.5 13.5', 'M13.5 2.5L2.5 13.5'],
543
+ },
544
+ /**
545
+ * The transitional spinner, `IconLoadingOutlineRegular` verbatim: one round
546
+ * stroke arc. Deliberately an icon and not a CSS border ring — a ring with
547
+ * `border-top-color:transparent` is the shape that reads as a square once
548
+ * the corners are squared off, and it does not match the rest of the set.
549
+ */
550
+ loading: {
551
+ viewBox: '0 0 16 16',
552
+ paths: [
553
+ 'M12.596 12.596C11.687 13.5049 10.5288 14.1239 9.26798 14.3747C8.00716 14.6255 6.70028 14.4968 5.51261 14.0048C4.32494 13.5129 3.30981 12.6798 2.59557 11.611C1.88134 10.5421 1.50008 9.2855 1.5 7.99998C1.50008 6.71446 1.88134 5.45783 2.59557 4.38898C3.30981 3.32013 4.32494 2.48707 5.51261 1.99513C6.70028 1.50319 8.00716 1.37447 9.26798 1.62524C10.5288 1.87602 11.687 2.49502 12.596 3.40398',
554
+ ],
555
+ },
556
+ }
557
+
558
+ /** Hover delay before a tooltip appears — the host's own header controls use 500ms. */
559
+ const TOOLTIP_DELAY_MS = 500
560
+
561
+ /**
562
+ * The plus strokes of the host's `IconProjectAddOutlineRegular`, verbatim.
563
+ *
564
+ * Copied rather than redrawn so the "add" mark is pixel-identical to the one
565
+ * on the host's own "+" button. It occupies x 9.76..14.55, y 2.11..6.90.
566
+ */
567
+ const PLUS_PATHS = [
568
+ 'M9.75977 4.50208H14.5509',
569
+ 'M12.1492 6.89758L12.1492 2.10642',
570
+ ]
571
+
572
+ /**
573
+ * The "add" cloud: the SAME full-size outline, broken where the plus crosses.
574
+ *
575
+ * This is how the host's own `IconProjectAddOutlineRegular` composes its two
576
+ * glyphs — the folder keeps its full size and its outline is simply
577
+ * interrupted around the plus — so the mark reads as one icon rather than as a
578
+ * shape with a badge floating beside it. An earlier revision instead shrank
579
+ * the cloud into the lower-left to dodge the plus; this gets the same result
580
+ * without giving up any size.
581
+ *
582
+ * The break is made with an SVG `<mask>`: a white 16x16 rect, then each plus
583
+ * stroke painted black at {@link CLOUD_MASK_BAND} width, and the cloud drawn
584
+ * through that mask. Cutting the outline by hand would mean re-deriving the
585
+ * path at two intersections — the plus's vertical stroke crosses the cloud's
586
+ * right lobe at `y = 9.667 - sqrt(3^2 - (12.1492-11.666)^2) = 6.706`, and its
587
+ * horizontal stroke's *band* also clips the big arc near `x = 9.76` — and
588
+ * keeping those numbers in step with the artwork forever. The mask expresses
589
+ * the intent directly and cannot drift.
590
+ *
591
+ * The band is the gap width: it is painted with `stroke-linecap="butt"` so it
592
+ * stops exactly at each stroke's ends. Round caps would extend half a band
593
+ * past the plus's lower tip and could bite a detached sliver out of the lobe.
594
+ */
595
+ const CLOUD_MASK_BAND = '2.6'
596
+
597
+ /**
598
+ * Source of unique mask ids.
599
+ *
600
+ * Ids are document-global, so two `CloudIcon`s on one page must not share one:
601
+ * the browser resolves `url(#id)` to the first match, which would silently
602
+ * make the second icon use the first one's mask.
603
+ */
604
+ let cloudMaskSeq = 0
605
+
606
+ const CSS = [
607
+ /* ---- shared page chrome ---- */
608
+ '.dcw-root{font-size:13px;line-height:1.6;color:var(--dsw-alias-label-primary);max-width:880px}',
609
+ '.dcw-root *{box-sizing:border-box}',
610
+ '.dcw-title{font-size:20px;font-weight:600;line-height:1.3;margin:0}',
611
+ '.dcw-sub{font-size:12px;color:var(--dsw-alias-label-secondary);margin:4px 0 0}',
612
+ '.dcw-card{border:1px solid var(--dsw-alias-border-l2);border-radius:12px;padding:16px;margin-top:12px;display:flex;flex-direction:column;gap:12px;background:var(--dsw-alias-settings-card-fill,transparent)}',
613
+ '.dcw-card-title{font-size:15px;font-weight:600;line-height:1.4;margin:0}',
614
+ '.dcw-row{display:flex;align-items:center;justify-content:space-between;gap:12px}',
615
+ '.dcw-row-start{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}',
616
+ '.dcw-stack{display:flex;flex-direction:column;gap:4px;min-width:0;flex:1}',
617
+ '.dcw-label{font-size:13px;font-weight:500;line-height:1.5}',
618
+ '.dcw-muted{color:var(--dsw-alias-label-secondary);font-size:12px}',
619
+ '.dcw-mono{font-family:var(--dsw-font-mono,ui-monospace,Consolas,monospace);font-size:12px;overflow-wrap:anywhere}',
620
+ '.dcw-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
621
+ '.dcw-divider{height:1px;background:var(--dsw-alias-border-l2);margin:4px 0}',
622
+
623
+ /* ---- controls ---- */
624
+ '.dcw-button{display:inline-flex;align-items:center;justify-content:center;gap:8px;height:32px;padding:0 14px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:transparent;color:inherit;font:inherit;font-size:13px;cursor:pointer;white-space:nowrap}',
625
+ '.dcw-button:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
626
+ '.dcw-button:disabled{cursor:default;opacity:.45}',
627
+ '.dcw-button[data-variant="primary"]{background:var(--dsw-alias-button-primary-fill);border-color:transparent;color:var(--dsw-alias-label-primary-inverted);font-weight:600}',
628
+ '.dcw-button[data-variant="primary"]:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover)}',
629
+ '.dcw-button[data-variant="danger"]{color:var(--dsw-alias-state-error-primary);border-color:transparent;padding:0 10px}',
630
+ '.dcw-button[data-variant="danger"]:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger)}',
631
+ '.dcw-button[data-variant="ghost"]{border-color:transparent;padding:0 8px;color:var(--dsw-alias-label-secondary)}',
632
+ '.dcw-button[data-variant="ghost"]:hover:not(:disabled){color:var(--dsw-alias-label-primary)}',
633
+ '.dcw-button:focus-visible,.dcw-input:focus-visible,.dcw-switch:focus-visible,.dcw-choice:focus-visible,[data-dsh-codespace-launcher]:focus-visible{outline:2px solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}',
634
+ '.dcw-input{height:32px;width:100%;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:transparent;color:inherit;font:inherit;font-size:13px;padding:0 10px}',
635
+ '.dcw-input::placeholder{color:var(--dsw-alias-label-tertiary)}',
636
+ 'textarea.dcw-input{height:auto;min-height:72px;padding:8px 10px;resize:vertical;line-height:1.5}',
637
+ '.dcw-switch{position:relative;width:36px;height:20px;flex:none;padding:0;border:1px solid var(--dsw-alias-border-l2);border-radius:999px;background:var(--dsw-alias-bg-layer-3,transparent);cursor:pointer}',
638
+ '.dcw-switch[data-on="true"]{background:var(--dsw-alias-state-business-primary);border-color:transparent}',
639
+ '.dcw-switch:disabled{cursor:default;opacity:.45}',
640
+ '.dcw-switch-knob{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--dsw-alias-bg-base);transition:transform .15s}',
641
+ '.dcw-switch[data-on="true"] .dcw-switch-knob{transform:translateX(16px);background:var(--dsw-alias-label-primary-inverted)}',
642
+
643
+ /* ---- choice rows (repo / codespace / machine) ---- */
644
+ '.dcw-choice{display:flex;align-items:flex-start;gap:10px;width:100%;text-align:left;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:transparent;color:inherit;font:inherit;padding:8px 10px;cursor:pointer}',
645
+ '.dcw-choice:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
646
+ '.dcw-choice:disabled{cursor:default;opacity:.5}',
647
+ '.dcw-choice[data-selected="true"]{border-color:var(--dsw-alias-state-business-primary)}',
648
+ '.dcw-choice-mark{flex:none;width:16px;height:16px;margin-top:2px;border-radius:50%;border:1.5px solid var(--dsw-alias-label-tertiary)}',
649
+ '.dcw-choice[data-selected="true"] .dcw-choice-mark{border:5px solid var(--dsw-alias-state-business-primary)}',
650
+ '.dcw-choice-name{font-weight:600;overflow-wrap:anywhere}',
651
+ '.dcw-choice-meta{display:flex;flex-wrap:wrap;gap:8px;font-size:12px;color:var(--dsw-alias-label-secondary)}',
652
+ '.dcw-list{display:flex;flex-direction:column;gap:4px;max-height:280px;overflow:auto;padding-right:2px}',
653
+ '.dcw-tag{font-size:11px;line-height:18px;padding:0 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);white-space:nowrap}',
654
+ '.dcw-tag[data-tone="business"]{color:var(--dsw-alias-state-business-primary);border-color:transparent;background:var(--dsw-alias-bg-layer-3,transparent)}',
655
+
656
+ /* ---- notices / errors ---- */
657
+ '.dcw-notice{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:8px 12px;font-size:12px;color:var(--dsw-alias-label-secondary)}',
658
+ '.dcw-notice[data-tone="warn"]{color:var(--dsw-alias-state-warn-primary);border-color:transparent;background:var(--dsw-alias-bg-layer-3,transparent)}',
659
+ '.dcw-error{border-radius:8px;padding:8px 12px;font-size:12px;color:var(--dsw-alias-state-error-primary);background:var(--dsw-alias-interactive-bg-hover-danger);overflow-wrap:anywhere}',
660
+ '.dcw-empty{padding:12px 0;color:var(--dsw-alias-label-secondary);font-size:12px}',
661
+
662
+ /* ---- steps ---- */
663
+ '.dcw-steps{display:flex;flex-wrap:wrap;gap:8px;align-items:center}',
664
+ '.dcw-step{display:inline-flex;align-items:center;gap:8px;font-size:12px;color:var(--dsw-alias-label-tertiary)}',
665
+ '.dcw-step[data-active="true"]{color:var(--dsw-alias-label-primary)}',
666
+ '.dcw-step-number{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:50%;border:1px solid currentColor;font-size:11px;flex:none}',
667
+ '.dcw-step[data-active="true"] .dcw-step-number{border-color:transparent;background:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-label-primary-inverted)}',
668
+ '.dcw-step-sep{color:var(--dsw-alias-label-tertiary);font-size:11px}',
669
+
670
+ /* ---- spinner ---- *
671
+ * The host's own loading affordance is `IconLoadingOutlineRegular` (a round
672
+ * stroke arc) paired with a bare rotate animation — not a CSS border ring,
673
+ * which is what this used to be. Rotating the icon keeps the spinner inside
674
+ * this plugin's 16px/currentColor icon language, so it matches the four
675
+ * state glyphs it replaces and the host controls beside it. */
676
+ '@keyframes dcw-spin{to{transform:rotate(360deg)}}',
677
+ '.dcw-spin{display:inline-flex;align-items:center;justify-content:center;flex:none;animation:dcw-spin .8s linear infinite}',
678
+ '.dcw-spin svg{width:14px;height:14px;display:block}',
679
+ '@media (prefers-reduced-motion:reduce){.dcw-spin{animation:none}}',
680
+
681
+ /* ---- overlay window ---- */
682
+ '.dcw-overlay{position:fixed;inset:0;z-index:70;display:grid;place-items:center;padding:16px;background:var(--dsw-alias-bg-mask-1,color-mix(in srgb,var(--dsw-alias-label-primary) 40%,transparent))}',
683
+ '.dcw-window{width:min(680px,calc(100vw - 32px));max-height:calc(100dvh - 48px);overflow:auto;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;padding:20px;display:flex;flex-direction:column;gap:12px;background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-base))}',
684
+ '.dcw-window-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}',
685
+ '.dcw-window-foot{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap}',
686
+
687
+ /* ---- workspace-row augmentation ---------------------------------- *
688
+ * OBSERVED (dsh-client-ui-workspace/lib/client.js, the Rows CSS module):
689
+ * .<hash>_rowActions{flex:none;align-items:center;gap:10px;display:none}
690
+ * .<hash>_projectRow:hover .<hash>_rowActions,
691
+ * .<hash>_sessionRow:hover .<hash>_rowActions,
692
+ * .<hash>_searchResultRow:hover .<hash>_rowActions,
693
+ * .<hash>_projectRow.<hash>_menuOpen .<hash>_rowActions,
694
+ * .<hash>_sessionRow.<hash>_menuOpen .<hash>_rowActions{display:inline-flex}
695
+ * .<hash>_slot{width:16px;height:20px;color:var(--dsw-alias-label-tertiary);flex:none;
696
+ * justify-content:center;align-items:center;display:inline-flex}
697
+ * .<hash>_iconButton{border-radius:var(--dsw-radius-xs);cursor:pointer;width:16px;height:16px;
698
+ * color:var(--dsw-alias-label-tertiary);background:0 0;border:none;flex:none;
699
+ * justify-content:center;align-items:center;padding:0;display:inline-flex}
700
+ * .<hash>_iconButton:hover{color:var(--dsw-alias-label-primary)}
701
+ * .<hash>_projectRow .<hash>_chevron{display:none}
702
+ * .<hash>_projectRow:hover .<hash>_chevron{display:inline-flex}
703
+ * .<hash>_projectRow:hover .<hash>_folder{display:none}
704
+ * .<hash>_folderActive{color:var(--dsw-alias-state-business-primary)}
705
+ * NOTE the folder rules: the folder is VISIBLE at rest and only steps aside
706
+ * on hover. There is no unconditional `.<hash>_folder{display:none}` — an
707
+ * earlier revision read the hover rule as one and concluded there was no
708
+ * folder to replace. `folderActive` is added by the host to that same span
709
+ * for the workspace owning the current session, and its colour is what the
710
+ * cloud inherits (so do NOT set a colour here).
711
+ * The hashed prefix (`hIlkoa_`) is per build, so every selector below matches
712
+ * the class name by suffix via [class*="_name"]. The stable hooks are
713
+ * `data-row-key` (host-rendered) and the `data-dsh-codespace-*` attributes
714
+ * this bundle adds.
715
+ * ------------------------------------------------------------------ */
716
+ /* No `color` here on purpose. The host marks the workspace that owns the
717
+ * current session by adding `folderActive` to this very span, and that rule
718
+ * is `.…_folderActive{color:var(--dsw-alias-state-business-primary)}` — the
719
+ * blue every other active row shows. Setting a colour of our own would
720
+ * OVERRIDE that inherited value and leave the cloud grey while the folder
721
+ * beside it, in another workspace, turned blue. Inherit: the span supplies
722
+ * `label-tertiary` at rest and the business colour when active. */
723
+ '[data-dsh-codespace-icon]{width:16px;height:20px;flex:none;display:inline-flex;align-items:center;justify-content:center;pointer-events:none}',
724
+ '[data-dsh-codespace-icon] svg{width:16px;height:16px;display:block}',
725
+ '[data-dsh-codespace-action]{display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;padding:0;border:0;border-radius:var(--dsw-radius-xs,4px);background:0 0;color:var(--dsw-alias-label-tertiary);font:inherit;font-size:12px;line-height:1;cursor:pointer;flex:none}',
726
+ '[data-dsh-codespace-action]:hover:not(:disabled){color:var(--dsw-alias-label-primary)}',
727
+ '[data-dsh-codespace-action]:disabled{cursor:default;opacity:.6}',
728
+ '[data-dsh-codespace-action] .dcw-spin svg{width:14px;height:14px}',
729
+ '[data-dsh-codespace-glyph]{display:inline-block;font-size:12px;line-height:1}',
730
+ /* `alwaysShowCloudButton`: the container is `display:none` when the row is
731
+ * not hovered, and a `display:none` ancestor cannot be overridden from a
732
+ * descendant — so the CONTAINER has to be revealed. Only managed rows carry
733
+ * the marker, so no other row changes. */
734
+ '[data-dsh-codespace-row][data-dsh-codespace-always] [class*="_rowActions"]{display:inline-flex}',
735
+
736
+ /* ---- section-header launcher ------------------------------------- *
737
+ * OBSERVED (dsh-client-ui-workspace/lib/client.js, the WorkspaceBrowser
738
+ * CSS module, `_9lTDKa_`):
739
+ * .<hash>_sectionHeader{height:36px;justify-content:flex-end;align-items:center;
740
+ * gap:4px;padding-left:4px;display:flex;overflow:hidden}
741
+ * .<hash>_headerActions{max-width:60px;flex:none;align-items:center;gap:4px;
742
+ * display:flex;overflow:hidden}
743
+ * .<hash>_headerActionsHidden{opacity:0;visibility:hidden;pointer-events:none;max-width:0}
744
+ * .<hash>_rail .<hash>_headerActions{max-width:none}
745
+ * .<hash>_rail .<hash>_iconButton{border-radius:var(--dsw-radius-md);
746
+ * width:36px;height:36px;
747
+ * color:var(--dsw-alias-label-primary)}
748
+ * .<hash>_iconButton{border-radius:var(--dsw-radius-sm);cursor:pointer;
749
+ * width:28px;height:28px;color:var(--dsw-alias-label-secondary);
750
+ * background:0 0;border:none;flex:none;
751
+ * justify-content:center;align-items:center;
752
+ * padding:0;display:inline-flex}
753
+ * .<hash>_iconButton:hover{background:var(--dsw-alias-interactive-bg-hover)}
754
+ * .<hash>_iconButton:focus-visible{outline:var(--dsw-focus-ring-width) solid
755
+ * var(--dsw-focus-ring-color,…);outline-offset:-2px}
756
+ * NOTE: this is the HEADER control (`WorkspaceBrowser`), and it is 28x28. The
757
+ * 16x16 `_iconButton` with `--dsw-radius-xs` / `--dsw-alias-label-tertiary` /
758
+ * a colour-only hover belongs to `Rows` (the workspace-ROW buttons) — a
759
+ * different module that happens to use the same suffix. Copying that one is
760
+ * what previously made this button too small next to its peers.
761
+ * The launcher is a peer of those controls, so it copies `iconButton` exactly
762
+ * and takes the rail metrics in rail mode. Three consequences of the host's
763
+ * own rules are handled here rather than in JavaScript:
764
+ * - `max-width:60px` fits exactly two 28px controls with a 4px gap
765
+ * (28*2 + 4 = 60). A third peer would be clipped by `overflow:hidden`,
766
+ * so the container is widened. The `:not(...)` guard is load-bearing:
767
+ * without it this rule would also beat the host's `headerActionsHidden`
768
+ * collapse (same property, and ours is more specific), leaving the group
769
+ * visible while the search box is expanded.
770
+ * - `headerActionsHidden` (applied to the same element while the search box
771
+ * is expanded) collapses the CONTAINER to `max-width:0;opacity:0;
772
+ * pointer-events:none`. Because the launcher is a child of that container
773
+ * it disappears with its peers for free.
774
+ * - The header is `justify-content:flex-end` and the search slot is `flex:1`,
775
+ * so widening the group shifts it left without pushing any host control
776
+ * out of view. */
777
+ /* The launcher is a peer of the host's own header controls, so it copies
778
+ * that control's metrics verbatim from `WorkspaceBrowser`'s
779
+ * `_iconButton`: 28x28, `--dsw-radius-sm`, `--dsw-alias-label-secondary`,
780
+ * a transparent background, and a hover that shows a BACKGROUND rather
781
+ * than changing the text colour. An earlier revision used a 16x16 box
782
+ * with `--dsw-radius-xs` and a colour-only hover, which read as a smaller,
783
+ * differently-styled control next to the "+" button. */
784
+ '[data-dsh-codespace-launcher]{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:0;border-radius:var(--dsw-radius-sm,6px);background:0 0;color:var(--dsw-alias-label-secondary);font:inherit;cursor:pointer;flex:none}',
785
+ '[data-dsh-codespace-launcher]:hover{background:var(--dsw-alias-interactive-bg-hover)}',
786
+ '[data-dsh-codespace-launcher]:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-2px}',
787
+ '[data-dsh-codespace-launcher] svg{display:block}',
788
+ '[data-dsh-codespace-header][class*="' + HEADER_ACTIONS_SUFFIX + '"]:not([class*="' + HEADER_HIDDEN_SUFFIX + '"]){max-width:none}',
789
+ /* Rail mode: the host enlarges the box to 36px and its glyph to 18px
790
+ * (`size: wide ? 16 : 18`), so the glyph follows the box. */
791
+ '[class*="' + RAIL_SUFFIX + '"] [data-dsh-codespace-launcher]{border-radius:var(--dsw-radius-md,8px);width:36px;height:36px;color:var(--dsw-alias-label-primary)}',
792
+ '[class*="' + RAIL_SUFFIX + '"] [data-dsh-codespace-launcher] svg{width:18px;height:18px}',
793
+
794
+ /* ---- tooltip bubble, matching the host's own ---------------------- *
795
+ * OBSERVED (`@deepseek-ai/dsh-client-ui-primitives` `Tooltip.module.css`,
796
+ * class `_bubble_12mhf_1` / `_label_12mhf_27`):
797
+ * .bubble{display:inline-flex;align-items:center;gap:8px;position:fixed;
798
+ * z-index:100;width:max-content;max-width:50vw;padding:3px 7px;
799
+ * border-radius:var(--dsw-radius-sm);
800
+ * background:var(--dsw-alias-tooltip-bg);
801
+ * color:var(--dsw-static-neutral-bluish-00);font-size:13px;
802
+ * line-height:20px;white-space:pre-line;overflow-wrap:break-word;
803
+ * pointer-events:none;animation:tooltip-in 150ms var(--ds-ease-in-out)}
804
+ * @keyframes tooltip-in{from{opacity:0}}
805
+ * No token value is hardcoded: these are theme-provided, so they are only
806
+ * ever referenced. The native `title` attribute is deliberately NOT used —
807
+ * it draws an OS bubble that looks nothing like DSH's. */
808
+ '[data-dsh-codespace-tip]{display:inline-flex;align-items:center;gap:8px;position:fixed;z-index:1100;width:max-content;max-width:50vw;padding:3px 7px;border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-tooltip-bg);color:var(--dsw-static-neutral-bluish-00);font-size:13px;line-height:20px;white-space:pre-line;overflow-wrap:break-word;pointer-events:none;animation:dcw-tooltip-in 150ms var(--ds-ease-in-out)}',
809
+ '@keyframes dcw-tooltip-in{from{opacity:0}}',
810
+ '@media (prefers-reduced-motion:reduce){[data-dsh-codespace-tip]{animation:none}}',
811
+
812
+ /* ---- menu item we append to the host's portaled workspace menu ---- */
813
+ '[data-dsh-codespace-menu-separator]{height:1px;background:var(--dsw-alias-border-l2);margin:4px 6px}',
814
+ '[data-dsh-codespace-menu-item]{display:flex;align-items:center;width:100%;gap:8px;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-state-error-primary);font:inherit;font-size:13px;text-align:left;padding:6px 10px;cursor:pointer}',
815
+ '[data-dsh-codespace-menu-item]:hover{background:var(--dsw-alias-interactive-bg-hover-danger)}',
816
+ ].join('\n')
817
+
818
+ /* ------------------------------------------------------------------ *
819
+ * 4. Transport
820
+ * ------------------------------------------------------------------ */
821
+
822
+ /** Error carrying the host's `error.code`, never a credential. */
823
+ function RpcError(code, message) {
824
+ const error = new Error(redact(message))
825
+ error.code = code
826
+ error.name = 'RpcError'
827
+ return error
828
+ }
829
+
830
+ /**
831
+ * One RPC call against the host route. Always resolves the parsed body;
832
+ * rejects only on a transport failure or `{ ok: false }`.
833
+ *
834
+ * @param {string} action - one of ACTIONS.
835
+ * @param {object} [payload] - the action's payload.
836
+ * @returns {Promise<object>} the success body.
837
+ */
838
+ async function rpc(action, payload) {
839
+ const body = Object.assign({ action }, payload === undefined ? {} : payload)
840
+ const send = (globalThis.__DSH_TRANSPORT__ && globalThis.__DSH_TRANSPORT__.fetch) || fetch
841
+ let response
842
+ try {
843
+ response = await send(RPC_ROUTE, {
844
+ method: 'POST',
845
+ credentials: 'same-origin',
846
+ headers: { 'content-type': 'application/json' },
847
+ body: JSON.stringify(body),
848
+ })
849
+ } catch (error) {
850
+ throw RpcError('E_TRANSPORT', messageOf(error))
851
+ }
852
+ let data = null
853
+ try {
854
+ data = await response.json()
855
+ } catch (error) {
856
+ throw RpcError('E_BAD_RESPONSE', 'HTTP ' + String(response.status))
857
+ }
858
+ if (data === null || typeof data !== 'object') throw RpcError('E_BAD_RESPONSE', 'HTTP ' + String(response.status))
859
+ if (data.ok !== true) {
860
+ const failure = data.error !== null && typeof data.error === 'object' ? data.error : {}
861
+ throw RpcError(typeof failure.code === 'string' ? failure.code : 'E_UNKNOWN', String(failure.message || 'HTTP ' + String(response.status)))
862
+ }
863
+ return data
864
+ }
865
+
866
+ /** Reduce an unknown thrown value to a displayable, redacted string. */
867
+ function messageOf(error) {
868
+ if (error === null || error === undefined) return ''
869
+ if (typeof error === 'string') return redact(error)
870
+ if (typeof error.message === 'string') return redact(error.message)
871
+ return redact(String(error))
872
+ }
873
+
874
+ /* ------------------------------------------------------------------ *
875
+ * 5. Tolerant readers for the wire records
876
+ *
877
+ * `Managed` and `Codespace` are described in docs/CONTRACT.md §6, but the
878
+ * host half is written in parallel: every reader below accepts the documented
879
+ * shape plus the two obvious variants, and returns an empty/neutral value
880
+ * rather than throwing.
881
+ * ------------------------------------------------------------------ */
882
+
883
+ /** Coerce to a finite number, or `Number.MAX_SAFE_INTEGER` so it sorts last. */
884
+ function num(value) {
885
+ const n = typeof value === 'number' ? value : Number(value)
886
+ return Number.isFinite(n) ? n : Number.MAX_SAFE_INTEGER
887
+ }
888
+
889
+ /** VERIFY: `Managed.codespace` is documented as a `Codespace` object; the
890
+ * host may also send just the name. Both are accepted here. */
891
+ function codespaceOf(record) {
892
+ if (record === null || typeof record !== 'object') return null
893
+ const cs = record.codespace
894
+ if (cs !== null && typeof cs === 'object') return cs
895
+ if (typeof cs === 'string' && cs !== '') return { name: cs, state: record.state }
896
+ return null
897
+ }
898
+
899
+ /** The Codespace name used by every lifecycle RPC (`{ name }`). */
900
+ function codespaceNameOf(record) {
901
+ const cs = codespaceOf(record)
902
+ if (cs !== null && typeof cs.name === 'string' && cs.name !== '') return cs.name
903
+ if (typeof record.codespaceName === 'string') return record.codespaceName
904
+ return ''
905
+ }
906
+
907
+ /** Human label for the hover card: display name, then name, then DSH title. */
908
+ function codespaceLabelOf(record) {
909
+ const cs = codespaceOf(record)
910
+ if (cs !== null) {
911
+ if (typeof cs.displayName === 'string' && cs.displayName !== '') return cs.displayName
912
+ if (typeof cs.name === 'string' && cs.name !== '') return cs.name
913
+ }
914
+ if (typeof record.title === 'string' && record.title !== '') return record.title
915
+ return codespaceNameOf(record)
916
+ }
917
+
918
+ /**
919
+ * The Codespace's repository as `owner/name`, or `''` when unknown.
920
+ *
921
+ * The row is titled after this repository's SHORT name (see the host half's
922
+ * `titleForCodespace`), so the hover card carries the two facts the title
923
+ * cannot: which owner it belongs to, and the Codespace's own name.
924
+ *
925
+ * @param {object} record - a `Managed` record.
926
+ * @returns {string} `owner/name`, or `''`.
927
+ */
928
+ function repositoryOf(record) {
929
+ const cs = codespaceOf(record)
930
+ if (cs === null) return ''
931
+ return typeof cs.repository === 'string' ? cs.repository.trim() : ''
932
+ }
933
+
934
+ /** The raw state string, for the error tooltip. */
935
+ function codespaceStateOf(record) {
936
+ const cs = codespaceOf(record)
937
+ if (cs !== null && typeof cs.state === 'string') return cs.state
938
+ if (typeof record.state === 'string') return record.state
939
+ return ''
940
+ }
941
+
942
+ /** Normalized phase, falling back to the top-level `state` field. */
943
+ function phaseOf(record) {
944
+ return codespacePhase(codespaceStateOf(record))
945
+ }
946
+
947
+ /** VERIFY: `autoPauseRemainingMs` is documented in milliseconds and
948
+ * `autoPauseDeadline` as a deadline; an epoch-seconds deadline is also
949
+ * accepted, because that mistake is invisible in a 4-second poll. */
950
+ function countdownMsOf(record) {
951
+ const remaining = record.autoPauseRemainingMs
952
+ if (typeof remaining === 'number' && Number.isFinite(remaining) && remaining > 0) return remaining
953
+ const deadline = record.autoPauseDeadline
954
+ let at = null
955
+ if (typeof deadline === 'number' && Number.isFinite(deadline)) at = deadline
956
+ else if (typeof deadline === 'string') {
957
+ const parsed = Date.parse(deadline)
958
+ if (!Number.isNaN(parsed)) at = parsed
959
+ }
960
+ if (at === null) return 0
961
+ if (at < 1e11) at *= 1000
962
+ return Math.max(0, at - Date.now())
963
+ }
964
+
965
+ /**
966
+ * Whether the row button must show a spinner — i.e. whether a STATE CHANGE is
967
+ * in flight.
968
+ *
969
+ * `record.busy` is deliberately NOT consulted. The host sets it on
970
+ * `user/message` and clears it on `turn/end`, so it means "the agent is
971
+ * working in this workspace" — which can be minutes. Feeding that into this
972
+ * predicate made the row spin with the tooltip 「正在切换状态…」 for the whole
973
+ * turn even though GitHub was reporting a settled state, which is precisely
974
+ * the "it keeps spinning but the state is clear" complaint. Here only two
975
+ * things matter: our own RPC is in flight, or GitHub says the Codespace is
976
+ * genuinely mid-transition (`Provisioning`/`Starting`/`ShuttingDown`/…).
977
+ *
978
+ * @param {object} record - a `Managed` record.
979
+ * @param {Set<string>} pendingIds - workspace ids with an in-flight action.
980
+ * @returns {boolean} whether to spin.
981
+ */
982
+ function isBusy(record, pendingIds) {
983
+ if (pendingIds.has(record.workspaceId)) return true
984
+ return phaseOf(record) === 'pending'
985
+ }
986
+
987
+ /** Fill in defaults for a settings object coming off the wire. */
988
+ function normalizeSettings(raw) {
989
+ const out = Object.assign({}, SETTINGS_DEFAULTS)
990
+ if (raw !== null && typeof raw === 'object') {
991
+ for (const key of Object.keys(SETTINGS_DEFAULTS)) {
992
+ if (raw[key] !== undefined) out[key] = raw[key]
993
+ }
994
+ }
995
+ return out
996
+ }
997
+
998
+ /**
999
+ * Whether a write-only secret has a stored value.
1000
+ *
1001
+ * VERIFY: the exact redaction marker is the host's choice. A non-empty string
1002
+ * (including the usual `***` / `•••` / `[redacted]` placeholders), a truthy
1003
+ * boolean, or a `secrets` marker object all count as "set". The value itself
1004
+ * is never rendered and never sent back.
1005
+ */
1006
+ function secretIsSet(settings, secrets, key) {
1007
+ if (secrets !== null && typeof secrets === 'object') {
1008
+ const marker = secrets[key]
1009
+ if (marker === true) return true
1010
+ if (marker !== null && typeof marker === 'object' && marker.set === true) return true
1011
+ if (typeof marker === 'string' && marker !== '' && marker !== 'none') return true
1012
+ }
1013
+ if (settings === null || typeof settings !== 'object') return false
1014
+ const value = settings[key]
1015
+ if (typeof value === 'boolean') return value
1016
+ if (typeof value === 'string') return value !== ''
1017
+ return false
1018
+ }
1019
+
1020
+ /** Whether a settings object still carries a real secret value (never sent back). */
1021
+ function stripSecrets(settings) {
1022
+ const out = Object.assign({}, settings)
1023
+ for (const key of SECRET_KEYS) delete out[key]
1024
+ return out
1025
+ }
1026
+
1027
+ /** The smallest machine: fewest CPUs, then least memory, then least storage. */
1028
+ function compareMachines(a, b) {
1029
+ const cpus = num(a.cpus) - num(b.cpus)
1030
+ if (cpus !== 0) return cpus
1031
+ const memory = num(a.memoryGb) - num(b.memoryGb)
1032
+ if (memory !== 0) return memory
1033
+ const storage = num(a.storageGb) - num(b.storageGb)
1034
+ if (storage !== 0) return storage
1035
+ return String(a.name).localeCompare(String(b.name))
1036
+ }
1037
+
1038
+ /** The machine the window preselects. */
1039
+ function smallestMachine(machines) {
1040
+ if (!Array.isArray(machines) || machines.length === 0) return null
1041
+ return machines.slice().sort(compareMachines)[0]
1042
+ }
1043
+
1044
+ /* ------------------------------------------------------------------ *
1045
+ * 6. Stores
1046
+ * ------------------------------------------------------------------ */
1047
+
1048
+ /** Cheap structural equality for wire snapshots (they are small and flat). */
1049
+ function sameJson(a, b) {
1050
+ if (a === b) return true
1051
+ try {
1052
+ return JSON.stringify(a) === JSON.stringify(b)
1053
+ } catch (error) {
1054
+ return false
1055
+ }
1056
+ }
1057
+
1058
+ /**
1059
+ * The plugin's one data store: the managed-workspace list (polled) plus the
1060
+ * settings (loaded on demand).
1061
+ *
1062
+ * Polling is a non-overlapping `setTimeout` chain that runs only while at
1063
+ * least one subscriber holds an "owner" claim — the sidebar launcher and the
1064
+ * settings page's workspace list. The row augmenter observes without owning,
1065
+ * so the plugin does not poll when neither surface is mounted.
1066
+ *
1067
+ * @param {(action: string, payload?: object) => Promise<object>} call - the RPC.
1068
+ * @returns {object} the store.
1069
+ */
1070
+ function createStore(call) {
1071
+ const listeners = new Set()
1072
+ let owners = 0
1073
+ let disposed = false
1074
+ let timer = null
1075
+ let inFlight = false
1076
+ let state = {
1077
+ workspaces: [],
1078
+ workspacesReady: false,
1079
+ workspacesError: '',
1080
+ settings: null,
1081
+ settingsRevision: 0,
1082
+ settingsLoaded: false,
1083
+ settingsError: '',
1084
+ }
1085
+
1086
+ function getState() {
1087
+ return state
1088
+ }
1089
+
1090
+ function publish(patch) {
1091
+ let changed = false
1092
+ for (const key of Object.keys(patch)) {
1093
+ if (!sameJson(state[key], patch[key])) {
1094
+ changed = true
1095
+ break
1096
+ }
1097
+ }
1098
+ if (!changed) return
1099
+ state = Object.assign({}, state, patch)
1100
+ for (const listener of Array.from(listeners)) {
1101
+ try {
1102
+ listener()
1103
+ } catch (error) {
1104
+ console.warn('[dsh-codespace-workspace] store listener failed', error)
1105
+ }
1106
+ }
1107
+ }
1108
+
1109
+ function schedule() {
1110
+ if (disposed || owners === 0 || timer !== null) return
1111
+ timer = setTimeout(() => {
1112
+ timer = null
1113
+ void poll()
1114
+ }, POLL_MS)
1115
+ }
1116
+
1117
+ function stopPolling() {
1118
+ if (timer !== null) {
1119
+ clearTimeout(timer)
1120
+ timer = null
1121
+ }
1122
+ }
1123
+
1124
+ async function poll() {
1125
+ if (disposed || inFlight) return
1126
+ inFlight = true
1127
+ try {
1128
+ const data = await call(ACTIONS.WORKSPACES)
1129
+ publish({
1130
+ workspaces: Array.isArray(data.workspaces) ? data.workspaces : [],
1131
+ workspacesReady: true,
1132
+ workspacesError: '',
1133
+ })
1134
+ } catch (error) {
1135
+ publish({ workspacesError: messageOf(error), workspacesReady: true })
1136
+ } finally {
1137
+ inFlight = false
1138
+ schedule()
1139
+ }
1140
+ }
1141
+
1142
+ /** Refresh now (used after a mutation); never overlaps the poll chain. */
1143
+ function refresh() {
1144
+ if (disposed) return
1145
+ stopPolling()
1146
+ if (inFlight) {
1147
+ schedule()
1148
+ return
1149
+ }
1150
+ void poll()
1151
+ }
1152
+
1153
+ async function loadSettings(force) {
1154
+ if (state.settingsLoaded && force !== true) return state.settings
1155
+ try {
1156
+ const data = await call(ACTIONS.GET_SETTINGS)
1157
+ publish({
1158
+ settings: normalizeSettings(data.settings),
1159
+ settingsRevision: data.settingsRevision !== undefined ? data.settingsRevision : data.revision,
1160
+ settingsLoaded: true,
1161
+ settingsError: '',
1162
+ })
1163
+ } catch (error) {
1164
+ publish({ settingsError: messageOf(error), settingsLoaded: true })
1165
+ throw error
1166
+ }
1167
+ return state.settings
1168
+ }
1169
+
1170
+ async function saveSettings(patch) {
1171
+ const data = await call(ACTIONS.SET_SETTINGS, { patch })
1172
+ publish({
1173
+ settings: normalizeSettings(data.settings),
1174
+ settingsRevision: data.settingsRevision !== undefined ? data.settingsRevision : data.revision,
1175
+ settingsLoaded: true,
1176
+ settingsError: '',
1177
+ })
1178
+ return state.settings
1179
+ }
1180
+
1181
+ function subscribe(listener, owns) {
1182
+ listeners.add(listener)
1183
+ if (owns === true) {
1184
+ owners += 1
1185
+ if (owners === 1) refresh()
1186
+ }
1187
+ return () => {
1188
+ listeners.delete(listener)
1189
+ if (owns === true) {
1190
+ owners = Math.max(0, owners - 1)
1191
+ if (owners === 0) stopPolling()
1192
+ }
1193
+ }
1194
+ }
1195
+
1196
+ function dispose() {
1197
+ disposed = true
1198
+ stopPolling()
1199
+ listeners.clear()
1200
+ owners = 0
1201
+ }
1202
+
1203
+ return { getState, subscribe, refresh, loadSettings, saveSettings, dispose }
1204
+ }
1205
+
1206
+ /**
1207
+ * Which overlay is open. One `shell.overlay` entry reads this, so the create
1208
+ * flow and the delete confirmation are never a stack of dialogs.
1209
+ */
1210
+ function createUiStore() {
1211
+ const listeners = new Set()
1212
+ let seq = 0
1213
+ let state = { create: null, del: null }
1214
+
1215
+ function publish(next) {
1216
+ state = next
1217
+ for (const listener of Array.from(listeners)) {
1218
+ try {
1219
+ listener()
1220
+ } catch (error) {
1221
+ console.warn('[dsh-codespace-workspace] ui listener failed', error)
1222
+ }
1223
+ }
1224
+ }
1225
+
1226
+ return {
1227
+ getState() {
1228
+ return state
1229
+ },
1230
+ subscribe(listener) {
1231
+ listeners.add(listener)
1232
+ return () => listeners.delete(listener)
1233
+ },
1234
+ openCreate() {
1235
+ seq += 1
1236
+ publish({ create: { seq }, del: null })
1237
+ },
1238
+ openDelete(target) {
1239
+ seq += 1
1240
+ publish({ create: null, del: Object.assign({ seq }, target) })
1241
+ },
1242
+ close() {
1243
+ publish({ create: null, del: null })
1244
+ },
1245
+ dispose() {
1246
+ listeners.clear()
1247
+ state = { create: null, del: null }
1248
+ },
1249
+ }
1250
+ }
1251
+
1252
+ /* ------------------------------------------------------------------ *
1253
+ * 7. Hooks and shared components
1254
+ * ------------------------------------------------------------------ */
1255
+
1256
+ /** `useSyncExternalStore` when available, with a React-17-shaped fallback.
1257
+ * The branch is resolved once, so hook order is stable for the module. */
1258
+ const useExternalStore =
1259
+ typeof React.useSyncExternalStore === 'function'
1260
+ ? function useStore(subscribe, getSnapshot) {
1261
+ return React.useSyncExternalStore(subscribe, getSnapshot)
1262
+ }
1263
+ : function useStore(subscribe, getSnapshot) {
1264
+ const [value, setValue] = React.useState(getSnapshot)
1265
+ React.useEffect(() => subscribe(() => setValue(getSnapshot())), [subscribe, getSnapshot])
1266
+ return value
1267
+ }
1268
+
1269
+ /** Subscribe to a snapshot store. */
1270
+ function useStoreState(store, owns) {
1271
+ const subscribe = React.useCallback((listener) => store.subscribe(listener, owns), [store, owns])
1272
+ const getSnapshot = React.useCallback(() => store.getState(), [store])
1273
+ return useExternalStore(subscribe, getSnapshot)
1274
+ }
1275
+
1276
+ /** Re-render when the active locale changes. */
1277
+ function useLocaleTick(locale) {
1278
+ const [, bump] = React.useReducer((x) => x + 1, 0)
1279
+ React.useEffect(() => {
1280
+ if (locale === undefined || locale === null || typeof locale.subscribe !== 'function') return undefined
1281
+ return locale.subscribe(() => bump())
1282
+ }, [locale])
1283
+ }
1284
+
1285
+ /**
1286
+ * One hand-drawn host-spec icon, by name from {@link ACTION_ICONS}.
1287
+ *
1288
+ * The React counterpart of `iconNode`, sharing the same artwork table so the
1289
+ * two renderers cannot drift. Never use an emoji or a text glyph for an icon
1290
+ * — see the note on `ACTION_ICONS`.
1291
+ *
1292
+ * @param {{kind: string, size?: number}} props - which artwork, and the box.
1293
+ * @returns {object} the SVG element.
1294
+ */
1295
+ function Icon(props) {
1296
+ const spec = ACTION_ICONS[props.kind]
1297
+ const size = props.size ?? 16
1298
+ return h(
1299
+ 'svg',
1300
+ {
1301
+ width: size,
1302
+ height: size,
1303
+ viewBox: spec.viewBox,
1304
+ fill: 'none',
1305
+ xmlns: 'http://www.w3.org/2000/svg',
1306
+ 'aria-hidden': 'true',
1307
+ strokeWidth: ICON_STROKE_WIDTH,
1308
+ ...(spec.linecap === undefined ? {} : { strokeLinecap: spec.linecap }),
1309
+ ...(spec.linejoin === undefined ? {} : { strokeLinejoin: spec.linejoin }),
1310
+ },
1311
+ spec.paths.map((d) => h('path', { key: d, d, stroke: 'currentColor' })),
1312
+ )
1313
+ }
1314
+
1315
+ /**
1316
+ * The cloud glyph used by the launcher, the window header and the row marker.
1317
+ *
1318
+ * @param {{plus?: boolean}} [props] - `plus` adds the host's top-right plus,
1319
+ * for controls that CREATE a cloud workspace rather than merely marking one.
1320
+ * @returns {object} the SVG element.
1321
+ */
1322
+ function CloudIcon(props) {
1323
+ const withPlus = props !== undefined && props.plus === true
1324
+ if (!withPlus) {
1325
+ return h(
1326
+ 'svg',
1327
+ {
1328
+ width: 16,
1329
+ height: 16,
1330
+ viewBox: '0 0 16 16',
1331
+ fill: 'none',
1332
+ xmlns: 'http://www.w3.org/2000/svg',
1333
+ 'aria-hidden': 'true',
1334
+ strokeWidth: ICON_STROKE_WIDTH,
1335
+ },
1336
+ h('path', { d: CLOUD_PATH, stroke: 'currentColor' }),
1337
+ )
1338
+ }
1339
+ const maskId = 'dcw-cloud-add-' + String(cloudMaskSeq++)
1340
+ return h(
1341
+ 'svg',
1342
+ {
1343
+ width: 16,
1344
+ height: 16,
1345
+ viewBox: '0 0 16 16',
1346
+ fill: 'none',
1347
+ xmlns: 'http://www.w3.org/2000/svg',
1348
+ 'aria-hidden': 'true',
1349
+ strokeWidth: ICON_STROKE_WIDTH,
1350
+ },
1351
+ h('defs', { key: 'defs' },
1352
+ h('mask', {
1353
+ id: maskId,
1354
+ key: 'mask',
1355
+ maskUnits: 'userSpaceOnUse',
1356
+ x: 0,
1357
+ y: 0,
1358
+ width: 16,
1359
+ height: 16,
1360
+ },
1361
+ h('rect', { key: 'plate', x: 0, y: 0, width: 16, height: 16, fill: '#fff' }),
1362
+ ...PLUS_PATHS.map((d) => h('path', {
1363
+ key: 'cut-' + d,
1364
+ d,
1365
+ stroke: '#000',
1366
+ strokeWidth: CLOUD_MASK_BAND,
1367
+ fill: 'none',
1368
+ strokeLinecap: 'butt',
1369
+ })))),
1370
+ h('path', { key: 'cloud', d: CLOUD_PATH, stroke: 'currentColor', mask: 'url(#' + maskId + ')' }),
1371
+ ...PLUS_PATHS.map((d) => h('path', { key: d, d, stroke: 'currentColor' })),
1372
+ )
1373
+ }
1374
+
1375
+ /** A plain button matching the host's control metrics. */
1376
+ function Button(props) {
1377
+ return h(
1378
+ 'button',
1379
+ {
1380
+ type: 'button',
1381
+ className: 'dcw-button',
1382
+ 'data-variant': props.variant === undefined ? 'default' : props.variant,
1383
+ disabled: props.disabled === true,
1384
+ onClick: props.onClick,
1385
+ title: props.title,
1386
+ 'aria-label': props['aria-label'],
1387
+ },
1388
+ props.children,
1389
+ )
1390
+ }
1391
+
1392
+ /** Escape closes the topmost overlay window (the host Modal's own contract). */
1393
+ function useEscape(onClose) {
1394
+ const latest = React.useRef(onClose)
1395
+ latest.current = onClose
1396
+ React.useEffect(() => {
1397
+ const onKeyDown = (event) => {
1398
+ if (event.key !== 'Escape' || event.shiftKey) return
1399
+ if (event.defaultPrevented) return
1400
+ latest.current()
1401
+ }
1402
+ document.addEventListener('keydown', onKeyDown)
1403
+ return () => document.removeEventListener('keydown', onKeyDown)
1404
+ }, [])
1405
+ }
1406
+
1407
+ /** The one overlay host: a mask plus a card, matching the host Modal's shape.
1408
+ * The component itself unmounts when nothing is open, so the key listener and
1409
+ * the window are owned by the open window rather than by the slot. */
1410
+ function OverlayWindow(props) {
1411
+ useEscape(props.onClose)
1412
+ return h(
1413
+ 'div',
1414
+ {
1415
+ className: 'dcw-overlay',
1416
+ role: 'presentation',
1417
+ onClick: (event) => {
1418
+ if (event.target === event.currentTarget) props.onClose()
1419
+ },
1420
+ },
1421
+ h(
1422
+ 'div',
1423
+ {
1424
+ className: 'dcw-window',
1425
+ role: 'dialog',
1426
+ 'aria-modal': 'true',
1427
+ 'aria-label': props.label,
1428
+ },
1429
+ props.children,
1430
+ ),
1431
+ )
1432
+ }
1433
+
1434
+ /** `role="switch"` toggle (the host's own control contract). */
1435
+ function Switch(props) {
1436
+ return h(
1437
+ 'button',
1438
+ {
1439
+ type: 'button',
1440
+ role: 'switch',
1441
+ className: 'dcw-switch',
1442
+ 'aria-checked': props.checked === true ? 'true' : 'false',
1443
+ 'aria-label': props.label,
1444
+ title: props.label,
1445
+ 'data-on': props.checked === true ? 'true' : 'false',
1446
+ disabled: props.disabled === true,
1447
+ onClick: () => props.onChange(props.checked !== true),
1448
+ },
1449
+ h('span', { className: 'dcw-switch-knob' }),
1450
+ )
1451
+ }
1452
+
1453
+ /** One labelled control row. */
1454
+ function Field(props) {
1455
+ return h(
1456
+ 'div',
1457
+ { className: 'dcw-stack' },
1458
+ h('div', { className: 'dcw-label' }, props.label),
1459
+ props.control,
1460
+ props.hint === undefined || props.hint === null ? null : h('div', { className: 'dcw-muted' }, props.hint),
1461
+ )
1462
+ }
1463
+
1464
+ /** One labelled row with the control on the right (switches). */
1465
+ function ToggleRow(props) {
1466
+ return h(
1467
+ 'div',
1468
+ { className: 'dcw-row-start' },
1469
+ h(
1470
+ 'div',
1471
+ { className: 'dcw-stack' },
1472
+ h('div', { className: 'dcw-label' }, props.label),
1473
+ props.hint === undefined ? null : h('div', { className: 'dcw-muted' }, props.hint),
1474
+ ),
1475
+ props.control,
1476
+ )
1477
+ }
1478
+
1479
+ /** One selectable row used by the repo, codespace and machine steps. */
1480
+ function Choice(props) {
1481
+ return h(
1482
+ 'button',
1483
+ {
1484
+ type: 'button',
1485
+ className: 'dcw-choice',
1486
+ 'data-selected': props.selected === true ? 'true' : 'false',
1487
+ 'aria-pressed': props.selected === true ? 'true' : 'false',
1488
+ disabled: props.disabled === true,
1489
+ onClick: props.onClick,
1490
+ },
1491
+ h('span', { className: 'dcw-choice-mark', 'aria-hidden': 'true' }),
1492
+ h(
1493
+ 'span',
1494
+ { className: 'dcw-stack' },
1495
+ h(
1496
+ 'span',
1497
+ { className: 'dcw-row' },
1498
+ h('span', { className: 'dcw-choice-name' }, props.title),
1499
+ props.tag === undefined ? null : h('span', { className: 'dcw-tag', 'data-tone': props.tagTone }, props.tag),
1500
+ ),
1501
+ props.meta === undefined ? null : h('span', { className: 'dcw-choice-meta' }, props.meta),
1502
+ props.detail === undefined ? null : h('span', { className: 'dcw-muted' }, props.detail),
1503
+ ),
1504
+ )
1505
+ }
1506
+
1507
+ /** The multi-step progress line. */
1508
+ function Steps(props) {
1509
+ const labels = [props.t('step.repo'), props.t('step.codespace'), props.t('step.machine'), props.t('step.apply')]
1510
+ const children = []
1511
+ for (let i = 0; i < labels.length; i += 1) {
1512
+ if (i > 0) children.push(h('span', { key: 'sep' + i, className: 'dcw-step-sep' }, '›'))
1513
+ children.push(
1514
+ h(
1515
+ 'span',
1516
+ { key: 'step' + i, className: 'dcw-step', 'data-active': i === props.active ? 'true' : 'false' },
1517
+ h('span', { className: 'dcw-step-number' }, String(i + 1)),
1518
+ labels[i],
1519
+ ),
1520
+ )
1521
+ }
1522
+ return h('div', { className: 'dcw-steps' }, children)
1523
+ }
1524
+
1525
+ /* ------------------------------------------------------------------ *
1526
+ * 8. Settings section
1527
+ * ------------------------------------------------------------------ */
1528
+
1529
+ /**
1530
+ * The **Codespace** settings page.
1531
+ *
1532
+ * Every value is persisted through `set-settings`; nothing is kept in
1533
+ * localStorage. The SSH field is write-only: only a set/unset marker plus an
1534
+ * empty input is ever rendered, and a stored value is never read back.
1535
+ */
1536
+ function createSettingsSection(deps) {
1537
+ const { store, ui, t, locale } = deps
1538
+
1539
+ function SettingsSection() {
1540
+ useLocaleTick(locale)
1541
+ const snapshot = useStoreState(store, true)
1542
+ const [draft, setDraft] = React.useState(null)
1543
+ const [saving, setSaving] = React.useState(false)
1544
+ const [saveError, setSaveError] = React.useState('')
1545
+ const [saved, setSaved] = React.useState(false)
1546
+ const [secretDraft, setSecretDraft] = React.useState('')
1547
+ const [secretBusy, setSecretBusy] = React.useState(false)
1548
+ const [status, setStatus] = React.useState(null)
1549
+ const [statusError, setStatusError] = React.useState('')
1550
+ const [statusBusy, setStatusBusy] = React.useState(false)
1551
+ const alive = React.useRef(true)
1552
+
1553
+ React.useEffect(() => {
1554
+ alive.current = true
1555
+ return () => {
1556
+ alive.current = false
1557
+ }
1558
+ }, [])
1559
+
1560
+ // The settings page reads its own values; it does not depend on another
1561
+ // surface having loaded them.
1562
+ React.useEffect(() => {
1563
+ store.loadSettings().catch(() => {})
1564
+ }, [])
1565
+
1566
+ React.useEffect(() => {
1567
+ if (snapshot.settingsLoaded && draft === null && snapshot.settings !== null) {
1568
+ setDraft(stripSecrets(snapshot.settings))
1569
+ }
1570
+ }, [snapshot.settingsLoaded, snapshot.settings, draft])
1571
+
1572
+ const loadStatus = React.useCallback(() => {
1573
+ setStatusBusy(true)
1574
+ setStatusError('')
1575
+ rpc(ACTIONS.STATUS)
1576
+ .then((data) => {
1577
+ if (!alive.current) return
1578
+ setStatus(data)
1579
+ })
1580
+ .catch((error) => {
1581
+ if (!alive.current) return
1582
+ setStatusError(messageOf(error))
1583
+ })
1584
+ .then(() => {
1585
+ if (alive.current) setStatusBusy(false)
1586
+ })
1587
+ }, [])
1588
+
1589
+ React.useEffect(() => {
1590
+ loadStatus()
1591
+ }, [loadStatus])
1592
+
1593
+ const base = snapshot.settings === null ? null : stripSecrets(snapshot.settings)
1594
+ const dirty = draft !== null && base !== null && !sameJson(draft, base)
1595
+
1596
+ function setField(key, value) {
1597
+ setSaved(false)
1598
+ setDraft((current) => Object.assign({}, current === null ? SETTINGS_DEFAULTS : current, { [key]: value }))
1599
+ }
1600
+
1601
+ function commit() {
1602
+ if (draft === null || base === null) return
1603
+ const patch = {}
1604
+ for (const key of Object.keys(SETTINGS_DEFAULTS)) {
1605
+ if (key === 'sshKeyPath') continue
1606
+ if (!sameJson(draft[key], base[key])) patch[key] = draft[key]
1607
+ }
1608
+ if (Object.keys(patch).length === 0) return
1609
+ setSaving(true)
1610
+ setSaveError('')
1611
+ store
1612
+ .saveSettings(patch)
1613
+ .then(() => {
1614
+ if (!alive.current) return
1615
+ setSaving(false)
1616
+ setSaved(true)
1617
+ })
1618
+ .catch((error) => {
1619
+ if (!alive.current) return
1620
+ setSaving(false)
1621
+ setSaveError(messageOf(error))
1622
+ })
1623
+ }
1624
+
1625
+ function writeSecret(value) {
1626
+ setSecretBusy(true)
1627
+ setSaveError('')
1628
+ store
1629
+ .saveSettings({ sshKeyPath: value })
1630
+ .then(() => {
1631
+ if (!alive.current) return
1632
+ setSecretBusy(false)
1633
+ setSecretDraft('')
1634
+ setSaved(true)
1635
+ })
1636
+ .catch((error) => {
1637
+ if (!alive.current) return
1638
+ setSecretBusy(false)
1639
+ setSaveError(messageOf(error))
1640
+ })
1641
+ }
1642
+
1643
+ const gh = status !== null && status.gh !== null && typeof status.gh === 'object' ? status.gh : null
1644
+ const ghInstalled = gh !== null && gh.installed === true
1645
+ const tokenSource = status !== null && typeof status.tokenSource === 'string' ? status.tokenSource : 'none'
1646
+ const tokenFound = tokenSource !== 'none' && tokenSource !== ''
1647
+ const login = gh !== null && typeof gh.login === 'string' && gh.login !== '' ? gh.login : ''
1648
+
1649
+ const statusRows = []
1650
+ statusRows.push(
1651
+ h(
1652
+ 'div',
1653
+ { key: 'gh', className: 'dcw-row-start' },
1654
+ h('div', { className: 'dcw-stack' }, h('div', { className: 'dcw-label' }, t('status.gh'))),
1655
+ h(
1656
+ 'div',
1657
+ { className: 'dcw-stack', style: { alignItems: 'flex-end', textAlign: 'right' } },
1658
+ h(
1659
+ 'span',
1660
+ { className: ghInstalled ? undefined : 'dcw-muted' },
1661
+ status === null
1662
+ ? t('status.checking')
1663
+ : ghInstalled
1664
+ ? t('status.gh.installed', { version: gh.version === undefined || gh.version === null || gh.version === '' ? '?' : gh.version })
1665
+ : t('status.gh.missing'),
1666
+ ),
1667
+ ghInstalled || status === null
1668
+ ? null
1669
+ : h('span', { className: 'dcw-muted' }, t('status.gh.missingNote')),
1670
+ ),
1671
+ ),
1672
+ )
1673
+ statusRows.push(
1674
+ h(
1675
+ 'div',
1676
+ { key: 'token', className: 'dcw-row' },
1677
+ h('span', { className: 'dcw-label' }, t('status.token')),
1678
+ h('span', null, tokenSource === 'settings' ? t('status.token.settings') : tokenSource === 'env' ? t('status.token.env') : tokenSource === 'gh' ? t('status.token.gh') : t('status.token.none')),
1679
+ ),
1680
+ )
1681
+ if (!tokenFound && status !== null) {
1682
+ statusRows.push(h('div', { key: 'tokenNote', className: 'dcw-muted' }, t('status.token.noneNote')))
1683
+ }
1684
+ statusRows.push(
1685
+ h(
1686
+ 'div',
1687
+ { key: 'login', className: 'dcw-row' },
1688
+ h('span', { className: 'dcw-label' }, t('status.login')),
1689
+ h('span', null, login === '' ? t('status.loggedOut') : login),
1690
+ ),
1691
+ )
1692
+ // VERIFY: `status.configured` is documented but its shape is not; only a
1693
+ // boolean is rendered, anything else is ignored rather than guessed at.
1694
+ if (status !== null && typeof status.configured === 'boolean') {
1695
+ statusRows.push(
1696
+ h(
1697
+ 'div',
1698
+ { key: 'configured', className: 'dcw-row' },
1699
+ h('span', { className: 'dcw-label' }, t('status.configured')),
1700
+ h('span', null, status.configured ? t('status.configured.yes') : t('status.configured.no')),
1701
+ ),
1702
+ )
1703
+ }
1704
+
1705
+ const secretSet = secretIsSet(snapshot.settings, status === null ? null : status.secrets, 'sshKeyPath')
1706
+
1707
+ const managed = snapshot.workspaces
1708
+ const managedCard = h(
1709
+ 'div',
1710
+ { className: 'dcw-card' },
1711
+ h(
1712
+ 'div',
1713
+ { className: 'dcw-row' },
1714
+ h('h3', { className: 'dcw-card-title' }, t('managed.heading')),
1715
+ h(
1716
+ Button,
1717
+ { onClick: () => ui.openCreate() },
1718
+ h(CloudIcon, { plus: true }),
1719
+ t('managed.new'),
1720
+ ),
1721
+ ),
1722
+ snapshot.workspacesError !== ''
1723
+ ? h('div', { className: 'dcw-error' }, t('apply.failed', { message: snapshot.workspacesError }))
1724
+ : null,
1725
+ !snapshot.workspacesReady
1726
+ ? h('div', { className: 'dcw-empty' }, t('managed.loading'))
1727
+ : managed.length === 0
1728
+ ? h('div', { className: 'dcw-empty' }, t('managed.empty'))
1729
+ : h(
1730
+ 'div',
1731
+ { className: 'dcw-list' },
1732
+ managed.map((record) =>
1733
+ h(
1734
+ 'div',
1735
+ { key: String(record.workspaceId), className: 'dcw-row', style: { padding: '4px 0' } },
1736
+ h(
1737
+ 'div',
1738
+ { className: 'dcw-stack' },
1739
+ h('span', { className: 'dcw-label' }, codespaceLabelOf(record)),
1740
+ h(
1741
+ 'span',
1742
+ { className: 'dcw-muted' },
1743
+ t('cs.state', { state: codespaceStateOf(record) === '' ? 'unknown' : codespaceStateOf(record) }),
1744
+ ),
1745
+ ),
1746
+ h(
1747
+ Button,
1748
+ {
1749
+ variant: 'danger',
1750
+ onClick: () =>
1751
+ ui.openDelete({
1752
+ workspaceId: record.workspaceId,
1753
+ codespaceName: codespaceNameOf(record),
1754
+ label: codespaceLabelOf(record),
1755
+ }),
1756
+ },
1757
+ t('managed.delete'),
1758
+ ),
1759
+ ),
1760
+ ),
1761
+ ),
1762
+ h('div', { className: 'dcw-muted' }, t('managed.deleteFallbackNote')),
1763
+ )
1764
+
1765
+ return h(
1766
+ 'div',
1767
+ { className: 'dcw-root' },
1768
+ h('h2', { className: 'dcw-title' }, t('page.title')),
1769
+ h('p', { className: 'dcw-sub' }, t('page.subtitle')),
1770
+
1771
+ h(
1772
+ 'div',
1773
+ { className: 'dcw-card' },
1774
+ h(
1775
+ 'div',
1776
+ { className: 'dcw-row' },
1777
+ h('h3', { className: 'dcw-card-title' }, t('status.heading')),
1778
+ h(Button, { onClick: loadStatus, disabled: statusBusy }, statusBusy ? t('status.checking') : t('status.refresh')),
1779
+ ),
1780
+ statusError === '' ? null : h('div', { className: 'dcw-error' }, t('status.failed', { message: statusError })),
1781
+ statusRows,
1782
+ ),
1783
+
1784
+ h(
1785
+ 'div',
1786
+ { className: 'dcw-card' },
1787
+ h('h3', { className: 'dcw-card-title' }, t('settings.heading')),
1788
+ draft === null
1789
+ ? h('div', { className: 'dcw-empty' }, t('managed.loading'))
1790
+ : h(
1791
+ React.Fragment,
1792
+ null,
1793
+ h(Field, {
1794
+ label: t('field.githubUsername'),
1795
+ hint: t('field.githubUsername.hint'),
1796
+ control: h('input', {
1797
+ className: 'dcw-input',
1798
+ type: 'text',
1799
+ value: draft.githubUsername === undefined || draft.githubUsername === null ? '' : String(draft.githubUsername),
1800
+ spellCheck: false,
1801
+ onChange: (event) => setField('githubUsername', event.target.value),
1802
+ }),
1803
+ }),
1804
+ h(Field, {
1805
+ label: t('field.defaultBranch'),
1806
+ control: h('input', {
1807
+ className: 'dcw-input',
1808
+ type: 'text',
1809
+ value: draft.defaultBranch === undefined || draft.defaultBranch === null ? '' : String(draft.defaultBranch),
1810
+ spellCheck: false,
1811
+ onChange: (event) => setField('defaultBranch', event.target.value),
1812
+ }),
1813
+ }),
1814
+ h(Field, {
1815
+ label: t('field.autoPauseMinutes'),
1816
+ hint: t('field.autoPauseMinutes.hint'),
1817
+ control: h('input', {
1818
+ className: 'dcw-input',
1819
+ type: 'number',
1820
+ min: '0',
1821
+ step: '1',
1822
+ value: String(num(draft.autoPauseMinutes) === Number.MAX_SAFE_INTEGER ? 0 : draft.autoPauseMinutes),
1823
+ onChange: (event) => {
1824
+ const parsed = Number.parseInt(event.target.value, 10)
1825
+ setField('autoPauseMinutes', Number.isFinite(parsed) && parsed > 0 ? parsed : 0)
1826
+ },
1827
+ }),
1828
+ }),
1829
+ h(ToggleRow, {
1830
+ label: t('field.alwaysShowCloudButton'),
1831
+ hint: t('field.alwaysShowCloudButton.hint'),
1832
+ control: h(Switch, {
1833
+ checked: draft.alwaysShowCloudButton === true,
1834
+ label: t('field.alwaysShowCloudButton'),
1835
+ onChange: (next) => setField('alwaysShowCloudButton', next),
1836
+ }),
1837
+ }),
1838
+ h(ToggleRow, {
1839
+ label: t('field.autoInitEmptyRepo'),
1840
+ hint: t('field.autoInitEmptyRepo.hint'),
1841
+ control: h(Switch, {
1842
+ checked: draft.autoInitEmptyRepo === true,
1843
+ label: t('field.autoInitEmptyRepo'),
1844
+ onChange: (next) => setField('autoInitEmptyRepo', next),
1845
+ }),
1846
+ }),
1847
+ h(Field, {
1848
+ label: t('field.readmeContent'),
1849
+ hint: t('field.readmeContent.hint'),
1850
+ control: h('textarea', {
1851
+ className: 'dcw-input',
1852
+ rows: 4,
1853
+ value: draft.readmeContent === undefined || draft.readmeContent === null ? '' : String(draft.readmeContent),
1854
+ onChange: (event) => setField('readmeContent', event.target.value),
1855
+ }),
1856
+ }),
1857
+ h(
1858
+ 'div',
1859
+ { className: 'dcw-actions' },
1860
+ h(Button, { variant: 'primary', onClick: commit, disabled: saving || !dirty }, saving ? t('saving') : t('save')),
1861
+ saved && !dirty ? h('span', { className: 'dcw-muted' }, t('saved')) : null,
1862
+ ),
1863
+ ),
1864
+ saveError === '' ? null : h('div', { className: 'dcw-error' }, t('saveFailed', { message: saveError })),
1865
+
1866
+ h('div', { className: 'dcw-divider' }),
1867
+ h(Field, {
1868
+ label: t('field.sshKeyPath'),
1869
+ hint: t('field.sshKeyPath.hint'),
1870
+ control: h(
1871
+ 'div',
1872
+ { className: 'dcw-actions' },
1873
+ h('span', { className: 'dcw-tag' }, secretSet ? t('field.sshKeyPath.set') : t('field.sshKeyPath.unset')),
1874
+ h('input', {
1875
+ className: 'dcw-input',
1876
+ style: { flex: 1, minWidth: 160 },
1877
+ type: 'password',
1878
+ autoComplete: 'off',
1879
+ placeholder: t('field.sshKeyPath.write'),
1880
+ value: secretDraft,
1881
+ onChange: (event) => setSecretDraft(event.target.value),
1882
+ }),
1883
+ h(Button, { onClick: () => writeSecret(secretDraft), disabled: secretBusy || secretDraft === '' }, t('field.sshKeyPath.save')),
1884
+ secretSet
1885
+ ? h(Button, { variant: 'ghost', onClick: () => writeSecret(''), disabled: secretBusy }, t('field.sshKeyPath.clear'))
1886
+ : null,
1887
+ ),
1888
+ }),
1889
+ ),
1890
+
1891
+ managedCard,
1892
+ )
1893
+ }
1894
+
1895
+ return SettingsSection
1896
+ }
1897
+
1898
+ /* ------------------------------------------------------------------ *
1899
+ * 9. The single multi-step window (create flow) and the delete confirm
1900
+ * ------------------------------------------------------------------ */
1901
+
1902
+ /**
1903
+ * The create flow. Internal steps only — `repo` → `codespace` → `machine` →
1904
+ * `apply` → `done`. Reused by the sidebar launcher and by the settings page.
1905
+ */
1906
+ function createNewWorkspaceWindow(deps) {
1907
+ const { store, ui, t, locale } = deps
1908
+
1909
+ function CreateFlow(props) {
1910
+ useLocaleTick(locale)
1911
+ const snapshot = useStoreState(store, false)
1912
+ const [step, setStep] = React.useState('repo')
1913
+ const [repos, setRepos] = React.useState(null)
1914
+ const [reposError, setReposError] = React.useState('')
1915
+ const [query, setQuery] = React.useState('')
1916
+ const [repo, setRepo] = React.useState(null)
1917
+ const [codespaces, setCodespaces] = React.useState(null)
1918
+ const [codespacesError, setCodespacesError] = React.useState('')
1919
+ const [selected, setSelected] = React.useState(null)
1920
+ const [createNew, setCreateNew] = React.useState(false)
1921
+ const [prebuild, setPrebuild] = React.useState(undefined)
1922
+ const [prebuildAcknowledged, setPrebuildAcknowledged] = React.useState(false)
1923
+ const [machines, setMachines] = React.useState(null)
1924
+ const [machinesError, setMachinesError] = React.useState('')
1925
+ const [machine, setMachine] = React.useState(null)
1926
+ const [stage, setStage] = React.useState('')
1927
+ const [failure, setFailure] = React.useState('')
1928
+ const [created, setCreated] = React.useState(null)
1929
+ const [opening, setOpening] = React.useState(false)
1930
+ const [openHint, setOpenHint] = React.useState('')
1931
+ const alive = React.useRef(true)
1932
+ const retryRef = React.useRef(null)
1933
+
1934
+ React.useEffect(() => {
1935
+ alive.current = true
1936
+ store.loadSettings().catch(() => {})
1937
+ return () => {
1938
+ alive.current = false
1939
+ if (retryRef.current !== null) clearTimeout(retryRef.current)
1940
+ }
1941
+ }, [])
1942
+
1943
+ const loadRepos = React.useCallback(() => {
1944
+ setRepos(null)
1945
+ setReposError('')
1946
+ rpc(ACTIONS.LIST_REPOS)
1947
+ .then((data) => {
1948
+ if (alive.current) setRepos(Array.isArray(data.repos) ? data.repos : [])
1949
+ })
1950
+ .catch((error) => {
1951
+ if (!alive.current) return
1952
+ setRepos([])
1953
+ setReposError(messageOf(error))
1954
+ })
1955
+ }, [])
1956
+
1957
+ React.useEffect(() => {
1958
+ loadRepos()
1959
+ }, [loadRepos])
1960
+
1961
+ const settings = snapshot.settings === null ? SETTINGS_DEFAULTS : snapshot.settings
1962
+ const branch = typeof settings.defaultBranch === 'string' && settings.defaultBranch !== '' ? settings.defaultBranch : 'main'
1963
+ const autoInit = settings.autoInitEmptyRepo === true
1964
+
1965
+ /** Step 2: resolve the Codespace for the chosen repository. */
1966
+ function chooseRepo(next) {
1967
+ setRepo(next)
1968
+ setSelected(null)
1969
+ setCreateNew(false)
1970
+ setCodespaces(null)
1971
+ setCodespacesError('')
1972
+ setStep('codespace')
1973
+ rpc(ACTIONS.LIST_CODESPACES, { repo: next.nameWithOwner })
1974
+ .then((data) => {
1975
+ if (!alive.current) return
1976
+ const list = Array.isArray(data.codespaces) ? data.codespaces : []
1977
+ setCodespaces(list)
1978
+ // Several → the user picks; exactly one → use it; none → create.
1979
+ if (list.length === 1) setSelected(list[0])
1980
+ if (list.length === 0) setCreateNew(true)
1981
+ })
1982
+ .catch((error) => {
1983
+ if (!alive.current) return
1984
+ setCodespaces([])
1985
+ setCodespacesError(messageOf(error))
1986
+ setCreateNew(true)
1987
+ })
1988
+ }
1989
+
1990
+ /** Step 3: machine choice, and the prebuild notice for a new Codespace. */
1991
+ function goToMachine() {
1992
+ setStep('machine')
1993
+ setPrebuild(undefined)
1994
+ setPrebuildAcknowledged(false)
1995
+ setMachines(null)
1996
+ setMachinesError('')
1997
+ setMachine(null)
1998
+ rpc(ACTIONS.PREBUILD, { repo: repo.nameWithOwner })
1999
+ .then((data) => {
2000
+ if (!alive.current) return
2001
+ // THREE states, not two. `hasPrebuild` is `true | false | null`, and
2002
+ // `null` means the plugin could not read the repository's machine
2003
+ // list at all. Collapsing that into `false` would report the
2004
+ // PLUGIN's failure as a fact about the user's repository ("未配置
2005
+ // 预构建"), which is a claim we have not earned.
2006
+ //
2007
+ // Note `false` genuinely means "no prebuild": measured across 15
2008
+ // repositories (23 machine rows), `prebuild_availability` only ever
2009
+ // comes back as `null` or `"ready"` — never `"none"` — so a
2010
+ // readable-but-null field really is an absent prebuild.
2011
+ setPrebuild(data.hasPrebuild === true ? true : data.hasPrebuild === false ? false : null)
2012
+ })
2013
+ .catch(() => {
2014
+ // A failed call is "unknown" too, for the same reason.
2015
+ if (alive.current) setPrebuild(null)
2016
+ })
2017
+ rpc(ACTIONS.MACHINES, { repo: repo.nameWithOwner })
2018
+ .then((data) => {
2019
+ if (!alive.current) return
2020
+ const list = Array.isArray(data.machines) ? data.machines : []
2021
+ setMachines(list)
2022
+ setMachine(smallestMachine(list))
2023
+ })
2024
+ .catch((error) => {
2025
+ if (!alive.current) return
2026
+ setMachines([])
2027
+ setMachinesError(messageOf(error))
2028
+ })
2029
+ }
2030
+
2031
+ /** Step 4: run the sequence, then register the DSH workspace. */
2032
+ async function apply() {
2033
+ setStep('apply')
2034
+ setFailure('')
2035
+ setCreated(null)
2036
+ try {
2037
+ let cs = createNew ? null : selected
2038
+ if (cs === null) {
2039
+ setStage(t('apply.creating'))
2040
+ const data = await rpc(ACTIONS.CREATE, {
2041
+ repo: repo.nameWithOwner,
2042
+ branch,
2043
+ machine: machine === null ? undefined : machine.name,
2044
+ })
2045
+ cs = data.codespace
2046
+ } else if (phaseOf({ codespace: cs }) === 'stopped' || phaseOf({ codespace: cs }) === 'error') {
2047
+ setStage(t('apply.starting'))
2048
+ const data = await rpc(ACTIONS.START, { name: codespaceNameOf({ codespace: cs }) })
2049
+ cs = data.codespace
2050
+ }
2051
+ if (!alive.current) return
2052
+ setStage(t('apply.registering'))
2053
+ // VERIFY: `create-workspace` takes `{ codespace, title? }`; the
2054
+ // Codespace NAME (a string) is sent, which is what every lifecycle
2055
+ // action in the same contract uses as its key.
2056
+ const registered = await rpc(ACTIONS.CREATE_WORKSPACE, { codespace: codespaceNameOf({ codespace: cs }) })
2057
+ if (!alive.current) return
2058
+ setCreated(registered.workspace === undefined ? null : registered.workspace)
2059
+ setStage('')
2060
+ setStep('done')
2061
+ store.refresh()
2062
+ } catch (error) {
2063
+ if (!alive.current) return
2064
+ setStage('')
2065
+ setFailure(messageOf(error))
2066
+ }
2067
+ }
2068
+
2069
+ /** "Open a session here": click the host's own new-session button on the
2070
+ * row. VERIFY: the workspace UI exposes no public open-session API to
2071
+ * other plugins, so the only honest route is the host's own control. The
2072
+ * row appears only after the sidebar re-renders, hence the bounded retry. */
2073
+ function openSession() {
2074
+ const workspaceId = created === null || created === undefined ? '' : String(created.workspaceId)
2075
+ setOpening(true)
2076
+ setOpenHint('')
2077
+ let attempts = 0
2078
+ const attempt = () => {
2079
+ if (!alive.current) return
2080
+ attempts += 1
2081
+ const row = workspaceId === '' ? null : document.querySelector('[data-row-key="workspace:' + workspaceId + '"]')
2082
+ const actions = row === null ? null : row.querySelector('[class*="_rowActions"]')
2083
+ const buttons = actions === null ? [] : actions.querySelectorAll('button')
2084
+ const target = buttons.length === 0 ? null : buttons[buttons.length - 1]
2085
+ if (target !== null) {
2086
+ target.click()
2087
+ setOpening(false)
2088
+ props.onClose()
2089
+ return
2090
+ }
2091
+ store.refresh()
2092
+ if (attempts >= 8) {
2093
+ setOpening(false)
2094
+ setOpenHint(t('success.openFailed'))
2095
+ return
2096
+ }
2097
+ retryRef.current = setTimeout(attempt, 500)
2098
+ }
2099
+ attempt()
2100
+ }
2101
+
2102
+ const filtered = React.useMemo(() => {
2103
+ const list = repos === null ? [] : repos
2104
+ const needle = query.trim().toLowerCase()
2105
+ if (needle === '') return list
2106
+ return list.filter((entry) => String(entry.nameWithOwner).toLowerCase().indexOf(needle) >= 0)
2107
+ }, [repos, query])
2108
+
2109
+ /* ---- step bodies ---- */
2110
+
2111
+ const head = h(
2112
+ 'div',
2113
+ { className: 'dcw-window-head' },
2114
+ h(
2115
+ 'div',
2116
+ { className: 'dcw-stack' },
2117
+ h('h2', { className: 'dcw-title' }, t('window.title')),
2118
+ h(Steps, { t, active: step === 'repo' ? 0 : step === 'codespace' ? 1 : step === 'machine' ? 2 : 3 }),
2119
+ ),
2120
+ h(Button, { variant: 'ghost', onClick: props.onClose, 'aria-label': t('close'), title: t('close') },
2121
+ h(Icon, { kind: 'close' })),
2122
+ )
2123
+
2124
+ if (step === 'repo') {
2125
+ return h(
2126
+ React.Fragment,
2127
+ null,
2128
+ head,
2129
+ h('input', {
2130
+ className: 'dcw-input',
2131
+ type: 'search',
2132
+ placeholder: t('repo.search'),
2133
+ value: query,
2134
+ onChange: (event) => setQuery(event.target.value),
2135
+ }),
2136
+ reposError === '' ? null : h('div', { className: 'dcw-error' }, t('repo.failed', { message: reposError })),
2137
+ repos === null
2138
+ ? h('div', { className: 'dcw-empty' }, t('repo.loading'))
2139
+ : filtered.length === 0
2140
+ ? h('div', { className: 'dcw-empty' }, t('repo.empty'))
2141
+ : h(
2142
+ 'div',
2143
+ { className: 'dcw-list' },
2144
+ filtered.map((entry) =>
2145
+ h(Choice, {
2146
+ key: String(entry.nameWithOwner),
2147
+ title: String(entry.nameWithOwner),
2148
+ tag: entry.isPrivate === true ? t('repo.private') : undefined,
2149
+ meta: [
2150
+ entry.isEmpty === true ? h('span', { key: 'e' }, t('repo.isEmpty')) : null,
2151
+ h('span', { key: 'b' }, String(entry.defaultBranch === undefined || entry.defaultBranch === null ? branch : entry.defaultBranch)),
2152
+ ].filter(Boolean),
2153
+ selected: false,
2154
+ onClick: () => chooseRepo(entry),
2155
+ }),
2156
+ ),
2157
+ ),
2158
+ h(
2159
+ 'div',
2160
+ { className: 'dcw-window-foot' },
2161
+ repos === null ? null : h('span', { className: 'dcw-muted' }, t('repo.count', { n: filtered.length })),
2162
+ h(Button, { onClick: props.onClose }, t('cancel')),
2163
+ ),
2164
+ )
2165
+ }
2166
+
2167
+ if (step === 'codespace') {
2168
+ const list = codespaces === null ? [] : codespaces
2169
+ const showNotice = !createNew && list.length === 0
2170
+ return h(
2171
+ React.Fragment,
2172
+ null,
2173
+ head,
2174
+ h('div', { className: 'dcw-label' }, String(repo === null ? '' : repo.nameWithOwner)),
2175
+ codespacesError === '' ? null : h('div', { className: 'dcw-error' }, t('cs.failed', { message: codespacesError })),
2176
+ codespaces === null
2177
+ ? h('div', { className: 'dcw-empty' }, t('cs.loading'))
2178
+ : h(
2179
+ React.Fragment,
2180
+ null,
2181
+ h('h3', { className: 'dcw-card-title' }, list.length === 0 ? t('cs.none') : list.length === 1 ? t('cs.heading') : t('cs.pick')),
2182
+ h(
2183
+ 'div',
2184
+ { className: 'dcw-list' },
2185
+ list.map((entry) =>
2186
+ h(Choice, {
2187
+ key: String(entry.name),
2188
+ title: String(entry.displayName === undefined || entry.displayName === null || entry.displayName === '' ? entry.name : entry.displayName),
2189
+ meta: [h('span', { key: 's' }, t('cs.state', { state: String(entry.state === undefined ? 'unknown' : entry.state) }))],
2190
+ detail: String(entry.name),
2191
+ selected: createNew ? false : selected !== null && selected.name === entry.name,
2192
+ onClick: () => {
2193
+ setCreateNew(false)
2194
+ setSelected(entry)
2195
+ },
2196
+ }),
2197
+ ),
2198
+ showNotice || createNew
2199
+ ? h(Choice, {
2200
+ title: t('cs.createNew'),
2201
+ selected: createNew,
2202
+ onClick: () => {
2203
+ setCreateNew(true)
2204
+ setSelected(null)
2205
+ },
2206
+ })
2207
+ : null,
2208
+ ),
2209
+ ),
2210
+ h(
2211
+ 'div',
2212
+ { className: 'dcw-window-foot' },
2213
+ h(Button, { onClick: () => setStep('repo') }, t('back')),
2214
+ h(Button, { onClick: props.onClose }, t('cancel')),
2215
+ h(
2216
+ Button,
2217
+ {
2218
+ variant: 'primary',
2219
+ disabled: codespaces === null || (!createNew && selected === null),
2220
+ onClick: () => (createNew ? goToMachine() : apply()),
2221
+ },
2222
+ t('next'),
2223
+ ),
2224
+ ),
2225
+ )
2226
+ }
2227
+
2228
+ if (step === 'machine') {
2229
+ const list = machines === null ? [] : machines
2230
+ /*
2231
+ * Four renderings, keyed off the tri-state `prebuild`:
2232
+ *
2233
+ * undefined still checking
2234
+ * null could not determine — say so, and say what it costs
2235
+ * false no prebuild — the notice with its two buttons
2236
+ * true a prebuild is ready
2237
+ *
2238
+ * `prebuildAcknowledged` only silences the `false` notice; it must NOT
2239
+ * fall through to the "has a prebuild" line, which would claim the
2240
+ * opposite of what the user was just told. That was a real bug: after
2241
+ * choosing 直接创建 the window said 该仓库已配置预构建。
2242
+ */
2243
+ let prebuildBlock
2244
+ /*
2245
+ * Whether the wizard must wait for the user to answer the notice.
2246
+ *
2247
+ * Only the "no prebuild" case asks a question (取消 / 直接创建), so only
2248
+ * that one blocks 下一步. "Unknown" shows a plain notice with nothing to
2249
+ * decide, and "has a prebuild" shows none at all — gating either of them
2250
+ * would strand the user on a step with no way forward.
2251
+ */
2252
+ const prebuildBlocksNext = prebuild === false && !prebuildAcknowledged
2253
+ if (prebuild === undefined) {
2254
+ prebuildBlock = h('div', { className: 'dcw-empty' }, t('prebuild.checking'))
2255
+ } else if (prebuild === true) {
2256
+ prebuildBlock = h('div', { className: 'dcw-notice' }, t('prebuild.ok'))
2257
+ } else if (prebuild === false && prebuildAcknowledged) {
2258
+ // Acknowledged: the notice has done its job, so show nothing rather
2259
+ // than assert something untrue.
2260
+ prebuildBlock = null
2261
+ } else if (prebuild === false) {
2262
+ prebuildBlock = h(
2263
+ 'div',
2264
+ { className: 'dcw-notice', 'data-tone': 'warn' },
2265
+ h('div', null, t('prebuild.missing')),
2266
+ h(
2267
+ 'div',
2268
+ { className: 'dcw-actions', style: { marginTop: 8 } },
2269
+ h(Button, { onClick: () => setStep('codespace') }, t('prebuild.cancel')),
2270
+ h(Button, { variant: 'primary', onClick: () => setPrebuildAcknowledged(true) }, t('prebuild.proceed')),
2271
+ ),
2272
+ )
2273
+ } else {
2274
+ // Unknown: no buttons, because there is nothing for the user to
2275
+ // decide — we simply do not know, and creation may take a while.
2276
+ prebuildBlock = h('div', { className: 'dcw-notice', 'data-tone': 'warn' }, t('prebuild.unknown'))
2277
+ }
2278
+ return h(
2279
+ React.Fragment,
2280
+ null,
2281
+ head,
2282
+ h('h3', { className: 'dcw-card-title' }, t('machine.heading')),
2283
+ repo !== null && repo.isEmpty === true ? h('div', { className: 'dcw-notice', 'data-tone': 'warn' }, autoInit ? t('seed.note') : t('seed.note.off')) : null,
2284
+ prebuildBlock,
2285
+ machinesError === '' ? null : h('div', { className: 'dcw-error' }, t('machine.failed', { message: machinesError })),
2286
+ machines === null
2287
+ ? h('div', { className: 'dcw-empty' }, t('machine.loading'))
2288
+ : list.length === 0
2289
+ ? h('div', { className: 'dcw-empty' }, t('machine.empty'))
2290
+ : h(
2291
+ 'div',
2292
+ { className: 'dcw-list' },
2293
+ list.map((entry) => {
2294
+ const isDefault = machine !== null && machine.name === entry.name
2295
+ const meta = []
2296
+ if (Number.isFinite(Number(entry.cpus))) meta.push(h('span', { key: 'c' }, t('machine.cpus', { n: entry.cpus })))
2297
+ if (Number.isFinite(Number(entry.memoryGb))) meta.push(h('span', { key: 'm' }, t('machine.memory', { n: entry.memoryGb })))
2298
+ if (Number.isFinite(Number(entry.storageGb))) meta.push(h('span', { key: 's' }, t('machine.storage', { n: entry.storageGb })))
2299
+ if (entry.gpu === true) meta.push(h('span', { key: 'g' }, t('machine.gpu')))
2300
+ return h(Choice, {
2301
+ key: String(entry.name),
2302
+ title: String(entry.displayName === undefined || entry.displayName === null || entry.displayName === '' ? entry.name : entry.displayName),
2303
+ tag: isDefault ? t('machine.default') : undefined,
2304
+ tagTone: isDefault ? 'business' : undefined,
2305
+ meta,
2306
+ detail: String(entry.name),
2307
+ selected: isDefault,
2308
+ onClick: () => setMachine(entry),
2309
+ })
2310
+ }),
2311
+ ),
2312
+ h(
2313
+ 'div',
2314
+ { className: 'dcw-window-foot' },
2315
+ h(Button, { onClick: () => setStep('codespace') }, t('back')),
2316
+ h(Button, { onClick: props.onClose }, t('cancel')),
2317
+ h(Button, { variant: 'primary', disabled: machine === null || prebuildBlocksNext, onClick: apply }, t('next')),
2318
+ ),
2319
+ )
2320
+ }
2321
+
2322
+ if (step === 'apply') {
2323
+ return h(
2324
+ React.Fragment,
2325
+ null,
2326
+ head,
2327
+ failure === ''
2328
+ ? h(
2329
+ 'div',
2330
+ { className: 'dcw-row' },
2331
+ h('span', { className: 'dcw-spin' }, h(Icon, { kind: 'loading', size: 16 })),
2332
+ h('span', null, stage === '' ? t('apply.creating') : stage),
2333
+ )
2334
+ : h(
2335
+ React.Fragment,
2336
+ null,
2337
+ h('div', { className: 'dcw-error' }, t('apply.failed', { message: failure })),
2338
+ h(
2339
+ 'div',
2340
+ { className: 'dcw-window-foot' },
2341
+ h(Button, { onClick: props.onClose }, t('cancel')),
2342
+ h(Button, { variant: 'primary', onClick: apply }, t('retry')),
2343
+ ),
2344
+ ),
2345
+ failure === '' && stage === t('apply.creating') ? h('div', { className: 'dcw-muted' }, t('apply.creatingHint')) : null,
2346
+ failure === ''
2347
+ ? h('div', { className: 'dcw-window-foot' }, h(Button, { onClick: props.onClose }, t('cancel')))
2348
+ : null,
2349
+ )
2350
+ }
2351
+
2352
+ return h(
2353
+ React.Fragment,
2354
+ null,
2355
+ head,
2356
+ h('h3', { className: 'dcw-card-title' }, t('success.title')),
2357
+ h('div', { className: 'dcw-muted' }, t('success.body')),
2358
+ created === null ? null : h('div', { className: 'dcw-mono' }, String(created.title === undefined ? codespaceLabelOf(created) : created.title)),
2359
+ openHint === '' ? null : h('div', { className: 'dcw-notice', 'data-tone': 'warn' }, openHint),
2360
+ h(
2361
+ 'div',
2362
+ { className: 'dcw-window-foot' },
2363
+ created === null ? null : h(Button, { onClick: openSession, disabled: opening }, opening ? t('success.opening') : t('success.open')),
2364
+ h(Button, { variant: 'primary', onClick: props.onClose }, t('success.done')),
2365
+ ),
2366
+ )
2367
+ }
2368
+
2369
+ return CreateFlow
2370
+ }
2371
+
2372
+ /** The irreversible delete, confirmed in our own overlay. */
2373
+ function createDeleteDialog(deps) {
2374
+ const { ui, store, t, locale } = deps
2375
+
2376
+ function DeleteDialog(props) {
2377
+ useLocaleTick(locale)
2378
+ const [busy, setBusy] = React.useState(false)
2379
+ const [error, setError] = React.useState('')
2380
+ const alive = React.useRef(true)
2381
+ React.useEffect(() => {
2382
+ alive.current = true
2383
+ return () => {
2384
+ alive.current = false
2385
+ }
2386
+ }, [])
2387
+
2388
+ const target = props.target
2389
+ function confirm() {
2390
+ if (target.codespaceName === '') {
2391
+ setError(t('delete.failed', { message: 'missing codespace name' }))
2392
+ return
2393
+ }
2394
+ setBusy(true)
2395
+ setError('')
2396
+ rpc(ACTIONS.REMOVE, { name: target.codespaceName })
2397
+ .then(() => {
2398
+ if (!alive.current) return
2399
+ setBusy(false)
2400
+ store.refresh()
2401
+ props.onClose()
2402
+ })
2403
+ .catch((failure) => {
2404
+ if (!alive.current) return
2405
+ setBusy(false)
2406
+ setError(t('delete.failed', { message: messageOf(failure) }))
2407
+ })
2408
+ }
2409
+
2410
+ return h(
2411
+ React.Fragment,
2412
+ null,
2413
+ h('h2', { className: 'dcw-title' }, t('delete.title')),
2414
+ h('div', { className: 'dcw-muted' }, t('delete.body', { name: target.label === '' ? target.codespaceName : target.label })),
2415
+ error === '' ? null : h('div', { className: 'dcw-error' }, error),
2416
+ h(
2417
+ 'div',
2418
+ { className: 'dcw-window-foot' },
2419
+ h(Button, { onClick: props.onClose, disabled: busy }, t('cancel')),
2420
+ h(Button, { variant: 'danger', onClick: confirm, disabled: busy }, busy ? t('delete.working') : t('delete.confirm')),
2421
+ ),
2422
+ )
2423
+ }
2424
+
2425
+ return DeleteDialog
2426
+ }
2427
+
2428
+ /* ------------------------------------------------------------------ *
2429
+ * 10. Workspace-row augmentation (the DOM patch)
2430
+ *
2431
+ * The workspace row has no slot (docs/CONTRACT.md §2 "verified negative
2432
+ * finding"), so this is the `dsh-pet` technique applied to the sidebar:
2433
+ * observe, inject our own tagged nodes, never touch a React-owned one.
2434
+ * ------------------------------------------------------------------ */
2435
+
2436
+ /** Marker attributes — everything this module adds is findable and undoable. */
2437
+ const ATTR = {
2438
+ row: 'data-dsh-codespace-row',
2439
+ always: 'data-dsh-codespace-always',
2440
+ icon: 'data-dsh-codespace-icon',
2441
+ action: 'data-dsh-codespace-action',
2442
+ glyph: 'data-dsh-codespace-glyph',
2443
+ glyphKind: 'data-dsh-codespace-glyph-kind',
2444
+ for: 'data-dsh-codespace-for',
2445
+ path: 'data-dsh-codespace-path',
2446
+ original: 'data-dsh-codespace-original',
2447
+ menu: 'data-dsh-codespace-menu',
2448
+ menuItem: 'data-dsh-codespace-menu-item',
2449
+ menuSeparator: 'data-dsh-codespace-menu-separator',
2450
+ header: 'data-dsh-codespace-header',
2451
+ launcher: 'data-dsh-codespace-launcher',
2452
+ tip: 'data-dsh-codespace-tip',
2453
+ style: 'data-dsh-codespace-style',
2454
+ /** Marks a HOST node this module hid, and records its previous `style`. */
2455
+ hidden: 'data-dsh-codespace-hidden',
2456
+ }
2457
+
2458
+ /** `data-row-key` prefix used by the workspace row (observed at L1287). */
2459
+ const ROW_KEY_PREFIX = 'workspace:'
2460
+
2461
+ /**
2462
+ * Class-name suffix of the row's leading folder slot.
2463
+ *
2464
+ * Matched on the suffix because the module prefix is build-generated
2465
+ * (`hIlkoa_folder` today, a different hash in the next build) — the same
2466
+ * approach already used for `_sectionHeader` / `_rowActions`.
2467
+ */
2468
+ const ROW_FOLDER_CLASS = '_folder'
2469
+
2470
+ /** Set while this module is mutating the DOM, so the observer can ignore itself. */
2471
+ let mutating = false
2472
+
2473
+ /** Run `fn` with the self-mutation guard up, reporting whether it changed anything. */
2474
+ function mutate(fn) {
2475
+ mutating = true
2476
+ try {
2477
+ fn()
2478
+ } finally {
2479
+ mutating = false
2480
+ }
2481
+ }
2482
+
2483
+ const SVG_NS = 'http://www.w3.org/2000/svg'
2484
+
2485
+ /**
2486
+ * Build one stroked DSH-spec icon from a spec in {@link ACTION_ICONS}.
2487
+ *
2488
+ * One implementation for every glyph this module draws by hand: the 16px box,
2489
+ * the `Regular` weight (1px, on the `<svg>` and never on a path), the
2490
+ * `fill="none"` + `stroke="currentColor"` pair, and the per-artwork `viewBox`
2491
+ * all live here so a new glyph cannot quietly differ from its neighbours.
2492
+ *
2493
+ * @param {{viewBox: string, paths: string[], linecap?: string, linejoin?: string}} spec - the artwork.
2494
+ * @returns {SVGElement} the glyph.
2495
+ */
2496
+ function iconNode(spec) {
2497
+ const svg = document.createElementNS(SVG_NS, 'svg')
2498
+ svg.setAttribute('width', '16')
2499
+ svg.setAttribute('height', '16')
2500
+ svg.setAttribute('viewBox', spec.viewBox)
2501
+ svg.setAttribute('fill', 'none')
2502
+ svg.setAttribute('xmlns', SVG_NS)
2503
+ svg.setAttribute('aria-hidden', 'true')
2504
+ // The host puts `stroke-width` on the `<svg>`, not on each path.
2505
+ svg.setAttribute('stroke-width', ICON_STROKE_WIDTH)
2506
+ if (spec.linecap !== undefined) svg.setAttribute('stroke-linecap', spec.linecap)
2507
+ if (spec.linejoin !== undefined) svg.setAttribute('stroke-linejoin', spec.linejoin)
2508
+ for (const d of spec.paths) {
2509
+ const path = document.createElementNS(SVG_NS, 'path')
2510
+ path.setAttribute('d', d)
2511
+ path.setAttribute('stroke', 'currentColor')
2512
+ svg.appendChild(path)
2513
+ }
2514
+ return svg
2515
+ }
2516
+
2517
+ /**
2518
+ * Inline SVG cloud built by hand (the row and its controls are not
2519
+ * React-rendered by us).
2520
+ *
2521
+ * @param {{plus?: boolean}} [props] - `plus` mirrors {@link CloudIcon}'s.
2522
+ * @returns {SVGElement} the glyph, to the same spec `CloudIcon` emits.
2523
+ */
2524
+ function cloudNode(props) {
2525
+ const NS = SVG_NS
2526
+ const withPlus = props !== undefined && props.plus === true
2527
+ const svg = document.createElementNS(NS, 'svg')
2528
+ // The same spec as `CloudIcon`, so the two renderers cannot drift.
2529
+ svg.setAttribute('width', '16')
2530
+ svg.setAttribute('height', '16')
2531
+ svg.setAttribute('viewBox', '0 0 16 16')
2532
+ svg.setAttribute('fill', 'none')
2533
+ svg.setAttribute('xmlns', NS)
2534
+ svg.setAttribute('aria-hidden', 'true')
2535
+ svg.setAttribute('stroke-width', ICON_STROKE_WIDTH)
2536
+ if (withPlus) {
2537
+ // Same composition as `CloudIcon`: the full-size outline drawn through a
2538
+ // mask that knocks out the band around each plus stroke.
2539
+ const maskId = 'dcw-cloud-add-' + String(cloudMaskSeq++)
2540
+ const defs = document.createElementNS(NS, 'defs')
2541
+ const mask = document.createElementNS(NS, 'mask')
2542
+ mask.setAttribute('id', maskId)
2543
+ mask.setAttribute('maskUnits', 'userSpaceOnUse')
2544
+ mask.setAttribute('x', '0')
2545
+ mask.setAttribute('y', '0')
2546
+ mask.setAttribute('width', '16')
2547
+ mask.setAttribute('height', '16')
2548
+ const plate = document.createElementNS(NS, 'rect')
2549
+ plate.setAttribute('x', '0')
2550
+ plate.setAttribute('y', '0')
2551
+ plate.setAttribute('width', '16')
2552
+ plate.setAttribute('height', '16')
2553
+ plate.setAttribute('fill', '#fff')
2554
+ mask.appendChild(plate)
2555
+ for (const d of PLUS_PATHS) {
2556
+ const cut = document.createElementNS(NS, 'path')
2557
+ cut.setAttribute('d', d)
2558
+ cut.setAttribute('stroke', '#000')
2559
+ cut.setAttribute('stroke-width', CLOUD_MASK_BAND)
2560
+ cut.setAttribute('fill', 'none')
2561
+ cut.setAttribute('stroke-linecap', 'butt')
2562
+ mask.appendChild(cut)
2563
+ }
2564
+ defs.appendChild(mask)
2565
+ svg.appendChild(defs)
2566
+ const cloud = document.createElementNS(NS, 'path')
2567
+ cloud.setAttribute('d', CLOUD_PATH)
2568
+ cloud.setAttribute('stroke', 'currentColor')
2569
+ cloud.setAttribute('mask', 'url(#' + maskId + ')')
2570
+ svg.appendChild(cloud)
2571
+ for (const d of PLUS_PATHS) {
2572
+ const plus = document.createElementNS(NS, 'path')
2573
+ plus.setAttribute('d', d)
2574
+ plus.setAttribute('stroke', 'currentColor')
2575
+ svg.appendChild(plus)
2576
+ }
2577
+ return svg
2578
+ }
2579
+ const path = document.createElementNS(NS, 'path')
2580
+ path.setAttribute('d', CLOUD_PATH)
2581
+ path.setAttribute('stroke', 'currentColor')
2582
+ svg.appendChild(path)
2583
+ return svg
2584
+ }
2585
+
2586
+ /**
2587
+ * Attach a DSH-styled tooltip to one element.
2588
+ *
2589
+ * The host's `Tooltip` is a React component and cannot be reused here; this
2590
+ * copies its *rendered result* — a fixed-position `<span role="tooltip">`
2591
+ * styled by the same rules (see the tooltip block in `CSS`). The native
2592
+ * `title` attribute is deliberately not used, because an OS bubble looks
2593
+ * nothing like the DSH one this button sits beside.
2594
+ *
2595
+ * The bubble is appended to `document.body`, never to the button's own
2596
+ * container: DSH's non-portal tooltip lands as a SIBLING inside
2597
+ * `headerActions`, which is what made the launcher appear to the right of the
2598
+ * "+" (see `patchHeader`). Keeping it out of that container removes the
2599
+ * problem at the source.
2600
+ *
2601
+ * VERIFY: the host also flips side when the bubble would overflow the
2602
+ * viewport bottom. Tolerant branch: `bottom` is used unconditionally, which
2603
+ * is what the host's own "+" requests (`side: "bottom"`), and `aria-label`
2604
+ * stays on the button so the accessible name exists with or without it.
2605
+ *
2606
+ * @param {HTMLElement} element - the anchor.
2607
+ * @param {string} label - bubble text.
2608
+ * @returns {() => void} a disposer removing the bubble and its listeners.
2609
+ */
2610
+ function attachTooltip(element, label) {
2611
+ // Resolve `document` locally. This function is module-scoped while `doc` is
2612
+ // a local of `installRowAugmentation`, and `typeof someUndeclaredName`
2613
+ // returns 'undefined' WITHOUT throwing -- so an earlier revision that
2614
+ // tested `typeof doc` here silently returned a no-op: no error, no bubble,
2615
+ // and a passing-looking UI.
2616
+ const ownerDocument = typeof document === 'undefined' ? null : document
2617
+ if (ownerDocument === null || element === null) return () => {}
2618
+ const win = typeof window === 'undefined' ? null : window
2619
+ let bubble = null
2620
+ let timer = null
2621
+
2622
+ const place = () => {
2623
+ if (bubble === null || win === null) return
2624
+ const anchor = element.getBoundingClientRect()
2625
+ const box = bubble.getBoundingClientRect()
2626
+ const edge = 12
2627
+ const left = Math.max(edge, Math.min(anchor.left + anchor.width / 2 - box.width / 2, win.innerWidth - edge - box.width))
2628
+ bubble.style.left = String(left) + 'px'
2629
+ bubble.style.top = String(anchor.bottom + 8) + 'px'
2630
+ bubble.style.visibility = 'visible'
2631
+ }
2632
+
2633
+ const show = () => {
2634
+ if (bubble !== null) return
2635
+ bubble = ownerDocument.createElement('span')
2636
+ bubble.setAttribute(ATTR.tip, '')
2637
+ bubble.setAttribute('data-side', 'bottom')
2638
+ bubble.setAttribute('role', 'tooltip')
2639
+ bubble.style.visibility = 'hidden'
2640
+ const text = ownerDocument.createElement('span')
2641
+ text.textContent = label
2642
+ bubble.appendChild(text)
2643
+ ownerDocument.body.appendChild(bubble)
2644
+ place()
2645
+ }
2646
+
2647
+ const hide = () => {
2648
+ if (timer !== null) {
2649
+ clearTimeout(timer)
2650
+ timer = null
2651
+ }
2652
+ if (bubble === null) return
2653
+ bubble.remove()
2654
+ bubble = null
2655
+ }
2656
+
2657
+ const onEnter = () => {
2658
+ if (timer !== null) clearTimeout(timer)
2659
+ // The host's header controls use `delayMs: 500`.
2660
+ timer = setTimeout(() => {
2661
+ timer = null
2662
+ show()
2663
+ }, TOOLTIP_DELAY_MS)
2664
+ }
2665
+ const onLeave = () => hide()
2666
+ const onFocus = () => show()
2667
+ const onBlur = () => hide()
2668
+
2669
+ element.addEventListener('mouseenter', onEnter)
2670
+ element.addEventListener('mouseleave', onLeave)
2671
+ element.addEventListener('focus', onFocus, true)
2672
+ element.addEventListener('blur', onBlur, true)
2673
+ if (win !== null && typeof win.addEventListener === 'function') {
2674
+ win.addEventListener('resize', place)
2675
+ win.addEventListener('scroll', place, true)
2676
+ }
2677
+
2678
+ return () => {
2679
+ element.removeEventListener('mouseenter', onEnter)
2680
+ element.removeEventListener('mouseleave', onLeave)
2681
+ element.removeEventListener('focus', onFocus, true)
2682
+ element.removeEventListener('blur', onBlur, true)
2683
+ if (win !== null && typeof win.removeEventListener === 'function') {
2684
+ win.removeEventListener('resize', place)
2685
+ win.removeEventListener('scroll', place, true)
2686
+ }
2687
+ hide()
2688
+ }
2689
+ }
2690
+
2691
+ /**
2692
+ * Install the row augmentation.
2693
+ *
2694
+ * @param {object} deps - `{ store, ui, t }`.
2695
+ * @returns {() => void} the disposer: removes every node, attribute and observer.
2696
+ */
2697
+ function installRowAugmentation(deps) {
2698
+ const { store, ui, t } = deps
2699
+ const doc = typeof document === 'undefined' ? undefined : document
2700
+ const Observer = typeof MutationObserver === 'undefined' ? undefined : MutationObserver
2701
+
2702
+ /**
2703
+ * Live tooltips this module attached: `element -> { label, dispose }`.
2704
+ *
2705
+ * Tracked (rather than fire-and-forget) because the row button is
2706
+ * re-pointed whenever its label changes — 启动 / 暂停 / 还剩 X 分钟自动暂停 —
2707
+ * and because every bubble must be released on dispose: an orphaned one
2708
+ * would outlive the plugin and stay stranded on `document.body`.
2709
+ */
2710
+ const tips = new Map()
2711
+
2712
+ /** Attach once per label; a repaint with the same text is a no-op. */
2713
+ function pointTooltip(element, label) {
2714
+ const current = tips.get(element)
2715
+ if (current !== undefined && current.label === label) return
2716
+ if (current !== undefined) {
2717
+ current.dispose()
2718
+ tips.delete(element)
2719
+ }
2720
+ tips.set(element, { label, dispose: attachTooltip(element, label) })
2721
+ }
2722
+
2723
+ /** Release every tracked tooltip (dispose path). */
2724
+ function dropTooltips() {
2725
+ for (const entry of tips.values()) {
2726
+ try {
2727
+ entry.dispose()
2728
+ } catch {
2729
+ /* teardown must not throw */
2730
+ }
2731
+ }
2732
+ tips.clear()
2733
+ }
2734
+
2735
+ /**
2736
+ * The host control the launcher sits immediately before.
2737
+ *
2738
+ * Remembered rather than re-derived, so a later pass cannot decide that
2739
+ * some other control is "last" and move the launcher past the "+" (see
2740
+ * `patchHeader`). Cleared whenever the launcher is removed.
2741
+ */
2742
+ let headerAnchor = null
2743
+
2744
+ /**
2745
+ * The host's last real `<button>` in the icon row.
2746
+ *
2747
+ * Filters on tag name, not on child order: a `Tooltip` bubble is an open
2748
+ * `<span role="tooltip">` SIBLING in this same container, so "last child"
2749
+ * is not the same thing as "last control".
2750
+ *
2751
+ * @param {Element} actions - the icon row.
2752
+ * @param {Element|null} exclude - a node to ignore (our own launcher).
2753
+ * @returns {Element|null} the control, or null when there is none.
2754
+ */
2755
+ function lastHostButton(actions, exclude) {
2756
+ const buttons = Array.from(actions.children)
2757
+ .filter((el) => el !== exclude && el.tagName === 'BUTTON')
2758
+ return buttons.length === 0 ? null : buttons[buttons.length - 1]
2759
+ }
2760
+ if (doc === undefined || doc.body === null) {
2761
+ console.warn('[dsh-codespace-workspace] sidebar augmentation unavailable: no document.body')
2762
+ return () => {}
2763
+ }
2764
+
2765
+ /** Workspace ids with an in-flight click, so the button shows a spinner. */
2766
+ const pending = new Set()
2767
+ /** Deferred single clicks, keyed by workspace id (a double-click cancels one). */
2768
+ const clickTimers = new Map()
2769
+ let warnedMissingActions = false
2770
+ let queued = false
2771
+ let observer = null
2772
+ let active = true
2773
+
2774
+ /** Records by workspace id, from the last poll. */
2775
+ function managedIndex() {
2776
+ const list = store.getState().workspaces
2777
+ const map = new Map()
2778
+ if (Array.isArray(list)) {
2779
+ for (const record of list) {
2780
+ if (record !== null && typeof record === 'object' && record.workspaceId !== undefined) {
2781
+ map.set(String(record.workspaceId), record)
2782
+ }
2783
+ }
2784
+ }
2785
+ return map
2786
+ }
2787
+
2788
+ /** The workspace id a row stands for, or `null` when the row is not ours. */
2789
+ function workspaceIdOfRow(row) {
2790
+ const key = row.getAttribute('data-row-key')
2791
+ if (typeof key !== 'string' || key.indexOf(ROW_KEY_PREFIX) !== 0) return null
2792
+ const id = key.slice(ROW_KEY_PREFIX.length)
2793
+ return id === '' ? null : id
2794
+ }
2795
+
2796
+ /** Paint the button for the record's current state. */
2797
+ function paintButton(button, record) {
2798
+ const phase = phaseOf(record)
2799
+ // Two different "busy" notions. `pending` (an RPC in flight, or the host
2800
+ // reporting one) disables the control. `armed` (a single click waiting out
2801
+ // the double-click window) only shows the spinner: disabling the button
2802
+ // there would swallow the second click and with it the double-click.
2803
+ const inFlight = isBusy(record, pending)
2804
+ const armed = clickTimers.has(button.getAttribute(ATTR.for))
2805
+ const busy = inFlight || armed
2806
+ const remaining = countdownMsOf(record)
2807
+ let kind
2808
+ let label
2809
+ if (busy) {
2810
+ kind = 'pending'
2811
+ label = t('row.pending')
2812
+ } else if (phase === 'running' && remaining > 0) {
2813
+ kind = 'countdown'
2814
+ const minutes = Math.max(1, Math.ceil(remaining / 60000))
2815
+ label = t('row.countdown', { n: minutes })
2816
+ } else if (phase === 'running') {
2817
+ kind = 'stop'
2818
+ label = t('row.stop')
2819
+ } else if (phase === 'error') {
2820
+ kind = 'error'
2821
+ label = t('row.error', { state: codespaceStateOf(record) })
2822
+ } else {
2823
+ kind = 'start'
2824
+ label = t('row.start')
2825
+ }
2826
+
2827
+ const previous = button.getAttribute(ATTR.glyphKind)
2828
+ if (previous !== kind) {
2829
+ button.setAttribute(ATTR.glyphKind, kind)
2830
+ while (button.firstChild !== null) button.removeChild(button.firstChild)
2831
+ if (kind === 'pending') {
2832
+ const spinner = doc.createElement('span')
2833
+ spinner.className = 'dcw-spin'
2834
+ spinner.setAttribute('aria-hidden', 'true')
2835
+ spinner.appendChild(iconNode(ACTION_ICONS.loading))
2836
+ button.appendChild(spinner)
2837
+ } else {
2838
+ // A hand-drawn DSH icon, NOT an emoji: the two buttons beside this one
2839
+ // (the row menu and "new session") are `Icon*OutlineRegular`, so an
2840
+ // emoji here reads as a different kind of object — see ACTION_ICONS.
2841
+ const glyph = doc.createElement('span')
2842
+ glyph.setAttribute(ATTR.glyph, '')
2843
+ glyph.setAttribute('aria-hidden', 'true')
2844
+ glyph.appendChild(iconNode(ACTION_ICONS[kind]))
2845
+ button.appendChild(glyph)
2846
+ }
2847
+ }
2848
+ button.disabled = inFlight
2849
+ button.setAttribute('aria-label', label)
2850
+ pointTooltip(button, label)
2851
+ }
2852
+
2853
+ /** One lifecycle click; the button is disabled while it is in flight. */
2854
+ function act(workspaceId, record, action) {
2855
+ const name = codespaceNameOf(record)
2856
+ if (name === '') return
2857
+ pending.add(workspaceId)
2858
+ repaint(workspaceId)
2859
+ rpc(action, { name })
2860
+ .then(() => {
2861
+ pending.delete(workspaceId)
2862
+ store.refresh()
2863
+ repaint(workspaceId)
2864
+ })
2865
+ .catch((error) => {
2866
+ pending.delete(workspaceId)
2867
+ console.warn('[dsh-codespace-workspace] ' + action + ' failed: ' + messageOf(error))
2868
+ repaint(workspaceId)
2869
+ })
2870
+ }
2871
+
2872
+ /**
2873
+ * Open the double-click window for one workspace: show the spinner, and
2874
+ * when the window closes without a second click, perform the single-click
2875
+ * action.
2876
+ *
2877
+ * The action is re-derived from the CURRENT record rather than captured
2878
+ * when the window opened, because the poll can land in between (the
2879
+ * Codespace may have reached `Available`, or a countdown may have started)
2880
+ * and acting on the stale intent would be wrong. A record that has since
2881
+ * become busy is dropped: something else is already driving it.
2882
+ */
2883
+ function beginClickWindow(workspaceId) {
2884
+ repaint(workspaceId)
2885
+ return setTimeout(() => {
2886
+ clickTimers.delete(workspaceId)
2887
+ const current = managedIndex().get(workspaceId)
2888
+ if (current === undefined || isBusy(current, pending)) {
2889
+ repaint(workspaceId)
2890
+ return
2891
+ }
2892
+ const intent = clickIntent(current)
2893
+ if (intent === null || intent.defer !== true) {
2894
+ repaint(workspaceId)
2895
+ return
2896
+ }
2897
+ act(workspaceId, current, intent.action)
2898
+ }, CLICK_WINDOW_MS)
2899
+ }
2900
+
2901
+ /**
2902
+ * What one click on the row button means in the record's current state.
2903
+ *
2904
+ * `defer` is a flag, not a duration: a running Codespace's button carries
2905
+ * two meanings (one click pauses, two pause immediately), so its action
2906
+ * waits out `CLICK_WINDOW_MS` for a possible second click. Everything else
2907
+ * acts at once. The deferred action is re-derived when the window closes
2908
+ * (see `beginClickWindow`), so it is the flag — not a captured action —
2909
+ * that has to survive.
2910
+ *
2911
+ * @returns {{ action: string, defer: boolean } | null}
2912
+ */
2913
+ function clickIntent(record) {
2914
+ const phase = phaseOf(record)
2915
+ if (phase === 'running') {
2916
+ if (countdownMsOf(record) > 0) return { action: ACTIONS.PAUSE_CANCEL, defer: false }
2917
+ return { action: ACTIONS.STOP, defer: true }
2918
+ }
2919
+ if (phase === 'stopped' || phase === 'error') return { action: ACTIONS.START, defer: false }
2920
+ return null
2921
+ }
2922
+
2923
+ /** Re-read the record and repaint just that row's button. */
2924
+ function repaint(workspaceId) {
2925
+ const record = managedIndex().get(workspaceId)
2926
+ const row = doc.querySelector('[data-row-key="' + ROW_KEY_PREFIX + workspaceId + '"]')
2927
+ if (record === undefined || row === null) return
2928
+ const button = row.querySelector('[' + ATTR.action + ']')
2929
+ if (button === null) return
2930
+ mutate(() => paintButton(button, record))
2931
+ }
2932
+
2933
+ /**
2934
+ * Undo everything {@link patchRow} added to one row.
2935
+ *
2936
+ * Two halves, and the second is easy to forget: our own tagged nodes go
2937
+ * away, AND every HOST node we hid has to be given its `style` back. Without
2938
+ * that second half a workspace we stop managing would keep an invisible
2939
+ * folder glyph — the row would come back with no leading icon at all, which
2940
+ * is a worse bug than the one this file is fixing.
2941
+ *
2942
+ * @param {Element} row - the workspace row.
2943
+ */
2944
+ function unpatchRow(row) {
2945
+ const icon = row.querySelector('[' + ATTR.icon + ']')
2946
+ if (icon !== null && icon.parentNode !== null) icon.parentNode.removeChild(icon)
2947
+ const button = row.querySelector('[' + ATTR.action + ']')
2948
+ if (button !== null && button.parentNode !== null) button.parentNode.removeChild(button)
2949
+ for (const host of row.querySelectorAll('[' + ATTR.hidden + ']')) {
2950
+ const original = host.getAttribute(ATTR.hidden) ?? ''
2951
+ if (original === '') host.removeAttribute('style')
2952
+ else host.setAttribute('style', original)
2953
+ host.removeAttribute(ATTR.hidden)
2954
+ }
2955
+ row.removeAttribute(ATTR.row)
2956
+ row.removeAttribute(ATTR.always)
2957
+ }
2958
+
2959
+ /**
2960
+ * Patch one managed row.
2961
+ *
2962
+ * OBSERVED ORDER (dsh-client-ui-workspace/lib/client.js `ProjectRowItem`,
2963
+ * ≈L1314–1359): the `rowActions` container holds exactly two children —
2964
+ * `[ Menu(ellipsis → rename/delete), Tooltip > button(new session) ]`. The
2965
+ * ellipsis is therefore the FIRST child and the new-session button the LAST,
2966
+ * so "third from last, before the ellipsis" means: insert as the new first
2967
+ * child of `rowActions`. The container is checked to hold exactly two
2968
+ * elements first; a row that does not match is skipped untouched.
2969
+ */
2970
+ function patchRow(row, workspaceId, record) {
2971
+ mutate(() => {
2972
+ if (row.getAttribute(ATTR.row) === null) row.setAttribute(ATTR.row, '')
2973
+
2974
+ // (a) Leading marker: REPLACE the host's folder glyph, in place.
2975
+ //
2976
+ // OBSERVED (Rows module CSS, `dsh-client-ui-workspace/lib/client.js`):
2977
+ //
2978
+ // .hIlkoa_projectRow .hIlkoa_chevron{display:none}
2979
+ // .hIlkoa_projectRow:hover .hIlkoa_chevron{display:inline-flex}
2980
+ // .hIlkoa_projectRow:hover .hIlkoa_folder{display:none}
2981
+ //
2982
+ // so the folder is VISIBLE in the resting state and only steps aside,
2983
+ // on hover, for the chevron. (There is no unconditional
2984
+ // `.hIlkoa_folder{display:none}` — an earlier revision of this file
2985
+ // misread the hover rule as such and concluded there was no visible
2986
+ // folder to replace, then added its own marker to the left of it. That
2987
+ // is exactly the double icon this now removes.)
2988
+ //
2989
+ // The marker therefore goes INSIDE the host's own
2990
+ // `span.slot.folder` — the row's first child — with the host's own
2991
+ // `<svg>` hidden. Nothing is moved or deleted, so the `.slot` geometry
2992
+ // (16x20, centred, `--dsw-alias-label-tertiary`) and the hover swap are
2993
+ // inherited rather than re-implemented, and the cloud lands exactly
2994
+ // where the folder was.
2995
+ // Scoped to `span[class*="_slot"]` so a future `.…_folderSomething`
2996
+ // element elsewhere in the row cannot be picked up by mistake.
2997
+ const folder = row.querySelector('span[class*="' + ROW_FOLDER_CLASS + '"]')
2998
+ if (folder !== null) {
2999
+ // Hide the folder artwork, remembering how it looked. The folder swaps
3000
+ // between its closed and open drawings when a workspace is expanded,
3001
+ // so React replaces this node underneath us: `sync()` re-runs this on
3002
+ // every mutation and re-hides whatever is there now. Ours is skipped —
3003
+ // it is the one child that must stay visible.
3004
+ for (const child of folder.children) {
3005
+ if (child.getAttribute(ATTR.icon) !== null) continue
3006
+ if (child.getAttribute(ATTR.hidden) !== null) continue
3007
+ child.setAttribute(ATTR.hidden, child.getAttribute('style') ?? '')
3008
+ child.style.display = 'none'
3009
+ }
3010
+ if (folder.querySelector('[' + ATTR.icon + ']') === null) {
3011
+ const icon = doc.createElement('span')
3012
+ icon.setAttribute(ATTR.icon, '')
3013
+ // Decorative: the row itself already carries the workspace name, so
3014
+ // the marker stays out of the accessibility tree while still
3015
+ // explaining itself visually on hover.
3016
+ icon.setAttribute('aria-hidden', 'true')
3017
+ // The host's `.slot` is `display:inline-flex` with centred content;
3018
+ // this wrapper mirrors that so the glyph is centred like the folder
3019
+ // was, without touching the host's own class names.
3020
+ icon.style.display = 'inline-flex'
3021
+ icon.style.alignItems = 'center'
3022
+ icon.style.justifyContent = 'center'
3023
+ icon.appendChild(cloudNode())
3024
+ folder.appendChild(icon)
3025
+ pointTooltip(icon, t('row.cloud'))
3026
+ }
3027
+ }
3028
+
3029
+ // (b) The action button, first child of `rowActions`.
3030
+ const actions = row.querySelector('[class*="' + ROW_ACTIONS_SUFFIX + '"]')
3031
+ if (actions === null) {
3032
+ if (!warnedMissingActions) {
3033
+ warnedMissingActions = true
3034
+ console.warn('[dsh-codespace-workspace] no [class*="' + ROW_ACTIONS_SUFFIX + '"] container in the workspace row; the row button is not installed')
3035
+ }
3036
+ return
3037
+ }
3038
+ let button = actions.querySelector(':scope > [' + ATTR.action + ']')
3039
+ // The guard counts the HOST's controls only: once ours is in place the
3040
+ // container legitimately holds three children, and re-checking against
3041
+ // two would skip every later repaint of that row.
3042
+ const hostChildren = actions.children.length - (button === null ? 0 : 1)
3043
+ if (hostChildren !== 2) {
3044
+ // Not the layout this build was written against; leave the row alone.
3045
+ return
3046
+ }
3047
+ if (button === null) {
3048
+ button = doc.createElement('button')
3049
+ button.type = 'button'
3050
+ button.setAttribute(ATTR.action, '')
3051
+ // Click behaviour. A single click is deliberately deferred for a
3052
+ // running Codespace (see `clickIntent`), so the first `click` of a
3053
+ // double-click only arms the spinner and the second cancels the timer
3054
+ // and pauses immediately instead. Cancelling a running countdown is
3055
+ // immediate, because a double-click there has nothing else to mean.
3056
+ button.addEventListener('click', (event) => {
3057
+ event.stopPropagation()
3058
+ event.preventDefault()
3059
+ const id = button.getAttribute(ATTR.for)
3060
+ const current = managedIndex().get(id)
3061
+ if (current === undefined) return
3062
+ const intent = clickIntent(current)
3063
+ if (intent === null) return
3064
+ if (intent.defer !== true) {
3065
+ act(id, current, intent.action)
3066
+ return
3067
+ }
3068
+ if (clickTimers.has(id)) return
3069
+ clickTimers.set(id, beginClickWindow(id))
3070
+ repaint(id)
3071
+ })
3072
+ button.addEventListener('dblclick', (event) => {
3073
+ event.stopPropagation()
3074
+ event.preventDefault()
3075
+ const id = button.getAttribute(ATTR.for)
3076
+ const current = managedIndex().get(id)
3077
+ if (current === undefined) return
3078
+ // "Double-click the clock" (a running row) means pause it NOW.
3079
+ // The guard is the phase, NOT the remaining countdown: the first
3080
+ // click of this double-click has already fired `pause-cancel` when
3081
+ // a countdown was running (that action is immediate by design), so
3082
+ // by the time this handler runs the remaining time can already be
3083
+ // zero — guarding on it would silently drop the double-click.
3084
+ if (phaseOf(current) !== 'running') return
3085
+ const timer = clickTimers.get(id)
3086
+ if (timer !== undefined) {
3087
+ clearTimeout(timer)
3088
+ clickTimers.delete(id)
3089
+ }
3090
+ act(id, current, ACTIONS.PAUSE_NOW)
3091
+ })
3092
+ actions.insertBefore(button, actions.firstElementChild)
3093
+ }
3094
+ button.setAttribute(ATTR.for, workspaceId)
3095
+ paintButton(button, record)
3096
+
3097
+ // (c) `alwaysShowCloudButton`. OBSERVED: `rowActions` is `display:none`
3098
+ // unless the row is hovered or carries the `menuOpen` class, and a
3099
+ // `display:none` ancestor cannot be overridden from a descendant — so the
3100
+ // container is revealed, not just our button. The marker attribute is on
3101
+ // the row, so only managed rows are affected.
3102
+ const always = store.getState().settings !== null && store.getState().settings.alwaysShowCloudButton === true
3103
+ if (always) {
3104
+ if (row.getAttribute(ATTR.always) === null) row.setAttribute(ATTR.always, '')
3105
+ } else if (row.getAttribute(ATTR.always) !== null) {
3106
+ row.removeAttribute(ATTR.always)
3107
+ }
3108
+ })
3109
+ }
3110
+
3111
+ /**
3112
+ * Rewrite the hover card's location line.
3113
+ *
3114
+ * The card is a portal on `document.body`, so it has no row ancestor to read
3115
+ * an id from. VERIFY: it is matched by its location text instead — the raw
3116
+ * `Managed.path` (normalized, trailing slash removed, and either side of a
3117
+ * `~` home abbreviation) first, then, only when exactly one managed record
3118
+ * carries that DSH title, by the card's title line. This is not row identity
3119
+ * (the row still matches on `data-row-key`); it only decides which card shows
3120
+ * which Codespace. Unmatched cards are left exactly as the host drew them.
3121
+ * Only the text of the location element is rewritten; the node, its class and
3122
+ * its neighbours are untouched, and the original text is stashed so the
3123
+ * disposer can put it back.
3124
+ *
3125
+ * The line carries the two facts the ROW TITLE cannot: the repository's full
3126
+ * `owner/name` (the title is only its short name) and the Codespace's own
3127
+ * machine-generated name. Only the text is rewritten rather than adding a
3128
+ * line, because every extra node inside the card is a React-owned node this
3129
+ * module would have to track and restore.
3130
+ */
3131
+ function patchHoverCard(card, records) {
3132
+ const pathEl = card.querySelector('[class*="' + HOVER_PATH_SUFFIX + '"]')
3133
+ if (pathEl === null) return
3134
+ const raw = pathEl.textContent === null ? '' : pathEl.textContent
3135
+ const record = matchRecordByPath(raw, records) || matchRecordByTitle(card, records)
3136
+ if (record === null) return
3137
+ const wanted = hoverLocationOf(record)
3138
+ if (wanted === '') return
3139
+ mutate(() => {
3140
+ if (pathEl.getAttribute(ATTR.original) === null) pathEl.setAttribute(ATTR.original, raw)
3141
+ if (pathEl.getAttribute(ATTR.path) === null) pathEl.setAttribute(ATTR.path, '')
3142
+ if (pathEl.textContent !== wanted) pathEl.textContent = wanted
3143
+ })
3144
+ }
3145
+
3146
+ /**
3147
+ * What the hover card's location line should say.
3148
+ *
3149
+ * `owner/name · codespace-name` once the repository is known, and the
3150
+ * Codespace label alone while it is not (a Codespace still provisioning
3151
+ * reports an empty repository) — never an empty line.
3152
+ *
3153
+ * @param {object} record - a `Managed` record.
3154
+ * @returns {string} the line, or `''` when nothing is known.
3155
+ */
3156
+ function hoverLocationOf(record) {
3157
+ const repo = repositoryOf(record)
3158
+ const name = codespaceNameOf(record)
3159
+ const label = codespaceLabelOf(record)
3160
+ if (repo === '') return label
3161
+ return t('row.hoverRepo', { repo, name: name === '' ? label : name })
3162
+ }
3163
+
3164
+ /** Normalize a displayed path for comparison. */
3165
+ function normalizePath(text) {
3166
+ return String(text === null || text === undefined ? '' : text).trim().replace(/\\/g, '/').replace(/\/+$/, '')
3167
+ }
3168
+
3169
+ /** Match a hover-card location line against the managed records. */
3170
+ function matchRecordByPath(text, records) {
3171
+ const shown = normalizePath(text)
3172
+ if (shown === '') return null
3173
+ const bare = shown.charAt(0) === '~' ? shown.slice(1) : shown
3174
+ for (const record of records) {
3175
+ const path = normalizePath(record.path)
3176
+ if (path === '') continue
3177
+ if (path === shown || path === bare) return record
3178
+ if (bare.charAt(0) === '/' && path.length > bare.length && path.slice(path.length - bare.length) === bare) return record
3179
+ }
3180
+ return null
3181
+ }
3182
+
3183
+ /** Fallback: a title that is unique among the managed records. */
3184
+ function matchRecordByTitle(card, records) {
3185
+ const titleEl = card.querySelector('[class*="_hoverTitle"]')
3186
+ if (titleEl === null) return null
3187
+ const title = String(titleEl.textContent === null ? '' : titleEl.textContent).trim()
3188
+ if (title === '') return null
3189
+ let found = null
3190
+ for (const record of records) {
3191
+ if (String(record.title === undefined ? '' : record.title).trim() !== title) continue
3192
+ if (found !== null) return null
3193
+ found = record
3194
+ }
3195
+ return found
3196
+ }
3197
+
3198
+ /**
3199
+ * Append our row to the host's portaled workspace menu.
3200
+ *
3201
+ * OBSERVED: the row whose menu is open carries the CSS-module class
3202
+ * `.<hash>_menuOpen` (`clsx(projectRow, menuOpen && Rows_module_css_default.menuOpen)`
3203
+ * at L1286, and the same class in the reveal rule), and the open list is a
3204
+ * `role="menu"` element portaled onto `document.body` by the Menu primitive.
3205
+ * Requiring an open `_menuOpen` row is what ties the menu to *our* row rather
3206
+ * than to any other menu on the page. The host's own rows are never modified;
3207
+ * our row is appended after them and separated by a hairline.
3208
+ *
3209
+ * VERIFY: if a future build stops marking the open row with `_menuOpen`, no
3210
+ * menu is patched. The documented fallback is the settings page's "Cloud
3211
+ * workspaces" list, whose 删除 Codespace… button opens the same confirmation
3212
+ * overlay — the note under that list says so.
3213
+ */
3214
+ function patchMenu(records) {
3215
+ const openRow = doc.querySelector('[data-row-key^="' + ROW_KEY_PREFIX + '"][class*="' + MENU_OPEN_SUFFIX + '"]')
3216
+ if (openRow === null) return
3217
+ const workspaceId = workspaceIdOfRow(openRow)
3218
+ if (workspaceId === null) return
3219
+ const record = records.get(workspaceId)
3220
+ if (record === undefined) return
3221
+ const menus = doc.querySelectorAll('[role="menu"]')
3222
+ if (menus.length === 0) return
3223
+ const menu = menus[menus.length - 1]
3224
+ if (menu.getAttribute(ATTR.menu) !== null) return
3225
+ const name = codespaceNameOf(record)
3226
+ if (name === '') return
3227
+ mutate(() => {
3228
+ menu.setAttribute(ATTR.menu, '')
3229
+ const separator = doc.createElement('div')
3230
+ separator.setAttribute(ATTR.menuSeparator, '')
3231
+ separator.setAttribute('role', 'separator')
3232
+ const item = doc.createElement('button')
3233
+ item.type = 'button'
3234
+ item.setAttribute('role', 'menuitem')
3235
+ item.setAttribute(ATTR.menuItem, '')
3236
+ item.textContent = t('menu.delete')
3237
+ item.addEventListener('click', (event) => {
3238
+ event.stopPropagation()
3239
+ event.preventDefault()
3240
+ // Close the host's menu first (Escape is the menu's own close
3241
+ // command), then raise our confirmation overlay.
3242
+ doc.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
3243
+ ui.openDelete({ workspaceId, codespaceName: name, label: codespaceLabelOf(record) })
3244
+ })
3245
+ menu.appendChild(separator)
3246
+ menu.appendChild(item)
3247
+ })
3248
+ }
3249
+
3250
+ /** Remove everything this module injected into a menu. */
3251
+ function cleanMenu(menu) {
3252
+ mutate(() => {
3253
+ const item = menu.querySelector('[' + ATTR.menuItem + ']')
3254
+ if (item !== null && item.parentNode !== null) item.parentNode.removeChild(item)
3255
+ const separator = menu.querySelector('[' + ATTR.menuSeparator + ']')
3256
+ if (separator !== null && separator.parentNode !== null) separator.parentNode.removeChild(separator)
3257
+ menu.removeAttribute(ATTR.menu)
3258
+ })
3259
+ }
3260
+
3261
+ /**
3262
+ * Install the launcher into the workspace section's header icon row.
3263
+ *
3264
+ * OBSERVED (dsh-client-ui-workspace/lib/client.js, the WorkspaceBrowser
3265
+ * component ≈L150370): the section header is
3266
+ *
3267
+ * <div class="<hash>_sectionHeader">
3268
+ * {wide && <span class="<hash>_sectionLabel">…</span>}
3269
+ * {wide && <div class="<hash>_searchSlot">…</div>}
3270
+ * <div class="<hash>_headerActions"> <-- gap:4px, max-width:60px
3271
+ * {wide && <ViewOptionsMenu/>} <-- Tooltip > button.iconButton
3272
+ * {flowAvailable && <Tooltip> <-- button.iconButton, the "+"
3273
+ * <button class="<hash>_iconButton" aria-label="添加工作区"/>
3274
+ * </Tooltip>}
3275
+ * </div>
3276
+ * </div>
3277
+ *
3278
+ * The user's requirement is that the new-cloud-workspace control sits in
3279
+ * that row as a PEER of those icons, immediately LEFT of 添加工作区, rather
3280
+ * than as a separate control elsewhere. So it is inserted as a child of
3281
+ * `headerActions`, immediately before the host's own last control.
3282
+ *
3283
+ * The row is `justify-content:flex-end` with a `flex:1` search slot ahead of
3284
+ * it, so the group is right-aligned and our button takes the position that
3285
+ * puts it first in that right-hand cluster. It is a real `<button>` styled by
3286
+ * `[data-dsh-codespace-launcher]` to the host's `iconButton` metrics, so it
3287
+ * inherits the hover, focus and rail behaviour of its peers.
3288
+ *
3289
+ * A row whose last child is not the "+" control is left untouched: that only
3290
+ * happens when the directory flow is unavailable (then the host renders no
3291
+ * "+" at all), and in that case the settings page's own button still opens
3292
+ * the same window.
3293
+ *
3294
+ * @returns {boolean} whether a launcher is in place.
3295
+ */
3296
+ function patchHeader() {
3297
+ const headers = doc.querySelectorAll('[class*="' + SECTION_HEADER_SUFFIX + '"]')
3298
+ if (headers.length === 0) return false
3299
+ // The sidebar can show more than one section header (sessions vs
3300
+ // workspaces); only the one carrying the icon row is ours.
3301
+ let header = null
3302
+ for (const candidate of headers) {
3303
+ if (candidate.querySelector('[class*="' + HEADER_ACTIONS_SUFFIX + '"]') !== null) {
3304
+ header = candidate
3305
+ break
3306
+ }
3307
+ }
3308
+ if (header === null) return false
3309
+ const actions = header.querySelector('[class*="' + HEADER_ACTIONS_SUFFIX + '"]')
3310
+ if (actions === null) return false
3311
+
3312
+ const existing = actions.querySelector(':scope > [' + ATTR.launcher + ']')
3313
+ if (existing !== null) {
3314
+ if (existing.parentNode !== actions) return true
3315
+ // Keep the launcher immediately before the SAME host control it was
3316
+ // anchored to, rather than re-deriving "the last control" every pass.
3317
+ //
3318
+ // Re-deriving is what produced the drift. The host's `Tooltip` is not
3319
+ // portalled (the workspace header passes no `portal` prop), so while a
3320
+ // bubble is open React renders it as a SIBLING `<span role="tooltip">`
3321
+ // inside this same container; a later host control would make "last
3322
+ // button" something other than the "+", and the launcher would be moved
3323
+ // to sit in front of THAT -- i.e. to the right of 添加工作区. It also
3324
+ // could not self-repair, because in the broken state the launcher's own
3325
+ // next sibling was the thing being looked for.
3326
+ //
3327
+ // Holding the anchor makes every later pass a no-op unless the anchor
3328
+ // itself goes away. Only then is it re-derived, from the host's last
3329
+ // real button (never a tooltip bubble, which is not a BUTTON).
3330
+ if (headerAnchor !== null && headerAnchor.isConnected && headerAnchor.parentNode === actions) {
3331
+ if (existing.nextElementSibling !== headerAnchor) {
3332
+ mutate(() => actions.insertBefore(existing, headerAnchor))
3333
+ }
3334
+ return true
3335
+ }
3336
+ headerAnchor = lastHostButton(actions, existing)
3337
+ if (headerAnchor !== null && existing.nextElementSibling !== headerAnchor) {
3338
+ mutate(() => actions.insertBefore(existing, headerAnchor))
3339
+ }
3340
+ return true
3341
+ }
3342
+
3343
+ const addButton = lastHostButton(actions, null)
3344
+ if (addButton === null) return false
3345
+
3346
+ mutate(() => {
3347
+ const button = doc.createElement('button')
3348
+ button.type = 'button'
3349
+ button.setAttribute(ATTR.launcher, '')
3350
+ button.setAttribute('aria-label', t('launcher.label'))
3351
+ // `role="img"`-style markup only; the accessible name lives on the button.
3352
+ button.appendChild(cloudNode({ plus: true }))
3353
+ // A DSH-shaped bubble, NOT `title`: the OS bubble looks nothing like the
3354
+ // one on the neighbouring host controls.
3355
+ pointTooltip(button, t('launcher.label'))
3356
+ button.addEventListener('click', (event) => {
3357
+ event.stopPropagation()
3358
+ event.preventDefault()
3359
+ ui.openCreate()
3360
+ })
3361
+ actions.insertBefore(button, addButton)
3362
+ headerAnchor = addButton
3363
+ // Marks the container so the CSS above can widen it past the host's
3364
+ // `max-width:60px` without matching any other header.
3365
+ actions.setAttribute(ATTR.header, '')
3366
+ })
3367
+ return true
3368
+ }
3369
+
3370
+ /** Remove the header launcher and its marker. */
3371
+ function cleanHeader() {
3372
+ for (const launcher of doc.querySelectorAll('[' + ATTR.launcher + ']')) {
3373
+ if (launcher.parentNode !== null) launcher.parentNode.removeChild(launcher)
3374
+ }
3375
+ for (const marked of doc.querySelectorAll('[' + ATTR.header + ']')) marked.removeAttribute(ATTR.header)
3376
+ headerAnchor = null
3377
+ }
3378
+
3379
+ /** One coalesced pass over the DOM. Idempotent: it re-derives everything. */
3380
+ function sync() {
3381
+ if (!active) return
3382
+ const records = managedIndex()
3383
+
3384
+ // The header launcher is independent of any managed workspace: it is how
3385
+ // the first cloud workspace gets created, so it is installed even when
3386
+ // the record list is empty.
3387
+ setOwned(patchHeader())
3388
+
3389
+ // Rows: drop the marker from rows that are no longer managed.
3390
+ for (const row of doc.querySelectorAll('[' + ATTR.row + ']')) {
3391
+ const id = workspaceIdOfRow(row)
3392
+ if (id === null || !records.has(id)) {
3393
+ mutate(() => {
3394
+ unpatchRow(row)
3395
+ })
3396
+ }
3397
+ }
3398
+
3399
+ if (records.size > 0) {
3400
+ for (const row of doc.querySelectorAll('[data-row-key^="' + ROW_KEY_PREFIX + '"]')) {
3401
+ const id = workspaceIdOfRow(row)
3402
+ if (id === null) continue
3403
+ const record = records.get(id)
3404
+ if (record === undefined) continue
3405
+ patchRow(row, id, record)
3406
+ }
3407
+ }
3408
+
3409
+ // Hover cards live in a portal on document.body, outside any row.
3410
+ const list = Array.from(records.values())
3411
+ if (list.length > 0) {
3412
+ for (const card of doc.querySelectorAll('[class*="_hoverContent"]')) patchHoverCard(card, list)
3413
+ }
3414
+
3415
+ // The portaled workspace menu.
3416
+ for (const menu of doc.querySelectorAll('[' + ATTR.menu + ']')) {
3417
+ const openRow = doc.querySelector('[data-row-key^="' + ROW_KEY_PREFIX + '"][class*="' + MENU_OPEN_SUFFIX + '"]')
3418
+ const stillOpen = openRow !== null && records.has(String(workspaceIdOfRow(openRow)))
3419
+ if (!stillOpen || menu.isConnected !== true) cleanMenu(menu)
3420
+ }
3421
+ if (records.size > 0) patchMenu(records)
3422
+ }
3423
+
3424
+ /** Coalesce every mutation burst into one microtask. */
3425
+ function queue() {
3426
+ if (!active || queued) return
3427
+ queued = true
3428
+ queueMicrotask(() => {
3429
+ queued = false
3430
+ if (!active) return
3431
+ sync()
3432
+ // Guard against reacting to our own writes: the pass above just made
3433
+ // every record that is still pending either our own injection or a
3434
+ // mutation this pass has already accounted for, so they are dropped.
3435
+ // A later burst — the next poll, the next hover, the next React commit —
3436
+ // arrives through `queue()` again.
3437
+ if (observer !== null) observer.takeRecords()
3438
+ })
3439
+ }
3440
+
3441
+ // The store publishes on every poll and after every mutation, so the
3442
+ // augmentation re-derives from fresh host data without polling itself.
3443
+ const unsubscribe = store.subscribe(() => queue(), false)
3444
+
3445
+ /**
3446
+ * The store's owner claim (docs/CONTRACT.md §6): `subscribe(listener, true)`
3447
+ * makes the first claimer trigger an immediate refresh and the last one to
3448
+ * leave stop the 4 s `workspaces` poll.
3449
+ *
3450
+ * It is held only while the workspace section is actually rendered. The
3451
+ * poll is not free — with at least one managed workspace every tick calls
3452
+ * the Codespaces list API — so claiming it from `apply()` would keep
3453
+ * hitting GitHub even with the sidebar hidden. Keying on the section
3454
+ * header reproduces the lifetime the sidebar-mounted launcher used to
3455
+ * have, without depending on a control that may not exist. It is
3456
+ * deliberately not keyed on the record list: the records are what the
3457
+ * poll produces, so that would be circular.
3458
+ */
3459
+ let claim = null
3460
+ function setOwned(wanted) {
3461
+ if (wanted === true && claim === null) {
3462
+ claim = store.subscribe(() => {}, true)
3463
+ // The row patch reads `alwaysShowCloudButton` out of the store, so the
3464
+ // settings have to be loaded by whoever renders the rows. Without this
3465
+ // the value stays `null` until the settings page is opened, and the
3466
+ // switch silently has no effect on a fresh boot. `loadSettings` is
3467
+ // memoized on `settingsLoaded`, so this costs one request per session
3468
+ // at most, and a failure is not fatal — the row simply keeps the
3469
+ // default (hidden) behaviour.
3470
+ store.loadSettings().catch(() => {})
3471
+ } else if (wanted !== true && claim !== null) {
3472
+ claim()
3473
+ claim = null
3474
+ }
3475
+ }
3476
+
3477
+ sync()
3478
+ if (Observer !== undefined) {
3479
+ observer = new Observer(() => {
3480
+ if (mutating) return
3481
+ queue()
3482
+ })
3483
+ // VERIFY: the hover card is portaled onto document.body, so the observer
3484
+ // must cover the whole body rather than only the sidebar subtree. This is
3485
+ // the same scope dsh-rewind-plugin uses. The callback is coalesced, the
3486
+ // pass is idempotent, and its own records are drained, so the wider scope
3487
+ // costs work but cannot loop.
3488
+ observer.observe(doc.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-row-key', 'class'] })
3489
+ }
3490
+
3491
+ return () => {
3492
+ active = false
3493
+ if (observer !== null) {
3494
+ observer.disconnect()
3495
+ observer = null
3496
+ }
3497
+ unsubscribe()
3498
+ for (const timer of clickTimers.values()) clearTimeout(timer)
3499
+ clickTimers.clear()
3500
+ cleanHeader()
3501
+ for (const row of doc.querySelectorAll('[' + ATTR.row + ']')) unpatchRow(row)
3502
+ // Belt and braces: a hidden host node whose row lost its marker some other
3503
+ // way still has to get its `style` back before this module unloads.
3504
+ for (const host of doc.querySelectorAll('[' + ATTR.hidden + ']')) {
3505
+ const original = host.getAttribute(ATTR.hidden) ?? ''
3506
+ if (original === '') host.removeAttribute('style')
3507
+ else host.setAttribute('style', original)
3508
+ host.removeAttribute(ATTR.hidden)
3509
+ }
3510
+ for (const menu of doc.querySelectorAll('[' + ATTR.menu + ']')) cleanMenu(menu)
3511
+ for (const pathEl of doc.querySelectorAll('[' + ATTR.path + ']')) {
3512
+ const original = pathEl.getAttribute(ATTR.original)
3513
+ if (original !== null && pathEl.textContent !== original) pathEl.textContent = original
3514
+ pathEl.removeAttribute(ATTR.path)
3515
+ pathEl.removeAttribute(ATTR.original)
3516
+ }
3517
+ pending.clear()
3518
+ dropTooltips()
3519
+ }
3520
+ }
3521
+
3522
+ /* ------------------------------------------------------------------ *
3523
+ * 11. apply()
3524
+ * ------------------------------------------------------------------ */
3525
+
3526
+ /**
3527
+ * Register every surface. All resources are owned by `ctx.effect`, so plugin
3528
+ * unload removes the styles, the observers, the timers and the slots.
3529
+ *
3530
+ * @param {object} ctx - the Cordis client context.
3531
+ */
3532
+ function apply(ctx) {
3533
+ const slots = ctx.get('slots')
3534
+ if (slots === undefined) return
3535
+ const locale = ctx.get('locale')
3536
+
3537
+ if (locale !== undefined && typeof locale.register === 'function') {
3538
+ try {
3539
+ ctx.effect(() => {
3540
+ // VERIFY: `locale.register(ns, {zh, en})` and the older
3541
+ // `locale.register(ns, lang, dict)` are both accepted by the builds in
3542
+ // this profile (dsh-pet/dsh-keep-awake use one, dsh-mcp-native the
3543
+ // other). The object form is tried first and the two-argument form is
3544
+ // the fallback.
3545
+ let dispose = null
3546
+ try {
3547
+ dispose = locale.register(LOCALE_NS, { zh: MESSAGES_ZH, en: MESSAGES_EN })
3548
+ } catch (error) {
3549
+ const first = locale.register(LOCALE_NS, 'zh', MESSAGES_ZH)
3550
+ const second = locale.register(LOCALE_NS, 'en', MESSAGES_EN)
3551
+ dispose = () => {
3552
+ first()
3553
+ second()
3554
+ }
3555
+ }
3556
+ return () => {
3557
+ if (typeof dispose === 'function') dispose()
3558
+ }
3559
+ }, 'dsh-codespace-workspace: dictionaries')
3560
+ } catch (error) {
3561
+ console.warn('[dsh-codespace-workspace] locale registration failed; using the inline zh dictionary', error)
3562
+ }
3563
+ }
3564
+
3565
+ const t =
3566
+ locale !== undefined && typeof locale.bind === 'function'
3567
+ ? locale.bind(LOCALE_NS)
3568
+ : (key, params) => interpolate(MESSAGES_ZH[key] !== undefined ? MESSAGES_ZH[key] : key, params)
3569
+
3570
+ ctx.effect(() => {
3571
+ const style = document.createElement('style')
3572
+ style.setAttribute(ATTR.style, '')
3573
+ style.textContent = CSS
3574
+ document.head.appendChild(style)
3575
+ return () => {
3576
+ style.remove()
3577
+ }
3578
+ }, 'dsh-codespace-workspace: styles')
3579
+
3580
+ const store = createStore(rpc)
3581
+ const ui = createUiStore()
3582
+ ctx.effect(
3583
+ () => () => {
3584
+ store.dispose()
3585
+ ui.dispose()
3586
+ },
3587
+ 'dsh-codespace-workspace: stores',
3588
+ )
3589
+
3590
+ const SettingsSection = createSettingsSection({ store, ui, t, locale })
3591
+ const CreateFlow = createNewWorkspaceWindow({ store, ui, t, locale })
3592
+ const DeleteDialog = createDeleteDialog({ store, ui, t, locale })
3593
+
3594
+ ctx.slots.inject('settings.section', () =>
3595
+ ctx.slots.register(
3596
+ {
3597
+ name: 'settings.section',
3598
+ id: 'dsh-codespace-workspace',
3599
+ order: 70,
3600
+ label: () => t('nav'),
3601
+ },
3602
+ SettingsSection,
3603
+ ),
3604
+ )
3605
+
3606
+ ctx.slots.inject('shell.overlay', () =>
3607
+ ctx.slots.register(
3608
+ {
3609
+ name: 'shell.overlay',
3610
+ id: 'dsh-codespace-workspace',
3611
+ order: 900,
3612
+ },
3613
+ function CodespaceOverlay() {
3614
+ useLocaleTick(locale)
3615
+ const state = useStoreState(ui, false)
3616
+ if (state.del !== null) {
3617
+ return h(
3618
+ OverlayWindow,
3619
+ { label: t('delete.title'), onClose: () => ui.close() },
3620
+ h(DeleteDialog, { key: 'del' + String(state.del.seq), target: state.del, onClose: () => ui.close() }),
3621
+ )
3622
+ }
3623
+ if (state.create !== null) {
3624
+ return h(
3625
+ OverlayWindow,
3626
+ { label: t('window.title'), onClose: () => ui.close() },
3627
+ h(CreateFlow, { key: 'create' + String(state.create.seq), onClose: () => ui.close() }),
3628
+ )
3629
+ }
3630
+ return null
3631
+ },
3632
+ ),
3633
+ )
3634
+
3635
+ // No `sidebar.footer.action` entry: the launcher lives in the workspace
3636
+ // section's own header icon row, as a peer of the host's controls (see
3637
+ // `patchHeader`). The store's owner claim — which is what keeps the 4 s
3638
+ // `workspaces` poll alive (docs/CONTRACT.md §6) — is held by the row
3639
+ // augmentation instead, so the data the sidebar needs is owned by the
3640
+ // module that actually renders it, not by a control that may not exist.
3641
+ ctx.effect(() => installRowAugmentation({ store, ui, t }), 'dsh-codespace-workspace: sidebar row augmentation')
3642
+ }
3643
+
3644
+ module.exports.apply = apply
3645
+ module.exports.inject = ['slots']
3646
+ return module.exports
3647
+ },
3648
+ })