chore: added enabling disabled bundled mods from browser (#900)

This commit is contained in:
Maciek 2026-10-01 22:12:53 +02:00 • committed by GitHub
parent 76d929b82a
commit 959458abde
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 353 additions and 54 deletions

View file

@ -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,

View file

@ -245,22 +245,38 @@ pub(crate) fn toggle_action(
cluster_id: i64,
enabled: bool,
) -> Option<ClusterAction> {
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<ClusterAction> {
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,
})
}
}

View file

@ -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<InstallSource> {
#[derive(Clone, PartialEq)]
struct CardInstalled {
source: InstallSource,
enable: Option<ClusterAction>,
}
fn installed_for(
installed: &InstalledMap,
item: &ProjectSummary,
cluster_id: i64,
) -> Option<CardInstalled> {
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<Option<InstallSource>> = row
let installed: Vec<Option<CardInstalled>> = 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<InstallSource>,
installed: Option<CardInstalled>,
}
impl PackageCard {
@ -85,7 +100,7 @@ impl PackageCard {
item: ProjectSummary,
cluster_id: i64,
package_type: String,
installed: Option<InstallSource>,
installed: Option<CardInstalled>,
) -> 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<InstallSource>,
installed: Option<CardInstalled>,
}
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<InstallSource>,
installed: Option<CardInstalled>,
}
impl InstallButton {
@ -328,7 +343,7 @@ impl InstallButton {
item: &ProjectSummary,
cluster_id: i64,
package_type: &str,
installed: Option<InstallSource>,
installed: Option<CardInstalled>,
) -> 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()

View file

@ -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()

View file

@ -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<String>,
/// 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<BundlePin>,
}
impl InstalledVersion {
pub fn enable_action(&self, cluster_id: i64) -> Option<ClusterAction> {
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<LinkedArtifactInfo>,
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<PressEventData>| 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<LinkedArtifactInfo>,
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());
}
}

View file

@ -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);

View file

@ -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<ClusterAction>,
) -> impl IntoElement {
let version_id = v.version_id.clone();
let mut chips: Vec<String> = 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

View file

@ -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)),