prompt for different mc versions when installing modpacks

This commit is contained in:
Julian Chang 2026-10-02 01:38:42 -04:00
parent efbde53326
commit 538a0da24d
No known key found for this signature in database
9 changed files with 377 additions and 39 deletions

View file

@ -28,6 +28,7 @@ pub use active_cluster::{
};
pub use actions::{Actions, NotificationBuilder, PumpSignal, modpack_job_running};
pub use queries::ALL_VERSIONS;
pub(crate) use queries::use_folder_watch;
pub use queries::{
AddOfflineAccountKeys, AvailableBundlesQuery, BROWSE_PAGE_SIZE, BeginMicrosoftLoginMutation,
@ -64,9 +65,9 @@ pub use queries::{
use_global_analytics, use_java_majors, use_java_runtimes, use_leftovers, use_loader_versions,
use_local_image, use_log_action, use_log_content, use_migratable_routes, use_migration,
use_named_profiles, use_onboarding_bundles, use_package_categories, use_package_meta_batch,
use_package_project, use_package_search, use_package_updates, use_package_versions,
use_package_versions_when, use_player_profile, use_player_skin, use_provider_versions,
use_refresh_account, use_refresh_all_accounts, use_remove_account, use_screenshot_action,
use_package_project, use_package_search, use_package_updates, use_package_versions_when,
use_player_profile, use_player_skin, use_provider_versions, use_refresh_account,
use_refresh_all_accounts, use_remove_account, use_screenshot_action,
use_screenshot_folder_watch, use_set_default_account, use_storage_action, use_storage_report,
use_terms, use_upload_log, use_version_art, use_version_art_gallery, use_version_loaders,
use_version_metadata, use_versions, version_list, versions_metadata, versions_total,

View file

@ -79,11 +79,12 @@ pub use mutations::{
invalidate_profile_queries, use_cluster_mutation,
};
pub use package_updates::{package_updates, stale_hashes, use_package_updates};
pub use packages::ALL_VERSIONS;
pub use packages::{
BROWSE_PAGE_SIZE, VERSIONS_PAGE_SIZE, category_list, content_type_for_slug, package_meta_batch,
project_detail, search_items, search_pending, search_total, use_package_categories,
use_package_meta_batch, use_package_project, use_package_search, use_package_versions,
use_package_versions_when, version_list, versions_total,
use_package_meta_batch, use_package_project, use_package_search, use_package_versions_when,
version_list, versions_total,
};
pub use player_profile::use_player_profile;
pub use screenshots::{

View file

@ -12,6 +12,7 @@ use oneclient_core::LauncherError;
pub const BROWSE_PAGE_SIZE: usize = DEFAULT_PAGE_SIZE;
pub const VERSIONS_PAGE_SIZE: usize = 20;
pub const ALL_VERSIONS: usize = usize::MAX;
const VERSIONS_STALE: Duration = Duration::from_secs(5 * 60);
@ -209,6 +210,7 @@ pub struct PackageVersionsKeys {
pub game_version: Option<String>,
pub loader: Option<GameLoader>,
pub page: usize,
pub page_size: usize,
}
impl QueryCapability for PackageVersionsQuery {
@ -228,8 +230,8 @@ impl QueryCapability for PackageVersionsQuery {
&keys.project_id,
keys.game_version.as_deref(),
keys.loader,
keys.page * VERSIONS_PAGE_SIZE,
VERSIONS_PAGE_SIZE,
keys.page.saturating_mul(keys.page_size),
keys.page_size,
&state.services.content(),
)
.await?)
@ -238,16 +240,6 @@ impl QueryCapability for PackageVersionsQuery {
}
}
pub fn use_package_versions(
provider: ProviderId,
project_id: String,
game_version: Option<String>,
loader: Option<GameLoader>,
page: usize,
) -> UseQuery<PackageVersionsQuery> {
use_package_versions_when(true, provider, project_id, game_version, loader, page)
}
pub fn use_package_versions_when(
enabled: bool,
provider: ProviderId,
@ -255,6 +247,7 @@ pub fn use_package_versions_when(
game_version: Option<String>,
loader: Option<GameLoader>,
page: usize,
page_size: usize,
) -> UseQuery<PackageVersionsQuery> {
let keys = enabled.then_some(PackageVersionsKeys {
provider,
@ -262,6 +255,7 @@ pub fn use_package_versions_when(
game_version,
loader,
page,
page_size,
});
use_query(Query::new(keys, PackageVersionsQuery).stale_time(VERSIONS_STALE))

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,
ALL_VERSIONS, VERSIONS_PAGE_SIZE, 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;
@ -367,10 +367,17 @@ impl Component for InstallButton {
game_version,
loader,
0,
if is_modpack {
ALL_VERSIONS
} else {
VERSIONS_PAGE_SIZE
},
));
let latest = preferred_version(&versions, self.content_type).map(|v| v.version_id.clone());
let is_datapack = self.content_type == ContentType::DataPack;
let mut world_prompt = use_state(|| None::<String>);
let mut modpack_prompt = use_state(|| false);
let mut choices = use_state(Vec::new);
// Nothing to start twice while an install is running or before versions arrive
let (installing, waiting) =
@ -418,11 +425,17 @@ impl Component for InstallButton {
if is_datapack {
world_prompt.set(Some(version_id));
} else if is_modpack {
dispatch.install_modpack(ModpackSource::Provider {
provider,
project_id: project_id.clone(),
version_id,
});
let picks = minecraft_choices(&versions);
if picks.len() > 1 {
choices.set(picks);
modpack_prompt.set(true);
} else {
dispatch.install_modpack(ModpackSource::Provider {
provider,
project_id: project_id.clone(),
version_id,
});
}
} else {
dispatch.install_package(
cluster_id,
@ -455,9 +468,15 @@ impl Component for InstallButton {
.maybe_child(world_prompt.read().is_some().then_some(WorldInstallPrompt {
cluster_id,
provider,
project_id,
project_id: project_id.clone(),
pending: world_prompt,
}))
.maybe_child(modpack_prompt.read().then(|| ModpackVersionPrompt {
provider,
project_id,
choices: choices.read().clone(),
open: modpack_prompt,
}))
.into_element()
}
}

View file

@ -31,8 +31,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,
InstallSource, Installed, ModpackVersionPrompt, PackageBanner, Thumbnail, WorldInstallPrompt,
installed_map, minecraft_choices, preferred_version,
};
use crate::utils::{abbreviate_number, sort_clusters_for_home};

View file

@ -4,7 +4,9 @@ mod package;
pub use index::Browser;
pub(crate) use index::{browsable_type, encode_package_id};
mod modpack_prompt;
mod world_prompt;
use modpack_prompt::{ModpackVersionPrompt, minecraft_choices};
use world_prompt::WorldInstallPrompt;
/// Projects shipping both a mod and a data pack tag the mod files with a loader

View file

@ -0,0 +1,276 @@
use std::collections::HashSet;
use freya::prelude::*;
use oneclient_common::version::parse_mc_version;
use oneclient_content::packages::ProviderId;
use oneclient_content::packages::types::{ReleaseType, VersionSummary};
use oneclient_core::clusters::ModpackSource;
use crate::components::{Button, Icon, IconType, ScrollArea};
use crate::hooks::use_dispatch;
use crate::theme::colors;
use crate::ui::border_all_color;
use crate::view::app::cluster::dialog;
const ROW_H: f32 = 44.;
const ROW_GAP: f32 = 6.;
const VISIBLE_ROWS: usize = 5;
#[derive(Clone, PartialEq)]
pub(crate) struct MinecraftChoice {
mc: String,
version_id: String,
detail: String,
}
pub(crate) fn minecraft_choices(versions: &[VersionSummary]) -> Vec<MinecraftChoice> {
let mut seen = HashSet::new();
let mut order: Vec<_> = versions
.iter()
.flat_map(|v| &v.game_versions)
.filter(|mc| seen.insert(mc.as_str()))
.filter(|mc| mc.bytes().all(|b| b.is_ascii_digit() || b == b'.'))
.filter_map(|mc| parse_mc_version(mc).map(|p| (mc.clone(), (p.major, p.minor, p.patch))))
.collect();
order.sort_by(|a, b| b.1.cmp(&a.1));
let mut choices: Vec<MinecraftChoice> = Vec::new();
for (mc, _) in order {
let for_mc: Vec<_> = versions
.iter()
.filter(|v| v.game_versions.contains(&mc))
.collect();
let mut loader_sets: Vec<&Vec<_>> = Vec::new();
for v in &for_mc {
if !loader_sets.contains(&&v.loaders) {
loader_sets.push(&v.loaders);
}
}
for loaders in loader_sets {
let same = || for_mc.iter().filter(|v| &v.loaders == loaders);
let Some(pick) = same()
.find(|v| matches!(v.release_type, ReleaseType::Release))
.or_else(|| same().next())
else {
continue;
};
push_choice(&mut choices, &mc, pick);
}
}
choices
}
fn push_choice(choices: &mut Vec<MinecraftChoice>, mc: &str, pick: &VersionSummary) {
if let Some(existing) = choices.iter_mut().find(|c| c.version_id == pick.version_id) {
existing.mc.push_str(" / ");
existing.mc.push_str(mc);
return;
}
let mut detail = vec![pick.version_number.clone()];
detail.extend(pick.loaders.iter().map(|l| l.to_string()));
match pick.release_type {
ReleaseType::Release => {}
ReleaseType::Beta => detail.push("Beta".to_string()),
ReleaseType::Alpha => detail.push("Alpha".to_string()),
}
detail.push(pick.published.format("%Y-%m-%d").to_string());
choices.push(MinecraftChoice {
mc: mc.to_string(),
version_id: pick.version_id.clone(),
detail: detail.join(" · "),
});
}
#[derive(PartialEq)]
pub(crate) struct ModpackVersionPrompt {
pub provider: ProviderId,
pub project_id: String,
pub choices: Vec<MinecraftChoice>,
pub open: State<bool>,
}
impl Component for ModpackVersionPrompt {
fn render(&self) -> impl IntoElement {
let provider = self.provider;
let project_id = self.project_id.clone();
let mut open = self.open;
let dispatch = use_dispatch();
let mut selected = use_state(|| 0usize);
let current = (*selected.read()).min(self.choices.len().saturating_sub(1));
let rows: Vec<Element> = self
.choices
.iter()
.enumerate()
.map(|(i, choice)| {
choice_row(choice, i == current, move || selected.set(i)).into_element()
})
.collect();
let shown = self.choices.len().min(VISIBLE_ROWS) as f32;
let list_h = shown * ROW_H + (shown - 1.).max(0.) * ROW_GAP;
let control = ScrollArea::new()
.width(Size::fill())
.height(Size::px(list_h))
.spacing(ROW_GAP)
.children(rows)
.into_element();
let version_id = self.choices.get(current).map(|c| c.version_id.clone());
let install = move |_| {
let Some(version_id) = version_id.clone() else {
return;
};
dispatch.install_modpack(ModpackSource::Provider {
provider,
project_id: project_id.clone(),
version_id,
});
open.set(false);
};
dialog(
"Which Minecraft version?".to_string(),
"This modpack is made for several Minecraft versions. Pick the one to install."
.to_string(),
Some(control),
move || open.set(false),
[
Button::new()
.secondary()
.on_press(move |_| open.set(false))
.text("Cancel")
.into_element(),
Button::new()
.primary()
.on_press(install)
.child(Icon::new(IconType::Download01).size(14.))
.text("Install")
.into_element(),
],
)
}
}
fn choice_row(
choice: &MinecraftChoice,
active: bool,
mut on_select: impl FnMut() + 'static,
) -> impl IntoElement {
let ring = if active {
colors::brand()
} else {
colors::component_border()
};
let radio = rect()
.width(Size::px(16.))
.height(Size::px(16.))
.center()
.corner_radius(CornerRadius::new_all(8.))
.border(border_all_color(1.5, ring))
.maybe_child(active.then(|| {
rect()
.width(Size::px(8.))
.height(Size::px(8.))
.corner_radius(CornerRadius::new_all(4.))
.background(colors::brand())
}));
rect()
.horizontal()
.width(Size::fill())
.height(Size::px(ROW_H))
.cross_align(Alignment::Center)
.content(Content::Flex)
.spacing(10.)
.padding(Gaps::new_symmetric(0., 12.))
.corner_radius(CornerRadius::new_all(8.))
.background(colors::component_bg())
.border(border_all_color(1., ring))
.on_press(move |_| on_select())
.child(radio)
.child(
rect()
.vertical()
.width(Size::flex(1.))
.spacing(2.)
.child(
label()
.text(format!("Minecraft {}", choice.mc))
.font_size(13.)
.font_weight(FontWeight::SEMI_BOLD)
.max_lines(1)
.color(colors::fg_primary()),
)
.child(
label()
.text(choice.detail.clone())
.font_size(11.)
.max_lines(1)
.color(colors::fg_secondary()),
),
)
}
#[cfg(test)]
mod tests {
use super::*;
fn version(id: &str, mc: &[&str], release_type: ReleaseType) -> VersionSummary {
VersionSummary {
version_id: id.to_string(),
game_versions: mc.iter().map(|s| s.to_string()).collect(),
project_id: String::new(),
name: String::new(),
version_number: String::new(),
published: Default::default(),
release_type,
loaders: Vec::new(),
downloads: 0,
file_size: 0,
dependencies: Vec::new(),
}
}
#[test]
fn one_choice_per_minecraft_version_preferring_releases() {
let versions = [
version("b3", &["26.3"], ReleaseType::Beta),
version("r3", &["26.3"], ReleaseType::Release),
version("b2", &["26.2"], ReleaseType::Beta),
version(
"r1",
&["1.21.11", "Fabric", "26.1", "1.21.5-rc1", "Client"],
ReleaseType::Release,
),
];
let picks: Vec<(String, String)> = minecraft_choices(&versions)
.into_iter()
.map(|c| (c.mc, c.version_id))
.collect();
let expect = [("26.3", "r3"), ("26.2", "b2"), ("26.1 / 1.21.11", "r1")];
assert_eq!(
picks,
expect.map(|(a, b)| (a.to_string(), b.to_string())).to_vec()
);
}
#[test]
fn one_choice_per_loader_on_the_same_minecraft_version() {
use oneclient_common::domain::GameLoader;
let with = |id, loader, release_type| VersionSummary {
loaders: vec![loader],
..version(id, &["1.21.1"], release_type)
};
let versions = [
with("fabric-new", GameLoader::Fabric, ReleaseType::Release),
with("forge-beta", GameLoader::Forge, ReleaseType::Beta),
with("fabric-old", GameLoader::Fabric, ReleaseType::Release),
with("forge-rel", GameLoader::Forge, ReleaseType::Release),
];
let ids: Vec<String> = minecraft_choices(&versions)
.into_iter()
.map(|c| c.version_id)
.collect();
assert_eq!(ids, ["fabric-new", "forge-rel"]);
}
}

View file

@ -8,17 +8,19 @@ 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,
ALL_VERSIONS, VERSIONS_PAGE_SIZE, 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_when,
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,
Installed, InstalledVersion, ModpackVersionPrompt, PackageBanner, Thumbnail,
WorldInstallPrompt, activity_badge, installed_badge, installed_map, minecraft_choices,
preferred_version,
};
use crate::utils::abbreviate_number;
@ -40,9 +42,17 @@ struct Installer {
provider: ProviderId,
world_prompt: Option<State<Option<String>>>,
modpack: bool,
modpack_prompt: Option<State<bool>>,
}
impl Installer {
fn install_latest(&self, project_id: String, version_id: String) {
match self.modpack_prompt {
Some(mut prompt) => prompt.set(true),
None => self.install(project_id, version_id),
}
}
fn install(&self, project_id: String, version_id: String) {
if self.modpack {
self.dispatch.install_modpack(ModpackSource::Provider {
@ -148,13 +158,15 @@ impl Component for BrowserPackage {
let dispatch = use_dispatch();
let confirm = use_link_confirm();
let world_prompt = use_state(|| None::<String>);
let modpack_prompt = use_state(|| false);
let is_datapack = content_type == ContentType::DataPack;
let installer = Installer {
let mut installer = Installer {
dispatch: dispatch.clone(),
cluster_id,
provider,
world_prompt: is_datapack.then_some(world_prompt),
modpack: content_type == ContentType::Modpack,
modpack_prompt: None,
};
let cluster = use_cluster(cluster_id);
@ -178,13 +190,25 @@ impl Component for BrowserPackage {
}
let project_query = use_package_project(provider, project_id.clone());
let versions_query = use_package_versions(
let is_modpack = content_type == ContentType::Modpack;
let versions_query = use_package_versions_when(
!is_modpack,
provider,
project_id.clone(),
game_version,
loader,
*versions_page.read(),
VERSIONS_PAGE_SIZE,
);
let all_versions = version_list(&use_package_versions_when(
is_modpack,
provider,
project_id.clone(),
None,
None,
0,
ALL_VERSIONS,
));
let (installing, waiting) = use_installs_snapshot().package_busy(
content_type == ContentType::Modpack,
@ -216,8 +240,20 @@ impl Component for BrowserPackage {
}
});
let versions = version_list(&versions_query);
let total_versions = versions_total(&versions_query);
let (versions, total_versions) = if is_modpack {
let page = all_versions
.iter()
.skip(versions_page.read().saturating_mul(VERSIONS_PAGE_SIZE))
.take(VERSIONS_PAGE_SIZE)
.cloned()
.collect();
(page, all_versions.len())
} else {
(
version_list(&versions_query),
versions_total(&versions_query),
)
};
let dependency_ids: Vec<String> = versions
.iter()
.flat_map(|v| &v.dependencies)
@ -229,8 +265,11 @@ impl Component for BrowserPackage {
.into_iter()
.map(|(id, meta)| (id, meta.name))
.collect();
let latest_source = if is_modpack { &all_versions } else { &versions };
let latest_version =
preferred_version(&versions, content_type).map(|v| v.version_id.clone());
preferred_version(latest_source, content_type).map(|v| v.version_id.clone());
let choices = minecraft_choices(&all_versions);
installer.modpack_prompt = (choices.len() > 1).then_some(modpack_prompt);
let gallery = project
.as_ref()
@ -303,6 +342,12 @@ impl Component for BrowserPackage {
project_id: project_id.clone(),
pending: world_prompt,
}))
.maybe_child(modpack_prompt.read().then(|| ModpackVersionPrompt {
provider,
project_id: project_id.clone(),
choices,
open: modpack_prompt,
}))
.into_element()
}
}

View file

@ -108,7 +108,7 @@ pub(super) fn sidebar(
.enabled(can_install)
.on_press(move |_| {
if let Some(version_id) = latest_version.clone() {
installer.install(project_id.clone(), version_id);
installer.install_latest(project_id.clone(), version_id);
}
})
.child(Icon::new(IconType::Download01).size(14.))