Mercurial
comparison 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 |
comparison
equal
deleted
inserted
replaced
| 190:a2725419f988 | 191:a06710325c30 |
|---|---|
| 1 import React, { useState, useEffect, useRef } from 'react'; | 1 import React, { useState, useEffect, useRef, useCallback } from 'react'; |
| 2 import createMarkdownModule from 'markdown_converter/markdown_to_html_wasm/markdown_to_html_bin.js'; | 2 import createMarkdownModule from 'markdown_converter/markdown_to_html_wasm/markdown_to_html_bin.js'; |
| 3 | 3 import hljs from 'third_party/highlight/highlight.min.js'; |
| 4 // --- ICONS (Using CDN Links) --- | 4 |
| 5 // --- ICONS (served as static files) --- | |
| 5 const ICONS = { | 6 const ICONS = { |
| 6 folder: "https://cdn-icons-png.flaticon.com/512/11471/11471391.png", | 7 folder: "/icons/folder.png", |
| 7 file: "https://raw.githubusercontent.com/PKief/vscode-material-icon-theme/main/icons/document.svg", | 8 file: "/icons/file.svg", |
| 8 home: "https://cdn-icons-png.flaticon.com/512/1946/1946488.png", | 9 home: "/icons/home.png", |
| 9 repo: "/public/epi_all_colors.svg", | 10 repo: "/public/epi_all_colors.svg", |
| 10 clone: "https://cdn-icons-png.flaticon.com/512/11471/11471391.png" | 11 close: "/icons/close.png" |
| 11 }; | 12 }; |
| 12 | 13 |
| 14 // SVG Icons for theme toggle | |
| 15 const SunIcon = ({ color = "currentColor" }: { color?: string }) => ( | |
| 16 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> | |
| 17 <circle cx="12" cy="12" r="5"/> | |
| 18 <line x1="12" y1="1" x2="12" y2="3"/> | |
| 19 <line x1="12" y1="21" x2="12" y2="23"/> | |
| 20 <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/> | |
| 21 <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/> | |
| 22 <line x1="1" y1="12" x2="3" y2="12"/> | |
| 23 <line x1="21" y1="12" x2="23" y2="12"/> | |
| 24 <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/> | |
| 25 <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/> | |
| 26 </svg> | |
| 27 ); | |
| 28 | |
| 29 const MoonIcon = ({ color = "currentColor" }: { color?: string }) => ( | |
| 30 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> | |
| 31 <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/> | |
| 32 </svg> | |
| 33 ); | |
| 34 | |
| 13 const API_BASE = '/api/repo'; | 35 const API_BASE = '/api/repo'; |
| 36 | |
| 37 // File extensions that should be displayed as code | |
| 38 const CODE_EXTENSIONS = new Set([ | |
| 39 'js', 'jsx', 'ts', 'tsx', 'py', 'rb', 'java', 'c', 'cpp', 'h', 'hpp', | |
| 40 'cs', 'go', 'rs', 'swift', 'kt', 'scala', 'php', 'pl', 'sh', 'bash', | |
| 41 'zsh', 'fish', 'ps1', 'bat', 'cmd', 'html', 'htm', 'css', 'scss', | |
| 42 'sass', 'less', 'json', 'xml', 'yaml', 'yml', 'toml', 'ini', 'cfg', | |
| 43 'conf', 'md', 'markdown', 'txt', 'log', 'sql', 'graphql', 'vue', | |
| 44 'svelte', 'astro', 'prisma', 'dockerfile', 'makefile', 'cmake', | |
| 45 'gradle', 'pom', 'lock', 'gitignore', 'env', 'example', 'sample' | |
| 46 ]); | |
| 47 | |
| 48 // Prefetch cache | |
| 49 const prefetchCache = new Map<string, Promise<any>>(); | |
| 50 | |
| 51 function isCodeFile(filename: string): boolean { | |
| 52 const ext = filename.split('.').pop()?.toLowerCase() || ''; | |
| 53 const basename = filename.toLowerCase(); | |
| 54 return CODE_EXTENSIONS.has(ext) || | |
| 55 CODE_EXTENSIONS.has(basename) || | |
| 56 basename === 'dockerfile' || | |
| 57 basename === 'makefile' || | |
| 58 basename.startsWith('.'); | |
| 59 } | |
| 60 | |
| 61 function isMarkdownFile(filename: string): boolean { | |
| 62 const ext = filename.split('.').pop()?.toLowerCase() || ''; | |
| 63 return ext === 'md' || ext === 'markdown'; | |
| 64 } | |
| 65 | |
| 66 function prefetchDirectory(path: string): void { | |
| 67 const cacheKey = `dir:${path}`; | |
| 68 if (prefetchCache.has(cacheKey)) return; | |
| 69 | |
| 70 const url = path | |
| 71 ? `${API_BASE}/list?path=${encodeURIComponent(path)}` | |
| 72 : `${API_BASE}/list`; | |
| 73 | |
| 74 prefetchCache.set(cacheKey, fetch(url).then(r => r.json()).catch(() => null)); | |
| 75 } | |
| 76 | |
| 77 function prefetchFile(path: string): void { | |
| 78 const cacheKey = `file:${path}`; | |
| 79 if (prefetchCache.has(cacheKey)) return; | |
| 80 | |
| 81 prefetchCache.set(cacheKey, | |
| 82 fetch(`${API_BASE}/file?path=${encodeURIComponent(path)}`) | |
| 83 .then(r => r.ok ? r.text() : null) | |
| 84 .catch(() => null) | |
| 85 ); | |
| 86 } | |
| 87 | |
| 88 async function getCachedFile(path: string): Promise<string | null> { | |
| 89 const cacheKey = `file:${path}`; | |
| 90 if (prefetchCache.has(cacheKey)) { | |
| 91 return prefetchCache.get(cacheKey); | |
| 92 } | |
| 93 prefetchFile(path); | |
| 94 return prefetchCache.get(cacheKey)!; | |
| 95 } | |
| 14 | 96 |
| 15 /** | 97 /** |
| 16 * Component: Styles | 98 * Component: Styles |
| 17 * Injected CSS for the polished look | 99 * Injected CSS for the polished look with dark/light mode support |
| 18 */ | 100 */ |
| 19 const GlobalStyles = () => ( | 101 const GlobalStyles = ({ isDark }: { isDark: boolean }) => ( |
| 20 <style>{` | 102 <style>{` |
| 21 :root { | 103 :root { |
| 22 --bg-color: #ffffff; | 104 --bg-color: ${isDark ? '#0d1117' : '#ffffff'}; |
| 23 --bg-subtle: #f6f8fa; | 105 --bg-subtle: ${isDark ? '#161b22' : '#f6f8fa'}; |
| 24 --border-color: #d0d7de; | 106 --bg-code: ${isDark ? '#1c2128' : '#f6f8fa'}; |
| 25 --accent-color: #0969da; | 107 --border-color: ${isDark ? '#30363d' : '#d0d7de'}; |
| 26 --text-primary: #1f2328; | 108 --accent-color: ${isDark ? '#58a6ff' : '#0969da'}; |
| 27 --text-secondary: #656d76; | 109 --text-primary: ${isDark ? '#e6edf3' : '#1f2328'}; |
| 28 --hover-color: #f3f4f6; | 110 --text-secondary: ${isDark ? '#8b949e' : '#656d76'}; |
| 111 --hover-color: ${isDark ? '#1c2128' : '#f3f4f6'}; | |
| 29 --radius: 6px; | 112 --radius: 6px; |
| 113 --code-bg: ${isDark ? '#161b22' : '#ffffff'}; | |
| 114 } | |
| 115 | |
| 116 body { | |
| 117 background: var(--bg-color); | |
| 118 transition: background 0.2s; | |
| 30 } | 119 } |
| 31 | 120 |
| 32 .repo-container { | 121 .repo-container { |
| 33 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; | 122 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; |
| 34 max-width: 980px; | 123 max-width: 980px; |
| 45 gap: 15px; | 134 gap: 15px; |
| 46 } | 135 } |
| 47 .header-icon { width: 32px; height: 32px; opacity: 0.8; } | 136 .header-icon { width: 32px; height: 32px; opacity: 0.8; } |
| 48 .header h1 { margin: 0; font-size: 24px; font-weight: 600; } | 137 .header h1 { margin: 0; font-size: 24px; font-weight: 600; } |
| 49 .description { color: var(--text-secondary); margin: 0; font-size: 14px; } | 138 .description { color: var(--text-secondary); margin: 0; font-size: 14px; } |
| 139 | |
| 140 /* Theme Toggle */ | |
| 141 .theme-toggle { | |
| 142 margin-left: auto; | |
| 143 background: var(--bg-subtle); | |
| 144 border: 1px solid var(--border-color); | |
| 145 border-radius: var(--radius); | |
| 146 padding: 8px 12px; | |
| 147 cursor: pointer; | |
| 148 display: flex; | |
| 149 align-items: center; | |
| 150 gap: 6px; | |
| 151 color: var(--text-secondary); | |
| 152 font-size: 13px; | |
| 153 transition: all 0.2s; | |
| 154 } | |
| 155 .theme-toggle:hover { | |
| 156 background: var(--hover-color); | |
| 157 color: var(--text-primary); | |
| 158 } | |
| 159 .theme-toggle svg { | |
| 160 flex-shrink: 0; | |
| 161 } | |
| 50 | 162 |
| 51 /* Clone Box */ | 163 /* Clone Box */ |
| 52 .clone-box { | 164 .clone-box { |
| 53 background: var(--bg-subtle); | 165 background: var(--bg-subtle); |
| 54 border: 1px solid var(--border-color); | 166 border: 1px solid var(--border-color); |
| 58 display: flex; | 170 display: flex; |
| 59 justify-content: space-between; | 171 justify-content: space-between; |
| 60 align-items: center; | 172 align-items: center; |
| 61 } | 173 } |
| 62 .clone-label { font-weight: 600; font-size: 13px; margin-right: 10px; color: var(--text-primary); } | 174 .clone-label { font-weight: 600; font-size: 13px; margin-right: 10px; color: var(--text-primary); } |
| 63 .clone-url { | 175 .clone-url { |
| 64 font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; | 176 font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; |
| 65 background: white; | 177 background: var(--bg-color); |
| 66 border: 1px solid var(--border-color); | 178 border: 1px solid var(--border-color); |
| 67 padding: 4px 8px; | 179 padding: 4px 8px; |
| 68 border-radius: 4px; | 180 border-radius: 4px; |
| 69 font-size: 12px; | 181 font-size: 12px; |
| 70 color: var(--text-secondary); | 182 color: var(--text-secondary); |
| 78 font-size: 14px; | 190 font-size: 14px; |
| 79 margin-bottom: 16px; | 191 margin-bottom: 16px; |
| 80 color: var(--text-secondary); | 192 color: var(--text-secondary); |
| 81 padding: 8px 0; | 193 padding: 8px 0; |
| 82 } | 194 } |
| 83 #breadcrumb a { | 195 #breadcrumb a { |
| 84 color: var(--accent-color); | 196 color: var(--accent-color); |
| 85 text-decoration: none; | 197 text-decoration: none; |
| 86 border-radius: 4px; | 198 border-radius: 4px; |
| 87 padding: 2px 6px; | 199 padding: 2px 6px; |
| 88 } | 200 } |
| 89 #breadcrumb a:hover { background: var(--bg-subtle); text-decoration: underline; } | 201 #breadcrumb a:hover { background: var(--bg-subtle); text-decoration: underline; } |
| 90 #breadcrumb .separator { margin: 0 4px; color: var(--text-secondary); opacity: 0.5; } | 202 #breadcrumb .separator { margin: 0 4px; color: var(--text-secondary); opacity: 0.5; } |
| 93 /* File List Table Structure */ | 205 /* File List Table Structure */ |
| 94 .file-list-container { | 206 .file-list-container { |
| 95 border: 1px solid var(--border-color); | 207 border: 1px solid var(--border-color); |
| 96 border-radius: var(--radius); | 208 border-radius: var(--radius); |
| 97 overflow: hidden; | 209 overflow: hidden; |
| 210 background: var(--bg-color); | |
| 98 } | 211 } |
| 99 .file-header { | 212 .file-header { |
| 100 background: var(--bg-subtle); | 213 background: var(--bg-subtle); |
| 101 border-bottom: 1px solid var(--border-color); | 214 border-bottom: 1px solid var(--border-color); |
| 102 padding: 12px 16px; | 215 padding: 12px 16px; |
| 103 font-size: 13px; | 216 font-size: 13px; |
| 104 font-weight: 600; | 217 font-weight: 600; |
| 105 color: var(--text-secondary); | 218 color: var(--text-secondary); |
| 106 } | 219 } |
| 107 .empty-state { padding: 40px; text-align: center; color: var(--text-secondary); } | 220 .empty-state { padding: 40px; text-align: center; color: var(--text-secondary); } |
| 108 .error-message { | 221 .error-message { |
| 109 padding: 15px; border: 1px solid #ffdce0; | 222 padding: 15px; border: 1px solid ${isDark ? '#f8514966' : '#ffdce0'}; |
| 110 background: #ffebe9; color: #cf222e; | 223 background: ${isDark ? '#f8514926' : '#ffebe9'}; color: ${isDark ? '#f85149' : '#cf222e'}; |
| 111 border-radius: var(--radius); margin-bottom: 20px; | 224 border-radius: var(--radius); margin-bottom: 20px; |
| 112 } | 225 } |
| 113 | 226 |
| 114 /* File Row */ | 227 /* File Row */ |
| 115 .file-row { | 228 .file-row { |
| 116 display: flex; | 229 display: flex; |
| 119 border-bottom: 1px solid var(--border-color); | 232 border-bottom: 1px solid var(--border-color); |
| 120 transition: background 0.1s; | 233 transition: background 0.1s; |
| 121 } | 234 } |
| 122 .file-row:last-child { border-bottom: none; } | 235 .file-row:last-child { border-bottom: none; } |
| 123 .file-row:hover { background: var(--hover-color); } | 236 .file-row:hover { background: var(--hover-color); } |
| 124 | 237 |
| 125 .file-row .icon img { width: 20px; height: 20px; vertical-align: middle; margin-right: 12px; } | 238 .file-row .icon img { |
| 126 .file-row .name a { | 239 width: 20px; |
| 127 color: var(--text-primary); | 240 height: 20px; |
| 128 text-decoration: none; | 241 vertical-align: middle; |
| 129 font-size: 14px; | 242 margin-right: 12px; |
| 243 filter: ${isDark ? 'invert(0.8)' : 'none'}; | |
| 244 } | |
| 245 .file-row .name a { | |
| 246 color: var(--text-primary); | |
| 247 text-decoration: none; | |
| 248 font-size: 14px; | |
| 130 } | 249 } |
| 131 .file-row .name a:hover { color: var(--accent-color); text-decoration: underline; } | 250 .file-row .name a:hover { color: var(--accent-color); text-decoration: underline; } |
| 132 | 251 |
| 133 /* Readme */ | 252 /* Readme */ |
| 134 #readmeSection { margin-top: 32px; border: 1px solid var(--border-color); border-radius: var(--radius); } | 253 #readmeSection { margin-top: 32px; border: 1px solid var(--border-color); border-radius: var(--radius); } |
| 135 .readme-header { | 254 .readme-header { |
| 136 background: var(--bg-subtle); | 255 background: var(--bg-subtle); |
| 137 padding: 10px 16px; | 256 padding: 10px 16px; |
| 138 font-size: 12px; font-weight: 600; | 257 font-size: 12px; font-weight: 600; |
| 139 border-bottom: 1px solid var(--border-color); | 258 border-bottom: 1px solid var(--border-color); |
| 140 display: flex; align-items: center; gap: 8px; | 259 display: flex; align-items: center; gap: 8px; |
| 141 } | 260 } |
| 142 #readmeContent { padding: 32px; background: white; overflow-x: auto; } | 261 .readme-header img { |
| 262 filter: ${isDark ? 'invert(0.7)' : 'none'}; | |
| 263 } | |
| 264 #readmeContent { padding: 32px; background: var(--bg-color); overflow-x: auto; color: var(--text-primary); } | |
| 265 | |
| 266 /* File Viewer */ | |
| 267 .file-viewer-overlay { | |
| 268 position: fixed; | |
| 269 inset: 0; | |
| 270 background: ${isDark ? 'rgba(0,0,0,0.7)' : 'rgba(0,0,0,0.5)'}; | |
| 271 display: flex; | |
| 272 justify-content: center; | |
| 273 align-items: center; | |
| 274 z-index: 1000; | |
| 275 padding: 20px; | |
| 276 } | |
| 277 .file-viewer { | |
| 278 background: var(--bg-color); | |
| 279 border: 1px solid var(--border-color); | |
| 280 border-radius: var(--radius); | |
| 281 width: 100%; | |
| 282 max-width: 900px; | |
| 283 max-height: 90vh; | |
| 284 display: flex; | |
| 285 flex-direction: column; | |
| 286 box-shadow: 0 8px 32px rgba(0,0,0,0.3); | |
| 287 } | |
| 288 .file-viewer-header { | |
| 289 display: flex; | |
| 290 align-items: center; | |
| 291 justify-content: space-between; | |
| 292 padding: 12px 16px; | |
| 293 background: var(--bg-subtle); | |
| 294 border-bottom: 1px solid var(--border-color); | |
| 295 border-radius: var(--radius) var(--radius) 0 0; | |
| 296 } | |
| 297 .file-viewer-title { | |
| 298 font-weight: 600; | |
| 299 font-size: 14px; | |
| 300 color: var(--text-primary); | |
| 301 display: flex; | |
| 302 align-items: center; | |
| 303 gap: 8px; | |
| 304 } | |
| 305 .file-viewer-close { | |
| 306 background: transparent; | |
| 307 border: none; | |
| 308 cursor: pointer; | |
| 309 padding: 4px; | |
| 310 border-radius: 4px; | |
| 311 display: flex; | |
| 312 align-items: center; | |
| 313 justify-content: center; | |
| 314 } | |
| 315 .file-viewer-close:hover { | |
| 316 background: var(--hover-color); | |
| 317 } | |
| 318 .file-viewer-close img { | |
| 319 width: 16px; | |
| 320 height: 16px; | |
| 321 filter: ${isDark ? 'invert(0.7)' : 'none'}; | |
| 322 opacity: 0.7; | |
| 323 } | |
| 324 .file-viewer-content { | |
| 325 overflow: auto; | |
| 326 flex: 1; | |
| 327 } | |
| 328 .file-viewer-content pre { | |
| 329 margin: 0; | |
| 330 padding: 16px; | |
| 331 background: var(--code-bg); | |
| 332 font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; | |
| 333 font-size: 13px; | |
| 334 line-height: 1.5; | |
| 335 overflow-x: auto; | |
| 336 } | |
| 337 .file-viewer-content code { | |
| 338 background: transparent; | |
| 339 padding: 0; | |
| 340 } | |
| 341 .file-viewer-loading { | |
| 342 padding: 40px; | |
| 343 text-align: center; | |
| 344 color: var(--text-secondary); | |
| 345 } | |
| 346 .file-viewer-line-numbers { | |
| 347 display: inline-block; | |
| 348 user-select: none; | |
| 349 text-align: right; | |
| 350 padding-right: 16px; | |
| 351 margin-right: 16px; | |
| 352 border-right: 1px solid var(--border-color); | |
| 353 color: var(--text-secondary); | |
| 354 opacity: 0.5; | |
| 355 } | |
| 143 `}</style> | 356 `}</style> |
| 144 ); | 357 ); |
| 145 | 358 |
| 146 /** | 359 /** |
| 147 * Component: Breadcrumb | 360 * Component: Breadcrumb |
| 191 </nav> | 404 </nav> |
| 192 ); | 405 ); |
| 193 } | 406 } |
| 194 | 407 |
| 195 /** | 408 /** |
| 409 * Component: FileViewer | |
| 410 * Shows file content inline with syntax highlighting | |
| 411 */ | |
| 412 function FileViewer({ filePath, onClose }: { filePath: string; onClose: () => void }) { | |
| 413 const [content, setContent] = useState<string | null>(null); | |
| 414 const [loading, setLoading] = useState(true); | |
| 415 const codeRef = useRef<HTMLElement>(null); | |
| 416 | |
| 417 const filename = filePath.split('/').pop() || filePath; | |
| 418 | |
| 419 useEffect(() => { | |
| 420 setLoading(true); | |
| 421 getCachedFile(filePath).then((text) => { | |
| 422 setContent(text); | |
| 423 setLoading(false); | |
| 424 }); | |
| 425 }, [filePath]); | |
| 426 | |
| 427 useEffect(() => { | |
| 428 if (content && codeRef.current) { | |
| 429 hljs.highlightElement(codeRef.current); | |
| 430 } | |
| 431 }, [content]); | |
| 432 | |
| 433 // Close on escape key | |
| 434 useEffect(() => { | |
| 435 const handleKeyDown = (e: KeyboardEvent) => { | |
| 436 if (e.key === 'Escape') onClose(); | |
| 437 }; | |
| 438 window.addEventListener('keydown', handleKeyDown); | |
| 439 return () => window.removeEventListener('keydown', handleKeyDown); | |
| 440 }, [onClose]); | |
| 441 | |
| 442 // Get language from file extension for highlight.js | |
| 443 const getLanguage = () => { | |
| 444 const ext = filename.split('.').pop()?.toLowerCase() || ''; | |
| 445 const langMap: Record<string, string> = { | |
| 446 js: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript', | |
| 447 py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java', | |
| 448 c: 'c', cpp: 'cpp', h: 'c', hpp: 'cpp', cs: 'csharp', | |
| 449 sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'bash', | |
| 450 json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml', | |
| 451 html: 'html', htm: 'html', css: 'css', scss: 'scss', sass: 'scss', | |
| 452 sql: 'sql', md: 'markdown', markdown: 'markdown', xml: 'xml', | |
| 453 dockerfile: 'dockerfile', makefile: 'makefile' | |
| 454 }; | |
| 455 return langMap[ext] || 'plaintext'; | |
| 456 }; | |
| 457 | |
| 458 const addLineNumbers = (text: string) => { | |
| 459 const lines = text.split('\n'); | |
| 460 return lines.map((_, i) => i + 1).join('\n'); | |
| 461 }; | |
| 462 | |
| 463 return ( | |
| 464 <div className="file-viewer-overlay" onClick={onClose}> | |
| 465 <div className="file-viewer" onClick={(e) => e.stopPropagation()}> | |
| 466 <div className="file-viewer-header"> | |
| 467 <span className="file-viewer-title"> | |
| 468 <img src={ICONS.file} alt="" style={{ width: 16, height: 16 }} /> | |
| 469 {filename} | |
| 470 </span> | |
| 471 <button className="file-viewer-close" onClick={onClose} title="Close (Esc)"> | |
| 472 <img src={ICONS.close} alt="Close" /> | |
| 473 </button> | |
| 474 </div> | |
| 475 <div className="file-viewer-content"> | |
| 476 {loading ? ( | |
| 477 <div className="file-viewer-loading">Loading...</div> | |
| 478 ) : content ? ( | |
| 479 <pre style={{ display: 'flex' }}> | |
| 480 <span className="file-viewer-line-numbers">{addLineNumbers(content)}</span> | |
| 481 <code ref={codeRef} className={`language-${getLanguage()}`}>{content}</code> | |
| 482 </pre> | |
| 483 ) : ( | |
| 484 <div className="file-viewer-loading">Unable to load file</div> | |
| 485 )} | |
| 486 </div> | |
| 487 </div> | |
| 488 </div> | |
| 489 ); | |
| 490 } | |
| 491 | |
| 492 /** | |
| 493 * Component: MarkdownViewerModal | |
| 494 * Shows markdown content rendered in a modal | |
| 495 */ | |
| 496 function MarkdownViewerModal({ filePath, onClose }: { filePath: string; onClose: () => void }) { | |
| 497 const [content, setContent] = useState<string | null>(null); | |
| 498 const [loading, setLoading] = useState(true); | |
| 499 const contentRef = useRef<HTMLDivElement>(null); | |
| 500 const moduleRef = useRef<any>(null); | |
| 501 const [wasmReady, setWasmReady] = useState(false); | |
| 502 | |
| 503 const filename = filePath.split('/').pop() || filePath; | |
| 504 | |
| 505 useEffect(() => { | |
| 506 createMarkdownModule().then((Module: any) => { | |
| 507 moduleRef.current = Module; | |
| 508 setWasmReady(true); | |
| 509 }); | |
| 510 }, []); | |
| 511 | |
| 512 useEffect(() => { | |
| 513 setLoading(true); | |
| 514 getCachedFile(filePath).then((text) => { | |
| 515 setContent(text); | |
| 516 setLoading(false); | |
| 517 }); | |
| 518 }, [filePath]); | |
| 519 | |
| 520 useEffect(() => { | |
| 521 if (!content || !wasmReady || !contentRef.current || !moduleRef.current) return; | |
| 522 | |
| 523 const Module = moduleRef.current; | |
| 524 const markdownToHtmlPtr = Module.cwrap('markdown_to_html', 'number', ['string']); | |
| 525 const markdownFree = Module.cwrap('markdown_free', null, ['number']); | |
| 526 | |
| 527 const ptr = markdownToHtmlPtr(content); | |
| 528 const html = Module.UTF8ToString(ptr); | |
| 529 markdownFree(ptr); | |
| 530 contentRef.current.innerHTML = html; | |
| 531 }, [content, wasmReady]); | |
| 532 | |
| 533 // Close on escape key | |
| 534 useEffect(() => { | |
| 535 const handleKeyDown = (e: KeyboardEvent) => { | |
| 536 if (e.key === 'Escape') onClose(); | |
| 537 }; | |
| 538 window.addEventListener('keydown', handleKeyDown); | |
| 539 return () => window.removeEventListener('keydown', handleKeyDown); | |
| 540 }, [onClose]); | |
| 541 | |
| 542 return ( | |
| 543 <div className="file-viewer-overlay" onClick={onClose}> | |
| 544 <div className="file-viewer" onClick={(e) => e.stopPropagation()}> | |
| 545 <div className="file-viewer-header"> | |
| 546 <span className="file-viewer-title"> | |
| 547 <img src={ICONS.file} alt="" style={{ width: 16, height: 16 }} /> | |
| 548 {filename} | |
| 549 </span> | |
| 550 <button className="file-viewer-close" onClick={onClose} title="Close (Esc)"> | |
| 551 <img src={ICONS.close} alt="Close" /> | |
| 552 </button> | |
| 553 </div> | |
| 554 <div className="file-viewer-content"> | |
| 555 {loading || !wasmReady ? ( | |
| 556 <div className="file-viewer-loading">Loading...</div> | |
| 557 ) : content ? ( | |
| 558 <div id="readmeContent" ref={contentRef} style={{ padding: 32 }} /> | |
| 559 ) : ( | |
| 560 <div className="file-viewer-loading">Unable to load file</div> | |
| 561 )} | |
| 562 </div> | |
| 563 </div> | |
| 564 </div> | |
| 565 ); | |
| 566 } | |
| 567 | |
| 568 /** | |
| 196 * Component: FileList | 569 * Component: FileList |
| 197 */ | 570 */ |
| 198 function FileList({ directories, files, onNavigate }) { | 571 function FileList({ directories, files, onNavigate, onOpenFile }: { |
| 572 directories: any[]; | |
| 573 files: any[]; | |
| 574 onNavigate: (path: string) => void; | |
| 575 onOpenFile: (path: string) => void; | |
| 576 }) { | |
| 199 const isEmpty = directories.length === 0 && files.length === 0; | 577 const isEmpty = directories.length === 0 && files.length === 0; |
| 200 | 578 |
| 201 if (isEmpty) { | 579 if (isEmpty) { |
| 202 return ( | 580 return ( |
| 203 <div className="file-list-container"> | 581 <div className="file-list-container"> |
| 213 Files | 591 Files |
| 214 </div> | 592 </div> |
| 215 | 593 |
| 216 <div id="fileListBody"> | 594 <div id="fileListBody"> |
| 217 {directories.map((dir) => ( | 595 {directories.map((dir) => ( |
| 218 <FileRow | 596 <FileRow |
| 219 key={dir.abspath} | 597 key={dir.abspath} |
| 220 item={dir} | 598 item={dir} |
| 221 iconUrl={ICONS.folder} | 599 iconUrl={ICONS.folder} |
| 222 isDir={true} | 600 isDir={true} |
| 223 onNavigate={onNavigate} | 601 onNavigate={onNavigate} |
| 602 onOpenFile={onOpenFile} | |
| 224 /> | 603 /> |
| 225 ))} | 604 ))} |
| 226 | 605 |
| 227 {files.map((file) => ( | 606 {files.map((file) => ( |
| 228 <FileRow | 607 <FileRow |
| 229 key={file.abspath} | 608 key={file.abspath} |
| 230 item={file} | 609 item={file} |
| 231 iconUrl={ICONS.file} | 610 iconUrl={ICONS.file} |
| 232 isDir={false} | 611 isDir={false} |
| 612 onNavigate={onNavigate} | |
| 613 onOpenFile={onOpenFile} | |
| 233 /> | 614 /> |
| 234 ))} | 615 ))} |
| 235 </div> | 616 </div> |
| 236 </div> | 617 </div> |
| 237 ); | 618 ); |
| 238 } | 619 } |
| 239 | 620 |
| 240 /** | 621 /** |
| 241 * Component: FileRow | 622 * Component: FileRow |
| 242 */ | 623 */ |
| 243 function FileRow({ item, iconUrl, isDir, onNavigate }) { | 624 function FileRow({ item, iconUrl, isDir, onNavigate, onOpenFile }: { |
| 244 const handleClick = (e) => { | 625 item: { abspath: string; basename: string }; |
| 626 iconUrl: string; | |
| 627 isDir: boolean; | |
| 628 onNavigate: (path: string) => void; | |
| 629 onOpenFile: (path: string) => void; | |
| 630 }) { | |
| 631 const handleClick = (e: React.MouseEvent) => { | |
| 632 e.preventDefault(); | |
| 245 if (isDir) { | 633 if (isDir) { |
| 246 e.preventDefault(); | |
| 247 onNavigate(item.abspath); | 634 onNavigate(item.abspath); |
| 635 } else if (isCodeFile(item.basename)) { | |
| 636 onOpenFile(item.abspath); | |
| 637 } else { | |
| 638 // For non-code files, open in new tab | |
| 639 window.open(`/api/repo/file?path=${encodeURIComponent(item.abspath)}`, '_blank'); | |
| 248 } | 640 } |
| 249 }; | 641 }; |
| 250 | 642 |
| 251 const href = isDir | 643 const handleMouseEnter = () => { |
| 644 // Prefetch on hover | |
| 645 if (isDir) { | |
| 646 prefetchDirectory(item.abspath); | |
| 647 } else if (isCodeFile(item.basename)) { | |
| 648 prefetchFile(item.abspath); | |
| 649 } | |
| 650 }; | |
| 651 | |
| 652 const href = isDir | |
| 252 ? `?path=${encodeURIComponent(item.abspath)}` | 653 ? `?path=${encodeURIComponent(item.abspath)}` |
| 253 : `/api/repo/file?path=${encodeURIComponent(item.abspath)}`; | 654 : `/api/repo/file?path=${encodeURIComponent(item.abspath)}`; |
| 254 | |
| 255 const target = isDir ? undefined : "_blank"; | |
| 256 | 655 |
| 257 return ( | 656 return ( |
| 258 <div className="file-row"> | 657 <div className="file-row" onMouseEnter={handleMouseEnter}> |
| 259 <span className="icon"> | 658 <span className="icon"> |
| 260 <img src={iconUrl} alt={isDir ? "Directory" : "File"} /> | 659 <img src={iconUrl} alt={isDir ? "Directory" : "File"} /> |
| 261 </span> | 660 </span> |
| 262 <span className="name"> | 661 <span className="name"> |
| 263 <a href={href} onClick={handleClick} target={target} rel="noreferrer"> | 662 <a href={href} onClick={handleClick}> |
| 264 {item.basename} | 663 {item.basename} |
| 265 </a> | 664 </a> |
| 266 </span> | 665 </span> |
| 267 </div> | 666 </div> |
| 268 ); | 667 ); |
| 275 const contentRef = useRef<HTMLDivElement>(null); | 674 const contentRef = useRef<HTMLDivElement>(null); |
| 276 const moduleRef = useRef<any>(null); | 675 const moduleRef = useRef<any>(null); |
| 277 const [wasmReady, setWasmReady] = useState(false); | 676 const [wasmReady, setWasmReady] = useState(false); |
| 278 | 677 |
| 279 useEffect(() => { | 678 useEffect(() => { |
| 280 createMarkdownModule().then((Module) => { | 679 createMarkdownModule().then((Module: any) => { |
| 281 moduleRef.current = Module; | 680 moduleRef.current = Module; |
| 282 setWasmReady(true); | 681 setWasmReady(true); |
| 283 }); | 682 }); |
| 284 }, []); | 683 }, []); |
| 285 | 684 |
| 299 if (!content) return null; | 698 if (!content) return null; |
| 300 | 699 |
| 301 return ( | 700 return ( |
| 302 <div id="readmeSection"> | 701 <div id="readmeSection"> |
| 303 <div className="readme-header"> | 702 <div className="readme-header"> |
| 304 <img src="https://img.icons8.com/material-outlined/24/000000/menu--v1.png" width="16" alt="" style={{opacity:0.5}} /> | 703 <img src={ICONS.file} width="16" alt="" style={{opacity:0.5}} /> |
| 305 README.md | 704 README.md |
| 306 </div> | 705 </div> |
| 307 <div id="readmeContent" ref={contentRef}> | 706 <div id="readmeContent" ref={contentRef}> |
| 308 {!wasmReady && 'Loading...'} | 707 {!wasmReady && 'Loading...'} |
| 309 </div> | 708 </div> |
| 314 /** | 713 /** |
| 315 * Main Application Component | 714 * Main Application Component |
| 316 */ | 715 */ |
| 317 function RepoBrowser() { | 716 function RepoBrowser() { |
| 318 const [currentPath, setCurrentPath] = useState(getCurrentPath()); | 717 const [currentPath, setCurrentPath] = useState(getCurrentPath()); |
| 319 const [content, setContent] = useState({ files: [], directories: [] }); | 718 const [content, setContent] = useState<{ files: any[]; directories: any[] }>({ files: [], directories: [] }); |
| 320 const [readme, setReadme] = useState(null); | 719 const [readme, setReadme] = useState<string | null>(null); |
| 321 const [error, setError] = useState(null); | 720 const [error, setError] = useState<string | null>(null); |
| 322 const [loading, setLoading] = useState(false); | 721 const [loading, setLoading] = useState(false); |
| 722 const [isDarkMode, setIsDarkMode] = useState(() => { | |
| 723 // Check localStorage or system preference | |
| 724 const saved = localStorage.getItem('theme'); | |
| 725 if (saved) return saved === 'dark'; | |
| 726 return window.matchMedia('(prefers-color-scheme: dark)').matches; | |
| 727 }); | |
| 728 const [viewingFile, setViewingFile] = useState<string | null>(null); | |
| 323 | 729 |
| 324 function getCurrentPath() { | 730 function getCurrentPath() { |
| 325 const params = new URLSearchParams(window.location.search); | 731 const params = new URLSearchParams(window.location.search); |
| 326 return params.get('path') || ''; | 732 return params.get('path') || ''; |
| 327 } | 733 } |
| 328 | 734 |
| 735 // Persist theme preference | |
| 736 useEffect(() => { | |
| 737 localStorage.setItem('theme', isDarkMode ? 'dark' : 'light'); | |
| 738 }, [isDarkMode]); | |
| 739 | |
| 329 useEffect(() => { | 740 useEffect(() => { |
| 330 const handlePopState = () => setCurrentPath(getCurrentPath()); | 741 const handlePopState = () => setCurrentPath(getCurrentPath()); |
| 331 window.addEventListener('popstate', handlePopState); | 742 window.addEventListener('popstate', handlePopState); |
| 332 return () => window.removeEventListener('popstate', handlePopState); | 743 return () => window.removeEventListener('popstate', handlePopState); |
| 333 }, []); | 744 }, []); |
| 335 useEffect(() => { | 746 useEffect(() => { |
| 336 fetchDirectory(currentPath); | 747 fetchDirectory(currentPath); |
| 337 fetchReadme(currentPath); | 748 fetchReadme(currentPath); |
| 338 }, [currentPath]); | 749 }, [currentPath]); |
| 339 | 750 |
| 340 const navigate = (path) => { | 751 const navigate = (path: string) => { |
| 341 const newUrl = path ? `?path=${encodeURIComponent(path)}` : '/'; | 752 const newUrl = path ? `?path=${encodeURIComponent(path)}` : '/'; |
| 342 window.history.pushState({ path }, '', newUrl); | 753 window.history.pushState({ path }, '', newUrl); |
| 343 setCurrentPath(path); | 754 setCurrentPath(path); |
| 344 }; | 755 }; |
| 345 | 756 |
| 346 const fetchDirectory = async (path) => { | 757 const fetchDirectory = async (path: string) => { |
| 347 setLoading(true); | 758 setLoading(true); |
| 348 setError(null); | 759 setError(null); |
| 349 try { | 760 try { |
| 350 const url = path | 761 // Check prefetch cache first |
| 351 ? `${API_BASE}/list?path=${encodeURIComponent(path)}` | 762 const cacheKey = `dir:${path}`; |
| 352 : `${API_BASE}/list`; | |
| 353 | |
| 354 const response = await fetch(url); | |
| 355 let data; | 763 let data; |
| 356 if (response.ok) | 764 if (prefetchCache.has(cacheKey)) { |
| 357 data = await response.json(); | 765 data = await prefetchCache.get(cacheKey); |
| 358 | 766 prefetchCache.delete(cacheKey); // Clear after use for fresh data next time |
| 359 if (data.error) | 767 } else { |
| 768 const url = path | |
| 769 ? `${API_BASE}/list?path=${encodeURIComponent(path)}` | |
| 770 : `${API_BASE}/list`; | |
| 771 const response = await fetch(url); | |
| 772 if (response.ok) { | |
| 773 data = await response.json(); | |
| 774 } | |
| 775 } | |
| 776 | |
| 777 if (data?.error) { | |
| 360 throw new Error(data.error); | 778 throw new Error(data.error); |
| 361 | 779 } |
| 780 | |
| 362 setContent({ | 781 setContent({ |
| 363 files: data.files || [], | 782 files: data?.files || [], |
| 364 directories: data.directories || [] | 783 directories: data?.directories || [] |
| 365 }); | 784 }); |
| 366 } catch (err) { | 785 } catch (err: any) { |
| 367 console.error('Error loading directory:', err); | 786 console.error('Error loading directory:', err); |
| 368 setError(err.message); | 787 setError(err.message); |
| 369 } finally { | 788 } finally { |
| 370 setLoading(false); | 789 setLoading(false); |
| 371 } | 790 } |
| 372 }; | 791 }; |
| 373 | 792 |
| 374 const fetchReadme = async (path) => { | 793 const fetchReadme = async (path: string) => { |
| 794 setReadme(null); | |
| 375 const readmePath = path ? `${path}/README.md` : 'README.md'; | 795 const readmePath = path ? `${path}/README.md` : 'README.md'; |
| 376 const response = await fetch(`${API_BASE}/file?path=${encodeURIComponent(readmePath)}`); | 796 try { |
| 377 console.log(response); | 797 const response = await fetch(`${API_BASE}/file?path=${encodeURIComponent(readmePath)}`); |
| 378 if (response.ok) | 798 if (response.ok) { |
| 379 { | 799 const text = await response.text(); |
| 380 const text = await response.text(); | 800 setReadme(text); |
| 381 setReadme(text); | 801 } |
| 382 } | 802 } catch (err) { |
| 803 // Readme is optional, ignore errors | |
| 804 } | |
| 805 }; | |
| 806 | |
| 807 const handleOpenFile = useCallback((path: string) => { | |
| 808 setViewingFile(path); | |
| 809 }, []); | |
| 810 | |
| 811 const handleCloseFile = useCallback(() => { | |
| 812 setViewingFile(null); | |
| 813 }, []); | |
| 814 | |
| 815 const toggleTheme = () => { | |
| 816 setIsDarkMode(prev => !prev); | |
| 383 }; | 817 }; |
| 384 | 818 |
| 385 return ( | 819 return ( |
| 386 <> | 820 <> |
| 387 <GlobalStyles /> | 821 <GlobalStyles isDark={isDarkMode} /> |
| 388 <div className="repo-container"> | 822 <div className="repo-container"> |
| 389 | 823 |
| 390 {/* Header */} | 824 {/* Header */} |
| 391 <div className="header"> | 825 <div className="header"> |
| 392 <img src={ICONS.repo} alt="Repo" className="header-icon" /> | 826 <img src={ICONS.repo} alt="Repo" className="header-icon" /> |
| 393 <div> | 827 <div> |
| 394 <h1>Zenbu Repository</h1> | 828 <h1>Zenbu Repository</h1> |
| 395 <p className="description">Browse and manage the mercurial codebase</p> | 829 <p className="description">Browse and manage the mercurial codebase</p> |
| 396 </div> | 830 </div> |
| 831 <button className="theme-toggle" onClick={toggleTheme} title={isDarkMode ? 'Switch to light mode' : 'Switch to dark mode'}> | |
| 832 {isDarkMode ? <SunIcon color="#f0c674" /> : <MoonIcon color="#6e7681" />} | |
| 833 {isDarkMode ? 'Light' : 'Dark'} | |
| 834 </button> | |
| 397 </div> | 835 </div> |
| 398 | 836 |
| 399 {/* Clone Bar */} | 837 {/* Clone Bar */} |
| 400 <div className="clone-box"> | 838 <div className="clone-box"> |
| 401 <div style={{display:'flex', alignItems:'center', width:'100%'}}> | 839 <div style={{display:'flex', alignItems:'center', width:'100%'}}> |
| 404 </div> | 842 </div> |
| 405 </div> | 843 </div> |
| 406 | 844 |
| 407 {/* Navigation & Content */} | 845 {/* Navigation & Content */} |
| 408 <Breadcrumb currentPath={currentPath} onNavigate={navigate} /> | 846 <Breadcrumb currentPath={currentPath} onNavigate={navigate} /> |
| 409 | 847 |
| 410 {error && <div className="error-message">Error: {error}</div>} | 848 {error && <div className="error-message">Error: {error}</div>} |
| 411 | 849 |
| 412 {loading ? ( | 850 {loading ? ( |
| 413 <div className="file-list-container" style={{padding: '40px', textAlign: 'center', color:'#666'}}> | 851 <div className="file-list-container" style={{padding: '40px', textAlign: 'center', color: 'var(--text-secondary)'}}> |
| 414 Loading files... | 852 Loading files... |
| 415 </div> | 853 </div> |
| 416 ) : ( | 854 ) : ( |
| 417 <> | 855 <> |
| 418 <FileList | 856 <FileList |
| 419 directories={content.directories} | 857 directories={content.directories} |
| 420 files={content.files} | 858 files={content.files} |
| 421 onNavigate={navigate} | 859 onNavigate={navigate} |
| 860 onOpenFile={handleOpenFile} | |
| 422 /> | 861 /> |
| 423 <ReadmeViewer content={readme} /> | 862 <ReadmeViewer content={readme} /> |
| 424 </> | 863 </> |
| 425 )} | 864 )} |
| 426 </div> | 865 </div> |
| 866 | |
| 867 {/* File Viewer Modal */} | |
| 868 {viewingFile && ( | |
| 869 isMarkdownFile(viewingFile) ? ( | |
| 870 <MarkdownViewerModal filePath={viewingFile} onClose={handleCloseFile} /> | |
| 871 ) : ( | |
| 872 <FileViewer filePath={viewingFile} onClose={handleCloseFile} /> | |
| 873 ) | |
| 874 )} | |
| 427 </> | 875 </> |
| 428 ); | 876 ); |
| 429 } | 877 } |
| 430 | 878 |
| 431 export { RepoBrowser }; | 879 export { RepoBrowser }; |