diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index cd873cc289..0b9c45ccb6 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -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 ( + { + onBodyHeightChange?.(file.path, event.nativeEvent.layout.height); + }} + testID={testID} + > + + + {file.status === "binary" ? "Binary file" : "Diff too large to display"} + + + + ); + } + + if (isLoading) { + return ( + { + onBodyHeightChange?.(file.path, event.nativeEvent.layout.height); + }} + testID={testID} + > + + + + + ); + } + + if (isError || !resolvedFile) { + return ( + { + onBodyHeightChange?.(file.path, event.nativeEvent.layout.height); + }} + testID={testID} + > + + + {error instanceof Error ? error.message : "Failed to load diff"} + + + + ); + } + + return ( + + ); +} + 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 ( + + ); + } return ( (files: T[]): T[] { if (files.length < 2) { return files; } diff --git a/packages/app/src/hooks/use-checkout-diff-metadata-query.test.ts b/packages/app/src/hooks/use-checkout-diff-metadata-query.test.ts new file mode 100644 index 0000000000..bf85d8dcbb --- /dev/null +++ b/packages/app/src/hooks/use-checkout-diff-metadata-query.test.ts @@ -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"); + }); +}); diff --git a/packages/app/src/hooks/use-checkout-diff-metadata-query.ts b/packages/app/src/hooks/use-checkout-diff-metadata-query.ts new file mode 100644 index 0000000000..4c19786794 --- /dev/null +++ b/packages/app/src/hooks/use-checkout-diff-metadata-query.ts @@ -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; + +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(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(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, + }; +} diff --git a/packages/app/src/hooks/use-file-hunks.test.ts b/packages/app/src/hooks/use-file-hunks.test.ts new file mode 100644 index 0000000000..e6932ac46b --- /dev/null +++ b/packages/app/src/hooks/use-file-hunks.test.ts @@ -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([]); + }); +}); diff --git a/packages/app/src/hooks/use-file-hunks.ts b/packages/app/src/hooks/use-file-hunks.ts new file mode 100644 index 0000000000..b60db07745 --- /dev/null +++ b/packages/app/src/hooks/use-file-hunks.ts @@ -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 => { + 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, + }; +} diff --git a/packages/app/src/utils/server-info-capabilities.test.ts b/packages/app/src/utils/server-info-capabilities.test.ts index 00a9146adf..c6a36102c9 100644 --- a/packages/app/src/utils/server-info-capabilities.test.ts +++ b/packages/app/src/utils/server-info-capabilities.test.ts @@ -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); + }); +}); diff --git a/packages/app/src/utils/server-info-capabilities.ts b/packages/app/src/utils/server-info-capabilities.ts index fdba665548..2600d5d773 100644 --- a/packages/app/src/utils/server-info-capabilities.ts +++ b/packages/app/src/utils/server-info-capabilities.ts @@ -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: { diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index 74afe4b5c8..c1fb12e058 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -516,6 +516,70 @@ describe("DaemonClient", () => { }); }); + test("subscribes to lazy checkout diff updates via RPC handshake", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const promise = client.subscribeCheckoutDiffLazy( + "/tmp/project", + { mode: "uncommitted" }, + { subscriptionId: "checkout-lazy-sub-1" }, + ); + + expect(mock.sent).toHaveLength(1); + const request = JSON.parse(mock.sent[0]) as { + type: "session"; + message: { + type: "checkout/subscribe_diff"; + subscriptionId: string; + cwd: string; + compare: { mode: "uncommitted" | "base"; baseRef?: string }; + requestId: string; + }; + }; + expect(request.message.type).toBe("checkout/subscribe_diff"); + expect(request.message.subscriptionId).toBe("checkout-lazy-sub-1"); + expect(request.message.cwd).toBe("/tmp/project"); + expect(request.message.compare).toEqual({ mode: "uncommitted", ignoreWhitespace: false }); + + mock.triggerMessage( + JSON.stringify({ + type: "session", + message: { + type: "checkout/diff_snapshot", + payload: { + subscriptionId: "checkout-lazy-sub-1", + cwd: "/tmp/project", + files: [], + error: null, + requestId: request.message.requestId, + }, + }, + }), + ); + + await expect(promise).resolves.toEqual({ + subscriptionId: "checkout-lazy-sub-1", + cwd: "/tmp/project", + files: [], + error: null, + requestId: request.message.requestId, + }); + }); + test("getCheckoutDiff uses one-shot subscription protocol", async () => { const logger = createMockLogger(); const mock = createMockTransport(); @@ -589,6 +653,105 @@ describe("DaemonClient", () => { expect(unsubscribeRequest.message.subscriptionId).toBe(subscribeRequest.message.subscriptionId); }); + test("getCheckoutFileHunks requests hunks via RPC", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const promise = client.getCheckoutFileHunks( + "/tmp/project", + { mode: "base", baseRef: "main" }, + "src/app.ts", + ); + + expect(mock.sent).toHaveLength(1); + const request = JSON.parse(mock.sent[0]) as { + type: "session"; + message: { + type: "checkout/get_file_hunks"; + cwd: string; + compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }; + path: string; + requestId: string; + }; + }; + expect(request.message.type).toBe("checkout/get_file_hunks"); + expect(request.message.cwd).toBe("/tmp/project"); + expect(request.message.compare).toEqual({ + mode: "base", + baseRef: "main", + ignoreWhitespace: false, + }); + expect(request.message.path).toBe("src/app.ts"); + + mock.triggerMessage( + JSON.stringify({ + type: "session", + message: { + type: "checkout/file_hunks_response", + payload: { + requestId: request.message.requestId, + path: "src/app.ts", + hunks: [], + error: null, + }, + }, + }), + ); + + await expect(promise).resolves.toEqual({ + requestId: request.message.requestId, + path: "src/app.ts", + hunks: [], + error: null, + }); + }); + + test("unsubscribeCheckoutDiffLazy sends unsubscribe request", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + client.unsubscribeCheckoutDiffLazy("checkout-lazy-sub-1"); + + expect(mock.sent).toHaveLength(1); + const request = JSON.parse(mock.sent[0]) as { + type: "session"; + message: { + type: "checkout/unsubscribe_diff"; + subscriptionId: string; + }; + }; + expect(request.message).toEqual({ + type: "checkout/unsubscribe_diff", + subscriptionId: "checkout-lazy-sub-1", + }); + }); + test("requests branch suggestions via RPC", async () => { const logger = createMockLogger(); const mock = createMockTransport(); @@ -886,6 +1049,60 @@ describe("DaemonClient", () => { expect(request.message.requestId.length).toBeGreaterThan(0); }); + test("resubscribes lazy checkout diff streams after reconnect", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const internal = client as unknown as { + checkoutDiffLazySubscriptions: Map< + string, + { + cwd: string; + compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }; + } + >; + }; + internal.checkoutDiffLazySubscriptions.set("checkout-lazy-sub-1", { + cwd: "/tmp/project", + compare: { mode: "base", baseRef: "main", ignoreWhitespace: false }, + }); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + expect(mock.sent).toHaveLength(1); + const request = JSON.parse(mock.sent[0]) as { + type: "session"; + message: { + type: "checkout/subscribe_diff"; + subscriptionId: string; + cwd: string; + compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }; + requestId: string; + }; + }; + expect(request.message.type).toBe("checkout/subscribe_diff"); + expect(request.message.subscriptionId).toBe("checkout-lazy-sub-1"); + expect(request.message.cwd).toBe("/tmp/project"); + expect(request.message.compare).toEqual({ + mode: "base", + baseRef: "main", + ignoreWhitespace: false, + }); + expect(typeof request.message.requestId).toBe("string"); + expect(request.message.requestId.length).toBeGreaterThan(0); + }); + test("fetches agents via RPC with filters, sort, and pagination", async () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 2d7ba7db40..6681b14c73 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -216,6 +216,14 @@ type SubscribeCheckoutDiffPayload = Extract< { type: "subscribe_checkout_diff_response" } >["payload"]; type CheckoutDiffPayload = Omit; +type CheckoutDiffMetadataSnapshotPayload = Extract< + SessionOutboundMessage, + { type: "checkout/diff_snapshot" } +>["payload"]; +type CheckoutFileHunksPayload = Extract< + SessionOutboundMessage, + { type: "checkout/file_hunks_response" } +>["payload"]; type CheckoutCommitPayload = CheckoutCommitResponse["payload"]; type CheckoutMergePayload = CheckoutMergeResponse["payload"]; type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"]; @@ -500,8 +508,13 @@ type SetDaemonConfigResponse = Extract< SessionOutboundMessage, { type: "set_daemon_config_response" } >; +type CheckoutDiffSnapshotResponse = Extract< + SessionOutboundMessage, + { type: "checkout/diff_snapshot" } +>; type CorrelatedResponseMessage = | Extract + | CheckoutDiffSnapshotResponse | GetDaemonConfigResponse | SetDaemonConfigResponse; type CorrelatedResponseType = CorrelatedResponseMessage["type"]; @@ -616,6 +629,13 @@ export class DaemonClient { compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }; } >(); + private checkoutDiffLazySubscriptions = new Map< + string, + { + cwd: string; + compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }; + } + >(); private terminalDirectorySubscriptions = new Set(); private terminalSlots = new Map(); private slotTerminals = new Map(); @@ -1408,6 +1428,22 @@ export class DaemonClient { } } + private resubscribeCheckoutDiffLazySubscriptions(): void { + if (this.checkoutDiffLazySubscriptions.size === 0) { + return; + } + for (const [subscriptionId, subscription] of this.checkoutDiffLazySubscriptions) { + const message = SessionInboundMessageSchema.parse({ + type: "checkout/subscribe_diff", + subscriptionId, + cwd: subscription.cwd, + compare: subscription.compare, + requestId: this.createRequestId(), + }); + this.sendSessionMessage(message); + } + } + private resubscribeTerminalDirectorySubscriptions(): void { if (this.terminalDirectorySubscriptions.size === 0) { return; @@ -2270,6 +2306,105 @@ export class DaemonClient { }); } + async getCheckoutDiffMetadata( + cwd: string, + compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }, + requestId?: string, + ): Promise> { + const oneShotSubscriptionId = `oneshot-checkout-diff-lazy:${crypto.randomUUID()}`; + try { + const payload = await this.subscribeCheckoutDiffLazy(cwd, compare, { + subscriptionId: oneShotSubscriptionId, + requestId, + }); + return { + cwd: payload.cwd, + files: payload.files, + error: payload.error, + requestId: payload.requestId, + }; + } finally { + try { + this.unsubscribeCheckoutDiffLazy(oneShotSubscriptionId); + } catch { + // Ignore disconnect races during one-shot cleanup. + } + } + } + + async subscribeCheckoutDiffLazy( + cwd: string, + compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }, + options?: { subscriptionId?: string; requestId?: string }, + ): Promise { + const subscriptionId = options?.subscriptionId ?? crypto.randomUUID(); + const normalizedCompare = this.normalizeCheckoutDiffCompare(compare); + const previousSubscription = this.checkoutDiffLazySubscriptions.get(subscriptionId) ?? null; + this.checkoutDiffLazySubscriptions.set(subscriptionId, { + cwd, + compare: normalizedCompare, + }); + + const resolvedRequestId = this.createRequestId(options?.requestId); + const message = SessionInboundMessageSchema.parse({ + type: "checkout/subscribe_diff", + subscriptionId, + cwd, + compare: normalizedCompare, + requestId: resolvedRequestId, + }); + + try { + return await this.sendCorrelatedRequest({ + requestId: resolvedRequestId, + message, + responseType: "checkout/diff_snapshot", + timeout: 60000, + options: { skipQueue: true }, + selectPayload: (payload) => { + if (payload.subscriptionId !== subscriptionId) { + return null; + } + return payload; + }, + }); + } catch (error) { + if (previousSubscription) { + this.checkoutDiffLazySubscriptions.set(subscriptionId, previousSubscription); + } else { + this.checkoutDiffLazySubscriptions.delete(subscriptionId); + } + throw error; + } + } + + unsubscribeCheckoutDiffLazy(subscriptionId: string): void { + this.checkoutDiffLazySubscriptions.delete(subscriptionId); + this.sendSessionMessage({ + type: "checkout/unsubscribe_diff", + subscriptionId, + }); + } + + async getCheckoutFileHunks( + cwd: string, + compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }, + path: string, + requestId?: string, + ): Promise { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { + type: "checkout/get_file_hunks", + cwd, + compare: this.normalizeCheckoutDiffCompare(compare), + path, + }, + responseType: "checkout/file_hunks_response", + timeout: 60000, + }); + } + async checkoutCommit( cwd: string, input: { message?: string; addAll?: boolean }, @@ -3723,6 +3858,7 @@ export class DaemonClient { this.reconnectAttempt = 0; this.updateConnectionState({ status: "connected" }, { event: "HELLO_SERVER_INFO" }); this.resubscribeCheckoutDiffSubscriptions(); + this.resubscribeCheckoutDiffLazySubscriptions(); this.resubscribeTerminalDirectorySubscriptions(); this.flushPendingSendQueue(); this.resolveConnect(); diff --git a/packages/server/src/server/checkout-diff-manager.test.ts b/packages/server/src/server/checkout-diff-manager.test.ts index 356f8cf15c..f8ca46815c 100644 --- a/packages/server/src/server/checkout-diff-manager.test.ts +++ b/packages/server/src/server/checkout-diff-manager.test.ts @@ -81,6 +81,53 @@ vi.mock("./checkout-git-utils.js", () => ({ import { CheckoutDiffManager } from "./checkout-diff-manager.js"; +function createStructuredFile(options?: { + path?: string; + addContent?: string; + tokenStyle?: string; +}): { + path: string; + isNew: boolean; + isDeleted: boolean; + additions: number; + deletions: number; + hunks: Array<{ + oldStart: number; + oldCount: number; + newStart: number; + newCount: number; + lines: Array<{ type: "header" | "context" | "add"; content: string; tokens?: unknown[] }>; + }>; + status: "ok"; +} { + const addContent = options?.addContent ?? "const value = 2;"; + return { + path: options?.path ?? "src/example.ts", + isNew: false, + isDeleted: false, + additions: 1, + deletions: 0, + hunks: [ + { + oldStart: 1, + oldCount: 1, + newStart: 1, + newCount: 2, + lines: [ + { type: "header", content: "@@ -1,1 +1,2 @@" }, + { type: "context", content: "const value = 1;" }, + { + type: "add", + content: addContent, + tokens: [{ text: "const", style: options?.tokenStyle ?? "keyword" }], + }, + ], + }, + ], + status: "ok", + }; +} + describe("CheckoutDiffManager Linux watchers", () => { const originalPlatform = process.platform; @@ -134,4 +181,102 @@ describe("CheckoutDiffManager Linux watchers", () => { subscription.unsubscribe(); manager.dispose(); }); + + test("subscribeLazy returns metadata snapshots and caches file hunks", async () => { + getCheckoutDiffMock.mockResolvedValueOnce({ + diff: "", + structured: [createStructuredFile()], + }); + + const logger = { + child: () => logger, + warn: vi.fn(), + }; + const manager = new CheckoutDiffManager({ + logger: logger as any, + paseoHome: "/tmp/paseo-test", + }); + + const listener = vi.fn(); + const subscription = await manager.subscribeLazy( + { + cwd: path.join("/tmp/repo", "packages", "server"), + compare: { mode: "uncommitted" }, + }, + listener, + ); + + expect(subscription.initial.error).toBeNull(); + expect(subscription.initial.files).toEqual([ + expect.objectContaining({ + path: "src/example.ts", + additions: 1, + deletions: 0, + fingerprint: expect.any(String), + }), + ]); + expect((subscription.initial.files[0] as any).hunks).toBeUndefined(); + expect( + manager.getFileHunks( + path.join("/tmp/repo", "packages", "server"), + { mode: "uncommitted" }, + "src/example.ts", + ), + ).toEqual(createStructuredFile().hunks); + + subscription.unsubscribe(); + manager.dispose(); + }); + + test("lazy metadata fingerprint ignores token-only changes but emits on hunk changes", async () => { + getCheckoutDiffMock.mockResolvedValueOnce({ + diff: "", + structured: [createStructuredFile({ tokenStyle: "keyword" })], + }); + getCheckoutDiffMock.mockResolvedValueOnce({ + diff: "", + structured: [createStructuredFile({ tokenStyle: "variableName" })], + }); + getCheckoutDiffMock.mockResolvedValueOnce({ + diff: "", + structured: [createStructuredFile({ addContent: "const value = 3;" })], + }); + + const logger = { + child: () => logger, + warn: vi.fn(), + }; + const manager = new CheckoutDiffManager({ + logger: logger as any, + paseoHome: "/tmp/paseo-test", + }); + + const listener = vi.fn(); + await manager.subscribeLazy( + { + cwd: path.join("/tmp/repo", "packages", "server"), + compare: { mode: "uncommitted" }, + }, + listener, + ); + + const target = Array.from((manager as any).targets.values())[0]; + await (manager as any).refreshTarget(target); + expect(listener).not.toHaveBeenCalled(); + + await (manager as any).refreshTarget(target); + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenLastCalledWith( + expect.objectContaining({ + files: [ + expect.objectContaining({ + path: "src/example.ts", + fingerprint: expect.any(String), + }), + ], + }), + ); + + manager.dispose(); + }); }); diff --git a/packages/server/src/server/checkout-diff-manager.ts b/packages/server/src/server/checkout-diff-manager.ts index 0274fff221..d47775ff78 100644 --- a/packages/server/src/server/checkout-diff-manager.ts +++ b/packages/server/src/server/checkout-diff-manager.ts @@ -1,8 +1,10 @@ +import { createHash } from "node:crypto"; import { watch, type FSWatcher } from "node:fs"; import { readdir } from "node:fs/promises"; import { join } from "node:path"; import type pino from "pino"; import type { SubscribeCheckoutDiffRequest, SessionOutboundMessage } from "./messages.js"; +import type { DiffFileMetadata } from "./checkout/rpc-schemas.js"; import { getCheckoutDiff } from "../utils/checkout-git.js"; import { expandTilde } from "../utils/path.js"; import { runGitCommand } from "../utils/run-git-command.js"; @@ -18,6 +20,16 @@ export type CheckoutDiffSnapshotPayload = Omit< "subscriptionId" >; +type ParsedDiffFile = CheckoutDiffSnapshotPayload["files"][number]; + +export type { DiffFileMetadata }; + +export type CheckoutLazyDiffSnapshotPayload = { + cwd: string; + files: DiffFileMetadata[]; + error: CheckoutDiffSnapshotPayload["error"]; +}; + export type CheckoutDiffMetrics = { checkoutDiffTargetCount: number; checkoutDiffSubscriptionCount: number; @@ -31,13 +43,17 @@ type CheckoutDiffWatchTarget = { diffCwd: string; compare: CheckoutDiffCompareInput; listeners: Set<(snapshot: CheckoutDiffSnapshotPayload) => void>; + lazyListeners: Set<(snapshot: CheckoutLazyDiffSnapshotPayload) => void>; watchers: FSWatcher[]; fallbackRefreshInterval: NodeJS.Timeout | null; debounceTimer: NodeJS.Timeout | null; refreshPromise: Promise | null; refreshQueued: boolean; latestPayload: CheckoutDiffSnapshotPayload | null; + latestLazyPayload: CheckoutLazyDiffSnapshotPayload | null; latestFingerprint: string | null; + latestFilesByPath: Map; + latestMetadataFingerprints: Map; watchedPaths: Set; repoWatchPath: string | null; linuxTreeRefreshPromise: Promise | null; @@ -72,6 +88,7 @@ export class CheckoutDiffManager { diffCwd: target.diffCwd, })); target.latestPayload = initial; + target.latestFilesByPath = new Map(initial.files.map((file) => [file.path, file])); target.latestFingerprint = JSON.stringify(initial); return { initial, @@ -81,6 +98,36 @@ export class CheckoutDiffManager { }; } + async subscribeLazy( + params: { + cwd: string; + compare: CheckoutDiffCompareInput; + }, + listener: (snapshot: CheckoutLazyDiffSnapshotPayload) => void, + ): Promise<{ initial: CheckoutLazyDiffSnapshotPayload; unsubscribe: () => void }> { + const cwd = params.cwd; + const compare = this.normalizeCompare(params.compare); + const target = await this.ensureTarget(cwd, compare); + target.lazyListeners.add(listener); + + const baseSnapshot = + target.latestPayload ?? + (await this.computeCheckoutDiffSnapshot(target.cwd, target.compare, { + diffCwd: target.diffCwd, + })); + target.latestPayload = baseSnapshot; + target.latestFilesByPath = new Map(baseSnapshot.files.map((file) => [file.path, file])); + const initial = target.latestLazyPayload ?? this.buildLazySnapshot(baseSnapshot); + target.latestLazyPayload = initial; + target.latestMetadataFingerprints = this.buildMetadataFingerprintMap(initial.files); + return { + initial, + unsubscribe: () => { + this.removeLazyListener(target.key, listener); + }, + }; + } + scheduleRefreshForCwd(cwd: string): void { const resolvedCwd = expandTilde(cwd); for (const target of this.targets.values()) { @@ -98,6 +145,7 @@ export class CheckoutDiffManager { for (const target of this.targets.values()) { checkoutDiffSubscriptionCount += target.listeners.size; + checkoutDiffSubscriptionCount += target.lazyListeners.size; checkoutDiffWatcherCount += target.watchers.length; if (target.fallbackRefreshInterval) { checkoutDiffFallbackRefreshTargetCount += 1; @@ -154,6 +202,9 @@ export class CheckoutDiffManager { target.watchers = []; target.watchedPaths.clear(); target.listeners.clear(); + target.lazyListeners.clear(); + target.latestFilesByPath.clear(); + target.latestMetadataFingerprints.clear(); } private removeListener( @@ -165,7 +216,23 @@ export class CheckoutDiffManager { return; } target.listeners.delete(listener); - if (target.listeners.size > 0) { + if (target.listeners.size > 0 || target.lazyListeners.size > 0) { + return; + } + this.closeTarget(target); + this.targets.delete(targetKey); + } + + private removeLazyListener( + targetKey: string, + listener: (snapshot: CheckoutLazyDiffSnapshotPayload) => void, + ): void { + const target = this.targets.get(targetKey); + if (!target) { + return; + } + target.lazyListeners.delete(listener); + if (target.listeners.size > 0 || target.lazyListeners.size > 0) { return; } this.closeTarget(target); @@ -188,6 +255,15 @@ export class CheckoutDiffManager { } } + getFileHunks( + cwd: string, + compare: CheckoutDiffCompareInput, + path: string, + ): ParsedDiffFile["hunks"] | null { + const target = this.targets.get(this.buildTargetKey(cwd, this.normalizeCompare(compare))); + return target?.latestFilesByPath.get(path)?.hunks ?? null; + } + private scheduleTargetRefresh(target: CheckoutDiffWatchTarget): void { if (target.debounceTimer) { clearTimeout(target.debounceTimer); @@ -198,6 +274,81 @@ export class CheckoutDiffManager { }, CHECKOUT_DIFF_WATCH_DEBOUNCE_MS); } + private buildLazySnapshot( + snapshot: CheckoutDiffSnapshotPayload, + ): CheckoutLazyDiffSnapshotPayload { + return { + cwd: snapshot.cwd, + files: this.deriveMetadata(snapshot.files), + error: snapshot.error, + }; + } + + private deriveMetadata(files: ParsedDiffFile[]): DiffFileMetadata[] { + return files.map((file) => ({ + path: file.path, + isNew: file.isNew, + isDeleted: file.isDeleted, + additions: file.additions, + deletions: file.deletions, + ...(file.status ? { status: file.status } : {}), + fingerprint: this.buildFileFingerprint(file), + })); + } + + private buildFileFingerprint(file: ParsedDiffFile): string { + const serialized = JSON.stringify({ + hunks: file.hunks.map((hunk) => ({ + oldStart: hunk.oldStart, + oldCount: hunk.oldCount, + newStart: hunk.newStart, + newCount: hunk.newCount, + lines: hunk.lines.map((line) => ({ + type: line.type, + content: line.content, + })), + })), + }); + return createHash("sha256").update(serialized).digest("hex"); + } + + private buildMetadataFingerprintMap(files: DiffFileMetadata[]): Map { + return new Map( + files.map((file) => [ + file.path, + createHash("sha256").update(JSON.stringify(file)).digest("hex"), + ]), + ); + } + + private metadataSnapshotChanged( + target: CheckoutDiffWatchTarget, + snapshot: CheckoutLazyDiffSnapshotPayload, + ): boolean { + if (!target.latestLazyPayload) { + return true; + } + if (Boolean(target.latestLazyPayload.error) !== Boolean(snapshot.error)) { + return true; + } + if ( + target.latestLazyPayload.error?.code !== snapshot.error?.code || + target.latestLazyPayload.error?.message !== snapshot.error?.message + ) { + return true; + } + const nextFingerprints = this.buildMetadataFingerprintMap(snapshot.files); + if (nextFingerprints.size !== target.latestMetadataFingerprints.size) { + return true; + } + for (const [path, fingerprint] of nextFingerprints) { + if (target.latestMetadataFingerprints.get(path) !== fingerprint) { + return true; + } + } + return false; + } + private async computeCheckoutDiffSnapshot( cwd: string, compare: CheckoutDiffCompareInput, @@ -247,6 +398,7 @@ export class CheckoutDiffManager { diffCwd: target.diffCwd, }); target.latestPayload = snapshot; + target.latestFilesByPath = new Map(snapshot.files.map((file) => [file.path, file])); const fingerprint = JSON.stringify(snapshot); if (fingerprint !== target.latestFingerprint) { target.latestFingerprint = fingerprint; @@ -254,6 +406,17 @@ export class CheckoutDiffManager { listener(snapshot); } } + + const lazySnapshot = this.buildLazySnapshot(snapshot); + if (this.metadataSnapshotChanged(target, lazySnapshot)) { + target.latestLazyPayload = lazySnapshot; + target.latestMetadataFingerprints = this.buildMetadataFingerprintMap(lazySnapshot.files); + for (const listener of target.lazyListeners) { + listener(lazySnapshot); + } + } else { + target.latestLazyPayload = lazySnapshot; + } } while (target.refreshQueued); })(); @@ -281,13 +444,17 @@ export class CheckoutDiffManager { diffCwd: watchRoot ?? cwd, compare, listeners: new Set(), + lazyListeners: new Set(), watchers: [], fallbackRefreshInterval: null, debounceTimer: null, refreshPromise: null, refreshQueued: false, latestPayload: null, + latestLazyPayload: null, latestFingerprint: null, + latestFilesByPath: new Map(), + latestMetadataFingerprints: new Map(), watchedPaths: new Set(), repoWatchPath: null, linuxTreeRefreshPromise: null, diff --git a/packages/server/src/server/checkout/rpc-schemas.test.ts b/packages/server/src/server/checkout/rpc-schemas.test.ts new file mode 100644 index 0000000000..ecd1e3a908 --- /dev/null +++ b/packages/server/src/server/checkout/rpc-schemas.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, test } from "vitest"; +import { + ServerInfoStatusPayloadSchema, + SessionInboundMessageSchema, + SessionOutboundMessageSchema, +} from "../../shared/messages.js"; +import { DiffFileMetadataSchema } from "./rpc-schemas.js"; + +describe("checkout lazy RPC schemas", () => { + test("parses checkout/subscribe_diff inbound messages", () => { + const parsed = SessionInboundMessageSchema.parse({ + type: "checkout/subscribe_diff", + requestId: "req-1", + subscriptionId: "sub-1", + cwd: "/tmp/repo", + compare: { mode: "uncommitted" }, + }); + + expect(parsed.type).toBe("checkout/subscribe_diff"); + }); + + test("parses checkout/unsubscribe_diff inbound messages", () => { + const parsed = SessionInboundMessageSchema.parse({ + type: "checkout/unsubscribe_diff", + subscriptionId: "sub-1", + }); + + expect(parsed.type).toBe("checkout/unsubscribe_diff"); + }); + + test("parses checkout/get_file_hunks inbound messages", () => { + const parsed = SessionInboundMessageSchema.parse({ + type: "checkout/get_file_hunks", + requestId: "req-2", + cwd: "/tmp/repo", + path: "src/index.ts", + compare: { mode: "base", baseRef: "main", ignoreWhitespace: true }, + }); + + expect(parsed.type).toBe("checkout/get_file_hunks"); + }); + + test("parses checkout/diff_snapshot outbound messages", () => { + const parsed = SessionOutboundMessageSchema.parse({ + type: "checkout/diff_snapshot", + payload: { + subscriptionId: "sub-1", + requestId: "req-1", + cwd: "/tmp/repo", + files: [ + { + path: "src/index.ts", + isNew: false, + isDeleted: false, + additions: 3, + deletions: 1, + status: "ok", + fingerprint: "fp-1", + }, + ], + error: null, + }, + }); + + expect(parsed.type).toBe("checkout/diff_snapshot"); + }); + + test("parses checkout/file_hunks_response outbound messages", () => { + const parsed = SessionOutboundMessageSchema.parse({ + type: "checkout/file_hunks_response", + payload: { + requestId: "req-3", + path: "src/index.ts", + hunks: [ + { + oldStart: 1, + oldCount: 1, + newStart: 1, + newCount: 2, + lines: [ + { type: "header", content: "@@ -1,1 +1,2 @@" }, + { type: "context", content: "const a = 1;" }, + { type: "add", content: "const b = 2;" }, + ], + }, + ], + error: null, + }, + }); + + expect(parsed.type).toBe("checkout/file_hunks_response"); + }); + + test("requires fingerprint on diff metadata", () => { + const result = DiffFileMetadataSchema.safeParse({ + path: "src/index.ts", + isNew: false, + isDeleted: false, + additions: 3, + deletions: 1, + status: "ok", + }); + + expect(result.success).toBe(false); + }); + + test("accepts lazyDiffRpcs in server info features", () => { + const parsed = ServerInfoStatusPayloadSchema.parse({ + status: "server_info", + serverId: "server-1", + hostname: null, + version: null, + capabilities: {}, + features: { + providersSnapshot: true, + lazyDiffRpcs: true, + }, + }); + + expect(parsed.features?.lazyDiffRpcs).toBe(true); + }); +}); diff --git a/packages/server/src/server/checkout/rpc-schemas.ts b/packages/server/src/server/checkout/rpc-schemas.ts new file mode 100644 index 0000000000..df6bbbcea0 --- /dev/null +++ b/packages/server/src/server/checkout/rpc-schemas.ts @@ -0,0 +1,60 @@ +import { z } from "zod"; +import { + CheckoutDiffCompareSchema, + CheckoutErrorSchema, + DiffHunkSchema, +} from "../../shared/messages.js"; + +export const DiffFileMetadataSchema = z.object({ + path: z.string(), + isNew: z.boolean(), + isDeleted: z.boolean(), + additions: z.number(), + deletions: z.number(), + status: z.enum(["ok", "too_large", "binary"]).optional(), + fingerprint: z.string(), +}); + +export type DiffFileMetadata = z.infer; + +export const CheckoutSubscribeDiffRequestSchema = z.object({ + type: z.literal("checkout/subscribe_diff"), + requestId: z.string(), + subscriptionId: z.string(), + cwd: z.string(), + compare: z.lazy(() => CheckoutDiffCompareSchema), +}); + +export const CheckoutUnsubscribeDiffRequestSchema = z.object({ + type: z.literal("checkout/unsubscribe_diff"), + subscriptionId: z.string(), +}); + +export const CheckoutGetFileHunksRequestSchema = z.object({ + type: z.literal("checkout/get_file_hunks"), + requestId: z.string(), + cwd: z.string(), + path: z.string(), + compare: z.lazy(() => CheckoutDiffCompareSchema), +}); + +export const CheckoutDiffSnapshotSchema = z.object({ + type: z.literal("checkout/diff_snapshot"), + payload: z.object({ + subscriptionId: z.string(), + requestId: z.string().optional(), + cwd: z.string(), + files: z.array(DiffFileMetadataSchema), + error: z.lazy(() => CheckoutErrorSchema).nullable(), + }), +}); + +export const CheckoutFileHunksResponseSchema = z.object({ + type: z.literal("checkout/file_hunks_response"), + payload: z.object({ + requestId: z.string(), + path: z.string(), + hunks: z.array(z.lazy(() => DiffHunkSchema)), + error: z.lazy(() => CheckoutErrorSchema).nullable(), + }), +}); diff --git a/packages/server/src/server/session.checkout-diff.test.ts b/packages/server/src/server/session.checkout-diff.test.ts new file mode 100644 index 0000000000..45b128df04 --- /dev/null +++ b/packages/server/src/server/session.checkout-diff.test.ts @@ -0,0 +1,353 @@ +import { homedir } from "node:os"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const { getCheckoutDiffFileMock } = vi.hoisted(() => ({ + getCheckoutDiffFileMock: vi.fn(), +})); + +vi.mock("../utils/checkout-git.js", async () => { + const actual = await vi.importActual( + "../utils/checkout-git.js", + ); + return { + ...actual, + getCheckoutDiffFile: getCheckoutDiffFileMock, + }; +}); + +import { Session } from "./session.js"; + +function createSessionForCheckoutDiffTests(options?: { + checkoutDiffManager?: { + subscribe: ReturnType; + subscribeLazy: ReturnType; + getFileHunks: ReturnType; + scheduleRefreshForCwd: ReturnType; + getMetrics: ReturnType; + dispose: ReturnType; + }; +}) { + const emitted: any[] = []; + const logger = { + child: () => logger, + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + + const checkoutDiffManager = + options?.checkoutDiffManager ?? + ({ + subscribe: vi.fn(async () => ({ + initial: { cwd: "/tmp/repo", files: [], error: null }, + unsubscribe: vi.fn(), + })), + subscribeLazy: vi.fn(async () => ({ + initial: { cwd: "/tmp/repo", files: [], error: null }, + unsubscribe: vi.fn(), + })), + getFileHunks: vi.fn(() => null), + scheduleRefreshForCwd: vi.fn(), + getMetrics: vi.fn(() => ({ + checkoutDiffTargetCount: 0, + checkoutDiffSubscriptionCount: 0, + checkoutDiffWatcherCount: 0, + checkoutDiffFallbackRefreshTargetCount: 0, + })), + dispose: vi.fn(), + } as const); + + const session = new Session({ + clientId: "test-client", + appVersion: null, + onMessage: vi.fn(), + logger: logger as any, + downloadTokenStore: {} as any, + pushTokenStore: {} as any, + paseoHome: "/tmp/paseo-test", + agentManager: { + subscribe: () => () => {}, + listAgents: () => [], + getAgent: () => null, + archiveAgent: async () => ({ archivedAt: new Date().toISOString() }), + clearAgentAttention: async () => {}, + notifyAgentState: () => {}, + } as any, + agentStorage: { + list: async () => [], + get: async () => null, + } as any, + projectRegistry: { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [], + get: async () => null, + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + } as any, + workspaceRegistry: { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => [], + get: async () => null, + upsert: async () => {}, + archive: async () => {}, + remove: async () => {}, + } as any, + checkoutDiffManager: checkoutDiffManager as any, + workspaceGitService: { + subscribe: async () => ({ + initial: { + cwd: "/tmp/repo", + git: { + isGit: false, + repoRoot: null, + mainRepoRoot: null, + currentBranch: null, + remoteUrl: null, + isPaseoOwnedWorktree: false, + isDirty: null, + aheadBehind: null, + aheadOfOrigin: null, + behindOfOrigin: null, + diffStat: null, + }, + github: { + featuresEnabled: false, + pullRequest: null, + error: null, + refreshedAt: null, + }, + }, + unsubscribe: vi.fn(), + }), + peekSnapshot: () => null, + getSnapshot: async () => null, + refresh: async () => {}, + dispose: () => {}, + } as any, + mcpBaseUrl: null, + stt: null, + tts: null, + terminalManager: null, + }) as any; + + session.emit = (message: any) => emitted.push(message); + + return { session, emitted, checkoutDiffManager }; +} + +describe("Session checkout lazy diff handlers", () => { + beforeEach(() => { + getCheckoutDiffFileMock.mockReset(); + }); + + test("checkout/subscribe_diff emits initial metadata snapshot and replaces existing subscription", async () => { + const firstUnsubscribe = vi.fn(); + const secondUnsubscribe = vi.fn(); + const subscribeLazy = vi + .fn() + .mockResolvedValueOnce({ + initial: { + cwd: "/tmp/repo", + files: [ + { + path: "src/example.ts", + isNew: false, + isDeleted: false, + additions: 1, + deletions: 0, + fingerprint: "fp-1", + }, + ], + error: null, + }, + unsubscribe: firstUnsubscribe, + }) + .mockResolvedValueOnce({ + initial: { cwd: "/tmp/repo", files: [], error: null }, + unsubscribe: secondUnsubscribe, + }); + + const { session, emitted } = createSessionForCheckoutDiffTests({ + checkoutDiffManager: { + subscribe: vi.fn(), + subscribeLazy, + getFileHunks: vi.fn(), + scheduleRefreshForCwd: vi.fn(), + getMetrics: vi.fn(() => ({ + checkoutDiffTargetCount: 0, + checkoutDiffSubscriptionCount: 0, + checkoutDiffWatcherCount: 0, + checkoutDiffFallbackRefreshTargetCount: 0, + })), + dispose: vi.fn(), + }, + }); + + await session.handleMessage({ + type: "checkout/subscribe_diff", + requestId: "req-1", + subscriptionId: "sub-1", + cwd: "~/repo", + compare: { mode: "uncommitted" }, + }); + await session.handleMessage({ + type: "checkout/subscribe_diff", + requestId: "req-2", + subscriptionId: "sub-1", + cwd: "~/repo", + compare: { mode: "uncommitted" }, + }); + + expect(subscribeLazy).toHaveBeenNthCalledWith( + 1, + { cwd: `${homedir()}/repo`, compare: { mode: "uncommitted" } }, + expect.any(Function), + ); + expect(firstUnsubscribe).toHaveBeenCalledTimes(1); + expect(emitted[0]).toMatchObject({ + type: "checkout/diff_snapshot", + payload: { + subscriptionId: "sub-1", + requestId: "req-1", + cwd: "/tmp/repo", + }, + }); + }); + + test("checkout/get_file_hunks serves cached hunks before falling back to git", async () => { + const cachedHunks = [ + { + oldStart: 1, + oldCount: 1, + newStart: 1, + newCount: 2, + lines: [{ type: "header", content: "@@ -1,1 +1,2 @@" }], + }, + ]; + const getFileHunks = vi.fn(() => cachedHunks); + const { session, emitted } = createSessionForCheckoutDiffTests({ + checkoutDiffManager: { + subscribe: vi.fn(), + subscribeLazy: vi.fn(), + getFileHunks, + scheduleRefreshForCwd: vi.fn(), + getMetrics: vi.fn(() => ({ + checkoutDiffTargetCount: 0, + checkoutDiffSubscriptionCount: 0, + checkoutDiffWatcherCount: 0, + checkoutDiffFallbackRefreshTargetCount: 0, + })), + dispose: vi.fn(), + }, + }); + + await session.handleMessage({ + type: "checkout/get_file_hunks", + requestId: "req-hunks", + cwd: "/tmp/repo", + path: "src/example.ts", + compare: { mode: "uncommitted" }, + }); + + expect(getFileHunks).toHaveBeenCalledWith( + "/tmp/repo", + { mode: "uncommitted" }, + "src/example.ts", + ); + expect(getCheckoutDiffFileMock).not.toHaveBeenCalled(); + expect(emitted[0]).toMatchObject({ + type: "checkout/file_hunks_response", + payload: { + requestId: "req-hunks", + path: "src/example.ts", + hunks: cachedHunks, + error: null, + }, + }); + }); + + test("checkout/get_file_hunks falls back to single-file diff and unsubscribe cleans up", async () => { + const unsubscribe = vi.fn(); + getCheckoutDiffFileMock.mockResolvedValueOnce({ + path: "src/example.ts", + isNew: false, + isDeleted: false, + additions: 1, + deletions: 0, + hunks: [ + { + oldStart: 1, + oldCount: 1, + newStart: 1, + newCount: 2, + lines: [{ type: "header", content: "@@ -1,1 +1,2 @@" }], + }, + ], + status: "ok", + }); + + const { session, emitted } = createSessionForCheckoutDiffTests({ + checkoutDiffManager: { + subscribe: vi.fn(), + subscribeLazy: vi.fn(async () => ({ + initial: { cwd: "/tmp/repo", files: [], error: null }, + unsubscribe, + })), + getFileHunks: vi.fn(() => null), + scheduleRefreshForCwd: vi.fn(), + getMetrics: vi.fn(() => ({ + checkoutDiffTargetCount: 0, + checkoutDiffSubscriptionCount: 0, + checkoutDiffWatcherCount: 0, + checkoutDiffFallbackRefreshTargetCount: 0, + })), + dispose: vi.fn(), + }, + }); + + await session.handleMessage({ + type: "checkout/subscribe_diff", + requestId: "req-sub", + subscriptionId: "sub-1", + cwd: "/tmp/repo", + compare: { mode: "base", baseRef: "main" }, + }); + await session.handleMessage({ + type: "checkout/get_file_hunks", + requestId: "req-hunks", + cwd: "/tmp/repo", + path: "src/example.ts", + compare: { mode: "base", baseRef: "main" }, + }); + await session.handleMessage({ + type: "checkout/unsubscribe_diff", + subscriptionId: "sub-1", + }); + + expect(getCheckoutDiffFileMock).toHaveBeenCalledWith( + "/tmp/repo", + { + mode: "base", + baseRef: "main", + ignoreWhitespace: undefined, + includeStructured: true, + }, + "src/example.ts", + { paseoHome: "/tmp/paseo-test" }, + ); + expect(emitted[1]).toMatchObject({ + type: "checkout/file_hunks_response", + payload: { + requestId: "req-hunks", + path: "src/example.ts", + error: null, + }, + }); + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index e7d7d194af..a4304c4d41 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -28,6 +28,9 @@ import { type CaptureTerminalRequest, type SubscribeCheckoutDiffRequest, type UnsubscribeCheckoutDiffRequest, + type CheckoutSubscribeDiffRequest, + type CheckoutUnsubscribeDiffRequest, + type CheckoutGetFileHunksRequest, type DirectorySuggestionsRequest, type EditorTargetDescriptorPayload, type EditorTargetId, @@ -149,6 +152,7 @@ import { type WorktreeConfig } from "../utils/worktree.js"; import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js"; import { getCheckoutDiff, + getCheckoutDiffFile, getCheckoutStatus, listBranchSuggestions, commitChanges, @@ -622,6 +626,7 @@ export class Session { private inflightRequests = 0; private peakInflightRequests = 0; private readonly checkoutDiffSubscriptions = new Map void>(); + private readonly checkoutDiffLazySubscriptions = new Map void>(); private readonly workspaceGitSubscriptions = new Map void>(); private readonly registerVoiceSpeakHandler?: ( agentId: string, @@ -1783,6 +1788,18 @@ export class Session { this.handleUnsubscribeCheckoutDiffRequest(msg); break; + case "checkout/subscribe_diff": + await this.handleCheckoutSubscribeDiffRequest(msg); + break; + + case "checkout/get_file_hunks": + await this.handleCheckoutGetFileHunksRequest(msg); + break; + + case "checkout/unsubscribe_diff": + this.handleCheckoutUnsubscribeDiffRequest(msg); + break; + case "checkout_switch_branch_request": await this.handleCheckoutSwitchBranchRequest(msg); break; @@ -4277,6 +4294,89 @@ export class Session { this.checkoutDiffSubscriptions.delete(msg.subscriptionId); } + private async handleCheckoutSubscribeDiffRequest( + msg: CheckoutSubscribeDiffRequest, + ): Promise { + const cwd = expandTilde(msg.cwd); + this.checkoutDiffLazySubscriptions.get(msg.subscriptionId)?.(); + this.checkoutDiffLazySubscriptions.delete(msg.subscriptionId); + + const subscription = await this.checkoutDiffManager.subscribeLazy( + { cwd, compare: msg.compare }, + (snapshot) => { + this.emit({ + type: "checkout/diff_snapshot", + payload: { + subscriptionId: msg.subscriptionId, + ...snapshot, + }, + }); + }, + ); + this.checkoutDiffLazySubscriptions.set(msg.subscriptionId, subscription.unsubscribe); + + this.emit({ + type: "checkout/diff_snapshot", + payload: { + subscriptionId: msg.subscriptionId, + ...subscription.initial, + requestId: msg.requestId, + }, + }); + } + + private async handleCheckoutGetFileHunksRequest(msg: CheckoutGetFileHunksRequest): Promise { + const cwd = expandTilde(msg.cwd); + let hunks = this.checkoutDiffManager.getFileHunks(cwd, msg.compare, msg.path); + let error: { + code: "UNKNOWN" | "NOT_ALLOWED" | "NOT_GIT_REPO" | "MERGE_CONFLICT"; + message: string; + } | null = null; + + if (hunks === null) { + try { + const file = await getCheckoutDiffFile( + cwd, + { + mode: msg.compare.mode, + baseRef: msg.compare.baseRef, + ignoreWhitespace: msg.compare.ignoreWhitespace, + includeStructured: true, + }, + msg.path, + { paseoHome: this.paseoHome }, + ); + if (file) { + hunks = file.hunks; + } else { + hunks = []; + error = { + code: "UNKNOWN", + message: "File no longer present in diff", + }; + } + } catch (caughtError) { + hunks = []; + error = toCheckoutError(caughtError); + } + } + + this.emit({ + type: "checkout/file_hunks_response", + payload: { + requestId: msg.requestId, + path: msg.path, + hunks, + error, + }, + }); + } + + private handleCheckoutUnsubscribeDiffRequest(msg: CheckoutUnsubscribeDiffRequest): void { + this.checkoutDiffLazySubscriptions.get(msg.subscriptionId)?.(); + this.checkoutDiffLazySubscriptions.delete(msg.subscriptionId); + } + private async handleCheckoutSwitchBranchRequest( msg: Extract, ): Promise { @@ -7387,6 +7487,11 @@ export class Session { } this.checkoutDiffSubscriptions.clear(); + for (const unsubscribe of this.checkoutDiffLazySubscriptions.values()) { + unsubscribe(); + } + this.checkoutDiffLazySubscriptions.clear(); + for (const unsubscribe of this.workspaceGitSubscriptions.values()) { unsubscribe(); } diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 411c8dc7e9..2deb186c98 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -800,6 +800,7 @@ export class VoiceAssistantWebSocketServer { features: { // COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow. providersSnapshot: true, + lazyDiffRpcs: true, }, }; } diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 63fdd7e6bc..5c6d357790 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -47,6 +47,13 @@ import { LoopLogsResponseSchema, LoopStopResponseSchema, } from "../server/loop/rpc-schemas.js"; +import { + CheckoutDiffSnapshotSchema, + CheckoutFileHunksResponseSchema, + CheckoutGetFileHunksRequestSchema, + CheckoutSubscribeDiffRequestSchema, + CheckoutUnsubscribeDiffRequestSchema, +} from "../server/checkout/rpc-schemas.js"; // --------------------------------------------------------------------------- // Mutable daemon config schemas (shared between server store and client) // --------------------------------------------------------------------------- @@ -1013,12 +1020,12 @@ const CheckoutErrorCodeSchema = z.enum([ "UNKNOWN", ]); -const CheckoutErrorSchema = z.object({ +export const CheckoutErrorSchema = z.object({ code: CheckoutErrorCodeSchema, message: z.string(), }); -const CheckoutDiffCompareSchema = z.object({ +export const CheckoutDiffCompareSchema = z.object({ mode: z.enum(["uncommitted", "base"]), baseRef: z.string().optional(), ignoreWhitespace: z.boolean().optional(), @@ -1231,18 +1238,18 @@ export const ArchiveWorkspaceRequestSchema = z.object({ // Highlighted diff token schema // Note: style can be a compound class name (e.g., "heading meta") from the syntax highlighter -const HighlightTokenSchema = z.object({ +export const HighlightTokenSchema = z.object({ text: z.string(), style: z.string().nullable(), }); -const DiffLineSchema = z.object({ +export const DiffLineSchema = z.object({ type: z.enum(["add", "remove", "context", "header"]), content: z.string(), tokens: z.array(HighlightTokenSchema).optional(), }); -const DiffHunkSchema = z.object({ +export const DiffHunkSchema = z.object({ oldStart: z.number(), oldCount: z.number(), newStart: z.number(), @@ -1250,7 +1257,7 @@ const DiffHunkSchema = z.object({ lines: z.array(DiffLineSchema), }); -const ParsedDiffFileSchema = z.object({ +export const ParsedDiffFileSchema = z.object({ path: z.string(), isNew: z.boolean(), isDeleted: z.boolean(), @@ -1464,6 +1471,9 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ CheckoutStatusRequestSchema, SubscribeCheckoutDiffRequestSchema, UnsubscribeCheckoutDiffRequestSchema, + CheckoutSubscribeDiffRequestSchema, + CheckoutUnsubscribeDiffRequestSchema, + CheckoutGetFileHunksRequestSchema, CheckoutCommitRequestSchema, CheckoutMergeRequestSchema, CheckoutMergeFromBaseRequestSchema, @@ -1686,6 +1696,7 @@ export const ServerInfoStatusPayloadSchema = z features: z .object({ providersSnapshot: z.boolean().optional(), + lazyDiffRpcs: z.boolean().optional(), }) .optional(), }) @@ -2789,6 +2800,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ CheckoutStatusResponseSchema, SubscribeCheckoutDiffResponseSchema, CheckoutDiffUpdateSchema, + CheckoutDiffSnapshotSchema, + CheckoutFileHunksResponseSchema, CheckoutCommitResponseSchema, CheckoutMergeResponseSchema, CheckoutMergeFromBaseResponseSchema, @@ -2999,8 +3012,13 @@ export type CheckoutStatusRequest = z.infer; export type CheckoutStatusResponse = z.infer; export type SubscribeCheckoutDiffRequest = z.infer; export type UnsubscribeCheckoutDiffRequest = z.infer; +export type CheckoutSubscribeDiffRequest = z.infer; +export type CheckoutUnsubscribeDiffRequest = z.infer; +export type CheckoutGetFileHunksRequest = z.infer; export type SubscribeCheckoutDiffResponse = z.infer; export type CheckoutDiffUpdate = z.infer; +export type CheckoutDiffSnapshot = z.infer; +export type CheckoutFileHunksResponse = z.infer; export type CheckoutCommitRequest = z.infer; export type CheckoutCommitResponse = z.infer; export type CheckoutMergeRequest = z.infer; diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 45cd97f358..9a1c97edbe 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1277,9 +1277,10 @@ export async function getCheckoutShortstat( } } -export async function getCheckoutDiff( +async function getCheckoutDiffInternal( cwd: string, compare: CheckoutDiffCompare, + options?: { path?: string }, context?: CheckoutContext, ): Promise { await requireGitRepo(cwd); @@ -1305,7 +1306,12 @@ export async function getCheckoutDiff( const ignoreWhitespace = compare.ignoreWhitespace === true; const changes = await listCheckoutFileChanges(cwd, refForDiff, ignoreWhitespace); - changes.sort((a, b) => { + const targetPath = options?.path; + const filteredChanges = + typeof targetPath === "string" + ? changes.filter((change) => change.path === targetPath) + : changes; + filteredChanges.sort((a, b) => { if (a.path === b.path) return 0; return a.path < b.path ? -1 : 1; }); @@ -1329,8 +1335,8 @@ export async function getCheckoutDiff( } }; - const trackedChanges = changes.filter((change) => !change.isUntracked); - const untrackedChanges = changes.filter((change) => change.isUntracked === true); + const trackedChanges = filteredChanges.filter((change) => !change.isUntracked); + const untrackedChanges = filteredChanges.filter((change) => change.isUntracked === true); const trackedChangeByPath = new Map(trackedChanges.map((change) => [change.path, change])); const trackedNumstatByPath = @@ -1514,6 +1520,24 @@ export async function getCheckoutDiff( return { diff: diffText }; } +export async function getCheckoutDiff( + cwd: string, + compare: CheckoutDiffCompare, + context?: CheckoutContext, +): Promise { + return getCheckoutDiffInternal(cwd, compare, undefined, context); +} + +export async function getCheckoutDiffFile( + cwd: string, + compare: CheckoutDiffCompare, + path: string, + context?: CheckoutContext, +): Promise { + const result = await getCheckoutDiffInternal(cwd, compare, { path }, context); + return result.structured?.[0] ?? null; +} + export async function commitChanges( cwd: string, options: { message: string; addAll?: boolean },