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 {