Skip to repository content569 lines · 20.6 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T03:35:00.339Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
filter.rs
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