diff --git a/src/distrobox_handler.rs b/src/distrobox_handler.rs index 22c6c82..5317b37 100644 --- a/src/distrobox_handler.rs +++ b/src/distrobox_handler.rs @@ -524,6 +524,37 @@ pub fn create_box_streaming( } } +/// Builds the body of a distrobox-assemble `.ini` file from the fields of the +/// generator form. Kept here, and pure, so both the live preview and the save +/// path render identical text from one place and it can be unit-tested. +/// `section` and `image` are assumed already validated as non-empty by the +/// caller. +pub fn build_assemble_ini( + section: &str, + image: &str, + packages: &str, + home: &str, + init: bool, + nvidia: bool, +) -> String { + let mut body = String::new(); + body.push_str(&format!("[{section}]\n")); + body.push_str(&format!("image={image}\n")); + if !packages.trim().is_empty() { + body.push_str(&format!("additional_packages=\"{packages}\"\n")); + } + if !home.trim().is_empty() { + body.push_str(&format!("home={home}\n")); + } + if init { + body.push_str("init=true\n"); + } + if nvidia { + body.push_str("nvidia=true\n"); + } + body +} + /// Runs `distrobox-assemble` with the provided file. pub fn assemble_box(ini_file: &str) -> String { let args = &["assemble", "create", "--file", ini_file]; @@ -937,3 +968,40 @@ mod stream_tests { assert!(exists, "streaming create did not produce a listable box"); } } + +#[cfg(test)] +mod assemble_tests { + use super::build_assemble_ini; + + #[test] + fn minimal_ini_has_only_section_and_image() { + assert_eq!( + build_assemble_ini( + "dev", + "docker.io/library/ubuntu:latest", + "", + "", + false, + false + ), + "[dev]\nimage=docker.io/library/ubuntu:latest\n" + ); + } + + #[test] + fn optional_fields_appear_only_when_set() { + let ini = build_assemble_ini("work", "img", "git, vim", "/home/me/work", true, true); + assert_eq!( + ini, + "[work]\nimage=img\nadditional_packages=\"git, vim\"\nhome=/home/me/work\ninit=true\nnvidia=true\n" + ); + } + + #[test] + fn blank_optionals_are_skipped() { + let ini = build_assemble_ini("d", "i", " ", " ", false, false); + assert!(!ini.contains("additional_packages")); + assert!(!ini.contains("home=")); + assert!(!ini.contains("init=")); + } +} diff --git a/src/main.rs b/src/main.rs index a20908a..2bf8d0b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,11 +17,12 @@ 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, build_assemble_ini, 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; @@ -279,11 +280,18 @@ fn set_window_actions(window: &ApplicationWindow) { }) .build(); + let action_create_assemble_ini = gio::ActionEntry::builder("create_assemble_ini") + .activate(|window: &ApplicationWindow, _, _| { + show_create_assemble_ini_dialog(window); + }) + .build(); + window.add_action_entries([ action_refresh, action_about, action_close, action_set_preferred_terminal, + action_create_assemble_ini, ]); } @@ -307,11 +315,20 @@ fn get_main_menu_model() -> gio::MenuModel { menu.insert_item( 2, //TRANSLATORS: Menu Item - &gio::MenuItem::new(Some(&gettext("About BoxBuddy")), Some("win.about")), + &gio::MenuItem::new( + //TRANSLATORS: Menu Item + Some(&gettext("Create Assemble INI…")), + Some("win.create_assemble_ini"), + ), ); menu.insert_item( 3, //TRANSLATORS: Menu Item + &gio::MenuItem::new(Some(&gettext("About BoxBuddy")), Some("win.about")), + ); + menu.insert_item( + 4, + //TRANSLATORS: Menu Item &gio::MenuItem::new(Some(&gettext("Quit")), Some("win.close")), ); @@ -623,6 +640,218 @@ fn make_box_tab(dbox: &DBox, window: &ApplicationWindow, tab_num: u32) -> gtk::B tab_box } +/// Show a small dialog that helps the user author a `distrobox.ini` file from +/// scratch. The file is written with a `.ini` extension into a user-chosen +/// directory; nothing about it is executed, so a user who only wants to +/// inspect a draft can do so without any risk of an unintended container +/// being built. +/// +/// We deliberately keep this dialog simple. The shape of `distrobox.ini` is +/// a flat INI with one section per box and one key per option, and the few +/// keys the user is most likely to want (`image`, `init`, `nvidia`, `home`, +/// `additional_packages`) cover the common cases. Anything beyond that has +/// to be edited by hand. +fn show_create_assemble_ini_dialog(window: &ApplicationWindow) { + let popup = gtk::Window::builder() + // TRANSLATORS: Popup Window Title + .title(gettext("Create Assemble INI")) + .transient_for(window) + .default_width(560) + .default_height(560) + .modal(true) + .build(); + + let titlebar = adw::HeaderBar::new(); + + let cancel_btn = gtk::Button::with_label(&gettext("Cancel")); + // TRANSLATORS: Button tooltip + cancel_btn.set_tooltip_text(Some(&gettext("Cancel"))); + let popup_clone = popup.clone(); + cancel_btn.connect_clicked(move |_btn| popup_clone.destroy()); + + let save_btn = gtk::Button::with_label(&gettext("Save")); + // TRANSLATORS: Button tooltip + save_btn.set_tooltip_text(Some(&gettext("Save INI file"))); + save_btn.add_css_class("suggested-action"); + + titlebar.pack_start(&cancel_btn); + titlebar.pack_end(&save_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 form = gtk::ListBox::new(); + form.set_selection_mode(gtk::SelectionMode::None); + form.add_css_class("boxed-list"); + + // TRANSLATORS: Entry Label - section name in the assemble .ini + let name_row = adw::EntryRow::new(); + name_row.set_title(&gettext("Section / Box name")); + name_row.set_text("my-box"); + + // TRANSLATORS: Entry Label - container image + let image_row = adw::EntryRow::new(); + image_row.set_title(&gettext("Image")); + image_row.set_text("ubuntu:24.04"); + + // TRANSLATORS: Entry Label - comma-separated extra packages + let packages_row = adw::EntryRow::new(); + packages_row.set_title(&gettext("Additional packages (comma-separated, optional)")); + + // TRANSLATORS: Entry Label - custom home directory + let home_row = adw::EntryRow::new(); + home_row.set_title(&gettext("Custom home directory (optional)")); + + let init_row = adw::SwitchRow::new(); + init_row.set_title(&gettext("Enable init system (systemd)")); + + let nvidia_row = adw::SwitchRow::new(); + nvidia_row.set_title(&gettext("Enable NVIDIA GPU support")); + + form.append(&name_row); + form.append(&image_row); + form.append(&packages_row); + form.append(&home_row); + form.append(&init_row); + form.append(&nvidia_row); + + let preview_label = gtk::Label::new(None); + preview_label.set_xalign(0.0); + preview_label.set_yalign(0.0); + preview_label.set_wrap(true); + preview_label.set_selectable(true); + preview_label.add_css_class("monospace"); + preview_label.add_css_class("dim-label"); + // TRANSLATORS: Preview heading for the .ini contents the user is composing + preview_label.set_markup(&gettext( + "Preview — fill in the form to see what will be saved.", + )); + + main_box.append(&form); + main_box.append(&preview_label); + + popup.set_child(Some(&main_box)); + popup.present(); + + // One closure re-renders the preview from the current field values and + // toggles Save. Every field change calls it, and it runs once up front so + // the preview is populated before the user touches anything. + let update_preview = { + let name_row = name_row.clone(); + let image_row = image_row.clone(); + let packages_row = packages_row.clone(); + let home_row = home_row.clone(); + let init_row = init_row.clone(); + let nvidia_row = nvidia_row.clone(); + let preview_label = preview_label.clone(); + let save_btn = save_btn.clone(); + std::rc::Rc::new(move || { + let section = name_row.text(); + let image = image_row.text(); + if section.trim().is_empty() || image.trim().is_empty() { + preview_label.set_markup(&gettext( + "Preview — section name and image are required.", + )); + save_btn.set_sensitive(false); + return; + } + + let body = build_assemble_ini( + §ion, + &image, + &packages_row.text(), + &home_row.text(), + init_row.is_active(), + nvidia_row.is_active(), + ); + preview_label.set_text(&body); + save_btn.set_sensitive(true); + }) + }; + + for row in [&name_row, &image_row, &packages_row, &home_row] { + let update = update_preview.clone(); + row.connect_changed(move |_row| update()); + } + for row in [&init_row, &nvidia_row] { + let update = update_preview.clone(); + row.connect_active_notify(move |_row| update()); + } + update_preview(); + + // Picking a destination and writing the file is wired up to the Save + // button. We use the same FileDialog the assemble flow already uses for + // `.ini` selection, with save mode and the `.ini` filter pre-applied. + let popup_for_save = popup.clone(); + save_btn.connect_clicked(move |_btn| { + let section = name_row.text().to_string(); + let image = image_row.text().to_string(); + let packages = packages_row.text().to_string(); + let home = home_row.text().to_string(); + let init = init_row.is_active(); + let nvidia = nvidia_row.is_active(); + + if section.trim().is_empty() || image.trim().is_empty() { + return; + } + + let body = build_assemble_ini(§ion, &image, &packages, &home, init, nvidia); + + // Default filename:
.ini in the user's Documents folder. + let default_dir = if let Ok(home) = std::env::var("HOME") { + std::path::PathBuf::from(home).join("Documents") + } else { + std::path::PathBuf::from(".") + }; + let default_path = default_dir.join(format!("{section}.ini")); + + let ini_filter = gtk::FileFilter::new(); + //TRANSLATORS: File type + ini_filter.set_name(Some(&gettext("INI-Files"))); + ini_filter.add_suffix("ini"); + + let file_dialog = FileDialog::builder() + .default_filter(&ini_filter) + .modal(true) + .build(); + file_dialog.set_initial_file(Some(&gio::File::for_path(default_path))); + + let body_clone = body.clone(); + let popup_clone2 = popup_for_save.clone(); + file_dialog.save( + Some(&popup_for_save), + None::<&gio::Cancellable>, + move |result| { + if let Ok(file) = result { + if let Some(path) = file.path() { + match std::fs::write(&path, &body_clone) { + Ok(()) => popup_clone2.destroy(), + Err(e) => { + // A failed save used to vanish without a + // trace; tell the user instead of pretending + // it worked. + let dialog = adw::MessageDialog::new( + Some(&popup_clone2), + //TRANSLATORS: Error dialog heading when the .ini file cannot be written + Some(&gettext("Could not save file")), + Some(&format!("{e}")), + ); + //TRANSLATORS: Dialog button + dialog.add_response("ok", &gettext("OK")); + dialog.present(); + } + } + } + } + }, + ); + }); +} + fn assemble_new_distrobox(window: &ApplicationWindow, ini_file: String) { let assemble_box_popup = gtk::Window::builder() // TRANSLATORS: Popup Window Title