file_finder: Add option to create new file (#31567)

Daniel Zhu created

https://github.com/user-attachments/assets/7c8a05a1-8d59-4371-a1d6-a8cb82aa13b9

While implementing this, I noticed that currently when the search panel
displays only one result, the box oscillates a bit up and down like so:


https://github.com/user-attachments/assets/dd1520e2-fa0b-4307-b27a-984e69b0a644

Not sure how to fix this at the moment, maybe that could be another PR?

Release Notes:

- Add option to create new file in project search panel.

Change summary

crates/file_finder/src/file_finder.rs       | 73 ++++++++++++++++++++-
crates/file_finder/src/file_finder_tests.rs | 77 ++++++++++++++--------
2 files changed, 117 insertions(+), 33 deletions(-)

Detailed changes

crates/file_finder/src/file_finder.rs 🔗

@@ -332,6 +332,7 @@ impl FileFinder {
                             worktree_id: WorktreeId::from_usize(m.0.worktree_id),
                             path: m.0.path.clone(),
                         },
+                        Match::CreateNew(p) => p.clone(),
                     };
                     let open_task = workspace.update(cx, move |workspace, cx| {
                         workspace.split_path_preview(path, false, Some(split_direction), window, cx)
@@ -456,13 +457,15 @@ enum Match {
         panel_match: Option<ProjectPanelOrdMatch>,
     },
     Search(ProjectPanelOrdMatch),
+    CreateNew(ProjectPath),
 }
 
 impl Match {
-    fn path(&self) -> &Arc<Path> {
+    fn path(&self) -> Option<&Arc<Path>> {
         match self {
-            Match::History { path, .. } => &path.project.path,
-            Match::Search(panel_match) => &panel_match.0.path,
+            Match::History { path, .. } => Some(&path.project.path),
+            Match::Search(panel_match) => Some(&panel_match.0.path),
+            Match::CreateNew(_) => None,
         }
     }
 
@@ -470,6 +473,7 @@ impl Match {
         match self {
             Match::History { panel_match, .. } => panel_match.as_ref(),
             Match::Search(panel_match) => Some(&panel_match),
+            Match::CreateNew(_) => None,
         }
     }
 }
@@ -499,7 +503,10 @@ impl Matches {
             // reason for the matches set to change.
             self.matches
                 .iter()
-                .position(|m| path.project.path == *m.path())
+                .position(|m| match m.path() {
+                    Some(p) => path.project.path == *p,
+                    None => false,
+                })
                 .ok_or(0)
         } else {
             self.matches.binary_search_by(|m| {
@@ -576,6 +583,12 @@ impl Matches {
         a: &Match,
         b: &Match,
     ) -> cmp::Ordering {
+        // Handle CreateNew variant - always put it at the end
+        match (a, b) {
+            (Match::CreateNew(_), _) => return cmp::Ordering::Less,
+            (_, Match::CreateNew(_)) => return cmp::Ordering::Greater,
+            _ => {}
+        }
         debug_assert!(a.panel_match().is_some() && b.panel_match().is_some());
 
         match (&a, &b) {
@@ -908,6 +921,23 @@ impl FileFinderDelegate {
                 matches.into_iter(),
                 extend_old_matches,
             );
+            let worktree = self.project.read(cx).visible_worktrees(cx).next();
+            let filename = query.raw_query.to_string();
+            let path = Path::new(&filename);
+
+            // add option of creating new file only if path is relative
+            if let Some(worktree) = worktree {
+                let worktree = worktree.read(cx);
+                if path.is_relative()
+                    && worktree.entry_for_path(&path).is_none()
+                    && !filename.ends_with("/")
+                {
+                    self.matches.matches.push(Match::CreateNew(ProjectPath {
+                        worktree_id: worktree.id(),
+                        path: Arc::from(path),
+                    }));
+                }
+            }
 
             self.selected_index = selected_match.map_or_else(
                 || self.calculate_selected_index(cx),
@@ -988,6 +1018,12 @@ impl FileFinderDelegate {
                     }
                 }
                 Match::Search(path_match) => self.labels_for_path_match(&path_match.0),
+                Match::CreateNew(project_path) => (
+                    format!("Create file: {}", project_path.path.display()),
+                    vec![],
+                    String::from(""),
+                    vec![],
+                ),
             };
 
         if file_name_positions.is_empty() {
@@ -1372,6 +1408,29 @@ impl PickerDelegate for FileFinderDelegate {
                             }
                         };
                     match &m {
+                        Match::CreateNew(project_path) => {
+                            // Create a new file with the given filename
+                            if secondary {
+                                workspace.split_path_preview(
+                                    project_path.clone(),
+                                    false,
+                                    None,
+                                    window,
+                                    cx,
+                                )
+                            } else {
+                                workspace.open_path_preview(
+                                    project_path.clone(),
+                                    None,
+                                    true,
+                                    false,
+                                    true,
+                                    window,
+                                    cx,
+                                )
+                            }
+                        }
+
                         Match::History { path, .. } => {
                             let worktree_id = path.project.worktree_id;
                             if workspace
@@ -1502,6 +1561,10 @@ impl PickerDelegate for FileFinderDelegate {
                 .flex_none()
                 .size(IconSize::Small.rems())
                 .into_any_element(),
+            Match::CreateNew(_) => Icon::new(IconName::Plus)
+                .color(Color::Muted)
+                .size(IconSize::Small)
+                .into_any_element(),
         };
         let (file_name_label, full_path_label) = self.labels_for_match(path_match, window, cx, ix);
 
@@ -1509,7 +1572,7 @@ impl PickerDelegate for FileFinderDelegate {
             if !settings.file_icons {
                 return None;
             }
-            let file_name = path_match.path().file_name()?;
+            let file_name = path_match.path()?.file_name()?;
             let icon = FileIcons::get_icon(file_name.as_ref(), cx)?;
             Some(Icon::from_path(icon).color(Color::Muted))
         });

crates/file_finder/src/file_finder_tests.rs 🔗

@@ -196,7 +196,7 @@ async fn test_matching_paths(cx: &mut TestAppContext) {
 
     cx.simulate_input("bna");
     picker.update(cx, |picker, _| {
-        assert_eq!(picker.delegate.matches.len(), 2);
+        assert_eq!(picker.delegate.matches.len(), 3);
     });
     cx.dispatch_action(SelectNext);
     cx.dispatch_action(Confirm);
@@ -229,7 +229,12 @@ async fn test_matching_paths(cx: &mut TestAppContext) {
         picker.update(cx, |picker, _| {
             assert_eq!(
                 picker.delegate.matches.len(),
-                1,
+                // existence of CreateNew option depends on whether path already exists
+                if bandana_query == util::separator!("a/bandana") {
+                    1
+                } else {
+                    2
+                },
                 "Wrong number of matches for bandana query '{bandana_query}'. Matches: {:?}",
                 picker.delegate.matches
             );
@@ -269,9 +274,9 @@ async fn test_unicode_paths(cx: &mut TestAppContext) {
 
     cx.simulate_input("g");
     picker.update(cx, |picker, _| {
-        assert_eq!(picker.delegate.matches.len(), 1);
+        assert_eq!(picker.delegate.matches.len(), 2);
+        assert_match_at_position(picker, 1, "g");
     });
-    cx.dispatch_action(SelectNext);
     cx.dispatch_action(Confirm);
     cx.read(|cx| {
         let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
@@ -365,13 +370,12 @@ async fn test_complex_path(cx: &mut TestAppContext) {
 
     cx.simulate_input("t");
     picker.update(cx, |picker, _| {
-        assert_eq!(picker.delegate.matches.len(), 1);
+        assert_eq!(picker.delegate.matches.len(), 2);
         assert_eq!(
             collect_search_matches(picker).search_paths_only(),
             vec![PathBuf::from("其他/S数据表格/task.xlsx")],
         )
     });
-    cx.dispatch_action(SelectNext);
     cx.dispatch_action(Confirm);
     cx.read(|cx| {
         let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
@@ -416,8 +420,9 @@ async fn test_row_column_numbers_query_inside_file(cx: &mut TestAppContext) {
         })
         .await;
     picker.update(cx, |finder, _| {
+        assert_match_at_position(finder, 1, &query_inside_file.to_string());
         let finder = &finder.delegate;
-        assert_eq!(finder.matches.len(), 1);
+        assert_eq!(finder.matches.len(), 2);
         let latest_search_query = finder
             .latest_search_query
             .as_ref()
@@ -431,7 +436,6 @@ async fn test_row_column_numbers_query_inside_file(cx: &mut TestAppContext) {
         );
     });
 
-    cx.dispatch_action(SelectNext);
     cx.dispatch_action(Confirm);
 
     let editor = cx.update(|_, cx| workspace.read(cx).active_item_as::<Editor>(cx).unwrap());
@@ -491,8 +495,9 @@ async fn test_row_column_numbers_query_outside_file(cx: &mut TestAppContext) {
         })
         .await;
     picker.update(cx, |finder, _| {
+        assert_match_at_position(finder, 1, &query_outside_file.to_string());
         let delegate = &finder.delegate;
-        assert_eq!(delegate.matches.len(), 1);
+        assert_eq!(delegate.matches.len(), 2);
         let latest_search_query = delegate
             .latest_search_query
             .as_ref()
@@ -506,7 +511,6 @@ async fn test_row_column_numbers_query_outside_file(cx: &mut TestAppContext) {
         );
     });
 
-    cx.dispatch_action(SelectNext);
     cx.dispatch_action(Confirm);
 
     let editor = cx.update(|_, cx| workspace.read(cx).active_item_as::<Editor>(cx).unwrap());
@@ -561,7 +565,8 @@ async fn test_matching_cancellation(cx: &mut TestAppContext) {
         .await;
 
     picker.update(cx, |picker, _cx| {
-        assert_eq!(picker.delegate.matches.len(), 5)
+        // CreateNew option not shown in this case since file already exists
+        assert_eq!(picker.delegate.matches.len(), 5);
     });
 
     picker.update_in(cx, |picker, window, cx| {
@@ -959,7 +964,8 @@ async fn test_search_worktree_without_files(cx: &mut TestAppContext) {
         .await;
     cx.read(|cx| {
         let finder = picker.read(cx);
-        assert_eq!(finder.delegate.matches.len(), 0);
+        assert_eq!(finder.delegate.matches.len(), 1);
+        assert_match_at_position(finder, 0, "dir");
     });
 }
 
@@ -1518,12 +1524,13 @@ async fn test_keep_opened_file_on_top_of_search_results_and_select_next_one(
         })
         .await;
     picker.update(cx, |finder, _| {
-        assert_eq!(finder.delegate.matches.len(), 5);
+        assert_eq!(finder.delegate.matches.len(), 6);
         assert_match_at_position(finder, 0, "main.rs");
         assert_match_selection(finder, 1, "bar.rs");
         assert_match_at_position(finder, 2, "lib.rs");
         assert_match_at_position(finder, 3, "moo.rs");
         assert_match_at_position(finder, 4, "maaa.rs");
+        assert_match_at_position(finder, 5, ".rs");
     });
 
     // main.rs is not among matches, select top item
@@ -1533,9 +1540,10 @@ async fn test_keep_opened_file_on_top_of_search_results_and_select_next_one(
         })
         .await;
     picker.update(cx, |finder, _| {
-        assert_eq!(finder.delegate.matches.len(), 2);
+        assert_eq!(finder.delegate.matches.len(), 3);
         assert_match_at_position(finder, 0, "bar.rs");
         assert_match_at_position(finder, 1, "lib.rs");
+        assert_match_at_position(finder, 2, "b");
     });
 
     // main.rs is back, put it on top and select next item
@@ -1545,10 +1553,11 @@ async fn test_keep_opened_file_on_top_of_search_results_and_select_next_one(
         })
         .await;
     picker.update(cx, |finder, _| {
-        assert_eq!(finder.delegate.matches.len(), 3);
+        assert_eq!(finder.delegate.matches.len(), 4);
         assert_match_at_position(finder, 0, "main.rs");
         assert_match_selection(finder, 1, "moo.rs");
         assert_match_at_position(finder, 2, "maaa.rs");
+        assert_match_at_position(finder, 3, "m");
     });
 
     // get back to the initial state
@@ -1623,12 +1632,13 @@ async fn test_setting_auto_select_first_and_select_active_file(cx: &mut TestAppC
         })
         .await;
     picker.update(cx, |finder, _| {
-        assert_eq!(finder.delegate.matches.len(), 5);
+        assert_eq!(finder.delegate.matches.len(), 6);
         assert_match_selection(finder, 0, "main.rs");
         assert_match_at_position(finder, 1, "bar.rs");
         assert_match_at_position(finder, 2, "lib.rs");
         assert_match_at_position(finder, 3, "moo.rs");
         assert_match_at_position(finder, 4, "maaa.rs");
+        assert_match_at_position(finder, 5, ".rs");
     });
 }
 
@@ -1679,12 +1689,13 @@ async fn test_non_separate_history_items(cx: &mut TestAppContext) {
         })
         .await;
     picker.update(cx, |finder, _| {
-        assert_eq!(finder.delegate.matches.len(), 5);
+        assert_eq!(finder.delegate.matches.len(), 6);
         assert_match_at_position(finder, 0, "main.rs");
         assert_match_selection(finder, 1, "moo.rs");
         assert_match_at_position(finder, 2, "bar.rs");
         assert_match_at_position(finder, 3, "lib.rs");
         assert_match_at_position(finder, 4, "maaa.rs");
+        assert_match_at_position(finder, 5, ".rs");
     });
 
     // main.rs is not among matches, select top item
@@ -1694,9 +1705,10 @@ async fn test_non_separate_history_items(cx: &mut TestAppContext) {
         })
         .await;
     picker.update(cx, |finder, _| {
-        assert_eq!(finder.delegate.matches.len(), 2);
+        assert_eq!(finder.delegate.matches.len(), 3);
         assert_match_at_position(finder, 0, "bar.rs");
         assert_match_at_position(finder, 1, "lib.rs");
+        assert_match_at_position(finder, 2, "b");
     });
 
     // main.rs is back, put it on top and select next item
@@ -1706,10 +1718,11 @@ async fn test_non_separate_history_items(cx: &mut TestAppContext) {
         })
         .await;
     picker.update(cx, |finder, _| {
-        assert_eq!(finder.delegate.matches.len(), 3);
+        assert_eq!(finder.delegate.matches.len(), 4);
         assert_match_at_position(finder, 0, "main.rs");
         assert_match_selection(finder, 1, "moo.rs");
         assert_match_at_position(finder, 2, "maaa.rs");
+        assert_match_at_position(finder, 3, "m");
     });
 
     // get back to the initial state
@@ -1965,9 +1978,10 @@ async fn test_search_results_refreshed_on_worktree_updates(cx: &mut gpui::TestAp
     let picker = open_file_picker(&workspace, cx);
     cx.simulate_input("rs");
     picker.update(cx, |finder, _| {
-        assert_eq!(finder.delegate.matches.len(), 2);
+        assert_eq!(finder.delegate.matches.len(), 3);
         assert_match_at_position(finder, 0, "lib.rs");
         assert_match_at_position(finder, 1, "main.rs");
+        assert_match_at_position(finder, 2, "rs");
     });
 
     // Delete main.rs
@@ -1980,8 +1994,9 @@ async fn test_search_results_refreshed_on_worktree_updates(cx: &mut gpui::TestAp
 
     // main.rs is in not among search results anymore
     picker.update(cx, |finder, _| {
-        assert_eq!(finder.delegate.matches.len(), 1);
+        assert_eq!(finder.delegate.matches.len(), 2);
         assert_match_at_position(finder, 0, "lib.rs");
+        assert_match_at_position(finder, 1, "rs");
     });
 
     // Create util.rs
@@ -1994,9 +2009,10 @@ async fn test_search_results_refreshed_on_worktree_updates(cx: &mut gpui::TestAp
 
     // util.rs is among search results
     picker.update(cx, |finder, _| {
-        assert_eq!(finder.delegate.matches.len(), 2);
+        assert_eq!(finder.delegate.matches.len(), 3);
         assert_match_at_position(finder, 0, "lib.rs");
         assert_match_at_position(finder, 1, "util.rs");
+        assert_match_at_position(finder, 2, "rs");
     });
 }
 
@@ -2036,9 +2052,10 @@ async fn test_search_results_refreshed_on_adding_and_removing_worktrees(
     let picker = open_file_picker(&workspace, cx);
     cx.simulate_input("rs");
     picker.update(cx, |finder, _| {
-        assert_eq!(finder.delegate.matches.len(), 2);
+        assert_eq!(finder.delegate.matches.len(), 3);
         assert_match_at_position(finder, 0, "bar.rs");
         assert_match_at_position(finder, 1, "lib.rs");
+        assert_match_at_position(finder, 2, "rs");
     });
 
     // Add new worktree
@@ -2054,10 +2071,11 @@ async fn test_search_results_refreshed_on_adding_and_removing_worktrees(
 
     // main.rs is among search results
     picker.update(cx, |finder, _| {
-        assert_eq!(finder.delegate.matches.len(), 3);
+        assert_eq!(finder.delegate.matches.len(), 4);
         assert_match_at_position(finder, 0, "bar.rs");
         assert_match_at_position(finder, 1, "lib.rs");
         assert_match_at_position(finder, 2, "main.rs");
+        assert_match_at_position(finder, 3, "rs");
     });
 
     // Remove the first worktree
@@ -2068,8 +2086,9 @@ async fn test_search_results_refreshed_on_adding_and_removing_worktrees(
 
     // Files from the first worktree are not in the search results anymore
     picker.update(cx, |finder, _| {
-        assert_eq!(finder.delegate.matches.len(), 1);
+        assert_eq!(finder.delegate.matches.len(), 2);
         assert_match_at_position(finder, 0, "main.rs");
+        assert_match_at_position(finder, 1, "rs");
     });
 }
 
@@ -2414,7 +2433,7 @@ async fn test_repeat_toggle_action(cx: &mut gpui::TestAppContext) {
     cx.run_until_parked();
 
     picker.update(cx, |picker, _| {
-        assert_eq!(picker.delegate.matches.len(), 6);
+        assert_eq!(picker.delegate.matches.len(), 7);
         assert_eq!(picker.delegate.selected_index, 0);
     });
 
@@ -2426,7 +2445,7 @@ async fn test_repeat_toggle_action(cx: &mut gpui::TestAppContext) {
     cx.run_until_parked();
 
     picker.update(cx, |picker, _| {
-        assert_eq!(picker.delegate.matches.len(), 6);
+        assert_eq!(picker.delegate.matches.len(), 7);
         assert_eq!(picker.delegate.selected_index, 3);
     });
 }
@@ -2468,7 +2487,7 @@ async fn open_queried_buffer(
     let history_items = picker.update(cx, |finder, _| {
         assert_eq!(
             finder.delegate.matches.len(),
-            expected_matches,
+            expected_matches + 1, // +1 from CreateNew option
             "Unexpected number of matches found for query `{input}`, matches: {:?}",
             finder.delegate.matches
         );
@@ -2617,6 +2636,7 @@ fn collect_search_matches(picker: &Picker<FileFinderDelegate>) -> SearchEntries
                     .push(Path::new(path_match.0.path_prefix.as_ref()).join(&path_match.0.path));
                 search_entries.search_matches.push(path_match.0.clone());
             }
+            Match::CreateNew(_) => {}
         }
     }
     search_entries
@@ -2650,6 +2670,7 @@ fn assert_match_at_position(
     let match_file_name = match &match_item {
         Match::History { path, .. } => path.absolute.as_deref().unwrap().file_name(),
         Match::Search(path_match) => path_match.0.path.file_name(),
+        Match::CreateNew(project_path) => project_path.path.file_name(),
     }
     .unwrap()
     .to_string_lossy();