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:
Ludovic Marie authored and GitHub committed 2026-10-29 10:13:16 +01:00
commit 2b294bbe3a
7 files changed
+209 -4

No files matched your search

+10
View File
@@ -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.
+5 -1
View File
@@ -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."
}
+5 -1
View File
@@ -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} в корзину вместе с мирами и настройками? Оттуда его можно вернуть."
}
+10
View File
@@ -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))?)
}
+52 -1
View File
@@ -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();
+39
View File
@@ -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();
}
+88 -1
View File
@@ -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 {