Merge 5da13a76b4646351615e123f792815fb090242d9 into 83f29f7418a7adb6f5ca9ee5ac057ef38728fa28

This commit is contained in:
LM 2025-07-28 14:09:55 +00:00 committed by GitHub
commit 28189a4e54
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 540 additions and 12 deletions

View File

@ -12,3 +12,9 @@ env = { BAT_THEME = "ansi" }
[keybindings]
shortcut = "f1"
ctrl-f12 = "actions:edit"
[actions.edit]
description = "Opens the selected entries with Neovim"
command = "nvim {}"
mode = "execute"

View File

@ -1,13 +1,10 @@
use crate::{event::Key, screen::constants::ACTION_PREFIX};
use serde::{Deserialize, Serialize};
use serde_with::{OneOrMany, serde_as};
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 +115,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 +364,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<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,
s if s.starts_with(ACTION_PREFIX) => {
let action_name = s.trim_start_matches(ACTION_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 +574,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,51 @@ impl App {
self.television.set_pattern("");
}
}
Action::ExternalAction(ref action_name) => {
debug!("External action triggered: {}", action_name);
if let Some(selected_entries) =
self.television.get_selected_entries()
{
if let Some(action_spec) = self
.television
.channel_prototype
.actions
.get(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::<Vec<String>>()
.join(" ");
return Ok(ActionOutcome::ExternalAction(
action_spec.clone(),
concatenated_entries,
));
}
}
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);
}
_ => {}
}
// 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,47 @@ 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 (tv stays open)
#[default]
Fork,
/// Replace the current process with the command (tv exits, command takes over)
Execute,
}
/// Output handling mode for external actions
#[derive(
Debug, Clone, Default, serde::Deserialize, serde::Serialize, PartialEq,
)]
#[serde(rename_all = "lowercase")]
pub enum OutputMode {
/// Capture output for processing
Capture,
/// Discard output silently
#[default]
Discard,
}
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, PartialEq)]
pub struct ActionSpec {
#[serde(default)]
pub description: Option<String>,
#[serde(flatten)]
pub command: CommandSpec,
/// How to execute the command
#[serde(default)]
pub mode: ExecutionMode,
/// How to handle command output
#[serde(default)]
pub output_mode: OutputMode,
// TODO: add `requirements` (see `prototypes::BinaryRequirement`)
}
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct ChannelKeyBindings {
/// Optional channel specific shortcut that, when pressed, switches directly to this channel.
@ -198,7 +239,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 +270,7 @@ impl ChannelPrototype {
keybindings: None,
watch: 0.0,
history: HistoryConfig::default(),
actions: FxHashMap::default(),
}
}
@ -259,6 +302,7 @@ impl ChannelPrototype {
keybindings: None,
watch: 0.0,
history: HistoryConfig::default(),
actions: FxHashMap::default(),
}
}
@ -758,4 +802,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()
)
);
}
}

View File

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

View File

@ -1,5 +1,5 @@
pub const POINTER_SYMBOL: &str = "> ";
pub const SELECTED_SYMBOL: &str = "";
pub const DESELECTED_SYMBOL: &str = " ";
pub const LOGO_WIDTH: u16 = 24;
pub const ACTION_PREFIX: &str = "actions:";

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

@ -1,10 +1,12 @@
use crate::{
channels::prototypes::{ActionSpec, ExecutionMode, OutputMode},
utils::shell::Shell,
};
use std::{collections::HashMap, process::Command};
#[cfg(not(unix))]
use tracing::warn;
use super::shell::Shell;
pub fn shell_command<S>(
command: &str,
interactive: bool,
@ -32,3 +34,52 @@ pub fn shell_command<S>(
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<std::process::ExitStatus> {
// 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,
);
// Execute based on execution mode
match action_spec.mode {
ExecutionMode::Execute => {
// For Execute mode, let the new process inherit file descriptors naturally
// TODO: use execve to replace current process
let mut child = cmd.spawn()?;
child.wait()
}
ExecutionMode::Fork => {
// For Fork mode, configure stdio based on output mode
match action_spec.output_mode {
OutputMode::Capture => {
// TODO: For now, inherit stdio (future: capture output for processing)
cmd.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit());
}
OutputMode::Discard => {
// Discard output silently
cmd.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
}
}
let mut child = cmd.spawn()?;
child.wait()
}
}
}

View File

@ -0,0 +1,151 @@
//! 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 '{}'"
mode = "execute"
[actions.lsman]
description = "show stats"
command = "ls '{}'"
mode = "execute"
"#;
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 '{}'"
mode = "execute"
[actions.lsman]
description = "show stats"
command = "ls '{}'"
mode = "execute"
"#;
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);
}

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;