Skip to content

Commit 8526d91

Browse files
committed
Fix syntax highlighting for files
1 parent 9188015 commit 8526d91

File tree

4 files changed

+424
-42
lines changed

4 files changed

+424
-42
lines changed

src/api/diff.ts

Lines changed: 174 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,15 @@ function guessLang(filename?: string): string {
157157
// Syntax Highlighting
158158
// ============================================================================
159159

160+
function escapeHtml(text: string): string {
161+
return text
162+
.replace(/&/g, "&")
163+
.replace(/</g, "&lt;")
164+
.replace(/>/g, "&gt;")
165+
.replace(/"/g, "&quot;")
166+
.replace(/'/g, "&#039;");
167+
}
168+
160169
function hastToHtml(node: any): string {
161170
if (node.type === "text") {
162171
return escapeHtml(node.value);
@@ -171,15 +180,6 @@ function hastToHtml(node: any): string {
171180
return "";
172181
}
173182

174-
function escapeHtml(text: string): string {
175-
return text
176-
.replace(/&/g, "&amp;")
177-
.replace(/</g, "&lt;")
178-
.replace(/>/g, "&gt;")
179-
.replace(/"/g, "&quot;")
180-
.replace(/'/g, "&#039;");
181-
}
182-
183183
function highlight(code: string, lang: string): string {
184184
try {
185185
const tree = refractor.highlight(code, lang);
@@ -189,6 +189,95 @@ function highlight(code: string, lang: string): string {
189189
}
190190
}
191191

192+
/**
193+
* Highlight an entire file and return an array of HTML strings, one per line.
194+
* This handles multi-line constructs (strings, comments) correctly by
195+
* closing and reopening tags at line boundaries.
196+
*/
197+
interface OpenTag {
198+
tagName: string;
199+
className?: string;
200+
}
201+
202+
function highlightFileByLines(content: string, lang: string): string[] {
203+
if (!content) return [];
204+
205+
try {
206+
const tree = refractor.highlight(content, lang);
207+
const lines: string[] = [];
208+
let currentLine: string[] = [];
209+
const openTags: OpenTag[] = [];
210+
211+
function closeAllTags(): string {
212+
return [...openTags]
213+
.reverse()
214+
.map((t) => `</${t.tagName}>`)
215+
.join("");
216+
}
217+
218+
function openAllTags(): string {
219+
return openTags
220+
.map((t) => {
221+
const cls = t.className ? ` class="${t.className}"` : "";
222+
return `<${t.tagName}${cls}>`;
223+
})
224+
.join("");
225+
}
226+
227+
function processText(text: string) {
228+
const parts = text.split("\n");
229+
for (let i = 0; i < parts.length; i++) {
230+
if (i > 0) {
231+
// End current line with closing tags
232+
currentLine.push(closeAllTags());
233+
lines.push(currentLine.join(""));
234+
// Start new line with opening tags
235+
currentLine = [openAllTags()];
236+
}
237+
if (parts[i]) {
238+
currentLine.push(escapeHtml(parts[i]));
239+
}
240+
}
241+
}
242+
243+
function walkNode(node: any) {
244+
if (node.type === "text") {
245+
processText(node.value);
246+
} else if (node.type === "element") {
247+
const { tagName, properties, children } = node;
248+
const className = (properties?.className as string[] | undefined)?.join(
249+
" "
250+
);
251+
const tag: OpenTag = { tagName, className };
252+
253+
// Open tag
254+
const cls = className ? ` class="${className}"` : "";
255+
currentLine.push(`<${tagName}${cls}>`);
256+
openTags.push(tag);
257+
258+
// Process children
259+
children.forEach(walkNode);
260+
261+
// Close tag
262+
openTags.pop();
263+
currentLine.push(`</${tagName}>`);
264+
}
265+
}
266+
267+
tree.children.forEach(walkNode);
268+
269+
// Don't forget the last line
270+
if (currentLine.length > 0) {
271+
lines.push(currentLine.join(""));
272+
}
273+
274+
return lines;
275+
} catch {
276+
// Fallback: escape each line
277+
return content.split("\n").map(escapeHtml);
278+
}
279+
}
280+
192281
/**
193282
* Highlight a range of lines from file content.
194283
* Returns an array of DiffLine objects with syntax highlighting.
@@ -201,12 +290,18 @@ export function highlightFileLines(
201290
): DiffLine[] {
202291
const language = guessLang(filename);
203292
const allLines = content.split("\n");
293+
294+
// Pre-highlight the entire file for proper context
295+
const highlightedLines = highlightFileByLines(content, language);
296+
204297
const result: DiffLine[] = [];
205298

206299
for (let i = 0; i < count; i++) {
207300
const lineNum = startLine + i;
208301
const lineContent = allLines[lineNum - 1] ?? "";
209-
const highlighted = highlight(lineContent, language);
302+
// Use pre-highlighted HTML, fallback to individual highlighting
303+
const highlighted =
304+
highlightedLines[lineNum - 1] ?? highlight(lineContent, language);
210305

211306
result.push({
212307
type: "normal",
@@ -549,7 +644,9 @@ export function parseDiffWithHighlighting(
549644
patch: string,
550645
filename: string,
551646
previousFilename?: string,
552-
cacheKey?: string
647+
cacheKey?: string,
648+
oldContent?: string,
649+
newContent?: string
553650
): ParsedDiff {
554651
// Check cache
555652
if (cacheKey && diffCache.has(cacheKey)) {
@@ -570,6 +667,19 @@ ${patch}`;
570667
}
571668

572669
const language = guessLang(filename);
670+
const prevLanguage = previousFilename
671+
? guessLang(previousFilename)
672+
: language;
673+
674+
// Pre-highlight full files if content is provided
675+
// This ensures proper highlighting for multi-line constructs (strings, comments, etc.)
676+
const oldHighlightedLines = oldContent
677+
? highlightFileByLines(oldContent, prevLanguage)
678+
: null;
679+
const newHighlightedLines = newContent
680+
? highlightFileByLines(newContent, language)
681+
: null;
682+
573683
const rawHunks = insertSkipBlocks(
574684
file.hunks.map((hunk) => parseHunk(hunk, opts))
575685
);
@@ -598,15 +708,63 @@ ${patch}`;
598708
? (line as any).lineNumber
599709
: undefined;
600710

711+
// For lines with a single segment (no inline diff), use pre-highlighted content
712+
// For lines with multiple segments (inline diff), highlight each segment
713+
const hasSingleSegment = line.content.length === 1;
714+
const singleSegmentIsNormal =
715+
hasSingleSegment && line.content[0].type === "normal";
716+
601717
return {
602718
type: line.type,
603719
oldLineNumber: oldNum,
604720
newLineNumber: newNum,
605-
content: line.content.map((seg) => ({
606-
value: seg.value,
607-
html: highlight(seg.value, language),
608-
type: seg.type,
609-
})),
721+
content: line.content.map((seg) => {
722+
let html: string;
723+
724+
// Try to use pre-highlighted content for better context
725+
if (singleSegmentIsNormal) {
726+
// Use pre-highlighted line if available
727+
if (
728+
line.type === "delete" &&
729+
oldHighlightedLines &&
730+
oldNum !== undefined
731+
) {
732+
html =
733+
oldHighlightedLines[oldNum - 1] ??
734+
highlight(seg.value, prevLanguage);
735+
} else if (
736+
line.type === "insert" &&
737+
newHighlightedLines &&
738+
newNum !== undefined
739+
) {
740+
html =
741+
newHighlightedLines[newNum - 1] ??
742+
highlight(seg.value, language);
743+
} else if (
744+
line.type === "normal" &&
745+
newHighlightedLines &&
746+
newNum !== undefined
747+
) {
748+
// For normal lines, prefer new file highlighting (same content in both)
749+
html =
750+
newHighlightedLines[newNum - 1] ??
751+
highlight(seg.value, language);
752+
} else {
753+
html = highlight(seg.value, language);
754+
}
755+
} else {
756+
// Multiple segments (inline diff) - highlight each segment individually
757+
// This is acceptable since inline diffs are usually small
758+
const segLang = seg.type === "delete" ? prevLanguage : language;
759+
html = highlight(seg.value, segLang);
760+
}
761+
762+
return {
763+
value: seg.value,
764+
html,
765+
type: seg.type,
766+
};
767+
}),
610768
};
611769
}),
612770
};

src/browser/contexts/pr-review/useDiffLoader.ts

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useEffect } from "react";
22
import type { PullRequestFile } from "@/api/types";
33
import { diffService } from "@/browser/lib/diff";
4+
import { useGitHub } from "@/browser/contexts/github";
45
import { usePRReviewStore, usePRReviewSelector, type ParsedDiff } from ".";
56

67
const diffCache = new Map<string, ParsedDiff>();
@@ -26,15 +27,23 @@ function abortAllPendingFetches() {
2627
}
2728
}
2829

30+
/** Content getter function type for fetching file content */
31+
type FileContentGetter = (path: string, ref: string) => Promise<string>;
32+
2933
async function fetchParsedDiff(
3034
file: PullRequestFile,
31-
signal?: AbortSignal
35+
signal?: AbortSignal,
36+
getFileContent?: FileContentGetter,
37+
baseRef?: string,
38+
headRef?: string
3239
): Promise<ParsedDiff> {
3340
if (!file.patch || !file.sha) {
3441
return { hunks: [] };
3542
}
3643

37-
const cacheKey = file.sha;
44+
// Cache key includes whether we have file content (for better highlighting)
45+
const hasContent = !!(getFileContent && baseRef && headRef);
46+
const cacheKey = hasContent ? `${file.sha}:full` : file.sha;
3847

3948
// Check cache first
4049
if (diffCache.has(cacheKey)) {
@@ -72,11 +81,39 @@ async function fetchParsedDiff(
7281
}
7382

7483
const fetchPromise = (async () => {
84+
let oldContent: string | undefined;
85+
let newContent: string | undefined;
86+
87+
// Fetch file content for better syntax highlighting if getter is provided
88+
if (getFileContent && baseRef && headRef) {
89+
try {
90+
const [oldResult, newResult] = await Promise.all([
91+
// For deleted files or renames, use previous_filename for base
92+
file.status === "added"
93+
? Promise.resolve("")
94+
: getFileContent(
95+
file.previous_filename || file.filename,
96+
baseRef
97+
).catch(() => ""),
98+
// For deleted files, new content is empty
99+
file.status === "removed"
100+
? Promise.resolve("")
101+
: getFileContent(file.filename, headRef).catch(() => ""),
102+
]);
103+
oldContent = oldResult;
104+
newContent = newResult;
105+
} catch {
106+
// If fetching content fails, continue without it
107+
}
108+
}
109+
75110
// Use WebWorker for diff parsing (off main thread)
76111
const parsed = await diffService.parseDiff(
77112
file.patch!,
78113
file.filename,
79-
file.previous_filename
114+
file.previous_filename,
115+
oldContent,
116+
newContent
80117
);
81118

82119
// Clean up pending entry
@@ -108,6 +145,10 @@ async function fetchParsedDiff(
108145

109146
export function useDiffLoader() {
110147
const store = usePRReviewStore();
148+
const github = useGitHub();
149+
const owner = usePRReviewSelector((s) => s.owner);
150+
const repo = usePRReviewSelector((s) => s.repo);
151+
const pr = usePRReviewSelector((s) => s.pr);
111152
const selectedFile = usePRReviewSelector((s) => s.selectedFile);
112153
const files = usePRReviewSelector((s) => s.files);
113154
const loadedDiffs = usePRReviewSelector((s) => s.loadedDiffs);
@@ -120,7 +161,7 @@ export function useDiffLoader() {
120161

121162
const currentFile = selectedFile;
122163

123-
// Check cache synchronously - instant if cached
164+
// Check cache synchronously - instant if cached (with full file content)
124165
const cached = getDiffFromCache(file);
125166
if (cached) {
126167
if (!loadedDiffs[currentFile]) {
@@ -146,14 +187,19 @@ export function useDiffLoader() {
146187
}
147188
}, 50);
148189

149-
// Fetch immediately
150-
fetchParsedDiff(file)
190+
// Create file content getter for better syntax highlighting
191+
const getFileContent: FileContentGetter = (path, ref) =>
192+
github.getFileContent(owner, repo, path, ref);
193+
194+
// Fetch immediately with full file content for better highlighting
195+
fetchParsedDiff(file, undefined, getFileContent, pr.base.sha, pr.head.sha)
151196
.then((diff) => {
152197
if (store.getSnapshot().selectedFile === currentFile) {
153198
store.setLoadedDiff(currentFile, diff);
154199
store.setDiffLoading(currentFile, false);
155200

156201
// Prefetch next files aggressively (5 ahead, 2 behind)
202+
// Note: prefetch without file content for speed
157203
const currentIndex = files.findIndex(
158204
(f) => f.filename === currentFile
159205
);
@@ -166,7 +212,7 @@ export function useDiffLoader() {
166212
!getDiffFromCache(f)
167213
);
168214

169-
// Prefetch all in parallel
215+
// Prefetch all in parallel (without file content for speed)
170216
Promise.all(
171217
filesToPrefetch.map((pfile) =>
172218
fetchParsedDiff(pfile)
@@ -191,5 +237,15 @@ export function useDiffLoader() {
191237
clearTimeout(loadingTimeoutId);
192238
store.setDiffLoading(currentFile, false);
193239
};
194-
}, [selectedFile, files, loadedDiffs, store]);
240+
}, [
241+
selectedFile,
242+
files,
243+
loadedDiffs,
244+
store,
245+
github,
246+
owner,
247+
repo,
248+
pr.base.sha,
249+
pr.head.sha,
250+
]);
195251
}

0 commit comments

Comments
 (0)