From fb31addcf78a1cca3797b72c5903d5bedec4fd34 Mon Sep 17 00:00:00 2001 From: Kacper Paczos Date: Sat, 22 Aug 2026 22:16:58 +0200 Subject: [PATCH 1/2] feat: disable Upgrade All Boxes when there is nothing to upgrade The Upgrade All button in the header stayed clickable even with no boxes, where it can only run distrobox upgrade over an empty list and do nothing. Gate it on the box count as well as the dependency check, and re-evaluate it whenever the list is (re)loaded so it follows create and delete. --- src/main.rs | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/src/main.rs b/src/main.rs index a20908a..15cce40 100644 --- a/src/main.rs +++ b/src/main.rs @@ -242,6 +242,10 @@ fn make_titlebar(window: &ApplicationWindow, dependencies_met: bool) { // preference and About, none of which touch distrobox. add_btn.set_sensitive(dependencies_met); assemble_btn.set_sensitive(dependencies_met); + // Named so a refresh can find it again and re-check whether there is + // anything to upgrade; the box count decides that, and load_boxes sets the + // real value the moment the list is known. + upgrade_btn.set_widget_name(UPGRADE_ALL_BTN_NAME); upgrade_btn.set_sensitive(dependencies_met); let titlebar = adw::HeaderBar::new(); @@ -254,6 +258,46 @@ fn make_titlebar(window: &ApplicationWindow, dependencies_met: bool) { window.set_titlebar(Some(&titlebar)); } +/// Widget name of the "Upgrade All Boxes" header button, so a refresh can find +/// it without threading a reference through every render path. +const UPGRADE_ALL_BTN_NAME: &str = "upgrade-all-boxes"; + +/// Whether "Upgrade All Boxes" can do anything: the tooling has to be present +/// and there has to be at least one box to upgrade. Upgrading zero boxes is a +/// no-op, so the button is offered only when it would actually act. +fn upgrade_all_is_available(dependencies_met: bool, box_count: usize) -> bool { + dependencies_met && box_count > 0 +} + +/// Reflects `upgrade_all_is_available` on the header button. The button lives in +/// the titlebar, so it is found by name rather than passed around. +fn set_upgrade_all_sensitive(window: &ApplicationWindow, sensitive: bool) { + if let Some(titlebar) = window.titlebar() { + if let Some(btn) = find_named_descendant(&titlebar, UPGRADE_ALL_BTN_NAME) { + btn.set_sensitive(sensitive); + } + } +} + +/// Depth-first search for the first descendant whose widget name matches. +/// Widgets keep their type name until one is set, so only the button we named +/// can match here. +fn find_named_descendant(widget: >k::Widget, name: &str) -> Option { + if widget.widget_name() == name { + return Some(widget.clone()); + } + + let mut child = widget.first_child(); + while let Some(c) = child { + if let Some(found) = find_named_descendant(&c, name) { + return Some(found); + } + child = c.next_sibling(); + } + + None +} + fn set_window_actions(window: &ApplicationWindow) { let action_close = gio::ActionEntry::builder("close") .activate(|window: &ApplicationWindow, _, _| { @@ -391,6 +435,10 @@ fn load_boxes(scroll_area: >k::Box, window: &ApplicationWindow, active_page: O let boxes = get_all_distroboxes(); + // load_boxes only runs once the dependencies are present, so the box count + // is the only thing left to decide whether upgrading all of them is useful. + set_upgrade_all_sensitive(window, upgrade_all_is_available(true, boxes.len())); + if boxes.is_empty() { render_no_boxes_message(scroll_area); return; @@ -2081,3 +2129,25 @@ fn show_preferred_terminal_popup(window: &ApplicationWindow) { term_pref_popup.set_child(Some(&main_box)); term_pref_popup.present(); } + +#[cfg(test)] +mod upgrade_gate_tests { + use super::upgrade_all_is_available; + + #[test] + fn zero_boxes_disables_upgrade_all() { + // Upgrading nothing is a no-op, so the button must be off. + assert!(!upgrade_all_is_available(true, 0)); + } + + #[test] + fn at_least_one_box_enables_it_when_dependencies_are_met() { + assert!(upgrade_all_is_available(true, 1)); + assert!(upgrade_all_is_available(true, 5)); + } + + #[test] + fn missing_dependencies_keep_it_disabled_regardless_of_count() { + assert!(!upgrade_all_is_available(false, 3)); + } +} From 69c8abaea5be4f22874e4c84a2378b76c643376a Mon Sep 17 00:00:00 2001 From: Kacper Paczos Date: Sun, 23 Aug 2026 09:45:04 +0200 Subject: [PATCH 2/2] simplify: drive Upgrade All through a window action The header button now binds to a "win.upgrade-all" action and its sensitivity follows the action's enabled state, which load_boxes sets from the box count. That drops the named-widget lookup, the tree walk and the one-line predicate with its tests; the behaviour is the same. --- src/main.rs | 95 ++++++++++++++--------------------------------------- 1 file changed, 25 insertions(+), 70 deletions(-) diff --git a/src/main.rs b/src/main.rs index 15cce40..6203fdd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -83,6 +83,9 @@ fn make_window(app: &Application) -> ApplicationWindow { let has_distrobox = has_distrobox_installed(); let has_container_engine = has_podman_or_docker_installed(); + // Actions first: the titlebar binds a button to one of them, and loading + // the boxes enables it. + set_window_actions(&window); make_titlebar(&window, has_distrobox && has_container_engine); let scrolled_win = gtk::ScrolledWindow::new(); @@ -119,8 +122,6 @@ fn make_window(app: &Application) -> ApplicationWindow { render_not_installed(&scroll_area); } - set_window_actions(&window); - window.present(); window @@ -193,7 +194,10 @@ fn make_titlebar(window: &ApplicationWindow, dependencies_met: bool) { let upgrade_btn = gtk::Button::from_icon_name(&get_available_icon_name(UPGRADE_ICON_NAMES)); // TRANSLATORS: Button tooltip upgrade_btn.set_tooltip_text(Some(&gettext("Upgrade All Boxes"))); - upgrade_btn.connect_clicked(move |_btn| upgrade_all_boxes()); + // Bound to the window action rather than a click handler, so its + // sensitivity simply follows the action: load_boxes enables it only once + // there is at least one box to upgrade. + upgrade_btn.set_action_name(Some("win.upgrade-all")); let assemble_img = make_assemble_image(); let assemble_btn = gtk::Button::new(); @@ -242,11 +246,6 @@ fn make_titlebar(window: &ApplicationWindow, dependencies_met: bool) { // preference and About, none of which touch distrobox. add_btn.set_sensitive(dependencies_met); assemble_btn.set_sensitive(dependencies_met); - // Named so a refresh can find it again and re-check whether there is - // anything to upgrade; the box count decides that, and load_boxes sets the - // real value the moment the list is known. - upgrade_btn.set_widget_name(UPGRADE_ALL_BTN_NAME); - upgrade_btn.set_sensitive(dependencies_met); let titlebar = adw::HeaderBar::new(); @@ -258,46 +257,19 @@ fn make_titlebar(window: &ApplicationWindow, dependencies_met: bool) { window.set_titlebar(Some(&titlebar)); } -/// Widget name of the "Upgrade All Boxes" header button, so a refresh can find -/// it without threading a reference through every render path. -const UPGRADE_ALL_BTN_NAME: &str = "upgrade-all-boxes"; - -/// Whether "Upgrade All Boxes" can do anything: the tooling has to be present -/// and there has to be at least one box to upgrade. Upgrading zero boxes is a -/// no-op, so the button is offered only when it would actually act. -fn upgrade_all_is_available(dependencies_met: bool, box_count: usize) -> bool { - dependencies_met && box_count > 0 -} - -/// Reflects `upgrade_all_is_available` on the header button. The button lives in -/// the titlebar, so it is found by name rather than passed around. -fn set_upgrade_all_sensitive(window: &ApplicationWindow, sensitive: bool) { - if let Some(titlebar) = window.titlebar() { - if let Some(btn) = find_named_descendant(&titlebar, UPGRADE_ALL_BTN_NAME) { - btn.set_sensitive(sensitive); - } +/// "Upgrade All Boxes" is a window action so the header button's sensitivity +/// follows it. Upgrading zero boxes is a no-op, so it is only enabled once +/// load_boxes has found something to upgrade - which also means it stays off +/// while distrobox or the container engine are missing. +fn set_upgrade_all_enabled(window: &ApplicationWindow, enabled: bool) { + if let Some(action) = window + .lookup_action("upgrade-all") + .and_downcast::() + { + action.set_enabled(enabled); } } -/// Depth-first search for the first descendant whose widget name matches. -/// Widgets keep their type name until one is set, so only the button we named -/// can match here. -fn find_named_descendant(widget: >k::Widget, name: &str) -> Option { - if widget.widget_name() == name { - return Some(widget.clone()); - } - - let mut child = widget.first_child(); - while let Some(c) = child { - if let Some(found) = find_named_descendant(&c, name) { - return Some(found); - } - child = c.next_sibling(); - } - - None -} - fn set_window_actions(window: &ApplicationWindow) { let action_close = gio::ActionEntry::builder("close") .activate(|window: &ApplicationWindow, _, _| { @@ -323,12 +295,19 @@ fn set_window_actions(window: &ApplicationWindow) { }) .build(); + let action_upgrade_all = gio::ActionEntry::builder("upgrade-all") + .activate(|_window: &ApplicationWindow, _, _| upgrade_all_boxes()) + .build(); + window.add_action_entries([ action_refresh, action_about, action_close, action_set_preferred_terminal, + action_upgrade_all, ]); + + set_upgrade_all_enabled(window, false); } fn get_main_menu_model() -> gio::MenuModel { @@ -435,9 +414,7 @@ fn load_boxes(scroll_area: >k::Box, window: &ApplicationWindow, active_page: O let boxes = get_all_distroboxes(); - // load_boxes only runs once the dependencies are present, so the box count - // is the only thing left to decide whether upgrading all of them is useful. - set_upgrade_all_sensitive(window, upgrade_all_is_available(true, boxes.len())); + set_upgrade_all_enabled(window, !boxes.is_empty()); if boxes.is_empty() { render_no_boxes_message(scroll_area); @@ -2129,25 +2106,3 @@ fn show_preferred_terminal_popup(window: &ApplicationWindow) { term_pref_popup.set_child(Some(&main_box)); term_pref_popup.present(); } - -#[cfg(test)] -mod upgrade_gate_tests { - use super::upgrade_all_is_available; - - #[test] - fn zero_boxes_disables_upgrade_all() { - // Upgrading nothing is a no-op, so the button must be off. - assert!(!upgrade_all_is_available(true, 0)); - } - - #[test] - fn at_least_one_box_enables_it_when_dependencies_are_met() { - assert!(upgrade_all_is_available(true, 1)); - assert!(upgrade_all_is_available(true, 5)); - } - - #[test] - fn missing_dependencies_keep_it_disabled_regardless_of_count() { - assert!(!upgrade_all_is_available(false, 3)); - } -}