comparison hg-web/src/components/repo-browser.tsx @ 193:9f4429c49733 hg-web

[HgWeb] Making progress....
author MrJuneJune <me@mrjunejune.com>
date Sun, 25 Jan 2026 20:04:55 -0800
parents
children
comparison
equal deleted inserted replaced
192:b818a4561a3c 193:9f4429c49733
1 import React, { useState, useEffect, useRef, useCallback } from 'react';
2 import createMarkdownModule from 'markdown_converter/markdown_to_html_wasm/markdown_to_html_bin.js';
3 import hljs from 'third_party/highlight/highlight.min.js';
4 import { Header } from "hg-web/src/components/header";
5 import { Footer } from "hg-web/src/components/footer";
6 import { ThemeProvider, useTheme } from "hg-web/src/components/theme";
7
8 // --- ICONS (served as static files) ---
9 const ICONS = {
10 folder: "/icons/folder.png",
11 file: "/icons/file.svg",
12 close: "/icons/close.png"
13 };
14
15 const API_BASE = '/api/repo';
16
17 // File extensions that should be displayed as code
18 const CODE_EXTENSIONS = new Set([
19 'js', 'jsx', 'ts', 'tsx', 'py', 'rb', 'java', 'c', 'cpp', 'h', 'hpp',
20 'cs', 'go', 'rs', 'swift', 'kt', 'scala', 'php', 'pl', 'sh', 'bash',
21 'zsh', 'fish', 'ps1', 'bat', 'cmd', 'html', 'htm', 'css', 'scss',
22 'sass', 'less', 'json', 'xml', 'yaml', 'yml', 'toml', 'ini', 'cfg',
23 'conf', 'md', 'markdown', 'txt', 'log', 'sql', 'graphql', 'vue',
24 'svelte', 'astro', 'prisma', 'dockerfile', 'makefile', 'cmake',
25 'gradle', 'pom', 'lock', 'gitignore', 'env', 'example', 'sample'
26 ]);
27
28 // Prefetch cache
29 const prefetchCache = new Map<string, Promise<any>>();
30
31 function isCodeFile(filename: string): boolean {
32 const ext = filename.split('.').pop()?.toLowerCase() || '';
33 const basename = filename.toLowerCase();
34 return CODE_EXTENSIONS.has(ext) ||
35 CODE_EXTENSIONS.has(basename) ||
36 basename === 'dockerfile' ||
37 basename === 'makefile' ||
38 basename.startsWith('.');
39 }
40
41 function isMarkdownFile(filename: string): boolean {
42 const ext = filename.split('.').pop()?.toLowerCase() || '';
43 return ext === 'md' || ext === 'markdown';
44 }
45
46 function prefetchDirectory(path: string): void {
47 const cacheKey = `dir:${path}`;
48 if (prefetchCache.has(cacheKey)) return;
49
50 const url = path
51 ? `${API_BASE}/list?path=${encodeURIComponent(path)}`
52 : `${API_BASE}/list`;
53
54 prefetchCache.set(cacheKey, fetch(url).then(r => r.json()).catch(() => null));
55 }
56
57 function prefetchFile(path: string): void {
58 const cacheKey = `file:${path}`;
59 if (prefetchCache.has(cacheKey)) return;
60
61 prefetchCache.set(cacheKey,
62 fetch(`${API_BASE}/file?path=${encodeURIComponent(path)}`)
63 .then(r => r.ok ? r.text() : null)
64 .catch(() => null)
65 );
66 }
67
68 async function getCachedFile(path: string): Promise<string | null> {
69 const cacheKey = `file:${path}`;
70 if (prefetchCache.has(cacheKey)) {
71 return prefetchCache.get(cacheKey);
72 }
73 prefetchFile(path);
74 return prefetchCache.get(cacheKey)!;
75 }
76
77 /**
78 * Component: Breadcrumb
79 */
80 function Breadcrumb({ currentPath, onNavigate }: { currentPath: string; onNavigate: (path: string) => void }) {
81 if (!currentPath) {
82 return (
83 <nav className="breadcrumb">
84 <span className="nav-item active">root</span>
85 </nav>
86 );
87 }
88
89 const parts = currentPath.split('/').filter(p => p);
90 const crumbs = parts.map((part, index) => ({
91 name: part,
92 fullPath: parts.slice(0, index + 1).join('/')
93 }));
94
95 return (
96 <nav className="breadcrumb">
97 <a
98 href="/"
99 onClick={(e) => { e.preventDefault(); onNavigate(''); }}
100 title="Go to Root"
101 >
102 root
103 </a>
104 {crumbs.map((crumb, index) => {
105 const isLast = index === crumbs.length - 1;
106 return (
107 <React.Fragment key={crumb.fullPath}>
108 <span className="separator">/</span>
109 {isLast ? (
110 <span className="nav-item active">{crumb.name}</span>
111 ) : (
112 <a
113 href={`?path=${encodeURIComponent(crumb.fullPath)}`}
114 onClick={(e) => { e.preventDefault(); onNavigate(crumb.fullPath); }}
115 >
116 {crumb.name}
117 </a>
118 )}
119 </React.Fragment>
120 );
121 })}
122 </nav>
123 );
124 }
125
126 /**
127 * Component: FileViewer
128 * Shows file content inline with syntax highlighting
129 */
130 function FileViewer({ filePath, onClose }: { filePath: string; onClose: () => void }) {
131 const [content, setContent] = useState<string | null>(null);
132 const [loading, setLoading] = useState(true);
133 const codeRef = useRef<HTMLElement>(null);
134
135 const filename = filePath.split('/').pop() || filePath;
136
137 useEffect(() => {
138 setLoading(true);
139 getCachedFile(filePath).then((text) => {
140 setContent(text);
141 setLoading(false);
142 });
143 }, [filePath]);
144
145 useEffect(() => {
146 if (content && codeRef.current) {
147 hljs.highlightElement(codeRef.current);
148 }
149 }, [content]);
150
151 // Close on escape key
152 useEffect(() => {
153 const handleKeyDown = (e: KeyboardEvent) => {
154 if (e.key === 'Escape') onClose();
155 };
156 window.addEventListener('keydown', handleKeyDown);
157 return () => window.removeEventListener('keydown', handleKeyDown);
158 }, [onClose]);
159
160 // Get language from file extension for highlight.js
161 const getLanguage = () => {
162 const ext = filename.split('.').pop()?.toLowerCase() || '';
163 const langMap: Record<string, string> = {
164 js: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript',
165 py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java',
166 c: 'c', cpp: 'cpp', h: 'c', hpp: 'cpp', cs: 'csharp',
167 sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'bash',
168 json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml',
169 html: 'html', htm: 'html', css: 'css', scss: 'scss', sass: 'scss',
170 sql: 'sql', md: 'markdown', markdown: 'markdown', xml: 'xml',
171 dockerfile: 'dockerfile', makefile: 'makefile'
172 };
173 return langMap[ext] || 'plaintext';
174 };
175
176 const addLineNumbers = (text: string) => {
177 const lines = text.split('\n');
178 return lines.map((_, i) => i + 1).join('\n');
179 };
180
181 return (
182 <div className="file-viewer-overlay" onClick={onClose}>
183 <div className="file-viewer" onClick={(e) => e.stopPropagation()}>
184 <div className="file-viewer-header">
185 <span className="file-viewer-title">
186 <img src={ICONS.file} alt="" style={{ width: 16, height: 16 }} />
187 {filename}
188 </span>
189 <button className="file-viewer-close" onClick={onClose} title="Close (Esc)">
190 <img className="icon-invert" src={ICONS.close} alt="Close" />
191 </button>
192 </div>
193 <div className="file-viewer-content">
194 {loading ? (
195 <div className="file-viewer-loading">Loading...</div>
196 ) : content ? (
197 <pre style={{ display: 'flex' }}>
198 <span className="file-viewer-line-numbers">{addLineNumbers(content)}</span>
199 <code ref={codeRef} className={`language-${getLanguage()}`}>{content}</code>
200 </pre>
201 ) : (
202 <div className="file-viewer-loading">Unable to load file</div>
203 )}
204 </div>
205 </div>
206 </div>
207 );
208 }
209
210 /**
211 * Component: MarkdownViewerModal
212 * Shows markdown content rendered in a modal
213 */
214 function MarkdownViewerModal({ filePath, onClose }: { filePath: string; onClose: () => void }) {
215 const [content, setContent] = useState<string | null>(null);
216 const [loading, setLoading] = useState(true);
217 const contentRef = useRef<HTMLDivElement>(null);
218 const moduleRef = useRef<any>(null);
219 const [wasmReady, setWasmReady] = useState(false);
220
221 const filename = filePath.split('/').pop() || filePath;
222
223 useEffect(() => {
224 createMarkdownModule().then((Module: any) => {
225 moduleRef.current = Module;
226 setWasmReady(true);
227 });
228 }, []);
229
230 useEffect(() => {
231 setLoading(true);
232 getCachedFile(filePath).then((text) => {
233 setContent(text);
234 setLoading(false);
235 });
236 }, [filePath]);
237
238 useEffect(() => {
239 if (!content || !wasmReady || !contentRef.current || !moduleRef.current) return;
240
241 const Module = moduleRef.current;
242 const markdownToHtmlPtr = Module.cwrap('markdown_to_html', 'number', ['string']);
243 const markdownFree = Module.cwrap('markdown_free', null, ['number']);
244
245 const ptr = markdownToHtmlPtr(content);
246 const html = Module.UTF8ToString(ptr);
247 markdownFree(ptr);
248 contentRef.current.innerHTML = html;
249 }, [content, wasmReady]);
250
251 // Close on escape key
252 useEffect(() => {
253 const handleKeyDown = (e: KeyboardEvent) => {
254 if (e.key === 'Escape') onClose();
255 };
256 window.addEventListener('keydown', handleKeyDown);
257 return () => window.removeEventListener('keydown', handleKeyDown);
258 }, [onClose]);
259
260 return (
261 <div className="file-viewer-overlay" onClick={onClose}>
262 <div className="file-viewer" onClick={(e) => e.stopPropagation()}>
263 <div className="file-viewer-header">
264 <span className="file-viewer-title">
265 <img src={ICONS.file} alt="" style={{ width: 16, height: 16 }} />
266 {filename}
267 </span>
268 <button className="file-viewer-close" onClick={onClose} title="Close (Esc)">
269 <img className="icon-invert" src={ICONS.close} alt="Close" />
270 </button>
271 </div>
272 <div className="file-viewer-content">
273 {loading || !wasmReady ? (
274 <div className="file-viewer-loading">Loading...</div>
275 ) : content ? (
276 <div className="readme-content" ref={contentRef} />
277 ) : (
278 <div className="file-viewer-loading">Unable to load file</div>
279 )}
280 </div>
281 </div>
282 </div>
283 );
284 }
285
286 /**
287 * Component: FileList
288 */
289 function FileList({ directories, files, onNavigate, onOpenFile }: {
290 directories: any[];
291 files: any[];
292 onNavigate: (path: string) => void;
293 onOpenFile: (path: string) => void;
294 }) {
295 const isEmpty = directories.length === 0 && files.length === 0;
296
297 if (isEmpty) {
298 return (
299 <div className="file-list-container">
300 <div className="empty-state">This directory is empty.</div>
301 </div>
302 );
303 }
304
305 return (
306 <div className="file-list-container">
307 <div className="file-header">Files</div>
308
309 <div id="fileListBody">
310 {directories.map((dir) => (
311 <FileRow
312 key={dir.abspath}
313 item={dir}
314 iconUrl={ICONS.folder}
315 isDir={true}
316 onNavigate={onNavigate}
317 onOpenFile={onOpenFile}
318 />
319 ))}
320
321 {files.map((file) => (
322 <FileRow
323 key={file.abspath}
324 item={file}
325 iconUrl={ICONS.file}
326 isDir={false}
327 onNavigate={onNavigate}
328 onOpenFile={onOpenFile}
329 />
330 ))}
331 </div>
332 </div>
333 );
334 }
335
336 /**
337 * Component: FileRow
338 */
339 function FileRow({ item, iconUrl, isDir, onNavigate, onOpenFile }: {
340 item: { abspath: string; basename: string };
341 iconUrl: string;
342 isDir: boolean;
343 onNavigate: (path: string) => void;
344 onOpenFile: (path: string) => void;
345 }) {
346 const handleClick = (e: React.MouseEvent) => {
347 e.preventDefault();
348 if (isDir) {
349 onNavigate(item.abspath);
350 } else if (isCodeFile(item.basename)) {
351 onOpenFile(item.abspath);
352 } else {
353 window.open(`/api/repo/file?path=${encodeURIComponent(item.abspath)}`, '_blank');
354 }
355 };
356
357 const handleMouseEnter = () => {
358 if (isDir) {
359 prefetchDirectory(item.abspath);
360 } else if (isCodeFile(item.basename)) {
361 prefetchFile(item.abspath);
362 }
363 };
364
365 const href = isDir
366 ? `?path=${encodeURIComponent(item.abspath)}`
367 : `/api/repo/file?path=${encodeURIComponent(item.abspath)}`;
368
369 return (
370 <div className="file-row" onMouseEnter={handleMouseEnter}>
371 <span className="icon">
372 <img className="icon-invert" src={iconUrl} alt={isDir ? "Directory" : "File"} />
373 </span>
374 <span className="name">
375 <a href={href} onClick={handleClick}>
376 {item.basename}
377 </a>
378 </span>
379 </div>
380 );
381 }
382
383 /**
384 * Component: ReadmeViewer
385 */
386 function ReadmeViewer({ content }: { content: string | null }) {
387 const contentRef = useRef<HTMLDivElement>(null);
388 const moduleRef = useRef<any>(null);
389 const [wasmReady, setWasmReady] = useState(false);
390
391 useEffect(() => {
392 createMarkdownModule().then((Module: any) => {
393 moduleRef.current = Module;
394 setWasmReady(true);
395 });
396 }, []);
397
398 useEffect(() => {
399 if (!content || !wasmReady || !contentRef.current || !moduleRef.current) return;
400
401 const Module = moduleRef.current;
402 const markdownToHtmlPtr = Module.cwrap('markdown_to_html', 'number', ['string']);
403 const markdownFree = Module.cwrap('markdown_free', null, ['number']);
404
405 const ptr = markdownToHtmlPtr(content);
406 const html = Module.UTF8ToString(ptr);
407 markdownFree(ptr);
408 contentRef.current.innerHTML = html;
409 }, [content, wasmReady]);
410
411 if (!content) return null;
412
413 return (
414 <div className="readme-section">
415 <div className="readme-header">
416 <img className="icon-invert" src={ICONS.file} width="16" alt="" style={{ opacity: 0.5 }} />
417 README.md
418 </div>
419 <div className="readme-content" ref={contentRef}>
420 {!wasmReady && 'Loading...'}
421 </div>
422 </div>
423 );
424 }
425
426 /**
427 * Repository Browser Content (uses theme context)
428 */
429 function RepoBrowserContent() {
430 const [currentPath, setCurrentPath] = useState(getCurrentPath());
431 const [content, setContent] = useState<{ files: any[]; directories: any[] }>({ files: [], directories: [] });
432 const [readme, setReadme] = useState<string | null>(null);
433 const [error, setError] = useState<string | null>(null);
434 const [loading, setLoading] = useState(false);
435 const [viewingFile, setViewingFile] = useState<string | null>(null);
436
437 const { isDark, toggleTheme } = useTheme();
438
439 function getCurrentPath() {
440 const params = new URLSearchParams(window.location.search);
441 return params.get('path') || '';
442 }
443
444 useEffect(() => {
445 const handlePopState = () => setCurrentPath(getCurrentPath());
446 window.addEventListener('popstate', handlePopState);
447 return () => window.removeEventListener('popstate', handlePopState);
448 }, []);
449
450 useEffect(() => {
451 fetchDirectory(currentPath);
452 fetchReadme(currentPath);
453 }, [currentPath]);
454
455 const navigate = (path: string) => {
456 const newUrl = path ? `?path=${encodeURIComponent(path)}` : '/';
457 window.history.pushState({ path }, '', newUrl);
458 setCurrentPath(path);
459 };
460
461 const fetchDirectory = async (path: string) => {
462 setLoading(true);
463 setError(null);
464 try {
465 const cacheKey = `dir:${path}`;
466 let data;
467 if (prefetchCache.has(cacheKey)) {
468 data = await prefetchCache.get(cacheKey);
469 prefetchCache.delete(cacheKey);
470 } else {
471 const url = path
472 ? `${API_BASE}/list?path=${encodeURIComponent(path)}`
473 : `${API_BASE}/list`;
474 const response = await fetch(url);
475 if (response.ok) {
476 data = await response.json();
477 }
478 }
479
480 if (data?.error) {
481 throw new Error(data.error);
482 }
483
484 setContent({
485 files: data?.files || [],
486 directories: data?.directories || []
487 });
488 } catch (err: any) {
489 console.error('Error loading directory:', err);
490 setError(err.message);
491 } finally {
492 setLoading(false);
493 }
494 };
495
496 const fetchReadme = async (path: string) => {
497 setReadme(null);
498 const readmePath = path ? `${path}/README.md` : 'README.md';
499 try {
500 const response = await fetch(`${API_BASE}/file?path=${encodeURIComponent(readmePath)}`);
501 if (response.ok) {
502 const text = await response.text();
503 setReadme(text);
504 }
505 } catch (err) {
506 // Readme is optional, ignore errors
507 }
508 };
509
510 const handleOpenFile = useCallback((path: string) => {
511 setViewingFile(path);
512 }, []);
513
514 const handleCloseFile = useCallback(() => {
515 setViewingFile(null);
516 }, []);
517
518 return (
519 <>
520 <div className="repo-container">
521 <Header
522 title="Zenbu Repository"
523 subtitle="Browse and manage the mercurial codebase"
524 showThemeToggle={true}
525 isDark={isDark}
526 onToggleTheme={toggleTheme}
527 />
528
529 {/* Clone Bar */}
530 <div className="clone-box">
531 <div className="clone-box-inner">
532 <span className="clone-label">Clone HTTPS</span>
533 <code className="clone-url">hg clone http://zenbu.babocoder.com/repo</code>
534 </div>
535 </div>
536
537 {/* Navigation & Content */}
538 <Breadcrumb currentPath={currentPath} onNavigate={navigate} />
539
540 {error && <div className="error-message">Error: {error}</div>}
541
542 {loading ? (
543 <div className="file-list-container">
544 <div className="loading-state">Loading files...</div>
545 </div>
546 ) : (
547 <>
548 <FileList
549 directories={content.directories}
550 files={content.files}
551 onNavigate={navigate}
552 onOpenFile={handleOpenFile}
553 />
554 <ReadmeViewer content={readme} />
555 </>
556 )}
557
558 <Footer />
559 </div>
560
561 {/* File Viewer Modal */}
562 {viewingFile && (
563 isMarkdownFile(viewingFile) ? (
564 <MarkdownViewerModal filePath={viewingFile} onClose={handleCloseFile} />
565 ) : (
566 <FileViewer filePath={viewingFile} onClose={handleCloseFile} />
567 )
568 )}
569 </>
570 );
571 }
572
573 /**
574 * Main Application Component with ThemeProvider
575 */
576 function RepoBrowser() {
577 return (
578 <ThemeProvider>
579 <RepoBrowserContent />
580 </ThemeProvider>
581 );
582 }
583
584 export { RepoBrowser };