diff --git a/cable/unix/files.toml b/cable/unix/files.toml index 1f271ab..31b5a85 100644 --- a/cable/unix/files.toml +++ b/cable/unix/files.toml @@ -12,3 +12,8 @@ env = { BAT_THEME = "ansi" } [keybindings] shortcut = "f1" +ctrl-f12 = "actions:edit" + +[actions.edit] +description = "Opens the selected entries with Neovim" +command = "nvim {}" diff --git a/television/action.rs b/television/action.rs index 9198aed..a458d33 100644 --- a/television/action.rs +++ b/television/action.rs @@ -5,9 +5,7 @@ use std::fmt::Display; use crate::event::Key; /// The different actions that can be performed by the application. -#[derive( - Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash, PartialOrd, Ord, -)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Hash, PartialOrd, Ord)] #[serde(rename_all = "snake_case")] pub enum Action { // input actions @@ -118,6 +116,8 @@ pub enum Action { /// Handle mouse click event at specific coordinates #[serde(skip)] MouseClickAt(u16, u16), + /// Execute an external action + ExternalAction(String), } /// Container for one or more actions that can be executed together. @@ -365,10 +365,125 @@ impl Display for Action { write!(f, "select_entry_at_position") } Action::MouseClickAt(_, _) => write!(f, "mouse_click_at"), + Action::ExternalAction(name) => write!(f, "{}", name), } } } +impl<'de> serde::Deserialize<'de> for Action { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + + let action = match s.as_str() { + "add_input_char" => Action::AddInputChar(' '), + "delete_prev_char" => Action::DeletePrevChar, + "delete_prev_word" => Action::DeletePrevWord, + "delete_next_char" => Action::DeleteNextChar, + "delete_line" => Action::DeleteLine, + "go_to_prev_char" => Action::GoToPrevChar, + "go_to_next_char" => Action::GoToNextChar, + "go_to_input_start" => Action::GoToInputStart, + "go_to_input_end" => Action::GoToInputEnd, + "render" => Action::Render, + "resize" => Action::Resize(0, 0), + "clear_screen" => Action::ClearScreen, + "toggle_selection_down" => Action::ToggleSelectionDown, + "toggle_selection_up" => Action::ToggleSelectionUp, + "confirm_selection" => Action::ConfirmSelection, + "select_and_exit" => Action::SelectAndExit, + "expect" => Action::Expect(Key::Char(' ')), + "select_next_entry" => Action::SelectNextEntry, + "select_prev_entry" => Action::SelectPrevEntry, + "select_next_page" => Action::SelectNextPage, + "select_prev_page" => Action::SelectPrevPage, + "copy_entry_to_clipboard" => Action::CopyEntryToClipboard, + "scroll_preview_up" => Action::ScrollPreviewUp, + "scroll_preview_down" => Action::ScrollPreviewDown, + "scroll_preview_half_page_up" => Action::ScrollPreviewHalfPageUp, + "scroll_preview_half_page_down" => { + Action::ScrollPreviewHalfPageDown + } + "open_entry" => Action::OpenEntry, + "tick" => Action::Tick, + "suspend" => Action::Suspend, + "resume" => Action::Resume, + "quit" => Action::Quit, + "toggle_remote_control" => Action::ToggleRemoteControl, + "toggle_help" => Action::ToggleHelp, + "toggle_status_bar" => Action::ToggleStatusBar, + "toggle_preview" => Action::TogglePreview, + "error" => Action::Error(String::new()), + "no_op" => Action::NoOp, + "cycle_sources" => Action::CycleSources, + "reload_source" => Action::ReloadSource, + "switch_to_channel" => Action::SwitchToChannel(String::new()), + "watch_timer" => Action::WatchTimer, + "select_prev_history" => Action::SelectPrevHistory, + "select_next_history" => Action::SelectNextHistory, + 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) + } +} + impl Action { /// Returns a user-friendly description of the action for help panels and UI display. /// @@ -460,6 +575,9 @@ impl Action { // Mouse actions Action::SelectEntryAtPosition(_, _) => "Select at position", Action::MouseClickAt(_, _) => "Mouse click", + + // External actions + Action::ExternalAction(_) => "External action", } } } diff --git a/television/app.rs b/television/app.rs index 06f6d2b..e8bc02f 100644 --- a/television/app.rs +++ b/television/app.rs @@ -1,7 +1,10 @@ use crate::{ action::Action, cable::Cable, - channels::{entry::Entry, prototypes::ChannelPrototype}, + channels::{ + entry::Entry, + prototypes::{ActionSpec, ChannelPrototype}, + }, cli::PostProcessedCli, config::{Config, DEFAULT_PREVIEW_SIZE, default_tick_rate}, event::{Event, EventLoop, InputEvent, Key, MouseInputEvent}, @@ -147,6 +150,7 @@ pub enum ActionOutcome { EntriesWithExpect(FxHashSet, Key), Input(String), None, + ExternalAction(ActionSpec, String), } /// The result of the application. @@ -154,6 +158,7 @@ pub enum ActionOutcome { pub struct AppOutput { pub selected_entries: Option>, pub expect_key: Option, + pub external_action: Option<(ActionSpec, String)>, } impl AppOutput { @@ -162,20 +167,29 @@ impl AppOutput { ActionOutcome::Entries(entries) => Self { selected_entries: Some(entries), expect_key: None, + external_action: None, }, ActionOutcome::EntriesWithExpect(entries, expect_key) => Self { selected_entries: Some(entries), expect_key: Some(expect_key), + external_action: None, }, ActionOutcome::Input(input) => Self { selected_entries: Some(FxHashSet::from_iter([Entry::new( input, )])), expect_key: None, + external_action: None, }, ActionOutcome::None => Self { selected_entries: None, expect_key: None, + external_action: None, + }, + ActionOutcome::ExternalAction(action_spec, entry_value) => Self { + selected_entries: None, + expect_key: None, + external_action: Some((action_spec, entry_value)), }, } } @@ -669,6 +683,81 @@ impl App { self.television.set_pattern(""); } } + Action::ExternalAction(ref action_name) => { + debug!("External action triggered: {}", action_name); + + // 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() + { + // Use single entry under cursor + std::iter::once(current_entry).collect() + } else { + debug!("No entries available for external action"); + self.action_tx.send(Action::Error( + "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 + )))?; + } _ => {} } // Check if we're switching from remote control to channel mode diff --git a/television/channels/prototypes.rs b/television/channels/prototypes.rs index b05100f..760bce3 100644 --- a/television/channels/prototypes.rs +++ b/television/channels/prototypes.rs @@ -98,7 +98,7 @@ impl<'de> Deserialize<'de> for Template { } #[serde_as] -#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, PartialEq)] pub struct CommandSpec { #[serde(rename = "command")] #[serde_as(as = "OneOrMany<_>")] @@ -159,6 +159,48 @@ impl CommandSpec { } } +/// Execution mode for external actions +#[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) + #[default] + Fork, + /// Replace the current process with the command (tv exits, command takes over) + Become, +} + +/// Output handling mode for external actions +#[derive( + Debug, Clone, Default, serde::Deserialize, serde::Serialize, PartialEq, +)] +#[serde(rename_all = "lowercase")] +pub enum OutputMode { + /// Inherit stdin/stdout/stderr (current behavior) + #[default] + Inherit, + /// Capture output for processing + Capture, + /// Discard output silently + Discard, +} + +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, PartialEq)] +pub struct ActionSpec { + #[serde(default)] + pub description: Option, + #[serde(flatten)] + pub command: CommandSpec, + /// How to execute the command (fork vs become) + #[serde(default)] + pub r#become: bool, + /// How to handle command output + #[serde(default)] + pub output_mode: OutputMode, +} + #[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] pub struct ChannelKeyBindings { /// Optional channel specific shortcut that, when pressed, switches directly to this channel. @@ -198,7 +240,8 @@ pub struct ChannelPrototype { pub watch: f64, #[serde(default)] pub history: HistoryConfig, - // actions: Vec, + #[serde(default)] + pub actions: FxHashMap, } impl ChannelPrototype { @@ -228,6 +271,7 @@ impl ChannelPrototype { keybindings: None, watch: 0.0, history: HistoryConfig::default(), + actions: FxHashMap::default(), } } @@ -259,6 +303,7 @@ impl ChannelPrototype { keybindings: None, watch: 0.0, history: HistoryConfig::default(), + actions: FxHashMap::default(), } } @@ -758,4 +803,80 @@ mod tests { assert!(ui.help_panel.is_none()); assert!(ui.remote_control.is_none()); } + + #[test] + fn test_channel_prototype_with_actions() { + // Create a custom files.toml with external actions + let toml_data = r#" + [metadata] + name = "custom_files" + description = "A channel to select files and directories" + requirements = ["fd", "bat"] + + [source] + command = ["fd -t f", "fd -t f -H"] + + [preview] + command = "bat -n --color=always '{}'" + env = { BAT_THEME = "ansi" } + + [keybindings] + shortcut = "f1" + f8 = "actions:thebatman" + f9 = "actions:lsman" + + [actions.thebatman] + description = "cats the file" + command = "bat '{}'" + env = { BAT_THEME = "ansi" } + + [actions.lsman] + description = "show stats" + command = "ls '{}'" + "#; + + let prototype: ChannelPrototype = from_str(toml_data).unwrap(); + + // Verify basic prototype properties + assert_eq!(prototype.metadata.name, "custom_files"); + + // Verify actions are loaded + assert_eq!(prototype.actions.len(), 2); + assert!(prototype.actions.contains_key("thebatman")); + assert!(prototype.actions.contains_key("lsman")); + + // Verify edit action + let thebatman = prototype.actions.get("thebatman").unwrap(); + assert_eq!(thebatman.description, Some("cats the file".to_string())); + assert_eq!(thebatman.command.inner[0].raw(), "bat '{}'"); + assert_eq!( + thebatman.command.env.get("BAT_THEME"), + Some(&"ansi".to_string()) + ); + + // Verify lsman action + let lsman = prototype.actions.get("lsman").unwrap(); + assert_eq!(lsman.description, Some("show stats".to_string())); + assert_eq!(lsman.command.inner[0].raw(), "ls '{}'"); + assert!(lsman.command.env.is_empty()); + + // Verify keybindings reference the actions + let keybindings = prototype.keybindings.as_ref().unwrap(); + assert_eq!( + keybindings.bindings.get(&Key::F(8)), + Some( + &crate::action::Action::ExternalAction( + "thebatman".to_string() + ) + .into() + ) + ); + assert_eq!( + keybindings.bindings.get(&Key::F(9)), + Some( + &crate::action::Action::ExternalAction("lsman".to_string()) + .into() + ) + ); + } } diff --git a/television/main.rs b/television/main.rs index 843865d..21f924a 100644 --- a/television/main.rs +++ b/television/main.rs @@ -21,6 +21,7 @@ use television::{ gh::update_local_channels, television::Mode, utils::clipboard::CLIPBOARD, + utils::command::execute_action, utils::{ shell::{ Shell, completion_script, render_autocomplete_script_template, @@ -111,6 +112,27 @@ async fn main() -> Result<()> { debug!("Running application..."); let output = app.run(stdout().is_terminal(), false).await?; info!("App output: {:?}", output); + + // 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); + + // Process the concatenated entry value through the template system + let formatted_command = template.format(&entry_value)?; + + let status = execute_action(&action_spec, &formatted_command)?; + if !status.success() { + eprintln!( + "External command failed with exit code: {:?}", + status.code() + ); + exit(1); + } + + exit(0); + } + let stdout_handle = stdout().lock(); let mut bufwriter = BufWriter::new(stdout_handle); if let Some(key) = output.expect_key { diff --git a/television/screen/help_panel.rs b/television/screen/help_panel.rs index 5b523bb..39de841 100644 --- a/television/screen/help_panel.rs +++ b/television/screen/help_panel.rs @@ -112,7 +112,8 @@ fn is_action_relevant_for_mode(action: &Action, mode: Mode) -> bool { | Action::SelectEntryAtPosition(_, _) | Action::MouseClickAt(_, _) | Action::Expect(_) - | Action::SelectAndExit => false, + | Action::SelectAndExit + | Action::ExternalAction(_) => false, } } Mode::RemoteControl => { diff --git a/television/utils/command.rs b/television/utils/command.rs index 10922a7..ade0cdc 100644 --- a/television/utils/command.rs +++ b/television/utils/command.rs @@ -4,6 +4,7 @@ use std::{collections::HashMap, process::Command}; use tracing::warn; use super::shell::Shell; +use crate::channels::prototypes::{ActionSpec, OutputMode}; pub fn shell_command( command: &str, @@ -32,3 +33,55 @@ pub fn shell_command( cmd.envs(envs).arg(command); cmd } + +/// 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 +pub fn execute_action( + action_spec: &ActionSpec, + formatted_command: &str, +) -> 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, + &action_spec.command.env, + ); + + // Configure stdio based on output mode (future extension point) + match action_spec.output_mode { + OutputMode::Inherit => { + cmd.stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()); + } + OutputMode::Capture => { + // Future: capture output for processing + cmd.stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()); + } + OutputMode::Discard => { + // Future: discard output silently + cmd.stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()); + } + } + + // Execute based on become flag (future extension point) + if action_spec.r#become { + // Future: use execve to replace current process + // For now, use normal execution + let mut child = cmd.spawn()?; + child.wait() + } else { + // Normal fork execution (current behavior) + let mut child = cmd.spawn()?; + child.wait() + } +} diff --git a/tests/cli/cli_external_actions.rs b/tests/cli/cli_external_actions.rs new file mode 100644 index 0000000..02eac41 --- /dev/null +++ b/tests/cli/cli_external_actions.rs @@ -0,0 +1,147 @@ +//! Tests for external actions functionality. +//! +//! These tests verify that external actions defined in channel TOML files work correctly, +//! including keybinding integration and command execution. + +use super::common::*; +use std::{fs, thread::sleep, time::Duration}; + +// ANSI escape sequences for function keys +const F8_KEY: &str = "\x1b[19~"; +const F9_KEY: &str = "\x1b[20~"; + +/// Helper to create a custom cable directory with external actions. +fn write_toml_config( + cable_dir: &std::path::Path, + filename: &str, + content: &str, +) { + let toml_path = cable_dir.join(filename); + fs::write(&toml_path, content).unwrap(); +} + +/// Tests that external actions execute properly when triggered by keybindings. +#[test] +fn test_external_action_lsman_with_f9() { + let mut tester = PtyTester::new(); + + // Use TARGET_DIR for consistency with other tests + let cable_dir = std::path::Path::new(TARGET_DIR).join("custom_cable"); + fs::create_dir_all(&cable_dir).unwrap(); + + // Create a custom files.toml with external actions + let files_toml_content = r#" +[metadata] +name = "files" +description = "A channel to select files and directories" +requirements = ["fd", "bat"] + +[source] +command = ["fd -t f", "fd -t f -H"] + +[preview] +command = "bat -n --color=always '{}'" +env = { BAT_THEME = "ansi" } + +[keybindings] +shortcut = "f1" +f8 = "actions:thebatman" +f9 = "actions:lsman" + +[actions.thebatman] +description = "show file content" +command = "cat '{}'" + +[actions.lsman] +description = "show stats" +command = "ls '{}'" +"#; + + write_toml_config(&cable_dir, "files.toml", files_toml_content); + + // Use the LICENSE file as input since it exists in the repo + let mut cmd = tv(); + cmd.args(["--cable-dir", cable_dir.to_str().unwrap()]); + cmd.args(["--config-file", DEFAULT_CONFIG_FILE]); + cmd.args(["files", "--input", "LICENSE"]); + + let mut child = tester.spawn_command_tui(cmd); + + // Wait for the UI to load - we should see LICENSE in the selection + sleep(DEFAULT_DELAY); + tester.assert_tui_frame_contains("LICENSE"); + + // Send F9 to trigger the "lsman" action (mapped to ls command) + tester.send(F9_KEY); + + // Give time for the action to execute and television to exit + sleep(Duration::from_millis(500)); + + // The external action should have executed "ls 'LICENSE'" and exited + tester.assert_raw_output_contains("LICENSE"); + + // Process should exit successfully after executing the external command + PtyTester::assert_exit_ok(&mut child, DEFAULT_DELAY); +} + +/// Tests that external actions execute properly with F8 keybinding. +#[test] +fn test_external_action_thebatman_with_f8() { + let mut tester = PtyTester::new(); + + let cable_dir = std::path::Path::new(TARGET_DIR).join("custom_cable_f8"); + fs::create_dir_all(&cable_dir).unwrap(); + + // Create a custom files.toml with external actions + let files_toml_content = r#" +[metadata] +name = "files" +description = "A channel to select files and directories" +requirements = ["fd", "bat"] + +[source] +command = ["fd -t f", "fd -t f -H"] + +[preview] +command = "bat -n --color=always '{}'" +env = { BAT_THEME = "ansi" } + +[keybindings] +shortcut = "f1" +f8 = "actions:thebatman" +f9 = "actions:lsman" + +[actions.thebatman] +description = "show file content" +command = "cat '{}'" + +[actions.lsman] +description = "show stats" +command = "ls '{}'" +"#; + + write_toml_config(&cable_dir, "files.toml", files_toml_content); + + // Use the LICENSE file as input since it exists in the repo + let mut cmd = tv(); + cmd.args(["--cable-dir", cable_dir.to_str().unwrap()]); + cmd.args(["--config-file", DEFAULT_CONFIG_FILE]); + cmd.args(["files", "--input", "LICENSE"]); + + let mut child = tester.spawn_command_tui(cmd); + + // Wait for the UI to load + sleep(DEFAULT_DELAY); + + // Send F8 to trigger the "thebatman" action (mapped to cat command) + tester.send(F8_KEY); + + // Give time for the action to execute + sleep(Duration::from_millis(500)); + + // The command should execute and television should exit + tester.assert_raw_output_contains("Copyright (c)"); + + // Check that the process has finished + PtyTester::assert_exit_ok(&mut child, DEFAULT_DELAY); +} diff --git a/tests/cli/mod.rs b/tests/cli/mod.rs index 044b1aa..3fdec7c 100644 --- a/tests/cli/mod.rs +++ b/tests/cli/mod.rs @@ -18,6 +18,7 @@ mod common; pub mod cli_config; pub mod cli_errors; +pub mod cli_external_actions; pub mod cli_input; pub mod cli_modes; pub mod cli_monitoring;