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",
Quentin Gliech and Claude Opus 4.6 (1M context) created
Replace dangerouslySetInnerHTML with native React rendering via
Shiki's codeToHast → hast-util-to-jsx-runtime pipeline.
Features:
- Clickable line numbers in a sticky gutter
- Click to select a line (#L12 in URL hash)
- Shift+click to select a range (#L12:25)
- Selected lines highlighted with accent background
- URL hash syncs both ways (navigation + popstate)
- Scrolls to selected line on initial load
Also fix dark mode theme sync — add CSS rule to swap Shiki's
--shiki-dark variables when .dark class is active.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
webui2/package.json | 1
webui2/pnpm-lock.yaml | 3
webui2/src/components/code/file-viewer.tsx | 219 +++++++++++++++++++++--
webui2/src/index.css | 10 +
4 files changed, 206 insertions(+), 27 deletions(-)
@@ -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",
@@ -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)
@@ -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<HighlighterCore> | null = null;
function getHighlighter(): Promise<HighlighterCore> {
@@ -28,15 +32,12 @@ function getHighlighter(): Promise<HighlighterCore> {
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<unknown>;
}
const LANG_MAP: Record<string, LangEntry> = {
- // 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<string, LangEntry> = {
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<string, LangEntry> = {
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) {
</div>
);
}
- const [highlighted, setHighlighted] = useState<{ html: string; lineCount: number } | null>(null);
+
+ const [highlighted, setHighlighted] = useState<{ node: ReactNode; lineCount: number } | null>(
+ null,
+ );
+ const [selectedRange, setSelectedRange] = useState<LineRange | null>(() =>
+ 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<typeof highlighter.loadLanguage>[0]);
+ await highlighter.loadLanguage(
+ langModule as Parameters<typeof highlighter.loadLanguage>[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 <FileViewerSkeleton />;
- 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)}
</div>
) : (
- <div
- className="overflow-x-auto font-mono text-xs leading-5 [&_.shiki]:!bg-transparent [&_pre]:px-4 [&_pre]:py-4"
- dangerouslySetInnerHTML={{ __html: html }}
- />
+ <CodeWithLineNumbers
+ lineCount={lineCount}
+ selectedRange={selectedRange}
+ onLineClick={handleLineClick}
+ >
+ {highlighted.node}
+ </CodeWithLineNumbers>
)}
</div>
);
}
+// ── 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 (
+ <div className="flex overflow-x-auto font-mono text-xs leading-5">
+ {/* Line number gutter */}
+ <div
+ className="bg-muted/20 border-border sticky left-0 border-r py-4 text-right select-none"
+ aria-hidden
+ >
+ {Array.from({ length: lineCount }, (_, i) => {
+ const line = i + 1;
+ const selected = isLineSelected(line, selectedRange);
+ return (
+ <a
+ key={line}
+ id={`L${line}`}
+ href={`#L${line}`}
+ className={cn(
+ "block px-4 text-muted-foreground/50 hover:text-muted-foreground",
+ selected && "bg-accent/40 text-accent-foreground",
+ )}
+ onClick={(e) => {
+ e.preventDefault();
+ onLineClick(line, e.shiftKey);
+ }}
+ >
+ {line}
+ </a>
+ );
+ })}
+ </div>
+
+ {/* Code content — Shiki renders <pre><code><span class="line">... */}
+ <div
+ className={cn(
+ "flex-1 py-4 [&_.shiki]:!bg-transparent [&_pre]:!m-0 [&_pre]:!p-0 [&_code_.line]:block [&_code_.line]:px-4",
+ selectedRange && "[&_code_.line.highlighted]:bg-accent/40",
+ )}
+ >
+ <LineHighlighter selectedRange={selectedRange} lineCount={lineCount}>
+ {children}
+ </LineHighlighter>
+ </div>
+ </div>
+ );
+}
+
+// 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 (
+ <style>{`${selectors.join(",")}{background:var(--color-accent);opacity:0.4;background:color-mix(in srgb, var(--color-accent) 40%, transparent)}`}</style>
+ );
+ }, [selectedRange, lineCount]);
+
+ return (
+ <div className="line-highlight-scope">
+ {style}
+ {children}
+ </div>
+ );
+}
+
+// ── Utilities ─────────────────────────────────────────────────────────────────
+
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@@ -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;
+}
+