mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 16:34:36 +00:00
* feat(app): archive a workspace from its sidebar row and undo it Hovering a workspace row on desktop shows an archive button next to the row menu. Archiving shows a toast with Undo and a Cmd/Ctrl+Z shortcut so a mis-click costs one keystroke instead of a trip through History. The daemon serializes archive and undo per workspace across every connection. An undo that lands while an archive is still tearing down aborts it at the next checkpoint and restores only the agents that archive took down; deleting the worktree directory is the point of no return, after which undo recreates the worktree. * fix(server,app): close archive and undo races found in review - An archive request now claims the workspace's archive queue before looking up the workspace, so an undo sent right behind it cancels it instead of running first and losing to the archive. - A repeat archive of an already archived workspace no longer wipes the agents the earlier archive saved for undo. - Undo no longer brings back an agent that was archived before the workspace archive and only loaded to read its history. - Each local archive or undo request owns its own intent token, so a late response to an earlier request cannot release a newer request's hold on the row. An archive canceled by another client's undo puts the row back. - The undo failure toast shows translated copy; the cause goes to the log. * fix(app): keep a workspace's layout when another client restores it during the undo window The undo window purged tabs and layout whenever it closed after a finished archive. If another client restored the workspace before this client's toast closed, the restored workspace lost its saved arrangement. The archive now commits only if the workspace is still gone when the window closes. * fix(app): show the archived toast's Undo as a button * fix(app): keep a workspace's agent tabs when it is opened while its undo runs * fix(app): keep a row whose undo is running when a directory snapshot omits it
187 lines
7.4 KiB
TypeScript
187 lines
7.4 KiB
TypeScript
import type { Page } from "@playwright/test";
|
|
import { expect, test } from "../support/fixtures";
|
|
import { gotoAppShell } from "../support/helpers/app";
|
|
import {
|
|
archiveWorkspaceFromDaemon,
|
|
connectNewWorkspaceDaemonClient,
|
|
createWorktreeViaDaemon,
|
|
openProjectViaDaemon,
|
|
} from "../support/helpers/new-workspace";
|
|
import { getServerId } from "../support/helpers/server-id";
|
|
import { expectWorkspaceAbsentFromSidebar } from "../support/helpers/sidebar";
|
|
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
|
|
import { connectSeedClient } from "../support/helpers/seed-client";
|
|
import { createTempGitRepo } from "../support/helpers/workspace";
|
|
import { getWorkspaceTabTestIds } from "../support/helpers/workspace-tabs";
|
|
import {
|
|
archiveFromRowButton,
|
|
createGatedTeardown,
|
|
expectDirectoryPresence,
|
|
expectHostWorkspaceSettledActive,
|
|
outlastUndoWindow,
|
|
pressUndoShortcut,
|
|
rowArchiveButton,
|
|
rowKebab,
|
|
undoButton,
|
|
workspaceRow,
|
|
workspaceTitleWidth,
|
|
} from "../support/helpers/workspace-archive-undo";
|
|
import {
|
|
waitForSidebarHydration,
|
|
waitForWorkspaceInSidebar,
|
|
} from "../support/helpers/workspace-ui";
|
|
|
|
test.describe("Workspace archive undo", () => {
|
|
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
|
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
|
const worktreeDirectories = new Set<string>();
|
|
|
|
test.describe.configure({ timeout: 120_000 });
|
|
|
|
test.beforeEach(async () => {
|
|
client = await connectNewWorkspaceDaemonClient();
|
|
tempRepo = await createTempGitRepo("archive-undo-");
|
|
});
|
|
|
|
test.afterEach(async () => {
|
|
for (const directory of worktreeDirectories) {
|
|
await archiveWorkspaceFromDaemon(client, directory).catch(() => undefined);
|
|
}
|
|
worktreeDirectories.clear();
|
|
await client?.close().catch(() => undefined);
|
|
await tempRepo?.cleanup().catch(() => undefined);
|
|
});
|
|
|
|
async function seedWorktree(page: Page, slug: string) {
|
|
await openProjectViaDaemon(client, tempRepo.path);
|
|
const worktree = await createWorktreeViaDaemon(client, {
|
|
cwd: tempRepo.path,
|
|
slug: `${slug}-${Date.now()}`,
|
|
});
|
|
worktreeDirectories.add(worktree.workspaceDirectory);
|
|
await gotoAppShell(page);
|
|
await waitForSidebarHydration(page);
|
|
await waitForWorkspaceInSidebar(page, {
|
|
serverId: getServerId(),
|
|
workspaceId: worktree.workspaceId,
|
|
});
|
|
return worktree;
|
|
}
|
|
|
|
test("the hover archive button overlays the title instead of narrowing it", async ({ page }) => {
|
|
const worktree = await seedWorktree(page, "archive-button-overlay");
|
|
|
|
await page.mouse.move(0, 0);
|
|
await expect(rowArchiveButton(page, worktree.workspaceId)).toHaveCount(0);
|
|
const restingWidth = await workspaceTitleWidth(page, worktree.workspaceId);
|
|
|
|
await workspaceRow(page, worktree.workspaceId).hover();
|
|
await expect(rowArchiveButton(page, worktree.workspaceId)).toBeVisible();
|
|
await expect(page.getByTestId("sidebar-workspace-trailing-scrim")).toBeVisible();
|
|
expect(await workspaceTitleWidth(page, worktree.workspaceId)).toBe(restingWidth);
|
|
|
|
const [buttonBox, kebabBox] = await Promise.all([
|
|
rowArchiveButton(page, worktree.workspaceId).boundingBox(),
|
|
rowKebab(page, worktree.workspaceId).boundingBox(),
|
|
]);
|
|
expect(buttonBox && kebabBox && buttonBox.x + buttonBox.width <= kebabBox.x).toBe(true);
|
|
});
|
|
|
|
test("Cmd/Ctrl+Z right after archiving keeps the worktree", async ({ page }) => {
|
|
const worktree = await seedWorktree(page, "archive-undo-shortcut");
|
|
|
|
await archiveFromRowButton(page, worktree.workspaceId);
|
|
await pressUndoShortcut(page);
|
|
|
|
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible();
|
|
await expect(undoButton(page)).toHaveCount(0);
|
|
await expectHostWorkspaceSettledActive(client, worktree.workspaceId);
|
|
// Once the host settled, any remove the canceled archive sent has already arrived.
|
|
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible();
|
|
await expectDirectoryPresence(worktree.workspaceDirectory, true);
|
|
});
|
|
|
|
test("Undo after the worktree was removed recreates it", async ({ page }) => {
|
|
const worktree = await seedWorktree(page, "archive-undo-recreate");
|
|
|
|
await archiveFromRowButton(page, worktree.workspaceId);
|
|
// Hovering the toast holds it, and with it the undo, open while the archive finishes.
|
|
await undoButton(page).hover();
|
|
await expectDirectoryPresence(worktree.workspaceDirectory, false);
|
|
await undoButton(page).click();
|
|
|
|
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible({ timeout: 30_000 });
|
|
await expectDirectoryPresence(worktree.workspaceDirectory, true);
|
|
});
|
|
|
|
test("hovering Undo keeps the toast open past its deadline, and its close ends the undo", async ({
|
|
page,
|
|
}) => {
|
|
await page.clock.install({ time: Date.now() });
|
|
const worktree = await seedWorktree(page, "archive-undo-hold");
|
|
|
|
await archiveFromRowButton(page, worktree.workspaceId);
|
|
await page.getByTestId("workspace-archived-toast").hover({ position: { x: 8, y: 8 } });
|
|
await undoButton(page).hover();
|
|
await outlastUndoWindow(page);
|
|
await expect(undoButton(page)).toBeVisible();
|
|
|
|
await page.mouse.move(0, 0);
|
|
await outlastUndoWindow(page);
|
|
await expect(undoButton(page)).toHaveCount(0);
|
|
|
|
// The toast took the undo with it.
|
|
await pressUndoShortcut(page);
|
|
await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId);
|
|
});
|
|
|
|
test("opening the workspace while its undo is still running keeps its tabs", async ({ page }) => {
|
|
const teardown = createGatedTeardown();
|
|
const repo = await createTempGitRepo("archive-undo-tabs-", {
|
|
paseoConfig: teardown.paseoConfig,
|
|
});
|
|
const seedClient = await connectSeedClient();
|
|
try {
|
|
await openProjectViaDaemon(client, repo.path);
|
|
const worktree = await createWorktreeViaDaemon(client, {
|
|
cwd: repo.path,
|
|
slug: `archive-undo-tabs-${Date.now()}`,
|
|
});
|
|
worktreeDirectories.add(worktree.workspaceDirectory);
|
|
const target = { cwd: worktree.workspaceDirectory, workspaceId: worktree.workspaceId };
|
|
const agents = [
|
|
await createMockIdleAgent(seedClient, { ...target, title: "first" }),
|
|
await createMockIdleAgent(seedClient, { ...target, title: "second" }),
|
|
] as const;
|
|
await openWorkspaceWithAgents(page, [agents[0], agents[1]]);
|
|
const tabsBefore = await getWorkspaceTabTestIds(page);
|
|
|
|
await archiveFromRowButton(page, worktree.workspaceId);
|
|
// The archive has taken its agents down and now parks in teardown.
|
|
await teardown.expectStarted();
|
|
await undoButton(page).click();
|
|
await workspaceRow(page, worktree.workspaceId).click();
|
|
|
|
for (const agent of agents) {
|
|
await expect(page.getByTestId(`workspace-tab-agent_${agent.id}`).first()).toBeVisible();
|
|
}
|
|
teardown.release();
|
|
await expectHostWorkspaceSettledActive(client, worktree.workspaceId);
|
|
await expect.poll(() => getWorkspaceTabTestIds(page)).toEqual(tabsBefore);
|
|
} finally {
|
|
teardown.release();
|
|
await seedClient.close().catch(() => undefined);
|
|
await repo.cleanup().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
test("Cmd/Ctrl+Z without an archive to undo leaves the sidebar alone", async ({ page }) => {
|
|
const worktree = await seedWorktree(page, "archive-undo-none");
|
|
|
|
await pressUndoShortcut(page);
|
|
|
|
await expect(undoButton(page)).toHaveCount(0);
|
|
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible();
|
|
});
|
|
});
|