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.
126 lines
3.9 KiB
TypeScript
126 lines
3.9 KiB
TypeScript
/**
|
||
* `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",
|
||
)}
|
||
/>
|
||
);
|
||
}
|