Files

371 lines
12 KiB
TypeScript

"use client";
import { useState, useRef, useCallback, memo, useEffect } from "react";
import { FileDiff } from "@pierre/diffs/react";
import { cn } from "@kandev/ui/lib/utils";
import type { FileDiffData, DiffComment, DiffCommentUpdate } from "@/lib/diff/types";
import { useHunkHover } from "./use-hunk-hover";
import { useAnnotationRenderer } from "./use-diff-annotation-renderer";
import { DEFAULT_DIFF_WORD_WRAP } from "./diff-defaults";
import { useDiffOptions } from "./use-diff-options";
import { useDiffViewerState } from "./use-diff-viewer-state";
import { UnanchoredFindingsBanner } from "./unanchored-findings-banner";
import { useTranslation } from "react-i18next";
export type RevertBlockInfo = {
/** 1-based line number in the new file where additions start */
addStart: number;
/** Number of addition lines to remove (0 for pure deletions) */
addCount: number;
/** Original lines to restore (empty for pure additions) */
oldLines: string[];
};
interface DiffViewerProps {
data: FileDiffData;
enableComments?: boolean;
sessionId?: string;
onCommentAdd?: (comment: DiffComment) => void;
onCommentDelete?: (commentId: string) => void;
onCommentUpdate?: (commentId: string, updates: DiffCommentUpdate) => void;
onCommentRun?: (comment: DiffComment) => void;
comments?: DiffComment[];
className?: string;
compact?: boolean;
hideHeader?: boolean;
onOpenFile?: (filePath: string, repo?: string) => void;
onPreviewMarkdown?: (filePath: string) => void;
onRevert?: (filePath: string) => void;
enableAcceptReject?: boolean;
/** Render and navigate to the active task walkthrough inside this diff. */
enableWalkthroughAnnotations?: boolean;
onRevertBlock?: (filePath: string, info: RevertBlockInfo) => Promise<void> | void;
wordWrap?: boolean;
/** Enable diff expansion (show expand up/down buttons at hunk separators) */
enableExpansion?: boolean;
/** Base git ref for fetching old content (e.g., "origin/main", "HEAD~1") */
baseRef?: string;
/** Controlled expand-unchanged state (when provided, component is controlled) */
expandUnchanged?: boolean;
/** Callback when expand-unchanged is toggled (controlled mode) */
onToggleExpandUnchanged?: () => void;
/** Multi-repo subpath for the file (e.g. "kandev"); empty for single-repo. */
repo?: string;
taskId?: string | null;
repositoryId?: string | null;
status?: string | null;
previousPath?: string | null;
publishedBranch?: string | null;
externalBaseBranch?: string | null;
}
const SCALAR_PROP_KEYS: (keyof DiffViewerProps)[] = [
"enableComments",
"sessionId",
"onCommentDelete",
"onCommentUpdate",
"compact",
"hideHeader",
"className",
"onOpenFile",
"onPreviewMarkdown",
"onRevert",
"enableAcceptReject",
"enableWalkthroughAnnotations",
"onRevertBlock",
"wordWrap",
"enableExpansion",
"baseRef",
"expandUnchanged",
"onToggleExpandUnchanged",
"repo",
"taskId",
"repositoryId",
"status",
"previousPath",
"publishedBranch",
"externalBaseBranch",
];
const DATA_KEYS: (keyof FileDiffData)[] = ["filePath", "diff", "oldContent", "newContent"];
function areCommentsEqual(
prev: DiffComment[] | undefined,
next: DiffComment[] | undefined,
): boolean {
if (prev === next) return true;
if (!prev || !next || prev.length !== next.length) return false;
return prev.every((c, i) => c.id === next[i].id && c.text === next[i].text);
}
/** Auto-load expansion content and return whether expansion can be used. */
function useAutoLoadExpansion(
enableExpansion: boolean,
state: ReturnType<typeof useDiffViewerState>,
): boolean {
const { isExpansionContentLoaded, isExpansionLoading, expansionError, loadExpansionContent } =
state;
useEffect(() => {
if (enableExpansion && !isExpansionContentLoaded && !isExpansionLoading && !expansionError) {
void loadExpansionContent();
}
}, [
enableExpansion,
isExpansionContentLoaded,
isExpansionLoading,
expansionError,
loadExpansionContent,
]);
const hasValidData = !!(
state.fileDiffMetadata?.deletionLines?.length && state.fileDiffMetadata?.additionLines?.length
);
return enableExpansion && isExpansionContentLoaded && state.canExpand && hasValidData;
}
function arePropsEqual(prevProps: DiffViewerProps, nextProps: DiffViewerProps): boolean {
for (const key of DATA_KEYS) {
if (prevProps.data[key] !== nextProps.data[key]) return false;
}
for (const key of SCALAR_PROP_KEYS) {
if (prevProps[key] !== nextProps[key]) return false;
}
return areCommentsEqual(prevProps.comments, nextProps.comments);
}
function NoDiffPlaceholder({ className }: { className?: string }) {
const { t } = useTranslation();
return (
<div className={cn("rounded-md bg-muted/20 p-4 text-muted-foreground text-xs", className)}>
{t("diff:noDiffAvailable")}
</div>
);
}
function useScrollWalkthroughRangeIntoView(
wrapperRef: React.RefObject<HTMLDivElement | null>,
selectedLines: ReturnType<typeof useDiffViewerState>["walkthroughSelectedLines"],
filePath: string,
) {
useEffect(() => {
if (!selectedLines) return;
let innerFrame: number | null = null;
const frame = requestAnimationFrame(() => {
innerFrame = requestAnimationFrame(() => {
const container = wrapperRef.current?.querySelector("diffs-container");
const shadow = container?.shadowRoot;
const selected = shadow?.querySelector<HTMLElement>(
'[data-selected-line="first"], [data-selected-line="single"], [data-selected-line]',
);
const fallback = shadow?.querySelector<HTMLElement>(`[data-line="${selectedLines.start}"]`);
(selected ?? fallback)?.scrollIntoView({ block: "center", behavior: "smooth" });
});
});
return () => {
cancelAnimationFrame(frame);
if (innerFrame !== null) cancelAnimationFrame(innerFrame);
};
}, [wrapperRef, selectedLines, filePath]);
}
type WiringArgs = {
data: FileDiffData;
state: ReturnType<typeof useDiffViewerState>;
onCommentRun?: (comment: DiffComment) => void;
onOpenFile?: (filePath: string, repo?: string) => void;
onPreviewMarkdown?: (filePath: string) => void;
onRevert?: (filePath: string) => void;
enableComments: boolean;
enableExpansion: boolean;
hideHeader: boolean;
compact: boolean;
wordWrap: boolean;
setWordWrap: React.Dispatch<React.SetStateAction<boolean>>;
expandUnchanged: boolean;
toggleExpandUnchanged: () => void;
wrapperRef: React.RefObject<HTMLDivElement | null>;
repo?: string;
taskId?: string | null;
sessionId?: string | null;
repositoryId?: string | null;
status?: string | null;
previousPath?: string | null;
publishedBranch?: string | null;
externalBaseBranch?: string | null;
};
/**
* Bundles the hover/annotation/options wiring for DiffViewer so the top-level
* component body stays under the 100-line cap. Equivalent to inlining the
* three calls — extracted only for size, no behavior change.
*/
function useDiffViewerWiring(args: WiringArgs) {
const { data, state, onCommentRun, wrapperRef, hideHeader, compact, enableExpansion } = args;
const { onLineEnter, onLineLeave, onButtonEnter, onButtonLeave } = useHunkHover({
wrapperRef,
changeLineMapRef: state.changeLineMapRef,
hideTimeoutRef: state.hideTimeoutRef,
});
const renderAnnotation = useAnnotationRenderer({
handleRevertBlock: state.handleRevertBlock,
onButtonEnter,
onButtonLeave,
handleCommentSubmit: state.handleCommentSubmit,
handleCommentSubmitAndRun: state.handleCommentSubmitAndRun,
handleCommentUpdate: state.handleCommentUpdate,
handleCommentDelete: state.handleCommentDelete,
handleCommentRun: onCommentRun,
setShowCommentForm: state.setShowCommentForm,
setSelectedLines: state.setSelectedLines,
setEditingComment: state.setEditingComment,
});
const showHeader = !hideHeader && !compact;
const canUseExpansion = useAutoLoadExpansion(enableExpansion, state);
const opts = useDiffOptions({
filePath: data.filePath,
diff: data.diff,
enableComments: args.enableComments,
showHeader,
wordWrap: args.wordWrap,
setWordWrap: args.setWordWrap,
handleLineSelectionEnd: state.handleLineSelectionEnd,
onLineEnter,
onLineLeave,
onOpenFile: args.onOpenFile,
onPreviewMarkdown: args.onPreviewMarkdown,
onRevert: args.onRevert,
enableExpansion: canUseExpansion,
expandUnchanged: args.expandUnchanged,
onToggleExpandUnchanged: canUseExpansion ? args.toggleExpandUnchanged : undefined,
repo: args.repo,
taskId: args.taskId,
sessionId: args.sessionId,
repositoryId: args.repositoryId,
status: args.status,
previousPath: args.previousPath,
publishedBranch: args.publishedBranch,
baseBranch: args.externalBaseBranch,
});
return { ...opts, renderAnnotation };
}
function externalLinkContext(props: DiffViewerProps) {
return {
taskId: props.taskId,
repositoryId: props.repositoryId,
status: props.status,
previousPath: props.previousPath,
publishedBranch: props.publishedBranch,
externalBaseBranch: props.externalBaseBranch,
};
}
export const DiffViewer = memo(function DiffViewer(props: DiffViewerProps) {
const {
data,
enableComments = false,
sessionId,
onCommentAdd,
onCommentDelete,
onCommentUpdate,
onCommentRun,
comments: externalComments,
className,
compact = false,
hideHeader = false,
onOpenFile,
onPreviewMarkdown,
onRevert,
enableAcceptReject = false,
enableWalkthroughAnnotations = true,
onRevertBlock,
wordWrap: wordWrapProp,
enableExpansion = false,
baseRef,
expandUnchanged: expandUnchangedProp,
onToggleExpandUnchanged: onToggleExpandUnchangedProp,
repo,
} = props;
const [wordWrapLocal, setWordWrap] = useState(DEFAULT_DIFF_WORD_WRAP);
const wordWrap = wordWrapProp ?? wordWrapLocal;
const [expandUnchangedLocal, setExpandUnchangedLocal] = useState(false);
const expandUnchanged = expandUnchangedProp ?? expandUnchangedLocal;
const toggleExpandUnchanged = useCallback(() => {
if (onToggleExpandUnchangedProp) onToggleExpandUnchangedProp();
else setExpandUnchangedLocal((v) => !v);
}, [onToggleExpandUnchangedProp]);
const wrapperRef = useRef<HTMLDivElement>(null);
const state = useDiffViewerState({
data,
enableComments,
enableAcceptReject,
enableWalkthroughAnnotations,
sessionId,
onCommentAdd,
onCommentDelete,
onCommentUpdate,
onCommentRun,
externalComments,
onRevertBlock,
enableExpansion,
baseRef,
repo,
});
useScrollWalkthroughRangeIntoView(wrapperRef, state.walkthroughSelectedLines, data.filePath);
const { options, renderHeaderMetadata, renderHoverUtility, renderAnnotation } =
useDiffViewerWiring({
data,
state,
onCommentRun,
onOpenFile,
onPreviewMarkdown,
onRevert,
enableComments,
enableExpansion,
hideHeader,
compact,
wordWrap,
setWordWrap,
expandUnchanged,
toggleExpandUnchanged,
wrapperRef,
repo,
sessionId,
...externalLinkContext(props),
});
const controlledSelection = state.showCommentForm
? state.selectedLines
: state.walkthroughSelectedLines;
if (!state.fileDiffMetadata) {
return <NoDiffPlaceholder className={className} />;
}
return (
<div
ref={wrapperRef}
className={cn("diff-viewer", className)}
data-walkthrough-active={state.walkthroughSelectedLines ? "true" : undefined}
>
<UnanchoredFindingsBanner findings={state.unanchoredFindings} />
<FileDiff
fileDiff={state.fileDiffMetadata}
options={options}
selectedLines={controlledSelection}
lineAnnotations={state.annotations}
renderAnnotation={renderAnnotation}
renderHeaderMetadata={renderHeaderMetadata}
renderHoverUtility={renderHoverUtility}
className={cn("rounded-md ", "text-xs")}
/>
</div>
);
}, arePropsEqual);
/** Compact inline diff viewer for chat messages (Pierre implementation). */
export function DiffViewInline({ data, className }: { data: FileDiffData; className?: string }) {
return <DiffViewer data={data} compact hideHeader className={className} />;
}