diff --git a/bun.lock b/bun.lock index d3a4262ca..a9ff9435c 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@ai-sdk/openai": "^2.0.52", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", + "@types/react-syntax-highlighter": "^15.5.13", "ai": "^5.0.72", "ai-tokenizer": "^1.0.3", "cmdk": "^1.0.0", @@ -32,6 +33,7 @@ "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", + "shiki": "^3.13.0", "source-map-support": "^0.5.21", "undici": "^7.16.0", "write-file-atomic": "^6.0.0", @@ -58,7 +60,6 @@ "@types/minimist": "^1.2.5", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", - "@types/react-syntax-highlighter": "^15.5.13", "@types/write-file-atomic": "^4.0.3", "@typescript-eslint/eslint-plugin": "^8.44.1", "@typescript-eslint/parser": "^8.44.1", @@ -445,6 +446,20 @@ "@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="], + "@shikijs/core": ["@shikijs/core@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA=="], + + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg=="], + + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-O42rBGr4UDSlhT2ZFMxqM7QzIU+IcpoTMzb3W7AlziI1ZF7R8eS2M0yt5Ry35nnnTX/LTLXFPUjRFCIW+Operg=="], + + "@shikijs/langs": ["@shikijs/langs@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0" } }, "sha512-672c3WAETDYHwrRP0yLy3W1QYB89Hbpj+pO4KhxK6FzIrDI2FoEXNiNCut6BQmEApYLfuYfpgOZaqbY+E9b8wQ=="], + + "@shikijs/themes": ["@shikijs/themes@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0" } }, "sha512-Vxw1Nm1/Od8jyA7QuAenaV78BG2nSr3/gCGdBkLpfLscddCkzkL36Q5b67SrLLfvAJTOUzW39x4FHVCFriPVgg=="], + + "@shikijs/types": ["@shikijs/types@3.13.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="], + + "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + "@sideway/address": ["@sideway/address@4.1.5", "", { "dependencies": { "@hapi/hoek": "^9.0.0" } }, "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q=="], "@sideway/formula": ["@sideway/formula@3.0.1", "", {}, "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg=="], @@ -1509,6 +1524,8 @@ "hast-util-sanitize": ["hast-util-sanitize@5.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "unist-util-position": "^5.0.0" } }, "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg=="], + "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], + "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], "hast-util-to-parse5": ["hast-util-to-parse5@8.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw=="], @@ -2043,6 +2060,10 @@ "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="], + + "oniguruma-to-es": ["oniguruma-to-es@4.3.3", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg=="], + "open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], @@ -2219,6 +2240,12 @@ "refractor": ["refractor@3.6.0", "", { "dependencies": { "hastscript": "^6.0.0", "parse-entities": "^2.0.0", "prismjs": "~1.27.0" } }, "sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA=="], + "regex": ["regex@6.0.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA=="], + + "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], + + "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], + "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], "rehype-katex": ["rehype-katex@7.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/katex": "^0.16.0", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.0", "katex": "^0.16.0", "unist-util-visit-parents": "^6.0.0", "vfile": "^6.0.0" } }, "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA=="], @@ -2317,6 +2344,8 @@ "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], + "shiki": ["shiki@3.13.0", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/engine-javascript": "3.13.0", "@shikijs/engine-oniguruma": "3.13.0", "@shikijs/langs": "3.13.0", "@shikijs/themes": "3.13.0", "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-aZW4l8Og16CokuCLf8CF8kq+KK2yOygapU5m3+hoGw0Mdosc6fPitjM+ujYarppj5ZIKGyPDPP1vqmQhr+5/0g=="], + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], diff --git a/jest.config.js b/jest.config.js index bf369c4ed..e8d042fbc 100644 --- a/jest.config.js +++ b/jest.config.js @@ -25,6 +25,8 @@ module.exports = { }, ], }, + // Transform ESM modules (like shiki) to CommonJS for Jest + transformIgnorePatterns: ["node_modules/(?!(shiki)/)"], // Run tests in parallel (use 50% of available cores, or 4 minimum) maxWorkers: "50%", // Force exit after tests complete to avoid hanging on lingering handles diff --git a/package.json b/package.json index 6561370ef..bb29b3a4c 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@ai-sdk/openai": "^2.0.52", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", + "@types/react-syntax-highlighter": "^15.5.13", "ai": "^5.0.72", "ai-tokenizer": "^1.0.3", "cmdk": "^1.0.0", @@ -61,6 +62,7 @@ "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", + "shiki": "^3.13.0", "source-map-support": "^0.5.21", "undici": "^7.16.0", "write-file-atomic": "^6.0.0", @@ -87,7 +89,6 @@ "@types/minimist": "^1.2.5", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", - "@types/react-syntax-highlighter": "^15.5.13", "@types/write-file-atomic": "^4.0.3", "@typescript-eslint/eslint-plugin": "^8.44.1", "@typescript-eslint/parser": "^8.44.1", diff --git a/src/components/RightSidebar/CodeReview/UntrackedStatus.tsx b/src/components/RightSidebar/CodeReview/UntrackedStatus.tsx index 4c8867fb1..bba48ce9e 100644 --- a/src/components/RightSidebar/CodeReview/UntrackedStatus.tsx +++ b/src/components/RightSidebar/CodeReview/UntrackedStatus.tsx @@ -184,7 +184,6 @@ export const UntrackedStatus: React.FC = ({ return () => { cancelled = true; }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, [workspaceId, workspacePath, refreshTrigger]); // Close tooltip when clicking outside diff --git a/src/components/shared/DiffRenderer.test.tsx b/src/components/shared/DiffRenderer.test.tsx new file mode 100644 index 000000000..b1c155c05 --- /dev/null +++ b/src/components/shared/DiffRenderer.test.tsx @@ -0,0 +1,207 @@ +/** + * Tests for DiffRenderer components + * + * These are integration tests that verify the review note feature works end-to-end. + * We test the line extraction and formatting logic that ReviewNoteInput uses internally. + */ + +describe("SelectableDiffRenderer review notes", () => { + it("should extract correct line content for review notes", () => { + // Simulate the internal review note building logic + // This is what happens when user clicks comment button and submits + const content = "+const x = 1;\n+const y = 2;\n const z = 3;"; + const lines = content.split("\n").filter((line) => line.length > 0); + + // Simulate what ReviewNoteInput does + const lineData = [ + { index: 0, type: "add" as const, lineNum: 1 }, + { index: 1, type: "add" as const, lineNum: 2 }, + { index: 2, type: "context" as const, lineNum: 3 }, + ]; + + // Simulate selecting first two lines (the + lines) + const selectedLines = lineData + .slice(0, 2) + .map((lineInfo) => { + const line = lines[lineInfo.index]; + const indicator = line[0]; + const lineContent = line.slice(1); + return `${lineInfo.lineNum} ${indicator} ${lineContent}`; + }) + .join("\n"); + + // Verify the extracted content is correct + expect(selectedLines).toContain("const x = 1"); + expect(selectedLines).toContain("const y = 2"); + expect(selectedLines).not.toContain("const z = 3"); + + // Verify format includes line numbers and indicators + expect(selectedLines).toMatch(/1 \+ const x = 1/); + expect(selectedLines).toMatch(/2 \+ const y = 2/); + }); + + it("should handle removal lines correctly", () => { + const content = "-const old = 1;\n+const new = 2;"; + const lines = content.split("\n").filter((line) => line.length > 0); + + const lineData = [ + { index: 0, type: "remove" as const, lineNum: 10 }, + { index: 1, type: "add" as const, lineNum: 10 }, + ]; + + // Extract first line (removal) + const line = lines[lineData[0].index]; + const indicator = line[0]; + const lineContent = line.slice(1); + const formattedLine = `${lineData[0].lineNum} ${indicator} ${lineContent}`; + + expect(formattedLine).toBe("10 - const old = 1;"); + expect(lineContent).toBe("const old = 1;"); + }); + + it("should handle context lines correctly", () => { + const content = " unchanged line\n+new line"; + const lines = content.split("\n").filter((line) => line.length > 0); + + const lineData = [ + { index: 0, type: "context" as const, lineNum: 5 }, + { index: 1, type: "add" as const, lineNum: 6 }, + ]; + + // Extract context line + const line = lines[lineData[0].index]; + const indicator = line[0]; // Should be space + const lineContent = line.slice(1); + const formattedLine = `${lineData[0].lineNum} ${indicator} ${lineContent}`; + + expect(formattedLine).toBe("5 unchanged line"); + expect(indicator).toBe(" "); + }); + + it("should handle multiline selection correctly", () => { + const content = "+line1\n+line2\n+line3\n line4"; + const lines = content.split("\n").filter((line) => line.length > 0); + + const lineData = [ + { index: 0, type: "add" as const, lineNum: 1 }, + { index: 1, type: "add" as const, lineNum: 2 }, + { index: 2, type: "add" as const, lineNum: 3 }, + { index: 3, type: "context" as const, lineNum: 4 }, + ]; + + // Simulate selecting lines 0-2 (first 3 additions) + const selectedLines = lineData + .slice(0, 3) + .map((lineInfo) => { + const line = lines[lineInfo.index]; + const indicator = line[0]; + const lineContent = line.slice(1); + return `${lineInfo.lineNum} ${indicator} ${lineContent}`; + }) + .join("\n"); + + expect(selectedLines.split("\n")).toHaveLength(3); + expect(selectedLines).toContain("line1"); + expect(selectedLines).toContain("line2"); + expect(selectedLines).toContain("line3"); + expect(selectedLines).not.toContain("line4"); + }); + + it("should format review note with proper structure", () => { + const filePath = "src/test.ts"; + const lineRange = "10-12"; + const selectedLines = "10 + const x = 1;\n11 + const y = 2;\n12 + const z = 3;"; + const noteText = "These variables should be renamed"; + + // This is the format that ReviewNoteInput creates + const reviewNote = `\nRe ${filePath}:${lineRange}\n\`\`\`\n${selectedLines}\n\`\`\`\n> ${noteText.trim()}\n`; + + expect(reviewNote).toContain(""); + expect(reviewNote).toContain("Re src/test.ts:10-12"); + expect(reviewNote).toContain("const x = 1"); + expect(reviewNote).toContain("const y = 2"); + expect(reviewNote).toContain("const z = 3"); + expect(reviewNote).toContain("These variables should be renamed"); + expect(reviewNote).toContain(""); + }); + + describe("line elision for long selections", () => { + it("should show all lines when selection is ≤3 lines", () => { + const allLines = ["1 + line1", "2 + line2", "3 + line3"]; + + // No elision for 3 lines + const selectedLines = allLines.join("\n"); + + expect(selectedLines).toContain("line1"); + expect(selectedLines).toContain("line2"); + expect(selectedLines).toContain("line3"); + expect(selectedLines).not.toContain("omitted"); + }); + + it("should elide middle lines when selection is >3 lines", () => { + const allLines = ["1 + line1", "2 + line2", "3 + line3", "4 + line4", "5 + line5"]; + + // Elide middle 3 lines, show first and last + const omittedCount = allLines.length - 2; + const selectedLines = [ + allLines[0], + ` (${omittedCount} lines omitted)`, + allLines[allLines.length - 1], + ].join("\n"); + + expect(selectedLines).toContain("line1"); + expect(selectedLines).not.toContain("line2"); + expect(selectedLines).not.toContain("line3"); + expect(selectedLines).not.toContain("line4"); + expect(selectedLines).toContain("line5"); + expect(selectedLines).toContain("(3 lines omitted)"); + }); + + it("should handle exactly 4 lines (edge case)", () => { + const allLines = [ + "10 + const a = 1;", + "11 + const b = 2;", + "12 + const c = 3;", + "13 + const d = 4;", + ]; + + // Should elide 2 middle lines + const omittedCount = allLines.length - 2; + const selectedLines = [ + allLines[0], + ` (${omittedCount} lines omitted)`, + allLines[allLines.length - 1], + ].join("\n"); + + expect(selectedLines).toBe("10 + const a = 1;\n (2 lines omitted)\n13 + const d = 4;"); + expect(selectedLines).toContain("const a = 1"); + expect(selectedLines).toContain("const d = 4"); + expect(selectedLines).not.toContain("const b = 2"); + expect(selectedLines).not.toContain("const c = 3"); + expect(selectedLines).toContain("(2 lines omitted)"); + }); + + it("should format elision message correctly in review note", () => { + const filePath = "src/large.ts"; + const lineRange = "10-20"; + const allLines = Array.from({ length: 11 }, (_, i) => `${10 + i} + line${i + 1}`); + + // Elide middle lines + const omittedCount = allLines.length - 2; + const selectedLines = [ + allLines[0], + ` (${omittedCount} lines omitted)`, + allLines[allLines.length - 1], + ].join("\n"); + + const noteText = "Review this section"; + const reviewNote = `\nRe ${filePath}:${lineRange}\n\`\`\`\n${selectedLines}\n\`\`\`\n> ${noteText.trim()}\n`; + + expect(reviewNote).toContain("10 + line1"); + expect(reviewNote).toContain("(9 lines omitted)"); + expect(reviewNote).toContain("20 + line11"); + expect(reviewNote).not.toContain("line2"); + expect(reviewNote).not.toContain("line10"); + }); + }); +}); diff --git a/src/components/shared/DiffRenderer.tsx b/src/components/shared/DiffRenderer.tsx index 9f7e0134f..bc5ac6462 100644 --- a/src/components/shared/DiffRenderer.tsx +++ b/src/components/shared/DiffRenderer.tsx @@ -4,13 +4,12 @@ * ReviewPanel uses SelectableDiffRenderer for interactive line selection. */ -import React from "react"; +import React, { useEffect, useState } from "react"; import styled from "@emotion/styled"; -import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; -import { syntaxStyleNoBackgrounds } from "@/styles/syntaxHighlighting"; import { getLanguageFromPath } from "@/utils/git/languageDetector"; import { Tooltip, TooltipWrapper } from "../Tooltip"; -import "@/styles/prism-syntax.css"; +import { groupDiffLines } from "@/utils/highlighting/diffChunking"; +import { highlightDiffChunk, type HighlightedChunk } from "@/utils/highlighting/highlightDiffChunk"; // Shared type for diff line types export type DiffLineType = "add" | "remove" | "context" | "header"; @@ -97,6 +96,11 @@ export const LineContent = styled.span<{ type: DiffLineType }>` return "var(--color-text)"; } }}; + + /* Ensure Shiki spans don't interfere with diff backgrounds */ + span { + background: transparent !important; + } `; export const DiffIndicator = styled.span<{ type: DiffLineType }>` @@ -147,43 +151,46 @@ interface DiffRendererProps { } /** - * Highlighted code content - wraps syntax highlighted tokens - * This component applies syntax highlighting while preserving diff styling + * Hook to pre-process and highlight diff content in chunks + * Runs once when content/language changes */ -const HighlightedContent = React.memo<{ code: string; language: string }>(({ code, language }) => { - // Don't highlight if language is unknown - if (language === "text") { - return <>{code}; - } +function useHighlightedDiff( + content: string, + language: string, + oldStart: number, + newStart: number +): HighlightedChunk[] | null { + const [chunks, setChunks] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function highlight() { + // Split into lines + const lines = content.split("\n").filter((line) => line.length > 0); - return ( - - {code} - - ); -}); + // Group into chunks + const diffChunks = groupDiffLines(lines, oldStart, newStart); + + // Highlight each chunk + const highlighted = await Promise.all( + diffChunks.map((chunk) => highlightDiffChunk(chunk, language)) + ); -HighlightedContent.displayName = "HighlightedContent"; + if (!cancelled) { + setChunks(highlighted); + } + } + + void highlight(); + + return () => { + cancelled = true; + }; + }, [content, language, oldStart, newStart]); + + return chunks; +} /** * DiffRenderer - Renders diff content with consistent styling @@ -203,65 +210,41 @@ export const DiffRenderer: React.FC = ({ fontSize, maxHeight, }) => { - const lines = content.split("\n").filter((line) => line.length > 0); - // Detect language for syntax highlighting (memoized to prevent repeated detection) const language = React.useMemo( () => (filePath ? getLanguageFromPath(filePath) : "text"), [filePath] ); - let oldLineNum = oldStart; - let newLineNum = newStart; + const highlightedChunks = useHighlightedDiff(content, language, oldStart, newStart); + + // Show loading state while highlighting + if (!highlightedChunks) { + return ( + +
Processing...
+
+ ); + } return ( - {lines.map((line, index) => { - const firstChar = line[0]; - const lineContent = line.slice(1); // Remove the +/-/@ prefix - let type: DiffLineType = "context"; - let lineNumDisplay = ""; - - // Detect header lines (@@) - parse for line numbers but don't render - if (line.startsWith("@@")) { - // Parse hunk header for line numbers - const regex = /^@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/; - const match = regex.exec(line); - if (match) { - oldLineNum = parseInt(match[1], 10); - newLineNum = parseInt(match[2], 10); - } - // Don't render the header - it cuts off file names - return null; - } - - if (firstChar === "+") { - type = "add"; - lineNumDisplay = `${newLineNum}`; - newLineNum++; - } else if (firstChar === "-") { - type = "remove"; - lineNumDisplay = `${oldLineNum}`; - oldLineNum++; - } else { - // Context line - lineNumDisplay = `${oldLineNum}`; - oldLineNum++; - newLineNum++; - } - - return ( - - - {firstChar} - {showLineNumbers && {lineNumDisplay}} - - - - - - ); - })} + {highlightedChunks.flatMap((chunk) => + chunk.lines.map((line) => { + const indicator = chunk.type === "add" ? "+" : chunk.type === "remove" ? "-" : " "; + return ( + + + {indicator} + {showLineNumbers && {line.lineNumber}} + + + + + + ); + }) + )} ); }; @@ -372,8 +355,8 @@ const NoteTextarea = styled.textarea` // Separate component to prevent re-rendering diff lines on every keystroke interface ReviewNoteInputProps { selection: LineSelection; - lineData: Array<{ index: number; type: DiffLineType; lineNum: number; content: string }>; - lines: string[]; + lineData: Array<{ index: number; type: DiffLineType; lineNum: number }>; + lines: string[]; // Original diff lines with +/- prefix filePath: string; onSubmit: (note: string) => void; onCancel: () => void; @@ -407,14 +390,25 @@ const ReviewNoteInput: React.FC = React.memo( : `${selection.startLineNum}-${selection.endLineNum}`; const [start, end] = [selection.startIndex, selection.endIndex].sort((a, b) => a - b); - const selectedLines = lineData - .slice(start, end + 1) - .map((lineInfo) => { - const indicator = lines[lineInfo.index][0]; - const content = lineInfo.content; - return `${lineInfo.lineNum} ${indicator} ${content}`; - }) - .join("\n"); + const allLines = lineData.slice(start, end + 1).map((lineInfo) => { + const line = lines[lineInfo.index]; + const indicator = line[0]; // +, -, or space + const content = line.slice(1); // Remove the indicator + return `${lineInfo.lineNum} ${indicator} ${content}`; + }); + + // Elide middle lines if more than 3 lines selected + let selectedLines: string; + if (allLines.length <= 3) { + selectedLines = allLines.join("\n"); + } else { + const omittedCount = allLines.length - 2; + selectedLines = [ + allLines[0], + ` (${omittedCount} lines omitted)`, + allLines[allLines.length - 1], + ].join("\n"); + } const reviewNote = `\nRe ${filePath}:${lineRange}\n\`\`\`\n${selectedLines}\n\`\`\`\n> ${noteText.trim()}\n`; onSubmit(reviewNote); @@ -472,58 +466,33 @@ export const SelectableDiffRenderer = React.memo( [filePath] ); - // Parse lines to get line numbers (memoized to prevent repeated parsing) + const highlightedChunks = useHighlightedDiff(content, language, oldStart, newStart); + + // Build lineData from highlighted chunks (memoized to prevent repeated parsing) + // Note: content field is NOT included - must be extracted from lines array when needed const lineData = React.useMemo(() => { - const lines = content.split("\n").filter((line) => line.length > 0); + if (!highlightedChunks) return []; + const data: Array<{ index: number; type: DiffLineType; lineNum: number; - content: string; + html: string; }> = []; - let oldLineNum = oldStart; - let newLineNum = newStart; - - lines.forEach((line, index) => { - const firstChar = line[0]; - - // Skip header lines - if (line.startsWith("@@")) { - const regex = /^@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/; - const match = regex.exec(line); - if (match) { - oldLineNum = parseInt(match[1], 10); - newLineNum = parseInt(match[2], 10); - } - return; - } - - let type: DiffLineType = "context"; - let lineNum = 0; - - if (firstChar === "+") { - type = "add"; - lineNum = newLineNum++; - } else if (firstChar === "-") { - type = "remove"; - lineNum = oldLineNum++; - } else { - lineNum = newLineNum; - oldLineNum++; - newLineNum++; - } - - data.push({ - index, - type, - lineNum, - content: line.slice(1), + highlightedChunks.forEach((chunk) => { + chunk.lines.forEach((line) => { + data.push({ + index: line.originalIndex, + type: chunk.type, + lineNum: line.lineNumber, + html: line.html, + }); }); }); return data; - }, [content, oldStart, newStart]); + }, [highlightedChunks]); const handleCommentButtonClick = (lineIndex: number, shiftKey: boolean) => { // Notify parent that this hunk should become active @@ -567,6 +536,15 @@ export const SelectableDiffRenderer = React.memo( return index >= start && index <= end; }; + // Show loading state while highlighting + if (!highlightedChunks || lineData.length === 0) { + return ( + +
Processing...
+
+ ); + } + // Extract lines for rendering (done once, outside map) const lines = content.split("\n").filter((line) => line.length > 0); @@ -574,6 +552,7 @@ export const SelectableDiffRenderer = React.memo( {lineData.map((lineInfo, displayIndex) => { const isSelected = isLineSelected(displayIndex); + const indicator = lineInfo.type === "add" ? "+" : lineInfo.type === "remove" ? "-" : " "; return ( @@ -595,12 +574,12 @@ export const SelectableDiffRenderer = React.memo( - {lines[lineInfo.index][0]} + {indicator} {showLineNumbers && ( {lineInfo.lineNum} )} - + diff --git a/src/utils/highlighting/diffChunking.test.ts b/src/utils/highlighting/diffChunking.test.ts new file mode 100644 index 000000000..b863444d1 --- /dev/null +++ b/src/utils/highlighting/diffChunking.test.ts @@ -0,0 +1,79 @@ +import { groupDiffLines } from "./diffChunking"; + +describe("groupDiffLines", () => { + it("should group consecutive adds into a chunk", () => { + const lines = ["+line1", "+line2", "+line3"]; + const chunks = groupDiffLines(lines, 1, 1); + + expect(chunks).toHaveLength(1); + expect(chunks[0].type).toBe("add"); + expect(chunks[0].lines).toEqual(["line1", "line2", "line3"]); + expect(chunks[0].lineNumbers).toEqual([1, 2, 3]); + }); + + it("should group consecutive removes into a chunk", () => { + const lines = ["-line1", "-line2"]; + const chunks = groupDiffLines(lines, 10, 1); + + expect(chunks).toHaveLength(1); + expect(chunks[0].type).toBe("remove"); + expect(chunks[0].lines).toEqual(["line1", "line2"]); + expect(chunks[0].lineNumbers).toEqual([10, 11]); + }); + + it("should split chunks on type change", () => { + const lines = ["+added", " context", "-removed"]; + const chunks = groupDiffLines(lines, 1, 1); + + expect(chunks).toHaveLength(3); + expect(chunks[0].type).toBe("add"); + expect(chunks[0].lines).toEqual(["added"]); + expect(chunks[1].type).toBe("context"); + expect(chunks[1].lines).toEqual(["context"]); + expect(chunks[2].type).toBe("remove"); + expect(chunks[2].lines).toEqual(["removed"]); + }); + + it("should handle header lines and reset numbering", () => { + const lines = ["+line1", "@@ -10,3 +20,4 @@", "+line2"]; + const chunks = groupDiffLines(lines, 1, 1); + + expect(chunks).toHaveLength(2); + expect(chunks[0].type).toBe("add"); + expect(chunks[0].lineNumbers).toEqual([1]); // First chunk starts at newStart=1 + expect(chunks[1].type).toBe("add"); + expect(chunks[1].lineNumbers).toEqual([20]); // Second chunk resets to header's +20 + }); + + it("should track line numbers correctly for mixed diff", () => { + const lines = [" context1", "+added", " context2", "-removed"]; + const chunks = groupDiffLines(lines, 5, 10); + + expect(chunks).toHaveLength(4); + + // Context line increments both old and new + expect(chunks[0].lineNumbers).toEqual([5]); + + // Add line increments only new + expect(chunks[1].lineNumbers).toEqual([11]); + + // Context after add + expect(chunks[2].lineNumbers).toEqual([6]); + + // Remove after context increments only old + expect(chunks[3].lineNumbers).toEqual([7]); + }); + + it("should handle empty input", () => { + const chunks = groupDiffLines([], 1, 1); + expect(chunks).toHaveLength(0); + }); + + it("should preserve original index for each line", () => { + const lines = ["+line1", "+line2", " context"]; + const chunks = groupDiffLines(lines, 1, 1); + + expect(chunks[0].startIndex).toBe(0); + expect(chunks[1].startIndex).toBe(2); + }); +}); diff --git a/src/utils/highlighting/diffChunking.ts b/src/utils/highlighting/diffChunking.ts new file mode 100644 index 000000000..cbf239aee --- /dev/null +++ b/src/utils/highlighting/diffChunking.ts @@ -0,0 +1,87 @@ +import type { DiffLineType } from "@/components/shared/DiffRenderer"; + +export interface DiffChunk { + type: Exclude; // 'add' | 'remove' | 'context' + lines: string[]; // Line content (without +/- prefix) + startIndex: number; // Original line index in diff + lineNumbers: number[]; // Line numbers for display +} + +/** + * Group consecutive lines of same type into chunks + * This provides more syntactic context to the highlighter + */ +export function groupDiffLines(lines: string[], oldStart: number, newStart: number): DiffChunk[] { + const chunks: DiffChunk[] = []; + let currentChunk: DiffChunk | null = null; + + let oldLineNum = oldStart; + let newLineNum = newStart; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const firstChar = line[0]; + + // Skip headers (@@) - they reset line numbers + if (line.startsWith("@@")) { + // Flush current chunk + if (currentChunk && currentChunk.lines.length > 0) { + chunks.push(currentChunk); + currentChunk = null; + } + + // Parse header for line numbers + const regex = /^@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/; + const match = regex.exec(line); + if (match) { + oldLineNum = parseInt(match[1], 10); + newLineNum = parseInt(match[2], 10); + } + continue; + } + + // Determine line type and number + let type: Exclude; + let lineNum: number; + + if (firstChar === "+") { + type = "add"; + lineNum = newLineNum++; + } else if (firstChar === "-") { + type = "remove"; + lineNum = oldLineNum++; + } else { + type = "context"; + lineNum = oldLineNum; + oldLineNum++; + newLineNum++; + } + + // Start new chunk if type changed or no current chunk + // eslint-disable-next-line @typescript-eslint/prefer-optional-chain + if (!currentChunk || currentChunk.type !== type) { + // Flush previous chunk if it exists + if (currentChunk?.lines.length) { + chunks.push(currentChunk); + } + // Start new chunk + currentChunk = { + type, + lines: [], + startIndex: i, + lineNumbers: [], + }; + } + + // Add line to current chunk (without +/- prefix) + currentChunk.lines.push(line.slice(1)); + currentChunk.lineNumbers.push(lineNum); + } + + // Flush final chunk + if (currentChunk && currentChunk.lines.length > 0) { + chunks.push(currentChunk); + } + + return chunks; +} diff --git a/src/utils/highlighting/highlightDiffChunk.test.ts b/src/utils/highlighting/highlightDiffChunk.test.ts new file mode 100644 index 000000000..3245a55ae --- /dev/null +++ b/src/utils/highlighting/highlightDiffChunk.test.ts @@ -0,0 +1,271 @@ +import { highlightDiffChunk } from "./highlightDiffChunk"; +import type { DiffChunk } from "./diffChunking"; + +/** + * Tests use REAL Shiki highlighter (no mocks) + * This ensures we test actual behavior and catch changes in Shiki's HTML structure + * WASM loads on first test (~100ms), then cached for subsequent tests + */ + +describe("highlightDiffChunk", () => { + const mockChunk: DiffChunk = { + type: "add", + lines: ["const x = 1;", "const y = 2;"], + startIndex: 0, + lineNumbers: [1, 2], + }; + + describe("plain text files", () => { + it("should return plain text for text/plaintext language", async () => { + const result = await highlightDiffChunk(mockChunk, "text"); + + expect(result.type).toBe("add"); + expect(result.usedFallback).toBe(false); + expect(result.lines).toHaveLength(2); + expect(result.lines[0].html).toBe("const x = 1;"); + expect(result.lines[0].lineNumber).toBe(1); + }); + + it("should escape HTML in plain text fallback", async () => { + const htmlChunk: DiffChunk = { + type: "add", + lines: [''], + startIndex: 0, + lineNumbers: [1], + }; + + const result = await highlightDiffChunk(htmlChunk, "text"); + + expect(result.lines[0].html).toBe("<script>alert("xss")</script>"); + }); + + it("should preserve line numbers and original indices for text files", async () => { + const result = await highlightDiffChunk(mockChunk, "text"); + + expect(result.lines[0].lineNumber).toBe(1); + expect(result.lines[0].originalIndex).toBe(0); + expect(result.lines[1].lineNumber).toBe(2); + expect(result.lines[1].originalIndex).toBe(1); + }); + + it("should handle empty lines", async () => { + const emptyChunk: DiffChunk = { + type: "context", + lines: [""], + startIndex: 0, + lineNumbers: [1], + }; + + const result = await highlightDiffChunk(emptyChunk, "text"); + + expect(result.lines).toHaveLength(1); + expect(result.lines[0].lineNumber).toBe(1); + }); + + it("should handle multiple line types", async () => { + const removeChunk: DiffChunk = { + type: "remove", + lines: ["old code"], + startIndex: 5, + lineNumbers: [10], + }; + + const result = await highlightDiffChunk(removeChunk, "text"); + + expect(result.type).toBe("remove"); + expect(result.lines[0].originalIndex).toBe(5); + }); + }); + + describe("with real Shiki syntax highlighting", () => { + it("should correctly extract lines from nested span structure", async () => { + const chunk: DiffChunk = { + type: "add", + lines: ["const x = 1;", "const y = 2;"], + startIndex: 0, + lineNumbers: [1, 2], + }; + + const result = await highlightDiffChunk(chunk, "typescript"); + + expect(result.lines).toHaveLength(2); + // Should contain actual Shiki styling + expect(result.lines[0].html).toContain("/); + expect(result.usedFallback).toBe(false); + }); + + it("should handle incomplete syntax (unclosed string)", async () => { + const chunk: DiffChunk = { + type: "add", + lines: ['const str = "unclosed'], + startIndex: 0, + lineNumbers: [1], + }; + + const result = await highlightDiffChunk(chunk, "typescript"); + + // Real Shiki handles incomplete syntax gracefully + expect(result.lines).toHaveLength(1); + expect(result.lines[0].html.length).toBeGreaterThan(0); + expect(result.lines[0].html).toContain("const"); + }); + + it("should handle empty lines with highlighting", async () => { + const chunk: DiffChunk = { + type: "context", + lines: ["", "const y = 2;", ""], + startIndex: 0, + lineNumbers: [1, 2, 3], + }; + + const result = await highlightDiffChunk(chunk, "typescript"); + + expect(result.lines).toHaveLength(3); + // Empty lines might have empty content + expect(result.lines[0].html.length).toBeGreaterThanOrEqual(0); + // Non-empty line should be highlighted + expect(result.lines[1].html).toContain("const"); + expect(result.lines[1].html).toContain(" { + const chunk: DiffChunk = { + type: "add", + lines: ["if (x && y) { return true; }"], + startIndex: 0, + lineNumbers: [1], + }; + + const result = await highlightDiffChunk(chunk, "typescript"); + + expect(result.lines).toHaveLength(1); + expect(result.lines[0].html).toContain(" { + const chunk: DiffChunk = { + type: "add", + lines: ["const obj = { nested: { value: 1 } };"], + startIndex: 0, + lineNumbers: [1], + }; + + const result = await highlightDiffChunk(chunk, "typescript"); + + expect(result.lines).toHaveLength(1); + expect(result.lines[0].html).toContain("const"); + expect(result.lines[0].html).toContain("obj"); + expect(result.lines[0].html).toContain("nested"); + // Should have multiple spans for different tokens + const spanCount = (result.lines[0].html.match(/ { + const chunk: DiffChunk = { + type: "remove", + lines: ["const a = 1;", "const b = 2;", "const c = 3;"], + startIndex: 10, + lineNumbers: [15, 16, 17], + }; + + const result = await highlightDiffChunk(chunk, "typescript"); + + expect(result.lines[0].lineNumber).toBe(15); + expect(result.lines[1].lineNumber).toBe(16); + expect(result.lines[2].lineNumber).toBe(17); + expect(result.lines[0].originalIndex).toBe(10); + expect(result.lines[1].originalIndex).toBe(11); + expect(result.lines[2].originalIndex).toBe(12); + }); + + it("should handle multiline code with proper separation", async () => { + const chunk: DiffChunk = { + type: "add", + lines: ["function test() {", " return 42;", "}"], + startIndex: 0, + lineNumbers: [1, 2, 3], + }; + + const result = await highlightDiffChunk(chunk, "typescript"); + + expect(result.lines).toHaveLength(3); + // Each line should be independently highlighted + expect(result.lines[0].html).toContain("function"); + expect(result.lines[1].html).toContain("return"); + expect(result.lines[2].html).toContain("}"); + // No line should contain content from another line + expect(result.lines[0].html).not.toContain("return"); + expect(result.lines[2].html).not.toContain("function"); + }); + + describe("lazy language loading", () => { + it("should load language on first use", async () => { + const chunk: DiffChunk = { + type: "add", + lines: ["def hello():", ' print("world")'], + startIndex: 0, + lineNumbers: [1, 2], + }; + + // Python might not be loaded yet + const result = await highlightDiffChunk(chunk, "python"); + + // Should succeed by loading Python on-demand + expect(result.lines).toHaveLength(2); + expect(result.usedFallback).toBe(false); + expect(result.lines[0].html).toContain("def"); + }); + + it("should handle unsupported language gracefully", async () => { + const chunk: DiffChunk = { + type: "add", + lines: ["some code in unknown language"], + startIndex: 0, + lineNumbers: [1], + }; + + const result = await highlightDiffChunk(chunk, "totally-fake-language"); + + // Should fall back to plain text + expect(result.lines).toHaveLength(1); + expect(result.usedFallback).toBe(true); + expect(result.lines[0].html).toBe("some code in unknown language"); + }); + + it("should handle concurrent highlighting of same language", async () => { + const chunk1: DiffChunk = { + type: "add", + lines: ["const x = 1;"], + startIndex: 0, + lineNumbers: [1], + }; + + const chunk2: DiffChunk = { + type: "add", + lines: ["const y = 2;"], + startIndex: 0, + lineNumbers: [1], + }; + + // Highlight both concurrently - should handle race safely + const [result1, result2] = await Promise.all([ + highlightDiffChunk(chunk1, "typescript"), + highlightDiffChunk(chunk2, "typescript"), + ]); + + expect(result1.lines[0].html).toContain("const"); + expect(result2.lines[0].html).toContain("const"); + expect(result1.usedFallback).toBe(false); + expect(result2.usedFallback).toBe(false); + }); + }); + }); +}); diff --git a/src/utils/highlighting/highlightDiffChunk.ts b/src/utils/highlighting/highlightDiffChunk.ts new file mode 100644 index 000000000..db8112e93 --- /dev/null +++ b/src/utils/highlighting/highlightDiffChunk.ts @@ -0,0 +1,172 @@ +import { getShikiHighlighter, mapToShikiLang } from "./shikiHighlighter"; +import type { DiffChunk } from "./diffChunking"; + +/** + * Chunk-based diff highlighting with Shiki + * + * Current approach: Parse Shiki HTML to extract individual line HTMLs + * - Groups consecutive lines by type (add/remove/context) + * - Highlights each chunk with Shiki + * - Extracts per-line HTML for individual rendering + * + * Future optimization: Could render entire blocks and use CSS to style + * .line spans instead of extracting per-line HTML. Would simplify parsing + * and reduce dangerouslySetInnerHTML usage. + */ + +export interface HighlightedLine { + html: string; // HTML content (already escaped and tokenized) + lineNumber: number; + originalIndex: number; // Index in original diff +} + +export interface HighlightedChunk { + type: DiffChunk["type"]; + lines: HighlightedLine[]; + usedFallback: boolean; // True if highlighting failed +} + +/** + * Highlight a chunk of code using Shiki + * Falls back to plain text on error + */ +export async function highlightDiffChunk( + chunk: DiffChunk, + language: string +): Promise { + // Fast path: no highlighting for text files + if (language === "text" || language === "plaintext") { + return { + type: chunk.type, + lines: chunk.lines.map((line, i) => ({ + html: escapeHtml(line), + lineNumber: chunk.lineNumbers[i], + originalIndex: chunk.startIndex + i, + })), + usedFallback: false, + }; + } + + try { + const highlighter = await getShikiHighlighter(); + const shikiLang = mapToShikiLang(language); + + // Load language on-demand if not already loaded + // This is race-safe: concurrent loads of the same language are idempotent + const loadedLangs = highlighter.getLoadedLanguages(); + if (!loadedLangs.includes(shikiLang)) { + try { + // TypeScript doesn't know shikiLang is valid, but we handle errors gracefully + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument + await highlighter.loadLanguage(shikiLang as any); + } catch { + // Language not available in Shiki bundle - fall back to plain text + console.warn(`Language '${shikiLang}' not available in Shiki, using plain text`); + return createFallbackChunk(chunk); + } + } + + // Highlight entire chunk as one block + const code = chunk.lines.join("\n"); + const html = highlighter.codeToHtml(code, { + lang: shikiLang, + theme: "dark-plus", + }); + + // Parse HTML to extract line contents + const lines = extractLinesFromHtml(html); + + // Validate output (detect broken highlighting) + if (lines.length !== chunk.lines.length) { + // Mismatch - highlighting broke the structure + return createFallbackChunk(chunk); + } + + return { + type: chunk.type, + lines: lines.map((html, i) => ({ + html, + lineNumber: chunk.lineNumbers[i], + originalIndex: chunk.startIndex + i, + })), + usedFallback: false, + }; + } catch (error) { + console.warn(`Syntax highlighting failed for language ${language}:`, error); + return createFallbackChunk(chunk); + } +} + +/** + * Create plain text fallback for a chunk + */ +function createFallbackChunk(chunk: DiffChunk): HighlightedChunk { + return { + type: chunk.type, + lines: chunk.lines.map((line, i) => ({ + html: escapeHtml(line), + lineNumber: chunk.lineNumbers[i], + originalIndex: chunk.startIndex + i, + })), + usedFallback: true, + }; +} + +/** + * Extract individual line contents from Shiki's HTML output + * Shiki wraps output in
...
with ... per line + * + * Strategy: Split on newlines (which separate line spans), then extract inner HTML + * from each line span. This handles nested spans correctly. + */ +function extractLinesFromHtml(html: string): string[] { + // Remove
 and  wrappers
+  const codeRegex = /]*>(.*?)<\/code>/s;
+  const codeMatch = codeRegex.exec(html);
+  if (!codeMatch) return [];
+
+  const codeContent = codeMatch[1];
+
+  // Split by newlines - Shiki separates line spans with \n
+  const lineChunks = codeContent.split("\n");
+
+  return lineChunks
+    .map((chunk) => {
+      // Extract content from CONTENT
+      // We need to handle nested spans, so we:
+      // 1. Find the opening tag
+      // 2. Find the LAST closing  (which closes the line wrapper)
+      // 3. Extract everything between them
+
+      const openTag = '';
+      const closeTag = "";
+
+      const openIndex = chunk.indexOf(openTag);
+      if (openIndex === -1) {
+        // No line span - might be empty line or malformed
+        return "";
+      }
+
+      const contentStart = openIndex + openTag.length;
+      const closeIndex = chunk.lastIndexOf(closeTag);
+      if (closeIndex === -1 || closeIndex < contentStart) {
+        // Malformed - no closing tag
+        return "";
+      }
+
+      return chunk.substring(contentStart, closeIndex);
+    })
+    .filter((line) => line !== null); // Remove malformed lines
+}
+
+/**
+ * Escape HTML entities for plain text fallback
+ */
+function escapeHtml(text: string): string {
+  return text
+    .replace(/&/g, "&")
+    .replace(//g, ">")
+    .replace(/"/g, """)
+    .replace(/'/g, "'");
+}
diff --git a/src/utils/highlighting/shikiHighlighter.ts b/src/utils/highlighting/shikiHighlighter.ts
new file mode 100644
index 000000000..d4465e795
--- /dev/null
+++ b/src/utils/highlighting/shikiHighlighter.ts
@@ -0,0 +1,36 @@
+import { createHighlighter, type Highlighter } from "shiki";
+
+// Singleton promise (cached to prevent race conditions)
+// Multiple concurrent calls will await the same Promise
+let highlighterPromise: Promise | null = null;
+
+/**
+ * Get or create Shiki highlighter instance
+ * Lazy-loads WASM and themes on first call
+ * Thread-safe: concurrent calls share the same initialization Promise
+ */
+export async function getShikiHighlighter(): Promise {
+  // Must use if-check instead of ??= to prevent race condition
+  // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+  if (!highlighterPromise) {
+    highlighterPromise = createHighlighter({
+      themes: ["dark-plus"],
+      langs: [], // Load languages on-demand via highlightDiffChunk
+    });
+  }
+  return highlighterPromise;
+}
+
+/**
+ * Map file extensions/languages to Shiki language IDs
+ * Reuses existing getLanguageFromPath logic
+ */
+export function mapToShikiLang(detectedLang: string): string {
+  // Most languages match 1:1, but handle special cases
+  const mapping: Record = {
+    text: "plaintext",
+    sh: "bash",
+    // Add more mappings if needed
+  };
+  return mapping[detectedLang] || detectedLang;
+}