diff --git a/io.github.dvlv.boxbuddyrs.gschema.xml b/io.github.dvlv.boxbuddyrs.gschema.xml
index 19e8785..414460d 100644
--- a/io.github.dvlv.boxbuddyrs.gschema.xml
+++ b/io.github.dvlv.boxbuddyrs.gschema.xml
@@ -8,5 +8,14 @@
The terminal which should be checked for first when performing an action which spawns a terminal window.
+
+
+ {}
+ Per-box menu label overrides for exported applications
+
+ Maps a box name to the label distrobox-export puts after an exported app's
+ name in the host menu. Empty means the distrobox default, "(on <box>)".
+
+
diff --git a/src/distrobox_handler.rs b/src/distrobox_handler.rs
index 22c6c82..e92b0b2 100644
--- a/src/distrobox_handler.rs
+++ b/src/distrobox_handler.rs
@@ -232,23 +232,45 @@ pub fn open_terminal_in_box(box_name: String) {
}
}
-/// Exports the desktop file from a box.
-pub fn export_app_from_box(app_name: &str, box_name: &str) -> String {
- get_command_output(
- "distrobox",
- Some(&[
- "enter",
- box_name,
- "--",
- "distrobox-export",
- "--app",
- app_name,
- ]),
- )
+/// The in-container path of an application's desktop file, built from the
+/// desktop-file id that `get_apps_in_box` records (the file's basename).
+/// distrobox reads a box's apps from `/usr/share/applications`.
+fn desktop_file_path(desktop_file: &str) -> String {
+ format!("/usr/share/applications/{desktop_file}.desktop")
}
-/// Unexports a desktop file from the host.
-pub fn remove_app_from_host(app_name: &str, box_name: &str) -> String {
+/// Exports an application's desktop file from a box to the host menu.
+///
+/// The app is identified by its desktop-file id, not its display name. `--app`
+/// matches against the desktop file, and a display name can be empty, repeated
+/// across apps, or match several files - which is how a single click could
+/// export more than the one app. Handing distrobox the exact file path exports
+/// precisely that app, and keeps export in step with how the host copy is
+/// detected (`{box}-{id}.desktop`) and removed.
+///
+/// `label` overrides the text distrobox puts after the app's name in the menu
+/// (`--export-label`); `None` leaves distrobox's own default, `(on )`.
+pub fn export_app_from_box(desktop_file: &str, box_name: &str, label: Option<&str>) -> String {
+ let app_path = desktop_file_path(desktop_file);
+ let mut args: Vec<&str> = vec![
+ "enter",
+ box_name,
+ "--",
+ "distrobox-export",
+ "--app",
+ app_path.as_str(),
+ ];
+ if let Some(label) = label {
+ args.push("--export-label");
+ args.push(label);
+ }
+ get_command_output("distrobox", Some(&args))
+}
+
+/// Unexports an application's desktop file from the host. Identified by the same
+/// desktop-file id used to export it, so removal always targets the right app.
+pub fn remove_app_from_host(desktop_file: &str, box_name: &str) -> String {
+ let app_path = desktop_file_path(desktop_file);
get_command_output(
"distrobox",
Some(&[
@@ -257,7 +279,7 @@ pub fn remove_app_from_host(app_name: &str, box_name: &str) -> String {
"--",
"distrobox-export",
"--app",
- app_name,
+ &app_path,
"--delete",
]),
)
@@ -937,3 +959,21 @@ mod stream_tests {
assert!(exists, "streaming create did not produce a listable box");
}
}
+
+#[cfg(test)]
+mod export_tests {
+ use super::desktop_file_path;
+
+ #[test]
+ fn builds_the_in_container_desktop_path_from_an_id() {
+ assert_eq!(
+ desktop_file_path("org.gnome.TextEditor"),
+ "/usr/share/applications/org.gnome.TextEditor.desktop"
+ );
+ // A plain, single-word id is handled the same way.
+ assert_eq!(
+ desktop_file_path("gimp"),
+ "/usr/share/applications/gimp.desktop"
+ );
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index a20908a..577ff60 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -3,7 +3,9 @@ use std::path::Path;
use std::thread;
use adw::{
- prelude::{ActionRowExt, MessageDialogExt, PreferencesGroupExt, PreferencesRowExt},
+ prelude::{
+ ActionRowExt, EntryRowExt, MessageDialogExt, PreferencesGroupExt, PreferencesRowExt,
+ },
ActionRow, Application, StyleManager, ToastOverlay,
};
use gtk::{
@@ -27,13 +29,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_exported_app_label,
+ 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_exported_app_label,
+ 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,
+ MENU_LABEL_ICON_NAMES, OPEN_FILE_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";
@@ -515,6 +518,26 @@ fn make_box_tab(dbox: &DBox, window: &ApplicationWindow, tab_num: u32) -> gtk::B
on_show_applications_clicked(&win_clone, show_bn_clone.clone());
});
+ // Menu-label row: sets the "(on …)" label used for this box's exported apps.
+ let menu_label_icon =
+ gtk::Image::from_icon_name(&get_available_icon_name(MENU_LABEL_ICON_NAMES));
+ let menu_label_row = ActionRow::new();
+ // TRANSLATORS: Row Label - opens a dialog to set the menu label for exported apps
+ menu_label_row.set_title(&gettext("Menu Label"));
+ menu_label_row.set_subtitle(&format!(
+ "{} \"{}\"",
+ // TRANSLATORS: Row subtitle prefix, followed by the current menu label
+ gettext("Exported apps show"),
+ menu_label_for_export(&box_name).unwrap_or_else(|| format!("(on {box_name})"))
+ ));
+ menu_label_row.add_suffix(&menu_label_icon);
+ menu_label_row.set_activatable(true);
+ let ml_bn_clone = box_name.clone();
+ let ml_win = window.clone();
+ menu_label_row.connect_activated(move |_row| {
+ show_menu_label_dialog(&ml_win, ml_bn_clone.clone(), tab_num);
+ });
+
// Install Deb Icon
let deb_bn_clone = box_name.clone();
let install_deb_icon =
@@ -555,6 +578,7 @@ fn make_box_tab(dbox: &DBox, window: &ApplicationWindow, tab_num: u32) -> gtk::B
boxed_list.append(&open_terminal_row);
boxed_list.append(&upgrade_row);
boxed_list.append(&show_applications_row);
+ boxed_list.append(&menu_label_row);
// Make deb / rpm row if applicable
let deb_distros = get_deb_distros();
@@ -1439,14 +1463,79 @@ fn on_show_applications_clicked(window: &ApplicationWindow, box_name: String) {
));
}
+fn show_menu_label_dialog(window: &ApplicationWindow, box_name: String, tab_num: u32) {
+ let dialog = adw::MessageDialog::new(
+ Some(window),
+ // TRANSLATORS: Title of the dialog that sets a box's exported-app menu label
+ Some(&gettext("Menu Label")),
+ // TRANSLATORS: Body of the menu-label dialog
+ Some(&gettext(
+ "Set the name shown in the menu after each exported app, as \"(on …)\". Leave empty to use the box name.",
+ )),
+ );
+
+ let entry = adw::EntryRow::new();
+ // TRANSLATORS: Entry field label in the menu-label dialog
+ entry.set_title(&gettext("Menu label"));
+ entry.set_activates_default(true);
+ if let Some(current) = get_exported_app_label(&box_name) {
+ entry.set_text(¤t);
+ }
+
+ let group = adw::PreferencesGroup::new();
+ group.add(&entry);
+ dialog.set_extra_child(Some(&group));
+
+ // TRANSLATORS: Button
+ dialog.add_response("cancel", &gettext("Cancel"));
+ // TRANSLATORS: Button
+ dialog.add_response("apply", &gettext("Apply"));
+ dialog.set_response_appearance("apply", adw::ResponseAppearance::Suggested);
+ dialog.set_default_response(Some("apply"));
+ dialog.set_close_response("cancel");
+
+ let window_clone = window.clone();
+ dialog.connect_response(Some("apply"), move |_dialog, _res| {
+ set_exported_app_label(&box_name, &entry.text());
+ // Bring the entries already in the menu up to date with the new label.
+ reexport_box_apps(&box_name);
+ delayed_rerender(&window_clone, Some(tab_num));
+ });
+
+ dialog.present();
+}
+
fn add_app_to_menu(app: &DBoxApp, box_name: &str, success_lbl: >k::Label) {
- let _ = export_app_from_box(&app.name, box_name);
+ // Export by the desktop-file id, not the display name, so exactly this one
+ // app is exported and it matches how the host copy is detected and removed.
+ let label = menu_label_for_export(box_name);
+ let _ = export_app_from_box(&app.desktop_file, box_name, label.as_deref());
//TRANSLATORS: Success Message
success_lbl.set_text(&gettext("App Exported!"));
}
+/// The `--export-label` to hand distrobox for a box, or `None` for its default.
+/// A custom alias is wrapped in the same `(on …)` shape distrobox uses, so a
+/// box with no alias set behaves exactly as before.
+fn menu_label_for_export(box_name: &str) -> Option {
+ get_exported_app_label(box_name).map(|alias| format!("(on {alias})"))
+}
+
+/// Re-applies the current menu label to every app already exported from a box,
+/// by unexporting and re-exporting each one. Used after the alias changes so the
+/// entries already in the menu pick up the new label too.
+fn reexport_box_apps(box_name: &str) {
+ let label = menu_label_for_export(box_name);
+ for app in get_apps_in_box(box_name) {
+ if app.is_on_host {
+ let _ = remove_app_from_host(&app.desktop_file, box_name);
+ let _ = export_app_from_box(&app.desktop_file, box_name, label.as_deref());
+ }
+ }
+}
+
fn remove_app_from_menu(app: &DBoxApp, box_name: &str, success_lbl: >k::Label) {
- let _ = remove_app_from_host(&app.name, box_name);
+ let _ = remove_app_from_host(&app.desktop_file, box_name);
//TRANSLATORS: Success Message
success_lbl.set_text(&gettext("App Removed!"));
}
diff --git a/src/utils.rs b/src/utils.rs
index 20f3743..d65b9e0 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;
@@ -139,6 +139,16 @@ pub const UPGRADE_ICON_NAMES: &[&str] = &[
];
pub const WARNING_ICON_NAMES: &[&str] = &["dialog-warning-symbolic", "dialog-warning"];
pub const INFO_ICON_NAMES: &[&str] = &["dialog-information-symbolic", "dialog-information"];
+// The Menu Label row edits a label, so an edit icon fits better than an
+// info one - and Breeze has no dialog-information-symbolic, which made the
+// row fall back to a full-colour icon in an otherwise monochrome list.
+// document-edit-symbolic exists in both Adwaita and Breeze.
+pub const MENU_LABEL_ICON_NAMES: &[&str] = &[
+ "document-edit-symbolic",
+ "tag-symbolic",
+ "dialog-information-symbolic",
+ "dialog-information",
+];
pub const APPLICATIONS_ICON_NAMES: &[&str] = &[
"application-x-executable-symbolic",
"application-x-executable",
@@ -890,6 +900,31 @@ pub fn get_download_dir_path() -> String {
})
}
+/// The custom menu-label alias the user set for a box, or `None` for the
+/// distrobox default. Stored per box in GSettings, keyed by box name.
+pub fn get_exported_app_label(box_name: &str) -> Option {
+ let settings = Settings::new(APP_ID);
+ let labels: HashMap = settings.get("exported-app-labels");
+ labels
+ .get(box_name)
+ .map(|s| s.trim().to_string())
+ .filter(|s| !s.is_empty())
+}
+
+/// Sets (or, for an empty value, clears) the custom menu-label alias for a box.
+/// Clearing it means exports fall back to distrobox's own "(on )" label.
+pub fn set_exported_app_label(box_name: &str, label: &str) {
+ let settings = Settings::new(APP_ID);
+ let mut labels: HashMap = settings.get("exported-app-labels");
+ let trimmed = label.trim();
+ if trimmed.is_empty() {
+ labels.remove(box_name);
+ } else {
+ labels.insert(box_name.to_string(), trimmed.to_string());
+ }
+ let _ = settings.set("exported-app-labels", &labels);
+}
+
#[cfg(test)]
mod tests {
use super::{detect_pkg_manager, PkgManager};