WIP: Add the ability to make new directories by adding slashes to a file name (#2638)

Mikayla Maki created

This PR adds a new way to make files / directories in the project panel,
by writing a path instead of a file.

TODO:
- [x] Solve a race condition that sometimes causes the newly created
file to not be selected / expanded correctly.
- [x] Change file refreshes to be minimal

Release Notes:

- Adds the ability to create new folders in the create-file action
([743](https://github.com/zed-industries/community/issues/743))

Change summary

Cargo.lock                                |   1 
Cargo.toml                                |   1 
crates/collab/Cargo.toml                  |   2 
crates/fs/src/fs.rs                       |   6 
crates/project/Cargo.toml                 |   2 
crates/project/src/worktree.rs            |  43 ++++++
crates/project/src/worktree_tests.rs      | 113 ++++++++++++++++++
crates/project_panel/Cargo.toml           |   1 
crates/project_panel/src/project_panel.rs | 151 ++++++++++++++++++++++++
crates/settings/Cargo.toml                |   2 
10 files changed, 313 insertions(+), 9 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -5033,6 +5033,7 @@ dependencies = [
  "language",
  "menu",
  "postage",
+ "pretty_assertions",
  "project",
  "schemars",
  "serde",

Cargo.toml 🔗

@@ -101,6 +101,7 @@ time = { version = "0.3", features = ["serde", "serde-well-known"] }
 toml = { version = "0.5" }
 tree-sitter = "0.20"
 unindent = { version = "0.1.7" }
+pretty_assertions = "1.3.0"
 
 [patch.crates-io]
 tree-sitter = { git = "https://github.com/tree-sitter/tree-sitter", rev = "49226023693107fba9a1191136a4f47f38cdca73" }

crates/collab/Cargo.toml 🔗

@@ -67,7 +67,7 @@ fs = { path = "../fs", features = ["test-support"] }
 git = { path = "../git", features = ["test-support"] }
 live_kit_client = { path = "../live_kit_client", features = ["test-support"] }
 lsp = { path = "../lsp", features = ["test-support"] }
-pretty_assertions = "1.3.0"
+pretty_assertions.workspace = true
 project = { path = "../project", features = ["test-support"] }
 rpc = { path = "../rpc", features = ["test-support"] }
 settings = { path = "../settings", features = ["test-support"] }

crates/fs/src/fs.rs 🔗

@@ -279,6 +279,9 @@ impl Fs for RealFs {
 
     async fn save(&self, path: &Path, text: &Rope, line_ending: LineEnding) -> Result<()> {
         let buffer_size = text.summary().len.min(10 * 1024);
+        if let Some(path) = path.parent() {
+            self.create_dir(path).await?;
+        }
         let file = smol::fs::File::create(path).await?;
         let mut writer = smol::io::BufWriter::with_capacity(buffer_size, file);
         for chunk in chunks(text, line_ending) {
@@ -1077,6 +1080,9 @@ impl Fs for FakeFs {
         self.simulate_random_delay().await;
         let path = normalize_path(path);
         let content = chunks(text, line_ending).collect();
+        if let Some(path) = path.parent() {
+            self.create_dir(path).await?;
+        }
         self.write_file_internal(path, content)?;
         Ok(())
     }

crates/project/Cargo.toml 🔗

@@ -64,7 +64,7 @@ itertools = "0.10"
 [dev-dependencies]
 ctor.workspace = true
 env_logger.workspace = true
-pretty_assertions = "1.3.0"
+pretty_assertions.workspace = true
 client = { path = "../client", features = ["test-support"] }
 collections = { path = "../collections", features = ["test-support"] }
 db = { path = "../db", features = ["test-support"] }

crates/project/src/worktree.rs 🔗

@@ -981,6 +981,19 @@ impl LocalWorktree {
         })
     }
 
+    /// Find the lowest path in the worktree's datastructures that is an ancestor
+    fn lowest_ancestor(&self, path: &Path) -> PathBuf {
+        let mut lowest_ancestor = None;
+        for path in path.ancestors() {
+            if self.entry_for_path(path).is_some() {
+                lowest_ancestor = Some(path.to_path_buf());
+                break;
+            }
+        }
+
+        lowest_ancestor.unwrap_or_else(|| PathBuf::from(""))
+    }
+
     pub fn create_entry(
         &self,
         path: impl Into<Arc<Path>>,
@@ -988,6 +1001,7 @@ impl LocalWorktree {
         cx: &mut ModelContext<Worktree>,
     ) -> Task<Result<Entry>> {
         let path = path.into();
+        let lowest_ancestor = self.lowest_ancestor(&path);
         let abs_path = self.absolutize(&path);
         let fs = self.fs.clone();
         let write = cx.background().spawn(async move {
@@ -1001,10 +1015,31 @@ impl LocalWorktree {
 
         cx.spawn(|this, mut cx| async move {
             write.await?;
-            this.update(&mut cx, |this, cx| {
-                this.as_local_mut().unwrap().refresh_entry(path, None, cx)
-            })
-            .await
+            let (result, refreshes) = this.update(&mut cx, |this, cx| {
+                let mut refreshes = Vec::<Task<anyhow::Result<Entry>>>::new();
+                let refresh_paths = path.strip_prefix(&lowest_ancestor).unwrap();
+                for refresh_path in refresh_paths.ancestors() {
+                    if refresh_path == Path::new("") {
+                        continue;
+                    }
+                    let refresh_full_path = lowest_ancestor.join(refresh_path);
+
+                    refreshes.push(this.as_local_mut().unwrap().refresh_entry(
+                        refresh_full_path.into(),
+                        None,
+                        cx,
+                    ));
+                }
+                (
+                    this.as_local_mut().unwrap().refresh_entry(path, None, cx),
+                    refreshes,
+                )
+            });
+            for refresh in refreshes {
+                refresh.await.log_err();
+            }
+
+            result.await
         })
     }
 

crates/project/src/worktree_tests.rs 🔗

@@ -936,6 +936,119 @@ async fn test_create_directory_during_initial_scan(cx: &mut TestAppContext) {
     );
 }
 
+#[gpui::test]
+async fn test_create_dir_all_on_create_entry(cx: &mut TestAppContext) {
+    let client_fake = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
+
+    let fs_fake = FakeFs::new(cx.background());
+    fs_fake
+        .insert_tree(
+            "/root",
+            json!({
+                "a": {},
+            }),
+        )
+        .await;
+
+    let tree_fake = Worktree::local(
+        client_fake,
+        "/root".as_ref(),
+        true,
+        fs_fake,
+        Default::default(),
+        &mut cx.to_async(),
+    )
+    .await
+    .unwrap();
+
+    let entry = tree_fake
+        .update(cx, |tree, cx| {
+            tree.as_local_mut()
+                .unwrap()
+                .create_entry("a/b/c/d.txt".as_ref(), false, cx)
+        })
+        .await
+        .unwrap();
+    assert!(entry.is_file());
+
+    cx.foreground().run_until_parked();
+    tree_fake.read_with(cx, |tree, _| {
+        assert!(tree.entry_for_path("a/b/c/d.txt").unwrap().is_file());
+        assert!(tree.entry_for_path("a/b/c/").unwrap().is_dir());
+        assert!(tree.entry_for_path("a/b/").unwrap().is_dir());
+    });
+
+    let client_real = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
+
+    let fs_real = Arc::new(RealFs);
+    let temp_root = temp_tree(json!({
+        "a": {}
+    }));
+
+    let tree_real = Worktree::local(
+        client_real,
+        temp_root.path(),
+        true,
+        fs_real,
+        Default::default(),
+        &mut cx.to_async(),
+    )
+    .await
+    .unwrap();
+
+    let entry = tree_real
+        .update(cx, |tree, cx| {
+            tree.as_local_mut()
+                .unwrap()
+                .create_entry("a/b/c/d.txt".as_ref(), false, cx)
+        })
+        .await
+        .unwrap();
+    assert!(entry.is_file());
+
+    cx.foreground().run_until_parked();
+    tree_real.read_with(cx, |tree, _| {
+        assert!(tree.entry_for_path("a/b/c/d.txt").unwrap().is_file());
+        assert!(tree.entry_for_path("a/b/c/").unwrap().is_dir());
+        assert!(tree.entry_for_path("a/b/").unwrap().is_dir());
+    });
+
+    // Test smallest change
+    let entry = tree_real
+        .update(cx, |tree, cx| {
+            tree.as_local_mut()
+                .unwrap()
+                .create_entry("a/b/c/e.txt".as_ref(), false, cx)
+        })
+        .await
+        .unwrap();
+    assert!(entry.is_file());
+
+    cx.foreground().run_until_parked();
+    tree_real.read_with(cx, |tree, _| {
+        assert!(tree.entry_for_path("a/b/c/e.txt").unwrap().is_file());
+    });
+
+    // Test largest change
+    let entry = tree_real
+        .update(cx, |tree, cx| {
+            tree.as_local_mut()
+                .unwrap()
+                .create_entry("d/e/f/g.txt".as_ref(), false, cx)
+        })
+        .await
+        .unwrap();
+    assert!(entry.is_file());
+
+    cx.foreground().run_until_parked();
+    tree_real.read_with(cx, |tree, _| {
+        assert!(tree.entry_for_path("d/e/f/g.txt").unwrap().is_file());
+        assert!(tree.entry_for_path("d/e/f").unwrap().is_dir());
+        assert!(tree.entry_for_path("d/e/").unwrap().is_dir());
+        assert!(tree.entry_for_path("d/").unwrap().is_dir());
+    });
+}
+
 #[gpui::test(iterations = 100)]
 async fn test_random_worktree_operations_during_initial_scan(
     cx: &mut TestAppContext,

crates/project_panel/Cargo.toml 🔗

@@ -27,6 +27,7 @@ serde_derive.workspace = true
 serde_json.workspace = true
 anyhow.workspace = true
 schemars.workspace = true
+pretty_assertions.workspace = true
 unicase = "2.6"
 
 [dev-dependencies]

crates/project_panel/src/project_panel.rs 🔗

@@ -64,7 +64,7 @@ pub struct ProjectPanel {
     pending_serialization: Task<Option<()>>,
 }
 
-#[derive(Copy, Clone)]
+#[derive(Copy, Clone, Debug)]
 struct Selection {
     worktree_id: WorktreeId,
     entry_id: ProjectEntryId,
@@ -547,7 +547,7 @@ impl ProjectPanel {
                 worktree_id,
                 entry_id: NEW_ENTRY_ID,
             });
-            let new_path = entry.path.join(&filename);
+            let new_path = entry.path.join(&filename.trim_start_matches("/"));
             if path_already_exists(new_path.as_path()) {
                 return None;
             }
@@ -588,6 +588,7 @@ impl ProjectPanel {
                     if selection.entry_id == edited_entry_id {
                         selection.worktree_id = worktree_id;
                         selection.entry_id = new_entry.id;
+                        this.expand_to_selection(cx);
                     }
                 }
                 this.update_visible_entries(None, cx);
@@ -965,6 +966,24 @@ impl ProjectPanel {
         Some((worktree, entry))
     }
 
+    fn expand_to_selection(&mut self, cx: &mut ViewContext<Self>) -> Option<()> {
+        let (worktree, entry) = self.selected_entry(cx)?;
+        let expanded_dir_ids = self.expanded_dir_ids.entry(worktree.id()).or_default();
+
+        for path in entry.path.ancestors() {
+            let Some(entry) = worktree.entry_for_path(path) else {
+                continue;
+            };
+            if entry.is_dir() {
+                if let Err(idx) = expanded_dir_ids.binary_search(&entry.id) {
+                    expanded_dir_ids.insert(idx, entry.id);
+                }
+            }
+        }
+
+        Some(())
+    }
+
     fn update_visible_entries(
         &mut self,
         new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
@@ -1592,6 +1611,7 @@ impl ClipboardEntry {
 mod tests {
     use super::*;
     use gpui::{TestAppContext, ViewHandle};
+    use pretty_assertions::assert_eq;
     use project::FakeFs;
     use serde_json::json;
     use settings::SettingsStore;
@@ -2002,6 +2022,133 @@ mod tests {
         );
     }
 
+    #[gpui::test(iterations = 30)]
+    async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.background());
+        fs.insert_tree(
+            "/root1",
+            json!({
+                ".dockerignore": "",
+                ".git": {
+                    "HEAD": "",
+                },
+                "a": {
+                    "0": { "q": "", "r": "", "s": "" },
+                    "1": { "t": "", "u": "" },
+                    "2": { "v": "", "w": "", "x": "", "y": "" },
+                },
+                "b": {
+                    "3": { "Q": "" },
+                    "4": { "R": "", "S": "", "T": "", "U": "" },
+                },
+                "C": {
+                    "5": {},
+                    "6": { "V": "", "W": "" },
+                    "7": { "X": "" },
+                    "8": { "Y": {}, "Z": "" }
+                }
+            }),
+        )
+        .await;
+        fs.insert_tree(
+            "/root2",
+            json!({
+                "d": {
+                    "9": ""
+                },
+                "e": {}
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
+        let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
+        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
+
+        select_path(&panel, "root1", cx);
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..10, cx),
+            &[
+                "v root1  <== selected",
+                "    > .git",
+                "    > a",
+                "    > b",
+                "    > C",
+                "      .dockerignore",
+                "v root2",
+                "    > d",
+                "    > e",
+            ]
+        );
+
+        // Add a file with the root folder selected. The filename editor is placed
+        // before the first file in the root folder.
+        panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
+        cx.read_window(window_id, |cx| {
+            let panel = panel.read(cx);
+            assert!(panel.filename_editor.is_focused(cx));
+        });
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..10, cx),
+            &[
+                "v root1",
+                "    > .git",
+                "    > a",
+                "    > b",
+                "    > C",
+                "      [EDITOR: '']  <== selected",
+                "      .dockerignore",
+                "v root2",
+                "    > d",
+                "    > e",
+            ]
+        );
+
+        let confirm = panel.update(cx, |panel, cx| {
+            panel.filename_editor.update(cx, |editor, cx| {
+                editor.set_text("/bdir1/dir2/the-new-filename", cx)
+            });
+            panel.confirm(&Confirm, cx).unwrap()
+        });
+
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..10, cx),
+            &[
+                "v root1",
+                "    > .git",
+                "    > a",
+                "    > b",
+                "    > C",
+                "      [PROCESSING: '/bdir1/dir2/the-new-filename']  <== selected",
+                "      .dockerignore",
+                "v root2",
+                "    > d",
+                "    > e",
+            ]
+        );
+
+        confirm.await.unwrap();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..13, cx),
+            &[
+                "v root1",
+                "    > .git",
+                "    > a",
+                "    > b",
+                "    v bdir1",
+                "        v dir2",
+                "              the-new-filename  <== selected",
+                "    > C",
+                "      .dockerignore",
+                "v root2",
+                "    > d",
+                "    > e",
+            ]
+        );
+    }
+
     #[gpui::test]
     async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
         init_test(cx);

crates/settings/Cargo.toml 🔗

@@ -38,5 +38,5 @@ tree-sitter-json = "*"
 gpui = { path = "../gpui", features = ["test-support"] }
 fs = { path = "../fs", features = ["test-support"] }
 indoc.workspace = true
-pretty_assertions = "1.3.0"
+pretty_assertions.workspace = true
 unindent.workspace = true