Mercurial
comparison hg-web/src/repo-browser.tsx @ 176:fed99fc04e12 hg-web
[HgWeb] Problem with the emscript lol
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Wed, 21 Jan 2026 19:32:08 -0800 |
| parents | 71ad34a8bc9a |
| children | 32ce881452fa |
comparison
equal
deleted
inserted
replaced
| 175:71ad34a8bc9a | 176:fed99fc04e12 |
|---|---|
| 1 import React, { useState, useEffect } from 'react'; | 1 import React, { useState, useEffect } from 'react'; |
| 2 import { renderMarkdown } from './src/markdown_to_html.js'; | |
| 3 | |
| 4 // --- ICONS (Using CDN Links) --- | |
| 5 const ICONS = { | |
| 6 folder: "https://cdn-icons-png.flaticon.com/512/11471/11471391.png", | |
| 7 file: "https://raw.githubusercontent.com/PKief/vscode-material-icon-theme/main/icons/document.svg", | |
| 8 home: "https://cdn-icons-png.flaticon.com/512/1946/1946488.png", | |
| 9 repo: "/public/epi_all_colors.svg", | |
| 10 clone: "https://cdn-icons-png.flaticon.com/512/11471/11471391.png" | |
| 11 }; | |
| 2 | 12 |
| 3 const API_BASE = '/api/repo'; | 13 const API_BASE = '/api/repo'; |
| 4 | 14 |
| 5 /** | 15 /** |
| 16 * Component: Styles | |
| 17 * Injected CSS for the polished look | |
| 18 */ | |
| 19 const GlobalStyles = () => ( | |
| 20 <style>{` | |
| 21 :root { | |
| 22 --bg-color: #ffffff; | |
| 23 --bg-subtle: #f6f8fa; | |
| 24 --border-color: #d0d7de; | |
| 25 --accent-color: #0969da; | |
| 26 --text-primary: #1f2328; | |
| 27 --text-secondary: #656d76; | |
| 28 --hover-color: #f3f4f6; | |
| 29 --radius: 6px; | |
| 30 } | |
| 31 | |
| 32 .repo-container { | |
| 33 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; | |
| 34 max-width: 980px; | |
| 35 margin: 40px auto; | |
| 36 color: var(--text-primary); | |
| 37 padding: 0 20px; | |
| 38 } | |
| 39 | |
| 40 /* Header */ | |
| 41 .header { | |
| 42 display: flex; | |
| 43 align-items: center; | |
| 44 margin-bottom: 20px; | |
| 45 gap: 15px; | |
| 46 } | |
| 47 .header-icon { width: 32px; height: 32px; opacity: 0.8; } | |
| 48 .header h1 { margin: 0; font-size: 24px; font-weight: 600; } | |
| 49 .description { color: var(--text-secondary); margin: 0; font-size: 14px; } | |
| 50 | |
| 51 /* Clone Box */ | |
| 52 .clone-box { | |
| 53 background: var(--bg-subtle); | |
| 54 border: 1px solid var(--border-color); | |
| 55 border-radius: var(--radius); | |
| 56 padding: 12px 16px; | |
| 57 margin-bottom: 24px; | |
| 58 display: flex; | |
| 59 justify-content: space-between; | |
| 60 align-items: center; | |
| 61 } | |
| 62 .clone-label { font-weight: 600; font-size: 13px; margin-right: 10px; color: var(--text-primary); } | |
| 63 .clone-url { | |
| 64 font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; | |
| 65 background: white; | |
| 66 border: 1px solid var(--border-color); | |
| 67 padding: 4px 8px; | |
| 68 border-radius: 4px; | |
| 69 font-size: 12px; | |
| 70 color: var(--text-secondary); | |
| 71 flex-grow: 1; | |
| 72 } | |
| 73 | |
| 74 /* Breadcrumb */ | |
| 75 #breadcrumb { | |
| 76 display: flex; | |
| 77 align-items: center; | |
| 78 font-size: 14px; | |
| 79 margin-bottom: 16px; | |
| 80 color: var(--text-secondary); | |
| 81 padding: 8px 0; | |
| 82 } | |
| 83 #breadcrumb a { | |
| 84 color: var(--accent-color); | |
| 85 text-decoration: none; | |
| 86 border-radius: 4px; | |
| 87 padding: 2px 6px; | |
| 88 } | |
| 89 #breadcrumb a:hover { background: var(--bg-subtle); text-decoration: underline; } | |
| 90 #breadcrumb .separator { margin: 0 4px; color: var(--text-secondary); opacity: 0.5; } | |
| 91 #breadcrumb .nav-item.active { font-weight: 600; color: var(--text-primary); padding: 2px 6px;} | |
| 92 | |
| 93 /* File List Table Structure */ | |
| 94 .file-list-container { | |
| 95 border: 1px solid var(--border-color); | |
| 96 border-radius: var(--radius); | |
| 97 overflow: hidden; | |
| 98 } | |
| 99 .file-header { | |
| 100 background: var(--bg-subtle); | |
| 101 border-bottom: 1px solid var(--border-color); | |
| 102 padding: 12px 16px; | |
| 103 font-size: 13px; | |
| 104 font-weight: 600; | |
| 105 color: var(--text-secondary); | |
| 106 } | |
| 107 .empty-state { padding: 40px; text-align: center; color: var(--text-secondary); } | |
| 108 .error-message { | |
| 109 padding: 15px; border: 1px solid #ffdce0; | |
| 110 background: #ffebe9; color: #cf222e; | |
| 111 border-radius: var(--radius); margin-bottom: 20px; | |
| 112 } | |
| 113 | |
| 114 /* File Row */ | |
| 115 .file-row { | |
| 116 display: flex; | |
| 117 align-items: center; | |
| 118 padding: 10px 16px; | |
| 119 border-bottom: 1px solid var(--border-color); | |
| 120 transition: background 0.1s; | |
| 121 } | |
| 122 .file-row:last-child { border-bottom: none; } | |
| 123 .file-row:hover { background: var(--hover-color); } | |
| 124 | |
| 125 .file-row .icon img { width: 20px; height: 20px; vertical-align: middle; margin-right: 12px; } | |
| 126 .file-row .name a { | |
| 127 color: var(--text-primary); | |
| 128 text-decoration: none; | |
| 129 font-size: 14px; | |
| 130 } | |
| 131 .file-row .name a:hover { color: var(--accent-color); text-decoration: underline; } | |
| 132 | |
| 133 /* Readme */ | |
| 134 #readmeSection { margin-top: 32px; border: 1px solid var(--border-color); border-radius: var(--radius); } | |
| 135 .readme-header { | |
| 136 background: var(--bg-subtle); | |
| 137 padding: 10px 16px; | |
| 138 font-size: 12px; font-weight: 600; | |
| 139 border-bottom: 1px solid var(--border-color); | |
| 140 display: flex; align-items: center; gap: 8px; | |
| 141 } | |
| 142 #readmeContent { padding: 32px; background: white; overflow-x: auto; } | |
| 143 `}</style> | |
| 144 ); | |
| 145 | |
| 146 /** | |
| 6 * Component: Breadcrumb | 147 * Component: Breadcrumb |
| 7 * Renders the navigation path at the top | |
| 8 */ | 148 */ |
| 9 function Breadcrumb({ currentPath, onNavigate }) { | 149 function Breadcrumb({ currentPath, onNavigate }) { |
| 10 if (!currentPath) { | 150 if (!currentPath) { |
| 11 return ( | 151 return ( |
| 12 <nav id="breadcrumb"> | 152 <nav id="breadcrumb"> |
| 13 <span className="nav-item active">Root</span> | 153 <span className="nav-item active">root</span> |
| 14 </nav> | 154 </nav> |
| 15 ); | 155 ); |
| 16 } | 156 } |
| 17 | 157 |
| 18 const parts = currentPath.split('/').filter(p => p); | 158 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) => ({ | 159 const crumbs = parts.map((part, index) => ({ |
| 23 name: part, | 160 name: part, |
| 24 fullPath: parts.slice(0, index + 1).join('/') | 161 fullPath: parts.slice(0, index + 1).join('/') |
| 25 })); | 162 })); |
| 26 | 163 |
| 27 return ( | 164 return ( |
| 28 <nav id="breadcrumb"> | 165 <nav id="breadcrumb"> |
| 29 <a | 166 <a |
| 30 href="/" | 167 href="/" |
| 31 onClick={(e) => { e.preventDefault(); onNavigate(''); }} | 168 onClick={(e) => { e.preventDefault(); onNavigate(''); }} |
| 169 title="Go to Root" | |
| 32 > | 170 > |
| 33 Root | 171 root |
| 34 </a> | 172 </a> |
| 35 {crumbs.map((crumb, index) => { | 173 {crumbs.map((crumb, index) => { |
| 36 const isLast = index === crumbs.length - 1; | 174 const isLast = index === crumbs.length - 1; |
| 37 return ( | 175 return ( |
| 38 <React.Fragment key={crumb.fullPath}> | 176 <React.Fragment key={crumb.fullPath}> |
| 39 <span className="separator"> / </span> | 177 <span className="separator">/</span> |
| 40 {isLast ? ( | 178 {isLast ? ( |
| 41 <span className="nav-item active">{crumb.name}</span> | 179 <span className="nav-item active">{crumb.name}</span> |
| 42 ) : ( | 180 ) : ( |
| 43 <a | 181 <a |
| 44 href={`?path=${encodeURIComponent(crumb.fullPath)}`} | 182 href={`?path=${encodeURIComponent(crumb.fullPath)}`} |
| 54 ); | 192 ); |
| 55 } | 193 } |
| 56 | 194 |
| 57 /** | 195 /** |
| 58 * Component: FileList | 196 * Component: FileList |
| 59 * Renders the table of directories and files | |
| 60 */ | 197 */ |
| 61 function FileList({ directories, files, onNavigate }) { | 198 function FileList({ directories, files, onNavigate }) { |
| 62 const isEmpty = directories.length === 0 && files.length === 0; | 199 const isEmpty = directories.length === 0 && files.length === 0; |
| 63 | 200 |
| 64 if (isEmpty) { | 201 if (isEmpty) { |
| 65 return <div className="empty-state">No files found.</div>; | 202 return ( |
| 203 <div className="file-list-container"> | |
| 204 <div className="empty-state">This directory is empty.</div> | |
| 205 </div> | |
| 206 ); | |
| 66 } | 207 } |
| 67 | 208 |
| 68 return ( | 209 return ( |
| 69 <div id="fileList"> | 210 <div className="file-list-container"> |
| 70 {/* Render Directories */} | 211 {/* Optional header row like GitHub */} |
| 71 {directories.map((dir) => ( | 212 <div className="file-header"> |
| 72 <FileRow | 213 Files |
| 73 key={dir.abspath} | 214 </div> |
| 74 item={dir} | 215 |
| 75 icon="📁" | 216 <div id="fileListBody"> |
| 76 isDir={true} | 217 {directories.map((dir) => ( |
| 77 onNavigate={onNavigate} | 218 <FileRow |
| 78 /> | 219 key={dir.abspath} |
| 79 ))} | 220 item={dir} |
| 80 | 221 iconUrl={ICONS.folder} |
| 81 {/* Render Files */} | 222 isDir={true} |
| 82 {files.map((file) => ( | 223 onNavigate={onNavigate} |
| 83 <FileRow | 224 /> |
| 84 key={file.abspath} | 225 ))} |
| 85 item={file} | 226 |
| 86 icon="📄" | 227 {files.map((file) => ( |
| 87 isDir={false} | 228 <FileRow |
| 88 /> | 229 key={file.abspath} |
| 89 ))} | 230 item={file} |
| 231 iconUrl={ICONS.file} | |
| 232 isDir={false} | |
| 233 /> | |
| 234 ))} | |
| 235 </div> | |
| 90 </div> | 236 </div> |
| 91 ); | 237 ); |
| 92 } | 238 } |
| 93 | 239 |
| 94 /** | 240 /** |
| 95 * Component: FileRow | 241 * Component: FileRow |
| 96 * Individual item row | 242 */ |
| 97 */ | 243 function FileRow({ item, iconUrl, isDir, onNavigate }) { |
| 98 function FileRow({ item, icon, isDir, onNavigate }) { | |
| 99 const handleClick = (e) => { | 244 const handleClick = (e) => { |
| 100 if (isDir) { | 245 if (isDir) { |
| 101 e.preventDefault(); | 246 e.preventDefault(); |
| 102 onNavigate(item.abspath); | 247 onNavigate(item.abspath); |
| 103 } | 248 } |
| 104 // Files let the default <a> behavior happen (download/open in new tab) | |
| 105 }; | 249 }; |
| 106 | 250 |
| 107 // Files link to the raw content API, Dirs link to the app view | |
| 108 const href = isDir | 251 const href = isDir |
| 109 ? `?path=${encodeURIComponent(item.abspath)}` | 252 ? `?path=${encodeURIComponent(item.abspath)}` |
| 110 : `/api/repo/file?path=${encodeURIComponent(item.abspath)}`; | 253 : `/api/repo/file?path=${encodeURIComponent(item.abspath)}`; |
| 111 | 254 |
| 112 const target = isDir ? undefined : "_blank"; | 255 const target = isDir ? undefined : "_blank"; |
| 113 | 256 |
| 114 return ( | 257 return ( |
| 115 <div className={`file-item ${item.type}`}> | 258 <div className="file-row"> |
| 116 <span className="icon">{icon}</span> | 259 <span className="icon"> |
| 260 <img src={iconUrl} alt={isDir ? "Directory" : "File"} /> | |
| 261 </span> | |
| 117 <span className="name"> | 262 <span className="name"> |
| 118 <a href={href} onClick={handleClick} target={target} rel="noreferrer"> | 263 <a href={href} onClick={handleClick} target={target} rel="noreferrer"> |
| 119 {item.basename} | 264 {item.basename} |
| 120 </a> | 265 </a> |
| 121 </span> | 266 </span> |
| 123 ); | 268 ); |
| 124 } | 269 } |
| 125 | 270 |
| 126 /** | 271 /** |
| 127 * Component: ReadmeViewer | 272 * Component: ReadmeViewer |
| 128 * Renders the README content | |
| 129 */ | 273 */ |
| 130 function ReadmeViewer({ content }) { | 274 function ReadmeViewer({ content }) { |
| 131 if (!content) return null; | 275 if (!content) return null; |
| 132 | 276 |
| 277 useEffect(() => renderMarkdown(content, readmeContent), [content]); | |
| 278 | |
| 133 return ( | 279 return ( |
| 134 <div id="readmeSection" style={{ marginTop: '20px', borderTop: '1px solid #eee' }}> | 280 <div id="readmeSection"> |
| 135 <h3>README.md</h3> | 281 <div className="readme-header"> |
| 136 <div id="readmeContent"> | 282 <img src="https://img.icons8.com/material-outlined/24/000000/menu--v1.png" width="16" alt="" style={{opacity:0.5}} /> |
| 137 <pre style={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace' }}> | 283 README.md |
| 138 {content} | |
| 139 </pre> | |
| 140 </div> | 284 </div> |
| 285 <div id="readmeContent"></div> | |
| 141 </div> | 286 </div> |
| 142 ); | 287 ); |
| 143 } | 288 } |
| 144 | 289 |
| 145 | |
| 146 | |
| 147 /** | 290 /** |
| 148 * Main Application Component | 291 * Main Application Component |
| 149 */ | 292 */ |
| 150 function RepoBrowser() { | 293 function RepoBrowser() { |
| 151 // State management for path, data, and UI states | |
| 152 const [currentPath, setCurrentPath] = useState(getCurrentPath()); | 294 const [currentPath, setCurrentPath] = useState(getCurrentPath()); |
| 153 const [content, setContent] = useState({ files: [], directories: [] }); | 295 const [content, setContent] = useState({ files: [], directories: [] }); |
| 154 const [readme, setReadme] = useState(null); | 296 const [readme, setReadme] = useState(null); |
| 155 const [error, setError] = useState(null); | 297 const [error, setError] = useState(null); |
| 156 const [loading, setLoading] = useState(false); | 298 const [loading, setLoading] = useState(false); |
| 157 | 299 |
| 158 // Helper to get path from URL query params | |
| 159 function getCurrentPath() { | 300 function getCurrentPath() { |
| 160 const params = new URLSearchParams(window.location.search); | 301 const params = new URLSearchParams(window.location.search); |
| 161 return params.get('path') || ''; | 302 return params.get('path') || ''; |
| 162 } | 303 } |
| 163 | 304 |
| 164 // Effect: Handle Browser Navigation (Back/Forward buttons) | |
| 165 useEffect(() => { | 305 useEffect(() => { |
| 166 const handlePopState = () => setCurrentPath(getCurrentPath()); | 306 const handlePopState = () => setCurrentPath(getCurrentPath()); |
| 167 window.addEventListener('popstate', handlePopState); | 307 window.addEventListener('popstate', handlePopState); |
| 168 return () => window.removeEventListener('popstate', handlePopState); | 308 return () => window.removeEventListener('popstate', handlePopState); |
| 169 }, []); | 309 }, []); |
| 170 | 310 |
| 171 // Effect: Fetch Data whenever currentPath changes | |
| 172 useEffect(() => { | 311 useEffect(() => { |
| 173 fetchDirectory(currentPath); | 312 fetchDirectory(currentPath); |
| 174 fetchReadme(currentPath); | 313 fetchReadme(currentPath); |
| 175 }, [currentPath]); | 314 }, [currentPath]); |
| 176 | 315 |
| 177 // Internal navigation handler (avoids full page reload) | |
| 178 const navigate = (path) => { | 316 const navigate = (path) => { |
| 179 const newUrl = path ? `?path=${encodeURIComponent(path)}` : '/'; | 317 const newUrl = path ? `?path=${encodeURIComponent(path)}` : '/'; |
| 180 window.history.pushState({ path }, '', newUrl); | 318 window.history.pushState({ path }, '', newUrl); |
| 181 setCurrentPath(path); | 319 setCurrentPath(path); |
| 182 }; | 320 }; |
| 192 const response = await fetch(url); | 330 const response = await fetch(url); |
| 193 const data = await response.json(); | 331 const data = await response.json(); |
| 194 | 332 |
| 195 if (data.error) throw new Error(data.error); | 333 if (data.error) throw new Error(data.error); |
| 196 | 334 |
| 197 // Ensure we always have arrays even if API returns null | |
| 198 setContent({ | 335 setContent({ |
| 199 files: data.files || [], | 336 files: data.files || [], |
| 200 directories: data.directories || [] | 337 directories: data.directories || [] |
| 201 }); | 338 }); |
| 202 } catch (err) { | 339 } catch (err) { |
| 206 setLoading(false); | 343 setLoading(false); |
| 207 } | 344 } |
| 208 }; | 345 }; |
| 209 | 346 |
| 210 const fetchReadme = async (path) => { | 347 const fetchReadme = async (path) => { |
| 211 setReadme(null); // Reset previous readme | 348 setReadme(null); |
| 212 try { | 349 try { |
| 213 const readmePath = path ? `${path}/README.md` : 'README.md'; | 350 const readmePath = path ? `${path}/README.md` : 'README.md'; |
| 214 const response = await fetch(`${API_BASE}/readme?path=${encodeURIComponent(readmePath)}`); | 351 const response = await fetch(`${API_BASE}/readme?path=${encodeURIComponent(readmePath)}`); |
| 215 | 352 |
| 216 if (response.ok) { | 353 if (response.ok) { |
| 217 const text = await response.text(); | 354 const text = await response.text(); |
| 218 setReadme(text); | 355 setReadme(text); |
| 219 } | 356 } |
| 220 } catch (err) { | 357 } catch (err) { /* Silently fail */ } |
| 221 // Silently fail for Readme as it's optional | |
| 222 } | |
| 223 }; | 358 }; |
| 224 | 359 |
| 225 return ( | 360 return ( |
| 226 <div className="repo-container"> | 361 <> |
| 227 <div class="header"> | 362 <GlobalStyles /> |
| 228 <h1>Zenbu Repository</h1> | 363 <div className="repo-container"> |
| 229 <p class="description">Browse and clone this mercurial repository</p> | 364 |
| 365 {/* Header */} | |
| 366 <div className="header"> | |
| 367 <img src={ICONS.repo} alt="Repo" className="header-icon" /> | |
| 368 <div> | |
| 369 <h1>Zenbu Repository</h1> | |
| 370 <p className="description">Browse and manage the mercurial codebase</p> | |
| 371 </div> | |
| 372 </div> | |
| 373 | |
| 374 {/* Clone Bar */} | |
| 375 <div className="clone-box"> | |
| 376 <div style={{display:'flex', alignItems:'center', width:'100%'}}> | |
| 377 <span className="clone-label">Clone HTTPS</span> | |
| 378 <code className="clone-url">hg clone http://zenbu.babocoder.com/repo</code> | |
| 379 </div> | |
| 380 </div> | |
| 381 | |
| 382 {/* Navigation & Content */} | |
| 383 <Breadcrumb currentPath={currentPath} onNavigate={navigate} /> | |
| 384 | |
| 385 {error && <div className="error-message">Error: {error}</div>} | |
| 386 | |
| 387 {loading ? ( | |
| 388 <div className="file-list-container" style={{padding: '40px', textAlign: 'center', color:'#666'}}> | |
| 389 Loading files... | |
| 390 </div> | |
| 391 ) : ( | |
| 392 <> | |
| 393 <FileList | |
| 394 directories={content.directories} | |
| 395 files={content.files} | |
| 396 onNavigate={navigate} | |
| 397 /> | |
| 398 <ReadmeViewer content={readme} /> | |
| 399 </> | |
| 400 )} | |
| 230 </div> | 401 </div> |
| 231 | 402 </> |
| 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 ); | 403 ); |
| 255 } | 404 } |
| 256 | 405 |
| 257 export { RepoBrowser }; | 406 export { RepoBrowser }; |