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:
@@ -8,7 +8,7 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, act, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { MemoryRouter, useSearchParams } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
// Track all API calls made by recording the method and endpoint
|
||||
@@ -284,9 +284,27 @@ async function settle() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Stands in for the sidebar's project tree: picking a workspace sets `?lane=`. */
|
||||
function Picker() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
return (
|
||||
<>
|
||||
<span data-testid="lane-param">{params.get("lane")}</span>
|
||||
{[1, 2].map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
data-testid={`pick-${id}`}
|
||||
onClick={() => setParams({ lane: String(id) })}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function renderWorkspace() {
|
||||
const utils = render(
|
||||
<MemoryRouter initialEntries={["/run"]}>
|
||||
<Picker />
|
||||
<Workspace />
|
||||
</MemoryRouter>
|
||||
);
|
||||
@@ -329,11 +347,12 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("Workspace page — lane integration", () => {
|
||||
it("lane strip lists the lanes the API returned", async () => {
|
||||
it("has no lane strip: it opens the first workspace and names it in the URL", async () => {
|
||||
await renderWorkspace();
|
||||
|
||||
expect(screen.getByTestId("lane-tile-1").textContent).toContain("Lane A");
|
||||
expect(screen.getByTestId("lane-tile-2").textContent).toContain("Lane B");
|
||||
expect(screen.queryByTestId("lane-strip")).toBeNull();
|
||||
expect(screen.getByTestId("lane-card-1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("lane-param").textContent).toBe("1");
|
||||
});
|
||||
|
||||
it("selecting a lane switches the pipeline and run id", async () => {
|
||||
@@ -345,20 +364,16 @@ describe("Workspace page — lane integration", () => {
|
||||
await renderWorkspace();
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Selection is announced on the tile itself, not inferred from styling.
|
||||
const laneATile = screen.getByTestId("lane-tile-1");
|
||||
const laneBTile = screen.getByTestId("lane-tile-2");
|
||||
expect(laneATile.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(laneBTile.getAttribute("aria-pressed")).toBe("false");
|
||||
expect(screen.getByTestId("lane-card-1")).toBeInTheDocument();
|
||||
|
||||
await user.click(laneBTile);
|
||||
// The sidebar names the workspace in the URL; the page follows it.
|
||||
await user.click(screen.getByTestId("pick-2"));
|
||||
await settle();
|
||||
|
||||
expect(laneBTile.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(laneATile.getAttribute("aria-pressed")).toBe("false");
|
||||
// And the detail panel follows: only the selected lane gets a full card.
|
||||
// Only the selected lane gets a full card.
|
||||
expect(screen.getByTestId("lane-card-2")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-card-1")).toBeNull();
|
||||
expect(screen.queryByTestId("lane-card-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("starting a run requests /api/lanes/<id>/start and not /api/run/start", async () => {
|
||||
@@ -490,27 +505,17 @@ describe("Workspace page — lane integration", () => {
|
||||
});
|
||||
|
||||
describe("Workspace layout", () => {
|
||||
it("opens the add-lane modal from the strip's first tile", async () => {
|
||||
it("opens the add-workspace modal from the empty banner when there is none", async () => {
|
||||
const user = userEvent.setup();
|
||||
lanesToReturn = [];
|
||||
await renderWorkspace();
|
||||
|
||||
// The tile leads the strip, so it is reachable without scrolling past lanes.
|
||||
const strip = screen.getByTestId("lane-strip");
|
||||
expect(strip.firstElementChild).toBe(screen.getByTestId("lane-tile-add"));
|
||||
|
||||
await user.click(screen.getByTestId("lane-tile-add"));
|
||||
await user.click(screen.getByTestId("lane-empty"));
|
||||
expect(
|
||||
await screen.findByText("Create a workspace from a working directory")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("gives every lane a tile in the carousel", async () => {
|
||||
await renderWorkspace();
|
||||
expect(screen.getByTestId("lane-strip")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("lane-tile-1")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("lane-tile-2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the full card only for the selected lane, in the detail panel", async () => {
|
||||
await renderWorkspace();
|
||||
// Lane 1 is selected by default. Its full card — controls, git facts — is
|
||||
@@ -555,7 +560,6 @@ describe("Workspace — the console is its own section", () => {
|
||||
// The console is a window onto a process, not a property of a card. Nesting
|
||||
// it in one made the owning card span the row and left a hole when closed.
|
||||
expect(screen.getByTestId("lane-card-1").contains(body)).toBe(false);
|
||||
expect(screen.getByTestId("lane-strip").contains(body)).toBe(false);
|
||||
});
|
||||
|
||||
it("has no header line of its own - the pipeline panel already names the lane", async () => {
|
||||
|
||||
Reference in New Issue
Block a user