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 };