Mercurial
view hg-web/src/repo-browser.tsx @ 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 |
line wrap: on
line source
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 (served as static files) --- const ICONS = { folder: "/icons/folder.png", file: "/icons/file.svg", home: "/icons/home.png", repo: "/public/epi_all_colors.svg", 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 with dark/light mode support */ const GlobalStyles = ({ isDark }: { isDark: boolean }) => ( <style>{` :root { --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 { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; max-width: 980px; margin: 40px auto; color: var(--text-primary); padding: 0 20px; } /* Header */ .header { display: flex; align-items: center; margin-bottom: 20px; gap: 15px; } .header-icon { width: 32px; height: 32px; opacity: 0.8; } .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); border: 1px solid var(--border-color); border-radius: var(--radius); padding: 12px 16px; margin-bottom: 24px; display: flex; justify-content: space-between; align-items: center; } .clone-label { font-weight: 600; font-size: 13px; margin-right: 10px; color: var(--text-primary); } .clone-url { font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; background: var(--bg-color); border: 1px solid var(--border-color); padding: 4px 8px; border-radius: 4px; font-size: 12px; color: var(--text-secondary); flex-grow: 1; } /* Breadcrumb */ #breadcrumb { display: flex; align-items: center; font-size: 14px; margin-bottom: 16px; color: var(--text-secondary); padding: 8px 0; } #breadcrumb a { color: var(--accent-color); text-decoration: none; border-radius: 4px; padding: 2px 6px; } #breadcrumb a:hover { background: var(--bg-subtle); text-decoration: underline; } #breadcrumb .separator { margin: 0 4px; color: var(--text-secondary); opacity: 0.5; } #breadcrumb .nav-item.active { font-weight: 600; color: var(--text-primary); padding: 2px 6px;} /* File List Table Structure */ .file-list-container { border: 1px solid var(--border-color); border-radius: var(--radius); overflow: hidden; background: var(--bg-color); } .file-header { background: var(--bg-subtle); border-bottom: 1px solid var(--border-color); padding: 12px 16px; font-size: 13px; font-weight: 600; color: var(--text-secondary); } .empty-state { padding: 40px; text-align: center; color: var(--text-secondary); } .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 */ .file-row { display: flex; align-items: center; padding: 10px 16px; border-bottom: 1px solid var(--border-color); transition: background 0.1s; } .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; 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; border-bottom: 1px solid var(--border-color); display: flex; align-items: center; gap: 8px; } .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> ); /** * Component: Breadcrumb */ function Breadcrumb({ currentPath, onNavigate }) { if (!currentPath) { return ( <nav id="breadcrumb"> <span className="nav-item active">root</span> </nav> ); } const parts = currentPath.split('/').filter(p => p); const crumbs = parts.map((part, index) => ({ name: part, fullPath: parts.slice(0, index + 1).join('/') })); return ( <nav id="breadcrumb"> <a href="/" onClick={(e) => { e.preventDefault(); onNavigate(''); }} title="Go to Root" > root </a> {crumbs.map((crumb, index) => { const isLast = index === crumbs.length - 1; return ( <React.Fragment key={crumb.fullPath}> <span className="separator">/</span> {isLast ? ( <span className="nav-item active">{crumb.name}</span> ) : ( <a href={`?path=${encodeURIComponent(crumb.fullPath)}`} onClick={(e) => { e.preventDefault(); onNavigate(crumb.fullPath); }} > {crumb.name} </a> )} </React.Fragment> ); })} </nav> ); } /** * 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, onOpenFile }: { directories: any[]; files: any[]; onNavigate: (path: string) => void; onOpenFile: (path: string) => void; }) { const isEmpty = directories.length === 0 && files.length === 0; if (isEmpty) { return ( <div className="file-list-container"> <div className="empty-state">This directory is empty.</div> </div> ); } return ( <div className="file-list-container"> {/* Optional header row like GitHub */} <div className="file-header"> Files </div> <div id="fileListBody"> {directories.map((dir) => ( <FileRow key={dir.abspath} item={dir} iconUrl={ICONS.folder} isDir={true} onNavigate={onNavigate} onOpenFile={onOpenFile} /> ))} {files.map((file) => ( <FileRow key={file.abspath} item={file} iconUrl={ICONS.file} isDir={false} onNavigate={onNavigate} onOpenFile={onOpenFile} /> ))} </div> </div> ); } /** * Component: FileRow */ 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) { 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 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)}`; return ( <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}> {item.basename} </a> </span> </div> ); } /** * Component: ReadmeViewer */ function ReadmeViewer({ content }: { content: string | null }) { const contentRef = useRef<HTMLDivElement>(null); const moduleRef = useRef<any>(null); const [wasmReady, setWasmReady] = useState(false); useEffect(() => { createMarkdownModule().then((Module: any) => { moduleRef.current = Module; setWasmReady(true); }); }, []); 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]); if (!content) return null; return ( <div id="readmeSection"> <div className="readme-header"> <img src={ICONS.file} width="16" alt="" style={{opacity:0.5}} /> README.md </div> <div id="readmeContent" ref={contentRef}> {!wasmReady && 'Loading...'} </div> </div> ); } /** * Main Application Component */ function RepoBrowser() { const [currentPath, setCurrentPath] = useState(getCurrentPath()); 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); return () => window.removeEventListener('popstate', handlePopState); }, []); useEffect(() => { fetchDirectory(currentPath); fetchReadme(currentPath); }, [currentPath]); const navigate = (path: string) => { const newUrl = path ? `?path=${encodeURIComponent(path)}` : '/'; window.history.pushState({ path }, '', newUrl); setCurrentPath(path); }; const fetchDirectory = async (path: string) => { setLoading(true); setError(null); try { // Check prefetch cache first const cacheKey = `dir:${path}`; let data; 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) { throw new Error(data.error); } setContent({ files: data?.files || [], directories: data?.directories || [] }); } catch (err: any) { console.error('Error loading directory:', err); setError(err.message); } finally { setLoading(false); } }; const fetchReadme = async (path: string) => { setReadme(null); const readmePath = path ? `${path}/README.md` : 'README.md'; 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 isDark={isDarkMode} /> <div className="repo-container"> {/* Header */} <div className="header"> <img src={ICONS.repo} alt="Repo" className="header-icon" /> <div> <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 */} <div className="clone-box"> <div style={{display:'flex', alignItems:'center', width:'100%'}}> <span className="clone-label">Clone HTTPS</span> <code className="clone-url">hg clone http://zenbu.babocoder.com/repo</code> </div> </div> {/* 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: 'var(--text-secondary)'}}> Loading files... </div> ) : ( <> <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} /> ) )} </> ); } export { RepoBrowser };