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:
Mohamed Boudra
2026-07-11 18:22:31 +02:00
committed by GitHub
parent ada0955334
commit 03dcda7c41
40 changed files with 369 additions and 757 deletions
@@ -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");
+3 -4
View File
@@ -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();
+44 -6
View File
@@ -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);
});
});