feat(lanes): stage/undo one hunk at a time, sticky review file headers

Review and the single-file view gain per-hunk Stage and Undo
(stage-hunk / discard-hunk: the hunk is named by its @@ line and
re-matched against a fresh diff, then git apply --cached / -R; undo
needs confirm: true and two clicks). Each review file header stays
pinned while its diff scrolls and carries the file's own
stage/unstage/discard.
This commit is contained in:
2026-09-24 14:38:48 +07:00
parent 0737786faf
commit cfe832383c
17 changed files with 546 additions and 97 deletions
+138 -38
View File
@@ -41,6 +41,7 @@ import {
RefreshCw,
Rows3,
Trash2,
Undo2,
UnfoldVertical,
} from "lucide-react";
import { api } from "../../lib/api";
@@ -51,6 +52,7 @@ import type {
LaneChangeRange,
LaneChanges,
LaneCommit,
LaneDiffHunk,
LaneTreeEntry,
} from "../../lib/types";
@@ -192,6 +194,62 @@ function IconButton({
);
}
type FileAct = (action: "stage" | "unstage" | "discard", path: string, confirm?: boolean) => void;
/** A work-range file's stage / unstage / discard buttons — the overview row's
* and the review header's. */
function FileActions({
f,
busy,
armed,
onAct,
onArm,
}: {
f: LaneChangeFile;
busy: boolean;
armed: boolean;
onAct: FileAct;
onArm: (path: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
return (
<span className="flex shrink-0 items-center gap-0.5">
{f.staged ? (
<IconButton
label={t("changes.unstage")}
disabled={busy}
onClick={() => onAct("unstage", f.path)}
>
<Minus className="h-3 w-3" />
</IconButton>
) : null}
{f.unstaged || f.untracked ? (
<IconButton
label={t("changes.stage")}
disabled={busy}
onClick={() => onAct("stage", f.path)}
>
<Plus className="h-3 w-3" />
</IconButton>
) : null}
<IconButton
label={armed ? t("changes.discardConfirm") : t("changes.discard")}
disabled={busy}
danger
onClick={() => {
// Two clicks, always: the first arms the row, the second is the
// only one that reaches the server (which itself refuses the
// call without `confirm: true`).
if (armed) onAct("discard", f.path, true);
else onArm(f.path);
}}
>
<Trash2 className={`h-3 w-3 ${armed ? "text-status-danger" : ""}`} />
</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>). */
@@ -210,7 +268,7 @@ function ChangeRow({
/** This row's discard has been clicked once and awaits the second click. */
armed: boolean;
onOpen: (path: string) => void;
onAct: (action: "stage" | "unstage" | "discard", path: string, confirm?: boolean) => void;
onAct: FileAct;
onArm: (path: string) => void;
}) {
const { t } = useTranslation(["lanes"]);
@@ -241,40 +299,7 @@ function ChangeRow({
<Delta added={f.added} deleted={f.deleted} binary={f.binary} />
</button>
{range === "work" && (
<span className="flex shrink-0 items-center gap-0.5">
{f.staged ? (
<IconButton
label={t("changes.unstage")}
disabled={busy}
onClick={() => onAct("unstage", f.path)}
>
<Minus className="h-3 w-3" />
</IconButton>
) : null}
{f.unstaged || f.untracked ? (
<IconButton
label={t("changes.stage")}
disabled={busy}
onClick={() => onAct("stage", f.path)}
>
<Plus className="h-3 w-3" />
</IconButton>
) : null}
<IconButton
label={armed ? t("changes.discardConfirm") : t("changes.discard")}
disabled={busy}
danger
onClick={() => {
// Two clicks, always: the first arms the row, the second is the
// only one that reaches the server (which itself refuses the
// call without `confirm: true`).
if (armed) onAct("discard", f.path, true);
else onArm(f.path);
}}
>
<Trash2 className={`h-3 w-3 ${armed ? "text-status-danger" : ""}`} />
</IconButton>
</span>
<FileActions f={f} busy={busy} armed={armed} onAct={onAct} onArm={onArm} />
)}
</li>
);
@@ -316,6 +341,8 @@ export function LaneChangesPanel({
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [confirmDiscard, setConfirmDiscard] = useState<string | null>(null);
// 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
// a file already read is in the way of the next one — so the set is what is
// COLLAPSED, and a file arriving later is open by default.
@@ -551,6 +578,62 @@ export function LaneChangesPanel({
}
};
// 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) => {
const lane = laneId;
setBusy(true);
setError(null);
try {
await api.lanes.gitAction(lane, action, {
path,
hunk,
context: data?.available ? data.context : undefined,
...(action === "discard-hunk" ? { confirm: true } : {}),
});
if (laneNow.current !== lane) return;
setArmedHunk(null);
refreshGit();
await load();
} catch (err) {
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
} finally {
if (laneNow.current === lane) setBusy(false);
}
};
// Stage / Undo on each hunk of a file with unstaged changes. The diff is
// HEAD → working tree, so a staged-only file gets none: undoing its tree
// would leave the index holding the change.
const hunkActions = (f: LaneChangeFile) =>
range === "work" && f.unstaged && !f.untracked && !f.binary
? (hunk: LaneDiffHunk) => {
const key = `${f.path}\n${hunk.header}`;
const armed = armedHunk === key;
return (
<>
<IconButton
label={t("changes.stageHunk")}
disabled={busy}
onClick={() => void actHunk("stage-hunk", f.path, hunk.header)}
>
<Plus className="h-3 w-3" />
</IconButton>
<IconButton
label={armed ? t("changes.undoHunkConfirm") : t("changes.undoHunk")}
disabled={busy}
danger
onClick={() =>
armed ? void actHunk("discard-hunk", f.path, hunk.header) : setArmedHunk(key)
}
>
<Undo2 className={`h-3 w-3 ${armed ? "text-status-danger" : ""}`} />
</IconButton>
</>
);
}
: undefined;
const commitStaged = async () => {
const lane = laneId;
setBusy(true);
@@ -972,7 +1055,12 @@ export function LaneChangesPanel({
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
)
) : current ? (
<LaneDiffView file={current} layout={layout} fill />
<LaneDiffView
file={current}
layout={layout}
fill
hunkActions={whole ? undefined : hunkActions(current)}
/>
) : null}
</div>
) : (
@@ -1006,7 +1094,7 @@ export function LaneChangesPanel({
? t("changes.expandFile", { path: f.path })
: t("changes.collapseFile", { path: f.path })
}
className={`flex cursor-pointer items-baseline gap-1.5 bg-surface-1 px-2 py-1 hover:bg-surface-2 ${
className={`sticky top-0 z-10 flex cursor-pointer items-baseline gap-1.5 rounded-t bg-surface-1 px-2 py-1 hover:bg-surface-2 ${
shut ? "" : "border-b border-border-light"
}`}
>
@@ -1032,8 +1120,20 @@ export function LaneChangesPanel({
{f.path}
</button>
<Delta added={f.added} deleted={f.deleted} binary={f.binary} />
{range === "work" && (
// The header folds on click; its buttons must not.
<span className="self-center" onClick={(e) => e.stopPropagation()}>
<FileActions
f={f}
busy={busy}
armed={confirmDiscard === f.path}
onAct={(...a) => void act(...a)}
onArm={setConfirmDiscard}
/>
</span>
)}
</div>
{!shut && <LaneDiffView file={f} layout={layout} />}
{!shut && <LaneDiffView file={f} layout={layout} hunkActions={hunkActions(f)} />}
</div>
);
})}