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:
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -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")]
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -133,6 +133,20 @@ impl ProjectStore for FsProjectStore {
|
||||
self.write_registry(®istry).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(®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)
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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[] = [];
|
||||
|
||||
@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
105
frontend/src/features/projects/ProjectActionsMenu.tsx
Normal file
105
frontend/src/features/projects/ProjectActionsMenu.tsx
Normal 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,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
94
frontend/src/features/projects/RemoveProjectDialog.tsx
Normal file
94
frontend/src/features/projects/RemoveProjectDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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 })),
|
||||
|
||||
@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user