diff --git a/packages/oneclient_app/src/components/navbar/app_navbar.rs b/packages/oneclient_app/src/components/navbar/app_navbar.rs index 4cafc524..3a1145eb 100644 --- a/packages/oneclient_app/src/components/navbar/app_navbar.rs +++ b/packages/oneclient_app/src/components/navbar/app_navbar.rs @@ -7,8 +7,12 @@ use freya::{ use crate::{ Route, components::{Avatar, Icon, IconType}, - hooks::{try_default_account, use_current_account, use_dispatch, use_notifications_snapshot}, + hooks::{ + settled_or_loading, try_default_account, use_active_cluster_id, use_clusters, + use_current_account, use_dispatch, use_notifications_snapshot, + }, theme, + utils::sort_clusters_for_home, }; const NAVBAR_INTRO_MS: u64 = 460; @@ -77,13 +81,14 @@ fn navbar_logo() -> impl IntoElement { fn navbar_center() -> impl IntoElement { let route = use_route::(); + let browse_target = browse_target(); rect() .horizontal() .width(Size::flex(1.0)) .main_align(Alignment::Center) .cross_align(Alignment::Center) - .spacing(64.) + .spacing(48.) .child(NavLink { active: route == Route::Home {}, target: Route::Home {}, @@ -94,6 +99,17 @@ fn navbar_center() -> impl IntoElement { target: Route::Clusters {}, nav_label: "Versions", }) + .child(NavLink { + active: matches!( + route, + Route::Browser { + pick_cluster: true, + .. + } + ), + target: browse_target, + nav_label: "Browse", + }) .child(NavLink { active: route == Route::Stats {}, target: Route::Stats {}, @@ -101,6 +117,27 @@ fn navbar_center() -> impl IntoElement { }) } +/// Browsing always happens for some cluster, so the navbar picks the one the +/// shell has active and falls back to the most recently played one. With no +/// clusters at all there is nothing to browse for, so it points at Versions. +fn browse_target() -> Route { + let clusters = settled_or_loading(&use_clusters()).unwrap_or_default(); + let active = *use_active_cluster_id().read(); + + let cluster_id = active + .filter(|id| clusters.iter().any(|cluster| cluster.id == *id)) + .or_else(|| sort_clusters_for_home(clusters).first().map(|c| c.id)); + + match cluster_id { + Some(cluster_id) => Route::Browser { + cluster_id, + package_type: "mod".to_string(), + pick_cluster: true, + }, + None => Route::Clusters {}, + } +} + #[derive(PartialEq)] struct NavLink { active: bool, diff --git a/packages/oneclient_app/src/components/pagination.rs b/packages/oneclient_app/src/components/pagination.rs index bb3913b4..29dd8634 100644 --- a/packages/oneclient_app/src/components/pagination.rs +++ b/packages/oneclient_app/src/components/pagination.rs @@ -18,6 +18,7 @@ pub struct Pagination { page: State, total_pages: usize, window: usize, + enabled: bool, } impl Pagination { @@ -26,6 +27,7 @@ impl Pagination { page, total_pages, window: 5, + enabled: true, } } @@ -34,17 +36,26 @@ impl Pagination { self.window = window.max(1); self } + + /// Greys out every control without unmounting the pager, for callers that + /// are still loading the page the user just asked for. + pub fn enabled(mut self, enabled: bool) -> Self { + self.enabled = enabled; + self + } } impl Component for Pagination { fn render(&self) -> impl IntoElement { let page = self.page; let total_pages = self.total_pages; + let interactive = self.enabled; let current = *page.read(); let last = total_pages.saturating_sub(1); - let icon_btn = move |target: usize, enabled: bool, icon: IconType| { + let icon_btn = move |target: usize, available: bool, icon: IconType| { let mut page = page; + let enabled = available && interactive; Button::new() .secondary() .icon() @@ -85,6 +96,7 @@ impl Component for Pagination { .width(Size::px(32.)) .height(Size::px(32.)) .padding(Gaps::new_symmetric(6., 0.)) + .enabled(interactive) .on_press(move |_| page.set(target)) .child( label() diff --git a/packages/oneclient_app/src/hooks/actions.rs b/packages/oneclient_app/src/hooks/actions.rs index f80af977..5f1ada78 100644 --- a/packages/oneclient_app/src/hooks/actions.rs +++ b/packages/oneclient_app/src/hooks/actions.rs @@ -661,28 +661,73 @@ impl Actions { version_id: impl Into, ) { let (project_id, version_id) = (project_id.into(), version_id.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 Ok(state) = launcher::state() else { return }; - let events = state.services.events.clone(); - match crate::install::install_package( + 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 install = crate::install::install_package( &state, provider, &project_id, &version_id, cluster_id, ) - .await - { - Ok(name) => { - events.notify("Installed").body(format!("Added {name}")).send(); - events.signal(oneclient_events::Signal::ClustersChanged); - } - Err(err) => events - .notify("Install failed") - .body(err.to_string()) - .error() - .send(), + .await; + + // The download already raised a progress notification; the outcome + // replaces it in place rather than arriving as a second one. + let spec = match &install.result { + Ok(name) => NotificationSpec { + title: "Installed".to_string(), + body: format!("Added {name}"), + level: Level::Info, + icon: Some(IconType::Download01), + progress: None, + actions: Vec::new(), + }, + Err(err) => NotificationSpec { + title: "Install failed".to_string(), + body: err.to_string(), + level: Level::Error, + icon: None, + progress: None, + actions: Vec::new(), + }, + }; + + let session_id = install.session_id.unwrap_or_else(uuid::Uuid::nil); + actions.with_engine(|app| { + app.notifications + .finish_grouped_as_actions(&mut app.inbox, session_id, Some(spec)); + }); + + if install.result.is_ok() { + state + .services + .events + .signal(oneclient_events::Signal::ClustersChanged); } + + finish(&actions); }); } diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs index 277bd08e..f43120ec 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -50,7 +50,9 @@ pub use queries::{ }; use crate::notifications::NotificationSnapshot; -use crate::state::{AppChannel, GameState, LauncherInit, LoginProgress, SettingsState}; +use crate::state::{ + AppChannel, GameState, InstallState, LauncherInit, LoginProgress, SettingsState, +}; use freya::prelude::*; use freya::radio::use_radio; @@ -102,6 +104,10 @@ pub fn use_game_snapshot() -> GameState { use_radio(AppChannel::Game).read().game.clone() } +pub fn use_installs_snapshot() -> InstallState { + use_radio(AppChannel::Installs).read().installs.clone() +} + pub fn use_microsoft_login_status() -> Option { use_radio(AppChannel::MicrosoftLogin) .read() diff --git a/packages/oneclient_app/src/install.rs b/packages/oneclient_app/src/install.rs index 7360ba16..d66b0acb 100644 --- a/packages/oneclient_app/src/install.rs +++ b/packages/oneclient_app/src/install.rs @@ -152,20 +152,45 @@ pub async fn combined_cluster_update_spec( }) } +/// An install's outcome together with the progress session it ran under. +/// +/// The session is detached rather than finished so the caller can convert that +/// same notification into the "Installed" / "Install failed" result, instead of +/// leaving a finished progress card and raising a second notification beside it. +/// `session_id` is `None` when the install never got as far as downloading. +pub struct PackageInstall { + pub session_id: Option, + pub result: anyhow::Result, +} + pub async fn install_package( state: &Arc, provider: oneclient_content::packages::ProviderId, project_id: &str, version_id: &str, cluster_id: i64, -) -> anyhow::Result { - let provider_impl = state.services.packages.get(provider)?; - let project = provider_impl - .get_project(project_id, &state.services.content()) - .await?; - let version = provider_impl - .get_version(project_id, version_id, &state.services.content()) - .await?; +) -> PackageInstall { + let lookup = async { + let provider_impl = state.services.packages.get(provider)?; + let project = provider_impl + .get_project(project_id, &state.services.content()) + .await?; + let version = provider_impl + .get_version(project_id, version_id, &state.services.content()) + .await?; + anyhow::Ok((project, version)) + } + .await; + + let (project, version) = match lookup { + Ok(found) => found, + Err(err) => { + return PackageInstall { + session_id: None, + result: Err(err), + }; + } + }; let session = oneclient_events::GroupedProgressSession::start( &state.services.events, @@ -191,7 +216,9 @@ pub async fn install_package( .await; child.finish(); - session.finish(); - result?; - Ok(project.name) + + PackageInstall { + session_id: Some(session.detach()), + result: result.map(|_| project.name).map_err(anyhow::Error::from), + } } diff --git a/packages/oneclient_app/src/routes.rs b/packages/oneclient_app/src/routes.rs index ce354ce7..9dfbceb3 100644 --- a/packages/oneclient_app/src/routes.rs +++ b/packages/oneclient_app/src/routes.rs @@ -78,8 +78,8 @@ pub enum Route { ClusterSettings { cluster_id: i64 }, #[end_layout] - #[route("/app/browser/:cluster_id/:package_type")] - Browser { cluster_id: i64, package_type: String }, + #[route("/app/browser/:cluster_id/:package_type?:pick_cluster")] + Browser { cluster_id: i64, package_type: String, pick_cluster: bool }, #[route("/app/browser/:cluster_id/:package_type/package/:package_id")] BrowserPackage { cluster_id: i64, package_type: String, package_id: String }, #[route("/app/accounts")] diff --git a/packages/oneclient_app/src/state.rs b/packages/oneclient_app/src/state.rs index 11c865cc..2d511f0b 100644 --- a/packages/oneclient_app/src/state.rs +++ b/packages/oneclient_app/src/state.rs @@ -9,10 +9,11 @@ //! station only requires `'static`, this owns [`NotificationState`] outright //! rather than cloning a snapshot out of it on every event. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use freya::radio::RadioChannel; +use oneclient_common::domain::ProviderId; use oneclient_core::settings::LauncherSettings; use oneclient_events::LaunchStage; @@ -34,6 +35,8 @@ pub enum AppChannel { AccountSwitcher, /// Live progress of an in-flight Microsoft sign-in. MicrosoftLogin, + /// Packages currently being installed into a cluster. + Installs, } impl RadioChannel for AppChannel {} @@ -57,6 +60,31 @@ pub struct AppState { pub game: GameState, pub account_switcher_open: bool, pub microsoft_login: Option, + pub installs: InstallState, +} + +/// Package installs that are in flight, so the button that started one can stay +/// disabled until it lands โ€” and come back if it failed. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct InstallState { + pending: HashSet<(i64, ProviderId, String)>, +} + +impl InstallState { + pub fn begin(&mut self, cluster_id: i64, provider: ProviderId, project_id: String) { + self.pending.insert((cluster_id, provider, project_id)); + } + + pub fn finish(&mut self, cluster_id: i64, provider: ProviderId, project_id: &str) { + self.pending + .remove(&(cluster_id, provider, project_id.to_string())); + } + + #[must_use] + pub fn is_installing(&self, cluster_id: i64, provider: ProviderId, project_id: &str) -> bool { + self.pending + .contains(&(cluster_id, provider, project_id.to_string())) + } } #[derive(Clone, Debug, Default)] 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 097dc238..99f45d13 100644 --- a/packages/oneclient_app/src/view/app/browser/index/cards.rs +++ b/packages/oneclient_app/src/view/app/browser/index/cards.rs @@ -1,5 +1,7 @@ use super::*; +use std::collections::HashMap; + use freya::router::RouterContext; use oneclient_content::packages::ProviderId; use oneclient_content::packages::types::ProjectSummary; @@ -9,13 +11,26 @@ use crate::routes::Route; use crate::theme::colors; use crate::ui::border_all_color; +type InstalledMap = HashMap<(ProviderId, String), Installed>; + +fn installed_for(installed: &InstalledMap, item: &ProjectSummary) -> Option { + installed + .get(&(item.provider, item.id.clone())) + .map(|installed| installed.source) +} + pub(super) fn grid_row( row: Vec, cluster_id: i64, package_type: &str, + installed: &InstalledMap, ) -> impl IntoElement { let package_type = package_type.to_string(); let fill = GRID_COLUMNS - row.len(); + let installed: Vec> = row + .iter() + .map(|item| installed_for(installed, item)) + .collect(); rect() .horizontal() @@ -23,9 +38,14 @@ pub(super) fn grid_row( .height(Size::px(CARD_H)) .spacing(GRID_SPACING) .content(Content::Flex) - .children(row.into_iter().map(move |item| { - PackageCard::new(item, cluster_id, package_type.clone()).into_element() - })) + .children( + row.into_iter() + .zip(installed) + .map(move |(item, installed)| { + PackageCard::new(item, cluster_id, package_type.clone(), installed) + .into_element() + }), + ) .maybe(fill > 0, move |mut el| { for _ in 0..fill { el = el.child(rect().width(Size::flex(1.0))); @@ -48,14 +68,21 @@ struct PackageCard { item: ProjectSummary, cluster_id: i64, package_type: String, + installed: Option, } impl PackageCard { - fn new(item: ProjectSummary, cluster_id: i64, package_type: String) -> Self { + fn new( + item: ProjectSummary, + cluster_id: i64, + package_type: String, + installed: Option, + ) -> Self { Self { item, cluster_id, package_type, + installed, } } } @@ -151,7 +178,22 @@ impl Component for PackageCard { .color(colors::fg_secondary()), ), ) - .child(downloads_row(self.item.downloads)), + .child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .content(Content::Flex) + .child(downloads_row(self.item.downloads)) + .maybe_child(self.installed.map(|installed| { + rect() + .horizontal() + .width(Size::flex(1.0)) + .main_align(Alignment::End) + .child(installed_badge(installed, 10.)) + .into_element() + })), + ), ) } } @@ -160,8 +202,10 @@ pub(super) fn list_row( item: ProjectSummary, cluster_id: i64, package_type: &str, + installed: &InstalledMap, ) -> impl IntoElement { ListRow { + installed: installed_for(installed, &item), item, cluster_id, package_type: package_type.to_string(), @@ -173,6 +217,7 @@ struct ListRow { item: ProjectSummary, cluster_id: i64, package_type: String, + installed: Option, } impl Component for ListRow { @@ -253,6 +298,10 @@ 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)) } } diff --git a/packages/oneclient_app/src/view/app/browser/index/mod.rs b/packages/oneclient_app/src/view/app/browser/index/mod.rs index 83e5295a..d2553aa0 100644 --- a/packages/oneclient_app/src/view/app/browser/index/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/index/mod.rs @@ -4,24 +4,31 @@ use freya::animation::{ AnimNum, Ease, Function, OnChange, OnCreation, use_animation_with_dependencies, }; use freya::prelude::*; +use freya::router::RouterContext; +use oneclient_common::parse_mc_version; use oneclient_content::packages::types::ProjectSummary; use oneclient_content::packages::types::SearchSort; use oneclient_content::packages::{ContentType, ProviderId}; +use oneclient_core::VersionMetadata; +use oneclient_core::clusters::Cluster; use oneclient_core::settings::ViewLayout; use crate::components::{ - Icon, IconType, Pagination, ScrollArea, Segment, SegmentedControl, TextInput, + Dropdown, Icon, IconType, Pagination, ScrollArea, Segment, SegmentedControl, TextInput, }; use crate::hooks::{ - BROWSE_PAGE_SIZE, BrowserUiState, category_list, content_type_for_slug, search_items, - search_pending, search_total, use_browser_compat, use_browser_state_store, use_debounced, - use_package_categories, use_package_search, use_view_state, + BROWSE_PAGE_SIZE, BrowserUiState, bundles_with_status_items, category_list, + cluster_content_items, content_type_for_slug, pick_version_metadata, search_items, + search_pending, search_total, settled_or_loading, use_browser_compat, use_browser_state_store, + use_bundles_with_status, use_cluster_content, use_clusters, use_debounced, + use_package_categories, use_package_search, use_versions, use_view_state, versions_metadata, }; +use crate::routes::Route; use crate::theme::colors; use crate::hooks::use_cluster; -use super::{PackageBanner, Thumbnail}; -use crate::utils::abbreviate_number; +use super::{InstallSource, Installed, PackageBanner, Thumbnail, installed_badge, installed_map}; +use crate::utils::{abbreviate_number, sort_clusters_for_home}; mod cards; mod sidebar; @@ -57,6 +64,9 @@ fn encode_package_id(provider: ProviderId, id: &str) -> String { pub struct Browser { pub cluster_id: i64, pub package_type: String, + /// Entered from the navbar rather than from a cluster's content page, so + /// the cluster it browses for is picked here instead of being decided. + pub pick_cluster: bool, } impl Component for Browser { @@ -140,11 +150,29 @@ impl Component for Browser { let categories_query = use_package_categories(provider_id, content_type); let all_categories = category_list(&categories_query); + let installed = installed_map( + cluster_content_items(&use_cluster_content(cluster_id, content_type)), + &bundles_with_status_items(&use_bundles_with_status(cluster_id)), + ); + let packages = search_items(&search); let total = search_total(&search); let pending = search_pending(&search); let current_page = *page.read(); - let total_pages = total.div_ceil(BROWSE_PAGE_SIZE).max(1); + + // A page switch re-runs the search, which reports no total until it + // settles. Holding the last count keeps the pager in place โ€” disabled + // while the page loads โ€” instead of dropping out and back in. + let live_pages = (total > 0).then(|| total.div_ceil(BROWSE_PAGE_SIZE).max(1)); + let mut settled_pages = use_state(|| live_pages); + if !pending { + settled_pages.set_if_modified(live_pages); + } + let pages = if pending { + *settled_pages.read() + } else { + live_pages + }; let mode = *view_mode.read(); @@ -171,11 +199,11 @@ impl Component for Browser { packages.chunks(GRID_COLUMNS).map(|c| c.to_vec()).collect(); sa.lazy(rows.len(), CARD_H, GRID_SPACING, move |i| { - grid_row(rows[i].clone(), cluster_id, &pkg).into_element() + grid_row(rows[i].clone(), cluster_id, &pkg, &installed).into_element() }) } ViewLayout::List => sa.lazy(packages.len(), LIST_ROW_H, LIST_SPACING, move |i| { - list_row(packages[i].clone(), cluster_id, &pkg).into_element() + list_row(packages[i].clone(), cluster_id, &pkg, &installed).into_element() }), } } else if pending { @@ -205,6 +233,10 @@ impl Component for Browser { .child(page_title( &package_type, cluster.as_ref().map(|c| c.name.clone()), + self.pick_cluster.then(|| ClusterPicker { + cluster_id, + package_type: package_type.clone(), + }), )) .child(controls(provider, query, view_mode)) .child( @@ -233,13 +265,21 @@ impl Component for Browser { .child(results) .opacity(fade_opacity), ) - .maybe(total > 0, |el| el.child(Pagination::new(page, total_pages))), + .maybe_child(pages.map(|pages| { + Pagination::new(page, pages) + .enabled(!pending) + .into_element() + })), ), ) } } -fn page_title(package_type: &str, cluster_name: Option) -> impl IntoElement { +fn page_title( + package_type: &str, + cluster_name: Option, + picker: Option, +) -> impl IntoElement { rect() .vertical() .spacing(2.) @@ -250,12 +290,89 @@ fn page_title(package_type: &str, cluster_name: Option) -> impl IntoElem .font_weight(FontWeight::BOLD) .color(colors::fg_primary()), ) - .maybe_child(cluster_name.map(|name| { - label() - .text(format!("for {name}")) - .font_size(14.) - .color(colors::fg_secondary()) - })) + .maybe_child(match picker { + Some(picker) => Some(picker.into_element()), + // Without the picker the cluster is fixed, so it's just a subtitle. + None => cluster_name.map(|name| { + label() + .text(format!("for {name}")) + .font_size(14.) + .color(colors::fg_secondary()) + .into_element() + }), + }) +} + +/// The version's display name from the manifest ("Tricky Trials"), falling back +/// to the bare version while the manifest hasn't arrived or doesn't cover it. +fn version_name(metadata: &[VersionMetadata], cluster: &Cluster) -> String { + parse_mc_version(&cluster.mc_version) + .and_then(|parsed| { + pick_version_metadata( + metadata, + parsed.major, + parsed.key(), + Some(cluster.mc_loader), + ) + }) + .map(|m| m.name) + .unwrap_or_else(|| cluster.mc_version.clone()) +} + +/// Switches which cluster the browser installs into. The choice lives in the +/// route, so going back and forward keeps the cluster that was being browsed. +#[derive(PartialEq)] +struct ClusterPicker { + cluster_id: i64, + package_type: String, +} + +impl Component for ClusterPicker { + fn render(&self) -> impl IntoElement { + let clusters = + sort_clusters_for_home(settled_or_loading(&use_clusters()).unwrap_or_default()); + let metadata = versions_metadata(&use_versions()).unwrap_or_default(); + + let labels: Vec = clusters + .iter() + .map(|c| format!("{} ยท {}", c.name, version_name(&metadata, c))) + .collect(); + let ids: Vec = clusters.iter().map(|c| c.id).collect(); + + let selected = ids + .iter() + .position(|id| *id == self.cluster_id) + .and_then(|idx| labels.get(idx).cloned()) + .unwrap_or_else(|| "Select a version".to_string()); + + let package_type = self.package_type.clone(); + + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .margin(Gaps::new(4., 0., 0., 0.)) + .child( + label() + .text("for") + .font_size(14.) + .color(colors::fg_secondary()), + ) + .child( + Dropdown::new(selected, labels) + .width(Size::px(240.)) + .height(Size::px(28.)) + .on_select(move |idx: usize| { + if let Some(cluster_id) = ids.get(idx).copied() { + let _ = RouterContext::get().replace(Route::Browser { + cluster_id, + package_type: package_type.clone(), + pick_cluster: true, + }); + } + }), + ) + } } fn controls( diff --git a/packages/oneclient_app/src/view/app/browser/mod.rs b/packages/oneclient_app/src/view/app/browser/mod.rs index 5fadb63e..0fe5b8a9 100644 --- a/packages/oneclient_app/src/view/app/browser/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/mod.rs @@ -4,7 +4,11 @@ mod package; pub use index::Browser; pub use package::BrowserPackage; +use std::collections::HashMap; + use freya::prelude::*; +use oneclient_content::packages::ProviderId; +use oneclient_core::{BundleFileKind, BundleWithUpdateStatus, LinkedArtifactInfo}; use crate::components::{Icon, IconType}; use crate::hooks::{loaded_image, use_cached_image}; @@ -12,6 +16,118 @@ use crate::theme::colors; const BANNER_BG: Color = Color::from_rgb(21, 28, 34); +/// How a browsed package is already present in the cluster. +#[derive(Clone, Copy, PartialEq)] +pub(crate) enum InstallSource { + /// Installed on its own. + Manual, + /// Comes from one of the cluster's bundles. + Bundled, +} + +impl InstallSource { + fn label(self) -> &'static str { + match self { + Self::Manual => "Installed", + Self::Bundled => "Bundled", + } + } + + /// Bundled reads blue rather than green: the package is there, but the + /// cluster's bundle put it there, not the user. + fn color(self) -> Color { + match self { + Self::Manual => colors::success(), + Self::Bundled => colors::code_info(), + } + } +} + +/// A browsed package the cluster already has. +#[derive(Clone, PartialEq)] +pub(crate) struct Installed { + 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, +} + +impl Installed { + pub fn is_version(&self, version_id: &str) -> bool { + self.version_id.as_deref() == Some(version_id) + } +} + +/// Indexes what the cluster already has by the project it came from, so a +/// search result can tell at a glance whether it's in there and how it got +/// there. Local files have no project to match a search result against and +/// are left out, as are a bundle's external (non-provider) files. +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(); + + // 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. + for bundle in bundles { + for (file, _status) in &bundle.files { + if let BundleFileKind::Managed { + provider, + project_id, + version_id, + .. + } = &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()), + }); + } + } + } + + map +} + +pub(crate) fn installed_badge(installed: InstallSource, font_size: f32) -> impl IntoElement { + let color = installed.color(); + + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(4.) + .padding(Gaps::new_symmetric(2., 8.)) + .corner_radius(CornerRadius::new_all(999.)) + .background(color.with_a(38)) + .child( + Icon::new(IconType::CheckCircle) + .size(font_size) + .color(color), + ) + .child( + label() + .text(installed.label()) + .font_size(font_size) + .max_lines(1) + .color(color), + ) +} + #[derive(PartialEq)] pub(crate) struct Thumbnail { icon_url: Option, 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 95f91dfc..e7d1866f 100644 --- a/packages/oneclient_app/src/view/app/browser/package/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/package/mod.rs @@ -3,14 +3,16 @@ use oneclient_content::packages::{ContentType, ProviderId}; use crate::components::ScrollArea; use crate::hooks::{ - content_type_for_slug, project_detail, use_browser_compat, use_dispatch, use_link_confirm, - use_package_project, use_package_versions, version_list, versions_total, + bundles_with_status_items, cluster_content_items, content_type_for_slug, project_detail, + use_browser_compat, use_bundles_with_status, use_cluster_content, use_dispatch, + use_installs_snapshot, use_link_confirm, use_package_project, use_package_versions, + version_list, versions_total, }; use crate::theme::colors; use crate::ui::border_all_color; use crate::hooks::use_cluster; -use super::{PackageBanner, Thumbnail}; +use super::{InstallSource, Installed, PackageBanner, Thumbnail, installed_badge, installed_map}; use crate::utils::abbreviate_number; mod panels; @@ -136,6 +138,14 @@ impl Component for BrowserPackage { *versions_page.read(), ); + let installing = use_installs_snapshot().is_installing(cluster_id, provider, &project_id); + + let installed = installed_map( + cluster_content_items(&use_cluster_content(cluster_id, content_type)), + &bundles_with_status_items(&use_bundles_with_status(cluster_id)), + ) + .remove(&(provider, project_id.clone())); + let project = project_detail(&project_query); let versions = version_list(&versions_query); let total_versions = versions_total(&versions_query); @@ -160,6 +170,8 @@ impl Component for BrowserPackage { project.id.clone(), cluster_id, dispatch.clone(), + installed.clone(), + installing, ) .into_element(), (Some(_), _) => gallery_panel(gallery).into_element(), @@ -178,6 +190,8 @@ impl Component for BrowserPackage { cluster_id, dispatch, confirm, + installed, + installing, )) .child( rect() 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 6296ef89..6273d13b 100644 --- a/packages/oneclient_app/src/view/app/browser/package/panels.rs +++ b/packages/oneclient_app/src/view/app/browser/package/panels.rs @@ -157,6 +157,7 @@ impl Component for GalleryTile { } } +#[allow(clippy::too_many_arguments)] pub(super) fn versions_panel( versions: Vec, total_versions: usize, @@ -165,6 +166,8 @@ pub(super) fn versions_panel( project_id: String, cluster_id: i64, dispatch: Actions, + installed: Option, + installing: bool, ) -> impl IntoElement { let current = *versions_page.read(); let total_pages = total_versions.div_ceil(VERSIONS_PAGE_SIZE).max(1); @@ -187,12 +190,18 @@ pub(super) fn versions_panel( .width(Size::fill()) .spacing(8.) .children(versions.into_iter().map(move |v| { + let tag = installed + .as_ref() + .filter(|installed| installed.is_version(&v.version_id)) + .map(|installed| installed.source); version_row( v, provider, project_id.clone(), cluster_id, dispatch.clone(), + tag, + installing, ) .into_element() })) @@ -258,12 +267,15 @@ fn version_pager(current: usize, total_pages: usize, page: State) -> impl .into_element() } +#[allow(clippy::too_many_arguments)] fn version_row( v: VersionSummary, provider: ProviderId, project_id: String, cluster_id: i64, dispatch: Actions, + installed: Option, + installing: bool, ) -> impl IntoElement { let version_id = v.version_id.clone(); let mut chips: Vec = v.loaders.iter().map(|l| l.to_string()).collect(); @@ -311,10 +323,12 @@ 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, 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 9b0c7eca..f73ea5bf 100644 --- a/packages/oneclient_app/src/view/app/browser/package/sidebar.rs +++ b/packages/oneclient_app/src/view/app/browser/package/sidebar.rs @@ -8,6 +8,7 @@ use crate::components::{Button, Icon, IconType}; use crate::theme::colors; use crate::ui::border_all_color; +#[allow(clippy::too_many_arguments)] pub(super) fn sidebar( project: Option, latest_version: Option, @@ -15,6 +16,8 @@ pub(super) fn sidebar( cluster_id: i64, dispatch: Actions, confirm: State>, + installed: Option, + installing: bool, ) -> impl IntoElement { let Some(project) = project else { return rect() @@ -24,7 +27,13 @@ pub(super) fn sidebar( }; let project_id = project.id.clone(); - let can_install = latest_version.is_some(); + // Nothing to do when the cluster already has exactly this version, and + // nothing to do twice while the install that was just started is running. + let have_latest = match (&installed, &latest_version) { + (Some(installed), Some(latest)) => installed.is_version(latest), + _ => false, + }; + let can_install = latest_version.is_some() && !have_latest && !installing; rect() .vertical() @@ -54,22 +63,12 @@ pub(super) fn sidebar( .max_lines(2) .color(colors::fg_primary()), ) - .child( - rect() - .horizontal() - .cross_align(Alignment::Center) - .spacing(4.) - .child(Icon::new(project.provider).size(12.)) - .child( - label() - .text(format!( - "{} on {}", - project.content_type, project.provider - )) - .font_size(11.) - .color(colors::fg_secondary()), - ), - ) + .child(ProviderTag { + text: format!("{} on {}", project.content_type, project.provider), + provider: project.provider, + url: provider_project_url(&project), + confirm, + }) .maybe(!project.summary.is_empty(), |el| { el.child( label() @@ -101,6 +100,11 @@ pub(super) fn sidebar( ), ), ) + .maybe_child( + installed + .as_ref() + .map(|installed| installed_badge(installed.source, 12.).into_element()), + ) .child( Button::new() .primary() @@ -117,7 +121,13 @@ pub(super) fn sidebar( } }) .child(Icon::new(IconType::Download01).size(14.)) - .text("Install latest"), + .text(if installing { + "Installing..." + } else if have_latest { + "Latest installed" + } else { + "Install latest" + }), ) .maybe( !project.members.is_empty() || !project.author.is_empty(), @@ -130,11 +140,87 @@ pub(super) fn sidebar( .into_element() } +/// The package's own page on the provider's site. Modrinth doesn't hand one +/// out, so it's rebuilt from the slug; CurseForge already lists it as "Website". +fn provider_project_url(project: &ProjectDetail) -> Option { + match project.provider { + ProviderId::Modrinth => Some(format!( + "{}{}/{}", + ProviderId::Modrinth.website(), + project.content_type.modrinth_type(), + project.slug + )), + ProviderId::CurseForge => project + .links + .iter() + .find(|(label, _)| label == "Website") + .map(|(_, url)| url.clone()), + ProviderId::Local => None, + } +} + +/// "Mod on Modrinth", linking out to the package's page there when the provider +/// has one. Plain text otherwise, so a local package doesn't look clickable. +#[derive(PartialEq)] +struct ProviderTag { + text: String, + provider: ProviderId, + url: Option, + confirm: State>, +} + +impl Component for ProviderTag { + fn render(&self) -> impl IntoElement { + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let focused = focus().is_focused(); + + let url = self.url.clone(); + let interactive = url.is_some(); + let mut confirm = self.confirm; + + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(4.) + .child(Icon::new(self.provider).size(12.)) + .child( + label() + .text(self.text.clone()) + .font_size(11.) + .color(if interactive { + colors::code_info() + } else { + colors::fg_secondary() + }), + ) + .maybe(interactive, |el| { + let url = url.clone().unwrap_or_default(); + el.a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Link) + .corner_radius(CornerRadius::new_all(6.)) + .maybe(focused, |el| { + el.border(border_all_color(1., colors::brand())) + }) + .on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer)) + .on_pointer_leave(|_| Cursor::set(CursorIcon::default())) + .on_all_press(move |_| confirm.set(Some(url.clone()))) + }) + } +} + fn card(title: &str, rows: Vec) -> impl IntoElement { + card_spaced(title, rows, 10.) +} + +/// `card` with the gap between rows chosen by the caller: a list of one-line +/// links reads better tight than the multi-line author and detail rows do. +fn card_spaced(title: &str, rows: Vec, spacing: f32) -> impl IntoElement { rect() .vertical() .width(Size::fill()) - .spacing(10.) + .spacing(spacing) .padding(Gaps::new_all(16.)) .corner_radius(CornerRadius::new_all(12.)) .background(PANEL_BG) @@ -310,7 +396,7 @@ fn links_card(links: &[(String, String)], confirm: State>) -> imp .iter() .map(|(lbl, url)| link_row(lbl.clone(), url.clone(), confirm)) .collect(); - card("Links", rows) + card_spaced("Links", rows, 2.) } fn link_row(label_text: String, url: String, confirm: State>) -> Element { @@ -344,10 +430,10 @@ impl Component for LinkRow { .cross_align(Alignment::Center) .spacing(8.) .corner_radius(CornerRadius::new_all(6.)) - .padding(Gaps::new_all(4.)) + .padding(Gaps::new_symmetric(2., 4.)) .a11y_id(a11y_id) .a11y_focusable(true) - .a11y_role(AccessibilityRole::Button) + .a11y_role(AccessibilityRole::Link) .maybe(focused, |el| { el.border(border_all_color(1., colors::brand())) }) diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs index 592f2468..96810b93 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs @@ -469,6 +469,7 @@ fn browse_button(cluster_id: i64, package_type: &'static str) -> impl IntoElemen let _ = RouterContext::get().push(Route::Browser { cluster_id, package_type: package_type.to_string(), + pick_cluster: false, }); }) .child(Icon::new(IconType::SearchMd).size(14.)) diff --git a/packages/oneclient_content/src/packages/store/mod.rs b/packages/oneclient_content/src/packages/store/mod.rs index 3fcd6543..30b45cff 100644 --- a/packages/oneclient_content/src/packages/store/mod.rs +++ b/packages/oneclient_content/src/packages/store/mod.rs @@ -157,6 +157,7 @@ impl PackageStore { content_type, file_name: artifact.file_name, project_id: release.as_ref().map(|r| r.project_id.clone()), + version_id: release.as_ref().map(|r| r.version_id.clone()), display_name: release.as_ref().map(|r| r.display_name.clone()), display_version: release.as_ref().map(|r| r.display_version.clone()), provider: release diff --git a/packages/oneclient_content/src/packages/types.rs b/packages/oneclient_content/src/packages/types.rs index 941a0df2..88be6bff 100644 --- a/packages/oneclient_content/src/packages/types.rs +++ b/packages/oneclient_content/src/packages/types.rs @@ -200,6 +200,7 @@ pub struct LinkedArtifactInfo { pub content_type: ContentType, pub file_name: String, pub project_id: Option, + pub version_id: Option, pub display_name: Option, pub display_version: Option, pub provider: Option,