feat(ui): browser navbar tab + better installation feedback

This commit is contained in:
LynithDev 2026-07-29 00:06:14 +02:00
parent 34408056c2
commit 4aaff39abc
16 changed files with 633 additions and 79 deletions

View file

@ -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::<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,

View file

@ -18,6 +18,7 @@ pub struct Pagination {
page: State<usize>,
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()

View file

@ -661,28 +661,73 @@ impl Actions {
version_id: impl Into<String>,
) {
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);
});
}

View file

@ -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<LoginProgress> {
use_radio(AppChannel::MicrosoftLogin)
.read()

View file

@ -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<uuid::Uuid>,
pub result: anyhow::Result<String>,
}
pub async fn install_package(
state: &Arc<LauncherState>,
provider: oneclient_content::packages::ProviderId,
project_id: &str,
version_id: &str,
cluster_id: i64,
) -> anyhow::Result<String> {
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),
}
}

View file

@ -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")]

View file

@ -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<AppState> for AppChannel {}
@ -57,6 +60,31 @@ pub struct AppState {
pub game: GameState,
pub account_switcher_open: bool,
pub microsoft_login: Option<LoginProgress>,
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)]

View file

@ -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<InstallSource> {
installed
.get(&(item.provider, item.id.clone()))
.map(|installed| installed.source)
}
pub(super) fn grid_row(
row: Vec<ProjectSummary>,
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<Option<InstallSource>> = 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<InstallSource>,
}
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<InstallSource>,
) -> 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<InstallSource>,
}
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))
}
}

View file

@ -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<String>) -> impl IntoElement {
fn page_title(
package_type: &str,
cluster_name: Option<String>,
picker: Option<ClusterPicker>,
) -> impl IntoElement {
rect()
.vertical()
.spacing(2.)
@ -250,12 +290,89 @@ fn page_title(package_type: &str, cluster_name: Option<String>) -> 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<String> = clusters
.iter()
.map(|c| format!("{} · {}", c.name, version_name(&metadata, c)))
.collect();
let ids: Vec<i64> = 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(

View file

@ -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<String>,
}
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<LinkedArtifactInfo>,
bundles: &[BundleWithUpdateStatus],
) -> HashMap<(ProviderId, String), Installed> {
let mut map: HashMap<(ProviderId, String), Installed> = content
.into_iter()
.filter_map(|item| {
Some((
(item.provider?, item.project_id?),
Installed {
source: InstallSource::Manual,
version_id: item.version_id,
},
))
})
.collect();
// 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<String>,

View file

@ -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()

View file

@ -157,6 +157,7 @@ impl Component for GalleryTile {
}
}
#[allow(clippy::too_many_arguments)]
pub(super) fn versions_panel(
versions: Vec<VersionSummary>,
total_versions: usize,
@ -165,6 +166,8 @@ pub(super) fn versions_panel(
project_id: String,
cluster_id: i64,
dispatch: Actions,
installed: Option<Installed>,
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<usize>) -> 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<InstallSource>,
installing: bool,
) -> impl IntoElement {
let version_id = v.version_id.clone();
let mut chips: Vec<String> = 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,

View file

@ -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<ProjectDetail>,
latest_version: Option<String>,
@ -15,6 +16,8 @@ pub(super) fn sidebar(
cluster_id: i64,
dispatch: Actions,
confirm: State<Option<String>>,
installed: Option<Installed>,
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<String> {
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<String>,
confirm: State<Option<String>>,
}
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<Element>) -> 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<Element>, 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<Option<String>>) -> 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<Option<String>>) -> 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()))
})

View file

@ -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.))

View file

@ -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

View file

@ -200,6 +200,7 @@ pub struct LinkedArtifactInfo {
pub content_type: ContentType,
pub file_name: String,
pub project_id: Option<String>,
pub version_id: Option<String>,
pub display_name: Option<String>,
pub display_version: Option<String>,
pub provider: Option<ProviderId>,