Mercurial
diff hg-web/src/components/app.tsx @ 231:09a96dcb2b4c hg-web
[merge] Join existing hg-web branch head
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Sun, 02 Aug 2026 16:50:48 -0700 |
| parents | 70de0c80d093 |
| children |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/hg-web/src/components/app.tsx Sun Aug 02 16:50:48 2026 -0700 @@ -0,0 +1,725 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { Graph, useGraphData } from "hg-web/src/components/graph"; +import { DirectoryBrowser } from "hg-web/src/components/directory-browser"; +import { Header } from "hg-web/src/components/header"; +import { Footer } from "hg-web/src/components/footer"; +import { ThemeProvider, useTheme } from "hg-web/src/components/theme"; + +type Page = 'landing' | 'graph' | 'directory' | 'changeset'; + +type RouteState = { + page: Page; + graphCommit?: string; + graphTip?: string; + dirPath?: string; + changesetId?: string; + returnDepth?: number; +} + +type ChangesetDetail = { + node: string; + date: [number, number]; + desc: string; + branch: string; + bookmarks: string[]; + tags: string[]; + user: string; + parents: string[]; + files: Array<{ + file: string; + status: string; + }>; + diff: Array<{ + blockno: number; + lines: Array<{ t: string; n: number; l: string }>; + }>; +}; + +type DiffLine = ChangesetDetail['diff'][number]['lines'][number]; + +type DiffCell = { + lineNumber: number | null; + text: string; + kind: 'context' | 'add' | 'remove' | 'meta'; +}; + +type SideBySideRow = { + left?: DiffCell; + right?: DiffCell; + range?: string; +}; + +function trimDiffLine(line: string): string { + return line.endsWith('\n') ? line.slice(0, -1) : line; +} + +function contentDiffLine(line: DiffLine): string { + const text = trimDiffLine(line.l); + if ((line.t === '+' || line.t === '-' || line.t === '' || line.t === ' ') && + text.startsWith(line.t || ' ')) { + return text.slice(1); + } + return text; +} + +function buildSideBySideRows(lines: DiffLine[]): SideBySideRow[] { + const rows: SideBySideRow[] = []; + let removals: DiffCell[] = []; + let additions: DiffCell[] = []; + let oldLine: number | null = null; + let newLine: number | null = null; + let inHunk = false; + + const flushChanges = () => { + const count = Math.max(removals.length, additions.length); + for (let index = 0; index < count; index++) { + rows.push({ left: removals[index], right: additions[index] }); + } + removals = []; + additions = []; + }; + + for (const line of lines) { + if (line.t === '@') { + flushChanges(); + const range = trimDiffLine(line.l); + const match = range.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/); + oldLine = match ? Number(match[1]) : null; + newLine = match ? Number(match[2]) : null; + inHunk = true; + rows.push({ range }); + continue; + } + + if (!inHunk && (line.t === '-' || line.t === '+')) { + const cell: DiffCell = { + lineNumber: null, + text: trimDiffLine(line.l), + kind: 'meta', + }; + if (line.t === '-') removals.push(cell); + else additions.push(cell); + continue; + } + + if (line.t === '-') { + removals.push({ + lineNumber: oldLine, + text: contentDiffLine(line), + kind: 'remove', + }); + if (oldLine !== null) oldLine++; + continue; + } + + if (line.t === '+') { + additions.push({ + lineNumber: newLine, + text: contentDiffLine(line), + kind: 'add', + }); + if (newLine !== null) newLine++; + continue; + } + + flushChanges(); + const text = contentDiffLine(line); + rows.push({ + left: { lineNumber: oldLine, text, kind: 'context' }, + right: { lineNumber: newLine, text, kind: 'context' }, + }); + if (oldLine !== null) oldLine++; + if (newLine !== null) newLine++; + } + + flushChanges(); + return rows; +} + +function diffBlockFilename( + block: ChangesetDetail['diff'][number], + fallback?: string, +): string { + if (fallback) return fallback; + const newFileHeader = block.lines.find( + line => line.t === '+' && line.l.startsWith('+++ '), + ); + if (!newFileHeader) return `Diff block ${block.blockno}`; + return trimDiffLine(newFileHeader.l).replace(/^\+\+\+ (?:b\/)?/, '').split('\t')[0]; +} + +function SideBySideDiff({ + block, + filename, +}: { + block: ChangesetDetail['diff'][number]; + filename: string; +}) { + const rows = buildSideBySideRows(block.lines); + return ( + <div className="side-by-side-diff"> + <div className="diff-file-header">{filename}</div> + <div className="diff-column-headings"> + <span>Before</span> + <span>After</span> + </div> + <div className="diff-grid"> + {rows.map((row, index) => ( + row.range ? ( + <div className="diff-range-row" key={`range-${index}`}>{row.range}</div> + ) : ( + <React.Fragment key={`row-${index}`}> + <span className={`diff-side-number diff-${row.left?.kind || 'empty'}`}> + {row.left?.lineNumber ?? ''} + </span> + <code className={`diff-side-code diff-left diff-${row.left?.kind || 'empty'}`}> + {row.left?.text ?? ''} + </code> + <span className={`diff-side-number diff-column-divider diff-${row.right?.kind || 'empty'}`}> + {row.right?.lineNumber ?? ''} + </span> + <code className={`diff-side-code diff-right diff-${row.right?.kind || 'empty'}`}> + {row.right?.text ?? ''} + </code> + </React.Fragment> + ) + ))} + </div> + </div> + ); +} + +// Icons +const ICONS = { + folder: "/icons/folder.png", +}; + +const GraphIcon = () => ( + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <circle cx="6" cy="6" r="3"/> + <circle cx="6" cy="18" r="3"/> + <circle cx="18" cy="12" r="3"/> + <line x1="6" y1="9" x2="6" y2="15"/> + <path d="M8.5 7.5L15.5 11"/> + </svg> +); + +const FolderIcon = () => ( + <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" stroke="none"> + <path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/> + </svg> +); + +const API_BASE = '/api/repo'; + +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 { + page: 'graph', + graphCommit: params.get('commit') || undefined, + graphTip: params.get('tip') || undefined, + }; + } + + if (pathname.startsWith('/directory') || params.has('path')) { + return { + page: 'directory', + dirPath: params.get('path') || '', + }; + } + + return { page: 'landing' }; +} + +function buildUrl(state: RouteState): string { + const params = new URLSearchParams(); + + switch (state.page) { + case 'graph': + if (state.graphCommit) params.set('commit', state.graphCommit); + if (state.graphTip) params.set('tip', state.graphTip); + return `/graph${params.toString() ? '?' + params.toString() : ''}`; + 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 '/'; + } +} + +function isRouteState(value: unknown): value is RouteState { + if (!value || typeof value !== 'object' || !('page' in value)) return false; + return ['landing', 'graph', 'directory', 'changeset'].includes( + String((value as { page: unknown }).page), + ); +} + +// Landing Page Component +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[]>([]); + const [dirLoading, setDirLoading] = useState(true); + + const { data: graphData, loading: graphLoading } = useGraphData(); + + useEffect(() => { + fetch(`${API_BASE}/list`) + .then(r => r.json()) + .then(data => { + setDirectories(data.directories || []); + setFiles(data.files || []); + setDirLoading(false); + }) + .catch(() => setDirLoading(false)); + }, []); + + const previewItems = [ + ...directories.slice(0, 6), + ...files.slice(0, Math.max(0, 6 - directories.length)) + ].slice(0, 6); + + return ( + <div className="landing-grid"> + {/* Graph Preview */} + <div className="landing-section"> + <div className="landing-section-header"> + <span className="landing-section-title"> + <GraphIcon /> + Recent Commits + </span> + <a href="/graph" className="landing-section-link" onClick={(e) => { + e.preventDefault(); + onNavigateToGraph(); + }}> + View all + </a> + </div> + <div className="landing-section-content"> + {graphLoading ? ( + <div className="loading-state">Loading commits...</div> + ) : graphData ? ( + <Graph + data={graphData} + maxRows={8} + onCommitClick={onNavigateToChangeset} + /> + ) : ( + <div className="empty-state">Failed to load commits</div> + )} + </div> + </div> + + {/* Directory Preview */} + <div className="landing-section"> + <div className="landing-section-header"> + <span className="landing-section-title"> + <FolderIcon /> + Repository Files + </span> + <a href="/directory" className="landing-section-link" onClick={(e) => { + e.preventDefault(); + onNavigateToDirectory(); + }}> + Browse all + </a> + </div> + <div className="landing-section-content"> + {dirLoading ? ( + <div className="loading-state">Loading files...</div> + ) : previewItems.length > 0 ? ( + previewItems.map((item) => ( + <div + key={item.abspath} + className="dir-item" + onClick={() => onNavigateToDirectory(item.abspath)} + > + <span className="dir-item-icon"> + <img + className="icon-invert" + src={directories.includes(item) ? ICONS.folder : "/icons/file.svg"} + alt="" + /> + </span> + <span className="dir-item-name">{item.basename}</span> + </div> + )) + ) : ( + <div className="empty-state">No files found</div> + )} + </div> + </div> + </div> + ); +} + +// Graph Page Component +function GraphPage({ + 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, + graphTop: initialTip || null, + }); + + useEffect(() => { + if (tip && currentCommit) { + const params = new URLSearchParams(); + params.set('commit', currentCommit); + params.set('tip', tip); + const newUrl = `/graph?${params.toString()}`; + window.history.replaceState({ page: 'graph', graphCommit: currentCommit, graphTip: tip }, '', newUrl); + } + }, [currentCommit, tip]); + + return ( + <div> + <div className="page-header"> + <button className="back-button" onClick={onBack} aria-label="Back"> + ← Back + </button> + <span className="page-title">Commit Graph</span> + </div> + + {tip && ( + <div className="graph-params"> + <span className="graph-param"> + <span className="graph-param-label">Tip:</span> + <span className="graph-param-value">{tip.substring(0, 12)}</span> + </span> + {currentCommit && currentCommit !== tip && ( + <span className="graph-param"> + <span className="graph-param-label">Current:</span> + <span className="graph-param-value">{currentCommit.substring(0, 12)}</span> + </span> + )} + </div> + )} + + {error && ( + <div className="error-message">Error: {error}</div> + )} + + <Graph + data={data} + loading={loading} + hasMore={hasMore} + onLoadMore={loadMore} + 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} aria-label="Back"> + ← Back + </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}> + <span className={`changeset-file-status status-${file.status}`}> + {file.status} + </span> + {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, index) => ( + <SideBySideDiff + key={block.blockno} + block={block} + filename={diffBlockFilename(block, changeset.files[index]?.file)} + /> + ))} + </section> + </article> + )} + </div> + ); +} + +// Directory Page Component +function DirectoryPage({ + onBack, + initialPath, + onPathChange, +}: { + onBack: () => void; + initialPath?: string; + onPathChange: (path: string) => void; +}) { + return ( + <div> + <div className="page-header"> + <button className="back-button" onClick={onBack} aria-label="Back"> + ← Back + </button> + <span className="page-title">Repository Files</span> + </div> + + <DirectoryBrowser + initialPath={initialPath} + onPathChange={onPathChange} + /> + </div> + ); +} + +// Main App Content (uses theme context) +function AppContent() { + const [route, setRoute] = useState<RouteState>(parseRoute); + const { isDark, toggleTheme } = useTheme(); + + // Handle browser back/forward + useEffect(() => { + const handlePopState = (event: PopStateEvent) => { + setRoute(isRouteState(event.state) ? event.state : parseRoute()); + }; + window.addEventListener('popstate', handlePopState); + return () => window.removeEventListener('popstate', handlePopState); + }, []); + + const navigate = useCallback((newRoute: RouteState) => { + const url = buildUrl(newRoute); + window.history.pushState(newRoute, '', url); + setRoute(newRoute); + }, []); + + const navigateToLanding = useCallback(() => { + navigate({ page: 'landing' }); + }, [navigate]); + + const navigateToGraph = useCallback((commit?: string, tip?: string) => { + navigate({ page: 'graph', graphCommit: commit, graphTip: tip }); + }, [navigate]); + + const navigateToDirectory = useCallback((path?: string) => { + navigate({ page: 'directory', dirPath: path || '' }); + }, [navigate]); + + const navigateToChangeset = useCallback((changesetId: string) => { + const returnDepth = route.page === 'changeset' + ? (route.returnDepth ?? 0) + 1 + : 1; + navigate({ page: 'changeset', changesetId, returnDepth }); + }, [navigate, route.page, route.returnDepth]); + + const navigateBackFromChangeset = useCallback(() => { + if (route.returnDepth !== undefined && route.returnDepth > 0) { + window.history.go(-route.returnDepth); + return; + } + navigateToGraph(); + }, [navigateToGraph, route.returnDepth]); + + const handleDirectoryPathChange = useCallback((path: string) => { + // Update URL without full navigation + const params = new URLSearchParams(); + if (path) params.set('path', path); + const newUrl = `/directory${params.toString() ? '?' + params.toString() : ''}`; + window.history.replaceState({ page: 'directory', dirPath: path }, '', newUrl); + setRoute(prev => ({ ...prev, dirPath: path })); + }, []); + + return ( + <div className="app-container"> + <Header + title="Zenbu Repository" + showThemeToggle={true} + isDark={isDark} + onToggleTheme={toggleTheme} + /> + + {/* Navigation Tabs */} + <div className="nav-tabs"> + <button + className={`nav-tab ${route.page === 'landing' ? 'active' : ''}`} + onClick={navigateToLanding} + > + Home + </button> + <button + className={`nav-tab ${route.page === 'graph' || route.page === 'changeset' ? 'active' : ''}`} + onClick={() => navigateToGraph()} + > + <GraphIcon /> + Graph + </button> + <button + className={`nav-tab ${route.page === 'directory' ? 'active' : ''}`} + onClick={() => navigateToDirectory()} + > + <FolderIcon /> + Files + </button> + </div> + + {/* Page Content */} + {route.page === 'landing' && ( + <LandingPage + onNavigateToGraph={() => navigateToGraph()} + onNavigateToDirectory={navigateToDirectory} + onNavigateToChangeset={navigateToChangeset} + /> + )} + + {route.page === 'graph' && ( + <GraphPage + onBack={navigateToLanding} + initialCommit={route.graphCommit} + initialTip={route.graphTip} + onOpenChangeset={navigateToChangeset} + /> + )} + + {route.page === 'directory' && ( + <DirectoryPage + onBack={navigateToLanding} + initialPath={route.dirPath} + onPathChange={handleDirectoryPathChange} + /> + )} + + {route.page === 'changeset' && route.changesetId && ( + <ChangesetPage + changesetId={route.changesetId} + onBack={navigateBackFromChangeset} + onOpenChangeset={navigateToChangeset} + /> + )} + + <Footer /> + </div> + ); +} + +// App wrapper with ThemeProvider +function App() { + return ( + <ThemeProvider> + <AppContent /> + </ThemeProvider> + ); +} + +export { App };