/** * @file The lane's git action row: ahead/behind against the upstream, and the * writes `POST /api/lanes/:id/git/:action` offers that are not per-file — * fetch, pull, push, switch branch, and create/delete a branch. Committing is * NOT here: it is the Changes panel's staged-only commit box, beside the * per-file stage/unstage/discard it commits — one way to commit, and it only * ever takes what the user staged. * * It renders at the top of the Changes panel (`LaneGitHeader`), its actions as * icons on the same row as the branch line, rather than in the lane card's ⋯ menu: that menu holds the rare and dangerous (`reset * worktree`), and these are the ordinary moves a lane needs several times an hour. * Nothing here decides what an action may do; the server does (pull is always * --ff-only, switch refuses a dirty tree), and this only reports what it said. * Failures print git's own text, because "! [rejected] non-fast-forward" tells * the user more than any sentence this file could write. * @author Nguyễn Ngọc Trí Vĩ */ import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ArrowDown, ArrowUp, GitBranch, Plus, RefreshCw, Trash2, Upload } from "lucide-react"; import { api } from "../../lib/api"; import type { LaneGitFacts, LaneWorktree } from "../../lib/types"; type Action = "fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete"; function Btn({ label, icon, busy, disabled, title, compact, onClick, }: { label: string; icon?: React.ReactNode; busy?: boolean; disabled?: boolean; title?: string; /** Icon only; the label becomes the accessible name, the hint the tooltip. */ compact?: boolean; onClick: () => void; }) { return ( ); } export function LaneGitBar({ laneId, git, onChanged, onNewLane, lead, }: { laneId: number; git: LaneGitFacts; /** Rendered at the start of the action row: the header's branch line, so * the facts and the buttons that act on them read as one row. */ lead?: React.ReactNode; /** Called after any action that could have moved the working copy. */ onChanged: () => void; /** Open the Add-lane flow for a sibling worktree of this repo, optionally on * a branch this bar just created. Omitted where no such flow is reachable. */ onNewLane?: (branch?: string) => void; }) { const { t } = useTranslation(["lanes"]); const [busy, setBusy] = useState(null); const [error, setError] = useState(null); const [switching, setSwitching] = useState(false); const [branches, setBranches] = useState(null); const [worktrees, setWorktrees] = useState([]); const [picked, setPicked] = useState(""); const [newBranch, setNewBranch] = useState(""); // The Changes panel is not keyed by lane, so this bar outlives a lane switch. // All of the state above describes ONE lane — a branch picked for lane A, // applied after switching, would move B's HEAD — so it is reset during // render the moment the id changes, before anything can act on it. const [shownLane, setShownLane] = useState(laneId); // One action at a time, checked synchronously: `busy` is state, and a second // Enter can land before the render that disables anything. const inFlight = useRef(false); if (shownLane !== laneId) { setShownLane(laneId); setBusy(null); setError(null); setSwitching(false); setBranches(null); setWorktrees([]); setPicked(""); setNewBranch(""); inFlight.current = false; } const laneNow = useRef(laneId); laneNow.current = laneId; if (!git.available) return null; const dirty = git.dirty + git.untracked; const run = async (action: Action, body?: { branch?: string }) => { if (inFlight.current) return; inFlight.current = true; const lane = laneId; setBusy(action); setError(null); try { await api.lanes.gitAction(lane, action, body || {}); // Landed after a lane switch: every write below belongs to the lane // no longer shown. if (laneNow.current !== lane) return; // The branch list this bar is showing is stale the moment a branch is // created, deleted or switched to, so it is re-read rather than patched. if (action === "switch" || action.startsWith("branch-")) { if (action === "switch") setSwitching(false); else setNewBranch(""); setPicked(""); setBranches(null); await refreshBranches(); } onChanged(); } catch (err) { // The server appends git's own stderr to the message, so this is the // rejection text verbatim — hence the
 below rather than a line.
      // ENOREMOTE is the one refusal with no git text to show: the server's
      // sentence is English and says less than a translated one.
      if (laneNow.current === lane)
        setError(
          (err as { code?: string })?.code === "ENOREMOTE"
            ? t("gitBar.errNoRemote")
            : err instanceof Error
              ? err.message
              : String(err)
        );
    } finally {
      if (laneNow.current === lane) {
        inFlight.current = false;
        setBusy(null);
      }
    }
  };

  const refreshBranches = async () => {
    try {
      const r = await api.lanes.gitBranches(laneId);
      if (laneNow.current !== laneId) return;
      setBranches(r.branches);
      setWorktrees(r.worktrees || []);
    } catch {
      if (laneNow.current === laneId) setBranches([]);
    }
  };

  const openSwitch = async () => {
    setSwitching((v) => !v);
    setError(null);
    if (branches) return;
    await refreshBranches();
  };

  const heldBy = new Map(
    worktrees.filter((w) => w.branch).map((w) => [w.branch as string, w.path])
  );

  return (
    
{lead} {/* Ahead/behind first: it is the fact that decides which of the buttons beside it the user actually wants. */} {/* Null counts with an upstream named means git could not compare the two (the upstream ref is gone): claiming "in sync" there would be a lie, so it reads as not pushed. */} {git.upstream && git.ahead != null && git.behind != null ? ( {(git.ahead ?? 0) > 0 && ( {git.ahead} )} {(git.behind ?? 0) > 0 && ( {git.behind} )} {!git.ahead && !git.behind && {t("gitBar.inSync")}} ) : ( git.hasRemote && ( {t("gitBar.noUpstream")} ) )}
} busy={busy === "fetch"} disabled={!git.hasRemote || !!busy} title={t("gitBar.fetchHint")} onClick={() => run("fetch")} /> } busy={busy === "pull"} disabled={!git.hasRemote || !!busy} title={t("gitBar.pullHint")} onClick={() => run("pull")} /> } busy={busy === "push"} disabled={!git.hasRemote || !!busy} title={t("gitBar.pushHint")} onClick={() => run("push")} /> } disabled={!!busy} // A dirty tree is refused server-side; saying so before the click // beats a red box after it. title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint")} onClick={openSwitch} />
{switching && (
0} title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint")} onClick={() => void run("switch", { branch: picked })} /> {onNewLane && ( onNewLane(picked)} /> )} } busy={busy === "branch-delete"} disabled={!picked || picked === git.branch || !!heldBy.get(picked)} title={t("gitBar.branchDeleteHint")} onClick={() => void run("branch-delete", { branch: picked })} /> {dirty > 0 && ( {t("gitBar.switchDirty")} )}
)} {switching && (
setNewBranch(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && newBranch.trim()) void run("branch-create", { branch: newBranch.trim() }); }} placeholder={t("gitBar.branchNewPlaceholder")} aria-label={t("gitBar.branchNewPlaceholder")} className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none" /> } busy={busy === "branch-create"} disabled={!newBranch.trim()} // Creating does not switch: the point is usually a sibling lane, // and moving this pane's HEAD under a working agent is not. title={t("gitBar.branchCreateHint")} onClick={() => void run("branch-create", { branch: newBranch.trim() })} />
)} {error && (
          {error}
        
)}
); } export default LaneGitBar;