diff --git a/crates/client/src/collections.rs b/crates/client/src/collections.rs index 5df42f0..35bf7a9 100644 --- a/crates/client/src/collections.rs +++ b/crates/client/src/collections.rs @@ -9,6 +9,7 @@ use nomi_modding::modrinth::{ use crate::{ errors_pool::ErrorPoolExt, + toasts, views::{InstancesConfig, SimpleDependency}, }; @@ -102,9 +103,9 @@ impl<'c> TasksCollection<'c> for GameDownloadingCollection { pub struct GameDeletionCollection; impl<'c> TasksCollection<'c> for GameDeletionCollection { - type Context = (); + type Context = &'c InstancesConfig; - type Target = (); + type Target = InstanceProfileId; type Executor = executors::Linear; @@ -112,8 +113,43 @@ impl<'c> TasksCollection<'c> for GameDeletionCollection { "Game deletion collection" } - fn handle(_context: Self::Context) -> Handler<'c, Self::Target> { - Handler::new(|()| ()) + fn handle(context: Self::Context) -> Handler<'c, Self::Target> { + Handler::new(|id: InstanceProfileId| { + if let Some(instance) = context.find_instance(id.instance()) { + instance.write().remove_profile(id); + if context.update_instance_config(id.instance()).report_error().is_some() { + toasts::add(|toasts| toasts.success("Successfully removed the profile")) + } + } + }) + } +} + +pub struct InstanceDeletionCollection; + +impl<'c> TasksCollection<'c> for InstanceDeletionCollection { + type Context = &'c mut InstancesConfig; + + type Target = Option; + + type Executor = executors::Linear; + + fn name() -> &'static str { + "Instance deletion collection" + } + + fn handle(context: Self::Context) -> Handler<'c, Self::Target> { + Handler::new(|id: Option| { + let Some(id) = id else { + return; + }; + + if context.remove_instance(id).is_none() { + return; + } + + toasts::add(|toasts| toasts.success("Successfully removed the instance")) + }) } } diff --git a/crates/client/src/main.rs b/crates/client/src/main.rs index 6ec1589..35e7371 100644 --- a/crates/client/src/main.rs +++ b/crates/client/src/main.rs @@ -14,7 +14,7 @@ use egui_notify::Toasts; use open_directory::open_directory_native; use std::path::Path; use subscriber::EguiLayer; -use ui_ext::{UiExt, TOASTS_ID}; +use ui_ext::UiExt; use views::{add_tab_menu::AddTab, AddProfileMenu, CreateInstanceMenu, View}; use errors_pool::{ErrorPoolExt, ERRORS_POOL}; @@ -135,7 +135,8 @@ impl eframe::App for MyTabs { .manager .add_collection::(()) .add_collection::(&mut self.context.states.add_profile_menu.fabric_versions) - .add_collection::(()) + .add_collection::(&self.context.states.instances.instances) + .add_collection::(&mut self.context.states.instances.instances) .add_collection::(&self.context.states.instances.instances) .add_collection::(()) .add_collection::(&mut self.context.states.mod_manager.current_project) diff --git a/crates/client/src/ui_ext.rs b/crates/client/src/ui_ext.rs index 84c7ba1..5450021 100644 --- a/crates/client/src/ui_ext.rs +++ b/crates/client/src/ui_ext.rs @@ -1,7 +1,4 @@ use eframe::egui::{self, popup_below_widget, Id, PopupCloseBehavior, Response, RichText, Ui, WidgetText}; -use egui_notify::{Toast, Toasts}; - -pub const TOASTS_ID: &str = "global_egui_notify_toasts"; pub trait UiExt { fn ui(&self) -> &Ui; @@ -27,6 +24,10 @@ pub trait UiExt { ui.label(RichText::new(format!("⚠ {}", text.into())).color(ui.visuals().warn_fg_color)) } + fn warn_irreversible_action(&mut self) -> Response { + self.warn_label_with_icon_before("This action is irreversible.") + } + fn markdown_ui(&mut self, id: egui::Id, markdown: &str) { use parking_lot::Mutex; use std::sync::Arc; diff --git a/crates/client/src/views/profiles.rs b/crates/client/src/views/profiles.rs index 6859262..4a5ecde 100644 --- a/crates/client/src/views/profiles.rs +++ b/crates/client/src/views/profiles.rs @@ -1,7 +1,7 @@ use std::{collections::HashSet, path::PathBuf, sync::Arc}; use anyhow::bail; -use eframe::egui::{self, Align2, RichText, TextWrapMode, Ui}; +use eframe::egui::{self, Align2, Id, RichText, TextWrapMode, Ui}; use egui_extras::{Column, TableBuilder}; use egui_task_manager::{Caller, Task, TaskManager}; use itertools::Itertools; @@ -9,7 +9,7 @@ use nomi_core::{ configs::profile::{ProfileState, VersionProfile}, fs::write_toml_config_sync, game_paths::GamePaths, - instance::{launch::arguments::UserData, load_instances, Instance, InstanceProfileId, ProfilePayload}, + instance::{delete_profile, launch::arguments::UserData, load_instances, Instance, InstanceProfileId, ProfilePayload}, repository::{launcher_manifest::LauncherManifest, username::Username}, }; use parking_lot::RwLock; @@ -18,9 +18,11 @@ use tracing::error; use crate::{ cache::GLOBAL_CACHE, - collections::{AssetsCollection, GameDownloadingCollection, GameRunnerCollection}, + collections::{AssetsCollection, GameDeletionCollection, GameDownloadingCollection, GameRunnerCollection, InstanceDeletionCollection}, download::{task_assets, task_download_version}, errors_pool::ErrorPoolExt, + toasts, + ui_ext::UiExt, TabKind, }; @@ -94,6 +96,13 @@ impl InstancesConfig { self.instances.iter().find(|p| p.read().id() == id).cloned() } + pub fn remove_instance(&mut self, id: usize) -> Option>> { + self.instances + .iter() + .position(|i| i.read().id() == id) + .map(|idx| self.instances.remove(idx)) + } + pub fn load() -> Self { Self { instances: load_instances() @@ -154,7 +163,6 @@ impl InstancesConfig { } impl Instances<'_> { - // TODO: It requires profile to be loaded fn profile_action_ui(&mut self, ui: &mut Ui, profile_payload: &ProfilePayload) { let button = if profile_payload.is_downloaded { ui.add_enabled(self.is_allowed_to_take_action, egui::Button::new("Launch")) @@ -282,15 +290,42 @@ impl Instances<'_> { }); row.col(|ui| { - ui.button("TODO: Delete"); + ui.button_with_confirm_popup(Id::new("confirm_profile_deletion").with(profile.id), "Delete", |ui| { + ui.set_width(200.0); + ui.label("Are you sure you want to delete this profile?"); + ui.warn_irreversible_action(); + + ui.horizontal(|ui| { + let yes_button = ui.button("Yes"); + let no_button = ui.button("No"); + + if yes_button.clicked() { + let id = profile.id; + if let Some(profile) = self.profiles_state.instances.find_profile(id) { + let profile = profile.read(); + let game_version = profile.profile.version().to_owned(); + let task = Task::new( + "Deleting profile", + Caller::standard(async move { + delete_profile(GamePaths::from_id(id), &game_version).await; + id + }), + ); + + self.manager.push_task::(task) + } else { + toasts::add(|toasts| toasts.warning("Cannot find profile to delete")); + } + } + + if yes_button.clicked() || no_button.clicked() { + ui.memory_mut(|mem| mem.close_popup()) + } + }); + }); }); }); } - - // is_deleting.drain(..).for_each(|index| { - // self.profiles_state.instances.instances.remove(index); - // self.profiles_state.instances.update_config_sync().report_error(); - // }); }); } } @@ -303,87 +338,43 @@ impl View for Instances<'_> { for instance in iter { let instance = instance.read(); ui.group(|ui| { - ui.label(RichText::new(instance.name()).strong()); - egui::CollapsingHeader::new("Profiles") - .id_source(egui::Id::new(instance.id()).with("__profiles_list")) - .show(ui, |ui| { + let id = ui.make_persistent_id("instance_details").with(instance.id()); + egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, false) + .show_header(ui, |ui| { + ui.label(RichText::new(instance.name()).strong()); + ui.button("Launch"); + }) + .body(|ui| { + ui.button_with_confirm_popup(Id::new("confirm_instance_deletion").with(instance.id()), "Delete", |ui| { + ui.set_width(200.0); + ui.label("Are you sure you want to delete this instance?"); + ui.warn_irreversible_action(); + + ui.horizontal(|ui| { + let yes_button = ui.button("Yes"); + let no_button = ui.button("No"); + + if yes_button.clicked() { + let path = instance.path(); + let id = instance.id(); + let task = Task::new( + "Deleting the instance", + Caller::standard(async move { tokio::fs::remove_dir_all(path).await.report_error().map(|()| id) }), + ); + self.manager.push_task::(task); + } + + if yes_button.clicked() || no_button.clicked() { + ui.memory_mut(|mem| mem.close_popup()) + } + }); + }); + + ui.heading("Profiles"); + self.show_profiles_for_instance(ui, instance.profiles(), self.is_allowed_to_take_action) }); }); } } } - -fn delete_profile_ui() { - // if let ProfileState::Downloaded(instance) = &profile.profile.state { - // let popup_id = ui.make_persistent_id("delete_popup_id"); - // let button = ui - // .add_enabled(is_allowed_to_take_action, Button::new("Delete")) - // .on_hover_text("It will delete the profile and it's data"); - - // if button.clicked() { - // ui.memory_mut(|mem| mem.toggle_popup(popup_id)); - // } - - // popup_below_widget(ui, popup_id, &button, PopupCloseBehavior::CloseOnClickOutside, |ui| { - // ui.set_min_width(150.0); - - // let delete_client_id = Id::new("delete_client"); - // let delete_libraries_id = Id::new("delete_libraries"); - // let delete_assets_id = Id::new("delete_assets"); - // let delete_mods_id = Id::new("delete_mods"); - - // let mut make_checkbox = |text: &str, id, default: bool| { - // let mut state = ui.data_mut(|map| *map.get_temp_mut_or_insert_with(id, move || default)); - // ui.checkbox(&mut state, text); - // ui.data_mut(|map| map.insert_temp(id, state)); - // }; - - // make_checkbox("Delete profile's client", delete_client_id, true); - // make_checkbox("Delete profile's libraries", delete_libraries_id, false); - // if profile.profile.loader().is_fabric() { - // make_checkbox("Delete profile's mods", delete_mods_id, true); - // } - // make_checkbox("Delete profile's assets", delete_assets_id, false); - - // ui.label("Are you sure you want to delete this profile and it's data?"); - // ui.horizontal(|ui| { - // ui.warn_icon_with_hover_text("Deleting profile's assets and libraries might break other profiles."); - // if ui.button("Yes").clicked() { - // is_deleting.push(index); - - // let version = &instance.settings.version; - - // let checkbox_data = |id| ui.data(|data| data.get_temp(id)).unwrap_or_default(); - - // let delete_client = checkbox_data(delete_client_id); - // let delete_libraries = checkbox_data(delete_libraries_id); - // let delete_assets = checkbox_data(delete_assets_id); - // let delete_mods = checkbox_data(delete_mods_id); - - // let profile_id = profile.profile.id; - - // let instance = instance.clone(); - // let caller = Caller::standard(async move { - // let path = Path::new(DOT_NOMI_MODS_STASH_DIR).join(format!("{}", profile_id)); - // if delete_mods && path.exists() { - // tokio::fs::remove_dir_all(path).await.report_error(); - // } - // instance.delete(delete_client, delete_libraries, delete_assets).await.report_error(); - // }); - - // let task = Task::new(format!("Deleting the game's files ({})", version), caller); - - // self.manager.push_task::(task); - - // self.tabs_state.remove_profile_related_tabs(&profile); - - // ui.memory_mut(|mem| mem.close_popup()); - // } - // if ui.button("No").clicked() { - // ui.memory_mut(|mem| mem.close_popup()); - // } - // }); - // }); - // } -} diff --git a/crates/client/src/views/settings.rs b/crates/client/src/views/settings.rs index 81fd944..4541004 100644 --- a/crates/client/src/views/settings.rs +++ b/crates/client/src/views/settings.rs @@ -11,7 +11,7 @@ use nomi_core::{ }; use serde::{Deserialize, Serialize}; -use crate::{collections::JavaCollection, errors_pool::ErrorPoolExt, states::JavaState}; +use crate::{collections::JavaCollection, errors_pool::ErrorPoolExt, states::JavaState, ui_ext::UiExt}; use super::View; @@ -93,27 +93,27 @@ fn check_uuid(value: &str, _context: &()) -> garde::Result { impl View for SettingsPage<'_> { fn ui(self, ui: &mut eframe::egui::Ui) { - ui.collapsing("Utils", |ui| { - let launcher_path = PathBuf::from(DOT_NOMI_LOGS_DIR); + ui.heading("Utils"); - if launcher_path.exists() { - if ui.button("Delete launcher's logs").clicked() { - let _ = std::fs::remove_dir_all(launcher_path); - } - } else { - ui.label(RichText::new("The launcher log's directory is already deleted").color(ui.visuals().warn_fg_color)); + let launcher_path = PathBuf::from(DOT_NOMI_LOGS_DIR); + + if launcher_path.exists() { + if ui.button("Delete launcher's logs").clicked() { + let _ = std::fs::remove_dir_all(launcher_path); } + } else { + ui.warn_label("The launcher log's directory is already deleted"); + } - let game_path = PathBuf::from("./logs"); + let game_path = PathBuf::from("./logs"); - if game_path.exists() { - if ui.button("Delete game's logs").clicked() { - let _ = std::fs::remove_dir_all(game_path); - } - } else { - ui.label(RichText::new("The games log's directory is already deleted").color(ui.visuals().warn_fg_color)); + if game_path.exists() { + if ui.button("Delete game's logs").clicked() { + let _ = std::fs::remove_dir_all(game_path); } - }); + } else { + ui.warn_label("The games log's directory is already deleted"); + } let settings_data = self.settings_state.clone(); @@ -128,52 +128,52 @@ impl View for SettingsPage<'_> { } } - ui.collapsing("User", |ui| { - FormField::new(&mut form, field_path!("username")) - .label("Username") - .ui(ui, egui::TextEdit::singleline(&mut self.settings_state.username)); - - FormField::new(&mut form, field_path!("uuid")) - .label("UUID") - .ui(ui, egui::TextEdit::singleline(&mut self.settings_state.uuid)); - }); + ui.heading("User"); + + FormField::new(&mut form, field_path!("username")) + .label("Username") + .ui(ui, egui::TextEdit::singleline(&mut self.settings_state.username)); + + FormField::new(&mut form, field_path!("uuid")) + .label("UUID") + .ui(ui, egui::TextEdit::singleline(&mut self.settings_state.uuid)); + + ui.heading("Java"); + + if ui + .add_enabled( + self.manager.get_collection::().tasks().is_empty(), + egui::Button::new("Download Java"), + ) + .on_hover_text("Pressing this button will start the Java downloading process and add the downloaded binary as the selected one") + .clicked() + { + self.java_state.download_java(self.manager, ui.ctx().clone()); + self.settings_state.java = JavaRunner::path(PathBuf::from(DOT_NOMI_JAVA_EXECUTABLE)); + self.settings_state.update_config(); + } - ui.collapsing("Java", |ui| { - if ui - .add_enabled( - self.manager.get_collection::().tasks().is_empty(), - egui::Button::new("Download Java"), - ) - .on_hover_text("Pressing this button will start the Java downloading process and add the downloaded binary as selected") - .clicked() - { - self.java_state.download_java(self.manager, ui.ctx().clone()); - self.settings_state.java = JavaRunner::path(PathBuf::from(DOT_NOMI_JAVA_EXECUTABLE)); - self.settings_state.update_config(); - } + FormField::new(&mut form, field_path!("java")).label("Java").ui(ui, |ui: &mut egui::Ui| { + ui.radio_value(&mut self.settings_state.java, JavaRunner::command("java"), "Command"); - FormField::new(&mut form, field_path!("java")).label("Java").ui(ui, |ui: &mut egui::Ui| { - ui.radio_value(&mut self.settings_state.java, JavaRunner::command("java"), "Command"); + ui.radio_value(&mut self.settings_state.java, JavaRunner::path(PathBuf::new()), "Custom path"); - ui.radio_value(&mut self.settings_state.java, JavaRunner::path(PathBuf::new()), "Custom path"); + if matches!(settings_data.java, JavaRunner::Path(_)) && ui.button("Select custom java binary").clicked() { + self.file_dialog.select_file(); + } - if matches!(settings_data.java, JavaRunner::Path(_)) && ui.button("Select custom java binary").clicked() { - self.file_dialog.select_file(); + ui.label(format!( + "Java will be run using {}", + match &settings_data.java { + JavaRunner::Command(command) => format!("{} command", command), + JavaRunner::Path(path) => format!("{} executable", path.display()), } - - ui.label(format!( - "Java will be run using {}", - match &settings_data.java { - JavaRunner::Command(command) => format!("{} command", command), - JavaRunner::Path(path) => format!("{} executable", path.display()), - } - )) - }); + )) }); - ui.collapsing("Client", |ui| { - ui.add(egui::Slider::new(&mut self.settings_state.client_settings.pixels_per_point, 0.5..=5.0).text("Pixels per point")) - }); + ui.heading("Launcher"); + + ui.add(egui::Slider::new(&mut self.settings_state.client_settings.pixels_per_point, 0.5..=5.0).text("Pixels per point")); } if let Some(Ok(())) = form.handle_submit(&ui.button("Save"), ui) { diff --git a/crates/nomi-core/src/game_paths.rs b/crates/nomi-core/src/game_paths.rs index 46caa0d..2c164ec 100644 --- a/crates/nomi-core/src/game_paths.rs +++ b/crates/nomi-core/src/game_paths.rs @@ -7,9 +7,16 @@ use crate::{ #[derive(Debug, Clone)] pub struct GamePaths { + /// Game root directory. This is usually corresponds to the instance directory. pub game: PathBuf, + + /// Assets directory. pub assets: PathBuf, + + /// Profile directory. pub profile: PathBuf, + + /// Libraries directory. pub libraries: PathBuf, } @@ -24,7 +31,6 @@ impl GamePaths { Self { game: path.to_path_buf(), assets: ASSETS_DIR.into(), - // Is this a good approach? profile: path.join("profiles").join(format!("{profile_id}")), libraries: LIBRARIES_DIR.into(), } diff --git a/crates/nomi-core/src/instance/launch.rs b/crates/nomi-core/src/instance/launch.rs index 98a2d26..5c140a9 100644 --- a/crates/nomi-core/src/instance/launch.rs +++ b/crates/nomi-core/src/instance/launch.rs @@ -54,47 +54,6 @@ pub struct LaunchInstance { } impl LaunchInstance { - #[tracing::instrument(skip(self), err)] - pub async fn delete(&self, paths: GamePaths, delete_client: bool, delete_libraries: bool, delete_assets: bool) -> anyhow::Result<()> { - let manifest = read_json_config::(paths.manifest_file(&self.settings.version)).await?; - let arguments_builder = ArgumentsBuilder::new(&paths, self, &manifest).build_classpath(); - - if delete_client { - let path = paths.version_jar_file(&self.settings.version); - let _ = tokio::fs::remove_file(&path) - .await - .inspect(|()| { - debug!("Removed client successfully: {}", &path.display()); - }) - .inspect_err(|_| { - warn!("Cannot remove client: {}", &path.display()); - }); - } - - if delete_libraries { - for library in arguments_builder.classpath_as_slice() { - let _ = tokio::fs::remove_file(library) - .await - .inspect(|()| trace!("Removed library successfully: {}", library.display())) - .inspect_err(|_| warn!("Cannot remove library: {}", library.display())); - } - } - - if delete_assets { - let assets = read_json_config::(dbg!(paths.assets.join("indexes").join(format!("{}.json", manifest.asset_index.id)))).await?; - for asset in assets.objects.values() { - let path = paths.assets.join("objects").join(&asset.hash[0..2]).join(&asset.hash); - - let _ = tokio::fs::remove_file(&path) - .await - .inspect(|()| trace!("Removed asset successfully: {}", path.display())) - .inspect_err(|e| warn!("Cannot remove asset: {}. Error: {e}", path.display())); - } - } - - Ok(()) - } - pub fn loader_profile(&self) -> Option<&LoaderProfile> { self.loader_profile.as_ref() } diff --git a/crates/nomi-core/src/instance/mod.rs b/crates/nomi-core/src/instance/mod.rs index 0d01c68..94a4d71 100644 --- a/crates/nomi-core/src/instance/mod.rs +++ b/crates/nomi-core/src/instance/mod.rs @@ -9,7 +9,7 @@ use std::path::{Path, PathBuf}; pub use profile::*; use serde::{Deserialize, Serialize}; -use tracing::error; +use tracing::{error, warn}; use crate::{ configs::profile::{Loader, VersionProfile}, @@ -76,6 +76,16 @@ impl Instance { self.profiles_mut().iter_mut().find(|p| p.id == id) } + pub fn remove_profile(&mut self, id: InstanceProfileId) -> Option { + let opt = self.profiles.iter().position(|p| p.id == id).map(|idx| self.profiles.remove(idx)); + + if opt.is_none() { + warn!(?id, "Cannot find a profile to remove"); + } + + opt + } + /// Generate id for the next profile in this instance pub fn next_id(&self) -> InstanceProfileId { match &self.profiles.iter().max_by_key(|profile| profile.id.1) { diff --git a/crates/nomi-core/src/instance/profile.rs b/crates/nomi-core/src/instance/profile.rs index e1c9143..ef74597 100644 --- a/crates/nomi-core/src/instance/profile.rs +++ b/crates/nomi-core/src/instance/profile.rs @@ -1,3 +1,4 @@ +use tracing::{debug, warn}; use typed_builder::TypedBuilder; use crate::{downloads::downloaders::assets::AssetsDownloader, game_paths::GamePaths, state::get_launcher_manifest}; @@ -44,3 +45,36 @@ impl Profile { self.downloader.insert(builder).build() } } + +#[tracing::instrument] +pub async fn delete_profile(paths: GamePaths, game_version: &str) { + let path = paths.version_jar_file(game_version); + let _ = tokio::fs::remove_file(&path) + .await + .inspect(|()| { + debug!("Removed client successfully: {}", &path.display()); + }) + .inspect_err(|_| { + warn!("Cannot remove client: {}", &path.display()); + }); + + let path = &paths.profile_config(); + let _ = tokio::fs::remove_file(&path) + .await + .inspect(|()| { + debug!(path = %&path.display(), "Removed profile config successfully"); + }) + .inspect_err(|_| { + warn!(path = %&path.display(), "Cannot profile config"); + }); + + let path = &paths.profile; + let _ = tokio::fs::remove_dir(&path) + .await + .inspect(|()| { + debug!(path = %&path.display(), "Removed profile directory successfully"); + }) + .inspect_err(|_| { + warn!(path = %&path.display(), "Cannot profile directory"); + }); +}