Mercurial
view hg-web/src/components/app.tsx @ 279:b3b547563ec7
Add Google connector service and agent wiki
Implement the C/Seobeo Google Drive and Gmail connector with encrypted OAuth storage, Zenbu authentication, browser testing, AI tool discovery, chunked HTTP decoding, and Bazel coverage. Consolidate repository guidance into progressive wiki documentation and enforce arena-first allocation for new first-party C code.
Co-authored-by: Copilot <[email protected]>
Copilot-Session: 84c338fd-0939-4bb3-b7f3-1062eb213e5d
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Mon, 17 Aug 2026 22:22:36 -0700 |
| parents | 70de0c80d093 |
| children |
line wrap: on
line source
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 };