project: Add fuzzy search in remote projects dialogue (#54143)
Stacked on top of #54123 This is part 3 of 3 towards #51197 More details from the original PR https://github.com/zed-industries/zed/pull/53551 This PR implements fuzzy search in the remote projects dialogue, using the `fuzzy_nucleo::match_strings` introduced in #54123. ## remote project search The reviews of https://github.com/zed-industries/zed/pull/51197 seemed to indicate that performance was important here so it runs async and cancels the previous stale request when a new character is typed. other features: text highlighting of what was being typed, similar to other fuzzy searches. reorder the projects on the list based on the maximum score from its projects. video of it in action: https://github.com/user-attachments/assets/86b3ed85-fc29-46fb-b3c4-c0cbc3bf8edd Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #53318 Release Notes: - recent_projects: Added fuzzy search to the remote projects modal.
This commit is contained in:
1 parent
65013c98f3
commit
ee321d34d3
3 files changed
+946
-52
No files matched your search
@@ -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<Editor>,
|
||||
workspace: WeakEntity<Workspace>,
|
||||
retained_connections: Vec<Entity<RemoteClient>>,
|
||||
ssh_config_updates: Task<()>,
|
||||
ssh_config_servers: BTreeSet<SharedString>,
|
||||
create_new_window: bool,
|
||||
dev_container_picker: Option<Entity<Picker<DevContainerPickerDelegate>>>,
|
||||
_subscription: Subscription,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
filter_cancel: Arc<AtomicBool>,
|
||||
_filter_task: Task<()>,
|
||||
allow_dismissal: bool,
|
||||
}
|
||||
|
||||
@@ -622,11 +627,17 @@ impl From<WslServerIndex> for ServerIndex {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ProjectEntry {
|
||||
navigation: NavigableEntry,
|
||||
project: RemoteProject,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum RemoteEntry {
|
||||
Project {
|
||||
open_folder: NavigableEntry,
|
||||
projects: Vec<(NavigableEntry, RemoteProject)>,
|
||||
projects: Vec<ProjectEntry>,
|
||||
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<RemoteEntry>,
|
||||
/// `None` when no filter is active; `Some` carries the fuzzy match results
|
||||
/// (server/project indices plus highlight positions) sorted by score.
|
||||
filtered_servers: Option<Vec<FilteredServer>>,
|
||||
filter_data: Arc<FilterData>,
|
||||
}
|
||||
|
||||
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<VisibleEntry<'_>> {
|
||||
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<VisibleProject<'a>>,
|
||||
}
|
||||
|
||||
struct VisibleProject<'a> {
|
||||
entry: &'a ProjectEntry,
|
||||
highlight_positions: &'a [usize],
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -886,6 +1007,11 @@ impl RemoteServerProjects {
|
||||
cx: &mut Context<Self>,
|
||||
) -> 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::<SettingsStore>(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<Self>) {
|
||||
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<Self>) {
|
||||
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<Self>,
|
||||
) -> 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<Connection>,
|
||||
ix: usize,
|
||||
(navigation, project): &(NavigableEntry, RemoteProject),
|
||||
visible: &VisibleProject<'_>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> 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<Self>,
|
||||
) -> 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());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<FilteredServer>` 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<StringMatchCandidate>,
|
||||
pub(super) meta: Vec<CandidateMeta>,
|
||||
pub(super) server_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct CandidateMeta {
|
||||
pub(super) server_index: usize,
|
||||
pub(super) project_index: Option<usize>,
|
||||
/// 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<usize>,
|
||||
pub(super) project_matches: Vec<FilteredProject>,
|
||||
pub(super) score: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct FilteredProject {
|
||||
pub(super) project_index: usize,
|
||||
pub(super) path_positions: Vec<usize>,
|
||||
}
|
||||
|
||||
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<StringMatch>,
|
||||
filter_data: &FilterData,
|
||||
) -> Vec<FilteredServer> {
|
||||
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<StringMatch>,
|
||||
filter_data: &FilterData,
|
||||
) -> Vec<Vec<(StringMatch, &CandidateMeta)>> {
|
||||
let mut buckets: Vec<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<FilteredServer> {
|
||||
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<Vec<FilteredServer>> {
|
||||
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<RemoteEntry> {
|
||||
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<R>(
|
||||
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]);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user