comparison hg-web/src/components/app.tsx @ 221:ce7f4400c2de hg-web

[hg-web] Harden forge and add changeset UI
author MrJuneJune <me@mrjunejune.com>
date Sun, 02 Aug 2026 09:01:24 -0700
parents 9f4429c49733
children 0e7b9464248d
comparison
equal deleted inserted replaced
219:8c9bb0b0759e 221:ce7f4400c2de
3 import { DirectoryBrowser } from "hg-web/src/components/directory-browser"; 3 import { DirectoryBrowser } from "hg-web/src/components/directory-browser";
4 import { Header } from "hg-web/src/components/header"; 4 import { Header } from "hg-web/src/components/header";
5 import { Footer } from "hg-web/src/components/footer"; 5 import { Footer } from "hg-web/src/components/footer";
6 import { ThemeProvider, useTheme } from "hg-web/src/components/theme"; 6 import { ThemeProvider, useTheme } from "hg-web/src/components/theme";
7 7
8 type Page = 'landing' | 'graph' | 'directory'; 8 type Page = 'landing' | 'graph' | 'directory' | 'changeset';
9 9
10 type RouteState = { 10 type RouteState = {
11 page: Page; 11 page: Page;
12 graphCommit?: string; 12 graphCommit?: string;
13 graphTip?: string; 13 graphTip?: string;
14 dirPath?: string; 14 dirPath?: string;
15 } 15 changesetId?: string;
16 }
17
18 type ChangesetDetail = {
19 node: string;
20 date: [number, number];
21 desc: string;
22 branch: string;
23 bookmarks: string[];
24 tags: string[];
25 user: string;
26 parents: string[];
27 files: string[];
28 diff: Array<{
29 blockno: number;
30 lines: Array<{ t: string; n: number; l: string }>;
31 }>;
32 };
16 33
17 // Icons 34 // Icons
18 const ICONS = { 35 const ICONS = {
19 folder: "/icons/folder.png", 36 folder: "/icons/folder.png",
20 }; 37 };
38 const API_BASE = '/api/repo'; 55 const API_BASE = '/api/repo';
39 56
40 function parseRoute(): RouteState { 57 function parseRoute(): RouteState {
41 const params = new URLSearchParams(window.location.search); 58 const params = new URLSearchParams(window.location.search);
42 const pathname = window.location.pathname; 59 const pathname = window.location.pathname;
60 const changesetMatch = pathname.match(/^\/changeset\/([^/]+)$/);
61
62 if (changesetMatch) {
63 try {
64 const changesetId = decodeURIComponent(changesetMatch[1]);
65 if (/^(?:[0-9a-f]{1,40}|tip)$/i.test(changesetId)) {
66 return { page: 'changeset', changesetId };
67 }
68 } catch {
69 return { page: 'landing' };
70 }
71 }
43 72
44 if (pathname.startsWith('/graph') || params.has('graph')) { 73 if (pathname.startsWith('/graph') || params.has('graph')) {
45 return { 74 return {
46 page: 'graph', 75 page: 'graph',
47 graphCommit: params.get('commit') || undefined, 76 graphCommit: params.get('commit') || undefined,
68 if (state.graphTip) params.set('tip', state.graphTip); 97 if (state.graphTip) params.set('tip', state.graphTip);
69 return `/graph${params.toString() ? '?' + params.toString() : ''}`; 98 return `/graph${params.toString() ? '?' + params.toString() : ''}`;
70 case 'directory': 99 case 'directory':
71 if (state.dirPath) params.set('path', state.dirPath); 100 if (state.dirPath) params.set('path', state.dirPath);
72 return `/directory${params.toString() ? '?' + params.toString() : ''}`; 101 return `/directory${params.toString() ? '?' + params.toString() : ''}`;
102 case 'changeset':
103 return state.changesetId ? `/changeset/${encodeURIComponent(state.changesetId)}` : '/graph';
73 default: 104 default:
74 return '/'; 105 return '/';
75 } 106 }
76 } 107 }
77 108
78 // Landing Page Component 109 // Landing Page Component
79 function LandingPage({ 110 function LandingPage({
80 onNavigateToGraph, 111 onNavigateToGraph,
81 onNavigateToDirectory, 112 onNavigateToDirectory,
113 onNavigateToChangeset,
82 }: { 114 }: {
83 onNavigateToGraph: () => void; 115 onNavigateToGraph: () => void;
84 onNavigateToDirectory: (path?: string) => void; 116 onNavigateToDirectory: (path?: string) => void;
117 onNavigateToChangeset: (node: string) => void;
85 }) { 118 }) {
86 const [directories, setDirectories] = useState<any[]>([]); 119 const [directories, setDirectories] = useState<any[]>([]);
87 const [files, setFiles] = useState<any[]>([]); 120 const [files, setFiles] = useState<any[]>([]);
88 const [dirLoading, setDirLoading] = useState(true); 121 const [dirLoading, setDirLoading] = useState(true);
89 122
126 <div className="loading-state">Loading commits...</div> 159 <div className="loading-state">Loading commits...</div>
127 ) : graphData ? ( 160 ) : graphData ? (
128 <Graph 161 <Graph
129 data={graphData} 162 data={graphData}
130 maxRows={8} 163 maxRows={8}
131 onCommitClick={(node) => { 164 onCommitClick={onNavigateToChangeset}
132 console.log('Clicked commit:', node);
133 }}
134 /> 165 />
135 ) : ( 166 ) : (
136 <div className="empty-state">Failed to load commits</div> 167 <div className="empty-state">Failed to load commits</div>
137 )} 168 )}
138 </div> 169 </div>
184 // Graph Page Component 215 // Graph Page Component
185 function GraphPage({ 216 function GraphPage({
186 onBack, 217 onBack,
187 initialCommit, 218 initialCommit,
188 initialTip, 219 initialTip,
220 onOpenChangeset,
189 }: { 221 }: {
190 onBack: () => void; 222 onBack: () => void;
191 initialCommit?: string; 223 initialCommit?: string;
192 initialTip?: string; 224 initialTip?: string;
225 onOpenChangeset: (node: string) => void;
193 }) { 226 }) {
194 const { data, loading, error, loadMore, hasMore, tip, currentCommit } = useGraphData({ 227 const { data, loading, error, loadMore, hasMore, tip, currentCommit } = useGraphData({
195 initialCommit: initialCommit || null, 228 initialCommit: initialCommit || null,
196 graphTop: initialTip || null, 229 graphTop: initialTip || null,
197 }); 230 });
237 <Graph 270 <Graph
238 data={data} 271 data={data}
239 loading={loading} 272 loading={loading}
240 hasMore={hasMore} 273 hasMore={hasMore}
241 onLoadMore={loadMore} 274 onLoadMore={loadMore}
242 onCommitClick={(node) => { 275 onCommitClick={onOpenChangeset}
243 console.log('Clicked commit:', node);
244 }}
245 /> 276 />
277 </div>
278 );
279 }
280
281 function ChangesetPage({
282 changesetId,
283 onBack,
284 onOpenChangeset,
285 }: {
286 changesetId: string;
287 onBack: () => void;
288 onOpenChangeset: (node: string) => void;
289 }) {
290 const [changeset, setChangeset] = useState<ChangesetDetail | null>(null);
291 const [loading, setLoading] = useState(true);
292 const [error, setError] = useState<string | null>(null);
293
294 useEffect(() => {
295 const controller = new AbortController();
296
297 setLoading(true);
298 setError(null);
299 setChangeset(null);
300
301 fetch(`/api/changeset/${encodeURIComponent(changesetId)}`, { signal: controller.signal })
302 .then(async response => {
303 if (!response.ok) {
304 const message = await response.text();
305 throw new Error(message || `Unable to load changeset (${response.status})`);
306 }
307 return response.json() as Promise<ChangesetDetail>;
308 })
309 .then(setChangeset)
310 .catch(err => {
311 if (err.name !== 'AbortError') setError(err.message);
312 })
313 .finally(() => {
314 if (!controller.signal.aborted) setLoading(false);
315 });
316
317 return () => controller.abort();
318 }, [changesetId]);
319
320 return (
321 <div>
322 <div className="page-header">
323 <button className="back-button" onClick={onBack}>
324 &larr; Back to graph
325 </button>
326 <span className="page-title">Changeset</span>
327 </div>
328
329 {loading && <div className="loading-state">Loading changeset...</div>}
330 {error && <div className="error-message">Error: {error}</div>}
331
332 {changeset && (
333 <article className="changeset-paper">
334 <div className="changeset-heading">
335 <code>{changeset.node}</code>
336 <span className="changeset-branch">{changeset.branch}</span>
337 </div>
338 <h2>{changeset.desc}</h2>
339 <div className="changeset-meta">
340 <span>{changeset.user}</span>
341 <time dateTime={new Date(changeset.date[0] * 1000).toISOString()}>
342 {new Date(changeset.date[0] * 1000).toLocaleString()}
343 </time>
344 </div>
345
346 {(changeset.bookmarks.length > 0 || changeset.tags.length > 0) && (
347 <div className="changeset-labels">
348 {changeset.bookmarks.map(bookmark => <span key={`bookmark-${bookmark}`}>{bookmark}</span>)}
349 {changeset.tags.map(tag => <span key={`tag-${tag}`}>{tag}</span>)}
350 </div>
351 )}
352
353 {changeset.parents.length > 0 && (
354 <div className="changeset-parents">
355 <strong>Parents</strong>
356 {changeset.parents.map(parent => (
357 <button type="button" key={parent} onClick={() => onOpenChangeset(parent)}>
358 {parent.substring(0, 12)}
359 </button>
360 ))}
361 </div>
362 )}
363
364 {changeset.files.length > 0 && (
365 <div className="changeset-files">
366 <strong>Files</strong>
367 {changeset.files.map(file => <code key={file}>{file}</code>)}
368 </div>
369 )}
370
371 <section className="changeset-diff" aria-label="Changeset diff">
372 <h3>Diff</h3>
373 {changeset.diff.length === 0 ? (
374 <div className="empty-state">No textual changes in this changeset.</div>
375 ) : changeset.diff.map(block => (
376 <pre key={block.blockno}>
377 {block.lines.map((line, index) => (
378 <span
379 key={`${line.n}-${index}`}
380 className={
381 line.t === '+' ? 'diff-add' :
382 line.t === '-' ? 'diff-remove' :
383 line.t === '@' ? 'diff-range' : 'diff-context'
384 }
385 >
386 <span className="diff-line-number">{line.n}</span>
387 <span>{line.l}</span>
388 </span>
389 ))}
390 </pre>
391 ))}
392 </section>
393 </article>
394 )}
246 </div> 395 </div>
247 ); 396 );
248 } 397 }
249 398
250 // Directory Page Component 399 // Directory Page Component
304 453
305 const navigateToDirectory = useCallback((path?: string) => { 454 const navigateToDirectory = useCallback((path?: string) => {
306 navigate({ page: 'directory', dirPath: path || '' }); 455 navigate({ page: 'directory', dirPath: path || '' });
307 }, [navigate]); 456 }, [navigate]);
308 457
458 const navigateToChangeset = useCallback((changesetId: string) => {
459 navigate({ page: 'changeset', changesetId });
460 }, [navigate]);
461
309 const handleDirectoryPathChange = useCallback((path: string) => { 462 const handleDirectoryPathChange = useCallback((path: string) => {
310 // Update URL without full navigation 463 // Update URL without full navigation
311 const params = new URLSearchParams(); 464 const params = new URLSearchParams();
312 if (path) params.set('path', path); 465 if (path) params.set('path', path);
313 const newUrl = `/directory${params.toString() ? '?' + params.toString() : ''}`; 466 const newUrl = `/directory${params.toString() ? '?' + params.toString() : ''}`;
331 onClick={navigateToLanding} 484 onClick={navigateToLanding}
332 > 485 >
333 Home 486 Home
334 </button> 487 </button>
335 <button 488 <button
336 className={`nav-tab ${route.page === 'graph' ? 'active' : ''}`} 489 className={`nav-tab ${route.page === 'graph' || route.page === 'changeset' ? 'active' : ''}`}
337 onClick={() => navigateToGraph()} 490 onClick={() => navigateToGraph()}
338 > 491 >
339 <GraphIcon /> 492 <GraphIcon />
340 Graph 493 Graph
341 </button> 494 </button>
351 {/* Page Content */} 504 {/* Page Content */}
352 {route.page === 'landing' && ( 505 {route.page === 'landing' && (
353 <LandingPage 506 <LandingPage
354 onNavigateToGraph={() => navigateToGraph()} 507 onNavigateToGraph={() => navigateToGraph()}
355 onNavigateToDirectory={navigateToDirectory} 508 onNavigateToDirectory={navigateToDirectory}
509 onNavigateToChangeset={navigateToChangeset}
356 /> 510 />
357 )} 511 )}
358 512
359 {route.page === 'graph' && ( 513 {route.page === 'graph' && (
360 <GraphPage 514 <GraphPage
361 onBack={navigateToLanding} 515 onBack={navigateToLanding}
362 initialCommit={route.graphCommit} 516 initialCommit={route.graphCommit}
363 initialTip={route.graphTip} 517 initialTip={route.graphTip}
518 onOpenChangeset={navigateToChangeset}
364 /> 519 />
365 )} 520 )}
366 521
367 {route.page === 'directory' && ( 522 {route.page === 'directory' && (
368 <DirectoryPage 523 <DirectoryPage
369 onBack={navigateToLanding} 524 onBack={navigateToLanding}
370 initialPath={route.dirPath} 525 initialPath={route.dirPath}
371 onPathChange={handleDirectoryPathChange} 526 onPathChange={handleDirectoryPathChange}
527 />
528 )}
529
530 {route.page === 'changeset' && route.changesetId && (
531 <ChangesetPage
532 changesetId={route.changesetId}
533 onBack={() => navigateToGraph()}
534 onOpenChangeset={navigateToChangeset}
372 /> 535 />
373 )} 536 )}
374 537
375 <Footer /> 538 <Footer />
376 </div> 539 </div>