Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,14 @@ npx wingdiff https://gitlab.com/group/repository/-/merge_requests/123

Wingdiff reads metadata through `gh` or `glab`, fetches missing objects into private `refs/wingdiff/...` references, and builds the full diff from the exact pinned merge-base/head pair the host compares. When no matching checkout is available, it keeps a private bare repository cache beside its application data. Update reviews use the exact reviewed-head/current-head pair. It does not check out the change, modify the worktree or index, or execute change-request code. Acquired metadata, evidence, generated tours, and review checkpoints are stored in a local SQLite database. Set `WINGDIFF_DATA_DIR` to choose its location.

The built-in diff viewer takes a lightweight cue from
[diff-so-fancy](https://github.com/so-fancy/diff-so-fancy): paired changed
lines with meaningful shared context emphasize only the intraline span that
differs, and hunk headers are reduced to a readable `@ path:line @ context`
form. Wingdiff does not run the terminal renderer or consume ANSI output;
Git's structured diff remains the authoritative source for line numbers,
fingerprints, and review anchors.

From the real-PR Summary, choose a configured model and select **Generate guided review**. The model organizes the validated diff into semantic stops, ranks concrete findings by severity, and references opaque evidence anchors. Wingdiff rejects the result unless every changed file is covered and every claim or finding resolves to an exact known anchor. Generated tours are pinned to the acquired head SHA and can be resumed locally.

Before generation, Wingdiff shows the exact model context, provider transport, changed files, repository instructions, exclusions, size, and content fingerprint. The immutable manifest, fingerprint, and evidence-anchor snapshot are stored with the tour; a changed manifest invalidates the resumable tour. Model-facing evidence references use compact request-local aliases constrained to the supplied anchor set, then map back to immutable evidence fingerprints before validation and storage. Potentially sensitive, generated, and vendor files are marked; common key and environment-file patterns are excluded by default. Exclusions are editable local globs. `AGENTS.md`, `CONTRIBUTING.md`, `.github/CONTRIBUTING.md`, and `.gitlab/CONTRIBUTING.md` are included when present, capped at 20,000 characters each. Context over 750,000 characters is blocked until reduced.
Expand Down
63 changes: 58 additions & 5 deletions apps/web/src/components/CodeDiff.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import { describe, expect, it } from "vitest";
import type { EvidenceBlock } from "../types";
import { CodeDiff } from "./CodeDiff";

function ruleBody(css: string, selector: string): string {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return new RegExp(`${escaped}\\s*\\{([^}]*)\\}`).exec(css)?.[1] ?? "";
}

describe("CodeDiff", () => {
const sampleEvidence: EvidenceBlock = {
id: "evidence-1",
Expand All @@ -15,9 +20,9 @@ describe("CodeDiff", () => {
endLine: 3,
lines: [
{ kind: "header", content: "@@ -1,3 +1,3 @@" },
{ kind: "deletion", oldLine: 2, content: "- snap_count=old", emphasized: true },
{ kind: "addition", newLine: 2, content: "+ snap_count=new", emphasized: true },
{ kind: "context", oldLine: 3, newLine: 3, content: " exit 0" },
{ kind: "deletion", oldLine: 2, content: "snap_count=30", emphasized: true },
{ kind: "addition", newLine: 2, content: "snap_count=60", emphasized: true },
{ kind: "context", oldLine: 3, newLine: 3, content: "exit 0" },
],
};

Expand All @@ -29,12 +34,60 @@ describe("CodeDiff", () => {
expect(html).toContain("diff-line--context");
});

it("defines strong deletion tokens and emphasized deletion CSS rules in styles.css", () => {
it("renders the simplified hunk header and intraline change spans", () => {
const html = renderToStaticMarkup(<CodeDiff evidence={sampleEvidence} />);

expect(html).toContain("@ .github/workflows/ci.yml:1 @");
expect(html).not.toContain("@@ -1,3 +1,3 @@");
expect(html).toContain("diff-line__change");
expect(html).toContain("syntax-number");
});

it("keeps syntax classes on changed string content", () => {
const evidence: EvidenceBlock = {
...sampleEvidence,
path: "src/labels.ts",
lines: [
{ kind: "deletion", oldLine: 3, content: "const label = \"café old\";" },
{ kind: "addition", newLine: 3, content: "const label = \"café new\";" },
],
};

const html = renderToStaticMarkup(<CodeDiff evidence={evidence} />);

expect(html).toContain("diff-line__change syntax-string");
expect(html).toContain("old");
expect(html).toContain("new");
});

it("uses high-contrast intraline underlines without filled backgrounds", () => {
const cssPath = resolve(__dirname, "../styles.css");
const css = readFileSync(cssPath, "utf-8");
const additionChange = ruleBody(css, ".diff-line--addition .diff-line__change");
const deletionChange = ruleBody(css, ".diff-line--deletion .diff-line__change");

expect(css).toContain("--code-delete-strong:");
expect(css).toContain("--code-add-inline: #6ae2a0;");
expect(css).toContain("--code-delete-inline: #ff7e74;");
expect(css).toContain("--code-add-inline: #21794e;");
expect(css).toContain("--code-delete-inline: #b94538;");
expect(additionChange).toContain("box-shadow: inset 0 -2px 0 var(--code-add-inline);");
expect(deletionChange).toContain("box-shadow: inset 0 -2px 0 var(--code-delete-inline);");
expect(additionChange).not.toContain("background");
expect(deletionChange).not.toContain("background");
expect(css).toContain(".diff-line--addition { background: var(--code-add); }");
expect(css).toContain(".diff-line--deletion { background: var(--code-delete); }");
expect(css).toContain(".diff-line--deletion.is-emphasized");
expect(css).toContain(".diff-line--addition.is-emphasized");
});

it("preserves intraline distinction in forced-colors mode", () => {
const cssPath = resolve(__dirname, "../styles.css");
const css = readFileSync(cssPath, "utf-8");
const forcedColorsChange = /@media \(forced-colors: active\) \{[\s\S]*?\.diff-line--addition \.diff-line__change,\s*\.diff-line--deletion \.diff-line__change\s*\{([^}]*)\}/.exec(css)?.[1] ?? "";

expect(forcedColorsChange).toContain("text-decoration: underline;");
expect(forcedColorsChange).toContain("text-decoration-color: CanvasText;");
expect(forcedColorsChange).toContain("text-decoration-thickness: 2px;");
expect(forcedColorsChange).toContain("text-underline-offset: 2px;");
});
});
77 changes: 64 additions & 13 deletions apps/web/src/components/CodeDiff.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Fragment, memo } from "react";
import type { DiffLine, EvidenceBlock } from "../types";
import { memo, useMemo } from "react";
import { presentDiffLines, type DiffLineSegments } from "../diffPresentation";
import type { EvidenceBlock } from "../types";
import { Icon } from "./Icon";

interface Selection {
Expand Down Expand Up @@ -29,6 +30,10 @@ export const CodeDiff = memo(function CodeDiff({
const selectedCount = selection?.evidenceId === evidence.id
? Math.abs(selection.end - selection.start) + 1
: 0;
const lines = useMemo(
() => presentDiffLines(evidence.lines, evidence.path),
[evidence.lines, evidence.path],
);

return (
<section className="diff-card" aria-label={`${evidence.path} diff`}>
Expand All @@ -47,7 +52,7 @@ export const CodeDiff = memo(function CodeDiff({
</header>

<div className="diff-card__code" role="table">
{evidence.lines.map((line, index) => {
{lines.map((line, index) => {
const side = line.kind === "deletion" ? "LEFT" : "RIGHT";
const lineNumber = side === "RIGHT" ? line.newLine : line.oldLine;
const selected = Boolean(
Expand Down Expand Up @@ -79,7 +84,7 @@ export const CodeDiff = memo(function CodeDiff({
<span aria-hidden="true" className="diff-line__sign">
{line.kind === "addition" ? "+" : line.kind === "deletion" ? "−" : ""}
</span>
<code>{highlight(line)}</code>
<code>{highlight(line.content, line.segments)}</code>
</>
)}
</div>
Expand Down Expand Up @@ -116,15 +121,61 @@ function directoryName(path: string) {
return parts.length ? `${parts.join("/")}/` : "";
}

function highlight(line: DiffLine) {
const content = line.content.replace(/^\+ /, "").replace(/^- /, "");
function highlight(content: string, segments?: DiffLineSegments) {
const changeStart = segments?.before.length ?? -1;
const changeEnd = segments ? changeStart + segments.changed.length : -1;
return syntaxTokens(content).flatMap((token, tokenIndex) => (
splitToken(content, token, changeStart, changeEnd).map((part, partIndex) => (
<span
className={part.changed ? `diff-line__change ${token.className}`.trim() : token.className || undefined}
key={`${token.start}-${tokenIndex}-${partIndex}`}
>
{part.text}
</span>
))
));
}

interface SyntaxToken {
start: number;
end: number;
className: string;
}

function syntaxTokens(content: string): SyntaxToken[] {
const pattern = /(\"[^\"]*\"|'[^']*'|`[^`]*`|\b(?:async|await|const|return|if|else|new|throw|class|private|export|function|true|false|null|undefined)\b|\b\d+\b)/g;
return content.split(pattern).map((token, index) => {
if (!token) return null;
let className = "";
if (/^[\"'`]/.test(token)) className = "syntax-string";
else if (/^\d+$/.test(token)) className = "syntax-number";
else if (/^(async|await|const|return|if|else|new|throw|class|private|export|function|true|false|null|undefined)$/.test(token)) className = "syntax-keyword";
return <Fragment key={`${token}-${index}`}><span className={className}>{token}</span></Fragment>;
const tokens: SyntaxToken[] = [];
let cursor = 0;
for (const match of content.matchAll(pattern)) {
const start = match.index;
if (start > cursor) tokens.push({ start: cursor, end: start, className: "" });
const text = match[0];
tokens.push({ start, end: start + text.length, className: syntaxClass(text) });
cursor = start + text.length;
}
if (cursor < content.length) tokens.push({ start: cursor, end: content.length, className: "" });
return tokens;
}

function syntaxClass(token: string): string {
if (/^[\"'`]/.test(token)) return "syntax-string";
if (/^\d+$/.test(token)) return "syntax-number";
return "syntax-keyword";
}

function splitToken(content: string, token: SyntaxToken, changeStart: number, changeEnd: number) {
const boundaries = [token.start, token.end];
if (changeStart > token.start && changeStart < token.end) boundaries.push(changeStart);
if (changeEnd > token.start && changeEnd < token.end) boundaries.push(changeEnd);
boundaries.sort((left, right) => left - right);

return boundaries.slice(0, -1).map((start, index) => {
const end = boundaries[index + 1]!;
return {
text: content.slice(start, end),
start,
end,
changed: changeStart >= 0 && start >= changeStart && end <= changeEnd,
};
});
}
121 changes: 121 additions & 0 deletions apps/web/src/diffPresentation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { describe, expect, it } from "vitest";
import type { DiffLine } from "./types";
import { presentDiffLines } from "./diffPresentation";

describe("presentDiffLines", () => {
it("marks only the changed span in paired deletion and addition lines", () => {
const lines: DiffLine[] = [
{ kind: "deletion", oldLine: 7, content: "const timeout = 30;" },
{ kind: "addition", newLine: 7, content: "const timeout = 60;" },
];

const presented = presentDiffLines(lines, "src/timeouts.ts");

expect(presented[0]?.segments).toEqual({ before: "const timeout = ", changed: "3", after: "0;" });
expect(presented[1]?.segments).toEqual({ before: "const timeout = ", changed: "6", after: "0;" });
});

it("uses Unicode code points when finding the changed span", () => {
const lines: DiffLine[] = [
{ kind: "deletion", oldLine: 3, content: "const label = \"café old\";" },
{ kind: "addition", newLine: 3, content: "const label = \"café new\";" },
];

const presented = presentDiffLines(lines, "src/labels.ts");

expect(presented[0]?.segments).toEqual({ before: "const label = \"café ", changed: "old", after: "\";" });
expect(presented[1]?.segments).toEqual({ before: "const label = \"café ", changed: "new", after: "\";" });
});

it("keeps supplementary-plane code points intact", () => {
const lines: DiffLine[] = [
{ kind: "deletion", oldLine: 3, content: "const marker = \"😀\";" },
{ kind: "addition", newLine: 3, content: "const marker = \"😁\";" },
];

const presented = presentDiffLines(lines, "src/labels.ts");

expect(presented[0]?.segments).toEqual({ before: "const marker = \"", changed: "😀", after: "\";" });
expect(presented[1]?.segments).toEqual({ before: "const marker = \"", changed: "😁", after: "\";" });
});

it("suppresses intraline segments for complete replacements", () => {
const lines: DiffLine[] = [
{ kind: "deletion", oldLine: 4, content: "old" },
{ kind: "addition", newLine: 4, content: "new" },
];

expect(presentDiffLines(lines, "file.txt").every((line) => line.segments === undefined)).toBe(true);
});

it("ignores whitespace-only common affixes when deciding whether to segment", () => {
const lines: DiffLine[] = [
{ kind: "deletion", oldLine: 4, content: " old" },
{ kind: "addition", newLine: 4, content: " new" },
];

expect(presentDiffLines(lines, "file.txt").every((line) => line.segments === undefined)).toBe(true);
});

it("leaves unbalanced replacement runs unsegmented", () => {
const lines: DiffLine[] = [
{ kind: "deletion", oldLine: 2, content: "one()" },
{ kind: "addition", newLine: 2, content: "uno()" },
{ kind: "addition", newLine: 3, content: "dos()" },
];

expect(presentDiffLines(lines, "src/count.ts").every((line) => line.segments === undefined)).toBe(true);
});

it("preserves source content that begins with a diff marker", () => {
const lines: DiffLine[] = [
{ kind: "addition", newLine: 4, content: "+ Markdown list item" },
];

const presented = presentDiffLines(lines, "README.md");

expect(presented[0]?.content).toBe("+ Markdown list item");
expect(presented[0]?.segments).toBeUndefined();
});

it("does not emphasize identical paired lines", () => {
const lines: DiffLine[] = [
{ kind: "deletion", oldLine: 4, content: "same" },
{ kind: "addition", newLine: 4, content: "same" },
];

expect(presentDiffLines(lines, "file.txt").every((line) => line.segments === undefined)).toBe(true);
});

it("simplifies standard hunk headers and preserves non-hunk headers", () => {
const presented = presentDiffLines([
{ kind: "header", content: "@@ -41,8 +41,18 @@ export async function login(input: LoginInput) {" },
{ kind: "header", content: "@@ -10,2 +0,0 @@" },
{ kind: "header", content: "Binary file changed" },
], "src/auth/login.ts");

expect(presented[0]?.content).toBe("@ src/auth/login.ts:41 @ export async function login(input: LoginInput) {");
expect(presented[1]?.content).toBe("@ src/auth/login.ts:10 @");
expect(presented[2]?.content).toBe("Binary file changed");
});

it("uses the deleted range start when a nonzero new range is empty", () => {
const presented = presentDiffLines([
{ kind: "header", content: "@@ -10,2 +9,0 @@" },
], "src/auth/login.ts");

expect(presented[0]?.content).toBe("@ src/auth/login.ts:10 @");
});

it("does not mutate the supplied evidence lines", () => {
const lines: DiffLine[] = [
{ kind: "deletion", oldLine: 4, content: "old" },
{ kind: "addition", newLine: 4, content: "new" },
];
const original = structuredClone(lines);

presentDiffLines(lines, "file.txt");

expect(lines).toEqual(original);
});
});
Loading
Loading