canopycms 0.0.67 → 0.0.68-int.101

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 (281) hide show
  1. package/README.md +24 -17
  2. package/dist/ai/handler.js +8 -0
  3. package/dist/api/admin-branch-health.js +17 -20
  4. package/dist/api/admin.d.ts +30 -9
  5. package/dist/api/admin.js +32 -2
  6. package/dist/api/assets.js +84 -76
  7. package/dist/api/branch-create-window.d.ts +12 -0
  8. package/dist/api/branch-create-window.js +13 -0
  9. package/dist/api/branch-review.d.ts +3 -3
  10. package/dist/api/branch-review.js +14 -5
  11. package/dist/api/branch-status.d.ts +5 -5
  12. package/dist/api/branch-status.js +26 -6
  13. package/dist/api/branch-withdraw.d.ts +2 -2
  14. package/dist/api/branch-withdraw.js +7 -2
  15. package/dist/api/branch.d.ts +15 -2
  16. package/dist/api/branch.js +118 -92
  17. package/dist/api/client.d.ts +18 -6
  18. package/dist/api/client.js +79 -12
  19. package/dist/api/content.d.ts +6 -5
  20. package/dist/api/content.js +44 -41
  21. package/dist/api/entries.js +16 -15
  22. package/dist/api/github-sync.d.ts +19 -1
  23. package/dist/api/github-sync.js +57 -10
  24. package/dist/api/index.d.ts +1 -1
  25. package/dist/api/reference-options.js +1 -1
  26. package/dist/api/resolve-references.js +14 -40
  27. package/dist/api/settings-helpers.d.ts +1 -1
  28. package/dist/api/settings-helpers.js +1 -4
  29. package/dist/api/user.d.ts +3 -0
  30. package/dist/api/user.js +3 -0
  31. package/dist/assets/asset-url.d.ts +22 -16
  32. package/dist/assets/asset-url.js +42 -25
  33. package/dist/assets/factory.d.ts +5 -0
  34. package/dist/assets/factory.js +5 -0
  35. package/dist/assets/finalize.js +3 -0
  36. package/dist/assets/index.d.ts +1 -1
  37. package/dist/assets/materialize.d.ts +153 -0
  38. package/dist/assets/materialize.js +402 -0
  39. package/dist/assets/store-local.d.ts +19 -5
  40. package/dist/assets/store-local.js +69 -10
  41. package/dist/assets/store-s3.d.ts +38 -7
  42. package/dist/assets/store-s3.js +128 -22
  43. package/dist/assets/transform-directives.d.ts +52 -13
  44. package/dist/assets/transform-directives.js +87 -27
  45. package/dist/assets/transform.d.ts +3 -3
  46. package/dist/assets/transform.js +13 -8
  47. package/dist/assets/types.d.ts +31 -4
  48. package/dist/auth/file-based-auth-cache.js +7 -8
  49. package/dist/authorization/content.d.ts +5 -4
  50. package/dist/authorization/content.js +5 -5
  51. package/dist/authorization/path.d.ts +11 -6
  52. package/dist/authorization/path.js +11 -6
  53. package/dist/authorization/types.d.ts +2 -2
  54. package/dist/branch-health.d.ts +7 -1
  55. package/dist/branch-health.js +8 -3
  56. package/dist/branch-metadata.d.ts +5 -5
  57. package/dist/branch-metadata.js +44 -36
  58. package/dist/branch-provisioning.d.ts +167 -0
  59. package/dist/branch-provisioning.js +501 -0
  60. package/dist/branch-schema-cache.d.ts +5 -3
  61. package/dist/branch-schema-cache.js +29 -11
  62. package/dist/branch-sparse.d.ts +23 -0
  63. package/dist/branch-sparse.js +118 -0
  64. package/dist/branch-workspace.d.ts +42 -3
  65. package/dist/branch-workspace.js +240 -94
  66. package/dist/build/asset-refs.d.ts +93 -0
  67. package/dist/build/asset-refs.js +251 -0
  68. package/dist/build-identity.d.ts +3 -0
  69. package/dist/build-identity.js +13 -0
  70. package/dist/cli/asset-refs.d.ts +52 -0
  71. package/dist/cli/asset-refs.js +194 -0
  72. package/dist/cli/cli.d.ts +15 -1
  73. package/dist/cli/cli.js +5928 -2195
  74. package/dist/cli/configured-asset-store.d.ts +12 -0
  75. package/dist/cli/configured-asset-store.js +46 -0
  76. package/dist/cli/generate-ai-content.js +2185 -587
  77. package/dist/cli/github-app-manifest.d.ts +4 -3
  78. package/dist/cli/github-app-manifest.js +4 -3
  79. package/dist/cli/init.js +30 -27
  80. package/dist/cli/migrate.js +12 -8
  81. package/dist/cli/sync.js +83 -37
  82. package/dist/cli/template-files/Dockerfile.cms.template +8 -0
  83. package/dist/cli/template-files/cdk-app.ts.template +4 -0
  84. package/dist/cli/template-files/cms-stack.ts.template +23 -12
  85. package/dist/cli/template-files/deploy-cms.yml.template +2 -0
  86. package/dist/client.d.ts +1 -1
  87. package/dist/client.js +1 -1
  88. package/dist/config/helpers.js +1 -2
  89. package/dist/config/schemas/config.d.ts +19 -32
  90. package/dist/config/schemas/config.js +9 -11
  91. package/dist/config/schemas/field.js +1 -0
  92. package/dist/config/schemas/media.d.ts +4 -13
  93. package/dist/config/schemas/media.js +5 -8
  94. package/dist/config/schemas/url.d.ts +4 -20
  95. package/dist/config/schemas/url.js +6 -22
  96. package/dist/config/types.d.ts +30 -20
  97. package/dist/config/validation.d.ts +6 -0
  98. package/dist/config/validation.js +37 -0
  99. package/dist/content-listing.d.ts +12 -12
  100. package/dist/content-listing.js +10 -6
  101. package/dist/content-reader.d.ts +11 -2
  102. package/dist/content-reader.js +22 -14
  103. package/dist/content-store.d.ts +37 -9
  104. package/dist/content-store.js +70 -43
  105. package/dist/content-tree.d.ts +1 -1
  106. package/dist/content-tree.js +2 -2
  107. package/dist/context.d.ts +27 -9
  108. package/dist/context.js +154 -70
  109. package/dist/editor/BranchManager.d.ts +7 -1
  110. package/dist/editor/BranchManager.js +29 -14
  111. package/dist/editor/CanopyEditor.d.ts +1 -1
  112. package/dist/editor/CanopyEditor.js +2 -6
  113. package/dist/editor/Editor.d.ts +4 -3
  114. package/dist/editor/Editor.js +32 -22
  115. package/dist/editor/FormRenderer.js +34 -18
  116. package/dist/editor/PreviewFrame.d.ts +24 -0
  117. package/dist/editor/PreviewFrame.js +140 -0
  118. package/dist/editor/admin/SystemHealthPanel.js +47 -7
  119. package/dist/editor/components/BranchesDrawer.d.ts +9 -0
  120. package/dist/editor/components/BranchesDrawer.js +4 -0
  121. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  122. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  123. package/dist/editor/context/AssetContext.d.ts +18 -35
  124. package/dist/editor/context/AssetContext.js +20 -29
  125. package/dist/editor/context/index.d.ts +1 -1
  126. package/dist/editor/context/index.js +1 -1
  127. package/dist/editor/editor-config.d.ts +1 -2
  128. package/dist/editor/editor-config.js +0 -23
  129. package/dist/editor/editor-utils.d.ts +24 -21
  130. package/dist/editor/editor-utils.js +75 -62
  131. package/dist/editor/fields/BlockField.d.ts +1 -0
  132. package/dist/editor/fields/BlockField.js +6 -4
  133. package/dist/editor/fields/CodeField.d.ts +1 -0
  134. package/dist/editor/fields/CodeField.js +2 -2
  135. package/dist/editor/fields/DateTimeField.d.ts +1 -0
  136. package/dist/editor/fields/DateTimeField.js +2 -2
  137. package/dist/editor/fields/FieldDescription.d.ts +16 -0
  138. package/dist/editor/fields/FieldDescription.js +11 -0
  139. package/dist/editor/fields/ImageField.d.ts +1 -0
  140. package/dist/editor/fields/ImageField.js +3 -2
  141. package/dist/editor/fields/InlineGroupField.js +4 -1
  142. package/dist/editor/fields/MarkdownField.d.ts +1 -0
  143. package/dist/editor/fields/MarkdownField.js +115 -20
  144. package/dist/editor/fields/NumberField.d.ts +1 -0
  145. package/dist/editor/fields/NumberField.js +2 -2
  146. package/dist/editor/fields/NumberListField.d.ts +1 -0
  147. package/dist/editor/fields/NumberListField.js +2 -2
  148. package/dist/editor/fields/ObjectField.d.ts +1 -0
  149. package/dist/editor/fields/ObjectField.js +5 -2
  150. package/dist/editor/fields/ReferenceField.d.ts +1 -0
  151. package/dist/editor/fields/ReferenceField.js +5 -4
  152. package/dist/editor/fields/SelectField.d.ts +1 -0
  153. package/dist/editor/fields/SelectField.js +2 -2
  154. package/dist/editor/fields/StringListField.d.ts +1 -0
  155. package/dist/editor/fields/StringListField.js +2 -2
  156. package/dist/editor/fields/TextField.d.ts +1 -0
  157. package/dist/editor/fields/TextField.js +2 -2
  158. package/dist/editor/fields/ToggleField.d.ts +1 -0
  159. package/dist/editor/fields/ToggleField.js +7 -4
  160. package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
  161. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -2
  162. package/dist/editor/fields/mdx-jsx-support.d.ts +1 -0
  163. package/dist/editor/fields/mdx-jsx-support.js +137 -0
  164. package/dist/editor/hooks/create-branch-request.d.ts +20 -0
  165. package/dist/editor/hooks/create-branch-request.js +52 -0
  166. package/dist/editor/hooks/useBranchActions.d.ts +13 -2
  167. package/dist/editor/hooks/useBranchActions.js +48 -17
  168. package/dist/editor/hooks/useBranchManager.d.ts +21 -0
  169. package/dist/editor/hooks/useBranchManager.js +204 -37
  170. package/dist/editor/hooks/useBranchesData.d.ts +6 -0
  171. package/dist/editor/hooks/useBranchesData.js +9 -3
  172. package/dist/editor/hooks/useCommentSystem.js +25 -6
  173. package/dist/editor/hooks/useDraftManager.d.ts +16 -1
  174. package/dist/editor/hooks/useDraftManager.js +193 -36
  175. package/dist/editor/hooks/useEntriesData.js +3 -3
  176. package/dist/editor/hooks/useEntryManager.d.ts +2 -1
  177. package/dist/editor/hooks/useEntryManager.js +34 -19
  178. package/dist/editor/media/AssetCard.d.ts +1 -1
  179. package/dist/editor/media/crop-math.d.ts +3 -4
  180. package/dist/editor/media/crop-math.js +11 -22
  181. package/dist/editor/media/editor-image-src.d.ts +7 -0
  182. package/dist/editor/media/editor-image-src.js +12 -0
  183. package/dist/editor/preview-asset-base.d.ts +8 -0
  184. package/dist/editor/preview-asset-base.js +13 -0
  185. package/dist/editor/preview-bridge.d.ts +9 -21
  186. package/dist/editor/preview-bridge.js +19 -139
  187. package/dist/editor/preview-path.d.ts +10 -0
  188. package/dist/editor/preview-path.js +20 -0
  189. package/dist/editor/theme.js +2 -2
  190. package/dist/entry-schema-registry.d.ts +4 -4
  191. package/dist/entry-schema-registry.js +10 -6
  192. package/dist/entry-schema.d.ts +27 -4
  193. package/dist/entry-schema.js +20 -3
  194. package/dist/git-manager.d.ts +173 -6
  195. package/dist/git-manager.js +594 -76
  196. package/dist/github-service.d.ts +15 -0
  197. package/dist/github-service.js +19 -1
  198. package/dist/http/handler.js +37 -13
  199. package/dist/http/index.d.ts +2 -0
  200. package/dist/http/index.js +2 -0
  201. package/dist/http/router.d.ts +2 -0
  202. package/dist/http/router.js +2 -1
  203. package/dist/http/types.d.ts +2 -0
  204. package/dist/http/worker-not-ready.d.ts +10 -0
  205. package/dist/http/worker-not-ready.js +24 -0
  206. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  207. package/dist/operating-mode/types.d.ts +1 -4
  208. package/dist/paths/branch-name.d.ts +5 -0
  209. package/dist/paths/branch-name.js +9 -0
  210. package/dist/paths/branch.d.ts +6 -2
  211. package/dist/paths/branch.js +16 -10
  212. package/dist/paths/index.d.ts +3 -3
  213. package/dist/paths/index.js +3 -3
  214. package/dist/paths/normalize.d.ts +7 -0
  215. package/dist/paths/normalize.js +9 -0
  216. package/dist/paths/validation.js +0 -2
  217. package/dist/preview.d.ts +8 -0
  218. package/dist/preview.js +7 -0
  219. package/dist/reference-resolver.d.ts +5 -21
  220. package/dist/reference-resolver.js +9 -44
  221. package/dist/resolve-canopy-user.js +2 -1
  222. package/dist/schema/schema-store.d.ts +54 -31
  223. package/dist/schema/schema-store.js +77 -31
  224. package/dist/server.d.ts +64 -9
  225. package/dist/server.js +48 -8
  226. package/dist/services.d.ts +18 -6
  227. package/dist/services.js +85 -70
  228. package/dist/settings-workspace.d.ts +23 -3
  229. package/dist/settings-workspace.js +197 -45
  230. package/dist/static/seo.d.ts +2 -14
  231. package/dist/static/seo.js +2 -25
  232. package/dist/submission-attribution.d.ts +73 -0
  233. package/dist/submission-attribution.js +221 -0
  234. package/dist/sync-core.d.ts +12 -1
  235. package/dist/sync-core.js +31 -13
  236. package/dist/task-queue/worker-status.d.ts +8 -0
  237. package/dist/task-queue/worker-status.js +17 -0
  238. package/dist/types.d.ts +31 -2
  239. package/dist/utils/content-serialize.d.ts +5 -2
  240. package/dist/utils/content-serialize.js +98 -24
  241. package/dist/utils/content-write-lock.d.ts +18 -7
  242. package/dist/utils/content-write-lock.js +18 -9
  243. package/dist/utils/debug.d.ts +8 -0
  244. package/dist/utils/debug.js +10 -2
  245. package/dist/utils/git.d.ts +32 -0
  246. package/dist/utils/git.js +42 -0
  247. package/dist/utils/occ-json-write.js +10 -2
  248. package/dist/utils/provision-log.d.ts +25 -0
  249. package/dist/utils/provision-log.js +49 -0
  250. package/dist/utils/provisioning-lock.d.ts +35 -5
  251. package/dist/utils/provisioning-lock.js +73 -12
  252. package/dist/utils/request-timing.d.ts +25 -0
  253. package/dist/utils/request-timing.js +101 -0
  254. package/dist/utils/sanitize-href.d.ts +8 -22
  255. package/dist/utils/sanitize-href.js +11 -28
  256. package/dist/utils/url-prefix.d.ts +30 -0
  257. package/dist/utils/url-prefix.js +61 -0
  258. package/dist/utils/yaml-source-splice.d.ts +58 -0
  259. package/dist/utils/yaml-source-splice.js +515 -0
  260. package/dist/validation/entry-validator.js +9 -3
  261. package/dist/version.d.ts +1 -0
  262. package/dist/version.js +3 -0
  263. package/dist/worker/canopy-state.d.ts +45 -0
  264. package/dist/worker/canopy-state.js +75 -0
  265. package/dist/worker/cms-worker.d.ts +8 -3
  266. package/dist/worker/cms-worker.js +38 -2
  267. package/dist/worker/git-sync.d.ts +30 -12
  268. package/dist/worker/git-sync.js +253 -58
  269. package/dist/worker/history-rewrite.d.ts +1 -1
  270. package/dist/worker/history-rewrite.js +1 -1
  271. package/dist/worker/provisioned-workspace.d.ts +35 -0
  272. package/dist/worker/provisioned-workspace.js +50 -0
  273. package/dist/worker/rebase.d.ts +1 -1
  274. package/dist/worker/rebase.js +95 -23
  275. package/dist/worker/remote-git-maintenance.d.ts +3 -0
  276. package/dist/worker/remote-git-maintenance.js +11 -0
  277. package/dist/worker/sparse-cone.d.ts +20 -0
  278. package/dist/worker/sparse-cone.js +132 -0
  279. package/dist/worker/task-runner.js +99 -14
  280. package/dist/worker/worker-context.d.ts +6 -1
  281. package/package.json +8 -2
@@ -6,10 +6,11 @@ import { IconChevronDown, IconChevronUp, IconDots, IconFolderPlus, IconPlus, } f
6
6
  import { notifications } from '@mantine/notifications';
7
7
  import { EntryNavigator } from './EntryNavigator.js';
8
8
  import { FormRenderer } from './FormRenderer.js';
9
- import { PreviewFrame } from './preview-bridge.js';
9
+ import { PreviewFrame } from './PreviewFrame.js';
10
10
  import { EditorPanes } from './EditorPanes.js';
11
11
  import { CanopyCMSProvider } from './theme.js';
12
12
  import { BranchManager } from './BranchManager.js';
13
+ import { BranchesDrawer } from './components/BranchesDrawer.js';
13
14
  import { CommentsPanel } from './CommentsPanel.js';
14
15
  import { GroupManager } from './GroupManager.js';
15
16
  import { PermissionManager } from './PermissionManager.js';
@@ -24,15 +25,16 @@ import { EditorFooter, EditorHeader, EditorSidebar } from './components/index.js
24
25
  import { RenameEntryModal } from './components/RenameEntryModal.js';
25
26
  import { EntryCreateModal } from './components/EntryCreateModal.js';
26
27
  import { ConfirmDeleteModal } from './components/ConfirmDeleteModal.js';
28
+ import { NoEditPermissionNotice } from './components/NoEditPermissionNotice.js';
27
29
  import { CollectionEditor } from './schema-editor/index.js';
28
- import { AssetContextProvider, useApiClient } from './context/index.js';
30
+ import { AssetContextProvider, authenticatedAssetBase, useApiClient } from './context/index.js';
29
31
  import { EntryLinkContext } from './fields/entry-link/index.js';
30
32
  import { MediaLibrary } from './media/MediaLibrary.js';
31
33
  /**
32
34
  * High-level editor wrapper that wires entry navigation, form rendering,
33
35
  * saving/loading, and preview rendering using entry definitions.
34
36
  */
35
- export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtitle = subtitle, branchName = '', collections, contentRoot, entryLinkUrl, initialSelectedId, initialValues, renderPreview, onCreateEntry, themeOptions, operatingMode, previewBaseByCollection, currentUser = 'current-user', canResolveComments = true, assetBaseUrl, basePath, AccountComponent, onAccountClick, onLogoutClick, customRenderers, }) => {
37
+ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtitle = subtitle, branchName = '', collections, contentRoot, entryLinkUrl, initialSelectedId, initialValues, renderPreview, onCreateEntry, themeOptions, operatingMode, previewBaseByCollection, previewPrefix, currentUser = 'current-user', canResolveComments = true, basePath, AccountComponent, onAccountClick, onLogoutClick, customRenderers, }) => {
36
38
  const [branchesLoading, setBranchesLoading] = useState(false);
37
39
  const [entriesLoading, setEntriesLoading] = useState(false);
38
40
  const [commentsLoading] = useState(false);
@@ -70,7 +72,7 @@ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtit
70
72
  // Comments state (shared between useCommentSystem and useBranchManager)
71
73
  const [commentsForBranchSummaries, setCommentsForBranchSummaries] = useState([]);
72
74
  // 1. Branch manager (provides branchNameState, no dependencies)
73
- const { branchName: branchNameState, setBranchName, branchSummaries, currentBranch, handleSubmit, handleWithdraw, handleRequestChanges, handleDelete, handleReloadBranchData, loadBranches, } = useBranchManager({
75
+ const { branchName: branchNameState, setBranchName, branchSummaries, currentBranch, addCreatedBranch, handleSubmit, handleWithdraw, handleRequestChanges, handleDelete, handleReloadBranchData, loadBranches, } = useBranchManager({
74
76
  initialBranch: branchName,
75
77
  operatingMode,
76
78
  setBusy: setBranchesLoading,
@@ -89,15 +91,15 @@ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtit
89
91
  // loading state during that window anyway.
90
92
  const branchContentLocked = currentBranch?.writeBlocked ?? true;
91
93
  // 2. Entry manager (depends on branchNameState, owns selectedPath)
92
- const { selectedPath, setSelectedPath, entries: entriesState, collections: collectionsFromApi, currentEntry, availableSchemas, entriesInitializing, navigatorOpen, setNavigatorOpen, refreshEntries, handleCreateEntry, renameEntry, loadEntry, saveEntry, getEntryVersion, createModalOpen, createModalCollection, createModalError, createModalCreating, createModalExistingSlugs, handleCreateModalSubmit, closeCreateModal, } = useEntryManager({
94
+ const { selectedPath, setSelectedPath, entries: entriesState, collections: collectionsFromApi, currentEntry, availableSchemas, entriesInitializing, navigatorOpen, setNavigatorOpen, refreshEntries, handleCreateEntry, renameEntry, loadEntry, readEntryValue, saveEntry, getEntryVersion, createModalOpen, createModalCollection, createModalError, createModalCreating, createModalExistingSlugs, handleCreateModalSubmit, closeCreateModal, } = useEntryManager({
93
95
  initialEntries: entries,
94
96
  initialSelectedId,
95
97
  branchName: branchNameState,
96
98
  collections,
97
- previewBaseByCollection,
98
99
  resolvePreviewSrc: (entry) => buildPreviewSrc(entry, {
99
100
  branchName: branchNameState,
100
101
  previewBaseByCollection,
102
+ previewPrefix,
101
103
  contentRoot,
102
104
  basePath,
103
105
  }),
@@ -125,13 +127,14 @@ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtit
125
127
  setPreviewLoadingState({});
126
128
  }, [currentEntry?.contentId]);
127
129
  // 3. Draft manager (depends on branchNameState, selectedPath from useEntryManager)
128
- const { setDrafts, loadedValues, setLoadedValues, effectiveValue, modifiedCount, editedFiles, handleSave, handleDiscardDrafts, handleDiscardFileDraft, handleReload, isSelectedDirty, isAnyDirty, fieldErrors, } = useDraftManager({
130
+ const { setDrafts, loadedValues, setLoadedValues, effectiveValue, modifiedCount, editedFiles, handleSave, handleDiscardDrafts, handleDiscardFileDraft, handleReload, isSelectedDirty, resolveUnsaved, fieldErrors, } = useDraftManager({
129
131
  branchName: branchNameState,
130
132
  selectedPath,
131
133
  currentEntry,
132
134
  entries: entriesState,
133
135
  initialValues,
134
136
  loadEntry,
137
+ readEntryValue,
135
138
  saveEntry,
136
139
  getEntryVersion,
137
140
  setBusy: setEntriesLoading,
@@ -139,12 +142,14 @@ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtit
139
142
  void refreshEntries(branchNameState).catch((err) => console.error('Failed to refresh entries after save', err));
140
143
  },
141
144
  });
142
- // 4. Branch actions (depends on isAnyDirty, setBranchName)
143
- const { handleBranchChange, handleCreateBranch } = useBranchActions({
145
+ // 4. Branch actions (depends on resolveUnsaved, setBranchName)
146
+ const { handleBranchChange, confirmCreate: confirmCreateBranch, handleCreateBranch, confirmOpen: branchConfirmOpen, } = useBranchActions({
144
147
  branchName: branchNameState,
145
148
  setBranchName,
146
- isAnyDirty,
149
+ getUnsaved: resolveUnsaved,
147
150
  onReloadBranches: () => loadBranches(),
151
+ onBranchCreated: addCreatedBranch,
152
+ userId: userContext?.userId,
148
153
  });
149
154
  // 5. Comment system (depends on branchNameState)
150
155
  const { comments, focusedFieldPath, highlightThreadId, commentsPanelOpen, setCommentsPanelOpen, handleAddComment, handleResolveThread, handleJumpToField, handleJumpToEntry, handleJumpToBranch, } = useCommentSystem({
@@ -224,9 +229,8 @@ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtit
224
229
  // branch's content. Writing it would (a) show it as the new branch's
225
230
  // content and (b) satisfy the skip gate above, permanently suppressing
226
231
  // the new branch's own load -- which also leaves the new branch's OCC
227
- // token unset, turning the next save into a blind overwrite that can
228
- // never 409. Drop it instead; the new branch has its own load running
229
- // under its own key.
232
+ // token unset, so saveEntry refuses every save of the entry. Drop it
233
+ // instead; the new branch has its own load running under its own key.
230
234
  if (currentBranchRef.current !== requestBranch)
231
235
  return;
232
236
  setLoadedValues((prev) => ({ ...prev, [contentId]: loaded }));
@@ -599,7 +603,7 @@ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtit
599
603
  width: '100%',
600
604
  height: '100%',
601
605
  border: '1px solid var(--mantine-color-gray-3)',
602
- }, highlightEnabled: highlightEnabled, onPreviewError: setPreviewError })] })) : (_jsx(Paper, { withBorder: true, shadow: "xs", h: "100%", bg: "white", style: {
606
+ }, highlightEnabled: highlightEnabled, onPreviewError: setPreviewError, assetBase: authenticatedAssetBase(basePath) })] })) : (_jsx(Paper, { withBorder: true, shadow: "xs", h: "100%", bg: "white", style: {
603
607
  display: 'flex',
604
608
  alignItems: 'center',
605
609
  justifyContent: 'center',
@@ -607,12 +611,14 @@ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtit
607
611
  ? 'Setting up your branch workspace…'
608
612
  : entriesInitializing
609
613
  ? 'Loading content…'
610
- : 'Select an item to start editing.' }) }));
614
+ : currentEntry && !currentEntry.previewSrc
615
+ ? 'No preview for this entry.'
616
+ : 'Select an item to start editing.' }) }));
611
617
  const sidebarWidth = 64;
612
618
  const footerHeight = 40;
613
619
  const headerTitle = currentEntry?.label ?? currentEntry?.slug ?? title;
614
620
  const breadcrumbSegments = useMemo(() => buildBreadcrumbSegments(currentEntry, collectionLabels), [collectionLabels, currentEntry]);
615
- return (_jsx(CanopyCMSProvider, { ...(themeOptions ?? {}), children: _jsx(AssetContextProvider, { baseUrl: assetBaseUrl, basePath: basePath, children: _jsxs(Box, { bg: "gray.0", style: { minHeight: '100vh', width: '100%' }, children: [_jsx(EditorHeader, { ref: headerRef, siteTitle: siteTitle, siteSubtitle: siteSubtitle, headerTitle: headerTitle, currentEntry: currentEntry, branchName: branchNameState, operatingMode: operatingMode, branchStatus: currentBranch?.status, busy: busy, breadcrumbSegments: breadcrumbSegments, editedFiles: editedFiles, modifiedCount: modifiedCount, unresolvedCommentCount: comments.filter((t) => !t.resolved).length, comments: comments, hasUnsavedChanges: isSelectedDirty(), userContext: userContext, branchCreatedBy: currentBranch?.createdBy, branchAccess: currentBranch?.access,
621
+ return (_jsx(CanopyCMSProvider, { ...(themeOptions ?? {}), children: _jsx(AssetContextProvider, { basePath: basePath, children: _jsxs(Box, { bg: "gray.0", style: { minHeight: '100vh', width: '100%' }, children: [_jsx(EditorHeader, { ref: headerRef, siteTitle: siteTitle, siteSubtitle: siteSubtitle, headerTitle: headerTitle, currentEntry: currentEntry, branchName: branchNameState, operatingMode: operatingMode, branchStatus: currentBranch?.status, busy: busy, breadcrumbSegments: breadcrumbSegments, editedFiles: editedFiles, modifiedCount: modifiedCount, unresolvedCommentCount: comments.filter((t) => !t.resolved).length, comments: comments, hasUnsavedChanges: isSelectedDirty(), userContext: userContext, branchCreatedBy: currentBranch?.createdBy, branchAccess: currentBranch?.access,
616
622
  // Fail-closed for the same reason branchContentLocked above is:
617
623
  // isProtected/writeBlocked gate real actions in EditorHeader (hiding
618
624
  // Submit, disabling Save), so a missing wire value must read as
@@ -628,7 +634,13 @@ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtit
628
634
  // revalidateOnFocus false, and useBranchesData's refreshInterval
629
635
  // is 0 without data. Reloading is idempotent.
630
636
  loadBranches().catch(console.error);
631
- }, onCommentsPanelOpen: () => setCommentsPanelOpen(true), onSave: handleSave, onSubmit: () => branchNameState && handleSubmit(branchNameState), onWithdraw: () => branchNameState && handleWithdraw(branchNameState) }), _jsx(Box, { style: {
637
+ }, onCommentsPanelOpen: () => setCommentsPanelOpen(true), onSave: handleSave, onSubmit: () => {
638
+ if (branchNameState)
639
+ handleSubmit(branchNameState).catch(console.error);
640
+ }, onWithdraw: () => {
641
+ if (branchNameState)
642
+ handleWithdraw(branchNameState).catch(console.error);
643
+ } }), _jsx(Box, { style: {
632
644
  paddingTop: headerHeight,
633
645
  paddingBottom: footerHeight,
634
646
  paddingRight: sidebarWidth,
@@ -644,7 +656,7 @@ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtit
644
656
  ? 'Setting up your branch workspace…'
645
657
  : entriesInitializing
646
658
  ? 'Loading content…'
647
- : 'Select an item to start editing.' })) : currentEntry.canEdit === false ? (_jsx(CenteredMessage, { children: "You don't have permission to edit this content." })) : schema.length > 0 && effectiveValue ? (_jsx(EntryLinkContext.Provider, { value: entryLinkContextValue, children: _jsx(FormRenderer, { fields: schema, value: effectiveValue, onChange: (next) => {
659
+ : 'Select an item to start editing.' })) : currentEntry.canEdit === false ? (_jsx(NoEditPermissionNotice, { entryPath: currentEntry.path })) : schema.length > 0 && effectiveValue ? (_jsx(EntryLinkContext.Provider, { value: entryLinkContextValue, children: _jsx(FormRenderer, { fields: schema, value: effectiveValue, onChange: (next) => {
648
660
  const contentId = currentEntry?.contentId;
649
661
  if (contentId) {
650
662
  setDrafts((prev) => ({ ...prev, [contentId]: next }));
@@ -662,7 +674,7 @@ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtit
662
674
  })), selectedPath: selectedPath, onSelect: (id) => {
663
675
  setSelectedPath(id);
664
676
  setNavigatorOpen(false);
665
- }, onTreeControllerReady: handleTreeControllerReady, expandedStateRef: treeExpandedStateRef, onExpandedStateChange: handleExpandedStateChange, onDeleteEntry: handleDeleteEntry, onRenameEntry: handleRenameEntry, onReorderEntry: handleReorderEntry, hiddenRootPath: hiddenRootPath, loading: entriesInitializing, readOnly: branchContentLocked }) }) })] })] }), _jsx(Drawer, { opened: branchManagerOpen, onClose: () => setBranchManagerOpen(false), position: "right", title: _jsxs("div", { children: [_jsx(Title, { order: 4, children: "Branches" }), _jsx(Text, { size: "xs", c: "dimmed", children: "Manage access, status, and lifecycle" })] }), padding: "md", size: 420, overlayProps: { blur: 2 }, children: _jsx(BranchManager, { branches: branchSummaries, mode: operatingMode, user: userContext, onSelect: async (name) => {
677
+ }, onTreeControllerReady: handleTreeControllerReady, expandedStateRef: treeExpandedStateRef, onExpandedStateChange: handleExpandedStateChange, onDeleteEntry: handleDeleteEntry, onRenameEntry: handleRenameEntry, onReorderEntry: handleReorderEntry, hiddenRootPath: hiddenRootPath, loading: entriesInitializing, readOnly: branchContentLocked }) }) })] })] }), _jsx(BranchesDrawer, { opened: branchManagerOpen, onClose: () => setBranchManagerOpen(false), confirmOpen: branchConfirmOpen, children: _jsx(BranchManager, { branches: branchSummaries, mode: operatingMode, user: userContext, onSelect: async (name) => {
666
678
  try {
667
679
  await handleBranchChange(name);
668
680
  setBranchManagerOpen(false);
@@ -671,9 +683,7 @@ export const Editor = ({ entries, title, subtitle, siteTitle = title, siteSubtit
671
683
  console.error('Branch change failed or was cancelled:', err);
672
684
  // Don't close branch manager if there was an error or user cancelled
673
685
  }
674
- }, onCreate: (branch) => {
675
- handleCreateBranch(branch).catch((err) => console.error(err));
676
- }, onSubmit: (name) => {
686
+ }, onBeforeCreate: confirmCreateBranch, onCreate: handleCreateBranch, onSubmit: (name) => {
677
687
  handleSubmit(name).catch((err) => console.error(err));
678
688
  }, onWithdraw: (name) => {
679
689
  handleWithdraw(name).catch((err) => console.error(err));
@@ -15,11 +15,24 @@ import { ReferenceField } from './fields/ReferenceField.js';
15
15
  import { CodeField } from './fields/CodeField.js';
16
16
  import { ImageField } from './fields/ImageField.js';
17
17
  import { ObjectField } from './fields/ObjectField.js';
18
+ import { FieldDescription, groupDescriptionProps } from './fields/FieldDescription.js';
18
19
  import { InlineGroupField } from './fields/InlineGroupField.js';
19
20
  import { formatCanopyPath, normalizeCanopyPath } from './canopy-path.js';
20
21
  import { FieldWrapper } from './comments/FieldWrapper.js';
21
22
  import { EntryComments } from './comments/EntryComments.js';
22
23
  import { useReferenceResolution } from './hooks/useReferenceResolution.js';
24
+ /**
25
+ * Card heading for an object-list item: the value of its `itemTitleField` when that is a
26
+ * non-blank string or a number, otherwise `fallback`.
27
+ */
28
+ const listItemTitle = (item, itemTitleField, fallback) => {
29
+ const raw = itemTitleField ? item?.[itemTitleField] : undefined;
30
+ if (typeof raw === 'number' && Number.isFinite(raw))
31
+ return String(raw);
32
+ if (typeof raw === 'string' && raw.trim() !== '')
33
+ return raw.trim();
34
+ return fallback;
35
+ };
23
36
  const normalizeOptions = (options) => {
24
37
  if (!options)
25
38
  return [];
@@ -75,28 +88,28 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
75
88
  switch (field.type) {
76
89
  case 'string':
77
90
  if (field.list) {
78
- return wrapWithComments(_jsx(StringListField, { id: fieldId, label: label, value: Array.isArray(currentValue) ? currentValue : [], onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
91
+ return wrapWithComments(_jsx(StringListField, { id: fieldId, label: label, description: field.description, value: Array.isArray(currentValue) ? currentValue : [], onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
79
92
  }
80
- return wrapWithComments(_jsx(TextField, { id: fieldId, label: label, value: currentValue ?? '', onChange: update, dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
93
+ return wrapWithComments(_jsx(TextField, { id: fieldId, label: label, description: field.description, value: currentValue ?? '', onChange: update, dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
81
94
  case 'boolean':
82
- return wrapWithComments(_jsx(ToggleField, { id: fieldId, label: label, value: Boolean(currentValue), onChange: (v) => update(Boolean(v)), dataCanopyField: normalizeCanopyPath(path), testId: `field-toggle-${field.name}` }, fieldKey(path)));
95
+ return wrapWithComments(_jsx(ToggleField, { id: fieldId, label: label, description: field.description, value: Boolean(currentValue), onChange: (v) => update(Boolean(v)), dataCanopyField: normalizeCanopyPath(path), testId: `field-toggle-${field.name}` }, fieldKey(path)));
83
96
  case 'number':
84
97
  if (field.list) {
85
- return wrapWithComments(_jsx(NumberListField, { id: fieldId, label: label, value: Array.isArray(currentValue)
98
+ return wrapWithComments(_jsx(NumberListField, { id: fieldId, label: label, description: field.description, value: Array.isArray(currentValue)
86
99
  ? currentValue.filter((v) => typeof v === 'number')
87
100
  : [], onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
88
101
  }
89
- return wrapWithComments(_jsx(NumberField, { id: fieldId, label: label, value: typeof currentValue === 'number' ? currentValue : undefined, onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
102
+ return wrapWithComments(_jsx(NumberField, { id: fieldId, label: label, description: field.description, value: typeof currentValue === 'number' ? currentValue : undefined, onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
90
103
  case 'datetime':
91
- return wrapWithComments(_jsx(DateTimeField, { id: fieldId, label: label, value: typeof currentValue === 'string' ? currentValue : '', onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
104
+ return wrapWithComments(_jsx(DateTimeField, { id: fieldId, label: label, description: field.description, value: typeof currentValue === 'string' ? currentValue : '', onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
92
105
  case 'markdown':
93
106
  case 'mdx':
94
- return wrapWithComments(_jsx(MarkdownField, { id: fieldId, label: label, value: currentValue ?? '', onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
107
+ return wrapWithComments(_jsx(MarkdownField, { id: fieldId, label: label, description: field.description, value: currentValue ?? '', onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
95
108
  case 'select': {
96
109
  const selectField = field;
97
110
  const options = normalizeOptions(selectField.options);
98
111
  const isMulti = Boolean(selectField.list);
99
- return wrapWithComments(_jsx(SelectField, { id: fieldId, label: label, options: options, value: isMulti
112
+ return wrapWithComments(_jsx(SelectField, { id: fieldId, label: label, description: field.description, options: options, value: isMulti
100
113
  ? Array.isArray(currentValue)
101
114
  ? currentValue
102
115
  : []
@@ -108,7 +121,7 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
108
121
  ? normalizeOptions(referenceField.options)
109
122
  : undefined;
110
123
  const isMulti = Boolean(referenceField.list);
111
- return wrapWithComments(_jsx(ReferenceField, { id: fieldId, label: label, options: staticOptions?.map((opt) => ({
124
+ return wrapWithComments(_jsx(ReferenceField, { id: fieldId, label: label, description: field.description, options: staticOptions?.map((opt) => ({
112
125
  label: opt.label,
113
126
  value: opt.value,
114
127
  })), collections: referenceField.collections, entryTypes: referenceField.entryTypes, displayField: referenceField.displayField, branch: branch, value: isMulti
@@ -119,7 +132,7 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
119
132
  }
120
133
  case 'image': {
121
134
  const imageField = field;
122
- return wrapWithComments(_jsx(ImageField, { id: fieldId, label: label, value: currentValue, onChange: (next) => update(next), aspect: imageField.aspect, altOptional: imageField.altOptional, dataCanopyField: canopyPath, errors: {
135
+ return wrapWithComments(_jsx(ImageField, { id: fieldId, label: label, description: field.description, value: currentValue, onChange: (next) => update(next), aspect: imageField.aspect, altOptional: imageField.altOptional, dataCanopyField: canopyPath, errors: {
123
136
  src: fieldErrors?.[`${canopyPath}.src`],
124
137
  alt: fieldErrors?.[`${canopyPath}.alt`],
125
138
  crop: fieldErrors?.[`${canopyPath}.crop`],
@@ -127,7 +140,7 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
127
140
  }
128
141
  case 'block': {
129
142
  const blockField = field;
130
- return wrapWithComments(_jsx(BlockField, { label: label, templates: blockField.templates, value: (Array.isArray(currentValue) ? currentValue : []), onChange: (next) => update(next), renderField: renderField, path: path, dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
143
+ return wrapWithComments(_jsx(BlockField, { label: label, description: field.description, templates: blockField.templates, value: (Array.isArray(currentValue) ? currentValue : []), onChange: (next) => update(next), renderField: renderField, path: path, dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
131
144
  }
132
145
  case 'object': {
133
146
  const objectField = field;
@@ -135,11 +148,14 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
135
148
  const items = Array.isArray(currentValue)
136
149
  ? currentValue
137
150
  : [];
138
- return wrapWithComments(_jsx(Paper, { withBorder: true, radius: "md", p: "md", shadow: "xs", children: _jsxs(Stack, { gap: "sm", children: [_jsxs(Group, { justify: "space-between", children: [_jsx(Text, { size: "sm", fw: 600, children: label }), _jsx(Button, { size: "xs", variant: "light", onClick: () => update([...items, {}]), children: "Add item" })] }), _jsxs(Stack, { gap: "sm", children: [items.map((item, idx) => (_jsx(Paper, { withBorder: true, radius: "md", p: "sm", shadow: "xs", children: _jsxs(Stack, { gap: "xs", children: [_jsxs(Group, { justify: "space-between", children: [_jsxs(Text, { size: "xs", fw: 700, children: [label, " #", idx + 1] }), _jsx(Button, { size: "xs", variant: "subtle", color: "red", onClick: () => update(items.filter((_, i) => i !== idx)), children: "Remove" })] }), _jsx(ObjectField, { label: objectField.label, fields: objectField.fields, value: item, onChange: (next) => {
139
- const nextItems = [...items];
140
- nextItems[idx] = next;
141
- update(nextItems);
142
- }, renderField: renderField, path: [...path, idx], dataCanopyField: normalizeCanopyPath([...path, idx]) })] }) }, fieldKey([...path, idx])))), items.length === 0 && (_jsx(Text, { size: "xs", c: "dimmed", children: "No items yet. Add one to get started." }))] })] }) }, fieldKey(path)));
151
+ return wrapWithComments(_jsx(Paper, { withBorder: true, radius: "md", p: "md", shadow: "xs", ...groupDescriptionProps(fieldId, field.description), children: _jsxs(Stack, { gap: "sm", children: [_jsxs(Group, { justify: "space-between", children: [_jsx(Text, { size: "sm", fw: 600, children: label }), _jsx(Button, { size: "xs", variant: "light", onClick: () => update([...items, {}]), children: "Add item" })] }), _jsx(FieldDescription, { baseId: fieldId, description: field.description }), _jsxs(Stack, { gap: "sm", children: [items.map((item, idx) => {
152
+ const itemTitle = listItemTitle(item, objectField.itemTitleField, `${label} #${idx + 1}`);
153
+ return (_jsx(Paper, { role: "group", "aria-label": itemTitle, withBorder: true, radius: "md", p: "sm", shadow: "xs", children: _jsxs(Stack, { gap: "xs", children: [_jsxs(Group, { justify: "space-between", children: [_jsx(Text, { size: "xs", fw: 700, children: itemTitle }), _jsx(Button, { size: "xs", variant: "subtle", color: "red", onClick: () => update(items.filter((_, i) => i !== idx)), children: "Remove" })] }), _jsx(ObjectField, { fields: objectField.fields, value: item, onChange: (next) => {
154
+ const nextItems = [...items];
155
+ nextItems[idx] = next;
156
+ update(nextItems);
157
+ }, renderField: renderField, path: [...path, idx], dataCanopyField: normalizeCanopyPath([...path, idx]) })] }) }, fieldKey([...path, idx])));
158
+ }), items.length === 0 && (_jsx(Text, { size: "xs", c: "dimmed", children: "No items yet. Add one to get started." }))] })] }) }, fieldKey(path)));
143
159
  }
144
160
  // Unlike the list branch above (each item has its own Remove button), a
145
161
  // non-list object has no other way back to `undefined`: a required child
@@ -151,10 +167,10 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
151
167
  // doesn't read as deleting the field from the schema.
152
168
  const hasValue = currentValue !== undefined && currentValue !== null;
153
169
  const canClear = objectField.required !== true && hasValue;
154
- return wrapWithComments(_jsx(ObjectField, { label: label, fields: objectField.fields, value: currentValue, onChange: (next) => update(next), onRemove: canClear ? () => update(undefined) : undefined, renderField: renderField, path: path, dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
170
+ return wrapWithComments(_jsx(ObjectField, { label: label, description: field.description, fields: objectField.fields, value: currentValue, onChange: (next) => update(next), onRemove: canClear ? () => update(undefined) : undefined, renderField: renderField, path: path, dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
155
171
  }
156
172
  case 'code':
157
- return wrapWithComments(_jsx(CodeField, { id: fieldId, label: label, value: typeof currentValue === 'string' ? currentValue : '', onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
173
+ return wrapWithComments(_jsx(CodeField, { id: fieldId, label: label, description: field.description, value: typeof currentValue === 'string' ? currentValue : '', onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path) }, fieldKey(path)));
158
174
  default:
159
175
  return (_jsxs(Text, { size: "xs", c: "dimmed", children: ["Unsupported field: ", field.type] }, fieldKey(path)));
160
176
  }
@@ -0,0 +1,24 @@
1
+ import type { CSSProperties } from 'react';
2
+ /**
3
+ * Lightweight iframe wrapper to keep the preview in sync with form state.
4
+ * It posts the latest draft data to the iframe after load and when data changes.
5
+ */
6
+ export declare const PreviewFrame: ({ src, path, data, isLoading, className, style, highlightEnabled, onPreviewError, assetBase, }: {
7
+ src: string;
8
+ path: string;
9
+ data?: unknown;
10
+ isLoading?: unknown;
11
+ className?: string;
12
+ style?: CSSProperties;
13
+ highlightEnabled?: boolean;
14
+ /**
15
+ * The editor's authenticated asset route, a same-origin path, so it is sent with drafts only to
16
+ * a same-origin preview: another origin would resolve it against itself.
17
+ */
18
+ assetBase?: string;
19
+ /** Called when the preview reports a draft compile/render error; null clears it. */
20
+ onPreviewError?: (error: {
21
+ message: string;
22
+ fieldPath?: string;
23
+ } | null) => void;
24
+ }) => import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,140 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { CANOPY_PREVIEW_ERROR, CANOPY_PREVIEW_HIGHLIGHT, CANOPY_PREVIEW_MESSAGE, CANOPY_PREVIEW_READY, isOpaqueOrigin, resolveMessageOrigin, } from './preview-bridge.js';
5
+ const sendDraftUpdate = (iframe, message, targetOrigin) => {
6
+ if (!iframe?.contentWindow)
7
+ return;
8
+ const target = targetOrigin ?? resolveMessageOrigin(iframe.src);
9
+ if (isOpaqueOrigin(target))
10
+ return;
11
+ iframe.contentWindow.postMessage(message, target);
12
+ };
13
+ /**
14
+ * Lightweight iframe wrapper to keep the preview in sync with form state.
15
+ * It posts the latest draft data to the iframe after load and when data changes.
16
+ */
17
+ export const PreviewFrame = ({ src, path, data, isLoading, className, style, highlightEnabled, onPreviewError, assetBase, }) => {
18
+ const iframeRef = useRef(null);
19
+ // Pin the preview origin from the src prop: outbound messages target it (never '*'),
20
+ // and inbound messages must come from it. An iframe that navigates cross-origin
21
+ // silently stops participating in the bridge.
22
+ const previewOrigin = resolveMessageOrigin(src);
23
+ // Show progress bar while waiting for the preview's ready handshake.
24
+ const [syncPending, setSyncPending] = useState(data !== undefined);
25
+ // Reset when navigating to a different entry (src change = new iframe page load).
26
+ const [prevSrc, setPrevSrc] = useState(src);
27
+ if (src !== prevSrc) {
28
+ setPrevSrc(src);
29
+ setSyncPending(data !== undefined);
30
+ }
31
+ // Inject the progress bar keyframe animation once per page.
32
+ useEffect(() => {
33
+ const styleId = 'canopycms-preview-sync-style';
34
+ if (!document.getElementById(styleId)) {
35
+ const el = document.createElement('style');
36
+ el.id = styleId;
37
+ el.textContent = `@keyframes canopy-preview-sync { 0% { transform: translateX(-100%); } 100% { transform: translateX(250%); } }`;
38
+ document.head.appendChild(el);
39
+ }
40
+ }, []);
41
+ const post = () => {
42
+ if (data === undefined)
43
+ return;
44
+ const sameOrigin = previewOrigin === window.location.origin;
45
+ sendDraftUpdate(iframeRef.current, {
46
+ type: CANOPY_PREVIEW_MESSAGE,
47
+ path,
48
+ data,
49
+ isLoading,
50
+ ...(assetBase !== undefined && sameOrigin ? { assetBase } : {}),
51
+ }, previewOrigin);
52
+ };
53
+ const postHighlight = () => {
54
+ if (!iframeRef.current?.contentWindow)
55
+ return;
56
+ if (isOpaqueOrigin(previewOrigin))
57
+ return;
58
+ const msg = {
59
+ type: CANOPY_PREVIEW_HIGHLIGHT,
60
+ enabled: Boolean(highlightEnabled),
61
+ };
62
+ iframeRef.current.contentWindow.postMessage(msg, previewOrigin);
63
+ };
64
+ // Keep refs pointing at the latest closures so the message handler below never goes stale.
65
+ const postRef = useRef(post);
66
+ const postHighlightRef = useRef(postHighlight);
67
+ const onPreviewErrorRef = useRef(onPreviewError);
68
+ useEffect(() => {
69
+ postRef.current = post;
70
+ postHighlightRef.current = postHighlight;
71
+ onPreviewErrorRef.current = onPreviewError;
72
+ });
73
+ useEffect(() => {
74
+ post();
75
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- post is tracked via ref
76
+ }, [data, isLoading]);
77
+ useEffect(() => {
78
+ postHighlight();
79
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- postHighlight is tracked via ref
80
+ }, [highlightEnabled]);
81
+ // When the preview page's React effects have run and its message listener is ready,
82
+ // it sends CANOPY_PREVIEW_READY. We respond with the current data so the preview
83
+ // receives the draft even if it wasn't ready when onLoad fired.
84
+ useEffect(() => {
85
+ const handleMessage = (event) => {
86
+ if (!iframeRef.current || event.source !== iframeRef.current.contentWindow)
87
+ return;
88
+ // Source check alone is insufficient if the iframe navigated cross-origin.
89
+ if (isOpaqueOrigin(previewOrigin) || event.origin !== previewOrigin)
90
+ return;
91
+ const type = event.data?.type;
92
+ if (type === CANOPY_PREVIEW_READY) {
93
+ postRef.current();
94
+ postHighlightRef.current();
95
+ setSyncPending(false);
96
+ }
97
+ else if (type === CANOPY_PREVIEW_ERROR) {
98
+ const msg = event.data;
99
+ // Shape-check the payload: an adopter passing an Error object (instead of
100
+ // err.message) must not crash the editor when rendered as a React child.
101
+ if (msg.message != null && typeof msg.message !== 'string')
102
+ return;
103
+ const fieldPath = typeof msg.fieldPath === 'string' ? msg.fieldPath : undefined;
104
+ onPreviewErrorRef.current?.(msg.message == null
105
+ ? null
106
+ : { message: msg.message, ...(fieldPath ? { fieldPath } : {}) });
107
+ }
108
+ };
109
+ window.addEventListener('message', handleMessage);
110
+ return () => window.removeEventListener('message', handleMessage);
111
+ }, [previewOrigin]);
112
+ return (_jsxs("div", { className: className, style: { position: 'relative', overflow: 'hidden', ...style }, children: [syncPending && (_jsx("div", { style: {
113
+ position: 'absolute',
114
+ top: 0,
115
+ left: 0,
116
+ right: 0,
117
+ height: 3,
118
+ zIndex: 1,
119
+ overflow: 'hidden',
120
+ }, children: _jsx("div", { style: {
121
+ position: 'absolute',
122
+ top: 0,
123
+ left: 0,
124
+ height: '100%',
125
+ width: '40%',
126
+ background: 'var(--mantine-color-blue-filled, #228be6)',
127
+ borderRadius: '0 2px 2px 0',
128
+ animation: 'canopy-preview-sync 1.5s ease-in-out infinite',
129
+ } }) })), _jsx("iframe", { ref: iframeRef, src: src, style: {
130
+ display: 'block',
131
+ position: 'absolute',
132
+ inset: 0,
133
+ width: '100%',
134
+ height: '100%',
135
+ border: 'none',
136
+ }, onLoad: () => {
137
+ post();
138
+ postHighlight();
139
+ } })] }));
140
+ };
@@ -59,7 +59,9 @@ function purgeGateFor(entry) {
59
59
  }
60
60
  return {
61
61
  disabled: lockFresh,
62
- tooltip: lockFresh ? 'Provisioning may be in progress' : undefined,
62
+ tooltip: lockFresh
63
+ ? 'Provisioning, or the worker syncing this branch, may be in progress'
64
+ : undefined,
63
65
  };
64
66
  }
65
67
  function workerLivenessBadge(worker, mode) {
@@ -76,6 +78,33 @@ function workerLivenessBadge(worker, mode) {
76
78
  return { color: 'red', label: 'Worker: absent' };
77
79
  }
78
80
  }
81
+ const BASE_REFRESH_LABELS = {
82
+ refreshed: 'fast-forwarded',
83
+ 'up-to-date': 'up to date',
84
+ 'skipped-dirty': 'refresh skipped (uncommitted changes)',
85
+ 'skipped-locked': 'refresh skipped (workspace busy: provisioning or an admin action)',
86
+ 'skipped-not-provisioned': 'not yet provisioned',
87
+ failed: 'refresh failed',
88
+ };
89
+ /**
90
+ * Why the base branch needs an operator, or null when its last refresh needs
91
+ * nothing. Shared by the overview and the base row's warning tooltip.
92
+ */
93
+ function baseRefreshWarning(report) {
94
+ if (!report)
95
+ return null;
96
+ const lines = [];
97
+ if (report.outcome === 'skipped-dirty' || report.outcome === 'failed') {
98
+ lines.push(`Base branch ${BASE_REFRESH_LABELS[report.outcome]}${report.message ? `: ${report.message}` : ''}`);
99
+ if (report.dirtyFiles?.length)
100
+ lines.push(`Uncommitted: ${report.dirtyFiles.join(', ')}`);
101
+ }
102
+ if (report.trackedCanopyMeta?.length) {
103
+ lines.push(`The site repo tracks canopycms state (${report.trackedCanopyMeta.join(', ')}). ` +
104
+ 'Untrack it with `git rm -r --cached .canopy-meta`, add `.canopy-meta/` to .gitignore, and commit.');
105
+ }
106
+ return lines.length > 0 ? lines.join('\n') : null;
107
+ }
79
108
  // Mirrors BranchManager.tsx's statusColorMap -- kept local (not exported
80
109
  // there) rather than shared, same tiny lookup either way.
81
110
  const branchStatusColorMap = {
@@ -121,9 +150,19 @@ function OverviewTab({ health }) {
121
150
  const liveness = workerLivenessBadge(status.worker, status.mode);
122
151
  const lastFatalError = status.workerStatus?.lastFatalError;
123
152
  const lastGitSync = status.workerStatus?.lastGitSync;
124
- return (_jsxs(Stack, { gap: "md", children: [error && (_jsx(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), variant: "light", children: error })), _jsxs(Group, { gap: "sm", children: [_jsx(Badge, { color: liveness.color, variant: "light", size: "lg", children: liveness.label }), status.mode === 'dev' && (_jsx(Text, { size: "xs", c: "dimmed", children: "No worker runs in dev mode" }))] }), isRecentFatalError && lastFatalError && (_jsxs(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Worker crash detected", children: [_jsx(Text, { size: "sm", children: lastFatalError.message }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["at ", lastFatalError.at, " (", lastFatalError.phase, ")"] })] })), status.statusReadError && (_jsxs(Text, { size: "xs", c: "orange", children: ["Warning: could not read worker status (", status.statusReadError, ")"] })), status.workerStatus?.lastGitSyncError && (_jsxs(Alert, { color: "orange", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Last git sync failed", children: [_jsx(Text, { size: "sm", children: status.workerStatus.lastGitSyncError.message }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["at ", status.workerStatus.lastGitSyncError.at] })] })), lastGitSync && (_jsxs(Paper, { withBorder: true, p: "sm", radius: "md", children: [_jsx(Text, { size: "sm", fw: 600, children: "Last git sync" }), _jsxs(Text, { size: "xs", c: "dimmed", children: [status.workerStatus?.lastGitSyncAt ?? 'unknown time', " \u00B7 ", lastGitSync.durationMs, "ms \u00B7", ' ', lastGitSync.rebased.length, " rebased \u00B7 ", lastGitSync.skippedDirty.length, " skipped (dirty)", lastGitSync.skippedLocked && lastGitSync.skippedLocked.length > 0
125
- ? ` · ${lastGitSync.skippedLocked.length} skipped (content write in progress)`
126
- : ''] }), lastGitSync.failed.length > 0 && (_jsx(Spoiler, { maxHeight: 0, showLabel: `${lastGitSync.failed.length} failed — show details`, hideLabel: "Hide", children: _jsx(Stack, { gap: 4, mt: 4, children: lastGitSync.failed.map((f) => (_jsxs(Text, { size: "xs", c: "red", children: [f.branch, ": ", f.error] }, f.branch))) }) }))] })), _jsx(SimpleGrid, { cols: 5, spacing: "xs", children: ['pending', 'processing', 'completed', 'failed', 'corrupt'].map((key) => (_jsxs(Paper, { withBorder: true, p: "xs", radius: "md", ta: "center", children: [_jsx(Text, { size: "xs", c: "dimmed", tt: "capitalize", children: key }), _jsx(Text, { size: "lg", fw: 700, c: (key === 'failed' || key === 'corrupt') && status.queue[key] > 0 ? 'red' : undefined, children: status.queue[key] })] }, key))) }), status.queue.oldestPendingAgeMs !== undefined && (_jsxs(Text, { size: "xs", c: "dimmed", children: ["Oldest pending task: ", formatAgeMs(status.queue.oldestPendingAgeMs), " old"] })), _jsxs(Group, { justify: "space-between", align: "center", children: [_jsxs(Text, { size: "xs", c: "dimmed", children: ["Generated at ", status.generatedAt] }), _jsx(Button, { size: "xs", variant: "light", onClick: () => refresh(), loading: statusLoading, children: "Refresh" })] }), _jsx(Text, { size: "xs", c: "dimmed", children: "Data may lag up to ~60s (shared-filesystem caching)" })] }));
153
+ const baseWarning = baseRefreshWarning(lastGitSync?.baseRefresh);
154
+ const { build } = status;
155
+ // Absent for a worker that predates the field, which is not evidence of skew;
156
+ // nor is a stale or absent worker's leftover status file, which names no running build.
157
+ const workerVersion = status.workerStatus?.workerVersion || undefined;
158
+ const versionSkew = status.worker.state === 'alive' &&
159
+ workerVersion !== undefined &&
160
+ workerVersion !== build.canopycmsVersion;
161
+ return (_jsxs(Stack, { gap: "md", children: [error && (_jsx(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), variant: "light", children: error })), _jsxs(Group, { gap: "sm", children: [_jsx(Badge, { color: liveness.color, variant: "light", size: "lg", children: liveness.label }), status.mode === 'dev' && (_jsx(Text, { size: "xs", c: "dimmed", children: "No worker runs in dev mode" }))] }), isRecentFatalError && lastFatalError && (_jsxs(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Worker crash detected", children: [_jsx(Text, { size: "sm", children: lastFatalError.message }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["at ", lastFatalError.at, " (", lastFatalError.phase, ")"] })] })), status.settingsWorkspaceError && (_jsx(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Settings workspace unavailable: groups and path rules are not loading", children: _jsx(Text, { size: "sm", children: status.settingsWorkspaceError }) })), status.statusReadError && (_jsxs(Text, { size: "xs", c: "orange", children: ["Warning: could not read worker status (", status.statusReadError, ")"] })), status.workerStatus?.lastGitSyncError && (_jsxs(Alert, { color: "orange", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Last git sync failed", children: [_jsx(Text, { size: "sm", children: status.workerStatus.lastGitSyncError.message }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["at ", status.workerStatus.lastGitSyncError.at] })] })), versionSkew && (_jsx(Alert, { color: "orange", icon: _jsx(IconAlertCircle, { size: 16 }), title: "API and worker versions differ", "data-testid": "version-skew-warning", children: _jsxs(Text, { size: "sm", children: ["The API runs canopycms ", build.canopycmsVersion, " but the worker runs canopycms", ' ', workerVersion, ". They were deployed from different builds."] }) })), _jsxs(Paper, { withBorder: true, p: "sm", radius: "md", children: [_jsx(Text, { size: "sm", fw: 600, children: "Build" }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-api-version", children: ["API: canopycms ", build.canopycmsVersion] }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-source-revision", children: ["Source revision:", ' ', build.sourceRevision ? (_jsx(Tooltip, { label: build.sourceRevision, children: _jsx(Code, { children: build.sourceRevision.slice(0, 12) }) })) : ('not set (pass the CANOPY_SOURCE_SHA build arg to the image build)')] }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-worker-version", children: ["Worker version: ", workerVersion ? `canopycms ${workerVersion}` : 'unknown'] }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-media", children: ["Media storage:", ' ', status.assetStore.configured ? 'configured' : 'not configured — uploads are disabled'] }), _jsxs(Text, { size: "xs", c: status.imageProcessing.available ? 'dimmed' : 'orange', "data-testid": "build-image-processing", children: ["Image processing:", ' ', status.imageProcessing.available
162
+ ? 'available'
163
+ : `unavailable — new editor image sizes and crops fail, and uploads skip decode validation${status.imageProcessing.error ? ` (${status.imageProcessing.error})` : ''}`] })] }), lastGitSync && (_jsxs(Paper, { withBorder: true, p: "sm", radius: "md", children: [_jsx(Text, { size: "sm", fw: 600, children: "Last git sync" }), _jsxs(Text, { size: "xs", c: "dimmed", children: [status.workerStatus?.lastGitSyncAt ?? 'unknown time', " \u00B7 ", lastGitSync.durationMs, "ms \u00B7", ' ', lastGitSync.rebased.length, " rebased \u00B7 ", lastGitSync.skippedDirty.length, " skipped (dirty)", lastGitSync.skippedLocked && lastGitSync.skippedLocked.length > 0
164
+ ? ` · ${lastGitSync.skippedLocked.length} skipped (busy: a content write, provisioning or purge)`
165
+ : ''] }), lastGitSync.baseRefresh && (_jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "base-refresh-outcome", children: ["Base branch: ", BASE_REFRESH_LABELS[lastGitSync.baseRefresh.outcome]] })), baseWarning && (_jsx(Text, { size: "xs", c: "orange", style: { whiteSpace: 'pre-line' }, "data-testid": "base-refresh-warning", children: baseWarning })), lastGitSync.failed.length > 0 && (_jsx(Spoiler, { maxHeight: 0, showLabel: `${lastGitSync.failed.length} failed — show details`, hideLabel: "Hide", children: _jsx(Stack, { gap: 4, mt: 4, children: lastGitSync.failed.map((f) => (_jsxs(Text, { size: "xs", c: "red", children: [f.branch, ": ", f.error] }, f.branch))) }) }))] })), _jsx(SimpleGrid, { cols: 5, spacing: "xs", children: ['pending', 'processing', 'completed', 'failed', 'corrupt'].map((key) => (_jsxs(Paper, { withBorder: true, p: "xs", radius: "md", ta: "center", children: [_jsx(Text, { size: "xs", c: "dimmed", tt: "capitalize", children: key }), _jsx(Text, { size: "lg", fw: 700, c: (key === 'failed' || key === 'corrupt') && status.queue[key] > 0 ? 'red' : undefined, children: status.queue[key] })] }, key))) }), status.queue.oldestPendingAgeMs !== undefined && (_jsxs(Text, { size: "xs", c: "dimmed", children: ["Oldest pending task: ", formatAgeMs(status.queue.oldestPendingAgeMs), " old"] })), _jsxs(Group, { justify: "space-between", align: "center", children: [_jsxs(Text, { size: "xs", c: "dimmed", children: ["Generated at ", status.generatedAt] }), _jsx(Button, { size: "xs", variant: "light", onClick: () => refresh(), loading: statusLoading, children: "Refresh" })] }), _jsx(Text, { size: "xs", c: "dimmed", children: "Data may lag up to ~60s (shared-filesystem caching)" })] }));
127
166
  }
128
167
  function TasksTab({ health }) {
129
168
  const { taskStatus, setTaskStatus, tasks, tasksLoading } = health;
@@ -160,6 +199,7 @@ function TasksTab({ health }) {
160
199
  function BranchesTab({ health }) {
161
200
  const { branchHealth, branchHealthLoading } = health;
162
201
  const entries = branchHealth?.entries ?? [];
202
+ const baseWarning = baseRefreshWarning(health.status?.workerStatus?.lastGitSync?.baseRefresh);
163
203
  const handleMarkMergedClick = (branchName) => {
164
204
  modals.openConfirmModal({
165
205
  title: 'Mark branch as merged',
@@ -193,9 +233,9 @@ function BranchesTab({ health }) {
193
233
  if (entries.length === 0) {
194
234
  return (_jsx(Text, { size: "sm", c: "dimmed", py: "md", children: "No branch directories found." }));
195
235
  }
196
- return (_jsx(Table.ScrollContainer, { minWidth: 700, children: _jsxs(Table, { children: [_jsx(Table.Thead, { children: _jsxs(Table.Tr, { children: [_jsx(Table.Th, { children: "Name" }), _jsx(Table.Th, { children: "Status" }), _jsx(Table.Th, { children: "PR" }), _jsx(Table.Th, { children: "Sync" }), _jsx(Table.Th, { children: "Warnings" }), _jsx(Table.Th, { children: "Updated" }), _jsx(Table.Th, { children: "Actions" })] }) }), _jsx(Table.Tbody, { children: entries.map((entry) => (_jsx(BranchHealthRow, { entry: entry, onMarkMerged: handleMarkMergedClick, onRepair: handleRepairClick, onPurge: handlePurgeClick }, entry.dirName))) })] }) }));
236
+ return (_jsx(Table.ScrollContainer, { minWidth: 700, children: _jsxs(Table, { children: [_jsx(Table.Thead, { children: _jsxs(Table.Tr, { children: [_jsx(Table.Th, { children: "Name" }), _jsx(Table.Th, { children: "Status" }), _jsx(Table.Th, { children: "PR" }), _jsx(Table.Th, { children: "Sync" }), _jsx(Table.Th, { children: "Warnings" }), _jsx(Table.Th, { children: "Updated" }), _jsx(Table.Th, { children: "Actions" })] }) }), _jsx(Table.Tbody, { children: entries.map((entry) => (_jsx(BranchHealthRow, { entry: entry, baseWarning: entry.isBaseBranch ? baseWarning : null, onMarkMerged: handleMarkMergedClick, onRepair: handleRepairClick, onPurge: handlePurgeClick }, entry.dirName))) })] }) }));
197
237
  }
198
- function BranchHealthRow({ entry, onMarkMerged, onRepair, onPurge, }) {
238
+ function BranchHealthRow({ entry, baseWarning, onMarkMerged, onRepair, onPurge, }) {
199
239
  if (entry.kind === 'healthy' && entry.branch) {
200
240
  const b = entry.branch;
201
241
  // Mirrors the rebase loop's skip logic (worker/rebase.ts): the worker
@@ -207,7 +247,7 @@ function BranchHealthRow({ entry, onMarkMerged, onRepair, onPurge, }) {
207
247
  const canMarkMerged = (b.status === 'submitted' || b.status === 'approved') && !!b.pullRequestNumber;
208
248
  return (_jsxs(Table.Tr, { children: [_jsx(Table.Td, { children: _jsxs(Group, { gap: 4, wrap: "nowrap", children: [_jsx(Text, { size: "sm", children: entry.dirName }), entry.isBaseBranch && (_jsx(Badge, { size: "xs", color: "gray", variant: "outline", children: "base" }))] }) }), _jsx(Table.Td, { children: _jsx(Badge, { color: branchStatusColorMap[b.status] ?? 'neutral', variant: "light", children: b.status }) }), _jsx(Table.Td, { children: b.pullRequestNumber ? (_jsxs(Text, { size: "xs", c: "blue", component: "a", href: b.pullRequestUrl, target: "_blank", rel: "noopener noreferrer", style: { textDecoration: 'underline' }, children: ["PR #", b.pullRequestNumber, b.pullRequestState ? ` (${b.pullRequestState})` : ''] })) : (_jsx(Text, { size: "xs", c: "dimmed", children: "\u2014" })) }), _jsx(Table.Td, { children: b.syncStatus === 'sync-failed' ? (_jsx(Tooltip, { label: b.syncFailureReason
209
249
  ? `${b.syncFailureReason} — retry from the Tasks tab`
210
- : 'GitHub sync failed — retry from the Tasks tab', multiline: true, maw: 320, children: _jsx(Badge, { color: "red", variant: "light", children: "sync-failed" }) })) : b.syncStatus === 'pending-sync' ? (_jsx(Badge, { color: "gray", variant: "light", children: "pending-sync" })) : null }), _jsx(Table.Td, { children: _jsxs(Group, { gap: 6, wrap: "nowrap", children: [!!b.conflictFiles?.length && (_jsxs(Badge, { color: "orange", variant: "light", children: [b.conflictFiles.length, " conflict", b.conflictFiles.length === 1 ? '' : 's'] })), showRebaseFailure && b.rebaseFailure && (_jsx(Tooltip, { label: `${b.rebaseFailure.message} (failing since ${b.rebaseFailure.firstAt})`, multiline: true, maw: 320, children: _jsx(ThemeIcon, { color: "yellow", variant: "light", size: "sm", radius: "xl", "data-testid": `rebase-failure-${entry.dirName}`, children: _jsx(IconAlertTriangle, { size: 12 }) }) })), !!entry.duplicateContentIds?.length && (_jsx(Tooltip, { label: entry.duplicateContentIds
250
+ : 'GitHub sync failed — retry from the Tasks tab', multiline: true, maw: 320, children: _jsx(Badge, { color: "red", variant: "light", children: "sync-failed" }) })) : b.syncStatus === 'pending-sync' ? (_jsx(Badge, { color: "gray", variant: "light", children: "pending-sync" })) : null }), _jsx(Table.Td, { children: _jsxs(Group, { gap: 6, wrap: "nowrap", children: [!!b.conflictFiles?.length && (_jsxs(Badge, { color: "orange", variant: "light", children: [b.conflictFiles.length, " conflict", b.conflictFiles.length === 1 ? '' : 's'] })), showRebaseFailure && b.rebaseFailure && (_jsx(Tooltip, { label: `${b.rebaseFailure.message} (failing since ${b.rebaseFailure.firstAt})`, multiline: true, maw: 320, children: _jsx(ThemeIcon, { color: "yellow", variant: "light", size: "sm", radius: "xl", "data-testid": `rebase-failure-${entry.dirName}`, children: _jsx(IconAlertTriangle, { size: 12 }) }) })), baseWarning && (_jsx(Tooltip, { label: baseWarning, multiline: true, maw: 420, style: { whiteSpace: 'pre-line' }, children: _jsx(ThemeIcon, { color: "yellow", variant: "light", size: "sm", radius: "xl", "data-testid": `base-refresh-warning-${entry.dirName}`, children: _jsx(IconAlertTriangle, { size: 12 }) }) })), !!entry.duplicateContentIds?.length && (_jsx(Tooltip, { label: entry.duplicateContentIds
211
251
  .map((d) => `${d.id}: kept ${d.keptPath}; also on ${d.droppedPaths.join(', ')}`)
212
252
  .join('\n'), multiline: true, maw: 420, style: { whiteSpace: 'pre-line' }, children: _jsxs(Badge, { color: "orange", variant: "light", "data-testid": `duplicate-content-ids-${entry.dirName}`, children: [entry.duplicateContentIds.length, " duplicate ID", entry.duplicateContentIds.length === 1 ? '' : 's'] }) }))] }) }), _jsx(Table.Td, { children: _jsx(Text, { size: "xs", c: "dimmed", children: b.updatedAt }) }), _jsx(Table.Td, { children: canMarkMerged && (_jsx(Button, { size: "xs", variant: "light", "data-testid": `mark-merged-${b.name}`, onClick: () => onMarkMerged(b.name), children: "Mark merged" })) })] }));
213
253
  }
@@ -0,0 +1,9 @@
1
+ import React from 'react';
2
+ export interface BranchesDrawerProps {
3
+ opened: boolean;
4
+ onClose: () => void;
5
+ /** A confirm opened from inside is on screen: the drawer yields Escape to it (Mantine listens for Escape on window, capture phase). */
6
+ confirmOpen: boolean;
7
+ children: React.ReactNode;
8
+ }
9
+ export declare const BranchesDrawer: React.FC<BranchesDrawerProps>;
@@ -0,0 +1,4 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Drawer, Text, Title } from '@mantine/core';
4
+ export const BranchesDrawer = ({ opened, onClose, confirmOpen, children, }) => (_jsx(Drawer, { opened: opened, onClose: onClose, closeOnEscape: !confirmOpen, position: "right", title: _jsxs("div", { children: [_jsx(Title, { order: 4, children: "Branches" }), _jsx(Text, { size: "xs", c: "dimmed", children: "Manage access, status, and lifecycle" })] }), padding: "md", size: 420, overlayProps: { blur: 2 }, children: children }));