feat: better UX with folder data changing
This commit is contained in:
parent
7560d7e8b9
commit
776b5a8562
3 changed files with 288 additions and 40 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue