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.
This commit is contained in:
2026-09-11 21:53:56 +02:00
parent cd744b5f18
commit 1a1429f10e
157 changed files with 4531 additions and 3129 deletions

View File

@ -0,0 +1,93 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { useGateways } from "@/app/di";
import type { IdeaLanguage } from "@/ports";
import { catalogs, type TranslationKey } from "./catalog";
const DEFAULT_LANGUAGE: IdeaLanguage = "fr";
export const IDEA_LANGUAGE_CHANGED_EVENT = "idea:language-changed";
function currentFallbackLanguage(): IdeaLanguage {
const testLanguage = (globalThis as typeof globalThis & {
__IDEA_TEST_LANGUAGE__?: IdeaLanguage;
}).__IDEA_TEST_LANGUAGE__;
return testLanguage === "en" || testLanguage === "fr"
? testLanguage
: DEFAULT_LANGUAGE;
}
interface I18nValue {
language: IdeaLanguage;
setLanguage(language: IdeaLanguage): Promise<void>;
t(key: TranslationKey): string;
/** A parity-safe bilingual message for copy local to one surface. */
tr(french: string, english: string): string;
}
const fallback: I18nValue = {
get language() {
return currentFallbackLanguage();
},
setLanguage: async () => undefined,
t: (key) => catalogs[currentFallbackLanguage()][key],
// Components are occasionally rendered without the provider in focused
// unit tests and embedders. Keep both translation APIs on the same default
// language so a surface can never render a mixture of French and English.
tr: (french, english) => currentFallbackLanguage() === "fr" ? french : english,
};
const I18nContext = createContext<I18nValue>(fallback);
export function I18nProvider({ children }: { children: ReactNode }) {
const { userSettings } = useGateways();
const [language, setLanguageState] = useState<IdeaLanguage>(DEFAULT_LANGUAGE);
useEffect(() => {
let cancelled = false;
void userSettings.readUserSettings().then((settings) => {
if (!cancelled) setLanguageState(settings.language ?? DEFAULT_LANGUAGE);
}).catch(() => undefined);
const onChanged = (event: Event) => {
const next = (event as CustomEvent<IdeaLanguage>).detail;
if (next === "fr" || next === "en") setLanguageState(next);
};
window.addEventListener(IDEA_LANGUAGE_CHANGED_EVENT, onChanged);
return () => {
cancelled = true;
window.removeEventListener(IDEA_LANGUAGE_CHANGED_EVENT, onChanged);
};
}, [userSettings]);
const setLanguage = useCallback(async (next: IdeaLanguage) => {
const previous = language;
setLanguageState(next);
window.dispatchEvent(new CustomEvent(IDEA_LANGUAGE_CHANGED_EVENT, { detail: next }));
try {
const saved = await userSettings.updateUserSettings({ language: next });
const confirmed = saved.language ?? DEFAULT_LANGUAGE;
setLanguageState(confirmed);
window.dispatchEvent(new CustomEvent(IDEA_LANGUAGE_CHANGED_EVENT, { detail: confirmed }));
} catch (error) {
setLanguageState(previous);
window.dispatchEvent(new CustomEvent(IDEA_LANGUAGE_CHANGED_EVENT, { detail: previous }));
throw error;
}
}, [language, userSettings]);
const value = useMemo<I18nValue>(() => ({
language,
setLanguage,
t: (key) => catalogs[language][key],
tr: (french, english) => language === "fr" ? french : english,
}), [language, setLanguage]);
useEffect(() => {
document.documentElement.lang = language;
}, [language]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useI18n(): I18nValue {
return useContext(I18nContext);
}

View File

@ -0,0 +1,197 @@
export const fr = {
"common.apply": "Appliquer",
"common.cancel": "Annuler",
"common.close": "Fermer",
"common.loading": "Chargement…",
"common.none": "Aucun",
"common.remove": "Supprimer",
"common.retry": "Réessayer",
"common.search": "Rechercher",
"common.settings": "Paramètres",
"settings.general": "Général",
"settings.aiProfiles": "Profils IA",
"settings.terminal": "Terminal",
"settings.deployment": "Déploiement",
"settings.devices": "Appareils",
"settings.plugins": "Plugins",
"settings.sections": "sections des paramètres",
"settings.close": "Fermer les paramètres",
"settings.language.title": "Langue",
"settings.language.legend": "Langue de l’interface",
"settings.language.help": "Le changement s’applique immédiatement, sans redémarrer IdeA.",
"settings.language.fr": "Français",
"settings.language.en": "English",
"settings.language.loadError": "Impossible de charger la langue de l’interface.",
"settings.language.saveError": "Impossible d’enregistrer la langue de l’interface.",
"settings.terminal.default": "Interface de terminal par défaut",
"settings.terminal.help": "Utilisée à l'ouverture d'un nouveau terminal. Les terminaux déjà ouverts ne sont pas modifiés.",
"settings.terminal.loadError": "Impossible de charger la préférence de terminal.",
"settings.terminal.saveError": "Impossible d’enregistrer la préférence de terminal.",
"project.appearance": "Apparence",
"project.color": "Couleur du projet",
"project.color.reset": "Rétablir la couleur par défaut",
"project.color.error": "Impossible d’appliquer la couleur.",
"project.settings.loadError": "Impossible de charger les paramètres du projet.",
"project.context": "Contexte du projet",
"project.icon": "Icône du projet",
"project.icon.choose": "Choisir une icône",
"project.icon.change": "Modifier l’icône",
"project.icon.remove": "Retirer l’icône",
"project.icon.search": "Search icons in English",
"project.icon.results": "Icônes disponibles",
"project.icon.noResults": "Aucune icône ne correspond à cette recherche.",
"project.icon.error": "Impossible d’appliquer l’icône.",
"project.icon.picker": "Choisir l’icône du projet",
"custom.unavailable.title": "CLI custom indisponible",
"custom.unavailable.body": "Cette cellule reste configurée en CLI custom. Vérifiez le profil de l’agent ou utilisez temporairement la TUI native.",
"custom.openNative": "Ouvrir temporairement la TUI native",
"menu.panels": "Panneaux",
"menu.settings": "Paramètres",
"placement.closed": "Fermé",
"placement.floating": "Flottant",
"placement.left": "Ancré à gauche",
"placement.right": "Ancré à droite",
"placement.detached": "Fenêtre détachée",
"project.new": "Nouveau projet",
"project.name": "nom du projet",
"project.root": "racine du projet",
"project.browse": "parcourir le dossier du projet",
"project.create": "Créer le projet",
"project.refresh": "Rafraîchir",
"project.known": "Projets connus",
"project.empty": "Aucun projet.",
"project.open": "Ouvrir",
"panel.projects": "Projets",
"panel.projectSettings": "Paramètres du projet",
"panel.work": "Travail",
"panel.scheduledTasks": "Tâches planifiées",
"panel.tickets": "Tickets",
"panel.agents": "Agents",
"panel.templates": "Modèles",
"panel.skills": "Skills",
"panel.mcp": "MCP",
"panel.permissions": "Permissions",
"panel.memory": "Mémoire",
"panel.git": "Git",
"panel.context": "Contexte",
"custom.actions": "custom agent chat actions",
"custom.unsupported": "CLI custom indisponible pour ce profil ou ce transport. Utilisez la TUI native.",
"custom.startFailure": "Le CLI custom n'a pas pu démarrer.",
"custom.openTui": "Ouvrir en TUI native",
"custom.profile.change": "Changer de profil",
"custom.profile.warning": "Valider ce changement va réinitialiser la session courante. L'historique de cette CLI custom sera perdu.",
"custom.profile": "Profil",
"custom.resume": "Reprise en cours...",
"custom.opening": "Ouverture de la session structurée…",
"custom.empty": "Envoyez un message pour démarrer la conversation structurée.",
"custom.attachment": "Fichier joint",
"custom.removeAttachment": "Retirer",
"custom.slashSuggestions": "suggestions commandes slash",
"custom.message": "Message à l'agent…",
"custom.resize": "Redimensionner le composer",
"custom.attach": "Joindre",
"custom.stop": "Stop",
"custom.stopping": "Stopping...",
"custom.send": "Envoyer",
} as const;
export type TranslationKey = keyof typeof fr;
export const en: Record<TranslationKey, string> = {
"common.apply": "Apply",
"common.cancel": "Cancel",
"common.close": "Close",
"common.loading": "Loading…",
"common.none": "None",
"common.remove": "Remove",
"common.retry": "Retry",
"common.search": "Search",
"common.settings": "Settings",
"settings.general": "General",
"settings.aiProfiles": "AI profiles",
"settings.terminal": "Terminal",
"settings.deployment": "Deployment",
"settings.devices": "Devices",
"settings.plugins": "Plugins",
"settings.sections": "Settings sections",
"settings.close": "Close settings",
"settings.language.title": "Language",
"settings.language.legend": "Interface language",
"settings.language.help": "The change applies immediately, without restarting IdeA.",
"settings.language.fr": "Français",
"settings.language.en": "English",
"settings.language.loadError": "Unable to load the interface language.",
"settings.language.saveError": "Unable to save the interface language.",
"settings.terminal.default": "Default terminal interface",
"settings.terminal.help": "Used when opening a new terminal. Existing terminals are not changed.",
"settings.terminal.loadError": "Unable to load the terminal preference.",
"settings.terminal.saveError": "Unable to save the terminal preference.",
"project.appearance": "Appearance",
"project.color": "Project color",
"project.color.reset": "Restore default color",
"project.color.error": "Unable to apply the color.",
"project.settings.loadError": "Unable to load project settings.",
"project.context": "Project context",
"project.icon": "Project icon",
"project.icon.choose": "Choose an icon",
"project.icon.change": "Change icon",
"project.icon.remove": "Remove icon",
"project.icon.search": "Search icons in English",
"project.icon.results": "Available icons",
"project.icon.noResults": "No icon matches this search.",
"project.icon.error": "Unable to apply the icon.",
"project.icon.picker": "Choose the project icon",
"custom.unavailable.title": "Custom CLI unavailable",
"custom.unavailable.body": "This cell remains configured for the custom CLI. Check the agent profile or temporarily use the native TUI.",
"custom.openNative": "Temporarily open the native TUI",
"menu.panels": "Panels",
"menu.settings": "Settings",
"placement.closed": "Closed",
"placement.floating": "Floating",
"placement.left": "Docked left",
"placement.right": "Docked right",
"placement.detached": "Detached window",
"project.new": "New project",
"project.name": "Project name",
"project.root": "Project root",
"project.browse": "Browse…",
"project.create": "Create project",
"project.refresh": "Refresh",
"project.known": "Known projects",
"project.empty": "No projects yet.",
"project.open": "Open",
"panel.projects": "Projects",
"panel.projectSettings": "Project settings",
"panel.work": "Work",
"panel.scheduledTasks": "Scheduled tasks",
"panel.tickets": "Tickets",
"panel.agents": "Agents",
"panel.templates": "Templates",
"panel.skills": "Skills",
"panel.mcp": "MCP",
"panel.permissions": "Permissions",
"panel.memory": "Memory",
"panel.git": "Git",
"panel.context": "Context",
"custom.actions": "Custom CLI actions",
"custom.unsupported": "The custom CLI is unavailable for this profile or transport. Use the native TUI.",
"custom.startFailure": "The custom CLI could not start.",
"custom.openTui": "Open in native TUI",
"custom.profile.change": "Change profile",
"custom.profile.warning": "Confirming this change resets the current session. This custom CLI history will be lost.",
"custom.profile": "Profile",
"custom.resume": "Resuming…",
"custom.opening": "Opening structured session…",
"custom.empty": "Send a message to start the structured conversation.",
"custom.attachment": "Attached file",
"custom.removeAttachment": "Remove",
"custom.slashSuggestions": "Slash command suggestions",
"custom.message": "Message the agent…",
"custom.resize": "Resize composer",
"custom.attach": "Attach",
"custom.stop": "Stop",
"custom.stopping": "Stopping…",
"custom.send": "Send",
};
export const catalogs = { fr, en } as const;

View File

@ -0,0 +1,37 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { createMockGateways } from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { GeneralSettings } from "@/features/settings/GeneralSettings";
import { I18nProvider, useI18n } from "./I18nProvider";
import { en, fr } from "./catalog";
describe("FR/EN localization", () => {
it("keeps exact catalog parity", () => {
expect(Object.keys(en).sort()).toEqual(Object.keys(fr).sort());
});
it("switches visible labels immediately and persists the wire language", async () => {
const gateways = createMockGateways();
const update = vi.spyOn(gateways.userSettings, "updateUserSettings");
function Probe() {
const { t, tr } = useI18n();
return <><span>{t("settings.close")}</span><span>{tr("Surface locale", "Local surface")}</span></>;
}
render(<DIProvider gateways={gateways}><I18nProvider><GeneralSettings /><Probe /></I18nProvider></DIProvider>);
expect(await screen.findByText("Fermer les paramètres")).toBeTruthy();
expect(screen.getByText("Surface locale")).toBeTruthy();
fireEvent.click(screen.getByRole("radio", { name: "English" }));
expect(await screen.findByText("Close settings")).toBeTruthy();
expect(screen.getByText("Local surface")).toBeTruthy();
await waitFor(() => expect(update).toHaveBeenCalledWith({ language: "en" }));
expect(document.documentElement.lang).toBe("en");
});
it("never renders translation keys on migrated settings surfaces", async () => {
const gateways = createMockGateways();
render(<DIProvider gateways={gateways}><I18nProvider><GeneralSettings /></I18nProvider></DIProvider>);
await screen.findByText("Langue de l’interface");
expect(document.body.textContent).not.toMatch(/settings\.[a-z]/);
});
});

View File

@ -0,0 +1,2 @@
export { I18nProvider, IDEA_LANGUAGE_CHANGED_EVENT, useI18n } from "./I18nProvider";
export { catalogs, en, fr, type TranslationKey } from "./catalog";

View File

@ -0,0 +1,54 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
const migratedSurfaces = [
"features/layout/LayoutGrid.tsx",
"features/settings/DeploymentSettings.tsx",
"features/permissions/McpToolPermissionsPanel.tsx",
"features/scheduled-tasks/ScheduledTasksPanel.tsx",
"features/projects/ProjectsView.tsx",
"features/model-servers/ModelServersPanel.tsx",
"features/agents/AgentsPanel.tsx",
"features/plugins/PluginsPanel.tsx",
"features/agents/CustomAgentChatView.tsx",
"features/web/WebWorkspace.tsx",
"features/permissions/PermissionsPanel.tsx",
"features/web/tickets/WebTicketDetail.tsx",
"features/first-run/OpenCodeModeFields.tsx",
"features/first-run/ProfilesSettings.tsx",
"features/projects/ProjectActionsMenu.tsx",
"features/projects/RemoveProjectDialog.tsx",
"features/plugins/PluginConfirmDialog.tsx",
"features/devices/ConfirmDialog.tsx",
"features/web/tickets/WebConfirmDialog.tsx",
"shared/ui/Tabs.tsx",
"shared/ui/FloatingWindow.tsx",
"shared/ui/DockRegion.tsx",
] as const;
const migratedTextNodeSurfaces = [
"features/skills/SkillsPanel.tsx",
] as const;
// These examples are protocol/model/path/command values. They must stay literal:
// translating them would make the examples invalid rather than localizing UI.
const technicalPlaceholder = /^(?:https?:\/\/|\/|--|\d+$|(?:unsloth\/)?[\w.-]+(?::[\w.-]+)?$)/;
const rawProductHeading = /<h[1-6][^>]*>\s*(Plugins|Skills)\s*<\/h[1-6]>/g;
describe("visible source localization guard", () => {
it.each(migratedSurfaces)("has no raw visible aria/title copy in %s", (relative) => {
const source = readFileSync(resolve(process.cwd(), "src", relative), "utf8");
const raw = [...source.matchAll(/\b(aria-label|title|placeholder)="([^"]+)"/g)]
.map((match) => ({ prop: match[1], value: match[2] }))
.filter(({ prop, value }) => prop !== "placeholder" || !technicalPlaceholder.test(value));
expect(raw).toEqual([]);
});
it.each(migratedTextNodeSurfaces)("has no raw product text nodes in %s", (relative) => {
const source = readFileSync(resolve(process.cwd(), "src", relative), "utf8");
const raw = [...source.matchAll(rawProductHeading)].map((match) => match[1].trim());
expect(raw).toEqual([]);
});
});