Skip to content

Commit

Permalink
Root rename detection (#20313)
Browse files Browse the repository at this point in the history
Closes #5349

Release Notes:

- Fixed Zed when the directory that you opened is renamed.
  • Loading branch information
ConradIrwin authored Nov 7, 2024
1 parent 216ea4d commit e645aa9
Show file tree
Hide file tree
Showing 3 changed files with 196 additions and 4 deletions.
105 changes: 102 additions & 3 deletions crates/fs/src/fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ use git::GitHostingProviderRegistry;
#[cfg(target_os = "linux")]
use ashpd::desktop::trash;
#[cfg(target_os = "linux")]
use std::{fs::File, os::fd::AsFd};
use std::fs::File;
#[cfg(unix)]
use std::os::fd::AsFd;
#[cfg(unix)]
use std::os::fd::AsRawFd;

#[cfg(unix)]
use std::os::unix::fs::MetadataExt;
Expand Down Expand Up @@ -51,14 +55,14 @@ pub trait Watcher: Send + Sync {
fn remove(&self, path: &Path) -> Result<()>;
}

#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
pub enum PathEventKind {
Removed,
Created,
Changed,
}

#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)]
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
pub struct PathEvent {
pub path: PathBuf,
pub kind: Option<PathEventKind>,
Expand Down Expand Up @@ -95,6 +99,7 @@ pub trait Fs: Send + Sync {
async fn trash_file(&self, path: &Path, options: RemoveOptions) -> Result<()> {
self.remove_file(path, options).await
}
async fn open_handle(&self, path: &Path) -> Result<Arc<dyn FileHandle>>;
async fn open_sync(&self, path: &Path) -> Result<Box<dyn io::Read>>;
async fn load(&self, path: &Path) -> Result<String> {
Ok(String::from_utf8(self.load_bytes(path).await?)?)
Expand Down Expand Up @@ -187,6 +192,52 @@ pub struct RealFs {
git_binary_path: Option<PathBuf>,
}

pub trait FileHandle: Send + Sync + std::fmt::Debug {
fn current_path(&self, fs: &Arc<dyn Fs>) -> Result<PathBuf>;
}

impl FileHandle for std::fs::File {
#[cfg(target_os = "macos")]
fn current_path(&self, _: &Arc<dyn Fs>) -> Result<PathBuf> {
use std::{
ffi::{CStr, OsStr},
os::unix::ffi::OsStrExt,
};

let fd = self.as_fd();
let mut path_buf: [libc::c_char; libc::PATH_MAX as usize] = [0; libc::PATH_MAX as usize];

let result = unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_GETPATH, path_buf.as_mut_ptr()) };
if result == -1 {
anyhow::bail!("fcntl returned -1".to_string());
}

let c_str = unsafe { CStr::from_ptr(path_buf.as_ptr()) };
let path = PathBuf::from(OsStr::from_bytes(c_str.to_bytes()));
Ok(path)
}

#[cfg(target_os = "linux")]
fn current_path(&self, _: &Arc<dyn Fs>) -> Result<PathBuf> {
let fd = self.as_fd();
let fd_path = format!("/proc/self/fd/{}", fd.as_raw_fd());
let new_path = std::fs::read_link(fd_path)?;
if new_path
.file_name()
.is_some_and(|f| f.to_string_lossy().ends_with(" (deleted)"))
{
anyhow::bail!("file was deleted")
};

Ok(new_path)
}

#[cfg(target_os = "windows")]
fn current_path(&self, _: &Arc<dyn Fs>) -> Result<PathBuf> {
anyhow::bail!("unimplemented")
}
}

pub struct RealWatcher {}

impl RealFs {
Expand Down Expand Up @@ -400,6 +451,10 @@ impl Fs for RealFs {
Ok(Box::new(std::fs::File::open(path)?))
}

async fn open_handle(&self, path: &Path) -> Result<Arc<dyn FileHandle>> {
Ok(Arc::new(std::fs::File::open(path)?))
}

async fn load(&self, path: &Path) -> Result<String> {
let path = path.to_path_buf();
let text = smol::unblock(|| std::fs::read_to_string(path)).await?;
Expand Down Expand Up @@ -755,6 +810,7 @@ struct FakeFsState {
buffered_events: Vec<PathEvent>,
metadata_call_count: usize,
read_dir_call_count: usize,
moves: std::collections::HashMap<u64, PathBuf>,
}

#[cfg(any(test, feature = "test-support"))]
Expand Down Expand Up @@ -926,6 +982,7 @@ impl FakeFs {
events_paused: false,
read_dir_call_count: 0,
metadata_call_count: 0,
moves: Default::default(),
}),
});

Expand Down Expand Up @@ -1362,6 +1419,27 @@ impl Watcher for FakeWatcher {
}
}

#[cfg(any(test, feature = "test-support"))]
#[derive(Debug)]
struct FakeHandle {
inode: u64,
}

#[cfg(any(test, feature = "test-support"))]
impl FileHandle for FakeHandle {
fn current_path(&self, fs: &Arc<dyn Fs>) -> Result<PathBuf> {
let state = fs.as_fake().state.lock();
let Some(target) = state.moves.get(&self.inode) else {
anyhow::bail!("fake fd not moved")
};

if state.try_read_path(&target, false).is_some() {
return Ok(target.clone());
}
anyhow::bail!("fake fd target not found")
}
}

#[cfg(any(test, feature = "test-support"))]
#[async_trait::async_trait]
impl Fs for FakeFs {
Expand Down Expand Up @@ -1500,6 +1578,14 @@ impl Fs for FakeFs {
}
})?;

let inode = match *moved_entry.lock() {
FakeFsEntry::File { inode, .. } => inode,
FakeFsEntry::Dir { inode, .. } => inode,
_ => 0,
};

state.moves.insert(inode, new_path.clone());

state.write_path(&new_path, |e| {
match e {
btree_map::Entry::Occupied(mut e) => {
Expand Down Expand Up @@ -1644,6 +1730,19 @@ impl Fs for FakeFs {
Ok(Box::new(io::Cursor::new(bytes)))
}

async fn open_handle(&self, path: &Path) -> Result<Arc<dyn FileHandle>> {
self.simulate_random_delay().await;
let state = self.state.lock();
let entry = state.read_path(&path)?;
let entry = entry.lock();
let inode = match *entry {
FakeFsEntry::File { inode, .. } => inode,
FakeFsEntry::Dir { inode, .. } => inode,
_ => unreachable!(),
};
Ok(Arc::new(FakeHandle { inode }))
}

async fn load(&self, path: &Path) -> Result<String> {
let content = self.load_internal(path).await?;
Ok(String::from_utf8(content.clone())?)
Expand Down
39 changes: 39 additions & 0 deletions crates/remote_server/src/remote_editing_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1086,6 +1086,45 @@ async fn test_reconnect(cx: &mut TestAppContext, server_cx: &mut TestAppContext)
);
}

#[gpui::test]
async fn test_remote_root_rename(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
let fs = FakeFs::new(server_cx.executor());
fs.insert_tree(
"/code",
json!({
"project1": {
".git": {},
"README.md": "# project 1",
},
}),
)
.await;

let (project, _) = init_test(&fs, cx, server_cx).await;

let (worktree, _) = project
.update(cx, |project, cx| {
project.find_or_create_worktree("/code/project1", true, cx)
})
.await
.unwrap();

cx.run_until_parked();

fs.rename(
&PathBuf::from("/code/project1"),
&PathBuf::from("/code/project2"),
Default::default(),
)
.await
.unwrap();

cx.run_until_parked();
worktree.update(cx, |worktree, _| {
assert_eq!(worktree.root_name(), "project2")
})
}

#[gpui::test]
async fn test_remote_git_branches(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
let fs = FakeFs::new(server_cx.executor());
Expand Down
56 changes: 55 additions & 1 deletion crates/worktree/src/worktree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,9 @@ pub struct LocalSnapshot {
/// All of the git repositories in the worktree, indexed by the project entry
/// id of their parent directory.
git_repositories: TreeMap<ProjectEntryId, LocalRepositoryEntry>,
/// The file handle of the root dir
/// (so we can find it after it's been moved)
root_file_handle: Option<Arc<dyn fs::FileHandle>>,
}

struct BackgroundScannerState {
Expand Down Expand Up @@ -341,6 +344,9 @@ enum ScanState {
barrier: SmallVec<[barrier::Sender; 1]>,
scanning: bool,
},
RootUpdated {
new_path: Option<Arc<Path>>,
},
}

struct UpdateObservationState {
Expand Down Expand Up @@ -382,6 +388,8 @@ impl Worktree {
true
});

let root_file_handle = fs.open_handle(&abs_path).await.log_err();

cx.new_model(move |cx: &mut ModelContext<Worktree>| {
let mut snapshot = LocalSnapshot {
ignores_by_parent_abs_path: Default::default(),
Expand All @@ -393,6 +401,7 @@ impl Worktree {
.map_or(String::new(), |f| f.to_string_lossy().to_string()),
abs_path,
),
root_file_handle,
};

if let Some(metadata) = metadata {
Expand Down Expand Up @@ -1076,6 +1085,17 @@ impl LocalWorktree {
this.set_snapshot(snapshot, changes, cx);
drop(barrier);
}
ScanState::RootUpdated { new_path } => {
if let Some(new_path) = new_path {
this.snapshot.git_repositories = Default::default();
this.snapshot.ignores_by_parent_abs_path = Default::default();
let root_name = new_path
.file_name()
.map_or(String::new(), |f| f.to_string_lossy().to_string());
this.snapshot.update_abs_path(new_path, root_name);
}
this.restart_background_scanners(cx);
}
}
cx.notify();
})
Expand Down Expand Up @@ -2073,12 +2093,24 @@ impl Snapshot {
.and_then(|entry| entry.git_status)
}

fn update_abs_path(&mut self, abs_path: Arc<Path>, root_name: String) {
self.abs_path = abs_path;
if root_name != self.root_name {
self.root_char_bag = root_name.chars().map(|c| c.to_ascii_lowercase()).collect();
self.root_name = root_name;
}
}

pub(crate) fn apply_remote_update(&mut self, mut update: proto::UpdateWorktree) -> Result<()> {
log::trace!(
"applying remote worktree update. {} entries updated, {} removed",
update.updated_entries.len(),
update.removed_entries.len()
);
self.update_abs_path(
Arc::from(PathBuf::from(update.abs_path).as_path()),
update.root_name,
);

let mut entries_by_path_edits = Vec::new();
let mut entries_by_id_edits = Vec::new();
Expand Down Expand Up @@ -3732,7 +3764,29 @@ impl BackgroundScanner {
let root_canonical_path = match self.fs.canonicalize(&root_path).await {
Ok(path) => path,
Err(err) => {
log::error!("failed to canonicalize root path: {}", err);
let new_path = self
.state
.lock()
.snapshot
.root_file_handle
.clone()
.and_then(|handle| handle.current_path(&self.fs).log_err())
.filter(|new_path| **new_path != *root_path);

if let Some(new_path) = new_path.as_ref() {
log::info!(
"root renamed from {} to {}",
root_path.display(),
new_path.display()
)
} else {
log::warn!("root path could not be canonicalized: {}", err);
}
self.status_updates_tx
.unbounded_send(ScanState::RootUpdated {
new_path: new_path.map(|p| p.into()),
})
.ok();
return;
}
};
Expand Down

0 comments on commit e645aa9

Please sign in to comment.