feat: better UX with folder data changing

This commit is contained in:
LynithDev 2026-09-06 01:08:22 +02:00
parent 7560d7e8b9
commit 776b5a8562
3 changed files with 288 additions and 40 deletions

View file

@ -3,6 +3,7 @@ use std::path::{Path, PathBuf};
use freya::prelude::*;
use freya::router::RouterContext;
use oneclient_core::relocate::RelocationPlan;
use oneclient_core::settings::data_dir;
use oneclient_core::storage::format_bytes;
use super::{section_header, settings_page, settings_row};
@ -126,6 +127,21 @@ impl Component for DataFolder {
.on_press(move |_| browse(pending, error, checking))
.text(if checking_now { "Checking…" } else { "Change…" });
let mut buttons = Vec::new();
if !data_dir::is_default(Path::new(&data_dir)) {
buttons.push(
Button::new()
.secondary()
.small()
.disabled(checking_now)
.on_press(move |_| plan(pending, error, checking, Source::Default))
.text("Reset"),
);
}
buttons.push(change);
let mut section = rect()
.vertical()
.width(Size::fill())
@ -134,7 +150,7 @@ impl Component for DataFolder {
IconType::Folder,
"Launcher Folder",
data_dir.clone(),
row_actions(PathBuf::from(data_dir), change),
row_actions(PathBuf::from(data_dir), buttons),
));
if let Some(message) = error.read().clone() {
@ -161,7 +177,7 @@ impl Component for DataFolder {
format_bytes(left.bytes),
left.path.display()
),
row_actions(left.path, remove),
row_actions(left.path, vec![remove]),
));
}
@ -177,31 +193,55 @@ impl Component for DataFolder {
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Source {
/// Whatever folder the picker comes back with
Picked,
/// The folder OneClient uses when nothing is set
Default,
}
fn browse(
pending: State<Option<RelocationPlan>>,
error: State<Option<String>>,
checking: State<bool>,
) {
plan(pending, error, checking, Source::Picked);
}
fn plan(
mut pending: State<Option<RelocationPlan>>,
mut error: State<Option<String>>,
mut checking: State<bool>,
source: Source,
) {
if *checking.peek() {
return;
}
spawn(async move {
let mut dialog =
rfd::AsyncFileDialog::new().set_title("Choose where OneClient should store game data");
let picked = match source {
Source::Picked => {
let mut dialog = rfd::AsyncFileDialog::new()
.set_title("Choose where OneClient should store game data");
if let Some(start) = oneclient_common::paths::picker_start_dir() {
dialog = dialog.set_directory(start);
}
if let Some(start) = oneclient_common::paths::picker_start_dir() {
dialog = dialog.set_directory(start);
}
let Some(handle) = dialog.pick_folder().await else {
return;
let Some(handle) = dialog.pick_folder().await else {
return;
};
Some(handle.path().to_path_buf())
}
Source::Default => None,
};
checking.set(true);
error.set(None);
match plan_move(handle.path()).await {
match plan_move(picked.as_deref()).await {
Ok(planned) => pending.set(Some(planned)),
Err(message) => error.set(Some(message)),
}
@ -210,18 +250,27 @@ fn browse(
});
}
async fn plan_move(picked: &Path) -> Result<RelocationPlan, String> {
async fn plan_move(picked: Option<&Path>) -> Result<RelocationPlan, String> {
let state = crate::launcher::state().map_err(|err| err.to_string())?;
oneclient_core::relocate::plan(&state, picked).await
match picked {
Some(picked) => oneclient_core::relocate::plan(&state, picked).await,
None => oneclient_core::relocate::plan_default(&state).await,
}
}
fn row_actions(folder: PathBuf, action: Button) -> impl IntoElement {
rect()
fn row_actions(folder: PathBuf, actions: Vec<Button>) -> impl IntoElement {
let mut row = rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(8.)
.child(open_folder_button(folder))
.child(action)
.child(open_folder_button(folder));
for action in actions {
row = row.child(action);
}
row
}
fn confirm_move(

View file

@ -64,6 +64,27 @@ pub struct Leftovers {
}
pub async fn plan(state: &LauncherState, picked: &Path) -> Result<RelocationPlan, String> {
let from = ready_to_move(state)?;
let checked = data_dir::check(picked).await?;
weigh(state, from, checked, &[]).await
}
pub async fn plan_default(state: &LauncherState) -> Result<RelocationPlan, String> {
let from = ready_to_move(state)?;
let home = data_dir::default_path()?;
if home == from {
return Err("Your game data is already in the default folder.".to_string());
}
let keep = config_owned_names(&home);
let checked = data_dir::check_exact(&home).await?;
weigh(state, from, checked, &keep).await
}
fn ready_to_move(state: &LauncherState) -> Result<PathBuf, String> {
let from = current_dir()?;
if let Some(reason) = busy_reason(state) {
@ -74,7 +95,16 @@ pub async fn plan(state: &LauncherState, picked: &Path) -> Result<RelocationPlan
return Err(reason);
}
let checked = data_dir::check(picked).await?;
Ok(from)
}
/// `settled` names what the destination is allowed to be holding already
async fn weigh(
state: &LauncherState,
from: PathBuf,
checked: data_dir::DataDirCheck,
settled: &[OsString],
) -> Result<RelocationPlan, String> {
let to = checked.path;
if to == from {
@ -83,7 +113,7 @@ pub async fn plan(state: &LauncherState, picked: &Path) -> Result<RelocationPlan
if to.starts_with(&from) || from.starts_with(&to) {
return Err(format!(
"Pick a folder outside {} — one cannot hold the other.",
"Pick a folder outside {} (one cannot hold the other).",
from.display()
));
}
@ -95,19 +125,18 @@ pub async fn plan(state: &LauncherState, picked: &Path) -> Result<RelocationPlan
));
}
if polyio::dir_has_content(&to).await {
return Err(format!("{} is not empty. Pick an empty folder.", to.display()));
}
if let Some(old) = leftovers(state).await {
return Err(format!(
"Clear the {} still sitting in {} first — OneClient keeps track of one old folder at \
a time.",
"Clear the {} still sitting in {} first. OneClient keeps track of one old folder at a time.",
format_bytes(old.bytes),
old.path.display()
));
}
if !data_dir::looks_empty(&to, settled).await {
return Err(format!("{} is not empty. Pick an empty folder.", to.display()));
}
let bytes = collect(&from, &skipped_names(&from)).await?.total;
let available = data_dir::available_space(&to);
@ -278,7 +307,7 @@ fn busy_reason(state: &LauncherState) -> Option<String> {
}
Some(format!(
"Close Minecraft first — {active} instance{} still open.",
"Close Minecraft first. {active} instance{} still open.",
if active == 1 { " is" } else { "s are" }
))
}
@ -403,9 +432,16 @@ async fn collect(root: &Path, skip_top: &[OsString]) -> Result<Found, String> {
if file_type.is_dir() {
found.dirs.push(relative);
stack.push((path, false));
} else if let Ok(meta) = entry.metadata().await {
found.total += meta.len();
found.files.push((relative, meta.len()));
} else if file_type.is_file() {
if let Ok(meta) = entry.metadata().await {
found.total += meta.len();
found.files.push((relative, meta.len()));
}
} else {
tracing::debug!(
path = %relative.display(),
"leaving a socket or pipe behind"
);
}
}
}
@ -728,6 +764,30 @@ mod tests {
std::fs::remove_dir_all(dir).ok();
}
#[cfg(unix)]
#[tokio::test]
async fn a_socket_is_walked_past_rather_than_queued_for_copying() {
let root = polyio::testing::ScratchDir::new("relocate_socket");
let dir = root.path();
let socket = std::os::unix::net::UnixListener::bind(dir.join("ipc.sock")).unwrap();
polyio::write(dir.join("kept.bin"), vec![0u8; 100])
.await
.unwrap();
let found = collect(dir, &[]).await.unwrap();
assert_eq!(
found.files.len(),
1,
"a socket cannot be copied, so it must never reach the copy list"
);
assert_eq!(found.total, 100, "nor count towards what has to fit");
drop(socket);
std::fs::remove_dir_all(dir).ok();
}
#[tokio::test]
async fn a_folder_kept_out_of_the_copy_is_kept_out_of_the_total_too() {
let root = polyio::testing::ScratchDir::new("relocate_skip_dir");

View file

@ -1,3 +1,4 @@
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use oneclient_common::paths;
@ -10,23 +11,81 @@ const LOW_SPACE_BYTES: u64 = 5 * 1000 * 1000 * 1000;
const PROBE_NAME: &str = ".oneclient_write_test";
/// What the operating system leaves lying around on its own. A folder holding
/// nothing else is one the person who picked it would call empty.
const OS_CLUTTER: &[&str] = &[
".DS_Store",
".localized",
".Spotlight-V100",
".Trashes",
".fseventsd",
"desktop.ini",
"Thumbs.db",
"$RECYCLE.BIN",
"System Volume Information",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DataDirCheck {
pub path: PathBuf,
pub warning: Option<String>,
}
#[must_use]
pub fn resolve(picked: &Path) -> PathBuf {
if picked.file_name().is_some_and(|name| name == FOLDER_NAME) {
/// Whether `dir` holds nothing worth worrying about: a folder that is missing,
/// unreadable, or carrying only [`OS_CLUTTER`] and `ignored` names counts as empty
pub async fn looks_empty(dir: &Path, ignored: &[OsString]) -> bool {
let Ok(mut entries) = polyio::read_dir(dir).await else {
return true;
};
while let Ok(Some(entry)) = entries.next_entry().await {
let name = entry.file_name();
if ignored.contains(&name) {
continue;
}
let Some(name) = name.to_str() else {
return false;
};
if !OS_CLUTTER.iter().any(|junk| junk.eq_ignore_ascii_case(name)) {
return false;
}
}
true
}
pub async fn resolve(picked: &Path) -> PathBuf {
if picked.file_name().is_some_and(|name| name == FOLDER_NAME)
|| looks_empty(picked, &[]).await
{
picked.to_path_buf()
} else {
picked.join(FOLDER_NAME)
}
}
/// Where the game data sits when nothing is set: alongside the settings
pub fn default_path() -> Result<PathBuf, String> {
paths::config_dir()
.map(Path::to_path_buf)
.map_err(|err| format!("Couldn't work out where OneClient keeps its settings: {err}"))
}
#[must_use]
pub fn is_default(dir: &Path) -> bool {
default_path().is_ok_and(|home| home == dir)
}
pub async fn check(picked: &Path) -> Result<DataDirCheck, String> {
let path = resolve(picked);
check_exact(&resolve(picked).await).await
}
/// Checks the folder handed over rather than one derived from it
pub async fn check_exact(path: &Path) -> Result<DataDirCheck, String> {
let path = path.to_path_buf();
let existed = polyio::try_exists(&path).await.unwrap_or(false);
polyio::create_dir_all(&path)
@ -107,16 +166,92 @@ pub(crate) fn available_space(path: &Path) -> Option<u64> {
mod tests {
use super::*;
#[test]
fn a_drive_root_gets_a_folder_of_its_own() {
let resolved = resolve(Path::new(std::path::MAIN_SEPARATOR_STR));
assert_eq!(resolved.file_name().unwrap(), FOLDER_NAME);
#[tokio::test]
async fn a_folder_with_things_in_it_gets_a_folder_of_its_own() {
let root = polyio::testing::ScratchDir::new("data_dir_busy");
let dir = root.path();
polyio::create_dir_all(dir).await.unwrap();
polyio::write(dir.join("holiday.jpg"), b"mine".as_slice())
.await
.unwrap();
assert_eq!(resolve(dir).await, dir.join(FOLDER_NAME));
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn resolving_twice_does_not_nest() {
let once = resolve(Path::new("D:/Games"));
assert_eq!(resolve(&once), once);
#[tokio::test]
async fn an_empty_folder_is_used_as_it_stands() {
let root = polyio::testing::ScratchDir::new("data_dir_empty");
let dir = root.path();
polyio::create_dir_all(dir).await.unwrap();
assert_eq!(
resolve(dir).await,
dir.to_path_buf(),
"a folder made for this should not get another one inside it"
);
std::fs::remove_dir_all(dir).ok();
}
#[tokio::test]
async fn a_folder_the_finder_has_been_in_still_counts_as_empty() {
let root = polyio::testing::ScratchDir::new("data_dir_clutter");
let dir = root.path();
polyio::create_dir_all(dir).await.unwrap();
polyio::write(dir.join(".DS_Store"), b"junk".as_slice())
.await
.unwrap();
assert_eq!(
resolve(dir).await,
dir.to_path_buf(),
"the folder looks empty to the person who picked it"
);
std::fs::remove_dir_all(dir).ok();
}
#[tokio::test]
async fn resolving_twice_does_not_nest() {
let root = polyio::testing::ScratchDir::new("data_dir_nest");
let dir = root.path();
polyio::create_dir_all(dir).await.unwrap();
polyio::write(dir.join("holiday.jpg"), b"mine".as_slice())
.await
.unwrap();
let once = resolve(dir).await;
assert_eq!(resolve(&once).await, once);
std::fs::remove_dir_all(dir).ok();
}
#[tokio::test]
async fn the_settings_that_stayed_behind_do_not_make_the_default_folder_look_busy() {
let root = polyio::testing::ScratchDir::new("data_dir_settled");
let dir = root.path();
polyio::create_dir_all(dir).await.unwrap();
polyio::write(dir.join("settings.json"), b"{}".as_slice())
.await
.unwrap();
let settled = [OsString::from("settings.json")];
assert!(
looks_empty(dir, &settled).await,
"moving back in has to be allowed over the settings that never left"
);
polyio::create_dir_all(dir.join("clusters")).await.unwrap();
assert!(
!looks_empty(dir, &settled).await,
"game data already sitting there is another matter"
);
std::fs::remove_dir_all(dir).ok();
}
#[tokio::test]
@ -124,6 +259,10 @@ mod tests {
let root = polyio::testing::ScratchDir::new("data_dir_check");
polyio::create_dir_all(root.path()).await.unwrap();
polyio::write(root.join("holiday.jpg"), b"mine".as_slice())
.await
.unwrap();
let checked = check(root.path()).await.unwrap();
assert_eq!(checked.path, root.join(FOLDER_NAME));
assert!(