Mercurial
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); + })();