comparison hg-web/src/components/directory-browser.tsx @ 226:3fa4bf481f42

[merge] Merge hg-web into default
author MrJuneJune <me@mrjunejune.com>
date Sun, 02 Aug 2026 14:42:01 -0700
parents 70de0c80d093
children
comparison
equal deleted inserted replaced
220:eb8b4230fdb9 226:3fa4bf481f42
17 'cs', 'go', 'rs', 'swift', 'kt', 'scala', 'php', 'pl', 'sh', 'bash', 17 'cs', 'go', 'rs', 'swift', 'kt', 'scala', 'php', 'pl', 'sh', 'bash',
18 'zsh', 'fish', 'ps1', 'bat', 'cmd', 'html', 'htm', 'css', 'scss', 18 'zsh', 'fish', 'ps1', 'bat', 'cmd', 'html', 'htm', 'css', 'scss',
19 'sass', 'less', 'json', 'xml', 'yaml', 'yml', 'toml', 'ini', 'cfg', 19 'sass', 'less', 'json', 'xml', 'yaml', 'yml', 'toml', 'ini', 'cfg',
20 'conf', 'md', 'markdown', 'txt', 'log', 'sql', 'graphql', 'vue', 20 'conf', 'md', 'markdown', 'txt', 'log', 'sql', 'graphql', 'vue',
21 'svelte', 'astro', 'prisma', 'dockerfile', 'makefile', 'cmake', 21 'svelte', 'astro', 'prisma', 'dockerfile', 'makefile', 'cmake',
22 'gradle', 'pom', 'lock', 'gitignore', 'env', 'example', 'sample' 22 'gradle', 'pom', 'lock', 'gitignore', 'env', 'example', 'sample',
23 'bzl', 'bazel'
23 ]); 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']);
24 37
25 // Prefetch cache 38 // Prefetch cache
26 const prefetchCache = new Map<string, Promise<any>>(); 39 const prefetchCache = new Map<string, Promise<any>>();
27 40
28 function isCodeFile(filename: string): boolean { 41 function isCodeFile(filename: string): boolean {
29 const ext = filename.split('.').pop()?.toLowerCase() || ''; 42 const ext = filename.split('.').pop()?.toLowerCase() || '';
30 const basename = filename.toLowerCase(); 43 const basename = filename.toLowerCase();
31 return CODE_EXTENSIONS.has(ext) || 44 return CODE_EXTENSIONS.has(ext) ||
32 CODE_EXTENSIONS.has(basename) || 45 CODE_EXTENSIONS.has(basename) ||
46 BAZEL_FILENAMES.has(basename) ||
33 basename === 'dockerfile' || 47 basename === 'dockerfile' ||
34 basename === 'makefile' || 48 basename === 'makefile' ||
35 basename.startsWith('.'); 49 basename.startsWith('.');
36 } 50 }
37 51
38 function isMarkdownFile(filename: string): boolean { 52 function isMarkdownFile(filename: string): boolean {
39 const ext = filename.split('.').pop()?.toLowerCase() || ''; 53 const ext = filename.split('.').pop()?.toLowerCase() || '';
40 return ext === 'md' || ext === 'markdown'; 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;
41 } 64 }
42 65
43 function prefetchDirectory(path: string): void { 66 function prefetchDirectory(path: string): void {
44 const cacheKey = `dir:${path}`; 67 const cacheKey = `dir:${path}`;
45 if (prefetchCache.has(cacheKey)) return; 68 if (prefetchCache.has(cacheKey)) return;
153 return () => window.removeEventListener('keydown', handleKeyDown); 176 return () => window.removeEventListener('keydown', handleKeyDown);
154 }, [onClose]); 177 }, [onClose]);
155 178
156 const getLanguage = () => { 179 const getLanguage = () => {
157 const ext = filename.split('.').pop()?.toLowerCase() || ''; 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 }
158 const langMap: Record<string, string> = { 185 const langMap: Record<string, string> = {
159 js: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript', 186 js: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript',
160 py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java', 187 py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java',
161 c: 'c', cpp: 'cpp', h: 'c', hpp: 'cpp', cs: 'csharp', 188 c: 'c', cpp: 'cpp', h: 'c', hpp: 'cpp', cs: 'csharp',
162 sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'bash', 189 sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'bash',
274 </div> 301 </div>
275 </div> 302 </div>
276 ); 303 );
277 } 304 }
278 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
279 /** 381 /**
280 * Component: FileList 382 * Component: FileList
281 */ 383 */
282 function FileList({ directories, files, onNavigate, onOpenFile }: { 384 function FileList({ directories, files, onNavigate, onOpenFile }: {
283 directories: any[]; 385 directories: any[];
336 }) { 438 }) {
337 const handleClick = (e: React.MouseEvent) => { 439 const handleClick = (e: React.MouseEvent) => {
338 e.preventDefault(); 440 e.preventDefault();
339 if (isDir) { 441 if (isDir) {
340 onNavigate(item.abspath); 442 onNavigate(item.abspath);
341 } else if (isCodeFile(item.basename)) { 443 } else if (isCodeFile(item.basename) || getStaticPreviewKind(item.basename)) {
342 onOpenFile(item.abspath); 444 onOpenFile(item.abspath);
343 } else { 445 } else {
344 window.open(`/api/repo/file?path=${encodeURIComponent(item.abspath)}`, '_blank'); 446 window.open(`/api/repo/file?path=${encodeURIComponent(item.abspath)}`, '_blank');
345 } 447 }
346 }; 448 };
427 const [content, setContent] = useState<{ files: any[]; directories: any[] }>({ files: [], directories: [] }); 529 const [content, setContent] = useState<{ files: any[]; directories: any[] }>({ files: [], directories: [] });
428 const [readme, setReadme] = useState<string | null>(null); 530 const [readme, setReadme] = useState<string | null>(null);
429 const [error, setError] = useState<string | null>(null); 531 const [error, setError] = useState<string | null>(null);
430 const [loading, setLoading] = useState(false); 532 const [loading, setLoading] = useState(false);
431 const [viewingFile, setViewingFile] = useState<string | null>(null); 533 const [viewingFile, setViewingFile] = useState<string | null>(null);
534 const requestGeneration = useRef(0);
432 535
433 // Sync with initialPath prop 536 // Sync with initialPath prop
434 useEffect(() => { 537 useEffect(() => {
435 setCurrentPath(initialPath); 538 setCurrentPath(initialPath);
436 }, [initialPath]); 539 }, [initialPath]);
437 540
438 useEffect(() => { 541 useEffect(() => {
439 fetchDirectory(currentPath); 542 const generation = ++requestGeneration.current;
440 fetchReadme(currentPath); 543 fetchDirectory(currentPath, generation);
544 fetchReadme(currentPath, generation);
441 }, [currentPath]); 545 }, [currentPath]);
442 546
443 const navigate = useCallback((path: string) => { 547 const navigate = useCallback((path: string) => {
444 setCurrentPath(path); 548 setCurrentPath(path);
445 onPathChange?.(path); 549 onPathChange?.(path);
446 }, [onPathChange]); 550 }, [onPathChange]);
447 551
448 const fetchDirectory = async (path: string) => { 552 const fetchDirectory = async (path: string, generation: number) => {
449 setLoading(true); 553 setLoading(true);
450 setError(null); 554 setError(null);
451 try { 555 try {
452 const cacheKey = `dir:${path}`; 556 const cacheKey = `dir:${path}`;
453 let data; 557 let data;
466 570
467 if (data?.error) { 571 if (data?.error) {
468 throw new Error(data.error); 572 throw new Error(data.error);
469 } 573 }
470 574
471 setContent({ 575 if (generation === requestGeneration.current) {
472 files: data?.files || [], 576 setContent({
473 directories: data?.directories || [] 577 files: data?.files || [],
474 }); 578 directories: data?.directories || []
579 });
580 }
475 } catch (err: any) { 581 } catch (err: any) {
476 console.error('Error loading directory:', err); 582 console.error('Error loading directory:', err);
477 setError(err.message); 583 if (generation === requestGeneration.current) setError(err.message);
478 } finally { 584 } finally {
479 setLoading(false); 585 if (generation === requestGeneration.current) setLoading(false);
480 } 586 }
481 }; 587 };
482 588
483 const fetchReadme = async (path: string) => { 589 const fetchReadme = async (path: string, generation: number) => {
484 setReadme(null); 590 if (generation === requestGeneration.current) setReadme(null);
485 const readmePath = path ? `${path}/README.md` : 'README.md';
486 try { 591 try {
487 const response = await fetch(`${API_BASE}/file?path=${encodeURIComponent(readmePath)}`); 592 const url = path
593 ? `${API_BASE}/readme?path=${encodeURIComponent(path)}`
594 : `${API_BASE}/readme`;
595 const response = await fetch(url);
488 if (response.ok) { 596 if (response.ok) {
489 const text = await response.text(); 597 const text = await response.text();
490 setReadme(text); 598 if (generation === requestGeneration.current) setReadme(text || null);
491 } 599 }
492 } catch (err) { 600 } catch (err) {
493 // Readme is optional 601 // Readme is optional
494 } 602 }
495 }; 603 };
526 634
527 {/* File Viewer Modal */} 635 {/* File Viewer Modal */}
528 {viewingFile && ( 636 {viewingFile && (
529 isMarkdownFile(viewingFile) ? ( 637 isMarkdownFile(viewingFile) ? (
530 <MarkdownViewerModal filePath={viewingFile} onClose={handleCloseFile} /> 638 <MarkdownViewerModal filePath={viewingFile} onClose={handleCloseFile} />
639 ) : getStaticPreviewKind(viewingFile) ? (
640 <StaticFileViewer filePath={viewingFile} onClose={handleCloseFile} />
531 ) : ( 641 ) : (
532 <FileViewer filePath={viewingFile} onClose={handleCloseFile} /> 642 <FileViewer filePath={viewingFile} onClose={handleCloseFile} />
533 ) 643 )
534 )} 644 )}
535 </> 645 </>