mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
2 Commits
v0.2.3
...
orchestrat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a298b8f70 | ||
|
|
bf1d5d2ab2 |
@@ -32,11 +32,15 @@ import {
|
||||
} from "lucide-react-native";
|
||||
import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store";
|
||||
import {
|
||||
useCheckoutDiffQuery,
|
||||
type ParsedDiffFile,
|
||||
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 { 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 +134,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 +629,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 +740,8 @@ 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: "lazy-body"; file: DiffFileMetadata; fileIndex: number };
|
||||
|
||||
export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) {
|
||||
const { theme } = useUnistyles();
|
||||
@@ -687,7 +802,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
isError: isDiffError,
|
||||
error: diffError,
|
||||
refresh: refreshDiff,
|
||||
} = useCheckoutDiffQuery({
|
||||
} = useCheckoutDiffMetadataQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
mode: diffMode,
|
||||
@@ -788,7 +903,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
stickyIndices.push(items.length - 1);
|
||||
}
|
||||
if (isExpanded) {
|
||||
items.push({ type: "body", file, fileIndex: i });
|
||||
items.push({ type: "lazy-body", file: file as DiffFileMetadata, fileIndex: i });
|
||||
}
|
||||
}
|
||||
return { flatItems: items, stickyHeaderIndices: stickyIndices };
|
||||
@@ -1068,8 +1183,13 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DiffFileBody
|
||||
<LazyDiffFileBody
|
||||
file={item.file}
|
||||
serverId={serverId}
|
||||
cwd={cwd}
|
||||
mode={diffMode}
|
||||
baseRef={baseRef}
|
||||
ignoreWhitespace={changesPreferences.hideWhitespace}
|
||||
layout={effectiveLayout}
|
||||
wrapLines={wrapLines}
|
||||
onBodyHeightChange={handleBodyHeightChange}
|
||||
@@ -1078,10 +1198,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");
|
||||
});
|
||||
});
|
||||
214
packages/app/src/hooks/use-checkout-diff-metadata-query.ts
Normal file
214
packages/app/src/hooks/use-checkout-diff-metadata-query.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
93
packages/app/src/hooks/use-file-hunks.test.ts
Normal file
93
packages/app/src/hooks/use-file-hunks.test.ts
Normal file
@@ -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([]);
|
||||
});
|
||||
});
|
||||
99
packages/app/src/hooks/use-file-hunks.ts
Normal file
99
packages/app/src/hooks/use-file-hunks.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -216,6 +216,14 @@ type SubscribeCheckoutDiffPayload = Extract<
|
||||
{ type: "subscribe_checkout_diff_response" }
|
||||
>["payload"];
|
||||
type CheckoutDiffPayload = Omit<SubscribeCheckoutDiffPayload, "subscriptionId">;
|
||||
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<SessionOutboundMessage, { payload: { requestId: string } }>
|
||||
| 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<string>();
|
||||
private terminalSlots = new Map<string, number>();
|
||||
private slotTerminals = new Map<number, string>();
|
||||
@@ -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<Omit<CheckoutDiffMetadataSnapshotPayload, "subscriptionId">> {
|
||||
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<CheckoutDiffMetadataSnapshotPayload> {
|
||||
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<CheckoutFileHunksPayload> {
|
||||
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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void> | null;
|
||||
refreshQueued: boolean;
|
||||
latestPayload: CheckoutDiffSnapshotPayload | null;
|
||||
latestLazyPayload: CheckoutLazyDiffSnapshotPayload | null;
|
||||
latestFingerprint: string | null;
|
||||
latestFilesByPath: Map<string, ParsedDiffFile>;
|
||||
latestMetadataFingerprints: Map<string, string>;
|
||||
watchedPaths: Set<string>;
|
||||
repoWatchPath: string | null;
|
||||
linuxTreeRefreshPromise: Promise<void> | 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<string, string> {
|
||||
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<string>(),
|
||||
repoWatchPath: null,
|
||||
linuxTreeRefreshPromise: null,
|
||||
|
||||
122
packages/server/src/server/checkout/rpc-schemas.test.ts
Normal file
122
packages/server/src/server/checkout/rpc-schemas.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
60
packages/server/src/server/checkout/rpc-schemas.ts
Normal file
60
packages/server/src/server/checkout/rpc-schemas.ts
Normal file
@@ -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<typeof DiffFileMetadataSchema>;
|
||||
|
||||
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(),
|
||||
}),
|
||||
});
|
||||
353
packages/server/src/server/session.checkout-diff.test.ts
Normal file
353
packages/server/src/server/session.checkout-diff.test.ts
Normal file
@@ -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<typeof import("../utils/checkout-git.js")>(
|
||||
"../utils/checkout-git.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
getCheckoutDiffFile: getCheckoutDiffFileMock,
|
||||
};
|
||||
});
|
||||
|
||||
import { Session } from "./session.js";
|
||||
|
||||
function createSessionForCheckoutDiffTests(options?: {
|
||||
checkoutDiffManager?: {
|
||||
subscribe: ReturnType<typeof vi.fn>;
|
||||
subscribeLazy: ReturnType<typeof vi.fn>;
|
||||
getFileHunks: ReturnType<typeof vi.fn>;
|
||||
scheduleRefreshForCwd: ReturnType<typeof vi.fn>;
|
||||
getMetrics: ReturnType<typeof vi.fn>;
|
||||
dispose: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
}) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, () => void>();
|
||||
private readonly checkoutDiffLazySubscriptions = new Map<string, () => void>();
|
||||
private readonly workspaceGitSubscriptions = new Map<string, () => 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<void> {
|
||||
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<void> {
|
||||
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<SessionInboundMessage, { type: "checkout_switch_branch_request" }>,
|
||||
): Promise<void> {
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -800,6 +800,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
features: {
|
||||
// COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow.
|
||||
providersSnapshot: true,
|
||||
lazyDiffRpcs: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<typeof CheckoutStatusRequestSchema>;
|
||||
export type CheckoutStatusResponse = z.infer<typeof CheckoutStatusResponseSchema>;
|
||||
export type SubscribeCheckoutDiffRequest = z.infer<typeof SubscribeCheckoutDiffRequestSchema>;
|
||||
export type UnsubscribeCheckoutDiffRequest = z.infer<typeof UnsubscribeCheckoutDiffRequestSchema>;
|
||||
export type CheckoutSubscribeDiffRequest = z.infer<typeof CheckoutSubscribeDiffRequestSchema>;
|
||||
export type CheckoutUnsubscribeDiffRequest = z.infer<typeof CheckoutUnsubscribeDiffRequestSchema>;
|
||||
export type CheckoutGetFileHunksRequest = z.infer<typeof CheckoutGetFileHunksRequestSchema>;
|
||||
export type SubscribeCheckoutDiffResponse = z.infer<typeof SubscribeCheckoutDiffResponseSchema>;
|
||||
export type CheckoutDiffUpdate = z.infer<typeof CheckoutDiffUpdateSchema>;
|
||||
export type CheckoutDiffSnapshot = z.infer<typeof CheckoutDiffSnapshotSchema>;
|
||||
export type CheckoutFileHunksResponse = z.infer<typeof CheckoutFileHunksResponseSchema>;
|
||||
export type CheckoutCommitRequest = z.infer<typeof CheckoutCommitRequestSchema>;
|
||||
export type CheckoutCommitResponse = z.infer<typeof CheckoutCommitResponseSchema>;
|
||||
export type CheckoutMergeRequest = z.infer<typeof CheckoutMergeRequestSchema>;
|
||||
|
||||
@@ -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<CheckoutDiffResult> {
|
||||
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<CheckoutDiffResult> {
|
||||
return getCheckoutDiffInternal(cwd, compare, undefined, context);
|
||||
}
|
||||
|
||||
export async function getCheckoutDiffFile(
|
||||
cwd: string,
|
||||
compare: CheckoutDiffCompare,
|
||||
path: string,
|
||||
context?: CheckoutContext,
|
||||
): Promise<ParsedDiffFile | null> {
|
||||
const result = await getCheckoutDiffInternal(cwd, compare, { path }, context);
|
||||
return result.structured?.[0] ?? null;
|
||||
}
|
||||
|
||||
export async function commitChanges(
|
||||
cwd: string,
|
||||
options: { message: string; addAll?: boolean },
|
||||
|
||||
Reference in New Issue
Block a user