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}