mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
feat: lazy-load diff RPCs for mobile performance
Opening a workspace with a large diff blocked the JS thread because the full structured diff (hunks, lines, syntax tokens) was sent over the wire and parsed via JSON.parse + Zod safeParse on the main thread. Add namespaced checkout RPCs that split diff delivery into two phases: 1. **Metadata-only snapshots** (`checkout/subscribe_diff` → `checkout/diff_snapshot`) — lightweight file list with status, path, insertions/deletions, and a fingerprint. Sent on every file-system change. No hunks, no tokens. 2. **On-demand hunk fetching** (`checkout/get_file_hunks` → `checkout/file_hunks_response`) — full structured diff for a single file, fetched when the user expands it. Served from an in-memory cache with a single-file git fallback. Feature detection via `server_info.features.lazyDiffRpcs`. New clients fall back to the V1 full-diff path when talking to older daemons. Old clients are completely unaffected — all new schema fields are optional and new message types are additive. Per-file fingerprints (hash of hunks excluding tokens) drive React Query cache invalidation so expanded files auto-refresh only when their actual content changes.
This commit is contained in:
@@ -37,6 +37,12 @@ import {
|
||||
type DiffLine,
|
||||
type HighlightToken,
|
||||
} from "@/hooks/use-checkout-diff-query";
|
||||
import {
|
||||
useCheckoutDiffMetadataQuery,
|
||||
type DiffFileMetadata,
|
||||
} from "@/hooks/use-checkout-diff-metadata-query";
|
||||
import { useFileHunks } from "@/hooks/use-file-hunks";
|
||||
import { useSessionForServer } from "@/hooks/use-session-directory";
|
||||
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
|
||||
import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
|
||||
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
||||
@@ -130,8 +136,16 @@ function HighlightedText({ tokens, wrapLines = false }: HighlightedTextProps) {
|
||||
);
|
||||
}
|
||||
|
||||
interface DiffFileHeaderFields {
|
||||
path: string;
|
||||
isNew: boolean;
|
||||
isDeleted: boolean;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
}
|
||||
|
||||
interface DiffFileSectionProps {
|
||||
file: ParsedDiffFile;
|
||||
file: DiffFileHeaderFields;
|
||||
isExpanded: boolean;
|
||||
onToggle: (path: string) => void;
|
||||
onHeaderHeightChange?: (path: string, height: number) => void;
|
||||
@@ -617,6 +631,109 @@ function DiffFileBody({
|
||||
);
|
||||
}
|
||||
|
||||
interface LazyDiffFileBodyProps {
|
||||
file: DiffFileMetadata;
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
mode: "uncommitted" | "base";
|
||||
baseRef?: string;
|
||||
ignoreWhitespace?: boolean;
|
||||
layout: "unified" | "split";
|
||||
wrapLines: boolean;
|
||||
onBodyHeightChange?: (path: string, height: number) => void;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
function LazyDiffFileBody({
|
||||
file,
|
||||
serverId,
|
||||
cwd,
|
||||
mode,
|
||||
baseRef,
|
||||
ignoreWhitespace,
|
||||
layout,
|
||||
wrapLines,
|
||||
onBodyHeightChange,
|
||||
testID,
|
||||
}: LazyDiffFileBodyProps) {
|
||||
const {
|
||||
file: resolvedFile,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useFileHunks({
|
||||
serverId,
|
||||
cwd,
|
||||
mode,
|
||||
baseRef,
|
||||
ignoreWhitespace,
|
||||
file,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
if (file.status === "binary" || file.status === "too_large") {
|
||||
return (
|
||||
<View
|
||||
style={[styles.fileSectionBodyContainer, styles.fileSectionBorder]}
|
||||
onLayout={(event) => {
|
||||
onBodyHeightChange?.(file.path, event.nativeEvent.layout.height);
|
||||
}}
|
||||
testID={testID}
|
||||
>
|
||||
<View style={styles.statusMessageContainer}>
|
||||
<Text style={styles.statusMessageText}>
|
||||
{file.status === "binary" ? "Binary file" : "Diff too large to display"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View
|
||||
style={[styles.fileSectionBodyContainer, styles.fileSectionBorder]}
|
||||
onLayout={(event) => {
|
||||
onBodyHeightChange?.(file.path, event.nativeEvent.layout.height);
|
||||
}}
|
||||
testID={testID}
|
||||
>
|
||||
<View style={styles.statusMessageContainer}>
|
||||
<ActivityIndicator size="small" />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !resolvedFile) {
|
||||
return (
|
||||
<View
|
||||
style={[styles.fileSectionBodyContainer, styles.fileSectionBorder]}
|
||||
onLayout={(event) => {
|
||||
onBodyHeightChange?.(file.path, event.nativeEvent.layout.height);
|
||||
}}
|
||||
testID={testID}
|
||||
>
|
||||
<View style={styles.statusMessageContainer}>
|
||||
<Text style={styles.statusMessageText}>
|
||||
{error instanceof Error ? error.message : "Failed to load diff"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DiffFileBody
|
||||
file={resolvedFile}
|
||||
layout={layout}
|
||||
wrapLines={wrapLines}
|
||||
onBodyHeightChange={onBodyHeightChange}
|
||||
testID={testID}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface GitDiffPaneProps {
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
@@ -625,8 +742,9 @@ interface GitDiffPaneProps {
|
||||
}
|
||||
|
||||
type DiffFlatItem =
|
||||
| { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean }
|
||||
| { type: "body"; file: ParsedDiffFile; fileIndex: number };
|
||||
| { type: "header"; file: DiffFileHeaderFields; fileIndex: number; isExpanded: boolean }
|
||||
| { type: "body"; file: ParsedDiffFile; fileIndex: number }
|
||||
| { type: "lazy-body"; file: DiffFileMetadata; fileIndex: number };
|
||||
|
||||
export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) {
|
||||
const { theme } = useUnistyles();
|
||||
@@ -643,6 +761,13 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
const wrapLines = changesPreferences.wrapLines;
|
||||
const effectiveLayout = canUseSplitLayout ? changesPreferences.layout : "unified";
|
||||
|
||||
// Feature detection: use V2 lazy diff when daemon supports it
|
||||
const useLazyDiff =
|
||||
useSessionForServer(
|
||||
serverId,
|
||||
(session) => session?.serverInfo?.features?.lazyDiffRpcs === true,
|
||||
) ?? false;
|
||||
|
||||
const handleToggleWrapLines = useCallback(() => {
|
||||
void updateChangesPreferences({ wrapLines: !wrapLines });
|
||||
}, [updateChangesPreferences, wrapLines]);
|
||||
@@ -679,22 +804,50 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
const autoDiffMode = hasUncommittedChanges ? "uncommitted" : "base";
|
||||
const diffMode = diffModeOverride ?? autoDiffMode;
|
||||
|
||||
// V1: full diff (hunks included) — used when daemon doesn't support lazy diff
|
||||
const {
|
||||
files,
|
||||
payloadError: diffPayloadError,
|
||||
isLoading: isDiffLoading,
|
||||
isFetching: isDiffFetching,
|
||||
isError: isDiffError,
|
||||
error: diffError,
|
||||
refresh: refreshDiff,
|
||||
files: v1Files,
|
||||
payloadError: v1DiffPayloadError,
|
||||
isLoading: isV1DiffLoading,
|
||||
isFetching: isV1DiffFetching,
|
||||
isError: isV1DiffError,
|
||||
error: v1DiffError,
|
||||
refresh: refreshV1Diff,
|
||||
} = useCheckoutDiffQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
mode: diffMode,
|
||||
baseRef,
|
||||
ignoreWhitespace: changesPreferences.hideWhitespace,
|
||||
enabled: isGit,
|
||||
enabled: isGit && !useLazyDiff,
|
||||
});
|
||||
|
||||
// V2: metadata-only (hunks fetched on demand per file)
|
||||
const {
|
||||
files: v2Files,
|
||||
payloadError: v2DiffPayloadError,
|
||||
isLoading: isV2DiffLoading,
|
||||
isFetching: isV2DiffFetching,
|
||||
isError: isV2DiffError,
|
||||
error: v2DiffError,
|
||||
refresh: refreshV2Diff,
|
||||
} = useCheckoutDiffMetadataQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
mode: diffMode,
|
||||
baseRef,
|
||||
ignoreWhitespace: changesPreferences.hideWhitespace,
|
||||
enabled: isGit && useLazyDiff,
|
||||
});
|
||||
|
||||
// Unified interface for both V1 and V2
|
||||
const files: DiffFileHeaderFields[] = useLazyDiff ? v2Files : v1Files;
|
||||
const diffPayloadError = useLazyDiff ? v2DiffPayloadError : v1DiffPayloadError;
|
||||
const isDiffLoading = useLazyDiff ? isV2DiffLoading : isV1DiffLoading;
|
||||
const isDiffFetching = useLazyDiff ? isV2DiffFetching : isV1DiffFetching;
|
||||
const isDiffError = useLazyDiff ? isV2DiffError : isV1DiffError;
|
||||
const diffError = useLazyDiff ? v2DiffError : v1DiffError;
|
||||
const refreshDiff = useLazyDiff ? refreshV2Diff : refreshV1Diff;
|
||||
const {
|
||||
status: prStatus,
|
||||
githubFeaturesEnabled,
|
||||
@@ -788,11 +941,15 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
stickyIndices.push(items.length - 1);
|
||||
}
|
||||
if (isExpanded) {
|
||||
items.push({ type: "body", file, fileIndex: i });
|
||||
if (useLazyDiff) {
|
||||
items.push({ type: "lazy-body", file: file as DiffFileMetadata, fileIndex: i });
|
||||
} else {
|
||||
items.push({ type: "body", file: file as ParsedDiffFile, fileIndex: i });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { flatItems: items, stickyHeaderIndices: stickyIndices };
|
||||
}, [expandedPaths, files]);
|
||||
}, [expandedPaths, files, useLazyDiff]);
|
||||
|
||||
const handleHeaderHeightChange = useCallback((path: string, height: number) => {
|
||||
if (!Number.isFinite(height) || height <= 0) {
|
||||
@@ -1067,6 +1224,22 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (item.type === "lazy-body") {
|
||||
return (
|
||||
<LazyDiffFileBody
|
||||
file={item.file}
|
||||
serverId={serverId}
|
||||
cwd={cwd}
|
||||
mode={diffMode}
|
||||
baseRef={baseRef}
|
||||
ignoreWhitespace={changesPreferences.hideWhitespace}
|
||||
layout={effectiveLayout}
|
||||
wrapLines={wrapLines}
|
||||
onBodyHeightChange={handleBodyHeightChange}
|
||||
testID={`diff-file-${item.fileIndex}-body`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DiffFileBody
|
||||
file={item.file}
|
||||
@@ -1078,10 +1251,15 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
);
|
||||
},
|
||||
[
|
||||
baseRef,
|
||||
changesPreferences.hideWhitespace,
|
||||
cwd,
|
||||
diffMode,
|
||||
effectiveLayout,
|
||||
handleBodyHeightChange,
|
||||
handleHeaderHeightChange,
|
||||
handleToggleExpanded,
|
||||
serverId,
|
||||
wrapLines,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages";
|
||||
|
||||
type ParsedDiffFile = SubscribeCheckoutDiffResponse["payload"]["files"][number];
|
||||
interface DiffFileWithPath {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function compareCheckoutDiffPaths(left: string, right: string): number {
|
||||
if (left === right) {
|
||||
@@ -9,7 +9,7 @@ export function compareCheckoutDiffPaths(left: string, right: string): number {
|
||||
return left < right ? -1 : 1;
|
||||
}
|
||||
|
||||
export function orderCheckoutDiffFiles(files: ParsedDiffFile[]): ParsedDiffFile[] {
|
||||
export function orderCheckoutDiffFiles<T extends DiffFileWithPath>(files: T[]): T[] {
|
||||
if (files.length < 2) {
|
||||
return files;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DiffFileMetadata } from "./use-checkout-diff-metadata-query";
|
||||
import { orderCheckoutDiffFiles } from "./checkout-diff-order";
|
||||
|
||||
function createMetadata(path: string, fingerprint = "fp1"): DiffFileMetadata {
|
||||
return {
|
||||
path,
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
fingerprint,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useCheckoutDiffMetadataQuery — cache replacement behavior", () => {
|
||||
it("orderCheckoutDiffFiles works with DiffFileMetadata arrays", () => {
|
||||
const files: DiffFileMetadata[] = [
|
||||
createMetadata("zeta.ts"),
|
||||
createMetadata("alpha.ts"),
|
||||
createMetadata("beta.ts"),
|
||||
];
|
||||
const ordered = orderCheckoutDiffFiles(files);
|
||||
expect(ordered.map((f) => f.path)).toEqual(["alpha.ts", "beta.ts", "zeta.ts"]);
|
||||
});
|
||||
|
||||
it("replaces full list on snapshot — does not merge with previous", () => {
|
||||
const initial: DiffFileMetadata[] = [
|
||||
createMetadata("a.ts", "fp1"),
|
||||
createMetadata("b.ts", "fp2"),
|
||||
createMetadata("c.ts", "fp3"),
|
||||
];
|
||||
// Simulate a new snapshot that removes b.ts and changes c.ts fingerprint
|
||||
const updated: DiffFileMetadata[] = [
|
||||
createMetadata("a.ts", "fp1"),
|
||||
createMetadata("c.ts", "fp4"),
|
||||
];
|
||||
|
||||
// Full replacement: the result is exactly the updated list, not a merge
|
||||
const result = orderCheckoutDiffFiles(updated);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((f) => f.path)).toEqual(["a.ts", "c.ts"]);
|
||||
expect(result.find((f) => f.path === "c.ts")?.fingerprint).toBe("fp4");
|
||||
// b.ts is gone — no remnant from initial
|
||||
expect(result.find((f) => f.path === "b.ts")).toBeUndefined();
|
||||
|
||||
// Initial is unchanged (no mutation)
|
||||
expect(initial).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("preserves fingerprint and status on metadata items", () => {
|
||||
const file: DiffFileMetadata = {
|
||||
path: "large.bin",
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
status: "binary",
|
||||
fingerprint: "abc123",
|
||||
};
|
||||
const ordered = orderCheckoutDiffFiles([file]);
|
||||
expect(ordered[0].fingerprint).toBe("abc123");
|
||||
expect(ordered[0].status).toBe("binary");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, 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 { CheckoutDiffSnapshot } from "@server/shared/messages";
|
||||
import { orderCheckoutDiffFiles } from "./checkout-diff-order";
|
||||
|
||||
const CHECKOUT_DIFF_METADATA_STALE_TIME = 30_000;
|
||||
|
||||
export type DiffFileMetadata = CheckoutDiffSnapshot["payload"]["files"][number];
|
||||
|
||||
type CheckoutDiffMetadataPayload = Omit<CheckoutDiffSnapshot["payload"], "subscriptionId">;
|
||||
|
||||
function checkoutDiffMetadataQueryKey(
|
||||
serverId: string,
|
||||
cwd: string,
|
||||
mode: "uncommitted" | "base",
|
||||
baseRef?: string,
|
||||
ignoreWhitespace?: boolean,
|
||||
) {
|
||||
return [
|
||||
"checkoutDiffMetadata",
|
||||
serverId,
|
||||
cwd,
|
||||
mode,
|
||||
baseRef ?? "",
|
||||
ignoreWhitespace === true,
|
||||
] as const;
|
||||
}
|
||||
|
||||
interface UseCheckoutDiffMetadataQueryOptions {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
mode: "uncommitted" | "base";
|
||||
baseRef?: string;
|
||||
ignoreWhitespace?: boolean;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
function normalizeCheckoutDiffCompare(compare: {
|
||||
mode: "uncommitted" | "base";
|
||||
baseRef?: string;
|
||||
ignoreWhitespace?: boolean;
|
||||
}): { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean } {
|
||||
const ignoreWhitespace = compare.ignoreWhitespace === true;
|
||||
if (compare.mode === "uncommitted") {
|
||||
return { mode: "uncommitted", ignoreWhitespace };
|
||||
}
|
||||
const trimmedBaseRef = compare.baseRef?.trim();
|
||||
return trimmedBaseRef
|
||||
? { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace }
|
||||
: { mode: "base", ignoreWhitespace };
|
||||
}
|
||||
|
||||
export function useCheckoutDiffMetadataQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
mode,
|
||||
baseRef,
|
||||
ignoreWhitespace,
|
||||
enabled = true,
|
||||
}: UseCheckoutDiffMetadataQueryOptions) {
|
||||
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 hookInstanceId = useId();
|
||||
const normalizedCompare = useMemo(
|
||||
() => normalizeCheckoutDiffCompare({ mode, baseRef, ignoreWhitespace }),
|
||||
[mode, baseRef, ignoreWhitespace],
|
||||
);
|
||||
const compareMode = normalizedCompare.mode;
|
||||
const compareBaseRef = normalizedCompare.baseRef;
|
||||
const compareIgnoreWhitespace = normalizedCompare.ignoreWhitespace;
|
||||
const queryKey = useMemo(
|
||||
() => checkoutDiffMetadataQueryKey(serverId, cwd, mode, baseRef, compareIgnoreWhitespace),
|
||||
[serverId, cwd, mode, baseRef, compareIgnoreWhitespace],
|
||||
);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
const payload = await client.getCheckoutDiffMetadata(cwd, {
|
||||
mode: compareMode,
|
||||
baseRef: compareBaseRef,
|
||||
ignoreWhitespace: compareIgnoreWhitespace,
|
||||
});
|
||||
return {
|
||||
cwd: payload.cwd,
|
||||
files: orderCheckoutDiffFiles(payload.files),
|
||||
error: payload.error,
|
||||
requestId: payload.requestId,
|
||||
};
|
||||
},
|
||||
enabled: !!client && isConnected && !!cwd && enabled,
|
||||
staleTime: CHECKOUT_DIFF_METADATA_STALE_TIME,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !isConnected || !cwd || !enabled) {
|
||||
return;
|
||||
}
|
||||
if (!isOpen || explorerTab !== "changes") {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscriptionId = [
|
||||
"checkoutDiffMetadata",
|
||||
hookInstanceId,
|
||||
serverId,
|
||||
cwd,
|
||||
compareMode,
|
||||
compareBaseRef ?? "",
|
||||
compareIgnoreWhitespace ? "ignore-ws" : "keep-ws",
|
||||
].join(":");
|
||||
let cancelled = false;
|
||||
|
||||
const unsubscribeSnapshot = client.on("checkout/diff_snapshot", (message) => {
|
||||
if (message.type !== "checkout/diff_snapshot") {
|
||||
return;
|
||||
}
|
||||
if (message.payload.subscriptionId !== subscriptionId) {
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData<CheckoutDiffMetadataPayload>(queryKey, {
|
||||
cwd: message.payload.cwd,
|
||||
files: orderCheckoutDiffFiles(message.payload.files),
|
||||
error: message.payload.error,
|
||||
requestId: message.payload.requestId,
|
||||
});
|
||||
});
|
||||
|
||||
void client
|
||||
.subscribeCheckoutDiffLazy(
|
||||
cwd,
|
||||
{
|
||||
mode: compareMode,
|
||||
baseRef: compareBaseRef,
|
||||
ignoreWhitespace: compareIgnoreWhitespace,
|
||||
},
|
||||
{ subscriptionId },
|
||||
)
|
||||
.then((payload) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData<CheckoutDiffMetadataPayload>(queryKey, {
|
||||
cwd: payload.cwd,
|
||||
files: orderCheckoutDiffFiles(payload.files),
|
||||
error: payload.error,
|
||||
requestId: payload.requestId,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
console.error("[useCheckoutDiffMetadataQuery] subscribeCheckoutDiffLazy failed", {
|
||||
serverId,
|
||||
cwd,
|
||||
error,
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribeSnapshot();
|
||||
try {
|
||||
client.unsubscribeCheckoutDiffLazy(subscriptionId);
|
||||
} catch {
|
||||
// Ignore disconnect race during effect cleanup.
|
||||
}
|
||||
};
|
||||
}, [
|
||||
client,
|
||||
isConnected,
|
||||
cwd,
|
||||
enabled,
|
||||
isOpen,
|
||||
explorerTab,
|
||||
hookInstanceId,
|
||||
serverId,
|
||||
compareMode,
|
||||
compareBaseRef,
|
||||
compareIgnoreWhitespace,
|
||||
queryKey,
|
||||
queryClient,
|
||||
]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
return query.refetch();
|
||||
}, [query]);
|
||||
|
||||
const payload = query.data ?? null;
|
||||
const payloadError = payload?.error ?? null;
|
||||
|
||||
return {
|
||||
files: payload?.files ?? [],
|
||||
payloadError,
|
||||
isLoading: query.isLoading,
|
||||
isFetching: query.isFetching,
|
||||
isError: query.isError || Boolean(payloadError),
|
||||
error: query.error,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DiffFileMetadata } from "./use-checkout-diff-metadata-query";
|
||||
|
||||
/**
|
||||
* Tests for use-file-hunks hook logic.
|
||||
*
|
||||
* The hook itself is a thin React Query wrapper, so we test the key properties:
|
||||
* 1. Query key includes fingerprint — fingerprint change = automatic refetch
|
||||
* 2. Metadata fields are passed through to the merged result
|
||||
*/
|
||||
|
||||
function fileHunksQueryKey(
|
||||
serverId: string,
|
||||
cwd: string,
|
||||
mode: "uncommitted" | "base",
|
||||
baseRef: string | undefined,
|
||||
ignoreWhitespace: boolean | undefined,
|
||||
path: string,
|
||||
fingerprint: string,
|
||||
) {
|
||||
return [
|
||||
"checkoutFileHunks",
|
||||
serverId,
|
||||
cwd,
|
||||
mode,
|
||||
baseRef ?? "",
|
||||
ignoreWhitespace === true,
|
||||
path,
|
||||
fingerprint,
|
||||
] as const;
|
||||
}
|
||||
|
||||
describe("use-file-hunks query key", () => {
|
||||
it("includes fingerprint in query key", () => {
|
||||
const key = fileHunksQueryKey("s1", "/repo", "base", "main", false, "foo.ts", "fp-abc");
|
||||
expect(key).toContain("fp-abc");
|
||||
expect(key).toContain("foo.ts");
|
||||
});
|
||||
|
||||
it("different fingerprints produce different query keys", () => {
|
||||
const key1 = fileHunksQueryKey("s1", "/repo", "base", "main", false, "foo.ts", "fp-1");
|
||||
const key2 = fileHunksQueryKey("s1", "/repo", "base", "main", false, "foo.ts", "fp-2");
|
||||
expect(key1).not.toEqual(key2);
|
||||
// Only the fingerprint element differs
|
||||
expect(key1.slice(0, -1)).toEqual(key2.slice(0, -1));
|
||||
});
|
||||
|
||||
it("same fingerprint + same params produces identical key", () => {
|
||||
const key1 = fileHunksQueryKey("s1", "/repo", "uncommitted", undefined, true, "bar.ts", "fp-x");
|
||||
const key2 = fileHunksQueryKey("s1", "/repo", "uncommitted", undefined, true, "bar.ts", "fp-x");
|
||||
expect(key1).toEqual(key2);
|
||||
});
|
||||
|
||||
it("normalizes missing baseRef to empty string", () => {
|
||||
const key = fileHunksQueryKey("s1", "/repo", "uncommitted", undefined, false, "a.ts", "fp");
|
||||
expect(key[4]).toBe("");
|
||||
});
|
||||
|
||||
it("normalizes ignoreWhitespace to boolean", () => {
|
||||
const key = fileHunksQueryKey("s1", "/repo", "base", "main", undefined, "a.ts", "fp");
|
||||
expect(key[5]).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("use-file-hunks metadata merge", () => {
|
||||
it("metadata fields carry through to merged ParsedDiffFile shape", () => {
|
||||
const meta: DiffFileMetadata = {
|
||||
path: "src/index.ts",
|
||||
isNew: true,
|
||||
isDeleted: false,
|
||||
additions: 10,
|
||||
deletions: 2,
|
||||
status: "ok",
|
||||
fingerprint: "abc",
|
||||
};
|
||||
|
||||
// Simulates merging metadata with fetched hunks
|
||||
const merged = {
|
||||
path: meta.path,
|
||||
isNew: meta.isNew,
|
||||
isDeleted: meta.isDeleted,
|
||||
additions: meta.additions,
|
||||
deletions: meta.deletions,
|
||||
status: meta.status,
|
||||
hunks: [],
|
||||
};
|
||||
|
||||
expect(merged.path).toBe("src/index.ts");
|
||||
expect(merged.isNew).toBe(true);
|
||||
expect(merged.additions).toBe(10);
|
||||
expect(merged.hunks).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
|
||||
import type { DiffFileMetadata } from "@/hooks/use-checkout-diff-metadata-query";
|
||||
|
||||
function fileHunksQueryKey(
|
||||
serverId: string,
|
||||
cwd: string,
|
||||
mode: "uncommitted" | "base",
|
||||
baseRef: string | undefined,
|
||||
ignoreWhitespace: boolean | undefined,
|
||||
path: string,
|
||||
fingerprint: string,
|
||||
) {
|
||||
return [
|
||||
"checkoutFileHunks",
|
||||
serverId,
|
||||
cwd,
|
||||
mode,
|
||||
baseRef ?? "",
|
||||
ignoreWhitespace === true,
|
||||
path,
|
||||
fingerprint,
|
||||
] as const;
|
||||
}
|
||||
|
||||
interface UseFileHunksOptions {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
mode: "uncommitted" | "base";
|
||||
baseRef?: string;
|
||||
ignoreWhitespace?: boolean;
|
||||
file: DiffFileMetadata;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function useFileHunks({
|
||||
serverId,
|
||||
cwd,
|
||||
mode,
|
||||
baseRef,
|
||||
ignoreWhitespace,
|
||||
file,
|
||||
enabled = true,
|
||||
}: UseFileHunksOptions) {
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() =>
|
||||
fileHunksQueryKey(
|
||||
serverId,
|
||||
cwd,
|
||||
mode,
|
||||
baseRef,
|
||||
ignoreWhitespace,
|
||||
file.path,
|
||||
file.fingerprint,
|
||||
),
|
||||
[serverId, cwd, mode, baseRef, ignoreWhitespace, file.path, file.fingerprint],
|
||||
);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey,
|
||||
queryFn: async (): Promise<ParsedDiffFile> => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
const payload = await client.getCheckoutFileHunks(
|
||||
cwd,
|
||||
{ mode, baseRef, ignoreWhitespace },
|
||||
file.path,
|
||||
);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
return {
|
||||
path: file.path,
|
||||
isNew: file.isNew,
|
||||
isDeleted: file.isDeleted,
|
||||
additions: file.additions,
|
||||
deletions: file.deletions,
|
||||
status: file.status,
|
||||
hunks: payload.hunks,
|
||||
};
|
||||
},
|
||||
enabled: !!client && isConnected && enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
return {
|
||||
file: query.data ?? null,
|
||||
isLoading: query.isLoading,
|
||||
isFetching: query.isFetching,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getServerCapabilities,
|
||||
getVoiceReadinessState,
|
||||
resolveVoiceUnavailableMessage,
|
||||
supportsLazyDiffRpcs,
|
||||
} from "./server-info-capabilities";
|
||||
|
||||
function buildServerInfo(capabilities?: ServerCapabilities): DaemonServerInfo {
|
||||
@@ -117,3 +118,42 @@ describe("server-info-capabilities", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("supportsLazyDiffRpcs", () => {
|
||||
it("returns false when serverInfo is null", () => {
|
||||
expect(supportsLazyDiffRpcs({ serverInfo: null })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when serverInfo is undefined", () => {
|
||||
expect(supportsLazyDiffRpcs({ serverInfo: undefined })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when features is absent", () => {
|
||||
const serverInfo = buildServerInfo();
|
||||
expect(supportsLazyDiffRpcs({ serverInfo })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when lazyDiffRpcs is not set", () => {
|
||||
const serverInfo: DaemonServerInfo = {
|
||||
...buildServerInfo(),
|
||||
features: {},
|
||||
};
|
||||
expect(supportsLazyDiffRpcs({ serverInfo })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when lazyDiffRpcs is true", () => {
|
||||
const serverInfo: DaemonServerInfo = {
|
||||
...buildServerInfo(),
|
||||
features: { lazyDiffRpcs: true },
|
||||
};
|
||||
expect(supportsLazyDiffRpcs({ serverInfo })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when lazyDiffRpcs is false", () => {
|
||||
const serverInfo: DaemonServerInfo = {
|
||||
...buildServerInfo(),
|
||||
features: { lazyDiffRpcs: false },
|
||||
};
|
||||
expect(supportsLazyDiffRpcs({ serverInfo })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { ServerCapabilityState } from "@server/shared/messages";
|
||||
import type { DaemonServerInfo } from "@/stores/session-store";
|
||||
|
||||
export function supportsLazyDiffRpcs(params: {
|
||||
serverInfo: DaemonServerInfo | null | undefined;
|
||||
}): boolean {
|
||||
return params.serverInfo?.features?.lazyDiffRpcs === true;
|
||||
}
|
||||
|
||||
export type VoiceReadinessMode = "dictation" | "voice";
|
||||
|
||||
export function getServerCapabilities(params: {
|
||||
|
||||
Reference in New Issue
Block a user