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

296 lines
9.0 KiB
TypeScript

/**
* `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>
);
}