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.
- package/CHANGELOG.md +74 -48
- package/dist/api/admin.d.ts +11 -0
- package/dist/api/admin.js +2 -0
- package/dist/api/archived-branches.d.ts +70 -0
- package/dist/api/archived-branches.js +146 -0
- package/dist/api/branch-merge.js +16 -1
- package/dist/api/branch-status.d.ts +11 -0
- package/dist/api/branch-status.js +2 -0
- package/dist/api/branch-withdraw.js +1 -1
- package/dist/api/branch.js +46 -108
- package/dist/api/client.d.ts +25 -21
- package/dist/api/client.js +46 -38
- package/dist/branch-archive.d.ts +19 -0
- package/dist/branch-archive.js +27 -0
- package/dist/branch-delete.d.ts +45 -0
- package/dist/branch-delete.js +97 -0
- package/dist/branch-metadata-file.d.ts +3 -0
- package/dist/branch-metadata-file.js +1 -0
- package/dist/branch-metadata.d.ts +6 -1
- package/dist/branch-metadata.js +15 -2
- package/dist/cli/cli.js +2 -0
- package/dist/cli/generate-ai-content.js +1 -0
- package/dist/editor/BranchManager.d.ts +20 -0
- package/dist/editor/BranchManager.js +92 -26
- package/dist/editor/Editor.js +49 -25
- package/dist/editor/PreviewFrame.d.ts +8 -1
- package/dist/editor/PreviewFrame.js +15 -2
- package/dist/editor/PreviewPane.d.ts +7 -2
- package/dist/editor/PreviewPane.js +6 -5
- package/dist/editor/PreviewToolbarTools.d.ts +6 -0
- package/dist/editor/PreviewToolbarTools.js +6 -1
- package/dist/editor/admin/SystemHealthPanel.js +61 -6
- package/dist/editor/admin/useSystemHealth.d.ts +3 -0
- package/dist/editor/admin/useSystemHealth.js +59 -0
- package/dist/editor/branch-status.js +9 -0
- package/dist/editor/comments/CommentThreadsPopover.d.ts +2 -0
- package/dist/editor/comments/CommentThreadsPopover.js +9 -1
- package/dist/editor/comments/FieldWrapper.js +3 -1
- package/dist/editor/copy.d.ts +3 -0
- package/dist/editor/copy.js +3 -0
- package/dist/editor/hooks/useBranchManager.d.ts +5 -0
- package/dist/editor/hooks/useBranchManager.js +55 -4
- package/dist/editor/hooks/useBranchesData.d.ts +2 -0
- package/dist/editor/hooks/useBranchesData.js +3 -1
- package/dist/editor/hooks/useEditorLayout.d.ts +4 -0
- package/dist/editor/hooks/useEditorLayout.js +5 -0
- package/dist/editor/hooks/usePreviewFollow.d.ts +26 -0
- package/dist/editor/hooks/usePreviewFollow.js +54 -0
- package/dist/editor/preview-bridge.d.ts +25 -0
- package/dist/editor/preview-bridge.js +175 -0
- package/dist/preview.d.ts +2 -2
- package/dist/preview.js +1 -1
- package/dist/types.d.ts +33 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/archived-sweep.d.ts +23 -0
- package/dist/worker/archived-sweep.js +137 -0
- package/dist/worker/cms-worker.d.ts +6 -0
- package/dist/worker/cms-worker.js +7 -0
- package/dist/worker/git-sync.d.ts +4 -4
- package/dist/worker/git-sync.js +3 -0
- package/dist/worker/github-commits.d.ts +2 -2
- package/dist/worker/github-commits.js +5 -2
- package/dist/worker/github-gateway-wire.d.ts +2 -2
- package/dist/worker/rebase.js +6 -2
- package/dist/worker/task-runner.js +7 -4
- package/dist/worker/worker-context.d.ts +2 -0
- package/package.json +1 -1
package/dist/editor/Editor.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
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' (
|
|
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') &&
|
|
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;
|