thread_archive_cleanup.rs

  1use std::{
  2    collections::HashSet,
  3    path::{Path, PathBuf},
  4    sync::Arc,
  5};
  6
  7use agent_client_protocol as acp;
  8use anyhow::{Context as _, Result, anyhow};
  9use gpui::{App, AsyncApp, Entity, Global, Task, WindowHandle};
 10use parking_lot::Mutex;
 11use project::{LocalProjectFlags, Project, WorktreeId, git_store::Repository};
 12use util::ResultExt;
 13use workspace::{
 14    AppState, MultiWorkspace, OpenMode, OpenOptions, PathList, Toast, Workspace,
 15    notifications::NotificationId, open_new, open_paths,
 16};
 17
 18use crate::thread_metadata_store::ThreadMetadataStore;
 19
 20#[derive(Default)]
 21pub struct ThreadArchiveCleanupCoordinator {
 22    in_flight_roots: Mutex<HashSet<PathBuf>>,
 23}
 24
 25impl Global for ThreadArchiveCleanupCoordinator {}
 26
 27fn ensure_global(cx: &mut App) {
 28    if !cx.has_global::<ThreadArchiveCleanupCoordinator>() {
 29        cx.set_global(ThreadArchiveCleanupCoordinator::default());
 30    }
 31}
 32
 33#[derive(Clone)]
 34pub struct ArchiveOutcome {
 35    pub archived_immediately: bool,
 36    pub roots_to_delete: Vec<PathBuf>,
 37}
 38
 39#[derive(Clone)]
 40struct RootPlan {
 41    root_path: PathBuf,
 42    main_repo_path: PathBuf,
 43    affected_projects: Vec<AffectedProject>,
 44}
 45
 46#[derive(Clone)]
 47struct AffectedProject {
 48    project: Entity<Project>,
 49    worktree_id: WorktreeId,
 50}
 51
 52#[derive(Clone)]
 53enum FallbackTarget {
 54    ExistingWorkspace {
 55        window: WindowHandle<MultiWorkspace>,
 56        workspace: Entity<Workspace>,
 57    },
 58    OpenPaths {
 59        requesting_window: WindowHandle<MultiWorkspace>,
 60        paths: Vec<PathBuf>,
 61    },
 62    OpenEmpty {
 63        requesting_window: WindowHandle<MultiWorkspace>,
 64    },
 65}
 66
 67#[derive(Clone)]
 68struct CleanupPlan {
 69    roots: Vec<RootPlan>,
 70    current_workspace: Option<Entity<Workspace>>,
 71    current_workspace_will_be_empty: bool,
 72    fallback: Option<FallbackTarget>,
 73    affected_workspaces: Vec<Entity<Workspace>>,
 74}
 75
 76pub fn archive_thread(
 77    session_id: &acp::SessionId,
 78    current_workspace: Option<Entity<Workspace>>,
 79    window: WindowHandle<MultiWorkspace>,
 80    cx: &mut App,
 81) -> ArchiveOutcome {
 82    ensure_global(cx);
 83    let plan = build_cleanup_plan(session_id, current_workspace, window, cx);
 84
 85    ThreadMetadataStore::global(cx).update(cx, |store, cx| store.archive(session_id, cx));
 86
 87    if let Some(plan) = plan {
 88        let roots_to_delete = plan
 89            .roots
 90            .iter()
 91            .map(|root| root.root_path.clone())
 92            .collect::<Vec<_>>();
 93        if !roots_to_delete.is_empty() {
 94            cx.spawn(async move |cx| {
 95                run_cleanup(plan, cx).await;
 96            })
 97            .detach();
 98
 99            return ArchiveOutcome {
100                archived_immediately: true,
101                roots_to_delete,
102            };
103        }
104    }
105
106    ArchiveOutcome {
107        archived_immediately: true,
108        roots_to_delete: Vec::new(),
109    }
110}
111
112fn build_cleanup_plan(
113    session_id: &acp::SessionId,
114    current_workspace: Option<Entity<Workspace>>,
115    requesting_window: WindowHandle<MultiWorkspace>,
116    cx: &App,
117) -> Option<CleanupPlan> {
118    let metadata = ThreadMetadataStore::global(cx)
119        .read(cx)
120        .entry(session_id)
121        .cloned()?;
122
123    let workspaces = all_open_workspaces(cx);
124
125    let candidate_roots = metadata
126        .folder_paths
127        .ordered_paths()
128        .filter_map(|path| build_root_plan(path, &workspaces, cx))
129        .filter(|plan| {
130            !path_is_referenced_by_other_unarchived_threads(session_id, &plan.root_path, cx)
131        })
132        .collect::<Vec<_>>();
133
134    if candidate_roots.is_empty() {
135        return Some(CleanupPlan {
136            roots: Vec::new(),
137            current_workspace,
138            current_workspace_will_be_empty: false,
139            fallback: None,
140            affected_workspaces: Vec::new(),
141        });
142    }
143
144    let mut affected_workspaces = Vec::new();
145    let mut current_workspace_will_be_empty = false;
146
147    for workspace in workspaces.iter() {
148        let doomed_root_count = workspace
149            .read(cx)
150            .root_paths(cx)
151            .into_iter()
152            .filter(|path| {
153                candidate_roots
154                    .iter()
155                    .any(|root| root.root_path.as_path() == path.as_ref())
156            })
157            .count();
158
159        if doomed_root_count == 0 {
160            continue;
161        }
162
163        let surviving_root_count = workspace
164            .read(cx)
165            .root_paths(cx)
166            .len()
167            .saturating_sub(doomed_root_count);
168        if current_workspace
169            .as_ref()
170            .is_some_and(|current| current == workspace)
171        {
172            current_workspace_will_be_empty = surviving_root_count == 0;
173        }
174        affected_workspaces.push(workspace.clone());
175    }
176
177    let fallback = if current_workspace_will_be_empty {
178        choose_fallback_target(
179            session_id,
180            current_workspace.as_ref(),
181            &candidate_roots,
182            &requesting_window,
183            &workspaces,
184            cx,
185        )
186    } else {
187        None
188    };
189
190    Some(CleanupPlan {
191        roots: candidate_roots,
192        current_workspace,
193        current_workspace_will_be_empty,
194        fallback,
195        affected_workspaces,
196    })
197}
198
199fn build_root_plan(path: &Path, workspaces: &[Entity<Workspace>], cx: &App) -> Option<RootPlan> {
200    let path = path.to_path_buf();
201    let affected_projects = workspaces
202        .iter()
203        .filter_map(|workspace| {
204            let project = workspace.read(cx).project().clone();
205            let worktree = project
206                .read(cx)
207                .visible_worktrees(cx)
208                .find(|worktree| worktree.read(cx).abs_path().as_ref() == path.as_path())?;
209            let worktree_id = worktree.read(cx).id();
210            Some(AffectedProject {
211                project,
212                worktree_id,
213            })
214        })
215        .collect::<Vec<_>>();
216
217    let linked_snapshot = workspaces
218        .iter()
219        .flat_map(|workspace| {
220            workspace
221                .read(cx)
222                .project()
223                .read(cx)
224                .repositories(cx)
225                .values()
226                .cloned()
227                .collect::<Vec<_>>()
228        })
229        .find_map(|repo| {
230            let snapshot = repo.read(cx).snapshot();
231            (snapshot.is_linked_worktree()
232                && snapshot.work_directory_abs_path.as_ref() == path.as_path())
233            .then_some(snapshot)
234        })?;
235
236    Some(RootPlan {
237        root_path: path,
238        main_repo_path: linked_snapshot.original_repo_abs_path.to_path_buf(),
239        affected_projects,
240    })
241}
242
243fn path_is_referenced_by_other_unarchived_threads(
244    current_session_id: &acp::SessionId,
245    path: &Path,
246    cx: &App,
247) -> bool {
248    ThreadMetadataStore::global(cx)
249        .read(cx)
250        .entries()
251        .filter(|thread| thread.session_id != *current_session_id)
252        .filter(|thread| !thread.archived)
253        .any(|thread| {
254            thread
255                .folder_paths
256                .paths()
257                .iter()
258                .any(|other_path| other_path.as_path() == path)
259        })
260}
261
262fn choose_fallback_target(
263    current_session_id: &acp::SessionId,
264    current_workspace: Option<&Entity<Workspace>>,
265    roots: &[RootPlan],
266    requesting_window: &WindowHandle<MultiWorkspace>,
267    workspaces: &[Entity<Workspace>],
268    cx: &App,
269) -> Option<FallbackTarget> {
270    let doomed_roots = roots
271        .iter()
272        .map(|root| root.root_path.clone())
273        .collect::<HashSet<_>>();
274
275    let surviving_same_window = requesting_window.read(cx).ok().and_then(|multi_workspace| {
276        multi_workspace
277            .workspaces()
278            .iter()
279            .filter(|workspace| current_workspace.is_none_or(|current| *workspace != current))
280            .find(|workspace| workspace_survives(workspace, &doomed_roots, cx))
281            .cloned()
282    });
283    if let Some(workspace) = surviving_same_window {
284        return Some(FallbackTarget::ExistingWorkspace {
285            window: *requesting_window,
286            workspace,
287        });
288    }
289
290    for window in cx
291        .windows()
292        .into_iter()
293        .filter_map(|window| window.downcast::<MultiWorkspace>())
294    {
295        if window == *requesting_window {
296            continue;
297        }
298        if let Ok(multi_workspace) = window.read(cx) {
299            if let Some(workspace) = multi_workspace
300                .workspaces()
301                .iter()
302                .find(|workspace| workspace_survives(workspace, &doomed_roots, cx))
303                .cloned()
304            {
305                return Some(FallbackTarget::ExistingWorkspace { window, workspace });
306            }
307        }
308    }
309
310    let safe_thread_workspace = ThreadMetadataStore::global(cx)
311        .read(cx)
312        .entries()
313        .filter(|metadata| metadata.session_id != *current_session_id && !metadata.archived)
314        .filter_map(|metadata| {
315            workspaces
316                .iter()
317                .find(|workspace| workspace_path_list(workspace, cx) == metadata.folder_paths)
318                .cloned()
319        })
320        .find(|workspace| workspace_survives(workspace, &doomed_roots, cx));
321
322    if let Some(workspace) = safe_thread_workspace {
323        let window = window_for_workspace(&workspace, cx).unwrap_or(*requesting_window);
324        return Some(FallbackTarget::ExistingWorkspace { window, workspace });
325    }
326
327    if let Some(root) = roots.first() {
328        return Some(FallbackTarget::OpenPaths {
329            requesting_window: *requesting_window,
330            paths: vec![root.main_repo_path.clone()],
331        });
332    }
333
334    Some(FallbackTarget::OpenEmpty {
335        requesting_window: *requesting_window,
336    })
337}
338
339async fn run_cleanup(plan: CleanupPlan, cx: &mut AsyncApp) {
340    let roots_to_delete =
341        cx.update_global::<ThreadArchiveCleanupCoordinator, _>(|coordinator, _cx| {
342            let mut in_flight_roots = coordinator.in_flight_roots.lock();
343            plan.roots
344                .iter()
345                .filter_map(|root| {
346                    if in_flight_roots.insert(root.root_path.clone()) {
347                        Some(root.clone())
348                    } else {
349                        None
350                    }
351                })
352                .collect::<Vec<_>>()
353        });
354
355    if roots_to_delete.is_empty() {
356        return;
357    }
358
359    let active_workspace = plan.current_workspace.clone();
360    if let Some(workspace) = active_workspace
361        .as_ref()
362        .filter(|_| plan.current_workspace_will_be_empty)
363    {
364        let Some(window) = window_for_workspace_async(workspace, cx) else {
365            release_in_flight_roots(&roots_to_delete, cx);
366            return;
367        };
368
369        let should_continue = save_workspace_for_root_removal(workspace.clone(), window, cx).await;
370        if !should_continue {
371            release_in_flight_roots(&roots_to_delete, cx);
372            return;
373        }
374    }
375
376    for workspace in plan
377        .affected_workspaces
378        .iter()
379        .filter(|workspace| Some((*workspace).clone()) != active_workspace)
380    {
381        let Some(window) = window_for_workspace_async(workspace, cx) else {
382            continue;
383        };
384
385        if !save_workspace_for_root_removal(workspace.clone(), window, cx).await {
386            release_in_flight_roots(&roots_to_delete, cx);
387            return;
388        }
389    }
390
391    if plan.current_workspace_will_be_empty {
392        if let Some(fallback) = plan.fallback.clone() {
393            activate_fallback(fallback, cx).await.log_err();
394        }
395    }
396
397    let mut git_removal_errors: Vec<(PathBuf, anyhow::Error)> = Vec::new();
398
399    for root in &roots_to_delete {
400        if let Err(error) = remove_root(root.clone(), cx).await {
401            git_removal_errors.push((root.root_path.clone(), error));
402        }
403    }
404
405    cleanup_empty_workspaces(&plan.affected_workspaces, cx).await;
406
407    if !git_removal_errors.is_empty() {
408        let detail = git_removal_errors
409            .into_iter()
410            .map(|(path, error)| format!("{}: {error}", path.display()))
411            .collect::<Vec<_>>()
412            .join("\n");
413        show_error_toast(
414            "Thread archived, but linked worktree cleanup failed",
415            &detail,
416            &plan,
417            cx,
418        );
419    }
420
421    release_in_flight_roots(&roots_to_delete, cx);
422}
423
424async fn save_workspace_for_root_removal(
425    workspace: Entity<Workspace>,
426    window: WindowHandle<MultiWorkspace>,
427    cx: &mut AsyncApp,
428) -> bool {
429    let has_dirty_items = workspace.read_with(cx, |workspace, cx| {
430        workspace.items(cx).any(|item| item.is_dirty(cx))
431    });
432
433    if has_dirty_items {
434        let _ = window.update(cx, |multi_workspace, window, cx| {
435            window.activate_window();
436            multi_workspace.activate(workspace.clone(), window, cx);
437        });
438    }
439
440    let save_task = window.update(cx, |_multi_workspace, window, cx| {
441        workspace.update(cx, |workspace, cx| {
442            workspace.save_for_root_removal(window, cx)
443        })
444    });
445
446    let Ok(task) = save_task else {
447        return false;
448    };
449
450    task.await.unwrap_or(false)
451}
452
453async fn activate_fallback(target: FallbackTarget, cx: &mut AsyncApp) -> Result<()> {
454    match target {
455        FallbackTarget::ExistingWorkspace { window, workspace } => {
456            window.update(cx, |multi_workspace, window, cx| {
457                window.activate_window();
458                multi_workspace.activate(workspace, window, cx);
459            })?;
460        }
461        FallbackTarget::OpenPaths {
462            requesting_window,
463            paths,
464        } => {
465            let app_state = current_app_state(cx).context("no workspace app state available")?;
466            cx.update(|cx| {
467                open_paths(
468                    &paths,
469                    app_state,
470                    OpenOptions {
471                        requesting_window: Some(requesting_window),
472                        open_mode: OpenMode::Activate,
473                        ..Default::default()
474                    },
475                    cx,
476                )
477            })
478            .await?;
479        }
480        FallbackTarget::OpenEmpty { requesting_window } => {
481            let app_state = current_app_state(cx).context("no workspace app state available")?;
482            cx.update(|cx| {
483                open_new(
484                    OpenOptions {
485                        requesting_window: Some(requesting_window),
486                        open_mode: OpenMode::Activate,
487                        ..Default::default()
488                    },
489                    app_state,
490                    cx,
491                    |_workspace, _window, _cx| {},
492                )
493            })
494            .await?;
495        }
496    }
497
498    Ok(())
499}
500
501async fn remove_root(root: RootPlan, cx: &mut AsyncApp) -> Result<()> {
502    let release_tasks: Vec<_> = root
503        .affected_projects
504        .iter()
505        .map(|affected| {
506            let project = affected.project.clone();
507            let worktree_id = affected.worktree_id;
508            project.update(cx, |project, cx| {
509                let wait = project.wait_for_worktree_release(worktree_id, cx);
510                project.remove_worktree(worktree_id, cx);
511                wait
512            })
513        })
514        .collect();
515
516    if let Err(error) = remove_root_after_worktree_removal(&root, release_tasks, cx).await {
517        rollback_root(&root, cx).await;
518        return Err(error);
519    }
520
521    Ok(())
522}
523
524async fn remove_root_after_worktree_removal(
525    root: &RootPlan,
526    release_tasks: Vec<Task<Result<()>>>,
527    cx: &mut AsyncApp,
528) -> Result<()> {
529    for task in release_tasks {
530        task.await?;
531    }
532
533    let (repo, _temp_project) = repository_for_root_removal(root, cx).await?;
534    let receiver = repo.update(cx, |repo: &mut Repository, _cx| {
535        repo.remove_worktree(root.root_path.clone(), false)
536    });
537    let result = receiver
538        .await
539        .map_err(|_| anyhow!("git worktree removal was canceled"))?;
540    result
541}
542
543async fn repository_for_root_removal(
544    root: &RootPlan,
545    cx: &mut AsyncApp,
546) -> Result<(Entity<Repository>, Option<Entity<Project>>)> {
547    let live_repo = cx.update(|cx| {
548        all_open_workspaces(cx)
549            .into_iter()
550            .flat_map(|workspace| {
551                workspace
552                    .read(cx)
553                    .project()
554                    .read(cx)
555                    .repositories(cx)
556                    .values()
557                    .cloned()
558                    .collect::<Vec<_>>()
559            })
560            .find(|repo| {
561                repo.read(cx).snapshot().work_directory_abs_path.as_ref()
562                    == root.main_repo_path.as_path()
563            })
564    });
565
566    if let Some(repo) = live_repo {
567        return Ok((repo, None));
568    }
569
570    let app_state =
571        current_app_state(cx).context("no app state available for temporary project")?;
572    let temp_project = cx.update(|cx| {
573        Project::local(
574            app_state.client.clone(),
575            app_state.node_runtime.clone(),
576            app_state.user_store.clone(),
577            app_state.languages.clone(),
578            app_state.fs.clone(),
579            None,
580            LocalProjectFlags::default(),
581            cx,
582        )
583    });
584
585    let create_worktree = temp_project.update(cx, |project, cx| {
586        project.create_worktree(root.main_repo_path.clone(), true, cx)
587    });
588    let _worktree = create_worktree.await?;
589    let initial_scan = temp_project.read_with(cx, |project, cx| project.wait_for_initial_scan(cx));
590    initial_scan.await;
591
592    let repo = temp_project
593        .update(cx, |project, cx| {
594            project
595                .repositories(cx)
596                .values()
597                .find(|repo| {
598                    repo.read(cx).snapshot().work_directory_abs_path.as_ref()
599                        == root.main_repo_path.as_path()
600                })
601                .cloned()
602        })
603        .context("failed to resolve temporary main repository handle")?;
604
605    let barrier = repo.update(cx, |repo: &mut Repository, _cx| repo.barrier());
606    barrier
607        .await
608        .map_err(|_| anyhow!("temporary repository barrier canceled"))?;
609    Ok((repo, Some(temp_project)))
610}
611
612async fn rollback_root(root: &RootPlan, cx: &mut AsyncApp) {
613    for affected in &root.affected_projects {
614        let task = affected.project.update(cx, |project, cx| {
615            project.create_worktree(root.root_path.clone(), true, cx)
616        });
617        let _ = task.await;
618    }
619}
620
621async fn cleanup_empty_workspaces(workspaces: &[Entity<Workspace>], cx: &mut AsyncApp) {
622    for workspace in workspaces {
623        let is_empty = workspace.read_with(cx, |workspace, cx| workspace.root_paths(cx).is_empty());
624        if !is_empty {
625            continue;
626        }
627
628        let Some(window) = window_for_workspace_async(workspace, cx) else {
629            continue;
630        };
631
632        let _ = window.update(cx, |multi_workspace, window, cx| {
633            if !multi_workspace.remove(workspace, window, cx) {
634                window.remove_window();
635            }
636        });
637    }
638}
639
640fn show_error_toast(summary: &str, detail: &str, plan: &CleanupPlan, cx: &mut AsyncApp) {
641    let target_workspace = plan
642        .current_workspace
643        .clone()
644        .or_else(|| plan.affected_workspaces.first().cloned());
645    let Some(workspace) = target_workspace else {
646        return;
647    };
648
649    let _ = workspace.update(cx, |workspace, cx| {
650        struct ArchiveCleanupErrorToast;
651        let message = if detail.is_empty() {
652            summary.to_string()
653        } else {
654            format!("{summary}: {detail}")
655        };
656        workspace.show_toast(
657            Toast::new(
658                NotificationId::unique::<ArchiveCleanupErrorToast>(),
659                message,
660            )
661            .autohide(),
662            cx,
663        );
664    });
665}
666
667fn all_open_workspaces(cx: &App) -> Vec<Entity<Workspace>> {
668    cx.windows()
669        .into_iter()
670        .filter_map(|window| window.downcast::<MultiWorkspace>())
671        .flat_map(|multi_workspace| {
672            multi_workspace
673                .read(cx)
674                .map(|multi_workspace| multi_workspace.workspaces().to_vec())
675                .unwrap_or_default()
676        })
677        .collect()
678}
679
680fn workspace_survives(
681    workspace: &Entity<Workspace>,
682    doomed_roots: &HashSet<PathBuf>,
683    cx: &App,
684) -> bool {
685    workspace
686        .read(cx)
687        .root_paths(cx)
688        .into_iter()
689        .any(|root| !doomed_roots.contains(root.as_ref()))
690}
691
692fn workspace_path_list(workspace: &Entity<Workspace>, cx: &App) -> PathList {
693    PathList::new(&workspace.read(cx).root_paths(cx))
694}
695
696fn window_for_workspace(
697    workspace: &Entity<Workspace>,
698    cx: &App,
699) -> Option<WindowHandle<MultiWorkspace>> {
700    cx.windows()
701        .into_iter()
702        .filter_map(|window| window.downcast::<MultiWorkspace>())
703        .find(|window| {
704            window
705                .read(cx)
706                .map(|multi_workspace| multi_workspace.workspaces().contains(workspace))
707                .unwrap_or(false)
708        })
709}
710
711fn window_for_workspace_async(
712    workspace: &Entity<Workspace>,
713    cx: &mut AsyncApp,
714) -> Option<WindowHandle<MultiWorkspace>> {
715    let workspace = workspace.clone();
716    cx.update(|cx| window_for_workspace(&workspace, cx))
717}
718
719fn current_app_state(cx: &mut AsyncApp) -> Option<Arc<AppState>> {
720    cx.update(|cx| {
721        all_open_workspaces(cx)
722            .into_iter()
723            .next()
724            .map(|workspace| workspace.read(cx).app_state().clone())
725    })
726}
727
728fn release_in_flight_roots(roots: &[RootPlan], cx: &mut AsyncApp) {
729    cx.update_global::<ThreadArchiveCleanupCoordinator, _>(|coordinator, _cx| {
730        let mut in_flight_roots = coordinator.in_flight_roots.lock();
731        for root in roots {
732            in_flight_roots.remove(&root.root_path);
733        }
734    });
735}