mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a298b8f70 | ||
|
|
bf1d5d2ab2 |
@@ -32,11 +32,15 @@ import {
|
|||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store";
|
import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store";
|
||||||
import {
|
import {
|
||||||
useCheckoutDiffQuery,
|
|
||||||
type ParsedDiffFile,
|
type ParsedDiffFile,
|
||||||
type DiffLine,
|
type DiffLine,
|
||||||
type HighlightToken,
|
type HighlightToken,
|
||||||
} from "@/hooks/use-checkout-diff-query";
|
} 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 { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
|
||||||
import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
|
import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
|
||||||
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
|
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 {
|
interface DiffFileSectionProps {
|
||||||
file: ParsedDiffFile;
|
file: DiffFileHeaderFields;
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
onToggle: (path: string) => void;
|
onToggle: (path: string) => void;
|
||||||
onHeaderHeightChange?: (path: string, height: number) => 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 {
|
interface GitDiffPaneProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
workspaceId?: string | null;
|
workspaceId?: string | null;
|
||||||
@@ -625,8 +740,8 @@ interface GitDiffPaneProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DiffFlatItem =
|
type DiffFlatItem =
|
||||||
| { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean }
|
| { 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) {
|
export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDiffPaneProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
@@ -687,7 +802,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
isError: isDiffError,
|
isError: isDiffError,
|
||||||
error: diffError,
|
error: diffError,
|
||||||
refresh: refreshDiff,
|
refresh: refreshDiff,
|
||||||
} = useCheckoutDiffQuery({
|
} = useCheckoutDiffMetadataQuery({
|
||||||
serverId,
|
serverId,
|
||||||
cwd,
|
cwd,
|
||||||
mode: diffMode,
|
mode: diffMode,
|
||||||
@@ -788,7 +903,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
stickyIndices.push(items.length - 1);
|
stickyIndices.push(items.length - 1);
|
||||||
}
|
}
|
||||||
if (isExpanded) {
|
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 };
|
return { flatItems: items, stickyHeaderIndices: stickyIndices };
|
||||||
@@ -1068,8 +1183,13 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<DiffFileBody
|
<LazyDiffFileBody
|
||||||
file={item.file}
|
file={item.file}
|
||||||
|
serverId={serverId}
|
||||||
|
cwd={cwd}
|
||||||
|
mode={diffMode}
|
||||||
|
baseRef={baseRef}
|
||||||
|
ignoreWhitespace={changesPreferences.hideWhitespace}
|
||||||
layout={effectiveLayout}
|
layout={effectiveLayout}
|
||||||
wrapLines={wrapLines}
|
wrapLines={wrapLines}
|
||||||
onBodyHeightChange={handleBodyHeightChange}
|
onBodyHeightChange={handleBodyHeightChange}
|
||||||
@@ -1078,10 +1198,15 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
baseRef,
|
||||||
|
changesPreferences.hideWhitespace,
|
||||||
|
cwd,
|
||||||
|
diffMode,
|
||||||
effectiveLayout,
|
effectiveLayout,
|
||||||
handleBodyHeightChange,
|
handleBodyHeightChange,
|
||||||
handleHeaderHeightChange,
|
handleHeaderHeightChange,
|
||||||
handleToggleExpanded,
|
handleToggleExpanded,
|
||||||
|
serverId,
|
||||||
wrapLines,
|
wrapLines,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages";
|
interface DiffFileWithPath {
|
||||||
|
path: string;
|
||||||
type ParsedDiffFile = SubscribeCheckoutDiffResponse["payload"]["files"][number];
|
}
|
||||||
|
|
||||||
export function compareCheckoutDiffPaths(left: string, right: string): number {
|
export function compareCheckoutDiffPaths(left: string, right: string): number {
|
||||||
if (left === right) {
|
if (left === right) {
|
||||||
@@ -9,7 +9,7 @@ export function compareCheckoutDiffPaths(left: string, right: string): number {
|
|||||||
return left < right ? -1 : 1;
|
return left < right ? -1 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function orderCheckoutDiffFiles(files: ParsedDiffFile[]): ParsedDiffFile[] {
|
export function orderCheckoutDiffFiles<T extends DiffFileWithPath>(files: T[]): T[] {
|
||||||
if (files.length < 2) {
|
if (files.length < 2) {
|
||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { DiffFileMetadata } from "./use-checkout-diff-metadata-query";
|
||||||
|
import { orderCheckoutDiffFiles } from "./checkout-diff-order";
|
||||||
|
|
||||||
|
function createMetadata(path: string, fingerprint = "fp1"): DiffFileMetadata {
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
isNew: false,
|
||||||
|
isDeleted: false,
|
||||||
|
additions: 1,
|
||||||
|
deletions: 0,
|
||||||
|
fingerprint,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useCheckoutDiffMetadataQuery — cache replacement behavior", () => {
|
||||||
|
it("orderCheckoutDiffFiles works with DiffFileMetadata arrays", () => {
|
||||||
|
const files: DiffFileMetadata[] = [
|
||||||
|
createMetadata("zeta.ts"),
|
||||||
|
createMetadata("alpha.ts"),
|
||||||
|
createMetadata("beta.ts"),
|
||||||
|
];
|
||||||
|
const ordered = orderCheckoutDiffFiles(files);
|
||||||
|
expect(ordered.map((f) => f.path)).toEqual(["alpha.ts", "beta.ts", "zeta.ts"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces full list on snapshot — does not merge with previous", () => {
|
||||||
|
const initial: DiffFileMetadata[] = [
|
||||||
|
createMetadata("a.ts", "fp1"),
|
||||||
|
createMetadata("b.ts", "fp2"),
|
||||||
|
createMetadata("c.ts", "fp3"),
|
||||||
|
];
|
||||||
|
// Simulate a new snapshot that removes b.ts and changes c.ts fingerprint
|
||||||
|
const updated: DiffFileMetadata[] = [
|
||||||
|
createMetadata("a.ts", "fp1"),
|
||||||
|
createMetadata("c.ts", "fp4"),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Full replacement: the result is exactly the updated list, not a merge
|
||||||
|
const result = orderCheckoutDiffFiles(updated);
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result.map((f) => f.path)).toEqual(["a.ts", "c.ts"]);
|
||||||
|
expect(result.find((f) => f.path === "c.ts")?.fingerprint).toBe("fp4");
|
||||||
|
// b.ts is gone — no remnant from initial
|
||||||
|
expect(result.find((f) => f.path === "b.ts")).toBeUndefined();
|
||||||
|
|
||||||
|
// Initial is unchanged (no mutation)
|
||||||
|
expect(initial).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves fingerprint and status on metadata items", () => {
|
||||||
|
const file: DiffFileMetadata = {
|
||||||
|
path: "large.bin",
|
||||||
|
isNew: false,
|
||||||
|
isDeleted: false,
|
||||||
|
additions: 0,
|
||||||
|
deletions: 0,
|
||||||
|
status: "binary",
|
||||||
|
fingerprint: "abc123",
|
||||||
|
};
|
||||||
|
const ordered = orderCheckoutDiffFiles([file]);
|
||||||
|
expect(ordered[0].fingerprint).toBe("abc123");
|
||||||
|
expect(ordered[0].status).toBe("binary");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useCallback, useEffect, useId, useMemo } from "react";
|
||||||
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
|
import type { CheckoutDiffSnapshot } from "@server/shared/messages";
|
||||||
|
import { orderCheckoutDiffFiles } from "./checkout-diff-order";
|
||||||
|
|
||||||
|
const CHECKOUT_DIFF_METADATA_STALE_TIME = 30_000;
|
||||||
|
|
||||||
|
export type DiffFileMetadata = CheckoutDiffSnapshot["payload"]["files"][number];
|
||||||
|
|
||||||
|
type CheckoutDiffMetadataPayload = Omit<CheckoutDiffSnapshot["payload"], "subscriptionId">;
|
||||||
|
|
||||||
|
function checkoutDiffMetadataQueryKey(
|
||||||
|
serverId: string,
|
||||||
|
cwd: string,
|
||||||
|
mode: "uncommitted" | "base",
|
||||||
|
baseRef?: string,
|
||||||
|
ignoreWhitespace?: boolean,
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
"checkoutDiffMetadata",
|
||||||
|
serverId,
|
||||||
|
cwd,
|
||||||
|
mode,
|
||||||
|
baseRef ?? "",
|
||||||
|
ignoreWhitespace === true,
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseCheckoutDiffMetadataQueryOptions {
|
||||||
|
serverId: string;
|
||||||
|
cwd: string;
|
||||||
|
mode: "uncommitted" | "base";
|
||||||
|
baseRef?: string;
|
||||||
|
ignoreWhitespace?: boolean;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCheckoutDiffCompare(compare: {
|
||||||
|
mode: "uncommitted" | "base";
|
||||||
|
baseRef?: string;
|
||||||
|
ignoreWhitespace?: boolean;
|
||||||
|
}): { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean } {
|
||||||
|
const ignoreWhitespace = compare.ignoreWhitespace === true;
|
||||||
|
if (compare.mode === "uncommitted") {
|
||||||
|
return { mode: "uncommitted", ignoreWhitespace };
|
||||||
|
}
|
||||||
|
const trimmedBaseRef = compare.baseRef?.trim();
|
||||||
|
return trimmedBaseRef
|
||||||
|
? { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace }
|
||||||
|
: { mode: "base", ignoreWhitespace };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCheckoutDiffMetadataQuery({
|
||||||
|
serverId,
|
||||||
|
cwd,
|
||||||
|
mode,
|
||||||
|
baseRef,
|
||||||
|
ignoreWhitespace,
|
||||||
|
enabled = true,
|
||||||
|
}: UseCheckoutDiffMetadataQueryOptions) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const client = useHostRuntimeClient(serverId);
|
||||||
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
|
const isMobile = useIsCompactFormFactor();
|
||||||
|
const mobileView = usePanelStore((state) => state.mobileView);
|
||||||
|
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||||
|
const explorerTab = usePanelStore((state) => state.explorerTab);
|
||||||
|
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||||
|
const hookInstanceId = useId();
|
||||||
|
const normalizedCompare = useMemo(
|
||||||
|
() => normalizeCheckoutDiffCompare({ mode, baseRef, ignoreWhitespace }),
|
||||||
|
[mode, baseRef, ignoreWhitespace],
|
||||||
|
);
|
||||||
|
const compareMode = normalizedCompare.mode;
|
||||||
|
const compareBaseRef = normalizedCompare.baseRef;
|
||||||
|
const compareIgnoreWhitespace = normalizedCompare.ignoreWhitespace;
|
||||||
|
const queryKey = useMemo(
|
||||||
|
() => checkoutDiffMetadataQueryKey(serverId, cwd, mode, baseRef, compareIgnoreWhitespace),
|
||||||
|
[serverId, cwd, mode, baseRef, compareIgnoreWhitespace],
|
||||||
|
);
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey,
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!client) {
|
||||||
|
throw new Error("Daemon client not available");
|
||||||
|
}
|
||||||
|
const payload = await client.getCheckoutDiffMetadata(cwd, {
|
||||||
|
mode: compareMode,
|
||||||
|
baseRef: compareBaseRef,
|
||||||
|
ignoreWhitespace: compareIgnoreWhitespace,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
cwd: payload.cwd,
|
||||||
|
files: orderCheckoutDiffFiles(payload.files),
|
||||||
|
error: payload.error,
|
||||||
|
requestId: payload.requestId,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
enabled: !!client && isConnected && !!cwd && enabled,
|
||||||
|
staleTime: CHECKOUT_DIFF_METADATA_STALE_TIME,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!client || !isConnected || !cwd || !enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isOpen || explorerTab !== "changes") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptionId = [
|
||||||
|
"checkoutDiffMetadata",
|
||||||
|
hookInstanceId,
|
||||||
|
serverId,
|
||||||
|
cwd,
|
||||||
|
compareMode,
|
||||||
|
compareBaseRef ?? "",
|
||||||
|
compareIgnoreWhitespace ? "ignore-ws" : "keep-ws",
|
||||||
|
].join(":");
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const unsubscribeSnapshot = client.on("checkout/diff_snapshot", (message) => {
|
||||||
|
if (message.type !== "checkout/diff_snapshot") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message.payload.subscriptionId !== subscriptionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
queryClient.setQueryData<CheckoutDiffMetadataPayload>(queryKey, {
|
||||||
|
cwd: message.payload.cwd,
|
||||||
|
files: orderCheckoutDiffFiles(message.payload.files),
|
||||||
|
error: message.payload.error,
|
||||||
|
requestId: message.payload.requestId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
void client
|
||||||
|
.subscribeCheckoutDiffLazy(
|
||||||
|
cwd,
|
||||||
|
{
|
||||||
|
mode: compareMode,
|
||||||
|
baseRef: compareBaseRef,
|
||||||
|
ignoreWhitespace: compareIgnoreWhitespace,
|
||||||
|
},
|
||||||
|
{ subscriptionId },
|
||||||
|
)
|
||||||
|
.then((payload) => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
queryClient.setQueryData<CheckoutDiffMetadataPayload>(queryKey, {
|
||||||
|
cwd: payload.cwd,
|
||||||
|
files: orderCheckoutDiffFiles(payload.files),
|
||||||
|
error: payload.error,
|
||||||
|
requestId: payload.requestId,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error("[useCheckoutDiffMetadataQuery] subscribeCheckoutDiffLazy failed", {
|
||||||
|
serverId,
|
||||||
|
cwd,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
unsubscribeSnapshot();
|
||||||
|
try {
|
||||||
|
client.unsubscribeCheckoutDiffLazy(subscriptionId);
|
||||||
|
} catch {
|
||||||
|
// Ignore disconnect race during effect cleanup.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
client,
|
||||||
|
isConnected,
|
||||||
|
cwd,
|
||||||
|
enabled,
|
||||||
|
isOpen,
|
||||||
|
explorerTab,
|
||||||
|
hookInstanceId,
|
||||||
|
serverId,
|
||||||
|
compareMode,
|
||||||
|
compareBaseRef,
|
||||||
|
compareIgnoreWhitespace,
|
||||||
|
queryKey,
|
||||||
|
queryClient,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
return query.refetch();
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
const payload = query.data ?? null;
|
||||||
|
const payloadError = payload?.error ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
files: payload?.files ?? [],
|
||||||
|
payloadError,
|
||||||
|
isLoading: query.isLoading,
|
||||||
|
isFetching: query.isFetching,
|
||||||
|
isError: query.isError || Boolean(payloadError),
|
||||||
|
error: query.error,
|
||||||
|
refresh,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { DiffFileMetadata } from "./use-checkout-diff-metadata-query";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for use-file-hunks hook logic.
|
||||||
|
*
|
||||||
|
* The hook itself is a thin React Query wrapper, so we test the key properties:
|
||||||
|
* 1. Query key includes fingerprint — fingerprint change = automatic refetch
|
||||||
|
* 2. Metadata fields are passed through to the merged result
|
||||||
|
*/
|
||||||
|
|
||||||
|
function fileHunksQueryKey(
|
||||||
|
serverId: string,
|
||||||
|
cwd: string,
|
||||||
|
mode: "uncommitted" | "base",
|
||||||
|
baseRef: string | undefined,
|
||||||
|
ignoreWhitespace: boolean | undefined,
|
||||||
|
path: string,
|
||||||
|
fingerprint: string,
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
"checkoutFileHunks",
|
||||||
|
serverId,
|
||||||
|
cwd,
|
||||||
|
mode,
|
||||||
|
baseRef ?? "",
|
||||||
|
ignoreWhitespace === true,
|
||||||
|
path,
|
||||||
|
fingerprint,
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("use-file-hunks query key", () => {
|
||||||
|
it("includes fingerprint in query key", () => {
|
||||||
|
const key = fileHunksQueryKey("s1", "/repo", "base", "main", false, "foo.ts", "fp-abc");
|
||||||
|
expect(key).toContain("fp-abc");
|
||||||
|
expect(key).toContain("foo.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("different fingerprints produce different query keys", () => {
|
||||||
|
const key1 = fileHunksQueryKey("s1", "/repo", "base", "main", false, "foo.ts", "fp-1");
|
||||||
|
const key2 = fileHunksQueryKey("s1", "/repo", "base", "main", false, "foo.ts", "fp-2");
|
||||||
|
expect(key1).not.toEqual(key2);
|
||||||
|
// Only the fingerprint element differs
|
||||||
|
expect(key1.slice(0, -1)).toEqual(key2.slice(0, -1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("same fingerprint + same params produces identical key", () => {
|
||||||
|
const key1 = fileHunksQueryKey("s1", "/repo", "uncommitted", undefined, true, "bar.ts", "fp-x");
|
||||||
|
const key2 = fileHunksQueryKey("s1", "/repo", "uncommitted", undefined, true, "bar.ts", "fp-x");
|
||||||
|
expect(key1).toEqual(key2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes missing baseRef to empty string", () => {
|
||||||
|
const key = fileHunksQueryKey("s1", "/repo", "uncommitted", undefined, false, "a.ts", "fp");
|
||||||
|
expect(key[4]).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes ignoreWhitespace to boolean", () => {
|
||||||
|
const key = fileHunksQueryKey("s1", "/repo", "base", "main", undefined, "a.ts", "fp");
|
||||||
|
expect(key[5]).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("use-file-hunks metadata merge", () => {
|
||||||
|
it("metadata fields carry through to merged ParsedDiffFile shape", () => {
|
||||||
|
const meta: DiffFileMetadata = {
|
||||||
|
path: "src/index.ts",
|
||||||
|
isNew: true,
|
||||||
|
isDeleted: false,
|
||||||
|
additions: 10,
|
||||||
|
deletions: 2,
|
||||||
|
status: "ok",
|
||||||
|
fingerprint: "abc",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simulates merging metadata with fetched hunks
|
||||||
|
const merged = {
|
||||||
|
path: meta.path,
|
||||||
|
isNew: meta.isNew,
|
||||||
|
isDeleted: meta.isDeleted,
|
||||||
|
additions: meta.additions,
|
||||||
|
deletions: meta.deletions,
|
||||||
|
status: meta.status,
|
||||||
|
hunks: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(merged.path).toBe("src/index.ts");
|
||||||
|
expect(merged.isNew).toBe(true);
|
||||||
|
expect(merged.additions).toBe(10);
|
||||||
|
expect(merged.hunks).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
|
import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
|
||||||
|
import type { DiffFileMetadata } from "@/hooks/use-checkout-diff-metadata-query";
|
||||||
|
|
||||||
|
function fileHunksQueryKey(
|
||||||
|
serverId: string,
|
||||||
|
cwd: string,
|
||||||
|
mode: "uncommitted" | "base",
|
||||||
|
baseRef: string | undefined,
|
||||||
|
ignoreWhitespace: boolean | undefined,
|
||||||
|
path: string,
|
||||||
|
fingerprint: string,
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
"checkoutFileHunks",
|
||||||
|
serverId,
|
||||||
|
cwd,
|
||||||
|
mode,
|
||||||
|
baseRef ?? "",
|
||||||
|
ignoreWhitespace === true,
|
||||||
|
path,
|
||||||
|
fingerprint,
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseFileHunksOptions {
|
||||||
|
serverId: string;
|
||||||
|
cwd: string;
|
||||||
|
mode: "uncommitted" | "base";
|
||||||
|
baseRef?: string;
|
||||||
|
ignoreWhitespace?: boolean;
|
||||||
|
file: DiffFileMetadata;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFileHunks({
|
||||||
|
serverId,
|
||||||
|
cwd,
|
||||||
|
mode,
|
||||||
|
baseRef,
|
||||||
|
ignoreWhitespace,
|
||||||
|
file,
|
||||||
|
enabled = true,
|
||||||
|
}: UseFileHunksOptions) {
|
||||||
|
const client = useHostRuntimeClient(serverId);
|
||||||
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
|
|
||||||
|
const queryKey = useMemo(
|
||||||
|
() =>
|
||||||
|
fileHunksQueryKey(
|
||||||
|
serverId,
|
||||||
|
cwd,
|
||||||
|
mode,
|
||||||
|
baseRef,
|
||||||
|
ignoreWhitespace,
|
||||||
|
file.path,
|
||||||
|
file.fingerprint,
|
||||||
|
),
|
||||||
|
[serverId, cwd, mode, baseRef, ignoreWhitespace, file.path, file.fingerprint],
|
||||||
|
);
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey,
|
||||||
|
queryFn: async (): Promise<ParsedDiffFile> => {
|
||||||
|
if (!client) {
|
||||||
|
throw new Error("Daemon client not available");
|
||||||
|
}
|
||||||
|
const payload = await client.getCheckoutFileHunks(
|
||||||
|
cwd,
|
||||||
|
{ mode, baseRef, ignoreWhitespace },
|
||||||
|
file.path,
|
||||||
|
);
|
||||||
|
if (payload.error) {
|
||||||
|
throw new Error(payload.error.message);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
path: file.path,
|
||||||
|
isNew: file.isNew,
|
||||||
|
isDeleted: file.isDeleted,
|
||||||
|
additions: file.additions,
|
||||||
|
deletions: file.deletions,
|
||||||
|
status: file.status,
|
||||||
|
hunks: payload.hunks,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
enabled: !!client && isConnected && enabled,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
file: query.data ?? null,
|
||||||
|
isLoading: query.isLoading,
|
||||||
|
isFetching: query.isFetching,
|
||||||
|
isError: query.isError,
|
||||||
|
error: query.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 () => {
|
test("getCheckoutDiff uses one-shot subscription protocol", async () => {
|
||||||
const logger = createMockLogger();
|
const logger = createMockLogger();
|
||||||
const mock = createMockTransport();
|
const mock = createMockTransport();
|
||||||
@@ -589,6 +653,105 @@ describe("DaemonClient", () => {
|
|||||||
expect(unsubscribeRequest.message.subscriptionId).toBe(subscribeRequest.message.subscriptionId);
|
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 () => {
|
test("requests branch suggestions via RPC", async () => {
|
||||||
const logger = createMockLogger();
|
const logger = createMockLogger();
|
||||||
const mock = createMockTransport();
|
const mock = createMockTransport();
|
||||||
@@ -886,6 +1049,60 @@ describe("DaemonClient", () => {
|
|||||||
expect(request.message.requestId.length).toBeGreaterThan(0);
|
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 () => {
|
test("fetches agents via RPC with filters, sort, and pagination", async () => {
|
||||||
const logger = createMockLogger();
|
const logger = createMockLogger();
|
||||||
const mock = createMockTransport();
|
const mock = createMockTransport();
|
||||||
|
|||||||
@@ -216,6 +216,14 @@ type SubscribeCheckoutDiffPayload = Extract<
|
|||||||
{ type: "subscribe_checkout_diff_response" }
|
{ type: "subscribe_checkout_diff_response" }
|
||||||
>["payload"];
|
>["payload"];
|
||||||
type CheckoutDiffPayload = Omit<SubscribeCheckoutDiffPayload, "subscriptionId">;
|
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 CheckoutCommitPayload = CheckoutCommitResponse["payload"];
|
||||||
type CheckoutMergePayload = CheckoutMergeResponse["payload"];
|
type CheckoutMergePayload = CheckoutMergeResponse["payload"];
|
||||||
type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"];
|
type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"];
|
||||||
@@ -500,8 +508,13 @@ type SetDaemonConfigResponse = Extract<
|
|||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
{ type: "set_daemon_config_response" }
|
{ type: "set_daemon_config_response" }
|
||||||
>;
|
>;
|
||||||
|
type CheckoutDiffSnapshotResponse = Extract<
|
||||||
|
SessionOutboundMessage,
|
||||||
|
{ type: "checkout/diff_snapshot" }
|
||||||
|
>;
|
||||||
type CorrelatedResponseMessage =
|
type CorrelatedResponseMessage =
|
||||||
| Extract<SessionOutboundMessage, { payload: { requestId: string } }>
|
| Extract<SessionOutboundMessage, { payload: { requestId: string } }>
|
||||||
|
| CheckoutDiffSnapshotResponse
|
||||||
| GetDaemonConfigResponse
|
| GetDaemonConfigResponse
|
||||||
| SetDaemonConfigResponse;
|
| SetDaemonConfigResponse;
|
||||||
type CorrelatedResponseType = CorrelatedResponseMessage["type"];
|
type CorrelatedResponseType = CorrelatedResponseMessage["type"];
|
||||||
@@ -616,6 +629,13 @@ export class DaemonClient {
|
|||||||
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
|
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 terminalDirectorySubscriptions = new Set<string>();
|
||||||
private terminalSlots = new Map<string, number>();
|
private terminalSlots = new Map<string, number>();
|
||||||
private slotTerminals = new Map<number, string>();
|
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 {
|
private resubscribeTerminalDirectorySubscriptions(): void {
|
||||||
if (this.terminalDirectorySubscriptions.size === 0) {
|
if (this.terminalDirectorySubscriptions.size === 0) {
|
||||||
return;
|
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(
|
async checkoutCommit(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
input: { message?: string; addAll?: boolean },
|
input: { message?: string; addAll?: boolean },
|
||||||
@@ -3723,6 +3858,7 @@ export class DaemonClient {
|
|||||||
this.reconnectAttempt = 0;
|
this.reconnectAttempt = 0;
|
||||||
this.updateConnectionState({ status: "connected" }, { event: "HELLO_SERVER_INFO" });
|
this.updateConnectionState({ status: "connected" }, { event: "HELLO_SERVER_INFO" });
|
||||||
this.resubscribeCheckoutDiffSubscriptions();
|
this.resubscribeCheckoutDiffSubscriptions();
|
||||||
|
this.resubscribeCheckoutDiffLazySubscriptions();
|
||||||
this.resubscribeTerminalDirectorySubscriptions();
|
this.resubscribeTerminalDirectorySubscriptions();
|
||||||
this.flushPendingSendQueue();
|
this.flushPendingSendQueue();
|
||||||
this.resolveConnect();
|
this.resolveConnect();
|
||||||
|
|||||||
@@ -81,6 +81,53 @@ vi.mock("./checkout-git-utils.js", () => ({
|
|||||||
|
|
||||||
import { CheckoutDiffManager } from "./checkout-diff-manager.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", () => {
|
describe("CheckoutDiffManager Linux watchers", () => {
|
||||||
const originalPlatform = process.platform;
|
const originalPlatform = process.platform;
|
||||||
|
|
||||||
@@ -134,4 +181,102 @@ describe("CheckoutDiffManager Linux watchers", () => {
|
|||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
manager.dispose();
|
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 { watch, type FSWatcher } from "node:fs";
|
||||||
import { readdir } from "node:fs/promises";
|
import { readdir } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type pino from "pino";
|
import type pino from "pino";
|
||||||
import type { SubscribeCheckoutDiffRequest, SessionOutboundMessage } from "./messages.js";
|
import type { SubscribeCheckoutDiffRequest, SessionOutboundMessage } from "./messages.js";
|
||||||
|
import type { DiffFileMetadata } from "./checkout/rpc-schemas.js";
|
||||||
import { getCheckoutDiff } from "../utils/checkout-git.js";
|
import { getCheckoutDiff } from "../utils/checkout-git.js";
|
||||||
import { expandTilde } from "../utils/path.js";
|
import { expandTilde } from "../utils/path.js";
|
||||||
import { runGitCommand } from "../utils/run-git-command.js";
|
import { runGitCommand } from "../utils/run-git-command.js";
|
||||||
@@ -18,6 +20,16 @@ export type CheckoutDiffSnapshotPayload = Omit<
|
|||||||
"subscriptionId"
|
"subscriptionId"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
type ParsedDiffFile = CheckoutDiffSnapshotPayload["files"][number];
|
||||||
|
|
||||||
|
export type { DiffFileMetadata };
|
||||||
|
|
||||||
|
export type CheckoutLazyDiffSnapshotPayload = {
|
||||||
|
cwd: string;
|
||||||
|
files: DiffFileMetadata[];
|
||||||
|
error: CheckoutDiffSnapshotPayload["error"];
|
||||||
|
};
|
||||||
|
|
||||||
export type CheckoutDiffMetrics = {
|
export type CheckoutDiffMetrics = {
|
||||||
checkoutDiffTargetCount: number;
|
checkoutDiffTargetCount: number;
|
||||||
checkoutDiffSubscriptionCount: number;
|
checkoutDiffSubscriptionCount: number;
|
||||||
@@ -31,13 +43,17 @@ type CheckoutDiffWatchTarget = {
|
|||||||
diffCwd: string;
|
diffCwd: string;
|
||||||
compare: CheckoutDiffCompareInput;
|
compare: CheckoutDiffCompareInput;
|
||||||
listeners: Set<(snapshot: CheckoutDiffSnapshotPayload) => void>;
|
listeners: Set<(snapshot: CheckoutDiffSnapshotPayload) => void>;
|
||||||
|
lazyListeners: Set<(snapshot: CheckoutLazyDiffSnapshotPayload) => void>;
|
||||||
watchers: FSWatcher[];
|
watchers: FSWatcher[];
|
||||||
fallbackRefreshInterval: NodeJS.Timeout | null;
|
fallbackRefreshInterval: NodeJS.Timeout | null;
|
||||||
debounceTimer: NodeJS.Timeout | null;
|
debounceTimer: NodeJS.Timeout | null;
|
||||||
refreshPromise: Promise<void> | null;
|
refreshPromise: Promise<void> | null;
|
||||||
refreshQueued: boolean;
|
refreshQueued: boolean;
|
||||||
latestPayload: CheckoutDiffSnapshotPayload | null;
|
latestPayload: CheckoutDiffSnapshotPayload | null;
|
||||||
|
latestLazyPayload: CheckoutLazyDiffSnapshotPayload | null;
|
||||||
latestFingerprint: string | null;
|
latestFingerprint: string | null;
|
||||||
|
latestFilesByPath: Map<string, ParsedDiffFile>;
|
||||||
|
latestMetadataFingerprints: Map<string, string>;
|
||||||
watchedPaths: Set<string>;
|
watchedPaths: Set<string>;
|
||||||
repoWatchPath: string | null;
|
repoWatchPath: string | null;
|
||||||
linuxTreeRefreshPromise: Promise<void> | null;
|
linuxTreeRefreshPromise: Promise<void> | null;
|
||||||
@@ -72,6 +88,7 @@ export class CheckoutDiffManager {
|
|||||||
diffCwd: target.diffCwd,
|
diffCwd: target.diffCwd,
|
||||||
}));
|
}));
|
||||||
target.latestPayload = initial;
|
target.latestPayload = initial;
|
||||||
|
target.latestFilesByPath = new Map(initial.files.map((file) => [file.path, file]));
|
||||||
target.latestFingerprint = JSON.stringify(initial);
|
target.latestFingerprint = JSON.stringify(initial);
|
||||||
return {
|
return {
|
||||||
initial,
|
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 {
|
scheduleRefreshForCwd(cwd: string): void {
|
||||||
const resolvedCwd = expandTilde(cwd);
|
const resolvedCwd = expandTilde(cwd);
|
||||||
for (const target of this.targets.values()) {
|
for (const target of this.targets.values()) {
|
||||||
@@ -98,6 +145,7 @@ export class CheckoutDiffManager {
|
|||||||
|
|
||||||
for (const target of this.targets.values()) {
|
for (const target of this.targets.values()) {
|
||||||
checkoutDiffSubscriptionCount += target.listeners.size;
|
checkoutDiffSubscriptionCount += target.listeners.size;
|
||||||
|
checkoutDiffSubscriptionCount += target.lazyListeners.size;
|
||||||
checkoutDiffWatcherCount += target.watchers.length;
|
checkoutDiffWatcherCount += target.watchers.length;
|
||||||
if (target.fallbackRefreshInterval) {
|
if (target.fallbackRefreshInterval) {
|
||||||
checkoutDiffFallbackRefreshTargetCount += 1;
|
checkoutDiffFallbackRefreshTargetCount += 1;
|
||||||
@@ -154,6 +202,9 @@ export class CheckoutDiffManager {
|
|||||||
target.watchers = [];
|
target.watchers = [];
|
||||||
target.watchedPaths.clear();
|
target.watchedPaths.clear();
|
||||||
target.listeners.clear();
|
target.listeners.clear();
|
||||||
|
target.lazyListeners.clear();
|
||||||
|
target.latestFilesByPath.clear();
|
||||||
|
target.latestMetadataFingerprints.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
private removeListener(
|
private removeListener(
|
||||||
@@ -165,7 +216,23 @@ export class CheckoutDiffManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
target.listeners.delete(listener);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
this.closeTarget(target);
|
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 {
|
private scheduleTargetRefresh(target: CheckoutDiffWatchTarget): void {
|
||||||
if (target.debounceTimer) {
|
if (target.debounceTimer) {
|
||||||
clearTimeout(target.debounceTimer);
|
clearTimeout(target.debounceTimer);
|
||||||
@@ -198,6 +274,81 @@ export class CheckoutDiffManager {
|
|||||||
}, CHECKOUT_DIFF_WATCH_DEBOUNCE_MS);
|
}, 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(
|
private async computeCheckoutDiffSnapshot(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
compare: CheckoutDiffCompareInput,
|
compare: CheckoutDiffCompareInput,
|
||||||
@@ -247,6 +398,7 @@ export class CheckoutDiffManager {
|
|||||||
diffCwd: target.diffCwd,
|
diffCwd: target.diffCwd,
|
||||||
});
|
});
|
||||||
target.latestPayload = snapshot;
|
target.latestPayload = snapshot;
|
||||||
|
target.latestFilesByPath = new Map(snapshot.files.map((file) => [file.path, file]));
|
||||||
const fingerprint = JSON.stringify(snapshot);
|
const fingerprint = JSON.stringify(snapshot);
|
||||||
if (fingerprint !== target.latestFingerprint) {
|
if (fingerprint !== target.latestFingerprint) {
|
||||||
target.latestFingerprint = fingerprint;
|
target.latestFingerprint = fingerprint;
|
||||||
@@ -254,6 +406,17 @@ export class CheckoutDiffManager {
|
|||||||
listener(snapshot);
|
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);
|
} while (target.refreshQueued);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -281,13 +444,17 @@ export class CheckoutDiffManager {
|
|||||||
diffCwd: watchRoot ?? cwd,
|
diffCwd: watchRoot ?? cwd,
|
||||||
compare,
|
compare,
|
||||||
listeners: new Set(),
|
listeners: new Set(),
|
||||||
|
lazyListeners: new Set(),
|
||||||
watchers: [],
|
watchers: [],
|
||||||
fallbackRefreshInterval: null,
|
fallbackRefreshInterval: null,
|
||||||
debounceTimer: null,
|
debounceTimer: null,
|
||||||
refreshPromise: null,
|
refreshPromise: null,
|
||||||
refreshQueued: false,
|
refreshQueued: false,
|
||||||
latestPayload: null,
|
latestPayload: null,
|
||||||
|
latestLazyPayload: null,
|
||||||
latestFingerprint: null,
|
latestFingerprint: null,
|
||||||
|
latestFilesByPath: new Map(),
|
||||||
|
latestMetadataFingerprints: new Map(),
|
||||||
watchedPaths: new Set<string>(),
|
watchedPaths: new Set<string>(),
|
||||||
repoWatchPath: null,
|
repoWatchPath: null,
|
||||||
linuxTreeRefreshPromise: null,
|
linuxTreeRefreshPromise: null,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -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 CaptureTerminalRequest,
|
||||||
type SubscribeCheckoutDiffRequest,
|
type SubscribeCheckoutDiffRequest,
|
||||||
type UnsubscribeCheckoutDiffRequest,
|
type UnsubscribeCheckoutDiffRequest,
|
||||||
|
type CheckoutSubscribeDiffRequest,
|
||||||
|
type CheckoutUnsubscribeDiffRequest,
|
||||||
|
type CheckoutGetFileHunksRequest,
|
||||||
type DirectorySuggestionsRequest,
|
type DirectorySuggestionsRequest,
|
||||||
type EditorTargetDescriptorPayload,
|
type EditorTargetDescriptorPayload,
|
||||||
type EditorTargetId,
|
type EditorTargetId,
|
||||||
@@ -149,6 +152,7 @@ import { type WorktreeConfig } from "../utils/worktree.js";
|
|||||||
import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js";
|
import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js";
|
||||||
import {
|
import {
|
||||||
getCheckoutDiff,
|
getCheckoutDiff,
|
||||||
|
getCheckoutDiffFile,
|
||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
listBranchSuggestions,
|
listBranchSuggestions,
|
||||||
commitChanges,
|
commitChanges,
|
||||||
@@ -622,6 +626,7 @@ export class Session {
|
|||||||
private inflightRequests = 0;
|
private inflightRequests = 0;
|
||||||
private peakInflightRequests = 0;
|
private peakInflightRequests = 0;
|
||||||
private readonly checkoutDiffSubscriptions = new Map<string, () => void>();
|
private readonly checkoutDiffSubscriptions = new Map<string, () => void>();
|
||||||
|
private readonly checkoutDiffLazySubscriptions = new Map<string, () => void>();
|
||||||
private readonly workspaceGitSubscriptions = new Map<string, () => void>();
|
private readonly workspaceGitSubscriptions = new Map<string, () => void>();
|
||||||
private readonly registerVoiceSpeakHandler?: (
|
private readonly registerVoiceSpeakHandler?: (
|
||||||
agentId: string,
|
agentId: string,
|
||||||
@@ -1783,6 +1788,18 @@ export class Session {
|
|||||||
this.handleUnsubscribeCheckoutDiffRequest(msg);
|
this.handleUnsubscribeCheckoutDiffRequest(msg);
|
||||||
break;
|
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":
|
case "checkout_switch_branch_request":
|
||||||
await this.handleCheckoutSwitchBranchRequest(msg);
|
await this.handleCheckoutSwitchBranchRequest(msg);
|
||||||
break;
|
break;
|
||||||
@@ -4277,6 +4294,89 @@ export class Session {
|
|||||||
this.checkoutDiffSubscriptions.delete(msg.subscriptionId);
|
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(
|
private async handleCheckoutSwitchBranchRequest(
|
||||||
msg: Extract<SessionInboundMessage, { type: "checkout_switch_branch_request" }>,
|
msg: Extract<SessionInboundMessage, { type: "checkout_switch_branch_request" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -7387,6 +7487,11 @@ export class Session {
|
|||||||
}
|
}
|
||||||
this.checkoutDiffSubscriptions.clear();
|
this.checkoutDiffSubscriptions.clear();
|
||||||
|
|
||||||
|
for (const unsubscribe of this.checkoutDiffLazySubscriptions.values()) {
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
this.checkoutDiffLazySubscriptions.clear();
|
||||||
|
|
||||||
for (const unsubscribe of this.workspaceGitSubscriptions.values()) {
|
for (const unsubscribe of this.workspaceGitSubscriptions.values()) {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -800,6 +800,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
features: {
|
features: {
|
||||||
// COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow.
|
// COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow.
|
||||||
providersSnapshot: true,
|
providersSnapshot: true,
|
||||||
|
lazyDiffRpcs: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ import {
|
|||||||
LoopLogsResponseSchema,
|
LoopLogsResponseSchema,
|
||||||
LoopStopResponseSchema,
|
LoopStopResponseSchema,
|
||||||
} from "../server/loop/rpc-schemas.js";
|
} 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)
|
// Mutable daemon config schemas (shared between server store and client)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1013,12 +1020,12 @@ const CheckoutErrorCodeSchema = z.enum([
|
|||||||
"UNKNOWN",
|
"UNKNOWN",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const CheckoutErrorSchema = z.object({
|
export const CheckoutErrorSchema = z.object({
|
||||||
code: CheckoutErrorCodeSchema,
|
code: CheckoutErrorCodeSchema,
|
||||||
message: z.string(),
|
message: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const CheckoutDiffCompareSchema = z.object({
|
export const CheckoutDiffCompareSchema = z.object({
|
||||||
mode: z.enum(["uncommitted", "base"]),
|
mode: z.enum(["uncommitted", "base"]),
|
||||||
baseRef: z.string().optional(),
|
baseRef: z.string().optional(),
|
||||||
ignoreWhitespace: z.boolean().optional(),
|
ignoreWhitespace: z.boolean().optional(),
|
||||||
@@ -1231,18 +1238,18 @@ export const ArchiveWorkspaceRequestSchema = z.object({
|
|||||||
|
|
||||||
// Highlighted diff token schema
|
// Highlighted diff token schema
|
||||||
// Note: style can be a compound class name (e.g., "heading meta") from the syntax highlighter
|
// 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(),
|
text: z.string(),
|
||||||
style: z.string().nullable(),
|
style: z.string().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const DiffLineSchema = z.object({
|
export const DiffLineSchema = z.object({
|
||||||
type: z.enum(["add", "remove", "context", "header"]),
|
type: z.enum(["add", "remove", "context", "header"]),
|
||||||
content: z.string(),
|
content: z.string(),
|
||||||
tokens: z.array(HighlightTokenSchema).optional(),
|
tokens: z.array(HighlightTokenSchema).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const DiffHunkSchema = z.object({
|
export const DiffHunkSchema = z.object({
|
||||||
oldStart: z.number(),
|
oldStart: z.number(),
|
||||||
oldCount: z.number(),
|
oldCount: z.number(),
|
||||||
newStart: z.number(),
|
newStart: z.number(),
|
||||||
@@ -1250,7 +1257,7 @@ const DiffHunkSchema = z.object({
|
|||||||
lines: z.array(DiffLineSchema),
|
lines: z.array(DiffLineSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const ParsedDiffFileSchema = z.object({
|
export const ParsedDiffFileSchema = z.object({
|
||||||
path: z.string(),
|
path: z.string(),
|
||||||
isNew: z.boolean(),
|
isNew: z.boolean(),
|
||||||
isDeleted: z.boolean(),
|
isDeleted: z.boolean(),
|
||||||
@@ -1464,6 +1471,9 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
CheckoutStatusRequestSchema,
|
CheckoutStatusRequestSchema,
|
||||||
SubscribeCheckoutDiffRequestSchema,
|
SubscribeCheckoutDiffRequestSchema,
|
||||||
UnsubscribeCheckoutDiffRequestSchema,
|
UnsubscribeCheckoutDiffRequestSchema,
|
||||||
|
CheckoutSubscribeDiffRequestSchema,
|
||||||
|
CheckoutUnsubscribeDiffRequestSchema,
|
||||||
|
CheckoutGetFileHunksRequestSchema,
|
||||||
CheckoutCommitRequestSchema,
|
CheckoutCommitRequestSchema,
|
||||||
CheckoutMergeRequestSchema,
|
CheckoutMergeRequestSchema,
|
||||||
CheckoutMergeFromBaseRequestSchema,
|
CheckoutMergeFromBaseRequestSchema,
|
||||||
@@ -1686,6 +1696,7 @@ export const ServerInfoStatusPayloadSchema = z
|
|||||||
features: z
|
features: z
|
||||||
.object({
|
.object({
|
||||||
providersSnapshot: z.boolean().optional(),
|
providersSnapshot: z.boolean().optional(),
|
||||||
|
lazyDiffRpcs: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
@@ -2789,6 +2800,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
CheckoutStatusResponseSchema,
|
CheckoutStatusResponseSchema,
|
||||||
SubscribeCheckoutDiffResponseSchema,
|
SubscribeCheckoutDiffResponseSchema,
|
||||||
CheckoutDiffUpdateSchema,
|
CheckoutDiffUpdateSchema,
|
||||||
|
CheckoutDiffSnapshotSchema,
|
||||||
|
CheckoutFileHunksResponseSchema,
|
||||||
CheckoutCommitResponseSchema,
|
CheckoutCommitResponseSchema,
|
||||||
CheckoutMergeResponseSchema,
|
CheckoutMergeResponseSchema,
|
||||||
CheckoutMergeFromBaseResponseSchema,
|
CheckoutMergeFromBaseResponseSchema,
|
||||||
@@ -2999,8 +3012,13 @@ export type CheckoutStatusRequest = z.infer<typeof CheckoutStatusRequestSchema>;
|
|||||||
export type CheckoutStatusResponse = z.infer<typeof CheckoutStatusResponseSchema>;
|
export type CheckoutStatusResponse = z.infer<typeof CheckoutStatusResponseSchema>;
|
||||||
export type SubscribeCheckoutDiffRequest = z.infer<typeof SubscribeCheckoutDiffRequestSchema>;
|
export type SubscribeCheckoutDiffRequest = z.infer<typeof SubscribeCheckoutDiffRequestSchema>;
|
||||||
export type UnsubscribeCheckoutDiffRequest = z.infer<typeof UnsubscribeCheckoutDiffRequestSchema>;
|
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 SubscribeCheckoutDiffResponse = z.infer<typeof SubscribeCheckoutDiffResponseSchema>;
|
||||||
export type CheckoutDiffUpdate = z.infer<typeof CheckoutDiffUpdateSchema>;
|
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 CheckoutCommitRequest = z.infer<typeof CheckoutCommitRequestSchema>;
|
||||||
export type CheckoutCommitResponse = z.infer<typeof CheckoutCommitResponseSchema>;
|
export type CheckoutCommitResponse = z.infer<typeof CheckoutCommitResponseSchema>;
|
||||||
export type CheckoutMergeRequest = z.infer<typeof CheckoutMergeRequestSchema>;
|
export type CheckoutMergeRequest = z.infer<typeof CheckoutMergeRequestSchema>;
|
||||||
|
|||||||
@@ -1277,9 +1277,10 @@ export async function getCheckoutShortstat(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCheckoutDiff(
|
async function getCheckoutDiffInternal(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
compare: CheckoutDiffCompare,
|
compare: CheckoutDiffCompare,
|
||||||
|
options?: { path?: string },
|
||||||
context?: CheckoutContext,
|
context?: CheckoutContext,
|
||||||
): Promise<CheckoutDiffResult> {
|
): Promise<CheckoutDiffResult> {
|
||||||
await requireGitRepo(cwd);
|
await requireGitRepo(cwd);
|
||||||
@@ -1305,7 +1306,12 @@ export async function getCheckoutDiff(
|
|||||||
|
|
||||||
const ignoreWhitespace = compare.ignoreWhitespace === true;
|
const ignoreWhitespace = compare.ignoreWhitespace === true;
|
||||||
const changes = await listCheckoutFileChanges(cwd, refForDiff, ignoreWhitespace);
|
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;
|
if (a.path === b.path) return 0;
|
||||||
return a.path < b.path ? -1 : 1;
|
return a.path < b.path ? -1 : 1;
|
||||||
});
|
});
|
||||||
@@ -1329,8 +1335,8 @@ export async function getCheckoutDiff(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const trackedChanges = changes.filter((change) => !change.isUntracked);
|
const trackedChanges = filteredChanges.filter((change) => !change.isUntracked);
|
||||||
const untrackedChanges = changes.filter((change) => change.isUntracked === true);
|
const untrackedChanges = filteredChanges.filter((change) => change.isUntracked === true);
|
||||||
const trackedChangeByPath = new Map(trackedChanges.map((change) => [change.path, change]));
|
const trackedChangeByPath = new Map(trackedChanges.map((change) => [change.path, change]));
|
||||||
|
|
||||||
const trackedNumstatByPath =
|
const trackedNumstatByPath =
|
||||||
@@ -1514,6 +1520,24 @@ export async function getCheckoutDiff(
|
|||||||
return { diff: diffText };
|
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(
|
export async function commitChanges(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
options: { message: string; addAll?: boolean },
|
options: { message: string; addAll?: boolean },
|
||||||
|
|||||||
Reference in New Issue
Block a user