feat(lanes): move the lane's git section into the Changes panel

- The branch line, the uncommitted-files hover panel and the git action
  row (fetch/pull/push/branch) move from the lane card to the top of the
  Changes panel (new LaneGitHeader.tsx); the card has no git row now.
- Header and panel share one git-facts read: stage/unstage/discard/commit
  and Refresh re-read it, and a git bar action re-reads the diff, the
  history and the file tree.
- The git bar no longer commits: the panel's staged-only box is the one
  commit path. Orphaned gitBar.commit*/cancel strings removed (en, vi).
- "New lane here" is wired from Workspace to the panel.
This commit is contained in:
2026-09-24 11:44:31 +07:00
parent 7bc4594b94
commit faa56f5c75
13 changed files with 612 additions and 621 deletions
@@ -0,0 +1,160 @@
/**
* @file LaneGitHeader.test.tsx
* @description Pins the Changes panel's working-copy line: the live branch,
* head, subject and uncommitted counts, the per-file hover panel behind the
* counts, that a lane switch drops the previous lane's facts at once, and that
* an unreadable repo renders nothing rather than an error.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { LaneGitHeader, useLaneGitFacts } from "../LaneGitHeader";
import { api } from "../../../lib/api";
vi.mock("../../../lib/api", () => ({
api: { lanes: { git: vi.fn(), gitAction: vi.fn(), gitBranches: vi.fn() } },
}));
beforeEach(() => {
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
});
/** The panel's own wiring: one facts read feeding the header. */
function Harness({ laneId }: { laneId: number }) {
const [git, refresh] = useLaneGitFacts(laneId);
return <LaneGitHeader laneId={laneId} git={git} onChanged={refresh} />;
}
describe("LaneGitHeader", () => {
const facts = {
available: true as const,
branch: "feat/rename-metric",
head: "9b3e74a",
subject: "free-text rule mode in the form",
dirty: 2,
untracked: 1,
files: [
{ path: "server/lib/alerts.js", status: " M", added: 12, deleted: 3 },
{ path: "client/src/lib/api.ts", status: "M ", added: 1, deleted: 0 },
{ path: "scratch.txt", status: "??", added: null, deleted: null },
],
// Tracking facts ride along on the same endpoint; a branch that has never
// been pushed reports nulls rather than zeros.
hasRemote: true,
upstream: null,
ahead: null,
behind: null,
};
it("renders the live branch, head, subject and uncommitted counts", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
render(<Harness laneId={1} />);
const block = await screen.findByTestId("lane-git");
expect(block.textContent).toContain("feat/rename-metric");
expect(block.textContent).toContain("9b3e74a");
expect(block.textContent).toContain("free-text rule mode in the form");
expect(block.textContent).toContain("2");
});
it("drops the previous lane's facts the moment the lane changes", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
const { rerender } = render(<Harness laneId={1} />);
await screen.findByText(/feat\/rename-metric/);
// The next lane's read never lands: nothing of lane 1's git may linger.
vi.mocked(api.lanes.git).mockReturnValueOnce(new Promise(() => {}));
rerender(<Harness laneId={2} />);
expect(screen.queryByText(/feat\/rename-metric/)).toBeNull();
});
it("names the files behind the counts on hover, with each one's line delta", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
render(<Harness laneId={1} />);
const counts = await screen.findByTestId("lane-git-counts");
// Hidden until asked for: the row is a one-line summary until you hover.
expect(screen.queryByTestId("lane-git-files")).toBeNull();
fireEvent.mouseEnter(counts);
const panel = screen.getByTestId("lane-git-files");
expect(panel.textContent).toContain("alerts.js");
expect(panel.textContent).toContain("+12");
expect(panel.textContent).toContain("−3");
// Totals across the listed files.
expect(panel.textContent).toContain("+13");
// An untracked file has no delta to report and says so rather than "+0".
expect(panel.textContent).toContain("new");
expect(panel.textContent).toContain("3 files changed");
// Closing is delayed so the pointer can travel into the panel and scroll
// it — the panel is portalled to the body, away from its trigger.
fireEvent.mouseLeave(counts);
expect(screen.queryByTestId("lane-git-files")).not.toBeNull();
await waitFor(() => expect(screen.queryByTestId("lane-git-files")).toBeNull());
});
it("stays open while the pointer is inside the panel", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
render(<Harness laneId={1} />);
const counts = await screen.findByTestId("lane-git-counts");
fireEvent.mouseEnter(counts);
fireEvent.mouseLeave(counts);
// The pointer landed on the panel before the pending close fired.
fireEvent.mouseEnter(screen.getByTestId("lane-git-files"));
await new Promise((r) => setTimeout(r, 200));
expect(screen.queryByTestId("lane-git-files")).not.toBeNull();
fireEvent.mouseLeave(screen.getByTestId("lane-git-files"));
await waitFor(() => expect(screen.queryByTestId("lane-git-files")).toBeNull());
});
it("says how many files the server-side cap left out", async () => {
// 40 uncommitted files, 3 listed: the counts are exact, the listing is not.
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 39, untracked: 1 });
render(<Harness laneId={1} />);
fireEvent.mouseEnter(await screen.findByTestId("lane-git-counts"));
expect(screen.getByTestId("lane-git-files").textContent).toContain("37 more");
});
it("omits the uncommitted line when the tree is clean", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 0, untracked: 0 });
render(<Harness laneId={1} />);
const block = await screen.findByTestId("lane-git");
expect(block.textContent).not.toContain("modified");
});
it("renders nothing and no error when git is unavailable", async () => {
vi.mocked(api.lanes.git).mockResolvedValueOnce({ available: false });
render(<Harness laneId={2} />);
await waitFor(() => expect(api.lanes.git).toHaveBeenCalledWith(2));
expect(screen.queryByTestId("lane-git-header")).toBeNull();
});
it("swallows a rejected request instead of surfacing an error", async () => {
vi.mocked(api.lanes.git).mockRejectedValueOnce(new Error("network down"));
render(<Harness laneId={3} />);
await waitFor(() => expect(api.lanes.git).toHaveBeenCalledWith(3));
expect(screen.queryByTestId("lane-git")).toBeNull();
expect(screen.queryByText(/network down/)).toBeNull();
});
it("stops polling once the panel unmounts", async () => {
vi.useFakeTimers();
try {
vi.mocked(api.lanes.git).mockResolvedValue({ available: false });
const { unmount } = render(<Harness laneId={1} />);
expect(api.lanes.git).toHaveBeenCalledTimes(1);
unmount();
vi.advanceTimersByTime(120_000);
expect(api.lanes.git).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
});