feat(ui): sidebar project tree, status bar and a tabbed workbench for workspaces

The Workspace page's lane strip moves into the sidebar as a project tree:
workspaces grouped by git repository (new GET /api/projects, cached in
memory, POST /api/projects/refresh), untracked worktrees with Adopt, inline
rename, drag/Alt+arrow ordering and a right-click row menu. The selected
workspace is the URL's ?lane=, one shared lane list feeds the tree, the new
bottom status bar and the page. The Changes panel gains an activity bar
(Files / Changes / Sessions, with resume), and split panes get a header.
This commit is contained in:
2026-10-01 16:33:25 +07:00
parent 5b3884b309
commit ba2c4e1914
39 changed files with 2431 additions and 1439 deletions
@@ -13,6 +13,7 @@
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 { useState } from "react";
import { LaneChangesPanel } from "../LaneChangesPanel";
import { api } from "../../../lib/api";
import type { LaneChanges } from "../../../lib/types";
@@ -130,6 +131,23 @@ beforeEach(() => {
});
});
/** The Workspace right panel's Files/Changes activity-bar buttons, around the
* panel — the tab is the Workspace's state, passed in as `tab`. */
function Tabbed({ laneId }: { laneId: number }) {
const [tab, setTab] = useState<"files" | "changes">("changes");
return (
<>
<button type="button" onClick={() => setTab("files")}>
Files
</button>
<button type="button" onClick={() => setTab("changes")}>
Changes
</button>
<LaneChangesPanel laneId={laneId} tab={tab} />
</>
);
}
describe("LaneChangesPanel", () => {
it("opens on the overview: a summary request, files listed, no diff bodies", async () => {
render(<LaneChangesPanel laneId={7} />);
@@ -553,7 +571,7 @@ describe("LaneChangesPanel", () => {
truncated: false,
}
);
render(<LaneChangesPanel laneId={7} />);
render(<Tabbed laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
@@ -589,7 +607,7 @@ describe("LaneChangesPanel", () => {
truncated: false,
};
});
render(<LaneChangesPanel laneId={7} />);
render(<Tabbed laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
@@ -617,7 +635,7 @@ describe("LaneChangesPanel", () => {
truncated: false,
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
render(<Tabbed laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
@@ -633,7 +651,7 @@ describe("LaneChangesPanel", () => {
it("quick-opens a tracked file as its whole content, not a diff", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
render(<Tabbed laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
@@ -664,7 +682,7 @@ describe("LaneChangesPanel", () => {
sha: "c".repeat(40),
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
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");
@@ -697,7 +715,7 @@ describe("LaneChangesPanel", () => {
lines: ["partial"],
});
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
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");
@@ -723,7 +741,7 @@ describe("LaneChangesPanel", () => {
Object.assign(new Error("the file changed on disk since it was opened"), { code: "ESTALE" })
);
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
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");
@@ -871,17 +889,12 @@ describe("LaneChangesPanel", () => {
expect(screen.queryByTestId("lane-changes-commit")).toBeNull();
});
it("puts Files first in the mode row", async () => {
it("leaves Files out of the mode row: it is the right panel's own tab", 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();
expect(screen.queryByRole("button", { name: "Files" })).toBeNull();
for (const name of ["Overview", "Review", "History"]) {
expect(screen.getByRole("button", { name })).toBeInTheDocument();
}
});
@@ -1142,13 +1155,13 @@ describe("LaneChangesPanel", () => {
answer = r as typeof answer;
}) as ReturnType<typeof api.lanes.gitLs>
);
const { rerender } = render(<LaneChangesPanel laneId={7} />);
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(<LaneChangesPanel laneId={8} />);
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" }));
@@ -1170,7 +1183,7 @@ describe("LaneChangesPanel", () => {
truncated: false,
total: 1,
});
render(<LaneChangesPanel laneId={7} />);
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…");
@@ -1208,7 +1221,7 @@ describe("LaneChangesPanel", () => {
it("opens an unchanged tracked file from Files with no error banner", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
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"), {
@@ -1250,7 +1263,7 @@ describe("LaneChangesPanel", () => {
src: [{ name: "main.ts", path: "src/main.ts", dir: false }],
},
});
render(<LaneChangesPanel laneId={7} />);
render(<Tabbed laneId={7} />);
await screen.findByTestId("lane-changes-overview");
await user.click(screen.getByRole("button", { name: "Files" }));
await screen.findByText("src");
@@ -1332,13 +1345,13 @@ describe("LaneChangesPanel", () => {
truncated: false,
});
});
const { rerender } = render(<LaneChangesPanel laneId={7} />);
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(<LaneChangesPanel laneId={8} />);
rerender(<Tabbed laneId={8} />);
await user.click(await screen.findByRole("button", { name: "Files" }));
expect(await screen.findByText("lib8")).toBeInTheDocument();