* feat(git): add manual refresh button to diff controls Adds a checkout_refresh RPC that forces a hard re-read of the git and GitHub snapshot plus the diff, bypassing polling. The diff controls now show a refresh button (feature-gated) that triggers it and surfaces errors via a toast — an escape hatch when the polled state goes stale. * refactor(git): address review — dotted RPC name + withUnistyles - Rename checkout_refresh RPC to the dotted convention (checkout.refresh.request/response) per docs/rpc-namespacing.md. - Replace the banned useUnistyles() call in DiffRefreshButton with withUnistyles wrappers for the icon/spinner color, per docs/unistyles.md. * refactor(git): move refresh into the checkout actions store Per the feature audit: the manual refresh was the one checkout action whose UI logic lived inline in the component instead of alongside its siblings (commit/pull/push) in useCheckoutGitActionsStore. Move it into the store so there's one home for "UI triggers a checkout RPC" — it now reuses runCheckoutAction's pending/success status and query invalidation. The component just reads status and shows an error toast. * fix(git): size refresh loader to the icon to stop layout shift ActivityIndicator size="small" renders ~20px regardless of platform, wider than the 14px refresh icon, so swapping to it grew the button and shifted the controls row. Use SyncedLoader at the same iconSize — it renders into a size×size box, so both states share one footprint. * fix(git): match file-explorer refresh control (RotateCw + LoadingSpinner) Mirror the file-explorer pane's refresh button exactly: RotateCw icon, LoadingSpinner while refreshing, both centered in a fixed icon-sized box so the spinner can't grow the control or shift the row. Replaces the ad-hoc RefreshCw + SyncedLoader pairing.
398 lines
13 KiB
TypeScript
398 lines
13 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { DaemonClient } from "@getpaseo/client/internal/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,
|
|
isLocalWorktreeArchivePending,
|
|
useCheckoutGitActionsStore,
|
|
} from "@/git/actions-store";
|
|
import {
|
|
clearWorkspaceArchivePending,
|
|
isWorkspaceArchivePending,
|
|
} from "@/contexts/session-workspace-upserts";
|
|
|
|
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>() {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (reason?: unknown) => void;
|
|
const promise = new Promise<T>((res, rej) => {
|
|
resolve = res;
|
|
reject = rej;
|
|
});
|
|
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",
|
|
archivingAt: input.archivingAt ?? null,
|
|
diffStat: input.diffStat ?? null,
|
|
scripts: input.scripts ?? [],
|
|
} satisfies WorkspaceDescriptor;
|
|
}
|
|
|
|
describe("checkout-git-actions-store", () => {
|
|
const serverId = "server-1";
|
|
const cwd = "/tmp/repo";
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
__resetCheckoutGitActionsStoreForTests();
|
|
clearWorkspaceArchivePending({ serverId, workspaceId: cwd });
|
|
clearWorkspaceArchivePending({ serverId, workspaceId: "ws-feature" });
|
|
appQueryClient.clear();
|
|
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
__resetCheckoutGitActionsStoreForTests();
|
|
clearWorkspaceArchivePending({ serverId, workspaceId: cwd });
|
|
clearWorkspaceArchivePending({ serverId, workspaceId: "ws-feature" });
|
|
appQueryClient.clear();
|
|
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
|
|
});
|
|
|
|
it("shares pending state per checkout and de-dupes in-flight calls", async () => {
|
|
const deferred = createDeferred<unknown>();
|
|
const client = {
|
|
checkoutCommit: vi.fn(() => deferred.promise),
|
|
};
|
|
|
|
useSessionStore.setState((state) => ({
|
|
...state,
|
|
sessions: {
|
|
...state.sessions,
|
|
[serverId]: { client } as unknown as (typeof state.sessions)[string],
|
|
},
|
|
}));
|
|
|
|
const store = useCheckoutGitActionsStore.getState();
|
|
|
|
const first = store.commit({ serverId, cwd });
|
|
const second = store.commit({ serverId, cwd });
|
|
|
|
expect(store.getStatus({ serverId, cwd, actionId: "commit" })).toBe("pending");
|
|
|
|
deferred.resolve({});
|
|
await Promise.all([first, second]);
|
|
|
|
expect(store.getStatus({ serverId, cwd, actionId: "commit" })).toBe("success");
|
|
|
|
vi.advanceTimersByTime(1000);
|
|
expect(store.getStatus({ serverId, cwd, actionId: "commit" })).toBe("idle");
|
|
});
|
|
|
|
it("runs pull then push sequentially for pull-and-push", async () => {
|
|
const order: string[] = [];
|
|
const client = {
|
|
checkoutPull: vi.fn(async () => {
|
|
order.push("pull");
|
|
return {};
|
|
}),
|
|
checkoutPush: vi.fn(async () => {
|
|
order.push("push");
|
|
return {};
|
|
}),
|
|
};
|
|
useSessionStore.setState((state) => ({
|
|
...state,
|
|
sessions: {
|
|
...state.sessions,
|
|
[serverId]: { client } as unknown as (typeof state.sessions)[string],
|
|
},
|
|
}));
|
|
|
|
await useCheckoutGitActionsStore.getState().pullAndPush({ serverId, cwd });
|
|
|
|
expect(order).toEqual(["pull", "push"]);
|
|
expect(
|
|
useCheckoutGitActionsStore.getState().getStatus({ serverId, cwd, actionId: "pull-and-push" }),
|
|
).toBe("success");
|
|
});
|
|
|
|
it("does not push when pull fails for pull-and-push", async () => {
|
|
const client = {
|
|
checkoutPull: vi.fn(async () => ({ error: { message: "pull conflict" } })),
|
|
checkoutPush: vi.fn(async () => ({})),
|
|
};
|
|
useSessionStore.setState((state) => ({
|
|
...state,
|
|
sessions: {
|
|
...state.sessions,
|
|
[serverId]: { client } as unknown as (typeof state.sessions)[string],
|
|
},
|
|
}));
|
|
|
|
await expect(
|
|
useCheckoutGitActionsStore.getState().pullAndPush({ serverId, cwd }),
|
|
).rejects.toThrow("pull conflict");
|
|
expect(
|
|
useCheckoutGitActionsStore.getState().getStatus({ serverId, cwd, actionId: "pull-and-push" }),
|
|
).toBe("idle");
|
|
});
|
|
|
|
it("surfaces push errors from pull-and-push after a successful pull", async () => {
|
|
const client = {
|
|
checkoutPull: vi.fn(async () => ({})),
|
|
checkoutPush: vi.fn(async () => ({ error: { message: "push rejected" } })),
|
|
};
|
|
useSessionStore.setState((state) => ({
|
|
...state,
|
|
sessions: {
|
|
...state.sessions,
|
|
[serverId]: { client } as unknown as (typeof state.sessions)[string],
|
|
},
|
|
}));
|
|
|
|
await expect(
|
|
useCheckoutGitActionsStore.getState().pullAndPush({ serverId, cwd }),
|
|
).rejects.toThrow("push rejected");
|
|
expect(
|
|
useCheckoutGitActionsStore.getState().getStatus({ serverId, cwd, actionId: "pull-and-push" }),
|
|
).toBe("idle");
|
|
});
|
|
|
|
it("refreshes git and GitHub state and reports success", async () => {
|
|
const client = {
|
|
checkoutRefresh: vi.fn(async () => ({ success: true, error: null })),
|
|
};
|
|
useSessionStore.setState((state) => ({
|
|
...state,
|
|
sessions: {
|
|
...state.sessions,
|
|
[serverId]: { client } as unknown as (typeof state.sessions)[string],
|
|
},
|
|
}));
|
|
|
|
await useCheckoutGitActionsStore.getState().refresh({ serverId, cwd });
|
|
|
|
expect(client.checkoutRefresh).toHaveBeenCalledWith(cwd);
|
|
expect(
|
|
useCheckoutGitActionsStore.getState().getStatus({ serverId, cwd, actionId: "refresh" }),
|
|
).toBe("success");
|
|
});
|
|
|
|
it("surfaces a refresh error and returns to idle", async () => {
|
|
const client = {
|
|
checkoutRefresh: vi.fn(async () => ({ error: { message: "not a git repository" } })),
|
|
};
|
|
useSessionStore.setState((state) => ({
|
|
...state,
|
|
sessions: {
|
|
...state.sessions,
|
|
[serverId]: { client } as unknown as (typeof state.sessions)[string],
|
|
},
|
|
}));
|
|
|
|
await expect(useCheckoutGitActionsStore.getState().refresh({ serverId, cwd })).rejects.toThrow(
|
|
"not a git repository",
|
|
);
|
|
expect(
|
|
useCheckoutGitActionsStore.getState().getStatus({ serverId, cwd, actionId: "refresh" }),
|
|
).toBe("idle");
|
|
});
|
|
|
|
it("enables PR auto-merge when the daemon advertises auto-merge actions", async () => {
|
|
const client = {
|
|
checkoutGithubSetAutoMerge: vi.fn(async () => ({
|
|
enabled: true,
|
|
success: true,
|
|
error: null,
|
|
})),
|
|
};
|
|
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
|
useSessionStore.getState().updateSessionServerInfo(serverId, {
|
|
serverId,
|
|
hostname: null,
|
|
version: null,
|
|
features: { checkoutGithubSetAutoMerge: true },
|
|
});
|
|
|
|
await useCheckoutGitActionsStore
|
|
.getState()
|
|
.enablePrAutoMerge({ serverId, cwd, method: "squash" });
|
|
|
|
expect(client.checkoutGithubSetAutoMerge).toHaveBeenCalledWith(cwd, {
|
|
enabled: true,
|
|
method: "squash",
|
|
});
|
|
expect(
|
|
useCheckoutGitActionsStore
|
|
.getState()
|
|
.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-squash" }),
|
|
).toBe("success");
|
|
});
|
|
|
|
it("disables PR auto-merge when the daemon advertises auto-merge actions", async () => {
|
|
const client = {
|
|
checkoutGithubSetAutoMerge: vi.fn(async () => ({
|
|
enabled: false,
|
|
success: true,
|
|
error: null,
|
|
})),
|
|
};
|
|
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
|
useSessionStore.getState().updateSessionServerInfo(serverId, {
|
|
serverId,
|
|
hostname: null,
|
|
version: null,
|
|
features: { checkoutGithubSetAutoMerge: true },
|
|
});
|
|
|
|
await useCheckoutGitActionsStore.getState().disablePrAutoMerge({ serverId, cwd });
|
|
|
|
expect(client.checkoutGithubSetAutoMerge).toHaveBeenCalledWith(cwd, { enabled: false });
|
|
expect(
|
|
useCheckoutGitActionsStore
|
|
.getState()
|
|
.getStatus({ serverId, cwd, actionId: "disable-pr-auto-merge" }),
|
|
).toBe("success");
|
|
});
|
|
|
|
it("does not call PR auto-merge RPCs when the daemon lacks the feature flag", async () => {
|
|
const client = {
|
|
checkoutGithubSetAutoMerge: vi.fn(async () => ({
|
|
enabled: true,
|
|
success: true,
|
|
error: null,
|
|
})),
|
|
};
|
|
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
|
useSessionStore.getState().updateSessionServerInfo(serverId, {
|
|
serverId,
|
|
hostname: null,
|
|
version: null,
|
|
features: {},
|
|
});
|
|
|
|
await expect(
|
|
useCheckoutGitActionsStore.getState().enablePrAutoMerge({ serverId, cwd, method: "merge" }),
|
|
).rejects.toThrow("Update the host to use GitHub auto-merge actions.");
|
|
|
|
expect(client.checkoutGithubSetAutoMerge).not.toHaveBeenCalled();
|
|
expect(
|
|
useCheckoutGitActionsStore
|
|
.getState()
|
|
.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-merge" }),
|
|
).toBe("idle");
|
|
});
|
|
|
|
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(useSessionStore.getState().sessions[serverId]?.workspaces.has(cwd)).toBe(false);
|
|
expect(appQueryClient.getQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"])).toEqual([
|
|
{ worktreePath: "/tmp/other" },
|
|
]);
|
|
expect(isLocalWorktreeArchivePending({ serverId, cwd })).toBe(true);
|
|
|
|
deferred.resolve({});
|
|
await archive;
|
|
|
|
expect(
|
|
isWorkspaceArchivePending({
|
|
serverId,
|
|
workspaceId: cwd,
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("hides an archived worktree when the workspace map is keyed by opaque id", async () => {
|
|
const deferred = createDeferred<Record<string, never>>();
|
|
const client = {
|
|
archivePaseoWorktree: vi.fn(() => deferred.promise),
|
|
};
|
|
const featureWorkspace = workspace({
|
|
id: "ws-feature",
|
|
name: "feature",
|
|
workspaceDirectory: cwd,
|
|
});
|
|
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
|
useSessionStore.getState().setWorkspaces(serverId, new Map([["ws-feature", featureWorkspace]]));
|
|
|
|
const archive = useCheckoutGitActionsStore
|
|
.getState()
|
|
.archiveWorktree({ serverId, cwd, worktreePath: cwd });
|
|
|
|
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has("ws-feature")).toBe(false);
|
|
|
|
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,
|
|
);
|
|
});
|
|
|
|
it("reports local archive pending only while the archive action 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]]));
|
|
|
|
const archive = useCheckoutGitActionsStore
|
|
.getState()
|
|
.archiveWorktree({ serverId, cwd, worktreePath: cwd });
|
|
|
|
expect(isLocalWorktreeArchivePending({ serverId, cwd })).toBe(true);
|
|
|
|
deferred.resolve({});
|
|
await archive;
|
|
|
|
expect(isLocalWorktreeArchivePending({ serverId, cwd })).toBe(false);
|
|
});
|
|
});
|