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

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

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

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

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

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

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

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

View 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";

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