dsh-config-manager 0.1.19 → 0.1.21

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.
Files changed (82) hide show
  1. package/lib/client.d.ts +319 -2
  2. package/lib/client.js +1271 -109
  3. package/lib/client.js.map +1 -1
  4. package/lib/core/run-registry.d.ts +2 -2
  5. package/lib/core/run-registry.js +5 -1
  6. package/lib/core/run-registry.js.map +1 -1
  7. package/lib/index.js +408 -2
  8. package/lib/index.js.map +1 -1
  9. package/lib/sync/ancestor.d.ts +17 -0
  10. package/lib/sync/ancestor.js +64 -0
  11. package/lib/sync/ancestor.js.map +1 -0
  12. package/lib/sync/autosync-config.d.ts +34 -0
  13. package/lib/sync/autosync-config.js +116 -0
  14. package/lib/sync/autosync-config.js.map +1 -0
  15. package/lib/sync/autosync-scheduler.d.ts +95 -0
  16. package/lib/sync/autosync-scheduler.js +379 -0
  17. package/lib/sync/autosync-scheduler.js.map +1 -0
  18. package/lib/sync/merge.d.ts +42 -0
  19. package/lib/sync/merge.js +325 -0
  20. package/lib/sync/merge.js.map +1 -0
  21. package/lib/sync/review-queue.d.ts +51 -0
  22. package/lib/sync/review-queue.js +119 -0
  23. package/lib/sync/review-queue.js.map +1 -0
  24. package/lib/sync/risk.d.ts +59 -0
  25. package/lib/sync/risk.js +76 -0
  26. package/lib/sync/risk.js.map +1 -0
  27. package/lib/sync/sync-config.d.ts +7 -2
  28. package/lib/sync/sync-config.js +24 -4
  29. package/lib/sync/sync-config.js.map +1 -1
  30. package/lib/sync/sync-engine.d.ts +88 -2
  31. package/lib/sync/sync-engine.js +333 -8
  32. package/lib/sync/sync-engine.js.map +1 -1
  33. package/lib/sync/sync-history.d.ts +39 -0
  34. package/lib/sync/sync-history.js +88 -0
  35. package/lib/sync/sync-history.js.map +1 -0
  36. package/lib/sync/sync-session.d.ts +39 -0
  37. package/lib/sync/sync-session.js +54 -0
  38. package/lib/sync/sync-session.js.map +1 -0
  39. package/lib/sync/sync-state.d.ts +7 -2
  40. package/lib/sync/sync-state.js +9 -5
  41. package/lib/sync/sync-state.js.map +1 -1
  42. package/lib/ui/i18n.d.ts +63 -0
  43. package/lib/ui/i18n.js +130 -1
  44. package/lib/ui/i18n.js.map +1 -1
  45. package/package.json +1 -1
  46. package/src/client/config-manager.module.css +18 -0
  47. package/src/client/sync/SyncConfirmView.tsx +301 -0
  48. package/src/client/sync/SyncHistoryView.test.ts +40 -0
  49. package/src/client/sync/SyncHistoryView.tsx +155 -0
  50. package/src/client/sync/SyncSettingsView.tsx +241 -12
  51. package/src/client/sync/history-model.test.ts +82 -0
  52. package/src/client/sync/history-model.ts +112 -0
  53. package/src/client/sync/sync-api.test.ts +155 -0
  54. package/src/client/sync/sync-api.ts +219 -0
  55. package/src/client/sync/sync-locales.ts +162 -1
  56. package/src/client/sync/sync-view-v2.test.ts +131 -0
  57. package/src/client/sync/sync-view.ts +151 -4
  58. package/src/core/run-registry.ts +7 -3
  59. package/src/index.ts +420 -3
  60. package/src/sync/ancestor.test.ts +152 -0
  61. package/src/sync/ancestor.ts +81 -0
  62. package/src/sync/autosync-config.test.ts +93 -0
  63. package/src/sync/autosync-config.ts +137 -0
  64. package/src/sync/autosync-scheduler.test.ts +191 -0
  65. package/src/sync/autosync-scheduler.ts +443 -0
  66. package/src/sync/merge.test.ts +204 -0
  67. package/src/sync/merge.ts +360 -0
  68. package/src/sync/review-queue.test.ts +120 -0
  69. package/src/sync/review-queue.ts +167 -0
  70. package/src/sync/risk.test.ts +131 -0
  71. package/src/sync/risk.ts +122 -0
  72. package/src/sync/sync-config.test.ts +106 -0
  73. package/src/sync/sync-config.ts +23 -4
  74. package/src/sync/sync-engine.test.ts +392 -3
  75. package/src/sync/sync-engine.ts +383 -10
  76. package/src/sync/sync-history.test.ts +85 -0
  77. package/src/sync/sync-history.ts +126 -0
  78. package/src/sync/sync-session.test.ts +137 -0
  79. package/src/sync/sync-session.ts +76 -0
  80. package/src/sync/sync-state.test.ts +48 -2
  81. package/src/sync/sync-state.ts +11 -5
  82. package/src/ui/i18n.ts +132 -3
@@ -7,8 +7,10 @@
7
7
  * + gitBin(可选);
8
8
  * - 私有仓库强制提示横幅(常驻);
9
9
  * - 推送按钮 → SyncPushReport(快照 id / 分区 / 告警);
10
- * - 拉取按钮 → SyncPullReport.changes 差异摘要(description/kind/severity)+
11
- * 「预览不执行导入」提示;needsReview 高亮(v1 不做完整导入接线);
10
+ * - 拉取按钮 → SyncPullReport.changes 差异摘要(description/kind/severity);
11
+ * - 【方案 A】一键同步主按钮:拉取 → 差异确认会话(SyncConfirmView 逐项确认)→
12
+ * 确认导入(apply-items)→ 执行结果 + 一键回滚(restoreId);「选择历史快照」下拉;
13
+ * - 【方案 A】自动同步设置区块:总开关 + 间隔下拉 + 状态(上次运行 / 下次倒计时);
12
14
  * - 状态行:凭据配置 + 上次同步时间 + 通道(来自 GET /sync/status,组件挂载时加载)。
13
15
  *
14
16
  * 全部渲染模型来自 ./sync-view.ts 纯函数(node 单测覆盖),组件只做装配;
@@ -22,12 +24,18 @@ import type { SyncPullReport, SyncPushReport } from '../../sync/sync-engine.ts'
22
24
  import { Badge, Banner, Button, Card, SectionTitle, Spinner } from '../common/ui.tsx'
23
25
  import { ErrorBanner } from '../common/ErrorBanner.tsx'
24
26
  import { SYNC_CREDENTIAL_REF } from './sync-api.ts'
25
- import type { SyncApi, SyncStatusResponse } from './sync-api.ts'
27
+ import type {
28
+ AutosyncInterval, AutosyncStatusResponse, SyncApi, SyncSnapshotLite, SyncStartResponse,
29
+ SyncStatusResponse,
30
+ } from './sync-api.ts'
26
31
  import {
27
- computeGithubLoginView, computeSyncButtons, computeSyncStatus, githubPollMessage,
28
- kindLabel, privateRepoHint, pullReportView, pushReportView, severityLabel,
32
+ autosyncIntervalMs, autosyncStatusText, computeAutosyncCountdown, computeGithubLoginView,
33
+ computeSyncButtons, computeSyncStatus, formatIntervalDuration, githubPollMessage, kindLabel,
34
+ privateRepoHint, pullReportView, pushReportView, severityLabel,
29
35
  } from './sync-view.ts'
30
36
  import type { GithubLoginPhase } from './sync-view.ts'
37
+ import { SyncHistoryView } from './SyncHistoryView.tsx'
38
+ import { SyncConfirmView } from './SyncConfirmView.tsx'
31
39
  import css from '../config-manager.module.css'
32
40
 
33
41
  export interface SyncSettingsViewProps {
@@ -43,9 +51,23 @@ interface SyncUiState {
43
51
  gitBin: string
44
52
  /** 仅内存:成功后清空(已写入 DSH credentials),绝不持久化 */
45
53
  token: string
46
- busy: 'push' | 'pull' | null
54
+ busy: 'sync' | 'push' | 'pull' | 'rollback' | null
47
55
  pushReport: SyncPushReport | null
48
56
  pullReport: SyncPullReport | null
57
+ /** 一键同步差异确认会话(POST /sync/sync 结果;非空时渲染 SyncConfirmView) */
58
+ confirmSession: SyncStartResponse | null
59
+ /** 远端历史快照列表(「选择历史快照」下拉数据源) */
60
+ snapshots: SyncSnapshotLite[]
61
+ /** 当前选中的历史快照 id('' = 最新) */
62
+ selectedSnapshotId: string
63
+ /** 自动同步状态 */
64
+ autosync: AutosyncStatusResponse | null
65
+ /** 自动同步开关(回填自 autosync) */
66
+ autosyncEnabled: boolean
67
+ /** 自动同步间隔(回填自 autosync) */
68
+ autosyncInterval: AutosyncInterval
69
+ /** 最近一次一键同步执行结果(回滚入口) */
70
+ lastRestoreId: string | null
49
71
  error: string | null
50
72
  /** GitHub OAuth device flow 状态(flowId/userCode 仅内存,token 只存宿主) */
51
73
  github: GithubUiState
@@ -65,6 +87,8 @@ const initialGithub: GithubUiState = {
65
87
  phase: 'idle', flowId: '', userCode: '', verificationUri: '', interval: 5, error: null,
66
88
  }
67
89
 
90
+ const AUTOSYNC_INTERVAL_OPTIONS: AutosyncInterval[] = ['5m', '15m', '30m', '60m', '6h', '12h', '24h'];
91
+
68
92
  const initial: SyncUiState = {
69
93
  loading: true,
70
94
  loadError: null,
@@ -75,6 +99,13 @@ const initial: SyncUiState = {
75
99
  busy: null,
76
100
  pushReport: null,
77
101
  pullReport: null,
102
+ confirmSession: null,
103
+ snapshots: [],
104
+ selectedSnapshotId: '',
105
+ autosync: null,
106
+ autosyncEnabled: false,
107
+ autosyncInterval: '30m',
108
+ lastRestoreId: null,
78
109
  error: null,
79
110
  github: initialGithub,
80
111
  }
@@ -87,17 +118,53 @@ export function SyncSettingsView({ api, t }: SyncSettingsViewProps) {
87
118
  /** GitHub 轮询定时器(卸载/取消时清理,防止泄漏与跨流程串扰) */
88
119
  const githubPollTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
89
120
 
90
- /** 挂载时读取同步状态(配置回填 + 上次同步时间 + 凭据状态) */
121
+ /** 挂载时读取同步状态(配置回填 + 上次同步时间 + 凭据状态 + autosync) */
91
122
  const loadStatus = async (): Promise<void> => {
92
123
  patch({ loading: true, loadError: null })
93
124
  try {
94
125
  const info = await api.status()
95
- patch({ loading: false, statusInfo: info, repoUrl: info.repoUrl ?? '', gitBin: info.gitBin ?? '' })
126
+ patch({
127
+ loading: false,
128
+ statusInfo: info,
129
+ repoUrl: info.repoUrl ?? '',
130
+ gitBin: info.gitBin ?? '',
131
+ ...(info.autosync !== undefined
132
+ ? {
133
+ autosync: info.autosync,
134
+ autosyncEnabled: info.autosync.enabled,
135
+ autosyncInterval: info.autosync.interval,
136
+ }
137
+ : {}),
138
+ })
139
+ // 独立拉取 autosync(若 status 未带则补一次)
140
+ if (info.autosync === undefined) {
141
+ void loadAutosync()
142
+ }
96
143
  } catch (err) {
97
144
  patch({ loading: false, loadError: err instanceof Error ? err.message : String(err) })
98
145
  }
99
146
  }
100
147
 
148
+ /** 读取自动同步状态(GET /sync/autosync)。 */
149
+ const loadAutosync = async (): Promise<void> => {
150
+ try {
151
+ const autosync = await api.autosyncStatus()
152
+ patch({ autosync, autosyncEnabled: autosync.enabled, autosyncInterval: autosync.interval })
153
+ } catch (err) {
154
+ patch({ error: err instanceof Error ? err.message : String(err) })
155
+ }
156
+ }
157
+
158
+ /** 读取远端历史快照列表(「选择历史快照」下拉数据源)。 */
159
+ const loadSnapshots = async (): Promise<void> => {
160
+ try {
161
+ const res = await api.snapshotsList(payload())
162
+ patch({ snapshots: res.snapshots })
163
+ } catch {
164
+ // 拉取失败不阻断主流程(下拉留空,用户可重试)
165
+ }
166
+ }
167
+
101
168
  useEffect(() => {
102
169
  void loadStatus()
103
170
  // api 为注入单例(注册时创建),生命周期内稳定;仅挂载时加载一次
@@ -199,6 +266,60 @@ export function SyncSettingsView({ api, t }: SyncSettingsViewProps) {
199
266
  }
200
267
  }
201
268
 
269
+ /* ------------------------------------------------ 一键同步(方案 A) */
270
+
271
+ /** 一键同步:拉取 → 差异确认会话(先取消旧会话,再发起新会话)。 */
272
+ const runSync = async (snapshotId?: string): Promise<void> => {
273
+ // 清理旧的差异确认会话(避免残留临时 ZIP / 同 key 冲突)
274
+ if (state.confirmSession !== null) {
275
+ try { await api.cancel(state.confirmSession.syncSessionId) } catch { /* 尽力清理 */ }
276
+ }
277
+ patch({ busy: 'sync', error: null, confirmSession: null, lastRestoreId: null })
278
+ try {
279
+ const session = await api.sync({ ...payload(), ...(snapshotId !== undefined && snapshotId !== '' ? { snapshotId } : {}) })
280
+ if (!session.ok) {
281
+ patch({ busy: null, error: session.message ?? t('syncflow.syncFailed') })
282
+ return
283
+ }
284
+ patch({ busy: null, confirmSession: session, token: '' })
285
+ void loadSnapshots()
286
+ } catch (err) {
287
+ patch({ busy: null, error: err instanceof Error ? err.message : String(err) })
288
+ }
289
+ }
290
+
291
+ /** 用户取消差异确认:清除会话,复位到空闲。 */
292
+ const cancelConfirm = (): void => {
293
+ patch({ confirmSession: null })
294
+ }
295
+
296
+ /** 从 SyncConfirmView 透传的一键回滚完成信号。 */
297
+ const onRollbackApplied = (): void => {
298
+ patch({ lastRestoreId: null })
299
+ }
300
+
301
+ /* ------------------------------------------------ 自动同步(方案 A) */
302
+
303
+ const toggleAutosync = async (enabled: boolean): Promise<void> => {
304
+ patch({ autosyncEnabled: enabled, error: null })
305
+ try {
306
+ const updated = await api.autosyncUpdate({ enabled, interval: state.autosyncInterval })
307
+ patch({ autosync: updated, autosyncEnabled: updated.enabled, autosyncInterval: updated.interval })
308
+ } catch (err) {
309
+ patch({ error: err instanceof Error ? err.message : String(err) })
310
+ }
311
+ }
312
+
313
+ const updateAutosyncInterval = async (interval: AutosyncInterval): Promise<void> => {
314
+ patch({ autosyncInterval: interval, error: null })
315
+ try {
316
+ const updated = await api.autosyncUpdate({ enabled: state.autosyncEnabled, interval })
317
+ patch({ autosync: updated, autosyncEnabled: updated.enabled, autosyncInterval: updated.interval })
318
+ } catch (err) {
319
+ patch({ error: err instanceof Error ? err.message : String(err) })
320
+ }
321
+ }
322
+
202
323
  const status = computeSyncStatus(state.statusInfo, state.loading, state.loadError, uiT)
203
324
  const buttons = computeSyncButtons(state.busy, state.repoUrl, uiT)
204
325
  const pushView = pushReportView(state.pushReport, uiT)
@@ -210,6 +331,11 @@ export function SyncSettingsView({ api, t }: SyncSettingsViewProps) {
210
331
  const githubBusy =
211
332
  state.github.phase === 'starting' || state.github.phase === 'waiting' || state.github.phase === 'polling'
212
333
 
334
+ const autosyncText = state.autosync !== null ? autosyncStatusText(state.autosync, uiT) : t('autosync.statusNever')
335
+ const autosyncCountdown = state.autosync !== null && state.autosync.elapsedMs >= 0
336
+ ? formatIntervalDuration(computeAutosyncCountdown(state.autosync.elapsedMs, autosyncIntervalMs(state.autosync.interval)), uiT)
337
+ : null
338
+
213
339
  return (
214
340
  <div className={css.viewBody}>
215
341
  <SectionTitle title={t('section.label')} subtitle={t('section.description')} />
@@ -243,7 +369,7 @@ export function SyncSettingsView({ api, t }: SyncSettingsViewProps) {
243
369
  className={css.input}
244
370
  value={state.token}
245
371
  autoComplete="off"
246
- placeholder="ghp_…(可选)"
372
+ placeholder={t('config.tokenPlaceholder')}
247
373
  disabled={state.busy !== null}
248
374
  onChange={(e: ChangeEvent<HTMLInputElement>) => { patch({ token: e.target.value }) }}
249
375
  />
@@ -312,9 +438,16 @@ export function SyncSettingsView({ api, t }: SyncSettingsViewProps) {
312
438
  </div>
313
439
  </Card>
314
440
 
315
- {/* 操作 */}
441
+ {/* 一键同步 + 手动推送/拉取 */}
316
442
  <div className={css.actionRow}>
317
- <Button variant="primary" disabled={!buttons.canPush || githubBusy} onClick={() => { void runPush() }}>
443
+ <Button
444
+ variant="primary"
445
+ disabled={state.busy !== null || state.repoUrl.trim() === ''}
446
+ onClick={() => { void runSync() }}
447
+ >
448
+ {state.busy === 'sync' ? <Spinner label={t('syncflow.syncing')} /> : t('syncflow.button')}
449
+ </Button>
450
+ <Button disabled={!buttons.canPush || githubBusy} onClick={() => { void runPush() }}>
318
451
  {state.busy === 'push' ? <Spinner label={buttons.pushLabel} /> : buttons.pushLabel}
319
452
  </Button>
320
453
  <Button disabled={!buttons.canPull || githubBusy} onClick={() => { void runPull() }}>
@@ -322,8 +455,48 @@ export function SyncSettingsView({ api, t }: SyncSettingsViewProps) {
322
455
  </Button>
323
456
  </div>
324
457
 
458
+ {/* 选择历史快照下拉 */}
459
+ <div className={css.statRow}>
460
+ <label className={css.field}>
461
+ <span className={css.fieldLabel}>{t('syncflow.selectSnapshot')}</span>
462
+ <select
463
+ className={css.input}
464
+ value={state.selectedSnapshotId}
465
+ disabled={state.busy !== null}
466
+ onChange={(e: ChangeEvent<HTMLSelectElement>) => {
467
+ const id = e.target.value
468
+ patch({ selectedSnapshotId: id })
469
+ void runSync(id === '' ? undefined : id)
470
+ }}
471
+ >
472
+ <option value="">{t('syncflow.latestSnapshot')}</option>
473
+ {state.snapshots.map((s) => (
474
+ <option key={s.id} value={s.id}>
475
+ {s.id}{t('syncflow.snapshotOption', { date: s.createdAt.slice(0, 10), count: String(s.sectionCount) })}
476
+ </option>
477
+ ))}
478
+ </select>
479
+ {state.snapshots.length === 0 && <span className={css.hint}>{t('syncflow.noSnapshots')}</span>}
480
+ </label>
481
+ </div>
482
+
325
483
  {state.error !== null && <ErrorBanner error={state.error} />}
326
484
 
485
+ {/* 一键同步差异确认(拉取 → 逐项确认 → 导入) */}
486
+ {state.confirmSession !== null && (
487
+ <SyncConfirmView
488
+ api={api}
489
+ syncSessionId={state.confirmSession.syncSessionId}
490
+ snapshotId={state.confirmSession.snapshotId}
491
+ items={state.confirmSession.items}
492
+ needsReview={state.confirmSession.needsReview}
493
+ compatibility={state.confirmSession.compatibility}
494
+ t={t}
495
+ onCancel={cancelConfirm}
496
+ onRollbackDone={onRollbackApplied}
497
+ />
498
+ )}
499
+
327
500
  {/* 推送结果 */}
328
501
  {pushView !== null && (
329
502
  <Card>
@@ -378,6 +551,62 @@ export function SyncSettingsView({ api, t }: SyncSettingsViewProps) {
378
551
  {pullView.previewHint !== '' && <Banner kind="info">{pullView.previewHint}</Banner>}
379
552
  </Card>
380
553
  )}
554
+
555
+ {/* 自动同步设置(方案 A) */}
556
+ <Card>
557
+ <span className={css.groupLabel}>{t('autosync.title')}</span>
558
+ <span className={css.hint}>{t('autosync.description')}</span>
559
+ <label className={css.checkboxRow}>
560
+ <input
561
+ type="checkbox"
562
+ checked={state.autosyncEnabled}
563
+ disabled={state.busy !== null}
564
+ onChange={(e: ChangeEvent<HTMLInputElement>) => { void toggleAutosync(e.target.checked) }}
565
+ />
566
+ <span>{t('autosync.enable')}</span>
567
+ </label>
568
+ <label className={css.field}>
569
+ <span className={css.fieldLabel}>{t('autosync.interval')}</span>
570
+ <select
571
+ className={css.input}
572
+ value={state.autosyncInterval}
573
+ disabled={state.busy !== null}
574
+ onChange={(e: ChangeEvent<HTMLSelectElement>) => {
575
+ void updateAutosyncInterval(e.target.value as AutosyncInterval)
576
+ }}
577
+ >
578
+ {AUTOSYNC_INTERVAL_OPTIONS.map((iv) => (
579
+ <option key={iv} value={iv}>{intervalLabel(iv, t)}</option>
580
+ ))}
581
+ </select>
582
+ <span className={css.hint}>{t('autosync.intervalHint')}</span>
583
+ </label>
584
+ <div className={css.statRow}>
585
+ <Badge kind={state.autosync?.lastRunStatus === 'failed' ? 'error' : state.autosync?.lastRunStatus === 'skipped' ? 'warn' : 'info'}>
586
+ {autosyncText}
587
+ </Badge>
588
+ {autosyncCountdown !== null && state.autosyncEnabled && (
589
+ <Badge kind="info">{t('autosync.nextRun', { time: autosyncCountdown })}</Badge>
590
+ )}
591
+ </div>
592
+ </Card>
593
+
594
+ {/* P2:同步历史视图(Host /sync/history 端点) */}
595
+ <SyncHistoryView api={api} t={t} />
381
596
  </div>
382
597
  )
383
- }
598
+ }
599
+
600
+ /** AutosyncInterval → 可读标签(复用 i18n interval 键)。 */
601
+ function intervalLabel(iv: AutosyncInterval, t: TranslateNS<'config-manager-sync'>): string {
602
+ switch (iv) {
603
+ case '5m': return t('autosync.interval5m');
604
+ case '15m': return t('autosync.interval15m');
605
+ case '30m': return t('autosync.interval30m');
606
+ case '60m': return t('autosync.interval60m');
607
+ case '6h': return t('autosync.interval6h');
608
+ case '12h': return t('autosync.interval12h');
609
+ case '24h': return t('autosync.interval24h');
610
+ default: return iv;
611
+ }
612
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * m-sync-ui (方案 A):同步历史投影(含自动同步记录)纯函数测试。
3
+ * TDD:先写失败测试,再实现 history-model.ts 对应函数。
4
+ */
5
+ import test from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+
8
+ import type { SyncHistoryEntry } from './sync-api.ts';
9
+ import {
10
+ autosyncStatusLabel, describeSkipReason, directionLabel, formatDateTime,
11
+ projectAutosyncEntry, projectSyncHistoryEntries,
12
+ } from './history-model.ts';
13
+ import type { AutosyncHistoryEntry } from './sync-api.ts';
14
+
15
+ const autosyncEntry = (overrides: Partial<AutosyncHistoryEntry>): AutosyncHistoryEntry => ({
16
+ direction: 'both',
17
+ status: 'skipped',
18
+ skipReason: 'conflict',
19
+ conflictedSections: ['settings', 'plugins'],
20
+ appliedSections: [],
21
+ failureCountAtRun: 0,
22
+ createdAt: '2026-08-17T10:00:00.000Z',
23
+ ...overrides,
24
+ });
25
+
26
+ test('projectSyncHistoryEntries:快照 + 自动同步 按 createdAt 倒序合并', () => {
27
+ const entries: SyncHistoryEntry[] = [
28
+ { id: 'a', createdAt: '2026-08-17T10:00:00.000Z', kind: 'apply', sectionCount: 3, reviewCount: 0 },
29
+ {
30
+ id: 'b', createdAt: '2026-08-17T12:00:00.000Z', kind: 'autosync',
31
+ autosync: autosyncEntry({ createdAt: '2026-08-17T12:00:00.000Z' }),
32
+ },
33
+ { id: 'c', createdAt: '2026-08-17T11:00:00.000Z', kind: 'apply', sectionCount: 2, reviewCount: 0 },
34
+ ];
35
+ const sorted = projectSyncHistoryEntries(entries);
36
+ assert.equal(sorted[0]!.id, 'b');
37
+ assert.equal(sorted[1]!.id, 'c');
38
+ assert.equal(sorted[2]!.id, 'a');
39
+ });
40
+
41
+ test('directionLabel / autosyncStatusLabel:方向与状态映射', () => {
42
+ assert.equal(directionLabel('pull'), '下载');
43
+ assert.equal(directionLabel('push'), '上传');
44
+ assert.equal(directionLabel('both'), '双向');
45
+ assert.equal(autosyncStatusLabel('success'), '成功');
46
+ assert.equal(autosyncStatusLabel('skipped'), '已跳过');
47
+ assert.equal(autosyncStatusLabel('failed'), '失败');
48
+ assert.equal(autosyncStatusLabel('partial'), '部分成功');
49
+ });
50
+
51
+ test('describeSkipReason:已知原因映射,未知回退原串', () => {
52
+ assert.equal(describeSkipReason('conflict'), '冲突项被跳过');
53
+ assert.equal(describeSkipReason('no-remote'), '远端无快照');
54
+ assert.equal(describeSkipReason('not-configured'), '未配置仓库');
55
+ assert.equal(describeSkipReason('network'), '网络问题');
56
+ assert.equal(describeSkipReason('weird'), 'weird');
57
+ assert.equal(describeSkipReason(undefined), '未知');
58
+ });
59
+
60
+ test('projectAutosyncEntry:摘要行 + 可展开明细(冲突分区 / 应用分区 / 错误)', () => {
61
+ const row = projectAutosyncEntry(autosyncEntry({}));
62
+ assert.equal(row.direction, '双向');
63
+ assert.equal(row.status, '已跳过');
64
+ assert.match(row.summary, /双向/);
65
+ assert.match(row.summary, /已跳过/);
66
+ assert.match(row.summary, /冲突项被跳过/);
67
+ assert.deepEqual(row.conflictedSections, ['settings', 'plugins']);
68
+ assert.equal(row.hasDetail, true);
69
+ });
70
+
71
+ test('projectAutosyncEntry:无冲突/无应用/无错误 → hasDetail=false', () => {
72
+ const row = projectAutosyncEntry(autosyncEntry({
73
+ conflictedSections: undefined, appliedSections: undefined, error: undefined,
74
+ }));
75
+ assert.equal(row.hasDetail, false);
76
+ });
77
+
78
+ test('formatDateTime:合法 ISO → 本地格式;空/非法回退', () => {
79
+ assert.match(formatDateTime('2026-08-17T10:30:00.000Z'), /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/);
80
+ assert.equal(formatDateTime(''), '—');
81
+ assert.equal(formatDateTime('not-a-date'), 'not-a-date');
82
+ });
@@ -0,0 +1,112 @@
1
+ /**
2
+ * m-sync-ui (P2b):SyncHistoryView 的纯函数投影。
3
+ *
4
+ * 现状:本地祖先快照目录(manifest.json)→ SnapshotHistoryEntry;倒序排序 + ISO 格式化。
5
+ * 方案 A 扩展:/sync/history 现返回 { entries: SyncHistoryEntry[] }(快照 kind=apply
6
+ * + 自动同步 kind=autosync),投影需统一处理两源,并生成自动同步跳过冲突的可读明细。
7
+ */
8
+ import type { AutosyncHistoryEntry, SyncHistoryEntry } from './sync-api.ts';
9
+
10
+ /** 兼容旧快照条目(manifest.json 投影)。 */
11
+ export interface SnapshotHistoryEntry {
12
+ id: string;
13
+ createdAt: string;
14
+ /** 分区数(manifest.sectionHashes 的 key 数) */
15
+ sectionCount: number;
16
+ /** 关联到该快照的待审项数(来自 sync-review-queue.json 中的 items) */
17
+ reviewCount: number;
18
+ }
19
+
20
+ /** 把快照 entries 排序(createdAt 倒序)并组装展示字段。 */
21
+ export function projectHistoryRows(entries: readonly SnapshotHistoryEntry[]): SnapshotHistoryEntry[] {
22
+ return [...entries].sort(byCreatedAtDesc);
23
+ }
24
+
25
+ /** 统一历史条目(快照 + 自动同步)按 createdAt 倒序排序。 */
26
+ export function projectSyncHistoryEntries(entries: readonly SyncHistoryEntry[]): SyncHistoryEntry[] {
27
+ return [...entries].sort(byCreatedAtDesc);
28
+ }
29
+
30
+ function byCreatedAtDesc(a: { createdAt: string }, b: { createdAt: string }): number {
31
+ return a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0;
32
+ }
33
+
34
+ /** ISO 时间 → 本地可读字符串(短格式) */
35
+ export function formatDateTime(iso: string): string {
36
+ if (!iso) return '—';
37
+ const d = new Date(iso);
38
+ if (isNaN(d.getTime())) return iso;
39
+ const pad = (n: number) => String(n).padStart(2, '0');
40
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
41
+ }
42
+
43
+ /* ---------------------------------------------------------------- 自动同步记录投影 */
44
+
45
+ /** 自动同步执行记录的方向可读标签。 */
46
+ export function directionLabel(direction: AutosyncHistoryEntry['direction']): string {
47
+ switch (direction) {
48
+ case 'pull': return '下载';
49
+ case 'push': return '上传';
50
+ default: return '双向';
51
+ }
52
+ }
53
+
54
+ /** 自动同步执行状态可读标签。 */
55
+ export function autosyncStatusLabel(status: AutosyncHistoryEntry['status']): string {
56
+ switch (status) {
57
+ case 'success': return '成功';
58
+ case 'skipped': return '已跳过';
59
+ case 'partial': return '部分成功';
60
+ default: return '失败';
61
+ }
62
+ }
63
+
64
+ /** 跳过原因 → 可读描述(host 透传语义;未知原因回退原串)。 */
65
+ export function describeSkipReason(reason: string | undefined): string {
66
+ switch (reason) {
67
+ case 'conflict': return '冲突项被跳过';
68
+ case 'no-remote': return '远端无快照';
69
+ case 'not-configured': return '未配置仓库';
70
+ case 'network': return '网络问题';
71
+ default: return reason ?? '未知';
72
+ }
73
+ }
74
+
75
+ /** 把一条自动同步记录投影为展示行(摘要文本 + 可展开的被跳过冲突分区明细)。 */
76
+ export interface AutosyncHistoryRow {
77
+ id: string;
78
+ createdAt: string;
79
+ direction: string;
80
+ status: string;
81
+ /** 摘要行文本(如「下载 · 已跳过 · 冲突项被跳过」)。 */
82
+ summary: string;
83
+ /** 被跳过的冲突分区 id(展开明细用);无则 undefined。 */
84
+ conflictedSections?: string[];
85
+ /** 实际应用的分区 id。 */
86
+ appliedSections?: string[];
87
+ error?: string;
88
+ notifiedAt?: string;
89
+ /** 是否有关联的跳过分区明细可展开。 */
90
+ hasDetail: boolean;
91
+ }
92
+
93
+ /** 自动同步记录 → 展示行投影。 */
94
+ export function projectAutosyncEntry(entry: AutosyncHistoryEntry): AutosyncHistoryRow {
95
+ const parts: string[] = [directionLabel(entry.direction), autosyncStatusLabel(entry.status)];
96
+ if (entry.skipReason !== undefined) parts.push(describeSkipReason(entry.skipReason));
97
+ return {
98
+ id: entry.createdAt,
99
+ createdAt: entry.createdAt,
100
+ direction: directionLabel(entry.direction),
101
+ status: autosyncStatusLabel(entry.status),
102
+ summary: parts.join(' · '),
103
+ conflictedSections: entry.conflictedSections,
104
+ appliedSections: entry.appliedSections,
105
+ error: entry.error,
106
+ notifiedAt: entry.notifiedAt,
107
+ hasDetail:
108
+ (entry.conflictedSections !== undefined && entry.conflictedSections.length > 0) ||
109
+ (entry.appliedSections !== undefined && entry.appliedSections.length > 0) ||
110
+ entry.error !== undefined,
111
+ };
112
+ }