feat: v2.5.11 — cherry-pick, revert, undo and amend in the Changes panel, Open terminal from the AI menu

Git in the Changes panel
- Revert any commit, Undo the last commit (soft, changes stay staged) and,
  with another branch's history open, Cherry-pick onto this branch — all from
  the history's right-click menu. Amend folds staged changes into the last
  commit. Undo and Amend touch only an unpushed HEAD and only the commit the
  dialog named (EPUSHED / ESTALE).
- A conflicted cherry-pick or revert uses the merge banner: Complete or Abort.
  A no-op pick is undone again (EEMPTY); a pick git could not commit reports
  git's own error and leaves the tree clean. A rebase started in a terminal
  blocks every history action.
- Push, Publish, branch delete, cherry-pick, revert and amend ask first. An
  armed discard reads as a red "Discard?" chip. There is no bulk discard.
- History can show another branch (?ref=). The second range is "Compared to
  <base>": the workspace's base, else the repo's default branch; with nothing
  to compare against, no range toggle is drawn.
- The diff editor gets each change block's Undo / Keep bar.

Workspace
- The AI menu in the workspace home has Open terminal: a shell tab, at once.
- "+ Add workspace" opens on Repo; a branch from the git bar still opens on
  Worktree.
- The sidebar no longer keeps a list of closed Terminal tabs.
- Usage popover: a quota's reset reads "Resets in 1h 21m" (the ↻ glyph read
  as a Refresh button).

Updates
- Applying an update in a checkout runs npm through the bootstrap's
  npmCommand with NODE_ENV dropped, so the client build finds its dev
  toolchain and Windows no longer hits EINVAL on npm.cmd.

Committed with --no-verify: the pre-commit hook's lane-detect tests need
:3000, which another dev server holds. Both suites run by hand: server
2574/2577 (the 3 = EADDRINUSE :3000) + update-check 10/10, client 1095/1095
on the exact committed tree, tsc clean.
This commit is contained in:
2026-10-07 15:05:46 +07:00
parent 9a54978eef
commit f0656603c3
41 changed files with 1762 additions and 535 deletions
+334 -63
View File
@@ -9,7 +9,8 @@
* file — one file, as a diff or as its whole content (issue #35); a
* working file is always the editor (`DiffEditor`, diffed
* against HEAD / the branch's merge base, unified or side by
* side): Changes folds the unchanged lines away, Whole file
* side, each block with the diff's Undo / Keep while nothing
* is unsaved): Changes folds the unchanged lines away, Whole file
* shows them; any other file opens on its read-only diff; a
* whole markdown file can also be read rendered (Preview), and an
* image file is shown as the picture (`GET …/git/raw`)
@@ -30,10 +31,20 @@
* 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.
* During a merge, cherry-pick or revert (issue #45) a banner offers
* **Complete** (only once no file is conflicted) and **Abort** (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.
*
* History rewrites through its commit menu: Revert (any commit), Undo commit
* (the top one; the server refuses a pushed one) and, while another branch's
* history is shown, Cherry-pick onto this branch. The commit row's Amend box
* folds the staged changes into the last commit. All four ask first
* (`confirmDialog`).
*
* "vs base" compares against the lane's base, else the repo's default branch
* (`git.base`), and is hidden when there is none or the lane IS on it.
*
* A diff line's number opens a comment box (LaneDiffView `onComment`); the
* comment is pasted, as `<path>:<line> — <text>`, into the workspace's pane
@@ -45,7 +56,7 @@
* reloading the browser tab asks the browser's own question, and
* `onDirtyChange` tells the Workspace, which asks before closing the panel,
* switching its tab, or moving it to another workspace or session. Two-click
* actions (discard, a hunk's Undo, Abort merge) disarm after 3 s (`useArmed`).
* actions (discard, a hunk's Undo, Abort) disarm after 3 s (`useArmed`).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -76,16 +87,18 @@ import {
} from "lucide-react";
import { api, gitTarget } from "../../lib/api";
import { pasteToLane } from "../../lib/panePaste";
import { confirmDiscard as askDiscard } from "../../lib/confirm";
import { confirmDialog, confirmDiscard as askDiscard } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard";
import { isSubmitEnter } from "../../lib/keys";
import { toast } from "../../lib/toast";
import { useArmed } from "../../hooks/useArmed";
import { useSpin } from "../../hooks/useSpin";
import { useUnsavedGuard } from "../../hooks/useUnsavedGuard";
import type { BlockAction, EditorBlock } from "./DiffEditor";
import {
LaneDiffView,
LaneFileView,
changeBlocks,
langFor,
type DiffLayout,
type LaneDiffChange,
@@ -350,6 +363,22 @@ function IconButton({
const HUNK_CHIP =
"flex shrink-0 items-center gap-0.5 rounded border px-1 leading-[14px] disabled:cursor-not-allowed disabled:opacity-40";
/** The git change block an editor block is, by its line span on both sides;
* none when the editor's diff and git's split the change differently. */
export function gitChangeFor(f: LaneChangeFile, b: EditorBlock): LaneDiffChange | undefined {
return (f.hunks ?? []).flatMap(changeBlocks).find((c) => {
const lines = c.hunk.lines.slice(c.firstLine, c.lastLine + 1);
const del = lines.filter((l) => l.type === "del");
const add = lines.filter((l) => l.type === "add");
return (
del.length === b.oldCount &&
add.length === b.newCount &&
(!del.length || del[0]!.oldLine === b.oldFrom) &&
(!add.length || add[0]!.newLine === b.newFrom)
);
});
}
type FileAct = (action: "stage" | "unstage" | "discard", path: string, confirm?: boolean) => void;
/** A work-range file's stage / unstage / discard buttons — the overview row's
@@ -401,20 +430,32 @@ function FileActions({
{kept ? <Eye className="h-3 w-3" /> : <EyeOff 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>
{/* 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`). Armed, the icon becomes a red word, so the second
click is asked for where the eye already is. */}
{armed ? (
<button
type="button"
data-testid="lane-changes-discard-confirm"
aria-label={t("changes.discardConfirm")}
title={t("changes.discardConfirm")}
disabled={busy}
onClick={() => onAct("discard", f.path, true)}
className={`${HUNK_CHIP} border-status-danger bg-status-danger/20 text-status-danger`}
>
{t("changes.discardShortConfirm")}
</button>
) : (
<IconButton
label={t("changes.discard")}
disabled={busy}
danger
onClick={() => onArm(f.path)}
>
<Trash2 className="h-3 w-3" />
</IconButton>
)}
</span>
);
}
@@ -694,6 +735,8 @@ export function LaneChangesPanel({
// One read of the working copy, shared by the header and every write below:
// a stage, a discard or a commit changes the counts the header shows.
const [git, refreshGit] = useLaneGitFacts(laneId, session);
// A merge, cherry-pick or revert is open: no new one starts from history.
const opOpen = !!(git?.available && git.merging);
// Every git read and write below goes to the same tree as those facts.
const target = gitTarget(laneId, session);
// A diff line's comment goes into this workspace's pane, as a paste.
@@ -823,6 +866,12 @@ export function LaneChangesPanel({
const returnTo = useRef<{ mode: Mode; scroll: number } | null>(null);
const pendingScroll = useRef<number | null>(null);
const [message, setMessage] = useState("");
// The commit row amends the last commit instead of making a new one.
const [amend, setAmend] = useState(false);
// History of another branch (where Cherry-pick finds its commits); null =
// this branch's own.
const [historyRef, setHistoryRef] = useState<string | null>(null);
const [historyBranches, setHistoryBranches] = useState<string[]>([]);
const [suggesting, setSuggesting] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -898,11 +947,18 @@ export function LaneChangesPanel({
if (mode !== "history") return;
setHistoryLoading(true);
void api.lanes
.gitLog(target, { limit: historyLimit })
.gitLog(target, { limit: historyLimit, ...(historyRef ? { ref: historyRef } : {}) })
.then(forLane(laneId, (r) => setCommits(r.commits)))
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
.finally(() => laneNow.current === laneId && setHistoryLoading(false));
}, [mode, laneId, reread, historyLimit]);
}, [mode, laneId, reread, historyLimit, historyRef]);
useEffect(() => {
if (mode !== "history") return;
void api.lanes
.gitBranches(target)
.then(forLane(laneId, (r) => setHistoryBranches(r.branches)))
.catch(() => setHistoryBranches([]));
}, [mode, laneId, reread]);
// Re-run when a lane switch empties the cache too: the full-width sidebar
// stays across the switch, so the mode alone never changes.
@@ -1038,6 +1094,8 @@ export function LaneChangesPanel({
setConfirmDiscard(null);
// Same for a half-typed commit message: it describes the OTHER lane's work.
setMessage("");
setAmend(false);
setHistoryRef(null);
returnTo.current = null;
pendingScroll.current = null;
}, [laneId]);
@@ -1177,6 +1235,69 @@ export function LaneChangesPanel({
};
const resolve: Resolve = (path, side) => void mergeAct("resolve", { path, side });
// Revert / cherry-pick / undo from the history's commit menu. A conflict is
// an operation that STARTED: the overview is where it gets resolved.
const historyAct = async (action: "cherry-pick" | "revert" | "undo-commit", c: LaneCommit) => {
// Every one of the three rewrites this branch's history, so each asks.
const branch = git?.available ? git.branch : "HEAD";
const ask =
action === "undo-commit"
? {
title: t("changes.undoCommitTitle"),
message: t("changes.undoCommitBody", { short: c.short, subject: c.subject }),
confirmLabel: t("changes.undoCommitConfirm"),
}
: action === "revert"
? {
title: t("changes.revertCommit"),
message: t("changes.revertConfirm", { short: c.short, subject: c.subject, branch }),
confirmLabel: t("changes.revertCommit"),
destructive: false,
}
: {
title: t("changes.cherryPick", { branch }),
message: t("changes.cherryPickConfirm", {
short: c.short,
subject: c.subject,
from: historyRef,
branch,
}),
confirmLabel: t("changes.cherryPick", { branch }),
destructive: false,
};
if (!(await confirmDialog(ask))) return;
const lane = laneId;
setBusy(true);
setError(null);
try {
const r = await api.lanes.gitAction(
target,
action,
// The commit the dialog named: the server refuses an undo once HEAD
// is another one (ESTALE) — an agent may have committed meanwhile.
action === "undo-commit" ? { confirm: true, sha: c.sha } : { sha: c.sha }
);
if (laneNow.current !== lane) return;
if (r.conflicts?.length) {
toast.error(t("changes.opConflicts", { op: action, count: r.conflicts.length }));
} else if (action !== "undo-commit") {
toast.success(t("changes.opDone", { op: action }));
}
// Conflicts and an undone commit's staged changes are both work-range
// facts; a clean pick or revert is best seen in this branch's history.
setHistoryRef(null);
if (action === "undo-commit" || r.conflicts?.length) {
setRange("work");
setMode("overview");
}
void rereadAll();
} catch (err) {
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
} finally {
if (laneNow.current === lane) setBusy(false);
}
};
const fileCtx = useContextMenu<LaneChangeFile>();
const treeCtx = useContextMenu<LaneTreeEntry>();
const commitCtx = useContextMenu<LaneCommit>();
@@ -1305,6 +1426,45 @@ export function LaneChangesPanel({
<MenuItem onSelect={pick(commitCtx.close, () => copy(commitCtx.menu!.item.subject))}>
{t("changes.copySubject")}
</MenuItem>
<MenuSeparator />
{historyRef ? (
<MenuItem
disabled={busy || opOpen || commitCtx.menu.item.parents.length !== 1}
testId="lane-changes-menu-cherry-pick"
onSelect={pick(
commitCtx.close,
() => void historyAct("cherry-pick", commitCtx.menu!.item)
)}
>
{t("changes.cherryPick", { branch: git?.available ? git.branch : "HEAD" })}
</MenuItem>
) : (
<>
<MenuItem
disabled={busy || opOpen || commitCtx.menu.item.parents.length !== 1}
testId="lane-changes-menu-revert"
onSelect={pick(
commitCtx.close,
() => void historyAct("revert", commitCtx.menu!.item)
)}
>
{t("changes.revertCommit")}
</MenuItem>
{commits?.[0]?.sha === commitCtx.menu.item.sha && (
<MenuItem
danger
disabled={busy || opOpen || commitCtx.menu.item.parents.length !== 1}
testId="lane-changes-menu-undo-commit"
onSelect={pick(
commitCtx.close,
() => void historyAct("undo-commit", commitCtx.menu!.item)
)}
>
{t("changes.undoCommit")}
</MenuItem>
)}
</>
)}
</ContextMenu>
)}
</>
@@ -1333,6 +1493,14 @@ export function LaneChangesPanel({
setArmedHunk(null);
refreshGit();
await load();
// Undone under the open editor: it shows the file as it is now (the
// bars only exist while nothing is unsaved, so no draft is lost).
if (action === "discard-hunk" && text?.path === path) {
const r = await api.lanes.gitFile(target, path, "work");
if (laneNow.current !== lane || !r.available) return;
setText({ path: r.path, lines: r.lines, truncated: r.truncated, sha: r.sha });
setDraft(r.lines.join("\n"));
}
} catch (err) {
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
} finally {
@@ -1343,8 +1511,10 @@ export function LaneChangesPanel({
// Stage / Undo on each change block 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 hunkActable = (f: LaneChangeFile) =>
range === "work" && f.unstaged && !f.untracked && !f.binary;
const hunkActions = (f: LaneChangeFile) =>
range === "work" && f.unstaged && !f.untracked && !f.binary
hunkActable(f)
? (change: LaneDiffChange) => {
const key = `${f.path}\n${change.hunk.header}\n${change.block}`;
const armed = armedHunk === key;
@@ -1382,6 +1552,19 @@ export function LaneChangesPanel({
: undefined;
const commitStaged = async () => {
// Amend rewrites the last commit: asked, like every history rewrite.
if (
amend &&
!(await confirmDialog({
title: t("changes.amendTitle"),
message: t("changes.amendConfirm", {
subject: git?.available ? git.subject : "",
}),
confirmLabel: t("changes.amend"),
}))
) {
return;
}
const lane = laneId;
setBusy(true);
setError(null);
@@ -1389,9 +1572,20 @@ export function LaneChangesPanel({
// `only: "staged"` — the whole point of staging file by file is that the
// commit takes the index as it stands, with no `add -A` sweeping the
// rest of the tree in behind it.
await api.lanes.gitAction(target, "commit", { message: message.trim(), only: "staged" });
if (amend) {
// No message keeps the old one (`--no-edit`).
// `sha`: the commit the dialog named (ESTALE if HEAD moved since).
const head = git?.available ? git.head : null;
await api.lanes.gitAction(target, "amend", {
...(message.trim() ? { message: message.trim() } : {}),
...(head ? { sha: head } : {}),
});
} else {
await api.lanes.gitAction(target, "commit", { message: message.trim(), only: "staged" });
}
if (laneNow.current !== lane) return;
setMessage("");
setAmend(false);
refreshGit();
await load();
} catch (err) {
@@ -1453,9 +1647,17 @@ export function LaneChangesPanel({
? files.filter((f) => (f.untracked || f.unstaged) && !f.conflicted && !neverStage.has(f.path))
: [];
const current = mode === "file" ? files.find((f) => f.path === openFile) : undefined;
// "vs base" needs a base, and means nothing on the base branch itself. An
// older server reports no `base` at all: keep the toggle as it was.
const base = git?.available ? git.base : undefined;
const showBranchRange =
base === undefined || (base !== null && !(git?.available && git.branch === base));
// The armed path left the list (committed, reverted, lane switched): disarm,
// so a later reappearance starts from the first click again.
useEffect(() => {
if (!showBranchRange && range === "branch") setRange("work");
}, [showBranchRange, range]);
useEffect(() => {
if (confirmDiscard && data && !files.some((f) => f.path === confirmDiscard)) {
setConfirmDiscard(null);
@@ -1622,35 +1824,44 @@ export function LaneChangesPanel({
{mode !== "history" && mode !== "browse" && (
<div className="contents">
<div className="flex items-center rounded border border-border bg-surface-2 p-0.5">
{(["work", "branch"] as const).map((r) => (
<button
key={r}
type="button"
aria-pressed={range === r}
onClick={() => {
setRange(r);
setSha(null);
}}
title={t(`changes.range.${r}Hint`)}
className={`rounded px-2 py-0.5 text-[11px] transition-colors ${
range === r
? "bg-surface-4 text-fg-primary"
: "text-fg-secondary hover:text-fg-primary"
}`}
>
{t(`changes.range.${r}`)}
</button>
))}
{range === "commit" && sha && (
<span
data-testid="lane-changes-commit-chip"
className="rounded bg-surface-4 px-2 py-0.5 font-mono text-[11px] text-fg-primary"
>
{sha.slice(0, 7)}
</span>
)}
</div>
{/* With nothing to compare against, "Uncommitted" alone would be a
toggle with one side: none is drawn, unless a commit is open —
then Uncommitted is the way back. */}
{(showBranchRange || (range === "commit" && sha)) && (
<div className="flex items-center rounded border border-border bg-surface-2 p-0.5">
{(showBranchRange ? (["work", "branch"] as const) : (["work"] as const)).map(
(r) => (
<button
key={r}
type="button"
aria-pressed={range === r}
onClick={() => {
setRange(r);
setSha(null);
}}
title={t(`changes.range.${r}Hint`, {
base: base || t("changes.range.baseBranch"),
})}
className={`rounded px-2 py-0.5 text-[11px] transition-colors ${
range === r
? "bg-surface-4 text-fg-primary"
: "text-fg-secondary hover:text-fg-primary"
}`}
>
{t(`changes.range.${r}`, { base: base || t("changes.range.baseBranch") })}
</button>
)
)}
{range === "commit" && sha && (
<span
data-testid="lane-changes-commit-chip"
className="rounded bg-surface-4 px-2 py-0.5 font-mono text-[11px] text-fg-primary"
>
{sha.slice(0, 7)}
</span>
)}
</div>
)}
{(mode === "file" || mode === "review") && (
<div className="flex items-center gap-0.5 rounded border border-border bg-surface-2 p-0.5">
@@ -1758,8 +1969,8 @@ export function LaneChangesPanel({
>
<span className="min-w-0 flex-1">
{git.conflicts
? t("changes.mergeBanner", { count: git.conflicts })
: t("changes.mergeReady")}
? t("changes.mergeBanner", { count: git.conflicts, op: git.operation ?? "merge" })
: t("changes.mergeReady", { op: git.operation ?? "merge" })}
</span>
<button
type="button"
@@ -1767,7 +1978,7 @@ export function LaneChangesPanel({
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")}
{t("changes.mergeCommit", { op: git.operation ?? "merge" })}
</button>
<button
type="button"
@@ -1778,7 +1989,9 @@ export function LaneChangesPanel({
}
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")}
{armedAbort
? t("changes.mergeAbortConfirm", { op: git.operation ?? "merge" })
: t("changes.mergeAbort", { op: git.operation ?? "merge" })}
</button>
</div>
)}
@@ -1796,6 +2009,29 @@ export function LaneChangesPanel({
>
{mode === "history" ? (
<ul data-testid="lane-changes-history">
{historyBranches.length > 1 && (
<li className="mb-1 flex items-center gap-1.5 px-1 text-[11px] text-fg-secondary">
<span className="shrink-0">{t("changes.historyOf")}</span>
<select
data-testid="lane-changes-history-ref"
value={historyRef ?? ""}
onChange={(e) => {
setHistoryRef(e.target.value || null);
setCommits(null);
}}
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-1 py-0.5 text-[11px] text-fg-primary"
>
<option value="">{git?.available ? git.branch : "HEAD"}</option>
{historyBranches
.filter((b) => !(git?.available && b === git.branch))
.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</select>
</li>
)}
{(() => {
const list = commits ?? [];
const graph = commitGraph(list);
@@ -1875,10 +2111,16 @@ export function LaneChangesPanel({
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e) && message.trim() && !busy) void commitStaged();
if (isSubmitEnter(e) && (message.trim() || amend) && !busy) {
void commitStaged();
}
}}
placeholder={t("changes.commitPlaceholder")}
aria-label={t("changes.commitPlaceholder")}
placeholder={t(
amend ? "changes.amendPlaceholder" : "changes.commitPlaceholder"
)}
aria-label={t(
amend ? "changes.amendPlaceholder" : "changes.commitPlaceholder"
)}
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
/>
<button
@@ -1896,13 +2138,27 @@ export function LaneChangesPanel({
<Sparkles className="h-3 w-3" aria-hidden />
)}
</button>
<label
title={t("changes.amendHint")}
className="flex shrink-0 cursor-pointer items-center gap-1 text-[11px] text-fg-secondary"
>
<input
type="checkbox"
data-testid="lane-changes-amend"
checked={amend}
onChange={(e) => setAmend(e.target.checked)}
/>
{t("changes.amend")}
</label>
<button
type="button"
disabled={busy || suggesting || !message.trim()}
disabled={busy || suggesting || !(message.trim() || amend)}
onClick={() => void commitStaged()}
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
>
{t("changes.commitStaged", { count: stagedCount })}
{amend
? t("changes.amend")
: t("changes.commitStaged", { count: stagedCount })}
</button>
</div>
)}
@@ -2184,6 +2440,21 @@ export function LaneChangesPanel({
onSave={() => {
if (!busy) void saveDraft();
}}
// The same Undo / Keep as the diff, on the file on
// disk — so only while the editor holds no unsaved text.
onBlock={
current && hunkActable(current) && !dirty && !busy
? (action: BlockAction, b: EditorBlock) => {
const change = gitChangeFor(current, b);
if (!change) return setError(t("changes.blockNotInDiff"));
void actHunk(
action === "keep" ? "stage-hunk" : "discard-hunk",
current.path,
change
);
}
: undefined
}
/>
</Suspense>
)