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:
parent
48a25f0c2c
commit
abb41dcc52
36 changed files with 2427 additions and 213 deletions
|
|
@ -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>>,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {},
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>>);
|
||||
|
||||
|
|
|
|||
|
|
@ -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::{
|
||||
|
|
|
|||
115
packages/oneclient_app/src/hooks/queries/folder_watch.rs
Normal file
115
packages/oneclient_app/src/hooks/queries/folder_watch.rs
Normal 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(¤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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: ¬ify::Event) -> bool {
|
||||
fn touches_image(_root: &Path, event: &Event) -> bool {
|
||||
if matches!(event.kind, EventKind::Access(_)) {
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
199
packages/oneclient_app/src/hooks/queries/worlds.rs
Normal file
199
packages/oneclient_app/src/hooks/queries/worlds.rs
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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.))
|
||||
|
|
|
|||
109
packages/oneclient_app/src/view/app/browser/world_prompt.rs
Normal file
109
packages/oneclient_app/src/view/app/browser/world_prompt.rs
Normal 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(),
|
||||
],
|
||||
)
|
||||
}
|
||||
}
|
||||
312
packages/oneclient_app/src/view/app/cluster/datapacks.rs
Normal file
312
packages/oneclient_app/src/view/app/cluster/datapacks.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
501
packages/oneclient_app/src/view/app/cluster/folder_list.rs
Normal file
501
packages/oneclient_app/src/view/app/cluster/folder_list.rs
Normal 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()
|
||||
}
|
||||
|
|
@ -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::*;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.)
|
||||
|
|
|
|||
332
packages/oneclient_app/src/view/app/cluster/worlds.rs
Normal file
332
packages/oneclient_app/src/view/app/cluster/worlds.rs
Normal 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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
326
packages/oneclient_cluster/src/worlds/mod.rs
Normal file
326
packages/oneclient_cluster/src/worlds/mod.rs
Normal 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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue