/** * `GitGraphView` — full git-graph layout (L8 / git-graph view). * * Renders the commit DAG for a project: each row shows an SVG node+edges on * the left, ref badges (branches in primary blue, tags in amber), a short * hash, the commit summary, author, and relative date. Clicking a row opens a * detail panel on the right. * * Data flow: `useGitGraph` → `computeGraphRows` → render rows. * Never calls `invoke()` — all data comes through the GitGateway port. */ import { useCallback, useEffect, useState } from "react"; import type { GraphCommit } from "@/domain"; import { useGateways } from "@/app/di"; import { Spinner, cn } from "@/shared"; import { computeGraphRows, type GraphLink, type GraphRow } from "./graphLayout"; // --------------------------------------------------------------------------- // Hook // --------------------------------------------------------------------------- interface GitGraphViewModel { commits: GraphCommit[]; rows: GraphRow[]; busy: boolean; error: string | null; refresh: () => Promise; } const GRAPH_LIMIT = 200; function useGitGraph(projectId: string): GitGraphViewModel { const { git } = useGateways(); const [commits, setCommits] = useState([]); const [rows, setRows] = useState([]); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const refresh = useCallback(async () => { setBusy(true); setError(null); try { const data = await git.graph(projectId, GRAPH_LIMIT); setCommits(data); setRows(computeGraphRows(data)); } catch (e) { setError(e && typeof e === "object" && "message" in e ? String((e as { message: unknown }).message) : String(e)); } finally { setBusy(false); } }, [git, projectId]); useEffect(() => { void refresh(); }, [refresh]); return { commits, rows, busy, error, refresh }; } // --------------------------------------------------------------------------- // SVG graph column // --------------------------------------------------------------------------- /** Width per lane in the SVG graph column. */ const LANE_W = 16; /** Row height must match the rendered row height so lanes connect across rows. */ const ROW_H = 36; /** Radius of the commit dot. */ const DOT_R = 4.5; /** Horizontal centre of a lane. */ const laneX = (lane: number) => lane * LANE_W + LANE_W / 2; /** SVG path for a link spanning `y0`→`y1` between two lanes (curved when bent). */ function linkPath(link: GraphLink, y0: number, y1: number): string { const x0 = laneX(link.fromLane); const x1 = laneX(link.toLane); if (x0 === x1) return `M ${x0} ${y0} L ${x1} ${y1}`; // straight lane line const ym = (y0 + y1) / 2; return `M ${x0} ${y0} C ${x0} ${ym} ${x1} ${ym} ${x1} ${y1}`; // smooth S-bend } interface GraphColumnProps { row: GraphRow; /** Total lane columns across the whole graph (fixed width so lanes align). */ laneCount: number; } /** Renders the SVG lanes (incoming + outgoing) and the commit node for one row. */ function GraphColumn({ row, laneCount }: GraphColumnProps) { const w = laneCount * LANE_W + LANE_W / 2; const mid = ROW_H / 2; const cx = laneX(row.lane); return ( {/* Top half: top boundary → node mid */} {row.incoming.map((link, i) => ( ))} {/* Bottom half: node mid → bottom boundary */} {row.outgoing.map((link, i) => ( ))} {/* Commit node: filled dot with a dark ring so it reads over the lanes */} ); } // --------------------------------------------------------------------------- // Relative date helper // --------------------------------------------------------------------------- function relativeDate(timestamp: number): string { const secs = Math.floor(Date.now() / 1000) - timestamp; if (secs < 60) return "just now"; if (secs < 3600) return `${Math.floor(secs / 60)}m ago`; if (secs < 86400) return `${Math.floor(secs / 3600)}h ago`; if (secs < 2592000) return `${Math.floor(secs / 86400)}d ago`; if (secs < 31536000) return `${Math.floor(secs / 2592000)}mo ago`; return `${Math.floor(secs / 31536000)}y ago`; } // --------------------------------------------------------------------------- // Detail panel // --------------------------------------------------------------------------- interface CommitDetailProps { commit: GraphCommit; onClose: () => void; } function CommitDetail({ commit, onClose }: CommitDetailProps) { const isTag = (ref: string) => ref.startsWith("tag:"); return ( ); } // --------------------------------------------------------------------------- // Main component // --------------------------------------------------------------------------- export interface GitGraphViewProps { projectId: string; } export function GitGraphView({ projectId }: GitGraphViewProps) { const vm = useGitGraph(projectId); const [selectedHash, setSelectedHash] = useState(null); const selectedCommit = selectedHash != null ? vm.commits.find((c) => c.hash === selectedHash) ?? null : null; // Fixed lane-column count across all rows so the lanes line up vertically. const laneCount = vm.rows.reduce((m, r) => Math.max(m, r.laneCount), 1); const isTag = (ref: string) => ref.startsWith("tag:"); return (
{/* ── Graph + commit list ── */}
{/* Toolbar */}
Git Graph {vm.busy && }
{/* Error */} {vm.error && (

{vm.error}

)} {/* Rows */}
{vm.rows.length === 0 && !vm.busy && (

No commits found.

)} {vm.rows.map((row) => { const isSelected = row.commit.hash === selectedHash; return (
setSelectedHash(isSelected ? null : row.commit.hash) } className={cn( "flex cursor-pointer items-center gap-2 border-b border-border/50 px-2 hover:bg-raised", isSelected && "bg-raised", )} style={{ height: ROW_H }} > {/* SVG lanes + node */} {/* Ref badges */} {row.commit.refs.length > 0 && (
{row.commit.refs.map((ref) => ( {ref} ))}
)} {/* Short hash */} {row.commit.hash.slice(0, 8)} {/* Summary */} {row.commit.summary} {/* Author + relative date */} {row.commit.author} {relativeDate(row.commit.timestamp)}
); })}
{/* ── Detail panel (shown when a commit is selected) ── */} {selectedCommit && ( setSelectedHash(null)} /> )}
); }