fix(ui): scope package manager search to the actively viewed category

This commit is contained in:
LynithDev 2026-08-01 11:49:40 +02:00
parent 2e7bc89dbc
commit e6515c21ed
2 changed files with 142 additions and 20 deletions

View file

@ -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<PackageEntry> {
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<String> {
let mut cats: Vec<String> = 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<PackageEntry> = 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<PackageEntry> {
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"]);
}
}

View file

@ -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<String>,
},
}
#[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."))