dsh-plugin-cicd 0.0.0-stage → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js ADDED
@@ -0,0 +1,4417 @@
1
+ /**
2
+ * Browser half of the `dsh-plugin-cicd` bundle — 发布台 (Release Console).
3
+ *
4
+ * Four registrations:
5
+ * main the operating view: one compact row per repository
6
+ * sidebar.panellist its sidebar button
7
+ * settings.plugins.tab 「DSH 插件发布台」: the three credentials, one page
8
+ *
9
+ * That settings page holds GitHub, npm and Bilibili as SEGMENTS rather than as three
10
+ * pages of their own. Both shared surfaces this plugin could have taken are flat
11
+ * lists — `settings.section` (the top-level nav) and `settings.plugins.tab` (the strip
12
+ * inside Plugins) — and one row per plugin is this plugin's share of each.
13
+ *
14
+ * The layout rule is density. This panel's job is to answer "which of my
15
+ * repositories needs attention" at a glance, and a card per repository with a
16
+ * path line, a run line and a release list answers it in three screens instead of
17
+ * one. So a row is ~32px and carries only what is actionable; everything else
18
+ * (path, releases, run history, logs) lives in the row's expansion.
19
+ *
20
+ * Nothing here requires a terminal. Signing in runs `gh auth login --web` from the
21
+ * Host and renders the one-time code; registering a repository is a tick on a list
22
+ * fetched from GitHub. The commands still exist — `configure.mjs` is the scripted
23
+ * path, and the setup block shows them collapsed — but they are the fallback, not
24
+ * the instruction.
25
+ *
26
+ * Deliberate choices:
27
+ * - No Harness Client package is required; only `react`, which is a platform
28
+ * seed word in the client module table.
29
+ * - The stylesheet is tagged `data-plugin`/`data-plugin-css` so the loader's
30
+ * claimStyles/removeOwnedStyles cannot take it over or delete it.
31
+ * - A failed BACKGROUND poll never clears the last known state and never raises
32
+ * a banner: it stops updating, so a sleeping machine produces no error the
33
+ * user did not cause.
34
+ * - Colours come only from `--dsw-alias-*`, so both themes stay legible.
35
+ * - Publishing a draft is public and one-way, so it takes two clicks.
36
+ */
37
+
38
+ window.__ModuleLoader__.load({
39
+ id: 'dsh-plugin-cicd',
40
+ factory(require) {
41
+ const React = require('react')
42
+ const h = React.createElement
43
+
44
+ const NS = 'dsh-cicd'
45
+ /** Sidebar entry id and `main` slot key — the same value links the two. */
46
+ const PANEL_ID = 'dsh-cicd'
47
+ /**
48
+ * The console's one page in the Plugins section. `account`, `general` and the rest
49
+ * are shipped sections, so this must not reuse one of their ids.
50
+ */
51
+ const SETTINGS_ID = 'dsh-cicd'
52
+ /**
53
+ * The three credentials, as segments inside that one page.
54
+ *
55
+ * They are not tabs of their own: this plugin would then take three of the rows in
56
+ * a tab strip shared with every other plugin, which is the same unfair share of a
57
+ * shared surface that moved them out of the top-level nav in the first place. As
58
+ * segments they stay one thing — the console's settings — and the strip stays one
59
+ * row per plugin.
60
+ */
61
+ const SEGMENT_GITHUB = 'github'
62
+ const SEGMENT_NPM = 'npm'
63
+ const SEGMENT_BILIBILI = 'bilibili'
64
+ const STYLE_ID = 'dsh-plugin-cicd-styles'
65
+
66
+ const BASE = '/api/dsh-cicd'
67
+ /**
68
+ * The pages a first npm credential needs.
69
+ *
70
+ * Two of these come from GitHub's own migration note for the November 2025 npm
71
+ * token change, which is also where the fact that classic tokens are gone was
72
+ * confirmed — a guide that guessed at either would send a first-time publisher
73
+ * somewhere that no longer exists.
74
+ */
75
+ const NPM_SIGNUP_URL = 'https://www.npmjs.com/signup'
76
+ const NPM_TOKENS_URL = 'https://www.npmjs.com/settings/~/tokens'
77
+ const NPM_TOKEN_DOCS_URL = 'https://docs.npmjs.com/creating-and-viewing-access-tokens'
78
+ /**
79
+ * The Host protocol this page needs.
80
+ *
81
+ * A page refresh replaces this file but not the Host process, so a route can
82
+ * be missing while the button that calls it is on screen. Comparing this
83
+ * number turns that into one sentence instead of a bare HTTP status.
84
+ */
85
+ const PROTOCOL = 6
86
+ const STATUS_TIMEOUT_MS = 20_000
87
+ const OVERVIEW_TIMEOUT_MS = 60_000
88
+ const ACTION_TIMEOUT_MS = 45_000
89
+ /**
90
+ * An update downloads a release tarball and then runs pnpm against the profile.
91
+ * Both are seconds of real work on a machine that is doing nothing else, and the
92
+ * first pnpm run after a store change is the slow one — so this action gets its
93
+ * own, longer bound instead of borrowing the 45 s the dispatch actions use.
94
+ */
95
+ const UPDATE_TIMEOUT_MS = 180_000
96
+ /**
97
+ * Asking npm is one HTTPS request per repository plus a `whoami`; a publish
98
+ * uploads the tarball. Neither is an "action" a person waits on twice, so the
99
+ * status gets a short bound and the publish shares the update's long one.
100
+ */
101
+ const NPM_TIMEOUT_MS = 45_000
102
+ const NOTICE_TTL_MS = 5_000
103
+ /**
104
+ * While an attempt is pending this polls `/auth-state` — and it is the only
105
+ * thing the user is waiting for at that moment. One second rather than two: the
106
+ * code normally exists within a second of the click, this is a loopback call,
107
+ * and it stops as soon as the attempt settles.
108
+ */
109
+ const AUTH_POLL_MS = 1_000
110
+
111
+ /** Simplified Chinese dictionary (key-set source of truth). */
112
+ const zh = {
113
+ 'panel': 'DSH 插件发布台',
114
+ 'title': 'DSH 插件发布台',
115
+ 'subtitle': '持续构建与发布(CI/CD)',
116
+ 'settings.consoleLabel': 'DSH 插件发布台',
117
+ 'settings.segment.github': 'GitHub',
118
+ 'settings.title': 'GitHub 账户',
119
+ 'settings.subtitle': '发布台用这台机器上的 gh 访问 GitHub,插件自己不保存任何凭据。',
120
+ 'action.refresh': '刷新',
121
+ 'action.manage': '管理仓库',
122
+ 'action.close': '收起',
123
+ 'action.commit': '提交',
124
+ 'action.push': '推送',
125
+ 'action.commitConfirm': '确认提交',
126
+ 'action.pushConfirm': '确认推送',
127
+ 'action.build': '构建',
128
+ 'action.release': '发布',
129
+ 'action.bumpRelease': '升版本并发布',
130
+ 'action.publishDraft': '公开草稿',
131
+ 'action.confirmPublish': '确认公开?',
132
+ 'action.update': '更新到 {tag}',
133
+ 'action.installRelease': '装 Release {tag}',
134
+ 'action.confirmUpdate': '确认更新',
135
+ 'action.restartNow': '立即重启 DSH',
136
+ 'action.restartLater': '稍后',
137
+ 'action.open': '打开',
138
+ 'action.logs': '日志',
139
+ 'action.hideLogs': '收起日志',
140
+ 'action.rerun': '重跑',
141
+ 'action.cancel': '取消',
142
+ 'action.expand': '详情',
143
+ 'action.collapse': '收起',
144
+ 'action.add': '添加',
145
+ 'action.remove': '移除',
146
+ 'action.signIn': '用浏览器登录 GitHub',
147
+ 'action.recheck': '重新检测',
148
+ 'action.grantScopes': '补齐权限',
149
+ 'action.copy': '复制',
150
+ 'action.copied': '已复制',
151
+ 'action.openDevicePage': '打开授权页面',
152
+ 'action.cancelSignIn': '取消登录',
153
+ 'action.signOut': '退出登录',
154
+ 'action.signOutConfirm': '确认退出登录?',
155
+ 'state.signedOut': '已退出登录:令牌已从系统凭据存储移除。注意 git 推送会随之失效(git 的凭据本来就由 gh 提供),重新登录后恢复。',
156
+ 'label.runs': '运行记录',
157
+ 'chip.published': '已发布',
158
+ 'chip.unpublished': '未发布',
159
+ 'chip.draft': '草稿',
160
+ 'chip.versionTaken': '版本被占用',
161
+ 'chip.update': '可更新 {tag}',
162
+ 'chip.checkout': '本地检出',
163
+ 'chip.dirty': '未提交 {count}',
164
+ 'chip.ahead': '领先 {count}',
165
+ 'chip.behind': '落后 {count}',
166
+ 'chip.private': '私有',
167
+ 'state.loading': '正在读取…',
168
+ 'state.reading': '读取中…',
169
+ 'state.hostGone': '无法连接 Host;面板会保留上一次的数据,稍后自动重试。',
170
+ 'state.timeout': '请求超时,Host 没有在时限内回答。',
171
+ 'stale.title': '页面与宿主半边版本不一致',
172
+ 'stale.text': '页面已经是最新的,但运行中的宿主半边还是旧版本——它里面没有这条接口,所以按钮会报 401/404。刷新页面不会更新宿主。',
173
+ 'stale.how': '点侧边栏底部的一键重启(或重启 DeepSeek Harness)之后,两半就一致了。',
174
+ 'state.noRuns': '还没有运行记录',
175
+ 'state.noReleases': '还没有 Release',
176
+ 'state.dispatched': '已触发 {workflow}({repo})。',
177
+ 'state.releaseDispatched': '已触发发布流程({repo})。它产出的是草稿——但草稿是否真的出现,要看这次运行的结果:失败时展开该行点【日志】就能看到原因(最常见的是版本号没升)。',
178
+ 'state.releaseBumped': '已把 {from} 升到 {tag} 并推送 {branch},发布流程也已触发。产出的是草稿,是否成功看这次运行的结果。',
179
+ 'state.committed': '已提交并推送到 {branch}({count} 个改动)。现在 构建 与 发布 都能看到它们了。',
180
+ 'state.pushed': '已把 {count} 个本地提交推送到 {branch}。',
181
+ 'commit.noCheckout': '这个仓库没有可用的本地检出,所以没有可提交的东西。',
182
+ 'commit.willCommit': '将提交 {count} 个改动:',
183
+ 'commit.willPush': '工作区干净,有 {count} 个本地提交等着推送——发布构建的是 GitHub 上的提交,不是这台机器上的。',
184
+ 'commit.nothing': '工作区干净,且与上游同步:没有可提交或推送的东西。',
185
+ 'commit.messagePlaceholder': '提交信息',
186
+ 'commit.messageRequired': '提交信息不能为空。',
187
+ 'confirm.push': '把 {count} 个本地提交推送到 {branch}?',
188
+ 'state.published': '{tag} 已公开。',
189
+ 'state.rerun': '已请求重跑。',
190
+ 'state.cancelled': '已请求取消。',
191
+ 'state.actionFailed': '操作失败:{reason}',
192
+ 'state.registered': '已添加 {repo}。',
193
+ 'state.removed': '已移除 {repo}。',
194
+ 'state.truncated': '(只显示最后 {lines} 行)',
195
+ 'drafts.title': '有 {count} 个草稿还没公开',
196
+ 'drafts.text': '草稿对外不可见,也不创建 tag,所以在仓库的 Releases 页面看不到——这不是失败。展开下面带【草稿】标记的行,点【公开草稿】即可公开发布。',
197
+ 'meta.account': '账号',
198
+ 'meta.updated': '更新于 {time}',
199
+ 'meta.cached': '缓存',
200
+ 'meta.version': '本地',
201
+ 'meta.expectedTag': '期望 tag {tag}',
202
+ 'meta.noLocal': '未配置本地路径,无法对比本地状态',
203
+ 'clone.title': '本地检出',
204
+ 'clone.why.none': '还没有本地检出。克隆下来之后,提交 / 构建 / 发布才有东西可作用于它——这些动作都需要一份工作区。',
205
+ 'clone.why.broken': '配置的路径不可用({path}):{reason}',
206
+ 'clone.urlPlaceholder': '仓库地址,如 https://github.com/owner/repo.git',
207
+ 'clone.action': '克隆',
208
+ 'clone.target': '将克隆到 {path}',
209
+ 'clone.noRoot': '还没设置检出根目录:请在 profile 的 cordis.patch.yml 里给 `projectsRoot` 一个绝对路径,否则不知道该克隆到哪里。',
210
+ 'clone.done': '已克隆到 {path}。',
211
+ 'meta.assets': '{count} 个资产',
212
+ 'meta.path': '路径',
213
+ 'meta.scopes': '权限',
214
+ 'meta.noScopes': '(gh 未报告权限列表)',
215
+ 'meta.gh': 'gh',
216
+ 'meta.configFrom': '配置来源',
217
+ 'meta.projectsRoot': '本地检出根目录',
218
+ 'meta.projectsRootUnset': '未设置,添加仓库时无法自动找到本地检出',
219
+ 'run.unknown': '未知',
220
+ 'release.takenTitle': '发布前检查:这个版本号已经属于另一个提交',
221
+ 'release.taken': '{tag} 已属于 {owner},而这次发布会构建 {built}——release.yml 会拒绝覆盖属于另一个提交的同号 Release(否则 tag 与资产会不一致),所以直接发布必然失败。先把它升到 {next} 再发布。',
222
+ 'release.takenNoNext': '{tag} 已属于 {owner},而这次发布会构建 {built}——release.yml 会拒绝覆盖属于另一个提交的同号 Release,所以直接发布必然失败。先把版本号升到下一号。',
223
+ 'release.dirtyNote': '工作区有 {count} 个未提交改动:发布构建的是已推送的提交,这些改动不会进入发布包(升版本也会因此被拒绝)。',
224
+ 'release.aheadNote': '有 {count} 个提交还没推送:发布构建的是远端分支,推送之前它们不会进入发布包。',
225
+ 'release.dirty': '工作区有 {count} 个未提交改动,先提交或暂存它们。',
226
+ 'release.noUpstream': '分支 {branch} 没有上游,无法推送。',
227
+ 'release.behind': '分支落后上游 {count} 个提交,先拉取再发布。',
228
+ 'release.noCheckout': '这个仓库没有可用的本地检出(找不到 package.json)。',
229
+ 'release.noVersion': '本地读不到可升级的版本号。',
230
+ 'release.pushFailed': '本地已提交 {tag},但推送失败:{reason}(提交还在本地,重试推送即可)',
231
+ 'release.carried': '这次推送同时带上了此前未推送的 {count} 个提交。',
232
+ 'confirm.bumpRelease': '把 package.json 从 {from} 升到 {to}、提交并推送到 {branch},然后触发发布?这会创建一个提交。',
233
+ 'confirm.publish': '公开发布 {tag}?发布后任何人可见,无法收回。',
234
+ 'confirm.cancel': '再想想',
235
+ 'confirm.update': '把 {profile} profile 里的 {package}(现在指向 {spec})换成 Release {tag} 的 tgz 并重装?这会改写该 profile 的依赖条目。',
236
+ 'install.title': '安装',
237
+ 'install.notInstalled': '{package} 不在 {profile} profile 的依赖里,所以没有"已装的那份"可以更新。',
238
+ 'install.noRelease': '这个仓库还没有已发布的 Release(草稿不算),所以没有可以装的东西。',
239
+ 'install.current': 'profile 里就是 Release {tag} 的包,已经是最新的。',
240
+ 'install.checkout': 'profile 里这份指向本地检出({spec}),跑的不是发布出去的那份。装 Release 会用发布包替换它;要回到开发状态再用 dsh plugin add link:<检出路径> 换回来。',
241
+ 'install.ahead': 'profile 里是 v{installed},比这个 Release({tag})新——装它等于用发布包退回到旧版本。',
242
+ 'install.differs': 'profile 里是 {installed},与 Release {tag} 不是同一号版本,无法比较先后。',
243
+ 'state.updated': '{package} 已从 {from} 换成 {tag} 的发布包。新版本要重启 DSH 才会真正生效。',
244
+ 'state.updatedSame': '{package} 已经指向 {tag} 这份发布包了:没有改动,也不需要重启。',
245
+ 'npm.title': 'npm',
246
+ 'npm.chipPublished': 'npm v{version}',
247
+ 'npm.chipPending': 'npm 待推 v{version}',
248
+ 'npm.chipUnregistered': 'npm 上还没有',
249
+ 'npm.account': 'npm {account}',
250
+ 'npm.registry': '源 {registry}',
251
+ 'npm.recheck': '重新检测 npm',
252
+ 'npm.notLoggedIn': '{command} 还没有登录 {registry}——发布需要 npm 的凭据,而插件自己不保存 token。下面是从零开始的四步。',
253
+ 'npm.guide.step1': '① 还没有 npm 账号?',
254
+ 'npm.guide.signup': '打开 npmjs.com 注册',
255
+ 'npm.guide.emailNote': '注册后**必须**到邮箱点确认链接:npm 不允许未验证邮箱的账号发布。',
256
+ 'npm.guide.step2': '② 生成一个 Access Token',
257
+ 'npm.guide.tokens': '打开 token 页面',
258
+ 'npm.guide.tokenType': '类型只有一种可选:Granular Access Token。Classic token 已于 2025-11-19 被 npm 全部撤销,现在也建不出来——别去找它。',
259
+ 'npm.guide.tokenScope': 'Permissions 选 Read and write;Packages 选全部,或只勾这个包(只勾一个包更安全)。',
260
+ 'npm.guide.tokenExpiry': '有效期最长 90 天,这是 npm 对可写 token 的硬限制。到期后 token 就失效了,回到第 ② 步再生成一个。',
261
+ 'npm.guide.token2fa': '只有"给 CI 用、没有人能输一次性密码"时才勾 Bypass 2FA。在这里推**不要勾**:推送时面板会让你填一次性密码。',
262
+ 'npm.guide.blocked': '如果 npmjs.com 在你这台机器上打不开,或者被"我不是机器人"的验证拦住:**换一台设备或换一个网络**(手机流量最省事)打开 npmjs.com 生成 token,再把那串字符粘到第 ③ 步。面板只认这个 token,不在乎它是在哪里生成的——这一步和你本机的浏览器无关。',
263
+ 'npm.guide.step3': '③ 粘贴到下面',
264
+ 'npm.guide.step3Note': '它会写进 {npmrc}(npm 自己读的用户级配置文件,其他行原样保留);插件不保存 token,请求一返回输入框就清空。',
265
+ 'npm.guide.step4': '④ 账号开了 2FA?',
266
+ 'npm.guide.step4Note': '推送的确认行里会出现一次性密码输入框,填 6 位数字再点一次【确认推送】即可。',
267
+ 'npm.guide.docs': 'npm 官方文档',
268
+ 'npm.stepUnknown': '无法自动检测',
269
+ 'npm.stepTodo': '待做',
270
+ 'npm.stepAccountDone': '✓ 已有账号 {account}',
271
+ 'npm.stepTokenDone': '✓ 已有可用的 token',
272
+ 'npm.stepWrittenDone': '✓ {npmrc} 里已有凭据',
273
+ 'npm.status.title': '凭据状态',
274
+ 'npm.status.signedIn': '已登录 {account}',
275
+ 'npm.status.credentialPresent': '已有凭据,whoami 未确认',
276
+ 'npm.status.none': '还没有凭据',
277
+ 'npm.status.unknown': '还没读到 npm 的状态',
278
+ 'npm.status.unknownHint': 'Host 还没回答 /npm-status(可能是运行中的宿主半边还是旧版本,也可能面板刚打开)。点「重新检测」。在没有答案之前,这里不替你下结论。',
279
+ 'npm.packages.title': '每个包在 npm 上的状态',
280
+ 'npm.packages.empty': '还没有登记仓库,所以没有可检查的包。',
281
+ 'npm.packages.recheck': '重新检测',
282
+ 'npm.package.published': '已在 npm 上',
283
+ 'npm.package.unpublished': '待推',
284
+ 'npm.package.unregistered': '名字空闲',
285
+ 'npm.package.unknown': '读不到',
286
+ 'npm.package.local': '本地 {version}',
287
+ 'npm.package.latest': 'npm 上是 {latest}',
288
+ 'npm.credentialPresentShort': '.npmrc 里已经有 {registry} 的凭据,但 whoami 没有确认它——granular token 是包级范围的,whoami 是用户级端点,这很正常。直接推即可,真伪由推送来判。',
289
+ 'npm.moreInSettings': '完整引导与每个包的状态在 设置 → 插件 → DSH 插件发布台 · npm。',
290
+ 'npm.ciHint': '要给 CI 自动发布?那不该用这里的 token——npm 支持 trusted publishing(GitHub Actions OIDC),不需要任何长期凭据。',
291
+ 'settings.segment.npm': 'npm',
292
+ 'settings.npmTitle': 'npm 凭据',
293
+ 'settings.npmSubtitle': '发布用 Host 自己的包管理器,凭据走 npm 自己读的 ~/.npmrc——插件不保存任何 token。',
294
+ 'settings.npmGuideTitle': '从零开始的四步',
295
+ 'npm.credentialPresentTitle': '这个源上已经有一行 token',
296
+ 'npm.credentialPresent': '.npmrc 里已经有 {registry} 的凭据,但 whoami 没有确认它。granular token 是包级范围的,而 whoami 是用户级端点——它拒绝一个能正常发布的 token 是正常的。所以推送本身才是真正的验证:直接推,失败时下方会告诉你是要填一次性密码、还是重建 token。',
297
+ 'npm.rejectedTitle': '这个 token 没有被接受。按顺序排查:',
298
+ 'npm.rejectedCauses': '① 复制不全(前后带了空格或换行);② 权限不是 Read and write;③ 已被撤销、或超过 90 天有效期;④ token 被限制到别的包或组织;⑤ 账号邮箱还没验证。',
299
+ 'npm.hint.otp-required': '这个账号要求一次性密码:在推送的确认行里填 6 位数字,再点一次【确认推送】。',
300
+ 'npm.hint.email-unverified': '先去邮箱点确认链接——npm 不允许未验证邮箱的账号发布——然后回来重试。',
301
+ 'npm.hint.not-logged-in': 'token 无效、过期或已被撤销:照上面第 ② 步重新生成一个(可写 token 最长 90 天)。',
302
+ 'npm.hint.already-published': '这个版本已经在 npm 上了。npm 不接受同一版本推两次:先用【升版本并发布】升版本,再推。',
303
+ 'npm.hint.payment-required': 'npm 对私有包收费,公开包免费。检查 package.json 里有没有 private 字段、以及发布的是不是公开包。',
304
+ 'npm.hint.forbidden': '这个 token 没有被授权发布这个包:检查 token 的 Packages 范围,以及包名是否属于你的账号或组织。',
305
+ 'npm.hint.not-found': '源上没有这个包名可发布的位置:确认包名拼写、以及账号或组织名。',
306
+ 'npm.hint.rate-limited': '被源限流了,等几分钟再试。',
307
+ 'npm.hint.registry-error': '这是源自己出错(5xx),不是你配置的问题,稍后重试。',
308
+ 'npm.hint.network': '连不上源:检查网络或代理设置。',
309
+ 'npm.hint.timeout': '推送超过时限还没结束:可能是网络慢,也可能源在等你回答什么。重试一次,仍失败就看下面的原话。',
310
+ 'npm.hint.unknown': '没识别出具体原因,看下面 npm 的原话。',
311
+ 'npm.tokenPlaceholder': 'npm_… 或粘贴 Access Token',
312
+ 'npm.writeToken': '写入并验证',
313
+ 'npm.tokenWritten': 'token 已写入 {npmrc},当前账号 {account}。',
314
+ 'npm.npmrcPath': '配置文件',
315
+ 'npm.state.published': '这个版本已经在 npm 上了({version}),没有可推的。',
316
+ 'npm.state.unregistered': 'npm 上还没有这个名字:这次推送就是它的首次发布。',
317
+ 'npm.state.unpublished': 'npm 上是 {latest},本地这个 {version} 还没推过。',
318
+ 'npm.state.unknown': '读不到 npm 的状态({reason}),所以不提供推送——宁可不给按钮,也不给一个注定失败的按钮。',
319
+ 'npm.action.publish': '推送到 npm v{version}',
320
+ 'npm.action.confirm': '确认推送',
321
+ 'npm.otpLabel': '一次性密码',
322
+ 'npm.otpHint': '账号开了 2FA 才需要;填一次即可,不会被保存。',
323
+ 'npm.otpPlaceholder': '6 位数字',
324
+ 'npm.blocked.private-package': 'package.json 里写着 "private": true,npm 会拒绝。这是作者的决定,不是这里该绕过的东西。',
325
+ 'npm.blocked.not-logged-in': '还没有登录 npm,先把 token 写进去。',
326
+ 'npm.blocked.dirty-tree': '工作区有未提交改动:publish 打包的是工作目录,先提交或暂存(files 白名单挡不住名单目录里的新文件)。',
327
+ 'npm.blocked.no-checkout': '这个仓库没有可用的本地检出。',
328
+ 'npm.blocked.no-version': 'package.json 里没有版本号。',
329
+ 'npm.blocked.no-package-name': '读不到包名。',
330
+ 'npm.blocked.already-published': '这个版本已经在 npm 上了(npm 不允许同一版本推第二次),先升版本。',
331
+ 'npm.blocked.registry-unreachable': '连不上 npm 源,无法确认这个版本在不在。',
332
+ 'confirm.npmPublish': '把 {package}@{version} 推到 {registry}?npm 不允许同一版本推第二次,撤回也只在很短的时间内可行——推上去任何人可见。',
333
+ 'state.npmPublished': '已把 {package}@{version} 推到 {registry}。',
334
+ 'state.npmFirstPublish': '{package}@{version} 已首次发布到 {registry}。',
335
+ 'npm.githubDraft': '注意:GitHub 上这个版本只有草稿(或还没有 Release)——npm 会先于 Release 面世。',
336
+ 'state.updatedBuilds': '(注意:这次安装有 {count} 个构建脚本被拦住,没有执行。)',
337
+ 'restart.ask': '{package} 已经更新到 {tag},但要重启 DSH 才会用上它——现在重启?',
338
+ 'restart.scheduled': '已安排重启:DSH 会在一两秒后关闭并自动重新打开。刷新页面不够,模块已经加载在运行中的进程里。',
339
+ 'restart.unavailable': '重启不了:{reason}',
340
+ 'setup.needGh': '没有找到 gh CLI,发布台无法访问 GitHub。',
341
+ 'setup.needSignIn': '还没有登录 GitHub。登录后这里会列出你的仓库。',
342
+ 'setup.needScopes': '当前凭据缺少权限:{scopes}。',
343
+ 'setup.scopeHint': '缺 repo 读不到私有仓库,缺 workflow 无法触发构建。',
344
+ 'setup.needRepos': '还没有登记仓库。',
345
+ 'setup.codeHint': '在浏览器里打开下面的地址,输入这个一次性代码:',
346
+ 'setup.waiting': '等待授权…(在浏览器里完成即可,这里会自动继续)',
347
+ 'setup.requestingCode': '正在向 GitHub 申请一次性码…',
348
+ 'setup.codePending': '授权页面已打开——请把下面出现的码输入进去。码还没到,稍等一下它就会出现。',
349
+ 'setup.stalled': '已等待 {seconds} 秒仍没有一次性码:{detail}。最常见的原因是 github.com 不可达(面板读取仓库用的 api.github.com 是另一条线路,可能仍然正常)。你可以继续等——网络恢复后码会自己出现——也可以取消后重试。',
350
+ 'setup.stalledNoOutput': 'gh 完全没有输出',
351
+ 'setup.stalledQuietOutput': 'gh 只输出了空白内容(通常在重试连接)',
352
+ 'setup.stalledSaid': 'gh 说:{text}',
353
+ 'setup.unreachableTitle': 'github.com 当前连不上',
354
+ 'setup.unreachable': '提示:从本机连不上 github.com:443,而设备码流程需要它——如果一直不出码,多半是这个原因。gh 已经在尝试;若它其实能连上(例如走代理),码仍会出现。',
355
+ 'setup.dnsTitle': 'DNS 解析 github.com 失败',
356
+ 'setup.dns': '提示:本机解析不出 github.com 的地址,而地址本身是通的(实测直连它的 IP 只需几十毫秒)。gh 也要自行解析域名,所以它连请求都发不出去,这就是一直不出码的原因。换个 DNS 或稍后重试即可;gh 已在尝试,解析一恢复码就会出现。',
357
+ 'setup.succeeded': '授权成功,正在读取账号…',
358
+ 'setup.failed': '授权未完成:{reason}',
359
+ 'setup.expired': '一次性代码已过期,请重新开始。',
360
+ 'setup.cancelled': '已取消登录。',
361
+ 'setup.advanced': '用命令行配置(可选)',
362
+ 'setup.ghInstall': '安装 GitHub CLI(任选其一)',
363
+ 'picker.title': '添加仓库',
364
+ 'picker.search': '搜索仓库',
365
+ 'picker.empty': '没有可添加的仓库(可能是没登录,或账号下没有仓库)。',
366
+ 'picker.loading': '正在读取你的仓库…',
367
+ 'picker.failed': '读取仓库列表失败:{reason}',
368
+ 'picker.local': '本地检出',
369
+ 'picker.noLocal': '未找到本地检出',
370
+ 'picker.hint': '勾选即添加,取消勾选即移除;改动立刻生效,不需要重启。',
371
+ 'picker.reload': '重新载入',
372
+
373
+ /* -- Bilibili update notes ------------------------------------------- */
374
+ 'settings.segment.bilibili': 'Bilibili',
375
+ 'bili.settings.title': 'B 站更新播报',
376
+ 'bili.settings.subtitle': '插件发版后,在对应视频的评论区自动补一条更新说明;凭据只落在插件自己的目录里,插件不回显它。',
377
+ 'bili.credential.title': '登录状态',
378
+ 'bili.credential.ready': '已登录 {uname}',
379
+ 'bili.credential.none': '还没有 B 站凭据',
380
+ 'bili.credential.unreadable': '凭据读不出来',
381
+ 'bili.credential.incomplete': '凭据不完整',
382
+ 'bili.credential.not-logged-in': '这份凭据发不了评论',
383
+ 'bili.credential.unverified': '还没有验证这份凭据',
384
+ 'bili.credential.none.hint': '发评论走的是 B 站 Web 接口,所以凭据必须是网页登录。biliup 默认写下的 cookies.json 是 APP/TV 登录(platform=BiliTV),Web 会员接口一律回 -101「账号未登录」——它能投稿,但发不了评论。',
385
+ 'bili.credential.source': '凭据文件 {path}',
386
+ 'bili.credential.external': '这份凭据来自配置指定的外部文件(不是面板写入的):{path}',
387
+ 'bili.signIn': '登录 B 站',
388
+ 'bili.signIn.hint': '点一下会生成一个二维码:用手机 B 站 App 扫,然后在手机上确认。这里会自动继续,不需要终端。扫不动就用下面粘贴的方式。',
389
+ 'bili.qrLabel': 'B 站登录二维码',
390
+ 'bili.qrHint': '用手机 B 站 App 扫码。二维码约 3 分钟后过期,过期就再点一次【登录 B 站】。',
391
+ 'bili.qrTooLong': '这条登录链接太长,画不成二维码——请改用下面粘贴 SESSDATA 的方式。',
392
+ 'bili.waiting': '等待扫码…',
393
+ 'bili.waitingScanned': '已扫码,请在手机上确认…',
394
+ 'bili.signIn.done': '已登录:{uname}',
395
+ 'bili.signIn.doneNoName': '已登录,但没能读到账号名。',
396
+ 'bili.signIn.expired': '二维码已过期,重新点一次【登录 B 站】。',
397
+ 'bili.cancelSignIn': '取消登录',
398
+ 'bili.paste.title': '或者粘贴一次',
399
+ 'bili.paste.hint': '浏览器里 F12 → Application → Cookies → bilibili.com,复制 SESSDATA 与 bili_jct(后者就是 csrf)。只写进插件自己的文件,验证通过才保存。',
400
+ 'bili.paste.sessdata': 'SESSDATA',
401
+ 'bili.paste.csrf': 'bili_jct',
402
+ 'bili.paste.save': '保存并验证',
403
+ 'bili.paste.saved': '凭据已保存:{uname}',
404
+ 'bili.signOut': '退出 B 站登录',
405
+ 'bili.signOut.confirm': '确认退出?',
406
+ 'bili.signOut.done': '已删除插件保存的 B 站凭据。',
407
+ 'bili.signOut.fallback': '注意:还有一份外部凭据继续生效({path}),它属于别的工具,这里没有动它。',
408
+ 'bili.bind.title': '绑定的视频',
409
+ 'bili.chip.announced': '已播报 {tag}',
410
+ 'bili.chip.pending': '待播报 {tag}',
411
+ 'bili.bind.placeholder': 'BV 号,如 BV1RopP6FEJp',
412
+ 'bili.bind.save': '绑定',
413
+ 'bili.bind.unbind': '解绑',
414
+ 'bili.bind.locked': '已绑定:要换视频先【解绑】。',
415
+ 'bili.bind.done': '已绑定 {bvid}{baseline}',
416
+ 'bili.bind.baseline': '(基线 {tag}:只有之后发布的新版本会自动播报)',
417
+ 'bili.bind.baselineUnknown': '(读不到 Release 列表,绑定时刻之前的版本都按已公开处理)',
418
+ 'bili.unbound.done': '已解绑 {repo} 的 B 站视频,播报记录保留。',
419
+ 'bili.auto.on': '自动播报',
420
+ 'bili.auto.off': '仅手动',
421
+ 'bili.auto.toggle': '切换自动播报',
422
+ 'bili.announce': '发更新评论',
423
+ 'bili.announce.again': '已播报过,重新预览',
424
+ 'bili.announce.preview': '将发送这条评论',
425
+ 'bili.announce.confirm': '确认发送',
426
+ 'bili.announce.force': '仍然发送',
427
+ 'bili.announce.cancel': '取消',
428
+ 'bili.announce.sent': '已发送到 {bvid} 的评论区({tag}),并且已经能公开读到。',
429
+ 'bili.announce.unverified': 'B 站收下了这条评论({bvid} · {tag}),但公开列表里读不到——可能还在审核,也可能已被移除。要重发就用【仍然发送】。',
430
+ 'bili.visible.false': '已发送但读不到',
431
+ 'bili.visible.hint': 'B 站接受了这条评论,但公开列表里读不到它(B 站原话:{code})。审核中的和被移除的,从接口上看一模一样。可以用【仍然发送】重发。',
432
+ 'bili.announce.previewFailed': '预览失败:{reason}',
433
+ 'bili.repo.none': '这个仓库还没有绑定 B 站视频。',
434
+ 'bili.state.already': '{tag} 已经播报过了。',
435
+ 'bili.state.baseline': '{tag} 在绑定视频之前就已经发布了,不算这次更新。',
436
+ 'bili.state.no-release': '还没有已公开的 Release(草稿不算)——先在发布台把草稿公开。',
437
+ 'bili.state.gave-up': '这条已经失败 {count} 次,先看看原因再手动重试。',
438
+ 'bili.state.unbound': '这个仓库还没有绑定 B 站视频。',
439
+ 'bili.needCredential': '有仓库绑定了 B 站视频,但还没有可用的 B 站凭据,更新评论发不出去。',
440
+ 'bili.openSettings': '在 设置 → 插件 → DSH 插件发布台 · Bilibili 里管理',
441
+ 'bili.lastSweep': '自动巡检 {time}:{summary}',
442
+ 'bili.sweep.none': '还没有需要播报的新版本',
443
+ 'bili.sweep.announced': '已播报 {repo} {tag}',
444
+ 'bili.sweep.failed': '{repo} {tag} 播报失败',
445
+ 'bili.neverSwept': '自动巡检还没有跑过',
446
+ 'bili.ledger.problem': '播报记录读不出来({problem})。在修好之前不会发送任何评论——否则会把已经发过的评论再发一遍。',
447
+ 'bili.template': '模板',
448
+ 'bili.template.hint': '可用占位符:{tag} {version} {label} {repo} {title} {summary} {url} {date}。默认不含链接,因为带链接的评论更容易被 B 站过滤。',
449
+ 'bili.unknownPlaceholder': '模板里有不认识的占位符:{names}',
450
+ 'bili.video.failed': '读不到视频:{reason}',
451
+ 'bili.watch.off': '自动巡检已关(bilibiliWatchSeconds: 0),只剩手动按钮与公开草稿后的那一次。',
452
+ 'bili.repos.title': '每个仓库',
453
+ 'bili.repos.empty': '还没有登记仓库。',
454
+ 'bili.announced.title': '已播报',
455
+ 'bili.announced.none': '还没有播报过。',
456
+ 'bili.failure.last': '上次失败:{message}',
457
+ }
458
+
459
+ /** English dictionary, same key set. */
460
+ const en = {
461
+ 'panel': 'DSH Plugin CI/CD',
462
+ 'title': 'DSH Plugin CI/CD',
463
+ 'subtitle': 'Continuous builds and releases for DSH plugins',
464
+ 'settings.consoleLabel': 'DSH Release Console',
465
+ 'settings.segment.github': 'GitHub',
466
+ 'settings.title': 'GitHub account',
467
+ 'settings.subtitle': 'The console reaches GitHub through the gh CLI on this machine; the plugin stores no credential of its own.',
468
+ 'action.refresh': 'Refresh',
469
+ 'action.manage': 'Repositories',
470
+ 'action.close': 'Done',
471
+ 'action.commit': 'Commit',
472
+ 'action.push': 'Push',
473
+ 'action.commitConfirm': 'Commit',
474
+ 'action.pushConfirm': 'Push',
475
+ 'action.build': 'Build',
476
+ 'action.release': 'Release',
477
+ 'action.bumpRelease': 'Bump and release',
478
+ 'action.publishDraft': 'Publish',
479
+ 'action.confirmPublish': 'Publish?',
480
+ 'action.update': 'Update to {tag}',
481
+ 'action.installRelease': 'Install release {tag}',
482
+ 'action.confirmUpdate': 'Update',
483
+ 'action.restartNow': 'Restart DSH now',
484
+ 'action.restartLater': 'Later',
485
+ 'action.open': 'Open',
486
+ 'action.logs': 'Logs',
487
+ 'action.hideLogs': 'Hide logs',
488
+ 'action.rerun': 'Re-run',
489
+ 'action.cancel': 'Cancel',
490
+ 'action.expand': 'Details',
491
+ 'action.collapse': 'Less',
492
+ 'action.add': 'Add',
493
+ 'action.remove': 'Remove',
494
+ 'action.signIn': 'Sign in with a browser',
495
+ 'action.recheck': 'Re-check',
496
+ 'action.grantScopes': 'Grant scopes',
497
+ 'action.copy': 'Copy',
498
+ 'action.copied': 'Copied',
499
+ 'action.openDevicePage': 'Open the authorization page',
500
+ 'action.cancelSignIn': 'Cancel sign-in',
501
+ 'action.signOut': 'Sign out',
502
+ 'action.signOutConfirm': 'Confirm sign out?',
503
+ 'state.signedOut': 'Signed out: the token was removed from the operating system credential store. git push stops working until you sign in again — git takes its credential from gh.',
504
+ 'label.runs': 'Runs',
505
+ 'chip.published': 'Released',
506
+ 'chip.unpublished': 'Unreleased',
507
+ 'chip.draft': 'Draft',
508
+ 'chip.versionTaken': 'version taken',
509
+ 'chip.update': 'update {tag}',
510
+ 'chip.checkout': 'local checkout',
511
+ 'chip.dirty': '{count} uncommitted',
512
+ 'chip.ahead': '{count} ahead',
513
+ 'chip.behind': '{count} behind',
514
+ 'chip.private': 'private',
515
+ 'state.loading': 'Loading…',
516
+ 'state.reading': 'reading…',
517
+ 'state.hostGone': 'Cannot reach the Host; the panel keeps the last known data and retries on its own.',
518
+ 'state.timeout': 'The request timed out without an answer from the Host.',
519
+ 'stale.title': 'The page and the Host half are different versions',
520
+ 'stale.text': 'This page is current, but the running Host half is older and does not have this route, so the buttons answer 401/404. Refreshing the page does not replace the Host.',
521
+ 'stale.how': 'Use the one-click restart at the foot of the sidebar (or restart DeepSeek Harness) and the two halves match again.',
522
+ 'state.noRuns': 'no runs yet',
523
+ 'state.noReleases': 'no releases yet',
524
+ 'state.dispatched': 'Triggered {workflow} on {repo}.',
525
+ 'state.releaseDispatched': 'Release workflow triggered for {repo}. It produces a DRAFT — but whether a draft actually appears depends on that run: if it fails, expand the row and press Logs to see why (a stale version number is the usual reason).',
526
+ 'state.releaseBumped': 'Bumped {from} to {tag} and pushed {branch}; the release workflow is triggered. It produces a draft, and whether it succeeds depends on that run.',
527
+ 'state.committed': 'Committed and pushed {branch} ({count} change(s)). Build and Release can see them now.',
528
+ 'state.pushed': 'Pushed {count} local commit(s) to {branch}.',
529
+ 'commit.noCheckout': 'This repository has no usable local checkout, so there is nothing here to commit.',
530
+ 'commit.willCommit': 'This will commit {count} change(s):',
531
+ 'commit.willPush': 'The tree is clean and {count} local commit(s) are waiting to be pushed — a release builds the commit on GitHub, not the one on this disk.',
532
+ 'commit.nothing': 'The tree is clean and in sync with its upstream: nothing to commit or push.',
533
+ 'commit.messagePlaceholder': 'Commit message',
534
+ 'commit.messageRequired': 'A commit needs a message.',
535
+ 'confirm.push': 'Push {count} local commit(s) to {branch}?',
536
+ 'state.published': '{tag} is public now.',
537
+ 'state.rerun': 'Re-run requested.',
538
+ 'state.cancelled': 'Cancellation requested.',
539
+ 'state.actionFailed': 'The action failed: {reason}',
540
+ 'state.registered': 'Added {repo}.',
541
+ 'state.removed': 'Removed {repo}.',
542
+ 'state.truncated': '(showing the last {lines} lines)',
543
+ 'drafts.title': '{count} draft(s) not published yet',
544
+ 'drafts.text': 'A draft is invisible to everyone else and creates no tag, so it does not appear on the repository Releases page — that is not a failure. Expand the rows marked Draft below and press Publish to make one public.',
545
+ 'meta.account': 'Account',
546
+ 'meta.updated': 'Updated {time}',
547
+ 'meta.cached': 'cached',
548
+ 'meta.version': 'local',
549
+ 'meta.expectedTag': 'expected tag {tag}',
550
+ 'meta.noLocal': 'no local path, so local state cannot be compared',
551
+ 'clone.title': 'Local checkout',
552
+ 'clone.why.none': 'There is no local checkout yet. Cloning one is what gives 提交, 构建 and 发布 something to act on — all three need a working tree.',
553
+ 'clone.why.broken': 'The configured path is unusable ({path}): {reason}',
554
+ 'clone.urlPlaceholder': 'Repository address, e.g. https://github.com/owner/repo.git',
555
+ 'clone.action': 'Clone',
556
+ 'clone.target': 'Will clone into {path}',
557
+ 'clone.noRoot': 'No checkout root is set: give `projectsRoot` an absolute path in the profile\'s cordis.patch.yml, or there is nowhere to clone into.',
558
+ 'clone.done': 'Cloned into {path}.',
559
+ 'meta.assets': '{count} assets',
560
+ 'meta.path': 'Path',
561
+ 'meta.scopes': 'Scopes',
562
+ 'meta.noScopes': '(gh reported no scope list)',
563
+ 'meta.gh': 'gh',
564
+ 'meta.configFrom': 'Config from',
565
+ 'meta.projectsRoot': 'Checkout root',
566
+ 'meta.projectsRootUnset': 'not set, so adding a repository cannot find its local checkout',
567
+ 'run.unknown': 'unknown',
568
+ 'release.takenTitle': 'Release preflight: this version already belongs to another commit',
569
+ 'release.taken': '{tag} was created from {owner}, but this release would build {built} — release.yml refuses to overwrite a release of the same version that belongs to another commit (the tag and its assets would disagree), so dispatching now cannot succeed. Bump it to {next} first.',
570
+ 'release.takenNoNext': '{tag} was created from {owner}, but this release would build {built} — release.yml refuses to overwrite a release of the same version that belongs to another commit, so dispatching now cannot succeed. Bump the version first.',
571
+ 'release.dirtyNote': '{count} uncommitted change(s): a release builds the commit that was pushed, so these files would not be in the published package (and a version bump is refused for the same reason).',
572
+ 'release.aheadNote': '{count} commit(s) not pushed yet: a release builds the remote branch, so they would not be in the published package until they are pushed.',
573
+ 'release.dirty': 'The checkout has {count} uncommitted change(s); commit or stash them first.',
574
+ 'release.noUpstream': 'Branch {branch} has no upstream to push to.',
575
+ 'release.behind': 'The branch is {count} commit(s) behind its upstream; pull before releasing.',
576
+ 'release.noCheckout': 'This repository has no usable local checkout (no package.json).',
577
+ 'release.noVersion': 'No bumpable version number was found locally.',
578
+ 'release.pushFailed': 'Committed {tag} locally, but the push failed: {reason} (the commit is still local — retry the push)',
579
+ 'release.carried': 'This push also delivered {count} commit(s) that were not pushed before.',
580
+ 'confirm.bumpRelease': 'Bump package.json from {from} to {to}, commit it, push {branch}, then trigger the release? This creates a commit.',
581
+ 'confirm.publish': 'Publish {tag} publicly? Once published, anyone can see it.',
582
+ 'confirm.cancel': 'Not yet',
583
+ 'confirm.update': 'Replace {package} in the {profile} profile (currently {spec}) with the tgz from release {tag} and reinstall? This rewrites that profile dependency.',
584
+ 'install.title': 'Install',
585
+ 'install.notInstalled': '{package} is not a dependency of the {profile} profile, so there is no installed copy to update.',
586
+ 'install.noRelease': 'This repository has no published release yet (a draft does not count), so there is nothing to install from.',
587
+ 'install.current': 'The profile already holds the package from release {tag}; it is current.',
588
+ 'install.checkout': 'This copy points at a local checkout ({spec}), so it is not the code anyone downloaded. Installing the release replaces it; switch back with dsh plugin add link:<checkout path>.',
589
+ 'install.ahead': 'The profile holds v{installed}, which is newer than this release ({tag}) — installing it would move backwards to the published copy.',
590
+ 'install.differs': 'The profile holds {installed}, which is not the same version number as release {tag}, so the two cannot be ordered.',
591
+ 'state.updated': '{package} was replaced with the release package {tag}. The new version takes effect only after DSH restarts.',
592
+ 'state.updatedSame': '{package} already points at the {tag} release package: nothing changed, and no restart is needed.',
593
+ 'npm.title': 'npm',
594
+ 'npm.chipPublished': 'npm v{version}',
595
+ 'npm.chipPending': 'npm v{version} pending',
596
+ 'npm.chipUnregistered': 'not on npm',
597
+ 'npm.account': 'npm {account}',
598
+ 'npm.registry': 'registry {registry}',
599
+ 'npm.recheck': 'Re-check npm',
600
+ 'npm.notLoggedIn': '{command} is not signed in to {registry} — publishing needs npm credentials, and this plugin stores no token of its own. Four steps, from nothing.',
601
+ 'npm.guide.step1': '(1) No npm account yet?',
602
+ 'npm.guide.signup': 'Open npmjs.com to sign up',
603
+ 'npm.guide.emailNote': 'You MUST confirm the link npm emails you: an account with an unverified email cannot publish.',
604
+ 'npm.guide.step2': '(2) Create an access token',
605
+ 'npm.guide.tokens': 'Open the token page',
606
+ 'npm.guide.tokenType': 'There is only one kind left: a Granular Access Token. Classic tokens were all revoked on 2025-11-19 and can no longer be created — do not go looking for one.',
607
+ 'npm.guide.tokenScope': 'Set Permissions to Read and write; scope Packages to everything, or to just this package (narrower is safer).',
608
+ 'npm.guide.tokenExpiry': 'The maximum lifetime is 90 days, which is npm\'s hard limit for a write token. When it expires it simply stops working; come back to step 2 and make another.',
609
+ 'npm.guide.token2fa': 'Tick Bypass 2FA only for CI, where nobody can type a one-time password. For pushing here, leave it OFF — the panel will ask for the code.',
610
+ 'npm.guide.blocked': 'If npmjs.com will not open on this machine, or an "are you a robot" check blocks it: create the token on ANOTHER device or network (a phone on mobile data is easiest) and paste the string into step 3. The panel only needs the token — where it was made makes no difference, and this step has nothing to do with this machine\'s browser.',
611
+ 'npm.guide.step3': '(3) Paste it below',
612
+ 'npm.guide.step3Note': 'It is written to {npmrc} (the user-level file npm itself reads; every other line is preserved). This plugin stores no token, and the field is cleared as soon as the request answers.',
613
+ 'npm.guide.step4': '(4) Is 2FA on the account?',
614
+ 'npm.guide.step4Note': 'The push confirmation grows a one-time password field: enter the 6 digits and press Push again.',
615
+ 'npm.guide.docs': 'npm documentation',
616
+ 'npm.stepUnknown': 'cannot be detected',
617
+ 'npm.stepTodo': 'to do',
618
+ 'npm.stepAccountDone': '✓ account {account} exists',
619
+ 'npm.stepTokenDone': '✓ a working token exists',
620
+ 'npm.stepWrittenDone': '✓ a credential is already in {npmrc}',
621
+ 'npm.status.title': 'Credential',
622
+ 'npm.status.signedIn': 'signed in as {account}',
623
+ 'npm.status.credentialPresent': 'credential present, whoami unconfirmed',
624
+ 'npm.status.none': 'no credential yet',
625
+ 'npm.status.unknown': 'npm has not answered yet',
626
+ 'npm.status.unknownHint': 'The Host has not answered /npm-status (the running Host half may be an older version, or the panel just opened). Press Re-check. Until there is an answer, this page will not draw a conclusion for you.',
627
+ 'npm.packages.title': 'Every package on npm',
628
+ 'npm.packages.empty': 'No repository is registered, so there is no package to check.',
629
+ 'npm.packages.recheck': 'Re-check',
630
+ 'npm.package.published': 'on npm',
631
+ 'npm.package.unpublished': 'pending',
632
+ 'npm.package.unregistered': 'name is free',
633
+ 'npm.package.unknown': 'unreadable',
634
+ 'npm.package.local': 'local {version}',
635
+ 'npm.package.latest': 'npm has {latest}',
636
+ 'npm.credentialPresentShort': '.npmrc already carries a credential for {registry}, but whoami would not confirm it — a granular token is scoped to packages while whoami is a user-level endpoint, so that is normal. Just push; the publish decides whether it is real.',
637
+ 'npm.moreInSettings': 'The full guide and every package\'s state live in Settings → Plugins → DSH Release Console · npm.',
638
+ 'npm.ciHint': 'Publishing from CI? This token is not the way — npm supports trusted publishing (GitHub Actions OIDC), which needs no long-lived credential at all.',
639
+ 'settings.segment.npm': 'npm',
640
+ 'settings.npmTitle': 'npm credentials',
641
+ 'settings.npmSubtitle': 'Publishing uses the Host\'s own package manager, and the credential comes from the ~/.npmrc npm itself reads — this plugin stores no token.',
642
+ 'settings.npmGuideTitle': 'Four steps, from nothing',
643
+ 'npm.credentialPresentTitle': 'This registry already has a token line',
644
+ 'npm.credentialPresent': '.npmrc carries a credential for {registry}, but whoami would not confirm it. A granular token is scoped to PACKAGES while whoami is a user-level endpoint, so refusing a token that publishes perfectly well is normal. The publish itself is the real test: just push, and if it fails the panel says whether to enter a one-time password or make a new token.',
645
+ 'npm.rejectedTitle': 'That token was not accepted. Check, in order:',
646
+ 'npm.rejectedCauses': '(1) it was copied incompletely (leading or trailing whitespace); (2) Permissions are not Read and write; (3) it was revoked, or passed its 90-day limit; (4) it is scoped to another package or organization; (5) the account email is still unverified.',
647
+ 'npm.hint.otp-required': 'This account requires a one-time password: enter the 6 digits in the push confirmation and press Push again.',
648
+ 'npm.hint.email-unverified': 'Confirm the link npm emailed you first — an unverified email cannot publish — then try again.',
649
+ 'npm.hint.not-logged-in': 'The token is invalid, expired or revoked: make a new one as in step 2 (a write token lasts at most 90 days).',
650
+ 'npm.hint.already-published': 'This version is already on npm, which never accepts the same version twice: bump the version first, then push.',
651
+ 'npm.hint.payment-required': 'npm charges for private packages and not for public ones. Check for a private flag in package.json and whether this is meant to be public.',
652
+ 'npm.hint.forbidden': 'This token is not allowed to publish this package: check its Packages scope, and whether the name belongs to your account or organization.',
653
+ 'npm.hint.not-found': 'The registry has no publishable place for that name: check the spelling and the account or organization.',
654
+ 'npm.hint.rate-limited': 'The registry is rate-limiting you; try again in a few minutes.',
655
+ 'npm.hint.registry-error': 'The registry itself failed (5xx). That is not your configuration — try again shortly.',
656
+ 'npm.hint.network': 'The registry cannot be reached: check the network or proxy settings.',
657
+ 'npm.hint.timeout': 'The publish did not finish in time: a slow network, or the registry waiting for an answer. Retry once, and read its own words below if it fails again.',
658
+ 'npm.hint.unknown': 'No specific cause was recognised; npm\'s own words are below.',
659
+ 'npm.tokenPlaceholder': 'npm_… or paste an access token',
660
+ 'npm.writeToken': 'Write and verify',
661
+ 'npm.tokenWritten': 'The token is in {npmrc}; signed in as {account}.',
662
+ 'npm.npmrcPath': 'config file',
663
+ 'npm.state.published': 'This version is already on npm ({version}); there is nothing to push.',
664
+ 'npm.state.unregistered': 'Nobody owns this name on npm yet: this push would be its first release.',
665
+ 'npm.state.unpublished': 'npm has {latest}; the local {version} has not been pushed.',
666
+ 'npm.state.unknown': 'The npm state cannot be read ({reason}), so no push is offered — a missing button beats one that must fail.',
667
+ 'npm.action.publish': 'Push to npm v{version}',
668
+ 'npm.action.confirm': 'Push',
669
+ 'npm.otpLabel': 'One-time password',
670
+ 'npm.otpHint': 'Only needed with 2FA on the account, and it is never stored.',
671
+ 'npm.otpPlaceholder': '6 digits',
672
+ 'npm.blocked.private-package': 'package.json says "private": true, which npm refuses. That is the author\'s decision, not something to work around here.',
673
+ 'npm.blocked.not-logged-in': 'Not signed in to npm yet; write a token above first.',
674
+ 'npm.blocked.dirty-tree': 'The checkout has uncommitted changes: publish packs the working directory, so commit or stash first (the files allow-list does not protect a new file inside a listed directory).',
675
+ 'npm.blocked.no-checkout': 'This repository has no usable local checkout.',
676
+ 'npm.blocked.no-version': 'package.json has no version.',
677
+ 'npm.blocked.no-package-name': 'No package name could be read.',
678
+ 'npm.blocked.already-published': 'This version is already on npm, which never accepts the same version twice — bump the version first.',
679
+ 'npm.blocked.registry-unreachable': 'The npm registry cannot be reached, so whether this version exists is unknown.',
680
+ 'confirm.npmPublish': 'Push {package}@{version} to {registry}? npm never accepts the same version twice, and unpublishing is only possible briefly — once it is up, anyone can see it.',
681
+ 'state.npmPublished': 'Pushed {package}@{version} to {registry}.',
682
+ 'state.npmFirstPublish': '{package}@{version} is now published to {registry} for the first time.',
683
+ 'npm.githubDraft': 'Note: GitHub has only a draft (or no release) for this version — npm would go public before the Release does.',
684
+ 'state.updatedBuilds': '(Note: {count} build script(s) were blocked and did not run.)',
685
+ 'restart.ask': '{package} is updated to {tag}, but DSH has to restart to use it — restart now?',
686
+ 'restart.scheduled': 'Restart scheduled: DSH closes in a second or two and reopens by itself. Refreshing the page is not enough; the module is already loaded in the running process.',
687
+ 'restart.unavailable': 'Cannot restart: {reason}',
688
+ 'setup.needGh': 'The gh CLI was not found, so the console cannot reach GitHub.',
689
+ 'setup.needSignIn': 'Not signed in to GitHub yet. Once you are, your repositories appear here.',
690
+ 'setup.needScopes': 'The current credential is missing: {scopes}.',
691
+ 'setup.scopeHint': 'Without repo, private repositories cannot be read; without workflow, a build cannot be triggered.',
692
+ 'setup.needRepos': 'No repository is registered yet.',
693
+ 'setup.codeHint': 'Open the address below in your browser and enter this one-time code:',
694
+ 'setup.waiting': 'Waiting for authorization… finish in the browser and this continues by itself.',
695
+ 'setup.requestingCode': 'Requesting a one-time code from GitHub…',
696
+ 'setup.codePending': 'The authorization page is open — enter the code below once it appears. It has not arrived yet; give it a moment.',
697
+ 'setup.stalled': 'No one-time code after {seconds}s: {detail}. The usual cause is that github.com cannot be reached (the panel reads repositories over api.github.com, a different route, so that can still work). You can keep waiting — the code appears once the network recovers — or cancel and try again.',
698
+ 'setup.stalledNoOutput': 'gh has printed nothing at all',
699
+ 'setup.stalledQuietOutput': 'gh has printed only whitespace (it is usually retrying the connection)',
700
+ 'setup.stalledSaid': 'gh said: {text}',
701
+ 'setup.unreachableTitle': 'github.com is not reachable right now',
702
+ 'setup.unreachable': 'Note: this machine cannot open a connection to github.com:443, which the device-code flow needs — if no code appears, that is the likely reason. gh is already trying; if it can reach GitHub by another route (a proxy, for instance) the code will still appear.',
703
+ 'setup.dnsTitle': 'Cannot resolve github.com',
704
+ 'setup.dns': 'Note: this machine cannot resolve github.com to an address, while the address itself is reachable (connecting straight to its IP takes tens of milliseconds). gh resolves the name too, so it cannot even send the request — which is why no code appears. A different resolver, or simply retrying later, fixes it; gh is already trying and the code appears as soon as resolution works.',
705
+ 'setup.succeeded': 'Authorized. Reading the account…',
706
+ 'setup.failed': 'Sign-in did not finish: {reason}',
707
+ 'setup.expired': 'The one-time code expired. Start again.',
708
+ 'setup.cancelled': 'Sign-in cancelled.',
709
+ 'setup.advanced': 'Configure from a command line (optional)',
710
+ 'setup.ghInstall': 'Install GitHub CLI (either one)',
711
+ 'picker.title': 'Add repositories',
712
+ 'picker.search': 'Search repositories',
713
+ 'picker.empty': 'Nothing to add (not signed in, or this account has no repositories).',
714
+ 'picker.loading': 'Reading your repositories…',
715
+ 'picker.failed': 'Reading the repository list failed: {reason}',
716
+ 'picker.local': 'local checkout',
717
+ 'picker.noLocal': 'no local checkout found',
718
+ 'picker.hint': 'Ticking adds, unticking removes; the change applies at once, with no restart.',
719
+ 'picker.reload': 'Reload',
720
+
721
+ /* -- Bilibili update notes ------------------------------------------- */
722
+ 'settings.segment.bilibili': 'Bilibili',
723
+ 'bili.settings.title': 'Bilibili update notes',
724
+ 'bili.settings.subtitle': 'When a plugin is released, one short note is added to the comment section of the video that introduces it. The credential lives only in this plugin\'s own directory, and is never echoed back.',
725
+ 'bili.credential.title': 'Sign-in',
726
+ 'bili.credential.ready': 'Signed in as {uname}',
727
+ 'bili.credential.none': 'No Bilibili credential yet',
728
+ 'bili.credential.unreadable': 'The credential cannot be read',
729
+ 'bili.credential.incomplete': 'The credential is incomplete',
730
+ 'bili.credential.not-logged-in': 'This credential cannot comment',
731
+ 'bili.credential.unverified': 'Not verified yet',
732
+ 'bili.credential.none.hint': 'Commenting is a WEB API, so the credential has to be a web session. The cookies.json biliup writes by default is an APP/TV login (platform=BiliTV): it can upload, and every web member endpoint answers it with -101 账号未登录 — so it cannot comment.',
733
+ 'bili.credential.source': 'Credential file {path}',
734
+ 'bili.credential.external': 'This credential comes from the external file named in the config, not from this panel: {path}',
735
+ 'bili.signIn': 'Sign in to Bilibili',
736
+ 'bili.signIn.hint': 'One click produces a code to scan with the Bilibili app, then confirm on the phone. This page continues by itself, with no terminal. If the code will not scan, paste the cookies instead.',
737
+ 'bili.qrLabel': 'Bilibili sign-in code',
738
+ 'bili.qrHint': 'Scan this with the Bilibili app. It expires after about three minutes; press 【Sign in to Bilibili】 again if it does.',
739
+ 'bili.qrTooLong': 'This sign-in URL is too long to draw as a code — paste SESSDATA below instead.',
740
+ 'bili.waiting': 'Waiting to be scanned…',
741
+ 'bili.waitingScanned': 'Scanned — confirm it on your phone…',
742
+ 'bili.signIn.done': 'Signed in: {uname}',
743
+ 'bili.signIn.doneNoName': 'Signed in, but the account name could not be read.',
744
+ 'bili.signIn.expired': 'The code expired. Press 【Sign in to Bilibili】 again.',
745
+ 'bili.cancelSignIn': 'Cancel sign-in',
746
+ 'bili.paste.title': 'Or paste one',
747
+ 'bili.paste.hint': 'In the browser: F12 → Application → Cookies → bilibili.com, and copy SESSDATA and bili_jct (the latter is the csrf). It is written to this plugin\'s own file, and only after Bilibili accepts it.',
748
+ 'bili.paste.sessdata': 'SESSDATA',
749
+ 'bili.paste.csrf': 'bili_jct',
750
+ 'bili.paste.save': 'Save and verify',
751
+ 'bili.paste.saved': 'Credential saved: {uname}',
752
+ 'bili.signOut': 'Sign out of Bilibili',
753
+ 'bili.signOut.confirm': 'Sign out?',
754
+ 'bili.signOut.done': 'The credential this plugin stored has been deleted.',
755
+ 'bili.signOut.fallback': 'Note: an external credential is still in use ({path}). It belongs to another tool and was left alone.',
756
+ 'bili.bind.title': 'Bound video',
757
+ 'bili.chip.announced': 'Announced {tag}',
758
+ 'bili.chip.pending': 'Waiting to announce {tag}',
759
+ 'bili.bind.placeholder': 'BV id, e.g. BV1RopP6FEJp',
760
+ 'bili.bind.save': 'Bind',
761
+ 'bili.bind.unbind': 'Unbind',
762
+ 'bili.bind.locked': 'Bound: press 【Unbind】 first to change the video.',
763
+ 'bili.bind.done': 'Bound {bvid}{baseline}',
764
+ 'bili.bind.baseline': ' (baseline {tag}: only releases published after this are announced automatically)',
765
+ 'bili.bind.baselineUnknown': ' (the release list could not be read, so everything published before now counts as already public)',
766
+ 'bili.unbound.done': 'Unbound the video for {repo}; the announcement history is kept.',
767
+ 'bili.auto.on': 'Automatic',
768
+ 'bili.auto.off': 'Manual only',
769
+ 'bili.auto.toggle': 'Toggle automatic announcing',
770
+ 'bili.announce': 'Post update note',
771
+ 'bili.announce.again': 'Already announced — preview again',
772
+ 'bili.announce.preview': 'This is the comment that would be posted',
773
+ 'bili.announce.confirm': 'Post it',
774
+ 'bili.announce.force': 'Post anyway',
775
+ 'bili.announce.cancel': 'Cancel',
776
+ 'bili.announce.sent': 'Posted to the comments under {bvid} ({tag}), and it reads back publicly.',
777
+ 'bili.announce.unverified': 'Bilibili accepted this comment ({bvid} · {tag}) but the public listing does not serve it — it may still be in review, or it may have been removed. Use 【Send anyway】 to post it again.',
778
+ 'bili.visible.false': 'posted, not readable',
779
+ 'bili.visible.hint': 'Bilibili accepted this comment, but the public listing does not serve it (Bilibili said: {code}). A comment in review and a removed one are indistinguishable through the API. Use 【Send anyway】 to post it again.',
780
+ 'bili.announce.previewFailed': 'Preview failed: {reason}',
781
+ 'bili.repo.none': 'This repository has no video bound.',
782
+ 'bili.state.already': '{tag} has already been announced.',
783
+ 'bili.state.baseline': '{tag} was published before the video was bound, so it is not this update.',
784
+ 'bili.state.no-release': 'There is no published release yet (a draft does not count) — publish the draft from the panel first.',
785
+ 'bili.state.gave-up': 'This one has failed {count} times; read why before retrying by hand.',
786
+ 'bili.state.unbound': 'This repository has no video bound.',
787
+ 'bili.needCredential': 'A repository is bound to a video, but there is no usable Bilibili credential, so update notes cannot be posted.',
788
+ 'bili.openSettings': 'Manage under Settings → Plugins → DSH Release Console · Bilibili',
789
+ 'bili.lastSweep': 'Sweep {time}: {summary}',
790
+ 'bili.sweep.none': 'nothing new to announce',
791
+ 'bili.sweep.announced': 'announced {repo} {tag}',
792
+ 'bili.sweep.failed': '{repo} {tag} failed to announce',
793
+ 'bili.neverSwept': 'The sweep has not run yet',
794
+ 'bili.ledger.problem': 'The announcement ledger cannot be read ({problem}). Nothing will be posted until it can — otherwise every comment already posted would be posted again.',
795
+ 'bili.template': 'Template',
796
+ 'bili.template.hint': 'Placeholders: {tag} {version} {label} {repo} {title} {summary} {url} {date}. The default carries no link, because a comment with a link is filtered more often.',
797
+ 'bili.unknownPlaceholder': 'The template uses placeholders that do not exist: {names}',
798
+ 'bili.video.failed': 'The video cannot be read: {reason}',
799
+ 'bili.watch.off': 'The sweep is off (bilibiliWatchSeconds: 0); only the manual button and the sweep after publishing a draft remain.',
800
+ 'bili.repos.title': 'Per repository',
801
+ 'bili.repos.empty': 'No repository is registered yet.',
802
+ 'bili.announced.title': 'Announced',
803
+ 'bili.announced.none': 'Nothing has been announced yet.',
804
+ 'bili.failure.last': 'Last failure: {message}',
805
+ }
806
+
807
+ /* ------------------------------------------------------------- styles -- */
808
+
809
+ const CSS = `
810
+ /* One scale for the whole panel. Every rule below reads these; nothing picks its
811
+ own size, height or radius, because that is how a UI drifts into six different
812
+ font sizes and three different button heights. */
813
+ .dsc-root {
814
+ --dsc-fs-sm: 11px; /* secondary: meta, chips, hints, detail, logs */
815
+ --dsc-fs-md: 12px; /* primary: rows, buttons, inputs, notices */
816
+ --dsc-fs-lg: 15px; /* titles, and the one-time code */
817
+ --dsc-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
818
+ --dsc-ctl-h: 26px; /* every clickable control is this tall */
819
+ --dsc-ctl-r: 6px; /* every control, code block and chip container */
820
+ --dsc-box-r: 10px; /* every container */
821
+ --dsc-gap: 8px;
822
+ display: flex; flex-direction: column; gap: 10px; padding: 12px 14px 20px;
823
+ height: 100%; overflow: auto;
824
+ font-family: inherit; font-size: var(--dsc-fs-md); line-height: 1.5;
825
+ color: var(--dsw-alias-label-primary);
826
+ }
827
+ .dsc-bar { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
828
+ .dsc-heading { display: flex; align-items: baseline; gap: var(--dsc-gap); min-width: 0; }
829
+ .dsc-title { font-size: var(--dsc-fs-lg); font-weight: 600; }
830
+ .dsc-subtitle { font-size: var(--dsc-fs-sm); color: var(--dsw-alias-label-secondary); }
831
+ .dsc-meta { display: flex; align-items: center; gap: var(--dsc-gap); flex-wrap: wrap; font-size: var(--dsc-fs-sm); color: var(--dsw-alias-label-secondary); }
832
+ .dsc-actions { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
833
+
834
+ /* One button. data-kind changes colour only; data-square changes width only. */
835
+ .dsc-btn {
836
+ display: inline-flex; align-items: center; justify-content: center; gap: 4px;
837
+ height: var(--dsc-ctl-h); padding: 0 10px;
838
+ border: 1px solid var(--dsw-alias-border-l1); border-radius: var(--dsc-ctl-r);
839
+ background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary);
840
+ font-family: inherit; font-size: var(--dsc-fs-md); font-weight: 400; line-height: 1;
841
+ cursor: pointer; white-space: nowrap;
842
+ }
843
+ .dsc-btn:hover:not([disabled]) { background: var(--dsw-alias-bg-layer-2); border-color: var(--dsw-alias-border-l2); }
844
+ .dsc-btn:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 1px; }
845
+ .dsc-btn[disabled] { opacity: .5; cursor: default; }
846
+ .dsc-btn[data-kind="primary"] { border-color: transparent; background: var(--dsw-alias-brand-primary); color: #fff; }
847
+ .dsc-btn[data-kind="danger"] { border-color: transparent; background: var(--dsw-alias-state-error-primary); color: #fff; }
848
+ .dsc-btn[data-kind="quiet"] { background: transparent; border-color: transparent; color: var(--dsw-alias-label-secondary); }
849
+ .dsc-btn[data-kind="quiet"]:hover:not([disabled]) { background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); }
850
+ .dsc-btn[data-square="true"] { width: var(--dsc-ctl-h); padding: 0; }
851
+
852
+ .dsc-chip {
853
+ display: inline-flex; align-items: center; gap: 4px; height: 18px; padding: 0 7px;
854
+ border: 1px solid var(--dsw-alias-border-l1); border-radius: 999px;
855
+ font-size: var(--dsc-fs-sm); line-height: 1; color: var(--dsw-alias-label-secondary); white-space: nowrap; flex: none;
856
+ }
857
+ .dsc-chip[data-state="success"] { color: var(--dsw-alias-state-success-primary); border-color: currentColor; }
858
+ .dsc-chip[data-state="error"] { color: var(--dsw-alias-state-error-primary); border-color: currentColor; }
859
+ .dsc-chip[data-state="warn"] { color: var(--dsw-alias-state-warn-primary); border-color: currentColor; }
860
+ .dsc-chip[data-state="busy"] { color: var(--dsw-alias-brand-primary); border-color: currentColor; }
861
+ .dsc-chip[data-state="idle"] { color: var(--dsw-alias-state-idle-primary); border-color: currentColor; }
862
+ .dsc-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--dsw-alias-state-idle-primary); flex: none; }
863
+ .dsc-dot[data-state="success"] { background: var(--dsw-alias-state-success-primary); }
864
+ .dsc-dot[data-state="error"] { background: var(--dsw-alias-state-error-primary); }
865
+ .dsc-dot[data-state="warn"] { background: var(--dsw-alias-state-warn-primary); }
866
+ .dsc-dot[data-state="busy"] { background: var(--dsw-alias-brand-primary); }
867
+
868
+ /* One container, one row per repository, separators instead of card gaps. */
869
+ .dsc-list { border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px; overflow: hidden; background: var(--dsw-alias-bg-layer-1); }
870
+ .dsc-item { border-top: 1px solid var(--dsw-alias-border-l1); }
871
+ .dsc-item:first-child { border-top: none; }
872
+ .dsc-row { display: flex; align-items: center; gap: var(--dsc-gap); padding: 5px 10px; min-height: 32px; }
873
+ .dsc-name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 70px; flex: 0 1 auto; }
874
+ .dsc-grow { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-secondary); font-size: var(--dsc-fs-sm); }
875
+ .dsc-right { margin-left: auto; display: flex; align-items: center; gap: 6px; flex: none; }
876
+ .dsc-detail { padding: 4px 10px 10px 28px; display: flex; flex-direction: column; gap: var(--dsc-gap); font-size: var(--dsc-fs-sm); color: var(--dsw-alias-label-secondary); }
877
+ .dsc-mono { font-family: var(--dsc-mono); overflow-wrap: anywhere; }
878
+ .dsc-sub { display: flex; flex-direction: column; gap: 3px; }
879
+ .dsc-line { display: flex; align-items: center; gap: var(--dsc-gap); padding: 3px 0; border-top: 1px dashed var(--dsw-alias-border-l1); }
880
+ .dsc-line:first-child { border-top: none; }
881
+ .dsc-empty { padding: var(--dsc-gap) 2px; font-size: var(--dsc-fs-sm); color: var(--dsw-alias-label-secondary); }
882
+ /* A line that only points somewhere else: quieter than a warning, and never a
883
+ control, so it cannot be mistaken for one. */
884
+ .dsc-quiet { font-size: var(--dsc-fs-sm); color: var(--dsw-alias-label-secondary); }
885
+ /* The commit confirmation: a sentence, the file list, a message field, then the two
886
+ buttons. A column, because the list wraps and a row would push the buttons off. */
887
+ .dsc-commit { display: flex; flex-direction: column; gap: var(--dsc-gap); padding: var(--dsc-gap) 0; font-size: var(--dsc-fs-sm); }
888
+ /* The sign-in code. White behind, black modules, fixed size whatever the theme is:
889
+ a QR is read by a camera, so it cannot follow a dark theme's colours the way the
890
+ rest of this panel does. */
891
+ .dsc-scan { display: flex; flex-direction: column; gap: 6px; padding: var(--dsc-gap) 0; align-items: flex-start; }
892
+ /* Sized for the WORST case the encoder can produce: version 10 is 57 modules across, so
893
+ 220px leaves about 3.8px per module — and the common login URL is version 8 (49
894
+ modules, about 4.5px). A camera needs the modules to be separable, and the only person
895
+ who can check that is holding the phone. */
896
+ .dsc-qr { width: 220px; height: 220px; background: #fff; padding: 8px; border-radius: 4px; box-sizing: content-box; }
897
+ .dsc-qr rect { fill: #000; }
898
+ /* The console's own segment strip, inside its one Plugins tab. Left-aligned and with a
899
+ rule under it, so it reads as "which part of this page" rather than as a second set
900
+ of tabs floating in the middle: the dsc-bar rule alone would spread three buttons
901
+ across the full width. No backticks in here — this whole sheet is one template
902
+ literal, and a backtick ends it. */
903
+ .dsc-segments { justify-content: flex-start; gap: 6px; padding-bottom: var(--dsc-gap); border-bottom: 1px solid var(--dsw-alias-border-l1); margin-bottom: var(--dsc-gap); }
904
+ .dsc-settings { display: flex; flex-direction: column; gap: var(--dsc-gap); }
905
+
906
+ .dsc-notice, .dsc-warn, .dsc-error {
907
+ padding: 6px 10px; border-radius: var(--dsc-ctl-r); font-size: var(--dsc-fs-md); line-height: 1.5;
908
+ border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary);
909
+ }
910
+ .dsc-warn { color: var(--dsw-alias-state-warn-primary); border-color: currentColor; }
911
+ .dsc-error { color: var(--dsw-alias-state-error-primary); border-color: currentColor; }
912
+ .dsc-notice code, .dsc-setup code, .dsc-cmd code {
913
+ font-family: var(--dsc-mono); font-size: var(--dsc-fs-sm);
914
+ background: var(--dsw-alias-bg-layer-2); padding: 1px 5px; border-radius: 4px;
915
+ }
916
+ .dsc-setup {
917
+ border: 1px solid var(--dsw-alias-brand-primary); border-radius: var(--dsc-box-r);
918
+ background: var(--dsw-alias-bg-layer-1); padding: 10px 12px;
919
+ display: flex; flex-direction: column; gap: var(--dsc-gap);
920
+ font-size: var(--dsc-fs-md); color: var(--dsw-alias-label-secondary);
921
+ }
922
+ .dsc-setup-line { display: flex; align-items: center; gap: var(--dsc-gap); flex-wrap: wrap; }
923
+ .dsc-setup-strong { color: var(--dsw-alias-label-primary); }
924
+ .dsc-code { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 7px 10px; border-radius: var(--dsc-ctl-r); background: var(--dsw-alias-bg-layer-2); }
925
+ .dsc-code-value { font-family: var(--dsc-mono); font-size: var(--dsc-fs-lg); letter-spacing: .12em; font-weight: 600; color: var(--dsw-alias-label-primary); }
926
+ .dsc-cmd { display: flex; align-items: center; gap: var(--dsc-gap); flex-wrap: wrap; padding: 5px 8px; border-radius: var(--dsc-ctl-r); background: var(--dsw-alias-bg-layer-2); }
927
+ .dsc-cmd code { flex: 1 1 260px; min-width: 0; overflow-wrap: anywhere; }
928
+ .dsc-logs {
929
+ margin: 0; padding: var(--dsc-gap) 10px; max-height: 200px; overflow: auto; border-radius: var(--dsc-ctl-r);
930
+ background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary);
931
+ font-family: var(--dsc-mono); font-size: var(--dsc-fs-sm); line-height: 1.5; white-space: pre-wrap;
932
+ }
933
+ .dsc-picker { border: 1px solid var(--dsw-alias-border-l1); border-radius: var(--dsc-box-r); background: var(--dsw-alias-bg-layer-1); display: flex; flex-direction: column; overflow: hidden; }
934
+ .dsc-picker-head { display: flex; align-items: center; gap: var(--dsc-gap); padding: var(--dsc-gap) 10px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
935
+ .dsc-input {
936
+ flex: 1 1 auto; min-width: 0; height: var(--dsc-ctl-h); padding: 0 10px;
937
+ font-family: inherit; font-size: var(--dsc-fs-md); line-height: 1;
938
+ border: 1px solid var(--dsw-alias-border-l1); border-radius: var(--dsc-ctl-r);
939
+ background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary);
940
+ }
941
+ .dsc-input:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 1px; }
942
+ /* The one-time password field is a control, not a form field: it shares the control
943
+ height and radius so it sits on a confirmation line with the buttons. */
944
+ .dsc-otp { flex: 0 0 110px; min-width: 0; }
945
+ .dsc-picker-list { max-height: 260px; overflow: auto; }
946
+ .dsc-picker-row { display: flex; align-items: center; gap: var(--dsc-gap); padding: 5px 10px; border-top: 1px solid var(--dsw-alias-border-l1); font-size: var(--dsc-fs-md); cursor: pointer; }
947
+ .dsc-picker-row:first-child { border-top: none; }
948
+ .dsc-picker-row:hover { background: var(--dsw-alias-bg-layer-2); }
949
+ .dsc-check { width: 14px; height: 14px; flex: none; accent-color: var(--dsw-alias-brand-primary); }
950
+ .dsc-hint { padding: 7px 10px; font-size: var(--dsc-fs-sm); color: var(--dsw-alias-label-secondary); border-top: 1px solid var(--dsw-alias-border-l1); }
951
+ `
952
+
953
+ /**
954
+ * Inject the stylesheet once per document.
955
+ *
956
+ * The loader's materialize step claims every `<style>` that carries no
957
+ * `data-plugin` attribute for the package being materialized, and a later HMR
958
+ * replace of THAT package deletes what it claimed. Tagging the element with
959
+ * this package's own identity keeps it out of both paths.
960
+ */
961
+ function ensureStyles() {
962
+ if (document.getElementById(STYLE_ID) !== null) return
963
+ const style = document.createElement('style')
964
+ style.id = STYLE_ID
965
+ style.setAttribute('data-plugin', 'dsh-plugin-cicd')
966
+ style.setAttribute('data-plugin-css', STYLE_ID)
967
+ style.textContent = CSS
968
+ document.head.append(style)
969
+ }
970
+
971
+ /** POST one JSON body with a hard timeout; never throws. */
972
+ async function postJson(path, body, timeoutMs) {
973
+ const controller = new AbortController()
974
+ const timer = globalThis.setTimeout(() => {
975
+ controller.abort()
976
+ }, timeoutMs)
977
+ try {
978
+ const response = await fetch(`${BASE}${path}`, {
979
+ method: 'POST',
980
+ headers: { 'content-type': 'application/json' },
981
+ body: JSON.stringify(body ?? {}),
982
+ signal: controller.signal,
983
+ })
984
+ let payload = null
985
+ try {
986
+ payload = await response.json()
987
+ } catch {
988
+ payload = null
989
+ }
990
+ return { ok: true, response, payload }
991
+ } catch (error) {
992
+ return { ok: false, aborted: controller.signal.aborted, error: error instanceof Error ? error.message : String(error) }
993
+ } finally {
994
+ globalThis.clearTimeout(timer)
995
+ }
996
+ }
997
+
998
+ /** A short local time stamp; an unparseable value is shown as-is. */
999
+ function stamp(iso) {
1000
+ if (typeof iso !== 'string' || iso === '') return ''
1001
+ const parsed = new Date(iso)
1002
+ if (Number.isNaN(parsed.getTime())) return iso
1003
+ return parsed.toLocaleString()
1004
+ }
1005
+
1006
+ /** A compact "how long did it take" label for a settled run. */
1007
+ function duration(run) {
1008
+ if (run === null || run.createdAt === '' || run.updatedAt === '') return ''
1009
+ const from = new Date(run.createdAt).getTime()
1010
+ const to = new Date(run.updatedAt).getTime()
1011
+ if (!Number.isFinite(from) || !Number.isFinite(to) || to < from) return ''
1012
+ const seconds = Math.round((to - from) / 1000)
1013
+ return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m${seconds % 60}s`
1014
+ }
1015
+
1016
+ /** Map a run's state onto the chip vocabulary. */
1017
+ function runState(run) {
1018
+ if (run === null) return 'idle'
1019
+ if (run.status !== 'completed') return 'busy'
1020
+ if (run.conclusion === 'success') return 'success'
1021
+ if (run.conclusion === 'failure' || run.conclusion === 'timed_out' || run.conclusion === 'startup_failure') return 'error'
1022
+ return 'idle'
1023
+ }
1024
+
1025
+ /** The 7-character commit form git itself prints, for a message a person reads. */
1026
+ function shortSha(value) {
1027
+ return typeof value === 'string' && value !== '' ? value.slice(0, 7) : '?'
1028
+ }
1029
+
1030
+ /**
1031
+ * A failed release route's reason, in the panel's language.
1032
+ *
1033
+ * The Host answers with a stable `code` plus the facts, and its `message` is the
1034
+ * English fallback a log would show. Rendering the fallback here would put an
1035
+ * English sentence in an otherwise Chinese panel, so the codes that the panel
1036
+ * can explain are translated and anything unknown falls back to the raw message.
1037
+ *
1038
+ * @param {Function} t - dictionary lookup.
1039
+ * @param {object|null} payload - route body.
1040
+ * @param {object|null} response - fetch response, for the status fallback.
1041
+ * @returns {string} a sentence for the error line.
1042
+ */
1043
+ function releaseFailure(t, payload, response) {
1044
+ const code = typeof payload?.code === 'string' ? payload.code : ''
1045
+ const value = payload?.value ?? {}
1046
+ if (code === 'dirty-tree') return t('release.dirty', { count: String(value.dirty ?? '?') })
1047
+ if (code === 'no-upstream') return t('release.noUpstream', { branch: String(value.branch ?? '?') })
1048
+ if (code === 'behind') return t('release.behind', { count: String(value.behind ?? '?') })
1049
+ if (code === 'no-checkout') return t('release.noCheckout')
1050
+ if (code === 'unusable-version' || code === 'unusable-manifest') return t('release.noVersion')
1051
+ if (code === 'push-failed') return t('release.pushFailed', { tag: String(value.tag ?? ''), reason: String(payload?.message ?? '') })
1052
+ if (code === 'version-taken') {
1053
+ return t(value.nextTag === null || value.nextTag === undefined ? 'release.takenNoNext' : 'release.taken', {
1054
+ tag: String(value.tag ?? ''),
1055
+ owner: shortSha(value.owner),
1056
+ built: shortSha(value.built),
1057
+ next: String(value.nextTag ?? ''),
1058
+ })
1059
+ }
1060
+ return typeof payload?.message === 'string' && payload.message !== '' ? payload.message : `HTTP ${String(response?.status ?? '?')}`
1061
+ }
1062
+
1063
+ /** A compact isometric package, drawn with `currentColor`. */
1064
+ function ConsoleIcon(props) {
1065
+ const size = Number.isFinite(props?.size) ? props.size : 16
1066
+ return h(
1067
+ 'svg',
1068
+ { width: size, height: size, viewBox: '0 0 16 16', 'aria-hidden': true, focusable: 'false', style: { display: 'block' } },
1069
+ h('path', { d: 'M8 1.6 2.4 4.5v7L8 14.4l5.6-2.9v-7L8 1.6Z', fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, strokeLinejoin: 'round' }),
1070
+ h('path', { d: 'M2.6 4.6 8 7.3l5.4-2.7M8 7.3v6.9', fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, strokeLinejoin: 'round' }),
1071
+ )
1072
+ }
1073
+
1074
+ /** One pill. */
1075
+ function Chip(props) {
1076
+ return h('span', { className: 'dsc-chip', 'data-state': props.state ?? 'idle', title: props.title }, props.children)
1077
+ }
1078
+
1079
+ /**
1080
+ * One button.
1081
+ *
1082
+ * `kind` changes colour and `square` changes width — nothing is allowed to
1083
+ * change its own height or font size, which is how a toolbar ends up with
1084
+ * three different button sizes.
1085
+ */
1086
+ function Btn(props) {
1087
+ return h(
1088
+ 'button',
1089
+ {
1090
+ type: 'button',
1091
+ className: 'dsc-btn',
1092
+ 'data-kind': props.kind,
1093
+ 'data-square': props.square === true ? 'true' : undefined,
1094
+ title: props.title,
1095
+ disabled: props.disabled === true,
1096
+ onClick: props.onClick,
1097
+ },
1098
+ props.children,
1099
+ )
1100
+ }
1101
+
1102
+ /** A value with a copy button — the fallback path, never the instruction. */
1103
+ function CopyLine(props) {
1104
+ const { t, command } = props
1105
+ /**
1106
+ * When the value is already on screen, showing it again beside the copy button
1107
+ * just prints the same string twice. The button is then the whole control.
1108
+ */
1109
+ const hideCommand = props.hideCommand === true
1110
+ const [copied, setCopied] = React.useState(false)
1111
+ React.useEffect(() => {
1112
+ if (!copied) return undefined
1113
+ const timer = globalThis.setTimeout(() => {
1114
+ setCopied(false)
1115
+ }, 2_000)
1116
+ return () => {
1117
+ globalThis.clearTimeout(timer)
1118
+ }
1119
+ }, [copied])
1120
+ return h(
1121
+ 'div',
1122
+ { className: 'dsc-cmd' },
1123
+ hideCommand ? null : h('code', null, command),
1124
+ h(
1125
+ Btn,
1126
+ {
1127
+ kind: 'quiet',
1128
+ onClick: () => {
1129
+ // Clipboard access needs a secure context; the loopback GUI is one,
1130
+ // but a refusal must not be reported as a successful copy.
1131
+ const write = globalThis.navigator?.clipboard?.writeText
1132
+ if (typeof write !== 'function') return
1133
+ void write.call(globalThis.navigator.clipboard, command).then(() => setCopied(true)).catch(() => setCopied(false))
1134
+ },
1135
+ },
1136
+ copied ? t('action.copied') : t('action.copy'),
1137
+ ),
1138
+ )
1139
+ }
1140
+
1141
+ /**
1142
+ * The browser sign-in, driven from the panel.
1143
+ *
1144
+ * The Host runs `gh auth login --web` and streams back the one-time code, so
1145
+ * the user's whole job is: click, open the linked page, type the code. No
1146
+ * terminal — and the plugin still never sees the token, because `gh` writes it.
1147
+ */
1148
+ function SignIn(props) {
1149
+ const { t, status, onChanged } = props
1150
+ const [attempt, setAttempt] = React.useState(status?.auth ?? { state: 'idle' })
1151
+ const [busy, setBusy] = React.useState(false)
1152
+ const [error, setError] = React.useState(null)
1153
+ const gh = status?.gh ?? {}
1154
+ const missing = Array.isArray(gh.missingScopes) ? gh.missingScopes : []
1155
+
1156
+ const start = React.useCallback(
1157
+ async (mode) => {
1158
+ setBusy(true)
1159
+ setError(null)
1160
+ const result = await postJson('/auth-start', { mode, scopes: mode === 'refresh' ? missing : [] }, ACTION_TIMEOUT_MS)
1161
+ setBusy(false)
1162
+ if (!result.ok) {
1163
+ setError(result.aborted ? t('state.timeout') : t('state.hostGone'))
1164
+ return
1165
+ }
1166
+ if (!result.response.ok || result.payload?.ok !== true) {
1167
+ setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
1168
+ return
1169
+ }
1170
+ setAttempt(result.payload.value)
1171
+ },
1172
+ [missing, t],
1173
+ )
1174
+
1175
+ // Poll only while an attempt is pending: waking up every two seconds for the
1176
+ // rest of the session would be work nobody asked for.
1177
+ const pending = attempt.state === 'pending' || attempt.state === 'running'
1178
+ /**
1179
+ * `onChanged` is passed as an inline arrow, so its identity changes on every
1180
+ * render of the parent. Keeping it in a dependency array therefore tore this
1181
+ * interval down and restarted it whenever anything re-rendered — and a timer
1182
+ * that is reset more often than it fires never fires at all, which is how the
1183
+ * one-time code could sit unread in the Host while the panel showed nothing.
1184
+ * A ref carries the latest callback without being a dependency.
1185
+ */
1186
+ const onChangedRef = React.useRef(onChanged)
1187
+ onChangedRef.current = onChanged
1188
+ React.useEffect(() => {
1189
+ if (!pending) return undefined
1190
+ let cancelled = false
1191
+ /* Ask once immediately: the code usually exists by the time /auth-start
1192
+ answers, so waiting a full interval just to see it is pure latency. */
1193
+ const poll = async () => {
1194
+ const result = await postJson('/auth-state', {}, STATUS_TIMEOUT_MS)
1195
+ if (cancelled || !result.ok || result.payload?.ok !== true) return
1196
+ const next = result.payload.value
1197
+ setAttempt(next)
1198
+ if (next.state === 'succeeded') onChangedRef.current()
1199
+ }
1200
+ void poll()
1201
+ const timer = globalThis.setInterval(() => { void poll() }, AUTH_POLL_MS)
1202
+ return () => {
1203
+ cancelled = true
1204
+ globalThis.clearInterval(timer)
1205
+ }
1206
+ }, [pending])
1207
+
1208
+ const cancel = React.useCallback(async () => {
1209
+ await postJson('/auth-cancel', {}, STATUS_TIMEOUT_MS)
1210
+ setAttempt({ state: 'idle' })
1211
+ }, [])
1212
+
1213
+ const code = typeof attempt.code === 'string' && attempt.code !== '' ? attempt.code : null
1214
+ const url = typeof attempt.url === 'string' && attempt.url !== '' ? attempt.url : 'https://github.com/login/device'
1215
+
1216
+ /**
1217
+ * Nothing left to do.
1218
+ *
1219
+ * Once the credential carries every scope the panel needs, this component has
1220
+ * no button to offer and no status worth reporting. Returning `null` retires
1221
+ * it: leaving a redundant "sign in" button, or the remains of "authorized,
1222
+ * reading the account…", made a finished login look stuck — and it stayed
1223
+ * that way until something else happened to re-render the block.
1224
+ */
1225
+ if (gh.available === true && gh.authenticated === true && missing.length === 0 && !pending) return null
1226
+
1227
+ return h(
1228
+ 'div',
1229
+ { className: 'dsc-sub' },
1230
+ error !== null ? h('div', { className: 'dsc-error', role: 'alert' }, error) : null,
1231
+ pending
1232
+ ? h(
1233
+ React.Fragment,
1234
+ null,
1235
+ h('span', { className: 'dsc-setup-strong' }, code === null ? t('setup.requestingCode') : t('setup.codeHint')),
1236
+ h(
1237
+ 'div',
1238
+ { className: 'dsc-code' },
1239
+ h('span', { className: 'dsc-code-value' }, code ?? '····-····'),
1240
+ /* Opening the page before the code exists is the reported failure:
1241
+ the browser shows GitHub's device page and there is nothing to
1242
+ type. The button therefore stays disabled until there is a code. */
1243
+ /* Always usable: the device page's address is static, so opening it
1244
+ costs nothing and waiting for the code to enable it was the reason
1245
+ nothing at all appeared to happen. */
1246
+ h(Btn, { kind: 'primary', onClick: () => globalThis.open(url, '_blank', 'noopener,noreferrer') }, t('action.openDevicePage')),
1247
+ code !== null ? h(CopyLine, { t, command: code, hideCommand: true }) : null,
1248
+ ),
1249
+ h('span', null, code === null ? t('setup.codePending') : t('setup.waiting')),
1250
+ /* Advisory, never a refusal: the probe cannot see a proxy that gh
1251
+ might be using, so this reports a likely cause without stopping
1252
+ an attempt that could still succeed. */
1253
+ pending && attempt.reachable === false
1254
+ ? h(
1255
+ 'span',
1256
+ { className: 'dsc-warn' },
1257
+ h('strong', null, t(attempt.reachabilityStage === 'dns' ? 'setup.dnsTitle' : 'setup.unreachableTitle')),
1258
+ ' — ',
1259
+ t(attempt.reachabilityStage === 'dns' ? 'setup.dns' : 'setup.unreachable'),
1260
+ )
1261
+ : null,
1262
+ /* A stalled attempt keeps running — the code has been observed to
1263
+ arrive once the network recovers — so this reports the wait
1264
+ instead of declaring a failure the user would have to retry. */
1265
+ pending && attempt.stalled === true
1266
+ ? h('span', { className: 'dsc-warn' }, t('setup.stalled', {
1267
+ seconds: String(Math.max(1, Math.round((typeof attempt.waitedMs === 'number' ? attempt.waitedMs : 0) / 1000))),
1268
+ detail: typeof attempt.outputExcerpt === 'string' && attempt.outputExcerpt !== ''
1269
+ ? t('setup.stalledSaid', { text: attempt.outputExcerpt })
1270
+ : (typeof attempt.outputBytes === 'number' && attempt.outputBytes > 0 ? t('setup.stalledQuietOutput') : t('setup.stalledNoOutput')),
1271
+ }))
1272
+ : null,
1273
+ h('div', { className: 'dsc-actions' }, h(Btn, { onClick: cancel }, t('action.cancelSignIn'))),
1274
+ )
1275
+ : h(
1276
+ 'div',
1277
+ { className: 'dsc-actions' },
1278
+ gh.available !== true
1279
+ ? h(Btn, { kind: 'primary', onClick: () => globalThis.open('https://cli.github.com/', '_blank', 'noopener,noreferrer') }, 'cli.github.com')
1280
+ : missing.length > 0
1281
+ ? h(Btn, { kind: 'primary', disabled: busy, onClick: () => void start('refresh') }, t('action.grantScopes'))
1282
+ /*
1283
+ * Open the page from inside the click.
1284
+ *
1285
+ * The address is static and does not depend on the code, and opening
1286
+ * it here is what makes the click keep its promise. It has to happen
1287
+ * synchronously in the handler: a window opened from an effect after
1288
+ * an await is outside the user gesture and browsers block it. gh's
1289
+ * own browser launch does not happen until it has a code — which is
1290
+ * precisely the situation where nothing seems to happen.
1291
+ */
1292
+ : h(Btn, { kind: 'primary', disabled: busy, onClick: () => {
1293
+ globalThis.open('https://github.com/login/device', '_blank', 'noopener,noreferrer')
1294
+ void start('login')
1295
+ } }, t('action.signIn')),
1296
+ h(Btn, { disabled: busy, onClick: onChanged }, t('action.recheck')),
1297
+ ),
1298
+ attempt.state === 'succeeded' ? h('span', null, t('setup.succeeded')) : null,
1299
+ attempt.state === 'expired' ? h('span', { className: 'dsc-warn' }, t('setup.expired')) : null,
1300
+ attempt.state === 'cancelled' ? h('span', null, t('setup.cancelled')) : null,
1301
+ attempt.state === 'failed' ? h('span', { className: 'dsc-warn' }, t('setup.failed', { reason: attempt.message ?? t('run.unknown') })) : null,
1302
+ )
1303
+ }
1304
+
1305
+ /**
1306
+ * The repository list: fetched from GitHub, ticked to register.
1307
+ *
1308
+ * This is what replaced "run `configure.mjs add <repo> --path <absolute dir>`".
1309
+ * The local checkout is looked up from the configured root, so the user ticks a
1310
+ * box and types nothing.
1311
+ */
1312
+ function RepoPicker(props) {
1313
+ const { t, onChanged } = props
1314
+ const [repos, setRepos] = React.useState(null)
1315
+ const [busy, setBusy] = React.useState('')
1316
+ const [error, setError] = React.useState(null)
1317
+ const [filter, setFilter] = React.useState('')
1318
+
1319
+ const load = React.useCallback(async () => {
1320
+ setError(null)
1321
+ setRepos(null)
1322
+ const result = await postJson('/repos-available', { limit: 200 }, OVERVIEW_TIMEOUT_MS)
1323
+ if (!result.ok) {
1324
+ setError(result.aborted ? t('state.timeout') : t('state.hostGone'))
1325
+ return
1326
+ }
1327
+ if (!result.response.ok || result.payload?.ok !== true) {
1328
+ // A missing route is a version mismatch, not a failure of the account.
1329
+ setError(isMissingRoute(result.response.status)
1330
+ ? t('stale.text')
1331
+ : t('picker.failed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
1332
+ return
1333
+ }
1334
+ setRepos(result.payload.value.repos ?? [])
1335
+ }, [t])
1336
+
1337
+ React.useEffect(() => {
1338
+ void load()
1339
+ }, [load])
1340
+
1341
+ const toggle = React.useCallback(
1342
+ async (entry) => {
1343
+ setBusy(entry.fullName)
1344
+ setError(null)
1345
+ /**
1346
+ * Add with the full `owner/name`: the picker knows it, and it is
1347
+ * self-sufficient — a fresh install has no `owner` configured, and a bare
1348
+ * name would then register something the Host cannot resolve to a slug.
1349
+ * Remove with whatever form is actually stored.
1350
+ */
1351
+ const target = entry.registered && typeof entry.registeredAs === 'string' ? entry.registeredAs : entry.fullName
1352
+ const result = entry.registered
1353
+ ? await postJson('/config-remove', { repo: target }, ACTION_TIMEOUT_MS)
1354
+ : await postJson('/config-add', { repo: target }, ACTION_TIMEOUT_MS)
1355
+ setBusy('')
1356
+ if (!result.ok) {
1357
+ setError(result.aborted ? t('state.timeout') : t('state.hostGone'))
1358
+ return
1359
+ }
1360
+ if (!result.response.ok || result.payload?.ok !== true) {
1361
+ setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
1362
+ return
1363
+ }
1364
+ setRepos((current) => (current ?? []).map((item) => (item.fullName === entry.fullName ? { ...item, registered: !entry.registered } : item)))
1365
+ onChanged()
1366
+ },
1367
+ [onChanged, t],
1368
+ )
1369
+
1370
+ const needle = filter.trim().toLowerCase()
1371
+ const visible = (repos ?? []).filter((entry) => needle === ''
1372
+ || entry.fullName.toLowerCase().includes(needle)
1373
+ || entry.description.toLowerCase().includes(needle))
1374
+
1375
+ return h(
1376
+ 'div',
1377
+ { className: 'dsc-picker' },
1378
+ h(
1379
+ 'div',
1380
+ { className: 'dsc-picker-head' },
1381
+ h('input', {
1382
+ className: 'dsc-input',
1383
+ type: 'search',
1384
+ value: filter,
1385
+ placeholder: t('picker.search'),
1386
+ onChange: (event) => setFilter(event.target.value),
1387
+ }),
1388
+ h(Btn, { onClick: () => void load() }, t('picker.reload')),
1389
+ ),
1390
+ error !== null ? h('div', { className: 'dsc-warn' }, error) : null,
1391
+ repos === null
1392
+ ? h('div', { className: 'dsc-empty' }, t('picker.loading'))
1393
+ : visible.length === 0
1394
+ ? h('div', { className: 'dsc-empty' }, t('picker.empty'))
1395
+ : h(
1396
+ 'div',
1397
+ { className: 'dsc-picker-list' },
1398
+ visible.map((entry) =>
1399
+ h(
1400
+ 'label',
1401
+ { className: 'dsc-picker-row', key: entry.fullName },
1402
+ h('input', {
1403
+ className: 'dsc-check',
1404
+ type: 'checkbox',
1405
+ checked: entry.registered,
1406
+ disabled: busy !== '',
1407
+ onChange: () => void toggle(entry),
1408
+ }),
1409
+ h('span', { className: 'dsc-name', style: { minWidth: '0' } }, entry.bare),
1410
+ entry.private ? h(Chip, { state: 'idle' }, t('chip.private')) : null,
1411
+ h('span', { className: 'dsc-grow' }, entry.description),
1412
+ entry.localPath !== ''
1413
+ ? h(Chip, { state: 'success', title: entry.localPath }, t('picker.local'))
1414
+ : h(Chip, { state: 'idle' }, t('picker.noLocal')),
1415
+ ),
1416
+ ),
1417
+ ),
1418
+ h('div', { className: 'dsc-hint' }, t('picker.hint')),
1419
+ )
1420
+ }
1421
+
1422
+ /** One repository row, expandable into path, releases, runs and logs. */
1423
+ function RepoRow(props) {
1424
+ const { t, data, busy, onAction, onBump, onCommit, onClone, onPublish, onUpdate, npm, onNpmPublish, bili, onBiliBind, onBiliPreview, onBiliAnnounce, logs, onLogs } = props
1425
+ const [open, setOpen] = React.useState(false)
1426
+ /**
1427
+ * A draft release is the panel's one invisible outcome: it exists, it is not
1428
+ * a failure, and it cannot be seen anywhere until it is published. So a row
1429
+ * that has one opens itself — the Publish button is the answer to "why is
1430
+ * this not released", and it should not be behind a click that nobody knows
1431
+ * to make. The effect keys on the tag, so collapsing the row by hand stays
1432
+ * collapsed until the draft state actually changes.
1433
+ */
1434
+ React.useEffect(() => {
1435
+ if (data.draftTag !== null && data.draftTag !== undefined) setOpen(true)
1436
+ }, [data.draftTag])
1437
+ const [confirming, setConfirming] = React.useState(null)
1438
+ /** The bump confirmation is inline, like publishing a draft: it creates a commit. */
1439
+ const [bumping, setBumping] = React.useState(false)
1440
+ /** 提交 asks first, and asks for a message: it writes a commit on this disk. */
1441
+ const [committing, setCommitting] = React.useState(false)
1442
+ const [commitMessage, setCommitMessage] = React.useState('')
1443
+ /** Installing a release rewrites a profile dependency, so it asks first too. */
1444
+ const [updating, setUpdating] = React.useState(false)
1445
+ /** A publish cannot be undone, so it asks first — and 2FA needs somewhere to type. */
1446
+ const [publishing, setPublishing] = React.useState(false)
1447
+ const [otp, setOtp] = React.useState('')
1448
+ const run = data.latestRun
1449
+ const local = data.local ?? { available: false }
1450
+ const dirty = Number.isFinite(local.dirty) ? local.dirty : null
1451
+ const ahead = Number.isFinite(local.ahead) ? local.ahead : null
1452
+ /*
1453
+ * 提交 is two actions in one button, because they are one trap: a release builds
1454
+ * the PUSHED commit, so work that never left the working tree and work that never
1455
+ * left the machine are equally absent from the released package. Changes → commit
1456
+ * and push. Clean but ahead → push. Neither → nothing to do, and the button says
1457
+ * so instead of offering a call that could only fail.
1458
+ */
1459
+ const canCommit = local.available === true && dirty !== null && dirty > 0
1460
+ const canPush = local.available === true && dirty === 0 && ahead !== null && ahead > 0
1461
+ const commitLabel = canPush ? 'action.push' : 'action.commit'
1462
+ const branchName = typeof local.branch === 'string' && local.branch !== '' ? local.branch : 'main'
1463
+ const commitExplain = local.available !== true
1464
+ ? t('commit.noCheckout')
1465
+ : canCommit
1466
+ ? t('commit.willCommit', { count: String(dirty) })
1467
+ : canPush
1468
+ ? t('commit.willPush', { count: String(ahead) })
1469
+ : t('commit.nothing')
1470
+ /**
1471
+ * What this profile has installed for this repository's package.
1472
+ *
1473
+ * The Host decides, not the page: the answer depends on the profile this Host
1474
+ * is running from, and a panel that guessed it from the row's version number
1475
+ * would offer "update" where the two copies are the same code and hide it where
1476
+ * they are not (a `link:` checkout and a released tarball can carry the same
1477
+ * version).
1478
+ */
1479
+ const install = data.install ?? null
1480
+ const installState = install === null ? '' : String(install.state ?? '')
1481
+ /**
1482
+ * The one state where installing is the row's business: the profile holds an
1483
+ * older PUBLISHED copy, so the action is the reason the console exists.
1484
+ */
1485
+ const installable = installState === 'update'
1486
+ /**
1487
+ * Installing is possible in three more states, and in none of them does it
1488
+ * belong on the row.
1489
+ *
1490
+ * `checkout` is the normal state on the machine that DEVELOPED the plugin: the
1491
+ * profile points at the working copy, so the row would carry a button whose only
1492
+ * effect is to replace the code being edited with a tarball. The row is for the
1493
+ * work, not for undoing the setup that makes the work possible. `ahead` is worse
1494
+ * than noise — the profile holds something NEWER, so the button offers a
1495
+ * downgrade. `differs` cannot be ordered at all, so it is a shot in the dark.
1496
+ *
1497
+ * They stay in the expansion, as quiet buttons sitting next to the sentence that
1498
+ * says what they would do, because that is still where "does the released
1499
+ * artifact actually install?" gets answered.
1500
+ */
1501
+ const installSecondary = installState === 'checkout' || installState === 'ahead' || installState === 'differs'
1502
+ const installLabel = installState === 'update' ? 'action.update' : 'action.installRelease'
1503
+ const installExplain = (() => {
1504
+ if (install === null) return null
1505
+ const params = {
1506
+ package: String(install.packageName ?? ''),
1507
+ profile: String(install.profile ?? ''),
1508
+ tag: String(install.latestTag ?? ''),
1509
+ spec: String(install.spec ?? ''),
1510
+ installed: String(install.installedVersion ?? '?'),
1511
+ }
1512
+ if (installState === 'checkout') return t('install.checkout', params)
1513
+ if (installState === 'ahead') return t('install.ahead', params)
1514
+ if (installState === 'differs') return t('install.differs', params)
1515
+ if (installState === 'current') return t('install.current', params)
1516
+ if (installState === 'no-release') return t('install.noRelease', params)
1517
+ return t('install.notInstalled', params)
1518
+ })()
1519
+ /**
1520
+ * What npm holds for this package, and why a push is or is not offered.
1521
+ *
1522
+ * Fetched on demand by the page rather than baked into the overview, so it is
1523
+ * `null` until the first answer arrives — and a `null` renders nothing at all,
1524
+ * which is the honest thing to show when the registry has not been asked.
1525
+ */
1526
+ const npmInfo = npm ?? null
1527
+ const npmBlockers = Array.isArray(npmInfo?.blockers) ? npmInfo.blockers : []
1528
+ const npmCanPush = npmInfo !== null && npmInfo.canPublish === true
1529
+ const npmExplain = (() => {
1530
+ if (npmInfo === null) return null
1531
+ const params = {
1532
+ version: String(npmInfo.version ?? '?'),
1533
+ latest: String(npmInfo.latest ?? '—'),
1534
+ reason: String(npmInfo.registryProblem ?? npmInfo.state ?? ''),
1535
+ }
1536
+ if (npmInfo.canPublish === true) {
1537
+ if (npmInfo.state === 'unregistered') return t('npm.state.unregistered', params)
1538
+ if (npmInfo.state === 'published') return t('npm.state.published', params)
1539
+ return t('npm.state.unpublished', params)
1540
+ }
1541
+ /* The first blocker is the one to fix first; the rest are consequences. */
1542
+ const first = npmBlockers[0]
1543
+ const key = `npm.blocked.${first ?? 'registry-unreachable'}`
1544
+ const text = t(key, params)
1545
+ return text === key ? t('npm.state.unknown', params) : text
1546
+ })()
1547
+ /**
1548
+ * Whether releasing the local version is possible, decided by the Host so this
1549
+ * panel and the dispatch route cannot disagree. `blocked` is only ever proven
1550
+ * (the version's release belongs to another commit), never guessed.
1551
+ */
1552
+ const check = data.releaseCheck ?? null
1553
+ const blocked = check !== null && check.state === 'blocked'
1554
+ const state = runState(run)
1555
+
1556
+ /* Only non-zero local signals are shown: a column of "clean" chips is noise,
1557
+ and the row exists to make the exceptions visible. */
1558
+ const chips = []
1559
+ /* A version that is already taken makes 发布 impossible, so the row says so
1560
+ before the button is pressed rather than after a run has failed. */
1561
+ if (blocked) chips.push(h(Chip, { key: 'v', state: 'warn', title: t('release.takenTitle') }, t('chip.versionTaken')))
1562
+ /* An installed copy that is genuinely behind the release is the one update
1563
+ worth a chip; a `link:` checkout is the normal state here and saying so on
1564
+ every row would be a column of noise. */
1565
+ if (installState === 'update' && typeof install?.latestTag === 'string') {
1566
+ chips.push(h(Chip, { key: 'u', state: 'warn', title: installExplain ?? undefined }, t('chip.update', { tag: install.latestTag })))
1567
+ }
1568
+ /*
1569
+ * The npm chip is the whole row-level surface for the second channel: a version
1570
+ * that is on npm is a fact worth seeing, and a version that is not is a job
1571
+ * waiting. Anything else — private, dirty, not signed in — belongs in the
1572
+ * expansion, where there is room to say what to do about it.
1573
+ */
1574
+ if (npmInfo !== null && npmInfo.state === 'published') {
1575
+ chips.push(h(Chip, { key: 'n', state: 'success', title: String(npmInfo.pageUrl ?? '') }, t('npm.chipPublished', { version: String(npmInfo.version ?? '') })))
1576
+ } else if (npmCanPush) {
1577
+ chips.push(h(Chip, { key: 'n', state: 'warn', title: npmExplain ?? undefined }, t('npm.chipPending', { version: String(npmInfo.version ?? '') })))
1578
+ }
1579
+ /*
1580
+ * A published release whose update note has not gone out yet. It is a chip
1581
+ * rather than a sentence because the row is scanned: the answer to "why has
1582
+ * this not been announced" belongs behind the expansion, next to the button.
1583
+ */
1584
+ if (bili?.binding != null && typeof data.publishedTag === 'string' && data.publishedTag !== '') {
1585
+ const announced = Array.isArray(bili.announced) ? bili.announced : []
1586
+ const baselineTag = bili.baseline === null || bili.baseline === undefined ? null : bili.baseline.tag
1587
+ const covered = announced.some((entry) => entry.tag === data.publishedTag) || baselineTag === data.publishedTag || (bili.baseline !== null && baselineTag === null)
1588
+ if (!covered) chips.push(h(Chip, { key: 'bl', state: 'warn' }, t('bili.chip.pending', { tag: data.publishedTag })))
1589
+ }
1590
+ if (local.available === true) {
1591
+ if (Number.isFinite(local.dirty) && local.dirty > 0) chips.push(h(Chip, { key: 'd', state: 'warn' }, t('chip.dirty', { count: String(local.dirty) })))
1592
+ if (Number.isFinite(local.ahead) && local.ahead > 0) chips.push(h(Chip, { key: 'a', state: 'warn' }, `↑${local.ahead}`))
1593
+ if (Number.isFinite(local.behind) && local.behind > 0) chips.push(h(Chip, { key: 'b', state: 'idle' }, `↓${local.behind}`))
1594
+ }
1595
+
1596
+ return h(
1597
+ 'div',
1598
+ { className: 'dsc-item' },
1599
+ h(
1600
+ 'div',
1601
+ { className: 'dsc-row' },
1602
+ h('span', { className: 'dsc-dot', 'data-state': state, title: run === null ? t('state.noRuns') : `${run.workflow} · ${run.conclusion || run.status}` }),
1603
+ h('span', { className: 'dsc-name' }, data.label),
1604
+ /* Registered, but GitHub has not been read yet: say so rather than
1605
+ showing "未发布", which would be a claim the panel cannot make. */
1606
+ data.pending === true
1607
+ ? h(Chip, { state: 'busy' }, t('state.reading'))
1608
+ : data.versionKnown === true && data.version !== null ? h(Chip, { state: 'idle' }, `v${data.version}`) : null,
1609
+ data.pending === true
1610
+ ? null
1611
+ : data.draftTag !== null
1612
+ ? h(Chip, { state: 'warn' }, `${t('chip.draft')} ${data.draftTag}`)
1613
+ : data.published
1614
+ // Name the tag: without a local checkout the panel knows the release
1615
+ // from GitHub alone, and the tag is the fact it actually has.
1616
+ ? h(Chip, { state: 'success' }, data.publishedTag === null ? t('chip.published') : `${t('chip.published')} ${data.publishedTag}`)
1617
+ : h(Chip, { state: 'idle' }, t('chip.unpublished')),
1618
+ chips,
1619
+ h('span', { className: 'dsc-grow' }, data.pending === true ? t('state.reading') : run === null ? t('state.noRuns') : `${run.workflow} · ${duration(run)} · ${stamp(run.createdAt)}`),
1620
+ h(
1621
+ 'div',
1622
+ { className: 'dsc-right' },
1623
+ /* 提交, 构建, 发布 — the order the work happens in. 提交 first because the
1624
+ other two act on what is on GitHub: a release builds the pushed commit, and
1625
+ a build runs the pushed commit, so neither can see work that is still only
1626
+ on this disk. It is never primary: it is the step, not the goal. */
1627
+ local.available === true
1628
+ ? h(Btn, {
1629
+ disabled: busy !== '' || (!canCommit && !canPush),
1630
+ title: commitExplain,
1631
+ onClick: () => {
1632
+ setOpen(true)
1633
+ setCommitting(true)
1634
+ },
1635
+ }, busy === `commit:${data.repo}` ? '…' : t(commitLabel))
1636
+ : null,
1637
+ data.hasBuildWorkflow ? h(Btn, { disabled: busy !== '', title: t('action.build'), onClick: () => onAction('build', data) }, busy === `build:${data.repo}` ? '…' : t('action.build')) : null,
1638
+ data.hasReleaseWorkflow
1639
+ ? blocked && typeof check.nextTag === 'string' && check.nextTag !== ''
1640
+ /* The version is the obstacle, so the button that works is the one
1641
+ that removes it — refusing to dispatch and leaving the user to edit
1642
+ package.json by hand would be the same dead end, one click later. */
1643
+ ? h(Btn, {
1644
+ kind: 'primary',
1645
+ disabled: busy !== '',
1646
+ title: t('action.bumpRelease'),
1647
+ onClick: () => {
1648
+ setOpen(true)
1649
+ setBumping(true)
1650
+ },
1651
+ }, busy === `bump:${data.repo}` ? '…' : t('action.bumpRelease'))
1652
+ : h(Btn, { kind: 'primary', disabled: busy !== '', title: t('action.release'), onClick: () => onAction('release', data) }, busy === `release:${data.repo}` ? '…' : t('action.release'))
1653
+ : null,
1654
+ /* The other half of the loop the console exists for: 发布 cuts the
1655
+ release, this one installs it into the profile that is running. */
1656
+ installable && install?.latestTag
1657
+ ? h(Btn, {
1658
+ disabled: busy !== '',
1659
+ title: installExplain ?? undefined,
1660
+ onClick: () => {
1661
+ setOpen(true)
1662
+ setUpdating(true)
1663
+ },
1664
+ }, busy === `update:${data.repo}` ? '…' : t(installLabel, { tag: String(install.latestTag) }))
1665
+ : null,
1666
+ h(Btn, { kind: 'quiet', square: true, onClick: () => setOpen((value) => !value), title: open ? t('action.collapse') : t('action.expand') }, open ? '▴' : '▾'),
1667
+ ),
1668
+ ),
1669
+ open
1670
+ ? h(
1671
+ 'div',
1672
+ { className: 'dsc-detail' },
1673
+ /* No usable working tree: say where one would go and offer to fetch it.
1674
+ Everything else on this page — 提交, 构建, 发布, the dirty chip — acts on
1675
+ a checkout, so this is the step that makes the rest of the row real. */
1676
+ local.available === true
1677
+ ? h('span', { className: 'dsc-mono' }, `${t('meta.path')} ${data.localPath}`)
1678
+ : h(CloneCheckout, {
1679
+ t,
1680
+ data,
1681
+ local,
1682
+ busy,
1683
+ onClone: (repo, url) => { void onClone(repo, url) },
1684
+ }),
1685
+ data.problems.length > 0 ? h('div', { className: 'dsc-warn' }, data.problems.join(' · ')) : null,
1686
+
1687
+ /* Why 发布 cannot work, and what would make it work. Both notes are
1688
+ about the same trap: a release builds the pushed commit, so anything
1689
+ only in the working tree or only local is silently absent from it. */
1690
+ blocked
1691
+ ? h('div', { className: 'dsc-warn' }, check.nextTag === null
1692
+ ? t('release.takenNoNext', { tag: check.tag ?? '', owner: shortSha(check.owner), built: shortSha(check.built) })
1693
+ : t('release.taken', { tag: check.tag ?? '', owner: shortSha(check.owner), built: shortSha(check.built), next: check.nextTag }))
1694
+ : null,
1695
+ local.available === true && Number.isFinite(local.dirty) && local.dirty > 0
1696
+ ? h('div', { className: 'dsc-warn' }, t('release.dirtyNote', { count: String(local.dirty) }))
1697
+ : null,
1698
+ local.available === true && Number.isFinite(local.ahead) && local.ahead > 0
1699
+ ? h('div', { className: 'dsc-warn' }, t('release.aheadNote', { count: String(local.ahead) }))
1700
+ : null,
1701
+ bumping && blocked && typeof check.nextTag === 'string'
1702
+ ? h(
1703
+ 'div',
1704
+ { className: 'dsc-line' },
1705
+ h('span', { className: 'dsc-grow' }, t('confirm.bumpRelease', {
1706
+ from: data.version ?? '?',
1707
+ to: check.nextTag,
1708
+ branch: typeof local.branch === 'string' && local.branch !== '' ? local.branch : 'main',
1709
+ })),
1710
+ h(Btn, {
1711
+ kind: 'danger',
1712
+ disabled: busy !== '',
1713
+ onClick: () => {
1714
+ setBumping(false)
1715
+ onBump(data)
1716
+ },
1717
+ }, t('action.bumpRelease')),
1718
+ h(Btn, { kind: 'quiet', onClick: () => setBumping(false) }, t('confirm.cancel')),
1719
+ )
1720
+ : null,
1721
+ /*
1722
+ * 提交 asks first, and asks for a message — it writes a commit on this disk
1723
+ * and pushes it. `git add -A` is the one action in this panel that can
1724
+ * sweep in a file the author never meant to publish, so the list is on
1725
+ * screen above the button: the count alone ("7 changes") could be the three
1726
+ * you meant and four you have never seen.
1727
+ */
1728
+ committing && (canCommit || canPush)
1729
+ ? h(
1730
+ 'div',
1731
+ { className: 'dsc-commit' },
1732
+ canCommit ? h('span', null, t('commit.willCommit', { count: String(dirty) })) : null,
1733
+ canCommit && Array.isArray(local.files) && local.files.length > 0
1734
+ ? h('span', { className: 'dsc-mono dsc-quiet' }, `${local.files.join(' · ')}${dirty > local.files.length ? ` …+${String(dirty - local.files.length)}` : ''}`)
1735
+ : null,
1736
+ canCommit
1737
+ ? h('input', {
1738
+ className: 'dsc-input',
1739
+ type: 'text',
1740
+ value: commitMessage,
1741
+ placeholder: t('commit.messagePlaceholder'),
1742
+ onChange: (event) => setCommitMessage(String(event?.target?.value ?? '')),
1743
+ })
1744
+ : h('span', null, t('confirm.push', { count: String(ahead), branch: branchName })),
1745
+ h(
1746
+ 'div',
1747
+ { className: 'dsc-line' },
1748
+ h(Btn, {
1749
+ kind: 'danger',
1750
+ disabled: busy !== '' || (canCommit && commitMessage.trim() === ''),
1751
+ title: canCommit && commitMessage.trim() === '' ? t('commit.messageRequired') : undefined,
1752
+ onClick: () => {
1753
+ const message = canCommit ? commitMessage.trim() : ''
1754
+ setCommitting(false)
1755
+ setCommitMessage('')
1756
+ onCommit(data, message)
1757
+ },
1758
+ }, t(canCommit ? 'action.commitConfirm' : 'action.pushConfirm')),
1759
+ h(Btn, { kind: 'quiet', onClick: () => setCommitting(false) }, t('confirm.cancel')),
1760
+ ),
1761
+ )
1762
+ : null,
1763
+
1764
+ /* What this profile has installed, and the one action that changes it.
1765
+ Without this the panel could cut a release and never take it: the
1766
+ installed copy is the half of "is it published?" that GitHub cannot
1767
+ answer. */
1768
+ install !== null
1769
+ ? h(
1770
+ 'div',
1771
+ { className: 'dsc-sub' },
1772
+ h('span', null, t('install.title')),
1773
+ h(
1774
+ 'div',
1775
+ { className: 'dsc-line' },
1776
+ h('span', { className: 'dsc-name', style: { minWidth: '0', fontWeight: '500' } }, String(install.packageName ?? '')),
1777
+ /* No version chip when none could be read: the explanation
1778
+ below says which copy this is, and an invented "v?" would
1779
+ be a claim the Host did not make. */
1780
+ install.installedVersion === null
1781
+ ? null
1782
+ : h(Chip, { state: installState === 'current' ? 'success' : installState === 'update' ? 'warn' : 'idle' }, `v${String(install.installedVersion)}`),
1783
+ installState === 'checkout' ? h(Chip, { state: 'warn' }, t('chip.checkout')) : null,
1784
+ h('span', { className: 'dsc-grow', title: String(install.spec ?? '') }, install.present === true ? String(install.spec ?? '') : `— ${String(install.profile ?? '')}`),
1785
+ (installable || installSecondary) && install.latestTag
1786
+ ? h(Btn, {
1787
+ kind: installState === 'update' ? 'primary' : 'quiet',
1788
+ disabled: busy !== '',
1789
+ title: installExplain ?? undefined,
1790
+ onClick: () => setUpdating(true),
1791
+ }, busy === `update:${data.repo}` ? '…' : t(installLabel, { tag: String(install.latestTag) }))
1792
+ : null,
1793
+ ),
1794
+ h('span', null, installExplain),
1795
+ )
1796
+ : null,
1797
+ /* The second channel. A GitHub Release and an npm version are separate
1798
+ facts about the same package, and this is where they sit next to each
1799
+ other instead of in two browser tabs. */
1800
+ npmInfo !== null
1801
+ ? h(
1802
+ 'div',
1803
+ { className: 'dsc-sub' },
1804
+ h('span', null, t('npm.title')),
1805
+ h(
1806
+ 'div',
1807
+ { className: 'dsc-line' },
1808
+ h('span', { className: 'dsc-name', style: { minWidth: '0', fontWeight: '500' } }, String(npmInfo.packageName ?? '')),
1809
+ npmInfo.state === 'published'
1810
+ ? h(Chip, { state: 'success' }, `v${String(npmInfo.version ?? '?')}`)
1811
+ : npmInfo.state === 'unregistered'
1812
+ ? h(Chip, { state: 'warn' }, t('npm.chipUnregistered'))
1813
+ : h(Chip, { state: 'idle' }, `v${String(npmInfo.version ?? '?')}`),
1814
+ npmInfo.latest !== null && npmInfo.latest !== undefined
1815
+ ? h('span', { className: 'dsc-grow' }, `npm latest ${String(npmInfo.latest)}`)
1816
+ : h('span', { className: 'dsc-grow' }, String(npmInfo.pageUrl ?? '')),
1817
+ npmCanPush
1818
+ ? h(Btn, {
1819
+ disabled: busy !== '',
1820
+ title: npmExplain ?? undefined,
1821
+ onClick: () => setPublishing(true),
1822
+ }, busy === `npm:${data.repo}` ? '…' : t('npm.action.publish', { version: String(npmInfo.version ?? '') }))
1823
+ : null,
1824
+ ),
1825
+ h('span', null, npmExplain),
1826
+ /* A draft (or missing) GitHub release on the same version is worth
1827
+ saying out loud: the two channels are about to disagree in public. */
1828
+ npmCanPush && data.publishedTag !== `v${String(npmInfo.version ?? '')}`
1829
+ ? h('span', { className: 'dsc-warn' }, t('npm.githubDraft'))
1830
+ : null,
1831
+ )
1832
+ : null,
1833
+ publishing && npmCanPush
1834
+ ? h(
1835
+ 'div',
1836
+ { className: 'dsc-line' },
1837
+ h('span', { className: 'dsc-grow' }, t('confirm.npmPublish', {
1838
+ package: String(npmInfo.packageName ?? ''),
1839
+ version: String(npmInfo.version ?? ''),
1840
+ registry: String(npmInfo.registry ?? ''),
1841
+ })),
1842
+ h('input', {
1843
+ className: 'dsc-input dsc-otp',
1844
+ type: 'text',
1845
+ inputMode: 'numeric',
1846
+ autoComplete: 'one-time-code',
1847
+ placeholder: t('npm.otpPlaceholder'),
1848
+ title: t('npm.otpHint'),
1849
+ 'aria-label': t('npm.otpLabel'),
1850
+ value: otp,
1851
+ onChange: (event) => setOtp(String(event?.target?.value ?? '').replace(/[^0-9]/g, '').slice(0, 8)),
1852
+ }),
1853
+ h(Btn, {
1854
+ kind: 'danger',
1855
+ disabled: busy !== '',
1856
+ onClick: () => {
1857
+ setPublishing(false)
1858
+ onNpmPublish(data, otp)
1859
+ },
1860
+ }, t('npm.action.confirm')),
1861
+ h(Btn, { kind: 'quiet', onClick: () => setPublishing(false) }, t('confirm.cancel')),
1862
+ )
1863
+ : null,
1864
+
1865
+ /* The confirmation has to appear for every button that can be pressed,
1866
+ including the quiet ones — a secondary action whose confirm dialog was
1867
+ gated on the row's narrower condition would be a dead button. */
1868
+ updating && (installable || installSecondary) && install?.latestTag
1869
+ ? h(
1870
+ 'div',
1871
+ { className: 'dsc-line' },
1872
+ h('span', { className: 'dsc-grow' }, t('confirm.update', {
1873
+ package: String(install.packageName ?? ''),
1874
+ profile: String(install.profile ?? ''),
1875
+ spec: install.present === true ? String(install.spec ?? '') : '—',
1876
+ tag: String(install.latestTag),
1877
+ })),
1878
+ h(Btn, {
1879
+ kind: 'danger',
1880
+ disabled: busy !== '',
1881
+ onClick: () => {
1882
+ setUpdating(false)
1883
+ onUpdate(data)
1884
+ },
1885
+ }, t('action.confirmUpdate')),
1886
+ h(Btn, { kind: 'quiet', onClick: () => setUpdating(false) }, t('confirm.cancel')),
1887
+ )
1888
+ : null,
1889
+
1890
+ /* The third channel, inside the row that owns it: which video carries
1891
+ this repository's update notes, and the button that posts one now. */
1892
+ bili !== null && bili !== undefined
1893
+ ? h(BiliBinding, { t, data, bili, busy, onBind: onBiliBind, onPreview: onBiliPreview, onAnnounce: onBiliAnnounce })
1894
+ : null,
1895
+
1896
+ h(
1897
+ 'div',
1898
+ { className: 'dsc-sub' },
1899
+ h('span', null, `${t('meta.version')} ${data.version ?? '?'}${data.expectedTag === null ? '' : ` · ${t('meta.expectedTag', { tag: data.expectedTag })}`}`),
1900
+ data.releases.length === 0
1901
+ ? h('span', null, t('state.noReleases'))
1902
+ : data.releases.map((release) =>
1903
+ h(
1904
+ 'div',
1905
+ { className: 'dsc-line', key: release.tag },
1906
+ h('span', { className: 'dsc-name', style: { minWidth: '0', fontWeight: '500' } }, release.tag),
1907
+ release.draft ? h(Chip, { state: 'warn' }, t('chip.draft')) : h(Chip, { state: 'success' }, t('chip.published')),
1908
+ h('span', { className: 'dsc-grow' }, `${stamp(release.createdAt)} · ${t('meta.assets', { count: String(release.assets.length) })}`),
1909
+ release.draft
1910
+ ? confirming === release.tag
1911
+ ? h(
1912
+ React.Fragment,
1913
+ null,
1914
+ h(Btn, { kind: 'danger', disabled: busy !== '', onClick: () => onPublish(release.tag) }, t('action.confirmPublish')),
1915
+ h(Btn, { kind: 'quiet', onClick: () => setConfirming(null) }, t('confirm.cancel')),
1916
+ )
1917
+ : h(Btn, { onClick: () => setConfirming(release.tag) }, t('action.publishDraft'))
1918
+ : null,
1919
+ release.url !== '' ? h(Btn, { kind: 'quiet', onClick: () => globalThis.open(release.url, '_blank', 'noopener,noreferrer') }, t('action.open')) : null,
1920
+ ),
1921
+ ),
1922
+ ),
1923
+
1924
+ h(
1925
+ 'div',
1926
+ { className: 'dsc-sub' },
1927
+ h('span', null, t('label.runs')),
1928
+ data.runs.length === 0
1929
+ ? h('span', null, t('state.noRuns'))
1930
+ : data.runs.slice(0, 4).map((entry) =>
1931
+ h(
1932
+ 'div',
1933
+ { className: 'dsc-line', key: String(entry.id) },
1934
+ h(Chip, { state: runState(entry) }, entry.conclusion || entry.status || t('run.unknown')),
1935
+ h('span', { className: 'dsc-grow', title: entry.title }, `${entry.workflow} · ${entry.title}`),
1936
+ h('span', null, duration(entry)),
1937
+ entry.status !== 'completed' && entry.id !== null
1938
+ ? h(Btn, { kind: 'quiet', disabled: busy !== '', onClick: () => onAction('cancel', data, { runId: entry.id }) }, t('action.cancel'))
1939
+ : null,
1940
+ entry.status === 'completed' && entry.id !== null
1941
+ ? h(Btn, { kind: 'quiet', disabled: busy !== '', onClick: () => onAction('rerun-failed', data, { runId: entry.id }) }, t('action.rerun'))
1942
+ : null,
1943
+ entry.status === 'completed' && entry.id !== null
1944
+ ? h(Btn, { kind: 'quiet', disabled: busy !== '', onClick: () => onLogs(data, entry) }, logs !== null && logs.runId === entry.id ? t('action.hideLogs') : t('action.logs'))
1945
+ : null,
1946
+ ),
1947
+ ),
1948
+ ),
1949
+
1950
+ logs !== null && logs.repo === data.repo
1951
+ ? h(
1952
+ 'div',
1953
+ null,
1954
+ h('pre', { className: 'dsc-logs' }, logs.lines.join('\n')),
1955
+ logs.truncated ? h('span', null, t('state.truncated', { lines: String(logs.lines.length) })) : null,
1956
+ )
1957
+ : null,
1958
+ )
1959
+ : null,
1960
+ )
1961
+ }
1962
+
1963
+ /** Shared data plumbing for the panel and the settings page. */
1964
+ function useConsoleState() {
1965
+ const [status, setStatus] = React.useState(null)
1966
+ const [overview, setOverview] = React.useState(null)
1967
+ const [npm, setNpm] = React.useState(null)
1968
+ const [bili, setBili] = React.useState(null)
1969
+ const [error, setError] = React.useState(null)
1970
+
1971
+ const loadStatus = React.useCallback(async () => {
1972
+ const result = await postJson('/status', {}, STATUS_TIMEOUT_MS)
1973
+ if (!result.ok) {
1974
+ setError(result.aborted ? 'timeout' : 'host')
1975
+ return null
1976
+ }
1977
+ if (result.response.ok && result.payload?.ok === true) {
1978
+ setStatus(result.payload.value ?? null)
1979
+ setError(null)
1980
+ return result.payload.value
1981
+ }
1982
+ setError(isMissingRoute(result.response.status) ? 'stale-host' : (result.payload?.message ?? `HTTP ${String(result.response.status)}`))
1983
+ return null
1984
+ }, [])
1985
+
1986
+ /**
1987
+ * `silent` is what the poll uses: a failed BACKGROUND request must never
1988
+ * clear the last known state nor raise a banner the user did not ask for.
1989
+ */
1990
+ const loadOverview = React.useCallback(async (options) => {
1991
+ const result = await postJson('/overview', { force: options?.force === true }, OVERVIEW_TIMEOUT_MS)
1992
+ if (!result.ok) {
1993
+ if (options?.silent !== true) setError(result.aborted ? 'timeout' : 'host')
1994
+ return
1995
+ }
1996
+ if (result.response.ok && result.payload?.ok === true) {
1997
+ setOverview(result.payload.value ?? null)
1998
+ if (options?.silent !== true) setError(null)
1999
+ }
2000
+ }, [])
2001
+
2002
+ /**
2003
+ * The npm registry's view of every configured package.
2004
+ *
2005
+ * Deliberately NOT part of the overview. The overview is polled every thirty
2006
+ * seconds and answers from GitHub; this costs one HTTPS request per repository
2007
+ * plus a `whoami`, and its answer changes when someone publishes — so it is
2008
+ * fetched when the panel opens, after a publish, and when asked.
2009
+ *
2010
+ * A failure is reported as "no npm information", never as an empty one: a chip
2011
+ * that says "not on npm" because the registry could not be reached would be a
2012
+ * statement the Host never made.
2013
+ */
2014
+ const loadNpm = React.useCallback(async (options) => {
2015
+ const result = await postJson('/npm-status', { force: options?.force === true }, NPM_TIMEOUT_MS)
2016
+ if (!result.ok || !result.response.ok || result.payload?.ok !== true) return
2017
+ setNpm(result.payload.value ?? null)
2018
+ }, [])
2019
+
2020
+ /**
2021
+ * The Bilibili side: which credential, which video, and what was said.
2022
+ *
2023
+ * Also not polled, and for a second reason on top of npm's: the answer
2024
+ * includes a video lookup and an account lookup, and the panel asks for it
2025
+ * when it opens, after an action, and when a person asks. The Host caches
2026
+ * both answers (`bilibiliVerifyTtlMs`), so even a refresh loop is bounded.
2027
+ */
2028
+ const loadBili = React.useCallback(async (options) => {
2029
+ const result = await postJson('/bilibili-status', { force: options?.force === true }, OVERVIEW_TIMEOUT_MS)
2030
+ if (!result.ok || !result.response.ok || result.payload?.ok !== true) return
2031
+ setBili(result.payload.value ?? null)
2032
+ }, [])
2033
+
2034
+ return { status, overview, npm, bili, error, setError, loadStatus, loadOverview, loadNpm, loadBili }
2035
+ }
2036
+
2037
+ /** Human-readable text for the transport and version failures. */
2038
+ function errorText(t, code) {
2039
+ if (code === 'timeout') return t('state.timeout')
2040
+ if (code === 'host') return t('state.hostGone')
2041
+ if (code === 'stale-host') return t('stale.text')
2042
+ return code
2043
+ }
2044
+
2045
+ /**
2046
+ * Whether a response means "this Host half does not have that route".
2047
+ *
2048
+ * The local web server answers an unmounted `/api/*` path with 401; a Host
2049
+ * that mounts a different set can also 404. Both are version mismatches first
2050
+ * and request failures second, so they get the version message.
2051
+ */
2052
+ function isMissingRoute(status) {
2053
+ return status === 401 || status === 404
2054
+ }
2055
+
2056
+ /** Whether a sign-in or a scope grant is still required. */
2057
+ function needsAccountSetup(gh) {
2058
+ if (gh.available !== true) return true
2059
+ if (gh.authenticated !== true) return true
2060
+ return Array.isArray(gh.missingScopes) && gh.missingScopes.length > 0
2061
+ }
2062
+
2063
+ /** The console panel: compact rows, and the picker behind one button. */
2064
+ function ConsolePage(props) {
2065
+ const t = typeof props?.t === 'function' ? props.t : (key) => key
2066
+ const { status, overview, npm, bili, error, setError, loadStatus, loadOverview, loadNpm, loadBili } = useConsoleState()
2067
+ const [busy, setBusy] = React.useState('')
2068
+ const [notice, setNotice] = React.useState(null)
2069
+ const [logs, setLogs] = React.useState(null)
2070
+ /**
2071
+ * The npm token being typed, held only until the request that writes it.
2072
+ *
2073
+ * It lives in page state rather than anywhere durable on purpose: the Host
2074
+ * writes it to the user-level `.npmrc` that npm itself reads, and clears it from
2075
+ * the field the moment that answers.
2076
+ */
2077
+ const [npmToken, setNpmToken] = React.useState('')
2078
+ /** Set when a pasted token was refused, which is when the checklist is read. */
2079
+ const [npmTokenRejected, setNpmTokenRejected] = React.useState(false)
2080
+ /** The classified outcome of the last publish: `otp-required`, `E403`, … */
2081
+ const [npmFailure, setNpmFailure] = React.useState(null)
2082
+ /**
2083
+ * The update that just landed and the restart it needs.
2084
+ *
2085
+ * Held as state rather than folded into the notice, because a notice is a
2086
+ * sentence that expires and this is a question with two buttons. It is also
2087
+ * the one thing here that cannot be discovered later: an updated plugin looks
2088
+ * identical until the app restarts, so "you are not running the new code yet"
2089
+ * has to be on screen while the user is still looking.
2090
+ */
2091
+ const [restartPrompt, setRestartPrompt] = React.useState(null)
2092
+ /**
2093
+ * Which view this panel opens in.
2094
+ *
2095
+ * A first visit is about setting the tool up, so it opens on the repository
2096
+ * list; afterwards the last choice wins. Without this, re-entering the panel
2097
+ * dropped back to the setup view every time — even for someone who had just
2098
+ * finished configuring it and wanted to see the repositories.
2099
+ */
2100
+ const [managing, setManaging] = React.useState(() => {
2101
+ try {
2102
+ const stored = globalThis.localStorage?.getItem('dsh-cicd.managing')
2103
+ if (stored === 'true') return true
2104
+ if (stored === 'false') return false
2105
+ } catch {
2106
+ /* storage can be unavailable; the default below is fine */
2107
+ }
2108
+ return true
2109
+ })
2110
+ React.useEffect(() => {
2111
+ try {
2112
+ globalThis.localStorage?.setItem('dsh-cicd.managing', String(managing))
2113
+ } catch {
2114
+ /* a preference that cannot be stored is not worth failing over */
2115
+ }
2116
+ }, [managing])
2117
+
2118
+ React.useEffect(() => {
2119
+ void loadStatus()
2120
+ void loadOverview({ force: true })
2121
+ // Once, not on the poll: the npm answer changes when someone publishes.
2122
+ void loadNpm({})
2123
+ // And likewise: a Bilibili answer includes an account and a video lookup.
2124
+ void loadBili({})
2125
+ }, [loadStatus, loadOverview, loadNpm, loadBili])
2126
+
2127
+ const pollSeconds = status?.config?.pollSeconds ?? 30
2128
+ React.useEffect(() => {
2129
+ const timer = globalThis.setInterval(() => {
2130
+ void loadOverview({ silent: true })
2131
+ }, Math.max(10, pollSeconds) * 1000)
2132
+ return () => {
2133
+ globalThis.clearInterval(timer)
2134
+ }
2135
+ }, [loadOverview, pollSeconds])
2136
+
2137
+ /**
2138
+ * Refresh when the panel comes back into view.
2139
+ *
2140
+ * This panel can stay mounted while another one is selected, so re-entering it
2141
+ * used to show whatever had last been rendered — up to a full poll interval
2142
+ * old, which reads as "it still shows the old screen".
2143
+ */
2144
+ React.useEffect(() => {
2145
+ const onVisible = () => {
2146
+ if (document.visibilityState === 'visible') void loadOverview({ force: true })
2147
+ }
2148
+ document.addEventListener('visibilitychange', onVisible)
2149
+ globalThis.addEventListener?.('focus', onVisible)
2150
+ return () => {
2151
+ document.removeEventListener('visibilitychange', onVisible)
2152
+ globalThis.removeEventListener?.('focus', onVisible)
2153
+ }
2154
+ }, [loadOverview])
2155
+
2156
+ React.useEffect(() => {
2157
+ if (notice === null) return undefined
2158
+ const timer = globalThis.setTimeout(() => {
2159
+ setNotice(null)
2160
+ }, NOTICE_TTL_MS)
2161
+ return () => {
2162
+ globalThis.clearTimeout(timer)
2163
+ }
2164
+ }, [notice])
2165
+
2166
+ /** Run one mutating route, then refresh so the panel shows its effect. */
2167
+ const run = React.useCallback(
2168
+ async (key, path, body, successKey, params) => {
2169
+ setBusy(key)
2170
+ setNotice(null)
2171
+ setError(null)
2172
+ const result = await postJson(path, body, ACTION_TIMEOUT_MS)
2173
+ setBusy('')
2174
+ if (!result.ok) {
2175
+ setError(result.aborted ? 'timeout' : 'host')
2176
+ return
2177
+ }
2178
+ if (!result.response.ok || result.payload?.ok !== true) {
2179
+ setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
2180
+ return
2181
+ }
2182
+ setNotice(t(successKey, params))
2183
+ await loadOverview({ force: true })
2184
+ },
2185
+ [loadOverview, setError, t],
2186
+ )
2187
+
2188
+ const onAction = React.useCallback(
2189
+ (kind, data, extra) => {
2190
+ if (kind === 'build') {
2191
+ const workflow = status?.config?.buildWorkflow ?? 'ci.yml'
2192
+ void run(`build:${data.repo}`, '/dispatch', { repo: data.repo, workflow }, 'state.dispatched', { workflow, repo: data.repo })
2193
+ return
2194
+ }
2195
+ if (kind === 'release') {
2196
+ void run(`release:${data.repo}`, '/dispatch', { repo: data.repo, workflow: status?.config?.releaseWorkflow ?? 'release.yml', inputs: { draft: 'true' } }, 'state.releaseDispatched', { repo: data.repo })
2197
+ return
2198
+ }
2199
+ void run(`${kind}:${data.repo}`, '/run-action', { repo: data.repo, runId: extra?.runId, action: kind }, kind === 'cancel' ? 'state.cancelled' : 'state.rerun', {})
2200
+ },
2201
+ [run, status],
2202
+ )
2203
+
2204
+ const onPublish = React.useCallback(
2205
+ (repo, tag) => {
2206
+ void run(`publish:${repo}`, '/release-action', { repo, tag, action: 'publish' }, 'state.published', { tag })
2207
+ },
2208
+ [run],
2209
+ )
2210
+
2211
+ /**
2212
+ * Bump the version, push it, then dispatch the release.
2213
+ *
2214
+ * One action rather than two buttons because neither half stands alone: a bump
2215
+ * with no release leaves a commit nobody asked for, and a release with no bump
2216
+ * is the run that could only fail. The new version is the Host's own
2217
+ * arithmetic — the number in the confirmation is the number that gets written —
2218
+ * and a failed bump reports why without dispatching anything.
2219
+ */
2220
+ const onBump = React.useCallback(
2221
+ async (data) => {
2222
+ const key = `bump:${data.repo}`
2223
+ setBusy(key)
2224
+ setNotice(null)
2225
+ setError(null)
2226
+ const bumped = await postJson('/version-bump', { repo: data.repo, release: 'patch' }, ACTION_TIMEOUT_MS)
2227
+ if (!bumped.ok) {
2228
+ setBusy('')
2229
+ setError('timeout')
2230
+ return
2231
+ }
2232
+ if (!bumped.response.ok || bumped.payload?.ok !== true) {
2233
+ setBusy('')
2234
+ setError(t('state.actionFailed', { reason: releaseFailure(t, bumped.payload, bumped.response) }))
2235
+ await loadOverview({ force: true })
2236
+ return
2237
+ }
2238
+ const value = bumped.payload.value ?? {}
2239
+ const dispatched = await postJson('/dispatch', {
2240
+ repo: data.repo,
2241
+ workflow: status?.config?.releaseWorkflow ?? 'release.yml',
2242
+ inputs: { draft: 'true' },
2243
+ }, ACTION_TIMEOUT_MS)
2244
+ setBusy('')
2245
+ if (!dispatched.ok || !dispatched.response.ok || dispatched.payload?.ok !== true) {
2246
+ setError(t('state.actionFailed', { reason: releaseFailure(t, dispatched.payload, dispatched.response) }))
2247
+ await loadOverview({ force: true })
2248
+ return
2249
+ }
2250
+ setNotice(t('state.releaseBumped', {
2251
+ from: String(value.from ?? '?'),
2252
+ tag: String(value.tag ?? ''),
2253
+ branch: String(value.branch ?? ''),
2254
+ }))
2255
+ await loadOverview({ force: true })
2256
+ },
2257
+ [loadOverview, setError, status, t],
2258
+ )
2259
+
2260
+ /**
2261
+ * Commit the working tree, and push it.
2262
+ *
2263
+ * The step 构建 and 发布 both depend on and neither can substitute for: a workflow
2264
+ * runs the pushed commit, so work that is still only on this disk is invisible to
2265
+ * both of them. `message === ''` means "push what is already committed".
2266
+ */
2267
+ const onCommit = React.useCallback(
2268
+ async (data, message) => {
2269
+ const key = `commit:${data.repo}`
2270
+ setBusy(key)
2271
+ setNotice(null)
2272
+ setError(null)
2273
+ const result = await postJson('/commit', { repo: data.repo, message }, ACTION_TIMEOUT_MS)
2274
+ setBusy('')
2275
+ if (!result.ok) {
2276
+ setError('timeout')
2277
+ return
2278
+ }
2279
+ if (!result.response.ok || result.payload?.ok !== true) {
2280
+ setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
2281
+ await loadOverview({ force: true })
2282
+ return
2283
+ }
2284
+ const value = result.payload.value ?? {}
2285
+ const branch = String(value.branch ?? '')
2286
+ setNotice(value.committed === true
2287
+ ? t('state.committed', { count: String(Array.isArray(value.files) ? value.files.length : 0), branch })
2288
+ : t('state.pushed', { count: String(value.carried ?? 0), branch }))
2289
+ await loadOverview({ force: true })
2290
+ },
2291
+ [loadOverview, setError, t],
2292
+ )
2293
+
2294
+ /**
2295
+ * Fetch a working tree for a repository that has none.
2296
+ *
2297
+ * The address is whatever the field holds — the Host derives one when the panel
2298
+ * opens, and a person may have replaced it with a fork or a private remote.
2299
+ */
2300
+ const onClone = React.useCallback(
2301
+ async (data, url) => {
2302
+ const key = `clone:${data.repo}`
2303
+ setBusy(key)
2304
+ setNotice(null)
2305
+ setError(null)
2306
+ const result = await postJson('/clone', { repo: data.repo, url }, ACTION_TIMEOUT_MS)
2307
+ setBusy('')
2308
+ if (!result.ok) {
2309
+ setError('timeout')
2310
+ return
2311
+ }
2312
+ if (!result.response.ok || result.payload?.ok !== true) {
2313
+ setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
2314
+ await loadOverview({ force: true })
2315
+ return
2316
+ }
2317
+ setNotice(t('clone.done', { path: String(result.payload.value?.path ?? '') }))
2318
+ await loadOverview({ force: true })
2319
+ },
2320
+ [loadOverview, setError, t],
2321
+ )
2322
+
2323
+ /**
2324
+ * Install the release into this profile, then ask about the restart.
2325
+ *
2326
+ * Deliberately not routed through `run`: that helper reports success with one
2327
+ * sentence and refreshes, and this action's result is a question. The Host is
2328
+ * the one that says whether a restart is needed (`restartRequired`), so the
2329
+ * prompt follows the answer instead of assuming it.
2330
+ */
2331
+ const onUpdate = React.useCallback(
2332
+ async (data) => {
2333
+ const key = `update:${data.repo}`
2334
+ const tag = String(data?.install?.latestTag ?? '')
2335
+ setBusy(key)
2336
+ setNotice(null)
2337
+ setError(null)
2338
+ setRestartPrompt(null)
2339
+ const result = await postJson('/update', { repo: data.repo }, UPDATE_TIMEOUT_MS)
2340
+ setBusy('')
2341
+ if (!result.ok) {
2342
+ setError(result.aborted ? 'timeout' : 'host')
2343
+ return
2344
+ }
2345
+ if (!result.response.ok || result.payload?.ok !== true) {
2346
+ setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
2347
+ await loadOverview({ force: true })
2348
+ return
2349
+ }
2350
+ const value = result.payload.value ?? {}
2351
+ const builds = Array.isArray(value.pendingBuilds) ? value.pendingBuilds.length : 0
2352
+ /*
2353
+ * `changed: false` is the honest answer when the profile already pointed at
2354
+ * exactly this tarball: nothing was written, so nothing needs restarting,
2355
+ * and saying "已从 X 换成 Y" would describe a write that did not happen.
2356
+ */
2357
+ setNotice(value.changed === false
2358
+ ? t('state.updatedSame', { package: String(value.packageName ?? data.repo), tag: String(value.tag ?? tag) })
2359
+ : t('state.updated', {
2360
+ package: String(value.packageName ?? data.repo),
2361
+ from: value.from === null || value.from === undefined ? '—' : `v${String(value.from)}`,
2362
+ tag: String(value.tag ?? tag),
2363
+ }) + (builds > 0 ? ` ${t('state.updatedBuilds', { count: String(builds) })}` : ''))
2364
+ if (value.restartRequired === true) {
2365
+ setRestartPrompt({ package: String(value.packageName ?? data.repo), tag: String(value.tag ?? tag) })
2366
+ }
2367
+ await loadOverview({ force: true })
2368
+ },
2369
+ [loadOverview, setError, t],
2370
+ )
2371
+
2372
+ /**
2373
+ * Ask the one-click restart plugin to restart DSH.
2374
+ *
2375
+ * The Host forwards this to `/api/dsh-restart/restart` on its own authority, so
2376
+ * a missing restart plugin comes back as a named 501 rather than a bare
2377
+ * failure — and this page never talks to another plugin directly.
2378
+ */
2379
+ const onRestart = React.useCallback(async () => {
2380
+ setBusy('restart')
2381
+ setError(null)
2382
+ const result = await postJson('/restart', {}, ACTION_TIMEOUT_MS)
2383
+ setBusy('')
2384
+ if (!result.ok) {
2385
+ setError(result.aborted ? 'timeout' : 'host')
2386
+ return
2387
+ }
2388
+ if (!result.response.ok || result.payload?.ok !== true) {
2389
+ setError(t('restart.unavailable', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
2390
+ return
2391
+ }
2392
+ setRestartPrompt(null)
2393
+ setNotice(t('restart.scheduled'))
2394
+ }, [setError, t])
2395
+
2396
+ /**
2397
+ * Push one package to npm.
2398
+ *
2399
+ * The one-time password is passed through and then thrown away, and every
2400
+ * refusal comes back as the Host's own sentence — `private-package`,
2401
+ * `dirty-tree` and `already-published` are three different fixes and the panel
2402
+ * does not try to paraphrase them.
2403
+ */
2404
+ const onNpmPublish = React.useCallback(
2405
+ async (data, otp) => {
2406
+ const key = `npm:${data.repo}`
2407
+ setBusy(key)
2408
+ setNotice(null)
2409
+ setError(null)
2410
+ const result = await postJson('/npm-publish', { repo: data.repo, otp: String(otp ?? '') }, UPDATE_TIMEOUT_MS)
2411
+ setBusy('')
2412
+ if (!result.ok) {
2413
+ setError(result.aborted ? 'timeout' : 'host')
2414
+ return
2415
+ }
2416
+ if (!result.response.ok || result.payload?.ok !== true) {
2417
+ setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
2418
+ /* The named outcome is what turns npm's own words into a next step. */
2419
+ setNpmFailure(String(result.payload?.code ?? 'unknown'))
2420
+ await loadNpm({ force: true })
2421
+ return
2422
+ }
2423
+ setNpmFailure(null)
2424
+ const value = result.payload.value ?? {}
2425
+ setNotice(t(value.wasUnregistered === true ? 'state.npmFirstPublish' : 'state.npmPublished', {
2426
+ package: String(value.packageName ?? data.repo),
2427
+ version: String(value.version ?? ''),
2428
+ registry: String(value.registry ?? ''),
2429
+ }))
2430
+ await loadNpm({ force: true })
2431
+ },
2432
+ [loadNpm, setError, t],
2433
+ )
2434
+
2435
+ /**
2436
+ * Write an npm token where npm itself reads it, and prove it works.
2437
+ *
2438
+ * The promise the panel makes about credentials is the one it already keeps for
2439
+ * `gh`: it stores none. The token goes into the user-level `.npmrc` — the same
2440
+ * file `npm login` writes — and is cleared from the field as soon as the Host
2441
+ * answers, so it is not left sitting in a page that stays open for hours.
2442
+ */
2443
+ const onNpmLogin = React.useCallback(async () => {
2444
+ setBusy('npm-login')
2445
+ setNotice(null)
2446
+ setError(null)
2447
+ const result = await postJson('/npm-login', { token: npmToken }, ACTION_TIMEOUT_MS)
2448
+ setBusy('')
2449
+ setNpmToken('')
2450
+ if (!result.ok) {
2451
+ setError(result.aborted ? 'timeout' : 'host')
2452
+ return
2453
+ }
2454
+ if (!result.response.ok || result.payload?.ok !== true) {
2455
+ setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
2456
+ setNpmTokenRejected(result.payload?.code === 'token-rejected')
2457
+ await loadNpm({ force: true })
2458
+ return
2459
+ }
2460
+ setNpmTokenRejected(false)
2461
+ const value = result.payload.value ?? {}
2462
+ setNotice(t('npm.tokenWritten', { npmrc: String(value.npmrcPath ?? ''), account: String(value.account ?? '') }))
2463
+ await loadNpm({ force: true })
2464
+ }, [loadNpm, npmToken, setError, t])
2465
+
2466
+ const onLogs = React.useCallback(async (data, entry) => {
2467
+ if (logs !== null && logs.repo === data.repo && logs.runId === entry.id) {
2468
+ setLogs(null)
2469
+ return
2470
+ }
2471
+ const result = await postJson('/logs', { repo: data.repo, runId: entry.id }, ACTION_TIMEOUT_MS)
2472
+ if (!result.ok || !result.response.ok || result.payload?.ok !== true) {
2473
+ setError(t('state.actionFailed', { reason: result.payload?.message ?? 'logs unavailable' }))
2474
+ return
2475
+ }
2476
+ setLogs({ repo: data.repo, runId: entry.id, lines: result.payload.value?.lines ?? [], truncated: result.payload.value?.truncated === true })
2477
+ }, [logs, setError, t])
2478
+
2479
+ /**
2480
+ * Bind or unbind a repository's video.
2481
+ *
2482
+ * `auto` is carried through on every save so that toggling the switch does not
2483
+ * silently re-enable a binding someone deliberately made manual — the Host
2484
+ * takes the whole binding on each write, and the panel sends the whole binding.
2485
+ */
2486
+ const onBiliBind = React.useCallback(
2487
+ async (data, bvid, auto) => {
2488
+ const key = `bili-bind:${data.repo}`
2489
+ setBusy(key)
2490
+ setNotice(null)
2491
+ setError(null)
2492
+ const result = await postJson('/bilibili-bind', { repo: data.repo, bvid: String(bvid ?? ''), auto: auto !== false }, ACTION_TIMEOUT_MS)
2493
+ setBusy('')
2494
+ if (!result.ok) {
2495
+ setError(result.aborted ? 'timeout' : 'host')
2496
+ return
2497
+ }
2498
+ if (!result.response.ok || result.payload?.ok !== true) {
2499
+ setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
2500
+ await loadBili({ force: true })
2501
+ return
2502
+ }
2503
+ const value = result.payload.value ?? {}
2504
+ if (String(value.bvid ?? '') === '') {
2505
+ setNotice(t('bili.unbound.done', { repo: data.repo }))
2506
+ } else {
2507
+ const baseline = value.baseline
2508
+ ? t('bili.bind.baseline', { tag: String(value.baseline) })
2509
+ : (value.note ? t('bili.bind.baselineUnknown') : '')
2510
+ setNotice(t('bili.bind.done', { bvid: String(value.bvid), baseline }))
2511
+ }
2512
+ await loadBili({ force: true })
2513
+ await loadOverview({ force: true })
2514
+ },
2515
+ [loadBili, loadOverview, setError, t],
2516
+ )
2517
+
2518
+ /**
2519
+ * Ask the Host what the comment would say, without sending it.
2520
+ *
2521
+ * A dry run rather than a client-side composition: the template and the
2522
+ * summary are the Host's, and a page that assembled its own preview could
2523
+ * show one sentence and post another.
2524
+ */
2525
+ const onBiliPreview = React.useCallback(
2526
+ async (data, tag) => {
2527
+ const key = `bili-preview:${data.repo}`
2528
+ setBusy(key)
2529
+ setError(null)
2530
+ const result = await postJson('/bilibili-announce', { repo: data.repo, tag: String(tag ?? ''), dryRun: true }, ACTION_TIMEOUT_MS)
2531
+ setBusy('')
2532
+ if (!result.ok) {
2533
+ setError(result.aborted ? 'timeout' : 'host')
2534
+ return null
2535
+ }
2536
+ if (!result.response.ok || result.payload?.ok !== true) {
2537
+ setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
2538
+ return null
2539
+ }
2540
+ return result.payload.value ?? null
2541
+ },
2542
+ [setError, t],
2543
+ )
2544
+
2545
+ /** Send the previewed comment, exactly as previewed unless it was edited. */
2546
+ const onBiliAnnounce = React.useCallback(
2547
+ async (data, preview, text, force) => {
2548
+ const key = `bili-announce:${data.repo}`
2549
+ setBusy(key)
2550
+ setNotice(null)
2551
+ setError(null)
2552
+ const result = await postJson('/bilibili-announce', {
2553
+ repo: data.repo,
2554
+ tag: String(preview?.tag ?? ''),
2555
+ text: String(text ?? ''),
2556
+ force: force === true,
2557
+ }, ACTION_TIMEOUT_MS)
2558
+ setBusy('')
2559
+ if (!result.ok) {
2560
+ setError(result.aborted ? 'timeout' : 'host')
2561
+ return null
2562
+ }
2563
+ if (!result.response.ok || result.payload?.ok !== true) {
2564
+ setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
2565
+ await loadBili({ force: true })
2566
+ return null
2567
+ }
2568
+ const value = result.payload.value ?? {}
2569
+ /*
2570
+ * Bilibili accepted it; that is not the same as a reader finding it. The Host
2571
+ * now looks the comment back up before answering, so the panel can say which of
2572
+ * the two happened instead of asserting the cheerful one.
2573
+ */
2574
+ setNotice(value.visible === false
2575
+ ? t('bili.announce.unverified', { bvid: String(value.bvid ?? ''), tag: String(value.tag ?? '') })
2576
+ : t('bili.announce.sent', { bvid: String(value.bvid ?? ''), tag: String(value.tag ?? '') }))
2577
+ await loadBili({ force: true })
2578
+ return value
2579
+ },
2580
+ [loadBili, setError, t],
2581
+ )
2582
+
2583
+ /**
2584
+ * The configured list comes from `/status`, the loaded detail from
2585
+ * `/overview`.
2586
+ *
2587
+ * They are not interchangeable. `/status` is cheap and knows the
2588
+ * configuration; `/overview` fans out three GitHub calls per repository and
2589
+ * takes a moment. Deciding "is anything registered?" from the expensive one
2590
+ * meant that any moment before it landed — or any failure — made the panel
2591
+ * announce 还没有登记仓库 to someone who had just registered five, and it hid
2592
+ * the reason at the same time.
2593
+ */
2594
+ const configured = status?.repos ?? []
2595
+ const repos = overview?.repos ?? []
2596
+ const gh = status?.gh ?? {}
2597
+ /**
2598
+ * npm's answers, keyed by the configured repository string.
2599
+ *
2600
+ * The overview and the npm status arrive independently, so a row renders
2601
+ * without its npm chip until the second answer lands — and renders without one
2602
+ * forever if that answer never comes, which is the point.
2603
+ */
2604
+ const npmByRepo = new Map((npm?.repos ?? []).map((entry) => [entry.repo, entry]))
2605
+ const npmAuth = npm?.auth ?? null
2606
+ /**
2607
+ * Bilibili's answers, keyed the same way and arriving independently.
2608
+ *
2609
+ * A row renders without its binding block until this lands, which is the
2610
+ * honest state: the panel has not been told which video this repository
2611
+ * speaks to yet.
2612
+ */
2613
+ const biliByRepo = new Map((bili?.repos ?? []).map((entry) => [entry.repo, entry]))
2614
+ /**
2615
+ * Whether the Bilibili side is blocked on a credential.
2616
+ *
2617
+ * Shown in the panel only when a video is actually bound and the credential
2618
+ * cannot post: a repository nobody bound must not be nagged about a feature
2619
+ * that is not in use, and a bound one that cannot post is exactly the state
2620
+ * where a silent sweep would fail forever.
2621
+ */
2622
+ const biliBlocked = bili !== null
2623
+ && (bili.repos ?? []).some((entry) => entry.binding !== null)
2624
+ && bili.credential?.state !== 'ready'
2625
+ /**
2626
+ * Three states, not a boolean — the Host's `npmAuthState`.
2627
+ *
2628
+ * A granular token is scoped to packages, so `whoami` (a user-level endpoint)
2629
+ * can refuse it while `publish` accepts it. Collapsing that into "not signed in"
2630
+ * would show the four-step guide to someone who just finished step 3.
2631
+ */
2632
+ const npmCredential = npmAuth === null
2633
+ // No answer is not "no credential": rendering the four-step guide here would
2634
+ // claim the machine has nothing to authenticate with, which nobody has said.
2635
+ ? 'unknown'
2636
+ : String(npmAuth.state ?? (npmAuth.loggedIn === true ? 'signed-in' : 'none'))
2637
+ const npmHintText = (() => {
2638
+ if (npmFailure === null) return null
2639
+ const text = t(`npm.hint.${npmFailure}`)
2640
+ return text === `npm.hint.${npmFailure}` ? null : text
2641
+ })()
2642
+ const missing = Array.isArray(gh.missingScopes) ? gh.missingScopes : []
2643
+ const setupNeeded = status !== null && (needsAccountSetup(gh) || configured.length === 0)
2644
+ const draftCount = repos.filter((entry) => entry.draftTag !== null && entry.draftTag !== undefined).length
2645
+ /** Configured repositories whose detail has not arrived yet, shown as placeholders. */
2646
+ const pendingRows = configured.length > repos.length
2647
+ ? configured
2648
+ .filter((entry) => !repos.some((loaded) => loaded.repo === entry.repo))
2649
+ .map((entry) => ({
2650
+ repo: entry.repo,
2651
+ label: entry.label,
2652
+ localPath: entry.localPath,
2653
+ pending: true,
2654
+ version: null,
2655
+ versionKnown: false,
2656
+ expectedTag: null,
2657
+ published: false,
2658
+ publishedTag: null,
2659
+ draftTag: null,
2660
+ latestRun: null,
2661
+ runs: [],
2662
+ releases: [],
2663
+ workflows: [],
2664
+ hasBuildWorkflow: false,
2665
+ hasReleaseWorkflow: false,
2666
+ problems: [],
2667
+ local: { available: false },
2668
+ }))
2669
+ : []
2670
+ // The page and the Host half load independently, so they can disagree.
2671
+ const stale = status !== null && status.protocol !== PROTOCOL
2672
+
2673
+ return h(
2674
+ 'div',
2675
+ { className: 'dsc-root' },
2676
+ h(
2677
+ 'div',
2678
+ { className: 'dsc-bar' },
2679
+ h(
2680
+ 'div',
2681
+ { className: 'dsc-heading' },
2682
+ h('span', { className: 'dsc-title' }, t('title')),
2683
+ h('span', { className: 'dsc-subtitle' }, t('subtitle')),
2684
+ ),
2685
+ h(
2686
+ 'div',
2687
+ { className: 'dsc-meta' },
2688
+ gh.account !== null && gh.account !== undefined ? h(Chip, { state: 'idle' }, `${t('meta.account')} ${gh.account}`) : null,
2689
+ npmAuth !== null && npmAuth.loggedIn === true
2690
+ ? h(Chip, { state: 'idle', title: String(npm.registry ?? '') }, t('npm.account', { account: String(npmAuth.account ?? '') }))
2691
+ : null,
2692
+ overview !== null && typeof overview.fetchedAt === 'string'
2693
+ ? h('span', null, t('meta.updated', { time: stamp(overview.fetchedAt) }), overview.cached === true ? ` (${t('meta.cached')})` : '')
2694
+ : null,
2695
+ h(Btn, { onClick: () => { void loadStatus(); void loadOverview({ force: true }) } }, t('action.refresh')),
2696
+ h(Btn, { kind: managing ? 'primary' : undefined, onClick: () => setManaging((value) => !value) }, managing ? t('action.close') : t('action.manage')),
2697
+ ),
2698
+ ),
2699
+
2700
+ error !== null ? h('div', { className: 'dsc-error', role: 'alert' }, errorText(t, error)) : null,
2701
+ /* The next step for a named npm failure. Rendered only when a hint exists for
2702
+ that code, so an unmapped name can never print a dictionary key on screen. */
2703
+ npmHintText !== null ? h('div', { className: 'dsc-warn', role: 'status' }, npmHintText) : null,
2704
+ notice !== null ? h('div', { className: 'dsc-notice', role: 'status' }, notice) : null,
2705
+ /* The question an update leaves behind. It sits above the list rather than
2706
+ inside the row, because the row is about a repository and this is about
2707
+ the application the user is looking at. */
2708
+ restartPrompt !== null
2709
+ ? h(
2710
+ 'div',
2711
+ { className: 'dsc-setup', role: 'status' },
2712
+ h('span', { className: 'dsc-setup-strong' }, t('restart.ask', { package: restartPrompt.package, tag: restartPrompt.tag })),
2713
+ h(
2714
+ 'div',
2715
+ { className: 'dsc-setup-line' },
2716
+ h(Btn, { kind: 'primary', disabled: busy !== '', onClick: () => { void onRestart() } }, busy === 'restart' ? '…' : t('action.restartNow')),
2717
+ h(Btn, { kind: 'quiet', onClick: () => setRestartPrompt(null) }, t('action.restartLater')),
2718
+ ),
2719
+ )
2720
+ : null,
2721
+ /*
2722
+ * The npm credential, in the operational view.
2723
+ *
2724
+ * The four steps live on their own settings page, because that is where
2725
+ * configuration belongs and where every package's npm state is listed. Here the
2726
+ * panel says which of the two states this machine is in, offers the one control
2727
+ * that resolves it, and points at the guide — so a first-time publisher who
2728
+ * lands on the push button still has a way forward without a terminal, without
2729
+ * four steps of onboarding sitting above a live repository list.
2730
+ */
2731
+ npmCredential === 'none' || npmCredential === 'credential-present'
2732
+ ? h(
2733
+ 'div',
2734
+ { className: 'dsc-setup', role: 'status' },
2735
+ h('span', { className: 'dsc-setup-strong' }, npmCredential === 'none'
2736
+ ? t('npm.notLoggedIn', {
2737
+ command: String(npm?.packageManager?.command ?? 'pnpm'),
2738
+ registry: String(npm?.registry ?? ''),
2739
+ })
2740
+ : t('npm.credentialPresentTitle')),
2741
+ npmCredential === 'none'
2742
+ ? null
2743
+ : h('span', null, t('npm.credentialPresentShort', { registry: String(npm?.registry ?? '') })),
2744
+ /* What `whoami` actually said, because "not signed in" and "this token was
2745
+ revoked" look identical and need different fixes. */
2746
+ npmAuth?.message !== null && npmAuth?.message !== undefined
2747
+ ? h('span', { className: 'dsc-mono' }, String(npmAuth.message))
2748
+ : null,
2749
+ h(
2750
+ 'div',
2751
+ { className: 'dsc-setup-line' },
2752
+ h('input', {
2753
+ className: 'dsc-input',
2754
+ type: 'password',
2755
+ autoComplete: 'off',
2756
+ spellCheck: 'false',
2757
+ placeholder: t('npm.tokenPlaceholder'),
2758
+ 'aria-label': t('npm.tokenPlaceholder'),
2759
+ value: npmToken,
2760
+ onChange: (event) => setNpmToken(String(event?.target?.value ?? '')),
2761
+ }),
2762
+ h(Btn, {
2763
+ kind: 'primary',
2764
+ disabled: busy !== '' || npmToken.trim() === '',
2765
+ onClick: () => { void onNpmLogin() },
2766
+ }, busy === 'npm-login' ? '…' : t('npm.writeToken')),
2767
+ h(Btn, { kind: 'quiet', disabled: busy !== '', onClick: () => { void loadNpm({ force: true }) } }, t('npm.recheck')),
2768
+ ),
2769
+ npmTokenRejected
2770
+ ? h(
2771
+ React.Fragment,
2772
+ null,
2773
+ h('span', { className: 'dsc-warn' }, t('npm.rejectedTitle')),
2774
+ h('span', null, t('npm.rejectedCauses')),
2775
+ )
2776
+ : null,
2777
+ h('span', { className: 'dsc-quiet' }, t('npm.moreInSettings')),
2778
+ )
2779
+ : null,
2780
+ stale
2781
+ ? h('div', { className: 'dsc-warn', role: 'alert' }, h('strong', null, t('stale.title')), ' — ', t('stale.how'))
2782
+ : null,
2783
+ /*
2784
+ * A bound video with no usable credential is the one Bilibili state the
2785
+ * panel has to raise on its own: every subsequent release would silently
2786
+ * fail to announce, and a failure nobody sees is the shape this project
2787
+ * keeps writing tests against.
2788
+ */
2789
+ biliBlocked && !stale
2790
+ ? h(
2791
+ 'div',
2792
+ { className: 'dsc-setup', role: 'status' },
2793
+ h('span', { className: 'dsc-setup-strong' }, t('bili.needCredential')),
2794
+ h('span', { className: 'dsc-quiet' }, t('bili.openSettings')),
2795
+ h(BilibiliSignIn, {
2796
+ t,
2797
+ bili,
2798
+ busy,
2799
+ setBusy,
2800
+ setNotice,
2801
+ setError,
2802
+ onChanged: () => loadBili({ force: true }),
2803
+ }),
2804
+ )
2805
+ : null,
2806
+ /* The one outcome the panel could report as "nothing happened". Say it, and
2807
+ say what to press, instead of leaving it inside a collapsed row. */
2808
+ draftCount > 0
2809
+ ? h(
2810
+ 'div',
2811
+ { className: 'dsc-warn', role: 'status' },
2812
+ h('strong', null, t('drafts.title', { count: String(draftCount) })),
2813
+ ' — ',
2814
+ t('drafts.text'),
2815
+ )
2816
+ : null,
2817
+
2818
+ setupNeeded && !stale
2819
+ ? h(
2820
+ 'div',
2821
+ { className: 'dsc-setup' },
2822
+ h('span', { className: 'dsc-setup-strong' }, gh.available !== true
2823
+ ? t('setup.needGh')
2824
+ : gh.authenticated !== true
2825
+ ? t('setup.needSignIn')
2826
+ : missing.length > 0
2827
+ ? t('setup.needScopes', { scopes: missing.join(', ') })
2828
+ : t('setup.needRepos')),
2829
+ missing.length > 0 ? h('span', null, t('setup.scopeHint')) : null,
2830
+ h(SignIn, { t, status, onChanged: () => { void loadStatus(); void loadOverview({ force: true }) } }),
2831
+ h(
2832
+ 'details',
2833
+ null,
2834
+ h('summary', null, t('setup.advanced')),
2835
+ h(
2836
+ 'div',
2837
+ { className: 'dsc-sub' },
2838
+ h('span', null, t('setup.ghInstall')),
2839
+ h(CopyLine, { t, command: 'winget install --id GitHub.cli' }),
2840
+ h(CopyLine, { t, command: `node "${status?.helper?.configureScript ?? 'scripts/configure.mjs'}" list` }),
2841
+ ),
2842
+ ),
2843
+ )
2844
+ : null,
2845
+
2846
+ managing && !stale ? h(RepoPicker, { t, onChanged: () => { void loadStatus(); void loadOverview({ force: true }) } }) : null,
2847
+
2848
+ repos.length > 0 || pendingRows.length > 0
2849
+ ? h(
2850
+ 'div',
2851
+ { className: 'dsc-list' },
2852
+ repos.map((data) => h(RepoRow, {
2853
+ key: data.repo,
2854
+ t,
2855
+ data,
2856
+ busy,
2857
+ onAction,
2858
+ onBump: () => onBump(data),
2859
+ onCommit: (row, message) => { void onCommit(row, message) },
2860
+ onClone: (row, url) => { void onClone(row, url) },
2861
+ onPublish: (tag) => onPublish(data.repo, tag),
2862
+ onUpdate: () => { void onUpdate(data) },
2863
+ npm: npmByRepo.get(data.repo) ?? null,
2864
+ onNpmPublish,
2865
+ bili: biliByRepo.get(data.repo) ?? null,
2866
+ onBiliBind,
2867
+ onBiliPreview,
2868
+ onBiliAnnounce,
2869
+ logs,
2870
+ onLogs,
2871
+ })),
2872
+ // Registered but not read yet: show the row with an honest "reading"
2873
+ // state instead of rendering nothing and implying an empty list.
2874
+ pendingRows.map((data) => h(RepoRow, {
2875
+ key: data.repo,
2876
+ t,
2877
+ data,
2878
+ busy,
2879
+ onAction,
2880
+ onBump: () => {},
2881
+ onCommit: () => {},
2882
+ onClone: () => {},
2883
+ onPublish: () => {},
2884
+ logs: null,
2885
+ onLogs: async () => {},
2886
+ })),
2887
+ )
2888
+ : null,
2889
+ )
2890
+ }
2891
+
2892
+ /**
2893
+ * The npm credential control, shared by the panel and the settings page.
2894
+ *
2895
+ * One implementation because the two surfaces must not drift: the panel shows the
2896
+ * short state and the settings page shows the four steps, but both write the same
2897
+ * file through the same route and have to report the same outcome. The token lives
2898
+ * here only until the request that writes it answers, and is cleared then.
2899
+ */
2900
+ function useNpmCredential(t, loadNpm, setError, setNotice) {
2901
+ const [token, setToken] = React.useState('')
2902
+ const [busy, setBusy] = React.useState(false)
2903
+ const [rejected, setRejected] = React.useState(false)
2904
+ const login = React.useCallback(async () => {
2905
+ setBusy(true)
2906
+ setRejected(false)
2907
+ setError(null)
2908
+ const result = await postJson('/npm-login', { token }, ACTION_TIMEOUT_MS)
2909
+ setBusy(false)
2910
+ setToken('')
2911
+ if (!result.ok) {
2912
+ setError(result.aborted ? 'timeout' : 'host')
2913
+ return
2914
+ }
2915
+ if (!result.response.ok || result.payload?.ok !== true) {
2916
+ setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
2917
+ setRejected(result.payload?.code === 'token-rejected')
2918
+ await loadNpm({ force: true })
2919
+ return
2920
+ }
2921
+ setRejected(false)
2922
+ const value = result.payload.value ?? {}
2923
+ setNotice(t('npm.tokenWritten', { npmrc: String(value.npmrcPath ?? ''), account: String(value.account ?? '') }))
2924
+ await loadNpm({ force: true })
2925
+ }, [loadNpm, setError, setNotice, t, token])
2926
+ return { token, setToken, busy, rejected, login }
2927
+ }
2928
+
2929
+ /** The token field and its two buttons — the control step ③ is about. */
2930
+ function NpmTokenLine(props) {
2931
+ const { t, credential, busy } = props
2932
+ return h(
2933
+ 'div',
2934
+ { className: 'dsc-setup-line' },
2935
+ h('input', {
2936
+ className: 'dsc-input',
2937
+ type: 'password',
2938
+ autoComplete: 'off',
2939
+ spellCheck: 'false',
2940
+ placeholder: t('npm.tokenPlaceholder'),
2941
+ 'aria-label': t('npm.tokenPlaceholder'),
2942
+ value: credential.token,
2943
+ onChange: (event) => credential.setToken(String(event?.target?.value ?? '')),
2944
+ }),
2945
+ h(Btn, {
2946
+ kind: 'primary',
2947
+ disabled: busy !== '' || credential.token.trim() === '',
2948
+ onClick: () => { void credential.login() },
2949
+ }, credential.busy ? '…' : t('npm.writeToken')),
2950
+ )
2951
+ }
2952
+
2953
+ /** The checklist a refused token needs, shown exactly when it is refused. */
2954
+ function NpmRejectedNote(props) {
2955
+ if (props.rejected !== true) return null
2956
+ return h(
2957
+ React.Fragment,
2958
+ null,
2959
+ h('span', { className: 'dsc-warn' }, props.t('npm.rejectedTitle')),
2960
+ h('span', null, props.t('npm.rejectedCauses')),
2961
+ )
2962
+ }
2963
+
2964
+ /**
2965
+ * The four steps, as a configuration guide rather than a paragraph.
2966
+ *
2967
+ * A step gets a status only where the Host can actually know one. It can see a
2968
+ * token line in `.npmrc`, and it can see an account once `whoami` answers — it
2969
+ * cannot see whether an account was registered or a token was generated, and a
2970
+ * guide that ticked those off would be inventing progress. So ① and ② carry a
2971
+ * tick only after a credential proves they happened, and say "cannot be detected"
2972
+ * until then; ③ is the one step with a live state of its own.
2973
+ */
2974
+ function NpmCredentialGuide(props) {
2975
+ const { t, npm, credential } = props
2976
+ const account = npm?.auth?.account ?? null
2977
+ const registry = String(npm?.registry ?? '')
2978
+ const npmrcPath = String(npm?.auth?.npmrcPath ?? '')
2979
+ const hasToken = npm?.auth?.npmrcHasToken === true
2980
+ const proven = account !== null
2981
+ const status = (kind, text) => h(Chip, { state: kind === 'done' ? 'success' : kind === 'todo' ? 'warn' : 'idle' }, text)
2982
+ return h(
2983
+ React.Fragment,
2984
+ null,
2985
+ h(
2986
+ 'div',
2987
+ { className: 'dsc-setup-line' },
2988
+ h('span', { className: 'dsc-setup-strong' }, t('npm.guide.step1')),
2989
+ proven
2990
+ ? status('done', t('npm.stepAccountDone', { account: String(account) }))
2991
+ : status('unknown', t('npm.stepUnknown')),
2992
+ ),
2993
+ h(
2994
+ 'div',
2995
+ { className: 'dsc-setup-line' },
2996
+ h(Btn, { onClick: () => globalThis.open(NPM_SIGNUP_URL, '_blank', 'noopener,noreferrer') }, t('npm.guide.signup')),
2997
+ ),
2998
+ h('span', null, t('npm.guide.emailNote')),
2999
+
3000
+ h(
3001
+ 'div',
3002
+ { className: 'dsc-setup-line' },
3003
+ h('span', { className: 'dsc-setup-strong' }, t('npm.guide.step2')),
3004
+ proven ? status('done', t('npm.stepTokenDone')) : status('unknown', t('npm.stepUnknown')),
3005
+ ),
3006
+ h(
3007
+ 'div',
3008
+ { className: 'dsc-setup-line' },
3009
+ h(Btn, { onClick: () => globalThis.open(NPM_TOKENS_URL, '_blank', 'noopener,noreferrer') }, t('npm.guide.tokens')),
3010
+ h(Btn, { kind: 'quiet', onClick: () => globalThis.open(NPM_TOKEN_DOCS_URL, '_blank', 'noopener,noreferrer') }, t('npm.guide.docs')),
3011
+ ),
3012
+ h('span', null, t('npm.guide.tokenType')),
3013
+ h('span', null, t('npm.guide.tokenScope')),
3014
+ h('span', null, t('npm.guide.tokenExpiry')),
3015
+ h('span', null, t('npm.guide.token2fa')),
3016
+ /* The step that is not on this machine: npmjs.com sits behind a bot challenge
3017
+ that answers whole-site 403s to some networks, and the token does not care
3018
+ where it was minted. Saying so here is the difference between a five-minute
3019
+ workaround and an afternoon spent on the wrong problem. */
3020
+ h('span', { className: 'dsc-quiet' }, t('npm.guide.blocked')),
3021
+
3022
+ h(
3023
+ 'div',
3024
+ { className: 'dsc-setup-line' },
3025
+ h('span', { className: 'dsc-setup-strong' }, t('npm.guide.step3')),
3026
+ hasToken ? status('done', t('npm.stepWrittenDone', { npmrc: npmrcPath })) : status('todo', t('npm.stepTodo')),
3027
+ ),
3028
+ h(NpmTokenLine, { t, credential, busy: props.busy }),
3029
+ h('span', null, t('npm.guide.step3Note', { npmrc: npmrcPath })),
3030
+ h(NpmRejectedNote, { t, rejected: credential.rejected }),
3031
+
3032
+ h(
3033
+ 'div',
3034
+ { className: 'dsc-setup-line' },
3035
+ h('span', { className: 'dsc-setup-strong' }, t('npm.guide.step4')),
3036
+ status('unknown', t('npm.stepUnknown')),
3037
+ ),
3038
+ h('span', null, t('npm.guide.step4Note')),
3039
+
3040
+ /* What the package manager said, verbatim: "not signed in" and "that token was
3041
+ revoked" are the same blank state and different problems. */
3042
+ npm?.auth?.message !== null && npm?.auth?.message !== undefined
3043
+ ? h('span', { className: 'dsc-mono' }, String(npm.auth.message))
3044
+ : null,
3045
+ h('span', { className: 'dsc-quiet' }, t('npm.ciHint')),
3046
+ )
3047
+ }
3048
+
3049
+ /**
3050
+ * The Settings page for npm credentials: the guide, and every package's state.
3051
+ *
3052
+ * The panel answers "which repository needs attention"; this page answers "can
3053
+ * this machine publish at all, and which of my packages are already up there" —
3054
+ * which is configuration, not operation, and belongs where the GitHub account
3055
+ * page already lives.
3056
+ */
3057
+ function NpmCredentialsPage(props) {
3058
+ const t = typeof props?.t === 'function' ? props.t : (key) => key
3059
+ const { npm, error, setError, loadNpm } = useConsoleState()
3060
+ const [busy, setBusy] = React.useState('')
3061
+ const [notice, setNotice] = React.useState(null)
3062
+ const credential = useNpmCredential(t, loadNpm, setError, setNotice)
3063
+
3064
+ React.useEffect(() => {
3065
+ void loadNpm({ force: true })
3066
+ }, [loadNpm])
3067
+
3068
+ const auth = npm?.auth ?? null
3069
+ const state = auth === null ? 'unknown' : String(auth.state ?? (auth.loggedIn === true ? 'signed-in' : 'none'))
3070
+
3071
+ const recheck = React.useCallback(async () => {
3072
+ setBusy('recheck')
3073
+ await loadNpm({ force: true })
3074
+ setBusy('')
3075
+ }, [loadNpm])
3076
+
3077
+ const packageState = (entry) => {
3078
+ if (entry.state === 'published') return { kind: 'success', text: t('npm.package.published') }
3079
+ if (entry.state === 'unregistered') return { kind: 'warn', text: t('npm.package.unregistered') }
3080
+ if (entry.state === 'unpublished') return { kind: 'warn', text: t('npm.package.unpublished') }
3081
+ return { kind: 'idle', text: t('npm.package.unknown') }
3082
+ }
3083
+ const blockerText = (entry) => {
3084
+ const first = Array.isArray(entry.blockers) ? entry.blockers[0] : null
3085
+ if (first === null) return null
3086
+ const key = `npm.blocked.${first}`
3087
+ const text = t(key)
3088
+ return text === key ? null : text
3089
+ }
3090
+
3091
+ return h(
3092
+ 'div',
3093
+ { className: 'dsc-root' },
3094
+ h(
3095
+ 'div',
3096
+ { className: 'dsc-bar' },
3097
+ h(
3098
+ 'div',
3099
+ { className: 'dsc-heading' },
3100
+ h('span', { className: 'dsc-title' }, t('settings.npmTitle')),
3101
+ h('span', { className: 'dsc-subtitle' }, t('settings.npmSubtitle')),
3102
+ ),
3103
+ h(Btn, { disabled: busy !== '', onClick: () => { void recheck() } }, busy === 'recheck' ? '…' : t('npm.packages.recheck')),
3104
+ ),
3105
+
3106
+ error !== null ? h('div', { className: 'dsc-error', role: 'alert' }, errorText(t, error)) : null,
3107
+ notice !== null ? h('div', { className: 'dsc-notice', role: 'status' }, notice) : null,
3108
+
3109
+ npm === null
3110
+ ? h('div', { className: 'dsc-setup' }, h('span', { className: 'dsc-setup-strong' }, t('npm.status.unknown')), h('span', null, t('npm.status.unknownHint')))
3111
+ : h(
3112
+ React.Fragment,
3113
+ null,
3114
+ h(
3115
+ 'div',
3116
+ { className: 'dsc-setup-line' },
3117
+ h('span', { className: 'dsc-setup-strong' }, t('npm.status.title')),
3118
+ state === 'signed-in'
3119
+ ? h(Chip, { state: 'success' }, t('npm.status.signedIn', { account: String(auth.account ?? '?') }))
3120
+ : state === 'credential-present'
3121
+ ? h(Chip, { state: 'idle' }, t('npm.status.credentialPresent'))
3122
+ : h(Chip, { state: 'warn' }, t('npm.status.none')),
3123
+ h('span', { className: 'dsc-mono' }, t('npm.registry', { registry: String(npm.registry ?? '') })),
3124
+ ),
3125
+ /* "Signed in" and "a credential that whoami will not confirm" are the same
3126
+ blank state on screen and different situations, so this page spells the
3127
+ second one out instead of collapsing it into the first. */
3128
+ state === 'credential-present'
3129
+ ? h('span', null, t('npm.credentialPresent', { registry: String(npm.registry ?? ''), npmrc: String(auth.npmrcPath ?? '') }))
3130
+ : null,
3131
+ /* The guide is the page's reason to exist, so it is always here — the
3132
+ panel is where it would be in the way. */
3133
+ h(
3134
+ 'div',
3135
+ { className: 'dsc-setup' },
3136
+ h('span', { className: 'dsc-setup-strong' }, t('settings.npmGuideTitle')),
3137
+ h(NpmCredentialGuide, { t, npm, credential, busy }),
3138
+ ),
3139
+ h(
3140
+ 'div',
3141
+ { className: 'dsc-sub' },
3142
+ h('span', { className: 'dsc-setup-strong' }, t('npm.packages.title')),
3143
+ (npm.repos ?? []).length === 0
3144
+ ? h('span', null, t('npm.packages.empty'))
3145
+ : (npm.repos ?? []).map((entry) =>
3146
+ h(
3147
+ 'div',
3148
+ { className: 'dsc-line', key: entry.repo },
3149
+ h('span', { className: 'dsc-name', style: { minWidth: '0', fontWeight: '500' } }, String(entry.packageName ?? entry.repo)),
3150
+ h(Chip, { state: packageState(entry).kind }, packageState(entry).text),
3151
+ h('span', { className: 'dsc-grow' }, `${t('npm.package.local', { version: String(entry.version ?? '?') })}${entry.latest === null || entry.latest === undefined ? '' : ` · ${t('npm.package.latest', { latest: String(entry.latest) })}`}`),
3152
+ blockerText(entry) === null ? null : h('span', { className: 'dsc-quiet' }, blockerText(entry)),
3153
+ entry.pageUrl ? h(Btn, { kind: 'quiet', onClick: () => globalThis.open(String(entry.pageUrl), '_blank', 'noopener,noreferrer') }, t('action.open')) : null,
3154
+ ),
3155
+ ),
3156
+ ),
3157
+ ),
3158
+ )
3159
+ }
3160
+
3161
+ /**
3162
+ * The Bilibili credential, in one component used in two places.
3163
+ *
3164
+ * The panel shows it only when it is the obstacle (a video is bound and the
3165
+ * credential cannot post); the settings page shows it always, because that is
3166
+ * where a credential is configured. One component, so the two cannot drift
3167
+ * into two different explanations of the same `-101`.
3168
+ */
3169
+ function BilibiliSignIn(props) {
3170
+ const { t, bili, busy, setBusy, setNotice, setError, onChanged } = props
3171
+ const credential = bili?.credential ?? null
3172
+ const login = bili?.login ?? { state: 'idle' }
3173
+ const [sessdata, setSessdata] = React.useState('')
3174
+ const [csrf, setCsrf] = React.useState('')
3175
+ const [confirmingSignOut, setConfirmingSignOut] = React.useState(false)
3176
+
3177
+ /** One request, with the two failures this page has to tell apart. */
3178
+ const post = React.useCallback(async (key, path, body) => {
3179
+ setBusy(key)
3180
+ setError(null)
3181
+ const result = await postJson(path, body, ACTION_TIMEOUT_MS)
3182
+ setBusy('')
3183
+ if (!result.ok) {
3184
+ setError(result.aborted ? 'timeout' : 'host')
3185
+ return null
3186
+ }
3187
+ if (!result.response.ok || result.payload?.ok !== true) {
3188
+ setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
3189
+ await onChanged()
3190
+ return null
3191
+ }
3192
+ return result.payload.value ?? {}
3193
+ }, [onChanged, setBusy, setError, t])
3194
+
3195
+ const start = React.useCallback(async () => {
3196
+ const value = await post('bili-login', '/bilibili-login-start', {})
3197
+ if (value !== null) await onChanged()
3198
+ }, [onChanged, post])
3199
+
3200
+ const cancel = React.useCallback(async () => {
3201
+ await post('bili-cancel', '/bilibili-login-cancel', {})
3202
+ await onChanged()
3203
+ }, [onChanged, post])
3204
+
3205
+ const save = React.useCallback(async () => {
3206
+ const value = await post('bili-paste', '/bilibili-credential', { sessdata, bili_jct: csrf })
3207
+ setSessdata('')
3208
+ setCsrf('')
3209
+ if (value === null) return
3210
+ setNotice(t('bili.paste.saved', { uname: String(value.account?.uname ?? '') }))
3211
+ await onChanged()
3212
+ }, [csrf, onChanged, post, sessdata, setNotice, t])
3213
+
3214
+ const signOut = React.useCallback(async () => {
3215
+ if (confirmingSignOut !== true) {
3216
+ setConfirmingSignOut(true)
3217
+ return
3218
+ }
3219
+ setConfirmingSignOut(false)
3220
+ const value = await post('bili-signout', '/bilibili-logout', {})
3221
+ if (value === null) return
3222
+ /* An external credential keeps working after this one is deleted, and
3223
+ reporting a sign-out that did not happen would be a lie the next sweep
3224
+ exposes anyway. */
3225
+ setNotice(value.stillAvailable ? t('bili.signOut.fallback', { path: String(value.stillAvailable.path ?? '') }) : t('bili.signOut.done'))
3226
+ await onChanged()
3227
+ }, [confirmingSignOut, onChanged, post, setNotice, t])
3228
+
3229
+ const running = login.state === 'waiting' || login.state === 'scanned'
3230
+ /* Encoded once per URL: the panel re-renders on every poll, and building the
3231
+ matrix is the only expensive thing on this screen. */
3232
+ const qr = React.useMemo(
3233
+ () => (typeof login.url === 'string' && login.url !== '' ? qrFor(login.url) : null),
3234
+ [login.url],
3235
+ )
3236
+ React.useEffect(() => {
3237
+ if (!running) return undefined
3238
+ let cancelled = false
3239
+ const tick = async () => {
3240
+ const result = await postJson('/bilibili-login-poll', {}, STATUS_TIMEOUT_MS)
3241
+ if (cancelled) return
3242
+ const value = result.payload?.value ?? {}
3243
+ if (result.ok && result.response.ok && result.payload?.ok === true) {
3244
+ if (value.state === 'succeeded') {
3245
+ setNotice(value.account?.uname
3246
+ ? t('bili.signIn.done', { uname: String(value.account.uname) })
3247
+ : t('bili.signIn.doneNoName'))
3248
+ }
3249
+ if (value.state === 'expired') setError(t('bili.signIn.expired'))
3250
+ } else if (result.ok && !result.aborted) {
3251
+ setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
3252
+ }
3253
+ await onChanged()
3254
+ }
3255
+ const timer = globalThis.setInterval(() => { void tick() }, AUTH_POLL_MS)
3256
+ return () => {
3257
+ cancelled = true
3258
+ globalThis.clearInterval(timer)
3259
+ }
3260
+ }, [onChanged, running, setError, setNotice, t])
3261
+
3262
+ if (credential === null) return null
3263
+ const state = String(credential.state ?? 'none')
3264
+ /**
3265
+ * A credential that answered the account endpoint. Everything below that exists
3266
+ * to GET one — the scan prompt, the paste field and their hints — is pointless
3267
+ * once there is one, and showing it anyway makes the page argue with its own
3268
+ * status line. The only action left is to stop being signed in.
3269
+ */
3270
+ const ready = state === 'ready'
3271
+ const stateText = (() => {
3272
+ if (state === 'ready') {
3273
+ return credential.account?.uname
3274
+ ? t('bili.credential.ready', { uname: String(credential.account.uname) })
3275
+ : t('bili.credential.ready', { uname: '?' })
3276
+ }
3277
+ const key = `bili.credential.${state}`
3278
+ const text = t(key)
3279
+ return text === key ? t('bili.credential.unverified') : text
3280
+ })()
3281
+
3282
+ return h(
3283
+ React.Fragment,
3284
+ null,
3285
+ h(
3286
+ 'div',
3287
+ { className: 'dsc-setup', role: 'status' },
3288
+ h(
3289
+ 'div',
3290
+ { className: 'dsc-setup-line' },
3291
+ h('span', { className: 'dsc-setup-strong' }, t('bili.credential.title')),
3292
+ h(Chip, { state: state === 'ready' ? 'success' : state === 'none' ? 'warn' : 'idle' }, stateText),
3293
+ credential.path
3294
+ ? h('span', { className: 'dsc-mono', title: String(credential.path) }, t('bili.credential.source', { path: String(credential.path) }))
3295
+ : null,
3296
+ ),
3297
+ /* Why it cannot be used, in Bilibili's own terms. A bare "-101" sends the
3298
+ reader to re-login in the wrong place when the credential is an APP one. */
3299
+ state !== 'ready' && credential.message ? h('span', { className: 'dsc-warn' }, String(credential.message)) : null,
3300
+ state !== 'ready' ? h('span', null, t('bili.credential.none.hint')) : null,
3301
+ credential.source === 'configured'
3302
+ ? h('span', { className: 'dsc-quiet' }, t('bili.credential.external', { path: String(credential.configuredPath ?? '') }))
3303
+ : null,
3304
+
3305
+ /*
3306
+ * The ways to OBTAIN a credential are for not having one, and a signed-in page
3307
+ * that still says 登录 B 站 — directly under 已登录:<name> — is the panel
3308
+ * contradicting itself. It did exactly that: this block was gated on "is a
3309
+ * sign-in running", never on the credential, so a fresh sign-in left the
3310
+ * invitation to sign in again on screen as if nothing had happened.
3311
+ *
3312
+ * `running` still wins, because a sign-in that is mid-flight should show its
3313
+ * code even in the instant before the credential lands.
3314
+ */
3315
+ ready
3316
+ ? null
3317
+ : running
3318
+ ? h(
3319
+ React.Fragment,
3320
+ null,
3321
+ /*
3322
+ * The code itself. The sign-in hands back a URL whose whole purpose is
3323
+ * to be SCANNED, and drawing it here is what makes "scan it with the
3324
+ * phone" true rather than merely stated.
3325
+ */
3326
+ qr === null
3327
+ ? h('span', { className: 'dsc-quiet' }, login.url ? t('bili.qrTooLong') : null)
3328
+ : h(
3329
+ 'div',
3330
+ { className: 'dsc-scan' },
3331
+ h(QrCode, { code: qr, label: t('bili.qrLabel') }),
3332
+ h('span', { className: 'dsc-quiet' }, t('bili.qrHint')),
3333
+ ),
3334
+ h(
3335
+ 'div',
3336
+ { className: 'dsc-setup-line' },
3337
+ h('span', { className: 'dsc-grow' }, login.state === 'scanned' ? t('bili.waitingScanned') : t('bili.waiting')),
3338
+ /*
3339
+ * No "open the sign-in page" button. That URL is the H5 page for a
3340
+ * PHONE, and opening it in a desktop browser offers the Bilibili app's
3341
+ * APK instead of a sign-in — a download, not a login. The code above is
3342
+ * the way through; the paste route below is the fallback.
3343
+ */
3344
+ h(Btn, { kind: 'quiet', disabled: busy !== '', onClick: () => { void cancel() } }, t('bili.cancelSignIn')),
3345
+ ),
3346
+ )
3347
+ : h(
3348
+ 'div',
3349
+ { className: 'dsc-setup-line' },
3350
+ h(Btn, { kind: 'primary', disabled: busy !== '', onClick: () => { void start() } }, busy === 'bili-login' ? '…' : t('bili.signIn')),
3351
+ h('span', { className: 'dsc-quiet' }, t('bili.signIn.hint')),
3352
+ ),
3353
+ /* Signed in: the one action left is to stop being signed in, which is also how
3354
+ someone switches accounts. */
3355
+ ready
3356
+ ? h(
3357
+ 'div',
3358
+ { className: 'dsc-setup-line' },
3359
+ h(Btn, { kind: 'quiet', disabled: busy !== '', onClick: () => { void signOut() } }, confirmingSignOut ? t('bili.signOut.confirm') : t('bili.signOut')),
3360
+ )
3361
+ : null,
3362
+
3363
+ ready
3364
+ ? null
3365
+ : h(
3366
+ React.Fragment,
3367
+ null,
3368
+ h('span', { className: 'dsc-setup-strong' }, t('bili.paste.title')),
3369
+ h('span', { className: 'dsc-quiet' }, t('bili.paste.hint')),
3370
+ ),
3371
+ ready
3372
+ ? null
3373
+ : h(
3374
+ 'div',
3375
+ { className: 'dsc-setup-line' },
3376
+ h('input', {
3377
+ className: 'dsc-input',
3378
+ type: 'password',
3379
+ autoComplete: 'off',
3380
+ spellCheck: 'false',
3381
+ placeholder: t('bili.paste.sessdata'),
3382
+ 'aria-label': t('bili.paste.sessdata'),
3383
+ value: sessdata,
3384
+ onChange: (event) => setSessdata(String(event?.target?.value ?? '')),
3385
+ }),
3386
+ h('input', {
3387
+ className: 'dsc-input',
3388
+ type: 'password',
3389
+ autoComplete: 'off',
3390
+ spellCheck: 'false',
3391
+ placeholder: t('bili.paste.csrf'),
3392
+ 'aria-label': t('bili.paste.csrf'),
3393
+ value: csrf,
3394
+ onChange: (event) => setCsrf(String(event?.target?.value ?? '')),
3395
+ }),
3396
+ h(Btn, {
3397
+ kind: 'primary',
3398
+ disabled: busy !== '' || sessdata.trim() === '' || csrf.trim() === '',
3399
+ onClick: () => { void save() },
3400
+ }, busy === 'bili-paste' ? '…' : t('bili.paste.save')),
3401
+ ),
3402
+ ),
3403
+ )
3404
+ }
3405
+
3406
+ /**
3407
+ * Where a working tree would go, and the button that fetches one.
3408
+ *
3409
+ * A repository can be registered without being cloned, and then every action this
3410
+ * console offers is unavailable for it — 提交 has nothing to commit, 构建 and 发布
3411
+ * act on what is on GitHub rather than on this disk, and the whole local column
3412
+ * reads as broken. So the panel answers the one question that unblocks all of that:
3413
+ * where is it, and shall I fetch it.
3414
+ *
3415
+ * The address comes pre-filled from the Host, which derives it from the repository
3416
+ * name (`cloneUrl`) — being asked to paste the clone URL of a repository the Host
3417
+ * can already name is exactly the kind of step this console exists to remove. It
3418
+ * stays editable, because the derivation is a convenience and not a fact: a fork, a
3419
+ * private remote or a second remote is a real thing.
3420
+ */
3421
+ function CloneCheckout(props) {
3422
+ const { t, data, local, busy, onClone } = props
3423
+ const [url, setUrl] = React.useState(String(data.cloneUrl ?? ''))
3424
+ React.useEffect(() => {
3425
+ setUrl(String(data.cloneUrl ?? ''))
3426
+ }, [data.cloneUrl])
3427
+
3428
+ const root = String(data.checkoutRoot ?? '')
3429
+ const name = String(data.repo ?? '').split('/').pop()
3430
+ const separator = root.endsWith('/') || root.endsWith('\\') ? '' : '\\'
3431
+ const target = root === '' ? '' : `${root}${separator}${name}`
3432
+ const why = data.localPath === ''
3433
+ ? t('clone.why.none')
3434
+ : t('clone.why.broken', { path: String(data.localPath), reason: String(local?.reason ?? '') })
3435
+
3436
+ return h(
3437
+ 'div',
3438
+ { className: 'dsc-sub' },
3439
+ h('span', { className: 'dsc-setup-strong' }, t('clone.title')),
3440
+ h('span', { className: 'dsc-quiet' }, why),
3441
+ h(
3442
+ 'div',
3443
+ { className: 'dsc-line' },
3444
+ h('input', {
3445
+ className: 'dsc-input',
3446
+ type: 'text',
3447
+ spellCheck: 'false',
3448
+ placeholder: t('clone.urlPlaceholder'),
3449
+ 'aria-label': t('clone.urlPlaceholder'),
3450
+ value: url,
3451
+ onChange: (event) => setUrl(String(event?.target?.value ?? '').trim()),
3452
+ }),
3453
+ h(Btn, {
3454
+ kind: 'primary',
3455
+ disabled: busy !== '' || url === '' || root === '',
3456
+ onClick: () => onClone(data, url),
3457
+ }, busy === `clone:${data.repo}` ? '…' : t('clone.action')),
3458
+ ),
3459
+ /* Set where it will land, or say what is missing to be able to answer that at
3460
+ all. A clone that silently picks a directory is a clone someone has to go and
3461
+ find afterwards. */
3462
+ root === ''
3463
+ ? h('span', { className: 'dsc-warn' }, t('clone.noRoot'))
3464
+ : h('span', { className: 'dsc-quiet dsc-mono' }, t('clone.target', { path: target })),
3465
+ )
3466
+ }
3467
+
3468
+ /**
3469
+ * One repository's video binding, inside its row.
3470
+ *
3471
+ * The binding is edited here because this is where the question is asked: the
3472
+ * row already knows what was released and whether the profile has it, and "who
3473
+ * reads about it" is the same repository's business. The announcement itself is
3474
+ * always previewed first — the comment is public, and the sentence shown is the
3475
+ * sentence the Host would send, composed on the Host.
3476
+ */
3477
+ function BiliBinding(props) {
3478
+ const { t, data, bili, busy, onBind, onPreview, onAnnounce } = props
3479
+ const binding = bili?.binding ?? null
3480
+ const [draft, setDraft] = React.useState(binding === null ? '' : String(binding.bvid))
3481
+ const [preview, setPreview] = React.useState(null)
3482
+
3483
+ /* The Host is the source of truth for the binding: after a save the field
3484
+ shows what was actually stored, not what was typed. */
3485
+ React.useEffect(() => {
3486
+ setDraft(binding === null ? '' : String(binding.bvid))
3487
+ }, [binding === null ? '' : String(binding.bvid)])
3488
+
3489
+ const announced = Array.isArray(bili?.announced) ? bili.announced : []
3490
+ const last = announced.length > 0 ? announced[announced.length - 1] : null
3491
+ const failures = Array.isArray(bili?.failures) ? bili.failures : []
3492
+ const failure = failures.length > 0 ? failures[failures.length - 1] : null
3493
+ /**
3494
+ * Why the Host would not post this one, in the reader's language where the
3495
+ * dictionary knows the state, and in the Host's own words otherwise. The Host
3496
+ * composes its refusals in one language; a known state deserves the other.
3497
+ */
3498
+ const previewReason = (value) => {
3499
+ const key = `bili.state.${String(value?.state ?? '')}`
3500
+ const text = t(key, { count: String(value?.attempts ?? '') })
3501
+ return text === key ? String(value?.message ?? '') : text
3502
+ }
3503
+ /**
3504
+ * What would be announced if the sweep ran now, as far as the panel can tell.
3505
+ * The decision itself is the Host's; this is a chip, and it stays silent when
3506
+ * the baseline is unknown rather than guessing at the one case that matters.
3507
+ */
3508
+ const pendingTag = (() => {
3509
+ if (binding === null) return null
3510
+ const tag = typeof data.publishedTag === 'string' && data.publishedTag !== '' ? data.publishedTag : null
3511
+ if (tag === null) return null
3512
+ if (announced.some((entry) => entry.tag === tag)) return null
3513
+ if (bili?.baseline === null || bili?.baseline === undefined) return null
3514
+ if (bili.baseline.tag === null) return null
3515
+ if (bili.baseline.tag === tag) return null
3516
+ return tag
3517
+ })()
3518
+
3519
+ return h(
3520
+ 'div',
3521
+ { className: 'dsc-sub' },
3522
+ h('span', null, t('bili.bind.title')),
3523
+ h(
3524
+ 'div',
3525
+ { className: 'dsc-line' },
3526
+ /*
3527
+ * Bound is a state, not a draft. Editing a live binding in place would change
3528
+ * which video a repository announces under, with the announcement history
3529
+ * still pointing at the old one — so the field is only editable while there is
3530
+ * nothing bound, and once there is, the single action left is 解绑. A wrong BV
3531
+ * is therefore fixed by untying it first, which is also the only way the ledger
3532
+ * and the binding stay consistent with each other.
3533
+ */
3534
+ binding === null
3535
+ ? h('input', {
3536
+ className: 'dsc-input',
3537
+ type: 'text',
3538
+ spellCheck: 'false',
3539
+ placeholder: t('bili.bind.placeholder'),
3540
+ 'aria-label': t('bili.bind.placeholder'),
3541
+ value: draft,
3542
+ onChange: (event) => setDraft(String(event?.target?.value ?? '').trim()),
3543
+ })
3544
+ : h('span', { className: 'dsc-mono dsc-bound' }, String(binding.bvid)),
3545
+ binding === null
3546
+ ? h(Btn, {
3547
+ kind: 'primary',
3548
+ disabled: busy !== '' || draft.trim() === '',
3549
+ onClick: () => { void onBind(data, draft.trim(), true) },
3550
+ }, busy === `bili-bind:${data.repo}` ? '…' : t('bili.bind.save'))
3551
+ : h(Btn, { kind: 'quiet', disabled: busy !== '', onClick: () => { void onBind(data, '', true) } }, t('bili.bind.unbind')),
3552
+ binding === null ? null : h('span', { className: 'dsc-quiet' }, t('bili.bind.locked')),
3553
+ h('span', { className: 'dsc-grow' }, binding === null
3554
+ ? t('bili.repo.none')
3555
+ : bili?.video?.ok === true
3556
+ ? `${String(bili.video.title ?? '')}${bili.video.owner ? ` · ${String(bili.video.owner)}` : ''}`
3557
+ : t('bili.video.failed', { reason: String(bili?.video?.message ?? '?') })),
3558
+ ),
3559
+ binding !== null
3560
+ ? h(
3561
+ 'div',
3562
+ { className: 'dsc-line' },
3563
+ h(Chip, { state: binding.auto === false ? 'idle' : 'success' }, binding.auto === false ? t('bili.auto.off') : t('bili.auto.on')),
3564
+ h(Btn, { kind: 'quiet', disabled: busy !== '', onClick: () => { void onBind(data, binding.bvid, binding.auto === false) } }, t('bili.auto.toggle')),
3565
+ h('span', { className: 'dsc-grow' }, last === null
3566
+ ? t('bili.announced.none')
3567
+ : `${t('bili.chip.announced', { tag: String(last.tag) })}${last.url ? '' : ''}`),
3568
+ pendingTag !== null ? h(Chip, { state: 'warn' }, t('bili.chip.pending', { tag: pendingTag })) : null,
3569
+ /* The comment exists as far as Bilibili is concerned, and not as far as a
3570
+ reader is — a distinction the ledger now records and the panel used to
3571
+ hide behind "已播报". */
3572
+ last !== null && last.visible === false
3573
+ ? h(Chip, { state: 'warn', title: t('bili.visible.hint', { code: String(last.visibleCode ?? '?') }) }, t('bili.visible.false'))
3574
+ : null,
3575
+ last !== null && typeof last.url === 'string' && last.url !== ''
3576
+ ? h(Btn, { kind: 'quiet', onClick: () => globalThis.open(String(last.url), '_blank', 'noopener,noreferrer') }, t('action.open'))
3577
+ : null,
3578
+ )
3579
+ : null,
3580
+ failure !== null
3581
+ ? h('span', { className: 'dsc-warn' }, t('bili.failure.last', { message: String(failure.message ?? failure.failure ?? '') }))
3582
+ : null,
3583
+ binding !== null
3584
+ ? h(
3585
+ 'div',
3586
+ { className: 'dsc-line' },
3587
+ h(Btn, {
3588
+ kind: 'primary',
3589
+ disabled: busy !== '',
3590
+ onClick: () => {
3591
+ void (async () => {
3592
+ const value = await onPreview(data)
3593
+ if (value !== null) setPreview(value)
3594
+ })()
3595
+ },
3596
+ }, busy === `bili-preview:${data.repo}` ? '…' : (announced.length > 0 ? t('bili.announce.again') : t('bili.announce'))),
3597
+ )
3598
+ : null,
3599
+ preview !== null
3600
+ ? h(
3601
+ React.Fragment,
3602
+ null,
3603
+ h('span', { className: 'dsc-setup-strong' }, t('bili.announce.preview')),
3604
+ preview.state !== 'ready'
3605
+ ? h('span', { className: 'dsc-warn' }, previewReason(preview))
3606
+ : null,
3607
+ h('pre', { className: 'dsc-logs' }, String(preview.text ?? '')),
3608
+ Array.isArray(preview.unknown) && preview.unknown.length > 0
3609
+ ? h('span', { className: 'dsc-warn' }, t('bili.unknownPlaceholder', { names: preview.unknown.join(', ') }))
3610
+ : null,
3611
+ h(
3612
+ 'div',
3613
+ { className: 'dsc-line' },
3614
+ preview.state === 'ready'
3615
+ ? h(Btn, {
3616
+ kind: 'danger',
3617
+ disabled: busy !== '',
3618
+ onClick: () => {
3619
+ setPreview(null)
3620
+ void onAnnounce(data, preview, String(preview.text ?? ''), false)
3621
+ },
3622
+ }, t('bili.announce.confirm'))
3623
+ : h(Btn, {
3624
+ kind: 'danger',
3625
+ disabled: busy !== '',
3626
+ onClick: () => {
3627
+ setPreview(null)
3628
+ void onAnnounce(data, preview, String(preview.text ?? ''), true)
3629
+ },
3630
+ }, t('bili.announce.force')),
3631
+ h(Btn, { kind: 'quiet', onClick: () => setPreview(null) }, t('bili.announce.cancel')),
3632
+ ),
3633
+ )
3634
+ : null,
3635
+ )
3636
+ }
3637
+
3638
+ /**
3639
+ * The Settings page for Bilibili update notes.
3640
+ *
3641
+ * The panel answers "what would be posted, and to which video"; this page
3642
+ * answers "can this machine post at all, and what has it already said" — which
3643
+ * is configuration plus a public record, and belongs beside the two credential
3644
+ * pages that already live here.
3645
+ */
3646
+ /* ------------------------------------------------------------- QR code -- */
3647
+ /*
3648
+ * A QR encoder, because the Bilibili sign-in hands over a string a PHONE has to
3649
+ * SCAN, and this half has nothing to draw one with: the client builtins are React,
3650
+ * `ctx`, `host.call`, `styles` and `console`, and this plugin has no dependencies by
3651
+ * design. The two ways out are both worse — an external QR image service would hand
3652
+ * a live sign-in URL to a third party, and adding a package would put a dependency
3653
+ * tree inside a plugin whose install story is "one tgz, no tree".
3654
+ *
3655
+ * Byte mode, error correction M, versions 1-10 (up to 213 bytes): enough for the
3656
+ * ~130-byte login URL with room to spare, and small enough to be checked rather than
3657
+ * trusted. Every table here was read out of a reference implementation instead of
3658
+ * recalled, and `tests/client-render.mjs` compares the modules this produces against
3659
+ * a matrix that reference produced, frozen into the test.
3660
+ *
3661
+ * The encoder is deliberately quiet about failure: a string that does not fit comes
3662
+ * back as `null` and the caller shows the link instead of a wrong code.
3663
+ */
3664
+
3665
+ /** Total codewords (data + EC) per version, 1-10. */
3666
+ const QR_CODEWORDS = [26, 44, 70, 100, 134, 172, 196, 242, 292, 346]
3667
+ /** EC codewords for the whole symbol, level M. */
3668
+ const QR_EC_M = [10, 16, 26, 36, 48, 64, 72, 88, 110, 130]
3669
+ /** Reed-Solomon blocks, level M. */
3670
+ const QR_BLOCKS_M = [1, 1, 1, 2, 2, 4, 4, 4, 5, 5]
3671
+
3672
+ const QR_GF_EXP = new Uint8Array(512)
3673
+ const QR_GF_LOG = new Uint8Array(256)
3674
+ {
3675
+ let value = 1
3676
+ for (let i = 0; i < 255; i += 1) {
3677
+ QR_GF_EXP[i] = value
3678
+ QR_GF_LOG[value] = i
3679
+ value <<= 1
3680
+ if ((value & 0x100) !== 0) value ^= 0x11d
3681
+ }
3682
+ for (let i = 255; i < 512; i += 1) QR_GF_EXP[i] = QR_GF_EXP[i - 255]
3683
+ }
3684
+
3685
+ const qrMultiply = (a, b) => (a === 0 || b === 0 ? 0 : QR_GF_EXP[QR_GF_LOG[a] + QR_GF_LOG[b]])
3686
+
3687
+ /** The generator polynomial of this degree, highest power first. */
3688
+ function qrGenerator(degree) {
3689
+ let poly = [1]
3690
+ for (let i = 0; i < degree; i += 1) {
3691
+ const next = new Array(poly.length + 1).fill(0)
3692
+ for (let j = 0; j < poly.length; j += 1) {
3693
+ next[j] ^= poly[j]
3694
+ next[j + 1] ^= qrMultiply(poly[j], QR_GF_EXP[i])
3695
+ }
3696
+ poly = next
3697
+ }
3698
+ return poly
3699
+ }
3700
+
3701
+ /** The EC codewords for one data block. */
3702
+ function qrEcCodewords(block, ecCount) {
3703
+ const generator = qrGenerator(ecCount)
3704
+ const work = new Uint8Array(block.length + ecCount)
3705
+ work.set(block)
3706
+ for (let i = 0; i < block.length; i += 1) {
3707
+ const factor = work[i]
3708
+ if (factor === 0) continue
3709
+ for (let j = 0; j < generator.length; j += 1) work[i + j] ^= qrMultiply(generator[j], factor)
3710
+ }
3711
+ return work.slice(block.length)
3712
+ }
3713
+
3714
+ /** The smallest version that holds this many bytes, or null. */
3715
+ function qrVersionFor(byteLength) {
3716
+ for (let version = 1; version <= QR_CODEWORDS.length; version += 1) {
3717
+ const countBits = version <= 9 ? 8 : 16
3718
+ const capacityBits = (QR_CODEWORDS[version - 1] - QR_EC_M[version - 1]) * 8
3719
+ if (4 + countBits + byteLength * 8 <= capacityBits) return version
3720
+ }
3721
+ return null
3722
+ }
3723
+
3724
+ /** The final interleaved codeword stream for this text, or null if it does not fit. */
3725
+ function qrCodewords(bytes) {
3726
+ const version = qrVersionFor(bytes.length)
3727
+ if (version === null) return null
3728
+ const countBits = version <= 9 ? 8 : 16
3729
+ const dataCodewords = QR_CODEWORDS[version - 1] - QR_EC_M[version - 1]
3730
+ const dataBits = dataCodewords * 8
3731
+ const bits = []
3732
+ const push = (value, length) => {
3733
+ for (let i = length - 1; i >= 0; i -= 1) bits.push((value >>> i) & 1)
3734
+ }
3735
+ push(0b0100, 4)
3736
+ push(bytes.length, countBits)
3737
+ for (const byte of bytes) push(byte, 8)
3738
+ push(0, Math.min(4, dataBits - bits.length))
3739
+ while (bits.length % 8 !== 0) bits.push(0)
3740
+ let padIndex = 0
3741
+ while (bits.length < dataBits) {
3742
+ push(padIndex % 2 === 0 ? 0xec : 0x11, 8)
3743
+ padIndex += 1
3744
+ }
3745
+ const data = new Uint8Array(dataCodewords)
3746
+ for (let i = 0; i < dataCodewords; i += 1) {
3747
+ let byte = 0
3748
+ for (let b = 0; b < 8; b += 1) byte = (byte << 1) | bits[i * 8 + b]
3749
+ data[i] = byte
3750
+ }
3751
+
3752
+ const total = QR_CODEWORDS[version - 1]
3753
+ const ecTotal = QR_EC_M[version - 1]
3754
+ const blocks = QR_BLOCKS_M[version - 1]
3755
+ const group2 = total % blocks
3756
+ const group1 = blocks - group2
3757
+ const dataInGroup1 = Math.floor(dataCodewords / blocks)
3758
+ const ecCount = Math.floor(total / blocks) - dataInGroup1
3759
+ const dataBlocks = []
3760
+ const ecBlocks = []
3761
+ let offset = 0
3762
+ for (let b = 0; b < blocks; b += 1) {
3763
+ const size = b < group1 ? dataInGroup1 : dataInGroup1 + 1
3764
+ const block = data.slice(offset, offset + size)
3765
+ dataBlocks.push(block)
3766
+ ecBlocks.push(qrEcCodewords(block, ecCount))
3767
+ offset += size
3768
+ }
3769
+ const out = new Uint8Array(total)
3770
+ let index = 0
3771
+ const maxData = dataInGroup1 + (group2 > 0 ? 1 : 0)
3772
+ for (let i = 0; i < maxData; i += 1) {
3773
+ for (const block of dataBlocks) if (i < block.length) out[index++] = block[i]
3774
+ }
3775
+ for (let i = 0; i < ecCount; i += 1) {
3776
+ for (const block of ecBlocks) out[index++] = block[i]
3777
+ }
3778
+ return { version, codewords: out }
3779
+ }
3780
+
3781
+ /** Centre coordinates of the alignment patterns, per the version. */
3782
+ function qrAlignmentPositions(version) {
3783
+ if (version === 1) return []
3784
+ const count = Math.floor(version / 7) + 2
3785
+ const step = version === 32 ? 26 : Math.ceil((version * 4 + 4) / (count * 2 - 2)) * 2
3786
+ const positions = [6]
3787
+ for (let pos = version * 4 + 10; positions.length < count; pos -= step) positions.splice(1, 0, pos)
3788
+ return positions
3789
+ }
3790
+
3791
+ const qrMaskBit = (pattern, row, col) => {
3792
+ switch (pattern) {
3793
+ case 0: return (row + col) % 2 === 0
3794
+ case 1: return row % 2 === 0
3795
+ case 2: return col % 3 === 0
3796
+ case 3: return (row + col) % 3 === 0
3797
+ case 4: return (Math.floor(row / 2) + Math.floor(col / 3)) % 2 === 0
3798
+ case 5: return ((row * col) % 2) + ((row * col) % 3) === 0
3799
+ case 6: return (((row * col) % 2) + ((row * col) % 3)) % 2 === 0
3800
+ default: return (((row + col) % 2) + ((row * col) % 3)) % 2 === 0
3801
+ }
3802
+ }
3803
+
3804
+ /** The 15 format bits for level M and this mask, BCH-protected and XORed. */
3805
+ function qrFormatBits(maskPattern) {
3806
+ const data = (0b00 << 3) | maskPattern
3807
+ let remainder = data << 10
3808
+ for (let i = 14; i >= 10; i -= 1) {
3809
+ if (((remainder >>> i) & 1) === 1) remainder ^= 0b10100110111 << (i - 10)
3810
+ }
3811
+ return ((data << 10) | remainder) ^ 0b101010000010010
3812
+ }
3813
+
3814
+ /** The 18 version bits for version 7 and up. */
3815
+ function qrVersionBits(version) {
3816
+ let remainder = version << 12
3817
+ for (let i = 17; i >= 12; i -= 1) {
3818
+ if (((remainder >>> i) & 1) === 1) remainder ^= 0b1111100100101 << (i - 12)
3819
+ }
3820
+ return (version << 12) | remainder
3821
+ }
3822
+
3823
+ /** One complete symbol, masked and with its format bits written. */
3824
+ function qrBuildMatrix(codewords, version, maskPattern) {
3825
+ const size = version * 4 + 17
3826
+ const modules = Array.from({ length: size }, () => new Uint8Array(size))
3827
+ const reserved = Array.from({ length: size }, () => new Uint8Array(size))
3828
+ const put = (row, col, dark) => {
3829
+ modules[row][col] = dark === true ? 1 : 0
3830
+ reserved[row][col] = 1
3831
+ }
3832
+
3833
+ const finder = (top, left) => {
3834
+ for (let r = -1; r <= 7; r += 1) {
3835
+ for (let c = -1; c <= 7; c += 1) {
3836
+ const row = top + r
3837
+ const col = left + c
3838
+ if (row < 0 || row >= size || col < 0 || col >= size) continue
3839
+ const ring = (r === 0 || r === 6) && c >= 0 && c <= 6
3840
+ const side = (c === 0 || c === 6) && r >= 0 && r <= 6
3841
+ const core = r >= 2 && r <= 4 && c >= 2 && c <= 4
3842
+ put(row, col, ring || side || core)
3843
+ }
3844
+ }
3845
+ }
3846
+ finder(0, 0)
3847
+ finder(0, size - 7)
3848
+ finder(size - 7, 0)
3849
+
3850
+ for (let i = 8; i < size - 8; i += 1) {
3851
+ put(6, i, i % 2 === 0)
3852
+ put(i, 6, i % 2 === 0)
3853
+ }
3854
+
3855
+ const alignment = qrAlignmentPositions(version)
3856
+ for (const row of alignment) {
3857
+ for (const col of alignment) {
3858
+ const overlapsFinder = (row === 6 && col === 6) || (row === 6 && col === size - 7) || (row === size - 7 && col === 6)
3859
+ if (overlapsFinder) continue
3860
+ for (let r = -2; r <= 2; r += 1) {
3861
+ for (let c = -2; c <= 2; c += 1) put(row + r, col + c, Math.max(Math.abs(r), Math.abs(c)) !== 1)
3862
+ }
3863
+ }
3864
+ }
3865
+
3866
+ /* Reserve the format and version areas before the data goes in, so the data
3867
+ placement skips them and the mask never touches them. */
3868
+ for (let i = 0; i <= 8; i += 1) {
3869
+ if (i !== 6) {
3870
+ reserved[8][i] = 1
3871
+ reserved[i][8] = 1
3872
+ }
3873
+ }
3874
+ reserved[8][8] = 1
3875
+ for (let i = 0; i < 8; i += 1) {
3876
+ reserved[8][size - 1 - i] = 1
3877
+ reserved[size - 1 - i][8] = 1
3878
+ }
3879
+ put(size - 8, 8, true)
3880
+ if (version >= 7) {
3881
+ for (let i = 0; i < 18; i += 1) {
3882
+ const row = Math.floor(i / 3)
3883
+ const col = size - 11 + (i % 3)
3884
+ reserved[row][col] = 1
3885
+ reserved[col][row] = 1
3886
+ }
3887
+ }
3888
+
3889
+ const totalBits = codewords.length * 8
3890
+ let bitIndex = 0
3891
+ let upward = true
3892
+ for (let col = size - 1; col > 0; col -= 2) {
3893
+ if (col === 6) col -= 1
3894
+ for (let i = 0; i < size; i += 1) {
3895
+ const row = upward ? size - 1 - i : i
3896
+ for (const c of [col, col - 1]) {
3897
+ if (reserved[row][c] === 1) continue
3898
+ const bit = bitIndex < totalBits ? (codewords[bitIndex >> 3] >>> (7 - (bitIndex & 7))) & 1 : 0
3899
+ modules[row][c] = bit
3900
+ bitIndex += 1
3901
+ }
3902
+ }
3903
+ upward = !upward
3904
+ }
3905
+
3906
+ for (let row = 0; row < size; row += 1) {
3907
+ for (let col = 0; col < size; col += 1) {
3908
+ if (reserved[row][col] === 0 && qrMaskBit(maskPattern, row, col)) modules[row][col] ^= 1
3909
+ }
3910
+ }
3911
+
3912
+ /*
3913
+ * The two format strips, and which bit goes where. They are not two copies laid
3914
+ * out the same way: the strip beside the TOP-LEFT finder is the VERTICAL one
3915
+ * (column 8, bits 0-7 climbing the corner then bits 8-14 up the bottom-left), and
3916
+ * the other is the HORIZONTAL one (row 8, bits 0-7 running left from the
3917
+ * bottom-right corner, then bit 8 and bits 9-14 back toward the corner). Getting
3918
+ * this backwards produces a perfectly ordinary-looking symbol that no scanner
3919
+ * reads — which is why it is checked against a reference matrix, not by eye.
3920
+ */
3921
+ const format = qrFormatBits(maskPattern)
3922
+ for (let i = 0; i < 15; i += 1) {
3923
+ const bit = ((format >>> i) & 1) === 1
3924
+ if (i < 6) modules[i][8] = bit ? 1 : 0
3925
+ else if (i < 8) modules[i + 1][8] = bit ? 1 : 0
3926
+ else modules[size - 15 + i][8] = bit ? 1 : 0
3927
+ if (i < 8) modules[8][size - i - 1] = bit ? 1 : 0
3928
+ else if (i < 9) modules[8][7] = bit ? 1 : 0
3929
+ else modules[8][14 - i] = bit ? 1 : 0
3930
+ }
3931
+ if (version >= 7) {
3932
+ const info = qrVersionBits(version)
3933
+ for (let i = 0; i < 18; i += 1) {
3934
+ const bit = ((info >>> i) & 1) === 1 ? 1 : 0
3935
+ const row = Math.floor(i / 3)
3936
+ const col = size - 11 + (i % 3)
3937
+ modules[row][col] = bit
3938
+ modules[col][row] = bit
3939
+ }
3940
+ }
3941
+ return { modules, size }
3942
+ }
3943
+
3944
+ /** The spec's four penalty rules, used to pick the least confusing mask. */
3945
+ function qrPenalty(modules, size) {
3946
+ let penalty = 0
3947
+ const at = (row, col) => modules[row][col]
3948
+ for (let i = 0; i < size; i += 1) {
3949
+ for (const vertical of [false, true]) {
3950
+ let run = 1
3951
+ for (let j = 1; j < size; j += 1) {
3952
+ const previous = vertical ? at(j - 1, i) : at(i, j - 1)
3953
+ const current = vertical ? at(j, i) : at(i, j)
3954
+ if (current === previous) {
3955
+ run += 1
3956
+ } else {
3957
+ if (run >= 5) penalty += 3 + (run - 5)
3958
+ run = 1
3959
+ }
3960
+ }
3961
+ if (run >= 5) penalty += 3 + (run - 5)
3962
+ }
3963
+ }
3964
+ for (let row = 0; row < size - 1; row += 1) {
3965
+ for (let col = 0; col < size - 1; col += 1) {
3966
+ const first = at(row, col)
3967
+ if (first === at(row, col + 1) && first === at(row + 1, col) && first === at(row + 1, col + 1)) penalty += 3
3968
+ }
3969
+ }
3970
+ const patternA = [1, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0]
3971
+ const patternB = [0, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1]
3972
+ for (let i = 0; i < size; i += 1) {
3973
+ for (let j = 0; j + 11 <= size; j += 1) {
3974
+ for (const vertical of [false, true]) {
3975
+ let matchesA = true
3976
+ let matchesB = true
3977
+ for (let k = 0; k < 11; k += 1) {
3978
+ const value = vertical ? at(j + k, i) : at(i, j + k)
3979
+ if (value !== patternA[k]) matchesA = false
3980
+ if (value !== patternB[k]) matchesB = false
3981
+ }
3982
+ if (matchesA) penalty += 40
3983
+ if (matchesB) penalty += 40
3984
+ }
3985
+ }
3986
+ }
3987
+ let dark = 0
3988
+ for (let row = 0; row < size; row += 1) {
3989
+ for (let col = 0; col < size; col += 1) dark += at(row, col) === 1 ? 1 : 0
3990
+ }
3991
+ const percent = (dark * 100) / (size * size)
3992
+ penalty += Math.floor(Math.abs(percent - 50) / 5) * 10
3993
+ return penalty
3994
+ }
3995
+
3996
+ /** UTF-8 bytes without a dependency on `TextEncoder`, which a sandbox may not have. */
3997
+ function qrUtf8(text) {
3998
+ const bytes = []
3999
+ for (const character of String(text)) {
4000
+ const code = character.codePointAt(0)
4001
+ if (code < 0x80) bytes.push(code)
4002
+ else if (code < 0x800) bytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f))
4003
+ else if (code < 0x10000) bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f))
4004
+ else bytes.push(0xf0 | (code >> 18), 0x80 | ((code >> 12) & 0x3f), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f))
4005
+ }
4006
+ return bytes
4007
+ }
4008
+
4009
+ /**
4010
+ * A QR code for this text, as horizontal runs of dark modules.
4011
+ *
4012
+ * Runs rather than one rect per module: a version-8 symbol has ~1000 dark modules
4013
+ * and ~250 runs, and the panel redraws on every poll.
4014
+ *
4015
+ * @param {string} text - what the phone should read.
4016
+ * @returns {{size: number, runs: Array<{row: number, col: number, width: number}>}|null}
4017
+ */
4018
+ function qrFor(text) {
4019
+ const encoded = qrCodewords(qrUtf8(text))
4020
+ if (encoded === null) return null
4021
+ let best = null
4022
+ for (let mask = 0; mask < 8; mask += 1) {
4023
+ const candidate = qrBuildMatrix(encoded.codewords, encoded.version, mask)
4024
+ const score = qrPenalty(candidate.modules, candidate.size)
4025
+ if (best === null || score < best.score) best = { score, ...candidate }
4026
+ }
4027
+ const runs = []
4028
+ for (let row = 0; row < best.size; row += 1) {
4029
+ let col = 0
4030
+ while (col < best.size) {
4031
+ if (best.modules[row][col] === 0) {
4032
+ col += 1
4033
+ continue
4034
+ }
4035
+ let width = 1
4036
+ while (col + width < best.size && best.modules[row][col + width] === 1) width += 1
4037
+ runs.push({ row, col, width })
4038
+ col += width
4039
+ }
4040
+ }
4041
+ return { size: best.size, runs }
4042
+ }
4043
+
4044
+ function QrCode(props) {
4045
+ const code = props.code
4046
+ return h(
4047
+ 'svg',
4048
+ {
4049
+ className: 'dsc-qr',
4050
+ viewBox: `0 0 ${code.size} ${code.size}`,
4051
+ role: 'img',
4052
+ 'aria-label': props.label,
4053
+ shapeRendering: 'crispEdges',
4054
+ },
4055
+ ...code.runs.map((run, index) => h('rect', { key: `r${index}`, x: run.col, y: run.row, width: run.width, height: 1 })),
4056
+ )
4057
+ }
4058
+
4059
+ function BilibiliPage(props) {
4060
+ const t = typeof props?.t === 'function' ? props.t : (key) => key
4061
+ const { bili, error, setError, loadBili } = useConsoleState()
4062
+ const [busy, setBusy] = React.useState('')
4063
+ const [notice, setNotice] = React.useState(null)
4064
+
4065
+ React.useEffect(() => {
4066
+ void loadBili({ force: true })
4067
+ }, [loadBili])
4068
+
4069
+ const reload = React.useCallback(() => loadBili({ force: true }), [loadBili])
4070
+ const repositories = Array.isArray(bili?.repos) ? bili.repos : []
4071
+ const lastSweep = bili?.lastSweep ?? null
4072
+ const sweepText = (() => {
4073
+ if (bili === null) return null
4074
+ if (lastSweep === null || lastSweep === undefined) return t('bili.neverSwept')
4075
+ const results = Array.isArray(lastSweep.results) ? lastSweep.results : []
4076
+ const announced = results.filter((entry) => entry.state === 'announced')
4077
+ const failed = results.filter((entry) => entry.state === 'failed')
4078
+ const summary = announced.length > 0
4079
+ ? announced.map((entry) => t('bili.sweep.announced', { repo: String(entry.repo ?? ''), tag: String(entry.tag ?? '') })).join(',')
4080
+ : failed.length > 0
4081
+ ? failed.map((entry) => t('bili.sweep.failed', { repo: String(entry.repo ?? ''), tag: String(entry.tag ?? '') })).join(',')
4082
+ : t('bili.sweep.none')
4083
+ return t('bili.lastSweep', { time: stamp(String(lastSweep.at ?? '')), summary })
4084
+ })()
4085
+
4086
+ return h(
4087
+ 'div',
4088
+ { className: 'dsc-root' },
4089
+ h(
4090
+ 'div',
4091
+ { className: 'dsc-bar' },
4092
+ h(
4093
+ 'div',
4094
+ { className: 'dsc-heading' },
4095
+ h('span', { className: 'dsc-title' }, t('bili.settings.title')),
4096
+ h('span', { className: 'dsc-subtitle' }, t('bili.settings.subtitle')),
4097
+ ),
4098
+ h(Btn, { disabled: busy !== '', onClick: () => { void reload() } }, t('action.recheck')),
4099
+ ),
4100
+
4101
+ error !== null ? h('div', { className: 'dsc-error', role: 'alert' }, errorText(t, error)) : null,
4102
+ notice !== null ? h('div', { className: 'dsc-notice', role: 'status' }, notice) : null,
4103
+
4104
+ bili === null
4105
+ ? h('div', { className: 'dsc-empty' }, t('state.loading'))
4106
+ : h(
4107
+ React.Fragment,
4108
+ null,
4109
+ bili.enabled === false
4110
+ ? h('div', { className: 'dsc-warn' }, 'bilibiliEnabled: false')
4111
+ : null,
4112
+ bili.watchSeconds === 0 ? h('div', { className: 'dsc-warn' }, t('bili.watch.off')) : null,
4113
+ bili.ledger?.problem
4114
+ ? h('div', { className: 'dsc-error', role: 'alert' }, t('bili.ledger.problem', { problem: String(bili.ledger.problem) }))
4115
+ : null,
4116
+ h(BilibiliSignIn, {
4117
+ t,
4118
+ bili,
4119
+ busy,
4120
+ setBusy,
4121
+ setNotice,
4122
+ setError,
4123
+ onChanged: () => loadBili({ force: true }),
4124
+ }),
4125
+ h(
4126
+ 'div',
4127
+ { className: 'dsc-setup' },
4128
+ h('span', { className: 'dsc-setup-strong' }, t('bili.template')),
4129
+ h('span', { className: 'dsc-mono' }, String(bili.template === '' ? '【更新 {tag}】{summary}' : bili.template)),
4130
+ h('span', { className: 'dsc-quiet' }, t('bili.template.hint')),
4131
+ sweepText === null ? null : h('span', { className: 'dsc-quiet' }, sweepText),
4132
+ ),
4133
+ h(
4134
+ 'div',
4135
+ { className: 'dsc-sub' },
4136
+ h('span', { className: 'dsc-setup-strong' }, t('bili.repos.title')),
4137
+ repositories.length === 0
4138
+ ? h('span', null, t('bili.repos.empty'))
4139
+ : repositories.map((entry) =>
4140
+ h(
4141
+ 'div',
4142
+ { className: 'dsc-line', key: entry.repo },
4143
+ h('span', { className: 'dsc-name', style: { minWidth: '0', fontWeight: '500' } }, String(entry.label ?? entry.repo)),
4144
+ entry.binding === null
4145
+ ? h(Chip, { state: 'idle' }, t('bili.repo.none'))
4146
+ : h(Chip, { state: 'success' }, String(entry.binding.bvid)),
4147
+ h('span', { className: 'dsc-grow' }, entry.video?.ok === true
4148
+ ? String(entry.video.title ?? '')
4149
+ : (Array.isArray(entry.announced) && entry.announced.length > 0
4150
+ ? t('bili.chip.announced', { tag: String(entry.announced[entry.announced.length - 1].tag) })
4151
+ : t('bili.announced.none'))),
4152
+ Array.isArray(entry.failures) && entry.failures.length > 0
4153
+ ? h('span', { className: 'dsc-warn' }, t('bili.failure.last', { message: String(entry.failures[entry.failures.length - 1].message ?? '') }))
4154
+ : null,
4155
+ entry.commentUrl
4156
+ ? h(Btn, { kind: 'quiet', onClick: () => globalThis.open(String(entry.commentUrl), '_blank', 'noopener,noreferrer') }, t('action.open'))
4157
+ : null,
4158
+ ),
4159
+ ),
4160
+ ),
4161
+ ),
4162
+ )
4163
+ }
4164
+
4165
+ /** The Settings page: account, scopes, and the repository list. */
4166
+ function AccountPage(props) {
4167
+ const t = typeof props?.t === 'function' ? props.t : (key) => key
4168
+ const { status, error, setError, loadStatus } = useConsoleState()
4169
+ const [managing, setManaging] = React.useState(false)
4170
+ const [confirmingSignOut, setConfirmingSignOut] = React.useState(false)
4171
+ const [signOutBusy, setSignOutBusy] = React.useState(false)
4172
+ const [notice, setNotice] = React.useState(null)
4173
+
4174
+ React.useEffect(() => {
4175
+ void loadStatus()
4176
+ }, [loadStatus])
4177
+
4178
+ const gh = status?.gh ?? {}
4179
+ const config = status?.config ?? {}
4180
+ const missing = Array.isArray(gh.missingScopes) ? gh.missingScopes : []
4181
+ const scopes = Array.isArray(gh.scopes) ? gh.scopes : []
4182
+ const registered = Array.isArray(status?.repos) ? status.repos.length : 0
4183
+ const stale = status !== null && status.protocol !== PROTOCOL
4184
+
4185
+ return h(
4186
+ 'div',
4187
+ { className: 'dsc-root' },
4188
+ h(
4189
+ 'div',
4190
+ { className: 'dsc-bar' },
4191
+ h(
4192
+ 'div',
4193
+ { className: 'dsc-heading' },
4194
+ h('span', { className: 'dsc-title' }, t('settings.title')),
4195
+ h('span', { className: 'dsc-subtitle' }, t('settings.subtitle')),
4196
+ ),
4197
+ h(Btn, { onClick: () => void loadStatus() }, t('action.recheck')),
4198
+ /* Signing out is destructive and easy to hit by accident, so it takes two
4199
+ clicks — the same shape as publishing a draft. */
4200
+ gh.authenticated === true
4201
+ ? h(Btn, {
4202
+ kind: 'quiet',
4203
+ disabled: signOutBusy,
4204
+ title: t('state.signedOut'),
4205
+ onClick: () => {
4206
+ if (!confirmingSignOut) {
4207
+ setConfirmingSignOut(true)
4208
+ return
4209
+ }
4210
+ void (async () => {
4211
+ setSignOutBusy(true)
4212
+ const result = await postJson('/auth-logout', {}, ACTION_TIMEOUT_MS)
4213
+ setSignOutBusy(false)
4214
+ setConfirmingSignOut(false)
4215
+ if (!result.ok || result.payload?.ok !== true) {
4216
+ setError(t('state.actionFailed', { reason: result.payload?.message ?? `HTTP ${String(result.response.status)}` }))
4217
+ return
4218
+ }
4219
+ setNotice(t('state.signedOut'))
4220
+ void loadStatus()
4221
+ })()
4222
+ },
4223
+ }, signOutBusy ? '…' : confirmingSignOut ? t('action.signOutConfirm') : t('action.signOut'))
4224
+ : null,
4225
+ ),
4226
+
4227
+ error !== null ? h('div', { className: 'dsc-error', role: 'alert' }, errorText(t, error)) : null,
4228
+ notice !== null ? h('div', { className: 'dsc-notice', role: 'status' }, notice) : null,
4229
+ stale
4230
+ ? h('div', { className: 'dsc-warn', role: 'alert' }, h('strong', null, t('stale.title')), ' — ', t('stale.how'))
4231
+ : null,
4232
+
4233
+ status === null
4234
+ ? h('div', { className: 'dsc-empty' }, t('state.loading'))
4235
+ : h(
4236
+ React.Fragment,
4237
+ null,
4238
+ h(
4239
+ 'div',
4240
+ { className: 'dsc-list' },
4241
+ h(
4242
+ 'div',
4243
+ { className: 'dsc-item' },
4244
+ h(
4245
+ 'div',
4246
+ { className: 'dsc-row' },
4247
+ h('span', { className: 'dsc-dot', 'data-state': gh.available !== true ? 'error' : gh.authenticated !== true ? 'warn' : 'success' }),
4248
+ h('span', { className: 'dsc-name' }, t('meta.gh')),
4249
+ h('span', { className: 'dsc-grow' }, gh.available !== true ? (gh.message ?? 'not found') : (gh.version ?? 'present')),
4250
+ gh.authenticated === true && typeof gh.account === 'string' && gh.account !== ''
4251
+ ? h(Chip, { state: 'success' }, gh.account)
4252
+ : h(Chip, { state: 'warn' }, t('setup.needSignIn')),
4253
+ ),
4254
+ ),
4255
+ h(
4256
+ 'div',
4257
+ { className: 'dsc-item' },
4258
+ h(
4259
+ 'div',
4260
+ { className: 'dsc-row' },
4261
+ h('span', { className: 'dsc-dot', 'data-state': missing.length > 0 ? 'warn' : 'success' }),
4262
+ h('span', { className: 'dsc-name' }, t('meta.scopes')),
4263
+ h('span', { className: 'dsc-grow' }, gh.authenticated !== true ? '—' : scopes.length > 0 ? scopes.join(', ') : t('meta.noScopes')),
4264
+ missing.length > 0 ? h(Chip, { state: 'warn' }, missing.join(', ')) : null,
4265
+ ),
4266
+ ),
4267
+ h(
4268
+ 'div',
4269
+ { className: 'dsc-item' },
4270
+ h(
4271
+ 'div',
4272
+ { className: 'dsc-row' },
4273
+ h('span', { className: 'dsc-dot', 'data-state': 'idle' }),
4274
+ h('span', { className: 'dsc-name' }, t('meta.configFrom')),
4275
+ h('span', { className: 'dsc-grow dsc-mono' }, `${status.configSource ?? '—'} · ${status.configFile ?? ''}`),
4276
+ h('span', null, String(registered)),
4277
+ ),
4278
+ ),
4279
+ ),
4280
+
4281
+ needsAccountSetup(gh) && !stale
4282
+ ? h(
4283
+ 'div',
4284
+ { className: 'dsc-setup' },
4285
+ h('span', { className: 'dsc-setup-strong' }, gh.available !== true
4286
+ ? t('setup.needGh')
4287
+ : gh.authenticated !== true
4288
+ ? t('setup.needSignIn')
4289
+ : t('setup.needScopes', { scopes: missing.join(', ') })),
4290
+ missing.length > 0 ? h('span', null, t('setup.scopeHint')) : null,
4291
+ h(SignIn, { t, status, onChanged: () => void loadStatus() }),
4292
+ )
4293
+ : null,
4294
+
4295
+ h(
4296
+ 'div',
4297
+ { className: 'dsc-bar' },
4298
+ h('span', { className: 'dsc-subtitle' }, `${t('meta.projectsRoot')}: ${typeof config.projectsRoot === 'string' && config.projectsRoot !== '' ? config.projectsRoot : t('meta.projectsRootUnset')}`),
4299
+ h(Btn, { kind: managing ? 'primary' : undefined, onClick: () => setManaging((value) => !value) }, managing ? t('action.close') : t('action.manage')),
4300
+ ),
4301
+ managing && !stale ? h(RepoPicker, { t, onChanged: () => void loadStatus() }) : null,
4302
+ ),
4303
+ )
4304
+ }
4305
+
4306
+ /**
4307
+ * The console's settings page: three credentials, one page.
4308
+ *
4309
+ * One segment mounts at a time, and that is deliberate twice over. It keeps the
4310
+ * page from probing all three credential systems the moment someone opens it —
4311
+ * each segment asks its own Host route when it mounts, as it did as a page of its
4312
+ * own. And it keeps a failure local: the Bilibili half parses a ledger and a cookie
4313
+ * file, and a malformed one takes down the segment it is in, not the GitHub token
4314
+ * state someone opened this page to read.
4315
+ *
4316
+ * The segments render here rather than in the slot because `settings.plugins.tab`
4317
+ * is a flat list — the platform gives one strip of tabs and no nesting — so the
4318
+ * inner strip is ours. `order` in that outer strip is one row per plugin; inside,
4319
+ * the order is the workflow: release, distribute on npm, then announce.
4320
+ */
4321
+ function ConsoleSettingsPage(props) {
4322
+ const t = typeof props?.t === 'function' ? props.t : (key) => key
4323
+ const [segment, setSegment] = React.useState(SEGMENT_GITHUB)
4324
+ const segments = [
4325
+ { key: SEGMENT_GITHUB, label: 'settings.segment.github', page: AccountPage },
4326
+ { key: SEGMENT_NPM, label: 'settings.segment.npm', page: NpmCredentialsPage },
4327
+ { key: SEGMENT_BILIBILI, label: 'settings.segment.bilibili', page: BilibiliPage },
4328
+ ]
4329
+ const active = segments.find((entry) => entry.key === segment) ?? segments[0]
4330
+ return h(
4331
+ 'div',
4332
+ { className: 'dsc-settings', 'data-segment': active.key },
4333
+ h(
4334
+ 'div',
4335
+ { className: 'dsc-bar dsc-segments' },
4336
+ ...segments.map((entry) => h(
4337
+ Btn,
4338
+ {
4339
+ key: entry.key,
4340
+ kind: entry.key === active.key ? 'primary' : 'quiet',
4341
+ 'data-segment': entry.key,
4342
+ title: t(entry.label),
4343
+ onClick: () => setSegment(entry.key),
4344
+ },
4345
+ t(entry.label),
4346
+ )),
4347
+ ),
4348
+ h(active.page, { t }),
4349
+ )
4350
+ }
4351
+
4352
+ return {
4353
+ name: 'dsh-cicd-client',
4354
+ inject: ['slots', 'locale'],
4355
+ apply(ctx) {
4356
+ ensureStyles()
4357
+ ctx.effect(
4358
+ () => ctx.locale.register(NS, { zh, en }),
4359
+ 'dsh-plugin-cicd: dictionaries',
4360
+ )
4361
+ const t = ctx.locale.bind(NS)
4362
+ const disposers = [
4363
+ ctx.slots.inject('main', () =>
4364
+ ctx.slots.register(
4365
+ { name: 'main', key: PANEL_ID, locale: NS },
4366
+ ConsolePage,
4367
+ ),
4368
+ ),
4369
+ // `order: 16` sits the entry after the knowledge console (15) and before
4370
+ // whatever a later plugin adds; the sidebar owns the button itself.
4371
+ ctx.slots.inject('sidebar.panellist', () =>
4372
+ ctx.slots.register(
4373
+ { name: 'sidebar.panellist', id: PANEL_ID, order: 16, label: () => t('panel'), locale: NS },
4374
+ ConsoleIcon,
4375
+ ),
4376
+ ),
4377
+ /*
4378
+ * ONE page, in the Plugins section, holding the three credentials.
4379
+ *
4380
+ * Two shares of a shared surface were given back here. The top-level nav
4381
+ * (`settings.section`) is a FLAT list — one page per entry, no grouping, no
4382
+ * sub-navigation — and this plugin was taking 3 of its 13 rows, all of them
4383
+ * above 通用 / 模型 / 插件, while every other third-party plugin on this machine
4384
+ * takes exactly one. `settings.plugins.tab` is the seat the platform keeps for
4385
+ * a plugin's own pages ("One page inside the Plugins settings section"), so the
4386
+ * configuration belongs there — but three tabs in that strip would be the same
4387
+ * unfair share one level down: a strip shared with every plugin, three of its
4388
+ * rows belonging to this one. So the three are segments inside one page, and
4389
+ * the strip is one row per plugin.
4390
+ *
4391
+ * `order` 20 puts it after the inventory (`all`, 10) and before the marketplace
4392
+ * (60): it configures an installed plugin, which is closer to the inventory than
4393
+ * to a shop.
4394
+ *
4395
+ * A fresh id is required — reusing a shipped id would REPLACE that page instead
4396
+ * of adding one. The `locale` option is what supplies the `t` prop
4397
+ * (`PropsLocale`), and it is the registration's own option rather than anything
4398
+ * the slot owner gives, so the page translates exactly as the sections did.
4399
+ */
4400
+ ctx.slots.inject('settings.plugins.tab', () =>
4401
+ ctx.slots.register(
4402
+ { name: 'settings.plugins.tab', id: SETTINGS_ID, order: 20, label: () => t('settings.consoleLabel'), locale: NS },
4403
+ ConsoleSettingsPage,
4404
+ ),
4405
+ ),
4406
+ ]
4407
+ if (typeof ctx.effect === 'function') {
4408
+ ctx.effect(() => () => {
4409
+ for (const dispose of disposers) {
4410
+ if (typeof dispose === 'function') dispose()
4411
+ }
4412
+ }, 'dsh-plugin-cicd: panel registrations')
4413
+ }
4414
+ },
4415
+ }
4416
+ },
4417
+ })