Mercurial
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 </> |