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/LICENSE +21 -0
- package/README.md +680 -2
- package/cordis.patch.yml +11 -0
- package/icon.svg +10 -0
- package/lib/client.js +3598 -0
- package/lib/host/codespaces.js +942 -0
- package/lib/host/gh.js +271 -0
- package/lib/host/github.js +619 -0
- package/lib/host/remote-tools.js +565 -0
- package/lib/host/routes.js +555 -0
- package/lib/host/session.js +267 -0
- package/lib/host/transport.js +463 -0
- package/lib/index.js +170 -0
- package/lib/shared.js +181 -0
- package/locale/en.json +122 -0
- package/locale/zh.json +122 -0
- package/package.json +80 -4
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
|
+
})
|