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:
93
frontend/src/i18n/I18nProvider.tsx
Normal file
93
frontend/src/i18n/I18nProvider.tsx
Normal 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);
|
||||
}
|
||||
197
frontend/src/i18n/catalog.ts
Normal file
197
frontend/src/i18n/catalog.ts
Normal 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;
|
||||
37
frontend/src/i18n/i18n.test.tsx
Normal file
37
frontend/src/i18n/i18n.test.tsx
Normal 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]/);
|
||||
});
|
||||
});
|
||||
2
frontend/src/i18n/index.ts
Normal file
2
frontend/src/i18n/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { I18nProvider, IDEA_LANGUAGE_CHANGED_EVENT, useI18n } from "./I18nProvider";
|
||||
export { catalogs, en, fr, type TranslationKey } from "./catalog";
|
||||
54
frontend/src/i18n/visibleSourceScan.test.ts
Normal file
54
frontend/src/i18n/visibleSourceScan.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user