Files
kandev/apps/web/components/diff/use-expandable-diff.ts
T

166 lines
5.7 KiB
TypeScript

import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import type { FileDiffMetadata } from "@pierre/diffs";
import { getWebSocketClient } from "@/lib/ws/connection";
import { requestFileContent, requestFileContentAtRef } from "@/lib/ws/workspace-files";
/** Must match @pierre/diffs SPLIT_WITH_NEWLINES — splits preserving trailing \n */
const SPLIT_WITH_NEWLINES = /(?<=\n)/;
type UseExpandableDiffOptions = {
sessionId: string | undefined;
filePath: string;
baseRef: string | undefined;
fileDiffMetadata: FileDiffMetadata | null;
enableExpansion?: boolean;
};
type UseExpandableDiffResult = {
metadata: FileDiffMetadata | null;
isContentLoaded: boolean;
isLoading: boolean;
error: string | null;
loadContent: () => Promise<void>;
canExpand: boolean;
};
type WsClient = NonNullable<ReturnType<typeof getWebSocketClient>>;
/** Check if an error indicates file not found (various formats from backend). */
function isFileNotFoundError(error: string): boolean {
return /file not found|not found|no such file|does not exist/i.test(error);
}
/** Fetch old file content at a git ref. Returns empty string for new files. */
async function fetchOldContent(
client: WsClient,
sessionId: string,
filePath: string,
baseRef: string,
): Promise<string> {
try {
const res = await requestFileContentAtRef(client, sessionId, filePath, baseRef);
if (res.is_binary) throw new Error("Cannot expand binary files");
if (!res.error) return res.content;
// File not found at ref is expected for new files - return empty string
if (isFileNotFoundError(res.error)) return "";
throw new Error(res.error);
} catch (err) {
// WebSocket client throws errors for backend error responses
const msg = err instanceof Error ? err.message : String(err);
if (isFileNotFoundError(msg)) return "";
throw err;
}
}
/** Fetch new file content from the working tree. Returns empty string for deleted files. */
async function fetchNewContent(
client: WsClient,
sessionId: string,
filePath: string,
): Promise<string> {
try {
// Fetch from working tree (current file on disk), not HEAD.
// The diff shows working tree changes, so newLines must match.
const res = await requestFileContent(client, sessionId, filePath);
if (res.is_binary) throw new Error("Cannot expand binary files");
if (!res.error) return res.content;
// File not found is expected for deleted files - return empty string
if (isFileNotFoundError(res.error)) return "";
throw new Error(res.error);
} catch (err) {
// WebSocket client throws errors for backend error responses
const msg = err instanceof Error ? err.message : String(err);
if (isFileNotFoundError(msg)) return "";
throw err;
}
}
/** Fetch both old and new content, split into lines for @pierre/diffs expansion. */
async function fetchExpansionLines(
sessionId: string,
filePath: string,
baseRef: string | undefined,
) {
const client = getWebSocketClient();
if (!client) throw new Error("WebSocket client not available");
const newContent = await fetchNewContent(client, sessionId, filePath);
const oldContent = baseRef ? await fetchOldContent(client, sessionId, filePath, baseRef) : "";
return {
oldLines: oldContent.split(SPLIT_WITH_NEWLINES),
newLines: newContent.split(SPLIT_WITH_NEWLINES),
};
}
/**
* Hook for managing expandable diffs with lazy-loaded file content.
*
* The @pierre/diffs library requires `oldLines` and `newLines` in FileDiffMetadata
* for expansion to work. This hook fetches old/new content and merges it into the
* metadata.
*/
export function useExpandableDiff({
sessionId,
filePath,
baseRef,
fileDiffMetadata,
enableExpansion = false,
}: UseExpandableDiffOptions): UseExpandableDiffResult {
const requestVersionRef = useRef(0);
const [loadedContent, setLoadedContent] = useState<{
oldLines: string[];
newLines: string[];
} | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Reset cached content when inputs change so stale data is never rendered.
// Including fileDiffMetadata ensures expansion content is invalidated when
// the diff changes (e.g., file modified while Diff panel is open), because
// @pierre/diffs uses oldLines/newLines for rendering when present.
useEffect(() => {
requestVersionRef.current += 1;
setLoadedContent(null);
setError(null);
}, [sessionId, filePath, baseRef, fileDiffMetadata]);
const loadContent = useCallback(async () => {
if (!sessionId || !enableExpansion || loadedContent || isLoading) return;
const version = ++requestVersionRef.current;
setIsLoading(true);
setError(null);
try {
const lines = await fetchExpansionLines(sessionId, filePath, baseRef);
if (version === requestVersionRef.current) setLoadedContent(lines);
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to load file content";
console.error("[useExpandableDiff]", msg);
if (version === requestVersionRef.current) setError(msg);
} finally {
setIsLoading(false);
}
}, [sessionId, filePath, baseRef, enableExpansion, loadedContent, isLoading]);
const metadata = useMemo<FileDiffMetadata | null>(() => {
if (!fileDiffMetadata) return null;
if (!loadedContent) return fileDiffMetadata;
return {
...fileDiffMetadata,
oldLines: loadedContent.oldLines,
newLines: loadedContent.newLines,
};
}, [fileDiffMetadata, loadedContent]);
const isContentLoaded = loadedContent !== null;
return {
metadata,
isContentLoaded,
isLoading,
error,
loadContent,
canExpand: enableExpansion && isContentLoaded && !error,
};
}