Skip to content

feat(actions): improve actions behavior and add support for exec #689

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Aug 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions cable/unix/files.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ env = { BAT_THEME = "ansi" }

[keybindings]
shortcut = "f1"
# ctrl-f12 = "actions:edit"
f12 = "actions:edit"

# [actions.edit]
# description = "Opens the selected entries with Neovim"
# command = "nvim {}"
# mode = "execute"
[actions.edit]
description = "Opens the selected entries with the default editor (falls back to vim)"
command = "${EDITOR:-vim} '{}'"
# use `mode = "fork"` if you want to return to tv afterwards
mode = "execute"
8 changes: 2 additions & 6 deletions docs/01-Users/07-channels.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,6 @@ separator = "\n"
description = "View files with less"
command = "less {}"
mode = "fork"
output_mode = "discard"
separator = " "
# example: inputs "file1" and "file 2" will generate the command
# less file1 file 2
Expand All @@ -290,11 +289,8 @@ separator = " "
- `description` - Optional description of what the action does
- `command` - Command template to execute (supports [templating syntax](#templating-syntax))
- `mode` - Execution mode:
- `execute` - Run command and wait for completion (inherits terminal)
- `fork` - Run command in background (**not yet implemented**)
- `output_mode` - How to handle command output (when `mode = "fork"`, **not yet implemented**):
- `capture` - Capture output for processing
- `discard` - Discard output silently
- `execute` - Run command and become the new process
- `fork` - Run command in a subprocess, allowing you to return to tv upon completion
- `separator` - Character(s) to use when joining **multiple selected entries** when using complex template processing,
depending on the entries content it might be beneficial to change to another
one (default: `" "` - space)
116 changes: 90 additions & 26 deletions television/app.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
use std::{thread::sleep, time::Duration};

use crate::{
action::Action,
cable::Cable,
channels::{entry::Entry, prototypes::ActionSpec},
channels::{
entry::Entry,
prototypes::{ActionSpec, ExecutionMode},
},
config::layers::LayeredConfig,
event::{Event, EventLoop, InputEvent, Key, MouseInputEvent},
event::{
ControlEvent, Event, EventLoop, InputEvent, Key, MouseInputEvent,
},
history::History,
render::{RenderingTask, UiState, render},
television::{Mode, Television},
tui::{IoStream, Tui, TuiMode},
utils::command::execute_action,
};
use anyhow::Result;
use rustc_hash::FxHashSet;
Expand All @@ -19,8 +27,6 @@ pub struct App {
pub television: Television,
/// A flag that indicates whether the application should quit during the next frame.
should_quit: bool,
/// A flag that indicates whether the application should suspend during the next frame.
should_suspend: bool,
/// A sender channel for actions.
///
/// This is made public so that tests, for instance, can send actions to a running application.
Expand All @@ -29,8 +35,8 @@ pub struct App {
action_rx: mpsc::UnboundedReceiver<Action>,
/// The receiver channel for events.
event_rx: mpsc::UnboundedReceiver<Event<Key>>,
/// A sender channel to abort the event loop.
event_abort_tx: mpsc::UnboundedSender<()>,
/// A sender channel to control the event loop.
event_control_tx: mpsc::UnboundedSender<ControlEvent>,
/// A sender channel for rendering tasks.
render_tx: mpsc::UnboundedSender<RenderingTask>,
/// The receiver channel for rendering tasks.
Expand Down Expand Up @@ -131,11 +137,10 @@ impl App {
let mut app = Self {
television,
should_quit: false,
should_suspend: false,
action_tx,
action_rx,
event_rx,
event_abort_tx,
event_control_tx: event_abort_tx,
render_tx,
render_rx,
ui_state_rx,
Expand Down Expand Up @@ -287,7 +292,7 @@ impl App {
let event_loop =
EventLoop::new(self.television.merged_config.tick_rate);
self.event_rx = event_loop.rx;
self.event_abort_tx = event_loop.abort_tx;
self.event_control_tx = event_loop.control_tx;
}

// Start watch timer if configured
Expand Down Expand Up @@ -349,7 +354,7 @@ impl App {
if self.should_quit {
// send a termination signal to the event loop
if !headless {
self.event_abort_tx.send(())?;
self.event_control_tx.send(ControlEvent::Abort)?;
}

// persist search history
Expand Down Expand Up @@ -482,11 +487,9 @@ impl App {
}
}
Action::Suspend => {
self.should_suspend = true;
self.render_tx.send(RenderingTask::Suspend)?;
}
Action::Resume => {
self.should_suspend = false;
self.render_tx.send(RenderingTask::Resume)?;
}
Action::SelectAndExit => {
Expand Down Expand Up @@ -579,23 +582,32 @@ impl App {
.merged_config
.channel_actions
.get(action_name)
.cloned()
{
// 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)?;
// Pass entries directly to be processed by execute_action
return Ok(ActionOutcome::ExternalAction(
action_spec.clone(),
selected_entries,
));
match action_spec.mode {
// suspend the TUI and execute the action
ExecutionMode::Fork => {
self.run_external_command_fork(
&action_spec,
&selected_entries,
)?;
}
// clean up and exit the TUI and execute the action
ExecutionMode::Execute => {
self.run_external_command_execute(
&action_spec,
&selected_entries,
)?;
}
}
}
} else {
debug!("No entries available for external action");
self.action_tx.send(Action::Error(
"No entry available for external action"
.to_string(),
))?;
}
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);
}
_ => {}
}
Expand All @@ -622,6 +634,58 @@ impl App {
Ok(ActionOutcome::None)
}

fn run_external_command_fork(
&self,
action_spec: &ActionSpec,
entries: &FxHashSet<Entry>,
) -> Result<()> {
// suspend the event loop
self.event_control_tx
.send(ControlEvent::Pause)
.map_err(|e| {
error!("Failed to suspend event loop: {}", e);
anyhow::anyhow!("Failed to suspend event loop: {}", e)
})?;

// execute the external command in a separate process
execute_action(action_spec, entries).map_err(|e| {
error!("Failed to execute external action: {}", e);
anyhow::anyhow!("Failed to execute external action: {}", e)
})?;
// resume the event loop
self.event_control_tx
.send(ControlEvent::Resume)
.map_err(|e| {
anyhow::anyhow!("Failed to resume event loop: {}", e)
})?;
// resume the TUI (after the event loop so as not to produce any artifacts)
self.render_tx.send(RenderingTask::Resume)?;

Ok(())
}

fn run_external_command_execute(
&mut self,
action_spec: &ActionSpec,
entries: &FxHashSet<Entry>,
) -> Result<()> {
// cleanup
self.render_tx.send(RenderingTask::Quit)?;
// wait for the rendering task to finish
if let Some(rendering_task) = self.render_task.take() {
while !rendering_task.is_finished() {
sleep(Duration::from_millis(10));
}
}

execute_action(action_spec, entries).map_err(|e| {
error!("Failed to execute external action: {}", e);
anyhow::anyhow!("Failed to execute external action: {}", e)
})?;

Ok(())
}

/// Maybe select the first entry if there is only one entry available.
fn maybe_select_1(&mut self) -> Option<ActionOutcome> {
debug!("Automatically selecting the first entry");
Expand Down
20 changes: 3 additions & 17 deletions television/channels/prototypes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,19 +177,6 @@ pub enum ExecutionMode {
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,
}

fn default_separator() -> String {
" ".to_string()
}
Expand All @@ -203,10 +190,9 @@ pub struct ActionSpec {
/// How to execute the command
#[serde(default)]
pub mode: ExecutionMode,
/// How to handle command output
#[serde(default)]
pub output_mode: OutputMode,
/// Separator to use when joining multiple selected entries
/// Separator to use when formatting multiple entries into the command
///
/// Example: `rm file1+SEPARATOR+file2+SEPARATOR+file3`
#[serde(default = "default_separator")]
pub separator: String,
// TODO: add `requirements` (see `prototypes::BinaryRequirement`)
Expand Down
51 changes: 44 additions & 7 deletions television/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ impl Display for Key {
#[allow(clippy::module_name_repetitions)]
pub struct EventLoop {
pub rx: mpsc::UnboundedReceiver<Event<Key>>,
pub abort_tx: mpsc::UnboundedSender<()>,
pub control_tx: mpsc::UnboundedSender<ControlEvent>,
}

struct PollFuture {
Expand Down Expand Up @@ -256,12 +256,21 @@ fn flush_existing_events() {
}
}

pub enum ControlEvent {
/// Abort the event loop
Abort,
/// Pause the event loop
Pause,
/// Resume the event loop
Resume,
}

impl EventLoop {
pub fn new(tick_rate: u64) -> Self {
let (tx, rx) = mpsc::unbounded_channel();
let tick_interval = Duration::from_secs_f64(1.0 / tick_rate as f64);

let (abort, mut abort_recv) = mpsc::unbounded_channel();
let (control_tx, mut control_rx) = mpsc::unbounded_channel();

flush_existing_events();

Expand All @@ -272,10 +281,38 @@ impl EventLoop {

tokio::select! {
// if we receive a message on the abort channel, stop the event loop
_ = abort_recv.recv() => {
tx.send(Event::Closed).unwrap_or_else(|_| warn!("Unable to send Closed event"));
tx.send(Event::Tick).unwrap_or_else(|_| warn!("Unable to send Tick event"));
break;
Some(control_event) = control_rx.recv() => {
match control_event {
ControlEvent::Abort => {
debug!("Received Abort control event");
tx.send(Event::Closed).unwrap_or_else(|_| warn!("Unable to send Closed event"));
tx.send(Event::Tick).unwrap_or_else(|_| warn!("Unable to send Tick event"));
break;
},
ControlEvent::Pause => {
debug!("Received Pause control event");
// Stop processing events until resumed
while let Some(event) = control_rx.recv().await {
match event {
ControlEvent::Resume => {
debug!("Received Resume control event");
// flush any leftover events
flush_existing_events();
break; // Exit pause loop
},
ControlEvent::Abort => {
debug!("Received Abort control event during Pause");
tx.send(Event::Closed).unwrap_or_else(|_| warn!("Unable to send Closed event"));
tx.send(Event::Tick).unwrap_or_else(|_| warn!("Unable to send Tick event"));
return;
},
ControlEvent::Pause => {}
}
}
},
// these should always be captured by the pause loop
ControlEvent::Resume => {},
}
},
_ = signal::ctrl_c() => {
debug!("Received SIGINT");
Expand Down Expand Up @@ -319,7 +356,7 @@ impl EventLoop {
//tx,
rx,
//tick_rate,
abort_tx: abort,
control_tx,
}
}
}
Expand Down
17 changes: 0 additions & 17 deletions television/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ use television::{
gh::update_local_channels,
television::Mode,
utils::clipboard::CLIPBOARD,
utils::command::execute_action,
utils::{
shell::{
Shell, completion_script, render_autocomplete_script_template,
Expand Down Expand Up @@ -100,22 +99,6 @@ async fn main() -> Result<()> {
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, entries)) = output.external_action {
debug!("Executing external action command after terminal cleanup");

let status = execute_action(&action_spec, &entries)?;
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 {
Expand Down
Loading
Loading