worktree: Create parent directories on rename (#37437)

Mitch (a.k.a Voz) and Kirill Bulatov created

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 <kirill@zed.dev>

Change summary

crates/worktree/src/worktree.rs       | 59 ++++++++++++-----
crates/worktree/src/worktree_tests.rs | 95 +++++++++++++++++++++++++++++
2 files changed, 136 insertions(+), 18 deletions(-)

Detailed changes

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::<std::io::Error>()
+                    && 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)
                     }
                 })?

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);