feat(ui): browser package better multi-version management
This commit is contained in:
parent
fb90eb7bb8
commit
4c6de0d2c4
24 changed files with 1535 additions and 271 deletions
|
|
@ -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);
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
348
packages/oneclient_app/src/view/app/browser/package/gallery.rs
Normal file
348
packages/oneclient_app/src/view/app/browser/package/gallery.rs
Normal 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()
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -489,6 +489,7 @@ mod tests {
|
|||
display_name: None,
|
||||
display_version: None,
|
||||
provider: None,
|
||||
published_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
210
packages/oneclient_content/src/packages/activity.rs
Normal file
210
packages/oneclient_content/src/packages/activity.rs
Normal 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,
|
||||
©.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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue