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:
2026-06-06 01:27:01 +02:00
parent 55b3bee2c8
commit 307ae71857
273 changed files with 48740 additions and 0 deletions

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