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:
@ -240,6 +240,18 @@ describe("request/response gateways preserve the Tauri command contract", () =>
|
||||
});
|
||||
});
|
||||
|
||||
it("HttpProjectGateway.removeProject maps to the common command envelope", async () => {
|
||||
const http = new HttpInvoker({ baseUrl: "https://h" });
|
||||
const spy = vi.spyOn(http, "invoke").mockResolvedValue({} as never);
|
||||
const gw = new HttpProjectGateway(http);
|
||||
|
||||
await gw.removeProject("project-1");
|
||||
|
||||
expect(spy).toHaveBeenCalledWith("remove_project", {
|
||||
request: { projectId: "project-1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("HttpProjectGateway maps the open-project restore command envelopes", async () => {
|
||||
const http = new HttpInvoker({ baseUrl: "https://h" });
|
||||
const spy = vi.spyOn(http, "invoke").mockResolvedValue({} as never);
|
||||
|
||||
@ -39,6 +39,7 @@ import type {
|
||||
Project,
|
||||
ProjectFileListing,
|
||||
OpenProjectRestoreState,
|
||||
RemoveProjectOutput,
|
||||
RestoreOpenProjectsOutput,
|
||||
ProjectMcpToolPermissions,
|
||||
ProjectPermissions,
|
||||
@ -99,6 +100,11 @@ export class HttpProjectGateway implements ProjectGateway {
|
||||
async closeProject(projectId: string): Promise<void> {
|
||||
await this.http.invoke("close_project", { projectId });
|
||||
}
|
||||
removeProject(projectId: string): Promise<RemoveProjectOutput> {
|
||||
return this.http.invoke<RemoveProjectOutput>("remove_project", {
|
||||
request: { projectId },
|
||||
});
|
||||
}
|
||||
restoreOpenProjects(): Promise<RestoreOpenProjectsOutput> {
|
||||
return this.http.invoke<RestoreOpenProjectsOutput>("restore_open_projects");
|
||||
}
|
||||
|
||||
@ -72,6 +72,7 @@ import type {
|
||||
ProjectFileListing,
|
||||
OpenProjectRestoreFailure,
|
||||
OpenProjectRestoreState,
|
||||
RemoveProjectOutput,
|
||||
RestoreOpenProjectsOutput,
|
||||
JsonValue,
|
||||
ProjectMcpToolPermissions,
|
||||
@ -1314,6 +1315,28 @@ export class MockProjectGateway implements ProjectGateway {
|
||||
this.restoreFailures.delete(projectId);
|
||||
}
|
||||
|
||||
async removeProject(projectId: string): Promise<RemoveProjectOutput> {
|
||||
const index = this.projects.findIndex((project) => project.id === projectId);
|
||||
if (index < 0) {
|
||||
const err: GatewayError = {
|
||||
code: "NOT_FOUND",
|
||||
message: `project ${projectId} not found`,
|
||||
};
|
||||
throw err;
|
||||
}
|
||||
const [removed] = this.projects.splice(index, 1);
|
||||
const wasOpen = this.restoreProjectIds.includes(projectId);
|
||||
this.restoreProjectIds = this.restoreProjectIds.filter((id) => id !== projectId);
|
||||
this.restoreFailures.delete(projectId);
|
||||
this.contexts.delete(projectId);
|
||||
return {
|
||||
removed,
|
||||
projects: [...this.projects],
|
||||
restoreState: await this.readOpenProjectRestoreState(),
|
||||
wasOpen,
|
||||
};
|
||||
}
|
||||
|
||||
async restoreOpenProjects(): Promise<RestoreOpenProjectsOutput> {
|
||||
const failures: OpenProjectRestoreFailure[] = [];
|
||||
const opened: Project[] = [];
|
||||
|
||||
@ -53,4 +53,14 @@ describe("TauriProjectGateway invoke payloads", () => {
|
||||
await gateway.readOpenProjectRestoreState();
|
||||
expect(invoke).toHaveBeenCalledWith("read_open_project_restore_state");
|
||||
});
|
||||
|
||||
it("removes a project through the shared request envelope", async () => {
|
||||
const gateway = new TauriProjectGateway();
|
||||
|
||||
await gateway.removeProject("proj-1");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("remove_project", {
|
||||
request: { projectId: "proj-1" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -12,6 +12,7 @@ import type {
|
||||
OpenProjectRestoreState,
|
||||
Project,
|
||||
ProjectFileListing,
|
||||
RemoveProjectOutput,
|
||||
RestoreOpenProjectsOutput,
|
||||
} from "@/domain";
|
||||
import type { ProjectGateway } from "@/ports";
|
||||
@ -33,6 +34,12 @@ export class TauriProjectGateway implements ProjectGateway {
|
||||
await invoke("close_project", { projectId });
|
||||
}
|
||||
|
||||
removeProject(projectId: string): Promise<RemoveProjectOutput> {
|
||||
return invoke<RemoveProjectOutput>("remove_project", {
|
||||
request: { projectId },
|
||||
});
|
||||
}
|
||||
|
||||
restoreOpenProjects(): Promise<RestoreOpenProjectsOutput> {
|
||||
return invoke<RestoreOpenProjectsOutput>("restore_open_projects");
|
||||
}
|
||||
|
||||
@ -643,6 +643,14 @@ export interface RestoreOpenProjectsOutput {
|
||||
failures: OpenProjectRestoreFailure[];
|
||||
}
|
||||
|
||||
/** Authoritative registry/open-state returned after removing a project from IdeA. */
|
||||
export interface RemoveProjectOutput {
|
||||
removed: Project;
|
||||
projects: Project[];
|
||||
restoreState: OpenProjectRestoreState;
|
||||
wasOpen: boolean;
|
||||
}
|
||||
|
||||
/** Stable error shape mirrored from the backend `ErrorDto`. */
|
||||
export interface GatewayError {
|
||||
code: string;
|
||||
|
||||
105
frontend/src/features/projects/ProjectActionsMenu.tsx
Normal file
105
frontend/src/features/projects/ProjectActionsMenu.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { IconButton, zIndex } from "@/shared";
|
||||
|
||||
interface ProjectActionsMenuProps {
|
||||
projectName: string;
|
||||
disabled?: boolean;
|
||||
onOpen: () => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
export function ProjectActionsMenu({
|
||||
projectName,
|
||||
disabled = false,
|
||||
onOpen,
|
||||
onRemove,
|
||||
}: ProjectActionsMenuProps) {
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [position, setPosition] = useState({ top: 0, left: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function dismiss(event: PointerEvent) {
|
||||
const target = event.target as Node;
|
||||
if (!triggerRef.current?.contains(target) && !menuRef.current?.contains(target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
function dismissOnEscape(event: KeyboardEvent) {
|
||||
if (event.key !== "Escape") return;
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
document.addEventListener("pointerdown", dismiss);
|
||||
document.addEventListener("keydown", dismissOnEscape);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", dismiss);
|
||||
document.removeEventListener("keydown", dismissOnEscape);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
function toggle() {
|
||||
if (!open && triggerRef.current) {
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
setPosition({
|
||||
top: rect.bottom + 4,
|
||||
left: Math.max(8, rect.right - 192),
|
||||
});
|
||||
}
|
||||
setOpen((current) => !current);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
size="sm"
|
||||
aria-label={`Actions pour ${projectName}`}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
onClick={toggle}
|
||||
>
|
||||
⋯
|
||||
</IconButton>
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
role="menu"
|
||||
aria-label={`Actions pour ${projectName}`}
|
||||
className="fixed w-48 rounded-md border border-border bg-surface py-1 shadow-xl"
|
||||
style={{ ...position, zIndex: zIndex.transientPopup }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="w-full px-3 py-2 text-left text-xs text-content hover:bg-raised focus:bg-raised focus:outline-none"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onOpen();
|
||||
}}
|
||||
>
|
||||
Ouvrir
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="w-full border-t border-border px-3 py-2 text-left text-xs text-danger hover:bg-raised focus:bg-raised focus:outline-none"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onRemove();
|
||||
}}
|
||||
>
|
||||
Retirer d'IdeA…
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
94
frontend/src/features/projects/RemoveProjectDialog.tsx
Normal file
94
frontend/src/features/projects/RemoveProjectDialog.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import type { Project } from "@/domain";
|
||||
import { Button, zIndex } from "@/shared";
|
||||
|
||||
interface RemoveProjectDialogProps {
|
||||
project: Project;
|
||||
isOpen: boolean;
|
||||
pending: boolean;
|
||||
error: string | null;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function RemoveProjectDialog({
|
||||
project,
|
||||
isOpen,
|
||||
pending,
|
||||
error,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: RemoveProjectDialogProps) {
|
||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
cancelRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function cancelOnEscape(event: KeyboardEvent) {
|
||||
if (event.key === "Escape" && !pending) onCancel();
|
||||
}
|
||||
document.addEventListener("keydown", cancelOnEscape);
|
||||
return () => document.removeEventListener("keydown", cancelOnEscape);
|
||||
}, [onCancel, pending]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center bg-black/55 p-4"
|
||||
style={{ zIndex: zIndex.floatingWindowNested }}
|
||||
onMouseDown={(event) => {
|
||||
if (!pending && event.target === event.currentTarget) onCancel();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Retirer « ${project.name} » d'IdeA ?`}
|
||||
className="flex w-full max-w-md flex-col gap-3 rounded-lg border border-border bg-surface p-4 shadow-xl"
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-content">
|
||||
Retirer « {project.name} » d'IdeA ?
|
||||
</h3>
|
||||
<code className="break-all rounded-md bg-raised px-3 py-2 text-xs text-muted">
|
||||
{project.root}
|
||||
</code>
|
||||
<p className="text-sm text-muted">
|
||||
Le dossier et les fichiers resteront sur le disque.
|
||||
</p>
|
||||
{isOpen && (
|
||||
<p className="text-sm text-muted">
|
||||
Les vues de ce projet seront fermées et les opérations IdeA en cours seront
|
||||
interrompues.
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
ref={cancelRef}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={pending}
|
||||
onClick={onCancel}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
loading={pending}
|
||||
aria-label={pending ? "Retrait…" : undefined}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{pending ? "Retrait…" : "Retirer d'IdeA"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -224,6 +224,143 @@ describe("ProjectsView (with MockProjectGateway)", () => {
|
||||
expect(screen.getByRole("tab", { name: "alpha" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("confirms registry removal, keeps files explicit, and adopts backend state", async () => {
|
||||
const project = new MockProjectGateway();
|
||||
const alpha = await project.createProject("alpha", "/p/a");
|
||||
await project.createProject("beta", "/p/b");
|
||||
renderView(project);
|
||||
|
||||
await screen.findByText("/p/a");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Actions pour alpha" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Retirer d'IdeA…" }));
|
||||
|
||||
const dialog = screen.getByRole("alertdialog", {
|
||||
name: "Retirer « alpha » d'IdeA ?",
|
||||
});
|
||||
expect(within(dialog).getByText("/p/a")).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByText("Le dossier et les fichiers resteront sur le disque."),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Retirer d'IdeA" }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByText("/p/a")).toBeNull());
|
||||
expect(screen.getByText("/p/b")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"« alpha » a été retiré d'IdeA. Les fichiers sont restés sur le disque.",
|
||||
),
|
||||
).toBeTruthy();
|
||||
await expect(project.listProjects()).resolves.toEqual([
|
||||
expect.objectContaining({ name: "beta" }),
|
||||
]);
|
||||
expect(alpha.root).toBe("/p/a");
|
||||
});
|
||||
|
||||
it("removes an open active project and activates the last remaining tab", async () => {
|
||||
const project = new MockProjectGateway();
|
||||
await project.createProject("alpha", "/p/a");
|
||||
await project.createProject("beta", "/p/b");
|
||||
renderView(project);
|
||||
|
||||
await screen.findByText("/p/a");
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Open" })[0]);
|
||||
await screen.findByRole("tab", { name: "alpha" });
|
||||
openPanel("Projects");
|
||||
const betaRow = screen
|
||||
.getAllByRole("listitem")
|
||||
.find((item) => within(item).queryByText("beta"))!;
|
||||
fireEvent.click(within(betaRow).getByRole("button", { name: "Open" }));
|
||||
await waitFor(() => expect(screen.getByRole("tab", { name: "beta" })).toBeTruthy());
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Actions pour beta" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Retirer d'IdeA…" }));
|
||||
const dialog = screen.getByRole("alertdialog", {
|
||||
name: "Retirer « beta » d'IdeA ?",
|
||||
});
|
||||
expect(
|
||||
within(dialog).getByText(/Les vues de ce projet seront fermées/),
|
||||
).toBeTruthy();
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Retirer d'IdeA" }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("tab", { name: "beta" })).toBeNull());
|
||||
expect(screen.getByRole("tab", { name: "alpha" }).getAttribute("aria-selected")).toBe(
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the confirmation open and reports a generic error when removal fails", async () => {
|
||||
const project = new MockProjectGateway();
|
||||
await project.createProject("alpha", "/p/a");
|
||||
project.removeProject = async () => {
|
||||
throw { code: "STORE", message: "private backend detail" };
|
||||
};
|
||||
renderView(project);
|
||||
|
||||
await screen.findByText("/p/a");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Actions pour alpha" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Retirer d'IdeA…" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retirer d'IdeA" }));
|
||||
|
||||
const dialog = await screen.findByRole("alertdialog", {
|
||||
name: "Retirer « alpha » d'IdeA ?",
|
||||
});
|
||||
expect(within(dialog).getByRole("alert").textContent).toBe(
|
||||
"Impossible de retirer ce projet d'IdeA. Le projet est toujours enregistré. Réessayez.",
|
||||
);
|
||||
expect(within(dialog).queryByText("private backend detail")).toBeNull();
|
||||
expect(screen.getAllByText("/p/a")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows the pending removal state and prevents cancellation until completion", async () => {
|
||||
const project = new MockProjectGateway();
|
||||
await project.createProject("alpha", "/p/a");
|
||||
const remove = project.removeProject.bind(project);
|
||||
let continueRemoval!: () => void;
|
||||
project.removeProject = async (projectId) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
continueRemoval = resolve;
|
||||
});
|
||||
return remove(projectId);
|
||||
};
|
||||
renderView(project);
|
||||
|
||||
await screen.findByText("/p/a");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Actions pour alpha" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Retirer d'IdeA…" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retirer d'IdeA" }));
|
||||
|
||||
const pending = screen.getByRole("button", { name: "Retrait…" });
|
||||
expect((pending as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((screen.getByRole("button", { name: "Annuler" }) as HTMLButtonElement).disabled).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
continueRemoval();
|
||||
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
||||
});
|
||||
|
||||
it("treats NOT_FOUND as success after an authoritative registry refresh", async () => {
|
||||
const project = new MockProjectGateway();
|
||||
const alpha = await project.createProject("alpha", "/p/a");
|
||||
const remove = project.removeProject.bind(project);
|
||||
project.removeProject = async (projectId) => {
|
||||
await remove(projectId);
|
||||
throw { code: "NOT_FOUND", message: "already absent" };
|
||||
};
|
||||
renderView(project);
|
||||
|
||||
await screen.findByText("/p/a");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Actions pour alpha" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Retirer d'IdeA…" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Retirer d'IdeA" }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
||||
expect(screen.getByText("No projects yet.")).toBeTruthy();
|
||||
expect(screen.getByText(/« alpha » a été retiré d'IdeA/)).toBeTruthy();
|
||||
await expect(project.listProjects()).resolves.not.toContainEqual(alpha);
|
||||
});
|
||||
|
||||
it("creating a project adds it to the known list and opens a tab", async () => {
|
||||
renderView();
|
||||
|
||||
|
||||
@ -40,6 +40,8 @@ export interface ProjectsViewModel {
|
||||
openProject: (projectId: string) => Promise<void>;
|
||||
/** Closes a tab (does not delete the project from the registry). */
|
||||
closeTab: (projectId: string) => Promise<void>;
|
||||
/** Removes a project from IdeA's registry without deleting its files. */
|
||||
removeProject: (projectId: string) => Promise<void>;
|
||||
/** Retries one failed restore, or every failure when omitted. */
|
||||
retryRestore: (projectId?: string) => Promise<void>;
|
||||
/** Removes one failure from the persisted next-launch restore set. */
|
||||
@ -172,6 +174,50 @@ export function useProjects(): ProjectsViewModel {
|
||||
[project],
|
||||
);
|
||||
|
||||
const reconcileRemovedProject = useCallback(
|
||||
(projectId: string, nextProjects: Project[]) => {
|
||||
setProjects(nextProjects);
|
||||
setRestoreFailures((previous) =>
|
||||
previous.filter((failure) => failure.projectId !== projectId),
|
||||
);
|
||||
setOpenTabs((previous) => {
|
||||
const next = previous.filter((tab) => tab.id !== projectId);
|
||||
setActiveTabId((current) =>
|
||||
current === projectId ? (next.at(-1)?.id ?? null) : current,
|
||||
);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const removeProject = useCallback(
|
||||
async (projectId: string) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await project.removeProject(projectId);
|
||||
reconcileRemovedProject(projectId, result.projects);
|
||||
} catch (e) {
|
||||
if (
|
||||
e &&
|
||||
typeof e === "object" &&
|
||||
"code" in e &&
|
||||
String((e as GatewayError).code) === "NOT_FOUND"
|
||||
) {
|
||||
const listed = await project.listProjects();
|
||||
if (!listed.some((candidate) => candidate.id === projectId)) {
|
||||
reconcileRemovedProject(projectId, listed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[project, reconcileRemovedProject],
|
||||
);
|
||||
|
||||
const retryRestore = useCallback(
|
||||
async (projectId?: string) => {
|
||||
setBusy(true);
|
||||
@ -244,6 +290,7 @@ export function useProjects(): ProjectsViewModel {
|
||||
createProject,
|
||||
openProject,
|
||||
closeTab,
|
||||
removeProject,
|
||||
retryRestore,
|
||||
removeFromRestore,
|
||||
dismissRestoreNotification: () => setRestoreNotificationVisible(false),
|
||||
|
||||
@ -63,6 +63,7 @@ function gateways(overrides: {
|
||||
createProject: vi.fn(),
|
||||
openProject: vi.fn(),
|
||||
closeProject: vi.fn(),
|
||||
removeProject: vi.fn(),
|
||||
restoreOpenProjects: vi.fn(async () => ({ opened: [], failures: [] })),
|
||||
retryOpenProjectRestore: vi.fn(async () => ({ opened: [], failures: [] })),
|
||||
removeProjectFromRestore: vi.fn(async () => ({ version: 1, entries: [], updatedAt: 0 })),
|
||||
|
||||
@ -72,6 +72,7 @@ import type {
|
||||
Project,
|
||||
OpenProjectRestoreState,
|
||||
RestoreOpenProjectsOutput,
|
||||
RemoveProjectOutput,
|
||||
ProjectFileListing,
|
||||
ProjectPermissions,
|
||||
ProjectWorkState,
|
||||
@ -579,6 +580,8 @@ export interface ProjectGateway {
|
||||
openProject(projectId: string): Promise<Project>;
|
||||
/** Closes a project by id. */
|
||||
closeProject(projectId: string): Promise<void>;
|
||||
/** Removes a project from IdeA's registry without touching its files. */
|
||||
removeProject(projectId: string): Promise<RemoveProjectOutput>;
|
||||
/** Restores the projects retained at the end of the previous app run. */
|
||||
restoreOpenProjects(): Promise<RestoreOpenProjectsOutput>;
|
||||
/** Retries one retained restore failure, or every failure when omitted. */
|
||||
|
||||
Reference in New Issue
Block a user