dsh-config-manager 0.1.48 → 0.1.49

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/src/client/api.ts CHANGED
@@ -82,11 +82,13 @@ export interface DecryptArchiveResponse {
82
82
  refs: string[];
83
83
  }
84
84
 
85
- /** restore 端点响应:dryRun=true 返回 plan;执行返回 report(与 CLI 一致的诚实报告) */
85
+ /** restore 端点响应:dryRun=true 返回 plan;执行返回 report(与 CLI 一致的诚实报告)。
86
+ * runId 仅真实执行时存在(宿主 RunRegistry 登记;/runs + /progress 刷新恢复用)。 */
86
87
  export interface RestoreResponse {
87
88
  dryRun: boolean;
88
89
  plan?: RestorePlan;
89
90
  report?: RestoreReport;
91
+ runId?: string;
90
92
  }
91
93
 
92
94
  /** Star 引导弹窗状态(GET /star-prompt;纯偏好,无 secret) */
@@ -575,6 +575,28 @@
575
575
  font-size: 12.5px;
576
576
  font-weight: 600;
577
577
  color: var(--dsw-alias-label-secondary);
578
+ display: flex;
579
+ align-items: center;
580
+ gap: 8px;
581
+ }
582
+
583
+ /* 「↓ 新输出」跳底按钮:用户上滚后有新行时出现(§24 自动滚动纪律);
584
+ 复用 ghost 语义(L2 描边 + 细圆角),hover 走交互底色,不新增视觉体系 */
585
+ .logJumpButton {
586
+ font-family: var(--dsw-font-family);
587
+ font-size: 11px;
588
+ line-height: 1;
589
+ color: var(--dsw-alias-label-secondary);
590
+ background: transparent;
591
+ border: 1px solid var(--dsw-alias-border-l2);
592
+ border-radius: 999px;
593
+ padding: 3px 8px;
594
+ cursor: pointer;
595
+ }
596
+
597
+ .logJumpButton:hover {
598
+ background: var(--dsw-alias-interactive-bg-hover);
599
+ color: var(--dsw-alias-label-primary);
578
600
  }
579
601
 
580
602
  /* 命令日志容器:限高内滚(长日志不把整页撑长;见 DESIGN.md §7.2 logScroll) */
@@ -252,12 +252,12 @@ export function ExportView({ api, t }: ExportViewProps) {
252
252
  {running && <ProgressBar event={progress} active />}
253
253
 
254
254
  {error !== null && (
255
- <ErrorBanner error={error} onRetry={() => { void runExport() }} retrying={running} />
255
+ <ErrorBanner error={error} onRetry={() => { void runExport() }} retrying={running} t={api.t} />
256
256
  )}
257
257
 
258
258
  {result !== null && !running && (
259
259
  <>
260
- <ReportView kind="export" exportReport={result.report} onDownload={() => { void download(result.zipPath) }} downloadBusy={downloading} />
260
+ <ReportView kind="export" exportReport={result.report} onDownload={() => { void download(result.zipPath) }} downloadBusy={downloading} t={api.t} />
261
261
  {downloaded && <Banner kind="ok">{t('export.saved', { name: result.report.file.name })}</Banner>}
262
262
  </>
263
263
  )}
@@ -114,21 +114,26 @@ function ImportLogPanelBase({ lines, t }: { lines: string[]; t: TranslateNS<'con
114
114
  const stickRef = useRef(true)
115
115
  /** 用户上滚后是否有新行到达(显示「↓ 新输出」;点击跳到底部清除) */
116
116
  const [hasNewOutput, setHasNewOutput] = useState(false)
117
- /** 上次渲染的行数(memo 外比较用;新行 = 长度增加) */
118
- const prevCountRef = useRef(lines.length)
117
+ /**
118
+ * 上次渲染的数组引用(新输出 = 引用变化)。依赖 appendLog 的**不可变写入**:
119
+ * 每次追加都生成新数组(run-registry.ts)——行数封顶后长度恒定,但引用必变,
120
+ * 以引用判断才能感知截断后的新行(长度比较在 500 行封顶时失效)。
121
+ */
122
+ const prevLinesRef = useRef(lines)
119
123
 
120
124
  useEffect(() => {
121
125
  const el = scrollRef.current
122
126
  if (el === null) return
127
+ const hasNew = lines !== prevLinesRef.current
128
+ prevLinesRef.current = lines
123
129
  if (stickRef.current) {
124
130
  el.scrollTop = el.scrollHeight
125
131
  setHasNewOutput(false)
126
- } else if (lines.length > prevCountRef.current) {
132
+ } else if (hasNew) {
127
133
  // 用户已上滚且有新行到达:提示而非强制拉回(§24 自动滚动纪律)
128
134
  setHasNewOutput(true)
129
135
  }
130
- prevCountRef.current = lines.length
131
- }, [lines.length])
136
+ }, [lines])
132
137
 
133
138
  /** 滚动中更新贴底状态(上滚 → 停止跟随;滚回底部 → 恢复跟随并清除提示) */
134
139
  const onScroll = (): void => {
@@ -168,10 +173,11 @@ function ImportLogPanelBase({ lines, t }: { lines: string[]; t: TranslateNS<'con
168
173
  )
169
174
  }
170
175
 
171
- /** memo:lines 数组为同一引用被 append(RunState.log push 不换引用),浅比较失效 ——
172
- * 自定义比较以行数 + t 引用为准,长度未变时跳过整个列表重渲染。 */
176
+ /** memo:lines 数组经 appendLog **不可变追加**(每次 append 换新引用,run-registry.ts)——
177
+ * 自定义比较以「数组引用 + t 引用」为准:引用未变 = 无新输出,跳过整个列表重渲染;
178
+ * 引用已变 = 有新行(含 500 行封顶后长度不变的情况),必须重渲染。 */
173
179
  const ImportLogPanel = memo(ImportLogPanelBase, (prev, next) =>
174
- prev.lines.length === next.lines.length && prev.t === next.t,
180
+ prev.lines === next.lines && prev.t === next.t,
175
181
  )
176
182
 
177
183
  /**
@@ -505,7 +511,7 @@ export function ImportWizardView({ api, t }: ImportWizardViewProps) {
505
511
  {uploading ? <Spinner label={t('import.analyzing')} /> : t(browseLabelKey(selectModel.selectedName !== null))}
506
512
  </Button>
507
513
  </div>
508
- {error !== null && <ErrorBanner error={error} onRetry={resetWizard} />}
514
+ {error !== null && <ErrorBanner error={error} onRetry={resetWizard} t={api.t} />}
509
515
  </div>
510
516
  )
511
517
  }
@@ -514,7 +520,7 @@ export function ImportWizardView({ api, t }: ImportWizardViewProps) {
514
520
  return (
515
521
  <div className={css.viewBody}>
516
522
  <ProgressBar event={progress} active />
517
- {error !== null && <ErrorBanner error={error} onRetry={resetWizard} />}
523
+ {error !== null && <ErrorBanner error={error} onRetry={resetWizard} t={api.t} />}
518
524
  <ErrorList errors={imp.errors} />
519
525
  </div>
520
526
  )
@@ -541,7 +547,7 @@ export function ImportWizardView({ api, t }: ImportWizardViewProps) {
541
547
  {analysis.warnings.map((w, i) => <div key={i}>{w}</div>)}
542
548
  </Banner>
543
549
  )}
544
- {error !== null && <ErrorBanner error={error} onRetry={() => { void goPreview() }} />}
550
+ {error !== null && <ErrorBanner error={error} onRetry={() => { void goPreview() }} t={api.t} />}
545
551
  <div className={css.actionRow}>
546
552
  <Button variant="ghost" onClick={resetWizard}>{t('import.select.reselect')}</Button>
547
553
  <Button variant="primary" onClick={() => { void goPreview() }}>{t('common.next')}</Button>
@@ -568,7 +574,7 @@ export function ImportWizardView({ api, t }: ImportWizardViewProps) {
568
574
  </div>
569
575
  {isEncrypted && <Banner kind="warn">{t('import.decrypt.previewHint')}</Banner>}
570
576
  {summary.needsRestart && <Banner kind="warn">{t('import.preview.restart')}</Banner>}
571
- {error !== null && <ErrorBanner error={error} onRetry={() => { void goPreview() }} />}
577
+ {error !== null && <ErrorBanner error={error} onRetry={() => { void goPreview() }} t={api.t} />}
572
578
  <div className={css.actionRow}>
573
579
  <Button variant="ghost" onClick={resetWizard}>{t('import.select.reselect')}</Button>
574
580
  <Button
@@ -605,7 +611,7 @@ export function ImportWizardView({ api, t }: ImportWizardViewProps) {
605
611
  setArchiveUnlockError(null)
606
612
  }}
607
613
  />
608
- {archiveUnlockError !== null && <ErrorBanner error={archiveUnlockError} />}
614
+ {archiveUnlockError !== null && <ErrorBanner error={archiveUnlockError} t={api.t} />}
609
615
  <div className={css.actionRow}>
610
616
  <Button variant="ghost" onClick={resetWizard}>{t('import.select.reselect')}</Button>
611
617
  <Button
@@ -698,7 +704,7 @@ export function ImportWizardView({ api, t }: ImportWizardViewProps) {
698
704
  </Button>
699
705
  </div>
700
706
  </Card>
701
- {error !== null && <ErrorBanner error={error} onRetry={() => { void execute() }} />}
707
+ {error !== null && <ErrorBanner error={error} onRetry={() => { void execute() }} t={api.t} />}
702
708
  </div>
703
709
  )
704
710
  }
@@ -723,7 +729,7 @@ export function ImportWizardView({ api, t }: ImportWizardViewProps) {
723
729
  )}
724
730
  {imp.skipRequested && <div className={css.hint}>{t('import.skipPending')}</div>}
725
731
  <div className={css.hint}>{t('import.importing')}</div>
726
- {error !== null && <ErrorBanner error={error} />}
732
+ {error !== null && <ErrorBanner error={error} t={api.t} />}
727
733
  <ErrorList errors={imp.errors} />
728
734
  </div>
729
735
  )
@@ -753,7 +759,7 @@ export function ImportWizardView({ api, t }: ImportWizardViewProps) {
753
759
  </div>
754
760
  )}
755
761
  {result.needsRestart && <Banner kind="warn">{t('report.needsRestart')}</Banner>}
756
- {error !== null && <ErrorBanner error={error} />}
762
+ {error !== null && <ErrorBanner error={error} t={api.t} />}
757
763
  </div>
758
764
  )
759
765
  }
@@ -19,7 +19,8 @@ import assert from 'node:assert/strict'
19
19
 
20
20
  import { RunStore, STATE_KEY, type StoreStorage } from './run-store.ts'
21
21
  import type { RunProgress } from './common/progress-view.ts'
22
- import type { RunState } from '../core/run-registry.ts'
22
+ import { MAX_RUN_LOG_LINES, type RunState } from '../core/run-registry.ts'
23
+ import type { RestoreReport } from '../core/restore.ts'
23
24
  import type { ImportDecisions } from '../core/types.ts'
24
25
  import type { ConfigManagerApi } from './api.ts'
25
26
  import {
@@ -503,6 +504,9 @@ test('m3-poll: watchRunning 经 /runs 发现进行中 run 并轮询 /progress
503
504
  // 发现后转入 /progress 轮询且回填过真实进度(内部计数 + 当前项名)
504
505
  assert.ok(progressCalls >= 2, '发现 runId 后轮询 /progress')
505
506
  assert.equal(imp.running, false)
507
+ // 发现活跃 run 时 runId 同步写入 store(fresh run 期间「跳过当前插件」有正确目标,
508
+ // 不会用上一次导入的陈旧 runId —— run-store patchProgress 同步 runId)
509
+ assert.equal(imp.runId, RUN_ID, 'watchRunning 发现 run 后 store.runId 立即填充')
506
510
  })
507
511
 
508
512
  test('m3-poll: watchRunning 轮询回填分区/内部计数进度(ProgressBar 徽章数据源)', async () => {
@@ -565,6 +569,48 @@ test('m3-poll: stopRunWatch 停止发现阶段轮询(视图请求结束后)'
565
569
  store.stopRunWatch('export')
566
570
  })
567
571
 
572
+ test('m3-poll: 导入日志超过 MAX_RUN_LOG_LINES 后 store progress.log 仍持续更新(P1-3 封顶不冻结)', async () => {
573
+ // 模拟导入日志已满 500 行封顶:后续轮询长度恒为 500,但每次 append 都换新数组引用
574
+ // (run-registry appendLog 不可变写入)。store 的 progress.log 必须持续拿到新引用,
575
+ // 否则 ImportLogPanel 的 memo 按引用比较会判定「无变化」→ 日志面板冻结。
576
+ let log: string[] = []
577
+ for (let i = 0; i < MAX_RUN_LOG_LINES; i++) log = [...log, `line-${i}`]
578
+ const progressResponses: RunState[] = []
579
+ for (let i = 0; i < 4; i++) {
580
+ log = [...log.slice(-(MAX_RUN_LOG_LINES - 1)), `cap-${i}`]
581
+ progressResponses.push(runningRun('import', { section: 'plugins', item: i + 1, itemTotal: 8, detail: 'plugin:pkg', log }))
582
+ }
583
+ progressResponses.push({
584
+ runId: RUN_ID, kind: 'import', status: 'done',
585
+ section: null, sectionTotal: null, item: null, itemTotal: null, detail: null, log: [],
586
+ result: makeImportResult(), createdAt: 1, updatedAt: 3,
587
+ })
588
+ let progressCalls = 0
589
+ const api = makeApi({
590
+ runs: async () => [runningRun('import')],
591
+ progress: async () => progressResponses[Math.min(progressCalls++, progressResponses.length - 1)]!,
592
+ })
593
+ const store = new RunStore({ storage: null, pollIntervalMs: 5 })
594
+ await store.resume(api)
595
+ // 订阅捕获轮询期间的 progress.log 引用
596
+ const seenRefs = new Set<string[]>()
597
+ const seenTails = new Set<string>()
598
+ const unsub = store.subscribe(() => {
599
+ const p = store.getSnapshot().import.progress
600
+ const log = p?.log ?? []
601
+ if (log.length === MAX_RUN_LOG_LINES) {
602
+ seenRefs.add(log)
603
+ seenTails.add(log[log.length - 1]!)
604
+ }
605
+ })
606
+ await sleep(150)
607
+ unsub()
608
+ assert.equal(store.getSnapshot().import.step, 'result', '轮询收敛到完成')
609
+ assert.ok(seenRefs.size >= 3, `封顶后轮询仍拿到新数组引用(实际 ${seenRefs.size} 个不同引用)`)
610
+ assert.ok(seenTails.has('cap-3'), '最新行 cap-3 已到达 store(日志没有冻结在旧行)')
611
+ assert.ok(!seenTails.has('line-499'), '截断保留最新行(cap-* 覆盖 line-* 尾部)')
612
+ })
613
+
568
614
  test('m3-poll: 同一 kind 重复 watchRunning 不重复启动', async () => {
569
615
  let runsCalls = 0
570
616
  const api = makeApi({
@@ -873,3 +919,68 @@ test('低频面板: 旧版顶层 syncMode 载荷 → 迁移为 git 通道的 byC
873
919
  assert.equal(st.sync.byChannel.git.selectedSnapshotId, 'snap-old')
874
920
  assert.equal(st.sync.byChannel.webdav.syncMode, 'default', 'webdav 通道保持缺省')
875
921
  })
922
+
923
+ /* -------------------------------------------------- restore(P1-1)权威状态 */
924
+
925
+ test('低频面板: 快照恢复 running 为瞬态——不写入 sessionStorage、刷新后复位(以宿主 /runs 为权威)', () => {
926
+ const { storage, raw } = makeStorage()
927
+ const first = new RunStore({ storage })
928
+ first.patch({ panel: 'snapshots', snapshots: { selectedId: 'snap-1', running: true } })
929
+ const persisted = JSON.parse(raw() ?? '{}') as { snapshots?: { running?: boolean } }
930
+ assert.equal(persisted.snapshots?.running, false, 'running 恒不落盘(白名单剔除)')
931
+
932
+ // 即便旧载荷携带 running=true,applyPersisted 也硬性归零——绝不把浏览器陈旧状态
933
+ // 当成「恢复仍在执行」的依据(宿主 /runs 是唯一权威,resume() 负责重新置位)
934
+ storage.setItem(STATE_KEY, JSON.stringify({
935
+ ...persisted,
936
+ snapshots: { ...persisted.snapshots, running: true },
937
+ }))
938
+ const second = new RunStore({ storage })
939
+ assert.equal(second.getSnapshot().snapshots.running, false, '刷新后 running 复位(不读存储)')
940
+ assert.equal(second.getSnapshot().snapshots.selectedId, 'snap-1', '非瞬态字段仍恢复')
941
+ })
942
+
943
+ test('m2-resume: 活跃 restore run 经 /runs 恢复 running 并轮询到完成回填报告(宿主为权威)', async () => {
944
+ const report: RestoreReport = {
945
+ snapshotId: 'snap-1',
946
+ restored: ['settings.yaml'],
947
+ removedPlugins: ['@scope/pkg'],
948
+ manualHints: [],
949
+ failed: [],
950
+ skipped: [],
951
+ }
952
+ const progressResponses: RunState[] = [
953
+ runningRun('restore', { section: 'restore', item: 1, itemTotal: 3, detail: '整文件还原 settings.yaml' }),
954
+ {
955
+ runId: RUN_ID, kind: 'restore', status: 'done',
956
+ section: null, sectionTotal: null, item: null, itemTotal: null, detail: null, log: [],
957
+ result: report, createdAt: 1, updatedAt: 3,
958
+ },
959
+ ]
960
+ let progressCalls = 0
961
+ const api = makeApi({
962
+ runs: async () => [runningRun('restore')],
963
+ progress: async () => progressResponses[Math.min(progressCalls++, progressResponses.length - 1)]!,
964
+ })
965
+ const store = new RunStore({ storage: null, pollIntervalMs: 5 })
966
+ const resumed = await store.resume(api)
967
+ assert.equal(resumed, true, '发现活跃 restore run')
968
+ assert.equal(store.getSnapshot().snapshots.running, true, 'running 镜像置位(宿主 /runs 为权威)')
969
+
970
+ await sleep(80)
971
+ const snap = store.getSnapshot().snapshots
972
+ assert.equal(snap.running, false, '完成后 running 复位')
973
+ assert.deepEqual(snap.report?.restored, ['settings.yaml'], '恢复报告回填 store')
974
+ assert.equal(snap.actionError, null)
975
+ })
976
+
977
+ test('m2-resume: store 镜像显示恢复中但 host 无活跃 restore run → 复位并提示不可恢复', async () => {
978
+ const api = makeApi({ runs: async () => [] })
979
+ const store = new RunStore({ storage: null })
980
+ store.patch({ snapshots: { running: true } })
981
+ const resumed = await store.resume(api)
982
+ assert.equal(resumed, false)
983
+ const snap = store.getSnapshot().snapshots
984
+ assert.equal(snap.running, false, '无宿主 run 时 running 复位(不残留 UI 假状态)')
985
+ assert.ok(snap.actionError !== null, '如实提示结果不可恢复')
986
+ })
@@ -190,10 +190,15 @@ export interface MarketStoreSlice {
190
190
  myConfirmDeleteId: string | null
191
191
  }
192
192
 
193
- /** 快照恢复面板的切片(无敏感字段;快照列表本身可随时重载,不持久化)。 */
193
+ /** 快照恢复面板的切片(无敏感字段;快照列表本身可随时重载,不持久化)。
194
+ * running 为「内存切片瞬态」:切 tab 由模块级单例保留、刷新时被 toPersistedState
195
+ * 白名单剔除 —— 恢复是否仍在执行以宿主 RunRegistry(/runs + /progress)为权威,
196
+ * 刷新后经 resume() 重新发现;浏览器持久化绝不作为 destructive operation 的状态源。 */
194
197
  export interface SnapshotsStoreSlice {
195
198
  selectedId: string | null
196
199
  plan: RestorePlan | null
200
+ /** 真实恢复执行中(瞬态;宿主 /runs 是权威来源,本字段只是镜像) */
201
+ running: boolean
197
202
  report: RestoreReport | null
198
203
  actionError: string | null
199
204
  error: string | null
@@ -425,6 +430,7 @@ function defaultSnapshotsState(): SnapshotsStoreSlice {
425
430
  return {
426
431
  selectedId: null,
427
432
  plan: null,
433
+ running: false,
428
434
  report: null,
429
435
  actionError: null,
430
436
  error: null,
@@ -505,6 +511,7 @@ export function toSnapshotsStoreSlice(s: SnapshotsStoreSlice): SnapshotsStoreSli
505
511
  return {
506
512
  selectedId: s.selectedId,
507
513
  plan: s.plan,
514
+ running: s.running,
508
515
  report: s.report,
509
516
  actionError: s.actionError,
510
517
  error: s.error,
@@ -560,7 +567,13 @@ export function toPersistedState(state: StoreState): PersistedState {
560
567
  },
561
568
  },
562
569
  market: state.market,
563
- snapshots: state.snapshots,
570
+ snapshots: {
571
+ ...state.snapshots,
572
+ // 快照恢复 running 为内存切片瞬态:不落盘 —— 恢复是否仍在执行以宿主
573
+ // RunRegistry(/runs + /progress)为权威,刷新后由 resume() 重新发现;
574
+ // 持久化「running=true」会把浏览器陈旧状态误当成宿主真实状态(P1-1 原则)。
575
+ running: false,
576
+ },
564
577
  }
565
578
  }
566
579
 
@@ -901,7 +914,13 @@ export class RunStore {
901
914
  }
902
915
  })(),
903
916
  market: { ...defaultMarketState(), ...parsed.market },
904
- snapshots: { ...defaultSnapshotsState(), ...parsed.snapshots },
917
+ snapshots: {
918
+ ...defaultSnapshotsState(),
919
+ ...parsed.snapshots,
920
+ // 硬性:恢复执行中为瞬态,绝不从存储恢复(即使旧载荷携带 running=true 也清空)——
921
+ // 是否仍有恢复在执行以宿主 /runs 为权威,resume() 会重新发现并置 true
922
+ running: false,
923
+ },
905
924
  }
906
925
  // 安全兜底:整体加密备份容器已解锁标志绝不从存储恢复(archiveUnlocked 必为 false)→
907
926
  // 刷新后只要仍标记为加密容器且已越过 decrypt-archive 阶段,就强制退回重新解锁。
@@ -1037,6 +1056,25 @@ export class RunStore {
1037
1056
  },
1038
1057
  })
1039
1058
  }
1059
+
1060
+ // 快照恢复(P1-1):宿主 /runs 是「恢复是否仍在执行」的权威来源。
1061
+ // 刷新/重开面板后若存在活跃 restore run → 恢复 running 并轮询 /progress
1062
+ // (结果经 applySettled 回填 report);持久化的 running 恒为 false(白名单
1063
+ // 剔除),绝不把浏览器陈旧状态当成宿主真实状态。
1064
+ const restoreRun = active.find((r) => r.kind === 'restore')
1065
+ if (restoreRun !== undefined) {
1066
+ resumed = true
1067
+ this.patch({ snapshots: { running: true, actionError: null } })
1068
+ this.pollRun(restoreRun.runId, 'restore')
1069
+ } else if (this.state.snapshots.running) {
1070
+ // 面板镜像显示恢复中,但 host 无活跃 restore run:请求已结束且响应丢失
1071
+ this.patch({
1072
+ snapshots: {
1073
+ running: false,
1074
+ actionError: '上次恢复任务已结束但结果无法恢复,请重新执行',
1075
+ },
1076
+ })
1077
+ }
1040
1078
  return resumed
1041
1079
  }
1042
1080
 
@@ -1142,7 +1180,13 @@ export class RunStore {
1142
1180
  private patchProgress(kind: RunKind, state: RunState): void {
1143
1181
  const event = mapRunProgress(kind, state)
1144
1182
  if (kind === 'export') this.patch({ export: { progress: event } })
1145
- else this.patch({ import: { progress: event } })
1183
+ else if (kind === 'restore') this.patch({ snapshots: { running: true } })
1184
+ else {
1185
+ // 同步写入 runId:watchRunning 发现活跃 import run 时立即填充——
1186
+ // 否则 fresh run(非刷新恢复)期间 store.runId 仍是上一次导入的陈旧值
1187
+ // (/execute 响应在整段导入完成后才带 runId),「跳过当前插件」会打到旧 run。
1188
+ this.patch({ import: { progress: event, runId: state.runId } })
1189
+ }
1146
1190
  }
1147
1191
 
1148
1192
  /** run 完成/失败:把 RunState.result 回填 store(并镜像回控制器)。 */
@@ -1173,6 +1217,22 @@ export class RunStore {
1173
1217
  }
1174
1218
  return
1175
1219
  }
1220
+ // restore(P1-1):完成/失败回填快照面板切片(running 复位 + report/actionError)。
1221
+ // 刷新恢复路径与面板本地状态收敛:applySettled 写入 store,面板挂载时经
1222
+ // initFromStore 读取(切 tab 后回来也能看到结果)。
1223
+ if (kind === 'restore') {
1224
+ const result = state.result as RestoreReport | undefined
1225
+ if (state.status === 'done' && result !== undefined && typeof result === 'object') {
1226
+ this.patch({
1227
+ snapshots: { running: false, report: result, actionError: null },
1228
+ })
1229
+ } else {
1230
+ this.patch({
1231
+ snapshots: { running: false, actionError: state.error ?? '恢复失败(结果不可用)' },
1232
+ })
1233
+ }
1234
+ return
1235
+ }
1176
1236
  // import
1177
1237
  const result = state.result as ImportResult | undefined
1178
1238
  if (state.status === 'done' && result !== undefined && typeof result === 'object') {
@@ -1226,6 +1286,15 @@ export class RunStore {
1226
1286
  error: '任务已结束或超过保留期,进度不可恢复',
1227
1287
  },
1228
1288
  })
1289
+ } else if (kind === 'restore') {
1290
+ // 恢复 run 被清理/丢失:复位 running(宿主侧恢复动作可能仍在执行——
1291
+ // 以 RunRegistry 为准,本提示只说明进度不可恢复,不臆断恢复已停止)
1292
+ this.patch({
1293
+ snapshots: {
1294
+ running: false,
1295
+ actionError: '恢复任务已结束或超过保留期,进度不可恢复',
1296
+ },
1297
+ })
1229
1298
  } else {
1230
1299
  this.importWizardInst?.reset()
1231
1300
  this.patch({
@@ -98,12 +98,15 @@ function actionKindLabel(kind: string): string {
98
98
  /**
99
99
  * 从 runStore 恢复上次的快照面板状态(切 tab 回 / 刷新后挂载)。
100
100
  * 无敏感字段;plan/report 为纯数据,可安全序列化恢复。
101
+ * running 来自 store 镜像(刷新后经 runStore.resume() 以宿主 /runs 为权威重新置位——
102
+ * 持久化白名单恒为 false,绝不把浏览器陈旧状态当成恢复执行中的依据)。
101
103
  */
102
104
  function initFromStore(): PanelState {
103
105
  const s: SnapshotsStoreSlice = runStore.getSnapshot().snapshots
104
106
  return {
105
107
  ...initial,
106
108
  selectedId: s.selectedId,
109
+ running: s.running,
107
110
  plan: s.plan,
108
111
  report: s.report,
109
112
  actionError: s.actionError,
@@ -183,6 +186,10 @@ export function SnapshotsPanel({ api, t }: SnapshotsPanelProps) {
183
186
  if (state.selectedId === null || state.running) return
184
187
  patch({ running: true, report: null, actionError: null })
185
188
  setConfirmOpen(false)
189
+ // m3/P1-1:宿主侧权威防重(/restore 经 RunRegistry 登记,同 kind running → 409);
190
+ // 前端 running 只是 UX 镜像。watchRunning 轮询宿主 /runs + /progress——
191
+ // 切 tab(卸载)后轮询继续,完成/失败经 applySettled 写入 store,切回 initFromStore 恢复。
192
+ runStore.watchRunning('restore', 500)
186
193
  api.restoreSnapshot(state.selectedId, false).then(
187
194
  (res) => { patch({ running: false, report: res.report ?? null }) },
188
195
  (err) => {
@@ -191,7 +198,7 @@ export function SnapshotsPanel({ api, t }: SnapshotsPanelProps) {
191
198
  actionError: err instanceof Error ? err.message : String(err),
192
199
  })
193
200
  },
194
- )
201
+ ).finally(() => { runStore.stopRunWatch('restore') })
195
202
  }
196
203
 
197
204
  const summary = (): string => {
@@ -376,12 +383,17 @@ function BackupScheduleCard({ api, t }: { api: ConfigManagerApi; t: TranslateNS<
376
383
  const [lastRunDetail, setLastRunDetail] = useState<string | null>(null)
377
384
  const [flash, setFlash] = useState<string | null>(null)
378
385
  const [actionError, setActionError] = useState<string | null>(null)
386
+ /** 挂载守卫:切 tab 卸载后异步回调只更新 store(草稿),不再 setState(React 18 无告警但浪费) */
387
+ const mountedRef = useRef(true)
388
+
389
+ useEffect(() => () => { mountedRef.current = false }, [])
379
390
 
380
391
  const load = (): void => {
381
392
  setStatus('loading')
382
393
  setError(null)
383
394
  api.backupSchedule().then(
384
395
  (schedule) => {
396
+ if (!mountedRef.current) return
385
397
  setSaved(schedule)
386
398
  // 有未保存草稿(切 tab 回来)则保留,否则以宿主配置为权威
387
399
  setDraft(runStore.getSnapshot().snapshots.backupDraft ?? { enabled: schedule.enabled, interval: schedule.interval })
@@ -390,6 +402,7 @@ function BackupScheduleCard({ api, t }: { api: ConfigManagerApi; t: TranslateNS<
390
402
  setStatus('ready')
391
403
  },
392
404
  (err) => {
405
+ if (!mountedRef.current) return
393
406
  setStatus('error')
394
407
  setError(err instanceof Error ? err.message : String(err))
395
408
  },
@@ -415,15 +428,18 @@ function BackupScheduleCard({ api, t }: { api: ConfigManagerApi; t: TranslateNS<
415
428
  setActionError(null)
416
429
  api.saveBackupSchedule(parsed.value).then(
417
430
  (schedule) => {
431
+ // 宿主已保存:无论面板是否仍挂载都清 store 草稿(否则切回会显示陈旧未保存态)
432
+ runStore.patch({ snapshots: { backupDraft: null } })
433
+ if (!mountedRef.current) return
418
434
  setSaved(schedule)
419
435
  setDraft({ enabled: schedule.enabled, interval: schedule.interval })
420
436
  setLastRun(schedule.lastRunStatus)
421
437
  setLastRunDetail(formatRunTime(schedule.lastRunAt))
422
- runStore.patch({ snapshots: { backupDraft: null } })
423
438
  setSaving(false)
424
439
  setFlash(t('backupSchedule.saved'))
425
440
  },
426
441
  (err) => {
442
+ if (!mountedRef.current) return
427
443
  setSaving(false)
428
444
  setActionError(err instanceof Error ? err.message : String(err))
429
445
  },
@@ -437,6 +453,7 @@ function BackupScheduleCard({ api, t }: { api: ConfigManagerApi; t: TranslateNS<
437
453
  setActionError(null)
438
454
  api.runBackupNow().then(
439
455
  (res) => {
456
+ if (!mountedRef.current) return
440
457
  setSaved(res.schedule)
441
458
  setLastRun(res.run.status)
442
459
  setLastRunDetail(res.run.zip !== undefined && res.run.zip !== ''
@@ -445,6 +462,7 @@ function BackupScheduleCard({ api, t }: { api: ConfigManagerApi; t: TranslateNS<
445
462
  setRunning(false)
446
463
  },
447
464
  (err) => {
465
+ if (!mountedRef.current) return
448
466
  setRunning(false)
449
467
  setActionError(err instanceof Error ? err.message : String(err))
450
468
  },
@@ -82,6 +82,7 @@ export function SyncHistoryView(props: SyncHistoryViewProps): ReactNode {
82
82
  error={error}
83
83
  onRetry={() => { setReloadKey((k) => k + 1) }}
84
84
  retrying={loading}
85
+ t={api.t}
85
86
  />
86
87
  );
87
88
  }
@@ -94,6 +94,9 @@ export interface RestoreOptions {
94
94
  pluginUninstaller?: (
95
95
  name: string, profileDir: string, profile: string,
96
96
  ) => Promise<{ ok: boolean; message?: string }>;
97
+ /** 每项动作执行回调(宿主路由埋点:更新 RunRegistry 进度;index/1-based、total=计划动作数、
98
+ * detail=动作描述。dry-run/planRestore 不触发) */
99
+ onAction?: (info: { index: number; total: number; detail: string }) => void;
97
100
  /** 消息翻译器(缺省 zh;宿主按 DSH 应用语言注入) */
98
101
  msg?: MsgFunc;
99
102
  }
@@ -494,7 +497,11 @@ export async function restore(opts: RestoreOptions): Promise<RestoreReport> {
494
497
  };
495
498
 
496
499
  let seq = 0;
500
+ const total = plan.actions.length;
501
+ let actionIndex = 0;
497
502
  for (const action of plan.actions) {
503
+ actionIndex += 1;
504
+ opts.onAction?.({ index: actionIndex, total, detail: action.description });
498
505
  try {
499
506
  switch (action.kind) {
500
507
  case 'hostFileRestore': {
@@ -118,6 +118,46 @@ test('appendLog: 日志行数封顶 MAX_RUN_LOG_LINES(超限截断保留最新
118
118
  assert.equal(state?.log[state!.log.length - 1], `line-${MAX_RUN_LOG_LINES + 9}`)
119
119
  })
120
120
 
121
+ test('appendLog: 不可变追加——每次 append 换新数组引用(React memo 感知新行;封顶后长度恒定但引用仍变)', () => {
122
+ const reg = new RunRegistry()
123
+ const run = reg.register('import')
124
+ const initial = reg.get(run.runId)!.log
125
+ reg.appendLog(run.runId, 'line-1')
126
+ const second = reg.get(run.runId)!.log
127
+ assert.notEqual(second, initial, 'append 必须生成新数组引用(不得原地 push)')
128
+ assert.deepEqual(second, ['line-1'])
129
+
130
+ // 500 行封顶后:长度恒定(memo 按长度比较会漏),但每次 append 引用必变
131
+ const capRun = reg.register('export')
132
+ const refs = new Set<unknown[]>()
133
+ for (let i = 0; i < MAX_RUN_LOG_LINES + 5; i++) {
134
+ reg.appendLog(capRun.runId, `line-${i}`)
135
+ refs.add(reg.get(capRun.runId)!.log)
136
+ }
137
+ assert.equal(reg.get(capRun.runId)!.log.length, MAX_RUN_LOG_LINES)
138
+ assert.equal(refs.size, MAX_RUN_LOG_LINES + 5, '每次 append 都是新引用(含封顶后)——前端以引用比较不会漏渲染')
139
+ })
140
+
141
+ test('register: 快照恢复(restore)同 kind 进行中拒绝(P1-1 并发恢复防护)', () => {
142
+ const reg = new RunRegistry()
143
+ const first = reg.register('restore')
144
+ assert.throws(
145
+ () => reg.register('restore'),
146
+ (err: unknown) => {
147
+ assert.ok(err instanceof RunConflictError)
148
+ assert.equal((err as RunConflictError).runId, first.runId)
149
+ return true
150
+ },
151
+ )
152
+ // 恢复中允许导出(不同 kind 互不阻塞)
153
+ const exp = reg.register('export')
154
+ assert.equal(exp.kind, 'export')
155
+ // 完成后的 restore 不阻塞新 restore
156
+ reg.finish(first.runId, {})
157
+ const second = reg.register('restore')
158
+ assert.notEqual(second.runId, first.runId)
159
+ })
160
+
121
161
  test('finish/fail: 状态与 result/error 落账', () => {
122
162
  const reg = new RunRegistry()
123
163
  const done = reg.register('export')