From f5dd303b79c2053eb9f9955998ea73c7c9b9ef6b Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 15 Mar 2026 16:53:30 +0100 Subject: [PATCH] feat: add CmdPlugin for executing terminal commands and update workspace configuration --- Cargo.lock | 11 ++ Cargo.toml | 1 + crates/k-launcher/Cargo.toml | 1 + crates/k-launcher/src/main.rs | 2 + crates/plugins/plugin-cmd/Cargo.toml | 16 +++ crates/plugins/plugin-cmd/src/lib.rs | 154 +++++++++++++++++++++++++++ 6 files changed, 185 insertions(+) create mode 100644 crates/plugins/plugin-cmd/Cargo.toml create mode 100644 crates/plugins/plugin-cmd/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index b636554..c797e61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1767,6 +1767,7 @@ dependencies = [ "k-launcher-ui", "plugin-apps", "plugin-calc", + "plugin-cmd", "tokio", ] @@ -2750,6 +2751,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "plugin-cmd" +version = "0.1.0" +dependencies = [ + "async-trait", + "k-launcher-kernel", + "libc", + "tokio", +] + [[package]] name = "plugin-files" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 9d97132..9df0298 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/k-launcher-ui", "crates/plugins/plugin-apps", "crates/plugins/plugin-calc", + "crates/plugins/plugin-cmd", "crates/plugins/plugin-files", ] resolver = "2" diff --git a/crates/k-launcher/Cargo.toml b/crates/k-launcher/Cargo.toml index b2a2459..10e0cc9 100644 --- a/crates/k-launcher/Cargo.toml +++ b/crates/k-launcher/Cargo.toml @@ -14,4 +14,5 @@ k-launcher-kernel = { path = "../k-launcher-kernel" } k-launcher-ui = { path = "../k-launcher-ui" } plugin-apps = { path = "../plugins/plugin-apps" } plugin-calc = { path = "../plugins/plugin-calc" } +plugin-cmd = { path = "../plugins/plugin-cmd" } tokio = { workspace = true } diff --git a/crates/k-launcher/src/main.rs b/crates/k-launcher/src/main.rs index 295121b..8a5e725 100644 --- a/crates/k-launcher/src/main.rs +++ b/crates/k-launcher/src/main.rs @@ -3,9 +3,11 @@ use std::sync::Arc; use k_launcher_kernel::Kernel; use plugin_apps::{AppsPlugin, FsDesktopEntrySource}; use plugin_calc::CalcPlugin; +use plugin_cmd::CmdPlugin; fn main() -> iced::Result { let kernel = Arc::new(Kernel::new(vec![ + Arc::new(CmdPlugin::new()), Arc::new(CalcPlugin::new()), Arc::new(AppsPlugin::new(FsDesktopEntrySource::new())), ])); diff --git a/crates/plugins/plugin-cmd/Cargo.toml b/crates/plugins/plugin-cmd/Cargo.toml new file mode 100644 index 0000000..582aeee --- /dev/null +++ b/crates/plugins/plugin-cmd/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "plugin-cmd" +version = "0.1.0" +edition = "2024" + +[lib] +name = "plugin_cmd" +path = "src/lib.rs" + +[dependencies] +async-trait = { workspace = true } +k-launcher-kernel = { path = "../../k-launcher-kernel" } +libc = "0.2" + +[dev-dependencies] +tokio = { workspace = true } diff --git a/crates/plugins/plugin-cmd/src/lib.rs b/crates/plugins/plugin-cmd/src/lib.rs new file mode 100644 index 0000000..3981759 --- /dev/null +++ b/crates/plugins/plugin-cmd/src/lib.rs @@ -0,0 +1,154 @@ +use std::{process::{Command, Stdio}, sync::Arc}; +use std::os::unix::process::CommandExt; + +use async_trait::async_trait; +use k_launcher_kernel::{Plugin, PluginName, ResultId, ResultTitle, Score, SearchResult}; + +fn parse_term_cmd(s: &str) -> (String, Vec) { + let mut parts = s.split_whitespace(); + let bin = parts.next().unwrap_or("").to_string(); + let args = parts.map(str::to_string).collect(); + (bin, args) +} + +fn which(bin: &str) -> bool { + Command::new("which") + .arg(bin) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +fn resolve_terminal() -> Option<(String, Vec)> { + if let Ok(val) = std::env::var("TERM_CMD") { + let val = val.trim().to_string(); + if !val.is_empty() { + let (bin, args) = parse_term_cmd(&val); + if !bin.is_empty() { + return Some((bin, args)); + } + } + } + if let Ok(val) = std::env::var("TERMINAL") { + let bin = val.trim().to_string(); + if !bin.is_empty() { + return Some((bin, vec!["-e".to_string()])); + } + } + for (bin, flag) in &[ + ("foot", "-e"), + ("kitty", "-e"), + ("alacritty", "-e"), + ("wezterm", "start"), + ("konsole", "-e"), + ("xterm", "-e"), + ] { + if which(bin) { + return Some((bin.to_string(), vec![flag.to_string()])); + } + } + None +} + +pub struct CmdPlugin; + +impl CmdPlugin { + pub fn new() -> Self { + Self + } +} + +impl Default for CmdPlugin { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Plugin for CmdPlugin { + fn name(&self) -> PluginName { + "cmd" + } + + async fn search(&self, query: &str) -> Vec { + let Some(rest) = query.strip_prefix('>') else { + return vec![]; + }; + let cmd = rest.trim(); + if cmd.is_empty() { + return vec![]; + } + let cmd_owned = cmd.to_string(); + vec![SearchResult { + id: ResultId::new(format!("cmd-{cmd}")), + title: ResultTitle::new(format!("Run: {cmd}")), + description: None, + icon: None, + score: Score::new(95), + on_execute: Arc::new(move || { + let Some((term_bin, term_args)) = resolve_terminal() else { return }; + let _ = unsafe { + Command::new(&term_bin) + .args(&term_args) + .arg("sh").arg("-c").arg(&cmd_owned) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .pre_exec(|| { libc::setsid(); Ok(()) }) + .spawn() + }; + }), + }] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn cmd_prefix_triggers() { + let p = CmdPlugin::new(); + let results = p.search("> echo hello").await; + assert_eq!(results.len(), 1); + assert_eq!(results[0].title.as_str(), "Run: echo hello"); + assert_eq!(results[0].score.value(), 95); + } + + #[tokio::test] + async fn cmd_empty_remainder_returns_empty() { + let p = CmdPlugin::new(); + assert!(p.search(">").await.is_empty()); + assert!(p.search("> ").await.is_empty()); + } + + #[tokio::test] + async fn cmd_no_prefix_returns_empty() { + let p = CmdPlugin::new(); + assert!(p.search("echo hello").await.is_empty()); + assert!(p.search("firefox").await.is_empty()); + } + + #[test] + fn parse_term_cmd_single_flag() { + let (bin, args) = parse_term_cmd("foot -e"); + assert_eq!(bin, "foot"); + assert_eq!(args, vec!["-e"]); + } + + #[test] + fn parse_term_cmd_multiword() { + let (bin, args) = parse_term_cmd("wezterm start"); + assert_eq!(bin, "wezterm"); + assert_eq!(args, vec!["start"]); + } + + #[test] + fn parse_term_cmd_no_args() { + let (bin, args) = parse_term_cmd("xterm"); + assert_eq!(bin, "xterm"); + assert!(args.is_empty()); + } +}