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" + ); + } +}