Allow creating directories from the project panel

Max Brunsfeld created

Change summary

crates/collab/src/rpc.rs                  |   4 
crates/project/src/project.rs             |  16 +-
crates/project/src/worktree.rs            |  72 +++++++++---
crates/project_panel/src/project_panel.rs | 131 +++++++++++++++++++-----
4 files changed, 166 insertions(+), 57 deletions(-)

Detailed changes

crates/collab/src/rpc.rs 🔗

@@ -1849,7 +1849,9 @@ mod tests {
 
         let entry = project_b
             .update(cx_b, |project, cx| {
-                project.create_file((worktree_id, "c.txt"), cx).unwrap()
+                project
+                    .create_entry((worktree_id, "c.txt"), false, cx)
+                    .unwrap()
             })
             .await
             .unwrap();

crates/project/src/project.rs 🔗

@@ -690,33 +690,31 @@ impl Project {
             .map(|worktree| worktree.read(cx).id())
     }
 
-    pub fn create_file(
+    pub fn create_entry(
         &mut self,
         project_path: impl Into<ProjectPath>,
+        is_directory: bool,
         cx: &mut ModelContext<Self>,
     ) -> Option<Task<Result<Entry>>> {
         let project_path = project_path.into();
         let worktree = self.worktree_for_id(project_path.worktree_id, cx)?;
-
         if self.is_local() {
             Some(worktree.update(cx, |worktree, cx| {
-                worktree.as_local_mut().unwrap().write_file(
-                    project_path.path,
-                    Default::default(),
-                    cx,
-                )
+                worktree
+                    .as_local_mut()
+                    .unwrap()
+                    .create_entry(project_path.path, is_directory, cx)
             }))
         } else {
             let client = self.client.clone();
             let project_id = self.remote_id().unwrap();
-
             Some(cx.spawn_weak(|_, mut cx| async move {
                 let response = client
                     .request(proto::CreateProjectEntry {
                         worktree_id: project_path.worktree_id.to_proto(),
                         project_id,
                         path: project_path.path.as_os_str().as_bytes().to_vec(),
-                        is_directory: false,
+                        is_directory,
                     })
                     .await?;
                 let entry = response

crates/project/src/worktree.rs 🔗

@@ -686,32 +686,30 @@ impl LocalWorktree {
         })
     }
 
+    pub fn create_entry(
+        &self,
+        path: impl Into<Arc<Path>>,
+        is_dir: bool,
+        cx: &mut ModelContext<Worktree>,
+    ) -> Task<Result<Entry>> {
+        self.write_entry_internal(
+            path,
+            if is_dir {
+                None
+            } else {
+                Some(Default::default())
+            },
+            cx,
+        )
+    }
+
     pub fn write_file(
         &self,
         path: impl Into<Arc<Path>>,
         text: Rope,
         cx: &mut ModelContext<Worktree>,
     ) -> Task<Result<Entry>> {
-        let path = path.into();
-        let abs_path = self.absolutize(&path);
-        let save = cx.background().spawn({
-            let fs = self.fs.clone();
-            let abs_path = abs_path.clone();
-            async move { fs.save(&abs_path, &text).await }
-        });
-
-        cx.spawn(|this, mut cx| async move {
-            save.await?;
-            let entry = this
-                .update(&mut cx, |this, _| {
-                    this.as_local_mut()
-                        .unwrap()
-                        .refresh_entry(path, abs_path, None)
-                })
-                .await?;
-            this.update(&mut cx, |this, cx| this.poll_snapshot(cx));
-            Ok(entry)
-        })
+        self.write_entry_internal(path, Some(text), cx)
     }
 
     pub fn rename_entry(
@@ -749,6 +747,40 @@ impl LocalWorktree {
         }))
     }
 
+    fn write_entry_internal(
+        &self,
+        path: impl Into<Arc<Path>>,
+        text_if_file: Option<Rope>,
+        cx: &mut ModelContext<Worktree>,
+    ) -> Task<Result<Entry>> {
+        let path = path.into();
+        let abs_path = self.absolutize(&path);
+        let write = cx.background().spawn({
+            let fs = self.fs.clone();
+            let abs_path = abs_path.clone();
+            async move {
+                if let Some(text) = text_if_file {
+                    fs.save(&abs_path, &text).await
+                } else {
+                    fs.create_dir(&abs_path).await
+                }
+            }
+        });
+
+        cx.spawn(|this, mut cx| async move {
+            write.await?;
+            let entry = this
+                .update(&mut cx, |this, _| {
+                    this.as_local_mut()
+                        .unwrap()
+                        .refresh_entry(path, abs_path, None)
+                })
+                .await?;
+            this.update(&mut cx, |this, cx| this.poll_snapshot(cx));
+            Ok(entry)
+        })
+    }
+
     fn refresh_entry(
         &self,
         path: Arc<Path>,

crates/project_panel/src/project_panel.rs 🔗

@@ -25,7 +25,7 @@ use workspace::{
     Workspace,
 };
 
-const NEW_FILE_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
+const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
 
 pub struct ProjectPanel {
     project: ModelHandle<Project>,
@@ -48,7 +48,8 @@ struct Selection {
 struct EditState {
     worktree_id: WorktreeId,
     entry_id: ProjectEntryId,
-    new_file: bool,
+    is_new_entry: bool,
+    is_dir: bool,
     processing_filename: Option<String>,
 }
 
@@ -71,7 +72,13 @@ pub struct Open(pub ProjectEntryId);
 
 actions!(
     project_panel,
-    [ExpandSelectedEntry, CollapseSelectedEntry, AddFile, Rename]
+    [
+        ExpandSelectedEntry,
+        CollapseSelectedEntry,
+        AddDirectory,
+        AddFile,
+        Rename
+    ]
 );
 impl_internal_actions!(project_panel, [Open, ToggleExpanded]);
 
@@ -83,6 +90,7 @@ pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(ProjectPanel::select_next);
     cx.add_action(ProjectPanel::open_entry);
     cx.add_action(ProjectPanel::add_file);
+    cx.add_action(ProjectPanel::add_directory);
     cx.add_action(ProjectPanel::rename);
     cx.add_async_action(ProjectPanel::confirm);
     cx.add_action(ProjectPanel::cancel);
@@ -278,15 +286,15 @@ impl ProjectPanel {
         let edit_task;
         let edited_entry_id;
 
-        if edit_state.new_file {
+        if edit_state.is_new_entry {
             self.selection = Some(Selection {
                 worktree_id,
-                entry_id: NEW_FILE_ENTRY_ID,
+                entry_id: NEW_ENTRY_ID,
             });
             let new_path = entry.path.join(&filename);
-            edited_entry_id = NEW_FILE_ENTRY_ID;
+            edited_entry_id = NEW_ENTRY_ID;
             edit_task = self.project.update(cx, |project, cx| {
-                project.create_file((edit_state.worktree_id, new_path), cx)
+                project.create_entry((edit_state.worktree_id, new_path), edit_state.is_dir, cx)
             })?;
         } else {
             let new_path = if let Some(parent) = entry.path.clone().parent() {
@@ -332,6 +340,14 @@ impl ProjectPanel {
     }
 
     fn add_file(&mut self, _: &AddFile, cx: &mut ViewContext<Self>) {
+        self.add_entry(false, cx)
+    }
+
+    fn add_directory(&mut self, _: &AddDirectory, cx: &mut ViewContext<Self>) {
+        self.add_entry(true, cx)
+    }
+
+    fn add_entry(&mut self, is_dir: bool, cx: &mut ViewContext<Self>) {
         if let Some(Selection {
             worktree_id,
             entry_id,
@@ -373,13 +389,14 @@ impl ProjectPanel {
             self.edit_state = Some(EditState {
                 worktree_id,
                 entry_id: directory_id,
-                new_file: true,
+                is_new_entry: true,
+                is_dir,
                 processing_filename: None,
             });
             self.filename_editor
                 .update(cx, |editor, cx| editor.clear(cx));
             cx.focus(&self.filename_editor);
-            self.update_visible_entries(None, cx);
+            self.update_visible_entries(Some((worktree_id, NEW_ENTRY_ID)), cx);
             cx.notify();
         }
     }
@@ -395,7 +412,8 @@ impl ProjectPanel {
                     self.edit_state = Some(EditState {
                         worktree_id,
                         entry_id,
-                        new_file: false,
+                        is_new_entry: false,
+                        is_dir: entry.is_dir(),
                         processing_filename: None,
                     });
                     let filename = entry
@@ -526,22 +544,27 @@ impl ProjectPanel {
                 }
             };
 
-            let new_file_parent_id = self.edit_state.as_ref().and_then(|edit_state| {
-                if edit_state.worktree_id == worktree_id && edit_state.new_file {
-                    Some(edit_state.entry_id)
-                } else {
-                    None
+            let mut new_entry_parent_id = None;
+            let mut new_entry_kind = EntryKind::Dir;
+            if let Some(edit_state) = &self.edit_state {
+                if edit_state.worktree_id == worktree_id && edit_state.is_new_entry {
+                    new_entry_parent_id = Some(edit_state.entry_id);
+                    new_entry_kind = if edit_state.is_dir {
+                        EntryKind::Dir
+                    } else {
+                        EntryKind::File(Default::default())
+                    };
                 }
-            });
+            }
 
             let mut visible_worktree_entries = Vec::new();
             let mut entry_iter = snapshot.entries(false);
             while let Some(entry) = entry_iter.entry() {
                 visible_worktree_entries.push(entry.clone());
-                if Some(entry.id) == new_file_parent_id {
+                if Some(entry.id) == new_entry_parent_id {
                     visible_worktree_entries.push(Entry {
-                        id: NEW_FILE_ENTRY_ID,
-                        kind: project::EntryKind::File(Default::default()),
+                        id: NEW_ENTRY_ID,
+                        kind: new_entry_kind,
                         path: entry.path.join("\0").into(),
                         inode: 0,
                         mtime: entry.mtime,
@@ -669,8 +692,8 @@ impl ProjectPanel {
                         is_processing: false,
                     };
                     if let Some(edit_state) = &self.edit_state {
-                        let is_edited_entry = if edit_state.new_file {
-                            entry.id == NEW_FILE_ENTRY_ID
+                        let is_edited_entry = if edit_state.is_new_entry {
+                            entry.id == NEW_ENTRY_ID
                         } else {
                             entry.id == edit_state.entry_id
                         };
@@ -680,7 +703,7 @@ impl ProjectPanel {
                                 details.filename.clear();
                                 details.filename.push_str(&processing_filename);
                             } else {
-                                if edit_state.new_file {
+                                if edit_state.is_new_entry {
                                     details.filename.clear();
                                 }
                                 details.is_editing = true;
@@ -983,11 +1006,11 @@ mod tests {
         assert_eq!(
             visible_entries_as_strings(&panel, 0..10, cx),
             &[
-                "v root1  <== selected",
+                "v root1",
                 "    > a",
                 "    > b",
                 "    > C",
-                "      [EDITOR: '']",
+                "      [EDITOR: '']  <== selected",
                 "      .dockerignore",
                 "v root2",
                 "    > d",
@@ -1039,10 +1062,10 @@ mod tests {
             &[
                 "v root1",
                 "    > a",
-                "    v b  <== selected",
+                "    v b",
                 "        > 3",
                 "        > 4",
-                "          [EDITOR: '']",
+                "          [EDITOR: '']  <== selected",
                 "    > C",
                 "      .dockerignore",
                 "      the-new-filename",
@@ -1126,6 +1149,60 @@ mod tests {
                 "      the-new-filename",
             ]
         );
+
+        panel.update(cx, |panel, cx| panel.add_directory(&AddDirectory, cx));
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..9, cx),
+            &[
+                "v root1",
+                "    > a",
+                "    v b",
+                "        > [EDITOR: '']  <== selected",
+                "        > 3",
+                "        > 4",
+                "          a-different-filename",
+                "    > C",
+                "      .dockerignore",
+            ]
+        );
+
+        let confirm = panel.update(cx, |panel, cx| {
+            panel
+                .filename_editor
+                .update(cx, |editor, cx| editor.set_text("new-dir", cx));
+            panel.confirm(&Confirm, cx).unwrap()
+        });
+        panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..9, cx),
+            &[
+                "v root1",
+                "    > a",
+                "    v b",
+                "        > [PROCESSING: 'new-dir']",
+                "        > 3  <== selected",
+                "        > 4",
+                "          a-different-filename",
+                "    > C",
+                "      .dockerignore",
+            ]
+        );
+
+        confirm.await.unwrap();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..9, cx),
+            &[
+                "v root1",
+                "    > a",
+                "    v b",
+                "        > 3  <== selected",
+                "        > 4",
+                "        > new-dir",
+                "          a-different-filename",
+                "    > C",
+                "      .dockerignore",
+            ]
+        );
     }
 
     fn toggle_expand_dir(
@@ -1192,7 +1269,7 @@ mod tests {
                 }
 
                 let indent = "    ".repeat(details.depth);
-                let icon = if details.kind == EntryKind::Dir {
+                let icon = if matches!(details.kind, EntryKind::Dir | EntryKind::PendingDir) {
                     if details.is_expanded {
                         "v "
                     } else {