comparison hg-web/src/repo-browser.tsx @ 175:71ad34a8bc9a hg-web

[HgWeb] Can stream hg response now. Added react page for hg web since we use json anyway.
author MrJuneJune <me@mrjunejune.com>
date Tue, 20 Jan 2026 06:06:47 -0800
parents
children fed99fc04e12
comparison
equal deleted inserted replaced
174:1ba8c1df082c 175:71ad34a8bc9a
1 import React, { useState, useEffect } from 'react';
2
3 const API_BASE = '/api/repo';
4
5 /**
6 * Component: Breadcrumb
7 * Renders the navigation path at the top
8 */
9 function Breadcrumb({ currentPath, onNavigate }) {
10 if (!currentPath) {
11 return (
12 <nav id="breadcrumb">
13 <span className="nav-item active">Root</span>
14 </nav>
15 );
16 }
17
18 const parts = currentPath.split('/').filter(p => p);
19
20 // Create cumulative paths for links
21 // e.g., src/components -> ['src', 'src/components']
22 const crumbs = parts.map((part, index) => ({
23 name: part,
24 fullPath: parts.slice(0, index + 1).join('/')
25 }));
26
27 return (
28 <nav id="breadcrumb">
29 <a
30 href="/"
31 onClick={(e) => { e.preventDefault(); onNavigate(''); }}
32 >
33 Root
34 </a>
35 {crumbs.map((crumb, index) => {
36 const isLast = index === crumbs.length - 1;
37 return (
38 <React.Fragment key={crumb.fullPath}>
39 <span className="separator"> / </span>
40 {isLast ? (
41 <span className="nav-item active">{crumb.name}</span>
42 ) : (
43 <a
44 href={`?path=${encodeURIComponent(crumb.fullPath)}`}
45 onClick={(e) => { e.preventDefault(); onNavigate(crumb.fullPath); }}
46 >
47 {crumb.name}
48 </a>
49 )}
50 </React.Fragment>
51 );
52 })}
53 </nav>
54 );
55 }
56
57 /**
58 * Component: FileList
59 * Renders the table of directories and files
60 */
61 function FileList({ directories, files, onNavigate }) {
62 const isEmpty = directories.length === 0 && files.length === 0;
63
64 if (isEmpty) {
65 return <div className="empty-state">No files found.</div>;
66 }
67
68 return (
69 <div id="fileList">
70 {/* Render Directories */}
71 {directories.map((dir) => (
72 <FileRow
73 key={dir.abspath}
74 item={dir}
75 icon="📁"
76 isDir={true}
77 onNavigate={onNavigate}
78 />
79 ))}
80
81 {/* Render Files */}
82 {files.map((file) => (
83 <FileRow
84 key={file.abspath}
85 item={file}
86 icon="📄"
87 isDir={false}
88 />
89 ))}
90 </div>
91 );
92 }
93
94 /**
95 * Component: FileRow
96 * Individual item row
97 */
98 function FileRow({ item, icon, isDir, onNavigate }) {
99 const handleClick = (e) => {
100 if (isDir) {
101 e.preventDefault();
102 onNavigate(item.abspath);
103 }
104 // Files let the default <a> behavior happen (download/open in new tab)
105 };
106
107 // Files link to the raw content API, Dirs link to the app view
108 const href = isDir
109 ? `?path=${encodeURIComponent(item.abspath)}`
110 : `/api/repo/file?path=${encodeURIComponent(item.abspath)}`;
111
112 const target = isDir ? undefined : "_blank";
113
114 return (
115 <div className={`file-item ${item.type}`}>
116 <span className="icon">{icon}</span>
117 <span className="name">
118 <a href={href} onClick={handleClick} target={target} rel="noreferrer">
119 {item.basename}
120 </a>
121 </span>
122 </div>
123 );
124 }
125
126 /**
127 * Component: ReadmeViewer
128 * Renders the README content
129 */
130 function ReadmeViewer({ content }) {
131 if (!content) return null;
132
133 return (
134 <div id="readmeSection" style={{ marginTop: '20px', borderTop: '1px solid #eee' }}>
135 <h3>README.md</h3>
136 <div id="readmeContent">
137 <pre style={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace' }}>
138 {content}
139 </pre>
140 </div>
141 </div>
142 );
143 }
144
145
146
147 /**
148 * Main Application Component
149 */
150 function RepoBrowser() {
151 // State management for path, data, and UI states
152 const [currentPath, setCurrentPath] = useState(getCurrentPath());
153 const [content, setContent] = useState({ files: [], directories: [] });
154 const [readme, setReadme] = useState(null);
155 const [error, setError] = useState(null);
156 const [loading, setLoading] = useState(false);
157
158 // Helper to get path from URL query params
159 function getCurrentPath() {
160 const params = new URLSearchParams(window.location.search);
161 return params.get('path') || '';
162 }
163
164 // Effect: Handle Browser Navigation (Back/Forward buttons)
165 useEffect(() => {
166 const handlePopState = () => setCurrentPath(getCurrentPath());
167 window.addEventListener('popstate', handlePopState);
168 return () => window.removeEventListener('popstate', handlePopState);
169 }, []);
170
171 // Effect: Fetch Data whenever currentPath changes
172 useEffect(() => {
173 fetchDirectory(currentPath);
174 fetchReadme(currentPath);
175 }, [currentPath]);
176
177 // Internal navigation handler (avoids full page reload)
178 const navigate = (path) => {
179 const newUrl = path ? `?path=${encodeURIComponent(path)}` : '/';
180 window.history.pushState({ path }, '', newUrl);
181 setCurrentPath(path);
182 };
183
184 const fetchDirectory = async (path) => {
185 setLoading(true);
186 setError(null);
187 try {
188 const url = path
189 ? `${API_BASE}/list?path=${encodeURIComponent(path)}`
190 : `${API_BASE}/list`;
191
192 const response = await fetch(url);
193 const data = await response.json();
194
195 if (data.error) throw new Error(data.error);
196
197 // Ensure we always have arrays even if API returns null
198 setContent({
199 files: data.files || [],
200 directories: data.directories || []
201 });
202 } catch (err) {
203 console.error('Error loading directory:', err);
204 setError(err.message);
205 } finally {
206 setLoading(false);
207 }
208 };
209
210 const fetchReadme = async (path) => {
211 setReadme(null); // Reset previous readme
212 try {
213 const readmePath = path ? `${path}/README.md` : 'README.md';
214 const response = await fetch(`${API_BASE}/readme?path=${encodeURIComponent(readmePath)}`);
215
216 if (response.ok) {
217 const text = await response.text();
218 setReadme(text);
219 }
220 } catch (err) {
221 // Silently fail for Readme as it's optional
222 }
223 };
224
225 return (
226 <div className="repo-container">
227 <div class="header">
228 <h1>Zenbu Repository</h1>
229 <p class="description">Browse and clone this mercurial repository</p>
230 </div>
231
232 <div class="clone-info">
233 <strong>Clone this repository:</strong>
234 <p><code>hg clone http://zenbu.babocoder.com/repo</code></p>
235 </div>
236
237 <Breadcrumb currentPath={currentPath} onNavigate={navigate} />
238
239 {error && <div className="error-message">Error: {error}</div>}
240
241 {loading ? (
242 <div className="loading">Loading...</div>
243 ) : (
244 <>
245 <FileList
246 directories={content.directories}
247 files={content.files}
248 onNavigate={navigate}
249 />
250 <ReadmeViewer content={readme} />
251 </>
252 )}
253 </div>
254 );
255 }
256
257 export { RepoBrowser };