<!--
A unified diff, rendered.
Only the rendering: what is being compared, and where the text came from, is
the caller's business. Two things ask for this — the fleet's diff of a file
against HEAD, and the diff between this machine's version of a conflicted
file and a peer's — and a reader should not have to learn two layouts to
read the same shape of answer.
-->
<script lang="ts">
interface Props {
/** The unified diff. Null while it is being fetched. */
diff: string | null;
/** What to say when the diff is empty rather than absent. */
emptyMessage?: string;
loading?: boolean;
error?: string | null;
}
let {
diff,
emptyMessage = "No differences.",
loading = false,
error = null,
}: Props = $props();
type DiffLine =
| { kind: "header"; text: string }
| { kind: "hunk"; text: string }
| { kind: "added"; text: string; lineNo: number }
| { kind: "removed"; text: string; lineNo: number }
| { kind: "context"; text: string; lineNo: number };
function parseDiff(raw: string): DiffLine[] {
const lines: DiffLine[] = [];
let addedLine = 0;
let removedLine = 0;
// A `---` line is a header only BEFORE the first hunk. Inside one it is a
// removed line whose content begins with `--`, and rendering that as a
// header would show the reader a line their file does not contain.
let inHunk = false;
for (const text of raw.split("\n")) {
if (!inHunk && (text.startsWith("diff ") || text.startsWith("index ") ||
text.startsWith("--- ") || text.startsWith("+++ "))) {
lines.push({ kind: "header", text });
} else if (text.startsWith("@@")) {
// Parse hunk header: @@ -a,b +c,d @@
inHunk = true;
const m = text.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
if (m?.[1] && m[2]) {
removedLine = parseInt(m[1], 10);
addedLine = parseInt(m[2], 10);
}
lines.push({ kind: "hunk", text });
} else if (text.startsWith("+")) {
lines.push({ kind: "added", text: text.slice(1), lineNo: addedLine++ });
} else if (text.startsWith("-")) {
lines.push({ kind: "removed", text: text.slice(1), lineNo: removedLine++ });
} else if (text.startsWith(" ") || text === "") {
lines.push({ kind: "context", text: text.slice(1), lineNo: addedLine });
addedLine++;
removedLine++;
}
}
return lines;
}
let parsed = $derived(diff !== null ? parseDiff(diff) : []);
let isEmpty = $derived(!loading && !error && diff !== null && diff.trim() === "");
</script>
<div class="diff-viewer">
{#if loading}
<div class="diff-state">Loading…</div>
{:else if error}
<div class="diff-state diff-error">{error}</div>
{:else if isEmpty}
<div class="diff-state diff-empty">{emptyMessage}</div>
{:else}
<div class="diff-scroller">
<table class="diff-table">
<tbody>
{#each parsed as line}
{#if line.kind === "header"}
<tr class="diff-header-row">
<td class="diff-gutter"></td>
<td class="diff-gutter"></td>
<td class="diff-line diff-line--header">{line.text}</td>
</tr>
{:else if line.kind === "hunk"}
<tr class="diff-hunk-row">
<td class="diff-gutter"></td>
<td class="diff-gutter"></td>
<td class="diff-line diff-line--hunk">{line.text}</td>
</tr>
{:else if line.kind === "added"}
<tr class="diff-added-row">
<td class="diff-gutter"></td>
<td class="diff-gutter diff-gutter--no">{line.lineNo}</td>
<td class="diff-line diff-line--added">+{line.text}</td>
</tr>
{:else if line.kind === "removed"}
<tr class="diff-removed-row">
<td class="diff-gutter diff-gutter--no">{line.lineNo}</td>
<td class="diff-gutter"></td>
<td class="diff-line diff-line--removed">-{line.text}</td>
</tr>
{:else}
<tr class="diff-context-row">
<td class="diff-gutter diff-gutter--no">{line.lineNo}</td>
<td class="diff-gutter diff-gutter--no">{line.lineNo}</td>
<td class="diff-line diff-line--context"> {line.text}</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<style>
.diff-viewer {
height: 100%;
display: flex;
flex-direction: column;
background: var(--vscode-editor-background);
color: var(--vscode-editor-foreground);
font-family: var(--vscode-editor-font-family, monospace);
font-size: var(--vscode-editor-font-size, var(--wb-font-size-12));
line-height: 1.5;
}
.diff-state {
padding: var(--wb-spacing-24);
color: var(--vscode-disabledForeground);
font-size: var(--wb-font-size-12);
}
.diff-error {
color: var(--vscode-errorForeground);
}
.diff-empty {
color: var(--vscode-disabledForeground);
}
.diff-scroller {
flex: 1;
overflow: auto;
}
.diff-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.diff-gutter {
width: var(--wb-control-size-44);
min-width: var(--wb-control-size-44);
padding: 0 var(--wb-spacing-8);
text-align: right;
color: var(--vscode-editorLineNumber-foreground);
font-size: var(--wb-font-size-11);
user-select: none;
white-space: nowrap;
vertical-align: top;
}
.diff-gutter--no {
opacity: 0.6;
}
.diff-line {
padding: 0 var(--wb-spacing-12);
white-space: pre;
word-break: keep-all;
overflow: hidden;
}
.diff-header-row td {
background: var(--vscode-editor-background);
color: var(--vscode-disabledForeground);
padding-top: var(--wb-spacing-8);
}
.diff-hunk-row td {
background: color-mix(in srgb, #569cd6 10%, transparent);
color: #569cd6;
padding-top: var(--wb-spacing-4);
padding-bottom: var(--wb-spacing-4);
}
.diff-added-row td {
background: color-mix(in srgb, #73c991 12%, transparent);
}
.diff-line--added {
color: #73c991;
}
.diff-removed-row td {
background: color-mix(in srgb, #f14c4c 12%, transparent);
}
.diff-line--removed {
color: #f14c4c;
}
.diff-context-row td {
background: transparent;
}
.diff-line--context {
color: var(--vscode-editor-foreground);
opacity: 0.8;
}
.diff-line--hunk {
color: #569cd6;
}
.diff-line--header {
color: var(--vscode-disabledForeground);
font-style: italic;
}
</style>