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:
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
44
crates/application/src/project/remove.rs
Normal file
44
crates/application/src/project/remove.rs
Normal file
@ -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<Project>,
|
||||
}
|
||||
|
||||
/// Removes a registry entry without performing any operation on its root path.
|
||||
pub struct RemoveProjectFromRegistry {
|
||||
store: Arc<dyn ProjectStore>,
|
||||
}
|
||||
|
||||
impl RemoveProjectFromRegistry {
|
||||
/// Builds the use case from the project registry port.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn ProjectStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// Removes `project_id` and returns the refreshed registry.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
) -> Result<RemoveProjectFromRegistryOutput, AppError> {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@ -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<Option<Project>, 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<Option<Project>, 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user