Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions io.github.dvlv.boxbuddyrs.gschema.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,13 @@
The terminal which should be checked for first when performing an action which spawns a terminal window.
</description>
</key>
<key name="profiles" type="a{ss}">
<default>{}</default>
<summary>Named home directories for boxes</summary>
<description>
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.
</description>
</key>
</schema>
</schemalist>
257 changes: 218 additions & 39 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -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::{
Expand All @@ -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";

Expand Down Expand Up @@ -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,
]);
}

Expand All @@ -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")),
);
Expand Down Expand Up @@ -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::<Vec<&str>>(),
);

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<RefCell<String>> = 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
Expand Down Expand Up @@ -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();
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<RefCell<String>>, 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::<gtk::Window>().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();
}
Loading