From 31636c7afae375c764d1279a3cc04c5174c28c5f Mon Sep 17 00:00:00 2001 From: lalvarezt Date: Sun, 27 Jul 2025 13:33:17 +0200 Subject: [PATCH 1/4] feat: support for external actions --- cable/unix/files.toml | 5 ++ television/action.rs | 70 ++++++++++++++- television/app.rs | 68 +++++++++++++- television/channels/prototypes.rs | 91 ++++++++++++++++++- television/main.rs | 38 +++++++- television/screen/help_panel.rs | 3 +- tests/cli/cli_external_actions.rs | 143 ++++++++++++++++++++++++++++++ tests/cli/mod.rs | 1 + 8 files changed, 411 insertions(+), 8 deletions(-) create mode 100644 tests/cli/cli_external_actions.rs 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; From fd6a4ef4896f2cbb6c1af76e2bef17b3b22196da Mon Sep 17 00:00:00 2001 From: lalvarezt Date: Sun, 27 Jul 2025 16:24:08 +0200 Subject: [PATCH 2/4] refactor(tests): follow other tests conventions --- tests/cli/cli_external_actions.rs | 40 +++++++++++++++++-------------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/tests/cli/cli_external_actions.rs b/tests/cli/cli_external_actions.rs index bd3aac4..c1253b2 100644 --- a/tests/cli/cli_external_actions.rs +++ b/tests/cli/cli_external_actions.rs @@ -5,20 +5,29 @@ 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~"; +/// 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(); - // Create a temporary directory for the custom cable - let temp_dir = TempDir::new().unwrap(); - let cable_dir = temp_dir.path(); + // 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#" @@ -40,17 +49,15 @@ f8 = "thebatman" f9 = "lsman" [actions.thebatman] -description = "cats the file" -command = "bat '{}'" -env = { BAT_THEME = "ansi" } +description = "show file content" +command = "cat '{}'" [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(); + 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(); @@ -82,9 +89,8 @@ command = "ls '{}'" 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(); + 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#" @@ -106,17 +112,15 @@ f8 = "thebatman" f9 = "lsman" [actions.thebatman] -description = "cats the file" -command = "bat '{}'" -env = { BAT_THEME = "ansi" } +description = "show file content" +command = "cat '{}'" [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(); + 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(); @@ -129,7 +133,7 @@ command = "ls '{}'" // Wait for the UI to load sleep(DEFAULT_DELAY); - // Send F8 to trigger the "thebatman" action (mapped to bat command) + // Send F8 to trigger the "thebatman" action (mapped to cat command) tester.send(F8_KEY); // Give time for the action to execute From c514494f6d28f178c054e508518eaa5af4e8f6d6 Mon Sep 17 00:00:00 2001 From: lalvarezt Date: Mon, 28 Jul 2025 00:52:23 +0200 Subject: [PATCH 3/4] feat: add foundation for future external process features --- television/channels/prototypes.rs | 30 +++++++++++++++++ television/main.rs | 16 ++-------- television/utils/command.rs | 53 +++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 13 deletions(-) diff --git a/television/channels/prototypes.rs b/television/channels/prototypes.rs index 27bce75..43da9e3 100644 --- a/television/channels/prototypes.rs +++ b/television/channels/prototypes.rs @@ -159,12 +159,42 @@ 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 become: bool, + /// How to handle command output + #[serde(default)] + pub output_mode: OutputMode, } #[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] diff --git a/television/main.rs b/television/main.rs index 7cd0884..f545427 100644 --- a/television/main.rs +++ b/television/main.rs @@ -21,7 +21,7 @@ use television::{ gh::update_local_channels, television::Mode, utils::clipboard::CLIPBOARD, - utils::command::shell_command, + utils::command::{execute_action, shell_command}, utils::{ shell::{ Shell, completion_script, render_autocomplete_script_template, @@ -123,18 +123,8 @@ async fn main() -> Result<()> { 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()?; + // Execute the command using the new action execution abstraction + let status = execute_action(&action_spec, &formatted_command)?; if !status.success() { eprintln!( diff --git a/television/utils/command.rs b/television/utils/command.rs index 10922a7..0ffec0b 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.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() + } +} From 4986dd00d38564bb1e758a4ba760dbb7e1ae8d5a Mon Sep 17 00:00:00 2001 From: lalvarezt Date: Mon, 28 Jul 2025 01:18:58 +0200 Subject: [PATCH 4/4] 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"