feat(ui): browser navbar tab + better installation feedback
This commit is contained in:
parent
34408056c2
commit
4aaff39abc
16 changed files with 633 additions and 79 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue