canopycms 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/CHANGELOG.md +201 -47
  2. package/dist/api/admin-branch-health.js +47 -20
  3. package/dist/api/admin.d.ts +13 -1
  4. package/dist/api/admin.js +14 -24
  5. package/dist/api/branch-changes.d.ts +87 -0
  6. package/dist/api/branch-changes.js +269 -0
  7. package/dist/api/branch-entry-changes.d.ts +83 -0
  8. package/dist/api/branch-entry-changes.js +262 -0
  9. package/dist/api/branch-merge.js +2 -2
  10. package/dist/api/branch-review.js +35 -4
  11. package/dist/api/branch-status.js +14 -5
  12. package/dist/api/branch-withdraw.js +7 -0
  13. package/dist/api/branch.d.ts +13 -1
  14. package/dist/api/branch.js +171 -97
  15. package/dist/api/client.d.ts +15 -0
  16. package/dist/api/client.js +13 -1
  17. package/dist/api/content.d.ts +18 -18
  18. package/dist/api/content.js +1 -1
  19. package/dist/api/github-sync.js +7 -3
  20. package/dist/api/groups.d.ts +4 -4
  21. package/dist/api/pr-comment-sync.d.ts +9 -8
  22. package/dist/api/pr-comment-sync.js +23 -13
  23. package/dist/api/routes.js +4 -0
  24. package/dist/api/types.d.ts +3 -1
  25. package/dist/authorization/groups/schema.d.ts +4 -4
  26. package/dist/authorization/protected-branch.d.ts +3 -3
  27. package/dist/authorization/protected-branch.js +3 -3
  28. package/dist/branch-metadata-file.d.ts +84 -0
  29. package/dist/branch-metadata-file.js +9 -0
  30. package/dist/branch-metadata.d.ts +4 -4
  31. package/dist/branch-metadata.js +4 -5
  32. package/dist/branch-param.d.ts +24 -0
  33. package/dist/branch-param.js +87 -0
  34. package/dist/branch-provisioning.d.ts +6 -3
  35. package/dist/branch-provisioning.js +12 -10
  36. package/dist/branch-registry.d.ts +6 -0
  37. package/dist/branch-registry.js +12 -3
  38. package/dist/branch-workspace.js +22 -15
  39. package/dist/cli/cli.js +1060 -172
  40. package/dist/cli/generate-ai-content.js +732 -101
  41. package/dist/cli/init.js +126 -0
  42. package/dist/comment-store.d.ts +3 -0
  43. package/dist/comment-store.js +4 -1
  44. package/dist/config/schemas/config.d.ts +3 -0
  45. package/dist/config/schemas/config.js +2 -0
  46. package/dist/config/types.d.ts +11 -0
  47. package/dist/content-id-index.d.ts +5 -0
  48. package/dist/content-id-index.js +1 -1
  49. package/dist/content-listing.d.ts +53 -0
  50. package/dist/content-listing.js +123 -27
  51. package/dist/content-reader.js +23 -16
  52. package/dist/context.js +8 -7
  53. package/dist/editor/BranchManager.d.ts +7 -3
  54. package/dist/editor/BranchManager.js +51 -6
  55. package/dist/editor/Editor.js +166 -34
  56. package/dist/editor/EntryNavigator.d.ts +34 -10
  57. package/dist/editor/EntryNavigator.js +185 -29
  58. package/dist/editor/admin/SystemHealthPanel.js +36 -1
  59. package/dist/editor/admin/useSystemHealth.js +1 -0
  60. package/dist/editor/branch-names.d.ts +43 -0
  61. package/dist/editor/branch-names.js +75 -0
  62. package/dist/editor/components/BranchPill.d.ts +2 -2
  63. package/dist/editor/components/BranchPill.js +2 -2
  64. package/dist/editor/components/EditorHeader.d.ts +3 -1
  65. package/dist/editor/components/EditorHeader.js +2 -2
  66. package/dist/editor/components/EntryChangesDrawer.d.ts +51 -0
  67. package/dist/editor/components/EntryChangesDrawer.js +363 -0
  68. package/dist/editor/components/EntryChangesSkeleton.d.ts +9 -0
  69. package/dist/editor/components/EntryChangesSkeleton.js +7 -0
  70. package/dist/editor/components/EntryHeader.d.ts +9 -1
  71. package/dist/editor/components/EntryHeader.js +25 -3
  72. package/dist/editor/components/LazyEntryChangesDrawer.d.ts +16 -0
  73. package/dist/editor/components/LazyEntryChangesDrawer.js +45 -0
  74. package/dist/editor/components/ReviewDialogs.js +3 -3
  75. package/dist/editor/context/ApiClientContext.d.ts +9 -0
  76. package/dist/editor/context/ApiClientContext.js +26 -2
  77. package/dist/editor/context/index.d.ts +1 -1
  78. package/dist/editor/context/index.js +1 -1
  79. package/dist/editor/copy.d.ts +1 -0
  80. package/dist/editor/copy.js +1 -0
  81. package/dist/editor/editor-utils.d.ts +2 -0
  82. package/dist/editor/editor-utils.js +15 -0
  83. package/dist/editor/entry-diff.d.ts +143 -0
  84. package/dist/editor/entry-diff.js +593 -0
  85. package/dist/editor/hooks/create-branch-request.d.ts +11 -1
  86. package/dist/editor/hooks/create-branch-request.js +16 -6
  87. package/dist/editor/hooks/useBranchActions.d.ts +19 -2
  88. package/dist/editor/hooks/useBranchActions.js +29 -20
  89. package/dist/editor/hooks/useBranchChangesData.d.ts +57 -0
  90. package/dist/editor/hooks/useBranchChangesData.js +134 -0
  91. package/dist/editor/hooks/useBranchManager.d.ts +16 -0
  92. package/dist/editor/hooks/useBranchManager.js +142 -54
  93. package/dist/editor/hooks/useDraftManager.js +6 -2
  94. package/dist/editor/hooks/useEntryManager.d.ts +18 -2
  95. package/dist/editor/hooks/useEntryManager.js +36 -10
  96. package/dist/editor/utils/editor-errors.d.ts +5 -2
  97. package/dist/git-manager.d.ts +71 -0
  98. package/dist/git-manager.js +270 -1
  99. package/dist/http/handler.js +11 -9
  100. package/dist/operating-mode/branch-namespace-fixtures.d.ts +17 -0
  101. package/dist/operating-mode/branch-namespace-fixtures.js +65 -0
  102. package/dist/operating-mode/branch-namespace.d.ts +34 -0
  103. package/dist/operating-mode/branch-namespace.js +121 -0
  104. package/dist/operating-mode/client-unsafe-strategy.js +7 -0
  105. package/dist/operating-mode/types.d.ts +8 -0
  106. package/dist/paths/branch-name.d.ts +11 -19
  107. package/dist/paths/branch-name.js +17 -27
  108. package/dist/paths/index.d.ts +2 -2
  109. package/dist/paths/index.js +2 -2
  110. package/dist/paths/resolve.d.ts +0 -1
  111. package/dist/paths/resolve.js +0 -1
  112. package/dist/paths/validation.d.ts +40 -1
  113. package/dist/paths/validation.js +83 -4
  114. package/dist/pr-comments.d.ts +17 -9
  115. package/dist/pr-comments.js +53 -15
  116. package/dist/services.js +2 -1
  117. package/dist/submission-attribution.d.ts +22 -0
  118. package/dist/submission-attribution.js +62 -6
  119. package/dist/task-queue/worker-status.d.ts +6 -1
  120. package/dist/task-queue/worker-status.js +23 -2
  121. package/dist/types.d.ts +59 -0
  122. package/dist/version.d.ts +1 -1
  123. package/dist/version.js +1 -1
  124. package/dist/worker/branch-policy.d.ts +111 -16
  125. package/dist/worker/branch-policy.js +222 -17
  126. package/dist/worker/cms-worker.d.ts +24 -2
  127. package/dist/worker/cms-worker.js +35 -24
  128. package/dist/worker/git-sync.d.ts +3 -3
  129. package/dist/worker/git-sync.js +47 -1
  130. package/dist/worker/github-gateway-http.js +2 -2
  131. package/dist/worker/github-gateway-wire.d.ts +40 -25
  132. package/dist/worker/github-gateway-wire.js +14 -3
  133. package/dist/worker/github-gateway.d.ts +17 -6
  134. package/dist/worker/github-gateway.js +62 -6
  135. package/dist/worker/github-mirror.d.ts +7 -4
  136. package/dist/worker/github-mirror.js +27 -16
  137. package/dist/worker/history-rewrite.d.ts +23 -10
  138. package/dist/worker/history-rewrite.js +43 -20
  139. package/dist/worker/legacy-branch-fixtures.d.ts +21 -0
  140. package/dist/worker/legacy-branch-fixtures.js +71 -0
  141. package/dist/worker/rebase.d.ts +2 -1
  142. package/dist/worker/rebase.js +93 -36
  143. package/dist/worker/task-runner.d.ts +4 -1
  144. package/dist/worker/task-runner.js +77 -48
  145. package/dist/worker/unpublishable-branches.d.ts +34 -0
  146. package/dist/worker/unpublishable-branches.js +182 -0
  147. package/package.json +2 -1
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { useRef, useState } from 'react';
3
+ import { useEffect, useRef, useState } from 'react';
4
4
  import { ActionIcon, Badge, Button, Group, Menu, Paper, ScrollArea, Stack, Text, TextInput, Textarea, Collapse, Tooltip, } from '@mantine/core';
5
5
  import { IconDots, IconExternalLink } from '@tabler/icons-react';
6
6
  import { BranchComments } from './comments/BranchComments.js';
@@ -9,10 +9,12 @@ import { BranchAccessList } from './components/BranchAccessList.js';
9
9
  // Import directly from helpers to avoid server-only code in authorization barrel
10
10
  import { isAdmin, isLockedOutOfBranch, isReviewer } from '../authorization/helpers.js';
11
11
  import { clientOperatingStrategy } from '../operating-mode/client.js';
12
+ import { parseCreatedBranchName } from '../paths/validation.js';
12
13
  import { DestructiveMenuItem } from './components/DestructiveMenuItem.js';
13
14
  import { ReasonedMenuItem } from './components/ReasonedMenuItem.js';
14
15
  import { RelativeTime } from './components/RelativeTime.js';
15
16
  import { branchStatusPresentation } from './branch-status.js';
17
+ import { branchAddress, displayBranchName } from './branch-names.js';
16
18
  import { CLOSED_PULL_REQUEST_REASON, EDITOR_ACTIONS } from './copy.js';
17
19
  import { isReviewerOfOthersReview } from '../authorization/review-role.js';
18
20
  import { SYSTEM_USER_ID } from '../system-users.js';
@@ -183,9 +185,9 @@ const BranchCard = ({ branch: b, isCurrent, workflowBlockedReason, user, onSelec
183
185
  const showViewPullRequest = !!b.pullRequestUrl;
184
186
  const showDelete = !b.isProtected && (userIsAdmin || (!!user && b.createdBy === user.userId));
185
187
  const hasMenu = menuActions.length > 0 || showViewPullRequest || showDelete;
186
- const menuLabel = `More actions for ${b.name}`;
188
+ const menuLabel = `More actions for ${displayBranchName(b)}`;
187
189
  const [menuOpen, setMenuOpen] = useState(false);
188
- return (_jsxs(Paper, { withBorder: true, p: "md", shadow: "xs", "aria-current": isCurrent ? 'true' : undefined, "data-current": isCurrent || undefined, "data-testid": `branch-list-item-${b.name}`, children: [_jsxs(Group, { justify: "space-between", align: "flex-start", wrap: "nowrap", children: [_jsxs(Stack, { gap: 4, children: [_jsxs(Group, { gap: "xs", children: [_jsx(Text, { fw: 600, children: b.name }), isCurrent && (_jsx(Badge, { color: "brand", variant: "filled", "data-testid": `branch-current-badge-${b.name}`, children: "Current" })), statusBadge && (_jsx(Badge, { color: statusBadge.color, variant: statusBadge.variant, "data-testid": `branch-status-badge-${b.name}`, "data-status": b.status, "data-protected": b.isProtected || undefined, children: statusBadge.label })), b.status === 'archived' && b.mergedAt && (_jsx(Badge, { color: "teal", variant: "light", "data-testid": `branch-merged-badge-${b.name}`, children: "Merged" })), b.pullRequestNumber && (_jsxs(Badge, { color: b.pullRequestState === 'closed'
190
+ return (_jsxs(Paper, { withBorder: true, p: "md", shadow: "xs", "aria-current": isCurrent ? 'true' : undefined, "data-current": isCurrent || undefined, "data-testid": `branch-list-item-${b.name}`, children: [_jsxs(Group, { justify: "space-between", align: "flex-start", wrap: "nowrap", children: [_jsxs(Stack, { gap: 4, children: [_jsxs(Group, { gap: "xs", children: [_jsx(Text, { fw: 600, children: displayBranchName(b) }), isCurrent && (_jsx(Badge, { color: "brand", variant: "filled", "data-testid": `branch-current-badge-${b.name}`, children: "Current" })), statusBadge && (_jsx(Badge, { color: statusBadge.color, variant: statusBadge.variant, "data-testid": `branch-status-badge-${b.name}`, "data-status": b.status, "data-protected": b.isProtected || undefined, children: statusBadge.label })), b.status === 'archived' && b.mergedAt && (_jsx(Badge, { color: "teal", variant: "light", "data-testid": `branch-merged-badge-${b.name}`, children: "Merged" })), b.pullRequestNumber && (_jsxs(Badge, { color: b.pullRequestState === 'closed'
189
191
  ? 'red'
190
192
  : b.pullRequestState === 'merged'
191
193
  ? 'violet'
@@ -206,10 +208,29 @@ export const BranchManager = ({ branches, mode, user, currentBranchName, current
206
208
  const [newBranchTitle, setNewBranchTitle] = useState('');
207
209
  const [newBranchDescription, setNewBranchDescription] = useState('');
208
210
  const [creating, setCreating] = useState(false);
211
+ // Why a create refused the name, shown while that name is still what is typed.
212
+ const [refused, setRefused] = useState(null);
213
+ const nameInputRef = useRef(null);
214
+ // Set by a refusal: the name field takes focus once it is enabled again, so its error is read.
215
+ const focusNameRef = useRef(false);
216
+ useEffect(() => {
217
+ if (creating || !focusNameRef.current)
218
+ return;
219
+ focusNameRef.current = false;
220
+ nameInputRef.current?.focus();
221
+ }, [creating, refused]);
209
222
  // Read synchronously: state would not yet show a first click to a second one in the same tick.
210
223
  const creatingRef = useRef(false);
224
+ // The server's own rules, so the field says what will be created, or why nothing can be.
225
+ const typedBranchName = newBranchName.trim();
226
+ const createdBranchName = typedBranchName ? parseCreatedBranchName(typedBranchName) : null;
227
+ const nameError = createdBranchName?.ok === false
228
+ ? createdBranchName.error
229
+ : refused?.typed === typedBranchName
230
+ ? refused.reason
231
+ : undefined;
211
232
  const handleCreate = async () => {
212
- if (creatingRef.current || !newBranchName.trim())
233
+ if (creatingRef.current || !createdBranchName?.ok)
213
234
  return;
214
235
  creatingRef.current = true;
215
236
  try {
@@ -217,12 +238,18 @@ export const BranchManager = ({ branches, mode, user, currentBranchName, current
217
238
  return;
218
239
  setCreating(true);
219
240
  const created = await onCreate?.({
220
- name: newBranchName.trim(),
241
+ name: typedBranchName,
221
242
  title: newBranchTitle.trim() || undefined,
222
243
  description: newBranchDescription.trim() || undefined,
223
244
  });
224
245
  if (created === false)
225
246
  return;
247
+ if (typeof created === 'object') {
248
+ focusNameRef.current = true;
249
+ setRefused({ typed: typedBranchName, reason: created.refusal });
250
+ return;
251
+ }
252
+ setRefused(null);
226
253
  setNewBranchName('');
227
254
  setNewBranchTitle('');
228
255
  setNewBranchDescription('');
@@ -240,5 +267,23 @@ export const BranchManager = ({ branches, mode, user, currentBranchName, current
240
267
  ]
241
268
  : branches;
242
269
  const hasBranchThreads = comments.some((t) => t.type === 'branch');
243
- return (_jsxs(Stack, { h: "100%", style: { display: 'flex', flexDirection: 'column' }, gap: 0, "data-testid": "branch-manager", children: [currentUserId && onAddComment && onResolveThread && hasBranchThreads && (_jsx(Stack, { pt: "sm", children: _jsx(BranchComments, { comments: comments, currentUserId: currentUserId, canResolve: canResolve, onAddComment: onAddComment, onResolveThread: onResolveThread, highlightThreadId: highlightThreadId, onGetUserMetadata: onGetCommentUserMetadata }) })), !!supportsBranching && (_jsxs(Stack, { gap: "sm", pt: "sm", children: [_jsx(Button, { variant: "light", size: "sm", fullWidth: true, onClick: () => setShowCreateForm(!showCreateForm), disabled: creating, "data-testid": "create-branch-button", children: showCreateForm ? 'Cancel' : `${EDITOR_ACTIONS.newBranch}…` }), _jsx(Collapse, { in: showCreateForm, children: _jsx(Paper, { withBorder: true, p: "md", children: _jsxs(Stack, { gap: "sm", children: [_jsx(TextInput, { label: "Branch name", placeholder: "feature/my-branch", value: newBranchName, onChange: (e) => setNewBranchName(e.target.value), required: true, disabled: creating, "data-autofocus": startCreating || undefined, "data-testid": "branch-name-input" }), _jsx(TextInput, { label: "Title", placeholder: "Brief description", value: newBranchTitle, onChange: (e) => setNewBranchTitle(e.target.value), disabled: creating, "data-testid": "branch-title-input" }), _jsx(Textarea, { label: "Description", placeholder: "Detailed description of the changes", value: newBranchDescription, onChange: (e) => setNewBranchDescription(e.target.value), minRows: 2, disabled: creating, "data-testid": "branch-description-textarea" }), _jsx(Button, { size: "sm", onClick: handleCreate, disabled: !newBranchName.trim(), loading: creating, fullWidth: true, "data-testid": "create-branch-submit", children: "Create branch" })] }) }) })] })), _jsx(ScrollArea, { style: { flex: 1 }, pb: "md", children: branches.length === 0 ? (_jsx(Text, { size: "sm", c: "dimmed", py: "md", children: !supportsBranching ? 'Branch management is not available.' : 'No branches available.' })) : (_jsx(Stack, { gap: "sm", children: orderedBranches.map((b) => (_jsx(BranchCard, { branch: b, isCurrent: b.name === currentBranchName, workflowBlockedReason: b.name === currentBranchName ? currentWorkflowBlockedReason : undefined, user: user, onSelect: onSelect, onSubmit: onSubmit, onWithdraw: onWithdraw, onApprove: onApprove, onRequestChanges: onRequestChanges, onDelete: onDelete, onGetUserMetadata: onGetUserMetadata, onGetOwnerMetadata: onGetOwnerMetadata }, b.name))) })) })] }));
270
+ return (_jsxs(Stack, { h: "100%", style: { display: 'flex', flexDirection: 'column' }, gap: 0, "data-testid": "branch-manager", children: [currentUserId && onAddComment && onResolveThread && hasBranchThreads && (_jsx(Stack, { pt: "sm", children: _jsx(BranchComments, { comments: comments, currentUserId: currentUserId, canResolve: canResolve, onAddComment: onAddComment, onResolveThread: onResolveThread, highlightThreadId: highlightThreadId, onGetUserMetadata: onGetCommentUserMetadata }) })), !!supportsBranching && (_jsxs(Stack, { gap: "sm", pt: "sm", children: [_jsx(Button, { variant: "light", size: "sm", fullWidth: true, onClick: () => {
271
+ if (showCreateForm)
272
+ setRefused(null);
273
+ setShowCreateForm(!showCreateForm);
274
+ }, disabled: creating, "data-testid": "create-branch-button", children: showCreateForm ? 'Cancel' : `${EDITOR_ACTIONS.newBranch}…` }), _jsx(Collapse, { in: showCreateForm, children: _jsx(Paper, { withBorder: true, p: "md", children: _jsxs(Stack, { gap: "sm", children: [_jsx(TextInput, { label: "Branch name", placeholder: "spring-update",
275
+ // Below the input, so a preview that wraps never moves the field being typed in.
276
+ inputWrapperOrder: ['label', 'input', 'description', 'error'],
277
+ // Only when sanitizing changed what was typed, and never beside an error.
278
+ description: !nameError &&
279
+ createdBranchName?.ok &&
280
+ createdBranchName.shortName !== typedBranchName
281
+ ? `Will be named ${createdBranchName.shortName}`
282
+ : undefined, error: nameError, value: newBranchName, onChange: (e) => setNewBranchName(e.target.value), required: true, disabled: creating, ref: nameInputRef, "data-autofocus": startCreating || undefined, "data-testid": "branch-name-input" }), _jsx(TextInput, { label: "Title", placeholder: "Brief description", value: newBranchTitle, onChange: (e) => setNewBranchTitle(e.target.value), disabled: creating, "data-testid": "branch-title-input" }), _jsx(Textarea, { label: "Description", placeholder: "Detailed description of the changes", value: newBranchDescription, onChange: (e) => setNewBranchDescription(e.target.value), minRows: 2, disabled: creating, "data-testid": "branch-description-textarea" }), _jsx(Button, { size: "sm", onClick: handleCreate, disabled: !createdBranchName?.ok, loading: creating, fullWidth: true, "data-testid": "create-branch-submit", children: "Create branch" })] }) }) })] })), _jsx(ScrollArea, { style: { flex: 1 }, pb: "md", children: branches.length === 0 ? (_jsx(Text, { size: "sm", c: "dimmed", py: "md", children: !supportsBranching ? 'Branch management is not available.' : 'No branches available.' })) : (_jsx(Stack, { gap: "sm", children: orderedBranches.map((b) => {
283
+ // The editor resolves a shadowed outside branch's full name to the namespace branch
284
+ // whose short name it is, so a card's actions name its branch by `branchAddress`.
285
+ const address = branchAddress(branches, b);
286
+ const act = (run) => run && (() => run(address));
287
+ return (_jsx(BranchCard, { branch: b, isCurrent: b.name === currentBranchName, workflowBlockedReason: b.name === currentBranchName ? currentWorkflowBlockedReason : undefined, user: user, onSelect: act(onSelect), onSubmit: act(onSubmit), onWithdraw: act(onWithdraw), onApprove: act(onApprove), onRequestChanges: act(onRequestChanges), onDelete: act(onDelete), onGetUserMetadata: onGetUserMetadata, onGetOwnerMetadata: onGetOwnerMetadata }, b.name));
288
+ }) })) })] }));
244
289
  };
@@ -6,7 +6,7 @@ import { IconChevronDown, IconChevronUp, IconDots, IconFolderPlus, IconPlus, } f
6
6
  import { useHotkeys, useMediaQuery } from '@mantine/hooks';
7
7
  import { notifications } from '@mantine/notifications';
8
8
  const noop = () => { };
9
- import { EntryNavigator } from './EntryNavigator.js';
9
+ import { EntryNavigator, } from './EntryNavigator.js';
10
10
  import { FormRenderer } from './FormRenderer.js';
11
11
  import { PreviewPane } from './PreviewPane.js';
12
12
  import { displayPreviewPath, previewPageKey } from './preview-path.js';
@@ -29,6 +29,9 @@ import { fieldBreadcrumb } from './field-breadcrumb.js';
29
29
  import { buildPreviewRoute, buildPreviewSrc, buildCollectionLabels, entryStatusLabel, entryTypeLabel, buildBreadcrumbSegments, entryNameSource, } from './editor-utils.js';
30
30
  import { useEditorLayout, useDraftManager, useEntryManager, useGroupManager, usePermissionManager, useCommentSystem, useBranchManager, useUserContext, useSchemaManager, } from './hooks/index.js';
31
31
  import { useBranchActions } from './hooks/useBranchActions.js';
32
+ import { branchAddress, displayBranchName } from './branch-names.js';
33
+ import { useBranchChangesData, useEntryChangeData } from './hooks/useBranchChangesData.js';
34
+ import { LazyEntryChangesDrawer } from './components/LazyEntryChangesDrawer.js';
32
35
  import { CONTENT_PANEL_WIDTH_MAX, CONTENT_PANEL_WIDTH_MIN, editorLayoutStorageKey, } from './hooks/useEditorLayout.js';
33
36
  import { EntrySchemaUnavailableError } from './hooks/useEntryManager.js';
34
37
  import { useEntryLinkResolution } from './hooks/useEntryLinkResolution.js';
@@ -64,7 +67,6 @@ import { findFieldTarget, focusFieldTarget } from './field-target.js';
64
67
  import { UndoToastMessage } from './components/UndoToastMessage.js';
65
68
  import { getNotificationDuration } from './utils/env.js';
66
69
  import { MayHaveLandedError } from './unanswered.js';
67
- import { sanitizeBranchName } from '../paths/branch-name.js';
68
70
  // Stable fallbacks, so reference resolution's memo and effect don't re-run on every render
69
71
  // while no entry is open.
70
72
  const EMPTY_SCHEMA = [];
@@ -165,7 +167,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
165
167
  // Reloads the open branch's comments after a request for changes, whose note is a comment; the
166
168
  // comment system is built further down.
167
169
  const reloadCommentsRef = useRef(() => { });
168
- const { branchName: branchNameState, setBranchName, branchSummaries, currentBranch, branchesLoadFailed, branchesLoaded, addCreatedBranch, handleSubmit, handleWithdraw, handleApprove, handleRequestChanges, handleDelete, handleReloadBranchData, loadBranches, } = useBranchManager({
170
+ const { branchName: branchNameState, setBranchName, branches, branchSummaries, currentBranch, currentAddress, listedBranch, namesBranch, urlBranchName, branchesLoadFailed, branchesLoaded, addCreatedBranch, handleSubmit, handleWithdraw, handleApprove, handleRequestChanges, handleDelete, handleReloadBranchData, loadBranches, } = useBranchManager({
169
171
  initialBranch: branchName,
170
172
  operatingMode,
171
173
  setBusy: setBranchesLoading,
@@ -187,6 +189,8 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
187
189
  // of unlocked that invites a click the server rejects -- and the pane shows a
188
190
  // loading state during that window anyway.
189
191
  const branchContentLocked = currentBranch?.writeBlocked ?? true;
192
+ // What the page calls the open branch; everything keyed or sent per branch uses branchNameState.
193
+ const branchLabel = currentBranch ? displayBranchName(currentBranch) : branchNameState;
190
194
  const branchLoading = !currentBranch && (branchesLoading || branchSummaries.length === 0);
191
195
  // No lock reason is true yet while the list is unanswered; once it has failed, or answered
192
196
  // without this branch, the lock says which.
@@ -216,6 +220,8 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
216
220
  const branchNotAvailable = !!branchNameState && branchBarState === 'not-available';
217
221
  // Set once the branch actions below exist; they depend on the drafts, which depend on the entries.
218
222
  const switchBranchRef = useRef(async () => { });
223
+ // Set once the changes read exists; it waits on the panel state, declared after the entries.
224
+ const reloadChangesRef = useRef(() => { });
219
225
  // 2. Entry manager (depends on branchNameState, owns selectedPath)
220
226
  const { selectedPath, setSelectedPath, closeEntry, entries: entriesState, collections: collectionsFromApi, currentEntry, availableSchemas, entriesInitializing, entriesLoadFailed, navigatorOpen, setNavigatorOpen, refreshEntries, refreshContent, handleCreateEntry, renameEntry, loadEntry, readEntryValue, saveEntry, getEntryVersion, adoptEntryVersion, followMovedEntry, recreateEntry, createModalOpen, createModalCollection, createModalError, createModalCreating, createModalExistingSlugs, handleCreateModalSubmit, closeCreateModal, } = useEntryManager({
221
227
  initialEntries: entries,
@@ -223,7 +229,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
223
229
  branchName: branchNameState,
224
230
  collections,
225
231
  resolvePreviewSrc: (entry) => buildPreviewSrc(entry, {
226
- branchName: branchNameState,
232
+ branchName: urlBranchName(branchNameState),
227
233
  previewBaseByCollection,
228
234
  previewPrefix,
229
235
  contentRoot,
@@ -232,7 +238,11 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
232
238
  resolvePreviewRoute: (entry) => buildPreviewRoute(entry, { previewBaseByCollection, contentRoot }),
233
239
  setBusy: setEntriesLoading,
234
240
  branchBarState,
235
- switchBranch: (branch) => switchBranchRef.current(branch),
241
+ switchBranch: (branch, superseded) => switchBranchRef.current(branch, superseded),
242
+ // A short name in the URL is no switch to another branch.
243
+ sameBranch: namesBranch,
244
+ urlBranchName,
245
+ onContentRefresh: (branch) => reloadChangesRef.current(branch),
236
246
  // To the entry menu, which names the entry opened; a dialog keeps focus.
237
247
  onHistoryRestore: () => requestAnimationFrame(() => {
238
248
  if (!document.activeElement?.closest('[role="dialog"]')) {
@@ -337,22 +347,30 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
337
347
  // 4. Branch actions (depends on resolveUnsaved, setBranchName)
338
348
  const { handleBranchChange, confirmCreate: confirmCreateBranch, handleCreateBranch, confirmOpen: branchConfirmOpen, } = useBranchActions({
339
349
  branchName: branchNameState,
350
+ branchLabel,
340
351
  setBranchName,
341
352
  getUnsaved: resolveUnsaved,
342
353
  onReloadBranches: () => loadBranches(),
343
354
  onBranchCreated: addCreatedBranch,
355
+ urlBranchName,
356
+ sameBranch: namesBranch,
344
357
  userId: userContext?.userId,
345
358
  });
346
- switchBranchRef.current = async (branch) => {
347
- const listed = (name) => name === branch || name === sanitizeBranchName(branch);
348
- if (branchSummaries.length > 0 && !branchSummaries.some((b) => listed(b.name))) {
349
- notifications.show({
350
- message: `Branch “${branch}” no longer exists or you can no longer open it, so you're still on “${branchNameState}”.`,
351
- color: 'yellow',
352
- });
359
+ // Only a Back or Forward calls this: it switches to the listed branch, by an address that opens
360
+ // it alone, replacing the entry it arrived at. With no listing, the name is unchecked and could
361
+ // be either form, so the move is refused.
362
+ switchBranchRef.current = async (branch, superseded) => {
363
+ const listed = listedBranch(branch);
364
+ if (!listed) {
365
+ if (branches.length > 0) {
366
+ notifications.show({
367
+ message: `Branch “${branch}” no longer exists or you can no longer open it, so you're still on “${branchLabel}”.`,
368
+ color: 'yellow',
369
+ });
370
+ }
353
371
  throw new Error(`Branch ${branch} is not listed`);
354
372
  }
355
- await handleBranchChange(branch);
373
+ await handleBranchChange(branchAddress(branches, listed), { push: false, superseded });
356
374
  };
357
375
  // 5. Comment system (depends on branchNameState)
358
376
  const { comments, getCommentUser, focusedFieldPath, highlightThreadId, commentsPanelOpen, setCommentsPanelOpen, handleAddComment, handleResolveThread, handleJumpToField, handleJumpToEntry, handleJumpToBranch, loadComments, } = useCommentSystem({
@@ -367,8 +385,8 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
367
385
  onRevealForm: revealForm,
368
386
  });
369
387
  reloadCommentsRef.current = (branch) => {
370
- if (sanitizeBranchName(branch) === sanitizeBranchName(branchNameState))
371
- void loadComments(branch);
388
+ if (branch === branchNameState)
389
+ void loadComments(branchNameState);
372
390
  };
373
391
  const { groupsData, groupsLoading, handleSaveGroups, handleSearchUsers, handleGetUserMetadata, handleSearchExternalGroups, } = useGroupManager({ isOpen: groupManagerOpen });
374
392
  // The comments and branches responses name their people, without email; a privileged lookup
@@ -1186,7 +1204,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1186
1204
  // Opened under a modal or drawer, it would take the keyboard from the one in use.
1187
1205
  if (document.querySelector('[role="dialog"][aria-modal="true"]'))
1188
1206
  return;
1189
- setChangedOnly(false);
1207
+ setEntryFilter('all');
1190
1208
  const field = entryFilterRef.current;
1191
1209
  if (field && document.activeElement === field)
1192
1210
  field.select();
@@ -1211,14 +1229,115 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1211
1229
  if (!contentPanelShown)
1212
1230
  setContentReveal(undefined);
1213
1231
  }, [contentPanelShown]);
1214
- // The content panel's All / Changed filter. It stays as chosen, emptied by a save or not, except
1215
- // that Mod+P finds among all entries and another branch has its own unsaved set: both reset it.
1216
- const [changedOnly, setChangedOnly] = useState(false);
1217
- useEffect(() => setChangedOnly(false), [branchNameState]);
1232
+ // The content panel's All / Unsaved / Changed filter. It stays as chosen, emptied by a save or
1233
+ // not, except that Mod+P finds among all entries and another branch has its own sets: both reset it.
1234
+ const [entryFilter, setEntryFilter] = useState('all');
1235
+ useEffect(() => setEntryFilter('all'), [branchNameState]);
1218
1236
  const showUnsaved = () => {
1219
- setChangedOnly(true);
1220
- revealInContentPanel({ kind: 'changed' });
1237
+ setEntryFilter('unsaved');
1238
+ revealInContentPanel({ kind: 'unsaved' });
1239
+ };
1240
+ // What the branch changes against its base, read while the panel shows so Changed has its count,
1241
+ // and while an entry is open so its header knows to offer "Show changes".
1242
+ // The protected base has nothing to compare. Changed keeps its place while the branch list loads,
1243
+ // so the control doesn't re-flow then, and reads once the branch is known not to be the base. A
1244
+ // list that failed, or lacks the branch, offers none, so a branch just created can lack Changed
1245
+ // until the list catches up.
1246
+ const changesOffered = branchPending || currentBranch?.isProtected === false;
1247
+ const changesAvailable = !!currentBranch && currentBranch.isProtected === false;
1248
+ const changesRead = (contentPanelShown || !!currentEntry) && changesAvailable;
1249
+ const branchChanges = useBranchChangesData(apiClient, branchNameState, changesRead);
1250
+ reloadChangesRef.current = (branch) => {
1251
+ if (branch === branchNameState)
1252
+ void branchChanges.reload();
1221
1253
  };
1254
+ // Submit, withdraw and a review move the branch's status, which no entries refresh follows.
1255
+ const changesStatusRef = useRef({ branch: branchNameState, status: currentBranch?.status });
1256
+ useEffect(() => {
1257
+ const seen = changesStatusRef.current;
1258
+ changesStatusRef.current = { branch: branchNameState, status: currentBranch?.status };
1259
+ if (seen.branch === branchNameState && seen.status && seen.status !== currentBranch?.status) {
1260
+ void branchChanges.reload();
1261
+ }
1262
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- `reload` is fresh each branch; only a status move matters
1263
+ }, [branchNameState, currentBranch?.status]);
1264
+ // "Show changes": the entry compared stays set after a close, so the closing drawer keeps it.
1265
+ const [changesSubject, setChangesSubject] = useState();
1266
+ const [changesOpen, setChangesOpen] = useState(false);
1267
+ // Its comparison stays keyed until the close transition ends, so the closing drawer keeps it.
1268
+ const [changesShown, setChangesShown] = useState(false);
1269
+ // Who opened the drawer, and the control that stands in for it once it is gone.
1270
+ const changesOpenerRef = useRef({
1271
+ el: null,
1272
+ standIn: () => null,
1273
+ });
1274
+ const showChangesRef = useRef(null);
1275
+ useEffect(() => {
1276
+ setChangesOpen(false);
1277
+ setChangesShown(false);
1278
+ }, [branchNameState]);
1279
+ const entryChange = useEntryChangeData(apiClient, branchNameState, (changesOpen || changesShown) && changesSubject
1280
+ ? {
1281
+ logicalPath: changesSubject.logicalPath,
1282
+ from: changesSubject.from,
1283
+ id: changesSubject.contentId,
1284
+ }
1285
+ : undefined);
1286
+ const openEntryChange = currentEntry &&
1287
+ branchChanges.data?.entries.find((e) => e.status !== 'deleted' && e.logicalPath === currentEntry.path);
1288
+ const referenceTitles = useMemo(() => new Map(entriesState.map((e) => [e.contentId, e.title ?? e.label])), [entriesState]);
1289
+ const referenceTitle = useMemo(() => (id) => referenceTitles.get(id), [referenceTitles]);
1290
+ /** A logical path in the labels the content tree shows: "Blog › hello". */
1291
+ const humanEntryPath = (logicalPath) => {
1292
+ const parts = logicalPath.split('/').filter(Boolean);
1293
+ const collections = parts
1294
+ .slice(1, -1)
1295
+ .map((_, i) => collectionLabels.get(parts.slice(0, i + 2).join('/')))
1296
+ .filter((label) => !!label);
1297
+ return [...collections, parts[parts.length - 1]].join(' › ');
1298
+ };
1299
+ const showEntryChanges = (entry, title, from) => {
1300
+ // The header's button gives way to the entry menu's trigger; a deleted entry's row, gone with
1301
+ // its panel, to the rail's Content button, never to another entry's controls.
1302
+ changesOpenerRef.current = {
1303
+ el: rememberOpener(),
1304
+ standIn: from === 'header'
1305
+ ? () => showChangesRef.current ?? navigatorToggleRef.current ?? contentToggleRef.current
1306
+ : () => contentToggleRef.current,
1307
+ };
1308
+ // A deleted entry has no row to read a schema from, so it borrows its type's from a sibling.
1309
+ const fields = entriesState.find((e) => e.path === entry.logicalPath)?.schema ??
1310
+ entriesState.find((e) => e.collectionPath === entry.collectionPath && e.entryType === entry.entryType)?.schema ??
1311
+ EMPTY_SCHEMA;
1312
+ setChangesSubject({
1313
+ logicalPath: entry.logicalPath,
1314
+ contentId: entry.contentId,
1315
+ status: entry.status,
1316
+ title,
1317
+ fields,
1318
+ ...(entry.previousLogicalPath && {
1319
+ from: entry.previousLogicalPath,
1320
+ movedFromLabel: humanEntryPath(entry.previousLogicalPath),
1321
+ }),
1322
+ });
1323
+ setChangesOpen(true);
1324
+ setChangesShown(true);
1325
+ };
1326
+ const changesReturnTarget = () => returnTarget(changesOpenerRef.current.el, changesOpenerRef.current.standIn);
1327
+ // Where a refused comparison can still be looked at: the entry itself, if the branch has it and
1328
+ // it isn't the one open, and the pull request, for those who review it.
1329
+ const changesOpenEntry = changesSubject &&
1330
+ changesSubject.status !== 'deleted' &&
1331
+ changesSubject.logicalPath !== currentEntry?.path &&
1332
+ entriesState.some((e) => e.path === changesSubject.logicalPath)
1333
+ ? () => {
1334
+ setChangesOpen(false);
1335
+ openEntry(changesSubject.logicalPath);
1336
+ }
1337
+ : undefined;
1338
+ const changesPullRequestUrl = isReviewer(userContext?.groups)
1339
+ ? currentBranch?.pullRequestUrl
1340
+ : undefined;
1222
1341
  const runSaveAll = () => {
1223
1342
  void handleSaveAll()
1224
1343
  .then((result) => {
@@ -1459,14 +1578,22 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1459
1578
  branchMissing,
1460
1579
  reviewing: branchReviewing,
1461
1580
  pullRequestClosed: branchPullRequestClosed,
1462
- }), dirtyPaths: dirtyPaths, reveal: contentReveal, onRevealed: () => setContentReveal(undefined), changedOnly: changedOnly, onChangedOnlyChange: setChangedOnly, unsavedCount: modifiedCount, unsavedEntries: editedFiles, entryListFailed: entriesLoadFailed && entriesState.length === 0, branchNotAvailable: branchShowsNotAvailable, onSwitchBranch: () => openBranchManager(), filterRef: entryFilterRef, filterShortcut: findShortcut, onFilterEnter: contentPanelTransient
1581
+ }), dirtyPaths: dirtyPaths, reveal: contentReveal, onRevealed: () => setContentReveal(undefined), entryFilter: entryFilter === 'changed' && !changesOffered ? 'all' : entryFilter, onEntryFilterChange: setEntryFilter, onShowUnsaved: showUnsaved, onShowDeletedChanges: (entry, label) => showEntryChanges(entry, label, 'deleted row'), branchChanges: changesOffered
1582
+ ? {
1583
+ base: branchChanges.data?.base ?? currentBranch?.baseBranch ?? 'the base branch',
1584
+ data: branchChanges.data,
1585
+ error: branchChanges.error,
1586
+ onRetry: () => void branchChanges.reload(),
1587
+ retrying: branchChanges.isValidating,
1588
+ }
1589
+ : undefined, unsavedCount: modifiedCount, unsavedEntries: editedFiles, entryListFailed: entriesLoadFailed && entriesState.length === 0, branchNotAvailable: branchShowsNotAvailable, onSwitchBranch: () => openBranchManager(), filterRef: entryFilterRef, filterShortcut: findShortcut, onFilterEnter: contentPanelTransient
1463
1590
  ? (path) => {
1464
1591
  openEntry(path);
1465
1592
  setContentPanelTransient(false);
1466
1593
  setFormFocusPath(path);
1467
1594
  }
1468
1595
  : undefined, onFilterEscape: contentPanelTransient ? () => setContentPanelTransient(false) : undefined, onRowMenuOpen: keepContentPanelOpen }) }) }));
1469
- return (_jsx(CanopyCMSProvider, { ...(themeOptions ?? {}), children: _jsx(AssetContextProvider, { basePath: basePath, children: _jsx(EditorShell, { topBar: _jsx(EditorHeader, { branchName: branchNameState, branchStatus: currentBranch?.status, busy: busy, modifiedCount: modifiedCount, onShowUnsaved: showUnsaved, draftStorageFailed: draftStorageFailed, userContext: userContext, branchCreatedBy: currentBranch?.createdBy, branchPullRequestState: currentBranch?.pullRequestState, branchAccess: currentBranch?.access,
1596
+ return (_jsx(CanopyCMSProvider, { ...(themeOptions ?? {}), children: _jsx(AssetContextProvider, { basePath: basePath, children: _jsx(EditorShell, { topBar: _jsx(EditorHeader, { branchName: branchNameState, branchLabel: branchLabel, branchStatus: currentBranch?.status, busy: busy, modifiedCount: modifiedCount, onShowUnsaved: showUnsaved, draftStorageFailed: draftStorageFailed, userContext: userContext, branchCreatedBy: currentBranch?.createdBy, branchPullRequestState: currentBranch?.pullRequestState, branchAccess: currentBranch?.access,
1470
1597
  // Fail-closed for the same reason branchContentLocked above is:
1471
1598
  // isProtected/writeBlocked gate real actions in EditorHeader and EntryHeader
1472
1599
  // (hiding Submit, disabling Save and Save all), so a missing wire value must read as
@@ -1482,19 +1609,19 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1482
1609
  // Save all finds each draft's entry in the list, so with none listed it would save nothing.
1483
1610
  entryListFailed: entriesLoadFailed && entriesState.length === 0, onSubmit: (returnFocusTo) => {
1484
1611
  if (branchNameState && !isSaveAllRunning()) {
1485
- handleSubmit(branchNameState, returnFocusTo).catch(console.error);
1612
+ handleSubmit(currentAddress, returnFocusTo).catch(console.error);
1486
1613
  }
1487
1614
  }, onWithdraw: (returnFocusTo) => {
1488
1615
  if (branchNameState && !isSaveAllRunning()) {
1489
- handleWithdraw(branchNameState, returnFocusTo).catch(console.error);
1616
+ handleWithdraw(currentAddress, returnFocusTo).catch(console.error);
1490
1617
  }
1491
1618
  }, onApprove: (returnFocusTo) => {
1492
1619
  if (branchNameState && !isSaveAllRunning()) {
1493
- handleApprove(branchNameState, returnFocusTo).catch(console.error);
1620
+ handleApprove(currentAddress, returnFocusTo).catch(console.error);
1494
1621
  }
1495
1622
  }, onRequestChanges: (returnFocusTo) => {
1496
1623
  if (branchNameState && !isSaveAllRunning()) {
1497
- handleRequestChanges(branchNameState, returnFocusTo).catch(console.error);
1624
+ handleRequestChanges(currentAddress, returnFocusTo).catch(console.error);
1498
1625
  }
1499
1626
  } }), rail: _jsx(EditorRail, { contentPanelOpen: contentPanelShown, contentPanelId: CONTENT_PANEL_ID, onContentToggle: () => contentPanelTransient
1500
1627
  ? keepContentPanelOpen()
@@ -1507,7 +1634,9 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1507
1634
  // True exactly while the form below shows "Setting up…" or "Loading content…".
1508
1635
  entryLoading: !currentEntry && ((!branchNameState && busy) || entriesInitializing), branchName: branchNameState, busy: busy, breadcrumbSegments: breadcrumbSegments,
1509
1636
  // A draft kept for an unavailable entry stays in storage but cannot be saved.
1510
- hasUnsavedChanges: !currentEntryUnavailable && !contentReadOnly && isSelectedDirty(), entryHasDraft: isSelectedDirty(), entryReadOnly: currentEntry?.canEdit === false, entryUnavailable: currentEntryUnavailable, draftStorageFailed: draftStorageFailed, branchStatus: currentBranch?.status, branchReadOnly: currentBranch?.readOnly, branchWriteBlocked: branchContentLocked, branchLoading: branchLoading, branchPending: branchPending, branchMissing: branchMissing, branchReviewing: branchReviewing, branchPullRequestClosed: branchPullRequestClosed, onCollectionOpen: (path) => revealInContentPanel({ kind: 'collection', path }), onEntryReveal: () => currentEntry && revealInContentPanel({ kind: 'entry', path: currentEntry.path }), navigatorToggleRef: navigatorToggleRef, onFileReload: () => handleReload(() => navigatorToggleRef.current), onFileDiscardDraft: () => handleDiscardFileDraft(() => navigatorToggleRef.current), onEntryRename: () => currentEntry && handleRenameEntry(currentEntry.path, true), onEntryDelete: () => currentEntry && handleDeleteEntry(currentEntry.path, true), onSave: currentEntryUnavailable ? noop : handleSave, saveAllRunning: !!saveAllProgress, saving: singleSaving?.branch === branchNameState &&
1637
+ hasUnsavedChanges: !currentEntryUnavailable && !contentReadOnly && isSelectedDirty(), entryHasDraft: isSelectedDirty(), entryReadOnly: currentEntry?.canEdit === false, entryUnavailable: currentEntryUnavailable, draftStorageFailed: draftStorageFailed, branchStatus: currentBranch?.status, branchReadOnly: currentBranch?.readOnly, branchWriteBlocked: branchContentLocked, branchLoading: branchLoading, branchPending: branchPending, branchMissing: branchMissing, branchReviewing: branchReviewing, branchPullRequestClosed: branchPullRequestClosed, onCollectionOpen: (path) => revealInContentPanel({ kind: 'collection', path }), onEntryReveal: () => currentEntry && revealInContentPanel({ kind: 'entry', path: currentEntry.path }), navigatorToggleRef: navigatorToggleRef, onShowChanges: openEntryChange
1638
+ ? () => showEntryChanges(openEntryChange, currentEntry?.label ?? currentEntry?.slug ?? '', 'header')
1639
+ : undefined, showChangesPending: changesOffered && !!currentEntry, showChangesRef: showChangesRef, onFileReload: () => handleReload(() => navigatorToggleRef.current), onFileDiscardDraft: () => handleDiscardFileDraft(() => navigatorToggleRef.current), onEntryRename: () => currentEntry && handleRenameEntry(currentEntry.path, true), onEntryDelete: () => currentEntry && handleDeleteEntry(currentEntry.path, true), onSave: currentEntryUnavailable ? noop : handleSave, saveAllRunning: !!saveAllProgress, saving: singleSaving?.branch === branchNameState &&
1511
1640
  singleSaving.contentId === currentEntry?.contentId, savedSignal: lastSaved?.seq, savedHere: lastSaved?.branch === branchNameState &&
1512
1641
  lastSaved.contentId === currentEntry?.contentId }), _jsx(Box, { style: { flex: '1 1 0%', minHeight: 0 }, children: _jsx(PaneLayout, { formAside: contentPanelDocked && layout === 'stacked' ? contentPanel : undefined, formOverlay: contentPanelDocked ? undefined : contentPanel, formInert: contentOverlayShown, previewMaximized: previewShownMaximized,
1513
1642
  // Sizing the pane, by pointer or key, gives the page the pane's width.
@@ -1558,24 +1687,27 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1558
1687
  }, onBeforeCreate: confirmCreateBranch, onCreate: handleCreateBranch,
1559
1688
  // A Save all mid-run on the open branch would be submitted partway through.
1560
1689
  onSubmit: (name) => {
1561
- if (name === branchNameState && isSaveAllRunning())
1690
+ if (namesBranch(name, branchNameState) && isSaveAllRunning())
1562
1691
  return;
1563
1692
  handleSubmit(name).catch((err) => console.error(err));
1564
1693
  }, onWithdraw: (name) => {
1565
- if (name === branchNameState && isSaveAllRunning())
1694
+ if (namesBranch(name, branchNameState) && isSaveAllRunning())
1566
1695
  return;
1567
1696
  handleWithdraw(name).catch((err) => console.error(err));
1568
1697
  }, onApprove: (name) => {
1569
- if (name === branchNameState && isSaveAllRunning())
1698
+ if (namesBranch(name, branchNameState) && isSaveAllRunning())
1570
1699
  return;
1571
1700
  handleApprove(name).catch((err) => console.error(err));
1572
1701
  }, onRequestChanges: (name) => {
1573
- if (name === branchNameState && isSaveAllRunning())
1702
+ if (namesBranch(name, branchNameState) && isSaveAllRunning())
1574
1703
  return;
1575
1704
  handleRequestChanges(name).catch((err) => console.error(err));
1576
1705
  }, onDelete: (name) => {
1577
1706
  handleDelete(name).catch((err) => console.error(err));
1578
- }, onClose: () => setBranchManagerOpen(false), comments: comments, currentUserId: commentUserId, canResolve: canResolveAnyComment, onAddComment: handleAddComment, onResolveThread: handleResolveThread, highlightThreadId: highlightThreadId, onGetUserMetadata: canResolveUserNames ? handleGetUserMetadata : undefined, onGetOwnerMetadata: getCommentUserMetadata, onGetCommentUserMetadata: getCommentUserMetadata }, branchManagerOpening.seq) }), branchNameState && (_jsx(CommentsPanel, { opened: commentsPanelOpen, branchName: branchNameState, comments: displayComments, canResolve: canResolveAnyComment, currentUserId: commentUserId, entryLabel: commentEntryLabel, fieldLabel: commentFieldLabel, onAddComment: handleAddDisplayedComment, onResolveThread: handleResolveThread, onClose: () => setCommentsPanelOpen(false), onJumpToField: jumpToThreadField, onGetUserMetadata: getCommentUserMetadata, onJumpToEntry: handleJumpToEntry, onJumpToBranch: handleJumpToBranch }, branchNameState)), userIsAdmin && (_jsx(GroupManager, { opened: groupManagerOpen, onClose: () => setGroupManagerOpen(false), returnFocusTo: settingsReturnTarget, internalGroups: groupsData, loading: groupsLoading, canEdit: userIsAdmin, onSave: handleSaveGroups, onSearchUsers: handleSearchUsers, onGetUserMetadata: handleGetUserMetadata, onSearchExternalGroups: handleSearchExternalGroups })), userIsAdmin && (_jsx(PermissionManager, { opened: permissionManagerOpen, onClose: () => setPermissionManagerOpen(false), returnFocusTo: settingsReturnTarget, collections: activeCollections, contentRoot: contentRoot, permissions: permissionsData, loading: permissionsLoading, canEdit: userIsAdmin, onSave: handleSavePermissions, onSearchUsers: handleSearchUsers, onGetUserMetadata: handleGetUserMetadata, onListGroups: handleListGroups })), userIsAdmin && (_jsx(SystemHealthPanel, { opened: systemHealthOpen, onClose: () => setSystemHealthOpen(false), returnFocusTo: settingsReturnTarget })), _jsx(MediaLibrary, { opened: mediaLibraryOpen, onClose: () => setMediaLibraryOpen(false), mode: "manage", returnFocusTo: () => mediaButtonRef.current }), userIsAdmin && (_jsx(CollectionEditor, { isOpen: collectionEditorOpen, editingCollection: editingCollection, parentPath: collectionEditorParentPath, availableSchemas: availableSchemas, onSave: handleCollectionSave, onAddEntryType: editingCollection
1707
+ }, onClose: () => setBranchManagerOpen(false), comments: comments, currentUserId: commentUserId, canResolve: canResolveAnyComment, onAddComment: handleAddComment, onResolveThread: handleResolveThread, highlightThreadId: highlightThreadId, onGetUserMetadata: canResolveUserNames ? handleGetUserMetadata : undefined, onGetOwnerMetadata: getCommentUserMetadata, onGetCommentUserMetadata: getCommentUserMetadata }, branchManagerOpening.seq) }), changesSubject && (_jsx(LazyEntryChangesDrawer, { opened: changesOpen, onClose: () => setChangesOpen(false), onExited: () => setChangesShown(false), returnFocusTo: changesReturnTarget, base: branchChanges.data?.base ?? currentBranch?.baseBranch ?? 'the base branch', subject: {
1708
+ ...changesSubject,
1709
+ unsavedEdits: changesSubject.logicalPath === currentEntry?.path && isSelectedDirty(),
1710
+ }, state: entryChange, referenceTitle: referenceTitle, onOpenEntry: changesOpenEntry, pullRequestUrl: changesPullRequestUrl })), branchNameState && (_jsx(CommentsPanel, { opened: commentsPanelOpen, branchName: branchLabel, comments: displayComments, canResolve: canResolveAnyComment, currentUserId: commentUserId, entryLabel: commentEntryLabel, fieldLabel: commentFieldLabel, onAddComment: handleAddDisplayedComment, onResolveThread: handleResolveThread, onClose: () => setCommentsPanelOpen(false), onJumpToField: jumpToThreadField, onGetUserMetadata: getCommentUserMetadata, onJumpToEntry: handleJumpToEntry, onJumpToBranch: handleJumpToBranch }, branchNameState)), userIsAdmin && (_jsx(GroupManager, { opened: groupManagerOpen, onClose: () => setGroupManagerOpen(false), returnFocusTo: settingsReturnTarget, internalGroups: groupsData, loading: groupsLoading, canEdit: userIsAdmin, onSave: handleSaveGroups, onSearchUsers: handleSearchUsers, onGetUserMetadata: handleGetUserMetadata, onSearchExternalGroups: handleSearchExternalGroups })), userIsAdmin && (_jsx(PermissionManager, { opened: permissionManagerOpen, onClose: () => setPermissionManagerOpen(false), returnFocusTo: settingsReturnTarget, collections: activeCollections, contentRoot: contentRoot, permissions: permissionsData, loading: permissionsLoading, canEdit: userIsAdmin, onSave: handleSavePermissions, onSearchUsers: handleSearchUsers, onGetUserMetadata: handleGetUserMetadata, onListGroups: handleListGroups })), userIsAdmin && (_jsx(SystemHealthPanel, { opened: systemHealthOpen, onClose: () => setSystemHealthOpen(false), returnFocusTo: settingsReturnTarget })), _jsx(MediaLibrary, { opened: mediaLibraryOpen, onClose: () => setMediaLibraryOpen(false), mode: "manage", returnFocusTo: () => mediaButtonRef.current }), userIsAdmin && (_jsx(CollectionEditor, { isOpen: collectionEditorOpen, editingCollection: editingCollection, parentPath: collectionEditorParentPath, availableSchemas: availableSchemas, onSave: handleCollectionSave, onAddEntryType: editingCollection
1579
1711
  ? (path, entryType) => addEntryType(path, entryType)
1580
1712
  : undefined, onUpdateEntryType: editingCollection
1581
1713
  ? (path, name, updates) => updateEntryType(path, name, updates)
@@ -1,6 +1,8 @@
1
1
  import React from 'react';
2
2
  import type { ContentId, LogicalPath } from '../paths/types.js';
3
3
  import { useTree } from '@mantine/core';
4
+ import type { BranchChangesData, BranchChangeEntry } from '../api/branch-changes.js';
5
+ import type { BranchChangesError } from './hooks/useBranchChangesData.js';
4
6
  type TreeController = ReturnType<typeof useTree>;
5
7
  export interface EntryNavItem {
6
8
  path: LogicalPath;
@@ -77,21 +79,30 @@ export interface EntryNavigatorProps {
77
79
  readOnlyReason?: string;
78
80
  /** Entry paths with unsaved changes; each shows a dot, and so does every collection holding one. */
79
81
  dirtyPaths?: ReadonlySet<string>;
80
- /** A row to bring into view, or (`changed`) the unsaved entries; `onRevealed` once done. */
82
+ /** A row to bring into view, or (`unsaved`) the unsaved entries; `onRevealed` once done. */
81
83
  reveal?: ContentPanelReveal;
82
84
  /** The reveal is done, or its row is not in the tree. */
83
85
  onRevealed?: () => void;
84
- /** The All / Changed control shows only with this; Changed lists the entries in `dirtyPaths`. */
85
- onChangedOnlyChange?: (changedOnly: boolean) => void;
86
- changedOnly?: boolean;
86
+ /**
87
+ * The All / Unsaved / Changed control shows only with this. Unsaved lists the entries in
88
+ * `dirtyPaths`; Changed, those in `branchChanges`.
89
+ */
90
+ onEntryFilterChange?: (filter: EntryFilter) => void;
91
+ entryFilter?: EntryFilter;
92
+ /** What the branch changes against its base. Without it the control offers no Changed. */
93
+ branchChanges?: BranchChangesView;
94
+ /** Lists the unsaved entries, as the top bar's count does; else Unsaved is chosen here. */
95
+ onShowUnsaved?: () => void;
96
+ /** Opens a deleted entry's changes, read-only; without it its row is plain text. */
97
+ onShowDeletedChanges?: (entry: BranchChangeEntry, label: string) => void;
87
98
  /** Entries with unsaved changes, counting drafts whose entries the tree doesn't list yet. */
88
99
  unsavedCount?: number;
89
- /** The named unsaved entries; Changed offers any the tree has no row for beneath it. */
100
+ /** The named unsaved entries; Unsaved offers any the tree has no row for beneath it. */
90
101
  unsavedEntries?: ReadonlyArray<{
91
102
  path: string;
92
103
  label: string;
93
104
  }>;
94
- /** The branch's entry list failed to load, so Changed can't name what is unsaved. */
105
+ /** The branch's entry list failed to load, so Unsaved can't name what is unsaved. */
95
106
  entryListFailed?: boolean;
96
107
  /** The bar's "Not available" row: an empty tree says so, with `onSwitchBranch`, not "No content". */
97
108
  branchNotAvailable?: boolean;
@@ -109,17 +120,30 @@ export interface EntryNavigatorProps {
109
120
  };
110
121
  }
111
122
  /**
112
- * A collection or entry reveal clears the query, switches Changed back to All, expands the row's
113
- * ancestors (and a collection itself) and focuses the row. A `changed` reveal clears the query
114
- * only, for Changed set alongside it. `seq` makes a repeat ask a new one.
123
+ * A collection or entry reveal clears the query, switches the filter back to All, expands the
124
+ * row's ancestors (and a collection itself) and focuses the row. An `unsaved` reveal clears the
125
+ * query only, for Unsaved set alongside it. `seq` makes a repeat ask a new one.
115
126
  */
116
127
  export type ContentPanelReveal = {
117
128
  kind: 'collection' | 'entry';
118
129
  path: string;
119
130
  seq: number;
120
131
  } | {
121
- kind: 'changed';
132
+ kind: 'unsaved';
122
133
  seq: number;
123
134
  };
135
+ /** Which entries the Content panel lists: all, those with unsaved edits, or those differing from the base. */
136
+ export type EntryFilter = 'all' | 'unsaved' | 'changed';
137
+ /** The branch's changes against its base, as the Changed filter shows them. */
138
+ export interface BranchChangesView {
139
+ /** The base branch's name, for the copy. */
140
+ base: string;
141
+ /** The last answer, kept while a reload or its error shows. */
142
+ data?: BranchChangesData;
143
+ error?: BranchChangesError;
144
+ onRetry: () => void;
145
+ /** A reload is in flight, so Retry shows it is working. */
146
+ retrying?: boolean;
147
+ }
124
148
  export declare const EntryNavigator: React.FC<EntryNavigatorProps>;
125
149
  export default EntryNavigator;