Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
296 lines
9.0 KiB
TypeScript
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 & 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>
|
|
);
|
|
}
|