Remove projects through a daemon RPC so the sidebar can delete the project record after archiving its active workspaces. Reopening a removed directory recreates the missing project record so local projects keep their derived basename, and History skips orphaned archived workspace rows. Fixes #1583.
137 lines
5.7 KiB
TypeScript
137 lines
5.7 KiB
TypeScript
import { test, expect, type Page } from "./fixtures";
|
|
import { gotoAppShell } from "./helpers/app";
|
|
import { seedWorkspace } from "./helpers/seed-client";
|
|
import { getServerId } from "./helpers/server-id";
|
|
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
|
|
|
function workspaceRowTestId(workspaceId: string): string {
|
|
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
|
}
|
|
|
|
async function hideWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
|
|
const serverId = getServerId();
|
|
const row = page.getByTestId(workspaceRowTestId(workspaceId));
|
|
await expect(row).toBeVisible({ timeout: 30_000 });
|
|
await row.hover();
|
|
|
|
const kebab = page.getByTestId(`sidebar-workspace-kebab-${serverId}:${workspaceId}`);
|
|
await expect(kebab).toBeVisible({ timeout: 10_000 });
|
|
await kebab.click();
|
|
|
|
// Hiding a checkout from the sidebar raises a browser confirm; accept it so the
|
|
// user-confirmed archive proceeds deterministically.
|
|
page.once("dialog", (dialog) => void dialog.accept());
|
|
|
|
const archiveItem = page.getByTestId(`sidebar-workspace-menu-archive-${serverId}:${workspaceId}`);
|
|
await expect(archiveItem).toBeVisible({ timeout: 10_000 });
|
|
await archiveItem.click();
|
|
}
|
|
|
|
async function removeProjectFromSidebar(page: Page, projectId: string): Promise<void> {
|
|
const projectRow = page.getByTestId(`sidebar-project-row-${projectId}`);
|
|
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
|
await projectRow.hover();
|
|
|
|
const kebab = page.getByTestId(`sidebar-project-kebab-${projectId}`);
|
|
await expect(kebab).toBeVisible({ timeout: 10_000 });
|
|
await kebab.click();
|
|
|
|
// Removing a project raises a browser confirm; accept it so the
|
|
// user-confirmed removal proceeds deterministically.
|
|
page.once("dialog", (dialog) => void dialog.accept());
|
|
|
|
const removeItem = page.getByTestId(`sidebar-project-menu-remove-${projectId}`);
|
|
await expect(removeItem).toBeVisible({ timeout: 10_000 });
|
|
await removeItem.click();
|
|
}
|
|
|
|
// Model B makes the project a first-class parent: archiving its last workspace
|
|
// must not delete the project. The per-project "+ New workspace" row is gone;
|
|
// the empty project keeps its parent row, and creation stays reachable from the
|
|
// project row's own new-worktree icon (git projects) and the global button.
|
|
test.describe("Empty project persists", () => {
|
|
test("archiving the only workspace keeps the project row with creation still reachable", async ({
|
|
page,
|
|
}) => {
|
|
const workspace = await seedWorkspace({ repoPrefix: "empty-project-persists-" });
|
|
|
|
try {
|
|
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`);
|
|
const projectNewWorktreeIcon = page.getByTestId(
|
|
`sidebar-project-new-worktree-${workspace.projectId}`,
|
|
);
|
|
const globalNewWorkspace = page.getByTestId("sidebar-global-new-workspace");
|
|
|
|
await gotoAppShell(page);
|
|
await waitForSidebarHydration(page);
|
|
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
|
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
|
|
await hideWorkspaceFromSidebar(page, workspace.workspaceId);
|
|
|
|
// The workspace row goes away, but its project parent stays as an empty
|
|
// project row. Creation is still reachable: the project row keeps its own
|
|
// new-worktree icon (revealed on hover) and the global button persists.
|
|
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, {
|
|
timeout: 30_000,
|
|
});
|
|
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
|
await expect(globalNewWorkspace).toBeVisible({ timeout: 30_000 });
|
|
await projectRow.hover();
|
|
await expect(projectNewWorktreeIcon).toBeVisible({ timeout: 30_000 });
|
|
|
|
// The empty project survives a reload — it is persisted, not a transient
|
|
// artifact of the just-archived workspace still lingering in memory.
|
|
await page.reload();
|
|
await waitForSidebarHydration(page);
|
|
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
|
await projectRow.hover();
|
|
await expect(projectNewWorktreeIcon).toBeVisible({ timeout: 30_000 });
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
});
|
|
|
|
test.describe("Project remove", () => {
|
|
test("removing a project from project actions removes it from the sidebar", async ({ page }) => {
|
|
const workspace = await seedWorkspace({ repoPrefix: "project-remove-sidebar-" });
|
|
|
|
try {
|
|
const projectRow = page.getByTestId(`sidebar-project-row-${workspace.projectId}`);
|
|
|
|
await gotoAppShell(page);
|
|
await waitForSidebarHydration(page);
|
|
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
|
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
|
|
await removeProjectFromSidebar(page, workspace.projectId);
|
|
|
|
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, {
|
|
timeout: 30_000,
|
|
});
|
|
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
|
|
|
|
await page.reload();
|
|
await waitForSidebarHydration(page);
|
|
await expect(projectRow).toHaveCount(0, { timeout: 30_000 });
|
|
|
|
const reopened = await workspace.client.openProject(workspace.repoPath);
|
|
expect(reopened.error).toBeNull();
|
|
expect(reopened.workspace?.projectDisplayName).toBe(workspace.projectDisplayName);
|
|
|
|
await page.reload();
|
|
await waitForSidebarHydration(page);
|
|
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
|
await expect(projectRow).toContainText(workspace.projectDisplayName);
|
|
await expect(projectRow).not.toContainText(workspace.repoPath);
|
|
} finally {
|
|
await workspace.cleanup();
|
|
}
|
|
});
|
|
});
|