Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T03:35:00.339Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

filter.rs

569 lines · 20.6 KB · rust
1//! Fuzzy filtering for the remote-projects modal.
2//!
3//! At construction time we build [`FilterData`]: a flat list of
4//! [`StringMatchCandidate`]s with one candidate per project for servers that
5//! have projects, and one candidate per server (over the host name) for
6//! project-less servers and SSH-config-only entries. Each candidate matches
7//! against the displayed host plus any search-only alias (the real SSH host
8//! when a nickname hides it), so a server stays findable by either name. Each
9//! candidate is tagged with [`CandidateMeta`] recording which server (and
10//! project, if any) it represents. This snapshot is reused for every
11//! keystroke; we only rebuild it when the set of servers changes.
12//!
13//! On each query, `fuzzy_nucleo::match_strings[_async]` returns matches sorted
14//! by score. [`build_filter_results`] regroups those matches by server (a
15//! server with N projects can contribute up to N matches) and folds each
16//! bucket into a single [`FilteredServer`] carrying highlight positions and
17//! the best score across its candidates.
18//!
19//! Projects inside a [`FilteredServer`] are intentionally ordered by fuzzy
20//! score, not by their position in the source list — when a query matches
21//! only the host name, the server's projects come back ranked by how well
22//! each one also matched.
23//!
24//! The caller stores the resulting `Vec<FilteredServer>` in
25//! [`DefaultState::filtered_servers`] and renders by looking up each
26//! `server_index` / `project_index` against the unchanged source list.
27
28use std::sync::atomic::{self, AtomicBool};
29
30use fuzzy_nucleo::{StringMatch, StringMatchCandidate};
31use gpui::BackgroundExecutor;
32
33use super::RemoteEntry;
34
35#[derive(Debug)]
36pub(super) struct FilterData {
37    pub(super) candidates: Vec<StringMatchCandidate>,
38    pub(super) meta: Vec<CandidateMeta>,
39    pub(super) server_count: usize,
40}
41
42#[derive(Debug)]
43pub(super) struct CandidateMeta {
44    pub(super) server_index: usize,
45    pub(super) project_index: Option<usize>,
46    /// Byte length of the host text that is actually displayed (and thus
47    /// highlightable) as the server's primary label. Host match positions at
48    /// or beyond this are dropped — they fall inside the search-only alias.
49    pub(super) display_host_byte_len: usize,
50    /// Byte length of the full searchable host text (display host plus any
51    /// alias), used to find where the project-path portion of the combined
52    /// candidate string begins.
53    pub(super) match_host_byte_len: usize,
54}
55
56#[derive(Clone, Debug)]
57pub(super) struct FilteredServer {
58    pub(super) server_index: usize,
59    pub(super) host_positions: Vec<usize>,
60    pub(super) project_matches: Vec<FilteredProject>,
61    pub(super) score: f64,
62}
63
64#[derive(Clone, Debug)]
65pub(super) struct FilteredProject {
66    pub(super) project_index: usize,
67    pub(super) path_positions: Vec<usize>,
68}
69
70impl FilterData {
71    pub(super) fn build(servers: &[RemoteEntry]) -> Self {
72        let mut candidates = Vec::new();
73        let mut meta = Vec::new();
74        for (server_index, server) in servers.iter().enumerate() {
75            let display_host = server.display_host();
76            let display_host_byte_len = display_host.len();
77            let search_host = match server.host_alias() {
78                Some(alias) => format!("{display_host} {alias}"),
79                None => display_host.to_string(),
80            };
81            let match_host_byte_len = search_host.len();
82            match server {
83                RemoteEntry::Project { projects, .. } if !projects.is_empty() => {
84                    for (project_index, entry) in projects.iter().enumerate() {
85                        let combined = format!("{search_host} {}", entry.project.paths.join(", "));
86                        meta.push(CandidateMeta {
87                            server_index,
88                            project_index: Some(project_index),
89                            display_host_byte_len,
90                            match_host_byte_len,
91                        });
92                        candidates.push(StringMatchCandidate::new(candidates.len(), combined));
93                    }
94                }
95                RemoteEntry::Project { .. } | RemoteEntry::SshConfig { .. } => {
96                    meta.push(CandidateMeta {
97                        server_index,
98                        project_index: None,
99                        display_host_byte_len,
100                        match_host_byte_len,
101                    });
102                    candidates.push(StringMatchCandidate::new(candidates.len(), search_host));
103                }
104            }
105        }
106        Self {
107            candidates,
108            meta,
109            server_count: servers.len(),
110        }
111    }
112}
113
114pub(super) fn build_filter_results(
115    matches: Vec<StringMatch>,
116    filter_data: &FilterData,
117) -> Vec<FilteredServer> {
118    group_matches_by_server(matches, filter_data)
119        .into_iter()
120        .enumerate()
121        .filter_map(|(server_index, group)| {
122            (!group.is_empty()).then(|| build_server_result(server_index, group))
123        })
124        .collect()
125}
126
127fn group_matches_by_server(
128    matches: Vec<StringMatch>,
129    filter_data: &FilterData,
130) -> Vec<Vec<(StringMatch, &CandidateMeta)>> {
131    let mut buckets: Vec<Vec<_>> = (0..filter_data.server_count).map(|_| Vec::new()).collect();
132    for m in matches {
133        let Some(meta) = filter_data.meta.get(m.candidate_id) else {
134            continue;
135        };
136        let Some(bucket) = buckets.get_mut(meta.server_index) else {
137            continue;
138        };
139        bucket.push((m, meta));
140    }
141    buckets
142}
143
144fn build_server_result(
145    server_index: usize,
146    group: Vec<(StringMatch, &CandidateMeta)>,
147) -> FilteredServer {
148    debug_assert!(!group.is_empty(), "empty groups are filtered out upstream");
149
150    let mut host_positions = Vec::new();
151    let mut project_matches = Vec::new();
152    let mut score = f64::NEG_INFINITY;
153
154    for (m, meta) in group {
155        score = score.max(m.score);
156        // `FilterData::build` emits either one host-only candidate or one
157        // candidate per project for a server, never a mix, so the `None` arm
158        // assigning `host_positions` can't clobber positions accumulated by
159        // the `Some` arm.
160        match meta.project_index {
161            None => {
162                host_positions = m
163                    .positions
164                    .into_iter()
165                    .filter(|&p| p < meta.display_host_byte_len)
166                    .collect();
167            }
168            Some(project_index) => {
169                // +1 accounts for the single-byte space separator in
170                // format!("{search_host} {paths}") used by FilterData::build.
171                // Positions inside the search-only host alias (between the
172                // displayed host and the separator) are dropped from both
173                // sides — they index into content that isn't shown anywhere.
174                let host_prefix_len = meta.match_host_byte_len + 1;
175                host_positions.extend(
176                    m.positions
177                        .iter()
178                        .copied()
179                        .filter(|&p| p < meta.display_host_byte_len),
180                );
181                project_matches.push(FilteredProject {
182                    project_index,
183                    path_positions: m
184                        .positions
185                        .into_iter()
186                        .filter_map(|p| p.checked_sub(host_prefix_len))
187                        .collect(),
188                });
189            }
190        }
191    }
192
193    host_positions.sort_unstable();
194    host_positions.dedup();
195
196    FilteredServer {
197        server_index,
198        host_positions,
199        project_matches,
200        score,
201    }
202}
203
204pub(super) fn run_sync(data: &FilterData, query: &str) -> Vec<FilteredServer> {
205    let case = fuzzy_nucleo::Case::smart_if_uppercase_in(query);
206    let matches = fuzzy_nucleo::match_strings(
207        &data.candidates,
208        query,
209        case,
210        fuzzy_nucleo::LengthPenalty::Off,
211        data.candidates.len(),
212    );
213    let mut results = build_filter_results(matches, data);
214    results.sort_by(|a, b| b.score.total_cmp(&a.score));
215    results
216}
217
218pub(super) async fn run_async(
219    data: &FilterData,
220    query: &str,
221    cancel: &AtomicBool,
222    executor: BackgroundExecutor,
223) -> Option<Vec<FilteredServer>> {
224    let case = fuzzy_nucleo::Case::smart_if_uppercase_in(query);
225    let matches = fuzzy_nucleo::match_strings_async(
226        &data.candidates,
227        query,
228        case,
229        fuzzy_nucleo::LengthPenalty::Off,
230        data.candidates.len(),
231        cancel,
232        executor,
233    )
234    .await;
235    if cancel.load(atomic::Ordering::Acquire) {
236        return None;
237    }
238    let mut results = build_filter_results(matches, data);
239    results.sort_by(|a, b| b.score.total_cmp(&a.score));
240    Some(results)
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use crate::remote_connections::{Connection, SshConnection};
247    use crate::remote_servers::{ProjectEntry, ServerIndex, SshServerIndex};
248    use settings::RemoteProject;
249    use ui::SharedString;
250
251    struct MockServer {
252        host: &'static str,
253        nickname: Option<&'static str>,
254        project_paths: &'static [&'static str],
255    }
256
257    fn mock(host: &'static str, project_paths: &'static [&'static str]) -> MockServer {
258        MockServer {
259            host,
260            nickname: None,
261            project_paths,
262        }
263    }
264
265    fn mock_with_nickname(
266        host: &'static str,
267        nickname: &'static str,
268        project_paths: &'static [&'static str],
269    ) -> MockServer {
270        MockServer {
271            host,
272            nickname: Some(nickname),
273            project_paths,
274        }
275    }
276
277    fn build_entries(servers: &[MockServer]) -> Vec<RemoteEntry> {
278        servers
279            .iter()
280            .map(|server| {
281                if server.project_paths.is_empty() {
282                    RemoteEntry::SshConfig {
283                        host: SharedString::from(server.host),
284                    }
285                } else {
286                    let projects = server
287                        .project_paths
288                        .iter()
289                        .map(|path| ProjectEntry {
290                            project: RemoteProject {
291                                paths: vec![(*path).to_string()],
292                            },
293                        })
294                        .collect();
295                    let connection = Connection::Ssh(SshConnection {
296                        host: server.host.to_string(),
297                        nickname: server.nickname.map(str::to_string),
298                        projects: server
299                            .project_paths
300                            .iter()
301                            .map(|p| RemoteProject {
302                                paths: vec![(*p).to_string()],
303                            })
304                            .collect(),
305                        ..Default::default()
306                    });
307                    RemoteEntry::Project {
308                        projects,
309                        connection,
310                        index: ServerIndex::Ssh(SshServerIndex(0)),
311                    }
312                }
313            })
314            .collect()
315    }
316
317    fn with_filter_data<R>(
318        servers: &[MockServer],
319        f: impl FnOnce(&[MockServer], &FilterData) -> R,
320    ) -> R {
321        let entries = build_entries(servers);
322        let data = FilterData::build(&entries);
323        f(servers, &data)
324    }
325
326    #[test]
327    fn test_filter_host_only() {
328        with_filter_data(&[mock("myhost", &[])], |_, data| {
329            let results = run_sync(data, "myh");
330            assert_eq!(results.len(), 1);
331            assert_eq!(results[0].server_index, 0);
332            assert!(!results[0].host_positions.is_empty());
333        });
334    }
335
336    #[test]
337    fn test_filter_no_match() {
338        with_filter_data(&[mock("myhost", &["/home/project"])], |_, data| {
339            let results = run_sync(data, "zzz");
340            assert!(results.is_empty());
341        });
342    }
343
344    #[test]
345    fn test_filter_project_path_match() {
346        with_filter_data(&[mock("myhost", &["/home/user/project"])], |_, data| {
347            let results = run_sync(data, "project");
348            assert_eq!(results.len(), 1);
349            assert_eq!(results[0].project_matches.len(), 1);
350            assert_eq!(results[0].project_matches[0].project_index, 0);
351        });
352    }
353
354    #[test]
355    fn test_filter_host_match_includes_all_projects() {
356        with_filter_data(&[mock("myhost", &["/path/a", "/path/b"])], |_, data| {
357            let results = run_sync(data, "myhost");
358            assert_eq!(results.len(), 1);
359            assert_eq!(results[0].project_matches.len(), 2);
360        });
361    }
362
363    #[test]
364    fn test_filter_excludes_non_matching_servers() {
365        with_filter_data(
366            &[mock("alpha", &["/path/a"]), mock("beta", &["/path/b"])],
367            |_, data| {
368                let results = run_sync(data, "alpha");
369                assert_eq!(results.len(), 1);
370                assert_eq!(results[0].server_index, 0);
371            },
372        );
373    }
374
375    #[test]
376    fn test_position_mapping_splits_host_and_path() {
377        with_filter_data(&[mock("dev", &["/src/app"])], |servers, data| {
378            let results = run_sync(data, "dev app");
379
380            assert_eq!(results.len(), 1);
381            let result = &results[0];
382            let host = servers[result.server_index].host;
383            let path = servers[result.server_index].project_paths[0];
384
385            assert!(
386                result.host_positions.iter().all(|&p| p < host.len()),
387                "host positions {:?} must be within host {:?} (len {})",
388                result.host_positions,
389                host,
390                host.len(),
391            );
392
393            assert_eq!(result.project_matches.len(), 1);
394            let proj = &result.project_matches[0];
395            assert_eq!(proj.project_index, 0);
396            assert!(
397                proj.path_positions.iter().all(|&p| p < path.len()),
398                "path positions {:?} must be within path {:?} (len {})",
399                proj.path_positions,
400                path,
401                path.len(),
402            );
403
404            assert!(
405                !result.host_positions.is_empty(),
406                "query 'dev' should match host 'dev'"
407            );
408            assert!(
409                !proj.path_positions.is_empty(),
410                "query 'app' should match path '/src/app'"
411            );
412        });
413    }
414
415    #[test]
416    fn test_position_mapping_host_only_server() {
417        with_filter_data(&[mock("myhost", &[])], |servers, data| {
418            let results = run_sync(data, "myh");
419            assert_eq!(results.len(), 1);
420            let host = servers[0].host;
421            assert!(
422                results[0].host_positions.iter().all(|&p| p < host.len()),
423                "host positions {:?} out of bounds for {:?}",
424                results[0].host_positions,
425                host,
426            );
427            assert!(results[0].project_matches.is_empty());
428        });
429    }
430
431    #[test]
432    fn test_unicode_host_and_path_positions() {
433        with_filter_data(&[mock("señor", &["/código/app"])], |servers, data| {
434            let results = run_sync(data, "señ app");
435            assert_eq!(results.len(), 1);
436            let result = &results[0];
437            let host = servers[0].host;
438            let path = servers[0].project_paths[0];
439
440            assert!(
441                result
442                    .host_positions
443                    .iter()
444                    .all(|&p| p < host.len() && host.is_char_boundary(p)),
445                "host positions {:?} must be valid char boundaries in {:?}",
446                result.host_positions,
447                host,
448            );
449
450            assert_eq!(result.project_matches.len(), 1);
451            let proj = &result.project_matches[0];
452            assert!(
453                proj.path_positions
454                    .iter()
455                    .all(|&p| p < path.len() && path.is_char_boundary(p)),
456                "path positions {:?} must be valid char boundaries in {:?}",
457                proj.path_positions,
458                path,
459            );
460        });
461    }
462
463    #[test]
464    fn test_filter_data_build_from_real_entries() {
465        with_filter_data(&[mock("alpha", &[]), mock("beta", &[])], |_, data| {
466            assert_eq!(data.server_count, 2);
467            assert_eq!(data.candidates.len(), 2);
468            assert_eq!(data.candidates[0].string, "alpha");
469            assert_eq!(data.candidates[1].string, "beta");
470
471            let results = run_sync(data, "alp");
472            assert_eq!(results.len(), 1);
473            assert_eq!(results[0].server_index, 0);
474            assert!(!results[0].host_positions.is_empty());
475
476            let empty = run_sync(data, "zzz");
477            assert!(empty.is_empty());
478        });
479    }
480
481    #[gpui::test]
482    async fn test_run_async_returns_none_when_cancelled(cx: &mut gpui::TestAppContext) {
483        let data = FilterData::build(&build_entries(&[mock("alpha", &[])]));
484        let cancel = AtomicBool::new(true);
485        let executor = cx.background_executor.clone();
486        let result = run_async(&data, "alpha", &cancel, executor).await;
487        assert!(
488            result.is_none(),
489            "cancel set before run should short-circuit"
490        );
491    }
492
493    #[gpui::test]
494    async fn test_run_async_returns_results_when_not_cancelled(cx: &mut gpui::TestAppContext) {
495        let data = FilterData::build(&build_entries(&[mock("alpha", &["/home/project"])]));
496        let cancel = AtomicBool::new(false);
497        let executor = cx.background_executor.clone();
498        let results = run_async(&data, "alpha", &cancel, executor)
499            .await
500            .expect("uncancelled run should return results");
501        assert_eq!(results.len(), 1);
502        assert_eq!(results[0].server_index, 0);
503        assert!(!results[0].host_positions.is_empty());
504    }
505
506    #[test]
507    fn test_filter_matches_nickname_and_host() {
508        let servers = [mock_with_nickname("10.0.0.5", "prod", &["/srv/app"])];
509        with_filter_data(&servers, |servers, data| {
510            let nickname = servers[0].nickname.expect("server has a nickname");
511
512            let by_nickname = run_sync(data, "prod");
513            assert_eq!(by_nickname.len(), 1, "nickname should match");
514            assert!(
515                !by_nickname[0].host_positions.is_empty(),
516                "matching the nickname should highlight it"
517            );
518            assert!(
519                by_nickname[0]
520                    .host_positions
521                    .iter()
522                    .all(|&p| p < nickname.len()),
523                "host positions {:?} must stay within the displayed nickname {:?}",
524                by_nickname[0].host_positions,
525                nickname,
526            );
527
528            let by_host = run_sync(data, "10.0");
529            assert_eq!(by_host.len(), 1, "real host should remain searchable");
530            assert!(
531                by_host[0].host_positions.is_empty(),
532                "alias-only matches are searchable but not highlighted, got {:?}",
533                by_host[0].host_positions,
534            );
535        });
536    }
537
538    #[test]
539    fn test_projects_ordered_by_match_score() {
540        with_filter_data(&[mock("srv", &["/a", "/b"])], |_, data| {
541            // candidate 0 -> project 0, candidate 1 -> project 1; feed them
542            // in descending-score order as `match_strings` would, then check
543            // the regrouping keeps the higher-scored project first.
544            let matches = vec![
545                StringMatch {
546                    candidate_id: 1,
547                    score: 0.9,
548                    positions: Vec::new(),
549                    string: SharedString::default(),
550                },
551                StringMatch {
552                    candidate_id: 0,
553                    score: 0.5,
554                    positions: Vec::new(),
555                    string: SharedString::default(),
556                },
557            ];
558            let results = build_filter_results(matches, data);
559            assert_eq!(results.len(), 1);
560            let project_indices: Vec<_> = results[0]
561                .project_matches
562                .iter()
563                .map(|p| p.project_index)
564                .collect();
565            assert_eq!(project_indices, vec![1, 0]);
566        });
567    }
568}
569
Served at tenant.openagents/omega Member data and write actions are omitted.