Mercurial
view hg-web/src/repo-browser.tsx @ 175:71ad34a8bc9a hg-web
[HgWeb] Can stream hg response now. Added react page for hg web since we use json anyway.
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Tue, 20 Jan 2026 06:06:47 -0800 |
| parents | |
| children | fed99fc04e12 |
line wrap: on
line source
import React, { useState, useEffect } from 'react'; const API_BASE = '/api/repo'; /** * Component: Breadcrumb * Renders the navigation path at the top */ 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); // Create cumulative paths for links // e.g., src/components -> ['src', 'src/components'] 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(''); }} > 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: FileList * Renders the table of directories and files */ function FileList({ directories, files, onNavigate }) { const isEmpty = directories.length === 0 && files.length === 0; if (isEmpty) { return <div className="empty-state">No files found.</div>; } return ( <div id="fileList"> {/* Render Directories */} {directories.map((dir) => ( <FileRow key={dir.abspath} item={dir} icon="📁" isDir={true} onNavigate={onNavigate} /> ))} {/* Render Files */} {files.map((file) => ( <FileRow key={file.abspath} item={file} icon="📄" isDir={false} /> ))} </div> ); } /** * Component: FileRow * Individual item row */ function FileRow({ item, icon, isDir, onNavigate }) { const handleClick = (e) => { if (isDir) { e.preventDefault(); onNavigate(item.abspath); } // Files let the default <a> behavior happen (download/open in new tab) }; // Files link to the raw content API, Dirs link to the app view const href = isDir ? `?path=${encodeURIComponent(item.abspath)}` : `/api/repo/file?path=${encodeURIComponent(item.abspath)}`; const target = isDir ? undefined : "_blank"; return ( <div className={`file-item ${item.type}`}> <span className="icon">{icon}</span> <span className="name"> <a href={href} onClick={handleClick} target={target} rel="noreferrer"> {item.basename} </a> </span> </div> ); } /** * Component: ReadmeViewer * Renders the README content */ function ReadmeViewer({ content }) { if (!content) return null; return ( <div id="readmeSection" style={{ marginTop: '20px', borderTop: '1px solid #eee' }}> <h3>README.md</h3> <div id="readmeContent"> <pre style={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace' }}> {content} </pre> </div> </div> ); } /** * Main Application Component */ function RepoBrowser() { // State management for path, data, and UI states const [currentPath, setCurrentPath] = useState(getCurrentPath()); const [content, setContent] = useState({ files: [], directories: [] }); const [readme, setReadme] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); // Helper to get path from URL query params function getCurrentPath() { const params = new URLSearchParams(window.location.search); return params.get('path') || ''; } // Effect: Handle Browser Navigation (Back/Forward buttons) useEffect(() => { const handlePopState = () => setCurrentPath(getCurrentPath()); window.addEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState); }, []); // Effect: Fetch Data whenever currentPath changes useEffect(() => { fetchDirectory(currentPath); fetchReadme(currentPath); }, [currentPath]); // Internal navigation handler (avoids full page reload) const navigate = (path) => { const newUrl = path ? `?path=${encodeURIComponent(path)}` : '/'; window.history.pushState({ path }, '', newUrl); setCurrentPath(path); }; const fetchDirectory = async (path) => { setLoading(true); setError(null); try { const url = path ? `${API_BASE}/list?path=${encodeURIComponent(path)}` : `${API_BASE}/list`; const response = await fetch(url); const data = await response.json(); if (data.error) throw new Error(data.error); // Ensure we always have arrays even if API returns null setContent({ files: data.files || [], directories: data.directories || [] }); } catch (err) { console.error('Error loading directory:', err); setError(err.message); } finally { setLoading(false); } }; const fetchReadme = async (path) => { setReadme(null); // Reset previous readme try { const readmePath = path ? `${path}/README.md` : 'README.md'; const response = await fetch(`${API_BASE}/readme?path=${encodeURIComponent(readmePath)}`); if (response.ok) { const text = await response.text(); setReadme(text); } } catch (err) { // Silently fail for Readme as it's optional } }; return ( <div className="repo-container"> <div class="header"> <h1>Zenbu Repository</h1> <p class="description">Browse and clone this mercurial repository</p> </div> <div class="clone-info"> <strong>Clone this repository:</strong> <p><code>hg clone http://zenbu.babocoder.com/repo</code></p> </div> <Breadcrumb currentPath={currentPath} onNavigate={navigate} /> {error && <div className="error-message">Error: {error}</div>} {loading ? ( <div className="loading">Loading...</div> ) : ( <> <FileList directories={content.directories} files={content.files} onNavigate={navigate} /> <ReadmeViewer content={readme} /> </> )} </div> ); } export { RepoBrowser };