Mercurial
annotate 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 |
| rev | line source |
|---|---|
| 193 | 1 import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'; |
| 2 | |
| 3 // Configuration constants for the layout | |
| 4 const rowHeight = 40; | |
| 5 const colWidth = 20; | |
| 6 const nodeRadius = 4.5; | |
| 7 | |
| 8 // --- Interfaces --- | |
| 9 | |
| 10 interface Changeset { | |
| 11 node: string; | |
| 12 date: [number, number]; | |
| 13 desc: string; | |
| 14 branch: string; | |
| 15 bookmarks: string[]; | |
| 16 tags: string[]; | |
| 17 user: string; | |
| 18 phase: string; | |
| 19 col: number; | |
| 20 row: number; | |
| 21 color: number; | |
| 22 edges: Array<{ | |
| 23 bcolor: string; | |
| 24 col: number; | |
| 25 color: number; | |
| 26 nextcol: number; | |
| 27 width: number; | |
| 28 }>; | |
| 29 parents: string[]; | |
| 30 } | |
| 31 | |
| 32 interface GraphData { | |
| 33 node: string; | |
| 34 changeset_count: number; | |
| 35 changesets: Changeset[]; | |
| 36 } | |
| 37 | |
| 38 interface UseGraphDataOptions { | |
| 39 initialCommit?: string | null; | |
| 40 graphTop?: string | null; | |
| 41 } | |
| 42 | |
| 43 interface UseGraphDataResult { | |
| 44 data: GraphData | null; | |
| 45 loading: boolean; | |
| 46 error: string | null; | |
| 47 loadMore: () => void; | |
| 48 hasMore: boolean; | |
| 49 tip: string | null; | |
| 50 currentCommit: string | null; | |
| 51 } | |
| 52 | |
| 53 // --- Hook Logic --- | |
| 54 | |
| 55 function useGraphData({ initialCommit = null, graphTop = null }: UseGraphDataOptions = {}): UseGraphDataResult { | |
| 56 const [data, setData] = useState<GraphData | null>(null); | |
| 57 const [loading, setLoading] = useState(false); | |
| 58 const [error, setError] = useState<string | null>(null); | |
| 59 const [tip, setTip] = useState<string | null>(graphTop); | |
| 60 const [currentCommit, setCurrentCommit] = useState<string | null>(initialCommit); | |
| 61 const [hasMore, setHasMore] = useState(true); | |
| 62 | |
| 63 const fetchData = useCallback(async (commit: string | null, tipNode: string | null, append: boolean = false) => { | |
| 64 if (loading) return; | |
| 65 setLoading(true); | |
| 66 setError(null); | |
| 67 | |
| 68 try { | |
| 69 const url = !commit | |
| 70 ? `/api/graph/tip?style=json` | |
| 71 : `/api/graph/${commit}?graphtop=${tipNode}&style=json`; | |
| 72 | |
| 73 const response = await fetch(url); | |
| 74 if (!response.ok) throw new Error(`Fetch failed: ${response.status}`); | |
| 75 | |
| 76 const result: GraphData = await response.json(); | |
| 77 | |
| 78 setData(prev => { | |
| 79 if (append && prev) { | |
| 80 const existingNodes = new Set(prev.changesets.map(cs => cs.node)); | |
| 81 const newChangesets = result.changesets.filter(cs => !existingNodes.has(cs.node)); | |
| 82 | |
| 83 // Re-index rows to ensure they increment correctly for the canvas height | |
| 84 const startRow = prev.changesets.length; | |
| 85 const reindexed = newChangesets.map((cs, idx) => ({ | |
| 86 ...cs, | |
| 87 row: startRow + idx | |
| 88 })); | |
| 89 | |
| 90 return { | |
| 91 ...result, | |
| 92 changesets: [...prev.changesets, ...reindexed] | |
| 93 }; | |
| 94 } | |
| 95 return result; | |
| 96 }); | |
| 97 | |
| 98 if (!tip && !append) setTip(result.node); | |
| 99 | |
| 100 if (result.changesets.length > 0) { | |
| 101 const lastNode = result.changesets[result.changesets.length - 1].node; | |
| 102 setCurrentCommit(lastNode); | |
| 103 setHasMore(result.changesets.length >= 30); | |
| 104 } else { | |
| 105 setHasMore(false); | |
| 106 } | |
| 107 } catch (err: any) { | |
| 108 setError(err.message); | |
| 109 } finally { | |
| 110 setLoading(false); | |
| 111 } | |
| 112 }, [tip, loading]); | |
| 113 | |
| 114 useEffect(() => { | |
| 115 fetchData(initialCommit, graphTop, false); | |
| 116 }, [initialCommit, graphTop]); | |
| 117 | |
| 118 const loadMore = useCallback(() => { | |
| 119 if (!loading && hasMore && currentCommit && tip) { | |
| 120 fetchData(currentCommit, tip, true); | |
| 121 } | |
| 122 }, [loading, hasMore, currentCommit, tip, fetchData]); | |
| 123 | |
| 124 return { data, loading, error, loadMore, hasMore, tip, currentCommit }; | |
| 125 } | |
| 126 | |
| 127 // --- Pencil Rendering Logic --- | |
| 128 | |
| 129 const drawPencilLine = ( | |
| 130 ctx: CanvasRenderingContext2D, | |
| 131 x1: number, y1: number, | |
| 132 x2: number, y2: number, | |
| 133 texture: CanvasPattern | null, // Ensure type safety | |
| 134 isCurve: boolean = false | |
| 135 ) => { | |
| 136 const strokes = 3; | |
| 137 ctx.save(); | |
| 138 | |
| 139 for (let s = 0; s < strokes; s++) { | |
| 140 ctx.beginPath(); | |
| 141 ctx.strokeStyle = texture; | |
| 142 ctx.globalAlpha = 0.2 + (s * 0.2); | |
| 143 ctx.lineWidth = 1.5 - (s * 0.2); // Pencil lines are usually thinner | |
| 144 | |
| 145 // 2. Realistic Jitter: Actually return a random small number | |
| 146 const jitter = () => (Math.random() - 0.5) * 1.5; | |
| 147 | |
| 148 ctx.moveTo(x1 + jitter(), y1 + jitter()); | |
| 149 | |
| 150 if (isCurve) { | |
| 151 const cpY = y1 + (y2 - y1) / 2; | |
| 152 ctx.bezierCurveTo( | |
| 153 x1 + jitter(), cpY + jitter(), | |
| 154 x2 + jitter(), cpY + jitter(), | |
| 155 x2 + jitter(), y2 + jitter() | |
| 156 ); | |
| 157 } else { | |
| 158 ctx.lineTo(x2 + jitter(), y2 + jitter()); | |
| 159 } | |
| 160 | |
| 161 ctx.stroke(); | |
| 162 } | |
| 163 ctx.restore(); | |
| 164 } | |
| 165 | |
| 166 // --- Main Component --- | |
| 167 | |
| 168 interface GraphProps { | |
| 169 data: GraphData | null; | |
| 170 loading?: boolean; | |
| 171 hasMore?: boolean; | |
| 172 onLoadMore?: () => void; | |
| 173 onCommitClick?: (node: string) => void; | |
| 174 maxRows?: number; | |
| 175 } | |
| 176 | |
| 177 const Graph = ({ data, loading, hasMore, onLoadMore, onCommitClick, maxRows }: GraphProps) => { | |
| 178 const canvasRef = useRef<HTMLCanvasElement>(null); | |
| 179 const containerRef = useRef<HTMLDivElement>(null); | |
|
221
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
180 const [assetError, setAssetError] = useState<string | null>(null); |
| 193 | 181 |
| 182 const changesets = useMemo(() => | |
| 183 maxRows && data?.changesets ? data.changesets.slice(0, maxRows) : data?.changesets || [], [data, maxRows]); | |
| 184 | |
| 185 useEffect(() => { | |
| 186 const canvas = canvasRef.current; | |
| 187 if (!canvas || !changesets.length) return; | |
| 188 | |
| 189 const ctx = canvas.getContext('2d'); | |
| 190 if (!ctx) return; | |
| 191 | |
|
221
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
192 let cancelled = false; |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
193 let pencilPattern: CanvasPattern | null = null; |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
194 let loadedAssets = 0; |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
195 const pencilImage = new Image(); |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
196 const pandaImage = new Image(); |
| 193 | 197 const dpr = window.devicePixelRatio || 1; |
| 198 const maxCol = Math.max(...changesets.map(cs => cs.col), 0); | |
| 199 const canvasWidth = (maxCol + 2) * colWidth; | |
| 200 | |
| 201 // Scale for high-DPI screens | |
| 202 canvas.width = canvasWidth * dpr; | |
| 203 canvas.height = changesets.length * rowHeight * dpr; | |
| 204 canvas.style.width = `${canvasWidth}px`; | |
| 205 canvas.style.height = `${changesets.length * rowHeight}px`; | |
| 206 ctx.scale(dpr, dpr); | |
| 207 ctx.clearRect(0, 0, canvasWidth, changesets.length * rowHeight); | |
| 208 | |
| 209 const getX = (col: number) => (col + 1) * colWidth; | |
| 210 const getY = (row: number) => (row * rowHeight) + (rowHeight / 2); | |
| 211 | |
| 212 const renderCanvas = () => { | |
| 213 if (!pencilPattern) return; // Don't draw if the pattern isn't ready | |
| 214 | |
| 215 // Pass 1: Draw Connecting Edges | |
| 216 changesets.forEach((cs, i) => { | |
| 217 if (!cs.edges) return; | |
| 218 cs.edges.forEach(edge => { | |
| 219 const sX = getX(edge.col), sY = getY(i); | |
| 220 const eX = getX(edge.nextcol), eY = getY(i + 1); | |
| 221 | |
| 222 drawPencilLine(ctx, sX, sY, eX, eY, pencilPattern, edge.col !== edge.nextcol); | |
| 223 }); | |
| 224 }); | |
| 225 | |
| 226 // Pass 2: Draw Commit Nodes | |
| 227 changesets.forEach((cs, i) => { | |
| 228 const x = getX(cs.col), y = getY(i); | |
|
221
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
229 ctx.drawImage(pandaImage, x-10, y-10, 20, 20); |
| 193 | 230 }); |
| 231 }; | |
| 232 | |
|
221
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
233 const handleAssetLoad = () => { |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
234 loadedAssets++; |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
235 if (loadedAssets !== 2 || cancelled) return; |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
236 pencilPattern = ctx.createPattern(pencilImage, "repeat"); |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
237 renderCanvas(); |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
238 }; |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
239 |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
240 const handleAssetError = () => { |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
241 if (!cancelled) setAssetError('Unable to load the graph artwork.'); |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
242 }; |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
243 |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
244 setAssetError(null); |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
245 pencilImage.onload = handleAssetLoad; |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
246 pencilImage.onerror = handleAssetError; |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
247 pandaImage.onload = handleAssetLoad; |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
248 pandaImage.onerror = handleAssetError; |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
249 pencilImage.src = "/pencil_lines.png"; |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
250 pandaImage.src = "/panda.png"; |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
251 |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
252 return () => { |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
253 cancelled = true; |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
254 pencilImage.onload = null; |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
255 pencilImage.onerror = null; |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
256 pandaImage.onload = null; |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
257 pandaImage.onerror = null; |
| 193 | 258 }; |
| 259 }, [changesets]); | |
| 260 | |
| 261 // Handle Infinite Scroll via Intersection Observer | |
| 262 useEffect(() => { | |
| 263 if (!onLoadMore || !hasMore) return; | |
| 264 const observer = new IntersectionObserver((entries) => { | |
| 265 if (entries[0].isIntersecting && !loading) { | |
| 266 onLoadMore(); | |
| 267 } | |
| 268 }, { threshold: 0.1 }); | |
| 269 | |
| 270 const sentinel = document.getElementById('infinite-scroll-sentinel'); | |
| 271 if (sentinel) observer.observe(sentinel); | |
| 272 return () => observer.disconnect(); | |
| 273 }, [onLoadMore, hasMore, loading]); | |
| 274 | |
| 275 return ( | |
|
224
3007ef5fc0ed
[hg-web] Simplify visuals and preview static files
MrJuneJune <me@mrjunejune.com>
parents:
221
diff
changeset
|
276 <div className="graph-container"> |
|
221
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
277 {assetError && <div className="error-message">{assetError}</div>} |
| 193 | 278 <div |
| 279 ref={containerRef} | |
|
221
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
280 className="graph-wrapper" |
| 193 | 281 > |
|
221
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
282 <div className="graph-canvas-column"> |
| 193 | 283 <canvas ref={canvasRef} style={{ display: 'block' }} /> |
| 284 </div> | |
| 285 | |
|
221
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
286 <div className="graph-details-column"> |
| 193 | 287 {changesets.map((cs) => ( |
|
221
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
288 <button |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
289 type="button" |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
290 key={cs.node} |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
291 className="graph-row" |
| 193 | 292 onClick={() => onCommitClick?.(cs.node)} |
|
221
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
293 aria-label={`Open changeset ${cs.node.substring(0, 12)}: ${cs.desc}`} |
| 193 | 294 > |
|
221
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
295 <span className="graph-row-meta"> |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
296 <span className="graph-hash">{cs.node.substring(0, 12)}</span> |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
297 <span className="graph-user">{cs.user.split(' <')[0]}</span> |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
298 {cs.branch && <span className="graph-branch">{cs.branch}</span>} |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
299 </span> |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
300 <span className="graph-desc">{cs.desc}</span> |
|
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
301 </button> |
| 193 | 302 ))} |
| 303 <div id="infinite-scroll-sentinel" style={{ height: '50px' }} /> | |
| 304 </div> | |
| 305 </div> | |
| 306 | |
|
221
ce7f4400c2de
[hg-web] Harden forge and add changeset UI
MrJuneJune <me@mrjunejune.com>
parents:
194
diff
changeset
|
307 {loading && <div className="graph-loading-row">Loading repository history...</div>} |
| 193 | 308 </div> |
| 309 ); | |
| 310 }; | |
| 311 | |
| 312 export { Graph, useGraphData }; | |
| 313 export type { GraphData, Changeset, UseGraphDataResult }; |