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

@ -11,10 +11,12 @@ import type {
Template,
} from "@/domain";
import {
mcpToolGroupLabel,
mcpToolGroupsForCatalogue,
mcpToolLabel,
} from "@/features/permissions/mcpToolGroups";
import { Button, Input, SmallDropdown, cn } from "@/shared";
import { useI18n } from "@/i18n";
import { profileLabel } from "./templatePresentation";
export interface TemplateEditorSaveInput {
@ -38,16 +40,6 @@ export interface TemplateEditorProps {
type EditorTab = "edit" | "preview";
const NETWORK_OPTIONS: { value: "" | NetworkPolicy; label: string }[] = [
{ value: "", label: "Aucune permission système" },
{ value: "ask", label: "Réseau sur demande" },
{ value: "allow", label: "Réseau autorisé" },
{ value: "deny", label: "Réseau refusé" },
];
const TEMPLATE_CROSS_PROJECT_HELP =
"Les agents créés avec ce template reprendront cette permission.";
export function TemplateEditor({
template,
profiles,
@ -56,6 +48,13 @@ export function TemplateEditor({
onClose,
busy = false,
}: TemplateEditorProps) {
const { tr } = useI18n();
const networkOptions: { value: "" | NetworkPolicy; label: string }[] = [
{ value: "", label: tr("Aucune permission système", "No system permission") },
{ value: "ask", label: tr("Réseau sur demande", "Ask for network") },
{ value: "allow", label: tr("Réseau autorisé", "Network allowed") },
{ value: "deny", label: tr("Réseau refusé", "Network denied") },
];
const [name, setName] = useState(template?.name ?? "");
const [content, setContent] = useState(template?.contentMd ?? "");
const [defaultAgentName, setDefaultAgentName] = useState(
@ -114,16 +113,16 @@ export function TemplateEditor({
className="fixed inset-0 z-50 flex flex-col bg-canvas"
role="dialog"
aria-modal="true"
aria-label="template editor"
aria-label={tr("Éditeur de modèle", "Template editor")}
>
<div className="flex shrink-0 items-center justify-between border-b border-border bg-surface px-4 py-3">
<h2 className="text-sm font-semibold text-content">
{template ? `Edit template — ${template.name}` : "New template"}
{template ? tr(`Modifier le modèle — ${template.name}`, `Edit template — ${template.name}`) : tr("Nouveau modèle", "New template")}
</h2>
<Button
type="button"
variant="ghost"
aria-label="close template editor"
aria-label={tr("Fermer l’éditeur de modèle", "Close template editor")}
onClick={onClose}
disabled={busy}
>
@ -138,12 +137,12 @@ export function TemplateEditor({
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto px-4 py-3">
<div className="flex min-w-0 flex-col gap-1">
<label htmlFor="te-name" className="text-xs font-medium text-muted">
Nom du template
{tr("Nom du modèle", "Template name")}
</label>
<Input
id="te-name"
aria-label="template name"
placeholder="Template revue"
aria-label={tr("Nom du modèle", "Template name")}
placeholder={tr("Modèle de revue", "Review template")}
value={name}
onChange={(e) => setName(e.target.value)}
disabled={busy}
@ -156,15 +155,15 @@ export function TemplateEditor({
htmlFor="te-content"
className="py-2 text-xs font-medium text-muted"
>
Description
{tr("Description", "Description")}
</label>
<div role="tablist" aria-label="template content mode" className="flex">
<div role="tablist" aria-label={tr("Mode du contenu du modèle", "Template content mode")} className="flex">
{(["edit", "preview"] as EditorTab[]).map((t) => (
<button
key={t}
type="button"
role="tab"
aria-label={t === "edit" ? "Edit" : "Preview"}
aria-label={t === "edit" ? tr("Modifier", "Edit") : tr("Aperçu", "Preview")}
aria-selected={tab === t}
onClick={() => setTab(t)}
className={cn(
@ -174,7 +173,7 @@ export function TemplateEditor({
: "text-muted hover:text-content",
)}
>
{t === "edit" ? "Edit" : "Preview"}
{t === "edit" ? tr("Modifier", "Edit") : tr("Aperçu", "Preview")}
</button>
))}
</div>
@ -182,7 +181,7 @@ export function TemplateEditor({
{tab === "edit" ? (
<textarea
id="te-content"
aria-label="template content"
aria-label={tr("Contenu du modèle", "Template content")}
value={content}
onChange={(e) => setContent(e.target.value)}
disabled={busy}
@ -190,7 +189,7 @@ export function TemplateEditor({
"min-h-60 flex-1 resize-none bg-raised px-3 py-2 font-mono text-sm text-content",
"outline-none placeholder:text-faint disabled:cursor-not-allowed disabled:opacity-50",
)}
placeholder="# Instructions de l'agent"
placeholder={tr("# Instructions de l’agent", "# Agent instructions")}
/>
) : (
<div
@ -205,7 +204,7 @@ export function TemplateEditor({
{content.trim() ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
) : (
<p className="text-sm italic text-muted">Nothing to preview yet.</p>
<p className="text-sm italic text-muted">{tr("Rien à prévisualiser pour le moment.", "Nothing to preview yet.")}</p>
)}
</div>
)}
@ -217,11 +216,11 @@ export function TemplateEditor({
htmlFor="te-default-agent"
className="text-xs font-medium text-muted"
>
Nom par défaut de l'agent
{tr("Nom par défaut de l’agent", "Default agent name")}
</label>
<Input
id="te-default-agent"
aria-label="template default agent name"
aria-label={tr("Nom d’agent par défaut du modèle", "Template default agent name")}
placeholder="Main"
value={defaultAgentName}
onChange={(e) => setDefaultAgentName(e.target.value)}
@ -231,26 +230,26 @@ export function TemplateEditor({
<div className="flex min-w-0 flex-col gap-1">
<label htmlFor="te-profile" className="text-xs font-medium text-muted">
Modèle AI par défaut
{tr("Profil IA par défaut", "Default AI profile")}
</label>
{profiles.length > 0 ? (
<SmallDropdown
id="te-profile"
aria-label="template default profile"
aria-label={tr("Profil par défaut du modèle", "Template default profile")}
value={defaultProfileId}
onChange={setDefaultProfileId}
disabled={busy}
className="w-full"
size="md"
options={[
{ value: "", label: "Aucun modèle par défaut" },
{ value: "", label: tr("Aucun profil par défaut", "No default profile") },
...profiles.map((p) => ({ value: p.id, label: profileLabel(p) })),
]}
/>
) : (
<Input
id="te-profile"
aria-label="template default profile"
aria-label={tr("Profil par défaut du modèle", "Template default profile")}
placeholder="profile-id"
value={defaultProfileId}
onChange={(e) => setDefaultProfileId(e.target.value)}
@ -261,24 +260,24 @@ export function TemplateEditor({
<div className="flex min-w-0 flex-col gap-1">
<label htmlFor="te-system-network" className="text-xs font-medium text-muted">
Permissions système
{tr("Permissions système", "System permissions")}
</label>
<SmallDropdown
id="te-system-network"
aria-label="template system network permission"
aria-label={tr("Permission réseau système du modèle", "Template system network permission")}
value={network}
onChange={(value) => setNetwork(value as "" | NetworkPolicy)}
disabled={busy}
className="w-full"
size="md"
options={NETWORK_OPTIONS}
options={networkOptions}
/>
</div>
<label className="flex min-w-0 items-start gap-2 rounded-md border border-border bg-raised px-3 py-2">
<input
type="checkbox"
aria-label="Contactable depuis d’autres projets"
aria-label={tr("Contactable depuis d’autres projets", "Contactable from other projects")}
checked={defaultContactable}
onChange={(e) => setDefaultContactable(e.target.checked)}
disabled={busy}
@ -286,10 +285,10 @@ export function TemplateEditor({
/>
<span className="flex min-w-0 flex-col gap-1">
<span className="text-sm font-medium text-content">
Contactable depuis d’autres projets
{tr("Contactable depuis d’autres projets", "Contactable from other projects")}
</span>
<span className="text-xs text-muted">
{TEMPLATE_CROSS_PROJECT_HELP}
{tr("Les agents créés avec ce modèle reprendront cette permission.", "Agents created from this template will inherit this permission.")}
</span>
</span>
</label>
@ -298,23 +297,23 @@ export function TemplateEditor({
<section className="flex flex-col gap-3 rounded-md border border-border bg-raised p-3">
<div className="flex items-center justify-between gap-3">
<h3 className="text-xs font-medium text-muted">
Permissions Tools MCP IdeA
{tr("Permissions des outils MCP IdeA", "IdeA MCP tool permissions")}
</h3>
<span className="text-xs text-faint">{allowedTools.length} autorisé(s)</span>
<span className="text-xs text-faint">{allowedTools.length} {tr("autorisé(s)", "allowed")}</span>
</div>
{allMcpTools.length === 0 ? (
<p className="text-sm text-muted">Aucun tool MCP IdeA disponible.</p>
<p className="text-sm text-muted">{tr("Aucun outil MCP IdeA disponible.", "No IdeA MCP tool available.")}</p>
) : (
<>
{allowedTools.length === 0 && (
<p className="text-sm text-muted">
Aucun tool MCP IdeA autorisé.
{tr("Aucun outil MCP IdeA autorisé.", "No IdeA MCP tool allowed.")}
</p>
)}
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{toolGroups.map((group) => (
<div key={group.id} className="flex min-w-0 flex-col gap-2">
<h4 className="text-xs font-semibold text-content">{group.label}</h4>
<h4 className="text-xs font-semibold text-content">{mcpToolGroupLabel(group, tr)}</h4>
{group.tools.map((tool) => (
<label
key={tool}
@ -322,13 +321,13 @@ export function TemplateEditor({
>
<input
type="checkbox"
aria-label={`template MCP tool ${tool}`}
aria-label={`${tr("Outil MCP du modèle", "Template MCP tool")} ${tool}`}
checked={allowed.has(tool)}
onChange={() => toggleTool(tool)}
disabled={busy}
className="h-4 w-4 shrink-0 accent-primary"
/>
<span className="truncate">{mcpToolLabel(tool)}</span>
<span className="truncate">{mcpToolLabel(tool, tr)}</span>
</label>
))}
</div>
@ -341,16 +340,16 @@ export function TemplateEditor({
<div className="flex shrink-0 items-center justify-end gap-2 border-t border-border px-4 py-3">
<Button type="button" variant="ghost" onClick={onClose} disabled={busy}>
Cancel
{tr("Annuler", "Cancel")}
</Button>
<Button
type="submit"
variant="primary"
aria-label="Save template"
aria-label={tr("Enregistrer le modèle", "Save template")}
disabled={!canSave}
loading={busy}
>
Save
{tr("Enregistrer", "Save")}
</Button>
</div>
</form>