Mercurial
view hg-web/src/components/directory-browser.tsx @ 212:84826b3c655b
[MrJuneJune] Forgot to add assets.
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Sun, 15 Feb 2026 21:38:36 -0800 |
| parents | 9f4429c49733 |
| children |
line wrap: on
line source
import React, { useState, useEffect, useRef, useCallback } from 'react'; import createMarkdownModule from 'markdown_converter/markdown_to_html_wasm/markdown_to_html_bin.js'; import hljs from 'third_party/highlight/highlight.min.js'; // --- ICONS (served as static files) --- const ICONS = { folder: "/icons/folder.png", file: "/icons/file.svg", close: "/icons/close.png" }; const API_BASE = '/api/repo'; // File extensions that should be displayed as code const CODE_EXTENSIONS = new Set([ 'js', 'jsx', 'ts', 'tsx', 'py', 'rb', 'java', 'c', 'cpp', 'h', 'hpp', 'cs', 'go', 'rs', 'swift', 'kt', 'scala', 'php', 'pl', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd', 'html', 'htm', 'css', 'scss', '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' ]); // Prefetch cache const prefetchCache = new Map<string, Promise<any>>(); function isCodeFile(filename: string): boolean { const ext = filename.split('.').pop()?.toLowerCase() || ''; const basename = filename.toLowerCase(); return CODE_EXTENSIONS.has(ext) || CODE_EXTENSIONS.has(basename) || basename === 'dockerfile' || basename === 'makefile' || basename.startsWith('.'); } function isMarkdownFile(filename: string): boolean { const ext = filename.split('.').pop()?.toLowerCase() || ''; return ext === 'md' || ext === 'markdown'; } function prefetchDirectory(path: string): void { const cacheKey = `dir:${path}`; if (prefetchCache.has(cacheKey)) return; const url = path ? `${API_BASE}/list?path=${encodeURIComponent(path)}` : `${API_BASE}/list`; prefetchCache.set(cacheKey, fetch(url).then(r => r.json()).catch(() => null)); } function prefetchFile(path: string): void { const cacheKey = `file:${path}`; if (prefetchCache.has(cacheKey)) return; prefetchCache.set(cacheKey, fetch(`${API_BASE}/file?path=${encodeURIComponent(path)}`) .then(r => r.ok ? r.text() : null) .catch(() => null) ); } async function getCachedFile(path: string): Promise<string | null> { const cacheKey = `file:${path}`; if (prefetchCache.has(cacheKey)) { return prefetchCache.get(cacheKey); } prefetchFile(path); return prefetchCache.get(cacheKey)!; } /** * Component: Breadcrumb */ function Breadcrumb({ currentPath, onNavigate }: { currentPath: string; onNavigate: (path: string) => void }) { if (!currentPath) { return ( <nav className="breadcrumb"> <span className="nav-item active">root</span> </nav> ); } const parts = currentPath.split('/').filter(p => p); const crumbs = parts.map((part, index) => ({ name: part, fullPath: parts.slice(0, index + 1).join('/') })); return ( <nav className="breadcrumb"> <a href="#" onClick={(e) => { e.preventDefault(); onNavigate(''); }} title="Go to Root" > root </a> {crumbs.map((crumb, index) => { const isLast = index === crumbs.length - 1; return ( <React.Fragment key={crumb.fullPath}> <span className="separator">/</span> {isLast ? ( <span className="nav-item active">{crumb.name}</span> ) : ( <a href="#" onClick={(e) => { e.preventDefault(); onNavigate(crumb.fullPath); }} > {crumb.name} </a> )} </React.Fragment> ); })} </nav> ); } /** * Component: FileViewer * Shows file content inline with syntax highlighting */ function FileViewer({ filePath, onClose }: { filePath: string; onClose: () => void }) { const [content, setContent] = useState<string | null>(null); const [loading, setLoading] = useState(true); const codeRef = useRef<HTMLElement>(null); const filename = filePath.split('/').pop() || filePath; useEffect(() => { setLoading(true); getCachedFile(filePath).then((text) => { setContent(text); setLoading(false); }); }, [filePath]); useEffect(() => { if (content && codeRef.current) { hljs.highlightElement(codeRef.current); } }, [content]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [onClose]); const getLanguage = () => { const ext = filename.split('.').pop()?.toLowerCase() || ''; const langMap: Record<string, string> = { js: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript', py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java', c: 'c', cpp: 'cpp', h: 'c', hpp: 'cpp', cs: 'csharp', sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'bash', json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml', html: 'html', htm: 'html', css: 'css', scss: 'scss', sass: 'scss', sql: 'sql', md: 'markdown', markdown: 'markdown', xml: 'xml', dockerfile: 'dockerfile', makefile: 'makefile' }; return langMap[ext] || 'plaintext'; }; const addLineNumbers = (text: string) => { const lines = text.split('\n'); return lines.map((_, i) => i + 1).join('\n'); }; return ( <div className="file-viewer-overlay" onClick={onClose}> <div className="file-viewer" onClick={(e) => e.stopPropagation()}> <div className="file-viewer-header"> <span className="file-viewer-title"> <img src={ICONS.file} alt="" style={{ width: 16, height: 16 }} /> {filename} </span> <button className="file-viewer-close" onClick={onClose} title="Close (Esc)"> <img className="icon-invert" src={ICONS.close} alt="Close" /> </button> </div> <div className="file-viewer-content"> {loading ? ( <div className="file-viewer-loading">Loading...</div> ) : content ? ( <pre style={{ display: 'flex' }}> <span className="file-viewer-line-numbers">{addLineNumbers(content)}</span> <code ref={codeRef} className={`language-${getLanguage()}`}>{content}</code> </pre> ) : ( <div className="file-viewer-loading">Unable to load file</div> )} </div> </div> </div> ); } /** * Component: MarkdownViewerModal */ function MarkdownViewerModal({ filePath, onClose }: { filePath: string; onClose: () => void }) { const [content, setContent] = useState<string | null>(null); const [loading, setLoading] = useState(true); const contentRef = useRef<HTMLDivElement>(null); const moduleRef = useRef<any>(null); const [wasmReady, setWasmReady] = useState(false); const filename = filePath.split('/').pop() || filePath; useEffect(() => { createMarkdownModule().then((Module: any) => { moduleRef.current = Module; setWasmReady(true); }); }, []); useEffect(() => { setLoading(true); getCachedFile(filePath).then((text) => { setContent(text); setLoading(false); }); }, [filePath]); useEffect(() => { if (!content || !wasmReady || !contentRef.current || !moduleRef.current) return; const Module = moduleRef.current; const markdownToHtmlPtr = Module.cwrap('markdown_to_html', 'number', ['string']); const markdownFree = Module.cwrap('markdown_free', null, ['number']); const ptr = markdownToHtmlPtr(content); const html = Module.UTF8ToString(ptr); markdownFree(ptr); contentRef.current.innerHTML = html; }, [content, wasmReady]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.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" onClick={(e) => e.stopPropagation()}> <div className="file-viewer-header"> <span className="file-viewer-title"> <img src={ICONS.file} alt="" style={{ width: 16, height: 16 }} /> {filename} </span> <button className="file-viewer-close" onClick={onClose} title="Close (Esc)"> <img className="icon-invert" src={ICONS.close} alt="Close" /> </button> </div> <div className="file-viewer-content"> {loading || !wasmReady ? ( <div className="file-viewer-loading">Loading...</div> ) : content ? ( <div className="readme-content" ref={contentRef} /> ) : ( <div className="file-viewer-loading">Unable to load file</div> )} </div> </div> </div> ); } /** * Component: FileList */ function FileList({ directories, files, onNavigate, onOpenFile }: { directories: any[]; files: any[]; onNavigate: (path: string) => void; onOpenFile: (path: string) => void; }) { const isEmpty = directories.length === 0 && files.length === 0; if (isEmpty) { return ( <div className="file-list-container"> <div className="empty-state">This directory is empty.</div> </div> ); } return ( <div className="file-list-container"> <div className="file-header">Files</div> <div id="fileListBody"> {directories.map((dir) => ( <FileRow key={dir.abspath} item={dir} iconUrl={ICONS.folder} isDir={true} onNavigate={onNavigate} onOpenFile={onOpenFile} /> ))} {files.map((file) => ( <FileRow key={file.abspath} item={file} iconUrl={ICONS.file} isDir={false} onNavigate={onNavigate} onOpenFile={onOpenFile} /> ))} </div> </div> ); } /** * Component: FileRow */ function FileRow({ item, iconUrl, isDir, onNavigate, onOpenFile }: { item: { abspath: string; basename: string }; iconUrl: string; isDir: boolean; onNavigate: (path: string) => void; onOpenFile: (path: string) => void; }) { const handleClick = (e: React.MouseEvent) => { e.preventDefault(); if (isDir) { onNavigate(item.abspath); } else if (isCodeFile(item.basename)) { onOpenFile(item.abspath); } else { window.open(`/api/repo/file?path=${encodeURIComponent(item.abspath)}`, '_blank'); } }; const handleMouseEnter = () => { if (isDir) { prefetchDirectory(item.abspath); } else if (isCodeFile(item.basename)) { prefetchFile(item.abspath); } }; const href = isDir ? `#` : `/api/repo/file?path=${encodeURIComponent(item.abspath)}`; return ( <div className="file-row" onMouseEnter={handleMouseEnter}> <span className="icon"> <img className="icon-invert" src={iconUrl} alt={isDir ? "Directory" : "File"} /> </span> <span className="name"> <a href={href} onClick={handleClick}> {item.basename} </a> </span> </div> ); } /** * Component: ReadmeViewer */ function ReadmeViewer({ content }: { content: string | null }) { const contentRef = useRef<HTMLDivElement>(null); const moduleRef = useRef<any>(null); const [wasmReady, setWasmReady] = useState(false); useEffect(() => { createMarkdownModule().then((Module: any) => { moduleRef.current = Module; setWasmReady(true); }); }, []); useEffect(() => { if (!content || !wasmReady || !contentRef.current || !moduleRef.current) return; const Module = moduleRef.current; const markdownToHtmlPtr = Module.cwrap('markdown_to_html', 'number', ['string']); const markdownFree = Module.cwrap('markdown_free', null, ['number']); const ptr = markdownToHtmlPtr(content); const html = Module.UTF8ToString(ptr); markdownFree(ptr); contentRef.current.innerHTML = html; }, [content, wasmReady]); if (!content) return null; return ( <div className="readme-section"> <div className="readme-header"> <img className="icon-invert" src={ICONS.file} width="16" alt="" style={{ opacity: 0.5 }} /> README.md </div> <div className="readme-content" ref={contentRef}> {!wasmReady && 'Loading...'} </div> </div> ); } /** * Directory Browser Component (no header/footer - for embedding in app) */ interface DirectoryBrowserProps { initialPath?: string; onPathChange?: (path: string) => void; } function DirectoryBrowser({ initialPath = '', onPathChange }: DirectoryBrowserProps) { const [currentPath, setCurrentPath] = useState(initialPath); const [content, setContent] = useState<{ files: any[]; directories: any[] }>({ files: [], directories: [] }); const [readme, setReadme] = useState<string | null>(null); const [error, setError] = useState<string | null>(null); const [loading, setLoading] = useState(false); const [viewingFile, setViewingFile] = useState<string | null>(null); // Sync with initialPath prop useEffect(() => { setCurrentPath(initialPath); }, [initialPath]); useEffect(() => { fetchDirectory(currentPath); fetchReadme(currentPath); }, [currentPath]); const navigate = useCallback((path: string) => { setCurrentPath(path); onPathChange?.(path); }, [onPathChange]); const fetchDirectory = async (path: string) => { setLoading(true); setError(null); try { const cacheKey = `dir:${path}`; let data; if (prefetchCache.has(cacheKey)) { data = await prefetchCache.get(cacheKey); prefetchCache.delete(cacheKey); } else { const url = path ? `${API_BASE}/list?path=${encodeURIComponent(path)}` : `${API_BASE}/list`; const response = await fetch(url); if (response.ok) { data = await response.json(); } } if (data?.error) { throw new Error(data.error); } setContent({ files: data?.files || [], directories: data?.directories || [] }); } catch (err: any) { console.error('Error loading directory:', err); setError(err.message); } finally { setLoading(false); } }; const fetchReadme = async (path: string) => { setReadme(null); const readmePath = path ? `${path}/README.md` : 'README.md'; try { const response = await fetch(`${API_BASE}/file?path=${encodeURIComponent(readmePath)}`); if (response.ok) { const text = await response.text(); setReadme(text); } } catch (err) { // Readme is optional } }; const handleOpenFile = useCallback((path: string) => { setViewingFile(path); }, []); const handleCloseFile = useCallback(() => { setViewingFile(null); }, []); return ( <> <Breadcrumb currentPath={currentPath} onNavigate={navigate} /> {error && <div className="error-message">Error: {error}</div>} {loading ? ( <div className="file-list-container"> <div className="loading-state">Loading files...</div> </div> ) : ( <> <FileList directories={content.directories} files={content.files} onNavigate={navigate} onOpenFile={handleOpenFile} /> <ReadmeViewer content={readme} /> </> )} {/* File Viewer Modal */} {viewingFile && ( isMarkdownFile(viewingFile) ? ( <MarkdownViewerModal filePath={viewingFile} onClose={handleCloseFile} /> ) : ( <FileViewer filePath={viewingFile} onClose={handleCloseFile} /> ) )} </> ); } export { DirectoryBrowser };