diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index e58c7eb3526cc1a53d7b8e6d449e968a5923425a..95c3b8b2fde9c1c0fcc2248d2578f9eb094f9617 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -28,6 +28,7 @@ mod terminal_codegen; mod terminal_inline_assistant; #[cfg(any(test, feature = "test-support"))] pub mod test_support; +pub mod thread_archive_cleanup; mod thread_history; mod thread_history_view; mod thread_import; diff --git a/crates/agent_ui/src/thread_archive_cleanup.rs b/crates/agent_ui/src/thread_archive_cleanup.rs new file mode 100644 index 0000000000000000000000000000000000000000..ca2fe1f7cbc780779389b6023a30b13a15a3187a --- /dev/null +++ b/crates/agent_ui/src/thread_archive_cleanup.rs @@ -0,0 +1,735 @@ +use std::{ + collections::HashSet, + path::{Path, PathBuf}, + sync::Arc, +}; + +use agent_client_protocol as acp; +use anyhow::{Context as _, Result, anyhow}; +use gpui::{App, AsyncApp, Entity, Global, Task, WindowHandle}; +use parking_lot::Mutex; +use project::{LocalProjectFlags, Project, WorktreeId, git_store::Repository}; +use util::ResultExt; +use workspace::{ + AppState, MultiWorkspace, OpenMode, OpenOptions, PathList, Toast, Workspace, + notifications::NotificationId, open_new, open_paths, +}; + +use crate::thread_metadata_store::ThreadMetadataStore; + +#[derive(Default)] +pub struct ThreadArchiveCleanupCoordinator { + in_flight_roots: Mutex>, +} + +impl Global for ThreadArchiveCleanupCoordinator {} + +fn ensure_global(cx: &mut App) { + if !cx.has_global::() { + cx.set_global(ThreadArchiveCleanupCoordinator::default()); + } +} + +#[derive(Clone)] +pub struct ArchiveOutcome { + pub archived_immediately: bool, + pub roots_to_delete: Vec, +} + +#[derive(Clone)] +struct RootPlan { + root_path: PathBuf, + main_repo_path: PathBuf, + affected_projects: Vec, +} + +#[derive(Clone)] +struct AffectedProject { + project: Entity, + worktree_id: WorktreeId, +} + +#[derive(Clone)] +enum FallbackTarget { + ExistingWorkspace { + window: WindowHandle, + workspace: Entity, + }, + OpenPaths { + requesting_window: WindowHandle, + paths: Vec, + }, + OpenEmpty { + requesting_window: WindowHandle, + }, +} + +#[derive(Clone)] +struct CleanupPlan { + roots: Vec, + current_workspace: Option>, + current_workspace_will_be_empty: bool, + fallback: Option, + affected_workspaces: Vec>, +} + +pub fn archive_thread( + session_id: &acp::SessionId, + current_workspace: Option>, + window: WindowHandle, + cx: &mut App, +) -> ArchiveOutcome { + ensure_global(cx); + let plan = build_cleanup_plan(session_id, current_workspace, window, cx); + + ThreadMetadataStore::global(cx).update(cx, |store, cx| store.archive(session_id, cx)); + + if let Some(plan) = plan { + let roots_to_delete = plan + .roots + .iter() + .map(|root| root.root_path.clone()) + .collect::>(); + if !roots_to_delete.is_empty() { + cx.spawn(async move |cx| { + run_cleanup(plan, cx).await; + }) + .detach(); + + return ArchiveOutcome { + archived_immediately: true, + roots_to_delete, + }; + } + } + + ArchiveOutcome { + archived_immediately: true, + roots_to_delete: Vec::new(), + } +} + +fn build_cleanup_plan( + session_id: &acp::SessionId, + current_workspace: Option>, + requesting_window: WindowHandle, + cx: &App, +) -> Option { + let metadata = ThreadMetadataStore::global(cx) + .read(cx) + .entry(session_id) + .cloned()?; + + let workspaces = all_open_workspaces(cx); + + let candidate_roots = metadata + .folder_paths + .ordered_paths() + .filter_map(|path| build_root_plan(path, &workspaces, cx)) + .filter(|plan| { + !path_is_referenced_by_other_unarchived_threads(session_id, &plan.root_path, cx) + }) + .collect::>(); + + if candidate_roots.is_empty() { + return Some(CleanupPlan { + roots: Vec::new(), + current_workspace, + current_workspace_will_be_empty: false, + fallback: None, + affected_workspaces: Vec::new(), + }); + } + + let mut affected_workspaces = Vec::new(); + let mut current_workspace_will_be_empty = false; + + for workspace in workspaces.iter() { + let doomed_root_count = workspace + .read(cx) + .root_paths(cx) + .into_iter() + .filter(|path| { + candidate_roots + .iter() + .any(|root| root.root_path.as_path() == path.as_ref()) + }) + .count(); + + if doomed_root_count == 0 { + continue; + } + + let surviving_root_count = workspace + .read(cx) + .root_paths(cx) + .len() + .saturating_sub(doomed_root_count); + if current_workspace + .as_ref() + .is_some_and(|current| current == workspace) + { + current_workspace_will_be_empty = surviving_root_count == 0; + } + affected_workspaces.push(workspace.clone()); + } + + let fallback = if current_workspace_will_be_empty { + choose_fallback_target( + session_id, + current_workspace.as_ref(), + &candidate_roots, + &requesting_window, + &workspaces, + cx, + ) + } else { + None + }; + + Some(CleanupPlan { + roots: candidate_roots, + current_workspace, + current_workspace_will_be_empty, + fallback, + affected_workspaces, + }) +} + +fn build_root_plan(path: &Path, workspaces: &[Entity], cx: &App) -> Option { + let path = path.to_path_buf(); + let affected_projects = workspaces + .iter() + .filter_map(|workspace| { + let project = workspace.read(cx).project().clone(); + let worktree = project + .read(cx) + .visible_worktrees(cx) + .find(|worktree| worktree.read(cx).abs_path().as_ref() == path.as_path())?; + let worktree_id = worktree.read(cx).id(); + Some(AffectedProject { + project, + worktree_id, + }) + }) + .collect::>(); + + let linked_snapshot = workspaces + .iter() + .flat_map(|workspace| { + workspace + .read(cx) + .project() + .read(cx) + .repositories(cx) + .values() + .cloned() + .collect::>() + }) + .find_map(|repo| { + let snapshot = repo.read(cx).snapshot(); + (snapshot.is_linked_worktree() + && snapshot.work_directory_abs_path.as_ref() == path.as_path()) + .then_some(snapshot) + })?; + + Some(RootPlan { + root_path: path, + main_repo_path: linked_snapshot.original_repo_abs_path.to_path_buf(), + affected_projects, + }) +} + +fn path_is_referenced_by_other_unarchived_threads( + current_session_id: &acp::SessionId, + path: &Path, + cx: &App, +) -> bool { + ThreadMetadataStore::global(cx) + .read(cx) + .entries() + .filter(|thread| thread.session_id != *current_session_id) + .filter(|thread| !thread.archived) + .any(|thread| { + thread + .folder_paths + .paths() + .iter() + .any(|other_path| other_path.as_path() == path) + }) +} + +fn choose_fallback_target( + current_session_id: &acp::SessionId, + current_workspace: Option<&Entity>, + roots: &[RootPlan], + requesting_window: &WindowHandle, + workspaces: &[Entity], + cx: &App, +) -> Option { + let doomed_roots = roots + .iter() + .map(|root| root.root_path.clone()) + .collect::>(); + + let surviving_same_window = requesting_window.read(cx).ok().and_then(|multi_workspace| { + multi_workspace + .workspaces() + .iter() + .filter(|workspace| current_workspace.is_none_or(|current| *workspace != current)) + .find(|workspace| workspace_survives(workspace, &doomed_roots, cx)) + .cloned() + }); + if let Some(workspace) = surviving_same_window { + return Some(FallbackTarget::ExistingWorkspace { + window: *requesting_window, + workspace, + }); + } + + for window in cx + .windows() + .into_iter() + .filter_map(|window| window.downcast::()) + { + if window == *requesting_window { + continue; + } + if let Ok(multi_workspace) = window.read(cx) { + if let Some(workspace) = multi_workspace + .workspaces() + .iter() + .find(|workspace| workspace_survives(workspace, &doomed_roots, cx)) + .cloned() + { + return Some(FallbackTarget::ExistingWorkspace { window, workspace }); + } + } + } + + let safe_thread_workspace = ThreadMetadataStore::global(cx) + .read(cx) + .entries() + .filter(|metadata| metadata.session_id != *current_session_id && !metadata.archived) + .filter_map(|metadata| { + workspaces + .iter() + .find(|workspace| workspace_path_list(workspace, cx) == metadata.folder_paths) + .cloned() + }) + .find(|workspace| workspace_survives(workspace, &doomed_roots, cx)); + + if let Some(workspace) = safe_thread_workspace { + let window = window_for_workspace(&workspace, cx).unwrap_or(*requesting_window); + return Some(FallbackTarget::ExistingWorkspace { window, workspace }); + } + + if let Some(root) = roots.first() { + return Some(FallbackTarget::OpenPaths { + requesting_window: *requesting_window, + paths: vec![root.main_repo_path.clone()], + }); + } + + Some(FallbackTarget::OpenEmpty { + requesting_window: *requesting_window, + }) +} + +async fn run_cleanup(plan: CleanupPlan, cx: &mut AsyncApp) { + let roots_to_delete = + cx.update_global::(|coordinator, _cx| { + let mut in_flight_roots = coordinator.in_flight_roots.lock(); + plan.roots + .iter() + .filter_map(|root| { + if in_flight_roots.insert(root.root_path.clone()) { + Some(root.clone()) + } else { + None + } + }) + .collect::>() + }); + + if roots_to_delete.is_empty() { + return; + } + + let active_workspace = plan.current_workspace.clone(); + if let Some(workspace) = active_workspace + .as_ref() + .filter(|_| plan.current_workspace_will_be_empty) + { + let Some(window) = window_for_workspace_async(workspace, cx) else { + release_in_flight_roots(&roots_to_delete, cx); + return; + }; + + let should_continue = save_workspace_for_root_removal(workspace.clone(), window, cx).await; + if !should_continue { + release_in_flight_roots(&roots_to_delete, cx); + return; + } + } + + for workspace in plan + .affected_workspaces + .iter() + .filter(|workspace| Some((*workspace).clone()) != active_workspace) + { + let Some(window) = window_for_workspace_async(workspace, cx) else { + continue; + }; + + if !save_workspace_for_root_removal(workspace.clone(), window, cx).await { + release_in_flight_roots(&roots_to_delete, cx); + return; + } + } + + if plan.current_workspace_will_be_empty { + if let Some(fallback) = plan.fallback.clone() { + activate_fallback(fallback, cx).await.log_err(); + } + } + + let mut git_removal_errors: Vec<(PathBuf, anyhow::Error)> = Vec::new(); + + for root in &roots_to_delete { + if let Err(error) = remove_root(root.clone(), cx).await { + git_removal_errors.push((root.root_path.clone(), error)); + } + } + + cleanup_empty_workspaces(&plan.affected_workspaces, cx).await; + + if !git_removal_errors.is_empty() { + let detail = git_removal_errors + .into_iter() + .map(|(path, error)| format!("{}: {error}", path.display())) + .collect::>() + .join("\n"); + show_error_toast( + "Thread archived, but linked worktree cleanup failed", + &detail, + &plan, + cx, + ); + } + + release_in_flight_roots(&roots_to_delete, cx); +} + +async fn save_workspace_for_root_removal( + workspace: Entity, + window: WindowHandle, + cx: &mut AsyncApp, +) -> bool { + let has_dirty_items = workspace.read_with(cx, |workspace, cx| { + workspace.items(cx).any(|item| item.is_dirty(cx)) + }); + + if has_dirty_items { + let _ = window.update(cx, |multi_workspace, window, cx| { + window.activate_window(); + multi_workspace.activate(workspace.clone(), window, cx); + }); + } + + let save_task = window.update(cx, |_multi_workspace, window, cx| { + workspace.update(cx, |workspace, cx| { + workspace.save_for_root_removal(window, cx) + }) + }); + + let Ok(task) = save_task else { + return false; + }; + + task.await.unwrap_or(false) +} + +async fn activate_fallback(target: FallbackTarget, cx: &mut AsyncApp) -> Result<()> { + match target { + FallbackTarget::ExistingWorkspace { window, workspace } => { + window.update(cx, |multi_workspace, window, cx| { + window.activate_window(); + multi_workspace.activate(workspace, window, cx); + })?; + } + FallbackTarget::OpenPaths { + requesting_window, + paths, + } => { + let app_state = current_app_state(cx).context("no workspace app state available")?; + cx.update(|cx| { + open_paths( + &paths, + app_state, + OpenOptions { + requesting_window: Some(requesting_window), + open_mode: OpenMode::Activate, + ..Default::default() + }, + cx, + ) + }) + .await?; + } + FallbackTarget::OpenEmpty { requesting_window } => { + let app_state = current_app_state(cx).context("no workspace app state available")?; + cx.update(|cx| { + open_new( + OpenOptions { + requesting_window: Some(requesting_window), + open_mode: OpenMode::Activate, + ..Default::default() + }, + app_state, + cx, + |_workspace, _window, _cx| {}, + ) + }) + .await?; + } + } + + Ok(()) +} + +async fn remove_root(root: RootPlan, cx: &mut AsyncApp) -> Result<()> { + let release_tasks: Vec<_> = root + .affected_projects + .iter() + .map(|affected| { + let project = affected.project.clone(); + let worktree_id = affected.worktree_id; + project.update(cx, |project, cx| { + let wait = project.wait_for_worktree_release(worktree_id, cx); + project.remove_worktree(worktree_id, cx); + wait + }) + }) + .collect(); + + if let Err(error) = remove_root_after_worktree_removal(&root, release_tasks, cx).await { + rollback_root(&root, cx).await; + return Err(error); + } + + Ok(()) +} + +async fn remove_root_after_worktree_removal( + root: &RootPlan, + release_tasks: Vec>>, + cx: &mut AsyncApp, +) -> Result<()> { + for task in release_tasks { + task.await?; + } + + let (repo, _temp_project) = repository_for_root_removal(root, cx).await?; + let receiver = repo.update(cx, |repo: &mut Repository, _cx| { + repo.remove_worktree(root.root_path.clone(), false) + }); + let result = receiver + .await + .map_err(|_| anyhow!("git worktree removal was canceled"))?; + result +} + +async fn repository_for_root_removal( + root: &RootPlan, + cx: &mut AsyncApp, +) -> Result<(Entity, Option>)> { + let live_repo = cx.update(|cx| { + all_open_workspaces(cx) + .into_iter() + .flat_map(|workspace| { + workspace + .read(cx) + .project() + .read(cx) + .repositories(cx) + .values() + .cloned() + .collect::>() + }) + .find(|repo| { + repo.read(cx).snapshot().work_directory_abs_path.as_ref() + == root.main_repo_path.as_path() + }) + }); + + if let Some(repo) = live_repo { + return Ok((repo, None)); + } + + let app_state = + current_app_state(cx).context("no app state available for temporary project")?; + let temp_project = cx.update(|cx| { + Project::local( + app_state.client.clone(), + app_state.node_runtime.clone(), + app_state.user_store.clone(), + app_state.languages.clone(), + app_state.fs.clone(), + None, + LocalProjectFlags::default(), + cx, + ) + }); + + let create_worktree = temp_project.update(cx, |project, cx| { + project.create_worktree(root.main_repo_path.clone(), true, cx) + }); + let _worktree = create_worktree.await?; + let initial_scan = temp_project.read_with(cx, |project, cx| project.wait_for_initial_scan(cx)); + initial_scan.await; + + let repo = temp_project + .update(cx, |project, cx| { + project + .repositories(cx) + .values() + .find(|repo| { + repo.read(cx).snapshot().work_directory_abs_path.as_ref() + == root.main_repo_path.as_path() + }) + .cloned() + }) + .context("failed to resolve temporary main repository handle")?; + + let barrier = repo.update(cx, |repo: &mut Repository, _cx| repo.barrier()); + barrier + .await + .map_err(|_| anyhow!("temporary repository barrier canceled"))?; + Ok((repo, Some(temp_project))) +} + +async fn rollback_root(root: &RootPlan, cx: &mut AsyncApp) { + for affected in &root.affected_projects { + let task = affected.project.update(cx, |project, cx| { + project.create_worktree(root.root_path.clone(), true, cx) + }); + let _ = task.await; + } +} + +async fn cleanup_empty_workspaces(workspaces: &[Entity], cx: &mut AsyncApp) { + for workspace in workspaces { + let is_empty = workspace.read_with(cx, |workspace, cx| workspace.root_paths(cx).is_empty()); + if !is_empty { + continue; + } + + let Some(window) = window_for_workspace_async(workspace, cx) else { + continue; + }; + + let _ = window.update(cx, |multi_workspace, window, cx| { + if !multi_workspace.remove(workspace, window, cx) { + window.remove_window(); + } + }); + } +} + +fn show_error_toast(summary: &str, detail: &str, plan: &CleanupPlan, cx: &mut AsyncApp) { + let target_workspace = plan + .current_workspace + .clone() + .or_else(|| plan.affected_workspaces.first().cloned()); + let Some(workspace) = target_workspace else { + return; + }; + + let _ = workspace.update(cx, |workspace, cx| { + struct ArchiveCleanupErrorToast; + let message = if detail.is_empty() { + summary.to_string() + } else { + format!("{summary}: {detail}") + }; + workspace.show_toast( + Toast::new( + NotificationId::unique::(), + message, + ) + .autohide(), + cx, + ); + }); +} + +fn all_open_workspaces(cx: &App) -> Vec> { + cx.windows() + .into_iter() + .filter_map(|window| window.downcast::()) + .flat_map(|multi_workspace| { + multi_workspace + .read(cx) + .map(|multi_workspace| multi_workspace.workspaces().to_vec()) + .unwrap_or_default() + }) + .collect() +} + +fn workspace_survives( + workspace: &Entity, + doomed_roots: &HashSet, + cx: &App, +) -> bool { + workspace + .read(cx) + .root_paths(cx) + .into_iter() + .any(|root| !doomed_roots.contains(root.as_ref())) +} + +fn workspace_path_list(workspace: &Entity, cx: &App) -> PathList { + PathList::new(&workspace.read(cx).root_paths(cx)) +} + +fn window_for_workspace( + workspace: &Entity, + cx: &App, +) -> Option> { + cx.windows() + .into_iter() + .filter_map(|window| window.downcast::()) + .find(|window| { + window + .read(cx) + .map(|multi_workspace| multi_workspace.workspaces().contains(workspace)) + .unwrap_or(false) + }) +} + +fn window_for_workspace_async( + workspace: &Entity, + cx: &mut AsyncApp, +) -> Option> { + let workspace = workspace.clone(); + cx.update(|cx| window_for_workspace(&workspace, cx)) +} + +fn current_app_state(cx: &mut AsyncApp) -> Option> { + cx.update(|cx| { + all_open_workspaces(cx) + .into_iter() + .next() + .map(|workspace| workspace.read(cx).app_state().clone()) + }) +} + +fn release_in_flight_roots(roots: &[RootPlan], cx: &mut AsyncApp) { + cx.update_global::(|coordinator, _cx| { + let mut in_flight_roots = coordinator.in_flight_roots.lock(); + for root in roots { + in_flight_roots.remove(&root.root_path); + } + }); +} diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 41f57299835f37b001575b682118aa17a6516ad9..30040736796be6abf251c5fce44af8069672f8bf 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -4757,6 +4757,33 @@ impl Project { }) } + pub fn wait_for_worktree_release( + &mut self, + worktree_id: WorktreeId, + cx: &mut Context, + ) -> Task> { + let Some(worktree) = self.worktree_for_id(worktree_id, cx) else { + return Task::ready(Ok(())); + }; + + let (released_tx, released_rx) = futures::channel::oneshot::channel(); + let released_tx = std::sync::Arc::new(Mutex::new(Some(released_tx))); + let release_subscription = + cx.observe_release(&worktree, move |_project, _released_worktree, _cx| { + if let Some(released_tx) = released_tx.lock().take() { + let _ = released_tx.send(()); + } + }); + + cx.spawn(async move |_project, _cx| { + let _release_subscription = release_subscription; + released_rx + .await + .map_err(|_| anyhow!("worktree release observer dropped before release"))?; + Ok(()) + }) + } + pub fn remove_worktree(&mut self, id_to_remove: WorktreeId, cx: &mut Context) { self.worktree_store.update(cx, |worktree_store, cx| { worktree_store.remove_worktree(id_to_remove, cx); diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 6816898ffc55bbf81b2c17719b3bde6eb8b58e68..44bacece96bd49b2235442d55a4a2e92069001ce 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -4,6 +4,7 @@ use acp_thread::ThreadStatus; use action_log::DiffStats; use agent_client_protocol::{self as acp}; use agent_settings::AgentSettings; +use agent_ui::thread_archive_cleanup; use agent_ui::thread_metadata_store::{ThreadMetadata, ThreadMetadataStore}; use agent_ui::threads_archive_view::{ ThreadsArchiveView, ThreadsArchiveViewEvent, format_history_entry_timestamp, @@ -2386,7 +2387,17 @@ impl Sidebar { window: &mut Window, cx: &mut Context, ) { - ThreadMetadataStore::global(cx).update(cx, |store, cx| store.archive(session_id, cx)); + let current_workspace = self.active_entry_workspace().cloned(); + let Some(multi_workspace_handle) = window.window_handle().downcast::() + else { + return; + }; + thread_archive_cleanup::archive_thread( + session_id, + current_workspace, + multi_workspace_handle, + cx, + ); // If we're archiving the currently focused thread, move focus to the // nearest thread within the same project group. We never cross group diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index 6aa369774b63dd0d250ba67ba4a5b69a335a2de9..d41b562b6c754959cfd7a37f0a02ce2e9c4e99fe 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -305,6 +305,10 @@ impl MultiWorkspace { self.sidebar.as_deref() } + pub fn window_id(&self) -> WindowId { + self.window_id + } + pub fn set_sidebar_overlay(&mut self, overlay: Option, cx: &mut Context) { self.sidebar_overlay = overlay; cx.notify(); diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index e5b927cbbbc571966d2483e82d98ce61adb06cda..58dd51253e6510f1d17a11a9331ae02f75388e57 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -3299,6 +3299,14 @@ impl Workspace { state.task.clone().unwrap() } + pub fn save_for_root_removal( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + self.save_all_internal(SaveIntent::Close, window, cx) + } + fn save_all_internal( &mut self, mut save_intent: SaveIntent,