feat(lanes): full-width Changes, Stage all, one-read expand-all

- The Changes panel's width control gains a full-width step (narrow →
  50/50 → 70/30 → full → closed); the console beside it is hidden with
  CSS, never unmounted.
- "Expand all folders" is one GET /api/lanes/:id/git/tree-all (one
  `git ls-files`, every level shaped like /git/tree) instead of one
  request and one git process per folder.
- Overview: a Stage all button and the staged-only commit box sit above
  the file list and stay pinned while it scrolls; the Files finder is
  pinned too.
This commit is contained in:
2026-09-24 14:21:44 +07:00
parent 428519335d
commit 0737786faf
13 changed files with 231 additions and 107 deletions
@@ -28,6 +28,7 @@ vi.mock("../../../lib/api", () => ({
gitFile: vi.fn(),
gitLs: vi.fn(),
gitTree: vi.fn(),
gitTreeAll: vi.fn(),
},
},
}));
@@ -283,6 +284,22 @@ describe("LaneChangesPanel", () => {
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("needs two clicks to discard, and only the second one reaches the server", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
@@ -417,8 +434,16 @@ describe("LaneChangesPanel", () => {
expect(screen.getByText("src")).toBeInTheDocument();
});
it("expands every folder at once, fetching whatever level wasn't cached yet", async () => {
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 {
@@ -788,37 +813,29 @@ describe("LaneChangesPanel", () => {
expect(screen.getByText("server/lib/git-ops.js")).toBe(before);
});
it("expands all folders with at most four tree reads in flight", async () => {
it("expands all folders with ONE request, not one per folder", async () => {
const user = userEvent.setup();
let inFlight = 0;
let peak = 0;
vi.mocked(api.lanes.gitTree).mockClear();
vi.mocked(api.lanes.gitTree).mockImplementation(async (_id, path = "") => {
inFlight += 1;
peak = Math.max(peak, inFlight);
await new Promise((r) => setTimeout(r, 5));
inFlight -= 1;
const depth = path ? path.split("/").length : 0;
const entries =
depth >= 2
? [{ name: "leaf.ts", path: `${path}/leaf.ts`, dir: false }]
: Array.from({ length: 5 }, (_, i) => ({
name: `d${i}`,
path: path ? `${path}/d${i}` : `d${i}`,
dir: true,
}));
return { available: true, path, entries, truncated: false };
});
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("d0");
await screen.findByText("src");
await user.click(screen.getByRole("button", { name: "Expand all folders" }));
await waitFor(() => expect(screen.getAllByText("leaf.ts")).toHaveLength(25));
// Root + 5 + 25 folders, each fetched exactly once.
expect(api.lanes.gitTree).toHaveBeenCalledTimes(31);
expect(peak).toBeLessThanOrEqual(4);
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 () => {
@@ -861,21 +878,25 @@ describe("LaneChangesPanel", () => {
expect(screen.queryByText("server/lib/git-ops.js")).toBeNull();
});
it("does not paint the previous lane's tree when an expand-all walk outlives a lane switch", async () => {
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.gitTree).mockImplementation((id, path) => {
if (id === 7 && path === "src")
return new Promise(
vi.mocked(api.lanes.gitTreeAll).mockImplementation(
() =>
new Promise(
(r) =>
(resolveSrc = () =>
r({
available: true,
path: "src",
entries: [{ name: "main.ts", path: "src/main.ts", dir: false }],
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: "",