Mercurial
diff hg-web/src/components/directory-browser.tsx @ 193:9f4429c49733 hg-web
[HgWeb] Making progress....
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Sun, 25 Jan 2026 20:04:55 -0800 |
| parents | |
| children |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/src/components/directory-browser.tsx Sun Jan 25 20:04:55 2026 -0800 @@ -0,0 +1,539 @@ +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 };