Skip to content

feat: support for external actions #669

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

Open
wants to merge 2 commits into
base: main
Choose a base branch
from

Conversation

lalvarezt
Copy link
Contributor

@lalvarezt lalvarezt commented Jul 27, 2025

📺 PR Description

resolves #16; resolves #658, resolves #297
This PR adds support for definition of external actions, allowing the following

[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"
f10 = "copy"

[actions.thebatman]
description = "cats the file"
command = "bat '{}'"
env = { BAT_THEME = "ansi" }

[actions.lsman]
description = "show stats"
command = "ls '{}'"

[actions.copy]
description = "Copies filename to the clipboard"
command = "echo -n '{split:.:0}' | xsel -i -b"

Checklist

  • my commits and PR title follow the conventional commits format
  • if this is a new feature, I have added tests to consolidate the feature and prevent regressions
  • if this is a bug fix, I have added a test that reproduces the bug (if applicable)
  • I have added a reasonable amount of documentation to the code where appropriate

@alexpasmantier
Copy link
Owner

alexpasmantier commented Jul 27, 2025

This is awesome. It's pretty much what I was thinking of too which is great 😁 Here are the other things I had in mind for this summed up in part of a channel prototype:

# open the selected entry/ies in vim without leaving tv (i.e. exiting vim returns to tv)
# this is calling `fork` behind the scenes
[actions.open-in-vim]
description = "open the selected entry with vim"
command = "vim '{}'"
become = false

# this makes tv become vim (i.e. the tv process turns into vim so leaving vim will
# return you to the shell)
# this is calling `execve` behind the scenes
[actions.become-vim]
description = "exit tv and open the selected entry in vim"
command = "vim '{}'"
become = true

# when you don't care about the command's output
[actions.rm]
description = "delete a file"
command = "rm '{}'"
silent = true  # or 'verbose' or something like that

# when you actually care about the command's output
[actions.ls]
description = "list the entries in the currently selected directory"
command = "ls '{}'"
silent = false

These extra features shouldn't be added directly in this PR, but we should try to shape the code in a way that makes it easier to add them later on.

I also was thinking of adding a channel-like selection UI for the actions of a given channel, e.g.:

  • you're on the files channel and have your matching item
  • you want to perform an action that isn't tied to a keybinding and open a popup fuzzy finder with all actions for this channel as well as their description and any other useful metadata
  • you start typing, find your action and press enter to trigger it

The UI would be very similar to what the remote control already looks like. And the code would be very similar too (the remote control is more or less implemented as a hard-coded channel to reuse as much existing logic as possible).

What do you think?

PS: was considering either using libc directly or https://crates.io/crates/nix (at least for unix) unless there's a simpler way to do the equivalent of execve using the standard lib that I'm not aware of 🤔

@@ -12,3 +12,8 @@ env = { BAT_THEME = "ansi" }

[keybindings]
shortcut = "f1"
ctrl-f12 = "edit"
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should use a prefix here to avoid clashes with tv's builtin actions, e.g.:

[keybindings]
ctrl-f12 = "actions:edit"
ctrl-f11 = "actions:copy-entry-to-clipboard"  # the action defined below
ctrl-f10 = "copy-entry-to-clipboard"          # tv's builtin Action::CopyEntryToClipboard

[actions.edit]

[actions.copy-entry-to-clipboard]

This would also probably re-simplify deserialisation below.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yup, makes sense, thanks

Comment on lines +686 to +737
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(),
))?;
}
}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A couple of things here:

  • we possibly need to deal with multiple entries (the user might have selected 3 entries and wishes to execute rm on all three)
  • we won't always want to exit tv after performing an action (see PR main comment)

Comment on lines +121 to +122
let formatted_command =
action_spec.command.get_nth(0).format(&entry_value)?;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it kind of feels weird having to call get_nth(0) here but I guess that's how we ended up implementing multiple sources per-channel 🤷🏻

not a big deal

Comment on lines +132 to +135
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()?;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is where we don't always want to:

  • inherit std file descriptors
  • spawn a new process

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
2 participants