160 lines
5.0 KiB
TypeScript
160 lines
5.0 KiB
TypeScript
import { useMemo, useState } from 'react'
|
|
import { cn } from '../../lib/utils'
|
|
|
|
export interface FileDiff {
|
|
path: string
|
|
oldPath?: string
|
|
additions: number
|
|
deletions: number
|
|
patch: string
|
|
}
|
|
|
|
interface DiffViewerProps {
|
|
files: FileDiff[]
|
|
}
|
|
|
|
export function DiffViewer({ files }: DiffViewerProps) {
|
|
if (!files.length) {
|
|
return (
|
|
<div className="text-center py-12 text-[var(--c-muted)] text-sm">
|
|
No changes in this diff.
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
{files.map(file => (
|
|
<FileDiffBlock key={file.path} file={file} />
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function FileDiffBlock({ file }: { file: FileDiff }) {
|
|
const [collapsed, setCollapsed] = useState(false)
|
|
const lines = useMemo(() => parsePatch(file.patch), [file.patch])
|
|
|
|
return (
|
|
<div className="border border-[var(--c-border)] rounded overflow-hidden font-mono text-xs">
|
|
{/* File header */}
|
|
<div className="flex items-center justify-between px-3 py-2 bg-[var(--c-surface-muted)] border-b border-[var(--c-border)] gap-2">
|
|
<button
|
|
onClick={() => setCollapsed(c => !c)}
|
|
className="flex items-center gap-2 text-left min-w-0"
|
|
>
|
|
<svg
|
|
width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2"
|
|
viewBox="0 0 24 24"
|
|
className={cn('shrink-0 transition-transform', collapsed && '-rotate-90')}
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
|
|
</svg>
|
|
<span className="font-semibold text-[var(--c-text)] truncate">{file.path}</span>
|
|
{file.oldPath && file.oldPath !== file.path && (
|
|
<span className="text-[var(--c-muted)] text-[10px] shrink-0">← {file.oldPath}</span>
|
|
)}
|
|
</button>
|
|
<div className="flex items-center gap-2 shrink-0 text-[11px]">
|
|
{file.additions > 0 && (
|
|
<span className="text-[var(--c-success)] font-semibold">+{file.additions}</span>
|
|
)}
|
|
{file.deletions > 0 && (
|
|
<span className="text-[var(--c-danger)] font-semibold">-{file.deletions}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Diff lines */}
|
|
{!collapsed && (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full border-collapse text-[11px] leading-5">
|
|
<tbody>
|
|
{lines.map((line, i) => (
|
|
<DiffLine key={i} line={line} />
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
type LineType = 'added' | 'removed' | 'context' | 'hunk'
|
|
|
|
interface ParsedLine {
|
|
type: LineType
|
|
oldNo?: number
|
|
newNo?: number
|
|
content: string
|
|
}
|
|
|
|
function parsePatch(patch: string): ParsedLine[] {
|
|
const lines: ParsedLine[] = []
|
|
let oldNo = 0
|
|
let newNo = 0
|
|
|
|
for (const raw of patch.split('\n')) {
|
|
if (raw.startsWith('@@')) {
|
|
// @@ -oldStart,oldCount +newStart,newCount @@
|
|
const match = raw.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/)
|
|
if (match) {
|
|
oldNo = parseInt(match[1], 10)
|
|
newNo = parseInt(match[2], 10)
|
|
}
|
|
lines.push({ type: 'hunk', content: raw })
|
|
continue
|
|
}
|
|
if (raw.startsWith('+') && !raw.startsWith('+++')) {
|
|
lines.push({ type: 'added', newNo: newNo++, content: raw.slice(1) })
|
|
} else if (raw.startsWith('-') && !raw.startsWith('---')) {
|
|
lines.push({ type: 'removed', oldNo: oldNo++, content: raw.slice(1) })
|
|
} else if (!raw.startsWith('\\') && !raw.startsWith('---') && !raw.startsWith('+++')) {
|
|
lines.push({ type: 'context', oldNo: oldNo++, newNo: newNo++, content: raw })
|
|
}
|
|
}
|
|
return lines
|
|
}
|
|
|
|
function DiffLine({ line }: { line: ParsedLine }) {
|
|
if (line.type === 'hunk') {
|
|
return (
|
|
<tr className="bg-[var(--c-brand-tint)]">
|
|
<td className="px-2 py-0.5 text-[var(--c-muted)] select-none w-10 text-right" />
|
|
<td className="px-2 py-0.5 text-[var(--c-muted)] select-none w-10 text-right" />
|
|
<td className="px-3 py-0.5 text-[var(--c-brand)] font-semibold whitespace-pre">{line.content}</td>
|
|
</tr>
|
|
)
|
|
}
|
|
|
|
const bg = line.type === 'added'
|
|
? 'bg-[#E3FCEF]'
|
|
: line.type === 'removed'
|
|
? 'bg-[var(--c-danger-tint)]'
|
|
: ''
|
|
|
|
const gutter = line.type === 'added'
|
|
? 'bg-[#ABF5D1] text-[#006644]'
|
|
: line.type === 'removed'
|
|
? 'bg-[#FFBDAD] text-[var(--c-danger-dark)]'
|
|
: 'bg-[var(--c-surface-muted)] text-[var(--c-muted)]'
|
|
|
|
const prefix = line.type === 'added' ? '+' : line.type === 'removed' ? '-' : ' '
|
|
|
|
return (
|
|
<tr className={cn('group', bg)}>
|
|
<td className={cn('px-2 py-0.5 select-none w-10 text-right tabular-nums', gutter)}>
|
|
{line.type !== 'added' ? line.oldNo : ''}
|
|
</td>
|
|
<td className={cn('px-2 py-0.5 select-none w-10 text-right tabular-nums', gutter)}>
|
|
{line.type !== 'removed' ? line.newNo : ''}
|
|
</td>
|
|
<td className="px-3 py-0.5 whitespace-pre">
|
|
<span className="select-none mr-2 opacity-50">{prefix}</span>
|
|
{line.content}
|
|
</td>
|
|
</tr>
|
|
)
|
|
}
|