feat(server,app): redesign git/GitHub refresh architecture with server authority
Centralizes refresh orchestration in WorkspaceGitService and GitHubService: server coalesces, deduplicates, and throttles git/GitHub reads; clients peek snapshots (cold reads fall through to a one-shot fetch) and never drive refreshes. Introduces a two-TTL model (consumer vs internal), force-emit bypass so fingerprint-matched refreshes still propagate, notifyGitMutation wiring at ~13 action handlers, adaptive GitHub polling based on check state, and checkout_status_update / prStatus push-to-cache wire messages. Fixes the stale diff-stat bug and the inverted base-branch diff direction, and flips next-action affordances promptly after commit / PR merge.
This commit is contained in:
@@ -661,10 +661,8 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
const {
|
||||
status,
|
||||
isLoading: isStatusLoading,
|
||||
isFetching: isStatusFetching,
|
||||
isError: isStatusError,
|
||||
error: statusError,
|
||||
refresh: refreshStatus,
|
||||
} = useCheckoutStatusQuery({ serverId, cwd });
|
||||
const gitStatus = status && status.isGit ? status : null;
|
||||
const isGit = Boolean(gitStatus);
|
||||
@@ -683,10 +681,8 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
files,
|
||||
payloadError: diffPayloadError,
|
||||
isLoading: isDiffLoading,
|
||||
isFetching: isDiffFetching,
|
||||
isError: isDiffError,
|
||||
error: diffError,
|
||||
refresh: refreshDiff,
|
||||
} = useCheckoutDiffQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
@@ -699,14 +695,11 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
status: prStatus,
|
||||
githubFeaturesEnabled,
|
||||
payloadError: prPayloadError,
|
||||
refresh: refreshPrStatus,
|
||||
} = useCheckoutPrStatusQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
enabled: isGit,
|
||||
});
|
||||
// Track user-initiated refresh to avoid iOS RefreshControl animation on background fetches
|
||||
const [isManualRefresh, setIsManualRefresh] = useState(false);
|
||||
const normalizedWorkspaceRoot = useMemo(() => cwd.trim(), [cwd]);
|
||||
const workspaceStateKey = useMemo(
|
||||
() =>
|
||||
@@ -732,13 +725,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
const headerHeightByPathRef = useRef<Record<string, number>>({});
|
||||
const bodyHeightByPathRef = useRef<Record<string, number>>({});
|
||||
const defaultHeaderHeightRef = useRef<number>(44);
|
||||
const handleRefresh = useCallback(() => {
|
||||
setIsManualRefresh(true);
|
||||
void refreshDiff();
|
||||
void refreshStatus();
|
||||
void refreshPrStatus();
|
||||
}, [refreshDiff, refreshStatus, refreshPrStatus]);
|
||||
|
||||
const shipDefaultStorageKey = useMemo(() => {
|
||||
if (!gitStatus?.repoRoot) {
|
||||
return null;
|
||||
@@ -901,13 +887,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
}
|
||||
}, [allExpanded, files, setDiffExpandedPathsForWorkspace, workspaceStateKey]);
|
||||
|
||||
// Reset manual refresh flag when fetch completes
|
||||
useEffect(() => {
|
||||
if (!(isDiffFetching || isStatusFetching) && isManualRefresh) {
|
||||
setIsManualRefresh(false);
|
||||
}
|
||||
}, [isDiffFetching, isStatusFetching, isManualRefresh]);
|
||||
|
||||
// Clear diff mode override when auto mode changes (e.g., after commit)
|
||||
useEffect(() => {
|
||||
setDiffModeOverride(null);
|
||||
@@ -1199,8 +1178,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
onRefresh={handleRefresh}
|
||||
refreshing={isManualRefresh && isDiffFetching}
|
||||
// Mixed-height rows (header + potentially very large body) are prone to clipping artifacts.
|
||||
// Keep a larger render window and disable clipping to avoid bodies disappearing mid-scroll.
|
||||
removeClippedSubviews={false}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
export type CheckoutStatusRevalidationParams = {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
isOpen: boolean;
|
||||
explorerTab: string;
|
||||
};
|
||||
|
||||
export function checkoutStatusRevalidationKey(
|
||||
params: CheckoutStatusRevalidationParams,
|
||||
): string | null {
|
||||
if (!params.cwd) return null;
|
||||
if (!params.isOpen) return null;
|
||||
if (params.explorerTab !== "changes") return null;
|
||||
return `${params.serverId}:${params.cwd}`;
|
||||
}
|
||||
|
||||
export function nextCheckoutStatusRefetchDecision(
|
||||
prevKey: string | null,
|
||||
nextKey: string | null,
|
||||
): { nextSeenKey: string | null; shouldRefetch: boolean } {
|
||||
if (!nextKey) return { nextSeenKey: null, shouldRefetch: false };
|
||||
if (prevKey === nextKey) return { nextSeenKey: prevKey, shouldRefetch: false };
|
||||
return { nextSeenKey: nextKey, shouldRefetch: true };
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useId, useMemo } from "react";
|
||||
import { useEffect, useId, useMemo } from "react";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages";
|
||||
import { orderCheckoutDiffFiles } from "./checkout-diff-order";
|
||||
|
||||
const CHECKOUT_DIFF_STALE_TIME = 30_000;
|
||||
|
||||
function checkoutDiffQueryKey(
|
||||
serverId: string,
|
||||
cwd: string,
|
||||
@@ -95,7 +93,10 @@ export function useCheckoutDiffQuery({
|
||||
};
|
||||
},
|
||||
enabled: !!client && isConnected && !!cwd && enabled,
|
||||
staleTime: CHECKOUT_DIFF_STALE_TIME,
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -118,9 +119,6 @@ export function useCheckoutDiffQuery({
|
||||
let cancelled = false;
|
||||
|
||||
const unsubscribeUpdate = client.on("checkout_diff_update", (message) => {
|
||||
if (message.type !== "checkout_diff_update") {
|
||||
return;
|
||||
}
|
||||
if (message.payload.subscriptionId !== subscriptionId) {
|
||||
return;
|
||||
}
|
||||
@@ -134,9 +132,6 @@ export function useCheckoutDiffQuery({
|
||||
const unsubscribeSubscribeResponse = client.on(
|
||||
"subscribe_checkout_diff_response",
|
||||
(message) => {
|
||||
if (message.type !== "subscribe_checkout_diff_response") {
|
||||
return;
|
||||
}
|
||||
if (message.payload.subscriptionId !== subscriptionId) {
|
||||
return;
|
||||
}
|
||||
@@ -207,10 +202,6 @@ export function useCheckoutDiffQuery({
|
||||
queryClient,
|
||||
]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
return query.refetch();
|
||||
}, [query]);
|
||||
|
||||
const payload = query.data ?? null;
|
||||
const payloadError = payload?.error ?? null;
|
||||
|
||||
@@ -221,6 +212,5 @@ export function useCheckoutDiffQuery({
|
||||
isFetching: query.isFetching,
|
||||
isError: query.isError || Boolean(payloadError),
|
||||
error: query.error,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import type { CheckoutPrStatusResponse } from "@server/shared/messages";
|
||||
|
||||
const CHECKOUT_PR_STATUS_STALE_TIME = 20_000;
|
||||
const WORKSPACE_PR_HINT_REFETCH_INTERVAL = 60_000;
|
||||
|
||||
export function checkoutPrStatusQueryKey(serverId: string, cwd: string) {
|
||||
return ["checkoutPrStatus", serverId, cwd] as const;
|
||||
}
|
||||
@@ -16,6 +14,7 @@ interface UseCheckoutPrStatusQueryOptions {
|
||||
}
|
||||
|
||||
export type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
|
||||
|
||||
export interface PrHint {
|
||||
url: string;
|
||||
number: number;
|
||||
@@ -71,9 +70,24 @@ export function useCheckoutPrStatusQuery({
|
||||
cwd,
|
||||
enabled = true,
|
||||
}: UseCheckoutPrStatusQueryOptions) {
|
||||
const queryClient = useQueryClient();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !isConnected || !cwd) {
|
||||
return;
|
||||
}
|
||||
|
||||
return client.on("checkout_status_update", (message) => {
|
||||
const prStatus = message.payload.prStatus;
|
||||
if (!prStatus || prStatus.cwd !== cwd) {
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, cwd), prStatus);
|
||||
});
|
||||
}, [client, isConnected, cwd, queryClient, serverId]);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: checkoutPrStatusQueryKey(serverId, cwd),
|
||||
queryFn: async () => {
|
||||
@@ -83,8 +97,10 @@ export function useCheckoutPrStatusQuery({
|
||||
return await client.checkoutPrStatus(cwd);
|
||||
},
|
||||
enabled: !!client && isConnected && !!cwd && enabled,
|
||||
staleTime: CHECKOUT_PR_STATUS_STALE_TIME,
|
||||
refetchInterval: 15_000,
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -95,7 +111,6 @@ export function useCheckoutPrStatusQuery({
|
||||
isFetching: query.isFetching,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
refresh: query.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,9 +119,24 @@ export function useWorkspacePrHint({
|
||||
cwd,
|
||||
enabled = true,
|
||||
}: UseCheckoutPrStatusQueryOptions): PrHint | null {
|
||||
const queryClient = useQueryClient();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !isConnected || !cwd) {
|
||||
return;
|
||||
}
|
||||
|
||||
return client.on("checkout_status_update", (message) => {
|
||||
const prStatus = message.payload.prStatus;
|
||||
if (!prStatus || prStatus.cwd !== cwd) {
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, cwd), prStatus);
|
||||
});
|
||||
}, [client, isConnected, cwd, queryClient, serverId]);
|
||||
|
||||
const query = useQuery<CheckoutPrStatusPayload, Error, PrHint | null>({
|
||||
queryKey: checkoutPrStatusQueryKey(serverId, cwd),
|
||||
queryFn: async () => {
|
||||
@@ -116,8 +146,10 @@ export function useWorkspacePrHint({
|
||||
return await client.checkoutPrStatus(cwd);
|
||||
},
|
||||
enabled: !!client && isConnected && !!cwd && enabled,
|
||||
staleTime: CHECKOUT_PR_STATUS_STALE_TIME,
|
||||
refetchInterval: WORKSPACE_PR_HINT_REFETCH_INTERVAL,
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
select: selectWorkspacePrHint,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,75 +1,317 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { JSDOM } from "jsdom";
|
||||
import {
|
||||
checkoutStatusRevalidationKey,
|
||||
nextCheckoutStatusRefetchDecision,
|
||||
} from "./checkout-status-revalidation";
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
focusManager,
|
||||
onlineManager,
|
||||
} from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CheckoutStatusResponse } from "@server/shared/messages";
|
||||
import { checkoutStatusQueryKey, useCheckoutStatusQuery } from "./use-checkout-status-query";
|
||||
|
||||
type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
||||
|
||||
const { mockRuntime, mockClient, checkoutStatusUpdateHandlers } = vi.hoisted(() => {
|
||||
const checkoutStatusUpdateHandlers = new Set<(message: unknown) => void>();
|
||||
const mockClient = {
|
||||
getCheckoutStatus: vi.fn(),
|
||||
on: vi.fn((type: string, handler: (message: unknown) => void) => {
|
||||
if (type !== "checkout_status_update") {
|
||||
return () => {};
|
||||
}
|
||||
checkoutStatusUpdateHandlers.add(handler);
|
||||
return () => {
|
||||
checkoutStatusUpdateHandlers.delete(handler);
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
mockClient,
|
||||
checkoutStatusUpdateHandlers,
|
||||
mockRuntime: {
|
||||
client: mockClient,
|
||||
isConnected: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
useHostRuntimeClient: () => mockRuntime.client,
|
||||
useHostRuntimeIsConnected: () => mockRuntime.isConnected,
|
||||
}));
|
||||
|
||||
const serverId = "server-1";
|
||||
const cwd = "/repo";
|
||||
|
||||
function checkoutStatus(overrides: Partial<CheckoutStatusPayload> = {}): CheckoutStatusPayload {
|
||||
return {
|
||||
cwd,
|
||||
error: null,
|
||||
requestId: "checkout-status-1",
|
||||
isGit: true,
|
||||
isPaseoOwnedWorktree: false,
|
||||
repoRoot: cwd,
|
||||
currentBranch: "main",
|
||||
isDirty: false,
|
||||
baseRef: "origin/main",
|
||||
aheadBehind: { ahead: 0, behind: 0 },
|
||||
aheadOfOrigin: 0,
|
||||
behindOfOrigin: 0,
|
||||
hasRemote: true,
|
||||
remoteUrl: "git@github.com:getpaseo/paseo.git",
|
||||
...overrides,
|
||||
} as CheckoutStatusPayload;
|
||||
}
|
||||
|
||||
function createTestQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderCheckoutStatusHook({
|
||||
queryClient = createTestQueryClient(),
|
||||
hookCwd = cwd,
|
||||
}: {
|
||||
queryClient?: QueryClient;
|
||||
hookCwd?: string;
|
||||
} = {}) {
|
||||
let latest: ReturnType<typeof useCheckoutStatusQuery> | null = null;
|
||||
|
||||
function Probe() {
|
||||
latest = useCheckoutStatusQuery({ serverId, cwd: hookCwd });
|
||||
return null;
|
||||
}
|
||||
|
||||
const container = document.getElementById("root");
|
||||
if (!container) {
|
||||
throw new Error("Missing root container");
|
||||
}
|
||||
|
||||
const root = createRoot(container);
|
||||
|
||||
return {
|
||||
get latest() {
|
||||
if (!latest) {
|
||||
throw new Error("Expected hook result");
|
||||
}
|
||||
return latest;
|
||||
},
|
||||
async mount() {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(
|
||||
QueryClientProvider,
|
||||
{ client: queryClient },
|
||||
React.createElement(Probe),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
async unmount() {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForExpectation(assertion: () => void): Promise<void> {
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
try {
|
||||
assertion();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10);
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async function emitCheckoutStatusUpdate(payload: CheckoutStatusPayload): Promise<void> {
|
||||
await act(async () => {
|
||||
for (const handler of checkoutStatusUpdateHandlers) {
|
||||
handler({
|
||||
type: "checkout_status_update",
|
||||
payload,
|
||||
});
|
||||
}
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe("useCheckoutStatusQuery", () => {
|
||||
describe("checkoutStatusRevalidationKey", () => {
|
||||
it("returns null when sidebar is closed", () => {
|
||||
expect(
|
||||
checkoutStatusRevalidationKey({
|
||||
serverId: "daemon-1",
|
||||
cwd: "/path/to/project",
|
||||
isOpen: false,
|
||||
explorerTab: "changes",
|
||||
}),
|
||||
).toBeNull();
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id='root'></div></body></html>", {
|
||||
url: "http://localhost",
|
||||
});
|
||||
|
||||
it("returns null when tab is not changes", () => {
|
||||
expect(
|
||||
checkoutStatusRevalidationKey({
|
||||
serverId: "daemon-1",
|
||||
cwd: "/path/to/project",
|
||||
isOpen: true,
|
||||
explorerTab: "files",
|
||||
}),
|
||||
).toBeNull();
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
value: dom.window.document,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: dom.window,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: dom.window.navigator,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
it("returns a stable key when open and on changes tab", () => {
|
||||
expect(
|
||||
checkoutStatusRevalidationKey({
|
||||
serverId: "daemon-1",
|
||||
cwd: "/path/to/project",
|
||||
isOpen: true,
|
||||
explorerTab: "changes",
|
||||
}),
|
||||
).toBe("daemon-1:/path/to/project");
|
||||
});
|
||||
mockRuntime.client = mockClient;
|
||||
mockRuntime.isConnected = true;
|
||||
mockClient.getCheckoutStatus.mockReset();
|
||||
mockClient.on.mockClear();
|
||||
checkoutStatusUpdateHandlers.clear();
|
||||
});
|
||||
|
||||
describe("nextCheckoutStatusRefetchDecision", () => {
|
||||
it("refetches only once per key until reset", () => {
|
||||
const key = "daemon-1:/path/to/project";
|
||||
afterEach(() => {
|
||||
focusManager.setFocused(undefined);
|
||||
onlineManager.setOnline(true);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
expect(nextCheckoutStatusRefetchDecision(null, key)).toEqual({
|
||||
nextSeenKey: key,
|
||||
shouldRefetch: true,
|
||||
});
|
||||
it("does not schedule polling after the cold read", async () => {
|
||||
mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus());
|
||||
const hook = renderCheckoutStatusHook();
|
||||
|
||||
expect(nextCheckoutStatusRefetchDecision(key, key)).toEqual({
|
||||
nextSeenKey: key,
|
||||
shouldRefetch: false,
|
||||
});
|
||||
|
||||
expect(nextCheckoutStatusRefetchDecision(key, null)).toEqual({
|
||||
nextSeenKey: null,
|
||||
shouldRefetch: false,
|
||||
});
|
||||
|
||||
expect(nextCheckoutStatusRefetchDecision(null, key)).toEqual({
|
||||
nextSeenKey: key,
|
||||
shouldRefetch: true,
|
||||
});
|
||||
await hook.mount();
|
||||
await waitForExpectation(() => {
|
||||
expect(hook.latest.status?.currentBranch).toBe("main");
|
||||
});
|
||||
|
||||
it("refetches again when cwd changes while active", () => {
|
||||
expect(nextCheckoutStatusRefetchDecision("daemon-1:/path/a", "daemon-1:/path/b")).toEqual({
|
||||
nextSeenKey: "daemon-1:/path/b",
|
||||
shouldRefetch: true,
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(60_000);
|
||||
});
|
||||
|
||||
expect(mockClient.getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns the cached snapshot without calling the daemon", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const cached = checkoutStatus({ requestId: "cached" });
|
||||
queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), cached);
|
||||
mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus({ requestId: "uncached" }));
|
||||
const hook = renderCheckoutStatusHook({ queryClient });
|
||||
|
||||
await hook.mount();
|
||||
|
||||
expect(hook.latest.status).toEqual(cached);
|
||||
expect(mockClient.getCheckoutStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches once on a cold read and uses cache on remount", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const fetched = checkoutStatus({ requestId: "cold-read" });
|
||||
mockClient.getCheckoutStatus.mockResolvedValue(fetched);
|
||||
const firstHook = renderCheckoutStatusHook({ queryClient });
|
||||
|
||||
await firstHook.mount();
|
||||
await waitForExpectation(() => {
|
||||
expect(firstHook.latest.status).toEqual(fetched);
|
||||
});
|
||||
await firstHook.unmount();
|
||||
|
||||
const secondHook = renderCheckoutStatusHook({ queryClient });
|
||||
await secondHook.mount();
|
||||
|
||||
expect(secondHook.latest.status).toEqual(fetched);
|
||||
expect(mockClient.getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not refetch checkout status on focus or reconnect", async () => {
|
||||
mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus());
|
||||
const hook = renderCheckoutStatusHook();
|
||||
|
||||
await hook.mount();
|
||||
await waitForExpectation(() => {
|
||||
expect(hook.latest.status?.currentBranch).toBe("main");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
focusManager.setFocused(false);
|
||||
focusManager.setFocused(true);
|
||||
onlineManager.setOnline(false);
|
||||
onlineManager.setOnline(true);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mockClient.getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("server-pushed checkout status update writes into the React Query cache at checkoutStatusQueryKey(serverId, cwd)", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus({ requestId: "initial" }));
|
||||
const hook = renderCheckoutStatusHook({ queryClient });
|
||||
|
||||
await hook.mount();
|
||||
await waitForExpectation(() => {
|
||||
expect(hook.latest.status?.requestId).toBe("initial");
|
||||
});
|
||||
|
||||
mockClient.getCheckoutStatus.mockClear();
|
||||
const pushed = checkoutStatus({
|
||||
requestId: "server-push",
|
||||
currentBranch: "pushed-branch",
|
||||
isDirty: true,
|
||||
aheadBehind: { ahead: 2, behind: 1 },
|
||||
aheadOfOrigin: 2,
|
||||
behindOfOrigin: 1,
|
||||
});
|
||||
await emitCheckoutStatusUpdate(pushed);
|
||||
|
||||
expect(mockClient.getCheckoutStatus).not.toHaveBeenCalled();
|
||||
expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(pushed);
|
||||
});
|
||||
|
||||
it("server push only updates the matching cwd's cache key, not other workspaces", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const otherCwd = "/other-repo";
|
||||
const otherCached = checkoutStatus({
|
||||
cwd: otherCwd,
|
||||
repoRoot: otherCwd,
|
||||
requestId: "other-cached",
|
||||
currentBranch: "other-main",
|
||||
});
|
||||
queryClient.setQueryData(checkoutStatusQueryKey(serverId, otherCwd), otherCached);
|
||||
mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus({ requestId: "initial" }));
|
||||
const hook = renderCheckoutStatusHook({ queryClient });
|
||||
|
||||
await hook.mount();
|
||||
await waitForExpectation(() => {
|
||||
expect(hook.latest.status?.requestId).toBe("initial");
|
||||
});
|
||||
|
||||
const pushed = checkoutStatus({
|
||||
requestId: "server-push",
|
||||
currentBranch: "pushed-branch",
|
||||
});
|
||||
await emitCheckoutStatusUpdate(pushed);
|
||||
|
||||
expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(pushed);
|
||||
expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, otherCwd))).toEqual(
|
||||
otherCached,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { type QueryClient, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import type { CheckoutStatusResponse } from "@server/shared/messages";
|
||||
import {
|
||||
checkoutStatusRevalidationKey,
|
||||
nextCheckoutStatusRefetchDecision,
|
||||
} from "./checkout-status-revalidation";
|
||||
|
||||
export const CHECKOUT_STATUS_STALE_TIME = 15_000;
|
||||
|
||||
@@ -22,22 +16,56 @@ interface UseCheckoutStatusQueryOptions {
|
||||
|
||||
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
||||
|
||||
interface CheckoutStatusClient {
|
||||
getCheckoutStatus: (cwd: string) => Promise<CheckoutStatusPayload>;
|
||||
}
|
||||
|
||||
function fetchCheckoutStatus(
|
||||
client: { getCheckoutStatus: (cwd: string) => Promise<CheckoutStatusPayload> },
|
||||
client: CheckoutStatusClient,
|
||||
cwd: string,
|
||||
): Promise<CheckoutStatusPayload> {
|
||||
return client.getCheckoutStatus(cwd);
|
||||
}
|
||||
|
||||
async function peekOrFetchSnapshot({
|
||||
queryClient,
|
||||
client,
|
||||
serverId,
|
||||
cwd,
|
||||
}: {
|
||||
queryClient: QueryClient;
|
||||
client: CheckoutStatusClient;
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
}): Promise<CheckoutStatusPayload> {
|
||||
const queryKey = checkoutStatusQueryKey(serverId, cwd);
|
||||
const cached = queryClient.getQueryData<CheckoutStatusPayload>(queryKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const snapshot = await fetchCheckoutStatus(client, cwd);
|
||||
queryClient.setQueryData(queryKey, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQueryOptions) {
|
||||
const queryClient = useQueryClient();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
const explorerTab = usePanelStore((state) => state.explorerTab);
|
||||
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||
const shouldPoll = isOpen && explorerTab === "changes";
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !isConnected || !cwd) {
|
||||
return;
|
||||
}
|
||||
|
||||
return client.on("checkout_status_update", (message) => {
|
||||
if (message.payload.cwd !== cwd) {
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), message.payload);
|
||||
});
|
||||
}, [client, isConnected, cwd, queryClient, serverId]);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: checkoutStatusQueryKey(serverId, cwd),
|
||||
@@ -45,42 +73,21 @@ export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQuery
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
return await fetchCheckoutStatus(client, cwd);
|
||||
return await peekOrFetchSnapshot({ queryClient, client, serverId, cwd });
|
||||
},
|
||||
enabled: !!client && isConnected && !!cwd,
|
||||
staleTime: CHECKOUT_STATUS_STALE_TIME,
|
||||
refetchInterval: (query) => {
|
||||
if (!shouldPoll) return false;
|
||||
const data = query.state.data as CheckoutStatusPayload | undefined;
|
||||
return data?.isGit ? 10_000 : false;
|
||||
},
|
||||
refetchIntervalInBackground: shouldPoll,
|
||||
refetchOnMount: "always",
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
// Revalidate when sidebar is open with "changes" tab active.
|
||||
const revalidationKey = useMemo(
|
||||
() => checkoutStatusRevalidationKey({ serverId, cwd, isOpen, explorerTab }),
|
||||
[serverId, cwd, isOpen, explorerTab],
|
||||
);
|
||||
const lastRevalidationKey = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
const decision = nextCheckoutStatusRefetchDecision(
|
||||
lastRevalidationKey.current,
|
||||
revalidationKey,
|
||||
);
|
||||
lastRevalidationKey.current = decision.nextSeenKey;
|
||||
if (!decision.shouldRefetch) return;
|
||||
void query.refetch();
|
||||
}, [revalidationKey, query.refetch]);
|
||||
|
||||
return {
|
||||
status: query.data ?? null,
|
||||
isLoading: query.isLoading,
|
||||
isFetching: query.isFetching,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
refresh: query.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
focusManager,
|
||||
onlineManager,
|
||||
} from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
CheckoutPrStatusResponse,
|
||||
PullRequestTimelineResponse,
|
||||
@@ -18,14 +23,25 @@ type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"
|
||||
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
|
||||
type PullRequestTimelinePayload = PullRequestTimelineResponse["payload"];
|
||||
|
||||
const { mockRuntime, mockClient } = vi.hoisted(() => {
|
||||
const { mockRuntime, mockClient, checkoutStatusUpdateHandlers } = vi.hoisted(() => {
|
||||
const checkoutStatusUpdateHandlers = new Set<(message: unknown) => void>();
|
||||
const mockClient = {
|
||||
checkoutPrStatus: vi.fn(),
|
||||
pullRequestTimeline: vi.fn(),
|
||||
on: vi.fn((type: string, handler: (message: unknown) => void) => {
|
||||
if (type !== "checkout_status_update") {
|
||||
return () => {};
|
||||
}
|
||||
checkoutStatusUpdateHandlers.add(handler);
|
||||
return () => {
|
||||
checkoutStatusUpdateHandlers.delete(handler);
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
mockClient,
|
||||
checkoutStatusUpdateHandlers,
|
||||
mockRuntime: {
|
||||
client: mockClient,
|
||||
isConnected: true,
|
||||
@@ -85,6 +101,34 @@ function timelinePayload(
|
||||
};
|
||||
}
|
||||
|
||||
async function emitCheckoutStatusUpdatePrStatus(payload: CheckoutPrStatusPayload): Promise<void> {
|
||||
await act(async () => {
|
||||
for (const handler of checkoutStatusUpdateHandlers) {
|
||||
handler({
|
||||
type: "checkout_status_update",
|
||||
payload: {
|
||||
cwd: payload.cwd,
|
||||
requestId: `subscription:${payload.cwd}`,
|
||||
isGit: true,
|
||||
isPaseoOwnedWorktree: false,
|
||||
repoRoot: payload.cwd,
|
||||
currentBranch: "main",
|
||||
isDirty: false,
|
||||
baseRef: "main",
|
||||
aheadBehind: { ahead: 0, behind: 0 },
|
||||
aheadOfOrigin: 0,
|
||||
behindOfOrigin: 0,
|
||||
hasRemote: true,
|
||||
remoteUrl: "https://github.com/getpaseo/paseo.git",
|
||||
error: null,
|
||||
prStatus: payload,
|
||||
},
|
||||
});
|
||||
}
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function unsupportedTimelineError(): Error {
|
||||
const error = new Error(
|
||||
"Unknown request schema requestType=pull_request_timeline_request code=unknown_schema",
|
||||
@@ -246,6 +290,14 @@ describe("usePrPaneData", () => {
|
||||
mockRuntime.isConnected = true;
|
||||
mockClient.checkoutPrStatus.mockReset();
|
||||
mockClient.pullRequestTimeline.mockReset();
|
||||
mockClient.on.mockClear();
|
||||
checkoutStatusUpdateHandlers.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
focusManager.setFocused(undefined);
|
||||
onlineManager.setOnline(true);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns null when status has no PR number", async () => {
|
||||
@@ -349,6 +401,82 @@ describe("usePrPaneData", () => {
|
||||
expect(mockClient.checkoutPrStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("server-pushed PR status update writes into the checkout PR status cache", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
mockClient.checkoutPrStatus.mockResolvedValue(
|
||||
statusPayload({
|
||||
status: status({
|
||||
checksStatus: "pending",
|
||||
checks: [{ name: "test", status: "pending", url: "https://github.com/checks/1" }],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload());
|
||||
|
||||
const hook = renderPrPaneHook({ queryClient });
|
||||
await hook.mount();
|
||||
await waitForExpectation(() => {
|
||||
expect(hook.latest.data?.checks[0]?.status).toBe("pending");
|
||||
});
|
||||
|
||||
mockClient.checkoutPrStatus.mockClear();
|
||||
await emitCheckoutStatusUpdatePrStatus(
|
||||
statusPayload({
|
||||
requestId: "server-push",
|
||||
status: status({
|
||||
checksStatus: "success",
|
||||
checks: [{ name: "test", status: "success", url: "https://github.com/checks/1" }],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitForExpectation(() => {
|
||||
expect(hook.latest.data?.checks[0]?.status).toBe("success");
|
||||
});
|
||||
expect(queryClient.getQueryData(["checkoutPrStatus", serverId, cwd])).toEqual(
|
||||
statusPayload({
|
||||
requestId: "server-push",
|
||||
status: status({
|
||||
checksStatus: "success",
|
||||
checks: [{ name: "test", status: "success", url: "https://github.com/checks/1" }],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockClient.checkoutPrStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("server-pushed PR status for a different cwd does not overwrite the current cache", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const initial = statusPayload({
|
||||
status: status({
|
||||
checksStatus: "pending",
|
||||
checks: [{ name: "test", status: "pending", url: "https://github.com/checks/1" }],
|
||||
}),
|
||||
});
|
||||
mockClient.checkoutPrStatus.mockResolvedValue(initial);
|
||||
mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload());
|
||||
|
||||
const hook = renderPrPaneHook({ queryClient });
|
||||
await hook.mount();
|
||||
await waitForExpectation(() => {
|
||||
expect(hook.latest.data?.checks[0]?.status).toBe("pending");
|
||||
});
|
||||
|
||||
await emitCheckoutStatusUpdatePrStatus(
|
||||
statusPayload({
|
||||
cwd: "/other-repo",
|
||||
requestId: "other-server-push",
|
||||
status: status({
|
||||
checksStatus: "success",
|
||||
checks: [{ name: "test", status: "success", url: "https://github.com/checks/1" }],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(queryClient.getQueryData(["checkoutPrStatus", serverId, cwd])).toEqual(initial);
|
||||
expect(queryClient.getQueryData(["checkoutPrStatus", serverId, "/other-repo"])).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes repoOwner and repoName to the timeline request when present", async () => {
|
||||
mockClient.checkoutPrStatus.mockResolvedValue(
|
||||
statusPayload({
|
||||
@@ -517,6 +645,50 @@ describe("usePrPaneData", () => {
|
||||
expect(mockClient.pullRequestTimeline).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not poll PR status or timeline after the initial load", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockClient.checkoutPrStatus.mockResolvedValue(statusPayload());
|
||||
mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload());
|
||||
|
||||
const hook = renderPrPaneHook();
|
||||
await hook.mount();
|
||||
|
||||
await waitForHookResult(() => {
|
||||
expect(hook.latest.data?.number).toBe(42);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(120_000);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mockClient.checkoutPrStatus).toHaveBeenCalledTimes(1);
|
||||
expect(mockClient.pullRequestTimeline).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not refetch PR data on focus or reconnect", async () => {
|
||||
mockClient.checkoutPrStatus.mockResolvedValue(statusPayload());
|
||||
mockClient.pullRequestTimeline.mockResolvedValue(timelinePayload());
|
||||
|
||||
const hook = renderPrPaneHook();
|
||||
await hook.mount();
|
||||
|
||||
await waitForExpectation(() => {
|
||||
expect(hook.latest.data?.number).toBe(42);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
focusManager.setFocused(false);
|
||||
focusManager.setFocused(true);
|
||||
onlineManager.setOnline(false);
|
||||
onlineManager.setOnline(true);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mockClient.checkoutPrStatus).toHaveBeenCalledTimes(1);
|
||||
expect(mockClient.pullRequestTimeline).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reports first-load, background refresh, and non-suppressed errors", async () => {
|
||||
const statusDeferred = createDeferred<CheckoutPrStatusPayload>();
|
||||
mockClient.checkoutPrStatus.mockReturnValue(statusDeferred.promise);
|
||||
@@ -563,6 +735,25 @@ describe("usePrPaneData", () => {
|
||||
});
|
||||
});
|
||||
|
||||
async function waitForHookResult(assertion: () => void): Promise<void> {
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
try {
|
||||
assertion();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10);
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function createDeferred<T>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
|
||||
@@ -7,9 +7,6 @@ import type {
|
||||
import { mapPrPaneData, type PrPaneData } from "../utils/pr-pane-data";
|
||||
import { useCheckoutPrStatusQuery } from "./use-checkout-pr-status-query";
|
||||
|
||||
const PR_PANE_TIMELINE_STALE_TIME = 15_000;
|
||||
const PR_PANE_TIMELINE_REFETCH_INTERVAL = 30_000;
|
||||
|
||||
type CheckoutPrStatusPayloadError = CheckoutPrStatusResponse["payload"]["error"];
|
||||
type PullRequestTimeline = PullRequestTimelineResponse["payload"];
|
||||
|
||||
@@ -81,8 +78,10 @@ export function usePrPaneData({
|
||||
}
|
||||
},
|
||||
enabled: shouldFetchTimeline,
|
||||
staleTime: PR_PANE_TIMELINE_STALE_TIME,
|
||||
refetchInterval: PR_PANE_TIMELINE_REFETCH_INTERVAL,
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
placeholderData: keepPreviousData,
|
||||
retry: (failureCount, error) => !isUnsupportedTimelineError(error) && failureCount < 3,
|
||||
});
|
||||
|
||||
@@ -19,10 +19,7 @@ import { Combobox } from "@/components/ui/combobox";
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
||||
import {
|
||||
CHECKOUT_STATUS_STALE_TIME,
|
||||
checkoutStatusQueryKey,
|
||||
} from "@/hooks/use-checkout-status-query";
|
||||
import { checkoutStatusQueryKey } from "@/hooks/use-checkout-status-query";
|
||||
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { buildBranchComboOptions, normalizeBranchOptionName } from "@/utils/branch-suggestions";
|
||||
@@ -341,8 +338,10 @@ function DraftAgentScreenContent({
|
||||
},
|
||||
enabled: Boolean(trimmedWorkingDir) && canQuerySelectedHost,
|
||||
retry: false,
|
||||
staleTime: CHECKOUT_STATUS_STALE_TIME,
|
||||
refetchOnMount: "always",
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const checkout = checkoutStatusQuery.data ?? null;
|
||||
@@ -398,8 +397,10 @@ function DraftAgentScreenContent({
|
||||
enabled:
|
||||
isGitDirectory && Boolean(worktreeListRoot) && canQuerySelectedHost && !isNonGitDirectory,
|
||||
retry: false,
|
||||
staleTime: 0,
|
||||
refetchOnMount: "always",
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
const worktreeOptions = useMemo(() => {
|
||||
const options = (worktreeListQuery.data ?? []).map((worktree) => ({
|
||||
|
||||
@@ -162,6 +162,10 @@ export function NewWorkspaceScreen({
|
||||
return connectedClient.getCheckoutStatus(sourceDirectory);
|
||||
},
|
||||
enabled: isConnected && !!client,
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const currentBranch = checkoutStatusQuery.data?.currentBranch ?? null;
|
||||
|
||||
@@ -741,9 +741,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
}
|
||||
|
||||
const unsubscribeChanged = client.on("terminals_changed", (message) => {
|
||||
if (message.type !== "terminals_changed") {
|
||||
return;
|
||||
}
|
||||
if (message.payload.cwd !== workspaceDirectory) {
|
||||
return;
|
||||
}
|
||||
@@ -776,7 +773,10 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
}
|
||||
return (await client.getCheckoutStatus(workspaceDirectory)) as CheckoutStatusPayload;
|
||||
},
|
||||
staleTime: 15_000,
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
const isCheckoutStatusLoading =
|
||||
isCheckoutQueryEnabled && checkoutQuery.data === undefined && !checkoutQuery.isError;
|
||||
|
||||
Reference in New Issue
Block a user