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` +
+
+ ${lines.map((line) => this.renderLine(line))} +
+
+ `; + } + + 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`
${diff.path ?? "diff"}${diff.staged ? "staged" : "unstaged"}${diff.truncated ? " ยท truncated" : ""}
- +
`; } @@ -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"); }