dsh-config-manager 0.1.20 → 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.
- package/lib/client.d.ts +312 -46
- package/lib/client.js +1172 -205
- package/lib/client.js.map +1 -1
- package/lib/core/run-registry.d.ts +2 -2
- package/lib/core/run-registry.js +5 -1
- package/lib/core/run-registry.js.map +1 -1
- package/lib/index.js +292 -19
- package/lib/index.js.map +1 -1
- package/lib/sync/autosync-config.d.ts +34 -0
- package/lib/sync/autosync-config.js +116 -0
- package/lib/sync/autosync-config.js.map +1 -0
- package/lib/sync/autosync-scheduler.d.ts +95 -0
- package/lib/sync/autosync-scheduler.js +379 -0
- package/lib/sync/autosync-scheduler.js.map +1 -0
- package/lib/sync/sync-engine.d.ts +59 -4
- package/lib/sync/sync-engine.js +138 -12
- package/lib/sync/sync-engine.js.map +1 -1
- package/lib/sync/sync-history.d.ts +39 -0
- package/lib/sync/sync-history.js +88 -0
- package/lib/sync/sync-history.js.map +1 -0
- package/lib/sync/sync-session.d.ts +39 -0
- package/lib/sync/sync-session.js +54 -0
- package/lib/sync/sync-session.js.map +1 -0
- package/lib/ui/i18n.d.ts +63 -0
- package/lib/ui/i18n.js +130 -1
- package/lib/ui/i18n.js.map +1 -1
- package/package.json +1 -1
- package/src/client/config-manager.module.css +18 -0
- package/src/client/sync/SyncConfirmView.tsx +301 -0
- package/src/client/sync/SyncHistoryView.tsx +107 -25
- package/src/client/sync/SyncSettingsView.tsx +226 -83
- package/src/client/sync/history-model.test.ts +82 -0
- package/src/client/sync/history-model.ts +89 -2
- package/src/client/sync/sync-api.test.ts +155 -0
- package/src/client/sync/sync-api.ts +208 -16
- package/src/client/sync/sync-locales.ts +162 -1
- package/src/client/sync/sync-view-v2.test.ts +131 -0
- package/src/client/sync/sync-view.ts +151 -4
- package/src/core/run-registry.ts +7 -3
- package/src/index.ts +314 -22
- package/src/sync/autosync-config.test.ts +93 -0
- package/src/sync/autosync-config.ts +137 -0
- package/src/sync/autosync-scheduler.test.ts +191 -0
- package/src/sync/autosync-scheduler.ts +443 -0
- package/src/sync/sync-engine.test.ts +107 -7
- package/src/sync/sync-engine.ts +176 -14
- package/src/sync/sync-history.test.ts +85 -0
- package/src/sync/sync-history.ts +126 -0
- package/src/sync/sync-session.test.ts +137 -0
- package/src/sync/sync-session.ts +76 -0
- package/src/ui/i18n.ts +132 -3
- package/src/client/sync/SyncPullPreviewView.test.ts +0 -54
- package/src/client/sync/SyncPullPreviewView.tsx +0 -165
- package/src/client/sync/pull-preview-model.ts +0 -44
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
2
|
* 远程同步设置区块(config-manager-sync)表面文案:zh 为源语言,en 镜像每个键。
|
|
3
3
|
* 独立命名空间、独立文件:不触碰共享的 locales.ts(并行会话已改),零冲突。
|
|
4
4
|
* 键集合经 `SyncKey` 类型在 client/index.ts 注册处做编译期校验。
|
|
@@ -17,6 +17,7 @@ export const zh = {
|
|
|
17
17
|
'config.token': '认证 token',
|
|
18
18
|
'config.tokenHint': '将安全写入 DSH credentials(引用名 {ref}),不会写入同步文件或日志。留空表示沿用已保存的凭据。',
|
|
19
19
|
'config.tokenSaved': '凭据已配置',
|
|
20
|
+
'config.tokenPlaceholder': 'ghp_…(可选)',
|
|
20
21
|
'config.gitBin': 'git 可执行文件(可选)',
|
|
21
22
|
'config.gitBinHint': '缺省使用 PATH 中的 git。',
|
|
22
23
|
// GitHub OAuth 登录(device flow,无需手动输入 token)
|
|
@@ -45,6 +46,87 @@ export const zh = {
|
|
|
45
46
|
'change.total': '共 {total} 项变更',
|
|
46
47
|
'sections.title': '同步分区',
|
|
47
48
|
'warnings.title': '分区告警',
|
|
49
|
+
// 一键同步(方案 A:拉取 → 逐项确认 → apply-items)
|
|
50
|
+
'syncflow.title': '一键同步',
|
|
51
|
+
'syncflow.button': '一键同步',
|
|
52
|
+
'syncflow.syncing': '正在同步…',
|
|
53
|
+
'syncflow.syncFailed': '同步失败',
|
|
54
|
+
'syncflow.snapshotOption': '({date} · {count} 分区)',
|
|
55
|
+
'syncflow.selectSnapshot': '选择历史快照',
|
|
56
|
+
'syncflow.latestSnapshot': '最新快照',
|
|
57
|
+
'syncflow.noSnapshots': '远端暂无快照',
|
|
58
|
+
'syncflow.confirmImport': '确认导入',
|
|
59
|
+
'syncflow.cancel': '取消',
|
|
60
|
+
'syncflow.adoptRemote': '采用远端',
|
|
61
|
+
'syncflow.adoptHint': '勾选 = 导入该项;取消 = 跳过',
|
|
62
|
+
'syncflow.needsReviewBadge': '需人工决策',
|
|
63
|
+
'syncflow.empty': '远端快照与本地一致(无变更)',
|
|
64
|
+
'syncflow.diffCount': '共 {count} 项差异',
|
|
65
|
+
'syncflow.conflictTitle': '冲突解决',
|
|
66
|
+
'syncflow.conflictUseLocal': '用本地',
|
|
67
|
+
'syncflow.conflictUseRemote': '用远端',
|
|
68
|
+
'syncflow.conflictSkip': '跳过',
|
|
69
|
+
'syncflow.conflictLocalLabel': '本地',
|
|
70
|
+
'syncflow.conflictRemoteLabel': '远端',
|
|
71
|
+
'syncflow.conflictAncestorLabel': '共同祖先',
|
|
72
|
+
'syncflow.conflictUnresolved': '冲突项 {itemId} 尚未选择解决方式(用本地 / 用远端 / 跳过)',
|
|
73
|
+
'syncflow.diff': '差异',
|
|
74
|
+
'syncflow.importDone': '已导入 {n} 个分区',
|
|
75
|
+
'syncflow.importFailed': '导入失败(已整体回滚)',
|
|
76
|
+
'syncflow.importedSections': '已写入',
|
|
77
|
+
'syncflow.importWarnings': '导入告警',
|
|
78
|
+
'syncflow.rollback': '回滚到应用前',
|
|
79
|
+
'syncflow.rollingBack': '正在回滚…',
|
|
80
|
+
'syncflow.rollbackDone': '已回滚到应用前',
|
|
81
|
+
'syncflow.needsRestart': '部分改动需要重启 DSH 后生效',
|
|
82
|
+
// 自动同步(方案 A)
|
|
83
|
+
'autosync.title': '自动同步',
|
|
84
|
+
'autosync.description': '开启后,DSH 将按固定间隔自动执行双向同步(拉取合并 + 上传);DSH 启动时还会触发一次「自动下载合并(不上传)」以保持本地为最新。仅在无冲突且无需人工干预时才自动写入。',
|
|
85
|
+
'autosync.enable': '启用自动同步',
|
|
86
|
+
'autosync.interval': '同步间隔',
|
|
87
|
+
'autosync.intervalHint': 'DSH 启动时自动下载合并不上传。',
|
|
88
|
+
'autosync.interval5m': '5 分钟',
|
|
89
|
+
'autosync.interval15m': '15 分钟',
|
|
90
|
+
'autosync.interval30m': '30 分钟',
|
|
91
|
+
'autosync.interval60m': '60 分钟',
|
|
92
|
+
'autosync.interval6h': '6 小时',
|
|
93
|
+
'autosync.interval12h': '12 小时',
|
|
94
|
+
'autosync.interval24h': '24 小时',
|
|
95
|
+
'autosync.status': '上次运行:{status}({time})',
|
|
96
|
+
'autosync.statusNever': '从未运行',
|
|
97
|
+
'autosync.nextRun': '下次约 {time} 后',
|
|
98
|
+
'autosync.failCount': '连续失败 {n} 次',
|
|
99
|
+
'autosync.running': '自动同步进行中…',
|
|
100
|
+
'autosync.success': '成功',
|
|
101
|
+
'autosync.skipped': '已跳过',
|
|
102
|
+
'autosync.failed': '失败',
|
|
103
|
+
'autosync.partial': '部分成功',
|
|
104
|
+
// 同步历史(含自动同步记录)
|
|
105
|
+
'history.title': '同步历史',
|
|
106
|
+
'history.empty': '尚无同步历史',
|
|
107
|
+
'history.emptyHint': '完成首次 push / 一键同步 / 自动同步后,这里会显示记录。',
|
|
108
|
+
'history.colTime': '时间',
|
|
109
|
+
'history.colKind': '类型',
|
|
110
|
+
'history.colDetail': '详情',
|
|
111
|
+
'history.kindSnapshot': '快照',
|
|
112
|
+
'history.kindAutosync': '自动同步',
|
|
113
|
+
'history.sectionCount': '分区',
|
|
114
|
+
'history.detail': '明细',
|
|
115
|
+
'history.autosyncDirection': '方向:{direction}',
|
|
116
|
+
'history.autosyncPull': '下载',
|
|
117
|
+
'history.autosyncPush': '上传',
|
|
118
|
+
'history.autosyncBoth': '双向',
|
|
119
|
+
'history.autosyncSkipReason': '跳过原因:{reason}',
|
|
120
|
+
'history.autosyncReasonConflict': '冲突',
|
|
121
|
+
'history.autosyncReasonNoRemote': '无远端快照',
|
|
122
|
+
'history.autosyncReasonNotConfigured': '未配置仓库',
|
|
123
|
+
'history.autosyncReasonNetwork': '网络问题',
|
|
124
|
+
'history.autosyncConflicted': '跳过冲突分区:{sections}',
|
|
125
|
+
'history.autosyncApplied': '应用分区:{sections}',
|
|
126
|
+
'history.autosyncError': '错误:{error}',
|
|
127
|
+
'history.autosyncNotified': '已通知(连续失败 3 次)',
|
|
128
|
+
'history.column.snapshot': '快照',
|
|
129
|
+
'history.column.autosync': '自动同步',
|
|
48
130
|
// 公共
|
|
49
131
|
'common.close': '关闭',
|
|
50
132
|
'common.retry': '重试',
|
|
@@ -61,6 +143,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
61
143
|
'config.token': 'Auth token',
|
|
62
144
|
'config.tokenHint': 'Securely written into DSH credentials (ref {ref}); never written into sync files or logs. Leave blank to reuse the saved credential.',
|
|
63
145
|
'config.tokenSaved': 'Credential configured',
|
|
146
|
+
'config.tokenPlaceholder': 'ghp_… (optional)',
|
|
64
147
|
'config.gitBin': 'git executable (optional)',
|
|
65
148
|
'config.gitBinHint': 'Defaults to git on PATH.',
|
|
66
149
|
'github.title': 'GitHub Sign-in',
|
|
@@ -85,6 +168,84 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
85
168
|
'change.total': '{total} change(s)',
|
|
86
169
|
'sections.title': 'Synced Sections',
|
|
87
170
|
'warnings.title': 'Section Warnings',
|
|
171
|
+
'syncflow.title': 'One-Click Sync',
|
|
172
|
+
'syncflow.button': 'One-click sync',
|
|
173
|
+
'syncflow.syncing': 'Syncing…',
|
|
174
|
+
'syncflow.syncFailed': 'Sync failed',
|
|
175
|
+
'syncflow.snapshotOption': '({date} · {count} sections)',
|
|
176
|
+
'syncflow.selectSnapshot': 'Select snapshot',
|
|
177
|
+
'syncflow.latestSnapshot': 'Latest snapshot',
|
|
178
|
+
'syncflow.noSnapshots': 'No remote snapshots',
|
|
179
|
+
'syncflow.confirmImport': 'Confirm import',
|
|
180
|
+
'syncflow.cancel': 'Cancel',
|
|
181
|
+
'syncflow.adoptRemote': 'Adopt remote',
|
|
182
|
+
'syncflow.adoptHint': 'Check = import item; uncheck = skip',
|
|
183
|
+
'syncflow.needsReviewBadge': 'Needs decision',
|
|
184
|
+
'syncflow.empty': 'Remote snapshot matches local (no changes)',
|
|
185
|
+
'syncflow.diffCount': '{count} change(s)',
|
|
186
|
+
'syncflow.conflictTitle': 'Resolve conflict',
|
|
187
|
+
'syncflow.conflictUseLocal': 'Use local',
|
|
188
|
+
'syncflow.conflictUseRemote': 'Use remote',
|
|
189
|
+
'syncflow.conflictSkip': 'Skip',
|
|
190
|
+
'syncflow.conflictLocalLabel': 'Local',
|
|
191
|
+
'syncflow.conflictRemoteLabel': 'Remote',
|
|
192
|
+
'syncflow.conflictAncestorLabel': 'Common ancestor',
|
|
193
|
+
'syncflow.conflictUnresolved': 'Conflict item {itemId} has no resolution yet (use local / use remote / skip)',
|
|
194
|
+
'syncflow.diff': 'Diff',
|
|
195
|
+
'syncflow.importDone': 'Imported {n} section(s)',
|
|
196
|
+
'syncflow.importFailed': 'Import failed (rolled back)',
|
|
197
|
+
'syncflow.importedSections': 'Written',
|
|
198
|
+
'syncflow.importWarnings': 'Import warnings',
|
|
199
|
+
'syncflow.rollback': 'Rollback',
|
|
200
|
+
'syncflow.rollingBack': 'Rolling back…',
|
|
201
|
+
'syncflow.rollbackDone': 'Rolled back',
|
|
202
|
+
'syncflow.needsRestart': 'Some changes require a DSH restart to take effect',
|
|
203
|
+
'autosync.title': 'Auto Sync',
|
|
204
|
+
'autosync.description': 'When enabled, DSH will run two-way sync (pull merge + push) on a fixed interval; on startup it also triggers a download-merge (no upload) to keep local up to date. Local writes happen only when there are no conflicts or manual-decision items.',
|
|
205
|
+
'autosync.enable': 'Enable auto sync',
|
|
206
|
+
'autosync.interval': 'Sync interval',
|
|
207
|
+
'autosync.intervalHint': 'Auto download-merge on DSH startup, no upload.',
|
|
208
|
+
'autosync.interval5m': '5 min',
|
|
209
|
+
'autosync.interval15m': '15 min',
|
|
210
|
+
'autosync.interval30m': '30 min',
|
|
211
|
+
'autosync.interval60m': '60 min',
|
|
212
|
+
'autosync.interval6h': '6 hr',
|
|
213
|
+
'autosync.interval12h': '12 hr',
|
|
214
|
+
'autosync.interval24h': '24 hr',
|
|
215
|
+
'autosync.status': 'Last run: {status} ({time})',
|
|
216
|
+
'autosync.statusNever': 'Never run',
|
|
217
|
+
'autosync.nextRun': 'Next in ~{time}',
|
|
218
|
+
'autosync.failCount': '{n} consecutive failure(s)',
|
|
219
|
+
'autosync.running': 'Auto sync running…',
|
|
220
|
+
'autosync.success': 'Success',
|
|
221
|
+
'autosync.skipped': 'Skipped',
|
|
222
|
+
'autosync.failed': 'Failed',
|
|
223
|
+
'autosync.partial': 'Partial',
|
|
224
|
+
'history.title': 'Sync History',
|
|
225
|
+
'history.empty': 'No sync history yet',
|
|
226
|
+
'history.emptyHint': 'Records appear here after your first push / one-click sync / auto sync.',
|
|
227
|
+
'history.colTime': 'Time',
|
|
228
|
+
'history.colKind': 'Kind',
|
|
229
|
+
'history.colDetail': 'Detail',
|
|
230
|
+
'history.kindSnapshot': 'Snapshot',
|
|
231
|
+
'history.kindAutosync': 'Auto Sync',
|
|
232
|
+
'history.sectionCount': 'sections',
|
|
233
|
+
'history.detail': 'Detail',
|
|
234
|
+
'history.autosyncDirection': 'Direction: {direction}',
|
|
235
|
+
'history.autosyncPull': 'Pull',
|
|
236
|
+
'history.autosyncPush': 'Push',
|
|
237
|
+
'history.autosyncBoth': 'Both',
|
|
238
|
+
'history.autosyncSkipReason': 'Skip reason: {reason}',
|
|
239
|
+
'history.autosyncReasonConflict': 'Conflict',
|
|
240
|
+
'history.autosyncReasonNoRemote': 'No remote snapshot',
|
|
241
|
+
'history.autosyncReasonNotConfigured': 'No repository configured',
|
|
242
|
+
'history.autosyncReasonNetwork': 'Network issue',
|
|
243
|
+
'history.autosyncConflicted': 'Skipped conflict sections: {sections}',
|
|
244
|
+
'history.autosyncApplied': 'Applied sections: {sections}',
|
|
245
|
+
'history.autosyncError': 'Error: {error}',
|
|
246
|
+
'history.autosyncNotified': 'Notified (3 consecutive failures)',
|
|
247
|
+
'history.column.snapshot': 'Snapshot',
|
|
248
|
+
'history.column.autosync': 'Auto Sync',
|
|
88
249
|
'common.close': 'Close',
|
|
89
250
|
'common.retry': 'Retry',
|
|
90
251
|
'common.loading': 'Loading…',
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* m-sync-ui (方案 A):一键同步差异确认 + 自动同步 渲染模型纯函数测试。
|
|
3
|
+
* TDD:先写失败测试,再实现 sync-view.ts 对应函数。
|
|
4
|
+
*/
|
|
5
|
+
import test from 'node:test'
|
|
6
|
+
import assert from 'node:assert/strict'
|
|
7
|
+
|
|
8
|
+
import type { ApplyItemsResponse, AutosyncStatusResponse, SyncConfirmItem } from './sync-api.ts'
|
|
9
|
+
import {
|
|
10
|
+
applyItemsReportView, autosyncIntervalMs, autosyncStatusText, buildAdoptions,
|
|
11
|
+
computeAutosyncCountdown, summarizeConfirmItems,
|
|
12
|
+
} from './sync-view.ts'
|
|
13
|
+
|
|
14
|
+
/* ---------------------------------------------------------------- 一键同步差异确认 */
|
|
15
|
+
|
|
16
|
+
function confirmItem(overrides: Partial<SyncConfirmItem>): SyncConfirmItem {
|
|
17
|
+
return {
|
|
18
|
+
itemId: 'a', adapter: 'settings', kind: 'Update', description: 'd', severity: 'info',
|
|
19
|
+
defaultAdopt: true, adopt: true, ...overrides,
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test('sync-view: summarizeConfirmItems 统计 severity + 采用数 + needsReview', () => {
|
|
24
|
+
const items: SyncConfirmItem[] = [
|
|
25
|
+
confirmItem({ itemId: 'a', severity: 'info', kind: 'Update' }),
|
|
26
|
+
confirmItem({ itemId: 'b', severity: 'warning', kind: 'MissingDependency', defaultAdopt: false, adopt: false }),
|
|
27
|
+
confirmItem({ itemId: 'c', severity: 'error', kind: 'Error', defaultAdopt: false, adopt: false }),
|
|
28
|
+
confirmItem({ itemId: 'd', severity: 'info', kind: 'Conflict', defaultAdopt: false, adopt: false }),
|
|
29
|
+
]
|
|
30
|
+
const s = summarizeConfirmItems(items)
|
|
31
|
+
assert.equal(s.total, 4)
|
|
32
|
+
assert.equal(s.info, 2)
|
|
33
|
+
assert.equal(s.warning, 1)
|
|
34
|
+
assert.equal(s.error, 1)
|
|
35
|
+
assert.equal(s.adopted, 1)
|
|
36
|
+
assert.equal(s.needsReview, true)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('sync-view: summarizeConfirmItems 空数组 → 全零 + 不需决策', () => {
|
|
40
|
+
const s = summarizeConfirmItems([])
|
|
41
|
+
assert.equal(s.total, 0)
|
|
42
|
+
assert.equal(s.adopted, 0)
|
|
43
|
+
assert.equal(s.needsReview, false)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('sync-view: buildAdoptions 收集用户决策;未列出项视为 adopt=false', () => {
|
|
47
|
+
const items: SyncConfirmItem[] = [
|
|
48
|
+
confirmItem({ itemId: 'a', kind: 'Update' }),
|
|
49
|
+
confirmItem({ itemId: 'b', kind: 'Create' }),
|
|
50
|
+
confirmItem({ itemId: 'c', kind: 'Conflict', defaultAdopt: false }),
|
|
51
|
+
]
|
|
52
|
+
const adopted = new Map<string, boolean>([
|
|
53
|
+
['a', true],
|
|
54
|
+
['b', false],
|
|
55
|
+
['c', true],
|
|
56
|
+
])
|
|
57
|
+
const resolutions = new Map<string, 'useRemote' | 'keepLocal' | 'skip'>([['c', 'useRemote']])
|
|
58
|
+
const out = buildAdoptions(items, adopted, resolutions)
|
|
59
|
+
// 只有 adopt=true 的项进列表(a、c);b 未采纳剔除
|
|
60
|
+
const ids = out.map((o) => o.itemId)
|
|
61
|
+
assert.deepEqual(ids, ['a', 'c'])
|
|
62
|
+
assert.equal(out[1]?.resolution, 'useRemote')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('sync-view: buildAdoptions Conflict 项未给 resolution → 抛错(强制先解决)', () => {
|
|
66
|
+
const items: SyncConfirmItem[] = [
|
|
67
|
+
confirmItem({ itemId: 'c', kind: 'Conflict', defaultAdopt: false }),
|
|
68
|
+
]
|
|
69
|
+
const adopted = new Map<string, boolean>([['c', true]])
|
|
70
|
+
const resolutions = new Map<string, 'useRemote' | 'keepLocal' | 'skip'>()
|
|
71
|
+
assert.throws(() => buildAdoptions(items, adopted, resolutions), /解决/)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('sync-view: applyItemsReportView 成功 → ok 头部含 applied 计数与 restoreId', () => {
|
|
75
|
+
const report: ApplyItemsResponse = {
|
|
76
|
+
ok: true, applied: ['settings', 'plugins'], skipped: [], needsRestart: false,
|
|
77
|
+
warnings: [], restoreId: 'rest-1', rolledBack: false, failed: [], result: {},
|
|
78
|
+
}
|
|
79
|
+
const view = applyItemsReportView(report)
|
|
80
|
+
assert.notEqual(view, null)
|
|
81
|
+
assert.equal(view?.kind, 'ok')
|
|
82
|
+
assert.match(view?.headline ?? '', /2/)
|
|
83
|
+
assert.equal(view?.restoreId, 'rest-1')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('sync-view: applyItemsReportView 失败且整体回滚 → rolledBack 渲染', () => {
|
|
87
|
+
const report: ApplyItemsResponse = {
|
|
88
|
+
ok: false, applied: [], skipped: [], needsRestart: false,
|
|
89
|
+
warnings: [], restoreId: 'rest-2', rolledBack: true,
|
|
90
|
+
failed: [{ itemId: 'x', message: '导入失败' }], result: {},
|
|
91
|
+
}
|
|
92
|
+
const view = applyItemsReportView(report)
|
|
93
|
+
assert.equal(view?.kind, 'rolledBack')
|
|
94
|
+
assert.equal(view?.restoreId, 'rest-2')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('sync-view: applyItemsReportView null → null', () => {
|
|
98
|
+
assert.equal(applyItemsReportView(null), null)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
/* ---------------------------------------------------------------- 自动同步 */
|
|
102
|
+
|
|
103
|
+
test('sync-view: autosyncIntervalMs 换算正确', () => {
|
|
104
|
+
assert.equal(autosyncIntervalMs('5m'), 5 * 60 * 1000)
|
|
105
|
+
assert.equal(autosyncIntervalMs('30m'), 30 * 60 * 1000)
|
|
106
|
+
assert.equal(autosyncIntervalMs('24h'), 24 * 60 * 60 * 1000)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('sync-view: computeAutosyncCountdown 距上次已过超过间隔 → 0(到期立即)', () => {
|
|
110
|
+
assert.equal(computeAutosyncCountdown(30 * 60 * 1000, 30 * 60 * 1000), 0)
|
|
111
|
+
assert.equal(computeAutosyncCountdown(31 * 60 * 1000, 30 * 60 * 1000), 0)
|
|
112
|
+
assert.equal(computeAutosyncCountdown(10 * 60 * 1000, 30 * 60 * 1000), 20 * 60 * 1000)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('sync-view: autosyncStatusText 覆盖未运行 / 各状态', () => {
|
|
116
|
+
const status: AutosyncStatusResponse = {
|
|
117
|
+
enabled: true, interval: '30m', consecutiveFailures: 0, elapsedMs: -1,
|
|
118
|
+
}
|
|
119
|
+
assert.match(autosyncStatusText(status), /从未运行/)
|
|
120
|
+
const ran: AutosyncStatusResponse = {
|
|
121
|
+
enabled: true, interval: '30m', consecutiveFailures: 0, elapsedMs: 0,
|
|
122
|
+
lastRunAt: '2026-08-17T10:00:00.000Z', lastRunStatus: 'success',
|
|
123
|
+
}
|
|
124
|
+
assert.match(autosyncStatusText(ran), /成功/)
|
|
125
|
+
const failed: AutosyncStatusResponse = {
|
|
126
|
+
enabled: true, interval: '30m', consecutiveFailures: 3, elapsedMs: 0,
|
|
127
|
+
lastRunAt: '2026-08-17T10:00:00.000Z', lastRunStatus: 'failed', lastRunMessage: '认证失败',
|
|
128
|
+
}
|
|
129
|
+
assert.match(autosyncStatusText(failed), /失败/)
|
|
130
|
+
assert.match(autosyncStatusText(failed), /连续失败 3 次/)
|
|
131
|
+
})
|
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { PlanItem, PlanItemKind } from '../../core/types.ts';
|
|
9
9
|
import type { PullChange, SyncPullReport, SyncPushReport } from '../../sync/sync-engine.ts';
|
|
10
|
-
import type {
|
|
10
|
+
import type {
|
|
11
|
+
ApplyItemsResponse, AutosyncInterval, AutosyncStatusResponse, GithubPollResponse, SyncConfirmItem,
|
|
12
|
+
SyncItemAdoption, SyncStatusResponse,
|
|
13
|
+
} from './sync-api.ts';
|
|
11
14
|
import { zhUiT, type UiT } from '../../ui/i18n.ts';
|
|
12
15
|
|
|
13
16
|
/* ---------------------------------------------------------------- 私有仓库提示 */
|
|
@@ -94,15 +97,15 @@ export interface SyncButtons {
|
|
|
94
97
|
* - 仓库地址为空 → 禁用(无仓库无从同步);
|
|
95
98
|
* - busy 时按钮文案切换为「正在推送/拉取…」(配 Spinner)。
|
|
96
99
|
*/
|
|
97
|
-
export function computeSyncButtons(busy: 'push' | 'pull' | 'apply' | 'rollback' | null, repoUrl: string, t: UiT = zhUiT): SyncButtons {
|
|
100
|
+
export function computeSyncButtons(busy: 'sync' | 'push' | 'pull' | 'apply' | 'rollback' | null, repoUrl: string, t: UiT = zhUiT): SyncButtons {
|
|
98
101
|
const idle = busy === null;
|
|
99
102
|
const repoOk = repoUrl.trim() !== '';
|
|
100
103
|
const enabled = idle && repoOk;
|
|
101
104
|
return {
|
|
102
105
|
canPush: enabled,
|
|
103
106
|
canPull: enabled,
|
|
104
|
-
pushLabel: busy === 'push' ? t('sync.pushing') : t('sync.pushLabel'),
|
|
105
|
-
pullLabel: busy === 'pull' ? t('sync.pulling') : t('sync.pullLabel'),
|
|
107
|
+
pushLabel: busy === 'push' ? t('sync.pushing') : busy === 'sync' ? t('sync.syncing') : t('sync.pushLabel'),
|
|
108
|
+
pullLabel: busy === 'pull' ? t('sync.pulling') : busy === 'sync' ? t('sync.syncing') : t('sync.pullLabel'),
|
|
106
109
|
};
|
|
107
110
|
}
|
|
108
111
|
|
|
@@ -290,3 +293,147 @@ export function githubPollMessage(poll: GithubPollResponse, t: UiT = zhUiT): str
|
|
|
290
293
|
return '';
|
|
291
294
|
}
|
|
292
295
|
}
|
|
296
|
+
|
|
297
|
+
/* ---------------------------------------------------------------- 一键同步差异确认(方案 A) */
|
|
298
|
+
|
|
299
|
+
/** 需要人工决策的 PlanItemKind(与 Host /sync/sync 的 needsReview 判定对齐)。 */
|
|
300
|
+
const CONFIRM_REVIEW_KINDS: ReadonlySet<PlanItemKind> = new Set([
|
|
301
|
+
'Conflict', 'MissingSecret', 'MissingDependency', 'Install', 'Error', 'PathMapping',
|
|
302
|
+
]);
|
|
303
|
+
|
|
304
|
+
export interface SyncConfirmSummary {
|
|
305
|
+
total: number;
|
|
306
|
+
info: number;
|
|
307
|
+
warning: number;
|
|
308
|
+
error: number;
|
|
309
|
+
/** 默认/当前采用数(adopt=true 的项数)。 */
|
|
310
|
+
adopted: number;
|
|
311
|
+
/** 是否包含任何需人工决策项。 */
|
|
312
|
+
needsReview: boolean;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** 差异确认列表摘要(按 severity 计数 + 采用数 + needsReview 徽章数据源)。 */
|
|
316
|
+
export function summarizeConfirmItems(items: readonly SyncConfirmItem[]): SyncConfirmSummary {
|
|
317
|
+
let info = 0;
|
|
318
|
+
let warning = 0;
|
|
319
|
+
let error = 0;
|
|
320
|
+
let adopted = 0;
|
|
321
|
+
let needsReview = false;
|
|
322
|
+
for (const it of items) {
|
|
323
|
+
if (it.severity === 'error') error += 1;
|
|
324
|
+
else if (it.severity === 'warning') warning += 1;
|
|
325
|
+
else info += 1;
|
|
326
|
+
if (it.adopt) adopted += 1;
|
|
327
|
+
if (CONFIRM_REVIEW_KINDS.has(it.kind)) needsReview = true;
|
|
328
|
+
}
|
|
329
|
+
return { total: items.length, info, warning, error, adopted, needsReview };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* 收集用户逐项决策 → apply-items 请求体 adoptions[]。
|
|
334
|
+
* 仅包含 adopt=true 的项;Conflict 项 adopt=true 且未给 resolution → 抛错(强制先解决)。
|
|
335
|
+
*/
|
|
336
|
+
export function buildAdoptions(
|
|
337
|
+
items: readonly SyncConfirmItem[],
|
|
338
|
+
adopted: ReadonlyMap<string, boolean>,
|
|
339
|
+
resolutions: ReadonlyMap<string, 'useRemote' | 'keepLocal' | 'skip'>,
|
|
340
|
+
): SyncItemAdoption[] {
|
|
341
|
+
const out: SyncItemAdoption[] = [];
|
|
342
|
+
for (const it of items) {
|
|
343
|
+
if (adopted.get(it.itemId) !== true) continue; // adopt=false / 未列出 → 跳过
|
|
344
|
+
const adoption: SyncItemAdoption = { itemId: it.itemId, adopt: true };
|
|
345
|
+
if (it.kind === 'Conflict') {
|
|
346
|
+
const resolution = resolutions.get(it.itemId);
|
|
347
|
+
if (resolution === undefined) {
|
|
348
|
+
throw new Error(`冲突项 ${it.itemId} 必须先选择解决方式(用本地 / 用远端 / 跳过)`);
|
|
349
|
+
}
|
|
350
|
+
if (resolution !== 'skip') adoption.resolution = resolution;
|
|
351
|
+
}
|
|
352
|
+
out.push(adoption);
|
|
353
|
+
}
|
|
354
|
+
return out;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export type ApplyItemsViewKind = 'ok' | 'failed' | 'rolledBack';
|
|
358
|
+
|
|
359
|
+
export interface ApplyItemsView {
|
|
360
|
+
kind: ApplyItemsViewKind;
|
|
361
|
+
headline: string;
|
|
362
|
+
sections: string[];
|
|
363
|
+
warnings: string[];
|
|
364
|
+
restoreId: string;
|
|
365
|
+
needsRestart: boolean;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** apply-items 执行结果 → 渲染模型(ok / failed / 整体回滚)。 */
|
|
369
|
+
export function applyItemsReportView(
|
|
370
|
+
report: ApplyItemsResponse | null,
|
|
371
|
+
t: UiT = zhUiT,
|
|
372
|
+
): ApplyItemsView | null {
|
|
373
|
+
if (report === null) return null;
|
|
374
|
+
const failedOnly = report.failed.length > 0 && !report.ok;
|
|
375
|
+
const kind: ApplyItemsViewKind = !report.ok && report.rolledBack ? 'rolledBack' : failedOnly ? 'failed' : 'ok';
|
|
376
|
+
const headline = kind === 'ok'
|
|
377
|
+
? t('sync.importDone', { n: String(report.applied.length) })
|
|
378
|
+
: kind === 'rolledBack'
|
|
379
|
+
? t('sync.importFailed')
|
|
380
|
+
: t('sync.importFailed');
|
|
381
|
+
return {
|
|
382
|
+
kind,
|
|
383
|
+
headline,
|
|
384
|
+
sections: report.applied,
|
|
385
|
+
warnings: report.warnings,
|
|
386
|
+
restoreId: report.restoreId,
|
|
387
|
+
needsRestart: report.needsRestart,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/* ---------------------------------------------------------------- 自动同步(方案 A) */
|
|
392
|
+
|
|
393
|
+
/** AutosyncInterval → ms。 */
|
|
394
|
+
export function autosyncIntervalMs(interval: AutosyncInterval): number {
|
|
395
|
+
switch (interval) {
|
|
396
|
+
case '5m': return 5 * 60 * 1000;
|
|
397
|
+
case '15m': return 15 * 60 * 1000;
|
|
398
|
+
case '60m': return 60 * 60 * 1000;
|
|
399
|
+
case '6h': return 6 * 60 * 60 * 1000;
|
|
400
|
+
case '12h': return 12 * 60 * 60 * 1000;
|
|
401
|
+
case '24h': return 24 * 60 * 60 * 1000;
|
|
402
|
+
default: return 30 * 60 * 1000;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** 距下次自动同步剩余 ms(已到期 → 0)。elapsedMs 为 host 计算的「距上次执行已过 ms」。 */
|
|
407
|
+
export function computeAutosyncCountdown(elapsedMs: number, intervalMs: number): number {
|
|
408
|
+
if (elapsedMs < 0) return -1; // 从未运行
|
|
409
|
+
return Math.max(0, intervalMs - elapsedMs);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** 间隔 ms → 可读时长(如「30 分钟」「6 小时」)。 */
|
|
413
|
+
export function formatIntervalDuration(ms: number, t: UiT = zhUiT): string {
|
|
414
|
+
const minutes = Math.round(ms / 60000);
|
|
415
|
+
if (minutes < 60) return t('sync.interval.30m');
|
|
416
|
+
const hours = Math.round(minutes / 60);
|
|
417
|
+
if (hours === 6) return t('sync.interval.6h');
|
|
418
|
+
if (hours === 12) return t('sync.interval.12h');
|
|
419
|
+
if (hours === 24) return t('sync.interval.24h');
|
|
420
|
+
return `${hours}h`;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** 自动同步状态行的可读文案(未运行 / 上次状态 / 连续失败计数)。 */
|
|
424
|
+
export function autosyncStatusText(status: AutosyncStatusResponse, t: UiT = zhUiT): string {
|
|
425
|
+
if (status.lastRunAt === undefined || status.lastRunAt === '' || status.lastRunStatus === undefined) {
|
|
426
|
+
return t('sync.autosyncNever');
|
|
427
|
+
}
|
|
428
|
+
const statusText = status.lastRunStatus === 'success'
|
|
429
|
+
? t('sync.autosyncSuccess')
|
|
430
|
+
: status.lastRunStatus === 'skipped'
|
|
431
|
+
? t('sync.autosyncSkipped')
|
|
432
|
+
: status.lastRunStatus === 'partial'
|
|
433
|
+
? t('sync.autosyncPartial')
|
|
434
|
+
: t('sync.autosyncFailed');
|
|
435
|
+
const time = formatDateTime(status.lastRunAt);
|
|
436
|
+
const base = t('sync.autosyncLastRun', { time });
|
|
437
|
+
const fail = status.consecutiveFailures > 0 ? ` · ${t('sync.autosyncFailCount', { n: String(status.consecutiveFailures) })}` : '';
|
|
438
|
+
return `${statusText} · ${base}${fail}`;
|
|
439
|
+
}
|
package/src/core/run-registry.ts
CHANGED
|
@@ -21,8 +21,8 @@ import { randomBytes } from 'node:crypto'
|
|
|
21
21
|
import { zhMsg } from './messages.ts'
|
|
22
22
|
import type { MsgFunc } from './messages.ts'
|
|
23
23
|
|
|
24
|
-
/** run 类型:导出 /
|
|
25
|
-
export type RunKind = 'export' | 'import'
|
|
24
|
+
/** run 类型:导出 / 导入 / 自动同步 / 一键同步逐项应用。 */
|
|
25
|
+
export type RunKind = 'export' | 'import' | 'autosync' | 'sync-apply'
|
|
26
26
|
|
|
27
27
|
/** run 状态:进行中 / 完成 / 失败。 */
|
|
28
28
|
export type RunStatus = 'running' | 'done' | 'failed'
|
|
@@ -59,7 +59,11 @@ export class RunConflictError extends Error {
|
|
|
59
59
|
super(
|
|
60
60
|
kind === 'export'
|
|
61
61
|
? msg('run.conflict.export', { runId })
|
|
62
|
-
:
|
|
62
|
+
: kind === 'autosync'
|
|
63
|
+
? '自动同步已在进行中'
|
|
64
|
+
: kind === 'sync-apply'
|
|
65
|
+
? '一键同步已在进行中'
|
|
66
|
+
: msg('run.conflict.import', { runId }),
|
|
63
67
|
)
|
|
64
68
|
this.name = 'RunConflictError'
|
|
65
69
|
this.runId = runId
|