Add fuzzy search to remote servers modal

Implements a search text box at the top of the remote servers modal,
similar to the recent projects view. The search filters through both
host names and project paths using fuzzy matching.

Key features:
- Fuzzy search through host:project-paths combinations
- Maintains server section structure while filtering projects
- Only shows matching projects within each server
- Proper focus management and text input handling
- Always visible 'Connect New Server' and 'Open Folder' buttons

Fixes the issue described in:
https://github.com/zed-industries/zed/discussions/35588
This commit is contained in:
Mert Akengin 2025-08-07 16:10:48 +02:00
parent ecd182c52f
commit 5cf16c80c4

View file

@ -13,6 +13,7 @@ use futures::FutureExt;
use futures::channel::oneshot;
use futures::future::Shared;
use futures::select;
use fuzzy::{StringMatch, StringMatchCandidate};
use gpui::ClickEvent;
use gpui::ClipboardItem;
use gpui::Subscription;
@ -67,13 +68,15 @@ use crate::ssh_connections::open_ssh_project;
mod navigation_base {}
pub struct RemoteServerProjects {
mode: Mode,
focus_handle: FocusHandle,
workspace: WeakEntity<Workspace>,
retained_connections: Vec<Entity<SshRemoteClient>>,
ssh_config_updates: Task<()>,
ssh_config_servers: BTreeSet<SharedString>,
create_new_window: bool,
search_editor: Entity<Editor>,
filtered_servers: Vec<(RemoteEntry, Vec<StringMatch>)>,
_subscription: Subscription,
_search_subscription: Subscription,
}
struct CreateRemoteServer {
@ -380,7 +383,6 @@ impl RemoteServerProjects {
workspace: WeakEntity<Workspace>,
cx: &mut Context<Self>,
) -> Self {
let focus_handle = cx.focus_handle();
let mut read_ssh_config = SshSettings::get_global(cx).read_ssh_config;
let ssh_config_updates = if read_ssh_config {
spawn_ssh_config_watch(fs.clone(), cx)
@ -394,6 +396,26 @@ impl RemoteServerProjects {
..Default::default()
});
let search_editor = cx.new(|cx| {
let mut editor = Editor::single_line(window, cx);
editor.set_placeholder_text("Search remote servers and projects...", cx);
editor
});
// Set up search editor change listener to update filtering
let search_editor_subscription = cx.subscribe(
&search_editor,
|_this, _editor, _event: &editor::EditorEvent, cx| {
cx.notify();
},
);
// Focus the search editor initially
let search_editor_for_focus = search_editor.clone();
cx.defer_in(window, move |_, window, cx| {
search_editor_for_focus.focus_handle(cx).focus(window);
});
let _subscription =
cx.observe_global_in::<SettingsStore>(window, move |recent_projects, _, cx| {
let new_read_ssh_config = SshSettings::get_global(cx).read_ssh_config;
@ -408,16 +430,143 @@ impl RemoteServerProjects {
}
});
Self {
let mut this = Self {
mode: Mode::default_mode(&BTreeSet::new(), cx),
focus_handle,
workspace,
retained_connections: Vec::new(),
ssh_config_updates,
ssh_config_servers: BTreeSet::new(),
create_new_window,
search_editor,
filtered_servers: Vec::new(),
_subscription,
_search_subscription: search_editor_subscription,
};
this.update_filtered_servers("", cx);
this
}
fn update_filtered_servers(&mut self, query: &str, cx: &mut Context<Self>) {
let ssh_settings = SshSettings::get_global(cx);
let read_ssh_config = ssh_settings.read_ssh_config;
let mut servers: Vec<RemoteEntry> = ssh_settings
.ssh_connections()
.map(|connection| {
let open_folder = NavigableEntry::new(&ScrollHandle::new(), cx);
let configure = NavigableEntry::new(&ScrollHandle::new(), cx);
let projects = connection
.projects
.iter()
.map(|project| {
(
NavigableEntry::new(&ScrollHandle::new(), cx),
project.clone(),
)
})
.collect();
RemoteEntry::Project {
open_folder,
configure,
projects,
connection,
}
})
.collect();
if read_ssh_config {
let mut extra_servers_from_config = self.ssh_config_servers.clone();
for server in &servers {
if let RemoteEntry::Project { connection, .. } = server {
extra_servers_from_config.remove(&connection.host);
}
}
servers.extend(extra_servers_from_config.into_iter().map(|host| {
RemoteEntry::SshConfig {
open_folder: NavigableEntry::new(&ScrollHandle::new(), cx),
host,
}
}));
}
if query.trim().is_empty() {
self.filtered_servers = servers
.into_iter()
.map(|server| (server, Vec::new()))
.collect();
return;
}
let query = query.trim();
let smart_case = query.chars().any(|c| c.is_uppercase());
// Instead of matching entire servers, we need to match individual projects within servers
let mut filtered_servers = Vec::new();
for server in servers {
match &server {
RemoteEntry::Project { connection, .. } => {
// Filter projects within this server
let mut matching_projects = Vec::new();
for project in &connection.projects {
let project_paths = project.paths.join(",");
let search_string = format!("{}:{}", connection.host, project_paths);
// Create candidate for this specific project
let candidate = StringMatchCandidate::new(0, &search_string);
let matches = smol::block_on(fuzzy::match_strings(
&[candidate],
query,
smart_case,
true,
1,
&Default::default(),
cx.background_executor().clone(),
));
if !matches.is_empty() {
matching_projects.push((
NavigableEntry::new(&ScrollHandle::new(), cx),
project.clone(),
));
}
}
// If any projects match, include the server with only matching projects
if !matching_projects.is_empty() {
let filtered_server = RemoteEntry::Project {
open_folder: NavigableEntry::new(&ScrollHandle::new(), cx),
configure: NavigableEntry::new(&ScrollHandle::new(), cx),
projects: matching_projects,
connection: connection.clone(),
};
filtered_servers.push((filtered_server, Vec::new()));
}
}
RemoteEntry::SshConfig { host, .. } => {
let search_string = host.to_string();
let candidate = StringMatchCandidate::new(0, &search_string);
let matches = smol::block_on(fuzzy::match_strings(
&[candidate],
query,
smart_case,
true,
1,
&Default::default(),
cx.background_executor().clone(),
));
if !matches.is_empty() {
filtered_servers.push((server, Vec::new()));
}
}
}
}
self.filtered_servers = filtered_servers;
}
pub fn project_picker(
@ -492,7 +641,10 @@ impl RemoteServerProjects {
this.retained_connections.push(client);
this.add_ssh_server(connection_options, cx);
this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
this.focus_handle(cx).focus(window);
let search_editor = this.search_editor.clone();
cx.defer_in(window, move |_, window, cx| {
search_editor.focus_handle(cx).focus(window);
});
cx.notify()
})
.log_err(),
@ -536,7 +688,10 @@ impl RemoteServerProjects {
connection,
entries: std::array::from_fn(|_| NavigableEntry::focusable(cx)),
});
self.focus_handle(cx).focus(window);
let search_editor = self.search_editor.clone();
cx.defer_in(window, move |_, window, cx| {
search_editor.focus_handle(cx).focus(window);
});
cx.notify();
}
@ -671,7 +826,10 @@ impl RemoteServerProjects {
}
});
self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
self.focus_handle.focus(window);
let search_editor = self.search_editor.clone();
cx.defer_in(window, move |_, window, cx| {
search_editor.focus_handle(cx).focus(window);
});
}
}
}
@ -691,7 +849,10 @@ impl RemoteServerProjects {
}
_ => {
self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
self.focus_handle(cx).focus(window);
let search_editor = self.search_editor.clone();
cx.defer_in(window, move |_, window, cx| {
search_editor.focus_handle(cx).focus(window);
});
cx.notify();
}
}
@ -1329,7 +1490,10 @@ impl RemoteServerProjects {
.track_focus(&entries[3].focus_handle)
.on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
cx.focus_self(window);
let search_editor = this.search_editor.clone();
cx.defer_in(window, move |_, window, cx| {
search_editor.focus_handle(cx).focus(window);
});
cx.notify();
}))
.child(
@ -1346,7 +1510,10 @@ impl RemoteServerProjects {
.on_click(cx.listener(|this, _, window, cx| {
this.mode =
Mode::default_mode(&this.ssh_config_servers, cx);
cx.focus_self(window);
let search_editor = this.search_editor.clone();
cx.defer_in(window, move |_, window, cx| {
search_editor.focus_handle(cx).focus(window);
});
cx.notify()
})),
)
@ -1450,6 +1617,11 @@ impl RemoteServerProjects {
}
}
// Update filtered servers when query changes
let current_query = self.search_editor.read(cx).text(cx);
self.update_filtered_servers(&current_query, cx);
let filtered_servers = self.filtered_servers.clone();
let scroll_state = state.scrollbar.parent_entity(&cx.entity());
let connect_button = div()
.id("ssh-connect-new-server-container")
@ -1488,7 +1660,6 @@ impl RemoteServerProjects {
let mut modal_section = Navigable::new(
v_flex()
.track_focus(&self.focus_handle(cx))
.id("ssh-server-list")
.overflow_y_scroll()
.track_scroll(&scroll_handle)
@ -1500,22 +1671,28 @@ impl RemoteServerProjects {
v_flex()
.child(
div().px_3().child(
Label::new("No remote servers registered yet.")
.color(Color::Muted),
Label::new(if current_query.trim().is_empty() {
"No remote servers registered yet."
} else {
"No matching servers found."
})
.color(Color::Muted),
),
)
.into_any_element(),
)
.children(state.servers.iter().enumerate().map(|(ix, connection)| {
self.render_ssh_connection(ix, connection.clone(), window, cx)
.into_any_element()
})),
.children(filtered_servers.iter().enumerate().map(
|(ix, (server, _matches))| {
self.render_ssh_connection(ix, server.clone(), window, cx)
.into_any_element()
},
)),
)
.into_any_element(),
)
.entry(state.add_new_server.clone());
for server in &state.servers {
for (server, _) in &filtered_servers {
match server {
RemoteEntry::Project {
open_folder,
@ -1548,54 +1725,63 @@ impl RemoteServerProjects {
window.keystroke_text_for(&menu::Confirm),
)
};
let placeholder_text = Arc::from(format!(
let _placeholder_text: Arc<str> = Arc::from(format!(
"{reuse_window} reuses this window, {create_window} opens a new one",
));
// Set up search editor change listener
let search_editor = self.search_editor.clone();
Modal::new("remote-projects", None)
.header(
ModalHeader::new()
.child(Headline::new("Remote Projects").size(HeadlineSize::XSmall))
.child(
Label::new(placeholder_text)
.color(Color::Muted)
.size(LabelSize::XSmall),
),
.child(Headline::new("Remote Projects").size(HeadlineSize::XSmall)),
)
.section(
Section::new().padded(false).child(
v_flex()
.min_h(rems(20.))
.size_full()
.relative()
.child(ListSeparator)
.child(
canvas(
|bounds, window, cx| {
modal_section.prepaint_as_root(
bounds.origin,
bounds.size.into(),
window,
cx,
);
modal_section
},
|_, mut modal_section, window, cx| {
modal_section.paint(window, cx);
},
)
.size_full(),
)
.child(
div()
.occlude()
.h_full()
.absolute()
.top_1()
.bottom_1()
.right_1()
.w(px(8.))
.children(Scrollbar::vertical(scroll_state)),
.p_2()
.border_b_1()
.border_color(cx.theme().colors().border_variant)
.track_focus(&search_editor.focus_handle(cx))
.child(search_editor.clone()),
)
.child(
v_flex()
.min_h(rems(20.))
.size_full()
.relative()
.child(ListSeparator)
.child(
canvas(
|bounds, window, cx| {
modal_section.prepaint_as_root(
bounds.origin,
bounds.size.into(),
window,
cx,
);
modal_section
},
|_, mut modal_section, window, cx| {
modal_section.paint(window, cx);
},
)
.size_full(),
)
.child(
div()
.occlude()
.h_full()
.absolute()
.top_1()
.bottom_1()
.right_1()
.w(px(8.))
.children(Scrollbar::vertical(scroll_state)),
),
),
),
)
@ -1701,7 +1887,9 @@ impl Focusable for RemoteServerProjects {
fn focus_handle(&self, cx: &App) -> FocusHandle {
match &self.mode {
Mode::ProjectPicker(picker) => picker.focus_handle(cx),
_ => self.focus_handle.clone(),
Mode::CreateRemoteServer(state) => state.address_editor.focus_handle(cx),
Mode::EditNickname(state) => state.editor.focus_handle(cx),
_ => self.search_editor.focus_handle(cx),
}
}
}
@ -1713,11 +1901,18 @@ impl Render for RemoteServerProjects {
div()
.elevation_3(cx)
.w(rems(34.))
.key_context("RemoteServerModal")
.on_action(cx.listener(Self::cancel))
.on_action(cx.listener(Self::confirm))
.capture_any_mouse_down(cx.listener(|this, _, window, cx| {
this.focus_handle(cx).focus(window);
// Focus the appropriate element based on mode
match &this.mode {
Mode::Default(_) => {
this.search_editor.focus_handle(cx).focus(window);
}
_ => {
this.focus_handle(cx).focus(window);
}
}
}))
.on_mouse_down_out(cx.listener(|this, _, _, cx| {
if matches!(this.mode, Mode::Default(_)) {