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
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SyncSessionStore 测试:内存登记临时 ZIP + ImportPlan,TTL 30 分钟,惰性清理,同 id 覆盖。
|
|
3
|
+
*/
|
|
4
|
+
import test from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
|
|
8
|
+
import { SyncSessionStore } from './sync-session.ts';
|
|
9
|
+
import type { ImportPlan } from '../core/types.ts';
|
|
10
|
+
import type { SectionId } from '../schema/types.ts';
|
|
11
|
+
|
|
12
|
+
const DEFAULT_TTL_MS = 30 * 60 * 1000;
|
|
13
|
+
|
|
14
|
+
function makePlan(seed: string): ImportPlan {
|
|
15
|
+
return {
|
|
16
|
+
items: [{ id: `item-${seed}`, kind: 'Update', adapter: 'settings', description: `desc-${seed}`, severity: 'info', target: { adapter: 'settings', ref: 'general' } }],
|
|
17
|
+
globalStrategy: 'merge',
|
|
18
|
+
pathMappings: [],
|
|
19
|
+
missingSecrets: [],
|
|
20
|
+
needsRestart: false,
|
|
21
|
+
estimatedActions: { settings: 1 } as unknown as Record<SectionId, number>,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function makeAnalysis(seed: string): { valid: boolean; errors: string[]; warnings: string[]; compatibility: 'excellent' | 'good' | 'partial' | 'unsupported'; sectionsInZip: SectionId[]; pluginSummary: { installed: number; toInstall: number }; pathIssues: []; secretCount: number; dependencyIssues: [] } {
|
|
26
|
+
return {
|
|
27
|
+
valid: true,
|
|
28
|
+
errors: [],
|
|
29
|
+
warnings: [],
|
|
30
|
+
compatibility: 'excellent',
|
|
31
|
+
sectionsInZip: ['settings'] as SectionId[],
|
|
32
|
+
pluginSummary: { installed: 0, toInstall: 0 },
|
|
33
|
+
pathIssues: [],
|
|
34
|
+
secretCount: 0,
|
|
35
|
+
dependencyIssues: [],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function makeSession(store: SyncSessionStore, opts: { seed: string; nowMs?: number }): string {
|
|
40
|
+
const now = opts.nowMs ?? Date.now();
|
|
41
|
+
const id = crypto.randomUUID();
|
|
42
|
+
store.set({
|
|
43
|
+
id,
|
|
44
|
+
zipPath: `/tmp/session-${opts.seed}.zip`,
|
|
45
|
+
plan: makePlan(opts.seed),
|
|
46
|
+
analysis: makeAnalysis(opts.seed),
|
|
47
|
+
snapshotId: `snap-${opts.seed}`,
|
|
48
|
+
repoUrl: 'https://github.com/foo/bar.git',
|
|
49
|
+
createdAt: now,
|
|
50
|
+
expiresAt: now + DEFAULT_TTL_MS,
|
|
51
|
+
});
|
|
52
|
+
return id;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
test('SyncSessionStore: set/get 往返;同 id 覆盖', () => {
|
|
56
|
+
const store = new SyncSessionStore({ now: () => 5_000_000 });
|
|
57
|
+
const id = makeSession(store, { seed: 'a', nowMs: 5_000_000 });
|
|
58
|
+
const s1 = store.get(id);
|
|
59
|
+
assert.ok(s1, 'get 应返回 session');
|
|
60
|
+
assert.equal(s1!.zipPath, '/tmp/session-a.zip');
|
|
61
|
+
assert.equal(s1!.plan.items[0]!.id, 'item-a');
|
|
62
|
+
assert.equal(s1!.snapshotId, 'snap-a');
|
|
63
|
+
|
|
64
|
+
// 同 id 覆盖(仍在有效期内)
|
|
65
|
+
store.set({
|
|
66
|
+
id,
|
|
67
|
+
zipPath: '/tmp/session-b.zip',
|
|
68
|
+
plan: makePlan('b'),
|
|
69
|
+
analysis: makeAnalysis('b'),
|
|
70
|
+
snapshotId: 'snap-b',
|
|
71
|
+
repoUrl: 'https://github.com/foo/bar.git',
|
|
72
|
+
createdAt: 5_000_000,
|
|
73
|
+
expiresAt: 5_000_000 + DEFAULT_TTL_MS,
|
|
74
|
+
});
|
|
75
|
+
const s2 = store.get(id);
|
|
76
|
+
assert.equal(s2!.zipPath, '/tmp/session-b.zip');
|
|
77
|
+
assert.equal(s2!.plan.items[0]!.id, 'item-b');
|
|
78
|
+
assert.equal(s2!.snapshotId, 'snap-b');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('SyncSessionStore: get 过期条目 → undefined(惰性清理)', () => {
|
|
82
|
+
const store = new SyncSessionStore();
|
|
83
|
+
const now = 1_000_000;
|
|
84
|
+
const id = crypto.randomUUID();
|
|
85
|
+
store.set({
|
|
86
|
+
id,
|
|
87
|
+
zipPath: '/tmp/expired.zip',
|
|
88
|
+
plan: makePlan('x'),
|
|
89
|
+
analysis: makeAnalysis('x'),
|
|
90
|
+
snapshotId: 'snap-x',
|
|
91
|
+
repoUrl: 'https://github.com/foo/bar.git',
|
|
92
|
+
createdAt: now - DEFAULT_TTL_MS - 1,
|
|
93
|
+
expiresAt: now - 1,
|
|
94
|
+
});
|
|
95
|
+
const s = store.get(id);
|
|
96
|
+
assert.equal(s, undefined, '过期条目应返回 undefined');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('SyncSessionStore: delete 移除条目', () => {
|
|
100
|
+
const store = new SyncSessionStore({ now: () => 5_000_000 });
|
|
101
|
+
const id = makeSession(store, { seed: 'del', nowMs: 5_000_000 });
|
|
102
|
+
assert.ok(store.get(id), 'set 后存在');
|
|
103
|
+
store.delete(id);
|
|
104
|
+
assert.equal(store.get(id), undefined, 'delete 后不存在');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('SyncSessionStore: TTL 边界——过期临界点', () => {
|
|
108
|
+
const store = new SyncSessionStore({ now: () => 2_000_000 });
|
|
109
|
+
const now = 2_000_000;
|
|
110
|
+
const id = crypto.randomUUID();
|
|
111
|
+
// 恰好在 expiresAt(now === expiresAt)→ 视为过期(惰性清理)
|
|
112
|
+
store.set({
|
|
113
|
+
id,
|
|
114
|
+
zipPath: '/tmp/boundary.zip',
|
|
115
|
+
plan: makePlan('boundary'),
|
|
116
|
+
analysis: makeAnalysis('boundary'),
|
|
117
|
+
snapshotId: 'snap-b',
|
|
118
|
+
repoUrl: 'https://github.com/foo/bar.git',
|
|
119
|
+
createdAt: now - DEFAULT_TTL_MS,
|
|
120
|
+
expiresAt: now,
|
|
121
|
+
});
|
|
122
|
+
assert.equal(store.get(id), undefined, 'expiresAt === now 视为过期');
|
|
123
|
+
|
|
124
|
+
// 还差 1ms → 仍有效
|
|
125
|
+
const id2 = crypto.randomUUID();
|
|
126
|
+
store.set({
|
|
127
|
+
id: id2,
|
|
128
|
+
zipPath: '/tmp/boundary2.zip',
|
|
129
|
+
plan: makePlan('boundary2'),
|
|
130
|
+
analysis: makeAnalysis('boundary2'),
|
|
131
|
+
snapshotId: 'snap-b2',
|
|
132
|
+
repoUrl: 'https://github.com/foo/bar.git',
|
|
133
|
+
createdAt: now - DEFAULT_TTL_MS,
|
|
134
|
+
expiresAt: now + 1,
|
|
135
|
+
});
|
|
136
|
+
assert.ok(store.get(id2), 'expiresAt > now 仍应有效');
|
|
137
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SyncSessionStore:一键同步差异确认会话的内存登记。
|
|
3
|
+
*
|
|
4
|
+
* 会话把「拉取预览」与「逐项执行导入」解耦:一次 /sync/sync 在内存持有一份
|
|
5
|
+
* 临时 ZIP 路径 + ImportPlan + ImportAnalysis,配一个 syncSessionId;
|
|
6
|
+
* /sync/apply-items 凭 id 复用这些数据,避免重复拉取/重复 build ZIP。
|
|
7
|
+
*
|
|
8
|
+
* 生命周期:
|
|
9
|
+
* - 进程内存(不落盘),默认 TTL 30 分钟,过期条目惰性清理(get 时检查);
|
|
10
|
+
* - 同 id 覆盖(set 后旧 session 被新 session 替换,旧 ZIP 由上层负责清理);
|
|
11
|
+
* - delete 显式移除(/sync/cancel 与 apply-items 消费后调用)。
|
|
12
|
+
*/
|
|
13
|
+
import crypto from 'node:crypto';
|
|
14
|
+
import type { ImportAnalysis, ImportPlan } from '../core/types.ts';
|
|
15
|
+
|
|
16
|
+
/** 默认会话 TTL(30 分钟) */
|
|
17
|
+
export const DEFAULT_SESSION_TTL_MS = 30 * 60 * 1000;
|
|
18
|
+
|
|
19
|
+
/** 一次差异确认会话的完整数据 */
|
|
20
|
+
export interface SyncSession {
|
|
21
|
+
id: string;
|
|
22
|
+
/** 临时标准 ZIP(apply-items 执行 executeImportPlan 需要) */
|
|
23
|
+
zipPath: string;
|
|
24
|
+
plan: ImportPlan;
|
|
25
|
+
analysis: ImportAnalysis;
|
|
26
|
+
/** 被拉取的远端快照 id */
|
|
27
|
+
snapshotId: string;
|
|
28
|
+
repoUrl: string;
|
|
29
|
+
gitBin?: string;
|
|
30
|
+
/** 创建时间(epoch ms) */
|
|
31
|
+
createdAt: number;
|
|
32
|
+
/** 过期时间(epoch ms;过期条目惰性清理) */
|
|
33
|
+
expiresAt: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 内存会话存储;同 id 覆盖;过期条目视为不存在。 */
|
|
37
|
+
export class SyncSessionStore {
|
|
38
|
+
private readonly sessions = new Map<string, SyncSession>();
|
|
39
|
+
private readonly ttlMs: number;
|
|
40
|
+
private readonly now: () => number;
|
|
41
|
+
|
|
42
|
+
constructor(opts: { ttlMs?: number; now?: () => number } = {}) {
|
|
43
|
+
this.ttlMs = opts.ttlMs ?? DEFAULT_SESSION_TTL_MS;
|
|
44
|
+
this.now = opts.now ?? (() => Date.now());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 登记(或覆盖)会话;id 缺省时自动生成。createdAt/expiresAt 缺省自动填充。返回会话 id。 */
|
|
48
|
+
set(session: Omit<SyncSession, 'id' | 'createdAt' | 'expiresAt'> & { id?: string; createdAt?: number; expiresAt?: number }): string {
|
|
49
|
+
const id = session.id ?? `sync-session-${crypto.randomUUID()}`;
|
|
50
|
+
const createdAt = session.createdAt ?? this.now();
|
|
51
|
+
const expiresAt = session.expiresAt ?? createdAt + this.ttlMs;
|
|
52
|
+
this.sessions.set(id, {
|
|
53
|
+
...session,
|
|
54
|
+
id,
|
|
55
|
+
createdAt,
|
|
56
|
+
expiresAt,
|
|
57
|
+
});
|
|
58
|
+
return id;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 读取会话;过期条目返回 undefined(惰性清理)。 */
|
|
62
|
+
get(id: string): SyncSession | undefined {
|
|
63
|
+
const s = this.sessions.get(id);
|
|
64
|
+
if (s === undefined) return undefined;
|
|
65
|
+
if (this.now() >= s.expiresAt) {
|
|
66
|
+
this.sessions.delete(id);
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
return { ...s };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 删除会话(cancel / apply-items 消费后调用)。 */
|
|
73
|
+
delete(id: string): void {
|
|
74
|
+
this.sessions.delete(id);
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/ui/i18n.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
2
|
* 客户端展示层(src/ui/*、src/client/common/*)的渲染文案目录(zh 源语言 / en 镜像)。
|
|
3
3
|
*
|
|
4
4
|
* 与 core 的 messages.ts 分工:本目录负责「客户端纯渲染层」(ReportView / ErrorBanner /
|
|
@@ -158,6 +158,72 @@ export const uiZh = {
|
|
|
158
158
|
'sync.pullOk': '远端快照 {id} 差异预览:共 {count} 项变更',
|
|
159
159
|
'sync.pullEmpty': '远端快照与本地一致(无变更)',
|
|
160
160
|
'sync.previewHint': '以上为只读差异预览,不会执行导入。v1 暂不提供一键导入接线;如需应用远端配置,请使用「导入恢复」向导手动导入导出的备份。',
|
|
161
|
+
// 一键同步(方案 A:拉取 → 逐项确认 → apply-items)
|
|
162
|
+
'sync.syncTitle': '一键同步',
|
|
163
|
+
'sync.syncButton': '一键同步',
|
|
164
|
+
'sync.syncing': '正在同步…',
|
|
165
|
+
'sync.syncHeadline': '远端快照 {id} 差异确认:共 {count} 项',
|
|
166
|
+
'sync.syncEmpty': '远端快照与本地一致(无变更)',
|
|
167
|
+
'sync.syncFailed': '同步失败',
|
|
168
|
+
'sync.confirmAdopt': '采用远端',
|
|
169
|
+
'sync.confirmSkip': '跳过',
|
|
170
|
+
'sync.confirmRequired': '需要人工决策',
|
|
171
|
+
'sync.needsReviewBadge': '需人工决策',
|
|
172
|
+
'sync.selectSnapshot': '选择历史快照',
|
|
173
|
+
'sync.latestSnapshot': '最新快照',
|
|
174
|
+
'sync.noSnapshots': '远端暂无快照',
|
|
175
|
+
'sync.confirmImport': '确认导入',
|
|
176
|
+
'sync.cancelConfirm': '取消',
|
|
177
|
+
'sync.conflictResolve': '冲突解决',
|
|
178
|
+
'sync.conflictUseLocal': '用本地',
|
|
179
|
+
'sync.conflictUseRemote': '用远端',
|
|
180
|
+
'sync.conflictSkip': '跳过',
|
|
181
|
+
'sync.conflictResolvedLocal': '已选:本地',
|
|
182
|
+
'sync.conflictResolvedRemote': '已选:远端',
|
|
183
|
+
'sync.conflictResolvedSkip': '已选:跳过',
|
|
184
|
+
'sync.importDone': '已导入 {n} 个分区',
|
|
185
|
+
'sync.importSkipped': '已跳过 {n} 项',
|
|
186
|
+
'sync.importFailed': '导入失败(已整体回滚)',
|
|
187
|
+
'sync.rollbackButton': '回滚到应用前',
|
|
188
|
+
'sync.rollingBack': '正在回滚…',
|
|
189
|
+
'sync.rollbackDone': '已回滚到应用前',
|
|
190
|
+
'sync.appliedSections': '已写入',
|
|
191
|
+
'sync.importWarnings': '导入告警',
|
|
192
|
+
// 自动同步(方案 A:宿主后台调度)
|
|
193
|
+
'sync.autosyncTitle': '自动同步',
|
|
194
|
+
'sync.autosyncDesc': '开启后,DSH 将按固定间隔自动执行双向同步(拉取合并 + 上传);DSH 启动时还会触发一次「自动下载合并(不上传)」以保持本地为最新。仅在无冲突且无需人工干预时才自动写入。',
|
|
195
|
+
'sync.autosyncEnabled': '启用自动同步',
|
|
196
|
+
'sync.autosyncInterval': '同步间隔',
|
|
197
|
+
'sync.autosyncIntervalHint': 'DSH 启动时自动下载合并不上传。',
|
|
198
|
+
'sync.autosyncNever': '从未运行',
|
|
199
|
+
'sync.autosyncLastRun': '上次运行:{time}',
|
|
200
|
+
'sync.autosyncNextIn': '下次约 {time} 后',
|
|
201
|
+
'sync.autosyncSuccess': '成功',
|
|
202
|
+
'sync.autosyncSkipped': '已跳过',
|
|
203
|
+
'sync.autosyncFailed': '失败',
|
|
204
|
+
'sync.autosyncPartial': '部分成功',
|
|
205
|
+
'sync.autosyncFailCount': '连续失败 {n} 次',
|
|
206
|
+
'sync.autosyncSyncing': '自动同步进行中…',
|
|
207
|
+
'sync.interval.5m': '5 分钟',
|
|
208
|
+
'sync.interval.15m': '15 分钟',
|
|
209
|
+
'sync.interval.30m': '30 分钟',
|
|
210
|
+
'sync.interval.60m': '60 分钟',
|
|
211
|
+
'sync.interval.6h': '6 小时',
|
|
212
|
+
'sync.interval.12h': '12 小时',
|
|
213
|
+
'sync.interval.24h': '24 小时',
|
|
214
|
+
// 自动同步历史(kind=autosync 行渲染)
|
|
215
|
+
'sync.hist.autosync': '自动同步',
|
|
216
|
+
'sync.hist.directionPull': '下载',
|
|
217
|
+
'sync.hist.directionPush': '上传',
|
|
218
|
+
'sync.hist.directionBoth': '双向',
|
|
219
|
+
'sync.hist.skipReasonConflict': '冲突项被跳过',
|
|
220
|
+
'sync.hist.skipReasonNoRemote': '远端无快照',
|
|
221
|
+
'sync.hist.skipReasonNotConfigured': '未配置仓库',
|
|
222
|
+
'sync.hist.skipReasonNetwork': '网络问题',
|
|
223
|
+
'sync.hist.conflictedSections': '跳过冲突分区:{sections}',
|
|
224
|
+
'sync.hist.appliedSections': '应用分区:{sections}',
|
|
225
|
+
'sync.hist.failedError': '错误:{error}',
|
|
226
|
+
'sync.hist.notifiedAt': '已通知',
|
|
161
227
|
// GitHub OAuth 区块
|
|
162
228
|
'sync.github.starting': '正在发起 GitHub 授权…',
|
|
163
229
|
'sync.github.waitingNoCode': '请在浏览器中打开授权页面并完成授权…',
|
|
@@ -318,7 +384,70 @@ export const uiEn: Record<UiTextKey, string> = {
|
|
|
318
384
|
'sync.pullOk': 'Remote snapshot {id} diff preview: {count} change(s)',
|
|
319
385
|
'sync.pullEmpty': 'Remote snapshot matches local (no changes)',
|
|
320
386
|
'sync.previewHint': 'Read-only diff preview above; nothing is imported. v1 does not wire one-click import yet — use the Import wizard to apply a downloaded backup if needed.',
|
|
321
|
-
'sync.
|
|
387
|
+
'sync.syncTitle': 'One-Click Sync',
|
|
388
|
+
'sync.syncButton': 'One-click sync',
|
|
389
|
+
'sync.syncing': 'Syncing…',
|
|
390
|
+
'sync.syncHeadline': 'Remote snapshot {id} diff confirm: {count} item(s)',
|
|
391
|
+
'sync.syncEmpty': 'Remote snapshot matches local (no changes)',
|
|
392
|
+
'sync.syncFailed': 'Sync failed',
|
|
393
|
+
'sync.confirmAdopt': 'Adopt remote',
|
|
394
|
+
'sync.confirmSkip': 'Skip',
|
|
395
|
+
'sync.confirmRequired': 'Needs decision',
|
|
396
|
+
'sync.needsReviewBadge': 'Needs decision',
|
|
397
|
+
'sync.selectSnapshot': 'Select snapshot',
|
|
398
|
+
'sync.latestSnapshot': 'Latest snapshot',
|
|
399
|
+
'sync.noSnapshots': 'No remote snapshots',
|
|
400
|
+
'sync.confirmImport': 'Confirm import',
|
|
401
|
+
'sync.cancelConfirm': 'Cancel',
|
|
402
|
+
'sync.conflictResolve': 'Resolve conflict',
|
|
403
|
+
'sync.conflictUseLocal': 'Use local',
|
|
404
|
+
'sync.conflictUseRemote': 'Use remote',
|
|
405
|
+
'sync.conflictSkip': 'Skip',
|
|
406
|
+
'sync.conflictResolvedLocal': 'Chosen: local',
|
|
407
|
+
'sync.conflictResolvedRemote': 'Chosen: remote',
|
|
408
|
+
'sync.conflictResolvedSkip': 'Chosen: skip',
|
|
409
|
+
'sync.importDone': 'Imported {n} section(s)',
|
|
410
|
+
'sync.importSkipped': 'Skipped {n} item(s)',
|
|
411
|
+
'sync.importFailed': 'Import failed (rolled back)',
|
|
412
|
+
'sync.rollbackButton': 'Rollback',
|
|
413
|
+
'sync.rollingBack': 'Rolling back…',
|
|
414
|
+
'sync.rollbackDone': 'Rolled back',
|
|
415
|
+
'sync.appliedSections': 'Written',
|
|
416
|
+
'sync.importWarnings': 'Import warnings',
|
|
417
|
+
'sync.autosyncTitle': 'Auto Sync',
|
|
418
|
+
'sync.autosyncDesc': '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.',
|
|
419
|
+
'sync.autosyncEnabled': 'Enable auto sync',
|
|
420
|
+
'sync.autosyncInterval': 'Sync interval',
|
|
421
|
+
'sync.autosyncIntervalHint': 'Auto download-merge on DSH startup, no upload.',
|
|
422
|
+
'sync.autosyncNever': 'Never run',
|
|
423
|
+
'sync.autosyncLastRun': 'Last run: {time}',
|
|
424
|
+
'sync.autosyncNextIn': 'Next in ~{time}',
|
|
425
|
+
'sync.autosyncSuccess': 'Success',
|
|
426
|
+
'sync.autosyncSkipped': 'Skipped',
|
|
427
|
+
'sync.autosyncFailed': 'Failed',
|
|
428
|
+
'sync.autosyncPartial': 'Partial',
|
|
429
|
+
'sync.autosyncFailCount': '{n} consecutive failure(s)',
|
|
430
|
+
'sync.autosyncSyncing': 'Auto sync running…',
|
|
431
|
+
'sync.interval.5m': '5 min',
|
|
432
|
+
'sync.interval.15m': '15 min',
|
|
433
|
+
'sync.interval.30m': '30 min',
|
|
434
|
+
'sync.interval.60m': '60 min',
|
|
435
|
+
'sync.interval.6h': '6 hr',
|
|
436
|
+
'sync.interval.12h': '12 hr',
|
|
437
|
+
'sync.interval.24h': '24 hr',
|
|
438
|
+
'sync.hist.autosync': 'Auto Sync',
|
|
439
|
+
'sync.hist.directionPull': 'Pull',
|
|
440
|
+
'sync.hist.directionPush': 'Push',
|
|
441
|
+
'sync.hist.directionBoth': 'Both',
|
|
442
|
+
'sync.hist.skipReasonConflict': 'Conflict items skipped',
|
|
443
|
+
'sync.hist.skipReasonNoRemote': 'No remote snapshot',
|
|
444
|
+
'sync.hist.skipReasonNotConfigured': 'No repository configured',
|
|
445
|
+
'sync.hist.skipReasonNetwork': 'Network issue',
|
|
446
|
+
'sync.hist.conflictedSections': 'Skipped conflict sections: {sections}',
|
|
447
|
+
'sync.hist.appliedSections': 'Applied sections: {sections}',
|
|
448
|
+
'sync.hist.failedError': 'Error: {error}',
|
|
449
|
+
'sync.hist.notifiedAt': 'Notified',
|
|
450
|
+
'sync.github.starting': 'Starting GitHub authorization…',
|
|
322
451
|
'sync.github.waitingNoCode': 'Open the authorization page in your browser and complete the flow…',
|
|
323
452
|
'sync.github.waiting': 'Open the authorization page in your browser, enter the one-time code {code} to finish (waiting for confirmation).',
|
|
324
453
|
'sync.github.polling': 'Confirming GitHub authorization…',
|
|
@@ -352,4 +481,4 @@ export function makeUiT(lang: 'zh' | 'en'): UiT {
|
|
|
352
481
|
}
|
|
353
482
|
|
|
354
483
|
/** 缺省 UI 翻译(zh):未注入的渲染路径行为与改造前完全一致。 */
|
|
355
|
-
export const zhUiT: UiT = makeUiT('zh');
|
|
484
|
+
export const zhUiT: UiT = makeUiT('zh');
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* P2b SyncPullPreviewView 纯函数测试:projectReviewItems / describeConflicts / formatDiff。
|
|
3
|
-
* 组件挂载 + 交互通过 React 渲染测试覆盖(node --test 不跑 React 渲染;本文件
|
|
4
|
-
* 覆盖核心数据投影逻辑——这是组件层以下的可测部分)。
|
|
5
|
-
*/
|
|
6
|
-
import test from 'node:test';
|
|
7
|
-
import assert from 'node:assert/strict';
|
|
8
|
-
|
|
9
|
-
import { projectReviewItems, describeConflicts, formatDiff } from './pull-preview-model.ts';
|
|
10
|
-
import type { SyncApplyPlan } from '../../sync/risk.ts';
|
|
11
|
-
import type { MergeSectionResult } from '../../sync/merge.ts';
|
|
12
|
-
import type { SectionId } from '../../schema/types.ts';
|
|
13
|
-
|
|
14
|
-
const rev = (id: SectionId, conflicts: MergeSectionResult['conflicts']): MergeSectionResult => ({
|
|
15
|
-
id, decision: 'conflict', conflicts,
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
test('projectReviewItems:review → ReviewDisplayItem,sectionId 与 description 正确', () => {
|
|
19
|
-
const apply: SyncApplyPlan = {
|
|
20
|
-
autoApply: [],
|
|
21
|
-
review: [rev('settings', [{ path: 'general', kind: 'key' }]), rev('workspaces', [])],
|
|
22
|
-
skipped: [],
|
|
23
|
-
};
|
|
24
|
-
const out = projectReviewItems(apply);
|
|
25
|
-
assert.equal(out.length, 2);
|
|
26
|
-
assert.equal(out[0]!.sectionId, 'settings');
|
|
27
|
-
assert.equal(out[0]!.description, '冲突:general');
|
|
28
|
-
assert.equal(out[0]!.initial, 'pending');
|
|
29
|
-
assert.equal(out[1]!.sectionId, 'workspaces');
|
|
30
|
-
assert.equal(out[1]!.description, '分区 workspaces 待审');
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
test('describeConflicts:空 → 「无变更描述」;多条 → 逗号分隔 + +N', () => {
|
|
34
|
-
assert.equal(describeConflicts([]), '无变更描述');
|
|
35
|
-
assert.equal(describeConflicts([{ path: 'a', kind: 'key' }]), '冲突:a');
|
|
36
|
-
assert.equal(describeConflicts([
|
|
37
|
-
{ path: 'a', kind: 'key' },
|
|
38
|
-
{ path: 'b', kind: 'key' },
|
|
39
|
-
]), '冲突:a, b');
|
|
40
|
-
assert.equal(describeConflicts([
|
|
41
|
-
{ path: 'a', kind: 'key' },
|
|
42
|
-
{ path: 'b', kind: 'key' },
|
|
43
|
-
{ path: 'c', kind: 'key' },
|
|
44
|
-
]), '冲突:a, b +1');
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
test('formatDiff:local==remote → 「(值相同)」;否则显示 JSON 文本', () => {
|
|
48
|
-
assert.equal(formatDiff({ a: 1 }, { a: 1 }), '(值相同)');
|
|
49
|
-
const out = formatDiff({ a: 1 }, { a: 2 });
|
|
50
|
-
assert.ok(out.includes('local:'));
|
|
51
|
-
assert.ok(out.includes('remote:'));
|
|
52
|
-
assert.ok(out.includes('"a":1'));
|
|
53
|
-
assert.ok(out.includes('"a":2'));
|
|
54
|
-
});
|
|
@@ -1,165 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 拉取预览页(M5,P2b):消费 SyncApplyPlan,渲染
|
|
3
|
-
* - 自动已应用列表(autoApplied)—— 绿色状态徽章 + sectionId/description;
|
|
4
|
-
* - 待审变更列表(review)—— 每项三个按钮(用本地 / 用远端 / 跳过)+ 局部差异 diff;
|
|
5
|
-
* - 整体 rollback 后「回滚到应用前」按钮(调用 restoreId);
|
|
6
|
-
* - 全部 review 解决 → 触发 onAllResolved 回调(sync 完成)。
|
|
7
|
-
*
|
|
8
|
-
* 设计:与 SyncSettingsView 同模式 —— 全部渲染模型来自 ./sync-view.ts 纯函数,
|
|
9
|
-
* 组件只做装配 + 状态;测试在 node --test 跑纯函数部分。
|
|
10
|
-
*/
|
|
11
|
-
import { useCallback, useMemo, useState } from 'react'
|
|
12
|
-
import type { ReactNode } from 'react'
|
|
13
|
-
|
|
14
|
-
import type { SyncApplyPlan } from '../../sync/risk.ts'
|
|
15
|
-
import type { MergeConflict, MergeSectionResult } from '../../sync/merge.ts'
|
|
16
|
-
import { Badge, Button, Card, SectionTitle } from '../common/ui.tsx'
|
|
17
|
-
import { resolveItem } from '../../sync/review-queue.ts'
|
|
18
|
-
import type { ReviewQueueItem } from '../../sync/review-queue.ts'
|
|
19
|
-
import { projectReviewItems, describeConflicts, formatDiff } from './pull-preview-model.ts'
|
|
20
|
-
import type { ReviewDisplayItem } from './pull-preview-model.ts'
|
|
21
|
-
|
|
22
|
-
// 纯数据投影逻辑放在 ./pull-preview-model.ts(node --test 可测);本文件仅装配。
|
|
23
|
-
|
|
24
|
-
export interface SyncPullPreviewViewProps {
|
|
25
|
-
/** SyncApplyPlan(autoApply + review + skipped) */
|
|
26
|
-
applyPlan: SyncApplyPlan
|
|
27
|
-
/** stateDir(用于 resolveItem 写回 sync-review-queue.json) */
|
|
28
|
-
stateDir: string
|
|
29
|
-
/** 失败 ApplyReport(提供 restoreId) */
|
|
30
|
-
restoreId?: string
|
|
31
|
-
/** 已应用的 sectionId 列表(applyMergePlan.ok=true 时显示成功绿条) */
|
|
32
|
-
appliedIds?: readonly string[]
|
|
33
|
-
/** 来自 Importer 的 warning 列表(applyMergePlan.warnings) */
|
|
34
|
-
warnings?: readonly string[]
|
|
35
|
-
/** 全部 review 项解决后回调(让上层触发 sync 完成) */
|
|
36
|
-
onAllResolved?: () => void
|
|
37
|
-
/** 调用 rollback.ts 恢复 backup 的 handler(UI 一键回滚入口) */
|
|
38
|
-
onRollback?: (restoreId: string) => Promise<void> | void
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
interface ItemDecisionState {
|
|
42
|
-
/** 'pending' = 未决;其它 = 用户已决策 */
|
|
43
|
-
status: 'pending' | 'useRemote' | 'keepLocal' | 'skip'
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function SyncPullPreviewView(props: SyncPullPreviewViewProps): ReactNode {
|
|
47
|
-
const { applyPlan, stateDir, appliedIds = [], warnings = [], restoreId, onAllResolved, onRollback } = props;
|
|
48
|
-
const items = useMemo(() => projectReviewItems(applyPlan), [applyPlan]);
|
|
49
|
-
const [decisions, setDecisions] = useState<Record<string, ItemDecisionState['status']>>(() => {
|
|
50
|
-
const init: Record<string, ItemDecisionState['status']> = {};
|
|
51
|
-
for (const it of items) init[it.itemId] = it.initial;
|
|
52
|
-
return init;
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
const decide = useCallback(async (itemId: string, decision: 'useRemote' | 'keepLocal' | 'skip') => {
|
|
56
|
-
setDecisions((prev) => ({ ...prev, [itemId]: decision }));
|
|
57
|
-
try {
|
|
58
|
-
await resolveItem(stateDir, itemId, decision);
|
|
59
|
-
} catch (err) {
|
|
60
|
-
// 决策持久化失败:UI 层错误处理——此处仅控制台提示,上层可通过 warnings 透传
|
|
61
|
-
console.warn(`resolveItem(${itemId}) 失败:`, err);
|
|
62
|
-
}
|
|
63
|
-
}, [stateDir]);
|
|
64
|
-
|
|
65
|
-
// 计算剩余未决项
|
|
66
|
-
const pendingIds = useMemo(() => items.filter((i) => decisions[i.itemId] === 'pending').map((i) => i.itemId), [items, decisions]);
|
|
67
|
-
if (pendingIds.length === 0 && items.length > 0 && onAllResolved) {
|
|
68
|
-
// useEffect 替代:此处直接调用——组件是纯展示
|
|
69
|
-
queueMicrotask(() => onAllResolved());
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
return (
|
|
73
|
-
<div className="sync-pull-preview">
|
|
74
|
-
{/* 自动已应用列表 */}
|
|
75
|
-
{appliedIds.length > 0 && (
|
|
76
|
-
<Card>
|
|
77
|
-
<SectionTitle title={`自动已应用(${appliedIds.length})`} />
|
|
78
|
-
<ul>
|
|
79
|
-
{appliedIds.map((sid) => (
|
|
80
|
-
<li key={sid}>
|
|
81
|
-
<Badge kind="ok">✓</Badge> <code>{sid}</code>
|
|
82
|
-
</li>
|
|
83
|
-
))}
|
|
84
|
-
</ul>
|
|
85
|
-
{warnings.length > 0 && (
|
|
86
|
-
<ul className="warnings">
|
|
87
|
-
{warnings.map((w, i) => <li key={i} className="warning">{w}</li>)}
|
|
88
|
-
</ul>
|
|
89
|
-
)}
|
|
90
|
-
{restoreId && onRollback && (
|
|
91
|
-
<Button variant="danger" onClick={() => onRollback(restoreId)}>{`回滚到应用前 (${restoreId})`}</Button>
|
|
92
|
-
)}
|
|
93
|
-
</Card>
|
|
94
|
-
)}
|
|
95
|
-
|
|
96
|
-
{/* 待审列表 */}
|
|
97
|
-
{items.length > 0 && (
|
|
98
|
-
<Card>
|
|
99
|
-
<SectionTitle title={`待审变更(${items.length})`} />
|
|
100
|
-
{items.map((it) => (
|
|
101
|
-
<ReviewItemRow
|
|
102
|
-
key={it.itemId}
|
|
103
|
-
item={it}
|
|
104
|
-
state={decisions[it.itemId] ?? 'pending'}
|
|
105
|
-
onDecide={(d) => decide(it.itemId, d)}
|
|
106
|
-
/>
|
|
107
|
-
))}
|
|
108
|
-
</Card>
|
|
109
|
-
)}
|
|
110
|
-
|
|
111
|
-
{/* 跳过 */}
|
|
112
|
-
{applyPlan.skipped.length > 0 && (
|
|
113
|
-
<Card>
|
|
114
|
-
<SectionTitle title={`无变化(${applyPlan.skipped.length})`} />
|
|
115
|
-
<ul>
|
|
116
|
-
{applyPlan.skipped.map((r) => (
|
|
117
|
-
<li key={r.id}><code>{r.id}</code> —— skip(无变化)</li>
|
|
118
|
-
))}
|
|
119
|
-
</ul>
|
|
120
|
-
</Card>
|
|
121
|
-
)}
|
|
122
|
-
</div>
|
|
123
|
-
);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
interface ReviewItemRowProps {
|
|
127
|
-
item: ReviewDisplayItem
|
|
128
|
-
state: ItemDecisionState['status']
|
|
129
|
-
onDecide: (d: 'useRemote' | 'keepLocal' | 'skip') => void
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function ReviewItemRow({ item, state, onDecide }: ReviewItemRowProps): ReactNode {
|
|
133
|
-
return (
|
|
134
|
-
<div className={`review-item state-${state}`}>
|
|
135
|
-
<div className="review-item-header">
|
|
136
|
-
<code>{item.sectionId}</code> <span>{item.description}</span>
|
|
137
|
-
<Badge kind={state === 'pending' ? 'warn' : 'ok'}>{labelForState(state)}</Badge>
|
|
138
|
-
</div>
|
|
139
|
-
{item.conflicts.length > 0 && (
|
|
140
|
-
<details>
|
|
141
|
-
<summary>差异({item.conflicts.length})</summary>
|
|
142
|
-
<pre>
|
|
143
|
-
{item.conflicts.map((c, i) => (
|
|
144
|
-
<div key={i}>{formatDiff(c.local, c.remote)}</div>
|
|
145
|
-
))}
|
|
146
|
-
</pre>
|
|
147
|
-
</details>
|
|
148
|
-
)}
|
|
149
|
-
<div className="review-item-actions">
|
|
150
|
-
<Button variant="ghost" disabled={state !== 'pending'} onClick={() => onDecide('keepLocal')}>用本地</Button>
|
|
151
|
-
<Button variant="primary" disabled={state !== 'pending'} onClick={() => onDecide('useRemote')}>用远端</Button>
|
|
152
|
-
<Button variant="ghost" disabled={state !== 'pending'} onClick={() => onDecide('skip')}>跳过</Button>
|
|
153
|
-
</div>
|
|
154
|
-
</div>
|
|
155
|
-
);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function labelForState(s: ItemDecisionState['status']): string {
|
|
159
|
-
switch (s) {
|
|
160
|
-
case 'pending': return '待决';
|
|
161
|
-
case 'useRemote': return '已选:远端';
|
|
162
|
-
case 'keepLocal': return '已选:本地';
|
|
163
|
-
case 'skip': return '已选:跳过';
|
|
164
|
-
}
|
|
165
|
-
}
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* m-sync-ui (P2b):SyncPullPreviewView 的纯数据投影逻辑(无副作用、无 React 依赖)。
|
|
3
|
-
* 与 sync-view.ts 同模式:纯函数 → node --test 覆盖;React 组件只做装配。
|
|
4
|
-
*/
|
|
5
|
-
import type { SyncApplyPlan } from '../../sync/risk.ts';
|
|
6
|
-
import type { MergeConflict } from '../../sync/merge.ts';
|
|
7
|
-
|
|
8
|
-
export interface ReviewDisplayItem {
|
|
9
|
-
itemId: string;
|
|
10
|
-
sectionId: string;
|
|
11
|
-
description: string;
|
|
12
|
-
conflicts: MergeConflict[];
|
|
13
|
-
initial: 'pending' | 'useRemote' | 'keepLocal' | 'skip';
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/** 把 SyncApplyPlan.review 拆成 UI 可显示形态(同步函数,无副作用) */
|
|
17
|
-
export function projectReviewItems(applyPlan: SyncApplyPlan): ReviewDisplayItem[] {
|
|
18
|
-
const out: ReviewDisplayItem[] = [];
|
|
19
|
-
for (const r of applyPlan.review) {
|
|
20
|
-
out.push({
|
|
21
|
-
itemId: `${r.id}`,
|
|
22
|
-
sectionId: r.id,
|
|
23
|
-
description: r.conflicts.length > 0 ? describeConflicts(r.conflicts) : `分区 ${r.id} 待审`,
|
|
24
|
-
conflicts: r.conflicts,
|
|
25
|
-
initial: 'pending',
|
|
26
|
-
});
|
|
27
|
-
}
|
|
28
|
-
return out;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** 简短描述 conflicts(最多 2 条;超出显示 +N) */
|
|
32
|
-
export function describeConflicts(conflicts: readonly MergeConflict[]): string {
|
|
33
|
-
if (conflicts.length === 0) return '无变更描述';
|
|
34
|
-
const sample = conflicts.slice(0, 2).map((c) => `${c.path || '$'}`).join(', ');
|
|
35
|
-
return conflicts.length <= 2 ? `冲突:${sample}` : `冲突:${sample} +${conflicts.length - 2}`;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** 渲染差异(local vs remote)的紧凑 JSON 视图(纯字符串) */
|
|
39
|
-
export function formatDiff(local: unknown, remote: unknown): string {
|
|
40
|
-
const L = JSON.stringify(local, null, 0);
|
|
41
|
-
const R = JSON.stringify(remote, null, 0);
|
|
42
|
-
if (L === R) return '(值相同)';
|
|
43
|
-
return `local: ${L}\nremote: ${R}`;
|
|
44
|
-
}
|