From de225fd242a45abd49b409649cfc31dea92448e0 Mon Sep 17 00:00:00 2001 From: Daniel Zhu Date: Tue, 3 Jun 2025 10:44:57 -0700 Subject: [PATCH] file_finder: Add option to create new file (#31567) 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. --- crates/file_finder/src/file_finder.rs | 73 +++++++++++++++++-- crates/file_finder/src/file_finder_tests.rs | 77 +++++++++++++-------- 2 files changed, 117 insertions(+), 33 deletions(-) diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index bb49d7c147..05780bffa6 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/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, }, Search(ProjectPanelOrdMatch), + CreateNew(ProjectPath), } impl Match { - fn path(&self) -> &Arc { + fn path(&self) -> Option<&Arc> { 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)) }); diff --git a/crates/file_finder/src/file_finder_tests.rs b/crates/file_finder/src/file_finder_tests.rs index 43e86e900b..b0ac6e60f5 100644 --- a/crates/file_finder/src/file_finder_tests.rs +++ b/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::(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::(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::(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::(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) -> 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();