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:
parent
ecd182c52f
commit
5cf16c80c4
1 changed files with 252 additions and 57 deletions
|
@ -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(¤t_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(_)) {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue