daf5b7be92
- Changes panel: a working file opens in a CodeMirror editor with its diff inline (DiffEditor.tsx, @codemirror/merge, lazy-loaded); Changes folds the unchanged lines, Whole file shows them; side by side at 50/50 and full width; a Revert per block; original from HEAD or the branch's merge base (`mergeBase` on GET /git/changes?range=branch) - Every diff: green = line added whole, red = removed whole, blue = edited in place (--diff-mod) - History drawn as a commit graph (#58: `parents` on GET /git/log, --date-order; lib/commitGraph.ts) - Per-workspace never-stage list, drawn orange in the Files tree (--kept); closed Terminal rows past three deleted - Status bar plan quota for Claude (per account) and Codex (GET /api/pricing/quota, server/lib/quota.js); compact hover panel that stays open, Refresh button with a spinner - Settings → Appearance holds language, theme and pet; Ctrl+Shift+C copies; compact sidebar navigation and short session times
1650 lines
63 KiB
TypeScript
1650 lines
63 KiB
TypeScript
/**
|
|
* @file LaneChangesPanel.test.tsx
|
|
* @description Pins the Changes panel's five modes and its three ranges: the
|
|
* overview lists files and nothing else, clicking one opens that file's diff
|
|
* (and asks the server for THAT path), Review asks for every file's hunks at
|
|
* once, the range toggle changes which comparison is requested, History opens
|
|
* one commit, and Files quick-opens a whole file. Also pins the per-file git
|
|
* writes — including discard needing two clicks before the server hears
|
|
* `confirm` — and the degrade for a cwd git cannot read.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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 { api } from "../../../lib/api";
|
|
import type { LaneChanges } from "../../../lib/types";
|
|
|
|
// The themed confirm needs its host (App); answer it here.
|
|
// CodeMirror does not run in jsdom: the editor is a textarea over the same props.
|
|
vi.mock("../DiffEditor", () => ({
|
|
default: (p: {
|
|
value: string;
|
|
original: string;
|
|
layout?: string;
|
|
collapse?: boolean;
|
|
label: string;
|
|
onChange: (v: string) => void;
|
|
onSave: () => void;
|
|
}) => (
|
|
<textarea
|
|
data-testid="lane-file-editor"
|
|
data-original={p.original}
|
|
data-layout={p.layout}
|
|
data-collapse={String(!!p.collapse)}
|
|
aria-label={p.label}
|
|
autoFocus
|
|
value={p.value}
|
|
onChange={(e) => p.onChange(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
|
e.preventDefault();
|
|
p.onSave();
|
|
}
|
|
}}
|
|
/>
|
|
),
|
|
}));
|
|
|
|
vi.mock("../../../lib/confirm", () => ({
|
|
confirmDiscard: vi.fn(async () => true),
|
|
confirmDialog: vi.fn(async () => true),
|
|
}));
|
|
vi.mock("../../../lib/api", () => ({
|
|
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
|
|
api: {
|
|
lanes: {
|
|
git: vi.fn(),
|
|
gitBranches: vi.fn(),
|
|
changes: vi.fn(),
|
|
gitAction: vi.fn(),
|
|
suggestCommitMessage: vi.fn(),
|
|
gitLog: vi.fn(),
|
|
gitFile: vi.fn(),
|
|
gitRawUrl: (id: number, path: string, rev: string) =>
|
|
`/api/lanes/${id}/git/raw?path=${encodeURIComponent(path)}&rev=${rev}`,
|
|
gitSaveFile: vi.fn(),
|
|
gitLs: vi.fn(),
|
|
gitTree: vi.fn(),
|
|
gitTreeAll: vi.fn(),
|
|
},
|
|
},
|
|
}));
|
|
|
|
const HUNK = {
|
|
header: "@@ -1,2 +1,3 @@",
|
|
context: "",
|
|
oldStart: 1,
|
|
newStart: 1,
|
|
lines: [
|
|
{ type: "ctx" as const, oldLine: 1, newLine: 1, text: "keep" },
|
|
{ type: "del" as const, oldLine: 2, newLine: null, text: "old line" },
|
|
{ type: "add" as const, oldLine: null, newLine: 2, text: "new line" },
|
|
],
|
|
};
|
|
|
|
function changes(over: Partial<Extract<LaneChanges, { available: true }>> = {}): LaneChanges {
|
|
return {
|
|
available: true,
|
|
range: "work",
|
|
base: "development",
|
|
baseRef: "origin/development",
|
|
sha: null,
|
|
context: 3,
|
|
truncated: false,
|
|
files: [
|
|
{
|
|
path: "server/lib/git-ops.js",
|
|
status: " M",
|
|
untracked: false,
|
|
unstaged: true,
|
|
added: 12,
|
|
deleted: 3,
|
|
binary: false,
|
|
},
|
|
{ path: "notes.txt", status: "??", untracked: true, added: 4, deleted: 0, binary: false },
|
|
{
|
|
path: "docs/API.md",
|
|
status: "M ",
|
|
untracked: false,
|
|
staged: true,
|
|
added: 2,
|
|
deleted: 1,
|
|
binary: false,
|
|
},
|
|
],
|
|
...over,
|
|
};
|
|
}
|
|
|
|
/** A changed file opens in the editor; Whole file toggles to its read-only diff. */
|
|
async function toDiff(user: ReturnType<typeof userEvent.setup>) {
|
|
await user.click(await screen.findByRole("button", { name: "Changes", pressed: true }));
|
|
}
|
|
|
|
/** The overview row's discard button for one path. */
|
|
function discardOf(path: string) {
|
|
return within(screen.getByTitle(path).closest("li")!).getByRole("button", {
|
|
name: "Discard this file's changes",
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
window.localStorage.removeItem("agenthub.changes.neverStage.7");
|
|
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
|
|
vi.mocked(api.lanes.changes).mockReset();
|
|
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
|
|
vi.mocked(api.lanes.gitAction).mockReset();
|
|
vi.mocked(api.lanes.gitAction).mockResolvedValue({ action: "stage", ok: true, output: "" });
|
|
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: [],
|
|
},
|
|
],
|
|
});
|
|
vi.mocked(api.lanes.gitLs)
|
|
.mockReset()
|
|
.mockResolvedValue({
|
|
available: true,
|
|
paths: ["src/main.ts"],
|
|
truncated: false,
|
|
total: 1,
|
|
});
|
|
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
|
available: true,
|
|
path: "",
|
|
entries: [{ name: "src", path: "src", dir: true }],
|
|
truncated: false,
|
|
});
|
|
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
|
available: true,
|
|
path: "src/main.ts",
|
|
rev: "work",
|
|
binary: false,
|
|
truncated: false,
|
|
lines: ["const whole = 1;"],
|
|
});
|
|
});
|
|
|
|
/** The panel as the Workspace renders it: Files and Changes are its own mode
|
|
* buttons now, no activity-bar tab around it. */
|
|
function Tabbed({
|
|
laneId,
|
|
onDirtyChange,
|
|
}: {
|
|
laneId: number;
|
|
onDirtyChange?: (dirty: boolean) => void;
|
|
}) {
|
|
return <LaneChangesPanel laneId={laneId} onDirtyChange={onDirtyChange} />;
|
|
}
|
|
|
|
describe("LaneChangesPanel", () => {
|
|
it("opens on the overview: a summary request, files listed, no diff bodies", async () => {
|
|
render(<LaneChangesPanel laneId={7} />);
|
|
|
|
expect(await screen.findByTestId("lane-changes-overview")).toBeInTheDocument();
|
|
expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "work" });
|
|
expect(screen.getByTitle("server/lib/git-ops.js")).toBeInTheDocument();
|
|
expect(screen.getByText("+12")).toBeInTheDocument();
|
|
expect(screen.queryByText(/new line/)).toBeNull();
|
|
});
|
|
|
|
it("opens one file's diff when its row is clicked, asking for that path only", async () => {
|
|
const user = userEvent.setup();
|
|
render(<LaneChangesPanel laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
|
|
vi.mocked(api.lanes.changes).mockResolvedValue(
|
|
changes({
|
|
files: [
|
|
{
|
|
path: "server/lib/git-ops.js",
|
|
status: " M",
|
|
untracked: false,
|
|
added: 12,
|
|
deleted: 3,
|
|
binary: false,
|
|
hunks: [HUNK],
|
|
},
|
|
],
|
|
})
|
|
);
|
|
await user.click(screen.getByTitle("server/lib/git-ops.js"));
|
|
|
|
await waitFor(() =>
|
|
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
|
|
range: "work",
|
|
path: "server/lib/git-ops.js",
|
|
context: 3,
|
|
})
|
|
);
|
|
await toDiff(user);
|
|
// The highlighter splits a line into one span per token, so assert on the
|
|
// rendered text of the whole view rather than on a single node.
|
|
const view = await screen.findByTestId("lane-diff-view");
|
|
expect(view).toHaveTextContent("new line");
|
|
// Both gutters are rendered, so a reviewer can cite a line number.
|
|
expect(view).toHaveTextContent("keep");
|
|
});
|
|
|
|
it("asks for every file's hunks in review mode", async () => {
|
|
const user = userEvent.setup();
|
|
render(<LaneChangesPanel laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
|
|
await user.click(screen.getByRole("button", { name: "Review" }));
|
|
await waitFor(() =>
|
|
expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "work", full: true })
|
|
);
|
|
expect(await screen.findByTestId("lane-changes-review")).toBeInTheDocument();
|
|
});
|
|
|
|
it("switches the comparison when the range toggle is used", async () => {
|
|
const user = userEvent.setup();
|
|
render(<LaneChangesPanel laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
|
|
await user.click(screen.getByRole("button", { name: "vs base" }));
|
|
await waitFor(() => expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "branch" }));
|
|
});
|
|
|
|
it("says so, quietly, when the lane's directory is not a git repo", async () => {
|
|
vi.mocked(api.lanes.changes).mockResolvedValue({
|
|
available: false,
|
|
range: "work",
|
|
files: [],
|
|
});
|
|
render(<LaneChangesPanel laneId={7} />);
|
|
expect(
|
|
await screen.findByText("This workspace's directory is not a readable git repository.")
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("stages one file and asks the server for that path alone", async () => {
|
|
const user = userEvent.setup();
|
|
render(<LaneChangesPanel laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
|
|
// Both rows offer it (one modified, one untracked); the first is the
|
|
// modified file the assertion below names.
|
|
const [stage] = screen.getAllByRole("button", { name: "Stage this file" });
|
|
await user.click(stage!);
|
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
|
|
paths: ["server/lib/git-ops.js"],
|
|
});
|
|
});
|
|
|
|
it("keeps a never-stage file out of Stage all, without telling git", async () => {
|
|
const user = userEvent.setup();
|
|
render(<LaneChangesPanel laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
const [keep] = screen.getAllByRole("button", { name: /Never stage/ });
|
|
await user.click(keep!); // the first row: server/lib/git-ops.js
|
|
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
|
expect(JSON.parse(window.localStorage.getItem("agenthub.changes.neverStage.7")!)).toEqual([
|
|
"server/lib/git-ops.js",
|
|
]);
|
|
await user.click(screen.getByRole("button", { name: "Stage all (1)" }));
|
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", { paths: ["notes.txt"] });
|
|
// And back: the row's button now offers the way out.
|
|
await user.click(screen.getByRole("button", { name: "Include in Stage all again" }));
|
|
expect(JSON.parse(window.localStorage.getItem("agenthub.changes.neverStage.7")!)).toEqual([]);
|
|
});
|
|
|
|
it("draws a never-stage file orange in the Files tree", async () => {
|
|
window.localStorage.setItem("agenthub.changes.neverStage.7", JSON.stringify(["notes.txt"]));
|
|
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
|
available: true,
|
|
path: "",
|
|
entries: [
|
|
{ name: "notes.txt", path: "notes.txt", dir: false, status: "??" },
|
|
{ name: "a.txt", path: "a.txt", dir: false, status: " M" },
|
|
],
|
|
truncated: false,
|
|
});
|
|
const user = userEvent.setup();
|
|
render(<Tabbed laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
await user.click(screen.getByRole("button", { name: "Files" }));
|
|
const row = async (name: string) => (await screen.findByText(name)).closest("button")!;
|
|
expect(await row("notes.txt")).toHaveAttribute("data-tree-kept", "true");
|
|
expect(await row("notes.txt")).toHaveClass("text-kept");
|
|
expect(await row("a.txt")).not.toHaveAttribute("data-tree-kept");
|
|
expect(await row("a.txt")).toHaveClass("text-status-warning");
|
|
});
|
|
|
|
it("lists changes as a folder tree: a folder folds, and stages every file in it", async () => {
|
|
const user = userEvent.setup();
|
|
render(<LaneChangesPanel laneId={7} />);
|
|
const list = await screen.findByTestId("lane-changes-overview");
|
|
// `server/lib` holds nothing but `lib`: one compacted row.
|
|
const folder = within(list).getByTitle("server/lib");
|
|
expect(folder).toHaveTextContent("1 file");
|
|
expect(within(list).getByTitle("server/lib/git-ops.js")).toHaveTextContent("git-ops.js");
|
|
await user.click(folder);
|
|
expect(within(list).queryByTitle("server/lib/git-ops.js")).toBeNull();
|
|
await user.click(
|
|
within(folder.parentElement!).getByRole("button", {
|
|
name: "Stage every file in this folder",
|
|
})
|
|
);
|
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
|
|
paths: ["server/lib/git-ops.js"],
|
|
});
|
|
});
|
|
|
|
it("heads the panel with the lane's git line, and a stage re-reads it", async () => {
|
|
vi.mocked(api.lanes.git).mockResolvedValue({
|
|
available: true,
|
|
branch: "feat/thing",
|
|
head: "9b3e74a",
|
|
subject: "wip",
|
|
dirty: 2,
|
|
untracked: 1,
|
|
files: [],
|
|
hasRemote: false,
|
|
upstream: null,
|
|
ahead: null,
|
|
behind: null,
|
|
});
|
|
const user = userEvent.setup();
|
|
render(<LaneChangesPanel laneId={7} />);
|
|
expect(await screen.findByTestId("lane-git")).toHaveTextContent("feat/thing");
|
|
expect(screen.getByTestId("lane-git-bar")).toBeInTheDocument();
|
|
expect(api.lanes.git).toHaveBeenCalledTimes(1);
|
|
|
|
const [stage] = screen.getAllByRole("button", { name: "Stage this file" });
|
|
await user.click(stage!);
|
|
// The header's counts moved with the index: waiting out its 30s poll
|
|
// would leave them wrong beside the list that just changed.
|
|
await waitFor(() => expect(api.lanes.git).toHaveBeenCalledTimes(2));
|
|
});
|
|
|
|
it("re-reads the diff after a git bar action", async () => {
|
|
vi.mocked(api.lanes.git).mockResolvedValue({
|
|
available: true,
|
|
branch: "feat/thing",
|
|
head: "9b3e74a",
|
|
subject: "wip",
|
|
dirty: 0,
|
|
untracked: 0,
|
|
files: [],
|
|
hasRemote: true,
|
|
upstream: "origin/feat/thing",
|
|
ahead: 0,
|
|
behind: 1,
|
|
});
|
|
const user = userEvent.setup();
|
|
render(<LaneChangesPanel laneId={7} />);
|
|
await screen.findByTestId("lane-git-bar");
|
|
const before = vi.mocked(api.lanes.changes).mock.calls.length;
|
|
|
|
await user.click(screen.getByRole("button", { name: /Pull/ }));
|
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "pull", {});
|
|
// A pull rewrites the working tree the list below describes.
|
|
await waitFor(() =>
|
|
expect(vi.mocked(api.lanes.changes).mock.calls.length).toBeGreaterThan(before)
|
|
);
|
|
});
|
|
|
|
it("re-reads history too when Refresh is pressed there", async () => {
|
|
const user = userEvent.setup();
|
|
render(<LaneChangesPanel laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
await user.click(screen.getByRole("button", { name: "History" }));
|
|
await screen.findByText("feat: the commit");
|
|
vi.mocked(api.lanes.gitLog).mockClear();
|
|
|
|
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
|
// A pull or a switch from the git bar moves the commit list as well.
|
|
await waitFor(() => expect(api.lanes.gitLog).toHaveBeenCalledTimes(1));
|
|
});
|
|
|
|
it("stages every file the index lacks in one call, from above the list", async () => {
|
|
const user = userEvent.setup();
|
|
render(<LaneChangesPanel laneId={7} />);
|
|
const list = await screen.findByTestId("lane-changes-overview");
|
|
const actions = screen.getByTestId("lane-changes-actions");
|
|
// Commit box and Stage all come BEFORE the file list.
|
|
expect(actions.compareDocumentPosition(list) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
|
expect(screen.getByTestId("lane-changes-commit")).toBeInTheDocument();
|
|
|
|
await user.click(screen.getByRole("button", { name: "Stage all (2)" }));
|
|
// The already-staged docs/API.md is not sent again.
|
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
|
|
paths: ["server/lib/git-ops.js", "notes.txt"],
|
|
});
|
|
});
|
|
|
|
it("fills the commit box with the AI-suggested message", async () => {
|
|
vi.mocked(api.lanes.suggestCommitMessage).mockResolvedValue({ message: "docs: update API" });
|
|
const user = userEvent.setup();
|
|
render(<LaneChangesPanel laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
await user.click(screen.getByTestId("lane-changes-suggest-message"));
|
|
expect(api.lanes.suggestCommitMessage).toHaveBeenCalledWith(7);
|
|
expect(await screen.findByDisplayValue("docs: update API")).toBeInTheDocument();
|
|
});
|
|
|
|
it("lists staged files apart from the changes the next commit leaves behind", async () => {
|
|
render(<LaneChangesPanel laneId={7} />);
|
|
const list = await screen.findByTestId("lane-changes-overview");
|
|
const staged = list.querySelector('[data-group="staged"]') as HTMLElement;
|
|
const unstaged = list.querySelector('[data-group="unstaged"]') as HTMLElement;
|
|
expect(within(staged).getByTitle("docs/API.md")).toBeInTheDocument();
|
|
expect(within(staged).queryByText("notes.txt")).toBeNull();
|
|
expect(within(unstaged).getByTitle("server/lib/git-ops.js")).toBeInTheDocument();
|
|
expect(within(unstaged).getByText("notes.txt")).toBeInTheDocument();
|
|
expect(within(unstaged).queryByTitle("docs/API.md")).toBeNull();
|
|
expect(screen.getByText("Staged Changes")).toBeInTheDocument();
|
|
});
|
|
|
|
it("reads a whole markdown file rendered after Preview, and back as source after Raw", async () => {
|
|
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
|
available: true,
|
|
path: "",
|
|
entries: [{ name: "README.md", path: "README.md", dir: false, status: " M" }],
|
|
truncated: false,
|
|
});
|
|
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
|
available: true,
|
|
path: "README.md",
|
|
rev: "work",
|
|
binary: false,
|
|
truncated: false,
|
|
lines: ["# Hello", "", "- one"],
|
|
sha: "c".repeat(40),
|
|
});
|
|
const user = userEvent.setup();
|
|
render(<LaneChangesPanel laneId={7} full />);
|
|
const sidebar = await screen.findByTestId("lane-changes-sidebar");
|
|
await user.click(await within(sidebar).findByText("README.md"));
|
|
await screen.findByTestId("lane-file-editor");
|
|
await user.click(screen.getByTestId("lane-file-preview"));
|
|
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
|
|
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
|
await user.click(screen.getByTestId("lane-file-preview"));
|
|
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("# Hello\n\n- one");
|
|
});
|
|
|
|
it("shows an image file as the picture, with no edit control", async () => {
|
|
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
|
available: true,
|
|
path: "",
|
|
entries: [{ name: "logo.png", path: "logo.png", dir: false, status: "" }],
|
|
truncated: false,
|
|
});
|
|
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
|
available: true,
|
|
path: "logo.png",
|
|
rev: "work",
|
|
binary: true,
|
|
truncated: false,
|
|
lines: [],
|
|
});
|
|
const user = userEvent.setup();
|
|
render(<LaneChangesPanel laneId={7} full />);
|
|
const sidebar = await screen.findByTestId("lane-changes-sidebar");
|
|
await user.click(await within(sidebar).findByText("logo.png"));
|
|
const img = within(await screen.findByTestId("lane-file-image")).getByRole("img");
|
|
expect(img).toHaveAttribute("src", "/api/lanes/7/git/raw?path=logo.png&rev=work");
|
|
expect(screen.queryByTestId("lane-file-view")).toBeNull();
|
|
expect(screen.queryByTestId("lane-file-noedit")).toBeNull();
|
|
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
|
|
});
|
|
|
|
it("at full width shows the tree as a sidebar and opens a file straight in the editor", async () => {
|
|
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
|
available: true,
|
|
path: "",
|
|
entries: [{ name: "main.ts", path: "src/main.ts", dir: false, status: " M" }],
|
|
truncated: false,
|
|
});
|
|
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
|
available: true,
|
|
path: "src/main.ts",
|
|
rev: "work",
|
|
binary: false,
|
|
truncated: false,
|
|
lines: ["const whole = 1;"],
|
|
sha: "b".repeat(40),
|
|
});
|
|
const user = userEvent.setup();
|
|
render(<LaneChangesPanel laneId={7} full />);
|
|
const sidebar = await screen.findByTestId("lane-changes-sidebar");
|
|
// The sidebar IS the Files view: its finder lives there, and the tab is gone.
|
|
expect(within(sidebar).getByLabelText("Find a file by path…")).toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: "Files" })).toBeNull();
|
|
expect(screen.getAllByTestId("lane-changes-browse")).toHaveLength(1);
|
|
await user.click(await within(sidebar).findByText("main.ts"));
|
|
|
|
// Opening a file is editing it, diffed against its HEAD text.
|
|
const editor = await screen.findByTestId("lane-file-editor");
|
|
expect(editor).toHaveValue("const whole = 1;");
|
|
expect(editor).toHaveFocus();
|
|
expect(api.lanes.gitFile).toHaveBeenCalledWith(7, "src/main.ts", "HEAD");
|
|
expect(editor).toHaveAttribute("data-original", "const whole = 1;");
|
|
expect(screen.queryByTestId("lane-file-view")).toBeNull();
|
|
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
|
|
expect(screen.getByTestId("lane-file-cancel")).toBeDisabled();
|
|
await user.type(editor, "x");
|
|
expect(screen.getByTestId("lane-file-save")).toBeEnabled();
|
|
// Cancel asks before dropping an edit: "keep" keeps it…
|
|
vi.mocked(confirmDiscard).mockClear().mockResolvedValueOnce(false);
|
|
await user.click(screen.getByTestId("lane-file-cancel"));
|
|
expect(confirmDiscard).toHaveBeenCalledTimes(1);
|
|
expect(screen.getByTestId("lane-file-editor")).toHaveValue("const whole = 1;x");
|
|
// …and "discard" reverts it, still editing.
|
|
await user.click(screen.getByTestId("lane-file-cancel"));
|
|
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("const whole = 1;");
|
|
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
|
|
});
|
|
|
|
it("edits a changed file on its Changes, side by side when wide, and keeps the edit on Whole file", async () => {
|
|
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
|
available: true,
|
|
path: "server/lib/git-ops.js",
|
|
rev: "work",
|
|
binary: false,
|
|
truncated: false,
|
|
lines: ["a"],
|
|
sha: "b".repeat(40),
|
|
});
|
|
const user = userEvent.setup();
|
|
render(<LaneChangesPanel laneId={7} full />);
|
|
const list = await screen.findByTestId("lane-changes-overview");
|
|
await user.click(within(list).getByTitle("server/lib/git-ops.js"));
|
|
await screen.findByRole("button", { name: "Changes", pressed: true });
|
|
// Changes is the editor folded to its changes, side by side at full width.
|
|
const editor = await screen.findByTestId("lane-file-editor");
|
|
expect(editor).toHaveValue("a");
|
|
expect(editor).toHaveAttribute("data-collapse", "true");
|
|
expect(editor).toHaveAttribute("data-layout", "split");
|
|
await user.type(editor, "b");
|
|
// Whole file unfolds the same edit — no prompt, nothing dropped.
|
|
vi.mocked(confirmDiscard).mockClear();
|
|
await user.click(screen.getByRole("button", { name: "Whole file", pressed: false }));
|
|
const whole = await screen.findByTestId("lane-file-editor");
|
|
expect(whole).toHaveAttribute("data-collapse", "false");
|
|
expect(whole).toHaveValue("ab");
|
|
await user.click(screen.getByRole("button", { name: "Changes", pressed: false }));
|
|
expect(await screen.findByTestId("lane-file-editor")).toHaveValue("ab");
|
|
expect(confirmDiscard).not.toHaveBeenCalled();
|
|
expect(screen.getByTestId("lane-file-save")).toBeEnabled();
|
|
});
|
|
|
|
it("opens diffs unified at the narrow width", async () => {
|
|
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
|
available: true,
|
|
path: "server/lib/git-ops.js",
|
|
rev: "work",
|
|
binary: false,
|
|
truncated: false,
|
|
lines: ["a"],
|
|
sha: "b".repeat(40),
|
|
});
|
|
const user = userEvent.setup();
|
|
render(<LaneChangesPanel laneId={7} />);
|
|
const list = await screen.findByTestId("lane-changes-overview");
|
|
await user.click(within(list).getByTitle("server/lib/git-ops.js"));
|
|
expect(await screen.findByTestId("lane-file-editor")).toHaveAttribute("data-layout", "unified");
|
|
});
|
|
|
|
it("needs two clicks to discard, and only the second one reaches the server", async () => {
|
|
const user = userEvent.setup();
|
|
render(<LaneChangesPanel laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
|
|
await user.click(discardOf("server/lib/git-ops.js"));
|
|
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
|
|
|
await user.click(
|
|
screen.getByRole("button", { name: "Click again to discard — this cannot be undone" })
|
|
);
|
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", {
|
|
paths: ["server/lib/git-ops.js"],
|
|
confirm: true,
|
|
});
|
|
});
|
|
|
|
it("right-click on a changed file offers stage, a two-click discard and copy path", async () => {
|
|
const user = userEvent.setup();
|
|
render(<LaneChangesPanel laneId={7} />);
|
|
const list = await screen.findByTestId("lane-changes-overview");
|
|
const row = within(list).getByTitle("server/lib/git-ops.js");
|
|
const right = () => user.pointer({ keys: "[MouseRight]", target: row });
|
|
|
|
await right();
|
|
await user.click(
|
|
within(screen.getByTestId("lane-changes-file-menu")).getByText("Stage this file")
|
|
);
|
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
|
|
paths: ["server/lib/git-ops.js"],
|
|
});
|
|
vi.mocked(api.lanes.gitAction).mockClear();
|
|
|
|
await right();
|
|
await user.click(screen.getByTestId("lane-changes-menu-discard"));
|
|
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
|
expect(screen.getByTestId("lane-changes-menu-discard")).toHaveTextContent("Click again");
|
|
await user.click(screen.getByTestId("lane-changes-menu-discard"));
|
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", {
|
|
paths: ["server/lib/git-ops.js"],
|
|
confirm: true,
|
|
});
|
|
|
|
await right();
|
|
await user.click(
|
|
within(screen.getByTestId("lane-changes-file-menu")).getByText("Copy relative path")
|
|
);
|
|
expect(await navigator.clipboard.readText()).toBe("server/lib/git-ops.js");
|
|
});
|
|
|
|
it("disarms a pending discard when the lane changes, so one click cannot discard in another lane", async () => {
|
|
const user = userEvent.setup();
|
|
const { rerender } = render(<LaneChangesPanel laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
const [discard] = screen.getAllByRole("button", { name: "Discard this file's changes" });
|
|
await user.click(discard!);
|
|
|
|
// Lane 8 has a file at the SAME path.
|
|
rerender(<LaneChangesPanel laneId={8} />);
|
|
await waitFor(() => expect(api.lanes.changes).toHaveBeenCalledWith(8, expect.anything()));
|
|
expect(
|
|
screen.queryByRole("button", { name: "Click again to discard — this cannot be undone" })
|
|
).toBeNull();
|
|
const [again] = await screen.findAllByRole("button", { name: "Discard this file's changes" });
|
|
await user.click(again!);
|
|
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("disarms a pending discard once that path leaves the file list", async () => {
|
|
const user = userEvent.setup();
|
|
render(<LaneChangesPanel laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
await user.click(discardOf("server/lib/git-ops.js"));
|
|
|
|
// The file was committed/reverted elsewhere; a later reappearance must not be pre-armed.
|
|
const base = changes();
|
|
const rest = base.available ? base.files.slice(1) : [];
|
|
vi.mocked(api.lanes.changes).mockResolvedValueOnce(changes({ files: rest }));
|
|
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
|
|
// A plain refresh (the poll's twin), not a git action — an action already disarms.
|
|
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
|
await waitFor(() => expect(screen.queryByTitle("server/lib/git-ops.js")).toBeNull());
|
|
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
|
await screen.findByTitle("server/lib/git-ops.js");
|
|
expect(
|
|
screen.queryByRole("button", { name: "Click again to discard — this cannot be undone" })
|
|
).toBeNull();
|
|
});
|
|
|
|
it("ignores a late response for the lane it has already left", async () => {
|
|
let resolveOld: (v: LaneChanges) => void = () => {};
|
|
vi.mocked(api.lanes.changes).mockImplementation((id) =>
|
|
id === 7
|
|
? new Promise<LaneChanges>((r) => (resolveOld = r))
|
|
: Promise.resolve(
|
|
changes({
|
|
files: [
|
|
{
|
|
path: "lane8.txt",
|
|
status: "??",
|
|
untracked: true,
|
|
added: 1,
|
|
deleted: 0,
|
|
binary: false,
|
|
},
|
|
],
|
|
})
|
|
)
|
|
);
|
|
const { rerender } = render(<LaneChangesPanel laneId={7} />);
|
|
rerender(<LaneChangesPanel laneId={8} />);
|
|
expect(await screen.findByText("lane8.txt")).toBeInTheDocument();
|
|
|
|
resolveOld(changes());
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
expect(screen.getByText("lane8.txt")).toBeInTheDocument();
|
|
expect(screen.queryByTitle("server/lib/git-ops.js")).toBeNull();
|
|
});
|
|
|
|
it("opens a commit from history as a commit-range review", async () => {
|
|
const user = userEvent.setup();
|
|
render(<LaneChangesPanel laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
|
|
await user.click(screen.getByRole("button", { name: "History" }));
|
|
expect(await screen.findAllByTestId("lane-changes-graph")).toHaveLength(1);
|
|
await user.click(await screen.findByText("feat: the commit"));
|
|
|
|
await waitFor(() =>
|
|
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
|
|
range: "commit",
|
|
sha: "a".repeat(40),
|
|
full: true,
|
|
})
|
|
);
|
|
expect(await screen.findByTestId("lane-changes-commit-chip")).toHaveTextContent("aaaaaaa");
|
|
});
|
|
|
|
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) =>
|
|
path === "src"
|
|
? {
|
|
available: true,
|
|
path: "src",
|
|
entries: [{ name: "main.ts", path: "src/main.ts", dir: false }],
|
|
truncated: false,
|
|
}
|
|
: {
|
|
available: true,
|
|
path: "",
|
|
entries: [{ name: "src", path: "src", dir: true }],
|
|
truncated: false,
|
|
}
|
|
);
|
|
render(<Tabbed laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
|
|
await user.click(screen.getByRole("button", { name: "Files" }));
|
|
await user.click(await screen.findByText("src"));
|
|
|
|
expect(await screen.findByText("main.ts")).toBeInTheDocument();
|
|
// The root's "src" row is still there — expanding did not replace the list.
|
|
expect(screen.getByText("src")).toBeInTheDocument();
|
|
});
|
|
|
|
it("expands every folder at once from one whole-tree read", async () => {
|
|
const user = userEvent.setup();
|
|
vi.mocked(api.lanes.gitTreeAll).mockResolvedValue({
|
|
available: true,
|
|
truncated: false,
|
|
levels: {
|
|
"": [{ name: "src", path: "src", dir: true }],
|
|
src: [{ name: "main.ts", path: "src/main.ts", dir: false }],
|
|
},
|
|
});
|
|
vi.mocked(api.lanes.gitTree).mockImplementation(async (_id, path) => {
|
|
if (path === "src")
|
|
return {
|
|
available: true,
|
|
path: "src",
|
|
entries: [{ name: "main.ts", path: "src/main.ts", dir: false }],
|
|
truncated: false,
|
|
};
|
|
return {
|
|
available: true,
|
|
path: "",
|
|
entries: [{ name: "src", path: "src", dir: true }],
|
|
truncated: false,
|
|
};
|
|
});
|
|
render(<Tabbed laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
|
|
await user.click(screen.getByRole("button", { name: "Files" }));
|
|
await screen.findByText("src");
|
|
await user.click(screen.getByRole("button", { name: "Expand all folders" }));
|
|
|
|
expect(await screen.findByText("main.ts")).toBeInTheDocument();
|
|
|
|
await user.click(screen.getByRole("button", { name: "Collapse all folders" }));
|
|
expect(screen.queryByText("main.ts")).not.toBeInTheDocument();
|
|
expect(screen.getByText("src")).toBeInTheDocument();
|
|
});
|
|
|
|
it("highlights modified and untracked files in the Files tree, and folders holding them", async () => {
|
|
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
|
available: true,
|
|
path: "",
|
|
entries: [
|
|
{ name: "src", path: "src", dir: true, changed: true },
|
|
{ name: "docs", path: "docs", dir: true },
|
|
{ name: "a.txt", path: "a.txt", dir: false, status: " M" },
|
|
{ name: "new.txt", path: "new.txt", dir: false, status: "??" },
|
|
{ name: "keep.txt", path: "keep.txt", dir: false },
|
|
],
|
|
truncated: false,
|
|
});
|
|
const user = userEvent.setup();
|
|
render(<Tabbed laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
await user.click(screen.getByRole("button", { name: "Files" }));
|
|
|
|
const row = async (name: string) => (await screen.findByText(name)).closest("button")!;
|
|
expect(await row("a.txt")).toHaveAttribute("data-tree-status", "M");
|
|
expect(await row("a.txt")).toHaveClass("text-status-warning");
|
|
expect(await row("new.txt")).toHaveAttribute("data-tree-status", "U");
|
|
expect(await row("new.txt")).toHaveClass("text-status-success");
|
|
expect(await row("src")).toHaveAttribute("data-tree-status", "dir");
|
|
expect(await row("keep.txt")).not.toHaveAttribute("data-tree-status");
|
|
expect(await row("docs")).not.toHaveAttribute("data-tree-status");
|
|
});
|
|
|
|
it("quick-opens a tracked file as its whole content, not a diff", async () => {
|
|
const user = userEvent.setup();
|
|
render(<Tabbed laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
|
|
await user.click(screen.getByRole("button", { name: "Files" }));
|
|
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
|
|
await user.click(await screen.findByText("src/main.ts"));
|
|
|
|
await waitFor(() => expect(api.lanes.gitFile).toHaveBeenCalledWith(7, "src/main.ts", "work"));
|
|
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;");
|
|
});
|
|
|
|
it("edits a whole file in place and saves over the exact version it opened", async () => {
|
|
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
|
available: true,
|
|
path: "src/main.ts",
|
|
rev: "work",
|
|
binary: false,
|
|
truncated: false,
|
|
lines: ["const whole = 1;"],
|
|
sha: "b".repeat(40),
|
|
});
|
|
vi.mocked(api.lanes.gitSaveFile).mockResolvedValue({
|
|
available: true,
|
|
path: "src/main.ts",
|
|
rev: "work",
|
|
binary: false,
|
|
truncated: false,
|
|
lines: ["const whole = 2;"],
|
|
sha: "c".repeat(40),
|
|
});
|
|
const user = userEvent.setup();
|
|
render(<Tabbed laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
await user.click(screen.getByRole("button", { name: "Files" }));
|
|
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
|
|
await user.click(await screen.findByText("src/main.ts"));
|
|
const editor = await screen.findByTestId("lane-file-editor");
|
|
expect(editor).toHaveValue("const whole = 1;");
|
|
await user.clear(editor);
|
|
await user.type(editor, "const whole = 2;");
|
|
await user.click(screen.getByTestId("lane-file-save"));
|
|
|
|
expect(api.lanes.gitSaveFile).toHaveBeenCalledWith(
|
|
7,
|
|
"src/main.ts",
|
|
"const whole = 2;",
|
|
"b".repeat(40)
|
|
);
|
|
// Still editing, on what was saved: nothing left to save.
|
|
await waitFor(() => expect(screen.getByTestId("lane-file-save")).toBeDisabled());
|
|
expect(screen.getByTestId("lane-file-editor")).toHaveValue("const whole = 2;");
|
|
});
|
|
|
|
it("says why a file cannot be edited instead of offering a dead Edit button", async () => {
|
|
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
|
available: true,
|
|
path: "src/main.ts",
|
|
rev: "work",
|
|
binary: false,
|
|
truncated: true, // over the 1 MB view cap: no `sha`, nothing safe to save
|
|
lines: ["partial"],
|
|
});
|
|
const user = userEvent.setup();
|
|
render(<Tabbed laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
await user.click(screen.getByRole("button", { name: "Files" }));
|
|
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
|
|
await user.click(await screen.findByText("src/main.ts"));
|
|
|
|
expect(await screen.findByTestId("lane-file-noedit")).toHaveTextContent(
|
|
"Too large to edit here (over 1 MB)"
|
|
);
|
|
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
|
|
});
|
|
|
|
it("keeps the draft and says why when the file changed on disk meanwhile", async () => {
|
|
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
|
available: true,
|
|
path: "src/main.ts",
|
|
rev: "work",
|
|
binary: false,
|
|
truncated: false,
|
|
lines: ["x"],
|
|
sha: "b".repeat(40),
|
|
});
|
|
vi.mocked(api.lanes.gitSaveFile).mockRejectedValue(
|
|
Object.assign(new Error("the file changed on disk since it was opened"), { code: "ESTALE" })
|
|
);
|
|
const user = userEvent.setup();
|
|
render(<Tabbed laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
await user.click(screen.getByRole("button", { name: "Files" }));
|
|
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
|
|
await user.click(await screen.findByText("src/main.ts"));
|
|
await user.type(await screen.findByTestId("lane-file-editor"), "y");
|
|
await user.click(screen.getByTestId("lane-file-save"));
|
|
|
|
expect(await screen.findByRole("alert")).toHaveTextContent(/changed on disk/);
|
|
expect(screen.getByTestId("lane-file-editor")).toHaveValue("xy");
|
|
});
|
|
|
|
it("unstages every staged file in one call, on the same row as Stage all and commit", async () => {
|
|
const user = userEvent.setup();
|
|
render(<LaneChangesPanel laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
const row = screen.getByTestId("lane-changes-actions");
|
|
expect(row).toContainElement(screen.getByTestId("lane-changes-commit"));
|
|
expect(row).toContainElement(screen.getByRole("button", { name: "Stage all (2)" }));
|
|
|
|
await user.click(screen.getByRole("button", { name: "Unstage all (1)" }));
|
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "unstage", { paths: ["docs/API.md"] });
|
|
});
|
|
|
|
it("switches the diff to side-by-side", async () => {
|
|
const user = userEvent.setup();
|
|
render(<LaneChangesPanel laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
|
|
vi.mocked(api.lanes.changes).mockResolvedValue(
|
|
changes({
|
|
files: [
|
|
{
|
|
path: "server/lib/git-ops.js",
|
|
status: " M",
|
|
untracked: false,
|
|
added: 12,
|
|
deleted: 3,
|
|
binary: false,
|
|
hunks: [HUNK],
|
|
},
|
|
],
|
|
})
|
|
);
|
|
await user.click(screen.getByTitle("server/lib/git-ops.js"));
|
|
await toDiff(user);
|
|
expect(await screen.findByTestId("lane-diff-view")).toHaveAttribute("data-layout", "unified");
|
|
|
|
await user.click(screen.getByRole("button", { name: "Side-by-side" }));
|
|
expect(screen.getByTestId("lane-diff-view")).toHaveAttribute("data-layout", "split");
|
|
});
|
|
|
|
it("expands and then collapses the context again", async () => {
|
|
const user = userEvent.setup();
|
|
render(<LaneChangesPanel laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
await user.click(screen.getByTitle("server/lib/git-ops.js"));
|
|
await toDiff(user);
|
|
await screen.findByRole("button", { name: "Show more surrounding lines" });
|
|
|
|
// Collapse is dead at the tightest step, and live once expanded — an
|
|
// expanded diff that cannot be collapsed sends the reader back out of the
|
|
// file to get the tight view.
|
|
expect(screen.getByRole("button", { name: "Show fewer surrounding lines" })).toBeDisabled();
|
|
await user.click(screen.getByRole("button", { name: "Show more surrounding lines" }));
|
|
await waitFor(() =>
|
|
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
|
|
range: "work",
|
|
path: "server/lib/git-ops.js",
|
|
context: 12,
|
|
})
|
|
);
|
|
|
|
await user.click(screen.getByRole("button", { name: "Show fewer surrounding lines" }));
|
|
await waitFor(() =>
|
|
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
|
|
range: "work",
|
|
path: "server/lib/git-ops.js",
|
|
context: 3,
|
|
})
|
|
);
|
|
});
|
|
|
|
it("asks for more context when the diff is expanded", async () => {
|
|
const user = userEvent.setup();
|
|
render(<LaneChangesPanel laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
await user.click(screen.getByTitle("server/lib/git-ops.js"));
|
|
await toDiff(user);
|
|
|
|
await user.click(screen.getByRole("button", { name: "Show more surrounding lines" }));
|
|
await waitFor(() =>
|
|
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
|
|
range: "work",
|
|
path: "server/lib/git-ops.js",
|
|
context: 12,
|
|
})
|
|
);
|
|
});
|
|
|
|
it("commits the index from the overview, never the whole tree", async () => {
|
|
const user = userEvent.setup();
|
|
render(<LaneChangesPanel laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
|
|
// The box exists only because a file is staged — with a clean index there
|
|
// is nothing this button could mean.
|
|
const box = await screen.findByTestId("lane-changes-commit");
|
|
await user.type(screen.getByLabelText("Commit message"), "fix: the staged half");
|
|
await user.click(screen.getByRole("button", { name: "Commit 1 staged" }));
|
|
|
|
expect(box).toBeInTheDocument();
|
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "commit", {
|
|
message: "fix: the staged half",
|
|
only: "staged",
|
|
});
|
|
});
|
|
|
|
it("drops a half-typed commit message on a lane switch", async () => {
|
|
const user = userEvent.setup();
|
|
const { rerender } = render(<LaneChangesPanel laneId={7} />);
|
|
await screen.findByTestId("lane-changes-commit");
|
|
await user.type(screen.getByLabelText("Commit message"), "fix: meant for lane 7");
|
|
rerender(<LaneChangesPanel laneId={8} />);
|
|
await screen.findByTestId("lane-changes-commit");
|
|
expect(screen.getByLabelText("Commit message")).toHaveValue("");
|
|
});
|
|
|
|
it("hides the commit box when nothing is staged", async () => {
|
|
vi.mocked(api.lanes.changes).mockResolvedValue(
|
|
changes({
|
|
files: [
|
|
{
|
|
path: "server/lib/git-ops.js",
|
|
status: " M",
|
|
untracked: false,
|
|
unstaged: true,
|
|
added: 1,
|
|
deleted: 0,
|
|
binary: false,
|
|
},
|
|
],
|
|
})
|
|
);
|
|
render(<LaneChangesPanel laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
expect(screen.queryByTestId("lane-changes-commit")).toBeNull();
|
|
});
|
|
|
|
it("puts Files in the mode row beside Changes: one panel, no separate tab", async () => {
|
|
render(<LaneChangesPanel laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
for (const name of ["Changes", "Files", "Review", "History"]) {
|
|
expect(screen.getByRole("button", { name })).toBeInTheDocument();
|
|
}
|
|
});
|
|
|
|
it("folds one file shut in review mode, and all of them at once", async () => {
|
|
const user = userEvent.setup();
|
|
const withHunks = changes({
|
|
files: [
|
|
{
|
|
path: "server/lib/git-ops.js",
|
|
status: " M",
|
|
untracked: false,
|
|
added: 1,
|
|
deleted: 1,
|
|
binary: false,
|
|
hunks: [HUNK],
|
|
},
|
|
{
|
|
path: "notes.txt",
|
|
status: "??",
|
|
untracked: true,
|
|
added: 1,
|
|
deleted: 0,
|
|
binary: false,
|
|
hunks: [HUNK],
|
|
},
|
|
],
|
|
});
|
|
render(<LaneChangesPanel laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
vi.mocked(api.lanes.changes).mockResolvedValue(withHunks);
|
|
await user.click(screen.getByRole("button", { name: "Review" }));
|
|
await screen.findByTestId("lane-changes-review");
|
|
expect(screen.getAllByTestId("lane-diff-view")).toHaveLength(2);
|
|
|
|
await user.click(screen.getByRole("button", { name: "Collapse server/lib/git-ops.js" }));
|
|
expect(screen.getAllByTestId("lane-diff-view")).toHaveLength(1);
|
|
// The header stays, so a folded file is still a row you can reopen.
|
|
expect(
|
|
screen.getByRole("button", { name: "Expand server/lib/git-ops.js" })
|
|
).toBeInTheDocument();
|
|
|
|
await user.click(screen.getByRole("button", { name: "Collapse every file" }));
|
|
expect(screen.queryAllByTestId("lane-diff-view")).toHaveLength(0);
|
|
|
|
await user.click(screen.getByRole("button", { name: "Expand every file" }));
|
|
expect(screen.getAllByTestId("lane-diff-view")).toHaveLength(2);
|
|
});
|
|
|
|
describe("review actions", () => {
|
|
const reviewed = () =>
|
|
changes({
|
|
files: [
|
|
{
|
|
path: "server/lib/git-ops.js",
|
|
status: " M",
|
|
untracked: false,
|
|
unstaged: true,
|
|
added: 1,
|
|
deleted: 1,
|
|
binary: false,
|
|
hunks: [HUNK, { ...HUNK, header: "@@ -9,2 +10,3 @@" }],
|
|
},
|
|
{
|
|
path: "notes.txt",
|
|
status: "??",
|
|
untracked: true,
|
|
added: 1,
|
|
deleted: 0,
|
|
binary: false,
|
|
hunks: [HUNK],
|
|
},
|
|
],
|
|
});
|
|
const openReview = async (user: ReturnType<typeof userEvent.setup>) => {
|
|
render(<LaneChangesPanel laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
vi.mocked(api.lanes.changes).mockResolvedValue(reviewed());
|
|
await user.click(screen.getByRole("button", { name: "Review" }));
|
|
await screen.findByTestId("lane-changes-review");
|
|
};
|
|
|
|
it("stages one hunk by its @@ line, at the context the diff was read with", async () => {
|
|
const user = userEvent.setup();
|
|
await openReview(user);
|
|
// Two hunks on the tracked file; the untracked one has none to act on.
|
|
const stage = screen.getAllByRole("button", { name: "Stage this hunk" });
|
|
expect(stage).toHaveLength(2);
|
|
await user.click(stage[1]!);
|
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage-hunk", {
|
|
path: "server/lib/git-ops.js",
|
|
hunk: "@@ -9,2 +10,3 @@",
|
|
block: 0,
|
|
context: 3,
|
|
});
|
|
});
|
|
|
|
it("gives each +/- block inside ONE hunk its own bar and acts on that block only (#50)", async () => {
|
|
const user = userEvent.setup();
|
|
render(<LaneChangesPanel laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
vi.mocked(api.lanes.changes).mockResolvedValue(
|
|
changes({
|
|
files: [
|
|
{
|
|
path: "server/lib/git-ops.js",
|
|
status: " M",
|
|
untracked: false,
|
|
unstaged: true,
|
|
added: 2,
|
|
deleted: 1,
|
|
binary: false,
|
|
hunks: [
|
|
{
|
|
header: "@@ -1,4 +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: "add" as const, oldLine: null, newLine: 4, text: "new" },
|
|
{ type: "ctx" as const, oldLine: 4, newLine: 5, text: "d" },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
})
|
|
);
|
|
await user.click(screen.getByRole("button", { name: "Review" }));
|
|
await screen.findByTestId("lane-changes-review");
|
|
|
|
const bars = screen.getAllByTestId("lane-hunk-bar");
|
|
expect(bars.map((b) => b.textContent)).toEqual([
|
|
expect.stringContaining("1 of 2"),
|
|
expect.stringContaining("2 of 2"),
|
|
]);
|
|
await user.click(screen.getAllByRole("button", { name: "Stage this hunk" })[1]!);
|
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage-hunk", {
|
|
path: "server/lib/git-ops.js",
|
|
hunk: "@@ -1,4 +1,5 @@",
|
|
block: 1,
|
|
context: 3,
|
|
});
|
|
});
|
|
|
|
it("hangs one review bar under each actionable hunk, counting them", async () => {
|
|
const user = userEvent.setup();
|
|
await openReview(user);
|
|
// Two hunks on the tracked file; the untracked file gets no bar.
|
|
const bars = screen.getAllByTestId("lane-hunk-bar");
|
|
expect(bars.map((b) => b.textContent)).toEqual([
|
|
expect.stringContaining("1 of 2"),
|
|
expect.stringContaining("2 of 2"),
|
|
]);
|
|
// Undo sits beside Keep inside the bar — not on the @@ line.
|
|
expect(bars[0]).toContainElement(
|
|
screen.getAllByRole("button", { name: "Undo this hunk" })[0]!
|
|
);
|
|
expect(screen.getAllByRole("button", { name: "Previous change" })[0]).toBeDisabled();
|
|
expect(screen.getAllByRole("button", { name: "Next change" })[1]).toBeDisabled();
|
|
});
|
|
|
|
it("needs two clicks to undo a hunk, and arms only that hunk", async () => {
|
|
const user = userEvent.setup();
|
|
await openReview(user);
|
|
const [first] = screen.getAllByRole("button", { name: "Undo this hunk" });
|
|
await user.click(first!);
|
|
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
|
// The other hunk is still at its first click.
|
|
expect(screen.getAllByRole("button", { name: "Undo this hunk" })).toHaveLength(1);
|
|
await user.click(
|
|
screen.getByRole("button", {
|
|
name: "Click again to undo this hunk — this cannot be undone",
|
|
})
|
|
);
|
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard-hunk", {
|
|
path: "server/lib/git-ops.js",
|
|
hunk: "@@ -1,2 +1,3 @@",
|
|
block: 0,
|
|
context: 3,
|
|
confirm: true,
|
|
});
|
|
});
|
|
|
|
it("stages a whole file from its review header without folding it", async () => {
|
|
const user = userEvent.setup();
|
|
await openReview(user);
|
|
const [stageFile] = screen.getAllByRole("button", { name: "Stage this file" });
|
|
await user.click(stageFile!);
|
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
|
|
paths: ["server/lib/git-ops.js"],
|
|
});
|
|
expect(
|
|
screen.getByRole("button", { name: "Collapse server/lib/git-ops.js" })
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("keeps each file's header pinned while its diff scrolls", async () => {
|
|
const user = userEvent.setup();
|
|
await openReview(user);
|
|
expect(screen.getByRole("button", { name: "Collapse server/lib/git-ops.js" })).toHaveClass(
|
|
"sticky",
|
|
"top-0"
|
|
);
|
|
});
|
|
});
|
|
|
|
it("opens a file only from its path, and folds it from anywhere else", async () => {
|
|
const user = userEvent.setup();
|
|
render(<LaneChangesPanel laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
await user.click(screen.getByRole("button", { name: "Review" }));
|
|
await screen.findByTestId("lane-changes-review");
|
|
|
|
// Anywhere on the header but the path: fold, do not navigate.
|
|
await user.click(screen.getByRole("button", { name: "Collapse server/lib/git-ops.js" }));
|
|
expect(screen.getByTestId("lane-changes-review")).toBeInTheDocument();
|
|
expect(
|
|
screen.getByRole("button", { name: "Expand server/lib/git-ops.js" })
|
|
).toBeInTheDocument();
|
|
|
|
await user.click(screen.getByRole("button", { name: "server/lib/git-ops.js" }));
|
|
await waitFor(() =>
|
|
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
|
|
range: "work",
|
|
path: "server/lib/git-ops.js",
|
|
context: 3,
|
|
})
|
|
);
|
|
});
|
|
|
|
it("opens, not folds, when Enter is pressed on the path", async () => {
|
|
const user = userEvent.setup();
|
|
render(<LaneChangesPanel laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
await user.click(screen.getByRole("button", { name: "Review" }));
|
|
await screen.findByTestId("lane-changes-review");
|
|
|
|
screen.getByRole("button", { name: "server/lib/git-ops.js" }).focus();
|
|
await user.keyboard("{Enter}");
|
|
await waitFor(() =>
|
|
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
|
|
range: "work",
|
|
path: "server/lib/git-ops.js",
|
|
context: 3,
|
|
})
|
|
);
|
|
});
|
|
|
|
it("drops a quick-open answer for the lane it has already left", async () => {
|
|
const user = userEvent.setup();
|
|
let answer: (v: { paths: string[] }) => void = () => {};
|
|
vi.mocked(api.lanes.gitLs).mockReturnValueOnce(
|
|
new Promise((r) => {
|
|
answer = r as typeof answer;
|
|
}) as ReturnType<typeof api.lanes.gitLs>
|
|
);
|
|
const { rerender } = render(<Tabbed laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
await user.click(screen.getByRole("button", { name: "Files" }));
|
|
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
|
|
await waitFor(() => expect(api.lanes.gitLs).toHaveBeenCalledWith(7, "main"));
|
|
|
|
rerender(<Tabbed laneId={8} />);
|
|
await act(async () => answer({ paths: ["from/lane7.ts"] }));
|
|
// Back in Files on lane 8, before its own search lands: nothing of lane 7.
|
|
await user.click(screen.getByRole("button", { name: "Files" }));
|
|
expect(screen.queryByText("from/lane7.ts")).toBeNull();
|
|
});
|
|
|
|
it("keeps the newest quick-open answer when an older one lands after it", async () => {
|
|
const user = userEvent.setup();
|
|
let answer: (v: { paths: string[] }) => void = () => {};
|
|
vi.mocked(api.lanes.gitLs)
|
|
.mockReturnValueOnce(
|
|
new Promise((r) => {
|
|
answer = r as typeof answer;
|
|
}) as ReturnType<typeof api.lanes.gitLs>
|
|
)
|
|
.mockResolvedValueOnce({
|
|
available: true,
|
|
paths: ["new/mainx.ts"],
|
|
truncated: false,
|
|
total: 1,
|
|
});
|
|
render(<Tabbed laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
await user.click(screen.getByRole("button", { name: "Files" }));
|
|
const box = await screen.findByLabelText("Find a file by path…");
|
|
await user.type(box, "main");
|
|
await waitFor(() => expect(api.lanes.gitLs).toHaveBeenCalledWith(7, "main"));
|
|
await user.type(box, "x");
|
|
expect(await screen.findByText("new/mainx.ts")).toBeInTheDocument();
|
|
|
|
await act(async () => answer({ paths: ["old/main-only.ts"] }));
|
|
expect(screen.queryByText("old/main-only.ts")).toBeNull();
|
|
});
|
|
|
|
it("goes back to the list it came from, at the offset it was left at", async () => {
|
|
const user = userEvent.setup();
|
|
render(<LaneChangesPanel laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
await user.click(screen.getByRole("button", { name: "Review" }));
|
|
await screen.findByTestId("lane-changes-review");
|
|
|
|
// jsdom lays nothing out, so the scrollable box is described by hand —
|
|
// the restore deliberately waits until the list is tall enough again.
|
|
const body = screen.getByTestId("lane-changes-body");
|
|
Object.defineProperty(body, "scrollHeight", { value: 2000, configurable: true });
|
|
Object.defineProperty(body, "clientHeight", { value: 400, configurable: true });
|
|
body.scrollTop = 640;
|
|
|
|
await user.click(screen.getByRole("button", { name: "server/lib/git-ops.js" }));
|
|
await screen.findByRole("button", { name: /All files/ });
|
|
|
|
await user.click(screen.getByRole("button", { name: /All files/ }));
|
|
// Back to REVIEW (where the file was opened from), not to the overview.
|
|
expect(await screen.findByTestId("lane-changes-review")).toBeInTheDocument();
|
|
expect(screen.getByTestId("lane-changes-body").scrollTop).toBe(640);
|
|
});
|
|
|
|
it("opens an unchanged tracked file from Files with no error banner", async () => {
|
|
const user = userEvent.setup();
|
|
render(<Tabbed laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
vi.mocked(api.lanes.changes).mockRejectedValue(
|
|
Object.assign(new Error("file has no changes in this range"), {
|
|
code: "ENOFILECHANGE",
|
|
status: 400,
|
|
})
|
|
);
|
|
|
|
await user.click(screen.getByRole("button", { name: "Files" }));
|
|
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
|
|
await user.click(await screen.findByText("src/main.ts"));
|
|
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;");
|
|
|
|
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
|
await waitFor(() => expect(api.lanes.changes).toHaveBeenCalledTimes(3));
|
|
expect(screen.queryByRole("alert")).toBeNull();
|
|
expect(screen.getByTestId("lane-file-view")).toBeInTheDocument();
|
|
});
|
|
|
|
it("keeps the overview rows mounted across re-renders (typing in the commit box)", async () => {
|
|
const user = userEvent.setup();
|
|
render(<LaneChangesPanel laneId={7} />);
|
|
await screen.findByTestId("lane-changes-commit");
|
|
const before = screen.getByTitle("server/lib/git-ops.js");
|
|
await user.type(screen.getByLabelText("Commit message"), "x");
|
|
expect(screen.getByTitle("server/lib/git-ops.js")).toBe(before);
|
|
});
|
|
|
|
it("expands all folders with ONE request, not one per folder", async () => {
|
|
const user = userEvent.setup();
|
|
vi.mocked(api.lanes.gitTree).mockClear();
|
|
vi.mocked(api.lanes.gitTreeAll)
|
|
.mockClear()
|
|
.mockResolvedValue({
|
|
available: true,
|
|
truncated: false,
|
|
levels: {
|
|
"": [{ name: "src", path: "src", dir: true }],
|
|
src: [{ name: "main.ts", path: "src/main.ts", dir: false }],
|
|
},
|
|
});
|
|
render(<Tabbed laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
await user.click(screen.getByRole("button", { name: "Files" }));
|
|
await screen.findByText("src");
|
|
await user.click(screen.getByRole("button", { name: "Expand all folders" }));
|
|
|
|
expect(await screen.findByText("main.ts")).toBeInTheDocument();
|
|
expect(api.lanes.gitTreeAll).toHaveBeenCalledTimes(1);
|
|
// Only the root read the Files tab made on entry — no per-folder walk.
|
|
expect(api.lanes.gitTree).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not repaint the previous lane after its git action resolves post-switch", async () => {
|
|
const user = userEvent.setup();
|
|
let resolveAction: (v: { action: string; ok: true; output: string }) => void = () => {};
|
|
vi.mocked(api.lanes.gitAction).mockReturnValue(
|
|
new Promise((r) => (resolveAction = r)) as ReturnType<typeof api.lanes.gitAction>
|
|
);
|
|
vi.mocked(api.lanes.changes).mockImplementation(async (id) =>
|
|
id === 7
|
|
? changes()
|
|
: changes({
|
|
files: [
|
|
{
|
|
path: "lane8.txt",
|
|
status: "??",
|
|
untracked: true,
|
|
added: 1,
|
|
deleted: 0,
|
|
binary: false,
|
|
},
|
|
],
|
|
})
|
|
);
|
|
const { rerender } = render(<LaneChangesPanel laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
const [stage] = screen.getAllByRole("button", { name: "Stage this file" });
|
|
await user.click(stage!);
|
|
|
|
rerender(<LaneChangesPanel laneId={8} />);
|
|
expect(await screen.findByText("lane8.txt")).toBeInTheDocument();
|
|
const lane7Reads = vi.mocked(api.lanes.changes).mock.calls.filter(([id]) => id === 7).length;
|
|
|
|
resolveAction({ action: "stage", ok: true, output: "" });
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
expect(vi.mocked(api.lanes.changes).mock.calls.filter(([id]) => id === 7)).toHaveLength(
|
|
lane7Reads
|
|
);
|
|
expect(screen.getByText("lane8.txt")).toBeInTheDocument();
|
|
expect(screen.queryByTitle("server/lib/git-ops.js")).toBeNull();
|
|
});
|
|
|
|
it("does not paint the previous lane's tree when an expand-all read outlives a lane switch", async () => {
|
|
const user = userEvent.setup();
|
|
let resolveSrc: () => void = () => {};
|
|
vi.mocked(api.lanes.gitTreeAll).mockImplementation(
|
|
() =>
|
|
new Promise(
|
|
(r) =>
|
|
(resolveSrc = () =>
|
|
r({
|
|
available: true,
|
|
truncated: false,
|
|
levels: {
|
|
"": [{ name: "src", path: "src", dir: true }],
|
|
src: [{ name: "main.ts", path: "src/main.ts", dir: false }],
|
|
},
|
|
}))
|
|
)
|
|
);
|
|
vi.mocked(api.lanes.gitTree).mockImplementation((id) => {
|
|
return Promise.resolve({
|
|
available: true,
|
|
path: "",
|
|
entries: [
|
|
id === 7
|
|
? { name: "src", path: "src", dir: true }
|
|
: { name: "lib8", path: "lib8", dir: false },
|
|
],
|
|
truncated: false,
|
|
});
|
|
});
|
|
const { rerender } = render(<Tabbed laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
await user.click(screen.getByRole("button", { name: "Files" }));
|
|
await screen.findByText("src");
|
|
await user.click(screen.getByRole("button", { name: "Expand all folders" }));
|
|
|
|
rerender(<Tabbed laneId={8} />);
|
|
await user.click(await screen.findByRole("button", { name: "Files" }));
|
|
expect(await screen.findByText("lib8")).toBeInTheDocument();
|
|
|
|
resolveSrc();
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
expect(screen.getByText("lib8")).toBeInTheDocument();
|
|
expect(screen.queryByText("src")).toBeNull();
|
|
expect(screen.queryByText("main.ts")).toBeNull();
|
|
expect(screen.getByRole("button", { name: "Expand all folders" })).toBeEnabled();
|
|
});
|
|
});
|
|
|
|
describe("a merge in progress (issue #45)", () => {
|
|
const merging = (conflicts: number) => ({
|
|
available: true as const,
|
|
branch: "main",
|
|
head: "abc1234",
|
|
subject: "main",
|
|
dirty: 1,
|
|
untracked: 0,
|
|
files: [],
|
|
hasRemote: false,
|
|
upstream: null,
|
|
ahead: null,
|
|
behind: null,
|
|
merging: true,
|
|
conflicts,
|
|
});
|
|
const conflicted = changes({
|
|
files: [
|
|
{
|
|
path: "a.txt",
|
|
status: "UU",
|
|
untracked: false,
|
|
staged: true,
|
|
unstaged: true,
|
|
conflicted: true,
|
|
added: 3,
|
|
deleted: 0,
|
|
binary: false,
|
|
},
|
|
{
|
|
path: "b.txt",
|
|
status: "A ",
|
|
untracked: false,
|
|
staged: true,
|
|
added: 1,
|
|
deleted: 0,
|
|
binary: false,
|
|
},
|
|
],
|
|
});
|
|
|
|
it("lists conflicts on their own, resolves one side, and blocks the commit until none are left", async () => {
|
|
vi.mocked(api.lanes.git).mockResolvedValue(merging(1));
|
|
vi.mocked(api.lanes.changes).mockResolvedValue(conflicted);
|
|
const user = userEvent.setup();
|
|
render(<LaneChangesPanel laneId={3} />);
|
|
|
|
const group = await screen.findByTestId("lane-changes-conflicts");
|
|
expect(within(group).getByText("a.txt")).toBeInTheDocument();
|
|
// Not offered as an ordinary stage: that would mark it resolved blind.
|
|
expect(screen.getAllByText("a.txt")).toHaveLength(1);
|
|
expect(screen.getByRole("button", { name: "Complete merge" })).toBeDisabled();
|
|
|
|
await user.click(within(group).getByRole("button", { name: "Take theirs" }));
|
|
await waitFor(() =>
|
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "resolve", {
|
|
path: "a.txt",
|
|
side: "theirs",
|
|
})
|
|
);
|
|
});
|
|
|
|
it("opens a conflicted file in the conflict view, not the diff", async () => {
|
|
vi.mocked(api.lanes.git).mockResolvedValue(merging(1));
|
|
vi.mocked(api.lanes.changes).mockResolvedValue(conflicted);
|
|
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
|
available: true,
|
|
path: "a.txt",
|
|
rev: "work",
|
|
lines: ["<<<<<<< HEAD", "mine", "=======", "theirs", ">>>>>>> feat"],
|
|
truncated: false,
|
|
binary: false,
|
|
sha: "s1",
|
|
} as never);
|
|
const user = userEvent.setup();
|
|
render(<LaneChangesPanel laneId={3} />);
|
|
await user.click(
|
|
within(await screen.findByTestId("lane-changes-conflicts")).getByText("a.txt")
|
|
);
|
|
expect(await screen.findByTestId("lane-conflict-view")).toHaveTextContent("Theirs (feat)");
|
|
expect(api.lanes.gitFile).toHaveBeenCalledWith(3, "a.txt", "work");
|
|
});
|
|
|
|
it("completes the merge once nothing is conflicted, and aborts only on a second click", async () => {
|
|
vi.mocked(api.lanes.git).mockResolvedValue(merging(0));
|
|
const user = userEvent.setup();
|
|
render(<LaneChangesPanel laneId={3} />);
|
|
|
|
await user.click(await screen.findByRole("button", { name: "Complete merge" }));
|
|
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge-commit", {}));
|
|
|
|
await user.click(screen.getByRole("button", { name: "Abort merge" }));
|
|
expect(api.lanes.gitAction).not.toHaveBeenCalledWith(3, "merge-abort", expect.anything());
|
|
await user.click(screen.getByRole("button", { name: "Click again to abort the merge" }));
|
|
await waitFor(() =>
|
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge-abort", { confirm: true })
|
|
);
|
|
});
|
|
});
|