|
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',
|
|
|
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 };
|