Archived
feat: add inline Git diff highlights
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Highlight within-line changes in the Git diff viewer.
|
||||
@@ -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`<p class="empty">No diff.</p>`;
|
||||
return html`
|
||||
<div class="scroller">
|
||||
<div class="diff-grid" role="table" aria-label="Unified diff">
|
||||
${lines.map((line) => this.renderLine(line))}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderLine(line: UnifiedDiffLine): TemplateResult {
|
||||
const kindClass = line.kind;
|
||||
return html`
|
||||
<div class="line" role="row">
|
||||
<span class=${`cell line-number old ${kindClass}`} role="cell">${formatLineNumber(line.oldLineNumber)}</span>
|
||||
<span class=${`cell line-number new ${kindClass}`} role="cell">${formatLineNumber(line.newLineNumber)}</span>
|
||||
<span class=${`cell prefix ${kindClass}`} role="cell">${line.prefix}</span>
|
||||
<span class=${`cell content ${kindClass}`} role="cell">${renderSpans(line.spans)}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 class=${span.changed ? "inline-change" : ""}>${span.text}</span>`);
|
||||
}
|
||||
|
||||
function formatLineNumber(lineNumber: number | undefined): string {
|
||||
return lineNumber === undefined ? "" : String(lineNumber);
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<typeof diffChars>, 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;
|
||||
}
|
||||
@@ -146,11 +146,11 @@ function renderDiffViewer(context: WorkspacePanelContext): TemplateResult {
|
||||
}
|
||||
|
||||
function renderDiffSection(diff: GitDiffResponse): TemplateResult {
|
||||
loadCodeViewer();
|
||||
loadUnifiedDiffViewer();
|
||||
return html`
|
||||
<section class="diff-section">
|
||||
<div class="viewer-header"><strong>${diff.path ?? "diff"}</strong><small>${diff.staged ? "staged" : "unstaged"}${diff.truncated ? " · truncated" : ""}</small></div>
|
||||
<code-viewer .content=${diff.diff} .language=${"diff"}></code-viewer>
|
||||
<unified-diff-viewer .diff=${diff.diff}></unified-diff-viewer>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user