canopycms 0.0.67 → 0.0.68-int.100

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 (268) hide show
  1. package/README.md +21 -14
  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 +111 -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 +10 -1
  23. package/dist/api/github-sync.js +20 -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/index.d.ts +1 -1
  36. package/dist/assets/materialize.d.ts +122 -0
  37. package/dist/assets/materialize.js +305 -0
  38. package/dist/assets/store-local.d.ts +4 -0
  39. package/dist/assets/store-local.js +11 -0
  40. package/dist/assets/store-s3.d.ts +10 -1
  41. package/dist/assets/store-s3.js +55 -3
  42. package/dist/assets/transform-directives.d.ts +52 -13
  43. package/dist/assets/transform-directives.js +87 -27
  44. package/dist/assets/transform.d.ts +3 -3
  45. package/dist/assets/transform.js +13 -8
  46. package/dist/assets/types.d.ts +14 -1
  47. package/dist/auth/file-based-auth-cache.js +7 -8
  48. package/dist/authorization/content.d.ts +5 -4
  49. package/dist/authorization/content.js +5 -5
  50. package/dist/authorization/path.d.ts +11 -6
  51. package/dist/authorization/path.js +11 -6
  52. package/dist/authorization/types.d.ts +2 -2
  53. package/dist/branch-health.d.ts +7 -1
  54. package/dist/branch-health.js +8 -3
  55. package/dist/branch-metadata.d.ts +5 -5
  56. package/dist/branch-metadata.js +44 -36
  57. package/dist/branch-provisioning.d.ts +167 -0
  58. package/dist/branch-provisioning.js +501 -0
  59. package/dist/branch-schema-cache.d.ts +5 -3
  60. package/dist/branch-schema-cache.js +29 -11
  61. package/dist/branch-sparse.d.ts +23 -0
  62. package/dist/branch-sparse.js +118 -0
  63. package/dist/branch-workspace.d.ts +42 -3
  64. package/dist/branch-workspace.js +240 -94
  65. package/dist/build/asset-refs.d.ts +93 -0
  66. package/dist/build/asset-refs.js +251 -0
  67. package/dist/build-identity.d.ts +3 -0
  68. package/dist/build-identity.js +13 -0
  69. package/dist/cli/asset-refs.d.ts +30 -0
  70. package/dist/cli/asset-refs.js +168 -0
  71. package/dist/cli/cli.d.ts +2 -1
  72. package/dist/cli/cli.js +4843 -1870
  73. package/dist/cli/generate-ai-content.js +1584 -444
  74. package/dist/cli/github-app-manifest.d.ts +4 -3
  75. package/dist/cli/github-app-manifest.js +4 -3
  76. package/dist/cli/init.js +30 -27
  77. package/dist/cli/migrate.js +12 -8
  78. package/dist/cli/sync.js +83 -37
  79. package/dist/cli/template-files/Dockerfile.cms.template +8 -0
  80. package/dist/cli/template-files/cdk-app.ts.template +4 -0
  81. package/dist/cli/template-files/cms-stack.ts.template +23 -12
  82. package/dist/cli/template-files/deploy-cms.yml.template +2 -0
  83. package/dist/config/helpers.js +1 -2
  84. package/dist/config/schemas/config.d.ts +19 -32
  85. package/dist/config/schemas/config.js +9 -11
  86. package/dist/config/schemas/field.js +1 -0
  87. package/dist/config/schemas/media.d.ts +4 -13
  88. package/dist/config/schemas/media.js +5 -8
  89. package/dist/config/schemas/url.d.ts +4 -20
  90. package/dist/config/schemas/url.js +6 -22
  91. package/dist/config/types.d.ts +30 -20
  92. package/dist/config/validation.d.ts +6 -0
  93. package/dist/config/validation.js +37 -0
  94. package/dist/content-listing.d.ts +12 -12
  95. package/dist/content-listing.js +10 -6
  96. package/dist/content-reader.d.ts +11 -2
  97. package/dist/content-reader.js +22 -14
  98. package/dist/content-store.d.ts +37 -9
  99. package/dist/content-store.js +70 -43
  100. package/dist/content-tree.d.ts +1 -1
  101. package/dist/content-tree.js +2 -2
  102. package/dist/context.d.ts +27 -9
  103. package/dist/context.js +154 -70
  104. package/dist/editor/BranchManager.d.ts +7 -1
  105. package/dist/editor/BranchManager.js +29 -14
  106. package/dist/editor/CanopyEditor.d.ts +1 -1
  107. package/dist/editor/CanopyEditor.js +2 -6
  108. package/dist/editor/Editor.d.ts +4 -3
  109. package/dist/editor/Editor.js +24 -20
  110. package/dist/editor/FormRenderer.js +34 -18
  111. package/dist/editor/admin/SystemHealthPanel.js +47 -7
  112. package/dist/editor/components/BranchesDrawer.d.ts +9 -0
  113. package/dist/editor/components/BranchesDrawer.js +4 -0
  114. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  115. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  116. package/dist/editor/context/AssetContext.d.ts +18 -35
  117. package/dist/editor/context/AssetContext.js +20 -29
  118. package/dist/editor/context/index.d.ts +1 -1
  119. package/dist/editor/context/index.js +1 -1
  120. package/dist/editor/editor-config.d.ts +1 -2
  121. package/dist/editor/editor-config.js +0 -23
  122. package/dist/editor/editor-utils.d.ts +24 -21
  123. package/dist/editor/editor-utils.js +75 -62
  124. package/dist/editor/fields/BlockField.d.ts +1 -0
  125. package/dist/editor/fields/BlockField.js +6 -4
  126. package/dist/editor/fields/CodeField.d.ts +1 -0
  127. package/dist/editor/fields/CodeField.js +2 -2
  128. package/dist/editor/fields/DateTimeField.d.ts +1 -0
  129. package/dist/editor/fields/DateTimeField.js +2 -2
  130. package/dist/editor/fields/FieldDescription.d.ts +16 -0
  131. package/dist/editor/fields/FieldDescription.js +11 -0
  132. package/dist/editor/fields/ImageField.d.ts +1 -0
  133. package/dist/editor/fields/ImageField.js +3 -2
  134. package/dist/editor/fields/InlineGroupField.js +4 -1
  135. package/dist/editor/fields/MarkdownField.d.ts +1 -0
  136. package/dist/editor/fields/MarkdownField.js +115 -20
  137. package/dist/editor/fields/NumberField.d.ts +1 -0
  138. package/dist/editor/fields/NumberField.js +2 -2
  139. package/dist/editor/fields/NumberListField.d.ts +1 -0
  140. package/dist/editor/fields/NumberListField.js +2 -2
  141. package/dist/editor/fields/ObjectField.d.ts +1 -0
  142. package/dist/editor/fields/ObjectField.js +5 -2
  143. package/dist/editor/fields/ReferenceField.d.ts +1 -0
  144. package/dist/editor/fields/ReferenceField.js +5 -4
  145. package/dist/editor/fields/SelectField.d.ts +1 -0
  146. package/dist/editor/fields/SelectField.js +2 -2
  147. package/dist/editor/fields/StringListField.d.ts +1 -0
  148. package/dist/editor/fields/StringListField.js +2 -2
  149. package/dist/editor/fields/TextField.d.ts +1 -0
  150. package/dist/editor/fields/TextField.js +2 -2
  151. package/dist/editor/fields/ToggleField.d.ts +1 -0
  152. package/dist/editor/fields/ToggleField.js +7 -4
  153. package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
  154. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -2
  155. package/dist/editor/fields/mdx-jsx-support.d.ts +1 -0
  156. package/dist/editor/fields/mdx-jsx-support.js +137 -0
  157. package/dist/editor/hooks/create-branch-request.d.ts +20 -0
  158. package/dist/editor/hooks/create-branch-request.js +52 -0
  159. package/dist/editor/hooks/useBranchActions.d.ts +13 -2
  160. package/dist/editor/hooks/useBranchActions.js +48 -17
  161. package/dist/editor/hooks/useBranchManager.d.ts +11 -0
  162. package/dist/editor/hooks/useBranchManager.js +80 -2
  163. package/dist/editor/hooks/useBranchesData.d.ts +6 -0
  164. package/dist/editor/hooks/useBranchesData.js +9 -3
  165. package/dist/editor/hooks/useCommentSystem.js +25 -6
  166. package/dist/editor/hooks/useDraftManager.d.ts +16 -1
  167. package/dist/editor/hooks/useDraftManager.js +193 -36
  168. package/dist/editor/hooks/useEntriesData.js +3 -3
  169. package/dist/editor/hooks/useEntryManager.d.ts +2 -1
  170. package/dist/editor/hooks/useEntryManager.js +34 -19
  171. package/dist/editor/media/AssetCard.d.ts +1 -1
  172. package/dist/editor/media/crop-math.d.ts +3 -4
  173. package/dist/editor/media/crop-math.js +11 -22
  174. package/dist/editor/media/editor-image-src.d.ts +7 -0
  175. package/dist/editor/media/editor-image-src.js +12 -0
  176. package/dist/editor/preview-asset-base.d.ts +8 -0
  177. package/dist/editor/preview-asset-base.js +13 -0
  178. package/dist/editor/preview-bridge.d.ts +8 -1
  179. package/dist/editor/preview-bridge.js +15 -2
  180. package/dist/editor/preview-path.d.ts +10 -0
  181. package/dist/editor/preview-path.js +20 -0
  182. package/dist/editor/theme.js +2 -2
  183. package/dist/entry-schema-registry.d.ts +4 -4
  184. package/dist/entry-schema-registry.js +10 -6
  185. package/dist/entry-schema.d.ts +27 -4
  186. package/dist/entry-schema.js +20 -3
  187. package/dist/git-manager.d.ts +173 -6
  188. package/dist/git-manager.js +594 -76
  189. package/dist/github-service.d.ts +8 -0
  190. package/dist/github-service.js +5 -1
  191. package/dist/http/handler.js +37 -13
  192. package/dist/http/index.d.ts +2 -0
  193. package/dist/http/index.js +2 -0
  194. package/dist/http/router.d.ts +2 -0
  195. package/dist/http/router.js +2 -1
  196. package/dist/http/types.d.ts +2 -0
  197. package/dist/http/worker-not-ready.d.ts +10 -0
  198. package/dist/http/worker-not-ready.js +24 -0
  199. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  200. package/dist/operating-mode/types.d.ts +1 -4
  201. package/dist/paths/branch-name.d.ts +5 -0
  202. package/dist/paths/branch-name.js +9 -0
  203. package/dist/paths/branch.d.ts +6 -2
  204. package/dist/paths/branch.js +16 -10
  205. package/dist/paths/index.d.ts +3 -3
  206. package/dist/paths/index.js +3 -3
  207. package/dist/paths/normalize.d.ts +7 -0
  208. package/dist/paths/normalize.js +9 -0
  209. package/dist/paths/validation.js +0 -2
  210. package/dist/reference-resolver.d.ts +5 -21
  211. package/dist/reference-resolver.js +9 -44
  212. package/dist/resolve-canopy-user.js +2 -1
  213. package/dist/schema/schema-store.d.ts +54 -31
  214. package/dist/schema/schema-store.js +77 -31
  215. package/dist/server.d.ts +24 -9
  216. package/dist/server.js +22 -8
  217. package/dist/services.d.ts +18 -6
  218. package/dist/services.js +85 -70
  219. package/dist/settings-workspace.d.ts +23 -3
  220. package/dist/settings-workspace.js +197 -45
  221. package/dist/static/seo.d.ts +2 -14
  222. package/dist/static/seo.js +2 -25
  223. package/dist/submission-attribution.d.ts +73 -0
  224. package/dist/submission-attribution.js +221 -0
  225. package/dist/sync-core.d.ts +12 -1
  226. package/dist/sync-core.js +31 -13
  227. package/dist/task-queue/worker-status.d.ts +8 -0
  228. package/dist/task-queue/worker-status.js +17 -0
  229. package/dist/types.d.ts +31 -2
  230. package/dist/utils/content-write-lock.d.ts +18 -7
  231. package/dist/utils/content-write-lock.js +18 -9
  232. package/dist/utils/debug.d.ts +8 -0
  233. package/dist/utils/debug.js +10 -2
  234. package/dist/utils/git.d.ts +32 -0
  235. package/dist/utils/git.js +42 -0
  236. package/dist/utils/occ-json-write.js +10 -2
  237. package/dist/utils/provision-log.d.ts +25 -0
  238. package/dist/utils/provision-log.js +49 -0
  239. package/dist/utils/provisioning-lock.d.ts +35 -5
  240. package/dist/utils/provisioning-lock.js +73 -12
  241. package/dist/utils/request-timing.d.ts +25 -0
  242. package/dist/utils/request-timing.js +101 -0
  243. package/dist/utils/sanitize-href.d.ts +8 -22
  244. package/dist/utils/sanitize-href.js +11 -28
  245. package/dist/utils/url-prefix.d.ts +30 -0
  246. package/dist/utils/url-prefix.js +61 -0
  247. package/dist/validation/entry-validator.js +9 -3
  248. package/dist/version.d.ts +1 -0
  249. package/dist/version.js +3 -0
  250. package/dist/worker/canopy-state.d.ts +45 -0
  251. package/dist/worker/canopy-state.js +75 -0
  252. package/dist/worker/cms-worker.d.ts +8 -3
  253. package/dist/worker/cms-worker.js +38 -2
  254. package/dist/worker/git-sync.d.ts +30 -12
  255. package/dist/worker/git-sync.js +253 -58
  256. package/dist/worker/history-rewrite.d.ts +1 -1
  257. package/dist/worker/history-rewrite.js +1 -1
  258. package/dist/worker/provisioned-workspace.d.ts +35 -0
  259. package/dist/worker/provisioned-workspace.js +50 -0
  260. package/dist/worker/rebase.d.ts +1 -1
  261. package/dist/worker/rebase.js +95 -23
  262. package/dist/worker/remote-git-maintenance.d.ts +3 -0
  263. package/dist/worker/remote-git-maintenance.js +11 -0
  264. package/dist/worker/sparse-cone.d.ts +20 -0
  265. package/dist/worker/sparse-cone.js +132 -0
  266. package/dist/worker/task-runner.js +39 -4
  267. package/dist/worker/worker-context.d.ts +6 -1
  268. package/package.json +2 -1
@@ -0,0 +1,52 @@
1
+ import { isCreatorsRecentBranch } from '../../api/branch-create-window.js';
2
+ import { isNonApiResponse } from '../../api/client.js';
3
+ import { sanitizeBranchName } from '../../paths/branch-name.js';
4
+ /** @internal Exported for tests. Past the 60 s a Lambda behind CloudFront gets, so its 504 lands first. */
5
+ export const CREATE_BRANCH_DEADLINE_MS = 90_000;
6
+ /** @internal Exported for tests. */
7
+ export const CREATE_TIMED_OUT_MESSAGE = "Creating the branch didn't finish (the server timed out). It's safe to try again.";
8
+ const DEADLINE = Symbol('deadline');
9
+ /**
10
+ * Create a branch. Past the deadline, after an error page from in front of the API, or after a
11
+ * busy 503 the branch may exist anyway, so the branch list decides; retrying is safe because a
12
+ * create publishes the whole branch or nothing. `userId` is the current user, the only creator
13
+ * whose listed branch can be this create's result.
14
+ */
15
+ export async function requestBranchCreate(apiClient, body, userId, deadlineMs = CREATE_BRANCH_DEADLINE_MS) {
16
+ let timer;
17
+ const deadline = new Promise((resolve) => {
18
+ timer = setTimeout(() => resolve(DEADLINE), deadlineMs);
19
+ });
20
+ const result = await Promise.race([apiClient.branches.create(body), deadline]).finally(() => clearTimeout(timer));
21
+ if (result === DEADLINE)
22
+ return settle(apiClient, body.branch, userId, CREATE_TIMED_OUT_MESSAGE);
23
+ if (result.ok)
24
+ return { kind: 'created', branch: result.data?.branch };
25
+ if (isNonApiResponse(result))
26
+ return settle(apiClient, body.branch, userId, CREATE_TIMED_OUT_MESSAGE);
27
+ const message = result.error || 'Failed to create branch';
28
+ return result.status === 503
29
+ ? settle(apiClient, body.branch, userId, message)
30
+ : { kind: 'failed', message };
31
+ }
32
+ /** A same-named branch the server would not answer this create with is a name conflict. */
33
+ async function settle(apiClient, requested, userId, message) {
34
+ const listed = await findListedBranch(apiClient, requested);
35
+ if (!listed)
36
+ return { kind: 'failed', message };
37
+ if (isCreatorsRecentBranch(listed, userId))
38
+ return { kind: 'created', branch: listed };
39
+ return { kind: 'failed', message: `A branch named "${listed.name}" already exists` };
40
+ }
41
+ async function findListedBranch(apiClient, requested) {
42
+ const name = sanitizeBranchName(requested);
43
+ try {
44
+ const listing = await apiClient.branches.list();
45
+ if (!listing.ok)
46
+ return undefined;
47
+ return listing.data?.branches.find((branch) => sanitizeBranchName(branch.name) === name);
48
+ }
49
+ catch {
50
+ return undefined;
51
+ }
52
+ }
@@ -1,17 +1,28 @@
1
+ import type { BranchListItem } from '../../api/branch.js';
2
+ import type { UnsavedSummary } from './useDraftManager.js';
1
3
  export interface UseBranchActionsOptions {
2
4
  branchName: string;
3
5
  setBranchName: (name: string) => void;
4
- isAnyDirty: () => boolean;
6
+ /** From useDraftManager's `resolveUnsaved`: what a branch-level action would leave behind. */
7
+ getUnsaved: () => Promise<UnsavedSummary>;
5
8
  onReloadBranches: () => Promise<void>;
9
+ /** Receives the branch the server just created, so it can be shown before any listing includes it. */
10
+ onBranchCreated: (branch: BranchListItem) => void;
6
11
  onBranchSwitch?: (branch: string) => void;
12
+ /** The current user; a create the server never answered adopts only a listed branch they made. */
13
+ userId?: string;
7
14
  }
8
15
  export interface UseBranchActionsReturn {
9
16
  handleBranchChange: (branch: string | null) => Promise<void>;
17
+ /** True when nothing is unsaved or the user accepted the warning. Run before any in-flight UI. */
18
+ confirmCreate: () => Promise<boolean>;
10
19
  handleCreateBranch: (branch: {
11
20
  name: string;
12
21
  title?: string;
13
22
  description?: string;
14
- }) => Promise<void>;
23
+ }) => Promise<boolean>;
24
+ /** True while the unsaved-changes confirm is on screen. */
25
+ confirmOpen: boolean;
15
26
  }
16
27
  /**
17
28
  * Custom hook for branch navigation actions with dirty check support.
@@ -1,13 +1,25 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
2
3
  import { modals } from '@mantine/modals';
3
4
  import { notifications } from '@mantine/notifications';
4
5
  import { Text } from '@mantine/core';
5
6
  import { useApiClient } from '../context/index.js';
7
+ import { requestBranchCreate } from './create-branch-request.js';
8
+ const MAX_LISTED_LABELS = 5;
9
+ /** "Unsaved changes in: A, B." */
10
+ const describeUnsaved = ({ labels }) => {
11
+ if (labels.length === 0)
12
+ return 'You have unsaved changes.';
13
+ const shown = labels.slice(0, MAX_LISTED_LABELS).join(', ');
14
+ const rest = labels.length - MAX_LISTED_LABELS;
15
+ return `Unsaved changes in: ${shown}${rest > 0 ? ` and ${rest} more` : ''}.`;
16
+ };
6
17
  /**
7
18
  * Custom hook for branch navigation actions with dirty check support.
8
19
  */
9
20
  export function useBranchActions(options) {
10
21
  const apiClient = useApiClient();
22
+ const [confirmOpen, setConfirmOpen] = useState(false);
11
23
  const performBranchSwitch = (next) => {
12
24
  options.setBranchName(next);
13
25
  if (typeof window !== 'undefined') {
@@ -17,62 +29,81 @@ export function useBranchActions(options) {
17
29
  }
18
30
  options.onBranchSwitch?.(next);
19
31
  };
20
- const confirmIfDirty = async (message) => {
21
- if (!options.isAnyDirty())
32
+ const confirmIfDirty = async (question) => {
33
+ const unsaved = await options.getUnsaved();
34
+ if (unsaved.count === 0)
22
35
  return true;
23
36
  return new Promise((resolve) => {
37
+ const settle = (value) => {
38
+ // Mantine calls onClose and onCancel from its modals reducer, which runs while
39
+ // ModalsProvider renders, so a setState here must be deferred; resolving needs no deferral.
40
+ queueMicrotask(() => setConfirmOpen(false));
41
+ resolve(value);
42
+ };
43
+ setConfirmOpen(true);
24
44
  modals.openConfirmModal({
25
45
  title: 'Unsaved Changes',
26
- children: _jsx(Text, { size: "sm", children: message }),
46
+ // Drafts are stored per branch, so leaving does not lose them.
47
+ children: (_jsxs(Text, { size: "sm", children: [describeUnsaved(unsaved), " Your drafts stay on \u201C", options.branchName, "\u201D and come back when you return. ", question] })),
27
48
  labels: { confirm: 'Continue Anyway', cancel: 'Cancel' },
28
49
  confirmProps: { color: 'red' },
29
- onCancel: () => resolve(false),
30
- onConfirm: () => resolve(true),
50
+ onCancel: () => settle(false),
51
+ onConfirm: () => settle(true),
52
+ // Escape and overlay dismissals fire only onClose. Mantine also calls it
53
+ // right after onConfirm, which is harmless: a promise settles once.
54
+ onClose: () => settle(false),
31
55
  });
32
56
  });
33
57
  };
34
58
  const handleBranchChange = async (next) => {
35
59
  if (!next || next === options.branchName)
36
60
  return;
37
- const confirmed = await confirmIfDirty('You have unsaved changes. Switch branches anyway?');
61
+ const confirmed = await confirmIfDirty('Switch branches anyway?');
38
62
  if (!confirmed)
39
63
  throw new Error('User cancelled branch switch');
40
64
  performBranchSwitch(next);
41
65
  };
66
+ const confirmCreate = () => confirmIfDirty('Create the new branch anyway?');
67
+ /** Resolves true when the branch was created and switched to, false when it was not. */
42
68
  const handleCreateBranch = async (branch) => {
43
- const confirmed = await confirmIfDirty('Create new branch without saving changes?');
44
- if (!confirmed)
45
- return;
46
69
  try {
47
- const result = await apiClient.branches.create({
70
+ const outcome = await requestBranchCreate(apiClient, {
48
71
  branch: branch.name,
49
72
  title: branch.title,
50
73
  description: branch.description,
51
- });
52
- if (!result.ok) {
53
- throw new Error(result.error || 'Failed to create branch');
74
+ }, options.userId);
75
+ if (outcome.kind === 'failed') {
76
+ throw new Error(outcome.message);
54
77
  }
78
+ const created = outcome.branch;
79
+ if (created)
80
+ options.onBranchCreated(created);
55
81
  // The server sanitizes the branch name (e.g. "feature/x" -> "feature-x")
56
82
  // before persisting it. Adopt the canonical name from the response so
57
83
  // the client doesn't end up stuck on a name the server never saved.
58
- const createdName = result.data?.branch?.name ?? branch.name;
84
+ const createdName = created?.name ?? branch.name;
59
85
  notifications.show({
60
86
  message: createdName === branch.name
61
87
  ? `Branch "${createdName}" created`
62
88
  : `Branch "${createdName}" created (renamed from "${branch.name}")`,
63
89
  color: 'green',
64
90
  });
65
- await options.onReloadBranches();
66
- // Switch to new branch (already confirmed dirty check)
67
91
  performBranchSwitch(createdName);
92
+ // Not awaited: a listing can lag the create, and the switch must not wait
93
+ // on it. loadBranches reports its own failures and never rejects.
94
+ void options.onReloadBranches();
95
+ return true;
68
96
  }
69
97
  catch (err) {
70
98
  const message = err instanceof Error ? err.message : 'Failed to create branch';
71
99
  notifications.show({ message, color: 'red' });
100
+ return false;
72
101
  }
73
102
  };
74
103
  return {
75
104
  handleBranchChange,
105
+ confirmCreate,
76
106
  handleCreateBranch,
107
+ confirmOpen,
77
108
  };
78
109
  }
@@ -2,6 +2,15 @@ import type { ConflictStatus, PullRequestState, SyncStatus } from '../../types.j
2
2
  import type { OperatingMode } from '../../operating-mode/index.js';
3
3
  import type { CommentThread } from '../../comment-store.js';
4
4
  import type { BranchListItem } from '../../api/branch.js';
5
+ /**
6
+ * How long a branch this session created stays overlaid on listings. Each warm
7
+ * server container caches the shared filesystem separately, so for its
8
+ * attribute/dentry cache window (~60s, see docs/concurrency.md window A) a
9
+ * listing can lack the branch even after another container's listing showed
10
+ * it. Twice that absorbs request latency; past it, listings alone decide.
11
+ * @internal Exported only for the test that pins it.
12
+ */
13
+ export declare const CREATED_BRANCH_GRACE_MS = 120000;
5
14
  interface BranchSummary {
6
15
  name: string;
7
16
  status: string;
@@ -58,6 +67,8 @@ export interface UseBranchManagerReturn {
58
67
  branches: BranchListItem[];
59
68
  branchSummaries: BranchSummary[];
60
69
  currentBranch: BranchListItem | undefined;
70
+ /** Shows a branch the server just created before any listing includes it. */
71
+ addCreatedBranch: (branch: BranchListItem) => void;
61
72
  handleSubmit: (branchName: string) => Promise<void>;
62
73
  handleWithdraw: (branchName: string) => Promise<void>;
63
74
  handleRequestChanges: (branchName: string) => Promise<void>;
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
- import { useEffect, useMemo, useState } from 'react';
3
+ import { useCallback, useEffect, useMemo, useState } from 'react';
4
4
  import { useSWRConfig } from 'swr';
5
5
  import { Text } from '@mantine/core';
6
6
  import { modals } from '@mantine/modals';
@@ -92,6 +92,46 @@ const showDeleteConfirmation = (branchName, onConfirm, onDismiss) => {
92
92
  ...confirmModalHandlers(onConfirm, onDismiss),
93
93
  });
94
94
  };
95
+ /**
96
+ * How long a branch this session created stays overlaid on listings. Each warm
97
+ * server container caches the shared filesystem separately, so for its
98
+ * attribute/dentry cache window (~60s, see docs/concurrency.md window A) a
99
+ * listing can lack the branch even after another container's listing showed
100
+ * it. Twice that absorbs request latency; past it, listings alone decide.
101
+ * @internal Exported only for the test that pins it.
102
+ */
103
+ export const CREATED_BRANCH_GRACE_MS = 120_000;
104
+ /** The server listing plus any pending branches it lacks; the server's copy always wins. */
105
+ function mergePendingBranches(listed, pending) {
106
+ const listedNames = new Set(listed.map((b) => b.name));
107
+ const missing = pending.filter((p) => !listedNames.has(p.branch.name));
108
+ return missing.length === 0 ? listed : [...listed, ...missing.map((p) => p.branch)];
109
+ }
110
+ /**
111
+ * Applies one received listing to the pending branches: drops those it arrived
112
+ * past the grace window for, and takes the listing's copy of the rest. Written
113
+ * so a listing with no arrival time (NaN) drops nothing.
114
+ */
115
+ function reconcilePendingBranches(pending, listed, receivedAt) {
116
+ const listedByName = new Map(listed.map((b) => [b.name, b]));
117
+ let changed = false;
118
+ const next = [];
119
+ for (const p of pending) {
120
+ if (receivedAt - p.addedAt > CREATED_BRANCH_GRACE_MS) {
121
+ changed = true;
122
+ continue;
123
+ }
124
+ const listedCopy = listedByName.get(p.branch.name);
125
+ if (listedCopy && listedCopy !== p.branch) {
126
+ changed = true;
127
+ next.push({ branch: listedCopy, addedAt: p.addedAt });
128
+ }
129
+ else {
130
+ next.push(p);
131
+ }
132
+ }
133
+ return changed ? next : pending;
134
+ }
95
135
  /**
96
136
  * Custom hook for managing git branches.
97
137
  */
@@ -100,7 +140,40 @@ export function useBranchManager(options) {
100
140
  const { mutate: globalMutate } = useSWRConfig();
101
141
  const [branchName, setBranchName] = useState(options.initialBranch);
102
142
  const { data: branchesData, error: branchesError, isValidating: branchesIsValidating, } = useBranchesData(apiClient);
103
- const branches = useMemo(() => branchesData?.branches ?? [], [branchesData]);
143
+ const [pendingBranches, setPendingBranches] = useState([]);
144
+ const branches = useMemo(() => mergePendingBranches(branchesData?.branches ?? [], pendingBranches), [branchesData, pendingBranches]);
145
+ const addCreatedBranch = useCallback((branch) => {
146
+ setPendingBranches((prev) => [
147
+ ...prev.filter((p) => p.branch.name !== branch.name),
148
+ { branch, addedAt: Date.now() },
149
+ ]);
150
+ }, []);
151
+ const forgetCreatedBranch = (name) => {
152
+ setPendingBranches((prev) => {
153
+ const kept = prev.filter((p) => p.branch.name !== name);
154
+ return kept.length === prev.length ? prev : kept;
155
+ });
156
+ };
157
+ // After a workflow action, a pending branch's overlaid copy is stale. The
158
+ // server's returned copy replaces it, so a listing that still lacks the
159
+ // branch shows its new status instead of hiding it; without one, forgetting
160
+ // it fails closed rather than showing the pre-action state.
161
+ const updateCreatedBranch = (name, branch) => {
162
+ if (!branch) {
163
+ forgetCreatedBranch(name);
164
+ return;
165
+ }
166
+ setPendingBranches((prev) => prev.some((p) => p.branch.name === name)
167
+ ? prev.map((p) => (p.branch.name === name ? { ...p, branch } : p))
168
+ : prev);
169
+ };
170
+ // Each received listing is a new `branchesData` (see `BranchesData.receivedAt`);
171
+ // the grace window is measured to its arrival.
172
+ useEffect(() => {
173
+ if (!branchesData)
174
+ return;
175
+ setPendingBranches((prev) => reconcilePendingBranches(prev, branchesData.branches, branchesData.receivedAt));
176
+ }, [branchesData]);
104
177
  // Adopt the server's default branch once data arrives, if nothing pinned one.
105
178
  useEffect(() => {
106
179
  if (!branchName && branchesData?.defaultBranch) {
@@ -225,6 +298,7 @@ export function useBranchManager(options) {
225
298
  message: 'Branch submitted for review',
226
299
  color: 'green',
227
300
  });
301
+ updateCreatedBranch(branchNameToSubmit, result.data?.branch);
228
302
  await loadBranches();
229
303
  resolve();
230
304
  }
@@ -257,6 +331,7 @@ export function useBranchManager(options) {
257
331
  throw new Error(result.error || 'Failed to withdraw branch');
258
332
  }
259
333
  notifications.show({ message: 'Branch withdrawn', color: 'blue' });
334
+ updateCreatedBranch(branchNameToWithdraw, result.data?.branch);
260
335
  await loadBranches();
261
336
  resolve();
262
337
  }
@@ -279,6 +354,7 @@ export function useBranchManager(options) {
279
354
  throw new Error(result.error || 'Failed to request changes');
280
355
  }
281
356
  notifications.show({ message: 'Changes requested', color: 'orange' });
357
+ updateCreatedBranch(branchNameForChanges, result.data?.branch);
282
358
  await loadBranches();
283
359
  }
284
360
  catch (err) {
@@ -301,6 +377,7 @@ export function useBranchManager(options) {
301
377
  throw new Error(result.error || 'Failed to delete branch');
302
378
  }
303
379
  notifications.show({ message: 'Branch deleted', color: 'green' });
380
+ forgetCreatedBranch(branchNameToDelete);
304
381
  await loadBranches();
305
382
  }
306
383
  catch (err) {
@@ -338,6 +415,7 @@ export function useBranchManager(options) {
338
415
  branches,
339
416
  branchSummaries,
340
417
  currentBranch,
418
+ addCreatedBranch,
341
419
  handleSubmit,
342
420
  handleWithdraw,
343
421
  handleRequestChanges,
@@ -19,6 +19,12 @@ export declare const BRANCHES_KEY = "canopy:branches";
19
19
  export interface BranchesData {
20
20
  branches: BranchListItem[];
21
21
  defaultBranch?: string;
22
+ /**
23
+ * Client clock when this listing arrived. It makes listings fetched at
24
+ * different moments distinct `data` values even when their branches match,
25
+ * while a failed fetch (SWR keeps the previous `data`) changes nothing.
26
+ */
27
+ receivedAt: number;
22
28
  }
23
29
  /**
24
30
  * Fetch the branches list. Exported for direct testing and for the
@@ -13,6 +13,7 @@
13
13
  * `mutate()` to revalidate.
14
14
  */
15
15
  import useSWR from 'swr';
16
+ import { isNonApiResponse } from '../../api/client.js';
16
17
  /** Cache key for the branches list. */
17
18
  export const BRANCHES_KEY = 'canopy:branches';
18
19
  /**
@@ -21,15 +22,20 @@ export const BRANCHES_KEY = 'canopy:branches';
21
22
  */
22
23
  export async function fetchBranches(apiClient) {
23
24
  const result = await apiClient.branches.list();
24
- if (result.status === 404) {
25
+ if (result.status === 404 && !isNonApiResponse(result)) {
25
26
  // No branch endpoint available; stay branchless rather than erroring --
26
27
  // the branch dropdown stays clickable so the user can retry from there.
27
- return { branches: [] };
28
+ // A proxy's 404 page means the API was not reached, so it is an error.
29
+ return { branches: [], receivedAt: Date.now() };
28
30
  }
29
31
  if (!result.ok) {
30
32
  throw new Error(result.error ?? `Failed to load branches: ${result.status}`);
31
33
  }
32
- return { branches: result.data?.branches ?? [], defaultBranch: result.data?.defaultBranch };
34
+ return {
35
+ branches: result.data?.branches ?? [],
36
+ defaultBranch: result.data?.defaultBranch,
37
+ receivedAt: Date.now(),
38
+ };
33
39
  }
34
40
  /**
35
41
  * How often to re-poll while at least one branch is mid-flight, in ms.
@@ -4,6 +4,7 @@ import { notifications } from '@mantine/notifications';
4
4
  import { normalizeCanopyPath } from '../canopy-path.js';
5
5
  import { useApiClient } from '../context/index.js';
6
6
  import { resolveMessageOrigin } from '../preview-bridge.js';
7
+ import { isSamePreviewPath } from '../preview-path.js';
7
8
  import { commentsKey, fetchComments, useCommentsData } from './useCommentsData.js';
8
9
  /**
9
10
  * Custom hook for managing the comment system.
@@ -112,6 +113,16 @@ export function useCommentSystem(options) {
112
113
  }, [activeCommentContext, options.selectedPath, options.branchName]);
113
114
  // Listen for field focus messages from preview frame
114
115
  useEffect(() => {
116
+ // Delayed undo steps (highlight restore, focus clear). Cleanup runs them early rather than
117
+ // dropping them, so an entry switch never leaves a field highlighted or focused.
118
+ const pending = new Map();
119
+ const later = (undo, ms) => {
120
+ const id = window.setTimeout(() => {
121
+ pending.delete(id);
122
+ undo();
123
+ }, ms);
124
+ pending.set(id, undo);
125
+ };
115
126
  const handleFocus = (event) => {
116
127
  // Only accept messages from the preview's origin (same-origin when previewSrc is
117
128
  // relative). Origin-only by design: this hook has no handle on the preview iframe
@@ -121,8 +132,11 @@ export function useCommentSystem(options) {
121
132
  const msg = event.data;
122
133
  if (msg?.type !== 'canopycms:preview:focus')
123
134
  return;
135
+ const currentPath = options.currentEntry?.previewSrc ?? options.currentEntry?.path;
124
136
  if (msg.entryPath &&
125
- msg.entryPath !== (options.currentEntry?.previewSrc ?? options.currentEntry?.path))
137
+ (typeof msg.entryPath !== 'string' ||
138
+ currentPath === undefined ||
139
+ !isSamePreviewPath(msg.entryPath, currentPath)))
126
140
  return;
127
141
  const normalizedPath = msg.fieldPath ? normalizeCanopyPath(msg.fieldPath) : undefined;
128
142
  const target = normalizedPath
@@ -132,21 +146,26 @@ export function useCommentSystem(options) {
132
146
  target.scrollIntoView({ behavior: 'smooth', block: 'center' });
133
147
  const previous = target.style.boxShadow;
134
148
  target.style.boxShadow = '0 0 0 3px rgba(79, 70, 229, 0.35)';
135
- window.setTimeout(() => {
149
+ later(() => {
136
150
  target.style.boxShadow = previous;
137
151
  }, 1200);
138
152
  // Set focused field path to trigger FieldWrapper auto-focus
139
153
  if (normalizedPath) {
140
154
  setFocusedFieldPath(normalizedPath);
141
155
  // Clear after brief delay to allow FieldWrapper to detect the change
142
- window.setTimeout(() => {
143
- setFocusedFieldPath(undefined);
144
- }, 100);
156
+ later(() => setFocusedFieldPath(undefined), 100);
145
157
  }
146
158
  }
147
159
  };
148
160
  window.addEventListener('message', handleFocus);
149
- return () => window.removeEventListener('message', handleFocus);
161
+ return () => {
162
+ window.removeEventListener('message', handleFocus);
163
+ for (const [id, undo] of pending) {
164
+ window.clearTimeout(id);
165
+ undo();
166
+ }
167
+ pending.clear();
168
+ };
150
169
  }, [options.currentEntry]);
151
170
  const handleJumpToField = (entryPath, canopyPath, threadId) => {
152
171
  if (entryPath !== options.selectedPath) {
@@ -8,13 +8,19 @@ export interface UseDraftManagerOptions {
8
8
  entries: EditorEntry[];
9
9
  initialValues?: Record<string, FormValue>;
10
10
  loadEntry: (entry: EditorEntry) => Promise<FormValue>;
11
+ /**
12
+ * Server read that records no OCC token (useEntryManager's `readEntryValue`),
13
+ * for checking restored drafts of unopened entries. Without it they stay
14
+ * unverified and count as dirty.
15
+ */
16
+ readEntryValue?: (entry: EditorEntry) => Promise<FormValue>;
11
17
  saveEntry: (entry: EditorEntry, value: FormValue) => Promise<FormValue>;
12
18
  /**
13
19
  * The OCC version token currently held for an entry on the branch being
14
20
  * shown (useEntryManager's `getEntryVersion`). Stamps each draft with the
15
21
  * version it was based on, and detects at save time that the token has
16
22
  * since moved on. Optional: without it no base versions are recorded and
17
- * conflict detection falls back entirely to the server's 409.
23
+ * conflict detection falls back entirely to saveEntry's token check.
18
24
  */
19
25
  getEntryVersion?: (contentId: string) => number | undefined;
20
26
  setBusy: (busy: boolean) => void;
@@ -26,6 +32,11 @@ export interface UseDraftManagerOptions {
26
32
  */
27
33
  onSaved?: () => void;
28
34
  }
35
+ /** The unsaved work a branch-level action would leave behind. `labels` names the entries (it may be shorter than `count`). */
36
+ export interface UnsavedSummary {
37
+ count: number;
38
+ labels: string[];
39
+ }
29
40
  export interface UseDraftManagerReturn {
30
41
  drafts: Record<string, FormValue>;
31
42
  setDrafts: React.Dispatch<React.SetStateAction<Record<string, FormValue>>>;
@@ -46,6 +57,10 @@ export interface UseDraftManagerReturn {
46
57
  isDirtyForEntry: (entryPath: string) => boolean;
47
58
  isSelectedDirty: () => boolean;
48
59
  isAnyDirty: () => boolean;
60
+ /** Resolves once restored drafts have been checked against the server (or a read failed), capped so a hung read cannot block. */
61
+ whenDraftsVerified: () => Promise<void>;
62
+ /** `whenDraftsVerified`, then the entries still counted dirty. */
63
+ resolveUnsaved: () => Promise<UnsavedSummary>;
49
64
  /**
50
65
  * Per-field validation errors for the selected entry, keyed by canonical
51
66
  * canopy path (e.g. `blocks[0].title`). Populated when a save is blocked by