mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Keep git review state fresh after reconnect (#1491)
This commit is contained in:
@@ -69,6 +69,7 @@ import {
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { applyCheckoutStatusUpdateFromEvent } from "@/git/checkout-status-cache";
|
||||
|
||||
// Re-export types from session-store and draft-store for backward compatibility
|
||||
export type { DraftInput } from "@/stores/draft-store";
|
||||
@@ -1300,6 +1301,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
setWorkspaces(serverId, (prev) => patchWorkspaceScripts(prev, message.payload));
|
||||
});
|
||||
|
||||
const unsubCheckoutStatusUpdate = client.on("checkout_status_update", (message) => {
|
||||
if (message.type !== "checkout_status_update") return;
|
||||
applyCheckoutStatusUpdateFromEvent({ queryClient, serverId, message });
|
||||
});
|
||||
|
||||
const unsubWorkspaceSetupProgress = client.on("workspace_setup_progress", (message) => {
|
||||
if (message.type !== "workspace_setup_progress") return;
|
||||
applyWorkspaceSetupProgress(message.payload);
|
||||
@@ -1648,6 +1654,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
unsubAgentTimeline();
|
||||
unsubWorkspaceUpdate();
|
||||
unsubScriptStatusUpdate();
|
||||
unsubCheckoutStatusUpdate();
|
||||
unsubWorkspaceSetupProgress();
|
||||
unsubWorkspaceSetupStatusResponse();
|
||||
unsubStatus();
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
// @vitest-environment jsdom
|
||||
// The review draft store persists through AsyncStorage's web shim, which needs window.
|
||||
import "@/test/window-local-storage";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CheckoutStatusUpdate } from "@getpaseo/protocol/messages";
|
||||
import { checkoutStatusQueryKey } from "@/git/query-keys";
|
||||
import { checkoutPrStatusQueryKey, checkoutStatusQueryKey } from "@/git/query-keys";
|
||||
import { prPaneTimelineQueryKey } from "@/git/pull-request-panel/query-keys";
|
||||
import { resetReviewDraftStore, useReviewDraftStore } from "@/review/store";
|
||||
import {
|
||||
applyCheckoutStatusUpdate,
|
||||
applyCheckoutStatusUpdateFromEvent,
|
||||
type CheckoutPrStatusPayload,
|
||||
type CheckoutStatusPayload,
|
||||
peekOrFetchCheckoutStatus,
|
||||
fetchCheckoutStatus,
|
||||
} from "./checkout-status-cache";
|
||||
|
||||
const serverId = "server-1";
|
||||
@@ -31,94 +37,179 @@ function checkoutStatus(overrides: Partial<CheckoutStatusPayload> = {}): Checkou
|
||||
} as CheckoutStatusPayload;
|
||||
}
|
||||
|
||||
function checkoutStatusUpdate(payload: CheckoutStatusPayload): CheckoutStatusUpdate {
|
||||
return { type: "checkout_status_update", payload };
|
||||
function prStatus(overrides: Partial<CheckoutPrStatusPayload> = {}): CheckoutPrStatusPayload {
|
||||
return {
|
||||
cwd,
|
||||
status: {
|
||||
url: "https://github.com/getpaseo/paseo/pull/42",
|
||||
title: "My PR",
|
||||
state: "open",
|
||||
baseRefName: "main",
|
||||
headRefName: "feature",
|
||||
isMerged: false,
|
||||
isDraft: false,
|
||||
mergeable: "MERGEABLE",
|
||||
checks: [],
|
||||
checksStatus: "success",
|
||||
reviewDecision: null,
|
||||
},
|
||||
githubFeaturesEnabled: true,
|
||||
error: null,
|
||||
requestId: "pr-status-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function checkoutStatusUpdate(
|
||||
payload: CheckoutStatusPayload,
|
||||
extraPrStatus?: CheckoutPrStatusPayload,
|
||||
): CheckoutStatusUpdate {
|
||||
return {
|
||||
type: "checkout_status_update",
|
||||
payload: extraPrStatus ? { ...payload, prStatus: extraPrStatus } : payload,
|
||||
};
|
||||
}
|
||||
|
||||
function setDiffModeOverride(isDirtyAtSelection: boolean): void {
|
||||
useReviewDraftStore.getState().setDiffModeOverride({
|
||||
scopeKey: "review:scope",
|
||||
override: { serverId, cwd, mode: "base", isDirtyAtSelection },
|
||||
});
|
||||
}
|
||||
|
||||
function createQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
}
|
||||
|
||||
describe("peekOrFetchCheckoutStatus", () => {
|
||||
it("fetches from the client and writes the result to the cache on a cold read", async () => {
|
||||
const queryClient = createQueryClient();
|
||||
const fetched = checkoutStatus({ requestId: "cold-read" });
|
||||
beforeEach(() => {
|
||||
resetReviewDraftStore();
|
||||
});
|
||||
|
||||
describe("fetchCheckoutStatus", () => {
|
||||
it("fetches from the client and returns the payload", async () => {
|
||||
const fetched = checkoutStatus({ requestId: "fetch-1" });
|
||||
const client = { getCheckoutStatus: vi.fn(async () => fetched) };
|
||||
|
||||
const result = await peekOrFetchCheckoutStatus({ queryClient, client, serverId, cwd });
|
||||
const result = await fetchCheckoutStatus({ client, serverId, cwd });
|
||||
|
||||
expect(result).toEqual(fetched);
|
||||
expect(client.getCheckoutStatus).toHaveBeenCalledExactlyOnceWith(cwd);
|
||||
expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(fetched);
|
||||
});
|
||||
|
||||
it("returns the cached snapshot without calling the client when the cache already has data", async () => {
|
||||
const queryClient = createQueryClient();
|
||||
const cached = checkoutStatus({ requestId: "cached" });
|
||||
queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), cached);
|
||||
const client = {
|
||||
getCheckoutStatus: vi.fn(async () => checkoutStatus({ requestId: "uncached" })),
|
||||
};
|
||||
it("expires a manual diff-mode override when the fetched dirty state flipped", async () => {
|
||||
setDiffModeOverride(true);
|
||||
const client = { getCheckoutStatus: vi.fn(async () => checkoutStatus({ isDirty: false })) };
|
||||
|
||||
const result = await peekOrFetchCheckoutStatus({ queryClient, client, serverId, cwd });
|
||||
await fetchCheckoutStatus({ client, serverId, cwd });
|
||||
|
||||
expect(result).toEqual(cached);
|
||||
expect(client.getCheckoutStatus).not.toHaveBeenCalled();
|
||||
expect(useReviewDraftStore.getState().diffModeOverrides["review:scope"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyCheckoutStatusUpdate", () => {
|
||||
it("writes the pushed payload into the cache key for the matching cwd", () => {
|
||||
describe("applyCheckoutStatusUpdateFromEvent", () => {
|
||||
it("writes the checkout status to the cache using the cwd from the payload", () => {
|
||||
const queryClient = createQueryClient();
|
||||
const pushed = checkoutStatus({
|
||||
requestId: "server-push",
|
||||
currentBranch: "pushed-branch",
|
||||
isDirty: true,
|
||||
aheadBehind: { ahead: 2, behind: 1 },
|
||||
aheadOfOrigin: 2,
|
||||
behindOfOrigin: 1,
|
||||
});
|
||||
const pushed = checkoutStatus({ requestId: "push-1", isDirty: true });
|
||||
|
||||
applyCheckoutStatusUpdate({
|
||||
applyCheckoutStatusUpdateFromEvent({
|
||||
queryClient,
|
||||
serverId,
|
||||
cwd,
|
||||
message: checkoutStatusUpdate(pushed),
|
||||
});
|
||||
|
||||
expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(pushed);
|
||||
});
|
||||
|
||||
it("ignores updates whose payload cwd does not match the subscribed cwd", () => {
|
||||
it("writes the PR status cache when prStatus is present, and skips it otherwise", () => {
|
||||
const queryClient = createQueryClient();
|
||||
const otherCwd = "/other-repo";
|
||||
const otherCached = checkoutStatus({
|
||||
cwd: otherCwd,
|
||||
repoRoot: otherCwd,
|
||||
requestId: "other-cached",
|
||||
currentBranch: "other-main",
|
||||
});
|
||||
queryClient.setQueryData(checkoutStatusQueryKey(serverId, otherCwd), otherCached);
|
||||
const pushedPr = prStatus({ requestId: "pr-1" });
|
||||
|
||||
applyCheckoutStatusUpdate({
|
||||
applyCheckoutStatusUpdateFromEvent({
|
||||
queryClient,
|
||||
serverId,
|
||||
message: checkoutStatusUpdate(checkoutStatus(), pushedPr),
|
||||
});
|
||||
expect(queryClient.getQueryData(checkoutPrStatusQueryKey(serverId, cwd))).toEqual(pushedPr);
|
||||
|
||||
const otherCwd = "/repo2";
|
||||
applyCheckoutStatusUpdateFromEvent({
|
||||
queryClient,
|
||||
serverId,
|
||||
message: checkoutStatusUpdate(checkoutStatus({ cwd: otherCwd, repoRoot: otherCwd })),
|
||||
});
|
||||
expect(queryClient.getQueryData(checkoutPrStatusQueryKey(serverId, otherCwd))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("expires a manual diff-mode override when the pushed dirty state flipped", () => {
|
||||
const queryClient = createQueryClient();
|
||||
setDiffModeOverride(false);
|
||||
|
||||
applyCheckoutStatusUpdateFromEvent({
|
||||
queryClient,
|
||||
serverId,
|
||||
message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })),
|
||||
});
|
||||
|
||||
expect(useReviewDraftStore.getState().diffModeOverrides["review:scope"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps a manual diff-mode override while the pushed dirty state still matches", () => {
|
||||
const queryClient = createQueryClient();
|
||||
setDiffModeOverride(true);
|
||||
|
||||
applyCheckoutStatusUpdateFromEvent({
|
||||
queryClient,
|
||||
serverId,
|
||||
message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })),
|
||||
});
|
||||
|
||||
expect(useReviewDraftStore.getState().diffModeOverrides["review:scope"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("invalidates the PR timeline when the prStatus changes, ignoring the volatile requestId", () => {
|
||||
const queryClient = createQueryClient();
|
||||
queryClient.setQueryData(
|
||||
checkoutPrStatusQueryKey(serverId, cwd),
|
||||
prStatus({ requestId: "pr-v1" }),
|
||||
);
|
||||
const timelineKey = prPaneTimelineQueryKey({ serverId, cwd, prNumber: 42 });
|
||||
queryClient.setQueryData(timelineKey, { items: [] });
|
||||
|
||||
applyCheckoutStatusUpdateFromEvent({
|
||||
queryClient,
|
||||
serverId,
|
||||
message: checkoutStatusUpdate(checkoutStatus(), prStatus({ requestId: "pr-v2" })),
|
||||
});
|
||||
expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(false);
|
||||
|
||||
applyCheckoutStatusUpdateFromEvent({
|
||||
queryClient,
|
||||
serverId,
|
||||
cwd,
|
||||
message: checkoutStatusUpdate(
|
||||
checkoutStatus({
|
||||
cwd: otherCwd,
|
||||
repoRoot: otherCwd,
|
||||
requestId: "server-push",
|
||||
currentBranch: "pushed-branch",
|
||||
checkoutStatus(),
|
||||
prStatus({
|
||||
requestId: "pr-v3",
|
||||
status: { ...prStatus().status!, state: "closed" },
|
||||
}),
|
||||
),
|
||||
});
|
||||
expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(true);
|
||||
});
|
||||
|
||||
expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toBeUndefined();
|
||||
expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, otherCwd))).toEqual(
|
||||
otherCached,
|
||||
);
|
||||
it("invalidates the PR timeline on the first prStatus emission, scoped to its cwd", () => {
|
||||
const queryClient = createQueryClient();
|
||||
const timelineKey = prPaneTimelineQueryKey({ serverId, cwd, prNumber: 42 });
|
||||
const otherTimelineKey = prPaneTimelineQueryKey({ serverId, cwd: "/repo2", prNumber: 42 });
|
||||
queryClient.setQueryData(timelineKey, { items: [] });
|
||||
queryClient.setQueryData(otherTimelineKey, { items: [] });
|
||||
|
||||
applyCheckoutStatusUpdateFromEvent({
|
||||
queryClient,
|
||||
serverId,
|
||||
message: checkoutStatusUpdate(checkoutStatus(), prStatus()),
|
||||
});
|
||||
|
||||
expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(true);
|
||||
expect(queryClient.getQueryState(otherTimelineKey)?.isInvalidated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,48 +1,86 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import type { CheckoutStatusResponse, CheckoutStatusUpdate } from "@getpaseo/protocol/messages";
|
||||
import { checkoutStatusQueryKey } from "@/git/query-keys";
|
||||
import equal from "fast-deep-equal/es6";
|
||||
import {
|
||||
checkoutPrStatusQueryKey,
|
||||
checkoutStatusQueryKey,
|
||||
invalidatePrPaneTimelineForCheckout,
|
||||
} from "@/git/query-keys";
|
||||
import { expireStaleDiffModeOverrides } from "@/review/store";
|
||||
|
||||
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
||||
export type CheckoutPrStatusPayload = NonNullable<CheckoutStatusUpdate["payload"]["prStatus"]>;
|
||||
|
||||
export interface CheckoutStatusClient {
|
||||
getCheckoutStatus: (cwd: string) => Promise<CheckoutStatusPayload>;
|
||||
}
|
||||
|
||||
export async function peekOrFetchCheckoutStatus({
|
||||
queryClient,
|
||||
// Checkout status enters the app through exactly two doors: daemon pushes
|
||||
// (applyCheckoutStatusUpdateFromEvent) and query fetches (fetchCheckoutStatus). Both run
|
||||
// the dirty-state reactions, so they hold regardless of which screens are mounted.
|
||||
|
||||
export async function fetchCheckoutStatus({
|
||||
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 client.getCheckoutStatus(cwd);
|
||||
queryClient.setQueryData(queryKey, snapshot);
|
||||
return snapshot;
|
||||
const payload = await client.getCheckoutStatus(cwd);
|
||||
expireStaleDiffModeOverrides({ serverId, cwd, isDirty: payload.isGit && payload.isDirty });
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function applyCheckoutStatusUpdate({
|
||||
export function applyCheckoutStatusUpdateFromEvent({
|
||||
queryClient,
|
||||
serverId,
|
||||
cwd,
|
||||
message,
|
||||
}: {
|
||||
queryClient: QueryClient;
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
message: CheckoutStatusUpdate;
|
||||
}): void {
|
||||
if (message.payload.cwd !== cwd) {
|
||||
const { payload } = message;
|
||||
queryClient.setQueryData(checkoutStatusQueryKey(serverId, payload.cwd), payload);
|
||||
expireStaleDiffModeOverrides({
|
||||
serverId,
|
||||
cwd: payload.cwd,
|
||||
isDirty: payload.isGit && payload.isDirty,
|
||||
});
|
||||
|
||||
const prStatus = payload.prStatus;
|
||||
if (!prStatus) {
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), message.payload);
|
||||
|
||||
const previous = queryClient.getQueryData<CheckoutPrStatusPayload>(
|
||||
checkoutPrStatusQueryKey(serverId, prStatus.cwd),
|
||||
);
|
||||
queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, prStatus.cwd), prStatus);
|
||||
|
||||
// The PR activity timeline has no push channel; mark it stale when the pushed PR status
|
||||
// meaningfully changed. Active panes refetch immediately, evicted ones on next mount.
|
||||
if (hasPrStatusChanged(previous, prStatus)) {
|
||||
void invalidatePrPaneTimelineForCheckout(queryClient, { serverId, cwd: prStatus.cwd });
|
||||
}
|
||||
}
|
||||
|
||||
// requestId changes on every emission and carries no PR state.
|
||||
function prStatusWithoutVolatileFields(
|
||||
prStatus: CheckoutPrStatusPayload,
|
||||
): Omit<CheckoutPrStatusPayload, "requestId"> {
|
||||
const { requestId: _requestId, ...rest } = prStatus;
|
||||
return rest;
|
||||
}
|
||||
|
||||
function hasPrStatusChanged(
|
||||
previous: CheckoutPrStatusPayload | undefined,
|
||||
next: CheckoutPrStatusPayload,
|
||||
): boolean {
|
||||
if (!previous) {
|
||||
return true;
|
||||
}
|
||||
return !equal(prStatusWithoutVolatileFields(previous), prStatusWithoutVolatileFields(next));
|
||||
}
|
||||
|
||||
@@ -101,11 +101,10 @@ import {
|
||||
import {
|
||||
buildReviewDraftScopeKey,
|
||||
buildReviewDraftKey,
|
||||
useActiveReviewDraftMode,
|
||||
useReviewAttachmentSnapshot,
|
||||
useSetActiveReviewDraftMode,
|
||||
useResolvedDiffMode,
|
||||
useSetDiffModeOverride,
|
||||
type ReviewDraftComment,
|
||||
type ReviewDraftMode,
|
||||
getInlineReviewThreadState,
|
||||
getSplitInlineReviewThreadState,
|
||||
InlineReviewGutterCell,
|
||||
@@ -1593,7 +1592,6 @@ export function GitDiffPane({
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const showDesktopWebScrollbar = isWeb && !isMobile;
|
||||
const canUseSplitLayout = isWeb && !isMobile;
|
||||
const [diffModeOverride, setDiffModeOverride] = useState<ReviewDraftMode | null>(null);
|
||||
const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } =
|
||||
useChangesPreferences();
|
||||
const wrapLines = changesPreferences.wrapLines;
|
||||
@@ -1614,9 +1612,6 @@ export function GitDiffPane({
|
||||
void updateChangesPreferences({ hideWhitespace: !changesPreferences.hideWhitespace });
|
||||
}, [changesPreferences.hideWhitespace, updateChangesPreferences]);
|
||||
|
||||
// handleSelectUncommitted/handleSelectBase are defined later, after reviewDraftScopeKey
|
||||
// and setActiveReviewMode are available, so they can record the active review mode.
|
||||
|
||||
const handleLayoutUnified = useCallback(() => {
|
||||
handleLayoutChange("unified");
|
||||
}, [handleLayoutChange]);
|
||||
@@ -1691,8 +1686,6 @@ export function GitDiffPane({
|
||||
const statusState = deriveStatusState({ status, isStatusLoading, isStatusError, statusError });
|
||||
const { isGit, notGit, statusErrorMessage, baseRef, hasUncommittedChanges } = statusState;
|
||||
|
||||
// Auto-select diff mode based on state: uncommitted when dirty, base when clean
|
||||
const autoDiffMode: ReviewDraftMode = hasUncommittedChanges ? "uncommitted" : "base";
|
||||
const reviewDraftScopeKey = useMemo(
|
||||
() =>
|
||||
buildReviewDraftScopeKey({
|
||||
@@ -1704,8 +1697,11 @@ export function GitDiffPane({
|
||||
}),
|
||||
[baseRef, changesPreferences.hideWhitespace, cwd, serverId, workspaceId],
|
||||
);
|
||||
const activeReviewMode = useActiveReviewDraftMode({ scopeKey: reviewDraftScopeKey });
|
||||
const diffMode = diffModeOverride ?? activeReviewMode ?? autoDiffMode;
|
||||
const diffMode = useResolvedDiffMode({
|
||||
scopeKey: reviewDraftScopeKey,
|
||||
hasUncommittedChanges,
|
||||
});
|
||||
const setDiffModeOverride = useSetDiffModeOverride();
|
||||
|
||||
const {
|
||||
files,
|
||||
@@ -1731,17 +1727,20 @@ export function GitDiffPane({
|
||||
}),
|
||||
[baseRef, changesPreferences.hideWhitespace, cwd, diffMode, serverId, workspaceId],
|
||||
);
|
||||
const setActiveReviewMode = useSetActiveReviewDraftMode();
|
||||
|
||||
const handleSelectUncommitted = useCallback(() => {
|
||||
setDiffModeOverride("uncommitted");
|
||||
setActiveReviewMode({ scopeKey: reviewDraftScopeKey, mode: "uncommitted" });
|
||||
}, [reviewDraftScopeKey, setActiveReviewMode]);
|
||||
setDiffModeOverride({
|
||||
scopeKey: reviewDraftScopeKey,
|
||||
override: { serverId, cwd, mode: "uncommitted", isDirtyAtSelection: hasUncommittedChanges },
|
||||
});
|
||||
}, [cwd, hasUncommittedChanges, reviewDraftScopeKey, serverId, setDiffModeOverride]);
|
||||
|
||||
const handleSelectBase = useCallback(() => {
|
||||
setDiffModeOverride("base");
|
||||
setActiveReviewMode({ scopeKey: reviewDraftScopeKey, mode: "base" });
|
||||
}, [reviewDraftScopeKey, setActiveReviewMode]);
|
||||
setDiffModeOverride({
|
||||
scopeKey: reviewDraftScopeKey,
|
||||
override: { serverId, cwd, mode: "base", isDirtyAtSelection: hasUncommittedChanges },
|
||||
});
|
||||
}, [cwd, hasUncommittedChanges, reviewDraftScopeKey, serverId, setDiffModeOverride]);
|
||||
|
||||
const reviewActions = useInlineReviewController({
|
||||
reviewDraftKey,
|
||||
@@ -1994,11 +1993,6 @@ export function GitDiffPane({
|
||||
}
|
||||
}, [allExpanded, files, setDiffExpandedPathsForWorkspace, workspaceStateKey]);
|
||||
|
||||
// Clear diff mode override when auto mode changes (e.g., after commit)
|
||||
useEffect(() => {
|
||||
setDiffModeOverride(null);
|
||||
}, [autoDiffMode]);
|
||||
|
||||
const renderFlatItem = useCallback(
|
||||
({ item }: { item: DiffFlatItem }) => {
|
||||
if (item.type === "header") {
|
||||
|
||||
@@ -244,7 +244,9 @@ export function usePrPaneData({
|
||||
},
|
||||
enabled: shouldFetchTimeline,
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
// Refetch on mount only after explicit invalidation (reconnect, or a pushed PR status
|
||||
// change) — see useCheckoutStatusQuery for the rationale.
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
placeholderData: keepPreviousData,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
checkoutPrStatusQueryKey,
|
||||
checkoutStatusQueryKey,
|
||||
invalidateCheckoutGitQueriesForClient,
|
||||
invalidateCheckoutGitQueriesForServer,
|
||||
} from "@/git/query-keys";
|
||||
import { prPaneTimelineQueryKey } from "@/git/pull-request-panel/query-keys";
|
||||
|
||||
@@ -59,4 +60,47 @@ describe("checkout query keys", () => {
|
||||
|
||||
queryClient.clear();
|
||||
});
|
||||
|
||||
it("invalidates fetch-based checkout queries server-wide without touching other servers", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
const otherServerId = "server-2";
|
||||
const otherCwd = "/tmp/repo-2";
|
||||
|
||||
queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), { isGit: true });
|
||||
queryClient.setQueryData(checkoutStatusQueryKey(serverId, otherCwd), { isGit: true });
|
||||
queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, cwd), { status: { number: 12 } });
|
||||
queryClient.setQueryData(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }), {
|
||||
items: [],
|
||||
});
|
||||
// Subscription-fed diff queries are deliberately not part of the server-wide sweep.
|
||||
queryClient.setQueryData(checkoutDiffQueryKey(serverId, cwd, "base", "main", true), {
|
||||
files: [],
|
||||
});
|
||||
queryClient.setQueryData(checkoutStatusQueryKey(otherServerId, cwd), { isGit: true });
|
||||
|
||||
await invalidateCheckoutGitQueriesForServer(queryClient, serverId);
|
||||
|
||||
expect(queryClient.getQueryState(checkoutStatusQueryKey(serverId, cwd))?.isInvalidated).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
queryClient.getQueryState(checkoutStatusQueryKey(serverId, otherCwd))?.isInvalidated,
|
||||
).toBe(true);
|
||||
expect(queryClient.getQueryState(checkoutPrStatusQueryKey(serverId, cwd))?.isInvalidated).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }))
|
||||
?.isInvalidated,
|
||||
).toBe(true);
|
||||
expect(
|
||||
queryClient.getQueryState(checkoutDiffQueryKey(serverId, cwd, "base", "main", true))
|
||||
?.isInvalidated,
|
||||
).toBe(false);
|
||||
expect(
|
||||
queryClient.getQueryState(checkoutStatusQueryKey(otherServerId, cwd))?.isInvalidated,
|
||||
).toBe(false);
|
||||
|
||||
queryClient.clear();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,11 @@ interface CheckoutQueryIdentity {
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
interface CheckoutQueryScope {
|
||||
serverId: string;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
type CheckoutQueryKey = readonly unknown[];
|
||||
|
||||
export function checkoutStatusQueryKey(serverId: string, cwd: string) {
|
||||
@@ -46,17 +51,41 @@ export async function invalidateCheckoutGitQueriesForClient(
|
||||
]);
|
||||
}
|
||||
|
||||
// checkoutDiff is excluded: diff queries are subscription-fed (queryFn: skipToken) and
|
||||
// receive a fresh snapshot on every resubscribe, so invalidation cannot and need not
|
||||
// refetch them.
|
||||
export async function invalidateCheckoutGitQueriesForServer(
|
||||
queryClient: QueryClient,
|
||||
serverId: string,
|
||||
) {
|
||||
const kinds = ["checkoutStatus", "checkoutPrStatus", prPaneTimelineQueryKind];
|
||||
await Promise.all(
|
||||
kinds.map((kind) =>
|
||||
queryClient.invalidateQueries({ predicate: checkoutQueryPredicate(kind, { serverId }) }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function invalidatePrPaneTimelineForCheckout(
|
||||
queryClient: QueryClient,
|
||||
identity: CheckoutQueryIdentity,
|
||||
) {
|
||||
await queryClient.invalidateQueries({
|
||||
predicate: checkoutQueryPredicate(prPaneTimelineQueryKind, identity),
|
||||
});
|
||||
}
|
||||
|
||||
function checkoutQueryPredicate(
|
||||
queryKind: CheckoutQueryKey[0],
|
||||
identity: CheckoutQueryIdentity,
|
||||
scope: CheckoutQueryScope,
|
||||
): (query: Query) => boolean {
|
||||
return (query) => {
|
||||
const key = query.queryKey;
|
||||
return (
|
||||
isCheckoutQueryKey(key) &&
|
||||
key[0] === queryKind &&
|
||||
key[1] === identity.serverId &&
|
||||
key[2] === identity.cwd
|
||||
key[1] === scope.serverId &&
|
||||
(scope.cwd === undefined || key[2] === scope.cwd)
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import type { CheckoutPrStatusResponse } from "@getpaseo/protocol/messages";
|
||||
@@ -81,24 +80,9 @@ export function useCheckoutPrStatusQuery({
|
||||
enabled = true,
|
||||
}: UseCheckoutPrStatusQueryOptions) {
|
||||
const { t } = useTranslation();
|
||||
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 () => {
|
||||
@@ -109,7 +93,9 @@ export function useCheckoutPrStatusQuery({
|
||||
},
|
||||
enabled: !!client && isConnected && !!cwd && enabled,
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
// Refetch on mount only after explicit invalidation (e.g. reconnect) — see
|
||||
// useCheckoutStatusQuery for the rationale.
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
@@ -131,24 +117,9 @@ export function useWorkspacePrHint({
|
||||
enabled = true,
|
||||
}: UseCheckoutPrStatusQueryOptions): PrHint | null {
|
||||
const { t } = useTranslation();
|
||||
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 () => {
|
||||
@@ -159,7 +130,9 @@ export function useWorkspacePrHint({
|
||||
},
|
||||
enabled: !!client && isConnected && !!cwd && enabled,
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
// Refetch on mount only after explicit invalidation (e.g. reconnect) — see
|
||||
// useCheckoutStatusQuery for the rationale.
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
select: selectWorkspacePrHint,
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { checkoutStatusQueryKey } from "@/git/query-keys";
|
||||
import {
|
||||
applyCheckoutStatusUpdate,
|
||||
type CheckoutStatusClient,
|
||||
type CheckoutStatusPayload,
|
||||
peekOrFetchCheckoutStatus,
|
||||
} from "./checkout-status-cache";
|
||||
import { fetchCheckoutStatus } from "./checkout-status-cache";
|
||||
|
||||
export type { CheckoutStatusPayload } from "./checkout-status-cache";
|
||||
|
||||
@@ -19,40 +13,25 @@ interface UseCheckoutStatusQueryOptions {
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
function fetchCheckoutStatus(
|
||||
client: CheckoutStatusClient,
|
||||
cwd: string,
|
||||
): Promise<CheckoutStatusPayload> {
|
||||
return client.getCheckoutStatus(cwd);
|
||||
}
|
||||
|
||||
export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQueryOptions) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !isConnected || !cwd) {
|
||||
return;
|
||||
}
|
||||
|
||||
return client.on("checkout_status_update", (message) => {
|
||||
applyCheckoutStatusUpdate({ queryClient, serverId, cwd, message });
|
||||
});
|
||||
}, [client, isConnected, cwd, queryClient, serverId]);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: checkoutStatusQueryKey(serverId, cwd),
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error(t("common.errors.daemonClientUnavailable"));
|
||||
}
|
||||
return await peekOrFetchCheckoutStatus({ queryClient, client, serverId, cwd });
|
||||
return await fetchCheckoutStatus({ client, serverId, cwd });
|
||||
},
|
||||
enabled: !!client && isConnected && !!cwd,
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
// Freshness is push-driven (checkout_status_update applied globally); with
|
||||
// staleTime: Infinity, refetchOnMount only fires after an explicit invalidation
|
||||
// (e.g. reconnect), which is exactly when the push stream may have been missed.
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
@@ -81,7 +60,7 @@ export function useCheckoutStatusCacheOnly({ serverId, cwd }: UseCheckoutStatusQ
|
||||
if (!client) {
|
||||
throw new Error(t("common.errors.daemonClientUnavailable"));
|
||||
}
|
||||
return await fetchCheckoutStatus(client, cwd);
|
||||
return await fetchCheckoutStatus({ client, serverId, cwd });
|
||||
},
|
||||
enabled: false,
|
||||
staleTime: CHECKOUT_STATUS_STALE_TIME,
|
||||
|
||||
@@ -2,15 +2,17 @@ export {
|
||||
buildReviewAttachmentSnapshot,
|
||||
buildReviewDraftKey,
|
||||
buildReviewDraftScopeKey,
|
||||
expireStaleDiffModeOverrides,
|
||||
getReviewDraftComments,
|
||||
resetReviewDraftStore,
|
||||
useActiveReviewDraftMode,
|
||||
useClearReviewDraft,
|
||||
useReviewAttachmentSnapshot,
|
||||
useSetActiveReviewDraftMode,
|
||||
useResolvedDiffMode,
|
||||
useSetDiffModeOverride,
|
||||
addReviewDraftComment,
|
||||
type BuildReviewDraftKeyInput,
|
||||
type BuildReviewDraftScopeKeyInput,
|
||||
type DiffModeOverride,
|
||||
type ReviewDraftCommentInput,
|
||||
type ReviewDraftComment,
|
||||
type ReviewDraftMode,
|
||||
|
||||
@@ -11,32 +11,80 @@ export interface ReviewDraftComment {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// A manual mode selection is valid only while the checkout's dirty state matches the
|
||||
// value at the time of selection. serverId/cwd identify the checkout so the override can
|
||||
// be expired when its dirty state changes (see expireStaleDiffModeOverridesInState).
|
||||
export interface DiffModeOverride {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
mode: ReviewDraftMode;
|
||||
isDirtyAtSelection: boolean;
|
||||
}
|
||||
|
||||
export interface ReviewDraftStoreState {
|
||||
drafts: Record<string, ReviewDraftComment[]>;
|
||||
activeModesByScope: Record<string, ReviewDraftMode>;
|
||||
// In-memory only — not persisted. Keyed by scope key.
|
||||
diffModeOverrides: Record<string, DiffModeOverride>;
|
||||
}
|
||||
|
||||
// Only drafts are persisted; diffModeOverrides is intentionally excluded.
|
||||
export interface SerializedReviewDraftState {
|
||||
drafts: Record<string, ReviewDraftComment[]>;
|
||||
activeModesByScope: Record<string, ReviewDraftMode>;
|
||||
}
|
||||
|
||||
export function setActiveModeInState(
|
||||
export function setDiffModeOverrideInState(
|
||||
state: ReviewDraftStoreState,
|
||||
input: { scopeKey: string; mode: ReviewDraftMode },
|
||||
input: { scopeKey: string; override: DiffModeOverride },
|
||||
): ReviewDraftStoreState {
|
||||
if (state.activeModesByScope[input.scopeKey] === input.mode) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
activeModesByScope: {
|
||||
...state.activeModesByScope,
|
||||
[input.scopeKey]: input.mode,
|
||||
diffModeOverrides: {
|
||||
...state.diffModeOverrides,
|
||||
[input.scopeKey]: input.override,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Drops every override for the checkout whose dirty state no longer matches the value it
|
||||
// was selected under. Called whenever a checkout status enters the app (push or fetch),
|
||||
// so expiry does not depend on any screen being mounted.
|
||||
export function expireStaleDiffModeOverridesInState(
|
||||
state: ReviewDraftStoreState,
|
||||
input: { serverId: string; cwd: string; isDirty: boolean },
|
||||
): ReviewDraftStoreState {
|
||||
const staleScopeKeys = Object.entries(state.diffModeOverrides)
|
||||
.filter(
|
||||
([, override]) =>
|
||||
override.serverId === input.serverId &&
|
||||
override.cwd === input.cwd &&
|
||||
override.isDirtyAtSelection !== input.isDirty,
|
||||
)
|
||||
.map(([scopeKey]) => scopeKey);
|
||||
if (staleScopeKeys.length === 0) {
|
||||
return state;
|
||||
}
|
||||
const next = { ...state.diffModeOverrides };
|
||||
for (const scopeKey of staleScopeKeys) {
|
||||
delete next[scopeKey];
|
||||
}
|
||||
return { ...state, diffModeOverrides: next };
|
||||
}
|
||||
|
||||
// Pure read — returns the effective mode without mutating state. The staleness check is
|
||||
// kept even though stale overrides are expired at the data boundary: a render can observe
|
||||
// a fresh dirty state before the expiry lands, and resolution must be correct under any
|
||||
// interleaving.
|
||||
export function resolveDiffMode(input: {
|
||||
override: DiffModeOverride | undefined;
|
||||
hasUncommittedChanges: boolean;
|
||||
}): ReviewDraftMode {
|
||||
const { override, hasUncommittedChanges } = input;
|
||||
if (override && override.isDirtyAtSelection === hasUncommittedChanges) {
|
||||
return override.mode;
|
||||
}
|
||||
return hasUncommittedChanges ? "uncommitted" : "base";
|
||||
}
|
||||
|
||||
export function addCommentToState(
|
||||
state: ReviewDraftStoreState,
|
||||
input: { key: string; comment: ReviewDraftComment },
|
||||
@@ -108,18 +156,18 @@ export function serializeReviewDraftState(
|
||||
): SerializedReviewDraftState {
|
||||
return {
|
||||
drafts: state.drafts,
|
||||
activeModesByScope: state.activeModesByScope,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePersistedState(state: unknown): ReviewDraftStoreState {
|
||||
if (!state || typeof state !== "object") {
|
||||
return { drafts: {}, activeModesByScope: {} };
|
||||
return { drafts: {}, diffModeOverrides: {} };
|
||||
}
|
||||
const persisted = state as { drafts?: unknown; activeModesByScope?: unknown };
|
||||
// activeModesByScope may be present in old persisted JSON — tolerate and ignore it.
|
||||
const persisted = state as { drafts?: unknown };
|
||||
const drafts = persisted.drafts;
|
||||
if (!drafts || typeof drafts !== "object" || Array.isArray(drafts)) {
|
||||
return { drafts: {}, activeModesByScope: {} };
|
||||
return { drafts: {}, diffModeOverrides: {} };
|
||||
}
|
||||
|
||||
const normalized: Record<string, ReviewDraftComment[]> = {};
|
||||
@@ -132,20 +180,7 @@ export function normalizePersistedState(state: unknown): ReviewDraftStoreState {
|
||||
);
|
||||
}
|
||||
|
||||
const activeModesByScope: Record<string, ReviewDraftMode> = {};
|
||||
const persistedActiveModes = persisted.activeModesByScope;
|
||||
if (
|
||||
persistedActiveModes &&
|
||||
typeof persistedActiveModes === "object" &&
|
||||
!Array.isArray(persistedActiveModes)
|
||||
) {
|
||||
for (const [key, mode] of Object.entries(persistedActiveModes)) {
|
||||
if (mode === "base" || mode === "uncommitted") {
|
||||
activeModesByScope[key] = mode;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { drafts: normalized, activeModesByScope };
|
||||
return { drafts: normalized, diffModeOverrides: {} };
|
||||
}
|
||||
|
||||
export function isReviewDraftComment(value: unknown): value is ReviewDraftComment {
|
||||
|
||||
@@ -9,15 +9,25 @@ import {
|
||||
addCommentToState,
|
||||
clearReviewInState,
|
||||
deleteCommentFromState,
|
||||
type DiffModeOverride,
|
||||
expireStaleDiffModeOverridesInState,
|
||||
normalizePersistedState,
|
||||
resolveDiffMode,
|
||||
type ReviewDraftComment,
|
||||
type ReviewDraftStoreState,
|
||||
setActiveModeInState,
|
||||
serializeReviewDraftState,
|
||||
setDiffModeOverrideInState,
|
||||
updateCommentInState,
|
||||
} from "./state";
|
||||
|
||||
function emptyState(): ReviewDraftStoreState {
|
||||
return { drafts: {}, activeModesByScope: {} };
|
||||
return { drafts: {}, diffModeOverrides: {} };
|
||||
}
|
||||
|
||||
function makeOverride(
|
||||
input: Pick<DiffModeOverride, "mode" | "isDirtyAtSelection">,
|
||||
): DiffModeOverride {
|
||||
return { serverId: "server-1", cwd: "/repo", ...input };
|
||||
}
|
||||
|
||||
function makeComment(overrides: Partial<ReviewDraftComment> = {}): ReviewDraftComment {
|
||||
@@ -95,7 +105,7 @@ describe("buildReviewDraftKey", () => {
|
||||
).toBe("review:server=local:cwd=%2Frepo:mode=base:base=main:ignoreWhitespace=false");
|
||||
});
|
||||
|
||||
it("builds a mode-free scope key for active review mode sharing", () => {
|
||||
it("builds a mode-free scope key for diff mode override sharing", () => {
|
||||
const scope = buildReviewDraftScopeKey({
|
||||
serverId: "local",
|
||||
workspaceId: "workspace-1",
|
||||
@@ -112,7 +122,7 @@ describe("buildReviewDraftKey", () => {
|
||||
});
|
||||
|
||||
describe("normalizePersistedState", () => {
|
||||
it("filters invalid draft comments and drops unknown active modes", () => {
|
||||
it("filters invalid draft comments and ignores activeModesByScope from old persisted JSON", () => {
|
||||
const normalized = normalizePersistedState({
|
||||
drafts: {
|
||||
"review:key": [
|
||||
@@ -128,17 +138,14 @@ describe("normalizePersistedState", () => {
|
||||
{ id: "bad", filePath: "src/example.ts" },
|
||||
],
|
||||
},
|
||||
// Old persisted field — must be tolerated and ignored, not migrated.
|
||||
activeModesByScope: {
|
||||
"review:scope:base": "base",
|
||||
"review:scope:dirty": "uncommitted",
|
||||
"review:scope:bad": "other",
|
||||
},
|
||||
});
|
||||
|
||||
expect(normalized.activeModesByScope).toEqual({
|
||||
"review:scope:base": "base",
|
||||
"review:scope:dirty": "uncommitted",
|
||||
});
|
||||
expect(normalized.diffModeOverrides).toEqual({});
|
||||
expect(normalized.drafts["review:key"]).toEqual([
|
||||
{
|
||||
id: "comment-1",
|
||||
@@ -153,15 +160,144 @@ describe("normalizePersistedState", () => {
|
||||
});
|
||||
|
||||
it("returns empty state for null, non-object, or malformed inputs", () => {
|
||||
expect(normalizePersistedState(null)).toEqual({ drafts: {}, activeModesByScope: {} });
|
||||
expect(normalizePersistedState("nope")).toEqual({ drafts: {}, activeModesByScope: {} });
|
||||
expect(normalizePersistedState(null)).toEqual({ drafts: {}, diffModeOverrides: {} });
|
||||
expect(normalizePersistedState("nope")).toEqual({ drafts: {}, diffModeOverrides: {} });
|
||||
expect(normalizePersistedState({ drafts: [] })).toEqual({
|
||||
drafts: {},
|
||||
activeModesByScope: {},
|
||||
diffModeOverrides: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("serializeReviewDraftState", () => {
|
||||
it("serialized output does not contain activeModesByScope or diffModeOverrides", () => {
|
||||
const state = setDiffModeOverrideInState(
|
||||
addCommentToState(emptyState(), { key: "review:key", comment: makeComment() }),
|
||||
{
|
||||
scopeKey: "review:scope",
|
||||
override: makeOverride({ mode: "uncommitted", isDirtyAtSelection: true }),
|
||||
},
|
||||
);
|
||||
|
||||
const serialized = serializeReviewDraftState(state);
|
||||
|
||||
expect(Object.keys(serialized)).toEqual(["drafts"]);
|
||||
expect("activeModesByScope" in serialized).toBe(false);
|
||||
expect("diffModeOverrides" in serialized).toBe(false);
|
||||
expect(serialized.drafts["review:key"]).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("diff mode override", () => {
|
||||
it("resolves to auto mode from the dirty state when no override exists", () => {
|
||||
expect(resolveDiffMode({ override: undefined, hasUncommittedChanges: true })).toBe(
|
||||
"uncommitted",
|
||||
);
|
||||
expect(resolveDiffMode({ override: undefined, hasUncommittedChanges: false })).toBe("base");
|
||||
});
|
||||
|
||||
it("honors the override while isDirty matches the value at selection, across remounts", () => {
|
||||
const state = setDiffModeOverrideInState(emptyState(), {
|
||||
scopeKey: "review:scope",
|
||||
override: makeOverride({ mode: "base", isDirtyAtSelection: true }),
|
||||
});
|
||||
|
||||
// Resolution is a plain store read, so it gives the same answer on every (re)mount.
|
||||
const override = state.diffModeOverrides["review:scope"];
|
||||
expect(resolveDiffMode({ override, hasUncommittedChanges: true })).toBe("base");
|
||||
});
|
||||
|
||||
it("masks a stale override before its expiry lands", () => {
|
||||
const state = setDiffModeOverrideInState(emptyState(), {
|
||||
scopeKey: "review:scope",
|
||||
override: makeOverride({ mode: "uncommitted", isDirtyAtSelection: true }),
|
||||
});
|
||||
|
||||
const override = state.diffModeOverrides["review:scope"];
|
||||
expect(resolveDiffMode({ override, hasUncommittedChanges: false })).toBe("base");
|
||||
});
|
||||
|
||||
it("expires overrides for the checkout whose dirty state flipped, keeping the rest", () => {
|
||||
let state = setDiffModeOverrideInState(emptyState(), {
|
||||
scopeKey: "review:scope-a",
|
||||
override: makeOverride({ mode: "base", isDirtyAtSelection: true }),
|
||||
});
|
||||
state = setDiffModeOverrideInState(state, {
|
||||
scopeKey: "review:scope-b",
|
||||
override: makeOverride({ mode: "uncommitted", isDirtyAtSelection: false }),
|
||||
});
|
||||
state = setDiffModeOverrideInState(state, {
|
||||
scopeKey: "review:scope-other",
|
||||
override: {
|
||||
serverId: "server-2",
|
||||
cwd: "/other",
|
||||
mode: "base",
|
||||
isDirtyAtSelection: true,
|
||||
},
|
||||
});
|
||||
|
||||
const next = expireStaleDiffModeOverridesInState(state, {
|
||||
serverId: "server-1",
|
||||
cwd: "/repo",
|
||||
isDirty: false,
|
||||
});
|
||||
|
||||
expect(next.diffModeOverrides["review:scope-a"]).toBeUndefined();
|
||||
expect(next.diffModeOverrides["review:scope-b"]).toBeDefined();
|
||||
expect(next.diffModeOverrides["review:scope-other"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not resurrect an expired override when the dirty state flips back", () => {
|
||||
let state = setDiffModeOverrideInState(emptyState(), {
|
||||
scopeKey: "review:scope",
|
||||
override: makeOverride({ mode: "base", isDirtyAtSelection: true }),
|
||||
});
|
||||
|
||||
// The checkout goes clean (e.g. agent commits): the override expires at the boundary.
|
||||
state = expireStaleDiffModeOverridesInState(state, {
|
||||
serverId: "server-1",
|
||||
cwd: "/repo",
|
||||
isDirty: false,
|
||||
});
|
||||
// The checkout goes dirty again: nothing to expire, and nothing comes back.
|
||||
state = expireStaleDiffModeOverridesInState(state, {
|
||||
serverId: "server-1",
|
||||
cwd: "/repo",
|
||||
isDirty: true,
|
||||
});
|
||||
|
||||
expect(state.diffModeOverrides["review:scope"]).toBeUndefined();
|
||||
expect(
|
||||
resolveDiffMode({
|
||||
override: state.diffModeOverrides["review:scope"],
|
||||
hasUncommittedChanges: true,
|
||||
}),
|
||||
).toBe("uncommitted");
|
||||
});
|
||||
|
||||
it("keeps state identity when no override is stale", () => {
|
||||
const state = setDiffModeOverrideInState(emptyState(), {
|
||||
scopeKey: "review:scope",
|
||||
override: makeOverride({ mode: "base", isDirtyAtSelection: true }),
|
||||
});
|
||||
|
||||
expect(
|
||||
expireStaleDiffModeOverridesInState(state, {
|
||||
serverId: "server-1",
|
||||
cwd: "/repo",
|
||||
isDirty: true,
|
||||
}),
|
||||
).toBe(state);
|
||||
expect(
|
||||
expireStaleDiffModeOverridesInState(emptyState(), {
|
||||
serverId: "server-1",
|
||||
cwd: "/repo",
|
||||
isDirty: false,
|
||||
}),
|
||||
).toEqual(emptyState());
|
||||
});
|
||||
});
|
||||
|
||||
describe("review draft reducers", () => {
|
||||
it("adds, updates, and deletes draft comments by key", () => {
|
||||
let state = emptyState();
|
||||
@@ -203,18 +339,6 @@ describe("review draft reducers", () => {
|
||||
expect(deleteCommentFromState(state, { key: "review:key", id: "missing" })).toBe(state);
|
||||
expect(clearReviewInState(state, { key: "other-key" })).toBe(state);
|
||||
});
|
||||
|
||||
it("keeps state identity when setActiveMode does not change the mode", () => {
|
||||
const state = setActiveModeInState(emptyState(), {
|
||||
scopeKey: "review:scope",
|
||||
mode: "base",
|
||||
});
|
||||
|
||||
expect(setActiveModeInState(state, { scopeKey: "review:scope", mode: "base" })).toBe(state);
|
||||
expect(setActiveModeInState(state, { scopeKey: "review:scope", mode: "uncommitted" })).not.toBe(
|
||||
state,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildReviewAttachmentSnapshot", () => {
|
||||
|
||||
@@ -8,22 +8,31 @@ import {
|
||||
addCommentToState,
|
||||
clearReviewInState,
|
||||
deleteCommentFromState,
|
||||
type DiffModeOverride,
|
||||
expireStaleDiffModeOverridesInState,
|
||||
normalizePersistedState,
|
||||
resolveDiffMode,
|
||||
type ReviewDraftComment,
|
||||
type ReviewDraftMode,
|
||||
type ReviewDraftSide,
|
||||
type ReviewDraftStoreState,
|
||||
serializeReviewDraftState,
|
||||
setActiveModeInState,
|
||||
setDiffModeOverrideInState,
|
||||
updateCommentInState,
|
||||
} from "@/review/state";
|
||||
import { generateMessageId } from "@/types/stream";
|
||||
import { buildNumberedDiffHunks, type NumberedDiffLine } from "@/utils/diff-layout";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
|
||||
export type { ReviewDraftComment, ReviewDraftMode, ReviewDraftSide } from "@/review/state";
|
||||
export type {
|
||||
DiffModeOverride,
|
||||
ReviewDraftComment,
|
||||
ReviewDraftMode,
|
||||
ReviewDraftSide,
|
||||
} from "@/review/state";
|
||||
|
||||
const STORE_VERSION = 1;
|
||||
// v2 dropped persisted activeModesByScope (diff mode overrides are in-memory only).
|
||||
const STORE_VERSION = 2;
|
||||
const CONTEXT_RADIUS = 3;
|
||||
const EMPTY_REVIEW_DRAFT_COMMENTS: ReviewDraftComment[] = [];
|
||||
|
||||
@@ -55,7 +64,7 @@ export type ReviewDraftCommentInput = Omit<ReviewDraftComment, "id" | "createdAt
|
||||
Partial<Pick<ReviewDraftComment, "id" | "createdAt" | "updatedAt">>;
|
||||
|
||||
interface ReviewDraftStoreActions {
|
||||
setActiveMode: (input: { scopeKey: string; mode: ReviewDraftMode }) => void;
|
||||
setDiffModeOverride: (input: { scopeKey: string; override: DiffModeOverride }) => void;
|
||||
addComment: (input: { key: string; comment: ReviewDraftCommentInput }) => ReviewDraftComment;
|
||||
updateComment: (input: {
|
||||
key: string;
|
||||
@@ -129,9 +138,9 @@ export const useReviewDraftStore = create<ReviewDraftStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
drafts: {},
|
||||
activeModesByScope: {},
|
||||
setActiveMode: (input) => {
|
||||
set((state) => setActiveModeInState(state, input));
|
||||
diffModeOverrides: {},
|
||||
setDiffModeOverride: (input) => {
|
||||
set((state) => setDiffModeOverrideInState(state, input));
|
||||
},
|
||||
addComment: ({ key, comment }) => {
|
||||
const nextComment = createDraftComment(comment);
|
||||
@@ -271,8 +280,18 @@ export function useReviewDraftComments(key: string): ReviewDraftComment[] {
|
||||
return useReviewDraftStore((state) => state.drafts[key] ?? EMPTY_REVIEW_DRAFT_COMMENTS);
|
||||
}
|
||||
|
||||
export function useSetActiveReviewDraftMode(): ReviewDraftStoreActions["setActiveMode"] {
|
||||
return useReviewDraftStore((state) => state.setActiveMode);
|
||||
export function useSetDiffModeOverride(): ReviewDraftStoreActions["setDiffModeOverride"] {
|
||||
return useReviewDraftStore((state) => state.setDiffModeOverride);
|
||||
}
|
||||
|
||||
// Non-React entry point: called from the checkout-status data boundary, where dirty-state
|
||||
// changes enter the app regardless of which screens are mounted.
|
||||
export function expireStaleDiffModeOverrides(input: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
isDirty: boolean;
|
||||
}): void {
|
||||
useReviewDraftStore.setState((state) => expireStaleDiffModeOverridesInState(state, input));
|
||||
}
|
||||
|
||||
export function useClearReviewDraft(): ReviewDraftStoreActions["clearReview"] {
|
||||
@@ -291,7 +310,7 @@ export function getReviewDraftComments(key: string): ReviewDraftComment[] | unde
|
||||
}
|
||||
|
||||
export function resetReviewDraftStore(): void {
|
||||
useReviewDraftStore.setState({ drafts: {}, activeModesByScope: {} });
|
||||
useReviewDraftStore.setState({ drafts: {}, diffModeOverrides: {} });
|
||||
}
|
||||
|
||||
export function useReviewDraftCommentsForAttachment(input: {
|
||||
@@ -309,8 +328,16 @@ export function useReviewCommentCount(key: string): number {
|
||||
return useReviewDraftStore((state) => state.drafts[key]?.length ?? 0);
|
||||
}
|
||||
|
||||
export function useActiveReviewDraftMode(input: { scopeKey: string }): ReviewDraftMode | null {
|
||||
return useReviewDraftStore((state) => state.activeModesByScope[input.scopeKey] ?? null);
|
||||
export function useResolvedDiffMode(input: {
|
||||
scopeKey: string;
|
||||
hasUncommittedChanges: boolean;
|
||||
}): ReviewDraftMode {
|
||||
return useReviewDraftStore((state) =>
|
||||
resolveDiffMode({
|
||||
override: state.diffModeOverrides[input.scopeKey],
|
||||
hasUncommittedChanges: input.hasUncommittedChanges,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function useReviewAttachmentSnapshot(input: {
|
||||
|
||||
@@ -168,7 +168,7 @@ function comment(overrides: Partial<ReviewDraftComment> = {}): ReviewDraftCommen
|
||||
|
||||
describe("useInlineReviewController", () => {
|
||||
beforeEach(() => {
|
||||
useReviewDraftStore.setState({ drafts: {}, activeModesByScope: {} });
|
||||
useReviewDraftStore.setState({ drafts: {}, diffModeOverrides: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -38,6 +38,8 @@ import {
|
||||
} from "@/desktop/daemon/desktop-daemon-transport";
|
||||
import { replaceFetchedAgentDirectory } from "@/utils/agent-directory-sync";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { invalidateCheckoutGitQueriesForServer } from "@/git/query-keys";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
|
||||
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
|
||||
export type HostRegistryStatus = "loading" | "ready";
|
||||
@@ -1767,6 +1769,10 @@ export class HostRuntimeStore {
|
||||
snapshot.connectionStatus === "online" && previousStatus !== "online";
|
||||
if (didTransitionOnline) {
|
||||
useSessionStore.getState().bumpHistorySyncGeneration(serverId);
|
||||
// Checkout git data is push-driven; pushes emitted while disconnected are gone for
|
||||
// good (the daemon dedupes by snapshot fingerprint). Mark the caches stale so active
|
||||
// queries refetch now and evicted ones on their next mount.
|
||||
void invalidateCheckoutGitQueriesForServer(queryClient, serverId);
|
||||
}
|
||||
|
||||
// Runtime owns directory bootstrap policy, including reconnect and delayed
|
||||
|
||||
@@ -102,6 +102,7 @@ import { useWorkspace } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
|
||||
import type { CheckoutStatusPayload } from "@/git/use-status-query";
|
||||
import { checkoutStatusQueryKey } from "@/git/query-keys";
|
||||
import { fetchCheckoutStatus } from "@/git/checkout-status-cache";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
@@ -1715,10 +1716,16 @@ function useWorkspaceCheckoutStatus(input: {
|
||||
if (!input.client || !input.workspaceDirectory) {
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
return await input.client.getCheckoutStatus(input.workspaceDirectory);
|
||||
return await fetchCheckoutStatus({
|
||||
client: input.client,
|
||||
serverId: input.normalizedServerId,
|
||||
cwd: input.workspaceDirectory,
|
||||
});
|
||||
},
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
// Refetch on mount only after explicit invalidation (e.g. reconnect) — see
|
||||
// useCheckoutStatusQuery for the rationale.
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user