From eef4869bbbced24f94d133391499fb450f9a61e3 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Thu, 2 Jul 2026 08:35:29 +0200 Subject: [PATCH] add Repo Managment / Kontext Menu in Braches --- src-tauri/src/git.rs | 116 ++++++ src-tauri/src/main.rs | 13 +- src/App.svelte | 62 ++- src/app.css | 121 ++++++ src/lib/components/BranchPanel.svelte | 380 ++++++++++++++++--- src/lib/components/RenameBranchDialog.svelte | 77 ++++ src/lib/git.ts | 12 + 7 files changed, 719 insertions(+), 62 deletions(-) create mode 100644 src/lib/components/RenameBranchDialog.svelte diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index f0fd4bc..87538a1 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -347,6 +347,42 @@ pub fn create_branch( status_for_repo(&repo) } +#[tauri::command] +pub fn rename_branch( + path: String, + old_branch: String, + new_branch: String, +) -> Result { + let repo = resolve_repo(&path)?; + let old_branch = validate_existing_local_branch_name(&repo, &old_branch)?; + let new_branch = validate_new_branch_name(&repo, &new_branch)?; + + run_git( + &repo, + [ + "branch", + "-m", + "--", + old_branch.as_str(), + new_branch.as_str(), + ], + )?; + status_for_repo(&repo) +} + +#[tauri::command] +pub fn delete_branch(path: String, branch: String) -> Result { + let repo = resolve_repo(&path)?; + let branch = validate_existing_local_branch_name(&repo, &branch)?; + let status = status_for_repo(&repo)?; + if status.current_branch.as_deref() == Some(branch.as_str()) { + return Err("Der aktuelle Branch kann nicht geloescht werden.".to_string()); + } + + run_git(&repo, ["branch", "-d", "--", branch.as_str()])?; + status_for_repo(&repo) +} + #[tauri::command] pub fn stage_files(path: String, files: Vec) -> Result { let repo = resolve_repo(&path)?; @@ -2103,6 +2139,41 @@ fn validate_new_branch_name(repo: &Path, branch: &str) -> Result Ok(normalized) } +fn validate_existing_local_branch_name(repo: &Path, branch: &str) -> Result { + let branch = branch.trim(); + if branch.is_empty() { + return Err("Branch-Name darf nicht leer sein.".to_string()); + } + + let normalized = validate_branch_ref_name(branch)?; + if !ref_exists(repo, &format!("refs/heads/{normalized}"))? { + return Err(format!( + "Lokaler Branch '{normalized}' wurde nicht gefunden." + )); + } + + Ok(normalized) +} + +fn validate_branch_ref_name(branch: &str) -> Result { + let output = git_command() + .args(["check-ref-format", "--branch", branch]) + .output() + .map_err(|err| format!("Git konnte nicht gestartet werden. Ist Git installiert? {err}"))?; + + if !output.status.success() { + let details = command_output_details(&output); + return Err(format!("Ungueltiger Branch-Name: {details}")); + } + + let normalized = String::from_utf8_lossy(&output.stdout).trim().to_string(); + Ok(if normalized.is_empty() { + branch.to_string() + } else { + normalized + }) +} + fn ref_exists(repo: &Path, ref_name: &str) -> Result { let output = git_command() .arg("-C") @@ -3516,6 +3587,51 @@ mod tests { assert!(err.contains("existiert bereits")); } + #[test] + fn rename_branch_renames_existing_local_branch() { + let repo = init_temp_repo("rename_branch"); + commit_initial_file(&repo.path); + let current = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]); + run_git_test(&repo.path, ["branch", "feature/old-panel"]); + + let status = rename_branch( + repo.path.to_string_lossy().to_string(), + "feature/old-panel".to_string(), + "feature/new-panel".to_string(), + ) + .unwrap(); + + assert_eq!(status.current_branch.as_deref(), Some(current.as_str())); + assert!( + !ref_exists(&repo.path, "refs/heads/feature/old-panel").unwrap(), + "old branch should be gone" + ); + assert!( + ref_exists(&repo.path, "refs/heads/feature/new-panel").unwrap(), + "new branch should exist" + ); + } + + #[test] + fn delete_branch_removes_local_branch_but_rejects_current_branch() { + let repo = init_temp_repo("delete_branch"); + commit_initial_file(&repo.path); + let current = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]); + run_git_test(&repo.path, ["branch", "stale"]); + + let status = + delete_branch(repo.path.to_string_lossy().to_string(), "stale".to_string()).unwrap(); + + assert_eq!(status.current_branch.as_deref(), Some(current.as_str())); + assert!( + !ref_exists(&repo.path, "refs/heads/stale").unwrap(), + "deleted branch should be gone" + ); + + let err = delete_branch(repo.path.to_string_lossy().to_string(), current).unwrap_err(); + assert!(err.contains("aktuelle Branch")); + } + #[test] fn apply_file_patch_stages_and_discards_selected_changes() { let repo = init_temp_repo("apply_file_patch"); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 746d297..7db963e 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -5,11 +5,12 @@ mod git; use git::{ apply_file_patch, cancel_code_search, checkout_branch, commit, compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, cred_delete, cred_load, cred_save, - diff_file_against_working_tree, get_file_patch, get_remote_url, get_status, list_branches, - list_commits, list_file_history, list_repository_files, merge_branch, open_repo_in_explorer, - open_repository, open_repository_bundle, pull, push, read_conflict, resolve_conflict, - resolve_conflict_side, restore_file_from_commit, restore_files, restore_to_commit, - search_code_introductions, stage_files, unstage_files, SearchCancellationState, + delete_branch, diff_file_against_working_tree, get_file_patch, get_remote_url, get_status, + list_branches, list_commits, list_file_history, list_repository_files, merge_branch, + open_repo_in_explorer, open_repository, open_repository_bundle, pull, push, read_conflict, + rename_branch, resolve_conflict, resolve_conflict_side, restore_file_from_commit, + restore_files, restore_to_commit, search_code_introductions, stage_files, unstage_files, + SearchCancellationState, }; fn main() { @@ -24,6 +25,8 @@ fn main() { list_branches, checkout_branch, create_branch, + rename_branch, + delete_branch, stage_files, unstage_files, restore_files, diff --git a/src/App.svelte b/src/App.svelte index 2f1daf2..78d8827 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -16,6 +16,7 @@ import HistoryPanel from "./lib/components/HistoryPanel.svelte"; import LinePatchDialog from "./lib/components/LinePatchDialog.svelte"; import NewBranchDialog from "./lib/components/NewBranchDialog.svelte"; + import RenameBranchDialog from "./lib/components/RenameBranchDialog.svelte"; import RepoLoadingOverlay from "./lib/components/RepoLoadingOverlay.svelte"; import ResolveDialog from "./lib/components/ResolveDialog.svelte"; import StatusPanel from "./lib/components/StatusPanel.svelte"; @@ -28,6 +29,7 @@ cancelCodeSearch, applyFilePatch, createBranch, + deleteBranch, diffFileAgainstWorkingTree, compareFileToParent, getStatus, @@ -40,6 +42,7 @@ openRepositoryBundle, pull, push, + renameBranch, getRemoteUrl, credLoad, credSave, @@ -121,6 +124,7 @@ let compareTo = ""; let comparison: GitCommitComparison | null = null; let newBranchCommit: GitCommit | null = null; + let renameBranchTarget: GitBranchInfo | null = null; let compareSelectOpen = false; let compareDialogOpen = false; let selectedDiffPath = ""; @@ -662,6 +666,45 @@ }); } + function renameLocalBranch(branch: GitBranchInfo) { + if (!activeRepoPath || branch.remote) return; + renameBranchTarget = branch; + } + + async function submitRenameBranch(branchName: string) { + const branch = renameBranchTarget; + const name = branchName.trim(); + if (!activeRepoPath || !branch || branch.remote || !name || name === branch.name) return; + + await runOperation(`Renaming ${branch.name}`, async () => { + applyStatus(await renameBranch(activeRepoPath, branch.name, name)); + renameBranchTarget = null; + await refreshBranchList(activeRepoPath); + await refreshCommitHistory(activeRepoPath); + await refreshExplorerFiles(activeRepoPath); + await refreshFileHistory(activeRepoPath); + }); + } + + async function deleteLocalBranch(branch: GitBranchInfo) { + if (!activeRepoPath || branch.remote) return; + if (branch.current) { + errorMessage = "The current branch cannot be deleted."; + return; + } + + const confirmed = window.confirm(`Delete local branch "${branch.name}"?\n\nGit will refuse if the branch has unmerged changes.`); + if (!confirmed) return; + + await runOperation(`Deleting ${branch.name}`, async () => { + applyStatus(await deleteBranch(activeRepoPath, branch.name)); + await refreshBranchList(activeRepoPath); + await refreshCommitHistory(activeRepoPath); + await refreshExplorerFiles(activeRepoPath); + await refreshFileHistory(activeRepoPath); + }); + } + function openNewBranchDialog(commit: GitCommit) { if (!activeRepoPath || isBusy) return; newBranchCommit = commit; @@ -1271,16 +1314,21 @@ function handleWindowKeydown(event: KeyboardEvent) { if (event.key === "Escape" && compareDialogOpen) closeCompareDialog(); else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null; + else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null; else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false; else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog(); } + + function handleWindowContextMenu(event: MouseEvent) { + event.preventDefault(); + } GitLite - +
{/if} + +{#if renameBranchTarget} + { renameBranchTarget = null; }} + /> +{/if} + {#if compareSelectOpen} - import { Check, ChevronDown, ChevronRight, GitBranch, GitMerge, Plus, X } from "@lucide/svelte"; + import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitMerge, Pencil, Plus, Trash2, X } from "@lucide/svelte"; import type { GitBranch as GitBranchInfo } from "../types"; + type BranchTreeNode = BranchFolderNode | BranchLeafNode; + + interface BranchFolderNode { + kind: "folder"; + id: string; + name: string; + children: BranchTreeNode[]; + branchCount: number; + current: boolean; + folders: Map; + } + + interface BranchLeafNode { + kind: "branch"; + id: string; + branch: GitBranchInfo; + displayName: string; + } + + type BranchRow = BranchFolderRow | BranchLeafRow; + + interface BranchFolderRow { + kind: "folder"; + id: string; + name: string; + depth: number; + branchCount: number; + current: boolean; + } + + interface BranchLeafRow { + kind: "branch"; + id: string; + branch: GitBranchInfo; + displayName: string; + depth: number; + scopeLabel: string; + } + interface Props { branches: GitBranchInfo[]; localBranches: GitBranchInfo[]; @@ -11,6 +50,8 @@ onCheckout: (branch: GitBranchInfo) => void; onMerge: (branch: GitBranchInfo) => void; onCreateBranch: (branchName: string) => void | Promise; + onRenameBranch: (branch: GitBranchInfo) => void | Promise; + onDeleteBranch: (branch: GitBranchInfo) => void | Promise; } let { @@ -22,6 +63,8 @@ onCheckout = () => {}, onMerge = () => {}, onCreateBranch = () => {}, + onRenameBranch = () => {}, + onDeleteBranch = () => {}, }: Props = $props(); let localOpen = $state(true); @@ -29,6 +72,118 @@ let createOpen = $state(false); let newBranchName = $state(""); let createInput = $state(null); + let panelElement = $state(null); + let contextBranch = $state(null); + let contextMenuX = $state(0); + let contextMenuY = $state(0); + let collapsedBranchFolders = $state>(new Set()); + + let localBranchRows = $derived(buildBranchRows("local", localBranches, "local")); + let remoteBranchRows = $derived(buildBranchRows("remote", remoteBranches, "remote")); + + function createFolder(id: string, name: string): BranchFolderNode { + return { + kind: "folder", + id, + name, + children: [], + branchCount: 0, + current: false, + folders: new Map(), + }; + } + + function buildBranchRows(scope: string, branchList: GitBranchInfo[], scopeLabel: string): BranchRow[] { + const root = createFolder(`${scope}:root`, ""); + + for (const branch of branchList) { + const parts = branch.name.split("/").filter(Boolean); + const displayName = parts.length > 0 ? parts[parts.length - 1] : branch.name; + const folderParts = parts.slice(0, -1); + let parent = root; + + for (let index = 0; index < folderParts.length; index += 1) { + const folderName = folderParts[index]; + const folderPath = folderParts.slice(0, index + 1).join("/"); + let folder = parent.folders.get(folderName); + + if (!folder) { + folder = createFolder(`${scope}:folder:${folderPath}`, folderName); + parent.folders.set(folderName, folder); + parent.children.push(folder); + } + + folder.branchCount += 1; + folder.current ||= branch.current; + parent = folder; + } + + parent.children.push({ + kind: "branch", + id: `${scope}:branch:${branch.name}`, + branch, + displayName, + }); + } + + sortBranchNodes(root.children); + + const rows: BranchRow[] = []; + flattenBranchNodes(root.children, rows, 0, scopeLabel); + return rows; + } + + function sortBranchNodes(nodes: BranchTreeNode[]) { + nodes.sort((left, right) => { + if (left.kind !== right.kind) return left.kind === "folder" ? -1 : 1; + const leftName = left.kind === "folder" ? left.name : left.displayName; + const rightName = right.kind === "folder" ? right.name : right.displayName; + return leftName.localeCompare(rightName, undefined, { sensitivity: "base" }); + }); + + for (const node of nodes) { + if (node.kind === "folder") sortBranchNodes(node.children); + } + } + + function flattenBranchNodes(nodes: BranchTreeNode[], rows: BranchRow[], depth: number, scopeLabel: string) { + for (const node of nodes) { + if (node.kind === "folder") { + rows.push({ + kind: "folder", + id: node.id, + name: node.name, + depth, + branchCount: node.branchCount, + current: node.current, + }); + + if (isBranchFolderOpen(node.id)) { + flattenBranchNodes(node.children, rows, depth + 1, scopeLabel); + } + } else { + rows.push({ + kind: "branch", + id: node.id, + branch: node.branch, + displayName: node.displayName, + depth, + scopeLabel, + }); + } + } + } + + function isBranchFolderOpen(id: string) { + return !collapsedBranchFolders.has(id); + } + + function toggleBranchFolder(id: string) { + const next = new Set(collapsedBranchFolders); + if (next.has(id)) next.delete(id); + else next.add(id); + collapsedBranchFolders = next; + } function openCreateForm() { if (!hasRepository || isBusy) return; @@ -57,9 +212,49 @@ if (target?.closest("button")) return; onCheckout(branch); } + + function openBranchContextMenu(event: MouseEvent, branch: GitBranchInfo) { + event.preventDefault(); + event.stopPropagation(); + if (isBusy || branch.remote) return; + + const rect = panelElement?.getBoundingClientRect(); + const rawX = rect ? event.clientX - rect.left : event.offsetX; + const rawY = rect ? event.clientY - rect.top : event.offsetY; + const maxX = Math.max(8, (rect?.width ?? window.innerWidth) - 192); + const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 92); + + contextBranch = branch; + contextMenuX = Math.max(8, Math.min(rawX, maxX)); + contextMenuY = Math.max(8, Math.min(rawY, maxY)); + } + + function closeBranchContextMenu() { + contextBranch = null; + } + + async function renameContextBranch() { + const branch = contextBranch; + if (!branch || isBusy) return; + closeBranchContextMenu(); + await onRenameBranch(branch); + } + + async function deleteContextBranch() { + const branch = contextBranch; + if (!branch || branch.current || isBusy) return; + closeBranchContextMenu(); + await onDeleteBranch(branch); + } + + function handleWindowKeydown(event: KeyboardEvent) { + if (event.key === "Escape") closeBranchContextMenu(); + } -
+ + +
Branches @@ -127,34 +322,58 @@ {#if localBranches.length === 0}
No local branches.
{:else} - {#each localBranches as branch (branch.name)} -
checkoutOnDoubleClick(event, branch)} - title={branch.current ? "Current branch" : "Double-click to checkout"} - > -
-
+ {#if row.branch.current} + Current + {:else} +
+ + +
+ {/if} + + {/if} {/each} {/if} {/if} @@ -180,38 +399,87 @@ {#if remoteBranches.length === 0}
No remote branches.
{:else} - {#each remoteBranches as branch (branch.name)} -
checkoutOnDoubleClick(event, branch)} - title={branch.current ? "Current branch" : "Double-click to checkout"} - > -
-
+ {#if row.branch.current} + Current + {:else} +
+ + +
+ {/if} + + {/if} {/each} {/if} {/if}
{/if} + + {#if contextBranch} + + {/if}
diff --git a/src/lib/components/RenameBranchDialog.svelte b/src/lib/components/RenameBranchDialog.svelte new file mode 100644 index 0000000..51ab97a --- /dev/null +++ b/src/lib/components/RenameBranchDialog.svelte @@ -0,0 +1,77 @@ + + + diff --git a/src/lib/git.ts b/src/lib/git.ts index be7fdc5..7f32cc3 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -45,6 +45,18 @@ export function createBranch( return invoke("create_branch", { path, branch, startPoint: startPoint ?? null }); } +export function renameBranch( + path: string, + oldBranch: string, + newBranch: string, +): Promise { + return invoke("rename_branch", { path, oldBranch, newBranch }); +} + +export function deleteBranch(path: string, branch: string): Promise { + return invoke("delete_branch", { path, branch }); +} + export function stageFiles(path: string, files: string[]): Promise { return invoke("stage_files", { path, files }); }