feat(backend,domain,infrastructure,application,app-tauri,web-server,frontend): suppression d'un projet du registre IdeA sans toucher son dossier disque — #308 (QA verte)

cause: l'utilisateur ne pouvait pas retirer un projet du registre IdeA (projects.json) ; la seule issue laissait l'entrée fantôme dans la liste des projets, sans capacité générique de retrait.

domain: port ProjectStore::remove_project(id) -> Option<Project> avec implémentation par défaut « not supported » pour ne pas casser les stores spécialisés.

infrastructure: FsProjectStore implémente remove_project — retire l'entrée du registre persisté et la renvoie, None si id inconnu, ordre relatif du registre préservé ; test store dédié.

application: use case RemoveProjectFromRegistry (remove.rs) — NotFound explicite si id absent, retourne le projet retiré + registre rafraîchi.

backend/app-tauri/web-server: DTO RemoveProjectRequestDto/RemoveProjectResponseDto (removed, projects, restore_state, was_open), adapter partagé remove_project_for_adapter réutilisé par la commande Tauri remove_project et la route HTTP — même contrat desktop/web ; remove_project_and_deactivate désactive le projet s'il était ouvert, purge l'entrée open-projects restore correspondante et renvoie was_open/restore_state ; test adapter vérifiant le contrat de bout en bout (entrée retirée, restore purgé, root intact).

frontend: DTO + ProjectGateway.removeProject (HTTP + mock), ProjectsView expose un menu d'actions projet (ProjectActionsMenu) avec entrée Retirer, RemoveProjectDialog — confirmation explicite précisant que le dossier disque n'est pas supprimé, invalidation/rechargement de la liste des projets après succès (useProjects), tests ProjectsView/projects/adapters/httpInvoker/services.

QA verte: cargo targeted tests/check OK (domain, application project_usecases, infrastructure project_store, backend, app-tauri, web-server), npm targeted/typecheck/build OK ; suite npm test complète rouge non bloquante hors #308 (échecs préexistants listProjectSettings/web). Vérifications Git: git diff --check OK, cargo fmt --all -- --check OK.

fichiers .ideai/tickets/308/, tickets/counter.json, tickets/index.json, attachments/ et scripts/ hors périmètre et préservés en worktree (sync store traité séparément) ; aucun push.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 18:27:15 +02:00
parent ca1a27a2b9
commit b9f8ed303e
25 changed files with 1009 additions and 29 deletions

View File

@ -47,6 +47,7 @@ import type {
DomainEvent,
LayoutInfo,
OpenProjectRestoreFailure,
Project,
} from "@/domain";
import { LayoutGrid, LayoutTabs } from "@/features/layout";
import { ConversationViewer } from "@/features/conversations";
@ -78,6 +79,8 @@ import { useGateways } from "@/app/di";
import { useProjects } from "./useProjects";
import { useProjectActivity } from "./useProjectActivity";
import { ProjectTabs } from "./ProjectTabs";
import { ProjectActionsMenu } from "./ProjectActionsMenu";
import { RemoveProjectDialog } from "./RemoveProjectDialog";
import { ViewPanelBody, type ViewPanelId } from "./ViewPanelBody";
import {
PANEL_TITLE,
@ -266,6 +269,10 @@ export function ProjectsView() {
const [taskToasts, setTaskToasts] = useState<BackgroundTaskToast[]>([]);
const [detachErrorPanel, setDetachErrorPanel] = useState<PanelId | null>(null);
const [restoreDetailsOpen, setRestoreDetailsOpen] = useState(false);
const [removalTarget, setRemovalTarget] = useState<Project | null>(null);
const [removalPending, setRemovalPending] = useState(false);
const [removalError, setRemovalError] = useState<string | null>(null);
const [removalNotice, setRemovalNotice] = useState<Project | null>(null);
const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null;
const activeViewerConversation =
@ -273,6 +280,24 @@ export function ProjectsView() {
const showRestoreNotification =
vm.restoreNotificationVisible && vm.restoreFailures.length > 0;
async function confirmProjectRemoval() {
if (!removalTarget || removalPending) return;
const removed = removalTarget;
setRemovalPending(true);
setRemovalError(null);
try {
await vm.removeProject(removed.id);
setRemovalTarget(null);
setRemovalNotice(removed);
} catch {
setRemovalError(
"Impossible de retirer ce projet d'IdeA. Le projet est toujours enregistré. Réessayez.",
);
} finally {
setRemovalPending(false);
}
}
useEffect(() => {
if (vm.restoreFailures.length === 0) setRestoreDetailsOpen(false);
}, [vm.restoreFailures.length]);
@ -751,9 +776,20 @@ export function ProjectsView() {
<span className="font-medium text-content">{p.name}</span>
<code className="truncate text-xs text-muted">{p.root}</code>
</span>
<Button size="sm" onClick={() => void vm.openProject(p.id)}>
Open
</Button>
<span className="flex shrink-0 items-center gap-1">
<Button size="sm" onClick={() => void vm.openProject(p.id)}>
Open
</Button>
<ProjectActionsMenu
projectName={p.name}
disabled={vm.busy}
onOpen={() => void vm.openProject(p.id)}
onRemove={() => {
setRemovalError(null);
setRemovalTarget(p);
}}
/>
</span>
</li>
))}
</ul>
@ -1076,8 +1112,21 @@ export function ProjectsView() {
</ul>
</FloatingWindow>
{removalTarget && (
<RemoveProjectDialog
project={removalTarget}
isOpen={vm.openTabs.some((tab) => tab.id === removalTarget.id)}
pending={removalPending}
error={removalError}
onCancel={() => {
if (!removalPending) setRemovalTarget(null);
}}
onConfirm={() => void confirmProjectRemoval()}
/>
)}
{/* ── Non-blocking notifications (above floating windows) ── */}
{(showRestoreNotification || taskToasts.length > 0) && (
{(showRestoreNotification || removalNotice || taskToasts.length > 0) && (
<div
className="fixed bottom-4 right-4 flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2"
style={{ zIndex: zIndex.toast }}
@ -1117,6 +1166,27 @@ export function ProjectsView() {
</div>
</div>
)}
{removalNotice && (
<div
role="status"
className="rounded-md border border-border bg-surface px-3 py-2 shadow-lg"
>
<div className="flex items-start justify-between gap-2">
<span className="text-sm text-content">
« {removalNotice.name} » a été retiré d'IdeA. Les fichiers sont restés
sur le disque.
</span>
<Button
size="sm"
variant="ghost"
aria-label="Fermer la notification de retrait"
onClick={() => setRemovalNotice(null)}
>
×
</Button>
</div>
</div>
)}
{taskToasts.map((toast) => (
<button
key={toast.id}