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.
Files changed (77) hide show
  1. package/CHANGELOG.md +73 -47
  2. package/dist/ai/json-to-markdown.js +2 -0
  3. package/dist/api/admin.d.ts +11 -0
  4. package/dist/api/admin.js +2 -0
  5. package/dist/api/archived-branches.d.ts +70 -0
  6. package/dist/api/archived-branches.js +146 -0
  7. package/dist/api/branch-entry-changes.js +2 -4
  8. package/dist/api/branch-merge.js +16 -1
  9. package/dist/api/branch-status.d.ts +11 -0
  10. package/dist/api/branch-status.js +2 -0
  11. package/dist/api/branch-withdraw.js +1 -1
  12. package/dist/api/branch.js +46 -108
  13. package/dist/api/client.d.ts +25 -21
  14. package/dist/api/client.js +46 -38
  15. package/dist/branch-archive.d.ts +19 -0
  16. package/dist/branch-archive.js +27 -0
  17. package/dist/branch-delete.d.ts +45 -0
  18. package/dist/branch-delete.js +97 -0
  19. package/dist/branch-metadata-file.d.ts +3 -0
  20. package/dist/branch-metadata-file.js +1 -0
  21. package/dist/branch-metadata.d.ts +6 -1
  22. package/dist/branch-metadata.js +15 -2
  23. package/dist/cli/cli.js +85 -16
  24. package/dist/cli/generate-ai-content.js +83 -15
  25. package/dist/cli/init.js +1 -1
  26. package/dist/config/types.d.ts +4 -4
  27. package/dist/config/types.js +1 -1
  28. package/dist/config/validation.d.ts +9 -3
  29. package/dist/config/validation.js +30 -5
  30. package/dist/content-listing.js +2 -3
  31. package/dist/content-store.js +5 -5
  32. package/dist/editor/BranchManager.d.ts +20 -0
  33. package/dist/editor/BranchManager.js +92 -26
  34. package/dist/editor/Editor.js +5 -1
  35. package/dist/editor/FormRenderer.js +5 -0
  36. package/dist/editor/admin/SystemHealthPanel.js +61 -6
  37. package/dist/editor/admin/useSystemHealth.d.ts +3 -0
  38. package/dist/editor/admin/useSystemHealth.js +59 -0
  39. package/dist/editor/branch-status.js +9 -0
  40. package/dist/editor/components/EntryChangesDrawer.js +3 -2
  41. package/dist/editor/copy.d.ts +3 -0
  42. package/dist/editor/copy.js +3 -0
  43. package/dist/editor/entry-diff.js +7 -1
  44. package/dist/editor/fields/DateField.d.ts +19 -0
  45. package/dist/editor/fields/DateField.js +53 -0
  46. package/dist/editor/fields/DateTimeField.d.ts +10 -0
  47. package/dist/editor/fields/DateTimeField.js +5 -15
  48. package/dist/editor/fields/list-item-title.js +2 -1
  49. package/dist/editor/hooks/useBranchManager.d.ts +5 -0
  50. package/dist/editor/hooks/useBranchManager.js +55 -4
  51. package/dist/editor/hooks/useBranchesData.d.ts +2 -0
  52. package/dist/editor/hooks/useBranchesData.js +3 -1
  53. package/dist/editor/theme.js +12 -4
  54. package/dist/entry-schema-registry.js +2 -1
  55. package/dist/static/index.js +5 -2
  56. package/dist/types.d.ts +33 -0
  57. package/dist/utils/calendar-day.d.ts +23 -0
  58. package/dist/utils/calendar-day.js +102 -0
  59. package/dist/utils/content-serialize.d.ts +4 -2
  60. package/dist/utils/content-serialize.js +8 -6
  61. package/dist/validation/entry-validator.js +7 -1
  62. package/dist/validation/field-traversal.js +1 -1
  63. package/dist/version.d.ts +1 -1
  64. package/dist/version.js +1 -1
  65. package/dist/worker/archived-sweep.d.ts +23 -0
  66. package/dist/worker/archived-sweep.js +137 -0
  67. package/dist/worker/cms-worker.d.ts +6 -0
  68. package/dist/worker/cms-worker.js +7 -0
  69. package/dist/worker/git-sync.d.ts +4 -4
  70. package/dist/worker/git-sync.js +3 -0
  71. package/dist/worker/github-commits.d.ts +2 -2
  72. package/dist/worker/github-commits.js +5 -2
  73. package/dist/worker/github-gateway-wire.d.ts +2 -2
  74. package/dist/worker/rebase.js +6 -2
  75. package/dist/worker/task-runner.js +7 -4
  76. package/dist/worker/worker-context.d.ts +2 -0
  77. 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 menuActions = [approve, requestChanges, workflow].filter((a) => !!a && a !== primary && a !== secondary);
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 && (_jsxs(Badge, { color: b.pullRequestState === 'closed'
210
- ? 'red'
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' && !userIsAdmin
222
- ? 'This branch may hold changes that never reached GitHub. Ask an admin to check it before deleting it.'
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
- export const BranchManager = ({ branches, mode, user, currentBranchName, currentWorkflowBlockedReason, startCreating = false, onSelect, onBeforeCreate, onCreate, onDelete, onSubmit, onWithdraw, onApprove, onRequestChanges, onClose: _, comments = [], currentUserId, canResolve = false, onAddComment, onResolveThread, highlightThreadId, onGetUserMetadata, onGetOwnerMetadata, onGetCommentUserMetadata, }) => {
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 orderedBranches = currentBranchName
289
- ? [
290
- ...branches.filter((b) => b.name === currentBranchName),
291
- ...branches.filter((b) => b.name !== currentBranchName),
292
- ]
293
- : branches;
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.' })) : (_jsx(Stack, { gap: "sm", children: orderedBranches.map((b) => {
308
- // The editor resolves a shadowed outside branch's full name to the namespace branch
309
- // whose short name it is, so a card's actions name its branch by `branchAddress`.
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
  };
@@ -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' (already merged): one in review is not
473
+ // The worker looks at every branch but 'archived' (finished): one in review is not
423
474
  // rebased, but commits from GitHub are still brought into it. An exclusion, not a list of
424
475
  // statuses, so a status added later still shows its failures by default.
425
476
  const showRebaseFailure = b.status !== 'archived' && !!b.rebaseFailure;
@@ -429,7 +480,9 @@ function BranchHealthRow({ entry, baseWarning, duplicateIdScan, onMarkMerged, on
429
480
  : !inReview
430
481
  ? b.unpublishedAtMerge
431
482
  ? { kind: 'unpublished-at-merge' }
432
- : undefined
483
+ : b.status === 'archived' && b.rewrittenOnGitHub !== undefined
484
+ ? { kind: 'rewritten-at-merge', commit: b.rewrittenOnGitHub }
485
+ : undefined
433
486
  : b.submitUnfinished
434
487
  ? {
435
488
  kind: 'submit-unfinished',
@@ -437,7 +490,9 @@ function BranchHealthRow({ entry, baseWarning, duplicateIdScan, onMarkMerged, on
437
490
  notQueued: b.submitNotQueued,
438
491
  }
439
492
  : b.pullRequestHold;
440
- const canMarkMerged = (b.status === 'submitted' || b.status === 'approved') && !!b.pullRequestNumber;
493
+ const canMarkMerged = (b.status === 'submitted' || b.status === 'approved') &&
494
+ !!b.pullRequestNumber &&
495
+ b.pullRequestState !== 'closed';
441
496
  return (_jsxs(Table.Tr, { children: [_jsx(Table.Td, { children: _jsxs(Group, { gap: 4, wrap: "nowrap", children: [_jsx(Text, { size: "sm", children: entry.dirName }), entry.isBaseBranch && (_jsx(Badge, { size: "xs", color: "gray", variant: "outline", children: "Base" }))] }) }), _jsx(Table.Td, { children: _jsx(Badge, { color: branchStatusPresentation(b.status)?.color ?? 'neutral', variant: "light", children: branchStatusPresentation(b.status)?.label }) }), _jsx(Table.Td, { children: b.pullRequestNumber ? (_jsxs(Text, { size: "xs", c: "blue", component: "a", href: b.pullRequestUrl, target: "_blank", rel: "noopener noreferrer", style: { textDecoration: 'underline' }, children: ["PR #", b.pullRequestNumber, b.pullRequestState ? ` (${b.pullRequestState})` : ''] })) : (_jsx(Text, { size: "xs", c: "dimmed", children: "\u2014" })) }), _jsx(Table.Td, { children: b.syncStatus === 'sync-failed' ? (_jsx(TooltipDetail, { preLine: true, detail: syncFailedDetail(b.syncFailureReason, hold?.kind !== 'rewritten' &&
442
497
  b.status !== 'archived' &&
443
498
  !(b.submitUnfinished && b.submitNotQueued)), children: _jsx(Badge, { color: "red", variant: "light", children: "Sync failed" }) })) : b.syncStatus === 'pending-sync' ? (_jsx(Badge, { color: "gray", variant: "light", children: "Pending sync" })) : null }), _jsx(Table.Td, { children: _jsxs(Group, { gap: 6, children: [!!b.conflictFiles?.length && (_jsxs(Badge, { color: "orange", variant: "light", children: [b.conflictFiles.length, " conflict", b.conflictFiles.length === 1 ? '' : 's'] })), hold && (_jsx(TooltipDetail, { detail: branchHoldMessage(hold, {
@@ -51,6 +51,9 @@ export interface UseSystemHealthReturn {
51
51
  purgeDir: (dirName: string) => Promise<void>;
52
52
  repairDir: (dirName: string) => Promise<void>;
53
53
  markMerged: (branchName: string) => Promise<void>;
54
+ /** Deletes those of `branches` still past `olderThanDays`, then says what it could not. */
55
+ deleteArchivedBranches: (olderThanDays: number, branches: string[]) => Promise<void>;
56
+ deletingArchivedBranches: boolean;
54
57
  /** Stops listing a branch merged with an unfinished submit, a warning that doesn't come back. */
55
58
  dismissUnpublished: (dirName: string) => Promise<void>;
56
59
  }
@@ -16,7 +16,16 @@ import { useSWRConfig } from 'swr';
16
16
  import { notifications } from '@mantine/notifications';
17
17
  import { useApiClient } from '../context/index.js';
18
18
  import { BRANCHES_KEY } from '../hooks/useBranchesData.js';
19
+ import { isGatewayFailure, isUnanswered, UnansweredError } from '../unanswered.js';
19
20
  const POLL_INTERVAL_MS = 30_000;
21
+ const EMPTY_DELETE = {
22
+ deleted: [],
23
+ skipped: { notMerged: [], unpublished: [] },
24
+ failed: [],
25
+ remaining: 0,
26
+ warnings: [],
27
+ };
28
+ const countBranches = (count) => `${count} archived ${count === 1 ? 'branch' : 'branches'}`;
20
29
  /**
21
30
  * Crash-loop rider window: a worker that fatally exits and restarts
22
31
  * repeatedly can flap between 'absent' and 'alive' and never sit still long
@@ -38,6 +47,7 @@ export function useSystemHealth(options) {
38
47
  const [duplicateIdScan, setDuplicateIdScan] = useState(null);
39
48
  const [duplicateIdScanLoading, setDuplicateIdScanLoading] = useState(false);
40
49
  const [duplicateIdScanError, setDuplicateIdScanError] = useState(null);
50
+ const [deletingArchivedBranches, setDeletingArchivedBranches] = useState(false);
41
51
  const [error, setError] = useState(null);
42
52
  // refresh() has no status argument, so it needs the CURRENT taskStatus
43
53
  // without depending on it (that would tear down/rebuild the poll interval
@@ -266,6 +276,53 @@ export function useSystemHealth(options) {
266
276
  notifications.show({ message, color: 'red' });
267
277
  }
268
278
  }, [apiClient, refresh]);
279
+ const deleteArchivedBranches = useCallback(async (olderThanDays, branches) => {
280
+ setDeletingArchivedBranches(true);
281
+ try {
282
+ const result = await apiClient.admin.deleteArchivedBranches({ olderThanDays, branches });
283
+ if (isGatewayFailure(result))
284
+ throw new UnansweredError();
285
+ if (!result.ok)
286
+ throw new Error(result.error || 'Failed to delete archived branches');
287
+ const { deleted, skipped, failed, remaining, warnings } = result.data ?? EMPTY_DELETE;
288
+ const left = [
289
+ failed.length > 0
290
+ ? `${countBranches(failed.length)} not deleted: busy, or changed since listed`
291
+ : '',
292
+ remaining > 0 ? `${countBranches(remaining)} not reached, run this again` : '',
293
+ skipped.notMerged.length > 0
294
+ ? `${countBranches(skipped.notMerged.length)} kept: not merged`
295
+ : '',
296
+ skipped.unpublished.length > 0
297
+ ? `${countBranches(skipped.unpublished.length)} kept: may hold unpublished changes`
298
+ : '',
299
+ ...warnings,
300
+ ].filter(Boolean);
301
+ const message = `Deleted ${countBranches(deleted.length)}`;
302
+ notifications.show(left.length > 0
303
+ ? { message: `${message}. ${left.join('. ')}.`, color: 'yellow', autoClose: false }
304
+ : { message, color: 'green' });
305
+ await mutate(BRANCHES_KEY);
306
+ await refresh();
307
+ }
308
+ catch (err) {
309
+ if (isUnanswered(err)) {
310
+ notifications.show({
311
+ message: 'Deleting archived branches may have gone through even though the server didn\'t answer. Check the "Branches" tab.',
312
+ color: 'yellow',
313
+ autoClose: false,
314
+ });
315
+ await mutate(BRANCHES_KEY);
316
+ await refresh();
317
+ return;
318
+ }
319
+ const message = err instanceof Error ? err.message : 'Failed to delete archived branches';
320
+ notifications.show({ message, color: 'red', autoClose: false });
321
+ }
322
+ finally {
323
+ setDeletingArchivedBranches(false);
324
+ }
325
+ }, [apiClient, mutate, refresh]);
269
326
  return {
270
327
  status,
271
328
  statusLoading,
@@ -288,6 +345,8 @@ export function useSystemHealth(options) {
288
345
  purgeDir,
289
346
  repairDir,
290
347
  markMerged,
348
+ deleteArchivedBranches,
349
+ deletingArchivedBranches,
291
350
  dismissUnpublished,
292
351
  };
293
352
  }
@@ -77,6 +77,15 @@ export function branchHoldMessage(hold, viewer) {
77
77
  : '';
78
78
  return `${stuck} ${retry}${instead}.`;
79
79
  }
80
+ case 'rewritten-at-merge': {
81
+ const commit = hold.commit
82
+ ? ` GitHub no longer contains commit ${escapeForLog(hold.commit)}.`
83
+ : '';
84
+ const stuck = `This branch was changed on GitHub before it was archived, so some changes here may be missing from GitHub.${commit}`;
85
+ return viewer.isAdmin
86
+ ? `${stuck} Check them before deleting the branch.`
87
+ : `${stuck} Ask an admin to check it.`;
88
+ }
80
89
  case 'unpublished-at-merge': {
81
90
  const stuck = 'This pull request merged before the last submit finished, so some changes may not be in the merge.';
82
91
  if (viewer.inSystemHealth)
@@ -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.dateOnly ? `${value.iso}T00:00:00` : value.iso) })] }));
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;
@@ -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.";
@@ -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 new Date(year, month - 1, day).toLocaleDateString(undefined, {
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>;