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
@@ -13,8 +13,8 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, act, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LaneChangesPanel } from "../LaneChangesPanel";
import { confirmDiscard } from "../../../lib/confirm";
import { LaneChangesPanel, gitChangeFor } from "../LaneChangesPanel";
import { confirmDialog, confirmDiscard } from "../../../lib/confirm";
import { api } from "../../../lib/api";
import type { LaneChanges } from "../../../lib/types";
@@ -153,6 +153,14 @@ beforeEach(() => {
},
],
});
vi.mocked(api.lanes.gitBranches)
.mockReset()
.mockResolvedValue({
available: true,
branches: ["feat/thing", "other"],
current: "feat/thing",
worktrees: [],
});
vi.mocked(api.lanes.gitLs)
.mockReset()
.mockResolvedValue({
@@ -255,7 +263,8 @@ describe("LaneChangesPanel", () => {
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "vs base" }));
// No git facts (an older server): the base has no name to show.
await user.click(screen.getByRole("button", { name: "Compared to base branch" }));
await waitFor(() => expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "branch" }));
});
@@ -739,6 +748,129 @@ describe("LaneChangesPanel", () => {
expect(await screen.findByTestId("lane-changes-commit-chip")).toHaveTextContent("aaaaaaa");
});
describe("history rewrites, amend and vs base", () => {
const facts = (over: Record<string, unknown> = {}) => ({
available: true as const,
branch: "feat/thing",
head: "aaaaaaa",
subject: "feat: the commit",
dirty: 2,
untracked: 1,
files: [],
hasRemote: false,
upstream: null,
ahead: null,
behind: null,
base: "main",
...over,
});
const commitMenu = async (user: ReturnType<typeof userEvent.setup>) => {
const row = await within(await screen.findByTestId("lane-changes-history")).findByText(
"feat: the commit"
);
await user.pointer({ keys: "[MouseRight]", target: row });
return screen.getByTestId("lane-changes-commit-menu");
};
const withParent = () =>
vi.mocked(api.lanes.gitLog).mockResolvedValue({
available: true,
commits: [
{
sha: "a".repeat(40),
short: "aaaaaaa",
subject: "feat: the commit",
author: "t",
date: "2026-09-22T01:00:00Z",
refs: ["HEAD -> feat/thing"],
parents: ["b".repeat(40)],
},
],
});
it("reverts a commit and undoes the top one from the history menu", async () => {
withParent();
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "History" }));
await user.click(within(await commitMenu(user)).getByText("Revert this commit"));
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "revert", { sha: "a".repeat(40) });
await user.click(within(await commitMenu(user)).getByText("Undo commit (keep changes)"));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "undo-commit", {
confirm: true,
sha: "a".repeat(40),
})
);
});
it("cherry-picks only from another branch's history", async () => {
withParent();
vi.mocked(api.lanes.git).mockResolvedValue(facts());
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "History" }));
expect(within(await commitMenu(user)).queryByText(/Cherry-pick/)).toBeNull();
await user.keyboard("{Escape}");
await user.selectOptions(await screen.findByTestId("lane-changes-history-ref"), "other");
await waitFor(() =>
expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, { limit: 50, ref: "other" })
);
await user.click(within(await commitMenu(user)).getByText("Cherry-pick onto feat/thing"));
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "cherry-pick", { sha: "a".repeat(40) });
});
it("asks before a revert and an amend, and a No sends nothing", async () => {
withParent();
vi.mocked(confirmDialog).mockClear();
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
await user.click(screen.getByTestId("lane-changes-amend"));
await user.click(screen.getByRole("button", { name: "Amend" }));
expect(confirmDialog).toHaveBeenCalledTimes(1);
await user.click(screen.getByRole("button", { name: "History" }));
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
await user.click(within(await commitMenu(user)).getByText("Revert this commit"));
await waitFor(() => expect(confirmDialog).toHaveBeenCalledTimes(2));
expect(api.lanes.gitAction).not.toHaveBeenCalled();
});
it("amends the last commit, keeping its message when none is typed", async () => {
vi.mocked(api.lanes.git).mockResolvedValue(facts());
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByTestId("lane-changes-amend"));
await user.click(screen.getByRole("button", { name: "Amend" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "amend", { sha: "aaaaaaa" })
);
});
it("names the base in full, and on the base branch itself shows no toggle at all", async () => {
vi.mocked(api.lanes.git).mockResolvedValue(facts());
const { unmount } = render(<LaneChangesPanel laneId={7} />);
expect(await screen.findByRole("button", { name: "Compared to main" })).toBeInTheDocument();
unmount();
vi.mocked(api.lanes.git).mockResolvedValue(facts({ branch: "main" }));
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-git");
await screen.findByTestId("lane-changes-overview");
expect(screen.queryByRole("button", { name: "Compared to main" })).toBeNull();
// A lone "Uncommitted" would be a toggle with nothing to toggle to.
expect(screen.queryByRole("button", { name: "Uncommitted" })).toBeNull();
});
});
it("expands a folder in place, keeping its siblings visible, instead of navigating into it", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.gitTree).mockImplementation(async (_id, path) =>
@@ -1647,3 +1779,44 @@ describe("a merge in progress (issue #45)", () => {
);
});
});
describe("gitChangeFor", () => {
// One hunk, two change blocks three lines apart: an edit and a removal.
const file = {
path: "a.txt",
status: " M",
untracked: false,
added: 1,
deleted: 2,
binary: false,
hunks: [
{
header: "@@ -1,6 +1,5 @@",
context: "",
oldStart: 1,
newStart: 1,
lines: [
{ type: "del" as const, oldLine: 1, newLine: null, text: "a" },
{ type: "add" as const, oldLine: null, newLine: 1, text: "A" },
{ type: "ctx" as const, oldLine: 2, newLine: 2, text: "b" },
{ type: "ctx" as const, oldLine: 3, newLine: 3, text: "c" },
{ type: "ctx" as const, oldLine: 4, newLine: 4, text: "d" },
{ type: "del" as const, oldLine: 5, newLine: null, text: "e" },
{ type: "ctx" as const, oldLine: 6, newLine: 5, text: "f" },
],
},
],
};
it("finds git's block by its line span on both sides", () => {
expect(gitChangeFor(file, { oldFrom: 1, oldCount: 1, newFrom: 1, newCount: 1 })?.block).toBe(0);
// A pure removal: matched on the old side alone.
expect(gitChangeFor(file, { oldFrom: 5, oldCount: 1, newFrom: 5, newCount: 0 })?.block).toBe(1);
});
it("finds none when the editor split the change differently", () => {
expect(
gitChangeFor(file, { oldFrom: 1, oldCount: 2, newFrom: 1, newCount: 1 })
).toBeUndefined();
});
});