Mercurial
diff 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 |
line wrap: on
line diff
--- a/hg-web/src/components/app.tsx Sun Aug 02 08:34:54 2026 -0700 +++ b/hg-web/src/components/app.tsx Sun Aug 02 09:01:24 2026 -0700 @@ -5,15 +5,32 @@ import { Footer } from "hg-web/src/components/footer"; import { ThemeProvider, useTheme } from "hg-web/src/components/theme"; -type Page = 'landing' | 'graph' | 'directory'; +type Page = 'landing' | 'graph' | 'directory' | 'changeset'; type RouteState = { page: Page; graphCommit?: string; graphTip?: string; dirPath?: string; + changesetId?: string; } +type ChangesetDetail = { + node: string; + date: [number, number]; + desc: string; + branch: string; + bookmarks: string[]; + tags: string[]; + user: string; + parents: string[]; + files: string[]; + diff: Array<{ + blockno: number; + lines: Array<{ t: string; n: number; l: string }>; + }>; +}; + // Icons const ICONS = { folder: "/icons/folder.png", @@ -40,6 +57,18 @@ function parseRoute(): RouteState { const params = new URLSearchParams(window.location.search); const pathname = window.location.pathname; + const changesetMatch = pathname.match(/^\/changeset\/([^/]+)$/); + + if (changesetMatch) { + try { + const changesetId = decodeURIComponent(changesetMatch[1]); + if (/^(?:[0-9a-f]{1,40}|tip)$/i.test(changesetId)) { + return { page: 'changeset', changesetId }; + } + } catch { + return { page: 'landing' }; + } + } if (pathname.startsWith('/graph') || params.has('graph')) { return { @@ -70,6 +99,8 @@ case 'directory': if (state.dirPath) params.set('path', state.dirPath); return `/directory${params.toString() ? '?' + params.toString() : ''}`; + case 'changeset': + return state.changesetId ? `/changeset/${encodeURIComponent(state.changesetId)}` : '/graph'; default: return '/'; } @@ -79,9 +110,11 @@ function LandingPage({ onNavigateToGraph, onNavigateToDirectory, + onNavigateToChangeset, }: { onNavigateToGraph: () => void; onNavigateToDirectory: (path?: string) => void; + onNavigateToChangeset: (node: string) => void; }) { const [directories, setDirectories] = useState<any[]>([]); const [files, setFiles] = useState<any[]>([]); @@ -128,9 +161,7 @@ <Graph data={graphData} maxRows={8} - onCommitClick={(node) => { - console.log('Clicked commit:', node); - }} + onCommitClick={onNavigateToChangeset} /> ) : ( <div className="empty-state">Failed to load commits</div> @@ -186,10 +217,12 @@ onBack, initialCommit, initialTip, + onOpenChangeset, }: { onBack: () => void; initialCommit?: string; initialTip?: string; + onOpenChangeset: (node: string) => void; }) { const { data, loading, error, loadMore, hasMore, tip, currentCommit } = useGraphData({ initialCommit: initialCommit || null, @@ -239,14 +272,130 @@ loading={loading} hasMore={hasMore} onLoadMore={loadMore} - onCommitClick={(node) => { - console.log('Clicked commit:', node); - }} + onCommitClick={onOpenChangeset} /> </div> ); } +function ChangesetPage({ + changesetId, + onBack, + onOpenChangeset, +}: { + changesetId: string; + onBack: () => void; + onOpenChangeset: (node: string) => void; +}) { + const [changeset, setChangeset] = useState<ChangesetDetail | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + + useEffect(() => { + const controller = new AbortController(); + + setLoading(true); + setError(null); + setChangeset(null); + + fetch(`/api/changeset/${encodeURIComponent(changesetId)}`, { signal: controller.signal }) + .then(async response => { + if (!response.ok) { + const message = await response.text(); + throw new Error(message || `Unable to load changeset (${response.status})`); + } + return response.json() as Promise<ChangesetDetail>; + }) + .then(setChangeset) + .catch(err => { + if (err.name !== 'AbortError') setError(err.message); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + + return () => controller.abort(); + }, [changesetId]); + + return ( + <div> + <div className="page-header"> + <button className="back-button" onClick={onBack}> + ← Back to graph + </button> + <span className="page-title">Changeset</span> + </div> + + {loading && <div className="loading-state">Loading changeset...</div>} + {error && <div className="error-message">Error: {error}</div>} + + {changeset && ( + <article className="changeset-paper"> + <div className="changeset-heading"> + <code>{changeset.node}</code> + <span className="changeset-branch">{changeset.branch}</span> + </div> + <h2>{changeset.desc}</h2> + <div className="changeset-meta"> + <span>{changeset.user}</span> + <time dateTime={new Date(changeset.date[0] * 1000).toISOString()}> + {new Date(changeset.date[0] * 1000).toLocaleString()} + </time> + </div> + + {(changeset.bookmarks.length > 0 || changeset.tags.length > 0) && ( + <div className="changeset-labels"> + {changeset.bookmarks.map(bookmark => <span key={`bookmark-${bookmark}`}>{bookmark}</span>)} + {changeset.tags.map(tag => <span key={`tag-${tag}`}>{tag}</span>)} + </div> + )} + + {changeset.parents.length > 0 && ( + <div className="changeset-parents"> + <strong>Parents</strong> + {changeset.parents.map(parent => ( + <button type="button" key={parent} onClick={() => onOpenChangeset(parent)}> + {parent.substring(0, 12)} + </button> + ))} + </div> + )} + + {changeset.files.length > 0 && ( + <div className="changeset-files"> + <strong>Files</strong> + {changeset.files.map(file => <code key={file}>{file}</code>)} + </div> + )} + + <section className="changeset-diff" aria-label="Changeset diff"> + <h3>Diff</h3> + {changeset.diff.length === 0 ? ( + <div className="empty-state">No textual changes in this changeset.</div> + ) : changeset.diff.map(block => ( + <pre key={block.blockno}> + {block.lines.map((line, index) => ( + <span + key={`${line.n}-${index}`} + className={ + line.t === '+' ? 'diff-add' : + line.t === '-' ? 'diff-remove' : + line.t === '@' ? 'diff-range' : 'diff-context' + } + > + <span className="diff-line-number">{line.n}</span> + <span>{line.l}</span> + </span> + ))} + </pre> + ))} + </section> + </article> + )} + </div> + ); +} + // Directory Page Component function DirectoryPage({ onBack, @@ -306,6 +455,10 @@ navigate({ page: 'directory', dirPath: path || '' }); }, [navigate]); + const navigateToChangeset = useCallback((changesetId: string) => { + navigate({ page: 'changeset', changesetId }); + }, [navigate]); + const handleDirectoryPathChange = useCallback((path: string) => { // Update URL without full navigation const params = new URLSearchParams(); @@ -333,7 +486,7 @@ Home </button> <button - className={`nav-tab ${route.page === 'graph' ? 'active' : ''}`} + className={`nav-tab ${route.page === 'graph' || route.page === 'changeset' ? 'active' : ''}`} onClick={() => navigateToGraph()} > <GraphIcon /> @@ -353,6 +506,7 @@ <LandingPage onNavigateToGraph={() => navigateToGraph()} onNavigateToDirectory={navigateToDirectory} + onNavigateToChangeset={navigateToChangeset} /> )} @@ -361,6 +515,7 @@ onBack={navigateToLanding} initialCommit={route.graphCommit} initialTip={route.graphTip} + onOpenChangeset={navigateToChangeset} /> )} @@ -372,6 +527,14 @@ /> )} + {route.page === 'changeset' && route.changesetId && ( + <ChangesetPage + changesetId={route.changesetId} + onBack={() => navigateToGraph()} + onOpenChangeset={navigateToChangeset} + /> + )} + <Footer /> </div> );