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 };