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,
|
ProjectListDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto,
|
||||||
ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
|
ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
|
||||||
ReadChatDraftResponseDto, ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto,
|
ReadChatDraftResponseDto, ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto,
|
||||||
RecallMemoryRequestDto, RemoveOpenProjectRestoreEntryRequestDto, RenameLayoutRequestDto,
|
RecallMemoryRequestDto, RemoveOpenProjectRestoreEntryRequestDto, RemoveProjectRequestDto,
|
||||||
ReplyChunk, ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto,
|
RemoveProjectResponseDto, RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto,
|
||||||
ResolveAgentPermissionsResponseDto, ResolveAgentSystemPermissionsRequestDto,
|
ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto,
|
||||||
ResolvedAgentSystemPermissionsDto, RestoreOpenProjectsOutputDto, ResumableAgentListDto,
|
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
|
||||||
RetryOpenProjectRestoreRequestDto, SaveChatDraftRequestDto, SaveEmbedderProfileRequestDto,
|
RestoreOpenProjectsOutputDto, ResumableAgentListDto, RetryOpenProjectRestoreRequestDto,
|
||||||
SaveModelServerRequestDto, SaveOpenCodeProviderProfileRequestDto,
|
SaveChatDraftRequestDto, SaveEmbedderProfileRequestDto, SaveModelServerRequestDto,
|
||||||
SaveOpenProjectRestoreOrderRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto,
|
SaveOpenCodeProviderProfileRequestDto, SaveOpenProjectRestoreOrderRequestDto,
|
||||||
SetActiveLayoutResultDto, SetAgentMcpToolAllowedRequestDto, SkillDto, SkillListDto,
|
SaveProfileRequestDto, SetActiveLayoutRequestDto, SetActiveLayoutResultDto,
|
||||||
SlashCommandEffectDto, SlashCommandListDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto,
|
SetAgentMcpToolAllowedRequestDto, SkillDto, SkillListDto, SlashCommandEffectDto,
|
||||||
|
SlashCommandListDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto,
|
||||||
SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
|
SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto,
|
||||||
TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
|
TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto,
|
||||||
UpdateAgentContactableRequestDto, UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto,
|
UpdateAgentContactableRequestDto, UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto,
|
||||||
@ -584,6 +585,45 @@ mod open_project_template_tests {
|
|||||||
assert!(after_open.default_system_permissions.is_none());
|
assert!(after_open.default_system_permissions.is_none());
|
||||||
assert!(after_open.default_mcp_tool_policy.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.
|
/// `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)
|
.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.
|
/// Reads the ordered open-project restore state without opening projects.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn read_open_project_restore_state(
|
pub async fn read_open_project_restore_state(
|
||||||
|
|||||||
@ -254,6 +254,7 @@ pub fn run() {
|
|||||||
commands::create_project,
|
commands::create_project,
|
||||||
commands::open_project,
|
commands::open_project,
|
||||||
commands::close_project,
|
commands::close_project,
|
||||||
|
commands::remove_project,
|
||||||
commands::read_open_project_restore_state,
|
commands::read_open_project_restore_state,
|
||||||
commands::restore_open_projects,
|
commands::restore_open_projects,
|
||||||
commands::retry_open_project_restore,
|
commands::retry_open_project_restore,
|
||||||
|
|||||||
@ -208,9 +208,10 @@ pub use project::{
|
|||||||
OpenProjectRestoreService, ProjectFileEntry, ProjectFileKind, ProjectFileListing, ProjectMeta,
|
OpenProjectRestoreService, ProjectFileEntry, ProjectFileKind, ProjectFileListing, ProjectMeta,
|
||||||
ReadOpenProjectRestoreState, ReadProjectContext, ReadProjectContextInput,
|
ReadOpenProjectRestoreState, ReadProjectContext, ReadProjectContextInput,
|
||||||
ReadProjectContextOutput, RecordProjectClosedForRestore, RecordProjectOpenedForRestore,
|
ReadProjectContextOutput, RecordProjectClosedForRestore, RecordProjectOpenedForRestore,
|
||||||
RemoveProjectFromRestore, RestoreOpenProjectFailure, RestoreOpenProjects,
|
RemoveProjectFromRegistry, RemoveProjectFromRegistryOutput, RemoveProjectFromRestore,
|
||||||
RestoreOpenProjectsOutput, RetryOpenProjectRestore, SaveOpenProjectRestoreOrder,
|
RestoreOpenProjectFailure, RestoreOpenProjects, RestoreOpenProjectsOutput,
|
||||||
UpdateProjectContext, UpdateProjectContextInput, PROJECT_CONTEXT_FILE,
|
RetryOpenProjectRestore, SaveOpenProjectRestoreOrder, UpdateProjectContext,
|
||||||
|
UpdateProjectContextInput, PROJECT_CONTEXT_FILE,
|
||||||
};
|
};
|
||||||
pub use project_plugin::{
|
pub use project_plugin::{
|
||||||
plugin_skill_ref, plugin_tool_ref, AssignmentMutation, ListProjectPluginSettings,
|
plugin_skill_ref, plugin_tool_ref, AssignmentMutation, ListProjectPluginSettings,
|
||||||
|
|||||||
@ -19,6 +19,7 @@ mod create;
|
|||||||
mod files;
|
mod files;
|
||||||
pub(crate) mod meta;
|
pub(crate) mod meta;
|
||||||
mod open;
|
mod open;
|
||||||
|
mod remove;
|
||||||
mod restore;
|
mod restore;
|
||||||
|
|
||||||
pub use close::{CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput};
|
pub use close::{CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput};
|
||||||
@ -35,6 +36,7 @@ pub use meta::ProjectMeta;
|
|||||||
pub use open::{
|
pub use open::{
|
||||||
ListProjects, ListProjectsOutput, OpenProject, OpenProjectInput, OpenProjectOutput,
|
ListProjects, ListProjectsOutput, OpenProject, OpenProjectInput, OpenProjectOutput,
|
||||||
};
|
};
|
||||||
|
pub use remove::{RemoveProjectFromRegistry, RemoveProjectFromRegistryOutput};
|
||||||
pub use restore::{
|
pub use restore::{
|
||||||
OpenProjectRestoreService, ReadOpenProjectRestoreState, RecordProjectClosedForRestore,
|
OpenProjectRestoreService, ReadOpenProjectRestoreState, RecordProjectClosedForRestore,
|
||||||
RecordProjectOpenedForRestore, RemoveProjectFromRestore, RestoreOpenProjectFailure,
|
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::{
|
use application::{
|
||||||
CloseProject, CloseProjectInput, CloseTab, CloseTabInput, CreateProject, CreateProjectInput,
|
CloseProject, CloseProjectInput, CloseTab, CloseTabInput, CreateProject, CreateProjectInput,
|
||||||
ListProjects, OpenProject, OpenProjectInput, ReadProjectContext, ReadProjectContextInput,
|
ListProjects, OpenProject, OpenProjectInput, ReadProjectContext, ReadProjectContextInput,
|
||||||
UpdateProjectContext, UpdateProjectContextInput,
|
RemoveProjectFromRegistry, UpdateProjectContext, UpdateProjectContextInput,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -132,6 +132,14 @@ impl ProjectStore for FakeStore {
|
|||||||
Ok(())
|
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> {
|
async fn save_workspace(&self, workspace: &Workspace) -> Result<(), StoreError> {
|
||||||
self.0.lock().unwrap().workspace = Some(workspace.clone());
|
self.0.lock().unwrap().workspace = Some(workspace.clone());
|
||||||
Ok(())
|
Ok(())
|
||||||
@ -158,6 +166,9 @@ impl ProjectStore for BrokenStore {
|
|||||||
async fn save_project(&self, _project: &Project) -> Result<(), StoreError> {
|
async fn save_project(&self, _project: &Project) -> Result<(), StoreError> {
|
||||||
Err(StoreError::Io("boom".into()))
|
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> {
|
async fn save_workspace(&self, _w: &Workspace) -> Result<(), StoreError> {
|
||||||
Err(StoreError::Io("boom".into()))
|
Err(StoreError::Io("boom".into()))
|
||||||
}
|
}
|
||||||
@ -548,6 +559,35 @@ async fn list_projects_returns_registered() {
|
|||||||
assert!(names.contains(&"A") && names.contains(&"B"));
|
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
|
// CloseProject / CloseTab
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -1245,6 +1245,34 @@ pub struct RemoveOpenProjectRestoreEntryRequestDto {
|
|||||||
pub project_id: String,
|
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.
|
/// Persists the visible open-project order.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|||||||
@ -48,16 +48,16 @@ use application::{
|
|||||||
ReadTemplate, RecallMemory, ReconcileLayouts, ReconcileLiveState, ReconcileLiveStateInput,
|
ReadTemplate, RecallMemory, ReconcileLayouts, ReconcileLiveState, ReconcileLiveStateInput,
|
||||||
ReconcilePluginMcpServers, RecordProjectClosedForRestore, RecordProjectOpenedForRestore,
|
ReconcilePluginMcpServers, RecordProjectClosedForRestore, RecordProjectOpenedForRestore,
|
||||||
RecordTurn, RecordTurnProvider, ReferenceProfiles, ReleaseManagedTemp, ReloadPlugin,
|
RecordTurn, RecordTurnProvider, ReferenceProfiles, ReleaseManagedTemp, ReloadPlugin,
|
||||||
ReloadPluginInput, RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal,
|
ReloadPluginInput, RemoveProjectFromRegistry, RenameDevice, RenameLayout, RenameSprint,
|
||||||
ResolveAgentCapabilities, ResolveAgentPermissions, ResolveAgentSystemPermissions,
|
ReorderSprints, ResizeTerminal, ResolveAgentCapabilities, ResolveAgentPermissions,
|
||||||
ResolveManagedTemp, ResolveMemoryLinks, RestoreOpenProjects, RestoreOpenProjectsOutput,
|
ResolveAgentSystemPermissions, ResolveManagedTemp, ResolveMemoryLinks, RestoreOpenProjects,
|
||||||
RestoreOpenWindows, RetryBackgroundTask, RetryOpenProjectRestore, ReviewPluginPackage,
|
RestoreOpenProjectsOutput, RestoreOpenWindows, RetryBackgroundTask, RetryOpenProjectRestore,
|
||||||
RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveChatDraft, SaveEmbedderProfile,
|
ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog, SaveChatDraft,
|
||||||
SaveModelServer, SaveOpenCodeProviderProfile, SaveOpenProjectRestoreOrder, SaveProfile,
|
SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveOpenProjectRestoreOrder,
|
||||||
SessionLimitService, SetActiveLayout, SetAgentMcpToolAllowed, SetPluginEnabled,
|
SaveProfile, SessionLimitService, SetActiveLayout, SetAgentMcpToolAllowed, SetPluginEnabled,
|
||||||
SetProjectPluginEnabled, SlashCommandRegistry, SnapshotOpenWindows, SnapshotRunningAgents,
|
SetProjectPluginEnabled, SlashCommandRegistry, SnapshotOpenWindows, SnapshotRunningAgents,
|
||||||
SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions,
|
SnapshotRunningAgentsInput, SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode,
|
||||||
SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice,
|
StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice,
|
||||||
UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues,
|
UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues,
|
||||||
UpdateAgentContactable, UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions,
|
UpdateAgentContactable, UpdateAgentContext, UpdateAgentEffort, UpdateAgentMcpToolPermissions,
|
||||||
UpdateAgentPermissions, UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet,
|
UpdateAgentPermissions, UpdateAgentSystemPermissions, UpdateIssue, UpdateIssueCarnet,
|
||||||
@ -90,7 +90,7 @@ use domain::remote::RemoteKind;
|
|||||||
use domain::{
|
use domain::{
|
||||||
AgentId, AgentInbox, BackgroundTask, BackgroundTaskResult, BackgroundTaskWakePolicy,
|
AgentId, AgentInbox, BackgroundTask, BackgroundTaskResult, BackgroundTaskWakePolicy,
|
||||||
DomainEvent, EmbedderProfile, InboxError, InboxItem, InboxItemKind, InboxReceiptStatus,
|
DomainEvent, EmbedderProfile, InboxError, InboxItem, InboxItemKind, InboxReceiptStatus,
|
||||||
InboxSource, Project, ProjectId, TaskId, TicketId,
|
InboxSource, OpenProjectRestoreState, Project, ProjectId, TaskId, TicketId,
|
||||||
};
|
};
|
||||||
use serde_json::{json, Map, Value};
|
use serde_json::{json, Map, Value};
|
||||||
use uuid::Uuid;
|
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.
|
/// Everything a driving adapter needs at runtime.
|
||||||
///
|
///
|
||||||
/// Use cases are stored behind `Arc` so handlers clone cheaply. The concrete
|
/// Use cases are stored behind `Arc` so handlers clone cheaply. The concrete
|
||||||
@ -1852,6 +1865,8 @@ pub struct BackendCore {
|
|||||||
pub open_project: Arc<OpenProject>,
|
pub open_project: Arc<OpenProject>,
|
||||||
/// Close a project (persist state).
|
/// Close a project (persist state).
|
||||||
pub close_project: Arc<CloseProject>,
|
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.
|
/// Read the ordered open-project restore intent.
|
||||||
pub read_open_project_restore_state: Arc<ReadOpenProjectRestoreState>,
|
pub read_open_project_restore_state: Arc<ReadOpenProjectRestoreState>,
|
||||||
/// Record a visibly opened project.
|
/// Record a visibly opened project.
|
||||||
@ -1955,6 +1970,8 @@ pub struct BackendCore {
|
|||||||
pub pty_port: Arc<dyn PtyPort>,
|
pub pty_port: Arc<dyn PtyPort>,
|
||||||
/// Active-terminal registry shared by the terminal use cases.
|
/// Active-terminal registry shared by the terminal use cases.
|
||||||
pub terminal_sessions: Arc<TerminalSessions>,
|
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).
|
/// The domain event bus (also handed to the event relay).
|
||||||
pub event_bus: Arc<TokioBroadcastEventBus>,
|
pub event_bus: Arc<TokioBroadcastEventBus>,
|
||||||
/// Create an issue-backed public ticket.
|
/// Create an issue-backed public ticket.
|
||||||
@ -2512,6 +2529,8 @@ impl BackendCore {
|
|||||||
Arc::clone(&fs_port),
|
Arc::clone(&fs_port),
|
||||||
));
|
));
|
||||||
let close_project = Arc::new(CloseProject::new(Arc::clone(&store_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(
|
let open_project_restore_service = Arc::new(application::OpenProjectRestoreService::new(
|
||||||
Arc::clone(&open_project_restore_store_port),
|
Arc::clone(&open_project_restore_store_port),
|
||||||
Arc::clone(&store_port),
|
Arc::clone(&store_port),
|
||||||
@ -4127,6 +4146,7 @@ impl BackendCore {
|
|||||||
create_project,
|
create_project,
|
||||||
open_project,
|
open_project,
|
||||||
close_project,
|
close_project,
|
||||||
|
remove_project_from_registry,
|
||||||
read_open_project_restore_state,
|
read_open_project_restore_state,
|
||||||
record_project_opened_for_restore,
|
record_project_opened_for_restore,
|
||||||
record_project_closed_for_restore,
|
record_project_closed_for_restore,
|
||||||
@ -4175,6 +4195,7 @@ impl BackendCore {
|
|||||||
delete_model_artifact,
|
delete_model_artifact,
|
||||||
pty_port,
|
pty_port,
|
||||||
terminal_sessions,
|
terminal_sessions,
|
||||||
|
live_sessions,
|
||||||
event_bus,
|
event_bus,
|
||||||
create_issue,
|
create_issue,
|
||||||
read_issue,
|
read_issue,
|
||||||
@ -4390,6 +4411,76 @@ impl BackendCore {
|
|||||||
Ok(output)
|
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
|
/// Starts a [`FsOrchestratorWatcher`] for `project` if one is not already
|
||||||
/// running for it (idempotent). The watcher tails the project's
|
/// running for it (idempotent). The watcher tails the project's
|
||||||
/// `.ideai/requests/` tree and dispatches each request through the shared
|
/// `.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(&app_data);
|
||||||
let _ = std::fs::remove_dir_all(&roots);
|
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.
|
/// [`StoreError`] on failure.
|
||||||
async fn save_project(&self, project: &Project) -> Result<(), StoreError>;
|
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).
|
/// Saves the whole workspace (windows/tabs/layouts).
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
|
|||||||
@ -133,6 +133,20 @@ impl ProjectStore for FsProjectStore {
|
|||||||
self.write_registry(®istry).await
|
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> {
|
async fn save_workspace(&self, workspace: &Workspace) -> Result<(), StoreError> {
|
||||||
self.ensure_dir().await?;
|
self.ensure_dir().await?;
|
||||||
let bytes = serde_json::to_vec_pretty(workspace)
|
let bytes = serde_json::to_vec_pretty(workspace)
|
||||||
|
|||||||
@ -92,6 +92,44 @@ async fn missing_registry_lists_empty() {
|
|||||||
assert!(listed.is_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]
|
#[tokio::test]
|
||||||
async fn workspace_save_then_load_roundtrips() {
|
async fn workspace_save_then_load_roundtrips() {
|
||||||
let tmp = TempDir::new();
|
let tmp = TempDir::new();
|
||||||
|
|||||||
@ -95,8 +95,8 @@ use backend::dto::{
|
|||||||
ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto,
|
ProfileModelCatalogDto, ProjectDto, ProjectListDto, ProjectMcpToolPermissionsDto,
|
||||||
ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto,
|
ProjectPermissionsDto, ProjectSystemPermissionsDto, ProjectWorkStateDto,
|
||||||
ReadAgentContextResponseDto, ReadConversationPageRequestDto, RecallMemoryRequestDto,
|
ReadAgentContextResponseDto, ReadConversationPageRequestDto, RecallMemoryRequestDto,
|
||||||
RemoveOpenProjectRestoreEntryRequestDto, RenameLayoutRequestDto,
|
RemoveOpenProjectRestoreEntryRequestDto, RemoveProjectRequestDto, RemoveProjectResponseDto,
|
||||||
ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto,
|
RenameLayoutRequestDto, ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto,
|
||||||
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
|
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
|
||||||
RestoreOpenProjectsOutputDto, ResumableAgentListDto, RetryOpenProjectRestoreRequestDto,
|
RestoreOpenProjectsOutputDto, ResumableAgentListDto, RetryOpenProjectRestoreRequestDto,
|
||||||
SaveEmbedderProfileRequestDto, SaveOpenCodeProviderProfileRequestDto,
|
SaveEmbedderProfileRequestDto, SaveOpenCodeProviderProfileRequestDto,
|
||||||
@ -3347,6 +3347,7 @@ async fn invoke(
|
|||||||
"health" => invoke_health(&request.args, &state.app),
|
"health" => invoke_health(&request.args, &state.app),
|
||||||
"list_projects" => invoke_list_projects(&state.app).await,
|
"list_projects" => invoke_list_projects(&state.app).await,
|
||||||
"open_project" => invoke_open_project(&request.args, &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" => {
|
"read_open_project_restore_state" => {
|
||||||
invoke_read_open_project_restore_state(&state.app).await
|
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)
|
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(
|
async fn open_project_for_web(
|
||||||
project_id: &str,
|
project_id: &str,
|
||||||
state: &BackendCore,
|
state: &BackendCore,
|
||||||
@ -9617,6 +9628,82 @@ mod tests {
|
|||||||
.is_some_and(|root| root.contains("idea-server-project-")));
|
.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]
|
#[tokio::test]
|
||||||
async fn authorized_open_project_restore_commands_share_the_tauri_contract() {
|
async fn authorized_open_project_restore_commands_share_the_tauri_contract() {
|
||||||
let state = state();
|
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 () => {
|
it("HttpProjectGateway maps the open-project restore command envelopes", async () => {
|
||||||
const http = new HttpInvoker({ baseUrl: "https://h" });
|
const http = new HttpInvoker({ baseUrl: "https://h" });
|
||||||
const spy = vi.spyOn(http, "invoke").mockResolvedValue({} as never);
|
const spy = vi.spyOn(http, "invoke").mockResolvedValue({} as never);
|
||||||
|
|||||||
@ -39,6 +39,7 @@ import type {
|
|||||||
Project,
|
Project,
|
||||||
ProjectFileListing,
|
ProjectFileListing,
|
||||||
OpenProjectRestoreState,
|
OpenProjectRestoreState,
|
||||||
|
RemoveProjectOutput,
|
||||||
RestoreOpenProjectsOutput,
|
RestoreOpenProjectsOutput,
|
||||||
ProjectMcpToolPermissions,
|
ProjectMcpToolPermissions,
|
||||||
ProjectPermissions,
|
ProjectPermissions,
|
||||||
@ -99,6 +100,11 @@ export class HttpProjectGateway implements ProjectGateway {
|
|||||||
async closeProject(projectId: string): Promise<void> {
|
async closeProject(projectId: string): Promise<void> {
|
||||||
await this.http.invoke("close_project", { projectId });
|
await this.http.invoke("close_project", { projectId });
|
||||||
}
|
}
|
||||||
|
removeProject(projectId: string): Promise<RemoveProjectOutput> {
|
||||||
|
return this.http.invoke<RemoveProjectOutput>("remove_project", {
|
||||||
|
request: { projectId },
|
||||||
|
});
|
||||||
|
}
|
||||||
restoreOpenProjects(): Promise<RestoreOpenProjectsOutput> {
|
restoreOpenProjects(): Promise<RestoreOpenProjectsOutput> {
|
||||||
return this.http.invoke<RestoreOpenProjectsOutput>("restore_open_projects");
|
return this.http.invoke<RestoreOpenProjectsOutput>("restore_open_projects");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -72,6 +72,7 @@ import type {
|
|||||||
ProjectFileListing,
|
ProjectFileListing,
|
||||||
OpenProjectRestoreFailure,
|
OpenProjectRestoreFailure,
|
||||||
OpenProjectRestoreState,
|
OpenProjectRestoreState,
|
||||||
|
RemoveProjectOutput,
|
||||||
RestoreOpenProjectsOutput,
|
RestoreOpenProjectsOutput,
|
||||||
JsonValue,
|
JsonValue,
|
||||||
ProjectMcpToolPermissions,
|
ProjectMcpToolPermissions,
|
||||||
@ -1314,6 +1315,28 @@ export class MockProjectGateway implements ProjectGateway {
|
|||||||
this.restoreFailures.delete(projectId);
|
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> {
|
async restoreOpenProjects(): Promise<RestoreOpenProjectsOutput> {
|
||||||
const failures: OpenProjectRestoreFailure[] = [];
|
const failures: OpenProjectRestoreFailure[] = [];
|
||||||
const opened: Project[] = [];
|
const opened: Project[] = [];
|
||||||
|
|||||||
@ -53,4 +53,14 @@ describe("TauriProjectGateway invoke payloads", () => {
|
|||||||
await gateway.readOpenProjectRestoreState();
|
await gateway.readOpenProjectRestoreState();
|
||||||
expect(invoke).toHaveBeenCalledWith("read_open_project_restore_state");
|
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,
|
OpenProjectRestoreState,
|
||||||
Project,
|
Project,
|
||||||
ProjectFileListing,
|
ProjectFileListing,
|
||||||
|
RemoveProjectOutput,
|
||||||
RestoreOpenProjectsOutput,
|
RestoreOpenProjectsOutput,
|
||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
import type { ProjectGateway } from "@/ports";
|
import type { ProjectGateway } from "@/ports";
|
||||||
@ -33,6 +34,12 @@ export class TauriProjectGateway implements ProjectGateway {
|
|||||||
await invoke("close_project", { projectId });
|
await invoke("close_project", { projectId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removeProject(projectId: string): Promise<RemoveProjectOutput> {
|
||||||
|
return invoke<RemoveProjectOutput>("remove_project", {
|
||||||
|
request: { projectId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
restoreOpenProjects(): Promise<RestoreOpenProjectsOutput> {
|
restoreOpenProjects(): Promise<RestoreOpenProjectsOutput> {
|
||||||
return invoke<RestoreOpenProjectsOutput>("restore_open_projects");
|
return invoke<RestoreOpenProjectsOutput>("restore_open_projects");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -643,6 +643,14 @@ export interface RestoreOpenProjectsOutput {
|
|||||||
failures: OpenProjectRestoreFailure[];
|
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`. */
|
/** Stable error shape mirrored from the backend `ErrorDto`. */
|
||||||
export interface GatewayError {
|
export interface GatewayError {
|
||||||
code: string;
|
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,
|
DomainEvent,
|
||||||
LayoutInfo,
|
LayoutInfo,
|
||||||
OpenProjectRestoreFailure,
|
OpenProjectRestoreFailure,
|
||||||
|
Project,
|
||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
import { LayoutGrid, LayoutTabs } from "@/features/layout";
|
import { LayoutGrid, LayoutTabs } from "@/features/layout";
|
||||||
import { ConversationViewer } from "@/features/conversations";
|
import { ConversationViewer } from "@/features/conversations";
|
||||||
@ -78,6 +79,8 @@ import { useGateways } from "@/app/di";
|
|||||||
import { useProjects } from "./useProjects";
|
import { useProjects } from "./useProjects";
|
||||||
import { useProjectActivity } from "./useProjectActivity";
|
import { useProjectActivity } from "./useProjectActivity";
|
||||||
import { ProjectTabs } from "./ProjectTabs";
|
import { ProjectTabs } from "./ProjectTabs";
|
||||||
|
import { ProjectActionsMenu } from "./ProjectActionsMenu";
|
||||||
|
import { RemoveProjectDialog } from "./RemoveProjectDialog";
|
||||||
import { ViewPanelBody, type ViewPanelId } from "./ViewPanelBody";
|
import { ViewPanelBody, type ViewPanelId } from "./ViewPanelBody";
|
||||||
import {
|
import {
|
||||||
PANEL_TITLE,
|
PANEL_TITLE,
|
||||||
@ -266,6 +269,10 @@ export function ProjectsView() {
|
|||||||
const [taskToasts, setTaskToasts] = useState<BackgroundTaskToast[]>([]);
|
const [taskToasts, setTaskToasts] = useState<BackgroundTaskToast[]>([]);
|
||||||
const [detachErrorPanel, setDetachErrorPanel] = useState<PanelId | null>(null);
|
const [detachErrorPanel, setDetachErrorPanel] = useState<PanelId | null>(null);
|
||||||
const [restoreDetailsOpen, setRestoreDetailsOpen] = useState(false);
|
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 active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null;
|
||||||
const activeViewerConversation =
|
const activeViewerConversation =
|
||||||
@ -273,6 +280,24 @@ export function ProjectsView() {
|
|||||||
const showRestoreNotification =
|
const showRestoreNotification =
|
||||||
vm.restoreNotificationVisible && vm.restoreFailures.length > 0;
|
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(() => {
|
useEffect(() => {
|
||||||
if (vm.restoreFailures.length === 0) setRestoreDetailsOpen(false);
|
if (vm.restoreFailures.length === 0) setRestoreDetailsOpen(false);
|
||||||
}, [vm.restoreFailures.length]);
|
}, [vm.restoreFailures.length]);
|
||||||
@ -751,9 +776,20 @@ export function ProjectsView() {
|
|||||||
<span className="font-medium text-content">{p.name}</span>
|
<span className="font-medium text-content">{p.name}</span>
|
||||||
<code className="truncate text-xs text-muted">{p.root}</code>
|
<code className="truncate text-xs text-muted">{p.root}</code>
|
||||||
</span>
|
</span>
|
||||||
|
<span className="flex shrink-0 items-center gap-1">
|
||||||
<Button size="sm" onClick={() => void vm.openProject(p.id)}>
|
<Button size="sm" onClick={() => void vm.openProject(p.id)}>
|
||||||
Open
|
Open
|
||||||
</Button>
|
</Button>
|
||||||
|
<ProjectActionsMenu
|
||||||
|
projectName={p.name}
|
||||||
|
disabled={vm.busy}
|
||||||
|
onOpen={() => void vm.openProject(p.id)}
|
||||||
|
onRemove={() => {
|
||||||
|
setRemovalError(null);
|
||||||
|
setRemovalTarget(p);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@ -1076,8 +1112,21 @@ export function ProjectsView() {
|
|||||||
</ul>
|
</ul>
|
||||||
</FloatingWindow>
|
</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) ── */}
|
{/* ── Non-blocking notifications (above floating windows) ── */}
|
||||||
{(showRestoreNotification || taskToasts.length > 0) && (
|
{(showRestoreNotification || removalNotice || taskToasts.length > 0) && (
|
||||||
<div
|
<div
|
||||||
className="fixed bottom-4 right-4 flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2"
|
className="fixed bottom-4 right-4 flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2"
|
||||||
style={{ zIndex: zIndex.toast }}
|
style={{ zIndex: zIndex.toast }}
|
||||||
@ -1117,6 +1166,27 @@ export function ProjectsView() {
|
|||||||
</div>
|
</div>
|
||||||
</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) => (
|
{taskToasts.map((toast) => (
|
||||||
<button
|
<button
|
||||||
key={toast.id}
|
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();
|
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 () => {
|
it("creating a project adds it to the known list and opens a tab", async () => {
|
||||||
renderView();
|
renderView();
|
||||||
|
|
||||||
|
|||||||
@ -40,6 +40,8 @@ export interface ProjectsViewModel {
|
|||||||
openProject: (projectId: string) => Promise<void>;
|
openProject: (projectId: string) => Promise<void>;
|
||||||
/** Closes a tab (does not delete the project from the registry). */
|
/** Closes a tab (does not delete the project from the registry). */
|
||||||
closeTab: (projectId: string) => Promise<void>;
|
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. */
|
/** Retries one failed restore, or every failure when omitted. */
|
||||||
retryRestore: (projectId?: string) => Promise<void>;
|
retryRestore: (projectId?: string) => Promise<void>;
|
||||||
/** Removes one failure from the persisted next-launch restore set. */
|
/** Removes one failure from the persisted next-launch restore set. */
|
||||||
@ -172,6 +174,50 @@ export function useProjects(): ProjectsViewModel {
|
|||||||
[project],
|
[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(
|
const retryRestore = useCallback(
|
||||||
async (projectId?: string) => {
|
async (projectId?: string) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@ -244,6 +290,7 @@ export function useProjects(): ProjectsViewModel {
|
|||||||
createProject,
|
createProject,
|
||||||
openProject,
|
openProject,
|
||||||
closeTab,
|
closeTab,
|
||||||
|
removeProject,
|
||||||
retryRestore,
|
retryRestore,
|
||||||
removeFromRestore,
|
removeFromRestore,
|
||||||
dismissRestoreNotification: () => setRestoreNotificationVisible(false),
|
dismissRestoreNotification: () => setRestoreNotificationVisible(false),
|
||||||
|
|||||||
@ -63,6 +63,7 @@ function gateways(overrides: {
|
|||||||
createProject: vi.fn(),
|
createProject: vi.fn(),
|
||||||
openProject: vi.fn(),
|
openProject: vi.fn(),
|
||||||
closeProject: vi.fn(),
|
closeProject: vi.fn(),
|
||||||
|
removeProject: vi.fn(),
|
||||||
restoreOpenProjects: vi.fn(async () => ({ opened: [], failures: [] })),
|
restoreOpenProjects: vi.fn(async () => ({ opened: [], failures: [] })),
|
||||||
retryOpenProjectRestore: vi.fn(async () => ({ opened: [], failures: [] })),
|
retryOpenProjectRestore: vi.fn(async () => ({ opened: [], failures: [] })),
|
||||||
removeProjectFromRestore: vi.fn(async () => ({ version: 1, entries: [], updatedAt: 0 })),
|
removeProjectFromRestore: vi.fn(async () => ({ version: 1, entries: [], updatedAt: 0 })),
|
||||||
|
|||||||
@ -72,6 +72,7 @@ import type {
|
|||||||
Project,
|
Project,
|
||||||
OpenProjectRestoreState,
|
OpenProjectRestoreState,
|
||||||
RestoreOpenProjectsOutput,
|
RestoreOpenProjectsOutput,
|
||||||
|
RemoveProjectOutput,
|
||||||
ProjectFileListing,
|
ProjectFileListing,
|
||||||
ProjectPermissions,
|
ProjectPermissions,
|
||||||
ProjectWorkState,
|
ProjectWorkState,
|
||||||
@ -579,6 +580,8 @@ export interface ProjectGateway {
|
|||||||
openProject(projectId: string): Promise<Project>;
|
openProject(projectId: string): Promise<Project>;
|
||||||
/** Closes a project by id. */
|
/** Closes a project by id. */
|
||||||
closeProject(projectId: string): Promise<void>;
|
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. */
|
/** Restores the projects retained at the end of the previous app run. */
|
||||||
restoreOpenProjects(): Promise<RestoreOpenProjectsOutput>;
|
restoreOpenProjects(): Promise<RestoreOpenProjectsOutput>;
|
||||||
/** Retries one retained restore failure, or every failure when omitted. */
|
/** Retries one retained restore failure, or every failure when omitted. */
|
||||||
|
|||||||
Reference in New Issue
Block a user