<script lang="ts">
let { diff }: { diff: string } = $props();
interface ParsedLine {
type: 'header' | 'hunk' | 'added' | 'removed' | 'context' | 'meta';
content: string;
oldNum: number | null;
newNum: number | null;
}
const parsed = $derived.by((): ParsedLine[] => {
const lines = diff.split('\n');
const result: ParsedLine[] = [];
let oldLine = 0;
let newLine = 0;
for (const line of lines) {
if (
line.startsWith('--- ') ||
line.startsWith('+++ ') ||
line.startsWith('diff ') ||
line.startsWith('index ')
) {
result.push({ type: 'header', content: line, oldNum: null, newNum: null });
} else if (line.startsWith('@@ ')) {
const m = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
if (m) {
oldLine = parseInt(m[1], 10);
newLine = parseInt(m[2], 10);
}
result.push({ type: 'hunk', content: line, oldNum: null, newNum: null });
} else if (line.startsWith('+')) {
result.push({ type: 'added', content: line.slice(1), oldNum: null, newNum: newLine++ });
} else if (line.startsWith('-')) {
result.push({ type: 'removed', content: line.slice(1), oldNum: oldLine++, newNum: null });
} else {
const content = line.startsWith(' ') ? line.slice(1) : line;
result.push({ type: 'context', content, oldNum: oldLine++, newNum: newLine++ });
}
}
return result;
});
</script>
<div class="diff-wrap">
{#each parsed as line}
<div class="diff-row {line.type}">
{#if line.type === 'added' || line.type === 'removed' || line.type === 'context'}
<span class="gutter">{line.oldNum ?? ''}</span>
<span class="gutter">{line.newNum ?? ''}</span>
<span class="sign">{line.type === 'added' ? '+' : line.type === 'removed' ? '-' : ' '}</span>
{:else}
<span class="gutter-full"></span>
{/if}
<span class="code">{line.content}</span>
</div>
{/each}
</div>
<style>
.diff-wrap {
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
font-family: var(--font-mono);
font-size: 0.8rem;
background: var(--bg-elevated);
}
.diff-row {
display: flex;
line-height: 1.4;
min-height: 1.4em;
white-space: pre;
}
.diff-row:hover {
filter: brightness(1.06);
}
.gutter {
min-width: 3rem;
padding: 0 0.4rem;
text-align: right;
color: var(--fg-dim);
background: var(--bg-surface);
border-right: 1px solid var(--border);
user-select: none;
flex-shrink: 0;
}
.sign {
width: 1.2rem;
padding: 0 0.2rem;
text-align: center;
flex-shrink: 0;
}
/* Spans two gutters + sign column for header/hunk rows */
.gutter-full {
flex: 0 0 calc(3rem * 2 + 1.2rem + 2px);
background: var(--bg-surface);
border-right: 1px solid var(--border);
}
.code {
padding: 0 0.5rem;
flex: 1;
overflow-x: auto;
}
.diff-row.added {
background: color-mix(in srgb, var(--green) 10%, transparent);
color: var(--green);
}
.diff-row.added .gutter {
background: color-mix(in srgb, var(--green) 6%, var(--bg-surface));
}
.diff-row.removed {
background: color-mix(in srgb, var(--red) 10%, transparent);
color: var(--red);
}
.diff-row.removed .gutter {
background: color-mix(in srgb, var(--red) 6%, var(--bg-surface));
}
.diff-row.hunk {
background: color-mix(in srgb, var(--cyan) 8%, transparent);
color: var(--cyan);
}
.diff-row.header {
background: color-mix(in srgb, var(--fg-dim) 6%, transparent);
color: var(--fg-dim);
font-style: italic;
}
</style>