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

@ -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);

View File

@ -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");
}

View File

@ -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[] = [];

View File

@ -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" },
});
});
});

View File

@ -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");
}

View File

@ -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;

View 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,
)}
</>
);
}

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}

View 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>
);
}

View File

@ -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();

View File

@ -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),

View File

@ -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 })),

View File

@ -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. */