Mercurial
annotate hg-web/src/components/directory-browser.tsx @ 279:b3b547563ec7
Add Google connector service and agent wiki
Implement the C/Seobeo Google Drive and Gmail connector with encrypted OAuth storage, Zenbu authentication, browser testing, AI tool discovery, chunked HTTP decoding, and Bazel coverage. Consolidate repository guidance into progressive wiki documentation and enforce arena-first allocation for new first-party C code.
Co-authored-by: Copilot <[email protected]>
Copilot-Session: 84c338fd-0939-4bb3-b7f3-1062eb213e5d
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Mon, 17 Aug 2026 22:22:36 -0700 |
| parents | 70de0c80d093 |
| children |
| rev | line source |
|---|---|
| 193 | 1 import React, { useState, useEffect, useRef, useCallback } from 'react'; |
| 2 import createMarkdownModule from 'markdown_converter/markdown_to_html_wasm/markdown_to_html_bin.js'; | |
| 3 import hljs from 'third_party/highlight/highlight.min.js'; | |
| 4 | |
| 5 // --- ICONS (served as static files) --- | |
| 6 const ICONS = { | |
| 7 folder: "/icons/folder.png", | |
| 8 file: "/icons/file.svg", | |
| 9 close: "/icons/close.png" | |
| 10 }; | |
| 11 | |
| 12 const API_BASE = '/api/repo'; | |
| 13 | |
| 14 // File extensions that should be displayed as code | |
| 15 const CODE_EXTENSIONS = new Set([ | |
| 16 'js', 'jsx', 'ts', 'tsx', 'py', 'rb', 'java', 'c', 'cpp', 'h', 'hpp', | |
| 17 'cs', 'go', 'rs', 'swift', 'kt', 'scala', 'php', 'pl', 'sh', 'bash', | |
| 18 'zsh', 'fish', 'ps1', 'bat', 'cmd', 'html', 'htm', 'css', 'scss', | |
| 19 'sass', 'less', 'json', 'xml', 'yaml', 'yml', 'toml', 'ini', 'cfg', | |
| 20 'conf', 'md', 'markdown', 'txt', 'log', 'sql', 'graphql', 'vue', | |
| 21 'svelte', 'astro', 'prisma', 'dockerfile', 'makefile', 'cmake', | |
|
225
70de0c80d093
[hg-web] Add side-by-side changeset diffs
MrJuneJune <me@mrjunejune.com>
parents:
224
diff
changeset
|
22 'gradle', 'pom', 'lock', 'gitignore', 'env', 'example', 'sample', |
|
70de0c80d093
[hg-web] Add side-by-side changeset diffs
MrJuneJune <me@mrjunejune.com>
parents:
224
diff
changeset
|
23 'bzl', 'bazel' |
|
70de0c80d093
[hg-web] Add side-by-side changeset diffs
MrJuneJune <me@mrjunejune.com>
parents:
224
diff
changeset
|
24 ]); |
|
70de0c80d093
[hg-web] Add side-by-side changeset diffs
MrJuneJune <me@mrjunejune.com>
parents:
224
diff
changeset
|
25 |
|
70de0c80d093
[hg-web] Add side-by-side changeset diffs
MrJuneJune <me@mrjunejune.com>
parents:
224
diff
changeset
|
26 const BAZEL_FILENAMES = new Set([ |
|
70de0c80d093
[hg-web] Add side-by-side changeset diffs
MrJuneJune <me@mrjunejune.com>
parents:
224
diff
changeset
|
27 'build', 'build.bazel', 'module.bazel', 'workspace', 'workspace.bazel' |
| 193 | 28 ]); |
| 29 | |
|
224
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
30 type StaticPreviewKind = 'image' | 'video' | 'audio' | 'pdf'; |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
31 |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
32 const IMAGE_EXTENSIONS = new Set([ |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
33 'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'bmp', 'ico', 'svg' |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
34 ]); |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
35 const VIDEO_EXTENSIONS = new Set(['mp4', 'm4v', 'webm', 'mov', 'ogv']); |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
36 const AUDIO_EXTENSIONS = new Set(['mp3', 'wav', 'ogg', 'oga', 'flac', 'm4a', 'aac']); |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
37 |
| 193 | 38 // Prefetch cache |
| 39 const prefetchCache = new Map<string, Promise<any>>(); | |
| 40 | |
| 41 function isCodeFile(filename: string): boolean { | |
| 42 const ext = filename.split('.').pop()?.toLowerCase() || ''; | |
| 43 const basename = filename.toLowerCase(); | |
| 44 return CODE_EXTENSIONS.has(ext) || | |
| 45 CODE_EXTENSIONS.has(basename) || | |
|
225
70de0c80d093
[hg-web] Add side-by-side changeset diffs
MrJuneJune <me@mrjunejune.com>
parents:
224
diff
changeset
|
46 BAZEL_FILENAMES.has(basename) || |
| 193 | 47 basename === 'dockerfile' || |
| 48 basename === 'makefile' || | |
| 49 basename.startsWith('.'); | |
| 50 } | |
| 51 | |
| 52 function isMarkdownFile(filename: string): boolean { | |
| 53 const ext = filename.split('.').pop()?.toLowerCase() || ''; | |
| 54 return ext === 'md' || ext === 'markdown'; | |
| 55 } | |
| 56 | |
|
224
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
57 function getStaticPreviewKind(filename: string): StaticPreviewKind | null { |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
58 const ext = filename.split('.').pop()?.toLowerCase() || ''; |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
59 if (IMAGE_EXTENSIONS.has(ext)) return 'image'; |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
60 if (VIDEO_EXTENSIONS.has(ext)) return 'video'; |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
61 if (AUDIO_EXTENSIONS.has(ext)) return 'audio'; |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
62 if (ext === 'pdf') return 'pdf'; |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
63 return null; |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
64 } |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
65 |
| 193 | 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 } | |
| 96 | |
| 97 /** | |
| 98 * Component: Breadcrumb | |
| 99 */ | |
| 100 function Breadcrumb({ currentPath, onNavigate }: { currentPath: string; onNavigate: (path: string) => void }) { | |
| 101 if (!currentPath) { | |
| 102 return ( | |
| 103 <nav className="breadcrumb"> | |
| 104 <span className="nav-item active">root</span> | |
| 105 </nav> | |
| 106 ); | |
| 107 } | |
| 108 | |
| 109 const parts = currentPath.split('/').filter(p => p); | |
| 110 const crumbs = parts.map((part, index) => ({ | |
| 111 name: part, | |
| 112 fullPath: parts.slice(0, index + 1).join('/') | |
| 113 })); | |
| 114 | |
| 115 return ( | |
| 116 <nav className="breadcrumb"> | |
| 117 <a | |
| 118 href="#" | |
| 119 onClick={(e) => { e.preventDefault(); onNavigate(''); }} | |
| 120 title="Go to Root" | |
| 121 > | |
| 122 root | |
| 123 </a> | |
| 124 {crumbs.map((crumb, index) => { | |
| 125 const isLast = index === crumbs.length - 1; | |
| 126 return ( | |
| 127 <React.Fragment key={crumb.fullPath}> | |
| 128 <span className="separator">/</span> | |
| 129 {isLast ? ( | |
| 130 <span className="nav-item active">{crumb.name}</span> | |
| 131 ) : ( | |
| 132 <a | |
| 133 href="#" | |
| 134 onClick={(e) => { e.preventDefault(); onNavigate(crumb.fullPath); }} | |
| 135 > | |
| 136 {crumb.name} | |
| 137 </a> | |
| 138 )} | |
| 139 </React.Fragment> | |
| 140 ); | |
| 141 })} | |
| 142 </nav> | |
| 143 ); | |
| 144 } | |
| 145 | |
| 146 /** | |
| 147 * Component: FileViewer | |
| 148 * Shows file content inline with syntax highlighting | |
| 149 */ | |
| 150 function FileViewer({ filePath, onClose }: { filePath: string; onClose: () => void }) { | |
| 151 const [content, setContent] = useState<string | null>(null); | |
| 152 const [loading, setLoading] = useState(true); | |
| 153 const codeRef = useRef<HTMLElement>(null); | |
| 154 | |
| 155 const filename = filePath.split('/').pop() || filePath; | |
| 156 | |
| 157 useEffect(() => { | |
| 158 setLoading(true); | |
| 159 getCachedFile(filePath).then((text) => { | |
| 160 setContent(text); | |
| 161 setLoading(false); | |
| 162 }); | |
| 163 }, [filePath]); | |
| 164 | |
| 165 useEffect(() => { | |
| 166 if (content && codeRef.current) { | |
| 167 hljs.highlightElement(codeRef.current); | |
| 168 } | |
| 169 }, [content]); | |
| 170 | |
| 171 useEffect(() => { | |
| 172 const handleKeyDown = (e: KeyboardEvent) => { | |
| 173 if (e.key === 'Escape') onClose(); | |
| 174 }; | |
| 175 window.addEventListener('keydown', handleKeyDown); | |
| 176 return () => window.removeEventListener('keydown', handleKeyDown); | |
| 177 }, [onClose]); | |
| 178 | |
| 179 const getLanguage = () => { | |
| 180 const ext = filename.split('.').pop()?.toLowerCase() || ''; | |
|
225
70de0c80d093
[hg-web] Add side-by-side changeset diffs
MrJuneJune <me@mrjunejune.com>
parents:
224
diff
changeset
|
181 const basename = filename.toLowerCase(); |
|
70de0c80d093
[hg-web] Add side-by-side changeset diffs
MrJuneJune <me@mrjunejune.com>
parents:
224
diff
changeset
|
182 if (BAZEL_FILENAMES.has(basename) || ext === 'bzl' || ext === 'bazel') { |
|
70de0c80d093
[hg-web] Add side-by-side changeset diffs
MrJuneJune <me@mrjunejune.com>
parents:
224
diff
changeset
|
183 return 'python'; |
|
70de0c80d093
[hg-web] Add side-by-side changeset diffs
MrJuneJune <me@mrjunejune.com>
parents:
224
diff
changeset
|
184 } |
| 193 | 185 const langMap: Record<string, string> = { |
| 186 js: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript', | |
| 187 py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java', | |
| 188 c: 'c', cpp: 'cpp', h: 'c', hpp: 'cpp', cs: 'csharp', | |
| 189 sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'bash', | |
| 190 json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml', | |
| 191 html: 'html', htm: 'html', css: 'css', scss: 'scss', sass: 'scss', | |
| 192 sql: 'sql', md: 'markdown', markdown: 'markdown', xml: 'xml', | |
| 193 dockerfile: 'dockerfile', makefile: 'makefile' | |
| 194 }; | |
| 195 return langMap[ext] || 'plaintext'; | |
| 196 }; | |
| 197 | |
| 198 const addLineNumbers = (text: string) => { | |
| 199 const lines = text.split('\n'); | |
| 200 return lines.map((_, i) => i + 1).join('\n'); | |
| 201 }; | |
| 202 | |
| 203 return ( | |
| 204 <div className="file-viewer-overlay" onClick={onClose}> | |
| 205 <div className="file-viewer" onClick={(e) => e.stopPropagation()}> | |
| 206 <div className="file-viewer-header"> | |
| 207 <span className="file-viewer-title"> | |
| 208 <img src={ICONS.file} alt="" style={{ width: 16, height: 16 }} /> | |
| 209 {filename} | |
| 210 </span> | |
| 211 <button className="file-viewer-close" onClick={onClose} title="Close (Esc)"> | |
| 212 <img className="icon-invert" src={ICONS.close} alt="Close" /> | |
| 213 </button> | |
| 214 </div> | |
| 215 <div className="file-viewer-content"> | |
| 216 {loading ? ( | |
| 217 <div className="file-viewer-loading">Loading...</div> | |
| 218 ) : content ? ( | |
| 219 <pre style={{ display: 'flex' }}> | |
| 220 <span className="file-viewer-line-numbers">{addLineNumbers(content)}</span> | |
| 221 <code ref={codeRef} className={`language-${getLanguage()}`}>{content}</code> | |
| 222 </pre> | |
| 223 ) : ( | |
| 224 <div className="file-viewer-loading">Unable to load file</div> | |
| 225 )} | |
| 226 </div> | |
| 227 </div> | |
| 228 </div> | |
| 229 ); | |
| 230 } | |
| 231 | |
| 232 /** | |
| 233 * Component: MarkdownViewerModal | |
| 234 */ | |
| 235 function MarkdownViewerModal({ filePath, onClose }: { filePath: string; onClose: () => void }) { | |
| 236 const [content, setContent] = useState<string | null>(null); | |
| 237 const [loading, setLoading] = useState(true); | |
| 238 const contentRef = useRef<HTMLDivElement>(null); | |
| 239 const moduleRef = useRef<any>(null); | |
| 240 const [wasmReady, setWasmReady] = useState(false); | |
| 241 | |
| 242 const filename = filePath.split('/').pop() || filePath; | |
| 243 | |
| 244 useEffect(() => { | |
| 245 createMarkdownModule().then((Module: any) => { | |
| 246 moduleRef.current = Module; | |
| 247 setWasmReady(true); | |
| 248 }); | |
| 249 }, []); | |
| 250 | |
| 251 useEffect(() => { | |
| 252 setLoading(true); | |
| 253 getCachedFile(filePath).then((text) => { | |
| 254 setContent(text); | |
| 255 setLoading(false); | |
| 256 }); | |
| 257 }, [filePath]); | |
| 258 | |
| 259 useEffect(() => { | |
| 260 if (!content || !wasmReady || !contentRef.current || !moduleRef.current) return; | |
| 261 | |
| 262 const Module = moduleRef.current; | |
| 263 const markdownToHtmlPtr = Module.cwrap('markdown_to_html', 'number', ['string']); | |
| 264 const markdownFree = Module.cwrap('markdown_free', null, ['number']); | |
| 265 | |
| 266 const ptr = markdownToHtmlPtr(content); | |
| 267 const html = Module.UTF8ToString(ptr); | |
| 268 markdownFree(ptr); | |
| 269 contentRef.current.innerHTML = html; | |
| 270 }, [content, wasmReady]); | |
| 271 | |
| 272 useEffect(() => { | |
| 273 const handleKeyDown = (e: KeyboardEvent) => { | |
| 274 if (e.key === 'Escape') onClose(); | |
| 275 }; | |
| 276 window.addEventListener('keydown', handleKeyDown); | |
| 277 return () => window.removeEventListener('keydown', handleKeyDown); | |
| 278 }, [onClose]); | |
| 279 | |
| 280 return ( | |
| 281 <div className="file-viewer-overlay" onClick={onClose}> | |
| 282 <div className="file-viewer" onClick={(e) => e.stopPropagation()}> | |
| 283 <div className="file-viewer-header"> | |
| 284 <span className="file-viewer-title"> | |
| 285 <img src={ICONS.file} alt="" style={{ width: 16, height: 16 }} /> | |
| 286 {filename} | |
| 287 </span> | |
| 288 <button className="file-viewer-close" onClick={onClose} title="Close (Esc)"> | |
| 289 <img className="icon-invert" src={ICONS.close} alt="Close" /> | |
| 290 </button> | |
| 291 </div> | |
| 292 <div className="file-viewer-content"> | |
| 293 {loading || !wasmReady ? ( | |
| 294 <div className="file-viewer-loading">Loading...</div> | |
| 295 ) : content ? ( | |
| 296 <div className="readme-content" ref={contentRef} /> | |
| 297 ) : ( | |
| 298 <div className="file-viewer-loading">Unable to load file</div> | |
| 299 )} | |
| 300 </div> | |
| 301 </div> | |
| 302 </div> | |
| 303 ); | |
| 304 } | |
| 305 | |
|
224
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
306 function StaticFileViewer({ filePath, onClose }: { filePath: string; onClose: () => void }) { |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
307 const filename = filePath.split('/').pop() || filePath; |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
308 const previewKind = getStaticPreviewKind(filename); |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
309 const fileUrl = `${API_BASE}/file?path=${encodeURIComponent(filePath)}`; |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
310 const [failed, setFailed] = useState(false); |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
311 |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
312 useEffect(() => { |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
313 const handleKeyDown = (event: KeyboardEvent) => { |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
314 if (event.key === 'Escape') onClose(); |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
315 }; |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
316 window.addEventListener('keydown', handleKeyDown); |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
317 return () => window.removeEventListener('keydown', handleKeyDown); |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
318 }, [onClose]); |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
319 |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
320 return ( |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
321 <div className="file-viewer-overlay" onClick={onClose}> |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
322 <div |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
323 className="file-viewer static-file-viewer" |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
324 role="dialog" |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
325 aria-modal="true" |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
326 aria-label={`Preview ${filename}`} |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
327 onClick={(event) => event.stopPropagation()} |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
328 > |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
329 <div className="file-viewer-header"> |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
330 <span className="file-viewer-title"> |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
331 <img src={ICONS.file} alt="" style={{ width: 16, height: 16 }} /> |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
332 {filename} |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
333 </span> |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
334 <span className="file-viewer-actions"> |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
335 <a href={fileUrl} download={filename}>Download</a> |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
336 <button className="file-viewer-close" onClick={onClose} title="Close (Esc)"> |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
337 <img className="icon-invert" src={ICONS.close} alt="Close" /> |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
338 </button> |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
339 </span> |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
340 </div> |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
341 <div className="file-viewer-content static-file-preview"> |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
342 {failed && <div className="error-message">Unable to preview this file.</div>} |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
343 {!failed && previewKind === 'image' && ( |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
344 <img |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
345 className="static-file-image" |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
346 src={fileUrl} |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
347 alt={filename} |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
348 onError={() => setFailed(true)} |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
349 /> |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
350 )} |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
351 {!failed && previewKind === 'video' && ( |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
352 <video |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
353 className="static-file-video" |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
354 src={fileUrl} |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
355 controls |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
356 onError={() => setFailed(true)} |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
357 /> |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
358 )} |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
359 {!failed && previewKind === 'audio' && ( |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
360 <audio |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
361 className="static-file-audio" |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
362 src={fileUrl} |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
363 controls |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
364 onError={() => setFailed(true)} |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
365 /> |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
366 )} |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
367 {!failed && previewKind === 'pdf' && ( |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
368 <iframe |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
369 className="static-file-pdf" |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
370 src={fileUrl} |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
371 title={filename} |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
372 onError={() => setFailed(true)} |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
373 /> |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
374 )} |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
375 </div> |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
376 </div> |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
377 </div> |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
378 ); |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
379 } |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
380 |
| 193 | 381 /** |
| 382 * Component: FileList | |
| 383 */ | |
| 384 function FileList({ directories, files, onNavigate, onOpenFile }: { | |
| 385 directories: any[]; | |
| 386 files: any[]; | |
| 387 onNavigate: (path: string) => void; | |
| 388 onOpenFile: (path: string) => void; | |
| 389 }) { | |
| 390 const isEmpty = directories.length === 0 && files.length === 0; | |
| 391 | |
| 392 if (isEmpty) { | |
| 393 return ( | |
| 394 <div className="file-list-container"> | |
| 395 <div className="empty-state">This directory is empty.</div> | |
| 396 </div> | |
| 397 ); | |
| 398 } | |
| 399 | |
| 400 return ( | |
| 401 <div className="file-list-container"> | |
| 402 <div className="file-header">Files</div> | |
| 403 <div id="fileListBody"> | |
| 404 {directories.map((dir) => ( | |
| 405 <FileRow | |
| 406 key={dir.abspath} | |
| 407 item={dir} | |
| 408 iconUrl={ICONS.folder} | |
| 409 isDir={true} | |
| 410 onNavigate={onNavigate} | |
| 411 onOpenFile={onOpenFile} | |
| 412 /> | |
| 413 ))} | |
| 414 {files.map((file) => ( | |
| 415 <FileRow | |
| 416 key={file.abspath} | |
| 417 item={file} | |
| 418 iconUrl={ICONS.file} | |
| 419 isDir={false} | |
| 420 onNavigate={onNavigate} | |
| 421 onOpenFile={onOpenFile} | |
| 422 /> | |
| 423 ))} | |
| 424 </div> | |
| 425 </div> | |
| 426 ); | |
| 427 } | |
| 428 | |
| 429 /** | |
| 430 * Component: FileRow | |
| 431 */ | |
| 432 function FileRow({ item, iconUrl, isDir, onNavigate, onOpenFile }: { | |
| 433 item: { abspath: string; basename: string }; | |
| 434 iconUrl: string; | |
| 435 isDir: boolean; | |
| 436 onNavigate: (path: string) => void; | |
| 437 onOpenFile: (path: string) => void; | |
| 438 }) { | |
| 439 const handleClick = (e: React.MouseEvent) => { | |
| 440 e.preventDefault(); | |
| 441 if (isDir) { | |
| 442 onNavigate(item.abspath); | |
|
224
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
443 } else if (isCodeFile(item.basename) || getStaticPreviewKind(item.basename)) { |
| 193 | 444 onOpenFile(item.abspath); |
| 445 } else { | |
| 446 window.open(`/api/repo/file?path=${encodeURIComponent(item.abspath)}`, '_blank'); | |
| 447 } | |
| 448 }; | |
| 449 | |
| 450 const handleMouseEnter = () => { | |
| 451 if (isDir) { | |
| 452 prefetchDirectory(item.abspath); | |
| 453 } else if (isCodeFile(item.basename)) { | |
| 454 prefetchFile(item.abspath); | |
| 455 } | |
| 456 }; | |
| 457 | |
| 458 const href = isDir | |
| 459 ? `#` | |
| 460 : `/api/repo/file?path=${encodeURIComponent(item.abspath)}`; | |
| 461 | |
| 462 return ( | |
| 463 <div className="file-row" onMouseEnter={handleMouseEnter}> | |
| 464 <span className="icon"> | |
| 465 <img className="icon-invert" src={iconUrl} alt={isDir ? "Directory" : "File"} /> | |
| 466 </span> | |
| 467 <span className="name"> | |
| 468 <a href={href} onClick={handleClick}> | |
| 469 {item.basename} | |
| 470 </a> | |
| 471 </span> | |
| 472 </div> | |
| 473 ); | |
| 474 } | |
| 475 | |
| 476 /** | |
| 477 * Component: ReadmeViewer | |
| 478 */ | |
| 479 function ReadmeViewer({ content }: { content: string | null }) { | |
| 480 const contentRef = useRef<HTMLDivElement>(null); | |
| 481 const moduleRef = useRef<any>(null); | |
| 482 const [wasmReady, setWasmReady] = useState(false); | |
| 483 | |
| 484 useEffect(() => { | |
| 485 createMarkdownModule().then((Module: any) => { | |
| 486 moduleRef.current = Module; | |
| 487 setWasmReady(true); | |
| 488 }); | |
| 489 }, []); | |
| 490 | |
| 491 useEffect(() => { | |
| 492 if (!content || !wasmReady || !contentRef.current || !moduleRef.current) return; | |
| 493 | |
| 494 const Module = moduleRef.current; | |
| 495 const markdownToHtmlPtr = Module.cwrap('markdown_to_html', 'number', ['string']); | |
| 496 const markdownFree = Module.cwrap('markdown_free', null, ['number']); | |
| 497 | |
| 498 const ptr = markdownToHtmlPtr(content); | |
| 499 const html = Module.UTF8ToString(ptr); | |
| 500 markdownFree(ptr); | |
| 501 contentRef.current.innerHTML = html; | |
| 502 }, [content, wasmReady]); | |
| 503 | |
| 504 if (!content) return null; | |
| 505 | |
| 506 return ( | |
| 507 <div className="readme-section"> | |
| 508 <div className="readme-header"> | |
| 509 <img className="icon-invert" src={ICONS.file} width="16" alt="" style={{ opacity: 0.5 }} /> | |
| 510 README.md | |
| 511 </div> | |
| 512 <div className="readme-content" ref={contentRef}> | |
| 513 {!wasmReady && 'Loading...'} | |
| 514 </div> | |
| 515 </div> | |
| 516 ); | |
| 517 } | |
| 518 | |
| 519 /** | |
| 520 * Directory Browser Component (no header/footer - for embedding in app) | |
| 521 */ | |
| 522 interface DirectoryBrowserProps { | |
| 523 initialPath?: string; | |
| 524 onPathChange?: (path: string) => void; | |
| 525 } | |
| 526 | |
| 527 function DirectoryBrowser({ initialPath = '', onPathChange }: DirectoryBrowserProps) { | |
| 528 const [currentPath, setCurrentPath] = useState(initialPath); | |
| 529 const [content, setContent] = useState<{ files: any[]; directories: any[] }>({ files: [], directories: [] }); | |
| 530 const [readme, setReadme] = useState<string | null>(null); | |
| 531 const [error, setError] = useState<string | null>(null); | |
| 532 const [loading, setLoading] = useState(false); | |
| 533 const [viewingFile, setViewingFile] = useState<string | null>(null); | |
|
223
0e7b9464248d
[hg-web] Add browser route regression coverage
MrJuneJune <me@mrjunejune.com>
parents:
193
diff
changeset
|
534 const requestGeneration = useRef(0); |
| 193 | 535 |
| 536 // Sync with initialPath prop | |
| 537 useEffect(() => { | |
| 538 setCurrentPath(initialPath); | |
| 539 }, [initialPath]); | |
| 540 | |
| 541 useEffect(() => { | |
|
223
0e7b9464248d
[hg-web] Add browser route regression coverage
MrJuneJune <me@mrjunejune.com>
parents:
193
diff
changeset
|
542 const generation = ++requestGeneration.current; |
|
0e7b9464248d
[hg-web] Add browser route regression coverage
MrJuneJune <me@mrjunejune.com>
parents:
193
diff
changeset
|
543 fetchDirectory(currentPath, generation); |
|
0e7b9464248d
[hg-web] Add browser route regression coverage
MrJuneJune <me@mrjunejune.com>
parents:
193
diff
changeset
|
544 fetchReadme(currentPath, generation); |
| 193 | 545 }, [currentPath]); |
| 546 | |
| 547 const navigate = useCallback((path: string) => { | |
| 548 setCurrentPath(path); | |
| 549 onPathChange?.(path); | |
| 550 }, [onPathChange]); | |
| 551 | |
|
223
0e7b9464248d
[hg-web] Add browser route regression coverage
MrJuneJune <me@mrjunejune.com>
parents:
193
diff
changeset
|
552 const fetchDirectory = async (path: string, generation: number) => { |
| 193 | 553 setLoading(true); |
| 554 setError(null); | |
| 555 try { | |
| 556 const cacheKey = `dir:${path}`; | |
| 557 let data; | |
| 558 if (prefetchCache.has(cacheKey)) { | |
| 559 data = await prefetchCache.get(cacheKey); | |
| 560 prefetchCache.delete(cacheKey); | |
| 561 } else { | |
| 562 const url = path | |
| 563 ? `${API_BASE}/list?path=${encodeURIComponent(path)}` | |
| 564 : `${API_BASE}/list`; | |
| 565 const response = await fetch(url); | |
| 566 if (response.ok) { | |
| 567 data = await response.json(); | |
| 568 } | |
| 569 } | |
| 570 | |
| 571 if (data?.error) { | |
| 572 throw new Error(data.error); | |
| 573 } | |
| 574 | |
|
223
0e7b9464248d
[hg-web] Add browser route regression coverage
MrJuneJune <me@mrjunejune.com>
parents:
193
diff
changeset
|
575 if (generation === requestGeneration.current) { |
|
0e7b9464248d
[hg-web] Add browser route regression coverage
MrJuneJune <me@mrjunejune.com>
parents:
193
diff
changeset
|
576 setContent({ |
|
0e7b9464248d
[hg-web] Add browser route regression coverage
MrJuneJune <me@mrjunejune.com>
parents:
193
diff
changeset
|
577 files: data?.files || [], |
|
0e7b9464248d
[hg-web] Add browser route regression coverage
MrJuneJune <me@mrjunejune.com>
parents:
193
diff
changeset
|
578 directories: data?.directories || [] |
|
0e7b9464248d
[hg-web] Add browser route regression coverage
MrJuneJune <me@mrjunejune.com>
parents:
193
diff
changeset
|
579 }); |
|
0e7b9464248d
[hg-web] Add browser route regression coverage
MrJuneJune <me@mrjunejune.com>
parents:
193
diff
changeset
|
580 } |
| 193 | 581 } catch (err: any) { |
| 582 console.error('Error loading directory:', err); | |
|
223
0e7b9464248d
[hg-web] Add browser route regression coverage
MrJuneJune <me@mrjunejune.com>
parents:
193
diff
changeset
|
583 if (generation === requestGeneration.current) setError(err.message); |
| 193 | 584 } finally { |
|
223
0e7b9464248d
[hg-web] Add browser route regression coverage
MrJuneJune <me@mrjunejune.com>
parents:
193
diff
changeset
|
585 if (generation === requestGeneration.current) setLoading(false); |
| 193 | 586 } |
| 587 }; | |
| 588 | |
|
223
0e7b9464248d
[hg-web] Add browser route regression coverage
MrJuneJune <me@mrjunejune.com>
parents:
193
diff
changeset
|
589 const fetchReadme = async (path: string, generation: number) => { |
|
0e7b9464248d
[hg-web] Add browser route regression coverage
MrJuneJune <me@mrjunejune.com>
parents:
193
diff
changeset
|
590 if (generation === requestGeneration.current) setReadme(null); |
| 193 | 591 try { |
|
223
0e7b9464248d
[hg-web] Add browser route regression coverage
MrJuneJune <me@mrjunejune.com>
parents:
193
diff
changeset
|
592 const url = path |
|
0e7b9464248d
[hg-web] Add browser route regression coverage
MrJuneJune <me@mrjunejune.com>
parents:
193
diff
changeset
|
593 ? `${API_BASE}/readme?path=${encodeURIComponent(path)}` |
|
0e7b9464248d
[hg-web] Add browser route regression coverage
MrJuneJune <me@mrjunejune.com>
parents:
193
diff
changeset
|
594 : `${API_BASE}/readme`; |
|
0e7b9464248d
[hg-web] Add browser route regression coverage
MrJuneJune <me@mrjunejune.com>
parents:
193
diff
changeset
|
595 const response = await fetch(url); |
| 193 | 596 if (response.ok) { |
| 597 const text = await response.text(); | |
|
223
0e7b9464248d
[hg-web] Add browser route regression coverage
MrJuneJune <me@mrjunejune.com>
parents:
193
diff
changeset
|
598 if (generation === requestGeneration.current) setReadme(text || null); |
| 193 | 599 } |
| 600 } catch (err) { | |
| 601 // Readme is optional | |
| 602 } | |
| 603 }; | |
| 604 | |
| 605 const handleOpenFile = useCallback((path: string) => { | |
| 606 setViewingFile(path); | |
| 607 }, []); | |
| 608 | |
| 609 const handleCloseFile = useCallback(() => { | |
| 610 setViewingFile(null); | |
| 611 }, []); | |
| 612 | |
| 613 return ( | |
| 614 <> | |
| 615 <Breadcrumb currentPath={currentPath} onNavigate={navigate} /> | |
| 616 | |
| 617 {error && <div className="error-message">Error: {error}</div>} | |
| 618 | |
| 619 {loading ? ( | |
| 620 <div className="file-list-container"> | |
| 621 <div className="loading-state">Loading files...</div> | |
| 622 </div> | |
| 623 ) : ( | |
| 624 <> | |
| 625 <FileList | |
| 626 directories={content.directories} | |
| 627 files={content.files} | |
| 628 onNavigate={navigate} | |
| 629 onOpenFile={handleOpenFile} | |
| 630 /> | |
| 631 <ReadmeViewer content={readme} /> | |
| 632 </> | |
| 633 )} | |
| 634 | |
| 635 {/* File Viewer Modal */} | |
| 636 {viewingFile && ( | |
| 637 isMarkdownFile(viewingFile) ? ( | |
| 638 <MarkdownViewerModal filePath={viewingFile} onClose={handleCloseFile} /> | |
|
224
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
639 ) : getStaticPreviewKind(viewingFile) ? ( |
|
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
223
diff
changeset
|
640 <StaticFileViewer filePath={viewingFile} onClose={handleCloseFile} /> |
| 193 | 641 ) : ( |
| 642 <FileViewer filePath={viewingFile} onClose={handleCloseFile} /> | |
| 643 ) | |
| 644 )} | |
| 645 </> | |
| 646 ); | |
| 647 } | |
| 648 | |
| 649 export { DirectoryBrowser }; |