/** * @file The git bar's Branches panel (JetBrains' branch popup, GitLens' * Branches view): a dialog over the workspace listing every local branch * (current marked, upstream with ahead/behind, last commit, why a delete would * be refused) and the fetched remote branches grouped by remote, filtered by a * search box and walked with the arrow keys; picking a row opens its actions — * new branch from it (optionally switching to it on a clean tree), rename, * set/unset upstream, merge into the current branch, delete. Its Tags tab is * `TagManager`. Everything it may do is decided by the server * (`GET …/git/refs`, `POST …/git/:action`, server/lib/git-refs.js); a disabled * button here is a courtesy, and git's own text is shown when it refuses. * @author Nguyễn Ngọc Trí Vĩ */ import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ArrowDown, ArrowUp, Check, GitBranch, GitMerge, Link2, Link2Off, Pencil, Plus, Search, Tag, Trash2, X, } from "lucide-react"; import { api, gitTarget, type GitLocalBranch, type GitRefs, type GitRemoteBranch, type GitSource, } from "../../../lib/api"; import { useDialog } from "../../../hooks/useDialog"; import { confirmDialog } from "../../../lib/confirm"; import { toast } from "../../../lib/toast"; import { isSubmitEnter } from "../../../lib/keys"; import { timeAgo } from "../../../lib/format"; import { Checkbox } from "../../Checkbox"; import { TagManager } from "./TagManager"; import { onListKeys, smallBtn, smallInput, type RunRefAction } from "./refUi"; type Mode = "new" | "rename" | "upstream" | null; export function BranchManager({ laneId, session, source, dirty, merging, onClose, onChanged, }: { laneId: number; session?: string; source?: GitSource; /** Uncommitted files: a "switch to it" is refused on a dirty tree. */ dirty: number; /** A merge, cherry-pick or revert is open: every write here is refused. */ merging: boolean; onClose: () => void; /** Called after every write, so the git bar and the Changes panel re-read. */ onChanged: () => void; }) { const { t } = useTranslation(["gitRefs"]); const titleId = useId(); const panelRef = useRef(null); const searchRef = useRef(null); useDialog(true, panelRef, onClose, searchRef); const target = gitTarget(laneId, session, source); const [refs, setRefs] = useState(null); const [tab, setTab] = useState<"branches" | "tags">("branches"); const [query, setQuery] = useState(""); const [selected, setSelected] = useState(null); const [mode, setMode] = useState(null); const [text, setText] = useState(""); const [switchTo, setSwitchTo] = useState(false); const [upstream, setUpstream] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const inFlight = useRef(false); const load = useCallback(async () => { try { setRefs(await api.lanes.gitRefs(target)); } catch { setRefs({ available: false, current: null, head: null, local: [], remotes: [], tags: [], truncated: false, }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [laneId, session, source]); useEffect(() => { void load(); }, [load]); const run: RunRefAction = async (action, body, done) => { if (inFlight.current) return false; inFlight.current = true; setBusy(true); setError(null); try { await api.lanes.gitRefAction(target, action, body); if (done) toast.success(done); setMode(null); setText(""); await load(); onChanged(); return true; } catch (err) { const code = (err as { code?: string })?.code; setError( code === "EGITAUTH" ? t("errLogin") : code === "ESUBDIR" ? t("errSubdir") : err instanceof Error ? err.message : String(err) ); return false; } finally { inFlight.current = false; setBusy(false); } }; const q = query.trim().toLowerCase(); const match = (s: string) => !q || s.toLowerCase().includes(q); const local = useMemo( () => (refs?.local || []).filter((b) => match(b.name)), // eslint-disable-next-line react-hooks/exhaustive-deps [refs, q] ); const remoteGroups = useMemo( () => (refs?.remotes || []) .map((g) => ({ ...g, branches: g.branches.filter((b) => match(b.name)) })) .filter((g) => g.branches.length), // eslint-disable-next-line react-hooks/exhaustive-deps [refs, q] ); const allRemote = useMemo( () => (refs?.remotes || []).flatMap((g) => g.branches.map((b) => b.name)), [refs] ); const current = refs?.current ?? null; const pick = (key: string) => { setSelected((s) => (s === key ? null : key)); setMode(null); setText(""); setError(null); }; const open = (m: Mode, initial = "") => { setMode((v) => (v === m ? null : m)); setText(initial); setSwitchTo(false); setError(null); }; const merge = async (branch: string) => { const ok = await confirmDialog({ title: t("merge"), message: t("mergeConfirm", { branch, current: current ?? "HEAD" }), confirmLabel: t("merge"), destructive: false, }); if (ok) await run("merge", { branch }, t("done.merge", { branch })); }; const remove = async (b: GitLocalBranch) => { const ok = await confirmDialog({ title: t("delete"), message: b.merged ? t("deleteConfirm", { branch: b.name }) : t("deleteUnmerged", { branch: b.name }), confirmLabel: t("delete"), destructive: true, }); if (ok) { if (await run("branch-delete", { branch: b.name }, t("done.delete", { branch: b.name }))) setSelected(null); } }; /** Why `git branch -d` would refuse this branch, or null. */ const deleteBlock = (b: GitLocalBranch) => b.current ? t("why.current") : b.protected ? t("why.protected") : b.worktree ? t("why.worktree", { path: b.worktree }) : null; const newBranchForm = (from: string) => (
setText(e.target.value)} onKeyDown={(e) => { if (isSubmitEnter(e) && text.trim()) void run( "branch-create", { branch: text.trim(), from, ...(switchTo ? { switch: true } : {}) }, t("done.create", { branch: text.trim() }) ); }} placeholder={t("newBranchName")} aria-label={t("newBranchName")} className={smallInput} /> 0 || merging} label={t("switchToIt")} /> {dirty > 0 && {t("switchDirty")}}
); const localActions = (b: GitLocalBranch) => { const block = deleteBlock(b); return (
{b.upstream && ( )} {!b.current && ( )}
{block &&

{block}

} {!block && !b.merged && (

{t("why.unmerged")}

)} {mode === "new" && newBranchForm(b.name)} {mode === "rename" && (
setText(e.target.value)} onKeyDown={(e) => { if (isSubmitEnter(e) && text.trim() && text.trim() !== b.name) void run( "branch-rename", { branch: b.name, name: text.trim() }, t("done.rename", { branch: text.trim() }) ); }} aria-label={t("renameTo")} placeholder={t("renameTo")} className={smallInput} />
)} {mode === "upstream" && (
)}
); }; const remoteActions = (r: GitRemoteBranch) => (
{mode === "new" && newBranchForm(r.name)}
); const row = ( key: string, name: string, sub: React.ReactNode, badges: React.ReactNode, actions: () => React.ReactNode ) => (
  • {selected === key && actions()}
  • ); const badge = (label: string, tone = "text-fg-muted border-border-light") => ( {label} ); const localRow = (b: GitLocalBranch) => row( `l:${b.name}`, b.name, <> {b.subject} {b.date ? ` · ${timeAgo(b.date)}` : ""} , <> {b.current && badge(t("current"), "text-accent border-accent/40")} {b.protected && badge(t("protected"))} {b.worktree && ( {badge(t("worktree"))} )} {b.upstream && ( {b.gone ? ( {t("gone")} ) : ( <> {(b.ahead ?? 0) > 0 && ( {b.ahead} )} {(b.behind ?? 0) > 0 && ( {b.behind} )} {!b.ahead && !b.behind && } )} )} , () => localActions(b) ); const remoteRow = (r: GitRemoteBranch) => row( `r:${r.name}`, r.name, <> {r.subject} {r.date ? ` · ${timeAgo(r.date)}` : ""} , null, () => remoteActions(r) ); const tabBtn = (id: "branches" | "tags", label: string, icon: React.ReactNode) => ( ); return (
    e.stopPropagation()} className="flex max-h-full w-full max-w-xl min-w-0 flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40" >

    {t("title")}

    {tabBtn("branches", t("branches"), )} {tabBtn("tags", t("tags"), )}
    setQuery(e.target.value)} onKeyDown={(e) => { if (e.key === "ArrowDown") { e.preventDefault(); panelRef.current?.querySelector("[data-ref-row]")?.focus(); } }} placeholder={tab === "tags" ? t("searchTags") : t("searchBranches")} aria-label={tab === "tags" ? t("searchTags") : t("searchBranches")} className={`${smallInput} !text-xs`} />
    {merging &&

    {t("merging")}

    } {error && (
                {error}
              
    )}
    {!refs ? (

    {t("loading")}

    ) : !refs.available ? (

    {t("unavailable")}

    ) : tab === "tags" ? ( match(x.name))} busy={busy} merging={merging} dirty={dirty} run={run} /> ) : ( <>

    {t("local")}

    {local.length ? (
      {local.map(localRow)}
    ) : (

    {t("noMatch")}

    )} {remoteGroups.map((g) => (

    {t("remote", { remote: g.remote })}

      {g.branches.map(remoteRow)}
    ))} {refs.truncated && (

    {t("truncated")}

    )} )}
    ); } export default BranchManager;