From 80102753e13a8f2195d1b929f99258981a4cbef7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 23 Apr 2026 21:16:23 +0700 Subject: [PATCH] Make worktree archive optimistic --- packages/app/src/contexts/session-context.tsx | 3 - .../stores/checkout-git-actions-store.test.ts | 78 +++++++++++++++++++ .../src/stores/checkout-git-actions-store.ts | 70 +++++++++++++++-- .../workspace-archive-navigation.test.ts | 24 +++--- .../src/utils/workspace-archive-navigation.ts | 57 ++++---------- 5 files changed, 169 insertions(+), 63 deletions(-) diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 9917c7f3f..aa61ab253 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -1109,9 +1109,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider return; } const workspace = normalizeWorkspaceDescriptor(message.payload.workspace); - const existingWorkspace = useSessionStore - .getState() - .sessions[serverId]?.workspaces.get(workspace.id); mergeWorkspaces(serverId, [workspace]); }); diff --git a/packages/app/src/stores/checkout-git-actions-store.test.ts b/packages/app/src/stores/checkout-git-actions-store.test.ts index 24c04d634..22794270e 100644 --- a/packages/app/src/stores/checkout-git-actions-store.test.ts +++ b/packages/app/src/stores/checkout-git-actions-store.test.ts @@ -1,12 +1,23 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { QueryClient } from "@tanstack/react-query"; +import type { DaemonClient } from "@server/client/daemon-client"; +import { queryClient as appQueryClient } from "@/query/query-client"; import { useSessionStore } from "@/stores/session-store"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; import { __resetCheckoutGitActionsStoreForTests, invalidateCheckoutGitQueriesForClient, useCheckoutGitActionsStore, } from "@/stores/checkout-git-actions-store"; +vi.mock("@react-native-async-storage/async-storage", () => ({ + default: { + getItem: vi.fn(async () => null), + setItem: vi.fn(async () => undefined), + removeItem: vi.fn(async () => undefined), + }, +})); + function createDeferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; @@ -17,6 +28,22 @@ function createDeferred() { return { promise, resolve, reject }; } +function workspace(input: Partial & Pick) { + return { + id: input.id, + projectId: input.projectId ?? "project-1", + projectDisplayName: input.projectDisplayName ?? "Project", + projectRootPath: input.projectRootPath ?? "/tmp/repo", + workspaceDirectory: input.workspaceDirectory ?? input.id, + projectKind: input.projectKind ?? "git", + workspaceKind: input.workspaceKind ?? "worktree", + name: input.name ?? input.id, + status: input.status ?? "done", + diffStat: input.diffStat ?? null, + scripts: input.scripts ?? [], + } satisfies WorkspaceDescriptor; +} + describe("checkout-git-actions-store", () => { const serverId = "server-1"; const cwd = "/tmp/repo"; @@ -24,12 +51,14 @@ describe("checkout-git-actions-store", () => { beforeEach(() => { vi.useFakeTimers(); __resetCheckoutGitActionsStoreForTests(); + appQueryClient.clear(); useSessionStore.setState((state) => ({ ...state, sessions: {} as any })); }); afterEach(() => { vi.useRealTimers(); __resetCheckoutGitActionsStoreForTests(); + appQueryClient.clear(); useSessionStore.setState((state) => ({ ...state, sessions: {} as any })); }); @@ -89,4 +118,53 @@ describe("checkout-git-actions-store", () => { queryClient.clear(); }); + + it("hides an archived worktree optimistically while the archive RPC is in flight", async () => { + const deferred = createDeferred>(); + const client = { + archivePaseoWorktree: vi.fn(() => deferred.promise), + }; + const featureWorkspace = workspace({ id: cwd, name: "feature" }); + useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient); + useSessionStore.getState().setWorkspaces(serverId, new Map([[cwd, featureWorkspace]])); + appQueryClient.setQueryData( + ["sidebarPaseoWorktreeList", serverId, "/tmp"], + [{ worktreePath: cwd }, { worktreePath: "/tmp/other" }], + ); + + const archive = useCheckoutGitActionsStore + .getState() + .archiveWorktree({ serverId, cwd, worktreePath: cwd }); + + expect(client.archivePaseoWorktree).toHaveBeenCalledWith({ worktreePath: cwd }); + expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(cwd)).toBe(false); + expect(appQueryClient.getQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"])).toEqual([ + { worktreePath: "/tmp/other" }, + ]); + + deferred.resolve({}); + await archive; + }); + + it("restores an optimistically hidden worktree when archive fails", async () => { + const client = { + archivePaseoWorktree: vi.fn(async () => ({ error: { message: "archive failed" } })), + }; + const featureWorkspace = workspace({ id: cwd, name: "feature" }); + const listSnapshot = [{ worktreePath: cwd }, { worktreePath: "/tmp/other" }]; + useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient); + useSessionStore.getState().setWorkspaces(serverId, new Map([[cwd, featureWorkspace]])); + appQueryClient.setQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"], listSnapshot); + + await expect( + useCheckoutGitActionsStore.getState().archiveWorktree({ serverId, cwd, worktreePath: cwd }), + ).rejects.toThrow("archive failed"); + + expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(cwd)).toEqual( + featureWorkspace, + ); + expect(appQueryClient.getQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"])).toEqual( + listSnapshot, + ); + }); }); diff --git a/packages/app/src/stores/checkout-git-actions-store.ts b/packages/app/src/stores/checkout-git-actions-store.ts index b964602e5..a47c51809 100644 --- a/packages/app/src/stores/checkout-git-actions-store.ts +++ b/packages/app/src/stores/checkout-git-actions-store.ts @@ -1,4 +1,4 @@ -import type { QueryClient } from "@tanstack/react-query"; +import type { QueryClient, QueryKey } from "@tanstack/react-query"; import { create } from "zustand"; import { queryClient as appQueryClient } from "@/query/query-client"; import { @@ -6,6 +6,7 @@ import { useWorkspaceLayoutStore, } from "@/stores/workspace-layout-store"; import { useSessionStore } from "@/stores/session-store"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; import { useWorkspaceTabsStore } from "@/stores/workspace-tabs-store"; const SUCCESS_DISPLAY_MS = 1000; @@ -149,6 +150,58 @@ function removeWorktreeFromCachedLists(input: { serverId: string; worktreePath: ); } +interface WorktreeArchiveSnapshot { + workspace: WorkspaceDescriptor | null; + worktreeLists: Array<[QueryKey, unknown]>; +} + +function isWorktreeListQuery(input: { queryKey: QueryKey; serverId: string }): boolean { + return ( + Array.isArray(input.queryKey) && + (input.queryKey[0] === "paseoWorktreeList" || + input.queryKey[0] === "sidebarPaseoWorktreeList") && + input.queryKey[1] === input.serverId + ); +} + +function snapshotWorktreeArchiveState(input: { + serverId: string; + worktreePath: string; +}): WorktreeArchiveSnapshot { + return { + workspace: + useSessionStore.getState().sessions[input.serverId]?.workspaces.get(input.worktreePath) ?? + null, + worktreeLists: appQueryClient.getQueriesData({ + predicate: (query) => + isWorktreeListQuery({ queryKey: query.queryKey, serverId: input.serverId }), + }), + }; +} + +function removeWorktreeFromSessionStore(input: { serverId: string; worktreePath: string }): void { + const serverId = input.serverId.trim(); + const worktreePath = input.worktreePath.trim(); + if (!serverId || !worktreePath) { + return; + } + useSessionStore.getState().removeWorkspace(serverId, worktreePath); +} + +function restoreWorktreeArchiveState(input: { + serverId: string; + worktreePath: string; + snapshot: WorktreeArchiveSnapshot; +}): void { + if (input.snapshot.workspace) { + useSessionStore.getState().mergeWorkspaces(input.serverId, [input.snapshot.workspace]); + } + + for (const [queryKey, data] of input.snapshot.worktreeLists) { + appQueryClient.setQueryData(queryKey, data); + } +} + function purgeArchivedWorkspaceState(input: { serverId: string; worktreePath: string }): void { const serverId = input.serverId.trim(); const workspaceId = input.worktreePath.trim(); @@ -353,11 +406,18 @@ export const useCheckoutGitActionsStore = create() actionId: "archive-worktree", run: async () => { const client = resolveClient(serverId); - const payload = await client.archivePaseoWorktree({ worktreePath }); - if (payload.error) { - throw new Error(payload.error.message); - } + const snapshot = snapshotWorktreeArchiveState({ serverId, worktreePath }); removeWorktreeFromCachedLists({ serverId, worktreePath }); + removeWorktreeFromSessionStore({ serverId, worktreePath }); + try { + const payload = await client.archivePaseoWorktree({ worktreePath }); + if (payload.error) { + throw new Error(payload.error.message); + } + } catch (error) { + restoreWorktreeArchiveState({ serverId, worktreePath, snapshot }); + throw error; + } invalidateWorktreeList(); purgeArchivedWorkspaceState({ serverId, worktreePath }); }, diff --git a/packages/app/src/utils/workspace-archive-navigation.test.ts b/packages/app/src/utils/workspace-archive-navigation.test.ts index 4772308e3..067cf9f74 100644 --- a/packages/app/src/utils/workspace-archive-navigation.test.ts +++ b/packages/app/src/utils/workspace-archive-navigation.test.ts @@ -1,9 +1,6 @@ import type { DaemonClient } from "@server/client/daemon-client"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { - buildWorkspaceArchiveRedirectRoute, - resolveWorkspaceArchiveRedirectWorkspaceId, -} from "@/utils/workspace-archive-navigation"; +import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation"; import type { WorkspaceDescriptor } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store"; import { @@ -40,22 +37,23 @@ function workspace( }; } -describe("resolveWorkspaceArchiveRedirectWorkspaceId", () => { - it("redirects an archived worktree to the visible local checkout for the same project", () => { +describe("buildWorkspaceArchiveRedirectRoute", () => { + it("redirects an archived worktree to the new workspace screen for the same project", () => { const workspaces = [ workspace({ id: "/repo", workspaceKind: "checkout", name: "main" }), workspace({ id: "/repo/.paseo/worktrees/feature", name: "feature" }), ]; expect( - resolveWorkspaceArchiveRedirectWorkspaceId({ + buildWorkspaceArchiveRedirectRoute({ + serverId: "server-1", archivedWorkspaceId: "/repo/.paseo/worktrees/feature", workspaces, }), - ).toBe("/repo"); + ).toBe("/h/server-1/new?dir=%2Frepo&name=Project"); }); - it("falls back to the host root route when no sibling workspace target exists", () => { + it("redirects to the new workspace route when no sibling workspace target exists", () => { const workspaces = [ workspace({ id: "/repo/.paseo/worktrees/feature", @@ -70,10 +68,10 @@ describe("resolveWorkspaceArchiveRedirectWorkspaceId", () => { archivedWorkspaceId: "/repo/.paseo/worktrees/feature", workspaces, }), - ).toBe("/h/server-1"); + ).toBe("/h/server-1/new?dir=%2Frepo&name=Project"); }); - it("falls back to the host root route when no alternate workspace target exists", () => { + it("redirects to the new workspace route instead of another workspace", () => { const workspaces = [ workspace({ id: "/notes", @@ -90,7 +88,7 @@ describe("resolveWorkspaceArchiveRedirectWorkspaceId", () => { archivedWorkspaceId: "/notes", workspaces, }), - ).toBe("/h/server-1"); + ).toBe("/h/server-1/new?dir=%2Fnotes&name=Project"); }); }); @@ -140,6 +138,6 @@ describe("redirectIfArchivingActiveWorkspace", () => { }), ).toBe(true); - expect(replaceMock).toHaveBeenCalledWith("/h/server-1/workspace/main"); + expect(replaceMock).toHaveBeenCalledWith("/h/server-1/new?dir=%2Frepo&name=Project"); }); }); diff --git a/packages/app/src/utils/workspace-archive-navigation.ts b/packages/app/src/utils/workspace-archive-navigation.ts index d0895e863..858e59d94 100644 --- a/packages/app/src/utils/workspace-archive-navigation.ts +++ b/packages/app/src/utils/workspace-archive-navigation.ts @@ -1,55 +1,28 @@ import type { WorkspaceDescriptor } from "@/stores/session-store"; -import { buildHostRootRoute, buildHostWorkspaceRoute } from "@/utils/host-routes"; +import { buildHostNewWorkspaceRoute, buildHostRootRoute } from "@/utils/host-routes"; import { resolveWorkspaceRouteId } from "@/utils/workspace-execution"; -export function resolveWorkspaceArchiveRedirectWorkspaceId(input: { - archivedWorkspaceId: string; - workspaces: Iterable; -}): string | null { - const archivedWorkspaceId = resolveWorkspaceRouteId({ - routeWorkspaceId: input.archivedWorkspaceId, - }); - if (!archivedWorkspaceId) { - return null; - } - - const workspaces = Array.from(input.workspaces); - const archivedWorkspace = - workspaces.find((workspace) => workspace.id === archivedWorkspaceId) ?? null; - if (!archivedWorkspace) { - return null; - } - - const sameProjectWorkspaces = workspaces.filter( - (workspace) => workspace.projectId === archivedWorkspace.projectId, - ); - const rootCheckoutWorkspace = - sameProjectWorkspaces.find( - (workspace) => - workspace.workspaceKind === "local_checkout" && workspace.id !== archivedWorkspace.id, - ) ?? null; - if (rootCheckoutWorkspace) { - return rootCheckoutWorkspace.id; - } - - const siblingWorkspace = - sameProjectWorkspaces.find((workspace) => workspace.id !== archivedWorkspace.id) ?? null; - return siblingWorkspace?.id ?? null; -} - export function buildWorkspaceArchiveRedirectRoute(input: { serverId: string; archivedWorkspaceId: string; workspaces: Iterable; }) { - const redirectWorkspaceId = resolveWorkspaceArchiveRedirectWorkspaceId({ - archivedWorkspaceId: input.archivedWorkspaceId, - workspaces: input.workspaces, + const archivedWorkspaceId = resolveWorkspaceRouteId({ + routeWorkspaceId: input.archivedWorkspaceId, }); - - if (!redirectWorkspaceId) { + if (!archivedWorkspaceId) { return buildHostRootRoute(input.serverId); } - return buildHostWorkspaceRoute(input.serverId, redirectWorkspaceId); + const archivedWorkspace = + Array.from(input.workspaces).find((workspace) => workspace.id === archivedWorkspaceId) ?? null; + const sourceDirectory = + archivedWorkspace?.projectRootPath || archivedWorkspace?.workspaceDirectory; + if (!sourceDirectory) { + return buildHostRootRoute(input.serverId); + } + + return buildHostNewWorkspaceRoute(input.serverId, sourceDirectory, { + displayName: archivedWorkspace.projectDisplayName, + }); }