diff --git a/.changeset/git-inline-diff-highlights.md b/.changeset/git-inline-diff-highlights.md
new file mode 100644
index 0000000..26e3890
--- /dev/null
+++ b/.changeset/git-inline-diff-highlights.md
@@ -0,0 +1,5 @@
+---
+"@jmfederico/pi-web": patch
+---
+
+Highlight within-line changes in the Git diff viewer.
diff --git a/src/client/src/components/UnifiedDiffViewer.ts b/src/client/src/components/UnifiedDiffViewer.ts
new file mode 100644
index 0000000..3844f52
--- /dev/null
+++ b/src/client/src/components/UnifiedDiffViewer.ts
@@ -0,0 +1,59 @@
+import { LitElement, css, html, type TemplateResult } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { parseUnifiedDiff, type UnifiedDiffLine, type UnifiedDiffTextSpan } from "../diff/unifiedDiff";
+
+@customElement("unified-diff-viewer")
+export class UnifiedDiffViewer extends LitElement {
+ @property() diff = "";
+
+ override render(): TemplateResult {
+ const lines = parseUnifiedDiff(this.diff);
+ if (lines.length === 0) return html`
No diff.
`;
+ return html`
+
+ `;
+ }
+
+ private renderLine(line: UnifiedDiffLine): TemplateResult {
+ const kindClass = line.kind;
+ return html`
+
+ ${formatLineNumber(line.oldLineNumber)}
+ ${formatLineNumber(line.newLineNumber)}
+ ${line.prefix}
+ ${renderSpans(line.spans)}
+
+ `;
+ }
+
+ static override styles = css`
+ :host { display: block; min-height: 0; height: 100%; color: var(--pi-text); background: var(--pi-bg); }
+ .empty { box-sizing: border-box; margin: 0; padding: 10px; color: var(--pi-muted); }
+ .scroller { height: 100%; min-height: 0; overflow: auto; background: var(--pi-bg); }
+ .diff-grid { display: grid; grid-template-columns: max-content max-content 2ch max-content; width: max-content; min-width: 100%; padding: 6px 0; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; line-height: 1.45; }
+ .line { display: contents; }
+ .cell { min-height: 1.45em; white-space: pre; }
+ .line-number { min-width: 4ch; padding: 0 8px; border-right: 1px solid var(--pi-border-muted); color: var(--pi-dim); text-align: right; user-select: none; }
+ .prefix { padding: 0 4px; color: var(--pi-dim); text-align: center; user-select: none; }
+ .content { padding: 0 12px 0 4px; }
+ .meta { color: var(--pi-dim); }
+ .hunk { background: color-mix(in srgb, var(--pi-accent) 9%, transparent); color: var(--pi-accent); }
+ .add { background: color-mix(in srgb, var(--pi-success) 12%, transparent); }
+ .remove { background: color-mix(in srgb, var(--pi-danger) 12%, transparent); }
+ .marker { color: var(--pi-dim); }
+ .content.add .inline-change { border-radius: 2px; background: color-mix(in srgb, var(--pi-success) 36%, transparent); color: var(--pi-text); }
+ .content.remove .inline-change { border-radius: 2px; background: color-mix(in srgb, var(--pi-danger) 36%, transparent); color: var(--pi-text); }
+ `;
+}
+
+function renderSpans(spans: UnifiedDiffTextSpan[]): TemplateResult[] {
+ return spans.map((span) => html`${span.text}`);
+}
+
+function formatLineNumber(lineNumber: number | undefined): string {
+ return lineNumber === undefined ? "" : String(lineNumber);
+}
diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts
index 91165e5..fde76ff 100644
--- a/src/client/src/components/shared.ts
+++ b/src/client/src/components/shared.ts
@@ -200,7 +200,7 @@ export const workspacePanelStyles = css`
.diff-section:last-child { border-bottom: 0; }
.viewer-header { position: sticky; top: 0; display: flex; justify-content: space-between; gap: 8px; padding: 8px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); }
.viewer-header strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
- code-viewer { flex: 1 1 auto; min-height: 0; }
+ code-viewer, unified-diff-viewer { flex: 1 1 auto; min-height: 0; }
.image-preview { flex: 1 1 auto; min-height: 0; box-sizing: border-box; display: flex; align-items: center; justify-content: center; overflow: auto; padding: 16px; }
.image-preview img { display: block; max-width: 100%; max-height: 100%; object-fit: contain; border: 1px solid var(--pi-border-muted); border-radius: 8px; background-color: var(--pi-surface); background-image: linear-gradient(45deg, color-mix(in srgb, var(--pi-border-muted) 45%, transparent) 25%, transparent 25%), linear-gradient(-45deg, color-mix(in srgb, var(--pi-border-muted) 45%, transparent) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, color-mix(in srgb, var(--pi-border-muted) 45%, transparent) 75%), linear-gradient(-45deg, transparent 75%, color-mix(in srgb, var(--pi-border-muted) 45%, transparent) 75%); background-position: 0 0, 0 8px, 8px -8px, -8px 0; background-size: 16px 16px; box-shadow: 0 8px 24px var(--pi-shadow-soft); }
pre { margin: 0; padding: 10px; overflow: auto; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; line-height: 1.45; white-space: pre-wrap; overflow-wrap: anywhere; }
diff --git a/src/client/src/diff/unifiedDiff.test.ts b/src/client/src/diff/unifiedDiff.test.ts
new file mode 100644
index 0000000..bdebc9b
--- /dev/null
+++ b/src/client/src/diff/unifiedDiff.test.ts
@@ -0,0 +1,117 @@
+import { describe, expect, it } from "vitest";
+import { parseUnifiedDiff, type UnifiedDiffLine, type UnifiedDiffLineKind } from "./unifiedDiff";
+
+describe("parseUnifiedDiff", () => {
+ it("computes inline spans for paired removed and added lines", () => {
+ const diff = [
+ "diff --git a/src/app.ts b/src/app.ts",
+ "index 1111111..2222222 100644",
+ "--- a/src/app.ts",
+ "+++ b/src/app.ts",
+ "@@ -10,2 +10,2 @@ export function demo() {",
+ "- const name = \"fooBar\";",
+ "+ const name = \"fooBaz\";",
+ " return name;",
+ ].join("\n");
+
+ const lines = parseUnifiedDiff(diff);
+ const removed = firstLineOfKind(lines, "remove");
+ const added = firstLineOfKind(lines, "add");
+ const context = firstLineOfKind(lines, "context");
+
+ expect(removed.oldLineNumber).toBe(10);
+ expect(removed.newLineNumber).toBeUndefined();
+ expect(changedText(removed)).toEqual(["r"]);
+ expect(added.oldLineNumber).toBeUndefined();
+ expect(added.newLineNumber).toBe(10);
+ expect(changedText(added)).toEqual(["z"]);
+ expect(context.oldLineNumber).toBe(11);
+ expect(context.newLineNumber).toBe(11);
+ });
+
+ it("keeps file headers as metadata before a hunk starts", () => {
+ const diff = [
+ "diff --git a/README.md b/README.md",
+ "index 1111111..2222222 100644",
+ "--- a/README.md",
+ "+++ b/README.md",
+ "@@ -1 +1 @@",
+ "-old",
+ "+new",
+ ].join("\n");
+
+ expect(parseUnifiedDiff(diff).slice(0, 5).map((line) => line.kind)).toEqual(["meta", "meta", "meta", "meta", "hunk"]);
+ });
+
+ it("parses changed content that starts with file header markers inside hunks", () => {
+ const diff = [
+ "diff --git a/README.md b/README.md",
+ "--- a/README.md",
+ "+++ b/README.md",
+ "@@ -1 +1 @@",
+ "---- removed heading",
+ "++++ added heading",
+ ].join("\n");
+
+ const removed = firstLineOfKind(parseUnifiedDiff(diff), "remove");
+ const added = firstLineOfKind(parseUnifiedDiff(diff), "add");
+
+ expect(removed.text).toBe("--- removed heading");
+ expect(added.text).toBe("+++ added heading");
+ });
+
+ it("pairs a single removed line with the closest added line in uneven blocks", () => {
+ const diff = [
+ "diff --git a/src/app.ts b/src/app.ts",
+ "--- a/src/app.ts",
+ "+++ b/src/app.ts",
+ "@@ -1 +1,2 @@",
+ "-const label = \"old\";",
+ "+const label = \"new\";",
+ "+const extra = true;",
+ ].join("\n");
+
+ const addedLines = linesOfKind(parseUnifiedDiff(diff), "add");
+ const firstAdded = lineAt(addedLines, 0);
+ const secondAdded = lineAt(addedLines, 1);
+
+ expect(changedText(firstAdded)).toEqual(["new"]);
+ expect(secondAdded.spans.every((span) => !span.changed)).toBe(true);
+ });
+
+ it("leaves pure additions without inline change spans", () => {
+ const diff = [
+ "diff --git a/new.txt b/new.txt",
+ "new file mode 100644",
+ "--- /dev/null",
+ "+++ b/new.txt",
+ "@@ -0,0 +1 @@",
+ "+brand new",
+ ].join("\n");
+
+ const added = firstLineOfKind(parseUnifiedDiff(diff), "add");
+
+ expect(added.newLineNumber).toBe(1);
+ expect(added.spans).toEqual([{ text: "brand new", changed: false }]);
+ });
+});
+
+function firstLineOfKind(lines: UnifiedDiffLine[], kind: UnifiedDiffLineKind): UnifiedDiffLine {
+ const found = lines.find((line) => line.kind === kind);
+ if (found === undefined) throw new Error(`Missing ${kind} line`);
+ return found;
+}
+
+function linesOfKind(lines: UnifiedDiffLine[], kind: UnifiedDiffLineKind): UnifiedDiffLine[] {
+ return lines.filter((line) => line.kind === kind);
+}
+
+function lineAt(lines: UnifiedDiffLine[], index: number): UnifiedDiffLine {
+ const line = lines[index];
+ if (line === undefined) throw new Error(`Missing line at ${String(index)}`);
+ return line;
+}
+
+function changedText(line: UnifiedDiffLine): string[] {
+ return line.spans.filter((span) => span.changed).map((span) => span.text);
+}
diff --git a/src/client/src/diff/unifiedDiff.ts b/src/client/src/diff/unifiedDiff.ts
new file mode 100644
index 0000000..7a5a3c8
--- /dev/null
+++ b/src/client/src/diff/unifiedDiff.ts
@@ -0,0 +1,224 @@
+import { diffChars } from "diff";
+
+export type UnifiedDiffLineKind = "meta" | "hunk" | "context" | "add" | "remove" | "marker";
+
+export interface UnifiedDiffTextSpan {
+ text: string;
+ changed: boolean;
+}
+
+export interface UnifiedDiffLine {
+ kind: UnifiedDiffLineKind;
+ prefix: string;
+ text: string;
+ spans: UnifiedDiffTextSpan[];
+ oldLineNumber?: number;
+ newLineNumber?: number;
+}
+
+interface InlineDiffResult {
+ removed: UnifiedDiffTextSpan[];
+ added: UnifiedDiffTextSpan[];
+}
+
+interface DiffLinePair {
+ removed: UnifiedDiffLine;
+ added: UnifiedDiffLine;
+}
+
+const hunkHeaderPattern = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
+const maxInlineLineLength = 5_000;
+const maxInlineBlockLines = 20;
+const minInlineSimilarity = 0.20;
+const minPairSimilarity = 0.25;
+
+export function parseUnifiedDiff(diff: string): UnifiedDiffLine[] {
+ const parsedLines = parseUnifiedDiffLines(diff);
+ applyInlineDiffs(parsedLines);
+ return parsedLines;
+}
+
+function parseUnifiedDiffLines(diff: string): UnifiedDiffLine[] {
+ const lines = splitDiffLines(diff);
+ const parsedLines: UnifiedDiffLine[] = [];
+ let oldLineNumber: number | undefined;
+ let newLineNumber: number | undefined;
+
+ for (const rawLine of lines) {
+ const hunkMatch = hunkHeaderPattern.exec(rawLine);
+ if (hunkMatch !== null) {
+ oldLineNumber = Number(hunkMatch[1]);
+ newLineNumber = Number(hunkMatch[2]);
+ parsedLines.push(line("hunk", "", rawLine));
+ continue;
+ }
+
+ if (oldLineNumber !== undefined && newLineNumber !== undefined) {
+ if (rawLine.startsWith("+")) {
+ parsedLines.push(line("add", "+", rawLine.slice(1), { newLineNumber }));
+ newLineNumber++;
+ continue;
+ }
+ if (rawLine.startsWith("-")) {
+ parsedLines.push(line("remove", "-", rawLine.slice(1), { oldLineNumber }));
+ oldLineNumber++;
+ continue;
+ }
+ if (rawLine.startsWith(" ")) {
+ parsedLines.push(line("context", " ", rawLine.slice(1), { oldLineNumber, newLineNumber }));
+ oldLineNumber++;
+ newLineNumber++;
+ continue;
+ }
+ if (rawLine.startsWith("\\")) {
+ parsedLines.push(line("marker", "", rawLine));
+ continue;
+ }
+ }
+
+ oldLineNumber = undefined;
+ newLineNumber = undefined;
+ parsedLines.push(line("meta", "", rawLine));
+ }
+
+ return parsedLines;
+}
+
+function splitDiffLines(diff: string): string[] {
+ if (diff === "") return [];
+ const lines = diff.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
+ if (lines.at(-1) === "") lines.pop();
+ return lines;
+}
+
+function line(kind: UnifiedDiffLineKind, prefix: string, text: string, numbers: { oldLineNumber?: number; newLineNumber?: number } = {}): UnifiedDiffLine {
+ return {
+ kind,
+ prefix,
+ text,
+ spans: text === "" ? [] : [{ text, changed: false }],
+ ...numbers,
+ };
+}
+
+function applyInlineDiffs(lines: UnifiedDiffLine[]): void {
+ let index = 0;
+ while (index < lines.length) {
+ const current = lines[index];
+ if (current?.kind !== "remove") {
+ index++;
+ continue;
+ }
+
+ const removedStart = index;
+ while (lines[index]?.kind === "remove") index++;
+ const addedStart = index;
+ while (lines[index]?.kind === "add") index++;
+
+ if (addedStart === index) continue;
+ const removedLines = lines.slice(removedStart, addedStart);
+ const addedLines = lines.slice(addedStart, index);
+ applyInlineDiffBlock(removedLines, addedLines);
+ }
+}
+
+function applyInlineDiffBlock(removedLines: UnifiedDiffLine[], addedLines: UnifiedDiffLine[]): void {
+ if (removedLines.length + addedLines.length > maxInlineBlockLines) return;
+ for (const pair of pairChangedLines(removedLines, addedLines)) {
+ const inlineDiff = computeInlineDiff(pair.removed.text, pair.added.text);
+ if (inlineDiff === undefined) continue;
+ pair.removed.spans = inlineDiff.removed;
+ pair.added.spans = inlineDiff.added;
+ }
+}
+
+function pairChangedLines(removedLines: UnifiedDiffLine[], addedLines: UnifiedDiffLine[]): DiffLinePair[] {
+ if (removedLines.length === addedLines.length) return removedLines.map((removed, index) => ({ removed, added: addedLines[index] })).filter(isCompletePair);
+ if (removedLines.length === 1) return bestPairsForSingleRemovedLine(removedLines[0], addedLines);
+ if (addedLines.length === 1) return bestPairsForSingleAddedLine(removedLines, addedLines[0]);
+
+ const pairs: DiffLinePair[] = [];
+ const pairCount = Math.min(removedLines.length, addedLines.length);
+ for (let index = 0; index < pairCount; index++) {
+ const removed = removedLines[index];
+ const added = addedLines[index];
+ if (removed === undefined || added === undefined) continue;
+ if (lineSimilarity(removed.text, added.text) >= minPairSimilarity) pairs.push({ removed, added });
+ }
+ return pairs;
+}
+
+function isCompletePair(pair: { removed: UnifiedDiffLine; added: UnifiedDiffLine | undefined }): pair is DiffLinePair {
+ return pair.added !== undefined;
+}
+
+function bestPairsForSingleRemovedLine(removed: UnifiedDiffLine | undefined, addedLines: UnifiedDiffLine[]): DiffLinePair[] {
+ if (removed === undefined) return [];
+ const added = bestMatchingLine(removed.text, addedLines);
+ return added === undefined ? [] : [{ removed, added }];
+}
+
+function bestPairsForSingleAddedLine(removedLines: UnifiedDiffLine[], added: UnifiedDiffLine | undefined): DiffLinePair[] {
+ if (added === undefined) return [];
+ const removed = bestMatchingLine(added.text, removedLines);
+ return removed === undefined ? [] : [{ removed, added }];
+}
+
+function bestMatchingLine(text: string, candidates: UnifiedDiffLine[]): UnifiedDiffLine | undefined {
+ let bestCandidate: UnifiedDiffLine | undefined;
+ let bestScore = minPairSimilarity;
+ for (const candidate of candidates) {
+ const score = lineSimilarity(text, candidate.text);
+ if (score <= bestScore) continue;
+ bestCandidate = candidate;
+ bestScore = score;
+ }
+ return bestCandidate;
+}
+
+function computeInlineDiff(oldText: string, newText: string): InlineDiffResult | undefined {
+ if (oldText === newText) return undefined;
+ if (oldText.length > maxInlineLineLength || newText.length > maxInlineLineLength) return undefined;
+
+ const changes = diffChars(oldText, newText);
+ const similarity = similarityFromChanges(changes, oldText, newText);
+ if (Math.max(oldText.length, newText.length) >= 20 && similarity < minInlineSimilarity) return undefined;
+
+ const removed: UnifiedDiffTextSpan[] = [];
+ const added: UnifiedDiffTextSpan[] = [];
+ for (const change of changes) {
+ if (change.value === "") continue;
+ if (change.added) added.push({ text: change.value, changed: true });
+ else if (change.removed) removed.push({ text: change.value, changed: true });
+ else {
+ removed.push({ text: change.value, changed: false });
+ added.push({ text: change.value, changed: false });
+ }
+ }
+
+ if (!removed.some((span) => span.changed) && !added.some((span) => span.changed)) return undefined;
+ return { removed: mergeAdjacentSpans(removed), added: mergeAdjacentSpans(added) };
+}
+
+function lineSimilarity(oldText: string, newText: string): number {
+ if (oldText === newText) return 1;
+ if (oldText.length > maxInlineLineLength || newText.length > maxInlineLineLength) return 0;
+ return similarityFromChanges(diffChars(oldText, newText), oldText, newText);
+}
+
+function similarityFromChanges(changes: ReturnType, oldText: string, newText: string): number {
+ const maxLength = Math.max(oldText.length, newText.length);
+ if (maxLength === 0) return 1;
+ const unchangedLength = changes.reduce((total, change) => change.added || change.removed ? total : total + change.value.length, 0);
+ return unchangedLength / maxLength;
+}
+
+function mergeAdjacentSpans(spans: UnifiedDiffTextSpan[]): UnifiedDiffTextSpan[] {
+ const merged: UnifiedDiffTextSpan[] = [];
+ for (const span of spans) {
+ const previous = merged[merged.length - 1];
+ if (previous?.changed === span.changed) previous.text += span.text;
+ else merged.push({ ...span });
+ }
+ return merged;
+}
diff --git a/src/client/src/plugins/core/panels.ts b/src/client/src/plugins/core/panels.ts
index 8ba9ed8..0f0d2a3 100644
--- a/src/client/src/plugins/core/panels.ts
+++ b/src/client/src/plugins/core/panels.ts
@@ -146,11 +146,11 @@ function renderDiffViewer(context: WorkspacePanelContext): TemplateResult {
}
function renderDiffSection(diff: GitDiffResponse): TemplateResult {
- loadCodeViewer();
+ loadUnifiedDiffViewer();
return html`
`;
}
@@ -159,6 +159,10 @@ function loadCodeViewer(): void {
void import("../../components/CodeViewer");
}
+function loadUnifiedDiffViewer(): void {
+ void import("../../components/UnifiedDiffViewer");
+}
+
function loadTerminalPanel(): void {
void import("../../components/TerminalPanel");
}