diff --git a/packages/oneclient_app/src/components/file_drop.rs b/packages/oneclient_app/src/components/file_drop.rs index c80d4509..181d8d11 100644 --- a/packages/oneclient_app/src/components/file_drop.rs +++ b/packages/oneclient_app/src/components/file_drop.rs @@ -13,7 +13,10 @@ use oneclient_db::models::ClusterId; use crate::Route; use crate::components::{Button, Dropdown, Icon, IconType, OverlayPopup, ScrollArea}; -use crate::hooks::{settled_or_loading, use_active_cluster_id, use_clusters, use_dispatch}; +use crate::hooks::{ + add_world_datapacks, settled_or_loading, spawn_world_task, try_cluster_worlds, + use_active_cluster_id, use_cluster_worlds, use_clusters, use_datapack_world, use_dispatch, +}; use crate::theme::colors; use crate::ui::border_all_color; @@ -23,19 +26,22 @@ const FILE_ROW_H: f32 = 34.; const FILE_LIST_MAX_ROWS: usize = 5; const TYPE_DROPDOWN_W: f32 = 108.; -/// Worlds are directories and modpacks/datapacks have no view so both are excluded -const IMPORTABLE: [ContentType; 3] = [ +/// Worlds are directories and modpacks have no view so both are excluded +const IMPORTABLE: [ContentType; 4] = [ ContentType::Mod, ContentType::ResourcePack, ContentType::Shader, + ContentType::DataPack, ]; const SHADER_HINTS: [&str; 5] = ["shader", "bsl", "seus", "complementary", "sildur"]; +const DATAPACK_HINTS: [&str; 4] = ["datapack", "data pack", "data_pack", "data-pack"]; fn content_label(content_type: ContentType) -> &'static str { match content_type { ContentType::ResourcePack => "Textures", ContentType::Shader => "Shaders", + ContentType::DataPack => "Data packs", _ => "Mods", } } @@ -59,7 +65,9 @@ fn infer_content_type(path: &Path, route_type: Option) -> ContentTy return route_type; } let name = file_name(path).to_lowercase(); - if SHADER_HINTS.iter().any(|hint| name.contains(hint)) { + if DATAPACK_HINTS.iter().any(|hint| name.contains(hint)) { + ContentType::DataPack + } else if SHADER_HINTS.iter().any(|hint| name.contains(hint)) { ContentType::Shader } else { ContentType::ResourcePack @@ -71,6 +79,7 @@ fn route_target(route: &Route) -> Option<(ClusterId, ContentType)> { Route::ClusterMods { cluster_id } => Some((*cluster_id, ContentType::Mod)), Route::ClusterShaders { cluster_id } => Some((*cluster_id, ContentType::Shader)), Route::ClusterTextures { cluster_id } => Some((*cluster_id, ContentType::ResourcePack)), + Route::ClusterDataPacks { cluster_id } => Some((*cluster_id, ContentType::DataPack)), _ => None, } } @@ -191,23 +200,39 @@ impl Component for DropPrompt { let target = route_target(&route); let initial_cluster = target.map(|(id, _)| id).or(*active_cluster.peek()); let selected_cluster = use_state(move || initial_cluster); + let remembered_world = use_datapack_world(); // Absent entries fall back to the inferred type covering drops landing mid-prompt let overrides = use_state(HashMap::::new); let clusters: Vec = settled_or_loading(&clusters_query).unwrap_or_default(); - let body = if clusters.is_empty() { - no_clusters_body(pending) - } else { - prompt_body( + // The remembered cluster may have been deleted while the prompt was open + let cluster_id = selected_cluster + .read() + .filter(|id| clusters.iter().any(|c| c.id == *id)) + .or_else(|| clusters.first().map(|c| c.id)); + + let worlds_query = use_cluster_worlds(cluster_id.unwrap_or_default()); + let worlds: Vec = try_cluster_worlds(&worlds_query) + .unwrap_or_default() + .into_iter() + .map(|w| w.folder_name) + .collect(); + + let body = match cluster_id { + None => no_clusters_body(pending), + Some(cluster_id) => prompt_body( &files, &clusters, + cluster_id, target.map(|(_, ct)| ct), selected_cluster, + &worlds, + remembered_world, overrides, dispatch, pending, - ) + ), }; OverlayPopup::new() @@ -297,20 +322,19 @@ fn no_clusters_body(mut pending: State>) -> Element { .into_element() } +#[allow(clippy::too_many_arguments)] fn prompt_body( files: &[PathBuf], clusters: &[Cluster], + cluster_id: ClusterId, route_type: Option, selected_cluster: State>, + worlds: &[String], + remembered_world: State>, overrides: State>, dispatch: crate::Actions, mut pending: State>, ) -> Element { - // The remembered cluster may have been deleted while the prompt was open - let cluster_id = selected_cluster - .read() - .filter(|id| clusters.iter().any(|c| c.id == *id)) - .unwrap_or(clusters[0].id); let cluster_idx = clusters .iter() .position(|c| c.id == cluster_id) @@ -328,9 +352,39 @@ fn prompt_body( }) .collect(); + let has_datapacks = resolved.iter().any(|(_, ct)| *ct == ContentType::DataPack); + let world = remembered_world + .read() + .get(&cluster_id) + .filter(|name| worlds.contains(name)) + .cloned() + .or_else(|| worlds.first().cloned()); + let can_import = !has_datapacks || world.is_some(); + let import_list = resolved.clone(); + let import_world = world.clone(); let import = move |_| { - dispatch.import_local_files(cluster_id, import_list.clone()); + if !can_import { + return; + } + let (datapacks, packages): (Vec<_>, Vec<_>) = import_list + .iter() + .cloned() + .partition(|(_, ct)| *ct == ContentType::DataPack); + dispatch.import_local_files(cluster_id, packages); + if let Some(world) = import_world.clone() + && !datapacks.is_empty() + { + spawn_world_task( + dispatch.clone(), + "Couldn't add data packs", + add_world_datapacks( + cluster_id, + world, + datapacks.into_iter().map(|(path, _)| path).collect(), + ), + ); + } pending.set(Vec::new()); }; @@ -347,6 +401,10 @@ fn prompt_body( "Pick a cluster, then check what each one gets installed as.".to_string(), )) .child(cluster_field(clusters, cluster_idx, selected_cluster)) + .maybe_child( + has_datapacks + .then(|| world_field(worlds, world.as_deref(), cluster_id, remembered_world)), + ) .child(file_field(&resolved, overrides)) .child( rect() @@ -364,6 +422,7 @@ fn prompt_body( .child( Button::new() .primary() + .enabled(can_import) .on_press(import) .child(Icon::new(IconType::Plus).size(15.)) .text(match files.len() { @@ -415,6 +474,38 @@ fn cluster_field( ) } +fn world_field( + worlds: &[String], + current: Option<&str>, + cluster_id: ClusterId, + mut remembered: State>, +) -> Element { + let Some(current) = current else { + return field( + "Destination world", + label() + .text("This cluster has no worlds yet. Create one in game, then drop the data packs again.") + .font_size(12.) + .color(colors::fg_secondary()), + ) + .into_element(); + }; + + let names = worlds.to_vec(); + field( + "Destination world", + Dropdown::new(current.to_string(), worlds.to_vec()) + .width(Size::fill()) + .height(Size::px(32.)) + .on_select(move |idx: usize| { + if let Some(name) = names.get(idx) { + remembered.write().insert(cluster_id, name.clone()); + } + }), + ) + .into_element() +} + fn file_field( resolved: &[(PathBuf, ContentType)], overrides: State>, diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index f717f985..6b876220 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -83,7 +83,8 @@ pub use optional_mods_popup::OptionalModsPopup; pub use overlay_popup::{OVERLAY_BASE_LEVEL, OVERLAY_MAX_LEVEL, OverlayPopup, overlay_is_topmost}; pub use package_delete_dialog::use_shared_delete; pub(crate) use package_row::{ - CARD_GRID_H, GRID_GAP, GRID_MIN_W, grid_card, package_icon, remote_icon, + CARD_BG, CARD_GRID_H, CARD_H, CARD_NAME, GRID_GAP, GRID_MIN_W, badge, grid_card, icon_box, + kebab_button, meta_size, meta_text, on_secondary, package_icon, remote_icon, }; pub use package_row::{ CardLayout, PackageEntry, PackageRow, github_badge, package_context_menu, provider_badge, diff --git a/packages/oneclient_app/src/components/navbar/app_navbar.rs b/packages/oneclient_app/src/components/navbar/app_navbar.rs index b28a9df5..a39c8ae1 100644 --- a/packages/oneclient_app/src/components/navbar/app_navbar.rs +++ b/packages/oneclient_app/src/components/navbar/app_navbar.rs @@ -157,14 +157,17 @@ fn browse_target() -> Route { let active = *use_active_cluster_id().read(); let package_type = use_browser_type().read().clone(); - 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)); + let cluster = active + .and_then(|id| clusters.iter().find(|cluster| cluster.id == id).cloned()) + .or_else(|| sort_clusters_for_home(clusters).into_iter().next()); - match cluster_id { - Some(cluster_id) => Route::Browser { - cluster_id, - package_type, + match cluster { + Some(cluster) => Route::Browser { + cluster_id: cluster.id, + package_type: crate::view::app::browser::browsable_type( + &package_type, + &cluster.mc_version, + ), pick_cluster: true, }, None => Route::Clusters {}, diff --git a/packages/oneclient_app/src/components/package_row.rs b/packages/oneclient_app/src/components/package_row.rs index c99c332f..56ac5468 100644 --- a/packages/oneclient_app/src/components/package_row.rs +++ b/packages/oneclient_app/src/components/package_row.rs @@ -233,7 +233,9 @@ pub fn package_context_menu( menu } -fn on_secondary(handler: Option>) -> impl FnMut(Event) { +pub(crate) fn on_secondary( + handler: Option>, +) -> impl FnMut(Event) { move |e: Event| { if let (Some(handler), PressEventData::Mouse(m)) = (handler.as_ref(), e.data()) { e.stop_propagation(); @@ -491,7 +493,7 @@ impl Component for SourceLink { } } -fn meta_text(text: String, color: Color) -> Element { +pub(crate) fn meta_text(text: String, color: Color) -> Element { label() .text(text) .font_size(11.) @@ -500,7 +502,7 @@ fn meta_text(text: String, color: Color) -> Element { .into_element() } -fn kebab_button(on_context: EventHandler<(f32, f32)>) -> Element { +pub(crate) fn kebab_button(on_context: EventHandler<(f32, f32)>) -> Element { KebabButton { on_context }.into_element() } @@ -684,7 +686,7 @@ pub(crate) fn remote_icon( } } -fn icon_box(icon: IconType, size: f32) -> Element { +pub(crate) fn icon_box(icon: IconType, size: f32) -> Element { rect() .center() .width(Size::px(size)) @@ -699,7 +701,7 @@ fn icon_box(icon: IconType, size: f32) -> Element { .into_element() } -fn meta_size(size: u64) -> impl IntoElement { +pub(crate) fn meta_size(size: u64) -> impl IntoElement { rect() .maybe_child((size > 0).then(|| { label() @@ -767,7 +769,7 @@ fn local_badge() -> Element { ) } -fn badge(icon: impl IntoElement, text: String) -> Element { +pub(crate) fn badge(icon: impl IntoElement, text: String) -> Element { accent_badge(icon, text, colors::fg_secondary()) } diff --git a/packages/oneclient_app/src/components/scrollview.rs b/packages/oneclient_app/src/components/scrollview.rs index 96045d3c..b9ee19ab 100644 --- a/packages/oneclient_app/src/components/scrollview.rs +++ b/packages/oneclient_app/src/components/scrollview.rs @@ -262,6 +262,61 @@ impl ScrollArea { self } + pub fn lazy_grid( + mut self, + count: usize, + item_height: f32, + gap: f32, + min_width: f32, + max_cols: usize, + render: impl Fn(usize) -> Element + 'static, + ) -> Self { + let slot = (item_height + gap).max(1.); + self.builder = Some(Box::new(move |ctx: ScrollAreaCtx| { + let cols = (((ctx.viewport_w + gap) / (min_width + gap)).floor() as usize) + .clamp(1, max_cols.max(1)); + let rows_total = count.div_ceil(cols); + + let first = + (((-ctx.corrected_y) / slot).floor() as i64 - LAZY_OVERSCAN).max(0) as usize; + let span = ((ctx.viewport_h / slot).ceil() as i64 + 2 * LAZY_OVERSCAN).max(0) as usize; + let last = (first + span).min(rows_total); + + let top_pad = first as f32 * slot; + let bottom_pad = rows_total.saturating_sub(last) as f32 * slot; + + let mut container = rect().vertical().width(Size::fill()); + if top_pad > 0. { + container = container.child(rect().width(Size::fill()).height(Size::px(top_pad))); + } + for r in first..last { + let mut row = rect() + .key(r) + .horizontal() + .width(Size::fill()) + .height(Size::px(slot)) + .spacing(gap) + .content(Content::Flex); + for c in 0..cols { + let idx = r * cols + c; + let cell = rect().width(Size::flex(1.0)).height(Size::px(item_height)); + row = row.child(if idx < count { + cell.child(render(idx)) + } else { + cell + }); + } + container = container.child(row); + } + if bottom_pad > 0. { + container = + container.child(rect().width(Size::fill()).height(Size::px(bottom_pad))); + } + container.into_element() + })); + self + } + #[allow(clippy::too_many_arguments)] pub fn lazy_sections( mut self, diff --git a/packages/oneclient_app/src/hooks/actions.rs b/packages/oneclient_app/src/hooks/actions.rs index ce9be361..d0ef100f 100644 --- a/packages/oneclient_app/src/hooks/actions.rs +++ b/packages/oneclient_app/src/hooks/actions.rs @@ -1063,12 +1063,14 @@ impl Actions { provider: ProviderId, project_id: impl Into, version_id: impl Into, + world: Option, ) { self.start_install( cluster_id, provider, project_id.into(), version_id.into(), + world, false, ); } @@ -1080,6 +1082,7 @@ impl Actions { prompt.provider, prompt.project_id, prompt.version_id, + None, true, ); } @@ -1098,6 +1101,7 @@ impl Actions { provider: ProviderId, project_id: String, version_id: String, + world: Option, allow_flagged: bool, ) { let actions = self.clone(); @@ -1129,6 +1133,7 @@ impl Actions { &project_id, &version_id, cluster_id, + world.clone(), allow_flagged, ) .await @@ -1199,6 +1204,9 @@ impl Actions { } super::invalidate_cluster_content_queries().await; + if let Some(world) = world { + super::invalidate_world_contents(cluster_id, world).await; + } state .services diff --git a/packages/oneclient_app/src/hooks/active_cluster.rs b/packages/oneclient_app/src/hooks/active_cluster.rs index f5aa1838..7f727965 100644 --- a/packages/oneclient_app/src/hooks/active_cluster.rs +++ b/packages/oneclient_app/src/hooks/active_cluster.rs @@ -69,6 +69,17 @@ pub fn use_browser_type() -> State { consume_root_context::().0 } +#[derive(Clone)] +pub struct DataPackWorldState(pub State>); + +pub fn use_provide_datapack_world(state: DataPackWorldState) { + use_hook(move || provide_root_context(state)); +} + +pub fn use_datapack_world() -> State> { + consume_root_context::().0 +} + #[derive(Clone)] pub struct LinkConfirmState(pub State>); diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs index 3c867a8a..e28e2da2 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -15,12 +15,13 @@ pub use view_state::{PersistedView, use_view_state}; pub use active_cluster::{ ActiveClusterState, BROWSER_COMPAT_DEFAULT, BrowserCompatState, BrowserStateStore, - BrowserTypeState, BrowserUiState, LinkConfirmState, OnboardingSelectionState, SplashState, - StartMaximizedState, use_active_cluster_id, use_browser_compat, use_browser_state_store, - use_browser_type, use_link_confirm, use_onboarding_selection, use_provide_active_cluster, + BrowserTypeState, BrowserUiState, DataPackWorldState, LinkConfirmState, + OnboardingSelectionState, SplashState, StartMaximizedState, use_active_cluster_id, + use_browser_compat, use_browser_state_store, use_browser_type, use_datapack_world, + use_link_confirm, use_onboarding_selection, use_provide_active_cluster, use_provide_browser_compat, use_provide_browser_state, use_provide_browser_type, - use_provide_link_confirm, use_provide_onboarding_selection, use_provide_splash, - use_provide_start_maximized, use_splash, use_start_maximized, + use_provide_datapack_world, use_provide_link_confirm, use_provide_onboarding_selection, + use_provide_splash, use_provide_start_maximized, use_splash, use_start_maximized, }; pub use actions::{Actions, NotificationBuilder, PumpSignal}; @@ -62,6 +63,11 @@ pub use queries::{ use_set_default_account, use_storage_action, use_storage_report, use_terms, use_upload_log, use_version_metadata, use_versions, version_list, versions_metadata, versions_total, }; +pub use queries::{ + add_world_datapacks, delete_world, delete_world_datapack, invalidate_world_contents, + spawn_world_task, try_cluster_worlds, try_world_datapacks, try_world_size, use_cluster_worlds, + use_saves_folder_watch, use_world_datapacks, use_world_size, +}; use crate::notifications::NotificationSnapshot; use crate::state::{ diff --git a/packages/oneclient_app/src/hooks/queries/folder_watch.rs b/packages/oneclient_app/src/hooks/queries/folder_watch.rs new file mode 100644 index 00000000..0ab8d590 --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/folder_watch.rs @@ -0,0 +1,115 @@ +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use freya::prelude::{spawn, use_hook}; +use notify::{Event, RecursiveMode, Watcher}; +use tokio::sync::mpsc; + +const WATCH_QUIET: Duration = Duration::from_millis(400); + +pub(super) fn use_folder_watch( + folder: Option, + watch_children: bool, + relevant: fn(&Path, &Event) -> bool, + on_change: impl Fn() + 'static, +) { + use_hook(move || { + let Some(folder) = folder else { + return; + }; + + spawn(async move { + if let Err(err) = watch_folder(&folder, watch_children, relevant, on_change).await { + tracing::warn!( + folder = %folder.display(), + error = %err, + "not watching the folder; the list will refresh on re-entry only" + ); + } + }); + }); +} + +async fn watch_folder( + folder: &Path, + watch_children: bool, + relevant: fn(&Path, &Event) -> bool, + on_change: impl Fn(), +) -> notify::Result<()> { + std::fs::create_dir_all(folder)?; + + let root = folder.to_path_buf(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut watcher = + notify::recommended_watcher(move |event: notify::Result| match event { + Ok(event) if relevant(&root, &event) => { + let _ = tx.send(()); + } + Ok(_) => {} + Err(err) => tracing::debug!(error = %err, "folder watcher reported an error"), + })?; + watcher.watch(folder, RecursiveMode::NonRecursive)?; + + let mut children = HashSet::new(); + if watch_children { + sync_children(&mut watcher, folder, &mut children); + } + + let mut pending = false; + loop { + tokio::select! { + biased; + + event = rx.recv() => { + if event.is_none() { + return Ok(()); + } + pending = true; + } + + () = quiet_period(pending) => { + pending = false; + if watch_children { + sync_children(&mut watcher, folder, &mut children); + } + on_change(); + } + } + } +} + +fn sync_children(watcher: &mut impl Watcher, folder: &Path, watched: &mut HashSet) { + let current: HashSet = std::fs::read_dir(folder) + .map(|entries| { + entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .collect() + }) + .unwrap_or_default(); + + let mut paths = watcher.paths_mut(); + for gone in watched.difference(¤t) { + let _ = paths.remove(gone); + } + for added in current.difference(watched) { + if let Err(err) = paths.add(added, RecursiveMode::NonRecursive) { + tracing::debug!(path = %added.display(), error = %err, "could not watch subfolder"); + } + } + if let Err(err) = paths.commit() { + tracing::debug!(error = %err, "could not update subfolder watches"); + } + + *watched = current; +} + +async fn quiet_period(pending: bool) { + if pending { + tokio::time::sleep(WATCH_QUIET).await; + } else { + std::future::pending::<()>().await; + } +} diff --git a/packages/oneclient_app/src/hooks/queries/mod.rs b/packages/oneclient_app/src/hooks/queries/mod.rs index db5ec729..526a6c36 100644 --- a/packages/oneclient_app/src/hooks/queries/mod.rs +++ b/packages/oneclient_app/src/hooks/queries/mod.rs @@ -15,6 +15,7 @@ mod bundles; mod changelog; mod cluster_content; mod clusters; +mod folder_watch; mod image; mod java; mod logs; @@ -31,6 +32,7 @@ mod storage; mod tos; mod version_metadata; mod versions; +mod worlds; pub use analytics::{ try_cluster_analytics, try_global_analytics, use_cluster_analytics, use_global_analytics, @@ -99,3 +101,8 @@ pub use storage::{ pub use tos::{TermsQuery, terms_document, terms_error, terms_is_loading, use_terms}; pub use version_metadata::{pick_version_metadata, use_version_metadata}; pub use versions::{loader_versions, use_loader_versions, use_versions, versions_metadata}; +pub use worlds::{ + add_world_datapacks, delete_world, delete_world_datapack, invalidate_world_contents, + spawn_world_task, try_cluster_worlds, try_world_datapacks, try_world_size, use_cluster_worlds, + use_saves_folder_watch, use_world_datapacks, use_world_size, +}; diff --git a/packages/oneclient_app/src/hooks/queries/packages.rs b/packages/oneclient_app/src/hooks/queries/packages.rs index 548276b9..b1c23cbd 100644 --- a/packages/oneclient_app/src/hooks/queries/packages.rs +++ b/packages/oneclient_app/src/hooks/queries/packages.rs @@ -19,6 +19,7 @@ pub fn content_type_for_slug(slug: &str) -> ContentType { match slug { "shader" => ContentType::Shader, "texture" => ContentType::ResourcePack, + "datapack" => ContentType::DataPack, _ => ContentType::Mod, } } diff --git a/packages/oneclient_app/src/hooks/queries/screenshots.rs b/packages/oneclient_app/src/hooks/queries/screenshots.rs index 29625df6..6a599a08 100644 --- a/packages/oneclient_app/src/hooks/queries/screenshots.rs +++ b/packages/oneclient_app/src/hooks/queries/screenshots.rs @@ -1,17 +1,15 @@ use std::ffi::OsStr; use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock}; -use std::time::Duration; use bytes::Bytes; -use freya::prelude::{spawn, use_hook}; use freya::query::{ Mutation, MutationCapability, QueriesStorage, Query, QueryCapability, UseMutation, UseQuery, use_mutation, use_query, }; -use notify::{EventKind, RecursiveMode, Watcher}; +use notify::{Event, EventKind}; use oneclient_core::{LauncherError, ScreenshotInfo}; -use tokio::sync::{Semaphore, mpsc}; +use tokio::sync::Semaphore; static LOCAL_IMAGE_SEMAPHORE: OnceLock> = OnceLock::new(); @@ -88,76 +86,14 @@ pub fn use_local_image(path: PathBuf, max_edge: u32) -> UseQuery, query: UseQuery, ) { - use_hook(move || { - let Some(folder) = folder else { - return; - }; - - spawn(async move { - if let Err(err) = watch_folder(&folder, query).await { - tracing::warn!( - folder = %folder.display(), - error = %err, - "not watching the screenshot folder; the list will refresh on re-entry only" - ); - } - }); - }); + super::folder_watch::use_folder_watch(folder, false, touches_image, move || query.invalidate()); } -async fn watch_folder( - folder: &Path, - query: UseQuery, -) -> notify::Result<()> { - // If there is no screenshots folder it creates it - std::fs::create_dir_all(folder)?; - - let (tx, mut rx) = mpsc::unbounded_channel(); - let mut watcher = - notify::recommended_watcher(move |event: notify::Result| match event { - Ok(event) if touches_image(&event) => { - let _ = tx.send(()); - } - Ok(_) => {} - Err(err) => tracing::debug!(error = %err, "screenshot watcher reported an error"), - })?; - watcher.watch(folder, RecursiveMode::NonRecursive)?; - - let mut pending = false; - loop { - tokio::select! { - biased; - - event = rx.recv() => { - if event.is_none() { - return Ok(()); - } - pending = true; - } - - () = quiet_period(pending) => { - pending = false; - query.invalidate(); - } - } - } -} - -async fn quiet_period(pending: bool) { - if pending { - tokio::time::sleep(WATCH_QUIET).await; - } else { - std::future::pending::<()>().await; - } -} - -fn touches_image(event: ¬ify::Event) -> bool { +fn touches_image(_root: &Path, event: &Event) -> bool { if matches!(event.kind, EventKind::Access(_)) { return false; } diff --git a/packages/oneclient_app/src/hooks/queries/worlds.rs b/packages/oneclient_app/src/hooks/queries/worlds.rs new file mode 100644 index 00000000..39643b65 --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/worlds.rs @@ -0,0 +1,199 @@ +use std::future::Future; +use std::path::{Path, PathBuf}; + +use freya::prelude::spawn_forever; +use freya::query::{QueriesStorage, Query, QueryCapability, UseQuery, use_query}; +use notify::{Event, EventKind}; +use oneclient_core::{ClusterError, DataPackInfo, LEVEL_DAT, LauncherError, WORLD_ICON, WorldInfo}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ClusterWorldsKeys { + pub cluster_id: i64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ClusterWorldsQuery; + +impl QueryCapability for ClusterWorldsQuery { + type Ok = Vec; + type Err = LauncherError; + type Keys = ClusterWorldsKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let cluster = state.clusters.get(keys.cluster_id).await?; + run_blocking(move || oneclient_core::list_cluster_worlds(&cluster)).await + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct WorldSizeKeys { + pub cluster_id: i64, + pub world: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct WorldSizeQuery(WorldSizeKeys); + +impl QueryCapability for WorldSizeQuery { + type Ok = u64; + type Err = LauncherError; + type Keys = WorldSizeKeys; + + fn matches(&self, keys: &Self::Keys) -> bool { + self.0 == *keys + } + + async fn run(&self, keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let cluster = state.clusters.get(keys.cluster_id).await?; + let world = keys.world.clone(); + run_blocking(move || oneclient_core::world_size(&cluster, &world)).await + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct WorldDataPacksKeys { + pub cluster_id: i64, + pub world: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct WorldDataPacksQuery; + +impl QueryCapability for WorldDataPacksQuery { + type Ok = Vec; + type Err = LauncherError; + type Keys = WorldDataPacksKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + if keys.world.is_empty() { + return Ok(Vec::new()); + } + let state = crate::launcher::state()?; + let cluster = state.clusters.get(keys.cluster_id).await?; + let world = keys.world.clone(); + Ok(tokio::spawn( + async move { oneclient_core::list_world_datapacks(&cluster, &world).await }, + ) + .await + .map_err(std::io::Error::other)??) + } +} + +pub fn use_cluster_worlds(cluster_id: i64) -> UseQuery { + use_query(Query::new( + ClusterWorldsKeys { cluster_id }, + ClusterWorldsQuery, + )) +} + +pub fn use_world_size(cluster_id: i64, world: String) -> UseQuery { + let keys = WorldSizeKeys { cluster_id, world }; + use_query(Query::new(keys.clone(), WorldSizeQuery(keys))) +} + +pub fn try_world_size(query: &UseQuery) -> Option { + super::state::settled_or_loading(query) +} + +pub fn use_world_datapacks(cluster_id: i64, world: String) -> UseQuery { + use_query(Query::new( + WorldDataPacksKeys { cluster_id, world }, + WorldDataPacksQuery, + )) +} + +pub fn use_saves_folder_watch(folder: Option, query: UseQuery) { + super::folder_watch::use_folder_watch(folder, true, touches_world_list, move || { + query.invalidate() + }); +} + +fn touches_world_list(saves: &Path, event: &Event) -> bool { + if matches!(event.kind, EventKind::Access(_)) { + return false; + } + + event.paths.iter().any(|path| { + let parent = path.parent(); + parent == Some(saves) + || (parent.and_then(Path::parent) == Some(saves) + && path + .file_name() + .is_some_and(|name| name == LEVEL_DAT || name == WORLD_ICON)) + }) +} + +pub fn try_cluster_worlds(query: &UseQuery) -> Option> { + super::state::settled_or_loading(query) +} + +pub fn try_world_datapacks(query: &UseQuery) -> Option> { + super::state::settled_or_loading(query) +} + +pub fn spawn_world_task( + dispatch: crate::Actions, + failure_title: &'static str, + task: impl Future> + 'static, +) { + spawn_forever(async move { + if let Err(err) = task.await { + dispatch + .notify(failure_title) + .body(err.to_string()) + .error() + .send(); + } + }); +} + +pub async fn invalidate_world_contents(cluster_id: i64, world: String) { + QueriesStorage::::invalidate_all().await; + QueriesStorage::::invalidate_matching(WorldSizeKeys { cluster_id, world }) + .await; +} + +async fn run_blocking( + task: impl FnOnce() -> Result + Send + 'static, +) -> Result { + Ok(tokio::task::spawn_blocking(task) + .await + .map_err(std::io::Error::other)??) +} + +pub async fn add_world_datapacks( + cluster_id: i64, + world: String, + files: Vec, +) -> Result<(), LauncherError> { + let state = crate::launcher::state()?; + let cluster = state.clusters.get(cluster_id).await?; + let result = oneclient_core::add_world_datapacks(&cluster, &world, &files).await; + invalidate_world_contents(cluster_id, world).await; + Ok(result?) +} + +pub async fn delete_world(cluster_id: i64, world: String) -> Result<(), LauncherError> { + let state = crate::launcher::state()?; + let cluster = state.clusters.get(cluster_id).await?; + let result = run_blocking(move || oneclient_core::delete_world(&cluster, &world)).await; + QueriesStorage::::invalidate_all().await; + result +} + +pub async fn delete_world_datapack( + cluster_id: i64, + world: String, + file_name: String, +) -> Result<(), LauncherError> { + let state = crate::launcher::state()?; + let cluster = state.clusters.get(cluster_id).await?; + let target = world.clone(); + let result = + run_blocking(move || oneclient_core::delete_world_datapack(&cluster, &target, &file_name)) + .await; + invalidate_world_contents(cluster_id, world).await; + result +} diff --git a/packages/oneclient_app/src/install.rs b/packages/oneclient_app/src/install.rs index a70bf606..10446150 100644 --- a/packages/oneclient_app/src/install.rs +++ b/packages/oneclient_app/src/install.rs @@ -292,6 +292,7 @@ pub async fn install_package( project_id: &str, version_id: &str, cluster_id: i64, + world: Option, allow_flagged: bool, ) -> Result { let lookup = async { @@ -311,6 +312,14 @@ pub async fn install_package( Err(err) => return Ok(PackageInstall::failed(err)), }; + if let Some(world) = world { + let project = oneclient_content::packages::types::ProjectDetail { + content_type: oneclient_content::packages::ContentType::DataPack, + ..project + }; + return Ok(install_datapack(state, provider, &project, &version, cluster_id, world).await); + } + let content = state.services.content(); let bad_mods = oneclient_content::packages::load_bad_mods(&content).await; @@ -449,6 +458,62 @@ pub async fn install_package( }) } +async fn install_datapack( + state: &Arc, + provider: oneclient_content::packages::ProviderId, + project: &oneclient_content::packages::types::ProjectDetail, + version: &oneclient_content::packages::types::VersionDetail, + cluster_id: i64, + world: String, +) -> PackageInstall { + let Some(file) = version.primary_file() else { + return PackageInstall::failed(anyhow::anyhow!("This version has no file to download")); + }; + if !file.file_name.to_lowercase().ends_with(".zip") { + return PackageInstall::failed(anyhow::anyhow!( + "This version is a mod, not a data pack. Pick a .zip version instead" + )); + } + + let session = oneclient_events::GroupedProgressSession::start( + &state.services.events, + format!("Installing {}", project.name), + ); + session.expect(oneclient_events::TaskCategory::Packages, 1, file.size); + let child = session.child( + project.name.clone(), + file.size, + oneclient_events::TaskCategory::Packages, + ); + + let result = async { + let artifact = PackageStore::download_and_cache( + provider, + project, + version, + false, + Some(&child), + &state.services.content(), + ) + .await?; + let path = oneclient_content::packages::store::artifact_absolute_path(&artifact.path)?; + let cluster = state.clusters.get(cluster_id).await?; + oneclient_core::add_world_datapacks(&cluster, &world, &[path]).await?; + anyhow::Ok(()) + } + .await; + + child.finish(); + + PackageInstall { + session_id: Some(session.detach()), + result: result.map(|()| format!("{} to {world}", project.name)), + dependencies: Vec::new(), + missing_dependencies: Vec::new(), + live_deferred: false, + } +} + async fn mark_new(cluster_id: i64, hash: &str, state: &LauncherState) { if let Err(err) = PackageStore::mark_artifact_new(cluster_id, hash, &state.services.content()).await diff --git a/packages/oneclient_app/src/layout/app_shell.rs b/packages/oneclient_app/src/layout/app_shell.rs index e03a3e0e..da8d6f24 100644 --- a/packages/oneclient_app/src/layout/app_shell.rs +++ b/packages/oneclient_app/src/layout/app_shell.rs @@ -22,9 +22,9 @@ use oneclient_db::models::ClusterId; use crate::hooks::{ ActiveClusterState, BROWSER_COMPAT_DEFAULT, BrowserCompatState, BrowserStateStore, - BrowserTypeState, use_active_cluster_id, use_clusters, use_game_snapshot, use_launcher, - use_provide_active_cluster, use_provide_browser_compat, use_provide_browser_state, - use_provide_browser_type, use_splash, + BrowserTypeState, DataPackWorldState, use_active_cluster_id, use_clusters, use_game_snapshot, + use_launcher, use_provide_active_cluster, use_provide_browser_compat, + use_provide_browser_state, use_provide_browser_type, use_provide_datapack_world, use_splash, }; use crate::theme::colors; use oneclient_events::LaunchStage; @@ -48,6 +48,9 @@ impl Component for AppShell { let browser_type = use_state(|| "mod".to_string()); use_provide_browser_type(BrowserTypeState(browser_type)); + let datapack_world = use_state(HashMap::new); + use_provide_datapack_world(DataPackWorldState(datapack_world)); + // `FileDrop` bubbles so anything a drop zone doesn't `stop_propagation()` lands here let mut drop_hovering = use_state(|| false); let mut drop_pending = use_state(Vec::::new); diff --git a/packages/oneclient_app/src/layout/cluster_shell.rs b/packages/oneclient_app/src/layout/cluster_shell.rs index cf62ae64..754d1dc1 100644 --- a/packages/oneclient_app/src/layout/cluster_shell.rs +++ b/packages/oneclient_app/src/layout/cluster_shell.rs @@ -25,6 +25,8 @@ pub enum ClusterViewShellTab { Mods, Shaders, Textures, + Worlds, + DataPacks, Settings, } @@ -38,6 +40,8 @@ impl ClusterViewShellTab { Self::Mods => "Mods", Self::Shaders => "Shaders", Self::Textures => "Textures", + Self::Worlds => "Worlds", + Self::DataPacks => "Data packs", Self::Settings => "Settings", } } @@ -51,6 +55,8 @@ impl ClusterViewShellTab { Self::Mods => Some(Route::ClusterMods { cluster_id }), Self::Shaders => Some(Route::ClusterShaders { cluster_id }), Self::Textures => Some(Route::ClusterTextures { cluster_id }), + Self::Worlds => Some(Route::ClusterWorlds { cluster_id }), + Self::DataPacks => Some(Route::ClusterDataPacks { cluster_id }), Self::Settings => Some(Route::ClusterSettings { cluster_id }), } } @@ -63,8 +69,10 @@ impl ClusterViewShellTab { Self::Mods => 3, Self::Shaders => 4, Self::Textures => 5, - Self::Settings => 6, - Self::GameLog => 7, + Self::Worlds => 6, + Self::DataPacks => 7, + Self::Settings => 8, + Self::GameLog => 9, } } } @@ -84,6 +92,8 @@ fn route_cluster(route: &Route) -> Option<(i64, ClusterViewShellTab)> { Route::ClusterMods { cluster_id } => (*cluster_id, ClusterViewShellTab::Mods), Route::ClusterShaders { cluster_id } => (*cluster_id, ClusterViewShellTab::Shaders), Route::ClusterTextures { cluster_id } => (*cluster_id, ClusterViewShellTab::Textures), + Route::ClusterWorlds { cluster_id } => (*cluster_id, ClusterViewShellTab::Worlds), + Route::ClusterDataPacks { cluster_id } => (*cluster_id, ClusterViewShellTab::DataPacks), Route::ClusterSettings { cluster_id } => (*cluster_id, ClusterViewShellTab::Settings), _ => return None, }) @@ -105,6 +115,9 @@ impl Component for ClusterShell { let cluster = use_cluster(cluster_id); let show_game_log = game.is_active(cluster_id); + let show_datapacks = cluster + .as_ref() + .is_none_or(|c| crate::view::app::cluster::supports_datapacks(&c.mc_version)); let launch_state = launch_button_state(&game, cluster_id, syncing); // Queried unconditionally the shell can mount before the cluster list settles @@ -152,7 +165,12 @@ impl Component for ClusterShell { )) .spacing(BAR_SPACING) .maybe_child(header) - .child(cluster_tabs(active_tab, cluster_id, show_game_log)), + .child(cluster_tabs( + active_tab, + cluster_id, + show_game_log, + show_datapacks, + )), ) .child( rect() @@ -302,6 +320,7 @@ fn cluster_tabs( active: ClusterViewShellTab, cluster_id: i64, show_game_log: bool, + show_datapacks: bool, ) -> impl IntoElement { let tabs = [ Some(ClusterViewShellTab::Overview), @@ -310,6 +329,8 @@ fn cluster_tabs( Some(ClusterViewShellTab::Mods), Some(ClusterViewShellTab::Shaders), Some(ClusterViewShellTab::Textures), + Some(ClusterViewShellTab::Worlds), + show_datapacks.then_some(ClusterViewShellTab::DataPacks), Some(ClusterViewShellTab::Settings), show_game_log.then_some(ClusterViewShellTab::GameLog), ]; diff --git a/packages/oneclient_app/src/routes.rs b/packages/oneclient_app/src/routes.rs index 08c772ef..e77225ce 100644 --- a/packages/oneclient_app/src/routes.rs +++ b/packages/oneclient_app/src/routes.rs @@ -13,8 +13,8 @@ use crate::view::{ AccountSkins, Clusters, Debug, Home, Stats, browser::{Browser, BrowserPackage}, cluster::{ - ClusterLogs, ClusterMods, ClusterOverview, ClusterScreenshots, ClusterSettings, - ClusterShaders, ClusterTextures, ProcessLogs, + ClusterDataPacks, ClusterLogs, ClusterMods, ClusterOverview, ClusterScreenshots, + ClusterSettings, ClusterShaders, ClusterTextures, ClusterWorlds, ProcessLogs, }, settings::{ SettingsAccounts, SettingsApis, SettingsAppearance, SettingsChangelog, @@ -80,6 +80,10 @@ pub enum Route { ClusterShaders { cluster_id: i64 }, #[route("/app/clusters/:cluster_id/textures")] ClusterTextures { cluster_id: i64 }, + #[route("/app/clusters/:cluster_id/worlds")] + ClusterWorlds { cluster_id: i64 }, + #[route("/app/clusters/:cluster_id/datapacks")] + ClusterDataPacks { cluster_id: i64 }, #[route("/app/clusters/:cluster_id/settings")] ClusterSettings { cluster_id: i64 }, #[end_layout] @@ -148,6 +152,8 @@ impl Route { Route::ClusterMods { .. } => "Cluster Mods".to_string(), Route::ClusterShaders { .. } => "Cluster Shaders".to_string(), Route::ClusterTextures { .. } => "Cluster Textures".to_string(), + Route::ClusterWorlds { .. } => "Cluster Worlds".to_string(), + Route::ClusterDataPacks { .. } => "Cluster Data Packs".to_string(), Route::ClusterSettings { .. } => "Cluster Settings".to_string(), Route::Browser { .. } => "Browser".to_string(), Route::BrowserPackage { .. } => "Browser".to_string(), diff --git a/packages/oneclient_app/src/view/app/browser/index/cards.rs b/packages/oneclient_app/src/view/app/browser/index/cards.rs index 9d8d4c6b..bee8910f 100644 --- a/packages/oneclient_app/src/view/app/browser/index/cards.rs +++ b/packages/oneclient_app/src/view/app/browser/index/cards.rs @@ -362,7 +362,9 @@ impl Component for InstallButton { loader, 0, )); - let latest = versions.first().map(|v| v.version_id.clone()); + let latest = preferred_version(&versions, self.content_type).map(|v| v.version_id.clone()); + let is_datapack = self.content_type == ContentType::DataPack; + let mut world_prompt = use_state(|| None::); // Nothing to start twice while an install is running or before versions arrive let installing = use_installs_snapshot().is_installing(cluster_id, provider, &project_id); @@ -400,14 +402,23 @@ impl Component for InstallButton { .height(Size::px(INSTALL_BUTTON_H)) .padding(Gaps::new_symmetric(0., 11.)) .enabled(latest.is_some() && !installing) - .on_press(move |_| { - if let Some(version_id) = latest.clone() { - dispatch.install_package( - cluster_id, - provider, - project_id.clone(), - version_id, - ); + .on_press({ + let project_id = project_id.clone(); + move |_| { + let Some(version_id) = latest.clone() else { + return; + }; + if is_datapack { + world_prompt.set(Some(version_id)); + } else { + dispatch.install_package( + cluster_id, + provider, + project_id.clone(), + version_id, + None, + ); + } } }) .child( @@ -428,6 +439,12 @@ impl Component for InstallButton { .color(colors::fg_primary()), ), ) + .maybe_child(world_prompt.read().is_some().then_some(WorldInstallPrompt { + cluster_id, + provider, + project_id, + pending: world_prompt, + })) .into_element() } } diff --git a/packages/oneclient_app/src/view/app/browser/index/mod.rs b/packages/oneclient_app/src/view/app/browser/index/mod.rs index bc485053..715732e2 100644 --- a/packages/oneclient_app/src/view/app/browser/index/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/index/mod.rs @@ -28,8 +28,12 @@ use crate::hooks::{ use crate::routes::Route; use crate::theme::colors; use crate::ui::grid_columns_for_width; +use crate::view::app::cluster::supports_datapacks; -use super::{InstallSource, Installed, PackageBanner, Thumbnail, installed_map}; +use super::{ + InstallSource, Installed, PackageBanner, Thumbnail, WorldInstallPrompt, installed_map, + preferred_version, +}; use crate::utils::{abbreviate_number, sort_clusters_for_home}; mod cards; @@ -63,12 +67,23 @@ const SORTS: [(SearchSort, &str); 4] = [ (SearchSort::Updated, "Updated"), ]; -const BROWSE_TYPES: [(&str, &str); 3] = [ +const BROWSE_TYPES: [(&str, &str); 4] = [ ("mod", "Mods"), ("texture", "Textures"), ("shader", "Shaders"), + ("datapack", "Data packs"), ]; +const DATAPACK_SLUG: &str = "datapack"; + +pub(crate) fn browsable_type(package_type: &str, mc_version: &str) -> String { + if package_type == DATAPACK_SLUG && !supports_datapacks(mc_version) { + BROWSE_TYPES[0].0.to_string() + } else { + package_type.to_string() + } +} + fn type_title(package_type: &str) -> &'static str { BROWSE_TYPES .iter() @@ -208,6 +223,11 @@ impl Component for BrowserBody { cluster_content_items(&use_cluster_content(cluster_id, content_type)), &bundles_with_status_items(&use_bundles_with_status(cluster_id)), ); + let installed = if content_type == ContentType::DataPack { + Default::default() + } else { + installed + }; let packages = search_items(&search); let total = search_total(&search); @@ -565,8 +585,13 @@ impl Component for TypePicker { let cluster_id = self.cluster_id; let pick_cluster = self.pick_cluster; let current = self.package_type.clone(); + let datapacks = use_cluster(cluster_id).is_none_or(|c| supports_datapacks(&c.mc_version)); - let labels: Vec = BROWSE_TYPES + let types: Vec<(&str, &str)> = BROWSE_TYPES + .into_iter() + .filter(|(slug, _)| datapacks || *slug != DATAPACK_SLUG) + .collect(); + let labels: Vec = types .iter() .map(|(_, title)| (*title).to_string()) .collect(); @@ -576,7 +601,7 @@ impl Component for TypePicker { .width(Size::px(CATEGORY_SIDEBAR_W)) .height(Size::px(30.)) .on_select(move |idx: usize| { - let picked = BROWSE_TYPES.get(idx).filter(|(slug, _)| *slug != current); + let picked = types.get(idx).filter(|(slug, _)| *slug != current); if let Some((slug, _)) = picked { let _ = RouterContext::get().push(Route::Browser { cluster_id, @@ -606,6 +631,7 @@ impl Component for ClusterPicker { .map(|c| format!("{} · {}", c.name, version_name(&metadata, c))) .collect(); let ids: Vec = clusters.iter().map(|c| c.id).collect(); + let versions: Vec = clusters.iter().map(|c| c.mc_version.clone()).collect(); let selected = ids .iter() @@ -625,10 +651,12 @@ impl Component for ClusterPicker { .width(Size::px(240.)) .height(Size::px(28.)) .on_select(move |idx: usize| { - if let Some(cluster_id) = ids.get(idx).copied() { + if let (Some(cluster_id), Some(mc_version)) = + (ids.get(idx).copied(), versions.get(idx)) + { let _ = RouterContext::get().replace(Route::Browser { cluster_id, - package_type: package_type.clone(), + package_type: browsable_type(&package_type, mc_version), pick_cluster: true, }); } diff --git a/packages/oneclient_app/src/view/app/browser/mod.rs b/packages/oneclient_app/src/view/app/browser/mod.rs index ae4a90cb..f1814048 100644 --- a/packages/oneclient_app/src/view/app/browser/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/mod.rs @@ -2,7 +2,25 @@ mod index; mod package; pub use index::Browser; -pub(crate) use index::encode_package_id; +pub(crate) use index::{browsable_type, encode_package_id}; + +mod world_prompt; +use world_prompt::WorldInstallPrompt; + +/// Projects shipping both a mod and a data pack tag the mod files with a loader +fn preferred_version( + versions: &[oneclient_content::packages::types::VersionSummary], + content_type: oneclient_content::packages::ContentType, +) -> Option<&oneclient_content::packages::types::VersionSummary> { + if content_type == oneclient_content::packages::ContentType::DataPack { + versions + .iter() + .find(|v| v.loaders.is_empty()) + .or_else(|| versions.first()) + } else { + versions.first() + } +} pub use package::BrowserPackage; use std::collections::HashMap; diff --git a/packages/oneclient_app/src/view/app/browser/package/mod.rs b/packages/oneclient_app/src/view/app/browser/package/mod.rs index a559ffad..31520680 100644 --- a/packages/oneclient_app/src/view/app/browser/package/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/package/mod.rs @@ -13,8 +13,8 @@ use crate::theme::colors; use crate::ui::border_all_color; use super::{ - Installed, InstalledVersion, PackageBanner, Thumbnail, activity_badge, installed_badge, - installed_map, + Installed, InstalledVersion, PackageBanner, Thumbnail, WorldInstallPrompt, activity_badge, + installed_badge, installed_map, preferred_version, }; use crate::utils::abbreviate_number; @@ -29,6 +29,29 @@ const PANEL_BG: Color = Color::from_rgb(21, 28, 34); const SIDEBAR_W: f32 = 280.; const SCROLLBAR_GUTTER: f32 = 18.; +#[derive(Clone)] +struct Installer { + dispatch: crate::Actions, + cluster_id: i64, + provider: ProviderId, + world_prompt: Option>>, +} + +impl Installer { + fn install(&self, project_id: String, version_id: String) { + match self.world_prompt { + Some(mut prompt) => prompt.set(Some(version_id)), + None => self.dispatch.install_package( + self.cluster_id, + self.provider, + project_id, + version_id, + None, + ), + } + } +} + fn decode_package_id(package_id: &str) -> (ProviderId, String) { match package_id.split_once(':') { Some((repr, id)) => { @@ -110,6 +133,14 @@ impl Component for BrowserPackage { let compatible_only = use_browser_compat(); let dispatch = use_dispatch(); let confirm = use_link_confirm(); + let world_prompt = use_state(|| None::); + let is_datapack = content_type == ContentType::DataPack; + let installer = Installer { + dispatch: dispatch.clone(), + cluster_id, + provider, + world_prompt: is_datapack.then_some(world_prompt), + }; let cluster = use_cluster(cluster_id); let compat = *compatible_only.read(); @@ -145,7 +176,8 @@ impl Component for BrowserPackage { 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())); + .remove(&(provider, project_id.clone())) + .filter(|_| !is_datapack); let project = project_detail(&project_query); @@ -165,7 +197,8 @@ impl Component for BrowserPackage { let versions = version_list(&versions_query); let total_versions = versions_total(&versions_query); - let latest_version = versions.first().map(|v| v.version_id.clone()); + let latest_version = + preferred_version(&versions, content_type).map(|v| v.version_id.clone()); let gallery = project .as_ref() @@ -182,10 +215,8 @@ impl Component for BrowserPackage { versions, total_versions, versions_page, - provider, project.id.clone(), - cluster_id, - dispatch.clone(), + installer.clone(), on_remove, installed.clone(), installing, @@ -203,9 +234,7 @@ impl Component for BrowserPackage { .child(sidebar( project, latest_version, - provider, - cluster_id, - dispatch, + installer, confirm, installed, installing, @@ -234,6 +263,12 @@ impl Component for BrowserPackage { .children([row]), ) .maybe_child(remove_dialog) + .maybe_child(world_prompt.read().is_some().then(|| WorldInstallPrompt { + cluster_id, + provider, + project_id: project_id.clone(), + pending: world_prompt, + })) .into_element() } } diff --git a/packages/oneclient_app/src/view/app/browser/package/panels.rs b/packages/oneclient_app/src/view/app/browser/package/panels.rs index 26fec0c1..42400e6b 100644 --- a/packages/oneclient_app/src/view/app/browser/package/panels.rs +++ b/packages/oneclient_app/src/view/app/browser/package/panels.rs @@ -1,10 +1,8 @@ use super::*; -use oneclient_content::packages::ProviderId; use oneclient_content::packages::markdown::normalize_markdown; use oneclient_content::packages::types::{PackageBody, ProjectDetail, ReleaseType, VersionSummary}; -use crate::Actions; use crate::components::{ Button, Icon, IconType, Markdown, MarkdownStyle, Segment, SegmentedControl, }; @@ -107,10 +105,8 @@ pub(super) fn versions_panel( versions: Vec, total_versions: usize, versions_page: State, - provider: ProviderId, project_id: String, - cluster_id: i64, - dispatch: Actions, + installer: Installer, on_remove: EventHandler<(String, String)>, installed: Option, installing: bool, @@ -145,10 +141,8 @@ pub(super) fn versions_panel( .is_some_and(|installed| installed.is_duplicated()); version_row( v, - provider, project_id.clone(), - cluster_id, - dispatch.clone(), + installer.clone(), on_remove.clone(), tag, duplicated, @@ -217,13 +211,10 @@ fn version_pager(current: usize, total_pages: usize, page: State) -> impl .into_element() } -#[allow(clippy::too_many_arguments)] fn version_row( v: VersionSummary, - provider: ProviderId, project_id: String, - cluster_id: i64, - dispatch: Actions, + installer: Installer, on_remove: EventHandler<(String, String)>, installed: Option, // Saying which version is live only tells the user anything when there are several @@ -288,21 +279,17 @@ fn version_row( .map(|installed| activity_badge(installed.enabled).into_element()), ) .child(version_button( - installed, v.name, provider, project_id, version_id, cluster_id, dispatch, on_remove, - installing, + installed, v.name, project_id, version_id, installer, on_remove, installing, )) } /// A bundle pin with nothing linked leaves nothing to press no artifact to remove and installing by hand would duplicate it -#[allow(clippy::too_many_arguments)] fn version_button( installed: Option, version_name: String, - provider: ProviderId, project_id: String, version_id: String, - cluster_id: i64, - dispatch: Actions, + installer: Installer, on_remove: EventHandler<(String, String)>, busy: bool, ) -> impl IntoElement { @@ -311,14 +298,7 @@ fn version_button( .secondary() .small() .enabled(!busy) - .on_press(move |_| { - dispatch.install_package( - cluster_id, - provider, - project_id.clone(), - version_id.clone(), - ); - }) + .on_press(move |_| installer.install(project_id.clone(), version_id.clone())) .text("Install"); }; diff --git a/packages/oneclient_app/src/view/app/browser/package/sidebar.rs b/packages/oneclient_app/src/view/app/browser/package/sidebar.rs index bf3c4f83..35cae9cc 100644 --- a/packages/oneclient_app/src/view/app/browser/package/sidebar.rs +++ b/packages/oneclient_app/src/view/app/browser/package/sidebar.rs @@ -3,18 +3,14 @@ use super::*; use oneclient_content::packages::ProviderId; use oneclient_content::packages::types::{ProjectDetail, ProjectMember}; -use crate::Actions; use crate::components::{Button, Icon, IconType}; use crate::theme::colors; use crate::ui::border_all_color; -#[allow(clippy::too_many_arguments)] pub(super) fn sidebar( project: Option, latest_version: Option, - provider: ProviderId, - cluster_id: i64, - dispatch: Actions, + installer: Installer, confirm: State>, installed: Option, installing: bool, @@ -111,12 +107,7 @@ pub(super) fn sidebar( .enabled(can_install) .on_press(move |_| { if let Some(version_id) = latest_version.clone() { - dispatch.install_package( - cluster_id, - provider, - project_id.clone(), - version_id, - ); + installer.install(project_id.clone(), version_id); } }) .child(Icon::new(IconType::Download01).size(14.)) diff --git a/packages/oneclient_app/src/view/app/browser/world_prompt.rs b/packages/oneclient_app/src/view/app/browser/world_prompt.rs new file mode 100644 index 00000000..50261502 --- /dev/null +++ b/packages/oneclient_app/src/view/app/browser/world_prompt.rs @@ -0,0 +1,109 @@ +use freya::prelude::*; +use oneclient_content::packages::ProviderId; + +use crate::components::{Button, Dropdown, Icon, IconType}; +use crate::hooks::{ + query_is_loading, try_cluster_worlds, use_cluster_worlds, use_datapack_world, use_dispatch, +}; +use crate::theme::colors; +use crate::view::app::cluster::dialog; + +#[derive(PartialEq)] +pub(crate) struct WorldInstallPrompt { + pub cluster_id: i64, + pub provider: ProviderId, + pub project_id: String, + pub pending: State>, +} + +impl Component for WorldInstallPrompt { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let provider = self.provider; + let project_id = self.project_id.clone(); + let mut pending = self.pending; + + let dispatch = use_dispatch(); + let mut remembered = use_datapack_world(); + let worlds_query = use_cluster_worlds(cluster_id); + let names: Vec = try_cluster_worlds(&worlds_query) + .unwrap_or_default() + .into_iter() + .map(|w| w.folder_name) + .collect(); + + let world = remembered + .read() + .get(&cluster_id) + .filter(|name| names.contains(name)) + .cloned() + .or_else(|| names.first().cloned()); + + let control = match &world { + Some(current) => { + let options = names.clone(); + Dropdown::new(current.clone(), names.clone()) + .width(Size::fill()) + .height(Size::px(32.)) + .leading( + Icon::new(IconType::Globe01) + .size(14.) + .color(colors::fg_secondary()), + ) + .on_select(move |idx: usize| { + if let Some(name) = options.get(idx) { + remembered.write().insert(cluster_id, name.clone()); + } + }) + .into_element() + } + None => label() + .text(if query_is_loading(&worlds_query) { + "Loading worlds..." + } else { + "This version has no worlds yet. Create one in game first." + }) + .font_size(12.) + .color(colors::fg_secondary()) + .into_element(), + }; + + let install_world = world.clone(); + let install = move |_| { + let (Some(world), Some(version_id)) = (install_world.clone(), pending.read().clone()) + else { + return; + }; + remembered.write().insert(cluster_id, world.clone()); + dispatch.install_package( + cluster_id, + provider, + project_id.clone(), + version_id, + Some(world), + ); + pending.set(None); + }; + + dialog( + "Install into which world?".to_string(), + "Data packs only apply to the world they are added to.".to_string(), + Some(control), + move || pending.set(None), + [ + Button::new() + .secondary() + .on_press(move |_| pending.set(None)) + .text("Cancel") + .into_element(), + Button::new() + .primary() + .enabled(world.is_some()) + .on_press(install) + .child(Icon::new(IconType::Download01).size(14.)) + .text("Install") + .into_element(), + ], + ) + } +} diff --git a/packages/oneclient_app/src/view/app/cluster/datapacks.rs b/packages/oneclient_app/src/view/app/cluster/datapacks.rs new file mode 100644 index 00000000..e93777df --- /dev/null +++ b/packages/oneclient_app/src/view/app/cluster/datapacks.rs @@ -0,0 +1,312 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use freya::prelude::*; +use freya::router::RouterContext; +use oneclient_core::{DataPackInfo, PackIcon}; + +use crate::components::{Button, CardLayout, ContextMenu, Dropdown, Icon, IconType}; +use crate::hooks::{ + add_world_datapacks, delete_world_datapack, query_is_loading, spawn_world_task, + try_cluster_worlds, try_world_datapacks, use_cluster, use_cluster_worlds, use_datapack_world, + use_dispatch, use_view_state, use_world_datapacks, +}; +use crate::layout::cluster_content; +use crate::theme::colors; +use crate::{Actions, Route}; + +use super::cluster_not_found; +use super::folder_list::{ + CardIcon, FolderCard, PACKAGE_ROWS, confirm_dialog, content_box, folder_button, layout_toggle, + matches_search, notify_in_use, search_input, toolbar_action, toolbar_panel, + use_game_folder_in_use, +}; +use super::package_manager::{empty_hint, empty_shell, empty_title}; + +const WORLD_PICKER_W: f32 = 220.; + +#[derive(PartialEq)] +pub struct ClusterDataPacks { + pub cluster_id: i64, +} + +fn pick_and_add(cluster_id: i64, world: String, dispatch: Actions) { + spawn(async move { + let Some(handles) = rfd::AsyncFileDialog::new() + .set_title("Select data packs to add") + .add_filter("Data pack", &["zip"]) + .pick_files() + .await + else { + return; + }; + let files = handles.iter().map(|h| h.path().to_path_buf()).collect(); + spawn_world_task( + dispatch, + "Couldn't add data packs", + add_world_datapacks(cluster_id, world, files), + ); + }); +} + +fn browse(cluster_id: i64, world: String, mut remembered: State>) { + remembered.write().insert(cluster_id, world); + let _ = RouterContext::get().push(Route::Browser { + cluster_id, + package_type: "datapack".to_string(), + pick_cluster: false, + }); +} + +impl Component for ClusterDataPacks { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let cluster = use_cluster(cluster_id); + + let worlds_query = use_cluster_worlds(cluster_id); + let worlds = try_cluster_worlds(&worlds_query).unwrap_or_default(); + let names: Vec = worlds.iter().map(|w| w.folder_name.clone()).collect(); + + let mut remembered = use_datapack_world(); + let current = remembered + .read() + .get(&cluster_id) + .filter(|name| names.contains(name)) + .cloned() + .or_else(|| names.first().cloned()); + + let packs_query = use_world_datapacks(cluster_id, current.clone().unwrap_or_default()); + let dispatch = use_dispatch(); + let in_use = use_game_folder_in_use(cluster.as_ref()); + let search = use_state(String::new); + let layout = use_view_state("cluster.datapacks").layout; + let mut menu = use_state(|| None::<(f32, f32, DataPackInfo)>); + let mut pending_delete = use_state(|| None::); + + let Some(cluster) = cluster else { + return cluster_not_found(); + }; + let card_layout = CardLayout::from(*layout.read()); + + let Some(world) = current else { + let empty = (!query_is_loading(&worlds_query)).then(|| { + empty_shell(IconType::Globe01) + .child(empty_title("No worlds yet.")) + .child(empty_hint( + "Data packs live inside worlds, so create a world in game first.", + )) + .into_element() + }); + return cluster_content() + .child(toolbar_panel(None, vec![layout_toggle(layout)])) + .child(content_box( + 0, + card_layout, + PACKAGE_ROWS, + |_| rect().into_element(), + empty, + Vec::new(), + )) + .into_element(); + }; + + let folder: Option = cluster + .game_dir() + .ok() + .map(|d| d.join("saves").join(&world).join("datapacks")); + + let all = try_world_datapacks(&packs_query).unwrap_or_default(); + let needle = search.read().trim().to_lowercase(); + let packs: Vec = all + .iter() + .filter(|p| { + matches_search( + &needle, + &[&p.file_name, p.description.as_deref().unwrap_or_default()], + ) + }) + .cloned() + .collect(); + + let row = { + let packs = packs.clone(); + move |i: usize| { + let info = packs[i].clone(); + let menu_info = info.clone(); + FolderCard { + icon: match info.icon.clone() { + Some(PackIcon::File(path)) => CardIcon::Image(path), + Some(PackIcon::Cached(url)) => CardIcon::Cached(url, IconType::Database01), + None => CardIcon::Symbol(IconType::Database01), + }, + title: info.file_name.clone(), + badge: if info.is_dir { + (IconType::Folder, "Folder".to_string()) + } else { + (IconType::File02, "Zip".to_string()) + }, + description: info.description.clone(), + size: info.size_bytes, + layout: card_layout, + on_context: (move |(x, y)| menu.set(Some((x, y, menu_info.clone())))).into(), + } + .into_element() + } + }; + + let empty = if query_is_loading(&packs_query) { + None + } else if all.is_empty() { + let add_world = world.clone(); + let add_dispatch = dispatch.clone(); + Some( + empty_shell(IconType::FilePlus02) + .child(empty_title("No data packs in this world yet.")) + .child(empty_hint( + "Data packs only apply to the world they are added to.", + )) + .child(rect().height(Size::px(6.))) + .child( + rect() + .horizontal() + .spacing(8.) + .child({ + let browse_world = add_world.clone(); + Button::new() + .primary() + .on_press(move |_| { + browse(cluster_id, browse_world.clone(), remembered) + }) + .child(Icon::new(IconType::SearchMd).size(14.)) + .text("Browse Content") + }) + .child( + Button::new() + .secondary() + .on_press(move |_| { + pick_and_add( + cluster_id, + add_world.clone(), + add_dispatch.clone(), + ) + }) + .child(Icon::new(IconType::FilePlus02).size(14.)) + .text("Add from file"), + ), + ) + .into_element(), + ) + } else { + Some( + empty_shell(IconType::SearchMd) + .child(empty_title("No data packs match your search.")) + .child(empty_hint("Try a different term.")) + .into_element(), + ) + }; + + let picker_names = names.clone(); + let picker = Dropdown::new(world.clone(), names) + .width(Size::px(WORLD_PICKER_W)) + .height(Size::px(34.)) + .leading( + Icon::new(IconType::Globe01) + .size(14.) + .color(colors::fg_secondary()), + ) + .on_select(move |idx: usize| { + if let Some(name) = picker_names.get(idx) { + remembered.write().insert(cluster_id, name.clone()); + } + }) + .into_element(); + + let add_world = world.clone(); + let add_dispatch = dispatch.clone(); + let mut controls = vec![search_input(search)]; + controls.extend(folder.clone().map(folder_button)); + controls.push(layout_toggle(layout)); + let browse_world = world.clone(); + controls.push( + toolbar_action(IconType::SearchMd, "Browse") + .on_press(move |_| browse(cluster_id, browse_world.clone(), remembered)) + .into_element(), + ); + controls.push( + toolbar_action(IconType::Plus, "Add Data Packs") + .on_press(move |_| { + pick_and_add(cluster_id, add_world.clone(), add_dispatch.clone()) + }) + .into_element(), + ); + + let menu_overlay = menu.read().clone().map(|(x, y, info)| { + let delete_name = info.file_name.clone(); + let reveal = folder.clone(); + ContextMenu::new(x, y) + .title(info.file_name.clone()) + .action(IconType::Folder, "Open folder", move |()| { + if let Some(dir) = &reveal { + crate::platform::open_path(&dir.to_string_lossy()); + } + }) + .separator() + .danger_action(IconType::Trash01, "Delete", { + let dispatch = dispatch.clone(); + move |()| { + if in_use { + notify_in_use(&dispatch, "Data packs"); + } else { + pending_delete.set(Some(delete_name.clone())); + } + } + }) + .on_close(move |_| menu.set(None)) + .into_element() + }); + + let confirm_overlay = pending_delete.read().clone().map(|file_name| { + let delete_world = world.clone(); + confirm_dialog( + format!("Move \"{file_name}\" to trash?"), + format!( + "It is removed from \"{world}\" only and can be restored from your system trash." + ), + move || pending_delete.set(None), + { + let dispatch = dispatch.clone(); + move || { + pending_delete.set(None); + if in_use { + notify_in_use(&dispatch, "Data packs"); + return; + } + spawn_world_task( + dispatch.clone(), + "Couldn't delete data pack", + delete_world_datapack( + cluster_id, + delete_world.clone(), + file_name.clone(), + ), + ); + } + }, + ) + }); + + cluster_content() + .child(toolbar_panel(Some(picker), controls)) + .child(content_box( + packs.len(), + card_layout, + PACKAGE_ROWS, + row, + empty, + Vec::new(), + )) + .maybe_child(menu_overlay) + .maybe_child(confirm_overlay) + .into_element() + } +} diff --git a/packages/oneclient_app/src/view/app/cluster/folder_list.rs b/packages/oneclient_app/src/view/app/cluster/folder_list.rs new file mode 100644 index 00000000..44f221c4 --- /dev/null +++ b/packages/oneclient_app/src/view/app/cluster/folder_list.rs @@ -0,0 +1,501 @@ +use std::path::PathBuf; + +use freya::prelude::*; +use oneclient_common::parse_mc_version; +use oneclient_core::clusters::Cluster; +use oneclient_core::settings::ViewLayout; + +use crate::components::{ + Button, CARD_BG, CARD_GRID_H, CARD_H, CARD_NAME, CardLayout, GRID_GAP, GRID_MIN_W, Icon, + IconType, LocalImage, OverlayPopup, ScrollArea, Segment, SegmentedControl, TextInput, badge, + icon_box, kebab_button, meta_size, meta_text, on_secondary, open_folder_button, +}; +use crate::hooks::{ + loaded_image, settled_or_loading, use_cached_image, use_clusters, use_game_snapshot, +}; +use crate::theme::colors; +use crate::ui::{ImageFallbackExt, border_all_color}; + +use super::package_manager::{CARD_SPACING, GRID_MAX_COLS, notice_bar}; + +const CONTROL_H: f32 = 34.; +const SEARCH_W: f32 = 180.; +const IMAGE_EDGE: u32 = 128; +const FIRST_DATAPACK_MAJOR: u32 = 13; + +#[derive(Clone, Copy)] +pub(super) struct RowHeights { + pub list: f32, + pub grid: f32, +} + +pub(super) const PACKAGE_ROWS: RowHeights = RowHeights { + list: CARD_H, + grid: CARD_GRID_H, +}; + +pub(crate) fn supports_datapacks(mc_version: &str) -> bool { + parse_mc_version(mc_version).is_none_or(|p| p.major >= FIRST_DATAPACK_MAJOR) +} + +pub(super) fn use_game_folder_in_use(cluster: Option<&Cluster>) -> bool { + let game = use_game_snapshot(); + let clusters = settled_or_loading(&use_clusters()).unwrap_or_default(); + + cluster.is_some_and(|cluster| { + game.is_active(cluster.id) + || (!cluster.uses_dedicated_dir() + && clusters + .iter() + .any(|c| game.is_active(c.id) && !c.uses_dedicated_dir())) + }) +} + +pub(super) fn notify_in_use(dispatch: &crate::Actions, what: &str) { + dispatch + .notify("Close Minecraft first") + .body(format!( + "{what} can't be deleted while a version using this game folder is running." + )) + .error() + .send(); +} + +pub(super) fn matches_search(needle: &str, fields: &[&str]) -> bool { + needle.is_empty() || fields.iter().any(|f| f.to_lowercase().contains(needle)) +} + +pub(super) fn toolbar_panel(leading: Option, controls: Vec) -> Element { + let mut top_corners = CornerRadius::new_all(0.); + top_corners.fill_top(12.); + + rect() + .vertical() + .width(Size::fill()) + .overflow(Overflow::Clip) + .padding(Gaps::new_symmetric(8., 12.)) + .corner_radius(top_corners) + .background(colors::page_elevated()) + .child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(8.) + .content(Content::Flex) + .child( + rect() + .horizontal() + .width(Size::flex(1.0)) + .height(Size::px(CONTROL_H)) + .cross_align(Alignment::Center) + .maybe_child(leading), + ) + .children(controls), + ) + .into_element() +} + +pub(super) fn search_input(search: State) -> Element { + TextInput::new(search) + .placeholder("Search...") + .width(Size::px(SEARCH_W)) + .leading( + Icon::new(IconType::SearchMd) + .size(14.) + .color(colors::fg_secondary()) + .into_element(), + ) + .into_element() +} + +pub(super) fn folder_button(folder: PathBuf) -> Element { + open_folder_button(folder) + .width(Size::px(CONTROL_H)) + .height(Size::px(CONTROL_H)) + .into_element() +} + +pub(super) fn layout_toggle(layout: State) -> Element { + SegmentedControl::new(layout) + .height(CONTROL_H) + .icon_size(15.) + .equal_width(CONTROL_H) + .segment(Segment::new(ViewLayout::List).icon(IconType::ParagraphWrap)) + .segment(Segment::new(ViewLayout::Grid).icon(IconType::DotsGrid)) + .into_element() +} + +pub(super) fn toolbar_action(icon: IconType, text: &str) -> Button { + Button::new() + .primary() + .height(Size::px(CONTROL_H)) + .font_size(12.) + .child(Icon::new(icon).size(15.)) + .text(text.to_string()) +} + +pub(super) fn content_box( + count: usize, + layout: CardLayout, + heights: RowHeights, + row: impl Fn(usize) -> Element + 'static, + empty: Option, + notices: Vec, +) -> Element { + let scroll = (count > 0).then(|| { + let area = ScrollArea::new() + .width(Size::fill()) + .height(Size::flex(1.0)) + .scrollbar_gutter(true); + match layout { + CardLayout::List => area.lazy(count, heights.list, CARD_SPACING, row), + CardLayout::Grid => area.lazy_grid( + count, + heights.grid, + GRID_GAP, + GRID_MIN_W, + GRID_MAX_COLS, + row, + ), + } + .into_element() + }); + + let mut bottom_corners = CornerRadius::new_all(0.); + bottom_corners.fill_bottom(12.); + + let empty = if count == 0 { empty } else { None }; + + rect() + .vertical() + .width(Size::fill()) + .height(Size::flex(1.0)) + .padding(Gaps::new(0., 12., 12., 12.)) + .corner_radius(bottom_corners) + .background(colors::page_elevated()) + .overflow(Overflow::Clip) + .content(Content::Flex) + .children(notices.into_iter().map(notice_bar)) + .maybe_child(scroll) + .maybe_child(empty.map(|empty| { + rect() + .width(Size::fill()) + .height(Size::flex(1.0)) + .child(empty) + .into_element() + })) + .into_element() +} + +#[derive(Clone, PartialEq)] +pub(super) enum CardIcon { + Image(PathBuf), + Cached(String, IconType), + Symbol(IconType), +} + +#[derive(PartialEq)] +struct CachedIcon { + url: String, + fallback: IconType, + size: f32, +} + +impl Component for CachedIcon { + fn render(&self) -> impl IntoElement { + let query = use_cached_image(Some(self.url.clone()), IMAGE_EDGE); + + match loaded_image(Some(&self.url), &query) { + Some(loaded) => ImageViewer::new(loaded) + .width(Size::px(self.size)) + .height(Size::px(self.size)) + .aspect_ratio(AspectRatio::Min) + .corner_radius(CornerRadius::new_all(8.)) + .fallback(icon_box(self.fallback, self.size)) + .into_element(), + None => icon_box(self.fallback, self.size), + } + } +} + +#[derive(PartialEq)] +pub(super) struct FolderCard { + pub icon: CardIcon, + pub title: String, + pub badge: (IconType, String), + pub description: Option, + pub size: u64, + pub layout: CardLayout, + pub on_context: EventHandler<(f32, f32)>, +} + +impl Component for FolderCard { + fn render(&self) -> impl IntoElement { + let mut hovered = use_state(|| false); + + let icon = card_icon( + &self.icon, + match self.layout { + CardLayout::List => 44., + CardLayout::Grid => 40., + }, + ); + let on_context = Some(self.on_context.clone()); + + match self.layout { + CardLayout::List => rect() + .horizontal() + .width(Size::fill()) + .height(Size::px(CARD_H)) + .cross_align(Alignment::Center) + .spacing(12.) + .padding(Gaps::new_all(10.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(CARD_BG) + .content(Content::Flex) + .on_secondary_down(on_secondary(on_context.clone())) + .child(self.list_info(icon)) + .child(meta_size(self.size)) + .child(kebab_button(self.on_context.clone())) + .into_element(), + CardLayout::Grid => { + let hovering = *hovered.read(); + let muted = CARD_NAME.with_a(127); + + let header = rect() + .horizontal() + .width(Size::fill()) + .spacing(11.) + .content(Content::Flex) + .child(icon) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(3.) + .child( + label() + .text(self.title.clone()) + .font_size(14.) + .font_weight(FontWeight::SEMI_BOLD) + .max_lines(1) + .text_overflow(TextOverflow::Ellipsis) + .width(Size::fill()) + .color(Color::WHITE), + ) + .child(meta_text(self.grid_meta(), muted)), + ) + .child(kebab_button(self.on_context.clone())); + + let description = self.description.clone().map(|text| { + label() + .text(text) + .font_size(11.) + .line_height(1.45) + .max_lines(2) + .text_overflow(TextOverflow::Ellipsis) + .width(Size::fill()) + .color(colors::fg_primary().with_a(183)) + .into_element() + }); + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .spacing(9.) + .padding(Gaps::new_all(14.)) + .corner_radius(CornerRadius::new_all(6.)) + .background(if hovering { + colors::component_bg_hover() + } else { + colors::component_bg() + }) + .border(border_all_color( + 1., + if hovering { + colors::component_border_hover() + } else { + colors::component_border() + }, + )) + .overflow(Overflow::Clip) + .content(Content::Flex) + .on_pointer_enter(move |_| hovered.set(true)) + .on_pointer_leave(move |_| hovered.set(false)) + .on_secondary_down(on_secondary(on_context)) + .child(header) + .child( + rect() + .vertical() + .width(Size::fill()) + .height(Size::flex(1.0)) + .maybe_child(description), + ) + .into_element() + } + } + } +} + +impl FolderCard { + fn grid_meta(&self) -> String { + let mut parts = vec![self.badge.1.clone()]; + if self.size > 0 { + parts.push(crate::utils::format_size(self.size)); + } + parts.join(" \u{b7} ") + } + + fn list_info(&self, icon: Element) -> Element { + rect() + .horizontal() + .width(Size::flex(1.0)) + .cross_align(Alignment::Center) + .spacing(12.) + .content(Content::Flex) + .child(icon) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(3.) + .child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(8.) + .child( + label() + .text(self.title.clone()) + .font_size(15.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .text_overflow(TextOverflow::Ellipsis) + .max_width(Size::percent(60.)) + .color(CARD_NAME), + ) + .child(badge( + Icon::new(self.badge.0) + .size(12.) + .color(colors::fg_secondary()) + .into_element(), + self.badge.1.clone(), + )), + ) + .maybe_child(self.description.clone().map(|text| { + label() + .text(text) + .font_size(11.) + .max_lines(2) + .text_overflow(TextOverflow::Ellipsis) + .width(Size::fill()) + .color(colors::fg_secondary()) + .into_element() + })), + ) + .into_element() + } +} + +pub(super) fn card_icon(icon: &CardIcon, size: f32) -> Element { + match icon { + CardIcon::Image(path) => rect() + .width(Size::px(size)) + .height(Size::px(size)) + .corner_radius(CornerRadius::new_all(8.)) + .overflow(Overflow::Clip) + .background(colors::component_bg()) + .child(LocalImage::new(path.clone(), IMAGE_EDGE, true).skeleton(true)) + .into_element(), + CardIcon::Cached(url, fallback) => CachedIcon { + url: url.clone(), + fallback: *fallback, + size, + } + .into_element(), + CardIcon::Symbol(symbol) => icon_box(*symbol, size), + } +} + +pub(super) fn confirm_dialog( + title: String, + body: String, + on_cancel: impl FnMut() + Clone + 'static, + mut on_confirm: impl FnMut() + 'static, +) -> Element { + let mut cancel = on_cancel.clone(); + + dialog( + title, + body, + None, + on_cancel, + [ + Button::new() + .secondary() + .on_press(move |_| cancel()) + .text("Cancel") + .into_element(), + Button::new() + .danger() + .on_press(move |_| on_confirm()) + .child(Icon::new(IconType::Trash01).size(14.)) + .text("Delete") + .into_element(), + ], + ) +} + +pub(crate) fn dialog( + title: String, + body: String, + control: Option, + mut on_close: impl FnMut() + 'static, + buttons: [Element; 2], +) -> Element { + OverlayPopup::new() + .on_close(move |_| on_close()) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(400.)) + .max_width(Size::window_percent(90.)) + .spacing(14.) + .padding(Gaps::new_all(20.)) + .corner_radius(CornerRadius::new_all(14.)) + .background(CARD_BG) + .border(border_all_color(1., colors::component_border())) + .child( + label() + .text(title) + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(body) + .font_size(12.) + .color(colors::fg_secondary()), + ) + .maybe_child(control) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .spacing(8.) + .children(buttons), + ), + ), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/view/app/cluster/mod.rs b/packages/oneclient_app/src/view/app/cluster/mod.rs index 94ae1ec8..5cda715e 100644 --- a/packages/oneclient_app/src/view/app/cluster/mod.rs +++ b/packages/oneclient_app/src/view/app/cluster/mod.rs @@ -3,21 +3,27 @@ mod overview; mod package_manager; mod cluster_settings; +mod datapacks; +mod folder_list; mod mods; mod process_logs; mod screenshots; mod shaders; mod textures; +mod worlds; pub use logs::ClusterLogs; pub use overview::ClusterOverview; pub use cluster_settings::ClusterSettings; +pub use datapacks::ClusterDataPacks; +pub(crate) use folder_list::{dialog, supports_datapacks}; pub use mods::ClusterMods; pub use process_logs::ProcessLogs; pub use screenshots::ClusterScreenshots; pub use shaders::ClusterShaders; pub use textures::ClusterTextures; +pub use worlds::ClusterWorlds; use freya::prelude::*; diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs index 1e54d768..b66fe1e4 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs @@ -15,10 +15,11 @@ mod views; use views::{ AdvancedSection, ContentBox, ContentKind, EnabledFilter, HiddenFilter, SortMode, toolbar_bar, }; +pub(super) use views::{empty_hint, empty_shell, empty_title, notice_bar}; const CARD_H: f32 = 84.; -const CARD_SPACING: f32 = 8.; -const GRID_MAX_COLS: usize = 5; +pub(super) const CARD_SPACING: f32 = 8.; +pub(super) const GRID_MAX_COLS: usize = 5; pub type PackageMetaMap = HashMap<(ProviderId, String), CachedPackageMeta>; @@ -500,6 +501,14 @@ impl Component for PackageManager { } }; + let mut notices = Vec::new(); + if content_type.is_global() { + notices.push(views::global_notice(noun_plural)); + } + if session_live { + notices.push(views::running_notice(noun_plural, content_type)); + } + let (advanced, filtered): (Vec<_>, Vec<_>) = filtered.into_iter().partition(|p| p.advanced); rect() @@ -520,25 +529,22 @@ impl Component for PackageManager { package_type, toolbar_width, )) - .maybe_child( - content_type - .is_global() - .then(|| views::global_notice(noun_plural)), + .child( + ContentBox::new( + filtered, + advanced, + AdvancedSection { + open: advanced_open, + forced: !query.is_empty(), + }, + noun_plural, + package_type, + content_type, + cluster_id, + content_kind, + card_layout, + ) + .notices(notices), ) - .maybe_child(session_live.then(|| views::running_notice(noun_plural, content_type))) - .child(ContentBox::new( - filtered, - advanced, - AdvancedSection { - open: advanced_open, - forced: !query.is_empty(), - }, - noun_plural, - package_type, - content_type, - cluster_id, - content_kind, - card_layout, - )) } } diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs index 36fe4de1..16f8e21c 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs @@ -340,8 +340,8 @@ impl Component for CategoryChip { } } -pub(super) fn running_notice(noun_plural: &'static str, content_type: ContentType) -> Element { - let text = match content_type { +pub(super) fn running_notice(noun_plural: &'static str, content_type: ContentType) -> String { + match content_type { ContentType::ResourcePack => format!( "Minecraft is running. New {noun_plural} usually go in right away, open Options → Resource Packs in game to turn them on. OneClient tells you when one has to wait for the next launch." ), @@ -351,25 +351,23 @@ pub(super) fn running_notice(noun_plural: &'static str, content_type: ContentTyp _ => format!( "Minecraft is running. Changes to your {noun_plural} are saved, and take effect the next time you launch this version." ), - }; - - notice_bar(text) + } } -pub(super) fn global_notice(noun_plural: &'static str) -> Element { - notice_bar(format!( +pub(super) fn global_notice(noun_plural: &'static str) -> String { + format!( "These {noun_plural} are shared across all your clusters. Adding one here makes it available everywhere, and turning one off removes it everywhere." - )) + ) } -fn notice_bar(text: String) -> Element { +pub(crate) fn notice_bar(text: String) -> Element { rect() .horizontal() .width(Size::fill()) .cross_align(Alignment::Center) .content(Content::Flex) .spacing(10.) - .margin(Gaps::new(8., 0., 0., 0.)) + .margin(Gaps::new(0., 0., 8., 0.)) .padding(Gaps::new_symmetric(9., 12.)) .corner_radius(CornerRadius::new_all(10.)) .background(colors::brand().with_a(30)) @@ -771,6 +769,7 @@ pub(super) struct ContentBox { cluster_id: i64, kind: ContentKind, layout: CardLayout, + notices: Vec, } impl ContentBox { @@ -796,8 +795,14 @@ impl ContentBox { cluster_id, kind, layout, + notices: Vec::new(), } } + + pub(super) fn notices(mut self, notices: Vec) -> Self { + self.notices = notices; + self + } } impl Component for ContentBox { @@ -856,7 +861,7 @@ impl Component for ContentBox { }; ScrollArea::new() .width(Size::fill()) - .height(Size::fill()) + .height(Size::flex(1.0)) .scrollbar_gutter(true) .lazy_sections( sections, @@ -923,9 +928,16 @@ impl Component for ContentBox { .corner_radius(bottom_corners) .background(colors::page_elevated()) .overflow(Overflow::Clip) + .content(Content::Flex) + .children(self.notices.iter().map(|text| notice_bar(text.clone()))) .maybe_child(header) .maybe_child(scroll) - .maybe_child(empty) + .maybe_child(empty.map(|empty| { + rect() + .width(Size::fill()) + .height(Size::flex(1.0)) + .child(empty) + })) .maybe_child(menu_overlay) .maybe_child(delete_dialog) } @@ -941,7 +953,7 @@ fn action_header(button: impl IntoElement) -> impl IntoElement { .child(button) } -fn empty_shell(icon: IconType) -> Rect { +pub(crate) fn empty_shell(icon: IconType) -> Rect { rect() .vertical() .width(Size::fill()) @@ -952,14 +964,14 @@ fn empty_shell(icon: IconType) -> Rect { .child(Icon::new(icon).size(28.).color(colors::fg_secondary())) } -fn empty_title(text: impl Into) -> impl IntoElement { +pub(crate) fn empty_title(text: impl Into) -> impl IntoElement { label() .text(text.into()) .font_size(14.) .color(colors::fg_secondary()) } -fn empty_hint(text: impl Into) -> impl IntoElement { +pub(crate) fn empty_hint(text: impl Into) -> impl IntoElement { label() .text(text.into()) .font_size(12.) diff --git a/packages/oneclient_app/src/view/app/cluster/worlds.rs b/packages/oneclient_app/src/view/app/cluster/worlds.rs new file mode 100644 index 00000000..2faf7973 --- /dev/null +++ b/packages/oneclient_app/src/view/app/cluster/worlds.rs @@ -0,0 +1,332 @@ +use std::collections::HashMap; + +use freya::prelude::*; +use freya::router::RouterContext; +use oneclient_core::WorldInfo; + +use crate::components::{ + CARD_BG, CARD_NAME, CardLayout, ContextMenu, IconType, kebab_button, meta_size, meta_text, + on_secondary, +}; +use crate::hooks::{ + delete_world, query_is_loading, spawn_world_task, try_cluster_worlds, try_world_size, + use_cluster, use_cluster_worlds, use_datapack_world, use_dispatch, use_saves_folder_watch, + use_view_state, use_world_size, +}; +use crate::layout::cluster_content; +use crate::routes::Route; +use crate::theme::colors; +use crate::ui::{border_all_color, fmt_date}; +use crate::utils::format_size; + +use super::cluster_not_found; +use super::folder_list::{ + CardIcon, RowHeights, card_icon, confirm_dialog, content_box, folder_button, layout_toggle, + matches_search, notify_in_use, search_input, supports_datapacks, toolbar_panel, + use_game_folder_in_use, +}; +use super::package_manager::{empty_hint, empty_shell, empty_title}; + +const LIST_ICON: f32 = 44.; +const GRID_ICON: f32 = 40.; +const LIST_PAD: f32 = 10.; +const GRID_PAD: f32 = 14.; + +const SHARED_NOTICE: &str = "This version uses the shared game folder, so these worlds also appear in every other version that uses it."; + +const WORLD_ROWS: RowHeights = RowHeights { + list: LIST_ICON + 2. * LIST_PAD, + grid: GRID_ICON + 2. * GRID_PAD, +}; + +#[derive(PartialEq)] +pub struct ClusterWorlds { + pub cluster_id: i64, +} + +fn open_datapacks(cluster_id: i64, world: String, mut remembered: State>) { + remembered.write().insert(cluster_id, world); + let _ = RouterContext::get().push(Route::ClusterDataPacks { cluster_id }); +} + +impl Component for ClusterWorlds { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let cluster = use_cluster(cluster_id); + let saves = cluster + .as_ref() + .and_then(|c| c.game_dir().ok()) + .map(|d| d.join("saves")); + + let query = use_cluster_worlds(cluster_id); + use_saves_folder_watch(saves.clone(), query); + let dispatch = use_dispatch(); + let in_use = use_game_folder_in_use(cluster.as_ref()); + let remembered = use_datapack_world(); + let search = use_state(String::new); + let layout = use_view_state("cluster.worlds").layout; + let mut menu = use_state(|| None::<(f32, f32, WorldInfo)>); + let mut pending_delete = use_state(|| None::); + + let Some(cluster) = cluster else { + return cluster_not_found(); + }; + let shared = !cluster.uses_dedicated_dir(); + let datapacks = supports_datapacks(&cluster.mc_version); + + let all = try_cluster_worlds(&query).unwrap_or_default(); + let needle = search.read().trim().to_lowercase(); + let worlds: Vec = all + .iter() + .filter(|w| matches_search(&needle, &[&w.folder_name])) + .cloned() + .collect(); + let card_layout = CardLayout::from(*layout.read()); + + let row = { + let worlds = worlds.clone(); + move |i: usize| { + let info = worlds[i].clone(); + let press_world = info.folder_name.clone(); + let menu_info = info.clone(); + WorldCard { + cluster_id, + info, + layout: card_layout, + on_press: datapacks.then(|| { + (move |()| open_datapacks(cluster_id, press_world.clone(), remembered)) + .into() + }), + on_context: (move |(x, y)| menu.set(Some((x, y, menu_info.clone())))).into(), + } + .into_element() + } + }; + + let empty = if query_is_loading(&query) { + None + } else if all.is_empty() { + Some( + empty_shell(IconType::Globe01) + .child(empty_title("No worlds yet.")) + .child(empty_hint( + "Worlds show up here once you create one in game.", + )) + .into_element(), + ) + } else { + Some( + empty_shell(IconType::SearchMd) + .child(empty_title("No worlds match your search.")) + .child(empty_hint("Try a different term.")) + .into_element(), + ) + }; + + let mut controls = vec![search_input(search)]; + controls.extend(saves.map(folder_button)); + controls.push(layout_toggle(layout)); + + let menu_overlay = menu.read().clone().map(|(x, y, info)| { + let open_world = info.folder_name.clone(); + let target_world = info.folder_name.clone(); + let path = info.path.clone(); + let mut context = ContextMenu::new(x, y).title(info.folder_name.clone()); + if datapacks { + context = context.action(IconType::Database01, "Data packs", move |()| { + open_datapacks(cluster_id, open_world.clone(), remembered) + }); + } + context + .action(IconType::Folder, "Open folder", move |()| { + crate::platform::open_path(&path.to_string_lossy()) + }) + .separator() + .danger_action(IconType::Trash01, "Delete", { + let dispatch = dispatch.clone(); + move |()| { + if in_use { + notify_in_use(&dispatch, "Worlds"); + } else { + pending_delete.set(Some(target_world.clone())); + } + } + }) + .on_close(move |_| menu.set(None)) + .into_element() + }); + + let confirm_overlay = pending_delete.read().clone().map(|world| { + let target_world = world.clone(); + let body = if shared { + "This version uses the shared game folder, so the world is removed from every version that uses it. It can be restored from your system trash." + } else { + "It can be restored from your system trash." + }; + confirm_dialog( + format!("Move \"{world}\" to trash?"), + body.to_string(), + move || pending_delete.set(None), + { + let dispatch = dispatch.clone(); + move || { + pending_delete.set(None); + if in_use { + notify_in_use(&dispatch, "Worlds"); + return; + } + spawn_world_task( + dispatch.clone(), + "Couldn't delete world", + delete_world(cluster_id, target_world.clone()), + ); + } + }, + ) + }); + + cluster_content() + .child(toolbar_panel(None, controls)) + .child(content_box( + worlds.len(), + card_layout, + WORLD_ROWS, + row, + empty, + shared + .then(|| SHARED_NOTICE.to_string()) + .into_iter() + .collect(), + )) + .maybe_child(menu_overlay) + .maybe_child(confirm_overlay) + .into_element() + } +} + +#[derive(PartialEq)] +struct WorldCard { + cluster_id: i64, + info: WorldInfo, + layout: CardLayout, + on_press: Option>, + on_context: EventHandler<(f32, f32)>, +} + +impl Component for WorldCard { + fn render(&self) -> impl IntoElement { + let mut hovered = use_state(|| false); + let info = &self.info; + let grid = self.layout == CardLayout::Grid; + let size = try_world_size(&use_world_size(self.cluster_id, info.folder_name.clone())); + + let icon = card_icon( + &info + .icon + .clone() + .map_or(CardIcon::Symbol(IconType::Globe01), CardIcon::Image), + if grid { GRID_ICON } else { LIST_ICON }, + ); + let last_played = format!("Last played {}", fmt_date(info.last_played)); + let on_press = self.on_press.clone(); + let pressable = on_press.is_some(); + + let text = if grid { + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(3.) + .child( + label() + .text(info.folder_name.clone()) + .font_size(14.) + .font_weight(FontWeight::SEMI_BOLD) + .max_lines(1) + .text_overflow(TextOverflow::Ellipsis) + .width(Size::fill()) + .color(Color::WHITE), + ) + .child(meta_text( + match size { + Some(size) => format!("{last_played} \u{b7} {}", format_size(size)), + None => last_played.clone(), + }, + CARD_NAME.with_a(127), + )) + } else { + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(3.) + .child( + label() + .text(info.folder_name.clone()) + .font_size(15.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .text_overflow(TextOverflow::Ellipsis) + .width(Size::fill()) + .color(CARD_NAME), + ) + .child( + label() + .text(last_played) + .font_size(11.) + .max_lines(1) + .text_overflow(TextOverflow::Ellipsis) + .width(Size::fill()) + .color(colors::fg_secondary()), + ) + }; + + let hovering = *hovered.read(); + let card = rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .content(Content::Flex) + .maybe(pressable, |el| el.cursor(CursorIcon::Pointer)) + .on_press(move |_| { + if let Some(handler) = &on_press { + handler.call(()); + } + }) + .on_secondary_down(on_secondary(Some(self.on_context.clone()))); + + if grid { + card.height(Size::px(WORLD_ROWS.grid)) + .spacing(11.) + .padding(Gaps::new_all(GRID_PAD)) + .corner_radius(CornerRadius::new_all(6.)) + .background(if hovering { + colors::component_bg_hover() + } else { + colors::component_bg() + }) + .border(border_all_color( + 1., + if hovering { + colors::component_border_hover() + } else { + colors::component_border() + }, + )) + .overflow(Overflow::Clip) + .on_pointer_enter(move |_| hovered.set(true)) + .on_pointer_leave(move |_| hovered.set(false)) + .child(icon) + .child(text) + .child(kebab_button(self.on_context.clone())) + } else { + card.height(Size::px(WORLD_ROWS.list)) + .spacing(12.) + .padding(Gaps::new_all(LIST_PAD)) + .corner_radius(CornerRadius::new_all(8.)) + .background(CARD_BG) + .child(icon) + .child(text) + .child(meta_size(size.unwrap_or_default())) + .child(kebab_button(self.on_context.clone())) + } + } +} diff --git a/packages/oneclient_cluster/src/error.rs b/packages/oneclient_cluster/src/error.rs index 0fda9c82..f08d4f4e 100644 --- a/packages/oneclient_cluster/src/error.rs +++ b/packages/oneclient_cluster/src/error.rs @@ -37,6 +37,9 @@ pub enum ClusterError { #[error(transparent)] Screenshots(#[from] crate::screenshots::ScreenshotsError), + #[error(transparent)] + Worlds(#[from] crate::worlds::WorldsError), + #[error(transparent)] Request(#[from] oneclient_net::RequestError), diff --git a/packages/oneclient_cluster/src/lib.rs b/packages/oneclient_cluster/src/lib.rs index 904113bd..3f11c8d2 100644 --- a/packages/oneclient_cluster/src/lib.rs +++ b/packages/oneclient_cluster/src/lib.rs @@ -12,6 +12,7 @@ mod stage; pub mod logs; pub mod profiles; pub mod screenshots; +pub mod worlds; pub use cluster::{Cluster, ClusterLinkTarget, remove_mods_link}; pub use error::{ClusterError, ClusterResult}; diff --git a/packages/oneclient_cluster/src/worlds/mod.rs b/packages/oneclient_cluster/src/worlds/mod.rs new file mode 100644 index 00000000..b0bcbd80 --- /dev/null +++ b/packages/oneclient_cluster/src/worlds/mod.rs @@ -0,0 +1,326 @@ +use std::path::{Component, Path, PathBuf}; + +use chrono::{DateTime, Utc}; +use serde_json::Value; +use thiserror::Error; + +use crate::cluster::Cluster; +use crate::error::ClusterResult; + +const SAVES_DIR: &str = "saves"; +const DATAPACKS_DIR: &str = "datapacks"; +pub const LEVEL_DAT: &str = "level.dat"; +pub const WORLD_ICON: &str = "icon.png"; +const PACK_META: &str = "pack.mcmeta"; +const PACK_ICON: &str = "pack.png"; + +#[derive(Debug, Error)] +pub enum WorldsError { + #[error("not a plain file or folder name: {0}")] + InvalidName(String), + #[error("world '{0}' not found")] + NotFound(String), + #[error("failed to move to trash: {0}")] + Trash(String), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct WorldInfo { + pub folder_name: String, + pub path: PathBuf, + pub icon: Option, + pub last_played: DateTime, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum PackIcon { + File(PathBuf), + Cached(String), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct DataPackInfo { + pub file_name: String, + pub is_dir: bool, + pub size_bytes: u64, + pub modified: DateTime, + pub description: Option, + pub icon: Option, +} + +fn plain_name(name: &str) -> ClusterResult<&str> { + let mut components = Path::new(name).components(); + match (components.next(), components.next()) { + (Some(Component::Normal(_)), None) => Ok(name), + _ => Err(WorldsError::InvalidName(name.to_string()).into()), + } +} + +fn world_dir(cluster: &Cluster, world: &str) -> ClusterResult { + let dir = cluster.game_dir()?.join(SAVES_DIR).join(plain_name(world)?); + if dir.is_dir() { + Ok(dir) + } else { + Err(WorldsError::NotFound(world.to_string()).into()) + } +} + +fn modified_or_now(meta: &std::fs::Metadata) -> DateTime { + meta.modified() + .map(DateTime::::from) + .unwrap_or_else(|_| Utc::now()) +} + +fn dir_size(dir: &Path) -> u64 { + let Ok(entries) = std::fs::read_dir(dir) else { + return 0; + }; + + entries + .flatten() + .map(|entry| match entry.file_type() { + Ok(kind) if kind.is_dir() => dir_size(&entry.path()), + Ok(kind) if kind.is_file() => entry.metadata().map(|m| m.len()).unwrap_or(0), + _ => 0, + }) + .sum() +} + +fn move_to_trash(path: &Path) -> ClusterResult<()> { + trash::delete(path).map_err(|err| WorldsError::Trash(err.to_string()).into()) +} + +#[tracing::instrument(level = "debug", skip(cluster), fields(cluster_id = cluster.id))] +pub fn list_cluster_worlds(cluster: &Cluster) -> ClusterResult> { + let dir = cluster.game_dir()?.join(SAVES_DIR); + let mut out = Vec::new(); + + let Ok(entries) = std::fs::read_dir(&dir) else { + return Ok(out); + }; + + for entry in entries.flatten() { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|s| s.to_str()) else { + continue; + }; + + if !path.is_dir() { + continue; + } + + let Ok(level_meta) = std::fs::metadata(path.join(LEVEL_DAT)) else { + continue; + }; + + let icon = path.join(WORLD_ICON); + + out.push(WorldInfo { + folder_name: name.to_string(), + icon: icon.is_file().then_some(icon), + last_played: modified_or_now(&level_meta), + path, + }); + } + + out.sort_by_key(|w| std::cmp::Reverse(w.last_played)); + Ok(out) +} + +#[tracing::instrument(level = "debug", skip(cluster), fields(cluster_id = cluster.id))] +pub fn world_size(cluster: &Cluster, world: &str) -> ClusterResult { + Ok(dir_size(&world_dir(cluster, world)?)) +} + +#[tracing::instrument(level = "debug", skip(cluster), fields(cluster_id = cluster.id))] +pub fn delete_world(cluster: &Cluster, world: &str) -> ClusterResult<()> { + move_to_trash(&world_dir(cluster, world)?) +} + +#[tracing::instrument(level = "debug", skip(cluster), fields(cluster_id = cluster.id))] +pub async fn list_world_datapacks( + cluster: &Cluster, + world: &str, +) -> ClusterResult> { + let dir = world_dir(cluster, world)?.join(DATAPACKS_DIR); + let mut found = tokio::task::spawn_blocking(move || scan_datapacks(&dir)) + .await + .map_err(std::io::Error::other)?; + + for (path, info) in &mut found { + if !info.is_dir { + (info.description, info.icon) = zip_meta(path).await; + } + } + + let mut out: Vec = found.into_iter().map(|(_, info)| info).collect(); + out.sort_by_key(|p| p.file_name.to_lowercase()); + Ok(out) +} + +fn scan_datapacks(dir: &Path) -> Vec<(PathBuf, DataPackInfo)> { + let mut out = Vec::new(); + + let Ok(entries) = std::fs::read_dir(dir) else { + return out; + }; + + for entry in entries.flatten() { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|s| s.to_str()) else { + continue; + }; + let Ok(meta) = std::fs::metadata(&path) else { + continue; + }; + + let is_dir = meta.is_dir(); + let is_pack = if is_dir { + path.join(PACK_META).is_file() + } else { + name.to_ascii_lowercase().ends_with(".zip") + }; + if !is_pack { + continue; + } + + let info = DataPackInfo { + file_name: name.to_string(), + is_dir, + size_bytes: if is_dir { dir_size(&path) } else { meta.len() }, + modified: modified_or_now(&meta), + description: if is_dir { + std::fs::read(path.join(PACK_META)) + .ok() + .and_then(|raw| parse_description(&raw)) + } else { + None + }, + icon: is_dir + .then(|| path.join(PACK_ICON)) + .filter(|icon| icon.is_file()) + .map(PackIcon::File), + }; + out.push((path, info)); + } + + out +} + +#[tracing::instrument(level = "debug", skip(cluster), fields(cluster_id = cluster.id))] +pub async fn add_world_datapacks( + cluster: &Cluster, + world: &str, + files: &[PathBuf], +) -> ClusterResult<()> { + let dir = world_dir(cluster, world)?.join(DATAPACKS_DIR); + polyio::create_dir_all(&dir).await?; + + for file in files { + let Some(name) = file.file_name() else { + continue; + }; + let target = dir.join(name); + if is_same_file(file, &target) { + continue; + } + if file.is_dir() { + polyio::copy_dir(file, &target, &[]).await?; + } else { + polyio::copy(file, &target).await?; + } + } + + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(cluster), fields(cluster_id = cluster.id))] +pub fn delete_world_datapack(cluster: &Cluster, world: &str, file_name: &str) -> ClusterResult<()> { + let path = world_dir(cluster, world)? + .join(DATAPACKS_DIR) + .join(plain_name(file_name)?); + move_to_trash(&path) +} + +fn is_same_file(a: &Path, b: &Path) -> bool { + match (polyio::canonicalize(a), polyio::canonicalize(b)) { + (Ok(a), Ok(b)) => a == b, + _ => false, + } +} + +async fn zip_meta(path: &Path) -> (Option, Option) { + let Ok(entries) = + polyio::read_zip_file_entries(path, |name| name == PACK_META || name == PACK_ICON).await + else { + return (None, None); + }; + + let mut description = None; + let mut icon = None; + for (name, raw) in entries { + if name == PACK_META { + description = parse_description(&raw); + } else { + icon = store_icon(&raw).await; + } + } + (description, icon) +} + +async fn store_icon(bytes: &[u8]) -> Option { + if bytes.is_empty() { + return None; + } + + let mut hash = polyio::Sha1Stream::new(); + hash.update(bytes); + let dir = oneclient_common::paths::local_icons_dir().ok()?; + let name = format!("{}.png", hash.finish()); + let target = dir.join(&name); + if !target.is_file() { + polyio::create_dir_all(&dir).await.ok()?; + polyio::write(&target, bytes).await.ok()?; + } + Some(PackIcon::Cached(format!( + "{}{name}", + oneclient_common::paths::LOCAL_IMAGE_SCHEME + ))) +} + +fn parse_description(raw: &[u8]) -> Option { + let raw = raw.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(raw); + let meta: Value = serde_json::from_slice(raw).ok()?; + let text = strip_formatting(flatten_text(meta.get("pack")?.get("description")?).trim()); + (!text.is_empty()).then_some(text) +} + +fn flatten_text(value: &Value) -> String { + match value { + Value::String(s) => s.clone(), + Value::Array(items) => items.iter().map(flatten_text).collect(), + Value::Object(map) => { + let mut out = map.get("text").map(flatten_text).unwrap_or_default(); + if let Some(extra) = map.get("extra") { + out.push_str(&flatten_text(extra)); + } + out + } + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => String::new(), + } +} + +fn strip_formatting(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut chars = text.chars(); + while let Some(c) = chars.next() { + if c == '§' { + chars.next(); + } else { + out.push(c); + } + } + out +} diff --git a/packages/oneclient_content/src/packages/provider/curseforge.rs b/packages/oneclient_content/src/packages/provider/curseforge.rs index c999f412..53f5366b 100644 --- a/packages/oneclient_content/src/packages/provider/curseforge.rs +++ b/packages/oneclient_content/src/packages/provider/curseforge.rs @@ -487,7 +487,7 @@ struct CfHash { enum CfClass { Mod = 6, ResourcePack = 12, - DataPack = 4472, + DataPack = 6945, Shader = 6552, Modpack = 4471, } @@ -633,7 +633,7 @@ fn cf_class_id(t: ContentType) -> u32 { match t { ContentType::Mod => 6, ContentType::ResourcePack => 12, - ContentType::DataPack => 4472, + ContentType::DataPack => 6945, ContentType::Shader => 6552, ContentType::Modpack => 4471, ContentType::World => 6, @@ -669,7 +669,7 @@ impl From for CfClass { match value { 6 => Self::Mod, 12 => Self::ResourcePack, - 4472 => Self::DataPack, + 6945 => Self::DataPack, 6552 => Self::Shader, 4471 => Self::Modpack, _ => Self::Mod, diff --git a/packages/oneclient_content/src/packages/provider/modrinth.rs b/packages/oneclient_content/src/packages/provider/modrinth.rs index 477ce6f5..a4d028d5 100644 --- a/packages/oneclient_content/src/packages/provider/modrinth.rs +++ b/packages/oneclient_content/src/packages/provider/modrinth.rs @@ -198,7 +198,10 @@ impl PackageProvider for ModrinthProvider { project_type: String, } let tags: Vec = fetch_json(&ctx.net, Method::GET, &v2("/tag/category"), None).await?; - let want = content_type.modrinth_type(); + let want = match content_type { + ContentType::DataPack => ContentType::Mod.modrinth_type(), + other => other.modrinth_type(), + }; Ok(tags .into_iter() .filter(|t| t.project_type == want) diff --git a/packages/oneclient_core/src/lib.rs b/packages/oneclient_core/src/lib.rs index 749d31c5..e9878b5a 100644 --- a/packages/oneclient_core/src/lib.rs +++ b/packages/oneclient_core/src/lib.rs @@ -41,6 +41,10 @@ pub use oneclient_cluster::logs::{ pub use oneclient_cluster::screenshots::{ ScreenshotInfo, ScreenshotsError, delete_screenshot, list_cluster_screenshots, load_screenshot, }; +pub use oneclient_cluster::worlds::{ + DataPackInfo, LEVEL_DAT, PackIcon, WORLD_ICON, WorldInfo, WorldsError, add_world_datapacks, + delete_world, delete_world_datapack, list_cluster_worlds, list_world_datapacks, world_size, +}; pub use oneclient_content::bundles::{ ApplyBundleUpdatesResult, Bundle, BundleArchive, BundleError, BundleFile, BundleFileKind, BundleFileType, BundleManifest, BundleOptionalPackage, BundleUpdateCheckResult,