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:
@ -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<Value,
|
||||
serde_json::to_value(project).map_err(serialization_error)
|
||||
}
|
||||
|
||||
async fn invoke_remove_project(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
|
||||
let request = required_request::<RemoveProjectRequestDto>("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();
|
||||
|
||||
Reference in New Issue
Block a user