825 lines
27 KiB
Rust

use anyhow::Result;
use clap::Parser;
use std::env;
use std::io::{BufWriter, IsTerminal, Write, stdout};
use std::path::PathBuf;
use std::process::exit;
use television::cable::cable_empty_exit;
use television::{
action::Action,
app::{App, AppOptions},
cable::{Cable, load_cable},
channels::prototypes::{
ChannelPrototype, CommandSpec, PreviewSpec, Template, UiSpec,
},
cli::post_process,
cli::{
PostProcessedCli,
args::{Cli, Command},
guess_channel_from_prompt, list_channels,
},
config::{Config, ConfigEnv, merge_keybindings},
errors::os_error_exit,
features::FeatureFlags,
gh::update_local_channels,
television::Mode,
utils::clipboard::CLIPBOARD,
utils::{
shell::{
Shell, completion_script, render_autocomplete_script_template,
},
stdin::is_readable_stdin,
},
};
use tracing::{debug, info};
#[tokio::main(flavor = "multi_thread")]
async fn main() -> Result<()> {
television::errors::init()?;
television::logging::init()?;
debug!("\n\n==== NEW SESSION =====\n");
// process the CLI arguments
let cli = Cli::parse();
debug!("CLI: {:?}", cli);
let readable_stdin = is_readable_stdin();
let args = post_process(cli, readable_stdin);
debug!("PostProcessedCli: {:?}", args);
// load the configuration file
debug!("Loading configuration...");
let mut config =
Config::new(&ConfigEnv::init()?, args.config_file.as_deref())?;
// override configuration values with provided CLI arguments
debug!("Applying CLI overrides...");
apply_cli_overrides(&args, &mut config);
// handle subcommands
debug!("Handling subcommands...");
if let Some(subcommand) = &args.command {
handle_subcommand(subcommand, &config)?;
}
debug!("Loading cable channels...");
let cable = load_cable(&config.application.cable_dir).unwrap_or_default();
// optionally change the working directory
if let Some(ref working_dir) = args.working_directory {
set_current_dir(working_dir)
.unwrap_or_else(|e| os_error_exit(&e.to_string()));
}
// determine the channel to use based on the CLI arguments and configuration
debug!("Determining channel...");
let channel_prototype =
determine_channel(&args, &config, readable_stdin, Some(&cable));
CLIPBOARD.with(<_>::default);
debug!("Creating application...");
// Determine the effective watch interval (CLI override takes precedence)
let watch_interval =
args.watch_interval.unwrap_or(channel_prototype.watch);
let options = AppOptions::new(
args.exact,
args.select_1,
args.take_1,
args.take_1_fast,
args.no_remote,
args.no_preview,
args.preview_size,
config.application.tick_rate,
watch_interval,
args.height,
args.width,
args.inline,
);
let mut app = App::new(
channel_prototype,
config,
args.input.clone(),
options,
cable,
);
// If the user requested to show the remote control on startup, switch the
// television into Remote Control mode before the application event loop
// begins. This mirrors the behaviour of toggling the remote control via
// the corresponding keybinding after launch, ensuring the panel is
// visible from the start.
// TODO: This is a hack, preview is not initialised yet, find a better way to do it.
if args.show_remote && app.television.remote_control.is_some() {
app.television.mode = Mode::RemoteControl;
}
stdout().flush()?;
debug!("Running application...");
let output = app.run(stdout().is_terminal(), false).await?;
info!("App output: {:?}", output);
let stdout_handle = stdout().lock();
let mut bufwriter = BufWriter::new(stdout_handle);
if let Some(entries) = output.selected_entries {
for entry in &entries {
writeln!(
bufwriter,
"{}",
entry.output(&app.television.channel.prototype.source.output)
)?;
}
}
bufwriter.flush()?;
exit(0);
}
/// Apply overrides from the CLI arguments to the configuration.
///
/// This function mutates the configuration in place.
fn apply_cli_overrides(args: &PostProcessedCli, config: &mut Config) {
if let Some(cable_dir) = &args.cable_dir {
config.application.cable_dir.clone_from(cable_dir);
}
if let Some(tick_rate) = args.tick_rate {
config.application.tick_rate = tick_rate;
}
if args.global_history {
config.application.global_history = true;
}
// Handle preview panel flags
if args.no_preview {
config.ui.features.disable(FeatureFlags::PreviewPanel);
config.keybindings.remove(&Action::TogglePreview);
} else if args.hide_preview {
config.ui.features.hide(FeatureFlags::PreviewPanel);
} else if args.show_preview {
config.ui.features.enable(FeatureFlags::PreviewPanel);
}
if let Some(ps) = args.preview_size {
config.ui.preview_panel.size = ps;
}
// Handle status bar flags
if args.no_status_bar {
config.ui.features.disable(FeatureFlags::StatusBar);
config.keybindings.remove(&Action::ToggleStatusBar);
} else if args.hide_status_bar {
config.ui.features.hide(FeatureFlags::StatusBar);
} else if args.show_status_bar {
config.ui.features.enable(FeatureFlags::StatusBar);
}
// Handle remote control flags
if args.no_remote {
config.ui.features.disable(FeatureFlags::RemoteControl);
config.keybindings.remove(&Action::ToggleRemoteControl);
} else if args.hide_remote {
config.ui.features.hide(FeatureFlags::RemoteControl);
} else if args.show_remote {
config.ui.features.enable(FeatureFlags::RemoteControl);
}
// Handle help panel flags
if args.no_help_panel {
config.ui.features.disable(FeatureFlags::HelpPanel);
config.keybindings.remove(&Action::ToggleHelp);
} else if args.hide_help_panel {
config.ui.features.hide(FeatureFlags::HelpPanel);
} else if args.show_help_panel {
config.ui.features.enable(FeatureFlags::HelpPanel);
}
if let Some(keybindings) = &args.keybindings {
config.keybindings =
merge_keybindings(config.keybindings.clone(), keybindings);
}
config.ui.ui_scale = args.ui_scale.unwrap_or(config.ui.ui_scale);
if let Some(input_header) = &args.input_header {
if let Ok(t) = Template::parse(input_header) {
config.ui.input_header = Some(t);
}
}
if let Some(input_prompt) = &args.input_prompt {
config.ui.input_prompt.clone_from(input_prompt);
}
if let Some(preview_header) = &args.preview_header {
if let Ok(t) = Template::parse(preview_header) {
config.ui.preview_panel.header = Some(t);
}
}
if let Some(preview_footer) = &args.preview_footer {
if let Ok(t) = Template::parse(preview_footer) {
config.ui.preview_panel.footer = Some(t);
}
}
if let Some(layout) = args.layout {
config.ui.orientation = layout;
}
}
pub fn set_current_dir(path: &PathBuf) -> Result<()> {
env::set_current_dir(path)?;
Ok(())
}
pub fn handle_subcommand(command: &Command, config: &Config) -> Result<()> {
match command {
Command::ListChannels => {
list_channels(&config.application.cable_dir);
exit(0);
}
Command::InitShell { shell } => {
let target_shell = Shell::from(shell);
// the completion scripts for the various shells are templated
// so that it's possible to override the keybindings triggering
// shell autocomplete and command history in tv
let script = render_autocomplete_script_template(
target_shell,
completion_script(target_shell)?,
&config.shell_integration,
)?;
println!("{script}");
exit(0);
}
Command::UpdateChannels { force } => {
update_local_channels(force)?;
exit(0);
}
}
}
/// Creates a stdin channel prototype with optional preview configuration
fn create_stdin_channel(
args: &PostProcessedCli,
config: &Config,
) -> ChannelPrototype {
debug!("Using stdin channel");
let stdin_preview =
args.preview_command_override.as_ref().map(|preview_cmd| {
PreviewSpec::new(
CommandSpec::from(preview_cmd.clone()),
args.preview_offset_override.clone(),
)
});
let mut prototype =
ChannelPrototype::stdin(stdin_preview, args.source_entry_delimiter);
// Inherit UI features from global config (which has CLI overrides applied)
let mut features = config.ui.features.clone();
if args.preview_command_override.is_some() {
features.enable(FeatureFlags::PreviewPanel);
} else {
features.disable(FeatureFlags::PreviewPanel);
}
// Set UI specification to properly control feature visibility
let mut ui_spec = UiSpec::from(&config.ui);
ui_spec.features = Some(features);
prototype.ui = Some(ui_spec);
prototype
}
/// Default header for ad-hoc channels when no custom header is provided
const DEFAULT_ADHOC_CHANNEL_HEADER: &str = "Custom Channel";
/// Creates an ad-hoc channel prototype from CLI arguments
fn create_adhoc_channel(
args: &PostProcessedCli,
config: &Config,
) -> ChannelPrototype {
debug!("Creating ad-hoc channel with source command override");
let source_cmd = args.source_command_override.as_ref().unwrap();
// Create base prototype
let mut prototype = ChannelPrototype::new("custom", source_cmd.raw());
// Determine input header
let input_header = args
.input_header
.as_ref()
.and_then(|ih| Template::parse(ih).ok())
.unwrap_or_else(|| {
Template::parse(DEFAULT_ADHOC_CHANNEL_HEADER).unwrap()
});
// Inherit UI features from global config (which has CLI overrides applied)
let mut features = config.ui.features.clone();
if args.preview_command_override.is_some() {
features.enable(FeatureFlags::PreviewPanel);
} else {
features.disable(FeatureFlags::PreviewPanel);
}
// Set UI specification
let mut ui_spec = UiSpec::from(&config.ui);
ui_spec.input_header = Some(input_header);
ui_spec.features = Some(features);
prototype.ui = Some(ui_spec);
prototype
}
/// Applies source-related CLI overrides to the channel prototype
fn apply_source_overrides(
prototype: &mut ChannelPrototype,
args: &PostProcessedCli,
) {
if let Some(source_cmd) = &args.source_command_override {
prototype.source.command = CommandSpec::from(source_cmd.clone());
}
if let Some(source_display) = &args.source_display_override {
prototype.source.display = Some(source_display.clone());
}
if let Some(source_output) = &args.source_output_override {
prototype.source.output = Some(source_output.clone());
}
}
/// Applies preview-related CLI overrides to the channel prototype
fn apply_preview_overrides(
prototype: &mut ChannelPrototype,
args: &PostProcessedCli,
) {
if let Some(preview_cmd) = &args.preview_command_override {
if let Some(ref mut preview) = prototype.preview {
preview.command = CommandSpec::from(preview_cmd.clone());
} else {
prototype.preview = Some(PreviewSpec::new(
CommandSpec::from(preview_cmd.clone()),
None,
));
}
}
if let Some(preview_offset) = &args.preview_offset_override {
if let Some(ref mut preview) = prototype.preview {
preview.offset = Some(preview_offset.clone());
}
}
}
/// Applies UI-related CLI overrides to the channel prototype
fn apply_ui_overrides(
prototype: &mut ChannelPrototype,
args: &PostProcessedCli,
) {
let mut ui_changes_needed = false;
let mut ui_spec = prototype.ui.clone().unwrap_or(UiSpec {
ui_scale: None,
features: None,
orientation: None,
input_bar_position: None,
input_header: None,
input_prompt: None,
preview_panel: None,
status_bar: None,
help_panel: None,
remote_control: None,
});
// Apply input header override
if let Some(input_header_str) = &args.input_header {
if let Ok(template) = Template::parse(input_header_str) {
ui_spec.input_header = Some(template);
ui_changes_needed = true;
}
}
// Apply input prompt override
if let Some(input_prompt_str) = &args.input_prompt {
ui_spec.input_prompt = Some(input_prompt_str.clone());
ui_changes_needed = true;
}
// Apply layout/orientation override
if let Some(layout) = args.layout {
ui_spec.orientation = Some(layout);
ui_changes_needed = true;
}
// Apply preview panel overrides (header and footer)
if args.preview_header.is_some() || args.preview_footer.is_some() {
let mut preview_panel =
ui_spec.preview_panel.clone().unwrap_or_default();
if let Some(preview_header_str) = &args.preview_header {
if let Ok(template) = Template::parse(preview_header_str) {
preview_panel.header = Some(template);
}
}
if let Some(preview_footer_str) = &args.preview_footer {
if let Ok(template) = Template::parse(preview_footer_str) {
preview_panel.footer = Some(template);
}
}
ui_spec.preview_panel = Some(preview_panel);
ui_changes_needed = true;
}
// Apply the UI specification if any changes were made
if ui_changes_needed {
prototype.ui = Some(ui_spec);
}
}
/// Determines which channel prototype to use based on CLI arguments and configuration.
///
/// This function handles multiple modes of operation:
/// 1. **Stdin mode**: When stdin is readable, creates a stdin channel
/// 2. **Autocomplete mode**: When autocomplete prompt is provided, guesses channel from prompt
/// 3. **Ad-hoc mode**: When no channel is specified but source command is provided
/// 4. **Channel mode**: Uses a named channel from CLI args or config default
///
/// After determining the base channel, it applies all relevant CLI overrides.
pub fn determine_channel(
args: &PostProcessedCli,
config: &Config,
readable_stdin: bool,
cable: Option<&Cable>,
) -> ChannelPrototype {
// Determine the base channel prototype
let mut channel_prototype = if readable_stdin {
create_stdin_channel(args, config)
} else if let Some(prompt) = &args.autocomplete_prompt {
if cable.is_none() {
cable_empty_exit()
}
debug!("Using autocomplete prompt: {:?}", prompt);
let prototype = guess_channel_from_prompt(
prompt,
&config.shell_integration.commands,
&config.shell_integration.fallback_channel,
cable.unwrap(),
);
debug!("Using guessed channel: {:?}", prototype);
prototype
} else if args.channel.is_none() && args.source_command_override.is_some()
{
create_adhoc_channel(args, config)
} else {
if cable.is_none() {
cable_empty_exit()
}
let channel_name = args
.channel
.as_ref()
.unwrap_or(&config.application.default_channel);
debug!("Using channel: {:?}", channel_name);
cable.unwrap().get_channel(channel_name)
};
// Apply CLI overrides to the prototype
apply_source_overrides(&mut channel_prototype, args);
apply_preview_overrides(&mut channel_prototype, args);
apply_ui_overrides(&mut channel_prototype, args);
// Apply watch interval override
if let Some(watch_interval) = args.watch_interval {
channel_prototype.watch = watch_interval;
}
channel_prototype
}
#[cfg(test)]
mod tests {
use rustc_hash::FxHashMap;
use television::channels::prototypes::{
ChannelPrototype, CommandSpec, PreviewSpec, Template,
};
use super::*;
fn assert_is_correct_channel(
args: &PostProcessedCli,
config: &Config,
readable_stdin: bool,
expected_channel: &ChannelPrototype,
cable_channels: Option<Cable>,
) {
let channels: Cable =
cable_channels.unwrap_or(Cable::from_prototypes(vec![
ChannelPrototype::new("files", "fd -t f"),
ChannelPrototype::new("dirs", "ls"),
ChannelPrototype::new("git", "git status"),
]));
let channel =
determine_channel(args, config, readable_stdin, Some(&channels));
assert_eq!(
channel.metadata.name, expected_channel.metadata.name,
"Expected {:?} but got {:?}",
expected_channel.metadata.name, channel.metadata.name
);
}
#[test]
/// Test that the channel is stdin when stdin is readable
fn test_determine_channel_readable_stdin() {
let args = PostProcessedCli::default();
let config = Config::default();
assert_is_correct_channel(
&args,
&config,
true,
&ChannelPrototype::new("stdin", "cat"),
None,
);
}
#[test]
fn test_determine_channel_stdin_disables_preview_without_command() {
let args = PostProcessedCli::default();
let config = Config::default();
let cable = Cable::from_prototypes(vec![]);
let channel = determine_channel(&args, &config, true, Some(&cable));
assert_eq!(channel.metadata.name, "stdin");
assert!(channel.preview.is_none()); // No preview spec should be created
// Check that preview feature is explicitly disabled
assert!(channel.ui.is_some());
let ui_spec = channel.ui.as_ref().unwrap();
assert!(ui_spec.features.is_some());
let features = ui_spec.features.as_ref().unwrap();
assert!(!features.is_enabled(FeatureFlags::PreviewPanel));
assert!(!features.is_visible(FeatureFlags::PreviewPanel));
}
#[test]
fn test_determine_channel_stdin_enables_preview_with_command() {
let args = PostProcessedCli {
preview_command_override: Some(Template::parse("cat {}").unwrap()),
..Default::default()
};
let config = Config::default();
let cable = Cable::from_prototypes(vec![]);
let channel = determine_channel(&args, &config, true, Some(&cable));
assert_eq!(channel.metadata.name, "stdin");
assert!(channel.preview.is_some()); // Preview spec should be created
// Check that preview feature is enabled and visible
assert!(channel.ui.is_some());
let ui_spec = channel.ui.as_ref().unwrap();
assert!(ui_spec.features.is_some());
let features = ui_spec.features.as_ref().unwrap();
assert!(features.is_enabled(FeatureFlags::PreviewPanel));
assert!(features.is_visible(FeatureFlags::PreviewPanel));
}
#[test]
fn test_determine_channel_autocomplete_prompt() {
let autocomplete_prompt = Some("cd".to_string());
let expected_channel = ChannelPrototype::new("dirs", "ls {}");
let args = PostProcessedCli {
autocomplete_prompt,
..Default::default()
};
let mut config = Config {
shell_integration:
television::config::shell_integration::ShellIntegrationConfig {
fallback_channel: "files".to_string(),
commands: FxHashMap::default(),
channel_triggers: {
let mut m = FxHashMap::default();
m.insert("dirs".to_string(), vec!["cd".to_string()]);
m
},
keybindings: FxHashMap::default(),
},
..Default::default()
};
config.shell_integration.merge_triggers();
assert_is_correct_channel(
&args,
&config,
false,
&expected_channel,
None,
);
}
#[test]
fn test_determine_channel_standard_case() {
let channel = Some(String::from("dirs"));
let args = PostProcessedCli {
channel,
..Default::default()
};
let config = Config::default();
assert_is_correct_channel(
&args,
&config,
false,
&ChannelPrototype::new("dirs", "ls {}"),
None,
);
}
#[test]
fn test_determine_channel_config_fallback() {
let args = PostProcessedCli {
channel: None,
..Default::default()
};
let mut config = Config::default();
config.application.default_channel = String::from("dirs");
assert_is_correct_channel(
&args,
&config,
false,
&ChannelPrototype::new("dirs", "ls"),
None,
);
}
#[test]
fn test_determine_channel_with_cli_preview() {
let preview_command = Template::parse("echo hello").unwrap();
let preview_spec = PreviewSpec::new(
CommandSpec::new(
vec![preview_command.clone()],
false,
FxHashMap::default(),
),
None,
);
let args = PostProcessedCli {
channel: Some(String::from("dirs")),
preview_command_override: Some(preview_command),
..Default::default()
};
let config = Config::default();
let expected_prototype = ChannelPrototype::new("dirs", "ls")
.with_preview(Some(preview_spec));
assert_is_correct_channel(
&args,
&config,
false,
&expected_prototype,
None,
);
}
#[test]
fn test_determine_channel_adhoc_with_source_command() {
let args = PostProcessedCli {
channel: None,
source_command_override: Some(
Template::parse("fd -t f -H").unwrap(),
),
..Default::default()
};
let config = Config::default();
let channel =
determine_channel(&args, &config, false, Some(&Cable::default()));
assert_eq!(channel.metadata.name, "custom");
assert_eq!(channel.source.command.inner[0].raw(), "fd -t f -H");
// Check that UI options are set using the new features system
assert!(channel.ui.is_some());
let ui_spec = channel.ui.as_ref().unwrap();
assert!(ui_spec.features.is_some());
let features = ui_spec.features.as_ref().unwrap();
// Preview should be disabled since no preview command was provided
assert!(!features.is_enabled(FeatureFlags::PreviewPanel));
assert_eq!(
ui_spec.input_header,
Some(Template::parse("Custom Channel").unwrap())
);
}
#[test]
fn test_apply_cli_overrides() {
let mut config = Config::default();
let args = PostProcessedCli {
tick_rate: Some(100_f64),
no_preview: true,
input_header: Some("Input Header".to_string()),
preview_header: Some("Preview Header".to_string()),
preview_footer: Some("Preview Footer".to_string()),
..Default::default()
};
apply_cli_overrides(&args, &mut config);
assert_eq!(config.application.tick_rate, 100_f64);
assert!(!config.ui.features.is_enabled(FeatureFlags::PreviewPanel));
assert_eq!(
config.ui.input_header,
Some(Template::parse("Input Header").unwrap())
);
assert_eq!(
config.ui.preview_panel.header,
Some(Template::parse("Preview Header").unwrap())
);
assert_eq!(
config.ui.preview_panel.footer,
Some(Template::parse("Preview Footer").unwrap())
);
}
#[test]
fn test_determine_channel_cli_ui_overrides() {
use television::screen::layout::Orientation;
// Create a channel with default UI settings
let mut channel_prototype = ChannelPrototype::new("test", "ls");
// Set some initial UI values that should be overridden
channel_prototype.ui = Some(UiSpec {
ui_scale: None,
features: None,
orientation: Some(Orientation::Portrait),
input_bar_position: None,
input_header: Some(Template::parse("Original Header").unwrap()),
input_prompt: None,
preview_panel: Some(television::config::ui::PreviewPanelConfig {
size: 50,
header: Some(
Template::parse("Original Preview Header").unwrap(),
),
footer: Some(
Template::parse("Original Preview Footer").unwrap(),
),
scrollbar: false,
}),
status_bar: None,
help_panel: None,
remote_control: None,
});
let cable = Cable::from_prototypes(vec![channel_prototype]);
// Test CLI arguments that should override channel settings
let args = PostProcessedCli {
channel: Some("test".to_string()),
input_header: Some("CLI Input Header".to_string()),
preview_header: Some("CLI Preview Header".to_string()),
preview_footer: Some("CLI Preview Footer".to_string()),
layout: Some(Orientation::Landscape),
..Default::default()
};
let config = Config::default();
let result_channel =
determine_channel(&args, &config, false, Some(&cable));
// Verify that CLI arguments overrode the channel prototype's UI settings
assert!(result_channel.ui.is_some());
let ui_spec = result_channel.ui.as_ref().unwrap();
assert_eq!(
ui_spec.input_header,
Some(Template::parse("CLI Input Header").unwrap())
);
assert_eq!(ui_spec.orientation, Some(Orientation::Landscape));
assert!(ui_spec.preview_panel.is_some());
let preview_panel = ui_spec.preview_panel.as_ref().unwrap();
assert_eq!(
preview_panel.header,
Some(Template::parse("CLI Preview Header").unwrap())
);
assert_eq!(
preview_panel.footer,
Some(Template::parse("CLI Preview Footer").unwrap())
);
}
#[test]
fn test_apply_cli_overrides_ui_scale() {
// Test that the CLI ui_scale override is applied correctly
let mut config = Config::default();
let args = PostProcessedCli {
ui_scale: Some(90),
..Default::default()
};
apply_cli_overrides(&args, &mut config);
assert_eq!(config.ui.ui_scale, 90);
// Test that the config value is used when no CLI override is provided
let mut config = Config::default();
config.ui.ui_scale = 70;
let args = PostProcessedCli::default();
apply_cli_overrides(&args, &mut config);
assert_eq!(config.ui.ui_scale, 70);
}
}