From 5767a0fd59f2e53dff88e71d94038a8e69fbbb0e Mon Sep 17 00:00:00 2001
From: t8y2 <1156263951@qq.com>
Date: Thu, 20 Aug 2026 14:26:39 +0000
Subject: [PATCH] fix(snapshot): preserve previews when syntax highlighting
fails
Closes #6777
---
.../__tests__/codeSnapshot.spec.ts | 59 ++++++++++++++++---
.../src/lib/codeSnapshot/codeSnapshot.ts | 14 ++++-
2 files changed, 63 insertions(+), 10 deletions(-)
diff --git a/apps/desktop/src/lib/codeSnapshot/__tests__/codeSnapshot.spec.ts b/apps/desktop/src/lib/codeSnapshot/__tests__/codeSnapshot.spec.ts
index 5bb884f4cd..6ca204e986 100644
--- a/apps/desktop/src/lib/codeSnapshot/__tests__/codeSnapshot.spec.ts
+++ b/apps/desktop/src/lib/codeSnapshot/__tests__/codeSnapshot.spec.ts
@@ -1,15 +1,20 @@
-import { describe, expect, it, vi } from "vitest";
+import { beforeEach, describe, expect, it, vi } from "vitest";
import { CODE_SNAPSHOT_CSS, renderCodeSnapshotHtml, savePngDataUrlToFile, snapshotElementToPng } from "@/lib/codeSnapshot/codeSnapshot";
-const { toPng, isTauriRuntime, save, writeFile } = vi.hoisted(() => ({
- toPng: vi.fn(),
- isTauriRuntime: vi.fn(),
- save: vi.fn(),
- writeFile: vi.fn(),
-}));
+const { createCodeHighlighter, highlightCode, toPng, isTauriRuntime, save, writeFile } = vi.hoisted(() => {
+ const highlightCode = vi.fn((content: string, _lang: string) => `${content}`);
+ return {
+ createCodeHighlighter: vi.fn(async () => highlightCode),
+ highlightCode,
+ toPng: vi.fn(),
+ isTauriRuntime: vi.fn(),
+ save: vi.fn(),
+ writeFile: vi.fn(),
+ };
+});
vi.mock("@/lib/ai/aiCodeHighlighter", () => ({
- createAiShikiCodeHighlighter: vi.fn().mockResolvedValue((content: string, _lang: string) => `${content}`),
+ createAiShikiCodeHighlighter: createCodeHighlighter,
}));
vi.mock("dom-to-image-more", () => ({
@@ -23,7 +28,45 @@ vi.mock("@/lib/backend/tauriRuntime", () => ({
vi.mock("@tauri-apps/plugin-dialog", () => ({ save }));
vi.mock("@tauri-apps/plugin-fs", () => ({ writeFile }));
+beforeEach(() => {
+ createCodeHighlighter.mockResolvedValue(highlightCode);
+ highlightCode.mockClear();
+ highlightCode.mockImplementation((content: string) => `${content}`);
+});
+
describe("renderCodeSnapshotHtml", () => {
+ it("falls back to safe text when the code highlighter cannot initialize", async () => {
+ createCodeHighlighter.mockRejectedValueOnce(new SyntaxError("Invalid regular expression: invalid group specifier name"));
+
+ const html = await renderCodeSnapshotHtml({ code: "SELECT <初始化失败>", lang: "sql" }, { appearance: "light" });
+
+ expect(html).toContain('SELECT <初始化失败>');
+ });
+
+ it("falls back to safe line-preserving text when legacy WebKit cannot compile highlighting regexes", async () => {
+ highlightCode.mockImplementationOnce(() => {
+ throw new SyntaxError("Invalid regular expression: invalid group specifier name");
+ });
+
+ const html = await renderCodeSnapshotHtml({ code: `SELECT '<用户>&" 😀'\r\n-- 第二行\n`, lang: "sql" }, { appearance: "light" });
+
+ expect(html).toContain('SELECT '<用户>&" 😀'-- 第二行');
+ expect(html).not.toContain("<用户>");
+ expect(html.match(/class="line"/g)).toHaveLength(3);
+ });
+
+ it("renders large fallback selections with one highlighter attempt", async () => {
+ const code = Array.from({ length: 5_000 }, (_, index) => `SELECT ${index}`).join("\n");
+ highlightCode.mockImplementationOnce(() => {
+ throw new SyntaxError("Invalid regular expression: invalid group specifier name");
+ });
+
+ const html = await renderCodeSnapshotHtml({ code, lang: "sql" }, { appearance: "dark" });
+
+ expect(highlightCode).toHaveBeenCalledOnce();
+ expect(html.match(/class="line"/g)).toHaveLength(5_000);
+ });
+
it("renders a self-contained snapshot with embedded styles", async () => {
const html = await renderCodeSnapshotHtml({ code: "SELECT 1", lang: "sql" }, { appearance: "dark" });
diff --git a/apps/desktop/src/lib/codeSnapshot/codeSnapshot.ts b/apps/desktop/src/lib/codeSnapshot/codeSnapshot.ts
index 89cf5414c1..8f44809db8 100644
--- a/apps/desktop/src/lib/codeSnapshot/codeSnapshot.ts
+++ b/apps/desktop/src/lib/codeSnapshot/codeSnapshot.ts
@@ -150,6 +150,10 @@ function escapeHtml(value: string): string {
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
}
+function renderEscapedSnapshotLines(value: string): string {
+ return `${escapeHtml(value).replace(/\r\n|\r|\n/g, '')}`;
+}
+
function renderSnapshotBar(title: string | undefined, appearance: AppThemeAppearance, showTrafficLights: boolean): string {
const dots = showTrafficLights ? TRAFFIC_LIGHT_COLORS.map((color) => ``).join("") : "";
const titleHtml = title ? `${escapeHtml(title)}` : "";
@@ -162,8 +166,14 @@ function renderSnapshotBar(title: string | undefined, appearance: AppThemeAppear
* `v-html`, then exported with `snapshotElementToPng`.
*/
export async function renderCodeSnapshotHtml(source: CodeSnapshotSource, options: CodeSnapshotStyleOptions): Promise {
- const highlight = await getCodeSnapshotHighlighter();
- const body = highlight(source.code, source.lang, options.appearance);
+ let body: string;
+ try {
+ const highlight = await getCodeSnapshotHighlighter();
+ body = highlight(source.code, source.lang, options.appearance);
+ } catch {
+ // Legacy WebKit can reject regular expressions generated by Shiki.
+ body = renderEscapedSnapshotLines(source.code);
+ }
const appearance = options.appearance;
const showBar = options.showTrafficLights !== false || !!source.title;