diff --git a/cable/unix/files.toml b/cable/unix/files.toml index 1f271ab..f81c8aa 100644 --- a/cable/unix/files.toml +++ b/cable/unix/files.toml @@ -12,3 +12,8 @@ env = { BAT_THEME = "ansi" } [keybindings] shortcut = "f1" +ctrl-f12 = "edit" + +[actions.edit] +description = "Opens the selected entry with Neovim" +command = "nvim '{}'" diff --git a/television/action.rs b/television/action.rs index 9198aed..fdb9a17 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,71 @@ 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, + _ => Action::ExternalAction(s), + }; + + Ok(action) + } +} + impl Action { /// Returns a user-friendly description of the action for help panels and UI display. /// @@ -460,6 +521,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..d09bffb 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,58 @@ impl App { self.television.set_pattern(""); } } + Action::ExternalAction(ref action_name) => { + debug!("External action triggered: {}", action_name); + + // Handle external action execution + if let Some(selected_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 + )))?; + } else { + error!("No entry selected for external action"); + self.action_tx.send(Action::Error( + "No entry selected for external action" + .to_string(), + ))?; + } + } _ => {} } // 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..27bce75 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,14 @@ impl CommandSpec { } } +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, PartialEq)] +pub struct ActionSpec { + #[serde(default)] + pub description: Option, + #[serde(flatten)] + pub command: CommandSpec, +} + #[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] pub struct ChannelKeyBindings { /// Optional channel specific shortcut that, when pressed, switches directly to this channel. @@ -198,7 +206,8 @@ pub struct ChannelPrototype { pub watch: f64, #[serde(default)] pub history: HistoryConfig, - // actions: Vec, + #[serde(default)] + pub actions: FxHashMap, } impl ChannelPrototype { @@ -228,6 +237,7 @@ impl ChannelPrototype { keybindings: None, watch: 0.0, history: HistoryConfig::default(), + actions: FxHashMap::default(), } } @@ -259,6 +269,7 @@ impl ChannelPrototype { keybindings: None, watch: 0.0, history: HistoryConfig::default(), + actions: FxHashMap::default(), } } @@ -758,4 +769,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 = "thebatman" + f9 = "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..7cd0884 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::exit; +use std::process::{Stdio, exit}; use television::{ app::{App, AppOptions}, cable::{Cable, cable_empty_exit, load_cable}, @@ -21,6 +21,7 @@ use television::{ gh::update_local_channels, television::Mode, utils::clipboard::CLIPBOARD, + utils::command::shell_command, utils::{ shell::{ Shell, completion_script, render_autocomplete_script_template, @@ -111,6 +112,41 @@ 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"); + + // Format the command template with the entry value + let formatted_command = + action_spec.command.get_nth(0).format(&entry_value)?; + + debug!("External command: {}", formatted_command); + + // Execute the command with inherited stdio + let mut child = shell_command( + &formatted_command, + action_spec.command.interactive, + &action_spec.command.env, + ) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn()?; + + let status = child.wait()?; + + 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/tests/cli/cli_external_actions.rs b/tests/cli/cli_external_actions.rs new file mode 100644 index 0000000..bd3aac4 --- /dev/null +++ b/tests/cli/cli_external_actions.rs @@ -0,0 +1,143 @@ +//! 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}; +use tempfile::TempDir; + +// ANSI escape sequences for function keys +const F8_KEY: &str = "\x1b[19~"; +const F9_KEY: &str = "\x1b[20~"; + +/// Tests that external actions execute properly when triggered by keybindings. +#[test] +fn test_external_action_lsman_with_f9() { + let mut tester = PtyTester::new(); + + // Create a temporary directory for the custom cable + let temp_dir = TempDir::new().unwrap(); + let cable_dir = temp_dir.path(); + + // 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 = "thebatman" +f9 = "lsman" + +[actions.thebatman] +description = "cats the file" +command = "bat '{}'" +env = { BAT_THEME = "ansi" } + +[actions.lsman] +description = "show stats" +command = "ls '{}'" +"#; + + let files_toml_path = cable_dir.join("files.toml"); + fs::write(&files_toml_path, files_toml_content).unwrap(); + + // 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(); + + // Create a temporary directory for the custom cable + let temp_dir = TempDir::new().unwrap(); + let cable_dir = temp_dir.path(); + + // 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 = "thebatman" +f9 = "lsman" + +[actions.thebatman] +description = "cats the file" +command = "bat '{}'" +env = { BAT_THEME = "ansi" } + +[actions.lsman] +description = "show stats" +command = "ls '{}'" +"#; + + let files_toml_path = cable_dir.join("files.toml"); + fs::write(&files_toml_path, files_toml_content).unwrap(); + + // 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 bat 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;