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
@@ -1,8 +1,8 @@
/**
* @file LaneGitBar.test.tsx
* @description Pins the lane git action row: the ahead/behind readout, which
* buttons a repo with no remote may press, that commit sends the typed
* message, that a dirty tree blocks the branch switch client-side (the server
* buttons a repo with no remote may press, that it offers no commit (the
* Changes panel's staged-only box owns that), that a dirty tree blocks the branch switch client-side (the server
* refuses it too), and that a failed action shows git's own words rather than
* a generic sentence.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -70,28 +70,13 @@ describe("LaneGitBar", () => {
);
expect(screen.getByRole("button", { name: /Fetch/ })).toBeDisabled();
expect(screen.getByRole("button", { name: /Push/ })).toBeDisabled();
// Local actions stay available — a repo with no remote still commits.
expect(screen.getByRole("button", { name: /^Commit$/ })).toBeEnabled();
// Local actions stay available — a repo with no remote still branches.
expect(screen.getByRole("button", { name: /Branch/ })).toBeEnabled();
});
it("commits with the typed message and reports the change upward", async () => {
const user = userEvent.setup();
const onChanged = vi.fn();
render(<LaneGitBar laneId={3} git={facts()} onChanged={onChanged} />);
await user.click(screen.getByRole("button", { name: /^Commit$/ }));
await user.type(screen.getByLabelText("Commit message"), "fix: the thing");
await user.click(screen.getByRole("button", { name: /Commit 3 files/ }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "commit", { message: "fix: the thing" })
);
expect(onChanged).toHaveBeenCalled();
});
it("offers no commit when nothing is uncommitted", () => {
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
expect(screen.getByRole("button", { name: /^Commit$/ })).toBeDisabled();
it("offers no commit at all — that is the Changes panel's staged-only box", () => {
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
expect(screen.queryByRole("button", { name: /Commit/ })).toBeNull();
});
it("loads the branch list on demand and refuses to switch while dirty", async () => {
@@ -183,22 +168,6 @@ describe("LaneGitBar", () => {
expect(newLane()).toBeEnabled();
});
it("commits only the index when asked", async () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /^Commit$/ }));
await user.type(screen.getByLabelText(/Commit message/), "fix: staged only");
await user.click(screen.getByRole("button", { name: "Commit staged" }));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "commit", {
message: "fix: staged only",
only: "staged",
})
);
});
it("prints git's own rejection text when an action fails", async () => {
const user = userEvent.setup();
vi.mocked(api.lanes.gitAction).mockRejectedValue(
@@ -210,40 +179,6 @@ describe("LaneGitBar", () => {
expect(await screen.findByTestId("lane-git-bar-error")).toHaveTextContent("non-fast-forward");
});
it("keeps a half-typed commit message through a fetch", async () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /^Commit$/ }));
await user.type(screen.getByLabelText("Commit message"), "fix: half");
await user.click(screen.getByRole("button", { name: /Fetch/ }));
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "fetch", {}));
expect(screen.getByLabelText("Commit message")).toHaveValue("fix: half");
});
it("clears and closes the commit box once the commit lands", async () => {
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /^Commit$/ }));
await user.type(screen.getByLabelText("Commit message"), "fix: done");
await user.click(screen.getByRole("button", { name: /Commit 3 files/ }));
await waitFor(() => expect(screen.queryByLabelText("Commit message")).toBeNull());
});
it("drops the commit box and message on a lane switch, so Enter cannot commit lane B under lane A's message", async () => {
const user = userEvent.setup();
const { rerender } = render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /^Commit$/ }));
await user.type(screen.getByLabelText("Commit message"), "fix: lane three");
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} />);
expect(screen.queryByLabelText("Commit message")).toBeNull();
await user.click(screen.getByRole("button", { name: /^Commit$/ }));
expect(screen.getByLabelText("Commit message")).toHaveValue("");
await user.keyboard("{Enter}");
expect(api.lanes.gitAction).not.toHaveBeenCalled();
});
it("re-reads the branch list for the new lane instead of showing the old one's", async () => {
const user = userEvent.setup();
const { rerender } = render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
@@ -257,14 +192,13 @@ describe("LaneGitBar", () => {
expect(await screen.findByLabelText("Switch branch…")).toHaveValue("");
});
it("sends one commit for a double Enter", async () => {
it("sends one branch-create for a double Enter", async () => {
vi.mocked(api.lanes.gitAction).mockReturnValue(new Promise(() => {}));
const user = userEvent.setup();
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
await user.click(screen.getByRole("button", { name: /^Commit$/ }));
await user.type(screen.getByLabelText("Commit message"), "fix: once{Enter}{Enter}");
await user.click(screen.getByRole("button", { name: /Branch/ }));
await user.type(await screen.findByLabelText(/New branch/), "feat/once{Enter}{Enter}");
expect(api.lanes.gitAction).toHaveBeenCalledTimes(1);
expect(screen.getByRole("button", { name: "Commit staged" })).toBeDisabled();
});
it("forgets which branches the previous lane's worktrees held once the lane changes", async () => {