Files
IdeA/frontend/src/features/projects/ProjectTabs.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

126 lines
3.9 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* `ProjectTabs` — the project tab bar rendered below the app header.
*
* Shows one tab per open project with a close control, and a "+" button to the
* right that opens the **Projects** panel (floating) — the single entry point
* for creating a project or switching to a known one (#26). Purely
* presentational: all state lives in the parent (ProjectsView via useProjects).
*
* The bar is always present (even with no open project) so the "+" stays
* reachable; the empty case shows a muted placeholder next to it.
*/
import type { TabItem } from "@/shared";
import { Button, Tabs, cn } from "@/shared";
import { useI18n } from "@/i18n";
import type { ProjectActivityStatus } from "./projectActivity";
import { accessibleProjectAccent } from "./projectTheme";
export interface ProjectTabsProps {
items: TabItem[];
activeTabId: string | null;
onSelect: (id: string) => void;
onClose: (id: string) => void;
/** Whether the Projects panel is currently open (in any placement). */
projectsPanelOpen: boolean;
/** Open the Projects panel (floating) — create/switch project. */
onOpenProjectsPanel: () => void;
/** Per-project custom CLI activity, keyed by project id. Missing = none. */
activityByProjectId?: Record<string, ProjectActivityStatus>;
themeColorByProjectId?: Record<string, string | null>;
className?: string;
}
export function ProjectTabs({
items,
activeTabId,
onSelect,
onClose,
projectsPanelOpen,
onOpenProjectsPanel,
activityByProjectId = {},
themeColorByProjectId = {},
className,
}: ProjectTabsProps) {
const { tr } = useI18n();
const tabs = items.map((item) => ({
...item,
themeColor: themeColorByProjectId[item.id] ?? null,
themeAccent: themeColorByProjectId[item.id]
? accessibleProjectAccent(themeColorByProjectId[item.id]!)
: null,
leading: (
<ProjectActivityDot
status={activityByProjectId[item.id] ?? "none"}
label={item.label}
/>
),
}));
return (
<div
className={cn(
"flex shrink-0 items-center gap-1 border-b border-border bg-surface px-2 py-1",
className,
)}
>
{items.length === 0 ? (
<p className="px-2 text-sm text-muted">{tr("Aucun onglet ouvert.", "No open tabs.")}</p>
) : (
<Tabs
items={tabs}
value={activeTabId}
onSelect={onSelect}
onClose={onClose}
/>
)}
{/* Add-project affordance (#46): always a bordered `secondary` button so
it reads as a clickable control in every state — including zero open
projects — instead of the near-invisible `ghost` glyph it used to be.
When the Projects panel is open it also shows the pressed ring. */}
<Button
size="sm"
variant="secondary"
onClick={onOpenProjectsPanel}
aria-label={tr("ouvrir le panneau des projets", "open projects panel")}
aria-pressed={projectsPanelOpen}
title={tr("Ajouter un projet", "Add a project")}
className={cn(
"shrink-0",
projectsPanelOpen && "ring-1 ring-border-strong",
)}
>
+
</Button>
</div>
);
}
function ProjectActivityDot({
status,
label,
}: {
status: ProjectActivityStatus;
label: string;
}) {
const { tr } = useI18n();
const title =
status === "busy"
? tr(`${label} : échange en cours`, `${label}: conversation in progress`)
: tr(`${label} : prêt`, `${label}: ready`);
return (
<span
data-testid={`project-activity-${label}`}
aria-hidden="true"
title={title}
className={cn(
"h-2.5 w-2.5 shrink-0 rounded-full border",
status === "busy" &&
"animate-pulse border-warning bg-warning shadow-[0_0_0_2px_rgba(212,155,58,0.16)]",
status === "idle" && "border-success bg-success",
status === "none" && "border-success bg-success",
)}
/>
);
}