comparison hg-web/src/repo-browser.tsx @ 191:a06710325c30 hg-web

[HgWeb] Fully working copy.
author MrJuneJune <me@mrjunejune.com>
date Sat, 24 Jan 2026 21:51:51 -0800
parents a2725419f988
children
comparison
equal deleted inserted replaced
190:a2725419f988 191:a06710325c30
1 import React, { useState, useEffect, useRef } from 'react'; 1 import React, { useState, useEffect, useRef, useCallback } from 'react';
2 import createMarkdownModule from 'markdown_converter/markdown_to_html_wasm/markdown_to_html_bin.js'; 2 import createMarkdownModule from 'markdown_converter/markdown_to_html_wasm/markdown_to_html_bin.js';
3 3 import hljs from 'third_party/highlight/highlight.min.js';
4 // --- ICONS (Using CDN Links) --- 4
5 // --- ICONS (served as static files) ---
5 const ICONS = { 6 const ICONS = {
6 folder: "https://cdn-icons-png.flaticon.com/512/11471/11471391.png", 7 folder: "/icons/folder.png",
7 file: "https://raw.githubusercontent.com/PKief/vscode-material-icon-theme/main/icons/document.svg", 8 file: "/icons/file.svg",
8 home: "https://cdn-icons-png.flaticon.com/512/1946/1946488.png", 9 home: "/icons/home.png",
9 repo: "/public/epi_all_colors.svg", 10 repo: "/public/epi_all_colors.svg",
10 clone: "https://cdn-icons-png.flaticon.com/512/11471/11471391.png" 11 close: "/icons/close.png"
11 }; 12 };
12 13
14 // SVG Icons for theme toggle
15 const SunIcon = ({ color = "currentColor" }: { color?: string }) => (
16 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
17 <circle cx="12" cy="12" r="5"/>
18 <line x1="12" y1="1" x2="12" y2="3"/>
19 <line x1="12" y1="21" x2="12" y2="23"/>
20 <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
21 <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
22 <line x1="1" y1="12" x2="3" y2="12"/>
23 <line x1="21" y1="12" x2="23" y2="12"/>
24 <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
25 <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
26 </svg>
27 );
28
29 const MoonIcon = ({ color = "currentColor" }: { color?: string }) => (
30 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
31 <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
32 </svg>
33 );
34
13 const API_BASE = '/api/repo'; 35 const API_BASE = '/api/repo';
36
37 // File extensions that should be displayed as code
38 const CODE_EXTENSIONS = new Set([
39 'js', 'jsx', 'ts', 'tsx', 'py', 'rb', 'java', 'c', 'cpp', 'h', 'hpp',
40 'cs', 'go', 'rs', 'swift', 'kt', 'scala', 'php', 'pl', 'sh', 'bash',
41 'zsh', 'fish', 'ps1', 'bat', 'cmd', 'html', 'htm', 'css', 'scss',
42 'sass', 'less', 'json', 'xml', 'yaml', 'yml', 'toml', 'ini', 'cfg',
43 'conf', 'md', 'markdown', 'txt', 'log', 'sql', 'graphql', 'vue',
44 'svelte', 'astro', 'prisma', 'dockerfile', 'makefile', 'cmake',
45 'gradle', 'pom', 'lock', 'gitignore', 'env', 'example', 'sample'
46 ]);
47
48 // Prefetch cache
49 const prefetchCache = new Map<string, Promise<any>>();
50
51 function isCodeFile(filename: string): boolean {
52 const ext = filename.split('.').pop()?.toLowerCase() || '';
53 const basename = filename.toLowerCase();
54 return CODE_EXTENSIONS.has(ext) ||
55 CODE_EXTENSIONS.has(basename) ||
56 basename === 'dockerfile' ||
57 basename === 'makefile' ||
58 basename.startsWith('.');
59 }
60
61 function isMarkdownFile(filename: string): boolean {
62 const ext = filename.split('.').pop()?.toLowerCase() || '';
63 return ext === 'md' || ext === 'markdown';
64 }
65
66 function prefetchDirectory(path: string): void {
67 const cacheKey = `dir:${path}`;
68 if (prefetchCache.has(cacheKey)) return;
69
70 const url = path
71 ? `${API_BASE}/list?path=${encodeURIComponent(path)}`
72 : `${API_BASE}/list`;
73
74 prefetchCache.set(cacheKey, fetch(url).then(r => r.json()).catch(() => null));
75 }
76
77 function prefetchFile(path: string): void {
78 const cacheKey = `file:${path}`;
79 if (prefetchCache.has(cacheKey)) return;
80
81 prefetchCache.set(cacheKey,
82 fetch(`${API_BASE}/file?path=${encodeURIComponent(path)}`)
83 .then(r => r.ok ? r.text() : null)
84 .catch(() => null)
85 );
86 }
87
88 async function getCachedFile(path: string): Promise<string | null> {
89 const cacheKey = `file:${path}`;
90 if (prefetchCache.has(cacheKey)) {
91 return prefetchCache.get(cacheKey);
92 }
93 prefetchFile(path);
94 return prefetchCache.get(cacheKey)!;
95 }
14 96
15 /** 97 /**
16 * Component: Styles 98 * Component: Styles
17 * Injected CSS for the polished look 99 * Injected CSS for the polished look with dark/light mode support
18 */ 100 */
19 const GlobalStyles = () => ( 101 const GlobalStyles = ({ isDark }: { isDark: boolean }) => (
20 <style>{` 102 <style>{`
21 :root { 103 :root {
22 --bg-color: #ffffff; 104 --bg-color: ${isDark ? '#0d1117' : '#ffffff'};
23 --bg-subtle: #f6f8fa; 105 --bg-subtle: ${isDark ? '#161b22' : '#f6f8fa'};
24 --border-color: #d0d7de; 106 --bg-code: ${isDark ? '#1c2128' : '#f6f8fa'};
25 --accent-color: #0969da; 107 --border-color: ${isDark ? '#30363d' : '#d0d7de'};
26 --text-primary: #1f2328; 108 --accent-color: ${isDark ? '#58a6ff' : '#0969da'};
27 --text-secondary: #656d76; 109 --text-primary: ${isDark ? '#e6edf3' : '#1f2328'};
28 --hover-color: #f3f4f6; 110 --text-secondary: ${isDark ? '#8b949e' : '#656d76'};
111 --hover-color: ${isDark ? '#1c2128' : '#f3f4f6'};
29 --radius: 6px; 112 --radius: 6px;
113 --code-bg: ${isDark ? '#161b22' : '#ffffff'};
114 }
115
116 body {
117 background: var(--bg-color);
118 transition: background 0.2s;
30 } 119 }
31 120
32 .repo-container { 121 .repo-container {
33 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; 122 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
34 max-width: 980px; 123 max-width: 980px;
45 gap: 15px; 134 gap: 15px;
46 } 135 }
47 .header-icon { width: 32px; height: 32px; opacity: 0.8; } 136 .header-icon { width: 32px; height: 32px; opacity: 0.8; }
48 .header h1 { margin: 0; font-size: 24px; font-weight: 600; } 137 .header h1 { margin: 0; font-size: 24px; font-weight: 600; }
49 .description { color: var(--text-secondary); margin: 0; font-size: 14px; } 138 .description { color: var(--text-secondary); margin: 0; font-size: 14px; }
139
140 /* Theme Toggle */
141 .theme-toggle {
142 margin-left: auto;
143 background: var(--bg-subtle);
144 border: 1px solid var(--border-color);
145 border-radius: var(--radius);
146 padding: 8px 12px;
147 cursor: pointer;
148 display: flex;
149 align-items: center;
150 gap: 6px;
151 color: var(--text-secondary);
152 font-size: 13px;
153 transition: all 0.2s;
154 }
155 .theme-toggle:hover {
156 background: var(--hover-color);
157 color: var(--text-primary);
158 }
159 .theme-toggle svg {
160 flex-shrink: 0;
161 }
50 162
51 /* Clone Box */ 163 /* Clone Box */
52 .clone-box { 164 .clone-box {
53 background: var(--bg-subtle); 165 background: var(--bg-subtle);
54 border: 1px solid var(--border-color); 166 border: 1px solid var(--border-color);
58 display: flex; 170 display: flex;
59 justify-content: space-between; 171 justify-content: space-between;
60 align-items: center; 172 align-items: center;
61 } 173 }
62 .clone-label { font-weight: 600; font-size: 13px; margin-right: 10px; color: var(--text-primary); } 174 .clone-label { font-weight: 600; font-size: 13px; margin-right: 10px; color: var(--text-primary); }
63 .clone-url { 175 .clone-url {
64 font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; 176 font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
65 background: white; 177 background: var(--bg-color);
66 border: 1px solid var(--border-color); 178 border: 1px solid var(--border-color);
67 padding: 4px 8px; 179 padding: 4px 8px;
68 border-radius: 4px; 180 border-radius: 4px;
69 font-size: 12px; 181 font-size: 12px;
70 color: var(--text-secondary); 182 color: var(--text-secondary);
78 font-size: 14px; 190 font-size: 14px;
79 margin-bottom: 16px; 191 margin-bottom: 16px;
80 color: var(--text-secondary); 192 color: var(--text-secondary);
81 padding: 8px 0; 193 padding: 8px 0;
82 } 194 }
83 #breadcrumb a { 195 #breadcrumb a {
84 color: var(--accent-color); 196 color: var(--accent-color);
85 text-decoration: none; 197 text-decoration: none;
86 border-radius: 4px; 198 border-radius: 4px;
87 padding: 2px 6px; 199 padding: 2px 6px;
88 } 200 }
89 #breadcrumb a:hover { background: var(--bg-subtle); text-decoration: underline; } 201 #breadcrumb a:hover { background: var(--bg-subtle); text-decoration: underline; }
90 #breadcrumb .separator { margin: 0 4px; color: var(--text-secondary); opacity: 0.5; } 202 #breadcrumb .separator { margin: 0 4px; color: var(--text-secondary); opacity: 0.5; }
93 /* File List Table Structure */ 205 /* File List Table Structure */
94 .file-list-container { 206 .file-list-container {
95 border: 1px solid var(--border-color); 207 border: 1px solid var(--border-color);
96 border-radius: var(--radius); 208 border-radius: var(--radius);
97 overflow: hidden; 209 overflow: hidden;
210 background: var(--bg-color);
98 } 211 }
99 .file-header { 212 .file-header {
100 background: var(--bg-subtle); 213 background: var(--bg-subtle);
101 border-bottom: 1px solid var(--border-color); 214 border-bottom: 1px solid var(--border-color);
102 padding: 12px 16px; 215 padding: 12px 16px;
103 font-size: 13px; 216 font-size: 13px;
104 font-weight: 600; 217 font-weight: 600;
105 color: var(--text-secondary); 218 color: var(--text-secondary);
106 } 219 }
107 .empty-state { padding: 40px; text-align: center; color: var(--text-secondary); } 220 .empty-state { padding: 40px; text-align: center; color: var(--text-secondary); }
108 .error-message { 221 .error-message {
109 padding: 15px; border: 1px solid #ffdce0; 222 padding: 15px; border: 1px solid ${isDark ? '#f8514966' : '#ffdce0'};
110 background: #ffebe9; color: #cf222e; 223 background: ${isDark ? '#f8514926' : '#ffebe9'}; color: ${isDark ? '#f85149' : '#cf222e'};
111 border-radius: var(--radius); margin-bottom: 20px; 224 border-radius: var(--radius); margin-bottom: 20px;
112 } 225 }
113 226
114 /* File Row */ 227 /* File Row */
115 .file-row { 228 .file-row {
116 display: flex; 229 display: flex;
119 border-bottom: 1px solid var(--border-color); 232 border-bottom: 1px solid var(--border-color);
120 transition: background 0.1s; 233 transition: background 0.1s;
121 } 234 }
122 .file-row:last-child { border-bottom: none; } 235 .file-row:last-child { border-bottom: none; }
123 .file-row:hover { background: var(--hover-color); } 236 .file-row:hover { background: var(--hover-color); }
124 237
125 .file-row .icon img { width: 20px; height: 20px; vertical-align: middle; margin-right: 12px; } 238 .file-row .icon img {
126 .file-row .name a { 239 width: 20px;
127 color: var(--text-primary); 240 height: 20px;
128 text-decoration: none; 241 vertical-align: middle;
129 font-size: 14px; 242 margin-right: 12px;
243 filter: ${isDark ? 'invert(0.8)' : 'none'};
244 }
245 .file-row .name a {
246 color: var(--text-primary);
247 text-decoration: none;
248 font-size: 14px;
130 } 249 }
131 .file-row .name a:hover { color: var(--accent-color); text-decoration: underline; } 250 .file-row .name a:hover { color: var(--accent-color); text-decoration: underline; }
132 251
133 /* Readme */ 252 /* Readme */
134 #readmeSection { margin-top: 32px; border: 1px solid var(--border-color); border-radius: var(--radius); } 253 #readmeSection { margin-top: 32px; border: 1px solid var(--border-color); border-radius: var(--radius); }
135 .readme-header { 254 .readme-header {
136 background: var(--bg-subtle); 255 background: var(--bg-subtle);
137 padding: 10px 16px; 256 padding: 10px 16px;
138 font-size: 12px; font-weight: 600; 257 font-size: 12px; font-weight: 600;
139 border-bottom: 1px solid var(--border-color); 258 border-bottom: 1px solid var(--border-color);
140 display: flex; align-items: center; gap: 8px; 259 display: flex; align-items: center; gap: 8px;
141 } 260 }
142 #readmeContent { padding: 32px; background: white; overflow-x: auto; } 261 .readme-header img {
262 filter: ${isDark ? 'invert(0.7)' : 'none'};
263 }
264 #readmeContent { padding: 32px; background: var(--bg-color); overflow-x: auto; color: var(--text-primary); }
265
266 /* File Viewer */
267 .file-viewer-overlay {
268 position: fixed;
269 inset: 0;
270 background: ${isDark ? 'rgba(0,0,0,0.7)' : 'rgba(0,0,0,0.5)'};
271 display: flex;
272 justify-content: center;
273 align-items: center;
274 z-index: 1000;
275 padding: 20px;
276 }
277 .file-viewer {
278 background: var(--bg-color);
279 border: 1px solid var(--border-color);
280 border-radius: var(--radius);
281 width: 100%;
282 max-width: 900px;
283 max-height: 90vh;
284 display: flex;
285 flex-direction: column;
286 box-shadow: 0 8px 32px rgba(0,0,0,0.3);
287 }
288 .file-viewer-header {
289 display: flex;
290 align-items: center;
291 justify-content: space-between;
292 padding: 12px 16px;
293 background: var(--bg-subtle);
294 border-bottom: 1px solid var(--border-color);
295 border-radius: var(--radius) var(--radius) 0 0;
296 }
297 .file-viewer-title {
298 font-weight: 600;
299 font-size: 14px;
300 color: var(--text-primary);
301 display: flex;
302 align-items: center;
303 gap: 8px;
304 }
305 .file-viewer-close {
306 background: transparent;
307 border: none;
308 cursor: pointer;
309 padding: 4px;
310 border-radius: 4px;
311 display: flex;
312 align-items: center;
313 justify-content: center;
314 }
315 .file-viewer-close:hover {
316 background: var(--hover-color);
317 }
318 .file-viewer-close img {
319 width: 16px;
320 height: 16px;
321 filter: ${isDark ? 'invert(0.7)' : 'none'};
322 opacity: 0.7;
323 }
324 .file-viewer-content {
325 overflow: auto;
326 flex: 1;
327 }
328 .file-viewer-content pre {
329 margin: 0;
330 padding: 16px;
331 background: var(--code-bg);
332 font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
333 font-size: 13px;
334 line-height: 1.5;
335 overflow-x: auto;
336 }
337 .file-viewer-content code {
338 background: transparent;
339 padding: 0;
340 }
341 .file-viewer-loading {
342 padding: 40px;
343 text-align: center;
344 color: var(--text-secondary);
345 }
346 .file-viewer-line-numbers {
347 display: inline-block;
348 user-select: none;
349 text-align: right;
350 padding-right: 16px;
351 margin-right: 16px;
352 border-right: 1px solid var(--border-color);
353 color: var(--text-secondary);
354 opacity: 0.5;
355 }
143 `}</style> 356 `}</style>
144 ); 357 );
145 358
146 /** 359 /**
147 * Component: Breadcrumb 360 * Component: Breadcrumb
191 </nav> 404 </nav>
192 ); 405 );
193 } 406 }
194 407
195 /** 408 /**
409 * Component: FileViewer
410 * Shows file content inline with syntax highlighting
411 */
412 function FileViewer({ filePath, onClose }: { filePath: string; onClose: () => void }) {
413 const [content, setContent] = useState<string | null>(null);
414 const [loading, setLoading] = useState(true);
415 const codeRef = useRef<HTMLElement>(null);
416
417 const filename = filePath.split('/').pop() || filePath;
418
419 useEffect(() => {
420 setLoading(true);
421 getCachedFile(filePath).then((text) => {
422 setContent(text);
423 setLoading(false);
424 });
425 }, [filePath]);
426
427 useEffect(() => {
428 if (content && codeRef.current) {
429 hljs.highlightElement(codeRef.current);
430 }
431 }, [content]);
432
433 // Close on escape key
434 useEffect(() => {
435 const handleKeyDown = (e: KeyboardEvent) => {
436 if (e.key === 'Escape') onClose();
437 };
438 window.addEventListener('keydown', handleKeyDown);
439 return () => window.removeEventListener('keydown', handleKeyDown);
440 }, [onClose]);
441
442 // Get language from file extension for highlight.js
443 const getLanguage = () => {
444 const ext = filename.split('.').pop()?.toLowerCase() || '';
445 const langMap: Record<string, string> = {
446 js: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript',
447 py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java',
448 c: 'c', cpp: 'cpp', h: 'c', hpp: 'cpp', cs: 'csharp',
449 sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'bash',
450 json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml',
451 html: 'html', htm: 'html', css: 'css', scss: 'scss', sass: 'scss',
452 sql: 'sql', md: 'markdown', markdown: 'markdown', xml: 'xml',
453 dockerfile: 'dockerfile', makefile: 'makefile'
454 };
455 return langMap[ext] || 'plaintext';
456 };
457
458 const addLineNumbers = (text: string) => {
459 const lines = text.split('\n');
460 return lines.map((_, i) => i + 1).join('\n');
461 };
462
463 return (
464 <div className="file-viewer-overlay" onClick={onClose}>
465 <div className="file-viewer" onClick={(e) => e.stopPropagation()}>
466 <div className="file-viewer-header">
467 <span className="file-viewer-title">
468 <img src={ICONS.file} alt="" style={{ width: 16, height: 16 }} />
469 {filename}
470 </span>
471 <button className="file-viewer-close" onClick={onClose} title="Close (Esc)">
472 <img src={ICONS.close} alt="Close" />
473 </button>
474 </div>
475 <div className="file-viewer-content">
476 {loading ? (
477 <div className="file-viewer-loading">Loading...</div>
478 ) : content ? (
479 <pre style={{ display: 'flex' }}>
480 <span className="file-viewer-line-numbers">{addLineNumbers(content)}</span>
481 <code ref={codeRef} className={`language-${getLanguage()}`}>{content}</code>
482 </pre>
483 ) : (
484 <div className="file-viewer-loading">Unable to load file</div>
485 )}
486 </div>
487 </div>
488 </div>
489 );
490 }
491
492 /**
493 * Component: MarkdownViewerModal
494 * Shows markdown content rendered in a modal
495 */
496 function MarkdownViewerModal({ filePath, onClose }: { filePath: string; onClose: () => void }) {
497 const [content, setContent] = useState<string | null>(null);
498 const [loading, setLoading] = useState(true);
499 const contentRef = useRef<HTMLDivElement>(null);
500 const moduleRef = useRef<any>(null);
501 const [wasmReady, setWasmReady] = useState(false);
502
503 const filename = filePath.split('/').pop() || filePath;
504
505 useEffect(() => {
506 createMarkdownModule().then((Module: any) => {
507 moduleRef.current = Module;
508 setWasmReady(true);
509 });
510 }, []);
511
512 useEffect(() => {
513 setLoading(true);
514 getCachedFile(filePath).then((text) => {
515 setContent(text);
516 setLoading(false);
517 });
518 }, [filePath]);
519
520 useEffect(() => {
521 if (!content || !wasmReady || !contentRef.current || !moduleRef.current) return;
522
523 const Module = moduleRef.current;
524 const markdownToHtmlPtr = Module.cwrap('markdown_to_html', 'number', ['string']);
525 const markdownFree = Module.cwrap('markdown_free', null, ['number']);
526
527 const ptr = markdownToHtmlPtr(content);
528 const html = Module.UTF8ToString(ptr);
529 markdownFree(ptr);
530 contentRef.current.innerHTML = html;
531 }, [content, wasmReady]);
532
533 // Close on escape key
534 useEffect(() => {
535 const handleKeyDown = (e: KeyboardEvent) => {
536 if (e.key === 'Escape') onClose();
537 };
538 window.addEventListener('keydown', handleKeyDown);
539 return () => window.removeEventListener('keydown', handleKeyDown);
540 }, [onClose]);
541
542 return (
543 <div className="file-viewer-overlay" onClick={onClose}>
544 <div className="file-viewer" onClick={(e) => e.stopPropagation()}>
545 <div className="file-viewer-header">
546 <span className="file-viewer-title">
547 <img src={ICONS.file} alt="" style={{ width: 16, height: 16 }} />
548 {filename}
549 </span>
550 <button className="file-viewer-close" onClick={onClose} title="Close (Esc)">
551 <img src={ICONS.close} alt="Close" />
552 </button>
553 </div>
554 <div className="file-viewer-content">
555 {loading || !wasmReady ? (
556 <div className="file-viewer-loading">Loading...</div>
557 ) : content ? (
558 <div id="readmeContent" ref={contentRef} style={{ padding: 32 }} />
559 ) : (
560 <div className="file-viewer-loading">Unable to load file</div>
561 )}
562 </div>
563 </div>
564 </div>
565 );
566 }
567
568 /**
196 * Component: FileList 569 * Component: FileList
197 */ 570 */
198 function FileList({ directories, files, onNavigate }) { 571 function FileList({ directories, files, onNavigate, onOpenFile }: {
572 directories: any[];
573 files: any[];
574 onNavigate: (path: string) => void;
575 onOpenFile: (path: string) => void;
576 }) {
199 const isEmpty = directories.length === 0 && files.length === 0; 577 const isEmpty = directories.length === 0 && files.length === 0;
200 578
201 if (isEmpty) { 579 if (isEmpty) {
202 return ( 580 return (
203 <div className="file-list-container"> 581 <div className="file-list-container">
213 Files 591 Files
214 </div> 592 </div>
215 593
216 <div id="fileListBody"> 594 <div id="fileListBody">
217 {directories.map((dir) => ( 595 {directories.map((dir) => (
218 <FileRow 596 <FileRow
219 key={dir.abspath} 597 key={dir.abspath}
220 item={dir} 598 item={dir}
221 iconUrl={ICONS.folder} 599 iconUrl={ICONS.folder}
222 isDir={true} 600 isDir={true}
223 onNavigate={onNavigate} 601 onNavigate={onNavigate}
602 onOpenFile={onOpenFile}
224 /> 603 />
225 ))} 604 ))}
226 605
227 {files.map((file) => ( 606 {files.map((file) => (
228 <FileRow 607 <FileRow
229 key={file.abspath} 608 key={file.abspath}
230 item={file} 609 item={file}
231 iconUrl={ICONS.file} 610 iconUrl={ICONS.file}
232 isDir={false} 611 isDir={false}
612 onNavigate={onNavigate}
613 onOpenFile={onOpenFile}
233 /> 614 />
234 ))} 615 ))}
235 </div> 616 </div>
236 </div> 617 </div>
237 ); 618 );
238 } 619 }
239 620
240 /** 621 /**
241 * Component: FileRow 622 * Component: FileRow
242 */ 623 */
243 function FileRow({ item, iconUrl, isDir, onNavigate }) { 624 function FileRow({ item, iconUrl, isDir, onNavigate, onOpenFile }: {
244 const handleClick = (e) => { 625 item: { abspath: string; basename: string };
626 iconUrl: string;
627 isDir: boolean;
628 onNavigate: (path: string) => void;
629 onOpenFile: (path: string) => void;
630 }) {
631 const handleClick = (e: React.MouseEvent) => {
632 e.preventDefault();
245 if (isDir) { 633 if (isDir) {
246 e.preventDefault();
247 onNavigate(item.abspath); 634 onNavigate(item.abspath);
635 } else if (isCodeFile(item.basename)) {
636 onOpenFile(item.abspath);
637 } else {
638 // For non-code files, open in new tab
639 window.open(`/api/repo/file?path=${encodeURIComponent(item.abspath)}`, '_blank');
248 } 640 }
249 }; 641 };
250 642
251 const href = isDir 643 const handleMouseEnter = () => {
644 // Prefetch on hover
645 if (isDir) {
646 prefetchDirectory(item.abspath);
647 } else if (isCodeFile(item.basename)) {
648 prefetchFile(item.abspath);
649 }
650 };
651
652 const href = isDir
252 ? `?path=${encodeURIComponent(item.abspath)}` 653 ? `?path=${encodeURIComponent(item.abspath)}`
253 : `/api/repo/file?path=${encodeURIComponent(item.abspath)}`; 654 : `/api/repo/file?path=${encodeURIComponent(item.abspath)}`;
254
255 const target = isDir ? undefined : "_blank";
256 655
257 return ( 656 return (
258 <div className="file-row"> 657 <div className="file-row" onMouseEnter={handleMouseEnter}>
259 <span className="icon"> 658 <span className="icon">
260 <img src={iconUrl} alt={isDir ? "Directory" : "File"} /> 659 <img src={iconUrl} alt={isDir ? "Directory" : "File"} />
261 </span> 660 </span>
262 <span className="name"> 661 <span className="name">
263 <a href={href} onClick={handleClick} target={target} rel="noreferrer"> 662 <a href={href} onClick={handleClick}>
264 {item.basename} 663 {item.basename}
265 </a> 664 </a>
266 </span> 665 </span>
267 </div> 666 </div>
268 ); 667 );
275 const contentRef = useRef<HTMLDivElement>(null); 674 const contentRef = useRef<HTMLDivElement>(null);
276 const moduleRef = useRef<any>(null); 675 const moduleRef = useRef<any>(null);
277 const [wasmReady, setWasmReady] = useState(false); 676 const [wasmReady, setWasmReady] = useState(false);
278 677
279 useEffect(() => { 678 useEffect(() => {
280 createMarkdownModule().then((Module) => { 679 createMarkdownModule().then((Module: any) => {
281 moduleRef.current = Module; 680 moduleRef.current = Module;
282 setWasmReady(true); 681 setWasmReady(true);
283 }); 682 });
284 }, []); 683 }, []);
285 684
299 if (!content) return null; 698 if (!content) return null;
300 699
301 return ( 700 return (
302 <div id="readmeSection"> 701 <div id="readmeSection">
303 <div className="readme-header"> 702 <div className="readme-header">
304 <img src="https://img.icons8.com/material-outlined/24/000000/menu--v1.png" width="16" alt="" style={{opacity:0.5}} /> 703 <img src={ICONS.file} width="16" alt="" style={{opacity:0.5}} />
305 README.md 704 README.md
306 </div> 705 </div>
307 <div id="readmeContent" ref={contentRef}> 706 <div id="readmeContent" ref={contentRef}>
308 {!wasmReady && 'Loading...'} 707 {!wasmReady && 'Loading...'}
309 </div> 708 </div>
314 /** 713 /**
315 * Main Application Component 714 * Main Application Component
316 */ 715 */
317 function RepoBrowser() { 716 function RepoBrowser() {
318 const [currentPath, setCurrentPath] = useState(getCurrentPath()); 717 const [currentPath, setCurrentPath] = useState(getCurrentPath());
319 const [content, setContent] = useState({ files: [], directories: [] }); 718 const [content, setContent] = useState<{ files: any[]; directories: any[] }>({ files: [], directories: [] });
320 const [readme, setReadme] = useState(null); 719 const [readme, setReadme] = useState<string | null>(null);
321 const [error, setError] = useState(null); 720 const [error, setError] = useState<string | null>(null);
322 const [loading, setLoading] = useState(false); 721 const [loading, setLoading] = useState(false);
722 const [isDarkMode, setIsDarkMode] = useState(() => {
723 // Check localStorage or system preference
724 const saved = localStorage.getItem('theme');
725 if (saved) return saved === 'dark';
726 return window.matchMedia('(prefers-color-scheme: dark)').matches;
727 });
728 const [viewingFile, setViewingFile] = useState<string | null>(null);
323 729
324 function getCurrentPath() { 730 function getCurrentPath() {
325 const params = new URLSearchParams(window.location.search); 731 const params = new URLSearchParams(window.location.search);
326 return params.get('path') || ''; 732 return params.get('path') || '';
327 } 733 }
328 734
735 // Persist theme preference
736 useEffect(() => {
737 localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
738 }, [isDarkMode]);
739
329 useEffect(() => { 740 useEffect(() => {
330 const handlePopState = () => setCurrentPath(getCurrentPath()); 741 const handlePopState = () => setCurrentPath(getCurrentPath());
331 window.addEventListener('popstate', handlePopState); 742 window.addEventListener('popstate', handlePopState);
332 return () => window.removeEventListener('popstate', handlePopState); 743 return () => window.removeEventListener('popstate', handlePopState);
333 }, []); 744 }, []);
335 useEffect(() => { 746 useEffect(() => {
336 fetchDirectory(currentPath); 747 fetchDirectory(currentPath);
337 fetchReadme(currentPath); 748 fetchReadme(currentPath);
338 }, [currentPath]); 749 }, [currentPath]);
339 750
340 const navigate = (path) => { 751 const navigate = (path: string) => {
341 const newUrl = path ? `?path=${encodeURIComponent(path)}` : '/'; 752 const newUrl = path ? `?path=${encodeURIComponent(path)}` : '/';
342 window.history.pushState({ path }, '', newUrl); 753 window.history.pushState({ path }, '', newUrl);
343 setCurrentPath(path); 754 setCurrentPath(path);
344 }; 755 };
345 756
346 const fetchDirectory = async (path) => { 757 const fetchDirectory = async (path: string) => {
347 setLoading(true); 758 setLoading(true);
348 setError(null); 759 setError(null);
349 try { 760 try {
350 const url = path 761 // Check prefetch cache first
351 ? `${API_BASE}/list?path=${encodeURIComponent(path)}` 762 const cacheKey = `dir:${path}`;
352 : `${API_BASE}/list`;
353
354 const response = await fetch(url);
355 let data; 763 let data;
356 if (response.ok) 764 if (prefetchCache.has(cacheKey)) {
357 data = await response.json(); 765 data = await prefetchCache.get(cacheKey);
358 766 prefetchCache.delete(cacheKey); // Clear after use for fresh data next time
359 if (data.error) 767 } else {
768 const url = path
769 ? `${API_BASE}/list?path=${encodeURIComponent(path)}`
770 : `${API_BASE}/list`;
771 const response = await fetch(url);
772 if (response.ok) {
773 data = await response.json();
774 }
775 }
776
777 if (data?.error) {
360 throw new Error(data.error); 778 throw new Error(data.error);
361 779 }
780
362 setContent({ 781 setContent({
363 files: data.files || [], 782 files: data?.files || [],
364 directories: data.directories || [] 783 directories: data?.directories || []
365 }); 784 });
366 } catch (err) { 785 } catch (err: any) {
367 console.error('Error loading directory:', err); 786 console.error('Error loading directory:', err);
368 setError(err.message); 787 setError(err.message);
369 } finally { 788 } finally {
370 setLoading(false); 789 setLoading(false);
371 } 790 }
372 }; 791 };
373 792
374 const fetchReadme = async (path) => { 793 const fetchReadme = async (path: string) => {
794 setReadme(null);
375 const readmePath = path ? `${path}/README.md` : 'README.md'; 795 const readmePath = path ? `${path}/README.md` : 'README.md';
376 const response = await fetch(`${API_BASE}/file?path=${encodeURIComponent(readmePath)}`); 796 try {
377 console.log(response); 797 const response = await fetch(`${API_BASE}/file?path=${encodeURIComponent(readmePath)}`);
378 if (response.ok) 798 if (response.ok) {
379 { 799 const text = await response.text();
380 const text = await response.text(); 800 setReadme(text);
381 setReadme(text); 801 }
382 } 802 } catch (err) {
803 // Readme is optional, ignore errors
804 }
805 };
806
807 const handleOpenFile = useCallback((path: string) => {
808 setViewingFile(path);
809 }, []);
810
811 const handleCloseFile = useCallback(() => {
812 setViewingFile(null);
813 }, []);
814
815 const toggleTheme = () => {
816 setIsDarkMode(prev => !prev);
383 }; 817 };
384 818
385 return ( 819 return (
386 <> 820 <>
387 <GlobalStyles /> 821 <GlobalStyles isDark={isDarkMode} />
388 <div className="repo-container"> 822 <div className="repo-container">
389 823
390 {/* Header */} 824 {/* Header */}
391 <div className="header"> 825 <div className="header">
392 <img src={ICONS.repo} alt="Repo" className="header-icon" /> 826 <img src={ICONS.repo} alt="Repo" className="header-icon" />
393 <div> 827 <div>
394 <h1>Zenbu Repository</h1> 828 <h1>Zenbu Repository</h1>
395 <p className="description">Browse and manage the mercurial codebase</p> 829 <p className="description">Browse and manage the mercurial codebase</p>
396 </div> 830 </div>
831 <button className="theme-toggle" onClick={toggleTheme} title={isDarkMode ? 'Switch to light mode' : 'Switch to dark mode'}>
832 {isDarkMode ? <SunIcon color="#f0c674" /> : <MoonIcon color="#6e7681" />}
833 {isDarkMode ? 'Light' : 'Dark'}
834 </button>
397 </div> 835 </div>
398 836
399 {/* Clone Bar */} 837 {/* Clone Bar */}
400 <div className="clone-box"> 838 <div className="clone-box">
401 <div style={{display:'flex', alignItems:'center', width:'100%'}}> 839 <div style={{display:'flex', alignItems:'center', width:'100%'}}>
404 </div> 842 </div>
405 </div> 843 </div>
406 844
407 {/* Navigation & Content */} 845 {/* Navigation & Content */}
408 <Breadcrumb currentPath={currentPath} onNavigate={navigate} /> 846 <Breadcrumb currentPath={currentPath} onNavigate={navigate} />
409 847
410 {error && <div className="error-message">Error: {error}</div>} 848 {error && <div className="error-message">Error: {error}</div>}
411 849
412 {loading ? ( 850 {loading ? (
413 <div className="file-list-container" style={{padding: '40px', textAlign: 'center', color:'#666'}}> 851 <div className="file-list-container" style={{padding: '40px', textAlign: 'center', color: 'var(--text-secondary)'}}>
414 Loading files... 852 Loading files...
415 </div> 853 </div>
416 ) : ( 854 ) : (
417 <> 855 <>
418 <FileList 856 <FileList
419 directories={content.directories} 857 directories={content.directories}
420 files={content.files} 858 files={content.files}
421 onNavigate={navigate} 859 onNavigate={navigate}
860 onOpenFile={handleOpenFile}
422 /> 861 />
423 <ReadmeViewer content={readme} /> 862 <ReadmeViewer content={readme} />
424 </> 863 </>
425 )} 864 )}
426 </div> 865 </div>
866
867 {/* File Viewer Modal */}
868 {viewingFile && (
869 isMarkdownFile(viewingFile) ? (
870 <MarkdownViewerModal filePath={viewingFile} onClose={handleCloseFile} />
871 ) : (
872 <FileViewer filePath={viewingFile} onClose={handleCloseFile} />
873 )
874 )}
427 </> 875 </>
428 ); 876 );
429 } 877 }
430 878
431 export { RepoBrowser }; 879 export { RepoBrowser };