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

@ -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<RemoveProjectResponseDto, ErrorDto> {
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<RemoveProjectResponseDto, ErrorDto> {
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(

View File

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

View File

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

View File

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

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

View File

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

View File

@ -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<ProjectDto>,
pub restore_state: OpenProjectRestoreStateDto,
pub was_open: bool,
}
impl From<crate::RemoveProjectResult> 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")]

View File

@ -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<Project>,
/// 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<OpenProject>,
/// Close a project (persist state).
pub close_project: Arc<CloseProject>,
/// Remove a project from the known-project registry.
pub remove_project_from_registry: Arc<RemoveProjectFromRegistry>,
/// Read the ordered open-project restore intent.
pub read_open_project_restore_state: Arc<ReadOpenProjectRestoreState>,
/// Record a visibly opened project.
@ -1955,6 +1970,8 @@ pub struct BackendCore {
pub pty_port: Arc<dyn PtyPort>,
/// Active-terminal registry shared by the terminal use cases.
pub terminal_sessions: Arc<TerminalSessions>,
/// Unified live-session registry used for project-wide best-effort shutdown.
pub live_sessions: Arc<LiveSessions>,
/// The domain event bus (also handed to the event relay).
pub event_bus: Arc<TokioBroadcastEventBus>,
/// 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<RemoveProjectResult, AppError> {
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::<HashSet<_>>();
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);
}
}

View File

@ -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<Option<Project>, StoreError> {
let _ = id;
Err(StoreError::Invalid(
"project removal is not supported by this store".to_owned(),
))
}
/// Saves the whole workspace (windows/tabs/layouts).
///
/// # Errors

View File

@ -133,6 +133,20 @@ impl ProjectStore for FsProjectStore {
self.write_registry(&registry).await
}
async fn remove_project(&self, id: ProjectId) -> Result<Option<Project>, 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(&registry).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)

View File

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

View File

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

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