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
@@ -56,13 +56,15 @@ import { copyTextToClipboard } from '../common/clipboard.ts'
56
56
  import { sectionLabeler } from '../common/section-labels.ts'
57
57
  import { runStore, toSyncStoreSlice, type SyncConfirmDecisions, type SyncStoreSlice } from '../run-store.ts'
58
58
  import type {
59
- AutosyncInterval, SyncApi, SyncPushPayload, SyncStartResponse, SyncStatusResponse,
59
+ AutosyncAutoApplyTier, AutosyncConflictPolicy, AutosyncInterval, MergePreviewResponse, SyncApi,
60
+ SyncPushPayload, SyncStartResponse, SyncStatusResponse,
60
61
  } from './sync-api.ts'
62
+ import { resolveSmartSync } from './sync-entry.ts'
61
63
  import {
62
64
  channelMapOf, channelStateMap, computeGithubLoginView, computeSyncButtons, defaultChannelSyncState,
63
- githubPollMessage, isClientChannel, kindLabel, pullReportView, pushReportView, readStoredChannel,
65
+ githubPollMessage, isClientChannel, pushReportView, readStoredChannel,
64
66
  gistSessionsRiskKey,
65
- initialSyncSections, normalizeSessionsLimit, severityLabel, syncSectionOptions, writeStoredChannel,
67
+ initialSyncSections, normalizeSessionsLimit, syncSectionOptions, writeStoredChannel,
66
68
  } from './sync-view.ts'
67
69
  import type {
68
70
  ChannelSyncState, GithubLoginPhase, SyncChannel, SyncSectionOption,
@@ -91,7 +93,11 @@ import { WorkDirCard } from './WorkDirCard.tsx'
91
93
  import { Collapse } from '../common/Motion.tsx'
92
94
  import { SyncLogList } from './SyncLogList.tsx'
93
95
  import { UnreadableSnapshotsBanner } from './UnreadableSnapshotsBanner.tsx'
96
+ import { SyncProbeRow } from './SyncProbeRow.tsx'
97
+ import { deviceLabel } from './remote-device-view.ts'
94
98
  import { PushPreviewCard } from './PushPreviewCard.tsx'
99
+ import { MergeModal } from './MergeModal.tsx'
100
+ import { SyncActivityBar } from './SyncActivityBar.tsx'
95
101
  import { SyncConfirmView } from './SyncConfirmView.tsx'
96
102
  import css from '../config-manager.module.css'
97
103
 
@@ -171,9 +177,14 @@ interface SyncUiState {
171
177
  savingConfig: boolean
172
178
  busy: 'sync' | 'push' | 'pull' | 'rollback' | null
173
179
  pushReport: SyncPushReport | null
174
- pullReport: SyncPullReport | null
175
180
  /** P0-②:push 前只读预览弹窗(preview 结果 + 打开状态;非敏感,切 tab/刷新不丢) */
176
181
  pushPreview: PushPreviewSlice
182
+ /**
183
+ * 拉取报告(**仅作 run-store 切片的镜像字段**保留):拉取的 UI 路径已整体移除
184
+ * (按钮 / runPull / pullView / 报告弹窗 / pullReportView import 全部删除),
185
+ * 此字段只为满足 SyncStoreSlice 的结构契约;彻底删除需同步改 run-store.ts(已报 lead)。
186
+ */
187
+ pullReport: SyncPullReport | null
177
188
  /** 一键同步差异确认会话(POST /sync/sync 结果;非空时渲染 SyncConfirmView) */
178
189
  confirmSession: SyncStartResponse | null
179
190
  /** 一键同步差异确认的逐项决策(adopted/resolution;与 confirmSession 生命周期绑定,切 tab/刷新不丢) */
@@ -288,8 +299,7 @@ function initFromStore(): SyncUiState {
288
299
  busy: s.busy,
289
300
  savingConfig: s.savingConfig,
290
301
  pushReport: s.pushReport,
291
- pullReport: s.pullReport,
292
- pushPreview: s.pushPreview ?? { preview: null, open: false },
302
+ pushPreview: s.pushPreview ?? { preview: null, open: false },
293
303
  confirmSession: s.confirmSession,
294
304
  confirmDecisions: s.confirmDecisions,
295
305
  lastRestoreId: s.lastRestoreId,
@@ -311,6 +321,13 @@ export function SyncPanel({ api, t, cmT }: SyncPanelProps) {
311
321
  const [channelOpen, setChannelOpen] = useState(false)
312
322
  /** 待确认断开的通道(null = 未打开确认弹窗)——用户要求:配置过的通道必须能删掉 */
313
323
  const [clearTarget, setClearTarget] = useState<SyncChannel | null>(null)
324
+ /**
325
+ * 方案 A:一键同步自动分派命中合并路径时的**受控展开种子**。
326
+ * preview = 那一次只读请求的结果(视图不再二次拉远端快照);signal 变化 = 重建视图(旧裁决作废)。
327
+ */
328
+ const [mergeSeed, setMergeSeed] = useState<{ channel: SyncChannel; preview: MergePreviewResponse } | null>(null)
329
+ /** 一键同步阶段(null = 空闲):'preview' 拉远端快照预览、'sync' 差异确认/导入;只驱动文案 */
330
+ const [syncPhase, setSyncPhase] = useState<'preview' | 'sync' | null>(null)
314
331
  /** 同步分区选择弹窗开关(瞬态 UI:模式与勾选在弹窗内即时生效并持久化,关闭即完成) */
315
332
  const [sectionPickerOpen, setSectionPickerOpen] = useState(false)
316
333
  /**
@@ -501,9 +518,11 @@ export function SyncPanel({ api, t, cmT }: SyncPanelProps) {
501
518
  const all = await api.autosyncStatusAll()
502
519
  patchChannelState('git', {
503
520
  autosync: all.git, autosyncEnabled: all.git.enabled, autosyncInterval: all.git.interval,
521
+ autosyncRealtime: all.git.realtime ?? true,
504
522
  })
505
523
  patchChannelState('webdav', {
506
524
  autosync: all.webdav, autosyncEnabled: all.webdav.enabled, autosyncInterval: all.webdav.interval,
525
+ autosyncRealtime: all.webdav.realtime ?? true,
507
526
  })
508
527
  } catch (err) {
509
528
  // R-20:读取自动同步状态失败(此前写入页面最底部的共享 error Banner,常滚出视野)
@@ -864,7 +883,7 @@ export function SyncPanel({ api, t, cmT }: SyncPanelProps) {
864
883
 
865
884
  /** P0-②:push 前只读预览(弹窗确认流程第一步)——不写远端,只展示「将推送什么」。 */
866
885
  const runPushPreview = async (): Promise<void> => {
867
- patch({ busy: 'push', pushReport: null, pullReport: null })
886
+ patch({ busy: 'push', pushReport: null })
868
887
  try {
869
888
  // 预览前先把加密密码落库(用户不必手动保存;失败只提示,不阻断预览)
870
889
  await persistEncryptPassword()
@@ -879,7 +898,7 @@ export function SyncPanel({ api, t, cmT }: SyncPanelProps) {
879
898
 
880
899
  /** P0-②:确认弹窗里点「确认推送」→ 真正推送(复用既有 push 语义)。 */
881
900
  const runPush = async (): Promise<void> => {
882
- patch({ busy: 'push', pushReport: null, pullReport: null })
901
+ patch({ busy: 'push', pushReport: null })
883
902
  try {
884
903
  // 推送前先把加密密码落库(下次推送不必重输)
885
904
  await persistEncryptPassword()
@@ -907,26 +926,6 @@ export function SyncPanel({ api, t, cmT }: SyncPanelProps) {
907
926
  }
908
927
  }
909
928
 
910
- const runPull = async (): Promise<void> => {
911
- patch({ busy: 'pull', pullReport: null, pushReport: null })
912
- try {
913
- // 解密密码(可选):先落库再拉取,下次不必重输。留空时由 Host 用本机凭据库里
914
- // 已保存的密码 —— 且只在远端快照确实加密时才会被使用(engine.prepareSnapshot 判定)。
915
- await persistDecryptPassword()
916
- const decrypt =
917
- chState.decryptPassword !== '' ? { decryptPassword: chState.decryptPassword } : {}
918
- const report = await api.pull({ ...payload(), ...decrypt })
919
- patch({ busy: null, pullReport: report, token: '', webdavPassword: '' })
920
- patchChannel({ decryptPassword: '' })
921
- // 必须按 report.ok 分流(与 runPush 同口径):校验不通过时宿主仍回 HTTP 200 + ok:false
922
- if (report.ok) toast.ok(t('toast.pullDone'))
923
- else toast.error(t('toast.pullFailed'))
924
- } catch (err) {
925
- patch({ busy: null })
926
- toast.error(t('toast.pullFailed') + t('common.labelSeparator') + redact(err instanceof Error ? err.message : String(err)))
927
- }
928
- }
929
-
930
929
  /* ------------------------------------------------ 同步模式(默认/自定义) */
931
930
 
932
931
  /**
@@ -1051,6 +1050,47 @@ export function SyncPanel({ api, t, cmT }: SyncPanelProps) {
1051
1050
  }
1052
1051
  }
1053
1052
 
1053
+ /**
1054
+ * 方案 A 的入口分派:有基线先做**一次**只读合并预览 —— 有变更直接开裁决视图(复用该次结果);
1055
+ * 全部 skip 提示「已是最新」;无基准 / 请求失败 / ok=false 一律回退老导入确认路径(fail-safe)。
1056
+ */
1057
+ const runSyncSmart = async (channel: SyncChannel): Promise<void> => {
1058
+ // 视觉反馈从**点击那一刻**开始:预览要拉远端快照(整条链最慢的一步),
1059
+ // 此前只有回退分支 runSync() 才设 busy ⇒ 点击后数秒界面毫无动静(用户以为没响应)。
1060
+ // 这里全程占用 busy(顺带挡住并发的 push/切换),并用 syncPhase 区分阶段文案。
1061
+ patch({ busy: 'sync' })
1062
+ setSyncPhase('preview')
1063
+ try {
1064
+ await dispatchSmartSync(channel)
1065
+ } finally {
1066
+ setSyncPhase(null)
1067
+ patch({ busy: null })
1068
+ }
1069
+ }
1070
+
1071
+ /** 一键同步的实际分派(busy/阶段标志由 runSyncSmart 统一持有)。 */
1072
+ const dispatchSmartSync = async (channel: SyncChannel): Promise<void> => {
1073
+ switchChannel(channel)
1074
+ // 与 runSync() 同处置:先清在途的差异确认会话(否则它持有的临时 ZIP 一直留在宿主 syncSessions 里,
1075
+ // 走合并路径时更是没有出口;清掉后 fallback 分支的 runSync() 不会重复 cancel)。
1076
+ const staleConfirm = stateRef.current.confirmSession
1077
+ if (staleConfirm !== null) { try { await api.cancel(staleConfirm.syncSessionId) } catch { /* 尽力清理 */ } patch({ confirmSession: null, confirmDecisions: null }) }
1078
+ const smart = await resolveSmartSync({
1079
+ api,
1080
+ hasBaseline: stateRef.current.statusInfo?.hasBaseline,
1081
+ channelPayload: () => buildSyncChannelBody({ ...formOf(stateRef.current), channel }),
1082
+ decryptPassword: stateRef.current.byChannel[channel].decryptPassword,
1083
+ })
1084
+ if (smart.kind === 'merge') {
1085
+ setMergeSeed({ channel, preview: smart.preview })
1086
+ return
1087
+ }
1088
+ if (smart.kind === 'upToDate') { toast.ok(t('syncflow.upToDate')); return }
1089
+ if (smart.error !== undefined) toast.error(t('merge.toast.previewFailed') + t('common.labelSeparator') + redact(smart.error))
1090
+ setSyncPhase('sync')
1091
+ await runSync()
1092
+ }
1093
+
1054
1094
  /** 用户取消差异确认:清除会话,复位到空闲。 */
1055
1095
  const cancelConfirm = (): void => {
1056
1096
  patch({ confirmSession: null, confirmDecisions: null })
@@ -1099,6 +1139,35 @@ export function SyncPanel({ api, t, cmT }: SyncPanelProps) {
1099
1139
  }
1100
1140
  }
1101
1141
 
1142
+ /**
1143
+ * 更新「真·自动同步」的三个策略项(realtime / autoApplyTier / conflictPolicy)。
1144
+ * 只传要改的字段,其余沿用当前通道状态(显式传 enabled+interval,宿主按 read-modify-write 落盘)。
1145
+ */
1146
+ const updateAutosyncOptions = async (
1147
+ channel: SyncChannel,
1148
+ patch: { realtime?: boolean; autoApplyTier?: AutosyncAutoApplyTier; conflictPolicy?: AutosyncConflictPolicy },
1149
+ ): Promise<void> => {
1150
+ const cs = channelStateOf(channel)
1151
+ if (patch.realtime !== undefined) patchChannelOf(channel, { autosyncRealtime: patch.realtime })
1152
+ try {
1153
+ const updated = await api.autosyncUpdate({
1154
+ transport: channel,
1155
+ enabled: cs.autosyncEnabled,
1156
+ interval: cs.autosyncInterval,
1157
+ ...patch,
1158
+ })
1159
+ patchChannelOf(channel, {
1160
+ autosync: updated,
1161
+ autosyncEnabled: updated.enabled,
1162
+ autosyncInterval: updated.interval,
1163
+ autosyncRealtime: updated.realtime ?? true,
1164
+ })
1165
+ toast.ok(t('toast.autosyncUpdated'))
1166
+ } catch (err) {
1167
+ toast.error(t('toast.autosyncUpdateFailed') + t('common.labelSeparator') + redact(err instanceof Error ? err.message : String(err)))
1168
+ }
1169
+ }
1170
+
1102
1171
  /** 远端是否就绪(四通道统一判据在 ui 层:git/webdav=地址非空,s3/gist=表单校验通过)。 */
1103
1172
  const remoteReadyOf = (channel: SyncChannel): boolean => channelRemoteReady({ ...formOf(state), channel })
1104
1173
  /** 云端点表单的校验问题(对话框内联提示用;git/webdav 恒为空表)。 */
@@ -1106,7 +1175,6 @@ export function SyncPanel({ api, t, cmT }: SyncPanelProps) {
1106
1175
  /** 激活通道的远端就绪态(弹窗 / 页面级判断用) */
1107
1176
  const remoteReady = remoteReadyOf(state.channel)
1108
1177
  const pushView = pushReportView(state.pushReport, uiT)
1109
- const pullView = pullReportView(state.pullReport, uiT)
1110
1178
  const githubView = computeGithubLoginView(
1111
1179
  state.github.phase, state.github.userCode, state.github.verificationUri, state.github.error, uiT,
1112
1180
  )
@@ -1191,22 +1259,30 @@ export function SyncPanel({ api, t, cmT }: SyncPanelProps) {
1191
1259
  )}
1192
1260
  {ready && (
1193
1261
  <Card>
1194
- {/* 动作行:**一键同步**是这一行唯一的主操作(§8 定案)。 */}
1262
+ {/* 动作行(方案 A 定案):用户只面对两个方向 —— 下行「一键同步」/ 上行「推送到远端」。
1263
+ 合并裁决并入一键同步(有基线走三方裁决视图、无基线走老导入确认),不再单独占按钮。 */}
1195
1264
  <div className={css.actionRow}>
1196
1265
  <Button
1197
1266
  variant="primary"
1198
1267
  disabled={busy}
1199
- onClick={() => { switchChannel(channel); void runSync() }}
1268
+ loading={state.busy === 'sync'}
1269
+ onClick={() => { void runSyncSmart(channel) }}
1200
1270
  >
1201
- {state.busy === 'sync' ? <Spinner label={t('syncflow.syncing')} /> : t('syncflow.button')}
1271
+ {state.busy === 'sync'
1272
+ ? (syncPhase === 'preview' ? t('syncflow.checkingRemote') : t('syncflow.syncing'))
1273
+ : t('syncflow.button')}
1202
1274
  </Button>
1203
1275
  <Button disabled={!computeSyncButtons(state.busy, ready, uiT).canPush || githubBusy || !hasSelectedSections(cs) || computeEncryptInvalid(cs)} onClick={() => { switchChannel(channel); void runPushPreview() }}>
1204
1276
  {computeSyncButtons(state.busy, ready, uiT).pushLabel}
1205
1277
  </Button>
1206
- <Button disabled={!computeSyncButtons(state.busy, ready, uiT).canPull || githubBusy} onClick={() => { switchChannel(channel); void runPull() }}>
1207
- {computeSyncButtons(state.busy, ready, uiT).pullLabel}
1208
- </Button>
1278
+ {/* 测试连接(只读自检):与两个主操作同一行、排在最后;结果走 toast(不再占页面高度) */}
1279
+ <SyncProbeRow
1280
+ t={t} api={api} presentation="action" remoteReady={ready} busy={busy}
1281
+ body={buildSyncChannelBody({ ...formOf(state), channel })}
1282
+ />
1209
1283
  </div>
1284
+ {/* 同步在途的活动条:按钮 Spinner 只在按钮上,这条横条让「正在拉远端快照」在整张卡上可见 */}
1285
+ {syncPhase !== null && <SyncActivityBar phase={syncPhase} t={t} />}
1210
1286
  {/* 选择历史快照(该通道的远端快照;缺省 = 最新)。
1211
1287
  支持点击/展开即刷新,另有主动刷新按钮 —— 两个入口都保留(v1 行为不变)。 */}
1212
1288
  <div className={css.snapshotPickerRow}>
@@ -1226,7 +1302,10 @@ export function SyncPanel({ api, t, cmT }: SyncPanelProps) {
1226
1302
  { value: '', label: t('syncflow.latestSnapshot') },
1227
1303
  ...cs.snapshots.map((s) => ({
1228
1304
  value: s.id,
1229
- label: s.id + t('syncflow.snapshotOption', { date: s.createdAt.slice(0, 10), count: String(s.sectionCount) }),
1305
+ label: s.id
1306
+ + t('syncflow.snapshotOption', { date: s.createdAt.slice(0, 10), count: String(s.sectionCount) })
1307
+ // ⑥ 设备身份:只有快照确实带 deviceName 时才追加(旧快照不显示占位空串)
1308
+ + (deviceLabel(s, (device) => t('syncflow.snapshotDevice', { device })) ?? ''),
1230
1309
  })),
1231
1310
  ]}
1232
1311
  />
@@ -1257,6 +1336,7 @@ export function SyncPanel({ api, t, cmT }: SyncPanelProps) {
1257
1336
  busy={busy}
1258
1337
  onToggleAutosync={(enabled) => { void toggleAutosync(channel, enabled) }}
1259
1338
  onUpdateInterval={(interval) => { void updateAutosyncInterval(channel, interval) }}
1339
+ onUpdateOptions={(patch) => { void updateAutosyncOptions(channel, patch) }}
1260
1340
  />
1261
1341
  </Card>
1262
1342
  )}
@@ -1457,57 +1537,20 @@ export function SyncPanel({ api, t, cmT }: SyncPanelProps) {
1457
1537
  </Modal.Footer>
1458
1538
  </Modal>
1459
1539
 
1460
- {/* 拉取差异预览弹窗(Radix Modal) */}
1461
- <Modal
1462
- open={state.pullReport !== null && pullView !== null}
1463
- onClose={() => { patch({ pullReport: null }) }}
1464
- title={t('pull.title')}
1465
- cardStyle={{ width: 'min(720px, 100%)', maxHeight: '85vh' }}
1466
- >
1467
- <Modal.Header
1468
- title={t('pull.title')}
1469
- closeLabel={t('common.close')}
1470
- onClose={() => { patch({ pullReport: null }) }}
1471
- />
1472
- <Modal.Body scroll style={{ maxHeight: '70vh' }}>
1473
- {pullView !== null && (<>
1474
- <Banner kind={pullView.kind === 'ok' ? 'info' : pullView.kind === 'empty' ? 'ok' : 'error'}>
1475
- {pullView.headline}
1476
- </Banner>
1477
- {pullView.summary !== null && (
1478
- <>
1479
- <div className={css.statRow}>
1480
- <Badge kind="info">{t('change.total', { total: pullView.summary.total })}</Badge>
1481
- {pullView.summary.error > 0 && <Badge kind="error">{severityLabel('error', uiT)} × {pullView.summary.error}</Badge>}
1482
- {pullView.summary.warning > 0 && <Badge kind="warn">{severityLabel('warning', uiT)} × {pullView.summary.warning}</Badge>}
1483
- {pullView.summary.info > 0 && <Badge kind="info">{severityLabel('info', uiT)} × {pullView.summary.info}</Badge>}
1484
- </div>
1485
- {pullView.summary.needsReview && <Banner kind="warn">{t('pull.needsReview')}</Banner>}
1486
- <div className={css.pullScroll}>
1487
- <div className={css.reportList}>
1488
- {pullView.summary.items.map((c) => (
1489
- <div key={c.id} className={css.statRow}>
1490
- <span className={css.kindTag}>{kindLabel(c.kind, uiT)}</span>
1491
- <Badge kind={c.severity === 'error' ? 'error' : c.severity === 'warning' ? 'warn' : 'info'}>
1492
- {severityLabel(c.severity, uiT)}
1493
- </Badge>
1494
- <span>{c.description}</span>
1495
- </div>
1496
- ))}
1497
- </div>
1498
- </div>
1499
- </>
1500
- )}
1501
- {pullView.previewHint !== '' && <Banner kind="info">{pullView.previewHint}</Banner>}
1502
- </>)}
1503
- </Modal.Body>
1504
- <Modal.Footer>
1505
- <Button variant="primary" onClick={() => { patch({ pullReport: null }) }}>
1506
- {t('common.close')}
1507
- </Button>
1508
- </Modal.Footer>
1509
- </Modal>
1510
-
1540
+ {/* 方案 A:一键同步命中合并路径 → **弹窗内**裁决(页面不再内联)。
1541
+ 预览来自那一次 /sync/merge-preview(initialPreview,不二次请求);
1542
+ 内容:.mergeScroll 限高内滚 + Modal.Body scroll —— 564×720 画布下不撑出宿主面板滚动条。 */}
1543
+ <MergeModal
1544
+ seed={mergeSeed}
1545
+ onClose={() => { setMergeSeed(null) }}
1546
+ api={api} t={t}
1547
+ busy={state.busy !== null}
1548
+ channelBody={(channel) => buildSyncChannelBody({ ...formOf(stateRef.current), channel })}
1549
+ sectionName={(id) => sectionName(id as SectionId)}
1550
+ decryptPasswordOf={(channel) => stateRef.current.byChannel[channel].decryptPassword}
1551
+ onApplied={() => { void loadStatus() }}
1552
+ onRolledBack={() => { void loadStatus() }}
1553
+ />
1511
1554
  {/* 一键同步差异确认弹窗(Radix Modal;SyncConfirmView 内含取消/确认按钮) */}
1512
1555
  <Modal
1513
1556
  open={state.confirmSession !== null}
@@ -0,0 +1,162 @@
1
+ /**
2
+ * cloud-sync 借鉴项 ②:**测试连接**行(只读连通性自检)。
3
+ *
4
+ * 为什么要它:同步失败时用户此前只能靠「push/pull 报错」反推通道是否可达 —— 端点写错、
5
+ * 桶不存在、凭据没配、目标目录不存在,症状都是同一句失败。这里给一个**非破坏性**自检入口,
6
+ * 并把通道层本地化好的结论直接展示(S3 兼容端点该填哪个 region 这类信息只有通道层知道)。
7
+ *
8
+ * 三条纪律:
9
+ * 1. **失败是正常结论**(HTTP 200 + ok:false)→ 绝不落进 catch 当成崩溃;
10
+ * 2. 自检**可能写远端**(webdav 幂等 MKCOL 补齐上级目录)→ createdCollections 必须逐条说明,
11
+ * 绝不静默(「测试连接」把远端目录建出来是用户有权知道的事实);
12
+ * 3. hint 是「需要注意但没失败」的信息 → 与主结论分开展示(card 形态用 warn 横幅;
13
+ * action 形态追加在同一句 toast 里),不和成功文案混成一句。
14
+ *
15
+ * 两种展示形态(presentation):
16
+ * - 'card'(缺省):动作行 + **内联结果**(Banner / ErrorBanner)—— 独立入口用;
17
+ * - 'action':**只渲染按钮 + ⓘ**(供并入别的动作行),结果改走 toast:主结论 + hint +
18
+ * 自动创建的目录 + 快照数一句讲完,一条都不省。
19
+ *
20
+ * 文案全部走 config-manager-sync 字典;本组件不写任何样式(复用既有原语与卡片内类名)。
21
+ */
22
+ import { useCallback, useEffect, useRef, useState } from 'react'
23
+ import type { ReactNode } from 'react'
24
+ import type { TranslateNS } from '../client-types.ts'
25
+ import type { SyncApi, SyncProbeResult, SyncPushPayload } from './sync-api.ts'
26
+ import { Banner, Button, Spinner } from '../common/ui.tsx'
27
+ import { InfoHint } from '../common/InfoHint.tsx'
28
+ import { projectProbeResult } from './probe-view.ts'
29
+ import { toast } from '../common/toast-store.ts'
30
+ import { ErrorBanner } from '../common/ErrorBanner.tsx'
31
+ import { redact } from '../../security/redaction.ts'
32
+ import css from '../config-manager.module.css'
33
+
34
+ export interface SyncProbeRowProps {
35
+ api: SyncApi
36
+ t: TranslateNS<'config-manager-sync'>
37
+ /** 与 push/pull 同一份通道请求体(由 SyncPanel 组装) */
38
+ body: SyncPushPayload
39
+ /** 该通道远端地址是否就绪(未就绪时按钮禁用) */
40
+ remoteReady: boolean
41
+ /** 页面忙(同步/推送在途)时不发请求 */
42
+ busy: boolean
43
+ /**
44
+ * 展示形态(缺省 'card' = 既有行为,向后兼容):
45
+ * - 'card':动作行 + 内联结果横幅;
46
+ * - 'action':只渲染按钮 + ⓘ(嵌进别的动作行),结果走 toast。
47
+ */
48
+ presentation?: 'card' | 'action'
49
+ }
50
+
51
+ export function SyncProbeRow({ api, t, body, remoteReady, busy, presentation = 'card' }: SyncProbeRowProps): ReactNode {
52
+ const actionMode = presentation === 'action'
53
+ const [result, setResult] = useState<SyncProbeResult | null>(null)
54
+ const [loading, setLoading] = useState(false)
55
+ const /** 传输层异常(与「自检结论 = 失败」分开:前者是宿主/网络没走通,后者是通道层给出的结论) */
56
+ [error, setError] = useState<string | null>(null)
57
+ const seqRef = useRef(0)
58
+ const mountedRef = useRef(true)
59
+ useEffect(() => () => { mountedRef.current = false }, [])
60
+
61
+ /**
62
+ * action 形态的结果通道:**一句 toast 讲完**(主结论 + hint + 自动创建的目录 + 快照数)。
63
+ * createdCollections 绝不省 —— 这是本动作唯一可能写远端的路径,用户有权知道。
64
+ * 中性态(该通道不提供自检)走 ok 通道 + 同一句里的能力边界说明,绝不渲染成「操作失败」。
65
+ */
66
+ const toastResult = useCallback((res: SyncProbeResult): void => {
67
+ const v = projectProbeResult(res, t('probe.unsupported'))
68
+ const parts = [redact(v.message)]
69
+ if (v.hint !== undefined) parts.push(redact(v.hint))
70
+ if (v.unsupported) parts.push(t('probe.unsupportedHint'))
71
+ if (v.createdCollections.length > 0) {
72
+ parts.push(t('probe.createdCollections', { list: v.createdCollections.map((c) => redact(c)).join(t('common.listSeparator')) }))
73
+ }
74
+ if (v.snapshotCount !== undefined) parts.push(t('probe.snapshotCount', { count: String(v.snapshotCount) }))
75
+ const text = parts.join(t('common.labelSeparator'))
76
+ if (v.tone === 'error') toast.error(text)
77
+ else toast.ok(text)
78
+ }, [t])
79
+
80
+ const run = useCallback(async (): Promise<void> => {
81
+ if (!remoteReady) return
82
+ const seq = ++seqRef.current
83
+ setLoading(true)
84
+ try {
85
+ const res = await api.probe(body)
86
+ if (!mountedRef.current || seq !== seqRef.current) return
87
+ setResult(res)
88
+ setError(null)
89
+ if (actionMode) toastResult(res)
90
+ } catch (err) {
91
+ if (!mountedRef.current || seq !== seqRef.current) return
92
+ const message = err instanceof Error ? err.message : String(err)
93
+ setError(message)
94
+ if (actionMode) toast.error(redact(message))
95
+ } finally {
96
+ if (mountedRef.current && seq === seqRef.current) setLoading(false)
97
+ }
98
+ }, [api, body, remoteReady, actionMode, toastResult])
99
+
100
+ /**
101
+ * 渲染规则全在纯函数里(可单测):成功 / hint / 自动创建的目录 / 不支持自检 / 真失败**五态分开**。
102
+ * 兜底文案只在这里解析(纯模块不碰字典):宿主没给 error 时用字典键的本地化文案。
103
+ */
104
+ const view = result === null ? null : projectProbeResult(result, t('probe.unsupported'))
105
+
106
+ const button = (
107
+ <Button onClick={() => { void run() }} disabled={busy || loading || !remoteReady}>
108
+ {loading ? <Spinner label={t('probe.running')} /> : t('probe.run')}
109
+ </Button>
110
+ )
111
+ const hintNode = <InfoHint text={t('probe.hint')} label={t('common.infoHint')} />
112
+
113
+ return (
114
+ <>
115
+ {/* action 形态:直接摊进调用方自己的动作行(不再套一层 .actionRow),结果走 toast */}
116
+ {actionMode
117
+ ? <>{button}{hintNode}</>
118
+ : <div className={css.actionRow}>{button}{hintNode}</div>}
119
+ {!actionMode && error !== null && (
120
+ <ErrorBanner
121
+ error={redact(error)}
122
+ onRetry={() => { void run() }}
123
+ retrying={loading}
124
+ t={api.t}
125
+ />
126
+ )}
127
+ {/* 能力边界**不是**故障:中性 info 横幅 + 一句解释,绝不显示「操作失败」 */}
128
+ {!actionMode && view !== null && view.tone === 'info' && (
129
+ <>
130
+ <Banner kind="info">{redact(view.message)}</Banner>
131
+ <div className={css.hint}>{t('probe.unsupportedHint')}</div>
132
+ </>
133
+ )}
134
+ {!actionMode && view !== null && (view.tone === 'ok' || view.tone === 'warn') && (
135
+ <>
136
+ <Banner kind="ok">{redact(view.message)}</Banner>
137
+ {view.hint !== undefined && <Banner kind="warn">{redact(view.hint)}</Banner>}
138
+ {/* 自检期间自动创建了远端目录:逐条列出(这是唯一可能有写副作用的路径,绝不静默) */}
139
+ {view.createdCollections.length > 0 && (
140
+ <div className={css.hint}>
141
+ {t('probe.createdCollections', {
142
+ list: view.createdCollections.map((c) => redact(c)).join(t('common.listSeparator')),
143
+ })}
144
+ </div>
145
+ )}
146
+ {view.snapshotCount !== undefined && (
147
+ <div className={css.hint}>{t('probe.snapshotCount', { count: String(view.snapshotCount) })}</div>
148
+ )}
149
+ </>
150
+ )}
151
+ {/* 失败 = 诊断结论:ok:false 是**预期结果**,必须当结论展示(绝不是崩溃/未知错误) */}
152
+ {!actionMode && view !== null && view.tone === 'error' && (
153
+ <ErrorBanner
154
+ error={redact(view.message)}
155
+ onRetry={() => { void run() }}
156
+ retrying={loading}
157
+ t={api.t}
158
+ />
159
+ )}
160
+ </>
161
+ )
162
+ }
@@ -65,6 +65,8 @@ test('t42: 组件按职责物理拆分成独立文件(各自导出 + 主文件
65
65
  // issue #91:推送预览卡(五条并列提示带:只读 / 首次基线 / 加密 / 含凭据 / 引擎告警)自成文件,
66
66
  // 主文件只装配 —— 同样以「抽出去」而不是「抬上限」满足棘轮
67
67
  ['PushPreviewCard', 'PushPreviewCard.tsx'],
68
+ // 一键同步的加载反馈:在途活动条是纯展示(无状态无调用),同样以「抽出去」守住棘轮
69
+ ['SyncActivityBar', 'SyncActivityBar.tsx'],
68
70
  ]
69
71
  for (const [name, file] of units) {
70
72
  // 主文件:必须 import 并渲染该单元
@@ -100,7 +102,30 @@ test('t42: 组件按职责物理拆分成独立文件(各自导出 + 主文件
100
102
  // 1580 → 1585(t3:英文界面零中文)。增量是**装配接线**:分区选择弹窗新增 uiT 注入
101
103
  // (分区描述 / 分组说明改为字典键后,弹窗内需要 ui 字典翻译器才能解析;判定逻辑在 ui 层)。
102
104
  // 同一轮把 11 处 toast 的「全角冒号 + 详情」拼接改为 t('common.labelSeparator'),行数不变。
103
- assert.ok(viewLines <= 1585, '主文件行数必须保持在拆分后的量级(实际 ' + viewLines + ' 行)')
105
+ // 1585 → 1595(cloud-sync 借鉴项 ②⑥ + 远程合并裁决入口):新增的组件各自成文件
106
+ // (SyncProbeRow / MergePreviewEntry + 产物库那侧的 deviceLabel),
107
+ // 主文件只多出 import、挂载点与一处「快照标签追加设备名」的拼接 —— 没有可再下沉的纯逻辑
108
+ // (测试连接/设备名的判定与渲染规则在 ui 层,见 probe-view.ts / remote-device-view.ts)。
109
+ // 1595 → 1620(方案 A:同步页按钮瘦身)。增量逐项都是**装配**,且已按「先抽出去」外移:
110
+ // · 一键同步分派:决策与编排分别下沉 merge-view.decideSyncEntry / sync-entry.resolveSmartSync
111
+ // (两者都有 node 单测),主文件只留 switchChannel + 一次 resolveSmartSync + 三个分支;
112
+ // · 受控展开:合并视图改由 mergeSeed(preview + signal)驱动(MergePreviewEntry 增加
113
+ // openSignal / hideEntryButton / initialPreview),主文件只多一个 state 与一次挂载;
114
+ // · 按钮瘦身本身是**净减**(主行 3 → 2 个、拉取降级到快照行、测试连接下移到卡片底部)。
115
+ // 剩下的全是 React 状态与 JSX 位置,没有可再下沉的纯逻辑,故按仓库惯例抬上限并记录理由。
116
+ // 1620 → **1560**(三项 UI 收口:测试连接并入动作行、移除拉取差异预览、一键同步改弹窗)。
117
+ // 这一轮是**净减**,因此按棘轮本意把上限**压回去**(而不是继续抬):
118
+ // · 测试连接:结果走 toast,页面不再有它的一整块内联横幅(组件内 presentation='action' 只出按钮);
119
+ // · 拉取差异预览:按钮、runPull、pullView、报告弹窗、pullReportView import 全部删除;
120
+ // · 一键同步:裁决视图从页面内联改为**弹窗**装配(Modal + 一次 MergePreviewView),
121
+ // 页面侧只多一个 mergeSeed 状态、一个 mergeApplying 状态与一个弹窗块。
122
+ // 1560 不变(加载动画 + 合并弹窗重设计):新增的在途活动条抽成 SyncActivityBar.tsx,
123
+ // 裁决弹窗壳/状态/请求抽成 MergeModal.tsx(主文件只留一次挂载),主文件因此仍然回落。
124
+ // 1560 → 1600(真·自动同步:三项策略开关)。增量逐项都是**装配**,三个控件本身在 AutosyncCard.tsx:
125
+ // · updateAutosyncOptions:一次 api.autosyncUpdate + 两处 patchChannelOf(就地回填 autosync/interval/realtime);
126
+ // · 卡片新增 onUpdateOptions 接线与 import 两个类型(realtime / 分档 / 冲突策略都是通道级状态)。
127
+ // 没有可再下沉的纯逻辑:判定与文案都在 ui 层(sync-settings-view.ts)与字典里,这里只是 state 通道。
128
+ assert.ok(viewLines <= 1600, '主文件行数必须保持在拆分后的量级(实际 ' + viewLines + ' 行)')
104
129
  })
105
130
 
106
131
  test('t42: 通道卡必须是渲染函数(内联组件 = 每次渲染新类型 → 子树重挂、Select 展开态清零)', () => {
@@ -119,7 +144,7 @@ test('工作副本「重建」按钮必须在请求在途时转加载态(用
119
144
  // 重新 clone(可达数十秒),期间用户无从判断是否在执行。守卫钉住三条:在途态由本卡自持、两个出口都转
120
145
  // 加载态并互相禁用、失败也必须收掉加载态;任一条回退即变红。
121
146
  const src = readSource(fileURLToPath(new URL('./WorkDirCard.tsx', import.meta.url)))
122
- assert.match(src, /const \[pending, setPending\] = useState<'relink' \| 'reclone' \| null>\(null\)/, '在途态必须是本卡自持的 relink/reclone 判别式状态')
147
+ assert.match(src, /const \[pending, setPending\] = useState<'relink' \| 'adopt' \| 'reclone' \| null>\(null\)/, '在途态必须是本卡自持的 relink/reclone 判别式状态')
123
148
  assert.match(src, /loading=\{pending === 'reclone'\}/, '重新克隆按钮必须在自身请求在途时显示加载态')
124
149
  assert.match(src, /loading=\{pending === 'relink'\}/, '重指向按钮同理')
125
150
  assert.match(src, /disabled=\{busy \|\| pending !== null\}/, '在途时必须同时禁用两个出口(防重复点击)')