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:
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user