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
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,7 @@

(cdata_start) @keyword
(cdata_end) @keyword
(content) @string

; Do not capture `(content)`: in nested markup (for example Maven pom.xml) that
; node spans child elements across many lines. Diff rendering concatenates token
; slices, so overlapping multi-line content tokens duplicate visible text.
Original file line number Diff line number Diff line change
@@ -1,14 +1,29 @@
import { createElement, Fragment } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, test } from "bun:test";
import type { HighlightToken } from "@/features/editor/types/wasm-parser/wasm-parser.types";
import { getUnifiedLineGutterLabel, renderDiffLineContent } from "./git-diff-line";

function renderContent(content: string, showWhitespace: boolean): string {
function renderContent(
content: string,
showWhitespace: boolean,
tokens?: HighlightToken[],
): string {
return renderToStaticMarkup(
createElement(Fragment, null, renderDiffLineContent(content, undefined, showWhitespace)),
createElement(Fragment, null, renderDiffLineContent(content, tokens, showWhitespace)),
);
}

function token(start: number, end: number, type = "token-tag"): HighlightToken {
return {
type,
startIndex: start,
endIndex: end,
startPosition: { row: 0, column: start },
endPosition: { row: 0, column: end },
};
}

describe("diff line whitespace rendering", () => {
test("shows carriage returns when whitespace is enabled", () => {
expect(renderContent("same\r", true)).toContain("␍");
Expand All @@ -27,3 +42,42 @@ describe("unified diff line numbers", () => {
).toBe(16);
});
});

describe("diff line syntax token rendering", () => {
test("does not duplicate characters when highlight tokens overlap", () => {
const content = " <groupId>com.whds</groupId>";
const html = renderContent(content, false, [
// Nested markup often emits a wide parent capture plus inner tag captures.
token(0, content.length, "token-string"),
token(6, 15, "token-tag"),
token(6, 7, "token-punctuation"),
token(7, 14, "token-tag"),
token(14, 15, "token-punctuation"),
]);
const visibleText = html
.replace(/<[^>]+>/g, "")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">");

expect(visibleText).not.toContain("groupIdgroupId");
expect(visibleText).toBe(content);
});

test("keeps pom.xml-like markup text intact with nested token ranges", () => {
const content = " <dependency>";
const html = renderContent(content, false, [
token(0, content.length, "token-string"),
token(4, 5, "token-punctuation"),
token(5, 15, "token-tag"),
token(15, 16, "token-punctuation"),
]);
const visibleText = html
.replace(/<[^>]+>/g, "")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">");

expect(visibleText).toBe(content);
expect(html).toContain("token-tag");
expect(html).toContain("token-punctuation");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,42 @@ const renderHighlightedContent = (
return renderSegment(0, content.length, "plain");
}

// Prefer narrower captures first so nested markup tokens (common in XML)
// win over wide parent ranges. Concatenating overlapping slices would
// otherwise duplicate characters in the rendered diff line.
const resolvedTokens = [...tokens]
.map((token) => {
const start = Math.max(0, token.startPosition.column);
const end = Math.min(content.length, token.endPosition.column);
if (end <= start) return null;
return {
...token,
startPosition: { row: 0, column: start },
endPosition: { row: 0, column: end },
} satisfies HighlightToken;
})
.filter((token): token is HighlightToken => token !== null)
.sort((left, right) => {
const leftSize = left.endPosition.column - left.startPosition.column;
const rightSize = right.endPosition.column - right.startPosition.column;
if (leftSize !== rightSize) return leftSize - rightSize;
return left.startPosition.column - right.startPosition.column;
})
.reduce<HighlightToken[]>((accepted, token) => {
const overlaps = accepted.some(
(other) =>
token.startPosition.column < other.endPosition.column &&
token.endPosition.column > other.startPosition.column,
);
if (!overlaps) accepted.push(token);
return accepted;
}, [])
.sort((left, right) => left.startPosition.column - right.startPosition.column);

const result: React.ReactNode[] = [];
let lastEnd = 0;

for (const [tokenIndex, token] of tokens.entries()) {
for (const [tokenIndex, token] of resolvedTokens.entries()) {
const start = token.startPosition.column;
const end = token.endPosition.column;

Expand All @@ -142,7 +174,6 @@ const renderHighlightedContent = (
}

result.push(renderSegment(start, end, `token-${start}-${end}-${tokenIndex}`, token.type));

lastEnd = end;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, test } from "bun:test";
import { createLineBasedDiffTokenMap } from "./use-git-diff-highlight";
import type { GitDiffLine } from "../types/git.types";

function makeLine(
content: string,
lineType: GitDiffLine["line_type"],
lineNumber: number,
): GitDiffLine {
return {
content,
line_type: lineType,
old_line_number: lineType === "added" ? undefined : lineNumber,
new_line_number: lineType === "removed" ? undefined : lineNumber,
};
}

describe("createLineBasedDiffTokenMap", () => {
test("returns no tokens for xml because line-based fallback is unavailable", () => {
const lines = [
makeLine("<project>", "context", 1),
makeLine(" <dependency>", "added", 2),
makeLine(" </dependency>", "added", 3),
makeLine("</project>", "context", 4),
];

expect(createLineBasedDiffTokenMap(lines, "stat-agg/pom.xml").size).toBe(0);
});
});

describe("xml highlight query contract", () => {
test("does not capture nested content nodes that span child elements", async () => {
const query = await Bun.file(
new URL("../../../../public/tree-sitter/parsers/xml/highlights.scm", import.meta.url),
).text();

expect(query).not.toMatch(/\(content\)\s+@string/);
expect(query).toContain("(tag_name) @tag");
expect(query).toContain("(attribute_value) @string");
});
});
72 changes: 54 additions & 18 deletions windows/tauri/src/features/git/hooks/use-git-diff-highlight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ function getLanguageId(filePath: string): string | null {

interface ReconstructedContent {
content: string;
lines: string[];
lineMapping: Map<number, number>;
}

Expand All @@ -41,32 +42,51 @@ function reconstructContent(lines: GitDiffLine[], version: "old" | "new"): Recon

return {
content: contentLines.join("\n"),
lines: contentLines,
lineMapping,
};
}

function clipTokenToLine(
token: HighlightToken,
line: number,
lineLength: number,
): HighlightToken | null {
const startsBeforeLine = token.startPosition.row < line;
const endsAfterLine = token.endPosition.row > line;
const startColumn = startsBeforeLine ? 0 : token.startPosition.column;
const endColumn = endsAfterLine ? lineLength : token.endPosition.column;

if (endColumn <= startColumn || startColumn >= lineLength) {
return null;
}

return {
...token,
startIndex: startColumn,
endIndex: endColumn,
startPosition: { row: 0, column: startColumn },
endPosition: { row: 0, column: Math.min(endColumn, lineLength) },
};
}

function mapTokensToDiffLines(
tokensByLine: Map<number, HighlightToken[]>,
lineMapping: Map<number, number>,
reconstructedLines: string[],
): Map<number, HighlightToken[]> {
const result = new Map<number, HighlightToken[]>();

for (const [reconstructedLine, tokens] of tokensByLine) {
const diffIndex = lineMapping.get(reconstructedLine);
if (diffIndex !== undefined) {
const adjustedTokens = tokens.map((token) => ({
...token,
startPosition: {
row: 0,
column: token.startPosition.column,
},
endPosition: {
row: token.endPosition.row - token.startPosition.row,
column: token.endPosition.column,
},
}));
result.set(diffIndex, adjustedTokens);
}
if (diffIndex === undefined) continue;

const lineLength = reconstructedLines[reconstructedLine]?.length ?? 0;
const adjustedTokens = tokens
.map((token) => clipTokenToLine(token, reconstructedLine, lineLength))
.filter((token): token is HighlightToken => token !== null);

result.set(diffIndex, adjustedTokens);
}

return result;
Expand Down Expand Up @@ -138,8 +158,16 @@ export function createLineBasedDiffTokenMap(
const newContent = reconstructContent(lines, "new");
const oldTokensByLine = tokenizeLineBasedContentByLine(oldContent.content, languageId);
const newTokensByLine = tokenizeLineBasedContentByLine(newContent.content, languageId);
const oldTokenMap = mapTokensToDiffLines(oldTokensByLine, oldContent.lineMapping);
const newTokenMap = mapTokensToDiffLines(newTokensByLine, newContent.lineMapping);
const oldTokenMap = mapTokensToDiffLines(
oldTokensByLine,
oldContent.lineMapping,
oldContent.lines,
);
const newTokenMap = mapTokensToDiffLines(
newTokensByLine,
newContent.lineMapping,
newContent.lines,
);
const merged = new Map<number, HighlightToken[]>();

for (const [index, tokens] of oldTokenMap) {
Expand Down Expand Up @@ -231,8 +259,16 @@ export function useDiffHighlighting(

if (cancelled) return;

const oldTokenMap = mapTokensToDiffLines(oldTokensByLine, oldContent.lineMapping);
const newTokenMap = mapTokensToDiffLines(newTokensByLine, newContent.lineMapping);
const oldTokenMap = mapTokensToDiffLines(
oldTokensByLine,
oldContent.lineMapping,
oldContent.lines,
);
const newTokenMap = mapTokensToDiffLines(
newTokensByLine,
newContent.lineMapping,
newContent.lines,
);

const merged = new Map<number, HighlightToken[]>();

Expand Down
Loading