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