diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 349c980e8bb0aa..8ae42c8d281f9c 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.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)", "bindings": { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 23fd201b0d2cce..c558919ba7859f 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1218,6 +1218,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 3eece808cc60ca..abc625be497497 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -1172,6 +1172,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 50f0a99abdc87c..cc810b6ac25658 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 a9d67dd31aaa02..3bbb8d0009c37c 100644 --- a/crates/file_finder/src/file_finder_tests.rs +++ b/crates/file_finder/src/file_finder_tests.rs @@ -3922,6 +3922,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,