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();