From d0b1a761124b429f8a124962f284f7ee7f2e218b Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Fri, 3 Apr 2026 20:47:54 -0400 Subject: [PATCH] Add thread archive cleanup with linked worktree removal When archiving an agent thread that uses linked git worktrees, clean up those worktrees by removing them from open projects and running git worktree remove. Handles fallback workspace navigation when the current workspace becomes empty, save prompts for dirty buffers, and rollback on failure. Key design decisions: - Sidebar focus management always runs on archive, even when async cleanup is pending, so active_entry stays consistent. - All error paths after worktree removal trigger rollback to re-add the worktrees to affected projects. - Temporary projects created for repository access are kept alive until the git operation completes. - The open workspaces list is computed once during planning and threaded through to avoid redundant iteration. --- crates/agent_ui/src/agent_ui.rs | 1 + crates/agent_ui/src/thread_archive_cleanup.rs | 735 ++++++++++++++++++ crates/project/src/project.rs | 27 + crates/sidebar/src/sidebar.rs | 13 +- crates/workspace/src/multi_workspace.rs | 4 + crates/workspace/src/workspace.rs | 8 + 6 files changed, 787 insertions(+), 1 deletion(-) create mode 100644 crates/agent_ui/src/thread_archive_cleanup.rs 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,