comparison hg-web/src/components/directory-browser.tsx @ 193:9f4429c49733 hg-web

[HgWeb] Making progress....
author MrJuneJune <me@mrjunejune.com>
date Sun, 25 Jan 2026 20:04:55 -0800
parents
children
comparison
equal deleted inserted replaced
192:b818a4561a3c 193:9f4429c49733
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',
22 'gradle', 'pom', 'lock', 'gitignore', 'env', 'example', 'sample'
23 ]);
24
25 // Prefetch cache
26 const prefetchCache = new Map<string, Promise<any>>();
27
28 function isCodeFile(filename: string): boolean {
29 const ext = filename.split('.').pop()?.toLowerCase() || '';
30 const basename = filename.toLowerCase();
31 return CODE_EXTENSIONS.has(ext) ||
32 CODE_EXTENSIONS.has(basename) ||
33 basename === 'dockerfile' ||
34 basename === 'makefile' ||
35 basename.startsWith('.');
36 }
37
38 function isMarkdownFile(filename: string): boolean {
39 const ext = filename.split('.').pop()?.toLowerCase() || '';
40 return ext === 'md' || ext === 'markdown';
41 }
42
43 function prefetchDirectory(path: string): void {
44 const cacheKey = `dir:${path}`;
45 if (prefetchCache.has(cacheKey)) return;
46
47 const url = path
48 ? `${API_BASE}/list?path=${encodeURIComponent(path)}`
49 : `${API_BASE}/list`;
50
51 prefetchCache.set(cacheKey, fetch(url).then(r => r.json()).catch(() => null));
52 }
53
54 function prefetchFile(path: string): void {
55 const cacheKey = `file:${path}`;
56 if (prefetchCache.has(cacheKey)) return;
57
58 prefetchCache.set(cacheKey,
59 fetch(`${API_BASE}/file?path=${encodeURIComponent(path)}`)
60 .then(r => r.ok ? r.text() : null)
61 .catch(() => null)
62 );
63 }
64
65 async function getCachedFile(path: string): Promise<string | null> {
66 const cacheKey = `file:${path}`;
67 if (prefetchCache.has(cacheKey)) {
68 return prefetchCache.get(cacheKey);
69 }
70 prefetchFile(path);
71 return prefetchCache.get(cacheKey)!;
72 }
73
74 /**
75 * Component: Breadcrumb
76 */
77 function Breadcrumb({ currentPath, onNavigate }: { currentPath: string; onNavigate: (path: string) => void }) {
78 if (!currentPath) {
79 return (
80 <nav className="breadcrumb">
81 <span className="nav-item active">root</span>
82 </nav>
83 );
84 }
85
86 const parts = currentPath.split('/').filter(p => p);
87 const crumbs = parts.map((part, index) => ({
88 name: part,
89 fullPath: parts.slice(0, index + 1).join('/')
90 }));
91
92 return (
93 <nav className="breadcrumb">
94 <a
95 href="#"
96 onClick={(e) => { e.preventDefault(); onNavigate(''); }}
97 title="Go to Root"
98 >
99 root
100 </a>
101 {crumbs.map((crumb, index) => {
102 const isLast = index === crumbs.length - 1;
103 return (
104 <React.Fragment key={crumb.fullPath}>
105 <span className="separator">/</span>
106 {isLast ? (
107 <span className="nav-item active">{crumb.name}</span>
108 ) : (
109 <a
110 href="#"
111 onClick={(e) => { e.preventDefault(); onNavigate(crumb.fullPath); }}
112 >
113 {crumb.name}
114 </a>
115 )}
116 </React.Fragment>
117 );
118 })}
119 </nav>
120 );
121 }
122
123 /**
124 * Component: FileViewer
125 * Shows file content inline with syntax highlighting
126 */
127 function FileViewer({ filePath, onClose }: { filePath: string; onClose: () => void }) {
128 const [content, setContent] = useState<string | null>(null);
129 const [loading, setLoading] = useState(true);
130 const codeRef = useRef<HTMLElement>(null);
131
132 const filename = filePath.split('/').pop() || filePath;
133
134 useEffect(() => {
135 setLoading(true);
136 getCachedFile(filePath).then((text) => {
137 setContent(text);
138 setLoading(false);
139 });
140 }, [filePath]);
141
142 useEffect(() => {
143 if (content && codeRef.current) {
144 hljs.highlightElement(codeRef.current);
145 }
146 }, [content]);
147
148 useEffect(() => {
149 const handleKeyDown = (e: KeyboardEvent) => {
150 if (e.key === 'Escape') onClose();
151 };
152 window.addEventListener('keydown', handleKeyDown);
153 return () => window.removeEventListener('keydown', handleKeyDown);
154 }, [onClose]);
155
156 const getLanguage = () => {
157 const ext = filename.split('.').pop()?.toLowerCase() || '';
158 const langMap: Record<string, string> = {
159 js: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript',
160 py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java',
161 c: 'c', cpp: 'cpp', h: 'c', hpp: 'cpp', cs: 'csharp',
162 sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'bash',
163 json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml',
164 html: 'html', htm: 'html', css: 'css', scss: 'scss', sass: 'scss',
165 sql: 'sql', md: 'markdown', markdown: 'markdown', xml: 'xml',
166 dockerfile: 'dockerfile', makefile: 'makefile'
167 };
168 return langMap[ext] || 'plaintext';
169 };
170
171 const addLineNumbers = (text: string) => {
172 const lines = text.split('\n');
173 return lines.map((_, i) => i + 1).join('\n');
174 };
175
176 return (
177 <div className="file-viewer-overlay" onClick={onClose}>
178 <div className="file-viewer" onClick={(e) => e.stopPropagation()}>
179 <div className="file-viewer-header">
180 <span className="file-viewer-title">
181 <img src={ICONS.file} alt="" style={{ width: 16, height: 16 }} />
182 {filename}
183 </span>
184 <button className="file-viewer-close" onClick={onClose} title="Close (Esc)">
185 <img className="icon-invert" src={ICONS.close} alt="Close" />
186 </button>
187 </div>
188 <div className="file-viewer-content">
189 {loading ? (
190 <div className="file-viewer-loading">Loading...</div>
191 ) : content ? (
192 <pre style={{ display: 'flex' }}>
193 <span className="file-viewer-line-numbers">{addLineNumbers(content)}</span>
194 <code ref={codeRef} className={`language-${getLanguage()}`}>{content}</code>
195 </pre>
196 ) : (
197 <div className="file-viewer-loading">Unable to load file</div>
198 )}
199 </div>
200 </div>
201 </div>
202 );
203 }
204
205 /**
206 * Component: MarkdownViewerModal
207 */
208 function MarkdownViewerModal({ filePath, onClose }: { filePath: string; onClose: () => void }) {
209 const [content, setContent] = useState<string | null>(null);
210 const [loading, setLoading] = useState(true);
211 const contentRef = useRef<HTMLDivElement>(null);
212 const moduleRef = useRef<any>(null);
213 const [wasmReady, setWasmReady] = useState(false);
214
215 const filename = filePath.split('/').pop() || filePath;
216
217 useEffect(() => {
218 createMarkdownModule().then((Module: any) => {
219 moduleRef.current = Module;
220 setWasmReady(true);
221 });
222 }, []);
223
224 useEffect(() => {
225 setLoading(true);
226 getCachedFile(filePath).then((text) => {
227 setContent(text);
228 setLoading(false);
229 });
230 }, [filePath]);
231
232 useEffect(() => {
233 if (!content || !wasmReady || !contentRef.current || !moduleRef.current) return;
234
235 const Module = moduleRef.current;
236 const markdownToHtmlPtr = Module.cwrap('markdown_to_html', 'number', ['string']);
237 const markdownFree = Module.cwrap('markdown_free', null, ['number']);
238
239 const ptr = markdownToHtmlPtr(content);
240 const html = Module.UTF8ToString(ptr);
241 markdownFree(ptr);
242 contentRef.current.innerHTML = html;
243 }, [content, wasmReady]);
244
245 useEffect(() => {
246 const handleKeyDown = (e: KeyboardEvent) => {
247 if (e.key === 'Escape') onClose();
248 };
249 window.addEventListener('keydown', handleKeyDown);
250 return () => window.removeEventListener('keydown', handleKeyDown);
251 }, [onClose]);
252
253 return (
254 <div className="file-viewer-overlay" onClick={onClose}>
255 <div className="file-viewer" onClick={(e) => e.stopPropagation()}>
256 <div className="file-viewer-header">
257 <span className="file-viewer-title">
258 <img src={ICONS.file} alt="" style={{ width: 16, height: 16 }} />
259 {filename}
260 </span>
261 <button className="file-viewer-close" onClick={onClose} title="Close (Esc)">
262 <img className="icon-invert" src={ICONS.close} alt="Close" />
263 </button>
264 </div>
265 <div className="file-viewer-content">
266 {loading || !wasmReady ? (
267 <div className="file-viewer-loading">Loading...</div>
268 ) : content ? (
269 <div className="readme-content" ref={contentRef} />
270 ) : (
271 <div className="file-viewer-loading">Unable to load file</div>
272 )}
273 </div>
274 </div>
275 </div>
276 );
277 }
278
279 /**
280 * Component: FileList
281 */
282 function FileList({ directories, files, onNavigate, onOpenFile }: {
283 directories: any[];
284 files: any[];
285 onNavigate: (path: string) => void;
286 onOpenFile: (path: string) => void;
287 }) {
288 const isEmpty = directories.length === 0 && files.length === 0;
289
290 if (isEmpty) {
291 return (
292 <div className="file-list-container">
293 <div className="empty-state">This directory is empty.</div>
294 </div>
295 );
296 }
297
298 return (
299 <div className="file-list-container">
300 <div className="file-header">Files</div>
301 <div id="fileListBody">
302 {directories.map((dir) => (
303 <FileRow
304 key={dir.abspath}
305 item={dir}
306 iconUrl={ICONS.folder}
307 isDir={true}
308 onNavigate={onNavigate}
309 onOpenFile={onOpenFile}
310 />
311 ))}
312 {files.map((file) => (
313 <FileRow
314 key={file.abspath}
315 item={file}
316 iconUrl={ICONS.file}
317 isDir={false}
318 onNavigate={onNavigate}
319 onOpenFile={onOpenFile}
320 />
321 ))}
322 </div>
323 </div>
324 );
325 }
326
327 /**
328 * Component: FileRow
329 */
330 function FileRow({ item, iconUrl, isDir, onNavigate, onOpenFile }: {
331 item: { abspath: string; basename: string };
332 iconUrl: string;
333 isDir: boolean;
334 onNavigate: (path: string) => void;
335 onOpenFile: (path: string) => void;
336 }) {
337 const handleClick = (e: React.MouseEvent) => {
338 e.preventDefault();
339 if (isDir) {
340 onNavigate(item.abspath);
341 } else if (isCodeFile(item.basename)) {
342 onOpenFile(item.abspath);
343 } else {
344 window.open(`/api/repo/file?path=${encodeURIComponent(item.abspath)}`, '_blank');
345 }
346 };
347
348 const handleMouseEnter = () => {
349 if (isDir) {
350 prefetchDirectory(item.abspath);
351 } else if (isCodeFile(item.basename)) {
352 prefetchFile(item.abspath);
353 }
354 };
355
356 const href = isDir
357 ? `#`
358 : `/api/repo/file?path=${encodeURIComponent(item.abspath)}`;
359
360 return (
361 <div className="file-row" onMouseEnter={handleMouseEnter}>
362 <span className="icon">
363 <img className="icon-invert" src={iconUrl} alt={isDir ? "Directory" : "File"} />
364 </span>
365 <span className="name">
366 <a href={href} onClick={handleClick}>
367 {item.basename}
368 </a>
369 </span>
370 </div>
371 );
372 }
373
374 /**
375 * Component: ReadmeViewer
376 */
377 function ReadmeViewer({ content }: { content: string | null }) {
378 const contentRef = useRef<HTMLDivElement>(null);
379 const moduleRef = useRef<any>(null);
380 const [wasmReady, setWasmReady] = useState(false);
381
382 useEffect(() => {
383 createMarkdownModule().then((Module: any) => {
384 moduleRef.current = Module;
385 setWasmReady(true);
386 });
387 }, []);
388
389 useEffect(() => {
390 if (!content || !wasmReady || !contentRef.current || !moduleRef.current) return;
391
392 const Module = moduleRef.current;
393 const markdownToHtmlPtr = Module.cwrap('markdown_to_html', 'number', ['string']);
394 const markdownFree = Module.cwrap('markdown_free', null, ['number']);
395
396 const ptr = markdownToHtmlPtr(content);
397 const html = Module.UTF8ToString(ptr);
398 markdownFree(ptr);
399 contentRef.current.innerHTML = html;
400 }, [content, wasmReady]);
401
402 if (!content) return null;
403
404 return (
405 <div className="readme-section">
406 <div className="readme-header">
407 <img className="icon-invert" src={ICONS.file} width="16" alt="" style={{ opacity: 0.5 }} />
408 README.md
409 </div>
410 <div className="readme-content" ref={contentRef}>
411 {!wasmReady && 'Loading...'}
412 </div>
413 </div>
414 );
415 }
416
417 /**
418 * Directory Browser Component (no header/footer - for embedding in app)
419 */
420 interface DirectoryBrowserProps {
421 initialPath?: string;
422 onPathChange?: (path: string) => void;
423 }
424
425 function DirectoryBrowser({ initialPath = '', onPathChange }: DirectoryBrowserProps) {
426 const [currentPath, setCurrentPath] = useState(initialPath);
427 const [content, setContent] = useState<{ files: any[]; directories: any[] }>({ files: [], directories: [] });
428 const [readme, setReadme] = useState<string | null>(null);
429 const [error, setError] = useState<string | null>(null);
430 const [loading, setLoading] = useState(false);
431 const [viewingFile, setViewingFile] = useState<string | null>(null);
432
433 // Sync with initialPath prop
434 useEffect(() => {
435 setCurrentPath(initialPath);
436 }, [initialPath]);
437
438 useEffect(() => {
439 fetchDirectory(currentPath);
440 fetchReadme(currentPath);
441 }, [currentPath]);
442
443 const navigate = useCallback((path: string) => {
444 setCurrentPath(path);
445 onPathChange?.(path);
446 }, [onPathChange]);
447
448 const fetchDirectory = async (path: string) => {
449 setLoading(true);
450 setError(null);
451 try {
452 const cacheKey = `dir:${path}`;
453 let data;
454 if (prefetchCache.has(cacheKey)) {
455 data = await prefetchCache.get(cacheKey);
456 prefetchCache.delete(cacheKey);
457 } else {
458 const url = path
459 ? `${API_BASE}/list?path=${encodeURIComponent(path)}`
460 : `${API_BASE}/list`;
461 const response = await fetch(url);
462 if (response.ok) {
463 data = await response.json();
464 }
465 }
466
467 if (data?.error) {
468 throw new Error(data.error);
469 }
470
471 setContent({
472 files: data?.files || [],
473 directories: data?.directories || []
474 });
475 } catch (err: any) {
476 console.error('Error loading directory:', err);
477 setError(err.message);
478 } finally {
479 setLoading(false);
480 }
481 };
482
483 const fetchReadme = async (path: string) => {
484 setReadme(null);
485 const readmePath = path ? `${path}/README.md` : 'README.md';
486 try {
487 const response = await fetch(`${API_BASE}/file?path=${encodeURIComponent(readmePath)}`);
488 if (response.ok) {
489 const text = await response.text();
490 setReadme(text);
491 }
492 } catch (err) {
493 // Readme is optional
494 }
495 };
496
497 const handleOpenFile = useCallback((path: string) => {
498 setViewingFile(path);
499 }, []);
500
501 const handleCloseFile = useCallback(() => {
502 setViewingFile(null);
503 }, []);
504
505 return (
506 <>
507 <Breadcrumb currentPath={currentPath} onNavigate={navigate} />
508
509 {error && <div className="error-message">Error: {error}</div>}
510
511 {loading ? (
512 <div className="file-list-container">
513 <div className="loading-state">Loading files...</div>
514 </div>
515 ) : (
516 <>
517 <FileList
518 directories={content.directories}
519 files={content.files}
520 onNavigate={navigate}
521 onOpenFile={handleOpenFile}
522 />
523 <ReadmeViewer content={readme} />
524 </>
525 )}
526
527 {/* File Viewer Modal */}
528 {viewingFile && (
529 isMarkdownFile(viewingFile) ? (
530 <MarkdownViewerModal filePath={viewingFile} onClose={handleCloseFile} />
531 ) : (
532 <FileViewer filePath={viewingFile} onClose={handleCloseFile} />
533 )
534 )}
535 </>
536 );
537 }
538
539 export { DirectoryBrowser };