diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index 2317c6b6..64b1c8c5 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -98,7 +98,7 @@ pub use package_delete_dialog::use_shared_delete; pub(crate) use package_row::{ CARD_BG, CARD_GRID_H, CARD_H, CARD_NAME, GRID_GAP, GRID_MIN_W, badge, disable_warning_body, grid_card, icon_box, kebab_button, meta_size, meta_text, on_secondary, package_icon, pill, - remote_icon, toggle_action, + remote_icon, set_enabled_action, toggle_action, }; pub use package_row::{ CardLayout, PackageEntry, PackageRow, github_badge, package_context_menu, provider_badge, diff --git a/packages/oneclient_app/src/components/package_row.rs b/packages/oneclient_app/src/components/package_row.rs index 667ec14a..73d8642b 100644 --- a/packages/oneclient_app/src/components/package_row.rs +++ b/packages/oneclient_app/src/components/package_row.rs @@ -245,22 +245,38 @@ pub(crate) fn toggle_action( cluster_id: i64, enabled: bool, ) -> Option { - if let Some(hash) = &item.hash { + set_enabled_action( + cluster_id, + item.hash.as_deref(), + item.bundle_name.as_deref(), + &item.package_id, + item.manifest_default, + enabled, + ) +} + +pub(crate) fn set_enabled_action( + cluster_id: i64, + hash: Option<&str>, + bundle_name: Option<&str>, + package_id: &str, + manifest_default: bool, + enabled: bool, +) -> Option { + if let Some(hash) = hash { Some(ClusterAction::SetArtifactEnabled { cluster_id, - hash: hash.clone(), + hash: hash.to_string(), enabled, }) } else { - item.bundle_name - .clone() - .map(|bundle_name| ClusterAction::SetBundlePackageEnabled { - cluster_id, - bundle_name, - package_id: item.package_id.clone(), - enabled, - manifest_default: item.manifest_default, - }) + bundle_name.map(|bundle_name| ClusterAction::SetBundlePackageEnabled { + cluster_id, + bundle_name: bundle_name.to_string(), + package_id: package_id.to_string(), + enabled, + manifest_default, + }) } } diff --git a/packages/oneclient_app/src/view/app/browser/index/cards.rs b/packages/oneclient_app/src/view/app/browser/index/cards.rs index 609b817f..9108a197 100644 --- a/packages/oneclient_app/src/view/app/browser/index/cards.rs +++ b/packages/oneclient_app/src/view/app/browser/index/cards.rs @@ -9,8 +9,8 @@ use oneclient_core::clusters::ModpackSource; use crate::components::{Button, Icon, IconType}; use crate::hooks::{ - content_type_for_slug, use_browser_compat, use_cluster, use_dispatch, use_installs_snapshot, - use_package_versions_when, version_list, + ClusterAction, content_type_for_slug, use_browser_compat, use_cluster, use_dispatch, + use_installs_snapshot, use_package_versions_when, version_list, }; use crate::routes::Route; use crate::theme::colors; @@ -21,10 +21,25 @@ type InstalledMap = HashMap<(ProviderId, String), Installed>; /// Height of the install control, matched by the installed pill that replaces it const INSTALL_BUTTON_H: f32 = 28.; -fn installed_for(installed: &InstalledMap, item: &ProjectSummary) -> Option { +#[derive(Clone, PartialEq)] +struct CardInstalled { + source: InstallSource, + enable: Option, +} + +fn installed_for( + installed: &InstalledMap, + item: &ProjectSummary, + cluster_id: i64, +) -> Option { installed .get(&(item.provider, item.id.clone())) - .map(|installed| installed.source) + .map(|installed| CardInstalled { + source: installed.source, + enable: installed + .disabled_bundled() + .and_then(|version| version.enable_action(cluster_id)), + }) } pub(super) fn grid_row( @@ -36,9 +51,9 @@ pub(super) fn grid_row( ) -> impl IntoElement { let package_type = package_type.to_string(); let fill = cols.saturating_sub(row.len()); - let installed: Vec> = row + let installed: Vec> = row .iter() - .map(|item| installed_for(installed, item)) + .map(|item| installed_for(installed, item, cluster_id)) .collect(); rect() @@ -77,7 +92,7 @@ struct PackageCard { item: ProjectSummary, cluster_id: i64, package_type: String, - installed: Option, + installed: Option, } impl PackageCard { @@ -85,7 +100,7 @@ impl PackageCard { item: ProjectSummary, cluster_id: i64, package_type: String, - installed: Option, + installed: Option, ) -> Self { Self { item, @@ -202,7 +217,7 @@ impl Component for PackageCard { &self.item, self.cluster_id, &self.package_type, - self.installed, + self.installed.clone(), )), ) } @@ -215,7 +230,7 @@ pub(super) fn list_row( installed: &InstalledMap, ) -> impl IntoElement { ListRow { - installed: installed_for(installed, &item), + installed: installed_for(installed, &item, cluster_id), item, cluster_id, package_type: package_type.to_string(), @@ -227,7 +242,7 @@ struct ListRow { item: ProjectSummary, cluster_id: i64, package_type: String, - installed: Option, + installed: Option, } impl Component for ListRow { @@ -308,7 +323,7 @@ impl Component for ListRow { &self.item, self.cluster_id, &self.package_type, - self.installed, + self.installed.clone(), )) } } @@ -320,7 +335,7 @@ struct InstallButton { cluster_id: i64, content_type: ContentType, /// Cluster already has this one, so the control becomes a static pill - installed: Option, + installed: Option, } impl InstallButton { @@ -328,7 +343,7 @@ impl InstallButton { item: &ProjectSummary, cluster_id: i64, package_type: &str, - installed: Option, + installed: Option, ) -> Self { Self { provider: item.provider, @@ -376,7 +391,17 @@ impl Component for InstallButton { let (installing, waiting) = use_installs_snapshot().package_busy(is_modpack, cluster_id, provider, &project_id); - if let Some(installed) = self.installed { + if let Some(installed) = &self.installed { + if let Some(action) = installed.enable.clone() { + return EnableButton { + action, + variant: EnableVariant::Card { + height: INSTALL_BUTTON_H, + }, + } + .into_element(); + } + let installed = installed.source; let color = installed.color(); return rect() .horizontal() diff --git a/packages/oneclient_app/src/view/app/browser/index/mod.rs b/packages/oneclient_app/src/view/app/browser/index/mod.rs index 558d7235..c6ef0b51 100644 --- a/packages/oneclient_app/src/view/app/browser/index/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/index/mod.rs @@ -19,11 +19,12 @@ use crate::components::{ }; use crate::hooks::use_cluster; use crate::hooks::{ - BROWSE_PAGE_SIZE, BrowserUiState, bundles_with_status_items, category_list, - cluster_content_items, content_type_for_slug, pick_version_metadata, search_items, - search_pending, search_total, settled_or_loading, use_browser_compat, use_browser_state_store, - use_browser_type, use_bundles_with_status, use_cluster_content, use_clusters, use_debounced, - use_package_categories, use_package_search, use_versions, use_view_state, versions_metadata, + BROWSE_PAGE_SIZE, BrowserUiState, bundle_overrides_map, bundles_with_status_items, + category_list, cluster_content_items, content_type_for_slug, pick_version_metadata, + search_items, search_pending, search_total, settled_or_loading, use_browser_compat, + use_browser_state_store, use_browser_type, use_bundle_overrides, use_bundles_with_status, + use_cluster_content, use_clusters, use_debounced, use_package_categories, use_package_search, + use_versions, use_view_state, versions_metadata, }; use crate::routes::Route; use crate::theme::colors; @@ -31,8 +32,8 @@ use crate::ui::grid_columns_for_width; use crate::view::app::cluster::supports_datapacks; use super::{ - InstallSource, Installed, PackageBanner, Thumbnail, WorldInstallPrompt, installed_map, - preferred_version, + EnableButton, EnableVariant, InstallSource, Installed, PackageBanner, Thumbnail, + WorldInstallPrompt, installed_map, preferred_version, }; use crate::utils::{abbreviate_number, sort_clusters_for_home}; @@ -250,6 +251,7 @@ impl Component for BrowserBody { let installed = installed_map( cluster_content_items(&use_cluster_content(cluster_id, content_type)), &bundles_with_status_items(&use_bundles_with_status(cluster_id)), + &bundle_overrides_map(&use_bundle_overrides(cluster_id)), ); let installed = if matches!(content_type, ContentType::DataPack | ContentType::Modpack) { Default::default() diff --git a/packages/oneclient_app/src/view/app/browser/mod.rs b/packages/oneclient_app/src/view/app/browser/mod.rs index 54c4830e..682ec09d 100644 --- a/packages/oneclient_app/src/view/app/browser/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/mod.rs @@ -28,9 +28,12 @@ use std::collections::HashMap; use freya::prelude::*; use oneclient_content::packages::ProviderId; use oneclient_core::{BundleFileKind, BundleWithUpdateStatus, LinkedArtifactInfo}; +use oneclient_db::models::OverrideType; -use crate::components::{Icon, IconType}; -use crate::hooks::{loaded_image, use_cached_image}; +use crate::components::{Button, Icon, IconType, set_enabled_action}; +use crate::hooks::{ + ClusterAction, loaded_image, mutation_is_running, use_cached_image, use_cluster_mutation, +}; use crate::theme::colors; use crate::ui::{ImageFallbackExt, border_all_color}; @@ -59,15 +62,36 @@ impl InstallSource { } } +#[derive(Clone, PartialEq)] +pub(crate) struct BundlePin { + pub bundle_name: String, + pub package_id: String, + pub manifest_default: bool, +} + #[derive(Clone, PartialEq)] pub(crate) struct InstalledVersion { pub version_id: String, /// The artifact to remove /// `None` when a bundle names this version but nothing is linked pub hash: Option, - /// Only meaningful alongside a `hash` a bundle pin with nothing linked is neither on nor off pub enabled: bool, pub source: InstallSource, + pub bundle: Option, +} + +impl InstalledVersion { + pub fn enable_action(&self, cluster_id: i64) -> Option { + let pin = self.bundle.as_ref()?; + set_enabled_action( + cluster_id, + self.hash.as_deref(), + Some(&pin.bundle_name), + &pin.package_id, + pin.manifest_default, + true, + ) + } } /// Holds every version found rather than an arbitrary winner a cluster can end up with more than one @@ -91,12 +115,20 @@ impl Installed { pub fn is_duplicated(&self) -> bool { self.versions.iter().filter(|v| v.hash.is_some()).count() > 1 } + + pub fn disabled_bundled(&self) -> Option<&InstalledVersion> { + if self.versions.iter().any(|v| v.enabled) { + return None; + } + self.versions.iter().find(|v| v.bundle.is_some()) + } } /// Local files and a bundle's external files are left out they have no project id to match against pub(crate) fn installed_map( content: Vec, bundles: &[BundleWithUpdateStatus], + overrides: &HashMap<(String, String), String>, ) -> HashMap<(ProviderId, String), Installed> { let mut map: HashMap<(ProviderId, String), Installed> = HashMap::new(); @@ -117,6 +149,7 @@ pub(crate) fn installed_map( hash: Some(item.hash), enabled: item.enabled, source: InstallSource::Manual, + bundle: None, }); } } @@ -124,6 +157,7 @@ pub(crate) fn installed_map( // Bundle membership wins a bundle's files would otherwise read as hand-installed // The manifest pin is only a fallback for a missing linked version for bundle in bundles { + let bundle_name = &bundle.archive.manifest.name; for (file, _status) in &bundle.files { if let BundleFileKind::Managed { provider, @@ -132,18 +166,34 @@ pub(crate) fn installed_map( .. } = &file.kind { + let pin = BundlePin { + bundle_name: bundle_name.clone(), + package_id: file.kind.package_id(), + manifest_default: file.enabled, + }; match map.get_mut(&(*provider, project_id.clone())) { Some(installed) => { installed.source = InstallSource::Bundled; - if let Some(version) = installed + let only_copy = installed.versions.len() == 1; + match installed .versions .iter_mut() .find(|v| &v.version_id == version_id) { - version.source = InstallSource::Bundled; + Some(version) => { + version.source = InstallSource::Bundled; + version.bundle.get_or_insert(pin); + } + None if only_copy => { + installed.versions[0].bundle.get_or_insert(pin); + } + None => {} } } None => { + let user_override = overrides + .get(&(bundle_name.clone(), pin.package_id.clone())) + .and_then(|o| OverrideType::parse(o)); map.insert( (*provider, project_id.clone()), Installed { @@ -151,8 +201,9 @@ pub(crate) fn installed_map( versions: vec![InstalledVersion { version_id: version_id.clone(), hash: None, - enabled: false, + enabled: oneclient_core::effective_enabled(file, user_override), source: InstallSource::Bundled, + bundle: Some(pin), }], }, ); @@ -165,6 +216,68 @@ pub(crate) fn installed_map( map } +#[derive(Clone, Copy, PartialEq)] +pub(crate) enum EnableVariant { + Sidebar, + Card { height: f32 }, + VersionRow, +} + +#[derive(PartialEq)] +pub(crate) struct EnableButton { + pub action: ClusterAction, + pub variant: EnableVariant, +} + +impl Component for EnableButton { + fn render(&self) -> impl IntoElement { + let mutation = use_cluster_mutation(); + let running = mutation_is_running(&mutation); + let action = self.action.clone(); + + let button = Button::new() + .enabled(!running) + .on_press(move |_| mutation.mutate(action.clone())); + + match self.variant { + EnableVariant::Sidebar => button + .primary() + .width(Size::fill()) + .child(Icon::new(IconType::CheckCircle).size(14.)) + .text(if running { "Enabling..." } else { "Enable" }) + .into_element(), + EnableVariant::Card { height } => rect() + .on_press(|e: Event| e.stop_propagation()) + .child( + button + .primary() + .small() + .height(Size::px(height)) + .padding(Gaps::new_symmetric(0., 11.)) + .child( + Icon::new(if running { + IconType::Loading02 + } else { + IconType::CheckCircle + }) + .size(12.) + .color(colors::fg_primary()), + ) + .child( + label() + .text(if running { "Enabling" } else { "Enable" }) + .font_size(11.) + .font_weight(FontWeight::SEMI_BOLD) + .max_lines(1) + .color(colors::fg_primary()), + ), + ) + .into_element(), + EnableVariant::VersionRow => button.secondary().small().text("Enable").into_element(), + } + } +} + pub(crate) fn installed_badge(installed: InstallSource, font_size: f32) -> impl IntoElement { badge(installed, font_size, installed.color().with_a(38), None) } @@ -450,6 +563,30 @@ mod tests { }] } + fn installed_map( + content: Vec, + bundles: &[BundleWithUpdateStatus], + ) -> HashMap<(ProviderId, String), Installed> { + super::installed_map(content, bundles, &HashMap::new()) + } + + fn optional(project_id: &str, version_id: &str) -> BundleFile { + BundleFile { + enabled: false, + ..managed(project_id, version_id) + } + } + + fn overridden( + project_id: &str, + override_type: OverrideType, + ) -> HashMap<(String, String), String> { + HashMap::from([( + ("performance".to_string(), project_id.to_string()), + override_type.as_str().to_string(), + )]) + } + fn entry(map: &HashMap<(ProviderId, String), Installed>, project: &str) -> Installed { map.get(&(ProviderId::Modrinth, project.to_string())) .expect("project missing from the map") @@ -578,4 +715,96 @@ mod tests { ); assert_eq!(sodium.source, InstallSource::Manual); } + + #[test] + fn an_optional_mod_never_downloaded_offers_enable_through_the_bundle() { + let map = installed_map(Vec::new(), &bundles(vec![optional("sodium", "v1")])); + + let sodium = entry(&map, "sodium"); + let disabled = sodium.disabled_bundled().expect("offers enable"); + assert!(matches!( + disabled.enable_action(7), + Some(ClusterAction::SetBundlePackageEnabled { + cluster_id: 7, + enabled: true, + manifest_default: false, + .. + }) + )); + } + + #[test] + fn an_enabled_pin_waiting_for_sync_does_not_offer_enable() { + let map = installed_map(Vec::new(), &bundles(vec![managed("sodium", "v1")])); + + assert!(entry(&map, "sodium").disabled_bundled().is_none()); + } + + #[test] + fn a_disabled_override_on_an_unlinked_pin_offers_enable() { + let map = super::installed_map( + Vec::new(), + &bundles(vec![managed("sodium", "v1")]), + &overridden("sodium", OverrideType::Disabled), + ); + + let sodium = entry(&map, "sodium"); + assert!(matches!( + sodium.disabled_bundled().and_then(|v| v.enable_action(7)), + Some(ClusterAction::SetBundlePackageEnabled { + manifest_default: true, + .. + }) + )); + } + + #[test] + fn a_disabled_linked_bundle_artifact_is_enabled_by_hash() { + let map = installed_map( + vec![disabled_linked("sodium", Some("v1"), "hash-1", false)], + &bundles(vec![managed("sodium", "v1")]), + ); + + let sodium = entry(&map, "sodium"); + assert!(matches!( + sodium.disabled_bundled().and_then(|v| v.enable_action(7)), + Some(ClusterAction::SetArtifactEnabled { ref hash, enabled: true, .. }) if hash == "hash-1" + )); + } + + #[test] + fn a_disabled_copy_from_an_older_bundle_pin_still_offers_enable() { + let map = installed_map( + vec![disabled_linked("sodium", Some("v1"), "hash-1", false)], + &bundles(vec![managed("sodium", "v2")]), + ); + + assert!(entry(&map, "sodium").disabled_bundled().is_some()); + } + + #[test] + fn another_enabled_copy_hides_enable() { + let map = installed_map( + vec![ + disabled_linked("sodium", Some("v1"), "hash-1", false), + linked("sodium", Some("v2"), "hash-2"), + ], + &bundles(vec![managed("sodium", "v1")]), + ); + + assert!( + entry(&map, "sodium").disabled_bundled().is_none(), + "enabling the bundle copy would load two" + ); + } + + #[test] + fn a_hand_installed_disabled_mod_is_not_offered_enable() { + let map = installed_map( + vec![disabled_linked("sodium", Some("v1"), "hash-1", false)], + &[], + ); + + assert!(entry(&map, "sodium").disabled_bundled().is_none()); + } } diff --git a/packages/oneclient_app/src/view/app/browser/package/mod.rs b/packages/oneclient_app/src/view/app/browser/package/mod.rs index 48ed42cf..8b82534c 100644 --- a/packages/oneclient_app/src/view/app/browser/package/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/package/mod.rs @@ -8,17 +8,18 @@ use oneclient_core::clusters::ModpackSource; use crate::components::{ScrollArea, use_shared_delete}; use crate::hooks::use_cluster; use crate::hooks::{ - bundles_with_status_items, cluster_content_items, content_type_for_slug, package_meta_batch, - project_detail, use_browser_compat, use_bundles_with_status, use_cluster_content, - use_dispatch, use_installs_snapshot, use_link_confirm, use_package_meta_batch, - use_package_project, use_package_versions, version_list, versions_total, + bundle_overrides_map, bundles_with_status_items, cluster_content_items, content_type_for_slug, + package_meta_batch, project_detail, use_browser_compat, use_bundle_overrides, + use_bundles_with_status, use_cluster_content, use_dispatch, use_installs_snapshot, + use_link_confirm, use_package_meta_batch, use_package_project, use_package_versions, + version_list, versions_total, }; use crate::theme::colors; use crate::ui::border_all_color; use super::{ - Installed, InstalledVersion, PackageBanner, Thumbnail, WorldInstallPrompt, activity_badge, - installed_badge, installed_map, preferred_version, + EnableButton, EnableVariant, Installed, InstalledVersion, PackageBanner, Thumbnail, + WorldInstallPrompt, activity_badge, installed_badge, installed_map, preferred_version, }; use crate::utils::abbreviate_number; @@ -196,6 +197,7 @@ impl Component for BrowserPackage { let installed = installed_map( cluster_content_items(&use_cluster_content(cluster_id, content_type)), &bundles_with_status_items(&use_bundles_with_status(cluster_id)), + &bundle_overrides_map(&use_bundle_overrides(cluster_id)), ) .remove(&(provider, project_id.clone())) .filter(|_| !is_datapack); diff --git a/packages/oneclient_app/src/view/app/browser/package/panels.rs b/packages/oneclient_app/src/view/app/browser/package/panels.rs index 38478964..95984ef4 100644 --- a/packages/oneclient_app/src/view/app/browser/package/panels.rs +++ b/packages/oneclient_app/src/view/app/browser/package/panels.rs @@ -8,7 +8,7 @@ use oneclient_content::packages::types::{ use crate::components::{ Button, Icon, IconType, Markdown, MarkdownStyle, Segment, SegmentedControl, }; -use crate::hooks::VERSIONS_PAGE_SIZE; +use crate::hooks::{ClusterAction, VERSIONS_PAGE_SIZE}; use crate::theme::colors; use crate::ui::border_all_color; use crate::utils::format_size; @@ -142,6 +142,11 @@ pub(super) fn versions_panel( let duplicated = installed .as_ref() .is_some_and(|installed| installed.is_duplicated()); + let enable = installed + .as_ref() + .and_then(Installed::disabled_bundled) + .filter(|disabled| disabled.version_id == v.version_id) + .and_then(|disabled| disabled.enable_action(installer.cluster_id)); version_row( v, &dependency_names, @@ -151,6 +156,7 @@ pub(super) fn versions_panel( tag, duplicated, installing, + enable, ) .into_element() })) @@ -226,6 +232,7 @@ fn version_row( // Saying which version is live only tells the user anything when there are several duplicated: bool, installing: bool, + enable: Option, ) -> impl IntoElement { let version_id = v.version_id.clone(); let mut chips: Vec = v.loaders.iter().map(|l| l.to_string()).collect(); @@ -300,9 +307,17 @@ fn version_row( .filter(|_| duplicated) .map(|installed| activity_badge(installed.enabled).into_element()), ) - .child(version_button( - installed, v.name, project_id, version_id, installer, on_remove, installing, - )) + .child(match enable { + Some(action) => EnableButton { + action, + variant: EnableVariant::VersionRow, + } + .into_element(), + None => version_button( + installed, v.name, project_id, version_id, installer, on_remove, installing, + ) + .into_element(), + }) } /// A bundle pin with nothing linked leaves nothing to press no artifact to remove and installing by hand would duplicate it diff --git a/packages/oneclient_app/src/view/app/browser/package/sidebar.rs b/packages/oneclient_app/src/view/app/browser/package/sidebar.rs index 72b4a706..0eb26751 100644 --- a/packages/oneclient_app/src/view/app/browser/package/sidebar.rs +++ b/packages/oneclient_app/src/view/app/browser/package/sidebar.rs @@ -30,6 +30,10 @@ pub(super) fn sidebar( _ => false, }; let can_install = latest_version.is_some() && !have_latest && !installing && !waiting; + let enable = installed + .as_ref() + .and_then(Installed::disabled_bundled) + .and_then(|version| version.enable_action(installer.cluster_id)); rect() .vertical() @@ -101,8 +105,13 @@ pub(super) fn sidebar( ), ), ) - .child( - Button::new() + .child(match enable { + Some(action) => EnableButton { + action, + variant: EnableVariant::Sidebar, + } + .into_element(), + None => Button::new() .primary() .width(Size::fill()) .enabled(can_install) @@ -118,8 +127,9 @@ pub(super) fn sidebar( "Latest installed" } else { "Install latest" - }), - ) + }) + .into_element(), + }) .maybe( !project.members.is_empty() || !project.author.is_empty(), |el| el.child(authors_card(&project, confirm)),