feat(ui): browser package better multi-version management

This commit is contained in:
LynithDev 2026-08-01 16:32:57 +02:00
parent fb90eb7bb8
commit 4c6de0d2c4
24 changed files with 1535 additions and 271 deletions

View file

@ -3,17 +3,19 @@
//! Only ever shows packages the user installed from the browser; bundle content
//! has its own flow and is never listed here.
//!
//! Every row is answered on its own — one Update button, one Skip button — so a
//! user who wants a single mod moved forward is not made to take the rest with
//! it. Answering removes the row; the modal closes when the last one is gone.
//! Every row carries its own dropdown, so a user who wants a single mod moved
//! forward is not made to take the rest with it. Nothing happens until Proceed:
//! the choices are collected first and applied in one pass, which is why the
//! rows stay put while they are being answered.
use std::collections::HashMap;
use freya::prelude::*;
use oneclient_content::packages::{CachedPackageMeta, ProviderId};
use oneclient_core::BrowserPackageUpdate;
use oneclient_db::models::ClusterId;
use crate::components::{Button, Icon, IconType, OverlayPopup, ScrollArea};
use crate::components::{Button, Dropdown, Icon, IconType, OverlayPopup, ScrollArea};
use crate::hooks::{
package_meta_batch, use_dispatch, use_notifications_snapshot, use_package_meta_batch,
};
@ -22,11 +24,49 @@ use crate::theme::colors;
use crate::ui::border_all_color;
const CARD_BG: Color = Color::from_rgb(26, 34, 41);
const DIALOG_W: f32 = 460.;
const DIALOG_W: f32 = 520.;
const DIALOG_H: f32 = 420.;
const CHOICE_DROPDOWN_W: f32 = 150.;
type MetaMap = HashMap<(ProviderId, String), CachedPackageMeta>;
/// What Proceed should do with one row.
///
/// The two skips differ only in whether the answer outlives the modal: a plain
/// skip leaves the package stale and it is offered again next launch, while
/// [`RowChoice::SkipVersion`] records that this exact version was declined and
/// stops it being raised until a newer one appears.
#[derive(Clone, Copy, Default, PartialEq, Eq)]
enum RowChoice {
#[default]
Update,
Skip,
SkipVersion,
}
impl RowChoice {
const ALL: [Self; 3] = [Self::Update, Self::Skip, Self::SkipVersion];
fn label(self) -> &'static str {
match self {
Self::Update => "Update",
Self::Skip => "Skip for now",
Self::SkipVersion => "Skip this version",
}
}
fn options() -> Vec<String> {
Self::ALL.iter().map(|c| c.label().to_string()).collect()
}
}
/// Identifies a row across the whole modal, which may span several clusters.
type RowKey = (ClusterId, String);
fn row_key(update: &BrowserPackageUpdate) -> RowKey {
(update.cluster_id, update.hash.clone())
}
#[derive(PartialEq)]
pub struct PackageUpdatePopup;
@ -35,6 +75,11 @@ impl Component for PackageUpdatePopup {
let snapshot = use_notifications_snapshot();
let dispatch = use_dispatch();
// Only the rows the user actually touched. Everything absent is an
// Update, so a group that arrives while the modal is open still gets a
// sensible default without being reconciled into the map.
let choices = use_state(HashMap::<RowKey, RowChoice>::new);
let groups = snapshot.package_updates.clone();
// Hooks run unconditionally, so the meta lookup is built from a possibly
@ -67,7 +112,7 @@ impl Component for PackageUpdatePopup {
.width(Size::window_percent(100.))
.height(Size::window_percent(100.))
.center()
.child(dialog(&groups, &meta, dispatch)),
.child(dialog(&groups, &meta, dispatch, choices)),
)
.into_element()
}
@ -84,6 +129,7 @@ fn dialog(
groups: &[PackageUpdateGroup],
meta: &MetaMap,
dispatch: crate::Actions,
choices: State<HashMap<RowKey, RowChoice>>,
) -> impl IntoElement {
rect()
.vertical()
@ -101,26 +147,27 @@ fn dialog(
0.,
Color::from_argb(150, 0, 0, 0),
)))
.child(content(groups, meta, dispatch))
.child(content(groups, meta, dispatch, choices))
}
fn content(
groups: &[PackageUpdateGroup],
meta: &MetaMap,
dispatch: crate::Actions,
choices: State<HashMap<RowKey, RowChoice>>,
) -> impl IntoElement {
let dismiss = dispatch.clone();
let update_all = dispatch.clone();
let proceed_dispatch = dispatch.clone();
let total: usize = groups.iter().map(|group| group.packages.len()).sum();
let subtitle = match groups {
[only] => format!(
"{total} package{} in {} can be updated.",
"{total} package{} in {} can be updated. Pick what happens to each, then Proceed.",
if total == 1 { "" } else { "s" },
only.cluster_name
),
_ => format!(
"{total} package{} across {} clusters can be updated.",
"{total} package{} across {} clusters can be updated. Pick what happens to each, then Proceed.",
if total == 1 { "" } else { "s" },
groups.len()
),
@ -154,34 +201,52 @@ fn content(
label()
.text(subtitle)
.font_size(12.5)
.max_lines(2)
.max_lines(3)
.color(colors::fg_secondary()),
),
)
.child(update_list(groups, meta, groups.len() > 1, &dispatch))
.child(update_list(groups, meta, groups.len() > 1, choices))
.child(
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.main_align(Alignment::SpaceBetween)
.main_align(Alignment::End)
.spacing(8.)
.child(
Button::new()
.ghost()
.on_press(move |_| dismiss.close_package_updates())
.text("Not now"),
.text("Cancel"),
)
.child(
Button::new()
.primary()
.on_press(move |_| {
let answers = choices.read();
for update in &all {
update_all.apply_package_update(update.clone());
match answers
.get(&row_key(update))
.copied()
.unwrap_or_default()
{
RowChoice::Update => {
proceed_dispatch.apply_package_update(update.clone());
}
// Nothing to record: the package stays
// stale and is offered again next launch.
RowChoice::Skip => {}
RowChoice::SkipVersion => proceed_dispatch
.skip_package_update(
update.cluster_id,
update.hash.clone(),
),
}
}
proceed_dispatch.close_package_updates();
})
.child(Icon::new(IconType::DownloadCloud02).size(15.))
.text("Update all"),
.text("Proceed"),
),
)
}
@ -190,7 +255,7 @@ fn update_list(
groups: &[PackageUpdateGroup],
meta: &MetaMap,
show_headers: bool,
dispatch: &crate::Actions,
choices: State<HashMap<RowKey, RowChoice>>,
) -> impl IntoElement {
let mut scroll = ScrollArea::new()
.width(Size::fill())
@ -204,8 +269,13 @@ fn update_list(
for update in &group.packages {
let key = format!("{}:{}", group.cluster_id, update.hash);
let choice = choices
.read()
.get(&row_key(update))
.copied()
.unwrap_or_default();
scroll = scroll.child(
UpdateRow::new(resolve_name(update, meta), update.clone(), dispatch.clone())
UpdateRow::new(resolve_name(update, meta), update.clone(), choice, choices)
.key(key)
.into_element(),
);
@ -238,16 +308,23 @@ fn cluster_header(name: &str, first: bool) -> impl IntoElement {
struct UpdateRow {
name: String,
update: BrowserPackageUpdate,
dispatch: crate::Actions,
choice: RowChoice,
choices: State<HashMap<RowKey, RowChoice>>,
key: DiffKey,
}
impl UpdateRow {
fn new(name: String, update: BrowserPackageUpdate, dispatch: crate::Actions) -> Self {
fn new(
name: String,
update: BrowserPackageUpdate,
choice: RowChoice,
choices: State<HashMap<RowKey, RowChoice>>,
) -> Self {
Self {
name,
update,
dispatch,
choice,
choices,
key: DiffKey::None,
}
}
@ -255,7 +332,7 @@ impl UpdateRow {
impl PartialEq for UpdateRow {
fn eq(&self, other: &Self) -> bool {
self.name == other.name && self.update == other.update
self.name == other.name && self.update == other.update && self.choice == other.choice
}
}
@ -268,11 +345,9 @@ impl KeyExt for UpdateRow {
impl Component for UpdateRow {
fn render(&self) -> impl IntoElement {
let update = self.update.clone();
let cluster_id = update.cluster_id;
let skip_hash = update.hash.clone();
let apply_dispatch = self.dispatch.clone();
let skip_dispatch = self.dispatch.clone();
let choice = self.choice;
let mut choices = self.choices;
let key = row_key(&update);
// "1.0.2 to 1.1.0" when both are known; a provider that records neither
// still gets a usable row rather than an empty line.
@ -326,20 +401,14 @@ impl Component for UpdateRow {
),
)
.child(
Button::new()
.small()
.ghost()
.on_press(move |_| {
skip_dispatch.skip_package_update(cluster_id, skip_hash.clone())
})
.text("Skip"),
)
.child(
Button::new()
.small()
.primary()
.on_press(move |_| apply_dispatch.apply_package_update(update.clone()))
.text("Update"),
Dropdown::new(choice.label(), RowChoice::options())
.width(Size::px(CHOICE_DROPDOWN_W))
.height(Size::px(26.))
.on_select(move |idx: usize| {
if let Some(choice) = RowChoice::ALL.get(idx) {
choices.write().insert(key.clone(), *choice);
}
}),
)
}
}

View file

@ -793,6 +793,30 @@ impl Actions {
});
if install.result.is_ok() {
// Installing a second version of something the cluster already
// had leaves the newest live and the rest switched off. Done
// before the refresh below so the version list never renders the
// moment where both copies read as active.
if let Err(err) = oneclient_content::packages::reconcile_duplicate_activity(
cluster_id,
&state.services.content(),
)
.await
{
tracing::warn!(%err, "failed to resolve duplicate package versions");
}
// What the install button turns into is read straight off the
// cluster's content, so that is refreshed here and waited for
// before the busy flag drops. Left to the `ClustersChanged`
// signal alone, the button came back as a live "Install" the
// moment the download finished and only grew its "Installed"
// badge once an event-pump round trip and a full cluster-query
// sweep — network calls included — had gone by.
super::invalidate_cluster_content_queries().await;
// Everything else the install touched is nobody's blocker and
// catches up on the usual signal.
state
.services
.events
@ -803,6 +827,77 @@ impl Actions {
});
}
/// Drops one installed version of a browsed package.
///
/// Shares the installs channel with [`Self::install_package`] so the whole
/// project reads as busy while this runs: the version list has a button per
/// row, and none of them should be pressable while one is being acted on.
///
/// The removal is recorded as a bundle override when a bundle owns the
/// artifact — the same thing the package manager's remove does. Without it
/// the next sync puts the version straight back.
pub fn remove_package_version(
&self,
cluster_id: ClusterId,
provider: ProviderId,
project_id: impl Into<String>,
hash: impl Into<String>,
display_name: impl Into<String>,
) {
let (project_id, hash, display_name) =
(project_id.into(), hash.into(), display_name.into());
let actions = self.clone();
self.station
.clone()
.write_channel(AppChannel::Installs)
.installs
.begin(cluster_id, provider, project_id.clone());
spawn_forever(async move {
let finish = |actions: &Actions| {
actions
.station
.clone()
.write_channel(AppChannel::Installs)
.installs
.finish(cluster_id, provider, &project_id);
};
let Ok(state) = launcher::state() else {
finish(&actions);
return;
};
let events = state.services.events.clone();
match oneclient_core::remove_artifact_from_cluster(
cluster_id,
&hash,
true,
&state.services.content(),
)
.await
{
Ok(()) => {
events.notify("Removed").body(display_name).send();
// Same reasoning as the install path: what the row turns
// back into is read straight off the cluster's content, so
// that is refreshed before the busy flag drops.
super::invalidate_cluster_content_queries().await;
events.signal(oneclient_events::Signal::ClustersChanged);
}
Err(err) => events
.notify("Remove failed")
.body(err.to_string())
.error()
.send(),
}
finish(&actions);
});
}
pub fn install_bundle(
&self,
cluster_id: ClusterId,

View file

@ -27,7 +27,8 @@ pub use queries::{
UseStorageAction, UseUploadLog,
VERSIONS_PAGE_SIZE, accounts_have_microsoft, bundle_overrides_map, bundles_with_status_items,
category_list, changelog_error, changelog_groups, changelog_is_loading, cluster_content_items,
content_type_for_slug, has_migration_data, invalidate_cluster_queries, invalidate_java_queries,
content_type_for_slug, has_migration_data, invalidate_cluster_content_queries,
invalidate_cluster_queries, invalidate_java_queries,
invalidate_logs_queries, invalidate_profile_queries, invalidate_screenshots_queries,
invalidate_storage_queries, try_storage_report, use_storage_action, use_storage_report,
java_runtimes, latest_changelog_version, loaded_image, loader_versions,
@ -47,7 +48,8 @@ pub use queries::{
use_finish_microsoft_login, use_game_profile, use_global_analytics, use_java_runtimes,
use_loader_versions, use_local_image, use_log_action, use_log_content, use_migration,
use_named_profiles, use_onboarding_bundles, use_package_categories, use_package_meta_batch,
use_package_project, use_package_search, use_package_versions, use_player_profile,
use_package_project, use_package_search, 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_set_default_account, use_terms, use_upload_log,
use_version_metadata, use_versions, version_list, versions_metadata, versions_total,

View file

@ -78,13 +78,15 @@ pub use storage::{
invalidate_storage_queries, try_storage_report, use_storage_action, use_storage_report,
};
pub use mutations::{
ClusterAction, invalidate_cluster_queries, invalidate_profile_queries, use_cluster_mutation,
ClusterAction, invalidate_cluster_content_queries, invalidate_cluster_queries,
invalidate_profile_queries, use_cluster_mutation,
};
pub use package_updates::{package_updates, stale_hashes, use_package_updates};
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,
};
pub use player_profile::use_player_profile;

View file

@ -24,6 +24,16 @@ pub async fn invalidate_cluster_queries() {
QueriesStorage::<LoaderVersionsQuery>::try_invalidate_all().await;
}
/// Just the query that decides whether a cluster already has a package.
///
/// Split out of [`invalidate_cluster_queries`] so an install can wait for
/// exactly this before it stops calling itself busy. The full sweep reaches the
/// network several times over, and waiting for all of it would trade one stale
/// button for a slower one.
pub async fn invalidate_cluster_content_queries() {
QueriesStorage::<ClusterContentQuery>::try_invalidate_all().await;
}
pub async fn invalidate_profile_queries() {
QueriesStorage::<ListNamedProfilesQuery>::try_invalidate_all().await;
QueriesStorage::<GameProfileQuery>::try_invalidate_all().await;

View file

@ -237,6 +237,22 @@ pub fn use_package_versions(
game_version: Option<String>,
loader: Option<GameLoader>,
page: usize,
) -> UseQuery<PackageVersionsQuery> {
use_package_versions_when(true, provider, project_id, game_version, loader, page)
}
/// The version list, fetched only when `enabled`.
///
/// A listing row keeps its install button after the package is in the cluster,
/// so the hook still has to run there — but there is nothing left for it to
/// install, and a request per already-installed row is pure waste.
pub fn use_package_versions_when(
enabled: bool,
provider: ProviderId,
project_id: String,
game_version: Option<String>,
loader: Option<GameLoader>,
page: usize,
) -> UseQuery<PackageVersionsQuery> {
use_query(
Query::new(
@ -249,6 +265,7 @@ pub fn use_package_versions(
},
PackageVersionsQuery,
)
.enable(enabled)
.stale_time(VERSIONS_STALE),
)
}

View file

@ -71,6 +71,61 @@ pub fn centered_note(text: &str) -> Element {
.into_element()
}
/// How many columns no wider than `max_col` fit across `width`.
///
/// One column until the grid has measured itself, so the first frame renders
/// something rather than dividing by an unknown.
pub fn grid_columns_for_width(width: f32, max_col: f32, gap: f32) -> usize {
if width <= 0. {
return 1;
}
(((width + gap) / (max_col + gap)).ceil() as usize).max(1)
}
/// Lays `items` out in `cols` equal columns, filling row by row.
///
/// There is no wrapping flex row to lean on, so the split is done here from the
/// width the grid last measured. It reports its own size back through `width`,
/// which the caller feeds to [`grid_columns_for_width`] on the next render.
/// Short final rows are padded with empty flex cells so their tiles keep the
/// column width instead of stretching across the row.
pub fn flow_grid(items: Vec<Element>, cols: usize, mut width: State<f32>, gap: f32) -> Element {
let cols = cols.max(1);
let mut root = rect().vertical().width(Size::fill()).spacing(gap);
let mut iter = items.into_iter();
let mut remaining = true;
while remaining {
let mut row = rect()
.horizontal()
.width(Size::fill())
.spacing(gap)
.content(Content::Flex);
let mut filled = 0;
for _ in 0..cols {
if let Some(item) = iter.next() {
row = row.child(item);
filled += 1;
} else {
row = row.child(rect().width(Size::flex(1.0)));
}
}
if filled == 0 {
break;
}
remaining = filled == cols;
root = root.child(row.into_element());
}
root.on_sized(move |event: Event<SizedEventData>| {
let w = event.data().area.width();
if (w - *width.peek()).abs() > 0.5 {
*width.write() = w;
}
})
.into_element()
}
pub fn entrance_motion_layer(
slide_x: f32,
slide_y: f32,

View file

@ -9,7 +9,7 @@ use oneclient_content::packages::{ContentType, ProviderId};
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, version_list,
use_package_versions_when, version_list,
};
use crate::routes::Route;
use crate::theme::colors;
@ -17,9 +17,6 @@ use crate::ui::border_all_color;
type InstalledMap = HashMap<(ProviderId, String), Installed>;
/// How tall a card's footer row is kept, so the row sits at the same height
/// whether it holds the install button or only the download count.
const CARD_FOOTER_H: f32 = 30.;
/// Diameter of the round install button.
const INSTALL_BUTTON: f32 = 30.;
@ -146,13 +143,25 @@ impl Component for PackageCard {
.child(PackageBanner::new(icon_url, BANNER_H))
// Sat in the banner's top-left corner rather than down in
// the footer: it is the first thing worth knowing about a
// result, and it leaves the footer to the install button.
// result, and the opposite corner is the install button's.
.maybe_child(self.installed.map(|installed| {
rect()
.position(Position::new_absolute().top(8.).left(8.))
.layer(Layer::Relative(7))
.child(installed_badge_overlay(installed))
.into_element()
}))
.maybe_child(self.installed.is_none().then(|| {
rect()
.position(Position::new_absolute().top(8.).right(8.))
.layer(Layer::Relative(7))
.child(InstallButton::new(
&self.item,
self.cluster_id,
&self.package_type,
false,
))
.into_element()
})),
)
.child(
@ -198,32 +207,7 @@ impl Component for PackageCard {
.color(colors::fg_secondary()),
),
)
.child(
rect()
.horizontal()
.width(Size::fill())
// Held at the button's height so the download count
// doesn't shift down on cards without one.
.height(Size::px(CARD_FOOTER_H))
.cross_align(Alignment::Center)
.content(Content::Flex)
.child(downloads_row(self.item.downloads))
.child(
rect()
.horizontal()
.width(Size::flex(1.0))
.cross_align(Alignment::Center)
.main_align(Alignment::End)
.maybe_child(self.installed.is_none().then(|| {
InstallButton::new(
&self.item,
self.cluster_id,
&self.package_type,
)
.into_element()
})),
),
),
.child(downloads_row(self.item.downloads)),
)
}
}
@ -317,7 +301,15 @@ impl Component for ListRow {
.max_lines(1)
.color(colors::fg_secondary()),
)
.child(Icon::new(item.provider).size(12.)),
.child(Icon::new(item.provider).size(12.))
// Beside the attribution rather than out at the far
// right, where it drifted away from the package it
// describes and crowded the install button.
.maybe_child(
self.installed.map(|installed| {
installed_badge(installed, 10.).into_element()
}),
),
)
.child(
label()
@ -328,36 +320,41 @@ impl Component for ListRow {
.color(colors::fg_secondary()),
),
)
.maybe_child(
self.installed
.map(|installed| installed_badge(installed, 11.).into_element()),
)
.child(downloads_row(item.downloads))
.maybe_child(self.installed.is_none().then(|| {
InstallButton::new(&self.item, self.cluster_id, &self.package_type).into_element()
}))
// Kept in place once the package is in the cluster, rather than
// dropped: a row that loses its button leaves the ones around it
// looking misaligned. Disabled says the same thing and holds still.
.child(InstallButton::new(
&self.item,
self.cluster_id,
&self.package_type,
self.installed.is_some(),
))
}
}
/// Installs a listing entry's latest compatible version without making the user
/// open its page first. Same version pick and same dispatch as the install
/// button on the package page. Only rendered for entries the cluster doesn't
/// already have — an installed or bundled one wears its badge instead.
/// button on the package page.
#[derive(PartialEq)]
struct InstallButton {
provider: ProviderId,
project_id: String,
cluster_id: i64,
content_type: ContentType,
/// The cluster already has this one, so there is nothing left to press.
/// Callers that hide the button entirely in that case pass `false`.
installed: bool,
}
impl InstallButton {
fn new(item: &ProjectSummary, cluster_id: i64, package_type: &str) -> Self {
fn new(item: &ProjectSummary, cluster_id: i64, package_type: &str, installed: bool) -> Self {
Self {
provider: item.provider,
project_id: item.id.clone(),
cluster_id,
content_type: content_type_for_slug(package_type),
installed,
}
}
}
@ -382,7 +379,10 @@ impl Component for InstallButton {
_ => (None, None),
};
let versions = version_list(&use_package_versions(
let installed = self.installed;
let versions = version_list(&use_package_versions_when(
!installed,
provider,
project_id.clone(),
game_version,
@ -407,8 +407,14 @@ impl Component for InstallButton {
.height(Size::px(INSTALL_BUTTON))
.padding(Gaps::new_all(0.))
.corner_radius(CornerRadius::new_all(INSTALL_BUTTON / 2.))
.alt(if installing { "Installing" } else { "Install" })
.enabled(latest.is_some() && !installing)
.alt(if installed {
"Already installed"
} else if installing {
"Installing"
} else {
"Install"
})
.enabled(!installed && latest.is_some() && !installing)
.on_press(move |_| {
if let Some(version_id) = latest.clone() {
dispatch.install_package(

View file

@ -18,7 +18,7 @@ use crate::ui::border_all_color;
const BANNER_BG: Color = Color::from_rgb(21, 28, 34);
/// How a browsed package is already present in the cluster.
#[derive(Clone, Copy, PartialEq)]
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum InstallSource {
/// Installed on its own.
Manual,
@ -44,18 +44,50 @@ impl InstallSource {
}
}
/// One version of a browsed package that the cluster has.
#[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 yet, which is a state the user cannot act on.
pub hash: Option<String>,
/// Whether the game loads this one. Only meaningful alongside a `hash`;
/// a bundle pin with nothing linked is neither on nor off, and the badge
/// that reads this never renders for one.
pub enabled: bool,
pub source: InstallSource,
}
/// A browsed package the cluster already has.
///
/// More than one version is not a state the launcher aims for, but nothing
/// stops a cluster reaching it, so this holds every one it finds rather than an
/// arbitrary winner — the version list marks each of them.
#[derive(Clone, PartialEq)]
pub(crate) struct Installed {
/// How the package as a whole got here, for the badge on cards and the
/// sidebar. A bundle claiming the project wins over a hand-installed copy.
pub source: InstallSource,
/// Which version is in there, when the provider recorded one. Used to mark
/// the matching row in a package's version list, never shown as text.
pub version_id: Option<String>,
/// Every version in the cluster the provider recorded one for, in the order
/// the cluster reports them.
pub versions: Vec<InstalledVersion>,
}
impl Installed {
pub fn is_version(&self, version_id: &str) -> bool {
self.version_id.as_deref() == Some(version_id)
self.find_version(version_id).is_some()
}
pub fn find_version(&self, version_id: &str) -> Option<&InstalledVersion> {
self.versions.iter().find(|v| v.version_id == version_id)
}
/// Whether the cluster holds more than one copy of this package.
///
/// Counts linked artifacts only: a bundle pin the cluster never downloaded
/// is listed as a version but is not a second copy of anything.
pub fn is_duplicated(&self) -> bool {
self.versions.iter().filter(|v| v.hash.is_some()).count() > 1
}
}
@ -67,22 +99,36 @@ pub(crate) fn installed_map(
content: Vec<LinkedArtifactInfo>,
bundles: &[BundleWithUpdateStatus],
) -> HashMap<(ProviderId, String), Installed> {
let mut map: HashMap<(ProviderId, String), Installed> = content
.into_iter()
.filter_map(|item| {
Some((
(item.provider?, item.project_id?),
Installed {
source: InstallSource::Manual,
version_id: item.version_id,
},
))
})
.collect();
let mut map: HashMap<(ProviderId, String), Installed> = HashMap::new();
for item in content {
let (Some(provider), Some(project_id)) = (item.provider, item.project_id) else {
continue;
};
// The entry is created even for an artifact with no recorded version:
// the package is in the cluster either way, and the card badge only
// asks that much. It just cannot be tied to a row in the version list.
let installed = map.entry((provider, project_id)).or_insert(Installed {
source: InstallSource::Manual,
versions: Vec::new(),
});
if let Some(version_id) = item.version_id {
installed.versions.push(InstalledVersion {
version_id,
hash: Some(item.hash),
enabled: item.enabled,
source: InstallSource::Manual,
});
}
}
// Bundle membership wins: a bundle's files land in the cluster as ordinary
// content, so they'd otherwise read as hand-installed. The version on disk
// is the truth when there is one; the manifest's pin is the fallback.
// is the truth when there is one; the manifest's pin is the fallback, and
// is deliberately not added alongside a linked version — a pin the cluster
// has not downloaded is not something the user has installed.
for bundle in bundles {
for (file, _status) in &bundle.files {
if let BundleFileKind::Managed {
@ -92,12 +138,32 @@ pub(crate) fn installed_map(
..
} = &file.kind
{
map.entry((*provider, project_id.clone()))
.and_modify(|installed| installed.source = InstallSource::Bundled)
.or_insert_with(|| Installed {
source: InstallSource::Bundled,
version_id: Some(version_id.clone()),
});
match map.get_mut(&(*provider, project_id.clone())) {
Some(installed) => {
installed.source = InstallSource::Bundled;
if let Some(version) = installed
.versions
.iter_mut()
.find(|v| &v.version_id == version_id)
{
version.source = InstallSource::Bundled;
}
}
None => {
map.insert(
(*provider, project_id.clone()),
Installed {
source: InstallSource::Bundled,
versions: vec![InstalledVersion {
version_id: version_id.clone(),
hash: None,
enabled: false,
source: InstallSource::Bundled,
}],
},
);
}
}
}
}
}
@ -121,6 +187,41 @@ pub(crate) fn installed_badge_overlay(installed: InstallSource) -> impl IntoElem
)
}
/// Which of several installed versions the game actually loads.
///
/// Only worth showing when a package has more than one copy in the cluster —
/// with a single one "active" says nothing "installed" hasn't.
///
/// Reads the artifact's `enabled` flag rather than working the answer out
/// itself, because
/// [`reconcile_duplicate_activity`](oneclient_content::packages::reconcile_duplicate_activity)
/// has already resolved duplicates to the newest copy. Exactly one row in a
/// duplicated set should read Active; more than one means that pass has not run
/// since the copies appeared.
pub(crate) fn activity_badge(active: bool) -> impl IntoElement {
let (text, color) = if active {
("Active", colors::brand())
} else {
("Inactive", colors::fg_secondary())
};
// No icon, unlike the source badges it sits beside: three pills in a row
// all wearing a tick reads as decoration rather than as three facts.
rect()
.horizontal()
.cross_align(Alignment::Center)
.padding(Gaps::new_symmetric(2., 8.))
.corner_radius(CornerRadius::new_all(999.))
.background(color.with_a(38))
.child(
label()
.text(text)
.font_size(11.)
.max_lines(1)
.color(color),
)
}
fn badge(
installed: InstallSource,
font_size: f32,
@ -304,3 +405,185 @@ impl Component for PackageBanner {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::view::onboarding::test_support::archive;
use oneclient_content::packages::ContentType;
use oneclient_core::{BundleFile, FileUpdateStatus};
fn linked(project_id: &str, version_id: Option<&str>, hash: &str) -> LinkedArtifactInfo {
disabled_linked(project_id, version_id, hash, true)
}
fn disabled_linked(
project_id: &str,
version_id: Option<&str>,
hash: &str,
enabled: bool,
) -> LinkedArtifactInfo {
LinkedArtifactInfo {
hash: hash.to_string(),
cluster_file_name: format!("{hash}.jar"),
enabled,
content_type: ContentType::Mod,
file_name: format!("{hash}.jar"),
project_id: Some(project_id.to_string()),
version_id: version_id.map(Into::into),
display_name: None,
display_version: None,
provider: Some(ProviderId::Modrinth),
published_at: None,
}
}
fn managed(project_id: &str, version_id: &str) -> BundleFile {
BundleFile {
enabled: true,
hidden: false,
path: format!("mods/{project_id}.jar"),
size: 1,
kind: BundleFileKind::Managed {
provider: ProviderId::Modrinth,
project_id: project_id.to_string(),
version_id: version_id.to_string(),
sha1: format!("sha-{version_id}"),
},
}
}
fn bundles(files: Vec<BundleFile>) -> Vec<BundleWithUpdateStatus> {
vec![BundleWithUpdateStatus {
files: files
.iter()
.cloned()
.map(|f| (f, FileUpdateStatus::UpToDate))
.collect(),
archive: archive("performance", true, files),
has_updates: false,
}]
}
fn entry(map: &HashMap<(ProviderId, String), Installed>, project: &str) -> Installed {
map.get(&(ProviderId::Modrinth, project.to_string()))
.expect("project missing from the map")
.clone()
}
#[test]
fn every_installed_version_of_one_project_is_listed() {
let map = installed_map(
vec![
linked("sodium", Some("v1"), "hash-1"),
linked("sodium", Some("v2"), "hash-2"),
],
&[],
);
let sodium = entry(&map, "sodium");
assert!(sodium.is_version("v1"), "the older version is still in there");
assert!(sodium.is_version("v2"));
assert_eq!(
sodium.find_version("v2").and_then(|v| v.hash.clone()),
Some("hash-2".to_string()),
"each version carries the artifact the remove button needs"
);
}
#[test]
fn a_bundle_marks_only_the_version_it_ships() {
let map = installed_map(
vec![
linked("sodium", Some("v1"), "hash-1"),
linked("sodium", Some("v2"), "hash-2"),
],
&bundles(vec![managed("sodium", "v1")]),
);
let sodium = entry(&map, "sodium");
assert_eq!(sodium.source, InstallSource::Bundled, "the bundle owns the project");
assert_eq!(
sodium.find_version("v1").map(|v| v.source),
Some(InstallSource::Bundled)
);
assert_eq!(
sodium.find_version("v2").map(|v| v.source),
Some(InstallSource::Manual),
"a version the user added themselves does not become the bundle's"
);
}
#[test]
fn an_unlinked_bundle_pin_is_the_fallback() {
let map = installed_map(Vec::new(), &bundles(vec![managed("sodium", "v1")]));
let sodium = entry(&map, "sodium");
assert_eq!(sodium.source, InstallSource::Bundled);
assert_eq!(
sodium.find_version("v1").map(|v| v.hash.clone()),
Some(None),
"nothing is linked, so there is no artifact to remove"
);
}
#[test]
fn a_bundle_pin_is_not_listed_beside_a_linked_version() {
let map = installed_map(
vec![linked("sodium", Some("v2"), "hash-2")],
&bundles(vec![managed("sodium", "v1")]),
);
let sodium = entry(&map, "sodium");
assert!(
!sodium.is_version("v1"),
"a pin the cluster never downloaded is not installed"
);
assert_eq!(sodium.versions.len(), 1);
}
#[test]
fn a_single_copy_is_not_a_duplicate() {
let map = installed_map(vec![linked("sodium", Some("v1"), "hash-1")], &[]);
assert!(!entry(&map, "sodium").is_duplicated());
}
#[test]
fn an_unlinked_bundle_pin_is_not_a_second_copy() {
let map = installed_map(Vec::new(), &bundles(vec![managed("sodium", "v1")]));
assert!(
!entry(&map, "sodium").is_duplicated(),
"one pin and nothing linked is not two of anything"
);
}
#[test]
fn each_copy_reports_whether_the_game_loads_it() {
let map = installed_map(
vec![
disabled_linked("sodium", Some("v1"), "hash-1", false),
linked("sodium", Some("v2"), "hash-2"),
],
&[],
);
let sodium = entry(&map, "sodium");
assert!(sodium.is_duplicated());
assert_eq!(sodium.find_version("v1").map(|v| v.enabled), Some(false));
assert_eq!(sodium.find_version("v2").map(|v| v.enabled), Some(true));
}
#[test]
fn an_artifact_with_no_recorded_version_still_marks_the_project() {
let map = installed_map(vec![linked("sodium", None, "hash-1")], &[]);
let sodium = entry(&map, "sodium");
assert!(
sodium.versions.is_empty(),
"there is no version to tie to a row in the list"
);
assert_eq!(sodium.source, InstallSource::Manual);
}
}

View file

@ -0,0 +1,348 @@
//! The package page's gallery tab.
//!
//! Laid out like the cluster's screenshots: a grid of tiles that open into a
//! full-window viewer with arrows between them. The difference is where the
//! pixels come from — these are remote images behind the shared image cache
//! rather than files on disk, so a tile and the opened view ask the cache for
//! the same url at two different sizes instead of sharing one decode.
use super::*;
use oneclient_content::packages::types::GalleryImage;
use crate::components::{Button, Icon, IconType, OverlayPopup};
use crate::hooks::{loaded_image, query_is_busy, use_cached_image};
use crate::theme::colors;
use crate::ui::{border_all_color, flow_grid, grid_columns_for_width};
/// How wide a tile is allowed to get before the grid takes another column.
const MAX_COL_W: f32 = 400.;
const GRID_GAP: f32 = 16.;
const TILE_PREVIEW_H: f32 = 168.;
/// Downscale ceilings handed to the image cache. A tile never shows more than
/// its own width, and asking for the full thing there was what made the grid
/// slow to fill; the opened view is the one that needs the detail.
const TILE_EDGE: u32 = 640;
const FULL_EDGE: u32 = 2048;
pub(super) fn gallery_panel(images: Vec<GalleryImage>) -> impl IntoElement {
Gallery { images }
}
#[derive(PartialEq)]
struct Gallery {
images: Vec<GalleryImage>,
}
impl Component for Gallery {
fn render(&self) -> impl IntoElement {
let grid_width = use_state(|| 0f32);
let mut viewing = use_state(|| None::<usize>);
let tiles: Vec<Element> = self
.images
.iter()
.enumerate()
.map(|(idx, image)| {
let key = image.url.clone();
GalleryTile {
image: image.clone(),
on_open: (move |()| viewing.set(Some(idx))).into(),
key: DiffKey::None,
}
.key(key)
.into_element()
})
.collect();
let cols = grid_columns_for_width(*grid_width.read(), MAX_COL_W, GRID_GAP);
let images = self.images.clone();
rect()
.width(Size::fill())
.child(flow_grid(tiles, cols, grid_width, GRID_GAP))
.maybe_child((*viewing.read()).map(|start| {
GalleryViewer {
images: images.clone(),
start,
on_close: (move |()| viewing.set(None)).into(),
}
.into_element()
}))
}
}
#[derive(PartialEq)]
struct GalleryTile {
image: GalleryImage,
on_open: EventHandler<()>,
key: DiffKey,
}
impl KeyExt for GalleryTile {
fn write_key(&mut self) -> &mut DiffKey {
&mut self.key
}
}
impl Component for GalleryTile {
fn render(&self) -> impl IntoElement {
let query = use_cached_image(Some(self.image.url.clone()), TILE_EDGE);
let loaded = loaded_image(Some(&self.image.url), &query);
let mut hovered = use_state(|| false);
let on_open = self.on_open.clone();
use_drop(|| {
Cursor::set(CursorIcon::default());
});
let preview = rect()
.width(Size::fill())
.height(Size::px(TILE_PREVIEW_H))
.center()
.overflow(Overflow::Clip)
.background(colors::component_bg())
.maybe_child(loaded.map(|(url, bytes)| {
ImageViewer::new((url, bytes))
.width(Size::fill())
.height(Size::fill())
.aspect_ratio(AspectRatio::Max)
.image_cover(ImageCover::Center)
.into_element()
}));
rect()
.vertical()
.width(Size::flex(1.0))
.max_width(Size::px(MAX_COL_W))
.corner_radius(CornerRadius::new_all(10.))
.overflow(Overflow::Clip)
.background(PANEL_BG)
.border(
border_all_color(
1.,
if *hovered.read() {
colors::component_border_hover()
} else {
colors::component_border()
},
)
.alignment(BorderAlignment::Inner),
)
.a11y_role(AccessibilityRole::Button)
.on_pointer_enter(move |_| {
hovered.set(true);
Cursor::set(CursorIcon::Pointer);
})
.on_pointer_leave(move |_| {
hovered.set(false);
Cursor::set(CursorIcon::default());
})
.on_press(move |_| on_open.call(()))
.child(preview)
.maybe(self.image.title.is_some(), |el| {
el.child(
rect()
.width(Size::fill())
.padding(Gaps::new_all(10.))
.child(
label()
.text(self.image.title.clone().unwrap_or_default())
.font_size(12.)
.max_lines(2)
.color(colors::fg_secondary()),
),
)
})
}
}
/// The opened image, filling the window with the rest of the gallery a key
/// press away.
#[derive(PartialEq)]
struct GalleryViewer {
images: Vec<GalleryImage>,
start: usize,
on_close: EventHandler<()>,
}
impl Component for GalleryViewer {
fn render(&self) -> impl IntoElement {
let len = self.images.len();
let mut index = use_state({
let start = self.start.min(len.saturating_sub(1));
move || start
});
if len == 0 {
return rect().into_element();
}
let idx = (*index.read()).min(len - 1);
let image = self.images[idx].clone();
let query = use_cached_image(Some(image.url.clone()), FULL_EDGE);
let loaded = loaded_image(Some(&image.url), &query);
let close = self.on_close.clone();
let scrim_close = self.on_close.clone();
let has_prev = idx > 0;
let has_next = idx + 1 < len;
let preview = rect()
.width(Size::flex(1.0))
.height(Size::fill())
.center()
.overflow(Overflow::Clip)
.maybe_child(loaded.map(|(url, bytes)| {
ImageViewer::new((url, bytes))
.width(Size::fill())
.height(Size::fill())
.aspect_ratio(AspectRatio::Min)
.into_element()
}))
// The tile's smaller copy is already cached, but this one is a
// fresh fetch at full size, so the first moment of the opened view
// says so rather than showing an empty frame. Only while it is
// still working: a fetch that failed is not loading.
.maybe_child(query_is_busy(&query).then(|| {
label()
.text("Loading image...")
.font_size(13.)
.color(colors::fg_secondary())
.into_element()
}));
OverlayPopup::new()
.on_close(move |_| scrim_close.call(()))
.child(
rect()
.width(Size::window_percent(100.))
.height(Size::window_percent(100.))
.center()
.on_global_key_down(move |e: Event<KeyboardEventData>| match &e.key {
Key::Named(NamedKey::ArrowLeft) if idx > 0 => index.set(idx - 1),
Key::Named(NamedKey::ArrowRight) if idx + 1 < len => index.set(idx + 1),
_ => {}
})
.child(
rect()
.vertical()
.width(Size::window_percent(88.))
.height(Size::window_percent(90.))
.spacing(12.)
.padding(Gaps::new_all(16.))
.content(Content::Flex)
.child(header_row(&image, idx, len, move |_| close.call(())))
.child(
rect()
.width(Size::fill())
.height(Size::flex(1.0))
.horizontal()
.cross_align(Alignment::Center)
.content(Content::Flex)
.spacing(12.)
.overflow(Overflow::Clip)
.child(chevron_btn(IconType::ArrowLeft, has_prev, move |_| {
if idx > 0 {
index.set(idx - 1);
}
}))
.child(preview)
.child(chevron_btn(
IconType::ArrowRight,
has_next,
move |_| {
if idx + 1 < len {
index.set(idx + 1);
}
},
)),
),
),
)
.into_element()
}
}
fn chevron_btn(
icon: IconType,
enabled: bool,
on_press: impl Into<EventHandler<Event<PressEventData>>>,
) -> impl IntoElement {
let base = rect()
.width(Size::px(44.))
.height(Size::px(44.))
.center()
.corner_radius(CornerRadius::new_all(22.));
if !enabled {
return base
.background(Color::from_argb(70, 0, 0, 0))
.on_press(|_| {})
.child(
Icon::new(icon)
.size(26.)
.color(colors::fg_secondary().with_a(90)),
)
.into_element();
}
base.background(Color::from_argb(140, 0, 0, 0))
.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()))
.on_press(on_press)
.child(Icon::new(icon).size(26.).color(colors::fg_primary()))
.into_element()
}
fn header_row(
image: &GalleryImage,
idx: usize,
len: usize,
on_close: impl Into<EventHandler<Event<PressEventData>>>,
) -> impl IntoElement {
let title = image
.title
.clone()
.unwrap_or_else(|| format!("Image {}", idx + 1));
rect()
.horizontal()
.width(Size::fill())
.content(Content::Flex)
.cross_align(Alignment::Center)
.child(
rect()
.vertical()
.width(Size::flex(1.0))
.spacing(2.)
.child(
label()
.text(title)
.font_size(15.)
.font_weight(FontWeight::SEMI_BOLD)
.max_lines(1)
.color(colors::fg_primary()),
)
.maybe(len > 1, |el| {
el.child(
label()
.text(format!("{} of {len}", idx + 1))
.font_size(11.)
.color(colors::fg_secondary()),
)
}),
)
.child(
Button::new()
.ghost()
.icon()
.on_press(on_close)
.child(Icon::new(IconType::XClose).size(18.)),
)
.into_element()
}

View file

@ -12,12 +12,17 @@ use crate::theme::colors;
use crate::ui::border_all_color;
use crate::hooks::use_cluster;
use super::{InstallSource, Installed, PackageBanner, Thumbnail, installed_badge, installed_map};
use super::{
Installed, InstalledVersion, PackageBanner, Thumbnail, activity_badge, installed_badge,
installed_badge_overlay, installed_map,
};
use crate::utils::abbreviate_number;
mod gallery;
mod panels;
mod sidebar;
use panels::{about_panel, gallery_panel, loading_body, tabs, versions_panel};
use gallery::gallery_panel;
use panels::{about_panel, loading_body, tabs, versions_panel};
use sidebar::sidebar;
const PANEL_BG: Color = Color::from_rgb(21, 28, 34);

View file

@ -1,20 +1,15 @@
use super::*;
use oneclient_content::packages::ProviderId;
use oneclient_content::packages::types::{
GalleryImage, PackageBody, ProjectDetail, ReleaseType, VersionSummary,
};
use oneclient_content::packages::types::{PackageBody, ProjectDetail, ReleaseType, VersionSummary};
use crate::Actions;
use crate::components::{Button, Icon, IconType, Segment, SegmentedControl};
use crate::hooks::{VERSIONS_PAGE_SIZE, loaded_image, use_cached_image};
use crate::hooks::VERSIONS_PAGE_SIZE;
use crate::theme::colors;
use crate::ui::border_all_color;
use crate::utils::format_size;
const GALLERY_IMAGE_H: f32 = 360.;
const GALLERY_EDGE: u32 = 1024;
pub(super) fn loading_body() -> impl IntoElement {
rect()
.width(Size::fill())
@ -83,80 +78,6 @@ impl Component for MarkdownPanel {
}
}
pub(super) fn gallery_panel(images: Vec<GalleryImage>) -> impl IntoElement {
rect()
.vertical()
.width(Size::fill())
.spacing(12.)
.children(images.into_iter().map(|img| {
let key = img.url.clone();
GalleryTile {
image: img,
key: DiffKey::None,
}
.key(key)
.into_element()
}))
.into_element()
}
#[derive(PartialEq)]
struct GalleryTile {
image: GalleryImage,
key: DiffKey,
}
impl KeyExt for GalleryTile {
fn write_key(&mut self) -> &mut DiffKey {
&mut self.key
}
}
impl Component for GalleryTile {
fn render(&self) -> impl IntoElement {
let query = use_cached_image(Some(self.image.url.clone()), GALLERY_EDGE);
let loaded = loaded_image(Some(&self.image.url), &query);
let preview = rect()
.width(Size::fill())
.height(Size::px(GALLERY_IMAGE_H))
.center()
.overflow(Overflow::Clip)
.background(colors::component_bg())
.maybe_child(loaded.map(|(url, bytes)| {
ImageViewer::new((url, bytes))
.width(Size::fill())
.height(Size::fill())
.aspect_ratio(AspectRatio::Max)
.image_cover(ImageCover::Center)
.into_element()
}));
rect()
.vertical()
.width(Size::fill())
.corner_radius(CornerRadius::new_all(10.))
.overflow(Overflow::Clip)
.background(PANEL_BG)
.border(border_all_color(1., colors::component_border()))
.child(preview)
.maybe(self.image.title.is_some(), |el| {
el.child(
rect()
.width(Size::fill())
.padding(Gaps::new_all(10.))
.child(
label()
.text(self.image.title.clone().unwrap_or_default())
.font_size(12.)
.max_lines(2)
.color(colors::fg_secondary()),
),
)
})
}
}
#[allow(clippy::too_many_arguments)]
pub(super) fn versions_panel(
versions: Vec<VersionSummary>,
@ -192,8 +113,11 @@ pub(super) fn versions_panel(
.children(versions.into_iter().map(move |v| {
let tag = installed
.as_ref()
.filter(|installed| installed.is_version(&v.version_id))
.map(|installed| installed.source);
.and_then(|installed| installed.find_version(&v.version_id))
.cloned();
let duplicated = installed
.as_ref()
.is_some_and(|installed| installed.is_duplicated());
version_row(
v,
provider,
@ -201,6 +125,7 @@ pub(super) fn versions_panel(
cluster_id,
dispatch.clone(),
tag,
duplicated,
installing,
)
.into_element()
@ -274,7 +199,11 @@ fn version_row(
project_id: String,
cluster_id: i64,
dispatch: Actions,
installed: Option<InstallSource>,
installed: Option<InstalledVersion>,
// Whether the cluster has more than one version of this package, which is
// the only situation where saying which one is live tells the user
// anything.
duplicated: bool,
installing: bool,
) -> impl IntoElement {
let version_id = v.version_id.clone();
@ -323,22 +252,79 @@ fn version_row(
.color(colors::fg_secondary()),
),
)
.maybe_child(installed.map(|installed| installed_badge(installed, 11.).into_element()))
.child(
Button::new()
.secondary()
.small()
.enabled(installed.is_none() && !installing)
.on_press(move |_| {
dispatch.install_package(
cluster_id,
provider,
project_id.clone(),
version_id.clone(),
);
})
.text("Install"),
.maybe_child(
installed
.as_ref()
.map(|installed| installed_badge(installed.source, 11.).into_element()),
)
.maybe_child(
installed
.as_ref()
.filter(|_| duplicated)
.map(|installed| activity_badge(installed.enabled).into_element()),
)
.child(version_button(
installed,
v.name,
provider,
project_id,
version_id,
cluster_id,
dispatch,
installing,
))
}
/// Install, or remove the version that is already there.
///
/// A bundle that names this version without the cluster having linked anything
/// yet leaves nothing to press: there is no artifact to remove, and installing
/// it by hand would make a copy the bundle does not own.
#[allow(clippy::too_many_arguments)]
fn version_button(
installed: Option<InstalledVersion>,
version_name: String,
provider: ProviderId,
project_id: String,
version_id: String,
cluster_id: i64,
dispatch: Actions,
busy: bool,
) -> impl IntoElement {
let Some(installed) = installed else {
return Button::new()
.secondary()
.small()
.enabled(!busy)
.on_press(move |_| {
dispatch.install_package(
cluster_id,
provider,
project_id.clone(),
version_id.clone(),
);
})
.text("Install");
};
let Some(hash) = installed.hash else {
return Button::new().secondary().small().enabled(false).text("Install");
};
Button::new()
.danger()
.small()
.enabled(!busy)
.on_press(move |_| {
dispatch.remove_package_version(
cluster_id,
provider,
project_id.clone(),
hash.clone(),
version_name.clone(),
);
})
.text("Remove")
}
fn release_badge(release_type: ReleaseType) -> impl IntoElement {

View file

@ -48,7 +48,23 @@ pub(super) fn sidebar(
.overflow(Overflow::Clip)
.background(PANEL_BG)
.border(border_all_color(1., colors::component_border()))
.child(PackageBanner::new(project.icon_url.clone(), 110.))
// The badge rides on the artwork here for the same reason it
// does on a listing card: it belongs to the package, and it
// used to sit in a strip of its own between the card and the
// install button.
.child(
rect()
.width(Size::fill())
.overflow(Overflow::Clip)
.child(PackageBanner::new(project.icon_url.clone(), 110.))
.maybe_child(installed.as_ref().map(|installed| {
rect()
.position(Position::new_absolute().top(8.).left(8.))
.layer(Layer::Relative(7))
.child(installed_badge_overlay(installed.source))
.into_element()
})),
)
.child(
rect()
.vertical()
@ -100,11 +116,6 @@ pub(super) fn sidebar(
),
),
)
.maybe_child(
installed
.as_ref()
.map(|installed| installed_badge(installed.source, 12.).into_element()),
)
.child(
Button::new()
.primary()

View file

@ -489,6 +489,7 @@ mod tests {
display_name: None,
display_version: None,
provider: None,
published_at: None,
}
}

View file

@ -12,7 +12,7 @@ use crate::components::{
use crate::hooks::{ScreenshotAction, query_is_loading, try_cluster_screenshots, use_cluster_screenshots, use_dispatch, use_screenshot_action, use_view_state};
use crate::layout::cluster_content;
use crate::theme::colors;
use crate::ui::{border_all_color, fmt_date};
use crate::ui::{border_all_color, flow_grid, fmt_date, grid_columns_for_width};
use crate::utils::{format_res, format_size};
use super::cluster_not_found;
@ -114,8 +114,8 @@ impl Component for ClusterScreenshots {
match *view_mode.read() {
ViewLayout::Grid => {
let cols = grid_columns_for_width(*grid_width.read());
grid(items, cols, grid_width).into_element()
let cols = grid_columns_for_width(*grid_width.read(), MAX_COL_W, GRID_GAP);
flow_grid(items, cols, grid_width, GRID_GAP)
}
ViewLayout::List => rect()
.vertical()
@ -300,50 +300,6 @@ fn toolbar_row(
.into_element()
}
fn grid_columns_for_width(width: f32) -> usize {
if width <= 0. {
return 1;
}
(((width + GRID_GAP) / (MAX_COL_W + GRID_GAP)).ceil() as usize).max(1)
}
fn grid(items: Vec<Element>, cols: usize, mut width: State<f32>) -> impl IntoElement {
let cols = cols.max(1);
let mut root = rect().vertical().width(Size::fill()).spacing(GRID_GAP);
let mut iter = items.into_iter();
let mut remaining = true;
while remaining {
let mut row = rect()
.horizontal()
.width(Size::fill())
.spacing(GRID_GAP)
.content(Content::Flex);
let mut filled = 0;
for _ in 0..cols {
if let Some(card) = iter.next() {
row = row.child(card);
filled += 1;
} else {
row = row.child(rect().width(Size::flex(1.0)));
}
}
if filled == 0 {
break;
}
remaining = filled == cols;
root = root.child(row.into_element());
}
root.on_sized(move |event: Event<SizedEventData>| {
let w = event.data().area.width();
if (w - *width.peek()).abs() > 0.5 {
*width.write() = w;
}
})
.into_element()
}
fn empty_state(loading: bool) -> impl IntoElement {
rect()
.width(Size::fill())

View file

@ -0,0 +1,210 @@
//! Which copy wins when a cluster holds several versions of one package.
//!
//! Holding more than one is a defect the launcher does not aim for, but nothing
//! in the schema forbids it: `cluster_artifacts` is unique on `(cluster_id,
//! hash)`, so two versions are two perfectly legal rows. Until that changes, a
//! cluster can reach a state where every copy is enabled and the game is handed
//! three jars of the same mod — which for mods is a classloader conflict, not a
//! preference.
//!
//! The rule here is that the newest copy is the live one and the rest are
//! switched off. Nothing is unlinked: the older versions stay installed, stay
//! visible in the package manager, and can be switched back on one at a time.
//! `enabled` is also what materialization already reads, so enforcing it here
//! needs no special case at launch.
use std::collections::HashMap;
use oneclient_common::domain::ProviderId;
use crate::ctx::ContentCtx;
use crate::error::ContentResult;
use crate::packages::store::PackageStore;
use crate::packages::types::LinkedArtifactInfo;
/// One copy of a package, reduced to what picking a winner needs.
struct Copy {
hash: String,
enabled: bool,
published_at: Option<String>,
}
/// Ensures each project in the cluster has exactly one enabled copy: the newest.
///
/// A no-op on a cluster with no duplicates, which is every healthy one, so it is
/// cheap enough to run before a launch and after an install.
#[tracing::instrument(level = "debug", skip(ctx))]
pub async fn reconcile_duplicate_activity(
cluster_id: i64,
ctx: &ContentCtx,
) -> ContentResult<()> {
let linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?;
for ((provider, project_id), copies) in group_duplicates(&linked) {
let Some(winner) = newest(&copies) else {
continue;
};
for copy in &copies {
let should_be_enabled = copy.hash == winner;
if copy.enabled == should_be_enabled {
continue;
}
tracing::info!(
cluster_id,
?provider,
project_id,
hash = %copy.hash,
enabled = should_be_enabled,
"resolving duplicate versions of a package to the newest one"
);
PackageStore::set_artifact_enabled_to(
cluster_id,
&copy.hash,
should_be_enabled,
ctx,
)
.await?;
}
}
Ok(())
}
/// Groups the cluster's content by project, keeping only the projects it has
/// more than one copy of.
///
/// Local files and a bundle's external files have no project to group on and
/// are left out; two of those are two different packages as far as anything
/// here can tell.
fn group_duplicates(
linked: &[LinkedArtifactInfo],
) -> Vec<((ProviderId, String), Vec<Copy>)> {
let mut by_project: HashMap<(ProviderId, String), Vec<Copy>> = HashMap::new();
for info in linked {
let (Some(provider), Some(project_id)) = (info.provider, info.project_id.clone()) else {
continue;
};
by_project
.entry((provider, project_id))
.or_default()
.push(Copy {
hash: info.hash.clone(),
enabled: info.enabled,
published_at: info.published_at.clone(),
});
}
by_project.retain(|_, copies| copies.len() > 1);
by_project.into_iter().collect()
}
/// The hash of the newest copy.
///
/// `published_at` is written as RFC 3339 by the download path, so the strings
/// order the same way the timestamps do. A copy the provider gave no date for
/// loses to any copy that has one — it is usually an older row written before
/// the field was recorded — and an all-undated group falls back to the first,
/// which at least makes the choice stable across runs rather than arbitrary.
fn newest(copies: &[Copy]) -> Option<String> {
copies
.iter()
.max_by(|a, b| a.published_at.cmp(&b.published_at))
.map(|copy| copy.hash.clone())
}
#[cfg(test)]
mod tests {
use super::*;
use oneclient_common::domain::ContentType;
fn info(
project_id: Option<&str>,
hash: &str,
enabled: bool,
published_at: Option<&str>,
) -> LinkedArtifactInfo {
LinkedArtifactInfo {
hash: hash.into(),
cluster_file_name: format!("{hash}.jar"),
enabled,
content_type: ContentType::Mod,
file_name: format!("{hash}.jar"),
project_id: project_id.map(Into::into),
version_id: Some(format!("v-{hash}")),
display_name: None,
display_version: None,
provider: project_id.map(|_| ProviderId::Modrinth),
published_at: published_at.map(Into::into),
}
}
fn copies(specs: &[(&str, bool, Option<&str>)]) -> Vec<Copy> {
specs
.iter()
.map(|(hash, enabled, published)| Copy {
hash: (*hash).into(),
enabled: *enabled,
published_at: published.map(Into::into),
})
.collect()
}
#[test]
fn the_newest_publication_wins() {
let picked = newest(&copies(&[
("old", true, Some("2026-01-01T00:00:00Z")),
("new", false, Some("2026-06-01T00:00:00Z")),
("middle", false, Some("2026-03-01T00:00:00Z")),
]));
assert_eq!(picked.as_deref(), Some("new"), "being enabled does not win it");
}
#[test]
fn a_dated_copy_beats_an_undated_one() {
let picked = newest(&copies(&[
("undated", true, None),
("dated", false, Some("2026-01-01T00:00:00Z")),
]));
assert_eq!(picked.as_deref(), Some("dated"));
}
#[test]
fn an_undated_group_still_picks_one() {
let picked = newest(&copies(&[("a", false, None), ("b", false, None)]));
assert!(picked.is_some(), "a group with no dates must not go unresolved");
}
#[test]
fn only_projects_with_several_copies_are_reconciled() {
let groups = group_duplicates(&[
info(Some("sodium"), "s1", true, None),
info(Some("sodium"), "s2", true, None),
info(Some("lithium"), "l1", true, None),
]);
assert_eq!(groups.len(), 1, "a single copy is nothing to resolve");
assert_eq!(groups[0].0.1, "sodium");
assert_eq!(groups[0].1.len(), 2);
}
#[test]
fn content_with_no_project_is_left_alone() {
let groups = group_duplicates(&[
info(None, "local-1", true, None),
info(None, "local-2", true, None),
]);
assert!(
groups.is_empty(),
"two local files are two packages, not two copies of one"
);
}
}

View file

@ -1,3 +1,4 @@
pub mod activity;
pub mod dependencies;
pub mod error;
pub mod metadata_cache;
@ -16,6 +17,7 @@ pub use metadata_cache::{
CachedPackageMeta, cached_project_detail, fetch_package_meta, get_version_cached,
read_cached_package_meta,
};
pub use activity::reconcile_duplicate_activity;
pub use dependencies::{
DependencyResolution, ResolvedDependency, resolve_required, resolves_dependencies,
};

View file

@ -424,6 +424,11 @@ struct ModrinthProject {
#[derive(Deserialize)]
struct ModrinthGalleryItem {
url: String,
/// The image as it was uploaded. `url` is Modrinth's own re-encoded copy,
/// small enough to look soft once it is blown up to fill a gallery tile, so
/// this is preferred where the API hands it out.
#[serde(default)]
raw_url: Option<String>,
#[serde(default)]
title: Option<String>,
}
@ -514,7 +519,10 @@ impl ModrinthProject {
.gallery
.into_iter()
.map(|g| GalleryImage {
url: g.url,
url: g
.raw_url
.filter(|url| !url.is_empty())
.unwrap_or(g.url),
title: g.title.filter(|t| !t.is_empty()),
})
.collect(),

View file

@ -175,6 +175,7 @@ impl PackageStore {
provider: release
.as_ref()
.and_then(|r| ProviderId::from_repr(r.provider as u8)),
published_at: release.as_ref().and_then(|r| r.published_at.clone()),
});
}
@ -199,6 +200,30 @@ impl PackageStore {
cluster_id: i64,
hash: &str,
ctx: &ContentCtx,
) -> ContentResult<bool> {
Self::write_artifact_enabled(cluster_id, hash, None, ctx).await
}
/// Puts an artifact into a known enabled state rather than flipping it.
///
/// Returns the state it ended in, which is the requested one unless the
/// link was already there.
#[tracing::instrument(level = "debug", skip(ctx))]
pub async fn set_artifact_enabled_to(
cluster_id: i64,
hash: &str,
enabled: bool,
ctx: &ContentCtx,
) -> ContentResult<bool> {
Self::write_artifact_enabled(cluster_id, hash, Some(enabled), ctx).await
}
/// `target` of `None` means "the opposite of whatever it is now".
async fn write_artifact_enabled(
cluster_id: i64,
hash: &str,
target: Option<bool>,
ctx: &ContentCtx,
) -> ContentResult<bool> {
let cluster = Self::get_cluster(cluster_id, ctx).await?;
let artifact = artifact_dao::get_artifact_by_hash(&ctx.db, hash)
@ -214,7 +239,7 @@ impl PackageStore {
reason: format!("unknown content type {}", artifact.content_type),
})?;
let enabled = link.enabled == 0;
let enabled = target.unwrap_or(link.enabled == 0);
let file_name = link
.cluster_file_name
.trim_end_matches(".disabled")

View file

@ -227,4 +227,7 @@ pub struct LinkedArtifactInfo {
pub display_name: Option<String>,
pub display_version: Option<String>,
pub provider: Option<ProviderId>,
/// When the provider published this version, RFC 3339. Ordering two copies
/// of one project by recency is the only thing that reads it.
pub published_at: Option<String>,
}

View file

@ -375,6 +375,20 @@ pub async fn apply_browser_package_update(
.await?
.map(|link| link.enabled != 0);
// Everything else of this project goes, not just the artifact the check
// happened to record. A cluster that already held two versions would
// otherwise come out of an update holding two again, and the recorded hash
// can itself be stale if the user installed a version by hand since the
// check ran.
unlink_other_versions(
update.cluster_id,
update.provider,
&update.project_id,
&installed.hash,
ctx,
)
.await?;
// Nothing moved: the provider handed back the file that is already linked.
// Leave the link, and its enabled flag, exactly as they were.
if installed.hash == update.hash {
@ -382,8 +396,6 @@ pub async fn apply_browser_package_update(
return Ok(installed.hash);
}
unlink_superseded(update.cluster_id, &update.hash, ctx).await?;
// A package the user had switched off stays off across an update; being out
// of date is not a reason to turn it back on. The fresh link starts enabled,
// so this is a toggle rather than a set.
@ -396,6 +408,53 @@ pub async fn apply_browser_package_update(
Ok(installed.hash)
}
/// Drops every other version of `project_id` the cluster holds.
///
/// An update is a replacement, so what it leaves behind is exactly one artifact
/// per project. Bundle-owned copies are stepped over rather than removed: this
/// flow refuses to touch them at all, and unlinking one here would have the next
/// bundle sync put it straight back.
#[tracing::instrument(level = "debug", skip(ctx))]
async fn unlink_other_versions(
cluster_id: i64,
provider: ProviderId,
project_id: &str,
keep_hash: &str,
ctx: &ContentCtx,
) -> ContentResult<()> {
let others = artifact_dao::list_cluster_artifacts_for_project(
&ctx.db,
cluster_id,
provider as i64,
project_id,
keep_hash,
)
.await?;
for link in others {
if bundle_dao::get_bundle_tracked(&ctx.db, cluster_id, &link.hash)
.await?
.is_some()
{
tracing::debug!(
hash = %link.hash,
"leaving a bundle-owned version of the project in place"
);
continue;
}
tracing::info!(
cluster_id,
project_id,
hash = %link.hash,
"dropping a superseded version of the project"
);
unlink_superseded(cluster_id, &link.hash, ctx).await?;
}
Ok(())
}
/// Drops the artifact an applied update replaced.
///
/// Deliberately not `bundles::remove_artifact_from_cluster`: that one also
@ -446,6 +505,7 @@ mod tests {
display_name: None,
display_version: None,
provider,
published_at: None,
}
}

View file

@ -58,6 +58,21 @@ pub async fn materialize_content(
import_manual_content(services, cluster, game_dir).await;
// Before the folder is built, not after: a cluster holding several versions
// of one package would otherwise hand the game every enabled copy, and for
// mods that is a classloader conflict rather than a choice. Resolving to the
// newest here means the rest are simply not desired.
if let Err(err) = oneclient_content::packages::reconcile_duplicate_activity(
cluster.id,
&services.content(),
)
.await
{
// A launch is not worth blocking over this. The duplicates were already
// there and the game still gets a folder, just an untidy one.
tracing::warn!(cluster_id = cluster.id, %err, "failed to resolve duplicate package versions");
}
let desired = desired_content(services, cluster).await?;
let desired_paths: HashSet<String> = desired.iter().map(Desired::relative_path).collect();

View file

@ -0,0 +1,62 @@
{
"db_name": "SQLite",
"query": "\n\t\tSELECT DISTINCT ca.cluster_id, ca.hash, ca.cluster_file_name, ca.enabled\n\t\tFROM cluster_artifacts ca\n\t\tJOIN provider_releases pr ON pr.hash = ca.hash\n\t\tWHERE ca.cluster_id = ?\n\t\t\tAND pr.provider = ?\n\t\t\tAND pr.project_id = ?\n\t\t\tAND ca.hash <> ?\n\t\t",
"describe": {
"columns": [
{
"name": "cluster_id",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "cluster_artifacts",
"name": "cluster_id"
}
}
},
{
"name": "hash",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "cluster_artifacts",
"name": "hash"
}
}
},
{
"name": "cluster_file_name",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "cluster_artifacts",
"name": "cluster_file_name"
}
}
},
{
"name": "enabled",
"ordinal": 3,
"type_info": "Integer",
"origin": {
"Table": {
"table": "cluster_artifacts",
"name": "enabled"
}
}
}
],
"parameters": {
"Right": 4
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "bbbc24e8d4f13a39ebce8e8598f4adbc864909984313ea1c065ba226465b23ca"
}

View file

@ -211,6 +211,39 @@ pub async fn link_cluster_artifact(
.await
}
/// Every artifact the cluster has from the same project as `exclude_hash`,
/// excluding that one.
///
/// `provider_releases` is keyed by version, so one artifact can join several
/// rows of it. Without `DISTINCT` a package would be reported once per release
/// row and a caller unlinking the results would work from an inflated list.
pub async fn list_cluster_artifacts_for_project(
pool: &SqlitePool,
cluster_id: i64,
provider: i64,
project_id: &str,
exclude_hash: &str,
) -> Result<Vec<ClusterArtifactRow>, sqlx::Error> {
sqlx::query_as!(
ClusterArtifactRow,
r#"
SELECT DISTINCT ca.cluster_id, ca.hash, ca.cluster_file_name, ca.enabled
FROM cluster_artifacts ca
JOIN provider_releases pr ON pr.hash = ca.hash
WHERE ca.cluster_id = ?
AND pr.provider = ?
AND pr.project_id = ?
AND ca.hash <> ?
"#,
cluster_id,
provider,
project_id,
exclude_hash
)
.fetch_all(pool)
.await
}
pub async fn is_cluster_linked(
pool: &SqlitePool,
cluster_id: i64,