feat(shell): autocompletion plugin for zsh (#145)

This commit is contained in:
Alex Pasmantier 2024-12-25 18:53:50 +01:00 committed by GitHub
parent 22f1b4dc33
commit 68d118986c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 416 additions and 37 deletions

View File

@ -12,6 +12,12 @@
# In that case, television will expect the configuration file to be in:
# `$XDG_CONFIG_HOME/television/config.toml`
#
# General settings
# ----------------------------------------------------------------------------
frame_rate = 60
tick_rate = 50
[ui]
# Whether to use nerd font icons in the UI
# This option requires a font patched with Nerd Font in order to properly
@ -118,3 +124,55 @@ select_entry = "enter"
toggle_send_to_channel = "ctrl-s"
# Toggle the help bar
toggle_help = "ctrl-g"
# Shell integration
# ----------------------------------------------------------------------------
#
# The shell integration feature allows you to use television as a picker for
# your shell commands. E.g. typing `git checkout <TAB>` will open television
# with a list of branches to checkout.
[shell_integration.commands]
# Which commands should trigger which channels
# The keys are the commands that should trigger the channels, and the values
# are the channels that should be triggered.
# Example: `git checkout` should trigger the `git-branches` channel
# `ls` should trigger the `dirs` channel
# `cat` should trigger the `files` channel
#
# Would be written as:
# ```
# [shell_integration.commands]
# "git checkout" = "git-branches"
# "ls" = "dirs"
# "cat" = "files"
# ```
# The following are an example, you can remove/modify them and add your own.
# shell history (according to your shell)
"" = "zsh-history"
# dirs channel
"cd" = "dirs"
"ls" = "dirs"
"rmdir" = "dirs"
# files channel
"cat" = "files"
"less" = "files"
"head" = "files"
"tail" = "files"
"vim" = "files"
"bat" = "files"
# git-branch channel
"git checkout" = "git-branch"
"git branch -d" = "git-branch"
# docker-images channel
"docker run" = "docker-images"
# gitrepos channel
"nvim" = "gitrepos"

View File

@ -86,6 +86,14 @@ It is inspired by the neovim [telescope](https://github.com/nvim-telescope/teles
```
</details>
### Shell integration (currently only zsh)
To enable shell integration, run:
```bash
echo 'eval "$(tv init zsh)"' >> ~/.zshrc
```
And then restart your shell.
## Usage
```bash
tv [channel] #[default: files] [possible values: env, files, gitrepos, text, alias]

44
cable/unix-channels.toml Normal file
View File

@ -0,0 +1,44 @@
# GIT
[[cable_channel]]
name = "git-reflog"
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\" \"$@\""
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)\""
preview_command = "git show -p --stat --pretty=fuller --color=always {0}"
# Docker
[[cable_channel]]
name = "docker-images"
source_command = "docker image list --format \"{{.ID}}\""
preview_command = "docker image inspect {0} | jq -C"
# S3
[[cable_channel]]
name = "s3-buckets"
source_command = "aws s3 ls | cut -d \" \" -f 3"
preview_command = "aws s3 ls s3://{0}"
# Dotfiles
[[cable_channel]]
name = "my-dotfiles"
source_command = "fd -t f . $HOME/.config"
preview_command = "bat -n --color=always {0}"
# Shell history
[[cable_channel]]
name = "zsh-history"
source_command = "tac $HOME/.zsh_history | cut -d\";\" -f 2-"
preview_command = "echo '{}'"
[[cable_channel]]
name = "bash-history"
source_command = "tac $HOME/.bash_history"
preview_command = "echo '{}'"

View File

@ -0,0 +1,21 @@
# GIT
[[cable_channel]]
name = "git-reflog"
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\" \"$@\""
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)\""
preview_command = "git show -p --stat --pretty=fuller --color=always {0}"
# Docker
[[cable_channel]]
name = "docker-images"
source_command = "docker image list --format \"{{.ID}}\""
preview_command = "docker image inspect {0} | jq -C"

View File

@ -63,12 +63,14 @@ async fn load_candidates(command: String, injector: Injector<String>) {
.output()
.expect("failed to execute process");
let branches = String::from_utf8(output.stdout).unwrap();
let decoded_output = String::from_utf8(output.stdout).unwrap();
for line in branches.lines() {
let () = injector.push(line.to_string(), |e, cols| {
cols[0] = e.clone().into();
});
for line in decoded_output.lines() {
if !line.trim().is_empty() {
let () = injector.push(line.to_string(), |e, cols| {
cols[0] = e.clone().into();
});
}
}
}

View File

@ -4,6 +4,7 @@ pub mod files;
pub mod indices;
pub mod input;
pub mod metadata;
pub mod shell;
pub mod stdin;
pub mod strings;
pub mod syntax;

View File

@ -0,0 +1,22 @@
use color_eyre::Result;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Shell {
Bash,
Zsh,
Fish,
PowerShell,
Cmd,
}
const COMPLETION_ZSH: &str = include_str!("../../../shell/completion.zsh");
pub fn completion_script(shell: Shell) -> Result<&'static str> {
match shell {
Shell::Zsh => Ok(COMPLETION_ZSH),
_ => color_eyre::eyre::bail!(
"This shell is not yet supported: {:?}",
shell
),
}
}

View File

@ -78,15 +78,15 @@ impl From<ActionOutcome> for AppOutput {
impl App {
pub fn new(
channel: TelevisionChannel,
tick_rate: f64,
frame_rate: f64,
config: Config,
passthrough_keybindings: &[String],
) -> Result<Self> {
let (action_tx, action_rx) = mpsc::unbounded_channel();
let (render_tx, _) = mpsc::unbounded_channel();
let (_, event_rx) = mpsc::unbounded_channel();
let (event_abort_tx, _) = mpsc::unbounded_channel();
let config = Config::new()?;
let frame_rate = config.config.frame_rate;
let tick_rate = config.config.tick_rate;
let keymap = Keymap::from(&config.keybindings).with_mode_mappings(
Mode::Channel,
passthrough_keybindings

View File

@ -16,6 +16,14 @@ struct ChannelPrototypes {
const CABLE_FILE_NAME_SUFFIX: &str = "channels";
const CABLE_FILE_FORMAT: &str = "toml";
#[cfg(unix)]
const DEFAULT_CABLE_CHANNELS: &str =
include_str!("../../cable/unix-channels.toml");
#[cfg(not(unix))]
const DEFAULT_CABLE_CHANNELS: &str =
include_str!("../../cable/windows-channels.toml");
/// Load the cable configuration from the config directory.
///
/// Cable is loaded by compiling all files that match the following
@ -40,7 +48,7 @@ pub fn load_cable_channels() -> Result<CableChannels> {
.filter(|p| {
p.extension()
.and_then(|e| e.to_str())
.map_or(false, |e| e == CABLE_FILE_FORMAT)
.map_or(false, |e| e.to_lowercase() == CABLE_FILE_FORMAT)
})
.filter(|p| {
p.file_stem()
@ -48,7 +56,7 @@ pub fn load_cable_channels() -> Result<CableChannels> {
.map_or(false, |s| s.ends_with(CABLE_FILE_NAME_SUFFIX))
});
let all_prototypes = file_paths.fold(Vec::new(), |mut acc, p| {
let user_defined_prototypes = file_paths.fold(Vec::new(), |mut acc, p| {
let r: ChannelPrototypes = toml::from_str(
&std::fs::read_to_string(p)
.expect("Unable to read configuration file"),
@ -58,10 +66,20 @@ pub fn load_cable_channels() -> Result<CableChannels> {
acc
});
debug!("Loaded cable channels: {:?}", all_prototypes);
debug!("Loaded cable channels: {:?}", user_defined_prototypes);
let default_prototypes: ChannelPrototypes =
toml::from_str(DEFAULT_CABLE_CHANNELS)
.expect("Unable to parse default cable channels");
let mut cable_channels = HashMap::new();
for prototype in all_prototypes {
// chaining default with user defined prototypes so that users may override the
// default prototypes
for prototype in default_prototypes
.prototypes
.into_iter()
.chain(user_defined_prototypes)
{
cable_channels.insert(prototype.name.clone(), prototype);
}
Ok(CableChannels(cable_channels))

View File

@ -1,7 +1,8 @@
use std::path::Path;
use std::{collections::HashMap, path::Path};
use clap::{Parser, Subcommand};
use clap::{Parser, Subcommand, ValueEnum};
use color_eyre::{eyre::eyre, Result};
use tracing::debug;
use crate::{
cable,
@ -11,6 +12,7 @@ use television_channels::{
cable::CableChannelPrototype, channels::CliTvChannel,
entry::PreviewCommand,
};
use television_utils::shell::Shell as UtilShell;
#[derive(Parser, Debug)]
#[command(author, version = version(), about)]
@ -29,12 +31,12 @@ pub struct Cli {
pub delimiter: String,
/// Tick rate, i.e. number of ticks per second
#[arg(short, long, value_name = "FLOAT", default_value_t = 50.0)]
pub tick_rate: f64,
#[arg(short, long, value_name = "FLOAT")]
pub tick_rate: Option<f64>,
/// Frame rate, i.e. number of frames per second
#[arg(short, long, value_name = "FLOAT", default_value_t = 60.0)]
pub frame_rate: f64,
#[arg(short, long, value_name = "FLOAT")]
pub frame_rate: Option<f64>,
/// Passthrough keybindings (comma separated, e.g. "q,ctrl-w,ctrl-t") These keybindings will
/// trigger selection of the current entry and be passed through to stdout along with the entry
@ -46,6 +48,10 @@ pub struct Cli {
#[arg(value_name = "PATH", index = 2)]
pub working_directory: Option<String>,
/// Try to guess the channel from the provided input prompt
#[arg(long, value_name = "STRING")]
pub autocomplete_prompt: Option<String>,
#[command(subcommand)]
command: Option<Command>,
}
@ -54,17 +60,46 @@ pub struct Cli {
pub enum Command {
/// Lists available channels
ListChannels,
/// Initializes shell completion ("tv init zsh")
#[clap(name = "init")]
InitShell {
/// The shell for which to generate the autocompletion script
#[arg(value_enum)]
shell: Shell,
},
}
#[derive(Debug, Clone, Copy, PartialEq, ValueEnum)]
pub enum Shell {
Bash,
Zsh,
Fish,
PowerShell,
Cmd,
}
impl From<Shell> for UtilShell {
fn from(val: Shell) -> Self {
match val {
Shell::Bash => UtilShell::Bash,
Shell::Zsh => UtilShell::Zsh,
Shell::Fish => UtilShell::Fish,
Shell::PowerShell => UtilShell::PowerShell,
Shell::Cmd => UtilShell::Cmd,
}
}
}
#[derive(Debug)]
pub struct PostProcessedCli {
pub channel: ParsedCliChannel,
pub preview_command: Option<PreviewCommand>,
pub tick_rate: f64,
pub frame_rate: f64,
pub tick_rate: Option<f64>,
pub frame_rate: Option<f64>,
pub passthrough_keybindings: Vec<String>,
pub command: Option<Command>,
pub working_directory: Option<String>,
pub autocomplete_prompt: Option<String>,
}
impl From<Cli> for PostProcessedCli {
@ -84,7 +119,7 @@ impl From<Cli> for PostProcessedCli {
let channel: ParsedCliChannel;
let working_directory: Option<String>;
match channel_parser(&cli.channel) {
match parse_channel(&cli.channel) {
Ok(p) => {
channel = p;
working_directory = cli.working_directory;
@ -112,6 +147,7 @@ impl From<Cli> for PostProcessedCli {
passthrough_keybindings,
command: cli.command,
working_directory,
autocomplete_prompt: cli.autocomplete_prompt,
}
}
}
@ -129,7 +165,7 @@ pub enum ParsedCliChannel {
Cable(CableChannelPrototype),
}
fn channel_parser(channel: &str) -> Result<ParsedCliChannel> {
fn parse_channel(channel: &str) -> Result<ParsedCliChannel> {
let cable_channels = cable::load_cable_channels().unwrap_or_default();
CliTvChannel::try_from(channel)
.map(ParsedCliChannel::Builtin)
@ -170,6 +206,69 @@ pub fn list_channels() {
}
}
/// Backtrack from the end of the prompt and try to match each word to a known command
/// if a match is found, return the corresponding channel
/// if no match is found, throw an error
///
/// ## Example:
/// ```
/// let prompt = "ls -l";
/// let command_mapping = hashmap! {
/// "ls".to_string() => "files".to_string(),
/// "cat".to_string() => "files".to_string(),
/// };
/// let channel = guess_channel_from_prompt(prompt, &command_mapping).unwrap();
///
/// assert_eq!(channel, ParsedCliChannel::Builtin(CliTvChannel::Files));
/// ```
/// NOTE: this is a very naive implementation and needs to be improved
/// - it should be able to handle prompts containing multiple commands
/// e.g. "ls -l && cat <CTRL+T>"
/// - it should be able to handle commands within delimiters (quotes, brackets, etc.)
pub fn guess_channel_from_prompt(
prompt: &str,
command_mapping: &HashMap<String, String>,
) -> Result<ParsedCliChannel> {
debug!("Guessing channel from prompt: {}", prompt);
// git checkout -qf
// --- -------- --- <---------
if prompt.trim().is_empty() {
match command_mapping.get("") {
Some(channel) => return parse_channel(channel),
None => {
return Err(eyre!("No channel found for prompt: {}", prompt))
}
}
}
let rev_prompt_words = prompt.split_whitespace().rev();
let mut stack = Vec::new();
// for each patern
for (pattern, channel) in command_mapping {
if pattern.trim().is_empty() {
continue;
}
// push every word of the pattern onto the stack
stack.extend(pattern.split_whitespace());
for word in rev_prompt_words.clone() {
// if the stack is empty, we have a match
if stack.is_empty() {
return parse_channel(channel);
}
// if the word matches the top of the stack, pop it
if stack.last() == Some(&word) {
stack.pop();
}
}
// if the stack is empty, we have a match
if stack.is_empty() {
return parse_channel(channel);
}
// reset the stack
stack.clear();
}
Err(eyre!("No channel found for prompt: {}", prompt))
}
fn delimiter_parser(s: &str) -> Result<String, String> {
Ok(match s {
"" => ":".to_string(),
@ -230,11 +329,12 @@ mod tests {
channel: "files".to_string(),
preview: Some("bat -n --color=always {}".to_string()),
delimiter: ":".to_string(),
tick_rate: 50.0,
frame_rate: 60.0,
tick_rate: Some(50.0),
frame_rate: Some(60.0),
passthrough_keybindings: Some("q,ctrl-w,ctrl-t".to_string()),
command: None,
working_directory: Some("/home/user".to_string()),
autocomplete_prompt: None,
};
let post_processed_cli: PostProcessedCli = cli.into();
@ -250,8 +350,8 @@ mod tests {
delimiter: ":".to_string()
})
);
assert_eq!(post_processed_cli.tick_rate, 50.0);
assert_eq!(post_processed_cli.frame_rate, 60.0);
assert_eq!(post_processed_cli.tick_rate, Some(50.0));
assert_eq!(post_processed_cli.frame_rate, Some(60.0));
assert_eq!(
post_processed_cli.passthrough_keybindings,
vec!["q".to_string(), "ctrl-w".to_string(), "ctrl-t".to_string()]
@ -269,11 +369,12 @@ mod tests {
channel: ".".to_string(),
preview: None,
delimiter: ":".to_string(),
tick_rate: 50.0,
frame_rate: 60.0,
tick_rate: Some(50.0),
frame_rate: Some(60.0),
passthrough_keybindings: None,
command: None,
working_directory: None,
autocomplete_prompt: None,
};
let post_processed_cli: PostProcessedCli = cli.into();

View File

@ -8,6 +8,7 @@ pub use keybindings::KeyBindings;
use lazy_static::lazy_static;
use previewers::PreviewersConfig;
use serde::Deserialize;
use shell_integration::ShellIntegrationConfig;
use styles::Styles;
pub use themes::Theme;
use tracing::{debug, warn};
@ -15,6 +16,7 @@ use ui::UiConfig;
mod keybindings;
mod previewers;
mod shell_integration;
mod styles;
mod themes;
mod ui;
@ -28,6 +30,10 @@ pub struct AppConfig {
pub data_dir: PathBuf,
#[serde(default)]
pub config_dir: PathBuf,
#[serde(default = "default_frame_rate")]
pub frame_rate: f64,
#[serde(default = "default_tick_rate")]
pub tick_rate: f64,
}
#[allow(dead_code)]
@ -44,6 +50,8 @@ pub struct Config {
pub ui: UiConfig,
#[serde(default)]
pub previewers: PreviewersConfig,
#[serde(default)]
pub shell_integration: ShellIntegrationConfig,
}
lazy_static! {
@ -79,9 +87,15 @@ impl Config {
let mut builder = config::Config::builder()
.set_default("data_dir", data_dir.to_str().unwrap())?
.set_default("config_dir", config_dir.to_str().unwrap())?
.set_default("frame_rate", default_config.config.frame_rate)?
.set_default("tick_rate", default_config.config.tick_rate)?
.set_default("ui", default_config.ui.clone())?
.set_default("previewers", default_config.previewers.clone())?
.set_default("theme", default_config.ui.theme.clone())?;
.set_default("theme", default_config.ui.theme.clone())?
.set_default(
"shell_integration",
default_config.shell_integration.clone(),
)?;
// Load the user's config file
let source = config::File::from(config_dir.join(CONFIG_FILE_NAME))
@ -163,3 +177,11 @@ pub fn get_config_dir() -> PathBuf {
fn project_directory() -> Option<ProjectDirs> {
ProjectDirs::from("com", "", env!("CARGO_PKG_NAME"))
}
fn default_frame_rate() -> f64 {
60.0
}
fn default_tick_rate() -> f64 {
50.0
}

View File

@ -0,0 +1,24 @@
use serde::Deserialize;
use std::collections::HashMap;
#[derive(Clone, Debug, Deserialize, Default)]
pub struct ShellIntegrationConfig {
pub commands: HashMap<String, String>,
}
impl From<ShellIntegrationConfig> for config::ValueKind {
fn from(val: ShellIntegrationConfig) -> Self {
let mut m = HashMap::new();
m.insert(
String::from("commands"),
config::ValueKind::Table(
val.commands
.into_iter()
.map(|(k, v)| (k, config::ValueKind::String(v).into()))
.collect(),
)
.into(),
);
config::ValueKind::Table(m)
}
}

View File

@ -4,16 +4,23 @@ use std::path::Path;
use std::process::exit;
use clap::Parser;
use cli::{list_channels, ParsedCliChannel, PostProcessedCli};
use color_eyre::Result;
use television_channels::channels::TelevisionChannel;
use television_channels::entry::PreviewType;
use tracing::{debug, error, info};
use crate::app::App;
use crate::cli::Cli;
use television_channels::channels::stdin::Channel as StdinChannel;
use television_utils::stdin::is_readable_stdin;
use crate::cli::{
guess_channel_from_prompt, list_channels, Cli, ParsedCliChannel,
PostProcessedCli,
};
use crate::config::Config;
use television_channels::{
channels::{stdin::Channel as StdinChannel, TelevisionChannel},
entry::PreviewType,
};
use television_utils::{
shell::{completion_script, Shell},
stdin::is_readable_stdin,
};
pub mod action;
pub mod app;
@ -44,9 +51,20 @@ async fn main() -> Result<()> {
list_channels();
exit(0);
}
cli::Command::InitShell { shell } => {
let script = completion_script(Shell::from(shell))?;
println!("{script}");
exit(0);
}
}
}
let mut config = Config::new()?;
config.config.tick_rate =
args.tick_rate.unwrap_or(config.config.tick_rate);
config.config.frame_rate =
args.frame_rate.unwrap_or(config.config.frame_rate);
if let Some(working_directory) = args.working_directory {
let path = Path::new(&working_directory);
if !path.exists() {
@ -70,6 +88,18 @@ async fn main() -> Result<()> {
TelevisionChannel::Stdin(StdinChannel::new(
args.preview_command.map(PreviewType::Command),
))
} else if let Some(prompt) = args.autocomplete_prompt {
let channel = guess_channel_from_prompt(
&prompt,
&config.shell_integration.commands,
)?;
debug!("Using guessed channel: {:?}", channel);
match channel {
ParsedCliChannel::Builtin(c) => c.to_channel(),
ParsedCliChannel::Cable(c) => {
TelevisionChannel::Cable(c.into())
}
}
} else {
debug!("Using {:?} channel", args.channel);
match args.channel {
@ -80,8 +110,7 @@ async fn main() -> Result<()> {
}
}
},
args.tick_rate,
args.frame_rate,
config,
&args.passthrough_keybindings,
) {
Ok(mut app) => {

29
shell/completion.zsh Normal file
View File

@ -0,0 +1,29 @@
_tv_search() {
emulate -L zsh
zle -I
local current_prompt
current_prompt=$LBUFFER
local output
output=$(tv --autocomplete-prompt "$current_prompt" $*)
zle reset-prompt
if [[ -n $output ]]; then
RBUFFER=""
LBUFFER=$current_prompt$output
# uncomment this to automatically accept the line
# (i.e. run the command without having to press enter twice)
# zle accept-line
fi
}
zle -N tv-search _tv_search
bindkey '^T' tv-search