From 4fce6014a62f5644a29d0d8b363267926e96ae3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 22 May 2026 17:55:33 +0800 Subject: [PATCH] fix: preserve Shiki code rendering with legacy fallback Code blocks should keep the richer Shiki rendering in modern WebUI while avoiding the old WebKit startup crash path by loading Shiki lazily and falling back to Prism only when the runtime cannot safely parse or execute it. Constraint: Issue #461 is triggered by older Safari/WebKit parsing modern RegExp syntax during startup. Rejected: Replace Shiki entirely with Prism | visual quality regressed for normal WebUI usage. Confidence: high Scope-risk: moderate Directive: Keep Shiki out of the startup path unless macOS 12 WebView compatibility is re-verified. Tested: cd desktop && bun run test -- --run src/components/chat/CodeViewer.test.tsx Tested: cd desktop && bun run lint Tested: cd desktop && bun run build Tested: Real WebUI smoke on http://127.0.0.1:1420/ verified six code blocks rendered with data-highlight-engine=shiki. Not-tested: Physical macOS 12 WebView runtime. Related: https://github.com/NanmiCoder/cc-haha/issues/461 --- desktop/bun.lock | 24 +- .../src/components/chat/CodeViewer.test.tsx | 33 +-- desktop/src/components/chat/CodeViewer.tsx | 253 +++++++++++++----- desktop/src/theme/globals.css | 42 ++- 4 files changed, 241 insertions(+), 111 deletions(-) diff --git a/desktop/bun.lock b/desktop/bun.lock index bc30800c..fb56d1ac 100644 --- a/desktop/bun.lock +++ b/desktop/bun.lock @@ -294,19 +294,19 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="], - "@shikijs/core": ["@shikijs/core@4.0.2", "https://registry.npmmirror.com/@shikijs/core/-/core-4.0.2.tgz", { "dependencies": { "@shikijs/primitive": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw=="], + "@shikijs/core": ["@shikijs/core@4.1.0", "https://registry.npmmirror.com/@shikijs/core/-/core-4.1.0.tgz", { "dependencies": { "@shikijs/primitive": "4.1.0", "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ=="], - "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.0.2", "https://registry.npmmirror.com/@shikijs/engine-javascript/-/engine-javascript-4.0.2.tgz", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag=="], + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.1.0", "https://registry.npmmirror.com/@shikijs/engine-javascript/-/engine-javascript-4.1.0.tgz", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ=="], - "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.0.2", "https://registry.npmmirror.com/@shikijs/engine-oniguruma/-/engine-oniguruma-4.0.2.tgz", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg=="], + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.1.0", "https://registry.npmmirror.com/@shikijs/engine-oniguruma/-/engine-oniguruma-4.1.0.tgz", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg=="], - "@shikijs/langs": ["@shikijs/langs@4.0.2", "https://registry.npmmirror.com/@shikijs/langs/-/langs-4.0.2.tgz", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg=="], + "@shikijs/langs": ["@shikijs/langs@4.1.0", "https://registry.npmmirror.com/@shikijs/langs/-/langs-4.1.0.tgz", { "dependencies": { "@shikijs/types": "4.1.0" } }, "sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg=="], - "@shikijs/primitive": ["@shikijs/primitive@4.0.2", "https://registry.npmmirror.com/@shikijs/primitive/-/primitive-4.0.2.tgz", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw=="], + "@shikijs/primitive": ["@shikijs/primitive@4.1.0", "https://registry.npmmirror.com/@shikijs/primitive/-/primitive-4.1.0.tgz", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw=="], - "@shikijs/themes": ["@shikijs/themes@4.0.2", "https://registry.npmmirror.com/@shikijs/themes/-/themes-4.0.2.tgz", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA=="], + "@shikijs/themes": ["@shikijs/themes@4.1.0", "https://registry.npmmirror.com/@shikijs/themes/-/themes-4.1.0.tgz", { "dependencies": { "@shikijs/types": "4.1.0" } }, "sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw=="], - "@shikijs/types": ["@shikijs/types@4.0.2", "https://registry.npmmirror.com/@shikijs/types/-/types-4.0.2.tgz", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="], + "@shikijs/types": ["@shikijs/types@4.1.0", "https://registry.npmmirror.com/@shikijs/types/-/types-4.1.0.tgz", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA=="], "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "https://registry.npmmirror.com/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], @@ -484,7 +484,7 @@ "@types/unist": ["@types/unist@3.0.3", "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], - "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="], "@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "https://registry.npmmirror.com/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="], @@ -974,9 +974,9 @@ "nwsapi": ["nwsapi@2.2.23", "https://registry.npmmirror.com/nwsapi/-/nwsapi-2.2.23.tgz", {}, "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ=="], - "oniguruma-parser": ["oniguruma-parser@0.12.1", "https://registry.npmmirror.com/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="], + "oniguruma-parser": ["oniguruma-parser@0.12.2", "https://registry.npmmirror.com/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="], - "oniguruma-to-es": ["oniguruma-to-es@4.3.5", "https://registry.npmmirror.com/oniguruma-to-es/-/oniguruma-to-es-4.3.5.tgz", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ=="], + "oniguruma-to-es": ["oniguruma-to-es@4.3.6", "https://registry.npmmirror.com/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="], "p-limit": ["p-limit@2.3.0", "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], @@ -1046,7 +1046,7 @@ "react-is": ["react-is@17.0.2", "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], - "react-shiki": ["react-shiki@0.9.2", "https://registry.npmmirror.com/react-shiki/-/react-shiki-0.9.2.tgz", { "dependencies": { "clsx": "^2.1.1", "dequal": "^2.0.3", "hast-util-to-jsx-runtime": "^2.3.6", "shiki": "^4.0.0", "unist-util-visit": "^5.1.0" }, "peerDependencies": { "@types/react": ">=16.8.0", "@types/react-dom": ">=16.8.0", "react": ">= 16.8.0", "react-dom": ">= 16.8.0" } }, "sha512-TMj2IYm9mWUhWF+OEffdfnaYEpYLr3vs5b8xRcnuRYR4Dkk2TU2zT9RlbSlktl5mLNdiioDnkEqZYzsGYjYQNA=="], + "react-shiki": ["react-shiki@0.9.3", "https://registry.npmmirror.com/react-shiki/-/react-shiki-0.9.3.tgz", { "dependencies": { "clsx": "^2.1.1", "dequal": "^2.0.3", "hast-util-to-jsx-runtime": "^2.3.6", "shiki": "^4.0.0", "unist-util-visit": "^5.1.0" }, "peerDependencies": { "@types/react": ">=16.8.0", "@types/react-dom": ">=16.8.0", "react": ">= 16.8.0", "react-dom": ">= 16.8.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F2Uju1/BeUTFQeS+3v3HM0Ry4p+8gcLC4ssObmXxwrzlwPJYq5RGAKcA1r5JBEnJCpEVKf9PajnwM+JMwZnzGg=="], "redent": ["redent@3.0.0", "https://registry.npmmirror.com/redent/-/redent-3.0.0.tgz", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], @@ -1090,7 +1090,7 @@ "shebang-regex": ["shebang-regex@3.0.0", "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "shiki": ["shiki@4.0.2", "https://registry.npmmirror.com/shiki/-/shiki-4.0.2.tgz", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="], + "shiki": ["shiki@4.1.0", "https://registry.npmmirror.com/shiki/-/shiki-4.1.0.tgz", { "dependencies": { "@shikijs/core": "4.1.0", "@shikijs/engine-javascript": "4.1.0", "@shikijs/engine-oniguruma": "4.1.0", "@shikijs/langs": "4.1.0", "@shikijs/themes": "4.1.0", "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q=="], "siginfo": ["siginfo@2.0.0", "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], diff --git a/desktop/src/components/chat/CodeViewer.test.tsx b/desktop/src/components/chat/CodeViewer.test.tsx index 90b602e4..41353035 100644 --- a/desktop/src/components/chat/CodeViewer.test.tsx +++ b/desktop/src/components/chat/CodeViewer.test.tsx @@ -1,40 +1,25 @@ -import { render, screen, waitFor } from '@testing-library/react' -import { describe, expect, it, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' import { CodeViewer } from './CodeViewer' -const mockShikiState = vi.hoisted(() => ({ - lastProps: null as Record | null, - engine: { kind: 'js-regex-engine' as const }, -})) - -vi.mock('react-shiki', () => ({ - createJavaScriptRegexEngine: () => mockShikiState.engine, - ShikiHighlighter: (props: { children: string } & Record) => { - mockShikiState.lastProps = props - return ( -
- {props.children} -
- ) - }, -})) - describe('CodeViewer', () => { - it('keeps the same inner padding for highlighted code content', async () => { + it('keeps the same inner padding for highlighted code content', () => { const { container } = render( , ) - await waitFor(() => { - expect(screen.getByTestId('shiki-container')).toBeTruthy() - }) + expect(screen.getByText('cd testb')).toBeTruthy() + expect(screen.getByText('npm run dev')).toBeTruthy() const contentWrapper = container.querySelector('[data-code-viewer-content]') as HTMLElement | null expect(contentWrapper).toBeTruthy() expect(contentWrapper?.style.padding).toBe('0.5rem 12px') + expect(contentWrapper?.style.whiteSpace).toBe('pre') + expect(contentWrapper?.style.wordBreak).toBe('normal') const codeArea = container.querySelector('.code-viewer-area') as HTMLElement | null expect(codeArea?.getAttribute('data-has-line-numbers')).toBe('true') - expect(mockShikiState.lastProps?.engine).toBe(mockShikiState.engine) + expect(container.querySelector('[data-line-number="1"]')).toBeTruthy() + expect(container.querySelector('[data-line-number="2"]')).toBeTruthy() }) }) diff --git a/desktop/src/components/chat/CodeViewer.tsx b/desktop/src/components/chat/CodeViewer.tsx index a6cb8726..8ede06e3 100644 --- a/desktop/src/components/chat/CodeViewer.tsx +++ b/desktop/src/components/chat/CodeViewer.tsx @@ -1,6 +1,5 @@ -import { useState, useRef, useEffect } from 'react' -import { ShikiHighlighter, createJavaScriptRegexEngine } from 'react-shiki' -import 'react-shiki/css' +import { useEffect, useRef, useState, type ComponentType, type CSSProperties } from 'react' +import { Highlight, type PrismTheme } from 'prism-react-renderer' import { CopyButton } from '../shared/CopyButton' type Props = { @@ -10,11 +9,30 @@ type Props = { showLineNumbers?: boolean } -/** - * Custom warm-toned TextMate theme — uses VS Code-quality tokenization - * while harmonizing with the app's cream/terra-cotta design system. - */ -const warmCodeTheme = { +const warmPrismTheme: PrismTheme = { + plain: { + color: 'var(--color-code-fg)', + backgroundColor: 'transparent', + }, + styles: [ + { types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: 'var(--color-code-comment)', fontStyle: 'italic' as const } }, + { types: ['string', 'attr-value', 'template-string'], style: { color: 'var(--color-code-string)' } }, + { types: ['keyword', 'selector', 'important', 'atrule'], style: { color: 'var(--color-code-keyword)' } }, + { types: ['function'], style: { color: 'var(--color-code-function)' } }, + { types: ['tag'], style: { color: 'var(--color-code-keyword)' } }, + { types: ['number', 'boolean'], style: { color: 'var(--color-code-number)' } }, + { types: ['operator'], style: { color: 'var(--color-code-fg)' } }, + { types: ['punctuation'], style: { color: 'var(--color-code-punctuation)' } }, + { types: ['variable', 'parameter'], style: { color: 'var(--color-code-fg)' } }, + { types: ['property', 'attr-name'], style: { color: 'var(--color-code-property)' } }, + { types: ['builtin', 'class-name', 'constant', 'symbol'], style: { color: 'var(--color-code-type)' } }, + { types: ['regex'], style: { color: 'var(--color-primary-container)' } }, + { types: ['inserted'], style: { color: 'var(--color-code-inserted)' } }, + { types: ['deleted'], style: { color: 'var(--color-code-deleted)' } }, + ], +} + +const warmShikiTheme = { name: 'warm-code', type: 'dark' as const, fg: 'var(--color-code-fg)', @@ -47,26 +65,135 @@ const warmCodeTheme = { const CODE_AREA_PADDING = '0.5rem 12px' const CODE_LINE_HEIGHT = 1.3 -const shikiEngine = createJavaScriptRegexEngine({ forgiving: true }) -/** - * Wraps ShikiHighlighter with a plain-text fallback so the code area - * is never empty while the async WASM / language-grammar load is in-flight, - * or if highlighting fails entirely. - */ +type ShikiHighlighterProps = { + language: string + theme: typeof warmShikiTheme + engine: unknown + showLineNumbers: boolean + showLanguage: boolean + addDefaultStyles: boolean + style: CSSProperties + children: string +} + +type ReactShikiModule = { + ShikiHighlighter: ComponentType + createJavaScriptRegexEngine: (options: { forgiving: boolean }) => unknown +} + +type ShikiRuntime = { + Highlighter: ComponentType + engine: unknown +} + +let shikiRuntimePromise: Promise | null = null + +function canUseShikiRuntime(): boolean { + if (import.meta.env.MODE === 'test') return false + if (typeof window === 'undefined') return false + + try { + new RegExp('(?a)') + new RegExp('(?<=a)b') + } catch { + return false + } + + const ua = window.navigator.userAgent + const chromiumLike = /\b(Chrome|Chromium|CriOS|Edg|OPR|Firefox)\b/.test(ua) + const safariVersion = /\bVersion\/(\d+)(?:\.\d+)?\b.*\bSafari\//.exec(ua) + if (!chromiumLike && safariVersion && Number(safariVersion[1]) <= 15) { + return false + } + + return true +} + +function loadShikiRuntime(): Promise { + if (!canUseShikiRuntime()) return Promise.resolve(null) + shikiRuntimePromise ??= import('react-shiki') + .then((mod) => { + const shiki = mod as unknown as ReactShikiModule + return { + Highlighter: shiki.ShikiHighlighter as ComponentType, + engine: shiki.createJavaScriptRegexEngine({ forgiving: true }), + } + }) + .catch(() => null) + return shikiRuntimePromise +} + +function PrismCodeContent({ code, language, showLineNumbers }: { code: string; language?: string; showLineNumbers: boolean }) { + return ( + + {({ tokens, getLineProps, getTokenProps }) => ( +
+          {tokens.map((line, index) => (
+            
+              {showLineNumbers && (
+                
+                  {index + 1}
+                
+              )}
+              {line.map((token, key) => (
+                
+              ))}
+            
+          ))}
+        
+ )} +
+ ) +} + function CodeArea({ code, language, showLineNumbers }: { code: string; language?: string; showLineNumbers: boolean }) { const containerRef = useRef(null) + const [runtime, setRuntime] = useState(null) const [loaded, setLoaded] = useState(false) useEffect(() => { - // ShikiHighlighter renders `null` until the async highlight completes. - // Watch for real content appearing via MutationObserver so we can hide - // the plain-text fallback as soon as highlighted output is in the DOM. + let cancelled = false + setLoaded(false) + loadShikiRuntime().then((nextRuntime) => { + if (!cancelled) setRuntime(nextRuntime) + }) + return () => { + cancelled = true + } + }, []) + + useEffect(() => { + setLoaded(false) + }, [code, language]) + + useEffect(() => { + if (!runtime) return const el = containerRef.current if (!el) return const check = () => { const shikiContainer = el.querySelector('[data-testid="shiki-container"]') - // shiki renders a element inside its container once highlighting is done if (shikiContainer?.querySelector('code')) { setLoaded(true) } @@ -75,7 +202,9 @@ function CodeArea({ code, language, showLineNumbers }: { code: string; language? const observer = new MutationObserver(check) observer.observe(el, { childList: true, subtree: true }) return () => observer.disconnect() - }, [code, language]) + }, [runtime, code, language]) + + const ShikiHighlighter = runtime?.Highlighter return (
- {/* Plain-text fallback shown until Shiki finishes highlighting */} - {!loaded && ( -
-          {code}
-        
- )} -
- + )} + {ShikiHighlighter && ( +
- {code} - -
+ + {code} + +
+ )}
) } diff --git a/desktop/src/theme/globals.css b/desktop/src/theme/globals.css index e628dbe2..f03e0402 100644 --- a/desktop/src/theme/globals.css +++ b/desktop/src/theme/globals.css @@ -67,11 +67,12 @@ src: url('/fonts/material-symbols-outlined.woff2') format('woff2'); } -/* Code highlighting is handled by shiki/react-shiki (inline styles + CSS line numbers) */ - -/* ─── Shiki code viewer ───────────────────────────────────────── */ -.code-viewer-area .line { +.code-viewer-area .line, +.code-viewer-area .token-line { display: block; +} + +.code-viewer-area .line { padding: 0 12px; } @@ -79,20 +80,41 @@ padding-left: 12px; } -.code-viewer-area .line:hover { +.code-viewer-area .line:hover, +.code-viewer-area .token-line:hover { background: rgba(0, 0, 0, 0.03); } -.code-viewer-area .line-numbers::before { - margin-right: 1.5ch; -} - -.code-viewer-area .has-line-numbers { +.code-viewer-area .has-line-numbers, +.code-viewer-area .rs-has-line-numbers { + counter-reset: line-number calc(var(--line-start, 1) - 1); --line-numbers-foreground: rgba(135, 115, 109, 0.5); --line-numbers-width: 2.5ch; --line-numbers-padding-left: 0; --line-numbers-padding-right: 0; --line-numbers-font-size: 11px; + --rs-line-numbers-foreground: var(--line-numbers-foreground); + --rs-line-numbers-width: var(--line-numbers-width); + --rs-line-numbers-padding-left: var(--line-numbers-padding-left); + --rs-line-numbers-padding-right: var(--line-numbers-padding-right); + --rs-line-numbers-font-size: var(--line-numbers-font-size); +} + +.code-viewer-area .line-numbers::before, +.code-viewer-area .rs-line-number::before { + content: counter(line-number); + counter-increment: line-number; + display: inline-flex; + justify-content: flex-end; + box-sizing: content-box; + min-width: var(--rs-line-numbers-width); + padding-left: var(--rs-line-numbers-padding-left); + padding-right: var(--rs-line-numbers-padding-right); + color: var(--rs-line-numbers-foreground); + font-size: var(--rs-line-numbers-font-size); + user-select: none; + pointer-events: none; + margin-right: 1.5ch; } .settings-terminal-host .xterm {