Mercurial
diff 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 diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/src/repo-browser.tsx Tue Jan 20 06:06:47 2026 -0800 @@ -0,0 +1,257 @@ +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 };