diff --git a/packages/oneclient_app/src/components/package_update_popup.rs b/packages/oneclient_app/src/components/package_update_popup.rs index 7dfc4524..07cea084 100644 --- a/packages/oneclient_app/src/components/package_update_popup.rs +++ b/packages/oneclient_app/src/components/package_update_popup.rs @@ -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 { + 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::::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>, ) -> 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>, ) -> 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>, ) -> 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>, key: DiffKey, } impl UpdateRow { - fn new(name: String, update: BrowserPackageUpdate, dispatch: crate::Actions) -> Self { + fn new( + name: String, + update: BrowserPackageUpdate, + choice: RowChoice, + choices: State>, + ) -> 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); + } + }), ) } } diff --git a/packages/oneclient_app/src/hooks/actions.rs b/packages/oneclient_app/src/hooks/actions.rs index 6f4bd19d..d10a837c 100644 --- a/packages/oneclient_app/src/hooks/actions.rs +++ b/packages/oneclient_app/src/hooks/actions.rs @@ -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, + hash: impl Into, + display_name: impl Into, + ) { + 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, diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs index ac8ad34e..16068351 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -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, diff --git a/packages/oneclient_app/src/hooks/queries/mod.rs b/packages/oneclient_app/src/hooks/queries/mod.rs index aae4f370..d1362b34 100644 --- a/packages/oneclient_app/src/hooks/queries/mod.rs +++ b/packages/oneclient_app/src/hooks/queries/mod.rs @@ -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; diff --git a/packages/oneclient_app/src/hooks/queries/mutations.rs b/packages/oneclient_app/src/hooks/queries/mutations.rs index 71e943d2..b70313d9 100644 --- a/packages/oneclient_app/src/hooks/queries/mutations.rs +++ b/packages/oneclient_app/src/hooks/queries/mutations.rs @@ -24,6 +24,16 @@ pub async fn invalidate_cluster_queries() { QueriesStorage::::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::::try_invalidate_all().await; +} + pub async fn invalidate_profile_queries() { QueriesStorage::::try_invalidate_all().await; QueriesStorage::::try_invalidate_all().await; diff --git a/packages/oneclient_app/src/hooks/queries/packages.rs b/packages/oneclient_app/src/hooks/queries/packages.rs index 4b8ceabe..7b826c7e 100644 --- a/packages/oneclient_app/src/hooks/queries/packages.rs +++ b/packages/oneclient_app/src/hooks/queries/packages.rs @@ -237,6 +237,22 @@ pub fn use_package_versions( game_version: Option, loader: Option, page: usize, +) -> UseQuery { + 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, + loader: Option, + page: usize, ) -> UseQuery { use_query( Query::new( @@ -249,6 +265,7 @@ pub fn use_package_versions( }, PackageVersionsQuery, ) + .enable(enabled) .stale_time(VERSIONS_STALE), ) } diff --git a/packages/oneclient_app/src/ui.rs b/packages/oneclient_app/src/ui.rs index 4c0d4093..e0ce5dec 100644 --- a/packages/oneclient_app/src/ui.rs +++ b/packages/oneclient_app/src/ui.rs @@ -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, cols: usize, mut width: State, 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| { + 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, diff --git a/packages/oneclient_app/src/view/app/browser/index/cards.rs b/packages/oneclient_app/src/view/app/browser/index/cards.rs index 7f6588ec..8bb72400 100644 --- a/packages/oneclient_app/src/view/app/browser/index/cards.rs +++ b/packages/oneclient_app/src/view/app/browser/index/cards.rs @@ -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( diff --git a/packages/oneclient_app/src/view/app/browser/mod.rs b/packages/oneclient_app/src/view/app/browser/mod.rs index 380ff905..50170f7e 100644 --- a/packages/oneclient_app/src/view/app/browser/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/mod.rs @@ -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, + /// 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, + /// Every version in the cluster the provider recorded one for, in the order + /// the cluster reports them. + pub versions: Vec, } 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, 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) -> Vec { + 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); + } +} diff --git a/packages/oneclient_app/src/view/app/browser/package/gallery.rs b/packages/oneclient_app/src/view/app/browser/package/gallery.rs new file mode 100644 index 00000000..a0dc3b34 --- /dev/null +++ b/packages/oneclient_app/src/view/app/browser/package/gallery.rs @@ -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) -> impl IntoElement { + Gallery { images } +} + +#[derive(PartialEq)] +struct Gallery { + images: Vec, +} + +impl Component for Gallery { + fn render(&self) -> impl IntoElement { + let grid_width = use_state(|| 0f32); + let mut viewing = use_state(|| None::); + + let tiles: Vec = 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, + 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| 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>>, +) -> 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>>, +) -> 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() +} diff --git a/packages/oneclient_app/src/view/app/browser/package/mod.rs b/packages/oneclient_app/src/view/app/browser/package/mod.rs index e7d1866f..62212e47 100644 --- a/packages/oneclient_app/src/view/app/browser/package/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/package/mod.rs @@ -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); diff --git a/packages/oneclient_app/src/view/app/browser/package/panels.rs b/packages/oneclient_app/src/view/app/browser/package/panels.rs index 6273d13b..7172be03 100644 --- a/packages/oneclient_app/src/view/app/browser/package/panels.rs +++ b/packages/oneclient_app/src/view/app/browser/package/panels.rs @@ -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) -> 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, @@ -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, + installed: Option, + // 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, + 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 { diff --git a/packages/oneclient_app/src/view/app/browser/package/sidebar.rs b/packages/oneclient_app/src/view/app/browser/package/sidebar.rs index f73ea5bf..d3ac2ec1 100644 --- a/packages/oneclient_app/src/view/app/browser/package/sidebar.rs +++ b/packages/oneclient_app/src/view/app/browser/package/sidebar.rs @@ -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() diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs index 06deeb34..874516b0 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs @@ -489,6 +489,7 @@ mod tests { display_name: None, display_version: None, provider: None, + published_at: None, } } diff --git a/packages/oneclient_app/src/view/app/cluster/screenshots.rs b/packages/oneclient_app/src/view/app/cluster/screenshots.rs index 47db40c3..77c6b538 100644 --- a/packages/oneclient_app/src/view/app/cluster/screenshots.rs +++ b/packages/oneclient_app/src/view/app/cluster/screenshots.rs @@ -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, cols: usize, mut width: State) -> 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| { - 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()) diff --git a/packages/oneclient_content/src/packages/activity.rs b/packages/oneclient_content/src/packages/activity.rs new file mode 100644 index 00000000..550659bc --- /dev/null +++ b/packages/oneclient_content/src/packages/activity.rs @@ -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, +} + +/// 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)> { + let mut by_project: HashMap<(ProviderId, String), Vec> = 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 { + 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 { + 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" + ); + } +} diff --git a/packages/oneclient_content/src/packages/mod.rs b/packages/oneclient_content/src/packages/mod.rs index e33fd556..508930f6 100644 --- a/packages/oneclient_content/src/packages/mod.rs +++ b/packages/oneclient_content/src/packages/mod.rs @@ -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, }; diff --git a/packages/oneclient_content/src/packages/provider/modrinth.rs b/packages/oneclient_content/src/packages/provider/modrinth.rs index 3c851fc8..dfe04fa2 100644 --- a/packages/oneclient_content/src/packages/provider/modrinth.rs +++ b/packages/oneclient_content/src/packages/provider/modrinth.rs @@ -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, #[serde(default)] title: Option, } @@ -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(), diff --git a/packages/oneclient_content/src/packages/store/mod.rs b/packages/oneclient_content/src/packages/store/mod.rs index 4cec1256..f32081b6 100644 --- a/packages/oneclient_content/src/packages/store/mod.rs +++ b/packages/oneclient_content/src/packages/store/mod.rs @@ -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 { + 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 { + 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, + ctx: &ContentCtx, ) -> ContentResult { 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") diff --git a/packages/oneclient_content/src/packages/types.rs b/packages/oneclient_content/src/packages/types.rs index 33b47558..811e9e57 100644 --- a/packages/oneclient_content/src/packages/types.rs +++ b/packages/oneclient_content/src/packages/types.rs @@ -227,4 +227,7 @@ pub struct LinkedArtifactInfo { pub display_name: Option, pub display_version: Option, pub provider: Option, + /// 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, } diff --git a/packages/oneclient_content/src/packages/updates.rs b/packages/oneclient_content/src/packages/updates.rs index f7f0298a..d13f2960 100644 --- a/packages/oneclient_content/src/packages/updates.rs +++ b/packages/oneclient_content/src/packages/updates.rs @@ -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, } } diff --git a/packages/oneclient_core/src/game/shared_dir.rs b/packages/oneclient_core/src/game/shared_dir.rs index 15064533..58ff3dc0 100644 --- a/packages/oneclient_core/src/game/shared_dir.rs +++ b/packages/oneclient_core/src/game/shared_dir.rs @@ -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 = desired.iter().map(Desired::relative_path).collect(); diff --git a/packages/oneclient_db/.sqlx/query-bbbc24e8d4f13a39ebce8e8598f4adbc864909984313ea1c065ba226465b23ca.json b/packages/oneclient_db/.sqlx/query-bbbc24e8d4f13a39ebce8e8598f4adbc864909984313ea1c065ba226465b23ca.json new file mode 100644 index 00000000..b79fa719 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-bbbc24e8d4f13a39ebce8e8598f4adbc864909984313ea1c065ba226465b23ca.json @@ -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" +} diff --git a/packages/oneclient_db/src/dao/artifact.rs b/packages/oneclient_db/src/dao/artifact.rs index 8760b319..c707a507 100644 --- a/packages/oneclient_db/src/dao/artifact.rs +++ b/packages/oneclient_db/src/dao/artifact.rs @@ -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, 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,