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
@@ -31,7 +31,7 @@ describe("selectProjectWorkspacesToArchive", () => {
expect(confirmWorktreeArchive).toHaveBeenCalledOnce();
expect(confirmWorktreeArchive).toHaveBeenCalledWith({
worktreeName: "feature/risky",
workspaceName: "feature/risky",
isDirty: true,
aheadOfOrigin: 2,
diffStat: { additions: 5, deletions: 1 },
@@ -73,7 +73,7 @@ describe("selectProjectWorkspacesToArchive", () => {
expect(confirmWorktreeArchive).toHaveBeenCalledOnce();
expect(confirmWorktreeArchive).toHaveBeenCalledWith({
worktreeName: "feature/risky",
workspaceName: "feature/risky",
isDirty: true,
aheadOfOrigin: 2,
diffStat: { additions: 5, deletions: 1 },
@@ -24,7 +24,7 @@ export async function selectProjectWorkspacesToArchive(
for (const workspace of workspaces) {
if (workspace.workspaceKind === "worktree") {
const shouldArchive = await confirmWorktreeArchive({
worktreeName: workspace.name,
workspaceName: workspace.name,
...toWorktreeArchiveRisk(workspace),
});
if (!shouldArchive) {
@@ -2,20 +2,30 @@ import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useToast } from "@/contexts/toast-context";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import {
confirmRiskyWorktreeArchive,
DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS,
type WorktreeArchiveWarningLabels,
} from "@/git/worktree-archive-warning";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import {
buildWorkspaceTabPersistenceKey,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
import { useWorkspaceTabsStore } from "@/stores/workspace-tabs-store";
import { archiveWorkspaceOptimistically } from "@/workspace/workspace-archive";
import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
function purgeArchivedWorkspaceState(input: { serverId: string; workspaceId: string }): void {
const workspaceKey = buildWorkspaceTabPersistenceKey(input);
if (workspaceKey) {
useWorkspaceLayoutStore.getState().purgeWorkspace(workspaceKey);
}
useWorkspaceTabsStore.getState().purgeWorkspace(input);
}
export interface ArchiveWorkspaceInput {
serverId: string;
workspaceId: string;
workspaceDirectory: string | null | undefined;
workspaceKind: WorkspaceDescriptor["workspaceKind"];
name: string;
isDirty?: boolean | null;
@@ -34,7 +44,6 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
const {
serverId,
workspaceId,
workspaceDirectory,
workspaceKind,
name,
isDirty,
@@ -46,37 +55,8 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
} = input;
const { t } = useTranslation();
const toast = useToast();
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
const archiveWorktreeRecord = useCallback(() => {
let archiveDirectory: string;
try {
archiveDirectory = requireWorkspaceDirectory({
workspaceId,
workspaceDirectory,
});
} catch (error) {
toast.error(
error instanceof Error
? error.message
: t("sidebar.workspace.toasts.workspacePathUnavailable"),
);
return;
}
onArchiveStarted();
void archiveWorktree({
serverId,
cwd: archiveDirectory,
worktreePath: archiveDirectory,
workspaceId,
}).catch((error) => {
toast.error(
error instanceof Error ? error.message : t("sidebar.workspace.toasts.archiveFailed"),
);
});
}, [archiveWorktree, onArchiveStarted, serverId, t, toast, workspaceDirectory, workspaceId]);
const archiveNonWorktreeRecord = useCallback(async () => {
const archiveWorkspaceRecord = useCallback(async () => {
const client = getHostRuntimeStore().getClient(serverId);
if (!client) {
toast.error(t("sidebar.workspace.toasts.hostDisconnected"));
@@ -84,17 +64,18 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
}
onSetHiding?.(true);
try {
onArchiveStarted();
await archiveWorkspaceOptimistically({
client,
workspace: {
serverId,
workspaceId,
},
afterHide: onArchiveStarted,
});
purgeArchivedWorkspaceState({ serverId, workspaceId });
} catch (error) {
toast.error(
error instanceof Error ? error.message : t("sidebar.workspace.toasts.hideFailed"),
error instanceof Error ? error.message : t("sidebar.workspace.toasts.archiveFailed"),
);
} finally {
onSetHiding?.(false);
@@ -106,7 +87,7 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
if (workspaceKind === "worktree") {
const confirmed = await confirmRiskyWorktreeArchive(
{
worktreeName: name,
workspaceName: name,
isDirty,
aheadOfOrigin,
diffStat,
@@ -116,15 +97,12 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
if (!confirmed) {
return;
}
archiveWorktreeRecord();
return;
}
await archiveNonWorktreeRecord();
await archiveWorkspaceRecord();
})();
}, [
aheadOfOrigin,
archiveNonWorktreeRecord,
archiveWorktreeRecord,
archiveWorkspaceRecord,
diffStat,
isDirty,
name,
@@ -144,23 +144,6 @@ describe("archiveWorkspaceOptimistically", () => {
}),
).toBe(false);
});
it("runs the after-hide hook after local state is hidden", async () => {
const archived = workspace();
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [archived]);
const client = createClient(vi.fn(async () => archivePayload({ workspaceId: archived.id })));
const afterHide = vi.fn(() => {
expect(storedWorkspace(archived.id)).toBeUndefined();
});
await archiveWorkspaceOptimistically({
client,
workspace: target(),
afterHide,
});
expect(afterHide).toHaveBeenCalledOnce();
});
});
describe("archiveWorkspacesOptimistically", () => {
@@ -183,10 +166,7 @@ describe("archiveWorkspacesOptimistically", () => {
const failures = await archiveWorkspacesOptimistically({
getClient: () => client,
workspaces: [
target({ workspaceId: first.id, workspaceDirectory: first.workspaceDirectory }),
target({ workspaceId: second.id, workspaceDirectory: second.workspaceDirectory }),
],
workspaces: [target({ workspaceId: first.id }), target({ workspaceId: second.id })],
});
expect(failures).toHaveLength(1);
@@ -219,12 +199,10 @@ describe("archiveWorkspacesOptimistically", () => {
target({
serverId: SERVER_ID,
workspaceId: first.id,
workspaceDirectory: first.workspaceDirectory,
}),
target({
serverId: SECOND_SERVER_ID,
workspaceId: second.id,
workspaceDirectory: second.workspaceDirectory,
}),
],
});
@@ -9,7 +9,6 @@ import { i18n } from "@/i18n/i18next";
export interface WorkspaceArchiveTarget {
serverId: string;
workspaceId: string;
workspaceDirectory?: string | null;
}
interface WorkspaceArchiveClient {
@@ -50,7 +49,6 @@ function hideWorkspaceOptimistically(
markWorkspaceArchivePending({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
useSessionStore.getState().removeWorkspace(workspace.serverId, workspace.workspaceId);
return { workspace: snapshot };
@@ -83,10 +81,8 @@ async function archiveWorkspaceOrThrow(input: {
export async function archiveWorkspaceOptimistically(input: {
client: WorkspaceArchiveClient;
workspace: WorkspaceArchiveTarget;
afterHide?: () => void;
}): Promise<void> {
const snapshot = hideWorkspaceOptimistically(input.workspace);
input.afterHide?.();
try {
await archiveWorkspaceOrThrow({