codenuk_frontend_mine/src/components/diff-viewer/UnifiedView.tsx
2025-10-10 09:02:08 +05:30

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;