diff --git a/crates/edit_prediction/src/license_detection.rs b/crates/edit_prediction/src/license_detection.rs index 2b44825c4ceef1a317034966aa1a0b6a7a0f54c2..6f701d13a9d4d915bbfbc2442ea5643afac30ef4 100644 --- a/crates/edit_prediction/src/license_detection.rs +++ b/crates/edit_prediction/src/license_detection.rs @@ -308,7 +308,9 @@ impl LicenseDetectionWatcher { } } } - worktree::Event::DeletedEntry(_) | worktree::Event::UpdatedGitRepositories(_) => {} + worktree::Event::DeletedEntry(_) + | worktree::Event::UpdatedGitRepositories(_) + | worktree::Event::Deleted => {} }); let worktree_snapshot = worktree.read(cx).snapshot(); diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 10fb447a6f6c7867212a4622d084deb4fcea91a2..286d3a85f86173bff5d17d8d7c86d26464a04714 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -4413,7 +4413,8 @@ impl LspStore { this.update_local_worktree_language_servers(&worktree, changes, cx); } worktree::Event::UpdatedGitRepositories(_) - | worktree::Event::DeletedEntry(_) => {} + | worktree::Event::DeletedEntry(_) + | worktree::Event::Deleted => {} }) .detach() } diff --git a/crates/project/src/manifest_tree.rs b/crates/project/src/manifest_tree.rs index 82dd1bc0d3fdd0149ced5ce3f2cf9ae480c9f2b7..1ae5b0e809f3803c3f8858afb065637ba0a0f256 100644 --- a/crates/project/src/manifest_tree.rs +++ b/crates/project/src/manifest_tree.rs @@ -59,6 +59,7 @@ impl WorktreeRoots { let path = TriePath::from(entry.path.as_ref()); this.roots.remove(&path); } + WorktreeEvent::Deleted => {} } }), }) diff --git a/crates/project/src/worktree_store.rs b/crates/project/src/worktree_store.rs index 4d464182fa670c6efc7ea2644abd68ef0dcda90a..92f7db453a81c6224455002b7811f2e6945f2a82 100644 --- a/crates/project/src/worktree_store.rs +++ b/crates/project/src/worktree_store.rs @@ -808,6 +808,10 @@ impl WorktreeStore { worktree::Event::DeletedEntry(id) => { cx.emit(WorktreeStoreEvent::WorktreeDeletedEntry(worktree_id, *id)) } + worktree::Event::Deleted => { + // The worktree root itself has been deleted (for single-file worktrees) + // The worktree will be removed via the observe_release callback + } } }) .detach(); diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 1ad3e7ecaa694326aa479b5b69ccd3206fbf1e8d..6665dfa532be31eedba6e522cafd06945e3b33e4 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -355,6 +355,7 @@ enum ScanState { RootUpdated { new_path: Arc, }, + RootDeleted, } struct UpdateObservationState { @@ -368,6 +369,8 @@ pub enum Event { UpdatedEntries(UpdatedEntriesSet), UpdatedGitRepositories(UpdatedGitRepositoriesSet), DeletedEntry(ProjectEntryId), + /// The worktree root itself has been deleted (for single-file worktrees) + Deleted, } impl EventEmitter for Worktree {} @@ -1106,6 +1109,7 @@ impl LocalWorktree { }; let fs_case_sensitive = fs.is_case_sensitive().await; + let is_single_file = snapshot.snapshot.root_dir().is_none(); let mut scanner = BackgroundScanner { fs, fs_case_sensitive, @@ -1128,6 +1132,7 @@ impl LocalWorktree { share_private_files, settings, watcher, + is_single_file, }; scanner @@ -1156,6 +1161,13 @@ impl LocalWorktree { ScanState::RootUpdated { new_path } => { this.update_abs_path_and_refresh(new_path, cx); } + ScanState::RootDeleted => { + log::info!( + "worktree root {} no longer exists, closing worktree", + this.abs_path().display() + ); + cx.emit(Event::Deleted); + } } }); } @@ -3799,6 +3811,9 @@ struct BackgroundScanner { watcher: Arc, settings: WorktreeSettings, share_private_files: bool, + /// Whether this is a single-file worktree (root is a file, not a directory). + /// Used to determine if we should give up after repeated canonicalization failures. + is_single_file: bool, } #[derive(Copy, Clone, PartialEq)] @@ -4096,6 +4111,18 @@ impl BackgroundScanner { .ok(); } else { log::error!("root path could not be canonicalized: {err:#}"); + + // For single-file worktrees, if we can't canonicalize and the file handle + // fallback also failed, the file is gone - close the worktree + if self.is_single_file { + log::info!( + "single-file worktree root {:?} no longer exists, marking as deleted", + root_path.as_path() + ); + self.status_updates_tx + .unbounded_send(ScanState::RootDeleted) + .ok(); + } } return; } diff --git a/crates/worktree/tests/integration/main.rs b/crates/worktree/tests/integration/main.rs index fb8ec444dd324e935aad873bf201d4f0b8ae2019..cd7dd1c9056a7d501bec2bcd7b07d596f689a908 100644 --- a/crates/worktree/tests/integration/main.rs +++ b/crates/worktree/tests/integration/main.rs @@ -14,10 +14,12 @@ use worktree::{Entry, EntryKind, Event, PathChange, Worktree, WorktreeModelHandl use serde_json::json; use settings::{SettingsStore, WorktreeId}; use std::{ + cell::Cell, env, fmt::Write, mem, path::{Path, PathBuf}, + rc::Rc, sync::Arc, }; use util::{ @@ -3105,3 +3107,67 @@ async fn test_refresh_entries_for_paths_creates_ancestors(cx: &mut TestAppContex ); }); } + +#[gpui::test] +async fn test_single_file_worktree_deleted(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + + fs.insert_tree( + "/root", + json!({ + "test.txt": "content", + }), + ) + .await; + + let tree = Worktree::local( + Path::new("/root/test.txt"), + true, + fs.clone(), + Default::default(), + true, + WorktreeId::from_proto(0), + &mut cx.to_async(), + ) + .await + .unwrap(); + + cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) + .await; + + tree.read_with(cx, |tree, _| { + assert!(tree.is_single_file(), "Should be a single-file worktree"); + assert_eq!(tree.abs_path().as_ref(), Path::new("/root/test.txt")); + }); + + // Delete the file + fs.remove_file(Path::new("/root/test.txt"), Default::default()) + .await + .unwrap(); + + // Subscribe to worktree events + let deleted_event_received = Rc::new(Cell::new(false)); + let _subscription = cx.update({ + let deleted_event_received = deleted_event_received.clone(); + |cx| { + cx.subscribe(&tree, move |_, event, _| { + if matches!(event, Event::Deleted) { + deleted_event_received.set(true); + } + }) + } + }); + + // Trigger filesystem events - the scanner should detect the file is gone immediately + // and emit a Deleted event + cx.background_executor.run_until_parked(); + cx.background_executor + .advance_clock(std::time::Duration::from_secs(1)); + cx.background_executor.run_until_parked(); + + assert!( + deleted_event_received.get(), + "Should receive Deleted event when single-file worktree root is deleted" + ); +}