refactor!(cable): update cable channel preview configuration format + add custom preview offsets (#511)

BREAKING CHANGE: the format of the cable channel files and more
specifically the preview specification is updated to be a single table
named `preview` (with keys `command`, `delimiter`, and `offset`) instead
of three flat fields.

```toml
preview_command = "echo 3"
preview_delimiter = " "
preview_offset = "{1}"
```

becomes: 

```toml
preview.command = "echo 3"
preview.delimiter = " "
preview.offset = "{1}"
```
This commit is contained in:
Alex Pasmantier 2025-05-16 01:07:12 +02:00 committed by GitHub
parent 39dd9efd5d
commit ca09c503ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 237 additions and 232 deletions

View File

@ -472,11 +472,11 @@ pub fn draw(c: &mut Criterion) {
let backend = TestBackend::new(width, height); let backend = TestBackend::new(width, height);
let terminal = Terminal::new(backend).unwrap(); let terminal = Terminal::new(backend).unwrap();
let (tx, _) = tokio::sync::mpsc::unbounded_channel(); let (tx, _) = tokio::sync::mpsc::unbounded_channel();
let channel = ChannelPrototype::default(); let channel_prototype = ChannelPrototype::default();
// Wait for the channel to finish loading // Wait for the channel to finish loading
let mut tv = Television::new( let mut tv = Television::new(
tx, tx,
channel, &channel_prototype,
config, config,
None, None,
false, false,

View File

@ -2,26 +2,27 @@
[[cable_channel]] [[cable_channel]]
name = "files" name = "files"
source_command = "fd -t f" source_command = "fd -t f"
preview_command = "bat -n --color=always {}" preview.command = "bat -n --color=always {}"
# Text # Text
[[cable_channel]] [[cable_channel]]
name = "text" name = "text"
source_command = "rg . --no-heading --line-number" source_command = "rg . --no-heading --line-number"
preview_command = "bat -n --color=always {0} -H {1}" preview.command = "bat -n --color=always {0}"
preview_delimiter = ":" preview.delimiter = ":"
preview.offset = "{1}"
# Directories # Directories
[[cable_channel]] [[cable_channel]]
name = "dirs" name = "dirs"
source_command = "fd -t d" source_command = "fd -t d"
preview_command = "ls -la --color=always {}" preview.command = "ls -la --color=always {}"
# Environment variables # Environment variables
[[cable_channel]] [[cable_channel]]
name = "env" name = "env"
source_command = "printenv" source_command = "printenv"
preview_command = "cut -d= -f2 <<< ${0} | cut -d\" \" -f2- | sed 's/:/\\n/g'" preview.command = "cut -d= -f2 <<< ${0} | cut -d\" \" -f2- | sed 's/:/\\n/g'"
# Aliases # Aliases
[[cable_channel]] [[cable_channel]]
@ -32,47 +33,50 @@ interactive = true
# GIT # GIT
[[cable_channel]] [[cable_channel]]
name = "git-repos" name = "git-repos"
# this is a MacOS version but feel free to change it to fit your needs # this is a MacOS version but feel free to override it to fit your needs
source_command = "fd -g .git -HL -t d -d 10 --prune ~ -E 'Library' -E 'Application Support' --exec dirname {}" 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" preview.command = "cd {} && git log -n 200 --pretty=medium --all --graph --color"
[[cable_channel]] [[cable_channel]]
name = "git-diff" name = "git-diff"
source_command = "git diff --name-only" source_command = "git diff --name-only"
preview_command = "git diff --color=always {0}" preview.command = "git diff --color=always {0}"
[[cable_channel]] [[cable_channel]]
name = "git-reflog" name = "git-reflog"
source_command = 'git reflog' source_command = 'git reflog'
preview_command = 'git show -p --stat --pretty=fuller --color=always {0}' preview.command = 'git show -p --stat --pretty=fuller --color=always {0}'
[[cable_channel]] [[cable_channel]]
name = "git-log" 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}" preview.command = 'git show -p --stat --pretty=fuller --color=always {0}'
[[cable_channel]] [[cable_channel]]
name = "git-branch" name = "git-branch"
source_command = "git --no-pager branch --all --format=\"%(refname:short)\"" source_command = "git --no-pager branch --all --format=\"%(refname:short)\""
preview_command = "git show -p --stat --pretty=fuller --color=always {0}" preview.command = 'git show -p --stat --pretty=fuller --color=always {0}'
# Docker # Docker
[[cable_channel]] [[cable_channel]]
name = "docker-images" name = "docker-images"
source_command = "docker image list --format \"{{.ID}}\"" source_command = "docker image list --format \"{{.ID}}\""
preview_command = "docker image inspect {0} | jq -C" preview.command = "docker image inspect {0} | jq -C"
# S3 # S3
[[cable_channel]] [[cable_channel]]
name = "s3-buckets" name = "s3-buckets"
source_command = "aws s3 ls | cut -d \" \" -f 3" source_command = "aws s3 ls | cut -d \" \" -f 3"
preview_command = "aws s3 ls s3://{0}" preview.command = "aws s3 ls s3://{0}"
# Dotfiles # Dotfiles
[[cable_channel]] [[cable_channel]]
name = "my-dotfiles" name = "my-dotfiles"
source_command = "fd -t f . $HOME/.config" source_command = "fd -t f . $HOME/.config"
preview_command = "bat -n --color=always {}" preview.command = "bat -n --color=always {}"
# Shell history # Shell history
[[cable_channel]] [[cable_channel]]

View File

@ -2,13 +2,13 @@
[[cable_channel]] [[cable_channel]]
name = "files" name = "files"
source_command = "Get-ChildItem -Recurse -File | Select-Object -ExpandProperty FullName" source_command = "Get-ChildItem -Recurse -File | Select-Object -ExpandProperty FullName"
preview_command = "bat -n --color=always {}" preview.command = "bat -n --color=always {}"
# Directories # Directories
[[cable_channel]] [[cable_channel]]
name = "dirs" name = "dirs"
source_command = "Get-ChildItem -Recurse -Directory | Select-Object -ExpandProperty FullName" source_command = "Get-ChildItem -Recurse -Directory | Select-Object -ExpandProperty FullName"
preview_command = "ls -l {}" preview.command = "ls -l {}"
# Environment variables # Environment variables
[[cable_channel]] [[cable_channel]]
@ -24,39 +24,39 @@ source_command = "Get-Alias"
[[cable_channel]] [[cable_channel]]
name = "git-repos" 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" 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" preview.command = "cd '{}' ; git log -n 200 --pretty=medium --all --graph --color"
[[cable_channel]] [[cable_channel]]
name = "git-diff" name = "git-diff"
source_command = "git diff --name-only" source_command = "git diff --name-only"
preview_command = "git diff --color=always {}" preview.command = "git diff --color=always {}"
[[cable_channel]] [[cable_channel]]
name = "git-reflog" name = "git-reflog"
source_command = "git reflog" source_command = "git reflog"
preview_command = "git show -p --stat --pretty=fuller --color=always {0}" preview.command = "git show -p --stat --pretty=fuller --color=always {0}"
[[cable_channel]] [[cable_channel]]
name = "git-log" 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}" preview.command = "git show -p --stat --pretty=fuller --color=always {0}"
[[cable_channel]] [[cable_channel]]
name = "git-branch" name = "git-branch"
source_command = "git branch --all --format='%(refname:short)'" source_command = "git branch --all --format='%(refname:short)'"
preview_command = "git show -p --stat --pretty=fuller --color=always {0}" preview.command = "git show -p --stat --pretty=fuller --color=always {0}"
# Docker # Docker
[[cable_channel]] [[cable_channel]]
name = "docker-images" name = "docker-images"
source_command = "docker image ls --format '{{.ID}}'" source_command = "docker image ls --format '{{.ID}}'"
preview_command = "docker image inspect {0} | jq -C" preview.command = "docker image inspect {0} | jq -C"
# Dotfiles (adapted to common Windows dotfile locations) # Dotfiles (adapted to common Windows dotfile locations)
[[cable_channel]] [[cable_channel]]
name = "my-dotfiles" name = "my-dotfiles"
source_command = "Get-ChildItem -Recurse -File -Path \"$env:USERPROFILE\\AppData\\Roaming\\\"" source_command = "Get-ChildItem -Recurse -File -Path \"$env:USERPROFILE\\AppData\\Roaming\\\""
preview_command = "bat -n --color=always {}" preview.command = "bat -n --color=always {}"
# Shell history # Shell history
[[cable_channel]] [[cable_channel]]

View File

@ -137,7 +137,7 @@ const ACTION_BUF_SIZE: usize = 8;
impl App { impl App {
pub fn new( pub fn new(
channel_prototype: ChannelPrototype, channel_prototype: &ChannelPrototype,
config: Config, config: Config,
input: Option<String>, input: Option<String>,
options: AppOptions, options: AppOptions,

View File

@ -80,11 +80,10 @@ pub fn load_cable() -> Result<Cable> {
}, },
); );
debug!( debug!("Loaded {} custom cable channels", prototypes.len());
"Loaded {} default and {} custom prototypes", if prototypes.is_empty() {
default_prototypes.prototypes.len(), debug!("No custom cable channels found");
prototypes.len() }
);
let mut cable_channels = FxHashMap::default(); let mut cable_channels = FxHashMap::default();
// custom prototypes take precedence over default ones // custom prototypes take precedence over default ones

View File

@ -6,14 +6,14 @@ use rustc_hash::{FxBuildHasher, FxHashSet};
use tracing::debug; use tracing::debug;
use crate::channels::{ use crate::channels::{
entry::Entry, entry::Entry, preview::PreviewCommand, prototypes::ChannelPrototype,
preview::PreviewCommand,
prototypes::{ChannelPrototype, DEFAULT_DELIMITER},
}; };
use crate::matcher::Matcher; use crate::matcher::Matcher;
use crate::matcher::{config::Config, injector::Injector}; use crate::matcher::{config::Config, injector::Injector};
use crate::utils::command::shell_command; use crate::utils::command::shell_command;
use super::prototypes::format_prototype_string;
pub struct Channel { pub struct Channel {
pub name: String, pub name: String,
matcher: Matcher<String>, matcher: Matcher<String>,
@ -24,53 +24,28 @@ pub struct Channel {
impl Default for Channel { impl Default for Channel {
fn default() -> Self { fn default() -> Self {
Self::new( Self::new(&ChannelPrototype::new(
"files", "files",
"find . -type f", "find . -type f",
false, false,
Some(PreviewCommand::new("cat {}", ":", None)), Some(PreviewCommand::new("cat {}", ":", None)),
) ))
}
}
impl From<ChannelPrototype> for Channel {
fn from(prototype: ChannelPrototype) -> Self {
Self::new(
&prototype.name,
&prototype.source_command,
prototype.interactive,
match prototype.preview_command {
Some(command) => Some(PreviewCommand::new(
&command,
&prototype
.preview_delimiter
.unwrap_or(DEFAULT_DELIMITER.to_string()),
prototype.preview_offset,
)),
None => None,
},
)
} }
} }
impl Channel { impl Channel {
pub fn new( pub fn new(prototype: &ChannelPrototype) -> Self {
name: &str,
entries_command: &str,
interactive: bool,
preview_command: Option<PreviewCommand>,
) -> Self {
let matcher = Matcher::new(Config::default()); let matcher = Matcher::new(Config::default());
let injector = matcher.injector(); let injector = matcher.injector();
let crawl_handle = tokio::spawn(load_candidates( let crawl_handle = tokio::spawn(load_candidates(
entries_command.to_string(), prototype.source_command.to_string(),
interactive, prototype.interactive,
injector, injector,
)); ));
Self { Self {
matcher, matcher,
preview_command, preview_command: prototype.preview_command.clone(),
name: name.to_string(), name: prototype.name.to_string(),
selected_entries: HashSet::with_hasher(FxBuildHasher), selected_entries: HashSet::with_hasher(FxBuildHasher),
crawl_handle, crawl_handle,
} }
@ -94,8 +69,27 @@ impl Channel {
pub fn get_result(&self, index: u32) -> Option<Entry> { pub fn get_result(&self, index: u32) -> Option<Entry> {
self.matcher.get_result(index).map(|item| { self.matcher.get_result(index).map(|item| {
let path = item.matched_string; let name = item.matched_string;
Entry::new(path) if let Some(cmd) = &self.preview_command {
if let Some(offset_expr) = &cmd.offset_expr {
let offset_string = format_prototype_string(
offset_expr,
&name,
&cmd.delimiter,
);
let offset_str = {
offset_string
.strip_prefix('\'')
.and_then(|s| s.strip_suffix('\''))
.unwrap_or(&offset_string)
};
return Entry::new(name).with_line_number(
offset_str.parse::<usize>().unwrap(),
);
}
}
Entry::new(name)
}) })
} }

View File

@ -1,21 +1,27 @@
use std::fmt::Display; use std::fmt::Display;
use crate::channels::{ use serde::Deserialize;
entry::Entry,
prototypes::{ChannelPrototype, DEFAULT_DELIMITER},
};
use lazy_regex::{regex, Lazy, Regex};
use tracing::debug;
static CMD_RE: &Lazy<Regex> = regex!(r"\{(\d+)\}"); use crate::channels::{entry::Entry, prototypes::format_prototype_string};
#[derive(Debug, Clone, Eq, PartialEq, Hash, Default)] #[derive(Debug, Clone, Eq, PartialEq, Hash, Default, Deserialize)]
pub struct PreviewCommand { pub struct PreviewCommand {
pub command: String, pub command: String,
#[serde(default = "default_delimiter")]
pub delimiter: String, pub delimiter: String,
#[serde(rename = "offset")]
pub offset_expr: Option<String>, pub offset_expr: Option<String>,
} }
pub const DEFAULT_DELIMITER: &str = " ";
/// The default delimiter to use for the preview command to use to split
/// entries into multiple referenceable parts.
#[allow(clippy::unnecessary_wraps)]
fn default_delimiter() -> String {
DEFAULT_DELIMITER.to_string()
}
impl PreviewCommand { impl PreviewCommand {
pub fn new( pub fn new(
command: &str, command: &str,
@ -47,43 +53,7 @@ impl PreviewCommand {
/// assert_eq!(formatted_command, "something 'a:given:entry:to:preview' 'entry' 'a'"); /// assert_eq!(formatted_command, "something 'a:given:entry:to:preview' 'entry' 'a'");
/// ``` /// ```
pub fn format_with(&self, entry: &Entry) -> String { pub fn format_with(&self, entry: &Entry) -> String {
let parts = entry.name.split(&self.delimiter).collect::<Vec<&str>>(); format_prototype_string(&self.command, &entry.name, &self.delimiter)
let mut formatted_command = self
.command
.replace("{}", format!("'{}'", entry.name).as_str());
debug!("FORMATTED_COMMAND: {formatted_command}");
debug!("PARTS: {parts:?}");
formatted_command = CMD_RE
.replace_all(&formatted_command, |caps: &regex::Captures| {
let index =
caps.get(1).unwrap().as_str().parse::<usize>().unwrap();
format!("'{}'", parts[index])
})
.to_string();
formatted_command
}
}
impl From<&ChannelPrototype> for Option<PreviewCommand> {
fn from(value: &ChannelPrototype) -> Self {
if let Some(command) = value.preview_command.as_ref() {
let delimiter = value
.preview_delimiter
.as_ref()
.map_or(DEFAULT_DELIMITER, |v| v);
let offset_expr = value.preview_offset.clone();
// FIXME: handle offset here (side note: we don't want to reparse the offset
// expression for each entry, so maybe just parse it once and try to store it
// as some sort of function we can call later on
Some(PreviewCommand::new(command, delimiter, offset_expr))
} else {
None
}
} }
} }

View File

@ -1,3 +1,4 @@
use lazy_regex::{regex, Lazy, Regex};
use rustc_hash::FxHashMap; use rustc_hash::FxHashMap;
use std::{ use std::{
fmt::{self, Display, Formatter}, fmt::{self, Display, Formatter},
@ -43,10 +44,8 @@ pub struct ChannelPrototype {
pub source_command: String, pub source_command: String,
#[serde(default)] #[serde(default)]
pub interactive: bool, pub interactive: bool,
pub preview_command: Option<String>, #[serde(rename = "preview")]
#[serde(default = "default_delimiter")] pub preview_command: Option<PreviewCommand>,
pub preview_delimiter: Option<String>,
pub preview_offset: Option<String>,
} }
const STDIN_CHANNEL_NAME: &str = "stdin"; const STDIN_CHANNEL_NAME: &str = "stdin";
@ -57,52 +56,27 @@ impl ChannelPrototype {
name: &str, name: &str,
source_command: &str, source_command: &str,
interactive: bool, interactive: bool,
preview_command: Option<String>, preview_command: Option<PreviewCommand>,
preview_delimiter: Option<String>,
preview_offset: Option<String>,
) -> Self { ) -> Self {
Self { Self {
name: name.to_string(), name: name.to_string(),
source_command: source_command.to_string(), source_command: source_command.to_string(),
interactive, interactive,
preview_command, preview_command,
preview_delimiter,
preview_offset,
} }
} }
pub fn stdin(preview: Option<PreviewCommand>) -> Self { pub fn stdin(preview: Option<PreviewCommand>) -> Self {
match preview { Self {
Some(PreviewCommand { name: STDIN_CHANNEL_NAME.to_string(),
command, source_command: STDIN_SOURCE_COMMAND.to_string(),
delimiter, interactive: false,
offset_expr, preview_command: preview,
}) => Self {
name: STDIN_CHANNEL_NAME.to_string(),
source_command: STDIN_SOURCE_COMMAND.to_string(),
interactive: false,
preview_command: Some(command),
preview_delimiter: Some(delimiter),
preview_offset: offset_expr,
},
None => Self {
name: STDIN_CHANNEL_NAME.to_string(),
source_command: STDIN_SOURCE_COMMAND.to_string(),
interactive: false,
preview_command: None,
preview_delimiter: None,
preview_offset: None,
},
} }
} }
pub fn preview_command(&self) -> Option<PreviewCommand> {
self.into()
}
} }
const DEFAULT_PROTOTYPE_NAME: &str = "files"; pub const DEFAULT_PROTOTYPE_NAME: &str = "files";
pub const DEFAULT_DELIMITER: &str = " ";
impl Default for ChannelPrototype { impl Default for ChannelPrototype {
fn default() -> Self { fn default() -> Self {
@ -113,19 +87,35 @@ impl Default for ChannelPrototype {
} }
} }
/// The default delimiter to use for the preview command to use to split
/// entries into multiple referenceable parts.
#[allow(clippy::unnecessary_wraps)]
fn default_delimiter() -> Option<String> {
Some(DEFAULT_DELIMITER.to_string())
}
impl Display for ChannelPrototype { impl Display for ChannelPrototype {
fn fmt(&self, f: &mut Formatter) -> fmt::Result { fn fmt(&self, f: &mut Formatter) -> fmt::Result {
write!(f, "{}", self.name) write!(f, "{}", self.name)
} }
} }
pub static CMD_RE: &Lazy<Regex> = regex!(r"\{(\d+)\}");
pub fn format_prototype_string(
template: &str,
source: &str,
delimiter: &str,
) -> String {
let parts = source.split(delimiter).collect::<Vec<&str>>();
let mut formatted_string =
template.replace("{}", format!("'{}'", source).as_str());
formatted_string = CMD_RE
.replace_all(&formatted_string, |caps: &regex::Captures| {
let index =
caps.get(1).unwrap().as_str().parse::<usize>().unwrap();
format!("'{}'", parts[index])
})
.to_string();
formatted_string
}
/// A neat `HashMap` of channel prototypes indexed by their name. /// A neat `HashMap` of channel prototypes indexed by their name.
/// ///
/// This is used to store cable channel prototypes throughout the application /// This is used to store cable channel prototypes throughout the application
@ -142,6 +132,16 @@ impl Deref for Cable {
} }
} }
impl Cable {
pub fn default_channel(&self) -> ChannelPrototype {
self.get(DEFAULT_PROTOTYPE_NAME)
.cloned()
.unwrap_or_else(|| {
panic!("Default channel '{DEFAULT_PROTOTYPE_NAME}' not found")
})
}
}
/// A default cable channels specification that is compiled into the /// A default cable channels specification that is compiled into the
/// application. /// application.
#[cfg(unix)] #[cfg(unix)]

View File

@ -25,6 +25,14 @@ pub struct Cli {
#[arg(short, long, value_name = "STRING", verbatim_doc_comment)] #[arg(short, long, value_name = "STRING", verbatim_doc_comment)]
pub preview: Option<String>, pub preview: Option<String>,
/// A preview line number offset template to use to scroll the preview to for each
/// entry.
///
/// This template uses the same syntax as the `preview` option and will be formatted
/// using the currently selected entry.
#[arg(long, value_name = "STRING", verbatim_doc_comment)]
pub preview_offset: Option<String>,
/// Disable the preview panel entirely on startup. /// Disable the preview panel entirely on startup.
#[arg(long, default_value = "false", verbatim_doc_comment)] #[arg(long, default_value = "false", verbatim_doc_comment)]
pub no_preview: bool, pub no_preview: bool,

View File

@ -11,7 +11,7 @@ use crate::{
prototypes::{Cable, ChannelPrototype}, prototypes::{Cable, ChannelPrototype},
}, },
cli::args::{Cli, Command}, cli::args::{Cli, Command},
config::{get_config_dir, get_data_dir, KeyBindings, DEFAULT_CHANNEL}, config::{get_config_dir, get_data_dir, KeyBindings},
}; };
pub mod args; pub mod args;
@ -75,23 +75,19 @@ impl From<Cli> for PostProcessedCli {
let preview_command = cli.preview.map(|preview| PreviewCommand { let preview_command = cli.preview.map(|preview| PreviewCommand {
command: preview, command: preview,
delimiter: cli.delimiter.clone(), delimiter: cli.delimiter.clone(),
// TODO: add the --preview-offset option to the CLI offset_expr: cli.preview_offset.clone(),
offset_expr: None,
}); });
let mut channel: ChannelPrototype; let mut channel: ChannelPrototype;
let working_directory: Option<String>; let working_directory: Option<String>;
let cable_channels = cable::load_cable().unwrap_or_default(); let cable = cable::load_cable().unwrap_or_default();
if cli.channel.is_none() { if cli.channel.is_none() {
channel = cable_channels channel = cable.default_channel();
.get(DEFAULT_CHANNEL)
.expect("Default channel not found in cable channels")
.clone();
working_directory = cli.working_directory; working_directory = cli.working_directory;
} else { } else {
let cli_channel = cli.channel.as_ref().unwrap().to_owned(); let cli_channel = cli.channel.as_ref().unwrap().to_owned();
match parse_channel(&cli_channel, &cable_channels) { match parse_channel(&cli_channel, &cable) {
Ok(p) => { Ok(p) => {
channel = p; channel = p;
working_directory = cli.working_directory; working_directory = cli.working_directory;
@ -102,12 +98,7 @@ impl From<Cli> for PostProcessedCli {
if cli.working_directory.is_none() if cli.working_directory.is_none()
&& Path::new(&cli_channel).exists() && Path::new(&cli_channel).exists()
{ {
channel = cable_channels channel = cable.default_channel();
.get(DEFAULT_CHANNEL)
.expect(
"Default channel not found in cable channels",
)
.clone();
working_directory = Some(cli.channel.unwrap().clone()); working_directory = Some(cli.channel.unwrap().clone());
} else { } else {
unknown_channel_exit(&cli.channel.unwrap()); unknown_channel_exit(&cli.channel.unwrap());
@ -117,10 +108,9 @@ impl From<Cli> for PostProcessedCli {
} }
} }
// override the default previewer
if let Some(preview_cmd) = &preview_command { if let Some(preview_cmd) = &preview_command {
channel.preview_command = Some(preview_cmd.command.clone()); channel.preview_command = Some(preview_cmd.clone());
channel.preview_delimiter = Some(preview_cmd.delimiter.clone());
channel.preview_offset.clone_from(&preview_cmd.offset_expr);
} }
Self { Self {
@ -317,7 +307,11 @@ mod tests {
let post_processed_cli: PostProcessedCli = cli.into(); let post_processed_cli: PostProcessedCli = cli.into();
let expected = ChannelPrototype { let expected = ChannelPrototype {
preview_delimiter: Some(":".to_string()), preview_command: Some(PreviewCommand {
command: "bat -n --color=always {}".to_string(),
delimiter: ":".to_string(),
offset_expr: None,
}),
..Default::default() ..Default::default()
}; };

View File

@ -15,6 +15,8 @@ pub use themes::Theme;
use tracing::{debug, warn}; use tracing::{debug, warn};
pub use ui::UiConfig; pub use ui::UiConfig;
use crate::channels::prototypes::DEFAULT_PROTOTYPE_NAME;
mod keybindings; mod keybindings;
pub mod shell_integration; pub mod shell_integration;
mod themes; mod themes;
@ -39,10 +41,8 @@ pub struct AppConfig {
pub default_channel: String, pub default_channel: String,
} }
pub const DEFAULT_CHANNEL: &str = "files";
fn default_channel() -> String { fn default_channel() -> String {
DEFAULT_CHANNEL.to_string() DEFAULT_PROTOTYPE_NAME.to_string()
} }
impl Hash for AppConfig { impl Hash for AppConfig {

View File

@ -60,7 +60,7 @@ async fn main() -> Result<()> {
// determine the channel to use based on the CLI arguments and configuration // determine the channel to use based on the CLI arguments and configuration
debug!("Determining channel..."); debug!("Determining channel...");
let channel = determine_channel( let channel_prototype = determine_channel(
args.clone(), args.clone(),
&config, &config,
is_readable_stdin(), is_readable_stdin(),
@ -77,8 +77,13 @@ async fn main() -> Result<()> {
args.no_help, args.no_help,
config.application.tick_rate, config.application.tick_rate,
); );
let mut app = let mut app = App::new(
App::new(channel, config, args.input, options, &cable_channels); &channel_prototype,
config,
args.input,
options,
&cable_channels,
);
stdout().flush()?; stdout().flush()?;
debug!("Running application..."); debug!("Running application...");
let output = app.run(stdout().is_terminal(), false).await?; let output = app.run(stdout().is_terminal(), false).await?;
@ -217,7 +222,7 @@ mod tests {
&args, &args,
&config, &config,
true, true,
&ChannelPrototype::new("stdin", "cat", false, None, None, None), &ChannelPrototype::new("stdin", "cat", false, None),
None, None,
); );
} }
@ -226,7 +231,7 @@ mod tests {
async fn test_determine_channel_autocomplete_prompt() { async fn test_determine_channel_autocomplete_prompt() {
let autocomplete_prompt = Some("cd".to_string()); let autocomplete_prompt = Some("cd".to_string());
let expected_channel = let expected_channel =
ChannelPrototype::new("dirs", "ls {}", false, None, None, None); ChannelPrototype::new("dirs", "ls {}", false, None);
let args = PostProcessedCli { let args = PostProcessedCli {
autocomplete_prompt, autocomplete_prompt,
..Default::default() ..Default::default()
@ -258,8 +263,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_determine_channel_standard_case() { async fn test_determine_channel_standard_case() {
let channel = let channel = ChannelPrototype::new("dirs", "", false, None);
ChannelPrototype::new("dirs", "", false, None, None, None);
let args = PostProcessedCli { let args = PostProcessedCli {
channel, channel,
..Default::default() ..Default::default()
@ -269,7 +273,7 @@ mod tests {
&args, &args,
&config, &config,
false, false,
&ChannelPrototype::new("dirs", "", false, None, None, None), &ChannelPrototype::new("dirs", "", false, None),
None, None,
); );
} }

View File

@ -9,7 +9,7 @@ pub struct PreviewState {
} }
const PREVIEW_MIN_SCROLL_LINES: u16 = 3; const PREVIEW_MIN_SCROLL_LINES: u16 = 3;
pub const ANSI_BEFORE_CONTEXT_SIZE: u16 = 10; pub const ANSI_BEFORE_CONTEXT_SIZE: u16 = 3;
const ANSI_CONTEXT_SIZE: usize = 500; const ANSI_CONTEXT_SIZE: usize = 500;
impl PreviewState { impl PreviewState {
@ -51,7 +51,7 @@ impl PreviewState {
scroll: u16, scroll: u16,
target_line: Option<u16>, target_line: Option<u16>,
) { ) {
if self.preview.title != preview.title { if self.preview.title != preview.title || self.scroll != scroll {
self.preview = preview; self.preview = preview;
self.scroll = scroll; self.scroll = scroll;
self.target_line = target_line; self.target_line = target_line;
@ -59,16 +59,31 @@ impl PreviewState {
} }
pub fn for_render_context(&self) -> Self { pub fn for_render_context(&self) -> Self {
let skipped_lines = let num_skipped_lines =
self.scroll.saturating_sub(ANSI_BEFORE_CONTEXT_SIZE); self.scroll.saturating_sub(ANSI_BEFORE_CONTEXT_SIZE);
let cropped_content = self let cropped_content = self
.preview .preview
.content .content
.lines() .lines()
.skip(skipped_lines as usize) .skip(num_skipped_lines as usize)
.take(ANSI_CONTEXT_SIZE) .take(ANSI_CONTEXT_SIZE)
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n"); .join("\n");
let target_line: Option<u16> =
if let Some(target_line) = self.target_line {
if num_skipped_lines < target_line
&& (target_line - num_skipped_lines)
<= u16::try_from(ANSI_CONTEXT_SIZE).unwrap()
{
Some(target_line.saturating_sub(num_skipped_lines))
} else {
None
}
} else {
None
};
PreviewState::new( PreviewState::new(
self.enabled, self.enabled,
Preview::new( Preview::new(
@ -77,8 +92,8 @@ impl PreviewState {
self.preview.icon, self.preview.icon,
self.preview.total_lines, self.preview.total_lines,
), ),
skipped_lines, num_skipped_lines,
self.target_line, target_line,
) )
} }
} }

View File

@ -32,13 +32,19 @@ pub fn draw_preview_content_block(
use_nerd_font_icons, use_nerd_font_icons,
)?; )?;
// render the preview content // render the preview content
let rp = build_preview_paragraph(&preview_state.preview.content); let rp = build_preview_paragraph(
preview_state,
colorscheme.preview.highlight_bg,
);
f.render_widget(rp, inner); f.render_widget(rp, inner);
Ok(()) Ok(())
} }
pub fn build_preview_paragraph(content: &str) -> Paragraph<'_> { pub fn build_preview_paragraph(
preview_state: &PreviewState,
highlight_bg: Color,
) -> Paragraph<'_> {
let preview_block = let preview_block =
Block::default().style(Style::default()).padding(Padding { Block::default().style(Style::default()).padding(Padding {
top: 0, top: 0,
@ -47,14 +53,30 @@ pub fn build_preview_paragraph(content: &str) -> Paragraph<'_> {
left: 1, left: 1,
}); });
build_ansi_text_paragraph(content, preview_block) build_ansi_text_paragraph(
&preview_state.preview.content,
preview_block,
preview_state.target_line,
highlight_bg,
)
} }
fn build_ansi_text_paragraph<'a>( fn build_ansi_text_paragraph<'a>(
text: &'a str, text: &'a str,
preview_block: Block<'a>, preview_block: Block<'a>,
target_line: Option<u16>,
highlight_bg: Color,
) -> Paragraph<'a> { ) -> Paragraph<'a> {
Paragraph::new(text.into_text().unwrap()).block(preview_block) let mut t = text.into_text().unwrap();
if let Some(target_line) = target_line {
// Highlight the target line
if let Some(line) = t.lines.get_mut((target_line - 1) as usize) {
for span in &mut line.spans {
span.style = span.style.bg(highlight_bg);
}
}
}
Paragraph::new(t).block(preview_block)
} }
pub fn build_meta_preview_paragraph<'a>( pub fn build_meta_preview_paragraph<'a>(

View File

@ -31,6 +31,7 @@ use std::collections::HashSet;
use tokio::sync::mpsc::{ use tokio::sync::mpsc::{
unbounded_channel, UnboundedReceiver, UnboundedSender, unbounded_channel, UnboundedReceiver, UnboundedSender,
}; };
use tracing::debug;
#[derive(PartialEq, Copy, Clone, Hash, Eq, Debug, Serialize, Deserialize)] #[derive(PartialEq, Copy, Clone, Hash, Eq, Debug, Serialize, Deserialize)]
pub enum Mode { pub enum Mode {
@ -72,7 +73,7 @@ impl Television {
#[must_use] #[must_use]
pub fn new( pub fn new(
action_tx: UnboundedSender<Action>, action_tx: UnboundedSender<Action>,
channel_prototype: ChannelPrototype, channel_prototype: &ChannelPrototype,
mut config: Config, mut config: Config,
input: Option<String>, input: Option<String>,
no_remote: bool, no_remote: bool,
@ -86,9 +87,9 @@ impl Television {
} }
// previewer // previewer
let preview_handles = Self::setup_previewer(&channel_prototype); let preview_handles = Self::setup_previewer(channel_prototype);
let mut channel: CableChannel = channel_prototype.into(); let mut channel = CableChannel::new(channel_prototype);
let app_metadata = AppMetadata::new( let app_metadata = AppMetadata::new(
env!("CARGO_PKG_VERSION").to_string(), env!("CARGO_PKG_VERSION").to_string(),
@ -157,7 +158,7 @@ impl Television {
let (pv_request_tx, pv_request_rx) = unbounded_channel(); let (pv_request_tx, pv_request_rx) = unbounded_channel();
let (pv_preview_tx, pv_preview_rx) = unbounded_channel(); let (pv_preview_tx, pv_preview_rx) = unbounded_channel();
let previewer = Previewer::new( let previewer = Previewer::new(
channel_prototype.preview_command().unwrap(), channel_prototype.preview_command.clone().unwrap(),
PreviewerConfig::default(), PreviewerConfig::default(),
pv_request_rx, pv_request_rx,
pv_preview_tx, pv_preview_tx,
@ -204,7 +205,7 @@ impl Television {
self.channel.name.clone() self.channel.name.clone()
} }
pub fn change_channel(&mut self, channel_prototype: ChannelPrototype) { pub fn change_channel(&mut self, channel_prototype: &ChannelPrototype) {
self.preview_state.reset(); self.preview_state.reset();
self.preview_state.enabled = self.preview_state.enabled =
channel_prototype.preview_command.is_some(); channel_prototype.preview_command.is_some();
@ -217,8 +218,9 @@ impl Television {
.send(PreviewRequest::Shutdown) .send(PreviewRequest::Shutdown)
.expect("Failed to send shutdown signal to previewer"); .expect("Failed to send shutdown signal to previewer");
} }
self.preview_handles = Self::setup_previewer(&channel_prototype); self.preview_handles = Self::setup_previewer(channel_prototype);
self.channel = channel_prototype.into(); self.channel = CableChannel::new(channel_prototype);
debug!("Changed channel to {:?}", channel_prototype);
} }
pub fn find(&mut self, pattern: &str) { pub fn find(&mut self, pattern: &str) {
@ -416,25 +418,26 @@ impl Television {
// available previews // available previews
let entry = selected_entry.as_ref().unwrap(); let entry = selected_entry.as_ref().unwrap();
if let Ok(preview) = receiver.try_recv() { if let Ok(preview) = receiver.try_recv() {
let scroll = entry
.line_number
.unwrap_or(0)
.saturating_sub(
(self
.ui_state
.layout
.preview_window
.map_or(0, |w| w.height.saturating_sub(2)) // borders
/ 2)
.into(),
)
.saturating_add(3) // 3 lines above the center
.try_into()
// if the scroll doesn't fit in a u16, just scroll to the top
// this is a current limitation of ratatui
.unwrap_or(0);
self.preview_state.update( self.preview_state.update(
preview, preview,
// scroll to center the selected entry scroll,
entry
.line_number
.unwrap_or(0)
.saturating_sub(
(self
.ui_state
.layout
.preview_window
.map_or(0, |w| w.height)
/ 2)
.into(),
)
.try_into()
// if the scroll doesn't fit in a u16, just scroll to the top
// this is a current limitation of ratatui
.unwrap_or(0),
entry.line_number.and_then(|l| l.try_into().ok()), entry.line_number.and_then(|l| l.try_into().ok()),
); );
self.action_tx.send(Action::Render)?; self.action_tx.send(Action::Render)?;
@ -547,7 +550,7 @@ impl Television {
self.reset_picker_input(); self.reset_picker_input();
self.remote_control.as_mut().unwrap().find(EMPTY_STRING); self.remote_control.as_mut().unwrap().find(EMPTY_STRING);
self.mode = Mode::Channel; self.mode = Mode::Channel;
self.change_channel(new_channel); self.change_channel(&new_channel);
} }
} }
} }

View File

@ -47,7 +47,7 @@ fn setup_app(
false, false,
config.application.tick_rate, config.application.tick_rate,
); );
let mut app = App::new(chan, config, input, options, &Cable::default()); let mut app = App::new(&chan, config, input, options, &Cable::default());
// retrieve the app's action channel handle in order to send a quit action // retrieve the app's action channel handle in order to send a quit action
let tx = app.action_tx.clone(); let tx = app.action_tx.clone();
@ -212,14 +212,8 @@ async fn test_app_exact_search_positive() {
#[tokio::test(flavor = "multi_thread", worker_threads = 3)] #[tokio::test(flavor = "multi_thread", worker_threads = 3)]
async fn test_app_exits_when_select_1_and_only_one_result() { async fn test_app_exits_when_select_1_and_only_one_result() {
let prototype = ChannelPrototype::new( let prototype =
"some_channel", ChannelPrototype::new("some_channel", "echo file1.txt", false, None);
"echo file1.txt",
false,
None,
None,
None,
);
let (f, tx) = setup_app(Some(prototype), true, false); let (f, tx) = setup_app(Some(prototype), true, false);
// tick a few times to get the results // tick a few times to get the results
@ -255,8 +249,6 @@ async fn test_app_does_not_exit_when_select_1_and_more_than_one_result() {
"echo 'file1.txt\nfile2.txt'", "echo 'file1.txt\nfile2.txt'",
false, false,
None, None,
None,
None,
); );
let (f, tx) = setup_app(Some(prototype), true, false); let (f, tx) = setup_app(Some(prototype), true, false);