Mercurial
diff hg-web/src/repo-browser.tsx @ 191:a06710325c30 hg-web
[HgWeb] Fully working copy.
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Sat, 24 Jan 2026 21:51:51 -0800 |
| parents | a2725419f988 |
| children |
line wrap: on
line diff
--- a/hg-web/src/repo-browser.tsx Sat Jan 24 21:06:42 2026 -0800 +++ b/hg-web/src/repo-browser.tsx Sat Jan 24 21:51:51 2026 -0800 @@ -1,32 +1,121 @@ -import React, { useState, useEffect, useRef } from 'react'; +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 (Using CDN Links) --- +// --- ICONS (served as static files) --- const ICONS = { - folder: "https://cdn-icons-png.flaticon.com/512/11471/11471391.png", - file: "https://raw.githubusercontent.com/PKief/vscode-material-icon-theme/main/icons/document.svg", - home: "https://cdn-icons-png.flaticon.com/512/1946/1946488.png", + folder: "/icons/folder.png", + file: "/icons/file.svg", + home: "/icons/home.png", repo: "/public/epi_all_colors.svg", - clone: "https://cdn-icons-png.flaticon.com/512/11471/11471391.png" + close: "/icons/close.png" }; +// SVG Icons for theme toggle +const SunIcon = ({ color = "currentColor" }: { color?: string }) => ( + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <circle cx="12" cy="12" r="5"/> + <line x1="12" y1="1" x2="12" y2="3"/> + <line x1="12" y1="21" x2="12" y2="23"/> + <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/> + <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/> + <line x1="1" y1="12" x2="3" y2="12"/> + <line x1="21" y1="12" x2="23" y2="12"/> + <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/> + <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/> + </svg> +); + +const MoonIcon = ({ color = "currentColor" }: { color?: string }) => ( + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/> + </svg> +); + 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: Styles - * Injected CSS for the polished look + * Injected CSS for the polished look with dark/light mode support */ -const GlobalStyles = () => ( +const GlobalStyles = ({ isDark }: { isDark: boolean }) => ( <style>{` :root { - --bg-color: #ffffff; - --bg-subtle: #f6f8fa; - --border-color: #d0d7de; - --accent-color: #0969da; - --text-primary: #1f2328; - --text-secondary: #656d76; - --hover-color: #f3f4f6; + --bg-color: ${isDark ? '#0d1117' : '#ffffff'}; + --bg-subtle: ${isDark ? '#161b22' : '#f6f8fa'}; + --bg-code: ${isDark ? '#1c2128' : '#f6f8fa'}; + --border-color: ${isDark ? '#30363d' : '#d0d7de'}; + --accent-color: ${isDark ? '#58a6ff' : '#0969da'}; + --text-primary: ${isDark ? '#e6edf3' : '#1f2328'}; + --text-secondary: ${isDark ? '#8b949e' : '#656d76'}; + --hover-color: ${isDark ? '#1c2128' : '#f3f4f6'}; --radius: 6px; + --code-bg: ${isDark ? '#161b22' : '#ffffff'}; + } + + body { + background: var(--bg-color); + transition: background 0.2s; } .repo-container { @@ -48,6 +137,29 @@ .header h1 { margin: 0; font-size: 24px; font-weight: 600; } .description { color: var(--text-secondary); margin: 0; font-size: 14px; } + /* Theme Toggle */ + .theme-toggle { + margin-left: auto; + background: var(--bg-subtle); + border: 1px solid var(--border-color); + border-radius: var(--radius); + padding: 8px 12px; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + color: var(--text-secondary); + font-size: 13px; + transition: all 0.2s; + } + .theme-toggle:hover { + background: var(--hover-color); + color: var(--text-primary); + } + .theme-toggle svg { + flex-shrink: 0; + } + /* Clone Box */ .clone-box { background: var(--bg-subtle); @@ -60,9 +172,9 @@ align-items: center; } .clone-label { font-weight: 600; font-size: 13px; margin-right: 10px; color: var(--text-primary); } - .clone-url { + .clone-url { font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; - background: white; + background: var(--bg-color); border: 1px solid var(--border-color); padding: 4px 8px; border-radius: 4px; @@ -80,9 +192,9 @@ color: var(--text-secondary); padding: 8px 0; } - #breadcrumb a { - color: var(--accent-color); - text-decoration: none; + #breadcrumb a { + color: var(--accent-color); + text-decoration: none; border-radius: 4px; padding: 2px 6px; } @@ -95,6 +207,7 @@ border: 1px solid var(--border-color); border-radius: var(--radius); overflow: hidden; + background: var(--bg-color); } .file-header { background: var(--bg-subtle); @@ -105,10 +218,10 @@ color: var(--text-secondary); } .empty-state { padding: 40px; text-align: center; color: var(--text-secondary); } - .error-message { - padding: 15px; border: 1px solid #ffdce0; - background: #ffebe9; color: #cf222e; - border-radius: var(--radius); margin-bottom: 20px; + .error-message { + padding: 15px; border: 1px solid ${isDark ? '#f8514966' : '#ffdce0'}; + background: ${isDark ? '#f8514926' : '#ffebe9'}; color: ${isDark ? '#f85149' : '#cf222e'}; + border-radius: var(--radius); margin-bottom: 20px; } /* File Row */ @@ -121,25 +234,125 @@ } .file-row:last-child { border-bottom: none; } .file-row:hover { background: var(--hover-color); } - - .file-row .icon img { width: 20px; height: 20px; vertical-align: middle; margin-right: 12px; } - .file-row .name a { - color: var(--text-primary); - text-decoration: none; - font-size: 14px; + + .file-row .icon img { + width: 20px; + height: 20px; + vertical-align: middle; + margin-right: 12px; + filter: ${isDark ? 'invert(0.8)' : 'none'}; + } + .file-row .name a { + color: var(--text-primary); + text-decoration: none; + font-size: 14px; } .file-row .name a:hover { color: var(--accent-color); text-decoration: underline; } /* Readme */ #readmeSection { margin-top: 32px; border: 1px solid var(--border-color); border-radius: var(--radius); } - .readme-header { - background: var(--bg-subtle); - padding: 10px 16px; - font-size: 12px; font-weight: 600; + .readme-header { + background: var(--bg-subtle); + padding: 10px 16px; + font-size: 12px; font-weight: 600; border-bottom: 1px solid var(--border-color); display: flex; align-items: center; gap: 8px; } - #readmeContent { padding: 32px; background: white; overflow-x: auto; } + .readme-header img { + filter: ${isDark ? 'invert(0.7)' : 'none'}; + } + #readmeContent { padding: 32px; background: var(--bg-color); overflow-x: auto; color: var(--text-primary); } + + /* File Viewer */ + .file-viewer-overlay { + position: fixed; + inset: 0; + background: ${isDark ? 'rgba(0,0,0,0.7)' : 'rgba(0,0,0,0.5)'}; + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + padding: 20px; + } + .file-viewer { + background: var(--bg-color); + border: 1px solid var(--border-color); + border-radius: var(--radius); + width: 100%; + max-width: 900px; + max-height: 90vh; + display: flex; + flex-direction: column; + box-shadow: 0 8px 32px rgba(0,0,0,0.3); + } + .file-viewer-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: var(--bg-subtle); + border-bottom: 1px solid var(--border-color); + border-radius: var(--radius) var(--radius) 0 0; + } + .file-viewer-title { + font-weight: 600; + font-size: 14px; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 8px; + } + .file-viewer-close { + background: transparent; + border: none; + cursor: pointer; + padding: 4px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + } + .file-viewer-close:hover { + background: var(--hover-color); + } + .file-viewer-close img { + width: 16px; + height: 16px; + filter: ${isDark ? 'invert(0.7)' : 'none'}; + opacity: 0.7; + } + .file-viewer-content { + overflow: auto; + flex: 1; + } + .file-viewer-content pre { + margin: 0; + padding: 16px; + background: var(--code-bg); + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 13px; + line-height: 1.5; + overflow-x: auto; + } + .file-viewer-content code { + background: transparent; + padding: 0; + } + .file-viewer-loading { + padding: 40px; + text-align: center; + color: var(--text-secondary); + } + .file-viewer-line-numbers { + display: inline-block; + user-select: none; + text-align: right; + padding-right: 16px; + margin-right: 16px; + border-right: 1px solid var(--border-color); + color: var(--text-secondary); + opacity: 0.5; + } `}</style> ); @@ -193,9 +406,174 @@ } /** + * 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]); + + // Close on escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + // Get language from file extension for highlight.js + 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 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 + * Shows markdown content rendered in a modal + */ +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]); + + // Close on escape key + 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 src={ICONS.close} alt="Close" /> + </button> + </div> + <div className="file-viewer-content"> + {loading || !wasmReady ? ( + <div className="file-viewer-loading">Loading...</div> + ) : content ? ( + <div id="readmeContent" ref={contentRef} style={{ padding: 32 }} /> + ) : ( + <div className="file-viewer-loading">Unable to load file</div> + )} + </div> + </div> + </div> + ); +} + +/** * Component: FileList */ -function FileList({ directories, files, onNavigate }) { +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) { @@ -215,21 +593,24 @@ <div id="fileListBody"> {directories.map((dir) => ( - <FileRow + <FileRow key={dir.abspath} item={dir} iconUrl={ICONS.folder} isDir={true} onNavigate={onNavigate} + onOpenFile={onOpenFile} /> ))} {files.map((file) => ( - <FileRow + <FileRow key={file.abspath} item={file} iconUrl={ICONS.file} isDir={false} + onNavigate={onNavigate} + onOpenFile={onOpenFile} /> ))} </div> @@ -240,27 +621,45 @@ /** * Component: FileRow */ -function FileRow({ item, iconUrl, isDir, onNavigate }) { - const handleClick = (e) => { +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) { - e.preventDefault(); onNavigate(item.abspath); + } else if (isCodeFile(item.basename)) { + onOpenFile(item.abspath); + } else { + // For non-code files, open in new tab + window.open(`/api/repo/file?path=${encodeURIComponent(item.abspath)}`, '_blank'); } }; - const href = isDir + const handleMouseEnter = () => { + // Prefetch on hover + if (isDir) { + prefetchDirectory(item.abspath); + } else if (isCodeFile(item.basename)) { + prefetchFile(item.abspath); + } + }; + + const href = isDir ? `?path=${encodeURIComponent(item.abspath)}` : `/api/repo/file?path=${encodeURIComponent(item.abspath)}`; - - const target = isDir ? undefined : "_blank"; return ( - <div className="file-row"> + <div className="file-row" onMouseEnter={handleMouseEnter}> <span className="icon"> <img src={iconUrl} alt={isDir ? "Directory" : "File"} /> </span> <span className="name"> - <a href={href} onClick={handleClick} target={target} rel="noreferrer"> + <a href={href} onClick={handleClick}> {item.basename} </a> </span> @@ -277,7 +676,7 @@ const [wasmReady, setWasmReady] = useState(false); useEffect(() => { - createMarkdownModule().then((Module) => { + createMarkdownModule().then((Module: any) => { moduleRef.current = Module; setWasmReady(true); }); @@ -301,7 +700,7 @@ return ( <div id="readmeSection"> <div className="readme-header"> - <img src="https://img.icons8.com/material-outlined/24/000000/menu--v1.png" width="16" alt="" style={{opacity:0.5}} /> + <img src={ICONS.file} width="16" alt="" style={{opacity:0.5}} /> README.md </div> <div id="readmeContent" ref={contentRef}> @@ -316,16 +715,28 @@ */ function RepoBrowser() { const [currentPath, setCurrentPath] = useState(getCurrentPath()); - const [content, setContent] = useState({ files: [], directories: [] }); - const [readme, setReadme] = useState(null); - const [error, setError] = useState(null); + 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 [isDarkMode, setIsDarkMode] = useState(() => { + // Check localStorage or system preference + const saved = localStorage.getItem('theme'); + if (saved) return saved === 'dark'; + return window.matchMedia('(prefers-color-scheme: dark)').matches; + }); + const [viewingFile, setViewingFile] = useState<string | null>(null); function getCurrentPath() { const params = new URLSearchParams(window.location.search); return params.get('path') || ''; } + // Persist theme preference + useEffect(() => { + localStorage.setItem('theme', isDarkMode ? 'dark' : 'light'); + }, [isDarkMode]); + useEffect(() => { const handlePopState = () => setCurrentPath(getCurrentPath()); window.addEventListener('popstate', handlePopState); @@ -337,33 +748,41 @@ fetchReadme(currentPath); }, [currentPath]); - const navigate = (path) => { + const navigate = (path: string) => { const newUrl = path ? `?path=${encodeURIComponent(path)}` : '/'; window.history.pushState({ path }, '', newUrl); setCurrentPath(path); }; - const fetchDirectory = async (path) => { + const fetchDirectory = async (path: string) => { setLoading(true); setError(null); try { - const url = path - ? `${API_BASE}/list?path=${encodeURIComponent(path)}` - : `${API_BASE}/list`; - - const response = await fetch(url); + // Check prefetch cache first + const cacheKey = `dir:${path}`; let data; - if (response.ok) - data = await response.json(); + if (prefetchCache.has(cacheKey)) { + data = await prefetchCache.get(cacheKey); + prefetchCache.delete(cacheKey); // Clear after use for fresh data next time + } 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) + if (data?.error) { throw new Error(data.error); - + } + setContent({ - files: data.files || [], - directories: data.directories || [] + files: data?.files || [], + directories: data?.directories || [] }); - } catch (err) { + } catch (err: any) { console.error('Error loading directory:', err); setError(err.message); } finally { @@ -371,22 +790,37 @@ } }; - const fetchReadme = async (path) => { + const fetchReadme = async (path: string) => { + setReadme(null); const readmePath = path ? `${path}/README.md` : 'README.md'; - const response = await fetch(`${API_BASE}/file?path=${encodeURIComponent(readmePath)}`); - console.log(response); - if (response.ok) - { - const text = await response.text(); - setReadme(text); + 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, ignore errors } }; + const handleOpenFile = useCallback((path: string) => { + setViewingFile(path); + }, []); + + const handleCloseFile = useCallback(() => { + setViewingFile(null); + }, []); + + const toggleTheme = () => { + setIsDarkMode(prev => !prev); + }; + return ( <> - <GlobalStyles /> + <GlobalStyles isDark={isDarkMode} /> <div className="repo-container"> - + {/* Header */} <div className="header"> <img src={ICONS.repo} alt="Repo" className="header-icon" /> @@ -394,6 +828,10 @@ <h1>Zenbu Repository</h1> <p className="description">Browse and manage the mercurial codebase</p> </div> + <button className="theme-toggle" onClick={toggleTheme} title={isDarkMode ? 'Switch to light mode' : 'Switch to dark mode'}> + {isDarkMode ? <SunIcon color="#f0c674" /> : <MoonIcon color="#6e7681" />} + {isDarkMode ? 'Light' : 'Dark'} + </button> </div> {/* Clone Bar */} @@ -406,24 +844,34 @@ {/* Navigation & Content */} <Breadcrumb currentPath={currentPath} onNavigate={navigate} /> - + {error && <div className="error-message">Error: {error}</div>} - + {loading ? ( - <div className="file-list-container" style={{padding: '40px', textAlign: 'center', color:'#666'}}> + <div className="file-list-container" style={{padding: '40px', textAlign: 'center', color: 'var(--text-secondary)'}}> Loading files... </div> ) : ( <> - <FileList - directories={content.directories} - files={content.files} - onNavigate={navigate} + <FileList + directories={content.directories} + files={content.files} + onNavigate={navigate} + onOpenFile={handleOpenFile} /> <ReadmeViewer content={readme} /> </> )} </div> + + {/* File Viewer Modal */} + {viewingFile && ( + isMarkdownFile(viewingFile) ? ( + <MarkdownViewerModal filePath={viewingFile} onClose={handleCloseFile} /> + ) : ( + <FileViewer filePath={viewingFile} onClose={handleCloseFile} /> + ) + )} </> ); }