Mercurial
view hg-web/src/components/graph.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 | 3007ef5fc0ed |
| children |
line wrap: on
line source
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'; // Configuration constants for the layout const rowHeight = 40; const colWidth = 20; const nodeRadius = 4.5; // --- Interfaces --- interface Changeset { node: string; date: [number, number]; desc: string; branch: string; bookmarks: string[]; tags: string[]; user: string; phase: string; col: number; row: number; color: number; edges: Array<{ bcolor: string; col: number; color: number; nextcol: number; width: number; }>; parents: string[]; } interface GraphData { node: string; changeset_count: number; changesets: Changeset[]; } interface UseGraphDataOptions { initialCommit?: string | null; graphTop?: string | null; } interface UseGraphDataResult { data: GraphData | null; loading: boolean; error: string | null; loadMore: () => void; hasMore: boolean; tip: string | null; currentCommit: string | null; } // --- Hook Logic --- function useGraphData({ initialCommit = null, graphTop = null }: UseGraphDataOptions = {}): UseGraphDataResult { const [data, setData] = useState<GraphData | null>(null); const [loading, setLoading] = useState(false); const [error, setError] = useState<string | null>(null); const [tip, setTip] = useState<string | null>(graphTop); const [currentCommit, setCurrentCommit] = useState<string | null>(initialCommit); const [hasMore, setHasMore] = useState(true); const fetchData = useCallback(async (commit: string | null, tipNode: string | null, append: boolean = false) => { if (loading) return; setLoading(true); setError(null); try { const url = !commit ? `/api/graph/tip?style=json` : `/api/graph/${commit}?graphtop=${tipNode}&style=json`; const response = await fetch(url); if (!response.ok) throw new Error(`Fetch failed: ${response.status}`); const result: GraphData = await response.json(); setData(prev => { if (append && prev) { const existingNodes = new Set(prev.changesets.map(cs => cs.node)); const newChangesets = result.changesets.filter(cs => !existingNodes.has(cs.node)); // Re-index rows to ensure they increment correctly for the canvas height const startRow = prev.changesets.length; const reindexed = newChangesets.map((cs, idx) => ({ ...cs, row: startRow + idx })); return { ...result, changesets: [...prev.changesets, ...reindexed] }; } return result; }); if (!tip && !append) setTip(result.node); if (result.changesets.length > 0) { const lastNode = result.changesets[result.changesets.length - 1].node; setCurrentCommit(lastNode); setHasMore(result.changesets.length >= 30); } else { setHasMore(false); } } catch (err: any) { setError(err.message); } finally { setLoading(false); } }, [tip, loading]); useEffect(() => { fetchData(initialCommit, graphTop, false); }, [initialCommit, graphTop]); const loadMore = useCallback(() => { if (!loading && hasMore && currentCommit && tip) { fetchData(currentCommit, tip, true); } }, [loading, hasMore, currentCommit, tip, fetchData]); return { data, loading, error, loadMore, hasMore, tip, currentCommit }; } // --- Pencil Rendering Logic --- const drawPencilLine = ( ctx: CanvasRenderingContext2D, x1: number, y1: number, x2: number, y2: number, texture: CanvasPattern | null, // Ensure type safety isCurve: boolean = false ) => { const strokes = 3; ctx.save(); for (let s = 0; s < strokes; s++) { ctx.beginPath(); ctx.strokeStyle = texture; ctx.globalAlpha = 0.2 + (s * 0.2); ctx.lineWidth = 1.5 - (s * 0.2); // Pencil lines are usually thinner // 2. Realistic Jitter: Actually return a random small number const jitter = () => (Math.random() - 0.5) * 1.5; ctx.moveTo(x1 + jitter(), y1 + jitter()); if (isCurve) { const cpY = y1 + (y2 - y1) / 2; ctx.bezierCurveTo( x1 + jitter(), cpY + jitter(), x2 + jitter(), cpY + jitter(), x2 + jitter(), y2 + jitter() ); } else { ctx.lineTo(x2 + jitter(), y2 + jitter()); } ctx.stroke(); } ctx.restore(); } // --- Main Component --- interface GraphProps { data: GraphData | null; loading?: boolean; hasMore?: boolean; onLoadMore?: () => void; onCommitClick?: (node: string) => void; maxRows?: number; } const Graph = ({ data, loading, hasMore, onLoadMore, onCommitClick, maxRows }: GraphProps) => { const canvasRef = useRef<HTMLCanvasElement>(null); const containerRef = useRef<HTMLDivElement>(null); const [assetError, setAssetError] = useState<string | null>(null); const changesets = useMemo(() => maxRows && data?.changesets ? data.changesets.slice(0, maxRows) : data?.changesets || [], [data, maxRows]); useEffect(() => { const canvas = canvasRef.current; if (!canvas || !changesets.length) return; const ctx = canvas.getContext('2d'); if (!ctx) return; let cancelled = false; let pencilPattern: CanvasPattern | null = null; let loadedAssets = 0; const pencilImage = new Image(); const pandaImage = new Image(); const dpr = window.devicePixelRatio || 1; const maxCol = Math.max(...changesets.map(cs => cs.col), 0); const canvasWidth = (maxCol + 2) * colWidth; // Scale for high-DPI screens canvas.width = canvasWidth * dpr; canvas.height = changesets.length * rowHeight * dpr; canvas.style.width = `${canvasWidth}px`; canvas.style.height = `${changesets.length * rowHeight}px`; ctx.scale(dpr, dpr); ctx.clearRect(0, 0, canvasWidth, changesets.length * rowHeight); const getX = (col: number) => (col + 1) * colWidth; const getY = (row: number) => (row * rowHeight) + (rowHeight / 2); const renderCanvas = () => { if (!pencilPattern) return; // Don't draw if the pattern isn't ready // Pass 1: Draw Connecting Edges changesets.forEach((cs, i) => { if (!cs.edges) return; cs.edges.forEach(edge => { const sX = getX(edge.col), sY = getY(i); const eX = getX(edge.nextcol), eY = getY(i + 1); drawPencilLine(ctx, sX, sY, eX, eY, pencilPattern, edge.col !== edge.nextcol); }); }); // Pass 2: Draw Commit Nodes changesets.forEach((cs, i) => { const x = getX(cs.col), y = getY(i); ctx.drawImage(pandaImage, x-10, y-10, 20, 20); }); }; const handleAssetLoad = () => { loadedAssets++; if (loadedAssets !== 2 || cancelled) return; pencilPattern = ctx.createPattern(pencilImage, "repeat"); renderCanvas(); }; const handleAssetError = () => { if (!cancelled) setAssetError('Unable to load the graph artwork.'); }; setAssetError(null); pencilImage.onload = handleAssetLoad; pencilImage.onerror = handleAssetError; pandaImage.onload = handleAssetLoad; pandaImage.onerror = handleAssetError; pencilImage.src = "/pencil_lines.png"; pandaImage.src = "/panda.png"; return () => { cancelled = true; pencilImage.onload = null; pencilImage.onerror = null; pandaImage.onload = null; pandaImage.onerror = null; }; }, [changesets]); // Handle Infinite Scroll via Intersection Observer useEffect(() => { if (!onLoadMore || !hasMore) return; const observer = new IntersectionObserver((entries) => { if (entries[0].isIntersecting && !loading) { onLoadMore(); } }, { threshold: 0.1 }); const sentinel = document.getElementById('infinite-scroll-sentinel'); if (sentinel) observer.observe(sentinel); return () => observer.disconnect(); }, [onLoadMore, hasMore, loading]); return ( <div className="graph-container"> {assetError && <div className="error-message">{assetError}</div>} <div ref={containerRef} className="graph-wrapper" > <div className="graph-canvas-column"> <canvas ref={canvasRef} style={{ display: 'block' }} /> </div> <div className="graph-details-column"> {changesets.map((cs) => ( <button type="button" key={cs.node} className="graph-row" onClick={() => onCommitClick?.(cs.node)} aria-label={`Open changeset ${cs.node.substring(0, 12)}: ${cs.desc}`} > <span className="graph-row-meta"> <span className="graph-hash">{cs.node.substring(0, 12)}</span> <span className="graph-user">{cs.user.split(' <')[0]}</span> {cs.branch && <span className="graph-branch">{cs.branch}</span>} </span> <span className="graph-desc">{cs.desc}</span> </button> ))} <div id="infinite-scroll-sentinel" style={{ height: '50px' }} /> </div> </div> {loading && <div className="graph-loading-row">Loading repository history...</div>} </div> ); }; export { Graph, useGraphData }; export type { GraphData, Changeset, UseGraphDataResult };