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:
210
frontend/src/features/layout/LayoutTabs.tsx
Normal file
210
frontend/src/features/layout/LayoutTabs.tsx
Normal file
@ -0,0 +1,210 @@
|
||||
/**
|
||||
* `LayoutTabs` — named-layout tab bar rendered above the terminal grid (L4 / #4).
|
||||
*
|
||||
* - Shows one tab per named layout (reuses the shared `Tabs` component).
|
||||
* - `+` button: offers a dropdown to create a "Terminal" or "Git graph" layout.
|
||||
* - Double-click a tab label: in-place rename.
|
||||
* - `×` button: closes the layout (disabled when it is the last one).
|
||||
* - Switching tabs calls `setActiveLayout` via `useLayouts.setActive`.
|
||||
* - Tabs for `gitGraph` layouts show a small "⎇" badge.
|
||||
*
|
||||
* Pure presentation; all state lives in the `useLayouts` hook.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { LayoutInfo } from "@/domain";
|
||||
import { cn } from "@/shared";
|
||||
import { useLayouts } from "./useLayouts";
|
||||
|
||||
interface LayoutTabsProps {
|
||||
projectId: string;
|
||||
/**
|
||||
* Called whenever the active layout changes (and on initial load) with the
|
||||
* full active {@link LayoutInfo} — so the parent knows both its id (to re-key
|
||||
* the grid) **and its kind** (to switch between the terminal grid and the git
|
||||
* graph view). This is the single source of truth: the parent must NOT keep a
|
||||
* separate `useLayouts` instance, or its `kind` would go stale.
|
||||
*/
|
||||
onActiveLayoutChange: (info: LayoutInfo) => void;
|
||||
}
|
||||
|
||||
export function LayoutTabs({ projectId, onActiveLayoutChange }: LayoutTabsProps) {
|
||||
const vm = useLayouts(projectId);
|
||||
|
||||
// Propagate the active layout (id + kind) to the parent on every change.
|
||||
useEffect(() => {
|
||||
const info = vm.layouts.find((l) => l.id === vm.activeId);
|
||||
if (info) onActiveLayoutChange(info);
|
||||
}, [vm.activeId, vm.layouts, onActiveLayoutChange]);
|
||||
// Track which tab is being renamed (null = none).
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const renameInputRef = useRef<HTMLInputElement | null>(null);
|
||||
// Show/hide the create-kind dropdown.
|
||||
const [showCreateMenu, setShowCreateMenu] = useState(false);
|
||||
|
||||
async function handleSelect(id: string) {
|
||||
// The effect above propagates the new active layout (id + kind) to the parent.
|
||||
await vm.setActive(id);
|
||||
}
|
||||
|
||||
async function handleClose(id: string) {
|
||||
if (vm.layouts.length <= 1) return;
|
||||
await vm.deleteLayout(id);
|
||||
// The hook updates activeId; the effect propagates it to the parent.
|
||||
}
|
||||
|
||||
function startRename(id: string, currentName: string) {
|
||||
setRenamingId(id);
|
||||
setRenameValue(currentName);
|
||||
// Focus the input on next tick once it's rendered.
|
||||
setTimeout(() => renameInputRef.current?.focus(), 0);
|
||||
}
|
||||
|
||||
async function commitRename() {
|
||||
if (!renamingId) return;
|
||||
const trimmed = renameValue.trim();
|
||||
if (trimmed) await vm.rename(renamingId, trimmed);
|
||||
setRenamingId(null);
|
||||
setRenameValue("");
|
||||
}
|
||||
|
||||
async function handleCreate(kind: "terminal" | "gitGraph") {
|
||||
setShowCreateMenu(false);
|
||||
const defaultName = kind === "gitGraph" ? "Git Graph" : "Layout";
|
||||
const name = window.prompt("New layout name:", defaultName);
|
||||
if (!name?.trim()) return;
|
||||
const newId = await vm.create(name.trim(), kind);
|
||||
if (newId) {
|
||||
// Make it active; the effect propagates the new active layout (with its
|
||||
// `kind`) to the parent, which then renders the right main view.
|
||||
await vm.setActive(newId);
|
||||
}
|
||||
}
|
||||
|
||||
if (vm.layouts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="layout-tabs"
|
||||
className="flex shrink-0 items-center gap-1 border-b border-border bg-surface px-2 py-1"
|
||||
>
|
||||
{vm.layouts.map((tab) => {
|
||||
const isActive = tab.id === vm.activeId;
|
||||
const isRenaming = tab.id === renamingId;
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={cn(
|
||||
"group flex items-center gap-1 rounded-md border px-1 transition-colors",
|
||||
isActive
|
||||
? "border-border-strong bg-raised"
|
||||
: "border-transparent hover:bg-raised",
|
||||
)}
|
||||
>
|
||||
{isRenaming ? (
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onBlur={() => void commitRename()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void commitRename();
|
||||
if (e.key === "Escape") {
|
||||
setRenamingId(null);
|
||||
setRenameValue("");
|
||||
}
|
||||
}}
|
||||
aria-label={`rename layout ${tab.name}`}
|
||||
className="w-24 rounded border border-border bg-base px-1 py-0.5 text-sm text-content focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
onClick={() => void handleSelect(tab.id)}
|
||||
onDoubleClick={() => startRename(tab.id, tab.name)}
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-2 py-1 text-sm focus-visible:outline-none",
|
||||
isActive
|
||||
? "font-semibold text-content"
|
||||
: "text-muted hover:text-content",
|
||||
)}
|
||||
>
|
||||
{tab.kind === "gitGraph" && (
|
||||
<span
|
||||
aria-label="git graph layout"
|
||||
className="text-xs text-primary"
|
||||
title="Git Graph"
|
||||
>
|
||||
⎇
|
||||
</span>
|
||||
)}
|
||||
{tab.name}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`delete layout ${tab.name}`}
|
||||
disabled={vm.layouts.length <= 1}
|
||||
onClick={() => void handleClose(tab.id)}
|
||||
className={cn(
|
||||
"flex h-4 w-4 items-center justify-center rounded text-xs transition-opacity",
|
||||
vm.layouts.length <= 1
|
||||
? "cursor-not-allowed opacity-20"
|
||||
: "opacity-60 hover:opacity-100 group-hover:opacity-100",
|
||||
)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Create new layout — dropdown to choose kind */}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="create layout"
|
||||
onClick={() => setShowCreateMenu((v) => !v)}
|
||||
className="flex h-6 w-6 items-center justify-center rounded border border-transparent text-sm text-muted transition-colors hover:border-border hover:text-content"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
{showCreateMenu && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute left-0 top-full z-20 mt-1 flex min-w-max flex-col rounded-md border border-border bg-surface shadow-lg"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-label="create terminal layout"
|
||||
onClick={() => void handleCreate("terminal")}
|
||||
className="px-3 py-2 text-left text-sm text-content hover:bg-raised"
|
||||
>
|
||||
Terminal
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-label="create git graph layout"
|
||||
onClick={() => void handleCreate("gitGraph")}
|
||||
className="px-3 py-2 text-left text-sm text-content hover:bg-raised"
|
||||
>
|
||||
⎇ Git graph
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{vm.error && (
|
||||
<span className="ml-2 text-xs text-danger" role="alert">
|
||||
{vm.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user