feat(domain,application,infrastructure,backend,web-server,app-tauri,frontend): bibliothèque d'icônes projet personnalisées importées par l'utilisateur — #358 (QA verte)

domain: variante ProjectIconSet::Custom, métadonnées CustomProjectIconAsset (id opaque, media type png/jpeg/webp, byte_size, dimensions décodées, sha256, nom de fichier original sans chemin, created_at), CustomProjectIconContent et port CustomProjectIconAssetStore (list/read/exists/create/delete) avec CustomProjectIconStoreError dédiée.
application: use case project_icon_assets — import validé png/jpeg/webp avec rejet du contenu SVG, dimensionnement et digest sha256, bibliothèque partagée à l'instance indépendante de tout projet, suppression refusée tant que l'asset est référencé par un projet.
infrastructure: store fichier store/project_icon_asset.rs — index canonique + octets, création atomique sous extension validée.
backend/web-server: contrat HTTP /api/project-icon-assets (GET liste, POST import, GET lecture, GET /usage, DELETE) avec prévol OPTIONS et tests contrat incluant le rejet SVG.
app-tauri: commandes import/list/read/get_usage/delete_custom_project_icon_asset exposées au frontend.
frontend: port ProjectIconAssetGateway (list/import/delete/getUrl sans exposer de chemin filesystem), gateways http+mock avec tests, DI, section Icônes personnalisées du ProjectIconPicker (import fichier accept png/jpeg/webp, sélection role=option, suppression avec retrait de sélection, erreurs lisibles i18n FR/EN), tests projectIcons étendus.

QA verte: contrats HTTP et Tauri identiques, suites cargo et npm ciblées vertes.
This commit is contained in:
2026-09-15 07:57:51 +02:00
parent a082b55c75
commit 0f01d1f9b1
32 changed files with 2002 additions and 151 deletions

View File

@ -122,6 +122,11 @@ export class HttpInvoker {
return this.request<T>("POST", "/api/invoke", { command, args }, `command '${command}'`);
}
/** Resolves a same-server REST path for browser-owned resources such as images. */
absoluteUrl(path: string): string {
return `${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
}
/**
* Sends one authenticated REST request and maps the response exactly like
* {@link invoke} (401 routing + `ErrorDto` preservation).

View File

@ -32,6 +32,7 @@ import {
HttpPermissionGateway,
HttpProfileGateway,
HttpProjectGateway,
HttpProjectIconAssetGateway,
HttpSkillGateway,
HttpTemplateGateway,
HttpWorkStateGateway,
@ -126,6 +127,7 @@ export function createHttpWsGateways(config: HttpWsGatewaysConfig = {}): Gateway
input: new HttpInputGateway(http),
terminal: new HttpTerminalGateway(ws),
project: new HttpProjectGateway(http),
projectIconAsset: new HttpProjectIconAssetGateway(http),
layout: new HttpLayoutGateway(http),
git: new HttpGitGateway(http),
remote: new WebRemoteGateway(),

View File

@ -0,0 +1,27 @@
import { describe, expect, it, vi } from "vitest";
import { HttpInvoker } from "./httpInvoker";
import { HttpProjectIconAssetGateway } from "./requestResponseGateways";
describe("HttpProjectIconAssetGateway", () => {
it("maps list/import/delete/read URLs to the dedicated REST resource", async () => {
const http = new HttpInvoker({ baseUrl: "https://idea.test" });
const request = vi.spyOn(http, "request").mockResolvedValue({ id: "asset-1" } as never);
const gateway = new HttpProjectIconAssetGateway(http);
await gateway.list();
const file = {
name: "icon.webp", type: "image/webp",
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
} as File;
await gateway.import(file);
await gateway.delete("asset/with spaces");
expect(request).toHaveBeenNthCalledWith(1, "GET", "/api/project-icon-assets", undefined, "custom project icons");
expect(request).toHaveBeenNthCalledWith(2, "POST", "/api/project-icon-assets", {
originalFilename: "icon.webp", mediaType: "image/webp", dataBase64: "AQID",
}, "custom project icon import");
expect(request).toHaveBeenNthCalledWith(3, "DELETE", "/api/project-icon-assets/asset%2Fwith%20spaces", undefined, "custom project icon deletion");
await expect(gateway.getUrl("asset-1")).resolves.toBe("https://idea.test/api/project-icon-assets/asset-1");
});
});

View File

@ -74,6 +74,8 @@ import type {
PermissionGateway,
ProfileGateway,
ProjectGateway,
ProjectIconAssetGateway,
CustomProjectIconAsset,
ProjectSettings,
SaveOpenCodeProviderProfileInput,
SkillGateway,
@ -170,6 +172,43 @@ export class HttpProjectGateway implements ProjectGateway {
}
}
export class HttpProjectIconAssetGateway implements ProjectIconAssetGateway {
constructor(private readonly http: HttpInvoker) {}
list(): Promise<CustomProjectIconAsset[]> {
return this.http.request("GET", "/api/project-icon-assets", undefined, "custom project icons");
}
async import(file: File): Promise<CustomProjectIconAsset> {
const dataBase64 = bytesToBase64(new Uint8Array(await file.arrayBuffer()));
return this.http.request("POST", "/api/project-icon-assets", {
originalFilename: file.name,
mediaType: file.type,
dataBase64,
}, "custom project icon import");
}
async delete(assetId: string): Promise<void> {
await this.http.request("DELETE", projectIconAssetPath(assetId), undefined, "custom project icon deletion");
}
async getUrl(assetId: string): Promise<string> {
return this.http.absoluteUrl(projectIconAssetPath(assetId));
}
}
function projectIconAssetPath(assetId: string): string {
return `/api/project-icon-assets/${encodeURIComponent(assetId)}`;
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
}
return btoa(binary);
}
function projectFilesPath(endpoint: string, projectId: string, path: string): string {
const params = new URLSearchParams({ projectId, path });
return `${endpoint}?${params.toString()}`;

View File

@ -16,6 +16,7 @@ import { TauriSystemGateway } from "./system";
import { TauriAgentGateway } from "./agent";
import { TauriInputGateway } from "./input";
import { TauriProjectGateway } from "./project";
import { TauriProjectIconAssetGateway } from "./projectIconAsset";
import { TauriTerminalGateway } from "./terminal";
import { TauriLayoutGateway } from "./layout";
import { TauriProfileGateway } from "./profile";
@ -66,6 +67,7 @@ export function createTauriGateways(): Gateways {
input: new TauriInputGateway(),
terminal: new TauriTerminalGateway(),
project: new TauriProjectGateway(),
projectIconAsset: new TauriProjectIconAssetGateway(),
layout: new TauriLayoutGateway(),
git: new TauriGitGateway(),
remote: new TauriRemoteGateway(),
@ -101,6 +103,7 @@ export {
TauriAgentGateway,
TauriInputGateway,
TauriProjectGateway,
TauriProjectIconAssetGateway,
TauriTerminalGateway,
TauriLayoutGateway,
TauriProfileGateway,

View File

@ -138,6 +138,8 @@ import type {
OpenTerminalOptions,
ProfileGateway,
ProjectGateway,
ProjectIconAssetGateway,
CustomProjectIconAsset,
ProjectSettings,
PermissionGateway,
PluginConfigDocumentReadInput,
@ -1287,6 +1289,59 @@ export class MockTerminalGateway implements TerminalGateway {
}
}
export class MockProjectIconAssetGateway implements ProjectIconAssetGateway {
private readonly assets = new Map<string, CustomProjectIconAsset>();
private readonly urls = new Map<string, string>();
private sequence = 0;
async list(): Promise<CustomProjectIconAsset[]> {
return [...this.assets.values()].map((asset) => ({ ...asset }));
}
async import(file: File): Promise<CustomProjectIconAsset> {
if (!["image/png", "image/jpeg", "image/webp"].includes(file.type)) {
throw { code: "INVALID_ICON_MEDIA_TYPE", message: "PNG, JPEG or WebP required" } as GatewayError;
}
if (file.size > 2 * 1024 * 1024) {
throw { code: "ICON_TOO_LARGE", message: "Icon exceeds 2 MiB" } as GatewayError;
}
const id = `mock-icon-${++this.sequence}`;
const asset: CustomProjectIconAsset = {
id,
mediaType: file.type as CustomProjectIconAsset["mediaType"],
byteSize: file.size,
width: 0,
height: 0,
sha256: `mock-${id}`,
originalFilename: file.name,
createdAt: Date.now(),
usedByProjectCount: 0,
};
this.assets.set(id, asset);
const createObjectURL = URL.createObjectURL?.bind(URL);
this.urls.set(id, createObjectURL ? createObjectURL(file) : `data:${file.type};base64,`);
return { ...asset };
}
async delete(assetId: string): Promise<void> {
const asset = this.assets.get(assetId);
if (!asset) throw { code: "NOT_FOUND", message: `icon ${assetId} not found` } as GatewayError;
if (asset.usedByProjectCount > 0) {
throw { code: "CUSTOM_ICON_IN_USE", message: `icon is used by ${asset.usedByProjectCount} projects` } as GatewayError;
}
const url = this.urls.get(assetId);
if (url?.startsWith("blob:")) URL.revokeObjectURL?.(url);
this.assets.delete(assetId);
this.urls.delete(assetId);
}
async getUrl(assetId: string): Promise<string> {
const url = this.urls.get(assetId);
if (!url) throw { code: "NOT_FOUND", message: `icon ${assetId} not found` } as GatewayError;
return url;
}
}
export class MockProjectGateway implements ProjectGateway {
private projects: Project[] = [];
private contexts = new Map<string, string>();
@ -5002,6 +5057,7 @@ export function createMockGateways(): Gateways {
input: new MockInputGateway(),
terminal: new MockTerminalGateway(),
project: new MockProjectGateway(),
projectIconAsset: new MockProjectIconAssetGateway(),
layout: new MockLayoutGateway(),
git: new MockGitGateway(),
remote: new MockRemoteGateway(),

View File

@ -35,6 +35,7 @@ describe("createMockGateways", () => {
"pluginWorkspace",
"profile",
"project",
"projectIconAsset",
"remote",
"scheduledTask",
"skill",

View File

@ -0,0 +1,38 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const invoke = vi.fn();
vi.mock("@tauri-apps/api/core", () => ({ invoke: (...args: unknown[]) => invoke(...args) }));
import { TauriProjectIconAssetGateway } from "./projectIconAsset";
describe("TauriProjectIconAssetGateway", () => {
beforeEach(() => invoke.mockReset());
it("uses the shared backend commands and base64 import DTO", async () => {
const gateway = new TauriProjectIconAssetGateway();
invoke.mockResolvedValueOnce([]);
await gateway.list();
expect(invoke).toHaveBeenCalledWith("list_custom_project_icon_assets");
invoke.mockResolvedValueOnce({ id: "asset-1" });
const file = {
name: "icon.png", type: "image/png",
arrayBuffer: async () => new Uint8Array([0, 1, 2]).buffer,
} as File;
await gateway.import(file);
expect(invoke).toHaveBeenCalledWith("import_custom_project_icon_asset", {
request: { originalFilename: "icon.png", mediaType: "image/png", dataBase64: "AAEC" },
});
invoke.mockResolvedValueOnce(undefined);
await gateway.delete("asset-1");
expect(invoke).toHaveBeenCalledWith("delete_custom_project_icon_asset", { assetId: "asset-1" });
});
it("turns the Tauri read DTO into a browser-safe data URL", async () => {
invoke.mockResolvedValueOnce({ mediaType: "image/webp", dataBase64: "AQID" });
await expect(new TauriProjectIconAssetGateway().getUrl("asset-1"))
.resolves.toBe("data:image/webp;base64,AQID");
expect(invoke).toHaveBeenCalledWith("read_custom_project_icon_asset", { assetId: "asset-1" });
});
});

View File

@ -0,0 +1,41 @@
import { invoke } from "@tauri-apps/api/core";
import type { CustomProjectIconAsset, ProjectIconAssetGateway } from "@/ports";
interface CustomProjectIconAssetContent extends CustomProjectIconAsset {
dataBase64: string;
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
}
return btoa(binary);
}
export class TauriProjectIconAssetGateway implements ProjectIconAssetGateway {
list(): Promise<CustomProjectIconAsset[]> {
return invoke("list_custom_project_icon_assets");
}
async import(file: File): Promise<CustomProjectIconAsset> {
const dataBase64 = bytesToBase64(new Uint8Array(await file.arrayBuffer()));
return invoke("import_custom_project_icon_asset", {
request: {
originalFilename: file.name,
mediaType: file.type,
dataBase64,
},
});
}
async delete(assetId: string): Promise<void> {
await invoke("delete_custom_project_icon_asset", { assetId });
}
getUrl(assetId: string): Promise<string> {
return invoke<CustomProjectIconAssetContent>("read_custom_project_icon_asset", { assetId })
.then((content) => `data:${content.mediaType};base64,${content.dataBase64}`);
}
}

View File

@ -80,3 +80,8 @@ export function useGateways(): Gateways {
}
return ctx;
}
/** Optional access for leaf renderers that retain a graceful no-provider fallback. */
export function useOptionalGateways(): Gateways | null {
return useContext(GatewaysContext);
}

View File

@ -1,69 +1,147 @@
import { useMemo, useState } from "react";
import type { ProjectIconRef } from "@/ports";
import { Button, Input } from "@/shared";
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import { useGateways } from "@/app/di";
import type { CustomProjectIconAsset, ProjectIconRef } from "@/ports";
import { Button, Input, Spinner } from "@/shared";
import { useI18n } from "@/i18n";
import {
PROJECT_ICON_CATALOG,
ProjectIcon,
type ProjectIconGroup,
toEmojiIconRef,
} from "./projectIcons";
import { PROJECT_ICON_CATALOG, ProjectIcon, type ProjectIconGroup, toEmojiIconRef } from "./projectIcons";
const GROUPS: readonly ProjectIconGroup[] = ["smileys", "nature", "food", "activities", "travel", "objects", "symbols"];
const GROUP_LABELS = {
smileys: "project.icon.group.smileys",
nature: "project.icon.group.nature",
food: "project.icon.group.food",
activities: "project.icon.group.activities",
travel: "project.icon.group.travel",
objects: "project.icon.group.objects",
smileys: "project.icon.group.smileys", nature: "project.icon.group.nature", food: "project.icon.group.food",
activities: "project.icon.group.activities", travel: "project.icon.group.travel", objects: "project.icon.group.objects",
symbols: "project.icon.group.symbols",
} as const;
const MAX_ICON_BYTES = 2 * 1024 * 1024;
const ACCEPTED_ICON_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]);
function readableError(error: unknown, fallback: string): string {
if (error && typeof error === "object" && "message" in error && typeof error.message === "string") return error.message;
return fallback;
}
export function ProjectIconPicker({ value, onApply, onCancel }: {
value: ProjectIconRef | null;
onApply(value: ProjectIconRef | null): void;
onCancel(): void;
}) {
const { t } = useI18n();
const { projectIconAsset } = useGateways();
const { t, tr } = useI18n();
const fileInput = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<ProjectIconRef | null>(() => toEmojiIconRef(value));
const results = useMemo(() => {
const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
return PROJECT_ICON_CATALOG.filter((entry) => words.every((word) => entry.keywords.some((key) => key.includes(word))));
}, [query]);
const groupedResults = useMemo(() => GROUPS.map((group) => ({
group,
entries: results.filter((entry) => entry.group === group),
})).filter(({ entries }) => entries.length > 0), [results]);
const [selected, setSelected] = useState<ProjectIconRef | null>(() => value?.set === "custom" ? value : toEmojiIconRef(value));
const [customAssets, setCustomAssets] = useState<CustomProjectIconAsset[]>([]);
const [loadingAssets, setLoadingAssets] = useState(true);
const [assetBusy, setAssetBusy] = useState(false);
const [assetError, setAssetError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoadingAssets(true);
void projectIconAsset.list().then((assets) => {
if (!cancelled) setCustomAssets(assets);
}).catch((error) => {
if (!cancelled) setAssetError(readableError(error, tr("Impossible de charger vos icônes.", "Unable to load your icons.")));
}).finally(() => { if (!cancelled) setLoadingAssets(false); });
return () => { cancelled = true; };
}, [projectIconAsset, tr]);
const words = useMemo(() => query.trim().toLowerCase().split(/\s+/).filter(Boolean), [query]);
const results = useMemo(() => PROJECT_ICON_CATALOG.filter((entry) =>
words.every((word) => entry.keywords.some((key) => key.includes(word)))), [words]);
const visibleCustomAssets = useMemo(() => customAssets.filter((asset) => {
const searchable = `${asset.originalFilename} ${asset.id}`.toLowerCase();
return words.every((word) => searchable.includes(word));
}), [customAssets, words]);
const groupedResults = useMemo(() => GROUPS.map((group) => ({ group, entries: results.filter((entry) => entry.group === group) }))
.filter(({ entries }) => entries.length > 0), [results]);
async function importIcon(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if (!ACCEPTED_ICON_TYPES.has(file.type)) {
setAssetError(tr("Format non pris en charge. Choisissez un fichier PNG, JPEG ou WebP.", "Unsupported format. Choose a PNG, JPEG or WebP file."));
return;
}
if (file.size > MAX_ICON_BYTES) {
setAssetError(tr("Cette icône dépasse la limite de 2 Mo.", "This icon exceeds the 2 MB limit."));
return;
}
setAssetBusy(true);
setAssetError(null);
try {
const imported = await projectIconAsset.import(file);
setCustomAssets((current) => [imported, ...current.filter(({ id }) => id !== imported.id)]);
setSelected({ set: "custom", name: imported.id });
} catch (error) {
setAssetError(readableError(error, tr("Impossible d’importer cette icône.", "Unable to import this icon.")));
} finally { setAssetBusy(false); }
}
async function deleteIcon(asset: CustomProjectIconAsset) {
if (asset.usedByProjectCount > 0) return;
setAssetBusy(true);
setAssetError(null);
try {
await projectIconAsset.delete(asset.id);
setCustomAssets((current) => current.filter(({ id }) => id !== asset.id));
if (selected?.set === "custom" && selected.name === asset.id) setSelected(null);
} catch (error) {
setAssetError(readableError(error, tr("Impossible de supprimer cette icône.", "Unable to delete this icon.")));
} finally { setAssetBusy(false); }
}
return (
<div role="dialog" aria-modal="true" aria-label={t("project.icon.picker")} className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/55 p-4">
<div className="flex max-h-[80vh] w-full max-w-xl flex-col gap-3 rounded-lg border border-border bg-surface p-4 shadow-xl">
<h3 className="text-base font-semibold text-content">{t("project.icon.picker")}</h3>
<Input autoFocus value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t("project.icon.search")} aria-label={t("project.icon.search")} />
<div role="listbox" aria-label={t("project.icon.results")} className="min-h-28 space-y-4 overflow-y-auto pr-1">
{groupedResults.map(({ group, entries }) => (
<div key={group} role="group" aria-labelledby={`project-icon-group-${group}`}>
<h4 id={`project-icon-group-${group}`} className="sticky top-0 z-10 mb-2 bg-surface py-1 text-xs font-semibold uppercase tracking-wide text-muted">
{t(GROUP_LABELS[group])}
</h4>
<div className="grid grid-cols-4 gap-2 sm:grid-cols-6">
{entries.map((entry) => {
const active = selected?.set === entry.set && selected.name === entry.name;
return <button key={`${entry.set}:${entry.name}`} type="button" role="option" aria-selected={active} title={entry.label} onClick={() => setSelected({ set: entry.set, name: entry.name })} className={`flex min-h-20 flex-col items-center justify-center gap-1 rounded border p-2 text-xs ${active ? "border-primary bg-primary/15 text-content" : "border-border bg-raised text-muted hover:text-content"}`}>
<ProjectIcon icon={entry} size={32} decorative />
<span className="line-clamp-2">{entry.label}</span>
</button>;
})}
</div>
<div role="listbox" aria-label={t("project.icon.results")} className="min-h-28 space-y-5 overflow-y-auto pr-1">
<section aria-labelledby="project-icon-custom-heading">
<div className="sticky top-0 z-20 mb-2 flex items-center justify-between gap-2 bg-surface py-1">
<h4 id="project-icon-custom-heading" className="text-xs font-semibold uppercase tracking-wide text-muted">{tr("Mes icônes", "My icons")}</h4>
<Button size="sm" variant="secondary" disabled={assetBusy} onClick={() => fileInput.current?.click()}>
{assetBusy ? <Spinner size={14} /> : null}{tr("Importer une icône", "Import an icon")}
</Button>
<input ref={fileInput} type="file" className="sr-only" accept="image/png,image/jpeg,image/webp" onChange={(event) => void importIcon(event)} aria-label={tr("Importer une icône", "Import an icon")} />
</div>
))}
<p className="mb-2 text-xs text-muted">{tr("PNG, JPEG ou WebP — 2 Mo maximum.", "PNG, JPEG or WebP — 2 MB maximum.")}</p>
{loadingAssets ? <div className="flex items-center gap-2 text-sm text-muted"><Spinner size={14} />{t("common.loading")}</div> : null}
{!loadingAssets && customAssets.length === 0 ? <p className="text-sm text-muted">{tr("Aucune icône importée.", "No imported icons yet.")}</p> : null}
{visibleCustomAssets.length > 0 ? <div className="grid grid-cols-4 gap-2 sm:grid-cols-6">
{visibleCustomAssets.map((asset) => {
const active = selected?.set === "custom" && selected.name === asset.id;
const inUse = asset.usedByProjectCount > 0;
const usage = tr(`Utilisée par ${asset.usedByProjectCount} projet${asset.usedByProjectCount > 1 ? "s" : ""}`, `Used by ${asset.usedByProjectCount} project${asset.usedByProjectCount === 1 ? "" : "s"}`);
return <div key={asset.id} className="relative min-w-0">
<button type="button" role="option" aria-selected={active} aria-label={asset.originalFilename} onClick={() => setSelected({ set: "custom", name: asset.id })} className={`flex min-h-20 w-full flex-col items-center justify-center gap-1 rounded border p-2 text-xs ${active ? "border-primary bg-primary/15 text-content" : "border-border bg-raised text-muted hover:text-content"}`}>
<ProjectIcon icon={{ set: "custom", name: asset.id }} size={36} decorative />
<span className="line-clamp-2 break-all">{asset.originalFilename}</span>
</button>
<button type="button" disabled={assetBusy} aria-disabled={inUse || undefined} title={inUse ? usage : tr("Supprimer de la bibliothèque", "Delete from library")} aria-label={inUse ? `${tr("Suppression impossible", "Cannot delete")}: ${usage}` : `${tr("Supprimer", "Delete")} ${asset.originalFilename}`} onClick={() => inUse ? setAssetError(`${tr("Suppression impossible", "Cannot delete")}: ${usage}.`) : void deleteIcon(asset)} className="absolute right-1 top-1 rounded bg-surface/90 px-1.5 py-0.5 text-xs text-muted hover:text-content disabled:cursor-not-allowed disabled:opacity-50">×</button>
</div>;
})}
</div> : null}
</section>
<section aria-labelledby="project-icon-emoji-heading">
<h4 id="project-icon-emoji-heading" className="sticky top-0 z-10 mb-2 bg-surface py-1 text-xs font-semibold uppercase tracking-wide text-muted">Emojis</h4>
<div className="space-y-4">{groupedResults.map(({ group, entries }) => <div key={group} role="group" aria-labelledby={`project-icon-group-${group}`}>
<h5 id={`project-icon-group-${group}`} className="mb-2 text-xs font-semibold text-muted">{t(GROUP_LABELS[group])}</h5>
<div className="grid grid-cols-4 gap-2 sm:grid-cols-6">{entries.map((entry) => {
const active = selected?.set === entry.set && selected.name === entry.name;
return <button key={`${entry.set}:${entry.name}`} type="button" role="option" aria-selected={active} title={entry.label} onClick={() => setSelected({ set: entry.set, name: entry.name })} className={`flex min-h-20 flex-col items-center justify-center gap-1 rounded border p-2 text-xs ${active ? "border-primary bg-primary/15 text-content" : "border-border bg-raised text-muted hover:text-content"}`}>
<ProjectIcon icon={entry} size={32} decorative /><span className="line-clamp-2">{entry.label}</span>
</button>;
})}</div>
</div>)}</div>
</section>
</div>
{results.length === 0 && <p role="status" className="text-sm text-muted">{t("project.icon.noResults")}</p>}
{assetError ? <p role="alert" className="text-sm text-danger">{assetError}</p> : null}
{results.length === 0 && visibleCustomAssets.length === 0 ? <p role="status" className="text-sm text-muted">{t("project.icon.noResults")}</p> : null}
<div className="flex flex-wrap justify-between gap-2">
<Button variant="ghost" disabled={!selected} onClick={() => setSelected(null)}>{t("project.icon.remove")}</Button>
<div className="flex gap-2"><Button variant="ghost" onClick={onCancel}>{t("common.cancel")}</Button><Button onClick={() => onApply(selected)}>{t("common.apply")}</Button></div>
<div className="flex gap-2"><Button variant="ghost" onClick={onCancel}>{t("common.cancel")}</Button><Button disabled={assetBusy} onClick={() => onApply(selected)}>{t("common.apply")}</Button></div>
</div>
</div>
</div>

View File

@ -1,6 +1,10 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import { createMockGateways } from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import type { CustomProjectIconAsset } from "@/ports";
import { ProjectIconPicker } from "./ProjectIconPicker";
import {
PROJECT_ICON_CATALOG,
@ -63,8 +67,12 @@ describe("project emoji catalogue", () => {
});
describe("ProjectIconPicker", () => {
function renderWithGateways(ui: ReactNode, gateways = createMockGateways()) {
return render(<DIProvider gateways={gateways}>{ui}</DIProvider>);
}
function renderPicker(value = null as Parameters<typeof ProjectIconPicker>[0]["value"]) {
render(<ProjectIconPicker value={value} onApply={vi.fn()} onCancel={vi.fn()} />);
renderWithGateways(<ProjectIconPicker value={value} onApply={vi.fn()} onCancel={vi.fn()} />);
}
it("shows phone-like emoji categories only", () => {
@ -89,7 +97,7 @@ describe("ProjectIconPicker", () => {
it("selects and persists an emoji reference", () => {
const onApply = vi.fn();
render(<ProjectIconPicker value={null} onApply={onApply} onCancel={vi.fn()} />);
renderWithGateways(<ProjectIconPicker value={null} onApply={onApply} onCancel={vi.fn()} />);
fireEvent.change(screen.getByLabelText("Search icons in English"), { target: { value: "rocket" } });
fireEvent.click(screen.getByRole("option", { name: "Rocket" }));
fireEvent.click(screen.getByRole("button", { name: "Appliquer" }));
@ -98,8 +106,62 @@ describe("ProjectIconPicker", () => {
it("normalizes a known legacy selection before applying it", () => {
const onApply = vi.fn();
render(<ProjectIconPicker value={{ set: "devicon", name: "python" }} onApply={onApply} onCancel={vi.fn()} />);
renderWithGateways(<ProjectIconPicker value={{ set: "devicon", name: "python" }} onApply={onApply} onCancel={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: "Appliquer" }));
expect(onApply).toHaveBeenCalledWith({ set: "emoji", name: "snake" });
});
it("imports a supported image into Mes icônes and selects its opaque id", async () => {
const gateways = createMockGateways();
const imported: CustomProjectIconAsset = {
id: "asset-opaque-1", mediaType: "image/png", byteSize: 12, width: 32, height: 32,
sha256: "abc", originalFilename: "équipe.png", createdAt: 1, usedByProjectCount: 0,
};
vi.spyOn(gateways.projectIconAsset, "import").mockResolvedValue(imported);
vi.spyOn(gateways.projectIconAsset, "getUrl").mockResolvedValue("data:image/png;base64,AA==");
const onApply = vi.fn();
const { container } = renderWithGateways(<ProjectIconPicker value={null} onApply={onApply} onCancel={vi.fn()} />, gateways);
expect(screen.getByText("Mes icônes")).toBeTruthy();
expect(screen.getByText("PNG, JPEG ou WebP — 2 Mo maximum.")).toBeTruthy();
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(input, { target: { files: [new File(["png"], "équipe.png", { type: "image/png" })] } });
await waitFor(() => expect(screen.getByRole("option", { name: "équipe.png" })).toBeTruthy());
fireEvent.click(screen.getByRole("button", { name: "Appliquer" }));
expect(onApply).toHaveBeenCalledWith({ set: "custom", name: "asset-opaque-1" });
});
it("blocks deletion while a custom icon is used by projects", async () => {
const gateways = createMockGateways();
vi.spyOn(gateways.projectIconAsset, "list").mockResolvedValue([{
id: "used-icon", mediaType: "image/webp", byteSize: 10, width: 16, height: 16,
sha256: "def", originalFilename: "used.webp", createdAt: 1, usedByProjectCount: 2,
}]);
const deleteSpy = vi.spyOn(gateways.projectIconAsset, "delete");
renderWithGateways(<ProjectIconPicker value={null} onApply={vi.fn()} onCancel={vi.fn()} />, gateways);
const remove = await screen.findByRole("button", { name: /Suppression impossible: Utilisée par 2 projets/ });
expect(remove.getAttribute("aria-disabled")).toBe("true");
fireEvent.click(remove);
expect(screen.getByRole("alert").textContent).toContain("Utilisée par 2 projets");
expect(deleteSpy).not.toHaveBeenCalled();
});
});
describe("custom ProjectIcon", () => {
it("renders a contained image and falls back cleanly when the asset is missing", async () => {
const gateways = createMockGateways();
vi.spyOn(gateways.projectIconAsset, "getUrl").mockResolvedValue("data:image/png;base64,AA==");
const { getByRole, rerender } = render(
<DIProvider gateways={gateways}><ProjectIcon icon={{ set: "custom", name: "asset-1" }} /></DIProvider>,
);
await waitFor(() => expect(getByRole("img").querySelector("img")?.style.objectFit).toBe("contain"));
expect(getByRole("img").dataset.resolvedProjectIcon).toBe("custom:asset-1");
vi.spyOn(gateways.projectIconAsset, "getUrl").mockRejectedValueOnce({ code: "NOT_FOUND" });
rerender(<DIProvider gateways={gateways}><ProjectIcon icon={{ set: "custom", name: "missing" }} fallbackLabel="Alpha" /></DIProvider>);
await waitFor(() => expect(getByRole("img").dataset.resolvedProjectIcon).toBe("initial"));
expect(getByRole("img").textContent).toBe("A");
});
});

View File

@ -1,4 +1,5 @@
import type { CSSProperties, HTMLAttributes } from "react";
import { useEffect, useState, type CSSProperties, type HTMLAttributes } from "react";
import { useOptionalGateways } from "@/app/di";
import type { ProjectIconRef } from "@/ports";
export type ProjectIconGroup =
@ -206,8 +207,24 @@ export function ProjectIcon({ icon: ref, fallbackLabel, size = 32, className, st
decorative?: boolean;
surface?: ProjectIconSurface;
} & Omit<HTMLAttributes<HTMLSpanElement>, "children" | "style" | "className">) {
const gateways = useOptionalGateways();
const entry = resolveProjectIcon(ref, surface);
const label = entry?.label ?? fallbackInitial(ref, fallbackLabel);
const customAssetId = ref?.set === "custom" ? ref.name : null;
const [customUrl, setCustomUrl] = useState<string | null>(null);
const [customMissing, setCustomMissing] = useState(false);
useEffect(() => {
let cancelled = false;
setCustomUrl(null);
setCustomMissing(false);
if (!customAssetId || !gateways) return () => { cancelled = true; };
void gateways.projectIconAsset.getUrl(customAssetId).then((url) => {
if (!cancelled) setCustomUrl(url);
}).catch(() => {
if (!cancelled) setCustomMissing(true);
});
return () => { cancelled = true; };
}, [customAssetId, gateways]);
const label = customAssetId ? (fallbackLabel ? `${fallbackLabel} — custom icon` : "Custom icon") : entry?.label ?? fallbackInitial(ref, fallbackLabel);
const numericSize = typeof size === "number" ? size : null;
const glyph = entry?.glyph ?? fallbackInitial(ref, fallbackLabel);
return (
@ -219,7 +236,7 @@ export function ProjectIcon({ icon: ref, fallbackLabel, size = 32, className, st
aria-label={decorative ? undefined : label}
title={decorative ? undefined : label}
data-project-icon={ref ? `${ref.set}:${ref.name}` : "none"}
data-resolved-project-icon={entry ? `emoji:${entry.name}` : "initial"}
data-resolved-project-icon={customUrl && !customMissing ? `custom:${customAssetId}` : entry ? `emoji:${entry.name}` : "initial"}
style={{
display: "inline-flex", alignItems: "center", justifyContent: "center",
width: size, height: size, fontSize: numericSize ? Math.max(16, numericSize * 0.72) : "62%",
@ -227,7 +244,15 @@ export function ProjectIcon({ icon: ref, fallbackLabel, size = 32, className, st
fontWeight: entry ? 400 : 700, lineHeight: 1, flexShrink: 0, ...style,
}}
>
{glyph}
{customUrl && !customMissing ? (
<img
src={customUrl}
alt=""
aria-hidden="true"
onError={() => setCustomMissing(true)}
style={{ width: "100%", height: "100%", objectFit: "contain", display: "block" }}
/>
) : glyph}
</span>
);
}

View File

@ -495,13 +495,35 @@ export interface UserSettingsGateway {
updateUserSettings(settings: Partial<UserSettings>): Promise<UserSettings>;
}
export type ProjectIconSet = "emoji" | "devicon" | "simple-icons" | "lucide" | "idea";
export type ProjectIconSet = "emoji" | "devicon" | "simple-icons" | "lucide" | "idea" | "custom";
export interface ProjectIconRef {
set: ProjectIconSet;
name: string;
}
/** One user-imported raster icon in the instance-wide project icon library. */
export interface CustomProjectIconAsset {
id: string;
mediaType: "image/png" | "image/jpeg" | "image/webp";
byteSize: number;
width: number;
height: number;
sha256: string;
originalFilename: string;
createdAt: number | string;
usedByProjectCount: number;
}
/** Shared custom-project-icon library, independent from any single project. */
export interface ProjectIconAssetGateway {
list(): Promise<CustomProjectIconAsset[]>;
import(file: File): Promise<CustomProjectIconAsset>;
delete(assetId: string): Promise<void>;
/** Returns a browser-loadable URL without exposing a local filesystem path. */
getUrl(assetId: string): Promise<string>;
}
export interface ProjectSettings {
themeColor: string | null;
/** Absent only when talking to a pre-#325 backend. */
@ -1892,6 +1914,7 @@ export interface Gateways {
input: InputGateway;
terminal: TerminalGateway;
project: ProjectGateway;
projectIconAsset: ProjectIconAssetGateway;
layout: LayoutGateway;
git: GitGateway;
remote: RemoteGateway;