canopycms 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +74 -48
- package/dist/api/admin.d.ts +11 -0
- package/dist/api/admin.js +2 -0
- package/dist/api/archived-branches.d.ts +70 -0
- package/dist/api/archived-branches.js +146 -0
- package/dist/api/branch-merge.js +16 -1
- package/dist/api/branch-status.d.ts +11 -0
- package/dist/api/branch-status.js +2 -0
- package/dist/api/branch-withdraw.js +1 -1
- package/dist/api/branch.js +46 -108
- package/dist/api/client.d.ts +25 -21
- package/dist/api/client.js +46 -38
- package/dist/branch-archive.d.ts +19 -0
- package/dist/branch-archive.js +27 -0
- package/dist/branch-delete.d.ts +45 -0
- package/dist/branch-delete.js +97 -0
- package/dist/branch-metadata-file.d.ts +3 -0
- package/dist/branch-metadata-file.js +1 -0
- package/dist/branch-metadata.d.ts +6 -1
- package/dist/branch-metadata.js +15 -2
- package/dist/cli/cli.js +2 -0
- package/dist/cli/generate-ai-content.js +1 -0
- package/dist/editor/BranchManager.d.ts +20 -0
- package/dist/editor/BranchManager.js +92 -26
- package/dist/editor/Editor.js +49 -25
- package/dist/editor/PreviewFrame.d.ts +8 -1
- package/dist/editor/PreviewFrame.js +15 -2
- package/dist/editor/PreviewPane.d.ts +7 -2
- package/dist/editor/PreviewPane.js +6 -5
- package/dist/editor/PreviewToolbarTools.d.ts +6 -0
- package/dist/editor/PreviewToolbarTools.js +6 -1
- package/dist/editor/admin/SystemHealthPanel.js +61 -6
- package/dist/editor/admin/useSystemHealth.d.ts +3 -0
- package/dist/editor/admin/useSystemHealth.js +59 -0
- package/dist/editor/branch-status.js +9 -0
- package/dist/editor/comments/CommentThreadsPopover.d.ts +2 -0
- package/dist/editor/comments/CommentThreadsPopover.js +9 -1
- package/dist/editor/comments/FieldWrapper.js +3 -1
- package/dist/editor/copy.d.ts +3 -0
- package/dist/editor/copy.js +3 -0
- package/dist/editor/hooks/useBranchManager.d.ts +5 -0
- package/dist/editor/hooks/useBranchManager.js +55 -4
- package/dist/editor/hooks/useBranchesData.d.ts +2 -0
- package/dist/editor/hooks/useBranchesData.js +3 -1
- package/dist/editor/hooks/useEditorLayout.d.ts +4 -0
- package/dist/editor/hooks/useEditorLayout.js +5 -0
- package/dist/editor/hooks/usePreviewFollow.d.ts +26 -0
- package/dist/editor/hooks/usePreviewFollow.js +54 -0
- package/dist/editor/preview-bridge.d.ts +25 -0
- package/dist/editor/preview-bridge.js +175 -0
- package/dist/preview.d.ts +2 -2
- package/dist/preview.js +1 -1
- package/dist/types.d.ts +33 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/archived-sweep.d.ts +23 -0
- package/dist/worker/archived-sweep.js +137 -0
- package/dist/worker/cms-worker.d.ts +6 -0
- package/dist/worker/cms-worker.js +7 -0
- package/dist/worker/git-sync.d.ts +4 -4
- package/dist/worker/git-sync.js +3 -0
- package/dist/worker/github-commits.d.ts +2 -2
- package/dist/worker/github-commits.js +5 -2
- package/dist/worker/github-gateway-wire.d.ts +2 -2
- package/dist/worker/rebase.js +6 -2
- package/dist/worker/task-runner.js +7 -4
- package/dist/worker/worker-context.d.ts +2 -0
- package/package.json +1 -1
|
@@ -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;
|
|
@@ -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,
|
|
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;
|
package/dist/branch-metadata.js
CHANGED
|
@@ -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,
|
|
285
|
+
export function buildMergedBranchUpdate(branch, mergedAt = new Date()) {
|
|
286
286
|
return {
|
|
287
287
|
status: 'archived',
|
|
288
288
|
pullRequestState: 'merged',
|
|
289
|
-
mergedAt:
|
|
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
|
|
198
|
+
const archive = heldBack(userIsAdmin && b.status !== 'archived' && b.pullRequestState === 'closed'
|
|
199
|
+
? {
|
|
200
|
+
label: EDITOR_ACTIONS.archiveBranch,
|
|
201
|
+
color: 'orange',
|
|
202
|
+
testId: `archive-branch-button-${b.name}`,
|
|
203
|
+
run: () => onArchive?.(b.name),
|
|
204
|
+
}
|
|
205
|
+
: undefined);
|
|
206
|
+
const menuActions = [approve, requestChanges, workflow, archive].filter((a) => !!a && a !== primary && a !== secondary);
|
|
199
207
|
const showViewPullRequest = !!b.pullRequestUrl;
|
|
200
208
|
const showDelete = !b.isProtected && (userIsAdmin || (!!user && b.createdBy === user.userId));
|
|
201
209
|
const hasMenu = menuActions.length > 0 || showViewPullRequest || showDelete;
|
|
@@ -206,11 +214,8 @@ const BranchCard = ({ branch: b, isCurrent, workflowBlockedReason, user, onSelec
|
|
|
206
214
|
role: reviewing ? 'review' : perms.canWithdraw ? 'withdraw' : 'none',
|
|
207
215
|
isAdmin: userIsAdmin,
|
|
208
216
|
};
|
|
209
|
-
return (_jsxs(Paper, { withBorder: true, p: "md", shadow: "xs", "aria-current": isCurrent ? 'true' : undefined, "data-current": isCurrent || undefined, "data-testid": `branch-list-item-${b.name}`, children: [_jsxs(Group, { justify: "space-between", align: "flex-start", wrap: "nowrap", children: [_jsxs(Stack, { gap: 4, style: CARD_DETAILS, children: [_jsxs(Group, { gap: "xs", children: [_jsx(Text, { fw: 600, style: WRAP_ANYWHERE, children: displayBranchName(b) }), isCurrent && (_jsx(Badge, { color: "brand", variant: "filled", "data-testid": `branch-current-badge-${b.name}`, children: "Current" })), statusBadge && (_jsx(Badge, { color: statusBadge.color, variant: statusBadge.variant, "data-testid": `branch-status-badge-${b.name}`, "data-status": b.status, "data-protected": b.isProtected || undefined, children: statusBadge.label })), b.hold && (_jsx(Badge, { color: HOLD_BADGE.color, variant: "light", "data-testid": `held-badge-${b.name}`, children: HOLD_BADGE.label })), b.status === 'archived' && b.mergedAt && (_jsx(Badge, { color: "teal", variant: "light", "data-testid": `branch-merged-badge-${b.name}`, children: "Merged" })), b.pullRequestNumber &&
|
|
210
|
-
|
|
211
|
-
: b.pullRequestState === 'merged'
|
|
212
|
-
? 'violet'
|
|
213
|
-
: 'blue', variant: "light", children: ["PR #", b.pullRequestNumber, b.pullRequestState === 'closed' ? ' (closed)' : ''] })), b.commentCount !== undefined && b.commentCount > 0 && (_jsxs(Badge, { color: "grape", variant: "light", children: [b.commentCount, " ", b.commentCount === 1 ? 'comment' : 'comments'] })), b.syncStatus === 'sync-failed' && (_jsx(Badge, { color: "red", variant: "light", "data-testid": `sync-failed-badge-${b.name}`, children: "Sync failed" })), b.syncStatus === 'pending-sync' && (_jsx(Tooltip, { label: "Changes are on their way to GitHub.", children: _jsx(Badge, { color: "gray", variant: "light", "data-testid": `pending-sync-badge-${b.name}`, children: "Syncing\u2026" }) })), b.conflictStatus === 'conflicts-detected' && (_jsx(TooltipDetail, { detail: "This branch's version was kept for conflicting entries. Review the flagged entries.", children: _jsxs(Badge, { color: "orange", variant: "light", "data-testid": `conflicts-badge-${b.name}`, children: ["Conflicts", b.conflictFiles?.length ? ` (${b.conflictFiles.length})` : ''] }) }))] }), b.hold && (_jsxs(Text, { size: "xs", maw: 480, style: WRAP_ANYWHERE, "data-testid": `branch-hold-${b.name}`, children: [branchHoldMessage(b.hold, holdViewer), b.hold.kind === 'retargeted' && b.pullRequestUrl && (_jsxs(_Fragment, { children: [' ', _jsx(Anchor, { href: b.pullRequestUrl, target: "_blank", rel: "noreferrer", inherit: true, children: "View pull request" })] }))] })), b.syncStatus === 'sync-failed' &&
|
|
217
|
+
return (_jsxs(Paper, { withBorder: true, p: "md", shadow: "xs", "aria-current": isCurrent ? 'true' : undefined, "data-current": isCurrent || undefined, "data-testid": `branch-list-item-${b.name}`, children: [_jsxs(Group, { justify: "space-between", align: "flex-start", wrap: "nowrap", children: [_jsxs(Stack, { gap: 4, style: CARD_DETAILS, children: [_jsxs(Group, { gap: "xs", children: [_jsx(Text, { fw: 600, style: WRAP_ANYWHERE, children: displayBranchName(b) }), isCurrent && (_jsx(Badge, { color: "brand", variant: "filled", "data-testid": `branch-current-badge-${b.name}`, children: "Current" })), statusBadge && (_jsx(Badge, { color: statusBadge.color, variant: statusBadge.variant, "data-testid": `branch-status-badge-${b.name}`, "data-status": b.status, "data-protected": b.isProtected || undefined, children: statusBadge.label })), b.hold && (_jsx(Badge, { color: HOLD_BADGE.color, variant: "light", "data-testid": `held-badge-${b.name}`, children: HOLD_BADGE.label })), b.status === 'archived' && b.mergedAt && (_jsx(Badge, { color: "teal", variant: "light", "data-testid": `branch-merged-badge-${b.name}`, children: "Merged" })), b.pullRequestNumber &&
|
|
218
|
+
(b.pullRequestState === 'closed' ? (_jsx(Badge, { color: "red", variant: "light", "data-testid": `pr-closed-badge-${b.name}`, children: "PR closed" })) : (_jsxs(Badge, { color: b.pullRequestState === 'merged' ? 'violet' : 'blue', variant: "light", children: ["PR #", b.pullRequestNumber] }))), b.commentCount !== undefined && b.commentCount > 0 && (_jsxs(Badge, { color: "grape", variant: "light", children: [b.commentCount, " ", b.commentCount === 1 ? 'comment' : 'comments'] })), b.syncStatus === 'sync-failed' && (_jsx(Badge, { color: "red", variant: "light", "data-testid": `sync-failed-badge-${b.name}`, children: "Sync failed" })), b.syncStatus === 'pending-sync' && (_jsx(Tooltip, { label: "Changes are on their way to GitHub.", children: _jsx(Badge, { color: "gray", variant: "light", "data-testid": `pending-sync-badge-${b.name}`, children: "Syncing\u2026" }) })), b.conflictStatus === 'conflicts-detected' && (_jsx(TooltipDetail, { detail: "This branch's version was kept for conflicting entries. Review the flagged entries.", children: _jsxs(Badge, { color: "orange", variant: "light", "data-testid": `conflicts-badge-${b.name}`, children: ["Conflicts", b.conflictFiles?.length ? ` (${b.conflictFiles.length})` : ''] }) }))] }), b.title && (_jsx(Text, { size: "xs", c: "dimmed", style: WRAP_ANYWHERE, "data-testid": `branch-title-${b.name}`, children: b.title })), b.hold && (_jsxs(Text, { size: "xs", maw: 480, style: WRAP_ANYWHERE, "data-testid": `branch-hold-${b.name}`, children: [branchHoldMessage(b.hold, holdViewer), b.hold.kind === 'retargeted' && b.pullRequestUrl && (_jsxs(_Fragment, { children: [' ', _jsx(Anchor, { href: b.pullRequestUrl, target: "_blank", rel: "noreferrer", inherit: true, children: "View pull request" })] }))] })), b.syncStatus === 'sync-failed' &&
|
|
214
219
|
(b.hold ? b.hold.kind !== 'rewritten' && !!b.syncFailureReason?.trim() : true) && (_jsxs(Text, { size: "xs", c: "var(--canopy-danger-text)", maw: 480, style: WRAP_ANYWHERE, "data-testid": `sync-failed-reason-${b.name}`, children: [endSentence(b.syncFailureReason?.trim() || syncFailedFallback(userIsAdmin, b.status)), !b.hold && inReview && b.pullRequestNumber && ` ${SYNC_FAILED_IN_REVIEW_NOTE}`, !b.hold &&
|
|
215
220
|
inReview &&
|
|
216
221
|
holdViewer.role === 'withdraw' &&
|
|
@@ -218,15 +223,65 @@ const BranchCard = ({ branch: b, isCurrent, workflowBlockedReason, user, onSelec
|
|
|
218
223
|
? `View pull request #${b.pullRequestNumber}`
|
|
219
224
|
: 'View pull request' })), showDelete && (_jsxs(_Fragment, { children: [(menuActions.length > 0 || showViewPullRequest) && _jsx(Menu.Divider, {}), _jsx(DestructiveMenuItem, { "data-testid": `delete-branch-button-${b.name}`, blockedReason: b.status === 'submitted' || b.status === 'approved'
|
|
220
225
|
? 'This branch is in review. Withdraw it from review before deleting it.'
|
|
221
|
-
: b.hold?.kind === 'unpublished-at-merge'
|
|
222
|
-
|
|
226
|
+
: (b.hold?.kind === 'unpublished-at-merge' ||
|
|
227
|
+
b.hold?.kind === 'rewritten-at-merge') &&
|
|
228
|
+
!userIsAdmin
|
|
229
|
+
? DELETE_NEEDS_ADMIN_REASON
|
|
223
230
|
: undefined, onClick: () => onDelete?.(b.name), children: "Delete branch" })] }))] })] }))] })] }), actionRow && (_jsxs(Group, { justify: "flex-end", gap: 8, wrap: "nowrap", mt: "sm", "data-testid": `branch-action-row-${b.name}`, children: [secondary && _jsx(CardActionButton, { action: secondary }), primary && _jsx(CardActionButton, { action: primary })] }))] }));
|
|
224
231
|
};
|
|
225
232
|
const CardActionButton = ({ action }) => (_jsx(Tooltip, { label: action.blockedReason, disabled: !action.blockedReason, multiline: true, maw: 280, children: _jsx(Button, { variant: "light", color: action.color, onClick: () => {
|
|
226
233
|
if (!action.blockedReason)
|
|
227
234
|
action.run();
|
|
228
235
|
}, "data-disabled": action.blockedReason ? true : undefined, "aria-disabled": action.blockedReason ? true : undefined, "data-testid": action.testId, children: action.label }) }));
|
|
229
|
-
|
|
236
|
+
/** The filter box appears once the list has more branches than this. */
|
|
237
|
+
const BRANCH_FILTER_THRESHOLD = 10;
|
|
238
|
+
const newestFirst = (a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '');
|
|
239
|
+
/**
|
|
240
|
+
* Each branch's place, newest first, kept while the list stays open so a listing arriving in the
|
|
241
|
+
* background moves no card. A branch that first appears then goes last.
|
|
242
|
+
*/
|
|
243
|
+
const useOpeningOrder = (branches) => {
|
|
244
|
+
const [places] = useState(() => new Map());
|
|
245
|
+
for (const { name } of [...branches].sort(newestFirst)) {
|
|
246
|
+
if (!places.has(name))
|
|
247
|
+
places.set(name, places.size);
|
|
248
|
+
}
|
|
249
|
+
return places;
|
|
250
|
+
};
|
|
251
|
+
/**
|
|
252
|
+
* The list's two parts: the current branch, whatever its status, then live branches in `places`;
|
|
253
|
+
* and the other archived branches in `places`.
|
|
254
|
+
* @internal Exported for tests.
|
|
255
|
+
*/
|
|
256
|
+
export const arrangeBranches = (branches, currentBranchName, places) => {
|
|
257
|
+
const others = branches
|
|
258
|
+
.filter((b) => b.name !== currentBranchName)
|
|
259
|
+
.sort((a, b) => (places.get(a.name) ?? 0) - (places.get(b.name) ?? 0));
|
|
260
|
+
return {
|
|
261
|
+
live: [
|
|
262
|
+
...branches.filter((b) => b.name === currentBranchName),
|
|
263
|
+
...others.filter((b) => b.status !== 'archived'),
|
|
264
|
+
],
|
|
265
|
+
archived: others.filter((b) => b.status === 'archived'),
|
|
266
|
+
};
|
|
267
|
+
};
|
|
268
|
+
/**
|
|
269
|
+
* Whether `query` appears in what the card shows of the branch: its name, title or owner's name,
|
|
270
|
+
* ignoring case.
|
|
271
|
+
* @internal Exported for tests.
|
|
272
|
+
*/
|
|
273
|
+
export const matchesBranchFilter = (branch, query) => {
|
|
274
|
+
const needle = query.trim().toLowerCase();
|
|
275
|
+
return [displayBranchName(branch), branch.title, branch.ownerName].some((text) => text?.toLowerCase().includes(needle));
|
|
276
|
+
};
|
|
277
|
+
/** The "Archived (n)" heading and what it reveals. */
|
|
278
|
+
const ArchivedSection = ({ count, open, onToggle, forcedOpen, children }) => {
|
|
279
|
+
const Chevron = open ? IconChevronDown : IconChevronRight;
|
|
280
|
+
return (_jsxs(Stack, { gap: "sm", "data-testid": "archived-branches", children: [forcedOpen ? (_jsxs(Text, { size: "sm", fw: 600, "data-testid": "archived-branches-toggle", children: ["Archived (", count, ")"] })) : (_jsx(UnstyledButton, { onClick: onToggle, "aria-expanded": open, px: 4,
|
|
281
|
+
// Inset and content-width, or the ScrollArea clips the focus ring.
|
|
282
|
+
style: { alignSelf: 'flex-start', outlineOffset: -2 }, "data-testid": "archived-branches-toggle", children: _jsxs(Group, { gap: 4, wrap: "nowrap", children: [_jsx(Chevron, { size: 16, "aria-hidden": true }), _jsxs(Text, { size: "sm", fw: 600, children: ["Archived (", count, ")"] })] }) })), open && children] }));
|
|
283
|
+
};
|
|
284
|
+
export const BranchManager = ({ branches, mode, user, currentBranchName, currentWorkflowBlockedReason, startCreating = false, onSelect, onBeforeCreate, onCreate, onDelete, onSubmit, onWithdraw, onApprove, onRequestChanges, onArchive, onClose: _, comments = [], currentUserId, canResolve = false, onAddComment, onResolveThread, highlightThreadId, onGetUserMetadata, onGetOwnerMetadata, onGetCommentUserMetadata, }) => {
|
|
230
285
|
const supportsBranching = clientOperatingStrategy(mode).supportsBranching();
|
|
231
286
|
const [showCreateForm, setShowCreateForm] = useState(startCreating);
|
|
232
287
|
const [newBranchName, setNewBranchName] = useState('');
|
|
@@ -285,12 +340,27 @@ export const BranchManager = ({ branches, mode, user, currentBranchName, current
|
|
|
285
340
|
setCreating(false);
|
|
286
341
|
}
|
|
287
342
|
};
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
343
|
+
const [filter, setFilter] = useState('');
|
|
344
|
+
const filterRef = useRef(null);
|
|
345
|
+
// Both clear controls unmount as they act, so focus moves to the box they cleared.
|
|
346
|
+
const clearFilter = () => {
|
|
347
|
+
setFilter('');
|
|
348
|
+
filterRef.current?.focus();
|
|
349
|
+
};
|
|
350
|
+
const [archivedOpen, setArchivedOpen] = useState(false);
|
|
351
|
+
// Read through this, so a list shrinking past the threshold can't leave a filter no box shows.
|
|
352
|
+
const showFilter = branches.length > BRANCH_FILTER_THRESHOLD;
|
|
353
|
+
const query = showFilter ? filter.trim() : '';
|
|
354
|
+
const places = useOpeningOrder(branches);
|
|
355
|
+
const { live, archived } = arrangeBranches(query ? branches.filter((b) => matchesBranchFilter(b, query)) : branches, currentBranchName, places);
|
|
356
|
+
const matchCount = live.length + archived.length;
|
|
357
|
+
const renderCard = (b) => {
|
|
358
|
+
// The editor resolves a shadowed outside branch's full name to the namespace branch whose
|
|
359
|
+
// short name it is, so a card's actions name its branch by `branchAddress`.
|
|
360
|
+
const address = branchAddress(branches, b);
|
|
361
|
+
const act = (run) => run && (() => run(address));
|
|
362
|
+
return (_jsx(BranchCard, { branch: b, isCurrent: b.name === currentBranchName, workflowBlockedReason: b.name === currentBranchName ? currentWorkflowBlockedReason : undefined, user: user, onSelect: act(onSelect), onSubmit: act(onSubmit), onWithdraw: act(onWithdraw), onApprove: act(onApprove), onRequestChanges: act(onRequestChanges), onArchive: act(onArchive), onDelete: act(onDelete), onGetUserMetadata: onGetUserMetadata, onGetOwnerMetadata: onGetOwnerMetadata }, b.name));
|
|
363
|
+
};
|
|
294
364
|
const hasBranchThreads = comments.some((t) => t.type === 'branch');
|
|
295
365
|
return (_jsxs(Stack, { h: "100%", style: { display: 'flex', flexDirection: 'column' }, gap: 0, "data-testid": "branch-manager", children: [currentUserId && onAddComment && onResolveThread && hasBranchThreads && (_jsx(Stack, { pt: "sm", children: _jsx(BranchComments, { comments: comments, currentUserId: currentUserId, canResolve: canResolve, onAddComment: onAddComment, onResolveThread: onResolveThread, highlightThreadId: highlightThreadId, onGetUserMetadata: onGetCommentUserMetadata }) })), !!supportsBranching && (_jsxs(Stack, { gap: "sm", pt: "sm", children: [_jsx(Button, { variant: "light", size: "sm", fullWidth: true, onClick: () => {
|
|
296
366
|
if (showCreateForm)
|
|
@@ -304,11 +374,7 @@ export const BranchManager = ({ branches, mode, user, currentBranchName, current
|
|
|
304
374
|
createdBranchName?.ok &&
|
|
305
375
|
createdBranchName.shortName !== typedBranchName
|
|
306
376
|
? `Will be named ${createdBranchName.shortName}`
|
|
307
|
-
: undefined, error: nameError, value: newBranchName, onChange: (e) => setNewBranchName(e.target.value), required: true, disabled: creating, ref: nameInputRef, "data-autofocus": startCreating || undefined, "data-testid": "branch-name-input" }), _jsx(TextInput, { label: "Title", placeholder: "Brief description", value: newBranchTitle, onChange: (e) => setNewBranchTitle(e.target.value), disabled: creating, "data-testid": "branch-title-input" }), _jsx(Textarea, { label: "Description", placeholder: "Detailed description of the changes", value: newBranchDescription, onChange: (e) => setNewBranchDescription(e.target.value), minRows: 2, disabled: creating, "data-testid": "branch-description-textarea" }), _jsx(Button, { size: "sm", onClick: handleCreate, disabled: !createdBranchName?.ok, loading: creating, fullWidth: true, "data-testid": "create-branch-submit", children: "Create branch" })] }) }) })] })), _jsx(ScrollArea, { style: { flex: 1 }, pb: "md", children: branches.length === 0 ? (_jsx(Text, { size: "sm", c: "dimmed", py: "md", children: !supportsBranching ? 'Branch management is not available.' : 'No branches available.' })) : (
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const address = branchAddress(branches, b);
|
|
311
|
-
const act = (run) => run && (() => run(address));
|
|
312
|
-
return (_jsx(BranchCard, { branch: b, isCurrent: b.name === currentBranchName, workflowBlockedReason: b.name === currentBranchName ? currentWorkflowBlockedReason : undefined, user: user, onSelect: act(onSelect), onSubmit: act(onSubmit), onWithdraw: act(onWithdraw), onApprove: act(onApprove), onRequestChanges: act(onRequestChanges), onDelete: act(onDelete), onGetUserMetadata: onGetUserMetadata, onGetOwnerMetadata: onGetOwnerMetadata }, b.name));
|
|
313
|
-
}) })) })] }));
|
|
377
|
+
: undefined, error: nameError, value: newBranchName, onChange: (e) => setNewBranchName(e.target.value), required: true, disabled: creating, ref: nameInputRef, "data-autofocus": startCreating || undefined, "data-testid": "branch-name-input" }), _jsx(TextInput, { label: "Title", placeholder: "Brief description", value: newBranchTitle, onChange: (e) => setNewBranchTitle(e.target.value), disabled: creating, "data-testid": "branch-title-input" }), _jsx(Textarea, { label: "Description", placeholder: "Detailed description of the changes", value: newBranchDescription, onChange: (e) => setNewBranchDescription(e.target.value), minRows: 2, disabled: creating, "data-testid": "branch-description-textarea" }), _jsx(Button, { size: "sm", onClick: handleCreate, disabled: !createdBranchName?.ok, loading: creating, fullWidth: true, "data-testid": "create-branch-submit", children: "Create branch" })] }) }) })] })), showFilter && (_jsx(TextInput, { pt: "sm", ref: filterRef, "aria-label": "Filter branches", placeholder: "Filter by name, title or owner", value: filter, onChange: (e) => setFilter(e.target.value), rightSectionPointerEvents: "all", rightSection: filter && (_jsx(Tooltip, { label: "Clear search", withinPortal: true, children: _jsx(CloseButton, { size: "sm", "aria-label": "Clear search", onClick: clearFilter }) })), "data-testid": "branch-filter-input" })), showFilter && (_jsx(VisuallyHidden, { role: "status", "data-testid": "branch-filter-status", children: query && `${matchCount} ${matchCount === 1 ? 'branch matches' : 'branches match'}` })), _jsx(ScrollArea, { style: { flex: 1 }, pt: showFilter ? 'sm' : undefined, pb: "md", children: branches.length === 0 ? (_jsx(Text, { size: "sm", c: "dimmed", py: "md", children: !supportsBranching ? 'Branch management is not available.' : 'No branches available.' })) : (_jsxs(Stack, { gap: "sm", children: [live.map(renderCard), archived.length > 0 && (_jsx(ArchivedSection, { count: archived.length, open: archivedOpen || !!query, onToggle: () => setArchivedOpen(!archivedOpen), forcedOpen: !!query, children: archived.map(renderCard) })), matchCount === 0 && (_jsxs(Stack, { gap: 4, py: "sm", align: "flex-start", "data-testid": "branch-filter-empty", children: [_jsxs(Text, { size: "sm", c: "dimmed", children: ["No branches match \u201C", query, "\u201D."] }), _jsx(Button, { size: "compact-sm", variant: "subtle",
|
|
378
|
+
// Inset, or the ScrollArea clips the focus ring.
|
|
379
|
+
style: { outlineOffset: -2 }, onClick: clearFilter, children: "Clear search" })] }))] })) })] }));
|
|
314
380
|
};
|