324 lines
10 KiB
TypeScript
324 lines
10 KiB
TypeScript
// components/diff-viewer/UnifiedView.tsx
|
|
import React, { useState, useMemo } from 'react';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import {
|
|
FileText,
|
|
Plus,
|
|
Minus,
|
|
Copy,
|
|
Download,
|
|
ChevronDown,
|
|
ChevronRight
|
|
} from 'lucide-react';
|
|
import { DiffFile, DiffPreferences } from './DiffViewerContext';
|
|
|
|
interface UnifiedViewProps {
|
|
files: DiffFile[];
|
|
selectedFile: DiffFile | null;
|
|
onFileSelect: (filePath: string) => void;
|
|
theme: string;
|
|
preferences: DiffPreferences;
|
|
}
|
|
|
|
interface DiffLine {
|
|
type: 'added' | 'removed' | 'unchanged' | 'context';
|
|
content: string;
|
|
lineNumber?: number;
|
|
}
|
|
|
|
const UnifiedView: React.FC<UnifiedViewProps> = ({
|
|
files,
|
|
selectedFile,
|
|
onFileSelect,
|
|
theme,
|
|
preferences
|
|
}) => {
|
|
const [expandedHunks, setExpandedHunks] = useState<Set<string>>(new Set());
|
|
|
|
// Parse diff content into structured format
|
|
const parseDiffContent = (diffContent: string): DiffLine[] => {
|
|
if (!diffContent) return [];
|
|
|
|
const lines = diffContent.split('\n');
|
|
const diffLines: DiffLine[] = [];
|
|
let lineNumber = 0;
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith('@@')) {
|
|
// Parse hunk header
|
|
const match = line.match(/@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@/);
|
|
if (match) {
|
|
lineNumber = parseInt(match[3]) - 1;
|
|
}
|
|
diffLines.push({ type: 'context', content: line });
|
|
} else if (line.startsWith('+')) {
|
|
lineNumber++;
|
|
diffLines.push({
|
|
type: 'added',
|
|
content: line.substring(1),
|
|
lineNumber
|
|
});
|
|
} else if (line.startsWith('-')) {
|
|
diffLines.push({
|
|
type: 'removed',
|
|
content: line.substring(1),
|
|
lineNumber: undefined
|
|
});
|
|
} else {
|
|
lineNumber++;
|
|
diffLines.push({
|
|
type: 'unchanged',
|
|
content: line.substring(1),
|
|
lineNumber
|
|
});
|
|
}
|
|
}
|
|
|
|
return diffLines;
|
|
};
|
|
|
|
// Group diff lines into hunks
|
|
const groupIntoHunks = (diffLines: DiffLine[]) => {
|
|
const hunks: { header: string; lines: DiffLine[] }[] = [];
|
|
let currentHunk: { header: string; lines: DiffLine[] } | null = null;
|
|
|
|
for (const line of diffLines) {
|
|
if (line.type === 'context' && line.content.startsWith('@@')) {
|
|
if (currentHunk) {
|
|
hunks.push(currentHunk);
|
|
}
|
|
currentHunk = { header: line.content, lines: [] };
|
|
} else if (currentHunk) {
|
|
currentHunk.lines.push(line);
|
|
}
|
|
}
|
|
|
|
if (currentHunk) {
|
|
hunks.push(currentHunk);
|
|
}
|
|
|
|
return hunks;
|
|
};
|
|
|
|
const diffLines = useMemo(() => {
|
|
if (!selectedFile?.diff_content) return [];
|
|
return parseDiffContent(selectedFile.diff_content);
|
|
}, [selectedFile]);
|
|
|
|
const hunks = useMemo(() => {
|
|
return groupIntoHunks(diffLines);
|
|
}, [diffLines]);
|
|
|
|
const toggleHunk = (hunkIndex: number) => {
|
|
const hunkId = `${selectedFile?.file_path}-${hunkIndex}`;
|
|
setExpandedHunks(prev => {
|
|
const newSet = new Set(prev);
|
|
if (newSet.has(hunkId)) {
|
|
newSet.delete(hunkId);
|
|
} else {
|
|
newSet.add(hunkId);
|
|
}
|
|
return newSet;
|
|
});
|
|
};
|
|
|
|
const copyToClipboard = (text: string) => {
|
|
navigator.clipboard.writeText(text);
|
|
};
|
|
|
|
const downloadDiff = () => {
|
|
if (!selectedFile?.diff_content) return;
|
|
|
|
const blob = new Blob([selectedFile.diff_content], { type: 'text/plain' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `${selectedFile.file_path}.diff`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
const getLineClass = (type: DiffLine['type']) => {
|
|
switch (type) {
|
|
case 'added':
|
|
return 'bg-green-50 dark:bg-green-900/20 border-l-4 border-green-500';
|
|
case 'removed':
|
|
return 'bg-red-50 dark:bg-red-900/20 border-l-4 border-red-500';
|
|
case 'unchanged':
|
|
return 'bg-gray-50 dark:bg-gray-800/50';
|
|
case 'context':
|
|
return 'bg-blue-50 dark:bg-blue-900/20 font-mono text-sm';
|
|
default:
|
|
return '';
|
|
}
|
|
};
|
|
|
|
const getLineIcon = (type: DiffLine['type']) => {
|
|
switch (type) {
|
|
case 'added':
|
|
return <Plus className="h-3 w-3 text-green-600" />;
|
|
case 'removed':
|
|
return <Minus className="h-3 w-3 text-red-600" />;
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const getLinePrefix = (type: DiffLine['type']) => {
|
|
switch (type) {
|
|
case 'added':
|
|
return '+';
|
|
case 'removed':
|
|
return '-';
|
|
case 'unchanged':
|
|
return ' ';
|
|
case 'context':
|
|
return '@';
|
|
default:
|
|
return ' ';
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="h-full flex flex-col">
|
|
{/* File tabs */}
|
|
<div className="border-b">
|
|
<Tabs value={selectedFile?.file_path || ''} onValueChange={onFileSelect}>
|
|
<TabsList className="w-full justify-start">
|
|
{files.map((file) => (
|
|
<TabsTrigger
|
|
key={file.file_path}
|
|
value={file.file_path}
|
|
className="flex items-center space-x-2"
|
|
>
|
|
<FileText className="h-4 w-4" />
|
|
<span className="truncate max-w-32">{file.file_path.split('/').pop()}</span>
|
|
<Badge
|
|
variant={
|
|
file.change_type === 'added' ? 'default' :
|
|
file.change_type === 'modified' ? 'secondary' :
|
|
file.change_type === 'deleted' ? 'destructive' : 'outline'
|
|
}
|
|
className="ml-1"
|
|
>
|
|
{file.change_type}
|
|
</Badge>
|
|
</TabsTrigger>
|
|
))}
|
|
</TabsList>
|
|
</Tabs>
|
|
</div>
|
|
|
|
{/* File info and controls */}
|
|
{selectedFile && (
|
|
<div className="p-4 border-b bg-muted/50">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center space-x-4">
|
|
<div>
|
|
<h3 className="font-medium">{selectedFile.file_path}</h3>
|
|
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
|
<span>{selectedFile.change_type}</span>
|
|
{selectedFile.diff_size_bytes && (
|
|
<span>• {(selectedFile.diff_size_bytes / 1024).toFixed(1)} KB</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => copyToClipboard(selectedFile.diff_content || '')}
|
|
>
|
|
<Copy className="h-4 w-4 mr-2" />
|
|
Copy
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={downloadDiff}
|
|
>
|
|
<Download className="h-4 w-4 mr-2" />
|
|
Download
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Diff content */}
|
|
<div className="flex-1 overflow-hidden h-[500px]">
|
|
<ScrollArea className="h-full">
|
|
<div className="font-mono text-sm">
|
|
{hunks.map((hunk, hunkIndex) => {
|
|
const hunkId = `${selectedFile?.file_path}-${hunkIndex}`;
|
|
const isExpanded = expandedHunks.has(hunkId);
|
|
|
|
return (
|
|
<div key={hunkIndex} className="border-b last:border-b-0">
|
|
<div
|
|
className="bg-blue-100 dark:bg-blue-900/30 px-4 py-2 cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-900/50"
|
|
onClick={() => toggleHunk(hunkIndex)}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<span className="font-mono text-sm">{hunk.header}</span>
|
|
<div className="flex items-center space-x-2">
|
|
<Button variant="ghost" size="sm">
|
|
{isExpanded ? (
|
|
<>
|
|
<ChevronDown className="h-4 w-4 mr-1" />
|
|
Collapse
|
|
</>
|
|
) : (
|
|
<>
|
|
<ChevronRight className="h-4 w-4 mr-1" />
|
|
Expand
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{isExpanded && (
|
|
<div>
|
|
{hunk.lines.map((line, lineIndex) => (
|
|
<div
|
|
key={lineIndex}
|
|
className={`px-4 py-1 flex items-center space-x-2 ${getLineClass(line.type)}`}
|
|
>
|
|
<div className="w-8 text-right text-xs text-muted-foreground">
|
|
{line.lineNumber || ''}
|
|
</div>
|
|
<div className="w-4 flex justify-center">
|
|
{getLineIcon(line.type)}
|
|
</div>
|
|
<div className="w-4 text-center font-mono text-xs">
|
|
{getLinePrefix(line.type)}
|
|
</div>
|
|
<div className="flex-1">
|
|
<code className="text-sm">{line.content}</code>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default UnifiedView;
|