canopycms 0.9.0 → 0.11.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 (68) hide show
  1. package/CHANGELOG.md +74 -48
  2. package/dist/api/admin.d.ts +11 -0
  3. package/dist/api/admin.js +2 -0
  4. package/dist/api/archived-branches.d.ts +70 -0
  5. package/dist/api/archived-branches.js +146 -0
  6. package/dist/api/branch-merge.js +16 -1
  7. package/dist/api/branch-status.d.ts +11 -0
  8. package/dist/api/branch-status.js +2 -0
  9. package/dist/api/branch-withdraw.js +1 -1
  10. package/dist/api/branch.js +46 -108
  11. package/dist/api/client.d.ts +25 -21
  12. package/dist/api/client.js +46 -38
  13. package/dist/branch-archive.d.ts +19 -0
  14. package/dist/branch-archive.js +27 -0
  15. package/dist/branch-delete.d.ts +45 -0
  16. package/dist/branch-delete.js +97 -0
  17. package/dist/branch-metadata-file.d.ts +3 -0
  18. package/dist/branch-metadata-file.js +1 -0
  19. package/dist/branch-metadata.d.ts +6 -1
  20. package/dist/branch-metadata.js +15 -2
  21. package/dist/cli/cli.js +2 -0
  22. package/dist/cli/generate-ai-content.js +1 -0
  23. package/dist/editor/BranchManager.d.ts +20 -0
  24. package/dist/editor/BranchManager.js +92 -26
  25. package/dist/editor/Editor.js +49 -25
  26. package/dist/editor/PreviewFrame.d.ts +8 -1
  27. package/dist/editor/PreviewFrame.js +15 -2
  28. package/dist/editor/PreviewPane.d.ts +7 -2
  29. package/dist/editor/PreviewPane.js +6 -5
  30. package/dist/editor/PreviewToolbarTools.d.ts +6 -0
  31. package/dist/editor/PreviewToolbarTools.js +6 -1
  32. package/dist/editor/admin/SystemHealthPanel.js +61 -6
  33. package/dist/editor/admin/useSystemHealth.d.ts +3 -0
  34. package/dist/editor/admin/useSystemHealth.js +59 -0
  35. package/dist/editor/branch-status.js +9 -0
  36. package/dist/editor/comments/CommentThreadsPopover.d.ts +2 -0
  37. package/dist/editor/comments/CommentThreadsPopover.js +9 -1
  38. package/dist/editor/comments/FieldWrapper.js +3 -1
  39. package/dist/editor/copy.d.ts +3 -0
  40. package/dist/editor/copy.js +3 -0
  41. package/dist/editor/hooks/useBranchManager.d.ts +5 -0
  42. package/dist/editor/hooks/useBranchManager.js +55 -4
  43. package/dist/editor/hooks/useBranchesData.d.ts +2 -0
  44. package/dist/editor/hooks/useBranchesData.js +3 -1
  45. package/dist/editor/hooks/useEditorLayout.d.ts +4 -0
  46. package/dist/editor/hooks/useEditorLayout.js +5 -0
  47. package/dist/editor/hooks/usePreviewFollow.d.ts +26 -0
  48. package/dist/editor/hooks/usePreviewFollow.js +54 -0
  49. package/dist/editor/preview-bridge.d.ts +25 -0
  50. package/dist/editor/preview-bridge.js +175 -0
  51. package/dist/preview.d.ts +2 -2
  52. package/dist/preview.js +1 -1
  53. package/dist/types.d.ts +33 -0
  54. package/dist/version.d.ts +1 -1
  55. package/dist/version.js +1 -1
  56. package/dist/worker/archived-sweep.d.ts +23 -0
  57. package/dist/worker/archived-sweep.js +137 -0
  58. package/dist/worker/cms-worker.d.ts +6 -0
  59. package/dist/worker/cms-worker.js +7 -0
  60. package/dist/worker/git-sync.d.ts +4 -4
  61. package/dist/worker/git-sync.js +3 -0
  62. package/dist/worker/github-commits.d.ts +2 -2
  63. package/dist/worker/github-commits.js +5 -2
  64. package/dist/worker/github-gateway-wire.d.ts +2 -2
  65. package/dist/worker/rebase.js +6 -2
  66. package/dist/worker/task-runner.js +7 -4
  67. package/dist/worker/worker-context.d.ts +2 -0
  68. package/package.json +1 -1
@@ -64,6 +64,7 @@ import { formatCanopyPath } from './canopy-path.js';
64
64
  import { itemKeysOnPath, resolveThreadPath, withItemKeys } from '../item-keys.js';
65
65
  import { ENTRY_NOT_LOADED_COMMENT_MESSAGE, NEW_ITEM_COMMENT_MESSAGE, } from './comments/item-anchor-copy.js';
66
66
  import { findFieldTarget, focusFieldTarget } from './field-target.js';
67
+ import { PreviewFollowContext, usePreviewFollow } from './hooks/usePreviewFollow.js';
67
68
  import { UndoToastMessage } from './components/UndoToastMessage.js';
68
69
  import { getNotificationDuration } from './utils/env.js';
69
70
  import { MayHaveLandedError } from './unanswered.js';
@@ -149,7 +150,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
149
150
  const commentUserId = userContext?.userId ?? currentUser;
150
151
  // Below 64em (1024px) the editor starts stacked and the content panel never docks.
151
152
  const wideWindow = useMediaQuery('(min-width: 64em)', true, { getInitialValueInEffect: false }) !== false;
152
- const { loaded: layoutLoaded, layout, setLayout, highlightEnabled, setHighlightEnabled, sideSplitPercent, stackedSplitPercent, setSplitPercent, previewWidth, setPreviewWidth, siteChrome, setSiteChrome, contentPanelOpen, setContentPanelOpen, contentPanelWidth, setContentPanelWidth, } = useEditorLayout({
153
+ const { loaded: layoutLoaded, layout, setLayout, highlightEnabled, setHighlightEnabled, previewFollowsForm, setPreviewFollowsForm, sideSplitPercent, stackedSplitPercent, setSplitPercent, previewWidth, setPreviewWidth, siteChrome, setSiteChrome, contentPanelOpen, setContentPanelOpen, contentPanelWidth, setContentPanelWidth, } = useEditorLayout({
153
154
  storageKey: editorLayoutStorageKey(basePath),
154
155
  defaultLayout: wideWindow ? 'side' : 'stacked',
155
156
  });
@@ -167,7 +168,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
167
168
  // Reloads the open branch's comments after a request for changes, whose note is a comment; the
168
169
  // comment system is built further down.
169
170
  const reloadCommentsRef = useRef(() => { });
170
- const { branchName: branchNameState, setBranchName, branches, branchSummaries, currentBranch, currentAddress, listedBranch, namesBranch, urlBranchName, branchesLoadFailed, branchesLoaded, addCreatedBranch, handleSubmit, handleWithdraw, handleApprove, handleRequestChanges, handleDelete, handleReloadBranchData, loadBranches, } = useBranchManager({
171
+ const { branchName: branchNameState, setBranchName, branches, branchSummaries, currentBranch, currentAddress, listedBranch, namesBranch, urlBranchName, branchesLoadFailed, branchesLoaded, addCreatedBranch, handleSubmit, handleWithdraw, handleApprove, handleRequestChanges, handleDelete, handleArchive, handleReloadBranchData, loadBranches, } = useBranchManager({
171
172
  initialBranch: branchName,
172
173
  operatingMode,
173
174
  setBusy: setBranchesLoading,
@@ -388,6 +389,11 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
388
389
  if (branch === branchNameState)
389
390
  void loadComments(branchNameState);
390
391
  };
392
+ const { reveal: previewReveal, follow: followInPreview } = usePreviewFollow({
393
+ enabled: previewFollowsForm,
394
+ branch: branchNameState,
395
+ entryId: currentEntry?.contentId,
396
+ });
391
397
  const { groupsData, groupsLoading, handleSaveGroups, handleSearchUsers, handleGetUserMetadata, handleSearchExternalGroups, } = useGroupManager({ isOpen: groupManagerOpen });
392
398
  // The comments and branches responses name their people, without email; a privileged lookup
393
399
  // adds the email.
@@ -535,9 +541,14 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
535
541
  ]);
536
542
  // A jump into another entry waits for its value to place the thread; the stored path may be stale.
537
543
  const [pendingFieldJump, setPendingFieldJump] = useState(null);
544
+ // A field whose comments are already open is still followed in the preview.
545
+ const jumpToField = (entryPath, canopyPath, threadId) => {
546
+ handleJumpToField(entryPath, canopyPath, threadId);
547
+ followInPreview?.(canopyPath);
548
+ };
538
549
  const jumpToThreadField = (entryPath, canopyPath, threadId) => {
539
550
  if (entryPath === currentEntry?.path) {
540
- handleJumpToField(entryPath, canopyPath, threadId);
551
+ jumpToField(entryPath, canopyPath, threadId);
541
552
  return;
542
553
  }
543
554
  setPendingFieldJump({ entryPath, canopyPath, threadId });
@@ -559,7 +570,8 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
559
570
  if (placed?.onRemovedItem)
560
571
  handleJumpToEntry(entryPath, threadId);
561
572
  else
562
- handleJumpToField(entryPath, placed?.canopyPath ?? canopyPath, threadId);
573
+ jumpToField(entryPath, placed?.canopyPath ?? canopyPath, threadId);
574
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- jumpToField reads this render's follow
563
575
  }, [pendingFieldJump, selectedPath, currentEntry, displayValue, displayComments]);
564
576
  // A thread on an item only the draft has is refused: a discard would leave it on nothing.
565
577
  const handleAddDisplayedComment = async (text, type, entryPath, canopyPath, threadId) => {
@@ -1504,7 +1516,15 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1504
1516
  if (maximized && !contentPanelDocked)
1505
1517
  setNavigatorOpen(false);
1506
1518
  setPreviewMaximized(maximized);
1507
- }, resizing: paneResizing, siteChrome: siteChrome, onSiteChromeChange: setSiteChrome, onNavigate: openPreviewLink, notice: previewError && (_jsx(Alert, { color: "red", title: "Preview error", withCloseButton: true, onClose: () => setPreviewError(null), radius: 0, children: previewErrorField
1519
+ }, resizing: paneResizing, siteChrome: siteChrome, onSiteChromeChange: setSiteChrome, reveal: previewReveal, previewFollowsForm: previewFollowsForm,
1520
+ // Only beside a form, the one thing it follows.
1521
+ onPreviewFollowsFormChange: !currentEntryUnavailable &&
1522
+ currentEntry?.canEdit !== false &&
1523
+ schema.length > 0 &&
1524
+ // Kept while the next entry loads, so the toolbar never changes under a switch.
1525
+ (displayValue || previewPending)
1526
+ ? setPreviewFollowsForm
1527
+ : undefined, onNavigate: openPreviewLink, notice: previewError && (_jsx(Alert, { color: "red", title: "Preview error", withCloseButton: true, onClose: () => setPreviewError(null), radius: 0, children: previewErrorField
1508
1528
  ? `${previewErrorField}: ${previewError.message}`
1509
1529
  : previewError.message })) })) : (_jsx(Paper, { withBorder: true, shadow: "xs", h: "100%", bg: "var(--mantine-color-body)", style: {
1510
1530
  display: 'flex',
@@ -1654,26 +1674,26 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1654
1674
  ? 'Setting up your branch workspace…'
1655
1675
  : entriesInitializing
1656
1676
  ? 'Loading content…'
1657
- : 'Select an item to start editing.' })) : currentEntryUnavailable ? (_jsx(UnavailableEntryNotice, { schemaRefs: currentEntry.unavailable ? [currentEntry.unavailable.schemaRef] : [] })) : currentEntry.canEdit === false ? (_jsx(NoEditPermissionNotice, { entryLabel: currentEntry.label })) : schema.length > 0 && displayValue ? (_jsx(EntryLinkContext.Provider, { value: entryLinkContextValue, children: _jsx(SiteMdxAllowContext.Provider, { value: mdxAllow, children: _jsx(FormRenderer, { notice: hiddenDraftNotice && (_jsx(ReadOnlyDraftNotice, { permanentlyReadOnly: (currentBranch?.isProtected ?? true) ||
1658
- currentBranch?.status === 'archived',
1659
- // A hidden draft's discard changes nothing on screen, so it says so.
1660
- onDiscard: () => handleDiscardFileDraft(() => navigatorToggleRef.current, {
1661
- announce: true,
1662
- }) })), fields: schema, value: displayValue, readOnly: contentReadOnly, onRemoved: handleFieldRemoved, savedValue: loadedValue, commentBlockedReason: commentBlockedReason, removedItemFieldLabel: commentFieldLabel, onGetUserMetadata: getCommentUserMetadata, header: {
1663
- title: currentEntry.label,
1664
- typeLabel: entryTypeLabel(activeCollections, currentEntry),
1665
- },
1666
- // The one writer of drafts. Read-only content gets none, whatever a
1667
- // field emits (MDXEditor's mount-time normalisation included).
1668
- onChange: (next) => {
1669
- const contentId = currentEntry?.contentId;
1670
- if (contentId && !contentReadOnlyRef.current) {
1671
- // Every list item keeps a key, whatever field made it.
1672
- const keyed = withItemKeys(schema, next, 'random');
1673
- setDrafts((prev) => ({ ...prev, [contentId]: keyed }));
1674
- }
1675
- }, customRenderers: customRenderers, branch: branchNameState, comments: displayComments, currentEntryPath: selectedPath, currentUserId: commentUserId, canResolve: canResolveAnyComment, focusedFieldPath: focusedFieldPath, highlightThreadId: highlightThreadId, onAddComment: handleAddDisplayedComment, onResolveThread: handleResolveThread, fieldErrors: fieldErrors, format: currentEntry.format, conflictNotice: !!(currentEntry?.contentId &&
1676
- currentBranch?.conflictFiles?.includes(currentEntry.contentId)) }) }) })) : schema.length === 0 ? (_jsx(CenteredMessage, { children: "No fields to edit." })) : loadFailedKey !== `${branchNameState}:${currentEntry.contentId}` ? (_jsx(Stack, { gap: "lg", p: "md", "data-testid": "entry-loading-skeleton", children: [0, 1, 2].map((row) => (_jsxs(Stack, { gap: 6, children: [_jsx(Skeleton, { h: 12, w: 120 }), _jsx(Skeleton, { h: 36 })] }, row))) })) : (_jsx(Alert, { color: "red", variant: "light", m: "md", title: "This entry couldn't be loaded.", "data-testid": "entry-load-failed", children: _jsx(Button, { variant: "default", onClick: () => {
1677
+ : 'Select an item to start editing.' })) : currentEntryUnavailable ? (_jsx(UnavailableEntryNotice, { schemaRefs: currentEntry.unavailable ? [currentEntry.unavailable.schemaRef] : [] })) : currentEntry.canEdit === false ? (_jsx(NoEditPermissionNotice, { entryLabel: currentEntry.label })) : schema.length > 0 && displayValue ? (_jsx(EntryLinkContext.Provider, { value: entryLinkContextValue, children: _jsx(SiteMdxAllowContext.Provider, { value: mdxAllow, children: _jsx(PreviewFollowContext.Provider, { value: followInPreview ?? null, children: _jsx(FormRenderer, { notice: hiddenDraftNotice && (_jsx(ReadOnlyDraftNotice, { permanentlyReadOnly: (currentBranch?.isProtected ?? true) ||
1678
+ currentBranch?.status === 'archived',
1679
+ // A hidden draft's discard changes nothing on screen, so it says so.
1680
+ onDiscard: () => handleDiscardFileDraft(() => navigatorToggleRef.current, {
1681
+ announce: true,
1682
+ }) })), fields: schema, value: displayValue, readOnly: contentReadOnly, onRemoved: handleFieldRemoved, savedValue: loadedValue, commentBlockedReason: commentBlockedReason, removedItemFieldLabel: commentFieldLabel, onGetUserMetadata: getCommentUserMetadata, header: {
1683
+ title: currentEntry.label,
1684
+ typeLabel: entryTypeLabel(activeCollections, currentEntry),
1685
+ },
1686
+ // The one writer of drafts. Read-only content gets none, whatever a
1687
+ // field emits (MDXEditor's mount-time normalisation included).
1688
+ onChange: (next) => {
1689
+ const contentId = currentEntry?.contentId;
1690
+ if (contentId && !contentReadOnlyRef.current) {
1691
+ // Every list item keeps a key, whatever field made it.
1692
+ const keyed = withItemKeys(schema, next, 'random');
1693
+ setDrafts((prev) => ({ ...prev, [contentId]: keyed }));
1694
+ }
1695
+ }, customRenderers: customRenderers, branch: branchNameState, comments: displayComments, currentEntryPath: selectedPath, currentUserId: commentUserId, canResolve: canResolveAnyComment, focusedFieldPath: focusedFieldPath, highlightThreadId: highlightThreadId, onAddComment: handleAddDisplayedComment, onResolveThread: handleResolveThread, fieldErrors: fieldErrors, format: currentEntry.format, conflictNotice: !!(currentEntry?.contentId &&
1696
+ currentBranch?.conflictFiles?.includes(currentEntry.contentId)) }) }) }) })) : schema.length === 0 ? (_jsx(CenteredMessage, { children: "No fields to edit." })) : loadFailedKey !== `${branchNameState}:${currentEntry.contentId}` ? (_jsx(Stack, { gap: "lg", p: "md", "data-testid": "entry-loading-skeleton", children: [0, 1, 2].map((row) => (_jsxs(Stack, { gap: 6, children: [_jsx(Skeleton, { h: 12, w: 120 }), _jsx(Skeleton, { h: 36 })] }, row))) })) : (_jsx(Alert, { color: "red", variant: "light", m: "md", title: "This entry couldn't be loaded.", "data-testid": "entry-load-failed", children: _jsx(Button, { variant: "default", onClick: () => {
1677
1697
  setLoadFailedKey(null);
1678
1698
  setLoadAttempt((attempt) => attempt + 1);
1679
1699
  }, children: "Retry" }) })) }) })] }), overlays: _jsxs(_Fragment, { children: [_jsx(BranchesDrawer, { opened: branchManagerOpen, onClose: () => setBranchManagerOpen(false), confirmOpen: branchConfirmOpen, returnFocusTo: branchReturnTarget, children: _jsx(BranchManager, { startCreating: branchManagerOpening.create, branches: branchSummaries, mode: operatingMode, user: userContext, currentBranchName: currentBranch?.name ?? branchNameState, currentWorkflowBlockedReason: saveAllProgress ? 'Wait for Save all to finish.' : undefined, onSelect: async (name) => {
@@ -1705,6 +1725,10 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
1705
1725
  handleRequestChanges(name).catch((err) => console.error(err));
1706
1726
  }, onDelete: (name) => {
1707
1727
  handleDelete(name).catch((err) => console.error(err));
1728
+ }, onArchive: (name) => {
1729
+ if (namesBranch(name, branchNameState) && isSaveAllRunning())
1730
+ return;
1731
+ handleArchive(name).catch((err) => console.error(err));
1708
1732
  }, 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: {
1709
1733
  ...changesSubject,
1710
1734
  unsavedEdits: changesSubject.logicalPath === currentEntry?.path && isSelectedDirty(),
@@ -4,6 +4,11 @@ export interface PreviewMarks {
4
4
  count: number;
5
5
  paths?: string[];
6
6
  }
7
+ /** A field to bring into view in the preview; a new `id` asks again for the same field. */
8
+ export interface PreviewReveal {
9
+ path: string;
10
+ id: number;
11
+ }
7
12
  /**
8
13
  * What a page's handshake says of the site's header and footer: its ready message says it renders
9
14
  * them for `chrome=1` or that it does not, or no ready message came in `READY_TIMEOUT_MS`.
@@ -15,7 +20,7 @@ export type SiteChromeSupport = 'supported' | 'unsupported' | 'unanswered';
15
20
  * runs until the preview's ready message arrives; when none does, a "Live updates off" chip offers
16
21
  * to reload the preview. While `busy`, the page stays in view, dimmed and inert, under the bar.
17
22
  */
18
- export declare const PreviewFrame: ({ src, path, data, isLoading, className, style, highlightEnabled, onPreviewError, onMarks, assetBase, iframeStyle, onReload, onSiteChrome, onNavigate, busy, }: {
23
+ export declare const PreviewFrame: ({ src, path, data, isLoading, className, style, highlightEnabled, onPreviewError, onMarks, assetBase, iframeStyle, onReload, onSiteChrome, onNavigate, reveal, busy, }: {
19
24
  src: string;
20
25
  path: string;
21
26
  data?: unknown;
@@ -48,4 +53,6 @@ export declare const PreviewFrame: ({ src, path, data, isLoading, className, sty
48
53
  onNavigate?: (href: string) => void;
49
54
  /** The editor is loading the next page's data: this page stays in view, dimmed and inert. */
50
55
  busy?: boolean;
56
+ /** Sent once the page connects, and each time its `id` changes. */
57
+ reveal?: PreviewReveal;
51
58
  }) => import("react/jsx-runtime").JSX.Element;
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useEffect, useLayoutEffect, useRef, useState } from 'react';
4
- import { CANOPY_PREVIEW_ERROR, CANOPY_PREVIEW_HIGHLIGHT, CANOPY_PREVIEW_MARKS, CANOPY_PREVIEW_MESSAGE, CANOPY_PREVIEW_NAVIGATE, CANOPY_PREVIEW_READY, isOpaqueOrigin, MARK_REPORT_LIMITS, resolveMessageOrigin, } from './preview-bridge.js';
4
+ import { CANOPY_PREVIEW_ERROR, CANOPY_PREVIEW_HIGHLIGHT, CANOPY_PREVIEW_MARKS, CANOPY_PREVIEW_MESSAGE, CANOPY_PREVIEW_NAVIGATE, CANOPY_PREVIEW_READY, CANOPY_PREVIEW_REVEAL, isOpaqueOrigin, MARK_REPORT_LIMITS, resolveMessageOrigin, } from './preview-bridge.js';
5
5
  /**
6
6
  * How long after the iframe's `load` the preview has to send its ready message before the frame
7
7
  * says live updates are off. Ready can also arrive before `load`, which waits for images too.
@@ -51,7 +51,7 @@ const sendDraftUpdate = (iframe, message, targetOrigin) => {
51
51
  * runs until the preview's ready message arrives; when none does, a "Live updates off" chip offers
52
52
  * to reload the preview. While `busy`, the page stays in view, dimmed and inert, under the bar.
53
53
  */
54
- export const PreviewFrame = ({ src, path, data, isLoading, className, style, highlightEnabled, onPreviewError, onMarks, assetBase, iframeStyle, onReload, onSiteChrome, onNavigate, busy = false, }) => {
54
+ export const PreviewFrame = ({ src, path, data, isLoading, className, style, highlightEnabled, onPreviewError, onMarks, assetBase, iframeStyle, onReload, onSiteChrome, onNavigate, reveal, busy = false, }) => {
55
55
  const iframeRef = useRef(null);
56
56
  // Pin the preview origin from the src prop: outbound messages target it (never '*'),
57
57
  // and inbound messages must come from it. An iframe that navigates cross-origin
@@ -166,6 +166,19 @@ export const PreviewFrame = ({ src, path, data, isLoading, className, style, hig
166
166
  postHighlight();
167
167
  // eslint-disable-next-line react-hooks/exhaustive-deps -- postHighlight is tracked via ref
168
168
  }, [highlightEnabled]);
169
+ // Each frame, a retry's or a new page's, gets the latest reveal once it connects.
170
+ const revealSent = useRef(undefined);
171
+ useEffect(() => {
172
+ const contentWindow = iframeRef.current?.contentWindow;
173
+ if (!reveal || handshake !== 'ready' || !contentWindow || isOpaqueOrigin(previewOrigin))
174
+ return;
175
+ const frame = `${retries}:${src}`;
176
+ if (revealSent.current?.frame === frame && revealSent.current.id === reveal.id)
177
+ return;
178
+ revealSent.current = { frame, id: reveal.id };
179
+ const msg = { type: CANOPY_PREVIEW_REVEAL, path: reveal.path };
180
+ contentWindow.postMessage(msg, previewOrigin);
181
+ }, [reveal, handshake, previewOrigin, retries, src]);
169
182
  // When the preview page's React effects have run and its message listener is ready,
170
183
  // it sends CANOPY_PREVIEW_READY. We respond with the current data so the preview
171
184
  // receives the draft even if it wasn't ready when onLoad fired.
@@ -1,6 +1,6 @@
1
1
  import type { ReactNode } from 'react';
2
2
  import type { PreviewWidth } from './hooks/useEditorLayout.js';
3
- import { type PreviewMarks } from './PreviewFrame.js';
3
+ import { type PreviewMarks, type PreviewReveal } from './PreviewFrame.js';
4
4
  import type { InexactMark } from './preview-marks.js';
5
5
  import type { PaneArrangement } from './shell/PaneLayout.js';
6
6
  import { type PreviewMount } from './preview-path.js';
@@ -54,10 +54,15 @@ export interface PreviewPaneProps {
54
54
  onSiteChromeChange?: (siteChrome: boolean) => void;
55
55
  /** Called with the absolute URL of a link clicked in the preview; see `PreviewFrame`. */
56
56
  onNavigate?: (href: string) => void;
57
+ /** The field the form is on, for the preview to bring into view; see `usePreviewFollow`. */
58
+ reveal?: PreviewReveal;
59
+ /** With `onPreviewFollowsFormChange`, the toolbar shows the follow toggle. */
60
+ previewFollowsForm?: boolean;
61
+ onPreviewFollowsFormChange?: (follows: boolean) => void;
57
62
  }
58
63
  /**
59
64
  * The preview region: a toolbar (path, width, reload, saved version) over a `PreviewFrame`. A
60
65
  * fixed width renders the iframe at that CSS width, so the site's own breakpoints apply, scaled
61
66
  * down to fit a narrower pane.
62
67
  */
63
- export declare const PreviewPane: ({ src, path, pending, mount, previewWidth, onPreviewWidthChange, notice, onHighlightToggle, marksNothing, inexactMarks, showMarkDiagnostics, layout, maximized, onMaximizedChange, resizing, siteChrome, onSiteChromeChange, data, isLoading, onMarks, ...frameProps }: PreviewPaneProps) => import("react/jsx-runtime").JSX.Element;
68
+ export declare const PreviewPane: ({ src, path, pending, mount, previewWidth, onPreviewWidthChange, notice, onHighlightToggle, marksNothing, inexactMarks, showMarkDiagnostics, layout, maximized, onMaximizedChange, resizing, siteChrome, onSiteChromeChange, previewFollowsForm, onPreviewFollowsFormChange, reveal, data, isLoading, onMarks, ...frameProps }: PreviewPaneProps) => import("react/jsx-runtime").JSX.Element;
@@ -4,8 +4,8 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react';
4
4
  import { ActionIcon, Box, Group, Select, Skeleton, Text, Tooltip, UnstyledButton, VisuallyHidden, } from '@mantine/core';
5
5
  import { useClipboard, useElementSize, useWindowEvent } from '@mantine/hooks';
6
6
  import { IconCheck, IconExternalLink, IconRefresh } from '@tabler/icons-react';
7
- import { PreviewFrame } from './PreviewFrame.js';
8
- import { HighlightToggle, MaximizeToggle, SiteChromeToggle, UnmappedMarksChip, } from './PreviewToolbarTools.js';
7
+ import { PreviewFrame, } from './PreviewFrame.js';
8
+ import { FollowFormToggle, HighlightToggle, MaximizeToggle, SiteChromeToggle, UnmappedMarksChip, } from './PreviewToolbarTools.js';
9
9
  import { PANE_HEADER_HEIGHT } from './pane-header.js';
10
10
  import { displayPreviewPath, siteChromeSrc } from './preview-path.js';
11
11
  import { computePreviewViewport, formatPreviewWidthReadout, PREVIEW_DEVICE_WIDTHS, } from './preview-scale.js';
@@ -28,7 +28,7 @@ const OPEN_SAVED_LABEL = 'Open saved version in a new tab';
28
28
  * fixed width renders the iframe at that CSS width, so the site's own breakpoints apply, scaled
29
29
  * down to fit a narrower pane.
30
30
  */
31
- export const PreviewPane = ({ src, path = src, pending = false, mount, previewWidth, onPreviewWidthChange, notice, onHighlightToggle, marksNothing, inexactMarks = [], showMarkDiagnostics = false, layout, maximized = false, onMaximizedChange, resizing = false, siteChrome = false, onSiteChromeChange, data, isLoading, onMarks, ...frameProps }) => {
31
+ export const PreviewPane = ({ src, path = src, pending = false, mount, previewWidth, onPreviewWidthChange, notice, onHighlightToggle, marksNothing, inexactMarks = [], showMarkDiagnostics = false, layout, maximized = false, onMaximizedChange, resizing = false, siteChrome = false, onSiteChromeChange, previewFollowsForm = false, onPreviewFollowsFormChange, reveal, data, isLoading, onMarks, ...frameProps }) => {
32
32
  const { ref: viewportRef, width, height } = useElementSize();
33
33
  const rootRef = useRef(null);
34
34
  const maximizeRef = useRef(null);
@@ -160,13 +160,14 @@ export const PreviewPane = ({ src, path = src, pending = false, mount, previewWi
160
160
  if (isPreviewWidth(value))
161
161
  onPreviewWidthChange(value);
162
162
  }, allowDeselect: false, size: "xs", w: 96, style: { flexShrink: 0 }, styles: { input: { height: 24, minHeight: 24 } }, comboboxProps: { width: 170, position: 'bottom-end' }, renderOption: ({ option, checked }) => (_jsxs(Group, { gap: 6, wrap: "nowrap", w: "100%", children: [_jsx(IconCheck, { "aria-hidden": true, size: 14, style: { visibility: checked ? 'visible' : 'hidden' } }), _jsx("span", { style: { flex: 1 }, children: option.label }), _jsx(Text, { component: "span", size: "xs", children: WIDTH_OPTIONS.find((o) => o.value === option.value)?.detail })] })) }), width > 0 && (_jsx(Text, { size: "xs", c: "dimmed", "data-testid": "preview-width-readout", style: { flexShrink: 0, whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' }, children: formatPreviewWidthReadout(viewport) })), (onHighlightToggle ||
163
+ onPreviewFollowsFormChange ||
163
164
  showChromeToggle ||
164
165
  (showMarkDiagnostics && inexactMarks.length > 0)) && (_jsxs(Group, { gap: 6, wrap: "nowrap", children: [showChromeToggle && onSiteChromeChange && (_jsx(SiteChromeToggle, { enabled: siteChrome, failed: chromeFallback === 'confirmed', onToggle: () => {
165
166
  setChromeFallback('off');
166
167
  onSiteChromeChange(!siteChrome);
167
- } })), onHighlightToggle && (_jsx(HighlightToggle, { enabled: Boolean(frameProps.highlightEnabled), onToggle: onHighlightToggle, marksNothing: marksNothing })), showMarkDiagnostics && _jsx(UnmappedMarksChip, { marks: inexactMarks })] })), _jsxs(Group, { gap: 6, wrap: "nowrap", children: [onMaximizedChange && (_jsx(MaximizeToggle, { maximized: maximized, onChange: onMaximizedChange, maximizeRef: maximizeRef, restoreRef: restoreRef })), _jsx(Tooltip, { label: RELOAD_LABEL, children: _jsx(ActionIcon, { ref: reloadButtonRef, variant: "subtle", size: "md", "aria-label": RELOAD_LABEL, onClick: reload, children: _jsx(IconRefresh, { size: 16 }) }) }), _jsx(Tooltip, { label: OPEN_SAVED_LABEL, children: _jsx(ActionIcon, { component: "a", href: liveSrc, target: "_blank", rel: "noopener noreferrer", variant: "subtle", size: "md", "aria-label": OPEN_SAVED_LABEL, children: _jsx(IconExternalLink, { size: 16 }) }) })] })] }), notice, _jsxs(Box, { ref: viewportRef, pos: "relative", style: { flex: '1 1 auto', minHeight: 0 }, children: [frame ? (_jsx(PreviewFrame, { src: frame.src, path: frame.path, ...frameProps,
168
+ } })), onPreviewFollowsFormChange && (_jsx(FollowFormToggle, { enabled: previewFollowsForm, onToggle: () => onPreviewFollowsFormChange(!previewFollowsForm) })), onHighlightToggle && (_jsx(HighlightToggle, { enabled: Boolean(frameProps.highlightEnabled), onToggle: onHighlightToggle, marksNothing: marksNothing })), showMarkDiagnostics && _jsx(UnmappedMarksChip, { marks: inexactMarks })] })), _jsxs(Group, { gap: 6, wrap: "nowrap", children: [onMaximizedChange && (_jsx(MaximizeToggle, { maximized: maximized, onChange: onMaximizedChange, maximizeRef: maximizeRef, restoreRef: restoreRef })), _jsx(Tooltip, { label: RELOAD_LABEL, children: _jsx(ActionIcon, { ref: reloadButtonRef, variant: "subtle", size: "md", "aria-label": RELOAD_LABEL, onClick: reload, children: _jsx(IconRefresh, { size: 16 }) }) }), _jsx(Tooltip, { label: OPEN_SAVED_LABEL, children: _jsx(ActionIcon, { component: "a", href: liveSrc, target: "_blank", rel: "noopener noreferrer", variant: "subtle", size: "md", "aria-label": OPEN_SAVED_LABEL, children: _jsx(IconExternalLink, { size: 16 }) }) })] })] }), notice, _jsxs(Box, { ref: viewportRef, pos: "relative", style: { flex: '1 1 auto', minHeight: 0 }, children: [frame ? (_jsx(PreviewFrame, { src: frame.src, path: frame.path, ...frameProps,
168
169
  // A held page gets no draft and its reports go nowhere: it belongs to another entry.
169
- data: pending ? undefined : data, isLoading: pending ? undefined : isLoading, onMarks: pending ? undefined : onMarks, onPreviewError: pending ? undefined : frameProps.onPreviewError, busy: pending, onSiteChrome: onSiteChrome, onReload: () => {
170
+ data: pending ? undefined : data, isLoading: pending ? undefined : isLoading, onMarks: pending ? undefined : onMarks, reveal: pending ? undefined : reveal, onPreviewError: pending ? undefined : frameProps.onPreviewError, busy: pending, onSiteChrome: onSiteChrome, onReload: () => {
170
171
  // Retry goes with the frame it sits in; the toolbar's reload offers the same action.
171
172
  reload();
172
173
  reloadButtonRef.current?.focus();
@@ -8,6 +8,12 @@ export interface HighlightToggleProps {
8
8
  }
9
9
  /** Turns preview highlights on and off; says so when the page marks nothing to highlight. */
10
10
  export declare function HighlightToggle({ enabled, onToggle, marksNothing }: HighlightToggleProps): import("react/jsx-runtime").JSX.Element;
11
+ export interface FollowFormToggleProps {
12
+ enabled: boolean;
13
+ onToggle: () => void;
14
+ }
15
+ /** Turns on and off the preview following the form to the field it is on. */
16
+ export declare function FollowFormToggle({ enabled, onToggle }: FollowFormToggleProps): import("react/jsx-runtime").JSX.Element;
11
17
  export interface SiteChromeToggleProps {
12
18
  enabled: boolean;
13
19
  onToggle: () => void;
@@ -2,7 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useId, useRef, useState } from 'react';
4
4
  import { ActionIcon, Button, Group, Popover, Stack, Text, Tooltip, UnstyledButton, VisuallyHidden, } from '@mantine/core';
5
- import { IconArrowsMaximize, IconArrowsMinimize, IconLayoutNavbar, IconMarquee, } from '@tabler/icons-react';
5
+ import { IconArrowsMaximize, IconArrowsMinimize, IconFocusCentered, IconLayoutNavbar, IconMarquee, } from '@tabler/icons-react';
6
6
  const HIGHLIGHT_LABEL = 'Highlight fields in the preview';
7
7
  const NOTHING_MARKED_NOTE = "This page marks no editable elements, so nothing can be highlighted or clicked to focus. The site's developer can mark them with fieldProps.";
8
8
  /** Turns preview highlights on and off; says so when the page marks nothing to highlight. */
@@ -11,6 +11,11 @@ export function HighlightToggle({ enabled, onToggle, marksNothing = false }) {
11
11
  const showNote = enabled && marksNothing;
12
12
  return (_jsxs(Group, { gap: 4, wrap: "nowrap", children: [_jsx(Tooltip, { label: HIGHLIGHT_LABEL, children: _jsx(ActionIcon, { variant: enabled ? 'filled' : 'subtle', color: enabled ? 'brand' : 'gray', size: "md", "aria-pressed": enabled, "aria-label": HIGHLIGHT_LABEL, "aria-describedby": showNote ? noteId : undefined, "data-testid": "toggle-highlights-button", onClick: onToggle, children: _jsx(IconMarquee, { size: 16 }) }) }), showNote && (_jsx(Tooltip, { label: NOTHING_MARKED_NOTE, multiline: true, w: 260, children: _jsxs(Text, { size: "xs", c: "dimmed", style: { whiteSpace: 'nowrap' }, children: ["Nothing to highlight", _jsx(VisuallyHidden, { id: noteId, children: NOTHING_MARKED_NOTE })] }) }))] }));
13
13
  }
14
+ const FOLLOW_FORM_LABEL = "Scroll the preview to the field you're on";
15
+ /** Turns on and off the preview following the form to the field it is on. */
16
+ export function FollowFormToggle({ enabled, onToggle }) {
17
+ return (_jsx(Tooltip, { label: FOLLOW_FORM_LABEL, children: _jsx(ActionIcon, { variant: enabled ? 'filled' : 'subtle', color: enabled ? 'brand' : 'gray', size: "md", "aria-pressed": enabled, "aria-label": FOLLOW_FORM_LABEL, "data-testid": "toggle-follow-form-button", onClick: onToggle, children: _jsx(IconFocusCentered, { size: 16 }) }) }));
18
+ }
14
19
  const SITE_CHROME_LABEL = 'Show site header and footer';
15
20
  const SITE_CHROME_FAILED_NOTE = "The site couldn't show its header and footer, so the preview shows pages without them. Turn this off and on, or reload the preview, to try again.";
16
21
  /** Shows and hides the site's header and footer; says so when the site couldn't show them. */
@@ -9,14 +9,15 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
9
9
  * only for admins (see isAdmin(userContext?.groups)); this component does not
10
10
  * re-check that itself, same as GroupManager and PermissionManager.
11
11
  */
12
- import { useRef } from 'react';
13
- import { Alert, Badge, Button, Code, Group, Loader, Modal, Paper, SegmentedControl, SimpleGrid, Spoiler, Stack, Table, Tabs, Text, ThemeIcon, Tooltip, } from '@mantine/core';
12
+ import { useRef, useState } from 'react';
13
+ import { Alert, Badge, Button, Code, Group, Loader, Modal, NumberInput, Paper, SegmentedControl, SimpleGrid, Spoiler, Stack, Table, Tabs, Text, ThemeIcon, Tooltip, } from '@mantine/core';
14
14
  import { IconAlertCircle, IconAlertTriangle } from '@tabler/icons-react';
15
15
  import { openConfirm } from '../utils/confirm-modal.js';
16
16
  import { focusReturnProps } from '../utils/focus-return.js';
17
17
  import { formatRelativeTime } from '../relative-time.js';
18
18
  import { EDITOR_ACTIONS } from '../copy.js';
19
19
  import { useSystemHealth, } from './useSystemHealth.js';
20
+ import { ageDeletionHold, archivedBefore, archivedCutoff, deletableByAge, } from '../../branch-archive.js';
20
21
  import { escapeForLog } from '../../utils/escape-text.js';
21
22
  import { TooltipDetail } from '../components/TooltipDetail.js';
22
23
  import { DISMISS_UNPUBLISHED_ATTRIBUTE, UnpublishedAtMergeAlert } from './UnpublishedAtMergeAlert.js';
@@ -256,6 +257,7 @@ function duplicateScanSummary(health, healthyDirNames) {
256
257
  return { text: 'No duplicate content IDs found.', color: 'dimmed' };
257
258
  return { text: parts.join(' '), color: 'orange' };
258
259
  }
260
+ const DEFAULT_CLEANUP_DAYS = 90;
259
261
  const PURGE_CONFIRM_TEXT = 'The directory is moved to a hidden trash name and kept for 30 days, then deleted. Any git work inside was never pushed and will be lost when the trash is swept.';
260
262
  export function SystemHealthPanel({ opened, onClose, returnFocusTo }) {
261
263
  const health = useSystemHealth({ isOpen: opened });
@@ -348,6 +350,55 @@ function TasksTab({ health }) {
348
350
  ? corruptRows.map((file) => (_jsxs(Table.Tr, { children: [_jsx(Table.Td, { children: _jsx("span", { title: file.fileName, children: truncate(file.fileName, 30) }) }), _jsxs(Table.Td, { children: [file.size, " bytes"] }), _jsx(Table.Td, { children: file.mtime }), _jsx(Table.Td, { children: _jsx(Code, { block: true, style: { maxWidth: 320, whiteSpace: 'pre-wrap' }, children: file.rawSnippet }) }), _jsx(Table.Td, { children: _jsx(Button, { variant: "light", color: "red", "data-testid": `delete-task-${file.fileName}`, onClick: () => handleDeleteClick('corrupt', file.fileName), children: "Delete" }) })] }, file.fileName)))
349
351
  : taskRows.map((task) => (_jsxs(Table.Tr, { children: [_jsx(Table.Td, { children: _jsx("span", { title: task.id, children: truncate(task.id, 12) }) }), _jsx(Table.Td, { children: task.action }), _jsx(Table.Td, { children: task.createdAt }), _jsx(Table.Td, { children: task.retryCount ?? 0 }), _jsx(Table.Td, { children: task.error ? (_jsx(Tooltip, { label: task.error, multiline: true, maw: 400, children: _jsx(Text, { size: "xs", style: { cursor: 'help' }, children: truncate(task.error, 50) }) })) : (_jsx(Text, { size: "xs", c: "dimmed", children: "\u2014" })) }), _jsx(Table.Td, { children: _jsxs(Group, { gap: "xs", children: [taskStatus === 'failed' && (_jsx(Button, { variant: "light", "data-testid": `retry-task-${task.id}`, onClick: () => handleRetryClick(task), children: "Retry" })), canDelete && (_jsx(Button, { variant: "light", color: "red", "data-testid": `delete-task-${task.id}`, onClick: () => handleDeleteClick(taskStatus, `${task.id}.json`), children: "Delete" }))] }) })] }, task.id))) })] }) }))] }));
350
352
  }
353
+ /** What `lastGitSync.archivedSweep` left undone, or nothing when it finished clean. */
354
+ const sweepTrouble = ({ failed = [], warnings = [] } = {}) => [
355
+ failed.length === 0
356
+ ? ''
357
+ : ` The last sweep could not delete ${failed.map((f) => f.branch).join(', ')} and tries again next sync.`,
358
+ ...warnings.map((warning) => ` ${endSentence(warning)}`),
359
+ ].join('');
360
+ const dayCount = (days) => `${days} ${days === 1 ? 'day' : 'days'}`;
361
+ /** The deployment's retention, and the admin's own delete by the same rule. */
362
+ function ArchivedBranchesCleanup({ health }) {
363
+ const [days, setDays] = useState(DEFAULT_CLEANUP_DAYS);
364
+ const workerStatus = health.status?.workerStatus;
365
+ const generatedAt = health.branchHealth?.generatedAt;
366
+ const olderThanDays = typeof days === 'number' && Number.isInteger(days) && days > 0 ? days : null;
367
+ // The scan's own clock, which keeps this render pure and matches the listing it counts.
368
+ const cutoff = olderThanDays !== null && generatedAt
369
+ ? archivedCutoff(olderThanDays, Date.parse(generatedAt))
370
+ : null;
371
+ const candidates = (health.branchHealth?.entries ?? []).filter((e) => !e.isBaseBranch);
372
+ const branchesMatching = (matches) => cutoff === null
373
+ ? []
374
+ : candidates.flatMap((e) => (e.branch && matches(e.branch, cutoff) ? [e.branch] : []));
375
+ const due = branchesMatching(deletableByAge);
376
+ const old = branchesMatching(archivedBefore);
377
+ const heldFor = (hold) => old.filter((branch) => ageDeletionHold(branch) === hold).map((branch) => branch.name);
378
+ const notMerged = heldFor('not-merged');
379
+ const unpublished = heldFor('unpublished');
380
+ const anyDeletable = candidates.some((e) => e.branch && deletableByAge(e.branch, Number.POSITIVE_INFINITY));
381
+ const dueLabel = `${due.length} archived ${due.length === 1 ? 'branch' : 'branches'}`;
382
+ const blockedReason = olderThanDays === null
383
+ ? 'Enter a whole number of days.'
384
+ : due.length === 0
385
+ ? `No merged archived branches are older than ${dayCount(olderThanDays)}.${olderThanDays > 1 && anyDeletable ? ' Lower the number of days to include newer ones.' : ''}${old.length > 0 ? ' Older branches kept from this delete go one at a time, from the "Branches" drawer.' : ''}`
386
+ : undefined;
387
+ const handleDeleteClick = () => {
388
+ if (blockedReason || olderThanDays === null)
389
+ return;
390
+ openConfirm({
391
+ title: 'Delete archived branches',
392
+ children: (_jsxs(Stack, { gap: "xs", children: [_jsxs(Text, { size: "sm", children: ["Delete ", dueLabel, " and their comments from the editor? Their GitHub branches stay. This cannot be undone."] }), _jsx(Text, { size: "xs", "data-testid": "delete-archived-names", children: due.map((branch) => branch.name).join(', ') }), notMerged.length > 0 && (_jsxs(Text, { size: "xs", "data-testid": "delete-archived-not-merged", children: ["Kept, as they were not merged: ", notMerged.join(', '), "."] })), unpublished.length > 0 && (_jsxs(Text, { size: "xs", "data-testid": "delete-archived-unpublished", children: ["Kept, as they may hold changes missing from GitHub: ", unpublished.join(', '), "."] }))] })),
393
+ labels: { confirm: 'Delete branches', cancel: 'Cancel' },
394
+ confirmProps: { color: 'red', 'data-testid': 'confirm-delete-archived' },
395
+ onConfirm: () => health.deleteArchivedBranches(olderThanDays, due.map((branch) => branch.name)),
396
+ });
397
+ };
398
+ return (_jsxs(Stack, { gap: 4, mb: "sm", "data-testid": "archived-branches-cleanup", children: [_jsx(Text, { size: "sm", fw: 600, children: "Archived branches" }), _jsxs(Group, { align: "flex-end", gap: "xs", children: [_jsx(NumberInput, { label: "Delete merged archived branches older than", suffix: days === 1 ? ' day' : ' days', size: "xs", w: 280, min: 1, allowDecimal: false, allowNegative: false, value: days, onChange: setDays, "data-testid": "archived-days-input" }), _jsx(Tooltip, { label: blockedReason, disabled: !blockedReason, multiline: true, maw: 280, position: "top-end", children: _jsxs(Button, { size: "xs", variant: "light", color: "red", onClick: handleDeleteClick, loading: health.deletingArchivedBranches, "data-disabled": blockedReason ? true : undefined, "aria-disabled": blockedReason ? true : undefined, "data-testid": "delete-archived-button", children: ["Delete ", blockedReason ? 'archived branches' : dueLabel] }) })] }), workerStatus?.lastGitSync && (_jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "archived-retention-note", children: [workerStatus.archivedBranchRetentionDays === undefined
399
+ ? 'This deployment keeps archived branches until someone deletes them.'
400
+ : `This deployment deletes merged branches ${dayCount(workerStatus.archivedBranchRetentionDays)} after archiving.`, sweepTrouble(workerStatus.lastGitSync?.archivedSweep)] }))] }));
401
+ }
351
402
  function BranchesTab({ health }) {
352
403
  const { branchHealth, branchHealthLoading } = health;
353
404
  const entries = branchHealth?.entries ?? [];
@@ -399,7 +450,7 @@ function BranchesTab({ health }) {
399
450
  if (entries.length === 0) {
400
451
  return (_jsx(Text, { size: "sm", c: "dimmed", py: "md", children: "No branch directories found." }));
401
452
  }
402
- return (_jsxs(Stack, { gap: "xs", children: [_jsxs(Group, { justify: "space-between", align: "center", wrap: "nowrap", children: [_jsx(Text, { size: "xs", c: duplicateSummary.color, "data-testid": "duplicate-id-summary", children: duplicateSummary.text }), _jsx(Button, { size: "compact-xs", variant: "subtle", loading: health.duplicateIdScanLoading, onClick: () => health.checkDuplicateIds(), children: "Check again" })] }), _jsx(Table.ScrollContainer, { minWidth: 700, children: _jsxs(Table, { children: [_jsx(Table.Thead, { children: _jsxs(Table.Tr, { children: [_jsx(Table.Th, { children: "Name" }), _jsx(Table.Th, { children: "Status" }), _jsx(Table.Th, { children: "PR" }), _jsx(Table.Th, { children: "Sync" }), _jsx(Table.Th, { children: "Warnings" }), _jsx(Table.Th, { children: "Updated" }), _jsx(Table.Th, { children: "Actions" })] }) }), _jsx(Table.Tbody, { children: entries.map((entry) => (_jsx(BranchHealthRow, { entry: entry, baseWarning: entry.isBaseBranch ? baseWarning : null, duplicateIdScan: health.duplicateIdScan
453
+ return (_jsxs(Stack, { gap: "xs", children: [_jsx(ArchivedBranchesCleanup, { health: health }), _jsxs(Group, { justify: "space-between", align: "center", wrap: "nowrap", children: [_jsx(Text, { size: "xs", c: duplicateSummary.color, "data-testid": "duplicate-id-summary", children: duplicateSummary.text }), _jsx(Button, { size: "compact-xs", variant: "subtle", loading: health.duplicateIdScanLoading, onClick: () => health.checkDuplicateIds(), children: "Check again" })] }), _jsx(Table.ScrollContainer, { minWidth: 700, children: _jsxs(Table, { children: [_jsx(Table.Thead, { children: _jsxs(Table.Tr, { children: [_jsx(Table.Th, { children: "Name" }), _jsx(Table.Th, { children: "Status" }), _jsx(Table.Th, { children: "PR" }), _jsx(Table.Th, { children: "Sync" }), _jsx(Table.Th, { children: "Warnings" }), _jsx(Table.Th, { children: "Updated" }), _jsx(Table.Th, { children: "Actions" })] }) }), _jsx(Table.Tbody, { children: entries.map((entry) => (_jsx(BranchHealthRow, { entry: entry, baseWarning: entry.isBaseBranch ? baseWarning : null, duplicateIdScan: health.duplicateIdScan
403
454
  ? (health.duplicateIdScan.byDir[entry.dirName] ?? null)
404
455
  : undefined, onMarkMerged: handleMarkMergedClick, onRepair: handleRepairClick, onPurge: handlePurgeClick, onDuplicateIds: handleDuplicateIdsClick }, entry.dirName))) })] }) })] }));
405
456
  }
@@ -419,7 +470,7 @@ const firstFailed = (iso) => {
419
470
  function BranchHealthRow({ entry, baseWarning, duplicateIdScan, onMarkMerged, onRepair, onPurge, onDuplicateIds, }) {
420
471
  if (entry.kind === 'healthy' && entry.branch) {
421
472
  const b = entry.branch;
422
- // The worker looks at every branch but 'archived' (already merged): one in review is not
473
+ // The worker looks at every branch but 'archived' (finished): one in review is not
423
474
  // rebased, but commits from GitHub are still brought into it. An exclusion, not a list of
424
475
  // statuses, so a status added later still shows its failures by default.
425
476
  const showRebaseFailure = b.status !== 'archived' && !!b.rebaseFailure;
@@ -429,7 +480,9 @@ function BranchHealthRow({ entry, baseWarning, duplicateIdScan, onMarkMerged, on
429
480
  : !inReview
430
481
  ? b.unpublishedAtMerge
431
482
  ? { kind: 'unpublished-at-merge' }
432
- : undefined
483
+ : b.status === 'archived' && b.rewrittenOnGitHub !== undefined
484
+ ? { kind: 'rewritten-at-merge', commit: b.rewrittenOnGitHub }
485
+ : undefined
433
486
  : b.submitUnfinished
434
487
  ? {
435
488
  kind: 'submit-unfinished',
@@ -437,7 +490,9 @@ function BranchHealthRow({ entry, baseWarning, duplicateIdScan, onMarkMerged, on
437
490
  notQueued: b.submitNotQueued,
438
491
  }
439
492
  : b.pullRequestHold;
440
- const canMarkMerged = (b.status === 'submitted' || b.status === 'approved') && !!b.pullRequestNumber;
493
+ const canMarkMerged = (b.status === 'submitted' || b.status === 'approved') &&
494
+ !!b.pullRequestNumber &&
495
+ b.pullRequestState !== 'closed';
441
496
  return (_jsxs(Table.Tr, { children: [_jsx(Table.Td, { children: _jsxs(Group, { gap: 4, wrap: "nowrap", children: [_jsx(Text, { size: "sm", children: entry.dirName }), entry.isBaseBranch && (_jsx(Badge, { size: "xs", color: "gray", variant: "outline", children: "Base" }))] }) }), _jsx(Table.Td, { children: _jsx(Badge, { color: branchStatusPresentation(b.status)?.color ?? 'neutral', variant: "light", children: branchStatusPresentation(b.status)?.label }) }), _jsx(Table.Td, { children: b.pullRequestNumber ? (_jsxs(Text, { size: "xs", c: "blue", component: "a", href: b.pullRequestUrl, target: "_blank", rel: "noopener noreferrer", style: { textDecoration: 'underline' }, children: ["PR #", b.pullRequestNumber, b.pullRequestState ? ` (${b.pullRequestState})` : ''] })) : (_jsx(Text, { size: "xs", c: "dimmed", children: "\u2014" })) }), _jsx(Table.Td, { children: b.syncStatus === 'sync-failed' ? (_jsx(TooltipDetail, { preLine: true, detail: syncFailedDetail(b.syncFailureReason, hold?.kind !== 'rewritten' &&
442
497
  b.status !== 'archived' &&
443
498
  !(b.submitUnfinished && b.submitNotQueued)), children: _jsx(Badge, { color: "red", variant: "light", children: "Sync failed" }) })) : b.syncStatus === 'pending-sync' ? (_jsx(Badge, { color: "gray", variant: "light", children: "Pending sync" })) : null }), _jsx(Table.Td, { children: _jsxs(Group, { gap: 6, children: [!!b.conflictFiles?.length && (_jsxs(Badge, { color: "orange", variant: "light", children: [b.conflictFiles.length, " conflict", b.conflictFiles.length === 1 ? '' : 's'] })), hold && (_jsx(TooltipDetail, { detail: branchHoldMessage(hold, {
@@ -51,6 +51,9 @@ export interface UseSystemHealthReturn {
51
51
  purgeDir: (dirName: string) => Promise<void>;
52
52
  repairDir: (dirName: string) => Promise<void>;
53
53
  markMerged: (branchName: string) => Promise<void>;
54
+ /** Deletes those of `branches` still past `olderThanDays`, then says what it could not. */
55
+ deleteArchivedBranches: (olderThanDays: number, branches: string[]) => Promise<void>;
56
+ deletingArchivedBranches: boolean;
54
57
  /** Stops listing a branch merged with an unfinished submit, a warning that doesn't come back. */
55
58
  dismissUnpublished: (dirName: string) => Promise<void>;
56
59
  }
@@ -16,7 +16,16 @@ import { useSWRConfig } from 'swr';
16
16
  import { notifications } from '@mantine/notifications';
17
17
  import { useApiClient } from '../context/index.js';
18
18
  import { BRANCHES_KEY } from '../hooks/useBranchesData.js';
19
+ import { isGatewayFailure, isUnanswered, UnansweredError } from '../unanswered.js';
19
20
  const POLL_INTERVAL_MS = 30_000;
21
+ const EMPTY_DELETE = {
22
+ deleted: [],
23
+ skipped: { notMerged: [], unpublished: [] },
24
+ failed: [],
25
+ remaining: 0,
26
+ warnings: [],
27
+ };
28
+ const countBranches = (count) => `${count} archived ${count === 1 ? 'branch' : 'branches'}`;
20
29
  /**
21
30
  * Crash-loop rider window: a worker that fatally exits and restarts
22
31
  * repeatedly can flap between 'absent' and 'alive' and never sit still long
@@ -38,6 +47,7 @@ export function useSystemHealth(options) {
38
47
  const [duplicateIdScan, setDuplicateIdScan] = useState(null);
39
48
  const [duplicateIdScanLoading, setDuplicateIdScanLoading] = useState(false);
40
49
  const [duplicateIdScanError, setDuplicateIdScanError] = useState(null);
50
+ const [deletingArchivedBranches, setDeletingArchivedBranches] = useState(false);
41
51
  const [error, setError] = useState(null);
42
52
  // refresh() has no status argument, so it needs the CURRENT taskStatus
43
53
  // without depending on it (that would tear down/rebuild the poll interval
@@ -266,6 +276,53 @@ export function useSystemHealth(options) {
266
276
  notifications.show({ message, color: 'red' });
267
277
  }
268
278
  }, [apiClient, refresh]);
279
+ const deleteArchivedBranches = useCallback(async (olderThanDays, branches) => {
280
+ setDeletingArchivedBranches(true);
281
+ try {
282
+ const result = await apiClient.admin.deleteArchivedBranches({ olderThanDays, branches });
283
+ if (isGatewayFailure(result))
284
+ throw new UnansweredError();
285
+ if (!result.ok)
286
+ throw new Error(result.error || 'Failed to delete archived branches');
287
+ const { deleted, skipped, failed, remaining, warnings } = result.data ?? EMPTY_DELETE;
288
+ const left = [
289
+ failed.length > 0
290
+ ? `${countBranches(failed.length)} not deleted: busy, or changed since listed`
291
+ : '',
292
+ remaining > 0 ? `${countBranches(remaining)} not reached, run this again` : '',
293
+ skipped.notMerged.length > 0
294
+ ? `${countBranches(skipped.notMerged.length)} kept: not merged`
295
+ : '',
296
+ skipped.unpublished.length > 0
297
+ ? `${countBranches(skipped.unpublished.length)} kept: may hold unpublished changes`
298
+ : '',
299
+ ...warnings,
300
+ ].filter(Boolean);
301
+ const message = `Deleted ${countBranches(deleted.length)}`;
302
+ notifications.show(left.length > 0
303
+ ? { message: `${message}. ${left.join('. ')}.`, color: 'yellow', autoClose: false }
304
+ : { message, color: 'green' });
305
+ await mutate(BRANCHES_KEY);
306
+ await refresh();
307
+ }
308
+ catch (err) {
309
+ if (isUnanswered(err)) {
310
+ notifications.show({
311
+ message: 'Deleting archived branches may have gone through even though the server didn\'t answer. Check the "Branches" tab.',
312
+ color: 'yellow',
313
+ autoClose: false,
314
+ });
315
+ await mutate(BRANCHES_KEY);
316
+ await refresh();
317
+ return;
318
+ }
319
+ const message = err instanceof Error ? err.message : 'Failed to delete archived branches';
320
+ notifications.show({ message, color: 'red', autoClose: false });
321
+ }
322
+ finally {
323
+ setDeletingArchivedBranches(false);
324
+ }
325
+ }, [apiClient, mutate, refresh]);
269
326
  return {
270
327
  status,
271
328
  statusLoading,
@@ -288,6 +345,8 @@ export function useSystemHealth(options) {
288
345
  purgeDir,
289
346
  repairDir,
290
347
  markMerged,
348
+ deleteArchivedBranches,
349
+ deletingArchivedBranches,
291
350
  dismissUnpublished,
292
351
  };
293
352
  }
@@ -77,6 +77,15 @@ export function branchHoldMessage(hold, viewer) {
77
77
  : '';
78
78
  return `${stuck} ${retry}${instead}.`;
79
79
  }
80
+ case 'rewritten-at-merge': {
81
+ const commit = hold.commit
82
+ ? ` GitHub no longer contains commit ${escapeForLog(hold.commit)}.`
83
+ : '';
84
+ const stuck = `This branch was changed on GitHub before it was archived, so some changes here may be missing from GitHub.${commit}`;
85
+ return viewer.isAdmin
86
+ ? `${stuck} Check them before deleting the branch.`
87
+ : `${stuck} Ask an admin to check it.`;
88
+ }
80
89
  case 'unpublished-at-merge': {
81
90
  const stuck = 'This pull request merged before the last submit finished, so some changes may not be in the merge.';
82
91
  if (viewer.inSystemHealth)
@@ -33,6 +33,8 @@ export interface CommentThreadsPopoverProps {
33
33
  * Shown before anyone types; the box stays usable, so a draft survives until it can be posted.
34
34
  */
35
35
  composeBlockedReason?: string;
36
+ /** Called each time the popover opens, by any route. */
37
+ onOpened?: () => void;
36
38
  testId: string;
37
39
  }
38
40
  export interface OpenRequest {
@@ -24,7 +24,7 @@ const openThreadsLabel = (count) => count === 1 ? '1 open thread' : `${count} op
24
24
  * with reply and Resolve, and a box for a new thread. Comments stay open on a read-only form,
25
25
  * since reviewers comment on submitted branches.
26
26
  */
27
- export const CommentThreadsPopover = ({ threads, subject, variant, revealed = false, anchors, openRequest, currentUserId, canResolve, onAddComment, onResolveThread, onGetUserMetadata, highlightThreadId, threadFieldLabel, composeBlockedReason, testId, }) => {
27
+ export const CommentThreadsPopover = ({ threads, subject, variant, revealed = false, anchors, openRequest, currentUserId, canResolve, onAddComment, onResolveThread, onGetUserMetadata, highlightThreadId, threadFieldLabel, composeBlockedReason, onOpened, testId, }) => {
28
28
  const [opened, setOpened] = useState(false);
29
29
  const [composing, setComposing] = useState(false);
30
30
  // Drafts live here, not in the dropdown, which unmounts whenever the popover closes.
@@ -139,6 +139,14 @@ export const CommentThreadsPopover = ({ threads, subject, variant, revealed = fa
139
139
  }, 20);
140
140
  return () => window.clearTimeout(timer);
141
141
  }, [opened]);
142
+ const onOpenedRef = useRef(onOpened);
143
+ useEffect(() => {
144
+ onOpenedRef.current = onOpened;
145
+ });
146
+ useEffect(() => {
147
+ if (opened)
148
+ onOpenedRef.current?.();
149
+ }, [opened]);
142
150
  useEffect(() => {
143
151
  if (!flashThreadId)
144
152
  return;