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

@ -16,6 +16,7 @@ import type { GraphCommit } from "@/domain";
import { useGateways } from "@/app/di";
import { Spinner, cn } from "@/shared";
import { computeGraphRows, type GraphLink, type GraphRow } from "./graphLayout";
import { useI18n } from "@/i18n";
// ---------------------------------------------------------------------------
// Hook
@ -136,14 +137,15 @@ function GraphColumn({ row, laneCount }: GraphColumnProps) {
// Relative date helper
// ---------------------------------------------------------------------------
function relativeDate(timestamp: number): string {
function relativeDate(timestamp: number, language: "fr" | "en"): string {
const secs = Math.floor(Date.now() / 1000) - timestamp;
if (secs < 60) return "just now";
if (secs < 3600) return `${Math.floor(secs / 60)}m ago`;
if (secs < 86400) return `${Math.floor(secs / 3600)}h ago`;
if (secs < 2592000) return `${Math.floor(secs / 86400)}d ago`;
if (secs < 31536000) return `${Math.floor(secs / 2592000)}mo ago`;
return `${Math.floor(secs / 31536000)}y ago`;
const rtf = new Intl.RelativeTimeFormat(language, { numeric: "auto", style: "narrow" });
if (secs < 60) return rtf.format(0, "second");
if (secs < 3600) return rtf.format(-Math.floor(secs / 60), "minute");
if (secs < 86400) return rtf.format(-Math.floor(secs / 3600), "hour");
if (secs < 2592000) return rtf.format(-Math.floor(secs / 86400), "day");
if (secs < 31536000) return rtf.format(-Math.floor(secs / 2592000), "month");
return rtf.format(-Math.floor(secs / 31536000), "year");
}
// ---------------------------------------------------------------------------
@ -156,18 +158,19 @@ interface CommitDetailProps {
}
function CommitDetail({ commit, onClose }: CommitDetailProps) {
const { tr } = useI18n();
const isTag = (ref: string) => ref.startsWith("tag:");
return (
<aside
aria-label="commit detail"
aria-label={tr("détail du commit", "commit detail")}
className="flex w-80 shrink-0 flex-col gap-3 overflow-y-auto border-l border-border bg-surface p-4"
>
<div className="flex items-start justify-between gap-2">
<h3 className="text-sm font-semibold text-content">{commit.summary}</h3>
<button
type="button"
aria-label="close commit detail"
aria-label={tr("fermer le détail du commit", "close commit detail")}
onClick={onClose}
className="shrink-0 text-muted hover:text-content"
>
@ -183,18 +186,18 @@ function CommitDetail({ commit, onClose }: CommitDetailProps) {
</dd>
</div>
<div>
<dt className="font-medium text-faint">Author</dt>
<dt className="font-medium text-faint">{tr("Auteur", "Author")}</dt>
<dd className="text-content">{commit.author}</dd>
</div>
<div>
<dt className="font-medium text-faint">Date</dt>
<dt className="font-medium text-faint">{tr("Date", "Date")}</dt>
<dd className="text-content">
{new Date(commit.timestamp * 1000).toLocaleString()}
</dd>
</div>
{commit.parents.length > 0 && (
<div>
<dt className="font-medium text-faint">Parents</dt>
<dt className="font-medium text-faint">{tr("Parents", "Parents")}</dt>
<dd className="flex flex-col gap-0.5">
{commit.parents.map((p) => (
<code key={p} className="text-content">
@ -236,6 +239,7 @@ export interface GitGraphViewProps {
}
export function GitGraphView({ projectId }: GitGraphViewProps) {
const { tr, language } = useI18n();
const vm = useGitGraph(projectId);
const [selectedHash, setSelectedHash] = useState<string | null>(null);
@ -250,21 +254,21 @@ export function GitGraphView({ projectId }: GitGraphViewProps) {
const isTag = (ref: string) => ref.startsWith("tag:");
return (
<div className="flex flex-1 overflow-hidden" aria-label="git graph">
<div className="flex flex-1 overflow-hidden" aria-label={tr("graphe Git", "git graph")}>
{/* ── Graph + commit list ── */}
<div className="flex flex-1 flex-col overflow-hidden">
{/* Toolbar */}
<div className="flex shrink-0 items-center gap-2 border-b border-border bg-surface px-3 py-2">
<span className="text-sm font-semibold text-content">Git Graph</span>
<span className="text-sm font-semibold text-content">{tr("Graphe Git", "Git graph")}</span>
{vm.busy && <Spinner size={14} />}
<button
type="button"
aria-label="refresh git graph"
aria-label={tr("rafraîchir le graphe Git", "refresh git graph")}
onClick={() => void vm.refresh()}
disabled={vm.busy}
className="ml-auto text-xs text-muted hover:text-content disabled:opacity-40"
>
Refresh
{tr("Rafraîchir", "Refresh")}
</button>
</div>
@ -279,9 +283,9 @@ export function GitGraphView({ projectId }: GitGraphViewProps) {
)}
{/* Rows */}
<div className="flex-1 overflow-y-auto" role="list" aria-label="commits">
<div className="flex-1 overflow-y-auto" role="list" aria-label={tr("commits", "commits")}>
{vm.rows.length === 0 && !vm.busy && (
<p className="p-4 text-sm text-muted">No commits found.</p>
<p className="p-4 text-sm text-muted">{tr("Aucun commit trouvé.", "No commits found.")}</p>
)}
{vm.rows.map((row) => {
const isSelected = row.commit.hash === selectedHash;
@ -291,7 +295,7 @@ export function GitGraphView({ projectId }: GitGraphViewProps) {
<div
key={row.commit.hash}
role="listitem"
aria-label={`commit ${row.commit.hash.slice(0, 8)}: ${row.commit.summary}`}
aria-label={tr(`commit ${row.commit.hash.slice(0, 8)} : ${row.commit.summary}`, `commit ${row.commit.hash.slice(0, 8)}: ${row.commit.summary}`)}
onClick={() =>
setSelectedHash(isSelected ? null : row.commit.hash)
}
@ -353,7 +357,7 @@ export function GitGraphView({ projectId }: GitGraphViewProps) {
{row.commit.author}
</span>
<span className="shrink-0 text-xs text-faint">
{relativeDate(row.commit.timestamp)}
{relativeDate(row.commit.timestamp, language)}
</span>
</div>
);

View File

@ -13,6 +13,7 @@ import { useState } from "react";
import { Button, Input, Panel, Spinner, cn } from "@/shared";
import { useGit } from "./useGit";
import { buildFileTree, type GitTreeNode } from "./gitTree";
import { useI18n } from "@/i18n";
/**
* Recursively renders a changed-files tree. Directories are static rows; file
@ -31,6 +32,7 @@ function FileTree({
onAction: (path: string) => void;
busy: boolean;
}) {
const { tr } = useI18n();
return (
<ul className="flex flex-col gap-0.5">
{nodes.map((node) =>
@ -74,12 +76,12 @@ function FileTree({
<Button
size="sm"
variant="ghost"
aria-label={`${action} ${node.path}`}
aria-label={tr(`${action === "unstage" ? "désindexer" : "indexer"} ${node.path}`, `${action} ${node.path}`)}
disabled={busy}
onClick={() => onAction(node.path)}
className="shrink-0 text-muted hover:text-content"
>
{action === "unstage" ? "Unstage" : "Stage"}
{action === "unstage" ? tr("Désindexer", "Unstage") : tr("Indexer", "Stage")}
</Button>
</li>
),
@ -94,6 +96,7 @@ export interface GitPanelProps {
}
export function GitPanel({ projectId }: GitPanelProps) {
const { tr } = useI18n();
const vm = useGit(projectId);
const [commitMessage, setCommitMessage] = useState("");
const [newBranch, setNewBranch] = useState("");
@ -133,22 +136,22 @@ export function GitPanel({ projectId }: GitPanelProps) {
)}
{/* ── Changes ── */}
<section aria-label="Changes" className="border-b border-border p-4">
<section aria-label={tr("Modifications", "Changes")} className="border-b border-border p-4">
<div className="mb-2 flex items-center justify-between gap-2">
<h4 className="text-xs font-semibold uppercase tracking-wide text-faint">
Changes
{tr("Modifications", "Changes")}
</h4>
{vm.busy && <Spinner size={12} />}
</div>
{vm.files.length === 0 ? (
<p className="text-sm text-muted">No changes.</p>
<p className="text-sm text-muted">{tr("Aucune modification.", "No changes.")}</p>
) : (
<div className="flex flex-col gap-3">
{/* Staged — shown as a file tree. */}
{staged.length > 0 && (
<div>
<p className="mb-1 text-xs font-medium text-muted">Staged</p>
<p className="mb-1 text-xs font-medium text-muted">{tr("Indexées", "Staged")}</p>
<FileTree
nodes={buildFileTree(staged)}
depth={0}
@ -162,7 +165,7 @@ export function GitPanel({ projectId }: GitPanelProps) {
{/* Unstaged — shown as a file tree. */}
{unstaged.length > 0 && (
<div>
<p className="mb-1 text-xs font-medium text-muted">Unstaged</p>
<p className="mb-1 text-xs font-medium text-muted">{tr("Non indexées", "Unstaged")}</p>
<FileTree
nodes={buildFileTree(unstaged)}
depth={0}
@ -177,14 +180,14 @@ export function GitPanel({ projectId }: GitPanelProps) {
</section>
{/* ── Commit ── */}
<section aria-label="Commit" className="border-b border-border p-4">
<section aria-label={tr("Commit", "Commit")} className="border-b border-border p-4">
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
Commit
</h4>
<form onSubmit={handleCommit} className="flex flex-col gap-2">
<Input
aria-label="commit message"
placeholder="Commit message"
aria-label={tr("message de commit", "commit message")}
placeholder={tr("Message de commit", "Commit message")}
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
/>
@ -201,14 +204,14 @@ export function GitPanel({ projectId }: GitPanelProps) {
</section>
{/* ── Branches ── */}
<section aria-label="Branches" className="border-b border-border p-4">
<section aria-label={tr("Branches", "Branches")} className="border-b border-border p-4">
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
Branches
</h4>
<p className="mb-2 text-sm text-content">
Current:{" "}
{tr("Courante :", "Current:")}{" "}
<span className="font-medium">
{vm.branches.current ?? "(none)"}
{vm.branches.current ?? tr("(aucune)", "(none)")}
</span>
</p>
@ -236,12 +239,12 @@ export function GitPanel({ projectId }: GitPanelProps) {
<Button
size="sm"
variant="ghost"
aria-label={`checkout ${b}`}
aria-label={tr(`basculer sur ${b}`, `checkout ${b}`)}
disabled={vm.busy}
onClick={() => void handleCheckout(b)}
className="shrink-0 text-muted hover:text-content"
>
Checkout
{tr("Basculer", "Checkout")}
</Button>
)}
</li>
@ -252,8 +255,8 @@ export function GitPanel({ projectId }: GitPanelProps) {
<form onSubmit={handleNewBranch} className="flex items-center gap-2">
<Input
aria-label="new branch name"
placeholder="New branch"
aria-label={tr("nom de la nouvelle branche", "new branch name")}
placeholder={tr("Nouvelle branche", "New branch")}
value={newBranch}
onChange={(e) => setNewBranch(e.target.value)}
className="flex-1"
@ -263,18 +266,18 @@ export function GitPanel({ projectId }: GitPanelProps) {
variant="ghost"
disabled={!newBranch.trim() || vm.busy}
>
Create &amp; checkout
{tr("Créer et basculer", "Create & checkout")}
</Button>
</form>
</section>
{/* ── Log ── */}
<section aria-label="Log" className="p-4">
<section aria-label={tr("Historique", "Log")} className="p-4">
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wide text-faint">
Log
{tr("Historique", "Log")}
</h4>
{vm.log.length === 0 ? (
<p className="text-sm text-muted">No commits yet.</p>
<p className="text-sm text-muted">{tr("Aucun commit.", "No commits yet.")}</p>
) : (
<ul className="flex flex-col divide-y divide-border">
{vm.log.map((c) => (

View File

@ -4,7 +4,7 @@
*
* Covers:
* - status shows staged/unstaged groupings
* - stage moves a file from Unstaged to Staged
* - indexer moves a file from Unstaged to Staged
* - commit with empty message is blocked (button disabled)
* - commit with message but nothing staged is blocked (button disabled)
* - commit moves staged files out and adds an entry to the log
@ -41,7 +41,7 @@ function renderPanel(git: MockGitGateway = new MockGitGateway()) {
/** Waits until the "Changes" section is rendered (panel stabilised). */
async function waitForPanel() {
await waitFor(() => {
expect(screen.getByRole("region", { name: "Changes" })).toBeTruthy();
expect(screen.getByRole("region", { name: "Modifications" })).toBeTruthy();
});
}
@ -59,15 +59,15 @@ describe("GitPanel (with MockGitGateway)", () => {
renderPanel(git);
await waitForPanel();
await screen.findByText("Staged");
await screen.findByText("Unstaged");
// src/main.rs is staged → Unstage button exists
await screen.findByText("Indexées");
await screen.findByText("Non indexées");
// src/main.rs is staged → Unindexer button exists
expect(
screen.getByRole("button", { name: "unstage src/main.rs" }),
screen.getByRole("button", { name: "désindexer src/main.rs" }),
).toBeTruthy();
// README.md is unstaged → Stage button exists
expect(
screen.getByRole("button", { name: "stage README.md" }),
screen.getByRole("button", { name: "indexer README.md" }),
).toBeTruthy();
});
@ -77,19 +77,19 @@ describe("GitPanel (with MockGitGateway)", () => {
// Stage README.md
const stageBtn = await screen.findByRole("button", {
name: "stage README.md",
name: "indexer README.md",
});
fireEvent.click(stageBtn);
// After staging, an Unstage button should appear for README.md
// After staging, an Unindexer button should appear for README.md
await waitFor(() => {
expect(
screen.getByRole("button", { name: "unstage README.md" }),
screen.getByRole("button", { name: "désindexer README.md" }),
).toBeTruthy();
});
});
it("clicking Unstage moves a file back to Unstaged", async () => {
it("clicking Unindexer moves a file back to Unstaged", async () => {
const git = new MockGitGateway();
await git.init(PROJECT_ID);
await git.stage(PROJECT_ID, "src/main.rs");
@ -98,13 +98,13 @@ describe("GitPanel (with MockGitGateway)", () => {
await waitForPanel();
const unstageBtn = await screen.findByRole("button", {
name: "unstage src/main.rs",
name: "désindexer src/main.rs",
});
fireEvent.click(unstageBtn);
await waitFor(() => {
expect(
screen.getByRole("button", { name: "stage src/main.rs" }),
screen.getByRole("button", { name: "indexer src/main.rs" }),
).toBeTruthy();
});
});
@ -121,7 +121,7 @@ describe("GitPanel (with MockGitGateway)", () => {
renderPanel();
await waitForPanel();
fireEvent.change(screen.getByLabelText("commit message"), {
fireEvent.change(screen.getByLabelText("message de commit"), {
target: { value: "my message" },
});
@ -138,7 +138,7 @@ describe("GitPanel (with MockGitGateway)", () => {
renderPanel(git);
await waitForPanel();
fireEvent.change(screen.getByLabelText("commit message"), {
fireEvent.change(screen.getByLabelText("message de commit"), {
target: { value: "initial commit" },
});
@ -148,7 +148,7 @@ describe("GitPanel (with MockGitGateway)", () => {
// After commit, no files should remain (both were staged).
await waitFor(() => {
expect(screen.getByText("No changes.")).toBeTruthy();
expect(screen.getByText("Aucune modification.")).toBeTruthy();
});
// The commit should appear in the log.
@ -168,9 +168,9 @@ describe("GitPanel (with MockGitGateway)", () => {
expect(branchesSection).toBeTruthy();
// Create & checkout a new branch via the form.
const branchInput = screen.getByLabelText("new branch name");
const branchInput = screen.getByLabelText("nom de la nouvelle branche");
fireEvent.change(branchInput, { target: { value: "feature/new-thing" } });
fireEvent.click(screen.getByRole("button", { name: /create.*checkout/i }));
fireEvent.click(screen.getByRole("button", { name: /créer.*basculer/i }));
// After checkout, the new branch should appear (may show in current + list).
await waitFor(() => {
@ -181,7 +181,7 @@ describe("GitPanel (with MockGitGateway)", () => {
// A checkout button for "main" should now appear (since it's no longer current).
await waitFor(() => {
expect(
screen.getByRole("button", { name: "checkout main" }),
screen.getByRole("button", { name: "basculer sur main" }),
).toBeTruthy();
});
});
@ -190,7 +190,7 @@ describe("GitPanel (with MockGitGateway)", () => {
renderPanel();
await waitForPanel();
expect(screen.getByText("No commits yet.")).toBeTruthy();
expect(screen.getByText("Aucun commit.")).toBeTruthy();
});
});
@ -213,7 +213,7 @@ describe("MockGitGateway (unit)", () => {
expect(Array.isArray(files)).toBe(true);
});
it("stage toggles a file to staged=true", async () => {
it("indexer toggles a file to staged=true", async () => {
const gw = new MockGitGateway();
const before = await gw.status("p");
const target = before[0].path;
@ -223,7 +223,7 @@ describe("MockGitGateway (unit)", () => {
expect(file.staged).toBe(true);
});
it("unstage toggles a file back to staged=false", async () => {
it("désindexer toggles a file back to staged=false", async () => {
const gw = new MockGitGateway();
const files = await gw.status("p");
const target = files[0].path;

View File

@ -114,7 +114,7 @@ describe("GitGraphView", () => {
await waitForCommits();
// The detail panel should not be visible yet.
expect(screen.queryByRole("complementary", { name: "commit detail" })).toBeNull();
expect(screen.queryByRole("complementary", { name: "détail du commit" })).toBeNull();
// Click the first commit row.
const items = screen.getAllByRole("listitem");
@ -122,7 +122,7 @@ describe("GitGraphView", () => {
await waitFor(() => {
expect(
screen.getByRole("complementary", { name: "commit detail" }),
screen.getByRole("complementary", { name: "détail du commit" }),
).toBeTruthy();
});
});
@ -147,7 +147,7 @@ describe("GitGraphView", () => {
// Author should appear in a <dd> (detail panel's definition list).
await waitFor(() => {
const detail = screen.getByRole("complementary", { name: "commit detail" });
const detail = screen.getByRole("complementary", { name: "détail du commit" });
expect(detail.textContent).toContain("Alice");
});
});
@ -162,7 +162,7 @@ describe("GitGraphView", () => {
fireEvent.click(mergeRow);
await waitFor(() => {
const detail = screen.getByRole("complementary", { name: "commit detail" });
const detail = screen.getByRole("complementary", { name: "détail du commit" });
// The merge commit has two parents displayed as short hashes.
expect(detail.textContent).toContain("bbbbbbbb");
expect(detail.textContent).toContain("dddddddd");
@ -178,16 +178,16 @@ describe("GitGraphView", () => {
await waitFor(() => {
expect(
screen.getByRole("complementary", { name: "commit detail" }),
screen.getByRole("complementary", { name: "détail du commit" }),
).toBeTruthy();
});
// Click the close button.
fireEvent.click(screen.getByRole("button", { name: "close commit detail" }));
fireEvent.click(screen.getByRole("button", { name: "fermer le détail du commit" }));
await waitFor(() => {
expect(
screen.queryByRole("complementary", { name: "commit detail" }),
screen.queryByRole("complementary", { name: "détail du commit" }),
).toBeNull();
});
});
@ -201,7 +201,7 @@ describe("GitGraphView", () => {
await waitFor(() => {
expect(
screen.getByRole("complementary", { name: "commit detail" }),
screen.getByRole("complementary", { name: "détail du commit" }),
).toBeTruthy();
});
@ -210,7 +210,7 @@ describe("GitGraphView", () => {
await waitFor(() => {
expect(
screen.queryByRole("complementary", { name: "commit detail" }),
screen.queryByRole("complementary", { name: "détail du commit" }),
).toBeNull();
});
});