diff --git a/apps/desktop/src/lib/ai/__tests__/aiCodeHighlighter.spec.ts b/apps/desktop/src/lib/ai/__tests__/aiCodeHighlighter.spec.ts new file mode 100644 index 0000000000..7e1d4ce8ee --- /dev/null +++ b/apps/desktop/src/lib/ai/__tests__/aiCodeHighlighter.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { createAiShikiBlockCodeHighlighter } from "@/lib/ai/aiCodeHighlighter"; + +describe("createAiShikiBlockCodeHighlighter", () => { + it("wraps every source line in its own .line span with no stray connecting newline", async () => { + const highlight = await createAiShikiBlockCodeHighlighter({ appearance: () => "light" }); + + const html = highlight("SELECT\n id,\n name\nFROM users", "sql"); + + expect(html.match(/class="line"/g)).toHaveLength(4); + // Issue #6894: shiki's classic structure joins lines with a literal "\n" text + // node; since `.line` is already `display:block`, leaving that in doubles the + // visual line spacing under `white-space:pre`. It must be stripped. + expect(html).not.toMatch(/<\/span>\n/); + // Leading indentation must survive (it's rendered as a plain text token). + expect(html).toContain(" id,"); + expect(html).toContain(" name"); + }); + + it("does not leak shiki's own
/ wrapper (the caller supplies its own)", async () => {
+ const highlight = await createAiShikiBlockCodeHighlighter({ appearance: () => "dark" });
+
+ const html = highlight("SELECT 1", "sql");
+
+ expect(html).not.toContain(" {
+ const highlighter = await getAiShikiHighlighter();
+ return (content, lang, appearance = options.appearance()) => {
+ const html = highlighter.codeToHtml(content, {
+ lang: resolveShikiLanguage(lang),
+ structure: "classic",
+ theme: SHIKI_THEMES[appearance],
+ });
+ return extractShikiCodeInnerHtml(html);
+ };
+}
+
+/**
+ * Shiki's "classic" structure wraps lines in `...
`;
+ * callers that supply their own `/` shell only want what's inside.
+ * It also joins `` siblings with a literal "\n" text node —
+ * redundant once `.line{display:block}` already breaks each line, and doubles
+ * the visual line spacing under `white-space:pre` if left in.
+ */
+function extractShikiCodeInnerHtml(html: string): string {
+ const match = /]*>([\s\S]*)<\/code>/.exec(html);
+ const inner = match ? match[1] : html;
+ return inner.replace(/<\/span>\n/g, "");
+}
+
function getAiShikiHighlighter(): Promise {
highlighterPromise ??= loadAiShikiHighlighter();
return highlighterPromise;
diff --git a/apps/desktop/src/lib/codeSnapshot/__tests__/codeSnapshot.spec.ts b/apps/desktop/src/lib/codeSnapshot/__tests__/codeSnapshot.spec.ts
index 6ca204e986..79b2cd9e0c 100644
--- a/apps/desktop/src/lib/codeSnapshot/__tests__/codeSnapshot.spec.ts
+++ b/apps/desktop/src/lib/codeSnapshot/__tests__/codeSnapshot.spec.ts
@@ -14,7 +14,7 @@ const { createCodeHighlighter, highlightCode, toPng, isTauriRuntime, save, write
});
vi.mock("@/lib/ai/aiCodeHighlighter", () => ({
- createAiShikiCodeHighlighter: createCodeHighlighter,
+ createAiShikiBlockCodeHighlighter: createCodeHighlighter,
}));
vi.mock("dom-to-image-more", () => ({
diff --git a/apps/desktop/src/lib/codeSnapshot/codeSnapshot.ts b/apps/desktop/src/lib/codeSnapshot/codeSnapshot.ts
index 8f44809db8..592d6efc08 100644
--- a/apps/desktop/src/lib/codeSnapshot/codeSnapshot.ts
+++ b/apps/desktop/src/lib/codeSnapshot/codeSnapshot.ts
@@ -12,7 +12,7 @@
* clones the node into a foreignObject.
*/
import type { AppThemeAppearance } from "@/lib/app/appTheme";
-import { createAiShikiCodeHighlighter, type AiCodeHighlighter } from "@/lib/ai/aiCodeHighlighter";
+import { createAiShikiBlockCodeHighlighter, type AiCodeHighlighter } from "@/lib/ai/aiCodeHighlighter";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
export interface CodeSnapshotSource {
@@ -135,7 +135,7 @@ let highlighterPromise: Promise | undefined;
/** Lazy singleton reusing the AI assistant's shiki highlighter instance. */
export function getCodeSnapshotHighlighter(): Promise {
- highlighterPromise ??= createAiShikiCodeHighlighter({
+ highlighterPromise ??= createAiShikiBlockCodeHighlighter({
appearance: () => "dark",
}).catch((err: unknown) => {
// Reset so a transient highlighter failure can be retried on the next