feat(branches): add folder delete action and context menu
Add ability to delete all branches in a folder from the UI. Add a handler to batch-delete local or remote branches and refresh views. - Batch deletion handles remote and local branches and reports failures. - Folder context menu receives branches and disables delete if needed. - Track folder deletion attempts and failures via telemetry.
This commit is contained in:
@@ -2656,6 +2656,51 @@
|
||||
trackEvent("branch_delete_dialog_opened");
|
||||
}
|
||||
|
||||
async function deleteBranchFolder(folderName: string, folderBranches: GitBranchInfo[]) {
|
||||
if (!activeRepoPath || isBusy) return;
|
||||
const deletableBranches = folderBranches.filter((branch) => !branch.current);
|
||||
const currentBranchKept = folderBranches.some((branch) => branch.current);
|
||||
if (deletableBranches.length === 0) {
|
||||
errorMessage = "The folder only contains the current branch, which cannot be deleted.";
|
||||
return;
|
||||
}
|
||||
|
||||
const scope = deletableBranches.every((branch) => branch.remote) ? "remote" : "local";
|
||||
const currentNote = currentBranchKept ? "\n\nThe current branch will be kept." : "";
|
||||
if (!window.confirm(`Delete ${deletableBranches.length} ${scope} branches in “${folderName}”?${currentNote}`)) return;
|
||||
|
||||
const repoPath = activeRepoPath;
|
||||
const failures: string[] = [];
|
||||
operation = `Deleting branches in ${folderName}`;
|
||||
errorMessage = "";
|
||||
try {
|
||||
for (const branch of deletableBranches) {
|
||||
try {
|
||||
if (branch.remote) {
|
||||
const slash = branch.name.indexOf("/");
|
||||
if (slash < 1) throw new Error("Could not determine remote name.");
|
||||
applyStatus(await deleteRemoteBranch(repoPath, branch.name.slice(0, slash), branch.name.slice(slash + 1)));
|
||||
} else {
|
||||
applyStatus(await deleteBranch(repoPath, branch.name, false));
|
||||
}
|
||||
} catch (error) {
|
||||
failures.push(`${branch.name}: ${errorToMessage(error)}`);
|
||||
}
|
||||
}
|
||||
await refreshRepositoryViews(repoPath);
|
||||
trackEvent("branch_folder_deleted", {
|
||||
attempted: deletableBranches.length,
|
||||
failed: failures.length,
|
||||
remote: scope === "remote" ? 1 : 0,
|
||||
});
|
||||
if (failures.length > 0) {
|
||||
errorMessage = `${failures.length} branch${failures.length === 1 ? "" : "es"} could not be deleted:\n${failures.join("\n")}`;
|
||||
}
|
||||
} finally {
|
||||
operation = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteBranch() {
|
||||
const branch = deleteBranchTarget;
|
||||
if (!activeRepoPath || !branch || branch.current || isBusy) return;
|
||||
@@ -4962,6 +5007,7 @@
|
||||
onRenameBranch={openRenameBranchDialog}
|
||||
onDeleteBranch={deleteLocalBranch}
|
||||
onDeleteRemoteBranch={deleteTrackedRemoteBranch}
|
||||
onDeleteBranchFolder={deleteBranchFolder}
|
||||
onCreateTag={createNewTag}
|
||||
onDeleteTag={deleteLocalTag}
|
||||
onPushTag={pushLocalTag}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
children: BranchTreeNode[];
|
||||
branchCount: number;
|
||||
current: boolean;
|
||||
branches: GitBranchInfo[];
|
||||
folders: Map<string, BranchFolderNode>;
|
||||
}
|
||||
|
||||
@@ -31,6 +32,7 @@
|
||||
depth: number;
|
||||
branchCount: number;
|
||||
current: boolean;
|
||||
branches: GitBranchInfo[];
|
||||
}
|
||||
|
||||
interface BranchLeafRow {
|
||||
@@ -57,6 +59,7 @@
|
||||
onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
||||
onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
||||
onDeleteRemoteBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
||||
onDeleteBranchFolder: (folderName: string, branches: GitBranchInfo[]) => void | Promise<void>;
|
||||
onCreateTag: (name: string, message: string) => void | Promise<void>;
|
||||
onDeleteTag: (tag: GitTag) => void | Promise<void>;
|
||||
onPushTag: (tag: GitTag) => void | Promise<void>;
|
||||
@@ -81,6 +84,7 @@
|
||||
onRenameBranch = () => {},
|
||||
onDeleteBranch = () => {},
|
||||
onDeleteRemoteBranch = () => {},
|
||||
onDeleteBranchFolder = () => {},
|
||||
onCreateTag = () => {},
|
||||
onDeleteTag = () => {},
|
||||
onPushTag = () => {},
|
||||
@@ -101,6 +105,7 @@
|
||||
let newTagMessage = $state("");
|
||||
let tagCreateInput = $state<HTMLInputElement | null>(null);
|
||||
let contextBranch = $state<GitBranchInfo | null>(null);
|
||||
let contextFolder = $state<BranchFolderRow | null>(null);
|
||||
let branchContextMenuElement = $state<HTMLElement | null>(null);
|
||||
let contextMenuX = $state(0);
|
||||
let contextMenuY = $state(0);
|
||||
@@ -121,6 +126,7 @@
|
||||
children: [],
|
||||
branchCount: 0,
|
||||
current: false,
|
||||
branches: [],
|
||||
folders: new Map(),
|
||||
};
|
||||
}
|
||||
@@ -147,6 +153,7 @@
|
||||
|
||||
folder.branchCount += 1;
|
||||
folder.current ||= branch.current;
|
||||
folder.branches.push(branch);
|
||||
parent = folder;
|
||||
}
|
||||
|
||||
@@ -188,6 +195,7 @@
|
||||
depth,
|
||||
branchCount: node.branchCount,
|
||||
current: node.current,
|
||||
branches: node.branches,
|
||||
});
|
||||
|
||||
if (isBranchFolderOpen(node.id)) {
|
||||
@@ -272,8 +280,25 @@
|
||||
contextMenuY = position.y;
|
||||
}
|
||||
|
||||
async function openFolderContextMenu(event: MouseEvent, folder: BranchFolderRow) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (isBusy) return;
|
||||
|
||||
contextFolder = folder;
|
||||
contextMenuX = event.clientX + 2;
|
||||
contextMenuY = event.clientY + 2;
|
||||
await tick();
|
||||
if (contextFolder !== folder) return;
|
||||
|
||||
const position = fitContextMenuToViewport(branchContextMenuElement, event.clientX, event.clientY);
|
||||
contextMenuX = position.x;
|
||||
contextMenuY = position.y;
|
||||
}
|
||||
|
||||
function closeBranchContextMenu() {
|
||||
contextBranch = null;
|
||||
contextFolder = null;
|
||||
}
|
||||
|
||||
async function renameContextBranch() {
|
||||
@@ -297,6 +322,13 @@
|
||||
if (branch.remote) await onDeleteRemoteBranch(branch); else await onDeleteBranch(branch);
|
||||
}
|
||||
|
||||
async function deleteContextFolder() {
|
||||
const folder = contextFolder;
|
||||
if (!folder || isBusy) return;
|
||||
closeBranchContextMenu();
|
||||
await onDeleteBranchFolder(folder.name, folder.branches);
|
||||
}
|
||||
|
||||
async function createContextWorktree() {
|
||||
const branch = contextBranch;
|
||||
closeBranchContextMenu();
|
||||
@@ -484,6 +516,7 @@
|
||||
style={`--branch-indent: ${row.depth * 16}px;`}
|
||||
type="button"
|
||||
onclick={() => toggleBranchFolder(row.id)}
|
||||
oncontextmenu={(event) => openFolderContextMenu(event, row)}
|
||||
aria-expanded={isBranchFolderOpen(row.id)}
|
||||
title={`${row.name} (${row.branchCount})`}
|
||||
>
|
||||
@@ -551,6 +584,7 @@
|
||||
style={`--branch-indent: ${row.depth * 16}px;`}
|
||||
type="button"
|
||||
onclick={() => toggleBranchFolder(row.id)}
|
||||
oncontextmenu={(event) => openFolderContextMenu(event, row)}
|
||||
aria-expanded={isBranchFolderOpen(row.id)}
|
||||
title={`${row.name} (${row.branchCount})`}
|
||||
>
|
||||
@@ -735,6 +769,29 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if contextFolder}
|
||||
<div
|
||||
bind:this={branchContextMenuElement}
|
||||
class="branch-context-menu"
|
||||
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
aria-label={`Actions for branch folder ${contextFolder.name}`}
|
||||
>
|
||||
<button
|
||||
class="danger"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onclick={deleteContextFolder}
|
||||
disabled={isBusy || contextFolder.branches.every((branch) => branch.current)}
|
||||
title={contextFolder.current ? "The current branch will be kept" : "Delete all branches in this folder"}
|
||||
>
|
||||
<Trash2 size={14} aria-hidden="true" />
|
||||
Delete {contextFolder.branches.filter((branch) => !branch.current).length} branches
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if contextTag}
|
||||
<div
|
||||
bind:this={tagContextMenuElement}
|
||||
|
||||
Reference in New Issue
Block a user