From 7f0a61954bd27dcf0623d120465bd9f6fd109c59 Mon Sep 17 00:00:00 2001 From: Eshayat Al-Wasiu Date: Tue, 27 Oct 2026 17:32:50 +0600 Subject: [PATCH 1/8] Add a confirmed server delete action to launcher settings Move stopped servers to the system trash after name confirmation. Refuse active supervisor locks and background services, and refresh the sidebar after removal. Co-Authored-By: Codex (GPT-6.1) --- launcher/src/servers.rs | 42 +++++++++++ launcher/src/servers/tests.rs | 51 +++++++++++++ launcher/src/servers_page.rs | 134 +++++++++++++++++++++++++++++++++- 3 files changed, 225 insertions(+), 2 deletions(-) diff --git a/launcher/src/servers.rs b/launcher/src/servers.rs index 09961ce3b..200d62698 100644 --- a/launcher/src/servers.rs +++ b/launcher/src/servers.rs @@ -68,6 +68,48 @@ pub fn forget(dir: &Path) -> Result<(), String> { save_list(&all) } +/// Removes a registered, stopped server, retaining its files in the system trash. +pub fn delete(dir: &Path) -> Result<(), String> { + if !list().iter().any(|entry| entry.dir == dir) { + return Err("This server is no longer in the launcher’s list.".into()); + } + if service_installed(dir) { + return Err("Turn off running in the background before deleting this server.".into()); + } + trash_stopped_server(dir, crate::desktop_seams::to_trash)?; + forget(dir).map_err(|e| { + format!("The server was moved to the trash, but its list could not be saved: {e}") + }) +} + +fn trash_stopped_server( + dir: &Path, + trash: impl FnOnce(&Path) -> Result<(), String>, +) -> Result<(), String> { + if dir + .symlink_metadata() + .map_err(|e| e.to_string())? + .file_type() + .is_symlink() + || !dir.join("server.toml").is_file() + { + return Err("This is not a server folder.".into()); + } + let state = dir.join(".horizon"); + fs::create_dir_all(&state).map_err(|e| e.to_string())?; + // The supervisor holds this same lock throughout starting, running and stopping. + // Hold it through the move so a stale UI/API status cannot delete a live server. + let lock = fs::File::options() + .create(true) + .truncate(false) + .write(true) + .open(state.join("lock")) + .map_err(|e| e.to_string())?; + lock.try_lock() + .map_err(|_| "Stop the server before deleting it.".to_string())?; + trash(dir).map_err(|e| format!("Could not move the server to the trash: {e}")) +} + /// A folder name for a server name: lower case letters, digits and dashes. pub fn slug(name: &str) -> String { let mut out = String::new(); diff --git a/launcher/src/servers/tests.rs b/launcher/src/servers/tests.rs index 890deb64e..fce4520e7 100644 --- a/launcher/src/servers/tests.rs +++ b/launcher/src/servers/tests.rs @@ -226,3 +226,54 @@ fn the_api_sends_the_token() { // Nothing answers: stopped. assert_eq!(status(Path::new("/nonexistent")).phase, "stopped"); } + +#[test] +fn deleting_a_stopped_server_moves_its_world_and_backups_to_trash() { + let root = scratch("delete-stopped"); + let dir = root.join("server"); + let trash = root.join("trash"); + fs::create_dir_all(dir.join("world/region")).unwrap(); + fs::create_dir_all(dir.join("backups")).unwrap(); + fs::write(dir.join("server.toml"), "[server]\n").unwrap(); + fs::write(dir.join("world/region/region.mca"), b"world").unwrap(); + fs::write(dir.join("backups/backup.zip"), b"backup").unwrap(); + trash_stopped_server(&dir, |path| crate::desktop_seams::to_folder(path, &trash)).unwrap(); + assert!(!dir.exists()); + assert_eq!( + fs::read(trash.join("server/world/region/region.mca")).unwrap(), + b"world" + ); + assert_eq!( + fs::read(trash.join("server/backups/backup.zip")).unwrap(), + b"backup" + ); + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn deleting_refuses_a_live_supervisor_even_when_its_api_is_unavailable() { + let root = scratch("delete-running"); + fs::create_dir_all(root.join(".horizon")).unwrap(); + fs::write(root.join("server.toml"), "[server]\n").unwrap(); + let lock = fs::File::create(root.join(".horizon/lock")).unwrap(); + lock.try_lock().unwrap(); + let result = trash_stopped_server(&root, |_| panic!("must not move a running server")); + assert!(result.unwrap_err().contains("Stop the server")); + assert!(root.join("server.toml").exists()); + drop(lock); + fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn deleting_refuses_an_unrecognized_folder_and_keeps_files_on_trash_failure() { + let root = scratch("delete-invalid"); + assert!(trash_stopped_server(&root, |_| panic!("not a server")).is_err()); + fs::write(root.join("server.toml"), "[server]\n").unwrap(); + assert!( + trash_stopped_server(&root, |_| Err("trash unavailable".into())) + .unwrap_err() + .contains("trash unavailable") + ); + assert!(root.join("server.toml").exists()); + fs::remove_dir_all(root).unwrap(); +} diff --git a/launcher/src/servers_page.rs b/launcher/src/servers_page.rs index e3ed4f441..46482fee0 100644 --- a/launcher/src/servers_page.rs +++ b/launcher/src/servers_page.rs @@ -46,10 +46,11 @@ pub(super) enum Field { LaunchEnv, /// Settings' new offline name (`switch_page.rs`). OfflineName, + DeleteServer, } impl Field { - const ALL: [Field; 18] = [ + const ALL: [Field; 19] = [ Field::Name, Field::Seed, Field::Players, @@ -68,6 +69,7 @@ impl Field { Field::Wrapper, Field::LaunchEnv, Field::OfflineName, + Field::DeleteServer, ]; } @@ -110,6 +112,10 @@ pub(super) enum Event { Line(String), Done(Result), Created(Result), + Deleted { + dir: PathBuf, + result: Result<(), String>, + }, Details { dir: PathBuf, config: Option, @@ -177,6 +183,7 @@ pub(super) struct Servers { restart_needed: BTreeSet, backups: Vec, confirm_restore: Option, + confirm_delete: bool, service: Option, update: Option, /// `update --check` is under way. @@ -225,6 +232,7 @@ impl Servers { restart_needed: BTreeSet::new(), backups: Vec::new(), confirm_restore: None, + confirm_delete: false, service: None, update: None, checking: false, @@ -492,6 +500,8 @@ impl Launcher { s.problem = None; s.note = None; s.confirm_restore = None; + s.confirm_delete = false; + s.texts.remove(&Field::DeleteServer); s.restart_needed.clear(); s.texts.remove(&Field::Motd); s.texts.remove(&Field::Port); @@ -640,6 +650,24 @@ impl Launcher { s.service = Some(service); } } + Event::Deleted { dir, result } => { + s.busy = None; + s.confirm_delete = false; + s.list = servers::list(); + s.statuses.remove(&dir); + STARTED_HERE + .lock() + .unwrap() + .retain(|started| started != &dir); + if !s.list.iter().any(|entry| entry.dir == dir) { + s.open = None; + self.side_servers = s.list.clone(); + } + match result { + Ok(()) => s.note = Some("Server moved to the trash.".into()), + Err(e) => s.problem = Some(Problem::plain(e)), + } + } Event::Applied(result) => { s.applying = false; match result { @@ -989,6 +1017,7 @@ impl Launcher { } Key::Enter => this.field_enter(f), Key::Escape => { + this.servers.confirm_delete = false; if this.servers.create.is_some() && !this.servers.creating { this.servers.create = None; } @@ -1013,6 +1042,10 @@ impl Launcher { /// Escape closes the Create dialog. Whether it did. pub(super) fn servers_escape(&mut self) -> bool { + if self.page == Page::Servers && self.servers.confirm_delete { + self.servers.confirm_delete = false; + return true; + } if self.page != Page::Servers || self.servers.create.is_none() || self.servers.creating { return false; } @@ -1044,7 +1077,10 @@ impl Launcher { Field::Port => self.save_port(), Field::Token => self.save_token(), Field::Email => self.invite_friend(), - Field::InstanceName | Field::InstanceGroup | Field::ConsoleSearch => {} + Field::InstanceName + | Field::InstanceGroup + | Field::ConsoleSearch + | Field::DeleteServer => {} Field::PreLaunch | Field::Wrapper | Field::LaunchEnv => self.save_launch(), Field::OfflineName => self.offline_name_enter(), Field::ServerName | Field::ServerAddress => self.add_server(), @@ -2314,6 +2350,100 @@ impl Launcher { a restart, and the page says so.", )) .child(form) + .child(self.delete_server_section(status, cx)) + } + + fn delete_server_section(&mut self, status: &Status, cx: &mut Context) -> Div { + let t = self.theme; + let name = self + .servers + .list + .iter() + .find(|entry| Some(&entry.dir) == self.servers.open.as_ref()) + .map(|entry| entry.name.clone()) + .unwrap_or_default(); + let ready = !status.running() + && self.servers.busy.is_none() + && !self.servers.applying + && self.servers.queued.is_empty() + && self.servers.service == Some(false); + let hint = if status.running() { + "Stop the server before deleting it." + } else if self.servers.service == Some(true) { + "Turn off running in the background before deleting it." + } else if !ready { + "Wait for the current operation to finish." + } else { + "You’ll be asked to confirm before anything is moved." + }; + let mut section = div().flex().flex_col().gap(px(12.)).mt(px(24.)) + .pt(px(24.)).border_t_1().border_color(t.rule) + .child(text(theme::HEADING).text_color(t.danger).child("Delete server")) + .child(text(theme::UI).text_color(t.ink_soft) + .child("Remove this server from Horizon and move its worlds, settings and backups to the trash.")); + if self.servers.confirm_delete { + let matches = self.servers.value(Field::DeleteServer) == name && !name.is_empty(); + let field = self.field(Field::DeleteServer, "Server name", 260., cx); + let confirm = button( + "server-delete-confirm", + "Move to trash", + Kind::Secondary, + &t, + ) + .text_color(t.danger); + let confirm = if ready && matches { + confirm.on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + if let Some(dir) = this.servers.open.clone() { + this.servers.busy = Some("Moving server to trash".into()); + let tx = this.servers.tx.clone(); + std::thread::spawn(move || { + let result = servers::delete(&dir); + let _ = tx.send(Event::Deleted { dir, result }); + }); + } + cx.notify(); + })) + } else { + disabled(confirm) + }; + section = section + .child( + text(theme::UI) + .text_color(t.ink) + .child(format!("Type {name} to confirm.")), + ) + .child(field) + .child( + div() + .flex() + .gap(px(8.)) + .child( + button("server-delete-cancel", "Cancel", Kind::Ghost, &t).on_click( + cx.listener(|this, _: &ClickEvent, _, cx| { + this.servers.confirm_delete = false; + cx.notify(); + }), + ), + ) + .child(confirm), + ); + } else { + let delete = + button("server-delete", "Delete server…", Kind::Secondary, &t).text_color(t.danger); + let delete = if ready { + delete.on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + this.servers.confirm_delete = true; + this.servers.text(Field::DeleteServer).clear(); + this.focus_handle(Field::DeleteServer, cx); + this.servers.focus_soon = Some(Field::DeleteServer); + cx.notify(); + })) + } else { + disabled(delete) + }; + section = section.child(div().flex().child(delete)); + } + section.child(text(theme::CAPTION).text_color(t.muted).child(hint)) } fn background_row(&mut self, cx: &mut Context) -> Div { From 761a7701740c97d73b7a11a64cf396591c4de08b Mon Sep 17 00:00:00 2001 From: Eshayat Al-Wasiu Date: Tue, 27 Oct 2026 17:38:57 +0600 Subject: [PATCH 2/8] Emphasize the server name in the delete confirmation Co-Authored-By: Codex (GPT-6.1) --- launcher/src/servers_page.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/launcher/src/servers_page.rs b/launcher/src/servers_page.rs index 46482fee0..eb6ffef50 100644 --- a/launcher/src/servers_page.rs +++ b/launcher/src/servers_page.rs @@ -2407,11 +2407,15 @@ impl Launcher { disabled(confirm) }; section = section - .child( - text(theme::UI) - .text_color(t.ink) - .child(format!("Type {name} to confirm.")), - ) + .child(text(theme::UI).text_color(t.ink).child( + gpui::StyledText::new(format!("Type {name} to confirm.")).with_highlights([( + 5..5 + name.len(), + gpui::HighlightStyle { + font_weight: Some(FontWeight::BOLD), + ..Default::default() + }, + )]), + )) .child(field) .child( div() From 5e7295a63164f6b4aa0021bbc65f38d2b960926e Mon Sep 17 00:00:00 2001 From: Eshayat Al-Wasiu Date: Tue, 27 Oct 2026 17:48:19 +0600 Subject: [PATCH 3/8] Align server deletion controls with the settings rows Co-Authored-By: Codex (GPT-6.1) --- launcher/src/servers_page.rs | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/launcher/src/servers_page.rs b/launcher/src/servers_page.rs index eb6ffef50..d6c76235c 100644 --- a/launcher/src/servers_page.rs +++ b/launcher/src/servers_page.rs @@ -2376,11 +2376,17 @@ impl Launcher { } else { "You’ll be asked to confirm before anything is moved." }; - let mut section = div().flex().flex_col().gap(px(12.)).mt(px(24.)) - .pt(px(24.)).border_t_1().border_color(t.rule) + let about = div().flex().flex_col().flex_1().min_w_0().gap(px(8.)) .child(text(theme::HEADING).text_color(t.danger).child("Delete server")) .child(text(theme::UI).text_color(t.ink_soft) .child("Remove this server from Horizon and move its worlds, settings and backups to the trash.")); + let mut controls = div() + .flex() + .flex_col() + .items_end() + .flex_none() + .w(px(260.)) + .gap(px(12.)); if self.servers.confirm_delete { let matches = self.servers.value(Field::DeleteServer) == name && !name.is_empty(); let field = self.field(Field::DeleteServer, "Server name", 260., cx); @@ -2406,7 +2412,7 @@ impl Launcher { } else { disabled(confirm) }; - section = section + controls = controls .child(text(theme::UI).text_color(t.ink).child( gpui::StyledText::new(format!("Type {name} to confirm.")).with_highlights([( 5..5 + name.len(), @@ -2445,9 +2451,26 @@ impl Launcher { } else { disabled(delete) }; - section = section.child(div().flex().child(delete)); + controls = controls.child(delete); } - section.child(text(theme::CAPTION).text_color(t.muted).child(hint)) + div() + .flex() + .items_start() + .gap(px(24.)) + .mt(px(24.)) + .pt(px(24.)) + .border_t_1() + .border_color(t.rule) + .child(about) + .child( + controls.child( + text(theme::CAPTION) + .text_color(t.muted) + .w(px(260.)) + .text_align(gpui::TextAlign::Right) + .child(hint), + ), + ) } fn background_row(&mut self, cx: &mut Context) -> Div { From e8145b07fab71d6f5729a25ab36de96d9a010f74 Mon Sep 17 00:00:00 2001 From: Eshayat Al-Wasiu Date: Tue, 27 Oct 2026 17:53:32 +0600 Subject: [PATCH 4/8] Remove redundant server deletion confirmation hint Co-Authored-By: Codex (GPT-6.1) --- launcher/src/servers_page.rs | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/launcher/src/servers_page.rs b/launcher/src/servers_page.rs index d6c76235c..87a330b5d 100644 --- a/launcher/src/servers_page.rs +++ b/launcher/src/servers_page.rs @@ -2368,13 +2368,13 @@ impl Launcher { && self.servers.queued.is_empty() && self.servers.service == Some(false); let hint = if status.running() { - "Stop the server before deleting it." + Some("Stop the server before deleting it.") } else if self.servers.service == Some(true) { - "Turn off running in the background before deleting it." + Some("Turn off running in the background before deleting it.") } else if !ready { - "Wait for the current operation to finish." + Some("Wait for the current operation to finish.") } else { - "You’ll be asked to confirm before anything is moved." + None }; let about = div().flex().flex_col().flex_1().min_w_0().gap(px(8.)) .child(text(theme::HEADING).text_color(t.danger).child("Delete server")) @@ -2453,6 +2453,15 @@ impl Launcher { }; controls = controls.child(delete); } + if let Some(hint) = hint { + controls = controls.child( + text(theme::CAPTION) + .text_color(t.muted) + .w(px(260.)) + .text_align(gpui::TextAlign::Right) + .child(hint), + ); + } div() .flex() .items_start() @@ -2462,15 +2471,7 @@ impl Launcher { .border_t_1() .border_color(t.rule) .child(about) - .child( - controls.child( - text(theme::CAPTION) - .text_color(t.muted) - .w(px(260.)) - .text_align(gpui::TextAlign::Right) - .child(hint), - ), - ) + .child(controls) } fn background_row(&mut self, cx: &mut Context) -> Div { From 1972ef3bef71489d38ef3340de307a45e0d360e4 Mon Sep 17 00:00:00 2001 From: Eshayat Al-Wasiu Date: Tue, 27 Oct 2026 17:57:13 +0600 Subject: [PATCH 5/8] Use shared settings row styling for server deletion Co-Authored-By: Codex (GPT-6.1) --- launcher/src/servers_page.rs | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/launcher/src/servers_page.rs b/launcher/src/servers_page.rs index 87a330b5d..01d626a30 100644 --- a/launcher/src/servers_page.rs +++ b/launcher/src/servers_page.rs @@ -2340,7 +2340,8 @@ impl Launcher { }, accounts_switch, )) - .child(self.background_row(cx)); + .child(self.background_row(cx)) + .child(self.delete_server_section(status, cx)); div() .flex() .flex_col() @@ -2350,7 +2351,6 @@ impl Launcher { a restart, and the page says so.", )) .child(form) - .child(self.delete_server_section(status, cx)) } fn delete_server_section(&mut self, status: &Status, cx: &mut Context) -> Div { @@ -2376,10 +2376,6 @@ impl Launcher { } else { None }; - let about = div().flex().flex_col().flex_1().min_w_0().gap(px(8.)) - .child(text(theme::HEADING).text_color(t.danger).child("Delete server")) - .child(text(theme::UI).text_color(t.ink_soft) - .child("Remove this server from Horizon and move its worlds, settings and backups to the trash.")); let mut controls = div() .flex() .flex_col() @@ -2413,7 +2409,7 @@ impl Launcher { disabled(confirm) }; controls = controls - .child(text(theme::UI).text_color(t.ink).child( + .child(text(theme::CAPTION).text_color(t.muted).child( gpui::StyledText::new(format!("Type {name} to confirm.")).with_highlights([( 5..5 + name.len(), gpui::HighlightStyle { @@ -2462,16 +2458,11 @@ impl Launcher { .child(hint), ); } - div() - .flex() - .items_start() - .gap(px(24.)) - .mt(px(24.)) - .pt(px(24.)) - .border_t_1() - .border_color(t.rule) - .child(about) - .child(controls) + self.setting_row( + "Delete server", + "Remove this server from Horizon and move its worlds, settings and backups to the trash.".into(), + controls.into_any_element(), + ) } fn background_row(&mut self, cx: &mut Context) -> Div { From 5d42108a98b329088eb1eb90b060becb2fb84d2a Mon Sep 17 00:00:00 2001 From: Eshayat Al-Wasiu Date: Tue, 27 Oct 2026 18:00:41 +0600 Subject: [PATCH 6/8] Place deletion confirmation help below input Co-Authored-By: Codex (GPT-6.1) --- launcher/src/servers_page.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/launcher/src/servers_page.rs b/launcher/src/servers_page.rs index 01d626a30..0936fdec2 100644 --- a/launcher/src/servers_page.rs +++ b/launcher/src/servers_page.rs @@ -2409,6 +2409,7 @@ impl Launcher { disabled(confirm) }; controls = controls + .child(field) .child(text(theme::CAPTION).text_color(t.muted).child( gpui::StyledText::new(format!("Type {name} to confirm.")).with_highlights([( 5..5 + name.len(), @@ -2418,7 +2419,6 @@ impl Launcher { }, )]), )) - .child(field) .child( div() .flex() @@ -2463,6 +2463,7 @@ impl Launcher { "Remove this server from Horizon and move its worlds, settings and backups to the trash.".into(), controls.into_any_element(), ) + .items_start() } fn background_row(&mut self, cx: &mut Context) -> Div { From 9d191c28f2081089e1ccc3d682980578bd9e4f56 Mon Sep 17 00:00:00 2001 From: Eshayat Al-Wasiu Date: Tue, 27 Oct 2026 18:12:18 +0600 Subject: [PATCH 7/8] Narrow server deletion confirmation input Co-Authored-By: Codex (GPT-6.1) --- launcher/src/servers_page.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/launcher/src/servers_page.rs b/launcher/src/servers_page.rs index 0936fdec2..41bca59e0 100644 --- a/launcher/src/servers_page.rs +++ b/launcher/src/servers_page.rs @@ -2385,7 +2385,7 @@ impl Launcher { .gap(px(12.)); if self.servers.confirm_delete { let matches = self.servers.value(Field::DeleteServer) == name && !name.is_empty(); - let field = self.field(Field::DeleteServer, "Server name", 260., cx); + let field = self.field(Field::DeleteServer, "Server name", 230., cx); let confirm = button( "server-delete-confirm", "Move to trash", From 84390f6dc1d3f50c4f3900fd3863c22cff07fd7c Mon Sep 17 00:00:00 2001 From: Eshayat Al-Wasiu Date: Tue, 27 Oct 2026 19:02:55 +0600 Subject: [PATCH 8/8] Match server deletion input to button row width Co-Authored-By: Codex (GPT-6.1) --- launcher/src/servers_page.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/launcher/src/servers_page.rs b/launcher/src/servers_page.rs index 41bca59e0..c87feba58 100644 --- a/launcher/src/servers_page.rs +++ b/launcher/src/servers_page.rs @@ -2385,7 +2385,7 @@ impl Launcher { .gap(px(12.)); if self.servers.confirm_delete { let matches = self.servers.value(Field::DeleteServer) == name && !name.is_empty(); - let field = self.field(Field::DeleteServer, "Server name", 230., cx); + let field = self.field(Field::DeleteServer, "Server name", 225., cx); let confirm = button( "server-delete-confirm", "Move to trash",