feat(web): add line numbers with GitHub-style line highlighting

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>

Change summary

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(-)

Detailed changes

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",

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)

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<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`;

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;
+}
+