feat: add external actions support for multiple items

This commit is contained in:
lalvarezt 2025-07-28 01:18:58 +02:00
parent c514494f6d
commit 4986dd00d3
7 changed files with 143 additions and 66 deletions

View File

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

View File

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

View File

@ -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::<Vec<String>>()
.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
)))?;
}
_ => {}
}

View File

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

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::{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: {:?}",

View File

@ -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()?;

View File

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