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>
|
||||
);
|
||||
}
|
||||
295
frontend/src/features/git/GitPanel.tsx
Normal file
295
frontend/src/features/git/GitPanel.tsx
Normal file
@ -0,0 +1,295 @@
|
||||
/**
|
||||
* `GitPanel` — feature component for the Git panel (L8).
|
||||
*
|
||||
* Shows the working-tree status grouped by Staged / Unstaged, a commit form,
|
||||
* branch management with checkout, and the recent commit log.
|
||||
*
|
||||
* Pure presentation: all behaviour comes from {@link useGit}. Styled with
|
||||
* `@/shared`; no inline styles.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button, Input, Panel, Spinner, cn } from "@/shared";
|
||||
import { useGit } from "./useGit";
|
||||
import { buildFileTree, type GitTreeNode } from "./gitTree";
|
||||
|
||||
/**
|
||||
* Recursively renders a changed-files tree. Directories are static rows; file
|
||||
* leaves carry the stage/unstage action (its `aria-label` uses the full path).
|
||||
*/
|
||||
function FileTree({
|
||||
nodes,
|
||||
depth,
|
||||
action,
|
||||
onAction,
|
||||
busy,
|
||||
}: {
|
||||
nodes: GitTreeNode[];
|
||||
depth: number;
|
||||
action: "stage" | "unstage";
|
||||
onAction: (path: string) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{nodes.map((node) =>
|
||||
node.isDir ? (
|
||||
<li key={node.path}>
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-0.5 text-xs text-muted"
|
||||
style={{ paddingLeft: `${depth * 14 + 8}px` }}
|
||||
>
|
||||
<span aria-hidden className="text-faint">
|
||||
▸
|
||||
</span>
|
||||
<span className="truncate">{node.name}</span>
|
||||
</div>
|
||||
<FileTree
|
||||
nodes={node.children}
|
||||
depth={depth + 1}
|
||||
action={action}
|
||||
onAction={onAction}
|
||||
busy={busy}
|
||||
/>
|
||||
</li>
|
||||
) : (
|
||||
<li
|
||||
key={node.path}
|
||||
className="flex items-center justify-between gap-2 rounded-md px-2 py-1 hover:bg-raised"
|
||||
style={{ paddingLeft: `${depth * 14 + 8}px` }}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"size-1.5 shrink-0 rounded-full",
|
||||
action === "unstage" ? "bg-success" : "bg-warning",
|
||||
)}
|
||||
/>
|
||||
<code className="min-w-0 truncate text-xs text-content">
|
||||
{node.name}
|
||||
</code>
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`${action} ${node.path}`}
|
||||
disabled={busy}
|
||||
onClick={() => onAction(node.path)}
|
||||
className="shrink-0 text-muted hover:text-content"
|
||||
>
|
||||
{action === "unstage" ? "Unstage" : "Stage"}
|
||||
</Button>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export interface GitPanelProps {
|
||||
/** The project whose git state to manage. */
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export function GitPanel({ projectId }: GitPanelProps) {
|
||||
const vm = useGit(projectId);
|
||||
const [commitMessage, setCommitMessage] = useState("");
|
||||
const [newBranch, setNewBranch] = useState("");
|
||||
|
||||
const staged = vm.files.filter((f) => f.staged);
|
||||
const unstaged = vm.files.filter((f) => !f.staged);
|
||||
const canCommit = commitMessage.trim().length > 0 && staged.length > 0 && !vm.busy;
|
||||
|
||||
async function handleCommit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!canCommit) return;
|
||||
await vm.commit(commitMessage.trim());
|
||||
setCommitMessage("");
|
||||
}
|
||||
|
||||
async function handleCheckout(branch: string) {
|
||||
await vm.checkout(branch);
|
||||
}
|
||||
|
||||
async function handleNewBranch(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const b = newBranch.trim();
|
||||
if (!b || vm.busy) return;
|
||||
await vm.checkout(b);
|
||||
setNewBranch("");
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel title="Git" className="flex flex-col gap-0">
|
||||
{vm.error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mx-4 mt-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
|
||||
>
|
||||
{vm.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── Changes ── */}
|
||||
<section aria-label="Changes" className="border-b border-border p-4">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Changes
|
||||
</h4>
|
||||
{vm.busy && <Spinner size={12} />}
|
||||
</div>
|
||||
|
||||
{vm.files.length === 0 ? (
|
||||
<p className="text-sm text-muted">No changes.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Staged — shown as a file tree. */}
|
||||
{staged.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-muted">Staged</p>
|
||||
<FileTree
|
||||
nodes={buildFileTree(staged)}
|
||||
depth={0}
|
||||
action="unstage"
|
||||
onAction={(p) => void vm.unstage(p)}
|
||||
busy={vm.busy}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unstaged — shown as a file tree. */}
|
||||
{unstaged.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-muted">Unstaged</p>
|
||||
<FileTree
|
||||
nodes={buildFileTree(unstaged)}
|
||||
depth={0}
|
||||
action="stage"
|
||||
onAction={(p) => void vm.stage(p)}
|
||||
busy={vm.busy}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── Commit ── */}
|
||||
<section aria-label="Commit" className="border-b border-border p-4">
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Commit
|
||||
</h4>
|
||||
<form onSubmit={handleCommit} className="flex flex-col gap-2">
|
||||
<Input
|
||||
aria-label="commit message"
|
||||
placeholder="Commit message"
|
||||
value={commitMessage}
|
||||
onChange={(e) => setCommitMessage(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={!canCommit}
|
||||
loading={vm.busy}
|
||||
className="self-end"
|
||||
>
|
||||
Commit
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* ── Branches ── */}
|
||||
<section aria-label="Branches" className="border-b border-border p-4">
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Branches
|
||||
</h4>
|
||||
<p className="mb-2 text-sm text-content">
|
||||
Current:{" "}
|
||||
<span className="font-medium">
|
||||
{vm.branches.current ?? "(none)"}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{vm.branches.branches.length > 0 && (
|
||||
<ul className="mb-3 flex flex-col gap-0.5">
|
||||
{vm.branches.branches.map((b) => {
|
||||
const isCurrent = b === vm.branches.current;
|
||||
return (
|
||||
<li
|
||||
key={b}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2 rounded-md px-2 py-1",
|
||||
isCurrent && "bg-raised",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
isCurrent ? "font-semibold text-primary" : "text-content",
|
||||
)}
|
||||
>
|
||||
{b}
|
||||
</span>
|
||||
{!isCurrent && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`checkout ${b}`}
|
||||
disabled={vm.busy}
|
||||
onClick={() => void handleCheckout(b)}
|
||||
className="shrink-0 text-muted hover:text-content"
|
||||
>
|
||||
Checkout
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleNewBranch} className="flex items-center gap-2">
|
||||
<Input
|
||||
aria-label="new branch name"
|
||||
placeholder="New branch"
|
||||
value={newBranch}
|
||||
onChange={(e) => setNewBranch(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
disabled={!newBranch.trim() || vm.busy}
|
||||
>
|
||||
Create & checkout
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* ── Log ── */}
|
||||
<section aria-label="Log" className="p-4">
|
||||
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
|
||||
Log
|
||||
</h4>
|
||||
{vm.log.length === 0 ? (
|
||||
<p className="text-sm text-muted">No commits yet.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{vm.log.map((c) => (
|
||||
<li key={c.hash} className="flex items-baseline gap-2 py-1.5">
|
||||
<code className="shrink-0 text-xs text-faint">
|
||||
{c.hash.slice(0, 7)}
|
||||
</code>
|
||||
<span className="min-w-0 truncate text-sm text-content">
|
||||
{c.summary}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
284
frontend/src/features/git/git.test.tsx
Normal file
284
frontend/src/features/git/git.test.tsx
Normal file
@ -0,0 +1,284 @@
|
||||
/**
|
||||
* L8 — git feature wired to the stateful `MockGitGateway` via the real
|
||||
* `DIProvider`.
|
||||
*
|
||||
* Covers:
|
||||
* - status shows staged/unstaged groupings
|
||||
* - stage moves a file from Unstaged to Staged
|
||||
* - commit with empty message is blocked (button disabled)
|
||||
* - commit with message but nothing staged is blocked (button disabled)
|
||||
* - commit moves staged files out and adds an entry to the log
|
||||
* - checkout changes the current branch
|
||||
* - MockGitGateway unit behaviour
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
|
||||
import { MockGitGateway } from "@/adapters/mock";
|
||||
import type { Gateways } from "@/ports";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { GitPanel } from "./GitPanel";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PROJECT_ID = "proj-git-test-001";
|
||||
|
||||
function renderPanel(git: MockGitGateway = new MockGitGateway()) {
|
||||
const gateways = { git } as unknown as Gateways;
|
||||
return {
|
||||
git,
|
||||
...render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<GitPanel projectId={PROJECT_ID} />
|
||||
</DIProvider>,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Waits until the "Changes" section is rendered (panel stabilised). */
|
||||
async function waitForPanel() {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("region", { name: "Changes" })).toBeTruthy();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration tests (GitPanel + MockGitGateway via DIProvider)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("GitPanel (with MockGitGateway)", () => {
|
||||
it("shows demo files split between Staged and Unstaged sections", async () => {
|
||||
const git = new MockGitGateway();
|
||||
// Seed a staged file.
|
||||
await git.init(PROJECT_ID);
|
||||
await git.stage(PROJECT_ID, "src/main.rs");
|
||||
|
||||
renderPanel(git);
|
||||
await waitForPanel();
|
||||
|
||||
expect(screen.getByText("Staged")).toBeTruthy();
|
||||
expect(screen.getByText("Unstaged")).toBeTruthy();
|
||||
// src/main.rs is staged → Unstage button exists
|
||||
expect(
|
||||
screen.getByRole("button", { name: "unstage src/main.rs" }),
|
||||
).toBeTruthy();
|
||||
// README.md is unstaged → Stage button exists
|
||||
expect(
|
||||
screen.getByRole("button", { name: "stage README.md" }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clicking Stage moves a file to the Staged group", async () => {
|
||||
renderPanel();
|
||||
await waitForPanel();
|
||||
|
||||
// Stage README.md
|
||||
const stageBtn = await screen.findByRole("button", {
|
||||
name: "stage README.md",
|
||||
});
|
||||
fireEvent.click(stageBtn);
|
||||
|
||||
// After staging, an Unstage button should appear for README.md
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "unstage README.md" }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("clicking Unstage moves a file back to Unstaged", async () => {
|
||||
const git = new MockGitGateway();
|
||||
await git.init(PROJECT_ID);
|
||||
await git.stage(PROJECT_ID, "src/main.rs");
|
||||
|
||||
renderPanel(git);
|
||||
await waitForPanel();
|
||||
|
||||
const unstageBtn = await screen.findByRole("button", {
|
||||
name: "unstage src/main.rs",
|
||||
});
|
||||
fireEvent.click(unstageBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "stage src/main.rs" }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("Commit button is disabled when message is empty", async () => {
|
||||
renderPanel();
|
||||
await waitForPanel();
|
||||
|
||||
const btn = screen.getByRole("button", { name: /commit/i });
|
||||
expect((btn as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("Commit button is disabled when message is set but nothing is staged", async () => {
|
||||
renderPanel();
|
||||
await waitForPanel();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("commit message"), {
|
||||
target: { value: "my message" },
|
||||
});
|
||||
|
||||
const btn = screen.getByRole("button", { name: /commit/i });
|
||||
expect((btn as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("successful commit removes staged files and adds to the log", async () => {
|
||||
const git = new MockGitGateway();
|
||||
await git.init(PROJECT_ID);
|
||||
await git.stage(PROJECT_ID, "src/main.rs");
|
||||
await git.stage(PROJECT_ID, "README.md");
|
||||
|
||||
renderPanel(git);
|
||||
await waitForPanel();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("commit message"), {
|
||||
target: { value: "initial commit" },
|
||||
});
|
||||
|
||||
const btn = screen.getByRole("button", { name: /commit/i });
|
||||
expect((btn as HTMLButtonElement).disabled).toBe(false);
|
||||
fireEvent.click(btn);
|
||||
|
||||
// After commit, no files should remain (both were staged).
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No changes.")).toBeTruthy();
|
||||
});
|
||||
|
||||
// The commit should appear in the log.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("initial commit")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("checkout changes the current branch label", async () => {
|
||||
renderPanel();
|
||||
await waitForPanel();
|
||||
|
||||
// Ensure the Branches section is rendered with "main" as current.
|
||||
const branchesSection = await screen.findByRole("region", {
|
||||
name: "Branches",
|
||||
});
|
||||
expect(branchesSection).toBeTruthy();
|
||||
|
||||
// Create & checkout a new branch via the form.
|
||||
const branchInput = screen.getByLabelText("new branch name");
|
||||
fireEvent.change(branchInput, { target: { value: "feature/new-thing" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /create.*checkout/i }));
|
||||
|
||||
// After checkout, the new branch should appear (may show in current + list).
|
||||
await waitFor(() => {
|
||||
const matches = screen.getAllByText("feature/new-thing");
|
||||
expect(matches.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// A checkout button for "main" should now appear (since it's no longer current).
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: "checkout main" }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows 'No commits yet.' in the Log section initially", async () => {
|
||||
renderPanel();
|
||||
await waitForPanel();
|
||||
|
||||
expect(screen.getByText("No commits yet.")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MockGitGateway unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("MockGitGateway (unit)", () => {
|
||||
it("status returns seeded files for a new project", async () => {
|
||||
const gw = new MockGitGateway();
|
||||
const files = await gw.status("proj");
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
expect(files.every((f) => !f.staged)).toBe(true);
|
||||
});
|
||||
|
||||
it("init creates state if absent", async () => {
|
||||
const gw = new MockGitGateway();
|
||||
await gw.init("new-proj");
|
||||
const files = await gw.status("new-proj");
|
||||
expect(Array.isArray(files)).toBe(true);
|
||||
});
|
||||
|
||||
it("stage toggles a file to staged=true", async () => {
|
||||
const gw = new MockGitGateway();
|
||||
const before = await gw.status("p");
|
||||
const target = before[0].path;
|
||||
await gw.stage("p", target);
|
||||
const after = await gw.status("p");
|
||||
const file = after.find((f) => f.path === target)!;
|
||||
expect(file.staged).toBe(true);
|
||||
});
|
||||
|
||||
it("unstage toggles a file back to staged=false", async () => {
|
||||
const gw = new MockGitGateway();
|
||||
const files = await gw.status("p");
|
||||
const target = files[0].path;
|
||||
await gw.stage("p", target);
|
||||
await gw.unstage("p", target);
|
||||
const after = await gw.status("p");
|
||||
expect(after.find((f) => f.path === target)!.staged).toBe(false);
|
||||
});
|
||||
|
||||
it("commit removes staged files from status and adds to log", async () => {
|
||||
const gw = new MockGitGateway();
|
||||
const files = await gw.status("p");
|
||||
const target = files[0].path;
|
||||
await gw.stage("p", target);
|
||||
const result = await gw.commit("p", "test commit\n\nmore text");
|
||||
expect(result.summary).toBe("test commit");
|
||||
expect(result.hash).toMatch(/^mock-/);
|
||||
const after = await gw.status("p");
|
||||
expect(after.find((f) => f.path === target)).toBeUndefined();
|
||||
const log = await gw.log("p", 10);
|
||||
expect(log[0].summary).toBe("test commit");
|
||||
});
|
||||
|
||||
it("branches returns initial ['main'] with current='main'", async () => {
|
||||
const gw = new MockGitGateway();
|
||||
const b = await gw.branches("p");
|
||||
expect(b.branches).toEqual(["main"]);
|
||||
expect(b.current).toBe("main");
|
||||
});
|
||||
|
||||
it("checkout creates a new branch and sets it as current", async () => {
|
||||
const gw = new MockGitGateway();
|
||||
await gw.checkout("p", "develop");
|
||||
const b = await gw.branches("p");
|
||||
expect(b.branches).toContain("develop");
|
||||
expect(b.current).toBe("develop");
|
||||
});
|
||||
|
||||
it("log(limit) returns at most limit entries", async () => {
|
||||
const gw = new MockGitGateway();
|
||||
const files = await gw.status("p");
|
||||
// Stage and commit each file individually.
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
await gw.stage("p", files[i].path);
|
||||
await gw.commit("p", `commit ${i}`);
|
||||
}
|
||||
const log = await gw.log("p", 1);
|
||||
expect(log).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("projects are isolated", async () => {
|
||||
const gw = new MockGitGateway();
|
||||
await gw.stage("proj-A", (await gw.status("proj-A"))[0].path);
|
||||
await gw.commit("proj-A", "A commit");
|
||||
const logB = await gw.log("proj-B", 10);
|
||||
expect(logB).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
190
frontend/src/features/git/gitGraphView.test.tsx
Normal file
190
frontend/src/features/git/gitGraphView.test.tsx
Normal file
@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Integration tests for `GitGraphView` via `MockGitGateway`.
|
||||
*
|
||||
* Covers:
|
||||
* - The commits from the demo DAG are displayed.
|
||||
* - Branch refs are shown with primary colour class.
|
||||
* - Tag refs are shown with warning colour class.
|
||||
* - Clicking a commit row opens the detail panel.
|
||||
* - The detail panel shows full hash, author, and parents.
|
||||
* - Clicking close hides the detail panel.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
|
||||
import { MockGitGateway } from "@/adapters/mock";
|
||||
import type { Gateways } from "@/ports";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { GitGraphView } from "./GitGraphView";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderView(git: MockGitGateway = new MockGitGateway()) {
|
||||
const gateways = { git } as unknown as Gateways;
|
||||
return render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<GitGraphView projectId="proj-graph-test" />
|
||||
</DIProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** Waits until at least one commit row is rendered. */
|
||||
async function waitForCommits() {
|
||||
await waitFor(() => {
|
||||
const list = screen.getByRole("list", { name: "commits" });
|
||||
expect(list.children.length).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("GitGraphView", () => {
|
||||
it("renders commits from the mock graph", async () => {
|
||||
renderView();
|
||||
await waitForCommits();
|
||||
|
||||
// The demo DAG has "Merge feature into main" as the newest commit.
|
||||
expect(screen.getByText("Merge feature into main")).toBeTruthy();
|
||||
// And "Initial commit" as the oldest.
|
||||
expect(screen.getByText("Initial commit")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows branch ref badge for 'main'", async () => {
|
||||
renderView();
|
||||
await waitForCommits();
|
||||
|
||||
// The "main" badge should appear (it's in the refs of the merge commit).
|
||||
const mainBadges = screen.getAllByText("main");
|
||||
expect(mainBadges.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("shows tag ref badge for 'tag: v1.0'", async () => {
|
||||
renderView();
|
||||
await waitForCommits();
|
||||
|
||||
const tagBadge = screen.getByText("tag: v1.0");
|
||||
expect(tagBadge).toBeTruthy();
|
||||
// Tag badge should have the warning colour class
|
||||
expect(tagBadge.className).toMatch(/warning/);
|
||||
});
|
||||
|
||||
it("shows 'feature' branch badge", async () => {
|
||||
renderView();
|
||||
await waitForCommits();
|
||||
|
||||
const featureBadge = screen.getByText("feature");
|
||||
expect(featureBadge).toBeTruthy();
|
||||
expect(featureBadge.className).toMatch(/primary/);
|
||||
});
|
||||
|
||||
it("clicking a commit row opens the detail panel", async () => {
|
||||
renderView();
|
||||
await waitForCommits();
|
||||
|
||||
// The detail panel should not be visible yet.
|
||||
expect(screen.queryByRole("complementary", { name: "commit detail" })).toBeNull();
|
||||
|
||||
// Click the first commit row.
|
||||
const items = screen.getAllByRole("listitem");
|
||||
fireEvent.click(items[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("complementary", { name: "commit detail" }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("detail panel shows full commit hash and author", async () => {
|
||||
renderView();
|
||||
await waitForCommits();
|
||||
|
||||
// Click the merge commit row.
|
||||
const mergeRow = screen.getByRole("listitem", {
|
||||
name: /commit eeeeeeee/,
|
||||
});
|
||||
fireEvent.click(mergeRow);
|
||||
|
||||
await waitFor(() => {
|
||||
// Full hash should appear in the detail panel (code element).
|
||||
const fullHash = screen.getByText(
|
||||
"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
|
||||
);
|
||||
expect(fullHash.tagName.toLowerCase()).toBe("code");
|
||||
});
|
||||
|
||||
// Author should appear in a <dd> (detail panel's definition list).
|
||||
await waitFor(() => {
|
||||
const detail = screen.getByRole("complementary", { name: "commit detail" });
|
||||
expect(detail.textContent).toContain("Alice");
|
||||
});
|
||||
});
|
||||
|
||||
it("detail panel lists parents for a merge commit", async () => {
|
||||
renderView();
|
||||
await waitForCommits();
|
||||
|
||||
const mergeRow = screen.getByRole("listitem", {
|
||||
name: /commit eeeeeeee/,
|
||||
});
|
||||
fireEvent.click(mergeRow);
|
||||
|
||||
await waitFor(() => {
|
||||
const detail = screen.getByRole("complementary", { name: "commit detail" });
|
||||
// The merge commit has two parents displayed as short hashes.
|
||||
expect(detail.textContent).toContain("bbbbbbbb");
|
||||
expect(detail.textContent).toContain("dddddddd");
|
||||
});
|
||||
});
|
||||
|
||||
it("closing the detail panel hides it", async () => {
|
||||
renderView();
|
||||
await waitForCommits();
|
||||
|
||||
const items = screen.getAllByRole("listitem");
|
||||
fireEvent.click(items[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("complementary", { name: "commit detail" }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click the close button.
|
||||
fireEvent.click(screen.getByRole("button", { name: "close commit detail" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole("complementary", { name: "commit detail" }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("clicking the same row again deselects and hides detail", async () => {
|
||||
renderView();
|
||||
await waitForCommits();
|
||||
|
||||
const items = screen.getAllByRole("listitem");
|
||||
fireEvent.click(items[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("complementary", { name: "commit detail" }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click the same row again to deselect.
|
||||
fireEvent.click(items[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole("complementary", { name: "commit detail" }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
33
frontend/src/features/git/gitTree.test.ts
Normal file
33
frontend/src/features/git/gitTree.test.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { buildFileTree } from "./gitTree";
|
||||
|
||||
describe("buildFileTree", () => {
|
||||
it("folds paths into a nested directory tree", () => {
|
||||
const tree = buildFileTree([
|
||||
{ path: "src/app/main.rs", staged: false },
|
||||
{ path: "src/lib.rs", staged: true },
|
||||
{ path: "README.md", staged: false },
|
||||
]);
|
||||
|
||||
// Directories first, then files, alphabetically: src/ then README.md.
|
||||
expect(tree.map((n) => n.name)).toEqual(["src", "README.md"]);
|
||||
|
||||
const src = tree[0];
|
||||
expect(src.isDir).toBe(true);
|
||||
// Inside src: app/ (dir) then lib.rs (file).
|
||||
expect(src.children.map((n) => n.name)).toEqual(["app", "lib.rs"]);
|
||||
expect(src.children[0].isDir).toBe(true);
|
||||
expect(src.children[0].children[0].path).toBe("src/app/main.rs");
|
||||
expect(src.children[1].path).toBe("src/lib.rs");
|
||||
|
||||
// The README leaf keeps its full path + staged flag.
|
||||
const readme = tree[1];
|
||||
expect(readme.isDir).toBe(false);
|
||||
expect(readme.path).toBe("README.md");
|
||||
});
|
||||
|
||||
it("returns an empty array for no files", () => {
|
||||
expect(buildFileTree([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
69
frontend/src/features/git/gitTree.ts
Normal file
69
frontend/src/features/git/gitTree.ts
Normal file
@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Pure helper that folds a flat list of changed file paths into a directory
|
||||
* tree, so the Git panel can show changes as a file tree (L8 visual). Kept free
|
||||
* of React for straightforward unit testing.
|
||||
*/
|
||||
|
||||
import type { GitFileStatus } from "@/domain";
|
||||
|
||||
/** A node in the changed-files tree: a directory or a file leaf. */
|
||||
export interface GitTreeNode {
|
||||
/** Last path segment (folder or file name). */
|
||||
name: string;
|
||||
/** Full repo-relative path of this node. */
|
||||
path: string;
|
||||
/** Whether this node is a directory (has children) or a file leaf. */
|
||||
isDir: boolean;
|
||||
/** For file leaves: whether the change is staged. */
|
||||
staged: boolean;
|
||||
/** Child nodes (empty for files). */
|
||||
children: GitTreeNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the directory tree for a set of changed files. Directories come before
|
||||
* files at each level, both alphabetically sorted.
|
||||
*/
|
||||
export function buildFileTree(files: GitFileStatus[]): GitTreeNode[] {
|
||||
const root: GitTreeNode = {
|
||||
name: "",
|
||||
path: "",
|
||||
isDir: true,
|
||||
staged: false,
|
||||
children: [],
|
||||
};
|
||||
|
||||
for (const file of files) {
|
||||
const parts = file.path.split("/").filter(Boolean);
|
||||
let node = root;
|
||||
parts.forEach((part, i) => {
|
||||
const isLeaf = i === parts.length - 1;
|
||||
const path = parts.slice(0, i + 1).join("/");
|
||||
let child = node.children.find(
|
||||
(c) => c.name === part && c.isDir === !isLeaf,
|
||||
);
|
||||
if (!child) {
|
||||
child = {
|
||||
name: part,
|
||||
path,
|
||||
isDir: !isLeaf,
|
||||
staged: file.staged,
|
||||
children: [],
|
||||
};
|
||||
node.children.push(child);
|
||||
}
|
||||
node = child;
|
||||
});
|
||||
}
|
||||
|
||||
sort(root);
|
||||
return root.children;
|
||||
}
|
||||
|
||||
function sort(node: GitTreeNode): void {
|
||||
node.children.sort((a, b) => {
|
||||
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
node.children.forEach(sort);
|
||||
}
|
||||
109
frontend/src/features/git/graphLayout.test.ts
Normal file
109
frontend/src/features/git/graphLayout.test.ts
Normal file
@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Unit tests for `computeGraphRows` — the pure lane-layout algorithm.
|
||||
*
|
||||
* Each row is split into `incoming` (top boundary → node) and `outgoing`
|
||||
* (node → bottom boundary) links; a link with `fromLane !== toLane` is a bent
|
||||
* fork/merge connector, otherwise it is a straight pass-through lane.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { computeGraphRows } from "./graphLayout";
|
||||
import type { GraphCommit } from "@/domain";
|
||||
|
||||
function commit(hash: string, parents: string[]): GraphCommit {
|
||||
return { hash, summary: hash, parents, refs: [], author: "test", timestamp: 0 };
|
||||
}
|
||||
|
||||
/** Just the (fromLane,toLane) pairs of a set of links. */
|
||||
function pairs(links: { fromLane: number; toLane: number }[]) {
|
||||
return links.map((l) => ({ fromLane: l.fromLane, toLane: l.toLane }));
|
||||
}
|
||||
|
||||
describe("computeGraphRows — linear history", () => {
|
||||
// c → b → a (root); newest first.
|
||||
const commits = [commit("c", ["b"]), commit("b", ["a"]), commit("a", [])];
|
||||
|
||||
it("keeps all commits on lane 0", () => {
|
||||
const rows = computeGraphRows(commits);
|
||||
expect(rows.map((r) => r.lane)).toEqual([0, 0, 0]);
|
||||
});
|
||||
|
||||
it("a non-root commit flows straight down lane 0", () => {
|
||||
const b = computeGraphRows(commits).find((r) => r.commit.hash === "b")!;
|
||||
expect(pairs(b.outgoing)).toEqual([{ fromLane: 0, toLane: 0 }]);
|
||||
});
|
||||
|
||||
it("the root commit has no outgoing link (lane ends)", () => {
|
||||
const a = computeGraphRows(commits).find((r) => r.commit.hash === "a")!;
|
||||
expect(a.outgoing).toHaveLength(0);
|
||||
// It still receives the lane from the commit above it.
|
||||
expect(pairs(a.incoming)).toContainEqual({ fromLane: 0, toLane: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeGraphRows — fork then converge", () => {
|
||||
// d→b, c→a, b→a, a root. Order newest-first: d, c, b, a.
|
||||
const commits = [
|
||||
commit("d", ["b"]),
|
||||
commit("c", ["a"]),
|
||||
commit("b", ["a"]),
|
||||
commit("a", []),
|
||||
];
|
||||
const rows = computeGraphRows(commits);
|
||||
const by = Object.fromEntries(rows.map((r) => [r.commit.hash, r]));
|
||||
|
||||
it("opens a second lane for the diverged tip", () => {
|
||||
expect(by["d"].lane).toBe(0);
|
||||
expect(by["c"].lane).toBe(1);
|
||||
expect(by["b"].lane).toBe(0);
|
||||
});
|
||||
|
||||
it("converges the second lane into the shared ancestor", () => {
|
||||
// At commit `a`, lane 1 bends into lane 0 (the node).
|
||||
expect(pairs(by["a"].incoming)).toContainEqual({ fromLane: 1, toLane: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeGraphRows — merge commit", () => {
|
||||
// e merges b and d. e→[b,d], d→[c], b→[a], c→[a], a root.
|
||||
const commits = [
|
||||
commit("e", ["b", "d"]),
|
||||
commit("d", ["c"]),
|
||||
commit("b", ["a"]),
|
||||
commit("c", ["a"]),
|
||||
commit("a", []),
|
||||
];
|
||||
const rows = computeGraphRows(commits);
|
||||
const by = Object.fromEntries(rows.map((r) => [r.commit.hash, r]));
|
||||
|
||||
it("the merge node sits on lane 0 and forks down to both parents", () => {
|
||||
expect(by["e"].lane).toBe(0);
|
||||
expect(pairs(by["e"].outgoing)).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ fromLane: 0, toLane: 0 }, // first parent keeps the lane
|
||||
{ fromLane: 0, toLane: 1 }, // second parent diverges to a new lane
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("the second parent lands on the new lane", () => {
|
||||
expect(by["d"].lane).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeGraphRows — colours", () => {
|
||||
it("gives distinct lanes distinct colours", () => {
|
||||
const rows = computeGraphRows([
|
||||
commit("x", ["z"]),
|
||||
commit("y", ["z"]),
|
||||
commit("z", []),
|
||||
]);
|
||||
const by = Object.fromEntries(rows.map((r) => [r.commit.hash, r]));
|
||||
expect(by["x"].color).not.toBe(by["y"].color);
|
||||
});
|
||||
|
||||
it("returns a hex colour per row", () => {
|
||||
const rows = computeGraphRows([commit("a", [])]);
|
||||
expect(rows[0].color).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
});
|
||||
});
|
||||
173
frontend/src/features/git/graphLayout.ts
Normal file
173
frontend/src/features/git/graphLayout.ts
Normal file
@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Pure git-graph lane layout (L8 / git-graph view).
|
||||
*
|
||||
* `computeGraphRows` turns a newest-first commit list into render-ready rows that
|
||||
* a renderer can draw as a GitKraken-style graph: **continuous coloured lanes**
|
||||
* with curved connectors at branch points (forks) and merges.
|
||||
*
|
||||
* ## Model
|
||||
*
|
||||
* We keep an ordered list of "active lanes": `lanes[i]` is the hash of the commit
|
||||
* we expect next on lane `i` (or `null` when the lane is free). A lane index is a
|
||||
* stable column; we never re-index existing lanes (a freed lane is reused in
|
||||
* place), so a branch that doesn't bend draws a **straight vertical line**.
|
||||
*
|
||||
* Each row is split into two halves around the commit node (drawn at mid-height):
|
||||
* - `incoming` links go from the **top** boundary to the node's mid level,
|
||||
* - `outgoing` links go from the node's mid level to the **bottom** boundary.
|
||||
*
|
||||
* Straight links (`fromLane === toLane`) are pass-through lane lines; bent links
|
||||
* are the fork/merge connectors. Because lane x-positions are identical across
|
||||
* rows, a row's `outgoing` meets the next row's `incoming` at the shared
|
||||
* boundary, yielding unbroken lanes top-to-bottom.
|
||||
*/
|
||||
|
||||
import type { GraphCommit } from "@/domain";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A line segment within one half of a row. */
|
||||
export interface GraphLink {
|
||||
/** Lane index at the start of the segment. */
|
||||
fromLane: number;
|
||||
/** Lane index at the end of the segment. */
|
||||
toLane: number;
|
||||
/** Stroke colour. */
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface GraphRow {
|
||||
commit: GraphCommit;
|
||||
/** Lane (column) of this commit's node. */
|
||||
lane: number;
|
||||
/** Node colour (its lane's colour). */
|
||||
color: string;
|
||||
/** Links in the top half: top boundary → node mid. */
|
||||
incoming: GraphLink[];
|
||||
/** Links in the bottom half: node mid → bottom boundary. */
|
||||
outgoing: GraphLink[];
|
||||
/** Number of lane columns occupied around this row (for SVG width). */
|
||||
laneCount: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Colour palette (dark-theme-friendly)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const LANE_COLORS: readonly string[] = [
|
||||
"#4f9cf9", // blue
|
||||
"#a78bfa", // violet
|
||||
"#34d399", // emerald
|
||||
"#fbbf24", // amber
|
||||
"#f87171", // rose
|
||||
"#38bdf8", // sky
|
||||
"#fb923c", // orange
|
||||
"#c084fc", // purple
|
||||
"#f472b6", // pink
|
||||
"#2dd4bf", // teal
|
||||
];
|
||||
|
||||
/** Stable colour for a lane index. */
|
||||
export function laneColor(laneIndex: number): string {
|
||||
return LANE_COLORS[laneIndex % LANE_COLORS.length];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core algorithm
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Index of the first free (null) lane, or -1. */
|
||||
function firstFree(lanes: (string | null)[]): number {
|
||||
return lanes.indexOf(null);
|
||||
}
|
||||
|
||||
/** Places `hash` on a lane: an existing free slot if any, else a new column. */
|
||||
function allocLane(lanes: (string | null)[], hash: string): number {
|
||||
const free = firstFree(lanes);
|
||||
if (free !== -1) {
|
||||
lanes[free] = hash;
|
||||
return free;
|
||||
}
|
||||
lanes.push(hash);
|
||||
return lanes.length - 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns lanes + links to each commit.
|
||||
*
|
||||
* @param commits Commits ordered **newest-first** (as `git log`).
|
||||
*/
|
||||
export function computeGraphRows(commits: GraphCommit[]): GraphRow[] {
|
||||
// lanes[i] = hash expected next on lane i (null = free).
|
||||
const lanes: (string | null)[] = [];
|
||||
const rows: GraphRow[] = [];
|
||||
|
||||
for (const commit of commits) {
|
||||
const { hash, parents } = commit;
|
||||
const top = lanes.slice();
|
||||
|
||||
// ── Resolve the commit's lane ────────────────────────────────────────
|
||||
let commitLane = lanes.indexOf(hash);
|
||||
if (commitLane === -1) {
|
||||
// A tip nobody pointed at yet: open a lane for it.
|
||||
commitLane = allocLane(lanes, hash);
|
||||
}
|
||||
|
||||
// Other lanes also expecting this hash converge into the node and end.
|
||||
for (let i = 0; i < top.length; i++) {
|
||||
if (i !== commitLane && top[i] === hash) lanes[i] = null;
|
||||
}
|
||||
|
||||
// ── Route parents ────────────────────────────────────────────────────
|
||||
const parentLanes: number[] = [];
|
||||
if (parents.length === 0) {
|
||||
lanes[commitLane] = null; // root: lane ends here.
|
||||
} else {
|
||||
lanes[commitLane] = parents[0]; // first parent keeps the lane.
|
||||
parentLanes.push(commitLane);
|
||||
for (let k = 1; k < parents.length; k++) {
|
||||
const ph = parents[k];
|
||||
const existing = lanes.indexOf(ph);
|
||||
parentLanes.push(existing !== -1 ? existing : allocLane(lanes, ph));
|
||||
}
|
||||
}
|
||||
|
||||
const bottom = lanes.slice();
|
||||
|
||||
// ── Top half: every active top lane → node mid ───────────────────────
|
||||
const incoming: GraphLink[] = [];
|
||||
for (let i = 0; i < top.length; i++) {
|
||||
if (top[i] === null) continue;
|
||||
if (i === commitLane || top[i] === hash) {
|
||||
// Into the node (straight if i === commitLane, a converging curve else).
|
||||
incoming.push({ fromLane: i, toLane: commitLane, color: laneColor(i) });
|
||||
} else {
|
||||
incoming.push({ fromLane: i, toLane: i, color: laneColor(i) }); // pass-through
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bottom half: node mid → every active bottom lane ─────────────────
|
||||
const outgoing: GraphLink[] = [];
|
||||
for (let j = 0; j < bottom.length; j++) {
|
||||
if (bottom[j] === null) continue;
|
||||
if (parentLanes.includes(j)) {
|
||||
outgoing.push({ fromLane: commitLane, toLane: j, color: laneColor(j) }); // node → parent
|
||||
} else {
|
||||
outgoing.push({ fromLane: j, toLane: j, color: laneColor(j) }); // pass-through
|
||||
}
|
||||
}
|
||||
|
||||
rows.push({
|
||||
commit,
|
||||
lane: commitLane,
|
||||
color: laneColor(commitLane),
|
||||
incoming,
|
||||
outgoing,
|
||||
laneCount: Math.max(top.length, bottom.length, commitLane + 1),
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
6
frontend/src/features/git/index.ts
Normal file
6
frontend/src/features/git/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export { GitPanel } from "./GitPanel";
|
||||
export type { GitPanelProps } from "./GitPanel";
|
||||
export { useGit } from "./useGit";
|
||||
export type { GitViewModel } from "./useGit";
|
||||
export { GitGraphView } from "./GitGraphView";
|
||||
export type { GitGraphViewProps } from "./GitGraphView";
|
||||
167
frontend/src/features/git/useGit.ts
Normal file
167
frontend/src/features/git/useGit.ts
Normal file
@ -0,0 +1,167 @@
|
||||
/**
|
||||
* `useGit` — view-model hook for the Git feature (L8).
|
||||
*
|
||||
* Owns the git feature state for a given project and exposes the actions the
|
||||
* UI triggers. Consumes {@link GitGateway} exclusively through `useGateways().git`;
|
||||
* never touches `invoke()` or `@tauri-apps/api`.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { GitBranches, GitCommit, GitFileStatus, GatewayError } from "@/domain";
|
||||
import { useGateways } from "@/app/di";
|
||||
|
||||
/** What the Git UI needs from this hook. */
|
||||
export interface GitViewModel {
|
||||
/** Files in the working tree (staged + unstaged). */
|
||||
files: GitFileStatus[];
|
||||
/** Branch info: list + current. */
|
||||
branches: GitBranches;
|
||||
/** Recent commit log. */
|
||||
log: GitCommit[];
|
||||
/** Whether a request is in flight. */
|
||||
busy: boolean;
|
||||
/** Last error message, or `null`. */
|
||||
error: string | null;
|
||||
/** Refresh all git state. */
|
||||
refresh: () => Promise<void>;
|
||||
/** Stage a file. */
|
||||
stage: (path: string) => Promise<void>;
|
||||
/** Unstage a file. */
|
||||
unstage: (path: string) => Promise<void>;
|
||||
/** Commit staged files with the given message. */
|
||||
commit: (message: string) => Promise<void>;
|
||||
/** Checkout (or create) a branch. */
|
||||
checkout: (branch: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function describeError(e: unknown): string {
|
||||
if (e && typeof e === "object" && "message" in e) {
|
||||
return String((e as GatewayError).message);
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
|
||||
const EMPTY_BRANCHES: GitBranches = { branches: [], current: null };
|
||||
const LOG_LIMIT = 20;
|
||||
|
||||
export function useGit(projectId: string): GitViewModel {
|
||||
const { git } = useGateways();
|
||||
|
||||
const [files, setFiles] = useState<GitFileStatus[]>([]);
|
||||
const [branches, setBranches] = useState<GitBranches>(EMPTY_BRANCHES);
|
||||
const [log, setLog] = useState<GitCommit[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [fileList, branchInfo, commitLog] = await Promise.all([
|
||||
git.status(projectId),
|
||||
git.branches(projectId),
|
||||
git.log(projectId, LOG_LIMIT),
|
||||
]);
|
||||
setFiles(fileList);
|
||||
setBranches(branchInfo);
|
||||
setLog(commitLog);
|
||||
} catch (e) {
|
||||
setError(describeError(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [git, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const stage = useCallback(
|
||||
async (path: string) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await git.stage(projectId, path);
|
||||
setFiles((prev) =>
|
||||
prev.map((f) => (f.path === path ? { ...f, staged: true } : f)),
|
||||
);
|
||||
} catch (e) {
|
||||
setError(describeError(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[git, projectId],
|
||||
);
|
||||
|
||||
const unstage = useCallback(
|
||||
async (path: string) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await git.unstage(projectId, path);
|
||||
setFiles((prev) =>
|
||||
prev.map((f) => (f.path === path ? { ...f, staged: false } : f)),
|
||||
);
|
||||
} catch (e) {
|
||||
setError(describeError(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[git, projectId],
|
||||
);
|
||||
|
||||
const commit = useCallback(
|
||||
async (message: string) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const newCommit = await git.commit(projectId, message);
|
||||
// Remove committed (staged) files from the list.
|
||||
setFiles((prev) => prev.filter((f) => !f.staged));
|
||||
setLog((prev) => [newCommit, ...prev].slice(0, LOG_LIMIT));
|
||||
} catch (e) {
|
||||
setError(describeError(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[git, projectId],
|
||||
);
|
||||
|
||||
const checkout = useCallback(
|
||||
async (branch: string) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await git.checkout(projectId, branch);
|
||||
setBranches((prev) => ({
|
||||
branches: prev.branches.includes(branch)
|
||||
? prev.branches
|
||||
: [...prev.branches, branch],
|
||||
current: branch,
|
||||
}));
|
||||
} catch (e) {
|
||||
setError(describeError(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[git, projectId],
|
||||
);
|
||||
|
||||
return {
|
||||
files,
|
||||
branches,
|
||||
log,
|
||||
busy,
|
||||
error,
|
||||
refresh,
|
||||
stage,
|
||||
unstage,
|
||||
commit,
|
||||
checkout,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user