From 55d728e7523f27ceb670f1e26cde59e7c3854c3c Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sun, 5 Apr 2026 21:53:54 -0400 Subject: [PATCH] Implement worktree archive persistence and restore Add the ability to archive and restore git worktrees when threads are archived, preserving both staged and unstaged state across the cycle. Key changes: - Store both staged and unstaged commit SHAs in archived worktree DB records instead of a single hash, eliminating fragile HEAD~N arithmetic for rollback and restore operations - Await rollback DB operations and surface errors instead of fire-and-forget with .detach() - Handle multiple worktrees in restore with targeted path replacement in complete_worktree_restore, preserving unrelated paths in multi-root threads - Guard cleanup_empty_workspaces against dropped workspace entities - Add cancel button for the restore spinner (visible on hover) - Show toast to user on worktree restore failure - Deserialize persisted project_group_keys when restoring windows - Add deterministic tests for two-SHA round-trip, single/multiple path replacement, and multi-worktree archive scenarios --- Cargo.lock | 1 + crates/agent_ui/src/agent_ui.rs | 1 + crates/agent_ui/src/thread_import.rs | 1 + crates/agent_ui/src/thread_metadata_store.rs | 772 ++++++++++- .../agent_ui/src/thread_worktree_archive.rs | 1167 +++++++++++++++++ crates/fs/src/fake_git_repo.rs | 161 ++- crates/fs/tests/integration/fake_git_repo.rs | 12 +- crates/git/src/repository.rs | 84 +- crates/git_ui/src/commit_modal.rs | 1 + crates/git_ui/src/git_panel.rs | 8 +- crates/project/src/git_store.rs | 86 +- crates/project/src/project.rs | 28 +- crates/proto/proto/git.proto | 1 + crates/sidebar/Cargo.toml | 1 + crates/sidebar/src/sidebar.rs | 168 ++- crates/sidebar/src/sidebar_tests.rs | 16 + crates/ui/src/components/ai/thread_item.rs | 59 +- crates/workspace/src/multi_workspace.rs | 4 + crates/workspace/src/workspace.rs | 24 + 19 files changed, 2532 insertions(+), 63 deletions(-) create mode 100644 crates/agent_ui/src/thread_worktree_archive.rs diff --git a/Cargo.lock b/Cargo.lock index fd3ddfc882edafa29722cee7b3dbf329caecdad4..9d99c1dbfe0474bb353a1bddcd680cbb3bd3cc65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15877,6 +15877,7 @@ dependencies = [ "git", "gpui", "language_model", + "log", "menu", "platform_title_bar", "pretty_assertions", diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index e58c7eb3526cc1a53d7b8e6d449e968a5923425a..ab0b01cc090f6f03ac37692c4f07284c8237fba4 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -32,6 +32,7 @@ mod thread_history; mod thread_history_view; mod thread_import; pub mod thread_metadata_store; +pub mod thread_worktree_archive; pub mod threads_archive_view; mod ui; diff --git a/crates/agent_ui/src/thread_import.rs b/crates/agent_ui/src/thread_import.rs index 5402b1c74353b73a522a068aa32dfd0a9dc85c60..a1507e1e53f0e467b5ad333f6357490a039d75fb 100644 --- a/crates/agent_ui/src/thread_import.rs +++ b/crates/agent_ui/src/thread_import.rs @@ -502,6 +502,7 @@ fn collect_importable_threads( folder_paths, main_worktree_paths: PathList::default(), archived: true, + pending_worktree_restore: None, }); } } diff --git a/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs index fcd9665c52451d62fe8185abca919148a1666126..d52654af60b88f610b82ec39eadcef8413765e94 100644 --- a/crates/agent_ui/src/thread_metadata_store.rs +++ b/crates/agent_ui/src/thread_metadata_store.rs @@ -1,4 +1,7 @@ -use std::{path::Path, sync::Arc}; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; use acp_thread::AcpThreadEvent; use agent::{ThreadStore, ZED_AGENT_ID}; @@ -68,6 +71,7 @@ fn migrate_thread_metadata(cx: &mut App) { folder_paths: entry.folder_paths, main_worktree_paths: PathList::default(), archived: true, + pending_worktree_restore: None, }) }) .collect::>() @@ -129,6 +133,10 @@ pub struct ThreadMetadata { pub folder_paths: PathList, pub main_worktree_paths: PathList, pub archived: bool, + /// When set, the thread's original worktree is being restored in the background. + /// The PathBuf is the main repo path shown temporarily while restoration is pending. + /// This is runtime-only state — not persisted to the database. + pub pending_worktree_restore: Option, } impl From<&ThreadMetadata> for acp_thread::AgentSessionInfo { @@ -144,6 +152,25 @@ impl From<&ThreadMetadata> for acp_thread::AgentSessionInfo { } } +/// Record of a git worktree that was archived (deleted from disk) when its +/// last thread was archived. +pub struct ArchivedGitWorktree { + /// Auto-incrementing primary key. + pub id: i64, + /// Absolute path to the worktree directory before deletion. + pub worktree_path: PathBuf, + /// Absolute path of the main repository that owned this worktree. + pub main_repo_path: PathBuf, + /// Branch checked out at archive time. None if detached HEAD. + pub branch_name: Option, + /// SHA of the commit capturing the staged state at archive time. + pub staged_commit_hash: String, + /// SHA of the commit capturing the unstaged state at archive time. + pub unstaged_commit_hash: String, + /// Whether this worktree has been restored. + pub restored: bool, +} + /// The store holds all metadata needed to show threads in the sidebar/the archive. /// /// Automatically listens to AcpThread events and updates metadata if it has changed. @@ -388,6 +415,123 @@ impl ThreadMetadataStore { self.update_archived(session_id, false, cx); } + pub fn set_pending_worktree_restore( + &mut self, + session_id: &acp::SessionId, + main_repo_path: Option, + cx: &mut Context, + ) { + if let Some(thread) = self.threads.get_mut(session_id) { + thread.pending_worktree_restore = main_repo_path; + cx.notify(); + } + } + + pub fn complete_worktree_restore( + &mut self, + session_id: &acp::SessionId, + path_replacements: &[(PathBuf, PathBuf)], + cx: &mut Context, + ) { + if let Some(thread) = self.threads.get(session_id).cloned() { + let mut paths: Vec = thread.folder_paths.paths().to_vec(); + for (old_path, new_path) in path_replacements { + if let Some(pos) = paths.iter().position(|p| p == old_path) { + paths[pos] = new_path.clone(); + } + } + let new_folder_paths = PathList::new(&paths); + self.save_internal(ThreadMetadata { + pending_worktree_restore: None, + folder_paths: new_folder_paths, + ..thread + }); + cx.notify(); + } + } + + pub fn create_archived_worktree( + &self, + worktree_path: &str, + main_repo_path: &str, + branch_name: Option<&str>, + staged_commit_hash: &str, + unstaged_commit_hash: &str, + cx: &App, + ) -> Task> { + let db = self.db.clone(); + let worktree_path = worktree_path.to_string(); + let main_repo_path = main_repo_path.to_string(); + let branch_name = branch_name.map(|s| s.to_string()); + let staged_commit_hash = staged_commit_hash.to_string(); + let unstaged_commit_hash = unstaged_commit_hash.to_string(); + cx.background_spawn(async move { + db.create_archived_worktree( + &worktree_path, + &main_repo_path, + branch_name.as_deref(), + &staged_commit_hash, + &unstaged_commit_hash, + ) + .await + }) + } + + pub fn link_thread_to_archived_worktree( + &self, + session_id: &str, + archived_worktree_id: i64, + cx: &App, + ) -> Task> { + let db = self.db.clone(); + let session_id = session_id.to_string(); + cx.background_spawn(async move { + db.link_thread_to_archived_worktree(&session_id, archived_worktree_id) + .await + }) + } + + pub fn get_archived_worktrees_for_thread( + &self, + session_id: &str, + cx: &App, + ) -> Task>> { + let db = self.db.clone(); + let session_id = session_id.to_string(); + cx.background_spawn(async move { db.get_archived_worktrees_for_thread(&session_id).await }) + } + + pub fn delete_archived_worktree(&self, id: i64, cx: &App) -> Task> { + let db = self.db.clone(); + cx.background_spawn(async move { db.delete_archived_worktree(id).await }) + } + + pub fn set_archived_worktree_restored( + &self, + id: i64, + worktree_path: &str, + branch_name: Option<&str>, + cx: &App, + ) -> Task> { + let db = self.db.clone(); + let worktree_path = worktree_path.to_string(); + let branch_name = branch_name.map(|s| s.to_string()); + cx.background_spawn(async move { + db.set_archived_worktree_restored(id, &worktree_path, branch_name.as_deref()) + .await + }) + } + + pub fn all_session_ids_for_path<'a>( + &'a self, + path_list: &PathList, + ) -> impl Iterator { + self.threads_by_paths + .get(path_list) + .into_iter() + .flat_map(|session_ids| session_ids.iter()) + } + fn update_archived( &mut self, session_id: &acp::SessionId, @@ -598,6 +742,7 @@ impl ThreadMetadataStore { folder_paths, main_worktree_paths, archived, + pending_worktree_restore: None, }; self.save(metadata, cx); @@ -634,6 +779,27 @@ impl Domain for ThreadMetadataDb { sql!(ALTER TABLE sidebar_threads ADD COLUMN archived INTEGER DEFAULT 0), sql!(ALTER TABLE sidebar_threads ADD COLUMN main_worktree_paths TEXT), sql!(ALTER TABLE sidebar_threads ADD COLUMN main_worktree_paths_order TEXT), + sql!( + CREATE TABLE IF NOT EXISTS archived_git_worktrees( + id INTEGER PRIMARY KEY, + worktree_path TEXT NOT NULL, + main_repo_path TEXT NOT NULL, + branch_name TEXT, + commit_hash TEXT NOT NULL, + restored INTEGER NOT NULL DEFAULT 0 + ) STRICT; + + CREATE TABLE IF NOT EXISTS thread_archived_worktrees( + session_id TEXT NOT NULL, + archived_worktree_id INTEGER NOT NULL REFERENCES archived_git_worktrees(id), + PRIMARY KEY (session_id, archived_worktree_id) + ) STRICT; + ), + sql!( + ALTER TABLE archived_git_worktrees ADD COLUMN staged_commit_hash TEXT; + ALTER TABLE archived_git_worktrees ADD COLUMN unstaged_commit_hash TEXT; + UPDATE archived_git_worktrees SET staged_commit_hash = commit_hash, unstaged_commit_hash = commit_hash WHERE staged_commit_hash IS NULL; + ), ]; } @@ -722,6 +888,111 @@ impl ThreadMetadataDb { }) .await } + + pub async fn create_archived_worktree( + &self, + worktree_path: &str, + main_repo_path: &str, + branch_name: Option<&str>, + staged_commit_hash: &str, + unstaged_commit_hash: &str, + ) -> anyhow::Result { + let worktree_path = worktree_path.to_string(); + let main_repo_path = main_repo_path.to_string(); + let branch_name = branch_name.map(|s| s.to_string()); + let staged_commit_hash = staged_commit_hash.to_string(); + let unstaged_commit_hash = unstaged_commit_hash.to_string(); + + self.write(move |conn| { + let mut stmt = Statement::prepare( + conn, + "INSERT INTO archived_git_worktrees(worktree_path, main_repo_path, branch_name, commit_hash, staged_commit_hash, unstaged_commit_hash) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6) \ + RETURNING id", + )?; + let mut i = stmt.bind(&worktree_path, 1)?; + i = stmt.bind(&main_repo_path, i)?; + i = stmt.bind(&branch_name, i)?; + i = stmt.bind(&unstaged_commit_hash, i)?; + i = stmt.bind(&staged_commit_hash, i)?; + stmt.bind(&unstaged_commit_hash, i)?; + stmt.maybe_row::()?.context("expected RETURNING id") + }) + .await + } + + pub async fn link_thread_to_archived_worktree( + &self, + session_id: &str, + archived_worktree_id: i64, + ) -> anyhow::Result<()> { + let session_id = session_id.to_string(); + + self.write(move |conn| { + let mut stmt = Statement::prepare( + conn, + "INSERT INTO thread_archived_worktrees(session_id, archived_worktree_id) \ + VALUES (?1, ?2)", + )?; + let i = stmt.bind(&session_id, 1)?; + stmt.bind(&archived_worktree_id, i)?; + stmt.exec() + }) + .await + } + + pub async fn get_archived_worktrees_for_thread( + &self, + session_id: &str, + ) -> anyhow::Result> { + let session_id = session_id.to_string(); + + self.select_bound::( + "SELECT a.id, a.worktree_path, a.main_repo_path, a.branch_name, a.staged_commit_hash, a.unstaged_commit_hash, a.restored \ + FROM archived_git_worktrees a \ + JOIN thread_archived_worktrees t ON a.id = t.archived_worktree_id \ + WHERE t.session_id = ?1", + )?(session_id) + } + + pub async fn delete_archived_worktree(&self, id: i64) -> anyhow::Result<()> { + self.write(move |conn| { + let mut stmt = Statement::prepare( + conn, + "DELETE FROM thread_archived_worktrees WHERE archived_worktree_id = ?", + )?; + stmt.bind(&id, 1)?; + stmt.exec()?; + + let mut stmt = + Statement::prepare(conn, "DELETE FROM archived_git_worktrees WHERE id = ?")?; + stmt.bind(&id, 1)?; + stmt.exec() + }) + .await + } + + pub async fn set_archived_worktree_restored( + &self, + id: i64, + worktree_path: &str, + branch_name: Option<&str>, + ) -> anyhow::Result<()> { + let worktree_path = worktree_path.to_string(); + let branch_name = branch_name.map(|s| s.to_string()); + + self.write(move |conn| { + let mut stmt = Statement::prepare( + conn, + "UPDATE archived_git_worktrees SET restored = 1, worktree_path = ?1, branch_name = ?2 WHERE id = ?3", + )?; + let mut i = stmt.bind(&worktree_path, 1)?; + i = stmt.bind(&branch_name, i)?; + stmt.bind(&id, i)?; + stmt.exec() + }) + .await + } } impl Column for ThreadMetadata { @@ -779,6 +1050,32 @@ impl Column for ThreadMetadata { folder_paths, main_worktree_paths, archived, + pending_worktree_restore: None, + }, + next, + )) + } +} + +impl Column for ArchivedGitWorktree { + fn column(statement: &mut Statement, start_index: i32) -> anyhow::Result<(Self, i32)> { + let (id, next): (i64, i32) = Column::column(statement, start_index)?; + let (worktree_path_str, next): (String, i32) = Column::column(statement, next)?; + let (main_repo_path_str, next): (String, i32) = Column::column(statement, next)?; + let (branch_name, next): (Option, i32) = Column::column(statement, next)?; + let (staged_commit_hash, next): (String, i32) = Column::column(statement, next)?; + let (unstaged_commit_hash, next): (String, i32) = Column::column(statement, next)?; + let (restored_int, next): (i64, i32) = Column::column(statement, next)?; + + Ok(( + ArchivedGitWorktree { + id, + worktree_path: PathBuf::from(worktree_path_str), + main_repo_path: PathBuf::from(main_repo_path_str), + branch_name, + staged_commit_hash, + unstaged_commit_hash, + restored: restored_int != 0, }, next, )) @@ -835,6 +1132,7 @@ mod tests { created_at: Some(updated_at), folder_paths, main_worktree_paths: PathList::default(), + pending_worktree_restore: None, } } @@ -1052,6 +1350,7 @@ mod tests { folder_paths: project_a_paths.clone(), main_worktree_paths: PathList::default(), archived: false, + pending_worktree_restore: None, }; cx.update(|cx| { @@ -1162,6 +1461,7 @@ mod tests { folder_paths: project_paths.clone(), main_worktree_paths: PathList::default(), archived: false, + pending_worktree_restore: None, }; cx.update(|cx| { @@ -1913,4 +2213,474 @@ mod tests { ); }); } + + #[gpui::test] + async fn test_create_and_retrieve_archived_worktree(cx: &mut TestAppContext) { + init_test(cx); + let store = cx.update(|cx| ThreadMetadataStore::global(cx)); + + let id = store + .read_with(cx, |store, cx| { + store.create_archived_worktree( + "/tmp/worktree", + "/home/user/repo", + Some("feature-branch"), + "abc123def456", + "abc123def456", + cx, + ) + }) + .await + .unwrap(); + + store + .read_with(cx, |store, cx| { + store.link_thread_to_archived_worktree("session-1", id, cx) + }) + .await + .unwrap(); + + let worktrees = store + .read_with(cx, |store, cx| { + store.get_archived_worktrees_for_thread("session-1", cx) + }) + .await + .unwrap(); + + assert_eq!(worktrees.len(), 1); + let wt = &worktrees[0]; + assert_eq!(wt.id, id); + assert_eq!(wt.worktree_path, PathBuf::from("/tmp/worktree")); + assert_eq!(wt.main_repo_path, PathBuf::from("/home/user/repo")); + assert_eq!(wt.branch_name.as_deref(), Some("feature-branch")); + assert_eq!(wt.staged_commit_hash, "abc123def456"); + assert_eq!(wt.unstaged_commit_hash, "abc123def456"); + assert!(!wt.restored); + } + + #[gpui::test] + async fn test_delete_archived_worktree(cx: &mut TestAppContext) { + init_test(cx); + let store = cx.update(|cx| ThreadMetadataStore::global(cx)); + + let id = store + .read_with(cx, |store, cx| { + store.create_archived_worktree( + "/tmp/worktree", + "/home/user/repo", + Some("main"), + "deadbeef", + "deadbeef", + cx, + ) + }) + .await + .unwrap(); + + store + .read_with(cx, |store, cx| { + store.link_thread_to_archived_worktree("session-1", id, cx) + }) + .await + .unwrap(); + + store + .read_with(cx, |store, cx| store.delete_archived_worktree(id, cx)) + .await + .unwrap(); + + let worktrees = store + .read_with(cx, |store, cx| { + store.get_archived_worktrees_for_thread("session-1", cx) + }) + .await + .unwrap(); + assert!(worktrees.is_empty()); + } + + #[gpui::test] + async fn test_set_archived_worktree_restored(cx: &mut TestAppContext) { + init_test(cx); + let store = cx.update(|cx| ThreadMetadataStore::global(cx)); + + let id = store + .read_with(cx, |store, cx| { + store.create_archived_worktree( + "/tmp/old-worktree", + "/home/user/repo", + Some("old-branch"), + "abc123", + "abc123", + cx, + ) + }) + .await + .unwrap(); + + store + .read_with(cx, |store, cx| { + store.set_archived_worktree_restored( + id, + "/tmp/new-worktree", + Some("new-branch"), + cx, + ) + }) + .await + .unwrap(); + + store + .read_with(cx, |store, cx| { + store.link_thread_to_archived_worktree("session-1", id, cx) + }) + .await + .unwrap(); + + let worktrees = store + .read_with(cx, |store, cx| { + store.get_archived_worktrees_for_thread("session-1", cx) + }) + .await + .unwrap(); + + assert_eq!(worktrees.len(), 1); + let wt = &worktrees[0]; + assert!(wt.restored); + assert_eq!(wt.worktree_path, PathBuf::from("/tmp/new-worktree")); + assert_eq!(wt.branch_name.as_deref(), Some("new-branch")); + } + + #[gpui::test] + async fn test_link_multiple_threads_to_archived_worktree(cx: &mut TestAppContext) { + init_test(cx); + let store = cx.update(|cx| ThreadMetadataStore::global(cx)); + + let id = store + .read_with(cx, |store, cx| { + store.create_archived_worktree( + "/tmp/worktree", + "/home/user/repo", + None, + "abc123", + "abc123", + cx, + ) + }) + .await + .unwrap(); + + store + .read_with(cx, |store, cx| { + store.link_thread_to_archived_worktree("session-1", id, cx) + }) + .await + .unwrap(); + + store + .read_with(cx, |store, cx| { + store.link_thread_to_archived_worktree("session-2", id, cx) + }) + .await + .unwrap(); + + let wt1 = store + .read_with(cx, |store, cx| { + store.get_archived_worktrees_for_thread("session-1", cx) + }) + .await + .unwrap(); + + let wt2 = store + .read_with(cx, |store, cx| { + store.get_archived_worktrees_for_thread("session-2", cx) + }) + .await + .unwrap(); + + assert_eq!(wt1.len(), 1); + assert_eq!(wt2.len(), 1); + assert_eq!(wt1[0].id, wt2[0].id); + } + + #[gpui::test] + async fn test_all_session_ids_for_path(cx: &mut TestAppContext) { + init_test(cx); + let store = cx.update(|cx| ThreadMetadataStore::global(cx)); + let paths = PathList::new(&[Path::new("/project-x")]); + + let meta1 = ThreadMetadata { + session_id: acp::SessionId::new("session-1"), + agent_id: agent::ZED_AGENT_ID.clone(), + title: "Thread 1".into(), + updated_at: Utc::now(), + created_at: Some(Utc::now()), + folder_paths: paths.clone(), + main_worktree_paths: PathList::default(), + archived: false, + pending_worktree_restore: None, + }; + let meta2 = ThreadMetadata { + session_id: acp::SessionId::new("session-2"), + agent_id: agent::ZED_AGENT_ID.clone(), + title: "Thread 2".into(), + updated_at: Utc::now(), + created_at: Some(Utc::now()), + folder_paths: paths.clone(), + main_worktree_paths: PathList::default(), + archived: true, + pending_worktree_restore: None, + }; + + store.update(cx, |store, _cx| { + store.save_internal(meta1); + store.save_internal(meta2); + }); + + let ids: HashSet = store.read_with(cx, |store, _cx| { + store.all_session_ids_for_path(&paths).cloned().collect() + }); + + assert!(ids.contains(&acp::SessionId::new("session-1"))); + assert!(ids.contains(&acp::SessionId::new("session-2"))); + assert_eq!(ids.len(), 2); + } + + #[gpui::test] + async fn test_two_sha_round_trip(cx: &mut TestAppContext) { + init_test(cx); + let store = cx.update(|cx| ThreadMetadataStore::global(cx)); + + let id = store + .read_with(cx, |store, cx| { + store.create_archived_worktree( + "/tmp/worktree", + "/home/user/repo", + Some("feature"), + "staged_sha_aaa", + "unstaged_sha_bbb", + cx, + ) + }) + .await + .unwrap(); + + store + .read_with(cx, |store, cx| { + store.link_thread_to_archived_worktree("session-1", id, cx) + }) + .await + .unwrap(); + + let worktrees = store + .read_with(cx, |store, cx| { + store.get_archived_worktrees_for_thread("session-1", cx) + }) + .await + .unwrap(); + + assert_eq!(worktrees.len(), 1); + let wt = &worktrees[0]; + assert_eq!(wt.staged_commit_hash, "staged_sha_aaa"); + assert_eq!(wt.unstaged_commit_hash, "unstaged_sha_bbb"); + assert_eq!(wt.branch_name.as_deref(), Some("feature")); + assert!(!wt.restored); + } + + #[gpui::test] + async fn test_complete_worktree_restore_single_path(cx: &mut TestAppContext) { + init_test(cx); + let store = cx.update(|cx| ThreadMetadataStore::global(cx)); + + let original_paths = PathList::new(&[Path::new("/projects/worktree-a")]); + let meta = make_metadata("session-1", "Thread 1", Utc::now(), original_paths); + + store.update(cx, |store, cx| { + store.save_manually(meta, cx); + }); + + let replacements = vec![( + PathBuf::from("/projects/worktree-a"), + PathBuf::from("/projects/worktree-a-restored"), + )]; + + store.update(cx, |store, cx| { + store.complete_worktree_restore(&acp::SessionId::new("session-1"), &replacements, cx); + }); + + let entry = store.read_with(cx, |store, _cx| { + store.entry(&acp::SessionId::new("session-1")).cloned() + }); + let entry = entry.unwrap(); + assert!(entry.pending_worktree_restore.is_none()); + assert_eq!( + entry.folder_paths.paths(), + &[PathBuf::from("/projects/worktree-a-restored")] + ); + } + + #[gpui::test] + async fn test_complete_worktree_restore_multiple_paths(cx: &mut TestAppContext) { + init_test(cx); + let store = cx.update(|cx| ThreadMetadataStore::global(cx)); + + let original_paths = PathList::new(&[ + Path::new("/projects/worktree-a"), + Path::new("/projects/worktree-b"), + Path::new("/other/unrelated"), + ]); + let meta = make_metadata("session-multi", "Multi Thread", Utc::now(), original_paths); + + store.update(cx, |store, cx| { + store.save_manually(meta, cx); + }); + + let replacements = vec![ + ( + PathBuf::from("/projects/worktree-a"), + PathBuf::from("/restored/worktree-a"), + ), + ( + PathBuf::from("/projects/worktree-b"), + PathBuf::from("/restored/worktree-b"), + ), + ]; + + store.update(cx, |store, cx| { + store.complete_worktree_restore( + &acp::SessionId::new("session-multi"), + &replacements, + cx, + ); + }); + + let entry = store.read_with(cx, |store, _cx| { + store.entry(&acp::SessionId::new("session-multi")).cloned() + }); + let entry = entry.unwrap(); + assert!(entry.pending_worktree_restore.is_none()); + + let paths = entry.folder_paths.paths(); + assert_eq!(paths.len(), 3); + assert!(paths.contains(&PathBuf::from("/restored/worktree-a"))); + assert!(paths.contains(&PathBuf::from("/restored/worktree-b"))); + assert!(paths.contains(&PathBuf::from("/other/unrelated"))); + } + + #[gpui::test] + async fn test_complete_worktree_restore_preserves_unmatched_paths(cx: &mut TestAppContext) { + init_test(cx); + let store = cx.update(|cx| ThreadMetadataStore::global(cx)); + + let original_paths = + PathList::new(&[Path::new("/projects/worktree-a"), Path::new("/other/path")]); + let meta = make_metadata("session-partial", "Partial", Utc::now(), original_paths); + + store.update(cx, |store, cx| { + store.save_manually(meta, cx); + }); + + let replacements = vec![ + ( + PathBuf::from("/projects/worktree-a"), + PathBuf::from("/new/worktree-a"), + ), + ( + PathBuf::from("/nonexistent/path"), + PathBuf::from("/should/not/appear"), + ), + ]; + + store.update(cx, |store, cx| { + store.complete_worktree_restore( + &acp::SessionId::new("session-partial"), + &replacements, + cx, + ); + }); + + let entry = store.read_with(cx, |store, _cx| { + store + .entry(&acp::SessionId::new("session-partial")) + .cloned() + }); + let entry = entry.unwrap(); + let paths = entry.folder_paths.paths(); + assert_eq!(paths.len(), 2); + assert!(paths.contains(&PathBuf::from("/new/worktree-a"))); + assert!(paths.contains(&PathBuf::from("/other/path"))); + assert!(!paths.contains(&PathBuf::from("/should/not/appear"))); + } + + #[gpui::test] + async fn test_multiple_archived_worktrees_per_thread(cx: &mut TestAppContext) { + init_test(cx); + let store = cx.update(|cx| ThreadMetadataStore::global(cx)); + + let id1 = store + .read_with(cx, |store, cx| { + store.create_archived_worktree( + "/projects/worktree-a", + "/home/user/repo", + Some("branch-a"), + "staged_a", + "unstaged_a", + cx, + ) + }) + .await + .unwrap(); + + let id2 = store + .read_with(cx, |store, cx| { + store.create_archived_worktree( + "/projects/worktree-b", + "/home/user/repo", + Some("branch-b"), + "staged_b", + "unstaged_b", + cx, + ) + }) + .await + .unwrap(); + + store + .read_with(cx, |store, cx| { + store.link_thread_to_archived_worktree("session-1", id1, cx) + }) + .await + .unwrap(); + + store + .read_with(cx, |store, cx| { + store.link_thread_to_archived_worktree("session-1", id2, cx) + }) + .await + .unwrap(); + + let worktrees = store + .read_with(cx, |store, cx| { + store.get_archived_worktrees_for_thread("session-1", cx) + }) + .await + .unwrap(); + + assert_eq!(worktrees.len(), 2); + + let wt_a = worktrees + .iter() + .find(|w| w.worktree_path.as_path() == Path::new("/projects/worktree-a")) + .unwrap(); + assert_eq!(wt_a.staged_commit_hash, "staged_a"); + assert_eq!(wt_a.unstaged_commit_hash, "unstaged_a"); + assert_eq!(wt_a.branch_name.as_deref(), Some("branch-a")); + + let wt_b = worktrees + .iter() + .find(|w| w.worktree_path.as_path() == Path::new("/projects/worktree-b")) + .unwrap(); + assert_eq!(wt_b.staged_commit_hash, "staged_b"); + assert_eq!(wt_b.unstaged_commit_hash, "unstaged_b"); + assert_eq!(wt_b.branch_name.as_deref(), Some("branch-b")); + } } diff --git a/crates/agent_ui/src/thread_worktree_archive.rs b/crates/agent_ui/src/thread_worktree_archive.rs new file mode 100644 index 0000000000000000000000000000000000000000..ca1902ee461c2fa5bfd6923051fe5a2de40c0e5f --- /dev/null +++ b/crates/agent_ui/src/thread_worktree_archive.rs @@ -0,0 +1,1167 @@ +use std::{ + collections::HashSet, + path::{Path, PathBuf}, + sync::Arc, +}; + +use agent_client_protocol as acp; +use anyhow::{Context as _, Result, anyhow}; +use collections::HashMap; +use git::repository::{AskPassDelegate, CommitOptions, DEFAULT_WORKTREE_DIRECTORY, ResetMode}; +use gpui::{App, AsyncApp, Entity, Global, Task, WindowHandle}; +use parking_lot::Mutex; +use project::{ + LocalProjectFlags, Project, WorktreeId, git_store::Repository, worktrees_directory_for_repo, +}; +use util::ResultExt; +use workspace::{ + AppState, MultiWorkspace, OpenMode, OpenOptions, PathList, Toast, Workspace, + notifications::NotificationId, open_new, open_paths, +}; + +use crate::thread_metadata_store::{ArchivedGitWorktree, 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, + worktree_repo: Option>, + branch_name: Option, +} + +#[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 { + folder_paths: PathList, + roots: Vec, + current_workspace: Option>, + current_workspace_will_be_empty: bool, + fallback: Option, + affected_workspaces: Vec>, +} + +fn archived_worktree_ref_name(id: i64) -> String { + format!("refs/archived-worktrees/{}", id) +} + +struct PersistOutcome { + archived_worktree_id: i64, + staged_commit_hash: String, +} + +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 { + folder_paths: metadata.folder_paths, + 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 { + folder_paths: metadata.folder_paths, + 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, worktree_repo) = 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, repo)) + })?; + + let branch_name = linked_snapshot + .branch + .as_ref() + .map(|b| b.name().to_string()); + + Some(RootPlan { + root_path: path, + main_repo_path: linked_snapshot.original_repo_abs_path.to_path_buf(), + affected_projects, + worktree_repo: Some(worktree_repo), + branch_name, + }) +} + +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(); + let mut persist_errors: Vec<(PathBuf, anyhow::Error)> = Vec::new(); + let mut persist_outcomes: HashMap = HashMap::default(); + + for root in &roots_to_delete { + if root.worktree_repo.is_some() { + match persist_worktree_state(root, &plan, cx).await { + Ok(outcome) => { + persist_outcomes.insert(root.root_path.clone(), outcome); + } + Err(error) => { + log::error!( + "Failed to persist worktree state for {}: {error}", + root.root_path.display() + ); + persist_errors.push((root.root_path.clone(), error)); + continue; + } + } + } + + if let Err(error) = remove_root(root.clone(), cx).await { + if let Some(outcome) = persist_outcomes.remove(&root.root_path) { + rollback_persist(&outcome, root, cx).await; + } + git_removal_errors.push((root.root_path.clone(), error)); + } + } + + cleanup_empty_workspaces(&plan.affected_workspaces, cx).await; + + let all_errors: Vec<(PathBuf, anyhow::Error)> = persist_errors + .into_iter() + .chain(git_removal_errors) + .collect(); + + if !all_errors.is_empty() { + let detail = all_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) = find_or_create_repository(&root.main_repo_path, 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 +} + +/// Finds a live `Repository` entity for the given path, or creates a temporary +/// `Project::local` to obtain one. +/// +/// `Repository` entities can only be obtained through a `Project` because +/// `GitStore` (which creates and manages `Repository` entities) is owned by +/// `Project`. When no open workspace contains the repo we need, we spin up a +/// headless `Project::local` just to get a `Repository` handle. The caller +/// keeps the returned `Option>` alive for the duration of the +/// git operations, then drops it. +/// +/// Future improvement: decoupling `GitStore` from `Project` so that +/// `Repository` entities can be created standalone would eliminate this +/// temporary-project workaround. +async fn find_or_create_repository( + repo_path: &Path, + cx: &mut AsyncApp, +) -> Result<(Entity, Option>)> { + let repo_path_owned = repo_path.to_path_buf(); + 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() + == repo_path_owned.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 repo_path_for_worktree = repo_path.to_path_buf(); + let create_worktree = temp_project.update(cx, |project, cx| { + project.create_worktree(repo_path_for_worktree, 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_path_for_find = repo_path.to_path_buf(); + let repo = temp_project + .update(cx, |project, cx| { + project + .repositories(cx) + .values() + .find(|repo| { + repo.read(cx).snapshot().work_directory_abs_path.as_ref() + == repo_path_for_find.as_path() + }) + .cloned() + }) + .context("failed to resolve temporary 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 persist_worktree_state( + root: &RootPlan, + plan: &CleanupPlan, + cx: &mut AsyncApp, +) -> Result { + let worktree_repo = root + .worktree_repo + .clone() + .context("no worktree repo entity for persistence")?; + + // Step 1: Create WIP commit #1 (staged state) + let askpass = AskPassDelegate::new(cx, |_, _, _| {}); + let commit_rx = worktree_repo.update(cx, |repo, cx| { + repo.commit( + "WIP staged".into(), + None, + CommitOptions { + allow_empty: true, + ..Default::default() + }, + askpass, + cx, + ) + }); + commit_rx + .await + .map_err(|_| anyhow!("WIP staged commit canceled"))??; + + // Read SHA after staged commit + let staged_sha_result = worktree_repo + .update(cx, |repo, _cx| repo.head_sha()) + .await + .map_err(|_| anyhow!("head_sha canceled")) + .and_then(|r| r.context("failed to read HEAD SHA after staged commit")) + .and_then(|opt| opt.context("HEAD SHA is None after staged commit")); + let staged_commit_hash = match staged_sha_result { + Ok(sha) => sha, + Err(error) => { + let rx = worktree_repo.update(cx, |repo, cx| { + repo.reset("HEAD~1".to_string(), ResetMode::Mixed, cx) + }); + let _ = rx.await; + return Err(error); + } + }; + + // Step 2: Stage all files including untracked + let stage_rx = worktree_repo.update(cx, |repo, _cx| repo.stage_all_including_untracked()); + if let Err(error) = stage_rx + .await + .map_err(|_| anyhow!("stage all canceled")) + .and_then(|inner| inner) + { + let rx = worktree_repo.update(cx, |repo, cx| { + repo.reset("HEAD~1".to_string(), ResetMode::Mixed, cx) + }); + let _ = rx.await; + return Err(error.context("failed to stage all files including untracked")); + } + + // Step 3: Create WIP commit #2 (unstaged/untracked state) + let askpass = AskPassDelegate::new(cx, |_, _, _| {}); + let commit_rx = worktree_repo.update(cx, |repo, cx| { + repo.commit( + "WIP unstaged".into(), + None, + CommitOptions { + allow_empty: true, + ..Default::default() + }, + askpass, + cx, + ) + }); + if let Err(error) = commit_rx + .await + .map_err(|_| anyhow!("WIP unstaged commit canceled")) + .and_then(|inner| inner) + { + let rx = worktree_repo.update(cx, |repo, cx| { + repo.reset("HEAD~1".to_string(), ResetMode::Mixed, cx) + }); + let _ = rx.await; + return Err(error); + } + + // Step 4: Read HEAD SHA after WIP commits + let head_sha_result = worktree_repo + .update(cx, |repo, _cx| repo.head_sha()) + .await + .map_err(|_| anyhow!("head_sha canceled")) + .and_then(|r| r.context("failed to read HEAD SHA after WIP commits")) + .and_then(|opt| opt.context("HEAD SHA is None after WIP commits")); + let unstaged_commit_hash = match head_sha_result { + Ok(sha) => sha, + Err(error) => { + let rx = worktree_repo.update(cx, |repo, cx| { + repo.reset(format!("{}~1", staged_commit_hash), ResetMode::Mixed, cx) + }); + let _ = rx.await; + return Err(error); + } + }; + + // Step 5: Create DB record + let store = cx.update(|cx| ThreadMetadataStore::global(cx)); + let worktree_path_str = root.root_path.to_string_lossy().to_string(); + let main_repo_path_str = root.main_repo_path.to_string_lossy().to_string(); + let branch_name = root.branch_name.clone(); + + let db_result = store + .read_with(cx, |store, cx| { + store.create_archived_worktree( + &worktree_path_str, + &main_repo_path_str, + branch_name.as_deref(), + &staged_commit_hash, + &unstaged_commit_hash, + cx, + ) + }) + .await + .context("failed to create archived worktree DB record"); + let archived_worktree_id = match db_result { + Ok(id) => id, + Err(error) => { + let rx = worktree_repo.update(cx, |repo, cx| { + repo.reset(format!("{}~1", staged_commit_hash), ResetMode::Mixed, cx) + }); + let _ = rx.await; + return Err(error); + } + }; + + // Step 6: Link all threads on this worktree to the archived record + let session_ids: Vec = store.read_with(cx, |store, _cx| { + store + .all_session_ids_for_path(&plan.folder_paths) + .cloned() + .collect() + }); + + for session_id in &session_ids { + let link_result = store + .read_with(cx, |store, cx| { + store.link_thread_to_archived_worktree(&session_id.0, archived_worktree_id, cx) + }) + .await; + if let Err(error) = link_result { + if let Err(delete_error) = store + .read_with(cx, |store, cx| { + store.delete_archived_worktree(archived_worktree_id, cx) + }) + .await + { + log::error!( + "Failed to delete archived worktree DB record during link rollback: {delete_error:#}" + ); + } + let rx = worktree_repo.update(cx, |repo, cx| { + repo.reset(format!("{}~1", staged_commit_hash), ResetMode::Mixed, cx) + }); + let _ = rx.await; + return Err(error.context("failed to link thread to archived worktree")); + } + } + + // Step 7: Create git ref on main repo (non-fatal) + let ref_name = archived_worktree_ref_name(archived_worktree_id); + let main_repo_result = find_or_create_repository(&root.main_repo_path, cx).await; + match main_repo_result { + Ok((main_repo, _temp_project)) => { + let rx = main_repo.update(cx, |repo, _cx| { + repo.update_ref(ref_name.clone(), unstaged_commit_hash.clone()) + }); + if let Err(error) = rx + .await + .map_err(|_| anyhow!("update_ref canceled")) + .and_then(|r| r) + { + log::warn!( + "Failed to create ref {} on main repo (non-fatal): {error}", + ref_name + ); + } + } + Err(error) => { + log::warn!( + "Could not find main repo to create ref {} (non-fatal): {error}", + ref_name + ); + } + } + + Ok(PersistOutcome { + archived_worktree_id, + staged_commit_hash, + }) +} + +async fn rollback_persist(outcome: &PersistOutcome, root: &RootPlan, cx: &mut AsyncApp) { + // Undo WIP commits on the worktree repo + if let Some(worktree_repo) = &root.worktree_repo { + let rx = worktree_repo.update(cx, |repo, cx| { + repo.reset( + format!("{}~1", outcome.staged_commit_hash), + ResetMode::Mixed, + cx, + ) + }); + let _ = rx.await; + } + + // Delete the git ref on main repo + if let Ok((main_repo, _temp_project)) = + find_or_create_repository(&root.main_repo_path, cx).await + { + let ref_name = archived_worktree_ref_name(outcome.archived_worktree_id); + let rx = main_repo.update(cx, |repo, _cx| repo.delete_ref(ref_name)); + let _ = rx.await; + } + + // Delete the DB record + let store = cx.update(|cx| ThreadMetadataStore::global(cx)); + if let Err(error) = store + .read_with(cx, |store, cx| { + store.delete_archived_worktree(outcome.archived_worktree_id, cx) + }) + .await + { + log::error!("Failed to delete archived worktree DB record during rollback: {error:#}"); + } +} + +async fn cleanup_empty_workspaces(workspaces: &[Entity], cx: &mut AsyncApp) { + for workspace in workspaces { + let is_empty = match workspace + .downgrade() + .read_with(cx, |workspace, cx| workspace.root_paths(cx).is_empty()) + { + Ok(is_empty) => is_empty, + Err(_) => { + log::debug!("Workspace entity already dropped during cleanup; skipping"); + continue; + } + }; + 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(); + } + }); + } +} + +pub async fn restore_worktree_via_git( + row: &ArchivedGitWorktree, + cx: &mut AsyncApp, +) -> Result { + // Step 1: Find the main repo entity + let (main_repo, _temp_project) = find_or_create_repository(&row.main_repo_path, cx).await?; + + // Step 2: Handle path conflicts + let worktree_path = &row.worktree_path; + let app_state = current_app_state(cx).context("no app state available")?; + let already_exists = app_state.fs.metadata(worktree_path).await?.is_some(); + + let final_path = if already_exists { + let worktree_directory = + worktrees_directory_for_repo(&row.main_repo_path, DEFAULT_WORKTREE_DIRECTORY)?; + let new_name = format!( + "{}-restored-{}", + row.branch_name.as_deref().unwrap_or("worktree"), + row.id + ); + let project_name = row + .main_repo_path + .file_name() + .context("git repo must have a directory name")?; + worktree_directory.join(&new_name).join(project_name) + } else { + worktree_path.clone() + }; + + // Step 3: Create detached worktree + let rx = main_repo.update(cx, |repo, _cx| { + repo.create_worktree_detached(final_path.clone(), row.unstaged_commit_hash.clone()) + }); + rx.await + .map_err(|_| anyhow!("worktree creation was canceled"))? + .context("failed to create worktree")?; + + // Step 4: Get the worktree's repo entity + let (wt_repo, _temp_wt_project) = find_or_create_repository(&final_path, cx).await?; + + // Step 5: Mixed reset to staged commit (undo the "WIP unstaged" commit) + let rx = wt_repo.update(cx, |repo, cx| { + repo.reset(row.staged_commit_hash.clone(), ResetMode::Mixed, cx) + }); + match rx.await { + Ok(Ok(())) => {} + Ok(Err(error)) => { + let _ = wt_repo + .update(cx, |repo, cx| { + repo.reset(row.unstaged_commit_hash.clone(), ResetMode::Mixed, cx) + }) + .await; + return Err(error.context("mixed reset failed while restoring worktree")); + } + Err(_) => { + return Err(anyhow!("mixed reset was canceled")); + } + } + + // Step 6: Soft reset to parent of staged commit (undo the "WIP staged" commit) + let rx = wt_repo.update(cx, |repo, cx| { + repo.reset(format!("{}~1", row.staged_commit_hash), ResetMode::Soft, cx) + }); + match rx.await { + Ok(Ok(())) => {} + Ok(Err(error)) => { + let _ = wt_repo + .update(cx, |repo, cx| { + repo.reset(row.unstaged_commit_hash.clone(), ResetMode::Mixed, cx) + }) + .await; + return Err(error.context("soft reset failed while restoring worktree")); + } + Err(_) => { + return Err(anyhow!("soft reset was canceled")); + } + } + + // Step 7: Restore the branch + if let Some(branch_name) = &row.branch_name { + let rx = wt_repo.update(cx, |repo, _cx| repo.change_branch(branch_name.clone())); + match rx.await { + Ok(Ok(())) => {} + _ => { + let rx = wt_repo.update(cx, |repo, _cx| { + repo.create_branch(branch_name.clone(), None) + }); + if let Ok(Err(_)) | Err(_) = rx.await { + log::warn!( + "Could not switch to branch '{}' — \ + restored worktree is in detached HEAD state.", + branch_name + ); + } + } + } + } + + Ok(final_path) +} + +pub async fn cleanup_archived_worktree_record(row: &ArchivedGitWorktree, cx: &mut AsyncApp) { + // Delete the git ref from the main repo + if let Ok((main_repo, _temp_project)) = find_or_create_repository(&row.main_repo_path, cx).await + { + let ref_name = archived_worktree_ref_name(row.id); + let rx = main_repo.update(cx, |repo, _cx| repo.delete_ref(ref_name)); + match rx.await { + Ok(Ok(())) => {} + Ok(Err(error)) => log::warn!("Failed to delete archive ref: {error}"), + Err(_) => log::warn!("Archive ref deletion was canceled"), + } + } + + // Delete the DB records + let store = cx.update(|cx| ThreadMetadataStore::global(cx)); + store + .read_with(cx, |store, cx| store.delete_archived_worktree(row.id, cx)) + .await + .log_err(); +} + +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/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs index a00061452e4dbd2051b961fdde9e33dc05fba0b1..ed62c21e1e70ca71dc0eb4cdeb90519ff0a77bda 100644 --- a/crates/fs/src/fake_git_repo.rs +++ b/crates/fs/src/fake_git_repo.rs @@ -35,8 +35,16 @@ pub struct FakeGitRepository { pub(crate) is_trusted: Arc, } +#[derive(Debug, Clone)] +pub struct FakeCommitSnapshot { + pub head_contents: HashMap, + pub index_contents: HashMap, + pub sha: String, +} + #[derive(Debug, Clone)] pub struct FakeGitRepositoryState { + pub commit_history: Vec, pub event_emitter: smol::channel::Sender, pub unmerged_paths: HashMap, pub head_contents: HashMap, @@ -72,6 +80,7 @@ impl FakeGitRepositoryState { oids: Default::default(), remotes: HashMap::default(), graph_commits: Vec::new(), + commit_history: Vec::new(), } } } @@ -214,11 +223,52 @@ impl GitRepository for FakeGitRepository { fn reset( &self, - _commit: String, - _mode: ResetMode, + commit: String, + mode: ResetMode, _env: Arc>, ) -> BoxFuture<'_, Result<()>> { - unimplemented!() + self.with_state_async(true, move |state| { + let pop_count = if commit == "HEAD~" { + 1 + } else if let Some(suffix) = commit.strip_prefix("HEAD~") { + suffix + .parse::() + .with_context(|| format!("Invalid HEAD~ offset: {commit}"))? + } else { + match state + .commit_history + .iter() + .rposition(|entry| entry.sha == commit) + { + Some(index) => state.commit_history.len() - index, + None => anyhow::bail!("Unknown commit ref: {commit}"), + } + }; + + if pop_count == 0 || pop_count > state.commit_history.len() { + anyhow::bail!( + "Cannot reset {pop_count} commit(s): only {} in history", + state.commit_history.len() + ); + } + + let target_index = state.commit_history.len() - pop_count; + let snapshot = state.commit_history[target_index].clone(); + state.commit_history.truncate(target_index); + + match mode { + ResetMode::Soft => { + state.head_contents = snapshot.head_contents; + } + ResetMode::Mixed => { + state.head_contents = snapshot.head_contents; + state.index_contents = state.head_contents.clone(); + } + } + + state.refs.insert("HEAD".into(), snapshot.sha); + Ok(()) + }) } fn checkout_files( @@ -483,7 +533,7 @@ impl GitRepository for FakeGitRepository { fn create_worktree( &self, - branch_name: String, + branch_name: Option, path: PathBuf, from_commit: Option, ) -> BoxFuture<'_, Result<()>> { @@ -498,8 +548,10 @@ impl GitRepository for FakeGitRepository { if let Some(message) = &state.simulated_create_worktree_error { anyhow::bail!("{message}"); } - if state.branches.contains(&branch_name) { - bail!("a branch named '{}' already exists", branch_name); + if let Some(ref name) = branch_name { + if state.branches.contains(name) { + bail!("a branch named '{}' already exists", name); + } } Ok(()) })??; @@ -508,13 +560,22 @@ impl GitRepository for FakeGitRepository { fs.create_dir(&path).await?; // Create .git/worktrees// directory with HEAD, commondir, gitdir. - let ref_name = format!("refs/heads/{branch_name}"); - let worktrees_entry_dir = common_dir_path.join("worktrees").join(&branch_name); + let worktree_entry_name = branch_name + .as_deref() + .unwrap_or_else(|| path.file_name().unwrap().to_str().unwrap()); + let worktrees_entry_dir = common_dir_path.join("worktrees").join(worktree_entry_name); fs.create_dir(&worktrees_entry_dir).await?; + let sha = from_commit.unwrap_or_else(|| "fake-sha".to_string()); + let head_content = if let Some(ref branch_name) = branch_name { + let ref_name = format!("refs/heads/{branch_name}"); + format!("ref: {ref_name}") + } else { + sha.clone() + }; fs.write_file_internal( worktrees_entry_dir.join("HEAD"), - format!("ref: {ref_name}").into_bytes(), + head_content.into_bytes(), false, )?; fs.write_file_internal( @@ -537,10 +598,14 @@ impl GitRepository for FakeGitRepository { )?; // Update git state: add ref and branch. - let sha = from_commit.unwrap_or_else(|| "fake-sha".to_string()); fs.with_git_state(&dot_git_path, true, move |state| { - state.refs.insert(ref_name, sha); - state.branches.insert(branch_name); + if let Some(branch_name) = branch_name { + let ref_name = format!("refs/heads/{branch_name}"); + state.refs.insert(ref_name, sha); + state.branches.insert(branch_name); + } else { + state.refs.insert("HEAD".into(), sha); + } Ok::<(), anyhow::Error>(()) })??; Ok(()) @@ -815,11 +880,30 @@ impl GitRepository for FakeGitRepository { &self, _message: gpui::SharedString, _name_and_email: Option<(gpui::SharedString, gpui::SharedString)>, - _options: CommitOptions, + options: CommitOptions, _askpass: AskPassDelegate, _env: Arc>, ) -> BoxFuture<'_, Result<()>> { - async { Ok(()) }.boxed() + self.with_state_async(true, move |state| { + if !options.allow_empty && !options.amend && state.index_contents == state.head_contents + { + anyhow::bail!("nothing to commit (use allow_empty to create an empty commit)"); + } + + let old_sha = state.refs.get("HEAD").cloned().unwrap_or_default(); + state.commit_history.push(FakeCommitSnapshot { + head_contents: state.head_contents.clone(), + index_contents: state.index_contents.clone(), + sha: old_sha, + }); + + state.head_contents = state.index_contents.clone(); + + let new_sha = format!("fake-commit-{}", state.commit_history.len()); + state.refs.insert("HEAD".into(), new_sha); + + Ok(()) + }) } fn run_hook( @@ -1203,6 +1287,55 @@ impl GitRepository for FakeGitRepository { anyhow::bail!("commit_data_reader not supported for FakeGitRepository") } + fn update_ref(&self, ref_name: String, commit: String) -> BoxFuture<'_, Result<()>> { + self.with_state_async(true, move |state| { + state.refs.insert(ref_name, commit); + Ok(()) + }) + } + + fn delete_ref(&self, ref_name: String) -> BoxFuture<'_, Result<()>> { + self.with_state_async(true, move |state| { + state.refs.remove(&ref_name); + Ok(()) + }) + } + + fn stage_all_including_untracked(&self) -> BoxFuture<'_, Result<()>> { + let workdir_path = self.dot_git_path.parent().unwrap(); + let git_files: Vec<(RepoPath, String)> = self + .fs + .files() + .iter() + .filter_map(|path| { + let repo_path = path.strip_prefix(workdir_path).ok()?; + if repo_path.starts_with(".git") { + return None; + } + let content = self + .fs + .read_file_sync(path) + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok())?; + let rel_path = RelPath::new(repo_path, PathStyle::local()).ok()?; + Some((RepoPath::from_rel_path(&rel_path), content)) + }) + .collect(); + + self.with_state_async(true, move |state| { + // Stage all filesystem contents, mirroring `git add -A`. + let fs_paths: HashSet = git_files.iter().map(|(p, _)| p.clone()).collect(); + for (path, content) in git_files { + state.index_contents.insert(path, content); + } + // Remove index entries for files that no longer exist on disk. + state + .index_contents + .retain(|path, _| fs_paths.contains(path)); + Ok(()) + }) + } + fn set_trusted(&self, trusted: bool) { self.is_trusted .store(trusted, std::sync::atomic::Ordering::Release); diff --git a/crates/fs/tests/integration/fake_git_repo.rs b/crates/fs/tests/integration/fake_git_repo.rs index 6428083c161235001ef29daf3583520e7f7d25a2..f4192a22bb42f88f8769ef59f817b2bf2a288fb9 100644 --- a/crates/fs/tests/integration/fake_git_repo.rs +++ b/crates/fs/tests/integration/fake_git_repo.rs @@ -24,7 +24,7 @@ async fn test_fake_worktree_lifecycle(cx: &mut TestAppContext) { // Create a worktree let worktree_1_dir = worktrees_dir.join("feature-branch"); repo.create_worktree( - "feature-branch".to_string(), + Some("feature-branch".to_string()), worktree_1_dir.clone(), Some("abc123".to_string()), ) @@ -47,9 +47,13 @@ async fn test_fake_worktree_lifecycle(cx: &mut TestAppContext) { // Create a second worktree (without explicit commit) let worktree_2_dir = worktrees_dir.join("bugfix-branch"); - repo.create_worktree("bugfix-branch".to_string(), worktree_2_dir.clone(), None) - .await - .unwrap(); + repo.create_worktree( + Some("bugfix-branch".to_string()), + worktree_2_dir.clone(), + None, + ) + .await + .unwrap(); let worktrees = repo.worktrees().await.unwrap(); assert_eq!(worktrees.len(), 3); diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index b03fe1b0c63904bfc751ab7946f92a7c8595db00..48648809e5dee26f5e678ef8942c4932318ec314 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -329,6 +329,7 @@ impl Upstream { pub struct CommitOptions { pub amend: bool, pub signoff: bool, + pub allow_empty: bool, } #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] @@ -715,7 +716,7 @@ pub trait GitRepository: Send + Sync { fn create_worktree( &self, - branch_name: String, + branch_name: Option, path: PathBuf, from_commit: Option, ) -> BoxFuture<'_, Result<()>>; @@ -916,6 +917,12 @@ pub trait GitRepository: Send + Sync { fn commit_data_reader(&self) -> Result; + fn update_ref(&self, ref_name: String, commit: String) -> BoxFuture<'_, Result<()>>; + + fn delete_ref(&self, ref_name: String) -> BoxFuture<'_, Result<()>>; + + fn stage_all_including_untracked(&self) -> BoxFuture<'_, Result<()>>; + fn set_trusted(&self, trusted: bool); fn is_trusted(&self) -> bool; } @@ -1660,19 +1667,20 @@ impl GitRepository for RealGitRepository { fn create_worktree( &self, - branch_name: String, + branch_name: Option, path: PathBuf, from_commit: Option, ) -> BoxFuture<'_, Result<()>> { let git_binary = self.git_binary(); - let mut args = vec![ - OsString::from("worktree"), - OsString::from("add"), - OsString::from("-b"), - OsString::from(branch_name.as_str()), - OsString::from("--"), - OsString::from(path.as_os_str()), - ]; + let mut args = vec![OsString::from("worktree"), OsString::from("add")]; + if let Some(branch_name) = &branch_name { + args.push(OsString::from("-b")); + args.push(OsString::from(branch_name.as_str())); + } else { + args.push(OsString::from("--detach")); + } + args.push(OsString::from("--")); + args.push(OsString::from(path.as_os_str())); if let Some(from_commit) = from_commit { args.push(OsString::from(from_commit)); } else { @@ -2165,6 +2173,10 @@ impl GitRepository for RealGitRepository { cmd.arg("--signoff"); } + if options.allow_empty { + cmd.arg("--allow-empty"); + } + if let Some((name, email)) = name_and_email { cmd.arg("--author").arg(&format!("{name} <{email}>")); } @@ -2176,6 +2188,50 @@ impl GitRepository for RealGitRepository { .boxed() } + fn update_ref(&self, ref_name: String, commit: String) -> BoxFuture<'_, Result<()>> { + let git_binary = self.git_binary(); + self.executor + .spawn(async move { + let args: Vec = vec![ + "--no-optional-locks".into(), + "update-ref".into(), + ref_name.into(), + commit.into(), + ]; + git_binary?.run(&args).await?; + Ok(()) + }) + .boxed() + } + + fn delete_ref(&self, ref_name: String) -> BoxFuture<'_, Result<()>> { + let git_binary = self.git_binary(); + self.executor + .spawn(async move { + let args: Vec = vec![ + "--no-optional-locks".into(), + "update-ref".into(), + "-d".into(), + ref_name.into(), + ]; + git_binary?.run(&args).await?; + Ok(()) + }) + .boxed() + } + + fn stage_all_including_untracked(&self) -> BoxFuture<'_, Result<()>> { + let git_binary = self.git_binary(); + self.executor + .spawn(async move { + let args: Vec = + vec!["--no-optional-locks".into(), "add".into(), "-A".into()]; + git_binary?.run(&args).await?; + Ok(()) + }) + .boxed() + } + fn push( &self, branch_name: String, @@ -4009,7 +4065,7 @@ mod tests { // Create a new worktree repo.create_worktree( - "test-branch".to_string(), + Some("test-branch".to_string()), worktree_path.clone(), Some("HEAD".to_string()), ) @@ -4068,7 +4124,7 @@ mod tests { // Create a worktree let worktree_path = worktrees_dir.join("worktree-to-remove"); repo.create_worktree( - "to-remove".to_string(), + Some("to-remove".to_string()), worktree_path.clone(), Some("HEAD".to_string()), ) @@ -4092,7 +4148,7 @@ mod tests { // Create a worktree let worktree_path = worktrees_dir.join("dirty-wt"); repo.create_worktree( - "dirty-wt".to_string(), + Some("dirty-wt".to_string()), worktree_path.clone(), Some("HEAD".to_string()), ) @@ -4162,7 +4218,7 @@ mod tests { // Create a worktree let old_path = worktrees_dir.join("old-worktree-name"); repo.create_worktree( - "old-name".to_string(), + Some("old-name".to_string()), old_path.clone(), Some("HEAD".to_string()), ) diff --git a/crates/git_ui/src/commit_modal.rs b/crates/git_ui/src/commit_modal.rs index 432da803e6eedfec304836198f6111f5418084cc..2088ad77ec5d7e71bdfb42ebcbfab6d001f64375 100644 --- a/crates/git_ui/src/commit_modal.rs +++ b/crates/git_ui/src/commit_modal.rs @@ -453,6 +453,7 @@ impl CommitModal { CommitOptions { amend: is_amend_pending, signoff: is_signoff_enabled, + allow_empty: false, }, window, cx, diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 5b40c4bffc3a492f0113a8c5e45b2cfc1763d380..123f04a442597db66ae00963453d009b0ee8518c 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -2155,6 +2155,7 @@ impl GitPanel { CommitOptions { amend: false, signoff: self.signoff_enabled, + allow_empty: false, }, window, cx, @@ -2195,6 +2196,7 @@ impl GitPanel { CommitOptions { amend: true, signoff: self.signoff_enabled, + allow_empty: false, }, window, cx, @@ -4454,7 +4456,11 @@ impl GitPanel { git_panel .update(cx, |git_panel, cx| { git_panel.commit_changes( - CommitOptions { amend, signoff }, + CommitOptions { + amend, + signoff, + allow_empty: false, + }, window, cx, ); diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 20e04a19a7891c5b8800b270a1c8d55720ce90ff..fa06e8e8fd9888b80582b816632a3a28d7853e77 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -2338,6 +2338,7 @@ impl GitStore { CommitOptions { amend: options.amend, signoff: options.signoff, + allow_empty: options.allow_empty, }, askpass, cx, @@ -5484,6 +5485,7 @@ impl Repository { options: Some(proto::commit::CommitOptions { amend: options.amend, signoff: options.signoff, + allow_empty: options.allow_empty, }), askpass_id, }) @@ -5977,7 +5979,9 @@ impl Repository { move |repo, _cx| async move { match repo { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { - backend.create_worktree(branch_name, path, commit).await + backend + .create_worktree(Some(branch_name), path, commit) + .await } RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { client @@ -5997,6 +6001,86 @@ impl Repository { ) } + pub fn create_worktree_detached( + &mut self, + path: PathBuf, + commit: String, + ) -> oneshot::Receiver> { + self.send_job( + Some("git worktree add (detached)".into()), + move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.create_worktree(None, path, Some(commit)).await + } + RepositoryState::Remote(_) => { + anyhow::bail!( + "create_worktree_detached is not supported for remote repositories" + ) + } + } + }, + ) + } + + pub fn head_sha(&mut self) -> oneshot::Receiver>> { + self.send_job(None, move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + Ok(backend.head_sha().await) + } + RepositoryState::Remote(_) => { + anyhow::bail!("head_sha is not supported for remote repositories") + } + } + }) + } + + pub fn update_ref( + &mut self, + ref_name: String, + commit: String, + ) -> oneshot::Receiver> { + self.send_job(None, move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.update_ref(ref_name, commit).await + } + RepositoryState::Remote(_) => { + anyhow::bail!("update_ref is not supported for remote repositories") + } + } + }) + } + + pub fn delete_ref(&mut self, ref_name: String) -> oneshot::Receiver> { + self.send_job(None, move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.delete_ref(ref_name).await + } + RepositoryState::Remote(_) => { + anyhow::bail!("delete_ref is not supported for remote repositories") + } + } + }) + } + + pub fn stage_all_including_untracked(&mut self) -> oneshot::Receiver> { + self.send_job(None, move |repo, _cx| async move { + match repo { + RepositoryState::Local(LocalRepositoryState { backend, .. }) => { + backend.stage_all_including_untracked().await + } + RepositoryState::Remote(_) => { + anyhow::bail!( + "stage_all_including_untracked is not supported for remote repositories" + ) + } + } + }) + } + pub fn remove_worktree(&mut self, path: PathBuf, force: bool) -> oneshot::Receiver> { let id = self.id; self.send_job( diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 0ec3366ca8f9f6c6e4e3cbd411e1894de4d0f2b8..662d6b29fbda5fb03c72e8abb54cb20244e43015 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); @@ -6089,7 +6116,6 @@ impl ProjectGroupKey { self.host.clone() } } - pub struct PathMatchCandidateSet { pub snapshot: Snapshot, pub include_ignored: bool, diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index 0cbb635d78dddc81aa7c75340f2fbebe83a474e3..a8df92d05a740f4b66db720dc80fd2d92689e0ed 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -403,6 +403,7 @@ message Commit { message CommitOptions { bool amend = 1; bool signoff = 2; + bool allow_empty = 3; } } diff --git a/crates/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml index d76fd139557dd10438d7cf98f9168d87dcae9804..25dd8e1d2835ced3d9071f14dbadcec1133f09a9 100644 --- a/crates/sidebar/Cargo.toml +++ b/crates/sidebar/Cargo.toml @@ -28,6 +28,7 @@ feature_flags.workspace = true fs.workspace = true git.workspace = true gpui.workspace = true +log.workspace = true menu.workspace = true platform_title_bar.workspace = true project.workspace = true diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 53ae57d1a7c55f66e40e1d704859d689d41045e4..e585b7aca6f8164743802ed3a9cfe59d8d10ee45 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -5,6 +5,7 @@ use action_log::DiffStats; use agent_client_protocol::{self as acp}; use agent_settings::AgentSettings; use agent_ui::thread_metadata_store::{ThreadMetadata, ThreadMetadataStore}; +use agent_ui::thread_worktree_archive; use agent_ui::threads_archive_view::{ ThreadsArchiveView, ThreadsArchiveViewEvent, format_history_entry_timestamp, }; @@ -16,8 +17,8 @@ use chrono::{DateTime, Utc}; use editor::Editor; use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _}; use gpui::{ - Action as _, AnyElement, App, Context, Entity, FocusHandle, Focusable, KeyContext, ListState, - Pixels, Render, SharedString, WeakEntity, Window, WindowHandle, linear_color_stop, + Action as _, AnyElement, App, ClickEvent, Context, Entity, FocusHandle, Focusable, KeyContext, + ListState, Pixels, Render, SharedString, WeakEntity, Window, WindowHandle, linear_color_stop, linear_gradient, list, prelude::*, px, }; use menu::{ @@ -34,6 +35,7 @@ use serde::{Deserialize, Serialize}; use settings::Settings as _; use std::collections::{HashMap, HashSet}; use std::mem; +use std::path::PathBuf; use std::rc::Rc; use theme::ActiveTheme; use ui::{ @@ -45,8 +47,8 @@ use util::ResultExt as _; use util::path_list::{PathList, SerializedPathList}; use workspace::{ AddFolderToProject, CloseWindow, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent, - Open, Sidebar as WorkspaceSidebar, SidebarSide, ToggleWorkspaceSidebar, Workspace, WorkspaceId, - sidebar_side_context_menu, + Open, Sidebar as WorkspaceSidebar, SidebarSide, Toast, ToggleWorkspaceSidebar, Workspace, + WorkspaceId, notifications::NotificationId, sidebar_side_context_menu, }; use zed_actions::OpenRecent; @@ -2182,33 +2184,127 @@ impl Sidebar { window: &mut Window, cx: &mut Context, ) { - ThreadMetadataStore::global(cx) - .update(cx, |store, cx| store.unarchive(&metadata.session_id, cx)); + let session_id = metadata.session_id.clone(); - if !metadata.folder_paths.paths().is_empty() { - let path_list = metadata.folder_paths.clone(); - if let Some(workspace) = self.find_current_workspace_for_path_list(&path_list, cx) { + 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() + }); + + if let Some(workspace) = active_workspace { self.activate_thread_locally(&metadata, &workspace, window, cx); - } else if let Some((target_window, workspace)) = - self.find_open_workspace_for_path_list(&path_list, cx) - { - self.activate_thread_in_other_window(metadata, workspace, target_window, cx); - } else { - self.open_workspace_and_activate_thread(metadata, path_list, window, cx); } return; } - let active_workspace = self.multi_workspace.upgrade().and_then(|w| { - w.read(cx) - .workspaces() - .get(w.read(cx).active_workspace_index()) - .cloned() - }); + let store = ThreadMetadataStore::global(cx); + let task = store + .read(cx) + .get_archived_worktrees_for_thread(&session_id.0, cx); + let path_list = metadata.folder_paths.clone(); - if let Some(workspace) = active_workspace { - self.activate_thread_locally(&metadata, &workspace, window, cx); - } + cx.spawn_in(window, async move |this, cx| { + let archived_worktrees = task.await?; + + if archived_worktrees.is_empty() { + this.update_in(cx, |this, window, cx| { + if let Some(workspace) = + this.find_current_workspace_for_path_list(&path_list, cx) + { + this.activate_thread_locally(&metadata, &workspace, window, cx); + } else if let Some((target_window, workspace)) = + this.find_open_workspace_for_path_list(&path_list, cx) + { + this.activate_thread_in_other_window( + metadata, + workspace, + target_window, + cx, + ); + } else { + this.open_workspace_and_activate_thread(metadata, path_list, window, cx); + } + })?; + return anyhow::Ok(()); + } + + let first = &archived_worktrees[0]; + let main_repo_path = first.main_repo_path.clone(); + + cx.update(|_window, cx| { + store.update(cx, |store, cx| { + store.set_pending_worktree_restore(&session_id, Some(main_repo_path), cx); + }); + })?; + + let mut path_replacements: Vec<(PathBuf, PathBuf)> = Vec::new(); + for row in &archived_worktrees { + match thread_worktree_archive::restore_worktree_via_git(row, &mut *cx).await { + Ok(restored_path) => { + thread_worktree_archive::cleanup_archived_worktree_record(row, &mut *cx) + .await; + path_replacements.push((row.worktree_path.clone(), restored_path)); + } + Err(error) => { + log::error!("Failed to restore worktree: {error:#}"); + cx.update(|_window, cx| { + store.update(cx, |store, cx| { + store.set_pending_worktree_restore(&session_id, None, cx); + }); + })?; + this.update_in(cx, |this, _window, cx| { + if let Some(multi_workspace) = this.multi_workspace.upgrade() { + let workspace = multi_workspace.read(cx).workspace().clone(); + workspace.update(cx, |workspace, cx| { + struct RestoreWorktreeErrorToast; + workspace.show_toast( + Toast::new( + NotificationId::unique::(), + format!("Failed to restore worktree: {error:#}"), + ) + .autohide(), + cx, + ); + }); + } + }) + .ok(); + return anyhow::Ok(()); + } + } + } + + if !path_replacements.is_empty() { + cx.update(|_window, cx| { + store.update(cx, |store, cx| { + store.complete_worktree_restore(&session_id, &path_replacements, cx); + }); + })?; + + let updated_metadata = + cx.update(|_window, cx| store.read(cx).entry(&session_id).cloned())?; + + if let Some(updated_metadata) = updated_metadata { + let new_paths = updated_metadata.folder_paths.clone(); + this.update_in(cx, |this, window, cx| { + this.open_workspace_and_activate_thread( + updated_metadata, + new_paths, + window, + cx, + ); + })?; + } + } + + anyhow::Ok(()) + }) + .detach_and_log_err(cx); } fn expand_selected_entry( @@ -2357,7 +2453,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_worktree_archive::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 @@ -2821,6 +2927,18 @@ impl Sidebar { }) .collect(), ) + .pending_worktree_restore(thread.metadata.pending_worktree_restore.is_some()) + .when(thread.metadata.pending_worktree_restore.is_some(), |this| { + let session_id = thread.metadata.session_id.clone(); + this.on_cancel_restore(cx.listener( + move |_this, _event: &ClickEvent, _window, cx| { + let store = ThreadMetadataStore::global(cx); + store.update(cx, |store, cx| { + store.set_pending_worktree_restore(&session_id, None, cx); + }); + }, + )) + }) .timestamp(timestamp) .highlight_positions(thread.highlight_positions.to_vec()) .title_generating(thread.is_title_generating) diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index cf1ee8a0f524d9d94edf83c24ecea900f3261fb8..b50ebe097d6202d4fe3e10cca85e4d1eee326575 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -166,6 +166,7 @@ fn save_thread_metadata( folder_paths, main_worktree_paths, archived: false, + pending_worktree_restore: None, }; ThreadMetadataStore::global(cx).update(cx, |store, cx| store.save_manually(metadata, cx)); }); @@ -736,6 +737,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { updated_at: Utc::now(), created_at: Some(Utc::now()), archived: false, + pending_worktree_restore: None, }, icon: IconName::ZedAgent, icon_from_external_svg: None, @@ -759,6 +761,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { updated_at: Utc::now(), created_at: Some(Utc::now()), archived: false, + pending_worktree_restore: None, }, icon: IconName::ZedAgent, icon_from_external_svg: None, @@ -782,6 +785,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { updated_at: Utc::now(), created_at: Some(Utc::now()), archived: false, + pending_worktree_restore: None, }, icon: IconName::ZedAgent, icon_from_external_svg: None, @@ -805,6 +809,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { updated_at: Utc::now(), created_at: Some(Utc::now()), archived: false, + pending_worktree_restore: None, }, icon: IconName::ZedAgent, icon_from_external_svg: None, @@ -828,6 +833,7 @@ async fn test_visible_entries_as_strings(cx: &mut TestAppContext) { updated_at: Utc::now(), created_at: Some(Utc::now()), archived: false, + pending_worktree_restore: None, }, icon: IconName::ZedAgent, icon_from_external_svg: None, @@ -2059,6 +2065,7 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) { folder_paths: PathList::default(), main_worktree_paths: PathList::default(), archived: false, + pending_worktree_restore: None, }, &workspace_a, window, @@ -2114,6 +2121,7 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) { folder_paths: PathList::default(), main_worktree_paths: PathList::default(), archived: false, + pending_worktree_restore: None, }, &workspace_b, window, @@ -3554,6 +3562,7 @@ async fn test_activate_archived_thread_with_saved_paths_activates_matching_works folder_paths: PathList::new(&[PathBuf::from("/project-b")]), main_worktree_paths: PathList::default(), archived: false, + pending_worktree_restore: None, }, window, cx, @@ -3617,6 +3626,7 @@ async fn test_activate_archived_thread_cwd_fallback_with_matching_workspace( folder_paths: PathList::new(&[std::path::PathBuf::from("/project-b")]), main_worktree_paths: PathList::default(), archived: false, + pending_worktree_restore: None, }, window, cx, @@ -3680,6 +3690,7 @@ async fn test_activate_archived_thread_no_paths_no_cwd_uses_active_workspace( folder_paths: PathList::default(), main_worktree_paths: PathList::default(), archived: false, + pending_worktree_restore: None, }, window, cx, @@ -3735,6 +3746,7 @@ async fn test_activate_archived_thread_saved_paths_opens_new_workspace(cx: &mut folder_paths: path_list_b, main_worktree_paths: PathList::default(), archived: false, + pending_worktree_restore: None, }, window, cx, @@ -3785,6 +3797,7 @@ async fn test_activate_archived_thread_reuses_workspace_in_another_window(cx: &m folder_paths: PathList::new(&[PathBuf::from("/project-b")]), main_worktree_paths: PathList::default(), archived: false, + pending_worktree_restore: None, }, window, cx, @@ -3862,6 +3875,7 @@ async fn test_activate_archived_thread_reuses_workspace_in_another_window_with_t folder_paths: PathList::new(&[PathBuf::from("/project-b")]), main_worktree_paths: PathList::default(), archived: false, + pending_worktree_restore: None, }, window, cx, @@ -3938,6 +3952,7 @@ async fn test_activate_archived_thread_prefers_current_window_for_matching_paths folder_paths: PathList::new(&[PathBuf::from("/project-a")]), main_worktree_paths: PathList::default(), archived: false, + pending_worktree_restore: None, }, window, cx, @@ -4925,6 +4940,7 @@ mod property_test { folder_paths: path_list, main_worktree_paths, archived: false, + pending_worktree_restore: None, }; cx.update(|_, cx| { ThreadMetadataStore::global(cx) diff --git a/crates/ui/src/components/ai/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs index 7658946b6395d6314d90db52716020a922c85ccc..a68da04059312b616f7bae14f2b5183db9713443 100644 --- a/crates/ui/src/components/ai/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -46,6 +46,8 @@ pub struct ThreadItem { project_paths: Option>, project_name: Option, worktrees: Vec, + pending_worktree_restore: bool, + on_cancel_restore: Option>, on_click: Option>, on_hover: Box, action_slot: Option, @@ -78,6 +80,8 @@ impl ThreadItem { project_paths: None, project_name: None, worktrees: Vec::new(), + pending_worktree_restore: false, + on_cancel_restore: None, on_click: None, on_hover: Box::new(|_, _, _| {}), action_slot: None, @@ -171,6 +175,19 @@ impl ThreadItem { self } + pub fn pending_worktree_restore(mut self, pending: bool) -> Self { + self.pending_worktree_restore = pending; + self + } + + pub fn on_cancel_restore( + mut self, + handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_cancel_restore = Some(Box::new(handler)); + self + } + pub fn hovered(mut self, hovered: bool) -> Self { self.hovered = hovered; self @@ -211,7 +228,7 @@ impl ThreadItem { } impl RenderOnce for ThreadItem { - fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + fn render(mut self, _: &mut Window, cx: &mut App) -> impl IntoElement { let color = cx.theme().colors(); let sidebar_base_bg = color .title_bar_background @@ -359,7 +376,7 @@ impl RenderOnce for ThreadItem { let has_project_name = self.project_name.is_some(); let has_project_paths = project_paths.is_some(); - let has_worktree = !self.worktrees.is_empty(); + let has_worktree = !self.worktrees.is_empty() || self.pending_worktree_restore; let has_timestamp = !self.timestamp.is_empty(); let timestamp = self.timestamp; @@ -488,6 +505,44 @@ impl RenderOnce for ThreadItem { ); } + if self.pending_worktree_restore { + let on_cancel = self.on_cancel_restore.take(); + let restore_element = h_flex() + .id(format!("{}-worktree-restore", self.id.clone())) + .gap_1() + .child( + Icon::new(IconName::LoadCircle) + .size(IconSize::XSmall) + .color(Color::Muted) + .with_rotate_animation(2), + ) + .child( + Label::new("Restoring worktree\u{2026}") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .when_some(on_cancel, |this, on_cancel| { + this.child( + IconButton::new( + format!("{}-cancel-restore", self.id.clone()), + IconName::Close, + ) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Cancel Restore")) + .on_click( + move |event, window, cx| { + cx.stop_propagation(); + on_cancel(event, window, cx); + }, + ), + ) + }) + .tooltip(Tooltip::text("Restoring the Git worktree for this thread")) + .into_any_element(); + worktree_labels.push(restore_element); + } + this.child( h_flex() .min_w_0() diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs index cb2640142442b458b60759547c783d87d9de8a10..fb06240a91a95845c074a37b598674b0c6306f00 100644 --- a/crates/workspace/src/multi_workspace.rs +++ b/crates/workspace/src/multi_workspace.rs @@ -309,6 +309,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 1bf0d2bc4a09a2c6417ce2b35e46372d274c6161..3e4a3582d92b352a1904e40e887b0ef6745e998a 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -3300,6 +3300,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, @@ -8715,6 +8723,22 @@ pub async fn restore_multiworkspace( .ok(); } + if !state.project_group_keys.is_empty() { + window_handle + .update(cx, |multi_workspace, _window, _cx| { + for serialized_key in &state.project_group_keys { + let paths = PathList::deserialize(&serialized_key.path_list); + let host = match &serialized_key.location { + SerializedWorkspaceLocation::Local => None, + SerializedWorkspaceLocation::Remote(opts) => Some(opts.clone()), + }; + let key = ProjectGroupKey::new(host, paths); + multi_workspace.add_project_group_key(key); + } + }) + .ok(); + } + if state.sidebar_open { window_handle .update(cx, |multi_workspace, _, cx| {