feat: add main features
Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
This commit is contained in:
354
frontend/src/features/git/GitGraphView.tsx
Normal file
354
frontend/src/features/git/GitGraphView.tsx
Normal file
@ -0,0 +1,354 @@
|
||||
/**
|
||||
* `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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user