/** * @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, create/delete a branch, publish a repo * that has no remote yet (a URL, then `origin` + `push -u`), and start a merge * (the picked branch, or the upstream once diverged; resolving it is the * Changes panel's job). 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. The one exception * is `EGITAUTH` (issue #45): the dashboard's git has no terminal to prompt * in, so a remote wanting a login opens a form here and the SAME action is * resent with the typed credential — the token is never rendered back. * Fetch, pull, push and publish say so in a toast when they succeed; a merge, * a push, a publish and a branch delete ask through the themed confirm first; the rows wrap in a narrow panel. * @author Nguyễn Ngọc Trí Vĩ */ import { Checkbox } from "../Checkbox"; import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ArrowDown, ArrowUp, CloudUpload, GitBranch, GitMerge, Plus, RefreshCw, Trash2, Upload, } from "lucide-react"; import { api, gitTarget, type GitAuthNeeded } from "../../lib/api"; import { toast } from "../../lib/toast"; import { confirmDialog } from "../../lib/confirm"; import { isSubmitEnter } from "../../lib/keys"; import type { LaneGitFacts, LaneWorktree } from "../../lib/types"; type Action = "fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete" | "merge" | "publish"; type RunBody = Parameters[2]; const LOCAL_HOST = /^(localhost|127\.|\[::1\])/; 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, session, source, git, onChanged, onNewLane, lead, }: { laneId: number; /** Work in that session's own worktree (see `gitTarget`). */ session?: string; /** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */ source?: 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(""); // Set when git needed a login for `action`; the form resends that action. const [login, setLogin] = useState<{ action: Action; auth: GitAuthNeeded } | null>(null); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [remember, setRemember] = useState(true); const [publishing, setPublishing] = useState(false); const [url, setUrl] = 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(""); setLogin(null); setPassword(""); setPublishing(false); setUrl(""); inFlight.current = false; } const laneNow = useRef(laneId); laneNow.current = laneId; if (!git.available) return null; const dirty = git.dirty + git.untracked; const merging = !!git.merging; const run = async (action: Action, body?: RunBody) => { if (inFlight.current) return; inFlight.current = true; const lane = laneId; setBusy(action); setError(null); try { const res = await api.lanes.gitAction(gitTarget(lane, session, source), action, body || {}); // Landed after a lane switch: every write below belongs to the lane // no longer shown. if (laneNow.current !== lane) return; // A merge that stopped on conflicts still succeeded in starting; the // Changes panel below lists the files to resolve. if (res?.conflicts?.length) setError(t("gitBar.mergeConflicts", { count: res.conflicts.length })); setLogin(null); setPassword(""); if (action === "fetch" || action === "pull" || action === "push" || action === "publish") toast.success(t(`gitBar.done.${action}`)); // 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 === "publish") { setPublishing(false); setUrl(""); } else 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.
      const auth = (err as { code?: string; auth?: GitAuthNeeded })?.auth;
      if (laneNow.current === lane && (err as { code?: string })?.code === "EGITAUTH" && auth) {
        setLogin({ action, auth });
        setUsername((u) => u || auth.username || "");
        setPassword("");
      } else 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(gitTarget(laneId, session, source));
      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();
  };

  // Writes that leave this machine (push, publish) or delete a ref ask
  // first. Asked HERE, at the click — never inside `run` — so the retry a git
  // login sends is not asked twice.
  const confirmRun = async (
    opts: { title: string; message: string; destructive?: boolean },
    action: Action,
    body?: RunBody
  ) => {
    if (await confirmDialog({ destructive: false, confirmLabel: opts.title, ...opts })) {
      void run(action, body);
    }
  };
  const push = () =>
    confirmRun(
      git.upstream
        ? {
            title: t("gitBar.push"),
            message: t("gitBar.pushConfirm", {
              count: git.ahead ?? 0,
              branch: git.branch,
              upstream: git.upstream,
            }),
          }
        : {
            title: t("gitBar.publishBranch"),
            message: t("gitBar.publishBranchConfirm", { branch: git.branch }),
          },
      "push"
    );
  const publish = () =>
    confirmRun(
      {
        title: t("gitBar.publishApply"),
        message: t("gitBar.publishConfirm", { branch: git.branch, url: url.trim() }),
      },
      "publish",
      { url: url.trim() }
    );

  const merge = async (branch: string) => {
    const ok = await confirmDialog({
      title: t("gitBar.merge"),
      message: t("gitBar.mergeConfirm", { branch, current: git.branch }),
      confirmLabel: t("gitBar.merge"),
      destructive: false,
    });
    if (ok) void run("merge", { branch });
  };

  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.unborn ? ( {t("gitBar.noCommits")} ) : 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")} ) )} {/* Diverged: a --ff-only pull can only refuse, so offer the merge. */} {git.upstream && (git.ahead ?? 0) > 0 && (git.behind ?? 0) > 0 && !merging && ( } busy={busy === "merge"} disabled={!!busy || dirty > 0} title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.mergeUpstreamHint")} onClick={() => void merge(git.upstream as string)} /> )} {/* A repo with no commit has nothing to push, fetch into or branch from — the one next step is the commit box below. */} {git.unborn ? ( {t("gitBar.unbornHint")} ) : (
} busy={busy === "fetch"} disabled={!git.hasRemote || !!busy} title={t("gitBar.fetchHint")} onClick={() => run("fetch")} /> } busy={busy === "pull"} disabled={!git.hasRemote || !!busy || merging} title={ merging ? t("gitBar.merging", { op: git.operation ?? "merge" }) : t("gitBar.pullHint") } onClick={() => run("pull")} /> HEAD` either way; a branch with no // upstream is being published, not updated, so it says that. label={git.hasRemote && !git.upstream ? t("gitBar.publishBranch") : t("gitBar.push")} icon={} busy={busy === "push"} disabled={!git.hasRemote || !!busy} title={ git.hasRemote && !git.upstream ? t("gitBar.publishBranchHint") : t("gitBar.pushHint") } onClick={() => void push()} /> {!git.hasRemote && ( } disabled={!!busy} title={t("gitBar.publishHint")} onClick={() => { setPublishing((v) => !v); setError(null); }} /> )} } disabled={!!busy || merging} // A dirty tree is refused server-side; saying so before the click // beats a red box after it. title={ merging ? t("gitBar.merging", { op: git.operation ?? "merge" }) : 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 })} /> } busy={busy === "merge"} disabled={!picked || picked === git.branch || dirty > 0 || !!busy} title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.mergeHint")} onClick={() => void merge(picked)} /> {onNewLane && ( onNewLane(picked)} /> )} } busy={busy === "branch-delete"} disabled={!picked || picked === git.branch || !!heldBy.get(picked)} title={t("gitBar.branchDeleteHint")} onClick={() => void confirmRun( { title: t("gitBar.branchDelete"), message: t("gitBar.branchDeleteConfirm", { branch: picked }), destructive: true, }, "branch-delete", { branch: picked } ) } /> {dirty > 0 && ( {t("gitBar.switchDirty")} )}
)} {switching && (
setNewBranch(e.target.value)} onKeyDown={(e) => { if (isSubmitEnter(e) && 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() })} />
)} {publishing && !git.hasRemote && !git.unborn && (
setUrl(e.target.value)} onKeyDown={(e) => { if (isSubmitEnter(e) && url.trim()) void publish(); }} // The server decides which URLs are acceptable (https without a // token, ssh, user@host:path); this only names the two usual ones. placeholder={t("gitBar.publishUrl")} aria-label={t("gitBar.publishUrl")} 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 === "publish"} disabled={!url.trim()} title={t("gitBar.publishHint")} onClick={() => void publish()} />
)} {login && (
{ e.preventDefault(); const { protocol, host } = login.auth; void run(login.action, { credentials: { protocol, host, username: username.trim(), password, remember }, }); }} >

{t("gitBar.loginTitle", { host: login.auth.host })}

{login.auth.rejected && (

{t("gitBar.loginRejected")}

)}
setUsername(e.target.value)} className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none" /> setPassword(e.target.value)} className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none" />

{t("gitBar.loginTokenHint")}

{/* The token crosses the network in the clear on a plain-http LAN address. */} {location.protocol === "http:" && !LOCAL_HOST.test(location.hostname) && (

{t("gitBar.loginInsecure")}

)}
{ setLogin(null); setPassword(""); }} />
)} {error && (
          {error}
        
)}
); } export default LaneGitBar;