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..0f0cfb9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,13 @@ 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, MessageDialogExt, PreferencesGroupExt, PreferencesRowExt, + }, ActionRow, Application, StyleManager, ToastOverlay, }; use gtk::{ @@ -27,13 +31,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, 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 +284,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 +318,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,40 +915,66 @@ fn create_new_distrobox(window: &ApplicationWindow) { // 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()); + } + //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())); + + 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(); + 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) { + profile_combo_set_home(&combo_for_handler, &chosen_home_combo, path); + } })); // hostname @@ -972,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(); @@ -982,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 @@ -1075,7 +1121,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 +2127,136 @@ 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()); +} + +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")); + } +}