Git panel: share checkout actions + fix push loading (#20)

* Update files

* Git panel: share checkout actions

Hoist checkout-scoped async git actions into a shared store and keep primary Push CTA loading in-place. Also stabilize e2e git flows.

* Stabilize git panel async actions and fix e2e flakes
This commit is contained in:
Mohamed Boudra
2026-02-06 13:18:30 +07:00
committed by GitHub
parent d724988542
commit cf9ff07be6
27 changed files with 858 additions and 433 deletions
+2 -16
View File
@@ -15,7 +15,7 @@ import { darkTheme } from "@/styles/theme";
import { DaemonRegistryProvider, useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { DaemonConnectionsProvider } from "@/contexts/daemon-connections-context";
import { MultiDaemonSessionHost } from "@/components/multi-daemon-session-host";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { QueryClientProvider } from "@tanstack/react-query";
import { useState, useEffect, type ReactNode, useMemo, useRef } from "react";
import { Platform } from "react-native";
import * as Linking from "expo-linking";
@@ -37,6 +37,7 @@ import { getIsTauri, getIsTauriMac } from "@/constants/layout";
import { useTrafficLightPadding } from "@/utils/tauri-window";
import { CommandCenter } from "@/components/command-center";
import { useGlobalKeyboardNav } from "@/hooks/use-global-keyboard-nav";
import { queryClient } from "@/query/query-client";
polyfillCrypto();
@@ -98,21 +99,6 @@ function PushNotificationRouter() {
}
function QueryProvider({ children }: { children: ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity,
gcTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
},
},
})
);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
@@ -222,6 +222,11 @@ export function DropdownSheet({
const bottomSheetRef = useRef<BottomSheetModal>(null);
const snapPoints = useMemo(() => ["60%", "90%"], []);
const handleClose = useCallback(() => {
bottomSheetRef.current?.dismiss();
onClose();
}, [onClose]);
useEffect(() => {
if (visible) {
bottomSheetRef.current?.present();
@@ -267,6 +272,15 @@ export function DropdownSheet({
>
<View style={styles.bottomSheetHeader}>
<Text style={styles.dropdownSheetTitle}>{title}</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel="Close sheet"
onPress={handleClose}
hitSlop={10}
testID="dropdown-sheet-close"
>
<X size={18} color={defaultTheme.colors.foregroundMuted} />
</Pressable>
</View>
<BottomSheetScrollView
contentContainerStyle={styles.dropdownSheetScrollContent}
@@ -990,11 +1004,12 @@ export function GitOptionsSection({
visible={isWorktreeSheetOpen}
onClose={() => setIsWorktreeSheetOpen(false)}
>
{worktreeOptions.map((option) => (
{worktreeOptions.map((option, index) => (
<SelectOption
key={option.path}
label={option.label}
selected={option.path === selectedWorktreePath}
testID={`worktree-attach-option-${index}`}
onPress={() => {
onSelectWorktreePath(option.path);
setIsWorktreeSheetOpen(false);
@@ -1094,6 +1109,9 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.palette.zinc[600],
},
bottomSheetHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: theme.spacing[6],
paddingBottom: theme.spacing[2],
},
@@ -1108,6 +1108,7 @@ function PermissionRequestCard({
) : null}
<Text
testID="permission-request-question"
style={[
permissionStyles.question,
{ color: theme.colors.mutedForeground },
@@ -1123,6 +1124,7 @@ function PermissionRequestCard({
]}
>
<Pressable
testID="permission-request-deny"
style={(state) => {
const hovered = Boolean((state as any).hovered);
const pressed = Boolean(state.pressed);
@@ -1163,6 +1165,7 @@ function PermissionRequestCard({
</Pressable>
<Pressable
testID="permission-request-accept"
style={(state) => {
const hovered = Boolean((state as any).hovered);
const pressed = Boolean(state.pressed);
@@ -263,37 +263,33 @@ function SidebarContent({
const { status } = useCheckoutStatusQuery({ serverId, cwd });
const isGit = status?.isGit ?? false;
// If not a git repo, only show files tab
const effectiveTab = isGit ? activeTab : "files";
return (
<View style={styles.sidebarContent} pointerEvents="auto">
{/* Header with tabs and close button */}
<View style={styles.header} testID="explorer-header">
<View style={styles.tabsContainer}>
{isGit ? (
<Pressable
style={[styles.tab, effectiveTab === "changes" && styles.tabActive]}
onPress={() => onTabPress("changes")}
>
<Text
style={[
styles.tabText,
effectiveTab === "changes" && styles.tabTextActive,
]}
>
Changes
</Text>
</Pressable>
) : null}
<Pressable
style={[styles.tab, effectiveTab === "files" && styles.tabActive]}
style={[styles.tab, activeTab === "changes" && styles.tabActive]}
onPress={() => onTabPress("changes")}
>
<Text
style={[
styles.tabText,
activeTab === "changes" && styles.tabTextActive,
!isGit && styles.tabTextMuted,
]}
>
Changes
</Text>
</Pressable>
<Pressable
style={[styles.tab, activeTab === "files" && styles.tabActive]}
onPress={() => onTabPress("files")}
>
<Text
style={[
styles.tabText,
effectiveTab === "files" && styles.tabTextActive,
activeTab === "files" && styles.tabTextActive,
]}
>
Files
@@ -311,10 +307,10 @@ function SidebarContent({
{/* Content based on active tab */}
<View style={styles.contentArea} testID="explorer-content-area">
{effectiveTab === "changes" && (
{activeTab === "changes" && (
<GitDiffPane serverId={serverId} agentId={agentId} cwd={cwd} />
)}
{effectiveTab === "files" && (
{activeTab === "files" && (
<FileExplorerPane serverId={serverId} agentId={agentId} />
)}
</View>
@@ -389,6 +385,9 @@ const styles = StyleSheet.create((theme) => ({
tabTextActive: {
color: theme.colors.foreground,
},
tabTextMuted: {
opacity: 0.8,
},
headerRightSection: {
flexDirection: "row",
alignItems: "center",
+108 -226
View File
@@ -1,5 +1,4 @@
import { useState, useCallback, useEffect, useId, useMemo, useRef, memo, type ReactElement } from "react";
import type { UseMutationResult } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import {
View,
@@ -17,8 +16,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Linking from "expo-linking";
import { Archive, ChevronDown, ChevronRight, GitBranch, MoreVertical, ArrowLeftRight, ListChevronsDownUp, ListChevronsUpDown } from "lucide-react-native";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useSessionStore } from "@/stores/session-store";
import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store";
import {
useCheckoutDiffQuery,
type ParsedDiffFile,
@@ -40,20 +38,6 @@ import {
type ActionStatus,
} from "@/components/ui/dropdown-menu";
// =============================================================================
// Action Status Hook
// =============================================================================
// Tracks mutation state with a brief success phase before returning to idle.
// State flow: idle → pending → success (1s) → idle
// =============================================================================
const SUCCESS_DISPLAY_MS = 1000;
type ActionState = {
status: ActionStatus;
trigger: () => void;
};
// =============================================================================
// Git Actions Data Structure
// =============================================================================
@@ -85,47 +69,6 @@ interface GitActions {
menu: GitAction[];
}
function useActionStatus<TData, TError, TVariables, TContext>(
mutation: UseMutationResult<TData, TError, TVariables, TContext>,
onTrigger?: () => void
): ActionState {
const [showSuccess, setShowSuccess] = useState(false);
const successTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Clear timeout on unmount
useEffect(() => {
return () => {
if (successTimeoutRef.current) {
clearTimeout(successTimeoutRef.current);
}
};
}, []);
// Watch for mutation success to trigger success display
useEffect(() => {
if (mutation.isSuccess && !mutation.isPending) {
setShowSuccess(true);
successTimeoutRef.current = setTimeout(() => {
setShowSuccess(false);
mutation.reset();
}, SUCCESS_DISPLAY_MS);
}
}, [mutation.isSuccess, mutation.isPending, mutation]);
const status: ActionStatus = mutation.isPending
? "pending"
: showSuccess
? "success"
: "idle";
const trigger = useCallback(() => {
onTrigger?.();
mutation.mutate(undefined as TVariables);
}, [mutation, onTrigger]);
return { status, trigger };
}
function openURLInNewTab(url: string): void {
if (Platform.OS === "web") {
window.open(url, "_blank", "noopener");
@@ -480,10 +423,6 @@ type GitDiffSection = {
export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
const { theme } = useUnistyles();
const router = useRouter();
const queryClient = useQueryClient();
const client = useSessionStore(
(state) => state.sessions[serverId]?.client ?? null
);
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
@@ -669,157 +608,98 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
});
}, [agentId, diffMetrics, isDiffFetching, isDiffLoading, serverId]);
const commitMutation = useMutation({
mutationFn: async () => {
if (!client) {
throw new Error("Daemon client unavailable");
}
const payload = await client.checkoutCommit(cwd, { addAll: true });
if (payload.error) {
throw new Error(payload.error.message);
}
return payload;
},
onSuccess: () => {
setActionError(null);
void refreshDiff();
void refreshStatus();
},
onError: (err) => {
const commitStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "commit" })
);
const pushStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "push" })
);
const prCreateStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "create-pr" })
);
const mergeStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "merge-branch" })
);
const mergeFromBaseStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "merge-from-base" })
);
const archiveStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "archive-worktree" })
);
const runCommit = useCheckoutGitActionsStore((state) => state.commit);
const runPush = useCheckoutGitActionsStore((state) => state.push);
const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr);
const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch);
const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase);
const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
const handleCommit = useCallback(() => {
setActionError(null);
void runCommit({ serverId, cwd }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to commit";
setActionError(message);
},
});
});
}, [runCommit, serverId, cwd]);
const prMutation = useMutation({
mutationFn: async () => {
if (!client) {
throw new Error("Daemon client unavailable");
}
const payload = await client.checkoutPrCreate(cwd, {});
if (payload.error) {
throw new Error(payload.error.message);
}
return payload;
},
onSuccess: () => {
setActionError(null);
void refreshPrStatus();
},
onError: (err) => {
const message = err instanceof Error ? err.message : "Failed to create PR";
setActionError(message);
},
});
const mergeMutation = useMutation({
mutationFn: async () => {
if (!client) {
throw new Error("Daemon client unavailable");
}
const payload = await client.checkoutMerge(cwd, {
baseRef,
strategy: "merge",
requireCleanTarget: true,
});
if (payload.error) {
throw new Error(payload.error.message);
}
return payload;
},
onSuccess: () => {
setActionError(null);
void refreshDiff();
void refreshStatus();
},
onError: (err) => {
const message = err instanceof Error ? err.message : "Failed to merge";
setActionError(message);
},
});
const mergeFromBaseMutation = useMutation({
mutationFn: async () => {
if (!client) {
throw new Error("Daemon client unavailable");
}
const payload = await client.checkoutMergeFromBase(cwd, {
baseRef,
requireCleanTarget: true,
});
if (payload.error) {
throw new Error(payload.error.message);
}
return payload;
},
onSuccess: () => {
setActionError(null);
void refreshDiff();
void refreshStatus();
},
onError: (err) => {
const message = err instanceof Error ? err.message : "Failed to merge from base";
setActionError(message);
},
});
const pushMutation = useMutation({
mutationFn: async () => {
if (!client) {
throw new Error("Daemon client unavailable");
}
const payload = await client.checkoutPush(cwd);
if (payload.error) {
throw new Error(payload.error.message);
}
return payload;
},
onSuccess: () => {
setActionError(null);
void refreshStatus();
},
onError: (err) => {
const handlePush = useCallback(() => {
setActionError(null);
void runPush({ serverId, cwd }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to push";
setActionError(message);
},
});
});
}, [runPush, serverId, cwd]);
const archiveMutation = useMutation({
mutationFn: async () => {
if (!client) {
throw new Error("Daemon client unavailable");
}
const worktreePath = status?.cwd;
if (!worktreePath) {
throw new Error("Worktree path unavailable");
}
const payload = await client.archivePaseoWorktree({ worktreePath });
if (payload.error) {
throw new Error(payload.error.message);
}
return payload;
},
onSuccess: () => {
setActionError(null);
queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === "paseoWorktreeList",
});
router.replace("/agent" as any);
},
onError: (err) => {
const message = err instanceof Error ? err.message : "Failed to archive worktree";
const handleCreatePr = useCallback(() => {
void persistShipDefault("pr");
setActionError(null);
void runCreatePr({ serverId, cwd }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to create PR";
setActionError(message);
},
});
});
}, [persistShipDefault, runCreatePr, serverId, cwd]);
// Wrap mutations with action status for UI feedback
const commitAction = useActionStatus(commitMutation);
const prCreateAction = useActionStatus(prMutation, () => void persistShipDefault("pr"));
const mergeAction = useActionStatus(mergeMutation, () => void persistShipDefault("merge"));
const mergeFromBaseAction = useActionStatus(mergeFromBaseMutation);
const pushAction = useActionStatus(pushMutation);
const archiveAction = useActionStatus(archiveMutation);
const handleMergeBranch = useCallback(() => {
if (!baseRef) {
setActionError("Base ref unavailable");
return;
}
void persistShipDefault("merge");
setActionError(null);
void runMergeBranch({ serverId, cwd, baseRef }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to merge";
setActionError(message);
});
}, [baseRef, persistShipDefault, runMergeBranch, serverId, cwd]);
const handleMergeFromBase = useCallback(() => {
if (!baseRef) {
setActionError("Base ref unavailable");
return;
}
setActionError(null);
void runMergeFromBase({ serverId, cwd, baseRef }).catch((err) => {
const message = err instanceof Error ? err.message : "Failed to merge from base";
setActionError(message);
});
}, [baseRef, runMergeFromBase, serverId, cwd]);
const handleArchiveWorktree = useCallback(() => {
const worktreePath = status?.cwd;
if (!worktreePath) {
setActionError("Worktree path unavailable");
return;
}
setActionError(null);
void runArchiveWorktree({ serverId, cwd, worktreePath })
.then(() => {
router.replace("/agent" as any);
})
.catch((err) => {
const message = err instanceof Error ? err.message : "Failed to archive worktree";
setActionError(message);
});
}, [runArchiveWorktree, router, serverId, cwd, status?.cwd]);
const renderFileBody: SectionListRenderItem<ParsedDiffFile, GitDiffSection> = useCallback(
({ item, section }) => (
@@ -861,16 +741,17 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim();
return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed;
}, [baseRef]);
const commitDisabled = actionsDisabled || commitMutation.isPending;
const prDisabled = actionsDisabled || prMutation.isPending;
const mergeDisabled = actionsDisabled || mergeMutation.isPending || hasUncommittedChanges;
const commitDisabled = actionsDisabled || commitStatus === "pending";
const prDisabled = actionsDisabled || prCreateStatus === "pending";
const mergeDisabled =
actionsDisabled || mergeStatus === "pending" || hasUncommittedChanges || !baseRef;
const mergeFromBaseDisabled =
actionsDisabled || mergeFromBaseMutation.isPending || hasUncommittedChanges;
actionsDisabled || mergeFromBaseStatus === "pending" || hasUncommittedChanges || !baseRef;
const pushDisabled =
actionsDisabled || pushMutation.isPending || !(gitStatus?.hasRemote ?? false);
actionsDisabled || pushStatus === "pending" || !(gitStatus?.hasRemote ?? false);
const archiveDisabled =
actionsDisabled ||
archiveMutation.isPending ||
archiveStatus === "pending" ||
!gitStatus?.isPaseoOwnedWorktree;
let bodyContent: ReactElement;
@@ -966,8 +847,8 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
pendingLabel: "Committing...",
successLabel: "Committed",
disabled: commitDisabled,
status: commitAction.status,
handler: commitAction.trigger,
status: commitStatus,
handler: handleCommit,
});
// Push - when has remote
@@ -978,9 +859,9 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
pendingLabel: "Pushing...",
successLabel: "Pushed",
disabled: pushDisabled,
status: pushAction.status,
status: pushStatus,
description: !hasRemote ? "No remote configured" : undefined,
handler: pushAction.trigger,
handler: handlePush,
});
}
@@ -1006,8 +887,8 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
pendingLabel: "Creating PR...",
successLabel: "PR Created",
disabled: prDisabled,
status: prCreateAction.status,
handler: prCreateAction.trigger,
status: prCreateStatus,
handler: handleCreatePr,
});
}
@@ -1019,9 +900,9 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
pendingLabel: "Merging...",
successLabel: "Merged",
disabled: mergeDisabled,
status: mergeAction.status,
status: mergeStatus,
description: hasUncommittedChanges ? "Requires clean working tree" : undefined,
handler: mergeAction.trigger,
handler: handleMergeBranch,
});
}
@@ -1033,9 +914,9 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
pendingLabel: "Updating...",
successLabel: "Updated",
disabled: mergeFromBaseDisabled,
status: mergeFromBaseAction.status,
status: mergeFromBaseStatus,
description: hasUncommittedChanges ? "Requires clean working tree" : undefined,
handler: mergeFromBaseAction.trigger,
handler: handleMergeFromBase,
});
}
@@ -1047,9 +928,9 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
pendingLabel: "Archiving...",
successLabel: "Archived",
disabled: archiveDisabled,
status: archiveAction.status,
status: archiveStatus,
icon: <Archive size={16} color={theme.colors.foregroundMuted} />,
handler: archiveAction.trigger,
handler: handleArchiveWorktree,
});
}
@@ -1061,7 +942,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
primaryActionId = "commit";
}
// Rule 2: Ahead of origin → Push
else if (aheadOfOrigin > 0 && allActions.has("push") && !pushDisabled) {
else if (aheadOfOrigin > 0 && allActions.has("push")) {
primaryActionId = "push";
}
// Rule 3: Has PR → View PR
@@ -1103,7 +984,8 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
isGit, hasRemote, hasPullRequest, prStatus?.url, aheadCount, isPaseoOwnedWorktree, isOnBaseBranch,
hasUncommittedChanges, aheadOfOrigin, shipDefault, baseRefLabel,
commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled,
commitAction, pushAction, prCreateAction, mergeAction, mergeFromBaseAction, archiveAction,
commitStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus,
handleCommit, handlePush, handleCreatePr, handleMergeBranch, handleMergeFromBase, handleArchiveWorktree,
]);
// Helper to get display label based on status
@@ -1216,7 +1098,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
) : null}
</View>
{isGit && (hasUncommittedChanges || aheadCount > 0) ? (
{isGit ? (
<View style={styles.diffStatusContainer}>
<View style={styles.diffStatusInner}>
<Pressable
+14
View File
@@ -0,0 +1,14 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity,
gcTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
},
},
});
@@ -0,0 +1,67 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useSessionStore } from "@/stores/session-store";
import {
__resetCheckoutGitActionsStoreForTests,
useCheckoutGitActionsStore,
} from "@/stores/checkout-git-actions-store";
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 };
}
describe("checkout-git-actions-store", () => {
const serverId = "server-1";
const cwd = "/tmp/repo";
beforeEach(() => {
vi.useFakeTimers();
__resetCheckoutGitActionsStoreForTests();
useSessionStore.setState((state) => ({ ...state, sessions: {} as any }));
});
afterEach(() => {
vi.useRealTimers();
__resetCheckoutGitActionsStoreForTests();
useSessionStore.setState((state) => ({ ...state, sessions: {} as any }));
});
it("shares pending state per checkout and de-dupes in-flight calls", async () => {
const deferred = createDeferred<any>();
const client = {
checkoutCommit: vi.fn(() => deferred.promise),
};
useSessionStore.setState((state) => ({
...state,
sessions: {
...(state.sessions as any),
[serverId]: { client } as any,
},
}));
const store = useCheckoutGitActionsStore.getState();
const first = store.commit({ serverId, cwd });
const second = store.commit({ serverId, cwd });
expect(client.checkoutCommit).toHaveBeenCalledTimes(1);
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");
});
});
@@ -0,0 +1,283 @@
import { create } from "zustand";
import { useSessionStore } from "@/stores/session-store";
import { queryClient } from "@/query/query-client";
const SUCCESS_DISPLAY_MS = 1000;
export type CheckoutGitActionStatus = "idle" | "pending" | "success";
export type CheckoutGitAsyncActionId =
| "commit"
| "push"
| "create-pr"
| "merge-branch"
| "merge-from-base"
| "archive-worktree";
type CheckoutKey = string;
type StatusMap = Partial<Record<CheckoutGitAsyncActionId, CheckoutGitActionStatus>>;
function checkoutKey(serverId: string, cwd: string): CheckoutKey {
return `${serverId}::${cwd}`;
}
function resolveClient(serverId: string) {
const session = useSessionStore.getState().sessions[serverId];
const client = session?.client ?? null;
if (!client) {
throw new Error("Daemon client unavailable");
}
return client;
}
function setStatus(
key: CheckoutKey,
actionId: CheckoutGitAsyncActionId,
status: CheckoutGitActionStatus
) {
useCheckoutGitActionsStore.setState((state) => {
const current = state.statusByCheckout[key]?.[actionId] ?? "idle";
if (current === status) {
return state;
}
return {
...state,
statusByCheckout: {
...state.statusByCheckout,
[key]: {
...(state.statusByCheckout[key] ?? {}),
[actionId]: status,
},
},
};
});
}
function invalidateCheckoutGitQueries(serverId: string, cwd: string) {
void queryClient.invalidateQueries({
queryKey: ["checkoutStatus", serverId, cwd],
});
void queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return (
Array.isArray(key) &&
key[0] === "checkoutDiff" &&
key[1] === serverId &&
key[2] === cwd
);
},
});
void queryClient.invalidateQueries({
predicate: (query) => {
const key = query.queryKey;
return (
Array.isArray(key) &&
key[0] === "checkoutPrStatus" &&
key[1] === serverId &&
key[2] === cwd
);
},
});
}
function invalidateWorktreeList() {
void queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === "paseoWorktreeList",
});
}
const successTimers = new Map<string, ReturnType<typeof setTimeout>>();
const inFlight = new Map<string, Promise<unknown>>();
function inFlightKey(key: CheckoutKey, actionId: CheckoutGitAsyncActionId): string {
return `${key}::${actionId}`;
}
interface CheckoutGitActionsStoreState {
statusByCheckout: Record<CheckoutKey, StatusMap>;
getStatus: (params: {
serverId: string;
cwd: string;
actionId: CheckoutGitAsyncActionId;
}) => CheckoutGitActionStatus;
commit: (params: { serverId: string; cwd: string }) => Promise<void>;
push: (params: { serverId: string; cwd: string }) => Promise<void>;
createPr: (params: { serverId: string; cwd: string }) => Promise<void>;
mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
archiveWorktree: (params: {
serverId: string;
cwd: string;
worktreePath: string;
}) => Promise<void>;
}
async function runCheckoutAction({
serverId,
cwd,
actionId,
run,
}: {
serverId: string;
cwd: string;
actionId: CheckoutGitAsyncActionId;
run: () => Promise<void>;
}): Promise<void> {
const key = checkoutKey(serverId, cwd);
const inflightId = inFlightKey(key, actionId);
const existing = inFlight.get(inflightId);
if (existing) {
await existing;
return;
}
const prevTimer = successTimers.get(inflightId);
if (prevTimer) {
clearTimeout(prevTimer);
successTimers.delete(inflightId);
}
setStatus(key, actionId, "pending");
const promise = (async () => {
try {
await run();
invalidateCheckoutGitQueries(serverId, cwd);
setStatus(key, actionId, "success");
const timer = setTimeout(() => {
setStatus(key, actionId, "idle");
successTimers.delete(inflightId);
}, SUCCESS_DISPLAY_MS);
successTimers.set(inflightId, timer);
} catch (error) {
setStatus(key, actionId, "idle");
throw error;
} finally {
inFlight.delete(inflightId);
}
})();
inFlight.set(inflightId, promise);
await promise;
}
export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()((set, get) => ({
statusByCheckout: {},
getStatus: ({ serverId, cwd, actionId }) => {
const key = checkoutKey(serverId, cwd);
return get().statusByCheckout[key]?.[actionId] ?? "idle";
},
commit: async ({ serverId, cwd }) => {
await runCheckoutAction({
serverId,
cwd,
actionId: "commit",
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutCommit(cwd, { addAll: true });
if (payload.error) {
throw new Error(payload.error.message);
}
},
});
},
push: async ({ serverId, cwd }) => {
await runCheckoutAction({
serverId,
cwd,
actionId: "push",
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutPush(cwd);
if (payload.error) {
throw new Error(payload.error.message);
}
},
});
},
createPr: async ({ serverId, cwd }) => {
await runCheckoutAction({
serverId,
cwd,
actionId: "create-pr",
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutPrCreate(cwd, {});
if (payload.error) {
throw new Error(payload.error.message);
}
},
});
},
mergeBranch: async ({ serverId, cwd, baseRef }) => {
await runCheckoutAction({
serverId,
cwd,
actionId: "merge-branch",
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutMerge(cwd, {
baseRef,
strategy: "merge",
requireCleanTarget: true,
});
if (payload.error) {
throw new Error(payload.error.message);
}
},
});
},
mergeFromBase: async ({ serverId, cwd, baseRef }) => {
await runCheckoutAction({
serverId,
cwd,
actionId: "merge-from-base",
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutMergeFromBase(cwd, {
baseRef,
requireCleanTarget: true,
});
if (payload.error) {
throw new Error(payload.error.message);
}
},
});
},
archiveWorktree: async ({ serverId, cwd, worktreePath }) => {
await runCheckoutAction({
serverId,
cwd,
actionId: "archive-worktree",
run: async () => {
const client = resolveClient(serverId);
const payload = await client.archivePaseoWorktree({ worktreePath });
if (payload.error) {
throw new Error(payload.error.message);
}
invalidateWorktreeList();
},
});
},
}));
export function __resetCheckoutGitActionsStoreForTests() {
for (const timer of successTimers.values()) {
clearTimeout(timer);
}
successTimers.clear();
inFlight.clear();
useCheckoutGitActionsStore.setState({ statusByCheckout: {} });
}