mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge pull request #3506 from horizon-rust/fix/launcher-delete-instance
🤖 Agent: launcher-java-1 verified this head: PR checks green (verify, launcher), launcher build matrix green on Windows, macOS and Linux (dry run https://github.com/horizon-rust/horizon/actions/runs/40817273793), pr_ready --quick green with the new test deleted_instances_go_to_the_trash_and_are_forgotten; docs/launcher-commands.md regenerated.
This commit is contained in:
7 files changed
+209
-4
No files matched your search
@@ -375,6 +375,16 @@ Rename an instance (its folder stays).
|
||||
|
||||
MCP: `instance_rename`.
|
||||
|
||||
## `horizon-launcher instance delete`
|
||||
|
||||
Delete an instance: its folder (worlds, options, packs) goes to the Trash, where you can get it back. Horizon's own instance stays.
|
||||
|
||||
horizon-launcher instance delete <INSTANCE>
|
||||
|
||||
- `INSTANCE`
|
||||
|
||||
MCP: `instance_delete`.
|
||||
|
||||
## `horizon-launcher instance icon`
|
||||
|
||||
Set an instance's icon: a block or item name from your prepared files (grass_block), a colour (#3fa7d6), or none.
|
||||
|
||||
@@ -185,5 +185,9 @@
|
||||
"These mods will run with full access to your computer": "Ces mods auront un accès complet à votre ordinateur",
|
||||
"They can read, change or delete your files, and use your accounts and network, like any program you run. Build them only if you trust their authors: the Mods page names them.": "Ils peuvent lire, modifier ou supprimer vos fichiers, et utiliser vos comptes et votre réseau, comme tout programme que vous lancez. Ne les compilez que si vous faites confiance à leurs auteurs : la page Mods les nomme.",
|
||||
"Mods: {mods}": "Mods : {mods}",
|
||||
"The build compiles the game: about ten minutes and a few GB of disk the first time, a few minutes after that.": "La compilation construit le jeu : une dizaine de minutes et quelques Go de disque la première fois, quelques minutes ensuite."
|
||||
"The build compiles the game: about ten minutes and a few GB of disk the first time, a few minutes after that.": "La compilation construit le jeu : une dizaine de minutes et quelques Go de disque la première fois, quelques minutes ensuite.",
|
||||
"Delete…": "Supprimer…",
|
||||
"Keep": "Garder",
|
||||
"Move to Trash": "Mettre à la corbeille",
|
||||
"Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "Mettre {name} à la corbeille, avec ses mondes et ses réglages ? Vous pourrez l'y récupérer."
|
||||
}
|
||||
@@ -185,5 +185,9 @@
|
||||
"These mods will run with full access to your computer": "Эти моды получат полный доступ к вашему компьютеру",
|
||||
"They can read, change or delete your files, and use your accounts and network, like any program you run. Build them only if you trust their authors: the Mods page names them.": "Они могут читать, изменять и удалять ваши файлы, использовать ваши аккаунты и сеть, как любая запущенная вами программа. Собирайте их, только если доверяете авторам: они указаны на странице «Моды».",
|
||||
"Mods: {mods}": "Моды: {mods}",
|
||||
"The build compiles the game: about ten minutes and a few GB of disk the first time, a few minutes after that.": "Сборка компилирует игру: около десяти минут и несколько ГБ на диске в первый раз, потом несколько минут."
|
||||
"The build compiles the game: about ten minutes and a few GB of disk the first time, a few minutes after that.": "Сборка компилирует игру: около десяти минут и несколько ГБ на диске в первый раз, потом несколько минут.",
|
||||
"Delete…": "Удалить…",
|
||||
"Keep": "Оставить",
|
||||
"Move to Trash": "В корзину",
|
||||
"Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "Переместить {name} в корзину вместе с мирами и настройками? Оттуда его можно вернуть."
|
||||
}
|
||||
@@ -243,6 +243,9 @@ enum InstanceCmd {
|
||||
},
|
||||
/// Rename an instance (its folder stays)
|
||||
Rename { instance: String, name: String },
|
||||
/// Delete an instance: its folder (worlds, options, packs) goes to the Trash, where you can
|
||||
/// get it back. Horizon's own instance stays
|
||||
Delete { instance: String },
|
||||
/// Set an instance's icon: a block or item name from your prepared files (grass_block),
|
||||
/// a colour (#3fa7d6), or none
|
||||
Icon { instance: String, icon: String },
|
||||
@@ -2274,6 +2277,13 @@ fn instance(action: InstanceCmd, as_json: bool) -> Result<(), String> {
|
||||
}
|
||||
})?)
|
||||
}
|
||||
InstanceCmd::Delete { instance } => {
|
||||
let gone = instances::delete(&instance)?;
|
||||
match as_json {
|
||||
true => json(serde_json::json!({"deleted": gone.id, "trashed": gone.work})),
|
||||
false => println!("{} is in the Trash.", gone.name),
|
||||
}
|
||||
}
|
||||
InstanceCmd::Rename { instance, name } => {
|
||||
changed(instances::edit(&instance, |m| m.name = Some(name))?)
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@ pub fn list() -> Vec<Instance> {
|
||||
}
|
||||
|
||||
fn list_in(game_home: &Path, data: &Path) -> Vec<Instance> {
|
||||
let store = Store::load(data);
|
||||
let mut store = Store::load(data);
|
||||
let mut found = vec![(
|
||||
DEFAULT.to_string(),
|
||||
"Horizon".to_string(),
|
||||
@@ -227,6 +227,17 @@ fn list_in(game_home: &Path, data: &Path) -> Vec<Instance> {
|
||||
.filter_map(|e| e.file_name().into_string().ok())
|
||||
.collect();
|
||||
own.sort();
|
||||
// An own instance whose folder is gone (deleted outside the launcher) is forgotten, so a new
|
||||
// one of that name starts clean. Only when the folder of instances itself can be read.
|
||||
if game_home.join(OWN).is_dir() {
|
||||
let before = store.instances.len();
|
||||
store
|
||||
.instances
|
||||
.retain(|id, _| id == DEFAULT || id.contains(':') || own.contains(id));
|
||||
if store.instances.len() != before {
|
||||
let _ = store.save(data);
|
||||
}
|
||||
}
|
||||
for slug in own {
|
||||
let dir = game_home.join(OWN).join(&slug);
|
||||
found.push((slug.clone(), slug, Kind::Own, dir.clone(), dir));
|
||||
@@ -287,6 +298,46 @@ fn find_in(all: &[Instance], query: &str) -> Result<Instance, String> {
|
||||
.ok_or_else(|| format!("No instance {q}: horizon-launcher instance list shows them"))
|
||||
}
|
||||
|
||||
/// Moves an instance's folder to the Trash (where the player can get it back, worlds and all)
|
||||
/// and forgets it: its choices, and the selection when Play started it. Horizon's own instance,
|
||||
/// the game's home, stays.
|
||||
pub fn delete(query: &str) -> Result<Instance, String> {
|
||||
let gone = delete_in(
|
||||
&backend::game_home(),
|
||||
&backend::data_dir(),
|
||||
query,
|
||||
&crate::desktop_seams::to_trash,
|
||||
)?;
|
||||
let mut settings = backend::Settings::load();
|
||||
if settings.instance.as_ref() == Some(&gone.id) {
|
||||
settings.instance = None;
|
||||
settings.save();
|
||||
}
|
||||
Ok(gone)
|
||||
}
|
||||
|
||||
fn delete_in(
|
||||
game_home: &Path,
|
||||
data: &Path,
|
||||
query: &str,
|
||||
trash: &dyn Fn(&Path) -> Result<(), String>,
|
||||
) -> Result<Instance, String> {
|
||||
let instance = find_in(&list_in(game_home, data), query)?;
|
||||
if instance.kind == Kind::Default {
|
||||
return Err(format!(
|
||||
"{} is the game's own folder: it can't be deleted.",
|
||||
instance.name
|
||||
));
|
||||
}
|
||||
trash(&instance.work)
|
||||
.map_err(|e| format!("{} could not go to the Trash: {e}", instance.name))?;
|
||||
let mut store = Store::load(data);
|
||||
if store.instances.remove(&instance.id).is_some() {
|
||||
store.save(data)?;
|
||||
}
|
||||
Ok(instance)
|
||||
}
|
||||
|
||||
/// The instance Play starts: the one picked in the launcher, else Horizon.
|
||||
pub fn selected() -> Instance {
|
||||
let all = list();
|
||||
|
||||
@@ -334,3 +334,42 @@ fn a_java_instance_plays_one_minecraft_version_in_its_own_folder() {
|
||||
assert_eq!(edited.java.unwrap().memory_mb, Some(6144));
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
/// Delete moves an instance's folder to the Trash and forgets it; one deleted outside the
|
||||
/// launcher is forgotten when the list is next read (#horizon-bugs: an instance that stayed).
|
||||
#[test]
|
||||
fn deleted_instances_go_to_the_trash_and_are_forgotten() {
|
||||
let root = scratch("delete");
|
||||
let (home, data, trash) = (root.join("home"), root.join("data"), root.join("trash"));
|
||||
player_home(&home);
|
||||
let to_trash = |p: &Path| crate::desktop_seams::to_folder(p, &trash);
|
||||
let survival = create_in(&home, &data, "Survival", None, None).unwrap();
|
||||
put(&survival.home.join("saves/Base/level.dat"), "base");
|
||||
edit_in(&home, &data, "Survival", |m| m.group = Some("Mine".into())).unwrap();
|
||||
let gone = delete_in(&home, &data, "Survival", &to_trash).unwrap();
|
||||
assert_eq!(gone.id, "survival");
|
||||
assert_eq!(
|
||||
fs::read(trash.join("survival/saves/Base/level.dat")).unwrap(),
|
||||
b"base"
|
||||
);
|
||||
assert!(find_in(&list_in(&home, &data), "Survival").is_err());
|
||||
assert!(!Store::load(&data).instances.contains_key("survival"));
|
||||
// The game's home stays; an imported pack goes like an instance.
|
||||
assert!(delete_in(&home, &data, DEFAULT, &to_trash)
|
||||
.unwrap_err()
|
||||
.contains("can't be deleted"));
|
||||
assert!(home.join("saves/World/level.dat").is_file());
|
||||
delete_in(&home, &data, "small-pack", &to_trash).unwrap();
|
||||
assert!(trash
|
||||
.join("small-pack/home/saves/Modded/level.dat")
|
||||
.is_file());
|
||||
// A folder removed by hand: its choices are dropped, a new one of that name starts clean.
|
||||
create_in(&home, &data, "Skyblock", None, None).unwrap();
|
||||
edit_in(&home, &data, "Skyblock", |m| m.group = Some("Old".into())).unwrap();
|
||||
fs::remove_dir_all(home.join(OWN).join("skyblock")).unwrap();
|
||||
assert!(find_in(&list_in(&home, &data), "Skyblock").is_err());
|
||||
assert!(!Store::load(&data).instances.contains_key("skyblock"));
|
||||
let again = create_in(&home, &data, "Skyblock", None, None).unwrap();
|
||||
assert_eq!(again.group, None);
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
@@ -58,6 +58,9 @@ pub(super) struct InstanceDialog {
|
||||
pub(super) struct Instances {
|
||||
pub(super) list: Vec<Instance>,
|
||||
pub(super) dialog: Option<InstanceDialog>,
|
||||
/// The instance whose Delete is waiting for its confirmation, and why the last one failed.
|
||||
confirm_delete: Option<String>,
|
||||
delete_error: Option<String>,
|
||||
icons: HashMap<(String, u32), Option<Arc<RenderImage>>>,
|
||||
}
|
||||
|
||||
@@ -618,7 +621,8 @@ impl Launcher {
|
||||
cx.open_with_system(&home);
|
||||
},
|
||||
),
|
||||
);
|
||||
)
|
||||
.children(self.delete_button(&instance, cx));
|
||||
Some(index_row(
|
||||
n,
|
||||
tr("Instance"),
|
||||
@@ -640,6 +644,7 @@ impl Launcher {
|
||||
)
|
||||
.children(self.shortcut_note(&instance.id))
|
||||
.child(actions)
|
||||
.children(self.delete_confirmation(&instance, cx))
|
||||
.into_any_element(),
|
||||
),
|
||||
None,
|
||||
@@ -648,6 +653,88 @@ impl Launcher {
|
||||
}
|
||||
}
|
||||
|
||||
impl Launcher {
|
||||
/// Delete…, for every instance but Horizon's own (the game's home); not while a game runs.
|
||||
fn delete_button(&self, instance: &Instance, cx: &mut Context<Self>) -> Option<Stateful<Div>> {
|
||||
if instance.kind == InstanceKind::Default {
|
||||
return None;
|
||||
}
|
||||
let t = self.theme;
|
||||
let delete = button("instance-delete", "Delete…", Kind::Ghost, &t);
|
||||
if self.client.is_some() || self.starting_client.is_some() {
|
||||
return Some(disabled(delete));
|
||||
}
|
||||
let id = instance.id.clone();
|
||||
Some(
|
||||
delete.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
|
||||
this.instances.confirm_delete = Some(id.clone());
|
||||
this.instances.delete_error = None;
|
||||
cx.notify();
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
/// "Move it to the Trash?" under the actions, once Delete… is pressed.
|
||||
fn delete_confirmation(&self, instance: &Instance, cx: &mut Context<Self>) -> Option<Div> {
|
||||
if self.instances.confirm_delete.as_ref() != Some(&instance.id) {
|
||||
return None;
|
||||
}
|
||||
let t = self.theme;
|
||||
let id = instance.id.clone();
|
||||
let line = match &self.instances.delete_error {
|
||||
Some(e) => e.clone(),
|
||||
None => trf(
|
||||
"Move {name} to the Trash, with its worlds and settings? You can get it back from there.",
|
||||
&[("name", &instance.name)],
|
||||
),
|
||||
};
|
||||
Some(
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.items_center()
|
||||
.gap(px(8.))
|
||||
.mt(px(8.))
|
||||
.child(text(theme::CAPTION).text_color(t.danger).child(line))
|
||||
.child(
|
||||
button("instance-keep", "Keep", Kind::Ghost, &t).on_click(cx.listener(
|
||||
|this, _: &ClickEvent, _, cx| {
|
||||
this.instances.confirm_delete = None;
|
||||
this.instances.delete_error = None;
|
||||
cx.notify();
|
||||
},
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
button("instance-trash", "Move to Trash", Kind::Secondary, &t).on_click(
|
||||
cx.listener(move |this, _: &ClickEvent, _, cx| {
|
||||
this.delete_instance(&id);
|
||||
cx.notify();
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn delete_instance(&mut self, id: &str) {
|
||||
if self.client.is_some() || self.starting_client.is_some() {
|
||||
return;
|
||||
}
|
||||
match instances::delete(id) {
|
||||
Ok(_) => {
|
||||
self.instances.confirm_delete = None;
|
||||
// Play starts Horizon again; the window's copy of the settings says so too.
|
||||
if self.settings.instance.as_deref() == Some(id) {
|
||||
self.settings.instance = None;
|
||||
self.settings.save();
|
||||
}
|
||||
self.reload_instances();
|
||||
}
|
||||
Err(e) => self.instances.delete_error = Some(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// "3 h 05 min in total · last played today", or "Not played yet".
|
||||
fn played_line(instance: &Instance) -> String {
|
||||
match instance.last_played {
|
||||
|
||||
Reference in new issue
Block a user