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 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 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
5 changes: 5 additions & 0 deletions cable/unix/files.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,8 @@ env = { BAT_THEME = "ansi" }

[keybindings]
shortcut = "f1"
ctrl-f12 = "edit"

[actions.edit]
description = "Opens the selected entry with Neovim"
command = "nvim '{}'"
70 changes: 67 additions & 3 deletions television/action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@ 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
Expand Down Expand Up @@ -118,6 +116,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.
Expand Down Expand Up @@ -365,10 +365,71 @@ 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,
_ => Action::ExternalAction(s),
};

Ok(action)
}
}

impl Action {
/// Returns a user-friendly description of the action for help panels and UI display.
///
Expand Down Expand Up @@ -460,6 +521,9 @@ impl Action {
// Mouse actions
Action::SelectEntryAtPosition(_, _) => "Select at position",
Action::MouseClickAt(_, _) => "Mouse click",

// External actions
Action::ExternalAction(_) => "External action",
}
}
}
Expand Down
68 changes: 67 additions & 1 deletion television/app.rs
Original file line number Diff line number Diff line change
@@ -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},
Expand Down Expand Up @@ -147,13 +150,15 @@ pub enum ActionOutcome {
EntriesWithExpect(FxHashSet<Entry>, Key),
Input(String),
None,
ExternalAction(ActionSpec, String),
}

/// The result of the application.
#[derive(Debug)]
pub struct AppOutput {
pub selected_entries: Option<FxHashSet<Entry>>,
pub expect_key: Option<Key>,
pub external_action: Option<(ActionSpec, String)>,
}

impl AppOutput {
Expand All @@ -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)),
},
}
}
Expand Down Expand Up @@ -669,6 +683,58 @@ impl App {
self.television.set_pattern("");
}
}
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(),
))?;
}
}
_ => {}
}
// Check if we're switching from remote control to channel mode
Expand Down
91 changes: 89 additions & 2 deletions television/channels/prototypes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<_>")]
Expand Down Expand Up @@ -159,6 +159,14 @@ impl CommandSpec {
}
}

#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, PartialEq)]
pub struct ActionSpec {
#[serde(default)]
pub description: Option<String>,
#[serde(flatten)]
pub command: CommandSpec,
}

#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct ChannelKeyBindings {
/// Optional channel specific shortcut that, when pressed, switches directly to this channel.
Expand Down Expand Up @@ -198,7 +206,8 @@ pub struct ChannelPrototype {
pub watch: f64,
#[serde(default)]
pub history: HistoryConfig,
// actions: Vec<Action>,
#[serde(default)]
pub actions: FxHashMap<String, ActionSpec>,
}

impl ChannelPrototype {
Expand Down Expand Up @@ -228,6 +237,7 @@ impl ChannelPrototype {
keybindings: None,
watch: 0.0,
history: HistoryConfig::default(),
actions: FxHashMap::default(),
}
}

Expand Down Expand Up @@ -259,6 +269,7 @@ impl ChannelPrototype {
keybindings: None,
watch: 0.0,
history: HistoryConfig::default(),
actions: FxHashMap::default(),
}
}

Expand Down Expand Up @@ -758,4 +769,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 = "thebatman"
f9 = "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()
)
);
}
}
Loading
Loading