* feat(app): let the file panel close its tree rail The file panel's tree rail was permanent on desktop — the changes panel had a folder-tree toggle in its toolbar, the file toolbar had nothing, so the rail was there whether you wanted it or not. The changes panel's private DesktopTreeToggle becomes a shared TreeRailToggle so both toolbars render the same affordance; that also drops the treeToggleStyle prop threaded through ChangesToolbarProps, which recomputed exactly what the component already built internally. The i18n keys move out of workspace.git.diff into workspace.tree, since a shared component reaching into the git namespace would rot. The two rails keep independent flags — the file rail in panel-store, the changes rail in useChangesPreferences — so closing one leaves the other alone. FileTreeToggle wires itself to the store rather than taking a callback: the bar renders several components below FilePanel, where the rail actually lives. Both rails also get narrower (320 -> 260 default, 200 -> 180 minimum). The persist version bump resets treeRailWidth on purpose: nearly every existing install carries the old 320 default, so without the reset the narrower rail would only ever reach new users. Anyone who dragged their rail to a deliberate custom width loses it once. Note that MIN_CONTENT_WIDTH still clamps the rail to `containerWidth - 240`, so in a narrow pane the new default never applies — that floor, not the default, is what sets the width there. * feat(app): make the side panel an empty workspace surface The fixed, hideable side panel keeps supporting context beside an agent without seeding content. Pane-local choices stay local, while implicit supporting opens follow a persisted routing preference. * test(app): exercise side panel policy through its capability boundary The browser rail journey already covers the real tree-toggle behavior. Side-panel policy tests now pass their platform capability through the public boundary instead of replacing a production module. * test(app): expect an empty side panel after upgrade * test(app): expect an empty first split pane * test(app): account for the empty side panel in split retention * feat(app): make workspace panel tabs independent Give explicit New actions fresh tab instances, keep panel state opaque to tab management, preserve File navigation context, and stabilize uncached File transitions. * fix(app): preserve file conflicts during preview refresh Keep the current preview mounted for same-target reads and conversions so the editor retains its conflict observation state. Clear retained previews only when the file target changes.
218 lines
8.9 KiB
TypeScript
218 lines
8.9 KiB
TypeScript
import { expect, type Locator, type Page, type TestInfo } from "@playwright/test";
|
|
import { test } from "../support/fixtures";
|
|
import { gotoWorkspace } from "../support/helpers/launcher";
|
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
|
import { ensureSidePanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
|
|
|
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
|
const path = testInfo.outputPath(`${name}.png`);
|
|
await page.screenshot({ path });
|
|
await testInfo.attach(name, { path, contentType: "image/png" });
|
|
}
|
|
|
|
function sidePanel(page: Page): Locator {
|
|
return page.getByTestId("workspace-side-panel").filter({ visible: true });
|
|
}
|
|
|
|
function mainPane(page: Page): Locator {
|
|
return page.getByTestId("workspace-pane-main").filter({ visible: true });
|
|
}
|
|
|
|
function emptyState(scope: Locator): Locator {
|
|
return scope.getByTestId("workspace-pane-empty-state");
|
|
}
|
|
|
|
function sidePanelToggle(page: Page): Locator {
|
|
return page.getByTestId("workspace-explorer-toggle").first();
|
|
}
|
|
|
|
function closePaneButton(page: Page): Locator {
|
|
return page.getByRole("button", { name: "Close pane" });
|
|
}
|
|
|
|
/** Drags the pane's only tab into the left edge of its own pane, splitting it out. */
|
|
async function splitTabOutOfMainPane(page: Page, chip: Locator): Promise<void> {
|
|
const paneBox = await mainPane(page).boundingBox();
|
|
const chipBox = await chip.boundingBox();
|
|
expect(paneBox).not.toBeNull();
|
|
expect(chipBox).not.toBeNull();
|
|
if (!paneBox || !chipBox) throw new Error("Unable to measure the main pane");
|
|
|
|
await page.mouse.move(chipBox.x + chipBox.width / 2, chipBox.y + chipBox.height / 2);
|
|
await page.mouse.down();
|
|
await page.mouse.move(chipBox.x + chipBox.width / 2 + 12, chipBox.y + chipBox.height / 2 + 4);
|
|
await page.mouse.move(paneBox.x + paneBox.width * 0.06, paneBox.y + paneBox.height * 0.6, {
|
|
steps: 20,
|
|
});
|
|
await page.mouse.up();
|
|
}
|
|
|
|
test.describe("Side panel", () => {
|
|
test("reveals empty, launches a view, and hides back out of the way", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const workspace = await seedWorkspace({ repoPrefix: "side-panel-reveal-" });
|
|
|
|
try {
|
|
await gotoWorkspace(page, workspace.workspaceId);
|
|
await waitForWorkspaceTabsVisible(page);
|
|
await expect(sidePanel(page)).toHaveCount(0);
|
|
|
|
await test.step("revealing the Side panel seeds nothing", async () => {
|
|
await sidePanelToggle(page).click();
|
|
await expect(sidePanel(page)).toBeVisible({ timeout: 30_000 });
|
|
await expect(emptyState(sidePanel(page))).toBeVisible();
|
|
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
|
|
await capture(page, testInfo, "01-side-panel-reveals-empty");
|
|
});
|
|
|
|
await test.step("Changes opens in the Side panel that offered it", async () => {
|
|
await emptyState(sidePanel(page)).getByRole("button", { name: "Changes" }).click();
|
|
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
await capture(page, testInfo, "02-changes-opens-in-side-panel");
|
|
});
|
|
|
|
await test.step("a loaded pane offers no one-tap close", async () => {
|
|
await expect(closePaneButton(page)).toHaveCount(0);
|
|
});
|
|
|
|
await test.step("closing its final tab hides the Side panel, and it reopens empty", async () => {
|
|
const changesTab = sidePanel(page).getByTestId("workspace-tab-working_diff");
|
|
await changesTab.hover();
|
|
await page
|
|
.locator('[data-testid^="workspace-working-diff-close-"]')
|
|
.filter({ visible: true })
|
|
.click();
|
|
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
|
|
await capture(page, testInfo, "03-side-panel-hidden-after-final-tab-close");
|
|
|
|
await sidePanelToggle(page).click();
|
|
await expect(sidePanel(page)).toBeVisible({ timeout: 15_000 });
|
|
await expect(emptyState(sidePanel(page))).toBeVisible();
|
|
await capture(page, testInfo, "04-side-panel-revealed-empty");
|
|
});
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
|
|
test("an emptied ordinary pane is removed rather than hidden", async ({ page }, testInfo) => {
|
|
const workspace = await seedWorkspace({ repoPrefix: "side-panel-close-main-" });
|
|
|
|
try {
|
|
await gotoWorkspace(page, workspace.workspaceId);
|
|
await waitForWorkspaceTabsVisible(page);
|
|
await ensureSidePanel(page);
|
|
|
|
// Dragging the pane's only tab into its own left edge splits that tab into a
|
|
// new pane and leaves the one it came from empty.
|
|
const draftTab = page
|
|
.locator('[data-testid^="workspace-tab-draft_"]')
|
|
.filter({ visible: true })
|
|
.first();
|
|
await expect(draftTab).toBeVisible({ timeout: 30_000 });
|
|
await splitTabOutOfMainPane(page, draftTab);
|
|
const draftPane = page
|
|
.locator('[data-testid^="workspace-pane-pane_"]')
|
|
.filter({ visible: true })
|
|
.first();
|
|
await expect(draftPane).toBeVisible({ timeout: 15_000 });
|
|
|
|
const emptyMain = emptyState(mainPane(page));
|
|
await expect(emptyMain).toBeVisible({ timeout: 15_000 });
|
|
await capture(page, testInfo, "06-empty-main-pane-launcher");
|
|
|
|
await emptyMain.getByRole("button", { name: "Close pane" }).click();
|
|
|
|
// Gone for good, unlike the Side panel beside it, which is still only hidden away.
|
|
await expect(mainPane(page)).toHaveCount(0, { timeout: 30_000 });
|
|
await expect(sidePanel(page)).toBeVisible();
|
|
await expect(draftPane.getByTestId("workspace-pane-empty-state")).toHaveCount(0);
|
|
await capture(page, testInfo, "07-empty-main-pane-removed");
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
|
|
test("the last pane on screen offers no Close pane", async ({ page }, testInfo) => {
|
|
const workspace = await seedWorkspace({ repoPrefix: "side-panel-final-pane-" });
|
|
|
|
try {
|
|
await gotoWorkspace(page, workspace.workspaceId);
|
|
await waitForWorkspaceTabsVisible(page);
|
|
|
|
// Park a tab in the Side panel, then put the panel away. The workspace still
|
|
// holds that tab, so emptying Main does not look like an empty workspace and
|
|
// nothing reseeds a draft into it.
|
|
await ensureSidePanel(page);
|
|
await emptyState(sidePanel(page)).getByRole("button", { name: "Files" }).click();
|
|
await expect(sidePanel(page).getByTestId("workspace-tab-files")).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
await sidePanelToggle(page).click();
|
|
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
|
|
|
|
const draftTab = page
|
|
.locator('[data-testid^="workspace-tab-draft_"]')
|
|
.filter({ visible: true })
|
|
.first();
|
|
await draftTab.hover();
|
|
await page
|
|
.locator('[data-testid^="workspace-draft-close-"]')
|
|
.filter({ visible: true })
|
|
.first()
|
|
.click();
|
|
|
|
// Main is now empty and the only pane the user can see. Taking it away would
|
|
// leave nowhere to look, so the launcher appears without a Close pane.
|
|
const lastPane = emptyState(mainPane(page));
|
|
await expect(lastPane).toBeVisible({ timeout: 15_000 });
|
|
await expect(lastPane.getByRole("button", { name: "Files" })).toBeVisible();
|
|
await expect(lastPane.getByRole("button", { name: "Close pane" })).toHaveCount(0);
|
|
await expect(closePaneButton(page)).toHaveCount(0);
|
|
await capture(page, testInfo, "08-final-visible-pane-has-no-close");
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
|
|
test("a pane's own menu creates an independent view when one is open elsewhere", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const workspace = await seedWorkspace({ repoPrefix: "side-panel-claim-" });
|
|
|
|
try {
|
|
await gotoWorkspace(page, workspace.workspaceId);
|
|
await waitForWorkspaceTabsVisible(page);
|
|
await ensureSidePanel(page);
|
|
await emptyState(sidePanel(page)).getByRole("button", { name: "Changes" }).click();
|
|
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
await capture(page, testInfo, "08-changes-in-side-panel");
|
|
|
|
await mainPane(page).getByTestId("workspace-new-tab-menu-trigger").click();
|
|
await page
|
|
.getByTestId("workspace-new-tab-menu-changes")
|
|
.filter({ visible: true })
|
|
.first()
|
|
.click();
|
|
|
|
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toHaveCount(1);
|
|
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toHaveCount(1);
|
|
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(2);
|
|
await capture(page, testInfo, "09-independent-changes-in-both-panes");
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
});
|