dsh-autorestart 0.8.2 → 0.8.4
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/CHANGELOG.md +50 -1
- package/README.md +39 -14
- package/client.js +952 -56
- package/docs/README.zh.md +17 -10
- package/index.js +26 -7
- package/lib/checkpoint.js +23 -4
- package/lib/restart-phase.js +52 -11
- package/locale/en.json +26 -5
- package/locale/zh.json +26 -5
- package/package.json +1 -1
package/client.js
CHANGED
|
@@ -155,7 +155,7 @@
|
|
|
155
155
|
const ZH = {
|
|
156
156
|
label: '待重启',
|
|
157
157
|
busyPrefix: ' · 等待 ',
|
|
158
|
-
busySuffix: '
|
|
158
|
+
busySuffix: ' 个会话',
|
|
159
159
|
idle: ' · 空闲后自动重启',
|
|
160
160
|
goalActive: ' · 被某个自动续跑的 goal 占着',
|
|
161
161
|
goalExhausted: ' · 某个任务的 goal 轮次额度已用完,无法续跑',
|
|
@@ -166,7 +166,7 @@
|
|
|
166
166
|
parkBusy: '停泊中…',
|
|
167
167
|
parkError: '停泊失败:{message}',
|
|
168
168
|
tooltip: '等待其他会话空闲后自动重启;重启后会自动继续本会话进度',
|
|
169
|
-
tooltipBusy: '
|
|
169
|
+
tooltipBusy: '等待其他会话结束(计数含“排队但还没开始”的会话)后自动重启;重启后本会话会自动继续进度',
|
|
170
170
|
tooltipWithError: '等待其他会话空闲后自动重启;重启后本会话会自动继续进度。{message}',
|
|
171
171
|
dotAria: '待重启',
|
|
172
172
|
panelHeading: '待重启',
|
|
@@ -219,7 +219,7 @@
|
|
|
219
219
|
// The manual cancel of the capsule: the panel, the confirmation and the
|
|
220
220
|
// readable reasons. The capsule's own text stays free of hints — the panel
|
|
221
221
|
// is the discoverability.
|
|
222
|
-
cancelPanelTasks: '
|
|
222
|
+
cancelPanelTasks: '还在等 {n} 个会话',
|
|
223
223
|
cancelPanelOthers: '还有 {n} 个会话也在等这次重启',
|
|
224
224
|
cancelPanelOthersAfter: '还剩 {n} 个会话在等',
|
|
225
225
|
cancelPanelAria: '待重启详情',
|
|
@@ -375,8 +375,10 @@
|
|
|
375
375
|
'settings.backup.copyPath': '复制路径',
|
|
376
376
|
'settings.backup.copied': '路径已复制',
|
|
377
377
|
'settings.backup.copyFailed': '复制失败,请手动选中上面的路径',
|
|
378
|
-
'settings.backup.
|
|
379
|
-
'settings.backup.
|
|
378
|
+
'settings.backup.time': '备份时间',
|
|
379
|
+
'settings.backup.size': '备份大小',
|
|
380
|
+
'settings.backup.withPlugins': '(含已装插件)',
|
|
381
|
+
'settings.backup.withoutPlugins': '(不含已装插件)',
|
|
380
382
|
'settings.groups.report': '报告问题',
|
|
381
383
|
'settings.groups.rescue': '出事了怎么办',
|
|
382
384
|
'settings.rescue.hint': 'DSH 打不开的时候,用下面这两个脚本救自己(都不用 DSH 跑起来)。',
|
|
@@ -412,6 +414,9 @@
|
|
|
412
414
|
'settings.report.issueTitle': '重启 / 开机自启相关的问题',
|
|
413
415
|
'settings.report.issueBody': '请把刚才复制的诊断信息粘贴到下面。\n\n## 发生了什么\n\n\n## 期望什么\n\n\n## 怎么复现\n',
|
|
414
416
|
'settings.error': '没能保存设置:{message}',
|
|
417
|
+
'settings.saveNotice.saved': '已生效',
|
|
418
|
+
'settings.saveNotice.failed': '没保存成功',
|
|
419
|
+
'settings.saveNotice.dismiss': '关闭',
|
|
415
420
|
'sleep.dryRun.title': '试运行:不会真的关机',
|
|
416
421
|
'sleep.dryRun.body': '彩排结束,本来会执行这条命令(真的什么都没执行):',
|
|
417
422
|
'sleep.dryRun.close': '知道了',
|
|
@@ -473,12 +478,32 @@
|
|
|
473
478
|
'announcement.disabled': '按钮目前关闭,可在设置里打开。',
|
|
474
479
|
'announcement.button': '知道了',
|
|
475
480
|
'announcement.error': '没能记下来:{message}',
|
|
481
|
+
// THE COPY OF THE COMPOSER ROW. It is one SHORT line and never a sentence:
|
|
482
|
+
// the long stage sentence was clipped away there (user report,
|
|
483
|
+
// 2026-10-29). While the gate waits the pill keeps its own composed line
|
|
484
|
+
// ("待重启 · 空闲后自动重启" / "待重启 · 等待 N 个会话"); the three
|
|
485
|
+
// other stages REPLACE that line with one of these.
|
|
486
|
+
'restart.stage.snapshotting': '重启前备份中',
|
|
487
|
+
'restart.stage.restarting': '正在重启…',
|
|
488
|
+
'restart.stage.back': '已回来',
|
|
489
|
+
// The centred overlay of a restart that is REALLY RUNNING (the backup, the
|
|
490
|
+
// shutdown, the return): one short line per stage, never wrapped, never cut
|
|
491
|
+
// with an ellipsis (the card shrinks its font instead). The numbers come
|
|
492
|
+
// from the Host's real measurements. There is no copy for `waiting`: no
|
|
493
|
+
// card is drawn while the gate merely waits.
|
|
494
|
+
'restart.overlay.snapshotting': 'DSH 重启前备份中',
|
|
495
|
+
// The unit travels WITH the number (KB or MB, chosen by the size): a 92 KB
|
|
496
|
+
// profile must never be rounded down to "0 MB".
|
|
497
|
+
'restart.overlay.size': '{done} / {total}',
|
|
498
|
+
'restart.overlay.sizeDone': '{done}',
|
|
499
|
+
'restart.overlay.restarting': '正在重启…',
|
|
500
|
+
'restart.overlay.back': '已回来',
|
|
476
501
|
}
|
|
477
502
|
|
|
478
503
|
const EN = {
|
|
479
504
|
label: 'Pending restart',
|
|
480
505
|
busyPrefix: ' · waiting for ',
|
|
481
|
-
busySuffix: '
|
|
506
|
+
busySuffix: ' session(s)',
|
|
482
507
|
idle: ' · restarts once idle',
|
|
483
508
|
goalActive: ' · held by a self-continuing goal',
|
|
484
509
|
goalExhausted: ' · a goal ran out of rounds',
|
|
@@ -491,7 +516,7 @@
|
|
|
491
516
|
tooltip:
|
|
492
517
|
'Waits for the other sessions to become idle, then restarts automatically; this session resumes its progress after the restart.',
|
|
493
518
|
tooltipBusy:
|
|
494
|
-
'Waits for the other
|
|
519
|
+
'Waits for the other sessions to finish (the count includes work that is queued but not started yet), then restarts automatically; this session resumes its progress after the restart.',
|
|
495
520
|
tooltipWithError:
|
|
496
521
|
'Waits for the other sessions to become idle, then restarts automatically; this session resumes its progress after the restart. {message}',
|
|
497
522
|
dotAria: 'Pending restart',
|
|
@@ -543,7 +568,7 @@
|
|
|
543
568
|
// The manual cancel of the capsule: the panel, the confirmation and the
|
|
544
569
|
// readable reasons. The capsule's own text stays free of hints — the panel
|
|
545
570
|
// is the discoverability.
|
|
546
|
-
cancelPanelTasks: '
|
|
571
|
+
cancelPanelTasks: 'Still waiting for {n} session(s)',
|
|
547
572
|
cancelPanelOthers: '{n} other session(s) are waiting for this restart too',
|
|
548
573
|
cancelPanelOthersAfter: '{n} session(s) are still waiting',
|
|
549
574
|
cancelPanelAria: 'Pending restart details',
|
|
@@ -699,8 +724,10 @@
|
|
|
699
724
|
'settings.backup.copyPath': 'Copy the path',
|
|
700
725
|
'settings.backup.copied': 'The path is on the clipboard',
|
|
701
726
|
'settings.backup.copyFailed': 'The copy failed — please select the path above by hand',
|
|
702
|
-
'settings.backup.
|
|
703
|
-
'settings.backup.
|
|
727
|
+
'settings.backup.time': 'Backup time',
|
|
728
|
+
'settings.backup.size': 'Backup size',
|
|
729
|
+
'settings.backup.withPlugins': '(plugins included)',
|
|
730
|
+
'settings.backup.withoutPlugins': '(plugins excluded)',
|
|
704
731
|
'settings.groups.report': 'Report a problem',
|
|
705
732
|
'settings.groups.rescue': 'If DSH will not start',
|
|
706
733
|
'settings.rescue.hint': 'When DSH does not come up, these two scripts rescue it for you (neither of them needs DSH running).',
|
|
@@ -736,6 +763,9 @@
|
|
|
736
763
|
'settings.report.issueTitle': 'A problem with the restart / start at login',
|
|
737
764
|
'settings.report.issueBody': 'Please paste the diagnostics you just copied below.\n\n## What happened\n\n\n## What you expected\n\n\n## How to reproduce\n',
|
|
738
765
|
'settings.error': 'Could not save the settings: {message}',
|
|
766
|
+
'settings.saveNotice.saved': 'Saved',
|
|
767
|
+
'settings.saveNotice.failed': 'Not saved',
|
|
768
|
+
'settings.saveNotice.dismiss': 'Dismiss',
|
|
739
769
|
'sleep.dryRun.title': 'Dry run: the machine stays on',
|
|
740
770
|
'sleep.dryRun.body': 'The rehearsal is over. This is the command it WOULD have run (nothing was executed):',
|
|
741
771
|
'sleep.dryRun.close': 'Got it',
|
|
@@ -797,6 +827,17 @@
|
|
|
797
827
|
'announcement.disabled': 'The button is currently switched off; you can turn it on in the settings.',
|
|
798
828
|
'announcement.button': 'Got it',
|
|
799
829
|
'announcement.error': 'Could not record it: {message}',
|
|
830
|
+
// THE COPY OF THE COMPOSER ROW (see the Chinese block): one short line,
|
|
831
|
+
// never a sentence.
|
|
832
|
+
'restart.stage.snapshotting': 'Backing up…',
|
|
833
|
+
'restart.stage.restarting': 'Restarting…',
|
|
834
|
+
'restart.stage.back': 'Back',
|
|
835
|
+
// The centred overlay of a running restart.
|
|
836
|
+
'restart.overlay.snapshotting': 'Backing up before DSH restart',
|
|
837
|
+
'restart.overlay.size': '{done} / {total}',
|
|
838
|
+
'restart.overlay.sizeDone': '{done}',
|
|
839
|
+
'restart.overlay.restarting': 'Restarting…',
|
|
840
|
+
'restart.overlay.back': 'Back again',
|
|
800
841
|
}
|
|
801
842
|
|
|
802
843
|
/**
|
|
@@ -1384,6 +1425,40 @@
|
|
|
1384
1425
|
return families.size
|
|
1385
1426
|
}
|
|
1386
1427
|
|
|
1428
|
+
/**
|
|
1429
|
+
* THE number of tasks the pending restart waits for — ONE source for every
|
|
1430
|
+
* surface of the interface.
|
|
1431
|
+
*
|
|
1432
|
+
* THE DEFECT THIS FIXES (user report, 2026-10-29)
|
|
1433
|
+
*
|
|
1434
|
+
* The capsule said "还有 1 个会话在忙" while the panel it opens said
|
|
1435
|
+
* "这次重启还在等 0 个会话". Two numbers, ONE fact: the capsule counted
|
|
1436
|
+
* the blockers itself, the panel re-counted a FILTERED copy of them (the own
|
|
1437
|
+
* session dropped out, the goal blockers dropped out) — so the same pending
|
|
1438
|
+
* restart was "1" on one line and "0" on the next.
|
|
1439
|
+
*
|
|
1440
|
+
* THE RULE
|
|
1441
|
+
*
|
|
1442
|
+
* The Host counts the TASKS that hold the gate and publishes the number as
|
|
1443
|
+
* `busyCount` on its state route. Everything reads THAT number — the
|
|
1444
|
+
* capsule, its panel and the long-wait line — so the two can never disagree
|
|
1445
|
+
* again. Only a payload without the number (an older Host, a test fixture)
|
|
1446
|
+
* falls back to counting the blockers, and then both surfaces count the SAME
|
|
1447
|
+
* list.
|
|
1448
|
+
*
|
|
1449
|
+
* A blocker WITHOUT a readable name is still a task: the count never drops
|
|
1450
|
+
* to 0 just because the name is missing.
|
|
1451
|
+
*
|
|
1452
|
+
* @param {any} info the state payload of the Host
|
|
1453
|
+
* @param {any[]} [entries] the list to count when the Host sent no number
|
|
1454
|
+
* @returns {number}
|
|
1455
|
+
*/
|
|
1456
|
+
function waitingTaskCount(info, entries) {
|
|
1457
|
+
const host = info && typeof info === 'object' ? info.busyCount : undefined
|
|
1458
|
+
if (typeof host === 'number' && Number.isFinite(host) && host >= 0) return Math.floor(host)
|
|
1459
|
+
return countBlockerTasks(Array.isArray(entries) ? entries : [])
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1387
1462
|
/**
|
|
1388
1463
|
* How many of those tasks are carried by SUBAGENTS ONLY.
|
|
1389
1464
|
*
|
|
@@ -1616,9 +1691,9 @@
|
|
|
1616
1691
|
// capsule: the capsule cannot afford "16 分 37 秒".
|
|
1617
1692
|
const duration = formatDurationMs(longWait.waitedMs, translate)
|
|
1618
1693
|
const compact = formatCompactDurationMs(longWait.waitedMs, translate)
|
|
1619
|
-
//
|
|
1620
|
-
//
|
|
1621
|
-
const count =
|
|
1694
|
+
// THE same number the capsule and its panel show (the Host's `busyCount`
|
|
1695
|
+
// when it sends one): a session and its own subagents are ONE task.
|
|
1696
|
+
const count = waitingTaskCount(info, blockedBy)
|
|
1622
1697
|
// The tooltip: the whole story, every name, nothing cut.
|
|
1623
1698
|
const sentence = fillTemplate(translate('longWaitTooltip'), { duration, blockers: summary.full })
|
|
1624
1699
|
return {
|
|
@@ -1989,7 +2064,11 @@
|
|
|
1989
2064
|
}
|
|
1990
2065
|
|
|
1991
2066
|
/**
|
|
1992
|
-
* The size of one backup,
|
|
2067
|
+
* The size of one backup, with the unit that fits it (the manifest carries
|
|
2068
|
+
* the byte count).
|
|
2069
|
+
*
|
|
2070
|
+
* 92 KB stays 92 KB and 156 MB stays 156 MB: the tenth is kept only when it
|
|
2071
|
+
* says something ("117.7 MB"), never as a pointless "156.0 MB".
|
|
1993
2072
|
*
|
|
1994
2073
|
* @param {number|null|undefined} bytes
|
|
1995
2074
|
* @returns {string}
|
|
@@ -1997,21 +2076,150 @@
|
|
|
1997
2076
|
function formatBackupSize(bytes) {
|
|
1998
2077
|
const value = Number(bytes)
|
|
1999
2078
|
if (!Number.isFinite(value) || value < 0) return ''
|
|
2000
|
-
|
|
2079
|
+
const units = [
|
|
2080
|
+
{ size: 1024 * 1024 * 1024, suffix: 'GB' },
|
|
2081
|
+
{ size: 1024 * 1024, suffix: 'MB' },
|
|
2082
|
+
{ size: 1024, suffix: 'KB' },
|
|
2083
|
+
]
|
|
2084
|
+
for (const unit of units) {
|
|
2085
|
+
if (value < unit.size) continue
|
|
2086
|
+
const scaled = Math.round((value / unit.size) * 10) / 10
|
|
2087
|
+
// 156.0 → 156, 117.7 → 117.7: the tenth only when it says something.
|
|
2088
|
+
return (Number.isInteger(scaled) ? String(scaled) : scaled.toFixed(1)) + ' ' + unit.suffix
|
|
2089
|
+
}
|
|
2090
|
+
return Math.round(value) + ' B'
|
|
2001
2091
|
}
|
|
2002
2092
|
|
|
2003
2093
|
/**
|
|
2004
|
-
* The local time of one backup.
|
|
2094
|
+
* The local time of one backup, to the minute.
|
|
2005
2095
|
*
|
|
2006
2096
|
* The directory name is UTC on purpose, so the panel shows the LOCAL time
|
|
2007
2097
|
* the manifest carries: a fresh snapshot can never look like an old one.
|
|
2008
2098
|
*
|
|
2099
|
+
* The OFFSET is dropped on purpose (user report, 2026-10-29): the string is
|
|
2100
|
+
* already the local time of the machine that took the copy, so "+08:00" is
|
|
2101
|
+
* noise to whoever reads it — and the seconds never say anything either.
|
|
2102
|
+
*
|
|
2009
2103
|
* @param {string|null|undefined} atLocal
|
|
2010
2104
|
* @returns {string}
|
|
2011
2105
|
*/
|
|
2012
2106
|
function formatBackupTime(atLocal) {
|
|
2013
|
-
if (typeof atLocal !== 'string' || atLocal === '') return ''
|
|
2014
|
-
|
|
2107
|
+
if (typeof atLocal !== 'string' || atLocal.trim() === '') return ''
|
|
2108
|
+
const text = atLocal.trim().replace('T', ' ')
|
|
2109
|
+
// "2026-10-29 22:08:46+08:00" → "2026-10-29 22:08" (date and minute only).
|
|
2110
|
+
const matched = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.exec(text)
|
|
2111
|
+
if (matched) return matched[0]
|
|
2112
|
+
// Anything else: at least never show an offset.
|
|
2113
|
+
return text.replace(/ ?([+-]\d{2}:?\d{2}|Z)$/, '')
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
/**
|
|
2117
|
+
* THE COPY OF THE COMPOSER ROW: one SHORT line, never a sentence.
|
|
2118
|
+
*
|
|
2119
|
+
* WHY (user report, 2026-10-29): the pill sits in the composer tool row — a
|
|
2120
|
+
* single clipped line inside a 22px toolbar. The long stage sentence
|
|
2121
|
+
* ("正在等待所有会话空闲…(还有 1 个会话在忙)") was cut off on a narrow
|
|
2122
|
+
* window and the ellipsis ate exactly the part that mattered.
|
|
2123
|
+
*
|
|
2124
|
+
* SO the pill now shows ONE of these, and nothing else:
|
|
2125
|
+
*
|
|
2126
|
+
* while the gate waits 待重启 · 空闲后自动重启 (the composed line of
|
|
2127
|
+
* the pill itself — it already carries the short
|
|
2128
|
+
* count of the tasks that hold the gate)
|
|
2129
|
+
* while the snapshot runs 重启前备份中
|
|
2130
|
+
* while the host goes down 正在重启…
|
|
2131
|
+
* after the new host is up 已回来 (1.5s, then the pill is plain)
|
|
2132
|
+
*
|
|
2133
|
+
* The detail — the number of busy conversations, the megabytes of the copy,
|
|
2134
|
+
* the progress bar — lives in the CENTRED OVERLAY (`restart.overlay.*`),
|
|
2135
|
+
* where there is room for it. The words come from the same dictionaries as
|
|
2136
|
+
* everything else, so no surface has a second source of truth.
|
|
2137
|
+
*/
|
|
2138
|
+
const RESTART_PILL_STAGE_KEYS = Object.freeze({
|
|
2139
|
+
// `waiting` is deliberately MISSING: while the gate waits the pill already
|
|
2140
|
+
// says "待重启 · 等待 N 个…" / "待重启 · 空闲后自动重启", and saying
|
|
2141
|
+
// the stage as well would put two lines into a one-line row.
|
|
2142
|
+
snapshotting: 'restart.stage.snapshotting',
|
|
2143
|
+
restarting: 'restart.stage.restarting',
|
|
2144
|
+
back: 'restart.stage.back',
|
|
2145
|
+
})
|
|
2146
|
+
|
|
2147
|
+
/**
|
|
2148
|
+
* The locale keys of the centred overlay.
|
|
2149
|
+
*
|
|
2150
|
+
* `waiting` is deliberately MISSING: while the gate waits for the other
|
|
2151
|
+
* conversations there is NOTHING in progress yet, so no card is drawn at all
|
|
2152
|
+
* (user report, 2026-10-29: "不是应该触发备份才触发吗?备份完就消失" — a card
|
|
2153
|
+
* that said "等待会话空闲" hung on screen for ten minutes while the request
|
|
2154
|
+
* merely queued). The waiting count belongs to the pill, which is where it
|
|
2155
|
+
* always was: "待重启 · 等待 N 个会话".
|
|
2156
|
+
*/
|
|
2157
|
+
const RESTART_OVERLAY_KEYS = Object.freeze({
|
|
2158
|
+
snapshotting: 'restart.overlay.snapshotting',
|
|
2159
|
+
restarting: 'restart.overlay.restarting',
|
|
2160
|
+
back: 'restart.overlay.back',
|
|
2161
|
+
})
|
|
2162
|
+
|
|
2163
|
+
/**
|
|
2164
|
+
* Is this a stage the centred overlay draws at all?
|
|
2165
|
+
*
|
|
2166
|
+
* @param {unknown} phase
|
|
2167
|
+
* @returns {boolean}
|
|
2168
|
+
*/
|
|
2169
|
+
function isRestartOverlayPhase(phase) {
|
|
2170
|
+
return typeof phase === 'string' && Object.prototype.hasOwnProperty.call(RESTART_OVERLAY_KEYS, phase)
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
/**
|
|
2174
|
+
* THE COPY OF THE COMPOSER ROW for a stage ('' = the pill keeps its own
|
|
2175
|
+
* composed line, "待重启 · …").
|
|
2176
|
+
*
|
|
2177
|
+
* @param {{ phase?: string }|null|undefined} stage the `restartPhase` payload
|
|
2178
|
+
* @param {Function} [translator] the translator (the built-in one otherwise)
|
|
2179
|
+
* @returns {string}
|
|
2180
|
+
*/
|
|
2181
|
+
function restartPillStageText(stage, translator) {
|
|
2182
|
+
const phase = stage && typeof stage === 'object' && typeof stage.phase === 'string' ? stage.phase : ''
|
|
2183
|
+
if (!Object.prototype.hasOwnProperty.call(RESTART_PILL_STAGE_KEYS, phase)) return ''
|
|
2184
|
+
const t = typeof translator === 'function' ? translator : makeTranslate(null)
|
|
2185
|
+
return t(RESTART_PILL_STAGE_KEYS[phase])
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
/**
|
|
2189
|
+
* The short line of the centred overlay for a stage ('' when there is none).
|
|
2190
|
+
*
|
|
2191
|
+
* @param {{ phase?: string }|null|undefined} stage
|
|
2192
|
+
* @param {Function} [translator]
|
|
2193
|
+
* @returns {string}
|
|
2194
|
+
*/
|
|
2195
|
+
function restartOverlayTitle(stage, translator) {
|
|
2196
|
+
const phase = stage && typeof stage === 'object' && typeof stage.phase === 'string' ? stage.phase : ''
|
|
2197
|
+
if (!isRestartOverlayPhase(phase)) return ''
|
|
2198
|
+
const t = typeof translator === 'function' ? translator : makeTranslate(null)
|
|
2199
|
+
return t(RESTART_OVERLAY_KEYS[phase])
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
/**
|
|
2203
|
+
* A SIZE, WITH THE UNIT THAT FITS IT: "92 KB", "156 MB", "12.3 MB", "0 B".
|
|
2204
|
+
*
|
|
2205
|
+
* WHY the unit is chosen here (user report, 2026-10-29): a hosted profile is
|
|
2206
|
+
* only 92 KB. Printed in megabytes that is "0.1 MB" at best and "0 MB" after
|
|
2207
|
+
* rounding — the size of a real backup would have looked like nothing at all.
|
|
2208
|
+
* So: below 1 MB the number is KB, below 1 KB it is B, and a whole number of
|
|
2209
|
+
* megabytes keeps no ".0".
|
|
2210
|
+
*
|
|
2211
|
+
* @param {number|null|undefined} bytes
|
|
2212
|
+
* @returns {string} '' when the Host did not measure anything
|
|
2213
|
+
*/
|
|
2214
|
+
function formatOverlaySize(bytes) {
|
|
2215
|
+
// No measurement is NOT a measurement of zero: a missing byte count must
|
|
2216
|
+
// never be printed as "0" (Number(null) is 0, which is exactly the trap).
|
|
2217
|
+
if (bytes === null || bytes === undefined || bytes === '') return ''
|
|
2218
|
+
const value = Number(bytes)
|
|
2219
|
+
if (!Number.isFinite(value) || value < 0) return ''
|
|
2220
|
+
if (value < 1024) return Math.round(value) + ' B'
|
|
2221
|
+
if (value < 1024 * 1024) return Math.round(value / 1024) + ' KB'
|
|
2222
|
+
return String(Math.round((value / (1024 * 1024)) * 10) / 10) + ' MB'
|
|
2015
2223
|
}
|
|
2016
2224
|
|
|
2017
2225
|
/**
|
|
@@ -2035,8 +2243,10 @@
|
|
|
2035
2243
|
summarizeBlockers,
|
|
2036
2244
|
truncateBlockerName,
|
|
2037
2245
|
// The task count: one rule, four surfaces (mirrored from lib/blockers.js).
|
|
2246
|
+
// `waitingTaskCount` is THE number the capsule and its panel both show.
|
|
2038
2247
|
blockerTaskKeys,
|
|
2039
2248
|
countBlockerTasks,
|
|
2249
|
+
waitingTaskCount,
|
|
2040
2250
|
countSubagentTasks,
|
|
2041
2251
|
resolveAccessRedirect,
|
|
2042
2252
|
supervisorModeLabel,
|
|
@@ -2062,6 +2272,14 @@
|
|
|
2062
2272
|
startupStatusLine,
|
|
2063
2273
|
formatBackupSize,
|
|
2064
2274
|
formatBackupTime,
|
|
2275
|
+
// The stage of a restart on the two surfaces that have room for it: the
|
|
2276
|
+
// composer row (one short line) and the centred overlay (the detail).
|
|
2277
|
+
isRestartOverlayPhase,
|
|
2278
|
+
restartPillStageText,
|
|
2279
|
+
restartOverlayTitle,
|
|
2280
|
+
// The size with the unit that fits it ("92 KB", "156 MB"): a hosted profile
|
|
2281
|
+
// is small enough that megabytes would round it to "0 MB".
|
|
2282
|
+
formatOverlaySize,
|
|
2065
2283
|
// The TRANSLATOR: the Host's one when it answers, the built-in dictionary
|
|
2066
2284
|
// when it does not (a key name is never shown).
|
|
2067
2285
|
makeTranslate,
|
|
@@ -2591,6 +2809,87 @@
|
|
|
2591
2809
|
opacity: 0.85,
|
|
2592
2810
|
}
|
|
2593
2811
|
|
|
2812
|
+
// HOW LONG THE "已回来" CARD STAYS after the new host is up (ms).
|
|
2813
|
+
const RESTART_OVERLAY_BACK_MS = 1500
|
|
2814
|
+
// The id of the injected stylesheet, so a second mount never duplicates it.
|
|
2815
|
+
const RESTART_OVERLAY_STYLE_ID = 'dsh-autorestart-restart-overlay'
|
|
2816
|
+
|
|
2817
|
+
// THE CENTRED OVERLAY of a running restart.
|
|
2818
|
+
//
|
|
2819
|
+
// WHY IT EXISTS (user report, 2026-10-29): the stage line lived in the
|
|
2820
|
+
// composer row, a single clipped line inside a 22px toolbar — on a narrow
|
|
2821
|
+
// window "正在等待所有会话空闲…(还有 1 个会话在忙)" was cut off, and an
|
|
2822
|
+
// ellipsis ate exactly the part that mattered. The detail therefore moved
|
|
2823
|
+
// OUT of the composer row onto the middle of the interface: a fixed 320px
|
|
2824
|
+
// card, one short line per stage, a real progress bar for the backup.
|
|
2825
|
+
//
|
|
2826
|
+
// TWO RULES:
|
|
2827
|
+
// 1. IT NEVER COVERS THE INPUT. `pointer-events: none` and a translation
|
|
2828
|
+
// only — every click goes through to the interface below, and the
|
|
2829
|
+
// card sits in the middle of the conversation, not over the composer;
|
|
2830
|
+
// 2. NO TEXT IS EVER CUT. The card keeps its line unwrapped, has no
|
|
2831
|
+
// `text-overflow` at all, and shrinks its font on a narrow window
|
|
2832
|
+
// (`clamp`), so the number of the backup is never replaced by "…".
|
|
2833
|
+
const RESTART_OVERLAY_CSS = [
|
|
2834
|
+
'.dsh-autorestart-restart-overlay{',
|
|
2835
|
+
' position:fixed;',
|
|
2836
|
+
' top:44%;',
|
|
2837
|
+
' left:50%;',
|
|
2838
|
+
' transform:translate(-50%,-50%);',
|
|
2839
|
+
' z-index:9997;',
|
|
2840
|
+
' box-sizing:border-box;',
|
|
2841
|
+
' width:min(320px,calc(100vw - 32px));',
|
|
2842
|
+
' padding:14px 16px;',
|
|
2843
|
+
' border:1px solid var(--dsw-alias-border-l1);',
|
|
2844
|
+
' border-radius:14px;',
|
|
2845
|
+
' background:color-mix(in srgb, var(--dsw-alias-bg-layer-1) 88%, transparent);',
|
|
2846
|
+
' box-shadow:0 10px 30px color-mix(in srgb, var(--dsw-alias-bg-mask-3) 20%, transparent);',
|
|
2847
|
+
' color:var(--dsw-alias-label-primary);',
|
|
2848
|
+
' text-align:center;',
|
|
2849
|
+
' pointer-events:none;',
|
|
2850
|
+
'}',
|
|
2851
|
+
'.dsh-autorestart-restart-overlay__title{',
|
|
2852
|
+
' margin:0;',
|
|
2853
|
+
' font-size:clamp(11px,2.6vw,14px);',
|
|
2854
|
+
' font-weight:600;',
|
|
2855
|
+
' line-height:1.4;',
|
|
2856
|
+
' white-space:nowrap;',
|
|
2857
|
+
' overflow:visible;',
|
|
2858
|
+
'}',
|
|
2859
|
+
'.dsh-autorestart-restart-overlay__detail{',
|
|
2860
|
+
' margin:4px 0 0;',
|
|
2861
|
+
' font-size:clamp(10px,2.2vw,12.5px);',
|
|
2862
|
+
' line-height:1.5;',
|
|
2863
|
+
' color:var(--dsw-alias-label-secondary);',
|
|
2864
|
+
' white-space:nowrap;',
|
|
2865
|
+
' overflow:visible;',
|
|
2866
|
+
'}',
|
|
2867
|
+
'.dsh-autorestart-restart-overlay__bar{',
|
|
2868
|
+
' margin:9px 0 0;',
|
|
2869
|
+
' height:6px;',
|
|
2870
|
+
' border-radius:999px;',
|
|
2871
|
+
' background:color-mix(in srgb, var(--dsw-alias-label-primary) 14%, transparent);',
|
|
2872
|
+
' overflow:hidden;',
|
|
2873
|
+
'}',
|
|
2874
|
+
'.dsh-autorestart-restart-overlay__bar-fill{',
|
|
2875
|
+
' height:100%;',
|
|
2876
|
+
' border-radius:999px;',
|
|
2877
|
+
' background:var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#5b8def));',
|
|
2878
|
+
' transition:width .3s ease-out;',
|
|
2879
|
+
'}',
|
|
2880
|
+
'.dsh-autorestart-restart-overlay__size{',
|
|
2881
|
+
' display:flex;',
|
|
2882
|
+
' align-items:baseline;',
|
|
2883
|
+
' justify-content:space-between;',
|
|
2884
|
+
' gap:8px;',
|
|
2885
|
+
' margin:6px 0 0;',
|
|
2886
|
+
' font-size:clamp(10px,2.2vw,12px);',
|
|
2887
|
+
' line-height:1.5;',
|
|
2888
|
+
' color:var(--dsw-alias-label-secondary);',
|
|
2889
|
+
' white-space:nowrap;',
|
|
2890
|
+
'}',
|
|
2891
|
+
].join('\n')
|
|
2892
|
+
|
|
2594
2893
|
// The details panel: anchored to the wrapper, above the pill, left
|
|
2595
2894
|
// aligned. It uses the host theme tokens (background / border / label
|
|
2596
2895
|
// color) so it follows the app theme.
|
|
@@ -2964,7 +3263,17 @@
|
|
|
2964
3263
|
// whether a cancel is still honest. Both come from the Host: the pill
|
|
2965
3264
|
// never guesses the stage and never invents progress.
|
|
2966
3265
|
const stage = info && info.restartPhase && typeof info.restartPhase === 'object' ? info.restartPhase : null
|
|
2967
|
-
|
|
3266
|
+
// THE COPY OF THE COMPOSER ROW: ONE short line, never a sentence.
|
|
3267
|
+
// waiting (or no stage at all): the pill keeps its own composed line —
|
|
3268
|
+
// "待重启 · 空闲后自动重启" / "待重启 · 等待 N 个…"
|
|
3269
|
+
// (the noun of that count belongs to the capsule's own copy);
|
|
3270
|
+
// snapshotting / restarting / back: the stage REPLACES that line —
|
|
3271
|
+
// "重启前备份中" / "正在重启…" / "已回来", and nothing else.
|
|
3272
|
+
// The sentence that used to sit here ("正在等待所有会话空闲…(还有 1 个
|
|
3273
|
+
// 会话在忙)") was clipped away by the composer row (user report,
|
|
3274
|
+
// 2026-10-29). The detail — the count of busy conversations, the
|
|
3275
|
+
// megabytes, the progress bar — is the CENTRED OVERLAY.
|
|
3276
|
+
const pillStageText = restartPillStageText(stage, t)
|
|
2968
3277
|
const cancelLocked = restartStageLocked(stage)
|
|
2969
3278
|
// The TASK of every blocker: a session and its own subagents are ONE
|
|
2970
3279
|
// task (the sidebar counts them once). The keys are aligned with the
|
|
@@ -2999,9 +3308,6 @@
|
|
|
2999
3308
|
const busyBlockers = others.filter(
|
|
3000
3309
|
(entry) => String(entry.reason) !== 'goal-active' && String(entry.reason) !== 'goal-exhausted',
|
|
3001
3310
|
)
|
|
3002
|
-
// The count is about TASKS, not blockers: a parent session and its own
|
|
3003
|
-
// subagents are ONE task, exactly as the left sidebar counts them.
|
|
3004
|
-
const otherBusy = countBlockerTasks(busyBlockers)
|
|
3005
3311
|
|
|
3006
3312
|
// WHY the gate is closed: the Host reports the reason next to the
|
|
3007
3313
|
// blockers. With no blocker but the reason "settling" the restart is
|
|
@@ -3013,6 +3319,18 @@
|
|
|
3013
3319
|
// more line about it — how long it has been waiting and how many tasks
|
|
3014
3320
|
// are running.
|
|
3015
3321
|
const longWait = formatLongWaitNoteParts(info, t)
|
|
3322
|
+
// THE ONE NUMBER both surfaces show (user report, 2026-10-29: the
|
|
3323
|
+
// capsule said "还有 1 个会话在忙" while the panel it opens said
|
|
3324
|
+
// "这次重启还在等 0 个会话"). The Host counts the tasks that hold
|
|
3325
|
+
// the gate and publishes `busyCount`; the capsule AND its panel read
|
|
3326
|
+
// that very value, so the two can never disagree again. A blocker
|
|
3327
|
+
// WITHOUT a readable name is still counted.
|
|
3328
|
+
const longWaitBlockers = info && info.longWait && Array.isArray(info.longWait.blockedBy)
|
|
3329
|
+
? info.longWait.blockedBy
|
|
3330
|
+
: []
|
|
3331
|
+
const countingBlockers = others.length > 0 || !longWait.hasLongWait ? others : longWaitBlockers
|
|
3332
|
+
const waitingCount = waitingTaskCount(info, countingBlockers)
|
|
3333
|
+
const otherBusy = waitingCount
|
|
3016
3334
|
// The blockers of the ordinary list: the SAME rule as the long wait
|
|
3017
3335
|
// (grouped by the session that created them).
|
|
3018
3336
|
const blockers = summarizeBlockers(busyBlockers, { t })
|
|
@@ -3047,7 +3365,9 @@
|
|
|
3047
3365
|
// The panel: how many tasks the restart waits for, and how many OTHER
|
|
3048
3366
|
// sessions wait for the same restart. The count is about TASKS (a
|
|
3049
3367
|
// session and its own subagents are one), like everywhere else.
|
|
3050
|
-
|
|
3368
|
+
// The SAME variable the capsule used: the panel can never claim a
|
|
3369
|
+
// different count than the line it was opened from.
|
|
3370
|
+
const taskCount = waitingCount
|
|
3051
3371
|
const otherWaiters = waiters.filter((waiter) => String(waiter) !== key).length
|
|
3052
3372
|
const sessionsWaiting = sessionsLeft === null ? otherWaiters : sessionsLeft
|
|
3053
3373
|
// The session that asks for the cancel: without it the own request
|
|
@@ -3213,7 +3533,14 @@
|
|
|
3213
3533
|
if (event && typeof event.stopPropagation === 'function') event.stopPropagation()
|
|
3214
3534
|
},
|
|
3215
3535
|
},
|
|
3216
|
-
|
|
3536
|
+
// THE SAME number the capsule shows (one variable, one number).
|
|
3537
|
+
// A restart that waits for nobody says so in words — "还在等 0 个
|
|
3538
|
+
// 会话" is not a sentence anybody writes.
|
|
3539
|
+
h(
|
|
3540
|
+
'p',
|
|
3541
|
+
{ style: { margin: 0 } },
|
|
3542
|
+
taskCount > 0 ? fill(t('cancelPanelTasks'), { n: taskCount }) : t('panelStateIdle'),
|
|
3543
|
+
),
|
|
3217
3544
|
sessionsWaiting > 0
|
|
3218
3545
|
? h(
|
|
3219
3546
|
'p',
|
|
@@ -3326,22 +3653,27 @@
|
|
|
3326
3653
|
onKeyDown: onPillKeyDown,
|
|
3327
3654
|
},
|
|
3328
3655
|
h('span', { style: dotStyle(DOT_STYLE, pulsing), 'aria-hidden': 'true' }),
|
|
3329
|
-
|
|
3330
|
-
//
|
|
3331
|
-
//
|
|
3332
|
-
//
|
|
3333
|
-
//
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3656
|
+
// THE ONE VISIBLE LINE OF THE COMPOSER ROW — short, never a
|
|
3657
|
+
// sentence:
|
|
3658
|
+
// waiting (or no stage at all): the composed line of the pill —
|
|
3659
|
+
// "待重启 · 空闲后自动重启" / "待重启 · 等待 N 个…";
|
|
3660
|
+
// snapshotting / restarting / back: the stage REPLACES it —
|
|
3661
|
+
// "重启前备份中" / "正在重启…" / "已回来".
|
|
3662
|
+
// The long sentence that used to live here was clipped away by the
|
|
3663
|
+
// composer row (user report, 2026-10-29): the detail is the CENTRED
|
|
3664
|
+
// OVERLAY, and the pill is one clipped line again.
|
|
3665
|
+
h(
|
|
3666
|
+
'span',
|
|
3667
|
+
{
|
|
3668
|
+
style: TEXT_STYLE,
|
|
3669
|
+
'data-dsh-autorestart-copy': 'true',
|
|
3670
|
+
'data-dsh-autorestart-stage': String(stage && stage.phase ? stage.phase : ''),
|
|
3671
|
+
},
|
|
3672
|
+
pillStageText === ''
|
|
3673
|
+
? h('b', { key: 'label' }, t('label'))
|
|
3674
|
+
: h('b', { key: 'stage' }, pillStageText),
|
|
3675
|
+
pillStageText === '' ? status : null,
|
|
3676
|
+
),
|
|
3345
3677
|
// One more line for the long wait: how long the restart has been
|
|
3346
3678
|
// waiting and how many tasks are running. It is only there when the
|
|
3347
3679
|
// Host says so. The line carries NO name at all — it is one
|
|
@@ -3429,6 +3761,150 @@
|
|
|
3429
3761
|
})
|
|
3430
3762
|
}
|
|
3431
3763
|
|
|
3764
|
+
/**
|
|
3765
|
+
* Inject the overlay stylesheet exactly once. The id keeps a second
|
|
3766
|
+
* injection from duplicating the rule.
|
|
3767
|
+
*/
|
|
3768
|
+
function ensureRestartOverlayStyle() {
|
|
3769
|
+
if (typeof document === 'undefined' || !document.head) return
|
|
3770
|
+
try {
|
|
3771
|
+
if (document.getElementById(RESTART_OVERLAY_STYLE_ID)) return
|
|
3772
|
+
const style = document.createElement('style')
|
|
3773
|
+
style.id = RESTART_OVERLAY_STYLE_ID
|
|
3774
|
+
style.textContent = RESTART_OVERLAY_CSS
|
|
3775
|
+
document.head.appendChild(style)
|
|
3776
|
+
} catch {
|
|
3777
|
+
// Without the stylesheet the card is still readable, just plainer.
|
|
3778
|
+
}
|
|
3779
|
+
}
|
|
3780
|
+
|
|
3781
|
+
/**
|
|
3782
|
+
* THE CENTRED OVERLAY OF A RESTART — the stage, on the interface itself.
|
|
3783
|
+
*
|
|
3784
|
+
* WHY (first report): the stage lived in the composer row, one clipped line
|
|
3785
|
+
* inside a 22px toolbar, where the sentence was cut off by an ellipsis. The
|
|
3786
|
+
* detail therefore moved onto the middle of the conversation.
|
|
3787
|
+
*
|
|
3788
|
+
* WHAT IS DRAWN, AND WHEN — the card follows the Host EXACTLY, with no time
|
|
3789
|
+
* floor of its own (the owner's decision, 2026-10-29: "浮层不用保底时间,
|
|
3790
|
+
* 短就算了,不用硬加长"): a 92 KB backup is copied in ~5 ms and the card is
|
|
3791
|
+
* gone just as fast; a slow backup keeps it on screen for as long as it
|
|
3792
|
+
* takes. Nothing here invents a duration.
|
|
3793
|
+
* snapshotting "DSH 重启前备份中" + the real bar + "12.3 MB / 40 MB" + %
|
|
3794
|
+
* restarting "正在重启…" (the host is on its way out)
|
|
3795
|
+
* back "已回来", for 1.5s, and only for a restart THIS page saw
|
|
3796
|
+
* waiting/idle NOTHING: the gate merely waits, nothing is in progress
|
|
3797
|
+
*
|
|
3798
|
+
* A running stage counts only while the restart is really PENDING
|
|
3799
|
+
* (pendingRestart): the stage a previous restart left in the state file is
|
|
3800
|
+
* not an action, and a card for it would be a lie.
|
|
3801
|
+
*
|
|
3802
|
+
* IT LIVES ON THIS PAGE ONLY (no localStorage, no global marker), it never
|
|
3803
|
+
* covers the input (pointer-events: none), and it never cuts a word.
|
|
3804
|
+
*/
|
|
3805
|
+
function RestartOverlay() {
|
|
3806
|
+
const info = useSharedInfo(true)
|
|
3807
|
+
const t = makeTranslate(translate)
|
|
3808
|
+
// The stage said goodbye: the card folds away once, and only once.
|
|
3809
|
+
const [backGone, setBackGone] = useState(false)
|
|
3810
|
+
// DID THIS PAGE WATCH A RESTART IN PROGRESS? A page opened afterwards
|
|
3811
|
+
// reads a LEFTOVER stage from the state file and must draw nothing.
|
|
3812
|
+
const [sawRunning, setSawRunning] = useState(false)
|
|
3813
|
+
|
|
3814
|
+
const stage = info && info.restartPhase && typeof info.restartPhase === 'object' ? info.restartPhase : null
|
|
3815
|
+
const phase = stage && typeof stage.phase === 'string' ? stage.phase : ''
|
|
3816
|
+
// A RESTART THAT IS REALLY PENDING: the request says "something is going
|
|
3817
|
+
// on right now", the state file alone does not.
|
|
3818
|
+
const pending = Boolean(info && info.pendingRestart === true)
|
|
3819
|
+
// THE ONLY TWO STAGES THAT ARE "SOMETHING IS REALLY HAPPENING": the
|
|
3820
|
+
// snapshot is being written, or the host is on its way out.
|
|
3821
|
+
const running = pending && (phase === 'snapshotting' || phase === 'restarting')
|
|
3822
|
+
|
|
3823
|
+
useEffect(() => {
|
|
3824
|
+
if (running) setSawRunning(true)
|
|
3825
|
+
}, [running])
|
|
3826
|
+
|
|
3827
|
+
// "已回来" is a MOMENT, not a state: 1.5s and then the card is gone.
|
|
3828
|
+
useEffect(() => {
|
|
3829
|
+
if (phase !== 'back') {
|
|
3830
|
+
setBackGone(false)
|
|
3831
|
+
return undefined
|
|
3832
|
+
}
|
|
3833
|
+
const timer = setTimeout(() => setBackGone(true), RESTART_OVERLAY_BACK_MS)
|
|
3834
|
+
return () => clearTimeout(timer)
|
|
3835
|
+
}, [phase])
|
|
3836
|
+
|
|
3837
|
+
// THE CARD IS ONLY DRAWN WHILE SOMETHING IS REALLY IN PROGRESS.
|
|
3838
|
+
const visible = running || (phase === 'back' && sawRunning && !backGone)
|
|
3839
|
+
if (!visible) return null
|
|
3840
|
+
|
|
3841
|
+
const progress = stage.progress && typeof stage.progress === 'object' ? stage.progress : null
|
|
3842
|
+
const bytes = progress !== null && Number.isFinite(Number(progress.bytes)) ? Number(progress.bytes) : null
|
|
3843
|
+
const total = progress !== null && Number.isFinite(Number(progress.total)) ? Number(progress.total) : null
|
|
3844
|
+
const percent = progress !== null && Number.isFinite(Number(progress.percent))
|
|
3845
|
+
? Math.max(0, Math.min(100, Math.round(Number(progress.percent))))
|
|
3846
|
+
: null
|
|
3847
|
+
|
|
3848
|
+
const title = restartOverlayTitle(stage, t)
|
|
3849
|
+
|
|
3850
|
+
let bar = null
|
|
3851
|
+
let detail = null
|
|
3852
|
+
if (phase === 'snapshotting') {
|
|
3853
|
+
// The numbers are the ones the copy loop really measured; when there is
|
|
3854
|
+
// no measurement yet, the card shows no number at all (an invented
|
|
3855
|
+
// progress bar is worse than none). The unit fits the size, so a 92 KB
|
|
3856
|
+
// profile is "92 KB" and never "0 MB".
|
|
3857
|
+
const done = bytes === null ? '' : formatOverlaySize(bytes)
|
|
3858
|
+
const all = total === null ? '' : formatOverlaySize(total)
|
|
3859
|
+
const sizeText = done === ''
|
|
3860
|
+
? ''
|
|
3861
|
+
: (all === '' ? fill(t('restart.overlay.sizeDone'), { done }) : fill(t('restart.overlay.size'), { done, total: all }))
|
|
3862
|
+
bar = h(
|
|
3863
|
+
'div',
|
|
3864
|
+
{
|
|
3865
|
+
className: 'dsh-autorestart-restart-overlay__bar',
|
|
3866
|
+
role: 'progressbar',
|
|
3867
|
+
'aria-valuemin': 0,
|
|
3868
|
+
'aria-valuemax': 100,
|
|
3869
|
+
'aria-valuenow': percent === null ? 0 : percent,
|
|
3870
|
+
},
|
|
3871
|
+
h('div', {
|
|
3872
|
+
className: 'dsh-autorestart-restart-overlay__bar-fill',
|
|
3873
|
+
'data-dsh-autorestart-restart-overlay-bar': 'true',
|
|
3874
|
+
style: { width: (percent === null ? 0 : percent) + '%' },
|
|
3875
|
+
}),
|
|
3876
|
+
)
|
|
3877
|
+
detail = sizeText === ''
|
|
3878
|
+
? null
|
|
3879
|
+
: h(
|
|
3880
|
+
'p',
|
|
3881
|
+
{ className: 'dsh-autorestart-restart-overlay__size' },
|
|
3882
|
+
h('span', { 'data-dsh-autorestart-restart-overlay-size': 'true' }, sizeText),
|
|
3883
|
+
h('span', { 'data-dsh-autorestart-restart-overlay-percent': 'true' }, percent === null ? '' : percent + '%'),
|
|
3884
|
+
)
|
|
3885
|
+
}
|
|
3886
|
+
|
|
3887
|
+
ensureRestartOverlayStyle()
|
|
3888
|
+
|
|
3889
|
+
return h(
|
|
3890
|
+
'div',
|
|
3891
|
+
{
|
|
3892
|
+
className: 'dsh-autorestart-restart-overlay',
|
|
3893
|
+
role: 'status',
|
|
3894
|
+
'aria-live': 'polite',
|
|
3895
|
+
'data-dsh-autorestart-restart-overlay': 'true',
|
|
3896
|
+
'data-dsh-autorestart-restart-overlay-phase': phase,
|
|
3897
|
+
},
|
|
3898
|
+
h(
|
|
3899
|
+
'p',
|
|
3900
|
+
{ className: 'dsh-autorestart-restart-overlay__title', 'data-dsh-autorestart-restart-overlay-title': 'true' },
|
|
3901
|
+
title,
|
|
3902
|
+
),
|
|
3903
|
+
bar,
|
|
3904
|
+
detail,
|
|
3905
|
+
)
|
|
3906
|
+
}
|
|
3907
|
+
|
|
3432
3908
|
/**
|
|
3433
3909
|
* Inject the prompt styles exactly once. The id keeps a second injection
|
|
3434
3910
|
* from duplicating the rule.
|
|
@@ -4399,6 +4875,37 @@
|
|
|
4399
4875
|
' font-size:13px;',
|
|
4400
4876
|
'}',
|
|
4401
4877
|
'.dsh-autorestart-settings__hint{font-size:12px;line-height:17px;color:var(--dsw-alias-label-secondary);}',
|
|
4878
|
+
// THE BACKUP LIST (user report, 2026-10-29: the old one-liner
|
|
4879
|
+
// "2026-10-29 22:08:46+08:00 156.0 MB 含已装插件" was unreadable).
|
|
4880
|
+
// One GROUP per backup, a labelled row for the time and one for the
|
|
4881
|
+
// size, and enough air between two groups that two copies never read
|
|
4882
|
+
// as one block.
|
|
4883
|
+
'.dsh-autorestart-settings__backup{',
|
|
4884
|
+
' display:flex;',
|
|
4885
|
+
' flex-direction:column;',
|
|
4886
|
+
' gap:3px;',
|
|
4887
|
+
' padding-top:8px;',
|
|
4888
|
+
' border-top:1px solid var(--dsw-alias-border-l1);',
|
|
4889
|
+
'}',
|
|
4890
|
+
'.dsh-autorestart-settings__backup + .dsh-autorestart-settings__backup{margin-top:12px;}',
|
|
4891
|
+
'.dsh-autorestart-settings__backup-row{display:flex;flex-wrap:wrap;gap:8px;align-items:baseline;}',
|
|
4892
|
+
'.dsh-autorestart-settings__backup-label{',
|
|
4893
|
+
' flex:none;',
|
|
4894
|
+
' font-size:12.5px;',
|
|
4895
|
+
' line-height:18px;',
|
|
4896
|
+
' color:var(--dsw-alias-label-secondary);',
|
|
4897
|
+
'}',
|
|
4898
|
+
'.dsh-autorestart-settings__backup-value{',
|
|
4899
|
+
' font-size:13px;',
|
|
4900
|
+
' line-height:18px;',
|
|
4901
|
+
' color:var(--dsw-alias-label-primary);',
|
|
4902
|
+
' font-variant-numeric:tabular-nums;',
|
|
4903
|
+
'}',
|
|
4904
|
+
'.dsh-autorestart-settings__backup-note{',
|
|
4905
|
+
' font-size:12px;',
|
|
4906
|
+
' line-height:18px;',
|
|
4907
|
+
' color:var(--dsw-alias-label-secondary);',
|
|
4908
|
+
'}',
|
|
4402
4909
|
// The folded "technical details": the command itself stays out of the
|
|
4403
4910
|
// way until somebody asks for it.
|
|
4404
4911
|
'.dsh-autorestart-settings__details{',
|
|
@@ -4577,6 +5084,321 @@
|
|
|
4577
5084
|
* @param {object} props the Host's props: `t` (the translator of the
|
|
4578
5085
|
* registered locale, injected by the section registration)
|
|
4579
5086
|
*/
|
|
5087
|
+
/* ---------------------------------------------------------------- *
|
|
5088
|
+
* THE RECEIPT OF A SETTINGS CHANGE
|
|
5089
|
+
*
|
|
5090
|
+
* A setting is written the moment the user picks it ("change it and it
|
|
5091
|
+
* is in effect"), which left the user unable to tell whether it was
|
|
5092
|
+
* really stored. This is the missing half: ONE `sticky` line at the TOP
|
|
5093
|
+
* OF THE SETTINGS PANEL that says "Saved 21:40:12" once the HOST
|
|
5094
|
+
* answered `ok: true`, or "Not saved — <the reason>" in red when it
|
|
5095
|
+
* refused.
|
|
5096
|
+
*
|
|
5097
|
+
* It is drawn INSIDE the panel (a child of the panel's own tree), never
|
|
5098
|
+
* on the window: a window overlay put it on top of the conversation
|
|
5099
|
+
* area, outside the dialog it belongs to.
|
|
5100
|
+
*
|
|
5101
|
+
* The rules it keeps:
|
|
5102
|
+
*
|
|
5103
|
+
* - NOTHING is announced before the Host's receipt: rendering the
|
|
5104
|
+
* page, polling it and clicking a control all stay silent;
|
|
5105
|
+
* - a failure is never swallowed and never fades: it stays until the
|
|
5106
|
+
* next change succeeds or the user closes it;
|
|
5107
|
+
* - a success fades out on its own after a moment, and three changes
|
|
5108
|
+
* in a row update the SAME line instead of stacking new ones;
|
|
5109
|
+
* - the line lives exactly as long as the panel does (closing the
|
|
5110
|
+
* settings throws it away).
|
|
5111
|
+
* ---------------------------------------------------------------- */
|
|
5112
|
+
|
|
5113
|
+
/** The id of the one stylesheet of the receipt line. */
|
|
5114
|
+
const SAVE_NOTICE_STYLE_ID = 'dsh-autorestart-save-notice'
|
|
5115
|
+
/** How long the green line stays; the red one stays for ever. */
|
|
5116
|
+
const SAVE_NOTICE_HIDE_MS = 3000
|
|
5117
|
+
|
|
5118
|
+
/**
|
|
5119
|
+
* The receipt on screen right now (one for the whole panel: two changes
|
|
5120
|
+
* never produce two lines).
|
|
5121
|
+
*
|
|
5122
|
+
* `notice` is `null` until a save came back from the Host, and it is
|
|
5123
|
+
* `{ kind: 'ok' | 'failed', at, atText, reason }` afterwards.
|
|
5124
|
+
*/
|
|
5125
|
+
const saveNoticeStore = {
|
|
5126
|
+
notice: null,
|
|
5127
|
+
listeners: new Set(),
|
|
5128
|
+
timer: null,
|
|
5129
|
+
}
|
|
5130
|
+
|
|
5131
|
+
/** Tell every mounted receipt about the current one. */
|
|
5132
|
+
function notifySaveNotice() {
|
|
5133
|
+
saveNoticeStore.listeners.forEach((listener) => {
|
|
5134
|
+
try {
|
|
5135
|
+
listener(saveNoticeStore.notice)
|
|
5136
|
+
} catch {
|
|
5137
|
+
// One broken listener must not stop the others.
|
|
5138
|
+
}
|
|
5139
|
+
})
|
|
5140
|
+
}
|
|
5141
|
+
|
|
5142
|
+
/** The local clock time of a receipt, as HH:MM:SS. */
|
|
5143
|
+
function formatSaveNoticeClock(at) {
|
|
5144
|
+
const moment = new Date(at)
|
|
5145
|
+
const pad = (value) => (value < 10 ? '0' + value : String(value))
|
|
5146
|
+
return pad(moment.getHours()) + ':' + pad(moment.getMinutes()) + ':' + pad(moment.getSeconds())
|
|
5147
|
+
}
|
|
5148
|
+
|
|
5149
|
+
/**
|
|
5150
|
+
* Publish one receipt.
|
|
5151
|
+
*
|
|
5152
|
+
* A pending fade-out of the previous receipt is dropped first, so a
|
|
5153
|
+
* second change restarts the clock on the SAME line (test: "two saves,
|
|
5154
|
+
* one line").
|
|
5155
|
+
*/
|
|
5156
|
+
function publishSaveNotice(notice) {
|
|
5157
|
+
if (saveNoticeStore.timer !== null) {
|
|
5158
|
+
clearTimeout(saveNoticeStore.timer)
|
|
5159
|
+
saveNoticeStore.timer = null
|
|
5160
|
+
}
|
|
5161
|
+
saveNoticeStore.notice = notice
|
|
5162
|
+
notifySaveNotice()
|
|
5163
|
+
// A failure stays: only the user closes it (or a save that works).
|
|
5164
|
+
if (notice.kind !== 'ok') return
|
|
5165
|
+
const timer = setTimeout(() => {
|
|
5166
|
+
saveNoticeStore.timer = null
|
|
5167
|
+
// A newer receipt replaced this one while the timer ran.
|
|
5168
|
+
if (saveNoticeStore.notice !== notice) return
|
|
5169
|
+
saveNoticeStore.notice = null
|
|
5170
|
+
notifySaveNotice()
|
|
5171
|
+
}, SAVE_NOTICE_HIDE_MS)
|
|
5172
|
+
// In a browser this is a number; under Node it is a Timeout that must
|
|
5173
|
+
// not hold the process open (the tests run in one).
|
|
5174
|
+
if (timer && typeof timer.unref === 'function') timer.unref()
|
|
5175
|
+
saveNoticeStore.timer = timer
|
|
5176
|
+
}
|
|
5177
|
+
|
|
5178
|
+
/**
|
|
5179
|
+
* The Host took the change (`ok: true`): this is the ONE moment the
|
|
5180
|
+
* page may say "Saved".
|
|
5181
|
+
*/
|
|
5182
|
+
function publishSaveSaved() {
|
|
5183
|
+
const at = Date.now()
|
|
5184
|
+
publishSaveNotice({ kind: 'ok', at, atText: formatSaveNoticeClock(at), reason: '' })
|
|
5185
|
+
}
|
|
5186
|
+
|
|
5187
|
+
/**
|
|
5188
|
+
* The Host refused the change: say so, with the reason it gave, and
|
|
5189
|
+
* keep saying it.
|
|
5190
|
+
*
|
|
5191
|
+
* @param {string} reason the error of the Host answer (or of the request)
|
|
5192
|
+
*/
|
|
5193
|
+
function publishSaveFailure(reason) {
|
|
5194
|
+
const at = Date.now()
|
|
5195
|
+
publishSaveNotice({
|
|
5196
|
+
kind: 'failed',
|
|
5197
|
+
at,
|
|
5198
|
+
atText: formatSaveNoticeClock(at),
|
|
5199
|
+
reason: typeof reason === 'string' ? reason : '',
|
|
5200
|
+
})
|
|
5201
|
+
}
|
|
5202
|
+
|
|
5203
|
+
/** Close the receipt: only the user does that, with the close button. */
|
|
5204
|
+
function clearSaveNotice() {
|
|
5205
|
+
if (saveNoticeStore.timer !== null) {
|
|
5206
|
+
clearTimeout(saveNoticeStore.timer)
|
|
5207
|
+
saveNoticeStore.timer = null
|
|
5208
|
+
}
|
|
5209
|
+
if (saveNoticeStore.notice === null) return
|
|
5210
|
+
saveNoticeStore.notice = null
|
|
5211
|
+
notifySaveNotice()
|
|
5212
|
+
}
|
|
5213
|
+
|
|
5214
|
+
/** The receipt on screen, re-rendered whenever it changes. */
|
|
5215
|
+
function useSaveNotice() {
|
|
5216
|
+
const [notice, setNotice] = useState(saveNoticeStore.notice)
|
|
5217
|
+
useEffect(() => {
|
|
5218
|
+
setNotice(saveNoticeStore.notice)
|
|
5219
|
+
saveNoticeStore.listeners.add(setNotice)
|
|
5220
|
+
return () => { saveNoticeStore.listeners.delete(setNotice) }
|
|
5221
|
+
}, [])
|
|
5222
|
+
return notice
|
|
5223
|
+
}
|
|
5224
|
+
|
|
5225
|
+
// THE RECEIPT OF A SETTINGS CHANGE IS A STRIP OF THE SETTINGS PANEL.
|
|
5226
|
+
//
|
|
5227
|
+
// It used to be a WINDOW overlay (`shell.overlay`), and that was wrong:
|
|
5228
|
+
// the user saw it floating over the conversation area — outside the
|
|
5229
|
+
// dialog it belonged to. It is a child of the panel itself now: a
|
|
5230
|
+
// `sticky` strip at the top of the panel, so it stays visible while the
|
|
5231
|
+
// settings are scrolled and it can never leave the dialog.
|
|
5232
|
+
//
|
|
5233
|
+
// The strip COVERS the first line of the panel for its 30px (the wrapper
|
|
5234
|
+
// is sticky with height 0) instead of pushing the form down, so nothing
|
|
5235
|
+
// jumps when it appears or fades.
|
|
5236
|
+
const SAVE_NOTICE_CSS = [
|
|
5237
|
+
'.dsh-autorestart-save-notice{',
|
|
5238
|
+
' position:sticky;',
|
|
5239
|
+
' top:0;',
|
|
5240
|
+
' z-index:5;',
|
|
5241
|
+
' height:0;',
|
|
5242
|
+
' pointer-events:none;',
|
|
5243
|
+
'}',
|
|
5244
|
+
'.dsh-autorestart-save-notice__bar{',
|
|
5245
|
+
' display:flex;',
|
|
5246
|
+
' align-items:center;',
|
|
5247
|
+
' gap:8px;',
|
|
5248
|
+
' box-sizing:border-box;',
|
|
5249
|
+
' height:30px;',
|
|
5250
|
+
' max-width:100%;',
|
|
5251
|
+
' padding:0 4px 0 10px;',
|
|
5252
|
+
' border:1px solid transparent;',
|
|
5253
|
+
' border-radius:8px;',
|
|
5254
|
+
' font-size:13px;',
|
|
5255
|
+
' line-height:18px;',
|
|
5256
|
+
' white-space:nowrap;',
|
|
5257
|
+
' pointer-events:auto;',
|
|
5258
|
+
' box-shadow:0 2px 8px color-mix(in srgb, var(--dsw-alias-bg-mask-3) 14%, transparent);',
|
|
5259
|
+
'}',
|
|
5260
|
+
// The green strip: a light green ground with green text.
|
|
5261
|
+
'.dsh-autorestart-save-notice--ok .dsh-autorestart-save-notice__bar{',
|
|
5262
|
+
' border-color:color-mix(in srgb, var(--dsw-alias-state-success,#1a7f37) 38%, transparent);',
|
|
5263
|
+
' background:color-mix(in srgb, var(--dsw-alias-state-success,#1a7f37) 12%, var(--dsw-alias-bg-layer-1));',
|
|
5264
|
+
' color:var(--dsw-alias-state-success-fg,var(--dsw-alias-state-success,#1a7f37));',
|
|
5265
|
+
'}',
|
|
5266
|
+
// The red one: a light red ground with red text. It does not fade.
|
|
5267
|
+
'.dsh-autorestart-save-notice--failed .dsh-autorestart-save-notice__bar{',
|
|
5268
|
+
' border-color:color-mix(in srgb, var(--dsw-alias-danger,#d14343) 38%, transparent);',
|
|
5269
|
+
' background:color-mix(in srgb, var(--dsw-alias-danger,#d14343) 12%, var(--dsw-alias-bg-layer-1));',
|
|
5270
|
+
' color:var(--dsw-alias-danger,#d14343);',
|
|
5271
|
+
'}',
|
|
5272
|
+
'.dsh-autorestart-save-notice__title{flex:none;font-weight:600;}',
|
|
5273
|
+
// The clock time sits at the RIGHT of the strip, in small type.
|
|
5274
|
+
'.dsh-autorestart-save-notice__time{',
|
|
5275
|
+
' flex:none;',
|
|
5276
|
+
' margin-left:auto;',
|
|
5277
|
+
' font-size:11.5px;',
|
|
5278
|
+
' opacity:.75;',
|
|
5279
|
+
' font-variant-numeric:tabular-nums;',
|
|
5280
|
+
'}',
|
|
5281
|
+
// The reason is the only text that may be cut, and even then the whole
|
|
5282
|
+
// text is in the tooltip: the short copy itself is never cut.
|
|
5283
|
+
'.dsh-autorestart-save-notice__reason{',
|
|
5284
|
+
' flex:0 1 auto;',
|
|
5285
|
+
' min-width:0;',
|
|
5286
|
+
' overflow:hidden;',
|
|
5287
|
+
' text-overflow:ellipsis;',
|
|
5288
|
+
'}',
|
|
5289
|
+
'.dsh-autorestart-save-notice__close{',
|
|
5290
|
+
' flex:none;',
|
|
5291
|
+
' width:20px;',
|
|
5292
|
+
' height:20px;',
|
|
5293
|
+
' padding:0;',
|
|
5294
|
+
' border:none;',
|
|
5295
|
+
' border-radius:6px;',
|
|
5296
|
+
' background:transparent;',
|
|
5297
|
+
' color:inherit;',
|
|
5298
|
+
' font:inherit;',
|
|
5299
|
+
' font-size:14px;',
|
|
5300
|
+
' line-height:18px;',
|
|
5301
|
+
' cursor:pointer;',
|
|
5302
|
+
'}',
|
|
5303
|
+
'.dsh-autorestart-save-notice__close:hover{background:color-mix(in srgb, currentColor 14%, transparent);}',
|
|
5304
|
+
'.dsh-autorestart-save-notice__close:focus-visible{',
|
|
5305
|
+
' outline:2px solid var(--dsw-alias-state-business-primary,var(--dsw-alias-brand-primary,#4d6bfe));',
|
|
5306
|
+
' outline-offset:2px;',
|
|
5307
|
+
'}',
|
|
5308
|
+
// The fade is decoration: a system that asks for less motion gets none.
|
|
5309
|
+
'@media (prefers-reduced-motion: no-preference){',
|
|
5310
|
+
' .dsh-autorestart-save-notice__bar{animation:dsh-autorestart-save-notice-in .16s ease-out;}',
|
|
5311
|
+
'}',
|
|
5312
|
+
'@keyframes dsh-autorestart-save-notice-in{',
|
|
5313
|
+
' from{opacity:0;transform:translateY(-3px);}',
|
|
5314
|
+
' to{opacity:1;transform:translateY(0);}',
|
|
5315
|
+
'}',
|
|
5316
|
+
].join('\n')
|
|
5317
|
+
|
|
5318
|
+
/** Inject the stylesheet of the receipt exactly once. */
|
|
5319
|
+
function ensureSaveNoticeStyle() {
|
|
5320
|
+
if (typeof document === 'undefined' || !document.head) return
|
|
5321
|
+
try {
|
|
5322
|
+
if (document.getElementById(SAVE_NOTICE_STYLE_ID)) return
|
|
5323
|
+
const style = document.createElement('style')
|
|
5324
|
+
style.id = SAVE_NOTICE_STYLE_ID
|
|
5325
|
+
style.textContent = SAVE_NOTICE_CSS
|
|
5326
|
+
document.head.appendChild(style)
|
|
5327
|
+
} catch {
|
|
5328
|
+
// Without the stylesheet the line is still readable, just plainer.
|
|
5329
|
+
}
|
|
5330
|
+
}
|
|
5331
|
+
|
|
5332
|
+
/**
|
|
5333
|
+
* THE RECEIPT OF A SETTINGS CHANGE, drawn INSIDE the settings panel.
|
|
5334
|
+
*
|
|
5335
|
+
* It is a child of the panel itself (never a window overlay): the DOM
|
|
5336
|
+
* ancestor of this strip IS the settings section, so it can not float
|
|
5337
|
+
* over the conversation area. `SAVE_NOTICE_CSS` makes it a `sticky`
|
|
5338
|
+
* strip at the top of the panel, so it stays visible while the settings
|
|
5339
|
+
* are scrolled, and it covers the first line instead of pushing the
|
|
5340
|
+
* form down.
|
|
5341
|
+
*
|
|
5342
|
+
* It renders NOTHING until a save came back from the Host — the first
|
|
5343
|
+
* render and every poll are silent.
|
|
5344
|
+
*
|
|
5345
|
+
* @param {(key: string) => string} t the translator of the panel
|
|
5346
|
+
* @param {object|null} notice the receipt on screen (`null` = none)
|
|
5347
|
+
*/
|
|
5348
|
+
function saveNoticeBar(t, notice) {
|
|
5349
|
+
if (!notice) return null
|
|
5350
|
+
ensureSaveNoticeStyle()
|
|
5351
|
+
const saved = notice.kind === 'ok'
|
|
5352
|
+
return h(
|
|
5353
|
+
'div',
|
|
5354
|
+
{
|
|
5355
|
+
className: 'dsh-autorestart-save-notice'
|
|
5356
|
+
+ (saved ? ' dsh-autorestart-save-notice--ok' : ' dsh-autorestart-save-notice--failed'),
|
|
5357
|
+
role: saved ? 'status' : 'alert',
|
|
5358
|
+
'aria-live': saved ? 'polite' : 'assertive',
|
|
5359
|
+
'data-dsh-autorestart-save-notice': saved ? 'ok' : 'failed',
|
|
5360
|
+
'data-dsh-autorestart-save-notice-at': notice.atText,
|
|
5361
|
+
},
|
|
5362
|
+
h(
|
|
5363
|
+
'div',
|
|
5364
|
+
{ className: 'dsh-autorestart-save-notice__bar' },
|
|
5365
|
+
// LEFT: the short copy ("已生效" / "Saved").
|
|
5366
|
+
h(
|
|
5367
|
+
'span',
|
|
5368
|
+
{ className: 'dsh-autorestart-save-notice__title' },
|
|
5369
|
+
saved ? t('settings.saveNotice.saved') : t('settings.saveNotice.failed'),
|
|
5370
|
+
),
|
|
5371
|
+
// The reason of a failure (the only text that may be cut, and the
|
|
5372
|
+
// tooltip carries all of it).
|
|
5373
|
+
saved || notice.reason === ''
|
|
5374
|
+
? null
|
|
5375
|
+
: h(
|
|
5376
|
+
'span',
|
|
5377
|
+
{
|
|
5378
|
+
className: 'dsh-autorestart-save-notice__reason',
|
|
5379
|
+
'data-dsh-autorestart-save-notice-reason': 'true',
|
|
5380
|
+
title: notice.reason,
|
|
5381
|
+
},
|
|
5382
|
+
notice.reason,
|
|
5383
|
+
),
|
|
5384
|
+
// RIGHT: the small clock time, then the close button.
|
|
5385
|
+
h('span', { className: 'dsh-autorestart-save-notice__time' }, notice.atText),
|
|
5386
|
+
h(
|
|
5387
|
+
'button',
|
|
5388
|
+
{
|
|
5389
|
+
type: 'button',
|
|
5390
|
+
className: 'dsh-autorestart-save-notice__close',
|
|
5391
|
+
'data-dsh-autorestart-save-notice-close': 'true',
|
|
5392
|
+
onClick: clearSaveNotice,
|
|
5393
|
+
title: t('settings.saveNotice.dismiss'),
|
|
5394
|
+
'aria-label': t('settings.saveNotice.dismiss'),
|
|
5395
|
+
},
|
|
5396
|
+
'×',
|
|
5397
|
+
),
|
|
5398
|
+
),
|
|
5399
|
+
)
|
|
5400
|
+
}
|
|
5401
|
+
|
|
4580
5402
|
function AutorestartSection(props) {
|
|
4581
5403
|
const status = useSleepStatus()
|
|
4582
5404
|
const info = useSharedInfo(true)
|
|
@@ -4596,6 +5418,19 @@
|
|
|
4596
5418
|
// accident.
|
|
4597
5419
|
const [detailsOpen, setDetailsOpen] = useState(false)
|
|
4598
5420
|
const t = makeTranslate(props && typeof props.t === 'function' ? props.t : translate)
|
|
5421
|
+
// THE RECEIPT OF A SETTINGS CHANGE, on screen right now. It belongs to
|
|
5422
|
+
// THIS panel (it is not a window overlay), so it is read here and
|
|
5423
|
+
// drawn here.
|
|
5424
|
+
const saveNotice = useSaveNotice()
|
|
5425
|
+
|
|
5426
|
+
// The receipt lives exactly as long as this panel is on screen: a page
|
|
5427
|
+
// that is closed (or reopened) never shows a "Saved" of an earlier
|
|
5428
|
+
// visit. The mount clearing also covers a Host that keeps the section
|
|
5429
|
+
// mounted while it is hidden.
|
|
5430
|
+
useEffect(() => {
|
|
5431
|
+
clearSaveNotice()
|
|
5432
|
+
return () => { clearSaveNotice() }
|
|
5433
|
+
}, [])
|
|
4599
5434
|
|
|
4600
5435
|
// The current values come from the Host, so the panel always shows what
|
|
4601
5436
|
// is really in effect.
|
|
@@ -4805,6 +5640,9 @@
|
|
|
4805
5640
|
* back with a fresh poll — so the controls never show a value the
|
|
4806
5641
|
* Host did not take.
|
|
4807
5642
|
*
|
|
5643
|
+
* ONLY the Host's answer (`ok: true`) makes the window say "Saved":
|
|
5644
|
+
* picking a value, rendering the page and polling it stay silent.
|
|
5645
|
+
*
|
|
4808
5646
|
* @param {object} changes the keys to write
|
|
4809
5647
|
*/
|
|
4810
5648
|
const save = async (changes) => {
|
|
@@ -4823,12 +5661,31 @@
|
|
|
4823
5661
|
? (body.message || body.error || String(response.status))
|
|
4824
5662
|
: String(response.status)
|
|
4825
5663
|
setError(fill(t('settings.error'), { message }))
|
|
5664
|
+
// The Host refused the write: the red line keeps the reason
|
|
5665
|
+
// until the next change works (or the user closes it).
|
|
5666
|
+
publishSaveFailure(message)
|
|
4826
5667
|
return
|
|
4827
5668
|
}
|
|
5669
|
+
if (!body || typeof body !== 'object' || body.ok !== true) {
|
|
5670
|
+
// The request came back, but the Host did not confirm it: a
|
|
5671
|
+
// "saved" line here would be a lie.
|
|
5672
|
+
const reason = body && typeof body === 'object'
|
|
5673
|
+
? String(body.message || body.error || '')
|
|
5674
|
+
: ''
|
|
5675
|
+
setError(fill(t('settings.error'), {
|
|
5676
|
+
message: reason === '' ? String(response.status) : reason,
|
|
5677
|
+
}))
|
|
5678
|
+
publishSaveFailure(reason)
|
|
5679
|
+
return
|
|
5680
|
+
}
|
|
5681
|
+
// The receipt is there: now the window may say "Saved".
|
|
5682
|
+
publishSaveSaved()
|
|
4828
5683
|
// Read the new values back from the Host.
|
|
4829
5684
|
await pollSleepOnce()
|
|
4830
5685
|
} catch (failure) {
|
|
4831
|
-
|
|
5686
|
+
const message = describeError(failure)
|
|
5687
|
+
setError(fill(t('settings.error'), { message }))
|
|
5688
|
+
publishSaveFailure(message)
|
|
4832
5689
|
} finally {
|
|
4833
5690
|
setBusy(false)
|
|
4834
5691
|
}
|
|
@@ -4954,6 +5811,11 @@
|
|
|
4954
5811
|
return h(
|
|
4955
5812
|
'div',
|
|
4956
5813
|
{ className: 'dsh-autorestart-settings' },
|
|
5814
|
+
// THE RECEIPT: a `sticky` strip at the TOP of this panel. It is a
|
|
5815
|
+
// child of the panel (the DOM ancestor of the message IS the
|
|
5816
|
+
// settings section), so it can not float over the conversation area
|
|
5817
|
+
// again — and it stays visible while the settings are scrolled.
|
|
5818
|
+
saveNoticeBar(t, saveNotice),
|
|
4957
5819
|
// The panel is split into two titled groups: the RESTART settings
|
|
4958
5820
|
// come first, the SHUTDOWN settings after them. The two situations
|
|
4959
5821
|
// are different, so they do not share one flat list.
|
|
@@ -5092,25 +5954,50 @@
|
|
|
5092
5954
|
: null,
|
|
5093
5955
|
backupItems.map((item, index) => h(
|
|
5094
5956
|
'div',
|
|
5095
|
-
{
|
|
5096
|
-
|
|
5097
|
-
'
|
|
5098
|
-
|
|
5099
|
-
|
|
5100
|
-
|
|
5957
|
+
{
|
|
5958
|
+
className: 'dsh-autorestart-settings__backup',
|
|
5959
|
+
'data-dsh-autorestart-backup': 'item',
|
|
5960
|
+
key: 'backup-' + index,
|
|
5961
|
+
},
|
|
5962
|
+
// WHEN it was taken. No timezone offset: what is shown is
|
|
5963
|
+
// already the local clock of the machine that took the copy.
|
|
5101
5964
|
h(
|
|
5102
|
-
'
|
|
5103
|
-
{ '
|
|
5104
|
-
|
|
5965
|
+
'div',
|
|
5966
|
+
{ className: 'dsh-autorestart-settings__backup-row' },
|
|
5967
|
+
h(
|
|
5968
|
+
'span',
|
|
5969
|
+
{ className: 'dsh-autorestart-settings__backup-label' },
|
|
5970
|
+
t('settings.backup.time'),
|
|
5971
|
+
),
|
|
5972
|
+
h(
|
|
5973
|
+
'span',
|
|
5974
|
+
{ className: 'dsh-autorestart-settings__backup-value', 'data-dsh-autorestart-backup': 'time' },
|
|
5975
|
+
formatBackupTime(item && item.atLocal),
|
|
5976
|
+
),
|
|
5105
5977
|
),
|
|
5978
|
+
// HOW BIG it is — and whether the plugins are in it, because
|
|
5979
|
+
// that is WHY it is that big: the note belongs to the size.
|
|
5106
5980
|
h(
|
|
5107
|
-
'
|
|
5108
|
-
{ '
|
|
5109
|
-
|
|
5110
|
-
|
|
5111
|
-
:
|
|
5981
|
+
'div',
|
|
5982
|
+
{ className: 'dsh-autorestart-settings__backup-row' },
|
|
5983
|
+
h(
|
|
5984
|
+
'span',
|
|
5985
|
+
{ className: 'dsh-autorestart-settings__backup-label' },
|
|
5986
|
+
t('settings.backup.size'),
|
|
5987
|
+
),
|
|
5988
|
+
h(
|
|
5989
|
+
'span',
|
|
5990
|
+
{ className: 'dsh-autorestart-settings__backup-value', 'data-dsh-autorestart-backup': 'size' },
|
|
5991
|
+
formatBackupSize(item && item.bytes),
|
|
5992
|
+
),
|
|
5993
|
+
h(
|
|
5994
|
+
'span',
|
|
5995
|
+
{ className: 'dsh-autorestart-settings__backup-note', 'data-dsh-autorestart-backup': 'plugins' },
|
|
5996
|
+
item && item.includePlugins === true
|
|
5997
|
+
? t('settings.backup.withPlugins')
|
|
5998
|
+
: t('settings.backup.withoutPlugins'),
|
|
5999
|
+
),
|
|
5112
6000
|
),
|
|
5113
|
-
index === 0 ? null : null,
|
|
5114
6001
|
)),
|
|
5115
6002
|
backupsInfo.dir === ''
|
|
5116
6003
|
? null
|
|
@@ -6735,6 +7622,15 @@
|
|
|
6735
7622
|
// The countdown overlay: a frame-wide floating layer above every
|
|
6736
7623
|
// column.
|
|
6737
7624
|
ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'autorestart-sleep-overlay', order: 60 }, SleepOverlay))
|
|
7625
|
+
// THE CENTRED OVERLAY of a running restart: the stage (waiting / the
|
|
7626
|
+
// backup with its real megabytes and its progress bar / restarting /
|
|
7627
|
+
// back) in the middle of the interface, so the composer row can stay
|
|
7628
|
+
// one short clipped line. It draws NOTHING when no restart is staged.
|
|
7629
|
+
ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'autorestart-restart-overlay', order: 55, locale: NS }, RestartOverlay))
|
|
7630
|
+
// NOTE: the receipt of a settings change is NOT registered here. It
|
|
7631
|
+
// is a `sticky` strip INSIDE the settings panel (see
|
|
7632
|
+
// `saveNoticeBar`), because a window overlay put it on top of the
|
|
7633
|
+
// conversation area instead of inside the dialog it belongs to.
|
|
6738
7634
|
},
|
|
6739
7635
|
// The named exports of this module (the client module system hands
|
|
6740
7636
|
// `factory(require) -> exports` to every importer).
|