canopycms 0.9.0 → 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 (51) hide show
  1. package/CHANGELOG.md +60 -48
  2. package/dist/api/admin.d.ts +11 -0
  3. package/dist/api/admin.js +2 -0
  4. package/dist/api/archived-branches.d.ts +70 -0
  5. package/dist/api/archived-branches.js +146 -0
  6. package/dist/api/branch-merge.js +16 -1
  7. package/dist/api/branch-status.d.ts +11 -0
  8. package/dist/api/branch-status.js +2 -0
  9. package/dist/api/branch-withdraw.js +1 -1
  10. package/dist/api/branch.js +46 -108
  11. package/dist/api/client.d.ts +25 -21
  12. package/dist/api/client.js +46 -38
  13. package/dist/branch-archive.d.ts +19 -0
  14. package/dist/branch-archive.js +27 -0
  15. package/dist/branch-delete.d.ts +45 -0
  16. package/dist/branch-delete.js +97 -0
  17. package/dist/branch-metadata-file.d.ts +3 -0
  18. package/dist/branch-metadata-file.js +1 -0
  19. package/dist/branch-metadata.d.ts +6 -1
  20. package/dist/branch-metadata.js +15 -2
  21. package/dist/cli/cli.js +2 -0
  22. package/dist/cli/generate-ai-content.js +1 -0
  23. package/dist/editor/BranchManager.d.ts +20 -0
  24. package/dist/editor/BranchManager.js +92 -26
  25. package/dist/editor/Editor.js +5 -1
  26. package/dist/editor/admin/SystemHealthPanel.js +61 -6
  27. package/dist/editor/admin/useSystemHealth.d.ts +3 -0
  28. package/dist/editor/admin/useSystemHealth.js +59 -0
  29. package/dist/editor/branch-status.js +9 -0
  30. package/dist/editor/copy.d.ts +3 -0
  31. package/dist/editor/copy.js +3 -0
  32. package/dist/editor/hooks/useBranchManager.d.ts +5 -0
  33. package/dist/editor/hooks/useBranchManager.js +55 -4
  34. package/dist/editor/hooks/useBranchesData.d.ts +2 -0
  35. package/dist/editor/hooks/useBranchesData.js +3 -1
  36. package/dist/types.d.ts +33 -0
  37. package/dist/version.d.ts +1 -1
  38. package/dist/version.js +1 -1
  39. package/dist/worker/archived-sweep.d.ts +23 -0
  40. package/dist/worker/archived-sweep.js +137 -0
  41. package/dist/worker/cms-worker.d.ts +6 -0
  42. package/dist/worker/cms-worker.js +7 -0
  43. package/dist/worker/git-sync.d.ts +4 -4
  44. package/dist/worker/git-sync.js +3 -0
  45. package/dist/worker/github-commits.d.ts +2 -2
  46. package/dist/worker/github-commits.js +5 -2
  47. package/dist/worker/github-gateway-wire.d.ts +2 -2
  48. package/dist/worker/rebase.js +6 -2
  49. package/dist/worker/task-runner.js +7 -4
  50. package/dist/worker/worker-context.d.ts +2 -0
  51. package/package.json +1 -1
@@ -0,0 +1,27 @@
1
+ /** When the branch was archived, or undefined for one that records neither time. */
2
+ export const archivedTime = (branch) => {
3
+ const recorded = branch.archivedAt ?? branch.mergedAt;
4
+ if (recorded === undefined)
5
+ return undefined;
6
+ const time = Date.parse(recorded);
7
+ return Number.isNaN(time) ? undefined : time;
8
+ };
9
+ /** Archived before `cutoffMs`. `updatedAt` is no clock: any metadata write moves it. */
10
+ export const archivedBefore = (branch, cutoffMs) => {
11
+ const time = archivedTime(branch);
12
+ return branch.status === 'archived' && time !== undefined && time < cutoffMs;
13
+ };
14
+ /**
15
+ * What keeps a branch from deletion by age, past any age: one archived without merging holds work
16
+ * that reached no base branch, and one merged with an unfinished submit, or rewritten on GitHub,
17
+ * may hold the only copy of commits the merge left out.
18
+ */
19
+ export const ageDeletionHold = (branch) => branch.mergedAt === undefined
20
+ ? 'not-merged'
21
+ : branch.unpublishedAtMerge === true || branch.rewrittenOnGitHub !== undefined
22
+ ? 'unpublished'
23
+ : undefined;
24
+ /** The one rule the retention sweep and the admin bulk delete both select by. */
25
+ export const deletableByAge = (branch, cutoffMs) => archivedBefore(branch, cutoffMs) && ageDeletionHold(branch) === undefined;
26
+ /** `now` less `days`, as the cutoff the functions above take. */
27
+ export const archivedCutoff = (days, now) => now - days * 24 * 60 * 60_000;
@@ -0,0 +1,45 @@
1
+ import type { BranchRegistry } from './branch-registry.js';
2
+ import type { BranchContext, BranchMetadata } from './types.js';
3
+ export interface DeleteBranchWorkspaceOptions {
4
+ context: Pick<BranchContext, 'branch' | 'branchRoot' | 'baseRoot'>;
5
+ registry: Pick<BranchRegistry, 'invalidate'>;
6
+ /**
7
+ * For a delete a person asked for. Deletion by age passes none: commits pushed to a GitHub branch
8
+ * after its merge have no other copy, and reconcile prunes the mirror's head once GitHub drops it.
9
+ */
10
+ remoteCopies?: {
11
+ /** The branch it forked from, or the deployment's base branch. */
12
+ baseBranch: string;
13
+ /** Removes the branch's head from `remote.git`; a failure costs a warning, not the delete. */
14
+ deleteMirrorHead: () => Promise<void>;
15
+ /** Deletes or queues deleting the branch on GitHub, resolving a warning if that failed. */
16
+ deleteOnGitHub: () => Promise<string | undefined>;
17
+ };
18
+ /**
19
+ * Re-decides from a fresh read inside the lock every save() takes, so a change before the rename
20
+ * keeps the branch and one after it fails. For a caller that chose the branch earlier.
21
+ */
22
+ stillWanted?: (current: BranchMetadata) => boolean;
23
+ }
24
+ export type DeleteBranchWorkspaceOutcome = {
25
+ kind: 'deleted';
26
+ cleanupWarning?: string;
27
+ }
28
+ /** `stillWanted` refused it, or it was already gone. */
29
+ | {
30
+ kind: 'kept';
31
+ }
32
+ /** The lock was not taken in time, or branch.json could not be read under it. */
33
+ | {
34
+ kind: 'busy';
35
+ error: string;
36
+ };
37
+ /**
38
+ * Delete a branch and everything stored with it: workspace, branch.json and comments.
39
+ *
40
+ * The directory leaves its name by one rename, under the server-enforced lockfile branch-metadata
41
+ * saves hold (utils/occ-json-write.ts), so a save() that reaches that lock after the rename fails
42
+ * rather than recreating the tree. The `rm` runs last: a process killed during it leaves only a
43
+ * `.deleting-*` directory the worker sweeps, never residue under a name that may be created again.
44
+ */
45
+ export declare function deleteBranchWorkspace(options: DeleteBranchWorkspaceOptions): Promise<DeleteBranchWorkspaceOutcome>;
@@ -0,0 +1,97 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { BRANCH_META_DIR, BRANCH_META_FILE, readBranchMetadataFile } from './branch-metadata-file.js';
4
+ import { deletingDirName, removeLeftoverDir } from './branch-provisioning.js';
5
+ import { RESERVED_SETTINGS_BRANCH_PREFIX, sanitizeBranchName } from './paths/branch-name.js';
6
+ import { getErrorMessage, isNotFoundError, sanitizeErrorMessage } from './utils/error.js';
7
+ import { filePathExists } from './utils/fs.js';
8
+ import { canopyLogError, canopyLogWarn } from './utils/logger.js';
9
+ import { withOccFileLock } from './utils/occ-json-write.js';
10
+ /**
11
+ * Delete a branch and everything stored with it: workspace, branch.json and comments.
12
+ *
13
+ * The directory leaves its name by one rename, under the server-enforced lockfile branch-metadata
14
+ * saves hold (utils/occ-json-write.ts), so a save() that reaches that lock after the rename fails
15
+ * rather than recreating the tree. The `rm` runs last: a process killed during it leaves only a
16
+ * `.deleting-*` directory the worker sweeps, never residue under a name that may be created again.
17
+ */
18
+ export async function deleteBranchWorkspace(options) {
19
+ const { context, registry, remoteCopies, stillWanted } = options;
20
+ const metadataFile = path.join(context.branchRoot, BRANCH_META_DIR, BRANCH_META_FILE);
21
+ let cleanupWarning;
22
+ let deletingPath;
23
+ const warn = (warning) => {
24
+ cleanupWarning = cleanupWarning ? `${cleanupWarning}; ${warning}` : warning;
25
+ };
26
+ let wanted = true;
27
+ try {
28
+ await withOccFileLock(metadataFile, async () => {
29
+ if (stillWanted) {
30
+ const current = await readBranchMetadataFile(context.branchRoot);
31
+ if (current === null || !stillWanted(current.branch)) {
32
+ wanted = false;
33
+ return;
34
+ }
35
+ }
36
+ if (context.branchRoot === context.baseRoot) {
37
+ await fs.unlink(metadataFile).catch((err) => {
38
+ if (!isNotFoundError(err))
39
+ throw err;
40
+ });
41
+ return;
42
+ }
43
+ const target = path.join(context.baseRoot, deletingDirName(path.basename(context.branchRoot)));
44
+ try {
45
+ await fs.rename(context.branchRoot, target);
46
+ deletingPath = target;
47
+ }
48
+ catch (err) {
49
+ if (isNotFoundError(err))
50
+ return;
51
+ // Still deleted logically: without branch.json the branch is unlisted,
52
+ // and the worker quarantines what is left.
53
+ await fs.unlink(metadataFile).catch(() => { });
54
+ // [REDACT] Returned to the browser; the console line keeps the path.
55
+ warn(`Failed to remove branch directory: ${sanitizeErrorMessage(getErrorMessage(err))}`);
56
+ canopyLogError(`CanopyCMS: Failed to move branch directory for ${context.branch.name} aside:`, getErrorMessage(err));
57
+ }
58
+ });
59
+ }
60
+ catch (err) {
61
+ // A lock on a directory another delete took fails like any other.
62
+ if (!(await filePathExists(context.branchRoot)))
63
+ return { kind: 'kept' };
64
+ return { kind: 'busy', error: getErrorMessage(err) };
65
+ }
66
+ if (!wanted)
67
+ return { kind: 'kept' };
68
+ // Defense-in-depth only: callers refuse the base branch, and settings branches never resolve
69
+ // to a context here.
70
+ const sanitizedDeleted = sanitizeBranchName(context.branch.name);
71
+ if (remoteCopies &&
72
+ sanitizedDeleted !== sanitizeBranchName(remoteCopies.baseBranch) &&
73
+ !sanitizedDeleted.startsWith(RESERVED_SETTINGS_BRANCH_PREFIX)) {
74
+ try {
75
+ await remoteCopies.deleteMirrorHead();
76
+ }
77
+ catch (err) {
78
+ // Client-facing copy is sanitized (API-H2 — no absolute EFS paths); the console line keeps
79
+ // the raw detail for server logs.
80
+ warn(`Failed to remove the deleted branch's head from the local mirror: ${sanitizeErrorMessage(getErrorMessage(err))}`);
81
+ canopyLogWarn(`CanopyCMS: failed to remove deleted branch's mirror head (branch ${context.branch.name}): ${getErrorMessage(err)}`);
82
+ }
83
+ const warning = await remoteCopies.deleteOnGitHub();
84
+ if (warning)
85
+ warn(warning);
86
+ }
87
+ try {
88
+ await registry.invalidate();
89
+ }
90
+ catch (err) {
91
+ // The branch is gone; a listing corrects itself at the next bump.
92
+ warn(`Failed to refresh the branch list: ${sanitizeErrorMessage(getErrorMessage(err))}`);
93
+ }
94
+ if (deletingPath)
95
+ await removeLeftoverDir(deletingPath);
96
+ return { kind: 'deleted', ...(cleanupWarning && { cleanupWarning }) };
97
+ }
@@ -68,6 +68,7 @@ export declare const branchMetadataSchema: z.ZodObject<{
68
68
  merged: "merged";
69
69
  }>>;
70
70
  mergedAt: z.ZodOptional<z.ZodString>;
71
+ archivedAt: z.ZodOptional<z.ZodString>;
71
72
  rebaseFailure: z.ZodOptional<z.ZodObject<{
72
73
  message: z.ZodString;
73
74
  firstAt: z.ZodString;
@@ -183,6 +184,7 @@ export declare const branchMetadataSchema: z.ZodObject<{
183
184
  merged: "merged";
184
185
  }>>;
185
186
  mergedAt: z.ZodOptional<z.ZodString>;
187
+ archivedAt: z.ZodOptional<z.ZodString>;
186
188
  rebaseFailure: z.ZodOptional<z.ZodObject<{
187
189
  message: z.ZodString;
188
190
  firstAt: z.ZodString;
@@ -298,6 +300,7 @@ export declare const branchMetadataSchema: z.ZodObject<{
298
300
  merged: "merged";
299
301
  }>>;
300
302
  mergedAt: z.ZodOptional<z.ZodString>;
303
+ archivedAt: z.ZodOptional<z.ZodString>;
301
304
  rebaseFailure: z.ZodOptional<z.ZodObject<{
302
305
  message: z.ZodString;
303
306
  firstAt: z.ZodString;
@@ -77,6 +77,7 @@ const branchMetadataShape = {
77
77
  merged: 'merged',
78
78
  }).optional(),
79
79
  mergedAt: z.string().optional(),
80
+ archivedAt: z.string().optional(),
80
81
  rebaseFailure: z
81
82
  .object({ message: z.string(), firstAt: z.string(), lastAt: z.string() })
82
83
  .optional(),
@@ -130,7 +130,12 @@ export declare const awaitsSubmitRetry: ({ submitUnfinished, status }: BranchMet
130
130
  * existing values for fields the incoming update omits, so the branch's ref and
131
131
  * its recorded PR number and URL survive untouched.
132
132
  */
133
- export declare function buildMergedBranchUpdate(branch: BranchMetadata, now?: Date): NonNullable<BranchMetadataUpdate['branch']>;
133
+ export declare function buildMergedBranchUpdate(branch: BranchMetadata, mergedAt?: Date): NonNullable<BranchMetadataUpdate['branch']>;
134
+ /**
135
+ * The metadata update for archiving a branch whose PR closed without merging. Records no merge:
136
+ * `mergedAt` is what tells the two apart, and `pullRequestState` stays `closed`.
137
+ */
138
+ export declare function buildUnmergedArchiveUpdate(): NonNullable<BranchMetadataUpdate['branch']>;
134
139
  /** {@link BranchMetadataFileManager.get} as a function. */
135
140
  export declare const getBranchMetadataFileManager: (branchRoot: string, baseRoot: string, options?: {
136
141
  settleMs?: number;
@@ -282,11 +282,13 @@ const owesSubmit = (branch) => awaitsSubmitRetry(branch) ||
282
282
  * existing values for fields the incoming update omits, so the branch's ref and
283
283
  * its recorded PR number and URL survive untouched.
284
284
  */
285
- export function buildMergedBranchUpdate(branch, now = new Date()) {
285
+ export function buildMergedBranchUpdate(branch, mergedAt = new Date()) {
286
286
  return {
287
287
  status: 'archived',
288
288
  pullRequestState: 'merged',
289
- mergedAt: now.toISOString(),
289
+ mergedAt: mergedAt.toISOString(),
290
+ // Now, not the merge: retention's clock must not start before a worker outage that hid it.
291
+ archivedAt: new Date().toISOString(),
290
292
  // The merge settled every conflict the record named.
291
293
  conflictStatus: 'clean',
292
294
  conflictFiles: [],
@@ -294,6 +296,17 @@ export function buildMergedBranchUpdate(branch, now = new Date()) {
294
296
  ...(owesSubmit(branch) ? { unpublishedAtMerge: true } : {}),
295
297
  };
296
298
  }
299
+ /**
300
+ * The metadata update for archiving a branch whose PR closed without merging. Records no merge:
301
+ * `mergedAt` is what tells the two apart, and `pullRequestState` stays `closed`.
302
+ */
303
+ export function buildUnmergedArchiveUpdate() {
304
+ return {
305
+ status: 'archived',
306
+ archivedAt: new Date().toISOString(),
307
+ submitUnfinished: undefined,
308
+ };
309
+ }
297
310
  /** {@link BranchMetadataFileManager.get} as a function. */
298
311
  export const getBranchMetadataFileManager = (branchRoot, baseRoot, options) => {
299
312
  return BranchMetadataFileManager.get(branchRoot, baseRoot, options);
package/dist/cli/cli.js CHANGED
@@ -12459,6 +12459,7 @@ var init_generate_ai_content = __esm({
12459
12459
  merged: "merged"
12460
12460
  }).optional(),
12461
12461
  mergedAt: z72.string().optional(),
12462
+ archivedAt: z72.string().optional(),
12462
12463
  rebaseFailure: z72.object({ message: z72.string(), firstAt: z72.string(), lastAt: z72.string() }).optional(),
12463
12464
  historyRewrittenFrom: z72.string().optional(),
12464
12465
  lastGitHubTip: z72.string().optional(),
@@ -17935,6 +17936,7 @@ var init_branch_metadata_file = __esm({
17935
17936
  merged: "merged"
17936
17937
  }).optional(),
17937
17938
  mergedAt: z16.string().optional(),
17939
+ archivedAt: z16.string().optional(),
17938
17940
  rebaseFailure: z16.object({ message: z16.string(), firstAt: z16.string(), lastAt: z16.string() }).optional(),
17939
17941
  historyRewrittenFrom: z16.string().optional(),
17940
17942
  lastGitHubTip: z16.string().optional(),
@@ -7908,6 +7908,7 @@ var branchMetadataShape = {
7908
7908
  merged: "merged"
7909
7909
  }).optional(),
7910
7910
  mergedAt: z7.string().optional(),
7911
+ archivedAt: z7.string().optional(),
7911
7912
  rebaseFailure: z7.object({ message: z7.string(), firstAt: z7.string(), lastAt: z7.string() }).optional(),
7912
7913
  historyRewrittenFrom: z7.string().optional(),
7913
7914
  lastGitHubTip: z7.string().optional(),
@@ -9,8 +9,11 @@ export interface BranchSummary {
9
9
  name: string;
10
10
  /** What the card shows for the branch (`displayBranchName`); the full `name` keys it. */
11
11
  shortName?: string;
12
+ title?: string;
12
13
  status: string;
13
14
  createdBy?: string;
15
+ /** The creator's display name, which the filter searches. */
16
+ ownerName?: string;
14
17
  updatedAt?: string;
15
18
  access?: {
16
19
  users?: string[];
@@ -99,6 +102,8 @@ export interface BranchManagerProps {
99
102
  onWithdraw?: (name: string) => void;
100
103
  onApprove?: (name: string) => void;
101
104
  onRequestChanges?: (name: string) => void;
105
+ /** Archives a branch whose pull request closed without merging; offered to admins only. */
106
+ onArchive?: (name: string) => void;
102
107
  onClose?: () => void;
103
108
  comments?: CommentThread[];
104
109
  currentUserId?: string;
@@ -114,4 +119,19 @@ export interface BranchManagerProps {
114
119
  /** Names the people in branch comments; without it they show as ids */
115
120
  onGetCommentUserMetadata?: (userId: string) => Promise<UserSearchResult | null>;
116
121
  }
122
+ /**
123
+ * The list's two parts: the current branch, whatever its status, then live branches in `places`;
124
+ * and the other archived branches in `places`.
125
+ * @internal Exported for tests.
126
+ */
127
+ export declare const arrangeBranches: (branches: readonly BranchSummary[], currentBranchName: string | undefined, places: ReadonlyMap<string, number>) => {
128
+ live: BranchSummary[];
129
+ archived: BranchSummary[];
130
+ };
131
+ /**
132
+ * Whether `query` appears in what the card shows of the branch: its name, title or owner's name,
133
+ * ignoring case.
134
+ * @internal Exported for tests.
135
+ */
136
+ export declare const matchesBranchFilter: (branch: BranchSummary, query: string) => boolean;
117
137
  export declare const BranchManager: React.FC<BranchManagerProps>;
@@ -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(),