952e8584f0
- git (#50): stage-hunk/discard-hunk take an optional `block` — one run of consecutive +/- lines inside an @@ hunk. `onlyBlock` (server/lib/git-diff.js) cuts git's fresh hunk down to it, folding the other blocks into the side being checked; LaneDiffView hangs one review bar per block (unified + split) and its arrows walk blocks. - git exclude: entries AgentHub writes for a lane go to the COMMON git dir with the lane's path prefix (server/lib/git-exclude.js, used by router-apply and lane-mcp) — a linked worktree's own info/exclude is never read by git, and a subdir lane has no .git. MCP sync no longer fails with ENOTDIR on a worktree lane or creates a stray .git in a non-repo lane. - codex lanes carry no pipeline: codex-ingest mirrors only idle/running, codex-nudge is removed, agents install writes nothing for codex, the Workspace hides stage/pipeline/feature pickers/PipelineMap for them, and every server start clears leftover codex stage data and the old AGENTS.md stage block. - Changes panel: a whole file opens for reading and enters the editor on a click on its text (caret at the clicked line), not automatically. - macOS: LaunchAgent start-at-login (scripts/launchd-service.js, picked by scripts/service-manager.js). - Adds the AgentHub introduction slides and their image-embedding script.
1425 lines
54 KiB
TypeScript
1425 lines
54 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 { api } from "../../../lib/api";
|
|
import type { LaneChanges } from "../../../lib/types";
|
|
|
|
vi.mock("../../../lib/api", () => ({
|
|
api: {
|
|
lanes: {
|
|
git: vi.fn(),
|
|
gitBranches: vi.fn(),
|
|
changes: vi.fn(),
|
|
gitAction: vi.fn(),
|
|
gitLog: vi.fn(),
|
|
gitFile: vi.fn(),
|
|
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,
|
|
};
|
|
}
|
|
|
|
/** The overview row's discard button for one path. */
|
|
function discardOf(path: string) {
|
|
return within(screen.getByText(path).closest("li")!).getByRole("button", {
|
|
name: "Discard this file's changes",
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
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"],
|
|
},
|
|
],
|
|
});
|
|
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;"],
|
|
});
|
|
});
|
|
|
|
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.getByText("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.getByText("server/lib/git-ops.js"));
|
|
|
|
await waitFor(() =>
|
|
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
|
|
range: "work",
|
|
path: "server/lib/git-ops.js",
|
|
context: 3,
|
|
})
|
|
);
|
|
// 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 lane'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("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("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).getByText("docs/API.md")).toBeInTheDocument();
|
|
expect(within(staged).queryByText("notes.txt")).toBeNull();
|
|
expect(within(unstaged).getByText("server/lib/git-ops.js")).toBeInTheDocument();
|
|
expect(within(unstaged).getByText("notes.txt")).toBeInTheDocument();
|
|
expect(within(unstaged).queryByText("docs/API.md")).toBeNull();
|
|
expect(screen.getByText("Staged Changes")).toBeInTheDocument();
|
|
});
|
|
|
|
it("at full width shows the tree as a sidebar and opens a file for reading, editing on a click", 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 only shows it: no editor until the text itself is clicked.
|
|
const view = await screen.findByTestId("lane-file-view");
|
|
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
|
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
|
|
await user.click(view);
|
|
const editor = await screen.findByTestId("lane-file-editor");
|
|
expect(editor).toHaveValue("const whole = 1;");
|
|
expect(editor).toHaveFocus();
|
|
expect(screen.getByTestId("lane-file-save")).toBeDisabled();
|
|
await user.type(editor, "x");
|
|
expect(screen.getByTestId("lane-file-save")).toBeEnabled();
|
|
// Cancel drops the edit and goes back to reading.
|
|
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
|
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;");
|
|
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
|
});
|
|
|
|
it("at full width opens a changed file from the overview for reading, not editing", 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).getByText("server/lib/git-ops.js"));
|
|
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("a");
|
|
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
|
});
|
|
|
|
it("does not enter the editor when a click only ends a text selection", async () => {
|
|
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
|
available: true,
|
|
path: "server/lib/git-ops.js",
|
|
rev: "work",
|
|
binary: false,
|
|
truncated: false,
|
|
lines: ["copy me"],
|
|
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).getByText("server/lib/git-ops.js"));
|
|
const view = await screen.findByTestId("lane-file-view");
|
|
const spy = vi.spyOn(window, "getSelection").mockReturnValue({
|
|
toString: () => "copy",
|
|
} as unknown as Selection);
|
|
await user.click(view);
|
|
spy.mockRestore();
|
|
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
|
});
|
|
|
|
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("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.queryByText("server/lib/git-ops.js")).toBeNull());
|
|
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
|
await screen.findByText("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.queryByText("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" }));
|
|
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(<LaneChangesPanel 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(<LaneChangesPanel 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(<LaneChangesPanel 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(<LaneChangesPanel 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(<LaneChangesPanel 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.click(await screen.findByTestId("lane-file-view"));
|
|
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)
|
|
);
|
|
// Back to reading, showing what was saved.
|
|
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 2;");
|
|
expect(screen.queryByTestId("lane-file-editor")).toBeNull();
|
|
});
|
|
|
|
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(<LaneChangesPanel 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(<LaneChangesPanel 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.click(await screen.findByTestId("lane-file-view"));
|
|
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.getByText("server/lib/git-ops.js"));
|
|
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.getByText("server/lib/git-ops.js"));
|
|
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.getByText("server/lib/git-ops.js"));
|
|
await screen.findByText(/server\/lib\/git-ops\.js/);
|
|
|
|
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 first in the mode row", async () => {
|
|
render(<LaneChangesPanel laneId={7} />);
|
|
await screen.findByTestId("lane-changes-overview");
|
|
const modes = ["Files", "Overview", "Review", "History"].map((name) =>
|
|
screen.getByRole("button", { name })
|
|
);
|
|
// Source order is the reading order here, so comparing positions pins it.
|
|
for (let i = 1; i < modes.length; i += 1) {
|
|
expect(
|
|
modes[i - 1]!.compareDocumentPosition(modes[i]!) & Node.DOCUMENT_POSITION_FOLLOWING
|
|
).toBeTruthy();
|
|
}
|
|
});
|
|
|
|
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(<LaneChangesPanel 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(<LaneChangesPanel 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(<LaneChangesPanel 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(<LaneChangesPanel 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.getByText("server/lib/git-ops.js");
|
|
await user.type(screen.getByLabelText("Commit message"), "x");
|
|
expect(screen.getByText("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(<LaneChangesPanel 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.queryByText("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(<LaneChangesPanel 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(<LaneChangesPanel 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 })
|
|
);
|
|
});
|
|
});
|