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
@@ -1,10 +1,38 @@
1
- import { useEffect, useMemo, useRef, useState } from 'react';
1
+ import { createElement, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import { notifications } from '@mantine/notifications';
3
- import { modals } from '@mantine/modals';
3
+ import { openConfirm } from '../utils/confirm-modal.js';
4
+ import { focusReturnProps } from '../utils/focus-return.js';
4
5
  import equal from 'fast-deep-equal';
5
6
  import { getNotificationDuration } from '../utils/env.js';
6
7
  import { validateEntryFormValue } from '../../validation/entry-validator.js';
7
- import { SaveApiError } from './useEntryManager.js';
8
+ import { EntrySchemaUnavailableError, SaveApiError } from './useEntryManager.js';
9
+ import { SCHEMA_UNAVAILABLE_CODE } from '../unavailable-entry-type.js';
10
+ import { ENTRY_CHANGED_MESSAGE } from '../../api/entries-constants.js';
11
+ import { isUnanswered } from '../unanswered.js';
12
+ import { withItemKeys } from '../../item-keys.js';
13
+ import { RetryToastMessage } from '../components/RetryToastMessage.js';
14
+ import { clearSaveAllResult, settleSaveAllEntry } from '../components/SaveAllResult.js';
15
+ import { classifySaveAllError, invalidReason, SAVE_ALL_REASONS, } from './save-all.js';
16
+ /** @internal Exported for tests. */
17
+ export const SAVE_TIMED_OUT_MESSAGE = "Save didn't finish, and may not have gone through. Your changes are still here, so save again.";
18
+ /** @internal Exported for tests. Shown when a save may already have landed, until a reload. */
19
+ export const SAVE_MAY_HAVE_LANDED_MESSAGE = 'Your last save may already have gone through. Before saving again, choose "Refresh from server" in the entry\'s menu, on its name; your unsaved edits will be lost.';
20
+ const saveMayHaveLandedToastId = (contentId) => `canopy-save-may-have-landed:${contentId}`;
21
+ const saveTimedOutToastId = (contentId) => `canopy-save-timed-out:${contentId}`;
22
+ /** @internal Exported for tests. A save refused because the entry was deleted. */
23
+ export const ENTRY_DELETED_MESSAGE = 'This entry was deleted since you opened it, so it was not saved. Your changes are still in the form until the content list next refreshes and closes it: choose "Save as new entry" to keep them.';
24
+ /** @internal Exported for tests. A save refused because the entry was renamed; the editor has opened it. */
25
+ export const ENTRY_RENAMED_MESSAGE = 'This entry was renamed since you opened it, so it was not saved. Your changes moved with it and are still in the form: save again to keep them.';
26
+ /** @internal Exported for tests. "Save as new entry" refused because an entry, possibly the user's own earlier attempt, now holds the path. */
27
+ export const RECREATE_TAKEN_MESSAGE = "An entry with this entry's name already exists, perhaps from your own earlier attempt, so it was not saved as a new entry. Your changes are still in the form: copy what you want to keep into that entry.";
28
+ /** @internal Exported for tests. The save was refused as gone, and the check for a rename failed. */
29
+ export const ENTRY_GONE_UNCHECKED_MESSAGE = 'This entry was deleted or renamed since you opened it, so it was not saved, and the editor could not check which. Your changes are still in the form until the content list next refreshes and closes it: copy what you want to keep.';
30
+ /** @internal Exported for tests. "Save as new entry" landed, but the content list does not show it yet. */
31
+ export const RECREATED_NOT_LISTED_MESSAGE = "Saved as a new entry, but the content list doesn't show it yet. Reload the page in a moment to open it.";
32
+ /** @internal Exported for tests. "Save as new entry" got no answer. */
33
+ export const RECREATE_TIMED_OUT_MESSAGE = 'Saving as a new entry may have gone through even though the server didn\'t answer. Choose "Save as new entry" to try again: if it went through, you\'ll be told an entry with this name already exists.';
34
+ const entryGoneToastId = (contentId) => `canopy-entry-gone:${contentId}`;
35
+ const ENTRY_GONE_CODE = 'ENTRY_GONE';
8
36
  /** Collapse a list of per-field errors into a path → message map (first error per path wins). */
9
37
  const toFieldErrorMap = (errors) => {
10
38
  const map = {};
@@ -63,6 +91,9 @@ const parsePersistedDrafts = (raw) => {
63
91
  baseVersions[id] = null;
64
92
  return { drafts, baseVersions };
65
93
  };
94
+ const VERIFY_CONCURRENCY = 4;
95
+ /** How long `whenDraftsVerified` waits before answering with whatever is known. */
96
+ const VERIFY_WAIT_CAP_MS = 3000;
66
97
  /**
67
98
  * Custom hook for managing draft state (localStorage persistence, save/discard).
68
99
  */
@@ -87,6 +118,33 @@ export function useDraftManager(options) {
87
118
  // preceded the entry's first load); treated as safe, matching
88
119
  // the behavior before base versions existed.
89
120
  const draftBaseVersionsRef = useRef({});
121
+ const [draftStorageFailed, setDraftStorageFailed] = useState(false);
122
+ const [lastSaved, setLastSaved] = useState(null);
123
+ const [singleSaving, setSingleSaving] = useState(null);
124
+ const [saveInFlight, setSaveInFlight] = useState(false);
125
+ const [saveAllProgress, setSaveAllProgress] = useState(null);
126
+ const saveAllRunningRef = useRef(false);
127
+ const mountedRef = useRef(true);
128
+ // Entries a Save, refresh or discard settled while Save all ran: its result no longer applies.
129
+ const settledDuringRunRef = useRef(new Set());
130
+ const settleEntry = (id) => {
131
+ if (saveAllRunningRef.current)
132
+ settledDuringRunRef.current.add(id);
133
+ settleSaveAllEntry(id);
134
+ };
135
+ const singleSaveRef = useRef(false);
136
+ // contentId -> the token held when a save came back WRITE_OUTCOME_UNKNOWN, or when the retry of
137
+ // a timed-out save was refused as a version conflict. That save may have landed, so re-sending
138
+ // the token would bounce off the user's own write; held until re-read.
139
+ const reloadRequiredRef = useRef({});
140
+ // Entries whose timed-out save toast is up, taken down once a save, reload, discard or branch
141
+ // switch ends it, or a retry is refused as having landed.
142
+ const timedOutSavesRef = useRef(new Set());
143
+ // Entries whose "deleted" toast is up, taken down once a save, reload, discard or branch switch
144
+ // ends it.
145
+ const goneEntriesRef = useRef(new Set());
146
+ // Entries whose "Save as new entry" create is in flight, so a second activation sends none.
147
+ const recreatingRef = useRef(new Set());
90
148
  // The draft ids the reconcile effect below has already accounted for. It
91
149
  // prunes on an observed present -> absent TRANSITION rather than on "not
92
150
  // currently in drafts": the restore effect stamps base versions for ids
@@ -99,52 +157,124 @@ export function useDraftManager(options) {
99
157
  const storageKey = useMemo(() => `canopycms:drafts:${options.branchName}`, [options.branchName]);
100
158
  // Draft keys are now content IDs, not paths
101
159
  const currentId = options.currentEntry?.contentId;
160
+ const currentIdRef = useRef(currentId);
161
+ currentIdRef.current = currentId;
102
162
  const selectedValue = currentId ? drafts[currentId] : undefined;
103
163
  const loadedValue = currentId ? loadedValues[currentId] : undefined;
104
164
  const effectiveValue = selectedValue ?? loadedValue;
105
165
  const fieldErrors = useMemo(() => (errorState && errorState.entryId === currentId ? errorState.errors : {}), [errorState, currentId]);
106
- // Number of draft entries that differ from their loaded server value.
166
+ // Server values read for restored drafts of unopened entries that differ from
167
+ // the draft. Not `loadedValues`: that map gates the entry load and OCC token.
168
+ const [verifiedBaselines, setVerifiedBaselines] = useState({});
169
+ // Ids whose verification read failed: still dirty, but no longer awaited.
170
+ const [unreadableIds, setUnreadableIds] = useState(() => new Set());
171
+ const requestedIdsRef = useRef(new Set());
172
+ const verifyQueueRef = useRef([]);
173
+ const verifyActiveRef = useRef(0);
174
+ const verifyWaitersRef = useRef([]);
175
+ const entryById = useMemo(() => new Map(options.entries.map((e) => [e.contentId, e])), [options.entries]);
176
+ // Empty while a branch switch loads, so only a non-empty list can say an entry is gone.
177
+ const entriesKnown = options.entries.length > 0;
178
+ // The draft ids that count as unsaved work:
107
179
  //
108
- // Two intentional behaviors worth noting:
180
+ // 1. A draft with no baseline (loaded or verified value) counts as dirty: it
181
+ // cannot be proven to match the server, and counting it keeps the
182
+ // branch-switch guard from silently discarding restored drafts.
183
+ // Verification (below) removes the ones that turn out pristine.
109
184
  //
110
- // 1. A draft without a corresponding `loadedValues` entry (e.g. a localStorage-restored
111
- // draft whose entry has not been opened in this session) is counted as dirty. We
112
- // cannot prove such a draft matches server state, so we conservatively treat it
113
- // as unsaved work — this is what keeps the branch-switch guard from silently
114
- // discarding restored drafts.
185
+ // 2. A draft for an entry that no longer exists can never be loaded or saved,
186
+ // so it is not counted. It stays in storage.
115
187
  //
116
- // 2. The comparison uses `fast-deep-equal`, a value-based deep equality
117
- // check -- not property-order sensitive the way `JSON.stringify`
118
- // comparison was. A rehydrated draft whose keys were serialized in a
119
- // different order than the server-loaded object no longer shows as
120
- // dirty when the values are semantically identical.
121
- const modifiedCount = useMemo(() => Object.keys(drafts).filter((id) => !loadedValues[id] || !equal(drafts[id], loadedValues[id]))
122
- .length, [drafts, loadedValues]);
123
- const editedFiles = useMemo(() => {
124
- const draftIds = Object.keys(drafts);
125
- if (draftIds.length === 0)
126
- return [];
127
- return draftIds
128
- .map((id) => {
129
- const entry = options.entries.find((e) => e.contentId === id);
130
- return entry ? { path: entry.path, label: entry.label } : null;
131
- })
132
- .filter((x) => x !== null);
133
- }, [drafts, options.entries]);
188
+ // 3. The comparison is `fast-deep-equal`, so key insertion order of a
189
+ // rehydrated draft does not make it dirty.
190
+ const dirtyIds = useMemo(() => Object.keys(drafts).filter((id) => {
191
+ if (entriesKnown && !entryById.has(id))
192
+ return false;
193
+ const baseline = loadedValues[id] ?? verifiedBaselines[id];
194
+ return baseline === undefined || !equal(drafts[id], baseline);
195
+ }), [drafts, loadedValues, verifiedBaselines, entriesKnown, entryById]);
196
+ const modifiedCount = dirtyIds.length;
197
+ const editedFiles = useMemo(() => dirtyIds
198
+ .map((id) => {
199
+ const entry = entryById.get(id);
200
+ return entry ? { path: entry.path, label: entry.label } : null;
201
+ })
202
+ .filter((x) => x !== null), [dirtyIds, entryById]);
203
+ // Draft ids verification can still settle.
204
+ const unresolvedIds = useMemo(() => options.readEntryValue && entriesKnown
205
+ ? Object.keys(drafts).filter((id) => entryById.has(id) &&
206
+ loadedValues[id] === undefined &&
207
+ verifiedBaselines[id] === undefined &&
208
+ !unreadableIds.has(id))
209
+ : [], [
210
+ drafts,
211
+ loadedValues,
212
+ verifiedBaselines,
213
+ unreadableIds,
214
+ entriesKnown,
215
+ entryById,
216
+ options.readEntryValue,
217
+ ]);
218
+ // Async callbacks outlive the render that created them; these give them current values.
219
+ const latestRef = useRef({
220
+ drafts,
221
+ loadedValues,
222
+ unresolvedCount: 0,
223
+ modifiedCount,
224
+ editedFiles,
225
+ dirtyIds,
226
+ entryById,
227
+ });
228
+ latestRef.current = {
229
+ drafts,
230
+ loadedValues,
231
+ unresolvedCount: unresolvedIds.length,
232
+ modifiedCount,
233
+ editedFiles,
234
+ dirtyIds,
235
+ entryById,
236
+ };
237
+ const latestOptionsRef = useRef(options);
238
+ latestOptionsRef.current = options;
134
239
  // Clear drafts when branch changes (before localStorage restore)
135
240
  const prevBranchRef = useRef(options.branchName);
136
241
  useEffect(() => {
137
242
  if (prevBranchRef.current && prevBranchRef.current !== options.branchName) {
138
243
  setDrafts({});
139
244
  setLoadedValues({});
245
+ setVerifiedBaselines({});
246
+ setUnreadableIds(new Set());
247
+ requestedIdsRef.current = new Set();
248
+ verifyQueueRef.current = [];
140
249
  // Base versions are file mtimes, i.e. inherently per-branch -- carrying
141
250
  // them across a switch would compare one branch's version against
142
251
  // another's.
143
252
  draftBaseVersionsRef.current = {};
253
+ for (const id of Object.keys(reloadRequiredRef.current)) {
254
+ notifications.hide(saveMayHaveLandedToastId(id));
255
+ }
256
+ reloadRequiredRef.current = {};
257
+ // A timed-out save belongs to the branch it was sent to; content ids repeat across branches.
258
+ for (const id of timedOutSavesRef.current)
259
+ notifications.hide(saveTimedOutToastId(id));
260
+ timedOutSavesRef.current.clear();
261
+ for (const id of goneEntriesRef.current)
262
+ notifications.hide(entryGoneToastId(id));
263
+ goneEntriesRef.current.clear();
264
+ // Its entries, and their "Open" and "Save as new entry" buttons, are the old branch's.
265
+ clearSaveAllResult();
144
266
  reconciledDraftIdsRef.current = new Set();
145
267
  }
146
268
  prevBranchRef.current = options.branchName;
147
269
  }, [options.branchName]);
270
+ // A result left up would act through this unmounted instance's callbacks.
271
+ useEffect(() => {
272
+ mountedRef.current = true;
273
+ return () => {
274
+ mountedRef.current = false;
275
+ clearSaveAllResult();
276
+ };
277
+ }, []);
148
278
  // Restore drafts from localStorage on mount or when storageKey changes
149
279
  useEffect(() => {
150
280
  if (typeof window === 'undefined')
@@ -256,11 +386,145 @@ export function useDraftManager(options) {
256
386
  baseVersions: draftBaseVersionsRef.current,
257
387
  };
258
388
  window.localStorage.setItem(storageKey, JSON.stringify(payload));
389
+ setDraftStorageFailed(false);
259
390
  }
260
391
  catch (err) {
261
392
  console.warn('Failed to persist drafts', err);
393
+ setDraftStorageFailed(true);
262
394
  }
263
395
  }, [drafts, storageKey]);
396
+ // Drafts survive a reload in localStorage, so leaving is guarded only when it loses work.
397
+ const leaveLosesWork = saveInFlight || (draftStorageFailed && modifiedCount > 0);
398
+ useEffect(() => {
399
+ if (!leaveLosesWork || typeof window === 'undefined')
400
+ return;
401
+ const warn = (event) => {
402
+ event.preventDefault();
403
+ event.returnValue = '';
404
+ };
405
+ window.addEventListener('beforeunload', warn);
406
+ return () => window.removeEventListener('beforeunload', warn);
407
+ }, [leaveLosesWork]);
408
+ // A pre-key draft gains the keys a read gives, so an unchanged one drops, if free of references.
409
+ useEffect(() => {
410
+ const keyed = new Map();
411
+ for (const [id, draft] of Object.entries(drafts)) {
412
+ const schema = entryById.get(id)?.schema;
413
+ const next = schema ? withItemKeys(schema, draft, 'content') : draft;
414
+ if (next !== draft)
415
+ keyed.set(id, next);
416
+ }
417
+ if (keyed.size === 0)
418
+ return;
419
+ setDrafts((prev) => {
420
+ const next = { ...prev };
421
+ for (const [id, value] of keyed)
422
+ if (prev[id] === drafts[id])
423
+ next[id] = value;
424
+ return next;
425
+ });
426
+ }, [drafts, entryById]);
427
+ // A draft equal to its entry's loaded value is not an edit. Drops it so it
428
+ // neither counts as dirty nor lingers in storage.
429
+ useEffect(() => {
430
+ const redundant = Object.keys(drafts).filter((id) => loadedValues[id] !== undefined && equal(drafts[id], loadedValues[id]));
431
+ if (redundant.length === 0)
432
+ return;
433
+ setDrafts((prev) => {
434
+ const next = { ...prev };
435
+ let changed = false;
436
+ for (const id of redundant) {
437
+ if (id in next && equal(next[id], loadedValues[id])) {
438
+ delete next[id];
439
+ changed = true;
440
+ }
441
+ }
442
+ return changed ? next : prev;
443
+ });
444
+ }, [drafts, loadedValues]);
445
+ // Checks one restored draft against the server. The branch is re-read at
446
+ // settle time: a switch mid-read makes the result another branch's value.
447
+ const verifyDraft = async (job) => {
448
+ const id = job.entry.contentId;
449
+ const read = latestOptionsRef.current.readEntryValue;
450
+ if (!read)
451
+ return;
452
+ // The server refuses to read an entry of an unavailable type, so it settles unread at once.
453
+ if (job.entry.unavailable) {
454
+ setUnreadableIds((prev) => new Set(prev).add(id));
455
+ return;
456
+ }
457
+ try {
458
+ const server = await read(job.entry);
459
+ if (latestOptionsRef.current.branchName !== job.branch)
460
+ return;
461
+ const { drafts: latestDrafts, loadedValues: latestLoaded } = latestRef.current;
462
+ // Gone, or the entry's own load has taken over as the baseline.
463
+ if (latestDrafts[id] === undefined || latestLoaded[id] !== undefined)
464
+ return;
465
+ if (equal(latestDrafts[id], server)) {
466
+ setDrafts((prev) => {
467
+ if (!(id in prev) || !equal(prev[id], server))
468
+ return prev;
469
+ const next = { ...prev };
470
+ delete next[id];
471
+ return next;
472
+ });
473
+ }
474
+ else {
475
+ setVerifiedBaselines((prev) => ({ ...prev, [id]: server }));
476
+ }
477
+ }
478
+ catch {
479
+ // Stays unverified, so the draft keeps counting as dirty.
480
+ if (latestOptionsRef.current.branchName !== job.branch)
481
+ return;
482
+ setUnreadableIds((prev) => new Set(prev).add(id));
483
+ }
484
+ };
485
+ const pumpVerification = () => {
486
+ while (verifyActiveRef.current < VERIFY_CONCURRENCY && verifyQueueRef.current.length > 0) {
487
+ const job = verifyQueueRef.current.shift();
488
+ if (!job)
489
+ break;
490
+ verifyActiveRef.current++;
491
+ void verifyDraft(job).finally(() => {
492
+ verifyActiveRef.current--;
493
+ pumpVerification();
494
+ });
495
+ }
496
+ };
497
+ // Once per branch session, reads the server value of every restored draft
498
+ // whose entry has not loaded. The selected entry is left to its own load,
499
+ // which fills `loadedValues` and lets the effect above drop a pristine draft.
500
+ useEffect(() => {
501
+ if (!options.readEntryValue || !entriesKnown)
502
+ return;
503
+ // `drafts` lags a branch switch by a render (see draftsStorageKeyRef); reading
504
+ // the old branch's drafts under the new branch's name would be wrong.
505
+ if (draftsStorageKeyRef.current !== storageKey)
506
+ return;
507
+ const todo = unresolvedIds.filter((id) => id !== currentId && !requestedIdsRef.current.has(id));
508
+ if (todo.length === 0)
509
+ return;
510
+ for (const id of todo) {
511
+ requestedIdsRef.current.add(id);
512
+ const entry = entryById.get(id);
513
+ if (entry)
514
+ verifyQueueRef.current.push({ entry, branch: options.branchName });
515
+ }
516
+ pumpVerification();
517
+ // pumpVerification/verifyDraft only touch refs and stable setters.
518
+ }, [unresolvedIds, currentId, storageKey]);
519
+ // Wakes `whenDraftsVerified` callers once nothing is left to settle.
520
+ useEffect(() => {
521
+ if (unresolvedIds.length > 0)
522
+ return;
523
+ const waiters = verifyWaitersRef.current;
524
+ verifyWaitersRef.current = [];
525
+ for (const wake of waiters)
526
+ wake();
527
+ }, [unresolvedIds]);
264
528
  // Recomputes field errors as the user edits, or as the selected entry's
265
529
  // schema/format changes while it stays open, so each error clears when its
266
530
  // field is fixed or no longer required. Errors for a different entry are
@@ -297,20 +561,123 @@ export function useDraftManager(options) {
297
561
  });
298
562
  }, [effectiveValue, currentId, options.currentEntry?.schema, options.currentEntry?.format]);
299
563
  /**
300
- * The 409 presentation, in one place. The client-side stale-base check below
301
- * and the server's own 409 response are the same situation from the user's
302
- * point of view -- someone else's work is at risk -- so they must read
303
- * identically, and "Reload" (now itself confirmed, see `handleReload`) is the
304
- * recovery for both.
564
+ * The 409 presentation. Server 409s differ in cause and recovery (version
565
+ * moved on, branch syncing, write may have landed), so their own wording is
566
+ * shown; the default is the version-mismatch wording the stale-base check
567
+ * below shares.
305
568
  */
306
- const showConflictNotification = () => {
569
+ const showConflictNotification = (message = ENTRY_CHANGED_MESSAGE) => {
307
570
  notifications.show({
308
- message: 'Content was modified by another editor. Reload to see the latest changes.',
571
+ message,
309
572
  color: 'yellow',
310
573
  autoClose: getNotificationDuration(8000),
311
574
  withCloseButton: true,
312
575
  });
313
576
  };
577
+ /**
578
+ * Stays until dismissed or the entry is reloaded: saving is held until then. Named, since it
579
+ * outlasts opening other entries.
580
+ */
581
+ const showSaveMayHaveLanded = (entry, message = SAVE_MAY_HAVE_LANDED_MESSAGE) => {
582
+ notifications.hide(saveMayHaveLandedToastId(entry.contentId));
583
+ notifications.show({
584
+ id: saveMayHaveLandedToastId(entry.contentId),
585
+ title: `No answer saving "${entry.label}"`,
586
+ message: message || SAVE_MAY_HAVE_LANDED_MESSAGE,
587
+ color: 'yellow',
588
+ autoClose: false,
589
+ withCloseButton: true,
590
+ });
591
+ };
592
+ const hideEntryGone = (contentId) => {
593
+ if (goneEntriesRef.current.delete(contentId))
594
+ notifications.hide(entryGoneToastId(contentId));
595
+ };
596
+ /**
597
+ * Sticky: the form shows the changes only until the list next refreshes and closes it. Offers the
598
+ * deliberate create on the save's branch. Hidden first so a repeat is announced again.
599
+ */
600
+ const showEntryGone = (entry, branch, message = ENTRY_DELETED_MESSAGE, offerCreate = true) => {
601
+ goneEntriesRef.current.add(entry.contentId);
602
+ notifications.hide(entryGoneToastId(entry.contentId));
603
+ notifications.show({
604
+ id: entryGoneToastId(entry.contentId),
605
+ title: `"${entry.label}" not saved`,
606
+ message: offerCreate
607
+ ? createElement(RetryToastMessage, {
608
+ message,
609
+ actionLabel: 'Save as new entry',
610
+ onRetry: () => void recreateGoneEntry(entry, branch),
611
+ })
612
+ : message,
613
+ color: 'yellow',
614
+ autoClose: false,
615
+ withCloseButton: true,
616
+ });
617
+ };
618
+ /** "Save as new entry", once at a time, and only on its branch: after a switch the drafts are another branch's. */
619
+ const recreateGoneEntry = async (entry, branch) => {
620
+ const id = entry.contentId;
621
+ const onBranch = () => latestOptionsRef.current.branchName === branch;
622
+ const inFlightKey = `${branch}:${id}`;
623
+ if (recreatingRef.current.has(inFlightKey))
624
+ return;
625
+ if (!onBranch()) {
626
+ notifications.hide(entryGoneToastId(id));
627
+ return;
628
+ }
629
+ const { drafts: latestDrafts, loadedValues: latestLoaded } = latestRef.current;
630
+ const value = latestDrafts[id] ?? latestLoaded[id];
631
+ const recreate = latestOptionsRef.current.recreateEntry;
632
+ if (!value || !recreate)
633
+ return;
634
+ recreatingRef.current.add(inFlightKey);
635
+ hideEntryGone(id);
636
+ options.setBusy(true);
637
+ try {
638
+ const created = await recreate(entry, value);
639
+ if (!onBranch())
640
+ return;
641
+ if (created) {
642
+ // The changes now live in the new entry, under a new content ID; edits made while the
643
+ // create was in flight follow it there.
644
+ setDrafts((prev) => {
645
+ if (!(id in prev))
646
+ return prev;
647
+ const { [id]: latest, ...rest } = prev;
648
+ return equal(latest, value) ? rest : { ...rest, [created.contentId]: latest };
649
+ });
650
+ }
651
+ if (created)
652
+ settleEntry(id);
653
+ notifications.show({
654
+ message: created ? 'Saved as a new entry' : RECREATED_NOT_LISTED_MESSAGE,
655
+ color: 'green',
656
+ autoClose: getNotificationDuration(created ? 4000 : 8000),
657
+ withCloseButton: true,
658
+ });
659
+ }
660
+ catch (err) {
661
+ console.error(err);
662
+ if (!onBranch())
663
+ return;
664
+ if (err instanceof SaveApiError && err.status === 409) {
665
+ showEntryGone(entry, branch, RECREATE_TAKEN_MESSAGE);
666
+ }
667
+ else if (isUnanswered(err)) {
668
+ showEntryGone(entry, branch, RECREATE_TIMED_OUT_MESSAGE);
669
+ }
670
+ else {
671
+ showEntryGone(entry, branch, err instanceof SaveApiError && err.serverMessage
672
+ ? `It was not saved as a new entry: ${err.serverMessage}`
673
+ : 'It was not saved as a new entry. Your changes are still in the form.');
674
+ }
675
+ }
676
+ finally {
677
+ recreatingRef.current.delete(inFlightKey);
678
+ options.setBusy(false);
679
+ }
680
+ };
314
681
  const draftBaseState = () => {
315
682
  if (!currentId || drafts[currentId] === undefined)
316
683
  return 'ok';
@@ -345,12 +712,13 @@ export function useDraftManager(options) {
345
712
  decided = true;
346
713
  resolve(value);
347
714
  };
348
- modals.openConfirmModal({
715
+ openConfirm({
349
716
  title: 'Unsaved draft from an older editor version',
350
717
  children: 'This draft was saved by an earlier version of the editor, which did not record ' +
351
- 'which version of the file it was based on. Saving it will overwrite the copy ' +
718
+ 'which version of the entry it was based on. Saving it will overwrite the copy ' +
352
719
  'currently on the server, including any changes made by someone else since. ' +
353
- 'Reload or Discard this file instead if you would rather not risk that.',
720
+ 'Choose "Refresh from server" or "Discard changes" in the entry\'s menu, on its name, ' +
721
+ 'instead if you would rather not risk that.',
354
722
  labels: { confirm: 'Save anyway', cancel: 'Cancel' },
355
723
  confirmProps: { color: 'red' },
356
724
  onConfirm: () => settle(true),
@@ -362,10 +730,23 @@ export function useDraftManager(options) {
362
730
  });
363
731
  });
364
732
  const handleSave = async () => {
365
- if (!options.currentEntry || !effectiveValue || !currentId)
733
+ // Save all may be sending this entry with the same token; the loser's 409 would be a false conflict.
734
+ if (saveAllRunningRef.current)
366
735
  return;
736
+ const entry = options.currentEntry;
737
+ const branch = options.branchName;
738
+ if (!entry || !effectiveValue || !currentId)
739
+ return;
740
+ if (currentId in reloadRequiredRef.current) {
741
+ if (reloadRequiredRef.current[currentId] === options.getEntryVersion?.(currentId)) {
742
+ showSaveMayHaveLanded(entry);
743
+ return;
744
+ }
745
+ delete reloadRequiredRef.current[currentId];
746
+ notifications.hide(saveMayHaveLandedToastId(currentId));
747
+ }
367
748
  // Conflict check first: a stale-based draft must not be schema-validated
368
- // into a green "Saved" -- and nagging about field errors is noise when the
749
+ // into a "Saved" -- and nagging about field errors is noise when the
369
750
  // save is not going out at all.
370
751
  const baseState = draftBaseState();
371
752
  if (baseState === 'stale') {
@@ -384,10 +765,11 @@ export function useDraftManager(options) {
384
765
  }
385
766
  // Client-side pre-save validation (ED-H1): the same pure schema rules the
386
767
  // server enforces authoritatively at the write boundary. Blocks the save
387
- // and surfaces per-field errors in the form instead of a green "Saved".
388
- const validationErrors = validateEntryFormValue(options.currentEntry.schema, options.currentEntry.format, effectiveValue);
768
+ // and surfaces per-field errors in the form instead of a "Saved".
769
+ const validationErrors = validateEntryFormValue(entry.schema, entry.format, effectiveValue);
389
770
  if (validationErrors.length > 0) {
390
771
  setErrorState({ entryId: currentId, errors: toFieldErrorMap(validationErrors) });
772
+ options.onSaveBlocked?.();
391
773
  notifications.show({
392
774
  title: 'Cannot save yet',
393
775
  message: `Fix ${validationErrors.length} validation ${validationErrors.length === 1 ? 'issue' : 'issues'} before saving`,
@@ -399,17 +781,21 @@ export function useDraftManager(options) {
399
781
  }
400
782
  setErrorState(null);
401
783
  options.setBusy(true);
784
+ setSaveInFlight(true);
785
+ setSingleSaving({ branch, contentId: currentId });
786
+ singleSaveRef.current = true;
402
787
  try {
403
- const saved = await options.saveEntry(options.currentEntry, effectiveValue);
788
+ const saved = await options.saveEntry(entry, effectiveValue);
789
+ if (latestOptionsRef.current.branchName !== branch)
790
+ return;
404
791
  // Drop the draft now that it has been persisted, rather than
405
792
  // overwriting it with `saved`. `effectiveValue` is `drafts[currentId]
406
793
  // ?? loadedValues[currentId]`, and `loadedValues[currentId]` is about
407
794
  // to become `saved` below, so removing the draft key is a no-op for
408
795
  // the rendered value while fixing the "phantom dirty" bug: a draft
409
796
  // that lingers forever is what made every fresh page load show Save
410
- // enabled with zero real edits (see modifiedCount's doc comment above
411
- // — a draft without a matching loadedValues entry is conservatively
412
- // treated as dirty).
797
+ // enabled with zero real edits (see `dirtyIds` above: a draft with no
798
+ // baseline is conservatively treated as dirty).
413
799
  setDrafts((prev) => {
414
800
  if (!(currentId in prev))
415
801
  return prev;
@@ -418,16 +804,20 @@ export function useDraftManager(options) {
418
804
  return next;
419
805
  });
420
806
  setLoadedValues((prev) => ({ ...prev, [currentId]: saved }));
421
- notifications.show({
422
- message: 'Saved',
423
- color: 'green',
424
- autoClose: getNotificationDuration(4000),
425
- withCloseButton: true,
426
- });
807
+ if (timedOutSavesRef.current.delete(currentId)) {
808
+ notifications.hide(saveTimedOutToastId(currentId));
809
+ }
810
+ hideEntryGone(currentId);
811
+ settleEntry(currentId);
812
+ // Said on the Save button, not in a toast.
813
+ setLastSaved((prev) => ({ branch, contentId: currentId, seq: (prev?.seq ?? 0) + 1 }));
427
814
  options.onSaved?.();
428
815
  }
429
816
  catch (err) {
430
817
  console.error(err);
818
+ // Content ids repeat across branches: none of what follows belongs on the next one.
819
+ if (latestOptionsRef.current.branchName !== branch)
820
+ return;
431
821
  const isConflict = err instanceof SaveApiError && err.status === 409;
432
822
  // 422 = rejected by server-side schema validation or the adopter's
433
823
  // validateEntry hook; show its message and map per-field errors (e.g.
@@ -445,31 +835,330 @@ export function useDraftManager(options) {
445
835
  // the moment the user is most confused, so surface the server's own
446
836
  // message instead, the same way the 422 branch already does.
447
837
  const isForbidden = err instanceof SaveApiError && err.status === 403;
838
+ // The entry's type has no schema in the running code; the server's message says why.
839
+ const isUnavailable = err instanceof SaveApiError && err.code === SCHEMA_UNAVAILABLE_CODE;
448
840
  if (err instanceof SaveApiError &&
449
841
  err.status === 422 &&
450
842
  err.fieldErrors &&
451
843
  err.fieldErrors.length > 0) {
452
844
  setErrorState({ entryId: currentId, errors: toFieldErrorMap(err.fieldErrors) });
845
+ options.onSaveBlocked?.();
453
846
  }
454
- if (isConflict) {
455
- showConflictNotification();
847
+ if (isConflict && err.code === ENTRY_GONE_CODE) {
848
+ // Deleted or renamed since it was read, by someone else or by a delete or rename of this
849
+ // user's that got no answer. A retry cannot save it, so the timed-out toast's "save
850
+ // again" no longer applies. A rename keeps the content ID, so the entry is looked for
851
+ // under its new name, where the draft follows it.
852
+ if (timedOutSavesRef.current.delete(currentId)) {
853
+ notifications.hide(saveTimedOutToastId(currentId));
854
+ }
855
+ let moved;
856
+ let checked = true;
857
+ try {
858
+ moved = await options.followMovedEntry?.(entry);
859
+ }
860
+ catch (followErr) {
861
+ console.error(followErr);
862
+ checked = false;
863
+ }
864
+ // A branch switch meanwhile cleared this branch's toasts; none of these belong on the next.
865
+ if (latestOptionsRef.current.branchName !== branch)
866
+ return;
867
+ if (moved) {
868
+ hideEntryGone(currentId);
869
+ notifications.show({
870
+ title: `"${entry.label}" not saved`,
871
+ message: ENTRY_RENAMED_MESSAGE,
872
+ color: 'yellow',
873
+ autoClose: getNotificationDuration(8000),
874
+ withCloseButton: true,
875
+ });
876
+ }
877
+ else if (checked) {
878
+ showEntryGone(entry, branch);
879
+ }
880
+ else {
881
+ // Deleted or renamed, but which is unknown, so a create could duplicate a renamed entry.
882
+ showEntryGone(entry, branch, ENTRY_GONE_UNCHECKED_MESSAGE, false);
883
+ }
884
+ }
885
+ else if (isConflict) {
886
+ // A version conflict on the retry of a save that went unanswered is most likely that
887
+ // save having landed, which a reload settles just as for WRITE_OUTCOME_UNKNOWN.
888
+ const ownSaveLanded = err.serverMessage === ENTRY_CHANGED_MESSAGE && timedOutSavesRef.current.has(currentId);
889
+ if (err.code === 'WRITE_OUTCOME_UNKNOWN' || ownSaveLanded) {
890
+ reloadRequiredRef.current[currentId] = options.getEntryVersion?.(currentId);
891
+ }
892
+ if (ownSaveLanded) {
893
+ timedOutSavesRef.current.delete(currentId);
894
+ notifications.hide(saveTimedOutToastId(currentId));
895
+ }
896
+ if (err.code === 'WRITE_OUTCOME_UNKNOWN' || ownSaveLanded) {
897
+ showSaveMayHaveLanded(entry, ownSaveLanded ? undefined : err.serverMessage);
898
+ }
899
+ else {
900
+ showConflictNotification(err.serverMessage || undefined);
901
+ }
902
+ }
903
+ else if (isUnanswered(err)) {
904
+ // A save that did land moved the file's version, so the server refuses the retry as a
905
+ // conflict; a file since deleted or moved refuses it as gone (`ContentStore.write`).
906
+ timedOutSavesRef.current.add(currentId);
907
+ notifications.show({
908
+ id: saveTimedOutToastId(currentId),
909
+ title: `No answer saving "${entry.label}"`,
910
+ message: SAVE_TIMED_OUT_MESSAGE,
911
+ color: 'yellow',
912
+ autoClose: false,
913
+ withCloseButton: true,
914
+ });
456
915
  }
457
916
  else {
917
+ const explained = isValidation || isForbidden || isUnavailable;
458
918
  notifications.show({
459
919
  ...(isValidation ? { title: 'Save rejected' } : {}),
460
920
  ...(isForbidden ? { title: 'Save not allowed' } : {}),
461
- message: isValidation || isForbidden ? err.message : 'Save failed',
462
- color: 'red',
463
- autoClose: getNotificationDuration(isValidation || isForbidden ? 8000 : 6000),
921
+ ...(isUnavailable ? { title: 'Not saved yet' } : {}),
922
+ message: explained ? err.message : 'Save failed',
923
+ color: isUnavailable ? 'yellow' : 'red',
924
+ autoClose: getNotificationDuration(explained ? 8000 : 6000),
464
925
  withCloseButton: true,
465
926
  });
466
927
  }
467
928
  }
468
929
  finally {
930
+ singleSaveRef.current = false;
931
+ setSingleSaving(null);
932
+ setSaveInFlight(false);
933
+ options.setBusy(false);
934
+ }
935
+ };
936
+ /**
937
+ * A save that got no answer landed if the server now holds exactly what was sent. Only that
938
+ * answer settles it: a differing value may be the server's normalisation of a save that did
939
+ * land, so it leaves the token alone and the retry's 409 decides, as for `handleSave`.
940
+ */
941
+ const settleUnansweredSave = async (entry, sent) => {
942
+ const { readEntryValue, loadEntry } = latestOptionsRef.current;
943
+ try {
944
+ if (!readEntryValue || !equal(await readEntryValue(entry), sent))
945
+ return undefined;
946
+ const loaded = await loadEntry(entry);
947
+ return equal(loaded, sent) ? loaded : undefined;
948
+ }
949
+ catch (err) {
950
+ console.error(err);
951
+ return undefined;
952
+ }
953
+ };
954
+ /**
955
+ * One entry of Save all, through the same checks as `handleSave`: held saves, the draft's base
956
+ * version, schema validation, then the OCC-checked write. An entry not opened this session has
957
+ * no token yet, so it is loaded first, which also lets the base check see whether it moved on.
958
+ * Returns undefined when the draft went away or the branch changed meanwhile.
959
+ */
960
+ const saveOneOfAll = async (entry, branch) => {
961
+ const id = entry.contentId;
962
+ const sent = latestRef.current.drafts[id];
963
+ if (sent === undefined)
964
+ return undefined;
965
+ const onBranch = () => latestOptionsRef.current.branchName === branch;
966
+ const fail = (kind, reason, extra) => ({
967
+ saved: false,
968
+ failure: { contentId: id, label: entry.label, path: entry.path, kind, reason, ...extra },
969
+ });
970
+ const { getEntryVersion, loadEntry, saveEntry } = latestOptionsRef.current;
971
+ if (entry.unavailable)
972
+ return fail('blocked', SAVE_ALL_REASONS.unavailable);
973
+ if (entry.canEdit === false)
974
+ return fail('blocked', SAVE_ALL_REASONS.noEditAccess);
975
+ if (id in reloadRequiredRef.current) {
976
+ if (reloadRequiredRef.current[id] === getEntryVersion?.(id)) {
977
+ return fail('unanswered', SAVE_ALL_REASONS.earlierUnanswered);
978
+ }
979
+ delete reloadRequiredRef.current[id];
980
+ notifications.hide(saveMayHaveLandedToastId(id));
981
+ }
982
+ if (getEntryVersion?.(id) === undefined) {
983
+ try {
984
+ await loadEntry(entry);
985
+ }
986
+ catch (err) {
987
+ console.error(err);
988
+ return fail('error', SAVE_ALL_REASONS.loadFailed);
989
+ }
990
+ if (!onBranch())
991
+ return undefined;
992
+ }
993
+ const base = draftBaseVersionsRef.current[id];
994
+ if (base === null)
995
+ return fail('conflict', SAVE_ALL_REASONS.unknownBase);
996
+ if (base !== undefined && base !== getEntryVersion?.(id)) {
997
+ return fail('conflict', SAVE_ALL_REASONS.staleBase);
998
+ }
999
+ const validationErrors = validateEntryFormValue(entry.schema, entry.format, sent);
1000
+ if (validationErrors.length > 0) {
1001
+ if (id === currentIdRef.current) {
1002
+ setErrorState({ entryId: id, errors: toFieldErrorMap(validationErrors) });
1003
+ latestOptionsRef.current.onSaveBlocked?.();
1004
+ }
1005
+ return fail('invalid', invalidReason(validationErrors.length));
1006
+ }
1007
+ const commitSaved = (saved) => {
1008
+ // Decided from `prev`, which holds an edit queued but not yet rendered.
1009
+ setDrafts((prev) => {
1010
+ if (!(id in prev))
1011
+ return prev;
1012
+ if (!equal(prev[id], sent)) {
1013
+ // Edits typed while the save was out stay a draft, based on the version just written; a
1014
+ // new object, so that base is persisted. Idempotent, as a strict-mode rerun requires.
1015
+ const version = latestOptionsRef.current.getEntryVersion?.(id);
1016
+ if (version !== undefined)
1017
+ draftBaseVersionsRef.current[id] = version;
1018
+ return { ...prev };
1019
+ }
1020
+ const next = { ...prev };
1021
+ delete next[id];
1022
+ return next;
1023
+ });
1024
+ setLoadedValues((prev) => ({ ...prev, [id]: saved }));
1025
+ if (timedOutSavesRef.current.delete(id))
1026
+ notifications.hide(saveTimedOutToastId(id));
1027
+ hideEntryGone(id);
1028
+ };
1029
+ try {
1030
+ const saved = await saveEntry(entry, sent);
1031
+ // Another branch's state now: its loaded values and draft bases are not this write's.
1032
+ if (!onBranch())
1033
+ return undefined;
1034
+ commitSaved(saved);
1035
+ return { saved: true };
1036
+ }
1037
+ catch (err) {
1038
+ console.error(err);
1039
+ if (!onBranch())
1040
+ return undefined;
1041
+ const outcome = classifySaveAllError(err, timedOutSavesRef.current.has(id));
1042
+ switch (outcome.kind) {
1043
+ case 'unanswered': {
1044
+ const landed = await settleUnansweredSave(entry, sent);
1045
+ if (!onBranch())
1046
+ return undefined;
1047
+ if (landed) {
1048
+ commitSaved(landed);
1049
+ return { saved: true };
1050
+ }
1051
+ timedOutSavesRef.current.add(id);
1052
+ return fail('unanswered', SAVE_ALL_REASONS.unanswered);
1053
+ }
1054
+ case 'landed?':
1055
+ reloadRequiredRef.current[id] = getEntryVersion?.(id);
1056
+ timedOutSavesRef.current.delete(id);
1057
+ notifications.hide(saveTimedOutToastId(id));
1058
+ return fail('unanswered', SAVE_ALL_REASONS.earlierUnanswered);
1059
+ case 'gone': {
1060
+ if (timedOutSavesRef.current.delete(id))
1061
+ notifications.hide(saveTimedOutToastId(id));
1062
+ let moved;
1063
+ try {
1064
+ moved = await latestOptionsRef.current.followMovedEntry?.(entry);
1065
+ }
1066
+ catch (followErr) {
1067
+ console.error(followErr);
1068
+ return fail('conflict', SAVE_ALL_REASONS.goneUnchecked);
1069
+ }
1070
+ // "Open" in the result goes to where the changes moved.
1071
+ if (moved)
1072
+ return fail('conflict', SAVE_ALL_REASONS.renamed, { path: moved.path });
1073
+ return fail('conflict', SAVE_ALL_REASONS.deleted, {
1074
+ recreate: () => void recreateGoneEntry(entry, branch),
1075
+ });
1076
+ }
1077
+ default:
1078
+ if (outcome.kind === 'invalid' && id === currentIdRef.current) {
1079
+ const fieldErrors = err instanceof SaveApiError ? err.fieldErrors : undefined;
1080
+ if (fieldErrors?.length) {
1081
+ setErrorState({ entryId: id, errors: toFieldErrorMap(fieldErrors) });
1082
+ latestOptionsRef.current.onSaveBlocked?.();
1083
+ }
1084
+ }
1085
+ return fail(outcome.kind, outcome.reason, outcome.retryable ? { retryable: true } : undefined);
1086
+ }
1087
+ }
1088
+ };
1089
+ const handleSaveAll = async () => {
1090
+ // A Save in flight would race this run's write of the same entry, with the same token.
1091
+ if (saveAllRunningRef.current || singleSaveRef.current)
1092
+ return undefined;
1093
+ saveAllRunningRef.current = true;
1094
+ settledDuringRunRef.current = new Set();
1095
+ const branch = options.branchName;
1096
+ // Busy from the first moment, so Save waits through the draft check too.
1097
+ options.setBusy(true);
1098
+ setSaveInFlight(true);
1099
+ setSaveAllProgress({ done: 0, total: latestRef.current.modifiedCount });
1100
+ try {
1101
+ // A restored draft identical to the server is not a change, so it isn't saved.
1102
+ await whenDraftsVerified();
1103
+ if (latestOptionsRef.current.branchName !== branch)
1104
+ return undefined;
1105
+ const { dirtyIds: ids, entryById: byId } = latestRef.current;
1106
+ const current = currentIdRef.current;
1107
+ const queue = [...ids]
1108
+ .sort((a, b) => Number(b === current) - Number(a === current))
1109
+ .flatMap((id) => {
1110
+ const entry = byId.get(id);
1111
+ return entry ? [entry] : [];
1112
+ });
1113
+ const result = { branch, total: queue.length, saved: [], failed: [] };
1114
+ if (queue.length === 0)
1115
+ return result;
1116
+ clearSaveAllResult();
1117
+ setSaveAllProgress({ done: 0, total: queue.length });
1118
+ for (const [index, entry] of queue.entries()) {
1119
+ if (latestOptionsRef.current.branchName !== branch || !mountedRef.current)
1120
+ return undefined;
1121
+ const outcome = await saveOneOfAll(entry, branch);
1122
+ if (outcome?.saved) {
1123
+ result.saved.push({ contentId: entry.contentId, label: entry.label, path: entry.path });
1124
+ }
1125
+ else if (outcome) {
1126
+ result.failed.push(outcome.failure);
1127
+ }
1128
+ setSaveAllProgress({ done: index + 1, total: queue.length });
1129
+ }
1130
+ // Unmounted: a result would act through this gone instance's callbacks.
1131
+ if (latestOptionsRef.current.branchName !== branch || !mountedRef.current)
1132
+ return undefined;
1133
+ // Discarded, refreshed or saved mid-run, so neither saved nor failed by this run.
1134
+ const { drafts: remaining } = latestRef.current;
1135
+ const settled = settledDuringRunRef.current;
1136
+ result.failed = result.failed.filter((failure) => failure.contentId in remaining && !settled.has(failure.contentId));
1137
+ result.total = result.saved.length + result.failed.length;
1138
+ if (result.saved.length > 0)
1139
+ latestOptionsRef.current.onSaved?.();
1140
+ return result;
1141
+ }
1142
+ finally {
1143
+ saveAllRunningRef.current = false;
1144
+ setSaveAllProgress(null);
1145
+ setSaveInFlight(false);
469
1146
  options.setBusy(false);
470
1147
  }
471
1148
  };
472
1149
  const performDiscardDrafts = () => {
1150
+ for (const id of timedOutSavesRef.current)
1151
+ notifications.hide(saveTimedOutToastId(id));
1152
+ timedOutSavesRef.current.clear();
1153
+ for (const id of goneEntriesRef.current)
1154
+ notifications.hide(entryGoneToastId(id));
1155
+ goneEntriesRef.current.clear();
1156
+ clearSaveAllResult();
1157
+ if (saveAllRunningRef.current) {
1158
+ for (const id of Object.keys(latestRef.current.drafts))
1159
+ settledDuringRunRef.current.add(id);
1160
+ }
1161
+ options.onDraftsReset?.();
473
1162
  setDrafts({});
474
1163
  setErrorState(null);
475
1164
  try {
@@ -491,22 +1180,33 @@ export function useDraftManager(options) {
491
1180
  // is actually something to lose (modifiedCount > 0, same definition used
492
1181
  // everywhere else in this hook). An all-clean discard (e.g. drafts that
493
1182
  // exactly mirror loaded values) clears silently.
494
- const handleDiscardDrafts = () => {
1183
+ const handleDiscardDrafts = (returnFocusTo) => {
495
1184
  if (modifiedCount === 0) {
496
1185
  performDiscardDrafts();
497
1186
  return;
498
1187
  }
499
- modals.openConfirmModal({
500
- title: 'Discard drafts',
501
- children: `Discard drafts for ${modifiedCount} ${modifiedCount === 1 ? 'file' : 'files'}? Unsaved changes will be lost.`,
502
- labels: { confirm: 'Discard', cancel: 'Cancel' },
503
- confirmProps: { color: 'red' },
1188
+ openConfirm({
1189
+ title: 'Discard all changes',
1190
+ children: `Discard unsaved changes for ${modifiedCount} ${modifiedCount === 1 ? 'entry' : 'entries'}? They will be lost.`,
1191
+ labels: { confirm: 'Discard all changes', cancel: 'Cancel' },
1192
+ confirmProps: { color: 'red', 'data-testid': 'confirm-discard-all' },
504
1193
  onConfirm: performDiscardDrafts,
1194
+ ...focusReturnProps(returnFocusTo),
505
1195
  });
506
1196
  };
507
- const performDiscardFileDraft = () => {
508
- if (!currentId)
1197
+ const performDiscardFileDraft = (announce = false) => {
1198
+ const entry = options.currentEntry;
1199
+ if (!entry || !currentId)
509
1200
  return;
1201
+ if (announce) {
1202
+ notifications.show({
1203
+ message: `Unsaved changes to "${entry.label}" discarded`,
1204
+ color: 'green',
1205
+ });
1206
+ }
1207
+ options.onDraftsReset?.(currentId);
1208
+ hideEntryGone(currentId);
1209
+ settleEntry(currentId);
510
1210
  setErrorState(null);
511
1211
  setDrafts((prev) => {
512
1212
  const next = { ...prev };
@@ -530,41 +1230,49 @@ export function useDraftManager(options) {
530
1230
  }
531
1231
  }
532
1232
  catch (err) {
533
- console.warn('Failed to clear draft for file', err);
1233
+ console.warn('Failed to clear draft for entry', err);
534
1234
  }
535
- notifications.show({
536
- message: 'Draft cleared for file',
537
- color: 'blue',
538
- autoClose: getNotificationDuration(3000),
539
- withCloseButton: true,
540
- });
541
1235
  };
542
1236
  // Only prompt when there is a real draft that actually differs from the
543
1237
  // loaded value (`isSelectedDirty()` below uses the exact same deep-equal
544
1238
  // comparison as `modifiedCount`). Discarding a draft that's identical to
545
1239
  // the loaded value, or discarding when there's no draft at all, has
546
1240
  // nothing to lose, so it clears silently.
547
- const handleDiscardFileDraft = () => {
1241
+ const handleDiscardFileDraft = (returnFocusTo, { announce = false } = {}) => {
548
1242
  if (!currentId)
549
1243
  return;
550
1244
  if (!isSelectedDirty()) {
551
- performDiscardFileDraft();
1245
+ performDiscardFileDraft(announce);
552
1246
  return;
553
1247
  }
554
- modals.openConfirmModal({
555
- title: 'Discard draft',
556
- children: 'Discard unsaved changes for this file?',
557
- labels: { confirm: 'Discard', cancel: 'Cancel' },
558
- confirmProps: { color: 'red' },
559
- onConfirm: performDiscardFileDraft,
1248
+ const label = options.currentEntry?.label;
1249
+ openConfirm({
1250
+ title: label ? `Discard changes to "${label}"?` : 'Discard changes?',
1251
+ children: 'Your unsaved changes to this entry will be lost.',
1252
+ labels: { confirm: 'Discard changes', cancel: 'Keep editing' },
1253
+ confirmProps: { color: 'red', 'data-testid': 'confirm-discard-entry' },
1254
+ onConfirm: () => performDiscardFileDraft(announce),
1255
+ ...focusReturnProps(returnFocusTo),
560
1256
  });
561
1257
  };
562
1258
  const performReload = async () => {
563
1259
  if (!options.currentEntry || !currentId)
564
1260
  return;
1261
+ const branch = options.branchName;
565
1262
  options.setBusy(true);
566
1263
  try {
567
1264
  const loaded = await options.loadEntry(options.currentEntry);
1265
+ // Content ids repeat across branches; the next branch's state is not this read's.
1266
+ if (latestOptionsRef.current.branchName !== branch)
1267
+ return;
1268
+ options.onDraftsReset?.(currentId);
1269
+ delete reloadRequiredRef.current[currentId];
1270
+ notifications.hide(saveMayHaveLandedToastId(currentId));
1271
+ if (timedOutSavesRef.current.delete(currentId)) {
1272
+ notifications.hide(saveTimedOutToastId(currentId));
1273
+ }
1274
+ hideEntryGone(currentId);
1275
+ settleEntry(currentId);
568
1276
  setLoadedValues((prev) => ({ ...prev, [currentId]: loaded }));
569
1277
  // Drop the draft rather than seeding it with `loaded`. `effectiveValue`
570
1278
  // falls back to `loadedValues`, so the rendered value is identical --
@@ -579,16 +1287,28 @@ export function useDraftManager(options) {
579
1287
  return next;
580
1288
  });
581
1289
  notifications.show({
582
- message: 'Reloaded',
1290
+ message: 'Refreshed from server',
583
1291
  color: 'blue',
584
1292
  autoClose: getNotificationDuration(3000),
585
1293
  withCloseButton: true,
586
1294
  });
587
1295
  }
588
1296
  catch (err) {
1297
+ if (latestOptionsRef.current.branchName !== branch)
1298
+ return;
1299
+ if (err instanceof EntrySchemaUnavailableError) {
1300
+ notifications.show({
1301
+ title: 'Not available yet',
1302
+ message: err.message,
1303
+ color: 'yellow',
1304
+ autoClose: getNotificationDuration(8000),
1305
+ withCloseButton: true,
1306
+ });
1307
+ return;
1308
+ }
589
1309
  console.error(err);
590
1310
  notifications.show({
591
- message: 'Reload failed',
1311
+ message: "Couldn't refresh from the server. Your changes are still here; try again.",
592
1312
  color: 'red',
593
1313
  autoClose: getNotificationDuration(6000),
594
1314
  withCloseButton: true,
@@ -604,21 +1324,22 @@ export function useDraftManager(options) {
604
1324
  // moment their unsaved work matters most. Guarded exactly like
605
1325
  // handleDiscardFileDraft: the same `isSelectedDirty()` test (nothing to lose
606
1326
  // -> no prompt) and the same confirm-modal shape.
607
- const handleReload = async () => {
1327
+ const handleReload = async (returnFocusTo) => {
608
1328
  if (!options.currentEntry || !currentId)
609
1329
  return;
610
1330
  if (!isSelectedDirty()) {
611
1331
  await performReload();
612
1332
  return;
613
1333
  }
614
- modals.openConfirmModal({
615
- title: 'Reload file',
616
- children: 'Reload this file from the server? Unsaved changes for this file will be lost.',
617
- labels: { confirm: 'Reload', cancel: 'Cancel' },
1334
+ openConfirm({
1335
+ title: 'Refresh from server',
1336
+ children: "Replace this entry with the server's copy? Unsaved changes to this entry will be lost.",
1337
+ labels: { confirm: 'Refresh', cancel: 'Cancel' },
618
1338
  confirmProps: { color: 'red' },
619
1339
  onConfirm: () => {
620
1340
  void performReload();
621
1341
  },
1342
+ ...focusReturnProps(returnFocusTo),
622
1343
  });
623
1344
  };
624
1345
  const isDirtyForEntry = (entryPath) => {
@@ -628,7 +1349,8 @@ export function useDraftManager(options) {
628
1349
  const id = entry.contentId;
629
1350
  if (!drafts[id])
630
1351
  return false;
631
- return !loadedValues[id] || !equal(drafts[id], loadedValues[id]);
1352
+ const baseline = loadedValues[id] ?? verifiedBaselines[id];
1353
+ return baseline === undefined || !equal(drafts[id], baseline);
632
1354
  };
633
1355
  const isSelectedDirty = () => {
634
1356
  if (!currentId)
@@ -637,14 +1359,77 @@ export function useDraftManager(options) {
637
1359
  return false;
638
1360
  return !loadedValues[currentId] || !equal(drafts[currentId], loadedValues[currentId]);
639
1361
  };
640
- // Returns true if ANY draft entry differs from its loaded value.
641
- //
642
- // Used for branch-switch guards so unsaved work in non-selected entries is not
643
- // silently discarded. Derived from `modifiedCount`, so its semantics note
644
- // above also applies: localStorage-restored drafts without a loaded value
645
- // count as dirty.
1362
+ // True if ANY counted draft is dirty (see `dirtyIds`). Used for branch-level
1363
+ // guards so unsaved work in non-selected entries is not silently discarded.
646
1364
  const isAnyDirty = () => modifiedCount > 0;
1365
+ const whenDraftsVerified = () => {
1366
+ if (latestRef.current.unresolvedCount === 0)
1367
+ return Promise.resolve();
1368
+ return new Promise((resolve) => {
1369
+ const wake = () => {
1370
+ clearTimeout(timer);
1371
+ resolve();
1372
+ };
1373
+ const timer = setTimeout(() => {
1374
+ verifyWaitersRef.current = verifyWaitersRef.current.filter((w) => w !== wake);
1375
+ resolve();
1376
+ }, VERIFY_WAIT_CAP_MS);
1377
+ verifyWaitersRef.current.push(wake);
1378
+ });
1379
+ };
1380
+ const resolveUnsaved = async () => {
1381
+ await whenDraftsVerified();
1382
+ const { modifiedCount: count, editedFiles: files } = latestRef.current;
1383
+ return { count, labels: files.map((f) => f.label) };
1384
+ };
1385
+ // An entry's token and draft base move to the stamped version only from the version the stamp
1386
+ // replaced: one already behind it stays behind, so the usual conflict check still catches it.
1387
+ const applySubmitStamps = (stamped) => {
1388
+ const { getEntryVersion, adoptEntryVersion } = latestOptionsRef.current;
1389
+ const loadedMoved = [];
1390
+ const draftsMoved = [];
1391
+ for (const entry of stamped) {
1392
+ if (getEntryVersion?.(entry.contentId) === entry.previousVersion) {
1393
+ adoptEntryVersion?.(entry.contentId, entry.version);
1394
+ loadedMoved.push(entry);
1395
+ }
1396
+ if (draftBaseVersionsRef.current[entry.contentId] === entry.previousVersion) {
1397
+ draftBaseVersionsRef.current[entry.contentId] = entry.version;
1398
+ draftsMoved.push(entry);
1399
+ }
1400
+ }
1401
+ if (loadedMoved.length > 0) {
1402
+ setLoadedValues((prev) => {
1403
+ const next = { ...prev };
1404
+ for (const { contentId, fields } of loadedMoved) {
1405
+ if (prev[contentId])
1406
+ next[contentId] = { ...prev[contentId], ...fields };
1407
+ }
1408
+ return next;
1409
+ });
1410
+ }
1411
+ // A new drafts object even when no value changes, so the moved base versions are persisted.
1412
+ if (draftsMoved.length > 0) {
1413
+ setDrafts((prev) => {
1414
+ const next = { ...prev };
1415
+ for (const { contentId, fields } of draftsMoved) {
1416
+ const draft = prev[contentId];
1417
+ if (!draft)
1418
+ continue;
1419
+ const filled = { ...draft };
1420
+ for (const [name, value] of Object.entries(fields)) {
1421
+ if (draft[name] === undefined || draft[name] === null || draft[name] === '') {
1422
+ filled[name] = value;
1423
+ }
1424
+ }
1425
+ next[contentId] = filled;
1426
+ }
1427
+ return next;
1428
+ });
1429
+ }
1430
+ };
647
1431
  return {
1432
+ applySubmitStamps,
648
1433
  drafts,
649
1434
  setDrafts,
650
1435
  loadedValues,
@@ -655,12 +1440,20 @@ export function useDraftManager(options) {
655
1440
  modifiedCount,
656
1441
  editedFiles,
657
1442
  handleSave,
1443
+ singleSaving,
1444
+ lastSaved,
1445
+ handleSaveAll,
1446
+ saveAllProgress,
1447
+ isSaveAllRunning: () => saveAllRunningRef.current,
658
1448
  handleDiscardDrafts,
659
1449
  handleDiscardFileDraft,
660
1450
  handleReload,
661
1451
  isDirtyForEntry,
662
1452
  isSelectedDirty,
663
1453
  isAnyDirty,
1454
+ whenDraftsVerified,
1455
+ resolveUnsaved,
664
1456
  fieldErrors,
1457
+ draftStorageFailed,
665
1458
  };
666
1459
  }