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..13a8500 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,9 +1,14 @@
use gettextrs::gettext;
+use std::cell::RefCell;
use std::path::Path;
+use std::rc::Rc;
use std::thread;
use adw::{
- prelude::{ActionRowExt, MessageDialogExt, PreferencesGroupExt, PreferencesRowExt},
+ prelude::{
+ ActionRowExt, ComboRowExt, EntryRowExt, MessageDialogExt, PreferencesGroupExt,
+ PreferencesRowExt,
+ },
ActionRow, Application, StyleManager, ToastOverlay,
};
use gtk::{
@@ -17,23 +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_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,
+ 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,
+ 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";
@@ -279,11 +286,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 +320,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")),
);
@@ -426,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);
@@ -551,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);
@@ -883,52 +918,129 @@ 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
name_entry_row.set_title(&gettext("Name"));
- // custom home
- 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
- let home_entry_row = adw::EntryRow::new();
- home_entry_row.set_hexpand(true);
-
//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();
+ // 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());
+ }
+ // 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;
+ let profile_strlist = gtk::StringList::new(
+ &profile_names
+ .iter()
+ .map(|s| s.as_str())
+ .collect::>(),
+ );
- choose_home_btn.connect_clicked(clone!(@weak window => move |_btn| {
- let home_clone = home_entry_row.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);
- }
- }));
+ // 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"));
+ profile_combo.set_model(Some(&profile_strlist));
+ profile_combo.set_selected(0);
+
+ 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;
+ }
+ }
+ // 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 == 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;
+ }
}));
// hostname
@@ -942,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();
@@ -972,29 +1094,20 @@ 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();
+ 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();
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
- .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;
@@ -1075,7 +1188,7 @@ fn create_new_distrobox(window: &ApplicationWindow) {
boxed_list.append(&image_select_row);
boxed_list.append(&init_row);
- boxed_list.append(&home_select_row);
+ boxed_list.append(&profile_combo);
boxed_list.append(&hostname_entry_row);
main_box.append(&boxed_list);
@@ -2081,3 +2194,443 @@ 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);
+}
+
+/// 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());
+}
+
+/// 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();
+}
+
+/// 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
+ .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..b81be77 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 gettextrs::{bind_textdomain_codeset, gettext, 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;
@@ -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"]));
@@ -890,6 +946,126 @@ 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);
+}
+
+/// 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};
@@ -968,3 +1144,161 @@ 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};
+
+ #[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"));
+ }
+
+ #[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"
+ );
+ }
+}