dsh-config-manager 0.1.78 → 0.1.79

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 (290) 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 +400 -112
  26. package/lib/client.js +2845 -1524
  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/autosync-config.d.ts +73 -0
  82. package/lib/sync/autosync-config.js +89 -0
  83. package/lib/sync/autosync-scheduler.d.ts +80 -6
  84. package/lib/sync/autosync-scheduler.js +263 -40
  85. package/lib/sync/gist/gist-transport.d.ts +15 -1
  86. package/lib/sync/gist/gist-transport.js +66 -5
  87. package/lib/sync/gist/messages.d.ts +3 -0
  88. package/lib/sync/gist/messages.js +7 -0
  89. package/lib/sync/git/git-transport.d.ts +42 -3
  90. package/lib/sync/git/git-transport.js +146 -3
  91. package/lib/sync/merge.d.ts +47 -14
  92. package/lib/sync/merge.js +242 -222
  93. package/lib/sync/messages.d.ts +8 -0
  94. package/lib/sync/messages.js +19 -0
  95. package/lib/sync/risk.d.ts +19 -2
  96. package/lib/sync/risk.js +10 -3
  97. package/lib/sync/s3/messages.d.ts +6 -0
  98. package/lib/sync/s3/messages.js +13 -0
  99. package/lib/sync/s3/s3-transport.d.ts +9 -1
  100. package/lib/sync/s3/s3-transport.js +78 -6
  101. package/lib/sync/sync-engine.d.ts +193 -4
  102. package/lib/sync/sync-engine.js +450 -37
  103. package/lib/sync/sync-history.d.ts +11 -0
  104. package/lib/sync/sync-history.js +20 -0
  105. package/lib/sync/sync-state.d.ts +43 -0
  106. package/lib/sync/sync-state.js +44 -0
  107. package/lib/sync/sync-watcher.d.ts +76 -0
  108. package/lib/sync/sync-watcher.js +202 -0
  109. package/lib/sync/text-merge.d.ts +85 -0
  110. package/lib/sync/text-merge.js +513 -0
  111. package/lib/sync/transport.d.ts +48 -0
  112. package/lib/sync/transport.js +12 -0
  113. package/lib/sync/watch-scope.d.ts +38 -0
  114. package/lib/sync/watch-scope.js +95 -0
  115. package/lib/sync/webdav/webdav-transport.d.ts +16 -2
  116. package/lib/sync/webdav/webdav-transport.js +128 -11
  117. package/lib/ui/foreign-view.js +10 -0
  118. package/lib/ui/i18n.d.ts +8 -0
  119. package/lib/ui/i18n.js +20 -0
  120. package/lib/ui/rollback-view.d.ts +12 -0
  121. package/lib/ui/rollback-view.js +2 -0
  122. package/lib/ui/sync-settings-view.d.ts +47 -0
  123. package/lib/ui/sync-settings-view.js +24 -0
  124. package/lib/ui/types.d.ts +21 -0
  125. package/lib/utils/env-lock.d.ts +1 -0
  126. package/lib/utils/env-lock.js +7 -4
  127. package/package.json +6 -6
  128. package/src/adapters/credentials.ts +22 -4
  129. package/src/adapters/file-collection.ts +2 -1
  130. package/src/adapters/mcp.test.ts +235 -1
  131. package/src/adapters/mcp.ts +276 -5
  132. package/src/adapters/messages.ts +7 -0
  133. package/src/adapters/plugin-files.ts +2 -1
  134. package/src/adapters/plugins.test.ts +176 -2
  135. package/src/adapters/plugins.ts +107 -21
  136. package/src/adapters/pnpm-workspace.test.ts +89 -1
  137. package/src/adapters/pnpm-workspace.ts +127 -2
  138. package/src/adapters/prompts.ts +20 -3
  139. package/src/adapters/providers.ts +62 -4
  140. package/src/adapters/settings.ts +16 -3
  141. package/src/adapters/skills.test.ts +141 -0
  142. package/src/adapters/skills.ts +58 -2
  143. package/src/adapters/ui.ts +16 -3
  144. package/src/adapters/units-entries.test.ts +190 -0
  145. package/src/adapters/units.ts +50 -0
  146. package/src/adapters/workspaces.ts +2 -1
  147. package/src/client/about/PluginUpdateDialog.tsx +4 -2
  148. package/src/client/about/ReleaseNotesDialog.tsx +4 -2
  149. package/src/client/chat/RollbackReviewDialog.tsx +3 -2
  150. package/src/client/common/Skeleton.tsx +32 -0
  151. package/src/client/common/routes.ts +9 -0
  152. package/src/client/config-manager.module.css +321 -11
  153. package/src/client/home/HomePanel.tsx +14 -6
  154. package/src/client/import/ForeignImportView.tsx +3 -1
  155. package/src/client/import/import-wizard-steps.tsx +2 -1
  156. package/src/client/library/ArtifactRow.tsx +12 -2
  157. package/src/client/library/LibraryActions.tsx +6 -3
  158. package/src/client/library/LibraryPanel.tsx +21 -3
  159. package/src/client/library/RestorePlanView.tsx +4 -2
  160. package/src/client/locales.ts +3 -0
  161. package/src/client/recovery/RecoveryPanel.tsx +54 -7
  162. package/src/client/recovery/RollbackCard.tsx +7 -2
  163. package/src/client/recovery/RollbackDiffPane.tsx +3 -2
  164. package/src/client/recovery/recovery-locales.ts +20 -2
  165. package/src/client/recovery/recovery-view.test.ts +39 -1
  166. package/src/client/recovery/recovery-view.ts +23 -1
  167. package/src/client/run-store.test.ts +1 -1
  168. package/src/client/run-store.ts +4 -0
  169. package/src/client/sync/AutosyncCard.tsx +49 -6
  170. package/src/client/sync/MergeModal.tsx +219 -0
  171. package/src/client/sync/MergePreviewView.tsx +306 -0
  172. package/src/client/sync/SyncActivityBar.tsx +31 -0
  173. package/src/client/sync/SyncConfirmView.tsx +2 -1
  174. package/src/client/sync/SyncPanel.tsx +130 -87
  175. package/src/client/sync/SyncProbeRow.tsx +162 -0
  176. package/src/client/sync/SyncSettingsView.test.ts +27 -2
  177. package/src/client/sync/WorkDirCard.tsx +44 -6
  178. package/src/client/sync/history-model.ts +4 -1
  179. package/src/client/sync/merge-view.test.ts +234 -0
  180. package/src/client/sync/merge-view.ts +371 -0
  181. package/src/client/sync/probe-view.test.ts +90 -0
  182. package/src/client/sync/probe-view.ts +63 -0
  183. package/src/client/sync/remote-device-view.test.ts +35 -0
  184. package/src/client/sync/remote-device-view.ts +25 -0
  185. package/src/client/sync/sync-api.ts +212 -2
  186. package/src/client/sync/sync-entry.test.ts +78 -0
  187. package/src/client/sync/sync-entry.ts +53 -0
  188. package/src/client/sync/sync-locales.ts +234 -2
  189. package/src/client/sync/sync-view.test.ts +18 -1
  190. package/src/client/sync/sync-view.ts +20 -0
  191. package/src/core/backup.test.ts +41 -0
  192. package/src/core/backup.ts +20 -0
  193. package/src/core/mcp-connector-store.test.ts +265 -0
  194. package/src/core/mcp-connector-store.ts +516 -0
  195. package/src/core/messages-core.ts +4 -0
  196. package/src/core/messages.ts +46 -0
  197. package/src/core/phase3-host.ts +18 -1
  198. package/src/core/phase3-production-integration.test.ts +4 -0
  199. package/src/core/plugin-cli.test.ts +26 -0
  200. package/src/core/plugin-cli.ts +30 -4
  201. package/src/core/recovery-orchestrator.test.ts +104 -5
  202. package/src/core/recovery-orchestrator.ts +74 -17
  203. package/src/core/rollback-activity.test.ts +311 -0
  204. package/src/core/rollback-activity.ts +367 -0
  205. package/src/core/rollback-cross-session.test.ts +694 -0
  206. package/src/core/rollback-interrupted.test.ts +137 -0
  207. package/src/core/rollback-ledger.ts +32 -0
  208. package/src/core/rollback-merge.test.ts +130 -0
  209. package/src/core/rollback-merge.ts +78 -14
  210. package/src/core/rollback-point.ts +468 -91
  211. package/src/core/rollback-undo-journal.test.ts +508 -0
  212. package/src/core/rollback-undo-journal.ts +571 -0
  213. package/src/core/self-update.ts +2 -5
  214. package/src/core/text-fidelity.test.ts +189 -0
  215. package/src/core/text-fidelity.ts +197 -0
  216. package/src/core/types.ts +11 -3
  217. package/src/core/update-check.ts +2 -5
  218. package/src/core/validator.ts +14 -0
  219. package/src/foreign/bundle-origin.test.ts +265 -0
  220. package/src/foreign/bundle.ts +45 -5
  221. package/src/foreign/claude-code.test.ts +268 -0
  222. package/src/foreign/claude-code.ts +406 -25
  223. package/src/foreign/claude-plugins.test.ts +234 -0
  224. package/src/foreign/claude-sessions.test.ts +141 -0
  225. package/src/foreign/claude-sessions.ts +179 -23
  226. package/src/foreign/file-budget.test.ts +4 -1
  227. package/src/foreign/read-claude-code.test.ts +320 -0
  228. package/src/foreign/read-claude-code.ts +490 -71
  229. package/src/foreign/read-claude-plugins.ts +465 -0
  230. package/src/foreign/registry.ts +25 -2
  231. package/src/foreign/session-ir.test.ts +65 -0
  232. package/src/foreign/truth-table.test.ts +81 -0
  233. package/src/foreign/truth-table.ts +6 -3
  234. package/src/foreign/types.ts +162 -2
  235. package/src/index.facade.test.ts +26 -4
  236. package/src/index.ts +76 -19
  237. package/src/market/git-file-writer.test.ts +4 -0
  238. package/src/market/git-file-writer.ts +7 -1
  239. package/src/profiles/dsh-profile-launcher.test.ts +68 -3
  240. package/src/profiles/dsh-profile-launcher.ts +56 -1
  241. package/src/profiles/dsh-profile-shared.ts +8 -2
  242. package/src/routes/foreign.ts +8 -1
  243. package/src/routes/index.ts +1 -1
  244. package/src/routes/kit.ts +2 -2
  245. package/src/routes/sync.ts +176 -9
  246. package/src/schema/manifest-schema.test.ts +87 -2
  247. package/src/schema/manifest.ts +57 -1
  248. package/src/schema/types.ts +45 -2
  249. package/src/sync/autosync-config.test.ts +73 -1
  250. package/src/sync/autosync-config.ts +124 -0
  251. package/src/sync/autosync-risk-tier.test.ts +38 -2
  252. package/src/sync/autosync-scheduler.test.ts +264 -12
  253. package/src/sync/autosync-scheduler.ts +296 -44
  254. package/src/sync/gist/gist-conditional-write.test.ts +223 -0
  255. package/src/sync/gist/gist-transport.ts +73 -7
  256. package/src/sync/gist/messages.ts +7 -0
  257. package/src/sync/git/git-probe.test.ts +80 -0
  258. package/src/sync/git/git-transport.test.ts +185 -0
  259. package/src/sync/git/git-transport.ts +178 -5
  260. package/src/sync/merge.test.ts +188 -55
  261. package/src/sync/merge.ts +282 -224
  262. package/src/sync/messages.ts +19 -0
  263. package/src/sync/risk.ts +29 -5
  264. package/src/sync/s3/messages.ts +13 -0
  265. package/src/sync/s3/s3-conditional-write.test.ts +330 -0
  266. package/src/sync/s3/s3-transport.ts +87 -9
  267. package/src/sync/sync-device-identity.test.ts +267 -0
  268. package/src/sync/sync-engine.test.ts +379 -0
  269. package/src/sync/sync-engine.ts +535 -36
  270. package/src/sync/sync-history.test.ts +16 -1
  271. package/src/sync/sync-history.ts +26 -0
  272. package/src/sync/sync-state.test.ts +64 -0
  273. package/src/sync/sync-state.ts +79 -0
  274. package/src/sync/sync-watcher.test.ts +197 -0
  275. package/src/sync/sync-watcher.ts +246 -0
  276. package/src/sync/text-merge.test.ts +358 -0
  277. package/src/sync/text-merge.ts +554 -0
  278. package/src/sync/transport.ts +57 -0
  279. package/src/sync/watch-scope.test.ts +122 -0
  280. package/src/sync/watch-scope.ts +120 -0
  281. package/src/sync/webdav/webdav-conditional-write.test.ts +360 -0
  282. package/src/sync/webdav/webdav-transport.ts +137 -14
  283. package/src/ui/foreign-view.test.ts +34 -0
  284. package/src/ui/foreign-view.ts +10 -0
  285. package/src/ui/i18n.ts +20 -0
  286. package/src/ui/rollback-view.ts +14 -0
  287. package/src/ui/sync-settings-view.test.ts +28 -1
  288. package/src/ui/sync-settings-view.ts +43 -0
  289. package/src/ui/types.ts +22 -0
  290. package/src/utils/env-lock.ts +8 -5
@@ -3,8 +3,10 @@
3
3
  *
4
4
  * 为什么需要它:改仓库地址后同步失败时,App 内此前**没有任何自救路径** —— 只能手工
5
5
  * `git remote set-url`,或删掉 `<dataDir>/sync/work` 重来。这里给出工作副本的只读事实
6
- * (路径 / origin / 与配置地址是否一致)与两条出口:
6
+ * (路径 / origin / 与配置地址是否一致)与三条出口:
7
7
  * - 「重指向仓库地址」= host 的 relink(非破坏性:只改 git 配置,不碰工作树与本地提交);
8
+ * - 「采纳远端历史」= host 的 adopt(issue #104:把远端分支合并进本地工作副本 —— 换仓库 / 远端历史
9
+ * 被重写后两条历史不相交,pull --ff-only 永远失败。不改写本地提交,可 revert,冲突自动 abort);
8
10
  * - 「重新克隆工作副本」= host 的 reclone(**丢弃未上行的本地提交**)→ danger 二次确认。
9
11
  *
10
12
  * 约定(与 ChannelClearConfirmDialog 同族):确认弹窗、宿主调用与终局回执都在本文件内自持,
@@ -19,10 +21,18 @@ import { ConfirmDialog } from '../common/ConfirmDialog.tsx'
19
21
  import { toast } from '../common/toast-store.ts'
20
22
  import { redact } from '../../security/redaction.ts'
21
23
  import {
22
- canRecloneWorkdir, formatSyncUrlPreview, needsWorkdirRelink, workdirStateBadgeKind, workdirStateKey,
24
+ canAdoptWorkdir, canRecloneWorkdir, formatSyncUrlPreview, needsWorkdirRelink, workdirBacklog, workdirStateBadgeKind,
25
+ workdirStateKey,
23
26
  } from '../../ui/sync-settings-view.ts'
24
27
  import css from '../config-manager.module.css'
25
28
 
29
+ /** 三种出口各自的终局回执(zh/en 都在 sync-locales,禁止在这里拼用户可见文案)。 */
30
+ const WORKDIR_TOAST = {
31
+ relink: 'toast.workdirRelinked',
32
+ adopt: 'toast.workdirAdopted',
33
+ reclone: 'toast.workdirRecloned',
34
+ } as const
35
+
26
36
  export function WorkDirCard({ t, api, workDir, busy, onChanged }: {
27
37
  t: TranslateNS<'config-manager-sync'>
28
38
  api: SyncApi
@@ -34,20 +44,27 @@ export function WorkDirCard({ t, api, workDir, busy, onChanged }: {
34
44
  }) {
35
45
  /** 重新克隆的二次确认(破坏性:丢弃未上行的本地提交) */
36
46
  const [recloneOpen, setRecloneOpen] = useState(false)
37
- /** 本次发起的出口(relink / reclone):请求在途时卡片按钮就地转加载态。
47
+ /** 本次发起的出口(relink / adopt / reclone):请求在途时卡片按钮就地转加载态。
38
48
  * 为什么必须有:reclone 要删目录 + 重新 clone(真机可达数十秒),此前点了只有 toast 在**结束**时才响,
39
49
  * 用户无从判断是否正在执行,容易重复点击或以为没反应。 */
40
- const [pending, setPending] = useState<'relink' | 'reclone' | null>(null)
50
+ const [pending, setPending] = useState<'relink' | 'adopt' | 'reclone' | null>(null)
41
51
  const stateKey = workdirStateKey(workDir)
42
52
  const badgeKind = workdirStateBadgeKind(stateKey)
43
53
  const facts = workDir !== undefined && 'facts' in workDir ? workDir.facts : null
54
+ /** issue #104 建议 3:本地攒了提交、远端却一个快照都没有 —— 这一条必须显式喊出来 */
55
+ const backlog = workdirBacklog(workDir)
56
+ const backlogBadge = backlog.kind === 'never-synced'
57
+ ? { kind: 'warn' as const, key: 'workdir.backlogNeverSynced' as const }
58
+ : backlog.kind === 'ahead'
59
+ ? { kind: 'warn' as const, key: 'workdir.backlogAhead' as const }
60
+ : null
44
61
 
45
- const reset = async (mode: 'relink' | 'reclone'): Promise<void> => {
62
+ const reset = async (mode: 'relink' | 'adopt' | 'reclone'): Promise<void> => {
46
63
  setRecloneOpen(false)
47
64
  setPending(mode)
48
65
  try {
49
66
  await api.workdirReset(mode === 'reclone' ? { mode, confirm: true } : { mode })
50
- toast.ok(t(mode === 'relink' ? 'toast.workdirRelinked' : 'toast.workdirRecloned'))
67
+ toast.ok(t(WORKDIR_TOAST[mode]))
51
68
  onChanged()
52
69
  } catch (err) {
53
70
  toast.error(t('toast.workdirResetFailed') + t('common.labelSeparator') + redact(err instanceof Error ? err.message : String(err)))
@@ -63,6 +80,7 @@ export function WorkDirCard({ t, api, workDir, busy, onChanged }: {
63
80
  {stateKey !== null && badgeKind !== null && (
64
81
  <div className={css.statRow}>
65
82
  <Badge kind={badgeKind}>{t(stateKey)}</Badge>
83
+ {backlogBadge !== null && <Badge kind={backlogBadge.kind}>{t(backlogBadge.key)}</Badge>}
66
84
  </div>
67
85
  )}
68
86
  {workDir === undefined ? (
@@ -83,6 +101,18 @@ export function WorkDirCard({ t, api, workDir, busy, onChanged }: {
83
101
  </span>
84
102
  </span>
85
103
  </div>
104
+ {backlog.kind !== 'unknown' && (
105
+ <div className={css.factCell}>
106
+ <span className={css.factLabel}>{t('workdir.ahead')}</span>
107
+ <span className={css.factValue}>{backlog.kind === 'clean' ? 0 : backlog.ahead}</span>
108
+ </div>
109
+ )}
110
+ {(backlog.kind === 'clean' || backlog.kind === 'ahead') && (
111
+ <div className={css.factCell}>
112
+ <span className={css.factLabel}>{t('workdir.remoteSnapshots')}</span>
113
+ <span className={css.factValue}>{backlog.remoteSnapshots}</span>
114
+ </div>
115
+ )}
86
116
  </div>
87
117
  )}
88
118
  <div className={css.actionRowTop}>
@@ -91,6 +121,14 @@ export function WorkDirCard({ t, api, workDir, busy, onChanged }: {
91
121
  {t('workdir.relink')}
92
122
  </Button>
93
123
  )}
124
+ {canAdoptWorkdir(workDir) && (
125
+ <>
126
+ <Button disabled={busy || pending !== null} loading={pending === 'adopt'} onClick={() => { void reset('adopt') }}>
127
+ {t('workdir.adopt')}
128
+ </Button>
129
+ <InfoHint text={t('workdir.adoptHint')} label={t('common.infoHint')} />
130
+ </>
131
+ )}
94
132
  {canRecloneWorkdir(workDir) && (
95
133
  <Button variant="danger" disabled={busy || pending !== null} loading={pending === 'reclone'} onClick={() => { setRecloneOpen(true) }}>
96
134
  {t('workdir.reclone')}
@@ -87,7 +87,7 @@ export function autosyncStatusLabel(status: AutosyncHistoryEntry['status'], t: S
87
87
  * 跳过原因 → 可读描述(host 透传语义;未知原因回退原串)。
88
88
  *
89
89
  * client-F1(本次收口):conflict / no-remote / not-configured / network / encrypted /
90
- * mutation-locked / 未知 **七类全部**走 history.autosyncReason* 字典键 —— 此前后三类是
90
+ * mutation-locked / conflict-kept-local / 未知 **八类全部**走 history.autosyncReason* 字典键 —— 此前部分码是
91
91
  * 硬编码中文(sync-locales.ts 当时不可写),英文界面下会原样透出。
92
92
  */
93
93
  export function skipReasonKey(reason: string | undefined): SyncKey | null {
@@ -106,6 +106,9 @@ export function skipReasonKey(reason: string | undefined): SyncKey | null {
106
106
  // 客户端拿不到细分 reason → 文案必须同时覆盖「活锁占用」与「残留锁需回收」两种可能,
107
107
  // 并给出可操作方向(否则界面只显示裸 token,用户无从判断要不要处理)。
108
108
  case 'mutation-locked': return 'history.autosyncReasonMutationLocked';
109
+ // 真·自动同步(conflictPolicy='local'):冲突已按策略「保留本机并推上去」消解,
110
+ // 远端那一版被覆盖(仍在远端历史快照里,可回滚)—— 必须与「冲突待裁决」分开说。
111
+ case 'conflict-kept-local': return 'history.autosyncReasonConflictKeptLocal';
109
112
  // 未知原因:没有字典键(调用方决定回退策略)
110
113
  default: return null;
111
114
  }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * T-ui-merge:远程合并裁决**纯函数**单测(node:test,零依赖、无 DOM)。
3
+ *
4
+ * 覆盖验收:决策/成因字典映射(禁止裸枚举)、冲突行投影与截断+redact、
5
+ * 保守缺省(空草稿 = 未点名的分区引擎不应用)、批量裁决语义、裁决计数与可应用判据、结果归一化。
6
+ */
7
+ import test from 'node:test'
8
+ import assert from 'node:assert/strict'
9
+
10
+ import {
11
+ applyBulkResolution, buildMergeResolutions, decideSyncEntry, hasApplicableSections, initialResolutionDraft,
12
+ mergeDecisionCounts, mergeDecisionKey, mergeDecisionTone, mergeKindKey, mergeReasonKey,
13
+ mergeResultView, mergeSectionRows, mergeSummaryItems, needsNoAncestorNotice,
14
+ normalizeDecision, setSectionAction, truncatePreview, MERGE_PREVIEW_MAX_CHARS,
15
+ } from './merge-view.ts'
16
+ import type { MergePreviewResponse, MergePreviewSection, MergeSectionDecision } from './sync-api.ts'
17
+
18
+ /** 构造一份预览(分区 id 用真实 SectionId 形态,便于与宿主口径对齐)。 */
19
+ function preview(overrides: Partial<MergePreviewResponse> = {}): MergePreviewResponse {
20
+ return {
21
+ ok: true,
22
+ snapshotId: 'snap-1',
23
+ hasAncestor: true,
24
+ sections: [],
25
+ summary: { auto: 0, review: 0, conflicts: 0, skipped: 0 },
26
+ ...overrides,
27
+ }
28
+ }
29
+
30
+ test('merge-view: normalizeDecision —— 未知值 fail-closed 归入「需裁决」,不静默当无变化', () => {
31
+ assert.equal(normalizeDecision('useRemote'), 'useRemote')
32
+ assert.equal(normalizeDecision('conflict'), 'conflict')
33
+ assert.equal(normalizeDecision('brand-new-decision'), 'conflict')
34
+ assert.equal(normalizeDecision(undefined), 'conflict')
35
+ assert.equal(normalizeDecision(42), 'conflict')
36
+ })
37
+
38
+ test('merge-view: 决策/成因/类型一律经字典键映射(界面不出现裸枚举)', () => {
39
+ assert.equal(mergeDecisionKey('merge'), 'merge.decision.merge')
40
+ assert.equal(mergeDecisionKey('nope'), 'merge.decision.conflict')
41
+ assert.equal(mergeDecisionTone('merge'), 'ok')
42
+ assert.equal(mergeDecisionTone('conflict'), 'warn')
43
+ assert.equal(mergeReasonKey('modify-delete'), 'merge.reason.modifyDelete')
44
+ assert.equal(mergeReasonKey('unknown-reason'), 'merge.reason.bothModified')
45
+ assert.equal(mergeReasonKey(undefined), 'merge.reason.bothModified')
46
+ assert.equal(mergeKindKey('file'), 'merge.kind.file')
47
+ assert.equal(mergeKindKey('key'), 'merge.kind.key')
48
+ assert.equal(mergeKindKey(undefined), 'merge.kind.key')
49
+ })
50
+
51
+ test('merge-view: truncatePreview —— 渲染前再过一次 redact,且按上限截断', () => {
52
+ const secret = 'ghp_abcdefghijklmnopqrstuvwxyz012345'
53
+ const masked = truncatePreview('Authorization: ' + secret)
54
+ assert.ok(masked.includes('REDACTED'), '密钥形状必须被掩码,实际:' + masked)
55
+ assert.ok(!masked.includes(secret))
56
+ assert.equal(truncatePreview(undefined), '')
57
+ assert.equal(truncatePreview(null), '')
58
+ assert.equal(truncatePreview(123), '123', '非字符串按 String() 展示,绝不 undefined')
59
+ const long = 'x'.repeat(MERGE_PREVIEW_MAX_CHARS + 40)
60
+ const cut = truncatePreview(long)
61
+ assert.equal(cut.length, MERGE_PREVIEW_MAX_CHARS + 1, '截断到上限 + 一个省略号')
62
+ assert.ok(cut.endsWith('…'))
63
+ })
64
+
65
+ test('merge-view: mergeSectionRows —— conflict 分区需裁决且「自动合并」不可用;merge 分区可用', () => {
66
+ const p = preview({
67
+ sections: [
68
+ { id: 'settings', decision: 'conflict', conflictCount: 1, conflicts: [{ path: 'foo', kind: 'key', reason: 'both-modified', localPreview: 'l', remotePreview: 'r', ancestorPreview: 'a' }] },
69
+ { id: 'providers', decision: 'merge', autoMerged: 2, conflictCount: 0, conflicts: [] },
70
+ ],
71
+ })
72
+ const rows = mergeSectionRows(p)
73
+ assert.equal(rows.length, 2)
74
+ const conflict = rows[0]!
75
+ assert.equal(conflict.needsDecision, true)
76
+ assert.equal(conflict.canUseMerged, false, 'conflict 分区没有 merged 载荷 → 采用自动合并必须禁用')
77
+ assert.equal(conflict.resolutionId, 'settings', '裁决单 id = 分区 id(无复合 id)')
78
+ assert.deepEqual(conflict.conflicts[0], {
79
+ path: 'foo',
80
+ kindKey: 'merge.kind.key',
81
+ reasonKey: 'merge.reason.bothModified',
82
+ localPreview: 'l',
83
+ remotePreview: 'r',
84
+ ancestorPreview: 'a',
85
+ })
86
+ const merged = rows[1]!
87
+ assert.equal(merged.needsDecision, false)
88
+ assert.equal(merged.canUseMerged, true)
89
+ assert.equal(merged.autoMerged, 2)
90
+ })
91
+
92
+ test('merge-view: mergeSectionRows —— 缺字段时用冲突数组长度兜底,不显示 undefined', () => {
93
+ const p = preview({
94
+ sections: [{ id: 'mcp', decision: 'conflict', conflictCount: undefined as unknown as number, conflicts: [{ path: 'a', kind: 'key', localPreview: '', remotePreview: '', ancestorPreview: '' }] }],
95
+ })
96
+ const rows = mergeSectionRows(p)
97
+ assert.equal(rows[0]!.conflictCount, 1)
98
+ assert.equal(rows[0]!.autoMerged, null)
99
+ })
100
+
101
+ test('merge-view: initialResolutionDraft —— 空草稿(未点名的分区引擎不应用,界面不再预置 skip)', () => {
102
+ const p = preview({
103
+ sections: [
104
+ { id: 'settings', decision: 'conflict', conflictCount: 2, conflicts: [] },
105
+ { id: 'providers', decision: 'useRemote', conflictCount: 0, conflicts: [] },
106
+ ],
107
+ })
108
+ assert.deepEqual(initialResolutionDraft(p), {}, '空草稿:请求体里不出现任何分区')
109
+ assert.deepEqual(initialResolutionDraft(null), {})
110
+ assert.deepEqual(buildMergeResolutions({}), [], '空草稿 = 空请求体')
111
+ })
112
+
113
+ test('merge-view: 批量裁决 —— 本地/远端覆盖全部分区;自动合并只碰 merge 分区;留到下次 = 清空待裁决', () => {
114
+ const p = preview({
115
+ sections: [
116
+ { id: 'settings', decision: 'conflict', conflictCount: 1, conflicts: [] },
117
+ { id: 'mcp', decision: 'conflict', conflictCount: 1, conflicts: [] },
118
+ { id: 'providers', decision: 'merge', autoMerged: 1, conflictCount: 0, conflicts: [] },
119
+ ],
120
+ })
121
+ const base = initialResolutionDraft(p)
122
+ assert.deepEqual(applyBulkResolution(p, base, 'useRemote'), { settings: 'useRemote', mcp: 'useRemote', providers: 'useRemote' })
123
+ assert.deepEqual(applyBulkResolution(p, base, 'keepLocal'), { settings: 'keepLocal', mcp: 'keepLocal', providers: 'keepLocal' })
124
+ assert.deepEqual(
125
+ applyBulkResolution(p, base, 'useMerged'),
126
+ { providers: 'useMerged' },
127
+ 'conflict 分区不因批量操作被静默改成别的动作,只有有 merged 载荷的分区可写 useMerged',
128
+ )
129
+ assert.deepEqual(applyBulkResolution(p, base, 'leavePending'), {}, '冲突留到下次 = 把待裁决分区移出草稿')
130
+ // 已裁决的分区被「留到下次」清掉,未裁决分区本来就不在草稿里
131
+ assert.deepEqual(applyBulkResolution(p, setSectionAction(base, 'settings', 'useRemote'), 'leavePending'), {})
132
+ })
133
+
134
+ test('merge-view: 单分区裁决 + 请求体排序稳定', () => {
135
+ const p = preview({
136
+ sections: [
137
+ { id: 'settings', decision: 'conflict', conflictCount: 1, conflicts: [] },
138
+ { id: 'mcp', decision: 'conflict', conflictCount: 1, conflicts: [] },
139
+ ],
140
+ })
141
+ const d = setSectionAction(initialResolutionDraft(p), 'mcp', 'useRemote')
142
+ assert.deepEqual(buildMergeResolutions(d), [{ id: 'mcp', action: 'useRemote' }])
143
+ assert.deepEqual(buildMergeResolutions(setSectionAction({}, 'settings', 'keepLocal')), [{ id: 'settings', action: 'keepLocal' }])
144
+ })
145
+
146
+ test('merge-view: hasApplicableSections —— 只要存在「有变化且不需裁决」的分区就可执行(与草稿无关)', () => {
147
+ const onlySkip = preview({ sections: [{ id: 'settings', decision: 'skip', conflictCount: 0, conflicts: [] }] })
148
+ assert.equal(hasApplicableSections(onlySkip), false, '全部无变化 → 执行合并必须禁用')
149
+ assert.equal(hasApplicableSections(null), false)
150
+ const withAuto = preview({
151
+ sections: [
152
+ { id: 'settings', decision: 'conflict', conflictCount: 1, conflicts: [] },
153
+ { id: 'providers', decision: 'useRemote', conflictCount: 0, conflicts: [] },
154
+ ],
155
+ })
156
+ assert.equal(hasApplicableSections(withAuto), true, '只有冲突 + 一个可应用分区 → 可执行(冲突留待下次)')
157
+ const onlyConflict = preview({ sections: [{ id: 'settings', decision: 'conflict', conflictCount: 1, conflicts: [] }] })
158
+ assert.equal(hasApplicableSections(onlyConflict), false, '只有待裁决分区 → 没有可应用内容')
159
+ })
160
+
161
+ test('merge-view: mergeDecisionCounts —— 待裁决覆盖 + 本次可应用分区数分别可数', () => {
162
+ const p = preview({
163
+ sections: [
164
+ { id: 'settings', decision: 'conflict', conflictCount: 2, conflicts: [] },
165
+ { id: 'mcp', decision: 'conflict', conflictCount: 1, conflicts: [] },
166
+ { id: 'providers', decision: 'merge', autoMerged: 1, conflictCount: 0, conflicts: [] },
167
+ { id: 'skills', decision: 'skip', conflictCount: 0, conflicts: [] },
168
+ ],
169
+ })
170
+ const empty = mergeDecisionCounts(p, {})
171
+ assert.deepEqual(empty, { needsDecision: 2, decided: 0, undecided: 2, conflictItems: 3, applicable: 1 })
172
+ assert.deepEqual(
173
+ mergeDecisionCounts(p, setSectionAction({}, 'settings', 'useRemote')),
174
+ { needsDecision: 2, decided: 1, undecided: 1, conflictItems: 3, applicable: 1 },
175
+ )
176
+ assert.deepEqual(
177
+ mergeDecisionCounts(p, setSectionAction(setSectionAction({}, 'settings', 'useRemote'), 'mcp', 'keepLocal')),
178
+ { needsDecision: 2, decided: 2, undecided: 0, conflictItems: 3, applicable: 1 },
179
+ )
180
+ })
181
+
182
+ test('merge-view: mergeSummaryItems —— 缺字段按 0,绝不 undefined', () => {
183
+ assert.deepEqual(mergeSummaryItems(null), [
184
+ { labelKey: 'merge.summary.auto', value: 0, tone: 'ok' },
185
+ { labelKey: 'merge.summary.review', value: 0, tone: 'warn' },
186
+ { labelKey: 'merge.summary.conflicts', value: 0, tone: 'warn' },
187
+ { labelKey: 'merge.summary.skipped', value: 0, tone: 'info' },
188
+ ])
189
+ assert.equal(mergeSummaryItems(preview({ summary: { auto: 1, review: 2, conflicts: 3, skipped: 4 } }))[2]!.value, 3)
190
+ })
191
+
192
+ test('merge-view: mergeResultView —— pendingConflicts 必须暴露出来(跳过 ≠ 已解决)', () => {
193
+ assert.equal(mergeResultView(null), null)
194
+ const v = mergeResultView({
195
+ ok: false,
196
+ applied: ['settings'],
197
+ skipped: ['mcp'],
198
+ warnings: ['w'],
199
+ restoreId: '',
200
+ rolledBack: true,
201
+ pendingConflicts: ['mcp'],
202
+ failed: [{ itemId: 'x' }],
203
+ ineffective: [{ itemId: 'y', adapter: 'z' }],
204
+ })!
205
+ assert.equal(v.restoreId, null, '空串 restoreId 不当作可回滚')
206
+ assert.equal(v.rollbackFull, null, '旧宿主缺 rollbackFull 不得当成「完整」')
207
+ assert.equal(v.hasPendingConflicts, true)
208
+ assert.deepEqual(v.pendingConflicts, ['mcp'])
209
+ assert.equal(v.failedCount, 1)
210
+ assert.equal(v.ineffectiveCount, 1)
211
+ assert.equal(v.rolledBack, true)
212
+ })
213
+
214
+ test('merge-view: decideSyncEntry —— 无基准/预览失败一律回退老路径(fail-safe),全 skip 视为已最新', () => {
215
+ const sec = (decision: MergeSectionDecision): MergePreviewSection => ({ id: 'settings', decision, conflictCount: 0, conflicts: [] })
216
+ const ok = (sections: MergePreviewSection[]): Pick<MergePreviewResponse, 'ok' | 'sections'> => ({ ok: true, sections })
217
+ assert.equal(decideSyncEntry(undefined, null), 'fallback', '缺省(旧宿主)= 未知 → 老路径')
218
+ assert.equal(decideSyncEntry(false, null), 'fallback')
219
+ assert.equal(decideSyncEntry('true', null), 'fallback', '非布尔 true 一律回退')
220
+ assert.equal(decideSyncEntry(true, null), 'fallback', '预览缺失 → 老路径')
221
+ assert.equal(decideSyncEntry(true, { ok: false, sections: [] }), 'fallback', '宿主明确未产出 → 老路径')
222
+ assert.equal(decideSyncEntry(true, ok([])), 'upToDate', '无分区 = 无差异')
223
+ assert.equal(decideSyncEntry(true, ok([sec('skip'), sec('skip')])), 'upToDate', '全 skip = 已是最新')
224
+ assert.equal(decideSyncEntry(true, ok([sec('skip'), sec('conflict')])), 'merge')
225
+ assert.equal(decideSyncEntry(true, ok([sec('merge')])), 'merge')
226
+ // 宿主未来版本可能回传新决策值:类型上不可能,运行时必须 fail-closed(用 cast 模拟线上数据)
227
+ assert.equal(decideSyncEntry(true, ok([sec('brand-new' as MergeSectionDecision)])), 'merge', '未知决策 fail-closed 归入需裁决')
228
+ })
229
+
230
+ test('merge-view: needsNoAncestorNotice —— hasAncestor=false 才提示,null 预览不提示', () => {
231
+ assert.equal(needsNoAncestorNotice(null), false)
232
+ assert.equal(needsNoAncestorNotice(preview({ hasAncestor: true })), false)
233
+ assert.equal(needsNoAncestorNotice(preview({ hasAncestor: false })), true)
234
+ })