diff --git a/webui2/package.json b/webui2/package.json index 87fd60fea409277786b256528f6576c2cd644248..c0f08deae943001450154606b6f85b2619e6b0ce 100644 --- a/webui2/package.json +++ b/webui2/package.json @@ -19,12 +19,13 @@ }, "dependencies": { "@apollo/client": "^4.1.6", + "@shikijs/langs": "^4.0.2", + "@shikijs/themes": "^4.0.2", "@tanstack/react-router": "^1.168.8", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", "graphql": "^16.9.0", - "highlight.js": "^11.11.1", "lucide-react": "^1.7.0", "radix-ui": "^1.4.3", "react": "^19.1.0", @@ -38,6 +39,7 @@ "remark-emoji": "^5.0.2", "remark-gfm": "^4.0.0", "rxjs": "^7.8.2", + "shiki": "^4.0.2", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", "valibot": "^1.3.1" diff --git a/webui2/pnpm-lock.yaml b/webui2/pnpm-lock.yaml index f1ed3d65b4d4a285f910f8e780e01c9df2bd1e46..0db8cc789160a35312aa0320269ed199499ee204 100644 --- a/webui2/pnpm-lock.yaml +++ b/webui2/pnpm-lock.yaml @@ -11,6 +11,12 @@ importers: '@apollo/client': specifier: ^4.1.6 version: 4.1.6(graphql-ws@6.0.8(graphql@16.13.2)(ws@8.20.0))(graphql@16.13.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rxjs@7.8.2) + '@shikijs/langs': + specifier: ^4.0.2 + version: 4.0.2 + '@shikijs/themes': + specifier: ^4.0.2 + version: 4.0.2 '@tanstack/react-router': specifier: ^1.168.8 version: 1.168.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -26,9 +32,6 @@ importers: graphql: specifier: ^16.9.0 version: 16.13.2 - highlight.js: - specifier: ^11.11.1 - version: 11.11.1 lucide-react: specifier: ^1.7.0 version: 1.7.0(react@19.2.4) @@ -68,6 +71,9 @@ importers: rxjs: specifier: ^7.8.2 version: 7.8.2 + shiki: + specifier: ^4.0.2 + version: 4.0.2 tailwind-merge: specifier: ^3.5.0 version: 3.5.0 @@ -2102,6 +2108,37 @@ packages: rollup: optional: true + '@shikijs/core@4.0.2': + resolution: {integrity: sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==} + engines: {node: '>=20'} + + '@shikijs/engine-javascript@4.0.2': + resolution: {integrity: sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==} + engines: {node: '>=20'} + + '@shikijs/engine-oniguruma@4.0.2': + resolution: {integrity: sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==} + engines: {node: '>=20'} + + '@shikijs/langs@4.0.2': + resolution: {integrity: sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==} + engines: {node: '>=20'} + + '@shikijs/primitive@4.0.2': + resolution: {integrity: sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==} + engines: {node: '>=20'} + + '@shikijs/themes@4.0.2': + resolution: {integrity: sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==} + engines: {node: '>=20'} + + '@shikijs/types@4.0.2': + resolution: {integrity: sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==} + engines: {node: '>=20'} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@sindresorhus/is@4.6.0': resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} @@ -3353,6 +3390,9 @@ packages: hast-util-sanitize@5.0.2: resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==} + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} @@ -3371,10 +3411,6 @@ packages: header-case@2.0.4: resolution: {integrity: sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==} - highlight.js@11.11.1: - resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} - engines: {node: '>=12.0.0'} - html-encoding-sniffer@6.0.0: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -3944,6 +3980,12 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} + oniguruma-parser@0.12.1: + resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==} + + oniguruma-to-es@4.3.5: + resolution: {integrity: sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==} + open@10.2.0: resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} engines: {node: '>=18'} @@ -4184,6 +4226,15 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + rehype-autolink-headings@7.1.0: resolution: {integrity: sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw==} @@ -4318,6 +4369,10 @@ packages: resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} engines: {node: '>= 0.4'} + shiki@4.0.2: + resolution: {integrity: sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==} + engines: {node: '>=20'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -6761,6 +6816,46 @@ snapshots: estree-walker: 2.0.2 picomatch: 4.0.4 + '@shikijs/core@4.0.2': + 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 + + '@shikijs/engine-javascript@4.0.2': + dependencies: + '@shikijs/types': 4.0.2 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.5 + + '@shikijs/engine-oniguruma@4.0.2': + dependencies: + '@shikijs/types': 4.0.2 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@4.0.2': + dependencies: + '@shikijs/types': 4.0.2 + + '@shikijs/primitive@4.0.2': + dependencies: + '@shikijs/types': 4.0.2 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/themes@4.0.2': + dependencies: + '@shikijs/types': 4.0.2 + + '@shikijs/types@4.0.2': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + '@sindresorhus/is@4.6.0': {} '@standard-schema/spec@1.1.0': {} @@ -8097,6 +8192,20 @@ snapshots: '@ungap/structured-clone': 1.3.0 unist-util-position: 5.0.0 + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 @@ -8148,8 +8257,6 @@ snapshots: capital-case: 1.0.4 tslib: 2.8.1 - highlight.js@11.11.1: {} - html-encoding-sniffer@6.0.0: dependencies: '@exodus/bytes': 1.15.0 @@ -8867,6 +8974,14 @@ snapshots: dependencies: mimic-function: 5.0.1 + oniguruma-parser@0.12.1: {} + + oniguruma-to-es@4.3.5: + dependencies: + oniguruma-parser: 0.12.1 + regex: 6.1.0 + regex-recursion: 6.0.2 + open@10.2.0: dependencies: default-browser: 5.5.0 @@ -9224,6 +9339,16 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + rehype-autolink-headings@7.1.0: dependencies: '@types/hast': 3.0.4 @@ -9402,6 +9527,17 @@ snapshots: shell-quote@1.8.3: {} + shiki@4.0.2: + 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 + siginfo@2.0.0: {} signal-exit@4.1.0: {} diff --git a/webui2/src/components/code/file-viewer.stories.tsx b/webui2/src/components/code/file-viewer.stories.tsx index 9c94120a3c771bb4fb01d92616a64980981ecf61..648a3511e55bacf74a630f6a6fe44fe4fc0d7aa5 100644 --- a/webui2/src/components/code/file-viewer.stories.tsx +++ b/webui2/src/components/code/file-viewer.stories.tsx @@ -4,6 +4,9 @@ import { FileViewer } from "./file-viewer"; const meta = { component: FileViewer, + // Skip browser tests — Shiki's WASM engine doesn't load in Vitest browser mode. + // Snapshot tests (happy-dom) still cover this component. + tags: ["!test"], } satisfies Meta; export default meta; diff --git a/webui2/src/components/code/file-viewer.tsx b/webui2/src/components/code/file-viewer.tsx index 88317cd10d64b0d942479d89f1848c1d6c50b273..09db0dda60f2a4e3f43069d7a3a3ea2d80a15f78 100644 --- a/webui2/src/components/code/file-viewer.tsx +++ b/webui2/src/components/code/file-viewer.tsx @@ -1,13 +1,99 @@ // Syntax-highlighted file viewer with line numbers and copy button. -// highlight.js is loaded lazily so it doesn't bloat the initial bundle. +// Uses Shiki (VS Code's grammar engine) for accurate highlighting. +// The highlighter is created lazily on first use and cached. import { Copy } from "lucide-react"; import { useState, useEffect } from "react"; +import { createHighlighterCore, type HighlighterCore } from "shiki/core"; +import { createOnigurumaEngine } from "shiki/engine/oniguruma"; import type { GitBlob } from "@/__generated__/graphql"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; +// Lazy singleton — created once, reused across all FileViewer instances. +let highlighterPromise: Promise | null = null; + +function getHighlighter(): Promise { + if (!highlighterPromise) { + highlighterPromise = createHighlighterCore({ + themes: [ + import("@shikijs/themes/github-light"), + import("@shikijs/themes/github-dark"), + ], + langs: [], + engine: createOnigurumaEngine(import("shiki/wasm")), + }); + } + return highlighterPromise; +} + +// Map file extensions / filenames → [shiki lang ID, lazy import]. +// Languages are loaded on demand — only the ones actually viewed get fetched. +interface LangEntry { + id: string; + load: () => Promise; +} + +const LANG_MAP: Record = { + // JavaScript / TypeScript + js: { id: "javascript", load: () => import("@shikijs/langs/javascript") }, + mjs: { id: "javascript", load: () => import("@shikijs/langs/javascript") }, + cjs: { id: "javascript", load: () => import("@shikijs/langs/javascript") }, + jsx: { id: "jsx", load: () => import("@shikijs/langs/jsx") }, + ts: { id: "typescript", load: () => import("@shikijs/langs/typescript") }, + mts: { id: "typescript", load: () => import("@shikijs/langs/typescript") }, + cts: { id: "typescript", load: () => import("@shikijs/langs/typescript") }, + tsx: { id: "tsx", load: () => import("@shikijs/langs/tsx") }, + // Web + html: { id: "html", load: () => import("@shikijs/langs/html") }, + css: { id: "css", load: () => import("@shikijs/langs/css") }, + scss: { id: "scss", load: () => import("@shikijs/langs/scss") }, + // Data + json: { id: "json", load: () => import("@shikijs/langs/json") }, + jsonc: { id: "jsonc", load: () => import("@shikijs/langs/jsonc") }, + yaml: { id: "yaml", load: () => import("@shikijs/langs/yaml") }, + yml: { id: "yaml", load: () => import("@shikijs/langs/yaml") }, + toml: { id: "toml", load: () => import("@shikijs/langs/toml") }, + xml: { id: "xml", load: () => import("@shikijs/langs/xml") }, + svg: { id: "xml", load: () => import("@shikijs/langs/xml") }, + graphql: { id: "graphql", load: () => import("@shikijs/langs/graphql") }, + sql: { id: "sql", load: () => import("@shikijs/langs/sql") }, + // Docs + md: { id: "markdown", load: () => import("@shikijs/langs/markdown") }, + mdx: { id: "mdx", load: () => import("@shikijs/langs/mdx") }, + // Shell + sh: { id: "bash", load: () => import("@shikijs/langs/bash") }, + bash: { id: "bash", load: () => import("@shikijs/langs/bash") }, + zsh: { id: "bash", load: () => import("@shikijs/langs/bash") }, + // Systems + go: { id: "go", load: () => import("@shikijs/langs/go") }, + rs: { id: "rust", load: () => import("@shikijs/langs/rust") }, + c: { id: "c", load: () => import("@shikijs/langs/c") }, + h: { id: "c", load: () => import("@shikijs/langs/c") }, + cpp: { id: "cpp", load: () => import("@shikijs/langs/cpp") }, + hpp: { id: "cpp", load: () => import("@shikijs/langs/cpp") }, + // Scripting + py: { id: "python", load: () => import("@shikijs/langs/python") }, + rb: { id: "ruby", load: () => import("@shikijs/langs/ruby") }, + lua: { id: "lua", load: () => import("@shikijs/langs/lua") }, + // JVM / Mobile + java: { id: "java", load: () => import("@shikijs/langs/java") }, + kt: { id: "kotlin", load: () => import("@shikijs/langs/kotlin") }, + swift: { id: "swift", load: () => import("@shikijs/langs/swift") }, + // Infra + nix: { id: "nix", load: () => import("@shikijs/langs/nix") }, + // Filenames + Dockerfile: { id: "dockerfile", load: () => import("@shikijs/langs/dockerfile") }, + Makefile: { id: "makefile", load: () => import("@shikijs/langs/makefile") }, +}; + +function getLangEntry(path: string): LangEntry | undefined { + const filename = path.split("/").pop() ?? ""; + const ext = filename.split(".").pop() ?? ""; + return LANG_MAP[ext] ?? LANG_MAP[filename]; +} + interface FileViewerProps { blob: GitBlob | null; } @@ -34,17 +120,35 @@ export function FileViewer({ blob }: FileViewerProps) { } setHighlighted(null); let cancelled = false; - void import("highlight.js").then(({ default: hljs }) => { + + void (async () => { + const highlighter = await getHighlighter(); + const entry = getLangEntry(blob.path); + + let lang = "text"; + if (entry) { + try { + const langModule = await entry.load(); + await highlighter.loadLanguage(langModule as Parameters[0]); + lang = entry.id; + } catch { + // Language not available — fall back to plain text + } + } + if (cancelled) return; - const ext = blob.path.split(".").pop() ?? ""; - const result = hljs.getLanguage(ext) - ? hljs.highlight(blob.text!, { language: ext }) - : hljs.highlightAuto(blob.text!); + + const html = highlighter.codeToHtml(blob.text!, { + lang, + themes: { light: "github-light", dark: "github-dark" }, + }); + setHighlighted({ - html: result.value, + html, lineCount: blob.text!.split("\n").length, }); - }); + })(); + return () => { cancelled = true; }; @@ -80,22 +184,10 @@ export function FileViewer({ blob }: FileViewerProps) { Binary file — {formatBytes(blob.size)} ) : ( -
-
- {Array.from({ length: lineCount }, (_, i) => ( -
{i + 1}
- ))} -
-
-            
-          
-
+
)}
); diff --git a/webui2/src/components/shared/__snapshots__/pagination.test.tsx.snap b/webui2/src/components/shared/__snapshots__/pagination.test.tsx.snap index 5a02f0b58a61f8a200728fea93a94cc1fb9c60ee..115538e21d6cf992ae066664352a3fb17a6e8dd1 100644 --- a/webui2/src/components/shared/__snapshots__/pagination.test.tsx.snap +++ b/webui2/src/components/shared/__snapshots__/pagination.test.tsx.snap @@ -16,10 +16,10 @@ exports[`Pagination/Default matches snapshot 1`] = `