comparison hg-web/src/components/app.tsx @ 223:0e7b9464248d hg-web

[hg-web] Add browser route regression coverage
author MrJuneJune <me@mrjunejune.com>
date Sun, 02 Aug 2026 10:07:40 -0700
parents ce7f4400c2de
children 70de0c80d093
comparison
equal deleted inserted replaced
222:a8d6435dc021 223:0e7b9464248d
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 changesetId?: string; 15 changesetId?: string;
16 returnDepth?: number;
16 } 17 }
17 18
18 type ChangesetDetail = { 19 type ChangesetDetail = {
19 node: string; 20 node: string;
20 date: [number, number]; 21 date: [number, number];
22 branch: string; 23 branch: string;
23 bookmarks: string[]; 24 bookmarks: string[];
24 tags: string[]; 25 tags: string[];
25 user: string; 26 user: string;
26 parents: string[]; 27 parents: string[];
27 files: string[]; 28 files: Array<{
29 file: string;
30 status: string;
31 }>;
28 diff: Array<{ 32 diff: Array<{
29 blockno: number; 33 blockno: number;
30 lines: Array<{ t: string; n: number; l: string }>; 34 lines: Array<{ t: string; n: number; l: string }>;
31 }>; 35 }>;
32 }; 36 };
102 case 'changeset': 106 case 'changeset':
103 return state.changesetId ? `/changeset/${encodeURIComponent(state.changesetId)}` : '/graph'; 107 return state.changesetId ? `/changeset/${encodeURIComponent(state.changesetId)}` : '/graph';
104 default: 108 default:
105 return '/'; 109 return '/';
106 } 110 }
111 }
112
113 function isRouteState(value: unknown): value is RouteState {
114 if (!value || typeof value !== 'object' || !('page' in value)) return false;
115 return ['landing', 'graph', 'directory', 'changeset'].includes(
116 String((value as { page: unknown }).page),
117 );
107 } 118 }
108 119
109 // Landing Page Component 120 // Landing Page Component
110 function LandingPage({ 121 function LandingPage({
111 onNavigateToGraph, 122 onNavigateToGraph,
240 }, [currentCommit, tip]); 251 }, [currentCommit, tip]);
241 252
242 return ( 253 return (
243 <div> 254 <div>
244 <div className="page-header"> 255 <div className="page-header">
245 <button className="back-button" onClick={onBack}> 256 <button className="back-button" onClick={onBack} aria-label="Back">
246 &larr; Back 257 &larr; Back
247 </button> 258 </button>
248 <span className="page-title">Commit Graph</span> 259 <span className="page-title">Commit Graph</span>
249 </div> 260 </div>
250 261
318 }, [changesetId]); 329 }, [changesetId]);
319 330
320 return ( 331 return (
321 <div> 332 <div>
322 <div className="page-header"> 333 <div className="page-header">
323 <button className="back-button" onClick={onBack}> 334 <button className="back-button" onClick={onBack} aria-label="Back">
324 &larr; Back to graph 335 &larr; Back
325 </button> 336 </button>
326 <span className="page-title">Changeset</span> 337 <span className="page-title">Changeset</span>
327 </div> 338 </div>
328 339
329 {loading && <div className="loading-state">Loading changeset...</div>} 340 {loading && <div className="loading-state">Loading changeset...</div>}
362 )} 373 )}
363 374
364 {changeset.files.length > 0 && ( 375 {changeset.files.length > 0 && (
365 <div className="changeset-files"> 376 <div className="changeset-files">
366 <strong>Files</strong> 377 <strong>Files</strong>
367 {changeset.files.map(file => <code key={file}>{file}</code>)} 378 {changeset.files.map(file => (
379 <code key={file.file}>
380 <span className={`changeset-file-status status-${file.status}`}>
381 {file.status}
382 </span>
383 {file.file}
384 </code>
385 ))}
368 </div> 386 </div>
369 )} 387 )}
370 388
371 <section className="changeset-diff" aria-label="Changeset diff"> 389 <section className="changeset-diff" aria-label="Changeset diff">
372 <h3>Diff</h3> 390 <h3>Diff</h3>
407 onPathChange: (path: string) => void; 425 onPathChange: (path: string) => void;
408 }) { 426 }) {
409 return ( 427 return (
410 <div> 428 <div>
411 <div className="page-header"> 429 <div className="page-header">
412 <button className="back-button" onClick={onBack}> 430 <button className="back-button" onClick={onBack} aria-label="Back">
413 &larr; Back 431 &larr; Back
414 </button> 432 </button>
415 <span className="page-title">Repository Files</span> 433 <span className="page-title">Repository Files</span>
416 </div> 434 </div>
417 435
428 const [route, setRoute] = useState<RouteState>(parseRoute); 446 const [route, setRoute] = useState<RouteState>(parseRoute);
429 const { isDark, toggleTheme } = useTheme(); 447 const { isDark, toggleTheme } = useTheme();
430 448
431 // Handle browser back/forward 449 // Handle browser back/forward
432 useEffect(() => { 450 useEffect(() => {
433 const handlePopState = () => { 451 const handlePopState = (event: PopStateEvent) => {
434 setRoute(parseRoute()); 452 setRoute(isRouteState(event.state) ? event.state : parseRoute());
435 }; 453 };
436 window.addEventListener('popstate', handlePopState); 454 window.addEventListener('popstate', handlePopState);
437 return () => window.removeEventListener('popstate', handlePopState); 455 return () => window.removeEventListener('popstate', handlePopState);
438 }, []); 456 }, []);
439 457
454 const navigateToDirectory = useCallback((path?: string) => { 472 const navigateToDirectory = useCallback((path?: string) => {
455 navigate({ page: 'directory', dirPath: path || '' }); 473 navigate({ page: 'directory', dirPath: path || '' });
456 }, [navigate]); 474 }, [navigate]);
457 475
458 const navigateToChangeset = useCallback((changesetId: string) => { 476 const navigateToChangeset = useCallback((changesetId: string) => {
459 navigate({ page: 'changeset', changesetId }); 477 const returnDepth = route.page === 'changeset'
460 }, [navigate]); 478 ? (route.returnDepth ?? 0) + 1
479 : 1;
480 navigate({ page: 'changeset', changesetId, returnDepth });
481 }, [navigate, route.page, route.returnDepth]);
482
483 const navigateBackFromChangeset = useCallback(() => {
484 if (route.returnDepth !== undefined && route.returnDepth > 0) {
485 window.history.go(-route.returnDepth);
486 return;
487 }
488 navigateToGraph();
489 }, [navigateToGraph, route.returnDepth]);
461 490
462 const handleDirectoryPathChange = useCallback((path: string) => { 491 const handleDirectoryPathChange = useCallback((path: string) => {
463 // Update URL without full navigation 492 // Update URL without full navigation
464 const params = new URLSearchParams(); 493 const params = new URLSearchParams();
465 if (path) params.set('path', path); 494 if (path) params.set('path', path);
528 )} 557 )}
529 558
530 {route.page === 'changeset' && route.changesetId && ( 559 {route.page === 'changeset' && route.changesetId && (
531 <ChangesetPage 560 <ChangesetPage
532 changesetId={route.changesetId} 561 changesetId={route.changesetId}
533 onBack={() => navigateToGraph()} 562 onBack={navigateBackFromChangeset}
534 onOpenChangeset={navigateToChangeset} 563 onOpenChangeset={navigateToChangeset}
535 /> 564 />
536 )} 565 )}
537 566
538 <Footer /> 567 <Footer />