From 517a8811c8c9a770e68488f72758d1d43064865e Mon Sep 17 00:00:00 2001 From: Steven Chanin Date: Thu, 25 Sep 2025 12:46:47 -0700 Subject: [PATCH 1/2] Allow file finder to open a file without dismissing the finder --- assets/keymaps/default-linux.json | 3 +- assets/keymaps/default-macos.json | 3 +- assets/keymaps/default-windows.json | 3 +- crates/file_finder/src/file_finder.rs | 319 ++++++++++++-------- crates/file_finder/src/file_finder_tests.rs | 56 ++++ crates/picker/src/picker.rs | 9 + 6 files changed, 261 insertions(+), 132 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index ceb25c18351720..1e7ae78a2898f6 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1053,7 +1053,8 @@ "bindings": { "ctrl-p": "file_finder::Toggle", "ctrl-shift-a": "file_finder::ToggleSplitMenu", - "ctrl-shift-i": "file_finder::ToggleFilterMenu" + "ctrl-shift-i": "file_finder::ToggleFilterMenu", + "ctrl-shift-enter": "file_finder::OpenWithoutDismiss" } }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 4cf25c3b71047b..d0a82b02751b61 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1124,7 +1124,8 @@ "use_key_equivalents": true, "bindings": { "cmd-shift-a": "file_finder::ToggleSplitMenu", - "cmd-shift-i": "file_finder::ToggleFilterMenu" + "cmd-shift-i": "file_finder::ToggleFilterMenu", + "cmd-shift-enter": "file_finder::OpenWithoutDismiss" } }, { diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index c1f8c2487a8512..dc97f402fcf11e 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -1065,7 +1065,8 @@ "bindings": { "ctrl-p": "file_finder::Toggle", "ctrl-shift-a": "file_finder::ToggleSplitMenu", - "ctrl-shift-i": "file_finder::ToggleFilterMenu" + "ctrl-shift-i": "file_finder::ToggleFilterMenu", + "ctrl-shift-enter": "file_finder::OpenWithoutDismiss" } }, { diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 4cc7f1d664c8c7..20761dbfc2da17 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -58,7 +58,9 @@ actions!( /// Toggles the file filter menu. ToggleFilterMenu, /// Toggles the split direction menu. - ToggleSplitMenu + ToggleSplitMenu, + /// Opens the selected file without dismissing the file finder. + OpenWithoutDismiss ] ); @@ -348,6 +350,18 @@ impl FileFinder { }) } + fn open_without_dismiss( + &mut self, + _: &OpenWithoutDismiss, + window: &mut Window, + cx: &mut Context, + ) { + self.picker.update(cx, |picker, cx| { + let delegate = &mut picker.delegate; + delegate.confirm_without_dismiss(false, window, cx); + }); + } + pub fn modal_max_width(width_setting: FileFinderWidth, window: &mut Window) -> Pixels { let window_width = window.viewport_size().width; let small_width = rems(34.).to_pixels(window.rem_size()); @@ -389,6 +403,7 @@ impl Render for FileFinder { .on_action(cx.listener(Self::go_to_file_split_right)) .on_action(cx.listener(Self::go_to_file_split_up)) .on_action(cx.listener(Self::go_to_file_split_down)) + .on_action(cx.listener(Self::open_without_dismiss)) .child(self.picker.clone()) } } @@ -1255,6 +1270,156 @@ impl FileFinderDelegate { } key_context } + + /// Helper function to open a file with configurable behavior for focus and dismissal + fn open_selected_file( + &mut self, + secondary: bool, + focus_item: bool, + activate_item: bool, + dismiss_after_open: bool, + window: &mut Window, + cx: &mut Context>, + ) { + if let Some(m) = self.matches.get(self.selected_index()) + && let Some(workspace) = self.workspace.upgrade() + { + let open_task = workspace.update(cx, |workspace, cx| { + let split_or_open = + |workspace: &mut Workspace, + project_path, + window: &mut Window, + cx: &mut Context| { + let allow_preview = + PreviewTabsSettings::get_global(cx).enable_preview_from_file_finder; + if secondary { + workspace.split_path_preview( + project_path, + allow_preview, + None, + window, + cx, + ) + } else { + workspace.open_path_preview( + project_path, + None, + focus_item, + allow_preview, + activate_item, + window, + cx, + ) + } + }; + + match &m { + Match::CreateNew(project_path) => { + // Create a new file with the given filename + workspace.open_path_preview( + project_path.clone(), + None, + focus_item, + false, + activate_item, + window, + cx, + ) + } + + Match::History { path, .. } => { + let worktree_id = path.project.worktree_id; + if workspace + .project() + .read(cx) + .worktree_for_id(worktree_id, cx) + .is_some() + { + split_or_open( + workspace, + ProjectPath { + worktree_id, + path: Arc::clone(&path.project.path), + }, + window, + cx, + ) + } else if secondary { + workspace.split_abs_path(path.absolute.clone(), false, window, cx) + } else { + workspace.open_abs_path( + path.absolute.clone(), + OpenOptions { + visible: Some(OpenVisible::None), + ..Default::default() + }, + window, + cx, + ) + } + } + Match::Search(m) => split_or_open( + workspace, + ProjectPath { + worktree_id: WorktreeId::from_usize(m.0.worktree_id), + path: m.0.path.clone(), + }, + window, + cx, + ), + } + }); + + let row = self + .latest_search_query + .as_ref() + .and_then(|query| query.path_position.row) + .map(|row| row.saturating_sub(1)); + let col = self + .latest_search_query + .as_ref() + .and_then(|query| query.path_position.column) + .unwrap_or(0) + .saturating_sub(1); + + if dismiss_after_open { + let finder = self.file_finder.clone(); + cx.spawn_in(window, async move |_, cx| { + let item = open_task.await.notify_async_err(cx)?; + if let Some(row) = row + && let Some(active_editor) = item.downcast::() + { + active_editor + .downgrade() + .update_in(cx, |editor, window, cx| { + editor.go_to_singleton_buffer_point(Point::new(row, col), window, cx); + }) + .log_err(); + } + finder.update(cx, |_, cx| cx.emit(DismissEvent)).ok()?; + Some(()) + }) + .detach(); + } else { + cx.spawn_in(window, async move |_, cx| { + let item = open_task.await.notify_async_err(cx)?; + if let Some(row) = row + && let Some(active_editor) = item.downcast::() + { + active_editor + .downgrade() + .update_in(cx, |editor, window, cx| { + editor.go_to_singleton_buffer_point(Point::new(row, col), window, cx); + }) + .log_err(); + } + // Note: We intentionally do NOT emit DismissEvent here to keep the finder open + Some(()) + }) + .detach(); + } + } + } } fn full_path_budget( @@ -1401,135 +1566,16 @@ impl PickerDelegate for FileFinderDelegate { window: &mut Window, cx: &mut Context>, ) { - if let Some(m) = self.matches.get(self.selected_index()) - && let Some(workspace) = self.workspace.upgrade() - { - let open_task = workspace.update(cx, |workspace, cx| { - let split_or_open = - |workspace: &mut Workspace, - project_path, - window: &mut Window, - cx: &mut Context| { - let allow_preview = - PreviewTabsSettings::get_global(cx).enable_preview_from_file_finder; - if secondary { - workspace.split_path_preview( - project_path, - allow_preview, - None, - window, - cx, - ) - } else { - workspace.open_path_preview( - project_path, - None, - true, - allow_preview, - true, - window, - cx, - ) - } - }; - 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 - .project() - .read(cx) - .worktree_for_id(worktree_id, cx) - .is_some() - { - split_or_open( - workspace, - ProjectPath { - worktree_id, - path: Arc::clone(&path.project.path), - }, - window, - cx, - ) - } else if secondary { - workspace.split_abs_path(path.absolute.clone(), false, window, cx) - } else { - workspace.open_abs_path( - path.absolute.clone(), - OpenOptions { - visible: Some(OpenVisible::None), - ..Default::default() - }, - window, - cx, - ) - } - } - Match::Search(m) => split_or_open( - workspace, - ProjectPath { - worktree_id: WorktreeId::from_usize(m.0.worktree_id), - path: m.0.path.clone(), - }, - window, - cx, - ), - } - }); - - let row = self - .latest_search_query - .as_ref() - .and_then(|query| query.path_position.row) - .map(|row| row.saturating_sub(1)); - let col = self - .latest_search_query - .as_ref() - .and_then(|query| query.path_position.column) - .unwrap_or(0) - .saturating_sub(1); - let finder = self.file_finder.clone(); - - cx.spawn_in(window, async move |_, cx| { - let item = open_task.await.notify_async_err(cx)?; - if let Some(row) = row - && let Some(active_editor) = item.downcast::() - { - active_editor - .downgrade() - .update_in(cx, |editor, window, cx| { - editor.go_to_singleton_buffer_point(Point::new(row, col), window, cx); - }) - .log_err(); - } - finder.update(cx, |_, cx| cx.emit(DismissEvent)).ok()?; + self.open_selected_file(secondary, true, true, true, window, cx); + } - Some(()) - }) - .detach(); - } + fn confirm_without_dismiss( + &mut self, + secondary: bool, + window: &mut Window, + cx: &mut Context>, + ) { + self.open_selected_file(secondary, false, false, false, window, cx); } fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { @@ -1716,6 +1762,21 @@ impl PickerDelegate for FileFinderDelegate { } }), ) + .child( + Button::new("open-without-dismiss", "Open (Keep Open)") + .key_binding( + KeyBinding::for_action_in( + &OpenWithoutDismiss, + &focus_handle, + window, + cx, + ) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(|_, window, cx| { + window.dispatch_action(OpenWithoutDismiss.boxed_clone(), cx) + }), + ) .child( Button::new("open-selection", "Open") .key_binding( diff --git a/crates/file_finder/src/file_finder_tests.rs b/crates/file_finder/src/file_finder_tests.rs index 75b2101101bcdd..c35cd9db88389d 100644 --- a/crates/file_finder/src/file_finder_tests.rs +++ b/crates/file_finder/src/file_finder_tests.rs @@ -2897,6 +2897,62 @@ fn assert_match_selection( assert_match_at_position(finder, expected_selection_index, expected_file_name); } +#[gpui::test] +async fn test_open_without_dismiss(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree( + path!("/root"), + json!({ + "a": { + "file1.txt": "content1", + "file2.txt": "content2", + "b": { + "file3.txt": "content3", + }, + } + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; + let (picker, workspace, cx) = build_find_picker(project, cx); + + // Search for file1 and open it without dismissing + cx.simulate_input("file1"); + picker.update(cx, |picker, _| { + assert!(!picker.delegate.matches.matches.is_empty(), "Should have matches for file1"); + assert_match_at_position(picker, 0, "file1.txt"); + }); + + // Use the OpenWithoutDismiss action + cx.dispatch_action(crate::OpenWithoutDismiss); + + // Verify that a file was opened (in background) + cx.read(|cx| { + let pane = workspace.read(cx).active_pane().read(cx); + let items: Vec<_> = pane.items().collect(); + assert!(!items.is_empty(), "Should have opened at least one item"); + }); + + // Verify that the file finder is still open (most important test) + cx.read(|cx| { + let file_finder = workspace.read(cx).active_modal::(cx); + assert!(file_finder.is_some(), "File finder should still be open after OpenWithoutDismiss"); + }); + + // Now test the regular confirm to ensure it still dismisses + cx.dispatch_action(Confirm); + + // Verify that the file finder is now closed + cx.read(|cx| { + let file_finder = workspace.read(cx).active_modal::(cx); + assert!(file_finder.is_none(), "File finder should be dismissed after regular confirm"); + }); +} + #[track_caller] fn assert_match_at_position( finder: &Picker, diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index 247fcbdd875ffc..2cb7ff89fe96df 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -149,6 +149,15 @@ pub trait PickerDelegate: Sized + 'static { None } fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>); + /// Confirms the selected item without dismissing the picker + fn confirm_without_dismiss( + &mut self, + _secondary: bool, + _window: &mut Window, + _cx: &mut Context>, + ) { + // Default implementation does nothing + } /// Instead of interacting with currently selected entry, treats editor input literally, /// performing some kind of action on it. fn confirm_input( From 56e4d17784f8a13e170ffe63fee6dc0121d96585 Mon Sep 17 00:00:00 2001 From: Steven Chanin Date: Tue, 7 Oct 2025 13:15:04 -0700 Subject: [PATCH 2/2] Fix formatting issues --- crates/file_finder/src/file_finder.rs | 12 ++++++++++-- crates/file_finder/src/file_finder_tests.rs | 15 ++++++++++++--- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 20761dbfc2da17..41bdd2aece5c3e 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -1392,7 +1392,11 @@ impl FileFinderDelegate { active_editor .downgrade() .update_in(cx, |editor, window, cx| { - editor.go_to_singleton_buffer_point(Point::new(row, col), window, cx); + editor.go_to_singleton_buffer_point( + Point::new(row, col), + window, + cx, + ); }) .log_err(); } @@ -1409,7 +1413,11 @@ impl FileFinderDelegate { active_editor .downgrade() .update_in(cx, |editor, window, cx| { - editor.go_to_singleton_buffer_point(Point::new(row, col), window, cx); + editor.go_to_singleton_buffer_point( + Point::new(row, col), + window, + cx, + ); }) .log_err(); } diff --git a/crates/file_finder/src/file_finder_tests.rs b/crates/file_finder/src/file_finder_tests.rs index c35cd9db88389d..0d09381a875243 100644 --- a/crates/file_finder/src/file_finder_tests.rs +++ b/crates/file_finder/src/file_finder_tests.rs @@ -2923,7 +2923,10 @@ async fn test_open_without_dismiss(cx: &mut TestAppContext) { // Search for file1 and open it without dismissing cx.simulate_input("file1"); picker.update(cx, |picker, _| { - assert!(!picker.delegate.matches.matches.is_empty(), "Should have matches for file1"); + assert!( + !picker.delegate.matches.matches.is_empty(), + "Should have matches for file1" + ); assert_match_at_position(picker, 0, "file1.txt"); }); @@ -2940,7 +2943,10 @@ async fn test_open_without_dismiss(cx: &mut TestAppContext) { // Verify that the file finder is still open (most important test) cx.read(|cx| { let file_finder = workspace.read(cx).active_modal::(cx); - assert!(file_finder.is_some(), "File finder should still be open after OpenWithoutDismiss"); + assert!( + file_finder.is_some(), + "File finder should still be open after OpenWithoutDismiss" + ); }); // Now test the regular confirm to ensure it still dismisses @@ -2949,7 +2955,10 @@ async fn test_open_without_dismiss(cx: &mut TestAppContext) { // Verify that the file finder is now closed cx.read(|cx| { let file_finder = workspace.read(cx).active_modal::(cx); - assert!(file_finder.is_none(), "File finder should be dismissed after regular confirm"); + assert!( + file_finder.is_none(), + "File finder should be dismissed after regular confirm" + ); }); }