OneLauncher-Cracked/packages/oneclient_app/src/components/release_migration_popup.rs

1363 lines
45 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use std::collections::{HashMap, HashSet};
use freya::prelude::*;
use freya::router::use_route;
use oneclient_common::domain::{ContentType, GameLoader};
use oneclient_common::version::parse_mc_version;
use oneclient_content::packages::release_migration::{
ReleaseMigrationDependency, ReleaseMigrationPackage, ReleaseMigrationPlan,
ReleaseMigrationSkip, SkipReason, WAITLIST_DAYS,
};
use oneclient_content::packages::{CachedPackageMeta, ProviderId};
use oneclient_core::clusters::Cluster;
use crate::components::{
Button, Dropdown, DynamicArt, Icon, IconType, OverlayPopup, ScrollArea, checkbox_labeled,
};
use crate::hooks::{
loaded_image, package_meta_batch, use_cached_image, use_dispatch, use_game_snapshot,
use_launcher, use_notifications_snapshot, use_package_meta_batch, use_release_migration,
};
use crate::routes::Route;
use crate::state::{ReleaseMigrationPrompt, ReleasePlanState};
use crate::theme::colors;
use crate::ui::{ImageFallbackExt, border_all_color};
use crate::utils::format_size;
const DIALOG_W: f32 = 1020.;
const DIALOG_H: f32 = 616.;
const ART_W: f32 = 368.;
const PANEL_BG: Color = Color::from_rgb(22, 28, 35);
const FOOTER_BG: Color = Color::from_rgb(19, 25, 31);
const ROW_ICON: f32 = 32.;
const CHECK_SIZE: f32 = 24.;
const SKIPPED_ICON: f32 = 20.;
const SKIPPED_NAME_W: f32 = 168.;
const REQUIRED_BY_W: f32 = 190.;
type MetaMap = HashMap<(ProviderId, String), CachedPackageMeta>;
type SelectionKey = (i64, String);
#[derive(Clone, Copy, PartialEq, Eq)]
enum PackageTab {
Mods,
ResourcePacks,
Shaders,
}
impl PackageTab {
const ALL: [Self; 3] = [Self::Mods, Self::ResourcePacks, Self::Shaders];
fn label(self) -> &'static str {
match self {
Self::Mods => "Mods",
Self::ResourcePacks => "Resource packs",
Self::Shaders => "Shaders",
}
}
fn content_type(self) -> ContentType {
match self {
Self::Mods => ContentType::Mod,
Self::ResourcePacks => ContentType::ResourcePack,
Self::Shaders => ContentType::Shader,
}
}
}
fn blocked_by_route(route: &Route) -> bool {
matches!(
route,
Route::Startup {}
| Route::Relocating {}
| Route::OnboardingWelcome {}
| Route::OnboardingLocation {}
| Route::OnboardingTerms {}
| Route::OnboardingMigration {}
| Route::OnboardingLanguage {}
| Route::OnboardingAccount {}
| Route::OnboardingPreferences {}
| Route::OnboardingBundles {}
| Route::OnboardingSummary {}
)
}
fn cluster_title(cluster: &Cluster) -> String {
cluster.name.clone()
}
fn selection_key(package: &ReleaseMigrationPackage, source_id: i64) -> SelectionKey {
(source_id, package.source_hash.clone())
}
#[derive(PartialEq)]
pub struct ReleaseMigrationPopup;
impl Component for ReleaseMigrationPopup {
fn render(&self) -> impl IntoElement {
let launcher = use_launcher();
let dispatch = use_dispatch();
let route = use_route::<Route>();
let snapshot = use_notifications_snapshot();
let game = use_game_snapshot();
let prompt = use_release_migration();
let mut excluded = use_state(HashSet::<SelectionKey>::new);
let mut tab = use_state(|| PackageTab::Mods);
let mut copy_configs = use_state(|| true);
let settled = launcher.ready && !launcher.fetching && !launcher.syncing_bundles;
let checked = use_state(|| false);
let check_dispatch = dispatch.clone();
use_side_effect_with_deps(&settled, move |&settled| {
let mut checked = checked;
if settled && !*checked.peek() {
checked.set(true);
check_dispatch.check_release_migration();
check_dispatch.process_release_waitlist();
}
});
let mut wont_open = use_state(|| true);
let mut deps_open = use_state(|| false);
let projects: Vec<(ProviderId, String)> =
prompt
.as_ref()
.and_then(|prompt| match prompt.plan() {
Some(ReleasePlanState::Ready(plan)) => Some(
plan.packages
.iter()
.map(|package| (package.provider, package.project_id.clone()))
.chain(
plan.unavailable
.iter()
.map(|skip| (skip.provider, skip.project_id.clone())),
)
.chain(plan.dependencies.iter().map(|dependency| {
(dependency.provider, dependency.project.id.clone())
}))
.collect(),
),
_ => None,
})
.unwrap_or_default();
let mut meta = MetaMap::new();
for provider in ProviderId::REMOTE_PROVIDERS.iter().copied() {
let ids: Vec<String> = projects
.iter()
.filter(|(owner, _)| *owner == provider)
.map(|(_, project_id)| project_id.clone())
.collect();
let query = use_package_meta_batch(provider, ids);
for (project_id, cached) in package_meta_batch(&query) {
meta.insert((provider, project_id), cached);
}
}
let Some(prompt) = prompt else {
excluded.set_if_modified(HashSet::new());
tab.set_if_modified(PackageTab::Mods);
wont_open.set_if_modified(true);
deps_open.set_if_modified(false);
copy_configs.set_if_modified(true);
return rect().into_element();
};
let other_popup = snapshot.cluster_update.is_some()
|| snapshot.optional_mods.is_some()
|| snapshot.bundle_choices.is_some()
|| snapshot.package_updates.is_some()
|| snapshot.pending_prompt.is_some();
if blocked_by_route(&route) || other_popup {
return rect().into_element();
}
let target_running = game.is_running(prompt.target.id);
let close = dispatch.clone();
let panel_dispatch = dispatch.clone();
OverlayPopup::new()
.on_close(move |_| close.dismiss_release_migration())
.child(
rect()
.width(Size::window_percent(100.))
.height(Size::window_percent(100.))
.center()
.child(dialog(
&prompt,
&meta,
panel_dispatch,
excluded,
tab,
wont_open,
deps_open,
copy_configs,
target_running,
)),
)
.into_element()
}
}
#[allow(clippy::too_many_arguments)]
fn dialog(
prompt: &ReleaseMigrationPrompt,
meta: &MetaMap,
dispatch: crate::Actions,
excluded: State<HashSet<SelectionKey>>,
tab: State<PackageTab>,
wont_open: State<bool>,
deps_open: State<bool>,
copy_configs: State<bool>,
target_running: bool,
) -> impl IntoElement {
let plan = match prompt.plan() {
Some(ReleasePlanState::Ready(plan)) => Some(plan),
_ => None,
};
rect()
.horizontal()
.width(Size::px(DIALOG_W))
.height(Size::px(DIALOG_H))
.max_width(Size::window_percent(95.))
.max_height(Size::window_percent(92.))
.overflow(Overflow::Clip)
.corner_radius(CornerRadius::new_all(18.))
.background(PANEL_BG)
.border(border_all_color(1., colors::component_border()))
.shadow(Shadow::from((
0.,
24.,
64.,
0.,
Color::from_argb(160, 0, 0, 0),
)))
.content(Content::Flex)
.child(art_panel(prompt, plan, &excluded.read()))
.child(content_panel(
prompt,
plan,
meta,
dispatch,
excluded,
tab,
wont_open,
deps_open,
copy_configs,
target_running,
))
}
fn art_panel(
prompt: &ReleaseMigrationPrompt,
plan: Option<&ReleaseMigrationPlan>,
excluded: &HashSet<SelectionKey>,
) -> impl IntoElement {
let target = &prompt.target;
let parsed = parse_mc_version(&target.mc_version);
let art = parsed
.map(|parsed| DynamicArt::for_version(parsed.major, parsed.key(), Some(target.mc_loader)));
let mut runtime = Vec::new();
if target.mc_loader != GameLoader::Vanilla {
runtime.push(match target.mc_loader_version.as_deref() {
Some(version) if !version.is_empty() => format!("{} {version}", target.mc_loader),
_ => target.mc_loader.to_string(),
});
}
if let Some(java) = prompt.java_major {
runtime.push(format!("Java {java}"));
}
rect()
.width(Size::px(ART_W))
.height(Size::fill())
.overflow(Overflow::Clip)
.background(colors::page())
.maybe_child(art.map(|art| {
rect()
.position(Position::new_absolute())
.width(Size::fill())
.height(Size::fill())
.layer(Layer::Relative(1))
.child(art)
}))
.child(
rect()
.vertical()
.width(Size::fill())
.height(Size::fill())
.padding(Gaps::new(24., 24., 24., 24.))
.main_align(Alignment::End)
.layer(Layer::Relative(3))
.background(
LinearGradient::new()
.angle(0.)
.stop((Color::from_af32rgb(0.0, 13, 17, 21), 28.))
.stop((Color::from_af32rgb(0.85, 13, 17, 21), 58.))
.stop((Color::from_af32rgb(0.98, 13, 17, 21), 100.)),
)
.child(
rect()
.vertical()
.width(Size::fill())
.child(
label()
.text(cluster_title(target))
.font_size(32.)
.font_weight(FontWeight::BOLD)
.max_lines(1)
.color(Color::WHITE),
)
.maybe_child((!runtime.is_empty()).then(|| {
label()
.text(runtime.join(" · "))
.font_size(13.)
.margin(Gaps::new(12., 0., 0., 0.))
.color(colors::fg_primary())
}))
.child(summary_card(plan, excluded)),
),
)
}
fn summary_card(
plan: Option<&ReleaseMigrationPlan>,
excluded: &HashSet<SelectionKey>,
) -> impl IntoElement {
let mut rows = rect().vertical().width(Size::fill()).spacing(10.);
for tab in PackageTab::ALL {
let value = plan.map_or_else(
|| "–".to_string(),
|plan| {
let dependencies = if tab.content_type() == ContentType::Mod {
plan.dependencies.len()
} else {
0
};
let needed = if tab.content_type() == ContentType::Mod {
let chosen = selected_hashes(plan, excluded);
plan.dependencies
.iter()
.filter(|dependency| dependency.is_needed_by(&chosen))
.count()
} else {
0
};
let total = plan.offered(tab.content_type())
+ dependencies
+ plan
.unavailable
.iter()
.filter(|skip| skip.content_type == tab.content_type())
.count();
let selected = needed
+ plan
.packages
.iter()
.filter(|package| package.content_type == tab.content_type())
.filter(|package| {
!excluded.contains(&selection_key(package, plan.source_cluster_id))
})
.count();
format!("{selected} of {total}")
},
);
rows = rows.child(summary_row(tab.label(), value, colors::fg_primary()));
}
let wont = plan.map_or_else(
|| "–".to_string(),
|plan| plan.unavailable.len().to_string(),
);
rect()
.vertical()
.width(Size::fill())
.margin(Gaps::new(20., 0., 0., 0.))
.child(rows)
.child(
rect()
.width(Size::fill())
.height(Size::px(1.))
.margin(Gaps::new(12., 0., 12., 0.))
.background(colors::component_border()),
)
.child(summary_row("Won't migrate", wont, colors::fg_secondary()))
}
fn summary_row(name: &str, value: String, name_color: Color) -> impl IntoElement {
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.content(Content::Flex)
.child(
label()
.text(name.to_string())
.font_size(13.)
.width(Size::flex(1.))
.color(name_color),
)
.child(
label()
.text(value)
.font_size(13.)
.font_weight(FontWeight::MEDIUM)
.color(Color::WHITE),
)
}
#[allow(clippy::too_many_arguments)]
fn content_panel(
prompt: &ReleaseMigrationPrompt,
plan: Option<&ReleaseMigrationPlan>,
meta: &MetaMap,
dispatch: crate::Actions,
excluded: State<HashSet<SelectionKey>>,
tab: State<PackageTab>,
wont_open: State<bool>,
deps_open: State<bool>,
copy_configs: State<bool>,
target_running: bool,
) -> impl IntoElement {
let close = dispatch.clone();
let picker_dispatch = dispatch.clone();
let source_title = prompt.source().map(cluster_title).unwrap_or_default();
let target_title = if prompt.target.user_created {
prompt.target.name.as_str()
} else {
prompt.target.mc_version.as_str()
};
let subtitle = format!(
"Files are copied into {target_title}. Your {source_title} cluster is left exactly as it is."
);
rect()
.vertical()
.width(Size::flex(1.))
.height(Size::fill())
.content(Content::Flex)
.child(
rect()
.vertical()
.width(Size::fill())
.height(Size::flex(1.))
.content(Content::Flex)
.padding(Gaps::new(22., 24., 0., 24.))
.child(
rect()
.horizontal()
.width(Size::fill())
.content(Content::Flex)
.cross_align(Alignment::Center)
.child(
label()
.text("Migrate your packages")
.font_size(21.)
.font_weight(FontWeight::SEMI_BOLD)
.width(Size::flex(1.))
.max_lines(1)
.color(colors::fg_primary()),
)
.child(
Button::new()
.ghost()
.icon()
.on_press(move |_| close.dismiss_release_migration())
.child(
Icon::new(IconType::XClose)
.size(16.)
.color(colors::fg_secondary()),
),
),
)
.child(
label()
.text(subtitle)
.font_size(13.)
.max_lines(2)
.margin(Gaps::new(4., 0., 0., 0.))
.color(colors::fg_secondary()),
)
.child(source_picker(prompt, picker_dispatch))
.child(tab_row(plan, excluded, tab))
.child(package_list(
prompt, plan, meta, excluded, tab, wont_open, deps_open,
)),
)
.child(
rect()
.width(Size::fill())
.height(Size::px(1.))
.background(colors::component_border()),
)
.child(footer(
prompt,
plan,
dispatch,
excluded,
copy_configs,
target_running,
))
}
fn source_picker(prompt: &ReleaseMigrationPrompt, dispatch: crate::Actions) -> impl IntoElement {
let sources: Vec<i64> = prompt.sources.iter().map(|cluster| cluster.id).collect();
let options: Vec<String> = prompt.sources.iter().map(cluster_title).collect();
let selected = prompt.source().map(cluster_title).unwrap_or_default();
rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(12.)
.margin(Gaps::new(18., 0., 0., 0.))
.child(
label()
.text("Migrate from")
.font_size(13.)
.color(colors::fg_secondary()),
)
.child(
Dropdown::new(selected, options)
.outlined()
.width(Size::px(212.))
.height(Size::px(32.))
.on_select(move |index: usize| {
if let Some(source_id) = sources.get(index) {
dispatch.select_release_migration_source(*source_id);
}
}),
)
}
fn tab_row(
plan: Option<&ReleaseMigrationPlan>,
excluded: State<HashSet<SelectionKey>>,
tab: State<PackageTab>,
) -> impl IntoElement {
let active = *tab.read();
let mut pills = rect()
.horizontal()
.spacing(8.)
.cross_align(Alignment::Center);
for option in PackageTab::ALL {
let count = plan.map_or(0, |plan| plan.offered(option.content_type()));
let mut tab = tab;
pills = pills.child(tab_pill(
format!("{} · {count}", option.label()),
option == active,
move || tab.set(option),
));
}
let all_keys: Vec<SelectionKey> = plan
.map(|plan| {
plan.packages
.iter()
.map(|package| selection_key(package, plan.source_cluster_id))
.collect()
})
.unwrap_or_default();
let any_selected = all_keys.iter().any(|key| !excluded.read().contains(key));
let has_packages = !all_keys.is_empty();
rect()
.horizontal()
.width(Size::fill())
.content(Content::Flex)
.cross_align(Alignment::Center)
.margin(Gaps::new(18., 0., 10., 0.))
.child(rect().width(Size::flex(1.)).child(pills))
.maybe_child(has_packages.then(|| {
let mut excluded = excluded;
rect()
.padding(Gaps::new_symmetric(6., 4.))
.cursor(CursorIcon::Pointer)
.on_press(move |_| {
let mut set = excluded.write();
if any_selected {
set.extend(all_keys.iter().cloned());
} else {
for key in &all_keys {
set.remove(key);
}
}
})
.child(
label()
.text(if any_selected {
"Clear all"
} else {
"Select all"
})
.font_size(13.)
.font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_primary()),
)
}))
}
fn tab_pill(text: String, active: bool, mut on_press: impl FnMut() + 'static) -> impl IntoElement {
rect()
.height(Size::px(34.))
.padding(Gaps::new_symmetric(0., 13.))
.center()
.corner_radius(CornerRadius::new_all(8.))
.background(if active {
colors::brand()
} else {
Color::TRANSPARENT
})
.border(border_all_color(
1.,
if active {
colors::brand()
} else {
colors::component_border()
},
))
.cursor(CursorIcon::Pointer)
.on_press(move |_| on_press())
.child(
label()
.text(text)
.font_size(14.)
.font_weight(FontWeight::MEDIUM)
.color(if active {
Color::WHITE
} else {
colors::fg_primary()
}),
)
}
fn package_list(
prompt: &ReleaseMigrationPrompt,
plan: Option<&ReleaseMigrationPlan>,
meta: &MetaMap,
excluded: State<HashSet<SelectionKey>>,
tab: State<PackageTab>,
wont_open: State<bool>,
deps_open: State<bool>,
) -> impl IntoElement {
let source_title = prompt.source().map(cluster_title).unwrap_or_default();
let message = match (prompt.plan(), plan) {
(Some(ReleasePlanState::Loading) | None, _) => {
Some("Checking which packages have a compatible version…".to_string())
}
(Some(ReleasePlanState::Failed), _) => Some(
"Couldn't reach Modrinth or CurseForge. Check your connection and try again."
.to_string(),
),
_ => None,
};
if let Some(message) = message {
return list_message(message).into_element();
}
let Some(plan) = plan else {
return rect().into_element();
};
let content_type = tab.read().content_type();
let rows: Vec<&ReleaseMigrationPackage> = plan
.packages
.iter()
.filter(|package| package.content_type == content_type)
.collect();
let skipped: Vec<&ReleaseMigrationSkip> = plan
.unavailable
.iter()
.filter(|skip| skip.content_type == content_type)
.collect();
let dependencies: Vec<&ReleaseMigrationDependency> = if content_type == ContentType::Mod {
plan.dependencies.iter().collect()
} else {
Vec::new()
};
if rows.is_empty() && skipped.is_empty() && dependencies.is_empty() {
return list_message(format!(
"Nothing from {source_title} has a {} version yet.",
prompt.target.mc_version
))
.into_element();
}
let mut scroll = ScrollArea::new()
.width(Size::fill())
.height(Size::flex(1.))
.spacing(0.);
let mut has_content = !rows.is_empty();
for package in rows {
let key = selection_key(package, plan.source_cluster_id);
let selected = !excluded.read().contains(&key);
scroll = scroll.child(
MigrationRow {
name: package_name(
meta,
package.provider,
&package.project_id,
&package.display_name,
),
icon_url: meta
.get(&(package.provider, package.project_id.clone()))
.and_then(|cached| cached.icon_url.clone()),
package: package.clone(),
selected,
selection: key.clone(),
excluded,
key: DiffKey::None,
}
.key(format!("{}:{}", key.0, key.1))
.into_element(),
);
}
if !dependencies.is_empty() {
let selected_hashes = selected_hashes(plan, &excluded.read());
let open = *deps_open.read();
scroll = scroll.child(section_header(
IconType::Link03,
format!("Dependencies · {}", dependencies.len()),
Some("Installed with the packages that need them".to_string()),
open,
deps_open,
has_content,
));
has_content = true;
if open {
for dependency in dependencies {
let required_by: Vec<String> = dependency
.required_by
.iter()
.filter_map(|hash| {
plan.packages
.iter()
.find(|package| &package.source_hash == hash)
})
.map(|package| {
package_name(
meta,
package.provider,
&package.project_id,
&package.display_name,
)
})
.collect();
let cached = meta.get(&(dependency.provider, dependency.project.id.clone()));
scroll = scroll.child(
DependencyRow {
name: package_name(
meta,
dependency.provider,
&dependency.project.id,
&dependency.project.name,
),
icon_url: cached
.and_then(|cached| cached.icon_url.clone())
.or_else(|| dependency.project.icon_url.clone()),
provider: dependency.provider,
version_name: dependency.version_name(),
size: dependency.size(),
required_by: format!("Required by {}", required_by.join(", ")),
needed: dependency.is_needed_by(&selected_hashes),
key: DiffKey::None,
}
.key(format!(
"dependency:{}:{}",
plan.source_cluster_id, dependency.project.id
))
.into_element(),
);
}
}
}
if !skipped.is_empty() {
let cross_loader = prompt.is_cross_loader();
let build = build_label(prompt);
let mc_version = &build;
let open = *wont_open.read();
scroll = scroll.child(section_header(
IconType::AlertCircle,
format!("Won't migrate · {}", skipped.len()),
most_common_reason(&skipped).map(|reason| reason_text(reason, mc_version)),
open,
wont_open,
has_content,
));
if open {
if !cross_loader {
scroll = scroll.child(
label()
.text(format!(
"Added automatically when a {mc_version} build appears (up to {WAITLIST_DAYS} days)"
))
.font_size(12.)
.max_lines(1)
.margin(Gaps::new(0., 12., 6., 12.))
.color(colors::fg_secondary()),
);
}
for skip in skipped {
let cached = meta.get(&(skip.provider, skip.project_id.clone()));
scroll = scroll.child(
SkippedRow {
name: package_name(
meta,
skip.provider,
&skip.project_id,
&skip.display_name,
),
icon_url: cached.and_then(|cached| cached.icon_url.clone()),
reason: reason_text(skip.reason, mc_version),
key: DiffKey::None,
}
.key(format!(
"skip:{}:{}",
plan.source_cluster_id, skip.project_id
))
.into_element(),
);
}
}
}
scroll.into_element()
}
fn package_name(meta: &MetaMap, provider: ProviderId, project_id: &str, fallback: &str) -> String {
meta.get(&(provider, project_id.to_string()))
.map(|cached| cached.name.clone())
.filter(|name| !name.is_empty())
.unwrap_or_else(|| fallback.to_string())
}
fn selected_hashes(
plan: &ReleaseMigrationPlan,
excluded: &HashSet<SelectionKey>,
) -> HashSet<String> {
plan.packages
.iter()
.filter(|package| !excluded.contains(&selection_key(package, plan.source_cluster_id)))
.map(|package| package.source_hash.clone())
.collect()
}
fn build_label(prompt: &ReleaseMigrationPrompt) -> String {
let mc_version = &prompt.target.mc_version;
if !prompt.is_cross_loader() {
return mc_version.clone();
}
match prompt.target.mc_loader {
GameLoader::Ornithe => format!("Ornithe {mc_version}"),
loader => format!("{loader} {mc_version}"),
}
}
fn reason_text(reason: SkipReason, mc_version: &str) -> String {
match reason {
SkipReason::NoCompatibleVersion => format!("No {mc_version} build yet"),
SkipReason::ProviderUnavailable => "Couldn't check its provider".to_string(),
SkipReason::MissingDependency => "Depends on a package that can't migrate".to_string(),
}
}
fn most_common_reason(skipped: &[&ReleaseMigrationSkip]) -> Option<SkipReason> {
let mut counts: HashMap<SkipReason, usize> = HashMap::new();
for skip in skipped {
*counts.entry(skip.reason).or_default() += 1;
}
[
SkipReason::NoCompatibleVersion,
SkipReason::MissingDependency,
SkipReason::ProviderUnavailable,
]
.into_iter()
.filter(|reason| counts.contains_key(reason))
.max_by_key(|reason| counts[reason])
}
fn section_header(
icon: IconType,
title: String,
subtitle: Option<String>,
open: bool,
mut state: State<bool>,
after_content: bool,
) -> impl IntoElement {
rect()
.horizontal()
.width(Size::fill())
.height(Size::px(44.))
.content(Content::Flex)
.cross_align(Alignment::Center)
.spacing(10.)
.margin(Gaps::new(if after_content { 10. } else { 0. }, 0., 6., 0.))
.padding(Gaps::new_symmetric(0., 12.))
.corner_radius(CornerRadius::new_all(8.))
.background(colors::ghost_overlay())
.border(border_all_color(1., colors::component_border()).alignment(BorderAlignment::Inner))
.cursor(CursorIcon::Pointer)
.on_press(move |_| state.toggle())
.child(Icon::new(icon).size(16.).color(colors::fg_secondary()))
.child(
label()
.text(title)
.font_size(13.)
.font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_primary()),
)
.child(
label()
.text(subtitle.unwrap_or_default())
.font_size(12.)
.width(Size::flex(1.))
.max_lines(1)
.color(colors::fg_secondary()),
)
.child(
Icon::new(if open {
IconType::ChevronDown
} else {
IconType::ChevronRight
})
.size(16.)
.color(colors::fg_secondary()),
)
}
#[derive(PartialEq)]
struct DependencyRow {
name: String,
icon_url: Option<String>,
provider: ProviderId,
version_name: String,
size: u64,
required_by: String,
needed: bool,
key: DiffKey,
}
impl KeyExt for DependencyRow {
fn write_key(&mut self) -> &mut DiffKey {
&mut self.key
}
}
impl Component for DependencyRow {
fn render(&self) -> impl IntoElement {
let icon_query = use_cached_image(self.icon_url.clone(), 96);
let icon = match loaded_image(self.icon_url.as_deref(), &icon_query) {
Some((url, bytes)) => rect()
.width(Size::px(ROW_ICON))
.height(Size::px(ROW_ICON))
.corner_radius(CornerRadius::new_all(8.))
.overflow(Overflow::Clip)
.child(
ImageViewer::new((url, bytes))
.width(Size::px(ROW_ICON))
.height(Size::px(ROW_ICON))
.aspect_ratio(AspectRatio::Min)
.fallback(placeholder_icon()),
)
.into_element(),
None => placeholder_icon().into_element(),
};
rect()
.horizontal()
.width(Size::fill())
.height(Size::px(56.))
.content(Content::Flex)
.cross_align(Alignment::Center)
.spacing(14.)
.padding(Gaps::new_symmetric(0., 12.))
.opacity(if self.needed { 1. } else { 0.45 })
.child(icon)
.child(
rect()
.vertical()
.width(Size::flex(1.))
.spacing(2.)
.child(
label()
.text(self.name.clone())
.font_size(14.)
.font_weight(FontWeight::MEDIUM)
.max_lines(1)
.width(Size::fill())
.color(colors::fg_primary()),
)
.child(
label()
.text(format!(
"{} · {}",
provider_label(self.provider),
self.version_name
))
.font_size(12.)
.max_lines(1)
.width(Size::fill())
.color(colors::fg_secondary()),
),
)
.maybe_child((self.size > 0).then(|| {
label()
.text(format_size(self.size))
.font_size(12.)
.color(colors::fg_secondary())
}))
.child(
label()
.text(self.required_by.clone())
.font_size(12.)
.width(Size::px(REQUIRED_BY_W))
.max_lines(1)
.text_align(TextAlign::End)
.color(colors::fg_secondary()),
)
}
}
fn provider_label(provider: ProviderId) -> &'static str {
match provider {
ProviderId::Modrinth => "Modrinth",
ProviderId::CurseForge => "CurseForge",
ProviderId::Local => "Local",
}
}
#[derive(PartialEq)]
struct SkippedRow {
name: String,
icon_url: Option<String>,
reason: String,
key: DiffKey,
}
impl KeyExt for SkippedRow {
fn write_key(&mut self) -> &mut DiffKey {
&mut self.key
}
}
impl Component for SkippedRow {
fn render(&self) -> impl IntoElement {
let icon_query = use_cached_image(self.icon_url.clone(), 96);
let icon = match loaded_image(self.icon_url.as_deref(), &icon_query) {
Some((url, bytes)) => rect()
.width(Size::px(SKIPPED_ICON))
.height(Size::px(SKIPPED_ICON))
.corner_radius(CornerRadius::new_all(6.))
.overflow(Overflow::Clip)
.child(
ImageViewer::new((url, bytes))
.width(Size::px(SKIPPED_ICON))
.height(Size::px(SKIPPED_ICON))
.aspect_ratio(AspectRatio::Min),
)
.into_element(),
None => Icon::new(IconType::DotsGrid)
.size(16.)
.color(colors::fg_secondary())
.into_element(),
};
rect()
.horizontal()
.width(Size::fill())
.height(Size::px(40.))
.content(Content::Flex)
.cross_align(Alignment::Center)
.spacing(14.)
.padding(Gaps::new_symmetric(0., 12.))
.opacity(0.6)
.child(rect().width(Size::px(ROW_ICON)).center().child(icon))
.child(
label()
.text(self.name.clone())
.font_size(14.)
.font_weight(FontWeight::MEDIUM)
.width(Size::px(SKIPPED_NAME_W))
.max_lines(1)
.color(colors::fg_primary()),
)
.child(
label()
.text(self.reason.clone())
.font_size(12.)
.width(Size::flex(1.))
.max_lines(1)
.color(colors::fg_secondary()),
)
}
}
fn list_message(text: String) -> impl IntoElement {
rect()
.width(Size::fill())
.height(Size::flex(1.))
.center()
.child(
label()
.text(text)
.font_size(13.)
.color(colors::fg_secondary()),
)
}
fn footer(
prompt: &ReleaseMigrationPrompt,
plan: Option<&ReleaseMigrationPlan>,
dispatch: crate::Actions,
excluded: State<HashSet<SelectionKey>>,
copy_configs: State<bool>,
target_running: bool,
) -> impl IntoElement {
let dismiss = dispatch.clone();
let chosen: Vec<ReleaseMigrationPackage> = plan
.map(|plan| {
plan.packages
.iter()
.filter(|package| {
!excluded
.read()
.contains(&selection_key(package, plan.source_cluster_id))
})
.cloned()
.collect()
})
.unwrap_or_default();
let total = plan.map_or(0, |plan| plan.packages.len());
let dependency_bytes: u64 = plan.map_or(0, |plan| {
let selected = selected_hashes(plan, &excluded.read());
plan.dependencies
.iter()
.filter(|dependency| dependency.is_needed_by(&selected))
.map(ReleaseMigrationDependency::size)
.sum()
});
let bytes: u64 = chosen.iter().map(|package| package.size).sum::<u64>() + dependency_bytes;
let count = chosen.len();
let summary = format!(
"{count} of {total} packages selected · {} to copy",
format_size(bytes)
);
let enabled = count > 0 && !target_running;
let mut migrate = Button::new()
.primary()
.enabled(enabled)
.on_press(move |_| {
dispatch.migrate_release_packages(chosen.clone(), *copy_configs.peek());
})
.text("Migrate");
if target_running {
migrate = migrate.tooltip(format!("Close {} before migrating", prompt.target.name));
}
rect()
.horizontal()
.width(Size::fill())
.content(Content::Flex)
.cross_align(Alignment::Center)
.spacing(12.)
.padding(Gaps::new(16., 24., 16., 24.))
.background(FOOTER_BG)
.child(
label()
.text(summary)
.font_size(12.)
.width(Size::flex(1.))
.max_lines(2)
.color(colors::fg_secondary()),
)
.maybe_child(
prompt
.target_dedicated
.then(|| checkbox_labeled(copy_configs, "Copy configs")),
)
.child(
Button::new()
.ghost()
.on_press(move |_| dismiss.dismiss_release_migration())
.text("Not now"),
)
.child(migrate)
}
struct MigrationRow {
name: String,
icon_url: Option<String>,
package: ReleaseMigrationPackage,
selected: bool,
selection: SelectionKey,
excluded: State<HashSet<SelectionKey>>,
key: DiffKey,
}
impl PartialEq for MigrationRow {
fn eq(&self, other: &Self) -> bool {
self.name == other.name
&& self.icon_url == other.icon_url
&& self.package.source_hash == other.package.source_hash
&& self.package.version.version_id == other.package.version.version_id
&& self.package.enabled == other.package.enabled
&& self.selected == other.selected
&& self.selection == other.selection
}
}
impl KeyExt for MigrationRow {
fn write_key(&mut self) -> &mut DiffKey {
&mut self.key
}
}
impl Component for MigrationRow {
fn render(&self) -> impl IntoElement {
let icon_query = use_cached_image(self.icon_url.clone(), 96);
let mut hovered = use_state(|| false);
let mut excluded = self.excluded;
let selection = self.selection.clone();
let selected = self.selected;
let icon = match loaded_image(self.icon_url.as_deref(), &icon_query) {
Some((url, bytes)) => rect()
.width(Size::px(ROW_ICON))
.height(Size::px(ROW_ICON))
.corner_radius(CornerRadius::new_all(8.))
.overflow(Overflow::Clip)
.child(
ImageViewer::new((url, bytes))
.width(Size::px(ROW_ICON))
.height(Size::px(ROW_ICON))
.aspect_ratio(AspectRatio::Min)
.fallback(placeholder_icon()),
)
.into_element(),
None => placeholder_icon().into_element(),
};
let provider = provider_label(self.package.provider);
rect()
.horizontal()
.width(Size::fill())
.height(Size::px(56.))
.content(Content::Flex)
.cross_align(Alignment::Center)
.spacing(14.)
.padding(Gaps::new_symmetric(0., 12.))
.corner_radius(CornerRadius::new_all(10.))
.background(if *hovered.read() {
colors::ghost_overlay()
} else {
Color::TRANSPARENT
})
.cursor(CursorIcon::Pointer)
.on_pointer_enter(move |_| hovered.set(true))
.on_pointer_leave(move |_| hovered.set(false))
.on_press(move |_| {
let mut set = excluded.write();
if selected {
set.insert(selection.clone());
} else {
set.remove(&selection);
}
})
.child(icon)
.child(
rect()
.vertical()
.width(Size::flex(1.))
.spacing(2.)
.child(
label()
.text(self.name.clone())
.font_size(14.)
.font_weight(FontWeight::MEDIUM)
.max_lines(1)
.width(Size::fill())
.color(colors::fg_primary()),
)
.child(
label()
.text(if self.package.enabled {
format!("{provider} · {}", self.package.version_name)
} else {
format!("{provider} · {} · Disabled", self.package.version_name)
})
.font_size(12.)
.max_lines(1)
.width(Size::fill())
.color(colors::fg_secondary()),
),
)
.maybe_child((self.package.size > 0).then(|| {
label()
.text(format_size(self.package.size))
.font_size(12.)
.color(colors::fg_secondary())
}))
.child(check_box(selected))
}
}
fn placeholder_icon() -> impl IntoElement {
rect()
.width(Size::px(ROW_ICON))
.height(Size::px(ROW_ICON))
.center()
.corner_radius(CornerRadius::new_all(8.))
.border(border_all_color(1., colors::component_border()).alignment(BorderAlignment::Inner))
.child(
Icon::new(IconType::DotsGrid)
.size(14.)
.color(colors::fg_secondary()),
)
}
fn check_box(checked: bool) -> impl IntoElement {
rect()
.width(Size::px(CHECK_SIZE))
.height(Size::px(CHECK_SIZE))
.center()
.corner_radius(CornerRadius::new_all(6.))
.background(if checked {
colors::brand()
} else {
Color::TRANSPARENT
})
.border(
border_all_color(
1.,
if checked {
colors::brand()
} else {
colors::component_border()
},
)
.alignment(BorderAlignment::Inner),
)
.maybe_child(checked.then(|| Icon::new(IconType::Check).size(14.).color(Color::WHITE)))
}