canopycms 0.0.68-int.98 → 0.1.0

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 (688) hide show
  1. package/CHANGELOG.md +2131 -0
  2. package/README.md +47 -63
  3. package/dist/ai/generate.d.ts +5 -0
  4. package/dist/ai/generate.js +244 -115
  5. package/dist/ai/index.d.ts +1 -1
  6. package/dist/ai/json-to-markdown.d.ts +21 -1
  7. package/dist/ai/json-to-markdown.js +120 -57
  8. package/dist/ai/references.d.ts +72 -0
  9. package/dist/ai/references.js +223 -0
  10. package/dist/ai/resolve-branch.js +9 -2
  11. package/dist/ai/to-plain-text.d.ts +17 -8
  12. package/dist/ai/to-plain-text.js +18 -15
  13. package/dist/ai/types.d.ts +84 -3
  14. package/dist/api/admin-branch-health.d.ts +24 -1
  15. package/dist/api/admin-branch-health.js +52 -20
  16. package/dist/api/admin.d.ts +23 -5
  17. package/dist/api/admin.js +36 -1
  18. package/dist/api/assets.js +88 -78
  19. package/dist/api/branch-create-window.d.ts +12 -0
  20. package/dist/api/branch-create-window.js +13 -0
  21. package/dist/api/branch-merge.d.ts +1 -1
  22. package/dist/api/branch-merge.js +1 -1
  23. package/dist/api/branch-review.d.ts +3 -3
  24. package/dist/api/branch-review.js +16 -7
  25. package/dist/api/branch-status.d.ts +16 -6
  26. package/dist/api/branch-status.js +57 -12
  27. package/dist/api/branch-withdraw.d.ts +2 -2
  28. package/dist/api/branch-withdraw.js +8 -3
  29. package/dist/api/branch.d.ts +25 -12
  30. package/dist/api/branch.js +164 -103
  31. package/dist/api/client.d.ts +78 -58
  32. package/dist/api/client.js +150 -117
  33. package/dist/api/comments.d.ts +12 -3
  34. package/dist/api/comments.js +118 -9
  35. package/dist/api/content.d.ts +9 -9
  36. package/dist/api/content.js +131 -22
  37. package/dist/api/entries-constants.d.ts +14 -3
  38. package/dist/api/entries-constants.js +14 -3
  39. package/dist/api/entries.d.ts +40 -2
  40. package/dist/api/entries.js +102 -6
  41. package/dist/api/github-sync.d.ts +19 -0
  42. package/dist/api/github-sync.js +50 -3
  43. package/dist/api/groups.d.ts +4 -4
  44. package/dist/api/groups.js +2 -1
  45. package/dist/api/guards.js +23 -3
  46. package/dist/api/index.d.ts +3 -1
  47. package/dist/api/index.js +1 -0
  48. package/dist/api/permissions.d.ts +21 -2
  49. package/dist/api/permissions.js +50 -3
  50. package/dist/api/request-deadline.d.ts +59 -0
  51. package/dist/api/request-deadline.js +73 -0
  52. package/dist/api/resolve-references.js +8 -7
  53. package/dist/api/route-builder.d.ts +14 -0
  54. package/dist/api/route-builder.js +14 -1
  55. package/dist/api/schema.d.ts +11 -9
  56. package/dist/api/schema.js +2 -0
  57. package/dist/api/settings-helpers.d.ts +5 -1
  58. package/dist/api/settings-helpers.js +10 -2
  59. package/dist/api/types.d.ts +18 -0
  60. package/dist/api/users-constants.d.ts +6 -0
  61. package/dist/api/users-constants.js +6 -0
  62. package/dist/assets/asset-url.d.ts +28 -16
  63. package/dist/assets/asset-url.js +51 -25
  64. package/dist/assets/finalize.js +3 -0
  65. package/dist/assets/index.d.ts +1 -1
  66. package/dist/assets/materialize.d.ts +153 -0
  67. package/dist/assets/materialize.js +402 -0
  68. package/dist/assets/store-local.d.ts +19 -5
  69. package/dist/assets/store-local.js +69 -10
  70. package/dist/assets/store-s3.d.ts +38 -7
  71. package/dist/assets/store-s3.js +128 -22
  72. package/dist/assets/transform-directives.d.ts +52 -13
  73. package/dist/assets/transform-directives.js +87 -27
  74. package/dist/assets/transform.d.ts +3 -3
  75. package/dist/assets/transform.js +13 -8
  76. package/dist/assets/types.d.ts +31 -4
  77. package/dist/auth/cache.d.ts +1 -1
  78. package/dist/auth/cache.js +1 -1
  79. package/dist/auth/caching-auth-plugin.d.ts +2 -0
  80. package/dist/auth/caching-auth-plugin.js +12 -2
  81. package/dist/auth/file-based-auth-cache.d.ts +8 -0
  82. package/dist/auth/file-based-auth-cache.js +18 -8
  83. package/dist/auth/person-name.d.ts +5 -0
  84. package/dist/auth/person-name.js +8 -0
  85. package/dist/auth/plugin.d.ts +10 -1
  86. package/dist/auth/types.d.ts +2 -0
  87. package/dist/auth/user-metadata-lookup.d.ts +21 -0
  88. package/dist/auth/user-metadata-lookup.js +116 -0
  89. package/dist/authorization/branch.d.ts +1 -1
  90. package/dist/authorization/branch.js +12 -13
  91. package/dist/authorization/groups/schema.d.ts +4 -4
  92. package/dist/authorization/helpers.d.ts +8 -0
  93. package/dist/authorization/helpers.js +8 -0
  94. package/dist/authorization/index.d.ts +1 -1
  95. package/dist/authorization/index.js +1 -1
  96. package/dist/authorization/protected-branch.d.ts +5 -4
  97. package/dist/authorization/protected-branch.js +7 -5
  98. package/dist/branch-health.d.ts +35 -8
  99. package/dist/branch-health.js +54 -18
  100. package/dist/branch-metadata-error.d.ts +27 -0
  101. package/dist/branch-metadata-error.js +23 -0
  102. package/dist/branch-metadata-file.d.ts +205 -24
  103. package/dist/branch-metadata-file.js +126 -25
  104. package/dist/branch-metadata.d.ts +45 -6
  105. package/dist/branch-metadata.js +142 -50
  106. package/dist/branch-provisioning.d.ts +167 -0
  107. package/dist/branch-provisioning.js +501 -0
  108. package/dist/branch-registry.d.ts +8 -0
  109. package/dist/branch-registry.js +61 -9
  110. package/dist/branch-schema-cache.d.ts +29 -12
  111. package/dist/branch-schema-cache.js +64 -21
  112. package/dist/branch-sparse.d.ts +23 -0
  113. package/dist/branch-sparse.js +118 -0
  114. package/dist/branch-workspace.d.ts +42 -3
  115. package/dist/branch-workspace.js +239 -100
  116. package/dist/build/asset-refs.d.ts +94 -0
  117. package/dist/build/asset-refs.js +267 -0
  118. package/dist/build/generate-ai-content.js +5 -0
  119. package/dist/build/inline-flight.d.ts +37 -0
  120. package/dist/build/inline-flight.js +142 -0
  121. package/dist/cli/asset-refs.d.ts +52 -0
  122. package/dist/cli/asset-refs.js +194 -0
  123. package/dist/cli/cli.d.ts +15 -1
  124. package/dist/cli/cli.js +8543 -3193
  125. package/dist/cli/configured-asset-store.d.ts +12 -0
  126. package/dist/cli/configured-asset-store.js +46 -0
  127. package/dist/cli/generate-ai-content.js +4244 -1072
  128. package/dist/cli/init.js +217 -126
  129. package/dist/cli/project-detect.d.ts +2 -0
  130. package/dist/cli/project-detect.js +2 -0
  131. package/dist/cli/template-files/Dockerfile.cms.template +13 -5
  132. package/dist/cli/template-files/cms-stack.ts.template +16 -13
  133. package/dist/client.d.ts +1 -2
  134. package/dist/client.js +1 -2
  135. package/dist/comment-store.d.ts +25 -0
  136. package/dist/comment-store.js +43 -8
  137. package/dist/config/flatten.js +2 -0
  138. package/dist/config/helpers.js +2 -2
  139. package/dist/config/index.d.ts +1 -1
  140. package/dist/config/schemas/config.d.ts +12 -29
  141. package/dist/config/schemas/config.js +9 -12
  142. package/dist/config/schemas/field.d.ts +3 -0
  143. package/dist/config/schemas/field.js +58 -4
  144. package/dist/config/schemas/media.d.ts +4 -13
  145. package/dist/config/schemas/media.js +5 -8
  146. package/dist/config/schemas/url.d.ts +2 -20
  147. package/dist/config/schemas/url.js +2 -25
  148. package/dist/config/types.d.ts +116 -22
  149. package/dist/config/types.js +3 -9
  150. package/dist/config/validation.d.ts +25 -4
  151. package/dist/config/validation.js +116 -3
  152. package/dist/content-id-index.js +2 -2
  153. package/dist/content-index-generation.d.ts +1 -1
  154. package/dist/content-index-generation.js +1 -1
  155. package/dist/content-listing.d.ts +10 -1
  156. package/dist/content-listing.js +22 -5
  157. package/dist/content-reader.d.ts +9 -0
  158. package/dist/content-reader.js +16 -11
  159. package/dist/content-store.d.ts +75 -12
  160. package/dist/content-store.js +249 -86
  161. package/dist/content-tree.js +2 -2
  162. package/dist/context.js +5 -2
  163. package/dist/dangling-reference-log.d.ts +17 -0
  164. package/dist/dangling-reference-log.js +29 -0
  165. package/dist/dev-content-watcher.d.ts +1 -1
  166. package/dist/dev-content-watcher.js +1 -1
  167. package/dist/editor/BranchManager.d.ts +23 -7
  168. package/dist/editor/BranchManager.js +119 -54
  169. package/dist/editor/CanopyEditor.d.ts +1 -1
  170. package/dist/editor/CanopyEditor.js +1 -2
  171. package/dist/editor/CommentsPanel.d.ts +14 -0
  172. package/dist/editor/CommentsPanel.js +68 -43
  173. package/dist/editor/Editor.d.ts +11 -5
  174. package/dist/editor/Editor.js +891 -218
  175. package/dist/editor/EditorAuthGate.d.ts +7 -1
  176. package/dist/editor/EditorAuthGate.js +41 -9
  177. package/dist/editor/EntryNavigator.d.ts +22 -0
  178. package/dist/editor/EntryNavigator.js +353 -105
  179. package/dist/editor/FormHeader.d.ts +25 -0
  180. package/dist/editor/FormHeader.js +82 -0
  181. package/dist/editor/FormRenderer.d.ts +27 -3
  182. package/dist/editor/FormRenderer.js +171 -76
  183. package/dist/editor/PreviewFrame.d.ts +40 -0
  184. package/dist/editor/PreviewFrame.js +215 -0
  185. package/dist/editor/PreviewPane.d.ts +47 -0
  186. package/dist/editor/PreviewPane.js +147 -0
  187. package/dist/editor/PreviewToolbarTools.d.ts +22 -0
  188. package/dist/editor/PreviewToolbarTools.js +73 -0
  189. package/dist/editor/admin/SystemHealthPanel.d.ts +2 -1
  190. package/dist/editor/admin/SystemHealthPanel.js +163 -33
  191. package/dist/editor/admin/useSystemHealth.d.ts +18 -0
  192. package/dist/editor/admin/useSystemHealth.js +70 -1
  193. package/dist/editor/branch-status.d.ts +17 -0
  194. package/dist/editor/branch-status.js +29 -0
  195. package/dist/editor/canopy-path.d.ts +9 -6
  196. package/dist/editor/canopy-path.js +8 -11
  197. package/dist/editor/client-reference-resolver.d.ts +43 -16
  198. package/dist/editor/client-reference-resolver.js +155 -64
  199. package/dist/editor/comments/BranchComments.d.ts +1 -1
  200. package/dist/editor/comments/BranchComments.js +2 -2
  201. package/dist/editor/comments/CloseRefusedNotice.d.ts +4 -0
  202. package/dist/editor/comments/CloseRefusedNotice.js +7 -0
  203. package/dist/editor/comments/CommentThreadsPopover.d.ts +47 -0
  204. package/dist/editor/comments/CommentThreadsPopover.js +274 -0
  205. package/dist/editor/comments/CommentWriteAlert.d.ts +11 -0
  206. package/dist/editor/comments/CommentWriteAlert.js +13 -0
  207. package/dist/editor/comments/EntryComments.d.ts +10 -4
  208. package/dist/editor/comments/EntryComments.js +14 -17
  209. package/dist/editor/comments/FieldWrapper.d.ts +18 -4
  210. package/dist/editor/comments/FieldWrapper.js +62 -28
  211. package/dist/editor/comments/InlineCommentThread.d.ts +9 -2
  212. package/dist/editor/comments/InlineCommentThread.js +11 -47
  213. package/dist/editor/comments/ThreadCarousel.d.ts +1 -1
  214. package/dist/editor/comments/ThreadCarousel.js +18 -21
  215. package/dist/editor/comments/ThreadReplyControls.d.ts +28 -0
  216. package/dist/editor/comments/ThreadReplyControls.js +92 -0
  217. package/dist/editor/comments/field-comment-context.d.ts +14 -0
  218. package/dist/editor/comments/field-comment-context.js +14 -0
  219. package/dist/editor/comments/form-comments.d.ts +48 -0
  220. package/dist/editor/comments/form-comments.js +178 -0
  221. package/dist/editor/comments/item-anchor-copy.d.ts +6 -0
  222. package/dist/editor/comments/item-anchor-copy.js +6 -0
  223. package/dist/editor/comments/move-focus-if-unmoved.d.ts +6 -0
  224. package/dist/editor/comments/move-focus-if-unmoved.js +11 -0
  225. package/dist/editor/comments/popover-placement.d.ts +18 -0
  226. package/dist/editor/comments/popover-placement.js +58 -0
  227. package/dist/editor/comments/use-tracked-writes.d.ts +16 -0
  228. package/dist/editor/comments/use-tracked-writes.js +53 -0
  229. package/dist/editor/components/BranchAccessList.d.ts +15 -0
  230. package/dist/editor/components/BranchAccessList.js +52 -0
  231. package/dist/editor/components/BranchLockBanner.d.ts +22 -0
  232. package/dist/editor/components/BranchLockBanner.js +39 -0
  233. package/dist/editor/components/BranchPill.d.ts +32 -0
  234. package/dist/editor/components/BranchPill.js +33 -0
  235. package/dist/editor/components/BranchesDrawer.d.ts +11 -0
  236. package/dist/editor/components/BranchesDrawer.js +5 -0
  237. package/dist/editor/components/ConfirmDeleteModal.d.ts +6 -1
  238. package/dist/editor/components/ConfirmDeleteModal.js +3 -2
  239. package/dist/editor/components/DestructiveMenuItem.d.ts +7 -0
  240. package/dist/editor/components/DestructiveMenuItem.js +8 -0
  241. package/dist/editor/components/EditorCrashScreen.d.ts +17 -0
  242. package/dist/editor/components/EditorCrashScreen.js +22 -0
  243. package/dist/editor/components/EditorErrorBoundary.d.ts +38 -0
  244. package/dist/editor/components/EditorErrorBoundary.js +40 -0
  245. package/dist/editor/components/EditorHeader.d.ts +56 -106
  246. package/dist/editor/components/EditorHeader.js +130 -131
  247. package/dist/editor/components/EditorRail.d.ts +49 -0
  248. package/dist/editor/components/EditorRail.js +65 -0
  249. package/dist/editor/components/EntryBreadcrumb.d.ts +33 -0
  250. package/dist/editor/components/EntryBreadcrumb.js +56 -0
  251. package/dist/editor/components/EntryCreateModal.d.ts +12 -7
  252. package/dist/editor/components/EntryCreateModal.js +51 -57
  253. package/dist/editor/components/NoEditPermissionNotice.d.ts +6 -5
  254. package/dist/editor/components/NoEditPermissionNotice.js +5 -4
  255. package/dist/editor/components/QuickOpenPalette.d.ts +22 -0
  256. package/dist/editor/components/QuickOpenPalette.js +66 -0
  257. package/dist/editor/components/ReadOnlyDraftNotice.d.ts +11 -0
  258. package/dist/editor/components/ReadOnlyDraftNotice.js +12 -0
  259. package/dist/editor/components/ReasonedMenuItem.d.ts +15 -0
  260. package/dist/editor/components/ReasonedMenuItem.js +15 -0
  261. package/dist/editor/components/ReferencedByList.d.ts +11 -0
  262. package/dist/editor/components/ReferencedByList.js +28 -0
  263. package/dist/editor/components/RelativeTime.d.ts +10 -0
  264. package/dist/editor/components/RelativeTime.js +10 -0
  265. package/dist/editor/components/RenameEntryModal.d.ts +9 -1
  266. package/dist/editor/components/RenameEntryModal.js +19 -31
  267. package/dist/editor/components/RetryToastMessage.d.ts +9 -0
  268. package/dist/editor/components/RetryToastMessage.js +6 -0
  269. package/dist/editor/components/SaveAllResult.d.ts +25 -0
  270. package/dist/editor/components/SaveAllResult.js +86 -0
  271. package/dist/editor/components/SaveSplitButton.d.ts +30 -0
  272. package/dist/editor/components/SaveSplitButton.js +108 -0
  273. package/dist/editor/components/StagedChangesDrawer.d.ts +39 -0
  274. package/dist/editor/components/StagedChangesDrawer.js +92 -0
  275. package/dist/editor/components/UnavailableEntryNotice.d.ts +6 -0
  276. package/dist/editor/components/UnavailableEntryNotice.js +9 -0
  277. package/dist/editor/components/UnavailableTypeMessage.d.ts +6 -0
  278. package/dist/editor/components/UnavailableTypeMessage.js +9 -0
  279. package/dist/editor/components/UndoToastMessage.d.ts +18 -0
  280. package/dist/editor/components/UndoToastMessage.js +10 -0
  281. package/dist/editor/components/UserBadge.d.ts +2 -5
  282. package/dist/editor/components/UserBadge.js +62 -33
  283. package/dist/editor/components/ValidationSummary.d.ts +14 -0
  284. package/dist/editor/components/ValidationSummary.js +24 -0
  285. package/dist/editor/components/index.d.ts +2 -2
  286. package/dist/editor/components/index.js +2 -2
  287. package/dist/editor/context/ApiClientContext.d.ts +8 -2
  288. package/dist/editor/context/ApiClientContext.js +19 -8
  289. package/dist/editor/context/AssetContext.d.ts +13 -35
  290. package/dist/editor/context/AssetContext.js +12 -31
  291. package/dist/editor/context/EditorStateContext.d.ts +4 -4
  292. package/dist/editor/context/EditorStateContext.js +4 -4
  293. package/dist/editor/context/SWRProvider.d.ts +3 -3
  294. package/dist/editor/context/SiteMdxAllowContext.d.ts +4 -0
  295. package/dist/editor/context/SiteMdxAllowContext.js +7 -0
  296. package/dist/editor/context/index.d.ts +2 -1
  297. package/dist/editor/context/index.js +2 -1
  298. package/dist/editor/contrast.d.ts +19 -0
  299. package/dist/editor/contrast.js +41 -0
  300. package/dist/editor/copy.d.ts +18 -0
  301. package/dist/editor/copy.js +18 -0
  302. package/dist/editor/editor-config.js +16 -0
  303. package/dist/editor/editor-utils.d.ts +34 -8
  304. package/dist/editor/editor-utils.js +42 -20
  305. package/dist/editor/entry-search.d.ts +20 -0
  306. package/dist/editor/entry-search.js +34 -0
  307. package/dist/editor/field-breadcrumb.d.ts +12 -0
  308. package/dist/editor/field-breadcrumb.js +85 -0
  309. package/dist/editor/field-props.d.ts +86 -0
  310. package/dist/editor/field-props.js +27 -0
  311. package/dist/editor/field-target.d.ts +30 -0
  312. package/dist/editor/field-target.js +77 -0
  313. package/dist/editor/fields/BlockField.d.ts +12 -0
  314. package/dist/editor/fields/BlockField.js +89 -73
  315. package/dist/editor/fields/CodeField.d.ts +2 -0
  316. package/dist/editor/fields/CodeField.js +6 -2
  317. package/dist/editor/fields/DateTimeField.d.ts +4 -29
  318. package/dist/editor/fields/DateTimeField.js +79 -41
  319. package/dist/editor/fields/ExpandAllButton.d.ts +6 -0
  320. package/dist/editor/fields/ExpandAllButton.js +4 -0
  321. package/dist/editor/fields/FieldCrashFallback.d.ts +17 -0
  322. package/dist/editor/fields/FieldCrashFallback.js +30 -0
  323. package/dist/editor/fields/FieldLabel.d.ts +37 -0
  324. package/dist/editor/fields/FieldLabel.js +19 -0
  325. package/dist/editor/fields/ImageField.d.ts +4 -0
  326. package/dist/editor/fields/ImageField.js +29 -14
  327. package/dist/editor/fields/InlineGroupField.js +4 -3
  328. package/dist/editor/fields/ItemList.d.ts +42 -0
  329. package/dist/editor/fields/ItemList.js +130 -0
  330. package/dist/editor/fields/MarkdownField.d.ts +32 -0
  331. package/dist/editor/fields/MarkdownField.js +223 -46
  332. package/dist/editor/fields/MdxImageDialog.js +1 -1
  333. package/dist/editor/fields/NumberField.d.ts +2 -0
  334. package/dist/editor/fields/NumberField.js +8 -5
  335. package/dist/editor/fields/NumberListField.d.ts +2 -0
  336. package/dist/editor/fields/NumberListField.js +19 -16
  337. package/dist/editor/fields/ObjectField.d.ts +2 -7
  338. package/dist/editor/fields/ObjectField.js +15 -5
  339. package/dist/editor/fields/ObjectListField.d.ts +29 -0
  340. package/dist/editor/fields/ObjectListField.js +57 -0
  341. package/dist/editor/fields/ReferenceField.d.ts +2 -0
  342. package/dist/editor/fields/ReferenceField.js +34 -9
  343. package/dist/editor/fields/SelectField.d.ts +2 -0
  344. package/dist/editor/fields/SelectField.js +11 -2
  345. package/dist/editor/fields/StringListField.d.ts +4 -1
  346. package/dist/editor/fields/StringListField.js +22 -8
  347. package/dist/editor/fields/TextField.d.ts +2 -0
  348. package/dist/editor/fields/TextField.js +6 -2
  349. package/dist/editor/fields/ToggleField.d.ts +2 -0
  350. package/dist/editor/fields/ToggleField.js +8 -3
  351. package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
  352. package/dist/editor/fields/entry-link/InsertEntryLink.js +1 -1
  353. package/dist/editor/fields/form-expansion.d.ts +9 -0
  354. package/dist/editor/fields/form-expansion.js +2 -0
  355. package/dist/editor/fields/item-expansion.d.ts +15 -0
  356. package/dist/editor/fields/item-expansion.js +60 -0
  357. package/dist/editor/fields/list-item-keys.d.ts +8 -0
  358. package/dist/editor/fields/list-item-keys.js +50 -0
  359. package/dist/editor/fields/list-item-title.d.ts +20 -0
  360. package/dist/editor/fields/list-item-title.js +85 -0
  361. package/dist/editor/fields/markdown-export-options.d.ts +2 -0
  362. package/dist/editor/fields/markdown-export-options.js +8 -0
  363. package/dist/editor/fields/markdown-fidelity-visitors.d.ts +11 -0
  364. package/dist/editor/fields/markdown-fidelity-visitors.js +160 -0
  365. package/dist/editor/fields/mdx-jsx-support.d.ts +5 -1
  366. package/dist/editor/fields/mdx-jsx-support.js +157 -69
  367. package/dist/editor/fields/rich-text-failures.d.ts +5 -0
  368. package/dist/editor/fields/rich-text-failures.js +26 -0
  369. package/dist/editor/fields/toolbar-wrap.d.ts +7 -0
  370. package/dist/editor/fields/toolbar-wrap.js +35 -0
  371. package/dist/editor/form-outline.d.ts +13 -0
  372. package/dist/editor/form-outline.js +70 -0
  373. package/dist/editor/group-manager/GroupCard.d.ts +4 -1
  374. package/dist/editor/group-manager/GroupCard.js +12 -3
  375. package/dist/editor/group-manager/GroupForm.js +1 -1
  376. package/dist/editor/group-manager/InternalGroupsTab.d.ts +2 -1
  377. package/dist/editor/group-manager/InternalGroupsTab.js +4 -4
  378. package/dist/editor/group-manager/MemberList.d.ts +2 -1
  379. package/dist/editor/group-manager/MemberList.js +15 -4
  380. package/dist/editor/group-manager/hooks/useGroupState.d.ts +5 -0
  381. package/dist/editor/group-manager/hooks/useGroupState.js +30 -7
  382. package/dist/editor/group-manager/index.js +34 -5
  383. package/dist/editor/group-manager/types.d.ts +5 -2
  384. package/dist/editor/hooks/create-branch-request.d.ts +20 -0
  385. package/dist/editor/hooks/create-branch-request.js +54 -0
  386. package/dist/editor/hooks/save-all.d.ts +68 -0
  387. package/dist/editor/hooks/save-all.js +61 -0
  388. package/dist/editor/hooks/useBranchActions.d.ts +11 -1
  389. package/dist/editor/hooks/useBranchActions.js +42 -19
  390. package/dist/editor/hooks/useBranchManager.d.ts +21 -0
  391. package/dist/editor/hooks/useBranchManager.js +321 -125
  392. package/dist/editor/hooks/useBranchesData.d.ts +8 -4
  393. package/dist/editor/hooks/useBranchesData.js +9 -5
  394. package/dist/editor/hooks/useCommentSystem.d.ts +14 -2
  395. package/dist/editor/hooks/useCommentSystem.js +79 -55
  396. package/dist/editor/hooks/useCommentsData.d.ts +11 -7
  397. package/dist/editor/hooks/useCommentsData.js +20 -19
  398. package/dist/editor/hooks/useDraftManager.d.ts +77 -3
  399. package/dist/editor/hooks/useDraftManager.js +887 -94
  400. package/dist/editor/hooks/useEditorLayout.d.ts +53 -7
  401. package/dist/editor/hooks/useEditorLayout.js +98 -20
  402. package/dist/editor/hooks/useEntriesData.d.ts +2 -1
  403. package/dist/editor/hooks/useEntriesData.js +18 -4
  404. package/dist/editor/hooks/useEntryManager.d.ts +41 -2
  405. package/dist/editor/hooks/useEntryManager.js +227 -41
  406. package/dist/editor/hooks/useGroupManager.js +13 -16
  407. package/dist/editor/hooks/usePermissionManager.js +14 -6
  408. package/dist/editor/hooks/usePreviewMarks.d.ts +21 -0
  409. package/dist/editor/hooks/usePreviewMarks.js +33 -0
  410. package/dist/editor/hooks/useReferenceResolution.d.ts +10 -3
  411. package/dist/editor/hooks/useReferenceResolution.js +34 -177
  412. package/dist/editor/hooks/useSchemaManager.d.ts +25 -6
  413. package/dist/editor/hooks/useSchemaManager.js +97 -39
  414. package/dist/editor/hooks/useUserMetadata.d.ts +14 -4
  415. package/dist/editor/hooks/useUserMetadata.js +76 -47
  416. package/dist/editor/hooks/user-metadata-batcher.d.ts +8 -0
  417. package/dist/editor/hooks/user-metadata-batcher.js +33 -0
  418. package/dist/editor/media/AssetCard.d.ts +6 -7
  419. package/dist/editor/media/AssetCard.js +24 -31
  420. package/dist/editor/media/AssetListRow.d.ts +16 -0
  421. package/dist/editor/media/AssetListRow.js +41 -0
  422. package/dist/editor/media/CropStep.js +6 -1
  423. package/dist/editor/media/MediaLibrary.d.ts +4 -3
  424. package/dist/editor/media/MediaLibrary.js +11 -4
  425. package/dist/editor/media/MediaLibraryBody.d.ts +3 -3
  426. package/dist/editor/media/MediaLibraryBody.js +195 -80
  427. package/dist/editor/media/MediaLibrarySkeleton.d.ts +6 -0
  428. package/dist/editor/media/MediaLibrarySkeleton.js +14 -0
  429. package/dist/editor/media/crop-math.d.ts +3 -4
  430. package/dist/editor/media/crop-math.js +11 -22
  431. package/dist/editor/media/editor-image-src.d.ts +8 -0
  432. package/dist/editor/media/editor-image-src.js +10 -0
  433. package/dist/editor/media/format-bytes.d.ts +2 -0
  434. package/dist/editor/media/format-bytes.js +23 -0
  435. package/dist/editor/media/media-library-prefs.d.ts +23 -0
  436. package/dist/editor/media/media-library-prefs.js +49 -0
  437. package/dist/editor/media/media-library-view.d.ts +34 -0
  438. package/dist/editor/media/media-library-view.js +40 -0
  439. package/dist/editor/media/useAssetLibrary.d.ts +30 -0
  440. package/dist/editor/media/useAssetLibrary.js +126 -0
  441. package/dist/editor/media/useStableAssetOrder.d.ts +11 -0
  442. package/dist/editor/media/useStableAssetOrder.js +37 -0
  443. package/dist/editor/media/useThumbnailFailure.d.ts +6 -0
  444. package/dist/editor/media/useThumbnailFailure.js +16 -0
  445. package/dist/editor/permission-manager/GroupSelector.js +2 -2
  446. package/dist/editor/permission-manager/PermissionEditor.d.ts +1 -1
  447. package/dist/editor/permission-manager/PermissionEditor.js +15 -4
  448. package/dist/editor/permission-manager/PermissionLevelBadge.js +1 -1
  449. package/dist/editor/permission-manager/PermissionTree.d.ts +1 -1
  450. package/dist/editor/permission-manager/UserSelector.d.ts +1 -1
  451. package/dist/editor/permission-manager/UserSelector.js +3 -3
  452. package/dist/editor/permission-manager/index.js +12 -3
  453. package/dist/editor/permission-manager/types.d.ts +5 -2
  454. package/dist/editor/platform.d.ts +3 -0
  455. package/dist/editor/platform.js +9 -0
  456. package/dist/editor/preview-asset-base.d.ts +9 -0
  457. package/dist/editor/preview-asset-base.js +17 -0
  458. package/dist/editor/preview-bridge.d.ts +51 -27
  459. package/dist/editor/preview-bridge.js +103 -144
  460. package/dist/editor/preview-marks.d.ts +15 -0
  461. package/dist/editor/preview-marks.js +74 -0
  462. package/dist/editor/preview-path.d.ts +12 -0
  463. package/dist/editor/preview-path.js +45 -1
  464. package/dist/editor/preview-scale.d.ts +27 -0
  465. package/dist/editor/preview-scale.js +34 -0
  466. package/dist/editor/raw-asset-base.d.ts +5 -0
  467. package/dist/editor/raw-asset-base.js +14 -0
  468. package/dist/editor/relative-time.d.ts +4 -4
  469. package/dist/editor/relative-time.js +16 -8
  470. package/dist/editor/schema-editor/CollectionEditor.d.ts +20 -3
  471. package/dist/editor/schema-editor/CollectionEditor.js +361 -62
  472. package/dist/editor/schema-editor/EntryTypeEditor.d.ts +5 -1
  473. package/dist/editor/schema-editor/EntryTypeEditor.js +11 -6
  474. package/dist/editor/schema-editor/collection-draft.d.ts +22 -0
  475. package/dist/editor/schema-editor/collection-draft.js +40 -0
  476. package/dist/editor/schema-editor/index.d.ts +1 -1
  477. package/dist/editor/schema-label.d.ts +10 -0
  478. package/dist/editor/schema-label.js +17 -0
  479. package/dist/editor/settings-conflict.d.ts +5 -0
  480. package/dist/editor/settings-conflict.js +8 -0
  481. package/dist/editor/shell/ActivityRail.d.ts +37 -0
  482. package/dist/editor/shell/ActivityRail.js +91 -0
  483. package/dist/editor/shell/EditorShell.d.ts +17 -0
  484. package/dist/editor/shell/EditorShell.js +31 -0
  485. package/dist/editor/shell/PaneLayout.d.ts +28 -0
  486. package/dist/editor/shell/PaneLayout.js +116 -0
  487. package/dist/editor/shell/SidePanelHost.d.ts +35 -0
  488. package/dist/editor/shell/SidePanelHost.js +184 -0
  489. package/dist/editor/shell/TopBar.d.ts +12 -0
  490. package/dist/editor/shell/TopBar.js +14 -0
  491. package/dist/editor/shell/host-chrome.d.ts +16 -0
  492. package/dist/editor/shell/host-chrome.js +69 -0
  493. package/dist/editor/slug.d.ts +16 -0
  494. package/dist/editor/slug.js +43 -0
  495. package/dist/editor/theme.d.ts +2 -1
  496. package/dist/editor/theme.js +314 -13
  497. package/dist/editor/unanswered.d.ts +17 -0
  498. package/dist/editor/unanswered.js +26 -0
  499. package/dist/editor/unavailable-entry-type.d.ts +20 -0
  500. package/dist/editor/unavailable-entry-type.js +28 -0
  501. package/dist/editor/undo-removal.d.ts +57 -0
  502. package/dist/editor/undo-removal.js +163 -0
  503. package/dist/editor/user-display.d.ts +4 -0
  504. package/dist/editor/user-display.js +5 -0
  505. package/dist/editor/utils/confirm-modal.d.ts +8 -0
  506. package/dist/editor/utils/confirm-modal.js +12 -0
  507. package/dist/editor/utils/editor-errors.d.ts +19 -0
  508. package/dist/editor/utils/editor-errors.js +32 -0
  509. package/dist/editor/utils/focus-return.d.ts +56 -0
  510. package/dist/editor/utils/focus-return.js +106 -0
  511. package/dist/entry-link-resolver.d.ts +11 -1
  512. package/dist/entry-link-resolver.js +65 -5
  513. package/dist/entry-schema-registry.d.ts +3 -3
  514. package/dist/entry-schema-registry.js +7 -4
  515. package/dist/entry-schema.d.ts +49 -3
  516. package/dist/entry-schema.js +18 -0
  517. package/dist/git-manager.d.ts +172 -10
  518. package/dist/git-manager.js +449 -107
  519. package/dist/github-service.d.ts +39 -2
  520. package/dist/github-service.js +101 -7
  521. package/dist/http/handler.js +74 -9
  522. package/dist/http/types.d.ts +2 -0
  523. package/dist/http/worker-not-ready.d.ts +9 -4
  524. package/dist/http/worker-not-ready.js +59 -6
  525. package/dist/index.d.ts +2 -0
  526. package/dist/index.js +3 -0
  527. package/dist/item-keys.d.ts +46 -0
  528. package/dist/item-keys.js +235 -0
  529. package/dist/operating-mode/editor-mode-check.d.ts +15 -0
  530. package/dist/operating-mode/editor-mode-check.js +24 -0
  531. package/dist/operating-mode/mode-env.d.ts +13 -14
  532. package/dist/operating-mode/mode-env.js +13 -14
  533. package/dist/operating-mode/types.d.ts +2 -2
  534. package/dist/paths/branch.d.ts +0 -1
  535. package/dist/paths/branch.js +1 -8
  536. package/dist/paths/index.d.ts +1 -1
  537. package/dist/paths/index.js +1 -1
  538. package/dist/paths/validation.js +0 -2
  539. package/dist/preview.d.ts +8 -0
  540. package/dist/preview.js +7 -0
  541. package/dist/reference-resolver.js +1 -0
  542. package/dist/resolve-canopy-user.d.ts +1 -1
  543. package/dist/resolve-canopy-user.js +1 -1
  544. package/dist/schema/meta-loader.d.ts +18 -8
  545. package/dist/schema/meta-loader.js +42 -9
  546. package/dist/schema/registry-fingerprint.d.ts +14 -0
  547. package/dist/schema/registry-fingerprint.js +63 -0
  548. package/dist/schema/resolver.d.ts +7 -4
  549. package/dist/schema/resolver.js +7 -6
  550. package/dist/schema/schema-store.d.ts +7 -10
  551. package/dist/schema/schema-store.js +13 -11
  552. package/dist/schema/schema-unavailable-error.d.ts +11 -0
  553. package/dist/schema/schema-unavailable-error.js +15 -0
  554. package/dist/schema/types.d.ts +22 -0
  555. package/dist/schema-registry-record.d.ts +35 -0
  556. package/dist/schema-registry-record.js +134 -0
  557. package/dist/server.d.ts +79 -9
  558. package/dist/server.js +62 -8
  559. package/dist/services.d.ts +33 -5
  560. package/dist/services.js +155 -37
  561. package/dist/settings-workspace.d.ts +23 -3
  562. package/dist/settings-workspace.js +159 -45
  563. package/dist/stamp-on-publish.d.ts +36 -0
  564. package/dist/stamp-on-publish.js +71 -0
  565. package/dist/static/index.d.ts +58 -5
  566. package/dist/static/index.js +133 -3
  567. package/dist/static/seo.d.ts +5 -5
  568. package/dist/static/seo.js +5 -5
  569. package/dist/submission-attribution.d.ts +8 -0
  570. package/dist/submission-attribution.js +22 -0
  571. package/dist/system-users.d.ts +12 -0
  572. package/dist/system-users.js +12 -0
  573. package/dist/task-queue/cms-task-queue.d.ts +1 -1
  574. package/dist/task-queue/cms-task-queue.js +1 -1
  575. package/dist/task-queue/index.d.ts +1 -1
  576. package/dist/task-queue/index.js +1 -1
  577. package/dist/task-queue/task-queue.d.ts +6 -0
  578. package/dist/task-queue/task-queue.js +29 -0
  579. package/dist/task-queue/worker-status.d.ts +35 -9
  580. package/dist/task-queue/worker-status.js +84 -17
  581. package/dist/types.d.ts +90 -9
  582. package/dist/url-collision.js +3 -3
  583. package/dist/url-exclusivity-fixtures.d.ts +8 -1
  584. package/dist/url-exclusivity-fixtures.js +11 -1
  585. package/dist/user.js +2 -1
  586. package/dist/utils/atomic-write.js +2 -1
  587. package/dist/utils/base-branch.d.ts +13 -0
  588. package/dist/utils/base-branch.js +23 -0
  589. package/dist/utils/content-serialize.d.ts +15 -7
  590. package/dist/utils/content-serialize.js +353 -51
  591. package/dist/utils/content-write-lock.d.ts +6 -1
  592. package/dist/utils/content-write-lock.js +21 -6
  593. package/dist/utils/debug.d.ts +1 -1
  594. package/dist/utils/debug.js +5 -3
  595. package/dist/utils/error.js +13 -2
  596. package/dist/utils/escape-text.d.ts +7 -0
  597. package/dist/utils/escape-text.js +27 -0
  598. package/dist/utils/fs.d.ts +12 -4
  599. package/dist/utils/fs.js +18 -5
  600. package/dist/utils/git.d.ts +21 -1
  601. package/dist/utils/git.js +63 -8
  602. package/dist/utils/json-source-splice.d.ts +14 -0
  603. package/dist/utils/json-source-splice.js +120 -0
  604. package/dist/utils/markdown-body-splice.d.ts +39 -0
  605. package/dist/utils/markdown-body-splice.js +421 -0
  606. package/dist/utils/occ-json-write.js +18 -12
  607. package/dist/utils/provision-log.d.ts +25 -0
  608. package/dist/utils/provision-log.js +49 -0
  609. package/dist/utils/provisioning-lock.d.ts +32 -1
  610. package/dist/utils/provisioning-lock.js +78 -32
  611. package/dist/utils/sanitize-href.d.ts +9 -23
  612. package/dist/utils/sanitize-href.js +14 -28
  613. package/dist/utils/title-field.d.ts +10 -0
  614. package/dist/utils/title-field.js +47 -0
  615. package/dist/utils/yaml-source-splice.d.ts +58 -0
  616. package/dist/utils/yaml-source-splice.js +515 -0
  617. package/dist/validation/block-structural-keys.d.ts +1 -1
  618. package/dist/validation/block-structural-keys.js +1 -1
  619. package/dist/validation/deletion-checker.d.ts +21 -29
  620. package/dist/validation/deletion-checker.js +41 -117
  621. package/dist/validation/entry-validator.d.ts +6 -10
  622. package/dist/validation/entry-validator.js +19 -26
  623. package/dist/validation/field-traversal.d.ts +22 -0
  624. package/dist/validation/field-traversal.js +39 -8
  625. package/dist/validation/markdown-safety.d.ts +68 -0
  626. package/dist/validation/markdown-safety.js +596 -0
  627. package/dist/validation/mdx-allowlist.d.ts +55 -0
  628. package/dist/validation/mdx-allowlist.js +183 -0
  629. package/dist/validation/reference-validator.d.ts +2 -0
  630. package/dist/validation/reference-validator.js +3 -0
  631. package/dist/version.d.ts +1 -1
  632. package/dist/version.js +1 -1
  633. package/dist/worker/branch-policy.d.ts +124 -0
  634. package/dist/worker/branch-policy.js +243 -0
  635. package/dist/worker/bundle-header.d.ts +117 -0
  636. package/dist/worker/bundle-header.js +253 -0
  637. package/dist/worker/cms-worker.d.ts +236 -142
  638. package/dist/worker/cms-worker.js +772 -285
  639. package/dist/worker/gateway-egress.d.ts +43 -0
  640. package/dist/worker/gateway-egress.js +209 -0
  641. package/dist/worker/gateway-instance.d.ts +42 -0
  642. package/dist/worker/gateway-instance.js +129 -0
  643. package/dist/worker/git-process.d.ts +81 -0
  644. package/dist/worker/git-process.js +234 -0
  645. package/dist/worker/git-sync.d.ts +34 -17
  646. package/dist/worker/git-sync.js +394 -131
  647. package/dist/worker/github-auth.d.ts +9 -9
  648. package/dist/worker/github-auth.js +13 -15
  649. package/dist/worker/github-gateway-http.d.ts +62 -0
  650. package/dist/worker/github-gateway-http.js +977 -0
  651. package/dist/worker/github-gateway-wire.d.ts +590 -0
  652. package/dist/worker/github-gateway-wire.js +488 -0
  653. package/dist/worker/github-gateway.d.ts +238 -0
  654. package/dist/worker/github-gateway.js +726 -0
  655. package/dist/worker/github-mirror-upkeep.d.ts +75 -0
  656. package/dist/worker/github-mirror-upkeep.js +266 -0
  657. package/dist/worker/github-mirror.d.ts +364 -0
  658. package/dist/worker/github-mirror.js +1030 -0
  659. package/dist/worker/github-transfer.d.ts +56 -0
  660. package/dist/worker/github-transfer.js +444 -0
  661. package/dist/worker/history-rewrite.d.ts +4 -5
  662. package/dist/worker/history-rewrite.js +12 -17
  663. package/dist/worker/provisioned-workspace.d.ts +5 -5
  664. package/dist/worker/provisioned-workspace.js +5 -5
  665. package/dist/worker/rebase.d.ts +1 -1
  666. package/dist/worker/rebase.js +58 -54
  667. package/dist/worker/remote-git-maintenance.d.ts +3 -0
  668. package/dist/worker/remote-git-maintenance.js +12 -0
  669. package/dist/worker/schema-gate.d.ts +49 -0
  670. package/dist/worker/schema-gate.js +226 -0
  671. package/dist/worker/self-checks.d.ts +107 -0
  672. package/dist/worker/self-checks.js +358 -0
  673. package/dist/worker/shared-repo-git.d.ts +96 -0
  674. package/dist/worker/shared-repo-git.js +459 -0
  675. package/dist/worker/sparse-cone.d.ts +20 -0
  676. package/dist/worker/sparse-cone.js +136 -0
  677. package/dist/worker/stale-mirror.d.ts +82 -0
  678. package/dist/worker/stale-mirror.js +130 -0
  679. package/dist/worker/task-runner.d.ts +5 -3
  680. package/dist/worker/task-runner.js +287 -186
  681. package/dist/worker/worker-context.d.ts +36 -37
  682. package/package.json +35 -7
  683. package/dist/editor/EditorPanes.d.ts +0 -10
  684. package/dist/editor/EditorPanes.js +0 -98
  685. package/dist/editor/components/EditorFooter.d.ts +0 -11
  686. package/dist/editor/components/EditorFooter.js +0 -9
  687. package/dist/editor/components/EditorSidebar.d.ts +0 -67
  688. package/dist/editor/components/EditorSidebar.js +0 -22
@@ -9,10 +9,15 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
9
9
  * only for admins (see isAdmin(userContext?.groups)); this component does not
10
10
  * re-check that itself, same as GroupManager and PermissionManager.
11
11
  */
12
- import { Alert, Badge, Button, Code, Group, Loader, Modal, Paper, SegmentedControl, SimpleGrid, Spoiler, Stack, Table, Tabs, Text, ThemeIcon, Tooltip, } from '@mantine/core';
12
+ import { Alert, Badge, Box, Button, Code, Group, Loader, Modal, Paper, SegmentedControl, SimpleGrid, Spoiler, Stack, Table, Tabs, Text, ThemeIcon, Tooltip, VisuallyHidden, } from '@mantine/core';
13
+ import { useId } from 'react';
13
14
  import { IconAlertCircle, IconAlertTriangle } from '@tabler/icons-react';
14
- import { modals } from '@mantine/modals';
15
+ import { openConfirm } from '../utils/confirm-modal.js';
16
+ import { focusReturnProps } from '../utils/focus-return.js';
17
+ import { formatRelativeTime } from '../relative-time.js';
15
18
  import { useSystemHealth, } from './useSystemHealth.js';
19
+ import { escapeForLog } from '../../utils/escape-text.js';
20
+ import { branchStatusPresentation } from '../branch-status.js';
16
21
  // ============================================================================
17
22
  // Small pure helpers
18
23
  // ============================================================================
@@ -49,21 +54,33 @@ function formatAgeMs(ms) {
49
54
  */
50
55
  function purgeGateFor(entry) {
51
56
  if (entry.isBaseBranch) {
52
- return { disabled: true, tooltip: 'The base branch can never be purged' };
57
+ return { disabled: true, tooltip: 'The base branch can never be purged.' };
53
58
  }
54
59
  const lockFresh = !!entry.provisioningLock && entry.provisioningLock.ageMs < PROVISIONING_LOCK_FRESH_MS;
55
60
  if (entry.kind === 'orphan') {
56
61
  const tooYoung = (entry.ageMs ?? 0) < ORPHAN_YOUTH_THRESHOLD_MS;
57
62
  const disabled = lockFresh || tooYoung;
58
- return { disabled, tooltip: disabled ? 'May be a clone in progress' : undefined };
63
+ return {
64
+ disabled,
65
+ tooltip: disabled
66
+ ? 'This may be a clone in progress. Try again in a few minutes.'
67
+ : undefined,
68
+ };
59
69
  }
60
70
  return {
61
71
  disabled: lockFresh,
62
72
  tooltip: lockFresh
63
- ? 'Provisioning, or the worker syncing this branch, may be in progress'
73
+ ? 'Provisioning, or the worker syncing this branch, may be in progress. Try again in a few minutes.'
64
74
  : undefined,
65
75
  };
66
76
  }
77
+ /** Purge stays focusable while gated (`aria-disabled`), so its reason opens on focus and touch. */
78
+ function PurgeButton({ entry, gate, onPurge, }) {
79
+ return (_jsx(Tooltip, { label: gate.tooltip, disabled: !gate.disabled, children: _jsx(Button, { variant: "light", color: "red", "data-disabled": gate.disabled ? true : undefined, "aria-disabled": gate.disabled ? true : undefined, "data-testid": `purge-dir-${entry.dirName}`, onClick: () => {
80
+ if (!gate.disabled)
81
+ onPurge(entry.dirName);
82
+ }, children: "Purge" }) }));
83
+ }
67
84
  function workerLivenessBadge(worker, mode) {
68
85
  if (mode === 'dev') {
69
86
  return { color: 'gray', label: `Worker: ${worker.state}` };
@@ -86,6 +103,25 @@ const BASE_REFRESH_LABELS = {
86
103
  'skipped-not-provisioned': 'not yet provisioned',
87
104
  failed: 'refresh failed',
88
105
  };
106
+ /**
107
+ * The worker holding the base branch for an editor deploy, or the cycle it stopped waiting.
108
+ * The rule and its bound live in worker/schema-gate.ts.
109
+ */
110
+ function BaseHoldAlert({ hold }) {
111
+ const names = hold.missingSchemas.map((name, i) => (_jsxs("span", { children: [i > 0 && ', ', _jsx(Code, { children: name })] }, name)));
112
+ const editor = hold.editorBuild.sourceRevision ? (_jsxs(_Fragment, { children: ["the running editor (built from ", _jsx(Code, { children: hold.editorBuild.sourceRevision.slice(0, 12) }), ")"] })) : ('the running editor');
113
+ return (_jsxs(Alert, { color: hold.expired ? 'orange' : 'blue', icon: _jsx(IconAlertTriangle, { size: 16 }), title: hold.expired ? 'Stopped waiting for the editor deploy' : 'Waiting for editor deploy', "data-testid": "base-hold-alert", children: [_jsxs(Text, { size: "sm", children: ["Newly merged content names ", names, ", which ", editor, " does not define.", ' ', hold.expired
114
+ ? 'The worker updated the base branch anyway; content types using them are unavailable until an editor image defining them is deployed.'
115
+ : 'The worker keeps the base branch at its current version until an editor image defining them handles a request.'] }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["Held since ", hold.since, " \u00B7 ", hold.files.join(', '), hold.fileCount > hold.files.length && ` and ${hold.fileCount - hold.files.length} more`] })] }));
116
+ }
117
+ /**
118
+ * Branches the worker left diverged from GitHub because this deployment's copy holds editor work
119
+ * (worker/git-sync.ts's `reconcileTrackedBranches`). A copy holding none is moved to GitHub's tip
120
+ * and never listed. Names come from GitHub and are attacker-chosen, so each is shown escaped.
121
+ */
122
+ function DivergedBranchesAlert({ names }) {
123
+ return (_jsx(Alert, { color: "orange", icon: _jsx(IconAlertTriangle, { size: 16 }), title: `${plural(names.length, 'branch')} diverged from GitHub`, "data-testid": "diverged-branches-alert", children: _jsxs(Stack, { gap: 4, children: [_jsx(Text, { size: "sm", children: "This deployment's copy of each branch below holds editor work that conflicts with the branch on GitHub, usually because the GitHub branch was rebased or force-pushed, or because another CanopyCMS deployment sharing this repository uses the same branch name. Neither copy has been changed, and publishing the branch fails until they agree." }), _jsx(Text, { size: "sm", children: "A developer decides which version to keep. To keep GitHub's, first push it to a new branch name on GitHub, because \"Delete branch\" in the editor also deletes a published branch on GitHub; then delete this branch in the editor (withdraw it from review first if it is in review). The editor's changes cannot be copied to another branch: to keep them, leave this branch as it is, or re-enter them by hand in a new branch. For the base branch or a settings branch, ask a developer to check what changed on GitHub." }), _jsx(Group, { gap: 4, children: names.map((name) => (_jsx(Code, { children: escapeForLog(name) }, name))) })] }) }));
124
+ }
89
125
  /**
90
126
  * Why the base branch needs an operator, or null when its last refresh needs
91
127
  * nothing. Shared by the overview and the base row's warning tooltip.
@@ -105,13 +141,6 @@ function baseRefreshWarning(report) {
105
141
  }
106
142
  return lines.length > 0 ? lines.join('\n') : null;
107
143
  }
108
- // Mirrors BranchManager.tsx's statusColorMap -- kept local (not exported
109
- // there) rather than shared, same tiny lookup either way.
110
- const branchStatusColorMap = {
111
- editing: 'brand',
112
- submitted: 'green',
113
- approved: 'teal',
114
- };
115
144
  const TASK_STATUS_OPTIONS = [
116
145
  { label: 'Pending', value: 'pending' },
117
146
  { label: 'Processing', value: 'processing' },
@@ -134,10 +163,63 @@ function deleteConfirmText(status) {
134
163
  }
135
164
  const MARK_MERGED_CONFIRM_TEXT = 'In production the server cannot verify the PR actually merged (no GitHub access from the API) — confirm the PR is merged on GitHub first.';
136
165
  const REPAIR_CONFIRM_TEXT = "Recreates metadata with defaults: status becomes 'editing', you become the creator, branch ACLs are reset. The corrupt file is archived alongside for forensics.";
166
+ const DUPLICATE_REPAIR_CONFIRM_TEXT = 'These files share a content ID, so the editor uses only the one marked Keep. Archiving renames each other file to a hidden name in the same folder. Nothing is deleted.';
167
+ const DUPLICATE_REPAIR_EDITOR_NOTE = 'This changes files on the branch, and adds you as one of its editors.';
168
+ /**
169
+ * Why a healthy row's duplicate-ID state is unknown. `null` is a row the last
170
+ * scan did not include.
171
+ */
172
+ function duplicateUncheckedReason(scan) {
173
+ if (scan?.state === 'unknown' && scan.reason === 'failed') {
174
+ return 'The duplicate ID check failed on this branch.';
175
+ }
176
+ if (scan?.state === 'unknown') {
177
+ return 'The duplicate ID check ran out of time before reaching this branch.';
178
+ }
179
+ return 'This branch was added after the last duplicate ID check.';
180
+ }
181
+ function plural(n, word) {
182
+ return `${n} ${word}${n === 1 ? '' : word.endsWith('h') ? 'es' : 's'}`;
183
+ }
184
+ /**
185
+ * One line for the whole Branches tab, so a scan that did not finish never
186
+ * reads as a clean one: rows without a badge are clean only when this says so.
187
+ */
188
+ function duplicateScanSummary(health, healthyDirNames) {
189
+ const { duplicateIdScan: scan, duplicateIdScanLoading, duplicateIdScanError } = health;
190
+ if (duplicateIdScanLoading && !scan) {
191
+ return { text: 'Checking for duplicate content IDs…', color: 'dimmed' };
192
+ }
193
+ if (duplicateIdScanError) {
194
+ return {
195
+ text: `Could not check for duplicate content IDs: ${duplicateIdScanError}`,
196
+ color: 'orange',
197
+ };
198
+ }
199
+ if (!scan)
200
+ return { text: 'Duplicate content IDs not checked yet.', color: 'orange' };
201
+ let found = 0;
202
+ let unchecked = 0;
203
+ for (const dirName of healthyDirNames) {
204
+ const state = scan.byDir[dirName]?.state;
205
+ if (state === 'found')
206
+ found++;
207
+ else if (state !== 'none')
208
+ unchecked++;
209
+ }
210
+ const parts = [];
211
+ if (found > 0)
212
+ parts.push(`Duplicate content IDs found on ${plural(found, 'branch')}.`);
213
+ if (unchecked > 0)
214
+ parts.push(`Could not check ${plural(unchecked, 'branch')}.`);
215
+ if (parts.length === 0)
216
+ return { text: 'No duplicate content IDs found.', color: 'dimmed' };
217
+ return { text: parts.join(' '), color: 'orange' };
218
+ }
137
219
  const PURGE_CONFIRM_TEXT = 'The directory is moved to a hidden trash name and kept for 30 days, then deleted. Any git work inside was never pushed and will be lost when the trash is swept.';
138
- export function SystemHealthPanel({ opened, onClose }) {
220
+ export function SystemHealthPanel({ opened, onClose, returnFocusTo }) {
139
221
  const health = useSystemHealth({ isOpen: opened });
140
- return (_jsx(Modal, { opened: opened, onClose: onClose, title: "System health", size: "xl", children: _jsxs(Tabs, { defaultValue: "overview", children: [_jsxs(Tabs.List, { children: [_jsx(Tabs.Tab, { value: "overview", children: "Overview" }), _jsx(Tabs.Tab, { value: "tasks", children: "Tasks" }), _jsx(Tabs.Tab, { value: "branches", children: "Branches" })] }), _jsx(Tabs.Panel, { value: "overview", pt: "md", children: _jsx(OverviewTab, { health: health }) }), _jsx(Tabs.Panel, { value: "tasks", pt: "md", children: _jsx(TasksTab, { health: health }) }), _jsx(Tabs.Panel, { value: "branches", pt: "md", children: _jsx(BranchesTab, { health: health }) })] }) }));
222
+ return (_jsx(Modal, { opened: opened, onClose: onClose, title: "System health", size: "xl", ...focusReturnProps(returnFocusTo), children: _jsxs(Tabs, { defaultValue: "overview", children: [_jsxs(Tabs.List, { children: [_jsx(Tabs.Tab, { value: "overview", children: "Overview" }), _jsx(Tabs.Tab, { value: "tasks", children: "Tasks" }), _jsx(Tabs.Tab, { value: "branches", children: "Branches" })] }), _jsx(Tabs.Panel, { value: "overview", pt: "md", children: _jsx(OverviewTab, { health: health }) }), _jsx(Tabs.Panel, { value: "tasks", pt: "md", children: _jsx(TasksTab, { health: health }) }), _jsx(Tabs.Panel, { value: "branches", pt: "md", children: _jsx(BranchesTab, { health: health }) })] }) }));
141
223
  }
142
224
  function OverviewTab({ health }) {
143
225
  const { status, statusLoading, isRecentFatalError, error, refresh } = health;
@@ -145,7 +227,7 @@ function OverviewTab({ health }) {
145
227
  return (_jsxs(Group, { justify: "center", py: "xl", children: [_jsx(Loader, { size: "md" }), _jsx(Text, { size: "sm", c: "dimmed", children: "Loading status..." })] }));
146
228
  }
147
229
  if (!status) {
148
- return (_jsxs(Stack, { align: "center", py: "xl", gap: "sm", children: [_jsx(Text, { size: "sm", c: "dimmed", children: "No status available." }), _jsx(Button, { size: "xs", variant: "light", onClick: () => refresh(), children: "Refresh" })] }));
230
+ return (_jsxs(Stack, { align: "center", py: "xl", gap: "sm", children: [_jsx(Text, { size: "sm", c: "dimmed", children: "No status available." }), _jsx(Button, { variant: "light", onClick: () => refresh(), children: "Refresh" })] }));
149
231
  }
150
232
  const liveness = workerLivenessBadge(status.worker, status.mode);
151
233
  const lastFatalError = status.workerStatus?.lastFatalError;
@@ -155,12 +237,22 @@ function OverviewTab({ health }) {
155
237
  // Absent for a worker that predates the field, which is not evidence of skew;
156
238
  // nor is a stale or absent worker's leftover status file, which names no running build.
157
239
  const workerVersion = status.workerStatus?.workerVersion || undefined;
240
+ const lastShutdown = status.workerStatus?.lastShutdown;
158
241
  const versionSkew = status.worker.state === 'alive' &&
159
242
  workerVersion !== undefined &&
160
243
  workerVersion !== build.canopycmsVersion;
161
- return (_jsxs(Stack, { gap: "md", children: [error && (_jsx(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), variant: "light", children: error })), _jsxs(Group, { gap: "sm", children: [_jsx(Badge, { color: liveness.color, variant: "light", size: "lg", children: liveness.label }), status.mode === 'dev' && (_jsx(Text, { size: "xs", c: "dimmed", children: "No worker runs in dev mode" }))] }), isRecentFatalError && lastFatalError && (_jsxs(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Worker crash detected", children: [_jsx(Text, { size: "sm", children: lastFatalError.message }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["at ", lastFatalError.at, " (", lastFatalError.phase, ")"] })] })), status.settingsWorkspaceError && (_jsx(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Settings workspace unavailable: groups and path rules are not loading", children: _jsx(Text, { size: "sm", children: status.settingsWorkspaceError }) })), status.statusReadError && (_jsxs(Text, { size: "xs", c: "orange", children: ["Warning: could not read worker status (", status.statusReadError, ")"] })), status.workerStatus?.lastGitSyncError && (_jsxs(Alert, { color: "orange", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Last git sync failed", children: [_jsx(Text, { size: "sm", children: status.workerStatus.lastGitSyncError.message }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["at ", status.workerStatus.lastGitSyncError.at] })] })), versionSkew && (_jsx(Alert, { color: "orange", icon: _jsx(IconAlertCircle, { size: 16 }), title: "API and worker versions differ", "data-testid": "version-skew-warning", children: _jsxs(Text, { size: "sm", children: ["The API runs canopycms ", build.canopycmsVersion, " but the worker runs canopycms", ' ', workerVersion, ". They were deployed from different builds."] }) })), _jsxs(Paper, { withBorder: true, p: "sm", radius: "md", children: [_jsx(Text, { size: "sm", fw: 600, children: "Build" }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-api-version", children: ["API: canopycms ", build.canopycmsVersion] }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-source-revision", children: ["Source revision:", ' ', build.sourceRevision ? (_jsx(Tooltip, { label: build.sourceRevision, children: _jsx(Code, { children: build.sourceRevision.slice(0, 12) }) })) : ('not set (pass the CANOPY_SOURCE_SHA build arg to the image build)')] }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-worker-version", children: ["Worker version: ", workerVersion ? `canopycms ${workerVersion}` : 'unknown'] }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-media", children: ["Media storage:", ' ', status.assetStore.configured ? 'configured' : 'not configured — uploads are disabled'] })] }), lastGitSync && (_jsxs(Paper, { withBorder: true, p: "sm", radius: "md", children: [_jsx(Text, { size: "sm", fw: 600, children: "Last git sync" }), _jsxs(Text, { size: "xs", c: "dimmed", children: [status.workerStatus?.lastGitSyncAt ?? 'unknown time', " \u00B7 ", lastGitSync.durationMs, "ms \u00B7", ' ', lastGitSync.rebased.length, " rebased \u00B7 ", lastGitSync.skippedDirty.length, " skipped (dirty)", lastGitSync.skippedLocked && lastGitSync.skippedLocked.length > 0
244
+ return (_jsxs(Stack, { gap: "md", children: [error && (_jsx(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), variant: "light", children: error })), _jsxs(Group, { gap: "sm", children: [_jsx(Badge, { color: liveness.color, variant: "light", size: "lg", children: liveness.label }), status.mode === 'dev' && (_jsx(Text, { size: "xs", c: "dimmed", children: "No worker runs in dev mode" }))] }), isRecentFatalError && lastFatalError && (_jsxs(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), title: lastFatalError.phase === 'startup'
245
+ ? 'The worker failed to start'
246
+ : 'The worker stopped while running', children: [_jsx(Text, { size: "sm", children: lastFatalError.message }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["at ", lastFatalError.at] })] })), status.settingsWorkspaceError && (_jsx(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Settings workspace unavailable: groups and path rules are not loading", children: _jsx(Text, { size: "sm", children: status.settingsWorkspaceError }) })), status.schemaIssues && status.schemaIssues.length > 0 && (_jsx(Alert, { color: "yellow", icon: _jsx(IconAlertTriangle, { size: 16 }), title: "Content types this editor version doesn't know", "data-testid": "schema-issues-alert", children: _jsx(Stack, { gap: 4, children: status.schemaIssues.map((issue) => (_jsx(Text, { size: "sm", children: issue.kind === 'unknown-schema' ? (_jsxs(_Fragment, { children: [_jsx(Code, { children: issue.metaFile }), " names ", _jsx(Code, { children: issue.schemaRef }), ", so entry type ", _jsx(Code, { children: issue.entryType }), " is unavailable"] })) : (issue.message) }, `${issue.kind}:${issue.message}`))) }) })), status.workerStatus?.baseHold && _jsx(BaseHoldAlert, { hold: status.workerStatus.baseHold }), status.statusReadError && (_jsxs(Text, { size: "xs", c: "orange", children: ["Warning: could not read worker status (", status.statusReadError, ")"] })), lastGitSync?.tracked && lastGitSync.tracked.diverged.length > 0 && (_jsx(DivergedBranchesAlert, { names: lastGitSync.tracked.diverged })), status.workerStatus?.lastGitSyncError && (_jsxs(Alert, { color: "orange", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Last git sync failed", children: [_jsx(Text, { size: "sm", children: status.workerStatus.lastGitSyncError.message }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["at ", status.workerStatus.lastGitSyncError.at] })] })), versionSkew && (_jsx(Alert, { color: "orange", icon: _jsx(IconAlertCircle, { size: 16 }), title: "API and worker versions differ", "data-testid": "version-skew-warning", children: _jsxs(Text, { size: "sm", children: ["The API runs canopycms ", build.canopycmsVersion, " but the worker runs canopycms", ' ', workerVersion, ". They were deployed from different builds."] }) })), _jsxs(Paper, { withBorder: true, p: "sm", children: [_jsx(Text, { size: "sm", fw: 600, children: "Build" }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-api-version", children: ["API: canopycms ", build.canopycmsVersion] }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-source-revision", children: ["Source revision:", ' ', build.sourceRevision ? (_jsx(Tooltip, { label: build.sourceRevision, children: _jsx(Code, { children: build.sourceRevision.slice(0, 12) }) })) : ('not set (pass the CANOPY_SOURCE_SHA build arg to the image build)')] }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-worker-version", children: ["Worker version: ", workerVersion ? `canopycms ${workerVersion}` : 'unknown'] }), lastShutdown && (_jsxs(Text, { size: "xs", c: lastShutdown.outcome === 'drained' ? 'dimmed' : 'orange', "data-testid": "build-last-shutdown", children: ["Last worker shutdown: ", lastShutdown.reason, " at ", lastShutdown.at, lastShutdown.outcome === 'deadline' &&
247
+ ` · drain deadline hit, aborted ${lastShutdown.abandoned?.join(', ') ?? 'in-flight work'}`, lastShutdown.outcome === 'drained' &&
248
+ lastShutdown.drainMs !== undefined &&
249
+ ` · drained in ${(lastShutdown.drainMs / 1000).toFixed(1)}s`, lastShutdown.outcome === 'not-drained' && ' (a crash or a forced stop)'] })), lastFatalError?.phase === 'startup' && (_jsxs(Text, { size: "xs", c: "orange", "data-testid": "build-failed-start", children: ["Last start failed at ", lastFatalError.at] })), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-media", children: ["Media storage:", ' ', status.assetStore.configured ? 'configured' : 'not configured — uploads are disabled'] }), _jsxs(Text, { size: "xs", c: status.imageProcessing.available ? 'dimmed' : 'orange', "data-testid": "build-image-processing", children: ["Image processing:", ' ', status.imageProcessing.available
250
+ ? 'available'
251
+ : `unavailable — new editor image sizes and crops fail, and uploads skip decode validation${status.imageProcessing.error ? ` (${status.imageProcessing.error})` : ''}`] })] }), lastGitSync && (_jsxs(Paper, { withBorder: true, p: "sm", children: [_jsx(Text, { size: "sm", fw: 600, children: "Last git sync" }), _jsxs(Text, { size: "xs", c: "dimmed", children: [status.workerStatus?.lastGitSyncAt ?? 'unknown time', " \u00B7 ", lastGitSync.durationMs, "ms \u00B7", ' ', lastGitSync.rebased.length, " rebased \u00B7 ", lastGitSync.skippedDirty.length, " skipped (dirty)", lastGitSync.skippedLocked && lastGitSync.skippedLocked.length > 0
162
252
  ? ` · ${lastGitSync.skippedLocked.length} skipped (busy: a content write, provisioning or purge)`
163
- : ''] }), lastGitSync.baseRefresh && (_jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "base-refresh-outcome", children: ["Base branch: ", BASE_REFRESH_LABELS[lastGitSync.baseRefresh.outcome]] })), baseWarning && (_jsx(Text, { size: "xs", c: "orange", style: { whiteSpace: 'pre-line' }, "data-testid": "base-refresh-warning", children: baseWarning })), lastGitSync.failed.length > 0 && (_jsx(Spoiler, { maxHeight: 0, showLabel: `${lastGitSync.failed.length} failed — show details`, hideLabel: "Hide", children: _jsx(Stack, { gap: 4, mt: 4, children: lastGitSync.failed.map((f) => (_jsxs(Text, { size: "xs", c: "red", children: [f.branch, ": ", f.error] }, f.branch))) }) }))] })), _jsx(SimpleGrid, { cols: 5, spacing: "xs", children: ['pending', 'processing', 'completed', 'failed', 'corrupt'].map((key) => (_jsxs(Paper, { withBorder: true, p: "xs", radius: "md", ta: "center", children: [_jsx(Text, { size: "xs", c: "dimmed", tt: "capitalize", children: key }), _jsx(Text, { size: "lg", fw: 700, c: (key === 'failed' || key === 'corrupt') && status.queue[key] > 0 ? 'red' : undefined, children: status.queue[key] })] }, key))) }), status.queue.oldestPendingAgeMs !== undefined && (_jsxs(Text, { size: "xs", c: "dimmed", children: ["Oldest pending task: ", formatAgeMs(status.queue.oldestPendingAgeMs), " old"] })), _jsxs(Group, { justify: "space-between", align: "center", children: [_jsxs(Text, { size: "xs", c: "dimmed", children: ["Generated at ", status.generatedAt] }), _jsx(Button, { size: "xs", variant: "light", onClick: () => refresh(), loading: statusLoading, children: "Refresh" })] }), _jsx(Text, { size: "xs", c: "dimmed", children: "Data may lag up to ~60s (shared-filesystem caching)" })] }));
253
+ : ''] }), lastGitSync.baseRefresh && (_jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "base-refresh-outcome", children: ["Base branch: ", BASE_REFRESH_LABELS[lastGitSync.baseRefresh.outcome]] })), baseWarning && (_jsx(Text, { size: "xs", c: "orange", style: { whiteSpace: 'pre-line' }, "data-testid": "base-refresh-warning", children: baseWarning })), lastGitSync.failed.length > 0 && (_jsx(Spoiler, { maxHeight: 0, showLabel: `${lastGitSync.failed.length} failed — show details`, hideLabel: "Hide", children: _jsx(Stack, { gap: 4, mt: 4, children: lastGitSync.failed.map((f) => (_jsxs(Text, { size: "xs", c: "var(--canopy-danger-text)", children: [f.branch, ": ", f.error] }, f.branch))) }) }))] })), _jsx(SimpleGrid, { cols: 5, spacing: "xs", children: ['pending', 'processing', 'completed', 'failed', 'corrupt'].map((key) => (_jsxs(Paper, { withBorder: true, p: "xs", ta: "center", children: [_jsx(Text, { size: "xs", c: "dimmed", tt: "capitalize", children: key }), _jsx(Text, { size: "lg", fw: 700, c: (key === 'failed' || key === 'corrupt') && status.queue[key] > 0
254
+ ? 'var(--canopy-danger-text)'
255
+ : undefined, children: status.queue[key] })] }, key))) }), status.queue.oldestPendingAgeMs !== undefined && (_jsxs(Text, { size: "xs", c: "dimmed", children: ["Oldest pending task: ", formatAgeMs(status.queue.oldestPendingAgeMs), " old"] })), _jsxs(Group, { justify: "space-between", align: "center", children: [_jsxs(Text, { size: "xs", c: "dimmed", children: ["Generated at ", status.generatedAt] }), _jsx(Button, { variant: "light", onClick: () => refresh(), loading: statusLoading, children: "Refresh" })] }), _jsx(Text, { size: "xs", c: "dimmed", children: "Data may lag up to ~60s (shared-filesystem caching)" })] }));
164
256
  }
165
257
  function TasksTab({ health }) {
166
258
  const { taskStatus, setTaskStatus, tasks, tasksLoading } = health;
@@ -170,7 +262,7 @@ function TasksTab({ health }) {
170
262
  const isEmpty = isCorrupt ? corruptRows.length === 0 : taskRows.length === 0;
171
263
  const canDelete = taskStatus === 'pending' || taskStatus === 'failed' || taskStatus === 'corrupt';
172
264
  const handleRetryClick = (task) => {
173
- modals.openConfirmModal({
265
+ openConfirm({
174
266
  title: 'Retry task',
175
267
  children: _jsx(Text, { size: "sm", children: RETRY_CONFIRM_TEXT }),
176
268
  labels: { confirm: 'Retry', cancel: 'Cancel' },
@@ -179,7 +271,7 @@ function TasksTab({ health }) {
179
271
  });
180
272
  };
181
273
  const handleDeleteClick = (status, fileName) => {
182
- modals.openConfirmModal({
274
+ openConfirm({
183
275
  title: 'Delete task file',
184
276
  children: _jsx(Text, { size: "sm", children: deleteConfirmText(status) }),
185
277
  labels: { confirm: 'Delete', cancel: 'Cancel' },
@@ -191,15 +283,15 @@ function TasksTab({ health }) {
191
283
  if (isAdminTaskStatus(value))
192
284
  setTaskStatus(value);
193
285
  } }), tasksLoading && taskRows.length === 0 && corruptRows.length === 0 ? (_jsxs(Group, { justify: "center", py: "xl", children: [_jsx(Loader, { size: "sm" }), _jsx(Text, { size: "sm", c: "dimmed", children: "Loading tasks..." })] })) : isEmpty ? (_jsxs(Text, { size: "sm", c: "dimmed", py: "md", children: ["No ", taskStatus, " tasks."] })) : (_jsx(Table.ScrollContainer, { minWidth: 600, children: _jsxs(Table, { children: [_jsx(Table.Thead, { children: _jsx(Table.Tr, { children: isCorrupt ? (_jsxs(_Fragment, { children: [_jsx(Table.Th, { children: "File" }), _jsx(Table.Th, { children: "Size" }), _jsx(Table.Th, { children: "Modified" }), _jsx(Table.Th, { children: "Raw snippet" }), _jsx(Table.Th, { children: "Actions" })] })) : (_jsxs(_Fragment, { children: [_jsx(Table.Th, { children: "ID" }), _jsx(Table.Th, { children: "Action" }), _jsx(Table.Th, { children: "Created" }), _jsx(Table.Th, { children: "Retries" }), _jsx(Table.Th, { children: "Error" }), _jsx(Table.Th, { children: "Actions" })] })) }) }), _jsx(Table.Tbody, { children: isCorrupt
194
- ? corruptRows.map((file) => (_jsxs(Table.Tr, { children: [_jsx(Table.Td, { children: _jsx("span", { title: file.fileName, children: truncate(file.fileName, 30) }) }), _jsxs(Table.Td, { children: [file.size, " bytes"] }), _jsx(Table.Td, { children: file.mtime }), _jsx(Table.Td, { children: _jsx(Code, { block: true, style: { maxWidth: 320, whiteSpace: 'pre-wrap' }, children: file.rawSnippet }) }), _jsx(Table.Td, { children: _jsx(Button, { size: "xs", variant: "light", color: "red", "data-testid": `delete-task-${file.fileName}`, onClick: () => handleDeleteClick('corrupt', file.fileName), children: "Delete" }) })] }, file.fileName)))
195
- : taskRows.map((task) => (_jsxs(Table.Tr, { children: [_jsx(Table.Td, { children: _jsx("span", { title: task.id, children: truncate(task.id, 12) }) }), _jsx(Table.Td, { children: task.action }), _jsx(Table.Td, { children: task.createdAt }), _jsx(Table.Td, { children: task.retryCount ?? 0 }), _jsx(Table.Td, { children: task.error ? (_jsx(Tooltip, { label: task.error, multiline: true, maw: 400, children: _jsx(Text, { size: "xs", style: { cursor: 'help' }, children: truncate(task.error, 50) }) })) : (_jsx(Text, { size: "xs", c: "dimmed", children: "\u2014" })) }), _jsx(Table.Td, { children: _jsxs(Group, { gap: "xs", children: [taskStatus === 'failed' && (_jsx(Button, { size: "xs", variant: "light", "data-testid": `retry-task-${task.id}`, onClick: () => handleRetryClick(task), children: "Retry" })), canDelete && (_jsx(Button, { size: "xs", variant: "light", color: "red", "data-testid": `delete-task-${task.id}`, onClick: () => handleDeleteClick(taskStatus, `${task.id}.json`), children: "Delete" }))] }) })] }, task.id))) })] }) }))] }));
286
+ ? corruptRows.map((file) => (_jsxs(Table.Tr, { children: [_jsx(Table.Td, { children: _jsx("span", { title: file.fileName, children: truncate(file.fileName, 30) }) }), _jsxs(Table.Td, { children: [file.size, " bytes"] }), _jsx(Table.Td, { children: file.mtime }), _jsx(Table.Td, { children: _jsx(Code, { block: true, style: { maxWidth: 320, whiteSpace: 'pre-wrap' }, children: file.rawSnippet }) }), _jsx(Table.Td, { children: _jsx(Button, { variant: "light", color: "red", "data-testid": `delete-task-${file.fileName}`, onClick: () => handleDeleteClick('corrupt', file.fileName), children: "Delete" }) })] }, file.fileName)))
287
+ : taskRows.map((task) => (_jsxs(Table.Tr, { children: [_jsx(Table.Td, { children: _jsx("span", { title: task.id, children: truncate(task.id, 12) }) }), _jsx(Table.Td, { children: task.action }), _jsx(Table.Td, { children: task.createdAt }), _jsx(Table.Td, { children: task.retryCount ?? 0 }), _jsx(Table.Td, { children: task.error ? (_jsx(Tooltip, { label: task.error, multiline: true, maw: 400, children: _jsx(Text, { size: "xs", style: { cursor: 'help' }, children: truncate(task.error, 50) }) })) : (_jsx(Text, { size: "xs", c: "dimmed", children: "\u2014" })) }), _jsx(Table.Td, { children: _jsxs(Group, { gap: "xs", children: [taskStatus === 'failed' && (_jsx(Button, { variant: "light", "data-testid": `retry-task-${task.id}`, onClick: () => handleRetryClick(task), children: "Retry" })), canDelete && (_jsx(Button, { variant: "light", color: "red", "data-testid": `delete-task-${task.id}`, onClick: () => handleDeleteClick(taskStatus, `${task.id}.json`), children: "Delete" }))] }) })] }, task.id))) })] }) }))] }));
196
288
  }
197
289
  function BranchesTab({ health }) {
198
290
  const { branchHealth, branchHealthLoading } = health;
199
291
  const entries = branchHealth?.entries ?? [];
200
292
  const baseWarning = baseRefreshWarning(health.status?.workerStatus?.lastGitSync?.baseRefresh);
201
293
  const handleMarkMergedClick = (branchName) => {
202
- modals.openConfirmModal({
294
+ openConfirm({
203
295
  title: 'Mark branch as merged',
204
296
  children: _jsx(Text, { size: "sm", children: MARK_MERGED_CONFIRM_TEXT }),
205
297
  labels: { confirm: 'Mark merged', cancel: 'Cancel' },
@@ -208,7 +300,7 @@ function BranchesTab({ health }) {
208
300
  });
209
301
  };
210
302
  const handleRepairClick = (dirName) => {
211
- modals.openConfirmModal({
303
+ openConfirm({
212
304
  title: 'Repair metadata',
213
305
  children: _jsx(Text, { size: "sm", children: REPAIR_CONFIRM_TEXT }),
214
306
  labels: { confirm: 'Repair', cancel: 'Cancel' },
@@ -217,7 +309,7 @@ function BranchesTab({ health }) {
217
309
  });
218
310
  };
219
311
  const handlePurgeClick = (dirName) => {
220
- modals.openConfirmModal({
312
+ openConfirm({
221
313
  title: 'Purge directory',
222
314
  children: _jsx(Text, { size: "sm", children: PURGE_CONFIRM_TEXT }),
223
315
  labels: { confirm: 'Purge', cancel: 'Cancel' },
@@ -225,15 +317,53 @@ function BranchesTab({ health }) {
225
317
  onConfirm: () => health.purgeDir(dirName),
226
318
  });
227
319
  };
320
+ // Its own dialog, reachable only from the duplicate badge, whose confirm
321
+ // calls only the duplicate repair: Purge sits on neighbouring rows and
322
+ // trashes a whole branch directory.
323
+ const handleDuplicateIdsClick = (dirName, duplicates) => {
324
+ openConfirm({
325
+ title: 'Fix duplicate content IDs',
326
+ children: _jsx(DuplicateRepairDetails, { dirName: dirName, duplicates: duplicates }),
327
+ labels: { confirm: 'Archive duplicates', cancel: 'Cancel' },
328
+ confirmProps: { color: 'orange' },
329
+ onConfirm: () => health.repairDuplicateIds(dirName),
330
+ });
331
+ };
332
+ const healthyDirNames = entries.filter((e) => e.kind === 'healthy').map((e) => e.dirName);
333
+ const duplicateSummary = duplicateScanSummary(health, healthyDirNames);
228
334
  if (branchHealthLoading && entries.length === 0) {
229
335
  return (_jsxs(Group, { justify: "center", py: "xl", children: [_jsx(Loader, { size: "sm" }), _jsx(Text, { size: "sm", c: "dimmed", children: "Loading branch health..." })] }));
230
336
  }
231
337
  if (entries.length === 0) {
232
338
  return (_jsx(Text, { size: "sm", c: "dimmed", py: "md", children: "No branch directories found." }));
233
339
  }
234
- return (_jsx(Table.ScrollContainer, { minWidth: 700, children: _jsxs(Table, { children: [_jsx(Table.Thead, { children: _jsxs(Table.Tr, { children: [_jsx(Table.Th, { children: "Name" }), _jsx(Table.Th, { children: "Status" }), _jsx(Table.Th, { children: "PR" }), _jsx(Table.Th, { children: "Sync" }), _jsx(Table.Th, { children: "Warnings" }), _jsx(Table.Th, { children: "Updated" }), _jsx(Table.Th, { children: "Actions" })] }) }), _jsx(Table.Tbody, { children: entries.map((entry) => (_jsx(BranchHealthRow, { entry: entry, baseWarning: entry.isBaseBranch ? baseWarning : null, onMarkMerged: handleMarkMergedClick, onRepair: handleRepairClick, onPurge: handlePurgeClick }, entry.dirName))) })] }) }));
340
+ return (_jsxs(Stack, { gap: "xs", children: [_jsxs(Group, { justify: "space-between", align: "center", wrap: "nowrap", children: [_jsx(Text, { size: "xs", c: duplicateSummary.color, "data-testid": "duplicate-id-summary", children: duplicateSummary.text }), _jsx(Button, { size: "compact-xs", variant: "subtle", loading: health.duplicateIdScanLoading, onClick: () => health.checkDuplicateIds(), children: "Check again" })] }), _jsx(Table.ScrollContainer, { minWidth: 700, children: _jsxs(Table, { children: [_jsx(Table.Thead, { children: _jsxs(Table.Tr, { children: [_jsx(Table.Th, { children: "Name" }), _jsx(Table.Th, { children: "Status" }), _jsx(Table.Th, { children: "PR" }), _jsx(Table.Th, { children: "Sync" }), _jsx(Table.Th, { children: "Warnings" }), _jsx(Table.Th, { children: "Updated" }), _jsx(Table.Th, { children: "Actions" })] }) }), _jsx(Table.Tbody, { children: entries.map((entry) => (_jsx(BranchHealthRow, { entry: entry, baseWarning: entry.isBaseBranch ? baseWarning : null, duplicateIdScan: health.duplicateIdScan
341
+ ? (health.duplicateIdScan.byDir[entry.dirName] ?? null)
342
+ : undefined, onMarkMerged: handleMarkMergedClick, onRepair: handleRepairClick, onPurge: handlePurgeClick, onDuplicateIds: handleDuplicateIdsClick }, entry.dirName))) })] }) })] }));
343
+ }
344
+ function DuplicateRepairDetails({ dirName, duplicates, }) {
345
+ return (_jsxs(Stack, { gap: "sm", children: [_jsxs(Text, { size: "sm", children: ["Branch ", _jsx(Code, { children: dirName })] }), _jsx(Text, { size: "sm", children: DUPLICATE_REPAIR_CONFIRM_TEXT }), duplicates.map((d) => (_jsxs(Paper, { withBorder: true, p: "xs", radius: "sm", "data-testid": `duplicate-id-${d.id}`, children: [_jsxs(Text, { size: "xs", fw: 600, children: ["Content ID ", _jsx(Code, { children: d.id })] }), _jsxs(Text, { size: "xs", children: ["Keep: ", _jsx(Code, { children: d.keptPath })] }), d.droppedPaths.map((p) => (_jsxs(Text, { size: "xs", children: ["Archive: ", _jsx(Code, { children: p })] }, p)))] }, d.id))), _jsx(Text, { size: "xs", c: "dimmed", children: DUPLICATE_REPAIR_EDITOR_NOTE })] }));
346
+ }
347
+ /** "2d ago, 1/1/2026, 9:00:00 AM": inside a tooltip, so the absolute date sits beside it. */
348
+ const firstFailed = (iso) => {
349
+ const date = new Date(iso);
350
+ return Number.isNaN(date.getTime()) ? iso : `${formatRelativeTime(iso)}, ${date.toLocaleString()}`;
351
+ };
352
+ /**
353
+ * A detail that only a Tooltip would show: hidden text for assistive tech, plus a Tooltip that
354
+ * opens on hover, focus and touch. The trigger is a focusable span, not a button: it does nothing
355
+ * when pressed, so it must not be announced as one. A trigger with no text of its own, such as an
356
+ * icon, takes its name from `name`.
357
+ */
358
+ function TooltipDetail({ detail, name, maw = 320, preLine = false, children, }) {
359
+ const detailId = useId();
360
+ return (_jsxs(_Fragment, { children: [_jsx(Tooltip
361
+ // A sighted user sees only the icon, so the tooltip names it too.
362
+ , {
363
+ // A sighted user sees only the icon, so the tooltip names it too.
364
+ label: name ? `${name}: ${detail}` : detail, multiline: true, maw: maw, style: preLine ? { whiteSpace: 'pre-line' } : undefined, events: { hover: true, focus: true, touch: true }, children: _jsx(Box, { component: "span", tabIndex: 0, role: name ? 'img' : undefined, "aria-label": name, "aria-describedby": detailId, className: "mantine-focus-auto", children: children }) }), _jsx(VisuallyHidden, { id: detailId, children: detail })] }));
235
365
  }
236
- function BranchHealthRow({ entry, baseWarning, onMarkMerged, onRepair, onPurge, }) {
366
+ function BranchHealthRow({ entry, baseWarning, duplicateIdScan, onMarkMerged, onRepair, onPurge, onDuplicateIds, }) {
237
367
  if (entry.kind === 'healthy' && entry.branch) {
238
368
  const b = entry.branch;
239
369
  // Mirrors the rebase loop's skip logic (worker/rebase.ts): the worker
@@ -243,17 +373,17 @@ function BranchHealthRow({ entry, baseWarning, onMarkMerged, onRepair, onPurge,
243
373
  // default instead of silently hiding them.
244
374
  const showRebaseFailure = !['submitted', 'approved', 'archived'].includes(b.status) && !!b.rebaseFailure;
245
375
  const canMarkMerged = (b.status === 'submitted' || b.status === 'approved') && !!b.pullRequestNumber;
246
- return (_jsxs(Table.Tr, { children: [_jsx(Table.Td, { children: _jsxs(Group, { gap: 4, wrap: "nowrap", children: [_jsx(Text, { size: "sm", children: entry.dirName }), entry.isBaseBranch && (_jsx(Badge, { size: "xs", color: "gray", variant: "outline", children: "base" }))] }) }), _jsx(Table.Td, { children: _jsx(Badge, { color: branchStatusColorMap[b.status] ?? 'neutral', variant: "light", children: b.status }) }), _jsx(Table.Td, { children: b.pullRequestNumber ? (_jsxs(Text, { size: "xs", c: "blue", component: "a", href: b.pullRequestUrl, target: "_blank", rel: "noopener noreferrer", style: { textDecoration: 'underline' }, children: ["PR #", b.pullRequestNumber, b.pullRequestState ? ` (${b.pullRequestState})` : ''] })) : (_jsx(Text, { size: "xs", c: "dimmed", children: "\u2014" })) }), _jsx(Table.Td, { children: b.syncStatus === 'sync-failed' ? (_jsx(Tooltip, { label: b.syncFailureReason
247
- ? `${b.syncFailureReason} — retry from the Tasks tab`
248
- : 'GitHub sync failed — retry from the Tasks tab', multiline: true, maw: 320, children: _jsx(Badge, { color: "red", variant: "light", children: "sync-failed" }) })) : b.syncStatus === 'pending-sync' ? (_jsx(Badge, { color: "gray", variant: "light", children: "pending-sync" })) : null }), _jsx(Table.Td, { children: _jsxs(Group, { gap: 6, wrap: "nowrap", children: [!!b.conflictFiles?.length && (_jsxs(Badge, { color: "orange", variant: "light", children: [b.conflictFiles.length, " conflict", b.conflictFiles.length === 1 ? '' : 's'] })), showRebaseFailure && b.rebaseFailure && (_jsx(Tooltip, { label: `${b.rebaseFailure.message} (failing since ${b.rebaseFailure.firstAt})`, multiline: true, maw: 320, children: _jsx(ThemeIcon, { color: "yellow", variant: "light", size: "sm", radius: "xl", "data-testid": `rebase-failure-${entry.dirName}`, children: _jsx(IconAlertTriangle, { size: 12 }) }) })), baseWarning && (_jsx(Tooltip, { label: baseWarning, multiline: true, maw: 420, style: { whiteSpace: 'pre-line' }, children: _jsx(ThemeIcon, { color: "yellow", variant: "light", size: "sm", radius: "xl", "data-testid": `base-refresh-warning-${entry.dirName}`, children: _jsx(IconAlertTriangle, { size: 12 }) }) })), !!entry.duplicateContentIds?.length && (_jsx(Tooltip, { label: entry.duplicateContentIds
249
- .map((d) => `${d.id}: kept ${d.keptPath}; also on ${d.droppedPaths.join(', ')}`)
250
- .join('\n'), multiline: true, maw: 420, style: { whiteSpace: 'pre-line' }, children: _jsxs(Badge, { color: "orange", variant: "light", "data-testid": `duplicate-content-ids-${entry.dirName}`, children: [entry.duplicateContentIds.length, " duplicate ID", entry.duplicateContentIds.length === 1 ? '' : 's'] }) }))] }) }), _jsx(Table.Td, { children: _jsx(Text, { size: "xs", c: "dimmed", children: b.updatedAt }) }), _jsx(Table.Td, { children: canMarkMerged && (_jsx(Button, { size: "xs", variant: "light", "data-testid": `mark-merged-${b.name}`, onClick: () => onMarkMerged(b.name), children: "Mark merged" })) })] }));
376
+ return (_jsxs(Table.Tr, { children: [_jsx(Table.Td, { children: _jsxs(Group, { gap: 4, wrap: "nowrap", children: [_jsx(Text, { size: "sm", children: entry.dirName }), entry.isBaseBranch && (_jsx(Badge, { size: "xs", color: "gray", variant: "outline", children: "Base" }))] }) }), _jsx(Table.Td, { children: _jsx(Badge, { color: branchStatusPresentation(b.status)?.color ?? 'neutral', variant: "light", children: branchStatusPresentation(b.status)?.label }) }), _jsx(Table.Td, { children: b.pullRequestNumber ? (_jsxs(Text, { size: "xs", c: "blue", component: "a", href: b.pullRequestUrl, target: "_blank", rel: "noopener noreferrer", style: { textDecoration: 'underline' }, children: ["PR #", b.pullRequestNumber, b.pullRequestState ? ` (${b.pullRequestState})` : ''] })) : (_jsx(Text, { size: "xs", c: "dimmed", children: "\u2014" })) }), _jsx(Table.Td, { children: b.syncStatus === 'sync-failed' ? (_jsx(TooltipDetail, { detail: b.syncFailureReason
377
+ ? `${b.syncFailureReason} — retry from the "Tasks" tab`
378
+ : 'GitHub sync failed — retry from the "Tasks" tab', children: _jsx(Badge, { color: "red", variant: "light", children: "Sync failed" }) })) : b.syncStatus === 'pending-sync' ? (_jsx(Badge, { color: "gray", variant: "light", children: "Pending sync" })) : null }), _jsx(Table.Td, { children: _jsxs(Group, { gap: 6, wrap: "nowrap", children: [!!b.conflictFiles?.length && (_jsxs(Badge, { color: "orange", variant: "light", children: [b.conflictFiles.length, " conflict", b.conflictFiles.length === 1 ? '' : 's'] })), showRebaseFailure && b.rebaseFailure && (_jsx(TooltipDetail, { name: "Rebase failing", detail: `${b.rebaseFailure.message} (first failed ${firstFailed(b.rebaseFailure.firstAt)})`, children: _jsx(ThemeIcon, { color: "yellow", variant: "light", size: "sm", radius: "xl", "data-testid": `rebase-failure-${entry.dirName}`, children: _jsx(IconAlertTriangle, { size: 12 }) }) })), baseWarning && (_jsx(TooltipDetail, { name: "Base branch refresh failing", detail: baseWarning, maw: 420, preLine: true, children: _jsx(ThemeIcon, { color: "yellow", variant: "light", size: "sm", radius: "xl", "data-testid": `base-refresh-warning-${entry.dirName}`, children: _jsx(IconAlertTriangle, { size: 12 }) }) })), duplicateIdScan?.state === 'found' && (_jsx(Tooltip, { label: "Review and fix", children: _jsx(Badge, { component: "button", type: "button", color: "orange", variant: "light", style: { cursor: 'pointer' }, "data-testid": `duplicate-content-ids-${entry.dirName}`, onClick: () => onDuplicateIds(entry.dirName, duplicateIdScan.duplicates), children: plural(duplicateIdScan.duplicates.length, 'duplicate ID') }) })), duplicateIdScan !== undefined &&
379
+ duplicateIdScan?.state !== 'found' &&
380
+ duplicateIdScan?.state !== 'none' && (_jsx(Tooltip, { label: duplicateUncheckedReason(duplicateIdScan), multiline: true, maw: 320, children: _jsx(Badge, { color: "gray", variant: "outline", "data-testid": `duplicate-ids-unchecked-${entry.dirName}`, children: "IDs not checked" }) }))] }) }), _jsx(Table.Td, { children: _jsx(Text, { size: "xs", c: "dimmed", children: b.updatedAt }) }), _jsx(Table.Td, { children: canMarkMerged && (_jsx(Button, { variant: "light", "data-testid": `mark-merged-${b.name}`, onClick: () => onMarkMerged(b.name), children: "Mark merged" })) })] }));
251
381
  }
252
382
  if (entry.kind === 'corrupt-metadata') {
253
383
  const purgeGate = purgeGateFor(entry);
254
- return (_jsxs(Table.Tr, { style: { backgroundColor: 'var(--mantine-color-red-light)' }, children: [_jsx(Table.Td, { children: entry.dirName }), _jsx(Table.Td, { children: _jsx(Badge, { color: "red", variant: "light", children: "corrupt metadata" }) }), _jsx(Table.Td, { colSpan: 3, children: _jsx(Tooltip, { label: entry.parseError, multiline: true, maw: 400, children: _jsx(Text, { size: "xs", c: "red", children: truncate(entry.parseError ?? 'Unknown parse error', 60) }) }) }), _jsx(Table.Td, { children: _jsx(Text, { size: "xs", c: "dimmed", children: entry.metaMtime ?? '—' }) }), _jsx(Table.Td, { children: _jsxs(Group, { gap: "xs", wrap: "nowrap", children: [_jsx(Button, { size: "xs", variant: "light", "data-testid": `repair-dir-${entry.dirName}`, onClick: () => onRepair(entry.dirName), children: "Repair" }), _jsx(Tooltip, { label: purgeGate.tooltip, disabled: !purgeGate.disabled, children: _jsx("span", { children: _jsx(Button, { size: "xs", variant: "light", color: "red", disabled: purgeGate.disabled, "data-testid": `purge-dir-${entry.dirName}`, onClick: () => onPurge(entry.dirName), children: "Purge" }) }) })] }) })] }));
384
+ return (_jsxs(Table.Tr, { style: { backgroundColor: 'var(--mantine-color-red-light)' }, children: [_jsx(Table.Td, { children: entry.dirName }), _jsx(Table.Td, { children: _jsx(Badge, { color: "red", variant: "light", children: "Corrupt metadata" }) }), _jsx(Table.Td, { colSpan: 3, children: _jsx(Tooltip, { label: entry.parseError, multiline: true, maw: 400, children: _jsx(Text, { size: "xs", c: "var(--canopy-danger-text)", children: truncate(entry.parseError ?? 'Unknown parse error', 60) }) }) }), _jsx(Table.Td, { children: _jsx(Text, { size: "xs", c: "dimmed", children: entry.metaMtime ?? '—' }) }), _jsx(Table.Td, { children: _jsxs(Group, { gap: "xs", wrap: "nowrap", children: [_jsx(Button, { variant: "light", "data-testid": `repair-dir-${entry.dirName}`, onClick: () => onRepair(entry.dirName), children: "Repair" }), _jsx(PurgeButton, { entry: entry, gate: purgeGate, onPurge: onPurge })] }) })] }));
255
385
  }
256
386
  // orphan
257
387
  const purgeGate = purgeGateFor(entry);
258
- return (_jsxs(Table.Tr, { style: { opacity: 0.65 }, children: [_jsx(Table.Td, { children: entry.dirName }), _jsx(Table.Td, { children: _jsx(Badge, { color: "gray", variant: "outline", children: "orphan" }) }), _jsx(Table.Td, { colSpan: 3, children: _jsxs(Text, { size: "xs", c: "dimmed", children: [entry.ageMs !== undefined ? `${formatAgeMs(entry.ageMs)} old` : 'unknown age', " \u00B7", ' ', entry.hasGitDir ? 'has .git' : 'no .git'] }) }), _jsx(Table.Td, { children: _jsx(Text, { size: "xs", c: "dimmed", children: entry.dirMtime ?? '—' }) }), _jsx(Table.Td, { children: _jsx(Tooltip, { label: purgeGate.tooltip, disabled: !purgeGate.disabled, children: _jsx("span", { children: _jsx(Button, { size: "xs", variant: "light", color: "red", disabled: purgeGate.disabled, "data-testid": `purge-dir-${entry.dirName}`, onClick: () => onPurge(entry.dirName), children: "Purge" }) }) }) })] }));
388
+ return (_jsxs(Table.Tr, { style: { opacity: 0.65 }, children: [_jsx(Table.Td, { children: entry.dirName }), _jsx(Table.Td, { children: _jsx(Badge, { color: "gray", variant: "outline", children: "Orphan" }) }), _jsx(Table.Td, { colSpan: 3, children: _jsxs(Text, { size: "xs", c: "dimmed", children: [entry.ageMs !== undefined ? `${formatAgeMs(entry.ageMs)} old` : 'unknown age', " \u00B7", ' ', entry.hasGitDir ? 'has .git' : 'no .git'] }) }), _jsx(Table.Td, { children: _jsx(Text, { size: "xs", c: "dimmed", children: entry.dirMtime ?? '—' }) }), _jsx(Table.Td, { children: _jsx(PurgeButton, { entry: entry, gate: purgeGate, onPurge: onPurge }) })] }));
259
389
  }
@@ -1,7 +1,16 @@
1
1
  import type { AdminStatusData, AdminTasksData, ListAdminTasksParams, DeleteTaskParams } from '../../api/admin.js';
2
2
  import type { BranchHealthData } from '../../api/admin-branch-health.js';
3
+ import type { DuplicateIdScan } from '../../branch-health.js';
3
4
  export type AdminTaskStatus = ListAdminTasksParams['status'];
4
5
  export type DeletableTaskStatus = DeleteTaskParams['status'];
6
+ /** The last completed duplicate-ID scan (`GET /admin/branch-health?duplicates=1`). */
7
+ interface DuplicateIdScanResult {
8
+ /** Healthy branches only, by dirName. A healthy row missing here was not scanned. */
9
+ byDir: Record<string, DuplicateIdScan>;
10
+ /** The server's time budget ran out before every healthy branch was scanned. */
11
+ truncated: boolean;
12
+ generatedAt: string;
13
+ }
5
14
  export interface UseSystemHealthOptions {
6
15
  /**
7
16
  * Whether the System Health panel is currently open. Data loads (and
@@ -25,6 +34,14 @@ export interface UseSystemHealthReturn {
25
34
  setTaskStatus: (status: AdminTaskStatus) => void;
26
35
  branchHealth: BranchHealthData | null;
27
36
  branchHealthLoading: boolean;
37
+ /** Null until a scan completes, and after a scan request fails. */
38
+ duplicateIdScan: DuplicateIdScanResult | null;
39
+ duplicateIdScanLoading: boolean;
40
+ duplicateIdScanError: string | null;
41
+ /** Runs the duplicate-ID scan. */
42
+ checkDuplicateIds: () => Promise<void>;
43
+ /** Archives the branch's duplicate files, then re-runs the scan. */
44
+ repairDuplicateIds: (dirName: string) => Promise<void>;
28
45
  /** Last fetch error across status/tasks/branchHealth, if any. */
29
46
  error: string | null;
30
47
  /** Refetches status, tasks (at the current taskStatus), and branchHealth. */
@@ -36,3 +53,4 @@ export interface UseSystemHealthReturn {
36
53
  markMerged: (branchName: string) => Promise<void>;
37
54
  }
38
55
  export declare function useSystemHealth(options: UseSystemHealthOptions): UseSystemHealthReturn;
56
+ export {};
@@ -6,6 +6,10 @@
6
6
  * then refresh()), but also polls every 30s while open (cleared on
7
7
  * close/unmount) since queue depth and worker liveness go stale quickly and
8
8
  * the panel has no other way to catch a worker recovering or a task finishing.
9
+ *
10
+ * The duplicate-ID scan is the exception: it reads every healthy branch's whole
11
+ * content tree, so it runs on open, on request, and after a duplicate repair,
12
+ * never on the poll.
9
13
  */
10
14
  import { useCallback, useEffect, useRef, useState } from 'react';
11
15
  import { notifications } from '@mantine/notifications';
@@ -28,6 +32,9 @@ export function useSystemHealth(options) {
28
32
  const [taskStatus, setTaskStatusState] = useState('failed');
29
33
  const [branchHealth, setBranchHealth] = useState(null);
30
34
  const [branchHealthLoading, setBranchHealthLoading] = useState(false);
35
+ const [duplicateIdScan, setDuplicateIdScan] = useState(null);
36
+ const [duplicateIdScanLoading, setDuplicateIdScanLoading] = useState(false);
37
+ const [duplicateIdScanError, setDuplicateIdScanError] = useState(null);
31
38
  const [error, setError] = useState(null);
32
39
  // refresh() has no status argument, so it needs the CURRENT taskStatus
33
40
  // without depending on it (that would tear down/rebuild the poll interval
@@ -74,7 +81,7 @@ export function useSystemHealth(options) {
74
81
  const fetchBranchHealth = useCallback(async () => {
75
82
  setBranchHealthLoading(true);
76
83
  try {
77
- const result = await apiClient.admin.branchHealth();
84
+ const result = await apiClient.admin.branchHealth({});
78
85
  if (!result.ok)
79
86
  throw new Error(result.error || 'Failed to load branch health');
80
87
  setBranchHealth(result.data ?? null);
@@ -87,6 +94,43 @@ export function useSystemHealth(options) {
87
94
  setBranchHealthLoading(false);
88
95
  }
89
96
  }, [apiClient]);
97
+ // Only the latest scan request may write state: a repair's re-scan can
98
+ // overlap the on-open scan, and the older response would bring back
99
+ // duplicates the repair just archived.
100
+ const duplicateScanSeq = useRef(0);
101
+ const checkDuplicateIds = useCallback(async () => {
102
+ const seq = ++duplicateScanSeq.current;
103
+ setDuplicateIdScanLoading(true);
104
+ try {
105
+ const result = await apiClient.admin.branchHealth({ duplicates: '1' });
106
+ if (seq !== duplicateScanSeq.current)
107
+ return;
108
+ if (!result.ok || !result.data?.duplicateIdScan) {
109
+ throw new Error(result.error || 'Failed to check for duplicate content IDs');
110
+ }
111
+ const byDir = {};
112
+ for (const entry of result.data.entries) {
113
+ if (entry.duplicateIdScan)
114
+ byDir[entry.dirName] = entry.duplicateIdScan;
115
+ }
116
+ setDuplicateIdScan({
117
+ byDir,
118
+ truncated: result.data.duplicateIdScan.truncated,
119
+ generatedAt: result.data.generatedAt,
120
+ });
121
+ setDuplicateIdScanError(null);
122
+ }
123
+ catch (err) {
124
+ if (seq !== duplicateScanSeq.current)
125
+ return;
126
+ setDuplicateIdScan(null);
127
+ setDuplicateIdScanError(err instanceof Error ? err.message : 'Failed to check for duplicate content IDs');
128
+ }
129
+ finally {
130
+ if (seq === duplicateScanSeq.current)
131
+ setDuplicateIdScanLoading(false);
132
+ }
133
+ }, [apiClient]);
90
134
  const refresh = useCallback(async () => {
91
135
  await Promise.all([fetchStatus(), fetchTasks(taskStatusRef.current), fetchBranchHealth()]);
92
136
  }, [fetchStatus, fetchTasks, fetchBranchHealth]);
@@ -101,6 +145,7 @@ export function useSystemHealth(options) {
101
145
  if (!options.isOpen)
102
146
  return;
103
147
  refresh();
148
+ void checkDuplicateIds();
104
149
  const interval = setInterval(() => {
105
150
  refresh();
106
151
  }, POLL_INTERVAL_MS);
@@ -168,6 +213,25 @@ export function useSystemHealth(options) {
168
213
  notifications.show({ message, color: 'red' });
169
214
  }
170
215
  }, [apiClient, refresh]);
216
+ const repairDuplicateIds = useCallback(async (dirName) => {
217
+ try {
218
+ const result = await apiClient.admin.repairContentDuplicates({ dirName });
219
+ if (!result.ok)
220
+ throw new Error(result.error || 'Failed to archive duplicate files');
221
+ const archived = (result.data?.resolved ?? []).reduce((n, r) => n + r.archivedAs.length, 0);
222
+ notifications.show({
223
+ message: `Archived ${archived} duplicate file${archived === 1 ? '' : 's'}`,
224
+ color: 'green',
225
+ });
226
+ }
227
+ catch (err) {
228
+ const message = err instanceof Error ? err.message : 'Failed to archive duplicate files';
229
+ notifications.show({ message, color: 'red' });
230
+ }
231
+ // Re-scan on failure too: a 409 or a partial repair leaves the branch in
232
+ // a state the last scan no longer describes.
233
+ await checkDuplicateIds();
234
+ }, [apiClient, checkDuplicateIds]);
171
235
  const markMerged = useCallback(async (branchName) => {
172
236
  try {
173
237
  const result = await apiClient.workflow.markMerged({ branch: branchName });
@@ -191,6 +255,11 @@ export function useSystemHealth(options) {
191
255
  setTaskStatus,
192
256
  branchHealth,
193
257
  branchHealthLoading,
258
+ duplicateIdScan,
259
+ duplicateIdScanLoading,
260
+ duplicateIdScanError,
261
+ checkDuplicateIds,
262
+ repairDuplicateIds,
194
263
  error,
195
264
  refresh,
196
265
  retryTask,
@@ -0,0 +1,17 @@
1
+ import type { BranchStatus } from '../types.js';
2
+ export interface BranchStatusPresentation {
3
+ label: string;
4
+ color: string;
5
+ variant: 'light' | 'outline';
6
+ }
7
+ /** @internal Exported for tests. */
8
+ export declare const PROTECTED_BRANCH_PRESENTATION: BranchStatusPresentation;
9
+ /** @internal Exported for tests. */
10
+ export declare const BRANCH_STATUS_PRESENTATION: Record<BranchStatus, BranchStatusPresentation>;
11
+ /**
12
+ * The badge for a branch: the protected base branch reads "Protected" whatever its workflow
13
+ * status, so a base branch never advertises a state it cannot act on. `status` is a string
14
+ * because server data is untyped at runtime; an unrecognised one shows as itself, neutrally.
15
+ * Returns undefined when there is nothing to show.
16
+ */
17
+ export declare function branchStatusPresentation(status: string | undefined, isProtected?: boolean): BranchStatusPresentation | undefined;
@@ -0,0 +1,29 @@
1
+ /** @internal Exported for tests. */
2
+ export const PROTECTED_BRANCH_PRESENTATION = {
3
+ label: 'Protected',
4
+ color: 'neutral',
5
+ variant: 'outline',
6
+ };
7
+ /** @internal Exported for tests. */
8
+ export const BRANCH_STATUS_PRESENTATION = {
9
+ editing: { label: 'Editing', color: 'brand', variant: 'light' },
10
+ submitted: { label: 'In review', color: 'green', variant: 'light' },
11
+ approved: { label: 'Approved', color: 'teal', variant: 'light' },
12
+ archived: { label: 'Archived', color: 'gray', variant: 'light' },
13
+ };
14
+ const isKnownStatus = (status) => Object.prototype.hasOwnProperty.call(BRANCH_STATUS_PRESENTATION, status);
15
+ /**
16
+ * The badge for a branch: the protected base branch reads "Protected" whatever its workflow
17
+ * status, so a base branch never advertises a state it cannot act on. `status` is a string
18
+ * because server data is untyped at runtime; an unrecognised one shows as itself, neutrally.
19
+ * Returns undefined when there is nothing to show.
20
+ */
21
+ export function branchStatusPresentation(status, isProtected = false) {
22
+ if (isProtected)
23
+ return PROTECTED_BRANCH_PRESENTATION;
24
+ if (!status)
25
+ return undefined;
26
+ if (isKnownStatus(status))
27
+ return BRANCH_STATUS_PRESENTATION[status];
28
+ return { label: status, color: 'gray', variant: 'light' };
29
+ }
@@ -1,16 +1,19 @@
1
+ /**
2
+ * The one spelling of a field path, `blocks[0].title`, for the form's fields, the preview's
3
+ * marks and comment threads. A digits-only segment is always a list index (`[0]` or `.0`):
4
+ * schemas refuse field names `isPathFieldName` rejects, since no path could spell them.
5
+ */
1
6
  export type CanopyPathSegment = string | number;
7
+ export declare const isPathFieldName: (name: string) => boolean;
2
8
  /**
3
9
  * Convert a list of path segments into the canonical CanopyCMS path string.
4
10
  * Arrays are rendered with bracket notation (e.g., blocks[0].title).
5
11
  */
6
- export declare const formatCanopyPath: (segments: CanopyPathSegment[]) => string;
12
+ export declare const formatCanopyPath: (segments: readonly CanopyPathSegment[]) => string;
7
13
  /**
8
14
  * Parse a CanopyCMS path string into segments. Supports bracketed array
9
15
  * indices and dotted segments (e.g., blocks.0.title or blocks[0].title).
10
16
  */
11
17
  export declare const parseCanopyPath: (path: string) => CanopyPathSegment[];
12
- /**
13
- * Normalize any supported path input (string or segments) into the
14
- * canonical bracketed representation.
15
- */
16
- export declare const normalizeCanopyPath: (input: string | CanopyPathSegment[]) => string;
18
+ /** The canonical string for a path; segments are re-parsed, so `['a', '0']` is `a[0]`. */
19
+ export declare const normalizeCanopyPath: (input: string | readonly CanopyPathSegment[]) => string;