From b9f8ed303ea91de6dc80c8ac0eb651a74395b2d1 Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 10 Sep 2026 18:27:15 +0200 Subject: [PATCH] =?UTF-8?q?feat(backend,domain,infrastructure,application,?= =?UTF-8?q?app-tauri,web-server,frontend):=20suppression=20d'un=20projet?= =?UTF-8?q?=20du=20registre=20IdeA=20sans=20toucher=20son=20dossier=20disq?= =?UTF-8?q?ue=20=E2=80=94=20#308=20(QA=20verte)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- crates/app-tauri/src/commands.rs | 80 ++++++++-- crates/app-tauri/src/lib.rs | 1 + crates/application/src/lib.rs | 7 +- crates/application/src/project/mod.rs | 2 + crates/application/src/project/remove.rs | 44 ++++++ crates/application/tests/project_usecases.rs | 42 ++++- crates/backend/src/dto.rs | 28 ++++ crates/backend/src/lib.rs | 147 ++++++++++++++++-- crates/domain/src/ports.rs | 13 ++ crates/infrastructure/src/store/project.rs | 14 ++ crates/infrastructure/tests/project_store.rs | 38 +++++ crates/web-server/src/lib.rs | 91 ++++++++++- .../src/adapters/http/httpInvoker.test.ts | 12 ++ .../adapters/http/requestResponseGateways.ts | 6 + frontend/src/adapters/mock/index.ts | 23 +++ frontend/src/adapters/project.test.ts | 10 ++ frontend/src/adapters/project.ts | 7 + frontend/src/domain/index.ts | 8 + .../features/projects/ProjectActionsMenu.tsx | 105 +++++++++++++ .../src/features/projects/ProjectsView.tsx | 78 +++++++++- .../features/projects/RemoveProjectDialog.tsx | 94 +++++++++++ .../src/features/projects/projects.test.tsx | 137 ++++++++++++++++ frontend/src/features/projects/useProjects.ts | 47 ++++++ frontend/src/plugins/runtime/services.test.ts | 1 + frontend/src/ports/index.ts | 3 + 25 files changed, 1009 insertions(+), 29 deletions(-) create mode 100644 crates/application/src/project/remove.rs create mode 100644 frontend/src/features/projects/ProjectActionsMenu.tsx create mode 100644 frontend/src/features/projects/RemoveProjectDialog.tsx diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 2bb818ec..f9796e60 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -69,15 +69,16 @@ use crate::dto::{ ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto, ReadChatDraftResponseDto, ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, - RecallMemoryRequestDto, RemoveOpenProjectRestoreEntryRequestDto, RenameLayoutRequestDto, - ReplyChunk, ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, - ResolveAgentPermissionsResponseDto, ResolveAgentSystemPermissionsRequestDto, - ResolvedAgentSystemPermissionsDto, RestoreOpenProjectsOutputDto, ResumableAgentListDto, - RetryOpenProjectRestoreRequestDto, SaveChatDraftRequestDto, SaveEmbedderProfileRequestDto, - SaveModelServerRequestDto, SaveOpenCodeProviderProfileRequestDto, - SaveOpenProjectRestoreOrderRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, - SetActiveLayoutResultDto, SetAgentMcpToolAllowedRequestDto, SkillDto, SkillListDto, - SlashCommandEffectDto, SlashCommandListDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto, + RecallMemoryRequestDto, RemoveOpenProjectRestoreEntryRequestDto, RemoveProjectRequestDto, + RemoveProjectResponseDto, RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto, + ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto, + ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto, + RestoreOpenProjectsOutputDto, ResumableAgentListDto, RetryOpenProjectRestoreRequestDto, + SaveChatDraftRequestDto, SaveEmbedderProfileRequestDto, SaveModelServerRequestDto, + SaveOpenCodeProviderProfileRequestDto, SaveOpenProjectRestoreOrderRequestDto, + SaveProfileRequestDto, SetActiveLayoutRequestDto, SetActiveLayoutResultDto, + SetAgentMcpToolAllowedRequestDto, SkillDto, SkillListDto, SlashCommandEffectDto, + SlashCommandListDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto, UpdateAgentContactableRequestDto, UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto, @@ -584,6 +585,45 @@ mod open_project_template_tests { assert!(after_open.default_system_permissions.is_none()); assert!(after_open.default_mcp_tool_policy.is_none()); } + + #[tokio::test] + async fn remove_project_adapter_returns_the_shared_contract() { + let state = AppState::build(temp_path("remove-appdata")); + let project_root = temp_path("remove-project"); + let project = state + .create_project + .execute(CreateProjectInput { + name: "Remove Adapter".to_owned(), + root: project_root.to_string_lossy().into_owned(), + remote: None, + default_profile_id: None, + }) + .await + .unwrap() + .project; + state + .record_project_opened_for_restore + .execute(project.clone()) + .await + .unwrap(); + + let response = remove_project_for_adapter( + RemoveProjectRequestDto { + project_id: project.id.to_string(), + }, + &state, + ) + .await + .unwrap(); + + assert_eq!(response.removed.id, project.id.to_string()); + assert!(response.projects.is_empty()); + assert!(response.restore_state.entries.is_empty()); + assert!(response.was_open); + assert!(project_root.exists()); + + let _ = std::fs::remove_dir_all(project_root); + } } /// `close_project` — persist state and release resources for a project. @@ -619,6 +659,28 @@ pub async fn close_project(project_id: String, state: State<'_, AppState>) -> Re .map_err(ErrorDto::from) } +/// Removes one project from IdeA's registry without touching its root directory. +#[tauri::command] +pub async fn remove_project( + request: RemoveProjectRequestDto, + state: State<'_, AppState>, +) -> Result { + remove_project_for_adapter(request, &state).await +} + +/// Shared desktop/web adapter body for project removal. +pub async fn remove_project_for_adapter( + request: RemoveProjectRequestDto, + state: &AppState, +) -> Result { + let project_id = parse_project_id(&request.project_id)?; + state + .remove_project_and_deactivate(project_id) + .await + .map(Into::into) + .map_err(ErrorDto::from) +} + /// Reads the ordered open-project restore state without opening projects. #[tauri::command] pub async fn read_open_project_restore_state( diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index e05ff604..074da388 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -254,6 +254,7 @@ pub fn run() { commands::create_project, commands::open_project, commands::close_project, + commands::remove_project, commands::read_open_project_restore_state, commands::restore_open_projects, commands::retry_open_project_restore, diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index acc4ebfb..afcdb29a 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -208,9 +208,10 @@ pub use project::{ OpenProjectRestoreService, ProjectFileEntry, ProjectFileKind, ProjectFileListing, ProjectMeta, ReadOpenProjectRestoreState, ReadProjectContext, ReadProjectContextInput, ReadProjectContextOutput, RecordProjectClosedForRestore, RecordProjectOpenedForRestore, - RemoveProjectFromRestore, RestoreOpenProjectFailure, RestoreOpenProjects, - RestoreOpenProjectsOutput, RetryOpenProjectRestore, SaveOpenProjectRestoreOrder, - UpdateProjectContext, UpdateProjectContextInput, PROJECT_CONTEXT_FILE, + RemoveProjectFromRegistry, RemoveProjectFromRegistryOutput, RemoveProjectFromRestore, + RestoreOpenProjectFailure, RestoreOpenProjects, RestoreOpenProjectsOutput, + RetryOpenProjectRestore, SaveOpenProjectRestoreOrder, UpdateProjectContext, + UpdateProjectContextInput, PROJECT_CONTEXT_FILE, }; pub use project_plugin::{ plugin_skill_ref, plugin_tool_ref, AssignmentMutation, ListProjectPluginSettings, diff --git a/crates/application/src/project/mod.rs b/crates/application/src/project/mod.rs index 6cf560db..ea4c09c6 100644 --- a/crates/application/src/project/mod.rs +++ b/crates/application/src/project/mod.rs @@ -19,6 +19,7 @@ mod create; mod files; pub(crate) mod meta; mod open; +mod remove; mod restore; pub use close::{CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput}; @@ -35,6 +36,7 @@ pub use meta::ProjectMeta; pub use open::{ ListProjects, ListProjectsOutput, OpenProject, OpenProjectInput, OpenProjectOutput, }; +pub use remove::{RemoveProjectFromRegistry, RemoveProjectFromRegistryOutput}; pub use restore::{ OpenProjectRestoreService, ReadOpenProjectRestoreState, RecordProjectClosedForRestore, RecordProjectOpenedForRestore, RemoveProjectFromRestore, RestoreOpenProjectFailure, diff --git a/crates/application/src/project/remove.rs b/crates/application/src/project/remove.rs new file mode 100644 index 00000000..b44d6c9c --- /dev/null +++ b/crates/application/src/project/remove.rs @@ -0,0 +1,44 @@ +//! Removal of a project from the known-project registry. + +use std::sync::Arc; + +use domain::ports::ProjectStore; +use domain::{Project, ProjectId}; + +use crate::AppError; + +/// Result of removing one registered project. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoveProjectFromRegistryOutput { + /// The registry entry that was removed. + pub removed: Project, + /// Complete registry after removal, preserving its prior relative order. + pub projects: Vec, +} + +/// Removes a registry entry without performing any operation on its root path. +pub struct RemoveProjectFromRegistry { + store: Arc, +} + +impl RemoveProjectFromRegistry { + /// Builds the use case from the project registry port. + #[must_use] + pub fn new(store: Arc) -> Self { + Self { store } + } + + /// Removes `project_id` and returns the refreshed registry. + pub async fn execute( + &self, + project_id: ProjectId, + ) -> Result { + let removed = self + .store + .remove_project(project_id) + .await? + .ok_or_else(|| AppError::NotFound(format!("project {project_id}")))?; + let projects = self.store.list_projects().await?; + Ok(RemoveProjectFromRegistryOutput { removed, projects }) + } +} diff --git a/crates/application/tests/project_usecases.rs b/crates/application/tests/project_usecases.rs index c4d23f06..5f6a4a0d 100644 --- a/crates/application/tests/project_usecases.rs +++ b/crates/application/tests/project_usecases.rs @@ -23,7 +23,7 @@ use domain::{Project, ProjectId, ProjectPath, RemoteRef}; use application::{ CloseProject, CloseProjectInput, CloseTab, CloseTabInput, CreateProject, CreateProjectInput, ListProjects, OpenProject, OpenProjectInput, ReadProjectContext, ReadProjectContextInput, - UpdateProjectContext, UpdateProjectContextInput, + RemoveProjectFromRegistry, UpdateProjectContext, UpdateProjectContextInput, }; // --------------------------------------------------------------------------- @@ -132,6 +132,14 @@ impl ProjectStore for FakeStore { Ok(()) } + async fn remove_project(&self, id: ProjectId) -> Result, StoreError> { + let mut inner = self.0.lock().unwrap(); + let Some(position) = inner.projects.iter().position(|project| project.id == id) else { + return Ok(None); + }; + Ok(Some(inner.projects.remove(position))) + } + async fn save_workspace(&self, workspace: &Workspace) -> Result<(), StoreError> { self.0.lock().unwrap().workspace = Some(workspace.clone()); Ok(()) @@ -158,6 +166,9 @@ impl ProjectStore for BrokenStore { async fn save_project(&self, _project: &Project) -> Result<(), StoreError> { Err(StoreError::Io("boom".into())) } + async fn remove_project(&self, _id: ProjectId) -> Result, StoreError> { + Err(StoreError::Io("boom".into())) + } async fn save_workspace(&self, _w: &Workspace) -> Result<(), StoreError> { Err(StoreError::Io("boom".into())) } @@ -548,6 +559,35 @@ async fn list_projects_returns_registered() { assert!(names.contains(&"A") && names.contains(&"B")); } +#[tokio::test] +async fn remove_project_returns_removed_entry_and_ordered_registry() { + let env = env(); + let first = env.create.execute(input("A", "/a")).await.unwrap().project; + let removed = env.create.execute(input("B", "/b")).await.unwrap().project; + let last = env.create.execute(input("C", "/c")).await.unwrap().project; + let use_case = RemoveProjectFromRegistry::new(Arc::new(env.store)); + + let output = use_case.execute(removed.id).await.unwrap(); + + assert_eq!(output.removed, removed); + assert_eq!(output.projects, vec![first, last]); +} + +#[tokio::test] +async fn remove_project_maps_absent_and_store_failures() { + let missing = RemoveProjectFromRegistry::new(Arc::new(FakeStore::default())) + .execute(ProjectId::new_random()) + .await + .unwrap_err(); + assert_eq!(missing.code(), "NOT_FOUND"); + + let failed = RemoveProjectFromRegistry::new(Arc::new(BrokenStore)) + .execute(ProjectId::new_random()) + .await + .unwrap_err(); + assert_eq!(failed.code(), "STORE"); +} + // --------------------------------------------------------------------------- // CloseProject / CloseTab // --------------------------------------------------------------------------- diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index 9b2f6ced..609e407b 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -1245,6 +1245,34 @@ pub struct RemoveOpenProjectRestoreEntryRequestDto { pub project_id: String, } +/// Removes a project from IdeA's registry without deleting its files. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoveProjectRequestDto { + pub project_id: String, +} + +/// Registry and open-project state returned after a project is removed. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoveProjectResponseDto { + pub removed: ProjectDto, + pub projects: Vec, + pub restore_state: OpenProjectRestoreStateDto, + pub was_open: bool, +} + +impl From for RemoveProjectResponseDto { + fn from(result: crate::RemoveProjectResult) -> Self { + Self { + removed: result.removed.into(), + projects: result.projects.into_iter().map(Into::into).collect(), + restore_state: result.restore_state.into(), + was_open: result.was_open, + } + } +} + /// Persists the visible open-project order. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 819d7cda..88d22ec0 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -48,16 +48,16 @@ use application::{ ReadTemplate, RecallMemory, ReconcileLayouts, ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordProjectClosedForRestore, RecordProjectOpenedForRestore, RecordTurn, RecordTurnProvider, ReferenceProfiles, ReleaseManagedTemp, ReloadPlugin, - ReloadPluginInput, RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, - ResolveAgentCapabilities, ResolveAgentPermissions, ResolveAgentSystemPermissions, - ResolveManagedTemp, ResolveMemoryLinks, RestoreOpenProjects, RestoreOpenProjectsOutput, - RestoreOpenWindows, RetryBackgroundTask, RetryOpenProjectRestore, ReviewPluginPackage, - RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveChatDraft, SaveEmbedderProfile, - SaveModelServer, SaveOpenCodeProviderProfile, SaveOpenProjectRestoreOrder, SaveProfile, - SessionLimitService, SetActiveLayout, SetAgentMcpToolAllowed, SetPluginEnabled, + ReloadPluginInput, RemoveProjectFromRegistry, RenameDevice, RenameLayout, RenameSprint, + ReorderSprints, ResizeTerminal, ResolveAgentCapabilities, ResolveAgentPermissions, + ResolveAgentSystemPermissions, ResolveManagedTemp, ResolveMemoryLinks, RestoreOpenProjects, + RestoreOpenProjectsOutput, RestoreOpenWindows, RetryBackgroundTask, RetryOpenProjectRestore, + ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveChatDraft, + SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveOpenProjectRestoreOrder, + SaveProfile, SessionLimitService, SetActiveLayout, SetAgentMcpToolAllowed, SetPluginEnabled, SetProjectPluginEnabled, SlashCommandRegistry, SnapshotOpenWindows, SnapshotRunningAgents, - SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions, - SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice, + SnapshotRunningAgentsInput, SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, + StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContactable, UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet, @@ -90,7 +90,7 @@ use domain::remote::RemoteKind; use domain::{ AgentId, AgentInbox, BackgroundTask, BackgroundTaskResult, BackgroundTaskWakePolicy, DomainEvent, EmbedderProfile, InboxError, InboxItem, InboxItemKind, InboxReceiptStatus, - InboxSource, Project, ProjectId, TaskId, TicketId, + InboxSource, OpenProjectRestoreState, Project, ProjectId, TaskId, TicketId, }; use serde_json::{json, Map, Value}; use uuid::Uuid; @@ -1815,6 +1815,19 @@ impl ManagedTempToolProvider for LateBoundManagedTempToolProvider { } } +/// Transport-neutral result of removing one project from the registry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoveProjectResult { + /// Removed registry entry. Its root remains untouched. + pub removed: Project, + /// Registry contents after removal. + pub projects: Vec, + /// Ordered open-project state after cleanup. + pub restore_state: OpenProjectRestoreState, + /// Whether the project was open in persisted or live runtime state. + pub was_open: bool, +} + /// Everything a driving adapter needs at runtime. /// /// Use cases are stored behind `Arc` so handlers clone cheaply. The concrete @@ -1852,6 +1865,8 @@ pub struct BackendCore { pub open_project: Arc, /// Close a project (persist state). pub close_project: Arc, + /// Remove a project from the known-project registry. + pub remove_project_from_registry: Arc, /// Read the ordered open-project restore intent. pub read_open_project_restore_state: Arc, /// Record a visibly opened project. @@ -1955,6 +1970,8 @@ pub struct BackendCore { pub pty_port: Arc, /// Active-terminal registry shared by the terminal use cases. pub terminal_sessions: Arc, + /// Unified live-session registry used for project-wide best-effort shutdown. + pub live_sessions: Arc, /// The domain event bus (also handed to the event relay). pub event_bus: Arc, /// Create an issue-backed public ticket. @@ -2512,6 +2529,8 @@ impl BackendCore { Arc::clone(&fs_port), )); let close_project = Arc::new(CloseProject::new(Arc::clone(&store_port))); + let remove_project_from_registry = + Arc::new(RemoveProjectFromRegistry::new(Arc::clone(&store_port))); let open_project_restore_service = Arc::new(application::OpenProjectRestoreService::new( Arc::clone(&open_project_restore_store_port), Arc::clone(&store_port), @@ -4127,6 +4146,7 @@ impl BackendCore { create_project, open_project, close_project, + remove_project_from_registry, read_open_project_restore_state, record_project_opened_for_restore, record_project_closed_for_restore, @@ -4175,6 +4195,7 @@ impl BackendCore { delete_model_artifact, pty_port, terminal_sessions, + live_sessions, event_bus, create_issue, read_issue, @@ -4390,6 +4411,76 @@ impl BackendCore { Ok(output) } + /// Deactivates an open project best-effort, removes its restore intent, then + /// removes only its registry entry. The project root is never deleted. + pub async fn remove_project_and_deactivate( + &self, + project_id: ProjectId, + ) -> Result { + let project = self.project_store.load_project(project_id).await?; + let before = self.read_open_project_restore_state.execute().await?; + let was_open = before + .entries + .iter() + .any(|entry| entry.project_id == project_id) + || self.open_project_ids().contains(&project_id) + || self + .live_sessions + .live_agent_snapshots() + .iter() + .any(|snapshot| snapshot.project_id == project_id); + + if was_open { + let _ = self + .snapshot_running_agents + .execute(SnapshotRunningAgentsInput { project_id }) + .await; + self.stop_orchestrator_watch(&project_id); + + let agent_ids = self + .live_sessions + .live_agent_snapshots() + .into_iter() + .filter(|snapshot| snapshot.project_id == project_id) + .map(|snapshot| snapshot.agent_id) + .collect::>(); + for agent_id in agent_ids { + self.stop_turn_watch(project_id, agent_id); + let _ = application::terminal::stop_live_agent_runtime_in_project( + &project, + agent_id, + self.live_sessions.as_ref(), + self.close_terminal.as_ref(), + ) + .await; + } + } + + let restore_state = self + .record_project_closed_for_restore + .execute(project_id) + .await?; + let output = match self.remove_project_from_registry.execute(project_id).await { + Ok(output) => output, + Err(error) => { + if was_open { + let _ = self + .record_project_opened_for_restore + .execute(project.clone()) + .await; + } + return Err(error); + } + }; + + Ok(RemoveProjectResult { + removed: output.removed, + projects: output.projects, + restore_state, + was_open, + }) + } + /// Starts a [`FsOrchestratorWatcher`] for `project` if one is not already /// running for it (idempotent). The watcher tails the project's /// `.ideai/requests/` tree and dispatches each request through the shared @@ -8618,4 +8709,40 @@ mod open_project_restore_tests { let _ = std::fs::remove_dir_all(&app_data); let _ = std::fs::remove_dir_all(&roots); } + + #[tokio::test] + async fn remove_project_cleans_open_state_and_registry_without_deleting_root() { + let app_data = temp_path("remove-appdata"); + let root = temp_path("remove-root"); + let core = BackendCore::build(app_data.clone()); + let project = create_project(&core, &root, "Removed").await; + core.record_project_opened_for_restore + .execute(project.clone()) + .await + .expect("open state is recorded"); + core.activate_project(&project).await; + + let output = core + .remove_project_and_deactivate(project.id) + .await + .expect("registered project can be removed"); + + assert_eq!(output.removed, project); + assert!(output.was_open); + assert!(output.projects.is_empty()); + assert!(output.restore_state.entries.is_empty()); + assert!(!core.open_project_ids().contains(&output.removed.id)); + assert!(root.exists(), "project root must remain on disk"); + assert!(root.join(".ideai/project.json").exists()); + assert_eq!( + core.remove_project_and_deactivate(output.removed.id) + .await + .unwrap_err() + .code(), + "NOT_FOUND" + ); + + let _ = std::fs::remove_dir_all(&app_data); + let _ = std::fs::remove_dir_all(&root); + } } diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index dd726789..d6b7dfe8 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -2294,6 +2294,19 @@ pub trait ProjectStore: Send + Sync { /// [`StoreError`] on failure. async fn save_project(&self, project: &Project) -> Result<(), StoreError>; + /// Removes a project from the known-project registry without touching its root. + /// + /// Returns the removed project, or `None` when the id was not registered. + async fn remove_project( + &self, + id: crate::ids::ProjectId, + ) -> Result, StoreError> { + let _ = id; + Err(StoreError::Invalid( + "project removal is not supported by this store".to_owned(), + )) + } + /// Saves the whole workspace (windows/tabs/layouts). /// /// # Errors diff --git a/crates/infrastructure/src/store/project.rs b/crates/infrastructure/src/store/project.rs index 49b2e8cd..da076002 100644 --- a/crates/infrastructure/src/store/project.rs +++ b/crates/infrastructure/src/store/project.rs @@ -133,6 +133,20 @@ impl ProjectStore for FsProjectStore { self.write_registry(®istry).await } + async fn remove_project(&self, id: ProjectId) -> Result, StoreError> { + let mut registry = self.read_registry().await?; + let Some(position) = registry + .projects + .iter() + .position(|project| project.id == id) + else { + return Ok(None); + }; + let removed = registry.projects.remove(position); + self.write_registry(®istry).await?; + Ok(Some(removed)) + } + async fn save_workspace(&self, workspace: &Workspace) -> Result<(), StoreError> { self.ensure_dir().await?; let bytes = serde_json::to_vec_pretty(workspace) diff --git a/crates/infrastructure/tests/project_store.rs b/crates/infrastructure/tests/project_store.rs index c21899d6..652d1b81 100644 --- a/crates/infrastructure/tests/project_store.rs +++ b/crates/infrastructure/tests/project_store.rs @@ -92,6 +92,44 @@ async fn missing_registry_lists_empty() { assert!(listed.is_empty()); } +#[tokio::test] +async fn remove_project_preserves_order_and_never_touches_project_root() { + let tmp = TempDir::new(); + let store = store(&tmp); + let root = tmp.0.join("owned-project-root"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("keep.txt"), b"untouched").unwrap(); + + let first = sample_project(ProjectId::new_random(), "first", "/outside/first"); + let removed = sample_project(ProjectId::new_random(), "removed", &root.to_string_lossy()); + let last = sample_project(ProjectId::new_random(), "last", "/outside/last"); + for project in [&first, &removed, &last] { + store.save_project(project).await.unwrap(); + } + + assert_eq!( + store.remove_project(removed.id).await.unwrap(), + Some(removed) + ); + assert_eq!(store.list_projects().await.unwrap(), vec![first, last]); + assert_eq!(std::fs::read(root.join("keep.txt")).unwrap(), b"untouched"); +} + +#[tokio::test] +async fn remove_missing_project_does_not_rewrite_the_registry() { + let tmp = TempDir::new(); + let store = store(&tmp); + let project = sample_project(ProjectId::new_random(), "kept", "/outside/kept"); + store.save_project(&project).await.unwrap(); + let before = std::fs::read(tmp.0.join("projects.json")).unwrap(); + + assert_eq!( + store.remove_project(ProjectId::new_random()).await.unwrap(), + None + ); + assert_eq!(std::fs::read(tmp.0.join("projects.json")).unwrap(), before); +} + #[tokio::test] async fn workspace_save_then_load_roundtrips() { let tmp = TempDir::new(); diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index 0177e2b7..5a245e0d 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -95,8 +95,8 @@ use backend::dto::{ ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto, ReadConversationPageRequestDto, RecallMemoryRequestDto, - RemoveOpenProjectRestoreEntryRequestDto, RenameLayoutRequestDto, - ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto, + RemoveOpenProjectRestoreEntryRequestDto, RemoveProjectRequestDto, RemoveProjectResponseDto, + RenameLayoutRequestDto, ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto, ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto, RestoreOpenProjectsOutputDto, ResumableAgentListDto, RetryOpenProjectRestoreRequestDto, SaveEmbedderProfileRequestDto, SaveOpenCodeProviderProfileRequestDto, @@ -3347,6 +3347,7 @@ async fn invoke( "health" => invoke_health(&request.args, &state.app), "list_projects" => invoke_list_projects(&state.app).await, "open_project" => invoke_open_project(&request.args, &state.app).await, + "remove_project" => invoke_remove_project(&request.args, &state.app).await, "read_open_project_restore_state" => { invoke_read_open_project_restore_state(&state.app).await } @@ -3552,6 +3553,16 @@ async fn invoke_open_project(args: &Value, state: &BackendCore) -> Result Result { + let request = required_request::("remove_project", args)?; + let output = state + .remove_project_and_deactivate(parse_project_id(&request.project_id)?) + .await + .map(RemoveProjectResponseDto::from) + .map_err(ErrorDto::from)?; + serde_json::to_value(output).map_err(serialization_error) +} + async fn open_project_for_web( project_id: &str, state: &BackendCore, @@ -9617,6 +9628,82 @@ mod tests { .is_some_and(|root| root.contains("idea-server-project-"))); } + #[tokio::test] + async fn authorized_remove_project_returns_common_contract_and_keeps_files() { + let state = state(); + let project_id = create_project_for_test(&state, "Remove Web").await; + let project = state + .app + .project_store + .load_project(parse_project_id(&project_id).unwrap()) + .await + .unwrap(); + state + .app + .record_project_opened_for_restore + .execute(project.clone()) + .await + .unwrap(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + let response = request( + Arc::clone(&state), + Method::POST, + "/api/invoke", + json!({ + "command": "remove_project", + "args": { "request": { "projectId": project_id } } + }), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["removed"]["id"], project_id); + assert_eq!(body["projects"], json!([])); + assert_eq!(body["restoreState"]["entries"], json!([])); + assert_eq!(body["wasOpen"], true); + assert!(std::path::Path::new(project.root.as_str()).exists()); + + let repeated = request( + state, + Method::POST, + "/api/invoke", + json!({ + "command": "remove_project", + "args": { "request": { "projectId": project_id } } + }), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(repeated).await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(body["code"], "NOT_FOUND"); + + let _ = std::fs::remove_dir_all(project.root.as_str()); + } + + #[tokio::test] + async fn remove_project_rejects_invalid_id() { + let state = state(); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + let response = request( + state, + Method::POST, + "/api/invoke", + json!({ + "command": "remove_project", + "args": { "request": { "projectId": "not-a-uuid" } } + }), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["code"], "INVALID"); + } + #[tokio::test] async fn authorized_open_project_restore_commands_share_the_tauri_contract() { let state = state(); diff --git a/frontend/src/adapters/http/httpInvoker.test.ts b/frontend/src/adapters/http/httpInvoker.test.ts index 10d49203..f9e1be35 100644 --- a/frontend/src/adapters/http/httpInvoker.test.ts +++ b/frontend/src/adapters/http/httpInvoker.test.ts @@ -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); diff --git a/frontend/src/adapters/http/requestResponseGateways.ts b/frontend/src/adapters/http/requestResponseGateways.ts index a525d85d..80f15c80 100644 --- a/frontend/src/adapters/http/requestResponseGateways.ts +++ b/frontend/src/adapters/http/requestResponseGateways.ts @@ -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 { await this.http.invoke("close_project", { projectId }); } + removeProject(projectId: string): Promise { + return this.http.invoke("remove_project", { + request: { projectId }, + }); + } restoreOpenProjects(): Promise { return this.http.invoke("restore_open_projects"); } diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 089e1a97..9ef426f1 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -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 { + 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 { const failures: OpenProjectRestoreFailure[] = []; const opened: Project[] = []; diff --git a/frontend/src/adapters/project.test.ts b/frontend/src/adapters/project.test.ts index 7c77d098..4fb7ec88 100644 --- a/frontend/src/adapters/project.test.ts +++ b/frontend/src/adapters/project.test.ts @@ -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" }, + }); + }); }); diff --git a/frontend/src/adapters/project.ts b/frontend/src/adapters/project.ts index 53af5fc6..c1fff1d3 100644 --- a/frontend/src/adapters/project.ts +++ b/frontend/src/adapters/project.ts @@ -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 { + return invoke("remove_project", { + request: { projectId }, + }); + } + restoreOpenProjects(): Promise { return invoke("restore_open_projects"); } diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 0e9ab7e5..36c24a5b 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -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; diff --git a/frontend/src/features/projects/ProjectActionsMenu.tsx b/frontend/src/features/projects/ProjectActionsMenu.tsx new file mode 100644 index 00000000..3ef81ddf --- /dev/null +++ b/frontend/src/features/projects/ProjectActionsMenu.tsx @@ -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(null); + const menuRef = useRef(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 ( + <> + + ⋯ + + {open && + createPortal( +
+ + +
, + document.body, + )} + + ); +} diff --git a/frontend/src/features/projects/ProjectsView.tsx b/frontend/src/features/projects/ProjectsView.tsx index a61bb761..559c5dd4 100644 --- a/frontend/src/features/projects/ProjectsView.tsx +++ b/frontend/src/features/projects/ProjectsView.tsx @@ -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([]); const [detachErrorPanel, setDetachErrorPanel] = useState(null); const [restoreDetailsOpen, setRestoreDetailsOpen] = useState(false); + const [removalTarget, setRemovalTarget] = useState(null); + const [removalPending, setRemovalPending] = useState(false); + const [removalError, setRemovalError] = useState(null); + const [removalNotice, setRemovalNotice] = useState(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() { {p.name} {p.root} - + + + void vm.openProject(p.id)} + onRemove={() => { + setRemovalError(null); + setRemovalTarget(p); + }} + /> + ))} @@ -1076,8 +1112,21 @@ export function ProjectsView() { + {removalTarget && ( + 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) && (
)} + {removalNotice && ( +
+
+ + « {removalNotice.name} » a été retiré d'IdeA. Les fichiers sont restés + sur le disque. + + +
+
+ )} {taskToasts.map((toast) => ( + + + + + ); +} diff --git a/frontend/src/features/projects/projects.test.tsx b/frontend/src/features/projects/projects.test.tsx index 6ab714cc..3a332986 100644 --- a/frontend/src/features/projects/projects.test.tsx +++ b/frontend/src/features/projects/projects.test.tsx @@ -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((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(); diff --git a/frontend/src/features/projects/useProjects.ts b/frontend/src/features/projects/useProjects.ts index 43f269fb..cd34b544 100644 --- a/frontend/src/features/projects/useProjects.ts +++ b/frontend/src/features/projects/useProjects.ts @@ -40,6 +40,8 @@ export interface ProjectsViewModel { openProject: (projectId: string) => Promise; /** Closes a tab (does not delete the project from the registry). */ closeTab: (projectId: string) => Promise; + /** Removes a project from IdeA's registry without deleting its files. */ + removeProject: (projectId: string) => Promise; /** Retries one failed restore, or every failure when omitted. */ retryRestore: (projectId?: string) => Promise; /** 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), diff --git a/frontend/src/plugins/runtime/services.test.ts b/frontend/src/plugins/runtime/services.test.ts index ee36d16c..66d37ce9 100644 --- a/frontend/src/plugins/runtime/services.test.ts +++ b/frontend/src/plugins/runtime/services.test.ts @@ -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 })), diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 427e4f30..abfe9473 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -72,6 +72,7 @@ import type { Project, OpenProjectRestoreState, RestoreOpenProjectsOutput, + RemoveProjectOutput, ProjectFileListing, ProjectPermissions, ProjectWorkState, @@ -579,6 +580,8 @@ export interface ProjectGateway { openProject(projectId: string): Promise; /** Closes a project by id. */ closeProject(projectId: string): Promise; + /** Removes a project from IdeA's registry without touching its files. */ + removeProject(projectId: string): Promise; /** Restores the projects retained at the end of the previous app run. */ restoreOpenProjects(): Promise; /** Retries one retained restore failure, or every failure when omitted. */