comparison hg-web/src/components/directory-browser.tsx @ 231:09a96dcb2b4c hg-web

[merge] Join existing hg-web branch head
author MrJuneJune <me@mrjunejune.com>
date Sun, 02 Aug 2026 16:50:48 -0700
parents 70de0c80d093
children
comparison
equal deleted inserted replaced
217:7ef4c9d2a72d 231:09a96dcb2b4c
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
5 // --- ICONS (served as static files) ---
6 const ICONS = {
7 folder: "/icons/folder.png",
8 file: "/icons/file.svg",
9 close: "/icons/close.png"
10 };
11
12 const API_BASE = '/api/repo';
13
14 // File extensions that should be displayed as code
15 const CODE_EXTENSIONS = new Set([
16 'js', 'jsx', 'ts', 'tsx', 'py', 'rb', 'java', 'c', 'cpp', 'h', 'hpp',
17 'cs', 'go', 'rs', 'swift', 'kt', 'scala', 'php', 'pl', 'sh', 'bash',
18 'zsh', 'fish', 'ps1', 'bat', 'cmd', 'html', 'htm', 'css', 'scss',
19 'sass', 'less', 'json', 'xml', 'yaml', 'yml', 'toml', 'ini', 'cfg',
20 'conf', 'md', 'markdown', 'txt', 'log', 'sql', 'graphql', 'vue',
21 'svelte', 'astro', 'prisma', 'dockerfile', 'makefile', 'cmake',
22 'gradle', 'pom', 'lock', 'gitignore', 'env', 'example', 'sample',
23 'bzl', 'bazel'
24 ]);
25
26 const BAZEL_FILENAMES = new Set([
27 'build', 'build.bazel', 'module.bazel', 'workspace', 'workspace.bazel'
28 ]);
29
30 type StaticPreviewKind = 'image' | 'video' | 'audio' | 'pdf';
31
32 const IMAGE_EXTENSIONS = new Set([
33 'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'bmp', 'ico', 'svg'
34 ]);
35 const VIDEO_EXTENSIONS = new Set(['mp4', 'm4v', 'webm', 'mov', 'ogv']);
36 const AUDIO_EXTENSIONS = new Set(['mp3', 'wav', 'ogg', 'oga', 'flac', 'm4a', 'aac']);
37
38 // Prefetch cache
39 const prefetchCache = new Map<string, Promise<any>>();
40
41 function isCodeFile(filename: string): boolean {
42 const ext = filename.split('.').pop()?.toLowerCase() || '';
43 const basename = filename.toLowerCase();
44 return CODE_EXTENSIONS.has(ext) ||
45 CODE_EXTENSIONS.has(basename) ||
46 BAZEL_FILENAMES.has(basename) ||
47 basename === 'dockerfile' ||
48 basename === 'makefile' ||
49 basename.startsWith('.');
50 }
51
52 function isMarkdownFile(filename: string): boolean {
53 const ext = filename.split('.').pop()?.toLowerCase() || '';
54 return ext === 'md' || ext === 'markdown';
55 }
56
57 function getStaticPreviewKind(filename: string): StaticPreviewKind | null {
58 const ext = filename.split('.').pop()?.toLowerCase() || '';
59 if (IMAGE_EXTENSIONS.has(ext)) return 'image';
60 if (VIDEO_EXTENSIONS.has(ext)) return 'video';
61 if (AUDIO_EXTENSIONS.has(ext)) return 'audio';
62 if (ext === 'pdf') return 'pdf';
63 return null;
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 }
96
97 /**
98 * Component: Breadcrumb
99 */
100 function Breadcrumb({ currentPath, onNavigate }: { currentPath: string; onNavigate: (path: string) => void }) {
101 if (!currentPath) {
102 return (
103 <nav className="breadcrumb">
104 <span className="nav-item active">root</span>
105 </nav>
106 );
107 }
108
109 const parts = currentPath.split('/').filter(p => p);
110 const crumbs = parts.map((part, index) => ({
111 name: part,
112 fullPath: parts.slice(0, index + 1).join('/')
113 }));
114
115 return (
116 <nav className="breadcrumb">
117 <a
118 href="#"
119 onClick={(e) => { e.preventDefault(); onNavigate(''); }}
120 title="Go to Root"
121 >
122 root
123 </a>
124 {crumbs.map((crumb, index) => {
125 const isLast = index === crumbs.length - 1;
126 return (
127 <React.Fragment key={crumb.fullPath}>
128 <span className="separator">/</span>
129 {isLast ? (
130 <span className="nav-item active">{crumb.name}</span>
131 ) : (
132 <a
133 href="#"
134 onClick={(e) => { e.preventDefault(); onNavigate(crumb.fullPath); }}
135 >
136 {crumb.name}
137 </a>
138 )}
139 </React.Fragment>
140 );
141 })}
142 </nav>
143 );
144 }
145
146 /**
147 * Component: FileViewer
148 * Shows file content inline with syntax highlighting
149 */
150 function FileViewer({ filePath, onClose }: { filePath: string; onClose: () => void }) {
151 const [content, setContent] = useState<string | null>(null);
152 const [loading, setLoading] = useState(true);
153 const codeRef = useRef<HTMLElement>(null);
154
155 const filename = filePath.split('/').pop() || filePath;
156
157 useEffect(() => {
158 setLoading(true);
159 getCachedFile(filePath).then((text) => {
160 setContent(text);
161 setLoading(false);
162 });
163 }, [filePath]);
164
165 useEffect(() => {
166 if (content && codeRef.current) {
167 hljs.highlightElement(codeRef.current);
168 }
169 }, [content]);
170
171 useEffect(() => {
172 const handleKeyDown = (e: KeyboardEvent) => {
173 if (e.key === 'Escape') onClose();
174 };
175 window.addEventListener('keydown', handleKeyDown);
176 return () => window.removeEventListener('keydown', handleKeyDown);
177 }, [onClose]);
178
179 const getLanguage = () => {
180 const ext = filename.split('.').pop()?.toLowerCase() || '';
181 const basename = filename.toLowerCase();
182 if (BAZEL_FILENAMES.has(basename) || ext === 'bzl' || ext === 'bazel') {
183 return 'python';
184 }
185 const langMap: Record<string, string> = {
186 js: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript',
187 py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java',
188 c: 'c', cpp: 'cpp', h: 'c', hpp: 'cpp', cs: 'csharp',
189 sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'bash',
190 json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml',
191 html: 'html', htm: 'html', css: 'css', scss: 'scss', sass: 'scss',
192 sql: 'sql', md: 'markdown', markdown: 'markdown', xml: 'xml',
193 dockerfile: 'dockerfile', makefile: 'makefile'
194 };
195 return langMap[ext] || 'plaintext';
196 };
197
198 const addLineNumbers = (text: string) => {
199 const lines = text.split('\n');
200 return lines.map((_, i) => i + 1).join('\n');
201 };
202
203 return (
204 <div className="file-viewer-overlay" onClick={onClose}>
205 <div className="file-viewer" onClick={(e) => e.stopPropagation()}>
206 <div className="file-viewer-header">
207 <span className="file-viewer-title">
208 <img src={ICONS.file} alt="" style={{ width: 16, height: 16 }} />
209 {filename}
210 </span>
211 <button className="file-viewer-close" onClick={onClose} title="Close (Esc)">
212 <img className="icon-invert" src={ICONS.close} alt="Close" />
213 </button>
214 </div>
215 <div className="file-viewer-content">
216 {loading ? (
217 <div className="file-viewer-loading">Loading...</div>
218 ) : content ? (
219 <pre style={{ display: 'flex' }}>
220 <span className="file-viewer-line-numbers">{addLineNumbers(content)}</span>
221 <code ref={codeRef} className={`language-${getLanguage()}`}>{content}</code>
222 </pre>
223 ) : (
224 <div className="file-viewer-loading">Unable to load file</div>
225 )}
226 </div>
227 </div>
228 </div>
229 );
230 }
231
232 /**
233 * Component: MarkdownViewerModal
234 */
235 function MarkdownViewerModal({ filePath, onClose }: { filePath: string; onClose: () => void }) {
236 const [content, setContent] = useState<string | null>(null);
237 const [loading, setLoading] = useState(true);
238 const contentRef = useRef<HTMLDivElement>(null);
239 const moduleRef = useRef<any>(null);
240 const [wasmReady, setWasmReady] = useState(false);
241
242 const filename = filePath.split('/').pop() || filePath;
243
244 useEffect(() => {
245 createMarkdownModule().then((Module: any) => {
246 moduleRef.current = Module;
247 setWasmReady(true);
248 });
249 }, []);
250
251 useEffect(() => {
252 setLoading(true);
253 getCachedFile(filePath).then((text) => {
254 setContent(text);
255 setLoading(false);
256 });
257 }, [filePath]);
258
259 useEffect(() => {
260 if (!content || !wasmReady || !contentRef.current || !moduleRef.current) return;
261
262 const Module = moduleRef.current;
263 const markdownToHtmlPtr = Module.cwrap('markdown_to_html', 'number', ['string']);
264 const markdownFree = Module.cwrap('markdown_free', null, ['number']);
265
266 const ptr = markdownToHtmlPtr(content);
267 const html = Module.UTF8ToString(ptr);
268 markdownFree(ptr);
269 contentRef.current.innerHTML = html;
270 }, [content, wasmReady]);
271
272 useEffect(() => {
273 const handleKeyDown = (e: KeyboardEvent) => {
274 if (e.key === 'Escape') onClose();
275 };
276 window.addEventListener('keydown', handleKeyDown);
277 return () => window.removeEventListener('keydown', handleKeyDown);
278 }, [onClose]);
279
280 return (
281 <div className="file-viewer-overlay" onClick={onClose}>
282 <div className="file-viewer" onClick={(e) => e.stopPropagation()}>
283 <div className="file-viewer-header">
284 <span className="file-viewer-title">
285 <img src={ICONS.file} alt="" style={{ width: 16, height: 16 }} />
286 {filename}
287 </span>
288 <button className="file-viewer-close" onClick={onClose} title="Close (Esc)">
289 <img className="icon-invert" src={ICONS.close} alt="Close" />
290 </button>
291 </div>
292 <div className="file-viewer-content">
293 {loading || !wasmReady ? (
294 <div className="file-viewer-loading">Loading...</div>
295 ) : content ? (
296 <div className="readme-content" ref={contentRef} />
297 ) : (
298 <div className="file-viewer-loading">Unable to load file</div>
299 )}
300 </div>
301 </div>
302 </div>
303 );
304 }
305
306 function StaticFileViewer({ filePath, onClose }: { filePath: string; onClose: () => void }) {
307 const filename = filePath.split('/').pop() || filePath;
308 const previewKind = getStaticPreviewKind(filename);
309 const fileUrl = `${API_BASE}/file?path=${encodeURIComponent(filePath)}`;
310 const [failed, setFailed] = useState(false);
311
312 useEffect(() => {
313 const handleKeyDown = (event: KeyboardEvent) => {
314 if (event.key === 'Escape') onClose();
315 };
316 window.addEventListener('keydown', handleKeyDown);
317 return () => window.removeEventListener('keydown', handleKeyDown);
318 }, [onClose]);
319
320 return (
321 <div className="file-viewer-overlay" onClick={onClose}>
322 <div
323 className="file-viewer static-file-viewer"
324 role="dialog"
325 aria-modal="true"
326 aria-label={`Preview ${filename}`}
327 onClick={(event) => event.stopPropagation()}
328 >
329 <div className="file-viewer-header">
330 <span className="file-viewer-title">
331 <img src={ICONS.file} alt="" style={{ width: 16, height: 16 }} />
332 {filename}
333 </span>
334 <span className="file-viewer-actions">
335 <a href={fileUrl} download={filename}>Download</a>
336 <button className="file-viewer-close" onClick={onClose} title="Close (Esc)">
337 <img className="icon-invert" src={ICONS.close} alt="Close" />
338 </button>
339 </span>
340 </div>
341 <div className="file-viewer-content static-file-preview">
342 {failed && <div className="error-message">Unable to preview this file.</div>}
343 {!failed && previewKind === 'image' && (
344 <img
345 className="static-file-image"
346 src={fileUrl}
347 alt={filename}
348 onError={() => setFailed(true)}
349 />
350 )}
351 {!failed && previewKind === 'video' && (
352 <video
353 className="static-file-video"
354 src={fileUrl}
355 controls
356 onError={() => setFailed(true)}
357 />
358 )}
359 {!failed && previewKind === 'audio' && (
360 <audio
361 className="static-file-audio"
362 src={fileUrl}
363 controls
364 onError={() => setFailed(true)}
365 />
366 )}
367 {!failed && previewKind === 'pdf' && (
368 <iframe
369 className="static-file-pdf"
370 src={fileUrl}
371 title={filename}
372 onError={() => setFailed(true)}
373 />
374 )}
375 </div>
376 </div>
377 </div>
378 );
379 }
380
381 /**
382 * Component: FileList
383 */
384 function FileList({ directories, files, onNavigate, onOpenFile }: {
385 directories: any[];
386 files: any[];
387 onNavigate: (path: string) => void;
388 onOpenFile: (path: string) => void;
389 }) {
390 const isEmpty = directories.length === 0 && files.length === 0;
391
392 if (isEmpty) {
393 return (
394 <div className="file-list-container">
395 <div className="empty-state">This directory is empty.</div>
396 </div>
397 );
398 }
399
400 return (
401 <div className="file-list-container">
402 <div className="file-header">Files</div>
403 <div id="fileListBody">
404 {directories.map((dir) => (
405 <FileRow
406 key={dir.abspath}
407 item={dir}
408 iconUrl={ICONS.folder}
409 isDir={true}
410 onNavigate={onNavigate}
411 onOpenFile={onOpenFile}
412 />
413 ))}
414 {files.map((file) => (
415 <FileRow
416 key={file.abspath}
417 item={file}
418 iconUrl={ICONS.file}
419 isDir={false}
420 onNavigate={onNavigate}
421 onOpenFile={onOpenFile}
422 />
423 ))}
424 </div>
425 </div>
426 );
427 }
428
429 /**
430 * Component: FileRow
431 */
432 function FileRow({ item, iconUrl, isDir, onNavigate, onOpenFile }: {
433 item: { abspath: string; basename: string };
434 iconUrl: string;
435 isDir: boolean;
436 onNavigate: (path: string) => void;
437 onOpenFile: (path: string) => void;
438 }) {
439 const handleClick = (e: React.MouseEvent) => {
440 e.preventDefault();
441 if (isDir) {
442 onNavigate(item.abspath);
443 } else if (isCodeFile(item.basename) || getStaticPreviewKind(item.basename)) {
444 onOpenFile(item.abspath);
445 } else {
446 window.open(`/api/repo/file?path=${encodeURIComponent(item.abspath)}`, '_blank');
447 }
448 };
449
450 const handleMouseEnter = () => {
451 if (isDir) {
452 prefetchDirectory(item.abspath);
453 } else if (isCodeFile(item.basename)) {
454 prefetchFile(item.abspath);
455 }
456 };
457
458 const href = isDir
459 ? `#`
460 : `/api/repo/file?path=${encodeURIComponent(item.abspath)}`;
461
462 return (
463 <div className="file-row" onMouseEnter={handleMouseEnter}>
464 <span className="icon">
465 <img className="icon-invert" src={iconUrl} alt={isDir ? "Directory" : "File"} />
466 </span>
467 <span className="name">
468 <a href={href} onClick={handleClick}>
469 {item.basename}
470 </a>
471 </span>
472 </div>
473 );
474 }
475
476 /**
477 * Component: ReadmeViewer
478 */
479 function ReadmeViewer({ content }: { content: string | null }) {
480 const contentRef = useRef<HTMLDivElement>(null);
481 const moduleRef = useRef<any>(null);
482 const [wasmReady, setWasmReady] = useState(false);
483
484 useEffect(() => {
485 createMarkdownModule().then((Module: any) => {
486 moduleRef.current = Module;
487 setWasmReady(true);
488 });
489 }, []);
490
491 useEffect(() => {
492 if (!content || !wasmReady || !contentRef.current || !moduleRef.current) return;
493
494 const Module = moduleRef.current;
495 const markdownToHtmlPtr = Module.cwrap('markdown_to_html', 'number', ['string']);
496 const markdownFree = Module.cwrap('markdown_free', null, ['number']);
497
498 const ptr = markdownToHtmlPtr(content);
499 const html = Module.UTF8ToString(ptr);
500 markdownFree(ptr);
501 contentRef.current.innerHTML = html;
502 }, [content, wasmReady]);
503
504 if (!content) return null;
505
506 return (
507 <div className="readme-section">
508 <div className="readme-header">
509 <img className="icon-invert" src={ICONS.file} width="16" alt="" style={{ opacity: 0.5 }} />
510 README.md
511 </div>
512 <div className="readme-content" ref={contentRef}>
513 {!wasmReady && 'Loading...'}
514 </div>
515 </div>
516 );
517 }
518
519 /**
520 * Directory Browser Component (no header/footer - for embedding in app)
521 */
522 interface DirectoryBrowserProps {
523 initialPath?: string;
524 onPathChange?: (path: string) => void;
525 }
526
527 function DirectoryBrowser({ initialPath = '', onPathChange }: DirectoryBrowserProps) {
528 const [currentPath, setCurrentPath] = useState(initialPath);
529 const [content, setContent] = useState<{ files: any[]; directories: any[] }>({ files: [], directories: [] });
530 const [readme, setReadme] = useState<string | null>(null);
531 const [error, setError] = useState<string | null>(null);
532 const [loading, setLoading] = useState(false);
533 const [viewingFile, setViewingFile] = useState<string | null>(null);
534 const requestGeneration = useRef(0);
535
536 // Sync with initialPath prop
537 useEffect(() => {
538 setCurrentPath(initialPath);
539 }, [initialPath]);
540
541 useEffect(() => {
542 const generation = ++requestGeneration.current;
543 fetchDirectory(currentPath, generation);
544 fetchReadme(currentPath, generation);
545 }, [currentPath]);
546
547 const navigate = useCallback((path: string) => {
548 setCurrentPath(path);
549 onPathChange?.(path);
550 }, [onPathChange]);
551
552 const fetchDirectory = async (path: string, generation: number) => {
553 setLoading(true);
554 setError(null);
555 try {
556 const cacheKey = `dir:${path}`;
557 let data;
558 if (prefetchCache.has(cacheKey)) {
559 data = await prefetchCache.get(cacheKey);
560 prefetchCache.delete(cacheKey);
561 } else {
562 const url = path
563 ? `${API_BASE}/list?path=${encodeURIComponent(path)}`
564 : `${API_BASE}/list`;
565 const response = await fetch(url);
566 if (response.ok) {
567 data = await response.json();
568 }
569 }
570
571 if (data?.error) {
572 throw new Error(data.error);
573 }
574
575 if (generation === requestGeneration.current) {
576 setContent({
577 files: data?.files || [],
578 directories: data?.directories || []
579 });
580 }
581 } catch (err: any) {
582 console.error('Error loading directory:', err);
583 if (generation === requestGeneration.current) setError(err.message);
584 } finally {
585 if (generation === requestGeneration.current) setLoading(false);
586 }
587 };
588
589 const fetchReadme = async (path: string, generation: number) => {
590 if (generation === requestGeneration.current) setReadme(null);
591 try {
592 const url = path
593 ? `${API_BASE}/readme?path=${encodeURIComponent(path)}`
594 : `${API_BASE}/readme`;
595 const response = await fetch(url);
596 if (response.ok) {
597 const text = await response.text();
598 if (generation === requestGeneration.current) setReadme(text || null);
599 }
600 } catch (err) {
601 // Readme is optional
602 }
603 };
604
605 const handleOpenFile = useCallback((path: string) => {
606 setViewingFile(path);
607 }, []);
608
609 const handleCloseFile = useCallback(() => {
610 setViewingFile(null);
611 }, []);
612
613 return (
614 <>
615 <Breadcrumb currentPath={currentPath} onNavigate={navigate} />
616
617 {error && <div className="error-message">Error: {error}</div>}
618
619 {loading ? (
620 <div className="file-list-container">
621 <div className="loading-state">Loading files...</div>
622 </div>
623 ) : (
624 <>
625 <FileList
626 directories={content.directories}
627 files={content.files}
628 onNavigate={navigate}
629 onOpenFile={handleOpenFile}
630 />
631 <ReadmeViewer content={readme} />
632 </>
633 )}
634
635 {/* File Viewer Modal */}
636 {viewingFile && (
637 isMarkdownFile(viewingFile) ? (
638 <MarkdownViewerModal filePath={viewingFile} onClose={handleCloseFile} />
639 ) : getStaticPreviewKind(viewingFile) ? (
640 <StaticFileViewer filePath={viewingFile} onClose={handleCloseFile} />
641 ) : (
642 <FileViewer filePath={viewingFile} onClose={handleCloseFile} />
643 )
644 )}
645 </>
646 );
647 }
648
649 export { DirectoryBrowser };