From b7ad20773c41cccbf5d761d2b7b58c4eb2197c0f Mon Sep 17 00:00:00 2001 From: "Mitch (a.k.a Voz)" Date: Thu, 4 Sep 2025 03:25:47 -0500 Subject: [PATCH] worktree: Create parent directories on rename (#37437) Closes https://github.com/zed-industries/zed/issues/37357 Release Notes: - Allow creating sub-directories when renaming a file in file finder --------- Co-authored-by: Kirill Bulatov --- crates/worktree/src/worktree.rs | 59 ++++++++++++----- crates/worktree/src/worktree_tests.rs | 95 +++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 18 deletions(-) diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 711c99ce28bbbc557a293d8b644ac6594f31ad7f..7af86d3364f6e07116ae701da83b897869bb905e 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -1775,36 +1775,54 @@ impl LocalWorktree { }; absolutize_path }; - let abs_path = abs_new_path.clone(); + let fs = self.fs.clone(); + let abs_path = abs_new_path.clone(); let case_sensitive = self.fs_case_sensitive; - let rename = cx.background_spawn(async move { - let abs_old_path = abs_old_path?; - let abs_new_path = abs_new_path; - - let abs_old_path_lower = abs_old_path.to_str().map(|p| p.to_lowercase()); - let abs_new_path_lower = abs_new_path.to_str().map(|p| p.to_lowercase()); - - // If we're on a case-insensitive FS and we're doing a case-only rename (i.e. `foobar` to `FOOBAR`) - // we want to overwrite, because otherwise we run into a file-already-exists error. - let overwrite = !case_sensitive - && abs_old_path != abs_new_path - && abs_old_path_lower == abs_new_path_lower; + let do_rename = async move |fs: &dyn Fs, old_path: &Path, new_path: &Path, overwrite| { fs.rename( - &abs_old_path, - &abs_new_path, + &old_path, + &new_path, fs::RenameOptions { overwrite, - ..Default::default() + ..fs::RenameOptions::default() }, ) .await - .with_context(|| format!("Renaming {abs_old_path:?} into {abs_new_path:?}")) + .with_context(|| format!("renaming {old_path:?} into {new_path:?}")) + }; + + let rename_task = cx.background_spawn(async move { + let abs_old_path = abs_old_path?; + + // If we're on a case-insensitive FS and we're doing a case-only rename (i.e. `foobar` to `FOOBAR`) + // we want to overwrite, because otherwise we run into a file-already-exists error. + let overwrite = !case_sensitive + && abs_old_path != abs_new_path + && abs_old_path.to_str().map(|p| p.to_lowercase()) + == abs_new_path.to_str().map(|p| p.to_lowercase()); + + // The directory we're renaming into might not exist yet + if let Err(e) = do_rename(fs.as_ref(), &abs_old_path, &abs_new_path, overwrite).await { + if let Some(err) = e.downcast_ref::() + && err.kind() == std::io::ErrorKind::NotFound + { + if let Some(parent) = abs_new_path.parent() { + fs.create_dir(parent) + .await + .with_context(|| format!("creating parent directory {parent:?}"))?; + return do_rename(fs.as_ref(), &abs_old_path, &abs_new_path, overwrite) + .await; + } + } + return Err(e); + } + Ok(()) }); cx.spawn(async move |this, cx| { - rename.await?; + rename_task.await?; Ok(this .update(cx, |this, cx| { let local = this.as_local_mut().unwrap(); @@ -1818,6 +1836,11 @@ impl LocalWorktree { ); Task::ready(Ok(this.root_entry().cloned())) } else { + // First refresh the parent directory (in case it was newly created) + if let Some(parent) = new_path.parent() { + let _ = local.refresh_entries_for_paths(vec![parent.into()]); + } + // Then refresh the new path local.refresh_entry(new_path.clone(), Some(old_path), cx) } })? diff --git a/crates/worktree/src/worktree_tests.rs b/crates/worktree/src/worktree_tests.rs index c46e14f077e6f4f527b1fcc616a6560cf9654b18..1783ba317c9927bb79ebdb91b1f57f13d200b60f 100644 --- a/crates/worktree/src/worktree_tests.rs +++ b/crates/worktree/src/worktree_tests.rs @@ -1924,6 +1924,101 @@ fn random_filename(rng: &mut impl Rng) -> String { .collect() } +#[gpui::test] +async fn test_rename_file_to_new_directory(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.background_executor.clone()); + let expected_contents = "content"; + fs.as_fake() + .insert_tree( + "/root", + json!({ + "test.txt": expected_contents + }), + ) + .await; + let worktree = Worktree::local( + Path::new("/root"), + true, + fs.clone(), + Arc::default(), + &mut cx.to_async(), + ) + .await + .unwrap(); + cx.read(|cx| worktree.read(cx).as_local().unwrap().scan_complete()) + .await; + + let entry_id = worktree.read_with(cx, |worktree, _| { + worktree.entry_for_path("test.txt").unwrap().id + }); + let _result = worktree + .update(cx, |worktree, cx| { + worktree.rename_entry(entry_id, Path::new("dir1/dir2/dir3/test.txt"), cx) + }) + .await + .unwrap(); + worktree.read_with(cx, |worktree, _| { + assert!( + worktree.entry_for_path("test.txt").is_none(), + "Old file should have been removed" + ); + assert!( + worktree.entry_for_path("dir1/dir2/dir3/test.txt").is_some(), + "Whole directory hierarchy and the new file should have been created" + ); + }); + assert_eq!( + worktree + .update(cx, |worktree, cx| { + worktree.load_file("dir1/dir2/dir3/test.txt".as_ref(), cx) + }) + .await + .unwrap() + .text, + expected_contents, + "Moved file's contents should be preserved" + ); + + let entry_id = worktree.read_with(cx, |worktree, _| { + worktree + .entry_for_path("dir1/dir2/dir3/test.txt") + .unwrap() + .id + }); + let _result = worktree + .update(cx, |worktree, cx| { + worktree.rename_entry(entry_id, Path::new("dir1/dir2/test.txt"), cx) + }) + .await + .unwrap(); + worktree.read_with(cx, |worktree, _| { + assert!( + worktree.entry_for_path("test.txt").is_none(), + "First file should not reappear" + ); + assert!( + worktree.entry_for_path("dir1/dir2/dir3/test.txt").is_none(), + "Old file should have been removed" + ); + assert!( + worktree.entry_for_path("dir1/dir2/test.txt").is_some(), + "No error should have occurred after moving into existing directory" + ); + }); + assert_eq!( + worktree + .update(cx, |worktree, cx| { + worktree.load_file("dir1/dir2/test.txt".as_ref(), cx) + }) + .await + .unwrap() + .text, + expected_contents, + "Moved file's contents should be preserved" + ); +} + #[gpui::test] async fn test_private_single_file_worktree(cx: &mut TestAppContext) { init_test(cx);