From e6515c21ed81a5096f7956a48273ebbdbb109500 Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:49:40 +0200 Subject: [PATCH] fix(ui): scope package manager search to the actively viewed category --- .../view/app/cluster/package_manager/mod.rs | 128 +++++++++++++++--- .../view/app/cluster/package_manager/views.rs | 34 ++++- 2 files changed, 142 insertions(+), 20 deletions(-) diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs index 019bdf08..057fb6ad 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs @@ -248,6 +248,32 @@ impl Tab { } } +/// `query` is expected to be lowercased already. +fn matches_query(p: &PackageEntry, query: &str) -> bool { + query.is_empty() + || p.name.to_lowercase().contains(query) + || p.file_name.to_lowercase().contains(query) +} + +/// Rows to show for the current toolbar state. The search is scoped to the +/// active tab: a package has to belong to the tab *and* match the query, so +/// typing in a category never pulls in packages from another one. `Tab::All` +/// admits everything, which keeps a search from there a search over the whole +/// cluster. +fn visible_packages( + items: &[PackageEntry], + tab: Option<&Tab>, + query: &str, + show: EnabledFilter, +) -> Vec { + items + .iter() + .filter(|p| tab.is_none_or(|t| t.matches(p)) && matches_query(p, query)) + .filter(|p| show.keep(p)) + .cloned() + .collect() +} + pub fn bundle_categories(bundles: &[BundleWithUpdateStatus]) -> Vec { let mut cats: Vec = Vec::new(); for bundle in bundles { @@ -347,26 +373,28 @@ impl Component for PackageManager { let card_layout = CardLayout::from(*layout.read()); let tab_filter = tabs.get(active_idx); - let content_kind = match tab_filter { - Some(Tab::Browser) => ContentKind::Browser, - Some(Tab::Local) => ContentKind::Local, - _ => ContentKind::Other, - }; - - let mut filtered: Vec = items - .iter() - .filter(|p| tab_filter.is_none_or(|t| t.matches(p))) - .filter(|p| { - query.is_empty() - || p.name.to_lowercase().contains(query.as_str()) - || p.file_name.to_lowercase().contains(query.as_str()) - }) - .filter(|p| show.keep(p)) - .cloned() - .collect(); + let mut filtered = visible_packages(&items, tab_filter, &query, show); sort_mode.sort(&mut filtered); + // Coming up empty during a search is about the query, not about the tab + // having nothing in it, so it gets its own empty state naming the tab + // that was searched. + let content_kind = if filtered.is_empty() && !query.is_empty() { + ContentKind::NoMatches { + scope: match tab_filter { + Some(Tab::All) | None => None, + Some(tab) => Some(tab.label()), + }, + } + } else { + match tab_filter { + Some(Tab::Browser) => ContentKind::Browser, + Some(Tab::Local) => ContentKind::Local, + _ => ContentKind::Other, + } + }; + rect() .vertical() .width(Size::fill()) @@ -395,3 +423,69 @@ impl Component for PackageManager { )) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(name: &str, provider: ProviderId, categories: &[&str]) -> PackageEntry { + PackageEntry { + package_id: name.to_string(), + bundle_name: (!categories.is_empty()).then(|| "bundle".to_string()), + provider, + name: name.to_string(), + file_name: format!("{}.jar", name.to_lowercase()), + author: String::new(), + description: String::new(), + icon_url: None, + size: 0, + categories: categories.iter().map(|c| (*c).to_string()).collect(), + enabled: true, + manifest_default: true, + installed: true, + hash: None, + } + } + + fn items() -> Vec { + vec![ + entry("Sodium", ProviderId::Modrinth, &["Performance"]), + entry("Sodium Extra", ProviderId::Modrinth, &["Visuals"]), + entry("Sodium Local", ProviderId::Local, &[]), + ] + } + + fn names(rows: &[PackageEntry]) -> Vec<&str> { + rows.iter().map(|p| p.name.as_str()).collect() + } + + #[test] + fn search_stays_inside_the_active_category() { + let items = items(); + let tab = Tab::Category("Performance".to_string()); + let rows = visible_packages(&items, Some(&tab), "sodium", EnabledFilter::All); + assert_eq!(names(&rows), ["Sodium"]); + } + + #[test] + fn search_stays_inside_the_local_tab() { + let items = items(); + let rows = visible_packages(&items, Some(&Tab::Local), "sodium", EnabledFilter::All); + assert_eq!(names(&rows), ["Sodium Local"]); + } + + #[test] + fn all_tab_searches_every_package() { + let items = items(); + let rows = visible_packages(&items, Some(&Tab::All), "sodium", EnabledFilter::All); + assert_eq!(names(&rows), ["Sodium", "Sodium Extra", "Sodium Local"]); + } + + #[test] + fn empty_query_keeps_the_whole_tab() { + let items = items(); + let tab = Tab::Category("Visuals".to_string()); + let rows = visible_packages(&items, Some(&tab), "", EnabledFilter::All); + assert_eq!(names(&rows), ["Sodium Extra"]); + } +} diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs index e16bd69d..a09b4f87 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs @@ -490,11 +490,16 @@ fn browse_button(cluster_id: i64, package_type: &'static str) -> impl IntoElemen .text("Browse Content") } -#[derive(Clone, Copy, PartialEq)] +#[derive(Clone, PartialEq)] pub(super) enum ContentKind { Browser, Local, Other, + /// A search returned nothing. `scope` names the tab that was searched when + /// it is narrower than "All". + NoMatches { + scope: Option, + }, } #[derive(PartialEq)] @@ -538,7 +543,7 @@ impl Component for ContentBox { let content_type = self.content_type; let cluster_id = self.cluster_id; let noun_plural = self.noun_plural; - let kind = self.kind; + let kind = &self.kind; let layout = self.layout; let dispatch = use_dispatch(); @@ -575,6 +580,9 @@ impl Component for ContentBox { local_empty(cluster_id, content_type, dispatch.clone(), noun_plural).into_element() } ContentKind::Other => empty_state(noun_plural).into_element(), + ContentKind::NoMatches { scope } => { + search_empty(noun_plural, scope.as_deref()).into_element() + } }); let header = (count > 0) @@ -590,7 +598,7 @@ impl Component for ContentBox { )) .into_element(), ), - ContentKind::Other => None, + ContentKind::Other | ContentKind::NoMatches { .. } => None, }) .flatten(); @@ -713,6 +721,26 @@ fn empty_state(noun_plural: &'static str) -> impl IntoElement { .into_element() } +/// The search only ever looks inside the active tab, so say which one came up +/// empty and point at the tab that does cover everything. +fn search_empty(noun_plural: &'static str, scope: Option<&str>) -> impl IntoElement { + let (title, hint) = match scope { + Some(tab) => ( + format!("No {noun_plural} in \"{tab}\" match your search."), + "Try a different term, or search from the All tab.", + ), + None => ( + format!("No {noun_plural} match your search."), + "Try a different term.", + ), + }; + + empty_shell(IconType::SearchMd) + .child(empty_title(title)) + .child(empty_hint(hint)) + .into_element() +} + fn browser_empty(cluster_id: i64, package_type: &'static str) -> impl IntoElement { empty_shell(IconType::SearchMd) .child(empty_title("No content installed from the browser."))