Detailed changes
@@ -15992,6 +15992,7 @@ dependencies = [
"editor",
"feature_flags",
"fs",
+ "futures 0.3.32",
"git",
"gpui",
"language_model",
@@ -16006,6 +16007,7 @@ dependencies = [
"serde",
"serde_json",
"settings",
+ "smol",
"theme",
"theme_settings",
"ui",
@@ -1,5 +1,4 @@
use std::{
- future::Future,
path::{Path, PathBuf},
sync::Arc,
};
@@ -427,23 +426,17 @@ impl ThreadMetadataStore {
}
}
- pub fn archive<F, Fut>(
+ pub fn archive(
&mut self,
session_id: &acp::SessionId,
- task_builder: Option<F>,
+ in_flight: Option<(Task<()>, smol::channel::Sender<()>)>,
cx: &mut Context<Self>,
- ) where
- F: FnOnce(smol::channel::Receiver<()>) -> Fut,
- Fut: Future<Output = ()> + 'static,
- {
+ ) {
self.update_archived(session_id, true, cx);
- if let Some(task_builder) = task_builder {
- let (cancel_tx, cancel_rx) = smol::channel::bounded(1);
- let future = task_builder(cancel_rx);
- let task = cx.foreground_executor().spawn(future);
+ if let Some(in_flight) = in_flight {
self.in_flight_archives
- .insert(session_id.clone(), (task, cancel_tx));
+ .insert(session_id.clone(), in_flight);
}
}
@@ -1907,14 +1900,11 @@ mod tests {
cx.update(|cx| {
let store = ThreadMetadataStore::global(cx);
store.update(cx, |store, cx| {
- store.archive(
- &acp::SessionId::new("session-1"),
- None::<fn(smol::channel::Receiver<()>) -> std::future::Ready<()>>,
- cx,
- );
+ store.archive(&acp::SessionId::new("session-1"), None, cx);
});
});
+ // Thread 1 should now be archived
cx.run_until_parked();
cx.update(|cx| {
@@ -1988,11 +1978,7 @@ mod tests {
cx.update(|cx| {
let store = ThreadMetadataStore::global(cx);
store.update(cx, |store, cx| {
- store.archive(
- &acp::SessionId::new("session-2"),
- None::<fn(smol::channel::Receiver<()>) -> std::future::Ready<()>>,
- cx,
- );
+ store.archive(&acp::SessionId::new("session-2"), None, cx);
});
});
@@ -2092,11 +2078,7 @@ mod tests {
cx.update(|cx| {
let store = ThreadMetadataStore::global(cx);
store.update(cx, |store, cx| {
- store.archive(
- &acp::SessionId::new("session-1"),
- None::<fn(smol::channel::Receiver<()>) -> std::future::Ready<()>>,
- cx,
- );
+ store.archive(&acp::SessionId::new("session-1"), None, cx);
});
});
@@ -2144,11 +2126,7 @@ mod tests {
cx.update(|cx| {
let store = ThreadMetadataStore::global(cx);
store.update(cx, |store, cx| {
- store.archive(
- &acp::SessionId::new("nonexistent"),
- None::<fn(smol::channel::Receiver<()>) -> std::future::Ready<()>>,
- cx,
- );
+ store.archive(&acp::SessionId::new("nonexistent"), None, cx);
});
});
@@ -2177,11 +2155,7 @@ mod tests {
let store = ThreadMetadataStore::global(cx);
store.update(cx, |store, cx| {
store.save(metadata.clone(), cx);
- store.archive(
- &session_id,
- None::<fn(smol::channel::Receiver<()>) -> std::future::Ready<()>>,
- cx,
- );
+ store.archive(&session_id, None, cx);
});
});
@@ -6,7 +6,7 @@ use std::{
use agent_client_protocol as acp;
use anyhow::{Context as _, Result, anyhow};
use git::repository::{AskPassDelegate, CommitOptions, ResetMode};
-use gpui::{App, AsyncApp, Entity, Task, WindowHandle};
+use gpui::{App, AsyncApp, Entity, Task};
use project::{
LocalProjectFlags, Project, WorktreeId,
git_store::{Repository, resolve_git_worktree_to_main_repo},
@@ -502,26 +502,8 @@ pub async fn restore_worktree_via_git(
row: &ArchivedGitWorktree,
cx: &mut AsyncApp,
) -> Result<PathBuf> {
- // Find the main repo entity and verify original_commit_hash exists
let (main_repo, _temp_project) = find_or_create_repository(&row.main_repo_path, cx).await?;
- let commit_exists = main_repo
- .update(cx, |repo, _cx| {
- repo.resolve_commit(row.original_commit_hash.clone())
- })
- .await
- .map_err(|_| anyhow!("resolve_commit was canceled"))?
- .context("failed to check if original commit exists")?;
-
- if !commit_exists {
- anyhow::bail!(
- "Original commit {} no longer exists in the repository — \
- cannot restore worktree. The git history this archive depends on may have been \
- rewritten or garbage-collected.",
- row.original_commit_hash
- );
- }
-
// Check if worktree path already exists on disk
let worktree_path = &row.worktree_path;
let app_state = current_app_state(cx).context("no app state available")?;
@@ -717,35 +699,12 @@ pub fn all_open_workspaces(cx: &App) -> Vec<Entity<Workspace>> {
.flat_map(|multi_workspace| {
multi_workspace
.read(cx)
- .map(|multi_workspace| multi_workspace.workspaces().to_vec())
+ .map(|multi_workspace| multi_workspace.workspaces().cloned().collect::<Vec<_>>())
.unwrap_or_default()
})
.collect()
}
-fn window_for_workspace(
- workspace: &Entity<Workspace>,
- cx: &App,
-) -> Option<WindowHandle<MultiWorkspace>> {
- cx.windows()
- .into_iter()
- .filter_map(|window| window.downcast::<MultiWorkspace>())
- .find(|window| {
- window
- .read(cx)
- .map(|multi_workspace| multi_workspace.workspaces().contains(workspace))
- .unwrap_or(false)
- })
-}
-
-fn window_for_workspace_async(
- workspace: &Entity<Workspace>,
- cx: &mut AsyncApp,
-) -> Option<WindowHandle<MultiWorkspace>> {
- let workspace = workspace.clone();
- cx.update(|cx| window_for_workspace(&workspace, cx))
-}
-
fn current_app_state(cx: &mut AsyncApp) -> Option<Arc<AppState>> {
cx.update(|cx| {
all_open_workspaces(cx)
@@ -26,6 +26,7 @@ chrono.workspace = true
editor.workspace = true
feature_flags.workspace = true
fs.workspace = true
+futures.workspace = true
git.workspace = true
gpui.workspace = true
log.workspace = true
@@ -37,6 +38,7 @@ remote.workspace = true
serde.workspace = true
serde_json.workspace = true
settings.workspace = true
+smol.workspace = true
theme.workspace = true
theme_settings.workspace = true
ui.workspace = true
@@ -110,6 +110,11 @@ enum SidebarView {
Archive(Entity<ThreadsArchiveView>),
}
+enum ArchiveStatus {
+ Success,
+ UserCancelledPrompt,
+}
+
#[derive(Clone, Debug)]
enum ActiveEntry {
Thread {
@@ -2190,12 +2195,10 @@ impl Sidebar {
ThreadMetadataStore::global(cx).update(cx, |store, cx| store.unarchive(&session_id, cx));
if metadata.folder_paths.paths().is_empty() {
- let active_workspace = self.multi_workspace.upgrade().and_then(|w| {
- w.read(cx)
- .workspaces()
- .get(w.read(cx).active_workspace_index())
- .cloned()
- });
+ let active_workspace = self
+ .multi_workspace
+ .upgrade()
+ .map(|w| w.read(cx).workspace().clone());
if let Some(workspace) = active_workspace {
self.activate_thread_locally(&metadata, &workspace, window, cx);
@@ -2455,12 +2458,83 @@ impl Sidebar {
window: &mut Window,
cx: &mut Context<Self>,
) {
- thread_worktree_archive::archive_thread(
- session_id,
- self.active_entry_workspace().cloned(),
- window.window_handle().downcast::<MultiWorkspace>(),
- cx,
- );
+ // --- Determine if worktree cleanup is needed (must come before archive call) ---
+ let metadata = ThreadMetadataStore::global(cx)
+ .read(cx)
+ .entry(session_id)
+ .cloned();
+
+ let window_handle = window.window_handle().downcast::<MultiWorkspace>();
+ let current_workspace = self.active_entry_workspace().cloned();
+
+ let in_flight = metadata.as_ref().and_then(|metadata| {
+ let window_handle = window_handle?;
+ let workspaces = thread_worktree_archive::all_open_workspaces(cx);
+ let roots: Vec<_> = metadata
+ .folder_paths
+ .ordered_paths()
+ .filter_map(|path| thread_worktree_archive::build_root_plan(path, &workspaces, cx))
+ .filter(|plan| {
+ !thread_worktree_archive::path_is_referenced_by_other_unarchived_threads(
+ session_id,
+ &plan.root_path,
+ cx,
+ )
+ })
+ .collect();
+
+ if roots.is_empty() {
+ return None;
+ }
+
+ let (cancel_tx, cancel_rx) = smol::channel::bounded(1);
+ let folder_paths = metadata.folder_paths.clone();
+ let current_workspace = current_workspace.clone();
+ let session_id = session_id.clone();
+
+ let task = cx.spawn(async move |_this, cx| {
+ let result = Self::archive_worktree(
+ roots,
+ folder_paths,
+ current_workspace,
+ window_handle,
+ cancel_rx,
+ cx,
+ )
+ .await;
+
+ match result {
+ Ok(ArchiveStatus::Success) => {
+ cx.update(|cx| {
+ ThreadMetadataStore::global(cx).update(cx, |store, _cx| {
+ store.cleanup_completed_archive(&session_id);
+ });
+ });
+ }
+ Ok(ArchiveStatus::UserCancelledPrompt) => {
+ cx.update(|cx| {
+ ThreadMetadataStore::global(cx).update(cx, |store, cx| {
+ store.unarchive(&session_id, cx);
+ });
+ });
+ }
+ Err(error) => {
+ log::error!("Failed to archive worktree: {error:#}");
+ cx.update(|cx| {
+ ThreadMetadataStore::global(cx).update(cx, |store, cx| {
+ store.unarchive(&session_id, cx);
+ });
+ });
+ }
+ }
+ });
+
+ Some((task, cancel_tx))
+ });
+
+ ThreadMetadataStore::global(cx).update(cx, |store, cx| {
+ store.archive(session_id, in_flight, cx);
+ });
// If we're archiving the currently focused thread, move focus to the
// nearest thread within the same project group. We never cross group
@@ -2571,6 +2645,122 @@ impl Sidebar {
}
}
+ async fn archive_worktree(
+ roots: Vec<thread_worktree_archive::RootPlan>,
+ folder_paths: PathList,
+ workspace: Option<Entity<Workspace>>,
+ window: WindowHandle<MultiWorkspace>,
+ cancel_rx: smol::channel::Receiver<()>,
+ cx: &mut gpui::AsyncApp,
+ ) -> anyhow::Result<ArchiveStatus> {
+ // Step 1: Prompt user to save/discard dirty items
+ if let Some(workspace) = &workspace {
+ let has_dirty_items = workspace.read_with(cx, |workspace, cx| {
+ workspace.items(cx).any(|item| item.is_dirty(cx))
+ });
+
+ if has_dirty_items {
+ window
+ .update(cx, |multi_workspace, window, cx| {
+ window.activate_window();
+ multi_workspace.activate(workspace.clone(), window, cx);
+ })
+ .log_err();
+ }
+
+ let save_task = window.update(cx, |_multi_workspace, window, cx| {
+ workspace.update(cx, |workspace, cx| {
+ workspace.prompt_to_save_or_discard_dirty_items(window, cx)
+ })
+ })?;
+
+ // Race the save prompt against cancellation
+ let mut save_future = std::pin::pin!(save_task);
+ let mut cancel_future = std::pin::pin!(cancel_rx.recv());
+ let user_confirmed =
+ futures::future::select(&mut save_future, &mut cancel_future).await;
+
+ match user_confirmed {
+ futures::future::Either::Left((result, _)) => {
+ if !result.unwrap_or(false) {
+ return Ok(ArchiveStatus::UserCancelledPrompt);
+ }
+ }
+ futures::future::Either::Right(_) => {
+ return Ok(ArchiveStatus::UserCancelledPrompt);
+ }
+ }
+ }
+
+ // Step 2: Close the workspace via MultiWorkspace::remove.
+ // Hold a strong Project reference so persist/remove can still work.
+ let project = workspace
+ .as_ref()
+ .map(|workspace| workspace.read_with(cx, |workspace, _cx| workspace.project().clone()));
+ if let Some(workspace) = &workspace {
+ window
+ .update(cx, |multi_workspace, window, cx| {
+ multi_workspace.remove(workspace, window, cx);
+ })
+ .log_err();
+ }
+
+ // Step 3: Iterate over roots - persist git state then remove
+ let mut completed_persists: Vec<(
+ thread_worktree_archive::PersistOutcome,
+ thread_worktree_archive::RootPlan,
+ )> = Vec::new();
+
+ for root in &roots {
+ // Check for cancellation before each root
+ if cancel_rx.try_recv().is_ok() {
+ for (outcome, completed_root) in completed_persists.iter().rev() {
+ thread_worktree_archive::rollback_persist(outcome, completed_root, cx).await;
+ }
+ return Ok(ArchiveStatus::UserCancelledPrompt);
+ }
+
+ // Persist worktree state (git WIP commits + DB record)
+ if root.worktree_repo.is_some() {
+ match thread_worktree_archive::persist_worktree_state(root, &folder_paths, cx).await
+ {
+ Ok(outcome) => {
+ completed_persists.push((outcome, root.clone()));
+ }
+ Err(error) => {
+ for (outcome, completed_root) in completed_persists.iter().rev() {
+ thread_worktree_archive::rollback_persist(outcome, completed_root, cx)
+ .await;
+ }
+ return Err(error);
+ }
+ }
+ }
+
+ // Remove the root (remove from projects + delete git worktree from disk)
+ if let Err(error) = thread_worktree_archive::remove_root(root.clone(), cx).await {
+ // Rollback the persist for this root if we just did one
+ if let Some((outcome, completed_root)) = completed_persists.last() {
+ if completed_root.root_path == root.root_path {
+ thread_worktree_archive::rollback_persist(outcome, completed_root, cx)
+ .await;
+ completed_persists.pop();
+ }
+ }
+ // Roll back all prior persists
+ for (outcome, completed_root) in completed_persists.iter().rev() {
+ thread_worktree_archive::rollback_persist(outcome, completed_root, cx).await;
+ }
+ return Err(error);
+ }
+ }
+
+ // Keep project alive until we're done
+ drop(project);
+
+ Ok(ArchiveStatus::Success)
+ }
+
fn remove_selected_thread(
&mut self,
_: &RemoveSelectedThread,
@@ -4664,7 +4664,7 @@ async fn test_archived_threads_excluded_from_sidebar_entries(cx: &mut TestAppCon
cx.update(|_, cx| {
ThreadMetadataStore::global(cx).update(cx, |store, cx| {
- store.archive(&archived_thread_session_id, cx)
+ store.archive(&archived_thread_session_id, None, cx)
})
});
cx.run_until_parked();