dsh-plugin-rack 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js ADDED
@@ -0,0 +1,2867 @@
1
+ /**
2
+ * Client half of the plugin dashboard.
3
+ *
4
+ * Registers a sidebar entry and the matching main panel. Every write goes to
5
+ * the Host half over the same-origin JSON route, so nothing here depends on the
6
+ * Client-side Remote access policy.
7
+ */
8
+ window.__ModuleLoader__.load({
9
+ id: 'dsh-plugin-rack',
10
+ factory(require) {
11
+ const React = require('react');
12
+ const h = React.createElement;
13
+
14
+ const PANEL_ID = 'plugin-rack';
15
+ const API = '/api/dsh-plugin-rack';
16
+ const NS = 'plugin-rack';
17
+
18
+ const DICT = {
19
+ en: {
20
+ title: 'Plugin rack',
21
+ subtitle: 'Per-plugin power switch and an auto/manual flag that decides who follows the one-click actions.',
22
+ refresh: 'Refresh',
23
+ enableAll: 'Turn all on',
24
+ disableAll: 'Turn all off',
25
+ markAllAuto: 'All auto',
26
+ markNoneAuto: 'All manual',
27
+ resetInstalled: 'Auto = installed',
28
+ rosterLabel: 'Auto list',
29
+ rosterCount: '{n} plugins follow the one-click actions',
30
+ groupFailed: 'Failed',
31
+ groupInstalled: 'Installed plugins',
32
+ groupCore: 'Core components',
33
+ groupSetAuto: 'Mark group auto',
34
+ groupSetManual: 'Mark group manual',
35
+ groupAutoOf: '{n} / {m} auto',
36
+ groupPowerHint: 'On / off',
37
+ groupCoreHidden: 'Switched off by a bundle',
38
+ groupCoreHiddenNote: 'Hidden by dsh-ops, not broken — turn off hideOfficialTools there to restore them',
39
+ groupAllAuto: 'Whole group is auto',
40
+ groupAllManual: 'Whole group is manual',
41
+ colName: 'Plugin',
42
+ colPower: 'On/off',
43
+ colMode: 'Mode',
44
+ modeAuto: 'Auto',
45
+ modeManual: 'Manual',
46
+ filterAll: 'All',
47
+ filterEnabled: 'On',
48
+ filterDisabled: 'Off',
49
+ filterFailed: 'Failed',
50
+ onlyInstalled: 'Hide core',
51
+ search: 'Search plugins...',
52
+ shown: 'shown',
53
+ total: 'total',
54
+ auto: 'auto',
55
+ on: 'on',
56
+ off: 'off',
57
+ failed: 'failed',
58
+ empty: 'No plugin matches this filter.',
59
+ loadFailed: 'Could not load plugins',
60
+ hostMissing: 'The Host half did not answer. It may be disabled, or the app needs a restart.',
61
+ readOnly: 'read-only',
62
+ rawManagement: 'core',
63
+ rawUnaddressable: 'unaddressable',
64
+ selfTag: 'this plugin',
65
+ rosterEmpty: 'The auto list is empty: mark the plugins you want the one-click actions to cover.',
66
+ persisted: 'Auto list saved to',
67
+ memoryOnly: '(memory only this session)',
68
+ confirmEnable: 'Turn on these {n} plugins on this page?',
69
+ confirmDisable: 'Turn off these {n} plugins on this page?',
70
+ bulkConfirmOn: 'Turn on {n} plugin(s) on this page?',
71
+ bulkConfirmOff: 'Turn off {n} plugin(s) on this page?',
72
+ bulkConfirmSave: 'One write to the profile file (not one switch at a time); it takes effect after a restart or the next hot swap.',
73
+ bulkConfirmOk: 'Do it',
74
+ bulkCancel: 'Cancel',
75
+ enableAllSave: 'Turn all on (one pass)',
76
+ disableAllSave: 'Turn all off (one pass)',
77
+ saveApplied: 'Written to the profile file; it takes effect after a restart or the next hot swap.',
78
+ done: 'Saved',
79
+ withFailures: 'Saved with {n} failure(s)',
80
+ withGuarded: '{n} skipped (this panel cannot switch itself off)',
81
+ withUnchanged: '{n} already in that state',
82
+ nothingToDo: 'Nothing to change',
83
+ runningBulk: 'Working… {n}/{total}',
84
+ stop: 'Stop',
85
+ stopping: 'Stopping…',
86
+ selfLocked: 'The dashboard cannot switch itself',
87
+ loading: 'loading',
88
+ detail: 'Details',
89
+ dModule: 'Module',
90
+ dEntry: 'Entry id',
91
+ dPatch: 'Patch id',
92
+ dPhase: 'State',
93
+ dMode: 'One-click',
94
+ dModeAuto: 'follows the auto list',
95
+ dModeManual: 'excluded from one-click',
96
+ dOfficial: 'Official',
97
+ dManageable: 'Toggleable',
98
+ dReason: 'Read-only reason',
99
+ dDiagnostic: 'Error',
100
+ yes: 'yes',
101
+ no: 'no',
102
+ close: 'Close',
103
+ links: 'Links',
104
+ dVersion: 'Version',
105
+ dLicense: 'License',
106
+ dAuthor: 'Author',
107
+ dDir: 'Directory',
108
+ dDeps: 'Dependencies',
109
+ detailFail: 'Could not load details',
110
+ uninstall: 'Uninstall',
111
+ uninstallConfirm: 'Click again to really uninstall',
112
+ uninstallRunning: 'Uninstalling…',
113
+ uninstallDone: 'Uninstalled',
114
+ uninstallFailed: 'Uninstall failed',
115
+ uninstallWarn: 'Removes the package and its entry in this profile. Make sure you have a backup.',
116
+ uninstallFreeModel: 'This plugin is what provides your free model — uninstalling it takes that away.',
117
+ uninstallUnavailable: 'Could not load the uninstallable list',
118
+ uninstallTitle: 'Uninstall this plugin (click again to confirm)',
119
+ installTitle: 'Install',
120
+ installSpecPlaceholder: 'package name, e.g. dsh-better-sidebar',
121
+ installCheck: 'Check',
122
+ installRun: 'Install',
123
+ installRunning: 'Installing…',
124
+ installOk: 'Registry accepts this',
125
+ installRefused: 'Not installable',
126
+ installDone: 'Installed',
127
+ installConflicts: '{n} conflict(s) detected; the new row was auto-disabled',
128
+ installBuild: 'This package wants to run build scripts; the host needs to approve it first.',
129
+ none: '—',
130
+ tabPlugins: 'Installed',
131
+ tabCore: 'Core',
132
+ tabFailed: 'Errors',
133
+ tabUpdates: 'Updates',
134
+ checkUpdates: 'Check for updates',
135
+ updateAll: 'Update all',
136
+ updateNone: 'Everything is up to date',
137
+ updateIdle: 'Not checked yet — pick “Check for updates”',
138
+ fileUpgradeSha: 'official sha256',
139
+ srcLocalFile: 'local tarball',
140
+ srcLocalLink: 'local link',
141
+ srcOther: 'other',
142
+ updateChecking: 'Asking the npm registry…',
143
+ updateWait: 'A session is still running; try again once it is idle',
144
+ updateDone: 'Updated',
145
+ updateRestart: 'Restart DSH once to load the new versions',
146
+ updatePartialSwap: 'Some packages are live, some need a restart',
147
+ updateSwapped: 'New versions are live; refresh the page for client-side plugins',
148
+ hasNewVersion: 'Update available',
149
+ updateNow: 'Update just this plugin',
150
+ jobRunning: 'Host is updating in the background · {n} plugins',
151
+ jobRollingBack: 'Rolling back — this cannot be cancelled',
152
+ jobDone: 'Background update finished',
153
+ jobFailed: 'Background update failed',
154
+ jobInterrupted: 'The last update never finished (host restarted or interrupted)',
155
+ connLost: 'The host reloaded its config to apply the change; refresh if the list looks stale',
156
+ jobCancel: 'Cancel',
157
+ jobQueued: 'Queued: downloaded and verified, installs by itself once a session is free ({n})',
158
+ updateRolledBack: 'Rolled back',
159
+ updateFailed: 'Failed',
160
+ updateNothing: 'Nothing to update for those packages',
161
+ updateUnknown: 'unknown',
162
+ updatePartial: '{n} of {total} groups failed — the rest went through',
163
+ updateSelectAll: 'Select all',
164
+ viewLog: 'Last log',
165
+ logEmpty: 'No update log yet',
166
+ logPath: 'Log file',
167
+ dFunction: 'What it does',
168
+ phaseActive: 'Running',
169
+ phaseFailed: 'Failed',
170
+ phaseLoading: 'Loading',
171
+ phaseOff: 'Stopped',
172
+ reasonManagement: 'Managed by the core',
173
+ reasonUnaddressable: 'Not addressable',
174
+ homepage: 'Homepage',
175
+ unitPlugins: 'plugins',
176
+ },
177
+ zh: {
178
+ title: '插件管理',
179
+ subtitle: '每个插件一个开关,外加「自动 / 手动」标记:只有标为自动的插件才受一键开启 / 关闭影响。',
180
+ refresh: '刷新',
181
+ enableAll: '一键全部开启',
182
+ disableAll: '一键全部关闭',
183
+ colPower: '开关',
184
+ colMode: '模式',
185
+ markAllAuto: '全部设为自动',
186
+ markNoneAuto: '全部设为手动',
187
+ resetInstalled: '自动=已安装',
188
+ rosterLabel: '自动清单',
189
+ rosterCount: '共 {n} 个插件参与一键操作',
190
+ groupFailed: '异常插件',
191
+ groupInstalled: '已安装插件',
192
+ groupCore: '核心组件',
193
+ groupSetAuto: '整组设为自动',
194
+ groupSetManual: '整组设为手动',
195
+ groupAutoOf: '{n} / {m} 自动',
196
+ groupPowerHint: '开启/关闭',
197
+ groupCoreHidden: '被插件包关闭',
198
+ groupCoreHiddenNote: '被 dsh-ops 隐藏,并非故障——关掉那边的 hideOfficialTools 即可恢复',
199
+ groupAllAuto: '整组已设为自动',
200
+ groupAllManual: '整组已设为手动',
201
+ colName: '插件名称',
202
+ colPower: '开启 / 关闭',
203
+ colMode: '自动 / 手动',
204
+ modeAuto: '自动',
205
+ modeManual: '手动',
206
+ filterAll: '全部',
207
+ filterEnabled: '已启用',
208
+ filterDisabled: '已禁用',
209
+ filterFailed: '异常',
210
+ onlyInstalled: '隐藏核心',
211
+ search: '搜索插件…',
212
+ shown: '显示',
213
+ total: '共',
214
+ auto: '自动',
215
+ on: '启用',
216
+ off: '禁用',
217
+ failed: '异常',
218
+ empty: '没有符合筛选条件的插件。',
219
+ loadFailed: '无法读取插件列表',
220
+ hostMissing: '宿主端没有响应:插件可能被停用,或需要重启。',
221
+ readOnly: '只读',
222
+ rawManagement: '核心',
223
+ rawUnaddressable: '无法定位配置行',
224
+ selfTag: '本插件',
225
+ rosterEmpty: '自动清单为空:请先把要参与一键操作的插件设为「自动」。',
226
+ persisted: '自动清单已保存到',
227
+ memoryOnly: '(本次仅存于内存)',
228
+ confirmEnable: '确认开启这一页的 {n} 个插件?',
229
+ confirmDisable: '确认关闭这一页的 {n} 个插件?',
230
+ bulkConfirmOn: '确认开启这一页的 {n} 个插件?',
231
+ bulkConfirmOff: '确认关闭这一页的 {n} 个插件?',
232
+ bulkConfirmSave: '只写一次配置文件(不逐个开关);重启或下次热替换后才生效。',
233
+ bulkConfirmOk: '就这么做',
234
+ bulkCancel: '取消',
235
+ enableAllSave: '一键全部开启(整批一次)',
236
+ disableAllSave: '一键全部关闭(整批一次)',
237
+ saveApplied: '已写入配置文件;重启 DSH 或下次热替换后才生效。',
238
+ done: '已保存',
239
+ withFailures: '已保存,{n} 个失败',
240
+ withGuarded: '已跳过 {n} 个(面板不能把自己关掉)',
241
+ withUnchanged: '{n} 个本来就是这个状态',
242
+ nothingToDo: '没有需要改的插件',
243
+ runningBulk: '正在处理… {n}/{total}',
244
+ stop: '停止',
245
+ stopping: '正在停止…',
246
+ selfLocked: '面板不能关闭自己',
247
+ loading: '加载中',
248
+ detail: '详细信息',
249
+ dModule: '模块',
250
+ dEntry: '配置行',
251
+ dPatch: '补丁 id',
252
+ dPhase: '运行状态',
253
+ dMode: '一键操作',
254
+ dModeAuto: '参与自动清单',
255
+ dModeManual: '不参与一键操作',
256
+ dOfficial: '官方',
257
+ dManageable: '可开关',
258
+ dReason: '只读原因',
259
+ dDiagnostic: '错误',
260
+ yes: '是',
261
+ no: '否',
262
+ close: '关闭',
263
+ links: '链接',
264
+ dVersion: '版本',
265
+ dLicense: '许可',
266
+ dAuthor: '作者',
267
+ dDir: '目录',
268
+ dDeps: '依赖',
269
+ detailFail: '无法加载详情',
270
+ uninstall: '卸载',
271
+ uninstallConfirm: '再点一下真的卸载',
272
+ uninstallRunning: '正在卸载…',
273
+ uninstallDone: '已卸载',
274
+ uninstallFailed: '卸载失败',
275
+ uninstallWarn: '会删掉这个包,并从本 profile 的启用清单里移除它。请确认你有备份。',
276
+ uninstallFreeModel: '这个插件就是给你提供免费模型的,卸掉它模型就没了。',
277
+ uninstallUnavailable: '拿不到可卸载列表',
278
+ uninstallTitle: '卸载这个插件(再点一下确认)',
279
+ installTitle: '安装',
280
+ installSpecPlaceholder: '包名,例如 dsh-better-sidebar',
281
+ installCheck: '检查',
282
+ installRun: '安装',
283
+ installRunning: '正在安装…',
284
+ installOk: '可以安装',
285
+ installRefused: '装不了',
286
+ installDone: '已安装',
287
+ installConflicts: '检测到 {n} 处冲突,已自动禁用新装的行',
288
+ installBuild: '这个包要跑构建脚本,需要宿主先批准。',
289
+ none: '—',
290
+ tabPlugins: '已安装插件',
291
+ tabCore: '核心组件',
292
+ tabFailed: '异常',
293
+ tabUpdates: '更新',
294
+ checkUpdates: '检查更新',
295
+ updateAll: '全部更新',
296
+ updateNone: '已经是最新版本',
297
+ updateIdle: '还没检查过,点「检查更新」看有没有新版本',
298
+ fileUpgradeSha: '官方 sha256',
299
+ srcLocalFile: '本地文件包',
300
+ srcLocalLink: '本地软链',
301
+ srcOther: '其他',
302
+ updateChecking: '正在查询 npm…',
303
+ updateWait: '有会话正在运行,等它结束后再试',
304
+ updateDone: '已更新',
305
+ updateRestart: '重启一次 DSH 让新版本生效',
306
+ updatePartialSwap: '部分包已生效,部分需要重启',
307
+ updateSwapped: '新版本已即时生效;界面类插件刷新页面即可',
308
+ hasNewVersion: '有新版',
309
+ updateNow: '只更新这一个插件',
310
+ jobRunning: '宿主正在后台更新 · {n} 个插件',
311
+ jobRollingBack: '正在回滚,这一步不能取消',
312
+ jobDone: '后台更新已完成',
313
+ jobFailed: '后台更新失败',
314
+ jobInterrupted: '上次更新没跑完(宿主重启或中断)',
315
+ connLost: '宿主为应用这次改动重载了配置,连接断了一下;列表没更新就刷新页面',
316
+ jobCancel: '取消',
317
+ jobQueued: '已排队:附件已下载校验,会话一空闲就会自动安装({n} 个)',
318
+ updateRolledBack: '已回滚',
319
+ updateFailed: '失败',
320
+ updateNothing: '选中的这些包没有可更新的内容',
321
+ updateUnknown: '未知',
322
+ updatePartial: '{total} 组里 {n} 组失败,其余已完成',
323
+ updateSelectAll: '全选',
324
+ viewLog: '上次日志',
325
+ logEmpty: '还没有更新日志',
326
+ logPath: '日志文件',
327
+ dFunction: '具体功能',
328
+ phaseActive: '运行中',
329
+ phaseFailed: '异常',
330
+ phaseLoading: '加载中',
331
+ phaseOff: '已停止',
332
+ reasonManagement: '由核心统一管理',
333
+ reasonUnaddressable: '无法寻址',
334
+ homepage: '主页',
335
+ unitPlugins: '个插件',
336
+ },
337
+ };
338
+
339
+ // 哪个任务的结果已经报过了。存在 localStorage 里,面板关掉再打开不会重复弹。
340
+ const JOB_SEEN_KEY = 'plugin-rack:seenJob';
341
+ const LANG_KEY = 'plugin-rack:lang';
342
+ const DEFAULT_LANG = 'zh';
343
+
344
+ function storedLang() {
345
+ try {
346
+ const value = window.localStorage.getItem(LANG_KEY);
347
+ return value === 'zh' || value === 'en' ? value : null;
348
+ } catch (error) {
349
+ void error;
350
+ return null;
351
+ }
352
+ }
353
+
354
+ function pickLocale() {
355
+ const stored = storedLang();
356
+ return stored === null ? DEFAULT_LANG : stored;
357
+ }
358
+
359
+ function fill(text, vars) {
360
+ if (vars === undefined) return text;
361
+ return text.replace(/\{(\w+)\}/g, (all, key) =>
362
+ Object.prototype.hasOwnProperty.call(vars, key) ? String(vars[key]) : all,
363
+ );
364
+ }
365
+
366
+ function translator(locale) {
367
+ const table = DICT[locale] || DICT.en;
368
+ return (key, vars) => fill(table[key] !== undefined ? table[key] : DICT.en[key] || key, vars);
369
+ }
370
+
371
+ const DEFAULT_TIMEOUT_MS = 120000;
372
+
373
+ /**
374
+ * 一次失败的写操作该显示什么。
375
+ *
376
+ * 单独抽出来是为了能被真正测到:它原来是三段 if 嵌在 run() 的 catch 里,
377
+ * 只能靠 grep 源码判断"这行还在不在",而 bug(load() 返回 undefined 时
378
+ * 走到 sameEnabledMap 里炸掉)恰恰是 grep 查不出来的。
379
+ *
380
+ * 返回 { kind: null } 表示"什么都不弹"——操作其实生效了。
381
+ */
382
+ function failureOutcome(message, expected, healed) {
383
+ if (expected !== null && expected !== undefined && sameEnabledMap(expected, healed)) {
384
+ return { kind: null };
385
+ }
386
+ if (message === 'unreachable' || message === 'timeout') return { kind: 'warn', reason: 'connLost' };
387
+ return { kind: 'err', text: message };
388
+ }
389
+
390
+ /**
391
+ * 两份列表的「哪一行是开着的」是否一致。
392
+ *
393
+ * 只比 enabled,不比别的字段:重载之后 fiber id、phase、详情缓存都可能变,
394
+ * 但「用户想关掉的那几个确实关掉了」才是判断这次操作成没成的依据。
395
+ */
396
+ function sameEnabledMap(left, right) {
397
+ // 两个都要先用真值判空:`!== null` 拦不住 undefined,而 load() 在失败
398
+ // 分支返回 null、在旧版本里返回 undefined,两种都会到这里来。
399
+ if (!left || !right) return false;
400
+ if (!Array.isArray(left.rows) || !Array.isArray(right.rows)) return false;
401
+ const flat = new Map();
402
+ for (const row of left.rows) flat.set(row.entryId, row.enabled === true);
403
+ for (const row of right.rows) {
404
+ const want = flat.get(row.entryId);
405
+ // 只比两份都在的行:重载期间某些行可能短暂消失,那不是「操作失败」。
406
+ if (want === undefined) continue;
407
+ if (want !== (row.enabled === true)) return false;
408
+ }
409
+ return true;
410
+ }
411
+
412
+ // The Host can legitimately sit on a write for a long time: it waits for an
413
+ // idle session (3 min), runs pnpm (25 min) and, if that fails, restores the
414
+ // manifests and re-syncs node_modules with another pnpm run (25 min).
415
+ // 60 minutes keeps the panel from giving up while the Host is still
416
+ // working — otherwise the UI reports a failure for a run that succeeds.
417
+ const WRITE_TIMEOUT_MS = 60 * 60 * 1000;
418
+
419
+ async function request(path, body, options) {
420
+ const timeoutMs =
421
+ options && typeof options.timeoutMs === 'number' ? options.timeoutMs : DEFAULT_TIMEOUT_MS;
422
+ const init =
423
+ body === undefined
424
+ ? { method: 'GET', headers: { accept: 'application/json' } }
425
+ : {
426
+ method: 'POST',
427
+ headers: {
428
+ 'content-type': 'application/json',
429
+ accept: 'application/json',
430
+ // 和宿主端的写操作闸门配对:面板自己永远带这个头,所以即使
431
+ // 某个环境下浏览器没发 Origin / Sec-Fetch-Site 也照样放行。
432
+ 'x-dsh-plugin-rack': '1',
433
+ },
434
+ body: JSON.stringify(body),
435
+ };
436
+ // Without this a stuck Host call pins busy/pending forever and the only
437
+ // way out is reloading the page.
438
+ const controller = new AbortController();
439
+ init.signal = controller.signal;
440
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
441
+ let response;
442
+ try {
443
+ response = await fetch(API + path, init);
444
+ } catch (error) {
445
+ void error;
446
+ throw new Error(controller.signal.aborted ? 'timeout' : 'unreachable');
447
+ } finally {
448
+ clearTimeout(timer);
449
+ }
450
+ let data = null;
451
+ try {
452
+ data = await response.json();
453
+ } catch (error) {
454
+ void error;
455
+ data = null;
456
+ }
457
+ // 服务端失败时常常带着细节(回滚那段 pnpm 输出在 data.failed.output 里),
458
+ // 之前只取 message 就抛,于是「为什么失败」的信息到不了界面。把整个 data
459
+ // 挂到 error 上,调用方要用就有。
460
+ if (!response.ok || data === null || data.ok !== true) {
461
+ const message =
462
+ (data && data.message) || (response.ok ? 'malformed response' : response.status + ' ' + response.statusText);
463
+ const error = new Error(message);
464
+ if (data !== null && typeof data === 'object') error.data = data;
465
+ throw error;
466
+ }
467
+ return data;
468
+ }
469
+
470
+ /**
471
+ * 把勾选的包名按更新通道分组。
472
+ *
473
+ * 精确版本锁定的依赖由 POST /updates 一次 pnpm 装完;github / 本地压缩包 /
474
+ * 压缩包地址各自有一个 `/extras` 动作,必须逐个跑。分成纯函数是为了能真的
475
+ * 测到——这个分组错了,`file:` 包会被送进 /updates,然后被 eligibleDeps
476
+ * 静默过滤掉,界面既不报错也不装。
477
+ */
478
+ function partitionTargets(names, extrasRows) {
479
+ const byName = new Map();
480
+ for (const row of Array.isArray(extrasRows) ? extrasRows : []) {
481
+ if (row !== null && typeof row === 'object' && typeof row.name === 'string') byName.set(row.name, row);
482
+ }
483
+ const npm = [];
484
+ const extras = [];
485
+ for (const name of Array.isArray(names) ? names : []) {
486
+ const row = byName.get(name);
487
+ if (row === undefined) {
488
+ npm.push(name);
489
+ continue;
490
+ }
491
+ if (row.kind === 'github') extras.push({ name, action: 'repull' });
492
+ else if (row.kind === 'file') extras.push({ name, action: 'file-upgrade' });
493
+ else if (row.kind === 'link') continue; // 软链没有「升级」这回事
494
+ else if (row.kind === 'tarball') extras.push({ name, action: 'pin' });
495
+ // 读不出来源(kind: 'other')的行不猜动作:猜成 pin 会对着一个不是
496
+ // 压缩包地址的 spec 调 /extras,只会换来一句「不是压缩包安装的包」。
497
+ // 它本来也不会被勾选(服务端不给 hasUpdate),这里只是把不确定性说清楚。
498
+ else continue;
499
+ }
500
+ return { npm, extras };
501
+ }
502
+
503
+ /**
504
+ * 把各通道的结果合并成一条给面板看的报告。
505
+ *
506
+ * 关键约束:每一条都必须保留,包括失败的那条。以前这里是
507
+ * `parts.filter(one => one.ok !== true)`,但 request() 在 ok !== true 时
508
+ * 直接抛错,所以那个 filter 永远为空,updatePartial 一次都不会显示;
509
+ * 而且「一个渠道失败就整批中断、后面一个都不跑」也藏在同一处。
510
+ * 现在调用方逐条收集 { ok, ... },这里只负责汇总。
511
+ */
512
+ function mergeUpdateReport(results, messageFor) {
513
+ const list = Array.isArray(results) ? results : [];
514
+ // 没有任何渠道真的跑过(比如勾的全是软链包):返回空,让调用方知道
515
+ // 「这次什么都没做」,而不是编一个 ok:true 的空报告。
516
+ if (list.length === 0) return undefined;
517
+ const failed = list.filter((one) => one !== null && typeof one === 'object' && one.ok !== true);
518
+ if (list.length === 1) {
519
+ // 单通道时保留原始字段(log / hotSwap / restart 面板要靠它显示详情),
520
+ // 但失败必须走 collectFailures 归一化 —— /updates 和 /extras 抛错时
521
+ // 返回的是 { ok:false, name, message }(可能还带一个 failed 对象),
522
+ // 渲染端只认数组。
523
+ const only = list[0];
524
+ if (only !== null && typeof only === 'object' && only.ok !== true) {
525
+ return Object.assign({}, only, { failed: collectFailures(only) });
526
+ }
527
+ return only;
528
+ }
529
+ const updated = [];
530
+ for (const one of list) {
531
+ if (one !== null && typeof one === 'object' && Array.isArray(one.updated)) {
532
+ for (const item of one.updated) updated.push(item);
533
+ }
534
+ }
535
+ // 每个包的 from/to 不在 extras 的返回里,改用它的 name + to 也能说明白。
536
+ for (const one of list) {
537
+ if (one === null || typeof one !== 'object') continue;
538
+ if (typeof one.name === 'string' && typeof one.to === 'string') {
539
+ updated.push({ name: one.name, to: one.to });
540
+ }
541
+ }
542
+ // 只要有一个渠道真的热替换成功,代码就是活的,不该再要求重启。
543
+ // 之前 restart 取 `some(one => one.restart === true)`、hotSwap 取最后一条,
544
+ // 于是「npm 渠道热替换成功 + extras 渠道没跑热替换」会被报成"需重启"。
545
+ // hotSwap 是「整批的代码换没换干净」,不能拿某一个渠道的结果代表全部。
546
+ // 各渠道管的是不同的包:npm 那条热替换成功,不代表 extras 那个包的变更
547
+ // 也生效了。界面只看 hotSwap.ok 决定显示「已热替换」还是「需重启」
548
+ // (restart 字段没有任何渲染消费点),所以这里必须是合取:
549
+ // 只有每一个真的做过热替换的渠道都成功,才算整批成功。
550
+ const swapAttempts = list
551
+ .map((one) => (one !== null && typeof one === 'object' ? one.hotSwap : undefined))
552
+ .filter((one) => one !== undefined && one !== null);
553
+ const anySwapFailed = swapAttempts.some((one) => one.ok !== true);
554
+ const allSwapOk = swapAttempts.length > 0 && !anySwapFailed;
555
+ // 任何一个渠道说「要重启」,就得提示重启:那是它自己管的包没换进去。
556
+ const restart =
557
+ list.some((one) => one !== null && typeof one === 'object' && one.restart === true) || anySwapFailed;
558
+ // 失败原因优先展示(它带 reason),否则第一个成功的。
559
+ const hotSwap =
560
+ swapAttempts.find((one) => one.ok !== true) ??
561
+ (swapAttempts.length > 0 ? swapAttempts[0] : undefined);
562
+ const log = list
563
+ .map((one) => (one !== null && typeof one === 'object' ? one.log : undefined))
564
+ .filter((one) => typeof one === 'string');
565
+ return {
566
+ ok: failed.length === 0,
567
+ updated,
568
+ // 多通道也走 collectFailures:它会把每个渠道的 failed.output 展开出来,
569
+ // 而不只看顶层 message。之前这里手写了一遍,正好把服务端回滚时的
570
+ // pnpm 输出吞掉 —— 混了 github / file / tarball 的全选是这个按钮的主场景。
571
+ failed: failed.flatMap((one) => collectFailures(one)),
572
+ restart,
573
+ hotSwap,
574
+ // 部分渠道换了、部分没换:界面上要说「部分需重启」,不能只说成功或失败。
575
+ partialSwap: swapAttempts.length > 1 && allSwapOk === false && swapAttempts.some((one) => one.ok === true),
576
+ log: log.length > 0 ? log[log.length - 1] : undefined,
577
+ message:
578
+ failed.length === 0
579
+ ? undefined
580
+ : messageFor === undefined
581
+ ? undefined
582
+ : messageFor(failed.length, list.length),
583
+ };
584
+ }
585
+
586
+ /**
587
+ * 把更新报告渲染成面板用的几行文本。
588
+ *
589
+ * 单独抽出来是因为这里踩过坑:`failed` 在单通道时是对象、合并后是数组,
590
+ * 而渲染端一律按对象读 `failed.name`,多通道部分失败时详情行变成
591
+ * "undefined: ",恰好把「哪个渠道挂了、为什么」这个最该说的信息丢掉。
592
+ * `from` 同理:extras 的结果只有 name + to,模板写死 item.from 会渲染成
593
+ * "b undefined → 3.0.0"。
594
+ *
595
+ * 返回 { updated: string[], failed: string[] },每一行都已经拼好。
596
+ */
597
+ function renderUpdateReport(report, options) {
598
+ const unknown = options !== undefined && options !== null && typeof options.unknown === 'string'
599
+ ? options.unknown
600
+ : '?';
601
+ const shown = [];
602
+ const failedLines = [];
603
+ if (report === null || report === undefined || typeof report !== 'object') {
604
+ return { updated: shown, failed: failedLines };
605
+ }
606
+ const updated = Array.isArray(report.updated) ? report.updated : [];
607
+ for (const item of updated) {
608
+ if (item === null || typeof item !== 'object') continue;
609
+ const name = typeof item.name === 'string' && item.name !== '' ? item.name : unknown;
610
+ const to = item.to === undefined || item.to === null || item.to === '' ? unknown : String(item.to);
611
+ // 没有 from 时只写目标版本:编一个假的起点比不写更误导。
612
+ const from = item.from === undefined || item.from === null || item.from === '' ? null : String(item.from);
613
+ shown.push(from === null ? name + ' → ' + to : name + ' ' + from + ' → ' + to);
614
+ }
615
+ // failed 可能是数组(合并后的报告)也可能是单个对象(/updates 原样返回)。
616
+ const raw = Array.isArray(report.failed) ? report.failed : report.failed === undefined || report.failed === null ? [] : [report.failed];
617
+ for (const item of raw) {
618
+ if (item === null || typeof item !== 'object') continue;
619
+ const name = typeof item.name === 'string' && item.name !== '' ? item.name : unknown;
620
+ const output = item.output === undefined || item.output === null ? '' : String(item.output);
621
+ failedLines.push(name + ': ' + output.slice(-600));
622
+ }
623
+ return { updated: shown, failed: failedLines };
624
+ }
625
+
626
+ /**
627
+ * 把一个抛出的失败包装成报告条目。
628
+ *
629
+ * request() 现在把整个响应体挂在 error.data 上,因为服务端失败时常常带着
630
+ * 细节 —— 回滚那段 pnpm 输出就放在 data.failed.output 里。之前只留 message,
631
+ * 于是「为什么失败」永远到不了界面。
632
+ */
633
+ function failureEntry(error, fallbackName) {
634
+ const detail =
635
+ error !== null && typeof error === 'object' && error.data !== undefined && error.data !== null
636
+ ? error.data
637
+ : null;
638
+ const base = detail !== null && typeof detail === 'object' ? detail : {};
639
+ const message = error !== null && typeof error === 'object' && typeof error.message === 'string' ? error.message : String(error);
640
+ return Object.assign({}, base, {
641
+ ok: false,
642
+ name: typeof base.name === 'string' && base.name !== '' ? base.name : fallbackName,
643
+ message,
644
+ });
645
+ }
646
+
647
+ /**
648
+ * 从一个失败结果里取出所有该展示的详情行。
649
+ *
650
+ * 这是单/多通道都必须走同一条路的归一化点:服务端失败时会把回滚那段
651
+ * pnpm 输出放在 failed.output 里(failureEntry 会把它展开到顶层 failed
652
+ * 字段),而渠道自己还有一句短 message —— 两者都要保留。
653
+ *
654
+ * 之前单通道分支手写了一遍、多通道分支又手写了一遍,后者只看顶层
655
+ * message/output、不读 one.failed.output,于是「混了 github / file /
656
+ * tarball 的全选」这个主场景把 pnpm 输出吞掉了。抽出来共用,加渠道时
657
+ * 不会再漏一次。
658
+ *
659
+ * 返回 [{ name, output }];数组和单个对象两种形态都吃,空值不产出假行。
660
+ */
661
+ function collectFailures(one) {
662
+ const lines = [];
663
+ if (one === null || one === undefined || typeof one !== 'object') return lines;
664
+ const push = (name, output) => {
665
+ const label = typeof name === 'string' && name !== '' ? name : '';
666
+ const text = output === undefined || output === null ? '' : String(output);
667
+ // 名字和原因都空的条目没有信息量,别在界面上占一行。
668
+ if (label === '' && text === '') return;
669
+ lines.push({ name: label, output: text });
670
+ };
671
+ // 先放服务端带回来的详情(它们更具体,通常带 pnpm 输出)。
672
+ const nested = one.failed;
673
+ if (Array.isArray(nested)) {
674
+ for (const item of nested) {
675
+ if (item === null || item === undefined || typeof item !== 'object') continue;
676
+ push(item.name, item.output ?? item.message);
677
+ }
678
+ } else if (nested !== null && nested !== undefined && typeof nested === 'object') {
679
+ push(nested.name, nested.output ?? nested.message);
680
+ }
681
+ // 再放这一层自己的短消息。名字缺失时用顶层 name 补上,否则用户只看到
682
+ // 一句无所指的「已回滚」。
683
+ const ownName = typeof one.name === 'string' && one.name !== '' ? one.name : '';
684
+ const ownMessage = one.message === undefined || one.message === null ? '' : String(one.message);
685
+ if (ownMessage !== '') {
686
+ // 嵌套详情已经用同样的文本占过位就不再重复。
687
+ const already = lines.some((line) => line.output === ownMessage);
688
+ if (!already) push(ownName, ownMessage);
689
+ } else if (ownName !== '' && lines.length === 0) {
690
+ push(ownName, '');
691
+ }
692
+ // 同一行被重复收集过就只留一条(嵌套详情和顶层消息常常是同一句话)。
693
+ const seen = new Set();
694
+ return lines.filter((line) => {
695
+ const key = line.name + '\u0000' + line.output;
696
+ if (seen.has(key)) return false;
697
+ seen.add(key);
698
+ return true;
699
+ });
700
+ }
701
+
702
+ const CSS = [
703
+ '.pd-root{display:flex;flex-direction:column;height:100%;min-height:0;box-sizing:border-box;',
704
+ 'background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary)}',
705
+ '.pd-scroll{flex:1 1 auto;min-height:0;overflow-y:auto;overflow-x:hidden}',
706
+ '.pd-inner{max-width:960px;margin:0 auto;padding:20px 24px 32px;box-sizing:border-box}',
707
+ '.pd-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:14px}',
708
+ '.pd-title{font-size:15px;font-weight:600;line-height:22px}',
709
+ '.pd-sub{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary);margin-top:2px}',
710
+ '.pd-head-act{display:flex;flex-direction:column;align-items:flex-end;gap:10px;flex:none}',
711
+ '.pd-close{appearance:none;font:inherit;font-size:15px;line-height:15px;width:24px;height:24px;padding:0;',
712
+ 'margin-left:14px;border-radius:7px;cursor:pointer;flex:none;',
713
+ 'border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);',
714
+ 'color:var(--dsw-alias-state-error-primary)}',
715
+ '.pd-close:hover{background:var(--dsw-alias-interactive-bg-hover-danger);',
716
+ 'border-color:var(--dsw-alias-state-error-primary)}',
717
+ '.pd-lang{margin-left:auto;flex:none}',
718
+ '.pd-stats{align-items:center}',
719
+ '.pd-stat{appearance:none;font:inherit;font-size:12px;color:var(--dsw-alias-label-secondary);',
720
+ 'padding:2px 6px;margin:0 -2px;border:1px solid transparent;border-radius:6px;background:transparent;cursor:pointer}',
721
+ '.pd-stat:hover{background:var(--dsw-alias-interactive-bg-hover)}',
722
+ '.pd-stat-on{border-color:var(--dsw-alias-border-l3);background:var(--dsw-alias-bg-layer-2);',
723
+ 'color:var(--dsw-alias-label-primary)}',
724
+ '.pd-link{color:var(--dsw-alias-state-business-primary);text-decoration:none}',
725
+ '.pd-link:hover{text-decoration:underline}',
726
+ '.pd-dot{color:var(--dsw-alias-label-tertiary)}',
727
+ '.pd-fact-block{align-items:flex-start}',
728
+ '.pd-desc{white-space:normal;word-break:normal;color:var(--dsw-alias-label-secondary)}',
729
+ '.pd-note{margin-top:4px;font-size:11px;line-height:17px;color:var(--dsw-alias-state-warn-label)}',
730
+ '.pd-note-inline{margin:0 0 0 8px;display:inline-block;max-width:520px;vertical-align:middle}',
731
+ '.pd-fileacts{display:inline-flex;align-items:center;gap:6px;flex:none}',
732
+ '.pd-fileplan{flex:1 0 100%;margin-top:2px}',
733
+ '.pd-uninstall{display:flex;align-items:center;flex-wrap:wrap;gap:2px}',
734
+ '.pd-install-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
735
+ '.pd-install-label{font-size:12px;color:var(--dsw-alias-label-secondary);flex:none}',
736
+ '.pd-install-input{flex:1 1 260px;max-width:420px}',
737
+ '.pd-cmd{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;word-break:break-all;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:4px 8px}',
738
+ '.pd-jobbar{display:flex;align-items:center;gap:8px;padding:6px 10px;border-radius:8px;background:var(--dsw-alias-bg-secondary,#f5f5f5);font-size:12px;color:var(--dsw-alias-label-secondary,#666)}',
739
+ '.pd-jobbar-cancel{margin-left:auto;border:0;background:transparent;padding:0 4px;font-size:12px;',
740
+ 'color:var(--dsw-alias-label-secondary,#666);cursor:pointer}',
741
+ '.pd-jobbar-cancel:hover{color:var(--dsw-alias-label-primary,#111)}',
742
+ '.pd-toolbar{position:sticky;top:0;z-index:3;display:flex;flex-direction:column;gap:8px;',
743
+ 'margin:0 -24px 14px;padding:10px 24px;background:var(--dsw-alias-bg-base);',
744
+ 'border-bottom:1px solid var(--dsw-alias-border-l1)}',
745
+ '.pd-bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center}',
746
+ '.pd-bar-label{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}',
747
+ '.pd-sep{width:1px;height:18px;background:var(--dsw-alias-border-l2);margin:0 2px}',
748
+ '.pd-btn{appearance:none;font:inherit;font-size:13px;line-height:20px;padding:5px 12px;border-radius:8px;cursor:pointer;',
749
+ 'border:1px solid var(--dsw-alias-border-l3);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}',
750
+ '.pd-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
751
+ '.pd-btn:disabled{opacity:.5;cursor:not-allowed}',
752
+ '.pd-btn-sm{display:inline-flex;align-items:center;justify-content:center;',
753
+ 'font-size:12px;line-height:16px;min-height:26px;padding:0 10px;border-radius:7px}',
754
+ '.pd-btn-quiet{border-color:transparent;background:transparent;color:var(--dsw-alias-label-secondary)}',
755
+ '.pd-btn-quiet:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
756
+ '.pd-btn-primary{border-color:transparent;background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground)}',
757
+ '.pd-btn-primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover)}',
758
+ '.pd-btn-danger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--dsw-alias-state-error-primary)}',
759
+ '.pd-input{width:200px;max-width:100%;font:inherit;font-size:13px;padding:5px 10px;border-radius:8px;outline:none;',
760
+ 'border:1px solid var(--dsw-alias-border-l3);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}',
761
+ '.pd-input:focus{border-color:var(--dsw-alias-state-business-primary)}',
762
+ '.pd-stats{display:flex;flex-wrap:wrap;gap:14px;font-size:12px;color:var(--dsw-alias-label-secondary);margin-bottom:4px}',
763
+ '.pd-stats b{font-weight:600;color:var(--dsw-alias-label-primary)}',
764
+ // One grid template shared by the column-head line and every row, so the
765
+ // labels sit exactly over their controls and no row can drift sideways.
766
+ '.pd-group{--pd-cols:12px minmax(0,1fr) 40px 56px 28px 20px;',
767
+ 'margin-top:16px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;',
768
+ 'background:var(--dsw-alias-bg-layer-1);overflow:hidden}',
769
+ // Group header: title line, then a column-head line on the row grid.
770
+ '.pd-group-head{display:flex;flex-direction:column;gap:5px;padding:9px 14px 7px;',
771
+ '.pd-title-row{display:flex;align-items:center;gap:8px;min-width:0}',
772
+ '.pd-colhead{display:grid;grid-template-columns:var(--pd-cols);gap:10px;align-items:center}',
773
+ '.pd-colhead-label{font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary);',
774
+ 'text-align:center;white-space:nowrap}',
775
+ 'background:var(--dsw-alias-bg-layer-2);border-bottom:1px solid var(--dsw-alias-border-l2)}',
776
+ '.pd-group-head[data-collapsed="true"]{border-bottom:none}',
777
+ '.pd-chevron{appearance:none;border:none;background:transparent;padding:0;cursor:pointer;',
778
+ 'color:var(--dsw-alias-label-tertiary);display:inline-flex;align-items:center;justify-content:center;',
779
+ 'width:16px;height:16px;transition:transform .15s ease}',
780
+ '.pd-chevron[data-collapsed="true"]{transform:rotate(-90deg)}',
781
+ '.pd-group-title{font-size:13px;font-weight:600;line-height:20px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:40%}',
782
+ '.pd-group-count{font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}',
783
+ '.pd-group-note{font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary);margin-left:2px}',
784
+ // Bulk buttons sit in the name-column cell of the column-head line.
785
+ '.pd-group-bulk{display:flex;align-items:center;gap:8px;min-width:0;overflow-x:auto}',
786
+ '.pd-group-bulk .pd-btn{flex:none}',
787
+ '.pd-group-bulk-sep{width:1px;height:16px;background:var(--dsw-alias-border-l2);margin:0 2px;flex:none}',
788
+ // Small inline uninstall button at the end of a plugin row: grey trash
789
+ // icon, turns red on hover / when it is in the click-again confirm state.
790
+ '.pd-row-uninstall{appearance:none;border:none;background:transparent;padding:0;cursor:pointer;',
791
+ 'display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:6px;',
792
+ 'color:var(--dsw-alias-label-tertiary);flex:none;justify-self:center}',
793
+ '.pd-row-uninstall:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger);',
794
+ 'color:var(--dsw-alias-state-error-primary)}',
795
+ '.pd-row:hover .pd-row-uninstall{color:var(--dsw-alias-label-secondary)}',
796
+ '.pd-row-uninstall:disabled{opacity:.45;cursor:not-allowed}',
797
+ '.pd-row-uninstall-confirm{color:var(--dsw-alias-state-error-primary);',
798
+ 'background:var(--dsw-alias-interactive-bg-hover-danger)}',
799
+ '.pd-confirm{display:flex;align-items:center;justify-content:space-between;gap:14px;flex-wrap:wrap;',
800
+ 'margin:0 0 10px;padding:10px 14px;border:1px solid var(--dsw-alias-border-l3);',
801
+ 'border-radius:10px;background:var(--dsw-alias-bg-layer-2)}',
802
+ '.pd-confirm-text{display:flex;flex-direction:column;gap:3px;font-size:12px;line-height:18px}',
803
+ '.pd-confirm-act{display:flex;align-items:center;gap:8px;flex:none}',
804
+ '.pd-item{border-bottom:1px solid var(--dsw-alias-border-l1)}',
805
+ '.pd-item:last-child{border-bottom:none}',
806
+ '.pd-row{display:grid;grid-template-columns:var(--pd-cols);',
807
+ 'align-items:center;gap:10px;',
808
+ 'padding:7px 14px;cursor:pointer}',
809
+ '.pd-cell{display:flex;align-items:center;min-width:0}',
810
+ '.pd-cell-power,.pd-cell-mode{justify-self:center}',
811
+ // 行最左的状态点:颜色即语义,title 写具体状态文本,失败时整行冒红。
812
+ '.pd-dot-status{width:8px;height:8px;border-radius:50%;flex:none;justify-self:center;',
813
+ 'background:var(--dsw-alias-border-l3)}',
814
+ '.pd-dot-running{background:var(--dsw-alias-state-success-primary)}',
815
+ '.pd-dot-loading{background:var(--dsw-alias-state-warn-primary);animation:pd-dot-pulse 1.2s ease-in-out infinite}',
816
+ '.pd-dot-failed{background:var(--dsw-alias-state-error-primary)}',
817
+ '.pd-dot-off{background:var(--dsw-alias-border-l3)}',
818
+ '.pd-dot-readonly{background:transparent;border:1.5px solid var(--dsw-alias-border-l3);box-sizing:border-box}',
819
+ '@keyframes pd-dot-pulse{0%,100%{opacity:1}50%{opacity:.35}}',
820
+ // 失败行整行染一层薄红,不用展开就能看见。
821
+ '.pd-row-failed{background:var(--dsw-alias-state-error-tertiary, rgba(220,60,60,.06))}',
822
+ '.pd-row-failed:hover{background:var(--dsw-alias-interactive-bg-hover-danger)}',
823
+ // 失败 tag 跟在名字后面,比详情里的字段更早进入视野。
824
+ '.pd-tag-fail{font-size:10px;line-height:16px;padding:0 6px;border-radius:6px;white-space:nowrap;',
825
+ 'border:1px solid transparent;background:var(--dsw-alias-state-error-tertiary, rgba(220,60,60,.12));',
826
+ 'color:var(--dsw-alias-state-error-primary)}',
827
+ '.pd-row:hover{background:var(--dsw-alias-interactive-bg-hover)}',
828
+ '.pd-row-open{background:var(--dsw-alias-interactive-bg-active)}',
829
+ '.pd-version{font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary);flex:none}',
830
+ '.pd-name-act{flex:none;display:flex;align-items:center;align-self:center;margin-left:2px}',
831
+ '.pd-upbtn-spin{flex:none;margin-right:1px}',
832
+ '.pd-upbtn-open{text-decoration:none}',
833
+ '.pd-src{flex:none;font-size:10px;line-height:16px;padding:0 6px;border-radius:6px;white-space:nowrap;',
834
+ 'border:1px solid var(--dsw-alias-border-l3);color:var(--dsw-alias-label-tertiary)}',
835
+ '.pd-upbtn{display:inline-flex;align-items:center;gap:5px;flex:none;font-family:inherit;',
836
+ 'font-size:11px;line-height:16px;padding:1px 7px;border-radius:8px;white-space:nowrap;',
837
+ 'font-variant-numeric:tabular-nums;cursor:pointer;',
838
+ 'border:1px solid var(--dsw-alias-state-warn-primary);background:var(--dsw-alias-state-warn-tertiary);',
839
+ 'color:var(--dsw-alias-state-warn-label)}',
840
+ '.pd-upbtn:hover{background:var(--dsw-alias-state-warn-primary);',
841
+ 'color:var(--dsw-alias-label-primary-foreground)}',
842
+ '.pd-upbtn[disabled]{cursor:default;opacity:.45}',
843
+ '.pd-upbtn-arrow{font-size:10px;line-height:16px;opacity:.85}',
844
+ '.pd-upbtn-src{font-size:10px;line-height:16px;opacity:.7;text-transform:lowercase}',
845
+ '.pd-tabs{display:flex;gap:2px;padding:3px;border-radius:11px;background:var(--dsw-alias-bg-layer-2);',
846
+ 'border:1px solid var(--dsw-alias-border-l2);flex-wrap:wrap}',
847
+ '.pd-tab{appearance:none;font:inherit;font-size:13px;line-height:20px;padding:3px 12px;border-radius:8px;',
848
+ 'border:none;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;',
849
+ 'display:inline-flex;align-items:center;gap:6px}',
850
+ '.pd-tab:hover{background:var(--dsw-alias-interactive-bg-hover)}',
851
+ '.pd-tab-on{background:var(--dsw-alias-border-l3);color:var(--dsw-alias-label-primary);font-weight:600;',
852
+ 'box-shadow:0 1px 2px rgba(0,0,0,.14)}',
853
+ '.pd-tab-on:hover{background:var(--dsw-alias-border-l3)}',
854
+ '.pd-tab-n{font-size:12px;color:var(--dsw-alias-label-tertiary)}',
855
+ '.pd-tab-on .pd-tab-n{color:inherit;opacity:.7}',
856
+ '.pd-desc-k{font-size:11px;line-height:17px;color:var(--dsw-alias-label-tertiary)}',
857
+ '.pd-row-chev{appearance:none;border:none;padding:0;background:transparent;color:var(--dsw-alias-label-tertiary);',
858
+ 'display:flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:6px;',
859
+ 'cursor:pointer;transition:transform .15s ease;justify-self:center}',
860
+ '.pd-row-chev[data-collapsed="true"]{transform:rotate(-90deg)}',
861
+ '.pd-row-chev:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}',
862
+ '.pd-detail{display:flex;flex-direction:column;gap:2px;padding:2px 14px 10px;background:var(--dsw-alias-bg-layer-1)}',
863
+ '.pd-fact{display:grid;grid-template-columns:104px minmax(0,1fr);gap:10px;font-size:11px;line-height:17px}',
864
+ '.pd-fact-k{color:var(--dsw-alias-label-tertiary)}',
865
+ '.pd-fact-v{color:var(--dsw-alias-label-secondary);word-break:break-all}',
866
+ '.pd-name{display:flex;align-items:baseline;gap:8px;min-width:0}',
867
+ '.pd-name-main{font-size:13px;line-height:19px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:0 1 auto}',
868
+ '.pd-name-sub{font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary);white-space:nowrap;',
869
+ 'overflow:hidden;text-overflow:ellipsis;flex:0 1 auto;min-width:0}',
870
+ '.pd-tags{display:flex;gap:4px;flex:0 0 auto;align-items:center}',
871
+ '.pd-tag{font-size:10px;line-height:16px;padding:0 6px;border-radius:6px;white-space:nowrap;',
872
+ 'border:1px solid var(--dsw-alias-border-l3);color:var(--dsw-alias-label-tertiary)}',
873
+ '.pd-tag-err{border-color:transparent;background:var(--dsw-alias-state-warn-tertiary);color:var(--dsw-alias-state-error-primary)}',
874
+ '.pd-tag-warn{border-color:transparent;background:var(--dsw-alias-state-warn-tertiary);color:var(--dsw-alias-state-warn-label)}',
875
+ '.pd-tag-busy{display:inline-flex;align-items:center;gap:5px}',
876
+ '.pd-spin{display:inline-block;width:9px;height:9px;box-sizing:border-box;border-radius:50%;',
877
+ 'border:1.5px solid var(--dsw-alias-state-warn-label);border-top-color:transparent;',
878
+ 'animation:pd-spin .7s linear infinite}',
879
+ '@keyframes pd-spin{to{transform:rotate(360deg)}}',
880
+ '.pd-switch{position:relative;appearance:none;border:none;padding:0;width:36px;height:20px;border-radius:999px;cursor:pointer;',
881
+ 'background:var(--dsw-alias-border-l4);transition:background .15s ease}',
882
+ '.pd-switch-on{background:var(--dsw-alias-state-success-primary)}',
883
+ '.pd-switch:disabled{opacity:.45;cursor:not-allowed}',
884
+ '.pd-knob{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;',
885
+ 'background:var(--dsw-alias-label-primary-foreground);transition:transform .15s ease}',
886
+ '.pd-switch-on .pd-knob{transform:translateX(16px)}',
887
+ // A 10px ring built from two stacked circles: a faint full circle plus a
888
+ // bright arc rotating on top. Using an explicit base ring avoids the
889
+ // "broken smile" look a single top-bordered circle has at this size.
890
+ '.pd-switch-wait{display:inline-flex;align-items:center;justify-content:center;',
891
+ 'cursor:progress;transition:background .15s ease;--pd-ink:var(--dsw-alias-label-primary)}',
892
+ '.pd-switch-wait.pd-switch-on{--pd-ink:var(--dsw-alias-label-primary-foreground)}',
893
+ '.pd-ring{position:relative;width:10px;height:10px;box-sizing:border-box}',
894
+ '.pd-ring-base{position:absolute;inset:0;box-sizing:border-box;border-radius:50%;',
895
+ 'border:1.5px solid var(--pd-ink);opacity:.22}',
896
+ '.pd-ring-arc{position:absolute;inset:0;box-sizing:border-box;border-radius:50%;',
897
+ 'border:1.5px solid transparent;border-top-color:var(--pd-ink);',
898
+ 'animation:pd-ring .7s linear infinite}',
899
+ '@keyframes pd-ring{to{transform:rotate(360deg)}}',
900
+ '.pd-mode{appearance:none;border:1px solid var(--dsw-alias-border-l3);background:transparent;border-radius:6px;',
901
+ 'font:inherit;font-size:11px;line-height:16px;min-width:46px;height:22px;padding:0 6px;cursor:pointer;white-space:nowrap;',
902
+ 'display:inline-flex;align-items:center;justify-content:center;',
903
+ 'color:var(--dsw-alias-label-tertiary)}',
904
+ '.pd-mode:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
905
+ '.pd-mode:disabled{opacity:.5;cursor:not-allowed}',
906
+ '.pd-mode-auto{border-color:transparent;background:var(--dsw-alias-border-l3);color:var(--dsw-alias-label-primary)}',
907
+ '.pd-mode-auto:hover:not(:disabled){background:var(--dsw-alias-border-l3)}',
908
+ '.pd-seg{display:inline-flex;border:1px solid var(--dsw-alias-border-l3);border-radius:8px;overflow:hidden}',
909
+ '.pd-seg-item{appearance:none;border:none;background:transparent;font:inherit;font-size:12px;line-height:18px;padding:3px 10px;cursor:pointer;',
910
+ 'color:var(--dsw-alias-label-tertiary)}',
911
+ '.pd-seg-item:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
912
+ '.pd-seg-item:disabled{opacity:.5;cursor:not-allowed}',
913
+ '.pd-seg-on{background:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-label-primary-foreground)}',
914
+ '.pd-empty{padding:28px;text-align:center;font-size:13px;color:var(--dsw-alias-label-tertiary)}',
915
+ '.pd-toast{display:flex;gap:8px;font-size:12px;line-height:18px;padding:8px 12px;border-radius:8px;',
916
+ 'border:1px solid var(--dsw-alias-border-l3);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary)}',
917
+ '.pd-toast-ok{color:var(--dsw-alias-state-success-primary)}',
918
+ '.pd-toast-busy{display:flex;align-items:center;gap:8px;color:var(--dsw-alias-label-secondary)}',
919
+ '.pd-toast-warn{color:var(--dsw-alias-state-warn-primary)}',
920
+ '.pd-toast-err{color:var(--dsw-alias-state-error-primary)}',
921
+ '.pd-foot{font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary);word-break:break-all;margin-top:14px}',
922
+ '.pd-updates{display:flex;flex-direction:column;gap:8px;padding:2px 0}',
923
+ '.pd-update-bar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}',
924
+ '.pd-hint{font-size:12px;line-height:16px;color:var(--dsw-alias-label-tertiary)}',
925
+ '.pd-update-row{display:flex;align-items:center;gap:10px;padding:7px 10px;border-radius:9px;cursor:pointer}',
926
+ // 已经是最新的行:压暗一档,让"需要动手的"和"不用管的"一眼分开。
927
+ '.pd-update-row-done{opacity:.55;cursor:default}',
928
+ '.pd-update-row-done:hover{opacity:.8}',
929
+ '.pd-update-row:hover{background:var(--dsw-alias-interactive-bg-hover)}',
930
+ '.pd-update-name{flex:1;min-width:0;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
931
+ '.pd-update-ver{flex:none;font-size:12px;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}',
932
+ '.pd-check{flex:none;width:15px;height:15px;margin:0;accent-color:var(--dsw-alias-state-business-primary);cursor:pointer}',
933
+ '.pd-update-log{padding:10px 12px;border-radius:9px;font-size:12px;line-height:18px;background:var(--dsw-alias-bg-layer-2)}',
934
+ '.pd-update-log-ok{border:1px solid var(--dsw-alias-state-success-primary)}',
935
+ '.pd-update-log-bad{border:1px solid var(--dsw-alias-state-error-primary)}',
936
+ '.pd-update-list{margin-top:6px;display:flex;flex-direction:column;gap:2px}',
937
+ '.pd-update-item{font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary)}',
938
+ '.pd-update-log-out{margin-top:6px;padding-top:6px;border-top:1px solid var(--dsw-alias-border-l3);font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary);white-space:pre-wrap;word-break:break-all;max-height:140px;overflow:auto}',
939
+ ].join('');
940
+
941
+ function SidebarIcon(props) {
942
+ const size = props && typeof props.size === 'number' ? props.size : 18;
943
+ return h(
944
+ 'svg',
945
+ { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', 'aria-hidden': true },
946
+ h('rect', {
947
+ x: 2.5,
948
+ y: 3.5,
949
+ width: 19,
950
+ height: 17,
951
+ rx: 3.5,
952
+ stroke: 'currentColor',
953
+ strokeWidth: 1.6,
954
+ }),
955
+ h('rect', { x: 6, y: 7.6, width: 7, height: 2, rx: 1, fill: 'currentColor' }),
956
+ h('rect', { x: 6, y: 14.4, width: 7, height: 2, rx: 1, fill: 'currentColor' }),
957
+ h('rect', { x: 15, y: 6.8, width: 5, height: 3.6, rx: 1.8, fill: 'currentColor' }),
958
+ h('rect', { x: 15, y: 13.6, width: 5, height: 3.6, rx: 1.8, fill: 'currentColor', opacity: 0.35 }),
959
+ );
960
+ }
961
+
962
+ function PowerSwitch(props) {
963
+ // While the host unloads and loads the plugin the switch keeps its track
964
+ // and shows a thin ring spinning in the middle. The ring uses the track's
965
+ // own contrast (dark on the grey track, white on the green one), so it
966
+ // stays legible in both states instead of glowing an unrelated colour.
967
+ if (props.pending === true) {
968
+ return h(
969
+ 'span',
970
+ {
971
+ className: 'pd-switch pd-switch-wait' + (props.checked ? ' pd-switch-on' : ''),
972
+ role: 'status',
973
+ 'aria-label': props.label,
974
+ },
975
+ h(
976
+ 'span',
977
+ { className: 'pd-ring' },
978
+ h('span', { className: 'pd-ring-base' }),
979
+ h('span', { className: 'pd-ring-arc' }),
980
+ ),
981
+ );
982
+ }
983
+ return h(
984
+ 'button',
985
+ {
986
+ type: 'button',
987
+ role: 'switch',
988
+ 'aria-checked': props.checked,
989
+ 'aria-label': props.label,
990
+ disabled: props.disabled,
991
+ className: 'pd-switch' + (props.checked ? ' pd-switch-on' : ''),
992
+ onClick: () => props.onChange(!props.checked),
993
+ },
994
+ h('span', { className: 'pd-knob' }),
995
+ );
996
+ }
997
+
998
+ function ModeControl(props) {
999
+ const t = props.t;
1000
+ return h(
1001
+ 'button',
1002
+ {
1003
+ type: 'button',
1004
+ role: 'switch',
1005
+ 'aria-checked': props.auto,
1006
+ 'aria-label': t('colMode') + ': ' + (props.auto ? t('modeAuto') : t('modeManual')),
1007
+ disabled: props.disabled,
1008
+ className: 'pd-mode' + (props.auto ? ' pd-mode-auto' : ''),
1009
+ onClick: () => props.onChange(!props.auto),
1010
+ },
1011
+ props.auto ? t('modeAuto') : t('modeManual'),
1012
+ );
1013
+ }
1014
+
1015
+ function makeDashboard(ctx) {
1016
+ function useLocale() {
1017
+ const [locale, setLocale] = React.useState(() => pickLocale());
1018
+ const setLang = React.useCallback((next) => {
1019
+ try {
1020
+ window.localStorage.setItem(LANG_KEY, next);
1021
+ } catch (error) {
1022
+ void error;
1023
+ }
1024
+ setLocale(next);
1025
+ }, []);
1026
+ return [locale, setLang];
1027
+ }
1028
+
1029
+ function Dashboard() {
1030
+ const [locale, setLang] = useLocale();
1031
+ const t = React.useMemo(() => translator(locale), [locale]);
1032
+
1033
+ const [data, setData] = React.useState(null);
1034
+ const [error, setError] = React.useState(null);
1035
+ const [busy, setBusy] = React.useState(false);
1036
+ const [pending, setPending] = React.useState({});
1037
+ const [bulkJob, setBulkRun] = React.useState(null);
1038
+ const [toast, setToast] = React.useState(null);
1039
+ const [filter, setFilter] = React.useState('all');
1040
+ const [query, setQuery] = React.useState('');
1041
+ const [tab, setTab] = React.useState('plugins');
1042
+ const [collapsed, setCollapsed] = React.useState({ 'plugins': false, 'core': true, 'core-off': true, 'failed': false });
1043
+ const [expanded, setExpanded] = React.useState({});
1044
+ const [details, setDetails] = React.useState({});
1045
+ const [updates, setUpdates] = React.useState(null);
1046
+ const [updatesBusy, setUpdatesBusy] = React.useState('');
1047
+ const [picked, setPicked] = React.useState({});
1048
+ const [updateLog, setUpdateLog] = React.useState(null);
1049
+ const [extras, setExtras] = React.useState(null);
1050
+ const [extrasBusy, setExtrasBusy] = React.useState('');
1051
+ const [logText, setLogText] = React.useState(null);
1052
+ const [removable, setRemovable] = React.useState(null);
1053
+ const [removableError, setRemovableError] = React.useState('');
1054
+ const [confirmUninstall, setConfirmUninstall] = React.useState('');
1055
+ const [installSpec, setInstallSpec] = React.useState('');
1056
+ const [installBusy, setInstallBusy] = React.useState('');
1057
+ const [installInfo, setInstallInfo] = React.useState(null);
1058
+ const [installLog, setInstallLog] = React.useState(null);
1059
+ const [bulkAsk, setBulkAsk] = React.useState(null);
1060
+ // 后台更新任务:更新由宿主执行,面板关掉之后它还在跑。回到这里时把
1061
+ // 进度、以及「跑完了没、成没成」接回来,而不是让人去翻日志。
1062
+ const [job, setJob] = React.useState(null);
1063
+ // 已经下好、只等会话空闲就会自己装上的那些更新。
1064
+ const [queued, setQueued] = React.useState([]);
1065
+
1066
+ // 展开过的行,详情只取一次并缓存;但列表一刷新(装了一个、卸了一个、
1067
+ // 升级了一个),缓存里那份的版本号与描述就全过时了,所以 load() 会把缓存
1068
+ // 整份清掉,还展开着的行由下面的 effect 重新取一次。
1069
+ const detailBusy = React.useRef({});
1070
+
1071
+ const fetchDetail = React.useCallback(async (row) => {
1072
+ if (detailBusy.current[row.entryId] === true) return;
1073
+ detailBusy.current[row.entryId] = true;
1074
+ setDetails((prev) => ({ ...prev, [row.entryId]: { status: 'loading' } }));
1075
+ try {
1076
+ const answer = await request('/detail?entryId=' + encodeURIComponent(row.entryId));
1077
+ setDetails((prev) => ({ ...prev, [row.entryId]: { status: 'ready', data: answer } }));
1078
+ } catch (error) {
1079
+ setDetails((prev) => ({
1080
+ ...prev,
1081
+ [row.entryId]: {
1082
+ status: 'error',
1083
+ message: String(error && error.message ? error.message : error),
1084
+ },
1085
+ }));
1086
+ } finally {
1087
+ delete detailBusy.current[row.entryId];
1088
+ }
1089
+ }, []);
1090
+
1091
+ const openDetail = React.useCallback(
1092
+ (row) => {
1093
+ const next = expanded[row.entryId] !== true;
1094
+ setExpanded((prev) => ({ ...prev, [row.entryId]: next }));
1095
+ if (next) fetchDetail(row);
1096
+ },
1097
+ [expanded, fetchDetail],
1098
+ );
1099
+
1100
+ const load = React.useCallback(async () => {
1101
+ try {
1102
+ const next = await request('/state');
1103
+ setData(next);
1104
+ setError(null);
1105
+ // 上面那份列表变了,之前缓存的详情就可能全是旧的。
1106
+ setDetails({});
1107
+ // 返回新快照:调用方要拿它和「操作成功应有的样子」比对,判断一次
1108
+ // 失败是不是假失败。返回 undefined 会让那个判断在拿 null 判空之后
1109
+ // 直接读到 undefined.rows。
1110
+ return next;
1111
+ } catch (failure) {
1112
+ setError(failure instanceof Error ? failure.message : String(failure));
1113
+ return null;
1114
+ }
1115
+ }, []);
1116
+
1117
+ React.useEffect(() => {
1118
+ load();
1119
+ }, [load]);
1120
+
1121
+ // load() 清掉了详情缓存,把仍然展开着的行重新取一遍,别让它们停在空白上。
1122
+ // 依赖里带上 details,是为了在“缓存被清空”的那一次渲染后立刻补回来。
1123
+ React.useEffect(() => {
1124
+ if (data === null) return;
1125
+ for (const entryId of Object.keys(expanded)) {
1126
+ if (expanded[entryId] !== true) continue;
1127
+ if (details[entryId] !== undefined) continue;
1128
+ const row = data.rows.find((candidate) => candidate.entryId === entryId);
1129
+ if (row !== undefined) fetchDetail(row);
1130
+ }
1131
+ }, [expanded, details, data, fetchDetail]);
1132
+
1133
+ const roster = data && Array.isArray(data.roster) ? data.roster : [];
1134
+ const isAuto = (row) => row.patchId !== null && roster.indexOf(row.patchId) >= 0;
1135
+
1136
+ const run = React.useCallback(
1137
+ async (label, optimistic, call, key) => {
1138
+ if (key === undefined) setBusy(true);
1139
+ else setPending((prev) => ({ ...prev, [key]: true }));
1140
+ setToast(null);
1141
+ // 记下「操作成功的话,列表应该长成什么样」。catch 里拿它判断这次
1142
+ // 失败是不是假失败:宿主重载配置会重置连接,但改动已经生效了。
1143
+ //
1144
+ // 用当前这次渲染里的 data 直接算,不靠 setData 的 updater 被调用的
1145
+ // 时机——updater 是 React 之后才跑的,在它里面赋值等于赌调度顺序。
1146
+ let expected = null;
1147
+ if (optimistic) {
1148
+ if (data !== null) expected = optimistic(data);
1149
+ setData((prev) => (prev === null ? prev : optimistic(prev)));
1150
+ }
1151
+ try {
1152
+ const next = await call();
1153
+ if (next !== null && typeof next === 'object' && next.patch === true) {
1154
+ setData((prev) => {
1155
+ if (prev === null) return prev;
1156
+ return {
1157
+ ...prev,
1158
+ rows: prev.rows.map((candidate) =>
1159
+ candidate.entryId === next.row.entryId ? next.row : candidate,
1160
+ ),
1161
+ roster: next.roster,
1162
+ };
1163
+ });
1164
+ const outcome = next.result;
1165
+ if (outcome && outcome.notice !== undefined) {
1166
+ setToast({ kind: 'warn', text: outcome.notice });
1167
+ } else if (outcome && outcome.ok === false) {
1168
+ setToast({ kind: 'err', text: label + ': ' + (outcome.message ?? 'failed') });
1169
+ }
1170
+ } else {
1171
+ setData(next);
1172
+ }
1173
+ return next;
1174
+ } catch (failure) {
1175
+ // 关掉/打开一个插件会让宿主重载配置,连接被重置是常见的正常收尾,
1176
+ // 操作其实已经生效了。先自愈再判断:如果重新读回来的状态和乐观更新
1177
+ // 预测的一致,就别弹红错——用户实测过「明明成功了却报 unreachable」。
1178
+ const message = failure instanceof Error ? failure.message : String(failure);
1179
+ const healed = await load();
1180
+ const verdict = failureOutcome(message, expected, healed);
1181
+ if (verdict.kind === 'warn') {
1182
+ setToast({ kind: 'warn', text: label + ': ' + t(verdict.reason) });
1183
+ } else if (verdict.kind === 'err') {
1184
+ setToast({ kind: 'err', text: label + ': ' + verdict.text });
1185
+ }
1186
+ // verdict.kind === null:状态已经对上了,静默收尾。
1187
+ return null;
1188
+ } finally {
1189
+ if (key === undefined) setBusy(false);
1190
+ else
1191
+ setPending((prev) => {
1192
+ const next = { ...prev };
1193
+ delete next[key];
1194
+ return next;
1195
+ });
1196
+ }
1197
+ },
1198
+ // data 进了依赖:run 现在要读当前快照来算乐观预期,依赖数组不带上
1199
+ // 它会一直闭包到第一次渲染时那份(通常是 null),导致判断永远跳过。
1200
+ [load, data],
1201
+ );
1202
+
1203
+ const togglePower = (row) =>
1204
+ run(
1205
+ row.name,
1206
+ (prev) => ({
1207
+ ...prev,
1208
+ rows: prev.rows.map((candidate) =>
1209
+ candidate.entryId === row.entryId ? { ...candidate, enabled: !row.enabled } : candidate,
1210
+ ),
1211
+ }),
1212
+ () => request('/plugin', { entryId: row.entryId, enabled: !row.enabled }),
1213
+ row.entryId,
1214
+ );
1215
+
1216
+ const checkUpdates = React.useCallback(async (quiet) => {
1217
+ if (updatesBusy !== '') return;
1218
+ setUpdatesBusy('check');
1219
+ try {
1220
+ const next = await request('/updates');
1221
+ setUpdates(next);
1222
+ // 「其他安装方式」那一栏已经去掉,file / github / tarball 的更新
1223
+ // 信息统一并进这一个列表。它们不能走 POST /updates(那里只认精确
1224
+ // 版本锁定的依赖),所以勾选时记下每个包属于哪条通道。
1225
+ let extrasNext = null;
1226
+ try {
1227
+ extrasNext = await request('/extras');
1228
+ setExtras(extrasNext);
1229
+ } catch {
1230
+ // 拿不到 extras 不影响精确版本的那些包。
1231
+ extrasNext = null;
1232
+ }
1233
+ const choice = {};
1234
+ for (const row of next.rows || []) if (row.hasUpdate) choice[row.name] = true;
1235
+ if (extrasNext !== null && Array.isArray(extrasNext.rows)) {
1236
+ for (const row of extrasNext.rows) {
1237
+ if (row.hasUpdate !== true) continue;
1238
+ if (typeof row.name !== 'string') continue;
1239
+ choice[row.name] = true;
1240
+ }
1241
+ }
1242
+ setPicked(choice);
1243
+ setUpdateLog(null);
1244
+ } catch (error) {
1245
+ // 进面板时那一次自动检查失败不弹提示,免得一打开就报错。
1246
+ if (quiet !== true) {
1247
+ setToast({ kind: 'error', text: t('updateFailed') + ' · ' + String(error && error.message) });
1248
+ }
1249
+ } finally {
1250
+ setUpdatesBusy('');
1251
+ }
1252
+ }, [updatesBusy, t]);
1253
+
1254
+ const applyUpdates = React.useCallback(async () => {
1255
+ if (updatesBusy !== '') return;
1256
+ const names = Object.keys(picked).filter((name) => picked[name] === true);
1257
+ if (names.length === 0) return;
1258
+ setUpdatesBusy('apply');
1259
+ setUpdateLog(null);
1260
+ try {
1261
+ // 勾选的包分两条通道:精确版本锁定的走 POST /updates(一次 pnpm 装完),
1262
+ // 本地压缩包 / github / 压缩包地址走 POST /extras(逐个下载校验)。
1263
+ const plan = partitionTargets(names, extras !== null && Array.isArray(extras.rows) ? extras.rows : []);
1264
+ // 每一条渠道各自 try/catch:request() 在 ok !== true 时抛错,如果不接住,
1265
+ // 一个包失败会让后面的渠道一个都不跑,前面已成功的也拿不到报告。
1266
+ const results = [];
1267
+ if (plan.npm.length > 0) {
1268
+ try {
1269
+ // A registry download per package; the host serializes them under one
1270
+ // maintenance hold, so this can legitimately outlive the default budget.
1271
+ results.push(await request('/updates', { names: plan.npm }, { timeoutMs: WRITE_TIMEOUT_MS }));
1272
+ } catch (error) {
1273
+ results.push(failureEntry(error, plan.npm.join(', ')));
1274
+ }
1275
+ }
1276
+ for (const item of plan.extras) {
1277
+ try {
1278
+ const next = await request(
1279
+ '/extras',
1280
+ { name: item.name, action: item.action },
1281
+ { timeoutMs: WRITE_TIMEOUT_MS },
1282
+ );
1283
+ results.push({ name: item.name, ...next });
1284
+ } catch (error) {
1285
+ results.push(failureEntry(error, item.name));
1286
+ }
1287
+ }
1288
+ const merged = mergeUpdateReport(results, (n, total) => t('updatePartial', { n, total }));
1289
+ if (merged === undefined) {
1290
+ // 勾到的全是软链包之类的「没有升级这回事」的行:不编造报告。
1291
+ setToast({ kind: 'warn', text: t('updateNothing') });
1292
+ return;
1293
+ }
1294
+ setUpdateLog(merged);
1295
+ // 全部成功才重新拉一遍:有失败时保留刚才那份报告,别立刻把它冲掉。
1296
+ if (merged.ok === true) {
1297
+ await load();
1298
+ await checkUpdates();
1299
+ }
1300
+ } catch (error) {
1301
+ // 兜底:流水线之外真有东西抛了,也要给用户一句话,并且不把按钮卡死。
1302
+ setUpdateLog({ ok: false, message: String(error && error.message) });
1303
+ } finally {
1304
+ // 不管走哪条路径都要清掉 —— 之前把这一步写在末尾,一旦中间抛错
1305
+ // (比如 load() 改成会抛之后)按钮就永远停在「更新中」。
1306
+ setUpdatesBusy('');
1307
+ }
1308
+ }, [picked, updatesBusy, extras, load, checkUpdates, t]);
1309
+
1310
+ const viewLog = React.useCallback(async () => {
1311
+ if (logText !== null) {
1312
+ setLogText(null);
1313
+ return;
1314
+ }
1315
+ try {
1316
+ const next = await request('/updatelog');
1317
+ setLogText({ path: next.path, text: next.text });
1318
+ } catch (error) {
1319
+ setLogText({ path: '', text: null, error: String(error && error.message) });
1320
+ }
1321
+ }, [logText]);
1322
+
1323
+ // Opening the 更新 page with nothing loaded yet is a dead end unless the
1324
+ // panel checks on its own; a registry round trip is cheap and read-only.
1325
+ // 列表行里要显示「有新版」角标,所以进面板就查一次(只读、便宜);失败静默。
1326
+ React.useEffect(() => {
1327
+ if (updates !== null || updatesBusy !== '') return;
1328
+ checkUpdates(true);
1329
+ }, [updates, updatesBusy, checkUpdates]);
1330
+
1331
+ const checkExtras = React.useCallback(async (quiet) => {
1332
+ if (extrasBusy !== '') return;
1333
+ setExtrasBusy('check');
1334
+ try {
1335
+ setExtras(await request('/extras'));
1336
+ setExtraLog(null);
1337
+ } catch (error) {
1338
+ // 进面板那一次自动检查失败时静默,别一打开就弹错。
1339
+ if (quiet !== true) setExtras({ ok: false, message: String(error && error.message) });
1340
+ } finally {
1341
+ setExtrasBusy('');
1342
+ }
1343
+ }, [extrasBusy]);
1344
+
1345
+
1346
+ // 行里那个「v旧 ➡ v新 来源」小按钮:点一下就把这一个包更新掉,不用去「更新」页。
1347
+ // npm 精确版本包走 /updates,其他安装方式(github / 压缩包)走 /extras。
1348
+ // 这里刻意不走 run():那个函数会把返回值当成整份快照塞进 data,而这两个接口
1349
+ // 返回的是更新结果对象,塞进去整块就废了。
1350
+ const applyOne = React.useCallback(
1351
+ async (row, info) => {
1352
+ if (updatesBusy !== '' || extrasBusy !== '') return;
1353
+ // 用独立的 pending 键:更新不该让开关(绿色按钮)跟着转,
1354
+ // 转圈的应该是被点击的那个黄色按钮自己。
1355
+ const key = 'update:' + row.entryId;
1356
+ setPending((prev) => ({ ...prev, [key]: true }));
1357
+ setToast(null);
1358
+ try {
1359
+ const next =
1360
+ info.action === 'npm'
1361
+ ? await request('/updates', { names: [row.name] }, { timeoutMs: WRITE_TIMEOUT_MS })
1362
+ : await request('/extras', { name: row.name, action: info.action }, { timeoutMs: WRITE_TIMEOUT_MS });
1363
+ if (next.ok === true) {
1364
+ setToast({ kind: 'ok', text: t('updateDone') + ' · ' + row.name });
1365
+ await load();
1366
+ await checkUpdates(true);
1367
+ await checkExtras(true);
1368
+ } else if (next.waiting === true) {
1369
+ setToast({ kind: 'warn', text: String(next.message ?? t('updateWait')) });
1370
+ } else {
1371
+ setToast({
1372
+ kind: 'err',
1373
+ text: t('updateFailed') + ' · ' + row.name + ' · ' + String(next.message ?? ''),
1374
+ });
1375
+ }
1376
+ } catch (error) {
1377
+ setToast({ kind: 'err', text: t('updateFailed') + ' · ' + String(error && error.message) });
1378
+ } finally {
1379
+ setPending((prev) => {
1380
+ const done = { ...prev };
1381
+ delete done[key];
1382
+ return done;
1383
+ });
1384
+ }
1385
+ },
1386
+ [updatesBusy, extrasBusy, load, checkUpdates, checkExtras, t],
1387
+ );
1388
+
1389
+ // 「可卸载」名单由宿主给:只有 removable === true 且真挂在 profile 上的包才在里面。
1390
+ // 核心组件和面板自己会被服务端直接挡掉,这里只是决定要不要显示按钮。
1391
+ const checkRemovable = React.useCallback(async () => {
1392
+ try {
1393
+ const next = await request('/removable');
1394
+ setRemovable(Array.isArray(next.rows) ? next.rows.map((item) => item.name) : []);
1395
+ setRemovableError('');
1396
+ } catch (error) {
1397
+ // 拿不到名单时**不能**把它设成空数组:那会让每一行的「卸载」按钮
1398
+ // 无声消失,而且 removable 不再是 null,那个「只取一次」的 effect
1399
+ // 再也不会重试 —— 这个功能就永远没了。保持 null,把原因留给人看。
1400
+ setRemovableError(String(error && error.message ? error.message : error));
1401
+ }
1402
+ }, []);
1403
+
1404
+ React.useEffect(() => {
1405
+ if (removable !== null) return;
1406
+ checkRemovable();
1407
+ }, [removable, checkRemovable]);
1408
+
1409
+ // 卸载不可逆,所以做成「第一下变成确认、第二下才真卸」。
1410
+ // 刻意不用浏览器原生 confirm —— 面板里弹原生框很丑,也容易被拦。
1411
+ const applyUninstall = React.useCallback(
1412
+ async (row) => {
1413
+ if (confirmUninstall !== row.entryId) {
1414
+ setConfirmUninstall(row.entryId);
1415
+ return;
1416
+ }
1417
+ setConfirmUninstall('');
1418
+ const key = 'uninstall:' + row.entryId;
1419
+ setPending((prev) => ({ ...prev, [key]: true }));
1420
+ setToast(null);
1421
+ try {
1422
+ const next = await request('/uninstall', { name: row.name }, { timeoutMs: WRITE_TIMEOUT_MS });
1423
+ if (next.ok === true) {
1424
+ setToast({ kind: 'ok', text: t('uninstallDone') + ' · ' + row.name });
1425
+ setExpanded((prev) => ({ ...prev, [row.entryId]: false }));
1426
+ await load();
1427
+ await checkRemovable();
1428
+ // 卸掉一个包会改变整份依赖图谱,所以顺手把更新/其他安装方式也重查一遍,
1429
+ // 不然列表里会留着上一个包的版本角标。
1430
+ await checkUpdates(true);
1431
+ await checkExtras(true);
1432
+ } else {
1433
+ setToast({
1434
+ kind: 'err',
1435
+ text:
1436
+ t('uninstallFailed') +
1437
+ ' · ' +
1438
+ row.name +
1439
+ ' · ' +
1440
+ String(next.message ?? ''),
1441
+ });
1442
+ }
1443
+ } catch (error) {
1444
+ setToast({
1445
+ kind: 'err',
1446
+ text: t('uninstallFailed') + ' · ' + String(error && error.message),
1447
+ });
1448
+ } finally {
1449
+ setPending((prev) => {
1450
+ const done = { ...prev };
1451
+ delete done[key];
1452
+ return done;
1453
+ });
1454
+ }
1455
+ },
1456
+ [confirmUninstall, load, checkRemovable, checkUpdates, checkExtras, t],
1457
+ );
1458
+
1459
+ // 「检查」只调宿主的 inspect,绝不动任何文件;「安装」才真的装。
1460
+ const inspectInstall = React.useCallback(async () => {
1461
+ const spec = installSpec.trim();
1462
+ if (spec === '' || installBusy !== '') return;
1463
+ setInstallBusy('check');
1464
+ setInstallInfo(null);
1465
+ setInstallLog(null);
1466
+ try {
1467
+ const next = await request('/inspect?spec=' + encodeURIComponent(spec));
1468
+ const found = next.inspection;
1469
+ if (next.ok === true && found !== null && typeof found === 'object' && found.status === 'accepted') {
1470
+ setInstallInfo({ ok: true, data: found });
1471
+ } else {
1472
+ setInstallInfo({
1473
+ ok: false,
1474
+ problem: found !== null && typeof found === 'object' ? String(found.problem ?? '') : '',
1475
+ message: String(
1476
+ (found !== null && typeof found === 'object' ? found.reason : '') ?? next.message ?? '',
1477
+ ),
1478
+ });
1479
+ }
1480
+ } catch (error) {
1481
+ setInstallInfo({ ok: false, problem: '', message: String(error && error.message) });
1482
+ } finally {
1483
+ setInstallBusy('');
1484
+ }
1485
+ }, [installSpec, installBusy]);
1486
+
1487
+ const runInstall = React.useCallback(async () => {
1488
+ const spec = installSpec.trim();
1489
+ if (spec === '' || installBusy !== '') return;
1490
+ setInstallBusy('install');
1491
+ setInstallLog(null);
1492
+ try {
1493
+ const next = await request('/install', { spec }, { timeoutMs: WRITE_TIMEOUT_MS });
1494
+ setInstallLog({ ok: next.ok === true, data: next });
1495
+ if (next.ok === true) {
1496
+ const conflictCount = Array.isArray(next.conflicts) ? next.conflicts.length : 0;
1497
+ if (conflictCount > 0) {
1498
+ setToast({
1499
+ kind: 'warn',
1500
+ text:
1501
+ t('installDone') + ' · ' + spec + ' · ' +
1502
+ t('installConflicts', { n: conflictCount }),
1503
+ });
1504
+ } else {
1505
+ setToast({ kind: 'ok', text: t('installDone') + ' · ' + spec });
1506
+ }
1507
+ setInstallSpec('');
1508
+ setInstallInfo(null);
1509
+ await load();
1510
+ await checkRemovable();
1511
+ // 新装的包也要立刻出现在更新列表里(它可能本来就落后于上游)。
1512
+ await checkUpdates(true);
1513
+ await checkExtras(true);
1514
+ } else if (next.stage === 'build-approval') {
1515
+ setToast({ kind: 'warn', text: t('installBuild') });
1516
+ }
1517
+ } catch (error) {
1518
+ setInstallLog({ ok: false, data: { message: String(error && error.message) } });
1519
+ } finally {
1520
+ setInstallBusy('');
1521
+ }
1522
+ }, [installSpec, installBusy, load, checkRemovable, checkUpdates, checkExtras, t]);
1523
+
1524
+ // 列表行里也要标出「其他安装方式」那几个包有没有新版,所以同样进面板就查一次。
1525
+ React.useEffect(() => {
1526
+ if (extras !== null || extrasBusy !== '') return;
1527
+ checkExtras(true);
1528
+ }, [extras, extrasBusy, checkExtras]);
1529
+
1530
+ const setAuto = (row, auto) =>
1531
+ run(
1532
+ row.name,
1533
+ null,
1534
+ () => request('/plugin', { entryId: row.entryId, auto }),
1535
+ row.entryId,
1536
+ );
1537
+
1538
+ const setGroupAuto = (scope, auto) =>
1539
+ run(scope, null, () => request('/roster', { mode: 'group', scope, auto }));
1540
+
1541
+ const bulk = async (enabled, scope, count, save) => {
1542
+ const total = typeof count === 'number' ? count : roster.length;
1543
+ if (total === 0) {
1544
+ setToast({ kind: 'warn', text: t('rosterEmpty') });
1545
+ return;
1546
+ }
1547
+ // Two-step confirmation drawn inside the panel: a native dialog would
1548
+ // cover the list the user is deciding about, and it cannot explain
1549
+ // what the run will actually touch.
1550
+ setBulkAsk({ enabled, scope, total, save: save === true });
1551
+ };
1552
+
1553
+ const doBulk = async (ask) => {
1554
+ if (ask === null) return;
1555
+ setBulkAsk(null);
1556
+ const { enabled, scope, total, save } = ask;
1557
+ let accepted;
1558
+ try {
1559
+ accepted = await request('/bulk', {
1560
+ enabled,
1561
+ scope: typeof scope === 'string' ? scope : '',
1562
+ save: save === true,
1563
+ });
1564
+ } catch (failure) {
1565
+ setToast({ kind: 'err', text: String(failure && failure.message ? failure.message : failure) });
1566
+ return;
1567
+ }
1568
+
1569
+ const notes = [];
1570
+ if (accepted.unchanged > 0) notes.push(t('withUnchanged', { n: accepted.unchanged }));
1571
+ if (accepted.guarded > 0) notes.push(t('withGuarded', { n: accepted.guarded }));
1572
+
1573
+ if (accepted.total === 0) {
1574
+ // Nothing to do: say so instead of polling a job that never started.
1575
+ setToast({ kind: 'ok', text: [t('nothingToDo')].concat(notes).join(' · ') });
1576
+ return;
1577
+ }
1578
+
1579
+ if (accepted.saved === true) {
1580
+ // `save` only writes the profile file; there is no background run
1581
+ // to poll and nothing changes until the next restart.
1582
+ setToast({ kind: 'ok', text: [t('saveApplied')].concat(notes).join(' · ') });
1583
+ await load();
1584
+ return;
1585
+ }
1586
+
1587
+ // The Host answers immediately and works through the rows behind this
1588
+ // panel; /state carries the progress while it runs.
1589
+ setBulkRun({ enabled: accepted.enabled, total: accepted.total, finished: 0, failed: 0, notes: notes });
1590
+ await load();
1591
+ };
1592
+
1593
+ const bulkRun = data && data.bulk && data.bulk.running === true ? data.bulk : null;
1594
+ const running = bulkRun !== null;
1595
+ const settled = data && data.bulk && data.bulk.running === false ? data.bulk : null;
1596
+
1597
+ // Poll only while the Host says it is working. `bulkJob` marks that a run
1598
+ // was accepted but /state has not confirmed it yet, so the first tick
1599
+ // cannot be missed; it is cleared as soon as a run settles.
1600
+ const waiting = bulkJob !== null && data !== null;
1601
+ React.useEffect(() => {
1602
+ if (!running && !waiting) return undefined;
1603
+ const timer = setInterval(() => {
1604
+ load();
1605
+ }, 1200);
1606
+ return () => clearInterval(timer);
1607
+ }, [running, waiting, load]);
1608
+
1609
+ // 失败行不会自己消失:插件热修复后 phase 会从 failed 回到 active,
1610
+ // 不轮询就永远停在红标上。但只有在真出过失败时才轮,平时不打扰服务端。
1611
+ const hasFailed = data !== null && Array.isArray(data.rows) && data.rows.some((row) => row.phase === 'failed');
1612
+ React.useEffect(() => {
1613
+ if (!hasFailed) return undefined;
1614
+ const timer = setInterval(() => {
1615
+ load();
1616
+ }, 5000);
1617
+ return () => clearInterval(timer);
1618
+ }, [hasFailed, load]);
1619
+
1620
+ // 宿主的后台任务(POST /updates、POST /extras)。这个轮询常驻:面板被
1621
+ // 关掉时更新的结局没人接,重新打开只能靠它补一次,顺便把列表演一遍。
1622
+ const seenJob = React.useRef(null);
1623
+ React.useEffect(() => {
1624
+ let alive = true;
1625
+ const tick = async () => {
1626
+ try {
1627
+ const answer = await request('/job');
1628
+ if (!alive) return;
1629
+ const current = answer.job === undefined ? null : answer.job;
1630
+ setJob(current);
1631
+ setQueued(Array.isArray(answer.queue) ? answer.queue : []);
1632
+ if (current === null || current.finishedAt === null) return;
1633
+ if (seenJob.current === null) {
1634
+ try {
1635
+ seenJob.current = window.localStorage.getItem(JOB_SEEN_KEY) || '';
1636
+ } catch (error) {
1637
+ void error;
1638
+ seenJob.current = '';
1639
+ }
1640
+ }
1641
+ if (seenJob.current === current.id) return;
1642
+ seenJob.current = current.id;
1643
+ try {
1644
+ window.localStorage.setItem(JOB_SEEN_KEY, current.id);
1645
+ } catch (error) {
1646
+ void error;
1647
+ }
1648
+ // 只报十分钟内结束的那次:几周前的旧结果不该在打开面板时冒出来。
1649
+ const age = Date.now() - Date.parse(current.finishedAt);
1650
+ if (Number.isFinite(age) && age > 10 * 60 * 1000) return;
1651
+ if (current.ok === true) {
1652
+ setToast({ kind: 'ok', text: t('jobDone') });
1653
+ } else if (current.interrupted === true) {
1654
+ setToast({ kind: 'warn', text: t('jobInterrupted') });
1655
+ } else {
1656
+ const detail = typeof current.message === 'string' && current.message.length > 0 ? ': ' + current.message : '';
1657
+ setToast({ kind: 'warn', text: t('jobFailed') + detail });
1658
+ }
1659
+ checkUpdates(true);
1660
+ load();
1661
+ } catch (error) {
1662
+ // 宿主正在重启时这里会失败,下一跳再试。
1663
+ void error;
1664
+ }
1665
+ };
1666
+ tick();
1667
+ const timer = setInterval(tick, 5000);
1668
+ return () => {
1669
+ alive = false;
1670
+ clearInterval(timer);
1671
+ };
1672
+ }, [t, load, checkUpdates]);
1673
+
1674
+ // 下载卡住时用户得能自己踩刹车,不然这条横幅会一直转到宿主超时。
1675
+ const cancelJob = async () => {
1676
+ try {
1677
+ await request('/job', { action: 'cancel' });
1678
+ } catch (error) {
1679
+ setToast({ kind: 'warn', text: error instanceof Error ? error.message : String(error) });
1680
+ }
1681
+ };
1682
+
1683
+ // Report the settled run once, then drop the local marker.
1684
+ const settledKey = settled === null ? null : settled.finished + '/' + settled.total + '/' + settled.failed;
1685
+ React.useEffect(() => {
1686
+ if (settled === null || bulkJob === null) return;
1687
+ const parts = [];
1688
+ if (settled.failed > 0) parts.push(t('withFailures', { n: settled.failed }));
1689
+ else parts.push(t('done') + ' · ' + (settled.total - settled.failed) + '/' + settled.total);
1690
+ if (settled.unchanged > 0) parts.push(t('withUnchanged', { n: settled.unchanged }));
1691
+ if (settled.guarded > 0) parts.push(t('withGuarded', { n: settled.guarded }));
1692
+ setToast({ kind: settled.failed > 0 ? 'warn' : 'ok', text: parts.join(' · ') });
1693
+ setBulkRun(null);
1694
+ }, [settledKey, bulkJob]);
1695
+
1696
+ if (error !== null) {
1697
+ const unreachable = error === 'unreachable';
1698
+ return h(
1699
+ 'div',
1700
+ { className: 'pd-root' },
1701
+ h('style', { dangerouslySetInnerHTML: { __html: CSS } }),
1702
+ h('div', { className: 'pd-title' }, t('title')),
1703
+ h('div', { className: 'pd-toast pd-toast-err' }, t('loadFailed') + ': ' + (unreachable ? t('hostMissing') : error)),
1704
+ h(
1705
+ 'div',
1706
+ { className: 'pd-bar' },
1707
+ h('button', { type: 'button', className: 'pd-btn', onClick: load }, t('refresh')),
1708
+ ),
1709
+ );
1710
+ }
1711
+
1712
+ if (data === null) {
1713
+ return h(
1714
+ 'div',
1715
+ { className: 'pd-root' },
1716
+ h('style', { dangerouslySetInnerHTML: { __html: CSS } }),
1717
+ h('div', { className: 'pd-title' }, t('title')),
1718
+ h('div', { className: 'pd-empty' }, '...'),
1719
+ );
1720
+ }
1721
+
1722
+ const needle = query.trim().toLowerCase();
1723
+ const matches = (row) => {
1724
+ if (needle.length === 0) return true;
1725
+ // patchId 进搜索词:像 cc-safety-net 这种 moduleName 是一条 file:// 路径、
1726
+ // 包名要靠 patchId 才认得出来的行,用户凭包名也得能搜到。
1727
+ // description 也进:有人记得「余额挂件」但不记得包名。
1728
+ const haystack = (
1729
+ row.name +
1730
+ ' ' +
1731
+ row.moduleName +
1732
+ ' ' +
1733
+ row.entryId +
1734
+ ' ' +
1735
+ (row.patchId === null || row.patchId === undefined ? '' : row.patchId) +
1736
+ ' ' +
1737
+ (row.description === null || row.description === undefined ? '' : row.description)
1738
+ ).toLowerCase();
1739
+ return haystack.indexOf(needle) >= 0;
1740
+ };
1741
+ const stateOk = (row) => {
1742
+ if (filter === 'enabled') return row.enabled;
1743
+ if (filter === 'disabled') return !row.enabled;
1744
+ if (filter === 'auto') return isAuto(row);
1745
+ return true;
1746
+ };
1747
+ const byName = (a, b) => a.name.localeCompare(b.name);
1748
+
1749
+ // 核心组件与异常各自成页,主列表只放真正安装的插件。
1750
+ const isPlugin = (row) => !row.official && row.phase !== 'failed';
1751
+ const isCore = (row) => row.official && row.phase !== 'failed';
1752
+ const isFailed = (row) => row.phase === 'failed';
1753
+
1754
+ const pageRows = (key) =>
1755
+ key === 'updates'
1756
+ ? []
1757
+ : key === 'core'
1758
+ ? data.rows.filter(isCore)
1759
+ : key === 'failed'
1760
+ ? data.rows.filter(isFailed)
1761
+ : data.rows.filter(isPlugin);
1762
+
1763
+ const pages = [
1764
+ { key: 'plugins', title: t('tabPlugins') },
1765
+ { key: 'core', title: t('tabCore') },
1766
+ { key: 'failed', title: t('tabFailed') },
1767
+ { key: 'updates', title: t('tabUpdates'), badge: updates === null ? null : updates.updates },
1768
+ ].map((page) => ({ ...page, rows: pageRows(page.key) }));
1769
+
1770
+ const current = pages.find((page) => page.key === tab) || pages[0];
1771
+
1772
+ const makeGroup = (key, title, scope, rows, options) => ({
1773
+ key: key,
1774
+ title: title,
1775
+ scope: scope,
1776
+ rows: rows.filter((row) => matches(row) && stateOk(row)).sort(byName),
1777
+ showBulk: !options || options.showBulk !== false,
1778
+ });
1779
+
1780
+ // The core page splits in two: tools the harness still runs, and tools a
1781
+ // bundle (dsh-ops' hideOfficialTools) has switched off on purpose. The
1782
+ // second set looks alarming in a flat list, so it folds away by default.
1783
+ const groups =
1784
+ current.key === 'core'
1785
+ ? [
1786
+ makeGroup(current.key, current.title, 'core', current.rows.filter((row) => row.enabled)),
1787
+ makeGroup(
1788
+ current.key + '-off',
1789
+ t('groupCoreHidden'),
1790
+ 'core',
1791
+ current.rows.filter((row) => !row.enabled),
1792
+ { showBulk: false },
1793
+ ),
1794
+ ].filter((group) => group.rows.length > 0)
1795
+ : [
1796
+ makeGroup(
1797
+ current.key,
1798
+ current.title,
1799
+ current.key === 'plugins' ? 'installed' : current.key,
1800
+ current.rows,
1801
+ ),
1802
+ ].filter((group) => group.rows.length > 0);
1803
+
1804
+ const visibleCount = groups.reduce((sum, group) => sum + group.rows.length, 0);
1805
+
1806
+ const counts = { total: current.rows.length, auto: 0, on: 0, off: 0 };
1807
+ for (const row of current.rows) {
1808
+ if (row.enabled) counts.on += 1;
1809
+ else counts.off += 1;
1810
+ if (isAuto(row)) counts.auto += 1;
1811
+ }
1812
+
1813
+ const statItem = (key, content) =>
1814
+ h(
1815
+ 'button',
1816
+ {
1817
+ type: 'button',
1818
+ className: 'pd-stat' + (filter === key ? ' pd-stat-on' : ''),
1819
+ 'aria-pressed': filter === key,
1820
+ onClick: () => setFilter(filter === key ? 'all' : key),
1821
+ },
1822
+ content,
1823
+ );
1824
+
1825
+ // 有新版就直接标在行里(开关左边那一列),不用专门去「更新」页看。
1826
+ // 两个来源都算上:「检查更新」的精确版本包(npm),和「其他安装方式」里查到新版的包。
1827
+ // 每一项记 { label, source, action }:label 是箭头后面显示的版本,source 是
1828
+ // 「从哪儿下的」(npm / github / npmmirror…),action 决定点一下走哪个接口。
1829
+ // 注意:这里**刻意不用 useMemo**——组件在 data===null 时会提前 return,
1830
+ // 任何 hook 放到那之后,都会让两次渲染的 hook 数量不一致,整块直接崩掉。
1831
+ // 数据只有几十条,直接算就好。
1832
+ const sourceOf = (spec) => {
1833
+ const text = typeof spec === 'string' ? spec : '';
1834
+ const matched = /^https?:\/\/([^/]+)/.exec(text);
1835
+ if (matched === null) return 'tarball';
1836
+ const host = matched[1].toLowerCase();
1837
+ if (host.indexOf('npmmirror') >= 0) return 'npmmirror';
1838
+ if (host.indexOf('npmjs') >= 0) return 'npm';
1839
+ if (host.indexOf('github') >= 0 || host.indexOf('codeload') >= 0) return 'github';
1840
+ return host;
1841
+ };
1842
+ // 每一行的「这东西是怎么装的」也要在主页面看得到,所以同一份数据里
1843
+ // 再收一张「名字 → 来源」表:精确版本的是 npm,其他按 extras 报上来的
1844
+ // kind 分(github / 压缩包域名 / 本地文件包 / 本地软链)。
1845
+ const srcLabelOf = (item) => {
1846
+ if (item.kind === 'github') return 'github';
1847
+ if (item.kind === 'tarball') return sourceOf(item.spec);
1848
+ if (item.kind === 'file') return t('srcLocalFile');
1849
+ if (item.kind === 'link') return t('srcLocalLink');
1850
+ return t('srcOther');
1851
+ };
1852
+ const srcOf = new Map();
1853
+ const updateOf = new Map();
1854
+ if (updates !== null && Array.isArray(updates.rows)) {
1855
+ for (const item of updates.rows) {
1856
+ if (typeof item.name !== 'string') continue;
1857
+ srcOf.set(item.name, 'npm');
1858
+ if (item.hasUpdate === true && typeof item.latest === 'string') {
1859
+ updateOf.set(item.name, { label: 'v' + item.latest, source: 'npm', action: 'npm' });
1860
+ }
1861
+ }
1862
+ }
1863
+ if (extras !== null && Array.isArray(extras.rows)) {
1864
+ for (const item of extras.rows) {
1865
+ if (typeof item.name !== 'string') continue;
1866
+ srcOf.set(item.name, srcLabelOf(item));
1867
+ if (item.hasUpdate !== true) continue;
1868
+ const head = item.head;
1869
+ const release = item.latestRelease;
1870
+ if (item.kind === 'github' && head !== null && typeof head === 'object' && typeof head.sha === 'string') {
1871
+ updateOf.set(item.name, { label: head.sha.slice(0, 7), source: 'github', action: 'repull' });
1872
+ } else if (item.kind === 'tarball' && typeof item.npmLatest === 'string') {
1873
+ updateOf.set(item.name, {
1874
+ label: 'v' + item.npmLatest,
1875
+ source: sourceOf(item.spec),
1876
+ action: 'pin',
1877
+ });
1878
+ } else if (
1879
+ item.kind === 'file' &&
1880
+ release !== null &&
1881
+ typeof release === 'object' &&
1882
+ typeof release.version === 'string'
1883
+ ) {
1884
+ // 本地压缩包走 /extras 的 file-upgrade:下载官方附件、核对 sha256、
1885
+ // 替换 vendor 里的文件再重装,失败会整块回滚,所以这里可以一键。
1886
+ updateOf.set(item.name, {
1887
+ label: 'v' + release.version,
1888
+ source: 'github',
1889
+ action: 'file-upgrade',
1890
+ });
1891
+ }
1892
+ }
1893
+ }
1894
+
1895
+ const rowNode = (row) => {
1896
+ const auto = isAuto(row);
1897
+ const locked = !row.manageable;
1898
+ const selfLocked = row.self === true;
1899
+ const isOpen = expanded[row.entryId] === true;
1900
+ const detail = details[row.entryId] || {};
1901
+ const pkg = detail.data && detail.data.package ? detail.data.package : null;
1902
+ const desc = row.description || (pkg === null ? null : pkg.description);
1903
+ const tags = [];
1904
+ if (row.self) tags.push(h('span', { key: 'self', className: 'pd-tag' }, t('selfTag')));
1905
+ if (locked) {
1906
+ tags.push(
1907
+ h(
1908
+ 'span',
1909
+ { key: 'locked', className: 'pd-tag' },
1910
+ t('readOnly') + ' · ' + t(row.readOnlyReason === 'management-required' ? 'rawManagement' : 'rawUnaddressable'),
1911
+ ),
1912
+ );
1913
+ }
1914
+ // The switch already spins while this row is switching, so a tag here
1915
+ // would be a second loading indicator for one action. One is enough.
1916
+ if (row.phase === 'failed') {
1917
+ tags.push(h('span', { key: 'failed', className: 'pd-tag pd-tag-err' }, t('failed')));
1918
+ } else if (row.phase === 'pending' || row.phase === 'loading') {
1919
+ tags.push(h('span', { key: 'loading', className: 'pd-tag pd-tag-warn' }, t('loading')));
1920
+ }
1921
+ // The raw entry id (`include:genui`) is an internal handle, not
1922
+ // something a user needs in the list. Only a real diagnostic or a
1923
+ // short Chinese label is worth showing here.
1924
+ const subtitle = row.diagnostic || row.description || null;
1925
+ const facts = [
1926
+ [t('dModule'), row.moduleName],
1927
+ [t('dEntry'), row.entryId],
1928
+ [t('dPatch'), row.patchId || '—'],
1929
+ [
1930
+ t('dPhase'),
1931
+ row.phase === 'failed'
1932
+ ? t('phaseFailed')
1933
+ : row.phase === 'pending' || row.phase === 'loading'
1934
+ ? t('phaseLoading')
1935
+ : row.enabled
1936
+ ? t('phaseActive')
1937
+ : t('phaseOff'),
1938
+ ],
1939
+ [t('dMode'), auto ? t('dModeAuto') : t('dModeManual')],
1940
+ [t('dOfficial'), row.official ? t('yes') : t('no')],
1941
+ [t('dManageable'), row.manageable ? t('yes') : t('no')],
1942
+ ];
1943
+ if (row.readOnlyReason) {
1944
+ facts.push([
1945
+ t('dReason'),
1946
+ row.readOnlyReason === 'management-required' ? t('reasonManagement') : t('reasonUnaddressable'),
1947
+ ]);
1948
+ }
1949
+ if (row.diagnostic) facts.push([t('dDiagnostic'), row.diagnostic]);
1950
+ if (pkg) {
1951
+ if (pkg.version) facts.push([t('dVersion'), pkg.version]);
1952
+ if (pkg.license) facts.push([t('dLicense'), pkg.license]);
1953
+ if (pkg.author) facts.push([t('dAuthor'), pkg.author]);
1954
+ if (pkg.dir) facts.push([t('dDir'), pkg.dir]);
1955
+ if (Array.isArray(pkg.dependencies) && pkg.dependencies.length > 0) {
1956
+ facts.push([t('dDeps'), pkg.dependencies.join(', ')]);
1957
+ }
1958
+ }
1959
+
1960
+ const links = [];
1961
+ // 仓库、npm 主页、作者主页都列出来;同一个地址只出现一次。
1962
+ const seenHref = new Set();
1963
+ const addLink = (label, href) => {
1964
+ if (typeof href !== 'string' || href === '') return;
1965
+ if (seenHref.has(href)) return;
1966
+ seenHref.add(href);
1967
+ links.push({ label, href });
1968
+ };
1969
+ if (pkg) {
1970
+ addLink('GitHub', pkg.github);
1971
+ addLink('npm', pkg.npmUrl);
1972
+ addLink(t('homepage'), pkg.homepage);
1973
+ }
1974
+
1975
+ return h(
1976
+ 'div',
1977
+ { className: 'pd-item', key: row.entryId },
1978
+ h(
1979
+ 'div',
1980
+ {
1981
+ className:
1982
+ 'pd-row' + (isOpen ? ' pd-row-open' : '') + (row.phase === 'failed' ? ' pd-row-failed' : ''),
1983
+ onClick: () => {
1984
+ openDetail(row);
1985
+ },
1986
+ },
1987
+ (() => {
1988
+ // 状态点:颜色即语义,title 写一句话说明,失败的行已经被整行染红。
1989
+ const failed = row.phase === 'failed';
1990
+ const loading = row.phase === 'pending' || row.phase === 'loading';
1991
+ const state = failed
1992
+ ? 'failed'
1993
+ : loading
1994
+ ? 'loading'
1995
+ : locked
1996
+ ? 'readonly'
1997
+ : row.enabled
1998
+ ? 'running'
1999
+ : 'off';
2000
+ const label =
2001
+ state === 'failed'
2002
+ ? t('phaseFailed')
2003
+ : state === 'loading'
2004
+ ? t('phaseLoading')
2005
+ : state === 'readonly'
2006
+ ? t('readOnly')
2007
+ : state === 'running'
2008
+ ? t('phaseActive')
2009
+ : t('phaseOff');
2010
+ return h('span', {
2011
+ className: 'pd-dot-status pd-dot-' + state,
2012
+ title: label,
2013
+ 'aria-label': label,
2014
+ });
2015
+ })(),
2016
+ h(
2017
+ 'div',
2018
+ { className: 'pd-name' },
2019
+ h('div', { className: 'pd-name-main', title: row.moduleName }, row.name),
2020
+ row.version ? h('span', { className: 'pd-version' }, 'v' + row.version) : null,
2021
+ subtitle === null
2022
+ ? null
2023
+ : h('div', { className: 'pd-name-sub', title: subtitle }, subtitle),
2024
+ tags.length > 0 ? h('div', { className: 'pd-tags' }, tags) : null,
2025
+ h(
2026
+ 'div',
2027
+ { className: 'pd-name-act', onClick: (event) => event.stopPropagation() },
2028
+ (() => {
2029
+ const info = updateOf.get(row.name);
2030
+ if (info === undefined) {
2031
+ const src = srcOf.get(row.name);
2032
+ // 普通 npm 装的是默认情况,标出来只是噪音;只有「不是按常规方式
2033
+ // 装的」才值得在主列表里露一个标签。
2034
+ return src === undefined || src === 'npm' ? null : h('span', { className: 'pd-src' }, src);
2035
+ }
2036
+ const updating = pending['update:' + row.entryId] === true;
2037
+ const parts = [
2038
+ updating ? h('span', { key: 'spin', className: 'pd-spin pd-upbtn-spin' }) : null,
2039
+ 'v' + (row.version ?? '?'),
2040
+ h('span', { key: 'arrow', className: 'pd-upbtn-arrow' }, '➡'),
2041
+ info.label,
2042
+ h('span', { key: 'src', className: 'pd-upbtn-src' }, info.source),
2043
+ ];
2044
+ const label =
2045
+ t('updateNow') + '(' + info.source + '):v' + (row.version ?? '?') + ' → ' + info.label;
2046
+ return h(
2047
+ 'button',
2048
+ {
2049
+ type: 'button',
2050
+ className: 'pd-upbtn',
2051
+ disabled: busy || updating || pending[row.entryId] === true,
2052
+ title: label,
2053
+ onClick: () => {
2054
+ applyOne(row, info);
2055
+ },
2056
+ },
2057
+ parts,
2058
+ );
2059
+ })(),
2060
+ ),
2061
+ ),
2062
+ h(
2063
+ 'div',
2064
+ {
2065
+ className: 'pd-cell pd-cell-power',
2066
+ onClick: (event) => event.stopPropagation(),
2067
+ title: selfLocked ? t('selfLocked') : undefined,
2068
+ },
2069
+ h(PowerSwitch, {
2070
+ checked: row.enabled,
2071
+ pending: pending[row.entryId] === true,
2072
+ disabled: locked || selfLocked || pending[row.entryId] === true,
2073
+ label: row.name + ' — ' + t('colPower'),
2074
+ onChange: () => togglePower(row),
2075
+ }),
2076
+ ),
2077
+ h(
2078
+ 'div',
2079
+ { className: 'pd-cell pd-cell-mode', onClick: (event) => event.stopPropagation() },
2080
+ h(ModeControl, {
2081
+ auto: auto,
2082
+ disabled: locked || pending[row.entryId] === true,
2083
+ t: t,
2084
+ onChange: (next) => setAuto(row, next),
2085
+ }),
2086
+ ),
2087
+ // 行尾卸载入口:一个垃圾桶图标,平时灰色、hover 变红;
2088
+ // 复用 applyUninstall 的「点一下变确认、再点才真卸」两步流程。
2089
+ // 只有宿主说 removable 的行才有图标;其余行渲染一个空占位,
2090
+ // 占住同一列,保证所有行的开关/模式/箭头都对齐。
2091
+ removable !== null && removable.indexOf(row.name) >= 0
2092
+ ? h(
2093
+ 'button',
2094
+ {
2095
+ type: 'button',
2096
+ className:
2097
+ 'pd-row-uninstall' +
2098
+ (confirmUninstall === row.entryId ? ' pd-row-uninstall-confirm' : ''),
2099
+ disabled:
2100
+ pending['uninstall:' + row.entryId] === true || busy || installBusy !== '',
2101
+ title: t('uninstallTitle'),
2102
+ 'aria-label': t('uninstall') + ' ' + row.name,
2103
+ onClick: (event) => {
2104
+ event.stopPropagation();
2105
+ applyUninstall(row);
2106
+ },
2107
+ },
2108
+ pending['uninstall:' + row.entryId] === true
2109
+ ? h('span', { className: 'pd-spin' })
2110
+ : h(
2111
+ 'svg',
2112
+ {
2113
+ width: 14,
2114
+ height: 14,
2115
+ viewBox: '0 0 14 14',
2116
+ fill: 'none',
2117
+ 'aria-hidden': true,
2118
+ },
2119
+ h('path', {
2120
+ d: 'M2.5 3.5 h9 M5.5 3.5 v-1 h3 v1 M4 3.5 l.5 8 h5 l.5 -8 M6 6 v3.5 M8 6 v3.5',
2121
+ stroke: 'currentColor',
2122
+ strokeWidth: 1.2,
2123
+ strokeLinecap: 'round',
2124
+ strokeLinejoin: 'round',
2125
+ }),
2126
+ ),
2127
+ )
2128
+ : h('span', null),
2129
+ h(
2130
+ 'button',
2131
+ {
2132
+ type: 'button',
2133
+ className: 'pd-row-chev',
2134
+ 'data-collapsed': isOpen ? 'false' : 'true',
2135
+ 'aria-expanded': isOpen,
2136
+ 'aria-label': t('detail'),
2137
+ title: t('detail'),
2138
+ onClick: () => {
2139
+ openDetail(row);
2140
+ },
2141
+ },
2142
+ h(
2143
+ 'svg',
2144
+ { width: 12, height: 12, viewBox: '0 0 12 12', fill: 'none', 'aria-hidden': true },
2145
+ h('path', {
2146
+ d: 'M2 4.2 L6 8 L10 4.2',
2147
+ stroke: 'currentColor',
2148
+ strokeWidth: 1.4,
2149
+ strokeLinecap: 'round',
2150
+ strokeLinejoin: 'round',
2151
+ }),
2152
+ ),
2153
+ ),
2154
+ ),
2155
+ isOpen
2156
+ ? h(
2157
+ 'div',
2158
+ { className: 'pd-detail' },
2159
+ links.length > 0
2160
+ ? h(
2161
+ 'div',
2162
+ { className: 'pd-fact' },
2163
+ h('span', { className: 'pd-fact-k' }, t('links')),
2164
+ h(
2165
+ 'span',
2166
+ { className: 'pd-fact-v' },
2167
+ links.flatMap((link, index) =>
2168
+ index === 0
2169
+ ? [
2170
+ h(
2171
+ 'a',
2172
+ {
2173
+ key: link.href,
2174
+ className: 'pd-link',
2175
+ href: link.href,
2176
+ target: '_blank',
2177
+ rel: 'noreferrer',
2178
+ },
2179
+ link.label,
2180
+ ),
2181
+ ]
2182
+ : [
2183
+ h('span', { key: link.href + ':dot', className: 'pd-dot' }, ' · '),
2184
+ h(
2185
+ 'a',
2186
+ {
2187
+ key: link.href,
2188
+ className: 'pd-link',
2189
+ href: link.href,
2190
+ target: '_blank',
2191
+ rel: 'noreferrer',
2192
+ },
2193
+ link.label,
2194
+ ),
2195
+ ],
2196
+ ),
2197
+ ),
2198
+ )
2199
+ : null,
2200
+ facts.map(([label, value]) =>
2201
+ h(
2202
+ 'div',
2203
+ { className: 'pd-fact', key: label },
2204
+ h('span', { className: 'pd-fact-k' }, label),
2205
+ h('span', { className: 'pd-fact-v', title: String(value) }, String(value)),
2206
+ ),
2207
+ ),
2208
+ desc === null
2209
+ ? null
2210
+ : h(
2211
+ 'div',
2212
+ { className: 'pd-fact pd-fact-block' },
2213
+ h('span', { className: 'pd-fact-k' }, t('dFunction')),
2214
+ h('span', { className: 'pd-fact-v pd-desc' }, desc),
2215
+ ),
2216
+ detail.status === 'error'
2217
+ ? h('div', { className: 'pd-note' }, t('detailFail') + ' · ' + detail.message)
2218
+ : null,
2219
+ detail.status === 'loading'
2220
+ ? h('div', { className: 'pd-note' }, t('loading'))
2221
+ : null,
2222
+ removable !== null && removable.indexOf(row.name) >= 0
2223
+ ? h(
2224
+ 'div',
2225
+ { className: 'pd-fact pd-fact-block' },
2226
+ h('span', { className: 'pd-fact-k' }, t('uninstall')),
2227
+ h(
2228
+ 'span',
2229
+ { className: 'pd-fact-v pd-uninstall' },
2230
+ h(
2231
+ 'button',
2232
+ {
2233
+ type: 'button',
2234
+ className: 'pd-btn pd-btn-danger',
2235
+ disabled:
2236
+ pending['uninstall:' + row.entryId] === true ||
2237
+ busy ||
2238
+ installBusy !== '',
2239
+ onClick: () => {
2240
+ applyUninstall(row);
2241
+ },
2242
+ },
2243
+ pending['uninstall:' + row.entryId] === true
2244
+ ? t('uninstallRunning')
2245
+ : confirmUninstall === row.entryId
2246
+ ? t('uninstallConfirm')
2247
+ : t('uninstall'),
2248
+ ),
2249
+ h(
2250
+ 'span',
2251
+ { className: 'pd-note pd-note-inline' },
2252
+ row.name === 'dsh-our-free-model'
2253
+ ? t('uninstallFreeModel')
2254
+ : t('uninstallWarn'),
2255
+ ),
2256
+ ),
2257
+ )
2258
+ : removable === null && removableError !== ''
2259
+ ? h(
2260
+ 'div',
2261
+ { className: 'pd-note' },
2262
+ t('uninstallUnavailable') + ' · ' + removableError + ' ',
2263
+ h(
2264
+ 'button',
2265
+ {
2266
+ type: 'button',
2267
+ className: 'pd-btn',
2268
+ onClick: () => {
2269
+ checkRemovable();
2270
+ },
2271
+ },
2272
+ t('refresh'),
2273
+ ),
2274
+ )
2275
+ : null,
2276
+ )
2277
+ : null,
2278
+ );
2279
+ };
2280
+
2281
+ const groupNode = (group) => {
2282
+ const isCollapsed = collapsed[group.key] === true;
2283
+ const allLocked = group.rows.length > 0 && group.rows.every((row) => !row.manageable);
2284
+ // The server acts on 自动清单 ∩ 页面范围, not on what the search box
2285
+ // happens to show, so the confirm dialog must count that set —
2286
+ // group.rows is the filtered view and can understate it badly.
2287
+ const autoInScope = current.rows.filter(
2288
+ (row) => row.manageable && isAuto(row) && !row.self,
2289
+ ).length;
2290
+ return h(
2291
+ 'div',
2292
+ { className: 'pd-group', key: group.key },
2293
+ h(
2294
+ 'div',
2295
+ { className: 'pd-group-head', 'data-collapsed': isCollapsed ? 'true' : 'false' },
2296
+ h('div', { className: 'pd-title-row' },
2297
+ h(
2298
+ 'button',
2299
+ {
2300
+ type: 'button',
2301
+ className: 'pd-chevron',
2302
+ 'data-collapsed': isCollapsed ? 'true' : 'false',
2303
+ 'aria-expanded': !isCollapsed,
2304
+ 'aria-label': group.title,
2305
+ onClick: () => setCollapsed((prev) => ({ ...prev, [group.key]: !isCollapsed })),
2306
+ },
2307
+ h(
2308
+ 'svg',
2309
+ { width: 12, height: 12, viewBox: '0 0 12 12', 'aria-hidden': true },
2310
+ h('path', {
2311
+ d: 'M2 4.2 L6 8 L10 4.2',
2312
+ fill: 'none',
2313
+ stroke: 'currentColor',
2314
+ strokeWidth: 1.4,
2315
+ strokeLinecap: 'round',
2316
+ strokeLinejoin: 'round',
2317
+ }),
2318
+ ),
2319
+ ),
2320
+ h('div', { className: 'pd-group-title' }, group.title),
2321
+ h('div', { className: 'pd-group-count' }, String(group.rows.length)),
2322
+ ),
2323
+ group.showBulk === false
2324
+ ? h('div', { className: 'pd-group-note' }, t('groupCoreHiddenNote'))
2325
+ : h(
2326
+ 'div',
2327
+ { className: 'pd-colhead', onClick: (event) => event.stopPropagation() },
2328
+ h('span', null),
2329
+ h(
2330
+ 'div',
2331
+ { className: 'pd-group-bulk' },
2332
+ h(
2333
+ 'button',
2334
+ {
2335
+ type: 'button',
2336
+ className: 'pd-btn pd-btn-sm pd-btn-primary',
2337
+ // Nothing on this page is marked 自动, so the one-click
2338
+ // actions would do nothing. Grey them out instead of
2339
+ // letting the user fire an empty run.
2340
+ disabled: busy || allLocked || running || autoInScope === 0,
2341
+ title: autoInScope === 0 ? t('rosterEmpty') : undefined,
2342
+ onClick: () => bulk(true, group.scope, autoInScope),
2343
+ },
2344
+ t('enableAll'),
2345
+ ),
2346
+ h(
2347
+ 'button',
2348
+ {
2349
+ type: 'button',
2350
+ className: 'pd-btn pd-btn-sm pd-btn-danger',
2351
+ disabled: busy || allLocked || running || autoInScope === 0,
2352
+ title: autoInScope === 0 ? t('rosterEmpty') : undefined,
2353
+ onClick: () => bulk(false, group.scope, autoInScope),
2354
+ },
2355
+ t('disableAll'),
2356
+ ),
2357
+ // 「整批一次」按钮从这里删掉了:更新流程本来就内嵌了整批热替换
2358
+ // (applyUpdates -> hotSwapUpdated),主列表不需要常驻入口;
2359
+ // server 端 /bulk save:true API 保留,真要用加个开关就能回来。
2360
+ h('span', { className: 'pd-group-bulk-sep' }),
2361
+ h(
2362
+ 'button',
2363
+ {
2364
+ type: 'button',
2365
+ className: 'pd-btn pd-btn-sm',
2366
+ disabled: busy || running,
2367
+ onClick: () => setGroupAuto(group.scope, true),
2368
+ },
2369
+ t('markAllAuto'),
2370
+ ),
2371
+ h(
2372
+ 'button',
2373
+ {
2374
+ type: 'button',
2375
+ className: 'pd-btn pd-btn-sm',
2376
+ disabled: busy || running,
2377
+ onClick: () => setGroupAuto(group.scope, false),
2378
+ },
2379
+ t('markNoneAuto'),
2380
+ ),
2381
+ ),
2382
+ h('span', { className: 'pd-colhead-label' }, t('colPower')),
2383
+ h('span', { className: 'pd-colhead-label' }, t('colMode')),
2384
+ h('span', null),
2385
+ h('span', null),
2386
+ ),
2387
+ ),
2388
+ isCollapsed ? null : h('div', null, group.rows.map(rowNode)),
2389
+ );
2390
+ };
2391
+
2392
+ const pickedNames = Object.keys(picked).filter((name) => picked[name] === true);
2393
+ const updateRowNode = (row) =>
2394
+ h(
2395
+ 'label',
2396
+ { key: row.name, className: 'pd-update-row' },
2397
+ h('input', {
2398
+ type: 'checkbox',
2399
+ className: 'pd-check',
2400
+ checked: picked[row.name] === true,
2401
+ disabled: updatesBusy !== '',
2402
+ onChange: () =>
2403
+ setPicked((prev) => ({ ...prev, [row.name]: prev[row.name] !== true })),
2404
+ }),
2405
+ h('span', { className: 'pd-update-name' }, row.name),
2406
+ h('span', { className: 'pd-update-ver' }, row.current + ' → ' + row.latest),
2407
+ );
2408
+
2409
+ const installPanel = h(
2410
+ 'div',
2411
+ { className: 'pd-updates' },
2412
+ h(
2413
+ 'div',
2414
+ { className: 'pd-install-head' },
2415
+ h('span', { className: 'pd-install-label' }, t('installTitle')),
2416
+ h('input', {
2417
+ className: 'pd-input pd-install-input',
2418
+ type: 'text',
2419
+ value: installSpec,
2420
+ placeholder: t('installSpecPlaceholder'),
2421
+ onChange: (event) => {
2422
+ setInstallSpec(event.target.value);
2423
+ setInstallInfo(null);
2424
+ },
2425
+ onKeyDown: (event) => {
2426
+ if (event.key === 'Enter') inspectInstall();
2427
+ },
2428
+ }),
2429
+ h(
2430
+ 'button',
2431
+ {
2432
+ type: 'button',
2433
+ className: 'pd-btn',
2434
+ disabled: installBusy !== '' || installSpec.trim() === '',
2435
+ onClick: inspectInstall,
2436
+ },
2437
+ installBusy === 'check' ? t('updateChecking') : t('installCheck'),
2438
+ ),
2439
+ h(
2440
+ 'button',
2441
+ {
2442
+ type: 'button',
2443
+ className: 'pd-btn pd-btn-primary',
2444
+ disabled: installBusy !== '' || installSpec.trim() === '',
2445
+ onClick: runInstall,
2446
+ },
2447
+ installBusy === 'install' ? t('installRunning') : t('installRun'),
2448
+ ),
2449
+ ),
2450
+ installInfo === null
2451
+ ? null
2452
+ : installInfo.ok === true
2453
+ ? h(
2454
+ 'div',
2455
+ { className: 'pd-update-log pd-update-log-ok' },
2456
+ t('installOk') +
2457
+ ' · ' +
2458
+ String(installInfo.data.name ?? '') +
2459
+ (typeof installInfo.data.version === 'string'
2460
+ ? ' v' + installInfo.data.version
2461
+ : '') +
2462
+ (typeof installInfo.data.kind === 'string' ? ' · ' + installInfo.data.kind : '') +
2463
+ (typeof installInfo.data.description === 'string' && installInfo.data.description !== ''
2464
+ ? ' · ' + installInfo.data.description
2465
+ : ''),
2466
+ )
2467
+ : h(
2468
+ 'div',
2469
+ { className: 'pd-update-log pd-update-log-bad' },
2470
+ t('installRefused') +
2471
+ (installInfo.problem !== '' ? ' · ' + installInfo.problem : '') +
2472
+ (installInfo.message !== '' ? ' · ' + installInfo.message : ''),
2473
+ ),
2474
+ installLog === null
2475
+ ? null
2476
+ : h(
2477
+ 'div',
2478
+ { className: 'pd-update-log ' + (installLog.ok ? 'pd-update-log-ok' : 'pd-update-log-bad') },
2479
+ installLog.ok ? t('installDone') : t('updateFailed'),
2480
+ installLog.data.message ? ' · ' + String(installLog.data.message) : '',
2481
+ Array.isArray(installLog.data.pendingBuilds) && installLog.data.pendingBuilds.length > 0
2482
+ ? ' · ' + installLog.data.pendingBuilds.join(', ')
2483
+ : '',
2484
+ typeof installLog.data.output === 'string' && installLog.data.output !== ''
2485
+ ? h('pre', { className: 'pd-update-log-out' }, installLog.data.output.slice(-1500))
2486
+ : null,
2487
+ ),
2488
+ );
2489
+
2490
+
2491
+ const updatePanel = h(
2492
+ 'div',
2493
+ { className: 'pd-updates' },
2494
+ h(
2495
+ 'div',
2496
+ { className: 'pd-update-bar' },
2497
+ h(
2498
+ 'button',
2499
+ { type: 'button', className: 'pd-btn', disabled: updatesBusy !== '', onClick: () => checkUpdates() },
2500
+ updatesBusy === 'check' ? t('updateChecking') : t('checkUpdates'),
2501
+ ),
2502
+ h(
2503
+ 'button',
2504
+ {
2505
+ type: 'button',
2506
+ className: 'pd-btn pd-btn-primary',
2507
+ disabled: updatesBusy !== '' || pickedNames.length === 0,
2508
+ onClick: applyUpdates,
2509
+ },
2510
+ t('updateAll') + (pickedNames.length > 0 ? ' (' + pickedNames.length + ')' : ''),
2511
+ ),
2512
+ h(
2513
+ 'button',
2514
+ { type: 'button', className: 'pd-btn', onClick: viewLog },
2515
+ t('viewLog'),
2516
+ ),
2517
+ ),
2518
+ logText === null
2519
+ ? null
2520
+ : h(
2521
+ 'div',
2522
+ { className: 'pd-update-log-out' },
2523
+ h('div', null, t('logPath') + ': ' + String(logText.path || '')),
2524
+ h(
2525
+ 'div',
2526
+ { className: 'pd-update-log-out' },
2527
+ logText.text === null || logText.text === undefined
2528
+ ? (logText.error !== undefined ? String(logText.error) : t('logEmpty'))
2529
+ : String(logText.text).slice(-4000),
2530
+ ),
2531
+ ),
2532
+ updates === null
2533
+ ? h(
2534
+ 'div',
2535
+ { className: 'pd-empty' },
2536
+ updatesBusy === 'check' ? t('updateChecking') : t('updateIdle'),
2537
+ )
2538
+ : updates.ok !== true
2539
+ ? h('div', { className: 'pd-empty' }, String(updates.message || 'error'))
2540
+ : updates.rows.filter((row) => row.hasUpdate).length === 0
2541
+ ? h('div', { className: 'pd-empty' }, t('updateNone'))
2542
+ : updates.rows.filter((row) => row.hasUpdate).map(updateRowNode),
2543
+ updateLog === null
2544
+ ? null
2545
+ : h(
2546
+ 'div',
2547
+ {
2548
+ className:
2549
+ 'pd-update-log ' + (updateLog.ok === true ? 'pd-update-log-ok' : 'pd-update-log-bad'),
2550
+ },
2551
+ h(
2552
+ 'div',
2553
+ null,
2554
+ h(
2555
+ 'b',
2556
+ null,
2557
+ updateLog.waiting === true
2558
+ ? '…'
2559
+ : updateLog.ok === true
2560
+ ? t('updateDone') + ' (' + (updateLog.updated ? updateLog.updated.length : 0) + ')'
2561
+ : updateLog.rolledBack === true
2562
+ ? t('updateRolledBack')
2563
+ : t('updateFailed'),
2564
+ ),
2565
+ h(
2566
+ 'span',
2567
+ null,
2568
+ ' · ' +
2569
+ (updateLog.ok === true
2570
+ ? updateLog.partialSwap === true
2571
+ ? // 一部分渠道换进去了、一部分没有:既不能说"已热替换",
2572
+ // 也不能说全都要重启。
2573
+ t('updatePartialSwap')
2574
+ : updateLog.hotSwap && updateLog.hotSwap.ok === true
2575
+ ? t('updateSwapped')
2576
+ : t('updateRestart')
2577
+ : updateLog.waiting === true
2578
+ ? t('updateWait')
2579
+ : String(updateLog.message || '')),
2580
+ ),
2581
+ ),
2582
+ // 只算一次,两个列表共用一份结果:纯函数重复调用没有副作用,
2583
+ // 但两处各算一次意味着两份渲染可能对不上(上一次就是这么漂的)。
2584
+ (() => {
2585
+ const lines = renderUpdateReport(updateLog, { unknown: t('updateUnknown') });
2586
+ return [
2587
+ lines.updated.length > 0
2588
+ ? h(
2589
+ 'div',
2590
+ { key: 'ok-list', className: 'pd-update-list' },
2591
+ lines.updated.map((line, index) =>
2592
+ h('div', { key: index, className: 'pd-update-item' }, line),
2593
+ ),
2594
+ )
2595
+ : null,
2596
+ // failed 是空数组时不画框:一个空的 pd-update-log-out 看起来像
2597
+ // 「有失败但没显示」,比不画更让人困惑。
2598
+ lines.failed.length > 0
2599
+ ? h(
2600
+ 'div',
2601
+ { key: 'fail-list', className: 'pd-update-log-out' },
2602
+ lines.failed.map((line, index) =>
2603
+ h('div', { key: index, className: 'pd-update-item' }, line),
2604
+ ),
2605
+ )
2606
+ : null,
2607
+ ];
2608
+ })(),
2609
+ ),
2610
+ );
2611
+
2612
+ return h(
2613
+ 'div',
2614
+ { className: 'pd-root' },
2615
+ h('style', { dangerouslySetInnerHTML: { __html: CSS } }),
2616
+ h(
2617
+ 'div',
2618
+ { className: 'pd-scroll' },
2619
+ h(
2620
+ 'div',
2621
+ { className: 'pd-inner' },
2622
+ h(
2623
+ 'div',
2624
+ { className: 'pd-head' },
2625
+ h(
2626
+ 'div',
2627
+ null,
2628
+ h('div', { className: 'pd-title' }, t('title')),
2629
+ h('div', { className: 'pd-sub' }, t('subtitle')),
2630
+ ),
2631
+ h(
2632
+ 'div',
2633
+ { className: 'pd-head-act' },
2634
+ h(
2635
+ 'button',
2636
+ {
2637
+ type: 'button',
2638
+ className: 'pd-close',
2639
+ title: t('close'),
2640
+ 'aria-label': t('close'),
2641
+ onClick: () => {
2642
+ ctx.layout.selectPanel(null);
2643
+ },
2644
+ },
2645
+ '×',
2646
+ ),
2647
+ h(
2648
+ 'div',
2649
+ { className: 'pd-seg', role: 'group', 'aria-label': t('title') },
2650
+ h(
2651
+ 'button',
2652
+ {
2653
+ type: 'button',
2654
+ className: 'pd-seg-item' + (locale === 'zh' ? ' pd-seg-on' : ''),
2655
+ 'aria-pressed': locale === 'zh',
2656
+ onClick: () => setLang('zh'),
2657
+ },
2658
+ '中文',
2659
+ ),
2660
+ h(
2661
+ 'button',
2662
+ {
2663
+ type: 'button',
2664
+ className: 'pd-seg-item' + (locale === 'en' ? ' pd-seg-on' : ''),
2665
+ 'aria-pressed': locale === 'en',
2666
+ onClick: () => setLang('en'),
2667
+ },
2668
+ 'English',
2669
+ ),
2670
+ ),
2671
+ ),
2672
+ ),
2673
+ h(
2674
+ 'div',
2675
+ { className: 'pd-toolbar' },
2676
+ h(
2677
+ 'div',
2678
+ { className: 'pd-bar' },
2679
+ h(
2680
+ 'div',
2681
+ { className: 'pd-tabs', role: 'tablist', 'aria-label': t('title') },
2682
+ pages.map((page) =>
2683
+ h(
2684
+ 'button',
2685
+ {
2686
+ key: page.key,
2687
+ type: 'button',
2688
+ role: 'tab',
2689
+ className: 'pd-tab' + (tab === page.key ? ' pd-tab-on' : ''),
2690
+ 'aria-selected': tab === page.key,
2691
+ onClick: () => setTab(page.key),
2692
+ },
2693
+ page.title,
2694
+ (() => {
2695
+ const badge =
2696
+ page.badge === undefined ? page.rows.length : page.badge;
2697
+ return badge === null
2698
+ ? null
2699
+ : h('span', { className: 'pd-tab-n' }, String(badge));
2700
+ })(),
2701
+ ),
2702
+ ),
2703
+ ),
2704
+ h('span', { className: 'pd-sep' }),
2705
+ h('button', { type: 'button', className: 'pd-btn', disabled: busy, onClick: load }, t('refresh')),
2706
+ busy
2707
+ ? h(
2708
+ 'span',
2709
+ { className: 'pd-tag pd-tag-warn pd-tag-busy' },
2710
+ h('span', { className: 'pd-spin' }),
2711
+ t('loading'),
2712
+ )
2713
+ : null,
2714
+ ),
2715
+ job !== null && job.running === true
2716
+ ? h(
2717
+ 'div',
2718
+ { className: 'pd-jobbar' },
2719
+ h('span', { className: 'pd-spin' }),
2720
+ h(
2721
+ 'span',
2722
+ null,
2723
+ job.stage === 'rollback'
2724
+ ? t('jobRollingBack')
2725
+ : t('jobRunning', { n: String(Array.isArray(job.names) ? job.names.length : 0) }),
2726
+ ),
2727
+ // 服务端判断这个任务此刻能不能取消。回滚阶段是唯一必须跑完的
2728
+ // 一段,client 不能靠 job.running 自己猜——那样回滚时按钮还在,
2729
+ // 再点一次会把回滚用的 pnpm install 也杀掉。
2730
+ job.cancelable === false
2731
+ ? null
2732
+ : h(
2733
+ 'button',
2734
+ { className: 'pd-jobbar-cancel', type: 'button', onClick: cancelJob },
2735
+ t('jobCancel'),
2736
+ ),
2737
+ )
2738
+ : queued.length > 0
2739
+ ? h(
2740
+ 'div',
2741
+ { className: 'pd-jobbar' },
2742
+ h('span', null, t('jobQueued', { n: String(queued.length) })),
2743
+ )
2744
+ : null,
2745
+ ),
2746
+ tab === 'updates'
2747
+ ? null
2748
+ : h(
2749
+ 'div',
2750
+ { className: 'pd-stats' },
2751
+ statItem('all', [h('b', { key: 'n' }, String(counts.total)), ' ' + t('unitPlugins')]),
2752
+ statItem('enabled', [t('on') + ' ', h('b', { key: 'n' }, String(counts.on))]),
2753
+ statItem('disabled', [t('off') + ' ', h('b', { key: 'n' }, String(counts.off))]),
2754
+ statItem('auto', [t('auto') + ' ', h('b', { key: 'n' }, String(counts.auto))]),
2755
+ visibleCount === counts.total
2756
+ ? null
2757
+ : h('span', null, t('shown') + ' ', h('b', null, String(visibleCount))),
2758
+ h('input', {
2759
+ className: 'pd-input',
2760
+ type: 'search',
2761
+ value: query,
2762
+ placeholder: t('search'),
2763
+ onChange: (event) => setQuery(event.target.value),
2764
+ }),
2765
+ ),
2766
+ toast !== null ? h('div', { className: 'pd-toast pd-toast-' + toast.kind }, toast.text) : null,
2767
+ bulkAsk !== null
2768
+ ? h(
2769
+ 'div',
2770
+ { className: 'pd-confirm' },
2771
+ h(
2772
+ 'div',
2773
+ { className: 'pd-confirm-text' },
2774
+ h('b', null, t(bulkAsk.enabled ? 'bulkConfirmOn' : 'bulkConfirmOff', { n: bulkAsk.total })),
2775
+ bulkAsk.save === true ? h('span', { className: 'pd-hint' }, t('bulkConfirmSave')) : null,
2776
+ ),
2777
+ h(
2778
+ 'div',
2779
+ { className: 'pd-confirm-act' },
2780
+ h(
2781
+ 'button',
2782
+ {
2783
+ type: 'button',
2784
+ className: 'pd-btn pd-btn-sm pd-btn-primary',
2785
+ onClick: () => doBulk(bulkAsk),
2786
+ },
2787
+ t('bulkConfirmOk'),
2788
+ ),
2789
+ h(
2790
+ 'button',
2791
+ {
2792
+ type: 'button',
2793
+ className: 'pd-btn pd-btn-sm pd-btn-quiet',
2794
+ onClick: () => setBulkAsk(null),
2795
+ },
2796
+ t('bulkCancel'),
2797
+ ),
2798
+ ),
2799
+ )
2800
+ : null,
2801
+ bulkRun !== null
2802
+ ? h(
2803
+ 'div',
2804
+ { className: 'pd-toast pd-toast-ok pd-toast-busy' },
2805
+ h('span', { className: 'pd-spin' }),
2806
+ t('runningBulk', { n: bulkRun.finished, total: bulkRun.total }) +
2807
+ (bulkRun.current ? ' · ' + bulkRun.current : '') +
2808
+ (bulkRun.guarded > 0 ? ' · ' + t('withGuarded', { n: bulkRun.guarded }) : ''),
2809
+ h(
2810
+ 'button',
2811
+ {
2812
+ type: 'button',
2813
+ className: 'pd-btn pd-btn-sm pd-btn-quiet',
2814
+ disabled: bulkRun.cancelled === true,
2815
+ onClick: () => {
2816
+ request('/bulk', { cancel: true }).then(load, load);
2817
+ },
2818
+ },
2819
+ bulkRun.cancelled === true ? t('stopping') : t('stop'),
2820
+ ),
2821
+ )
2822
+ : null,
2823
+ tab === 'updates'
2824
+ ? h('div', null, installPanel, updatePanel)
2825
+ : groups.length === 0
2826
+ ? h('div', { className: 'pd-empty' }, t('empty'))
2827
+ : groups.map(groupNode),
2828
+ h(
2829
+ 'div',
2830
+ { className: 'pd-foot' },
2831
+ t('persisted') + ' ' + data.statePath + (data.storage === 'memory' ? ' ' + t('memoryOnly') : ''),
2832
+ ),
2833
+ ),
2834
+ ),
2835
+ );
2836
+ }
2837
+
2838
+ return Dashboard;
2839
+ }
2840
+
2841
+ return {
2842
+ inject: ['slots', 'locale', 'layout'],
2843
+ apply(ctx) {
2844
+ const Dashboard = makeDashboard(ctx);
2845
+
2846
+ ctx.effect(() => ctx.locale.register(NS, 'en', DICT.en));
2847
+ ctx.effect(() => ctx.locale.register(NS, 'zh', DICT.zh));
2848
+
2849
+ ctx.slots.inject('sidebar.panellist', () =>
2850
+ ctx.slots.register(
2851
+ {
2852
+ name: 'sidebar.panellist',
2853
+ id: PANEL_ID,
2854
+ order: 25,
2855
+ label: () => DICT[pickLocale(ctx)].title,
2856
+ },
2857
+ SidebarIcon,
2858
+ ),
2859
+ );
2860
+
2861
+ ctx.slots.inject('main', () =>
2862
+ ctx.slots.register({ name: 'main', key: PANEL_ID }, Dashboard),
2863
+ );
2864
+ },
2865
+ };
2866
+ },
2867
+ });