Mercurial
diff 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 |
line wrap: on
line diff
--- a/hg-web/src/components/directory-browser.tsx Sun Aug 02 08:52:13 2026 -0700 +++ b/hg-web/src/components/directory-browser.tsx Sun Aug 02 14:42:01 2026 -0700 @@ -19,9 +19,22 @@ 'sass', 'less', 'json', 'xml', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', 'md', 'markdown', 'txt', 'log', 'sql', 'graphql', 'vue', 'svelte', 'astro', 'prisma', 'dockerfile', 'makefile', 'cmake', - 'gradle', 'pom', 'lock', 'gitignore', 'env', 'example', 'sample' + 'gradle', 'pom', 'lock', 'gitignore', 'env', 'example', 'sample', + 'bzl', 'bazel' +]); + +const BAZEL_FILENAMES = new Set([ + 'build', 'build.bazel', 'module.bazel', 'workspace', 'workspace.bazel' ]); +type StaticPreviewKind = 'image' | 'video' | 'audio' | 'pdf'; + +const IMAGE_EXTENSIONS = new Set([ + 'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'bmp', 'ico', 'svg' +]); +const VIDEO_EXTENSIONS = new Set(['mp4', 'm4v', 'webm', 'mov', 'ogv']); +const AUDIO_EXTENSIONS = new Set(['mp3', 'wav', 'ogg', 'oga', 'flac', 'm4a', 'aac']); + // Prefetch cache const prefetchCache = new Map<string, Promise<any>>(); @@ -30,6 +43,7 @@ const basename = filename.toLowerCase(); return CODE_EXTENSIONS.has(ext) || CODE_EXTENSIONS.has(basename) || + BAZEL_FILENAMES.has(basename) || basename === 'dockerfile' || basename === 'makefile' || basename.startsWith('.'); @@ -40,6 +54,15 @@ return ext === 'md' || ext === 'markdown'; } +function getStaticPreviewKind(filename: string): StaticPreviewKind | null { + const ext = filename.split('.').pop()?.toLowerCase() || ''; + if (IMAGE_EXTENSIONS.has(ext)) return 'image'; + if (VIDEO_EXTENSIONS.has(ext)) return 'video'; + if (AUDIO_EXTENSIONS.has(ext)) return 'audio'; + if (ext === 'pdf') return 'pdf'; + return null; +} + function prefetchDirectory(path: string): void { const cacheKey = `dir:${path}`; if (prefetchCache.has(cacheKey)) return; @@ -155,6 +178,10 @@ const getLanguage = () => { const ext = filename.split('.').pop()?.toLowerCase() || ''; + const basename = filename.toLowerCase(); + if (BAZEL_FILENAMES.has(basename) || ext === 'bzl' || ext === 'bazel') { + return 'python'; + } const langMap: Record<string, string> = { js: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript', py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java', @@ -276,6 +303,81 @@ ); } +function StaticFileViewer({ filePath, onClose }: { filePath: string; onClose: () => void }) { + const filename = filePath.split('/').pop() || filePath; + const previewKind = getStaticPreviewKind(filename); + const fileUrl = `${API_BASE}/file?path=${encodeURIComponent(filePath)}`; + const [failed, setFailed] = useState(false); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + return ( + <div className="file-viewer-overlay" onClick={onClose}> + <div + className="file-viewer static-file-viewer" + role="dialog" + aria-modal="true" + aria-label={`Preview ${filename}`} + onClick={(event) => event.stopPropagation()} + > + <div className="file-viewer-header"> + <span className="file-viewer-title"> + <img src={ICONS.file} alt="" style={{ width: 16, height: 16 }} /> + {filename} + </span> + <span className="file-viewer-actions"> + <a href={fileUrl} download={filename}>Download</a> + <button className="file-viewer-close" onClick={onClose} title="Close (Esc)"> + <img className="icon-invert" src={ICONS.close} alt="Close" /> + </button> + </span> + </div> + <div className="file-viewer-content static-file-preview"> + {failed && <div className="error-message">Unable to preview this file.</div>} + {!failed && previewKind === 'image' && ( + <img + className="static-file-image" + src={fileUrl} + alt={filename} + onError={() => setFailed(true)} + /> + )} + {!failed && previewKind === 'video' && ( + <video + className="static-file-video" + src={fileUrl} + controls + onError={() => setFailed(true)} + /> + )} + {!failed && previewKind === 'audio' && ( + <audio + className="static-file-audio" + src={fileUrl} + controls + onError={() => setFailed(true)} + /> + )} + {!failed && previewKind === 'pdf' && ( + <iframe + className="static-file-pdf" + src={fileUrl} + title={filename} + onError={() => setFailed(true)} + /> + )} + </div> + </div> + </div> + ); +} + /** * Component: FileList */ @@ -338,7 +440,7 @@ e.preventDefault(); if (isDir) { onNavigate(item.abspath); - } else if (isCodeFile(item.basename)) { + } else if (isCodeFile(item.basename) || getStaticPreviewKind(item.basename)) { onOpenFile(item.abspath); } else { window.open(`/api/repo/file?path=${encodeURIComponent(item.abspath)}`, '_blank'); @@ -429,6 +531,7 @@ const [error, setError] = useState<string | null>(null); const [loading, setLoading] = useState(false); const [viewingFile, setViewingFile] = useState<string | null>(null); + const requestGeneration = useRef(0); // Sync with initialPath prop useEffect(() => { @@ -436,8 +539,9 @@ }, [initialPath]); useEffect(() => { - fetchDirectory(currentPath); - fetchReadme(currentPath); + const generation = ++requestGeneration.current; + fetchDirectory(currentPath, generation); + fetchReadme(currentPath, generation); }, [currentPath]); const navigate = useCallback((path: string) => { @@ -445,7 +549,7 @@ onPathChange?.(path); }, [onPathChange]); - const fetchDirectory = async (path: string) => { + const fetchDirectory = async (path: string, generation: number) => { setLoading(true); setError(null); try { @@ -468,26 +572,30 @@ throw new Error(data.error); } - setContent({ - files: data?.files || [], - directories: data?.directories || [] - }); + if (generation === requestGeneration.current) { + setContent({ + files: data?.files || [], + directories: data?.directories || [] + }); + } } catch (err: any) { console.error('Error loading directory:', err); - setError(err.message); + if (generation === requestGeneration.current) setError(err.message); } finally { - setLoading(false); + if (generation === requestGeneration.current) setLoading(false); } }; - const fetchReadme = async (path: string) => { - setReadme(null); - const readmePath = path ? `${path}/README.md` : 'README.md'; + const fetchReadme = async (path: string, generation: number) => { + if (generation === requestGeneration.current) setReadme(null); try { - const response = await fetch(`${API_BASE}/file?path=${encodeURIComponent(readmePath)}`); + const url = path + ? `${API_BASE}/readme?path=${encodeURIComponent(path)}` + : `${API_BASE}/readme`; + const response = await fetch(url); if (response.ok) { const text = await response.text(); - setReadme(text); + if (generation === requestGeneration.current) setReadme(text || null); } } catch (err) { // Readme is optional @@ -528,6 +636,8 @@ {viewingFile && ( isMarkdownFile(viewingFile) ? ( <MarkdownViewerModal filePath={viewingFile} onClose={handleCloseFile} /> + ) : getStaticPreviewKind(viewingFile) ? ( + <StaticFileViewer filePath={viewingFile} onClose={handleCloseFile} /> ) : ( <FileViewer filePath={viewingFile} onClose={handleCloseFile} /> )