diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index aa2964d36a73..6a967657a183 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -37,6 +37,7 @@ import { resolveDiffThemeName, resolveFileDiffPath, } from "../lib/diffRendering"; +import { PREFERRED_HIGHLIGHTER } from "../lib/syntaxHighlighting"; import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "../lib/diffCollapse"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useProject, useThread } from "../state/entities"; @@ -960,6 +961,7 @@ export default function DiffPanel({ lineDiffType: "none", overflow: wordWrap ? "wrap" : "scroll", theme: resolveDiffThemeName(resolvedTheme), + preferredHighlighter: PREFERRED_HIGHLIGHTER, themeType: resolvedTheme as DiffThemeType, stickyHeaders: true, ...(loadDiffFiles ? { loadDiffFiles } : {}), diff --git a/apps/web/src/components/DiffWorkerPoolProvider.tsx b/apps/web/src/components/DiffWorkerPoolProvider.tsx index 3ec748c6bcb2..bcf90b118410 100644 --- a/apps/web/src/components/DiffWorkerPoolProvider.tsx +++ b/apps/web/src/components/DiffWorkerPoolProvider.tsx @@ -4,6 +4,7 @@ import * as Schema from "effect/Schema"; import { useEffect, useMemo, type ReactNode } from "react"; import { useTheme } from "../hooks/useTheme"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; +import { PREFERRED_HIGHLIGHTER } from "../lib/syntaxHighlighting"; export class DiffWorkerError extends Schema.TaggedErrorClass()("DiffWorkerError", { operation: Schema.Literals(["create-worker", "get-render-options", "set-render-options"]), @@ -73,6 +74,7 @@ export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) { }} highlighterOptions={{ theme: diffThemeName, + preferredHighlighter: PREFERRED_HIGHLIGHTER, tokenizeMaxLineLength: 1_000, useTokenTransformer: true, }} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index d406444c507b..d848c134e2ae 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -44,6 +44,7 @@ import { resolveDiffThemeName, resolveFileDiffPath, } from "../../lib/diffRendering"; +import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting"; import ChatMarkdown from "../ChatMarkdown"; import { BotIcon, @@ -2110,6 +2111,7 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte collapsed: false, diffStyle: "unified", theme: resolveDiffThemeName(ctx.resolvedTheme), + preferredHighlighter: PREFERRED_HIGHLIGHTER, }} /> ))} diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index a8c364763c28..f98179027315 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -25,6 +25,7 @@ import { useClientSettings } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage"; import { DIFF_SURFACE_THEME_UNSAFE_CSS, resolveDiffThemeName } from "~/lib/diffRendering"; +import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting"; import { cn } from "~/lib/utils"; import { isPreviewSupportedInRuntime } from "~/previewStateStore"; import { resolvePathLinkTarget } from "~/terminal-links"; @@ -670,6 +671,7 @@ function EditableFileSurface({ onLineSelectionEnd: handleLineSelectionEnd, overflow: wordWrap ? "wrap" : "scroll", theme: resolveDiffThemeName(resolvedTheme), + preferredHighlighter: PREFERRED_HIGHLIGHTER, themeType: resolvedTheme, unsafeCSS: FILE_LINK_REVEAL_UNSAFE_CSS, onPostRender: handlePostRender, @@ -1039,6 +1041,7 @@ export default function FilePreviewPanel({ disableFileHeader: true, overflow: wordWrap ? "wrap" : "scroll", theme: resolveDiffThemeName(resolvedTheme), + preferredHighlighter: PREFERRED_HIGHLIGHTER, themeType: resolvedTheme, unsafeCSS: FILE_LINK_REVEAL_UNSAFE_CSS, onPostRender: onFilePostRender, diff --git a/apps/web/src/components/settings/SettingsFontPreviews.tsx b/apps/web/src/components/settings/SettingsFontPreviews.tsx index 62629c3bb9f8..a9fdc949dadb 100644 --- a/apps/web/src/components/settings/SettingsFontPreviews.tsx +++ b/apps/web/src/components/settings/SettingsFontPreviews.tsx @@ -5,6 +5,7 @@ import { terminalThemeFromApp } from "../ThreadTerminalDrawer"; import { useTheme } from "../../hooks/useTheme"; import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; import { resolveDiffThemeName, type DiffThemeName } from "../../lib/diffRendering"; +import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting"; import { GhosttyTerminalSurface } from "~/terminal/ghostty/surface"; // The font previews are the real surfaces, not lookalikes: the composer's @@ -79,7 +80,7 @@ function loadDiffPreviewHtml(theme: DiffThemeName): Promise { if (promise === undefined) { promise = preloadPatchFile({ patch: DIFF_PREVIEW_PATCH, - options: { diffStyle: "unified", theme }, + options: { diffStyle: "unified", theme, preferredHighlighter: PREFERRED_HIGHLIGHTER }, }).then((results) => results.map((result) => result.prerenderedHTML)); diffPreviewHtmlByTheme.set(theme, promise); } diff --git a/apps/web/src/lib/syntaxHighlighting.test.ts b/apps/web/src/lib/syntaxHighlighting.test.ts index 1f7ce756b60c..bef8e54cdad4 100644 --- a/apps/web/src/lib/syntaxHighlighting.test.ts +++ b/apps/web/src/lib/syntaxHighlighting.test.ts @@ -9,7 +9,11 @@ vi.mock("@pierre/diffs", () => ({ getSharedHighlighter, })); -import { getSyntaxHighlighterPromise } from "./syntaxHighlighting"; +import { getSyntaxHighlighterPromise, PREFERRED_HIGHLIGHTER } from "./syntaxHighlighting"; + +it("prefers the Oniguruma WASM highlighter", () => { + expect(PREFERRED_HIGHLIGHTER).toBe("shiki-wasm"); +}); it("caches the recovered text highlighter for unsupported languages", async () => { const textHighlighter = {} as DiffsHighlighter; @@ -25,4 +29,7 @@ it("caches the recovered text highlighter for unsupported languages", async () = expect(second).toBe(first); expect(getSharedHighlighter).toHaveBeenCalledTimes(2); + expect(getSharedHighlighter).toHaveBeenCalledWith( + expect.objectContaining({ preferredHighlighter: "shiki-wasm" }), + ); }); diff --git a/apps/web/src/lib/syntaxHighlighting.ts b/apps/web/src/lib/syntaxHighlighting.ts index 171725617e80..34a6539d1914 100644 --- a/apps/web/src/lib/syntaxHighlighting.ts +++ b/apps/web/src/lib/syntaxHighlighting.ts @@ -1,11 +1,20 @@ import { getSharedHighlighter, type DiffsHighlighter, + type HighlighterTypes, type SupportedLanguages, } from "@pierre/diffs"; import { resolveDiffThemeName } from "./diffRendering"; +/** + * Always highlight with the Oniguruma WASM engine. The JS regex engine can + * backtrack catastrophically and hang the tokenizing thread. The shared + * highlighter is a first-caller-wins singleton, so every creation site must + * pass this value. + */ +export const PREFERRED_HIGHLIGHTER: HighlighterTypes = "shiki-wasm"; + const highlighterPromiseCache = new Map>(); export function getSyntaxHighlighterPromise(language: string): Promise { @@ -15,7 +24,7 @@ export function getSyntaxHighlighterPromise(language: string): Promise { if (language === "text") { highlighterPromiseCache.delete(language);