Mercurial
comparison 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 |
comparison
equal
deleted
inserted
replaced
| 174:1ba8c1df082c | 175:71ad34a8bc9a |
|---|---|
| 1 import React, { useState, useEffect } from 'react'; | |
| 2 | |
| 3 const API_BASE = '/api/repo'; | |
| 4 | |
| 5 /** | |
| 6 * Component: Breadcrumb | |
| 7 * Renders the navigation path at the top | |
| 8 */ | |
| 9 function Breadcrumb({ currentPath, onNavigate }) { | |
| 10 if (!currentPath) { | |
| 11 return ( | |
| 12 <nav id="breadcrumb"> | |
| 13 <span className="nav-item active">Root</span> | |
| 14 </nav> | |
| 15 ); | |
| 16 } | |
| 17 | |
| 18 const parts = currentPath.split('/').filter(p => p); | |
| 19 | |
| 20 // Create cumulative paths for links | |
| 21 // e.g., src/components -> ['src', 'src/components'] | |
| 22 const crumbs = parts.map((part, index) => ({ | |
| 23 name: part, | |
| 24 fullPath: parts.slice(0, index + 1).join('/') | |
| 25 })); | |
| 26 | |
| 27 return ( | |
| 28 <nav id="breadcrumb"> | |
| 29 <a | |
| 30 href="/" | |
| 31 onClick={(e) => { e.preventDefault(); onNavigate(''); }} | |
| 32 > | |
| 33 Root | |
| 34 </a> | |
| 35 {crumbs.map((crumb, index) => { | |
| 36 const isLast = index === crumbs.length - 1; | |
| 37 return ( | |
| 38 <React.Fragment key={crumb.fullPath}> | |
| 39 <span className="separator"> / </span> | |
| 40 {isLast ? ( | |
| 41 <span className="nav-item active">{crumb.name}</span> | |
| 42 ) : ( | |
| 43 <a | |
| 44 href={`?path=${encodeURIComponent(crumb.fullPath)}`} | |
| 45 onClick={(e) => { e.preventDefault(); onNavigate(crumb.fullPath); }} | |
| 46 > | |
| 47 {crumb.name} | |
| 48 </a> | |
| 49 )} | |
| 50 </React.Fragment> | |
| 51 ); | |
| 52 })} | |
| 53 </nav> | |
| 54 ); | |
| 55 } | |
| 56 | |
| 57 /** | |
| 58 * Component: FileList | |
| 59 * Renders the table of directories and files | |
| 60 */ | |
| 61 function FileList({ directories, files, onNavigate }) { | |
| 62 const isEmpty = directories.length === 0 && files.length === 0; | |
| 63 | |
| 64 if (isEmpty) { | |
| 65 return <div className="empty-state">No files found.</div>; | |
| 66 } | |
| 67 | |
| 68 return ( | |
| 69 <div id="fileList"> | |
| 70 {/* Render Directories */} | |
| 71 {directories.map((dir) => ( | |
| 72 <FileRow | |
| 73 key={dir.abspath} | |
| 74 item={dir} | |
| 75 icon="📁" | |
| 76 isDir={true} | |
| 77 onNavigate={onNavigate} | |
| 78 /> | |
| 79 ))} | |
| 80 | |
| 81 {/* Render Files */} | |
| 82 {files.map((file) => ( | |
| 83 <FileRow | |
| 84 key={file.abspath} | |
| 85 item={file} | |
| 86 icon="📄" | |
| 87 isDir={false} | |
| 88 /> | |
| 89 ))} | |
| 90 </div> | |
| 91 ); | |
| 92 } | |
| 93 | |
| 94 /** | |
| 95 * Component: FileRow | |
| 96 * Individual item row | |
| 97 */ | |
| 98 function FileRow({ item, icon, isDir, onNavigate }) { | |
| 99 const handleClick = (e) => { | |
| 100 if (isDir) { | |
| 101 e.preventDefault(); | |
| 102 onNavigate(item.abspath); | |
| 103 } | |
| 104 // Files let the default <a> behavior happen (download/open in new tab) | |
| 105 }; | |
| 106 | |
| 107 // Files link to the raw content API, Dirs link to the app view | |
| 108 const href = isDir | |
| 109 ? `?path=${encodeURIComponent(item.abspath)}` | |
| 110 : `/api/repo/file?path=${encodeURIComponent(item.abspath)}`; | |
| 111 | |
| 112 const target = isDir ? undefined : "_blank"; | |
| 113 | |
| 114 return ( | |
| 115 <div className={`file-item ${item.type}`}> | |
| 116 <span className="icon">{icon}</span> | |
| 117 <span className="name"> | |
| 118 <a href={href} onClick={handleClick} target={target} rel="noreferrer"> | |
| 119 {item.basename} | |
| 120 </a> | |
| 121 </span> | |
| 122 </div> | |
| 123 ); | |
| 124 } | |
| 125 | |
| 126 /** | |
| 127 * Component: ReadmeViewer | |
| 128 * Renders the README content | |
| 129 */ | |
| 130 function ReadmeViewer({ content }) { | |
| 131 if (!content) return null; | |
| 132 | |
| 133 return ( | |
| 134 <div id="readmeSection" style={{ marginTop: '20px', borderTop: '1px solid #eee' }}> | |
| 135 <h3>README.md</h3> | |
| 136 <div id="readmeContent"> | |
| 137 <pre style={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace' }}> | |
| 138 {content} | |
| 139 </pre> | |
| 140 </div> | |
| 141 </div> | |
| 142 ); | |
| 143 } | |
| 144 | |
| 145 | |
| 146 | |
| 147 /** | |
| 148 * Main Application Component | |
| 149 */ | |
| 150 function RepoBrowser() { | |
| 151 // State management for path, data, and UI states | |
| 152 const [currentPath, setCurrentPath] = useState(getCurrentPath()); | |
| 153 const [content, setContent] = useState({ files: [], directories: [] }); | |
| 154 const [readme, setReadme] = useState(null); | |
| 155 const [error, setError] = useState(null); | |
| 156 const [loading, setLoading] = useState(false); | |
| 157 | |
| 158 // Helper to get path from URL query params | |
| 159 function getCurrentPath() { | |
| 160 const params = new URLSearchParams(window.location.search); | |
| 161 return params.get('path') || ''; | |
| 162 } | |
| 163 | |
| 164 // Effect: Handle Browser Navigation (Back/Forward buttons) | |
| 165 useEffect(() => { | |
| 166 const handlePopState = () => setCurrentPath(getCurrentPath()); | |
| 167 window.addEventListener('popstate', handlePopState); | |
| 168 return () => window.removeEventListener('popstate', handlePopState); | |
| 169 }, []); | |
| 170 | |
| 171 // Effect: Fetch Data whenever currentPath changes | |
| 172 useEffect(() => { | |
| 173 fetchDirectory(currentPath); | |
| 174 fetchReadme(currentPath); | |
| 175 }, [currentPath]); | |
| 176 | |
| 177 // Internal navigation handler (avoids full page reload) | |
| 178 const navigate = (path) => { | |
| 179 const newUrl = path ? `?path=${encodeURIComponent(path)}` : '/'; | |
| 180 window.history.pushState({ path }, '', newUrl); | |
| 181 setCurrentPath(path); | |
| 182 }; | |
| 183 | |
| 184 const fetchDirectory = async (path) => { | |
| 185 setLoading(true); | |
| 186 setError(null); | |
| 187 try { | |
| 188 const url = path | |
| 189 ? `${API_BASE}/list?path=${encodeURIComponent(path)}` | |
| 190 : `${API_BASE}/list`; | |
| 191 | |
| 192 const response = await fetch(url); | |
| 193 const data = await response.json(); | |
| 194 | |
| 195 if (data.error) throw new Error(data.error); | |
| 196 | |
| 197 // Ensure we always have arrays even if API returns null | |
| 198 setContent({ | |
| 199 files: data.files || [], | |
| 200 directories: data.directories || [] | |
| 201 }); | |
| 202 } catch (err) { | |
| 203 console.error('Error loading directory:', err); | |
| 204 setError(err.message); | |
| 205 } finally { | |
| 206 setLoading(false); | |
| 207 } | |
| 208 }; | |
| 209 | |
| 210 const fetchReadme = async (path) => { | |
| 211 setReadme(null); // Reset previous readme | |
| 212 try { | |
| 213 const readmePath = path ? `${path}/README.md` : 'README.md'; | |
| 214 const response = await fetch(`${API_BASE}/readme?path=${encodeURIComponent(readmePath)}`); | |
| 215 | |
| 216 if (response.ok) { | |
| 217 const text = await response.text(); | |
| 218 setReadme(text); | |
| 219 } | |
| 220 } catch (err) { | |
| 221 // Silently fail for Readme as it's optional | |
| 222 } | |
| 223 }; | |
| 224 | |
| 225 return ( | |
| 226 <div className="repo-container"> | |
| 227 <div class="header"> | |
| 228 <h1>Zenbu Repository</h1> | |
| 229 <p class="description">Browse and clone this mercurial repository</p> | |
| 230 </div> | |
| 231 | |
| 232 <div class="clone-info"> | |
| 233 <strong>Clone this repository:</strong> | |
| 234 <p><code>hg clone http://zenbu.babocoder.com/repo</code></p> | |
| 235 </div> | |
| 236 | |
| 237 <Breadcrumb currentPath={currentPath} onNavigate={navigate} /> | |
| 238 | |
| 239 {error && <div className="error-message">Error: {error}</div>} | |
| 240 | |
| 241 {loading ? ( | |
| 242 <div className="loading">Loading...</div> | |
| 243 ) : ( | |
| 244 <> | |
| 245 <FileList | |
| 246 directories={content.directories} | |
| 247 files={content.files} | |
| 248 onNavigate={navigate} | |
| 249 /> | |
| 250 <ReadmeViewer content={readme} /> | |
| 251 </> | |
| 252 )} | |
| 253 </div> | |
| 254 ); | |
| 255 } | |
| 256 | |
| 257 export { RepoBrowser }; |