Files
IdeA/frontend/src/features/layout/LayoutTabs.tsx
Blomios 1a1429f10e feat(domain,application,infrastructure,backend,web-server,app-tauri,frontend): lot utilisateur #325 #326 — icônes projet avec watermark CLI custom et localisation FR/EN (QA verte)
Lot en un seul commit: les deux features partagent les mêmes contrats
(domain/settings.rs, DTO, commandes Tauri/HTTP, web-server) et les mêmes
surfaces UI — la séparation par fichiers était impossible sans chirurgie
de hunks risquant des états intermédiaires non compilables (convention
lot des batches #310-#313 et #315).

#325 — icône configurable par projet, watermark CLI custom:
- domaine: ProjectIconSet (devicon, simple-icons, lucide, idea),
  ProjectIconRef validé (nom ASCII 1..=64, -_. internes), champ icon
  optionnel sur ProjectSettings; sérialisation camelCase et try_from
  strict
- application: UpdateProjectSettings.execute accepte un patch
  theme_color OU icon (absent = inchangé, null = défaut produit), tests
  in-dependants avec store mémoire
- backend/app-tauri/web-server: ProjectIconRefDto, UpdateProjectSettingsRequestDto
  avec icon optionnel (désérialisation additive), commandes
  update_project_settings alignées Tauri/HTTP, tests DTO et HTTP
  (défaut null, set, clear, nom invalide rejeté)
- frontend: catalogue projectIcons (recherche EN), ProjectIconPicker en
  grille multi-lignes dans Paramètres projet, affichage de l'icône dans
  ProjectTabs et watermark grand format à faible opacité en fond de CLI
  custom; ports ProjectSettings.icon, Partial<ProjectSettings>,
  adapters tauri/mock/http et tests alignés

#326 — localisation FR/EN et traduction des textes IdeA:
- domaine: IdeaLanguage (fr | en), champ language optionnel sur
  IdeaUserSettings (absence = défaut produit, série additive validée)
- backend/web-server: UserSettingsDto.language, update_user_settings
  accepte language, tests HTTP alignés; fixtures structured_launch_d3
  mises à jour
- frontend: i18n/ (catalogue FR/EN, I18nProvider, scan anti-régression
  des sources visibles), GeneralSettings avec sélecteur de langue dans
  les paramètres, setupI18n dans vitest; migration de l'ensemble des
  textes hardcodés vers le catalogue sur toutes les surfaces (agents,
  layout, projets, tickets, sprints, web, plugins, permissions, git,
  templates, skills, mémoire, model servers, embedder, first-run,
  devices, scheduled tasks, workstate, dialogs), FR conservé par défaut;
  recherche d'icônes #325 en anglais conformément au ticket

QA verte sur feature/batch-325-326-project-icons-localisation. Aucun push.
2026-09-11 21:53:56 +02:00

237 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* `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 { PluginLayoutSelectorSection, usePluginRuntime } from "@/features/plugins";
import { useLayouts } from "./useLayouts";
import { useI18n } from "@/i18n";
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 { tr } = useI18n();
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);
const { registry: pluginRegistry } = usePluginRuntime();
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);
}
}
async function handleCreatePluginLayout(
name: string,
pluginId: string,
layoutType: string,
) {
setShowCreateMenu(false);
const newId = await vm.create(name, "plugin", { pluginId, layoutType });
if (newId) {
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={tr(`renommer le layout ${tab.name}`, `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={tr("layout du graphe Git", "git graph layout")}
className="text-xs text-primary"
title={tr("Graphe Git", "Git graph")}
>
⎇
</span>
)}
{tab.name}
</button>
)}
<button
type="button"
aria-label={tr(`supprimer le layout ${tab.name}`, `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={tr("créer un layout", "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={tr("créer un layout terminal", "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={tr("créer un layout de graphe Git", "create git graph layout")}
onClick={() => void handleCreate("gitGraph")}
className="px-3 py-2 text-left text-sm text-content hover:bg-raised"
>
⎇ {tr("Graphe Git", "Git graph")}
</button>
<PluginLayoutSelectorSection
registry={pluginRegistry}
onSelect={(choice) =>
void handleCreatePluginLayout(
choice.layout.label,
choice.pluginId,
choice.layout.type,
)
}
/>
</div>
)}
</div>
{vm.error && (
<span className="ml-2 text-xs text-danger" role="alert">
{vm.error}
</span>
)}
</div>
);
}