diff --git a/Cargo.lock b/Cargo.lock index 880aa4f..6de0587 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -249,6 +249,8 @@ dependencies = [ "gtk4", "libadwaita", "regex", + "serde", + "serde_json", "smol", "thiserror", "tracing", @@ -706,6 +708,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + [[package]] name = "lazy_static" version = "1.5.0" @@ -1025,6 +1033,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + [[package]] name = "semver" version = "1.0.25" @@ -1051,6 +1065,18 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + [[package]] name = "serde_spanned" version = "0.6.8" diff --git a/Cargo.toml b/Cargo.toml index 4f293e2..f1d3a9b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,8 @@ anyhow = "1.0.92" regex = "1.11.1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0.140" [dependencies.adw] package = "libadwaita" diff --git a/data/com.ranfdev.DistroShelf.metainfo.xml.in b/data/com.ranfdev.DistroShelf.metainfo.xml.in index da425da..a926c95 100644 --- a/data/com.ranfdev.DistroShelf.metainfo.xml.in +++ b/data/com.ranfdev.DistroShelf.metainfo.xml.in @@ -71,6 +71,14 @@ + + +
    +
  • Added support for custom terminal commands
  • +
  • Refactored code to improve support of flatpak and non-flatpak versions
  • +
+
+
    diff --git a/meson.build b/meson.build index 43a1484..489aa62 100644 --- a/meson.build +++ b/meson.build @@ -1,5 +1,5 @@ project('distroshelf', 'rust', - version: '1.0.6', + version: '1.0.7', meson_version: '>= 1.0.0', default_options: [ 'warning_level=2', 'werror=false', ], ) diff --git a/src/application.rs b/src/application.rs index 0b87a4b..23957ab 100644 --- a/src/application.rs +++ b/src/application.rs @@ -18,13 +18,19 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ +use std::path::Path; +use std::rc::Rc; + use adw::prelude::*; use adw::subclass::prelude::*; use gettextrs::gettext; use gtk::{gio, glib}; use crate::config::VERSION; -use crate::distrobox::{Distrobox, DistroboxCommandRunnerResponse}; +use crate::distrobox::{ + CommandRunner, Distrobox, DistroboxCommandRunnerResponse, FlatpakCommandRunner, + RealCommandRunner, +}; use crate::root_store::RootStore; use crate::DistroShelfWindow; @@ -191,33 +197,41 @@ impl DistroShelfApplication { .build() } + fn get_is_in_flatpak() -> bool { + let fp_env = std::env::var("FLATPAK_ID").is_ok(); + if fp_env { + return true; + } + + Path::new("/.flatpak-info").exists() + } + fn recreate_window(&self) -> adw::ApplicationWindow { - let distrobox = match { self.imp().distrobox_store_ty.borrow().to_owned() } { - DistroboxStoreTy::NullWorking => Distrobox::new_null_with_responses( - &[ - DistroboxCommandRunnerResponse::Version, - DistroboxCommandRunnerResponse::new_list_common_distros(), - DistroboxCommandRunnerResponse::new_common_images(), - DistroboxCommandRunnerResponse::new_common_exported_apps(), - ], - false, - ), - DistroboxStoreTy::NullEmpty => Distrobox::new_null_with_responses( - &[ - DistroboxCommandRunnerResponse::Version, - DistroboxCommandRunnerResponse::List(vec![]), - DistroboxCommandRunnerResponse::new_common_images(), - ], - false, - ), - DistroboxStoreTy::NullNoVersion => Distrobox::new_null_with_responses( - &[DistroboxCommandRunnerResponse::NoVersion], - false, - ), - _ => Distrobox::new(), + let command_runner = match { self.imp().distrobox_store_ty.borrow().to_owned() } { + DistroboxStoreTy::NullWorking => Distrobox::null_command_runner(&[ + DistroboxCommandRunnerResponse::Version, + DistroboxCommandRunnerResponse::new_list_common_distros(), + DistroboxCommandRunnerResponse::new_common_images(), + DistroboxCommandRunnerResponse::new_common_exported_apps(), + ]), + DistroboxStoreTy::NullEmpty => Distrobox::null_command_runner(&[ + DistroboxCommandRunnerResponse::Version, + DistroboxCommandRunnerResponse::List(vec![]), + DistroboxCommandRunnerResponse::new_common_images(), + ]), + DistroboxStoreTy::NullNoVersion => { + Distrobox::null_command_runner(&[DistroboxCommandRunnerResponse::NoVersion]) + } + _ => { + if Self::get_is_in_flatpak() { + Rc::new(FlatpakCommandRunner::new(Rc::new(RealCommandRunner {}))) as Rc + } else { + Rc::new(RealCommandRunner {}) as Rc + } + } }; - self.set_root_store(RootStore::new(distrobox)); + self.set_root_store(RootStore::new(command_runner)); let window = DistroShelfWindow::new(self.upcast_ref::(), self.root_store()); window.upcast() diff --git a/src/config.rs b/src/config.rs index 2f991e0..94861a5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,4 @@ -pub static VERSION: &str = "1.0.6"; +pub static VERSION: &str = "1.0.7"; pub static GETTEXT_PACKAGE: &str = "distroshelf"; pub static LOCALEDIR: &str = "/app/share/locale"; pub static PKGDATADIR: &str = "/app/share/distroshelf"; diff --git a/src/distrobox/command_runner.rs b/src/distrobox/command_runner.rs index 534562b..ab781a9 100644 --- a/src/distrobox/command_runner.rs +++ b/src/distrobox/command_runner.rs @@ -18,6 +18,8 @@ use futures::{ FutureExt, }; +use super::wrap_flatpak_cmd; + pub trait CommandRunner { fn spawn(&self, command: Command) -> io::Result>; fn output( @@ -43,6 +45,29 @@ impl CommandRunner for RealCommandRunner { } } +#[derive(Clone)] +pub struct FlatpakCommandRunner { + pub command_runner: Rc, +} +impl FlatpakCommandRunner { + pub fn new(command_runner: Rc) -> Self { + FlatpakCommandRunner { command_runner } + } +} + +impl CommandRunner for FlatpakCommandRunner { + fn spawn(&self, command: Command) -> io::Result> { + self.command_runner.spawn(wrap_flatpak_cmd(command)) + } + fn output( + &self, + command: Command, + ) -> Pin>>> { + self.command_runner.output(wrap_flatpak_cmd(command)) + } +} + + #[derive(Default, Clone)] pub struct NullCommandRunnerBuilder { responses: HashMap, Rc Result>>, diff --git a/src/distrobox/mod.rs b/src/distrobox/mod.rs index 896354d..57f512f 100644 --- a/src/distrobox/mod.rs +++ b/src/distrobox/mod.rs @@ -38,15 +38,13 @@ impl OutputTracker { } pub struct Distrobox { - cmd_runner: Box, + cmd_runner: Rc, output_tracker: OutputTracker, - is_in_flatpak: bool, } impl std::fmt::Debug for Distrobox { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Distrobox") - .field("is_in_flatpak", &self.is_in_flatpak) .field("output_tracker", &self.output_tracker) .finish() } @@ -412,25 +410,22 @@ impl DistroboxCommandRunnerResponse { } impl Distrobox { - pub fn new() -> Self { + pub fn new(cmd_runner:Rc ) -> Self { Self { - cmd_runner: Box::new(RealCommandRunner {}), - is_in_flatpak: Self::get_is_in_flatpak(), + cmd_runner, output_tracker: Default::default(), } } - pub fn new_null(runner: NullCommandRunner, is_in_flatpak: bool) -> Self { + pub fn new_null(runner: NullCommandRunner) -> Self { Self { - cmd_runner: Box::new(runner), + cmd_runner: Rc::new(runner), output_tracker: OutputTracker::default(), - is_in_flatpak, } } - pub fn new_null_with_responses( + pub fn null_command_runner( responses: &[DistroboxCommandRunnerResponse], - is_in_flatpak: bool, - ) -> Self { + ) -> Rc { let cmd_runner = { let mut builder = NullCommandRunnerBuilder::new(); for res in responses { @@ -440,11 +435,7 @@ impl Distrobox { } builder.build() }; - Self { - cmd_runner: Box::new(cmd_runner), - output_tracker: OutputTracker::default(), - is_in_flatpak, - } + Rc::new(cmd_runner) as Rc } pub fn output_tracker(&self) -> OutputTracker { @@ -452,21 +443,9 @@ impl Distrobox { self.output_tracker.clone() } - fn get_is_in_flatpak() -> bool { - let fp_env = std::env::var("FLATPAK_ID").is_ok(); - if fp_env { - return true; - } - - Path::new("/.flatpak-info").exists() - } + - pub fn cmd_spawn(&self, cmd: Command) -> Result, Error> { - let mut cmd = if self.is_in_flatpak { - wrap_flatpak_cmd(cmd) - } else { - cmd - }; + pub fn cmd_spawn(&self, mut cmd: Command) -> Result, Error> { wrap_capture_cmd(&mut cmd); let program = cmd.program.to_string_lossy().to_string(); @@ -491,12 +470,7 @@ impl Distrobox { Ok(child) } - async fn cmd_output(&self, cmd: Command) -> Result { - let mut cmd = if self.is_in_flatpak { - wrap_flatpak_cmd(cmd) - } else { - cmd - }; + async fn cmd_output(&self, mut cmd: Command) -> Result { wrap_capture_cmd(&mut cmd); let program = cmd.program.to_string_lossy().to_string(); @@ -838,7 +812,7 @@ impl Distrobox { impl Default for Distrobox { fn default() -> Self { - Self::new() + Self::new(Rc::new(NullCommandRunner::default())) } } @@ -856,7 +830,6 @@ d24405b14180 | ubuntu | Created | ghcr.io/ublue-os/ubun NullCommandRunnerBuilder::new() .cmd(&["distrobox", "ls", "--no-color"], output) .build(), - false, ); assert_eq!( db.list().await?, @@ -879,7 +852,6 @@ d24405b14180 | ubuntu | Created | ghcr.io/ublue-os/ubun NullCommandRunnerBuilder::new() .cmd(&["distrobox", "version"], output) .build(), - false, ); assert_eq!(db.version().await?, "1.7.2.1".to_string(),); Ok(()) @@ -935,7 +907,6 @@ Comment=A brief description of my application Categories=Utility;Network; ",) .build(), - false ); let apps = block_on(db.list_apps("ubuntu"))?; @@ -950,7 +921,7 @@ Categories=Utility;Network; #[test] fn create() -> Result<(), Error> { let _ = tracing_subscriber::fmt().with_test_writer().try_init(); - let db = Distrobox::new_null(NullCommandRunner::default(), false); + let db = Distrobox::new_null(NullCommandRunner::default()); let output_tracker = db.output_tracker(); debug!("Testing container creation"); let args = CreateArgs { @@ -969,7 +940,7 @@ Categories=Utility;Network; } #[test] fn assemble() -> Result<(), Error> { - let db = Distrobox::new_null(NullCommandRunner::default(), false); + let db = Distrobox::new_null(NullCommandRunner::default()); let output_tracker = db.output_tracker(); db.assemble("/path/to/assemble.yml")?; assert_eq!( @@ -981,7 +952,7 @@ Categories=Utility;Network; #[test] fn remove() -> Result<(), Error> { - let db = Distrobox::new_null(NullCommandRunner::default(), false); + let db = Distrobox::new_null(NullCommandRunner::default()); let output_tracker = db.output_tracker(); block_on(db.remove("ubuntu"))?; assert_eq!( diff --git a/src/store/root_store.rs b/src/store/root_store.rs index 4638433..044c5f6 100644 --- a/src/store/root_store.rs +++ b/src/store/root_store.rs @@ -7,28 +7,29 @@ use glib::Properties; use gtk::prelude::*; use gtk::{gio, glib}; use std::cell::RefCell; +use std::default; +use std::rc::Rc; use std::time::Duration; use tracing::debug; use tracing::error; use tracing::info; use crate::container::Container; -use crate::distrobox::wrap_flatpak_cmd; use crate::distrobox::Command; use crate::distrobox::CreateArgs; use crate::distrobox::Distrobox; use crate::distrobox::Status; +use crate::distrobox::{wrap_flatpak_cmd, CommandRunner}; use crate::distrobox_task::DistroboxTask; use crate::gtk_utils::reconcile_list_by_key; use crate::remote_resource::RemoteResource; -use crate::supported_terminals::SupportedTerminal; -use crate::supported_terminals::SUPPORTED_TERMINALS; +use crate::supported_terminals::{Terminal, TerminalRepository}; use crate::tagged_object::TaggedObject; mod imp { - use std::cell::OnceCell; + use std::{cell::OnceCell, rc::Rc}; - use crate::remote_resource::RemoteResource; + use crate::{distrobox::NullCommandRunner, remote_resource::RemoteResource}; use super::*; @@ -36,6 +37,9 @@ mod imp { #[properties(wrapper_type = super::RootStore)] pub struct RootStore { pub distrobox: OnceCell, + pub terminal_repository: RefCell, + pub command_runner: OnceCell>, + #[property(get, set)] pub distrobox_version: RefCell, @@ -65,6 +69,8 @@ mod imp { fn default() -> Self { Self { containers: gio::ListStore::new::(), + command_runner: OnceCell::new(), + terminal_repository: RefCell::new(TerminalRepository::new(Rc::new(NullCommandRunner::default()))), selected_container: Default::default(), current_view: Default::default(), current_dialog: Default::default(), @@ -92,12 +98,22 @@ glib::wrapper! { pub struct RootStore(ObjectSubclass); } impl RootStore { - pub fn new(distrobox: Distrobox) -> Self { + pub fn new(command_runner: Rc) -> Self { let this: Self = glib::Object::builder().build(); + this.imp() + .command_runner + .set(command_runner.clone()) + .or(Err("command_runner already set")) + .unwrap(); + + this.imp() + .terminal_repository + .replace(TerminalRepository::new(command_runner.clone())); + this.imp() .distrobox - .set(distrobox) + .set(Distrobox::new(command_runner.clone())) .or(Err("distrobox already set")) .unwrap(); @@ -129,6 +145,17 @@ impl RootStore { } })); + if this.selected_terminal().is_none() { + let this = this.clone(); + glib::MainContext::ref_thread_default().spawn_local(async move { + let Some(default_terminal) = this.terminal_repository().default_terminal().await + else { + return; + }; + this.set_selected_terminal_name(&default_terminal.name); + }); + } + this.load_containers(); this } @@ -137,6 +164,14 @@ impl RootStore { self.imp().distrobox.get().unwrap() } + pub fn command_runner(&self) -> Rc { + self.imp().command_runner.get().unwrap().clone() + } + + pub fn terminal_repository(&self) -> TerminalRepository { + self.imp().terminal_repository.borrow().clone() + } + pub fn load_containers(&self) { let this = self.clone(); glib::MainContext::ref_thread_default().spawn_local_with_priority( @@ -242,35 +277,47 @@ impl RootStore { .arg(supported_terminal.separator_arg) .arg(cmd.program.clone()) .args(cmd.args.clone()); - spawn_cmd = wrap_flatpak_cmd(spawn_cmd); debug!(?spawn_cmd, "Spawning terminal command"); - let mut async_cmd: async_process::Command = spawn_cmd.into(); - let mut child = async_cmd.spawn()?; + let mut child = self.command_runner().spawn(spawn_cmd)?; + let this = self.clone(); glib::MainContext::ref_thread_default().spawn_local(async move { this.reload_till_up(name, 5); }); - if !child.status().await?.success() { + if !child.wait().await?.success() { return Err(anyhow::anyhow!("Failed to spawn terminal")); } Ok(()) } - pub fn selected_terminal(&self) -> Option { - let program: String = self.settings().string("selected-terminal").into(); - SUPPORTED_TERMINALS - .iter() - .find(|x| x.program == program) - .cloned() - } - pub fn set_selected_terminal_program(&self, program: &str) { - if !SUPPORTED_TERMINALS.iter().any(|x| x.program == program) { - panic!("Unsupported terminal"); + pub fn selected_terminal(&self) -> Option { + // Old version stored the program, such as "gnome-terminal", now we store the name "GNOME console". + let name_or_program: String = self.settings().string("selected-terminal").into(); + + let by_name = self + .imp() + .terminal_repository + .borrow() + .terminal_by_name(&name_or_program); + + if let Some(terminal) = by_name { + Some(terminal) + } else if let Some(terminal) = self + .imp() + .terminal_repository + .borrow() + .terminal_by_program(&name_or_program) + { + Some(terminal) + } else { + error!("Terminal not found: {}", name_or_program); + None } - + } + pub fn set_selected_terminal_name(&self, name: &str) { self.imp() .settings - .set_string("selected-terminal", program) + .set_string("selected-terminal", name) .expect("Failed to save setting"); } diff --git a/src/supported_terminals.rs b/src/supported_terminals.rs index 276c63b..0dfda12 100644 --- a/src/supported_terminals.rs +++ b/src/supported_terminals.rs @@ -1,13 +1,26 @@ -use std::sync::LazyLock; +use std::{ + cell::RefCell, + path::{Path, PathBuf}, + rc::Rc, + sync::LazyLock, +}; -#[derive(Clone, Debug)] -pub struct SupportedTerminal { +use gtk::glib; +use tracing::{error, info}; + +use crate::distrobox::{wrap_capture_cmd, Command, CommandRunner, NullCommandRunner}; + +use gtk::subclass::prelude::*; + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct Terminal { pub name: String, pub program: String, pub separator_arg: String, + pub read_only: bool, } -pub static SUPPORTED_TERMINALS: LazyLock> = LazyLock::new(|| { +static SUPPORTED_TERMINALS: LazyLock> = LazyLock::new(|| { [ ("GNOME Console", "kgx", "--"), ("GNOME Terminal", "gnome-terminal", "--"), @@ -26,14 +39,190 @@ pub static SUPPORTED_TERMINALS: LazyLock> = LazyLock::new ("Terminator", "terminator", "-x"), ] .iter() - .map(|(name, program, separator_arg)| SupportedTerminal { + .map(|(name, program, separator_arg)| Terminal { name: name.to_string(), program: program.to_string(), separator_arg: separator_arg.to_string(), + read_only: true, }) .collect() }); -pub fn terminal_by_name(name: &str) -> Option { - SUPPORTED_TERMINALS.iter().find(|x| x.name == name).cloned() +mod imp { + use super::*; + use std::{ + cell::{OnceCell, RefCell}, + rc::Rc, + }; + + pub struct TerminalRepository { + pub list: RefCell>, + pub custom_list_path: PathBuf, + pub command_runner: OnceCell>, + } + + impl Default for TerminalRepository { + fn default() -> Self { + let custom_list_path = glib::user_data_dir().join("distroshelf-terminals.json"); + Self { + list: RefCell::new(vec![]), + custom_list_path, + command_runner: OnceCell::new(), + } + } + } + impl ObjectImpl for TerminalRepository {} + + #[glib::object_subclass] + impl ObjectSubclass for TerminalRepository { + const NAME: &'static str = "TerminalRepository"; + type Type = super::TerminalRepository; + } +} + +glib::wrapper! { + pub struct TerminalRepository(ObjectSubclass); +} + +impl TerminalRepository { + pub fn new(command_runner: Rc) -> Self { + let this: Self = glib::Object::builder().build(); + this.imp() + .command_runner + .set(command_runner) + .map_err(|e| "command runner already set") + .unwrap(); + + let mut list = SUPPORTED_TERMINALS.clone(); + if let Ok(loaded_list) = Self::load_terminals_from_json(&this.imp().custom_list_path) { + list.extend(loaded_list); + } else { + error!( + "Failed to load custom terminals from JSON file {:?}", + &this.imp().custom_list_path + ); + } + + list.sort_by(|a, b| a.name.cmp(&b.name)); + this.imp().list.replace(list); + this + } + + pub fn is_read_only(&self, name: &str) -> bool { + self.imp() + .list + .borrow() + .iter() + .find(|x| x.name == name) + .map_or(false, |x| x.read_only) + } + + pub fn save_terminal(&self, terminal: Terminal) -> anyhow::Result<()> { + if self.is_read_only(terminal.name.as_str()) { + return Err(anyhow::anyhow!("Cannot modify read-only terminal")); + } + { + let mut list = self.imp().list.borrow_mut(); + list.retain(|x| x.name != terminal.name); + list.push(terminal); + + list.sort_by(|a, b| a.name.cmp(&b.name)); + } + + self.save_terminals_to_json(); + Ok(()) + } + + pub fn delete_terminal(&self, name: &str) -> anyhow::Result<()> { + if self.is_read_only(name) { + return Err(anyhow::anyhow!("Cannot modify read-only terminal")); + } + { + self.imp().list.borrow_mut().retain(|x| x.name != name); + } + self.save_terminals_to_json(); + Ok(()) + } + + pub fn terminal_by_name(&self, name: &str) -> Option { + self.imp() + .list + .borrow() + .iter() + .find(|x| x.name == name) + .cloned() + } + + pub fn terminal_by_program(&self, program: &str) -> Option { + self.imp() + .list + .borrow() + .iter() + .find(|x| x.program == program) + .cloned() + } + + pub fn all_terminals(&self) -> Vec { + self.imp().list.borrow().clone() + } + + fn save_terminals_to_json(&self) { + let list: Vec = self + .imp() + .list + .borrow() + .iter() + .filter(|x| !x.read_only) + .cloned() + .collect::>(); + let json = serde_json::to_string(&*list).unwrap(); + std::fs::write(&self.imp().custom_list_path, json).unwrap(); + } + + fn load_terminals_from_json(path: &Path) -> anyhow::Result> { + let data = std::fs::read_to_string(path)?; + let list: Vec = serde_json::from_str(&data)?; + Ok(list) + } + + pub async fn default_terminal(&self) -> Option { + let mut command = Command::new_with_args( + "gsettings", + &[ + "get", + "org.gnome.desktop.default-applications.terminal", + "exec", + ], + ); + wrap_capture_cmd(&mut command); + let output = self + .imp() + .command_runner + .get() + .unwrap() + .output(command.clone()); + let Ok(output) = output.await else { + error!("Failed to get default terminal, running {:?}", &command); + return None; + }; + let terminal_program = String::from_utf8(output.stdout).unwrap().trim().to_string(); + let terminal_program = terminal_program.trim_matches('\''); + if terminal_program.is_empty() { + return None; + } + info!("Default terminal program: {}", terminal_program); + self.terminal_by_program(&terminal_program).or_else(|| { + error!( + "Terminal program {} not found in the list", + terminal_program + ); + None + }) + } +} + +impl Default for TerminalRepository { + fn default() -> Self { + Self::new(Rc::new(NullCommandRunner::default())) + } } diff --git a/src/terminal_combo_row.rs b/src/terminal_combo_row.rs index 046f183..9448b1a 100644 --- a/src/terminal_combo_row.rs +++ b/src/terminal_combo_row.rs @@ -2,17 +2,19 @@ // This file is licensed under the same terms as the project it belongs to use crate::root_store::RootStore; -use crate::{supported_terminals, supported_terminals::SUPPORTED_TERMINALS}; +use crate::supported_terminals; use adw::prelude::*; use adw::subclass::prelude::*; use glib::clone; use glib::subclass::Signal; use glib::Properties; -use gtk::glib; +use gtk::{glib, StringObject}; use std::cell::RefCell; use std::sync::OnceLock; mod imp { + use gtk::StringObject; + use super::*; #[derive(Properties, Default)] @@ -20,6 +22,7 @@ mod imp { pub struct TerminalComboRow { #[property(get, set)] root_store: RefCell, + pub selected_item_signal_handler: RefCell>, } #[glib::derived_properties] @@ -31,40 +34,29 @@ mod imp { obj.set_title("Preferred Terminal"); obj.set_use_subtitle(true); - let mut terminals = SUPPORTED_TERMINALS - .iter() - .map(|x| x.name.as_ref()) - .collect::>(); - // case-insensitive sort - terminals.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase())); - let selected_position = terminals.iter().position(|x| { - Some(x) - == obj - .root_store() - .selected_terminal() - .as_ref() - .map(|x| x.name.as_str()) - .as_ref() - }); - - let terminal_list = gtk::StringList::new(&terminals); - obj.set_model(Some(&terminal_list)); - if let Some(selected_position) = selected_position { - obj.set_selected(selected_position as u32); - } - obj.connect_selected_item_notify(clone!( + let signal_handler = obj.connect_selected_item_notify(clone!( #[weak] obj, move |combo| { - let selected: gtk::StringObject = combo.selected_item().and_downcast().unwrap(); - if let Some(terminal) = - supported_terminals::terminal_by_name(&selected.string()) + let Some(selected) = combo.selected_item().and_downcast::() + else { + return; + }; + + if let Some(terminal) = obj + .root_store() + .terminal_repository() + .terminal_by_name(&selected.string()) { - obj.root_store() - .set_selected_terminal_program(&terminal.program) + obj.root_store().set_selected_terminal_name(&terminal.name); } } )); + + self.selected_item_signal_handler + .replace(Some(signal_handler)); + + obj.reload_terminals(); } fn signals() -> &'static [Signal] { @@ -132,6 +124,45 @@ impl TerminalComboRow { .property("root-store", root_store) .build() } + + pub fn set_selected_by_name(&self, name: &str) { + let Some(terminals_strings) = self.model().unwrap().downcast::().ok() + else { + return; + }; + for i in 0..terminals_strings.n_items() { + let Some(item) = terminals_strings.item(i).and_downcast::() else { + continue; + }; + if item.string() == name { + self.set_selected(i); + return; + } + } + } + pub fn reload_terminals(&self) { + let terminals = self + .root_store() + .clone() + .terminal_repository() + .all_terminals(); + let terminals = terminals + .iter() + .map(|x| x.name.as_ref()) + .collect::>(); + + let terminal_list = gtk::StringList::new(&terminals); + + let signal_handler = self.imp().selected_item_signal_handler.borrow(); + self.block_signal(&signal_handler.as_ref().unwrap()); + { + self.set_model(Some(&terminal_list)); + if let Some(selected_terminal) = self.root_store().selected_terminal() { + self.set_selected_by_name(&selected_terminal.name); + } + } + self.unblock_signal(&signal_handler.as_ref().unwrap()); + } } impl Default for TerminalComboRow { diff --git a/src/window.rs b/src/window.rs index 6b1c115..d4dd7c2 100644 --- a/src/window.rs +++ b/src/window.rs @@ -25,6 +25,7 @@ use crate::gtk_utils::reaction; use crate::known_distros::PackageManager; use crate::root_store::RootStore; use crate::sidebar_row::SidebarRow; +use crate::supported_terminals; use crate::tagged_object::TaggedObject; use crate::task_manager_dialog::TaskManagerDialog; use crate::tasks_button::TasksButton; @@ -32,11 +33,11 @@ use crate::terminal_combo_row::TerminalComboRow; use adw::prelude::*; use adw::subclass::prelude::*; use glib::{derived_properties, Properties}; -use gtk::gdk; use gtk::glib::clone; +use gtk::{gdk, DeleteType}; use gtk::{gio, glib, pango}; use std::cell::RefCell; -use tracing::info; +use tracing::{error, info}; mod imp { use super::*; @@ -324,12 +325,13 @@ impl DistroShelfWindow { #[weak(rename_to = this)] self, move |_| { - let task = this.root_store() + let task = this + .root_store() .selected_container() .unwrap() .spawn_terminal(); task.connect_status_notify(move |task| { - if let Some(_) = &*task.error() { + if let Some(_) = &*task.error() { let toast = adw::Toast::new("Check your terminal settings."); toast.set_button_label(Some("Preferences")); toast.connect_button_clicked(clone!( @@ -342,7 +344,7 @@ impl DistroShelfWindow { )); this.add_toast(toast); } - }); + }); } )); status_child.append(&terminal_btn); @@ -584,9 +586,280 @@ impl DistroShelfWindow { let page = adw::PreferencesPage::new(); + // Terminal Settings Group let terminal_group = adw::PreferencesGroup::new(); terminal_group.set_title("Terminal Settings"); - terminal_group.add(&TerminalComboRow::new_with_params(self.root_store())); + let terminal_combo_row = TerminalComboRow::new_with_params(self.root_store()); + + let delete_btn = gtk::Button::with_label("Delete"); + delete_btn.add_css_class("destructive-action"); + delete_btn.add_css_class("pill"); + + if let Some(selected) = terminal_combo_row.selected_item() { + let selected_name = selected + .downcast_ref::() + .unwrap() + .string(); + let is_read_only = self + .root_store() + .terminal_repository() + .is_read_only(&selected_name); + + delete_btn.set_sensitive(!is_read_only); + } + + delete_btn.connect_clicked(clone!( + #[weak] + terminal_combo_row, + #[weak(rename_to = this)] + self, + #[weak] + delete_btn, + move |_| { + let selected = terminal_combo_row + .selected_item() + .and_downcast_ref::() + .unwrap() + .string(); + let dialog = adw::AlertDialog::builder() + .heading("Delete this terminal?") + .body(format!( + "{} will be removed from the terminal list.\nThis action cannot be undone.", + selected + )) + .close_response("cancel") + .default_response("cancel") + .build(); + dialog.add_response("cancel", "Cancel"); + dialog.add_response("delete", "Delete"); + + dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive); + dialog.connect_response( + Some("delete"), + clone!( + #[weak] + terminal_combo_row, + #[weak] + this, + #[strong] + selected, + move |d, _| { + match this + .root_store() + .terminal_repository() + .delete_terminal(&selected) + { + Ok(_) => { + glib::MainContext::ref_thread_default().spawn_local( + async move { + terminal_combo_row.reload_terminals(); + terminal_combo_row.set_selected_by_name( + &dbg!(this.root_store() + .terminal_repository() + .default_terminal() + .await + .map(|x| x.name) + .unwrap_or_default()), + ); + + this.add_toast(adw::Toast::new( + "Terminal removed successfully", + )); + }, + ); + } + Err(err) => { + error!(error = %err, "Failed to delete terminal"); + this.add_toast(adw::Toast::new("Failed to delete terminal")); + } + } + d.close(); + } + ), + ); + + dialog.present(Some(&this)); + } + )); + + // Add remove button for non read-only terminals + terminal_combo_row.connect_selected_item_notify(clone!( + #[weak] + terminal_combo_row, + #[weak] + delete_btn, + #[weak(rename_to = this)] + self, + move |_| { + if let Some(selected) = terminal_combo_row.selected_item() { + let selected_name = selected + .downcast_ref::() + .unwrap() + .string(); + let is_read_only = this + .root_store() + .terminal_repository() + .is_read_only(&selected_name); + + delete_btn.set_sensitive(!is_read_only); + } + } + )); + + terminal_group.add(&terminal_combo_row); + + // Add Custom Terminal Button + let add_terminal_btn = gtk::Button::with_label("Add Custom"); + add_terminal_btn.add_css_class("pill"); + add_terminal_btn.set_halign(gtk::Align::Start); + + let button_box = gtk::Box::new(gtk::Orientation::Horizontal, 12); + button_box.set_margin_start(12); + button_box.set_margin_end(12); + button_box.set_margin_top(12); + button_box.set_margin_bottom(12); + + button_box.append(&delete_btn); + button_box.append(&add_terminal_btn); + terminal_group.add(&button_box); + + // Connect add terminal button click handler + add_terminal_btn.connect_clicked(clone!( + #[weak] + terminal_combo_row, + #[weak(rename_to = this)] + self, + move |_| { + // Create dialog for adding a custom terminal + let custom_dialog = adw::Dialog::new(); + custom_dialog.set_title("Add Custom Terminal"); + // custom_dialog.set_default_width(400); + + let toolbar_view = adw::ToolbarView::new(); + toolbar_view.add_top_bar(&adw::HeaderBar::new()); + + let content = gtk::Box::new(gtk::Orientation::Vertical, 12); + content.set_margin_start(12); + content.set_margin_end(12); + content.set_margin_top(12); + content.set_margin_bottom(12); + + let group = adw::PreferencesGroup::new(); + + // Name entry + let name_entry = adw::EntryRow::builder().title("Terminal Name").build(); + + // Program entry + let program_entry = adw::EntryRow::builder().title("Program Path").build(); + + // Separator argument entry + let separator_entry = adw::EntryRow::builder() + .title("Separator Argument") + // .subtitle("The argument used to pass commands (e.g., '-e', '--')") + .build(); + + group.add(&name_entry); + group.add(&program_entry); + group.add(&separator_entry); + content.append(&group); + + // Add note about separator + let info_label = gtk::Label::new(Some( + "The separator argument is used to pass commands to the terminal.\n\ + Examples: '--' for GNOME Terminal, '-e' for xterm", + )); + info_label.add_css_class("caption"); + info_label.add_css_class("dim-label"); + info_label.set_wrap(true); + info_label.set_xalign(0.0); + info_label.set_margin_start(12); + content.append(&info_label); + + // Buttons + let button_box = gtk::Box::new(gtk::Orientation::Horizontal, 6); + button_box.set_margin_top(12); + button_box.set_homogeneous(true); + + let cancel_btn = gtk::Button::with_label("Cancel"); + cancel_btn.add_css_class("pill"); + + let save_btn = gtk::Button::with_label("Save"); + save_btn.add_css_class("suggested-action"); + save_btn.add_css_class("pill"); + + button_box.append(&cancel_btn); + button_box.append(&save_btn); + content.append(&button_box); + + toolbar_view.set_content(Some(&content)); + custom_dialog.set_child(Some(&toolbar_view)); + + // Connect button handlers + cancel_btn.connect_clicked(clone!( + #[weak] + custom_dialog, + move |_| { + custom_dialog.close(); + } + )); + + save_btn.connect_clicked(clone!( + #[weak] + custom_dialog, + #[weak] + name_entry, + #[weak] + program_entry, + #[weak] + separator_entry, + #[weak] + this, + move |_| { + let name = name_entry.text().to_string(); + let program = program_entry.text().to_string(); + let separator_arg = separator_entry.text().to_string(); + + // Validate inputs + if name.is_empty() || program.is_empty() || separator_arg.is_empty() { + this.add_toast(adw::Toast::new("All fields are required")); + return; + } + + // Create and save the terminal + let terminal = supported_terminals::Terminal { + name, + program, + separator_arg, + read_only: false, + }; + + match this + .root_store() + .terminal_repository() + .save_terminal(terminal.clone()) + { + Ok(_) => { + // Show success toast + let toast = adw::Toast::new("Custom terminal added successfully"); + + terminal_combo_row.reload_terminals(); + terminal_combo_row.set_selected_by_name(&terminal.name); + + this.add_toast(toast); + custom_dialog.close(); + } + Err(err) => { + error!(error = %err, "Failed to save terminal"); + this.add_toast(adw::Toast::new("Failed to save terminal")); + } + } + } + )); + + custom_dialog.present(Some(&this)); + } + )); + page.add(&terminal_group); dialog.add(&page);