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

@ -9,6 +9,7 @@ import type {
} from "@/domain";
import { useGateways } from "@/app/di";
import { Button, IconButton, Input, SmallDropdown, cn } from "@/shared";
import { useI18n } from "@/i18n";
import { ModelServerSelect } from "@/features/model-servers";
import {
defaultOpenCodeConfig,
@ -84,6 +85,7 @@ export function OpenCodeModeFields({
providerCatalog: OpenCodeProviderCatalog;
onChange: (p: AgentProfile) => void;
}) {
const { tr } = useI18n();
const [mode, setMode] = useState<"local" | "cloud">(
profile.opencodeProvider ? "cloud" : "local",
);
@ -103,13 +105,13 @@ export function OpenCodeModeFields({
<div className="mt-1 flex flex-col gap-2">
<div
role="radiogroup"
aria-label="mode du profil OpenCode"
aria-label={tr("Mode du profil OpenCode", "OpenCode profile mode")}
className="flex w-fit gap-1 rounded-md border border-border bg-raised p-0.5"
>
{(
[
{ id: "local", label: "Local (llama.cpp)" },
{ id: "cloud", label: "Provider cloud" },
{ id: "local", label: tr("Local (llama.cpp)", "Local (llama.cpp)") },
{ id: "cloud", label: tr("Fournisseur cloud", "Cloud provider") },
] as const
).map((seg) => {
const active = mode === seg.id;
@ -177,6 +179,7 @@ function OpenCodeProviderFields({
catalog: OpenCodeProviderCatalog;
onChange: (p: AgentProfile) => void;
}) {
const { tr } = useI18n();
const { profile: profileGateway } = useGateways();
const existing = profile.opencodeProvider;
const [mode, setMode] = useState<"catalog" | "custom">(
@ -217,16 +220,16 @@ function OpenCodeProviderFields({
async function save() {
const errors: CloudFieldErrors = {};
if (providerId.trim().length === 0) {
errors.providerId = "Le provider est obligatoire.";
errors.providerId = tr("Le fournisseur est obligatoire.", "Provider is required.");
}
if (model.trim().length === 0) errors.model = "Le modèle est obligatoire.";
if (model.trim().length === 0) errors.model = tr("Le modèle est obligatoire.", "Model is required.");
if (mode === "custom") {
if (customNpm.trim().length === 0) errors.npm = "Le paquet npm est obligatoire.";
if (customNpm.trim().length === 0) errors.npm = tr("Le paquet npm est obligatoire.", "npm package is required.");
if (customBaseUrl.trim().length === 0) {
errors.baseUrl = "L'URL de base est obligatoire.";
errors.baseUrl = tr("L’URL de base est obligatoire.", "Base URL is required.");
}
}
if (apiKey.length === 0) errors.apiKey = "La clé API est obligatoire.";
if (apiKey.length === 0) errors.apiKey = tr("La clé API est obligatoire.", "API key is required.");
setFieldErrors(errors);
if (Object.keys(errors).length > 0) return;
@ -263,7 +266,7 @@ function OpenCodeProviderFields({
return (
<fieldset className="mt-1 flex flex-col gap-2 rounded-md border border-border/70 p-2">
<legend className="px-1 text-xs font-medium text-muted">
Provider cloud (OpenCode)
{tr("Fournisseur cloud (OpenCode)", "Cloud provider (OpenCode)")}
</legend>
{saveError && (
@ -273,15 +276,15 @@ function OpenCodeProviderFields({
)}
{catalog.loading && (
<p className="text-xs text-faint">Chargement des providers…</p>
<p className="text-xs text-faint">{tr("Chargement des fournisseurs…", "Loading providers…")}</p>
)}
{catalog.error && (
<div className="flex flex-col gap-1">
<p role="alert" className="text-sm text-danger">
Impossible de charger la liste des providers cloud.
{tr("Impossible de charger la liste des fournisseurs cloud.", "Unable to load the cloud provider list.")}
</p>
<Button size="sm" onClick={() => catalog.reload()} className="w-fit">
Réessayer
{tr("Réessayer", "Retry")}
</Button>
</div>
)}
@ -289,19 +292,19 @@ function OpenCodeProviderFields({
{mode === "catalog" && (
<>
<label className="flex flex-col gap-1">
<Caption>Provider</Caption>
<Caption>{tr("Fournisseur", "Provider")}</Caption>
{catalogReady && (
<input
type="text"
aria-label={`${profile.name} provider search`}
placeholder="Rechercher un provider…"
aria-label={tr(`Rechercher un fournisseur pour ${profile.name}`, `Search providers for ${profile.name}`)}
placeholder={tr("Rechercher un fournisseur…", "Search providers…")}
value={providerFilter}
onChange={(e) => setProviderFilter(e.target.value)}
className="h-8 w-full rounded-md border border-border bg-raised px-3 text-xs text-content outline-none"
/>
)}
<SmallDropdown
aria-label={`${profile.name} provider`}
aria-label={tr(`Fournisseur de ${profile.name}`, `Provider for ${profile.name}`)}
value={providerId}
disabled={!catalogReady}
onChange={(v) => {
@ -322,8 +325,8 @@ function OpenCodeProviderFields({
{
value: "",
label: catalog.loading
? "Chargement des providers…"
: "Choisir un provider…",
? tr("Chargement des fournisseurs…", "Loading providers…")
: tr("Choisir un fournisseur…", "Choose a provider…"),
disabled: true,
},
...filteredProviders.map((p) => ({
@ -332,7 +335,7 @@ function OpenCodeProviderFields({
})),
{
value: CUSTOM_PROVIDER_VALUE,
label: "Autre / personnalisé…",
label: tr("Autre / personnalisé…", "Other / custom…"),
},
]}
/>
@ -342,9 +345,9 @@ function OpenCodeProviderFields({
</label>
<label className="flex flex-col gap-1">
<Caption>Modèle</Caption>
<Caption>{tr("Modèle", "Model")}</Caption>
<SmallDropdown
aria-label={`${profile.name} model`}
aria-label={tr(`Modèle de ${profile.name}`, `Model for ${profile.name}`)}
value={model}
disabled={providerId.length === 0}
onChange={(next) => {
@ -357,7 +360,7 @@ function OpenCodeProviderFields({
options={[
{
value: "",
label: providerId.length === 0 ? "—" : "Choisir un modèle…",
label: providerId.length === 0 ? "—" : tr("Choisir un modèle…", "Choose a model…"),
disabled: true,
},
...models.map((m) => ({ value: m, label: m })),
@ -373,7 +376,7 @@ function OpenCodeProviderFields({
{mode === "custom" && (
<fieldset className="flex flex-col gap-2 rounded-md border border-border/50 p-2">
<legend className="px-1 text-xs font-medium text-muted">
Provider personnalisé
{tr("Fournisseur personnalisé", "Custom provider")}
</legend>
<Button
@ -387,15 +390,15 @@ function OpenCodeProviderFields({
setFieldErrors({});
}}
>
← Choisir un provider du catalogue
← {tr("Choisir un fournisseur du catalogue", "Choose a catalog provider")}
</Button>
<label className="flex flex-col gap-1">
<Caption>Identifiant du provider</Caption>
<Caption>{tr("Identifiant du fournisseur", "Provider ID")}</Caption>
<Input
aria-label={`${profile.name} custom provider id`}
aria-label={tr(`Identifiant du fournisseur personnalisé de ${profile.name}`, `Custom provider ID for ${profile.name}`)}
value={providerId}
placeholder="ex. mon-provider"
placeholder={tr("ex. mon-fournisseur", "e.g. my-provider")}
invalid={Boolean(fieldErrors.providerId)}
onChange={(e) => {
setProviderId(e.target.value);
@ -408,9 +411,9 @@ function OpenCodeProviderFields({
</label>
<label className="flex flex-col gap-1">
<Caption>Paquet npm</Caption>
<Caption>{tr("Paquet npm", "npm package")}</Caption>
<Input
aria-label={`${profile.name} custom npm package`}
aria-label={tr(`Paquet npm personnalisé de ${profile.name}`, `Custom npm package for ${profile.name}`)}
value={customNpm}
placeholder={DEFAULT_CUSTOM_NPM}
invalid={Boolean(fieldErrors.npm)}
@ -425,11 +428,11 @@ function OpenCodeProviderFields({
</label>
<label className="flex flex-col gap-1">
<Caption>URL de base</Caption>
<Caption>{tr("URL de base", "Base URL")}</Caption>
<Input
aria-label={`${profile.name} custom base url`}
aria-label={tr(`URL de base personnalisée de ${profile.name}`, `Custom base URL for ${profile.name}`)}
value={customBaseUrl}
placeholder="https://api.mon-provider.example/v1"
placeholder="https://api.my-provider.example/v1"
invalid={Boolean(fieldErrors.baseUrl)}
onChange={(e) => {
setCustomBaseUrl(e.target.value);
@ -442,11 +445,11 @@ function OpenCodeProviderFields({
</label>
<label className="flex flex-col gap-1">
<Caption>Modèle</Caption>
<Caption>{tr("Modèle", "Model")}</Caption>
<Input
aria-label={`${profile.name} model`}
aria-label={tr(`Modèle de ${profile.name}`, `Model for ${profile.name}`)}
value={model}
placeholder="ex. mon-modele-1"
placeholder={tr("ex. mon-modele-1", "e.g. my-model-1")}
invalid={Boolean(fieldErrors.model)}
onChange={(e) => {
setModel(e.target.value);
@ -459,11 +462,11 @@ function OpenCodeProviderFields({
</label>
<label className="flex flex-col gap-1">
<Caption>Libellé du modèle (optionnel)</Caption>
<Caption>{tr("Libellé du modèle (facultatif)", "Model label (optional)")}</Caption>
<Input
aria-label={`${profile.name} custom display name`}
aria-label={tr(`Libellé personnalisé de ${profile.name}`, `Custom display name for ${profile.name}`)}
value={customDisplayName}
placeholder="ex. Mon modèle"
placeholder={tr("ex. Mon modèle", "e.g. My model")}
onChange={(e) => setCustomDisplayName(e.target.value)}
/>
</label>
@ -471,16 +474,16 @@ function OpenCodeProviderFields({
)}
<label className="flex flex-col gap-1">
<Caption>Clé API</Caption>
<Caption>{tr("Clé API", "API key")}</Caption>
<div className="flex items-center gap-1">
<Input
aria-label={`${profile.name} api key`}
aria-label={tr(`Clé API de ${profile.name}`, `API key for ${profile.name}`)}
type={showKey ? "text" : "password"}
value={apiKey}
placeholder={
isEditing
? "Ressaisissez la clé API pour confirmer l'enregistrement"
: "ex. sk-ant-…"
? tr("Ressaisissez la clé API pour confirmer l’enregistrement", "Enter the API key again to confirm saving")
: tr("ex. sk-ant-…", "e.g. sk-ant-…")
}
invalid={Boolean(fieldErrors.apiKey)}
onChange={(e) => {
@ -490,7 +493,7 @@ function OpenCodeProviderFields({
/>
<IconButton
size="sm"
aria-label="afficher/masquer la clé API"
aria-label={tr("Afficher ou masquer la clé API", "Show or hide API key")}
onClick={() => setShowKey((v) => !v)}
>
{showKey ? "🙈" : "👁"}
@ -500,13 +503,11 @@ function OpenCodeProviderFields({
<small className="text-xs text-danger">{fieldErrors.apiKey}</small>
)}
<small className="text-xs text-faint">
Jamais affichée ni renvoyée par IdeA une fois enregistrée ; stockée
chiffrée localement.
{tr("Jamais affichée ni renvoyée par IdeA une fois enregistrée ; stockée chiffrée localement.", "Never displayed or returned by IdeA after saving; stored locally in encrypted form.")}
</small>
{isEditing && (
<small className="text-xs text-muted">
Pour des raisons de sécurité, la clé n&apos;est jamais réaffichée :
ressaisissez-la à chaque modification de ce profil.
{tr("Pour des raisons de sécurité, la clé n’est jamais réaffichée : ressaisissez-la à chaque modification de ce profil.", "For security, the key is never displayed again: enter it again whenever you edit this profile.")}
</small>
)}
</label>
@ -519,7 +520,7 @@ function OpenCodeProviderFields({
onClick={() => void save()}
className="w-fit"
>
Enregistrer
{tr("Enregistrer", "Save")}
</Button>
</fieldset>
);
@ -537,6 +538,7 @@ function OpenCodeFields({
servers: LocalModelServerConfig[];
onChange: (p: AgentProfile) => void;
}) {
const { tr } = useI18n();
const opencode = profile.opencode ?? defaultOpenCodeConfig();
const patch = (next: Partial<OpenCodeConfig>) =>
onChange({
@ -548,13 +550,13 @@ function OpenCodeFields({
return (
<fieldset className="mt-1 flex flex-col gap-2 rounded-md border border-border/70 p-2">
<legend className="px-1 text-xs font-medium text-muted">
Local model (OpenCode + llama.cpp)
{tr("Modèle local (OpenCode + llama.cpp)", "Local model (OpenCode + llama.cpp)")}
</legend>
<label className="flex flex-col gap-1">
<Caption>Base URL</Caption>
<Caption>{tr("URL de base", "Base URL")}</Caption>
<Input
aria-label={`${profile.name} base url`}
aria-label={tr(`URL de base de ${profile.name}`, `Base URL for ${profile.name}`)}
value={opencode.baseURL}
placeholder="http://localhost:8080/v1"
invalid={Boolean(errors.baseURL)}
@ -566,9 +568,9 @@ function OpenCodeFields({
</label>
<label className="flex flex-col gap-1">
<Caption>Model</Caption>
<Caption>{tr("Modèle", "Model")}</Caption>
<Input
aria-label={`${profile.name} model`}
aria-label={tr(`Modèle de ${profile.name}`, `Model for ${profile.name}`)}
value={opencode.model}
placeholder="qwen3-coder-30b"
invalid={Boolean(errors.model)}
@ -578,11 +580,11 @@ function OpenCodeFields({
</label>
<label className="flex flex-col gap-1">
<Caption>API key (optional — a local llama.cpp needs none)</Caption>
<Caption>{tr("Clé API (facultative — un llama.cpp local n’en a pas besoin)", "API key (optional — a local llama.cpp needs none)")}</Caption>
<Input
aria-label={`${profile.name} api key`}
aria-label={tr(`Clé API de ${profile.name}`, `API key for ${profile.name}`)}
value={opencode.apiKey ?? ""}
placeholder="e.g. sk-no-key"
placeholder={tr("ex. sk-no-key", "e.g. sk-no-key")}
onChange={(e) => {
const v = e.target.value;
patch({ apiKey: v.length === 0 ? undefined : v });
@ -594,32 +596,32 @@ function OpenCodeFields({
<label className="flex items-center gap-2">
<input
type="checkbox"
aria-label={`${profile.name} reasoning`}
aria-label={tr(`Raisonnement de ${profile.name}`, `Reasoning for ${profile.name}`)}
checked={opencode.reasoning ?? true}
onChange={(e) => patch({ reasoning: e.target.checked })}
className="accent-primary"
/>
<Caption>Reasoning</Caption>
<Caption>{tr("Raisonnement", "Reasoning")}</Caption>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
aria-label={`${profile.name} attachment`}
aria-label={tr(`Pièces jointes de ${profile.name}`, `Attachments for ${profile.name}`)}
checked={opencode.attachment ?? false}
onChange={(e) => patch({ attachment: e.target.checked })}
className="accent-primary"
/>
<Caption>Attachments</Caption>
<Caption>{tr("Pièces jointes", "Attachments")}</Caption>
</label>
</div>
<label className="flex flex-col gap-1">
<Caption>
Local model server (bind to a managed llama.cpp server, or none)
{tr("Serveur de modèle local (lier à un serveur llama.cpp géré, ou aucun)", "Local model server (bind to a managed llama.cpp server, or none)")}
</Caption>
<ModelServerSelect
ariaLabel={`${profile.name} local model server`}
ariaLabel={tr(`Serveur de modèle local de ${profile.name}`, `Local model server for ${profile.name}`)}
servers={servers}
value={opencode.localModelServerId}
onChange={(serverId) => patch({ localModelServerId: serverId })}