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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user