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