diff --git a/windows/tauri/public/tree-sitter/parsers/xml/highlights.scm b/windows/tauri/public/tree-sitter/parsers/xml/highlights.scm
index 763a0520e..bd87c3f9c 100644
--- a/windows/tauri/public/tree-sitter/parsers/xml/highlights.scm
+++ b/windows/tauri/public/tree-sitter/parsers/xml/highlights.scm
@@ -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.
diff --git a/windows/tauri/src/features/git/components/diff/git-diff-line.test.tsx b/windows/tauri/src/features/git/components/diff/git-diff-line.test.tsx
index fbd7decac..5819ef402 100644
--- a/windows/tauri/src/features/git/components/diff/git-diff-line.test.tsx
+++ b/windows/tauri/src/features/git/components/diff/git-diff-line.test.tsx
@@ -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("␍");
@@ -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 = " com.whds";
+ 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(/</g, "<")
+ .replace(/>/g, ">");
+
+ expect(visibleText).not.toContain("groupIdgroupId");
+ expect(visibleText).toBe(content);
+ });
+
+ test("keeps pom.xml-like markup text intact with nested token ranges", () => {
+ const content = " ";
+ 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(/</g, "<")
+ .replace(/>/g, ">");
+
+ expect(visibleText).toBe(content);
+ expect(html).toContain("token-tag");
+ expect(html).toContain("token-punctuation");
+ });
+});
diff --git a/windows/tauri/src/features/git/components/diff/git-diff-line.tsx b/windows/tauri/src/features/git/components/diff/git-diff-line.tsx
index e8ad9d3f6..9f34c7c55 100644
--- a/windows/tauri/src/features/git/components/diff/git-diff-line.tsx
+++ b/windows/tauri/src/features/git/components/diff/git-diff-line.tsx
@@ -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((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;
@@ -142,7 +174,6 @@ const renderHighlightedContent = (
}
result.push(renderSegment(start, end, `token-${start}-${end}-${tokenIndex}`, token.type));
-
lastEnd = end;
}
diff --git a/windows/tauri/src/features/git/hooks/use-git-diff-highlight.test.ts b/windows/tauri/src/features/git/hooks/use-git-diff-highlight.test.ts
new file mode 100644
index 000000000..75e39e610
--- /dev/null
+++ b/windows/tauri/src/features/git/hooks/use-git-diff-highlight.test.ts
@@ -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("", "context", 1),
+ makeLine(" ", "added", 2),
+ makeLine(" ", "added", 3),
+ makeLine("", "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");
+ });
+});
diff --git a/windows/tauri/src/features/git/hooks/use-git-diff-highlight.ts b/windows/tauri/src/features/git/hooks/use-git-diff-highlight.ts
index 449dbdbb1..d13b3644d 100644
--- a/windows/tauri/src/features/git/hooks/use-git-diff-highlight.ts
+++ b/windows/tauri/src/features/git/hooks/use-git-diff-highlight.ts
@@ -20,6 +20,7 @@ function getLanguageId(filePath: string): string | null {
interface ReconstructedContent {
content: string;
+ lines: string[];
lineMapping: Map;
}
@@ -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,
lineMapping: Map,
+ reconstructedLines: string[],
): Map {
const result = new Map();
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;
@@ -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();
for (const [index, tokens] of oldTokenMap) {
@@ -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();