diff --git a/packages/oneclient_app/src/components/package_row.rs b/packages/oneclient_app/src/components/package_row.rs index cfd26ccd..61cedc4c 100644 --- a/packages/oneclient_app/src/components/package_row.rs +++ b/packages/oneclient_app/src/components/package_row.rs @@ -44,6 +44,9 @@ pub struct PackageEntry { pub installed: bool, pub hash: Option, pub manifest_default: bool, + /// The bundle ships this as a private dependency. Only ever set for bundle + /// rows, and only surfaced when the user asks for hidden packages. + pub hidden: bool, /// The launch-time check found a newer version for this artifact. Only ever /// set for browser-installed content: bundle packages report their staleness /// through the bundle update flow instead. 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 874516b0..b2738311 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 @@ -9,7 +9,7 @@ use crate::components::{CardLayout, PackageEntry}; use crate::hooks::{package_meta_batch, use_game_snapshot, use_package_meta_batch, use_view_state}; mod views; -use views::{ContentBox, ContentKind, EnabledFilter, SortMode, toolbar_bar}; +use views::{ContentBox, ContentKind, EnabledFilter, HiddenFilter, SortMode, toolbar_bar}; const CARD_H: f32 = 84.; const CARD_SPACING: f32 = 8.; @@ -75,6 +75,9 @@ pub fn use_content_meta( /// `stale` is the set of artifact hashes the last browser update check found a /// newer version for. Bundle-provided rows are built before it is consulted and /// never carry the flag, so the two update flows cannot both claim a package. +/// +/// Rows for a bundle's hidden files are included and flagged; whether they +/// reach the screen is [`HiddenFilter`]'s call. pub fn bundle_packages( content: Vec, bundles: &[BundleWithUpdateStatus], @@ -123,13 +126,6 @@ pub fn bundle_packages( if !seen.insert(pid.clone()) { continue; } - // Hidden files are dependencies the bundle manages on the user's - // behalf: never listed, never toggleable. The id still counts as - // seen so the loose-content pass below does not resurrect the - // installed artifact as an unmanaged local file. - if file.hidden { - continue; - } let provider = match &file.kind { BundleFileKind::Managed { provider, .. } => *provider, @@ -165,6 +161,11 @@ pub fn bundle_packages( meta, file.display_name(), false, + // Emitted as an ordinary row carrying the flag rather than + // dropped here: the row is what `HiddenFilter` filters on, and + // the id counting as seen keeps the loose-content pass below + // from resurrecting the installed artifact as a local file. + file.hidden, )); } } @@ -192,6 +193,7 @@ pub fn bundle_packages( .clone() .unwrap_or_else(|| info.file_name.clone()), outdated, + false, )); } @@ -211,6 +213,7 @@ fn make_row( meta: &PackageMetaMap, fallback_name: String, update_available: bool, + hidden: bool, ) -> PackageEntry { let m = meta.get(&(provider, package_id.clone())); let name = m @@ -250,6 +253,7 @@ fn make_row( installed: installed_info.is_some(), hash: installed_info.map(|i| i.hash.clone()), update_available, + hidden, } } @@ -300,11 +304,12 @@ fn visible_packages( tab: Option<&Tab>, query: &str, show: EnabledFilter, + hidden: HiddenFilter, ) -> Vec { items .iter() .filter(|p| tab.is_none_or(|t| t.matches(p)) && matches_query(p, query)) - .filter(|p| show.keep(p)) + .filter(|p| show.keep(p) && hidden.keep(p)) .cloned() .collect() } @@ -320,9 +325,11 @@ pub fn bundle_categories(bundles: &[BundleWithUpdateStatus]) -> Vec { cats } -fn build_tabs(categories: &[String], items: &[PackageEntry]) -> Vec { +/// `hidden` is applied here too, so a category whose only members are hidden +/// bundle dependencies does not offer a tab that leads to an empty list. +fn build_tabs(categories: &[String], items: &[PackageEntry], hidden: HiddenFilter) -> Vec { let mut cats: Vec = categories.to_vec(); - for item in items { + for item in items.iter().filter(|p| hidden.keep(p)) { for c in &item.categories { if !cats.contains(c) { cats.push(c.clone()); @@ -335,7 +342,7 @@ fn build_tabs(categories: &[String], items: &[PackageEntry]) -> Vec { tabs.extend( cats.into_iter() .map(Tab::Category) - .filter(|t| items.iter().any(|p| t.matches(p))), + .filter(|t| items.iter().any(|p| hidden.keep(p) && t.matches(p))), ); tabs.push(Tab::Browser); @@ -384,16 +391,15 @@ impl Component for PackageManager { let cluster_id = self.cluster_id; let content_type = self.content_type; - let tabs = build_tabs(&self.categories, &items); // Minecraft reads its content once at startup, so a toggle made now is // recorded but cannot reach the session already playing. Say so rather // than letting the switch look like it did nothing. let session_live = use_game_snapshot().is_active(cluster_id); let active = use_state(|| 0usize); - let active_idx = (*active.read()).min(tabs.len().saturating_sub(1)); let search = use_state(String::new); let enabled_filter = use_state(|| EnabledFilter::All); + let hidden_filter = use_state(|| HiddenFilter::Hide); let view = use_view_state("cluster.packages"); let sort = view.sort; let layout = view.layout; @@ -405,11 +411,14 @@ impl Component for PackageManager { .unwrap_or(SortMode::NameAsc); let show = *enabled_filter.read(); + let hidden = *hidden_filter.read(); let card_layout = CardLayout::from(*layout.read()); + let tabs = build_tabs(&self.categories, &items, hidden); + let active_idx = (*active.read()).min(tabs.len().saturating_sub(1)); let tab_filter = tabs.get(active_idx); - let mut filtered = visible_packages(&items, tab_filter, &query, show); + let mut filtered = visible_packages(&items, tab_filter, &query, show, hidden); sort_mode.sort(&mut filtered); // Coming up empty during a search is about the query, not about the tab @@ -442,6 +451,7 @@ impl Component for PackageManager { sort, sort_mode, enabled_filter, + hidden_filter, layout, cluster_id, package_type, @@ -511,21 +521,59 @@ mod tests { rows.iter().map(|r| r.package_id.clone()).collect() } + fn shown(rows: &[PackageEntry], hidden: HiddenFilter) -> Vec { + ids(&visible_packages( + rows, + None, + "", + EnabledFilter::All, + hidden, + )) + } + #[test] - fn hidden_bundle_files_are_not_listed() { + fn hidden_bundle_files_are_not_listed_by_default() { let bundles = vec![bundle( "Core", vec![file("visible", true, false), file("dep", true, true)], )]; - assert_eq!(ids(&rows_for(Vec::new(), &bundles)), ["visible"]); + let rows = rows_for(Vec::new(), &bundles); + assert_eq!(shown(&rows, HiddenFilter::Hide), ["visible"]); + } + + #[test] + fn turning_the_filter_off_reveals_hidden_bundle_files() { + let bundles = vec![bundle( + "Core", + vec![file("visible", true, false), file("dep", true, true)], + )]; + let rows = rows_for(Vec::new(), &bundles); + assert_eq!(shown(&rows, HiddenFilter::Show), ["visible", "dep"]); } #[test] fn an_installed_hidden_file_does_not_come_back_as_loose_content() { // The dependency really is in the cluster, so the pass over installed - // content would happily list it as an unmanaged local file. + // content would happily list it as a second, unmanaged local row. let bundles = vec![bundle("Core", vec![file("dep", true, true)])]; - assert!(rows_for(vec![installed("dep")], &bundles).is_empty()); + let rows = rows_for(vec![installed("dep")], &bundles); + assert_eq!(ids(&rows), ["dep"]); + assert!(rows[0].hidden && rows[0].in_bundle()); + assert!(shown(&rows, HiddenFilter::Hide).is_empty()); + } + + #[test] + fn a_category_of_only_hidden_files_gets_no_tab() { + let bundles = vec![bundle("Core", vec![file("dep", true, true)])]; + let rows = rows_for(Vec::new(), &bundles); + let labels = |hidden| -> Vec { + build_tabs(&bundle_categories(&bundles), &rows, hidden) + .iter() + .map(Tab::label) + .collect() + }; + assert!(!labels(HiddenFilter::Hide).contains(&"Core".to_string())); + assert!(labels(HiddenFilter::Show).contains(&"Core".to_string())); } #[test] @@ -559,6 +607,7 @@ mod tests { installed: true, hash: None, update_available: false, + hidden: false, } } @@ -578,21 +627,39 @@ mod tests { 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); + let rows = visible_packages( + &items, + Some(&tab), + "sodium", + EnabledFilter::All, + HiddenFilter::Hide, + ); 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); + let rows = visible_packages( + &items, + Some(&Tab::Local), + "sodium", + EnabledFilter::All, + HiddenFilter::Hide, + ); 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); + let rows = visible_packages( + &items, + Some(&Tab::All), + "sodium", + EnabledFilter::All, + HiddenFilter::Hide, + ); assert_eq!(names(&rows), ["Sodium", "Sodium Extra", "Sodium Local"]); } @@ -600,7 +667,13 @@ mod tests { 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); + let rows = visible_packages( + &items, + Some(&tab), + "", + EnabledFilter::All, + HiddenFilter::Hide, + ); 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 a09b4f87..e8f220b1 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 @@ -101,6 +101,32 @@ impl EnabledFilter { } } +/// Whether a bundle's hidden files get a row. They are dependencies the bundle +/// manages on the user's behalf, so the list leaves them out until asked. +#[derive(Clone, Copy, PartialEq)] +pub(super) enum HiddenFilter { + Hide, + Show, +} + +impl HiddenFilter { + const ALL: [HiddenFilter; 2] = [HiddenFilter::Hide, HiddenFilter::Show]; + + fn label(self) -> &'static str { + match self { + HiddenFilter::Hide => "Hide", + HiddenFilter::Show => "Show", + } + } + + pub(super) fn keep(self, p: &PackageEntry) -> bool { + match self { + HiddenFilter::Hide => !p.hidden, + HiddenFilter::Show => true, + } + } +} + const FILTER_PANEL_W: f32 = 172.; const FILTER_BTN_W: f32 = 34.; @@ -113,6 +139,7 @@ pub(super) fn toolbar_bar( sort: State>, current_sort: SortMode, enabled_filter: State, + hidden_filter: State, layout: State, cluster_id: i64, package_type: &'static str, @@ -165,6 +192,7 @@ pub(super) fn toolbar_bar( sort, current_sort, enabled_filter, + hidden_filter, }) .child( SegmentedControl::new(layout) @@ -223,6 +251,7 @@ struct FilterButton { sort: State>, current_sort: SortMode, enabled_filter: State, + hidden_filter: State, } impl Component for FilterButton { @@ -232,10 +261,14 @@ impl Component for FilterButton { let sort = self.sort; let current_sort = self.current_sort; let enabled_filter = self.enabled_filter; + let hidden_filter = self.hidden_filter; + // Hiding hidden packages is the default, so it does not count as the + // filters being touched. let is_open = open(); - let active = - current_sort != SortMode::NameAsc || *enabled_filter.read() != EnabledFilter::All; + let active = current_sort != SortMode::NameAsc + || *enabled_filter.read() != EnabledFilter::All + || *hidden_filter.read() != HiddenFilter::Hide; let icon_color = if active { colors::brand() @@ -263,6 +296,7 @@ impl Component for FilterButton { sort, current_sort, enabled_filter, + hidden_filter, on_close, } .into_element() @@ -275,6 +309,7 @@ struct FilterPopover { sort: State>, current_sort: SortMode, enabled_filter: State, + hidden_filter: State, on_close: EventHandler<()>, } @@ -283,6 +318,7 @@ impl Component for FilterPopover { let mut sort = self.sort; let current_sort = self.current_sort; let mut enabled_filter = self.enabled_filter; + let mut hidden_filter = self.hidden_filter; let backdrop_close = self.on_close.clone(); let key_close = self.on_close.clone(); @@ -295,6 +331,7 @@ impl Component for FilterPopover { let progress = fade.read().value(); let show = *enabled_filter.read(); + let hidden = *hidden_filter.read(); let mut panel = rect() .vertical() @@ -350,6 +387,20 @@ impl Component for FilterPopover { }); } + panel = panel.child(section_label("Hidden packages")); + for filter in HiddenFilter::ALL { + let selected = filter == hidden; + let on_press: EventHandler> = (move |_| { + hidden_filter.set(filter); + }) + .into(); + panel = panel.child(ChoiceRow { + text: filter.label(), + selected, + on_press, + }); + } + rect() .height(Size::px(0.)) .width(Size::px(FILTER_PANEL_W))