refactor(shell): improve shell integration configuration syntax (#334)

Before:
```toml
[shell_integration.commands]
# Add your commands here. Each key is a command that will trigger tv with the
# corresponding channel as value.
# Example: say you want the following prompts to trigger the following channels
# when pressing <CTRL-T>:
#          `git checkout` should trigger the `git-branches` channel
#          `ls`           should trigger the `dirs` channel
#          `cat`          should trigger the `files` channel
#
# You would add the following to your configuration file:
# ```
# [shell_integration.commands]
# "git checkout" = "git-branch"
# "ls" = "dirs"
# "cat" = "files"
# ```

# environment variables
"export" = "env"
"unset" = "env"

# dirs channel
"cd" = "dirs"
"ls" = "dirs"
"rmdir" = "dirs"

# files channel
"cat" = "files"
"less" = "files"
"head" = "files"
"tail" = "files"
"vim" = "files"
"bat" = "files"

# git-diff channel
"git add" = "git-diff"

# git-branch channel
"git checkout" = "git-branch"
"git branch -d" = "git-branch"

# docker-images channel
"docker run" = "docker-images"

# gitrepos channel
"nvim" = "git-repos"

```

After

```toml
[shell_integration.channel_triggers]
# Add your channel triggers here. Each key is a channel that will be triggered
# by the corresponding commands.
# Example: say you want the following commands to trigger the following channels
# when pressing <CTRL-T>:
#          `git checkout`  should trigger the `git-branches` channel
#          `ls`            should trigger the `dirs` channel
#          `cat` and `cp`  should trigger the `files` channel
#
# You would add the following to your configuration file:
# ```
# [shell_integration.channel_triggers]
# "git-branches" = ["git checkout"]
# "dirs" = ["ls"]
# "files" = ["cat", "cp"]
# ```
"env" = ["export", "unset"]
"dirs" = ["cd", "ls", "rmdir"]
"files" = ["cat", "less", "head", "tail", "vim", "bat"]
"git-diff" = ["git add"]
"git-branch" = ["git checkout", "git branch -d"]
"docker-images" = ["docker run"]
"git-repos" = ["nvim"]
```
This commit is contained in:
Alexandre Pasmantier 2025-02-03 00:03:09 +01:00 committed by GitHub
parent c74b47d07c
commit a6a73c5bb3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 321 additions and 78 deletions

View File

@ -149,52 +149,30 @@ toggle_preview = "ctrl-o"
# E.g. typing `git checkout <CTRL-T>` will open television with a list of
# branches to choose from.
[shell_integration.commands]
# Add your commands here. Each key is a command that will trigger tv with the
# corresponding channel as value.
# Example: say you want the following prompts to trigger the following channels
[shell_integration.channel_triggers]
# Add your channel triggers here. Each key is a channel that will be triggered
# by the corresponding commands.
# Example: say you want the following commands to trigger the following channels
# when pressing <CTRL-T>:
# `git checkout` should trigger the `git-branches` channel
# `ls` should trigger the `dirs` channel
# `cat` should trigger the `files` channel
# `git checkout` should trigger the `git-branches` channel
# `ls` should trigger the `dirs` channel
# `cat` and `cp` should trigger the `files` channel
#
# You would add the following to your configuration file:
# ```
# [shell_integration.commands]
# "git checkout" = "git-branch"
# "ls" = "dirs"
# "cat" = "files"
# [shell_integration.channel_triggers]
# "git-branches" = ["git checkout"]
# "dirs" = ["ls"]
# "files" = ["cat", "cp"]
# ```
"env" = ["export", "unset"]
"dirs" = ["cd", "ls", "rmdir"]
"files" = ["cat", "less", "head", "tail", "vim", "bat"]
"git-diff" = ["git add"]
"git-branch" = ["git checkout", "git branch -d"]
"docker-images" = ["docker run"]
"git-repos" = ["nvim"]
# environment variables
"export" = "env"
"unset" = "env"
# dirs channel
"cd" = "dirs"
"ls" = "dirs"
"rmdir" = "dirs"
# files channel
"cat" = "files"
"less" = "files"
"head" = "files"
"tail" = "files"
"vim" = "files"
"bat" = "files"
# git-diff channel
"git add" = "git-diff"
# git-branch channel
"git checkout" = "git-branch"
"git branch -d" = "git-branch"
# docker-images channel
"docker run" = "docker-images"
# gitrepos channel
"nvim" = "git-repos"
[shell_integration.keybindings]
# controls which key binding should trigger tv
@ -203,4 +181,3 @@ toggle_preview = "ctrl-o"
# controls which keybinding should trigger tv
# for command history
"command_history" = "ctrl-r"

1
Cargo.lock generated
View File

@ -1928,6 +1928,7 @@ dependencies = [
"strum",
"syntect",
"television-derive",
"tempfile",
"thiserror 2.0.11",
"tokio",
"toml",

View File

@ -65,6 +65,7 @@ winapi-util = "0.1.9"
[dev-dependencies]
criterion = { version = "0.5", features = ["async_tokio"] }
tempfile = "3.16.0"
[features]
simd = ["dep:simdutf8"]

View File

@ -6,7 +6,7 @@ use std::path::PathBuf;
use television::action::Action;
use television::channels::OnAir;
use television::channels::{files::Channel, TelevisionChannel};
use television::config::Config;
use television::config::{Config, ConfigEnv};
use television::television::Television;
use tokio::runtime::Runtime;
@ -20,7 +20,7 @@ fn draw(c: &mut Criterion) {
b.to_async(&rt).iter_batched(
// FIXME: this is kind of hacky
|| {
let config = Config::new().unwrap();
let config = Config::new(&ConfigEnv::init().unwrap()).unwrap();
let backend = TestBackend::new(width, height);
let terminal = Terminal::new(backend).unwrap();
let (tx, _) = tokio::sync::mpsc::unbounded_channel();

View File

@ -1,7 +1,11 @@
#![allow(clippy::module_name_repetitions, clippy::ref_option)]
use std::{env, hash::Hash, path::PathBuf};
use std::{
env,
hash::Hash,
path::{Path, PathBuf},
};
use anyhow::Result;
use anyhow::{Context, Result};
use directories::ProjectDirs;
use keybindings::merge_keybindings;
pub use keybindings::{parse_key, Binding, KeyBindings};
@ -85,51 +89,98 @@ lazy_static! {
const CONFIG_FILE_NAME: &str = "config.toml";
impl Config {
#[allow(clippy::missing_panics_doc, clippy::missing_errors_doc)]
pub fn new() -> Result<Self> {
// Load the default_config values as base defaults
let default_config: Config = toml::from_str(DEFAULT_CONFIG)
.expect("Error parsing default config");
pub struct ConfigEnv {
_data_dir: PathBuf,
config_dir: PathBuf,
}
// initialize the config builder
impl ConfigEnv {
pub fn init() -> Result<Self> {
let data_dir = get_data_dir();
let config_dir = get_config_dir();
std::fs::create_dir_all(&config_dir)
.expect("Failed creating configuration directory");
.context("Failed creating configuration directory")?;
std::fs::create_dir_all(&data_dir)
.expect("Failed creating data directory");
.context("Failed creating data directory")?;
if config_dir.join(CONFIG_FILE_NAME).is_file() {
debug!("Found config file at {:?}", config_dir);
Ok(Self {
_data_dir: data_dir,
config_dir,
})
}
}
let path = config_dir.join(CONFIG_FILE_NAME);
let contents = std::fs::read_to_string(&path)?;
impl Config {
#[allow(clippy::missing_panics_doc, clippy::missing_errors_doc)]
pub fn new(config_env: &ConfigEnv) -> Result<Self> {
// Load the default_config values as base defaults
let default_config: Config = toml::from_str(DEFAULT_CONFIG)
.expect("Error parsing default config");
let cfg: Config = toml::from_str(&contents).unwrap_or_else(|e| {
warn!(
"Error parsing config file, using default configuration: {}" , e
);
default_config.clone()
});
// if a config file exists, load it and merge it with the default configuration
if config_env.config_dir.join(CONFIG_FILE_NAME).is_file() {
debug!("Found config file at {:?}", config_env.config_dir);
// merge keybindings with default keybindings
let keybindings = merge_keybindings(
default_config.keybindings,
&cfg.keybindings,
);
let cfg = Config { keybindings, ..cfg };
let user_cfg: Config =
Self::load_user_config(&config_env.config_dir)?;
debug!("Config: {:?}", cfg);
Ok(cfg)
// merge the user configuration with the default configuration
let final_cfg =
Self::merge_user_with_default(default_config, user_cfg);
debug!("Config: {:?}", final_cfg);
Ok(final_cfg)
} else {
warn!("No config file found at {:?}, creating default configuration file at that location.", config_dir);
// otherwise, create the default configuration file
warn!("No config file found at {:?}, creating default configuration file at that location.", config_env.config_dir);
// create the default configuration file in the user's config directory
std::fs::write(config_dir.join(CONFIG_FILE_NAME), DEFAULT_CONFIG)?;
std::fs::write(
config_env.config_dir.join(CONFIG_FILE_NAME),
DEFAULT_CONFIG,
)?;
Ok(default_config)
}
}
fn load_user_config(config_dir: &Path) -> Result<Self> {
let path = config_dir.join(CONFIG_FILE_NAME);
let contents = std::fs::read_to_string(&path)?;
let user_cfg: Config = toml::from_str(&contents)
.context("Error parsing configuration file.")?;
Ok(user_cfg)
}
fn merge_user_with_default(
mut default: Config,
mut user: Config,
) -> Config {
// merge shell integration triggers with commands
default.shell_integration.merge_triggers();
user.shell_integration.merge_triggers();
// merge shell integration commands with default commands
let mut merged_commands = default.shell_integration.commands.clone();
merged_commands.extend(user.shell_integration.commands.clone());
user.shell_integration.commands = merged_commands;
// merge shell integration keybindings with default keybindings
let mut merged_keybindings =
default.shell_integration.keybindings.clone();
merged_keybindings.extend(user.shell_integration.keybindings.clone());
user.shell_integration.keybindings = merged_keybindings;
// merge keybindings with default keybindings
let keybindings =
merge_keybindings(default.keybindings.clone(), &user.keybindings);
user.keybindings = keybindings;
Config {
config: user.config,
keybindings: user.keybindings,
ui: user.ui,
previewers: user.previewers,
shell_integration: user.shell_integration,
}
}
}
pub fn get_data_dir() -> PathBuf {
@ -179,3 +230,175 @@ fn default_frame_rate() -> f64 {
fn default_tick_rate() -> f64 {
50.0
}
#[cfg(test)]
mod tests {
use crate::action::Action;
use crate::event::Key;
use crate::television::Mode;
use super::*;
use rustc_hash::FxHashMap;
use std::fs::File;
use std::io::Write;
use tempfile::tempdir;
#[test]
fn test_get_data_dir() {
let data_dir = get_data_dir();
assert!(data_dir.is_absolute());
}
#[test]
fn test_get_config_dir() {
let config_dir = get_config_dir();
assert!(config_dir.is_absolute());
}
#[test]
fn test_load_user_config() {
let dir = tempdir().unwrap();
let config_dir = dir.path();
let config_file = config_dir.join(CONFIG_FILE_NAME);
let mut file = File::create(&config_file).unwrap();
file.write_all(DEFAULT_CONFIG.as_bytes()).unwrap();
let config = Config::load_user_config(config_dir).unwrap();
assert_eq!(config.config.data_dir, get_data_dir());
assert_eq!(config.config.config_dir, get_config_dir());
assert_eq!(config, toml::from_str(DEFAULT_CONFIG).unwrap());
}
#[test]
fn test_config_new_empty_user_cfg() {
// write user config to a file
let dir = tempdir().unwrap();
let config_dir = dir.path();
let config_file = config_dir.join(CONFIG_FILE_NAME);
let _ = File::create(&config_file).unwrap();
let config_env = ConfigEnv {
_data_dir: get_data_dir(),
config_dir: config_dir.to_path_buf(),
};
let config = Config::new(&config_env).unwrap();
let mut default_config: Config =
toml::from_str(DEFAULT_CONFIG).unwrap();
default_config.shell_integration.merge_triggers();
assert_eq!(config.config, default_config.config);
assert_eq!(config.keybindings, default_config.keybindings);
assert_eq!(config.ui, default_config.ui);
assert_eq!(config.previewers, default_config.previewers);
// backwards compatibility
assert_eq!(
config.shell_integration.commands,
default_config.shell_integration.commands
);
assert_eq!(
config.shell_integration.keybindings,
default_config.shell_integration.keybindings
);
}
const USER_CONFIG_1: &str = r#"
frame_rate = 30.0
[ui]
ui_scale = 40
theme = "television"
[previewers.file]
theme = "Visual Studio Dark"
[keybindings.Channel]
toggle_help = ["ctrl-a", "ctrl-b"]
[keybindings.RemoteControl]
toggle_help = ["ctrl-c", "ctrl-d"]
[shell_integration.commands]
"git add" = "git-diff"
[shell_integration.keybindings]
"command_history" = "ctrl-h"
"#;
#[test]
fn test_config_new_with_user_cfg() {
// write user config to a file
let dir = tempdir().unwrap();
let config_dir = dir.path();
let config_file = config_dir.join(CONFIG_FILE_NAME);
let mut file = File::create(&config_file).unwrap();
file.write_all(USER_CONFIG_1.as_bytes()).unwrap();
let config_env = ConfigEnv {
_data_dir: get_data_dir(),
config_dir: config_dir.to_path_buf(),
};
let config = Config::new(&config_env).unwrap();
let mut default_config: Config =
toml::from_str(DEFAULT_CONFIG).unwrap();
default_config.config.frame_rate = 30.0;
default_config.ui.ui_scale = 40;
default_config.ui.theme = "television".to_string();
default_config.previewers.file.theme =
"Visual Studio Dark".to_string();
default_config
.keybindings
.get_mut(&Mode::Channel)
.unwrap()
.extend({
let mut map = FxHashMap::default();
map.insert(
Action::ToggleHelp,
Binding::MultipleKeys(vec![
Key::Ctrl('a'),
Key::Ctrl('b'),
]),
);
map
});
default_config
.keybindings
.get_mut(&Mode::RemoteControl)
.unwrap()
.extend({
let mut map = FxHashMap::default();
map.insert(
Action::ToggleHelp,
Binding::MultipleKeys(vec![
Key::Ctrl('c'),
Key::Ctrl('d'),
]),
);
map
});
default_config
.shell_integration
.commands
.extend(vec![("git add".to_string(), "git-diff".to_string())]);
default_config
.shell_integration
.keybindings
.insert("command_history".to_string(), "ctrl-h".to_string());
default_config.shell_integration.merge_triggers();
assert_eq!(config.config, default_config.config);
assert_eq!(config.keybindings, default_config.keybindings);
assert_eq!(config.ui, default_config.ui);
assert_eq!(config.previewers, default_config.previewers);
assert_eq!(
config.shell_integration.commands,
default_config.shell_integration.commands
);
assert_eq!(
config.shell_integration.keybindings,
default_config.shell_integration.keybindings
);
}
}

View File

@ -31,7 +31,7 @@ impl Default for FilePreviewerConfig {
fn default() -> Self {
Self {
//max_file_size: 1024 * 1024,
theme: String::from("gruvbox-dark"),
theme: String::from("TwoDark"),
}
}
}

View File

@ -2,13 +2,19 @@ use std::hash::Hash;
use crate::config::parse_key;
use crate::event::Key;
use crate::utils::hashmaps;
use rustc_hash::FxHashMap;
use serde::Deserialize;
#[derive(Clone, Debug, Deserialize, Default, PartialEq)]
#[serde(default)]
pub struct ShellIntegrationConfig {
/// DEPRECATED: This is a legacy configuration option that is no longer used.
/// It is kept here for backwards compatibility.
/// {command: channel}
pub commands: FxHashMap<String, String>,
/// {channel: [commands]}
pub channel_triggers: FxHashMap<String, Vec<String>>,
pub keybindings: FxHashMap<String, String>,
}
@ -19,6 +25,21 @@ impl Hash for ShellIntegrationConfig {
}
}
impl ShellIntegrationConfig {
/// Merge the channel triggers into the commands hashmap
/// This is done to maintain backwards compatibility with the old configuration
/// format.
///
/// {command: channel} + {channel: [commands]} => {command: channel}
pub fn merge_triggers(&mut self) {
// invert the hashmap to get {command: channel}
let inverted_triggers =
hashmaps::invert_hashmap(&self.channel_triggers);
// merge the inverted hashmap with the existing commands hashmap
self.commands.extend(inverted_triggers);
}
}
const SMART_AUTOCOMPLETE_CONFIGURATION_KEY: &str = "smart_autocomplete";
const COMMAND_HISTORY_CONFIGURATION_KEY: &str = "command_history";
const DEFAULT_SHELL_AUTOCOMPLETE_KEY: char = 'T';

View File

@ -16,7 +16,7 @@ use television::cli::{
PostProcessedCli,
};
use television::config::Config;
use television::config::{Config, ConfigEnv};
use television::utils::shell::render_autocomplete_script_template;
use television::utils::{
shell::{completion_script, Shell},
@ -31,7 +31,7 @@ async fn main() -> Result<()> {
let args: PostProcessedCli = Cli::parse().into();
debug!("{:?}", args);
let mut config = Config::new()?;
let mut config = Config::new(&ConfigEnv::init()?)?;
if let Some(command) = args.command {
match command {

View File

@ -49,9 +49,9 @@ impl HelpBarLayout {
#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Hash)]
pub enum InputPosition {
#[serde(rename = "top")]
#[default]
Top,
#[serde(rename = "bottom")]
#[default]
Bottom,
}

View File

@ -0,0 +1,19 @@
use std::collections::HashMap;
use std::hash::Hash;
pub fn invert_hashmap<K, V, I, S: ::std::hash::BuildHasher>(
hashmap: &HashMap<K, V, S>,
) -> HashMap<I, K>
where
K: Eq + Hash + Clone,
V: Eq + Hash + Clone + IntoIterator<Item = I>,
I: Eq + Hash + Clone,
{
let mut inverted = HashMap::new();
for (key, values) in hashmap {
for value in values.clone() {
inverted.insert(value, key.clone());
}
}
inverted
}

View File

@ -1,6 +1,7 @@
pub mod cache;
pub mod command;
pub mod files;
pub mod hashmaps;
pub mod indices;
pub mod input;
pub mod metadata;