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