Files
IdeA/frontend/src/features/git/GitGraphView.tsx
Blomios 307ae71857 feat: add main features
Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
2026-06-06 01:27:01 +02:00

355 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* `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<void>;
}
const GRAPH_LIMIT = 200;
function useGitGraph(projectId: string): GitGraphViewModel {
const { git } = useGateways();
const [commits, setCommits] = useState<GraphCommit[]>([]);
const [rows, setRows] = useState<GraphRow[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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 (
<svg width={w} height={ROW_H} aria-hidden className="shrink-0 overflow-visible">
{/* Top half: top boundary → node mid */}
{row.incoming.map((link, i) => (
<path
key={`in-${i}`}
d={linkPath(link, 0, mid)}
fill="none"
stroke={link.color}
strokeWidth={2}
/>
))}
{/* Bottom half: node mid → bottom boundary */}
{row.outgoing.map((link, i) => (
<path
key={`out-${i}`}
d={linkPath(link, mid, ROW_H)}
fill="none"
stroke={link.color}
strokeWidth={2}
/>
))}
{/* Commit node: filled dot with a dark ring so it reads over the lanes */}
<circle cx={cx} cy={mid} r={DOT_R + 1.5} className="fill-canvas" />
<circle
cx={cx}
cy={mid}
r={DOT_R}
fill={row.color}
stroke="var(--color-canvas)"
strokeWidth={1}
/>
</svg>
);
}
// ---------------------------------------------------------------------------
// 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 (
<aside
aria-label="commit detail"
className="flex w-80 shrink-0 flex-col gap-3 overflow-y-auto border-l border-border bg-surface p-4"
>
<div className="flex items-start justify-between gap-2">
<h3 className="text-sm font-semibold text-content">{commit.summary}</h3>
<button
type="button"
aria-label="close commit detail"
onClick={onClose}
className="shrink-0 text-muted hover:text-content"
>
×
</button>
</div>
<dl className="flex flex-col gap-2 text-xs">
<div>
<dt className="font-medium text-faint">Hash</dt>
<dd>
<code className="text-content break-all">{commit.hash}</code>
</dd>
</div>
<div>
<dt className="font-medium text-faint">Author</dt>
<dd className="text-content">{commit.author}</dd>
</div>
<div>
<dt className="font-medium text-faint">Date</dt>
<dd className="text-content">
{new Date(commit.timestamp * 1000).toLocaleString()}
</dd>
</div>
{commit.parents.length > 0 && (
<div>
<dt className="font-medium text-faint">Parents</dt>
<dd className="flex flex-col gap-0.5">
{commit.parents.map((p) => (
<code key={p} className="text-content">
{p.slice(0, 8)}
</code>
))}
</dd>
</div>
)}
{commit.refs.length > 0 && (
<div>
<dt className="font-medium text-faint">Refs</dt>
<dd className="flex flex-wrap gap-1">
{commit.refs.map((ref) => (
<span
key={ref}
className={cn(
"rounded px-1 py-0.5 text-xs font-medium text-base",
isTag(ref) ? "bg-warning/20 text-warning" : "bg-primary/20 text-primary",
)}
>
{ref}
</span>
))}
</dd>
</div>
)}
</dl>
</aside>
);
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export interface GitGraphViewProps {
projectId: string;
}
export function GitGraphView({ projectId }: GitGraphViewProps) {
const vm = useGitGraph(projectId);
const [selectedHash, setSelectedHash] = useState<string | null>(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 (
<div className="flex flex-1 overflow-hidden" aria-label="git graph">
{/* ── Graph + commit list ── */}
<div className="flex flex-1 flex-col overflow-hidden">
{/* Toolbar */}
<div className="flex shrink-0 items-center gap-2 border-b border-border bg-surface px-3 py-2">
<span className="text-sm font-semibold text-content">Git Graph</span>
{vm.busy && <Spinner size={14} />}
<button
type="button"
aria-label="refresh git graph"
onClick={() => void vm.refresh()}
disabled={vm.busy}
className="ml-auto text-xs text-muted hover:text-content disabled:opacity-40"
>
Refresh
</button>
</div>
{/* Error */}
{vm.error && (
<p
role="alert"
className="mx-3 mt-2 shrink-0 rounded border border-danger/40 bg-danger/10 px-3 py-2 text-xs text-danger"
>
{vm.error}
</p>
)}
{/* Rows */}
<div className="flex-1 overflow-y-auto" role="list" aria-label="commits">
{vm.rows.length === 0 && !vm.busy && (
<p className="p-4 text-sm text-muted">No commits found.</p>
)}
{vm.rows.map((row) => {
const isSelected = row.commit.hash === selectedHash;
return (
<div
key={row.commit.hash}
role="listitem"
aria-label={`commit ${row.commit.hash.slice(0, 8)}: ${row.commit.summary}`}
onClick={() =>
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 */}
<GraphColumn row={row} laneCount={laneCount} />
{/* Ref badges */}
{row.commit.refs.length > 0 && (
<div className="flex shrink-0 flex-wrap gap-1">
{row.commit.refs.map((ref) => (
<span
key={ref}
className={cn(
"rounded px-1 py-0.5 text-xs font-medium",
isTag(ref)
? "bg-warning/20 text-warning"
: "bg-primary/20 text-primary",
)}
>
{ref}
</span>
))}
</div>
)}
{/* Short hash */}
<code className="shrink-0 text-xs text-faint">
{row.commit.hash.slice(0, 8)}
</code>
{/* Summary */}
<span className="min-w-0 flex-1 truncate text-sm text-content">
{row.commit.summary}
</span>
{/* Author + relative date */}
<span className="shrink-0 text-xs text-muted">
{row.commit.author}
</span>
<span className="shrink-0 text-xs text-faint">
{relativeDate(row.commit.timestamp)}
</span>
</div>
);
})}
</div>
</div>
{/* ── Detail panel (shown when a commit is selected) ── */}
{selectedCommit && (
<CommitDetail
commit={selectedCommit}
onClose={() => setSelectedHash(null)}
/>
)}
</div>
);
}