file_finder: Allow opening files without dismissing the finder (#57258)

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 <chris@christopherbiscardi.com>
This commit is contained in:
init05andChristopher Biscardi authored and GitHub committed 2026-06-01 04:22:11 +00:00
1 parent 6263155419
commit 7a780fe61d
5 files changed
+390 -144

No files matched your search

+6
View File
@@ -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": {
+6
View File
@@ -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,
+6
View File
@@ -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,
+189 -144
View File
@@ -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>,
) {
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<Picker<FileFinderDelegate>>,
) {
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<Workspace>| {
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::<Editor>() {
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<Picker<FileFinderDelegate>>,
) {
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<Picker<FileFinderDelegate>>,
) {
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<Workspace>| {
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::<Editor>() {
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<Picker<FileFinderDelegate>>) {
@@ -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(
+183
View File
@@ -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::<FileFinder>(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::<FileFinder>(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::<FileFinder>(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::<FileFinder>(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::<FileFinder>(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::<Editor>(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,