dsh-codespace-workspace 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js ADDED
@@ -0,0 +1,3598 @@
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
+
546
+ /** Hover delay before a tooltip appears — the host's own header controls use 500ms. */
547
+ const TOOLTIP_DELAY_MS = 500
548
+
549
+ /**
550
+ * The plus strokes of the host's `IconProjectAddOutlineRegular`, verbatim.
551
+ *
552
+ * Copied rather than redrawn so the "add" mark is pixel-identical to the one
553
+ * on the host's own "+" button. It occupies x 9.76..14.55, y 2.11..6.90.
554
+ */
555
+ const PLUS_PATHS = [
556
+ 'M9.75977 4.50208H14.5509',
557
+ 'M12.1492 6.89758L12.1492 2.10642',
558
+ ]
559
+
560
+ /**
561
+ * The "add" cloud: the SAME full-size outline, broken where the plus crosses.
562
+ *
563
+ * This is how the host's own `IconProjectAddOutlineRegular` composes its two
564
+ * glyphs — the folder keeps its full size and its outline is simply
565
+ * interrupted around the plus — so the mark reads as one icon rather than as a
566
+ * shape with a badge floating beside it. An earlier revision instead shrank
567
+ * the cloud into the lower-left to dodge the plus; this gets the same result
568
+ * without giving up any size.
569
+ *
570
+ * The break is made with an SVG `<mask>`: a white 16x16 rect, then each plus
571
+ * stroke painted black at {@link CLOUD_MASK_BAND} width, and the cloud drawn
572
+ * through that mask. Cutting the outline by hand would mean re-deriving the
573
+ * path at two intersections — the plus's vertical stroke crosses the cloud's
574
+ * right lobe at `y = 9.667 - sqrt(3^2 - (12.1492-11.666)^2) = 6.706`, and its
575
+ * horizontal stroke's *band* also clips the big arc near `x = 9.76` — and
576
+ * keeping those numbers in step with the artwork forever. The mask expresses
577
+ * the intent directly and cannot drift.
578
+ *
579
+ * The band is the gap width: it is painted with `stroke-linecap="butt"` so it
580
+ * stops exactly at each stroke's ends. Round caps would extend half a band
581
+ * past the plus's lower tip and could bite a detached sliver out of the lobe.
582
+ */
583
+ const CLOUD_MASK_BAND = '2.6'
584
+
585
+ /**
586
+ * Source of unique mask ids.
587
+ *
588
+ * Ids are document-global, so two `CloudIcon`s on one page must not share one:
589
+ * the browser resolves `url(#id)` to the first match, which would silently
590
+ * make the second icon use the first one's mask.
591
+ */
592
+ let cloudMaskSeq = 0
593
+
594
+ const CSS = [
595
+ /* ---- shared page chrome ---- */
596
+ '.dcw-root{font-size:13px;line-height:1.6;color:var(--dsw-alias-label-primary);max-width:880px}',
597
+ '.dcw-root *{box-sizing:border-box}',
598
+ '.dcw-title{font-size:20px;font-weight:600;line-height:1.3;margin:0}',
599
+ '.dcw-sub{font-size:12px;color:var(--dsw-alias-label-secondary);margin:4px 0 0}',
600
+ '.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)}',
601
+ '.dcw-card-title{font-size:15px;font-weight:600;line-height:1.4;margin:0}',
602
+ '.dcw-row{display:flex;align-items:center;justify-content:space-between;gap:12px}',
603
+ '.dcw-row-start{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}',
604
+ '.dcw-stack{display:flex;flex-direction:column;gap:4px;min-width:0;flex:1}',
605
+ '.dcw-label{font-size:13px;font-weight:500;line-height:1.5}',
606
+ '.dcw-muted{color:var(--dsw-alias-label-secondary);font-size:12px}',
607
+ '.dcw-mono{font-family:var(--dsw-font-mono,ui-monospace,Consolas,monospace);font-size:12px;overflow-wrap:anywhere}',
608
+ '.dcw-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
609
+ '.dcw-divider{height:1px;background:var(--dsw-alias-border-l2);margin:4px 0}',
610
+
611
+ /* ---- controls ---- */
612
+ '.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}',
613
+ '.dcw-button:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
614
+ '.dcw-button:disabled{cursor:default;opacity:.45}',
615
+ '.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}',
616
+ '.dcw-button[data-variant="primary"]:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover)}',
617
+ '.dcw-button[data-variant="danger"]{color:var(--dsw-alias-state-error-primary);border-color:transparent;padding:0 10px}',
618
+ '.dcw-button[data-variant="danger"]:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger)}',
619
+ '.dcw-button[data-variant="ghost"]{border-color:transparent;padding:0 8px;color:var(--dsw-alias-label-secondary)}',
620
+ '.dcw-button[data-variant="ghost"]:hover:not(:disabled){color:var(--dsw-alias-label-primary)}',
621
+ '.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}',
622
+ '.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}',
623
+ '.dcw-input::placeholder{color:var(--dsw-alias-label-tertiary)}',
624
+ 'textarea.dcw-input{height:auto;min-height:72px;padding:8px 10px;resize:vertical;line-height:1.5}',
625
+ '.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}',
626
+ '.dcw-switch[data-on="true"]{background:var(--dsw-alias-state-business-primary);border-color:transparent}',
627
+ '.dcw-switch:disabled{cursor:default;opacity:.45}',
628
+ '.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}',
629
+ '.dcw-switch[data-on="true"] .dcw-switch-knob{transform:translateX(16px);background:var(--dsw-alias-label-primary-inverted)}',
630
+
631
+ /* ---- choice rows (repo / codespace / machine) ---- */
632
+ '.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}',
633
+ '.dcw-choice:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
634
+ '.dcw-choice:disabled{cursor:default;opacity:.5}',
635
+ '.dcw-choice[data-selected="true"]{border-color:var(--dsw-alias-state-business-primary)}',
636
+ '.dcw-choice-mark{flex:none;width:16px;height:16px;margin-top:2px;border-radius:50%;border:1.5px solid var(--dsw-alias-label-tertiary)}',
637
+ '.dcw-choice[data-selected="true"] .dcw-choice-mark{border:5px solid var(--dsw-alias-state-business-primary)}',
638
+ '.dcw-choice-name{font-weight:600;overflow-wrap:anywhere}',
639
+ '.dcw-choice-meta{display:flex;flex-wrap:wrap;gap:8px;font-size:12px;color:var(--dsw-alias-label-secondary)}',
640
+ '.dcw-list{display:flex;flex-direction:column;gap:4px;max-height:280px;overflow:auto;padding-right:2px}',
641
+ '.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}',
642
+ '.dcw-tag[data-tone="business"]{color:var(--dsw-alias-state-business-primary);border-color:transparent;background:var(--dsw-alias-bg-layer-3,transparent)}',
643
+
644
+ /* ---- notices / errors ---- */
645
+ '.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)}',
646
+ '.dcw-notice[data-tone="warn"]{color:var(--dsw-alias-state-warn-primary);border-color:transparent;background:var(--dsw-alias-bg-layer-3,transparent)}',
647
+ '.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}',
648
+ '.dcw-empty{padding:12px 0;color:var(--dsw-alias-label-secondary);font-size:12px}',
649
+
650
+ /* ---- steps ---- */
651
+ '.dcw-steps{display:flex;flex-wrap:wrap;gap:8px;align-items:center}',
652
+ '.dcw-step{display:inline-flex;align-items:center;gap:8px;font-size:12px;color:var(--dsw-alias-label-tertiary)}',
653
+ '.dcw-step[data-active="true"]{color:var(--dsw-alias-label-primary)}',
654
+ '.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}',
655
+ '.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)}',
656
+ '.dcw-step-sep{color:var(--dsw-alias-label-tertiary);font-size:11px}',
657
+
658
+ /* ---- spinner ---- */
659
+ '@keyframes dcw-spin{to{transform:rotate(360deg)}}',
660
+ '.dcw-spin{display:inline-block;width:12px;height:12px;border:2px solid var(--dsw-alias-label-tertiary);border-top-color:transparent;border-radius:50%;animation:dcw-spin .8s linear infinite;flex:none}',
661
+
662
+ /* ---- overlay window ---- */
663
+ '.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))}',
664
+ '.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))}',
665
+ '.dcw-window-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}',
666
+ '.dcw-window-foot{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap}',
667
+
668
+ /* ---- workspace-row augmentation ---------------------------------- *
669
+ * OBSERVED (dsh-client-ui-workspace/lib/client.js, the Rows CSS module):
670
+ * .<hash>_rowActions{flex:none;align-items:center;gap:10px;display:none}
671
+ * .<hash>_projectRow:hover .<hash>_rowActions,
672
+ * .<hash>_sessionRow:hover .<hash>_rowActions,
673
+ * .<hash>_searchResultRow:hover .<hash>_rowActions,
674
+ * .<hash>_projectRow.<hash>_menuOpen .<hash>_rowActions,
675
+ * .<hash>_sessionRow.<hash>_menuOpen .<hash>_rowActions{display:inline-flex}
676
+ * .<hash>_slot{width:16px;height:20px;color:var(--dsw-alias-label-tertiary);flex:none;
677
+ * justify-content:center;align-items:center;display:inline-flex}
678
+ * .<hash>_iconButton{border-radius:var(--dsw-radius-xs);cursor:pointer;width:16px;height:16px;
679
+ * color:var(--dsw-alias-label-tertiary);background:0 0;border:none;flex:none;
680
+ * justify-content:center;align-items:center;padding:0;display:inline-flex}
681
+ * .<hash>_iconButton:hover{color:var(--dsw-alias-label-primary)}
682
+ * .<hash>_folder{display:none} <-- the folder span is hidden in BOTH states
683
+ * .<hash>_projectRow:hover .<hash>_folder{display:none}
684
+ * The hashed prefix (`hIlkoa_`) is per build, so every selector below matches
685
+ * the class name by suffix via [class*="_name"]. The stable hooks are
686
+ * `data-row-key` (host-rendered) and the `data-dsh-codespace-*` attributes
687
+ * this bundle adds.
688
+ * ------------------------------------------------------------------ */
689
+ '[data-dsh-codespace-icon]{width:16px;height:20px;color:var(--dsw-alias-label-secondary);flex:none;display:inline-flex;align-items:center;justify-content:center;pointer-events:none}',
690
+ '[data-dsh-codespace-icon] svg{width:16px;height:16px;display:block}',
691
+ '[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}',
692
+ '[data-dsh-codespace-action]:hover:not(:disabled){color:var(--dsw-alias-label-primary)}',
693
+ '[data-dsh-codespace-action]:disabled{cursor:default;opacity:.6}',
694
+ '[data-dsh-codespace-action] .dcw-spin{width:10px;height:10px;border-width:1.5px}',
695
+ '[data-dsh-codespace-glyph]{display:inline-block;font-size:12px;line-height:1}',
696
+ /* `alwaysShowCloudButton`: the container is `display:none` when the row is
697
+ * not hovered, and a `display:none` ancestor cannot be overridden from a
698
+ * descendant — so the CONTAINER has to be revealed. Only managed rows carry
699
+ * the marker, so no other row changes. */
700
+ '[data-dsh-codespace-row][data-dsh-codespace-always] [class*="_rowActions"]{display:inline-flex}',
701
+
702
+ /* ---- section-header launcher ------------------------------------- *
703
+ * OBSERVED (dsh-client-ui-workspace/lib/client.js, the WorkspaceBrowser
704
+ * CSS module, `_9lTDKa_`):
705
+ * .<hash>_sectionHeader{height:36px;justify-content:flex-end;align-items:center;
706
+ * gap:4px;padding-left:4px;display:flex;overflow:hidden}
707
+ * .<hash>_headerActions{max-width:60px;flex:none;align-items:center;gap:4px;
708
+ * display:flex;overflow:hidden}
709
+ * .<hash>_headerActionsHidden{opacity:0;visibility:hidden;pointer-events:none;max-width:0}
710
+ * .<hash>_rail .<hash>_headerActions{max-width:none}
711
+ * .<hash>_rail .<hash>_iconButton{border-radius:var(--dsw-radius-md);
712
+ * width:36px;height:36px;
713
+ * color:var(--dsw-alias-label-primary)}
714
+ * .<hash>_iconButton{border-radius:var(--dsw-radius-sm);cursor:pointer;
715
+ * width:28px;height:28px;color:var(--dsw-alias-label-secondary);
716
+ * background:0 0;border:none;flex:none;
717
+ * justify-content:center;align-items:center;
718
+ * padding:0;display:inline-flex}
719
+ * .<hash>_iconButton:hover{background:var(--dsw-alias-interactive-bg-hover)}
720
+ * .<hash>_iconButton:focus-visible{outline:var(--dsw-focus-ring-width) solid
721
+ * var(--dsw-focus-ring-color,…);outline-offset:-2px}
722
+ * NOTE: this is the HEADER control (`WorkspaceBrowser`), and it is 28x28. The
723
+ * 16x16 `_iconButton` with `--dsw-radius-xs` / `--dsw-alias-label-tertiary` /
724
+ * a colour-only hover belongs to `Rows` (the workspace-ROW buttons) — a
725
+ * different module that happens to use the same suffix. Copying that one is
726
+ * what previously made this button too small next to its peers.
727
+ * The launcher is a peer of those controls, so it copies `iconButton` exactly
728
+ * and takes the rail metrics in rail mode. Three consequences of the host's
729
+ * own rules are handled here rather than in JavaScript:
730
+ * - `max-width:60px` fits exactly two 28px controls with a 4px gap
731
+ * (28*2 + 4 = 60). A third peer would be clipped by `overflow:hidden`,
732
+ * so the container is widened. The `:not(...)` guard is load-bearing:
733
+ * without it this rule would also beat the host's `headerActionsHidden`
734
+ * collapse (same property, and ours is more specific), leaving the group
735
+ * visible while the search box is expanded.
736
+ * - `headerActionsHidden` (applied to the same element while the search box
737
+ * is expanded) collapses the CONTAINER to `max-width:0;opacity:0;
738
+ * pointer-events:none`. Because the launcher is a child of that container
739
+ * it disappears with its peers for free.
740
+ * - The header is `justify-content:flex-end` and the search slot is `flex:1`,
741
+ * so widening the group shifts it left without pushing any host control
742
+ * out of view. */
743
+ /* The launcher is a peer of the host's own header controls, so it copies
744
+ * that control's metrics verbatim from `WorkspaceBrowser`'s
745
+ * `_iconButton`: 28x28, `--dsw-radius-sm`, `--dsw-alias-label-secondary`,
746
+ * a transparent background, and a hover that shows a BACKGROUND rather
747
+ * than changing the text colour. An earlier revision used a 16x16 box
748
+ * with `--dsw-radius-xs` and a colour-only hover, which read as a smaller,
749
+ * differently-styled control next to the "+" button. */
750
+ '[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}',
751
+ '[data-dsh-codespace-launcher]:hover{background:var(--dsw-alias-interactive-bg-hover)}',
752
+ '[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}',
753
+ '[data-dsh-codespace-launcher] svg{display:block}',
754
+ '[data-dsh-codespace-header][class*="' + HEADER_ACTIONS_SUFFIX + '"]:not([class*="' + HEADER_HIDDEN_SUFFIX + '"]){max-width:none}',
755
+ /* Rail mode: the host enlarges the box to 36px and its glyph to 18px
756
+ * (`size: wide ? 16 : 18`), so the glyph follows the box. */
757
+ '[class*="' + RAIL_SUFFIX + '"] [data-dsh-codespace-launcher]{border-radius:var(--dsw-radius-md,8px);width:36px;height:36px;color:var(--dsw-alias-label-primary)}',
758
+ '[class*="' + RAIL_SUFFIX + '"] [data-dsh-codespace-launcher] svg{width:18px;height:18px}',
759
+
760
+ /* ---- tooltip bubble, matching the host's own ---------------------- *
761
+ * OBSERVED (`@deepseek-ai/dsh-client-ui-primitives` `Tooltip.module.css`,
762
+ * class `_bubble_12mhf_1` / `_label_12mhf_27`):
763
+ * .bubble{display:inline-flex;align-items:center;gap:8px;position:fixed;
764
+ * z-index:100;width:max-content;max-width:50vw;padding:3px 7px;
765
+ * border-radius:var(--dsw-radius-sm);
766
+ * background:var(--dsw-alias-tooltip-bg);
767
+ * color:var(--dsw-static-neutral-bluish-00);font-size:13px;
768
+ * line-height:20px;white-space:pre-line;overflow-wrap:break-word;
769
+ * pointer-events:none;animation:tooltip-in 150ms var(--ds-ease-in-out)}
770
+ * @keyframes tooltip-in{from{opacity:0}}
771
+ * No token value is hardcoded: these are theme-provided, so they are only
772
+ * ever referenced. The native `title` attribute is deliberately NOT used —
773
+ * it draws an OS bubble that looks nothing like DSH's. */
774
+ '[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)}',
775
+ '@keyframes dcw-tooltip-in{from{opacity:0}}',
776
+ '@media (prefers-reduced-motion:reduce){[data-dsh-codespace-tip]{animation:none}}',
777
+
778
+ /* ---- menu item we append to the host's portaled workspace menu ---- */
779
+ '[data-dsh-codespace-menu-separator]{height:1px;background:var(--dsw-alias-border-l2);margin:4px 6px}',
780
+ '[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}',
781
+ '[data-dsh-codespace-menu-item]:hover{background:var(--dsw-alias-interactive-bg-hover-danger)}',
782
+ ].join('\n')
783
+
784
+ /* ------------------------------------------------------------------ *
785
+ * 4. Transport
786
+ * ------------------------------------------------------------------ */
787
+
788
+ /** Error carrying the host's `error.code`, never a credential. */
789
+ function RpcError(code, message) {
790
+ const error = new Error(redact(message))
791
+ error.code = code
792
+ error.name = 'RpcError'
793
+ return error
794
+ }
795
+
796
+ /**
797
+ * One RPC call against the host route. Always resolves the parsed body;
798
+ * rejects only on a transport failure or `{ ok: false }`.
799
+ *
800
+ * @param {string} action - one of ACTIONS.
801
+ * @param {object} [payload] - the action's payload.
802
+ * @returns {Promise<object>} the success body.
803
+ */
804
+ async function rpc(action, payload) {
805
+ const body = Object.assign({ action }, payload === undefined ? {} : payload)
806
+ const send = (globalThis.__DSH_TRANSPORT__ && globalThis.__DSH_TRANSPORT__.fetch) || fetch
807
+ let response
808
+ try {
809
+ response = await send(RPC_ROUTE, {
810
+ method: 'POST',
811
+ credentials: 'same-origin',
812
+ headers: { 'content-type': 'application/json' },
813
+ body: JSON.stringify(body),
814
+ })
815
+ } catch (error) {
816
+ throw RpcError('E_TRANSPORT', messageOf(error))
817
+ }
818
+ let data = null
819
+ try {
820
+ data = await response.json()
821
+ } catch (error) {
822
+ throw RpcError('E_BAD_RESPONSE', 'HTTP ' + String(response.status))
823
+ }
824
+ if (data === null || typeof data !== 'object') throw RpcError('E_BAD_RESPONSE', 'HTTP ' + String(response.status))
825
+ if (data.ok !== true) {
826
+ const failure = data.error !== null && typeof data.error === 'object' ? data.error : {}
827
+ throw RpcError(typeof failure.code === 'string' ? failure.code : 'E_UNKNOWN', String(failure.message || 'HTTP ' + String(response.status)))
828
+ }
829
+ return data
830
+ }
831
+
832
+ /** Reduce an unknown thrown value to a displayable, redacted string. */
833
+ function messageOf(error) {
834
+ if (error === null || error === undefined) return ''
835
+ if (typeof error === 'string') return redact(error)
836
+ if (typeof error.message === 'string') return redact(error.message)
837
+ return redact(String(error))
838
+ }
839
+
840
+ /* ------------------------------------------------------------------ *
841
+ * 5. Tolerant readers for the wire records
842
+ *
843
+ * `Managed` and `Codespace` are described in docs/CONTRACT.md §6, but the
844
+ * host half is written in parallel: every reader below accepts the documented
845
+ * shape plus the two obvious variants, and returns an empty/neutral value
846
+ * rather than throwing.
847
+ * ------------------------------------------------------------------ */
848
+
849
+ /** Coerce to a finite number, or `Number.MAX_SAFE_INTEGER` so it sorts last. */
850
+ function num(value) {
851
+ const n = typeof value === 'number' ? value : Number(value)
852
+ return Number.isFinite(n) ? n : Number.MAX_SAFE_INTEGER
853
+ }
854
+
855
+ /** VERIFY: `Managed.codespace` is documented as a `Codespace` object; the
856
+ * host may also send just the name. Both are accepted here. */
857
+ function codespaceOf(record) {
858
+ if (record === null || typeof record !== 'object') return null
859
+ const cs = record.codespace
860
+ if (cs !== null && typeof cs === 'object') return cs
861
+ if (typeof cs === 'string' && cs !== '') return { name: cs, state: record.state }
862
+ return null
863
+ }
864
+
865
+ /** The Codespace name used by every lifecycle RPC (`{ name }`). */
866
+ function codespaceNameOf(record) {
867
+ const cs = codespaceOf(record)
868
+ if (cs !== null && typeof cs.name === 'string' && cs.name !== '') return cs.name
869
+ if (typeof record.codespaceName === 'string') return record.codespaceName
870
+ return ''
871
+ }
872
+
873
+ /** Human label for the hover card: display name, then name, then DSH title. */
874
+ function codespaceLabelOf(record) {
875
+ const cs = codespaceOf(record)
876
+ if (cs !== null) {
877
+ if (typeof cs.displayName === 'string' && cs.displayName !== '') return cs.displayName
878
+ if (typeof cs.name === 'string' && cs.name !== '') return cs.name
879
+ }
880
+ if (typeof record.title === 'string' && record.title !== '') return record.title
881
+ return codespaceNameOf(record)
882
+ }
883
+
884
+ /**
885
+ * The Codespace's repository as `owner/name`, or `''` when unknown.
886
+ *
887
+ * The row is titled after this repository's SHORT name (see the host half's
888
+ * `titleForCodespace`), so the hover card carries the two facts the title
889
+ * cannot: which owner it belongs to, and the Codespace's own name.
890
+ *
891
+ * @param {object} record - a `Managed` record.
892
+ * @returns {string} `owner/name`, or `''`.
893
+ */
894
+ function repositoryOf(record) {
895
+ const cs = codespaceOf(record)
896
+ if (cs === null) return ''
897
+ return typeof cs.repository === 'string' ? cs.repository.trim() : ''
898
+ }
899
+
900
+ /** The raw state string, for the error tooltip. */
901
+ function codespaceStateOf(record) {
902
+ const cs = codespaceOf(record)
903
+ if (cs !== null && typeof cs.state === 'string') return cs.state
904
+ if (typeof record.state === 'string') return record.state
905
+ return ''
906
+ }
907
+
908
+ /** Normalized phase, falling back to the top-level `state` field. */
909
+ function phaseOf(record) {
910
+ return codespacePhase(codespaceStateOf(record))
911
+ }
912
+
913
+ /** VERIFY: `autoPauseRemainingMs` is documented in milliseconds and
914
+ * `autoPauseDeadline` as a deadline; an epoch-seconds deadline is also
915
+ * accepted, because that mistake is invisible in a 4-second poll. */
916
+ function countdownMsOf(record) {
917
+ const remaining = record.autoPauseRemainingMs
918
+ if (typeof remaining === 'number' && Number.isFinite(remaining) && remaining > 0) return remaining
919
+ const deadline = record.autoPauseDeadline
920
+ let at = null
921
+ if (typeof deadline === 'number' && Number.isFinite(deadline)) at = deadline
922
+ else if (typeof deadline === 'string') {
923
+ const parsed = Date.parse(deadline)
924
+ if (!Number.isNaN(parsed)) at = parsed
925
+ }
926
+ if (at === null) return 0
927
+ if (at < 1e11) at *= 1000
928
+ return Math.max(0, at - Date.now())
929
+ }
930
+
931
+ /** Whether the row button must show a spinner. */
932
+ function isBusy(record, pendingIds) {
933
+ if (pendingIds.has(record.workspaceId)) return true
934
+ if (record.busy === true) return true
935
+ return phaseOf(record) === 'pending'
936
+ }
937
+
938
+ /** Fill in defaults for a settings object coming off the wire. */
939
+ function normalizeSettings(raw) {
940
+ const out = Object.assign({}, SETTINGS_DEFAULTS)
941
+ if (raw !== null && typeof raw === 'object') {
942
+ for (const key of Object.keys(SETTINGS_DEFAULTS)) {
943
+ if (raw[key] !== undefined) out[key] = raw[key]
944
+ }
945
+ }
946
+ return out
947
+ }
948
+
949
+ /**
950
+ * Whether a write-only secret has a stored value.
951
+ *
952
+ * VERIFY: the exact redaction marker is the host's choice. A non-empty string
953
+ * (including the usual `***` / `•••` / `[redacted]` placeholders), a truthy
954
+ * boolean, or a `secrets` marker object all count as "set". The value itself
955
+ * is never rendered and never sent back.
956
+ */
957
+ function secretIsSet(settings, secrets, key) {
958
+ if (secrets !== null && typeof secrets === 'object') {
959
+ const marker = secrets[key]
960
+ if (marker === true) return true
961
+ if (marker !== null && typeof marker === 'object' && marker.set === true) return true
962
+ if (typeof marker === 'string' && marker !== '' && marker !== 'none') return true
963
+ }
964
+ if (settings === null || typeof settings !== 'object') return false
965
+ const value = settings[key]
966
+ if (typeof value === 'boolean') return value
967
+ if (typeof value === 'string') return value !== ''
968
+ return false
969
+ }
970
+
971
+ /** Whether a settings object still carries a real secret value (never sent back). */
972
+ function stripSecrets(settings) {
973
+ const out = Object.assign({}, settings)
974
+ for (const key of SECRET_KEYS) delete out[key]
975
+ return out
976
+ }
977
+
978
+ /** The smallest machine: fewest CPUs, then least memory, then least storage. */
979
+ function compareMachines(a, b) {
980
+ const cpus = num(a.cpus) - num(b.cpus)
981
+ if (cpus !== 0) return cpus
982
+ const memory = num(a.memoryGb) - num(b.memoryGb)
983
+ if (memory !== 0) return memory
984
+ const storage = num(a.storageGb) - num(b.storageGb)
985
+ if (storage !== 0) return storage
986
+ return String(a.name).localeCompare(String(b.name))
987
+ }
988
+
989
+ /** The machine the window preselects. */
990
+ function smallestMachine(machines) {
991
+ if (!Array.isArray(machines) || machines.length === 0) return null
992
+ return machines.slice().sort(compareMachines)[0]
993
+ }
994
+
995
+ /* ------------------------------------------------------------------ *
996
+ * 6. Stores
997
+ * ------------------------------------------------------------------ */
998
+
999
+ /** Cheap structural equality for wire snapshots (they are small and flat). */
1000
+ function sameJson(a, b) {
1001
+ if (a === b) return true
1002
+ try {
1003
+ return JSON.stringify(a) === JSON.stringify(b)
1004
+ } catch (error) {
1005
+ return false
1006
+ }
1007
+ }
1008
+
1009
+ /**
1010
+ * The plugin's one data store: the managed-workspace list (polled) plus the
1011
+ * settings (loaded on demand).
1012
+ *
1013
+ * Polling is a non-overlapping `setTimeout` chain that runs only while at
1014
+ * least one subscriber holds an "owner" claim — the sidebar launcher and the
1015
+ * settings page's workspace list. The row augmenter observes without owning,
1016
+ * so the plugin does not poll when neither surface is mounted.
1017
+ *
1018
+ * @param {(action: string, payload?: object) => Promise<object>} call - the RPC.
1019
+ * @returns {object} the store.
1020
+ */
1021
+ function createStore(call) {
1022
+ const listeners = new Set()
1023
+ let owners = 0
1024
+ let disposed = false
1025
+ let timer = null
1026
+ let inFlight = false
1027
+ let state = {
1028
+ workspaces: [],
1029
+ workspacesReady: false,
1030
+ workspacesError: '',
1031
+ settings: null,
1032
+ settingsRevision: 0,
1033
+ settingsLoaded: false,
1034
+ settingsError: '',
1035
+ }
1036
+
1037
+ function getState() {
1038
+ return state
1039
+ }
1040
+
1041
+ function publish(patch) {
1042
+ let changed = false
1043
+ for (const key of Object.keys(patch)) {
1044
+ if (!sameJson(state[key], patch[key])) {
1045
+ changed = true
1046
+ break
1047
+ }
1048
+ }
1049
+ if (!changed) return
1050
+ state = Object.assign({}, state, patch)
1051
+ for (const listener of Array.from(listeners)) {
1052
+ try {
1053
+ listener()
1054
+ } catch (error) {
1055
+ console.warn('[dsh-codespace-workspace] store listener failed', error)
1056
+ }
1057
+ }
1058
+ }
1059
+
1060
+ function schedule() {
1061
+ if (disposed || owners === 0 || timer !== null) return
1062
+ timer = setTimeout(() => {
1063
+ timer = null
1064
+ void poll()
1065
+ }, POLL_MS)
1066
+ }
1067
+
1068
+ function stopPolling() {
1069
+ if (timer !== null) {
1070
+ clearTimeout(timer)
1071
+ timer = null
1072
+ }
1073
+ }
1074
+
1075
+ async function poll() {
1076
+ if (disposed || inFlight) return
1077
+ inFlight = true
1078
+ try {
1079
+ const data = await call(ACTIONS.WORKSPACES)
1080
+ publish({
1081
+ workspaces: Array.isArray(data.workspaces) ? data.workspaces : [],
1082
+ workspacesReady: true,
1083
+ workspacesError: '',
1084
+ })
1085
+ } catch (error) {
1086
+ publish({ workspacesError: messageOf(error), workspacesReady: true })
1087
+ } finally {
1088
+ inFlight = false
1089
+ schedule()
1090
+ }
1091
+ }
1092
+
1093
+ /** Refresh now (used after a mutation); never overlaps the poll chain. */
1094
+ function refresh() {
1095
+ if (disposed) return
1096
+ stopPolling()
1097
+ if (inFlight) {
1098
+ schedule()
1099
+ return
1100
+ }
1101
+ void poll()
1102
+ }
1103
+
1104
+ async function loadSettings(force) {
1105
+ if (state.settingsLoaded && force !== true) return state.settings
1106
+ try {
1107
+ const data = await call(ACTIONS.GET_SETTINGS)
1108
+ publish({
1109
+ settings: normalizeSettings(data.settings),
1110
+ settingsRevision: data.settingsRevision !== undefined ? data.settingsRevision : data.revision,
1111
+ settingsLoaded: true,
1112
+ settingsError: '',
1113
+ })
1114
+ } catch (error) {
1115
+ publish({ settingsError: messageOf(error), settingsLoaded: true })
1116
+ throw error
1117
+ }
1118
+ return state.settings
1119
+ }
1120
+
1121
+ async function saveSettings(patch) {
1122
+ const data = await call(ACTIONS.SET_SETTINGS, { patch })
1123
+ publish({
1124
+ settings: normalizeSettings(data.settings),
1125
+ settingsRevision: data.settingsRevision !== undefined ? data.settingsRevision : data.revision,
1126
+ settingsLoaded: true,
1127
+ settingsError: '',
1128
+ })
1129
+ return state.settings
1130
+ }
1131
+
1132
+ function subscribe(listener, owns) {
1133
+ listeners.add(listener)
1134
+ if (owns === true) {
1135
+ owners += 1
1136
+ if (owners === 1) refresh()
1137
+ }
1138
+ return () => {
1139
+ listeners.delete(listener)
1140
+ if (owns === true) {
1141
+ owners = Math.max(0, owners - 1)
1142
+ if (owners === 0) stopPolling()
1143
+ }
1144
+ }
1145
+ }
1146
+
1147
+ function dispose() {
1148
+ disposed = true
1149
+ stopPolling()
1150
+ listeners.clear()
1151
+ owners = 0
1152
+ }
1153
+
1154
+ return { getState, subscribe, refresh, loadSettings, saveSettings, dispose }
1155
+ }
1156
+
1157
+ /**
1158
+ * Which overlay is open. One `shell.overlay` entry reads this, so the create
1159
+ * flow and the delete confirmation are never a stack of dialogs.
1160
+ */
1161
+ function createUiStore() {
1162
+ const listeners = new Set()
1163
+ let seq = 0
1164
+ let state = { create: null, del: null }
1165
+
1166
+ function publish(next) {
1167
+ state = next
1168
+ for (const listener of Array.from(listeners)) {
1169
+ try {
1170
+ listener()
1171
+ } catch (error) {
1172
+ console.warn('[dsh-codespace-workspace] ui listener failed', error)
1173
+ }
1174
+ }
1175
+ }
1176
+
1177
+ return {
1178
+ getState() {
1179
+ return state
1180
+ },
1181
+ subscribe(listener) {
1182
+ listeners.add(listener)
1183
+ return () => listeners.delete(listener)
1184
+ },
1185
+ openCreate() {
1186
+ seq += 1
1187
+ publish({ create: { seq }, del: null })
1188
+ },
1189
+ openDelete(target) {
1190
+ seq += 1
1191
+ publish({ create: null, del: Object.assign({ seq }, target) })
1192
+ },
1193
+ close() {
1194
+ publish({ create: null, del: null })
1195
+ },
1196
+ dispose() {
1197
+ listeners.clear()
1198
+ state = { create: null, del: null }
1199
+ },
1200
+ }
1201
+ }
1202
+
1203
+ /* ------------------------------------------------------------------ *
1204
+ * 7. Hooks and shared components
1205
+ * ------------------------------------------------------------------ */
1206
+
1207
+ /** `useSyncExternalStore` when available, with a React-17-shaped fallback.
1208
+ * The branch is resolved once, so hook order is stable for the module. */
1209
+ const useExternalStore =
1210
+ typeof React.useSyncExternalStore === 'function'
1211
+ ? function useStore(subscribe, getSnapshot) {
1212
+ return React.useSyncExternalStore(subscribe, getSnapshot)
1213
+ }
1214
+ : function useStore(subscribe, getSnapshot) {
1215
+ const [value, setValue] = React.useState(getSnapshot)
1216
+ React.useEffect(() => subscribe(() => setValue(getSnapshot())), [subscribe, getSnapshot])
1217
+ return value
1218
+ }
1219
+
1220
+ /** Subscribe to a snapshot store. */
1221
+ function useStoreState(store, owns) {
1222
+ const subscribe = React.useCallback((listener) => store.subscribe(listener, owns), [store, owns])
1223
+ const getSnapshot = React.useCallback(() => store.getState(), [store])
1224
+ return useExternalStore(subscribe, getSnapshot)
1225
+ }
1226
+
1227
+ /** Re-render when the active locale changes. */
1228
+ function useLocaleTick(locale) {
1229
+ const [, bump] = React.useReducer((x) => x + 1, 0)
1230
+ React.useEffect(() => {
1231
+ if (locale === undefined || locale === null || typeof locale.subscribe !== 'function') return undefined
1232
+ return locale.subscribe(() => bump())
1233
+ }, [locale])
1234
+ }
1235
+
1236
+ /**
1237
+ * One hand-drawn host-spec icon, by name from {@link ACTION_ICONS}.
1238
+ *
1239
+ * The React counterpart of `iconNode`, sharing the same artwork table so the
1240
+ * two renderers cannot drift. Never use an emoji or a text glyph for an icon
1241
+ * — see the note on `ACTION_ICONS`.
1242
+ *
1243
+ * @param {{kind: string, size?: number}} props - which artwork, and the box.
1244
+ * @returns {object} the SVG element.
1245
+ */
1246
+ function Icon(props) {
1247
+ const spec = ACTION_ICONS[props.kind]
1248
+ const size = props.size ?? 16
1249
+ return h(
1250
+ 'svg',
1251
+ {
1252
+ width: size,
1253
+ height: size,
1254
+ viewBox: spec.viewBox,
1255
+ fill: 'none',
1256
+ xmlns: 'http://www.w3.org/2000/svg',
1257
+ 'aria-hidden': 'true',
1258
+ strokeWidth: ICON_STROKE_WIDTH,
1259
+ ...(spec.linecap === undefined ? {} : { strokeLinecap: spec.linecap }),
1260
+ ...(spec.linejoin === undefined ? {} : { strokeLinejoin: spec.linejoin }),
1261
+ },
1262
+ spec.paths.map((d) => h('path', { key: d, d, stroke: 'currentColor' })),
1263
+ )
1264
+ }
1265
+
1266
+ /**
1267
+ * The cloud glyph used by the launcher, the window header and the row marker.
1268
+ *
1269
+ * @param {{plus?: boolean}} [props] - `plus` adds the host's top-right plus,
1270
+ * for controls that CREATE a cloud workspace rather than merely marking one.
1271
+ * @returns {object} the SVG element.
1272
+ */
1273
+ function CloudIcon(props) {
1274
+ const withPlus = props !== undefined && props.plus === true
1275
+ if (!withPlus) {
1276
+ return h(
1277
+ 'svg',
1278
+ {
1279
+ width: 16,
1280
+ height: 16,
1281
+ viewBox: '0 0 16 16',
1282
+ fill: 'none',
1283
+ xmlns: 'http://www.w3.org/2000/svg',
1284
+ 'aria-hidden': 'true',
1285
+ strokeWidth: ICON_STROKE_WIDTH,
1286
+ },
1287
+ h('path', { d: CLOUD_PATH, stroke: 'currentColor' }),
1288
+ )
1289
+ }
1290
+ const maskId = 'dcw-cloud-add-' + String(cloudMaskSeq++)
1291
+ return h(
1292
+ 'svg',
1293
+ {
1294
+ width: 16,
1295
+ height: 16,
1296
+ viewBox: '0 0 16 16',
1297
+ fill: 'none',
1298
+ xmlns: 'http://www.w3.org/2000/svg',
1299
+ 'aria-hidden': 'true',
1300
+ strokeWidth: ICON_STROKE_WIDTH,
1301
+ },
1302
+ h('defs', { key: 'defs' },
1303
+ h('mask', {
1304
+ id: maskId,
1305
+ key: 'mask',
1306
+ maskUnits: 'userSpaceOnUse',
1307
+ x: 0,
1308
+ y: 0,
1309
+ width: 16,
1310
+ height: 16,
1311
+ },
1312
+ h('rect', { key: 'plate', x: 0, y: 0, width: 16, height: 16, fill: '#fff' }),
1313
+ ...PLUS_PATHS.map((d) => h('path', {
1314
+ key: 'cut-' + d,
1315
+ d,
1316
+ stroke: '#000',
1317
+ strokeWidth: CLOUD_MASK_BAND,
1318
+ fill: 'none',
1319
+ strokeLinecap: 'butt',
1320
+ })))),
1321
+ h('path', { key: 'cloud', d: CLOUD_PATH, stroke: 'currentColor', mask: 'url(#' + maskId + ')' }),
1322
+ ...PLUS_PATHS.map((d) => h('path', { key: d, d, stroke: 'currentColor' })),
1323
+ )
1324
+ }
1325
+
1326
+ /** A plain button matching the host's control metrics. */
1327
+ function Button(props) {
1328
+ return h(
1329
+ 'button',
1330
+ {
1331
+ type: 'button',
1332
+ className: 'dcw-button',
1333
+ 'data-variant': props.variant === undefined ? 'default' : props.variant,
1334
+ disabled: props.disabled === true,
1335
+ onClick: props.onClick,
1336
+ title: props.title,
1337
+ 'aria-label': props['aria-label'],
1338
+ },
1339
+ props.children,
1340
+ )
1341
+ }
1342
+
1343
+ /** Escape closes the topmost overlay window (the host Modal's own contract). */
1344
+ function useEscape(onClose) {
1345
+ const latest = React.useRef(onClose)
1346
+ latest.current = onClose
1347
+ React.useEffect(() => {
1348
+ const onKeyDown = (event) => {
1349
+ if (event.key !== 'Escape' || event.shiftKey) return
1350
+ if (event.defaultPrevented) return
1351
+ latest.current()
1352
+ }
1353
+ document.addEventListener('keydown', onKeyDown)
1354
+ return () => document.removeEventListener('keydown', onKeyDown)
1355
+ }, [])
1356
+ }
1357
+
1358
+ /** The one overlay host: a mask plus a card, matching the host Modal's shape.
1359
+ * The component itself unmounts when nothing is open, so the key listener and
1360
+ * the window are owned by the open window rather than by the slot. */
1361
+ function OverlayWindow(props) {
1362
+ useEscape(props.onClose)
1363
+ return h(
1364
+ 'div',
1365
+ {
1366
+ className: 'dcw-overlay',
1367
+ role: 'presentation',
1368
+ onClick: (event) => {
1369
+ if (event.target === event.currentTarget) props.onClose()
1370
+ },
1371
+ },
1372
+ h(
1373
+ 'div',
1374
+ {
1375
+ className: 'dcw-window',
1376
+ role: 'dialog',
1377
+ 'aria-modal': 'true',
1378
+ 'aria-label': props.label,
1379
+ },
1380
+ props.children,
1381
+ ),
1382
+ )
1383
+ }
1384
+
1385
+ /** `role="switch"` toggle (the host's own control contract). */
1386
+ function Switch(props) {
1387
+ return h(
1388
+ 'button',
1389
+ {
1390
+ type: 'button',
1391
+ role: 'switch',
1392
+ className: 'dcw-switch',
1393
+ 'aria-checked': props.checked === true ? 'true' : 'false',
1394
+ 'aria-label': props.label,
1395
+ title: props.label,
1396
+ 'data-on': props.checked === true ? 'true' : 'false',
1397
+ disabled: props.disabled === true,
1398
+ onClick: () => props.onChange(props.checked !== true),
1399
+ },
1400
+ h('span', { className: 'dcw-switch-knob' }),
1401
+ )
1402
+ }
1403
+
1404
+ /** One labelled control row. */
1405
+ function Field(props) {
1406
+ return h(
1407
+ 'div',
1408
+ { className: 'dcw-stack' },
1409
+ h('div', { className: 'dcw-label' }, props.label),
1410
+ props.control,
1411
+ props.hint === undefined || props.hint === null ? null : h('div', { className: 'dcw-muted' }, props.hint),
1412
+ )
1413
+ }
1414
+
1415
+ /** One labelled row with the control on the right (switches). */
1416
+ function ToggleRow(props) {
1417
+ return h(
1418
+ 'div',
1419
+ { className: 'dcw-row-start' },
1420
+ h(
1421
+ 'div',
1422
+ { className: 'dcw-stack' },
1423
+ h('div', { className: 'dcw-label' }, props.label),
1424
+ props.hint === undefined ? null : h('div', { className: 'dcw-muted' }, props.hint),
1425
+ ),
1426
+ props.control,
1427
+ )
1428
+ }
1429
+
1430
+ /** One selectable row used by the repo, codespace and machine steps. */
1431
+ function Choice(props) {
1432
+ return h(
1433
+ 'button',
1434
+ {
1435
+ type: 'button',
1436
+ className: 'dcw-choice',
1437
+ 'data-selected': props.selected === true ? 'true' : 'false',
1438
+ 'aria-pressed': props.selected === true ? 'true' : 'false',
1439
+ disabled: props.disabled === true,
1440
+ onClick: props.onClick,
1441
+ },
1442
+ h('span', { className: 'dcw-choice-mark', 'aria-hidden': 'true' }),
1443
+ h(
1444
+ 'span',
1445
+ { className: 'dcw-stack' },
1446
+ h(
1447
+ 'span',
1448
+ { className: 'dcw-row' },
1449
+ h('span', { className: 'dcw-choice-name' }, props.title),
1450
+ props.tag === undefined ? null : h('span', { className: 'dcw-tag', 'data-tone': props.tagTone }, props.tag),
1451
+ ),
1452
+ props.meta === undefined ? null : h('span', { className: 'dcw-choice-meta' }, props.meta),
1453
+ props.detail === undefined ? null : h('span', { className: 'dcw-muted' }, props.detail),
1454
+ ),
1455
+ )
1456
+ }
1457
+
1458
+ /** The multi-step progress line. */
1459
+ function Steps(props) {
1460
+ const labels = [props.t('step.repo'), props.t('step.codespace'), props.t('step.machine'), props.t('step.apply')]
1461
+ const children = []
1462
+ for (let i = 0; i < labels.length; i += 1) {
1463
+ if (i > 0) children.push(h('span', { key: 'sep' + i, className: 'dcw-step-sep' }, '›'))
1464
+ children.push(
1465
+ h(
1466
+ 'span',
1467
+ { key: 'step' + i, className: 'dcw-step', 'data-active': i === props.active ? 'true' : 'false' },
1468
+ h('span', { className: 'dcw-step-number' }, String(i + 1)),
1469
+ labels[i],
1470
+ ),
1471
+ )
1472
+ }
1473
+ return h('div', { className: 'dcw-steps' }, children)
1474
+ }
1475
+
1476
+ /* ------------------------------------------------------------------ *
1477
+ * 8. Settings section
1478
+ * ------------------------------------------------------------------ */
1479
+
1480
+ /**
1481
+ * The **Codespace** settings page.
1482
+ *
1483
+ * Every value is persisted through `set-settings`; nothing is kept in
1484
+ * localStorage. The SSH field is write-only: only a set/unset marker plus an
1485
+ * empty input is ever rendered, and a stored value is never read back.
1486
+ */
1487
+ function createSettingsSection(deps) {
1488
+ const { store, ui, t, locale } = deps
1489
+
1490
+ function SettingsSection() {
1491
+ useLocaleTick(locale)
1492
+ const snapshot = useStoreState(store, true)
1493
+ const [draft, setDraft] = React.useState(null)
1494
+ const [saving, setSaving] = React.useState(false)
1495
+ const [saveError, setSaveError] = React.useState('')
1496
+ const [saved, setSaved] = React.useState(false)
1497
+ const [secretDraft, setSecretDraft] = React.useState('')
1498
+ const [secretBusy, setSecretBusy] = React.useState(false)
1499
+ const [status, setStatus] = React.useState(null)
1500
+ const [statusError, setStatusError] = React.useState('')
1501
+ const [statusBusy, setStatusBusy] = React.useState(false)
1502
+ const alive = React.useRef(true)
1503
+
1504
+ React.useEffect(() => {
1505
+ alive.current = true
1506
+ return () => {
1507
+ alive.current = false
1508
+ }
1509
+ }, [])
1510
+
1511
+ // The settings page reads its own values; it does not depend on another
1512
+ // surface having loaded them.
1513
+ React.useEffect(() => {
1514
+ store.loadSettings().catch(() => {})
1515
+ }, [])
1516
+
1517
+ React.useEffect(() => {
1518
+ if (snapshot.settingsLoaded && draft === null && snapshot.settings !== null) {
1519
+ setDraft(stripSecrets(snapshot.settings))
1520
+ }
1521
+ }, [snapshot.settingsLoaded, snapshot.settings, draft])
1522
+
1523
+ const loadStatus = React.useCallback(() => {
1524
+ setStatusBusy(true)
1525
+ setStatusError('')
1526
+ rpc(ACTIONS.STATUS)
1527
+ .then((data) => {
1528
+ if (!alive.current) return
1529
+ setStatus(data)
1530
+ })
1531
+ .catch((error) => {
1532
+ if (!alive.current) return
1533
+ setStatusError(messageOf(error))
1534
+ })
1535
+ .then(() => {
1536
+ if (alive.current) setStatusBusy(false)
1537
+ })
1538
+ }, [])
1539
+
1540
+ React.useEffect(() => {
1541
+ loadStatus()
1542
+ }, [loadStatus])
1543
+
1544
+ const base = snapshot.settings === null ? null : stripSecrets(snapshot.settings)
1545
+ const dirty = draft !== null && base !== null && !sameJson(draft, base)
1546
+
1547
+ function setField(key, value) {
1548
+ setSaved(false)
1549
+ setDraft((current) => Object.assign({}, current === null ? SETTINGS_DEFAULTS : current, { [key]: value }))
1550
+ }
1551
+
1552
+ function commit() {
1553
+ if (draft === null || base === null) return
1554
+ const patch = {}
1555
+ for (const key of Object.keys(SETTINGS_DEFAULTS)) {
1556
+ if (key === 'sshKeyPath') continue
1557
+ if (!sameJson(draft[key], base[key])) patch[key] = draft[key]
1558
+ }
1559
+ if (Object.keys(patch).length === 0) return
1560
+ setSaving(true)
1561
+ setSaveError('')
1562
+ store
1563
+ .saveSettings(patch)
1564
+ .then(() => {
1565
+ if (!alive.current) return
1566
+ setSaving(false)
1567
+ setSaved(true)
1568
+ })
1569
+ .catch((error) => {
1570
+ if (!alive.current) return
1571
+ setSaving(false)
1572
+ setSaveError(messageOf(error))
1573
+ })
1574
+ }
1575
+
1576
+ function writeSecret(value) {
1577
+ setSecretBusy(true)
1578
+ setSaveError('')
1579
+ store
1580
+ .saveSettings({ sshKeyPath: value })
1581
+ .then(() => {
1582
+ if (!alive.current) return
1583
+ setSecretBusy(false)
1584
+ setSecretDraft('')
1585
+ setSaved(true)
1586
+ })
1587
+ .catch((error) => {
1588
+ if (!alive.current) return
1589
+ setSecretBusy(false)
1590
+ setSaveError(messageOf(error))
1591
+ })
1592
+ }
1593
+
1594
+ const gh = status !== null && status.gh !== null && typeof status.gh === 'object' ? status.gh : null
1595
+ const ghInstalled = gh !== null && gh.installed === true
1596
+ const tokenSource = status !== null && typeof status.tokenSource === 'string' ? status.tokenSource : 'none'
1597
+ const tokenFound = tokenSource !== 'none' && tokenSource !== ''
1598
+ const login = gh !== null && typeof gh.login === 'string' && gh.login !== '' ? gh.login : ''
1599
+
1600
+ const statusRows = []
1601
+ statusRows.push(
1602
+ h(
1603
+ 'div',
1604
+ { key: 'gh', className: 'dcw-row-start' },
1605
+ h('div', { className: 'dcw-stack' }, h('div', { className: 'dcw-label' }, t('status.gh'))),
1606
+ h(
1607
+ 'div',
1608
+ { className: 'dcw-stack', style: { alignItems: 'flex-end', textAlign: 'right' } },
1609
+ h(
1610
+ 'span',
1611
+ { className: ghInstalled ? undefined : 'dcw-muted' },
1612
+ status === null
1613
+ ? t('status.checking')
1614
+ : ghInstalled
1615
+ ? t('status.gh.installed', { version: gh.version === undefined || gh.version === null || gh.version === '' ? '?' : gh.version })
1616
+ : t('status.gh.missing'),
1617
+ ),
1618
+ ghInstalled || status === null
1619
+ ? null
1620
+ : h('span', { className: 'dcw-muted' }, t('status.gh.missingNote')),
1621
+ ),
1622
+ ),
1623
+ )
1624
+ statusRows.push(
1625
+ h(
1626
+ 'div',
1627
+ { key: 'token', className: 'dcw-row' },
1628
+ h('span', { className: 'dcw-label' }, t('status.token')),
1629
+ 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')),
1630
+ ),
1631
+ )
1632
+ if (!tokenFound && status !== null) {
1633
+ statusRows.push(h('div', { key: 'tokenNote', className: 'dcw-muted' }, t('status.token.noneNote')))
1634
+ }
1635
+ statusRows.push(
1636
+ h(
1637
+ 'div',
1638
+ { key: 'login', className: 'dcw-row' },
1639
+ h('span', { className: 'dcw-label' }, t('status.login')),
1640
+ h('span', null, login === '' ? t('status.loggedOut') : login),
1641
+ ),
1642
+ )
1643
+ // VERIFY: `status.configured` is documented but its shape is not; only a
1644
+ // boolean is rendered, anything else is ignored rather than guessed at.
1645
+ if (status !== null && typeof status.configured === 'boolean') {
1646
+ statusRows.push(
1647
+ h(
1648
+ 'div',
1649
+ { key: 'configured', className: 'dcw-row' },
1650
+ h('span', { className: 'dcw-label' }, t('status.configured')),
1651
+ h('span', null, status.configured ? t('status.configured.yes') : t('status.configured.no')),
1652
+ ),
1653
+ )
1654
+ }
1655
+
1656
+ const secretSet = secretIsSet(snapshot.settings, status === null ? null : status.secrets, 'sshKeyPath')
1657
+
1658
+ const managed = snapshot.workspaces
1659
+ const managedCard = h(
1660
+ 'div',
1661
+ { className: 'dcw-card' },
1662
+ h(
1663
+ 'div',
1664
+ { className: 'dcw-row' },
1665
+ h('h3', { className: 'dcw-card-title' }, t('managed.heading')),
1666
+ h(
1667
+ Button,
1668
+ { onClick: () => ui.openCreate() },
1669
+ h(CloudIcon, { plus: true }),
1670
+ t('managed.new'),
1671
+ ),
1672
+ ),
1673
+ snapshot.workspacesError !== ''
1674
+ ? h('div', { className: 'dcw-error' }, t('apply.failed', { message: snapshot.workspacesError }))
1675
+ : null,
1676
+ !snapshot.workspacesReady
1677
+ ? h('div', { className: 'dcw-empty' }, t('managed.loading'))
1678
+ : managed.length === 0
1679
+ ? h('div', { className: 'dcw-empty' }, t('managed.empty'))
1680
+ : h(
1681
+ 'div',
1682
+ { className: 'dcw-list' },
1683
+ managed.map((record) =>
1684
+ h(
1685
+ 'div',
1686
+ { key: String(record.workspaceId), className: 'dcw-row', style: { padding: '4px 0' } },
1687
+ h(
1688
+ 'div',
1689
+ { className: 'dcw-stack' },
1690
+ h('span', { className: 'dcw-label' }, codespaceLabelOf(record)),
1691
+ h(
1692
+ 'span',
1693
+ { className: 'dcw-muted' },
1694
+ t('cs.state', { state: codespaceStateOf(record) === '' ? 'unknown' : codespaceStateOf(record) }),
1695
+ ),
1696
+ ),
1697
+ h(
1698
+ Button,
1699
+ {
1700
+ variant: 'danger',
1701
+ onClick: () =>
1702
+ ui.openDelete({
1703
+ workspaceId: record.workspaceId,
1704
+ codespaceName: codespaceNameOf(record),
1705
+ label: codespaceLabelOf(record),
1706
+ }),
1707
+ },
1708
+ t('managed.delete'),
1709
+ ),
1710
+ ),
1711
+ ),
1712
+ ),
1713
+ h('div', { className: 'dcw-muted' }, t('managed.deleteFallbackNote')),
1714
+ )
1715
+
1716
+ return h(
1717
+ 'div',
1718
+ { className: 'dcw-root' },
1719
+ h('h2', { className: 'dcw-title' }, t('page.title')),
1720
+ h('p', { className: 'dcw-sub' }, t('page.subtitle')),
1721
+
1722
+ h(
1723
+ 'div',
1724
+ { className: 'dcw-card' },
1725
+ h(
1726
+ 'div',
1727
+ { className: 'dcw-row' },
1728
+ h('h3', { className: 'dcw-card-title' }, t('status.heading')),
1729
+ h(Button, { onClick: loadStatus, disabled: statusBusy }, statusBusy ? t('status.checking') : t('status.refresh')),
1730
+ ),
1731
+ statusError === '' ? null : h('div', { className: 'dcw-error' }, t('status.failed', { message: statusError })),
1732
+ statusRows,
1733
+ ),
1734
+
1735
+ h(
1736
+ 'div',
1737
+ { className: 'dcw-card' },
1738
+ h('h3', { className: 'dcw-card-title' }, t('settings.heading')),
1739
+ draft === null
1740
+ ? h('div', { className: 'dcw-empty' }, t('managed.loading'))
1741
+ : h(
1742
+ React.Fragment,
1743
+ null,
1744
+ h(Field, {
1745
+ label: t('field.githubUsername'),
1746
+ hint: t('field.githubUsername.hint'),
1747
+ control: h('input', {
1748
+ className: 'dcw-input',
1749
+ type: 'text',
1750
+ value: draft.githubUsername === undefined || draft.githubUsername === null ? '' : String(draft.githubUsername),
1751
+ spellCheck: false,
1752
+ onChange: (event) => setField('githubUsername', event.target.value),
1753
+ }),
1754
+ }),
1755
+ h(Field, {
1756
+ label: t('field.defaultBranch'),
1757
+ control: h('input', {
1758
+ className: 'dcw-input',
1759
+ type: 'text',
1760
+ value: draft.defaultBranch === undefined || draft.defaultBranch === null ? '' : String(draft.defaultBranch),
1761
+ spellCheck: false,
1762
+ onChange: (event) => setField('defaultBranch', event.target.value),
1763
+ }),
1764
+ }),
1765
+ h(Field, {
1766
+ label: t('field.autoPauseMinutes'),
1767
+ hint: t('field.autoPauseMinutes.hint'),
1768
+ control: h('input', {
1769
+ className: 'dcw-input',
1770
+ type: 'number',
1771
+ min: '0',
1772
+ step: '1',
1773
+ value: String(num(draft.autoPauseMinutes) === Number.MAX_SAFE_INTEGER ? 0 : draft.autoPauseMinutes),
1774
+ onChange: (event) => {
1775
+ const parsed = Number.parseInt(event.target.value, 10)
1776
+ setField('autoPauseMinutes', Number.isFinite(parsed) && parsed > 0 ? parsed : 0)
1777
+ },
1778
+ }),
1779
+ }),
1780
+ h(ToggleRow, {
1781
+ label: t('field.alwaysShowCloudButton'),
1782
+ hint: t('field.alwaysShowCloudButton.hint'),
1783
+ control: h(Switch, {
1784
+ checked: draft.alwaysShowCloudButton === true,
1785
+ label: t('field.alwaysShowCloudButton'),
1786
+ onChange: (next) => setField('alwaysShowCloudButton', next),
1787
+ }),
1788
+ }),
1789
+ h(ToggleRow, {
1790
+ label: t('field.autoInitEmptyRepo'),
1791
+ hint: t('field.autoInitEmptyRepo.hint'),
1792
+ control: h(Switch, {
1793
+ checked: draft.autoInitEmptyRepo === true,
1794
+ label: t('field.autoInitEmptyRepo'),
1795
+ onChange: (next) => setField('autoInitEmptyRepo', next),
1796
+ }),
1797
+ }),
1798
+ h(Field, {
1799
+ label: t('field.readmeContent'),
1800
+ hint: t('field.readmeContent.hint'),
1801
+ control: h('textarea', {
1802
+ className: 'dcw-input',
1803
+ rows: 4,
1804
+ value: draft.readmeContent === undefined || draft.readmeContent === null ? '' : String(draft.readmeContent),
1805
+ onChange: (event) => setField('readmeContent', event.target.value),
1806
+ }),
1807
+ }),
1808
+ h(
1809
+ 'div',
1810
+ { className: 'dcw-actions' },
1811
+ h(Button, { variant: 'primary', onClick: commit, disabled: saving || !dirty }, saving ? t('saving') : t('save')),
1812
+ saved && !dirty ? h('span', { className: 'dcw-muted' }, t('saved')) : null,
1813
+ ),
1814
+ ),
1815
+ saveError === '' ? null : h('div', { className: 'dcw-error' }, t('saveFailed', { message: saveError })),
1816
+
1817
+ h('div', { className: 'dcw-divider' }),
1818
+ h(Field, {
1819
+ label: t('field.sshKeyPath'),
1820
+ hint: t('field.sshKeyPath.hint'),
1821
+ control: h(
1822
+ 'div',
1823
+ { className: 'dcw-actions' },
1824
+ h('span', { className: 'dcw-tag' }, secretSet ? t('field.sshKeyPath.set') : t('field.sshKeyPath.unset')),
1825
+ h('input', {
1826
+ className: 'dcw-input',
1827
+ style: { flex: 1, minWidth: 160 },
1828
+ type: 'password',
1829
+ autoComplete: 'off',
1830
+ placeholder: t('field.sshKeyPath.write'),
1831
+ value: secretDraft,
1832
+ onChange: (event) => setSecretDraft(event.target.value),
1833
+ }),
1834
+ h(Button, { onClick: () => writeSecret(secretDraft), disabled: secretBusy || secretDraft === '' }, t('field.sshKeyPath.save')),
1835
+ secretSet
1836
+ ? h(Button, { variant: 'ghost', onClick: () => writeSecret(''), disabled: secretBusy }, t('field.sshKeyPath.clear'))
1837
+ : null,
1838
+ ),
1839
+ }),
1840
+ ),
1841
+
1842
+ managedCard,
1843
+ )
1844
+ }
1845
+
1846
+ return SettingsSection
1847
+ }
1848
+
1849
+ /* ------------------------------------------------------------------ *
1850
+ * 9. The single multi-step window (create flow) and the delete confirm
1851
+ * ------------------------------------------------------------------ */
1852
+
1853
+ /**
1854
+ * The create flow. Internal steps only — `repo` → `codespace` → `machine` →
1855
+ * `apply` → `done`. Reused by the sidebar launcher and by the settings page.
1856
+ */
1857
+ function createNewWorkspaceWindow(deps) {
1858
+ const { store, ui, t, locale } = deps
1859
+
1860
+ function CreateFlow(props) {
1861
+ useLocaleTick(locale)
1862
+ const snapshot = useStoreState(store, false)
1863
+ const [step, setStep] = React.useState('repo')
1864
+ const [repos, setRepos] = React.useState(null)
1865
+ const [reposError, setReposError] = React.useState('')
1866
+ const [query, setQuery] = React.useState('')
1867
+ const [repo, setRepo] = React.useState(null)
1868
+ const [codespaces, setCodespaces] = React.useState(null)
1869
+ const [codespacesError, setCodespacesError] = React.useState('')
1870
+ const [selected, setSelected] = React.useState(null)
1871
+ const [createNew, setCreateNew] = React.useState(false)
1872
+ const [prebuild, setPrebuild] = React.useState(undefined)
1873
+ const [prebuildAcknowledged, setPrebuildAcknowledged] = React.useState(false)
1874
+ const [machines, setMachines] = React.useState(null)
1875
+ const [machinesError, setMachinesError] = React.useState('')
1876
+ const [machine, setMachine] = React.useState(null)
1877
+ const [stage, setStage] = React.useState('')
1878
+ const [failure, setFailure] = React.useState('')
1879
+ const [created, setCreated] = React.useState(null)
1880
+ const [opening, setOpening] = React.useState(false)
1881
+ const [openHint, setOpenHint] = React.useState('')
1882
+ const alive = React.useRef(true)
1883
+ const retryRef = React.useRef(null)
1884
+
1885
+ React.useEffect(() => {
1886
+ alive.current = true
1887
+ store.loadSettings().catch(() => {})
1888
+ return () => {
1889
+ alive.current = false
1890
+ if (retryRef.current !== null) clearTimeout(retryRef.current)
1891
+ }
1892
+ }, [])
1893
+
1894
+ const loadRepos = React.useCallback(() => {
1895
+ setRepos(null)
1896
+ setReposError('')
1897
+ rpc(ACTIONS.LIST_REPOS)
1898
+ .then((data) => {
1899
+ if (alive.current) setRepos(Array.isArray(data.repos) ? data.repos : [])
1900
+ })
1901
+ .catch((error) => {
1902
+ if (!alive.current) return
1903
+ setRepos([])
1904
+ setReposError(messageOf(error))
1905
+ })
1906
+ }, [])
1907
+
1908
+ React.useEffect(() => {
1909
+ loadRepos()
1910
+ }, [loadRepos])
1911
+
1912
+ const settings = snapshot.settings === null ? SETTINGS_DEFAULTS : snapshot.settings
1913
+ const branch = typeof settings.defaultBranch === 'string' && settings.defaultBranch !== '' ? settings.defaultBranch : 'main'
1914
+ const autoInit = settings.autoInitEmptyRepo === true
1915
+
1916
+ /** Step 2: resolve the Codespace for the chosen repository. */
1917
+ function chooseRepo(next) {
1918
+ setRepo(next)
1919
+ setSelected(null)
1920
+ setCreateNew(false)
1921
+ setCodespaces(null)
1922
+ setCodespacesError('')
1923
+ setStep('codespace')
1924
+ rpc(ACTIONS.LIST_CODESPACES, { repo: next.nameWithOwner })
1925
+ .then((data) => {
1926
+ if (!alive.current) return
1927
+ const list = Array.isArray(data.codespaces) ? data.codespaces : []
1928
+ setCodespaces(list)
1929
+ // Several → the user picks; exactly one → use it; none → create.
1930
+ if (list.length === 1) setSelected(list[0])
1931
+ if (list.length === 0) setCreateNew(true)
1932
+ })
1933
+ .catch((error) => {
1934
+ if (!alive.current) return
1935
+ setCodespaces([])
1936
+ setCodespacesError(messageOf(error))
1937
+ setCreateNew(true)
1938
+ })
1939
+ }
1940
+
1941
+ /** Step 3: machine choice, and the prebuild notice for a new Codespace. */
1942
+ function goToMachine() {
1943
+ setStep('machine')
1944
+ setPrebuild(undefined)
1945
+ setPrebuildAcknowledged(false)
1946
+ setMachines(null)
1947
+ setMachinesError('')
1948
+ setMachine(null)
1949
+ rpc(ACTIONS.PREBUILD, { repo: repo.nameWithOwner })
1950
+ .then((data) => {
1951
+ if (!alive.current) return
1952
+ // THREE states, not two. `hasPrebuild` is `true | false | null`, and
1953
+ // `null` means the plugin could not read the repository's machine
1954
+ // list at all. Collapsing that into `false` would report the
1955
+ // PLUGIN's failure as a fact about the user's repository ("未配置
1956
+ // 预构建"), which is a claim we have not earned.
1957
+ //
1958
+ // Note `false` genuinely means "no prebuild": measured across 15
1959
+ // repositories (23 machine rows), `prebuild_availability` only ever
1960
+ // comes back as `null` or `"ready"` — never `"none"` — so a
1961
+ // readable-but-null field really is an absent prebuild.
1962
+ setPrebuild(data.hasPrebuild === true ? true : data.hasPrebuild === false ? false : null)
1963
+ })
1964
+ .catch(() => {
1965
+ // A failed call is "unknown" too, for the same reason.
1966
+ if (alive.current) setPrebuild(null)
1967
+ })
1968
+ rpc(ACTIONS.MACHINES, { repo: repo.nameWithOwner })
1969
+ .then((data) => {
1970
+ if (!alive.current) return
1971
+ const list = Array.isArray(data.machines) ? data.machines : []
1972
+ setMachines(list)
1973
+ setMachine(smallestMachine(list))
1974
+ })
1975
+ .catch((error) => {
1976
+ if (!alive.current) return
1977
+ setMachines([])
1978
+ setMachinesError(messageOf(error))
1979
+ })
1980
+ }
1981
+
1982
+ /** Step 4: run the sequence, then register the DSH workspace. */
1983
+ async function apply() {
1984
+ setStep('apply')
1985
+ setFailure('')
1986
+ setCreated(null)
1987
+ try {
1988
+ let cs = createNew ? null : selected
1989
+ if (cs === null) {
1990
+ setStage(t('apply.creating'))
1991
+ const data = await rpc(ACTIONS.CREATE, {
1992
+ repo: repo.nameWithOwner,
1993
+ branch,
1994
+ machine: machine === null ? undefined : machine.name,
1995
+ })
1996
+ cs = data.codespace
1997
+ } else if (phaseOf({ codespace: cs }) === 'stopped' || phaseOf({ codespace: cs }) === 'error') {
1998
+ setStage(t('apply.starting'))
1999
+ const data = await rpc(ACTIONS.START, { name: codespaceNameOf({ codespace: cs }) })
2000
+ cs = data.codespace
2001
+ }
2002
+ if (!alive.current) return
2003
+ setStage(t('apply.registering'))
2004
+ // VERIFY: `create-workspace` takes `{ codespace, title? }`; the
2005
+ // Codespace NAME (a string) is sent, which is what every lifecycle
2006
+ // action in the same contract uses as its key.
2007
+ const registered = await rpc(ACTIONS.CREATE_WORKSPACE, { codespace: codespaceNameOf({ codespace: cs }) })
2008
+ if (!alive.current) return
2009
+ setCreated(registered.workspace === undefined ? null : registered.workspace)
2010
+ setStage('')
2011
+ setStep('done')
2012
+ store.refresh()
2013
+ } catch (error) {
2014
+ if (!alive.current) return
2015
+ setStage('')
2016
+ setFailure(messageOf(error))
2017
+ }
2018
+ }
2019
+
2020
+ /** "Open a session here": click the host's own new-session button on the
2021
+ * row. VERIFY: the workspace UI exposes no public open-session API to
2022
+ * other plugins, so the only honest route is the host's own control. The
2023
+ * row appears only after the sidebar re-renders, hence the bounded retry. */
2024
+ function openSession() {
2025
+ const workspaceId = created === null || created === undefined ? '' : String(created.workspaceId)
2026
+ setOpening(true)
2027
+ setOpenHint('')
2028
+ let attempts = 0
2029
+ const attempt = () => {
2030
+ if (!alive.current) return
2031
+ attempts += 1
2032
+ const row = workspaceId === '' ? null : document.querySelector('[data-row-key="workspace:' + workspaceId + '"]')
2033
+ const actions = row === null ? null : row.querySelector('[class*="_rowActions"]')
2034
+ const buttons = actions === null ? [] : actions.querySelectorAll('button')
2035
+ const target = buttons.length === 0 ? null : buttons[buttons.length - 1]
2036
+ if (target !== null) {
2037
+ target.click()
2038
+ setOpening(false)
2039
+ props.onClose()
2040
+ return
2041
+ }
2042
+ store.refresh()
2043
+ if (attempts >= 8) {
2044
+ setOpening(false)
2045
+ setOpenHint(t('success.openFailed'))
2046
+ return
2047
+ }
2048
+ retryRef.current = setTimeout(attempt, 500)
2049
+ }
2050
+ attempt()
2051
+ }
2052
+
2053
+ const filtered = React.useMemo(() => {
2054
+ const list = repos === null ? [] : repos
2055
+ const needle = query.trim().toLowerCase()
2056
+ if (needle === '') return list
2057
+ return list.filter((entry) => String(entry.nameWithOwner).toLowerCase().indexOf(needle) >= 0)
2058
+ }, [repos, query])
2059
+
2060
+ /* ---- step bodies ---- */
2061
+
2062
+ const head = h(
2063
+ 'div',
2064
+ { className: 'dcw-window-head' },
2065
+ h(
2066
+ 'div',
2067
+ { className: 'dcw-stack' },
2068
+ h('h2', { className: 'dcw-title' }, t('window.title')),
2069
+ h(Steps, { t, active: step === 'repo' ? 0 : step === 'codespace' ? 1 : step === 'machine' ? 2 : 3 }),
2070
+ ),
2071
+ h(Button, { variant: 'ghost', onClick: props.onClose, 'aria-label': t('close'), title: t('close') },
2072
+ h(Icon, { kind: 'close' })),
2073
+ )
2074
+
2075
+ if (step === 'repo') {
2076
+ return h(
2077
+ React.Fragment,
2078
+ null,
2079
+ head,
2080
+ h('input', {
2081
+ className: 'dcw-input',
2082
+ type: 'search',
2083
+ placeholder: t('repo.search'),
2084
+ value: query,
2085
+ onChange: (event) => setQuery(event.target.value),
2086
+ }),
2087
+ reposError === '' ? null : h('div', { className: 'dcw-error' }, t('repo.failed', { message: reposError })),
2088
+ repos === null
2089
+ ? h('div', { className: 'dcw-empty' }, t('repo.loading'))
2090
+ : filtered.length === 0
2091
+ ? h('div', { className: 'dcw-empty' }, t('repo.empty'))
2092
+ : h(
2093
+ 'div',
2094
+ { className: 'dcw-list' },
2095
+ filtered.map((entry) =>
2096
+ h(Choice, {
2097
+ key: String(entry.nameWithOwner),
2098
+ title: String(entry.nameWithOwner),
2099
+ tag: entry.isPrivate === true ? t('repo.private') : undefined,
2100
+ meta: [
2101
+ entry.isEmpty === true ? h('span', { key: 'e' }, t('repo.isEmpty')) : null,
2102
+ h('span', { key: 'b' }, String(entry.defaultBranch === undefined || entry.defaultBranch === null ? branch : entry.defaultBranch)),
2103
+ ].filter(Boolean),
2104
+ selected: false,
2105
+ onClick: () => chooseRepo(entry),
2106
+ }),
2107
+ ),
2108
+ ),
2109
+ h(
2110
+ 'div',
2111
+ { className: 'dcw-window-foot' },
2112
+ repos === null ? null : h('span', { className: 'dcw-muted' }, t('repo.count', { n: filtered.length })),
2113
+ h(Button, { onClick: props.onClose }, t('cancel')),
2114
+ ),
2115
+ )
2116
+ }
2117
+
2118
+ if (step === 'codespace') {
2119
+ const list = codespaces === null ? [] : codespaces
2120
+ const showNotice = !createNew && list.length === 0
2121
+ return h(
2122
+ React.Fragment,
2123
+ null,
2124
+ head,
2125
+ h('div', { className: 'dcw-label' }, String(repo === null ? '' : repo.nameWithOwner)),
2126
+ codespacesError === '' ? null : h('div', { className: 'dcw-error' }, t('cs.failed', { message: codespacesError })),
2127
+ codespaces === null
2128
+ ? h('div', { className: 'dcw-empty' }, t('cs.loading'))
2129
+ : h(
2130
+ React.Fragment,
2131
+ null,
2132
+ h('h3', { className: 'dcw-card-title' }, list.length === 0 ? t('cs.none') : list.length === 1 ? t('cs.heading') : t('cs.pick')),
2133
+ h(
2134
+ 'div',
2135
+ { className: 'dcw-list' },
2136
+ list.map((entry) =>
2137
+ h(Choice, {
2138
+ key: String(entry.name),
2139
+ title: String(entry.displayName === undefined || entry.displayName === null || entry.displayName === '' ? entry.name : entry.displayName),
2140
+ meta: [h('span', { key: 's' }, t('cs.state', { state: String(entry.state === undefined ? 'unknown' : entry.state) }))],
2141
+ detail: String(entry.name),
2142
+ selected: createNew ? false : selected !== null && selected.name === entry.name,
2143
+ onClick: () => {
2144
+ setCreateNew(false)
2145
+ setSelected(entry)
2146
+ },
2147
+ }),
2148
+ ),
2149
+ showNotice || createNew
2150
+ ? h(Choice, {
2151
+ title: t('cs.createNew'),
2152
+ selected: createNew,
2153
+ onClick: () => {
2154
+ setCreateNew(true)
2155
+ setSelected(null)
2156
+ },
2157
+ })
2158
+ : null,
2159
+ ),
2160
+ ),
2161
+ h(
2162
+ 'div',
2163
+ { className: 'dcw-window-foot' },
2164
+ h(Button, { onClick: () => setStep('repo') }, t('back')),
2165
+ h(Button, { onClick: props.onClose }, t('cancel')),
2166
+ h(
2167
+ Button,
2168
+ {
2169
+ variant: 'primary',
2170
+ disabled: codespaces === null || (!createNew && selected === null),
2171
+ onClick: () => (createNew ? goToMachine() : apply()),
2172
+ },
2173
+ t('next'),
2174
+ ),
2175
+ ),
2176
+ )
2177
+ }
2178
+
2179
+ if (step === 'machine') {
2180
+ const list = machines === null ? [] : machines
2181
+ /*
2182
+ * Four renderings, keyed off the tri-state `prebuild`:
2183
+ *
2184
+ * undefined still checking
2185
+ * null could not determine — say so, and say what it costs
2186
+ * false no prebuild — the notice with its two buttons
2187
+ * true a prebuild is ready
2188
+ *
2189
+ * `prebuildAcknowledged` only silences the `false` notice; it must NOT
2190
+ * fall through to the "has a prebuild" line, which would claim the
2191
+ * opposite of what the user was just told. That was a real bug: after
2192
+ * choosing 直接创建 the window said 该仓库已配置预构建。
2193
+ */
2194
+ let prebuildBlock
2195
+ /*
2196
+ * Whether the wizard must wait for the user to answer the notice.
2197
+ *
2198
+ * Only the "no prebuild" case asks a question (取消 / 直接创建), so only
2199
+ * that one blocks 下一步. "Unknown" shows a plain notice with nothing to
2200
+ * decide, and "has a prebuild" shows none at all — gating either of them
2201
+ * would strand the user on a step with no way forward.
2202
+ */
2203
+ const prebuildBlocksNext = prebuild === false && !prebuildAcknowledged
2204
+ if (prebuild === undefined) {
2205
+ prebuildBlock = h('div', { className: 'dcw-empty' }, t('prebuild.checking'))
2206
+ } else if (prebuild === true) {
2207
+ prebuildBlock = h('div', { className: 'dcw-notice' }, t('prebuild.ok'))
2208
+ } else if (prebuild === false && prebuildAcknowledged) {
2209
+ // Acknowledged: the notice has done its job, so show nothing rather
2210
+ // than assert something untrue.
2211
+ prebuildBlock = null
2212
+ } else if (prebuild === false) {
2213
+ prebuildBlock = h(
2214
+ 'div',
2215
+ { className: 'dcw-notice', 'data-tone': 'warn' },
2216
+ h('div', null, t('prebuild.missing')),
2217
+ h(
2218
+ 'div',
2219
+ { className: 'dcw-actions', style: { marginTop: 8 } },
2220
+ h(Button, { onClick: () => setStep('codespace') }, t('prebuild.cancel')),
2221
+ h(Button, { variant: 'primary', onClick: () => setPrebuildAcknowledged(true) }, t('prebuild.proceed')),
2222
+ ),
2223
+ )
2224
+ } else {
2225
+ // Unknown: no buttons, because there is nothing for the user to
2226
+ // decide — we simply do not know, and creation may take a while.
2227
+ prebuildBlock = h('div', { className: 'dcw-notice', 'data-tone': 'warn' }, t('prebuild.unknown'))
2228
+ }
2229
+ return h(
2230
+ React.Fragment,
2231
+ null,
2232
+ head,
2233
+ h('h3', { className: 'dcw-card-title' }, t('machine.heading')),
2234
+ repo !== null && repo.isEmpty === true ? h('div', { className: 'dcw-notice', 'data-tone': 'warn' }, autoInit ? t('seed.note') : t('seed.note.off')) : null,
2235
+ prebuildBlock,
2236
+ machinesError === '' ? null : h('div', { className: 'dcw-error' }, t('machine.failed', { message: machinesError })),
2237
+ machines === null
2238
+ ? h('div', { className: 'dcw-empty' }, t('machine.loading'))
2239
+ : list.length === 0
2240
+ ? h('div', { className: 'dcw-empty' }, t('machine.empty'))
2241
+ : h(
2242
+ 'div',
2243
+ { className: 'dcw-list' },
2244
+ list.map((entry) => {
2245
+ const isDefault = machine !== null && machine.name === entry.name
2246
+ const meta = []
2247
+ if (Number.isFinite(Number(entry.cpus))) meta.push(h('span', { key: 'c' }, t('machine.cpus', { n: entry.cpus })))
2248
+ if (Number.isFinite(Number(entry.memoryGb))) meta.push(h('span', { key: 'm' }, t('machine.memory', { n: entry.memoryGb })))
2249
+ if (Number.isFinite(Number(entry.storageGb))) meta.push(h('span', { key: 's' }, t('machine.storage', { n: entry.storageGb })))
2250
+ if (entry.gpu === true) meta.push(h('span', { key: 'g' }, t('machine.gpu')))
2251
+ return h(Choice, {
2252
+ key: String(entry.name),
2253
+ title: String(entry.displayName === undefined || entry.displayName === null || entry.displayName === '' ? entry.name : entry.displayName),
2254
+ tag: isDefault ? t('machine.default') : undefined,
2255
+ tagTone: isDefault ? 'business' : undefined,
2256
+ meta,
2257
+ detail: String(entry.name),
2258
+ selected: isDefault,
2259
+ onClick: () => setMachine(entry),
2260
+ })
2261
+ }),
2262
+ ),
2263
+ h(
2264
+ 'div',
2265
+ { className: 'dcw-window-foot' },
2266
+ h(Button, { onClick: () => setStep('codespace') }, t('back')),
2267
+ h(Button, { onClick: props.onClose }, t('cancel')),
2268
+ h(Button, { variant: 'primary', disabled: machine === null || prebuildBlocksNext, onClick: apply }, t('next')),
2269
+ ),
2270
+ )
2271
+ }
2272
+
2273
+ if (step === 'apply') {
2274
+ return h(
2275
+ React.Fragment,
2276
+ null,
2277
+ head,
2278
+ failure === ''
2279
+ ? h(
2280
+ 'div',
2281
+ { className: 'dcw-row' },
2282
+ h('span', { className: 'dcw-spin' }),
2283
+ h('span', null, stage === '' ? t('apply.creating') : stage),
2284
+ )
2285
+ : h(
2286
+ React.Fragment,
2287
+ null,
2288
+ h('div', { className: 'dcw-error' }, t('apply.failed', { message: failure })),
2289
+ h(
2290
+ 'div',
2291
+ { className: 'dcw-window-foot' },
2292
+ h(Button, { onClick: props.onClose }, t('cancel')),
2293
+ h(Button, { variant: 'primary', onClick: apply }, t('retry')),
2294
+ ),
2295
+ ),
2296
+ failure === '' && stage === t('apply.creating') ? h('div', { className: 'dcw-muted' }, t('apply.creatingHint')) : null,
2297
+ failure === ''
2298
+ ? h('div', { className: 'dcw-window-foot' }, h(Button, { onClick: props.onClose }, t('cancel')))
2299
+ : null,
2300
+ )
2301
+ }
2302
+
2303
+ return h(
2304
+ React.Fragment,
2305
+ null,
2306
+ head,
2307
+ h('h3', { className: 'dcw-card-title' }, t('success.title')),
2308
+ h('div', { className: 'dcw-muted' }, t('success.body')),
2309
+ created === null ? null : h('div', { className: 'dcw-mono' }, String(created.title === undefined ? codespaceLabelOf(created) : created.title)),
2310
+ openHint === '' ? null : h('div', { className: 'dcw-notice', 'data-tone': 'warn' }, openHint),
2311
+ h(
2312
+ 'div',
2313
+ { className: 'dcw-window-foot' },
2314
+ created === null ? null : h(Button, { onClick: openSession, disabled: opening }, opening ? t('success.opening') : t('success.open')),
2315
+ h(Button, { variant: 'primary', onClick: props.onClose }, t('success.done')),
2316
+ ),
2317
+ )
2318
+ }
2319
+
2320
+ return CreateFlow
2321
+ }
2322
+
2323
+ /** The irreversible delete, confirmed in our own overlay. */
2324
+ function createDeleteDialog(deps) {
2325
+ const { ui, store, t, locale } = deps
2326
+
2327
+ function DeleteDialog(props) {
2328
+ useLocaleTick(locale)
2329
+ const [busy, setBusy] = React.useState(false)
2330
+ const [error, setError] = React.useState('')
2331
+ const alive = React.useRef(true)
2332
+ React.useEffect(() => {
2333
+ alive.current = true
2334
+ return () => {
2335
+ alive.current = false
2336
+ }
2337
+ }, [])
2338
+
2339
+ const target = props.target
2340
+ function confirm() {
2341
+ if (target.codespaceName === '') {
2342
+ setError(t('delete.failed', { message: 'missing codespace name' }))
2343
+ return
2344
+ }
2345
+ setBusy(true)
2346
+ setError('')
2347
+ rpc(ACTIONS.REMOVE, { name: target.codespaceName })
2348
+ .then(() => {
2349
+ if (!alive.current) return
2350
+ setBusy(false)
2351
+ store.refresh()
2352
+ props.onClose()
2353
+ })
2354
+ .catch((failure) => {
2355
+ if (!alive.current) return
2356
+ setBusy(false)
2357
+ setError(t('delete.failed', { message: messageOf(failure) }))
2358
+ })
2359
+ }
2360
+
2361
+ return h(
2362
+ React.Fragment,
2363
+ null,
2364
+ h('h2', { className: 'dcw-title' }, t('delete.title')),
2365
+ h('div', { className: 'dcw-muted' }, t('delete.body', { name: target.label === '' ? target.codespaceName : target.label })),
2366
+ error === '' ? null : h('div', { className: 'dcw-error' }, error),
2367
+ h(
2368
+ 'div',
2369
+ { className: 'dcw-window-foot' },
2370
+ h(Button, { onClick: props.onClose, disabled: busy }, t('cancel')),
2371
+ h(Button, { variant: 'danger', onClick: confirm, disabled: busy }, busy ? t('delete.working') : t('delete.confirm')),
2372
+ ),
2373
+ )
2374
+ }
2375
+
2376
+ return DeleteDialog
2377
+ }
2378
+
2379
+ /* ------------------------------------------------------------------ *
2380
+ * 10. Workspace-row augmentation (the DOM patch)
2381
+ *
2382
+ * The workspace row has no slot (docs/CONTRACT.md §2 "verified negative
2383
+ * finding"), so this is the `dsh-pet` technique applied to the sidebar:
2384
+ * observe, inject our own tagged nodes, never touch a React-owned one.
2385
+ * ------------------------------------------------------------------ */
2386
+
2387
+ /** Marker attributes — everything this module adds is findable and undoable. */
2388
+ const ATTR = {
2389
+ row: 'data-dsh-codespace-row',
2390
+ always: 'data-dsh-codespace-always',
2391
+ icon: 'data-dsh-codespace-icon',
2392
+ action: 'data-dsh-codespace-action',
2393
+ glyph: 'data-dsh-codespace-glyph',
2394
+ glyphKind: 'data-dsh-codespace-glyph-kind',
2395
+ for: 'data-dsh-codespace-for',
2396
+ path: 'data-dsh-codespace-path',
2397
+ original: 'data-dsh-codespace-original',
2398
+ menu: 'data-dsh-codespace-menu',
2399
+ menuItem: 'data-dsh-codespace-menu-item',
2400
+ menuSeparator: 'data-dsh-codespace-menu-separator',
2401
+ header: 'data-dsh-codespace-header',
2402
+ launcher: 'data-dsh-codespace-launcher',
2403
+ tip: 'data-dsh-codespace-tip',
2404
+ style: 'data-dsh-codespace-style',
2405
+ /** Marks a HOST node this module hid, and records its previous `style`. */
2406
+ hidden: 'data-dsh-codespace-hidden',
2407
+ }
2408
+
2409
+ /** `data-row-key` prefix used by the workspace row (observed at L1287). */
2410
+ const ROW_KEY_PREFIX = 'workspace:'
2411
+
2412
+ /**
2413
+ * Class-name suffix of the row's leading folder slot.
2414
+ *
2415
+ * Matched on the suffix because the module prefix is build-generated
2416
+ * (`hIlkoa_folder` today, a different hash in the next build) — the same
2417
+ * approach already used for `_sectionHeader` / `_rowActions`.
2418
+ */
2419
+ const ROW_FOLDER_CLASS = '_folder'
2420
+
2421
+ /** Set while this module is mutating the DOM, so the observer can ignore itself. */
2422
+ let mutating = false
2423
+
2424
+ /** Run `fn` with the self-mutation guard up, reporting whether it changed anything. */
2425
+ function mutate(fn) {
2426
+ mutating = true
2427
+ try {
2428
+ fn()
2429
+ } finally {
2430
+ mutating = false
2431
+ }
2432
+ }
2433
+
2434
+ const SVG_NS = 'http://www.w3.org/2000/svg'
2435
+
2436
+ /**
2437
+ * Build one stroked DSH-spec icon from a spec in {@link ACTION_ICONS}.
2438
+ *
2439
+ * One implementation for every glyph this module draws by hand: the 16px box,
2440
+ * the `Regular` weight (1px, on the `<svg>` and never on a path), the
2441
+ * `fill="none"` + `stroke="currentColor"` pair, and the per-artwork `viewBox`
2442
+ * all live here so a new glyph cannot quietly differ from its neighbours.
2443
+ *
2444
+ * @param {{viewBox: string, paths: string[], linecap?: string, linejoin?: string}} spec - the artwork.
2445
+ * @returns {SVGElement} the glyph.
2446
+ */
2447
+ function iconNode(spec) {
2448
+ const svg = document.createElementNS(SVG_NS, 'svg')
2449
+ svg.setAttribute('width', '16')
2450
+ svg.setAttribute('height', '16')
2451
+ svg.setAttribute('viewBox', spec.viewBox)
2452
+ svg.setAttribute('fill', 'none')
2453
+ svg.setAttribute('xmlns', SVG_NS)
2454
+ svg.setAttribute('aria-hidden', 'true')
2455
+ // The host puts `stroke-width` on the `<svg>`, not on each path.
2456
+ svg.setAttribute('stroke-width', ICON_STROKE_WIDTH)
2457
+ if (spec.linecap !== undefined) svg.setAttribute('stroke-linecap', spec.linecap)
2458
+ if (spec.linejoin !== undefined) svg.setAttribute('stroke-linejoin', spec.linejoin)
2459
+ for (const d of spec.paths) {
2460
+ const path = document.createElementNS(SVG_NS, 'path')
2461
+ path.setAttribute('d', d)
2462
+ path.setAttribute('stroke', 'currentColor')
2463
+ svg.appendChild(path)
2464
+ }
2465
+ return svg
2466
+ }
2467
+
2468
+ /**
2469
+ * Inline SVG cloud built by hand (the row and its controls are not
2470
+ * React-rendered by us).
2471
+ *
2472
+ * @param {{plus?: boolean}} [props] - `plus` mirrors {@link CloudIcon}'s.
2473
+ * @returns {SVGElement} the glyph, to the same spec `CloudIcon` emits.
2474
+ */
2475
+ function cloudNode(props) {
2476
+ const NS = SVG_NS
2477
+ const withPlus = props !== undefined && props.plus === true
2478
+ const svg = document.createElementNS(NS, 'svg')
2479
+ // The same spec as `CloudIcon`, so the two renderers cannot drift.
2480
+ svg.setAttribute('width', '16')
2481
+ svg.setAttribute('height', '16')
2482
+ svg.setAttribute('viewBox', '0 0 16 16')
2483
+ svg.setAttribute('fill', 'none')
2484
+ svg.setAttribute('xmlns', NS)
2485
+ svg.setAttribute('aria-hidden', 'true')
2486
+ svg.setAttribute('stroke-width', ICON_STROKE_WIDTH)
2487
+ if (withPlus) {
2488
+ // Same composition as `CloudIcon`: the full-size outline drawn through a
2489
+ // mask that knocks out the band around each plus stroke.
2490
+ const maskId = 'dcw-cloud-add-' + String(cloudMaskSeq++)
2491
+ const defs = document.createElementNS(NS, 'defs')
2492
+ const mask = document.createElementNS(NS, 'mask')
2493
+ mask.setAttribute('id', maskId)
2494
+ mask.setAttribute('maskUnits', 'userSpaceOnUse')
2495
+ mask.setAttribute('x', '0')
2496
+ mask.setAttribute('y', '0')
2497
+ mask.setAttribute('width', '16')
2498
+ mask.setAttribute('height', '16')
2499
+ const plate = document.createElementNS(NS, 'rect')
2500
+ plate.setAttribute('x', '0')
2501
+ plate.setAttribute('y', '0')
2502
+ plate.setAttribute('width', '16')
2503
+ plate.setAttribute('height', '16')
2504
+ plate.setAttribute('fill', '#fff')
2505
+ mask.appendChild(plate)
2506
+ for (const d of PLUS_PATHS) {
2507
+ const cut = document.createElementNS(NS, 'path')
2508
+ cut.setAttribute('d', d)
2509
+ cut.setAttribute('stroke', '#000')
2510
+ cut.setAttribute('stroke-width', CLOUD_MASK_BAND)
2511
+ cut.setAttribute('fill', 'none')
2512
+ cut.setAttribute('stroke-linecap', 'butt')
2513
+ mask.appendChild(cut)
2514
+ }
2515
+ defs.appendChild(mask)
2516
+ svg.appendChild(defs)
2517
+ const cloud = document.createElementNS(NS, 'path')
2518
+ cloud.setAttribute('d', CLOUD_PATH)
2519
+ cloud.setAttribute('stroke', 'currentColor')
2520
+ cloud.setAttribute('mask', 'url(#' + maskId + ')')
2521
+ svg.appendChild(cloud)
2522
+ for (const d of PLUS_PATHS) {
2523
+ const plus = document.createElementNS(NS, 'path')
2524
+ plus.setAttribute('d', d)
2525
+ plus.setAttribute('stroke', 'currentColor')
2526
+ svg.appendChild(plus)
2527
+ }
2528
+ return svg
2529
+ }
2530
+ const path = document.createElementNS(NS, 'path')
2531
+ path.setAttribute('d', CLOUD_PATH)
2532
+ path.setAttribute('stroke', 'currentColor')
2533
+ svg.appendChild(path)
2534
+ return svg
2535
+ }
2536
+
2537
+ /**
2538
+ * Attach a DSH-styled tooltip to one element.
2539
+ *
2540
+ * The host's `Tooltip` is a React component and cannot be reused here; this
2541
+ * copies its *rendered result* — a fixed-position `<span role="tooltip">`
2542
+ * styled by the same rules (see the tooltip block in `CSS`). The native
2543
+ * `title` attribute is deliberately not used, because an OS bubble looks
2544
+ * nothing like the DSH one this button sits beside.
2545
+ *
2546
+ * The bubble is appended to `document.body`, never to the button's own
2547
+ * container: DSH's non-portal tooltip lands as a SIBLING inside
2548
+ * `headerActions`, which is what made the launcher appear to the right of the
2549
+ * "+" (see `patchHeader`). Keeping it out of that container removes the
2550
+ * problem at the source.
2551
+ *
2552
+ * VERIFY: the host also flips side when the bubble would overflow the
2553
+ * viewport bottom. Tolerant branch: `bottom` is used unconditionally, which
2554
+ * is what the host's own "+" requests (`side: "bottom"`), and `aria-label`
2555
+ * stays on the button so the accessible name exists with or without it.
2556
+ *
2557
+ * @param {HTMLElement} element - the anchor.
2558
+ * @param {string} label - bubble text.
2559
+ * @returns {() => void} a disposer removing the bubble and its listeners.
2560
+ */
2561
+ function attachTooltip(element, label) {
2562
+ // Resolve `document` locally. This function is module-scoped while `doc` is
2563
+ // a local of `installRowAugmentation`, and `typeof someUndeclaredName`
2564
+ // returns 'undefined' WITHOUT throwing -- so an earlier revision that
2565
+ // tested `typeof doc` here silently returned a no-op: no error, no bubble,
2566
+ // and a passing-looking UI.
2567
+ const ownerDocument = typeof document === 'undefined' ? null : document
2568
+ if (ownerDocument === null || element === null) return () => {}
2569
+ const win = typeof window === 'undefined' ? null : window
2570
+ let bubble = null
2571
+ let timer = null
2572
+
2573
+ const place = () => {
2574
+ if (bubble === null || win === null) return
2575
+ const anchor = element.getBoundingClientRect()
2576
+ const box = bubble.getBoundingClientRect()
2577
+ const edge = 12
2578
+ const left = Math.max(edge, Math.min(anchor.left + anchor.width / 2 - box.width / 2, win.innerWidth - edge - box.width))
2579
+ bubble.style.left = String(left) + 'px'
2580
+ bubble.style.top = String(anchor.bottom + 8) + 'px'
2581
+ bubble.style.visibility = 'visible'
2582
+ }
2583
+
2584
+ const show = () => {
2585
+ if (bubble !== null) return
2586
+ bubble = ownerDocument.createElement('span')
2587
+ bubble.setAttribute(ATTR.tip, '')
2588
+ bubble.setAttribute('data-side', 'bottom')
2589
+ bubble.setAttribute('role', 'tooltip')
2590
+ bubble.style.visibility = 'hidden'
2591
+ const text = ownerDocument.createElement('span')
2592
+ text.textContent = label
2593
+ bubble.appendChild(text)
2594
+ ownerDocument.body.appendChild(bubble)
2595
+ place()
2596
+ }
2597
+
2598
+ const hide = () => {
2599
+ if (timer !== null) {
2600
+ clearTimeout(timer)
2601
+ timer = null
2602
+ }
2603
+ if (bubble === null) return
2604
+ bubble.remove()
2605
+ bubble = null
2606
+ }
2607
+
2608
+ const onEnter = () => {
2609
+ if (timer !== null) clearTimeout(timer)
2610
+ // The host's header controls use `delayMs: 500`.
2611
+ timer = setTimeout(() => {
2612
+ timer = null
2613
+ show()
2614
+ }, TOOLTIP_DELAY_MS)
2615
+ }
2616
+ const onLeave = () => hide()
2617
+ const onFocus = () => show()
2618
+ const onBlur = () => hide()
2619
+
2620
+ element.addEventListener('mouseenter', onEnter)
2621
+ element.addEventListener('mouseleave', onLeave)
2622
+ element.addEventListener('focus', onFocus, true)
2623
+ element.addEventListener('blur', onBlur, true)
2624
+ if (win !== null && typeof win.addEventListener === 'function') {
2625
+ win.addEventListener('resize', place)
2626
+ win.addEventListener('scroll', place, true)
2627
+ }
2628
+
2629
+ return () => {
2630
+ element.removeEventListener('mouseenter', onEnter)
2631
+ element.removeEventListener('mouseleave', onLeave)
2632
+ element.removeEventListener('focus', onFocus, true)
2633
+ element.removeEventListener('blur', onBlur, true)
2634
+ if (win !== null && typeof win.removeEventListener === 'function') {
2635
+ win.removeEventListener('resize', place)
2636
+ win.removeEventListener('scroll', place, true)
2637
+ }
2638
+ hide()
2639
+ }
2640
+ }
2641
+
2642
+ /**
2643
+ * Install the row augmentation.
2644
+ *
2645
+ * @param {object} deps - `{ store, ui, t }`.
2646
+ * @returns {() => void} the disposer: removes every node, attribute and observer.
2647
+ */
2648
+ function installRowAugmentation(deps) {
2649
+ const { store, ui, t } = deps
2650
+ const doc = typeof document === 'undefined' ? undefined : document
2651
+ const Observer = typeof MutationObserver === 'undefined' ? undefined : MutationObserver
2652
+
2653
+ /**
2654
+ * Live tooltips this module attached: `element -> { label, dispose }`.
2655
+ *
2656
+ * Tracked (rather than fire-and-forget) because the row button is
2657
+ * re-pointed whenever its label changes — 启动 / 暂停 / 还剩 X 分钟自动暂停 —
2658
+ * and because every bubble must be released on dispose: an orphaned one
2659
+ * would outlive the plugin and stay stranded on `document.body`.
2660
+ */
2661
+ const tips = new Map()
2662
+
2663
+ /** Attach once per label; a repaint with the same text is a no-op. */
2664
+ function pointTooltip(element, label) {
2665
+ const current = tips.get(element)
2666
+ if (current !== undefined && current.label === label) return
2667
+ if (current !== undefined) {
2668
+ current.dispose()
2669
+ tips.delete(element)
2670
+ }
2671
+ tips.set(element, { label, dispose: attachTooltip(element, label) })
2672
+ }
2673
+
2674
+ /** Release every tracked tooltip (dispose path). */
2675
+ function dropTooltips() {
2676
+ for (const entry of tips.values()) {
2677
+ try {
2678
+ entry.dispose()
2679
+ } catch {
2680
+ /* teardown must not throw */
2681
+ }
2682
+ }
2683
+ tips.clear()
2684
+ }
2685
+
2686
+ /**
2687
+ * The host control the launcher sits immediately before.
2688
+ *
2689
+ * Remembered rather than re-derived, so a later pass cannot decide that
2690
+ * some other control is "last" and move the launcher past the "+" (see
2691
+ * `patchHeader`). Cleared whenever the launcher is removed.
2692
+ */
2693
+ let headerAnchor = null
2694
+
2695
+ /**
2696
+ * The host's last real `<button>` in the icon row.
2697
+ *
2698
+ * Filters on tag name, not on child order: a `Tooltip` bubble is an open
2699
+ * `<span role="tooltip">` SIBLING in this same container, so "last child"
2700
+ * is not the same thing as "last control".
2701
+ *
2702
+ * @param {Element} actions - the icon row.
2703
+ * @param {Element|null} exclude - a node to ignore (our own launcher).
2704
+ * @returns {Element|null} the control, or null when there is none.
2705
+ */
2706
+ function lastHostButton(actions, exclude) {
2707
+ const buttons = Array.from(actions.children)
2708
+ .filter((el) => el !== exclude && el.tagName === 'BUTTON')
2709
+ return buttons.length === 0 ? null : buttons[buttons.length - 1]
2710
+ }
2711
+ if (doc === undefined || doc.body === null) {
2712
+ console.warn('[dsh-codespace-workspace] sidebar augmentation unavailable: no document.body')
2713
+ return () => {}
2714
+ }
2715
+
2716
+ /** Workspace ids with an in-flight click, so the button shows a spinner. */
2717
+ const pending = new Set()
2718
+ /** Deferred single clicks, keyed by workspace id (a double-click cancels one). */
2719
+ const clickTimers = new Map()
2720
+ let warnedMissingActions = false
2721
+ let queued = false
2722
+ let observer = null
2723
+ let active = true
2724
+
2725
+ /** Records by workspace id, from the last poll. */
2726
+ function managedIndex() {
2727
+ const list = store.getState().workspaces
2728
+ const map = new Map()
2729
+ if (Array.isArray(list)) {
2730
+ for (const record of list) {
2731
+ if (record !== null && typeof record === 'object' && record.workspaceId !== undefined) {
2732
+ map.set(String(record.workspaceId), record)
2733
+ }
2734
+ }
2735
+ }
2736
+ return map
2737
+ }
2738
+
2739
+ /** The workspace id a row stands for, or `null` when the row is not ours. */
2740
+ function workspaceIdOfRow(row) {
2741
+ const key = row.getAttribute('data-row-key')
2742
+ if (typeof key !== 'string' || key.indexOf(ROW_KEY_PREFIX) !== 0) return null
2743
+ const id = key.slice(ROW_KEY_PREFIX.length)
2744
+ return id === '' ? null : id
2745
+ }
2746
+
2747
+ /** Paint the button for the record's current state. */
2748
+ function paintButton(button, record) {
2749
+ const phase = phaseOf(record)
2750
+ // Two different "busy" notions. `pending` (an RPC in flight, or the host
2751
+ // reporting one) disables the control. `armed` (a single click waiting out
2752
+ // the double-click window) only shows the spinner: disabling the button
2753
+ // there would swallow the second click and with it the double-click.
2754
+ const inFlight = isBusy(record, pending)
2755
+ const armed = clickTimers.has(button.getAttribute(ATTR.for))
2756
+ const busy = inFlight || armed
2757
+ const remaining = countdownMsOf(record)
2758
+ let kind
2759
+ let label
2760
+ if (busy) {
2761
+ kind = 'pending'
2762
+ label = t('row.pending')
2763
+ } else if (phase === 'running' && remaining > 0) {
2764
+ kind = 'countdown'
2765
+ const minutes = Math.max(1, Math.ceil(remaining / 60000))
2766
+ label = t('row.countdown', { n: minutes })
2767
+ } else if (phase === 'running') {
2768
+ kind = 'stop'
2769
+ label = t('row.stop')
2770
+ } else if (phase === 'error') {
2771
+ kind = 'error'
2772
+ label = t('row.error', { state: codespaceStateOf(record) })
2773
+ } else {
2774
+ kind = 'start'
2775
+ label = t('row.start')
2776
+ }
2777
+
2778
+ const previous = button.getAttribute(ATTR.glyphKind)
2779
+ if (previous !== kind) {
2780
+ button.setAttribute(ATTR.glyphKind, kind)
2781
+ while (button.firstChild !== null) button.removeChild(button.firstChild)
2782
+ if (kind === 'pending') {
2783
+ const spinner = doc.createElement('span')
2784
+ spinner.className = 'dcw-spin'
2785
+ spinner.setAttribute('aria-hidden', 'true')
2786
+ button.appendChild(spinner)
2787
+ } else {
2788
+ // A hand-drawn DSH icon, NOT an emoji: the two buttons beside this one
2789
+ // (the row menu and "new session") are `Icon*OutlineRegular`, so an
2790
+ // emoji here reads as a different kind of object — see ACTION_ICONS.
2791
+ const glyph = doc.createElement('span')
2792
+ glyph.setAttribute(ATTR.glyph, '')
2793
+ glyph.setAttribute('aria-hidden', 'true')
2794
+ glyph.appendChild(iconNode(ACTION_ICONS[kind]))
2795
+ button.appendChild(glyph)
2796
+ }
2797
+ }
2798
+ button.disabled = inFlight
2799
+ button.setAttribute('aria-label', label)
2800
+ pointTooltip(button, label)
2801
+ }
2802
+
2803
+ /** One lifecycle click; the button is disabled while it is in flight. */
2804
+ function act(workspaceId, record, action) {
2805
+ const name = codespaceNameOf(record)
2806
+ if (name === '') return
2807
+ pending.add(workspaceId)
2808
+ repaint(workspaceId)
2809
+ rpc(action, { name })
2810
+ .then(() => {
2811
+ pending.delete(workspaceId)
2812
+ store.refresh()
2813
+ repaint(workspaceId)
2814
+ })
2815
+ .catch((error) => {
2816
+ pending.delete(workspaceId)
2817
+ console.warn('[dsh-codespace-workspace] ' + action + ' failed: ' + messageOf(error))
2818
+ repaint(workspaceId)
2819
+ })
2820
+ }
2821
+
2822
+ /**
2823
+ * Open the double-click window for one workspace: show the spinner, and
2824
+ * when the window closes without a second click, perform the single-click
2825
+ * action.
2826
+ *
2827
+ * The action is re-derived from the CURRENT record rather than captured
2828
+ * when the window opened, because the poll can land in between (the
2829
+ * Codespace may have reached `Available`, or a countdown may have started)
2830
+ * and acting on the stale intent would be wrong. A record that has since
2831
+ * become busy is dropped: something else is already driving it.
2832
+ */
2833
+ function beginClickWindow(workspaceId) {
2834
+ repaint(workspaceId)
2835
+ return setTimeout(() => {
2836
+ clickTimers.delete(workspaceId)
2837
+ const current = managedIndex().get(workspaceId)
2838
+ if (current === undefined || isBusy(current, pending)) {
2839
+ repaint(workspaceId)
2840
+ return
2841
+ }
2842
+ const intent = clickIntent(current)
2843
+ if (intent === null || intent.defer !== true) {
2844
+ repaint(workspaceId)
2845
+ return
2846
+ }
2847
+ act(workspaceId, current, intent.action)
2848
+ }, CLICK_WINDOW_MS)
2849
+ }
2850
+
2851
+ /**
2852
+ * What one click on the row button means in the record's current state.
2853
+ *
2854
+ * `defer` is a flag, not a duration: a running Codespace's button carries
2855
+ * two meanings (one click pauses, two pause immediately), so its action
2856
+ * waits out `CLICK_WINDOW_MS` for a possible second click. Everything else
2857
+ * acts at once. The deferred action is re-derived when the window closes
2858
+ * (see `beginClickWindow`), so it is the flag — not a captured action —
2859
+ * that has to survive.
2860
+ *
2861
+ * @returns {{ action: string, defer: boolean } | null}
2862
+ */
2863
+ function clickIntent(record) {
2864
+ const phase = phaseOf(record)
2865
+ if (phase === 'running') {
2866
+ if (countdownMsOf(record) > 0) return { action: ACTIONS.PAUSE_CANCEL, defer: false }
2867
+ return { action: ACTIONS.STOP, defer: true }
2868
+ }
2869
+ if (phase === 'stopped' || phase === 'error') return { action: ACTIONS.START, defer: false }
2870
+ return null
2871
+ }
2872
+
2873
+ /** Re-read the record and repaint just that row's button. */
2874
+ function repaint(workspaceId) {
2875
+ const record = managedIndex().get(workspaceId)
2876
+ const row = doc.querySelector('[data-row-key="' + ROW_KEY_PREFIX + workspaceId + '"]')
2877
+ if (record === undefined || row === null) return
2878
+ const button = row.querySelector('[' + ATTR.action + ']')
2879
+ if (button === null) return
2880
+ mutate(() => paintButton(button, record))
2881
+ }
2882
+
2883
+ /**
2884
+ * Undo everything {@link patchRow} added to one row.
2885
+ *
2886
+ * Two halves, and the second is easy to forget: our own tagged nodes go
2887
+ * away, AND every HOST node we hid has to be given its `style` back. Without
2888
+ * that second half a workspace we stop managing would keep an invisible
2889
+ * folder glyph — the row would come back with no leading icon at all, which
2890
+ * is a worse bug than the one this file is fixing.
2891
+ *
2892
+ * @param {Element} row - the workspace row.
2893
+ */
2894
+ function unpatchRow(row) {
2895
+ const icon = row.querySelector('[' + ATTR.icon + ']')
2896
+ if (icon !== null && icon.parentNode !== null) icon.parentNode.removeChild(icon)
2897
+ const button = row.querySelector('[' + ATTR.action + ']')
2898
+ if (button !== null && button.parentNode !== null) button.parentNode.removeChild(button)
2899
+ for (const host of row.querySelectorAll('[' + ATTR.hidden + ']')) {
2900
+ const original = host.getAttribute(ATTR.hidden) ?? ''
2901
+ if (original === '') host.removeAttribute('style')
2902
+ else host.setAttribute('style', original)
2903
+ host.removeAttribute(ATTR.hidden)
2904
+ }
2905
+ row.removeAttribute(ATTR.row)
2906
+ row.removeAttribute(ATTR.always)
2907
+ }
2908
+
2909
+ /**
2910
+ * Patch one managed row.
2911
+ *
2912
+ * OBSERVED ORDER (dsh-client-ui-workspace/lib/client.js `ProjectRowItem`,
2913
+ * ≈L1314–1359): the `rowActions` container holds exactly two children —
2914
+ * `[ Menu(ellipsis → rename/delete), Tooltip > button(new session) ]`. The
2915
+ * ellipsis is therefore the FIRST child and the new-session button the LAST,
2916
+ * so "third from last, before the ellipsis" means: insert as the new first
2917
+ * child of `rowActions`. The container is checked to hold exactly two
2918
+ * elements first; a row that does not match is skipped untouched.
2919
+ */
2920
+ function patchRow(row, workspaceId, record) {
2921
+ mutate(() => {
2922
+ if (row.getAttribute(ATTR.row) === null) row.setAttribute(ATTR.row, '')
2923
+
2924
+ // (a) Leading marker: REPLACE the host's folder glyph, in place.
2925
+ //
2926
+ // OBSERVED (Rows module CSS, `dsh-client-ui-workspace/lib/client.js`):
2927
+ //
2928
+ // .hIlkoa_projectRow .hIlkoa_chevron{display:none}
2929
+ // .hIlkoa_projectRow:hover .hIlkoa_chevron{display:inline-flex}
2930
+ // .hIlkoa_projectRow:hover .hIlkoa_folder{display:none}
2931
+ //
2932
+ // so the folder is VISIBLE in the resting state and only steps aside,
2933
+ // on hover, for the chevron. (There is no unconditional
2934
+ // `.hIlkoa_folder{display:none}` — an earlier revision of this file
2935
+ // misread the hover rule as such and concluded there was no visible
2936
+ // folder to replace, then added its own marker to the left of it. That
2937
+ // is exactly the double icon this now removes.)
2938
+ //
2939
+ // The marker therefore goes INSIDE the host's own
2940
+ // `span.slot.folder` — the row's first child — with the host's own
2941
+ // `<svg>` hidden. Nothing is moved or deleted, so the `.slot` geometry
2942
+ // (16x20, centred, `--dsw-alias-label-tertiary`) and the hover swap are
2943
+ // inherited rather than re-implemented, and the cloud lands exactly
2944
+ // where the folder was.
2945
+ // Scoped to `span[class*="_slot"]` so a future `.…_folderSomething`
2946
+ // element elsewhere in the row cannot be picked up by mistake.
2947
+ const folder = row.querySelector('span[class*="' + ROW_FOLDER_CLASS + '"]')
2948
+ if (folder !== null) {
2949
+ // Hide the folder artwork, remembering how it looked. The folder swaps
2950
+ // between its closed and open drawings when a workspace is expanded,
2951
+ // so React replaces this node underneath us: `sync()` re-runs this on
2952
+ // every mutation and re-hides whatever is there now. Ours is skipped —
2953
+ // it is the one child that must stay visible.
2954
+ for (const child of folder.children) {
2955
+ if (child.getAttribute(ATTR.icon) !== null) continue
2956
+ if (child.getAttribute(ATTR.hidden) !== null) continue
2957
+ child.setAttribute(ATTR.hidden, child.getAttribute('style') ?? '')
2958
+ child.style.display = 'none'
2959
+ }
2960
+ if (folder.querySelector('[' + ATTR.icon + ']') === null) {
2961
+ const icon = doc.createElement('span')
2962
+ icon.setAttribute(ATTR.icon, '')
2963
+ // Decorative: the row itself already carries the workspace name, so
2964
+ // the marker stays out of the accessibility tree while still
2965
+ // explaining itself visually on hover.
2966
+ icon.setAttribute('aria-hidden', 'true')
2967
+ // The host's `.slot` is `display:inline-flex` with centred content;
2968
+ // this wrapper mirrors that so the glyph is centred like the folder
2969
+ // was, without touching the host's own class names.
2970
+ icon.style.display = 'inline-flex'
2971
+ icon.style.alignItems = 'center'
2972
+ icon.style.justifyContent = 'center'
2973
+ icon.appendChild(cloudNode())
2974
+ folder.appendChild(icon)
2975
+ pointTooltip(icon, t('row.cloud'))
2976
+ }
2977
+ }
2978
+
2979
+ // (b) The action button, first child of `rowActions`.
2980
+ const actions = row.querySelector('[class*="' + ROW_ACTIONS_SUFFIX + '"]')
2981
+ if (actions === null) {
2982
+ if (!warnedMissingActions) {
2983
+ warnedMissingActions = true
2984
+ console.warn('[dsh-codespace-workspace] no [class*="' + ROW_ACTIONS_SUFFIX + '"] container in the workspace row; the row button is not installed')
2985
+ }
2986
+ return
2987
+ }
2988
+ let button = actions.querySelector(':scope > [' + ATTR.action + ']')
2989
+ // The guard counts the HOST's controls only: once ours is in place the
2990
+ // container legitimately holds three children, and re-checking against
2991
+ // two would skip every later repaint of that row.
2992
+ const hostChildren = actions.children.length - (button === null ? 0 : 1)
2993
+ if (hostChildren !== 2) {
2994
+ // Not the layout this build was written against; leave the row alone.
2995
+ return
2996
+ }
2997
+ if (button === null) {
2998
+ button = doc.createElement('button')
2999
+ button.type = 'button'
3000
+ button.setAttribute(ATTR.action, '')
3001
+ // Click behaviour. A single click is deliberately deferred for a
3002
+ // running Codespace (see `clickIntent`), so the first `click` of a
3003
+ // double-click only arms the spinner and the second cancels the timer
3004
+ // and pauses immediately instead. Cancelling a running countdown is
3005
+ // immediate, because a double-click there has nothing else to mean.
3006
+ button.addEventListener('click', (event) => {
3007
+ event.stopPropagation()
3008
+ event.preventDefault()
3009
+ const id = button.getAttribute(ATTR.for)
3010
+ const current = managedIndex().get(id)
3011
+ if (current === undefined) return
3012
+ const intent = clickIntent(current)
3013
+ if (intent === null) return
3014
+ if (intent.defer !== true) {
3015
+ act(id, current, intent.action)
3016
+ return
3017
+ }
3018
+ if (clickTimers.has(id)) return
3019
+ clickTimers.set(id, beginClickWindow(id))
3020
+ repaint(id)
3021
+ })
3022
+ button.addEventListener('dblclick', (event) => {
3023
+ event.stopPropagation()
3024
+ event.preventDefault()
3025
+ const id = button.getAttribute(ATTR.for)
3026
+ const current = managedIndex().get(id)
3027
+ if (current === undefined) return
3028
+ // "Double-click the clock" (a running row) means pause it NOW.
3029
+ // The guard is the phase, NOT the remaining countdown: the first
3030
+ // click of this double-click has already fired `pause-cancel` when
3031
+ // a countdown was running (that action is immediate by design), so
3032
+ // by the time this handler runs the remaining time can already be
3033
+ // zero — guarding on it would silently drop the double-click.
3034
+ if (phaseOf(current) !== 'running') return
3035
+ const timer = clickTimers.get(id)
3036
+ if (timer !== undefined) {
3037
+ clearTimeout(timer)
3038
+ clickTimers.delete(id)
3039
+ }
3040
+ act(id, current, ACTIONS.PAUSE_NOW)
3041
+ })
3042
+ actions.insertBefore(button, actions.firstElementChild)
3043
+ }
3044
+ button.setAttribute(ATTR.for, workspaceId)
3045
+ paintButton(button, record)
3046
+
3047
+ // (c) `alwaysShowCloudButton`. OBSERVED: `rowActions` is `display:none`
3048
+ // unless the row is hovered or carries the `menuOpen` class, and a
3049
+ // `display:none` ancestor cannot be overridden from a descendant — so the
3050
+ // container is revealed, not just our button. The marker attribute is on
3051
+ // the row, so only managed rows are affected.
3052
+ const always = store.getState().settings !== null && store.getState().settings.alwaysShowCloudButton === true
3053
+ if (always) {
3054
+ if (row.getAttribute(ATTR.always) === null) row.setAttribute(ATTR.always, '')
3055
+ } else if (row.getAttribute(ATTR.always) !== null) {
3056
+ row.removeAttribute(ATTR.always)
3057
+ }
3058
+ })
3059
+ }
3060
+
3061
+ /**
3062
+ * Rewrite the hover card's location line.
3063
+ *
3064
+ * The card is a portal on `document.body`, so it has no row ancestor to read
3065
+ * an id from. VERIFY: it is matched by its location text instead — the raw
3066
+ * `Managed.path` (normalized, trailing slash removed, and either side of a
3067
+ * `~` home abbreviation) first, then, only when exactly one managed record
3068
+ * carries that DSH title, by the card's title line. This is not row identity
3069
+ * (the row still matches on `data-row-key`); it only decides which card shows
3070
+ * which Codespace. Unmatched cards are left exactly as the host drew them.
3071
+ * Only the text of the location element is rewritten; the node, its class and
3072
+ * its neighbours are untouched, and the original text is stashed so the
3073
+ * disposer can put it back.
3074
+ *
3075
+ * The line carries the two facts the ROW TITLE cannot: the repository's full
3076
+ * `owner/name` (the title is only its short name) and the Codespace's own
3077
+ * machine-generated name. Only the text is rewritten rather than adding a
3078
+ * line, because every extra node inside the card is a React-owned node this
3079
+ * module would have to track and restore.
3080
+ */
3081
+ function patchHoverCard(card, records) {
3082
+ const pathEl = card.querySelector('[class*="' + HOVER_PATH_SUFFIX + '"]')
3083
+ if (pathEl === null) return
3084
+ const raw = pathEl.textContent === null ? '' : pathEl.textContent
3085
+ const record = matchRecordByPath(raw, records) || matchRecordByTitle(card, records)
3086
+ if (record === null) return
3087
+ const wanted = hoverLocationOf(record)
3088
+ if (wanted === '') return
3089
+ mutate(() => {
3090
+ if (pathEl.getAttribute(ATTR.original) === null) pathEl.setAttribute(ATTR.original, raw)
3091
+ if (pathEl.getAttribute(ATTR.path) === null) pathEl.setAttribute(ATTR.path, '')
3092
+ if (pathEl.textContent !== wanted) pathEl.textContent = wanted
3093
+ })
3094
+ }
3095
+
3096
+ /**
3097
+ * What the hover card's location line should say.
3098
+ *
3099
+ * `owner/name · codespace-name` once the repository is known, and the
3100
+ * Codespace label alone while it is not (a Codespace still provisioning
3101
+ * reports an empty repository) — never an empty line.
3102
+ *
3103
+ * @param {object} record - a `Managed` record.
3104
+ * @returns {string} the line, or `''` when nothing is known.
3105
+ */
3106
+ function hoverLocationOf(record) {
3107
+ const repo = repositoryOf(record)
3108
+ const name = codespaceNameOf(record)
3109
+ const label = codespaceLabelOf(record)
3110
+ if (repo === '') return label
3111
+ return t('row.hoverRepo', { repo, name: name === '' ? label : name })
3112
+ }
3113
+
3114
+ /** Normalize a displayed path for comparison. */
3115
+ function normalizePath(text) {
3116
+ return String(text === null || text === undefined ? '' : text).trim().replace(/\\/g, '/').replace(/\/+$/, '')
3117
+ }
3118
+
3119
+ /** Match a hover-card location line against the managed records. */
3120
+ function matchRecordByPath(text, records) {
3121
+ const shown = normalizePath(text)
3122
+ if (shown === '') return null
3123
+ const bare = shown.charAt(0) === '~' ? shown.slice(1) : shown
3124
+ for (const record of records) {
3125
+ const path = normalizePath(record.path)
3126
+ if (path === '') continue
3127
+ if (path === shown || path === bare) return record
3128
+ if (bare.charAt(0) === '/' && path.length > bare.length && path.slice(path.length - bare.length) === bare) return record
3129
+ }
3130
+ return null
3131
+ }
3132
+
3133
+ /** Fallback: a title that is unique among the managed records. */
3134
+ function matchRecordByTitle(card, records) {
3135
+ const titleEl = card.querySelector('[class*="_hoverTitle"]')
3136
+ if (titleEl === null) return null
3137
+ const title = String(titleEl.textContent === null ? '' : titleEl.textContent).trim()
3138
+ if (title === '') return null
3139
+ let found = null
3140
+ for (const record of records) {
3141
+ if (String(record.title === undefined ? '' : record.title).trim() !== title) continue
3142
+ if (found !== null) return null
3143
+ found = record
3144
+ }
3145
+ return found
3146
+ }
3147
+
3148
+ /**
3149
+ * Append our row to the host's portaled workspace menu.
3150
+ *
3151
+ * OBSERVED: the row whose menu is open carries the CSS-module class
3152
+ * `.<hash>_menuOpen` (`clsx(projectRow, menuOpen && Rows_module_css_default.menuOpen)`
3153
+ * at L1286, and the same class in the reveal rule), and the open list is a
3154
+ * `role="menu"` element portaled onto `document.body` by the Menu primitive.
3155
+ * Requiring an open `_menuOpen` row is what ties the menu to *our* row rather
3156
+ * than to any other menu on the page. The host's own rows are never modified;
3157
+ * our row is appended after them and separated by a hairline.
3158
+ *
3159
+ * VERIFY: if a future build stops marking the open row with `_menuOpen`, no
3160
+ * menu is patched. The documented fallback is the settings page's "Cloud
3161
+ * workspaces" list, whose 删除 Codespace… button opens the same confirmation
3162
+ * overlay — the note under that list says so.
3163
+ */
3164
+ function patchMenu(records) {
3165
+ const openRow = doc.querySelector('[data-row-key^="' + ROW_KEY_PREFIX + '"][class*="' + MENU_OPEN_SUFFIX + '"]')
3166
+ if (openRow === null) return
3167
+ const workspaceId = workspaceIdOfRow(openRow)
3168
+ if (workspaceId === null) return
3169
+ const record = records.get(workspaceId)
3170
+ if (record === undefined) return
3171
+ const menus = doc.querySelectorAll('[role="menu"]')
3172
+ if (menus.length === 0) return
3173
+ const menu = menus[menus.length - 1]
3174
+ if (menu.getAttribute(ATTR.menu) !== null) return
3175
+ const name = codespaceNameOf(record)
3176
+ if (name === '') return
3177
+ mutate(() => {
3178
+ menu.setAttribute(ATTR.menu, '')
3179
+ const separator = doc.createElement('div')
3180
+ separator.setAttribute(ATTR.menuSeparator, '')
3181
+ separator.setAttribute('role', 'separator')
3182
+ const item = doc.createElement('button')
3183
+ item.type = 'button'
3184
+ item.setAttribute('role', 'menuitem')
3185
+ item.setAttribute(ATTR.menuItem, '')
3186
+ item.textContent = t('menu.delete')
3187
+ item.addEventListener('click', (event) => {
3188
+ event.stopPropagation()
3189
+ event.preventDefault()
3190
+ // Close the host's menu first (Escape is the menu's own close
3191
+ // command), then raise our confirmation overlay.
3192
+ doc.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
3193
+ ui.openDelete({ workspaceId, codespaceName: name, label: codespaceLabelOf(record) })
3194
+ })
3195
+ menu.appendChild(separator)
3196
+ menu.appendChild(item)
3197
+ })
3198
+ }
3199
+
3200
+ /** Remove everything this module injected into a menu. */
3201
+ function cleanMenu(menu) {
3202
+ mutate(() => {
3203
+ const item = menu.querySelector('[' + ATTR.menuItem + ']')
3204
+ if (item !== null && item.parentNode !== null) item.parentNode.removeChild(item)
3205
+ const separator = menu.querySelector('[' + ATTR.menuSeparator + ']')
3206
+ if (separator !== null && separator.parentNode !== null) separator.parentNode.removeChild(separator)
3207
+ menu.removeAttribute(ATTR.menu)
3208
+ })
3209
+ }
3210
+
3211
+ /**
3212
+ * Install the launcher into the workspace section's header icon row.
3213
+ *
3214
+ * OBSERVED (dsh-client-ui-workspace/lib/client.js, the WorkspaceBrowser
3215
+ * component ≈L150370): the section header is
3216
+ *
3217
+ * <div class="<hash>_sectionHeader">
3218
+ * {wide && <span class="<hash>_sectionLabel">…</span>}
3219
+ * {wide && <div class="<hash>_searchSlot">…</div>}
3220
+ * <div class="<hash>_headerActions"> <-- gap:4px, max-width:60px
3221
+ * {wide && <ViewOptionsMenu/>} <-- Tooltip > button.iconButton
3222
+ * {flowAvailable && <Tooltip> <-- button.iconButton, the "+"
3223
+ * <button class="<hash>_iconButton" aria-label="添加工作区"/>
3224
+ * </Tooltip>}
3225
+ * </div>
3226
+ * </div>
3227
+ *
3228
+ * The user's requirement is that the new-cloud-workspace control sits in
3229
+ * that row as a PEER of those icons, immediately LEFT of 添加工作区, rather
3230
+ * than as a separate control elsewhere. So it is inserted as a child of
3231
+ * `headerActions`, immediately before the host's own last control.
3232
+ *
3233
+ * The row is `justify-content:flex-end` with a `flex:1` search slot ahead of
3234
+ * it, so the group is right-aligned and our button takes the position that
3235
+ * puts it first in that right-hand cluster. It is a real `<button>` styled by
3236
+ * `[data-dsh-codespace-launcher]` to the host's `iconButton` metrics, so it
3237
+ * inherits the hover, focus and rail behaviour of its peers.
3238
+ *
3239
+ * A row whose last child is not the "+" control is left untouched: that only
3240
+ * happens when the directory flow is unavailable (then the host renders no
3241
+ * "+" at all), and in that case the settings page's own button still opens
3242
+ * the same window.
3243
+ *
3244
+ * @returns {boolean} whether a launcher is in place.
3245
+ */
3246
+ function patchHeader() {
3247
+ const headers = doc.querySelectorAll('[class*="' + SECTION_HEADER_SUFFIX + '"]')
3248
+ if (headers.length === 0) return false
3249
+ // The sidebar can show more than one section header (sessions vs
3250
+ // workspaces); only the one carrying the icon row is ours.
3251
+ let header = null
3252
+ for (const candidate of headers) {
3253
+ if (candidate.querySelector('[class*="' + HEADER_ACTIONS_SUFFIX + '"]') !== null) {
3254
+ header = candidate
3255
+ break
3256
+ }
3257
+ }
3258
+ if (header === null) return false
3259
+ const actions = header.querySelector('[class*="' + HEADER_ACTIONS_SUFFIX + '"]')
3260
+ if (actions === null) return false
3261
+
3262
+ const existing = actions.querySelector(':scope > [' + ATTR.launcher + ']')
3263
+ if (existing !== null) {
3264
+ if (existing.parentNode !== actions) return true
3265
+ // Keep the launcher immediately before the SAME host control it was
3266
+ // anchored to, rather than re-deriving "the last control" every pass.
3267
+ //
3268
+ // Re-deriving is what produced the drift. The host's `Tooltip` is not
3269
+ // portalled (the workspace header passes no `portal` prop), so while a
3270
+ // bubble is open React renders it as a SIBLING `<span role="tooltip">`
3271
+ // inside this same container; a later host control would make "last
3272
+ // button" something other than the "+", and the launcher would be moved
3273
+ // to sit in front of THAT -- i.e. to the right of 添加工作区. It also
3274
+ // could not self-repair, because in the broken state the launcher's own
3275
+ // next sibling was the thing being looked for.
3276
+ //
3277
+ // Holding the anchor makes every later pass a no-op unless the anchor
3278
+ // itself goes away. Only then is it re-derived, from the host's last
3279
+ // real button (never a tooltip bubble, which is not a BUTTON).
3280
+ if (headerAnchor !== null && headerAnchor.isConnected && headerAnchor.parentNode === actions) {
3281
+ if (existing.nextElementSibling !== headerAnchor) {
3282
+ mutate(() => actions.insertBefore(existing, headerAnchor))
3283
+ }
3284
+ return true
3285
+ }
3286
+ headerAnchor = lastHostButton(actions, existing)
3287
+ if (headerAnchor !== null && existing.nextElementSibling !== headerAnchor) {
3288
+ mutate(() => actions.insertBefore(existing, headerAnchor))
3289
+ }
3290
+ return true
3291
+ }
3292
+
3293
+ const addButton = lastHostButton(actions, null)
3294
+ if (addButton === null) return false
3295
+
3296
+ mutate(() => {
3297
+ const button = doc.createElement('button')
3298
+ button.type = 'button'
3299
+ button.setAttribute(ATTR.launcher, '')
3300
+ button.setAttribute('aria-label', t('launcher.label'))
3301
+ // `role="img"`-style markup only; the accessible name lives on the button.
3302
+ button.appendChild(cloudNode({ plus: true }))
3303
+ // A DSH-shaped bubble, NOT `title`: the OS bubble looks nothing like the
3304
+ // one on the neighbouring host controls.
3305
+ pointTooltip(button, t('launcher.label'))
3306
+ button.addEventListener('click', (event) => {
3307
+ event.stopPropagation()
3308
+ event.preventDefault()
3309
+ ui.openCreate()
3310
+ })
3311
+ actions.insertBefore(button, addButton)
3312
+ headerAnchor = addButton
3313
+ // Marks the container so the CSS above can widen it past the host's
3314
+ // `max-width:60px` without matching any other header.
3315
+ actions.setAttribute(ATTR.header, '')
3316
+ })
3317
+ return true
3318
+ }
3319
+
3320
+ /** Remove the header launcher and its marker. */
3321
+ function cleanHeader() {
3322
+ for (const launcher of doc.querySelectorAll('[' + ATTR.launcher + ']')) {
3323
+ if (launcher.parentNode !== null) launcher.parentNode.removeChild(launcher)
3324
+ }
3325
+ for (const marked of doc.querySelectorAll('[' + ATTR.header + ']')) marked.removeAttribute(ATTR.header)
3326
+ headerAnchor = null
3327
+ }
3328
+
3329
+ /** One coalesced pass over the DOM. Idempotent: it re-derives everything. */
3330
+ function sync() {
3331
+ if (!active) return
3332
+ const records = managedIndex()
3333
+
3334
+ // The header launcher is independent of any managed workspace: it is how
3335
+ // the first cloud workspace gets created, so it is installed even when
3336
+ // the record list is empty.
3337
+ setOwned(patchHeader())
3338
+
3339
+ // Rows: drop the marker from rows that are no longer managed.
3340
+ for (const row of doc.querySelectorAll('[' + ATTR.row + ']')) {
3341
+ const id = workspaceIdOfRow(row)
3342
+ if (id === null || !records.has(id)) {
3343
+ mutate(() => {
3344
+ unpatchRow(row)
3345
+ })
3346
+ }
3347
+ }
3348
+
3349
+ if (records.size > 0) {
3350
+ for (const row of doc.querySelectorAll('[data-row-key^="' + ROW_KEY_PREFIX + '"]')) {
3351
+ const id = workspaceIdOfRow(row)
3352
+ if (id === null) continue
3353
+ const record = records.get(id)
3354
+ if (record === undefined) continue
3355
+ patchRow(row, id, record)
3356
+ }
3357
+ }
3358
+
3359
+ // Hover cards live in a portal on document.body, outside any row.
3360
+ const list = Array.from(records.values())
3361
+ if (list.length > 0) {
3362
+ for (const card of doc.querySelectorAll('[class*="_hoverContent"]')) patchHoverCard(card, list)
3363
+ }
3364
+
3365
+ // The portaled workspace menu.
3366
+ for (const menu of doc.querySelectorAll('[' + ATTR.menu + ']')) {
3367
+ const openRow = doc.querySelector('[data-row-key^="' + ROW_KEY_PREFIX + '"][class*="' + MENU_OPEN_SUFFIX + '"]')
3368
+ const stillOpen = openRow !== null && records.has(String(workspaceIdOfRow(openRow)))
3369
+ if (!stillOpen || menu.isConnected !== true) cleanMenu(menu)
3370
+ }
3371
+ if (records.size > 0) patchMenu(records)
3372
+ }
3373
+
3374
+ /** Coalesce every mutation burst into one microtask. */
3375
+ function queue() {
3376
+ if (!active || queued) return
3377
+ queued = true
3378
+ queueMicrotask(() => {
3379
+ queued = false
3380
+ if (!active) return
3381
+ sync()
3382
+ // Guard against reacting to our own writes: the pass above just made
3383
+ // every record that is still pending either our own injection or a
3384
+ // mutation this pass has already accounted for, so they are dropped.
3385
+ // A later burst — the next poll, the next hover, the next React commit —
3386
+ // arrives through `queue()` again.
3387
+ if (observer !== null) observer.takeRecords()
3388
+ })
3389
+ }
3390
+
3391
+ // The store publishes on every poll and after every mutation, so the
3392
+ // augmentation re-derives from fresh host data without polling itself.
3393
+ const unsubscribe = store.subscribe(() => queue(), false)
3394
+
3395
+ /**
3396
+ * The store's owner claim (docs/CONTRACT.md §6): `subscribe(listener, true)`
3397
+ * makes the first claimer trigger an immediate refresh and the last one to
3398
+ * leave stop the 4 s `workspaces` poll.
3399
+ *
3400
+ * It is held only while the workspace section is actually rendered. The
3401
+ * poll is not free — with at least one managed workspace every tick calls
3402
+ * the Codespaces list API — so claiming it from `apply()` would keep
3403
+ * hitting GitHub even with the sidebar hidden. Keying on the section
3404
+ * header reproduces the lifetime the sidebar-mounted launcher used to
3405
+ * have, without depending on a control that may not exist. It is
3406
+ * deliberately not keyed on the record list: the records are what the
3407
+ * poll produces, so that would be circular.
3408
+ */
3409
+ let claim = null
3410
+ function setOwned(wanted) {
3411
+ if (wanted === true && claim === null) {
3412
+ claim = store.subscribe(() => {}, true)
3413
+ // The row patch reads `alwaysShowCloudButton` out of the store, so the
3414
+ // settings have to be loaded by whoever renders the rows. Without this
3415
+ // the value stays `null` until the settings page is opened, and the
3416
+ // switch silently has no effect on a fresh boot. `loadSettings` is
3417
+ // memoized on `settingsLoaded`, so this costs one request per session
3418
+ // at most, and a failure is not fatal — the row simply keeps the
3419
+ // default (hidden) behaviour.
3420
+ store.loadSettings().catch(() => {})
3421
+ } else if (wanted !== true && claim !== null) {
3422
+ claim()
3423
+ claim = null
3424
+ }
3425
+ }
3426
+
3427
+ sync()
3428
+ if (Observer !== undefined) {
3429
+ observer = new Observer(() => {
3430
+ if (mutating) return
3431
+ queue()
3432
+ })
3433
+ // VERIFY: the hover card is portaled onto document.body, so the observer
3434
+ // must cover the whole body rather than only the sidebar subtree. This is
3435
+ // the same scope dsh-rewind-plugin uses. The callback is coalesced, the
3436
+ // pass is idempotent, and its own records are drained, so the wider scope
3437
+ // costs work but cannot loop.
3438
+ observer.observe(doc.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-row-key', 'class'] })
3439
+ }
3440
+
3441
+ return () => {
3442
+ active = false
3443
+ if (observer !== null) {
3444
+ observer.disconnect()
3445
+ observer = null
3446
+ }
3447
+ unsubscribe()
3448
+ for (const timer of clickTimers.values()) clearTimeout(timer)
3449
+ clickTimers.clear()
3450
+ cleanHeader()
3451
+ for (const row of doc.querySelectorAll('[' + ATTR.row + ']')) unpatchRow(row)
3452
+ // Belt and braces: a hidden host node whose row lost its marker some other
3453
+ // way still has to get its `style` back before this module unloads.
3454
+ for (const host of doc.querySelectorAll('[' + ATTR.hidden + ']')) {
3455
+ const original = host.getAttribute(ATTR.hidden) ?? ''
3456
+ if (original === '') host.removeAttribute('style')
3457
+ else host.setAttribute('style', original)
3458
+ host.removeAttribute(ATTR.hidden)
3459
+ }
3460
+ for (const menu of doc.querySelectorAll('[' + ATTR.menu + ']')) cleanMenu(menu)
3461
+ for (const pathEl of doc.querySelectorAll('[' + ATTR.path + ']')) {
3462
+ const original = pathEl.getAttribute(ATTR.original)
3463
+ if (original !== null && pathEl.textContent !== original) pathEl.textContent = original
3464
+ pathEl.removeAttribute(ATTR.path)
3465
+ pathEl.removeAttribute(ATTR.original)
3466
+ }
3467
+ pending.clear()
3468
+ dropTooltips()
3469
+ }
3470
+ }
3471
+
3472
+ /* ------------------------------------------------------------------ *
3473
+ * 11. apply()
3474
+ * ------------------------------------------------------------------ */
3475
+
3476
+ /**
3477
+ * Register every surface. All resources are owned by `ctx.effect`, so plugin
3478
+ * unload removes the styles, the observers, the timers and the slots.
3479
+ *
3480
+ * @param {object} ctx - the Cordis client context.
3481
+ */
3482
+ function apply(ctx) {
3483
+ const slots = ctx.get('slots')
3484
+ if (slots === undefined) return
3485
+ const locale = ctx.get('locale')
3486
+
3487
+ if (locale !== undefined && typeof locale.register === 'function') {
3488
+ try {
3489
+ ctx.effect(() => {
3490
+ // VERIFY: `locale.register(ns, {zh, en})` and the older
3491
+ // `locale.register(ns, lang, dict)` are both accepted by the builds in
3492
+ // this profile (dsh-pet/dsh-keep-awake use one, dsh-mcp-native the
3493
+ // other). The object form is tried first and the two-argument form is
3494
+ // the fallback.
3495
+ let dispose = null
3496
+ try {
3497
+ dispose = locale.register(LOCALE_NS, { zh: MESSAGES_ZH, en: MESSAGES_EN })
3498
+ } catch (error) {
3499
+ const first = locale.register(LOCALE_NS, 'zh', MESSAGES_ZH)
3500
+ const second = locale.register(LOCALE_NS, 'en', MESSAGES_EN)
3501
+ dispose = () => {
3502
+ first()
3503
+ second()
3504
+ }
3505
+ }
3506
+ return () => {
3507
+ if (typeof dispose === 'function') dispose()
3508
+ }
3509
+ }, 'dsh-codespace-workspace: dictionaries')
3510
+ } catch (error) {
3511
+ console.warn('[dsh-codespace-workspace] locale registration failed; using the inline zh dictionary', error)
3512
+ }
3513
+ }
3514
+
3515
+ const t =
3516
+ locale !== undefined && typeof locale.bind === 'function'
3517
+ ? locale.bind(LOCALE_NS)
3518
+ : (key, params) => interpolate(MESSAGES_ZH[key] !== undefined ? MESSAGES_ZH[key] : key, params)
3519
+
3520
+ ctx.effect(() => {
3521
+ const style = document.createElement('style')
3522
+ style.setAttribute(ATTR.style, '')
3523
+ style.textContent = CSS
3524
+ document.head.appendChild(style)
3525
+ return () => {
3526
+ style.remove()
3527
+ }
3528
+ }, 'dsh-codespace-workspace: styles')
3529
+
3530
+ const store = createStore(rpc)
3531
+ const ui = createUiStore()
3532
+ ctx.effect(
3533
+ () => () => {
3534
+ store.dispose()
3535
+ ui.dispose()
3536
+ },
3537
+ 'dsh-codespace-workspace: stores',
3538
+ )
3539
+
3540
+ const SettingsSection = createSettingsSection({ store, ui, t, locale })
3541
+ const CreateFlow = createNewWorkspaceWindow({ store, ui, t, locale })
3542
+ const DeleteDialog = createDeleteDialog({ store, ui, t, locale })
3543
+
3544
+ ctx.slots.inject('settings.section', () =>
3545
+ ctx.slots.register(
3546
+ {
3547
+ name: 'settings.section',
3548
+ id: 'dsh-codespace-workspace',
3549
+ order: 70,
3550
+ label: () => t('nav'),
3551
+ },
3552
+ SettingsSection,
3553
+ ),
3554
+ )
3555
+
3556
+ ctx.slots.inject('shell.overlay', () =>
3557
+ ctx.slots.register(
3558
+ {
3559
+ name: 'shell.overlay',
3560
+ id: 'dsh-codespace-workspace',
3561
+ order: 900,
3562
+ },
3563
+ function CodespaceOverlay() {
3564
+ useLocaleTick(locale)
3565
+ const state = useStoreState(ui, false)
3566
+ if (state.del !== null) {
3567
+ return h(
3568
+ OverlayWindow,
3569
+ { label: t('delete.title'), onClose: () => ui.close() },
3570
+ h(DeleteDialog, { key: 'del' + String(state.del.seq), target: state.del, onClose: () => ui.close() }),
3571
+ )
3572
+ }
3573
+ if (state.create !== null) {
3574
+ return h(
3575
+ OverlayWindow,
3576
+ { label: t('window.title'), onClose: () => ui.close() },
3577
+ h(CreateFlow, { key: 'create' + String(state.create.seq), onClose: () => ui.close() }),
3578
+ )
3579
+ }
3580
+ return null
3581
+ },
3582
+ ),
3583
+ )
3584
+
3585
+ // No `sidebar.footer.action` entry: the launcher lives in the workspace
3586
+ // section's own header icon row, as a peer of the host's controls (see
3587
+ // `patchHeader`). The store's owner claim — which is what keeps the 4 s
3588
+ // `workspaces` poll alive (docs/CONTRACT.md §6) — is held by the row
3589
+ // augmentation instead, so the data the sidebar needs is owned by the
3590
+ // module that actually renders it, not by a control that may not exist.
3591
+ ctx.effect(() => installRowAugmentation({ store, ui, t }), 'dsh-codespace-workspace: sidebar row augmentation')
3592
+ }
3593
+
3594
+ module.exports.apply = apply
3595
+ module.exports.inject = ['slots']
3596
+ return module.exports
3597
+ },
3598
+ })