feat: support for external actions

This commit is contained in:
lalvarezt 2025-07-27 13:33:17 +02:00
parent 83f29f7418
commit 31636c7afa
8 changed files with 411 additions and 8 deletions

View File

@ -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 '{}'"

View File

@ -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<D>(deserializer: D) -> Result<Self, D::Error>
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",
}
}
}

View File

@ -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<Entry>, 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<FxHashSet<Entry>>,
pub expect_key: Option<Key>,
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

View File

@ -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<String>,
#[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<Action>,
#[serde(default)]
pub actions: FxHashMap<String, ActionSpec>,
}
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()
)
);
}
}

View File

@ -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 {

View File

@ -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 => {

View File

@ -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);
}

View File

@ -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;