import React, { useState, useRef, useEffect } from 'react';
interface Version {
id: string;
title: string;
timestamp: string;
branch: string;
tags: string[];
content: string;
parentId?: string;
}
const initialVersions: Version[] = [
{ id: 'v1', title: 'Initial Draft', timestamp: '2023-01-01', branch: 'main',
tags: ['initial'], content: 'Initial content\nLine two\nLine three' },
{ id: 'v2', title: 'First Edit', timestamp: '2023-01-02', branch: 'main', tags:
['edit'], content: 'Edited content\nLine two\nNew line three', parentId: 'v1' },
{ id: 'v3', title: 'Feature Branch', timestamp: '2023-01-03', branch: 'feature',
tags: ['feature'], content: 'Feature content\nLine two\nLine three', parentId: 'v1'
},
{ id: 'v4', title: 'Second Edit', timestamp: '2023-01-04', branch: 'main', tags:
['edit'], content: 'More edits\nLine two\nNew line three', parentId: 'v2' },
{ id: 'v5', title: 'Feature Complete', timestamp: '2023-01-05', branch:
'feature', tags: ['feature'], content: 'Feature complete\nLine two\nFeature three',
parentId: 'v3' },
{ id: 'v6', title: 'Merge', timestamp: '2023-01-06', branch: 'main', tags:
['merge'], content: 'Merged content\nLine two\nNew line three', parentId: 'v4' },
];
const tagColors: Record<string, string> = {
initial: '#4CAF50',
edit: '#2196F3',
feature: '#9C27B0',
merge: '#FF9800',
fork: '#795548'
};
const VersionHistory: React.FC = () => {
const [zoom, setZoom] = useState(1);
const [versions, setVersions] = useState(initialVersions);
const [selectedVersions, setSelectedVersions] = useState<Version[]>([]);
const [draggedVersion, setDraggedVersion] = useState<Version | null>(null);
const svgRef = useRef<SVGSVGElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const nodeSpacing = 150;
const branchSpacing = 100;
const handleZoom = (direction: 'in' | 'out') => {
setZoom(prev => Math.max(0.5, Math.min(2, prev + (direction === 'in' ? 0.2 : -
0.2))));
};
const getBranchY = (branch: string) => {
const branches = [...new Set(versions.map(v => v.branch))];
return (branches.indexOf(branch) * branchSpacing) + 50;
};
const getNodeX = (timestamp: string) => {
const baseDate = new Date(versions[0].timestamp).getTime();
const currentDate = new Date(timestamp).getTime();
return ((currentDate - baseDate) / (1000 * 60 * 60 * 24)) * nodeSpacing + 50;
};
const handleVersionSelect = (version: Version) => {
setSelectedVersions(prev => {
if (prev.length === 2) return [version];
if (prev.find(v => v.id === version.id)) return prev.filter(v => v.id !==
version.id);
return [...prev, version];
});
};
const computeDiff = (text1: string, text2: string) => {
const lines1 = text1.split('\n');
const lines2 = text2.split('\n');
const diff: {line: string; type: 'added' | 'removed' | 'unchanged' |
'modified'}[] = [];
lines1.forEach((line, i) => {
if (!lines2[i]) {
diff.push({ line, type: 'removed' });
} else if (line !== lines2[i]) {
diff.push({ line, type: 'removed' });
diff.push({ line: lines2[i], type: 'added' });
} else {
diff.push({ line, type: 'unchanged' });
}
});
lines2.slice(lines1.length).forEach(line => {
diff.push({ line, type: 'added' });
});
return diff;
};
const DiffView: React.FC = () => {
if (selectedVersions.length !== 2) return null;
const diff = computeDiff(selectedVersions[0].content,
selectedVersions[1].content);
return (
<div className="fixed right-0 top-0 h-full w-2/3 bg-white p-4 shadow-lg
overflow-y-auto">
<h2 className="text-xl font-bold mb-2">Version Comparison</h2>
<div className="flex mb-4">
<div className="w-1/2 pr-2">
<h3>{selectedVersions[0].title}</h3>
<p className="text-gray-600">{selectedVersions[0].timestamp}</p>
</div>
<div className="w-1/2 pl-2">
<h3>{selectedVersions[1].title}</h3>
<p className="text-gray-600">{selectedVersions[1].timestamp}</p>
</div>
</div>
<div className="bg-gray-100 p-2 rounded font-mono text-sm">
{diff.map((item, i) => (
<div
key={i}
className={`py-1 ${
item.type === 'added' ? 'bg-green-100' :
item.type === 'removed' ? 'bg-red-100' :
item.type === 'modified' ? 'bg-yellow-100' :
'bg-transparent'
}`}
>
{item.type === 'added' && '+'}
{item.type === 'removed' && '-'}
{' '}{item.line}
</div>
))}
</div>
<button
className="mt-4 bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-
600"
onClick={() => {
const newVersion: Version = {
id: `v${versions.length + 1}`,
title: `Merged: ${selectedVersions[0].title} + $
{selectedVersions[1].title}`,
timestamp: new Date().toISOString().split('T')[0],
branch: `${selectedVersions[1].branch}-merged`,
tags: ['merge'],
content: selectedVersions[1].content,
parentId: selectedVersions[1].id
};
setVersions(prev => [...prev, newVersion]);
setSelectedVersions([]);
}}
>
Merge Versions
</button>
</div>
);
};
const VersionNode: React.FC<{ version: Version }> = ({ version }) => {
const x = getNodeX(version.timestamp);
const y = getBranchY(version.branch);
const isSelected = selectedVersions.some(v => v.id === version.id);
const handleDragStart = (e: React.DragEvent) => {
setDraggedVersion(version);
e.dataTransfer.setData('versionId', version.id);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
if (draggedVersion && draggedVersion.id !== version.id) {
const newVersion: Version = {
id: `v${versions.length + 1}`,
title: `Forked from ${version.title}`,
timestamp: new Date().toISOString().split('T')[0],
branch: `${version.branch}-fork-${Date.now()}`,
tags: ['fork'],
content: draggedVersion.content,
parentId: version.id
};
setVersions(prev => [...prev, newVersion]);
setDraggedVersion(null);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
};
return (
<g
draggable
onDragStart={handleDragStart}
onDrop={handleDrop}
onDragOver={handleDragOver}
onClick={() => handleVersionSelect(version)}
style={{ cursor: 'pointer' }}
>
<circle
cx={x}
cy={y}
r={15}
fill={tagColors[version.tags[0]] || '#666'}
stroke={isSelected ? '#000' : 'none'}
strokeWidth={2}
opacity={draggedVersion?.id === version.id ? 0.5 : 1}
/>
<text x={x} y={y + 25} textAnchor="middle"
fontSize="12">{version.title}</text>
{version.parentId && (
<line
x1={getNodeX(versions.find(v => v.id === version.parentId)!.timestamp)}
y1={getBranchY(versions.find(v => v.id === version.parentId)!.branch)}
x2={x}
y2={y}
stroke="#666"
strokeWidth="2"
/>
)}
</g>
);
};
return (
<div ref={containerRef} className="h-screen w-full relative overflow-hidden">
<div className="absolute top-4 left-4 z-10">
<button
onClick={() => handleZoom('in')}
className="bg-blue-500 text-white px-4 py-2 rounded mr-2 hover:bg-blue-
600"
>
Zoom In
</button>
<button
onClick={() => handleZoom('out')}
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
>
Zoom Out
</button>
</div>
<div className="w-full h-full overflow-x-auto overflow-y-hidden">
<svg
ref={svgRef}
width={versions.length * nodeSpacing * zoom + 100}
height={Math.max(...[...new Set(versions.map(v =>
v.branch))].map(getBranchY)) + 100}
style={{ transform: `scale(${zoom})`, transformOrigin: '0 0' }}
>
{versions.map(version => (
<VersionNode key={version.id} version={version} />
))}
</svg>
</div>
<DiffView />
<style jsx>{`
@media (max-width: 768px) {
.fixed {
position: absolute;
width: 100%;
height: 50%;
top: auto;
bottom: 0;
right: 0;
}
}
`}</style>
</div>
);
};
export default VersionHistory;