From 686e4a9567b2e5dba1cc37257ba657d1e9265831 Mon Sep 17 00:00:00 2001 From: Kacper Paczos Date: Tue, 25 Aug 2026 21:54:18 +0200 Subject: [PATCH 1/5] feat: named profiles for the home directory a box is given Creating a box already accepts a custom home directory, but it is a bare path field: nothing says what it changes, and anyone using it for more than one box has to remember the paths and retype them. A profile is that directory with a name on it. The primary menu gains a Profiles window listing them, each with the directory it points at, a Browse button that opens it in the file manager, and Remove. Adding one takes a name and puts the directory under ~/boxes; the directory itself is created when it is first needed rather than up front, since distrobox makes it when a box is built and Browse makes it when someone looks. The new-box form gets a Profile combo listing "Host (shared home)" first, then the profiles. Picking one fills in the home path; picking Host clears it, which is distrobox's own default and what every existing box uses. The folder picker still works for a one-off path, and the value handed to distrobox is unchanged - so this is a way of choosing the existing option, not a new mechanism. Profiles live in GSettings as a name-to-path map, the way exported-app labels do. Removing one forgets the setting only: the directory and any box already built on it are left alone. --- io.github.dvlv.boxbuddyrs.gschema.xml | 8 + src/main.rs | 202 ++++++++++++++++++++++++-- src/utils.rs | 94 +++++++++++- 3 files changed, 291 insertions(+), 13 deletions(-) diff --git a/io.github.dvlv.boxbuddyrs.gschema.xml b/io.github.dvlv.boxbuddyrs.gschema.xml index 19e8785..39ee0d3 100644 --- a/io.github.dvlv.boxbuddyrs.gschema.xml +++ b/io.github.dvlv.boxbuddyrs.gschema.xml @@ -8,5 +8,13 @@ The terminal which should be checked for first when performing an action which spawns a terminal window. + + {} + Named home directories for boxes + + Maps a profile name to the home directory boxes using it are given. A box + with no profile uses the host's home, which is distrobox's default. + + diff --git a/src/main.rs b/src/main.rs index a20908a..49f16d5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,7 +3,9 @@ use std::path::Path; use std::thread; use adw::{ - prelude::{ActionRowExt, MessageDialogExt, PreferencesGroupExt, PreferencesRowExt}, + prelude::{ + ActionRowExt, ComboRowExt, MessageDialogExt, PreferencesGroupExt, PreferencesRowExt, + }, ActionRow, Application, StyleManager, ToastOverlay, }; use gtk::{ @@ -27,13 +29,14 @@ use distrobox_handler::{ mod utils; use utils::{ get_assemble_icon, get_available_app_icon_name, get_available_icon_name, get_cpu_and_mem_usage, - get_deb_distros, get_distro_img, get_download_dir_path, get_my_deb_boxes, get_my_rpm_boxes, - get_rpm_distros, get_supported_terminals, get_supported_terminals_list, - get_terminal_and_separator_arg, has_distrobox_installed, has_file_extension, has_host_access, - has_podman_or_docker_installed, set_up_localisation, ADD_ICON_NAMES, APPLICATIONS_ICON_NAMES, - ASSEMBLE_FALLBACK_ICON_NAMES, COPY_ICON_NAMES, INFO_ICON_NAMES, INSTALL_PACKAGE_ICON_NAMES, - MENU_ICON_NAMES, OPEN_FILE_ICON_NAMES, REMOVE_ICON_NAMES, STOP_ICON_NAMES, TERMINAL_ICON_NAMES, - TRASH_ICON_NAMES, UPGRADE_ICON_NAMES, WARNING_ICON_NAMES, + get_deb_distros, get_distro_img, get_download_dir_path, get_host_home_dir, get_my_deb_boxes, + get_my_rpm_boxes, get_profiles, get_rpm_distros, get_supported_terminals, + get_supported_terminals_list, get_terminal_and_separator_arg, has_distrobox_installed, + has_file_extension, has_host_access, has_podman_or_docker_installed, open_path_in_file_manager, + remove_profile, set_profile, set_up_localisation, valid_profile_name, ADD_ICON_NAMES, + APPLICATIONS_ICON_NAMES, ASSEMBLE_FALLBACK_ICON_NAMES, COPY_ICON_NAMES, INFO_ICON_NAMES, + INSTALL_PACKAGE_ICON_NAMES, MENU_ICON_NAMES, OPEN_FILE_ICON_NAMES, REMOVE_ICON_NAMES, + STOP_ICON_NAMES, TERMINAL_ICON_NAMES, TRASH_ICON_NAMES, UPGRADE_ICON_NAMES, WARNING_ICON_NAMES, }; const APP_ID: &str = "io.github.dvlv.boxbuddyrs"; @@ -279,11 +282,18 @@ fn set_window_actions(window: &ApplicationWindow) { }) .build(); + let action_show_profiles = gio::ActionEntry::builder("show_profiles") + .activate(|window: &ApplicationWindow, _, _| { + show_profiles_popup(window); + }) + .build(); + window.add_action_entries([ action_refresh, action_about, action_close, action_set_preferred_terminal, + action_show_profiles, ]); } @@ -306,11 +316,19 @@ fn get_main_menu_model() -> gio::MenuModel { ); menu.insert_item( 2, + &gio::MenuItem::new( + //TRANSLATORS: Menu Item + Some(&gettext("Profiles")), + Some("win.show_profiles"), + ), + ); + menu.insert_item( + 3, //TRANSLATORS: Menu Item &gio::MenuItem::new(Some(&gettext("About BoxBuddy")), Some("win.about")), ); menu.insert_item( - 3, + 4, //TRANSLATORS: Menu Item &gio::MenuItem::new(Some(&gettext("Quit")), Some("win.close")), ); @@ -895,7 +913,7 @@ fn create_new_distrobox(window: &ApplicationWindow) { // TRANSLATORS: Entry Label - Name input for new distrobox name_entry_row.set_title(&gettext("Name")); - // custom home + // Profile selection let choose_home_btn = gtk::Button::from_icon_name(&get_available_icon_name(OPEN_FILE_ICON_NAMES)); choose_home_btn.set_margin_top(10); @@ -904,9 +922,10 @@ fn create_new_distrobox(window: &ApplicationWindow) { home_select_row.set_activatable_widget(Some(&choose_home_btn)); home_select_row.add_suffix(&choose_home_btn); - //home entry row for manual edit + // home entry row for manual edit / custom path (kept insensitive, driven by combo) let home_entry_row = adw::EntryRow::new(); home_entry_row.set_hexpand(true); + home_entry_row.set_sensitive(false); //Additional Volumes - will not be shown without host access let volume_box_list = gtk::ListBox::new(); @@ -920,8 +939,9 @@ fn create_new_distrobox(window: &ApplicationWindow) { home_select_row.add_prefix(&home_entry_row); let home_entry_row_future_clone = home_entry_row.clone(); + let home_row_for_picker = home_entry_row.clone(); choose_home_btn.connect_clicked(clone!(@weak window => move |_btn| { - let home_clone = home_entry_row.clone(); + let home_clone = home_row_for_picker.clone(); let file_dialog = FileDialog::builder().modal(false).build(); file_dialog.select_folder(Some(&window), None::<&gio::Cancellable>, clone!(@weak window => move |result| { if let Ok(file) = result { @@ -931,6 +951,37 @@ fn create_new_distrobox(window: &ApplicationWindow) { })); })); + // Profile combo row + let profiles = get_profiles(); + let mut profile_names = vec![gettext("Host (shared home)")]; + for (name, _path) in &profiles { + profile_names.push(name.clone()); + } + let profile_strlist = gtk::StringList::new( + &profile_names + .iter() + .map(|s| s.as_str()) + .collect::>(), + ); + + let profile_combo = adw::ComboRow::new(); + profile_combo.set_title(&gettext("Profile")); + profile_combo.set_model(Some(&profile_strlist)); + profile_combo.set_selected(0); + + let profile_combo_clone = profile_combo.clone(); + let home_entry_row_combo_clone = home_entry_row.clone(); + let profiles_clone = profiles.clone(); + profile_combo.connect_selected_item_notify(move |_combo| { + let selected = profile_combo_clone.selected(); + if selected == 0 { + // "Host (shared home)" - empty path + home_entry_row_combo_clone.set_text(""); + } else if let Some((_name, path)) = profiles_clone.get((selected - 1) as usize) { + home_entry_row_combo_clone.set_text(path); + } + }); + // hostname let hostname_entry_row = adw::EntryRow::new(); hostname_entry_row.set_hexpand(true); @@ -1075,6 +1126,7 @@ fn create_new_distrobox(window: &ApplicationWindow) { boxed_list.append(&image_select_row); boxed_list.append(&init_row); + boxed_list.append(&profile_combo); boxed_list.append(&home_select_row); boxed_list.append(&hostname_entry_row); @@ -2081,3 +2133,129 @@ fn show_preferred_terminal_popup(window: &ApplicationWindow) { term_pref_popup.set_child(Some(&main_box)); term_pref_popup.present(); } + +/// One row of the profiles list: its name, the directory boxes using it get, +/// a button to look at that directory in the file manager, and one to forget +/// the profile. Removing it only forgets the setting - the directory and any +/// box already built on it are left alone. +fn add_profile_row(group: &adw::PreferencesGroup, name: &str, path: &str) { + let row = adw::ActionRow::new(); + row.set_title(name); + row.set_subtitle(path); + + // TRANSLATORS: Button Label - opens the profile's folder in the file manager + let browse_btn = gtk::Button::with_label(&gettext("Browse")); + browse_btn.set_valign(Align::Center); + let browse_path = path.to_string(); + browse_btn.connect_clicked(move |_btn| { + open_path_in_file_manager(&browse_path); + }); + row.add_suffix(&browse_btn); + + // TRANSLATORS: Button Label + let remove_btn = gtk::Button::with_label(&gettext("Remove")); + remove_btn.set_valign(Align::Center); + let name_clone = name.to_string(); + let row_clone = row.clone(); + let group_clone = group.clone(); + remove_btn.connect_clicked(move |_btn| { + remove_profile(&name_clone); + group_clone.remove(&row_clone); + }); + row.add_suffix(&remove_btn); + + group.add(&row); +} + +fn show_profiles_popup(window: &ApplicationWindow) { + let profiles_popup = gtk::Window::builder() + // TRANSLATORS: Popup Window Title + .title(gettext("Profiles")) + .transient_for(window) + .default_width(600) + .default_height(400) + .modal(true) + .build(); + + // TRANSLATORS: Button Label + let close_btn = gtk::Button::with_label(&gettext("Close")); + close_btn.add_css_class("suggested-action"); + close_btn.connect_clicked(move |btn| { + let win = btn.root().and_downcast::().unwrap(); + win.destroy(); + }); + + let profiles_titlebar = adw::HeaderBar::new(); + profiles_titlebar.set_show_end_title_buttons(false); + profiles_titlebar.pack_end(&close_btn); + + profiles_popup.set_titlebar(Some(&profiles_titlebar)); + + let main_box = gtk::Box::new(Orientation::Vertical, 10); + main_box.set_margin_start(10); + main_box.set_margin_end(10); + main_box.set_margin_top(10); + main_box.set_margin_bottom(10); + + // TRANSLATORS: Dialog heading + let heading = gtk::Label::new(Some(&gettext("Profiles"))); + heading.add_css_class("title-1"); + heading.set_xalign(0.0); + + // TRANSLATORS: Dialog body text + let body = gtk::Label::new(Some(&gettext( + "A profile gives a box its own home directory, so applications keep separate settings and logins. Boxes with no profile use your host home.", + ))); + body.set_xalign(0.0); + body.set_wrap(true); + body.set_wrap_mode(gtk::pango::WrapMode::WordChar); + + let prefs_group = adw::PreferencesGroup::new(); + + let profiles = get_profiles(); + for (name, path) in &profiles { + add_profile_row(&prefs_group, name, path); + } + + // Add new profile row + let add_row = adw::ActionRow::new(); + + let name_entry = adw::EntryRow::new(); + // TRANSLATORS: Entry title for new profile name + name_entry.set_title(&gettext("New Profile Name")); + name_entry.set_hexpand(true); + + let add_btn = gtk::Button::with_label(&gettext("Add")); + add_btn.add_css_class("suggested-action"); + add_btn.set_valign(Align::Center); + + let name_entry_clone = name_entry.clone(); + let prefs_group_clone = prefs_group.clone(); + let popup_clone = profiles_popup.clone(); + add_btn.connect_clicked(move |_btn| { + let name = name_entry_clone.text().to_string(); + let trimmed = name.trim(); + if !valid_profile_name(trimmed) { + return; + } + let host_home = get_host_home_dir(); + let safe_name = trimmed.replace(' ', "-"); + let home_path = format!("{host_home}/boxes/{safe_name}"); + set_profile(trimmed, &home_path); + + add_profile_row(&prefs_group_clone, trimmed, &home_path); + name_entry_clone.set_text(""); + popup_clone.queue_draw(); + }); + + add_row.add_prefix(&name_entry); + add_row.add_suffix(&add_btn); + prefs_group.add(&add_row); + + main_box.append(&heading); + main_box.append(&body); + main_box.append(&prefs_group); + + profiles_popup.set_child(Some(&main_box)); + profiles_popup.present(); +} diff --git a/src/utils.rs b/src/utils.rs index 20f3743..9f9c540 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,7 +1,7 @@ use adw::StyleManager; use gettextrs::{bind_textdomain_codeset, setlocale, textdomain, LocaleCategory}; use gtk::gio::Settings; -use gtk::prelude::SettingsExt; +use gtk::prelude::{SettingsExt, SettingsExtManual}; use std::collections::HashMap; use std::env; use std::path::Path; @@ -890,6 +890,76 @@ pub fn get_download_dir_path() -> String { }) } +/// Returns the user's home directory, used as a base for profile paths. +pub fn get_host_home_dir() -> String { + env::var("HOME").unwrap_or_else(|_| ".".to_string()) +} + +/// Validates a profile name. +/// Rules: not empty after trimming, at most 32 characters, and only letters, +/// digits, space, `-` and `_`. +pub fn valid_profile_name(name: &str) -> bool { + let trimmed = name.trim(); + if trimmed.is_empty() || trimmed.len() > 32 { + return false; + } + trimmed + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == ' ' || c == '-' || c == '_') +} + +/// Opens a profile's home directory in the system file manager, creating it +/// first if it is not there yet: distrobox only creates it when a box using the +/// profile is built, and an empty folder is more useful to look at than an +/// error. Spawned rather than waited on, so the file manager starting up does +/// not block the interface. +pub fn open_path_in_file_manager(path: &str) { + let _ = run_command("mkdir", Some(&["-p", "--", path])); + + let mut cmd = Command::new("xdg-open"); + if is_flatpak() { + cmd = Command::new("flatpak-spawn"); + cmd.arg("--host"); + cmd.arg("xdg-open"); + } + cmd.arg(path); + let _ = cmd.spawn(); +} + +/// Returns all profiles as a vector of (name, home_path) tuples, sorted by name. +pub fn get_profiles() -> Vec<(String, String)> { + let settings = Settings::new(APP_ID); + let profiles: HashMap = settings.get::>("profiles"); + let mut vec: Vec<(String, String)> = profiles.into_iter().collect(); + vec.sort_by(|a, b| a.0.cmp(&b.0)); + vec +} + +/// Adds or replaces a profile. Empty name or path is a no-op. +pub fn set_profile(name: &str, home_path: &str) { + let name = name.trim(); + let home_path = home_path.trim(); + if name.is_empty() || home_path.is_empty() { + return; + } + let settings = Settings::new(APP_ID); + let mut profiles: HashMap = settings.get::>("profiles"); + profiles.insert(name.to_string(), home_path.to_string()); + let _ = settings.set("profiles", &profiles); +} + +/// Removes a profile by name. +pub fn remove_profile(name: &str) { + let name = name.trim(); + if name.is_empty() { + return; + } + let settings = Settings::new(APP_ID); + let mut profiles: HashMap = settings.get::>("profiles"); + profiles.remove(name); + let _ = settings.set("profiles", &profiles); +} + #[cfg(test)] mod tests { use super::{detect_pkg_manager, PkgManager}; @@ -968,3 +1038,25 @@ mod tests { ); } } + +#[cfg(test)] +mod profile_tests { + use super::valid_profile_name; + + #[test] + fn valid_profile_names_accepted() { + assert!(valid_profile_name("work")); + assert!(valid_profile_name("Personal 2")); + assert!(valid_profile_name("a_b-c")); + } + + #[test] + fn invalid_profile_names_rejected() { + assert!(!valid_profile_name("")); + assert!(!valid_profile_name(" ")); + assert!(!valid_profile_name("a".repeat(40).as_str())); + assert!(!valid_profile_name("a/b")); + assert!(!valid_profile_name("a\"b")); + assert!(!valid_profile_name("a$b")); + } +} From fb4f08401c5faec0cd9e7ed491b0e71589f5547a Mon Sep 17 00:00:00 2001 From: Kacper Paczos Date: Tue, 25 Aug 2026 23:18:13 +0200 Subject: [PATCH 2/5] fix: one row decides a new box's home, not three MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first version of this left the Home Directory entry and its folder button in place beside the new Profile combo, with the combo writing into the entry. Two controls for one setting, and nothing said which of them won - the field even kept its "Leave blank for default" label while the combo was the thing actually deciding. The row is now the only control. Its choices are Host (shared home), the profiles, and Custom folder…, which opens the same folder chooser as before; the chosen directory shows as the row's subtitle, so the path is still visible. Cancelling the chooser falls back to Host rather than leaving the row claiming a folder nobody picked. What is lost is typing a path by hand, which the entry allowed. In a graphical form a folder chooser is the better way to name a directory, and the alternative was keeping an ambiguity in the interface to preserve it. --- src/main.rs | 95 +++++++++++++++++++++++++++-------------------------- 1 file changed, 48 insertions(+), 47 deletions(-) diff --git a/src/main.rs b/src/main.rs index 49f16d5..0f0cfb9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,7 @@ use gettextrs::gettext; +use std::cell::RefCell; use std::path::Path; +use std::rc::Rc; use std::thread; use adw::{ @@ -35,8 +37,8 @@ use utils::{ has_file_extension, has_host_access, has_podman_or_docker_installed, open_path_in_file_manager, remove_profile, set_profile, set_up_localisation, valid_profile_name, ADD_ICON_NAMES, APPLICATIONS_ICON_NAMES, ASSEMBLE_FALLBACK_ICON_NAMES, COPY_ICON_NAMES, INFO_ICON_NAMES, - INSTALL_PACKAGE_ICON_NAMES, MENU_ICON_NAMES, OPEN_FILE_ICON_NAMES, REMOVE_ICON_NAMES, - STOP_ICON_NAMES, TERMINAL_ICON_NAMES, TRASH_ICON_NAMES, UPGRADE_ICON_NAMES, WARNING_ICON_NAMES, + INSTALL_PACKAGE_ICON_NAMES, MENU_ICON_NAMES, REMOVE_ICON_NAMES, STOP_ICON_NAMES, + TERMINAL_ICON_NAMES, TRASH_ICON_NAMES, UPGRADE_ICON_NAMES, WARNING_ICON_NAMES, }; const APP_ID: &str = "io.github.dvlv.boxbuddyrs"; @@ -913,50 +915,24 @@ fn create_new_distrobox(window: &ApplicationWindow) { // TRANSLATORS: Entry Label - Name input for new distrobox name_entry_row.set_title(&gettext("Name")); - // Profile selection - let choose_home_btn = - gtk::Button::from_icon_name(&get_available_icon_name(OPEN_FILE_ICON_NAMES)); - choose_home_btn.set_margin_top(10); - choose_home_btn.set_margin_bottom(10); - let home_select_row = adw::ActionRow::new(); - home_select_row.set_activatable_widget(Some(&choose_home_btn)); - home_select_row.add_suffix(&choose_home_btn); - - // home entry row for manual edit / custom path (kept insensitive, driven by combo) - let home_entry_row = adw::EntryRow::new(); - home_entry_row.set_hexpand(true); - home_entry_row.set_sensitive(false); - //Additional Volumes - will not be shown without host access let volume_box_list = gtk::ListBox::new(); volume_box_list.set_selection_mode(gtk::SelectionMode::None); volume_box_list.add_css_class("boxed-list"); volume_box_list.set_visible(false); - // TRANSLATORS: Entry Label - Select home directory for new distrobox - home_entry_row.set_title(&gettext("Home Directory (Leave blank for default)")); - home_entry_row.set_width_request(600); - home_select_row.add_prefix(&home_entry_row); - let home_entry_row_future_clone = home_entry_row.clone(); - - let home_row_for_picker = home_entry_row.clone(); - choose_home_btn.connect_clicked(clone!(@weak window => move |_btn| { - let home_clone = home_row_for_picker.clone(); - let file_dialog = FileDialog::builder().modal(false).build(); - file_dialog.select_folder(Some(&window), None::<&gio::Cancellable>, clone!(@weak window => move |result| { - if let Ok(file) = result { - let home_path = file.path().unwrap().into_os_string().into_string().unwrap(); - home_clone.set_text(&home_path); - } - })); - })); - - // Profile combo row + // One row decides where the box's home is: the host's, a profile's, or a + // folder picked on the spot. A separate path field beside it would only + // raise the question of which of the two wins. let profiles = get_profiles(); + //TRANSLATORS: Profile choice meaning "no separate home, share the host's" let mut profile_names = vec![gettext("Host (shared home)")]; for (name, _path) in &profiles { profile_names.push(name.clone()); } + //TRANSLATORS: Last profile choice - opens a folder chooser for a one-off home + profile_names.push(gettext("Custom folder…")); + let custom_index = (profile_names.len() - 1) as u32; let profile_strlist = gtk::StringList::new( &profile_names .iter() @@ -964,23 +940,42 @@ fn create_new_distrobox(window: &ApplicationWindow) { .collect::>(), ); + // The home path the Create button will use; empty means the host's home. + let chosen_home: Rc> = Rc::new(RefCell::new(String::new())); + let profile_combo = adw::ComboRow::new(); + //TRANSLATORS: Combo Row Title - which home the new box is given profile_combo.set_title(&gettext("Profile")); profile_combo.set_model(Some(&profile_strlist)); profile_combo.set_selected(0); - let profile_combo_clone = profile_combo.clone(); - let home_entry_row_combo_clone = home_entry_row.clone(); + let combo_for_handler = profile_combo.clone(); + let chosen_home_combo = chosen_home.clone(); let profiles_clone = profiles.clone(); - profile_combo.connect_selected_item_notify(move |_combo| { - let selected = profile_combo_clone.selected(); - if selected == 0 { - // "Host (shared home)" - empty path - home_entry_row_combo_clone.set_text(""); + profile_combo.connect_selected_item_notify(clone!(@weak window => move |_combo| { + let selected = combo_for_handler.selected(); + if selected == custom_index { + let combo_for_pick = combo_for_handler.clone(); + let chosen_for_pick = chosen_home_combo.clone(); + let file_dialog = FileDialog::builder().modal(false).build(); + file_dialog.select_folder(Some(&window), None::<&gio::Cancellable>, move |result| { + if let Ok(file) = result { + if let Some(path) = file.path().and_then(|p| p.into_os_string().into_string().ok()) { + combo_for_pick.set_subtitle(&path); + chosen_for_pick.replace(path); + return; + } + } + // Nothing picked: fall back to the host so the row cannot claim + // a folder that was never chosen. + combo_for_pick.set_selected(0); + }); + } else if selected == 0 { + profile_combo_set_home(&combo_for_handler, &chosen_home_combo, ""); } else if let Some((_name, path)) = profiles_clone.get((selected - 1) as usize) { - home_entry_row_combo_clone.set_text(path); + profile_combo_set_home(&combo_for_handler, &chosen_home_combo, path); } - }); + })); // hostname let hostname_entry_row = adw::EntryRow::new(); @@ -1023,7 +1018,7 @@ fn create_new_distrobox(window: &ApplicationWindow) { let loading_spinner = gtk::Spinner::new(); - let home_row = home_entry_row_future_clone.clone(); + let chosen_home_for_create = chosen_home.clone(); let hn_row = hostname_entry_row.clone(); let ne_row = name_entry_row.clone(); let is_row = image_select_row.clone(); @@ -1033,7 +1028,7 @@ fn create_new_distrobox(window: &ApplicationWindow) { let volume_box_list_clone = volume_box_list.clone(); create_btn.connect_clicked(move |btn| { let mut name = ne_row.text().to_string(); - let mut home_path = home_row.text().to_string(); + let mut home_path = chosen_home_for_create.borrow().clone(); let mut hostname = hn_row.text().to_string(); let use_init = in_row.is_active(); let mut image = is_row @@ -1127,7 +1122,6 @@ fn create_new_distrobox(window: &ApplicationWindow) { boxed_list.append(&init_row); boxed_list.append(&profile_combo); - boxed_list.append(&home_select_row); boxed_list.append(&hostname_entry_row); main_box.append(&boxed_list); @@ -2167,6 +2161,13 @@ fn add_profile_row(group: &adw::PreferencesGroup, name: &str, path: &str) { group.add(&row); } +/// Keeps the row's subtitle and the home path it stands for in step: the +/// subtitle is the only place the chosen directory is visible now. +fn profile_combo_set_home(row: &adw::ComboRow, home: &Rc>, path: &str) { + row.set_subtitle(path); + home.replace(path.to_string()); +} + fn show_profiles_popup(window: &ApplicationWindow) { let profiles_popup = gtk::Window::builder() // TRANSLATORS: Popup Window Title From 0448e9445fd8e09f2afe8d4bc110ec9801bb8b60 Mon Sep 17 00:00:00 2001 From: Kacper Paczos Date: Wed, 26 Aug 2026 10:20:21 +0200 Subject: [PATCH 3/5] feat: show a box's profile, and create one without leaving the form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A box gave no sign of which home it was on, so the one thing a profile decides was invisible once the box existed. Its page now opens with a Profile row - a fact about the box, not a button - naming the profile whose directory the box uses, or the host, or the bare path when it belongs to no profile. The home comes from the container's own metadata, so nothing has to be started to read it. The new-box form's Profile row gains a "New profile…" entry, so a profile can be made at the moment it is wanted rather than in a separate window first. It asks for a name, stores the profile under ~/boxes, and selects it. Two things that needed care: putting the selection back from inside the row's own notify handler does not stick, because GTK is still applying the change being reacted to - it is deferred to the main loop instead, so a cancelled dialog never leaves the row sitting on "New profile…". And the label logic is a pure function taking the host home and the profile list, so it can be tested without touching GSettings. --- src/main.rs | 182 ++++++++++++++++++++++++++++++++++++++++++++++++--- src/utils.rs | 90 ++++++++++++++++++++++++- 2 files changed, 260 insertions(+), 12 deletions(-) diff --git a/src/main.rs b/src/main.rs index 0f0cfb9..73fcf05 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,7 +6,8 @@ use std::thread; use adw::{ prelude::{ - ActionRowExt, ComboRowExt, MessageDialogExt, PreferencesGroupExt, PreferencesRowExt, + ActionRowExt, ComboRowExt, EntryRowExt, MessageDialogExt, PreferencesGroupExt, + PreferencesRowExt, }, ActionRow, Application, StyleManager, ToastOverlay, }; @@ -30,15 +31,16 @@ use distrobox_handler::{ mod utils; use utils::{ - get_assemble_icon, get_available_app_icon_name, get_available_icon_name, get_cpu_and_mem_usage, - get_deb_distros, get_distro_img, get_download_dir_path, get_host_home_dir, get_my_deb_boxes, - get_my_rpm_boxes, get_profiles, get_rpm_distros, get_supported_terminals, - get_supported_terminals_list, get_terminal_and_separator_arg, has_distrobox_installed, - has_file_extension, has_host_access, has_podman_or_docker_installed, open_path_in_file_manager, - remove_profile, set_profile, set_up_localisation, valid_profile_name, ADD_ICON_NAMES, - APPLICATIONS_ICON_NAMES, ASSEMBLE_FALLBACK_ICON_NAMES, COPY_ICON_NAMES, INFO_ICON_NAMES, - INSTALL_PACKAGE_ICON_NAMES, MENU_ICON_NAMES, REMOVE_ICON_NAMES, STOP_ICON_NAMES, - TERMINAL_ICON_NAMES, TRASH_ICON_NAMES, UPGRADE_ICON_NAMES, WARNING_ICON_NAMES, + get_assemble_icon, get_available_app_icon_name, get_available_icon_name, get_box_home, + get_cpu_and_mem_usage, get_deb_distros, get_distro_img, get_download_dir_path, + get_host_home_dir, get_my_deb_boxes, get_my_rpm_boxes, get_profiles, get_rpm_distros, + get_supported_terminals, get_supported_terminals_list, get_terminal_and_separator_arg, + has_distrobox_installed, has_file_extension, has_host_access, has_podman_or_docker_installed, + open_path_in_file_manager, profile_label_for_home, remove_profile, set_profile, + set_up_localisation, valid_profile_name, ADD_ICON_NAMES, APPLICATIONS_ICON_NAMES, + ASSEMBLE_FALLBACK_ICON_NAMES, COPY_ICON_NAMES, INFO_ICON_NAMES, INSTALL_PACKAGE_ICON_NAMES, + MENU_ICON_NAMES, REMOVE_ICON_NAMES, STOP_ICON_NAMES, TERMINAL_ICON_NAMES, TRASH_ICON_NAMES, + UPGRADE_ICON_NAMES, WARNING_ICON_NAMES, }; const APP_ID: &str = "io.github.dvlv.boxbuddyrs"; @@ -446,6 +448,11 @@ fn load_boxes(scroll_area: >k::Box, window: &ApplicationWindow, active_page: O fn make_box_tab(dbox: &DBox, window: &ApplicationWindow, tab_num: u32) -> gtk::Box { let box_name = dbox.name.clone(); + // Read the box's home directory once, with the rest of the box's data, + // rather than every time the row redraws. + let box_home = get_box_home(&box_name); + let profile_label = profile_label_for_home(&box_home); + let tab_box = gtk::Box::new(Orientation::Vertical, 15); tab_box.set_hexpand(true); @@ -571,7 +578,15 @@ fn make_box_tab(dbox: &DBox, window: &ApplicationWindow, tab_num: u32) -> gtk::B let win_clone = window.clone(); clone_row.connect_activated(move |_row| on_clone_clicked(&win_clone, clone_bn.clone())); + // Profile - a fact about the box, not an action: no suffix icon, no click. + let profile_row = ActionRow::new(); + //TRANSLATORS: Row label - shows which home directory the box is using + profile_row.set_title(&gettext("Profile")); + profile_row.set_subtitle(&profile_label); + profile_row.set_activatable(false); + // put all into list + boxed_list.append(&profile_row); boxed_list.append(&open_terminal_row); boxed_list.append(&upgrade_row); boxed_list.append(&show_applications_row); @@ -930,6 +945,12 @@ fn create_new_distrobox(window: &ApplicationWindow) { for (name, _path) in &profiles { profile_names.push(name.clone()); } + // The index where the dialog-triggering entry will sit, between the + // profiles and the custom-folder one. Tracked now so the handler can + // recognise it. + let new_index = profile_names.len() as u32; + //TRANSLATORS: Profile choice - opens a dialog to define a new profile + profile_names.push(gettext("New profile…")); //TRANSLATORS: Last profile choice - opens a folder chooser for a one-off home profile_names.push(gettext("Custom folder…")); let custom_index = (profile_names.len() - 1) as u32; @@ -943,6 +964,13 @@ fn create_new_distrobox(window: &ApplicationWindow) { // The home path the Create button will use; empty means the host's home. let chosen_home: Rc> = Rc::new(RefCell::new(String::new())); + // The most recent non-"New profile…" selection, so a cancelled dialog + // can put the row back where the user left it. + let last_valid_selection: Rc> = Rc::new(RefCell::new(0)); + // Set while we are changing the selection or model from inside the + // handler, so the resulting re-entry does not loop. + let suppress_handler: Rc> = Rc::new(RefCell::new(false)); + let profile_combo = adw::ComboRow::new(); //TRANSLATORS: Combo Row Title - which home the new box is given profile_combo.set_title(&gettext("Profile")); @@ -952,17 +980,25 @@ fn create_new_distrobox(window: &ApplicationWindow) { let combo_for_handler = profile_combo.clone(); let chosen_home_combo = chosen_home.clone(); let profiles_clone = profiles.clone(); + let last_valid_for_handler = last_valid_selection.clone(); + let suppress_for_handler = suppress_handler.clone(); profile_combo.connect_selected_item_notify(clone!(@weak window => move |_combo| { + if *suppress_for_handler.borrow() { + return; + } let selected = combo_for_handler.selected(); if selected == custom_index { + *last_valid_for_handler.borrow_mut() = custom_index; let combo_for_pick = combo_for_handler.clone(); let chosen_for_pick = chosen_home_combo.clone(); + let last_valid_for_pick = last_valid_for_handler.clone(); let file_dialog = FileDialog::builder().modal(false).build(); file_dialog.select_folder(Some(&window), None::<&gio::Cancellable>, move |result| { if let Ok(file) = result { if let Some(path) = file.path().and_then(|p| p.into_os_string().into_string().ok()) { combo_for_pick.set_subtitle(&path); chosen_for_pick.replace(path); + *last_valid_for_pick.borrow_mut() = custom_index; return; } } @@ -970,10 +1006,35 @@ fn create_new_distrobox(window: &ApplicationWindow) { // a folder that was never chosen. combo_for_pick.set_selected(0); }); + } else if selected == new_index { + // Putting the selection back from inside its own notify handler + // does not stick - GTK is still applying the change being reacted + // to, and the row would be left sitting on "New profile…". Do it + // once the main loop is idle, then ask for the name. + let previous = *last_valid_for_handler.borrow(); + let combo_deferred = combo_for_handler.clone(); + let chosen_deferred = chosen_home_combo.clone(); + let last_valid_deferred = last_valid_for_handler.clone(); + let suppress_deferred = suppress_for_handler.clone(); + let window_deferred = window.clone(); + glib::idle_add_local_once(move || { + *suppress_deferred.borrow_mut() = true; + combo_deferred.set_selected(previous); + *suppress_deferred.borrow_mut() = false; + show_new_profile_dialog( + &window_deferred, + &combo_deferred, + &chosen_deferred, + &last_valid_deferred, + &suppress_deferred, + ); + }); } else if selected == 0 { profile_combo_set_home(&combo_for_handler, &chosen_home_combo, ""); + *last_valid_for_handler.borrow_mut() = 0; } else if let Some((_name, path)) = profiles_clone.get((selected - 1) as usize) { profile_combo_set_home(&combo_for_handler, &chosen_home_combo, path); + *last_valid_for_handler.borrow_mut() = selected; } })); @@ -2168,6 +2229,107 @@ fn profile_combo_set_home(row: &adw::ComboRow, home: &Rc>, path: home.replace(path.to_string()); } +/// Rebuild the profile combo's model from a fresh list of profiles. Used +/// after a new profile is created so the row appears in the dropdown. +fn rebuild_profile_combo(combo: &adw::ComboRow, profiles: &[(String, String)]) { + // TRANSLATORS: Profile choice meaning "no separate home, share the host's" + let mut profile_names = vec![gettext("Host (shared home)")]; + for (name, _path) in profiles { + profile_names.push(name.clone()); + } + // TRANSLATORS: Profile choice - opens a dialog to define a new profile + profile_names.push(gettext("New profile…")); + // TRANSLATORS: Last profile choice - opens a folder chooser for a one-off home + profile_names.push(gettext("Custom folder…")); + let strlist = gtk::StringList::new( + &profile_names + .iter() + .map(|s| s.as_str()) + .collect::>(), + ); + combo.set_model(Some(&strlist)); +} + +/// Asks the user for a new profile name and, on a valid answer, adds it +/// under `/boxes/` - the +/// same path the standalone Profiles window uses. On Cancel or an invalid +/// name the combo is put back on whatever the user had before the dialog +/// was opened. +fn show_new_profile_dialog( + window: &ApplicationWindow, + combo: &adw::ComboRow, + chosen_home: &Rc>, + last_valid_selection: &Rc>, + suppress_handler: &Rc>, +) { + let d = adw::MessageDialog::new( + Some(window), + //TRANSLATORS: Dialog heading - asking for a new profile name + Some(&gettext("New Profile")), + //TRANSLATORS: Dialog body - explains what a profile is + Some(&gettext( + "A profile is a home directory of its own, so boxes using it keep separate \ +application settings and logins.", + )), + ); + d.set_transient_for(Some(window)); + + //TRANSLATORS: Entry title - the new profile's name + let name_entry = adw::EntryRow::new(); + name_entry.set_title(&gettext("Name")); + name_entry.set_activates_default(true); + d.set_extra_child(Some(&name_entry)); + + //TRANSLATORS: Button label + d.add_response("cancel", &gettext("Cancel")); + //TRANSLATORS: Button label + d.add_response("create", &gettext("Create")); + d.set_default_response(Some("create")); + d.set_close_response("cancel"); + d.set_response_appearance("create", adw::ResponseAppearance::Suggested); + + let combo_clone = combo.clone(); + let chosen_home_clone = chosen_home.clone(); + let last_valid_clone = last_valid_selection.clone(); + let suppress_clone = suppress_handler.clone(); + d.connect_response(None, move |d, res| { + let name = name_entry.text().to_string(); + if res == "create" && valid_profile_name(&name) { + let trimmed = name.trim(); + let host_home = get_host_home_dir(); + let safe_name = trimmed.replace(' ', "-"); + let home_path = format!("{host_home}/boxes/{safe_name}"); + set_profile(trimmed, &home_path); + + // Find where the new profile landed in the sorted list, rebuild + // the model with it included, and select it. + let new_profiles = get_profiles(); + let pos = new_profiles + .iter() + .position(|(n, _)| n == trimmed) + .unwrap_or(0); + let new_combo_index = 1 + pos as u32; + + *suppress_clone.borrow_mut() = true; + rebuild_profile_combo(&combo_clone, &new_profiles); + combo_clone.set_selected(new_combo_index); + profile_combo_set_home(&combo_clone, &chosen_home_clone, &home_path); + *last_valid_clone.borrow_mut() = new_combo_index; + *suppress_clone.borrow_mut() = false; + } else { + // Cancel or invalid name: snap back to wherever the user was + // before "New profile…" was picked. + let previous = *last_valid_clone.borrow(); + *suppress_clone.borrow_mut() = true; + combo_clone.set_selected(previous); + *suppress_clone.borrow_mut() = false; + } + d.destroy(); + }); + + d.present(); +} + fn show_profiles_popup(window: &ApplicationWindow) { let profiles_popup = gtk::Window::builder() // TRANSLATORS: Popup Window Title diff --git a/src/utils.rs b/src/utils.rs index 9f9c540..50d3667 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,5 +1,5 @@ use adw::StyleManager; -use gettextrs::{bind_textdomain_codeset, setlocale, textdomain, LocaleCategory}; +use gettextrs::{bind_textdomain_codeset, gettext, setlocale, textdomain, LocaleCategory}; use gtk::gio::Settings; use gtk::prelude::{SettingsExt, SettingsExtManual}; use std::collections::HashMap; @@ -960,6 +960,56 @@ pub fn remove_profile(name: &str) { let _ = settings.set("profiles", &profiles); } +/// Returns the home directory a running or stopped box is using, by reading +/// the `HOME=` entry from the container's metadata. Returns an empty string +/// when the path cannot be determined - inspect can fail, the box can be +/// missing, or the environment may simply not have a HOME set. The call site +/// decides what to show instead. +pub fn get_box_home(box_name: &str) -> String { + let runtime = get_container_runtime(); + let format = "{{range .Config.Env}}{{if eq (slice . 0 5) \"HOME=\"}}{{.}}{{end}}{{end}}"; + let output = + get_command_output_no_err(&runtime, Some(&["inspect", box_name, "--format", format])); + + for line in output.lines() { + let trimmed = line.trim(); + if let Some(path) = trimmed.strip_prefix("HOME=") { + return path.trim().to_string(); + } + } + + String::new() +} + +/// What the box page should display in place of the raw home path. Pure so +/// it can be unit-tested without touching the host: takes the host home and +/// the profile list as parameters instead of looking them up itself. A thin +/// wrapper that fetches them lives in `profile_label_for_home` below. +pub fn profile_label_for_home_pure( + home: &str, + host_home: &str, + profiles: &[(String, String)], +) -> String { + if home.is_empty() || home == host_home { + //TRANSLATORS: Label shown on a box's page for the host's shared home directory + return gettext("Host (shared home)"); + } + + for (name, path) in profiles { + if path == home { + return name.clone(); + } + } + + home.to_string() +} + +/// Wrapper that fetches the host home and the profile list itself, for +/// callers where doing it by hand would be more noise than help. +pub fn profile_label_for_home(home: &str) -> String { + profile_label_for_home_pure(home, &get_host_home_dir(), &get_profiles()) +} + #[cfg(test)] mod tests { use super::{detect_pkg_manager, PkgManager}; @@ -1041,7 +1091,7 @@ mod tests { #[cfg(test)] mod profile_tests { - use super::valid_profile_name; + use super::{profile_label_for_home_pure, valid_profile_name}; #[test] fn valid_profile_names_accepted() { @@ -1059,4 +1109,40 @@ mod profile_tests { assert!(!valid_profile_name("a\"b")); assert!(!valid_profile_name("a$b")); } + + #[test] + fn host_home_uses_host_label() { + let profiles = vec![("work".to_string(), "/home/me/boxes/work".to_string())]; + assert_eq!( + profile_label_for_home_pure("/home/me", "/home/me", &profiles), + "Host (shared home)" + ); + } + + #[test] + fn empty_home_uses_host_label() { + let profiles = vec![("work".to_string(), "/home/me/boxes/work".to_string())]; + assert_eq!( + profile_label_for_home_pure("", "/home/me", &profiles), + "Host (shared home)" + ); + } + + #[test] + fn profile_path_uses_profile_name() { + let profiles = vec![("work".to_string(), "/home/me/boxes/work".to_string())]; + assert_eq!( + profile_label_for_home_pure("/home/me/boxes/work", "/home/me", &profiles), + "work" + ); + } + + #[test] + fn unknown_path_returned_as_is() { + let profiles = vec![("work".to_string(), "/home/me/boxes/work".to_string())]; + assert_eq!( + profile_label_for_home_pure("/srv/elsewhere", "/home/me", &profiles), + "/srv/elsewhere" + ); + } } From 842fc0ac44a31ad65eeefa3bbbd09713cae7841b Mon Sep 17 00:00:00 2001 From: Kacper Paczos Date: Wed, 26 Aug 2026 11:06:21 +0200 Subject: [PATCH 4/5] feat: a searchable image chooser, and a Create button that means it distrobox offers 122 images here. They all went into one dropdown, which cannot be read: you cannot tell an Ubuntu from an Alpine without going through every line, and nothing says which are already on the machine. The Image row now opens a chooser: a search box, one filter per package manager, and a "Downloaded only" switch, with a count of what is showing. Each row carries the image URL and who publishes it - Docker Official, Fedora Project, Red Hat, the GitHub or Quay organisation, or failing all that the registry itself - worked out from the URL alone, so opening the window costs nothing and touches no network. The package-manager filter reuses detect_pkg_manager rather than a second list of distro names. The Create button was enabled by the name field alone, though creating also needs an image, so clicking it with no image chosen did nothing and explained nothing - a trap a user escapes by filling in unrelated fields. It now needs both. --- src/main.rs | 298 +++++++++++++++++++++++++++++++++++++++++++-------- src/utils.rs | 156 +++++++++++++++++++++++++++ 2 files changed, 411 insertions(+), 43 deletions(-) diff --git a/src/main.rs b/src/main.rs index 73fcf05..dac6ba7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,25 +22,25 @@ use gtk::{ mod distrobox_handler; use distrobox_handler::{ - assemble_box, clone_box, create_box, create_box_streaming, delete_box, export_app_from_box, get_all_distroboxes, - get_apps_in_box, get_available_images_with_distro_name, get_binaries_exported_from_box, - get_number_of_boxes, install_deb_in_box, install_rpm_in_box, open_terminal_in_box, - remove_app_from_host, remove_exported_binary_from_box, run_command_in_box, stop_box, - upgrade_all_boxes, upgrade_box, DBox, DBoxApp, + assemble_box, clone_box, create_box, create_box_streaming, delete_box, export_app_from_box, + get_all_distroboxes, get_apps_in_box, get_available_images_with_distro_name, + get_binaries_exported_from_box, get_number_of_boxes, install_deb_in_box, install_rpm_in_box, + open_terminal_in_box, remove_app_from_host, remove_exported_binary_from_box, + run_command_in_box, stop_box, upgrade_all_boxes, upgrade_box, DBox, DBoxApp, }; mod utils; use utils::{ - get_assemble_icon, get_available_app_icon_name, get_available_icon_name, get_box_home, - get_cpu_and_mem_usage, get_deb_distros, get_distro_img, get_download_dir_path, + detect_pkg_manager, get_assemble_icon, get_available_app_icon_name, get_available_icon_name, + get_box_home, get_cpu_and_mem_usage, get_deb_distros, get_distro_img, get_download_dir_path, get_host_home_dir, get_my_deb_boxes, get_my_rpm_boxes, get_profiles, get_rpm_distros, get_supported_terminals, get_supported_terminals_list, get_terminal_and_separator_arg, has_distrobox_installed, has_file_extension, has_host_access, has_podman_or_docker_installed, - open_path_in_file_manager, profile_label_for_home, remove_profile, set_profile, - set_up_localisation, valid_profile_name, ADD_ICON_NAMES, APPLICATIONS_ICON_NAMES, - ASSEMBLE_FALLBACK_ICON_NAMES, COPY_ICON_NAMES, INFO_ICON_NAMES, INSTALL_PACKAGE_ICON_NAMES, - MENU_ICON_NAMES, REMOVE_ICON_NAMES, STOP_ICON_NAMES, TERMINAL_ICON_NAMES, TRASH_ICON_NAMES, - UPGRADE_ICON_NAMES, WARNING_ICON_NAMES, + image_publisher, open_path_in_file_manager, profile_label_for_home, remove_profile, + set_profile, set_up_localisation, valid_profile_name, PkgManager, ADD_ICON_NAMES, + APPLICATIONS_ICON_NAMES, ASSEMBLE_FALLBACK_ICON_NAMES, COPY_ICON_NAMES, INFO_ICON_NAMES, + INSTALL_PACKAGE_ICON_NAMES, MENU_ICON_NAMES, REMOVE_ICON_NAMES, STOP_ICON_NAMES, + TERMINAL_ICON_NAMES, TRASH_ICON_NAMES, UPGRADE_ICON_NAMES, WARNING_ICON_NAMES, }; const APP_ID: &str = "io.github.dvlv.boxbuddyrs"; @@ -918,13 +918,18 @@ fn create_new_distrobox(window: &ApplicationWindow) { name_entry_row.set_hexpand(true); // name input must have text in it to enable the create button + // The chosen image entry, in the " - " shape the list uses. + let chosen_image: Rc> = Rc::new(RefCell::new(String::new())); + + // Both a name and an image are needed to create a box. The button used to + // light up on the name alone, so clicking it with no image chosen did + // nothing at all and said nothing about why. let ner_clone = name_entry_row.clone(); + let chosen_image_for_sens = chosen_image.clone(); name_entry_row.connect_changed(clone!(@weak create_btn => move |_row| { - if ner_clone.text().to_string().len() > 0 { - create_btn.set_sensitive(true); - } else { - create_btn.set_sensitive(false); - } + let has_name = !ner_clone.text().to_string().is_empty(); + let has_image = !chosen_image_for_sens.borrow().is_empty(); + create_btn.set_sensitive(has_name && has_image); })); // TRANSLATORS: Entry Label - Name input for new distrobox @@ -1049,25 +1054,35 @@ fn create_new_distrobox(window: &ApplicationWindow) { ))); // Image + // Distrobox offers well over a hundred images. A dropdown of that many + // lines cannot be read, so the row opens a chooser that can be searched + // and filtered instead. The chosen entry is kept in the same + // " - " shape the list has always used, so everything + // downstream can go on splitting it the way it did. let available_images = get_available_images_with_distro_name(); - let avail_images_as_ref: Vec<&str> = available_images.iter().map(|s| s as &str).collect(); - let imgs_strlist = gtk::StringList::new(avail_images_as_ref.as_slice()); - - let exp = gtk::PropertyExpression::new( - gtk::StringObject::static_type(), - None::, - "string", - ); - - let image_select = gtk::DropDown::new(Some(imgs_strlist), Some(exp)); - image_select.set_enable_search(true); - image_select.set_search_match_mode(gtk::StringFilterMatchMode::Substring); let image_select_row = adw::ActionRow::new(); - // TRANSLATORS - Label for Dropdown where the user selects the container image to create + // TRANSLATORS - Label for the row where the user selects the container image to create image_select_row.set_title(&gettext("Image")); - image_select_row.set_activatable_widget(Some(&image_select)); - image_select_row.add_suffix(&image_select); + // TRANSLATORS - Shown in the Image row before an image has been picked + image_select_row.set_subtitle(&gettext("None chosen")); + image_select_row.set_activatable(true); + + let images_for_chooser = available_images.clone(); + let chosen_for_chooser = chosen_image.clone(); + let row_for_chooser = image_select_row.clone(); + let name_for_chooser = name_entry_row.clone(); + let btn_for_chooser = create_btn.clone(); + image_select_row.connect_activated(clone!(@weak window => move |_row| { + show_image_chooser( + &window, + images_for_chooser.clone(), + row_for_chooser.clone(), + chosen_for_chooser.clone(), + name_for_chooser.clone(), + btn_for_chooser.clone(), + ); + })); // init let init_row = adw::SwitchRow::new(); @@ -1082,7 +1097,7 @@ fn create_new_distrobox(window: &ApplicationWindow) { let chosen_home_for_create = chosen_home.clone(); let hn_row = hostname_entry_row.clone(); let ne_row = name_entry_row.clone(); - let is_row = image_select_row.clone(); + let chosen_image_for_create = chosen_image.clone(); let in_row = init_row.clone(); let loading_spinner_clone = loading_spinner.clone(); let win_clone = window.clone(); @@ -1092,16 +1107,7 @@ fn create_new_distrobox(window: &ApplicationWindow) { let mut home_path = chosen_home_for_create.borrow().clone(); let mut hostname = hn_row.text().to_string(); let use_init = in_row.is_active(); - let mut image = is_row - .activatable_widget() - .and_downcast::() - .unwrap() - .selected_item() - .unwrap() - .downcast::() - .unwrap() - .string() - .to_string(); + let mut image = chosen_image_for_create.borrow().clone(); if name.is_empty() || image.is_empty() { return; @@ -2330,6 +2336,212 @@ application settings and logins.", d.present(); } +/// Splits one entry of `get_available_images_with_distro_name` into the parts +/// the chooser shows: the image URL, and whether it is already pulled. The +/// list marks a pulled image by appending " ✦ ", which is also why the URL has +/// to be taken from the end rather than the whole string. +fn image_entry_parts(entry: &str) -> (String, bool) { + let downloaded = entry.contains('✦'); + let url = entry + .split(" - ") + .last() + .unwrap_or(entry) + .replace(" ✦ ", "") + .trim() + .to_string(); + (url, downloaded) +} + +/// The chooser for a container image: over a hundred of them, so it can be +/// searched, filtered by package manager and narrowed to what is already on +/// the machine. Everything it shows comes from the image URL - nothing here +/// touches the network. +fn show_image_chooser( + window: &ApplicationWindow, + images: Vec, + image_row: adw::ActionRow, + chosen: Rc>, + name_row: adw::EntryRow, + create_btn: gtk::Button, +) { + let popup = gtk::Window::builder() + // TRANSLATORS: Popup Window Title + .title(gettext("Choose an Image")) + .transient_for(window) + .default_width(720) + .default_height(560) + .modal(true) + .build(); + + // TRANSLATORS: Button Label + let close_btn = gtk::Button::with_label(&gettext("Close")); + close_btn.connect_clicked(move |btn| { + if let Some(win) = btn.root().and_downcast::() { + win.destroy(); + } + }); + let titlebar = adw::HeaderBar::new(); + titlebar.set_show_end_title_buttons(false); + titlebar.pack_end(&close_btn); + popup.set_titlebar(Some(&titlebar)); + + let main_box = gtk::Box::new(Orientation::Vertical, 10); + main_box.set_margin_start(10); + main_box.set_margin_end(10); + main_box.set_margin_top(10); + main_box.set_margin_bottom(10); + + let search = gtk::SearchEntry::new(); + // TRANSLATORS: Placeholder in the image chooser's search box + search.set_placeholder_text(Some(&gettext("Search images"))); + + // One filter active at a time; "All" is the way back to everything. + let filter_box = gtk::Box::new(Orientation::Horizontal, 0); + filter_box.add_css_class("linked"); + filter_box.set_halign(Align::Center); + //TRANSLATORS: Image filter - no package-manager filter at all + let all_btn = gtk::ToggleButton::with_label(&gettext("All")); + all_btn.set_active(true); + let filters: Vec<(gtk::ToggleButton, Option)> = vec![ + (all_btn.clone(), None), + (gtk::ToggleButton::with_label("apt"), Some(PkgManager::Apt)), + (gtk::ToggleButton::with_label("dnf"), Some(PkgManager::Dnf)), + (gtk::ToggleButton::with_label("apk"), Some(PkgManager::Apk)), + ( + gtk::ToggleButton::with_label("pacman"), + Some(PkgManager::Pacman), + ), + ( + gtk::ToggleButton::with_label("zypper"), + Some(PkgManager::Zypper), + ), + ]; + for (btn, _) in &filters { + filter_box.append(btn); + } + + //TRANSLATORS: Image filter - only images already pulled onto this machine + let downloaded_check = gtk::CheckButton::with_label(&gettext("Downloaded only")); + + let count_label = gtk::Label::new(None); + count_label.set_xalign(0.0); + count_label.add_css_class("dim-label"); + + let list = gtk::ListBox::new(); + list.set_selection_mode(gtk::SelectionMode::None); + list.add_css_class("boxed-list"); + + let scroller = gtk::ScrolledWindow::new(); + scroller.set_vexpand(true); + scroller.set_child(Some(&list)); + + // Redrawn on every change of search text or filter: the list is small + // enough that rebuilding it is simpler than keeping rows in sync. + let refill = { + let list = list.clone(); + let count_label = count_label.clone(); + let search = search.clone(); + let downloaded_check = downloaded_check.clone(); + let images = images.clone(); + let filters = filters.clone(); + let image_row = image_row.clone(); + let chosen = chosen.clone(); + let name_row = name_row.clone(); + let create_btn = create_btn.clone(); + let popup = popup.clone(); + Rc::new(move || { + list.remove_all(); + let needle = search.text().to_string().to_lowercase(); + let wanted = filters + .iter() + .find(|(btn, _)| btn.is_active()) + .and_then(|(_, mgr)| *mgr); + let only_downloaded = downloaded_check.is_active(); + + let mut shown = 0; + for entry in &images { + let (url, downloaded) = image_entry_parts(entry); + if !needle.is_empty() && !entry.to_lowercase().contains(&needle) { + continue; + } + if only_downloaded && !downloaded { + continue; + } + if let Some(wanted) = wanted { + if detect_pkg_manager(&url) != Some(wanted) { + continue; + } + } + + let row = adw::ActionRow::new(); + row.set_title(&markup_escape_text(entry)); + row.set_subtitle(&markup_escape_text(&url)); + row.set_activatable(true); + + let publisher = gtk::Label::new(Some(&image_publisher(&url))); + publisher.add_css_class("dim-label"); + publisher.set_valign(Align::Center); + row.add_suffix(&publisher); + + let entry_clone = entry.clone(); + let image_row = image_row.clone(); + let chosen = chosen.clone(); + let name_row = name_row.clone(); + let create_btn = create_btn.clone(); + let popup = popup.clone(); + row.connect_activated(move |_row| { + image_row.set_subtitle(&markup_escape_text(&entry_clone)); + chosen.replace(entry_clone.clone()); + create_btn.set_sensitive(!name_row.text().to_string().is_empty()); + popup.destroy(); + }); + + list.append(&row); + shown += 1; + } + + //TRANSLATORS: Image chooser count - first number is what is shown, second the total + count_label.set_text(&gettext(&format!("{} of {} images", shown, images.len()))); + }) + }; + + let refill_for_search = refill.clone(); + search.connect_search_changed(move |_entry| refill_for_search()); + let refill_for_check = refill.clone(); + downloaded_check.connect_toggled(move |_btn| refill_for_check()); + for (btn, _) in &filters { + let others: Vec = filters + .iter() + .map(|(b, _)| b.clone()) + .filter(|b| b != btn) + .collect(); + let refill_for_btn = refill.clone(); + btn.connect_toggled(move |this| { + if this.is_active() { + for other in &others { + other.set_active(false); + } + refill_for_btn(); + } else if others.iter().all(|b| !b.is_active()) { + // Refusing to leave every filter off keeps the list from + // going blank with no way back. + this.set_active(true); + } + }); + } + + refill(); + + main_box.append(&search); + main_box.append(&filter_box); + main_box.append(&downloaded_check); + main_box.append(&count_label); + main_box.append(&scroller); + + popup.set_child(Some(&main_box)); + popup.present(); +} + fn show_profiles_popup(window: &ApplicationWindow) { let profiles_popup = gtk::Window::builder() // TRANSLATORS: Popup Window Title diff --git a/src/utils.rs b/src/utils.rs index 50d3667..b81be77 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -376,6 +376,62 @@ pub fn detect_pkg_manager(image: &str) -> Option { } } +/// Who publishes an image, derived from its URL alone. Used to label the +/// image chooser rows so a long registry URL is recognisable at a glance. +/// Distinguishes the namespaces that matter to distrobox today from the +/// generic fallback of "the registry host", without trying to be a full +/// OCI registry database. +pub fn image_publisher(image: &str) -> String { + if !image.contains('/') { + return image.to_string(); + } + + if let Some(rest) = image.strip_prefix("docker.io/library/") { + // `docker.io/library/...` is Docker Hub's official-images namespace. + let _ = rest; + return "Docker Official".to_string(); + } + + if let Some(rest) = image.strip_prefix("quay.io/fedora/") { + let _ = rest; + return "Fedora Project".to_string(); + } + if image.starts_with("registry.fedoraproject.org/") { + return "Fedora Project".to_string(); + } + if image.starts_with("registry.access.redhat.com/") { + return "Red Hat".to_string(); + } + if image.starts_with("registry.opensuse.org/") { + return "openSUSE".to_string(); + } + if image.starts_with("container-registry.oracle.com/") { + return "Oracle".to_string(); + } + if image.starts_with("public.ecr.aws/") { + return "Amazon".to_string(); + } + if image.starts_with("cgr.dev/") { + return "Chainguard".to_string(); + } + if image.starts_with("invent-registry.kde.org/") { + return "KDE".to_string(); + } + + if let Some(rest) = image.strip_prefix("ghcr.io/") { + if let Some(org) = rest.split('/').next() { + return org.to_string(); + } + } + if let Some(rest) = image.strip_prefix("quay.io/") { + if let Some(org) = rest.split('/').next() { + return org.to_string(); + } + } + + image.split('/').next().unwrap_or(image).to_string() +} + /// Whether or not the `distrobox` command can be successfully run pub fn has_distrobox_installed() -> bool { let output = get_command_output("which", Some(&["distrobox"])); @@ -1089,6 +1145,106 @@ mod tests { } } +#[cfg(test)] +mod publisher_tests { + use super::image_publisher; + + #[test] + fn docker_official_namespace() { + assert_eq!( + image_publisher("docker.io/library/ubuntu:latest"), + "Docker Official" + ); + } + + #[test] + fn fedora_project_via_fedoraproject() { + assert_eq!( + image_publisher("registry.fedoraproject.org/fedora-toolbox:latest"), + "Fedora Project" + ); + } + + #[test] + fn fedora_project_via_quay() { + assert_eq!( + image_publisher("quay.io/fedora/fedora:43"), + "Fedora Project" + ); + } + + #[test] + fn red_hat_registry() { + assert_eq!( + image_publisher("registry.access.redhat.com/ubi9/ubi"), + "Red Hat" + ); + } + + #[test] + fn opensuse_registry() { + assert_eq!( + image_publisher("registry.opensuse.org/opensuse/tumbleweed:latest"), + "openSUSE" + ); + } + + #[test] + fn oracle_registry() { + assert_eq!( + image_publisher("container-registry.oracle.com/os/oraclelinux:9"), + "Oracle" + ); + } + + #[test] + fn amazon_ecr_public() { + assert_eq!( + image_publisher("public.ecr.aws/amazonlinux/amazonlinux:2023"), + "Amazon" + ); + } + + #[test] + fn chainguard_registry() { + assert_eq!( + image_publisher("cgr.dev/chainguard/wolfi-base"), + "Chainguard" + ); + } + + #[test] + fn kde_invent_registry() { + assert_eq!( + image_publisher("invent-registry.kde.org/kde/something:latest"), + "KDE" + ); + } + + #[test] + fn ghcr_io_returns_org() { + assert_eq!(image_publisher("ghcr.io/ublue-os/bluefin-cli"), "ublue-os"); + } + + #[test] + fn quay_io_returns_org_for_non_fedora() { + assert_eq!(image_publisher("quay.io/centos/centos:stream9"), "centos"); + } + + #[test] + fn unknown_registry_returns_host() { + assert_eq!( + image_publisher("example.com/some/thing:latest"), + "example.com" + ); + } + + #[test] + fn input_with_no_slash_returned_unchanged() { + assert_eq!(image_publisher("ubuntu"), "ubuntu"); + } +} + #[cfg(test)] mod profile_tests { use super::{profile_label_for_home_pure, valid_profile_name}; From 4e32171b36132085b41bb3153e62ef31bdcede6b Mon Sep 17 00:00:00 2001 From: Kacper Paczos Date: Wed, 26 Aug 2026 11:45:17 +0200 Subject: [PATCH 5/5] style: drop the needless borrows clippy flags in the new strings `make lint` runs clippy, and the strings added here tripped needless_borrows_for_generic_args - gettext takes anything that converts into a String, so the format! result can go in as it is. Only the lines this branch introduced are touched; the same pattern elsewhere in the file predates it and is left alone. --- src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index dac6ba7..13a8500 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2501,7 +2501,7 @@ fn show_image_chooser( } //TRANSLATORS: Image chooser count - first number is what is shown, second the total - count_label.set_text(&gettext(&format!("{} of {} images", shown, images.len()))); + count_label.set_text(&gettext(format!("{} of {} images", shown, images.len()))); }) };