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
This commit is contained in:
Julian Chang 2026-07-06 05:02:47 -04:00
parent a645388a40
commit 0f04462863
No known key found for this signature in database
10 changed files with 143 additions and 7 deletions

View file

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

View file

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

View file

@ -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<std::path::PathBuf>,
) -> 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()

View file

@ -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<LogFileInfo>,
size_text: String,
has_log: bool,
folder: Option<PathBuf>,
selected: State<PathBuf>,
mut confirm: State<Option<Confirm>>,
) -> 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()

View file

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

View file

@ -137,6 +137,7 @@ pub(super) fn header(
content_type: ContentType,
cluster_id: i64,
dispatch: BridgeDispatch,
folder: Option<std::path::PathBuf>,
) -> 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)),
)

View file

@ -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<PathBuf>,
view_mode: State<ViewLayout>,
mut edit_mode: State<bool>,
mut selected: State<HashSet<PathBuf>>,
@ -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();

View file

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

View file

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

View file

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