mirror of
https://github.com/alexpasmantier/television.git
synced 2025-07-29 06:11:37 +00:00
Merge 5da13a76b4646351615e123f792815fb090242d9 into 83f29f7418a7adb6f5ca9ee5ac057ef38728fa28
This commit is contained in:
commit
28189a4e54
@ -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"
|
||||
|
@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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:";
|
||||
|
@ -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 => {
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
151
tests/cli/cli_external_actions.rs
Normal file
151
tests/cli/cli_external_actions.rs
Normal 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);
|
||||
}
|
@ -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