diff --git a/src/ui/search/score.rs b/src/ui/search/score.rs index 0d485a8b..da40461f 100644 --- a/src/ui/search/score.rs +++ b/src/ui/search/score.rs @@ -17,11 +17,34 @@ pub fn fuzzy_score(query: &str, text: &str) -> Option { return None; } + // Every place the first letter occurs is a place the match could begin, + // and the best of them is the score. Matching greedily from the left + // alone let an early stray letter decide the alignment: `worktree` + // against `New Worktree Tab…` spent its `w` on "New" and scattered the + // rest, scoring the title as a poor match of the very word it contains. + let best = (0..hay.len()) + .filter(|&i| hay[i] == needle[0]) + .filter_map(|start| align(&needle, &hay, start)) + .max()?; + + let mut score = best; + if hay == needle { + score += 120; + } else if hay.starts_with(&needle) { + score += 50; + } + score -= (hay.len() as i32) / 6; + Some(score) +} + +/// `needle` as a subsequence of `hay` beginning at `start`, taken greedily +/// from there; `None` when the rest of it does not fit. +fn align(needle: &[char], hay: &[char], start: usize) -> Option { let mut qi = 0usize; let mut score = 0i32; let mut run = 0i32; let mut prev_hit = false; - for (i, ch) in hay.iter().enumerate() { + for (i, ch) in hay.iter().enumerate().skip(start) { if qi >= needle.len() { break; } @@ -47,17 +70,7 @@ pub fn fuzzy_score(query: &str, text: &str) -> Option { prev_hit = true; qi += 1; } - if qi < needle.len() { - return None; - } - - if hay == needle { - score += 120; - } else if hay.starts_with(&needle) { - score += 50; - } - score -= (hay.len() as i32) / 6; - Some(score) + (qi == needle.len()).then_some(score) } /// How far behind the visible label an alias hit lands. An alias *is* the @@ -117,6 +130,15 @@ mod tests { assert_eq!(frecency_bonus(1_000.0), frecency_bonus(10_000.0)); } + #[test] + fn a_whole_word_is_found_past_an_earlier_stray_letter() { + // The `w` of "New" used to claim the match and scatter the rest. + let whole = fuzzy_score("worktree", "New Worktree Tab…").expect("matches"); + let scattered = fuzzy_score("worktree", "New work on tree").expect("matches"); + assert!(whole > scattered, "{whole} should beat {scattered}"); + assert_eq!(whole, 88); + } + #[test] fn empty_query_matches_everything() { assert_eq!(fuzzy_score("", "anything"), Some(0)); diff --git a/src/ui/search/sources.rs b/src/ui/search/sources.rs index af71211e..b1c205fc 100644 --- a/src/ui/search/sources.rs +++ b/src/ui/search/sources.rs @@ -154,7 +154,7 @@ impl Catalog { let mut found: Vec<(i32, Section)> = tabs .filter_map(|s| { let hits = s.search(query, cx); - let best = hits.first()?.0; + let best = hits.first()?.0 - section_bias(s.tab()); let hidden = hits.len().saturating_sub(ALL_TAB_ROWS); let mut rows: Vec = hits .into_iter() @@ -181,6 +181,25 @@ impl Catalog { } } +/// How far a past session's best row stands back when the All tab orders its +/// sections — a little more than a prefix match is worth. +/// +/// A session is titled with the first thing someone typed to an agent, and +/// people start those with the words the commands are named after: +/// `Worktree cleanup` took the prefix bonus from `New Worktree Tab…`, so +/// `worktree` then Return resumed an agent instead of opening the dialog — +/// the heaviest thing on the page picked by a two-word guess. A session +/// still leads when it is plainly the better answer; the Sessions tab itself +/// ranks untouched. +const SESSION_SECTION_BIAS: i32 = 64; + +fn section_bias(tab: SearchTab) -> i32 { + match tab { + SearchTab::Sessions => SESSION_SECTION_BIAS, + _ => 0, + } +} + /// A tab's rows on the All tab before anything is typed: what it has been /// used for lately first, topped up from the rest of the tab to /// [`ALL_TAB_ROWS`], then a row into the tab for whatever did not fit. An @@ -693,6 +712,26 @@ mod tests { .in_section(section.to_string()) } + #[gpui::test] + fn a_session_named_like_a_command_does_not_take_return_from_it(cx: &mut TestAppContext) { + with_config(cx); + let mut catalog = Catalog::new( + vec![Item::new("New Worktree Tab…", CommandKind::NewWorktreeTab)], + Vec::new(), + Vec::new(), + ); + catalog.sessions = vec![session("Worktree cleanup", "Recent")]; + cx.update(|cx| { + let sections = catalog.sections(SearchTab::All, "worktree", cx); + let headers: Vec<_> = sections.iter().filter_map(|s| s.title.clone()).collect(); + assert_eq!(headers, vec!["Actions", "Sessions"]); + + // Typed out, the session is the answer, and it leads. + let sections = catalog.sections(SearchTab::All, "worktree cleanup", cx); + assert_eq!(sections[0].title.as_deref(), Some("Sessions")); + }); + } + /// Before anything is typed, the All tab offers to pick up where you left /// off here first; past its rows, the rest of the tab is one row away. #[gpui::test]