changeset 191:a06710325c30 hg-web

[HgWeb] Fully working copy.
author MrJuneJune <me@mrjunejune.com>
date Sat, 24 Jan 2026 21:51:51 -0800
parents a2725419f988
children b818a4561a3c
files hg-web/BUILD hg-web/src/index.html hg-web/src/repo-browser.tsx third_party/highlight/highlight.js
diffstat 4 files changed, 957 insertions(+), 83 deletions(-) [+]
line wrap: on
line diff
--- a/hg-web/BUILD	Sat Jan 24 21:06:42 2026 -0800
+++ b/hg-web/BUILD	Sat Jan 24 21:51:51 2026 -0800
@@ -19,6 +19,7 @@
   deps = [
     ":src_ts_files",
     "//markdown_converter:markdown_to_html_wasm",
+    "//third_party/highlight:js",
   ],
   visibility = ["//visibility:public"],
 )
--- a/hg-web/src/index.html	Sat Jan 24 21:06:42 2026 -0800
+++ b/hg-web/src/index.html	Sat Jan 24 21:51:51 2026 -0800
@@ -6,7 +6,6 @@
     <title>Zenbu Repository</title>
     <link rel="stylesheet" href="/a11y-dark.min.css"  media="(prefers-color-scheme: dark)">
     <link rel="stylesheet" href="/a11y-light.min.css" media="(prefers-color-scheme: light)">
-    <script src="/highlight.min.js"></script>
     <link rel="icon" type="image/svg+xml" href="/public/epi_all_colors.svg">
   </head>
   <body>
@@ -14,5 +13,6 @@
       <div id="root"></div>
     </main>
     <script type="module" src="/page.js"></script>
+    <script src="/highlight.min.js"></script>
   </body>
 </html>
--- a/hg-web/src/repo-browser.tsx	Sat Jan 24 21:06:42 2026 -0800
+++ b/hg-web/src/repo-browser.tsx	Sat Jan 24 21:51:51 2026 -0800
@@ -1,32 +1,121 @@
-import React, { useState, useEffect, useRef } from 'react';
+import React, { useState, useEffect, useRef, useCallback } from 'react';
 import createMarkdownModule from 'markdown_converter/markdown_to_html_wasm/markdown_to_html_bin.js';
+import hljs from 'third_party/highlight/highlight.min.js';
 
-// --- ICONS (Using CDN Links) ---
+// --- ICONS (served as static files) ---
 const ICONS = {
-  folder: "https://cdn-icons-png.flaticon.com/512/11471/11471391.png",
-  file: "https://raw.githubusercontent.com/PKief/vscode-material-icon-theme/main/icons/document.svg",
-  home: "https://cdn-icons-png.flaticon.com/512/1946/1946488.png",
+  folder: "/icons/folder.png",
+  file: "/icons/file.svg",
+  home: "/icons/home.png",
   repo: "/public/epi_all_colors.svg",
-  clone: "https://cdn-icons-png.flaticon.com/512/11471/11471391.png"
+  close: "/icons/close.png"
 };
 
+// SVG Icons for theme toggle
+const SunIcon = ({ color = "currentColor" }: { color?: string }) => (
+  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+    <circle cx="12" cy="12" r="5"/>
+    <line x1="12" y1="1" x2="12" y2="3"/>
+    <line x1="12" y1="21" x2="12" y2="23"/>
+    <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
+    <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
+    <line x1="1" y1="12" x2="3" y2="12"/>
+    <line x1="21" y1="12" x2="23" y2="12"/>
+    <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
+    <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
+  </svg>
+);
+
+const MoonIcon = ({ color = "currentColor" }: { color?: string }) => (
+  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+    <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
+  </svg>
+);
+
 const API_BASE = '/api/repo';
 
+// File extensions that should be displayed as code
+const CODE_EXTENSIONS = new Set([
+  'js', 'jsx', 'ts', 'tsx', 'py', 'rb', 'java', 'c', 'cpp', 'h', 'hpp',
+  'cs', 'go', 'rs', 'swift', 'kt', 'scala', 'php', 'pl', 'sh', 'bash',
+  'zsh', 'fish', 'ps1', 'bat', 'cmd', 'html', 'htm', 'css', 'scss',
+  'sass', 'less', 'json', 'xml', 'yaml', 'yml', 'toml', 'ini', 'cfg',
+  'conf', 'md', 'markdown', 'txt', 'log', 'sql', 'graphql', 'vue',
+  'svelte', 'astro', 'prisma', 'dockerfile', 'makefile', 'cmake',
+  'gradle', 'pom', 'lock', 'gitignore', 'env', 'example', 'sample'
+]);
+
+// Prefetch cache
+const prefetchCache = new Map<string, Promise<any>>();
+
+function isCodeFile(filename: string): boolean {
+  const ext = filename.split('.').pop()?.toLowerCase() || '';
+  const basename = filename.toLowerCase();
+  return CODE_EXTENSIONS.has(ext) ||
+         CODE_EXTENSIONS.has(basename) ||
+         basename === 'dockerfile' ||
+         basename === 'makefile' ||
+         basename.startsWith('.');
+}
+
+function isMarkdownFile(filename: string): boolean {
+  const ext = filename.split('.').pop()?.toLowerCase() || '';
+  return ext === 'md' || ext === 'markdown';
+}
+
+function prefetchDirectory(path: string): void {
+  const cacheKey = `dir:${path}`;
+  if (prefetchCache.has(cacheKey)) return;
+
+  const url = path
+    ? `${API_BASE}/list?path=${encodeURIComponent(path)}`
+    : `${API_BASE}/list`;
+
+  prefetchCache.set(cacheKey, fetch(url).then(r => r.json()).catch(() => null));
+}
+
+function prefetchFile(path: string): void {
+  const cacheKey = `file:${path}`;
+  if (prefetchCache.has(cacheKey)) return;
+
+  prefetchCache.set(cacheKey,
+    fetch(`${API_BASE}/file?path=${encodeURIComponent(path)}`)
+      .then(r => r.ok ? r.text() : null)
+      .catch(() => null)
+  );
+}
+
+async function getCachedFile(path: string): Promise<string | null> {
+  const cacheKey = `file:${path}`;
+  if (prefetchCache.has(cacheKey)) {
+    return prefetchCache.get(cacheKey);
+  }
+  prefetchFile(path);
+  return prefetchCache.get(cacheKey)!;
+}
+
 /**
  * Component: Styles
- * Injected CSS for the polished look
+ * Injected CSS for the polished look with dark/light mode support
  */
-const GlobalStyles = () => (
+const GlobalStyles = ({ isDark }: { isDark: boolean }) => (
   <style>{`
     :root {
-      --bg-color: #ffffff;
-      --bg-subtle: #f6f8fa;
-      --border-color: #d0d7de;
-      --accent-color: #0969da;
-      --text-primary: #1f2328;
-      --text-secondary: #656d76;
-      --hover-color: #f3f4f6;
+      --bg-color: ${isDark ? '#0d1117' : '#ffffff'};
+      --bg-subtle: ${isDark ? '#161b22' : '#f6f8fa'};
+      --bg-code: ${isDark ? '#1c2128' : '#f6f8fa'};
+      --border-color: ${isDark ? '#30363d' : '#d0d7de'};
+      --accent-color: ${isDark ? '#58a6ff' : '#0969da'};
+      --text-primary: ${isDark ? '#e6edf3' : '#1f2328'};
+      --text-secondary: ${isDark ? '#8b949e' : '#656d76'};
+      --hover-color: ${isDark ? '#1c2128' : '#f3f4f6'};
       --radius: 6px;
+      --code-bg: ${isDark ? '#161b22' : '#ffffff'};
+    }
+
+    body {
+      background: var(--bg-color);
+      transition: background 0.2s;
     }
 
     .repo-container {
@@ -48,6 +137,29 @@
     .header h1 { margin: 0; font-size: 24px; font-weight: 600; }
     .description { color: var(--text-secondary); margin: 0; font-size: 14px; }
 
+    /* Theme Toggle */
+    .theme-toggle {
+      margin-left: auto;
+      background: var(--bg-subtle);
+      border: 1px solid var(--border-color);
+      border-radius: var(--radius);
+      padding: 8px 12px;
+      cursor: pointer;
+      display: flex;
+      align-items: center;
+      gap: 6px;
+      color: var(--text-secondary);
+      font-size: 13px;
+      transition: all 0.2s;
+    }
+    .theme-toggle:hover {
+      background: var(--hover-color);
+      color: var(--text-primary);
+    }
+    .theme-toggle svg {
+      flex-shrink: 0;
+    }
+
     /* Clone Box */
     .clone-box {
       background: var(--bg-subtle);
@@ -60,9 +172,9 @@
       align-items: center;
     }
     .clone-label { font-weight: 600; font-size: 13px; margin-right: 10px; color: var(--text-primary); }
-    .clone-url { 
+    .clone-url {
       font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
-      background: white;
+      background: var(--bg-color);
       border: 1px solid var(--border-color);
       padding: 4px 8px;
       border-radius: 4px;
@@ -80,9 +192,9 @@
       color: var(--text-secondary);
       padding: 8px 0;
     }
-    #breadcrumb a { 
-      color: var(--accent-color); 
-      text-decoration: none; 
+    #breadcrumb a {
+      color: var(--accent-color);
+      text-decoration: none;
       border-radius: 4px;
       padding: 2px 6px;
     }
@@ -95,6 +207,7 @@
       border: 1px solid var(--border-color);
       border-radius: var(--radius);
       overflow: hidden;
+      background: var(--bg-color);
     }
     .file-header {
       background: var(--bg-subtle);
@@ -105,10 +218,10 @@
       color: var(--text-secondary);
     }
     .empty-state { padding: 40px; text-align: center; color: var(--text-secondary); }
-    .error-message { 
-      padding: 15px; border: 1px solid #ffdce0; 
-      background: #ffebe9; color: #cf222e; 
-      border-radius: var(--radius); margin-bottom: 20px; 
+    .error-message {
+      padding: 15px; border: 1px solid ${isDark ? '#f8514966' : '#ffdce0'};
+      background: ${isDark ? '#f8514926' : '#ffebe9'}; color: ${isDark ? '#f85149' : '#cf222e'};
+      border-radius: var(--radius); margin-bottom: 20px;
     }
 
     /* File Row */
@@ -121,25 +234,125 @@
     }
     .file-row:last-child { border-bottom: none; }
     .file-row:hover { background: var(--hover-color); }
-    
-    .file-row .icon img { width: 20px; height: 20px; vertical-align: middle; margin-right: 12px; }
-    .file-row .name a { 
-      color: var(--text-primary); 
-      text-decoration: none; 
-      font-size: 14px; 
+
+    .file-row .icon img {
+      width: 20px;
+      height: 20px;
+      vertical-align: middle;
+      margin-right: 12px;
+      filter: ${isDark ? 'invert(0.8)' : 'none'};
+    }
+    .file-row .name a {
+      color: var(--text-primary);
+      text-decoration: none;
+      font-size: 14px;
     }
     .file-row .name a:hover { color: var(--accent-color); text-decoration: underline; }
 
     /* Readme */
     #readmeSection { margin-top: 32px; border: 1px solid var(--border-color); border-radius: var(--radius); }
-    .readme-header { 
-      background: var(--bg-subtle); 
-      padding: 10px 16px; 
-      font-size: 12px; font-weight: 600; 
+    .readme-header {
+      background: var(--bg-subtle);
+      padding: 10px 16px;
+      font-size: 12px; font-weight: 600;
       border-bottom: 1px solid var(--border-color);
       display: flex; align-items: center; gap: 8px;
     }
-    #readmeContent { padding: 32px; background: white; overflow-x: auto; }
+    .readme-header img {
+      filter: ${isDark ? 'invert(0.7)' : 'none'};
+    }
+    #readmeContent { padding: 32px; background: var(--bg-color); overflow-x: auto; color: var(--text-primary); }
+
+    /* File Viewer */
+    .file-viewer-overlay {
+      position: fixed;
+      inset: 0;
+      background: ${isDark ? 'rgba(0,0,0,0.7)' : 'rgba(0,0,0,0.5)'};
+      display: flex;
+      justify-content: center;
+      align-items: center;
+      z-index: 1000;
+      padding: 20px;
+    }
+    .file-viewer {
+      background: var(--bg-color);
+      border: 1px solid var(--border-color);
+      border-radius: var(--radius);
+      width: 100%;
+      max-width: 900px;
+      max-height: 90vh;
+      display: flex;
+      flex-direction: column;
+      box-shadow: 0 8px 32px rgba(0,0,0,0.3);
+    }
+    .file-viewer-header {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      padding: 12px 16px;
+      background: var(--bg-subtle);
+      border-bottom: 1px solid var(--border-color);
+      border-radius: var(--radius) var(--radius) 0 0;
+    }
+    .file-viewer-title {
+      font-weight: 600;
+      font-size: 14px;
+      color: var(--text-primary);
+      display: flex;
+      align-items: center;
+      gap: 8px;
+    }
+    .file-viewer-close {
+      background: transparent;
+      border: none;
+      cursor: pointer;
+      padding: 4px;
+      border-radius: 4px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+    }
+    .file-viewer-close:hover {
+      background: var(--hover-color);
+    }
+    .file-viewer-close img {
+      width: 16px;
+      height: 16px;
+      filter: ${isDark ? 'invert(0.7)' : 'none'};
+      opacity: 0.7;
+    }
+    .file-viewer-content {
+      overflow: auto;
+      flex: 1;
+    }
+    .file-viewer-content pre {
+      margin: 0;
+      padding: 16px;
+      background: var(--code-bg);
+      font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
+      font-size: 13px;
+      line-height: 1.5;
+      overflow-x: auto;
+    }
+    .file-viewer-content code {
+      background: transparent;
+      padding: 0;
+    }
+    .file-viewer-loading {
+      padding: 40px;
+      text-align: center;
+      color: var(--text-secondary);
+    }
+    .file-viewer-line-numbers {
+      display: inline-block;
+      user-select: none;
+      text-align: right;
+      padding-right: 16px;
+      margin-right: 16px;
+      border-right: 1px solid var(--border-color);
+      color: var(--text-secondary);
+      opacity: 0.5;
+    }
   `}</style>
 );
 
@@ -193,9 +406,174 @@
 }
 
 /**
+ * Component: FileViewer
+ * Shows file content inline with syntax highlighting
+ */
+function FileViewer({ filePath, onClose }: { filePath: string; onClose: () => void }) {
+  const [content, setContent] = useState<string | null>(null);
+  const [loading, setLoading] = useState(true);
+  const codeRef = useRef<HTMLElement>(null);
+
+  const filename = filePath.split('/').pop() || filePath;
+
+  useEffect(() => {
+    setLoading(true);
+    getCachedFile(filePath).then((text) => {
+      setContent(text);
+      setLoading(false);
+    });
+  }, [filePath]);
+
+  useEffect(() => {
+    if (content && codeRef.current) {
+      hljs.highlightElement(codeRef.current);
+    }
+  }, [content]);
+
+  // Close on escape key
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  // Get language from file extension for highlight.js
+  const getLanguage = () => {
+    const ext = filename.split('.').pop()?.toLowerCase() || '';
+    const langMap: Record<string, string> = {
+      js: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript',
+      py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java',
+      c: 'c', cpp: 'cpp', h: 'c', hpp: 'cpp', cs: 'csharp',
+      sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'bash',
+      json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml',
+      html: 'html', htm: 'html', css: 'css', scss: 'scss', sass: 'scss',
+      sql: 'sql', md: 'markdown', markdown: 'markdown', xml: 'xml',
+      dockerfile: 'dockerfile', makefile: 'makefile'
+    };
+    return langMap[ext] || 'plaintext';
+  };
+
+  const addLineNumbers = (text: string) => {
+    const lines = text.split('\n');
+    return lines.map((_, i) => i + 1).join('\n');
+  };
+
+  return (
+    <div className="file-viewer-overlay" onClick={onClose}>
+      <div className="file-viewer" onClick={(e) => e.stopPropagation()}>
+        <div className="file-viewer-header">
+          <span className="file-viewer-title">
+            <img src={ICONS.file} alt="" style={{ width: 16, height: 16 }} />
+            {filename}
+          </span>
+          <button className="file-viewer-close" onClick={onClose} title="Close (Esc)">
+            <img src={ICONS.close} alt="Close" />
+          </button>
+        </div>
+        <div className="file-viewer-content">
+          {loading ? (
+            <div className="file-viewer-loading">Loading...</div>
+          ) : content ? (
+            <pre style={{ display: 'flex' }}>
+              <span className="file-viewer-line-numbers">{addLineNumbers(content)}</span>
+              <code ref={codeRef} className={`language-${getLanguage()}`}>{content}</code>
+            </pre>
+          ) : (
+            <div className="file-viewer-loading">Unable to load file</div>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}
+
+/**
+ * Component: MarkdownViewerModal
+ * Shows markdown content rendered in a modal
+ */
+function MarkdownViewerModal({ filePath, onClose }: { filePath: string; onClose: () => void }) {
+  const [content, setContent] = useState<string | null>(null);
+  const [loading, setLoading] = useState(true);
+  const contentRef = useRef<HTMLDivElement>(null);
+  const moduleRef = useRef<any>(null);
+  const [wasmReady, setWasmReady] = useState(false);
+
+  const filename = filePath.split('/').pop() || filePath;
+
+  useEffect(() => {
+    createMarkdownModule().then((Module: any) => {
+      moduleRef.current = Module;
+      setWasmReady(true);
+    });
+  }, []);
+
+  useEffect(() => {
+    setLoading(true);
+    getCachedFile(filePath).then((text) => {
+      setContent(text);
+      setLoading(false);
+    });
+  }, [filePath]);
+
+  useEffect(() => {
+    if (!content || !wasmReady || !contentRef.current || !moduleRef.current) return;
+
+    const Module = moduleRef.current;
+    const markdownToHtmlPtr = Module.cwrap('markdown_to_html', 'number', ['string']);
+    const markdownFree = Module.cwrap('markdown_free', null, ['number']);
+
+    const ptr = markdownToHtmlPtr(content);
+    const html = Module.UTF8ToString(ptr);
+    markdownFree(ptr);
+    contentRef.current.innerHTML = html;
+  }, [content, wasmReady]);
+
+  // Close on escape key
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  return (
+    <div className="file-viewer-overlay" onClick={onClose}>
+      <div className="file-viewer" onClick={(e) => e.stopPropagation()}>
+        <div className="file-viewer-header">
+          <span className="file-viewer-title">
+            <img src={ICONS.file} alt="" style={{ width: 16, height: 16 }} />
+            {filename}
+          </span>
+          <button className="file-viewer-close" onClick={onClose} title="Close (Esc)">
+            <img src={ICONS.close} alt="Close" />
+          </button>
+        </div>
+        <div className="file-viewer-content">
+          {loading || !wasmReady ? (
+            <div className="file-viewer-loading">Loading...</div>
+          ) : content ? (
+            <div id="readmeContent" ref={contentRef} style={{ padding: 32 }} />
+          ) : (
+            <div className="file-viewer-loading">Unable to load file</div>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}
+
+/**
  * Component: FileList
  */
-function FileList({ directories, files, onNavigate }) {
+function FileList({ directories, files, onNavigate, onOpenFile }: {
+  directories: any[];
+  files: any[];
+  onNavigate: (path: string) => void;
+  onOpenFile: (path: string) => void;
+}) {
   const isEmpty = directories.length === 0 && files.length === 0;
 
   if (isEmpty) {
@@ -215,21 +593,24 @@
 
       <div id="fileListBody">
         {directories.map((dir) => (
-          <FileRow 
+          <FileRow
             key={dir.abspath}
             item={dir}
             iconUrl={ICONS.folder}
             isDir={true}
             onNavigate={onNavigate}
+            onOpenFile={onOpenFile}
           />
         ))}
 
         {files.map((file) => (
-          <FileRow 
+          <FileRow
             key={file.abspath}
             item={file}
             iconUrl={ICONS.file}
             isDir={false}
+            onNavigate={onNavigate}
+            onOpenFile={onOpenFile}
           />
         ))}
       </div>
@@ -240,27 +621,45 @@
 /**
  * Component: FileRow
  */
-function FileRow({ item, iconUrl, isDir, onNavigate }) {
-  const handleClick = (e) => {
+function FileRow({ item, iconUrl, isDir, onNavigate, onOpenFile }: {
+  item: { abspath: string; basename: string };
+  iconUrl: string;
+  isDir: boolean;
+  onNavigate: (path: string) => void;
+  onOpenFile: (path: string) => void;
+}) {
+  const handleClick = (e: React.MouseEvent) => {
+    e.preventDefault();
     if (isDir) {
-      e.preventDefault();
       onNavigate(item.abspath);
+    } else if (isCodeFile(item.basename)) {
+      onOpenFile(item.abspath);
+    } else {
+      // For non-code files, open in new tab
+      window.open(`/api/repo/file?path=${encodeURIComponent(item.abspath)}`, '_blank');
     }
   };
 
-  const href = isDir 
+  const handleMouseEnter = () => {
+    // Prefetch on hover
+    if (isDir) {
+      prefetchDirectory(item.abspath);
+    } else if (isCodeFile(item.basename)) {
+      prefetchFile(item.abspath);
+    }
+  };
+
+  const href = isDir
     ? `?path=${encodeURIComponent(item.abspath)}`
     : `/api/repo/file?path=${encodeURIComponent(item.abspath)}`;
-  
-  const target = isDir ? undefined : "_blank";
 
   return (
-    <div className="file-row">
+    <div className="file-row" onMouseEnter={handleMouseEnter}>
       <span className="icon">
         <img src={iconUrl} alt={isDir ? "Directory" : "File"} />
       </span>
       <span className="name">
-        <a href={href} onClick={handleClick} target={target} rel="noreferrer">
+        <a href={href} onClick={handleClick}>
           {item.basename}
         </a>
       </span>
@@ -277,7 +676,7 @@
   const [wasmReady, setWasmReady] = useState(false);
 
   useEffect(() => {
-    createMarkdownModule().then((Module) => {
+    createMarkdownModule().then((Module: any) => {
       moduleRef.current = Module;
       setWasmReady(true);
     });
@@ -301,7 +700,7 @@
   return (
     <div id="readmeSection">
       <div className="readme-header">
-        <img src="https://img.icons8.com/material-outlined/24/000000/menu--v1.png" width="16" alt="" style={{opacity:0.5}} />
+        <img src={ICONS.file} width="16" alt="" style={{opacity:0.5}} />
         README.md
       </div>
       <div id="readmeContent" ref={contentRef}>
@@ -316,16 +715,28 @@
  */
 function RepoBrowser() {
   const [currentPath, setCurrentPath] = useState(getCurrentPath());
-  const [content, setContent] = useState({ files: [], directories: [] });
-  const [readme, setReadme] = useState(null);
-  const [error, setError] = useState(null);
+  const [content, setContent] = useState<{ files: any[]; directories: any[] }>({ files: [], directories: [] });
+  const [readme, setReadme] = useState<string | null>(null);
+  const [error, setError] = useState<string | null>(null);
   const [loading, setLoading] = useState(false);
+  const [isDarkMode, setIsDarkMode] = useState(() => {
+    // Check localStorage or system preference
+    const saved = localStorage.getItem('theme');
+    if (saved) return saved === 'dark';
+    return window.matchMedia('(prefers-color-scheme: dark)').matches;
+  });
+  const [viewingFile, setViewingFile] = useState<string | null>(null);
 
   function getCurrentPath() {
     const params = new URLSearchParams(window.location.search);
     return params.get('path') || '';
   }
 
+  // Persist theme preference
+  useEffect(() => {
+    localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
+  }, [isDarkMode]);
+
   useEffect(() => {
     const handlePopState = () => setCurrentPath(getCurrentPath());
     window.addEventListener('popstate', handlePopState);
@@ -337,33 +748,41 @@
     fetchReadme(currentPath);
   }, [currentPath]);
 
-  const navigate = (path) => {
+  const navigate = (path: string) => {
     const newUrl = path ? `?path=${encodeURIComponent(path)}` : '/';
     window.history.pushState({ path }, '', newUrl);
     setCurrentPath(path);
   };
 
-  const fetchDirectory = async (path) => {
+  const fetchDirectory = async (path: string) => {
     setLoading(true);
     setError(null);
     try {
-      const url = path 
-        ? `${API_BASE}/list?path=${encodeURIComponent(path)}` 
-        : `${API_BASE}/list`;
-      
-      const response = await fetch(url);
+      // Check prefetch cache first
+      const cacheKey = `dir:${path}`;
       let data;
-      if (response.ok)
-        data = await response.json();
+      if (prefetchCache.has(cacheKey)) {
+        data = await prefetchCache.get(cacheKey);
+        prefetchCache.delete(cacheKey); // Clear after use for fresh data next time
+      } else {
+        const url = path
+          ? `${API_BASE}/list?path=${encodeURIComponent(path)}`
+          : `${API_BASE}/list`;
+        const response = await fetch(url);
+        if (response.ok) {
+          data = await response.json();
+        }
+      }
 
-      if (data.error)
+      if (data?.error) {
         throw new Error(data.error);
-      
+      }
+
       setContent({
-        files: data.files || [],
-        directories: data.directories || []
+        files: data?.files || [],
+        directories: data?.directories || []
       });
-    } catch (err) {
+    } catch (err: any) {
       console.error('Error loading directory:', err);
       setError(err.message);
     } finally {
@@ -371,22 +790,37 @@
     }
   };
 
-  const fetchReadme = async (path) => {
+  const fetchReadme = async (path: string) => {
+    setReadme(null);
     const readmePath = path ? `${path}/README.md` : 'README.md';
-    const response = await fetch(`${API_BASE}/file?path=${encodeURIComponent(readmePath)}`);
-    console.log(response);
-    if (response.ok)
-    {
-      const text = await response.text();
-      setReadme(text);
+    try {
+      const response = await fetch(`${API_BASE}/file?path=${encodeURIComponent(readmePath)}`);
+      if (response.ok) {
+        const text = await response.text();
+        setReadme(text);
+      }
+    } catch (err) {
+      // Readme is optional, ignore errors
     }
   };
 
+  const handleOpenFile = useCallback((path: string) => {
+    setViewingFile(path);
+  }, []);
+
+  const handleCloseFile = useCallback(() => {
+    setViewingFile(null);
+  }, []);
+
+  const toggleTheme = () => {
+    setIsDarkMode(prev => !prev);
+  };
+
   return (
     <>
-      <GlobalStyles />
+      <GlobalStyles isDark={isDarkMode} />
       <div className="repo-container">
-        
+
         {/* Header */}
         <div className="header">
           <img src={ICONS.repo} alt="Repo" className="header-icon" />
@@ -394,6 +828,10 @@
             <h1>Zenbu Repository</h1>
             <p className="description">Browse and manage the mercurial codebase</p>
           </div>
+          <button className="theme-toggle" onClick={toggleTheme} title={isDarkMode ? 'Switch to light mode' : 'Switch to dark mode'}>
+            {isDarkMode ? <SunIcon color="#f0c674" /> : <MoonIcon color="#6e7681" />}
+            {isDarkMode ? 'Light' : 'Dark'}
+          </button>
         </div>
 
         {/* Clone Bar */}
@@ -406,24 +844,34 @@
 
         {/* Navigation & Content */}
         <Breadcrumb currentPath={currentPath} onNavigate={navigate} />
-        
+
         {error && <div className="error-message">Error: {error}</div>}
-        
+
         {loading ? (
-          <div className="file-list-container" style={{padding: '40px', textAlign: 'center', color:'#666'}}>
+          <div className="file-list-container" style={{padding: '40px', textAlign: 'center', color: 'var(--text-secondary)'}}>
              Loading files...
           </div>
         ) : (
           <>
-            <FileList 
-              directories={content.directories} 
-              files={content.files} 
-              onNavigate={navigate} 
+            <FileList
+              directories={content.directories}
+              files={content.files}
+              onNavigate={navigate}
+              onOpenFile={handleOpenFile}
             />
             <ReadmeViewer content={readme} />
           </>
         )}
       </div>
+
+      {/* File Viewer Modal */}
+      {viewingFile && (
+        isMarkdownFile(viewingFile) ? (
+          <MarkdownViewerModal filePath={viewingFile} onClose={handleCloseFile} />
+        ) : (
+          <FileViewer filePath={viewingFile} onClose={handleCloseFile} />
+        )
+      )}
     </>
   );
 }
--- a/third_party/highlight/highlight.js	Sat Jan 24 21:06:42 2026 -0800
+++ b/third_party/highlight/highlight.js	Sat Jan 24 21:51:51 2026 -0800
@@ -8163,4 +8163,429 @@
 })();
 
     hljs.registerLanguage('x86asm', hljsGrammar);
-  })();
\ No newline at end of file
+  })();
+  (function(){
+    var hljsGrammar = (function () {
+  'use strict';
+
+
+  /*
+  Language: Shell Session
+  Requires: bash.js
+  Author: TSUYUSATO Kitsune <[email protected]>
+  Category: common
+  Audit: 2020
+  */
+/*
+Language: Bash
+Author: vah <[email protected]>
+Contributrors: Benjamin Pannell <[email protected]>
+Website: https://www.gnu.org/software/bash/
+Category: common, scripting
+*/
+
+/** @type LanguageFn */
+  function bash(hljs) {
+    const regex = hljs.regex;
+    const VAR = {};
+    const BRACED_VAR = {
+      begin: /\$\{/,
+      end: /\}/,
+      contains: [
+        "self",
+        {
+          begin: /:-/,
+          contains: [ VAR ]
+        } // default values
+      ]
+    };
+    Object.assign(VAR, {
+      className: 'variable',
+      variants: [
+        { begin: regex.concat(/\$[\w\d#@][\w\d_]*/,
+          // negative look-ahead tries to avoid matching patterns that are not
+          // Perl at all like $ident$, @ident@, etc.
+          `(?![\\w\\d])(?![$])`) },
+        BRACED_VAR
+      ]
+    });
+  
+    const SUBST = {
+      className: 'subst',
+      begin: /\$\(/,
+      end: /\)/,
+      contains: [ hljs.BACKSLASH_ESCAPE ]
+    };
+    const COMMENT = hljs.inherit(
+      hljs.COMMENT(),
+      {
+        match: [
+          /(^|\s)/,
+          /#.*$/
+        ],
+        scope: {
+          2: 'comment'
+        }
+      }
+    );
+    const HERE_DOC = {
+      begin: /<<-?\s*(?=\w+)/,
+      starts: { contains: [
+        hljs.END_SAME_AS_BEGIN({
+          begin: /(\w+)/,
+          end: /(\w+)/,
+          className: 'string'
+        })
+      ] }
+    };
+    const QUOTE_STRING = {
+      className: 'string',
+      begin: /"/,
+      end: /"/,
+      contains: [
+        hljs.BACKSLASH_ESCAPE,
+        VAR,
+        SUBST
+      ]
+    };
+    SUBST.contains.push(QUOTE_STRING);
+    const ESCAPED_QUOTE = {
+      match: /\\"/
+    };
+    const APOS_STRING = {
+      className: 'string',
+      begin: /'/,
+      end: /'/
+    };
+    const ESCAPED_APOS = {
+      match: /\\'/
+    };
+    const ARITHMETIC = {
+      begin: /\$?\(\(/,
+      end: /\)\)/,
+      contains: [
+        {
+          begin: /\d+#[0-9a-f]+/,
+          className: "number"
+        },
+        hljs.NUMBER_MODE,
+        VAR
+      ]
+    };
+    const SH_LIKE_SHELLS = [
+      "fish",
+      "bash",
+      "zsh",
+      "sh",
+      "csh",
+      "ksh",
+      "tcsh",
+      "dash",
+      "scsh",
+    ];
+    const KNOWN_SHEBANG = hljs.SHEBANG({
+      binary: `(${SH_LIKE_SHELLS.join("|")})`,
+      relevance: 10
+    });
+    const FUNCTION = {
+      className: 'function',
+      begin: /\w[\w\d_]*\s*\(\s*\)\s*\{/,
+      returnBegin: true,
+      contains: [ hljs.inherit(hljs.TITLE_MODE, { begin: /\w[\w\d_]*/ }) ],
+      relevance: 0
+    };
+  
+    const KEYWORDS = [
+      "if",
+      "then",
+      "else",
+      "elif",
+      "fi",
+      "time",
+      "for",
+      "while",
+      "until",
+      "in",
+      "do",
+      "done",
+      "case",
+      "esac",
+      "coproc",
+      "function",
+      "select"
+    ];
+  
+    const LITERALS = [
+      "true",
+      "false"
+    ];
+  
+    // to consume paths to prevent keyword matches inside them
+    const PATH_MODE = { match: /(\/[a-z._-]+)+/ };
+  
+    // http://www.gnu.org/software/bash/manual/html_node/Shell-Builtin-Commands.html
+    const SHELL_BUILT_INS = [
+      "break",
+      "cd",
+      "continue",
+      "eval",
+      "exec",
+      "exit",
+      "export",
+      "getopts",
+      "hash",
+      "pwd",
+      "readonly",
+      "return",
+      "shift",
+      "test",
+      "times",
+      "trap",
+      "umask",
+      "unset"
+    ];
+  
+    const BASH_BUILT_INS = [
+      "alias",
+      "bind",
+      "builtin",
+      "caller",
+      "command",
+      "declare",
+      "echo",
+      "enable",
+      "help",
+      "let",
+      "local",
+      "logout",
+      "mapfile",
+      "printf",
+      "read",
+      "readarray",
+      "source",
+      "sudo",
+      "type",
+      "typeset",
+      "ulimit",
+      "unalias"
+    ];
+  
+    const ZSH_BUILT_INS = [
+      "autoload",
+      "bg",
+      "bindkey",
+      "bye",
+      "cap",
+      "chdir",
+      "clone",
+      "comparguments",
+      "compcall",
+      "compctl",
+      "compdescribe",
+      "compfiles",
+      "compgroups",
+      "compquote",
+      "comptags",
+      "comptry",
+      "compvalues",
+      "dirs",
+      "disable",
+      "disown",
+      "echotc",
+      "echoti",
+      "emulate",
+      "fc",
+      "fg",
+      "float",
+      "functions",
+      "getcap",
+      "getln",
+      "history",
+      "integer",
+      "jobs",
+      "kill",
+      "limit",
+      "log",
+      "noglob",
+      "popd",
+      "print",
+      "pushd",
+      "pushln",
+      "rehash",
+      "sched",
+      "setcap",
+      "setopt",
+      "stat",
+      "suspend",
+      "ttyctl",
+      "unfunction",
+      "unhash",
+      "unlimit",
+      "unsetopt",
+      "vared",
+      "wait",
+      "whence",
+      "where",
+      "which",
+      "zcompile",
+      "zformat",
+      "zftp",
+      "zle",
+      "zmodload",
+      "zparseopts",
+      "zprof",
+      "zpty",
+      "zregexparse",
+      "zsocket",
+      "zstyle",
+      "ztcp"
+    ];
+  
+    const GNU_CORE_UTILS = [
+      "chcon",
+      "chgrp",
+      "chown",
+      "chmod",
+      "cp",
+      "dd",
+      "df",
+      "dir",
+      "dircolors",
+      "ln",
+      "ls",
+      "mkdir",
+      "mkfifo",
+      "mknod",
+      "mktemp",
+      "mv",
+      "realpath",
+      "rm",
+      "rmdir",
+      "shred",
+      "sync",
+      "touch",
+      "truncate",
+      "vdir",
+      "b2sum",
+      "base32",
+      "base64",
+      "cat",
+      "cksum",
+      "comm",
+      "csplit",
+      "cut",
+      "expand",
+      "fmt",
+      "fold",
+      "head",
+      "join",
+      "md5sum",
+      "nl",
+      "numfmt",
+      "od",
+      "paste",
+      "ptx",
+      "pr",
+      "sha1sum",
+      "sha224sum",
+      "sha256sum",
+      "sha384sum",
+      "sha512sum",
+      "shuf",
+      "sort",
+      "split",
+      "sum",
+      "tac",
+      "tail",
+      "tr",
+      "tsort",
+      "unexpand",
+      "uniq",
+      "wc",
+      "arch",
+      "basename",
+      "chroot",
+      "date",
+      "dirname",
+      "du",
+      "echo",
+      "env",
+      "expr",
+      "factor",
+      // "false", // keyword literal already
+      "groups",
+      "hostid",
+      "id",
+      "link",
+      "logname",
+      "nice",
+      "nohup",
+      "nproc",
+      "pathchk",
+      "pinky",
+      "printenv",
+      "printf",
+      "pwd",
+      "readlink",
+      "runcon",
+      "seq",
+      "sleep",
+      "stat",
+      "stdbuf",
+      "stty",
+      "tee",
+      "test",
+      "timeout",
+      // "true", // keyword literal already
+      "tty",
+      "uname",
+      "unlink",
+      "uptime",
+      "users",
+      "who",
+      "whoami",
+      "yes"
+    ];
+  
+    return {
+      name: 'Bash',
+      aliases: [
+        'sh',
+        'zsh'
+      ],
+      keywords: {
+        $pattern: /\b[a-z][a-z0-9._-]+\b/,
+        keyword: KEYWORDS,
+        literal: LITERALS,
+        built_in: [
+          ...SHELL_BUILT_INS,
+          ...BASH_BUILT_INS,
+          // Shell modifiers
+          "set",
+          "shopt",
+          ...ZSH_BUILT_INS,
+          ...GNU_CORE_UTILS
+        ]
+      },
+      contains: [
+        KNOWN_SHEBANG, // to catch known shells and boost relevancy
+        hljs.SHEBANG(), // to catch unknown shells but still highlight the shebang
+        FUNCTION,
+        ARITHMETIC,
+        COMMENT,
+        HERE_DOC,
+        PATH_MODE,
+        QUOTE_STRING,
+        ESCAPED_QUOTE,
+        APOS_STRING,
+        ESCAPED_APOS,
+        VAR
+      ]
+    };
+  }
+}
+  return bash;
+})();
+
+    hljs.registerLanguage('bash', hljsGrammar);
+  })();