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:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user