feat(lanes): git login from the dashboard, merge and conflict resolution (#45)
Part A — a fetch/pull/push against a remote that needs a login no longer dies on "terminal prompts disabled": the server answers 409 EGITAUTH with the host, the git bar asks for a username + password/access token and resends the same action. AgentHub keeps its OWN credential store (<CLAUDE_HOME>/agenthub-git-credentials/, 0700/0600), one login per host, shared by every user of the install, managed from Settings -> Git credentials. A login is only sent to one of the repo's own remote hosts, saved only after git accepted it, never returned by any route, and reaches git through an exclusive credential helper (never argv). Part B — merge a branch (or the diverged upstream) from the git bar; a merge that stops on conflicts lists them in the Changes panel with Keep mine / Take theirs / Mark resolved, then Complete merge or Abort merge (confirm gate). Clean tree, repo-root lane and an existing ref only; pull/switch/hunk actions are blocked mid-merge; staging a conflicted file with markers left is refused. Rebase/reset/stash stay out.
This commit is contained in:
@@ -21,6 +21,11 @@
|
||||
* `POST /api/lanes/:id/git/:action`, and `discard` is asked for twice here
|
||||
* before the server is told `confirm: true` — it is the one action in this
|
||||
* panel git cannot undo.
|
||||
*
|
||||
* During a merge (issue #45) a banner offers **Complete merge** (only once no
|
||||
* file is conflicted) and **Abort merge** (two clicks, `confirm: true`), and
|
||||
* conflicted files get their own group with Keep mine / Take theirs / Mark
|
||||
* resolved instead of stage — staging one blind would mark it resolved.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -32,9 +37,12 @@ import {
|
||||
ChevronRight,
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
Check,
|
||||
Columns2,
|
||||
FileText,
|
||||
FoldVertical,
|
||||
ArrowLeftToLine,
|
||||
ArrowRightToLine,
|
||||
Folder,
|
||||
Minus,
|
||||
Pencil,
|
||||
@@ -298,6 +306,46 @@ function FileActions({
|
||||
);
|
||||
}
|
||||
|
||||
type Resolve = (path: string, side: "ours" | "theirs" | "done") => void;
|
||||
|
||||
/** A conflicted file's three ways out of the conflict. */
|
||||
function ConflictActions({
|
||||
f,
|
||||
busy,
|
||||
onResolve,
|
||||
}: {
|
||||
f: LaneChangeFile;
|
||||
busy: boolean;
|
||||
onResolve: Resolve;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
return (
|
||||
<span className="flex shrink-0 items-center gap-0.5">
|
||||
<IconButton
|
||||
label={t("changes.takeOurs")}
|
||||
disabled={busy}
|
||||
onClick={() => onResolve(f.path, "ours")}
|
||||
>
|
||||
<ArrowLeftToLine className="h-3 w-3" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label={t("changes.takeTheirs")}
|
||||
disabled={busy}
|
||||
onClick={() => onResolve(f.path, "theirs")}
|
||||
>
|
||||
<ArrowRightToLine className="h-3 w-3" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label={t("changes.markResolved")}
|
||||
disabled={busy}
|
||||
onClick={() => onResolve(f.path, "done")}
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
</IconButton>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** One overview row. At module scope on purpose: declared inside the panel it
|
||||
* was a new component type every render, so each poll and each keystroke in
|
||||
* the commit box remounted every row (focus dropped to <body>). */
|
||||
@@ -309,9 +357,11 @@ function ChangeRow({
|
||||
onOpen,
|
||||
onAct,
|
||||
onArm,
|
||||
onResolve,
|
||||
}: {
|
||||
f: LaneChangeFile;
|
||||
range: LaneChangeRange;
|
||||
onResolve: Resolve;
|
||||
busy: boolean;
|
||||
/** This row's discard has been clicked once and awaits the second click. */
|
||||
armed: boolean;
|
||||
@@ -346,9 +396,12 @@ function ChangeRow({
|
||||
)}
|
||||
<Delta added={f.added} deleted={f.deleted} binary={f.binary} />
|
||||
</button>
|
||||
{range === "work" && (
|
||||
<FileActions f={f} busy={busy} armed={armed} onAct={onAct} onArm={onArm} />
|
||||
)}
|
||||
{range === "work" &&
|
||||
(f.conflicted ? (
|
||||
<ConflictActions f={f} busy={busy} onResolve={onResolve} />
|
||||
) : (
|
||||
<FileActions f={f} busy={busy} armed={armed} onAct={onAct} onArm={onArm} />
|
||||
))}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -409,6 +462,7 @@ export function LaneChangesPanel({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [confirmDiscard, setConfirmDiscard] = useState<string | null>(null);
|
||||
const [armedAbort, setArmedAbort] = useState(false);
|
||||
// The hunk whose Undo was clicked once: `${path}\n${header}`.
|
||||
const [armedHunk, setArmedHunk] = useState<string | null>(null);
|
||||
// Files folded shut in review mode. A change-set is read file by file, and
|
||||
@@ -694,6 +748,28 @@ export function LaneChangesPanel({
|
||||
}
|
||||
};
|
||||
|
||||
// resolve / merge-commit / merge-abort: same shape as `act`, other body.
|
||||
const mergeAct = async (
|
||||
action: "resolve" | "merge-commit" | "merge-abort",
|
||||
body: Parameters<typeof api.lanes.gitAction>[2] = {}
|
||||
) => {
|
||||
const lane = laneId;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.lanes.gitAction(lane, action, body);
|
||||
if (laneNow.current !== lane) return;
|
||||
setArmedAbort(false);
|
||||
refreshGit();
|
||||
await load();
|
||||
} catch (err) {
|
||||
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (laneNow.current === lane) setBusy(false);
|
||||
}
|
||||
};
|
||||
const resolve: Resolve = (path, side) => void mergeAct("resolve", { path, side });
|
||||
|
||||
// One hunk, matched by its `@@` line against a fresh diff at the context
|
||||
// this diff was read with — so the server never applies a stale patch.
|
||||
const actHunk = async (action: "stage-hunk" | "discard-hunk", path: string, hunk: string) => {
|
||||
@@ -805,9 +881,11 @@ export function LaneChangesPanel({
|
||||
|
||||
const files = data?.available ? data.files : [];
|
||||
const allCollapsed = files.length > 0 && files.every((f) => collapsed.has(f.path));
|
||||
const stagedCount = range === "work" ? files.filter((f) => f.staged).length : 0;
|
||||
const stagedCount = range === "work" ? files.filter((f) => f.staged && !f.conflicted).length : 0;
|
||||
// What "Stage all" sends: every listed file with a change the index lacks.
|
||||
const toStage = range === "work" ? files.filter((f) => f.untracked || f.unstaged) : [];
|
||||
// Never a conflicted one — staging it would mark it resolved.
|
||||
const toStage =
|
||||
range === "work" ? files.filter((f) => (f.untracked || f.unstaged) && !f.conflicted) : [];
|
||||
const current = mode === "file" ? files.find((f) => f.path === openFile) : undefined;
|
||||
|
||||
// The armed path left the list (committed, reverted, lane switched): disarm,
|
||||
@@ -1079,6 +1157,38 @@ export function LaneChangesPanel({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{git?.available && git.merging && (
|
||||
<div
|
||||
data-testid="lane-merge-banner"
|
||||
className="mb-2 flex flex-wrap items-center gap-1.5 rounded border border-status-warning/40 bg-status-warning/10 px-2 py-1 text-[11px] text-status-warning"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
{git.conflicts
|
||||
? t("changes.mergeBanner", { count: git.conflicts })
|
||||
: t("changes.mergeReady")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !!git.conflicts}
|
||||
onClick={() => void mergeAct("merge-commit")}
|
||||
className="shrink-0 rounded border border-accent/40 bg-accent/15 px-1.5 py-0.5 text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{t("changes.mergeCommit")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
// Two clicks, like discard: aborting drops every resolution made.
|
||||
onClick={() =>
|
||||
armedAbort ? void mergeAct("merge-abort", { confirm: true }) : setArmedAbort(true)
|
||||
}
|
||||
className="shrink-0 rounded border border-status-danger/40 px-1.5 py-0.5 text-status-danger hover:bg-status-danger/10 disabled:opacity-40"
|
||||
>
|
||||
{armedAbort ? t("changes.mergeAbortConfirm") : t("changes.mergeAbort")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* In single-file mode the DIFF owns the scrolling, so its horizontal
|
||||
bar sits at the bottom of the visible box instead of at the bottom of
|
||||
a 2000-line container nobody has scrolled to yet. Every other mode is
|
||||
@@ -1207,16 +1317,17 @@ export function LaneChangesPanel({
|
||||
<div data-testid="lane-changes-overview">
|
||||
{(range === "work"
|
||||
? [
|
||||
{ key: "conflicts", rows: files.filter((f) => f.conflicted) },
|
||||
{
|
||||
key: "staged",
|
||||
rows: files
|
||||
.filter((f) => f.staged)
|
||||
.filter((f) => f.staged && !f.conflicted)
|
||||
.map((f) => ({ ...f, unstaged: false, untracked: false })),
|
||||
},
|
||||
{
|
||||
key: "unstaged",
|
||||
rows: files
|
||||
.filter((f) => f.unstaged || f.untracked)
|
||||
.filter((f) => (f.unstaged || f.untracked) && !f.conflicted)
|
||||
.map((f) => ({ ...f, staged: false })),
|
||||
},
|
||||
]
|
||||
@@ -1224,7 +1335,11 @@ export function LaneChangesPanel({
|
||||
).map(
|
||||
(g) =>
|
||||
g.rows.length > 0 && (
|
||||
<div key={g.key} className="mb-1.5">
|
||||
<div
|
||||
key={g.key}
|
||||
className="mb-1.5"
|
||||
data-testid={g.key === "conflicts" ? "lane-changes-conflicts" : undefined}
|
||||
>
|
||||
{g.key !== "all" && (
|
||||
<div className="flex items-center gap-1.5 px-1 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-fg-secondary">
|
||||
{t(`changes.${g.key}Group`)}
|
||||
@@ -1244,6 +1359,7 @@ export function LaneChangesPanel({
|
||||
onOpen={show}
|
||||
onAct={(...a) => void act(...a)}
|
||||
onArm={setConfirmDiscard}
|
||||
onResolve={resolve}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
Reference in New Issue
Block a user