mirror of
https://github.com/alexpasmantier/television.git
synced 2025-07-29 06:11:37 +00:00
feat: support for external actions
This commit is contained in:
parent
83f29f7418
commit
31636c7afa
@ -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 '{}'"
|
||||
|
@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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 => {
|
||||
|
143
tests/cli/cli_external_actions.rs
Normal file
143
tests/cli/cli_external_actions.rs
Normal 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);
|
||||
}
|
@ -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;
|
||||
|
Loading…
x
Reference in New Issue
Block a user