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:
@ -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'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 })}
|
||||
|
||||
Reference in New Issue
Block a user