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
@@ -0,0 +1,398 @@
1
+ /**
2
+ * 远程合并裁决(T-ui-merge)—— **纯渲染/裁决模型**。
3
+ *
4
+ * 为什么单独一个文件:仓库纪律要求「React 无组件框架 ⇒ 逻辑提炼到可 node 测的纯函数」,
5
+ * 组件(MergePreviewView.tsx)只做装配。本文件除 type-only 引用与 redact 外零依赖:
6
+ * 既能直接在 node:test 里跑,也不会把宿主 node 模块带进浏览器 bundle。
7
+ *
8
+ * 契约来源(两条新路由,见 src/routes/sync.ts):
9
+ * POST /sync/merge-preview → MergePreviewResponse(只读三方合并预览)
10
+ * POST /sync/apply-merge → MergeApplyResponse (按逐项裁决执行)
11
+ *
12
+ * **裁决粒度 = 分区 id**(Lead 2026-10-18 冻结,不做 'id::path' 复合 id):
13
+ * resolutions: Array<{ id: SectionId; action: 'keepLocal' | 'useRemote' | 'useMerged' }>
14
+ * - 'useMerged' 只对引擎回传 decision === 'merge' 的分区有效;conflict 分区**不渲染该按钮**(不是禁用态);
15
+ * - 界面**不再发送 'skip'**(2026-10-21 用户裁决:跳过既不是解决、又与「什么都不点」等价,
16
+ * 徒增一个会让人误以为已处理的按钮)。需要留到下次的分区**根本不出现在请求体里**:
17
+ * 引擎对未点名的分区按自己的结论应用(skip → 不动;conflict → 进 pendingConflicts),
18
+ * 所以「不在 resolutions 里」天然等于「本次不动、下次还出现」,语义比跳过的 skip 更诚实。
19
+ * 冲突明细行只做展示(path + reason + 三列预览),动作按钮挂在分区行上。
20
+ */
21
+ import { redact } from '../../security/redaction.ts'
22
+ import type { SyncKey } from './sync-locales.ts'
23
+ import type { MergeApplyResponse, MergePreviewResponse, MergePreviewSection } from './sync-api.ts'
24
+
25
+ /** 分区级合并决策(与 src/sync/merge.ts 的 MergeDecision + 'conflict' 同口径)。 */
26
+ export type MergeDecision = 'useRemote' | 'keepLocal' | 'merge' | 'skip' | 'conflict'
27
+
28
+ /**
29
+ * 单条裁决动作(**是 /sync/apply-merge 的 body.resolutions[].action 的子集**)。
30
+ *
31
+ * 宿主契约仍接受 'skip',但界面已不产生它 —— 见文件头「界面不再发送 skip」。
32
+ */
33
+ export type MergeResolutionAction = 'keepLocal' | 'useRemote' | 'useMerged'
34
+
35
+ /** 徽章色调(对齐 common/ui.tsx 的 BadgeKind,避免为纯函数引入 .tsx 类型)。 */
36
+ export type MergeTone = 'ok' | 'info' | 'warn' | 'error'
37
+
38
+ /** 三列预览的截断长度(界面是 564px 画布里的三列,超长内容只会挤爆单元格)。 */
39
+ export const MERGE_PREVIEW_MAX_CHARS = 160
40
+
41
+ /** 决策 → 字典键(**禁止渲染裸枚举**:界面一律走这三个映射函数)。 */
42
+ const DECISION_KEY: Record<MergeDecision, SyncKey> = {
43
+ useRemote: 'merge.decision.useRemote',
44
+ keepLocal: 'merge.decision.keepLocal',
45
+ merge: 'merge.decision.merge',
46
+ skip: 'merge.decision.skip',
47
+ conflict: 'merge.decision.conflict',
48
+ }
49
+
50
+ const DECISION_TONE: Record<MergeDecision, MergeTone> = {
51
+ useRemote: 'info',
52
+ keepLocal: 'info',
53
+ merge: 'ok',
54
+ skip: 'info',
55
+ conflict: 'warn',
56
+ }
57
+
58
+ /** 冲突成因 → 字典键(缺省/未知一律按 both-modified 措辞,不渲染原值)。 */
59
+ const REASON_KEY: Record<string, SyncKey> = {
60
+ 'both-modified': 'merge.reason.bothModified',
61
+ 'modify-delete': 'merge.reason.modifyDelete',
62
+ 'delete-modify': 'merge.reason.deleteModify',
63
+ 'no-ancestor': 'merge.reason.noAncestor',
64
+ 'binary': 'merge.reason.binary',
65
+ 'format': 'merge.reason.format',
66
+ 'array': 'merge.reason.array',
67
+ 'section': 'merge.reason.section',
68
+ }
69
+
70
+ /**
71
+ * 归一化分区决策:宿主回传的字符串可能是未来版本的新值,未知值一律按
72
+ * **需要用户裁决**(conflict)处理 —— fail-closed,绝不把未知状态静默当「无变化」。
73
+ */
74
+ export function normalizeDecision(value: unknown): MergeDecision {
75
+ if (value === 'useRemote' || value === 'keepLocal' || value === 'merge' || value === 'skip' || value === 'conflict') return value
76
+ return 'conflict'
77
+ }
78
+
79
+ /** 决策字典键。 */
80
+ export function mergeDecisionKey(decision: unknown): SyncKey {
81
+ return DECISION_KEY[normalizeDecision(decision)]
82
+ }
83
+
84
+ /** 决策色调。 */
85
+ export function mergeDecisionTone(decision: unknown): MergeTone {
86
+ return DECISION_TONE[normalizeDecision(decision)]
87
+ }
88
+
89
+ /** 冲突成因字典键(未知值 → both-modified 措辞)。 */
90
+ export function mergeReasonKey(reason: unknown): SyncKey {
91
+ const key = typeof reason === 'string' ? REASON_KEY[reason] : undefined
92
+ return key ?? 'merge.reason.bothModified'
93
+ }
94
+
95
+ /** 冲突位置类型字典键(key / file)。 */
96
+ export function mergeKindKey(kind: unknown): SyncKey {
97
+ return kind === 'file' ? 'merge.kind.file' : 'merge.kind.key'
98
+ }
99
+
100
+ /**
101
+ * 预览文本:先过 redact(仓库铁律:展示文本渲染前必须再脱敏一次),再截断。
102
+ * 引擎已做过一次 redact + 2 KiB 截断,这里是**渲染侧**的第二次保险。
103
+ */
104
+ export function truncatePreview(value: unknown, max: number = MERGE_PREVIEW_MAX_CHARS): string {
105
+ if (value === undefined || value === null) return ''
106
+ const text = redact(typeof value === 'string' ? value : String(value))
107
+ return text.length <= max ? text : text.slice(0, max) + '…'
108
+ }
109
+
110
+ /** 单条冲突的渲染行(**只读展示**:动作按钮在分区行上)。 */
111
+ export interface MergeConflictRow {
112
+ path: string
113
+ kindKey: SyncKey
114
+ reasonKey: SyncKey
115
+ localPreview: string
116
+ remotePreview: string
117
+ ancestorPreview: string
118
+ }
119
+
120
+ /** 单个分区的渲染行。 */
121
+ export interface MergeSectionRow {
122
+ id: string
123
+ decision: MergeDecision
124
+ decisionKey: SyncKey
125
+ tone: MergeTone
126
+ /** 该分区是否需要用户裁决(decision === 'conflict') */
127
+ needsDecision: boolean
128
+ conflictCount: number
129
+ /** 自动合并并入的变更点数量(无统计 = null) */
130
+ autoMerged: number | null
131
+ /** 是否存在可用的自动合并结果(**只有 decision === 'merge' 才算**,Lead 冻结口径) */
132
+ canUseMerged: boolean
133
+ conflicts: MergeConflictRow[]
134
+ /** 裁决单 id = 分区 id(分区级裁决,无复合 id) */
135
+ resolutionId: string
136
+ }
137
+
138
+ /**
139
+ * 分区是否存在可用的自动合并结果。
140
+ *
141
+ * **冻结判据**:只有引擎回传 decision === 'merge' 的分区有 merged 载荷;
142
+ * conflict 分区一律 false —— 界面据此禁用「采用自动合并」并给出原因,
143
+ * 而不是让用户点了才发现无效。
144
+ */
145
+ function sectionCanUseMerged(decision: MergeDecision): boolean {
146
+ return decision === 'merge'
147
+ }
148
+
149
+ /** 分区列表 → 渲染行(含冲突行)。sections 为 host 回传原样,顺序保持不变。 */
150
+ export function mergeSectionRows(preview: Pick<MergePreviewResponse, 'sections'> | null | undefined): MergeSectionRow[] {
151
+ const sections = preview?.sections ?? []
152
+ return sections.map((section: MergePreviewSection) => {
153
+ const decision = normalizeDecision(section.decision)
154
+ const conflicts = (section.conflicts ?? []).map((c) => ({
155
+ path: c.path,
156
+ kindKey: mergeKindKey(c.kind),
157
+ reasonKey: mergeReasonKey(c.reason),
158
+ localPreview: truncatePreview(c.localPreview),
159
+ remotePreview: truncatePreview(c.remotePreview),
160
+ ancestorPreview: truncatePreview(c.ancestorPreview),
161
+ }))
162
+ return {
163
+ id: section.id,
164
+ decision,
165
+ decisionKey: DECISION_KEY[decision],
166
+ tone: DECISION_TONE[decision],
167
+ needsDecision: decision === 'conflict',
168
+ conflictCount: typeof section.conflictCount === 'number' ? section.conflictCount : conflicts.length,
169
+ autoMerged: typeof section.autoMerged === 'number' ? section.autoMerged : null,
170
+ canUseMerged: sectionCanUseMerged(decision),
171
+ conflicts,
172
+ resolutionId: section.id,
173
+ }
174
+ })
175
+ }
176
+
177
+ /** 摘要项(可自动应用 / 待裁决 / 冲突 / 无变化)。 */
178
+ export interface MergeSummaryItem {
179
+ labelKey: SyncKey
180
+ value: number
181
+ tone: MergeTone
182
+ }
183
+
184
+ /** 摘要 → 界面项(缺字段按 0,绝不显示 undefined)。 */
185
+ export function mergeSummaryItems(preview: MergePreviewResponse | null | undefined): MergeSummaryItem[] {
186
+ const s = preview?.summary
187
+ const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
188
+ return [
189
+ { labelKey: 'merge.summary.auto', value: num(s?.auto), tone: 'ok' },
190
+ { labelKey: 'merge.summary.review', value: num(s?.review), tone: 'warn' },
191
+ { labelKey: 'merge.summary.conflicts', value: num(s?.conflicts), tone: 'warn' },
192
+ { labelKey: 'merge.summary.skipped', value: num(s?.skipped), tone: 'info' },
193
+ ]
194
+ }
195
+
196
+ /** 裁决草稿:分区 id → 动作(缺省 = 未裁决)。 */
197
+ export type MergeResolutionDraft = Record<string, MergeResolutionAction>
198
+
199
+ /**
200
+ * 初始草稿:**空**(用户什么都不点时请求体里没有任何分区)。
201
+ *
202
+ * 保守缺省依然成立,但不再靠「预置 skip」实现:引擎对未点名的分区按自己的结论处理 ——
203
+ * 无变化的 skip 分区不会写、冲突分区进 pendingConflicts(下次同步还在)。所以空草稿
204
+ * 与「全部预置 skip」在**写入结果**上完全一致,只是少了一个让人以为「已经处理过」的动作。
205
+ */
206
+ export function initialResolutionDraft(_preview?: Pick<MergePreviewResponse, 'sections'> | null): MergeResolutionDraft {
207
+ return {}
208
+ }
209
+
210
+ /** 设置**分区级**裁决(分区 id 就是裁决单 id)。 */
211
+ export function setSectionAction(draft: MergeResolutionDraft, sectionId: string, action: MergeResolutionAction): MergeResolutionDraft {
212
+ return { ...draft, [sectionId]: action }
213
+ }
214
+
215
+ /**
216
+ * 批量类型。
217
+ *
218
+ * - keepLocal / useRemote / useMerged:给分区写显式动作;
219
+ * - leavePending(原「仅处理无冲突项」):把**需要裁决的分区从草稿里删掉**,
220
+ * 即「这些留到下次同步」,本次只应用其余分区。
221
+ */
222
+ export type MergeBulkAction = MergeResolutionAction | 'leavePending'
223
+
224
+ export function applyBulkResolution(
225
+ preview: Pick<MergePreviewResponse, 'sections'> | null | undefined,
226
+ draft: MergeResolutionDraft,
227
+ bulk: MergeBulkAction,
228
+ ): MergeResolutionDraft {
229
+ let next: MergeResolutionDraft = { ...draft }
230
+ for (const row of mergeSectionRows(preview)) {
231
+ if (bulk === 'leavePending') {
232
+ // 需要裁决的分区**移出草稿**(不是写 skip):不进请求体 = 本次不应用、下次仍出现
233
+ if (row.needsDecision) {
234
+ const rest = { ...next }
235
+ delete rest[row.id]
236
+ next = rest
237
+ }
238
+ continue
239
+ }
240
+ if (bulk === 'useMerged') {
241
+ // 冻结口径:只有 decision === 'merge' 的分区有 merged 载荷,其余不因批量操作被改写
242
+ if (!row.canUseMerged) continue
243
+ next = setSectionAction(next, row.id, 'useMerged')
244
+ continue
245
+ }
246
+ // keepLocal / useRemote:批量语义就是「所有分区都按这一侧」,覆盖含自动决策在内的全部分区
247
+ next = setSectionAction(next, row.id, bulk)
248
+ }
249
+ return next
250
+ }
251
+
252
+ /** 裁决 → 请求体(排序保证同输入同请求,便于日志与测试比对)。 */
253
+ export function buildMergeResolutions(draft: MergeResolutionDraft): Array<{ id: string; action: MergeResolutionAction }> {
254
+ return Object.entries(draft)
255
+ .map(([id, action]) => ({ id, action }))
256
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
257
+ }
258
+
259
+ /** 裁决计数(执行前确认摘要用)。 */
260
+ export interface MergeDecisionCounts {
261
+ /** 需要裁决的分区数(decision === 'conflict') */
262
+ needsDecision: number
263
+ /** 已给出显式裁决的待裁决分区数 */
264
+ decided: number
265
+ /** 尚未裁决、本次会留给下次的分区数 */
266
+ undecided: number
267
+ /** 待裁决分区里的冲突条目总数 */
268
+ conflictItems: number
269
+ /** 本次**会被应用**的分区数(有变化且不需裁决,**或**已裁决且裁决会写盘 —— issue #106) */
270
+ applicable: number
271
+ }
272
+
273
+ /** 统计草稿覆盖面:把「已裁决 / 待裁决 / 本次会应用几个」讲清楚。 */
274
+ export function mergeDecisionCounts(
275
+ preview: Pick<MergePreviewResponse, 'sections'> | null | undefined,
276
+ draft: MergeResolutionDraft,
277
+ ): MergeDecisionCounts {
278
+ let needsDecision = 0
279
+ let decided = 0
280
+ let conflictItems = 0
281
+ let applicable = 0
282
+ for (const row of mergeSectionRows(preview)) {
283
+ const action = draft[row.resolutionId]
284
+ if (!row.needsDecision) {
285
+ // 无变化的分区不会有任何写入;其余(自动应用 / 显式结论)都会落盘
286
+ // 例外:批量「全部采用本机 / 远端」会给 skip 分区也写草稿,此时它同样会落盘
287
+ if (row.decision !== 'skip' || decisionWillApply(row, action)) applicable++
288
+ continue
289
+ }
290
+ needsDecision++
291
+ conflictItems += row.conflicts.length > 0 ? row.conflicts.length : row.conflictCount
292
+ if (action !== undefined) {
293
+ decided++
294
+ // 已裁决且裁决会写盘 → 本次真的会应用它(issue #106:否则「0 可自动应用 + 全冲突」时数字永远是 0)
295
+ if (decisionWillApply(row, action)) applicable++
296
+ }
297
+ }
298
+ return { needsDecision, decided, undecided: needsDecision - decided, conflictItems, applicable }
299
+ }
300
+
301
+ /**
302
+ * 单条裁决是否**真的会写盘**(issue #106)。
303
+ *
304
+ * - useMerged:只有存在可用自动合并结果(decision === 'merge')时才有内容可写;
305
+ * - useRemote / keepLocal:都会写盘,选哪侧不影响「会写」这件事。
306
+ */
307
+ export function decisionWillApply(row: MergeSectionRow, action: MergeResolutionAction | undefined): boolean {
308
+ if (action === undefined) return false
309
+ if (action === 'useMerged') return row.canUseMerged
310
+ return true
311
+ }
312
+
313
+ /**
314
+ * 本次是否存在**可应用的分区**(决定「执行合并」主按钮是否可用)。
315
+ *
316
+ * 判据 = 预览自身会写的分区 **或** 已裁决且裁决会写盘的分区(issue #106):
317
+ * 引擎对未点名的分区按自己的结论应用(skip 不动、conflict 进 pendingConflicts),
318
+ * 但只要用户已经逐条裁决了冲突,这次执行就真的会写东西 —— 只看预览会让
319
+ * 「0 可自动应用 + 全是冲突」的合并**永久禁用**,裁决完也点不动。
320
+ */
321
+ export function hasApplicableSections(
322
+ preview: Pick<MergePreviewResponse, 'sections'> | null | undefined,
323
+ draft: MergeResolutionDraft = {},
324
+ ): boolean {
325
+ return mergeSectionRows(preview).some((row) => (
326
+ row.needsDecision
327
+ ? decisionWillApply(row, draft[row.resolutionId])
328
+ : row.decision !== 'skip' || decisionWillApply(row, draft[row.resolutionId])
329
+ ))
330
+ }
331
+
332
+ /** 合并执行结果 → 渲染视图。 */
333
+ export interface MergeResultView {
334
+ ok: boolean
335
+ applied: string[]
336
+ skipped: string[]
337
+ warnings: string[]
338
+ restoreId: string | null
339
+ rolledBack: boolean
340
+ /** 整体回滚是否完整(null = 旧宿主未回传,不得当成「完整」) */
341
+ rollbackFull: boolean | null
342
+ pendingConflicts: string[]
343
+ failedCount: number
344
+ ineffectiveCount: number
345
+ /** 执行后**仍有**待裁决冲突分区(下次同步仍会出现) */
346
+ hasPendingConflicts: boolean
347
+ }
348
+
349
+ /** 结果归一化(字段缺失按 0 / 空数组处理,绝不显示 undefined)。 */
350
+ export function mergeResultView(result: MergeApplyResponse | null | undefined): MergeResultView | null {
351
+ if (result === null || result === undefined) return null
352
+ const list = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [])
353
+ const pending = list(result.pendingConflicts)
354
+ return {
355
+ ok: result.ok === true,
356
+ applied: list(result.applied),
357
+ skipped: list(result.skipped),
358
+ warnings: list(result.warnings),
359
+ restoreId: typeof result.restoreId === 'string' && result.restoreId !== '' ? result.restoreId : null,
360
+ rolledBack: result.rolledBack === true,
361
+ rollbackFull: result.rollbackFull === undefined ? null : result.rollbackFull === true,
362
+ pendingConflicts: pending,
363
+ failedCount: Array.isArray(result.failed) ? result.failed.length : 0,
364
+ ineffectiveCount: Array.isArray(result.ineffective) ? result.ineffective.length : 0,
365
+ hasPendingConflicts: pending.length > 0,
366
+ }
367
+ }
368
+
369
+ /**
370
+ * 是否可按「有基线」走三方裁决:**只有 boolean true 算**(缺省 undefined / 旧宿主 / 非法值一律 false)。
371
+ * 单一判据来源 —— decideSyncEntry 与 sync-entry 的编排共用它,避免两处判据漂移。
372
+ */
373
+ export function hasMergeBaseline(value: unknown): boolean {
374
+ return value === true
375
+ }
376
+
377
+ /**
378
+ * 一键同步入口分派(方案 A)。**纯函数、无副作用**,判据冻结:
379
+ * - hasBaseline 非 true(未知 / 缺省 / 旧宿主)→ 'fallback'(走老导入确认路径);
380
+ * - 预览缺失 / ok !== true → 'fallback'(新入口绝不挡住老路径);
381
+ * - 无分区,或**全部分区 decision === 'skip'** → 'upToDate'(远端与本地一致,不必同步);
382
+ * - 其余 → 'merge'(打开裁决视图;调用方复用已取到的 preview,绝不二次请求)。
383
+ */
384
+ export function decideSyncEntry(
385
+ hasBaseline: unknown,
386
+ preview: Pick<MergePreviewResponse, 'ok' | 'sections'> | null | undefined,
387
+ ): 'fallback' | 'merge' | 'upToDate' {
388
+ if (!hasMergeBaseline(hasBaseline)) return 'fallback'
389
+ if (preview === null || preview === undefined || preview.ok !== true) return 'fallback'
390
+ const sections = preview.sections ?? []
391
+ if (sections.length === 0) return 'upToDate'
392
+ return sections.every((s) => normalizeDecision(s.decision) === 'skip') ? 'upToDate' : 'merge'
393
+ }
394
+
395
+ /** 无共同基准时的降级提示是否需要显示(只读判断,不猜)。 */
396
+ export function needsNoAncestorNotice(preview: MergePreviewResponse | null | undefined): boolean {
397
+ return preview !== null && preview !== undefined && preview.hasAncestor === false
398
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * 连通性自检展示投影单测(node:test + node:assert,零依赖)。
3
+ *
4
+ * 把「ok:false 是结论不是崩溃」「hint 走 warn」「自动创建目录必须逐条列出」三条规则钉住。
5
+ */
6
+ import test from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+
9
+ import { projectProbeResult } from './probe-view.ts';
10
+
11
+ test('成功无附加信息 → ok 级,只回结论', () => {
12
+ const view = projectProbeResult({ ok: true, transport: 'git', message: '连接正常(可读写)' });
13
+ assert.equal(view.tone, 'ok');
14
+ assert.equal(view.message, '连接正常(可读写)');
15
+ assert.equal(view.hint, undefined);
16
+ assert.deepEqual(view.createdCollections, []);
17
+ assert.equal(view.snapshotCount, undefined);
18
+ });
19
+
20
+ test('成功带 hint → warn 级(提醒不等于失败)', () => {
21
+ const view = projectProbeResult({
22
+ ok: true, transport: 's3', message: '连接正常', hint: '该端点要求 region=auto',
23
+ });
24
+ assert.equal(view.tone, 'warn');
25
+ assert.equal(view.hint, '该端点要求 region=auto');
26
+ assert.equal(view.message, '连接正常');
27
+ });
28
+
29
+ test('成功带自动创建的远端目录 → warn 级 + 逐条列出(空串被剔除)', () => {
30
+ const view = projectProbeResult({
31
+ ok: true, transport: 'webdav', message: '已连通', createdCollections: ['dsh-config-manager', '', 'snapshots'],
32
+ });
33
+ assert.equal(view.tone, 'warn');
34
+ assert.deepEqual(view.createdCollections, ['dsh-config-manager', 'snapshots']);
35
+ });
36
+
37
+ test('成功且回传快照数 → 原样透传(0 也要保留,不是「没有」)', () => {
38
+ const view = projectProbeResult({ ok: true, transport: 'git', message: 'ok', snapshotCount: 0 });
39
+ assert.equal(view.snapshotCount, 0);
40
+ assert.equal(view.tone, 'ok');
41
+ });
42
+
43
+ test('不支持自检 → 中性 info 级(能力边界,绝不渲染成「操作失败」)', () => {
44
+ const view = projectProbeResult({
45
+ ok: false, unsupported: true, error: '当前同步通道不支持连通性自检',
46
+ });
47
+ assert.equal(view.tone, 'info');
48
+ assert.equal(view.unsupported, true);
49
+ assert.ok(view.message !== '', 'message 必须非空(界面不能出现空横幅)');
50
+ assert.equal(view.message, '当前同步通道不支持连通性自检');
51
+ assert.deepEqual(view.createdCollections, []);
52
+ assert.equal(view.hint, undefined);
53
+ assert.equal(view.snapshotCount, undefined);
54
+ });
55
+
56
+ test('不支持自检且宿主没给文案 → 用调用点传入的本地化兜底(仍非空)', () => {
57
+ const view = projectProbeResult({ ok: false, unsupported: true, error: '' }, '该通道不提供连通性自检');
58
+ assert.equal(view.tone, 'info');
59
+ assert.equal(view.message, '该通道不提供连通性自检');
60
+ assert.ok(view.message.length > 0);
61
+ });
62
+
63
+ test('unsupported 缺省/false 的失败仍是 error(能力边界不得吃掉真故障)', () => {
64
+ const plain = projectProbeResult({ ok: false, error: 'connect ETIMEDOUT' });
65
+ assert.equal(plain.tone, 'error');
66
+ assert.equal(plain.unsupported, false);
67
+ const explicitFalse = projectProbeResult({ ok: false, unsupported: false, error: 'auth failed' });
68
+ assert.equal(explicitFalse.tone, 'error');
69
+ assert.equal(explicitFalse.message, 'auth failed');
70
+ });
71
+
72
+ test('成功结果 unsupported 恒为 false(不给成功结论挂能力边界标记)', () => {
73
+ const view = projectProbeResult({ ok: true, transport: 'git', message: 'ok' });
74
+ assert.equal(view.unsupported, false);
75
+ assert.equal(view.tone, 'ok');
76
+ });
77
+
78
+ test('失败 → error 级,error 原文当结论(诊断动作的预期结果,绝不丢成异常)', () => {
79
+ const view = projectProbeResult({ ok: false, error: 'connect ETIMEDOUT 1.2.3.4:443' });
80
+ assert.equal(view.tone, 'error');
81
+ assert.equal(view.message, 'connect ETIMEDOUT 1.2.3.4:443');
82
+ assert.deepEqual(view.createdCollections, []);
83
+ assert.equal(view.hint, undefined);
84
+ });
85
+
86
+ test('空 hint 字符串不得产生 warn(不发假提醒)', () => {
87
+ const view = projectProbeResult({ ok: true, transport: 'git', message: 'ok', hint: '' });
88
+ assert.equal(view.tone, 'ok');
89
+ assert.equal(view.hint, undefined);
90
+ });
@@ -0,0 +1,63 @@
1
+ /**
2
+ * 连通性自检结果的**展示投影**(React 无关、可单测)。
3
+ *
4
+ * 为什么单独抽出来:/sync/probe 的渲染规则必须能被逐条证明 ——
5
+ * 1. ok:false 是**预期结论**(诊断动作),必须与「传输层异常」走不同通道展示;
6
+ * 2. hint 是「需要注意但没失败」→ warn 级,不和成功文案混成一句;
7
+ * 3. createdCollections 是**唯一可能写远端**的事实 → 必须逐条列出,绝不静默;
8
+ * 4. **「该通道不提供自检」是能力边界、不是故障** → 中性 info,绝不渲染成「操作失败」。
9
+ *
10
+ * 组件只消费本投影;规则变更只需改这里 + 改测试。
11
+ */
12
+ import type { SyncProbeResult } from './sync-api.ts'
13
+
14
+ /**
15
+ * 投影结果:tone = 横幅语义(与 common/ui 的 Banner 四态同值域)。
16
+ *
17
+ * info 是**中性态**:专用于「该通道不提供自检」(传输层未实现 probe)。
18
+ * 必须与 error(真的没连通)分开 —— 真机反馈过:git 通道点「测试连接」被渲染成红色
19
+ * 「操作失败」,把「没有这项能力」说成了「出故障」。
20
+ */
21
+ export interface ProbeView {
22
+ tone: 'ok' | 'warn' | 'info' | 'error'
23
+ /** 主结论(成功时 = 宿主本地化好的 message;失败/不支持时 = 宿主本地化好的 error) */
24
+ message: string
25
+ /** 该通道不提供连通性自检(能力边界;true 时组件用中性 Banner,绝不显示「操作失败」) */
26
+ unsupported: boolean
27
+ /** 附加提醒(仅成功且宿主给了 hint 时出现)→ warn 级展示 */
28
+ hint?: string
29
+ /** 自检过程中自动创建的远端目录(逐条展示;空数组 = 没写远端) */
30
+ createdCollections: string[]
31
+ /** 远端现有快照数(仅在成功且宿主回传时出现) */
32
+ snapshotCount?: number
33
+ }
34
+
35
+ /**
36
+ * 结果 → 展示投影。
37
+ *
38
+ * fallbackUnsupportedMessage:宿主没给 error 文案时的**本地化兜底**(调用方传 t(...))——
39
+ * 本模块零依赖、不碰字典,文案只在调用点解析(「语言在取文案那一刻解析」的硬规则)。
40
+ */
41
+ export function projectProbeResult(result: SyncProbeResult, fallbackUnsupportedMessage = ''): ProbeView {
42
+ if (!result.ok) {
43
+ // 能力边界优先于失败判定:unsupported=true 一律走中性分支,绝不落进 error
44
+ if (result.unsupported === true) {
45
+ return {
46
+ tone: 'info',
47
+ message: result.error !== '' ? result.error : fallbackUnsupportedMessage,
48
+ unsupported: true,
49
+ createdCollections: [],
50
+ }
51
+ }
52
+ return { tone: 'error', message: result.error, unsupported: false, createdCollections: [] }
53
+ }
54
+ const created = (result.createdCollections ?? []).filter((c) => c !== '')
55
+ return {
56
+ tone: created.length > 0 || (result.hint !== undefined && result.hint !== '') ? 'warn' : 'ok',
57
+ message: result.message,
58
+ unsupported: false,
59
+ ...(result.hint !== undefined && result.hint !== '' ? { hint: result.hint } : {}),
60
+ createdCollections: created,
61
+ ...(result.snapshotCount !== undefined ? { snapshotCount: result.snapshotCount } : {}),
62
+ }
63
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * 远端快照设备身份投影单测(cloud-sync 借鉴项 ⑥)。
3
+ *
4
+ * 钉住的唯一规则:**旧快照没有 deviceName ⇒ 不产出任何文案**(不是空串,也不是「未知设备」)。
5
+ */
6
+ import test from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+
9
+ import { deviceLabel, deviceLabelsById } from './remote-device-view.ts';
10
+
11
+ const format = (device: string): string => `由 ${device} 推送`;
12
+
13
+ test('deviceLabel:有名字才产出文案', () => {
14
+ assert.equal(deviceLabel({ deviceName: 'macbook-pro' }, format), '由 macbook-pro 推送');
15
+ });
16
+
17
+ test('deviceLabel:缺字段 / 空串 / 非字符串一律 undefined(不显示占位)', () => {
18
+ assert.equal(deviceLabel({}, format), undefined);
19
+ assert.equal(deviceLabel({ deviceName: undefined }, format), undefined);
20
+ assert.equal(deviceLabel({ deviceName: '' }, format), undefined);
21
+ assert.equal(deviceLabel({ deviceName: 42 }, format), undefined);
22
+ assert.equal(deviceLabel({ deviceName: null }, format), undefined);
23
+ });
24
+
25
+ test('deviceLabelsById:只收有名字的快照,按 id 建表(缺字段的行不进表)', () => {
26
+ const map = deviceLabelsById([
27
+ { id: 's1', deviceName: 'dev-a' },
28
+ { id: 's2' },
29
+ { id: 's3', deviceName: '' },
30
+ { id: 's4', deviceName: 'dev-b' },
31
+ ], format);
32
+ assert.deepEqual([...map.entries()], [['s1', '由 dev-a 推送'], ['s4', '由 dev-b 推送']]);
33
+ assert.equal(map.size, 2);
34
+ assert.equal(map.get('s2'), undefined);
35
+ });
@@ -0,0 +1,25 @@
1
+ /**
2
+ * 远端快照的**推送方设备身份**投影(React 无关、可单测;cloud-sync 借鉴项 ⑥)。
3
+ *
4
+ * 唯一规则(诚实性):**只有快照真的带非空 \`deviceName\` 才产出文案**。
5
+ * 旧快照没有该字段 —— 界面必须整段不显示,既不显示空串占位,也不把 \`undefined\` 渲染成任何文案。
6
+ */
7
+ /** 单条快照 → 设备文案;缺字段/空串 → undefined(调用方据此不渲染)。 */
8
+ export function deviceLabel(snapshot: { deviceName?: unknown }, format: (device: string) => string): string | undefined {
9
+ const name = snapshot.deviceName
10
+ if (typeof name !== 'string' || name === '') return undefined
11
+ return format(name)
12
+ }
13
+
14
+ /** 快照列表 → id → 设备文案(缺字段的行不进表,绝不用空串占位)。 */
15
+ export function deviceLabelsById(
16
+ snapshots: readonly { id: string; deviceName?: unknown }[],
17
+ format: (device: string) => string,
18
+ ): Map<string, string> {
19
+ const map = new Map<string, string>()
20
+ for (const snapshot of snapshots) {
21
+ const label = deviceLabel(snapshot, format)
22
+ if (label !== undefined) map.set(snapshot.id, label)
23
+ }
24
+ return map
25
+ }
@@ -118,13 +118,15 @@ test('S-04 错误映射:4xx 携带 error → ConfigManagerApiError', async ()
118
118
  });
119
119
  });
120
120
 
121
- test('S-05 服务未挂载:404(非 JSON 体)→ ConfigManagerApiError 提示插件未加载', async () => {
122
- // 路由不存在时宿主返回 404 但无 JSON error → readJson 兜底为「插件未挂载」提示
121
+ test('S-05 接口路径未命中:404(非 JSON 体)→ 提示请求前缀而不是「插件未挂载」(issue #108)', async () => {
122
+ // 网关对未知路径回 HTML 404:这是「前缀没转发到插件」,不是「插件没装」
123
123
  installFetchMock(() => new Response('Not Found', { status: 404 }));
124
124
  const api = new SyncApi(zhUiT);
125
125
  await assert.rejects(api.status(), (err: unknown) => {
126
126
  assert.ok(err instanceof ConfigManagerApiError);
127
- assert.match(err.message, /未挂载/);
127
+ assert.match(err.message, /接口路径未命中/);
128
+ assert.match(err.message, /\/api\/dsh-config-manager/);
129
+ assert.doesNotMatch(err.message, /未挂载/);
128
130
  return true;
129
131
  });
130
132
  });