mirror of
https://github.com/alexpasmantier/television.git
synced 2025-06-06 19:45:23 +00:00
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:
parent
c74b47d07c
commit
a6a73c5bb3
@ -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
1
Cargo.lock
generated
@ -1928,6 +1928,7 @@ dependencies = [
|
||||
"strum",
|
||||
"syntect",
|
||||
"television-derive",
|
||||
"tempfile",
|
||||
"thiserror 2.0.11",
|
||||
"tokio",
|
||||
"toml",
|
||||
|
@ -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"]
|
||||
|
@ -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();
|
||||
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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 {
|
||||
|
@ -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,
|
||||
}
|
||||
|
||||
|
19
television/utils/hashmaps.rs
Normal file
19
television/utils/hashmaps.rs
Normal 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
|
||||
}
|
@ -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;
|
||||
|
Loading…
x
Reference in New Issue
Block a user