refactor!: all channels are now cable channels (#479)

- tv's default channel (when lauching `tv`) is now configurable via the
`default_channel` configuration option
- add `RUST_BACKTRACE=1` and `--nocapture` to ci testing for better
debugging
- remove all builtin channels and associated glue code as well as the
`ToCliChannel` and `ToUnitChannel` derive macros
- recode all builtin channels using shell commands (along with `fd`,
`bat`, and `rg`)
- add support for interactive shell commands inside cable channels
- drop the `send_to_channel` feature until further notice (will be
reimplemented later on in a more generic and customizable way)
This commit is contained in:
Alexandre Pasmantier 2025-04-27 23:50:14 +02:00 committed by GitHub
parent 1f0c178a2d
commit 67677fb917
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 386 additions and 2117 deletions

View File

@ -15,8 +15,9 @@
# General settings
# ----------------------------------------------------------------------------
frame_rate = 60 # DEPRECATED: this option is no longer used
frame_rate = 60 # DEPRECATED: this option is no longer used
tick_rate = 50
default_channel = "files"
[ui]
# Whether to use nerd font icons in the UI

View File

@ -11,14 +11,18 @@ jobs:
test:
name: Test Suite
runs-on: ubuntu-latest
env:
RUST_BACKTRACE: 1
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@nightly
- uses: Swatinem/rust-cache@v2
- name: Install fd
run: sudo apt install -y fd-find && sudo ln -s $(which fdfind) /usr/bin/fd
- name: Run tests
run: cargo test --locked --all-features --workspace
run: cargo test --locked --all-features --workspace -- --nocapture
rustfmt:
name: Rustfmt

2
Cargo.lock generated
View File

@ -2252,7 +2252,7 @@ dependencies = [
[[package]]
name = "television-derive"
version = "0.0.26"
version = "0.0.27"
dependencies = [
"proc-macro2",
"quote",

View File

@ -31,7 +31,7 @@ build = "build.rs"
path = "television/lib.rs"
[dependencies]
television-derive = { path = "television-derive", version = "0.0.26" }
television-derive = { path = "television-derive", version = "0.0.27" }
anyhow = "1.0"
base64 = "0.22.1"

View File

@ -8,12 +8,12 @@ use ratatui::prelude::{Line, Style};
use ratatui::style::Color;
use ratatui::widgets::{Block, BorderType, Borders, ListDirection, Padding};
use ratatui::Terminal;
use std::path::PathBuf;
use television::action::Action;
use television::channels::cable::CableChannelPrototype;
use television::channels::entry::into_ranges;
use television::channels::entry::{Entry, PreviewType};
use television::channels::OnAir;
use television::channels::{files::Channel, TelevisionChannel};
use television::channels::TelevisionChannel;
use television::config::{Config, ConfigEnv};
use television::screen::colors::ResultsColorscheme;
use television::screen::results::build_results_list;
@ -506,10 +506,9 @@ pub fn draw(c: &mut Criterion) {
let backend = TestBackend::new(width, height);
let terminal = Terminal::new(backend).unwrap();
let (tx, _) = tokio::sync::mpsc::unbounded_channel();
let mut channel =
TelevisionChannel::Files(Channel::new(vec![
PathBuf::from("."),
]));
let mut channel = TelevisionChannel::Cable(
CableChannelPrototype::default().into(),
);
channel.find("television");
// Wait for the channel to finish loading
let mut tv = Television::new(

View File

@ -1,4 +1,41 @@
# Files
[[cable_channel]]
name = "files"
source_command = "fd -t f"
preview_command = ":files:"
# Text
[[cable_channel]]
name = "text"
source_command = "rg . --no-heading --line-number"
preview_command = "bat -n --color=always {0} -H {1}"
preview_delimiter = ":"
# Directories
[[cable_channel]]
name = "dirs"
source_command = "fd -t d"
preview_command = "ls -la --color=always {}"
# Environment variables
[[cable_channel]]
name = "env"
source_command = "printenv"
preview_command = "cut -d= -f2 <<< ${0} | cut -d\" \" -f2- | sed 's/:/\\n/g'"
# Aliases
[[cable_channel]]
name = "aliases"
source_command = "alias"
interactive = true
# GIT
[[cable_channel]]
name = "git-repos"
# this is a MacOS version but feel free to change it to fit your needs
source_command = "fd -g .git -HL -t d -d 10 --prune ~ -E 'Library' -E 'Application Support' --exec dirname {}"
preview_command = "cd {} && git log -n 200 --pretty=medium --all --graph --color"
[[cable_channel]]
name = "git-diff"
source_command = "git diff --name-only"

View File

@ -1,41 +1,65 @@
# Files
[[cable_channel]]
name = "files"
source_command = "Get-ChildItem -Recurse -File | Select-Object -ExpandProperty FullName"
preview_command = ":files:"
# Directories
[[cable_channel]]
name = "dirs"
source_command = "Get-ChildItem -Recurse -Directory | Select-Object -ExpandProperty FullName"
preview_command = "ls -l {0}"
# Environment variables
[[cable_channel]]
name = "env"
source_command = "Get-ChildItem Env:"
# Aliases
[[cable_channel]]
name = "aliases"
source_command = "Get-Alias"
# GIT
[[cable_channel]]
name = "git-repos"
source_command = "Get-ChildItem -Path 'C:\\Users' -Recurse -Directory -Force -ErrorAction SilentlyContinue | Where-Object { Test-Path \"$($_.FullName)\\.git\" } | Select-Object -ExpandProperty FullName"
preview_command = "cd '{}' ; git log -n 200 --pretty=medium --all --graph --color"
[[cable_channel]]
name = "git-diff"
source_command = "git diff --name-only"
preview_command = "git diff --color=always -- {0}"
preview_command = "git diff --color=always {0}"
[[cable_channel]]
name = "git-reflog"
source_command = 'git reflog'
preview_command = 'git show -p --stat --pretty=fuller --color=always {0}'
source_command = "git reflog"
preview_command = "git show -p --stat --pretty=fuller --color=always {0}"
[[cable_channel]]
name = "git-log"
source_command = "git log --oneline --date=short --pretty=\"format:%h %s %an %cd\" \"$@\""
source_command = "git log --oneline --date=short --pretty='format:%h %s %an %cd'"
preview_command = "git show -p --stat --pretty=fuller --color=always {0}"
[[cable_channel]]
name = "git-branch"
source_command = "git --no-pager branch --all --format=\"%(refname:short)\""
source_command = "git branch --all --format='%(refname:short)'"
preview_command = "git show -p --stat --pretty=fuller --color=always {0}"
# Docker
[[cable_channel]]
name = "docker-images"
source_command = "docker image list --format \"{{.ID}}\""
source_command = "docker image ls --format '{{.ID}}'"
preview_command = "docker image inspect {0} | jq -C"
# Dotfiles (adapted to common Windows dotfile locations)
[[cable_channel]]
name = "my-dotfiles"
source_command = "Get-ChildItem -Recurse -File -Path \"$env:USERPROFILE\\AppData\\Roaming\\\""
preview_command = ":files:"
# Shell history
[[cable_channel]]
name = "zsh-history"
source_command = "tail -r $HISTFILE | cut -d\";\" -f 2-"
[[cable_channel]]
name = "bash-history"
source_command = "tail -r $HISTFILE"
[[cable_channel]]
name = "fish-history"
source_command = "fish -c 'history'"
name = "powershell-history"
source_command = "Get-Content (Get-PSReadLineOption).HistorySavePath | Select-Object -Last 500"

View File

@ -1,6 +1,6 @@
[package]
name = "television-derive"
version = "0.0.26"
version = "0.0.27"
edition = "2021"
description = "The revolution will be televised."
license = "MIT"

View File

@ -1,129 +1,6 @@
use proc_macro::TokenStream;
use quote::quote;
/// This macro generates a `CliChannel` enum and the necessary glue code
/// to convert into a `TelevisionChannel` member:
///
/// ```ignore
/// use television::channels::{TelevisionChannel, OnAir};
/// use television-derive::ToCliChannel;
/// use television::channels::{files, text};
///
/// #[derive(ToCliChannel)]
/// enum TelevisionChannel {
/// Files(files::Channel),
/// Text(text::Channel),
/// // ...
/// }
///
/// let television_channel: TelevisionChannel = CliTvChannel::Files.to_channel();
///
/// assert!(matches!(television_channel, TelevisionChannel::Files(_)));
/// ```
///
/// The `CliChannel` enum is used to select channels from the command line.
///
/// Any variant that should not be included in the CLI should be annotated with
/// `#[exclude_from_cli]`.
#[proc_macro_derive(ToCliChannel, attributes(exclude_from_cli))]
pub fn cli_channel_derive(input: TokenStream) -> TokenStream {
// Construct a representation of Rust code as a syntax tree
// that we can manipulate
let ast = syn::parse(input).unwrap();
// Build the trait implementation
impl_cli_channel(&ast)
}
fn has_attribute(attrs: &[syn::Attribute], attribute: &str) -> bool {
attrs.iter().any(|attr| attr.path().is_ident(attribute))
}
const EXCLUDE_FROM_CLI: &str = "exclude_from_cli";
fn impl_cli_channel(ast: &syn::DeriveInput) -> TokenStream {
// check that the struct is an enum
let variants = if let syn::Data::Enum(data_enum) = &ast.data {
&data_enum.variants
} else {
panic!("#[derive(CliChannel)] is only defined for enums");
};
// check that the enum has at least one variant
assert!(
!variants.is_empty(),
"#[derive(CliChannel)] requires at least one variant"
);
// create the CliTvChannel enum
let cli_enum_variants: Vec<_> = variants
.iter()
.filter(|variant| !has_attribute(&variant.attrs, EXCLUDE_FROM_CLI))
.map(|variant| {
let variant_name = &variant.ident;
quote! {
#variant_name
}
})
.collect();
let cli_enum = quote! {
use clap::ValueEnum;
use serde::{Deserialize, Serialize};
use strum::{Display, EnumIter, EnumString};
use std::default::Default;
#[derive(Debug, Clone, ValueEnum, EnumIter, EnumString, Default, Copy, PartialEq, Eq, Serialize, Deserialize, Display)]
#[strum(serialize_all = "kebab_case")]
pub enum CliTvChannel {
#[default]
#(#cli_enum_variants),*
}
};
// Generate the match arms for the `to_channel` method
let arms = variants.iter().filter(
|variant| !has_attribute(&variant.attrs, EXCLUDE_FROM_CLI),
).map(|variant| {
let variant_name = &variant.ident;
// Get the inner type of the variant, assuming it is the first field of the variant
if let syn::Fields::Unnamed(fields) = &variant.fields {
if fields.unnamed.len() == 1 {
// Get the inner type of the variant (e.g., EnvChannel)
let inner_type = &fields.unnamed[0].ty;
quote! {
CliTvChannel::#variant_name => TelevisionChannel::#variant_name(#inner_type::default())
}
} else {
panic!("Enum variants should have exactly one unnamed field.");
}
} else {
panic!("Enum variants expected to only have unnamed fields.");
}
});
let gen = quote! {
#cli_enum
impl CliTvChannel {
pub fn to_channel(self) -> TelevisionChannel {
match self {
#(#arms),*
}
}
pub fn all_channels() -> Vec<String> {
use strum::IntoEnumIterator;
Self::iter().map(|v| v.to_string()).collect()
}
}
};
gen.into()
}
/// This macro generates the `OnAir` trait implementation for the given enum.
///
/// The `OnAir` trait is used to interact with the different television channels
@ -287,95 +164,3 @@ fn impl_tv_channel(ast: &syn::DeriveInput) -> TokenStream {
trait_impl.into()
}
/// This macro generates a `UnitChannel` enum and the necessary glue code
/// to convert from and to a `TelevisionChannel` member.
///
/// The `UnitChannel` enum is used as a unit variant of the `TelevisionChannel`
/// enum.
#[proc_macro_derive(ToUnitChannel, attributes(exclude_from_unit))]
pub fn unit_channel_derive(input: TokenStream) -> TokenStream {
// Construct a representation of Rust code as a syntax tree
// that we can manipulate
let ast = syn::parse(input).unwrap();
// Build the trait implementation
impl_unit_channel(&ast)
}
const EXCLUDE_FROM_UNIT: &str = "exclude_from_unit";
fn impl_unit_channel(ast: &syn::DeriveInput) -> TokenStream {
// Ensure the struct is an enum
let variants = if let syn::Data::Enum(data_enum) = &ast.data {
&data_enum.variants
} else {
panic!("#[derive(UnitChannel)] is only defined for enums");
};
// Ensure the enum has at least one variant
assert!(
!variants.is_empty(),
"#[derive(UnitChannel)] requires at least one variant"
);
let variant_names: Vec<_> = variants
.iter()
.filter(|variant| !has_attribute(&variant.attrs, EXCLUDE_FROM_UNIT))
.map(|v| &v.ident)
.collect();
let excluded_variants: Vec<_> = variants
.iter()
.filter(|variant| has_attribute(&variant.attrs, EXCLUDE_FROM_UNIT))
.map(|v| &v.ident)
.collect();
// Generate a unit enum from the given enum
let unit_enum = quote! {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumString, Hash)]
#[strum(serialize_all = "kebab_case")]
pub enum UnitChannel {
#(
#variant_names,
)*
}
};
// Generate Into<TelevisionChannel> implementation
let into_impl = quote! {
impl Into<TelevisionChannel> for UnitChannel {
fn into(self) -> TelevisionChannel {
match self {
#(
UnitChannel::#variant_names => TelevisionChannel::#variant_names(Default::default()),
)*
}
}
}
};
// Generate From<&TelevisionChannel> implementation
let from_impl = quote! {
impl From<&TelevisionChannel> for UnitChannel {
fn from(channel: &TelevisionChannel) -> Self {
match channel {
#(
TelevisionChannel::#variant_names(_) => Self::#variant_names,
)*
#(
TelevisionChannel::#excluded_variants(_) => panic!("Cannot convert excluded variant to unit channel."),
)*
}
}
}
};
let gen = quote! {
#unit_enum
#into_impl
#from_impl
};
gen.into()
}

View File

@ -10,9 +10,9 @@ use crate::config::get_config_dir;
/// Just a proxy struct to deserialize prototypes
#[derive(Debug, serde::Deserialize, Default)]
struct ChannelPrototypes {
pub struct ChannelPrototypes {
#[serde(rename = "cable_channel")]
prototypes: Vec<CableChannelPrototype>,
pub prototypes: Vec<CableChannelPrototype>,
}
const CABLE_FILE_NAME_SUFFIX: &str = "channels";
@ -65,14 +65,14 @@ pub fn load_cable_channels() -> Result<CableChannels> {
file_paths.push(default_channels_path);
}
let user_defined_prototypes = file_paths.iter().fold(
let prototypes = file_paths.iter().fold(
Vec::<CableChannelPrototype>::new(),
|mut acc, p| {
match toml::from_str::<ChannelPrototypes>(
&std::fs::read_to_string(p)
.expect("Unable to read configuration file"),
) {
Ok(prototypes) => acc.extend(prototypes.prototypes),
Ok(pts) => acc.extend(pts.prototypes),
Err(e) => {
error!(
"Failed to parse cable channel file {:?}: {}",
@ -84,10 +84,14 @@ pub fn load_cable_channels() -> Result<CableChannels> {
},
);
debug!("Loaded cable channels: {:?}", user_defined_prototypes);
debug!("Loaded cable channels: {:?}", prototypes);
if prototypes.is_empty() {
error!("No cable channels found");
return Err(anyhow::anyhow!("No cable channels found"));
}
let mut cable_channels = FxHashMap::default();
for prototype in user_defined_prototypes {
for prototype in prototypes {
cable_channels.insert(prototype.name.clone(), prototype);
}
Ok(CableChannels(cable_channels))

View File

@ -1,182 +0,0 @@
use std::collections::HashSet;
use crate::channels::entry::Entry;
use crate::channels::entry::PreviewType;
use crate::channels::OnAir;
use crate::matcher::{config::Config, injector::Injector, Matcher};
use crate::utils::command::shell_command;
use crate::utils::indices::sep_name_and_value_indices;
use crate::utils::shell::Shell;
use devicons::FileIcon;
use rustc_hash::FxBuildHasher;
use rustc_hash::FxHashSet;
use tracing::debug;
#[derive(Debug, Clone)]
struct Alias {
name: String,
value: String,
}
impl Alias {
fn new(name: String, value: String) -> Self {
Self { name, value }
}
}
pub struct Channel {
matcher: Matcher<Alias>,
file_icon: FileIcon,
selected_entries: FxHashSet<Entry>,
crawl_handle: tokio::task::JoinHandle<()>,
}
const NUM_THREADS: usize = 1;
const FILE_ICON_STR: &str = "nu";
fn get_raw_aliases(shell: Shell) -> Vec<String> {
// this needs to be run in an interactive shell in order to get the aliases
let mut command = shell_command(true);
let output = match shell {
Shell::PowerShell => {
command.arg("Get-Alias | Format-List -Property Name, Definition")
}
Shell::Cmd => command.arg("doskey /macros"),
_ => command.arg("-i").arg("alias").arg("2>/dev/null"),
}
.output()
.expect("failed to execute process");
let aliases = String::from_utf8_lossy(&output.stdout);
aliases.lines().map(ToString::to_string).collect()
}
impl Channel {
pub fn new() -> Self {
let matcher = Matcher::new(Config::default().n_threads(NUM_THREADS));
let injector = matcher.injector();
let crawl_handle = tokio::spawn(load_aliases(injector));
Self {
matcher,
file_icon: FileIcon::from(FILE_ICON_STR),
selected_entries: HashSet::with_hasher(FxBuildHasher),
crawl_handle,
}
}
}
impl Default for Channel {
fn default() -> Self {
Self::new()
}
}
impl OnAir for Channel {
fn find(&mut self, pattern: &str) {
self.matcher.find(pattern);
}
fn results(&mut self, num_entries: u32, offset: u32) -> Vec<Entry> {
self.matcher.tick();
self.matcher
.results(num_entries, offset)
.into_iter()
.map(|item| {
let (
name_indices,
value_indices,
should_add_name_indices,
should_add_value_indices,
) = sep_name_and_value_indices(
item.match_indices,
u32::try_from(item.inner.name.len()).unwrap(),
);
let mut entry =
Entry::new(item.inner.name.clone(), PreviewType::EnvVar)
.with_value(item.inner.value)
.with_icon(self.file_icon);
if should_add_name_indices {
entry = entry.with_name_match_indices(&name_indices);
}
if should_add_value_indices {
entry = entry.with_value_match_indices(&value_indices);
}
entry
})
.collect()
}
fn get_result(&self, index: u32) -> Option<Entry> {
self.matcher.get_result(index).map(|item| {
Entry::new(item.inner.name.clone(), PreviewType::EnvVar)
.with_value(item.inner.value)
.with_icon(self.file_icon)
})
}
fn selected_entries(&self) -> &FxHashSet<Entry> {
&self.selected_entries
}
fn toggle_selection(&mut self, entry: &Entry) {
if self.selected_entries.contains(entry) {
self.selected_entries.remove(entry);
} else {
self.selected_entries.insert(entry.clone());
}
}
fn result_count(&self) -> u32 {
self.matcher.matched_item_count
}
fn total_count(&self) -> u32 {
self.matcher.total_item_count
}
fn running(&self) -> bool {
self.matcher.status.running || !self.crawl_handle.is_finished()
}
fn shutdown(&self) {}
fn supports_preview(&self) -> bool {
true
}
}
#[allow(clippy::unused_async)]
async fn load_aliases(injector: Injector<Alias>) {
let shell = Shell::from_env().unwrap_or_default();
debug!("Current shell: {}", shell);
let raw_aliases = get_raw_aliases(shell);
raw_aliases
.iter()
.filter_map(|alias| {
let mut parts = alias.split('=');
if let Some(name) = parts.next() {
if let Some(value) = parts.next() {
return Some(Alias::new(
name.to_string(),
value.to_string(),
));
}
} else {
debug!("Invalid alias format: {}", alias);
}
None
})
.for_each(|alias| {
let () = injector.push(alias, |e, cols| {
cols[0] = (e.name.clone() + &e.value).into();
});
});
}

View File

@ -13,11 +13,14 @@ use regex::Regex;
use rustc_hash::{FxBuildHasher, FxHashSet};
use tracing::debug;
use crate::channels::entry::{Entry, PreviewCommand, PreviewType};
use crate::channels::OnAir;
use crate::matcher::Matcher;
use crate::matcher::{config::Config, injector::Injector};
use crate::utils::command::shell_command;
use crate::{
cable::ChannelPrototypes,
channels::entry::{Entry, PreviewCommand, PreviewType},
};
#[derive(Debug, Clone, PartialEq)]
pub enum PreviewKind {
@ -39,9 +42,10 @@ pub struct Channel {
impl Default for Channel {
fn default() -> Self {
Self::new(
"Files",
"files",
"find . -type f",
Some(PreviewCommand::new("bat -n --color=always {}", ":")),
false,
Some(PreviewCommand::new("cat {}", ":")),
)
}
}
@ -51,6 +55,7 @@ impl From<CableChannelPrototype> for Channel {
Self::new(
&prototype.name,
&prototype.source_command,
prototype.interactive,
match prototype.preview_command {
Some(command) => Some(PreviewCommand::new(
&command,
@ -79,12 +84,14 @@ impl Channel {
pub fn new(
name: &str,
entries_command: &str,
interactive: bool,
preview_command: Option<PreviewCommand>,
) -> Self {
let matcher = Matcher::new(Config::default());
let injector = matcher.injector();
let crawl_handle = tokio::spawn(load_candidates(
entries_command.to_string(),
interactive,
injector,
));
let preview_kind = match preview_command {
@ -108,9 +115,13 @@ impl Channel {
}
#[allow(clippy::unused_async)]
async fn load_candidates(command: String, injector: Injector<String>) {
async fn load_candidates(
command: String,
interactive: bool,
injector: Injector<String>,
) {
debug!("Loading candidates from command: {:?}", command);
let mut child = shell_command(false)
let mut child = shell_command(interactive)
.arg(command)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
@ -228,11 +239,47 @@ impl OnAir for Channel {
pub struct CableChannelPrototype {
pub name: String,
pub source_command: String,
#[serde(default)]
pub interactive: bool,
pub preview_command: Option<String>,
#[serde(default = "default_delimiter")]
pub preview_delimiter: Option<String>,
}
impl CableChannelPrototype {
pub fn new(
name: &str,
source_command: &str,
interactive: bool,
preview_command: Option<String>,
preview_delimiter: Option<String>,
) -> Self {
Self {
name: name.to_string(),
source_command: source_command.to_string(),
interactive,
preview_command,
preview_delimiter,
}
}
}
const DEFAULT_PROTOTYPE_NAME: &str = "files";
const DEFAULT_SOURCE_COMMAND: &str = "fd -t f";
const DEFAULT_PREVIEW_COMMAND: &str = ":files:";
impl Default for CableChannelPrototype {
fn default() -> Self {
Self {
name: DEFAULT_PROTOTYPE_NAME.to_string(),
source_command: DEFAULT_SOURCE_COMMAND.to_string(),
interactive: false,
preview_command: Some(DEFAULT_PREVIEW_COMMAND.to_string()),
preview_delimiter: Some(DEFAULT_DELIMITER.to_string()),
}
}
}
pub const DEFAULT_DELIMITER: &str = " ";
#[allow(clippy::unnecessary_wraps)]
@ -246,7 +293,7 @@ impl Display for CableChannelPrototype {
}
}
#[derive(Debug, serde::Deserialize, Default)]
#[derive(Debug, serde::Deserialize)]
pub struct CableChannels(pub FxHashMap<String, CableChannelPrototype>);
impl Deref for CableChannels {
@ -256,3 +303,25 @@ impl Deref for CableChannels {
&self.0
}
}
#[cfg(unix)]
const DEFAULT_CABLE_CHANNELS_FILE: &str =
include_str!("../../cable/unix-channels.toml");
#[cfg(not(unix))]
const DEFAULT_CABLE_CHANNELS_FILE: &str =
include_str!("../../cable/windows-channels.toml");
impl Default for CableChannels {
/// Fallback to the default cable channels specification (the template file
/// included in the repo).
fn default() -> Self {
let pts =
toml::from_str::<ChannelPrototypes>(DEFAULT_CABLE_CHANNELS_FILE)
.expect("Unable to parse default cable channels");
let mut channels = FxHashMap::default();
for prototype in pts.prototypes {
channels.insert(prototype.name.clone(), prototype);
}
CableChannels(channels)
}
}

View File

@ -1,186 +0,0 @@
use crate::channels::entry::{Entry, PreviewCommand, PreviewType};
use crate::channels::{OnAir, TelevisionChannel};
use crate::matcher::{config::Config, injector::Injector, Matcher};
use crate::utils::files::{get_default_num_threads, walk_builder};
use devicons::FileIcon;
use rustc_hash::{FxBuildHasher, FxHashSet};
use std::collections::HashSet;
use std::path::PathBuf;
pub struct Channel {
matcher: Matcher<String>,
crawl_handle: tokio::task::JoinHandle<()>,
// PERF: cache results (to make deleting characters smoother) with
// a shallow stack of sub-patterns as keys (e.g. "a", "ab", "abc")
selected_entries: FxHashSet<Entry>,
}
impl Channel {
pub fn new(paths: Vec<PathBuf>) -> Self {
let matcher = Matcher::new(Config::default().match_paths(true));
// start loading files in the background
let crawl_handle = tokio::spawn(load_dirs(paths, matcher.injector()));
Channel {
matcher,
crawl_handle,
selected_entries: HashSet::with_hasher(FxBuildHasher),
}
}
}
impl Default for Channel {
fn default() -> Self {
Self::new(vec![std::env::current_dir().unwrap()])
}
}
impl From<&mut TelevisionChannel> for Channel {
fn from(value: &mut TelevisionChannel) -> Self {
match value {
c @ TelevisionChannel::GitRepos(_) => {
let entries = if c.selected_entries().is_empty() {
c.results(c.result_count(), 0)
} else {
c.selected_entries().iter().cloned().collect()
};
Self::new(
entries
.iter()
.map(|entry| PathBuf::from(entry.name.clone()))
.collect(),
)
}
c @ TelevisionChannel::Dirs(_) => {
let entries = if c.selected_entries().is_empty() {
c.results(c.result_count(), 0)
} else {
c.selected_entries().iter().cloned().collect()
};
Self::new(
entries
.iter()
.map(|entry| PathBuf::from(&entry.name))
.collect::<HashSet<_>>()
.into_iter()
.collect(),
)
}
_ => unreachable!(),
}
}
}
#[cfg(unix)]
const PREVIEW_COMMAND: &str = "ls -la --color=always {}";
#[cfg(windows)]
const PREVIEW_COMMAND: &str = "dir /Q {}";
impl OnAir for Channel {
fn find(&mut self, pattern: &str) {
self.matcher.find(pattern);
}
fn results(&mut self, num_entries: u32, offset: u32) -> Vec<Entry> {
self.matcher.tick();
self.matcher
.results(num_entries, offset)
.into_iter()
.map(|item| {
let path = item.matched_string;
Entry::new(
path.clone(),
PreviewType::Command(PreviewCommand::new(
PREVIEW_COMMAND,
" ",
)),
)
.with_name_match_indices(&item.match_indices)
.with_icon(FileIcon::from(&path))
})
.collect()
}
fn get_result(&self, index: u32) -> Option<Entry> {
self.matcher.get_result(index).map(|item| {
let path = item.matched_string;
Entry::new(
path.clone(),
PreviewType::Command(PreviewCommand::new(
PREVIEW_COMMAND,
" ",
)),
)
.with_icon(FileIcon::from(&path))
})
}
fn selected_entries(&self) -> &FxHashSet<Entry> {
&self.selected_entries
}
fn toggle_selection(&mut self, entry: &Entry) {
if self.selected_entries.contains(entry) {
self.selected_entries.remove(entry);
} else {
self.selected_entries.insert(entry.clone());
}
}
fn result_count(&self) -> u32 {
self.matcher.matched_item_count
}
fn total_count(&self) -> u32 {
self.matcher.total_item_count
}
fn running(&self) -> bool {
self.matcher.status.running || !self.crawl_handle.is_finished()
}
fn shutdown(&self) {
self.crawl_handle.abort();
}
fn supports_preview(&self) -> bool {
true
}
}
#[allow(clippy::unused_async)]
async fn load_dirs(paths: Vec<PathBuf>, injector: Injector<String>) {
if paths.is_empty() {
return;
}
let current_dir = std::env::current_dir().unwrap();
let mut builder =
walk_builder(&paths[0], get_default_num_threads(), None, None);
paths[1..].iter().for_each(|path| {
builder.add(path);
});
let walker = builder.build_parallel();
walker.run(|| {
let injector = injector.clone();
let current_dir = current_dir.clone();
Box::new(move |result| {
if let Ok(entry) = result {
if entry.file_type().unwrap().is_dir() {
let dir_path = &entry
.path()
.strip_prefix(&current_dir)
.unwrap_or(entry.path())
.to_string_lossy();
if dir_path == "" {
return ignore::WalkState::Continue;
}
let () = injector.push(dir_path.to_string(), |e, cols| {
cols[0] = e.to_string().into();
});
}
}
ignore::WalkState::Continue
})
});
}

View File

@ -1,132 +0,0 @@
use std::collections::HashSet;
use devicons::FileIcon;
use rustc_hash::{FxBuildHasher, FxHashSet};
use super::OnAir;
use crate::channels::entry::{Entry, PreviewType};
use crate::matcher::{config::Config, Matcher};
use crate::utils::indices::sep_name_and_value_indices;
#[derive(Debug, Clone)]
struct EnvVar {
name: String,
value: String,
}
#[allow(clippy::module_name_repetitions)]
pub struct Channel {
matcher: Matcher<EnvVar>,
file_icon: FileIcon,
selected_entries: FxHashSet<Entry>,
}
const NUM_THREADS: usize = 1;
const FILE_ICON_STR: &str = "config";
impl Channel {
pub fn new() -> Self {
let matcher = Matcher::new(Config::default().n_threads(NUM_THREADS));
let injector = matcher.injector();
for (name, value) in std::env::vars() {
let () = injector.push(
EnvVar {
name: name.clone(),
value: value.clone(),
},
|e, cols| {
cols[0] = (e.name.clone() + &e.value).into();
},
);
}
Channel {
matcher,
file_icon: FileIcon::from(FILE_ICON_STR),
selected_entries: HashSet::with_hasher(FxBuildHasher),
}
}
}
impl Default for Channel {
fn default() -> Self {
Self::new()
}
}
impl OnAir for Channel {
fn find(&mut self, pattern: &str) {
self.matcher.find(pattern);
}
fn results(&mut self, num_entries: u32, offset: u32) -> Vec<Entry> {
self.matcher.tick();
self.matcher
.results(num_entries, offset)
.into_iter()
.map(|item| {
let (
name_indices,
value_indices,
should_add_name_indices,
should_add_value_indices,
) = sep_name_and_value_indices(
item.match_indices,
u32::try_from(item.inner.name.len()).unwrap(),
);
let mut entry =
Entry::new(item.inner.name.clone(), PreviewType::EnvVar)
.with_value(item.inner.value)
.with_icon(self.file_icon);
if should_add_name_indices {
entry = entry.with_name_match_indices(&name_indices);
}
if should_add_value_indices {
entry = entry.with_value_match_indices(&value_indices);
}
entry
})
.collect()
}
fn get_result(&self, index: u32) -> Option<Entry> {
self.matcher.get_result(index).map(|item| {
Entry::new(item.inner.name.clone(), PreviewType::EnvVar)
.with_value(item.inner.value)
.with_icon(self.file_icon)
})
}
fn selected_entries(&self) -> &FxHashSet<Entry> {
&self.selected_entries
}
fn toggle_selection(&mut self, entry: &Entry) {
if self.selected_entries.contains(entry) {
self.selected_entries.remove(entry);
} else {
self.selected_entries.insert(entry.clone());
}
}
fn result_count(&self) -> u32 {
self.matcher.matched_item_count
}
fn total_count(&self) -> u32 {
self.matcher.total_item_count
}
fn running(&self) -> bool {
self.matcher.status.running
}
fn shutdown(&self) {}
fn supports_preview(&self) -> bool {
true
}
}

View File

@ -1,190 +0,0 @@
use crate::channels::entry::{Entry, PreviewType};
use crate::channels::{OnAir, TelevisionChannel};
use crate::matcher::{config::Config, injector::Injector, Matcher};
use crate::utils::files::{get_default_num_threads, walk_builder};
use devicons::FileIcon;
use rustc_hash::{FxBuildHasher, FxHashSet};
use std::collections::HashSet;
use std::path::PathBuf;
pub struct Channel {
matcher: Matcher<String>,
crawl_handle: tokio::task::JoinHandle<()>,
// PERF: cache results (to make deleting characters smoother) with
// a shallow stack of sub-patterns as keys (e.g. "a", "ab", "abc")
selected_entries: FxHashSet<Entry>,
}
impl Channel {
pub fn new(paths: Vec<PathBuf>) -> Self {
let matcher = Matcher::new(Config::default().match_paths(true));
// start loading files in the background
let crawl_handle = tokio::spawn(load_files(paths, matcher.injector()));
Channel {
matcher,
crawl_handle,
selected_entries: HashSet::with_hasher(FxBuildHasher),
}
}
}
impl Default for Channel {
fn default() -> Self {
Self::new(vec![std::env::current_dir().unwrap()])
}
}
impl From<&mut TelevisionChannel> for Channel {
fn from(value: &mut TelevisionChannel) -> Self {
match value {
c @ TelevisionChannel::GitRepos(_) => {
let entries = if c.selected_entries().is_empty() {
c.results(c.result_count(), 0)
} else {
c.selected_entries().iter().cloned().collect()
};
Self::new(
entries
.iter()
.map(|entry| PathBuf::from(entry.name.clone()))
.collect(),
)
}
c @ TelevisionChannel::Files(_) => {
let entries = if c.selected_entries().is_empty() {
c.results(c.result_count(), 0)
} else {
c.selected_entries().iter().cloned().collect()
};
Self::new(
entries
.iter()
.map(|entry| PathBuf::from(entry.name.clone()))
.collect(),
)
}
c @ TelevisionChannel::Text(_) => {
let entries = if c.selected_entries().is_empty() {
c.results(c.result_count(), 0)
} else {
c.selected_entries().iter().cloned().collect()
};
Self::new(
entries
.iter()
.map(|entry| PathBuf::from(&entry.name))
.collect::<FxHashSet<_>>()
.into_iter()
.collect(),
)
}
c @ TelevisionChannel::Dirs(_) => {
let entries = c.results(c.result_count(), 0);
Self::new(
entries
.iter()
.map(|entry| PathBuf::from(&entry.name))
.collect::<FxHashSet<_>>()
.into_iter()
.collect(),
)
}
_ => unreachable!(),
}
}
}
impl OnAir for Channel {
fn find(&mut self, pattern: &str) {
self.matcher.find(pattern);
}
fn results(&mut self, num_entries: u32, offset: u32) -> Vec<Entry> {
self.matcher.tick();
self.matcher
.results(num_entries, offset)
.into_iter()
.map(|item| {
let path = item.matched_string;
Entry::new(path.clone(), PreviewType::Files)
.with_name_match_indices(&item.match_indices)
.with_icon(FileIcon::from(&path))
})
.collect()
}
fn get_result(&self, index: u32) -> Option<Entry> {
self.matcher.get_result(index).map(|item| {
let path = item.matched_string;
Entry::new(path.clone(), PreviewType::Files)
.with_icon(FileIcon::from(&path))
})
}
fn selected_entries(&self) -> &FxHashSet<Entry> {
&self.selected_entries
}
fn toggle_selection(&mut self, entry: &Entry) {
if self.selected_entries.contains(entry) {
self.selected_entries.remove(entry);
} else {
self.selected_entries.insert(entry.clone());
}
}
fn result_count(&self) -> u32 {
self.matcher.matched_item_count
}
fn total_count(&self) -> u32 {
self.matcher.total_item_count
}
fn running(&self) -> bool {
self.matcher.status.running || !self.crawl_handle.is_finished()
}
fn shutdown(&self) {
self.crawl_handle.abort();
}
fn supports_preview(&self) -> bool {
true
}
}
#[allow(clippy::unused_async)]
async fn load_files(paths: Vec<PathBuf>, injector: Injector<String>) {
if paths.is_empty() {
return;
}
let current_dir = std::env::current_dir().unwrap();
let mut builder =
walk_builder(&paths[0], get_default_num_threads(), None, None);
paths[1..].iter().for_each(|path| {
builder.add(path);
});
let walker = builder.build_parallel();
walker.run(|| {
let injector = injector.clone();
let current_dir = current_dir.clone();
Box::new(move |result| {
if let Ok(entry) = result {
if entry.file_type().unwrap().is_file() {
let file_path = &entry
.path()
.strip_prefix(&current_dir)
.unwrap_or(entry.path())
.to_string_lossy();
let () =
injector.push(file_path.to_string(), |e, cols| {
cols[0] = e.to_string().into();
});
}
}
ignore::WalkState::Continue
})
});
}

View File

@ -1,204 +0,0 @@
use devicons::FileIcon;
use directories::BaseDirs;
use ignore::overrides::OverrideBuilder;
use rustc_hash::{FxBuildHasher, FxHashSet};
use std::collections::HashSet;
use std::path::PathBuf;
use tokio::task::JoinHandle;
use tracing::debug;
use crate::channels::entry::{Entry, PreviewCommand, PreviewType};
use crate::channels::OnAir;
use crate::matcher::{config::Config, injector::Injector, Matcher};
use crate::utils::files::{get_default_num_threads, walk_builder};
pub struct Channel {
matcher: Matcher<String>,
icon: FileIcon,
crawl_handle: JoinHandle<()>,
selected_entries: FxHashSet<Entry>,
preview_command: PreviewCommand,
}
impl Channel {
pub fn new() -> Self {
let matcher = Matcher::new(Config::default().match_paths(true));
let base_dirs = BaseDirs::new().unwrap();
let crawl_handle = tokio::spawn(crawl_for_repos(
base_dirs.home_dir().to_path_buf(),
matcher.injector(),
));
let preview_command = PreviewCommand {
command: String::from(
"cd {} && git log -n 200 --pretty=medium --all --graph --color",
),
delimiter: ":".to_string(),
};
Channel {
matcher,
icon: FileIcon::from("git"),
crawl_handle,
selected_entries: HashSet::with_hasher(FxBuildHasher),
preview_command,
}
}
}
impl Default for Channel {
fn default() -> Self {
Self::new()
}
}
impl OnAir for Channel {
fn find(&mut self, pattern: &str) {
self.matcher.find(pattern);
}
fn results(&mut self, num_entries: u32, offset: u32) -> Vec<Entry> {
self.matcher.tick();
self.matcher
.results(num_entries, offset)
.into_iter()
.map(|item| {
let path = item.matched_string;
Entry::new(
path,
PreviewType::Command(self.preview_command.clone()),
)
.with_name_match_indices(&item.match_indices)
.with_icon(self.icon)
})
.collect()
}
fn get_result(&self, index: u32) -> Option<Entry> {
self.matcher.get_result(index).map(|item| {
let path = item.matched_string;
Entry::new(
path,
PreviewType::Command(self.preview_command.clone()),
)
.with_icon(self.icon)
})
}
fn selected_entries(&self) -> &FxHashSet<Entry> {
&self.selected_entries
}
fn toggle_selection(&mut self, entry: &Entry) {
if self.selected_entries.contains(entry) {
self.selected_entries.remove(entry);
} else {
self.selected_entries.insert(entry.clone());
}
}
fn result_count(&self) -> u32 {
self.matcher.matched_item_count
}
fn total_count(&self) -> u32 {
self.matcher.total_item_count
}
fn running(&self) -> bool {
self.matcher.status.running || !self.crawl_handle.is_finished()
}
fn shutdown(&self) {
debug!("Shutting down git repos channel");
self.crawl_handle.abort();
}
fn supports_preview(&self) -> bool {
true
}
}
fn get_ignored_paths() -> Vec<PathBuf> {
let mut ignored_paths = Vec::new();
if let Some(base_dirs) = BaseDirs::new() {
let home = base_dirs.home_dir();
#[cfg(target_os = "macos")]
{
ignored_paths.push(home.join("Library"));
ignored_paths.push(home.join("Applications"));
ignored_paths.push(home.join("Music"));
ignored_paths.push(home.join("Pictures"));
ignored_paths.push(home.join("Movies"));
ignored_paths.push(home.join("Downloads"));
ignored_paths.push(home.join("Public"));
}
#[cfg(target_os = "linux")]
{
ignored_paths.push(home.join(".cache"));
ignored_paths.push(home.join(".config"));
ignored_paths.push(home.join(".local"));
ignored_paths.push(home.join(".thumbnails"));
ignored_paths.push(home.join("Downloads"));
ignored_paths.push(home.join("Public"));
ignored_paths.push(home.join("snap"));
ignored_paths.push(home.join(".snap"));
}
#[cfg(target_os = "windows")]
{
ignored_paths.push(home.join("AppData"));
ignored_paths.push(home.join("Downloads"));
ignored_paths.push(home.join("Documents"));
ignored_paths.push(home.join("Music"));
ignored_paths.push(home.join("Pictures"));
ignored_paths.push(home.join("Videos"));
}
// Common paths to ignore for all platforms
ignored_paths.push(home.join("node_modules"));
ignored_paths.push(home.join("venv"));
ignored_paths.push(PathBuf::from("/tmp"));
}
ignored_paths
}
#[allow(clippy::unused_async)]
async fn crawl_for_repos(starting_point: PathBuf, injector: Injector<String>) {
let mut walker_overrides_builder = OverrideBuilder::new(&starting_point);
walker_overrides_builder.add(".git").unwrap();
let walker = walk_builder(
&starting_point,
get_default_num_threads(),
Some(walker_overrides_builder.build().unwrap()),
Some(get_ignored_paths()),
)
.build_parallel();
walker.run(|| {
let injector = injector.clone();
Box::new(move |result| {
if let Ok(entry) = result {
if entry.file_type().unwrap().is_dir() {
// if the entry is a .git directory, add its parent to the list of git repos
if entry.path().ends_with(".git") {
let parent_path =
&entry.path().parent().unwrap().to_string_lossy();
debug!("Found git repo: {:?}", parent_path);
let () = injector.push(
parent_path.to_string(),
|e, cols| {
cols[0] = e.clone().into();
},
);
return ignore::WalkState::Skip;
}
}
}
ignore::WalkState::Continue
})
});
}

View File

@ -1,18 +1,12 @@
use crate::channels::entry::Entry;
use anyhow::Result;
use rustc_hash::FxHashSet;
use television_derive::{Broadcast, ToCliChannel, ToUnitChannel};
use television_derive::Broadcast;
pub mod alias;
pub mod cable;
pub mod dirs;
pub mod entry;
pub mod env;
pub mod files;
pub mod git_repos;
pub mod remote_control;
pub mod stdin;
pub mod text;
/// The interface that all television channels must implement.
///
@ -118,56 +112,22 @@ pub trait OnAir: Send {
/// of carrying the actual channel instances around. It also generates the necessary
/// glue code to automatically create a channel instance from the selected enum variant.
#[allow(dead_code, clippy::module_name_repetitions)]
#[derive(ToUnitChannel, ToCliChannel, Broadcast)]
#[derive(Broadcast)]
pub enum TelevisionChannel {
/// The environment variables channel.
///
/// This channel allows to search through environment variables.
Env(env::Channel),
/// The files channel.
///
/// This channel allows to search through files.
Files(files::Channel),
/// The git repositories channel.
///
/// This channel allows to search through git repositories.
GitRepos(git_repos::Channel),
/// The dirs channel.
///
/// This channel allows to search through directories.
Dirs(dirs::Channel),
/// The text channel.
///
/// This channel allows to search through the contents of text files.
Text(text::Channel),
/// The standard input channel.
///
/// This channel allows to search through whatever is passed through stdin.
#[exclude_from_cli]
Stdin(stdin::Channel),
/// The alias channel.
///
/// This channel allows to search through aliases.
Alias(alias::Channel),
/// The remote control channel.
///
/// This channel allows to switch between different channels.
#[exclude_from_unit]
#[exclude_from_cli]
RemoteControl(remote_control::RemoteControl),
/// A custom channel.
///
/// This channel allows to search through custom data.
#[exclude_from_cli]
Cable(cable::Channel),
}
impl From<&Entry> for TelevisionChannel {
fn from(entry: &Entry) -> Self {
UnitChannel::try_from(entry.name.as_str()).unwrap().into()
}
}
impl TelevisionChannel {
pub fn zap(&self, channel_name: &str) -> Result<TelevisionChannel> {
match self {
@ -182,130 +142,7 @@ impl TelevisionChannel {
match self {
TelevisionChannel::Cable(channel) => channel.name.clone(),
TelevisionChannel::Stdin(_) => String::from("Stdin"),
_ => UnitChannel::from(self).to_string(),
TelevisionChannel::RemoteControl(_) => String::from("Remote"),
}
}
}
macro_rules! variant_to_module {
(Files) => {
files::Channel
};
(Text) => {
text::Channel
};
(Dirs) => {
dirs::Channel
};
(GitRepos) => {
git_repos::Channel
};
(Env) => {
env::Channel
};
(Stdin) => {
stdin::Channel
};
(Alias) => {
alias::Channel
};
(RemoteControl) => {
remote_control::RemoteControl
};
}
/// A macro that generates two methods for the `TelevisionChannel` enum based on
/// the transitions defined in the macro call.
///
/// The first method `available_transitions` returns a list of possible transitions
/// from the current channel.
///
/// The second method `transition_to` transitions from the current channel to the
/// target channel.
///
/// # Example
/// The following example defines transitions from the `Files` channel to the `Text`
/// channel and from the `GitRepos` channel to the `Files` and `Text` channels.
/// ```ignore
/// define_transitions! {
/// // The `Files` channel can transition to the `Text` channel.
/// Files => [Text],
/// // The `GitRepos` channel can transition to the `Files` and `Text` channels.
/// GitRepos => [Files, Text],
/// }
/// ```
/// This will generate the following methods for the `TelevisionChannel` enum:
/// ```ignore
/// impl TelevisionChannel {
/// pub fn available_transitions(&self) -> Vec<UnitChannel> {
/// match self {
/// TelevisionChannel::Files(_) => vec![UnitChannel::Text],
/// TelevisionChannel::GitRepos(_) => vec![UnitChannel::Files, UnitChannel::Text],
/// _ => Vec::new(),
/// }
/// }
///
/// pub fn transition_to(self, target: UnitChannel) -> TelevisionChannel {
/// match (self, target) {
/// (tv_channel @ TelevisionChannel::Files(_), UnitChannel::Text) => {
/// TelevisionChannel::Text(text::Channel::from(tv_channel))
/// },
/// (tv_channel @ TelevisionChannel::GitRepos(_), UnitChannel::Files) => {
/// TelevisionChannel::Files(files::Channel::from(tv_channel))
/// },
/// (tv_channel @ TelevisionChannel::GitRepos(_), UnitChannel::Text) => {
/// TelevisionChannel::Text(text::Channel::from(tv_channel))
/// },
/// _ => unreachable!(),
/// }
/// }
/// }
///
///
macro_rules! define_transitions {
(
$(
$from_variant:ident => [ $($to_variant:ident),* $(,)? ],
)*
) => {
impl TelevisionChannel {
pub fn available_transitions(&self) -> Vec<UnitChannel> {
match self {
$(
TelevisionChannel::$from_variant(_) => vec![
$( UnitChannel::$to_variant ),*
],
)*
_ => Vec::new(),
}
}
pub fn transition_to(&mut self, target: UnitChannel) -> TelevisionChannel {
match (self, target) {
$(
$(
(tv_channel @ TelevisionChannel::$from_variant(_), UnitChannel::$to_variant) => {
TelevisionChannel::$to_variant(
<variant_to_module!($to_variant)>::from(tv_channel)
)
},
)*
)*
_ => unreachable!(),
}
}
}
}
}
// Define the transitions between the different channels.
//
// This is where the transitions between the different channels are defined.
// The transitions are defined as a list of tuples where the first element
// is the source channel and the second element is a list of potential target channels.
define_transitions! {
Text => [Files, Text],
Files => [Files, Text],
Dirs => [Files, Text, Dirs],
GitRepos => [Files, Text, Dirs],
}

View File

@ -1,61 +1,33 @@
use std::collections::HashSet;
use std::fmt::Display;
use crate::channels::cable::{CableChannelPrototype, CableChannels};
use crate::channels::cable::CableChannels;
use crate::channels::entry::{Entry, PreviewType};
use crate::channels::{CliTvChannel, OnAir, TelevisionChannel, UnitChannel};
use crate::channels::{OnAir, TelevisionChannel};
use crate::matcher::{config::Config, Matcher};
use anyhow::Result;
use clap::ValueEnum;
use devicons::FileIcon;
use rustc_hash::{FxBuildHasher, FxHashSet};
use super::cable;
pub struct RemoteControl {
matcher: Matcher<RCButton>,
matcher: Matcher<String>,
cable_channels: Option<CableChannels>,
selected_entries: FxHashSet<Entry>,
}
#[derive(Clone)]
pub enum RCButton {
Channel(UnitChannel),
CableChannel(CableChannelPrototype),
}
impl Display for RCButton {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RCButton::Channel(channel) => write!(f, "{channel}"),
RCButton::CableChannel(prototype) => write!(f, "{prototype}"),
}
}
}
const NUM_THREADS: usize = 1;
impl RemoteControl {
pub fn new(
builtin_channels: Vec<UnitChannel>,
cable_channels: Option<CableChannels>,
) -> Self {
pub fn new(cable_channels: Option<CableChannels>) -> Self {
let matcher = Matcher::new(Config::default().n_threads(NUM_THREADS));
let injector = matcher.injector();
let buttons =
builtin_channels.into_iter().map(RCButton::Channel).chain(
cable_channels
.as_ref()
.map(|channels| {
channels.iter().map(|(_, prototype)| {
RCButton::CableChannel(prototype.clone())
})
})
.into_iter()
.flatten(),
);
for button in buttons {
let () = injector.push(button.clone(), |e, cols| {
for c in cable_channels
.as_ref()
.unwrap_or(&CableChannels::default())
.keys()
{
let () = injector.push(c.clone(), |e, cols| {
cols[0] = e.to_string().into();
});
}
@ -66,12 +38,6 @@ impl RemoteControl {
}
}
pub fn with_transitions_from(
television_channel: &TelevisionChannel,
) -> Self {
Self::new(television_channel.available_transitions(), None)
}
pub fn zap(&self, channel_name: &str) -> Result<TelevisionChannel> {
match self
.cable_channels
@ -81,47 +47,20 @@ impl RemoteControl {
Some(prototype) => {
Ok(TelevisionChannel::Cable(cable::Channel::from(prototype)))
}
None => match UnitChannel::try_from(channel_name) {
Ok(channel) => Ok(channel.into()),
Err(_) => Err(anyhow::anyhow!(
"No channel or cable channel prototype found for {}",
channel_name
)),
},
None => Err(anyhow::anyhow!(
"No channel or cable channel prototype found for {}",
channel_name
)),
}
}
}
impl Default for RemoteControl {
fn default() -> Self {
Self::new(
CliTvChannel::value_variants()
.iter()
.flat_map(|v| UnitChannel::try_from(v.to_string().as_str()))
.collect(),
None,
)
Self::new(None)
}
}
pub fn load_builtin_channels(
filter_out_cable_names: Option<&[&String]>,
) -> Vec<UnitChannel> {
let mut value_variants = CliTvChannel::value_variants()
.iter()
.map(std::string::ToString::to_string)
.collect::<Vec<_>>();
if let Some(f) = filter_out_cable_names {
value_variants.retain(|v| !f.iter().any(|c| *c == v));
}
value_variants
.iter()
.flat_map(|v| UnitChannel::try_from(v.as_str()))
.collect()
}
const TV_ICON: FileIcon = FileIcon {
icon: '📺',
color: "#000000",
@ -146,10 +85,7 @@ impl OnAir for RemoteControl {
let path = item.matched_string;
Entry::new(path, PreviewType::Basic)
.with_name_match_indices(&item.match_indices)
.with_icon(match item.inner {
RCButton::Channel(_) => TV_ICON,
RCButton::CableChannel(_) => CABLE_ICON,
})
.with_icon(CABLE_ICON)
})
.collect()
}

View File

@ -1,387 +0,0 @@
use super::{OnAir, TelevisionChannel};
use crate::channels::entry::{Entry, PreviewType};
use crate::matcher::{config::Config, injector::Injector, Matcher};
use crate::utils::files::{get_default_num_threads, walk_builder};
use crate::utils::strings::{
proportion_of_printable_ascii_characters, PRINTABLE_ASCII_THRESHOLD,
};
use devicons::FileIcon;
use ignore::WalkState;
use rustc_hash::{FxBuildHasher, FxHashSet};
use std::{
collections::HashSet,
fs::File,
io::{BufRead, Read, Seek},
path::{Path, PathBuf},
sync::{atomic::AtomicUsize, Arc},
};
use tracing::{debug, trace, warn};
#[derive(Debug, Clone)]
struct CandidateLine {
path: PathBuf,
line: String,
line_number: usize,
}
impl CandidateLine {
fn new(path: PathBuf, line: String, line_number: usize) -> Self {
CandidateLine {
path,
line,
line_number,
}
}
}
#[allow(clippy::module_name_repetitions)]
pub struct Channel {
matcher: Matcher<CandidateLine>,
crawl_handle: tokio::task::JoinHandle<()>,
selected_entries: FxHashSet<Entry>,
}
impl Channel {
pub fn new(directories: Vec<PathBuf>) -> Self {
let matcher = Matcher::new(Config::default());
// start loading files in the background
let crawl_handle = tokio::spawn(crawl_for_candidates(
directories,
matcher.injector(),
));
Channel {
matcher,
crawl_handle,
selected_entries: HashSet::with_hasher(FxBuildHasher),
}
}
fn from_file_paths(file_paths: Vec<PathBuf>) -> Self {
let matcher = Matcher::new(Config::default());
let injector = matcher.injector();
let current_dir = std::env::current_dir().unwrap();
let crawl_handle = tokio::spawn(async move {
let mut lines_in_mem = 0;
for path in file_paths {
if lines_in_mem > MAX_LINES_IN_MEM {
break;
}
if let Some(injected_lines) =
try_inject_lines(&injector, &current_dir, &path)
{
lines_in_mem += injected_lines;
}
}
});
Channel {
matcher,
crawl_handle,
selected_entries: HashSet::with_hasher(FxBuildHasher),
}
}
fn from_text_entries(entries: Vec<Entry>) -> Self {
let matcher = Matcher::new(Config::default());
let injector = matcher.injector();
let load_handle = tokio::spawn(async move {
for entry in entries.into_iter().take(MAX_LINES_IN_MEM) {
let v = entry.value.unwrap();
injector.push(
CandidateLine::new(
entry.name.into(),
v,
entry.line_number.unwrap(),
),
|e, cols| {
cols[0] = e.line.clone().into();
},
);
}
});
Channel {
matcher,
crawl_handle: load_handle,
selected_entries: HashSet::with_hasher(FxBuildHasher),
}
}
}
impl Default for Channel {
fn default() -> Self {
Self::new(vec![std::env::current_dir().unwrap()])
}
}
/// Since we're limiting the number of lines in memory, it makes sense to also limit the number of files
/// we're willing to search in when piping from the `Files` channel.
/// This prevents blocking the UI for too long when piping from a channel with a lot of files.
///
/// This should be calculated based on the number of lines we're willing to keep in memory:
/// `MAX_LINES_IN_MEM / 100` (assuming 100 lines per file on average).
const MAX_PIPED_FILES: usize = MAX_LINES_IN_MEM / 200;
impl From<&mut TelevisionChannel> for Channel {
fn from(value: &mut TelevisionChannel) -> Self {
match value {
c @ TelevisionChannel::Files(_) => {
let entries = if c.selected_entries().is_empty() {
c.results(
c.result_count().min(
u32::try_from(MAX_PIPED_FILES).unwrap_or(u32::MAX),
),
0,
)
} else {
c.selected_entries().iter().cloned().collect()
};
Self::from_file_paths(
entries
.iter()
.flat_map(|entry| {
PathBuf::from(entry.name.clone()).canonicalize()
})
.collect(),
)
}
c @ TelevisionChannel::GitRepos(_) => {
let entries = if c.selected_entries().is_empty() {
c.results(c.result_count(), 0)
} else {
c.selected_entries().iter().cloned().collect()
};
Self::new(
entries
.iter()
.flat_map(|entry| {
PathBuf::from(entry.name.clone()).canonicalize()
})
.collect(),
)
}
c @ TelevisionChannel::Text(_) => {
let entries = if c.selected_entries().is_empty() {
c.results(c.result_count(), 0)
} else {
c.selected_entries().iter().cloned().collect()
};
Self::from_text_entries(entries)
}
c @ TelevisionChannel::Dirs(_) => {
let entries = if c.selected_entries().is_empty() {
c.results(c.result_count(), 0)
} else {
c.selected_entries().iter().cloned().collect()
};
Self::new(
entries
.iter()
.map(|entry| PathBuf::from(&entry.name))
.collect(),
)
}
_ => unreachable!(),
}
}
}
impl OnAir for Channel {
fn find(&mut self, pattern: &str) {
self.matcher.find(pattern);
}
fn results(&mut self, num_entries: u32, offset: u32) -> Vec<Entry> {
self.matcher.tick();
self.matcher
.results(num_entries, offset)
.into_iter()
.map(|item| {
let line = item.matched_string;
let display_path =
item.inner.path.to_string_lossy().to_string();
Entry::new(display_path, PreviewType::Files)
.with_value(line)
.with_value_match_indices(&item.match_indices)
.with_icon(FileIcon::from(item.inner.path.as_path()))
.with_line_number(item.inner.line_number)
})
.collect()
}
fn get_result(&self, index: u32) -> Option<Entry> {
self.matcher.get_result(index).map(|item| {
let display_path = item.inner.path.to_string_lossy().to_string();
Entry::new(display_path, PreviewType::Files)
.with_icon(FileIcon::from(item.inner.path.as_path()))
.with_line_number(item.inner.line_number)
})
}
fn selected_entries(&self) -> &FxHashSet<Entry> {
&self.selected_entries
}
fn toggle_selection(&mut self, entry: &Entry) {
if self.selected_entries.contains(entry) {
self.selected_entries.remove(entry);
} else {
self.selected_entries.insert(entry.clone());
}
}
fn result_count(&self) -> u32 {
self.matcher.matched_item_count
}
fn total_count(&self) -> u32 {
self.matcher.total_item_count
}
fn running(&self) -> bool {
self.matcher.status.running || !self.crawl_handle.is_finished()
}
fn shutdown(&self) {
self.crawl_handle.abort();
}
fn supports_preview(&self) -> bool {
true
}
}
/// The maximum file size we're willing to search in.
///
/// This is to prevent taking humongous amounts of memory when searching in
/// a lot of files (e.g. starting tv in $HOME).
const MAX_FILE_SIZE: u64 = 4 * 1024 * 1024;
/// The maximum number of lines we're willing to keep in memory.
///
/// TODO: this should be configurable by the user depending on the amount of
/// memory they have/are willing to use.
///
/// This is to prevent taking humongous amounts of memory when searching in
/// a lot of files (e.g. starting tv in $HOME).
///
/// This is a soft limit, we might go over it a bit.
///
/// A typical line should take somewhere around 100 bytes in memory (for utf8 english text),
/// so this should take around 100 x `10_000_000` = 1GB of memory.
const MAX_LINES_IN_MEM: usize = 10_000_000;
#[allow(clippy::unused_async)]
async fn crawl_for_candidates(
directories: Vec<PathBuf>,
injector: Injector<CandidateLine>,
) {
if directories.is_empty() {
return;
}
let current_dir = std::env::current_dir().unwrap();
let mut walker =
walk_builder(&directories[0], get_default_num_threads(), None, None);
directories[1..].iter().for_each(|path| {
walker.add(path);
});
let lines_in_mem = Arc::new(AtomicUsize::new(0));
walker.build_parallel().run(|| {
let injector = injector.clone();
let current_dir = current_dir.clone();
let lines_in_mem = lines_in_mem.clone();
Box::new(move |result| {
if lines_in_mem.load(std::sync::atomic::Ordering::Relaxed)
> MAX_LINES_IN_MEM
{
return WalkState::Quit;
}
if let Ok(entry) = result {
if entry.file_type().unwrap().is_file() {
if let Ok(m) = entry.metadata() {
if m.len() > MAX_FILE_SIZE {
return WalkState::Continue;
}
}
// try to inject the lines of the file
if let Some(injected_lines) =
try_inject_lines(&injector, &current_dir, entry.path())
{
lines_in_mem.fetch_add(
injected_lines,
std::sync::atomic::Ordering::Relaxed,
);
}
}
}
WalkState::Continue
})
});
}
fn try_inject_lines(
injector: &Injector<CandidateLine>,
current_dir: &PathBuf,
path: &Path,
) -> Option<usize> {
match File::open(path) {
Ok(file) => {
// is the file a text-based file?
let mut reader = std::io::BufReader::new(&file);
let mut buffer = [0u8; 128];
match reader.read(&mut buffer) {
Ok(bytes_read) => {
if bytes_read == 0
|| proportion_of_printable_ascii_characters(
&buffer[..bytes_read],
) < PRINTABLE_ASCII_THRESHOLD
{
debug!("Skipping non-text file {:?}", path);
return None;
}
reader.seek(std::io::SeekFrom::Start(0)).unwrap();
}
Err(e) => {
warn!("Error reading file {:?}: {:?}", path, e);
return None;
}
}
// read the lines of the file
let mut line_number = 0;
let mut injected_lines = 0;
for maybe_line in reader.lines() {
match maybe_line {
Ok(l) => {
line_number += 1;
if l.is_empty() {
trace!("Empty line");
continue;
}
let candidate = CandidateLine::new(
path.strip_prefix(current_dir)
.unwrap_or(path)
.to_path_buf(),
l.clone(),
line_number,
);
let () = injector.push(candidate, |e, cols| {
cols[0] = e.line.clone().into();
});
injected_lines += 1;
}
Err(e) => {
warn!("Error reading line: {:?}", e);
break;
}
}
}
Some(injected_lines)
}
Err(e) => {
warn!("Error opening file {:?}: {:?}", path, e);
None
}
}
}

View File

@ -9,13 +9,8 @@ pub struct Cli {
/// A list of the available channels can be displayed using the
/// `list-channels` command. The channel can also be changed from within
/// the application.
#[arg(
value_enum,
default_value = "files",
index = 1,
verbatim_doc_comment
)]
pub channel: String,
#[arg(value_enum, index = 1, verbatim_doc_comment)]
pub channel: Option<String>,
/// A preview command to use with the stdin channel.
///

View File

@ -4,12 +4,10 @@ use std::path::Path;
use anyhow::{anyhow, Result};
use tracing::debug;
use crate::channels::cable::{parse_preview_kind, PreviewKind};
use crate::channels::{
cable::CableChannelPrototype, entry::PreviewCommand, CliTvChannel,
};
use crate::channels::cable::{parse_preview_kind, CableChannels, PreviewKind};
use crate::channels::{cable::CableChannelPrototype, entry::PreviewCommand};
use crate::cli::args::{Cli, Command};
use crate::config::KeyBindings;
use crate::config::{KeyBindings, DEFAULT_CHANNEL};
use crate::{
cable,
config::{get_config_dir, get_data_dir},
@ -20,7 +18,7 @@ pub mod args;
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone)]
pub struct PostProcessedCli {
pub channel: ParsedCliChannel,
pub channel: CableChannelPrototype,
pub preview_kind: PreviewKind,
pub no_preview: bool,
pub tick_rate: Option<f64>,
@ -40,7 +38,7 @@ pub struct PostProcessedCli {
impl Default for PostProcessedCli {
fn default() -> Self {
Self {
channel: ParsedCliChannel::Builtin(CliTvChannel::Files),
channel: CableChannelPrototype::default(),
preview_kind: PreviewKind::None,
no_preview: false,
tick_rate: None,
@ -85,25 +83,40 @@ impl From<Cli> for PostProcessedCli {
.unwrap()
});
let channel: ParsedCliChannel;
let channel: CableChannelPrototype;
let working_directory: Option<String>;
match parse_channel(&cli.channel) {
Ok(p) => {
channel = p;
working_directory = cli.working_directory;
}
Err(_) => {
// if the path is provided as first argument and it exists, use it as the working
// directory and default to the files channel
if cli.working_directory.is_none()
&& Path::new(&cli.channel).exists()
{
channel = ParsedCliChannel::Builtin(CliTvChannel::Files);
working_directory = Some(cli.channel.clone());
} else {
unknown_channel_exit(&cli.channel);
unreachable!();
let cable_channels = cable::load_cable_channels().unwrap_or_default();
if cli.channel.is_none() {
channel = cable_channels
.get(DEFAULT_CHANNEL)
.expect("Default channel not found in cable channels")
.clone();
working_directory = cli.working_directory;
} else {
let cli_channel = cli.channel.as_ref().unwrap().to_owned();
match parse_channel(&cli_channel, &cable_channels) {
Ok(p) => {
channel = p;
working_directory = cli.working_directory;
}
Err(_) => {
// if the path is provided as first argument and it exists, use it as the working
// directory and default to the files channel
if cli.working_directory.is_none()
&& Path::new(&cli_channel).exists()
{
channel = cable_channels
.get(DEFAULT_CHANNEL)
.expect(
"Default channel not found in cable channels",
)
.clone();
working_directory = Some(cli.channel.unwrap().clone());
} else {
unknown_channel_exit(&cli.channel.unwrap());
unreachable!();
}
}
}
}
@ -138,21 +151,6 @@ fn unknown_channel_exit(channel: &str) {
std::process::exit(1);
}
#[derive(Debug, Clone, PartialEq)]
pub enum ParsedCliChannel {
Builtin(CliTvChannel),
Cable(CableChannelPrototype),
}
impl ParsedCliChannel {
pub fn name(&self) -> String {
match self {
Self::Builtin(c) => c.to_string(),
Self::Cable(c) => c.name.clone(),
}
}
}
const CLI_KEYBINDINGS_DELIMITER: char = ';';
/// Parse a keybindings literal into a `KeyBindings` struct.
@ -174,45 +172,22 @@ fn parse_keybindings_literal(
toml::from_str(&toml_definition).map_err(|e| anyhow!(e))
}
pub fn parse_channel(channel: &str) -> Result<ParsedCliChannel> {
let cable_channels = cable::load_cable_channels().unwrap_or_default();
pub fn parse_channel(
channel: &str,
cable_channels: &CableChannels,
) -> Result<CableChannelPrototype> {
// try to parse the channel as a cable channel
cable_channels
match cable_channels
.iter()
.find(|(k, _)| k.to_lowercase() == channel)
.map_or_else(
|| {
// try to parse the channel as a builtin channel
CliTvChannel::try_from(channel)
.map(ParsedCliChannel::Builtin)
.map_err(|_| anyhow!("Unknown channel: '{}'", channel))
},
|(_, v)| Ok(ParsedCliChannel::Cable(v.clone())),
)
}
pub fn list_cable_channels() -> Vec<String> {
cable::load_cable_channels()
.unwrap_or_default()
.iter()
.map(|(k, _)| k.clone())
.collect()
}
pub fn list_builtin_channels() -> Vec<String> {
CliTvChannel::all_channels()
.iter()
.map(std::string::ToString::to_string)
.collect()
{
Some((_, v)) => Ok(v.clone()),
None => Err(anyhow!("The following channel wasn't found among cable channels: {channel}")),
}
}
pub fn list_channels() {
println!("\x1b[4mBuiltin channels:\x1b[0m");
for c in list_builtin_channels() {
println!("\t{c}");
}
println!("\n\x1b[4mCustom channels:\x1b[0m");
for c in list_cable_channels().iter().map(|c| c.to_lowercase()) {
for c in cable::load_cable_channels().unwrap_or_default().keys() {
println!("\t{c}");
}
}
@ -243,13 +218,18 @@ pub fn list_channels() {
pub fn guess_channel_from_prompt(
prompt: &str,
command_mapping: &FxHashMap<String, String>,
fallback_channel: ParsedCliChannel,
) -> Result<ParsedCliChannel> {
fallback_channel: &str,
cable_channels: &CableChannels,
) -> Result<CableChannelPrototype> {
debug!("Guessing channel from prompt: {}", prompt);
// git checkout -qf
// --- -------- --- <---------
let fallback = cable_channels
.get(fallback_channel)
.expect("Fallback channel not found in cable channels")
.clone();
if prompt.trim().is_empty() {
return Ok(fallback_channel);
return Ok(fallback);
}
let rev_prompt_words = prompt.split_whitespace().rev();
let mut stack = Vec::new();
@ -263,7 +243,7 @@ pub fn guess_channel_from_prompt(
for word in rev_prompt_words.clone() {
// if the stack is empty, we have a match
if stack.is_empty() {
return parse_channel(channel);
return parse_channel(channel, cable_channels);
}
// if the word matches the top of the stack, pop it
if stack.last() == Some(&word) {
@ -272,14 +252,14 @@ pub fn guess_channel_from_prompt(
}
// if the stack is empty, we have a match
if stack.is_empty() {
return parse_channel(channel);
return parse_channel(channel, cable_channels);
}
// reset the stack
stack.clear();
}
debug!("No match found, falling back to default channel");
Ok(fallback_channel)
Ok(fallback)
}
const VERSION_MESSAGE: &str = env!("CARGO_PKG_VERSION");
@ -327,7 +307,7 @@ mod tests {
#[allow(clippy::float_cmp)]
fn test_from_cli() {
let cli = Cli {
channel: "files".to_string(),
channel: Some("files".to_string()),
preview: Some("bat -n --color=always {}".to_string()),
delimiter: ":".to_string(),
working_directory: Some("/home/user".to_string()),
@ -338,7 +318,7 @@ mod tests {
assert_eq!(
post_processed_cli.channel,
ParsedCliChannel::Builtin(CliTvChannel::Files)
CableChannelPrototype::default(),
);
assert_eq!(
post_processed_cli.preview_kind,
@ -359,7 +339,7 @@ mod tests {
#[allow(clippy::float_cmp)]
fn test_from_cli_no_args() {
let cli = Cli {
channel: ".".to_string(),
channel: Some(".".to_string()),
delimiter: ":".to_string(),
..Default::default()
};
@ -368,7 +348,7 @@ mod tests {
assert_eq!(
post_processed_cli.channel,
ParsedCliChannel::Builtin(CliTvChannel::Files)
CableChannelPrototype::default(),
);
assert_eq!(
post_processed_cli.working_directory,
@ -380,7 +360,7 @@ mod tests {
#[test]
fn test_builtin_previewer_files() {
let cli = Cli {
channel: "files".to_string(),
channel: Some("files".to_string()),
preview: Some(":files:".to_string()),
delimiter: ":".to_string(),
..Default::default()
@ -397,7 +377,7 @@ mod tests {
#[test]
fn test_builtin_previewer_env() {
let cli = Cli {
channel: "files".to_string(),
channel: Some("files".to_string()),
preview: Some(":env_var:".to_string()),
delimiter: ":".to_string(),
..Default::default()
@ -414,7 +394,7 @@ mod tests {
#[test]
fn test_custom_keybindings() {
let cli = Cli {
channel: "files".to_string(),
channel: Some("files".to_string()),
preview: Some(":env_var:".to_string()),
delimiter: ":".to_string(),
keybindings: Some(
@ -436,15 +416,17 @@ mod tests {
assert_eq!(post_processed_cli.keybindings, Some(expected));
}
fn guess_channel_from_prompt_setup(
) -> (FxHashMap<String, String>, ParsedCliChannel) {
/// Returns a tuple containing a command mapping and a fallback channel.
fn guess_channel_from_prompt_setup<'a>(
) -> (FxHashMap<String, String>, &'a str, CableChannels) {
let mut command_mapping = FxHashMap::default();
command_mapping.insert("vim".to_string(), "files".to_string());
command_mapping.insert("export".to_string(), "env".to_string());
(
command_mapping,
ParsedCliChannel::Builtin(CliTvChannel::Env),
"env",
cable::load_cable_channels().unwrap_or_default(),
)
}
@ -452,44 +434,53 @@ mod tests {
fn test_guess_channel_from_prompt_present() {
let prompt = "vim -d file1";
let (command_mapping, fallback) = guess_channel_from_prompt_setup();
let (command_mapping, fallback, channels) =
guess_channel_from_prompt_setup();
let channel =
guess_channel_from_prompt(prompt, &command_mapping, fallback)
.unwrap();
let channel = guess_channel_from_prompt(
prompt,
&command_mapping,
fallback,
&channels,
)
.unwrap();
assert_eq!(channel.name(), "files");
assert_eq!(channel.name, "files");
}
#[test]
fn test_guess_channel_from_prompt_fallback() {
let prompt = "git checkout ";
let (command_mapping, fallback) = guess_channel_from_prompt_setup();
let (command_mapping, fallback, channels) =
guess_channel_from_prompt_setup();
let channel = guess_channel_from_prompt(
prompt,
&command_mapping,
fallback.clone(),
fallback,
&channels,
)
.unwrap();
assert_eq!(channel, fallback);
assert_eq!(channel.name, fallback);
}
#[test]
fn test_guess_channel_from_prompt_empty() {
let prompt = "";
let (command_mapping, fallback) = guess_channel_from_prompt_setup();
let (command_mapping, fallback, channels) =
guess_channel_from_prompt_setup();
let channel = guess_channel_from_prompt(
prompt,
&command_mapping,
fallback.clone(),
fallback,
&channels,
)
.unwrap();
assert_eq!(channel, fallback);
assert_eq!(channel.name, fallback);
}
}

View File

@ -36,6 +36,15 @@ pub struct AppConfig {
pub frame_rate: f64,
#[serde(default = "default_tick_rate")]
pub tick_rate: f64,
/// The default channel to use when no channel is specified
#[serde(default = "default_channel")]
pub default_channel: String,
}
pub const DEFAULT_CHANNEL: &str = "files";
fn default_channel() -> String {
DEFAULT_CHANNEL.to_string()
}
impl Hash for AppConfig {

View File

@ -109,7 +109,6 @@ pub struct Theme {
// modes
pub channel_mode_fg: Color,
pub remote_control_mode_fg: Color,
pub send_to_channel_mode_fg: Color,
}
impl Theme {
@ -180,7 +179,6 @@ struct Inner {
//modes
channel_mode_fg: String,
remote_control_mode_fg: String,
send_to_channel_mode_fg: String,
}
impl<'de> Deserialize<'de> for Theme {
@ -308,15 +306,6 @@ impl<'de> Deserialize<'de> for Theme {
&inner.remote_control_mode_fg
))
})?,
send_to_channel_mode_fg: Color::from_str(
&inner.send_to_channel_mode_fg,
)
.ok_or_else(|| {
serde::de::Error::custom(format!(
"invalid color {}",
&inner.send_to_channel_mode_fg
))
})?,
})
}
}
@ -439,7 +428,6 @@ impl Into<ModeColorscheme> for &Theme {
ModeColorscheme {
channel: (&self.channel_mode_fg).into(),
remote_control: (&self.remote_control_mode_fg).into(),
send_to_channel: (&self.send_to_channel_mode_fg).into(),
}
}
}
@ -466,7 +454,6 @@ mod tests {
preview_title_fg = "bright-white"
channel_mode_fg = "bright-white"
remote_control_mode_fg = "bright-white"
send_to_channel_mode_fg = "bright-white"
"##;
let theme: Theme = toml::from_str(theme_content).unwrap();
assert_eq!(
@ -496,10 +483,6 @@ mod tests {
theme.remote_control_mode_fg,
Color::Ansi(ANSIColor::BrightWhite)
);
assert_eq!(
theme.send_to_channel_mode_fg,
Color::Ansi(ANSIColor::BrightWhite)
);
}
#[test]
@ -519,7 +502,6 @@ mod tests {
preview_title_fg = "bright-white"
channel_mode_fg = "bright-white"
remote_control_mode_fg = "bright-white"
send_to_channel_mode_fg = "bright-white"
"##;
let theme: Theme = toml::from_str(theme_content).unwrap();
assert_eq!(theme.background, None);
@ -549,9 +531,5 @@ mod tests {
theme.remote_control_mode_fg,
Color::Ansi(ANSIColor::BrightWhite)
);
assert_eq!(
theme.send_to_channel_mode_fg,
Color::Ansi(ANSIColor::BrightWhite)
);
}
}

View File

@ -5,8 +5,8 @@ use std::process::exit;
use anyhow::Result;
use clap::Parser;
use television::channels::cable::PreviewKind;
use television::cli::parse_channel;
use television::cable;
use television::channels::cable::{CableChannels, PreviewKind};
use television::utils::clipboard::CLIPBOARD;
use tracing::{debug, error, info};
@ -16,8 +16,7 @@ use television::channels::{
};
use television::cli::{
args::{Cli, Command},
guess_channel_from_prompt, list_channels, ParsedCliChannel,
PostProcessedCli,
guess_channel_from_prompt, list_channels, PostProcessedCli,
};
use television::config::{merge_keybindings, Config, ConfigEnv};
@ -44,6 +43,9 @@ async fn main() -> Result<()> {
debug!("Loading configuration...");
let mut config = Config::new(&ConfigEnv::init()?)?;
debug!("Loading cable channels...");
let cable_channels = cable::load_cable_channels().unwrap_or_default();
// optionally handle subcommands
debug!("Handling subcommands...");
args.command
@ -59,8 +61,12 @@ async fn main() -> Result<()> {
// determine the channel to use based on the CLI arguments and configuration
debug!("Determining channel...");
let channel =
determine_channel(args.clone(), &config, is_readable_stdin())?;
let channel = determine_channel(
args.clone(),
&config,
is_readable_stdin(),
&cable_channels,
)?;
CLIPBOARD.with(<_>::default);
@ -147,6 +153,7 @@ pub fn determine_channel(
args: PostProcessedCli,
config: &Config,
readable_stdin: bool,
cable_channels: &CableChannels,
) -> Result<TelevisionChannel> {
if readable_stdin {
debug!("Using stdin channel");
@ -164,29 +171,23 @@ pub fn determine_channel(
let channel = guess_channel_from_prompt(
&prompt,
&config.shell_integration.commands,
parse_channel(&config.shell_integration.fallback_channel)?,
&config.shell_integration.fallback_channel,
cable_channels,
)?;
debug!("Using guessed channel: {:?}", channel);
match channel {
ParsedCliChannel::Builtin(c) => Ok(c.to_channel()),
ParsedCliChannel::Cable(c) => {
Ok(TelevisionChannel::Cable(c.into()))
}
}
Ok(TelevisionChannel::Cable(channel.into()))
} else {
debug!("Using {:?} channel", args.channel);
match args.channel {
ParsedCliChannel::Builtin(c) => Ok(c.to_channel()),
ParsedCliChannel::Cable(c) => {
Ok(TelevisionChannel::Cable(c.into()))
}
}
Ok(TelevisionChannel::Cable(args.channel.into()))
}
}
#[cfg(test)]
mod tests {
use rustc_hash::FxHashMap;
use television::{
cable::load_cable_channels, channels::cable::CableChannelPrototype,
};
use super::*;
@ -195,12 +196,17 @@ mod tests {
config: &Config,
readable_stdin: bool,
expected_channel: &TelevisionChannel,
cable_channels: Option<CableChannels>,
) {
let channels: CableChannels = cable_channels
.unwrap_or_else(|| load_cable_channels().unwrap_or_default());
let channel =
determine_channel(args.clone(), config, readable_stdin).unwrap();
determine_channel(args.clone(), config, readable_stdin, &channels)
.unwrap();
assert!(
channel.name() == expected_channel.name(),
assert_eq!(
channel.name(),
expected_channel.name(),
"Expected {:?} but got {:?}",
expected_channel.name(),
channel.name()
@ -208,10 +214,9 @@ mod tests {
}
#[tokio::test]
/// Test that the channel is stdin when stdin is readable
async fn test_determine_channel_readable_stdin() {
let channel = television::cli::ParsedCliChannel::Builtin(
television::channels::CliTvChannel::Env,
);
let channel = CableChannelPrototype::default();
let args = PostProcessedCli {
channel,
..Default::default()
@ -222,14 +227,16 @@ mod tests {
&config,
true,
&TelevisionChannel::Stdin(StdinChannel::new(PreviewType::None)),
None,
);
}
#[tokio::test]
async fn test_determine_channel_autocomplete_prompt() {
let autocomplete_prompt = Some("cd".to_string());
let expected_channel = television::channels::TelevisionChannel::Dirs(
television::channels::dirs::Channel::default(),
let expected_channel = TelevisionChannel::Cable(
CableChannelPrototype::new("dirs", "ls {}", false, None, None)
.into(),
);
let args = PostProcessedCli {
autocomplete_prompt,
@ -251,14 +258,19 @@ mod tests {
};
config.shell_integration.merge_triggers();
assert_is_correct_channel(&args, &config, false, &expected_channel);
assert_is_correct_channel(
&args,
&config,
false,
&expected_channel,
None,
);
}
#[tokio::test]
async fn test_determine_channel_standard_case() {
let channel = television::cli::ParsedCliChannel::Builtin(
television::channels::CliTvChannel::Dirs,
);
let channel =
CableChannelPrototype::new("dirs", "", false, None, None);
let args = PostProcessedCli {
channel,
..Default::default()
@ -268,9 +280,11 @@ mod tests {
&args,
&config,
false,
&TelevisionChannel::Dirs(
television::channels::dirs::Channel::default(),
&TelevisionChannel::Cable(
CableChannelPrototype::new("dirs", "", false, None, None)
.into(),
),
None,
);
}

View File

@ -107,7 +107,7 @@ impl Preview {
}
}
#[derive(Debug, Default, Clone, PartialEq, Hash)]
#[derive(Debug, Clone, PartialEq, Hash)]
pub struct PreviewState {
pub enabled: bool,
pub preview: Arc<Preview>,
@ -115,6 +115,17 @@ pub struct PreviewState {
pub target_line: Option<u16>,
}
impl Default for PreviewState {
fn default() -> Self {
PreviewState {
enabled: false,
preview: Arc::new(Preview::default()),
scroll: 0,
target_line: None,
}
}
}
const PREVIEW_MIN_SCROLL_LINES: u16 = 3;
impl PreviewState {

View File

@ -51,5 +51,4 @@ pub struct InputColorscheme {
pub struct ModeColorscheme {
pub channel: Color,
pub remote_control: Color,
pub send_to_channel: Color,
}

View File

@ -56,13 +56,6 @@ impl KeyBindings {
&[Action::CopyEntryToClipboard],
),
),
(
DisplayableAction::SendToChannel,
serialized_keys_for_actions(
self,
&[Action::ToggleSendToChannel],
),
),
(
DisplayableAction::ToggleRemoteControl,
serialized_keys_for_actions(
@ -101,41 +94,12 @@ impl KeyBindings {
),
]);
// send to channel mode keybindings
let send_to_channel_bindings: FxHashMap<
DisplayableAction,
Vec<String>,
> = FxHashMap::from_iter(vec![
(
DisplayableAction::ResultsNavigation,
serialized_keys_for_actions(
self,
&[Action::SelectPrevEntry, Action::SelectNextEntry],
),
),
(
DisplayableAction::SelectEntry,
serialized_keys_for_actions(self, &[Action::ConfirmSelection]),
),
(
DisplayableAction::Cancel,
serialized_keys_for_actions(
self,
&[Action::ToggleSendToChannel],
),
),
]);
FxHashMap::from_iter(vec![
(Mode::Channel, DisplayableKeybindings::new(channel_bindings)),
(
Mode::RemoteControl,
DisplayableKeybindings::new(remote_control_bindings),
),
(
Mode::SendToChannel,
DisplayableKeybindings::new(send_to_channel_bindings),
),
])
}
}
@ -167,7 +131,6 @@ pub enum DisplayableAction {
PreviewNavigation,
SelectEntry,
CopyEntryToClipboard,
SendToChannel,
ToggleRemoteControl,
Cancel,
Quit,
@ -183,7 +146,6 @@ impl Display for DisplayableAction {
DisplayableAction::CopyEntryToClipboard => {
"Copy entry to clipboard"
}
DisplayableAction::SendToChannel => "Send to channel",
DisplayableAction::ToggleRemoteControl => "Toggle Remote control",
DisplayableAction::Cancel => "Cancel",
DisplayableAction::Quit => "Quit",
@ -207,12 +169,6 @@ pub fn build_keybindings_table<'a>(
&keybindings[&mode],
colorscheme,
),
Mode::SendToChannel => {
build_keybindings_table_for_channel_transitions(
&keybindings[&mode],
colorscheme,
)
}
}
}
@ -268,18 +224,6 @@ fn build_keybindings_table_for_channel<'a>(
colorscheme.mode.channel,
));
// Send to channel
let send_to_channel_keys = keybindings
.bindings
.get(&DisplayableAction::SendToChannel)
.unwrap();
let send_to_channel_row = Row::new(build_cells_for_group(
"Send results to",
send_to_channel_keys,
colorscheme.help.metadata_field_name_fg,
colorscheme.mode.channel,
));
// Switch channels
let switch_channels_keys = keybindings
.bindings
@ -300,7 +244,6 @@ fn build_keybindings_table_for_channel<'a>(
preview_row,
select_entry_row,
copy_entry_row,
send_to_channel_row,
switch_channels_row,
],
widths,
@ -353,52 +296,6 @@ fn build_keybindings_table_for_channel_selection<'a>(
)
}
fn build_keybindings_table_for_channel_transitions<'a>(
keybindings: &'a DisplayableKeybindings,
colorscheme: &'a Colorscheme,
) -> Table<'a> {
// Results navigation
let results_navigation_keys = keybindings
.bindings
.get(&DisplayableAction::ResultsNavigation)
.unwrap();
let results_row = Row::new(build_cells_for_group(
"Browse channels",
results_navigation_keys,
colorscheme.help.metadata_field_name_fg,
colorscheme.mode.send_to_channel,
));
// Select entry
let select_entry_keys = keybindings
.bindings
.get(&DisplayableAction::SelectEntry)
.unwrap();
let select_entry_row = Row::new(build_cells_for_group(
"Send to channel",
select_entry_keys,
colorscheme.help.metadata_field_name_fg,
colorscheme.mode.send_to_channel,
));
// Cancel
let cancel_keys = keybindings
.bindings
.get(&DisplayableAction::Cancel)
.unwrap();
let cancel_row = Row::new(build_cells_for_group(
"Cancel",
cancel_keys,
colorscheme.help.metadata_field_name_fg,
colorscheme.mode.send_to_channel,
));
Table::new(
vec![results_row, select_entry_row, cancel_row],
vec![Constraint::Fill(1), Constraint::Fill(2)],
)
}
fn build_cells_for_group<'a>(
group_name: &str,
keys: &'a [String],

View File

@ -15,7 +15,6 @@ impl Display for Mode {
match self {
Mode::Channel => write!(f, "Channel"),
Mode::RemoteControl => write!(f, "Remote Control"),
Mode::SendToChannel => write!(f, "Send to Channel"),
}
}
}

View File

@ -6,6 +6,5 @@ pub fn mode_color(mode: Mode, colorscheme: &ModeColorscheme) -> Color {
match mode {
Mode::Channel => colorscheme.channel,
Mode::RemoteControl => colorscheme.remote_control,
Mode::SendToChannel => colorscheme.send_to_channel,
}
}

View File

@ -1,9 +1,8 @@
use crate::action::Action;
use crate::cable::load_cable_channels;
use crate::channels::entry::{Entry, ENTRY_PLACEHOLDER};
use crate::channels::entry::{Entry, PreviewType, ENTRY_PLACEHOLDER};
use crate::channels::{
remote_control::{load_builtin_channels, RemoteControl},
OnAir, TelevisionChannel, UnitChannel,
remote_control::RemoteControl, OnAir, TelevisionChannel,
};
use crate::config::{Config, Theme};
use crate::draw::{ChannelState, Ctx, TvState};
@ -28,7 +27,6 @@ use tokio::sync::mpsc::UnboundedSender;
pub enum Mode {
Channel,
RemoteControl,
SendToChannel,
}
#[derive(PartialEq, Copy, Clone, Hash, Eq, Debug, Serialize, Deserialize)]
@ -75,9 +73,6 @@ impl Television {
}
let previewer = Previewer::new(Some(config.previewers.clone().into()));
let cable_channels = load_cable_channels().unwrap_or_default();
let builtin_channels = load_builtin_channels(Some(
&cable_channels.keys().collect::<Vec<_>>(),
));
let app_metadata = AppMetadata::new(
env!("CARGO_PKG_VERSION").to_string(),
@ -101,10 +96,9 @@ impl Television {
let remote_control = if no_remote {
None
} else {
Some(TelevisionChannel::RemoteControl(RemoteControl::new(
builtin_channels,
Some(cable_channels),
)))
Some(TelevisionChannel::RemoteControl(RemoteControl::new(Some(
cable_channels,
))))
};
if no_help {
@ -146,11 +140,8 @@ impl Television {
pub fn init_remote_control(&mut self) {
let cable_channels = load_cable_channels().unwrap_or_default();
let builtin_channels = load_builtin_channels(Some(
&cable_channels.keys().collect::<Vec<_>>(),
));
self.remote_control = Some(TelevisionChannel::RemoteControl(
RemoteControl::new(builtin_channels, Some(cable_channels)),
RemoteControl::new(Some(cable_channels)),
));
}
@ -182,12 +173,13 @@ impl Television {
)
}
pub fn current_channel(&self) -> UnitChannel {
UnitChannel::from(&self.channel)
pub fn current_channel(&self) -> String {
self.channel.name()
}
pub fn change_channel(&mut self, channel: TelevisionChannel) {
self.preview_state.reset();
self.preview_state.enabled = channel.supports_preview();
self.reset_picker_selection();
self.reset_picker_input();
self.current_pattern = EMPTY_STRING.to_string();
@ -203,7 +195,7 @@ impl Television {
.as_str(),
);
}
Mode::RemoteControl | Mode::SendToChannel => {
Mode::RemoteControl => {
self.remote_control.as_mut().unwrap().find(pattern);
}
}
@ -233,7 +225,7 @@ impl Television {
}
None
}
Mode::RemoteControl | Mode::SendToChannel => {
Mode::RemoteControl => {
if let Some(i) = self.rc_picker.selected() {
return self
.remote_control
@ -268,7 +260,7 @@ impl Television {
Mode::Channel => {
(self.channel.result_count(), &mut self.results_picker)
}
Mode::RemoteControl | Mode::SendToChannel => (
Mode::RemoteControl => (
self.remote_control.as_ref().unwrap().total_count(),
&mut self.rc_picker,
),
@ -288,7 +280,7 @@ impl Television {
Mode::Channel => {
(self.channel.result_count(), &mut self.results_picker)
}
Mode::RemoteControl | Mode::SendToChannel => (
Mode::RemoteControl => (
self.remote_control.as_ref().unwrap().total_count(),
&mut self.rc_picker,
),
@ -306,7 +298,7 @@ impl Television {
fn reset_picker_selection(&mut self) {
match self.mode {
Mode::Channel => self.results_picker.reset_selection(),
Mode::RemoteControl | Mode::SendToChannel => {
Mode::RemoteControl => {
self.rc_picker.reset_selection();
}
}
@ -315,7 +307,7 @@ impl Television {
fn reset_picker_input(&mut self) {
match self.mode {
Mode::Channel => self.results_picker.reset_input(),
Mode::RemoteControl | Mode::SendToChannel => {
Mode::RemoteControl => {
self.rc_picker.reset_input();
}
}
@ -376,7 +368,9 @@ impl Television {
&mut self,
selected_entry: &Entry,
) -> Result<()> {
if self.config.ui.show_preview_panel && self.channel.supports_preview()
if self.config.ui.show_preview_panel
&& self.channel.supports_preview()
&& !matches!(selected_entry.preview_type, PreviewType::None)
{
// preview content
if let Some(preview) = self
@ -453,9 +447,7 @@ impl Television {
pub fn handle_input_action(&mut self, action: &Action) {
let input = match self.mode {
Mode::Channel => &mut self.results_picker.input,
Mode::RemoteControl | Mode::SendToChannel => {
&mut self.rc_picker.input
}
Mode::RemoteControl => &mut self.rc_picker.input,
};
input.handle(convert_action_to_input_request(action).unwrap());
match action {
@ -493,27 +485,6 @@ impl Television {
self.reset_picker_selection();
self.mode = Mode::Channel;
}
Mode::SendToChannel => {}
}
}
pub fn handle_toggle_send_to_channel(&mut self) {
if self.remote_control.is_none() {
return;
}
match self.mode {
Mode::Channel | Mode::RemoteControl => {
self.mode = Mode::SendToChannel;
self.remote_control = Some(TelevisionChannel::RemoteControl(
RemoteControl::with_transitions_from(&self.channel),
));
}
Mode::SendToChannel => {
self.reset_picker_input();
self.remote_control.as_mut().unwrap().find(EMPTY_STRING);
self.reset_picker_selection();
self.mode = Mode::Channel;
}
}
}
@ -550,18 +521,6 @@ impl Television {
self.change_channel(new_channel);
}
}
Mode::SendToChannel => {
if let Some(entry) = self.get_selected_entry(None) {
let new_channel = self
.channel
.transition_to(entry.name.as_str().try_into()?);
self.reset_picker_selection();
self.reset_picker_input();
self.remote_control.as_mut().unwrap().find(EMPTY_STRING);
self.mode = Mode::Channel;
self.change_channel(new_channel);
}
}
}
Ok(())
}
@ -644,9 +603,6 @@ impl Television {
Action::CopyEntryToClipboard => {
self.handle_copy_entry_to_clipboard();
}
Action::ToggleSendToChannel => {
self.handle_toggle_send_to_channel();
}
Action::ToggleHelp => {
if self.no_help {
return Ok(());

View File

@ -1,5 +1,8 @@
use std::process::Command;
#[cfg(not(unix))]
use tracing::warn;
use super::shell::Shell;
pub fn shell_command(interactive: bool) -> Command {
@ -17,5 +20,10 @@ pub fn shell_command(interactive: bool) -> Command {
cmd.arg("-i");
}
#[cfg(not(unix))]
if interactive {
warn!("Interactive mode is not supported on Windows.");
}
cmd
}

View File

@ -3,7 +3,7 @@ use std::{collections::HashSet, path::PathBuf, time::Duration};
use television::{
action::Action,
app::{App, AppOptions},
channels::TelevisionChannel,
channels::{cable::CableChannelPrototype, TelevisionChannel},
config::default_config_from_file,
};
use tokio::{task::JoinHandle, time::timeout};
@ -33,9 +33,7 @@ fn setup_app(
.join("tests")
.join("target_dir");
std::env::set_current_dir(&target_dir).unwrap();
TelevisionChannel::Files(television::channels::files::Channel::new(
vec![target_dir],
))
TelevisionChannel::Cable(CableChannelPrototype::default().into())
});
let mut config = default_config_from_file().unwrap();
// this speeds up the tests