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