feat(ui): install packages straight from the listing

This commit is contained in:
LynithDev 2026-08-01 11:52:29 +02:00
parent 1947e2d534
commit 44c153dd91
3 changed files with 152 additions and 26 deletions

View file

@ -1,3 +1,5 @@
use std::time::Duration;
use freya::query::{Query, QueryCapability, UseQuery, use_query};
use oneclient_common::domain::GameLoader;
use oneclient_content::packages::types::{
@ -10,6 +12,12 @@ use oneclient_core::LauncherError;
pub const BROWSE_PAGE_SIZE: usize = DEFAULT_PAGE_SIZE;
pub const VERSIONS_PAGE_SIZE: usize = 20;
/// A published version list barely moves, and the browser listing now asks for
/// one per visible card to know what "latest" is. Holding them for a while
/// keeps scrolling from refiring a request per card every time one comes back
/// into view.
const VERSIONS_STALE: Duration = Duration::from_secs(5 * 60);
pub fn content_type_for_slug(slug: &str) -> ContentType {
match slug {
"shader" => ContentType::Shader,
@ -230,16 +238,19 @@ pub fn use_package_versions(
loader: Option<GameLoader>,
page: usize,
) -> UseQuery<PackageVersionsQuery> {
use_query(Query::new(
PackageVersionsKeys {
provider,
project_id,
game_version,
loader,
page,
},
PackageVersionsQuery,
))
use_query(
Query::new(
PackageVersionsKeys {
provider,
project_id,
game_version,
loader,
page,
},
PackageVersionsQuery,
)
.stale_time(VERSIONS_STALE),
)
}
pub fn version_list(query: &UseQuery<PackageVersionsQuery>) -> Vec<VersionSummary> {

View file

@ -3,20 +3,22 @@ use super::*;
use std::collections::HashMap;
use freya::router::RouterContext;
use oneclient_content::packages::ProviderId;
use oneclient_content::packages::types::ProjectSummary;
use oneclient_content::packages::{ContentType, ProviderId};
use crate::components::{Icon, IconType};
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 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)
fn installed_for(installed: &InstalledMap, item: &ProjectSummary) -> Option<Installed> {
installed.get(&(item.provider, item.id.clone())).cloned()
}
pub(super) fn grid_row(
@ -27,7 +29,7 @@ pub(super) fn grid_row(
) -> impl IntoElement {
let package_type = package_type.to_string();
let fill = GRID_COLUMNS - row.len();
let installed: Vec<Option<InstallSource>> = row
let installed: Vec<Option<Installed>> = row
.iter()
.map(|item| installed_for(installed, item))
.collect();
@ -68,7 +70,7 @@ struct PackageCard {
item: ProjectSummary,
cluster_id: i64,
package_type: String,
installed: Option<InstallSource>,
installed: Option<Installed>,
}
impl PackageCard {
@ -76,7 +78,7 @@ impl PackageCard {
item: ProjectSummary,
cluster_id: i64,
package_type: String,
installed: Option<InstallSource>,
installed: Option<Installed>,
) -> Self {
Self {
item,
@ -185,14 +187,23 @@ impl Component for PackageCard {
.cross_align(Alignment::Center)
.content(Content::Flex)
.child(downloads_row(self.item.downloads))
.maybe_child(self.installed.map(|installed| {
.child(
rect()
.horizontal()
.width(Size::flex(1.0))
.cross_align(Alignment::Center)
.main_align(Alignment::End)
.child(installed_badge(installed, 10.))
.into_element()
})),
.spacing(6.)
.maybe_child(self.installed.as_ref().map(|installed| {
installed_badge(installed.source, 10.).into_element()
}))
.child(InstallButton::new(
&self.item,
self.cluster_id,
&self.package_type,
self.installed.clone(),
)),
),
),
)
}
@ -217,7 +228,7 @@ struct ListRow {
item: ProjectSummary,
cluster_id: i64,
package_type: String,
installed: Option<InstallSource>,
installed: Option<Installed>,
}
impl Component for ListRow {
@ -300,9 +311,113 @@ impl Component for ListRow {
)
.maybe_child(
self.installed
.map(|installed| installed_badge(installed, 11.).into_element()),
.as_ref()
.map(|installed| installed_badge(installed.source, 11.).into_element()),
)
.child(downloads_row(item.downloads))
.child(InstallButton::new(
&self.item,
self.cluster_id,
&self.package_type,
self.installed.clone(),
))
}
}
/// Installs a listing entry's latest compatible version without making the user
/// open its page first. Same version pick, same dispatch and same in-flight
/// handling as the install button on the package page.
#[derive(PartialEq)]
struct InstallButton {
provider: ProviderId,
project_id: String,
cluster_id: i64,
content_type: ContentType,
installed: Option<Installed>,
}
impl InstallButton {
fn new(
item: &ProjectSummary,
cluster_id: i64,
package_type: &str,
installed: Option<Installed>,
) -> Self {
Self {
provider: item.provider,
project_id: item.id.clone(),
cluster_id,
content_type: content_type_for_slug(package_type),
installed,
}
}
}
impl Component for InstallButton {
fn render(&self) -> impl IntoElement {
let cluster_id = self.cluster_id;
let provider = self.provider;
let project_id = self.project_id.clone();
let dispatch = use_dispatch();
let compat = *use_browser_compat().read();
let cluster = use_cluster(cluster_id);
// The same narrowing the package page does before calling the first
// entry of the list "latest", so both agree on what gets installed.
let (game_version, loader) = match (compat, &cluster) {
(true, Some(c)) => (
Some(c.mc_version.clone()),
(self.content_type == ContentType::Mod).then_some(c.mc_loader),
),
_ => (None, None),
};
let versions = version_list(&use_package_versions(
provider,
project_id.clone(),
game_version,
loader,
0,
));
let latest = versions.first().map(|v| v.version_id.clone());
let installing = use_installs_snapshot().is_installing(cluster_id, provider, &project_id);
// 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 (&self.installed, &latest) {
(Some(installed), Some(latest)) => installed.is_version(latest),
_ => false,
};
rect()
// The card behind this is one big press target. A press on the
// button is not a press on the card, so it stops here rather than
// opening the package page on top of the install.
.on_all_press(|e: Event<PressEventData>| e.stop_propagation())
.child(
Button::new()
.secondary()
.small()
.enabled(latest.is_some() && !have_latest && !installing)
.on_press(move |_| {
if let Some(version_id) = latest.clone() {
dispatch.install_package(
cluster_id,
provider,
project_id.clone(),
version_id,
);
}
})
.text(if installing {
"Installing..."
} else if have_latest {
"Installed"
} else {
"Install"
}),
)
}
}

View file

@ -27,7 +27,7 @@ use crate::routes::Route;
use crate::theme::colors;
use crate::hooks::use_cluster;
use super::{InstallSource, Installed, PackageBanner, Thumbnail, installed_badge, installed_map};
use super::{Installed, PackageBanner, Thumbnail, installed_badge, installed_map};
use crate::utils::{abbreviate_number, sort_clusters_for_home};
mod cards;