Mercurial
comparison hg-web/src/components/graph.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 | fb28063dc490 |
| children | 3007ef5fc0ed |
comparison
equal
deleted
inserted
replaced
| 219:8c9bb0b0759e | 221:ce7f4400c2de |
|---|---|
| 175 } | 175 } |
| 176 | 176 |
| 177 const Graph = ({ data, loading, hasMore, onLoadMore, onCommitClick, maxRows }: GraphProps) => { | 177 const Graph = ({ data, loading, hasMore, onLoadMore, onCommitClick, maxRows }: GraphProps) => { |
| 178 const canvasRef = useRef<HTMLCanvasElement>(null); | 178 const canvasRef = useRef<HTMLCanvasElement>(null); |
| 179 const containerRef = useRef<HTMLDivElement>(null); | 179 const containerRef = useRef<HTMLDivElement>(null); |
| 180 const [assetError, setAssetError] = useState<string | null>(null); | |
| 180 | 181 |
| 181 const changesets = useMemo(() => | 182 const changesets = useMemo(() => |
| 182 maxRows && data?.changesets ? data.changesets.slice(0, maxRows) : data?.changesets || [], [data, maxRows]); | 183 maxRows && data?.changesets ? data.changesets.slice(0, maxRows) : data?.changesets || [], [data, maxRows]); |
| 183 | |
| 184 let pencilPattern; | |
| 185 const img = new Image(); | |
| 186 img.src = "http://localhost:6970/pencil_lines.png"; | |
| 187 | |
| 188 const pandaImg = new Image(); | |
| 189 pandaImg.src = "http://localhost:6970/panda.png"; | |
| 190 | 184 |
| 191 useEffect(() => { | 185 useEffect(() => { |
| 192 const canvas = canvasRef.current; | 186 const canvas = canvasRef.current; |
| 193 if (!canvas || !changesets.length) return; | 187 if (!canvas || !changesets.length) return; |
| 194 | 188 |
| 195 const ctx = canvas.getContext('2d'); | 189 const ctx = canvas.getContext('2d'); |
| 196 if (!ctx) return; | 190 if (!ctx) return; |
| 197 | 191 |
| 198 // Grab colors from CSS variables or defaults | 192 let cancelled = false; |
| 199 const getColors = () => { | 193 let pencilPattern: CanvasPattern | null = null; |
| 200 const s = getComputedStyle(document.documentElement); | 194 let loadedAssets = 0; |
| 201 return [ | 195 const pencilImage = new Image(); |
| 202 s.getPropertyValue('--graph-1').trim() || '#4dabf7', | 196 const pandaImage = new Image(); |
| 203 s.getPropertyValue('--graph-2').trim() || '#63e6be', | |
| 204 s.getPropertyValue('--graph-3').trim() || '#ffbc42', | |
| 205 s.getPropertyValue('--graph-4').trim() || '#b197fc', | |
| 206 s.getPropertyValue('--graph-5').trim() || '#ff8787', | |
| 207 s.getPropertyValue('--graph-6').trim() || '#f06595', | |
| 208 ]; | |
| 209 }; | |
| 210 | |
| 211 const colors = getColors(); | |
| 212 const dpr = window.devicePixelRatio || 1; | 197 const dpr = window.devicePixelRatio || 1; |
| 213 const maxCol = Math.max(...changesets.map(cs => cs.col), 0); | 198 const maxCol = Math.max(...changesets.map(cs => cs.col), 0); |
| 214 const canvasWidth = (maxCol + 2) * colWidth; | 199 const canvasWidth = (maxCol + 2) * colWidth; |
| 215 | 200 |
| 216 // Scale for high-DPI screens | 201 // Scale for high-DPI screens |
| 239 }); | 224 }); |
| 240 | 225 |
| 241 // Pass 2: Draw Commit Nodes | 226 // Pass 2: Draw Commit Nodes |
| 242 changesets.forEach((cs, i) => { | 227 changesets.forEach((cs, i) => { |
| 243 const x = getX(cs.col), y = getY(i); | 228 const x = getX(cs.col), y = getY(i); |
| 244 ctx.drawImage(pandaImg, x-10, y-10, 20, 20); | 229 ctx.drawImage(pandaImage, x-10, y-10, 20, 20); |
| 245 }); | 230 }); |
| 246 }; | 231 }; |
| 247 | 232 |
| 248 img.onload = () => { | 233 const handleAssetLoad = () => { |
| 249 pencilPattern = ctx.createPattern(img, "repeat")!; | 234 loadedAssets++; |
| 250 renderCanvas(); | 235 if (loadedAssets !== 2 || cancelled) return; |
| 236 pencilPattern = ctx.createPattern(pencilImage, "repeat"); | |
| 237 renderCanvas(); | |
| 238 }; | |
| 239 | |
| 240 const handleAssetError = () => { | |
| 241 if (!cancelled) setAssetError('Unable to load the graph artwork.'); | |
| 242 }; | |
| 243 | |
| 244 setAssetError(null); | |
| 245 pencilImage.onload = handleAssetLoad; | |
| 246 pencilImage.onerror = handleAssetError; | |
| 247 pandaImage.onload = handleAssetLoad; | |
| 248 pandaImage.onerror = handleAssetError; | |
| 249 pencilImage.src = "/pencil_lines.png"; | |
| 250 pandaImage.src = "/panda.png"; | |
| 251 | |
| 252 return () => { | |
| 253 cancelled = true; | |
| 254 pencilImage.onload = null; | |
| 255 pencilImage.onerror = null; | |
| 256 pandaImage.onload = null; | |
| 257 pandaImage.onerror = null; | |
| 251 }; | 258 }; |
| 252 }, [changesets]); | 259 }, [changesets]); |
| 253 | 260 |
| 254 // Handle Infinite Scroll via Intersection Observer | 261 // Handle Infinite Scroll via Intersection Observer |
| 255 useEffect(() => { | 262 useEffect(() => { |
| 264 if (sentinel) observer.observe(sentinel); | 271 if (sentinel) observer.observe(sentinel); |
| 265 return () => observer.disconnect(); | 272 return () => observer.disconnect(); |
| 266 }, [onLoadMore, hasMore, loading]); | 273 }, [onLoadMore, hasMore, loading]); |
| 267 | 274 |
| 268 return ( | 275 return ( |
| 269 <div style={{ display: 'flex', flexDirection: 'column', height: '100%', backgroundImage: 'url("/hg-web-background.jpg")', fontFamily: 'monospace' }}> | 276 <div className="graph-container" style={{ backgroundImage: 'url("/hg-web-background.jpg")' }}> |
| 277 {assetError && <div className="error-message">{assetError}</div>} | |
| 270 <div | 278 <div |
| 271 ref={containerRef} | 279 ref={containerRef} |
| 272 style={{ display: 'flex', flex: 1, overflowY: 'auto', position: 'relative' }} | 280 className="graph-wrapper" |
| 273 > | 281 > |
| 274 {/* Graph Column - Sticky to keep lines aligned with text during scroll */} | 282 <div className="graph-canvas-column"> |
| 275 <div style={{ position: 'sticky', top: 0, height: 'fit-content', zIndex: 10, borderRight: '1px solid #333' }}> | |
| 276 <canvas ref={canvasRef} style={{ display: 'block' }} /> | 283 <canvas ref={canvasRef} style={{ display: 'block' }} /> |
| 277 </div> | 284 </div> |
| 278 | 285 |
| 279 {/* Details Column */} | 286 <div className="graph-details-column"> |
| 280 <div style={{ flex: 1 }}> | |
| 281 {changesets.map((cs) => ( | 287 {changesets.map((cs) => ( |
| 282 <div | 288 <button |
| 283 key={cs.node} | 289 type="button" |
| 284 style={{ | 290 key={cs.node} |
| 285 height: rowHeight, | 291 className="graph-row" |
| 286 display: 'flex', | |
| 287 alignItems: 'center', | |
| 288 padding: '0 15px', | |
| 289 borderBottom: '1px solid #252525', | |
| 290 cursor: 'pointer', | |
| 291 fontSize: '13px', | |
| 292 whiteSpace: 'nowrap' | |
| 293 }} | |
| 294 onClick={() => onCommitClick?.(cs.node)} | 292 onClick={() => onCommitClick?.(cs.node)} |
| 295 onMouseEnter={(e) => (e.currentTarget.style.background = '#222')} | 293 aria-label={`Open changeset ${cs.node.substring(0, 12)}: ${cs.desc}`} |
| 296 onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')} | |
| 297 > | 294 > |
| 298 <span style={{ color: '#4dabf7', width: '90px', flexShrink: 0 }}>{cs.node.substring(0, 12)}</span> | 295 <span className="graph-row-meta"> |
| 299 <span style={{ color: '#eee', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', paddingRight: '20px' }}>{cs.desc}</span> | 296 <span className="graph-hash">{cs.node.substring(0, 12)}</span> |
| 300 <span style={{ color: '#888', width: '150px', textAlign: 'right' }}>{cs.user.split(' <')[0]}</span> | 297 <span className="graph-user">{cs.user.split(' <')[0]}</span> |
| 301 </div> | 298 {cs.branch && <span className="graph-branch">{cs.branch}</span>} |
| 299 </span> | |
| 300 <span className="graph-desc">{cs.desc}</span> | |
| 301 </button> | |
| 302 ))} | 302 ))} |
| 303 <div id="infinite-scroll-sentinel" style={{ height: '50px' }} /> | 303 <div id="infinite-scroll-sentinel" style={{ height: '50px' }} /> |
| 304 </div> | 304 </div> |
| 305 </div> | 305 </div> |
| 306 | 306 |
| 307 {loading && <div style={{ padding: '10px', textAlign: 'center', color: '#888', fontSize: '12px', background: '#111' }}>Loading repository history...</div>} | 307 {loading && <div className="graph-loading-row">Loading repository history...</div>} |
| 308 </div> | 308 </div> |
| 309 ); | 309 ); |
| 310 }; | 310 }; |
| 311 | 311 |
| 312 export { Graph, useGraphData }; | 312 export { Graph, useGraphData }; |