From 502f29fcc5852bd08e7e07da791d455d5ec28ede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E5=A4=A9=E5=9B=B0?= <2570024918@qq.com> Date: Fri, 4 Sep 2026 18:49:55 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(windows):=20=E4=BF=AE=E5=A4=8D=E5=B7=AE?= =?UTF-8?q?=E5=BC=82=E8=A7=86=E5=9B=BE=E4=B8=AD=20pom.xml=20=E5=9B=A0?= =?UTF-8?q?=E9=87=8D=E5=8F=A0=E9=AB=98=E4=BA=AE=E5=AF=BC=E8=87=B4=E6=96=87?= =?UTF-8?q?=E5=AD=97=E9=94=99=E4=B9=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XML 高亮不再捕获跨子元素的 content 节点,diff 渲染优先保留更窄的 token,并将跨行 token 裁剪到当前行,避免切片拼接重复字符。Fixes #428 Co-authored-by: Cursor --- .../tree-sitter/parsers/xml/highlights.scm | 5 +- .../components/diff/git-diff-line.test.tsx | 58 ++++++++++++++- .../git/components/diff/git-diff-line.tsx | 35 ++++++++- .../git/hooks/use-git-diff-highlight.test.ts | 41 +++++++++++ .../git/hooks/use-git-diff-highlight.ts | 72 ++++++++++++++----- 5 files changed, 188 insertions(+), 23 deletions(-) create mode 100644 windows/tauri/src/features/git/hooks/use-git-diff-highlight.test.ts 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..39966de0a 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, "") + .replaceAll("<", "<") + .replaceAll(">", ">"); + + 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, "") + .replaceAll("<", "<") + .replaceAll(">", ">"); + + 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(); From 503b717dd14f6925b0dc99e962d013ab2e143430 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E5=A4=A9=E5=9B=B0?= <2570024918@qq.com> Date: Fri, 4 Sep 2026 20:20:03 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(windows):=20=E4=BF=AE=E5=A4=8D=20CI=20t?= =?UTF-8?q?ypecheck=20=E5=AF=B9=20replaceAll=20=E7=9A=84=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 测试中改用全局正则替换解码 HTML 实体,避免当前 TS lib 不支持 String.replaceAll。 Co-authored-by: Cursor --- .../features/git/components/diff/git-diff-line.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 39966de0a..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 @@ -56,8 +56,8 @@ describe("diff line syntax token rendering", () => { ]); const visibleText = html .replace(/<[^>]+>/g, "") - .replaceAll("<", "<") - .replaceAll(">", ">"); + .replace(/</g, "<") + .replace(/>/g, ">"); expect(visibleText).not.toContain("groupIdgroupId"); expect(visibleText).toBe(content); @@ -73,8 +73,8 @@ describe("diff line syntax token rendering", () => { ]); const visibleText = html .replace(/<[^>]+>/g, "") - .replaceAll("<", "<") - .replaceAll(">", ">"); + .replace(/</g, "<") + .replace(/>/g, ">"); expect(visibleText).toBe(content); expect(html).toContain("token-tag");