From 7a780fe61dc68c0fd6a7ade218097048a207cace Mon Sep 17 00:00:00 2001 From: init05 <76530448+init05@users.noreply.github.com> Date: Mon, 1 Jun 2026 06:22:11 +0200 Subject: [PATCH] file_finder: Allow opening files without dismissing the finder (#57258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #4864 Adds a `file_finder::OpenWithoutDismiss` action that opens the selected file without closing the finder, so multiple files can be opened in one session. Pressing right arrow when the cursor is at the end of the search query triggers it — matching VS Code and Sublime Text behaviour. A "Keep Open" button in the footer provides mouse access and serves as a discoverability hint. Each opened file becomes the active tab without stealing keyboard focus from the finder, preserving the search state between opens. Refactors the existing `confirm` implementation into a shared `open_selected_file` helper parameterised by `dismiss_after_open` to eliminate duplicated file-opening logic. ## What's different from PR #38914 - Uses `right` arrow at `end_of_input` context instead of `cmd-shift-enter` (better ergonomics, matches VS Code/Sublime — the `end_of_input` context that was added after that PR makes this possible) - Each opened file is activated as the current tab (`activate: true`, `focus_item: false`), giving visual feedback on every open without stealing focus from the modal - Cleaner refactor: single `open_selected_file` helper instead of duplicated logic Release Notes: - Added ability to open files from the file finder without dismissing it, using right arrow at end of input or the "Keep Open" footer button --------- Co-authored-by: Christopher Biscardi --- assets/keymaps/default-linux.json | 6 + assets/keymaps/default-macos.json | 6 + assets/keymaps/default-windows.json | 6 + crates/file_finder/src/file_finder.rs | 333 +++++++++++--------- crates/file_finder/src/file_finder_tests.rs | 183 +++++++++++ 5 files changed, 390 insertions(+), 144 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 47bd6f05ac..5fa5c4f575 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1157,6 +1157,12 @@ "ctrl-shift-i": "file_finder::ToggleFilterMenu", }, }, + { + "context": "FileFinder > Picker > Editor && end_of_input", + "bindings": { + "right": "file_finder::OpenWithoutDismiss", + }, + }, { "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", "bindings": { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index dbd6d64a71..79b7263639 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1210,6 +1210,12 @@ "cmd-shift-i": "file_finder::ToggleFilterMenu", }, }, + { + "context": "FileFinder > Picker > Editor && end_of_input", + "bindings": { + "right": "file_finder::OpenWithoutDismiss", + }, + }, { "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", "use_key_equivalents": true, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 1996fe19f6..1a16a7cc27 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -1164,6 +1164,12 @@ "ctrl-shift-i": "file_finder::ToggleFilterMenu", }, }, + { + "context": "FileFinder > Picker > Editor && end_of_input", + "bindings": { + "right": "file_finder::OpenWithoutDismiss", + }, + }, { "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", "use_key_equivalents": true, diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 50f0a99abd..cc810b6ac2 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -62,7 +62,10 @@ actions!( /// Toggles the file filter menu. ToggleFilterMenu, /// Toggles the split direction menu. - ToggleSplitMenu + ToggleSplitMenu, + /// Opens the selected file in the editor without dismissing the file finder, + /// so additional files can be opened in sequence. + OpenWithoutDismiss ] ); @@ -348,6 +351,17 @@ impl FileFinder { }) } + fn open_without_dismiss( + &mut self, + _: &OpenWithoutDismiss, + window: &mut Window, + cx: &mut Context, + ) { + self.picker.update(cx, |picker, cx| { + picker.delegate.confirm_without_dismiss(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()) } } @@ -1457,6 +1472,164 @@ impl FileFinderDelegate { } key_context } + + /// Shared file-opening logic for both `confirm` and `confirm_without_dismiss`. + /// + /// When `dismiss_after_open` is true this behaves like a normal confirm: the file is focused + /// and the finder is dismissed. When false the finder stays open so the user can continue + /// opening more files. + fn open_selected_file( + &mut self, + secondary: bool, + dismiss_after_open: bool, + window: &mut Window, + cx: &mut Context>, + ) { + let Some(m) = self.matches.get(self.selected_index()).cloned() else { + return; + }; + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + + // Channel matches always dismiss the finder. + if let Match::Channel { channel_id, .. } = &m { + let channel_id = channel_id.0; + let finder = self.file_finder.clone(); + window.dispatch_action(OpenChannelNotesById { channel_id }.boxed_clone(), cx); + finder.update(cx, |_, cx| cx.emit(DismissEvent)).log_err(); + return; + } + + // Focus the new item only when dismissing — this avoids stealing focus from the modal. + // Always activate (make the tab current) so every opened file is visually reflected. + let focus_item = dismiss_after_open; + + 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, + true, + window, + cx, + ) + } + }; + + match &m { + Match::CreateNew(project_path) => { + if secondary { + workspace.split_path_preview(project_path.clone(), false, None, window, cx) + } else { + workspace.open_path_preview( + project_path.clone(), + None, + focus_item, + 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(path_match) => split_or_open( + workspace, + ProjectPath { + worktree_id: WorktreeId::from_usize(path_match.0.worktree_id), + path: path_match.0.path.clone(), + }, + window, + cx, + ), + Match::Channel { .. } => unreachable!("handled above"), + } + }); + + let selection_query = self.latest_search_query.clone(); + let finder = self.file_finder.clone(); + let workspace = self.workspace.clone(); + + cx.spawn_in(window, async move |_, mut cx| { + let item = open_task + .await + .notify_workspace_async_err(workspace, &mut cx)?; + if let Some(active_editor) = item.downcast::() { + active_editor + .downgrade() + .update_in(cx, |editor, window, cx| { + let Some(buffer) = editor.buffer().read(cx).as_singleton() else { + return; + }; + let buffer_snapshot = buffer.read(cx).snapshot(); + let Some(selection_query) = selection_query.as_ref() else { + return; + }; + let Some(selection_range) = + selection_query.selection_range(&buffer_snapshot) + else { + return; + }; + editor.go_to_singleton_buffer_range(selection_range, window, cx); + }) + .log_err(); + } + if dismiss_after_open { + finder.update(cx, |_, cx| cx.emit(DismissEvent)).ok()?; + } + Some(()) + }) + .detach(); + } + + fn confirm_without_dismiss( + &mut self, + window: &mut Window, + cx: &mut Context>, + ) { + self.open_selected_file(false, false, window, cx); + } } fn full_path_budget( @@ -1611,149 +1784,7 @@ 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() - { - // Channel matches are handled separately since they dispatch an action - // rather than directly opening a file path. - if let Match::Channel { channel_id, .. } = m { - let channel_id = channel_id.0; - let finder = self.file_finder.clone(); - window.dispatch_action(OpenChannelNotesById { channel_id }.boxed_clone(), cx); - finder.update(cx, |_, cx| cx.emit(DismissEvent)).log_err(); - return; - } - - 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, - ), - Match::Channel { .. } => unreachable!("handled above"), - } - }); - - let selection_query = self.latest_search_query.clone(); - let finder = self.file_finder.clone(); - let workspace = self.workspace.clone(); - - cx.spawn_in(window, async move |_, mut cx| { - let item = open_task - .await - .notify_workspace_async_err(workspace, &mut cx)?; - if let Some(active_editor) = item.downcast::() { - active_editor - .downgrade() - .update_in(cx, |editor, window, cx| { - let Some(buffer) = editor.buffer().read(cx).as_singleton() else { - return; - }; - let buffer_snapshot = buffer.read(cx).snapshot(); - let Some(selection_query) = selection_query.as_ref() else { - return; - }; - let Some(selection_range) = - selection_query.selection_range(&buffer_snapshot) - else { - return; - }; - editor.go_to_singleton_buffer_range(selection_range, window, cx); - }) - .log_err(); - } - finder.update(cx, |_, cx| cx.emit(DismissEvent)).ok()?; - - Some(()) - }) - .detach(); - } + self.open_selected_file(secondary, true, window, cx); } fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { @@ -1979,6 +2010,20 @@ impl PickerDelegate for FileFinderDelegate { } }), ) + .child( + Button::new("open-without-dismiss", "Keep Open") + .key_binding( + KeyBinding::for_action_in( + &OpenWithoutDismiss, + &focus_handle, + 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 42466134f8..5667f03f01 100644 --- a/crates/file_finder/src/file_finder_tests.rs +++ b/crates/file_finder/src/file_finder_tests.rs @@ -3923,6 +3923,189 @@ async fn test_repeat_toggle_action(cx: &mut gpui::TestAppContext) { }); } +#[gpui::test] +async fn test_open_without_dismiss_keeps_finder_open(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", + "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); + + cx.simulate_input("file"); + cx.run_until_parked(); + picker.update(cx, |picker, _| { + assert!( + picker.delegate.matches.len() >= 3, + "Expected at least 3 matches for 'file', got {}", + picker.delegate.matches.len() + ); + }); + + cx.dispatch_action(OpenWithoutDismiss); + cx.run_until_parked(); + + // Finder must still be visible after opening a file without dismiss. + workspace.update(cx, |workspace, cx| { + assert!( + workspace.active_modal::(cx).is_some(), + "File finder should remain open after OpenWithoutDismiss" + ); + }); + + // Exactly one file was opened in the pane. + cx.read(|cx| { + let items: Vec<_> = workspace.read(cx).active_pane().read(cx).items().collect(); + assert_eq!(items.len(), 1, "One file should be open in the pane"); + }); + + // The search query and results are preserved so the user can continue browsing. + picker.update(cx, |picker, _| { + assert!( + picker.delegate.matches.len() >= 3, + "Search results should remain unchanged after OpenWithoutDismiss" + ); + }); +} + +#[gpui::test] +async fn test_open_without_dismiss_opens_multiple_files(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree( + path!("/root"), + json!({ + "a": { + "alpha.txt": "alpha", + "beta.txt": "beta", + "gamma.txt": "gamma", + } + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; + let (_picker, workspace, cx) = build_find_picker(project, cx); + + cx.simulate_input("a"); + cx.run_until_parked(); + + // Open the first match and stay in the finder. + cx.dispatch_action(OpenWithoutDismiss); + cx.run_until_parked(); + + workspace.update(cx, |workspace, cx| { + assert!( + workspace.active_modal::(cx).is_some(), + "Finder should remain open after first OpenWithoutDismiss" + ); + }); + cx.read(|cx| { + let pane = workspace.read(cx).active_pane().read(cx); + assert_eq!( + pane.items().count(), + 1, + "One file open after first OpenWithoutDismiss" + ); + }); + + // Navigate to the next result and open it too. + cx.dispatch_action(SelectNext); + cx.dispatch_action(OpenWithoutDismiss); + cx.run_until_parked(); + + workspace.update(cx, |workspace, cx| { + assert!( + workspace.active_modal::(cx).is_some(), + "Finder should remain open after second OpenWithoutDismiss" + ); + }); + cx.read(|cx| { + let pane = workspace.read(cx).active_pane().read(cx); + assert_eq!( + pane.items().count(), + 2, + "Two files open after second OpenWithoutDismiss" + ); + // The second opened file should now be the active tab. + let active_index = pane.active_item_index(); + assert_eq!(active_index, 1, "Second file should be the active tab"); + }); +} + +#[gpui::test] +async fn test_open_without_dismiss_then_confirm_closes_finder(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree( + path!("/root"), + json!({ + "a": { + "first.txt": "first", + "second.txt": "second", + } + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; + let (picker, workspace, cx) = build_find_picker(project, cx); + + cx.simulate_input("t"); + cx.run_until_parked(); + picker.update(cx, |picker, _| { + assert!(picker.delegate.matches.len() >= 2); + }); + + // Open first file, keep finder open. + cx.dispatch_action(OpenWithoutDismiss); + cx.run_until_parked(); + + workspace.update(cx, |workspace, cx| { + assert!(workspace.active_modal::(cx).is_some()); + }); + + // Navigate to the next match and confirm normally — this should close the finder. + cx.dispatch_action(SelectNext); + cx.dispatch_action(Confirm); + cx.run_until_parked(); + + workspace.update(cx, |workspace, cx| { + assert!( + workspace.active_modal::(cx).is_none(), + "Finder should be closed after regular Confirm" + ); + }); + + // Two files were opened in total, with the confirmed one now active. + cx.read(|cx| { + let pane = workspace.read(cx).active_pane().read(cx); + assert_eq!(pane.items().count(), 2, "Two files should be open total"); + let active_editor = workspace.read(cx).active_item_as::(cx).unwrap(); + let title = active_editor.read(cx).title(cx); + assert!( + title == "second.txt" || title == "first.txt", + "Active editor should be one of the opened files, got: {title}" + ); + }); +} + async fn open_close_queried_buffer( input: &str, expected_matches: usize,