dsh-config-manager 0.1.78 → 0.1.80

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 (302) hide show
  1. package/lib/adapters/credentials.d.ts +6 -2
  2. package/lib/adapters/credentials.js +19 -3
  3. package/lib/adapters/file-collection.js +2 -1
  4. package/lib/adapters/mcp.d.ts +48 -2
  5. package/lib/adapters/mcp.js +265 -3
  6. package/lib/adapters/messages.d.ts +3 -0
  7. package/lib/adapters/messages.js +7 -0
  8. package/lib/adapters/plugin-files.js +2 -1
  9. package/lib/adapters/plugins.js +105 -20
  10. package/lib/adapters/pnpm-workspace.d.ts +32 -0
  11. package/lib/adapters/pnpm-workspace.js +113 -2
  12. package/lib/adapters/prompts.d.ts +6 -2
  13. package/lib/adapters/prompts.js +17 -2
  14. package/lib/adapters/providers.d.ts +19 -2
  15. package/lib/adapters/providers.js +59 -3
  16. package/lib/adapters/settings.d.ts +6 -2
  17. package/lib/adapters/settings.js +13 -2
  18. package/lib/adapters/skills.d.ts +25 -1
  19. package/lib/adapters/skills.js +60 -0
  20. package/lib/adapters/ui.d.ts +6 -2
  21. package/lib/adapters/ui.js +13 -2
  22. package/lib/adapters/units.d.ts +18 -0
  23. package/lib/adapters/units.js +39 -0
  24. package/lib/adapters/workspaces.js +2 -1
  25. package/lib/client.d.ts +401 -112
  26. package/lib/client.js +2899 -1525
  27. package/lib/core/backup.js +20 -0
  28. package/lib/core/mcp-connector-store.d.ts +159 -0
  29. package/lib/core/mcp-connector-store.js +445 -0
  30. package/lib/core/messages-core.d.ts +2 -0
  31. package/lib/core/messages-core.js +4 -0
  32. package/lib/core/messages.d.ts +20 -0
  33. package/lib/core/messages.js +44 -0
  34. package/lib/core/phase3-host.js +18 -1
  35. package/lib/core/plugin-cli.d.ts +22 -2
  36. package/lib/core/plugin-cli.js +22 -3
  37. package/lib/core/recovery-orchestrator.d.ts +3 -1
  38. package/lib/core/recovery-orchestrator.js +75 -16
  39. package/lib/core/rollback-activity.d.ts +89 -0
  40. package/lib/core/rollback-activity.js +350 -0
  41. package/lib/core/rollback-ledger.d.ts +0 -9
  42. package/lib/core/rollback-ledger.js +37 -0
  43. package/lib/core/rollback-merge.d.ts +22 -2
  44. package/lib/core/rollback-merge.js +63 -13
  45. package/lib/core/rollback-point.d.ts +93 -0
  46. package/lib/core/rollback-point.js +420 -101
  47. package/lib/core/rollback-undo-journal.d.ts +123 -0
  48. package/lib/core/rollback-undo-journal.js +517 -0
  49. package/lib/core/self-update.js +2 -4
  50. package/lib/core/text-fidelity.d.ts +89 -0
  51. package/lib/core/text-fidelity.js +176 -0
  52. package/lib/core/types.d.ts +12 -3
  53. package/lib/core/update-check.js +2 -4
  54. package/lib/core/validator.d.ts +9 -0
  55. package/lib/core/validator.js +12 -0
  56. package/lib/foreign/bundle.js +42 -3
  57. package/lib/foreign/claude-code.js +358 -26
  58. package/lib/foreign/claude-sessions.d.ts +51 -8
  59. package/lib/foreign/claude-sessions.js +111 -13
  60. package/lib/foreign/read-claude-code.d.ts +33 -4
  61. package/lib/foreign/read-claude-code.js +446 -65
  62. package/lib/foreign/read-claude-plugins.d.ts +36 -0
  63. package/lib/foreign/read-claude-plugins.js +403 -0
  64. package/lib/foreign/registry.js +25 -2
  65. package/lib/foreign/truth-table.js +6 -4
  66. package/lib/foreign/types.d.ts +170 -2
  67. package/lib/index.d.ts +14 -5
  68. package/lib/index.js +60 -16
  69. package/lib/market/git-file-writer.js +7 -1
  70. package/lib/profiles/dsh-profile-launcher.d.ts +20 -0
  71. package/lib/profiles/dsh-profile-launcher.js +58 -1
  72. package/lib/profiles/dsh-profile-shared.d.ts +8 -2
  73. package/lib/profiles/dsh-profile-shared.js +8 -2
  74. package/lib/routes/foreign.js +8 -1
  75. package/lib/routes/index.d.ts +1 -1
  76. package/lib/routes/kit.d.ts +2 -2
  77. package/lib/routes/sync.js +173 -9
  78. package/lib/schema/manifest.d.ts +6 -1
  79. package/lib/schema/manifest.js +55 -0
  80. package/lib/schema/types.d.ts +47 -2
  81. package/lib/sync/ancestor.d.ts +22 -0
  82. package/lib/sync/ancestor.js +66 -2
  83. package/lib/sync/autosync-config.d.ts +73 -0
  84. package/lib/sync/autosync-config.js +89 -0
  85. package/lib/sync/autosync-scheduler.d.ts +80 -6
  86. package/lib/sync/autosync-scheduler.js +263 -40
  87. package/lib/sync/gist/gist-transport.d.ts +18 -1
  88. package/lib/sync/gist/gist-transport.js +42 -3
  89. package/lib/sync/gist/messages.d.ts +2 -0
  90. package/lib/sync/gist/messages.js +5 -0
  91. package/lib/sync/git/git-transport.d.ts +42 -3
  92. package/lib/sync/git/git-transport.js +146 -3
  93. package/lib/sync/merge.d.ts +47 -14
  94. package/lib/sync/merge.js +242 -222
  95. package/lib/sync/messages.d.ts +8 -0
  96. package/lib/sync/messages.js +19 -0
  97. package/lib/sync/risk.d.ts +19 -2
  98. package/lib/sync/risk.js +10 -3
  99. package/lib/sync/s3/messages.d.ts +6 -0
  100. package/lib/sync/s3/messages.js +13 -0
  101. package/lib/sync/s3/s3-transport.d.ts +9 -1
  102. package/lib/sync/s3/s3-transport.js +78 -6
  103. package/lib/sync/s3/sigv4.js +11 -2
  104. package/lib/sync/sync-engine.d.ts +212 -8
  105. package/lib/sync/sync-engine.js +514 -58
  106. package/lib/sync/sync-history.d.ts +11 -0
  107. package/lib/sync/sync-history.js +20 -0
  108. package/lib/sync/sync-state.d.ts +43 -0
  109. package/lib/sync/sync-state.js +44 -0
  110. package/lib/sync/sync-watcher.d.ts +76 -0
  111. package/lib/sync/sync-watcher.js +202 -0
  112. package/lib/sync/text-merge.d.ts +85 -0
  113. package/lib/sync/text-merge.js +513 -0
  114. package/lib/sync/transport.d.ts +48 -0
  115. package/lib/sync/transport.js +12 -0
  116. package/lib/sync/watch-scope.d.ts +38 -0
  117. package/lib/sync/watch-scope.js +95 -0
  118. package/lib/sync/webdav/webdav-transport.d.ts +16 -2
  119. package/lib/sync/webdav/webdav-transport.js +128 -11
  120. package/lib/ui/foreign-view.js +10 -0
  121. package/lib/ui/i18n.d.ts +9 -0
  122. package/lib/ui/i18n.js +22 -0
  123. package/lib/ui/rollback-view.d.ts +12 -0
  124. package/lib/ui/rollback-view.js +2 -0
  125. package/lib/ui/sync-settings-view.d.ts +47 -0
  126. package/lib/ui/sync-settings-view.js +24 -0
  127. package/lib/ui/types.d.ts +21 -0
  128. package/lib/utils/env-lock.d.ts +1 -0
  129. package/lib/utils/env-lock.js +7 -4
  130. package/package.json +6 -6
  131. package/src/adapters/credentials.ts +22 -4
  132. package/src/adapters/file-collection.ts +2 -1
  133. package/src/adapters/mcp.test.ts +235 -1
  134. package/src/adapters/mcp.ts +276 -5
  135. package/src/adapters/messages.ts +7 -0
  136. package/src/adapters/plugin-files.ts +2 -1
  137. package/src/adapters/plugins.test.ts +176 -2
  138. package/src/adapters/plugins.ts +107 -21
  139. package/src/adapters/pnpm-workspace.test.ts +89 -1
  140. package/src/adapters/pnpm-workspace.ts +127 -2
  141. package/src/adapters/prompts.ts +20 -3
  142. package/src/adapters/providers.ts +62 -4
  143. package/src/adapters/settings.ts +16 -3
  144. package/src/adapters/skills.test.ts +141 -0
  145. package/src/adapters/skills.ts +58 -2
  146. package/src/adapters/ui.ts +16 -3
  147. package/src/adapters/units-entries.test.ts +190 -0
  148. package/src/adapters/units.ts +50 -0
  149. package/src/adapters/workspaces.ts +2 -1
  150. package/src/client/about/PluginUpdateDialog.tsx +4 -2
  151. package/src/client/about/ReleaseNotesDialog.tsx +4 -2
  152. package/src/client/chat/RollbackReviewDialog.tsx +3 -2
  153. package/src/client/common/Skeleton.tsx +32 -0
  154. package/src/client/common/api-base.test.ts +36 -0
  155. package/src/client/common/http.test.ts +12 -1
  156. package/src/client/common/http.ts +8 -2
  157. package/src/client/common/routes.ts +46 -3
  158. package/src/client/config-manager.module.css +321 -11
  159. package/src/client/home/HomePanel.tsx +14 -6
  160. package/src/client/import/ForeignImportView.tsx +3 -1
  161. package/src/client/import/import-wizard-steps.tsx +2 -1
  162. package/src/client/library/ArtifactRow.tsx +12 -2
  163. package/src/client/library/LibraryActions.tsx +6 -3
  164. package/src/client/library/LibraryPanel.tsx +21 -3
  165. package/src/client/library/RestorePlanView.tsx +4 -2
  166. package/src/client/locales.ts +3 -0
  167. package/src/client/market/market-api.test.ts +3 -2
  168. package/src/client/recovery/RecoveryPanel.tsx +54 -7
  169. package/src/client/recovery/RollbackCard.tsx +7 -2
  170. package/src/client/recovery/RollbackDiffPane.tsx +3 -2
  171. package/src/client/recovery/recovery-locales.ts +20 -2
  172. package/src/client/recovery/recovery-view.test.ts +39 -1
  173. package/src/client/recovery/recovery-view.ts +23 -1
  174. package/src/client/run-store.test.ts +1 -1
  175. package/src/client/run-store.ts +4 -0
  176. package/src/client/sync/AutosyncCard.tsx +49 -6
  177. package/src/client/sync/MergeModal.tsx +220 -0
  178. package/src/client/sync/MergePreviewView.tsx +306 -0
  179. package/src/client/sync/SyncActivityBar.tsx +31 -0
  180. package/src/client/sync/SyncConfirmView.tsx +2 -1
  181. package/src/client/sync/SyncPanel.tsx +130 -87
  182. package/src/client/sync/SyncProbeRow.tsx +162 -0
  183. package/src/client/sync/SyncSettingsView.test.ts +27 -2
  184. package/src/client/sync/WorkDirCard.tsx +44 -6
  185. package/src/client/sync/history-model.ts +4 -1
  186. package/src/client/sync/merge-view.test.ts +248 -0
  187. package/src/client/sync/merge-view.ts +398 -0
  188. package/src/client/sync/probe-view.test.ts +90 -0
  189. package/src/client/sync/probe-view.ts +63 -0
  190. package/src/client/sync/remote-device-view.test.ts +35 -0
  191. package/src/client/sync/remote-device-view.ts +25 -0
  192. package/src/client/sync/sync-api.test.ts +5 -3
  193. package/src/client/sync/sync-api.ts +212 -2
  194. package/src/client/sync/sync-entry.test.ts +78 -0
  195. package/src/client/sync/sync-entry.ts +53 -0
  196. package/src/client/sync/sync-locales.ts +234 -2
  197. package/src/client/sync/sync-view.test.ts +18 -1
  198. package/src/client/sync/sync-view.ts +20 -0
  199. package/src/core/backup.test.ts +41 -0
  200. package/src/core/backup.ts +20 -0
  201. package/src/core/mcp-connector-store.test.ts +265 -0
  202. package/src/core/mcp-connector-store.ts +516 -0
  203. package/src/core/messages-core.ts +4 -0
  204. package/src/core/messages.ts +46 -0
  205. package/src/core/phase3-host.ts +18 -1
  206. package/src/core/phase3-production-integration.test.ts +4 -0
  207. package/src/core/plugin-cli.test.ts +26 -0
  208. package/src/core/plugin-cli.ts +30 -4
  209. package/src/core/recovery-orchestrator.test.ts +104 -5
  210. package/src/core/recovery-orchestrator.ts +74 -17
  211. package/src/core/rollback-activity.test.ts +311 -0
  212. package/src/core/rollback-activity.ts +367 -0
  213. package/src/core/rollback-cross-session.test.ts +694 -0
  214. package/src/core/rollback-interrupted.test.ts +137 -0
  215. package/src/core/rollback-ledger.ts +32 -0
  216. package/src/core/rollback-merge.test.ts +130 -0
  217. package/src/core/rollback-merge.ts +78 -14
  218. package/src/core/rollback-point.ts +468 -91
  219. package/src/core/rollback-undo-journal.test.ts +508 -0
  220. package/src/core/rollback-undo-journal.ts +571 -0
  221. package/src/core/self-update.ts +2 -5
  222. package/src/core/text-fidelity.test.ts +189 -0
  223. package/src/core/text-fidelity.ts +197 -0
  224. package/src/core/types.ts +11 -3
  225. package/src/core/update-check.ts +2 -5
  226. package/src/core/validator.ts +14 -0
  227. package/src/foreign/bundle-origin.test.ts +265 -0
  228. package/src/foreign/bundle.ts +45 -5
  229. package/src/foreign/claude-code.test.ts +268 -0
  230. package/src/foreign/claude-code.ts +406 -25
  231. package/src/foreign/claude-plugins.test.ts +234 -0
  232. package/src/foreign/claude-sessions.test.ts +141 -0
  233. package/src/foreign/claude-sessions.ts +179 -23
  234. package/src/foreign/file-budget.test.ts +4 -1
  235. package/src/foreign/read-claude-code.test.ts +320 -0
  236. package/src/foreign/read-claude-code.ts +490 -71
  237. package/src/foreign/read-claude-plugins.ts +465 -0
  238. package/src/foreign/registry.ts +25 -2
  239. package/src/foreign/session-ir.test.ts +65 -0
  240. package/src/foreign/truth-table.test.ts +81 -0
  241. package/src/foreign/truth-table.ts +6 -3
  242. package/src/foreign/types.ts +162 -2
  243. package/src/index.facade.test.ts +26 -4
  244. package/src/index.ts +76 -19
  245. package/src/market/git-file-writer.test.ts +4 -0
  246. package/src/market/git-file-writer.ts +7 -1
  247. package/src/profiles/dsh-profile-launcher.test.ts +68 -3
  248. package/src/profiles/dsh-profile-launcher.ts +56 -1
  249. package/src/profiles/dsh-profile-shared.ts +8 -2
  250. package/src/routes/foreign.ts +8 -1
  251. package/src/routes/index.ts +1 -1
  252. package/src/routes/kit.ts +2 -2
  253. package/src/routes/sync.ts +176 -9
  254. package/src/schema/manifest-schema.test.ts +87 -2
  255. package/src/schema/manifest.ts +57 -1
  256. package/src/schema/types.ts +45 -2
  257. package/src/sync/ancestor.ts +76 -2
  258. package/src/sync/autosync-config.test.ts +73 -1
  259. package/src/sync/autosync-config.ts +124 -0
  260. package/src/sync/autosync-risk-tier.test.ts +38 -2
  261. package/src/sync/autosync-scheduler.test.ts +264 -12
  262. package/src/sync/autosync-scheduler.ts +296 -44
  263. package/src/sync/gist/gist-conditional-write.test.ts +203 -0
  264. package/src/sync/gist/gist-transport.ts +53 -5
  265. package/src/sync/gist/messages.ts +5 -0
  266. package/src/sync/git/git-probe.test.ts +80 -0
  267. package/src/sync/git/git-transport.test.ts +185 -0
  268. package/src/sync/git/git-transport.ts +178 -5
  269. package/src/sync/merge.test.ts +188 -55
  270. package/src/sync/merge.ts +282 -224
  271. package/src/sync/messages.ts +19 -0
  272. package/src/sync/risk.ts +29 -5
  273. package/src/sync/s3/messages.ts +13 -0
  274. package/src/sync/s3/s3-conditional-write.test.ts +330 -0
  275. package/src/sync/s3/s3-transport.test.ts +2 -1
  276. package/src/sync/s3/s3-transport.ts +87 -9
  277. package/src/sync/s3/sigv4.test.ts +4 -1
  278. package/src/sync/s3/sigv4.ts +11 -2
  279. package/src/sync/sync-device-identity.test.ts +267 -0
  280. package/src/sync/sync-engine.test.ts +410 -19
  281. package/src/sync/sync-engine.ts +602 -57
  282. package/src/sync/sync-history.test.ts +16 -1
  283. package/src/sync/sync-history.ts +26 -0
  284. package/src/sync/sync-state.test.ts +64 -0
  285. package/src/sync/sync-state.ts +79 -0
  286. package/src/sync/sync-watcher.test.ts +197 -0
  287. package/src/sync/sync-watcher.ts +246 -0
  288. package/src/sync/text-merge.test.ts +358 -0
  289. package/src/sync/text-merge.ts +554 -0
  290. package/src/sync/transport.ts +57 -0
  291. package/src/sync/watch-scope.test.ts +122 -0
  292. package/src/sync/watch-scope.ts +120 -0
  293. package/src/sync/webdav/webdav-conditional-write.test.ts +360 -0
  294. package/src/sync/webdav/webdav-transport.ts +137 -14
  295. package/src/ui/foreign-view.test.ts +34 -0
  296. package/src/ui/foreign-view.ts +10 -0
  297. package/src/ui/i18n.ts +22 -0
  298. package/src/ui/rollback-view.ts +14 -0
  299. package/src/ui/sync-settings-view.test.ts +28 -1
  300. package/src/ui/sync-settings-view.ts +43 -0
  301. package/src/ui/types.ts +22 -0
  302. package/src/utils/env-lock.ts +8 -5
package/src/sync/merge.ts CHANGED
@@ -2,28 +2,68 @@
2
2
  * m-sync-flow:三方合并引擎(纯函数)。
3
3
  *
4
4
  * 输入:local / remote / ancestor 各分区的 SectionData + 分区 hash。
5
- * 行为(每分区):
6
- * - localHash == remoteHash → skip(无变化)
7
- * - localHash != remoteHash:
8
- * - localHash == ancestorHash → useRemote(远端独有改动)
9
- * - remoteHash == ancestorHash → keepLocal(本地独有改动)
10
- * - 其余 → 进入精细合并:JSON 分区按 top-level key 合并、FilesSection 按 relativePath 合并;
11
- * 仅双侧均改且值不同的键/文件留为 conflict,其余非重叠变更自动合入。
5
+ * 输出:MergePlan(纯数据,不含副作用),由上层(SyncEngine.merge / previewMerge)交给 UI 决策。
12
6
  *
13
- * 输出:MergePlan(纯数据,不含副作用),由上层(SyncEngine.merge)返回给 UI 决策。
7
+ * 每分区的决策链(顺序即优先级,与 git 的三方合并口径一致):
8
+ * 1. localHash == remoteHash → skip(无变化,绝不产生多余写入)
9
+ * 2. 仅一侧存在该分区 → 单边存在:不构成删除语义(见 §边界),按变化与否取舍
10
+ * 3. ancestor 缺失 → conflict(reason='no-ancestor'):拒绝猜测共同祖先
11
+ * 4. localHash == ancestorHash → useRemote(仅远端改)
12
+ * 5. remoteHash == ancestorHash → keepLocal(仅本地改)
13
+ * 6. 双侧相对祖先都改 → 精细合并:
14
+ * - JSON 分区:递归对象合并(mergeJsonValues),冲突路径精确到叶子('namespaces.general.value.theme')
15
+ * - FilesSection:逐 relativePath 合并,两侧都改的文本文件再走行级三方合并(mergeBytesThreeWay)
16
+ * - 无残留冲突 → decision='merge'(merged + autoMerged 计数)
17
+ * - 有残留冲突 → decision='conflict'(merged 为 undefined:绝不把半成品当结果)
14
18
  *
15
- * 安全约束:ancestor 为 undefined 时退化为两方合并:local != remote 视为整分区 conflict(拒绝猜测共同祖先)。
19
+ * 安全约束(改这里前先读):
20
+ * - 数组**绝不**按索引合并:双侧都改且不逐字相等 → 冲突(reason='array')。语义不明的合并比冲突更危险。
21
+ * - 二进制 / 超大文件不做文本合并(mergeBytesThreeWay 会如实拒绝)→ 保留冲突交用户裁决。
22
+ * - 绝不静默丢弃任一侧的未知字段 / 插件配置 / 用户数据:递归合并只对「双侧都改同一处」报冲突,
23
+ * 单侧新增/修改的键一律保留。
24
+ * - ancestor 缺失时退化为两方:差异一律整分区 conflict(拒绝猜测共同祖先)。
25
+ *
26
+ * 边界(有意如此,勿“修复”):分区在某侧**缺失**不等于「该侧删了这个分区」—— 同步只搬本轮
27
+ * 勾选的分区,可选分区(sessions/mcp)未勾选时为缺失。因此分区级缺失不判删除冲突;
28
+ * 文件级(FilesSection 内的 relativePath)才是真正的删除语义,那里按 modify/delete 判冲突。
16
29
  */
17
30
  import type { FilesSection, SectionData, SectionId } from '../schema/types.ts';
18
31
  import { hashSection } from './sync-state.ts';
32
+ import { mergeBytesThreeWay, mergeJsonValues, type TextMergeConflict } from './text-merge.ts';
33
+
34
+ /**
35
+ * 分区级决胜结果(三方合并,2026-10 收口):
36
+ * - 'skip' :双侧相对祖先都未改(或本就无变化)—— 不产生任何写入;
37
+ * - 'useRemote' :仅远端相对祖先改了(本地未改)→ 远端整分区原值胜出;
38
+ * - 'keepLocal' :仅本地相对祖先改了(远端未改)→ 本地整分区原值胜出;
39
+ * - 'merge' :双侧都改,但**精细合并成功**(无残留冲突)→ merged 为合并结果。
40
+ * 'merge' 绝不等于 'useRemote':把自动合并读成「用了远端」会让 UI/日志谎报数据来源。
41
+ * - 'conflict' :双侧都改且无法安全自动合并 → merged 为 undefined,仅 conflicts 有值。
42
+ */
43
+ export type MergeDecision = 'useRemote' | 'keepLocal' | 'merge' | 'skip';
19
44
 
20
- export type MergeDecision = 'useRemote' | 'keepLocal' | 'skip';
45
+ /** 冲突成因(可选字段:旧调用方/旧数据没有它,UI 缺省按 'both-modified' 措辞)。 */
46
+ export type MergeConflictReason =
47
+ | 'both-modified'
48
+ | 'modify-delete'
49
+ | 'delete-modify'
50
+ | 'no-ancestor'
51
+ | 'binary'
52
+ | 'too-large'
53
+ | 'overlap'
54
+ | 'array'
55
+ | 'format'
56
+ | 'section';
21
57
 
22
- /** 分区内精细合并产生的单条冲突(JSON key 或 FilesSection 单文件)。 */
58
+ /** 分区内精细合并产生的单条冲突(JSON 叶子 / FilesSection 单文件)。 */
23
59
  export interface MergeConflict {
24
- /** 冲突位置:JSON 分区是 top-level key;FilesSection 是 relativePath。 */
60
+ /** 冲突位置:JSON 分区是**点号路径**('a.b.c',整分区为 '$');FilesSection 是 relativePath。 */
25
61
  path: string;
26
62
  kind: 'key' | 'file';
63
+ /** 冲突成因(可选:旧数据没有该字段;UI 缺省按 'both-modified' 措辞) */
64
+ reason?: MergeConflictReason;
65
+ /** 是否已尝试自动合并(true + reason='binary'/'too-large'/'array' = 试过但不安全,故留冲突) */
66
+ autoMergeAttempted?: boolean;
27
67
  local?: unknown;
28
68
  remote?: unknown;
29
69
  ancestor?: unknown;
@@ -31,12 +71,17 @@ export interface MergeConflict {
31
71
 
32
72
  export interface MergeSectionResult {
33
73
  id: SectionId;
34
- /** 分区级决策;'skip' 时无 merged/conflicts;'conflict' 时 merged 为 null(仅 conflicts)。 */
74
+ /** 分区级决策;'skip' 时无 merged/conflicts;'conflict' 时 merged 为 undefined(仅 conflicts)。 */
35
75
  decision: MergeDecision | 'conflict';
36
- /** 当 decision 为 'useRemote' / 'keepLocal' 时:合并后的整分区数据(= 胜出方原值)。 */
76
+ /** decision 为 'useRemote' / 'keepLocal' / 'merge' 时为整分区数据(原值或合并结果)。 */
37
77
  merged?: SectionData;
38
78
  /** 仅 decision === 'conflict' 时非空:分区级冲突或精细合并后剩余冲突。 */
39
79
  conflicts: MergeConflict[];
80
+ /**
81
+ * 自动合并并入的变更点数量(仅 decision === 'merge' 时有意义;缺省 undefined = 未统计)。
82
+ * UI 用它区分「远端整分区覆盖」与「双方改动都保住了」。
83
+ */
84
+ autoMerged?: number;
40
85
  }
41
86
 
42
87
  export interface MergePlan {
@@ -104,76 +149,76 @@ function deepEqual(a: unknown, b: unknown): boolean {
104
149
  return true;
105
150
  }
106
151
 
107
- /** JSON 分区精细合并:按 top-level key 走三方合并。
108
- * - 双侧均未改 → 跳过
109
- * - 仅一侧改 → 采纳该侧
110
- * - 双侧均改 → 若 deepEqual 自动合并;否则留为冲突 */
111
- function mergeJsonSectionGranular(
112
- local: Record<string, unknown>,
113
- remote: Record<string, unknown>,
114
- ancestor: Record<string, unknown> | undefined,
115
- ): { merged: Record<string, unknown>; conflicts: MergeConflict[] } {
116
- const merged: Record<string, unknown> = {};
117
- const conflicts: MergeConflict[] = [];
118
- const keys = new Set<string>([...Object.keys(local), ...Object.keys(remote), ...(ancestor ? Object.keys(ancestor) : [])]);
119
- for (const k of keys) {
120
- const lHas = Object.prototype.hasOwnProperty.call(local, k);
121
- const rHas = Object.prototype.hasOwnProperty.call(remote, k);
122
- const aHas = ancestor ? Object.prototype.hasOwnProperty.call(ancestor, k) : false;
123
- const lv = lHas ? local[k] : undefined;
124
- const rv = rHas ? remote[k] : undefined;
125
- const av = aHas ? ancestor![k] : undefined;
126
- if (lHas && rHas) {
127
- if (deepEqual(lv, rv)) {
128
- merged[k] = lv;
129
- continue;
130
- }
131
- // 双侧都有该 key 但值不同 → 看是否都改过
132
- const lChanged = !aHas || !deepEqual(lv, av);
133
- const rChanged = !aHas || !deepEqual(rv, av);
134
- if (lChanged && rChanged) {
135
- if (deepEqual(lv, rv)) {
136
- merged[k] = lv; // 两侧改后恰好相同
137
- } else {
138
- conflicts.push({ path: k, kind: 'key', local: lv, remote: rv, ancestor: av });
139
- }
140
- } else if (lChanged) {
141
- merged[k] = lv;
142
- } else if (rChanged) {
143
- merged[k] = rv;
144
- } else {
145
- merged[k] = lv; // 双侧都未相对祖先改但 deepEqual 已失败——保持一致
146
- }
147
- } else if (lHas && !rHas) {
148
- // 仅本地有:删/改/增?
149
- const lChanged = !aHas || !deepEqual(lv, av);
150
- if (lChanged) {
151
- // 本地增/改,远程删 → 冲突
152
- conflicts.push({ path: k, kind: 'key', local: lv, remote: undefined, ancestor: av });
153
- } else {
154
- // 本地等于祖先、远程缺:远程删 → 跟随删除
155
- }
156
- } else if (!lHas && rHas) {
157
- const rChanged = !aHas || !deepEqual(rv, av);
158
- if (rChanged) {
159
- // 本地删、远程增 → 冲突
160
- conflicts.push({ path: k, kind: 'key', local: undefined, remote: rv, ancestor: av });
161
- } else {
162
- // 远程等于祖先、本地缺:本地删 → 跟随删除
163
- }
164
- } else {
165
- // 仅祖先有:双侧都删 → 跟随删除
166
- }
152
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
153
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
154
+ }
155
+
156
+ /** 与 sync-state 的判定保持一致(鸭子类型):{ version: 1, files: [...] }。 */
157
+ function isFilesSectionData(data: SectionData): data is FilesSection {
158
+ if (data === null || typeof data !== 'object') return false;
159
+ const obj = data as { version?: unknown; files?: unknown };
160
+ return obj.version === 1 && Array.isArray(obj.files);
161
+ }
162
+
163
+ function bytesEqual(a: Uint8Array | undefined, b: Uint8Array | undefined): boolean {
164
+ if (a === undefined || b === undefined) return a === b;
165
+ if (a.length !== b.length) return false;
166
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
167
+ return true;
168
+ }
169
+
170
+ /** 文本/字节级合并产出的冲突成因 → 统一冲突成因。 */
171
+ function mapConflictReason(reason: TextMergeConflict['reason']): MergeConflictReason {
172
+ switch (reason) {
173
+ case 'array': return 'array';
174
+ case 'format': return 'format';
175
+ case 'binary': return 'binary';
176
+ case 'too-large': return 'too-large';
177
+ default: return 'both-modified'; // 'overlap'(文本行重叠)= 双侧改同一处,统一口径
167
178
  }
168
- return { merged, conflicts };
169
179
  }
170
180
 
171
- /** FilesSection 精细合并:按 relativePath 走三方合并(按文件字节 deepEqual)。 */
181
+ function fromTextConflicts(list: TextMergeConflict[]): MergeConflict[] {
182
+ return list.map((c) => ({
183
+ path: c.path === '' ? '$' : c.path,
184
+ kind: 'key' as const,
185
+ reason: mapConflictReason(c.reason),
186
+ autoMergeAttempted: true,
187
+ ...(c.local !== undefined ? { local: c.local } : {}),
188
+ ...(c.remote !== undefined ? { remote: c.remote } : {}),
189
+ ...(c.ancestor !== undefined ? { ancestor: c.ancestor } : {}),
190
+ }));
191
+ }
192
+
193
+ /**
194
+ * 统计「自动合并并入了几个变更点」:叶子级比较,双侧值不同且合并结果取到一侧即计 1。
195
+ * 只统计对象叶子(数组按整体 1 个点),口径 = 用户能感知的「两边各自的改动都保住了」处数。
196
+ */
197
+ function countMergePoints(local: unknown, remote: unknown, merged: unknown): number {
198
+ if (isPlainObject(local) && isPlainObject(remote) && isPlainObject(merged)) {
199
+ const keys = new Set<string>([...Object.keys(local), ...Object.keys(remote), ...Object.keys(merged)]);
200
+ let n = 0;
201
+ for (const k of keys) n += countMergePoints(local[k], remote[k], merged[k]);
202
+ return n;
203
+ }
204
+ if (Array.isArray(local) || Array.isArray(remote) || Array.isArray(merged)) {
205
+ return deepEqual(local, remote) ? 0 : 1;
206
+ }
207
+ return deepEqual(local, remote) ? 0 : 1;
208
+ }
209
+
210
+ /**
211
+ * FilesSection 精细合并:按 relativePath 逐文件三方合并。
212
+ * - 双侧都改且内容不同 → 先尝试行级三方合并(文本/JSON);成功并入,失败留冲突(reason 如实);
213
+ * - 只有一侧改 → 采纳改动侧;
214
+ * - 一侧删、另一侧改/新增 → modify-delete / delete-modify 冲突(绝不直接删数据);
215
+ * - 一侧删、另一侧未改 → 跟随删除。
216
+ */
172
217
  function mergeFilesSectionGranular(
173
218
  local: FilesSection,
174
219
  remote: FilesSection,
175
220
  ancestor: FilesSection | undefined,
176
- ): { merged: FilesSection; conflicts: MergeConflict[] } {
221
+ ): { merged: FilesSection; conflicts: MergeConflict[]; mergePoints: number } {
177
222
  const lMap = new Map<string, Uint8Array>();
178
223
  for (const f of local.files) lMap.set(f.relativePath, f.data);
179
224
  const rMap = new Map<string, Uint8Array>();
@@ -184,72 +229,174 @@ function mergeFilesSectionGranular(
184
229
  const paths = new Set<string>([...lMap.keys(), ...rMap.keys(), ...aMap.keys()]);
185
230
  const mergedFiles: FilesSection['files'] = [];
186
231
  const conflicts: MergeConflict[] = [];
187
- for (const p of paths) {
188
- const l = lMap.get(p);
189
- const r = rMap.get(p);
190
- const a = aMap.get(p);
191
- const eq = (x: Uint8Array | undefined, y: Uint8Array | undefined) => {
192
- if (x === undefined || y === undefined) return x === y;
193
- if (x.length !== y.length) return false;
194
- for (let i = 0; i < x.length; i++) if (x[i] !== y[i]) return false;
195
- return true;
196
- };
197
- if (l && r) {
198
- if (eq(l, r)) {
199
- mergedFiles.push({ relativePath: p, data: l, contentHash: '' });
200
- continue;
201
- }
202
- const lChanged = !a || !eq(l, a);
203
- const rChanged = !a || !eq(r, a);
232
+ let mergePoints = 0;
233
+ const push = (relativePath: string, data: Uint8Array): void => {
234
+ mergedFiles.push({ relativePath, data, contentHash: '' });
235
+ };
236
+ for (const path of [...paths].sort()) {
237
+ const l = lMap.get(path);
238
+ const r = rMap.get(path);
239
+ const a = aMap.get(path);
240
+ if (l !== undefined && r !== undefined) {
241
+ if (bytesEqual(l, r)) { push(path, l); continue; }
242
+ const lChanged = a === undefined || !bytesEqual(l, a);
243
+ const rChanged = a === undefined || !bytesEqual(r, a);
204
244
  if (lChanged && rChanged) {
205
- conflicts.push({ path: p, kind: 'file', local: l, remote: r, ancestor: a });
206
- } else if (lChanged) {
207
- mergedFiles.push({ relativePath: p, data: l, contentHash: '' });
208
- } else if (rChanged) {
209
- mergedFiles.push({ relativePath: p, data: r, contentHash: '' });
210
- } else {
211
- mergedFiles.push({ relativePath: p, data: l, contentHash: '' });
212
- }
213
- } else if (l && !r) {
214
- const aExists = a !== undefined;
215
- const lChanged = aExists ? !eq(l, a) : true;
216
- if (lChanged) {
217
- if (!aExists) {
218
- // 纯本地新增(祖先里也没有)→ keepLocal
219
- mergedFiles.push({ relativePath: p, data: l, contentHash: '' });
220
- } else {
221
- // 本地改/增 vs 远程删 → 冲突
222
- conflicts.push({ path: p, kind: 'file', local: l, remote: undefined, ancestor: a });
245
+ const attempt = mergeBytesThreeWay(a, l, r);
246
+ if (attempt.ok && attempt.merged !== undefined) {
247
+ push(path, attempt.merged);
248
+ mergePoints += 1;
249
+ continue;
223
250
  }
251
+ conflicts.push({
252
+ path,
253
+ kind: 'file',
254
+ reason: attempt.kind === 'binary' || attempt.kind === 'too-large'
255
+ ? (attempt.kind === 'binary' ? 'binary' : 'too-large')
256
+ : 'both-modified',
257
+ autoMergeAttempted: true,
258
+ local: l,
259
+ remote: r,
260
+ ancestor: a,
261
+ });
262
+ continue;
224
263
  }
225
- } else if (!l && r) {
264
+ if (lChanged) { push(path, l); mergePoints += 1; continue; }
265
+ if (rChanged) { push(path, r); mergePoints += 1; continue; }
266
+ push(path, l);
267
+ continue;
268
+ }
269
+ if (l !== undefined && r === undefined) {
226
270
  const aExists = a !== undefined;
227
- const rChanged = aExists ? !eq(r, a) : true;
228
- if (rChanged) {
229
- if (!aExists) {
230
- // 纯远程新增(祖先里也没有)→ useRemote
231
- mergedFiles.push({ relativePath: p, data: r, contentHash: '' });
232
- } else {
233
- // 本地删 vs 远程改/增 → 冲突
234
- conflicts.push({ path: p, kind: 'file', local: undefined, remote: r, ancestor: a });
235
- }
236
- }
271
+ if (aExists && bytesEqual(l, a)) continue; // 本地未改、远端删 → 跟随删除
272
+ if (!aExists) { push(path, l); mergePoints += 1; continue; } // 纯本地新增
273
+ conflicts.push({ path, kind: 'file', reason: 'modify-delete', local: l, remote: undefined, ancestor: a });
274
+ continue;
275
+ }
276
+ if (l === undefined && r !== undefined) {
277
+ const aExists = a !== undefined;
278
+ if (aExists && bytesEqual(r, a)) continue; // 远端未改、本地删 → 跟随删除
279
+ if (!aExists) { push(path, r); mergePoints += 1; continue; } // 纯远端新增
280
+ conflicts.push({ path, kind: 'file', reason: 'delete-modify', local: undefined, remote: r, ancestor: a });
281
+ continue;
237
282
  }
283
+ // 双侧都删(或仅祖先有):跟随删除,不产生动作。
238
284
  }
239
- return { merged: { version: 1, files: mergedFiles }, conflicts };
285
+ return { merged: { version: 1, files: mergedFiles }, conflicts, mergePoints };
240
286
  }
241
287
 
242
- /** 识别 JSON 分区的「键级合并」键值映射(如 settings/ui 的 namespaces、providers 的 providers)。
243
- * 返回 undefined → 该分区无法做键级合并,调用方应退化为整分区冲突。 */
244
- function keyMapOf(data: SectionData): Record<string, unknown> | undefined {
245
- const obj = data as unknown as Record<string, unknown>;
246
- if (obj && typeof obj === 'object' && obj['version'] === 1) {
247
- const ns = obj['namespaces'];
248
- if (ns && typeof ns === 'object' && !Array.isArray(ns)) return ns as Record<string, unknown>;
249
- const prov = obj['providers'];
250
- if (prov && typeof prov === 'object' && !Array.isArray(prov)) return prov as Record<string, unknown>;
288
+ /** 单分区三方合并(决策链见文件头注释)。 */
289
+ function mergeSectionTriple(
290
+ id: SectionId,
291
+ lData: SectionData | undefined,
292
+ rData: SectionData | undefined,
293
+ aData: SectionData | undefined,
294
+ h: TripleHashes,
295
+ ): MergeSectionResult {
296
+ // ⓪ 形状闸门(先于一切取舍):远端载荷来自不可信输入(解析层只保证整体是合法 JSON,
297
+ // 不保证每个分区的值形状正确)。值形状不对时**绝不原样采用** —— 那等于把畸形数据
298
+ // 写进本机配置;一律按 conflict(reason='format') 交用户裁决。
299
+ if (lData !== undefined && !isPlainObject(lData)) {
300
+ return {
301
+ id,
302
+ decision: 'conflict',
303
+ conflicts: [{ path: '$', kind: 'key', reason: 'format', autoMergeAttempted: false, local: lData, remote: rData, ancestor: aData }],
304
+ };
251
305
  }
252
- return undefined;
306
+ if (rData !== undefined && !isPlainObject(rData)) {
307
+ return {
308
+ id,
309
+ decision: 'conflict',
310
+ conflicts: [{ path: '$', kind: 'key', reason: 'format', autoMergeAttempted: false, local: lData, remote: rData, ancestor: aData }],
311
+ };
312
+ }
313
+ // ① 远端无该分区:本地未改 → 无变化;本地改了 → 冲突(不准把本地改动当垃圾丢掉)
314
+ if (rData === undefined && lData !== undefined) {
315
+ const lChanged = h.ancestor === undefined || h.local !== h.ancestor;
316
+ if (!lChanged) return { id, decision: 'skip', conflicts: [] };
317
+ return {
318
+ id,
319
+ decision: 'conflict',
320
+ conflicts: [{
321
+ path: '$',
322
+ kind: 'key',
323
+ reason: h.ancestor === undefined ? 'no-ancestor' : 'modify-delete',
324
+ autoMergeAttempted: false,
325
+ local: lData,
326
+ remote: undefined,
327
+ ancestor: aData,
328
+ }],
329
+ };
330
+ }
331
+ // ② 本地无该分区、远端有:远端未改 → 无变化;远端改了 → 采用远端(分区缺失不是删除语义,见文件头)
332
+ if (lData === undefined && rData !== undefined) {
333
+ const rChanged = h.ancestor === undefined || h.remote !== h.ancestor;
334
+ if (!rChanged) return { id, decision: 'skip', conflicts: [] };
335
+ return { id, decision: 'useRemote', merged: rData, conflicts: [] };
336
+ }
337
+ if (lData === undefined && rData === undefined) return { id, decision: 'skip', conflicts: [] };
338
+ const local = lData as SectionData;
339
+ const remote = rData as SectionData;
340
+ if (h.local === h.remote) return { id, decision: 'skip', conflicts: [] };
341
+ // 分区形态互换(一侧 FilesSection、一侧 JSON 分区)→ 早于 useRemote/keepLocal 判冲突:
342
+ // 「远端改了、本地没改」不足以支撑把另一种形态的数据整分区写进本机(语义不同,采用必错)。
343
+ if (isFilesSectionData(local) !== isFilesSectionData(remote)) {
344
+ return {
345
+ id,
346
+ decision: 'conflict',
347
+ conflicts: [{ path: '$', kind: 'key', reason: 'section', autoMergeAttempted: false, local, remote, ancestor: aData }],
348
+ };
349
+ }
350
+ if (h.ancestor === undefined) {
351
+ return {
352
+ id,
353
+ decision: 'conflict',
354
+ conflicts: [{ path: '$', kind: 'key', reason: 'no-ancestor', autoMergeAttempted: false, local, remote, ancestor: undefined }],
355
+ };
356
+ }
357
+ if (h.local === h.ancestor) return { id, decision: 'useRemote', merged: remote, conflicts: [] };
358
+ if (h.remote === h.ancestor) return { id, decision: 'keepLocal', merged: local, conflicts: [] };
359
+
360
+ // ③ 双侧相对祖先都改 → 精细合并(形状与形态闸门已在 ⓪/前段通过)
361
+ if (isFilesSectionData(local) && isFilesSectionData(remote)) {
362
+ const aFiles = aData !== undefined && isFilesSectionData(aData) ? aData : undefined;
363
+ const { merged, conflicts, mergePoints } = mergeFilesSectionGranular(local, remote, aFiles);
364
+ if (conflicts.length === 0 && mergePoints > 0) {
365
+ return { id, decision: 'merge', merged, conflicts: [], autoMerged: mergePoints };
366
+ }
367
+ if (conflicts.length === 0) {
368
+ // 双侧都改但合并结果与某一侧逐字相同 → 如实报是哪一侧赢了(不谎称「合并」)
369
+ const mh = hashSection(merged);
370
+ if (mh === h.remote) return { id, decision: 'useRemote', merged, conflicts: [] };
371
+ if (mh === h.local) return { id, decision: 'keepLocal', merged, conflicts: [] };
372
+ return { id, decision: 'merge', merged, conflicts: [], autoMerged: 0 };
373
+ }
374
+ return { id, decision: 'conflict', conflicts };
375
+ }
376
+ if (isPlainObject(local) && isPlainObject(remote)) {
377
+ const aJson = aData !== undefined && isPlainObject(aData) ? aData : undefined;
378
+ const res = mergeJsonValues(aJson, local, remote);
379
+ if (!res.ok || res.merged === undefined) {
380
+ const conflicts = res.conflicts.length > 0
381
+ ? fromTextConflicts(res.conflicts)
382
+ : [{ path: '$', kind: 'key' as const, reason: 'format' as const, autoMergeAttempted: true, local, remote, ancestor: aData }];
383
+ return { id, decision: 'conflict', conflicts };
384
+ }
385
+ const merged = res.merged as SectionData;
386
+ if (res.conflicts.length === 0) {
387
+ const mh = hashSection(merged);
388
+ if (mh === h.remote) return { id, decision: 'useRemote', merged, conflicts: [] };
389
+ if (mh === h.local) return { id, decision: 'keepLocal', merged, conflicts: [] };
390
+ return { id, decision: 'merge', merged, conflicts: [], autoMerged: countMergePoints(local, remote, merged) };
391
+ }
392
+ return { id, decision: 'conflict', conflicts: fromTextConflicts(res.conflicts) };
393
+ }
394
+ // 类型不一致(一侧 FilesSection、一侧 JSON)或非对象 → 整分区冲突,交用户裁决
395
+ return {
396
+ id,
397
+ decision: 'conflict',
398
+ conflicts: [{ path: '$', kind: 'key', reason: 'section', autoMergeAttempted: false, local, remote, ancestor: aData }],
399
+ };
253
400
  }
254
401
 
255
402
  /** 主入口:三方合并。纯函数,无副作用。 */
@@ -264,97 +411,8 @@ export function merge(
264
411
  const ids = unionSectionIds(l, r, a);
265
412
  const hashes = computeHashes(l, r, a, ids);
266
413
  const results: MergeSectionResult[] = [];
267
-
268
414
  for (const id of ids) {
269
- const h = hashes.get(id)!;
270
- const lData = l[id];
271
- const rData = r[id];
272
- const aData = a ? a[id] : undefined;
273
-
274
- // 远端无 → skip(不在本轮考虑;本地有但远端没有 = 远端"删了",视作 useRemote=删除)
275
- // 本实现按远端视角:远端缺 + 本地存在 + 本地未改祖先 → skip(无变化);否则冲突。
276
- if (rData === undefined && lData !== undefined) {
277
- const lChanged = h.ancestor === undefined || h.local !== h.ancestor;
278
- if (!lChanged) {
279
- results.push({ id, decision: 'skip', conflicts: [] });
280
- continue;
281
- }
282
- results.push({ id, decision: 'conflict', conflicts: [{ path: '$', kind: 'key', local: lData, remote: undefined, ancestor: aData }] });
283
- continue;
284
- }
285
- // 本地无、远端有:远端新增;本地删除过(=等于祖先空)+祖先空→skip;否则按 useRemote。
286
- if (lData === undefined && rData !== undefined) {
287
- const rChanged = h.ancestor === undefined || h.remote !== h.ancestor;
288
- if (!rChanged) {
289
- results.push({ id, decision: 'skip', conflicts: [] });
290
- continue;
291
- }
292
- results.push({ id, decision: 'useRemote', merged: rData, conflicts: [] });
293
- continue;
294
- }
295
- // 双方都没有 → skip
296
- if (lData === undefined && rData === undefined) {
297
- results.push({ id, decision: 'skip', conflicts: [] });
298
- continue;
299
- }
300
- // 双方都有
301
- if (h.local === h.remote) {
302
- results.push({ id, decision: 'skip', conflicts: [] });
303
- continue;
304
- }
305
- if (h.ancestor === undefined) {
306
- // 无祖先 → 两方差异视为整分区冲突(不猜测)
307
- results.push({ id, decision: 'conflict', conflicts: [{ path: '$', kind: 'key', local: lData, remote: rData, ancestor: undefined }] });
308
- continue;
309
- }
310
- if (h.local === h.ancestor) {
311
- results.push({ id, decision: 'useRemote', merged: rData, conflicts: [] });
312
- continue;
313
- }
314
- if (h.remote === h.ancestor) {
315
- results.push({ id, decision: 'keepLocal', merged: lData, conflicts: [] });
316
- continue;
317
- }
318
- // 双侧相对祖先都改 → 进入精细合并
319
- if (isFilesSectionData(lData as SectionData)) {
320
- const { merged, conflicts } = mergeFilesSectionGranular(lData as FilesSection, rData as FilesSection, aData as FilesSection | undefined);
321
- if (conflicts.length === 0) {
322
- results.push({ id, decision: 'useRemote', merged: merged as SectionData, conflicts: [] });
323
- } else {
324
- results.push({ id, decision: 'conflict', conflicts, merged: undefined });
325
- }
326
- continue;
327
- }
328
- // JSON 分区:识别可键级合并的子映射(namespaces / providers)
329
- const lKeyMap = keyMapOf(lData as unknown as SectionData);
330
- const rKeyMap = keyMapOf(rData as unknown as SectionData);
331
- const aKeyMap = aData ? keyMapOf(aData as unknown as SectionData) : undefined;
332
- if (lKeyMap && rKeyMap) {
333
- const { merged: mergedKeys, conflicts } = mergeJsonSectionGranular(lKeyMap, rKeyMap, aKeyMap);
334
- // 重组 SectionData:用 l/r 中较新者保留非键级合并字段(version 等),键级合并字段用 mergedKeys
335
- const lObj = lData as unknown as Record<string, unknown>;
336
- const rObj = rData as unknown as Record<string, unknown>;
337
- const base: Record<string, unknown> = { ...lObj, ...rObj };
338
- // 找到键级合并字段在 base 中的键名
339
- const keyFieldName = lKeyMap === lObj['namespaces'] ? 'namespaces'
340
- : lKeyMap === lObj['providers'] ? 'providers' : undefined;
341
- if (keyFieldName) base[keyFieldName] = mergedKeys;
342
- if (conflicts.length === 0) {
343
- results.push({ id, decision: 'useRemote', merged: base as unknown as SectionData, conflicts: [] });
344
- } else {
345
- results.push({ id, decision: 'conflict', conflicts, merged: undefined });
346
- }
347
- continue;
348
- }
349
- // 非键级合并 JSON 分区(plugins/mcp/prompts/workspaces 等):退化为整分区冲突
350
- results.push({ id, decision: 'conflict', conflicts: [{ path: '$', kind: 'key', local: lData, remote: rData, ancestor: aData }] });
415
+ results.push(mergeSectionTriple(id, l[id], r[id], a ? a[id] : undefined, hashes.get(id)!));
351
416
  }
352
417
  return { sections: results };
353
418
  }
354
-
355
- /** 分区是否为文件类(FilesSection)—— 鸭子类型,复用 sync-state 的判定以保持一致。 */
356
- function isFilesSectionData(data: SectionData): data is FilesSection {
357
- if (data === null || typeof data !== 'object') return false;
358
- const obj = data as { version?: unknown; files?: unknown };
359
- return obj.version === 1 && Array.isArray(obj.files);
360
- }
@@ -87,6 +87,17 @@ export const zh = {
87
87
  'sync.backupHistoryFailed': '{trigger}失败',
88
88
  // ---------- 推送前只读预览(issue #91) ----------
89
89
  'sync.pushPreviewUnpackedLocalSources': '本地源插件(link:/file:)需打包才能精确比对变更,分区 {sections} 本次按「有变化」展示',
90
+ // ---------- cloud-sync 借鉴项 ①②③:条件写 / 探针 / 首连自建目录 ----------
91
+ 'sync.probeUnsupported': '当前同步通道不支持连通性自检',
92
+ 'sync.webdav.concurrentWrite': '远端索引已被其它设备更新(HTTP 412):本次写入已中止,请重新同步后再试',
93
+ 'sync.webdav.probeOk': '连接正常(远端 {count} 份快照)',
94
+ 'sync.webdav.probeUnreachable': 'WebDAV 目录不可访问(HTTP {status}):{url}',
95
+ 'sync.webdav.collectionCreated': '已自动创建远端目录:{path}',
96
+ 'sync.webdav.collectionCreateFailed': '创建远端目录失败(HTTP {status}):{path}',
97
+ // ---------- cloud-sync 借鉴项 ⑥:设备身份 ----------
98
+ 'sync.deviceNameFallback': '未命名设备',
99
+ // ---------- git 通道连通性探针(cloud-sync 借鉴项 ②,补齐四通道) ----------
100
+ 'sync.git.probeOk': '连接正常(远端可达、凭据可用)',
90
101
  } as const;
91
102
 
92
103
  export const en: Record<keyof typeof zh, string> = {
@@ -148,6 +159,14 @@ export const en: Record<keyof typeof zh, string> = {
148
159
  'sync.backupHistorySkipped': '{trigger} skipped: {reason}',
149
160
  'sync.backupHistoryFailed': '{trigger} failed',
150
161
  'sync.pushPreviewUnpackedLocalSources': 'Local-source plugins (link:/file:) must be packed to compare changes exactly; section(s) {sections} are shown as changed',
162
+ 'sync.probeUnsupported': 'This sync channel does not support a connectivity self-check',
163
+ 'sync.webdav.concurrentWrite': 'The remote index was updated by another device (HTTP 412): this write was aborted, please sync again',
164
+ 'sync.webdav.probeOk': 'Connection OK ({count} remote snapshots)',
165
+ 'sync.webdav.probeUnreachable': 'The WebDAV directory is unreachable (HTTP {status}): {url}',
166
+ 'sync.webdav.collectionCreated': 'Created the remote directory automatically: {path}',
167
+ 'sync.webdav.collectionCreateFailed': 'Failed to create the remote directory (HTTP {status}): {path}',
168
+ 'sync.deviceNameFallback': 'Unnamed device',
169
+ 'sync.git.probeOk': 'Connection OK (remote reachable, credentials accepted)',
151
170
  };
152
171
 
153
172
  registerMessageCatalog({ id: 'sync-domain', zh, en });