diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index 3c1ad31946..fefc8e1fad 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -5,13 +5,15 @@ use crate::{ }, ssh_config::parse_ssh_config_hosts, }; +mod filter; + use dev_container::{ DevContainerConfig, DevContainerContext, find_devcontainer_configs, start_dev_container_with_config, }; -use editor::Editor; - +use editor::{Editor, EditorEvent}; use extension_host::ExtensionStore; +use filter::{FilterData, FilteredServer}; use futures::{FutureExt, StreamExt as _, channel::oneshot, future::Shared}; use gpui::{ Action, AnyElement, App, ClickEvent, ClipboardItem, Context, DismissEvent, Entity, @@ -38,14 +40,14 @@ use std::{ rc::Rc, sync::{ Arc, - atomic::{self, AtomicUsize}, + atomic::{self, AtomicBool, AtomicUsize}, }, }; use ui::{ - CommonAnimationExt, IconButtonShape, KeyBinding, List, ListItem, ListSeparator, Modal, - ModalFooter, ModalHeader, Navigable, NavigableEntry, ScrollAxes, Scrollbars, Section, Tooltip, - WithScrollbar, prelude::*, + CommonAnimationExt, HighlightedLabel, IconButtonShape, KeyBinding, List, ListItem, + ListSeparator, Modal, ModalFooter, ModalHeader, Navigable, NavigableEntry, ScrollAxes, + Scrollbars, Section, Tooltip, WithScrollbar, prelude::*, }; use util::{ ResultExt, @@ -61,13 +63,16 @@ use workspace::{ pub struct RemoteServerProjects { mode: Mode, focus_handle: FocusHandle, + filter_editor: Entity, workspace: WeakEntity, retained_connections: Vec>, ssh_config_updates: Task<()>, ssh_config_servers: BTreeSet, create_new_window: bool, dev_container_picker: Option>>, - _subscription: Subscription, + _subscriptions: Vec, + filter_cancel: Arc, + _filter_task: Task<()>, allow_dismissal: bool, } @@ -622,11 +627,17 @@ impl From for ServerIndex { } } +#[derive(Clone)] +struct ProjectEntry { + navigation: NavigableEntry, + project: RemoteProject, +} + #[derive(Clone)] enum RemoteEntry { Project { open_folder: NavigableEntry, - projects: Vec<(NavigableEntry, RemoteProject)>, + projects: Vec, configure: NavigableEntry, connection: Connection, index: ServerIndex, @@ -642,6 +653,31 @@ impl RemoteEntry { matches!(self, Self::Project { .. }) } + fn display_host(&self) -> &str { + match self { + Self::Project { connection, .. } => match connection { + Connection::Ssh(c) => c.nickname.as_deref().unwrap_or(&c.host), + Connection::Wsl(c) => &c.distro_name, + Connection::DevContainer(c) => &c.name, + }, + Self::SshConfig { host, .. } => host, + } + } + + /// Extra text to match against that isn't shown in the primary label. + /// When an SSH connection has a nickname, [`display_host`] surfaces the + /// nickname and the real host is only shown as a muted aux label, so we + /// index the host here to keep it searchable. + fn host_alias(&self) -> Option<&str> { + match self { + Self::Project { + connection: Connection::Ssh(c), + .. + } if c.nickname.is_some() => Some(&c.host), + _ => None, + } + } + fn connection(&self) -> Cow<'_, Connection> { match self { Self::Project { connection, .. } => Cow::Borrowed(connection), @@ -663,6 +699,10 @@ struct DefaultState { add_new_devcontainer: NavigableEntry, add_new_wsl: NavigableEntry, servers: Vec, + /// `None` when no filter is active; `Some` carries the fuzzy match results + /// (server/project indices plus highlight positions) sorted by score. + filtered_servers: Option>, + filter_data: Arc, } impl DefaultState { @@ -684,7 +724,10 @@ impl DefaultState { let projects = connection .projects .iter() - .map(|project| (NavigableEntry::new(&handle, cx), project.clone())) + .map(|project| ProjectEntry { + navigation: NavigableEntry::new(&handle, cx), + project: project.clone(), + }) .collect(); RemoteEntry::Project { open_folder, @@ -704,7 +747,10 @@ impl DefaultState { let projects = connection .projects .iter() - .map(|project| (NavigableEntry::new(&handle, cx), project.clone())) + .map(|project| ProjectEntry { + navigation: NavigableEntry::new(&handle, cx), + project: project.clone(), + }) .collect(); RemoteEntry::Project { open_folder, @@ -736,14 +782,89 @@ impl DefaultState { })); } + let filter_data = Arc::new(FilterData::build(&servers)); Self { scroll_handle: handle, add_new_server, add_new_devcontainer, add_new_wsl, servers, + filtered_servers: None, + filter_data, } } + + fn filter_sync(&mut self, query: &str) { + if query.is_empty() { + self.filtered_servers = None; + return; + } + self.filtered_servers = Some(filter::run_sync(&self.filter_data, query)); + } + + /// Resolves [`filtered_servers`] (or the unfiltered source list) into a + /// flat list of borrowed `RemoteEntry`s paired with their highlight + /// positions. Rendering iterates this list rather than touching either + /// source directly; the borrowed entries avoid cloning the underlying + /// `RemoteEntry`/`RemoteProject` data on the per-keystroke path (only the + /// lightweight borrow vectors are allocated). + fn visible_servers(&self) -> Vec> { + match &self.filtered_servers { + None => self + .servers + .iter() + .map(|server| VisibleEntry { + server, + host_positions: &[], + visible_projects: match server { + RemoteEntry::Project { projects, .. } => projects + .iter() + .map(|entry| VisibleProject { + entry, + highlight_positions: &[], + }) + .collect(), + RemoteEntry::SshConfig { .. } => Vec::new(), + }, + }) + .collect(), + Some(results) => results + .iter() + .filter_map(|filtered| { + let server = self.servers.get(filtered.server_index)?; + let visible_projects = match server { + RemoteEntry::Project { projects, .. } => filtered + .project_matches + .iter() + .filter_map(|pm| { + projects.get(pm.project_index).map(|entry| VisibleProject { + entry, + highlight_positions: &pm.path_positions, + }) + }) + .collect(), + RemoteEntry::SshConfig { .. } => Vec::new(), + }; + Some(VisibleEntry { + server, + host_positions: &filtered.host_positions, + visible_projects, + }) + }) + .collect(), + } + } +} + +struct VisibleEntry<'a> { + server: &'a RemoteEntry, + host_positions: &'a [usize], + visible_projects: Vec>, +} + +struct VisibleProject<'a> { + entry: &'a ProjectEntry, + highlight_positions: &'a [usize], } #[derive(Clone)] @@ -886,6 +1007,11 @@ impl RemoteServerProjects { cx: &mut Context, ) -> Self { let focus_handle = cx.focus_handle(); + let filter_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text("Filter remote projects...", window, cx); + editor + }); let mut read_ssh_config = RemoteSettings::get_global(cx).read_ssh_config; let ssh_config_updates = if read_ssh_config { spawn_ssh_config_watch(fs.clone(), cx) @@ -899,7 +1025,7 @@ impl RemoteServerProjects { ..Default::default() }); - let _subscription = + let settings_subscription = cx.observe_global_in::(window, move |recent_projects, _, cx| { let new_read_ssh_config = RemoteSettings::get_global(cx).read_ssh_config; if read_ssh_config != new_read_ssh_config { @@ -913,16 +1039,26 @@ impl RemoteServerProjects { } }); + let filter_subscription = + cx.subscribe(&filter_editor, |this, _, event: &EditorEvent, cx| { + if matches!(event, EditorEvent::BufferEdited) { + this.recompute_filter(cx); + } + }); + Self { mode, focus_handle, + filter_editor, workspace, retained_connections: Vec::new(), ssh_config_updates, ssh_config_servers: BTreeSet::new(), create_new_window, dev_container_picker: None, - _subscription, + _subscriptions: vec![settings_subscription, filter_subscription], + filter_cancel: Arc::new(AtomicBool::new(false)), + _filter_task: Task::ready(()), allow_dismissal: true, } } @@ -1296,7 +1432,16 @@ impl RemoteServerProjects { fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context) { match &self.mode { - Mode::Default(_) => cx.emit(DismissEvent), + Mode::Default(_) => { + if !self.filter_editor.read(cx).text(cx).is_empty() { + self.filter_editor.update(cx, |editor, cx| { + editor.set_text("", window, cx); + }); + cx.notify(); + } else { + cx.emit(DismissEvent); + } + } Mode::CreateRemoteServer(state) if state.ssh_prompt.is_some() => { let new_state = CreateRemoteServer::new(window, cx); let old_prompt = state.address_editor.read(cx).text(cx); @@ -1322,14 +1467,58 @@ impl RemoteServerProjects { } } + fn recompute_filter(&mut self, cx: &mut Context) { + let Mode::Default(state) = &mut self.mode else { + return; + }; + let query = self.filter_editor.read(cx).text(cx); + let query = query.trim().to_string(); + + // Signal cancellation to the previously-spawned task: it still holds + // its own `Arc` clone of the old `filter_cancel`, so this store is + // visible to it. We then install a fresh `AtomicBool` below for the + // next task to observe independently. + self.filter_cancel.store(true, atomic::Ordering::Release); + + if query.is_empty() { + state.filtered_servers = None; + self._filter_task = Task::ready(()); + cx.notify(); + return; + } + + let filter_data = Arc::clone(&state.filter_data); + let cancel = Arc::new(AtomicBool::new(false)); + self.filter_cancel = cancel.clone(); + let executor = cx.background_executor().clone(); + + self._filter_task = cx.spawn(async move |this, cx| { + let Some(results) = filter::run_async(&filter_data, &query, &cancel, executor).await + else { + return; + }; + this.update(cx, |this, cx| { + if let Mode::Default(state) = &mut this.mode { + state.filtered_servers = Some(results); + cx.notify(); + } + }) + .ok(); + }); + } + fn render_remote_connection( &mut self, ix: usize, - remote_server: RemoteEntry, + visible: &VisibleEntry<'_>, + show_top_separator: bool, window: &mut Window, cx: &mut Context, ) -> impl IntoElement { + let remote_server = visible.server; let connection = remote_server.connection().into_owned(); + let shared_connection = Rc::new(connection.clone()); + let host_positions = visible.host_positions.to_vec(); let (main_label, aux_label, is_wsl) = match &connection { Connection::Ssh(connection) => { @@ -1349,7 +1538,7 @@ impl RemoteServerProjects { }; v_flex() .w_full() - .child(ListSeparator) + .when(show_top_separator, |this| this.child(ListSeparator)) .child( h_flex() .group("ssh-server") @@ -1372,7 +1561,7 @@ impl RemoteServerProjects { ) }) .child( - Label::new(main_label) + HighlightedLabel::new(main_label, host_positions) .size(LabelSize::Small) .color(Color::Muted), ), @@ -1383,21 +1572,22 @@ impl RemoteServerProjects { }), ), ) - .child(match &remote_server { + .child(match remote_server { RemoteEntry::Project { open_folder, - projects, configure, connection, index, + .. } => { let index = *index; List::new() .empty_message("No projects.") - .children(projects.iter().enumerate().map(|(pix, p)| { + .children(visible.visible_projects.iter().enumerate().map(|(pix, p)| { v_flex().gap_0p5().child(self.render_remote_project( index, - remote_server.clone(), + remote_server, + shared_connection.clone(), pix, p, window, @@ -1481,7 +1671,9 @@ impl RemoteServerProjects { ), ) } - RemoteEntry::SshConfig { open_folder, host } => List::new().child( + RemoteEntry::SshConfig { + open_folder, host, .. + } => List::new().child( h_flex() .id(("new-remote-project-container", ix)) .track_focus(&open_folder.focus_handle) @@ -1526,12 +1718,14 @@ impl RemoteServerProjects { fn render_remote_project( &mut self, server_ix: ServerIndex, - server: RemoteEntry, + server: &RemoteEntry, + connection: Rc, ix: usize, - (navigation, project): &(NavigableEntry, RemoteProject), + visible: &VisibleProject<'_>, window: &mut Window, cx: &mut Context, ) -> impl IntoElement { + let entry = visible.entry; let create_new_window = self.create_new_window; let is_from_zed = server.is_from_zed(); let element_id_base = SharedString::from(format!( @@ -1545,7 +1739,7 @@ impl RemoteServerProjects { SharedString::from(format!("remote-project-container-{element_id_base}")); let callback = Rc::new({ - let project = project.clone(); + let project = entry.project.clone(); move |remote_server_projects: &mut Self, secondary_confirm: bool, window: &mut Window, @@ -1558,7 +1752,7 @@ impl RemoteServerProjects { return; }; let project = project.clone(); - let server = server.connection().into_owned(); + let server = connection.as_ref().clone(); cx.emit(DismissEvent); let replace_window = match (create_new_window, secondary_confirm) { @@ -1598,8 +1792,8 @@ impl RemoteServerProjects { div() .id((container_element_id_base, ix)) - .track_focus(&navigation.focus_handle) - .anchor_scroll(navigation.scroll_anchor.clone()) + .track_focus(&entry.navigation.focus_handle) + .anchor_scroll(entry.navigation.scroll_anchor.clone()) .on_action(cx.listener({ let callback = callback.clone(); move |this, _: &menu::Confirm, window, cx| { @@ -1614,7 +1808,7 @@ impl RemoteServerProjects { })) .child( ListItem::new((element_id_base, ix)) - .toggle_state(navigation.focus_handle.contains_focused(window, cx)) + .toggle_state(entry.navigation.focus_handle.contains_focused(window, cx)) .inset(true) .spacing(ui::ListItemSpacing::Sparse) .start_slot( @@ -1622,19 +1816,25 @@ impl RemoteServerProjects { .color(Color::Muted) .size(IconSize::Small), ) - .child(Label::new(project.paths.join(", ")).truncate_start()) + .child( + HighlightedLabel::new( + entry.project.paths.join(", "), + visible.highlight_positions.to_vec(), + ) + .truncate_start(), + ) .on_click(cx.listener(move |this, e: &ClickEvent, window, cx| { let secondary_confirm = e.modifiers().platform; callback(this, secondary_confirm, window, cx) })) - .tooltip(Tooltip::text(project.paths.join("\n"))) + .tooltip(Tooltip::text(entry.project.paths.join("\n"))) .when(is_from_zed, |server_list_item| { server_list_item .end_slot( div() .mr_2() .child({ - let project = project.clone(); + let project = entry.project.clone(); IconButton::new("remove-remote-project", IconName::Trash) .icon_size(IconSize::Small) .shape(IconButtonShape::Square) @@ -2563,6 +2763,8 @@ impl RemoteServerProjects { cx: &mut Context, ) -> impl IntoElement { let ssh_settings = RemoteSettings::get_global(cx); + let query = self.filter_editor.read(cx).text(cx); + let query = query.trim(); let mut should_rebuild = false; let ssh_connections_changed = ssh_settings.ssh_connections.0.iter().ne(state @@ -2614,12 +2816,22 @@ impl RemoteServerProjects { } if should_rebuild { + // Rebuilding `DefaultState` invalidates the cached `FilterData` + // the in-flight async filter is borrowing, so cancel it and run + // synchronously here. This only fires on settings/ssh_config + // changes (rare), so the cost of doing the match under render + // is acceptable. + self.filter_cancel.store(true, atomic::Ordering::Release); + self._filter_task = Task::ready(()); self.mode = Mode::default_mode(&self.ssh_config_servers, cx); - if let Mode::Default(new_state) = &self.mode { + if let Mode::Default(new_state) = &mut self.mode { + new_state.filter_sync(query); state = new_state.clone(); } } + let visible_servers = state.visible_servers(); + let connect_button = div() .id("ssh-connect-new-server-container") .track_focus(&state.add_new_server.focus_handle) @@ -2722,14 +2934,16 @@ impl RemoteServerProjects { .unwrap_or(true); let modal_section = v_flex() - .track_focus(&self.focus_handle(cx)) + .track_focus(&self.focus_handle) .id("ssh-server-list") .overflow_y_scroll() .track_scroll(&state.scroll_handle) .size_full() - .child(connect_button) - .when(has_open_project && is_local, |this| { - this.child(connect_dev_container_button) + .when(query.is_empty(), |this| { + this.child(connect_button) + .when(has_open_project && is_local, |this| { + this.child(connect_dev_container_button) + }) }); #[cfg(target_os = "windows")] @@ -2749,14 +2963,25 @@ impl RemoteServerProjects { .border_t_1() .border_color(cx.theme().colors().border_variant) .child( - Label::new("No remote servers registered yet.") - .color(Color::Muted), + Label::new(if query.is_empty() { + "No remote servers registered yet." + } else { + "No matching remote projects." + }) + .color(Color::Muted), ) .into_any_element(), ) - .children(state.servers.iter().enumerate().map(|(ix, connection)| { - self.render_remote_connection(ix, connection.clone(), window, cx) - .into_any_element() + .children(visible_servers.iter().enumerate().map(|(ix, visible)| { + let show_top_separator = ix > 0 || query.is_empty(); + self.render_remote_connection( + ix, + visible, + show_top_separator, + window, + cx, + ) + .into_any_element() })), ) .into_any_element(), @@ -2771,17 +2996,16 @@ impl RemoteServerProjects { modal_section = modal_section.entry(state.add_new_wsl.clone()); } - for server in &state.servers { - match server { + for visible in &visible_servers { + for project in &visible.visible_projects { + modal_section = modal_section.entry(project.entry.navigation.clone()); + } + match visible.server { RemoteEntry::Project { open_folder, - projects, configure, .. } => { - for (navigation_state, _) in projects { - modal_section = modal_section.entry(navigation_state.clone()); - } modal_section = modal_section .entry(open_folder.clone()) .entry(configure.clone()); @@ -2793,13 +3017,18 @@ impl RemoteServerProjects { } let mut modal_section = modal_section.render(window, cx).into_any_element(); - let is_project_selected = state.servers.iter().any(|server| match server { - RemoteEntry::Project { projects, .. } => projects - .iter() - .any(|(entry, _)| entry.focus_handle.contains_focused(window, cx)), - RemoteEntry::SshConfig { .. } => false, + let is_project_selected = visible_servers.iter().any(|visible| { + visible.visible_projects.iter().any(|project| { + project + .entry + .navigation + .focus_handle + .contains_focused(window, cx) + }) }); + let filter_editor = self.filter_editor.clone(); + Modal::new("remote-projects", None) .header(ModalHeader::new().headline("Remote Projects")) .section( @@ -2808,6 +3037,7 @@ impl RemoteServerProjects { .min_h(rems(20.)) .size_full() .relative() + .child(div().px_2().py_1().child(filter_editor)) .child(ListSeparator) .child( canvas( @@ -2974,6 +3204,7 @@ impl ModalView for RemoteServerProjects { impl Focusable for RemoteServerProjects { fn focus_handle(&self, cx: &App) -> FocusHandle { match &self.mode { + Mode::Default(_) => self.filter_editor.focus_handle(cx), Mode::ProjectPicker(picker) => picker.focus_handle(cx), _ => self.focus_handle.clone(), } @@ -3022,3 +3253,54 @@ impl Render for RemoteServerProjects { }) } } + +#[cfg(test)] +mod filter_tests { + use super::*; + + fn ssh_config_entry(cx: &App, handle: &ScrollHandle, host: &'static str) -> RemoteEntry { + RemoteEntry::SshConfig { + open_folder: NavigableEntry::new(handle, cx), + host: SharedString::from(host), + } + } + + #[gpui::test] + async fn test_filter_sync_repopulates_after_rebuild(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + let handle = ScrollHandle::new(); + let entries = vec![ + ssh_config_entry(cx, &handle, "alpha"), + ssh_config_entry(cx, &handle, "beta"), + ]; + let mut state = DefaultState { + scroll_handle: handle.clone(), + add_new_server: NavigableEntry::new(&handle, cx), + add_new_devcontainer: NavigableEntry::new(&handle, cx), + add_new_wsl: NavigableEntry::new(&handle, cx), + filter_data: Arc::new(FilterData::build(&entries)), + servers: entries, + filtered_servers: None, + }; + + state.filter_sync("alp"); + let filtered = state.filtered_servers.as_ref().expect("should filter"); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].server_index, 0); + assert!(!filtered[0].host_positions.is_empty()); + + // visible_servers should resolve the filtered indices back into + // the original `RemoteEntry`s with positions attached. + let visible = state.visible_servers(); + assert_eq!(visible.len(), 1); + match visible[0].server { + RemoteEntry::SshConfig { host, .. } => assert_eq!(host.as_ref(), "alpha"), + _ => panic!("expected SshConfig"), + } + assert!(!visible[0].host_positions.is_empty()); + + state.filter_sync(""); + assert!(state.filtered_servers.is_none()); + }); + } +} diff --git a/crates/recent_projects/src/remote_servers/filter.rs b/crates/recent_projects/src/remote_servers/filter.rs new file mode 100644 index 0000000000..4e5ae9cc8d --- /dev/null +++ b/crates/recent_projects/src/remote_servers/filter.rs @@ -0,0 +1,606 @@ +//! Fuzzy filtering for the remote-projects modal. +//! +//! At construction time we build [`FilterData`]: a flat list of +//! [`StringMatchCandidate`]s with one candidate per project for servers that +//! have projects, and one candidate per server (over the host name) for +//! project-less servers and SSH-config-only entries. Each candidate matches +//! against the displayed host plus any search-only alias (the real SSH host +//! when a nickname hides it), so a server stays findable by either name. Each +//! candidate is tagged with [`CandidateMeta`] recording which server (and +//! project, if any) it represents. This snapshot is reused for every +//! keystroke; we only rebuild it when the set of servers changes. +//! +//! On each query, `fuzzy_nucleo::match_strings[_async]` returns matches sorted +//! by score. [`build_filter_results`] regroups those matches by server (a +//! server with N projects can contribute up to N matches) and folds each +//! bucket into a single [`FilteredServer`] carrying highlight positions and +//! the best score across its candidates. +//! +//! Projects inside a [`FilteredServer`] are intentionally ordered by fuzzy +//! score, not by their position in the source list — when a query matches +//! only the host name, the server's projects come back ranked by how well +//! each one also matched. +//! +//! The caller stores the resulting `Vec` in +//! [`DefaultState::filtered_servers`] and renders by looking up each +//! `server_index` / `project_index` against the unchanged source list. + +use std::sync::atomic::{self, AtomicBool}; + +use fuzzy_nucleo::{StringMatch, StringMatchCandidate}; +use gpui::BackgroundExecutor; + +use super::RemoteEntry; + +#[derive(Debug)] +pub(super) struct FilterData { + pub(super) candidates: Vec, + pub(super) meta: Vec, + pub(super) server_count: usize, +} + +#[derive(Debug)] +pub(super) struct CandidateMeta { + pub(super) server_index: usize, + pub(super) project_index: Option, + /// Byte length of the host text that is actually displayed (and thus + /// highlightable) as the server's primary label. Host match positions at + /// or beyond this are dropped — they fall inside the search-only alias. + pub(super) display_host_byte_len: usize, + /// Byte length of the full searchable host text (display host plus any + /// alias), used to find where the project-path portion of the combined + /// candidate string begins. + pub(super) match_host_byte_len: usize, +} + +#[derive(Clone, Debug)] +pub(super) struct FilteredServer { + pub(super) server_index: usize, + pub(super) host_positions: Vec, + pub(super) project_matches: Vec, + pub(super) score: f64, +} + +#[derive(Clone, Debug)] +pub(super) struct FilteredProject { + pub(super) project_index: usize, + pub(super) path_positions: Vec, +} + +impl FilterData { + pub(super) fn build(servers: &[RemoteEntry]) -> Self { + let mut candidates = Vec::new(); + let mut meta = Vec::new(); + for (server_index, server) in servers.iter().enumerate() { + let display_host = server.display_host(); + let display_host_byte_len = display_host.len(); + let search_host = match server.host_alias() { + Some(alias) => format!("{display_host} {alias}"), + None => display_host.to_string(), + }; + let match_host_byte_len = search_host.len(); + match server { + RemoteEntry::Project { projects, .. } if !projects.is_empty() => { + for (project_index, entry) in projects.iter().enumerate() { + let combined = format!("{search_host} {}", entry.project.paths.join(", ")); + meta.push(CandidateMeta { + server_index, + project_index: Some(project_index), + display_host_byte_len, + match_host_byte_len, + }); + candidates.push(StringMatchCandidate::new(candidates.len(), combined)); + } + } + RemoteEntry::Project { .. } | RemoteEntry::SshConfig { .. } => { + meta.push(CandidateMeta { + server_index, + project_index: None, + display_host_byte_len, + match_host_byte_len, + }); + candidates.push(StringMatchCandidate::new(candidates.len(), search_host)); + } + } + } + Self { + candidates, + meta, + server_count: servers.len(), + } + } +} + +pub(super) fn build_filter_results( + matches: Vec, + filter_data: &FilterData, +) -> Vec { + group_matches_by_server(matches, filter_data) + .into_iter() + .enumerate() + .filter_map(|(server_index, group)| { + (!group.is_empty()).then(|| build_server_result(server_index, group)) + }) + .collect() +} + +fn group_matches_by_server( + matches: Vec, + filter_data: &FilterData, +) -> Vec> { + let mut buckets: Vec> = (0..filter_data.server_count).map(|_| Vec::new()).collect(); + for m in matches { + let Some(meta) = filter_data.meta.get(m.candidate_id) else { + continue; + }; + let Some(bucket) = buckets.get_mut(meta.server_index) else { + continue; + }; + bucket.push((m, meta)); + } + buckets +} + +fn build_server_result( + server_index: usize, + group: Vec<(StringMatch, &CandidateMeta)>, +) -> FilteredServer { + debug_assert!(!group.is_empty(), "empty groups are filtered out upstream"); + + let mut host_positions = Vec::new(); + let mut project_matches = Vec::new(); + let mut score = f64::NEG_INFINITY; + + for (m, meta) in group { + score = score.max(m.score); + // `FilterData::build` emits either one host-only candidate or one + // candidate per project for a server, never a mix, so the `None` arm + // assigning `host_positions` can't clobber positions accumulated by + // the `Some` arm. + match meta.project_index { + None => { + host_positions = m + .positions + .into_iter() + .filter(|&p| p < meta.display_host_byte_len) + .collect(); + } + Some(project_index) => { + // +1 accounts for the single-byte space separator in + // format!("{search_host} {paths}") used by FilterData::build. + // Positions inside the search-only host alias (between the + // displayed host and the separator) are dropped from both + // sides — they index into content that isn't shown anywhere. + let host_prefix_len = meta.match_host_byte_len + 1; + host_positions.extend( + m.positions + .iter() + .copied() + .filter(|&p| p < meta.display_host_byte_len), + ); + project_matches.push(FilteredProject { + project_index, + path_positions: m + .positions + .into_iter() + .filter_map(|p| p.checked_sub(host_prefix_len)) + .collect(), + }); + } + } + } + + host_positions.sort_unstable(); + host_positions.dedup(); + + FilteredServer { + server_index, + host_positions, + project_matches, + score, + } +} + +pub(super) fn run_sync(data: &FilterData, query: &str) -> Vec { + let case = fuzzy_nucleo::Case::smart_if_uppercase_in(query); + let matches = fuzzy_nucleo::match_strings( + &data.candidates, + query, + case, + fuzzy_nucleo::LengthPenalty::Off, + data.candidates.len(), + ); + let mut results = build_filter_results(matches, data); + results.sort_by(|a, b| b.score.total_cmp(&a.score)); + results +} + +pub(super) async fn run_async( + data: &FilterData, + query: &str, + cancel: &AtomicBool, + executor: BackgroundExecutor, +) -> Option> { + let case = fuzzy_nucleo::Case::smart_if_uppercase_in(query); + let matches = fuzzy_nucleo::match_strings_async( + &data.candidates, + query, + case, + fuzzy_nucleo::LengthPenalty::Off, + data.candidates.len(), + cancel, + executor, + ) + .await; + if cancel.load(atomic::Ordering::Acquire) { + return None; + } + let mut results = build_filter_results(matches, data); + results.sort_by(|a, b| b.score.total_cmp(&a.score)); + Some(results) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::remote_connections::{Connection, SshConnection}; + use crate::remote_servers::{ProjectEntry, ServerIndex, SshServerIndex}; + use gpui::{App, ScrollHandle}; + use settings::RemoteProject; + use ui::{NavigableEntry, SharedString}; + + struct MockServer { + host: &'static str, + nickname: Option<&'static str>, + project_paths: &'static [&'static str], + } + + fn mock(host: &'static str, project_paths: &'static [&'static str]) -> MockServer { + MockServer { + host, + nickname: None, + project_paths, + } + } + + fn mock_with_nickname( + host: &'static str, + nickname: &'static str, + project_paths: &'static [&'static str], + ) -> MockServer { + MockServer { + host, + nickname: Some(nickname), + project_paths, + } + } + + fn build_entries(cx: &App, handle: &ScrollHandle, servers: &[MockServer]) -> Vec { + servers + .iter() + .map(|server| { + if server.project_paths.is_empty() { + RemoteEntry::SshConfig { + open_folder: NavigableEntry::new(handle, cx), + host: SharedString::from(server.host), + } + } else { + let projects = server + .project_paths + .iter() + .map(|path| ProjectEntry { + navigation: NavigableEntry::new(handle, cx), + project: RemoteProject { + paths: vec![(*path).to_string()], + }, + }) + .collect(); + let connection = Connection::Ssh(SshConnection { + host: server.host.to_string(), + nickname: server.nickname.map(str::to_string), + projects: server + .project_paths + .iter() + .map(|p| RemoteProject { + paths: vec![(*p).to_string()], + }) + .collect(), + ..Default::default() + }); + RemoteEntry::Project { + open_folder: NavigableEntry::new(handle, cx), + projects, + configure: NavigableEntry::new(handle, cx), + connection, + index: ServerIndex::Ssh(SshServerIndex(0)), + } + } + }) + .collect() + } + + fn with_filter_data( + cx: &App, + servers: &[MockServer], + f: impl FnOnce(&[MockServer], &FilterData) -> R, + ) -> R { + let handle = ScrollHandle::new(); + let entries = build_entries(cx, &handle, servers); + let data = FilterData::build(&entries); + f(servers, &data) + } + + #[gpui::test] + async fn test_filter_host_only(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + with_filter_data(cx, &[mock("myhost", &[])], |_, data| { + let results = run_sync(data, "myh"); + assert_eq!(results.len(), 1); + assert_eq!(results[0].server_index, 0); + assert!(!results[0].host_positions.is_empty()); + }); + }); + } + + #[gpui::test] + async fn test_filter_no_match(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + with_filter_data(cx, &[mock("myhost", &["/home/project"])], |_, data| { + let results = run_sync(data, "zzz"); + assert!(results.is_empty()); + }); + }); + } + + #[gpui::test] + async fn test_filter_project_path_match(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + with_filter_data(cx, &[mock("myhost", &["/home/user/project"])], |_, data| { + let results = run_sync(data, "project"); + assert_eq!(results.len(), 1); + assert_eq!(results[0].project_matches.len(), 1); + assert_eq!(results[0].project_matches[0].project_index, 0); + }); + }); + } + + #[gpui::test] + async fn test_filter_host_match_includes_all_projects(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + with_filter_data(cx, &[mock("myhost", &["/path/a", "/path/b"])], |_, data| { + let results = run_sync(data, "myhost"); + assert_eq!(results.len(), 1); + assert_eq!(results[0].project_matches.len(), 2); + }); + }); + } + + #[gpui::test] + async fn test_filter_excludes_non_matching_servers(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + with_filter_data( + cx, + &[mock("alpha", &["/path/a"]), mock("beta", &["/path/b"])], + |_, data| { + let results = run_sync(data, "alpha"); + assert_eq!(results.len(), 1); + assert_eq!(results[0].server_index, 0); + }, + ); + }); + } + + #[gpui::test] + async fn test_position_mapping_splits_host_and_path(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + with_filter_data(cx, &[mock("dev", &["/src/app"])], |servers, data| { + let results = run_sync(data, "dev app"); + + assert_eq!(results.len(), 1); + let result = &results[0]; + let host = servers[result.server_index].host; + let path = servers[result.server_index].project_paths[0]; + + assert!( + result.host_positions.iter().all(|&p| p < host.len()), + "host positions {:?} must be within host {:?} (len {})", + result.host_positions, + host, + host.len(), + ); + + assert_eq!(result.project_matches.len(), 1); + let proj = &result.project_matches[0]; + assert_eq!(proj.project_index, 0); + assert!( + proj.path_positions.iter().all(|&p| p < path.len()), + "path positions {:?} must be within path {:?} (len {})", + proj.path_positions, + path, + path.len(), + ); + + assert!( + !result.host_positions.is_empty(), + "query 'dev' should match host 'dev'" + ); + assert!( + !proj.path_positions.is_empty(), + "query 'app' should match path '/src/app'" + ); + }); + }); + } + + #[gpui::test] + async fn test_position_mapping_host_only_server(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + with_filter_data(cx, &[mock("myhost", &[])], |servers, data| { + let results = run_sync(data, "myh"); + assert_eq!(results.len(), 1); + let host = servers[0].host; + assert!( + results[0].host_positions.iter().all(|&p| p < host.len()), + "host positions {:?} out of bounds for {:?}", + results[0].host_positions, + host, + ); + assert!(results[0].project_matches.is_empty()); + }); + }); + } + + #[gpui::test] + async fn test_unicode_host_and_path_positions(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + with_filter_data(cx, &[mock("señor", &["/código/app"])], |servers, data| { + let results = run_sync(data, "señ app"); + assert_eq!(results.len(), 1); + let result = &results[0]; + let host = servers[0].host; + let path = servers[0].project_paths[0]; + + assert!( + result + .host_positions + .iter() + .all(|&p| p < host.len() && host.is_char_boundary(p)), + "host positions {:?} must be valid char boundaries in {:?}", + result.host_positions, + host, + ); + + assert_eq!(result.project_matches.len(), 1); + let proj = &result.project_matches[0]; + assert!( + proj.path_positions + .iter() + .all(|&p| p < path.len() && path.is_char_boundary(p)), + "path positions {:?} must be valid char boundaries in {:?}", + proj.path_positions, + path, + ); + }); + }); + } + + #[gpui::test] + async fn test_filter_data_build_from_real_entries(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + with_filter_data(cx, &[mock("alpha", &[]), mock("beta", &[])], |_, data| { + assert_eq!(data.server_count, 2); + assert_eq!(data.candidates.len(), 2); + assert_eq!(data.candidates[0].string, "alpha"); + assert_eq!(data.candidates[1].string, "beta"); + + let results = run_sync(data, "alp"); + assert_eq!(results.len(), 1); + assert_eq!(results[0].server_index, 0); + assert!(!results[0].host_positions.is_empty()); + + let empty = run_sync(data, "zzz"); + assert!(empty.is_empty()); + }); + }); + } + + #[gpui::test] + async fn test_run_async_returns_none_when_cancelled(cx: &mut gpui::TestAppContext) { + let data = cx.update(|cx| { + let handle = ScrollHandle::new(); + let entries = build_entries(cx, &handle, &[mock("alpha", &[])]); + FilterData::build(&entries) + }); + let cancel = AtomicBool::new(true); + let executor = cx.background_executor.clone(); + let result = run_async(&data, "alpha", &cancel, executor).await; + assert!( + result.is_none(), + "cancel set before run should short-circuit" + ); + } + + #[gpui::test] + async fn test_run_async_returns_results_when_not_cancelled(cx: &mut gpui::TestAppContext) { + let data = cx.update(|cx| { + let handle = ScrollHandle::new(); + let entries = build_entries(cx, &handle, &[mock("alpha", &["/home/project"])]); + FilterData::build(&entries) + }); + let cancel = AtomicBool::new(false); + let executor = cx.background_executor.clone(); + let results = run_async(&data, "alpha", &cancel, executor) + .await + .expect("uncancelled run should return results"); + assert_eq!(results.len(), 1); + assert_eq!(results[0].server_index, 0); + assert!(!results[0].host_positions.is_empty()); + } + + #[gpui::test] + async fn test_filter_matches_nickname_and_host(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + let servers = [mock_with_nickname("10.0.0.5", "prod", &["/srv/app"])]; + with_filter_data(cx, &servers, |servers, data| { + let nickname = servers[0].nickname.expect("server has a nickname"); + + let by_nickname = run_sync(data, "prod"); + assert_eq!(by_nickname.len(), 1, "nickname should match"); + assert!( + !by_nickname[0].host_positions.is_empty(), + "matching the nickname should highlight it" + ); + assert!( + by_nickname[0] + .host_positions + .iter() + .all(|&p| p < nickname.len()), + "host positions {:?} must stay within the displayed nickname {:?}", + by_nickname[0].host_positions, + nickname, + ); + + let by_host = run_sync(data, "10.0"); + assert_eq!(by_host.len(), 1, "real host should remain searchable"); + assert!( + by_host[0].host_positions.is_empty(), + "alias-only matches are searchable but not highlighted, got {:?}", + by_host[0].host_positions, + ); + }); + }); + } + + #[gpui::test] + async fn test_projects_ordered_by_match_score(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + with_filter_data(cx, &[mock("srv", &["/a", "/b"])], |_, data| { + // candidate 0 -> project 0, candidate 1 -> project 1; feed them + // in descending-score order as `match_strings` would, then check + // the regrouping keeps the higher-scored project first. + let matches = vec![ + StringMatch { + candidate_id: 1, + score: 0.9, + positions: Vec::new(), + string: SharedString::default(), + }, + StringMatch { + candidate_id: 0, + score: 0.5, + positions: Vec::new(), + string: SharedString::default(), + }, + ]; + let results = build_filter_results(matches, data); + assert_eq!(results.len(), 1); + let project_indices: Vec<_> = results[0] + .project_matches + .iter() + .map(|p| p.project_index) + .collect(); + assert_eq!(project_indices, vec![1, 0]); + }); + }); + } +} diff --git a/crates/ui/src/components/label/highlighted_label.rs b/crates/ui/src/components/label/highlighted_label.rs index b3f19e0060..2cac321ab6 100644 --- a/crates/ui/src/components/label/highlighted_label.rs +++ b/crates/ui/src/components/label/highlighted_label.rs @@ -71,6 +71,12 @@ impl HighlightedLabel { pub fn highlight_indices(&self) -> &[usize] { &self.highlight_indices } + + /// Truncates the label from the start, keeping the end visible. + pub fn truncate_start(mut self) -> Self { + self.base = self.base.truncate_start(); + self + } } impl HighlightedLabel {