diff --git a/webui2/package.json b/webui2/package.json index c0f08deae943001450154606b6f85b2619e6b0ce..a6da49516da772262879b113c49762002dd37492 100644 --- a/webui2/package.json +++ b/webui2/package.json @@ -26,6 +26,7 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "graphql": "^16.9.0", + "hast-util-to-jsx-runtime": "^2.3.6", "lucide-react": "^1.7.0", "radix-ui": "^1.4.3", "react": "^19.1.0", diff --git a/webui2/pnpm-lock.yaml b/webui2/pnpm-lock.yaml index 0db8cc789160a35312aa0320269ed199499ee204..c6e36fc27d265715768b12dc3acbefd7247799bd 100644 --- a/webui2/pnpm-lock.yaml +++ b/webui2/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: graphql: specifier: ^16.9.0 version: 16.13.2 + hast-util-to-jsx-runtime: + specifier: ^2.3.6 + version: 2.3.6 lucide-react: specifier: ^1.7.0 version: 1.7.0(react@19.2.4) diff --git a/webui2/src/components/code/file-viewer.tsx b/webui2/src/components/code/file-viewer.tsx index 09db0dda60f2a4e3f43069d7a3a3ea2d80a15f78..95b04fd65afd2130724bc8edcaedb32740617db5 100644 --- a/webui2/src/components/code/file-viewer.tsx +++ b/webui2/src/components/code/file-viewer.tsx @@ -1,17 +1,21 @@ -// Syntax-highlighted file viewer with line numbers and copy button. -// Uses Shiki (VS Code's grammar engine) for accurate highlighting. -// The highlighter is created lazily on first use and cached. +// Syntax-highlighted file viewer with clickable line numbers. +// Uses Shiki codeToHast → hast-util-to-jsx-runtime for native React rendering. +// Line selection syncs with the URL hash (e.g. #L12 or #L12:25). +import { toJsxRuntime } from "hast-util-to-jsx-runtime"; import { Copy } from "lucide-react"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback, useMemo, Fragment, type ReactNode } from "react"; +import { jsx, jsxs } from "react/jsx-runtime"; 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"; +import { cn } from "@/lib/utils"; + +// ── Shiki highlighter (lazy singleton) ──────────────────────────────────────── -// Lazy singleton — created once, reused across all FileViewer instances. let highlighterPromise: Promise | null = null; function getHighlighter(): Promise { @@ -28,15 +32,12 @@ function getHighlighter(): Promise { 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") }, @@ -45,11 +46,9 @@ const LANG_MAP: Record = { 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") }, @@ -59,31 +58,24 @@ const LANG_MAP: Record = { 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") }, }; @@ -94,6 +86,32 @@ function getLangEntry(path: string): LangEntry | undefined { return LANG_MAP[ext] ?? LANG_MAP[filename]; } +// ── Line selection from URL hash ────────────────────────────────────────────── + +interface LineRange { + start: number; + end: number; +} + +function parseHash(hash: string): LineRange | null { + const match = /^#?L(\d+)(?::(\d+))?$/.exec(hash); + if (!match) return null; + const start = parseInt(match[1]!, 10); + const end = match[2] ? parseInt(match[2], 10) : start; + return { start: Math.min(start, end), end: Math.max(start, end) }; +} + +function buildHash(range: LineRange): string { + return range.start === range.end ? `#L${range.start}` : `#L${range.start}:${range.end}`; +} + +function isLineSelected(line: number, range: LineRange | null): boolean { + if (!range) return false; + return line >= range.start && line <= range.end; +} + +// ── Component ───────────────────────────────────────────────────────────────── + interface FileViewerProps { blob: GitBlob | null; } @@ -111,11 +129,54 @@ export function FileViewer({ blob }: FileViewerProps) { ); } - const [highlighted, setHighlighted] = useState<{ html: string; lineCount: number } | null>(null); + + const [highlighted, setHighlighted] = useState<{ node: ReactNode; lineCount: number } | null>( + null, + ); + const [selectedRange, setSelectedRange] = useState(() => + parseHash(window.location.hash), + ); + + // Sync hash → state on popstate + useEffect(() => { + function onHashChange() { + setSelectedRange(parseHash(window.location.hash)); + } + window.addEventListener("hashchange", onHashChange); + return () => window.removeEventListener("hashchange", onHashChange); + }, []); + + // Scroll to selected line on initial load + useEffect(() => { + if (selectedRange && highlighted) { + const el = document.getElementById(`L${selectedRange.start}`); + el?.scrollIntoView({ block: "center" }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- only scroll on first render + }, [highlighted]); + + const handleLineClick = useCallback( + (lineNumber: number, shiftKey: boolean) => { + let newRange: LineRange; + if (shiftKey && selectedRange) { + // Extend from the existing anchor + const anchor = selectedRange.start; + newRange = { + start: Math.min(anchor, lineNumber), + end: Math.max(anchor, lineNumber), + }; + } else { + newRange = { start: lineNumber, end: lineNumber }; + } + setSelectedRange(newRange); + window.history.replaceState(null, "", buildHash(newRange)); + }, + [selectedRange], + ); useEffect(() => { if (blob.isBinary || !blob.text) { - setHighlighted({ html: "", lineCount: 0 }); + setHighlighted({ node: null, lineCount: 0 }); return; } setHighlighted(null); @@ -129,7 +190,9 @@ export function FileViewer({ blob }: FileViewerProps) { if (entry) { try { const langModule = await entry.load(); - await highlighter.loadLanguage(langModule as Parameters[0]); + await highlighter.loadLanguage( + langModule as Parameters[0], + ); lang = entry.id; } catch { // Language not available — fall back to plain text @@ -138,13 +201,19 @@ export function FileViewer({ blob }: FileViewerProps) { if (cancelled) return; - const html = highlighter.codeToHtml(blob.text!, { + const hast = highlighter.codeToHast(blob.text!, { lang, themes: { light: "github-light", dark: "github-dark" }, }); + const node = toJsxRuntime(hast, { + Fragment, + jsx, + jsxs, + }); + setHighlighted({ - html, + node, lineCount: blob.text!.split("\n").length, }); })(); @@ -155,7 +224,7 @@ export function FileViewer({ blob }: FileViewerProps) { }, [blob]); if (highlighted === null) return ; - const { html, lineCount } = highlighted; + const { lineCount } = highlighted; function copyToClipboard() { if (blob?.text) void navigator.clipboard.writeText(blob.text); @@ -184,15 +253,111 @@ export function FileViewer({ blob }: FileViewerProps) { Binary file — {formatBytes(blob.size)} ) : ( -
+ + {highlighted.node} + )}
); } +// ── Line numbers + highlighting ─────────────────────────────────────────────── + +interface CodeWithLineNumbersProps { + lineCount: number; + selectedRange: LineRange | null; + onLineClick: (line: number, shiftKey: boolean) => void; + children: ReactNode; +} + +function CodeWithLineNumbers({ + lineCount, + selectedRange, + onLineClick, + children, +}: CodeWithLineNumbersProps) { + return ( +
+ {/* Line number gutter */} +
+ {Array.from({ length: lineCount }, (_, i) => { + const line = i + 1; + const selected = isLineSelected(line, selectedRange); + return ( + { + e.preventDefault(); + onLineClick(line, e.shiftKey); + }} + > + {line} + + ); + })} +
+ + {/* Code content — Shiki renders
... */}
+      
+ + {children} + +
+
+ ); +} + +// Wraps Shiki output and adds "highlighted" class to selected .line spans via CSS +function LineHighlighter({ + selectedRange, + lineCount, + children, +}: { + selectedRange: LineRange | null; + lineCount: number; + children: ReactNode; +}) { + // Generate a CSS rule that highlights the selected lines via :nth-child + const style = useMemo(() => { + if (!selectedRange) return undefined; + const selectors: string[] = []; + for (let i = selectedRange.start; i <= selectedRange.end && i <= lineCount; i++) { + selectors.push(`.line-highlight-scope code > .line:nth-child(${i})`); + } + if (selectors.length === 0) return undefined; + return ( + + ); + }, [selectedRange, lineCount]); + + return ( +
+ {style} + {children} +
+ ); +} + +// ── Utilities ───────────────────────────────────────────────────────────────── + function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; diff --git a/webui2/src/index.css b/webui2/src/index.css index 6ee045db2db0706ef7b0e63d00f25a0ad9118220..59bbe507ebaa52c527b87e3a181c185dd5b6bf7d 100644 --- a/webui2/src/index.css +++ b/webui2/src/index.css @@ -111,3 +111,13 @@ body { color: var(--foreground); } +/* ── Shiki dual-theme: swap to dark variables when .dark is active ───────── */ +.dark .shiki, +.dark .shiki span { + color: var(--shiki-dark) !important; + background-color: var(--shiki-dark-bg) !important; + font-style: var(--shiki-dark-font-style) !important; + font-weight: var(--shiki-dark-font-weight) !important; + text-decoration: var(--shiki-dark-text-decoration) !important; +} +