diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a13774f..7648dcd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -430,6 +430,12 @@ struct GitStatusEntry { path: String, } +#[derive(serde::Serialize)] +struct BranchInfo { + name: String, + is_current: bool, +} + #[tauri::command] fn git_status(path: String) -> Result, String> { let output = new_command("git") @@ -475,6 +481,48 @@ fn get_git_branch(path: String) -> Result { Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } +#[tauri::command] +fn git_list_branches(repo_path: String) -> Result, String> { + let dir = std::path::Path::new(&repo_path); + let out = run_git(dir, &["branch"])?; + if !out.status.success() { + return Err(git_stderr(&out)); + } + let branches = String::from_utf8_lossy(&out.stdout) + .lines() + .filter(|l| l.len() >= 2) + .map(|l| { + let is_current = l.starts_with("* "); + let name = l[2..].trim().to_string(); + BranchInfo { name, is_current } + }) + .filter(|b| !b.name.is_empty()) + .collect(); + Ok(branches) +} + +#[tauri::command] +fn git_switch_branch(repo_path: String, branch: String) -> Result<(), String> { + let dir = std::path::Path::new(&repo_path); + let out = run_git(dir, &["checkout", &branch])?; + if out.status.success() { Ok(()) } else { Err(git_stderr(&out)) } +} + +#[tauri::command] +fn git_delete_branch(repo_path: String, branch: String, force: bool, delete_remote: bool) -> Result<(), String> { + let dir = std::path::Path::new(&repo_path); + let flag = if force { "-D" } else { "-d" }; + let local = run_git(dir, &["branch", flag, &branch])?; + if !local.status.success() { + return Err(git_stderr(&local)); + } + if delete_remote { + // Best-effort — don't fail if the remote branch doesn't exist + let _ = run_git(dir, &["push", "origin", "--delete", &branch]); + } + Ok(()) +} + #[tauri::command] fn git_worktree_remove(repo_path: String, worktree_path: String) -> Result<(), String> { #[cfg(windows)] @@ -1465,6 +1513,9 @@ pub fn run() { git_push_branch, git_push_current_branch, git_create_push_branch, + git_list_branches, + git_switch_branch, + git_delete_branch, git_stage, git_unstage, git_discard, diff --git a/src/components/DiffPane.css b/src/components/DiffPane.css index 178dab4..9537f1c 100644 --- a/src/components/DiffPane.css +++ b/src/components/DiffPane.css @@ -211,6 +211,155 @@ .dp-branch-input:focus { border-color: var(--tempest-accent-blue); } .dp-branch-input::placeholder { color: var(--tempest-fg-subtle); opacity: 0.6; } +/* ── Branch picker ──────────────────────────────────────────────────────────── */ + +.dp-branch-picker { + position: relative; + flex-shrink: 0; +} + +.dp-branch-pill { + display: flex; + align-items: center; + gap: 5px; + padding: 4px 8px; + border: 1px solid var(--tempest-border-subtle); + border-radius: 5px; + background: var(--tempest-bg-active); + color: var(--tempest-fg-muted); + font-size: 11px; + font-family: "Geist Mono", monospace; + cursor: pointer; + white-space: nowrap; + transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease; + max-width: 160px; +} + +.dp-branch-pill:hover { + background: var(--tempest-bg-hover); + color: var(--tempest-fg-default); + border-color: var(--tempest-border-default); +} + +.dp-branch-pill-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.dp-chevron--open { + transform: rotate(180deg); + transition: transform 0.15s ease; +} + +.dp-branch-menu { + position: absolute; + top: calc(100% + 6px); + left: 0; + min-width: 200px; + background: var(--tempest-bg-panel); + border: 1px solid var(--tempest-border-default); + border-radius: 7px; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.28); + z-index: 100; + overflow: hidden; + padding: 4px 0; +} + +.dp-branch-menu-empty { + padding: 8px 12px; + font-size: 11px; + color: var(--tempest-fg-subtle); + opacity: 0.6; +} + +.dp-branch-menu-row { + display: flex; + align-items: center; + gap: 4px; + padding: 0 6px 0 8px; + height: 30px; + transition: background 0.1s ease; +} + +.dp-branch-menu-row:hover { background: var(--tempest-bg-hover); } + +.dp-branch-menu-row--current { background: var(--tempest-bg-active); } +.dp-branch-menu-row--current:hover { background: var(--tempest-bg-active); } + +.dp-branch-menu-check { + flex-shrink: 0; + width: 16px; + color: var(--tempest-accent-blue); + display: flex; + align-items: center; +} + +.dp-branch-menu-name { + flex: 1; + background: none; + border: none; + padding: 0; + text-align: left; + font-size: 12px; + font-family: "Geist Mono", monospace; + color: var(--tempest-fg-muted); + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.dp-branch-menu-row--current .dp-branch-menu-name { + color: var(--tempest-fg-default); + font-weight: 500; + cursor: default; +} + +.dp-branch-menu-name:hover:not(:disabled) { color: var(--tempest-fg-default); } + +.dp-branch-menu-del { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--tempest-fg-subtle); + cursor: pointer; + opacity: 0; + transition: opacity 0.1s ease, background 0.1s ease, color 0.1s ease; +} + +.dp-branch-menu-row:hover .dp-branch-menu-del { opacity: 1; } +.dp-branch-menu-del:hover { background: var(--tempest-git-deleted); color: #fff; } + +/* ── Delete branch dialog extras ─────────────────────────────────────────────── */ + +.dp-dialog-icon--delete { color: var(--tempest-git-deleted); } + +.dp-dialog-warn--error { + color: var(--tempest-git-deleted); + font-size: 11px; + text-align: center; + max-width: 240px; + word-break: break-word; +} + +.dp-delete-remote-row { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + padding: 2px 0; +} + .dp-push-error { flex-shrink: 0; padding: 6px 12px; diff --git a/src/components/DiffPane.tsx b/src/components/DiffPane.tsx index a1a514f..f3bc879 100644 --- a/src/components/DiffPane.tsx +++ b/src/components/DiffPane.tsx @@ -3,7 +3,7 @@ import { invoke } from "@tauri-apps/api/core"; import { openUrl } from "@tauri-apps/plugin-opener"; import { RefreshCw, GitBranch, GitPullRequest, Loader, Check, - Plus, Minus, X, AlertTriangle, + Plus, Minus, X, AlertTriangle, ChevronDown, Trash2, } from "lucide-react"; import { useAttribution, setAttribution, COAUTHOR_LINE } from "../store/attribution"; import "./DiffPane.css"; @@ -25,6 +25,11 @@ interface FileEntry { type FileSection = "staged" | "unstaged"; +interface BranchInfo { + name: string; + is_current: boolean; +} + // ── Helpers ─────────────────────────────────────────────────────────────────── function buildPrUrl(remoteUrl: string, branch: string): string { @@ -101,6 +106,13 @@ export function DiffPane({ cwd, hidden, gitRevision }: Props) { const [newBranchName, setNewBranchName] = useState(""); const [branchPushState, setBranchPushState] = useState<"idle" | "pushing" | "done" | "error">("idle"); + const [branches, setBranches] = useState([]); + const [showBranchMenu, setShowBranchMenu] = useState(false); + const branchMenuRef = useRef(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteAlsoRemote, setDeleteAlsoRemote] = useState(true); + const [deleteError, setDeleteError] = useState(null); + const [discardTarget, setDiscardTarget] = useState(null); // ── Load file list ─────────────────────────────────────────────────────── @@ -109,11 +121,13 @@ export function DiffPane({ cwd, hidden, gitRevision }: Props) { setLoading(true); setError(null); try { - const [entries, branch] = await Promise.all([ + const [entries, branch, branchList] = await Promise.all([ invoke("git_status", { path: cwd }), invoke("get_git_branch", { path: cwd }).catch(() => ""), + invoke("git_list_branches", { repoPath: cwd }).catch(() => []), ]); setCurrentBranch(branch); + setBranches(branchList); const filtered = entries.filter((e) => !e.path.includes(".tempest-pid")); const s: FileEntry[] = []; const u: FileEntry[] = []; @@ -267,6 +281,46 @@ export function DiffPane({ cwd, hidden, gitRevision }: Props) { }); }, [cwd, newBranchName, branchPushState, load]); + // ── Branch menu ─────────────────────────────────────────────────────────── + + useEffect(() => { + if (!showBranchMenu) return; + const handler = (e: MouseEvent) => { + if (branchMenuRef.current && !branchMenuRef.current.contains(e.target as Node)) { + setShowBranchMenu(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [showBranchMenu]); + + const switchBranch = async (name: string) => { + setShowBranchMenu(false); + try { + await invoke("git_switch_branch", { repoPath: cwd, branch: name }); + await load(); + } catch (e) { + setError(String(e)); + } + }; + + const confirmDelete = async (force: boolean) => { + if (!deleteTarget) return; + setDeleteError(null); + try { + await invoke("git_delete_branch", { + repoPath: cwd, + branch: deleteTarget, + force, + deleteRemote: deleteAlsoRemote, + }); + setDeleteTarget(null); + await load(); + } catch (e) { + setDeleteError(String(e)); + } + }; + // ── Render ─────────────────────────────────────────────────────────────── return ( @@ -283,6 +337,52 @@ export function DiffPane({ cwd, hidden, gitRevision }: Props) { > + {/* ── Branch picker ── */} +
+ + {showBranchMenu && ( +
+ {branches.length === 0 ? ( +
No branches
+ ) : ( + branches.map((b) => ( +
+ {b.is_current ? : null} + + {!b.is_current && ( + + )} +
+ )) + )} +
+ )} +
+
{!showBranchInput ? ( <> @@ -547,6 +647,50 @@ export function DiffPane({ cwd, hidden, gitRevision }: Props) {
)} + + {/* Branch delete confirmation */} + {deleteTarget && ( +
{ setDeleteTarget(null); setDeleteError(null); }}> +
e.stopPropagation()}> + +

Delete branch?

+ {deleteTarget} + {deleteError ? ( + <> +

{deleteError}

+
+ + +
+ + ) : ( + <> + +
+ + +
+ + )} +
+
+ )} ); }