feat: pierre diffs lib (#147)
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { IconTrash, IconEdit, IconMessage } from '@tabler/icons-react';
|
||||
import type { DiffComment } from '@/lib/diff/types';
|
||||
import { formatLineRange } from '@/lib/diff';
|
||||
|
||||
interface CommentDisplayProps {
|
||||
/** The comment to display */
|
||||
comment: DiffComment;
|
||||
/** Callback to delete the comment */
|
||||
onDelete?: () => void;
|
||||
/** Callback to edit the comment */
|
||||
onEdit?: () => void;
|
||||
/** Whether to show the code content */
|
||||
showCode?: boolean;
|
||||
/** Compact mode for inline display */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function CommentDisplay({
|
||||
comment,
|
||||
onDelete,
|
||||
onEdit,
|
||||
showCode = false,
|
||||
compact = false,
|
||||
}: CommentDisplayProps) {
|
||||
const lineRange = formatLineRange(comment.startLine, comment.endLine);
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="group flex items-start gap-2 rounded border border-border bg-muted/50 px-2 py-1.5 text-xs">
|
||||
<IconMessage className="mt-0.5 h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-muted-foreground">{lineRange}</span>
|
||||
<span className="mx-1 text-muted-foreground">·</span>
|
||||
<span className="break-words">{comment.annotation}</span>
|
||||
</div>
|
||||
{onDelete && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onDelete}
|
||||
className="h-4 w-4 shrink-0 cursor-pointer p-0 opacity-0 hover:text-destructive group-hover:opacity-100"
|
||||
>
|
||||
<IconTrash className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group rounded-md border border-border bg-card p-2 shadow-sm">
|
||||
{/* Header */}
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<IconMessage className="h-3.5 w-3.5 text-blue-500" />
|
||||
<span className="font-medium">
|
||||
{lineRange}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
({comment.side === 'additions' ? 'new' : 'old'})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{onEdit && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onEdit}
|
||||
className="h-5 w-5 cursor-pointer p-0"
|
||||
>
|
||||
<IconEdit className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onDelete}
|
||||
className="h-5 w-5 cursor-pointer p-0 hover:text-destructive"
|
||||
>
|
||||
<IconTrash className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code preview */}
|
||||
{showCode && comment.codeContent && (
|
||||
<pre className="mb-2 overflow-x-auto rounded bg-muted p-1.5 text-[10px] leading-tight">
|
||||
<code>{comment.codeContent}</code>
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{/* Comment text */}
|
||||
<p className="whitespace-pre-wrap text-xs leading-relaxed text-foreground">
|
||||
{comment.annotation}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { Textarea } from '@kandev/ui/textarea';
|
||||
import { IconSend, IconX } from '@tabler/icons-react';
|
||||
|
||||
interface CommentFormProps {
|
||||
/** Initial content for editing */
|
||||
initialContent?: string;
|
||||
/** Callback when comment is submitted */
|
||||
onSubmit: (content: string) => void;
|
||||
/** Callback when form is cancelled */
|
||||
onCancel: () => void;
|
||||
/** Whether the form is in editing mode */
|
||||
isEditing?: boolean;
|
||||
/** Auto focus the textarea */
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
export function CommentForm({
|
||||
initialContent = '',
|
||||
onSubmit,
|
||||
onCancel,
|
||||
isEditing = false,
|
||||
autoFocus = true,
|
||||
}: CommentFormProps) {
|
||||
const [content, setContent] = useState(initialContent);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
}, [autoFocus]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmed = content.trim();
|
||||
if (trimmed) {
|
||||
onSubmit(trimmed);
|
||||
setContent('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-md border border-border bg-card p-2 shadow-md">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Add a comment..."
|
||||
className="min-h-[60px] resize-none text-xs"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{typeof navigator !== 'undefined' && navigator.platform?.includes('Mac') ? '⌘' : 'Ctrl'}+Enter to submit
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onCancel}
|
||||
className="h-6 cursor-pointer px-2 text-xs"
|
||||
>
|
||||
<IconX className="mr-1 h-3 w-3" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={!content.trim()}
|
||||
className="h-6 cursor-pointer px-2 text-xs"
|
||||
>
|
||||
<IconSend className="mr-1 h-3 w-3" />
|
||||
{isEditing ? 'Update' : 'Add'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
'use client';
|
||||
|
||||
// Re-export from the unified diff-viewer component
|
||||
export { DiffViewInline } from './diff-viewer';
|
||||
@@ -0,0 +1,507 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo, memo, useSyncExternalStore, type ReactNode } from 'react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { FileDiff } from '@pierre/diffs/react';
|
||||
import { parsePatchFiles, parseDiffFromFile } from '@pierre/diffs';
|
||||
import type { FileDiffOptions, FileDiffMetadata, DiffLineAnnotation, RenderHeaderMetadataProps, AnnotationSide, SelectedLineRange } from '@pierre/diffs';
|
||||
import { cn } from '@kandev/ui/lib/utils';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@kandev/ui/tooltip';
|
||||
import { IconCopy, IconTextWrap, IconLayoutRows, IconLayoutColumns, IconExternalLink, IconPlus, IconArrowBackUp } from '@tabler/icons-react';
|
||||
import type { FileDiffData, DiffComment } from '@/lib/diff/types';
|
||||
import { useDiffComments } from './use-diff-comments';
|
||||
import { CommentForm } from './comment-form';
|
||||
import { CommentDisplay } from './comment-display';
|
||||
|
||||
// Local storage key for global diff view mode
|
||||
const DIFF_VIEW_MODE_KEY = 'diff-view-mode';
|
||||
const DEFAULT_VIEW_MODE = 'unified' as const;
|
||||
|
||||
// Custom event for syncing view mode across components
|
||||
const VIEW_MODE_CHANGE_EVENT = 'diff-view-mode-change';
|
||||
|
||||
type ViewMode = 'split' | 'unified';
|
||||
|
||||
// Helper to get view mode from localStorage
|
||||
function getStoredViewMode(): ViewMode {
|
||||
if (typeof window === 'undefined') return DEFAULT_VIEW_MODE;
|
||||
const stored = localStorage.getItem(DIFF_VIEW_MODE_KEY);
|
||||
return stored === 'split' || stored === 'unified' ? stored : DEFAULT_VIEW_MODE;
|
||||
}
|
||||
|
||||
// Helper to set view mode in localStorage and dispatch event
|
||||
function setStoredViewMode(mode: ViewMode): void {
|
||||
localStorage.setItem(DIFF_VIEW_MODE_KEY, mode);
|
||||
window.dispatchEvent(new CustomEvent(VIEW_MODE_CHANGE_EVENT, { detail: mode }));
|
||||
}
|
||||
|
||||
// Hook to subscribe to global view mode changes
|
||||
function useGlobalViewMode(): [ViewMode, (mode: ViewMode) => void] {
|
||||
const subscribe = useCallback((callback: () => void) => {
|
||||
window.addEventListener(VIEW_MODE_CHANGE_EVENT, callback);
|
||||
window.addEventListener('storage', callback);
|
||||
return () => {
|
||||
window.removeEventListener(VIEW_MODE_CHANGE_EVENT, callback);
|
||||
window.removeEventListener('storage', callback);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getSnapshot = useCallback(() => getStoredViewMode(), []);
|
||||
const getServerSnapshot = useCallback(() => DEFAULT_VIEW_MODE, []);
|
||||
|
||||
const viewMode = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
|
||||
return [viewMode, setStoredViewMode];
|
||||
}
|
||||
|
||||
interface DiffViewerProps {
|
||||
/** Diff data to display */
|
||||
data: FileDiffData;
|
||||
/** Enable line selection for comments */
|
||||
enableComments?: boolean;
|
||||
/** Session ID for comment storage */
|
||||
sessionId?: string;
|
||||
/** Callback when comment is added */
|
||||
onCommentAdd?: (comment: DiffComment) => void;
|
||||
/** Callback when comment is deleted */
|
||||
onCommentDelete?: (commentId: string) => void;
|
||||
/** External comments (controlled mode) */
|
||||
comments?: DiffComment[];
|
||||
/** Additional class name */
|
||||
className?: string;
|
||||
/** Whether to show in compact mode (smaller text, no header) */
|
||||
compact?: boolean;
|
||||
/** Whether to hide the file header and toolbar */
|
||||
hideHeader?: boolean;
|
||||
/** Callback to open file in editor */
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
}
|
||||
|
||||
type AnnotationMetadata = {
|
||||
type: 'comment' | 'new-comment-form';
|
||||
comment?: DiffComment;
|
||||
isEditing?: boolean;
|
||||
};
|
||||
|
||||
// Custom comparison for memo - only re-render if actual content changes
|
||||
function arePropsEqual(prevProps: DiffViewerProps, nextProps: DiffViewerProps): boolean {
|
||||
// Compare data by content, not reference
|
||||
if (prevProps.data.filePath !== nextProps.data.filePath) return false;
|
||||
if (prevProps.data.diff !== nextProps.data.diff) return false;
|
||||
if (prevProps.data.oldContent !== nextProps.data.oldContent) return false;
|
||||
if (prevProps.data.newContent !== nextProps.data.newContent) return false;
|
||||
|
||||
// Compare other props
|
||||
if (prevProps.enableComments !== nextProps.enableComments) return false;
|
||||
if (prevProps.sessionId !== nextProps.sessionId) return false;
|
||||
if (prevProps.compact !== nextProps.compact) return false;
|
||||
if (prevProps.hideHeader !== nextProps.hideHeader) return false;
|
||||
if (prevProps.className !== nextProps.className) return false;
|
||||
if (prevProps.onOpenFile !== nextProps.onOpenFile) return false;
|
||||
|
||||
// Comments array - compare by length and IDs
|
||||
const prevComments = prevProps.comments;
|
||||
const nextComments = nextProps.comments;
|
||||
if (prevComments !== nextComments) {
|
||||
if (!prevComments || !nextComments) return false;
|
||||
if (prevComments.length !== nextComments.length) return false;
|
||||
for (let i = 0; i < prevComments.length; i++) {
|
||||
if (prevComments[i].id !== nextComments[i].id) return false;
|
||||
if (prevComments[i].annotation !== nextComments[i].annotation) return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export const DiffViewer = memo(function DiffViewer({
|
||||
data,
|
||||
enableComments = false,
|
||||
sessionId,
|
||||
onCommentAdd,
|
||||
onCommentDelete,
|
||||
comments: externalComments,
|
||||
className,
|
||||
compact = false,
|
||||
hideHeader = false,
|
||||
onOpenFile,
|
||||
}: DiffViewerProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [selectedLines, setSelectedLines] = useState<SelectedLineRange | null>(null);
|
||||
const [showCommentForm, setShowCommentForm] = useState(false);
|
||||
|
||||
// Global view mode (synced via localStorage)
|
||||
const [globalViewMode, setGlobalViewMode] = useGlobalViewMode();
|
||||
|
||||
// Local state for word wrap (per-diff)
|
||||
const [wordWrap, setWordWrap] = useState(false);
|
||||
|
||||
// Use internal comment management if sessionId provided
|
||||
const {
|
||||
comments: internalComments,
|
||||
addComment,
|
||||
removeComment,
|
||||
updateComment,
|
||||
editingCommentId,
|
||||
setEditingComment,
|
||||
} = useDiffComments({
|
||||
sessionId: sessionId || '',
|
||||
filePath: data.filePath,
|
||||
diff: data.diff,
|
||||
newContent: data.newContent,
|
||||
oldContent: data.oldContent,
|
||||
});
|
||||
|
||||
const comments = externalComments || internalComments;
|
||||
|
||||
// Create annotations from comments + new comment form
|
||||
const annotations = useMemo<DiffLineAnnotation<AnnotationMetadata>[]>(() => {
|
||||
const result: DiffLineAnnotation<AnnotationMetadata>[] = comments.map((comment) => ({
|
||||
side: comment.side,
|
||||
lineNumber: comment.endLine,
|
||||
metadata: {
|
||||
type: 'comment' as const,
|
||||
comment,
|
||||
isEditing: editingCommentId === comment.id,
|
||||
},
|
||||
}));
|
||||
|
||||
// Add new comment form annotation at the selected line
|
||||
if (showCommentForm && selectedLines) {
|
||||
result.push({
|
||||
side: (selectedLines.side || 'additions') as AnnotationSide,
|
||||
lineNumber: Math.max(selectedLines.start, selectedLines.end),
|
||||
metadata: {
|
||||
type: 'new-comment-form' as const,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [comments, editingCommentId, showCommentForm, selectedLines]);
|
||||
|
||||
// Stable handler for line selection - only fires when selection is complete
|
||||
const handleLineSelectionEnd = useCallback(
|
||||
(range: SelectedLineRange | null) => {
|
||||
setSelectedLines(range);
|
||||
if (range && enableComments) {
|
||||
setShowCommentForm(true);
|
||||
}
|
||||
},
|
||||
[enableComments]
|
||||
);
|
||||
|
||||
// Handler for comment submission
|
||||
const handleCommentSubmit = useCallback(
|
||||
(content: string) => {
|
||||
if (!selectedLines) return;
|
||||
|
||||
if (onCommentAdd && externalComments !== undefined) {
|
||||
const comment: DiffComment = {
|
||||
id: `${data.filePath}-${Date.now()}`,
|
||||
sessionId: sessionId || '',
|
||||
filePath: data.filePath,
|
||||
startLine: Math.min(selectedLines.start, selectedLines.end),
|
||||
endLine: Math.max(selectedLines.start, selectedLines.end),
|
||||
side: (selectedLines.side || 'additions') as DiffComment['side'],
|
||||
codeContent: '',
|
||||
annotation: content,
|
||||
createdAt: new Date().toISOString(),
|
||||
status: 'pending',
|
||||
};
|
||||
onCommentAdd(comment);
|
||||
} else if (sessionId) {
|
||||
addComment(selectedLines, content);
|
||||
}
|
||||
|
||||
setShowCommentForm(false);
|
||||
setSelectedLines(null);
|
||||
},
|
||||
[selectedLines, sessionId, data.filePath, addComment, onCommentAdd, externalComments]
|
||||
);
|
||||
|
||||
// Handler for comment deletion
|
||||
const handleCommentDelete = useCallback(
|
||||
(commentId: string) => {
|
||||
if (onCommentDelete && externalComments !== undefined) {
|
||||
onCommentDelete(commentId);
|
||||
} else {
|
||||
removeComment(commentId);
|
||||
}
|
||||
},
|
||||
[removeComment, onCommentDelete, externalComments]
|
||||
);
|
||||
|
||||
// Handle comment update
|
||||
const handleCommentUpdate = useCallback(
|
||||
(commentId: string, content: string) => {
|
||||
updateComment(commentId, { annotation: content });
|
||||
setEditingComment(null);
|
||||
},
|
||||
[updateComment, setEditingComment]
|
||||
);
|
||||
|
||||
// Render annotation callback
|
||||
const renderAnnotation = useCallback(
|
||||
(annotation: DiffLineAnnotation<AnnotationMetadata>): ReactNode => {
|
||||
const { type, comment, isEditing } = annotation.metadata;
|
||||
|
||||
if (type === 'new-comment-form') {
|
||||
return (
|
||||
<div className="my-1 px-2">
|
||||
<CommentForm
|
||||
onSubmit={handleCommentSubmit}
|
||||
onCancel={() => {
|
||||
setShowCommentForm(false);
|
||||
setSelectedLines(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEditing && comment) {
|
||||
return (
|
||||
<div className="my-1 px-2">
|
||||
<CommentForm
|
||||
initialContent={comment.annotation}
|
||||
onSubmit={(content) => handleCommentUpdate(comment.id, content)}
|
||||
onCancel={() => setEditingComment(null)}
|
||||
isEditing
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (comment) {
|
||||
return (
|
||||
<div className="my-1 px-2">
|
||||
<CommentDisplay
|
||||
comment={comment}
|
||||
onDelete={() => handleCommentDelete(comment.id)}
|
||||
onEdit={() => setEditingComment(comment.id)}
|
||||
showCode={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[setEditingComment, handleCommentDelete, handleCommentUpdate, handleCommentSubmit]
|
||||
);
|
||||
|
||||
// Render header metadata toolbar for each diff file
|
||||
const renderHeaderMetadata = useCallback(
|
||||
(props: RenderHeaderMetadataProps): ReactNode => {
|
||||
const filePath = props.fileDiff?.name || data.filePath;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Copy diff button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100"
|
||||
onClick={() => navigator.clipboard.writeText(data.diff || '')}
|
||||
>
|
||||
<IconCopy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy diff</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Revert button (non-functional placeholder) */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100"
|
||||
onClick={() => {
|
||||
// TODO: Implement revert functionality
|
||||
}}
|
||||
>
|
||||
<IconArrowBackUp className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Revert changes</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Word wrap toggle */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100',
|
||||
wordWrap && 'opacity-100 bg-muted'
|
||||
)}
|
||||
onClick={() => setWordWrap(!wordWrap)}
|
||||
>
|
||||
<IconTextWrap className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Toggle word wrap</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Split/Unified toggle (global) */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100"
|
||||
onClick={() => setGlobalViewMode(globalViewMode === 'split' ? 'unified' : 'split')}
|
||||
>
|
||||
{globalViewMode === 'split' ? (
|
||||
<IconLayoutRows className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<IconLayoutColumns className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{globalViewMode === 'split' ? 'Switch to unified view' : 'Switch to split view'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Open in editor button */}
|
||||
{onOpenFile && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 cursor-pointer opacity-60 hover:opacity-100"
|
||||
onClick={() => onOpenFile(filePath)}
|
||||
>
|
||||
<IconExternalLink className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Open in editor</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[data.filePath, data.diff, wordWrap, globalViewMode, setGlobalViewMode, onOpenFile]
|
||||
);
|
||||
|
||||
// Render hover utility (+) icon for adding comments
|
||||
const renderHoverUtility = useCallback(
|
||||
(): ReactNode => {
|
||||
// Only show when comments are enabled
|
||||
if (!enableComments) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-5 w-5 cursor-pointer items-center justify-center rounded border border-border bg-background text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
title="Add comment"
|
||||
>
|
||||
<IconPlus className="h-3 w-3" />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[enableComments]
|
||||
);
|
||||
|
||||
// Determine if header/toolbar should be shown
|
||||
const showHeader = !hideHeader && !compact;
|
||||
|
||||
// Stable options - only include values that truly need to change the diff render
|
||||
const options = useMemo<FileDiffOptions<AnnotationMetadata>>(
|
||||
() => ({
|
||||
diffStyle: globalViewMode,
|
||||
themeType: resolvedTheme === 'dark' ? 'dark' : 'light',
|
||||
theme: {
|
||||
dark: 'github-dark-high-contrast',
|
||||
light: 'github-light',
|
||||
},
|
||||
enableLineSelection: enableComments,
|
||||
enableHoverUtility: enableComments,
|
||||
diffIndicators: 'none',
|
||||
onLineSelectionEnd: handleLineSelectionEnd,
|
||||
disableFileHeader: !showHeader,
|
||||
lineDiffType: 'word',
|
||||
overflow: wordWrap ? 'wrap' : 'scroll',
|
||||
}),
|
||||
[globalViewMode, resolvedTheme, enableComments, showHeader, handleLineSelectionEnd, wordWrap]
|
||||
);
|
||||
|
||||
// Compute FileDiffMetadata from either diff string or content
|
||||
const fileDiffMetadata = useMemo<FileDiffMetadata | null>(() => {
|
||||
if (data.diff) {
|
||||
// Has diff string - parse it
|
||||
const parsed = parsePatchFiles(data.diff);
|
||||
return parsed[0]?.files[0] ?? null;
|
||||
} else if (data.oldContent || data.newContent) {
|
||||
// Has content - generate diff using library
|
||||
return parseDiffFromFile(
|
||||
{ name: data.filePath, contents: data.oldContent },
|
||||
{ name: data.filePath, contents: data.newContent }
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}, [data.diff, data.oldContent, data.newContent, data.filePath]);
|
||||
|
||||
// Only pass selectedLines when we have a completed selection (form shown)
|
||||
// During drag, let FileDiff manage its own internal state
|
||||
const controlledSelection = showCommentForm ? selectedLines : null;
|
||||
|
||||
if (!fileDiffMetadata) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'rounded-md border border-border/50 bg-muted/20 p-4 text-muted-foreground',
|
||||
compact ? 'text-xs' : 'text-sm',
|
||||
className
|
||||
)}>
|
||||
No diff available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('diff-viewer relative', className)}>
|
||||
<FileDiff
|
||||
fileDiff={fileDiffMetadata}
|
||||
options={options}
|
||||
selectedLines={controlledSelection}
|
||||
lineAnnotations={annotations}
|
||||
renderAnnotation={renderAnnotation}
|
||||
renderHeaderMetadata={showHeader ? renderHeaderMetadata : undefined}
|
||||
renderHoverUtility={renderHoverUtility}
|
||||
className={cn(
|
||||
'rounded-md border border-border/50',
|
||||
compact && 'text-xs'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}, arePropsEqual);
|
||||
|
||||
/**
|
||||
* Compact inline diff viewer for chat messages.
|
||||
* This is a convenience wrapper around DiffViewer with compact defaults.
|
||||
* @deprecated Use DiffViewer with compact={true} instead
|
||||
*/
|
||||
export function DiffViewInline({
|
||||
data,
|
||||
className,
|
||||
}: {
|
||||
data: FileDiffData;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<DiffViewer
|
||||
data={data}
|
||||
compact
|
||||
hideHeader
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useMemo } from 'react';
|
||||
import { DiffViewer } from './diff-viewer';
|
||||
import { transformGitDiff } from '@/lib/diff';
|
||||
import type { DiffComment } from '@/lib/diff/types';
|
||||
|
||||
interface FileDiffViewerProps {
|
||||
/** File path */
|
||||
filePath: string;
|
||||
/** Raw diff content */
|
||||
diff: string;
|
||||
/** File status (M, A, D, etc.) */
|
||||
status?: string;
|
||||
/** Enable line selection for comments */
|
||||
enableComments?: boolean;
|
||||
/** Session ID for comment storage */
|
||||
sessionId?: string;
|
||||
/** Callback when comment is added */
|
||||
onCommentAdd?: (comment: DiffComment) => void;
|
||||
/** Callback when comment is deleted */
|
||||
onCommentDelete?: (commentId: string) => void;
|
||||
/** External comments (controlled mode) */
|
||||
comments?: DiffComment[];
|
||||
/** Additional class name */
|
||||
className?: string;
|
||||
/** Whether to show in compact mode */
|
||||
compact?: boolean;
|
||||
/** Whether to hide the file header */
|
||||
hideHeader?: boolean;
|
||||
/** Callback to open file in editor */
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around DiffViewer that handles data transformation with memoization.
|
||||
*
|
||||
* Use this component when you have raw diff data (filePath, diff, status).
|
||||
* The transformation to FileDiffData is memoized internally, preventing
|
||||
* unnecessary re-renders when parent components update.
|
||||
*
|
||||
* This is the recommended component to use for rendering git diffs.
|
||||
*/
|
||||
export const FileDiffViewer = memo(function FileDiffViewer({
|
||||
filePath,
|
||||
diff,
|
||||
status = 'M',
|
||||
enableComments,
|
||||
sessionId,
|
||||
onCommentAdd,
|
||||
onCommentDelete,
|
||||
comments,
|
||||
className,
|
||||
compact,
|
||||
hideHeader,
|
||||
onOpenFile,
|
||||
}: FileDiffViewerProps) {
|
||||
// Memoize the transformation - only recalculates when raw data changes
|
||||
const data = useMemo(
|
||||
() => transformGitDiff(filePath, diff, status),
|
||||
[filePath, diff, status]
|
||||
);
|
||||
|
||||
return (
|
||||
<DiffViewer
|
||||
data={data}
|
||||
enableComments={enableComments}
|
||||
sessionId={sessionId}
|
||||
onCommentAdd={onCommentAdd}
|
||||
onCommentDelete={onCommentDelete}
|
||||
comments={comments}
|
||||
className={className}
|
||||
compact={compact}
|
||||
hideHeader={hideHeader}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
// Main components
|
||||
export { DiffViewer } from './diff-viewer';
|
||||
export { FileDiffViewer } from './file-diff-viewer';
|
||||
export { DiffViewInline } from './diff-view-inline';
|
||||
export { CommentForm } from './comment-form';
|
||||
export { CommentDisplay } from './comment-display';
|
||||
|
||||
// Hooks
|
||||
export { useDiffComments } from './use-diff-comments';
|
||||
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import type { SelectedLineRange } from '@pierre/diffs';
|
||||
import {
|
||||
useDiffCommentsStore,
|
||||
useFileComments,
|
||||
} from '@/lib/state/slices/diff-comments';
|
||||
import {
|
||||
commentsToAnnotations,
|
||||
extractCodeFromDiff,
|
||||
extractCodeFromContent,
|
||||
} from '@/lib/diff';
|
||||
import type { DiffComment, AnnotationSide, CommentAnnotation } from '@/lib/diff/types';
|
||||
|
||||
interface UseDiffCommentsOptions {
|
||||
sessionId: string;
|
||||
filePath: string;
|
||||
/** Diff string for extracting code from line selection */
|
||||
diff?: string;
|
||||
/** New content for extracting code from line selection */
|
||||
newContent?: string;
|
||||
/** Old content for extracting code from line selection */
|
||||
oldContent?: string;
|
||||
}
|
||||
|
||||
interface UseDiffCommentsReturn {
|
||||
/** Comments for this file */
|
||||
comments: DiffComment[];
|
||||
/** Annotations formatted for @pierre/diffs */
|
||||
annotations: CommentAnnotation[];
|
||||
/** Add a new comment */
|
||||
addComment: (
|
||||
range: SelectedLineRange,
|
||||
annotation: string
|
||||
) => void;
|
||||
/** Remove a comment */
|
||||
removeComment: (commentId: string) => void;
|
||||
/** Update a comment */
|
||||
updateComment: (commentId: string, updates: Partial<DiffComment>) => void;
|
||||
/** Currently editing comment ID */
|
||||
editingCommentId: string | null;
|
||||
/** Set the editing comment ID */
|
||||
setEditingComment: (commentId: string | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage comments for a specific file's diff
|
||||
*/
|
||||
export function useDiffComments({
|
||||
sessionId,
|
||||
filePath,
|
||||
diff,
|
||||
newContent,
|
||||
oldContent,
|
||||
}: UseDiffCommentsOptions): UseDiffCommentsReturn {
|
||||
const comments = useFileComments(sessionId, filePath);
|
||||
const editingCommentId = useDiffCommentsStore(
|
||||
(state) => state.editingCommentId
|
||||
);
|
||||
const storeAddComment = useDiffCommentsStore((state) => state.addComment);
|
||||
const storeRemoveComment = useDiffCommentsStore((state) => state.removeComment);
|
||||
const storeUpdateComment = useDiffCommentsStore((state) => state.updateComment);
|
||||
const storeSetEditingComment = useDiffCommentsStore((state) => state.setEditingComment);
|
||||
|
||||
const annotations = useMemo(
|
||||
() => commentsToAnnotations(comments),
|
||||
[comments]
|
||||
);
|
||||
|
||||
const addComment = useCallback(
|
||||
(range: SelectedLineRange, annotation: string) => {
|
||||
const side = (range.side || 'additions') as AnnotationSide;
|
||||
const startLine = Math.min(range.start, range.end);
|
||||
const endLine = Math.max(range.start, range.end);
|
||||
|
||||
// Extract the code content from the selected lines
|
||||
let codeContent = '';
|
||||
if (diff) {
|
||||
codeContent = extractCodeFromDiff(diff, startLine, endLine, side);
|
||||
} else {
|
||||
const content = side === 'additions' ? newContent : oldContent;
|
||||
if (content) {
|
||||
codeContent = extractCodeFromContent(content, startLine, endLine);
|
||||
}
|
||||
}
|
||||
|
||||
const comment: DiffComment = {
|
||||
id: `${filePath}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
sessionId,
|
||||
filePath,
|
||||
startLine,
|
||||
endLine,
|
||||
side,
|
||||
codeContent,
|
||||
annotation,
|
||||
createdAt: new Date().toISOString(),
|
||||
status: 'pending',
|
||||
};
|
||||
|
||||
storeAddComment(comment);
|
||||
toast.success('Comment added', {
|
||||
description: 'Your comment will be sent with your next message.',
|
||||
duration: 2000,
|
||||
});
|
||||
},
|
||||
[sessionId, filePath, diff, newContent, oldContent, storeAddComment]
|
||||
);
|
||||
|
||||
const removeComment = useCallback(
|
||||
(commentId: string) => {
|
||||
storeRemoveComment(sessionId, filePath, commentId);
|
||||
},
|
||||
[sessionId, filePath, storeRemoveComment]
|
||||
);
|
||||
|
||||
const updateComment = useCallback(
|
||||
(commentId: string, updates: Partial<DiffComment>) => {
|
||||
storeUpdateComment(commentId, updates);
|
||||
},
|
||||
[storeUpdateComment]
|
||||
);
|
||||
|
||||
const setEditingComment = useCallback(
|
||||
(commentId: string | null) => {
|
||||
storeSetEditingComment(commentId);
|
||||
},
|
||||
[storeSetEditingComment]
|
||||
);
|
||||
|
||||
return {
|
||||
comments,
|
||||
annotations,
|
||||
addComment,
|
||||
removeComment,
|
||||
updateComment,
|
||||
editingCommentId,
|
||||
setEditingComment,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user