feat: worlds and datapacks views (#870)

* feat: added worlds and datapacks tab

* feat: added datapacks in browser; fixed datapacks view loading too long

* feat: added icons to datapacks

* fix: scope world query invalidation to the world that changed

* fix: keep the selected data pack world in one place and guard deletes

* chore: share one dialog shell between the world prompt and delete confirm

* fix: stop watching every subfolder of saves

---------

Co-authored-by: LynithDev <61880709+LynithDev@users.noreply.github.com>
This commit is contained in:
Maciek 2026-09-28 21:05:52 +02:00 • committed by GitHub
parent 48a25f0c2c
commit abb41dcc52
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 2427 additions and 213 deletions

View file

@ -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<ContentType>) -> 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::<PathBuf, ContentType>::new);
let clusters: Vec<Cluster> = 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<String> = 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<Vec<PathBuf>>) -> Element {
.into_element()
}
#[allow(clippy::too_many_arguments)]
fn prompt_body(
files: &[PathBuf],
clusters: &[Cluster],
cluster_id: ClusterId,
route_type: Option<ContentType>,
selected_cluster: State<Option<ClusterId>>,
worlds: &[String],
remembered_world: State<HashMap<ClusterId, String>>,
overrides: State<HashMap<PathBuf, ContentType>>,
dispatch: crate::Actions,
mut pending: State<Vec<PathBuf>>,
) -> 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<HashMap<ClusterId, String>>,
) -> 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<HashMap<PathBuf, ContentType>>,

View file

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

View file

@ -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 {},

View file

@ -233,7 +233,9 @@ pub fn package_context_menu(
menu
}
fn on_secondary(handler: Option<EventHandler<(f32, f32)>>) -> impl FnMut(Event<PressEventData>) {
pub(crate) fn on_secondary(
handler: Option<EventHandler<(f32, f32)>>,
) -> impl FnMut(Event<PressEventData>) {
move |e: Event<PressEventData>| {
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())
}

View file

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

View file

@ -1063,12 +1063,14 @@ impl Actions {
provider: ProviderId,
project_id: impl Into<String>,
version_id: impl Into<String>,
world: Option<String>,
) {
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<String>,
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

View file

@ -69,6 +69,17 @@ pub fn use_browser_type() -> State<String> {
consume_root_context::<BrowserTypeState>().0
}
#[derive(Clone)]
pub struct DataPackWorldState(pub State<HashMap<ClusterId, String>>);
pub fn use_provide_datapack_world(state: DataPackWorldState) {
use_hook(move || provide_root_context(state));
}
pub fn use_datapack_world() -> State<HashMap<ClusterId, String>> {
consume_root_context::<DataPackWorldState>().0
}
#[derive(Clone)]
pub struct LinkConfirmState(pub State<Option<String>>);

View file

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

View file

@ -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<PathBuf>,
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<Event>| 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<PathBuf>) {
let current: HashSet<PathBuf> = 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(&current) {
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;
}
}

View file

@ -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,
};

View file

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

View file

@ -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<Arc<Semaphore>> = OnceLock::new();
@ -88,76 +86,14 @@ pub fn use_local_image(path: PathBuf, max_edge: u32) -> UseQuery<LocalImageQuery
))
}
const WATCH_QUIET: Duration = Duration::from_millis(400);
pub fn use_screenshot_folder_watch(
folder: Option<PathBuf>,
query: UseQuery<ClusterScreenshotsQuery>,
) {
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<ClusterScreenshotsQuery>,
) -> 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<notify::Event>| 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: &notify::Event) -> bool {
fn touches_image(_root: &Path, event: &Event) -> bool {
if matches!(event.kind, EventKind::Access(_)) {
return false;
}

View file

@ -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<WorldInfo>;
type Err = LauncherError;
type Keys = ClusterWorldsKeys;
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
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<Self::Ok, Self::Err> {
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<DataPackInfo>;
type Err = LauncherError;
type Keys = WorldDataPacksKeys;
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
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<ClusterWorldsQuery> {
use_query(Query::new(
ClusterWorldsKeys { cluster_id },
ClusterWorldsQuery,
))
}
pub fn use_world_size(cluster_id: i64, world: String) -> UseQuery<WorldSizeQuery> {
let keys = WorldSizeKeys { cluster_id, world };
use_query(Query::new(keys.clone(), WorldSizeQuery(keys)))
}
pub fn try_world_size(query: &UseQuery<WorldSizeQuery>) -> Option<u64> {
super::state::settled_or_loading(query)
}
pub fn use_world_datapacks(cluster_id: i64, world: String) -> UseQuery<WorldDataPacksQuery> {
use_query(Query::new(
WorldDataPacksKeys { cluster_id, world },
WorldDataPacksQuery,
))
}
pub fn use_saves_folder_watch(folder: Option<PathBuf>, query: UseQuery<ClusterWorldsQuery>) {
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<ClusterWorldsQuery>) -> Option<Vec<WorldInfo>> {
super::state::settled_or_loading(query)
}
pub fn try_world_datapacks(query: &UseQuery<WorldDataPacksQuery>) -> Option<Vec<DataPackInfo>> {
super::state::settled_or_loading(query)
}
pub fn spawn_world_task(
dispatch: crate::Actions,
failure_title: &'static str,
task: impl Future<Output = Result<(), LauncherError>> + '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::<WorldDataPacksQuery>::invalidate_all().await;
QueriesStorage::<WorldSizeQuery>::invalidate_matching(WorldSizeKeys { cluster_id, world })
.await;
}
async fn run_blocking<T: Send + 'static>(
task: impl FnOnce() -> Result<T, ClusterError> + Send + 'static,
) -> Result<T, LauncherError> {
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<PathBuf>,
) -> 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::<ClusterWorldsQuery>::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
}

View file

@ -292,6 +292,7 @@ pub async fn install_package(
project_id: &str,
version_id: &str,
cluster_id: i64,
world: Option<String>,
allow_flagged: bool,
) -> Result<PackageInstall, FlaggedInstall> {
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<LauncherState>,
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

View file

@ -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::<PathBuf>::new);

View file

@ -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),
];

View file

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

View file

@ -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::<String>);
// 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()
}
}

View file

@ -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<String> = BROWSE_TYPES
let types: Vec<(&str, &str)> = BROWSE_TYPES
.into_iter()
.filter(|(slug, _)| datapacks || *slug != DATAPACK_SLUG)
.collect();
let labels: Vec<String> = 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<i64> = clusters.iter().map(|c| c.id).collect();
let versions: Vec<String> = 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,
});
}

View file

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

View file

@ -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<State<Option<String>>>,
}
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::<String>);
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()
}
}

View file

@ -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<VersionSummary>,
total_versions: usize,
versions_page: State<usize>,
provider: ProviderId,
project_id: String,
cluster_id: i64,
dispatch: Actions,
installer: Installer,
on_remove: EventHandler<(String, String)>,
installed: Option<Installed>,
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<usize>) -> 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<InstalledVersion>,
// 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<InstalledVersion>,
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");
};

View file

@ -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<ProjectDetail>,
latest_version: Option<String>,
provider: ProviderId,
cluster_id: i64,
dispatch: Actions,
installer: Installer,
confirm: State<Option<String>>,
installed: Option<Installed>,
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.))

View file

@ -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<Option<String>>,
}
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<String> = 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(),
],
)
}
}

View file

@ -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<HashMap<i64, String>>) {
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<String> = 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::<String>);
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<PathBuf> = 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<DataPackInfo> = 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()
}
}

View file

@ -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<Element>, controls: Vec<Element>) -> 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<String>) -> 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<ViewLayout>) -> 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<Element>,
notices: Vec<String>,
) -> 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<String>,
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<Element>,
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()
}

View file

@ -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::*;

View file

@ -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,
))
}
}

View file

@ -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<String>,
}
impl ContentBox {
@ -796,8 +795,14 @@ impl ContentBox {
cluster_id,
kind,
layout,
notices: Vec::new(),
}
}
pub(super) fn notices(mut self, notices: Vec<String>) -> 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<String>) -> impl IntoElement {
pub(crate) fn empty_title(text: impl Into<String>) -> impl IntoElement {
label()
.text(text.into())
.font_size(14.)
.color(colors::fg_secondary())
}
fn empty_hint(text: impl Into<String>) -> impl IntoElement {
pub(crate) fn empty_hint(text: impl Into<String>) -> impl IntoElement {
label()
.text(text.into())
.font_size(12.)

View file

@ -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<HashMap<i64, String>>) {
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::<String>);
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<WorldInfo> = 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<EventHandler<()>>,
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()))
}
}
}

View file

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

View file

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

View file

@ -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<PathBuf>,
pub last_played: DateTime<Utc>,
}
#[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<Utc>,
pub description: Option<String>,
pub icon: Option<PackIcon>,
}
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<PathBuf> {
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<Utc> {
meta.modified()
.map(DateTime::<Utc>::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<Vec<WorldInfo>> {
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<u64> {
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<Vec<DataPackInfo>> {
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<DataPackInfo> = 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<String>, Option<PackIcon>) {
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<PackIcon> {
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<String> {
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
}

View file

@ -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<u32> for CfClass {
match value {
6 => Self::Mod,
12 => Self::ResourcePack,
4472 => Self::DataPack,
6945 => Self::DataPack,
6552 => Self::Shader,
4471 => Self::Modpack,
_ => Self::Mod,

View file

@ -198,7 +198,10 @@ impl PackageProvider for ModrinthProvider {
project_type: String,
}
let tags: Vec<Tag> = 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)

View file

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