canopycms 0.8.3 → 0.10.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 +73 -47
- package/dist/ai/json-to-markdown.js +2 -0
- 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-entry-changes.js +2 -4
- 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 +85 -16
- package/dist/cli/generate-ai-content.js +83 -15
- package/dist/cli/init.js +1 -1
- package/dist/config/types.d.ts +4 -4
- package/dist/config/types.js +1 -1
- package/dist/config/validation.d.ts +9 -3
- package/dist/config/validation.js +30 -5
- package/dist/content-listing.js +2 -3
- package/dist/content-store.js +5 -5
- package/dist/editor/BranchManager.d.ts +20 -0
- package/dist/editor/BranchManager.js +92 -26
- package/dist/editor/Editor.js +5 -1
- package/dist/editor/FormRenderer.js +5 -0
- 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/components/EntryChangesDrawer.js +3 -2
- package/dist/editor/copy.d.ts +3 -0
- package/dist/editor/copy.js +3 -0
- package/dist/editor/entry-diff.js +7 -1
- package/dist/editor/fields/DateField.d.ts +19 -0
- package/dist/editor/fields/DateField.js +53 -0
- package/dist/editor/fields/DateTimeField.d.ts +10 -0
- package/dist/editor/fields/DateTimeField.js +5 -15
- package/dist/editor/fields/list-item-title.js +2 -1
- 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/theme.js +12 -4
- package/dist/entry-schema-registry.js +2 -1
- package/dist/static/index.js +5 -2
- package/dist/types.d.ts +33 -0
- package/dist/utils/calendar-day.d.ts +23 -0
- package/dist/utils/calendar-day.js +102 -0
- package/dist/utils/content-serialize.d.ts +4 -2
- package/dist/utils/content-serialize.js +8 -6
- package/dist/validation/entry-validator.js +7 -1
- package/dist/validation/field-traversal.js +1 -1
- 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
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { useEffect, useRef, useState } from 'react';
|
|
4
|
-
import { ActionIcon, Anchor, Badge, Button, Group, Menu, Paper, ScrollArea, Stack, Text, TextInput, Textarea, Collapse, Tooltip, } from '@mantine/core';
|
|
5
|
-
import { IconDots, IconExternalLink } from '@tabler/icons-react';
|
|
4
|
+
import { ActionIcon, Anchor, Badge, Button, CloseButton, Group, Menu, Paper, ScrollArea, Stack, Text, TextInput, Textarea, Collapse, Tooltip, UnstyledButton, VisuallyHidden, } from '@mantine/core';
|
|
5
|
+
import { IconChevronDown, IconChevronRight, IconDots, IconExternalLink } from '@tabler/icons-react';
|
|
6
6
|
import { BranchComments } from './comments/BranchComments.js';
|
|
7
7
|
import { UserBadge } from './components/UserBadge.js';
|
|
8
8
|
import { BranchAccessList } from './components/BranchAccessList.js';
|
|
@@ -16,7 +16,7 @@ import { RelativeTime } from './components/RelativeTime.js';
|
|
|
16
16
|
import { HOLD_BADGE, SYNC_FAILED_IN_REVIEW_NOTE, branchHoldMessage, endSentence, syncFailedFallback, branchStatusPresentation, } from './branch-status.js';
|
|
17
17
|
import { TooltipDetail } from './components/TooltipDetail.js';
|
|
18
18
|
import { branchAddress, displayBranchName } from './branch-names.js';
|
|
19
|
-
import { CLOSED_PULL_REQUEST_REASON, EDITOR_ACTIONS, rewrittenSubmitReason, UNFINISHED_SUBMIT_APPROVE_REASON, } from './copy.js';
|
|
19
|
+
import { CLOSED_PULL_REQUEST_REASON, DELETE_NEEDS_ADMIN_REASON, EDITOR_ACTIONS, rewrittenSubmitReason, UNFINISHED_SUBMIT_APPROVE_REASON, } from './copy.js';
|
|
20
20
|
import { isReviewerOfOthersReview } from '../authorization/review-role.js';
|
|
21
21
|
import { SYSTEM_USER_ID } from '../system-users.js';
|
|
22
22
|
/**
|
|
@@ -149,7 +149,7 @@ const WRAP_ANYWHERE = { overflowWrap: 'anywhere' };
|
|
|
149
149
|
* reviewer Review, which opens it; the other cards show Open, which the current one never needs.
|
|
150
150
|
* No action is shown twice.
|
|
151
151
|
*/
|
|
152
|
-
const BranchCard = ({ branch: b, isCurrent, workflowBlockedReason, user, onSelect, onSubmit, onWithdraw, onApprove, onRequestChanges, onDelete, onGetUserMetadata, onGetOwnerMetadata = onGetUserMetadata, }) => {
|
|
152
|
+
const BranchCard = ({ branch: b, isCurrent, workflowBlockedReason, user, onSelect, onSubmit, onWithdraw, onApprove, onRequestChanges, onArchive, onDelete, onGetUserMetadata, onGetOwnerMetadata = onGetUserMetadata, }) => {
|
|
153
153
|
const statusBadge = branchStatusPresentation(b.status, b.isProtected);
|
|
154
154
|
const perms = getBranchPermissions(b, user);
|
|
155
155
|
const userIsAdmin = isAdmin(user?.groups);
|
|
@@ -195,7 +195,15 @@ const BranchCard = ({ branch: b, isCurrent, workflowBlockedReason, user, onSelec
|
|
|
195
195
|
// crowd the details at the drawer's width, so they take a row below. Open and Review stay beside
|
|
196
196
|
// the title.
|
|
197
197
|
const actionRow = !!secondary || primary?.label === EDITOR_ACTIONS.withdrawFromReview || (!!primary && !!b.hold);
|
|
198
|
-
const
|
|
198
|
+
const archive = heldBack(userIsAdmin && b.status !== 'archived' && b.pullRequestState === 'closed'
|
|
199
|
+
? {
|
|
200
|
+
label: EDITOR_ACTIONS.archiveBranch,
|
|
201
|
+
color: 'orange',
|
|
202
|
+
testId: `archive-branch-button-${b.name}`,
|
|
203
|
+
run: () => onArchive?.(b.name),
|
|
204
|
+
}
|
|
205
|
+
: undefined);
|
|
206
|
+
const menuActions = [approve, requestChanges, workflow, archive].filter((a) => !!a && a !== primary && a !== secondary);
|
|
199
207
|
const showViewPullRequest = !!b.pullRequestUrl;
|
|
200
208
|
const showDelete = !b.isProtected && (userIsAdmin || (!!user && b.createdBy === user.userId));
|
|
201
209
|
const hasMenu = menuActions.length > 0 || showViewPullRequest || showDelete;
|
|
@@ -206,11 +214,8 @@ const BranchCard = ({ branch: b, isCurrent, workflowBlockedReason, user, onSelec
|
|
|
206
214
|
role: reviewing ? 'review' : perms.canWithdraw ? 'withdraw' : 'none',
|
|
207
215
|
isAdmin: userIsAdmin,
|
|
208
216
|
};
|
|
209
|
-
return (_jsxs(Paper, { withBorder: true, p: "md", shadow: "xs", "aria-current": isCurrent ? 'true' : undefined, "data-current": isCurrent || undefined, "data-testid": `branch-list-item-${b.name}`, children: [_jsxs(Group, { justify: "space-between", align: "flex-start", wrap: "nowrap", children: [_jsxs(Stack, { gap: 4, style: CARD_DETAILS, children: [_jsxs(Group, { gap: "xs", children: [_jsx(Text, { fw: 600, style: WRAP_ANYWHERE, children: displayBranchName(b) }), isCurrent && (_jsx(Badge, { color: "brand", variant: "filled", "data-testid": `branch-current-badge-${b.name}`, children: "Current" })), statusBadge && (_jsx(Badge, { color: statusBadge.color, variant: statusBadge.variant, "data-testid": `branch-status-badge-${b.name}`, "data-status": b.status, "data-protected": b.isProtected || undefined, children: statusBadge.label })), b.hold && (_jsx(Badge, { color: HOLD_BADGE.color, variant: "light", "data-testid": `held-badge-${b.name}`, children: HOLD_BADGE.label })), b.status === 'archived' && b.mergedAt && (_jsx(Badge, { color: "teal", variant: "light", "data-testid": `branch-merged-badge-${b.name}`, children: "Merged" })), b.pullRequestNumber &&
|
|
210
|
-
|
|
211
|
-
: b.pullRequestState === 'merged'
|
|
212
|
-
? 'violet'
|
|
213
|
-
: 'blue', variant: "light", children: ["PR #", b.pullRequestNumber, b.pullRequestState === 'closed' ? ' (closed)' : ''] })), b.commentCount !== undefined && b.commentCount > 0 && (_jsxs(Badge, { color: "grape", variant: "light", children: [b.commentCount, " ", b.commentCount === 1 ? 'comment' : 'comments'] })), b.syncStatus === 'sync-failed' && (_jsx(Badge, { color: "red", variant: "light", "data-testid": `sync-failed-badge-${b.name}`, children: "Sync failed" })), b.syncStatus === 'pending-sync' && (_jsx(Tooltip, { label: "Changes are on their way to GitHub.", children: _jsx(Badge, { color: "gray", variant: "light", "data-testid": `pending-sync-badge-${b.name}`, children: "Syncing\u2026" }) })), b.conflictStatus === 'conflicts-detected' && (_jsx(TooltipDetail, { detail: "This branch's version was kept for conflicting entries. Review the flagged entries.", children: _jsxs(Badge, { color: "orange", variant: "light", "data-testid": `conflicts-badge-${b.name}`, children: ["Conflicts", b.conflictFiles?.length ? ` (${b.conflictFiles.length})` : ''] }) }))] }), b.hold && (_jsxs(Text, { size: "xs", maw: 480, style: WRAP_ANYWHERE, "data-testid": `branch-hold-${b.name}`, children: [branchHoldMessage(b.hold, holdViewer), b.hold.kind === 'retargeted' && b.pullRequestUrl && (_jsxs(_Fragment, { children: [' ', _jsx(Anchor, { href: b.pullRequestUrl, target: "_blank", rel: "noreferrer", inherit: true, children: "View pull request" })] }))] })), b.syncStatus === 'sync-failed' &&
|
|
217
|
+
return (_jsxs(Paper, { withBorder: true, p: "md", shadow: "xs", "aria-current": isCurrent ? 'true' : undefined, "data-current": isCurrent || undefined, "data-testid": `branch-list-item-${b.name}`, children: [_jsxs(Group, { justify: "space-between", align: "flex-start", wrap: "nowrap", children: [_jsxs(Stack, { gap: 4, style: CARD_DETAILS, children: [_jsxs(Group, { gap: "xs", children: [_jsx(Text, { fw: 600, style: WRAP_ANYWHERE, children: displayBranchName(b) }), isCurrent && (_jsx(Badge, { color: "brand", variant: "filled", "data-testid": `branch-current-badge-${b.name}`, children: "Current" })), statusBadge && (_jsx(Badge, { color: statusBadge.color, variant: statusBadge.variant, "data-testid": `branch-status-badge-${b.name}`, "data-status": b.status, "data-protected": b.isProtected || undefined, children: statusBadge.label })), b.hold && (_jsx(Badge, { color: HOLD_BADGE.color, variant: "light", "data-testid": `held-badge-${b.name}`, children: HOLD_BADGE.label })), b.status === 'archived' && b.mergedAt && (_jsx(Badge, { color: "teal", variant: "light", "data-testid": `branch-merged-badge-${b.name}`, children: "Merged" })), b.pullRequestNumber &&
|
|
218
|
+
(b.pullRequestState === 'closed' ? (_jsx(Badge, { color: "red", variant: "light", "data-testid": `pr-closed-badge-${b.name}`, children: "PR closed" })) : (_jsxs(Badge, { color: b.pullRequestState === 'merged' ? 'violet' : 'blue', variant: "light", children: ["PR #", b.pullRequestNumber] }))), b.commentCount !== undefined && b.commentCount > 0 && (_jsxs(Badge, { color: "grape", variant: "light", children: [b.commentCount, " ", b.commentCount === 1 ? 'comment' : 'comments'] })), b.syncStatus === 'sync-failed' && (_jsx(Badge, { color: "red", variant: "light", "data-testid": `sync-failed-badge-${b.name}`, children: "Sync failed" })), b.syncStatus === 'pending-sync' && (_jsx(Tooltip, { label: "Changes are on their way to GitHub.", children: _jsx(Badge, { color: "gray", variant: "light", "data-testid": `pending-sync-badge-${b.name}`, children: "Syncing\u2026" }) })), b.conflictStatus === 'conflicts-detected' && (_jsx(TooltipDetail, { detail: "This branch's version was kept for conflicting entries. Review the flagged entries.", children: _jsxs(Badge, { color: "orange", variant: "light", "data-testid": `conflicts-badge-${b.name}`, children: ["Conflicts", b.conflictFiles?.length ? ` (${b.conflictFiles.length})` : ''] }) }))] }), b.title && (_jsx(Text, { size: "xs", c: "dimmed", style: WRAP_ANYWHERE, "data-testid": `branch-title-${b.name}`, children: b.title })), b.hold && (_jsxs(Text, { size: "xs", maw: 480, style: WRAP_ANYWHERE, "data-testid": `branch-hold-${b.name}`, children: [branchHoldMessage(b.hold, holdViewer), b.hold.kind === 'retargeted' && b.pullRequestUrl && (_jsxs(_Fragment, { children: [' ', _jsx(Anchor, { href: b.pullRequestUrl, target: "_blank", rel: "noreferrer", inherit: true, children: "View pull request" })] }))] })), b.syncStatus === 'sync-failed' &&
|
|
214
219
|
(b.hold ? b.hold.kind !== 'rewritten' && !!b.syncFailureReason?.trim() : true) && (_jsxs(Text, { size: "xs", c: "var(--canopy-danger-text)", maw: 480, style: WRAP_ANYWHERE, "data-testid": `sync-failed-reason-${b.name}`, children: [endSentence(b.syncFailureReason?.trim() || syncFailedFallback(userIsAdmin, b.status)), !b.hold && inReview && b.pullRequestNumber && ` ${SYNC_FAILED_IN_REVIEW_NOTE}`, !b.hold &&
|
|
215
220
|
inReview &&
|
|
216
221
|
holdViewer.role === 'withdraw' &&
|
|
@@ -218,15 +223,65 @@ const BranchCard = ({ branch: b, isCurrent, workflowBlockedReason, user, onSelec
|
|
|
218
223
|
? `View pull request #${b.pullRequestNumber}`
|
|
219
224
|
: 'View pull request' })), showDelete && (_jsxs(_Fragment, { children: [(menuActions.length > 0 || showViewPullRequest) && _jsx(Menu.Divider, {}), _jsx(DestructiveMenuItem, { "data-testid": `delete-branch-button-${b.name}`, blockedReason: b.status === 'submitted' || b.status === 'approved'
|
|
220
225
|
? 'This branch is in review. Withdraw it from review before deleting it.'
|
|
221
|
-
: b.hold?.kind === 'unpublished-at-merge'
|
|
222
|
-
|
|
226
|
+
: (b.hold?.kind === 'unpublished-at-merge' ||
|
|
227
|
+
b.hold?.kind === 'rewritten-at-merge') &&
|
|
228
|
+
!userIsAdmin
|
|
229
|
+
? DELETE_NEEDS_ADMIN_REASON
|
|
223
230
|
: undefined, onClick: () => onDelete?.(b.name), children: "Delete branch" })] }))] })] }))] })] }), actionRow && (_jsxs(Group, { justify: "flex-end", gap: 8, wrap: "nowrap", mt: "sm", "data-testid": `branch-action-row-${b.name}`, children: [secondary && _jsx(CardActionButton, { action: secondary }), primary && _jsx(CardActionButton, { action: primary })] }))] }));
|
|
224
231
|
};
|
|
225
232
|
const CardActionButton = ({ action }) => (_jsx(Tooltip, { label: action.blockedReason, disabled: !action.blockedReason, multiline: true, maw: 280, children: _jsx(Button, { variant: "light", color: action.color, onClick: () => {
|
|
226
233
|
if (!action.blockedReason)
|
|
227
234
|
action.run();
|
|
228
235
|
}, "data-disabled": action.blockedReason ? true : undefined, "aria-disabled": action.blockedReason ? true : undefined, "data-testid": action.testId, children: action.label }) }));
|
|
229
|
-
|
|
236
|
+
/** The filter box appears once the list has more branches than this. */
|
|
237
|
+
const BRANCH_FILTER_THRESHOLD = 10;
|
|
238
|
+
const newestFirst = (a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '');
|
|
239
|
+
/**
|
|
240
|
+
* Each branch's place, newest first, kept while the list stays open so a listing arriving in the
|
|
241
|
+
* background moves no card. A branch that first appears then goes last.
|
|
242
|
+
*/
|
|
243
|
+
const useOpeningOrder = (branches) => {
|
|
244
|
+
const [places] = useState(() => new Map());
|
|
245
|
+
for (const { name } of [...branches].sort(newestFirst)) {
|
|
246
|
+
if (!places.has(name))
|
|
247
|
+
places.set(name, places.size);
|
|
248
|
+
}
|
|
249
|
+
return places;
|
|
250
|
+
};
|
|
251
|
+
/**
|
|
252
|
+
* The list's two parts: the current branch, whatever its status, then live branches in `places`;
|
|
253
|
+
* and the other archived branches in `places`.
|
|
254
|
+
* @internal Exported for tests.
|
|
255
|
+
*/
|
|
256
|
+
export const arrangeBranches = (branches, currentBranchName, places) => {
|
|
257
|
+
const others = branches
|
|
258
|
+
.filter((b) => b.name !== currentBranchName)
|
|
259
|
+
.sort((a, b) => (places.get(a.name) ?? 0) - (places.get(b.name) ?? 0));
|
|
260
|
+
return {
|
|
261
|
+
live: [
|
|
262
|
+
...branches.filter((b) => b.name === currentBranchName),
|
|
263
|
+
...others.filter((b) => b.status !== 'archived'),
|
|
264
|
+
],
|
|
265
|
+
archived: others.filter((b) => b.status === 'archived'),
|
|
266
|
+
};
|
|
267
|
+
};
|
|
268
|
+
/**
|
|
269
|
+
* Whether `query` appears in what the card shows of the branch: its name, title or owner's name,
|
|
270
|
+
* ignoring case.
|
|
271
|
+
* @internal Exported for tests.
|
|
272
|
+
*/
|
|
273
|
+
export const matchesBranchFilter = (branch, query) => {
|
|
274
|
+
const needle = query.trim().toLowerCase();
|
|
275
|
+
return [displayBranchName(branch), branch.title, branch.ownerName].some((text) => text?.toLowerCase().includes(needle));
|
|
276
|
+
};
|
|
277
|
+
/** The "Archived (n)" heading and what it reveals. */
|
|
278
|
+
const ArchivedSection = ({ count, open, onToggle, forcedOpen, children }) => {
|
|
279
|
+
const Chevron = open ? IconChevronDown : IconChevronRight;
|
|
280
|
+
return (_jsxs(Stack, { gap: "sm", "data-testid": "archived-branches", children: [forcedOpen ? (_jsxs(Text, { size: "sm", fw: 600, "data-testid": "archived-branches-toggle", children: ["Archived (", count, ")"] })) : (_jsx(UnstyledButton, { onClick: onToggle, "aria-expanded": open, px: 4,
|
|
281
|
+
// Inset and content-width, or the ScrollArea clips the focus ring.
|
|
282
|
+
style: { alignSelf: 'flex-start', outlineOffset: -2 }, "data-testid": "archived-branches-toggle", children: _jsxs(Group, { gap: 4, wrap: "nowrap", children: [_jsx(Chevron, { size: 16, "aria-hidden": true }), _jsxs(Text, { size: "sm", fw: 600, children: ["Archived (", count, ")"] })] }) })), open && children] }));
|
|
283
|
+
};
|
|
284
|
+
export const BranchManager = ({ branches, mode, user, currentBranchName, currentWorkflowBlockedReason, startCreating = false, onSelect, onBeforeCreate, onCreate, onDelete, onSubmit, onWithdraw, onApprove, onRequestChanges, onArchive, onClose: _, comments = [], currentUserId, canResolve = false, onAddComment, onResolveThread, highlightThreadId, onGetUserMetadata, onGetOwnerMetadata, onGetCommentUserMetadata, }) => {
|
|
230
285
|
const supportsBranching = clientOperatingStrategy(mode).supportsBranching();
|
|
231
286
|
const [showCreateForm, setShowCreateForm] = useState(startCreating);
|
|
232
287
|
const [newBranchName, setNewBranchName] = useState('');
|
|
@@ -285,12 +340,27 @@ export const BranchManager = ({ branches, mode, user, currentBranchName, current
|
|
|
285
340
|
setCreating(false);
|
|
286
341
|
}
|
|
287
342
|
};
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
343
|
+
const [filter, setFilter] = useState('');
|
|
344
|
+
const filterRef = useRef(null);
|
|
345
|
+
// Both clear controls unmount as they act, so focus moves to the box they cleared.
|
|
346
|
+
const clearFilter = () => {
|
|
347
|
+
setFilter('');
|
|
348
|
+
filterRef.current?.focus();
|
|
349
|
+
};
|
|
350
|
+
const [archivedOpen, setArchivedOpen] = useState(false);
|
|
351
|
+
// Read through this, so a list shrinking past the threshold can't leave a filter no box shows.
|
|
352
|
+
const showFilter = branches.length > BRANCH_FILTER_THRESHOLD;
|
|
353
|
+
const query = showFilter ? filter.trim() : '';
|
|
354
|
+
const places = useOpeningOrder(branches);
|
|
355
|
+
const { live, archived } = arrangeBranches(query ? branches.filter((b) => matchesBranchFilter(b, query)) : branches, currentBranchName, places);
|
|
356
|
+
const matchCount = live.length + archived.length;
|
|
357
|
+
const renderCard = (b) => {
|
|
358
|
+
// The editor resolves a shadowed outside branch's full name to the namespace branch whose
|
|
359
|
+
// short name it is, so a card's actions name its branch by `branchAddress`.
|
|
360
|
+
const address = branchAddress(branches, b);
|
|
361
|
+
const act = (run) => run && (() => run(address));
|
|
362
|
+
return (_jsx(BranchCard, { branch: b, isCurrent: b.name === currentBranchName, workflowBlockedReason: b.name === currentBranchName ? currentWorkflowBlockedReason : undefined, user: user, onSelect: act(onSelect), onSubmit: act(onSubmit), onWithdraw: act(onWithdraw), onApprove: act(onApprove), onRequestChanges: act(onRequestChanges), onArchive: act(onArchive), onDelete: act(onDelete), onGetUserMetadata: onGetUserMetadata, onGetOwnerMetadata: onGetOwnerMetadata }, b.name));
|
|
363
|
+
};
|
|
294
364
|
const hasBranchThreads = comments.some((t) => t.type === 'branch');
|
|
295
365
|
return (_jsxs(Stack, { h: "100%", style: { display: 'flex', flexDirection: 'column' }, gap: 0, "data-testid": "branch-manager", children: [currentUserId && onAddComment && onResolveThread && hasBranchThreads && (_jsx(Stack, { pt: "sm", children: _jsx(BranchComments, { comments: comments, currentUserId: currentUserId, canResolve: canResolve, onAddComment: onAddComment, onResolveThread: onResolveThread, highlightThreadId: highlightThreadId, onGetUserMetadata: onGetCommentUserMetadata }) })), !!supportsBranching && (_jsxs(Stack, { gap: "sm", pt: "sm", children: [_jsx(Button, { variant: "light", size: "sm", fullWidth: true, onClick: () => {
|
|
296
366
|
if (showCreateForm)
|
|
@@ -304,11 +374,7 @@ export const BranchManager = ({ branches, mode, user, currentBranchName, current
|
|
|
304
374
|
createdBranchName?.ok &&
|
|
305
375
|
createdBranchName.shortName !== typedBranchName
|
|
306
376
|
? `Will be named ${createdBranchName.shortName}`
|
|
307
|
-
: undefined, error: nameError, value: newBranchName, onChange: (e) => setNewBranchName(e.target.value), required: true, disabled: creating, ref: nameInputRef, "data-autofocus": startCreating || undefined, "data-testid": "branch-name-input" }), _jsx(TextInput, { label: "Title", placeholder: "Brief description", value: newBranchTitle, onChange: (e) => setNewBranchTitle(e.target.value), disabled: creating, "data-testid": "branch-title-input" }), _jsx(Textarea, { label: "Description", placeholder: "Detailed description of the changes", value: newBranchDescription, onChange: (e) => setNewBranchDescription(e.target.value), minRows: 2, disabled: creating, "data-testid": "branch-description-textarea" }), _jsx(Button, { size: "sm", onClick: handleCreate, disabled: !createdBranchName?.ok, loading: creating, fullWidth: true, "data-testid": "create-branch-submit", children: "Create branch" })] }) }) })] })), _jsx(ScrollArea, { style: { flex: 1 }, pb: "md", children: branches.length === 0 ? (_jsx(Text, { size: "sm", c: "dimmed", py: "md", children: !supportsBranching ? 'Branch management is not available.' : 'No branches available.' })) : (
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const address = branchAddress(branches, b);
|
|
311
|
-
const act = (run) => run && (() => run(address));
|
|
312
|
-
return (_jsx(BranchCard, { branch: b, isCurrent: b.name === currentBranchName, workflowBlockedReason: b.name === currentBranchName ? currentWorkflowBlockedReason : undefined, user: user, onSelect: act(onSelect), onSubmit: act(onSubmit), onWithdraw: act(onWithdraw), onApprove: act(onApprove), onRequestChanges: act(onRequestChanges), onDelete: act(onDelete), onGetUserMetadata: onGetUserMetadata, onGetOwnerMetadata: onGetOwnerMetadata }, b.name));
|
|
313
|
-
}) })) })] }));
|
|
377
|
+
: undefined, error: nameError, value: newBranchName, onChange: (e) => setNewBranchName(e.target.value), required: true, disabled: creating, ref: nameInputRef, "data-autofocus": startCreating || undefined, "data-testid": "branch-name-input" }), _jsx(TextInput, { label: "Title", placeholder: "Brief description", value: newBranchTitle, onChange: (e) => setNewBranchTitle(e.target.value), disabled: creating, "data-testid": "branch-title-input" }), _jsx(Textarea, { label: "Description", placeholder: "Detailed description of the changes", value: newBranchDescription, onChange: (e) => setNewBranchDescription(e.target.value), minRows: 2, disabled: creating, "data-testid": "branch-description-textarea" }), _jsx(Button, { size: "sm", onClick: handleCreate, disabled: !createdBranchName?.ok, loading: creating, fullWidth: true, "data-testid": "create-branch-submit", children: "Create branch" })] }) }) })] })), showFilter && (_jsx(TextInput, { pt: "sm", ref: filterRef, "aria-label": "Filter branches", placeholder: "Filter by name, title or owner", value: filter, onChange: (e) => setFilter(e.target.value), rightSectionPointerEvents: "all", rightSection: filter && (_jsx(Tooltip, { label: "Clear search", withinPortal: true, children: _jsx(CloseButton, { size: "sm", "aria-label": "Clear search", onClick: clearFilter }) })), "data-testid": "branch-filter-input" })), showFilter && (_jsx(VisuallyHidden, { role: "status", "data-testid": "branch-filter-status", children: query && `${matchCount} ${matchCount === 1 ? 'branch matches' : 'branches match'}` })), _jsx(ScrollArea, { style: { flex: 1 }, pt: showFilter ? 'sm' : undefined, pb: "md", children: branches.length === 0 ? (_jsx(Text, { size: "sm", c: "dimmed", py: "md", children: !supportsBranching ? 'Branch management is not available.' : 'No branches available.' })) : (_jsxs(Stack, { gap: "sm", children: [live.map(renderCard), archived.length > 0 && (_jsx(ArchivedSection, { count: archived.length, open: archivedOpen || !!query, onToggle: () => setArchivedOpen(!archivedOpen), forcedOpen: !!query, children: archived.map(renderCard) })), matchCount === 0 && (_jsxs(Stack, { gap: 4, py: "sm", align: "flex-start", "data-testid": "branch-filter-empty", children: [_jsxs(Text, { size: "sm", c: "dimmed", children: ["No branches match \u201C", query, "\u201D."] }), _jsx(Button, { size: "compact-sm", variant: "subtle",
|
|
378
|
+
// Inset, or the ScrollArea clips the focus ring.
|
|
379
|
+
style: { outlineOffset: -2 }, onClick: clearFilter, children: "Clear search" })] }))] })) })] }));
|
|
314
380
|
};
|
package/dist/editor/Editor.js
CHANGED
|
@@ -167,7 +167,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
167
167
|
// Reloads the open branch's comments after a request for changes, whose note is a comment; the
|
|
168
168
|
// comment system is built further down.
|
|
169
169
|
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({
|
|
170
|
+
const { branchName: branchNameState, setBranchName, branches, branchSummaries, currentBranch, currentAddress, listedBranch, namesBranch, urlBranchName, branchesLoadFailed, branchesLoaded, addCreatedBranch, handleSubmit, handleWithdraw, handleApprove, handleRequestChanges, handleDelete, handleArchive, handleReloadBranchData, loadBranches, } = useBranchManager({
|
|
171
171
|
initialBranch: branchName,
|
|
172
172
|
operatingMode,
|
|
173
173
|
setBusy: setBranchesLoading,
|
|
@@ -1705,6 +1705,10 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1705
1705
|
handleRequestChanges(name).catch((err) => console.error(err));
|
|
1706
1706
|
}, onDelete: (name) => {
|
|
1707
1707
|
handleDelete(name).catch((err) => console.error(err));
|
|
1708
|
+
}, onArchive: (name) => {
|
|
1709
|
+
if (namesBranch(name, branchNameState) && isSaveAllRunning())
|
|
1710
|
+
return;
|
|
1711
|
+
handleArchive(name).catch((err) => console.error(err));
|
|
1708
1712
|
}, 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
1713
|
...changesSubject,
|
|
1710
1714
|
unsavedEdits: changesSubject.logicalPath === currentEntry?.path && isSelectedDirty(),
|
|
@@ -12,6 +12,7 @@ import { TextField } from './fields/TextField.js';
|
|
|
12
12
|
import { ToggleField } from './fields/ToggleField.js';
|
|
13
13
|
import { NumberField } from './fields/NumberField.js';
|
|
14
14
|
import { NumberListField } from './fields/NumberListField.js';
|
|
15
|
+
import { DateField } from './fields/DateField.js';
|
|
15
16
|
import { DateTimeField } from './fields/DateTimeField.js';
|
|
16
17
|
import { BlockField } from './fields/BlockField.js';
|
|
17
18
|
import { SelectField } from './fields/SelectField.js';
|
|
@@ -53,6 +54,8 @@ const normalizeOptions = (options) => {
|
|
|
53
54
|
return options.map((opt) => (typeof opt === 'string' ? { label: opt, value: opt } : opt));
|
|
54
55
|
};
|
|
55
56
|
const fieldKey = (path) => formatCanopyPath(path);
|
|
57
|
+
/** A value of another type as text, for `DateField` to quote: the form cannot save it. */
|
|
58
|
+
const dateFieldText = (value) => typeof value === 'string' ? value : value == null ? '' : JSON.stringify(value);
|
|
56
59
|
const noop = () => { };
|
|
57
60
|
const FENCE_STYLE = { border: 0, padding: 0, margin: 0, minWidth: 0 };
|
|
58
61
|
const READ_ONLY_FORM_CLASS = 'canopy-read-only-form';
|
|
@@ -214,6 +217,8 @@ export const FormRenderer = ({ fields, value, onChange, customRenderers, branch
|
|
|
214
217
|
: [], onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path), readOnly: readOnly, maxItems: field.maxItems }, fieldKey(path)));
|
|
215
218
|
}
|
|
216
219
|
return wrapWithComments(_jsx(NumberField, { id: fieldId, label: label, description: field.description, value: typeof currentValue === 'number' ? currentValue : undefined, onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path), readOnly: readOnly }, fieldKey(path)));
|
|
220
|
+
case 'date':
|
|
221
|
+
return wrapWithComments(_jsx(DateField, { id: fieldId, label: label, description: field.description, value: dateFieldText(currentValue), onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path), readOnly: readOnly, required: field.required === true }, fieldKey(path)));
|
|
217
222
|
case 'datetime':
|
|
218
223
|
return wrapWithComments(_jsx(DateTimeField, { id: fieldId, label: label, description: field.description, value: typeof currentValue === 'string' ? currentValue : '', onChange: (v) => update(v), dataCanopyField: normalizeCanopyPath(path), readOnly: readOnly, stampOnPublish: 'stampOnPublish' in field && field.stampOnPublish === true }, fieldKey(path)));
|
|
219
224
|
case 'markdown':
|
|
@@ -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)
|
|
@@ -266,9 +266,10 @@ const Value = ({ value, alt, }) => {
|
|
|
266
266
|
};
|
|
267
267
|
/**
|
|
268
268
|
* A date in full, in the viewer's time zone: a comparison needs the value, so this is the one
|
|
269
|
-
* place the editor shows a date absolute first and relative after.
|
|
269
|
+
* place the editor shows a date absolute first and relative after. A day has no relative form: it
|
|
270
|
+
* may be a future event, and a past one is not hours ago.
|
|
270
271
|
*/
|
|
271
|
-
const DateValue = ({ value, text }) => (_jsxs(Stack, { gap: 0, children: [_jsx(Text, { size: "sm", style: TEXT_STYLE, children: text ?? value.text }), _jsx(Text, { size: "xs", c: "dimmed", children: formatRelativeTime(value.
|
|
272
|
+
const DateValue = ({ value, text }) => (_jsxs(Stack, { gap: 0, children: [_jsx(Text, { size: "sm", style: TEXT_STYLE, children: text ?? value.text }), !value.dateOnly && (_jsx(Text, { size: "xs", c: "dimmed", children: formatRelativeTime(value.iso) }))] }));
|
|
272
273
|
const SideColumn = ({ label, change, side, }) => {
|
|
273
274
|
const value = change[side] ?? { kind: 'empty' };
|
|
274
275
|
const words = change.text?.mode === 'words' ? change.text : undefined;
|
package/dist/editor/copy.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export declare const EDITOR_ACTIONS: {
|
|
|
14
14
|
readonly requestChanges: "Request changes";
|
|
15
15
|
readonly switchBranch: "Switch branch";
|
|
16
16
|
readonly newBranch: "New branch";
|
|
17
|
+
readonly archiveBranch: "Archive branch";
|
|
17
18
|
readonly remove: "Remove";
|
|
18
19
|
readonly restore: "Restore";
|
|
19
20
|
readonly clear: "Clear";
|
|
@@ -34,5 +35,7 @@ export declare const rewrittenSubmitReason: (isAdmin: boolean) => string;
|
|
|
34
35
|
* says how to finish the submit, where it can be retried at all.
|
|
35
36
|
*/
|
|
36
37
|
export declare const UNFINISHED_SUBMIT_APPROVE_REASON = "The last submit didn't finish, so this branch can't be approved yet. Use \"Request changes\" to send it back to its editor.";
|
|
38
|
+
/** Why only an admin may delete a branch that may hold commits nothing else has. */
|
|
39
|
+
export declare const DELETE_NEEDS_ADMIN_REASON = "This branch may hold changes that are missing from GitHub. Ask an admin to check it before deleting it.";
|
|
37
40
|
/** Why Approve and Request changes can't act: the pull request they act on is gone. */
|
|
38
41
|
export declare const CLOSED_PULL_REQUEST_REASON = "The pull request was closed on GitHub. Use \"Withdraw from review\", then submit it again to open a new one.";
|
package/dist/editor/copy.js
CHANGED
|
@@ -14,6 +14,7 @@ export const EDITOR_ACTIONS = {
|
|
|
14
14
|
requestChanges: 'Request changes',
|
|
15
15
|
switchBranch: 'Switch branch',
|
|
16
16
|
newBranch: 'New branch',
|
|
17
|
+
archiveBranch: 'Archive branch',
|
|
17
18
|
remove: 'Remove',
|
|
18
19
|
restore: 'Restore',
|
|
19
20
|
clear: 'Clear',
|
|
@@ -36,5 +37,7 @@ export const rewrittenSubmitReason = (isAdmin) => isAdmin
|
|
|
36
37
|
* says how to finish the submit, where it can be retried at all.
|
|
37
38
|
*/
|
|
38
39
|
export const UNFINISHED_SUBMIT_APPROVE_REASON = 'The last submit didn\'t finish, so this branch can\'t be approved yet. Use "Request changes" to send it back to its editor.';
|
|
40
|
+
/** Why only an admin may delete a branch that may hold commits nothing else has. */
|
|
41
|
+
export const DELETE_NEEDS_ADMIN_REASON = 'This branch may hold changes that are missing from GitHub. Ask an admin to check it before deleting it.';
|
|
39
42
|
/** Why Approve and Request changes can't act: the pull request they act on is gone. */
|
|
40
43
|
export const CLOSED_PULL_REQUEST_REASON = 'The pull request was closed on GitHub. Use "Withdraw from review", then submit it again to open a new one.';
|
|
@@ -8,6 +8,7 @@ import { diffLines, diffWordsWithSpace } from 'diff';
|
|
|
8
8
|
import equal from 'fast-deep-equal';
|
|
9
9
|
import { ITEM_KEY } from '../item-keys.js';
|
|
10
10
|
import { findBodyFieldName } from '../utils/body-field.js';
|
|
11
|
+
import { isCalendarDay, localMidnight } from '../utils/calendar-day.js';
|
|
11
12
|
import { flattenGroupFields } from '../utils/flatten-group-fields.js';
|
|
12
13
|
import { isPlainRecord, resolveBlockItem } from '../validation/field-traversal.js';
|
|
13
14
|
import { listItemHeading, listItemTitle } from './fields/list-item-title.js';
|
|
@@ -89,7 +90,7 @@ const ISO_CHARS = /^[\d:.+\-TZ]+$/;
|
|
|
89
90
|
const formatDate = (iso, dateOnly, precision = 'minute') => {
|
|
90
91
|
if (dateOnly) {
|
|
91
92
|
const [year, month, day] = iso.split('-').map(Number);
|
|
92
|
-
return
|
|
93
|
+
return localMidnight(year, month, day).toLocaleDateString(undefined, {
|
|
93
94
|
year: 'numeric',
|
|
94
95
|
month: 'short',
|
|
95
96
|
day: 'numeric',
|
|
@@ -190,6 +191,11 @@ class Walker {
|
|
|
190
191
|
}
|
|
191
192
|
break;
|
|
192
193
|
}
|
|
194
|
+
case 'date':
|
|
195
|
+
if (isCalendarDay(value)) {
|
|
196
|
+
return { kind: 'date', iso: value, text: formatDate(value, true), dateOnly: true };
|
|
197
|
+
}
|
|
198
|
+
break;
|
|
193
199
|
case 'datetime':
|
|
194
200
|
if (typeof value === 'string' && !Number.isNaN(Date.parse(value))) {
|
|
195
201
|
return DATE_ONLY.test(value)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
export interface DateFieldProps {
|
|
3
|
+
id?: string;
|
|
4
|
+
label?: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
value: string;
|
|
7
|
+
onChange: (value: string) => void;
|
|
8
|
+
dataCanopyField?: string;
|
|
9
|
+
/** Shows the value without accepting edits. */
|
|
10
|
+
readOnly?: boolean;
|
|
11
|
+
/** A required field has no Remove: a save needs a day in it. */
|
|
12
|
+
required?: boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Editor for `type: 'date'` fields: a day, stored as `YYYY-MM-DD`, with no time and no zone. Only
|
|
16
|
+
* a pick or Remove writes, so a loaded value saves back unchanged, and a save refuses one that is
|
|
17
|
+
* no real day.
|
|
18
|
+
*/
|
|
19
|
+
export declare const DateField: React.FC<DateFieldProps>;
|