From 0f044628635d99c0b73503c44102a811e6ad7082 Mon Sep 17 00:00:00 2001 From: Julian Chang Date: Mon, 6 Jul 2026 05:02:47 -0400 Subject: [PATCH] Folder button Automatically add stuff added to the mods folder to the next-ran cluster if in a shared directory Add a txt explanation as to why certain folders are empty in shared directory --- .../oneclient_app/src/components/button.rs | 11 +++ packages/oneclient_app/src/components/mod.rs | 2 +- .../oneclient_app/src/layout/cluster_shell.rs | 3 + .../src/view/app/cluster/logs.rs | 8 +- .../view/app/cluster/package_manager/mod.rs | 5 + .../view/app/cluster/package_manager/views.rs | 2 + .../src/view/app/cluster/screenshots.rs | 10 +- packages/oneclient_core/src/game/launch.rs | 8 ++ packages/oneclient_core/src/game/mod.rs | 2 +- .../oneclient_core/src/game/shared_dir.rs | 99 +++++++++++++++++++ 10 files changed, 143 insertions(+), 7 deletions(-) diff --git a/packages/oneclient_app/src/components/button.rs b/packages/oneclient_app/src/components/button.rs index 176b0596..cf9ac799 100644 --- a/packages/oneclient_app/src/components/button.rs +++ b/packages/oneclient_app/src/components/button.rs @@ -422,4 +422,15 @@ pub fn link_button() -> Button { .primary() .child(Icon::new(IconType::LinkExternal01).size(14.).color(colors::fg_primary())) .child(label().text("Open")) +} + +pub fn open_folder_button(folder: std::path::PathBuf) -> Button { + Button::new() + .secondary() + .icon() + .on_press(move |_| { + std::fs::create_dir_all(&folder).ok(); + crate::platform::open_url(&folder.to_string_lossy()); + }) + .child(Icon::new(IconType::Folder).size(16.)) } \ No newline at end of file diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index c5dd8447..fbb4f316 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -29,7 +29,7 @@ mod toggle; pub use active_cluster_panel::ActiveClusterPanel; pub use avatar::Avatar; -pub use button::{Button, ButtonSize, ButtonVariant, link_button}; +pub use button::{Button, ButtonSize, ButtonVariant, link_button, open_folder_button}; pub use charts::{BarChart, ValueUnit}; pub use cluster_landscape_art::ClusterLandscapeArt; pub use context_menu::ContextMenu; diff --git a/packages/oneclient_app/src/layout/cluster_shell.rs b/packages/oneclient_app/src/layout/cluster_shell.rs index 181061b2..cd614ecf 100644 --- a/packages/oneclient_app/src/layout/cluster_shell.rs +++ b/packages/oneclient_app/src/layout/cluster_shell.rs @@ -138,6 +138,7 @@ impl Component for ClusterShell { dispatch, launch_state, game.is_running(cluster_id), + cluster.game_dir().ok(), ) .into_element() }); @@ -235,6 +236,7 @@ fn cluster_header( dispatch: crate::BridgeDispatch, launch_state: (&'static str, bool), running: bool, + folder: Option, ) -> impl IntoElement { let (launch_label, launch_enabled) = launch_state; let kill_dispatch = dispatch.clone(); @@ -272,6 +274,7 @@ fn cluster_header( .horizontal() .cross_align(Alignment::Center) .spacing(10.) + .maybe_child(folder.map(crate::components::open_folder_button)) .maybe(running, |el| { el.child( Button::new() diff --git a/packages/oneclient_app/src/view/app/cluster/logs.rs b/packages/oneclient_app/src/view/app/cluster/logs.rs index b456f32d..41d07fda 100644 --- a/packages/oneclient_app/src/view/app/cluster/logs.rs +++ b/packages/oneclient_app/src/view/app/cluster/logs.rs @@ -8,7 +8,7 @@ use oneclient_core::{LogFileInfo, LogKind, LogLevel}; use crate::components::{ Button, Icon, IconType, LogViewer, OverlayPopup, ScrollArea, Segment, SegmentedControl, - TextInput, + TextInput, open_folder_button, }; use crate::hooks::{ LogAction, UploadLogKeys, UseLogAction, UseUploadLog, invalidate_logs_queries, @@ -159,9 +159,10 @@ impl Component for ClusterLogs { None, ); - let Some(_cluster) = load_cluster(cluster_id) else { + let Some(cluster) = load_cluster(cluster_id) else { return cluster_not_found(); }; + let folder = cluster.game_dir().ok().map(|d| d.join("logs")); let files = try_cluster_logs(&logs_query).unwrap_or_default(); let has_log = !files.is_empty(); @@ -190,6 +191,7 @@ impl Component for ClusterLogs { selected_info.clone(), size_text, has_log, + folder, selected, confirm, )) @@ -214,6 +216,7 @@ fn top_bar( selected_info: Option, size_text: String, has_log: bool, + folder: Option, selected: State, mut confirm: State>, ) -> impl IntoElement { @@ -249,6 +252,7 @@ fn top_bar( .color(colors::fg_secondary()), ) .child(rect().width(Size::flex(1.0))) + .maybe_child(folder.map(open_folder_button)) .child( Button::new() .secondary() diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs index a2295298..326f4eb6 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs @@ -281,6 +281,10 @@ impl Component for PackageManager { let content_type = self.content_type; let dispatch = use_dispatch(); + let folder = super::load_cluster(cluster_id) + .and_then(|c| c.game_dir().ok()) + .map(|dir| dir.join(content_type.folder_name())); + let total = items.len(); let enabled = items.iter().filter(|i| i.enabled).count(); @@ -325,6 +329,7 @@ impl Component for PackageManager { content_type, cluster_id, dispatch, + folder, )) .child(toolbar(search, sort, sort_filter, layout)) .child(tab_bar(&tabs, &items, active_idx, active)) diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs index f71e8401..2c283fcc 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs @@ -137,6 +137,7 @@ pub(super) fn header( content_type: ContentType, cluster_id: i64, dispatch: BridgeDispatch, + folder: Option, ) -> impl IntoElement { rect() .horizontal() @@ -159,6 +160,7 @@ pub(super) fn header( .main_align(Alignment::End) .cross_align(Alignment::Center) .spacing(8.) + .maybe_child(folder.map(crate::components::open_folder_button)) .child(add_from_file_button(cluster_id, content_type, dispatch)) .child(browse_button(cluster_id, package_type)), ) diff --git a/packages/oneclient_app/src/view/app/cluster/screenshots.rs b/packages/oneclient_app/src/view/app/cluster/screenshots.rs index 2ff5d3ac..ed13be2d 100644 --- a/packages/oneclient_app/src/view/app/cluster/screenshots.rs +++ b/packages/oneclient_app/src/view/app/cluster/screenshots.rs @@ -8,6 +8,7 @@ use oneclient_core::settings::ViewLayout; use crate::components::{ Button, ContextMenu, Icon, IconType, LocalImage, OverlayPopup, ScreenshotViewer, ScrollArea, + open_folder_button, Segment, SegmentedControl, }; use crate::hooks::{ @@ -36,9 +37,10 @@ pub struct ClusterScreenshots { impl Component for ClusterScreenshots { fn render(&self) -> impl IntoElement { - let Some(_cluster) = load_cluster(self.cluster_id) else { + let Some(cluster) = load_cluster(self.cluster_id) else { return cluster_not_found(); }; + let folder = cluster.game_dir().ok().map(|d| d.join("screenshots")); let query = use_cluster_screenshots(self.cluster_id); let action = use_screenshot_action(); @@ -55,7 +57,7 @@ impl Component for ClusterScreenshots { let grid_width = use_state(|| 0f32); let mut menu = use_state(|| None::<(f32, f32, PathBuf)>); - let toolbar = toolbar_row(&shots, view_mode, edit_mode, selected, confirm_delete); + let toolbar = toolbar_row(&shots, folder, view_mode, edit_mode, selected, confirm_delete); let content: Element = if shots.is_empty() { empty_state(matches!( @@ -194,6 +196,7 @@ impl Component for ClusterScreenshots { fn toolbar_row( shots: &[ScreenshotInfo], + folder: Option, view_mode: State, mut edit_mode: State, mut selected: State>, @@ -206,7 +209,8 @@ fn toolbar_row( let mut right = rect() .horizontal() .cross_align(Alignment::Center) - .spacing(10.); + .spacing(10.) + .maybe_child((!editing).then_some(folder).flatten().map(open_folder_button)); if editing { let select_all_paths = all_paths.clone(); diff --git a/packages/oneclient_core/src/game/launch.rs b/packages/oneclient_core/src/game/launch.rs index 36a182b2..aa839c14 100644 --- a/packages/oneclient_core/src/game/launch.rs +++ b/packages/oneclient_core/src/game/launch.rs @@ -318,6 +318,7 @@ pub async fn launch_cluster( let post_hook = profile.hook_post.clone(); let cluster_name = cluster.name.clone(); tokio::spawn(async move { + let cluster = cluster; let started = Instant::now(); let status = tokio::select! { status = child.wait() => status, @@ -342,6 +343,13 @@ pub async fn launch_cluster( run_hook(post_hook.as_deref(), &cwd).await; + if !dedicated { + crate::game::import_manual_content(&state.services, &cluster, &cwd).await; + if let Err(err) = crate::game::clear_shared_content(&cwd).await { + tracing::warn!(cluster_id, error = %err, "failed to clear shared content on exit"); + } + } + match status { Ok(status) if status.success() => state .services diff --git a/packages/oneclient_core/src/game/mod.rs b/packages/oneclient_core/src/game/mod.rs index 1b9d5c3e..e717c123 100644 --- a/packages/oneclient_core/src/game/mod.rs +++ b/packages/oneclient_core/src/game/mod.rs @@ -27,4 +27,4 @@ pub use metadata::{ resolve_minecraft_version, }; pub use rules::{validate_rule, validate_rules}; -pub use shared_dir::sync_shared_content; +pub use shared_dir::{clear_shared_content, import_manual_content, sync_shared_content}; diff --git a/packages/oneclient_core/src/game/shared_dir.rs b/packages/oneclient_core/src/game/shared_dir.rs index 0c648dfe..39df7c49 100644 --- a/packages/oneclient_core/src/game/shared_dir.rs +++ b/packages/oneclient_core/src/game/shared_dir.rs @@ -1,4 +1,5 @@ +use std::collections::HashSet; use std::path::Path; use oneclient_db::dao::artifact as artifact_dao; @@ -22,6 +23,8 @@ pub async fn sync_shared_content( ) -> LauncherResult<()> { tokio::fs::create_dir_all(game_dir).await.ok(); + import_manual_content(services, cluster, game_dir).await; + for content_type in SWAP_TYPES { let dir = game_dir.join(content_type.folder_name()); if dir.exists() { @@ -66,6 +69,102 @@ pub async fn sync_shared_content( Ok(()) } +pub async fn import_manual_content(services: &LauncherServices, cluster: &Cluster, game_dir: &Path) { + let linked = match PackageStore::list_linked_artifacts(cluster.id, services).await { + Ok(linked) => linked, + Err(err) => { + tracing::warn!(error = %err, "failed to list links; skipping manual-content import"); + return; + } + }; + + for content_type in SWAP_TYPES { + let dir = game_dir.join(content_type.folder_name()); + let Ok(mut entries) = tokio::fs::read_dir(&dir).await else { + continue; + }; + + let known: HashSet<&str> = linked + .iter() + .filter(|link| link.content_type == content_type) + .map(|link| link.cluster_file_name.as_str()) + .collect(); + + while let Ok(Some(entry)) = entries.next_entry().await { + let Ok(file_type) = entry.file_type().await else { + continue; + }; + if !file_type.is_file() { + continue; + } + + let path = entry.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + if name.starts_with('.') || !has_content_extension(content_type, name) { + continue; + } + if known.contains(name) { + continue; + } + + match PackageStore::import_local_file(&path, content_type, cluster.id, services).await { + Ok(_) => { + tracing::info!(file = name, "registered manually-added shared content") + } + Err(err) => tracing::warn!( + file = name, + error = %err, + "failed to register manually-added shared content" + ), + } + } + } +} + +fn has_content_extension(content_type: ContentType, name: &str) -> bool { + let lower = name.to_lowercase(); + match content_type { + ContentType::Mod => lower.ends_with(".jar"), + ContentType::ResourcePack | ContentType::Shader => lower.ends_with(".zip"), + _ => false, + } +} + +const EMPTY_NOTE_NAME: &str = "WHY_IS_THIS_EMPTY.txt"; + +fn empty_note_body(content_type: ContentType) -> String { + let noun = match content_type { + ContentType::ResourcePack => "resource packs", + ContentType::Shader => "shader packs", + _ => "mods", + }; + format!( + "It's empty here, but nothing is broken!\n\ + \n\ + OneClient keeps your {noun} safe somewhere else and only puts them here \ + while you play. When you close the game, it tidies them away again.\n\ + \n\ + Want to add {noun}? The easy way is right inside OneClient. Or you can drop \ + files in this folder, and OneClient will pick them up the next time you play.\n" + ) +} + +pub async fn clear_shared_content(game_dir: &Path) -> LauncherResult<()> { + for content_type in SWAP_TYPES { + let dir = game_dir.join(content_type.folder_name()); + if dir.exists() { + tokio::fs::remove_dir_all(&dir).await.ok(); + } + tokio::fs::create_dir_all(&dir).await.ok(); + tokio::fs::write(dir.join(EMPTY_NOTE_NAME), empty_note_body(content_type)) + .await + .ok(); + } + Ok(()) +} + async fn sync_fabric_dep_overrides(cluster: &Cluster, game_dir: &Path) -> LauncherResult<()> { let src = cluster.dir()?.join(FABRIC_DEP_OVERRIDES); let dest = game_dir.join(FABRIC_DEP_OVERRIDES);