diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index 354434a7fc..6b1a72d602 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -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, retained_connections: Vec>, ssh_config_updates: Task<()>, ssh_config_servers: BTreeSet, create_new_window: bool, + search_editor: Entity, + filtered_servers: Vec<(RemoteEntry, Vec)>, _subscription: Subscription, + _search_subscription: Subscription, } struct CreateRemoteServer { @@ -380,7 +383,6 @@ impl RemoteServerProjects { workspace: WeakEntity, cx: &mut Context, ) -> 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::(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) { + let ssh_settings = SshSettings::get_global(cx); + let read_ssh_config = ssh_settings.read_ssh_config; + + let mut servers: Vec = 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 = 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(_)) {