From 4986dd00d38564bb1e758a4ba760dbb7e1ae8d5a Mon Sep 17 00:00:00 2001 From: lalvarezt Date: Mon, 28 Jul 2025 01:18:58 +0200 Subject: [PATCH] feat: add external actions support for multiple items --- cable/unix/files.toml | 6 +- television/action.rs | 56 +++++++++++++++- television/app.rs | 105 ++++++++++++++++++------------ television/channels/prototypes.rs | 14 ++-- television/main.rs | 14 ++-- television/utils/command.rs | 6 +- tests/cli/cli_external_actions.rs | 8 +-- 7 files changed, 143 insertions(+), 66 deletions(-) diff --git a/cable/unix/files.toml b/cable/unix/files.toml index f81c8aa..31b5a85 100644 --- a/cable/unix/files.toml +++ b/cable/unix/files.toml @@ -12,8 +12,8 @@ env = { BAT_THEME = "ansi" } [keybindings] shortcut = "f1" -ctrl-f12 = "edit" +ctrl-f12 = "actions:edit" [actions.edit] -description = "Opens the selected entry with Neovim" -command = "nvim '{}'" +description = "Opens the selected entries with Neovim" +command = "nvim {}" diff --git a/television/action.rs b/television/action.rs index fdb9a17..a458d33 100644 --- a/television/action.rs +++ b/television/action.rs @@ -423,7 +423,61 @@ impl<'de> serde::Deserialize<'de> for Action { "watch_timer" => Action::WatchTimer, "select_prev_history" => Action::SelectPrevHistory, "select_next_history" => Action::SelectNextHistory, - _ => Action::ExternalAction(s), + s if s.starts_with("actions:") => { + let action_name = &s[8..]; // Remove "actions:" prefix + Action::ExternalAction(action_name.to_string()) + } + _ => { + return Err(serde::de::Error::unknown_variant( + &s, + &[ + "add_input_char", + "delete_prev_char", + "delete_prev_word", + "delete_next_char", + "delete_line", + "go_to_prev_char", + "go_to_next_char", + "go_to_input_start", + "go_to_input_end", + "render", + "resize", + "clear_screen", + "toggle_selection_down", + "toggle_selection_up", + "confirm_selection", + "select_and_exit", + "expect", + "select_next_entry", + "select_prev_entry", + "select_next_page", + "select_prev_page", + "copy_entry_to_clipboard", + "scroll_preview_up", + "scroll_preview_down", + "scroll_preview_half_page_up", + "scroll_preview_half_page_down", + "open_entry", + "tick", + "suspend", + "resume", + "quit", + "toggle_remote_control", + "toggle_help", + "toggle_status_bar", + "toggle_preview", + "error", + "no_op", + "cycle_sources", + "reload_source", + "switch_to_channel", + "watch_timer", + "select_prev_history", + "select_next_history", + "actions:*", + ], + )); + } }; Ok(action) diff --git a/television/app.rs b/television/app.rs index d09bffb..e8bc02f 100644 --- a/television/app.rs +++ b/television/app.rs @@ -686,54 +686,77 @@ impl App { Action::ExternalAction(ref action_name) => { debug!("External action triggered: {}", action_name); - // Handle external action execution - if let Some(selected_entry) = + // Handle external action execution for selected entries + let selected_entries = if !self + .television + .channel + .selected_entries() + .is_empty() + { + // Use multi-selected entries + self.television.channel.selected_entries().clone() + } else if let Some(current_entry) = self.television.get_selected_entry() { - debug!("Selected entry: {}", selected_entry.raw); - - if let Some(action_spec) = self - .television - .channel_prototype - .actions - .get(action_name) - { - debug!( - "Found action spec for: {}", - action_name - ); - // Store the external action info and exit - the command will be executed after terminal cleanup - self.should_quit = true; - self.render_tx.send(RenderingTask::Quit)?; - return Ok(ActionOutcome::ExternalAction( - action_spec.clone(), - selected_entry.raw.clone(), - )); - } - - error!("Unknown action: {}", action_name); - // List available actions for debugging - let available_actions: Vec<&String> = self - .television - .channel_prototype - .actions - .keys() - .collect(); - debug!( - "Available actions: {:?}", - available_actions - ); - self.action_tx.send(Action::Error(format!( - "Unknown action: {}", - action_name - )))?; + // Use single entry under cursor + std::iter::once(current_entry).collect() } else { - error!("No entry selected for external action"); + debug!("No entries available for external action"); self.action_tx.send(Action::Error( - "No entry selected for external action" + "No entry available for external action" .to_string(), ))?; + return Ok(ActionOutcome::None); + }; + + debug!( + "Selected {} entries for external action", + selected_entries.len() + ); + + if let Some(action_spec) = self + .television + .channel_prototype + .actions + .get(action_name) + { + debug!("Found action spec for: {}", action_name); + // Store the external action info and exit - the command will be executed after terminal cleanup + self.should_quit = true; + self.render_tx.send(RenderingTask::Quit)?; + // Concatenate entry values with space separator, quoting items with whitespace + let concatenated_entries: String = + selected_entries + .iter() + .map(|entry| { + let raw = entry.raw.clone(); + if raw.chars().any(char::is_whitespace) + { + format!("'{}'", raw) + } else { + raw + } + }) + .collect::>() + .join(" "); + return Ok(ActionOutcome::ExternalAction( + action_spec.clone(), + concatenated_entries, + )); } + error!("Unknown action: {}", action_name); + // List available actions for debugging + let available_actions: Vec<&String> = self + .television + .channel_prototype + .actions + .keys() + .collect(); + debug!("Available actions: {:?}", available_actions); + self.action_tx.send(Action::Error(format!( + "Unknown action: {}", + action_name + )))?; } _ => {} } diff --git a/television/channels/prototypes.rs b/television/channels/prototypes.rs index 43da9e3..760bce3 100644 --- a/television/channels/prototypes.rs +++ b/television/channels/prototypes.rs @@ -160,7 +160,9 @@ impl CommandSpec { } /// Execution mode for external actions -#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize, PartialEq)] +#[derive( + Debug, Clone, Default, serde::Deserialize, serde::Serialize, PartialEq, +)] #[serde(rename_all = "lowercase")] pub enum ExecutionMode { /// Fork the command as a child process (current behavior, tv stays open) @@ -171,7 +173,9 @@ pub enum ExecutionMode { } /// Output handling mode for external actions -#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize, PartialEq)] +#[derive( + Debug, Clone, Default, serde::Deserialize, serde::Serialize, PartialEq, +)] #[serde(rename_all = "lowercase")] pub enum OutputMode { /// Inherit stdin/stdout/stderr (current behavior) @@ -191,7 +195,7 @@ pub struct ActionSpec { pub command: CommandSpec, /// How to execute the command (fork vs become) #[serde(default)] - pub become: bool, + pub r#become: bool, /// How to handle command output #[serde(default)] pub output_mode: OutputMode, @@ -818,8 +822,8 @@ mod tests { [keybindings] shortcut = "f1" - f8 = "thebatman" - f9 = "lsman" + f8 = "actions:thebatman" + f9 = "actions:lsman" [actions.thebatman] description = "cats the file" diff --git a/television/main.rs b/television/main.rs index f545427..21f924a 100644 --- a/television/main.rs +++ b/television/main.rs @@ -3,7 +3,7 @@ use clap::Parser; use std::env; use std::io::{BufWriter, IsTerminal, Write, stdout}; use std::path::PathBuf; -use std::process::{Stdio, exit}; +use std::process::exit; use television::{ app::{App, AppOptions}, cable::{Cable, cable_empty_exit, load_cable}, @@ -21,7 +21,7 @@ use television::{ gh::update_local_channels, television::Mode, utils::clipboard::CLIPBOARD, - utils::command::{execute_action, shell_command}, + utils::command::execute_action, utils::{ shell::{ Shell, completion_script, render_autocomplete_script_template, @@ -116,16 +116,12 @@ async fn main() -> Result<()> { // Handle external action execution after terminal cleanup if let Some((action_spec, entry_value)) = output.external_action { debug!("Executing external action command after terminal cleanup"); + let template = action_spec.command.get_nth(0); - // Format the command template with the entry value - let formatted_command = - action_spec.command.get_nth(0).format(&entry_value)?; + // Process the concatenated entry value through the template system + let formatted_command = template.format(&entry_value)?; - debug!("External command: {}", formatted_command); - - // Execute the command using the new action execution abstraction let status = execute_action(&action_spec, &formatted_command)?; - if !status.success() { eprintln!( "External command failed with exit code: {:?}", diff --git a/television/utils/command.rs b/television/utils/command.rs index 0ffec0b..ade0cdc 100644 --- a/television/utils/command.rs +++ b/television/utils/command.rs @@ -35,7 +35,7 @@ pub fn shell_command( } /// Execute an external action with the appropriate execution mode and output handling -/// +/// /// Currently implements the existing behavior but designed to be extended with: /// - `become` flag for execve behavior /// - `output_mode` for different output handling modes @@ -45,7 +45,7 @@ pub fn execute_action( ) -> std::io::Result { // For now, preserve existing behavior regardless of the new flags // In the future, this will branch based on action_spec.become and action_spec.output_mode - + let mut cmd = shell_command( formatted_command, action_spec.command.interactive, @@ -74,7 +74,7 @@ pub fn execute_action( } // Execute based on become flag (future extension point) - if action_spec.become { + if action_spec.r#become { // Future: use execve to replace current process // For now, use normal execution let mut child = cmd.spawn()?; diff --git a/tests/cli/cli_external_actions.rs b/tests/cli/cli_external_actions.rs index c1253b2..02eac41 100644 --- a/tests/cli/cli_external_actions.rs +++ b/tests/cli/cli_external_actions.rs @@ -45,8 +45,8 @@ env = { BAT_THEME = "ansi" } [keybindings] shortcut = "f1" -f8 = "thebatman" -f9 = "lsman" +f8 = "actions:thebatman" +f9 = "actions:lsman" [actions.thebatman] description = "show file content" @@ -108,8 +108,8 @@ env = { BAT_THEME = "ansi" } [keybindings] shortcut = "f1" -f8 = "thebatman" -f9 = "lsman" +f8 = "actions:thebatman" +f9 = "actions:lsman" [actions.thebatman] description = "show file content"