Archive any workspace from the app (#2003)
* feat(app): expose archive for every workspace Workspace actions now archive one workspace by id. The daemon remains responsible for backing-specific cleanup, including removing a managed worktree only after its final workspace is archived. * refactor(app): clarify archive descriptor name * fix(app): preserve workspace archive safety * fix(app): allow archive without upstream * refactor(app): remove stale archive callback * test(app): cover workspace archive ownership
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import path from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { expectOpenedProject } from "./helpers/project-picker-ui";
|
||||
@@ -11,7 +12,7 @@ function workspaceRowTestId(workspaceId: string): string {
|
||||
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||
}
|
||||
|
||||
async function hideWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
const row = page.getByTestId(workspaceRowTestId(workspaceId));
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
@@ -21,10 +22,6 @@ async function hideWorkspaceFromSidebar(page: Page, workspaceId: string): Promis
|
||||
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();
|
||||
@@ -159,17 +156,24 @@ test.describe("Project with no workspaces persists", () => {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toBeVisible({
|
||||
const workspaceRow = page.getByTestId(workspaceRowTestId(workspace.workspaceId));
|
||||
await expect(workspaceRow).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await workspaceRow.click();
|
||||
await expect(page.getByTestId("changes-primary-cta")).toHaveAttribute(
|
||||
"aria-label",
|
||||
"Archive workspace",
|
||||
);
|
||||
|
||||
await hideWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
await archiveWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
|
||||
// The workspace row goes away, but its project parent stays and exposes a
|
||||
// child row for creating the next workspace.
|
||||
await expect(page.getByTestId(workspaceRowTestId(workspace.workspaceId))).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(existsSync(workspace.repoPath)).toBe(true);
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(newWorkspaceRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(newWorkspaceRow).toContainText("New workspace");
|
||||
|
||||
@@ -39,10 +39,9 @@ export async function clickArchiveWorkspaceMenuItem(
|
||||
await archiveItem.click();
|
||||
}
|
||||
|
||||
export async function archiveWorktreeFromSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
// A clean worktree archives with no prompt; if the host reports unsynced work the app
|
||||
// raises a browser confirm. Accept it so the user-confirmed archive stays deterministic
|
||||
// either way.
|
||||
export async function archiveWorkspaceFromSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
// A clean workspace archives with no prompt. Managed worktree backing may raise
|
||||
// a browser confirm for unsynced work, so accept it when present.
|
||||
page.once("dialog", (dialog) => void dialog.accept());
|
||||
await clickArchiveWorkspaceMenuItem(page, workspaceId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { expectWorkspaceAbsentFromSidebar, selectWorkspaceInSidebar } from "./helpers/sidebar";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
test.describe("Workspace archive shortcut", () => {
|
||||
test("archives the selected workspace without removing its local checkout", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "archive-shortcut-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await selectWorkspaceInSidebar(page, workspace.workspaceId);
|
||||
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.keyboard.press(`${modifier}+Shift+Backspace`);
|
||||
|
||||
await expectWorkspaceAbsentFromSidebar(page, workspace.workspaceId);
|
||||
expect(existsSync(workspace.repoPath)).toBe(true);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -76,7 +76,7 @@ async function clickArchiveAndAnswerWarning(
|
||||
return warning;
|
||||
}
|
||||
|
||||
test.describe("Worktree archive risk warning", () => {
|
||||
test.describe("Workspace archive risk warning for worktree backing", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
const createdWorktreeDirectories = new Set<string>();
|
||||
@@ -97,7 +97,7 @@ test.describe("Worktree archive risk warning", () => {
|
||||
await tempRepo?.cleanup().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("a risky worktree archive is gated by confirmation and removes the directory after acceptance", async ({
|
||||
test("a risky workspace archive is gated by confirmation and removes its worktree after acceptance", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
openProjectViaDaemon,
|
||||
} from "./helpers/new-workspace";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { archiveWorktreeFromSidebar, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
|
||||
import { archiveWorkspaceFromSidebar, expectWorkspaceAbsentFromSidebar } from "./helpers/sidebar";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { waitForSidebarHydration, waitForWorkspaceInSidebar } from "./helpers/workspace-ui";
|
||||
|
||||
test.describe("Worktree archive", () => {
|
||||
test.describe("Workspace archive with worktree backing", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
const createdWorktreeDirectories = new Set<string>();
|
||||
@@ -33,9 +33,7 @@ test.describe("Worktree archive", () => {
|
||||
await tempRepo?.cleanup().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("archiving a worktree from the sidebar removes its row and worktree directory", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("archiving the final workspace removes its managed worktree directory", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await openProjectViaDaemon(client, tempRepo.path);
|
||||
const worktree = await createWorktreeViaDaemon(client, {
|
||||
@@ -49,11 +47,51 @@ test.describe("Worktree archive", () => {
|
||||
await waitForSidebarHydration(page);
|
||||
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId });
|
||||
|
||||
await archiveWorktreeFromSidebar(page, worktree.workspaceId);
|
||||
await archiveWorkspaceFromSidebar(page, worktree.workspaceId);
|
||||
|
||||
await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId);
|
||||
await expect
|
||||
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
test("a managed worktree remains until its last workspace is archived", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await openProjectViaDaemon(client, tempRepo.path);
|
||||
const first = await createWorktreeViaDaemon(client, {
|
||||
cwd: tempRepo.path,
|
||||
slug: `shared-archive-${Date.now()}`,
|
||||
});
|
||||
createdWorktreeDirectories.add(first.workspaceDirectory);
|
||||
|
||||
const siblingPayload = await client.createWorkspace({
|
||||
source: {
|
||||
kind: "directory",
|
||||
path: first.workspaceDirectory,
|
||||
},
|
||||
title: "Second workspace",
|
||||
});
|
||||
if (!siblingPayload.workspace) {
|
||||
throw new Error(siblingPayload.error ?? "Failed to create a workspace on the worktree");
|
||||
}
|
||||
const sibling = siblingPayload.workspace;
|
||||
expect(sibling.workspaceKind).toBe("worktree");
|
||||
expect(sibling.workspaceDirectory).toBe(first.workspaceDirectory);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: first.workspaceId });
|
||||
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: sibling.id });
|
||||
|
||||
await archiveWorkspaceFromSidebar(page, first.workspaceId);
|
||||
|
||||
await expectWorkspaceAbsentFromSidebar(page, first.workspaceId);
|
||||
expect(existsSync(first.workspaceDirectory)).toBe(true);
|
||||
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: sibling.id });
|
||||
|
||||
await archiveWorkspaceFromSidebar(page, sibling.id);
|
||||
|
||||
await expectWorkspaceAbsentFromSidebar(page, sibling.id);
|
||||
await expect.poll(() => existsSync(first.workspaceDirectory), { timeout: 30_000 }).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user