diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 8473e28c66..a429a16a47 100644 --- a/crates/collab/src/rpc.rs +++ b/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(); diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 26a9ed14d7..d0891cadb1 100644 --- a/crates/project/src/project.rs +++ b/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, + is_directory: bool, cx: &mut ModelContext, ) -> Option>> { 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 diff --git a/crates/project/src/worktree.rs b/crates/project/src/worktree.rs index 2efeea1645..db4769b7aa 100644 --- a/crates/project/src/worktree.rs +++ b/crates/project/src/worktree.rs @@ -686,32 +686,30 @@ impl LocalWorktree { }) } + pub fn create_entry( + &self, + path: impl Into>, + is_dir: bool, + cx: &mut ModelContext, + ) -> Task> { + self.write_entry_internal( + path, + if is_dir { + None + } else { + Some(Default::default()) + }, + cx, + ) + } + pub fn write_file( &self, path: impl Into>, text: Rope, cx: &mut ModelContext, ) -> Task> { - 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>, + text_if_file: Option, + cx: &mut ModelContext, + ) -> Task> { + 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, diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index b76166886a..18ff22cd95 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/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, @@ -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, } @@ -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.add_entry(false, cx) + } + + fn add_directory(&mut self, _: &AddDirectory, cx: &mut ViewContext) { + self.add_entry(true, cx) + } + + fn add_entry(&mut self, is_dir: bool, cx: &mut ViewContext) { 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 {