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
9 changes: 9 additions & 0 deletions io.github.dvlv.boxbuddyrs.gschema.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,14 @@
The terminal which should be checked for first when performing an action which spawns a terminal window.
</description>
</key>

<key name="exported-app-labels" type="a{ss}">
<default>{}</default>
<summary>Per-box menu label overrides for exported applications</summary>
<description>
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 &lt;box&gt;)".
</description>
</key>
</schema>
</schemalist>
72 changes: 56 additions & 16 deletions src/distrobox_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <box>)`.
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(&[
Expand All @@ -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",
]),
)
Expand Down Expand Up @@ -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"
);
}
}
109 changes: 99 additions & 10 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand All @@ -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";

Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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(&current);
}

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: &gtk::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<String> {
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: &gtk::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!"));
}
Expand Down
37 changes: 36 additions & 1 deletion src/utils.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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<String> {
let settings = Settings::new(APP_ID);
let labels: HashMap<String, String> = 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 <box>)" label.
pub fn set_exported_app_label(box_name: &str, label: &str) {
let settings = Settings::new(APP_ID);
let mut labels: HashMap<String, String> = 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};
Expand Down