diff --git a/src/App.svelte b/src/App.svelte index 428ad44..be33802 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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} diff --git a/src/lib/components/BranchPanel.svelte b/src/lib/components/BranchPanel.svelte index 18bae37..2f3eadd 100644 --- a/src/lib/components/BranchPanel.svelte +++ b/src/lib/components/BranchPanel.svelte @@ -12,6 +12,7 @@ children: BranchTreeNode[]; branchCount: number; current: boolean; + branches: GitBranchInfo[]; folders: Map; } @@ -31,6 +32,7 @@ depth: number; branchCount: number; current: boolean; + branches: GitBranchInfo[]; } interface BranchLeafRow { @@ -57,6 +59,7 @@ onRenameBranch: (branch: GitBranchInfo) => void | Promise; onDeleteBranch: (branch: GitBranchInfo) => void | Promise; onDeleteRemoteBranch: (branch: GitBranchInfo) => void | Promise; + onDeleteBranchFolder: (folderName: string, branches: GitBranchInfo[]) => void | Promise; onCreateTag: (name: string, message: string) => void | Promise; onDeleteTag: (tag: GitTag) => void | Promise; onPushTag: (tag: GitTag) => void | Promise; @@ -81,6 +84,7 @@ onRenameBranch = () => {}, onDeleteBranch = () => {}, onDeleteRemoteBranch = () => {}, + onDeleteBranchFolder = () => {}, onCreateTag = () => {}, onDeleteTag = () => {}, onPushTag = () => {}, @@ -101,6 +105,7 @@ let newTagMessage = $state(""); let tagCreateInput = $state(null); let contextBranch = $state(null); + let contextFolder = $state(null); let branchContextMenuElement = $state(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 @@ {/if} + {#if contextFolder} + + {/if} + {#if contextTag}