/** * @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, commit (everything, or only what the Changes panel has * staged), switch branch, and create/delete a branch. Per-file staging and * discard live in the Changes panel, beside the file they act on. * * It renders beside the read-only git line in LaneCard rather than inside the * ⋯ menu: that menu holds the rare and dangerous (`reset worktree`), and these * are the opposite — 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 { useState } from "react"; import { useTranslation } from "react-i18next"; import { ArrowDown, ArrowUp, Check, GitBranch, Plus, RefreshCw, Trash2, Upload, X, } from "lucide-react"; import { api } from "../../lib/api"; import type { LaneGitFacts, LaneWorktree } from "../../lib/types"; type Action = "fetch" | "pull" | "push" | "commit" | "switch" | "branch-create" | "branch-delete"; function Btn({ label, icon, busy, disabled, title, onClick, }: { label: string; icon?: React.ReactNode; busy?: boolean; disabled?: boolean; title?: string; onClick: () => void; }) { return ( ); } export function LaneGitBar({ laneId, git, onChanged, onNewLane, }: { laneId: number; git: LaneGitFacts; /** 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 [committing, setCommitting] = useState(false); const [message, setMessage] = useState(""); const [switching, setSwitching] = useState(false); const [branches, setBranches] = useState(null); const [worktrees, setWorktrees] = useState([]); const [picked, setPicked] = useState(""); const [newBranch, setNewBranch] = useState(""); if (!git.available) return null; const dirty = git.dirty + git.untracked; const run = async ( action: Action, body?: { message?: string; only?: "staged"; branch?: string } ) => { setBusy(action); setError(null); try { await api.lanes.gitAction(laneId, action, body || {}); setCommitting(false); setMessage(""); // 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") setSwitching(false); else if (action.startsWith("branch-")) { 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.
      setError(err instanceof Error ? err.message : String(err));
    } finally {
      setBusy(null);
    }
  };

  const refreshBranches = async () => {
    try {
      const r = await api.lanes.gitBranches(laneId);
      setBranches(r.branches);
      setWorktrees(r.worktrees || []);
    } catch {
      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 (
    
{/* Ahead/behind first: it is the fact that decides which of the buttons beside it the user actually wants. */} {git.upstream ? ( {(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={dirty === 0 || !!busy} title={dirty === 0 ? t("gitBar.commitNothing") : t("gitBar.commitHint")} onClick={() => { setCommitting((v) => !v); setError(null); }} /> } 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} />
{committing && (
setMessage(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && message.trim()) void run("commit", { message }); if (e.key === "Escape") setCommitting(false); }} placeholder={t("gitBar.commitPlaceholder")} aria-label={t("gitBar.commitPlaceholder")} className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none" /> void run("commit", { message })} /> {/* The Changes panel's per-file staging only means something if a commit can take the index as it stands. The server answers ENOCHANGES when nothing is staged, and that text lands below. */} void run("commit", { message, only: "staged" })} />
)} {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;