428519335d
- The run form's prompt box offers suggestions again: `/` lists the run
cwd's project and user commands and skills plus the CLI built-ins
(claude lanes only), `@` lists files under the cwd; Enter picks while
the list is open and sends otherwise. The 2f39f4e rewrite had left a
bare textarea that ignored both.
- Active runs lists only the current lane's runs and external sessions
(sessions matched to the lane by longest path-boundary cwd), instead of
every lane's.
- The Changes panel's git line and its actions are one compact row in one
card, and the mode/range toolbar is one wrapping row.
- The lane card hides itself when it has nothing to show, instead of
leaving an empty frame where the git row used to be.
356 lines
13 KiB
TypeScript
356 lines
13 KiB
TypeScript
/**
|
|
* @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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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 (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
disabled={disabled || busy}
|
|
title={title}
|
|
aria-label={compact ? label : undefined}
|
|
className={`flex items-center gap-1 rounded text-[11px] text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40 ${
|
|
compact ? "h-6 w-6 justify-center" : "border border-border-light px-1.5 py-0.5"
|
|
}`}
|
|
>
|
|
{icon}
|
|
{!compact && label}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
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<Action | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [switching, setSwitching] = useState(false);
|
|
const [branches, setBranches] = useState<string[] | null>(null);
|
|
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
|
|
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 <pre> 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 (
|
|
<div data-testid="lane-git-bar" className="space-y-1">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
{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 ? (
|
|
<span
|
|
data-testid="lane-git-tracking"
|
|
title={git.upstream}
|
|
className="flex shrink-0 items-center gap-1 font-mono text-[11px] text-fg-muted"
|
|
>
|
|
{(git.ahead ?? 0) > 0 && (
|
|
<span className="flex items-center text-status-warning">
|
|
<ArrowUp className="h-3 w-3" />
|
|
{git.ahead}
|
|
</span>
|
|
)}
|
|
{(git.behind ?? 0) > 0 && (
|
|
<span className="flex items-center text-accent">
|
|
<ArrowDown className="h-3 w-3" />
|
|
{git.behind}
|
|
</span>
|
|
)}
|
|
{!git.ahead && !git.behind && <span>{t("gitBar.inSync")}</span>}
|
|
</span>
|
|
) : (
|
|
git.hasRemote && (
|
|
<span className="shrink-0 font-mono text-[11px] text-fg-muted">
|
|
{t("gitBar.noUpstream")}
|
|
</span>
|
|
)
|
|
)}
|
|
|
|
<div className="ml-auto flex shrink-0 items-center">
|
|
<Btn
|
|
compact
|
|
label={t("gitBar.fetch")}
|
|
icon={<RefreshCw className="h-3.5 w-3.5" />}
|
|
busy={busy === "fetch"}
|
|
disabled={!git.hasRemote || !!busy}
|
|
title={t("gitBar.fetchHint")}
|
|
onClick={() => run("fetch")}
|
|
/>
|
|
<Btn
|
|
compact
|
|
label={t("gitBar.pull")}
|
|
icon={<ArrowDown className="h-3.5 w-3.5" />}
|
|
busy={busy === "pull"}
|
|
disabled={!git.hasRemote || !!busy}
|
|
title={t("gitBar.pullHint")}
|
|
onClick={() => run("pull")}
|
|
/>
|
|
<Btn
|
|
compact
|
|
label={t("gitBar.push")}
|
|
icon={<Upload className="h-3.5 w-3.5" />}
|
|
busy={busy === "push"}
|
|
disabled={!git.hasRemote || !!busy}
|
|
title={t("gitBar.pushHint")}
|
|
onClick={() => run("push")}
|
|
/>
|
|
<Btn
|
|
compact
|
|
label={t("gitBar.switch")}
|
|
icon={<GitBranch className="h-3.5 w-3.5" />}
|
|
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}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{switching && (
|
|
<div className="flex items-center gap-1.5">
|
|
<select
|
|
aria-label={t("gitBar.switchLabel")}
|
|
value={picked}
|
|
disabled={!!busy}
|
|
onChange={(e) => setPicked(e.target.value)}
|
|
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 focus:border-accent focus:outline-none disabled:opacity-40"
|
|
>
|
|
<option value="">{t("gitBar.switchLabel")}</option>
|
|
{(branches || []).map((b) => {
|
|
const held = heldBy.get(b);
|
|
return (
|
|
<option key={b} value={b}>
|
|
{b}
|
|
{b === git.branch ? ` — ${t("gitBar.switchCurrent")}` : held ? ` — ${held}` : ""}
|
|
</option>
|
|
);
|
|
})}
|
|
</select>
|
|
<Btn
|
|
label={t("gitBar.switchApply")}
|
|
busy={busy === "switch"}
|
|
// A branch git already has checked out somewhere else cannot be
|
|
// switched to — git allows one worktree per branch.
|
|
disabled={!picked || picked === git.branch || !!heldBy.get(picked) || dirty > 0}
|
|
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint")}
|
|
onClick={() => void run("switch", { branch: picked })}
|
|
/>
|
|
{onNewLane && (
|
|
<Btn
|
|
label={t("gitBar.newLane")}
|
|
// A branch any worktree holds — this lane's own included — is
|
|
// one `addWorktree` refuses with EBRANCHBUSY; say so up front.
|
|
title={
|
|
picked && (heldBy.has(picked) || picked === git.branch)
|
|
? t("gitBar.newLaneHeld", {
|
|
path: heldBy.get(picked) ?? t("gitBar.switchCurrent"),
|
|
})
|
|
: t("gitBar.newLaneHint")
|
|
}
|
|
disabled={!picked || heldBy.has(picked) || picked === git.branch}
|
|
// The sibling lane is created through the normal Add-lane flow,
|
|
// prefilled — one provisioning path, not a second one here.
|
|
onClick={() => onNewLane(picked)}
|
|
/>
|
|
)}
|
|
<Btn
|
|
label={t("gitBar.branchDelete")}
|
|
icon={<Trash2 className="h-3 w-3" />}
|
|
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 && (
|
|
<span className="text-[10px] text-status-warning">{t("gitBar.switchDirty")}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{switching && (
|
|
<div className="flex items-center gap-1.5">
|
|
<input
|
|
value={newBranch}
|
|
onChange={(e) => 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"
|
|
/>
|
|
<Btn
|
|
label={t("gitBar.branchCreate")}
|
|
icon={<Plus className="h-3 w-3" />}
|
|
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() })}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<pre
|
|
role="alert"
|
|
data-testid="lane-git-bar-error"
|
|
className="max-h-24 overflow-auto whitespace-pre-wrap rounded border border-status-danger/40 bg-status-danger/10 px-2 py-1 text-[10px] text-status-danger"
|
|
>
|
|
{error}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default LaneGitBar;
|