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:
2026-08-15 19:04:16 +02:00
parent c8247f2b2a
commit f487090b4b
2 changed files with 103 additions and 0 deletions
+57
View File
@@ -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}