2cc492bb88
GET /git/refs lists local branches (upstream, ahead/behind, last commit, merged, held by a worktree, protected), remote branches grouped by remote and tags (target, annotated message/tagger/date). New writes in server/lib/git-refs.js, dispatched from git-ops.js: branch-rename, branch-upstream (set/unset), tag-create (light/annotated), tag-delete (confirm), tag-push (one tag to origin, no --tags, no force), and branch-create with a looked-up from + switch on a clean tree. Every ref is looked up, never passed through; repo-wide writes are repo-root only (ESUBDIR) and refused during any operation (EMERGING). Client: git/BranchManager + TagManager dialog opened from the git bar's tags icon, en+vi (gitRefs namespace), keyboard navigable. # Conflicts: # CLAUDE.md # client/src/components/lanes/LaneGitBar.tsx # docs/API.md # server/__tests__/lanes-api.test.js # server/routes/lanes.js
795 lines
31 KiB
TypeScript
795 lines
31 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 (a local one, or one only a remote has —
|
|
* listed under "On the remote" and checked out as a new tracking branch; the
|
|
* list is re-read after every fetch and pull), 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 unless told what to do with the
|
|
* changes), and this only reports what it said. With uncommitted changes,
|
|
* Switch branch asks first — **Bring my changes** (`changes: "carry"`; a
|
|
* file git would overwrite comes back as ECARRYCONFLICT, listed, with Shelve
|
|
* and switch offered instead) or **Shelve and switch** (`changes: "shelve"`)
|
|
* — and the new-branch row offers **Create & switch** (`switch: true`, the
|
|
* changes come along; the default on Enter while the tree is dirty) beside
|
|
* plain Create. A switch onto a branch that has shelves asks whether to
|
|
* restore the newest (never automatically), and a write the server answers
|
|
* EAGENTRUNNING is confirmed and resent (`withAgentConfirm`'s dialog).
|
|
* 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.
|
|
* The tags icon opens the Branches panel (`git/BranchManager`: every branch and tag, rename, upstream, tags).
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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 GitSource, 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";
|
|
import { restoreShelf } from "./LaneShelves";
|
|
import { BranchesButton } from "./git/BranchesButton";
|
|
|
|
type Action =
|
|
"fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete" | "merge" | "publish";
|
|
type RunBody = Parameters<typeof api.lanes.gitAction>[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 (
|
|
<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,
|
|
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?: GitSource;
|
|
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);
|
|
// Fetched remote branches with no local one yet (`origin/feature`).
|
|
const [remoteBranches, setRemoteBranches] = useState<string[]>([]);
|
|
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
|
|
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("");
|
|
// A switch with uncommitted changes asks how to take them: set to the
|
|
// branch picked (and the files git refused to carry, after ECARRYCONFLICT).
|
|
const [choice, setChoice] = useState<{ branch: string; conflict?: string[] } | null>(null);
|
|
// 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);
|
|
setRemoteBranches([]);
|
|
setWorktrees([]);
|
|
setPicked("");
|
|
setNewBranch("");
|
|
setLogin(null);
|
|
setPassword("");
|
|
setPublishing(false);
|
|
setUrl("");
|
|
setChoice(null);
|
|
inFlight.current = false;
|
|
}
|
|
const laneNow = useRef(laneId);
|
|
laneNow.current = laneId;
|
|
|
|
if (!git.available) return null;
|
|
const dirty = git.dirty + git.untracked;
|
|
// A remote branch: switching creates its local tracking branch; it cannot
|
|
// be deleted here or given a sibling worktree before that.
|
|
const pickedRemote = remoteBranches.includes(picked);
|
|
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);
|
|
// EAGENTRUNNING: asked AFTER the request settles, so the resend is not
|
|
// refused as a second action in flight.
|
|
let askAgent = false;
|
|
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("");
|
|
setChoice(null);
|
|
setBranches(null);
|
|
await refreshBranches();
|
|
} else if ((action === "fetch" || action === "pull") && branches !== null) {
|
|
// A fetch may have brought branches the list does not show yet.
|
|
await refreshBranches();
|
|
}
|
|
if (res?.shelved) toast.success(t("gitBar.shelvedOn", { branch: res.shelved.branch }));
|
|
onChanged();
|
|
// The branch switched to has changes shelved on it: offer them back —
|
|
// asking, never restoring on its own.
|
|
const offer = action === "switch" ? res?.shelves?.[0] : undefined;
|
|
if (offer) {
|
|
const yes = await confirmDialog({
|
|
title: t("gitBar.restoreOfferTitle"),
|
|
message: t("gitBar.restoreOffer", {
|
|
branch: offer.branch,
|
|
count: res?.shelves?.length ?? 1,
|
|
files: offer.files.length,
|
|
}),
|
|
confirmLabel: t("shelves.restore"),
|
|
destructive: false,
|
|
});
|
|
if (yes && laneNow.current === lane) {
|
|
await restoreShelf(gitTarget(lane, session, source), offer);
|
|
if (laneNow.current === lane) 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.
|
|
const auth = (err as { code?: string; auth?: GitAuthNeeded })?.auth;
|
|
const code = (err as { code?: string })?.code;
|
|
if (laneNow.current === lane && code === "EAGENTRUNNING" && body?.confirm !== true) {
|
|
askAgent = true;
|
|
} else if (laneNow.current === lane && code === "ECARRYCONFLICT" && body?.branch) {
|
|
// git would overwrite these files: show them and offer the shelf.
|
|
setChoice({
|
|
branch: body.branch,
|
|
conflict: (err as { files?: string[] }).files ?? [],
|
|
});
|
|
} else if (laneNow.current === lane && 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);
|
|
}
|
|
}
|
|
if (askAgent) {
|
|
const ok = await confirmDialog({
|
|
title: t("shelves.agentTitle"),
|
|
message: t("shelves.agentMessage"),
|
|
confirmLabel: t("shelves.agentConfirm"),
|
|
destructive: false,
|
|
});
|
|
if (ok && laneNow.current === lane) void run(action, { ...body, confirm: true });
|
|
}
|
|
};
|
|
|
|
const refreshBranches = async () => {
|
|
try {
|
|
const r = await api.lanes.gitBranches(gitTarget(laneId, session, source));
|
|
if (laneNow.current !== laneId) return;
|
|
setBranches(r.branches);
|
|
setRemoteBranches(r.remotes || []);
|
|
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 = async () => {
|
|
let ahead = git.ahead ?? 0;
|
|
if (git.upstream && ahead === 0) {
|
|
// The facts can be up to 30 s old (an agent may have just committed):
|
|
// ask git again before saying there is nothing to push — and never
|
|
// ask to confirm pushing zero commits.
|
|
const read = await api.lanes.git(gitTarget(laneId, session, source)).catch(() => null);
|
|
const fresh = read?.available ? read : null;
|
|
ahead = fresh?.ahead ?? 0;
|
|
if (fresh?.upstream && ahead === 0) {
|
|
toast.info(t("gitBar.nothingToPush", { upstream: fresh.upstream }));
|
|
onChanged();
|
|
return;
|
|
}
|
|
}
|
|
return confirmRun(
|
|
git.upstream
|
|
? {
|
|
title: t("gitBar.push"),
|
|
message: t("gitBar.pushConfirm", {
|
|
count: ahead,
|
|
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 });
|
|
};
|
|
|
|
// Switch branch: straight away on a clean tree; with changes, ask how.
|
|
const switchTo = (branch: string) => {
|
|
if (dirty > 0) {
|
|
setChoice({ branch });
|
|
setError(null);
|
|
} else void run("switch", { branch });
|
|
};
|
|
// Enter in the new-branch box: with changes, Create & switch (they come
|
|
// along); on a clean tree, plain Create as before.
|
|
const createBranch = (andSwitch: boolean) => {
|
|
const branch = newBranch.trim();
|
|
if (branch) void run("branch-create", andSwitch ? { branch, switch: true } : { branch });
|
|
};
|
|
|
|
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 flex-wrap items-center gap-x-2 gap-y-1">
|
|
{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 ? (
|
|
<span className="shrink-0 font-mono text-[11px] text-fg-muted">
|
|
{t("gitBar.noCommits")}
|
|
</span>
|
|
) : 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>
|
|
)
|
|
)}
|
|
|
|
{/* Diverged: a --ff-only pull can only refuse, so offer the merge. */}
|
|
{git.upstream && (git.ahead ?? 0) > 0 && (git.behind ?? 0) > 0 && !merging && (
|
|
<Btn
|
|
label={t("gitBar.mergeUpstream", { upstream: git.upstream })}
|
|
icon={<GitMerge className="h-3 w-3" />}
|
|
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 ? (
|
|
<span className="ml-auto text-[10px] text-fg-muted">{t("gitBar.unbornHint")}</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 || merging}
|
|
title={
|
|
merging
|
|
? t("gitBar.merging", { op: git.operation ?? "merge" })
|
|
: t("gitBar.pullHint")
|
|
}
|
|
onClick={() => run("pull")}
|
|
/>
|
|
<Btn
|
|
compact
|
|
// Same `push -u <remote> 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={<Upload className="h-3.5 w-3.5" />}
|
|
busy={busy === "push"}
|
|
disabled={!git.hasRemote || !!busy}
|
|
title={
|
|
git.hasRemote && !git.upstream
|
|
? t("gitBar.publishBranchHint")
|
|
: t("gitBar.pushHint")
|
|
}
|
|
onClick={() => void push()}
|
|
/>
|
|
{!git.hasRemote && (
|
|
<Btn
|
|
compact
|
|
label={t("gitBar.publish")}
|
|
icon={<CloudUpload className="h-3.5 w-3.5" />}
|
|
disabled={!!busy}
|
|
title={t("gitBar.publishHint")}
|
|
onClick={() => {
|
|
setPublishing((v) => !v);
|
|
setError(null);
|
|
}}
|
|
/>
|
|
)}
|
|
<Btn
|
|
compact
|
|
label={t("gitBar.switch")}
|
|
icon={<GitBranch className="h-3.5 w-3.5" />}
|
|
disabled={!!busy || merging}
|
|
// With changes, Switch branch asks whether to bring or shelve
|
|
// them; the hint says so before the click.
|
|
title={
|
|
merging
|
|
? t("gitBar.merging", { op: git.operation ?? "merge" })
|
|
: dirty > 0
|
|
? t("gitBar.switchDirtyHint", { count: dirty })
|
|
: t("gitBar.switchHint")
|
|
}
|
|
onClick={openSwitch}
|
|
/>
|
|
<BranchesButton
|
|
laneId={laneId}
|
|
session={session}
|
|
source={source}
|
|
dirty={dirty}
|
|
merging={merging}
|
|
disabled={!!busy}
|
|
onChanged={() => {
|
|
// A rename, delete or new branch makes the switcher's list stale.
|
|
if (branches !== null) void refreshBranches();
|
|
onChanged();
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{switching && (
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
<select
|
|
aria-label={t("gitBar.switchLabel")}
|
|
value={picked}
|
|
disabled={!!busy}
|
|
onChange={(e) => setPicked(e.target.value)}
|
|
className="select min-w-[8rem] flex-1 !rounded !py-0.5 !pl-2 !pr-6 font-mono !text-[11px]"
|
|
>
|
|
<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>
|
|
);
|
|
})}
|
|
{remoteBranches.length > 0 && (
|
|
<optgroup label={t("gitBar.remoteBranches")}>
|
|
{remoteBranches.map((b) => (
|
|
<option key={`remote:${b}`} value={b}>
|
|
{b}
|
|
</option>
|
|
))}
|
|
</optgroup>
|
|
)}
|
|
</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)}
|
|
title={
|
|
dirty > 0 ? t("gitBar.switchDirtyHint", { count: dirty }) : t("gitBar.switchHint")
|
|
}
|
|
onClick={() => switchTo(picked)}
|
|
/>
|
|
<Btn
|
|
label={t("gitBar.merge")}
|
|
icon={<GitMerge className="h-3 w-3" />}
|
|
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 && (
|
|
<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 || pickedRemote}
|
|
// 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) || pickedRemote}
|
|
title={t("gitBar.branchDeleteHint")}
|
|
onClick={() =>
|
|
void confirmRun(
|
|
{
|
|
title: t("gitBar.branchDelete"),
|
|
message: t("gitBar.branchDeleteConfirm", { branch: picked }),
|
|
destructive: true,
|
|
},
|
|
"branch-delete",
|
|
{ branch: picked }
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{switching && choice && (
|
|
<div
|
|
data-testid="lane-git-switch-choice"
|
|
role="group"
|
|
aria-label={t("gitBar.choiceTitle", { branch: choice.branch })}
|
|
className="space-y-1.5 rounded border border-border-light bg-surface-1 p-2 text-[11px]"
|
|
>
|
|
<p className="break-words text-fg-primary">
|
|
{choice.conflict
|
|
? t("gitBar.carryConflict", { branch: choice.branch })
|
|
: t("gitBar.choiceMessage", { count: dirty, branch: choice.branch })}
|
|
</p>
|
|
{choice.conflict && choice.conflict.length > 0 && (
|
|
<ul
|
|
data-testid="lane-git-carry-conflicts"
|
|
className="max-h-24 overflow-auto font-mono text-[10px] text-status-warning"
|
|
>
|
|
{choice.conflict.map((f) => (
|
|
<li key={f} className="truncate" title={f}>
|
|
{f}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{!choice.conflict && (
|
|
<Btn
|
|
label={t("gitBar.choiceCarry")}
|
|
busy={busy === "switch"}
|
|
title={t("gitBar.choiceCarryHint")}
|
|
onClick={() => void run("switch", { branch: choice.branch, changes: "carry" })}
|
|
/>
|
|
)}
|
|
<Btn
|
|
label={t("gitBar.choiceShelve")}
|
|
busy={busy === "switch"}
|
|
title={t("gitBar.choiceShelveHint")}
|
|
onClick={() => void run("switch", { branch: choice.branch, changes: "shelve" })}
|
|
/>
|
|
<Btn label={t("gitBar.loginCancel")} onClick={() => setChoice(null)} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{switching && (
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
<input
|
|
value={newBranch}
|
|
onChange={(e) => setNewBranch(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (isSubmitEnter(e)) createBranch(dirty > 0);
|
|
}}
|
|
placeholder={t("gitBar.branchNewPlaceholder")}
|
|
aria-label={t("gitBar.branchNewPlaceholder")}
|
|
className="min-w-[8rem] 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.branchCreateSwitch")}
|
|
icon={<GitBranch className="h-3 w-3" />}
|
|
busy={busy === "branch-create"}
|
|
disabled={!newBranch.trim()}
|
|
// The working tree stays as it is: uncommitted changes come along.
|
|
title={t("gitBar.branchCreateSwitchHint")}
|
|
onClick={() => createBranch(true)}
|
|
/>
|
|
<Btn
|
|
label={t("gitBar.branchCreate")}
|
|
icon={<Plus className="h-3 w-3" />}
|
|
busy={busy === "branch-create"}
|
|
disabled={!newBranch.trim()}
|
|
// Plain Create 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={() => createBranch(false)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{publishing && !git.hasRemote && !git.unborn && (
|
|
<div className="flex items-center gap-1.5">
|
|
<input
|
|
value={url}
|
|
onChange={(e) => 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"
|
|
/>
|
|
<Btn
|
|
label={t("gitBar.publishApply")}
|
|
icon={<Upload className="h-3 w-3" />}
|
|
busy={busy === "publish"}
|
|
disabled={!url.trim()}
|
|
title={t("gitBar.publishHint")}
|
|
onClick={() => void publish()}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{login && (
|
|
<form
|
|
data-testid="lane-git-login"
|
|
className="space-y-1.5 rounded border border-border-light bg-surface-1 p-2 text-[11px]"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
const { protocol, host } = login.auth;
|
|
void run(login.action, {
|
|
credentials: { protocol, host, username: username.trim(), password, remember },
|
|
});
|
|
}}
|
|
>
|
|
<p className="font-medium text-fg-primary">
|
|
{t("gitBar.loginTitle", { host: login.auth.host })}
|
|
</p>
|
|
{login.auth.rejected && (
|
|
<p role="alert" className="text-status-danger">
|
|
{t("gitBar.loginRejected")}
|
|
</p>
|
|
)}
|
|
<div className="flex gap-1.5">
|
|
<input
|
|
aria-label={t("gitBar.loginUser")}
|
|
placeholder={t("gitBar.loginUser")}
|
|
autoComplete="username"
|
|
value={username}
|
|
onChange={(e) => 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"
|
|
/>
|
|
<input
|
|
type="password"
|
|
aria-label={t("gitBar.loginPassword")}
|
|
placeholder={t("gitBar.loginPassword")}
|
|
autoComplete="current-password"
|
|
value={password}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
<Checkbox checked={remember} onChange={setRemember} label={t("gitBar.loginRemember")} />
|
|
<p className="text-[10px] text-fg-muted">{t("gitBar.loginTokenHint")}</p>
|
|
{/* The token crosses the network in the clear on a plain-http LAN address. */}
|
|
{location.protocol === "http:" && !LOCAL_HOST.test(location.hostname) && (
|
|
<p className="text-[10px] text-status-warning">{t("gitBar.loginInsecure")}</p>
|
|
)}
|
|
<div className="flex gap-1.5">
|
|
<button
|
|
type="submit"
|
|
disabled={!username.trim() || !password || !!busy}
|
|
className="rounded border border-accent/40 bg-accent/15 px-2 py-0.5 text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
|
|
>
|
|
{t("gitBar.loginSubmit")}
|
|
</button>
|
|
<Btn
|
|
label={t("gitBar.loginCancel")}
|
|
onClick={() => {
|
|
setLogin(null);
|
|
setPassword("");
|
|
}}
|
|
/>
|
|
</div>
|
|
</form>
|
|
)}
|
|
|
|
{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;
|