312 lines
10 KiB
TypeScript
312 lines
10 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useRef, useCallback, useMemo, useEffect } from 'react';
|
|
import { DiffView, DiffModeEnum, SplitSide } from '@git-diff-view/react';
|
|
import { generateDiffFile } from '@git-diff-view/file';
|
|
import { useTheme } from 'next-themes';
|
|
import { Button } from '@kandev/ui/button';
|
|
import { Badge } from '@kandev/ui/badge';
|
|
import { IconLayoutColumns, IconLayoutRows } from '@tabler/icons-react';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
import { useDiffComments } from './use-diff-comments';
|
|
import { InlineCommentWidget } from './comment-widget';
|
|
import { CommentRenderer } from './comment-renderer';
|
|
import { DragSelectionOverlay } from './selection-overlay';
|
|
import { CommentRangeIndicator } from './comment-indicator';
|
|
import { PositionedCommentWidget } from './multi-line-comment';
|
|
import { getLineInfoFromElement } from './dom-utils';
|
|
import type { GitDiffViewerProps, ExtendLineData, DragSelectionState } from './types';
|
|
|
|
import '@git-diff-view/react/styles/diff-view.css';
|
|
import './git-diff-viewer.css';
|
|
|
|
export function GitDiffViewer({
|
|
oldContent,
|
|
newContent,
|
|
filePath,
|
|
language = 'typescript',
|
|
defaultViewMode = 'split',
|
|
showToolbar = true,
|
|
enableComments = true,
|
|
comments: externalComments,
|
|
onCommentAdd,
|
|
onCommentDelete,
|
|
className,
|
|
}: GitDiffViewerProps) {
|
|
const { resolvedTheme } = useTheme();
|
|
const [viewMode, setViewMode] = useState<'split' | 'unified'>(defaultViewMode);
|
|
const [widgetStartLines, setWidgetStartLines] = useState<Record<string, number>>({});
|
|
const widgetStartLinesRef = useRef(widgetStartLines);
|
|
|
|
useEffect(() => {
|
|
widgetStartLinesRef.current = widgetStartLines;
|
|
}, [widgetStartLines]);
|
|
|
|
// Drag selection state for multi-line comments
|
|
const [dragSelection, setDragSelection] = useState<DragSelectionState | null>(null);
|
|
const [showDragWidget, setShowDragWidget] = useState(false);
|
|
const justCompletedMultiLineRef = useRef(false);
|
|
|
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Comment management
|
|
const { comments, addComment, deleteComment } = useDiffComments({
|
|
filePath,
|
|
externalComments,
|
|
onCommentAdd,
|
|
onCommentDelete,
|
|
});
|
|
|
|
const diffModeEnum = viewMode === 'split' ? DiffModeEnum.Split : DiffModeEnum.Unified;
|
|
const diffTheme = resolvedTheme === 'dark' ? 'dark' : 'light';
|
|
|
|
// Generate diff file
|
|
const diffFile = useMemo(() => {
|
|
try {
|
|
const file = generateDiffFile(filePath, oldContent, filePath, newContent, language, language);
|
|
file.initRaw();
|
|
return file;
|
|
} catch (e) {
|
|
console.error('Failed to generate diff:', e);
|
|
return null;
|
|
}
|
|
}, [filePath, oldContent, newContent, language]);
|
|
|
|
// Build extendData for displaying existing comments
|
|
const extendData = useMemo(() => {
|
|
const oldFileData: Record<string, { data: ExtendLineData }> = {};
|
|
const newFileData: Record<string, { data: ExtendLineData }> = {};
|
|
|
|
comments.forEach((comment) => {
|
|
const lineKey = String(comment.endLine);
|
|
const entry: ExtendLineData = { type: 'comment', comment };
|
|
|
|
if (comment.side === SplitSide.old) {
|
|
oldFileData[lineKey] = { data: entry };
|
|
} else {
|
|
newFileData[lineKey] = { data: entry };
|
|
}
|
|
});
|
|
|
|
return { oldFile: oldFileData, newFile: newFileData };
|
|
}, [comments]);
|
|
|
|
// Mouse handlers for drag selection
|
|
const handleMouseDown = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
if (!enableComments || e.button !== 0) return;
|
|
|
|
const target = e.target as HTMLElement;
|
|
const lineInfo = getLineInfoFromElement(target, wrapperRef.current, viewMode);
|
|
|
|
if (lineInfo) {
|
|
setDragSelection({
|
|
startLine: lineInfo.lineNumber,
|
|
endLine: lineInfo.lineNumber,
|
|
side: lineInfo.side,
|
|
isActive: true,
|
|
});
|
|
setShowDragWidget(false);
|
|
justCompletedMultiLineRef.current = false;
|
|
}
|
|
},
|
|
[enableComments, viewMode]
|
|
);
|
|
|
|
const handleMouseMove = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
if (!dragSelection?.isActive) return;
|
|
|
|
const target = e.target as HTMLElement;
|
|
const lineInfo = getLineInfoFromElement(target, wrapperRef.current, viewMode);
|
|
|
|
if (lineInfo && lineInfo.side === dragSelection.side && lineInfo.lineNumber !== dragSelection.endLine) {
|
|
setDragSelection((prev) => (prev ? { ...prev, endLine: lineInfo.lineNumber } : null));
|
|
}
|
|
},
|
|
[dragSelection, viewMode]
|
|
);
|
|
|
|
const handleMouseUp = useCallback(() => {
|
|
if (!dragSelection?.isActive) return;
|
|
|
|
const { startLine, endLine, side } = dragSelection;
|
|
const rangeStart = Math.min(startLine, endLine);
|
|
const rangeEnd = Math.max(startLine, endLine);
|
|
|
|
if (rangeStart !== rangeEnd) {
|
|
setDragSelection({ startLine: rangeStart, endLine: rangeEnd, side, isActive: false });
|
|
setShowDragWidget(true);
|
|
justCompletedMultiLineRef.current = true;
|
|
} else {
|
|
setDragSelection(null);
|
|
}
|
|
}, [dragSelection]);
|
|
|
|
const handleMouseLeave = useCallback(() => {
|
|
if (dragSelection?.isActive) {
|
|
setDragSelection(null);
|
|
}
|
|
}, [dragSelection]);
|
|
|
|
// Prevent library's single-line widget after multi-line selection
|
|
const handleClick = useCallback((e: React.MouseEvent) => {
|
|
if (justCompletedMultiLineRef.current) {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
justCompletedMultiLineRef.current = false;
|
|
}
|
|
}, []);
|
|
|
|
// Handle widget line rendering for single-line comments
|
|
const renderWidgetLine = useCallback(
|
|
(props: { side: SplitSide; lineNumber: number; onClose: () => void }) => {
|
|
if (!diffFile || !enableComments) return null;
|
|
|
|
const widgetKey = `${filePath}-${props.side}-${props.lineNumber}`;
|
|
const startLine = widgetStartLinesRef.current[widgetKey] ?? props.lineNumber;
|
|
|
|
return (
|
|
<InlineCommentWidget
|
|
key={widgetKey}
|
|
lineNumber={props.lineNumber}
|
|
side={props.side}
|
|
startLine={startLine}
|
|
diffFile={diffFile}
|
|
wrapperRef={wrapperRef}
|
|
viewMode={viewMode}
|
|
filePath={filePath}
|
|
onSave={(comment) => {
|
|
addComment(comment.startLine, comment.endLine, comment.side, comment.content);
|
|
setWidgetStartLines((prev) => {
|
|
const next = { ...prev };
|
|
delete next[widgetKey];
|
|
return next;
|
|
});
|
|
props.onClose();
|
|
}}
|
|
onCancel={() => {
|
|
setWidgetStartLines((prev) => {
|
|
const next = { ...prev };
|
|
delete next[widgetKey];
|
|
return next;
|
|
});
|
|
props.onClose();
|
|
}}
|
|
onStartLineChange={(newStartLine) => {
|
|
setWidgetStartLines((prev) => ({ ...prev, [widgetKey]: newStartLine }));
|
|
}}
|
|
/>
|
|
);
|
|
},
|
|
[filePath, diffFile, viewMode, enableComments, addComment]
|
|
);
|
|
|
|
// Render existing comments inline
|
|
const renderExtendLine = useCallback(
|
|
(lineData: { data: ExtendLineData }) => {
|
|
if (!lineData.data) return null;
|
|
return <CommentRenderer comment={lineData.data.comment} onDelete={(id) => deleteComment(id)} />;
|
|
},
|
|
[deleteComment]
|
|
);
|
|
|
|
if (!diffFile) {
|
|
return (
|
|
<div className={cn('flex items-center justify-center p-6', className)}>
|
|
<div className="text-muted-foreground">Failed to generate diff</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={cn('space-y-4', className)}>
|
|
{showToolbar && (
|
|
<div className="flex items-center justify-between">
|
|
<Badge variant="secondary" className="rounded-full text-xs">
|
|
{filePath}
|
|
</Badge>
|
|
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className={cn('h-7 px-2 text-xs rounded-none cursor-pointer', viewMode === 'unified' && 'bg-muted')}
|
|
onClick={() => setViewMode('unified')}
|
|
>
|
|
<IconLayoutRows className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className={cn('h-7 px-2 text-xs rounded-none cursor-pointer', viewMode === 'split' && 'bg-muted')}
|
|
onClick={() => setViewMode('split')}
|
|
>
|
|
<IconLayoutColumns className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Outer container for positioning widget outside overflow */}
|
|
<div className="relative">
|
|
<div
|
|
ref={wrapperRef}
|
|
className="relative border border-border rounded-lg overflow-x-auto select-none"
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onMouseLeave={handleMouseLeave}
|
|
onClickCapture={handleClick}
|
|
>
|
|
{/* Comment range indicators */}
|
|
{enableComments &&
|
|
comments.map((comment) => (
|
|
<CommentRangeIndicator key={comment.id} comment={comment} wrapperRef={wrapperRef} viewMode={viewMode} />
|
|
))}
|
|
|
|
{/* Drag selection overlay */}
|
|
{enableComments && dragSelection && diffFile && (
|
|
<DragSelectionOverlay selection={dragSelection} wrapperRef={wrapperRef} viewMode={viewMode} />
|
|
)}
|
|
|
|
<DiffView
|
|
diffFile={diffFile}
|
|
diffViewWrap={false}
|
|
diffViewTheme={diffTheme}
|
|
diffViewHighlight
|
|
diffViewMode={diffModeEnum}
|
|
diffViewFontSize={12}
|
|
diffViewAddWidget={enableComments}
|
|
onAddWidgetClick={() => {}}
|
|
renderWidgetLine={renderWidgetLine}
|
|
extendData={extendData}
|
|
renderExtendLine={renderExtendLine}
|
|
/>
|
|
</div>
|
|
|
|
{/* Multi-line comment widget - outside overflow container */}
|
|
{enableComments && showDragWidget && dragSelection && !dragSelection.isActive && diffFile && (
|
|
<PositionedCommentWidget
|
|
selection={dragSelection}
|
|
diffFile={diffFile}
|
|
wrapperRef={wrapperRef}
|
|
viewMode={viewMode}
|
|
onSave={(content) => {
|
|
addComment(dragSelection.startLine, dragSelection.endLine, dragSelection.side, content);
|
|
setDragSelection(null);
|
|
setShowDragWidget(false);
|
|
}}
|
|
onCancel={() => {
|
|
setDragSelection(null);
|
|
setShowDragWidget(false);
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|