Mercurial
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 ← 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> |