Make worktree archive optimistic
This commit is contained in:
@@ -1109,9 +1109,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const workspace = normalizeWorkspaceDescriptor(message.payload.workspace);
|
const workspace = normalizeWorkspaceDescriptor(message.payload.workspace);
|
||||||
const existingWorkspace = useSessionStore
|
|
||||||
.getState()
|
|
||||||
.sessions[serverId]?.workspaces.get(workspace.id);
|
|
||||||
mergeWorkspaces(serverId, [workspace]);
|
mergeWorkspaces(serverId, [workspace]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { QueryClient } from "@tanstack/react-query";
|
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 { useSessionStore } from "@/stores/session-store";
|
||||||
|
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
__resetCheckoutGitActionsStoreForTests,
|
__resetCheckoutGitActionsStoreForTests,
|
||||||
invalidateCheckoutGitQueriesForClient,
|
invalidateCheckoutGitQueriesForClient,
|
||||||
useCheckoutGitActionsStore,
|
useCheckoutGitActionsStore,
|
||||||
} from "@/stores/checkout-git-actions-store";
|
} 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<T>() {
|
function createDeferred<T>() {
|
||||||
let resolve!: (value: T) => void;
|
let resolve!: (value: T) => void;
|
||||||
let reject!: (reason?: unknown) => void;
|
let reject!: (reason?: unknown) => void;
|
||||||
@@ -17,6 +28,22 @@ function createDeferred<T>() {
|
|||||||
return { promise, resolve, reject };
|
return { promise, resolve, reject };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function workspace(input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">) {
|
||||||
|
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", () => {
|
describe("checkout-git-actions-store", () => {
|
||||||
const serverId = "server-1";
|
const serverId = "server-1";
|
||||||
const cwd = "/tmp/repo";
|
const cwd = "/tmp/repo";
|
||||||
@@ -24,12 +51,14 @@ describe("checkout-git-actions-store", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
__resetCheckoutGitActionsStoreForTests();
|
__resetCheckoutGitActionsStoreForTests();
|
||||||
|
appQueryClient.clear();
|
||||||
useSessionStore.setState((state) => ({ ...state, sessions: {} as any }));
|
useSessionStore.setState((state) => ({ ...state, sessions: {} as any }));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
__resetCheckoutGitActionsStoreForTests();
|
__resetCheckoutGitActionsStoreForTests();
|
||||||
|
appQueryClient.clear();
|
||||||
useSessionStore.setState((state) => ({ ...state, sessions: {} as any }));
|
useSessionStore.setState((state) => ({ ...state, sessions: {} as any }));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,4 +118,53 @@ describe("checkout-git-actions-store", () => {
|
|||||||
|
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hides an archived worktree optimistically while the archive RPC is in flight", async () => {
|
||||||
|
const deferred = createDeferred<Record<string, never>>();
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { QueryClient } from "@tanstack/react-query";
|
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { queryClient as appQueryClient } from "@/query/query-client";
|
import { queryClient as appQueryClient } from "@/query/query-client";
|
||||||
import {
|
import {
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
useWorkspaceLayoutStore,
|
useWorkspaceLayoutStore,
|
||||||
} from "@/stores/workspace-layout-store";
|
} from "@/stores/workspace-layout-store";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
import { useWorkspaceTabsStore } from "@/stores/workspace-tabs-store";
|
import { useWorkspaceTabsStore } from "@/stores/workspace-tabs-store";
|
||||||
|
|
||||||
const SUCCESS_DISPLAY_MS = 1000;
|
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 {
|
function purgeArchivedWorkspaceState(input: { serverId: string; worktreePath: string }): void {
|
||||||
const serverId = input.serverId.trim();
|
const serverId = input.serverId.trim();
|
||||||
const workspaceId = input.worktreePath.trim();
|
const workspaceId = input.worktreePath.trim();
|
||||||
@@ -353,11 +406,18 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
|
|||||||
actionId: "archive-worktree",
|
actionId: "archive-worktree",
|
||||||
run: async () => {
|
run: async () => {
|
||||||
const client = resolveClient(serverId);
|
const client = resolveClient(serverId);
|
||||||
const payload = await client.archivePaseoWorktree({ worktreePath });
|
const snapshot = snapshotWorktreeArchiveState({ serverId, worktreePath });
|
||||||
if (payload.error) {
|
|
||||||
throw new Error(payload.error.message);
|
|
||||||
}
|
|
||||||
removeWorktreeFromCachedLists({ 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();
|
invalidateWorktreeList();
|
||||||
purgeArchivedWorkspaceState({ serverId, worktreePath });
|
purgeArchivedWorkspaceState({ serverId, worktreePath });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import type { DaemonClient } from "@server/client/daemon-client";
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
|
||||||
buildWorkspaceArchiveRedirectRoute,
|
|
||||||
resolveWorkspaceArchiveRedirectWorkspaceId,
|
|
||||||
} from "@/utils/workspace-archive-navigation";
|
|
||||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
@@ -40,22 +37,23 @@ function workspace(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("resolveWorkspaceArchiveRedirectWorkspaceId", () => {
|
describe("buildWorkspaceArchiveRedirectRoute", () => {
|
||||||
it("redirects an archived worktree to the visible local checkout for the same project", () => {
|
it("redirects an archived worktree to the new workspace screen for the same project", () => {
|
||||||
const workspaces = [
|
const workspaces = [
|
||||||
workspace({ id: "/repo", workspaceKind: "checkout", name: "main" }),
|
workspace({ id: "/repo", workspaceKind: "checkout", name: "main" }),
|
||||||
workspace({ id: "/repo/.paseo/worktrees/feature", name: "feature" }),
|
workspace({ id: "/repo/.paseo/worktrees/feature", name: "feature" }),
|
||||||
];
|
];
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
resolveWorkspaceArchiveRedirectWorkspaceId({
|
buildWorkspaceArchiveRedirectRoute({
|
||||||
|
serverId: "server-1",
|
||||||
archivedWorkspaceId: "/repo/.paseo/worktrees/feature",
|
archivedWorkspaceId: "/repo/.paseo/worktrees/feature",
|
||||||
workspaces,
|
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 = [
|
const workspaces = [
|
||||||
workspace({
|
workspace({
|
||||||
id: "/repo/.paseo/worktrees/feature",
|
id: "/repo/.paseo/worktrees/feature",
|
||||||
@@ -70,10 +68,10 @@ describe("resolveWorkspaceArchiveRedirectWorkspaceId", () => {
|
|||||||
archivedWorkspaceId: "/repo/.paseo/worktrees/feature",
|
archivedWorkspaceId: "/repo/.paseo/worktrees/feature",
|
||||||
workspaces,
|
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 = [
|
const workspaces = [
|
||||||
workspace({
|
workspace({
|
||||||
id: "/notes",
|
id: "/notes",
|
||||||
@@ -90,7 +88,7 @@ describe("resolveWorkspaceArchiveRedirectWorkspaceId", () => {
|
|||||||
archivedWorkspaceId: "/notes",
|
archivedWorkspaceId: "/notes",
|
||||||
workspaces,
|
workspaces,
|
||||||
}),
|
}),
|
||||||
).toBe("/h/server-1");
|
).toBe("/h/server-1/new?dir=%2Fnotes&name=Project");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -140,6 +138,6 @@ describe("redirectIfArchivingActiveWorkspace", () => {
|
|||||||
}),
|
}),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
|
||||||
expect(replaceMock).toHaveBeenCalledWith("/h/server-1/workspace/main");
|
expect(replaceMock).toHaveBeenCalledWith("/h/server-1/new?dir=%2Frepo&name=Project");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,55 +1,28 @@
|
|||||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
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";
|
import { resolveWorkspaceRouteId } from "@/utils/workspace-execution";
|
||||||
|
|
||||||
export function resolveWorkspaceArchiveRedirectWorkspaceId(input: {
|
|
||||||
archivedWorkspaceId: string;
|
|
||||||
workspaces: Iterable<WorkspaceDescriptor>;
|
|
||||||
}): 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: {
|
export function buildWorkspaceArchiveRedirectRoute(input: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
archivedWorkspaceId: string;
|
archivedWorkspaceId: string;
|
||||||
workspaces: Iterable<WorkspaceDescriptor>;
|
workspaces: Iterable<WorkspaceDescriptor>;
|
||||||
}) {
|
}) {
|
||||||
const redirectWorkspaceId = resolveWorkspaceArchiveRedirectWorkspaceId({
|
const archivedWorkspaceId = resolveWorkspaceRouteId({
|
||||||
archivedWorkspaceId: input.archivedWorkspaceId,
|
routeWorkspaceId: input.archivedWorkspaceId,
|
||||||
workspaces: input.workspaces,
|
|
||||||
});
|
});
|
||||||
|
if (!archivedWorkspaceId) {
|
||||||
if (!redirectWorkspaceId) {
|
|
||||||
return buildHostRootRoute(input.serverId);
|
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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user