Mercurial
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 ← Back | 257 ← 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 ← Back to graph | 335 ← 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 ← Back | 431 ← 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 /> |