settings refactor

This commit is contained in:
Andrew Cherry 2024-02-28 00:29:20 +00:00 committed by Ellie Huxtable
parent dd587201ca
commit 32f0920463
9 changed files with 599 additions and 526 deletions

View File

@ -1,32 +1,41 @@
mod behaviour;
pub mod display;
mod input;
mod stats;
mod sync;
mod time;
use ::time as time_lib;
use std::{ use std::{
collections::HashMap, collections::HashMap,
convert::TryFrom,
fmt,
io::prelude::*, io::prelude::*,
path::{Path, PathBuf}, path::{Path, PathBuf},
str::FromStr, str::FromStr,
}; };
use atuin_common::record::HostId; use atuin_common::record::HostId;
use clap::ValueEnum;
use config::{ use config::{
builder::DefaultState, Config, ConfigBuilder, Environment, File as ConfigFile, FileFormat, builder::DefaultState, Config, ConfigBuilder, Environment, File as ConfigFile, FileFormat,
}; };
use eyre::{bail, eyre, Context, Error, Result}; use eyre::{eyre, Context, Result};
use fs_err::{create_dir_all, File}; use fs_err::{create_dir_all, File};
use parse_duration::parse; use parse_duration::parse;
use ratatui::style::{Color, Stylize};
use regex::RegexSet; use regex::RegexSet;
use semver::Version; use semver::Version;
use serde::{Deserialize, Deserializer}; use serde::Deserialize;
use serde_with::DeserializeFromStr; use time_lib::{format_description::well_known::Rfc3339, Duration, OffsetDateTime};
use time::{
format_description::{well_known::Rfc3339, FormatItem},
macros::format_description,
OffsetDateTime, UtcOffset,
};
use uuid::Uuid; use uuid::Uuid;
pub use self::{
behaviour::{ExitMode, FilterMode, SearchMode},
display::{Display, Styles},
input::{CursorStyle, KeymapMode, Keys, WordJumpMode},
stats::{Dialect, Stats},
sync::Sync,
time::Timezone,
};
pub const HISTORY_PAGE_SIZE: i64 = 100; pub const HISTORY_PAGE_SIZE: i64 = 100;
pub const LAST_SYNC_FILENAME: &str = "last_sync_time"; pub const LAST_SYNC_FILENAME: &str = "last_sync_time";
pub const LAST_VERSION_CHECK_FILENAME: &str = "last_version_check_time"; pub const LAST_VERSION_CHECK_FILENAME: &str = "last_version_check_time";
@ -34,464 +43,68 @@ pub const LATEST_VERSION_FILENAME: &str = "latest_version";
pub const HOST_ID_FILENAME: &str = "host_id"; pub const HOST_ID_FILENAME: &str = "host_id";
static EXAMPLE_CONFIG: &str = include_str!("../config.toml"); static EXAMPLE_CONFIG: &str = include_str!("../config.toml");
#[derive(Clone, Debug, Deserialize, Copy, ValueEnum, PartialEq)]
pub enum SearchMode {
#[serde(rename = "prefix")]
Prefix,
#[serde(rename = "fulltext")]
#[clap(aliases = &["fulltext"])]
FullText,
#[serde(rename = "fuzzy")]
Fuzzy,
#[serde(rename = "skim")]
Skim,
}
impl SearchMode {
pub fn as_str(&self) -> &'static str {
match self {
SearchMode::Prefix => "PREFIX",
SearchMode::FullText => "FULLTXT",
SearchMode::Fuzzy => "FUZZY",
SearchMode::Skim => "SKIM",
}
}
pub fn next(&self, settings: &Settings) -> Self {
match self {
SearchMode::Prefix => SearchMode::FullText,
// if the user is using skim, we go to skim
SearchMode::FullText if settings.search_mode == SearchMode::Skim => SearchMode::Skim,
// otherwise fuzzy.
SearchMode::FullText => SearchMode::Fuzzy,
SearchMode::Fuzzy | SearchMode::Skim => SearchMode::Prefix,
}
}
}
#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum)]
pub enum FilterMode {
#[serde(rename = "global")]
Global = 0,
#[serde(rename = "host")]
Host = 1,
#[serde(rename = "session")]
Session = 2,
#[serde(rename = "directory")]
Directory = 3,
#[serde(rename = "workspace")]
Workspace = 4,
}
impl FilterMode {
pub fn as_str(&self) -> &'static str {
match self {
FilterMode::Global => "GLOBAL",
FilterMode::Host => "HOST",
FilterMode::Session => "SESSION",
FilterMode::Directory => "DIRECTORY",
FilterMode::Workspace => "WORKSPACE",
}
}
}
#[derive(Clone, Debug, Deserialize, Copy)]
pub enum ExitMode {
#[serde(rename = "return-original")]
ReturnOriginal,
#[serde(rename = "return-query")]
ReturnQuery,
}
// FIXME: Can use upstream Dialect enum if https://github.com/stevedonovan/chrono-english/pull/16 is merged
// FIXME: Above PR was merged, but dependency was changed to interim (fork of chrono-english) in the ... interim
#[derive(Clone, Debug, Deserialize, Copy)]
pub enum Dialect {
#[serde(rename = "us")]
Us,
#[serde(rename = "uk")]
Uk,
}
impl From<Dialect> for interim::Dialect {
fn from(d: Dialect) -> interim::Dialect {
match d {
Dialect::Uk => interim::Dialect::Uk,
Dialect::Us => interim::Dialect::Us,
}
}
}
/// Type wrapper around `time::UtcOffset` to support a wider variety of timezone formats.
///
/// Note that the parsing of this struct needs to be done before starting any
/// multithreaded runtime, otherwise it will fail on most Unix systems.
///
/// See: https://github.com/atuinsh/atuin/pull/1517#discussion_r1447516426
#[derive(Clone, Copy, Debug, Eq, PartialEq, DeserializeFromStr)]
pub struct Timezone(pub UtcOffset);
impl fmt::Display for Timezone {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
/// format: <+|-><hour>[:<minute>[:<second>]]
static OFFSET_FMT: &[FormatItem<'_>] =
format_description!("[offset_hour sign:mandatory padding:none][optional [:[offset_minute padding:none][optional [:[offset_second padding:none]]]]]");
impl FromStr for Timezone {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
// local timezone
if matches!(s.to_lowercase().as_str(), "l" | "local") {
let offset = UtcOffset::current_local_offset()?;
return Ok(Self(offset));
}
if matches!(s.to_lowercase().as_str(), "0" | "utc") {
let offset = UtcOffset::UTC;
return Ok(Self(offset));
}
// offset from UTC
if let Ok(offset) = UtcOffset::parse(s, OFFSET_FMT) {
return Ok(Self(offset));
}
// IDEA: Currently named timezones are not supported, because the well-known crate
// for this is `chrono_tz`, which is not really interoperable with the datetime crate
// that we currently use - `time`. If ever we migrate to using `chrono`, this would
// be a good feature to add.
bail!(r#""{s}" is not a valid timezone spec"#)
}
}
#[derive(Clone, Debug, Deserialize, Copy)]
pub enum Style {
#[serde(rename = "auto")]
Auto,
#[serde(rename = "full")]
Full,
#[serde(rename = "compact")]
Compact,
}
#[derive(Clone, Debug, Deserialize, Copy)]
pub enum WordJumpMode {
#[serde(rename = "emacs")]
Emacs,
#[serde(rename = "subl")]
Subl,
}
#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum)]
pub enum KeymapMode {
#[serde(rename = "emacs")]
Emacs,
#[serde(rename = "vim-normal")]
VimNormal,
#[serde(rename = "vim-insert")]
VimInsert,
#[serde(rename = "auto")]
Auto,
}
impl KeymapMode {
pub fn as_str(&self) -> &'static str {
match self {
KeymapMode::Emacs => "EMACS",
KeymapMode::VimNormal => "VIMNORMAL",
KeymapMode::VimInsert => "VIMINSERT",
KeymapMode::Auto => "AUTO",
}
}
}
// We want to translate the config to crossterm::cursor::SetCursorStyle, but
// the original type does not implement trait serde::Deserialize unfortunately.
// It seems impossible to implement Deserialize for external types when it is
// used in HashMap (https://stackoverflow.com/questions/67142663). We instead
// define an adapter type.
#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum)]
pub enum CursorStyle {
#[serde(rename = "default")]
DefaultUserShape,
#[serde(rename = "blink-block")]
BlinkingBlock,
#[serde(rename = "steady-block")]
SteadyBlock,
#[serde(rename = "blink-underline")]
BlinkingUnderScore,
#[serde(rename = "steady-underline")]
SteadyUnderScore,
#[serde(rename = "blink-bar")]
BlinkingBar,
#[serde(rename = "steady-bar")]
SteadyBar,
}
impl CursorStyle {
pub fn as_str(&self) -> &'static str {
match self {
CursorStyle::DefaultUserShape => "DEFAULT",
CursorStyle::BlinkingBlock => "BLINKBLOCK",
CursorStyle::SteadyBlock => "STEADYBLOCK",
CursorStyle::BlinkingUnderScore => "BLINKUNDERLINE",
CursorStyle::SteadyUnderScore => "STEADYUNDERLINE",
CursorStyle::BlinkingBar => "BLINKBAR",
CursorStyle::SteadyBar => "STEADYBAR",
}
}
}
#[derive(Clone, Debug, Deserialize)]
pub struct Stats {
#[serde(default = "Stats::common_prefix_default")]
pub common_prefix: Vec<String>, // sudo, etc. commands we want to strip off
#[serde(default = "Stats::common_subcommands_default")]
pub common_subcommands: Vec<String>, // kubectl, commands we should consider subcommands for
#[serde(default = "Stats::ignored_commands_default")]
pub ignored_commands: Vec<String>, // cd, ls, etc. commands we want to completely hide from stats
}
impl Stats {
fn common_prefix_default() -> Vec<String> {
vec!["sudo", "doas"].into_iter().map(String::from).collect()
}
fn common_subcommands_default() -> Vec<String> {
vec![
"apt",
"cargo",
"composer",
"dnf",
"docker",
"git",
"go",
"ip",
"kubectl",
"nix",
"nmcli",
"npm",
"pecl",
"pnpm",
"podman",
"port",
"systemctl",
"tmux",
"yarn",
]
.into_iter()
.map(String::from)
.collect()
}
fn ignored_commands_default() -> Vec<String> {
vec![]
}
}
impl Default for Stats {
fn default() -> Self {
Self {
common_prefix: Self::common_prefix_default(),
common_subcommands: Self::common_subcommands_default(),
ignored_commands: Self::ignored_commands_default(),
}
}
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct Styles {
#[serde(default, deserialize_with = "Variants::deserialize_style")]
pub command: Option<ratatui::style::Style>,
#[serde(default, deserialize_with = "Variants::deserialize_style")]
pub command_selected: Option<ratatui::style::Style>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum Variants {
Color(Color),
Components(Components),
}
impl Variants {
fn deserialize_style<'de, D>(deserializer: D) -> Result<Option<ratatui::style::Style>, D::Error>
where
D: Deserializer<'de>,
{
let variants: Option<Variants> = Deserialize::deserialize(deserializer)?;
let style: Option<ratatui::style::Style> = variants.map(|variants| variants.into());
Ok(style)
}
}
impl From<Variants> for ratatui::style::Style {
fn from(value: Variants) -> ratatui::style::Style {
match value {
Variants::Components(complex_style) => complex_style.into(),
Variants::Color(color) => color.into(),
}
}
}
#[derive(Debug, Default, Deserialize)]
pub struct Components {
// Colors
#[serde(default)]
pub foreground: Option<Color>,
#[serde(default)]
pub background: Option<Color>,
#[serde(default)]
pub underline: Option<Color>,
// Modifiers
#[serde(default)]
pub bold: Option<bool>,
#[serde(default)]
pub crossed_out: Option<bool>,
#[serde(default)]
pub italic: Option<bool>,
#[serde(default)]
pub underlined: Option<bool>,
}
impl From<Components> for ratatui::style::Style {
fn from(value: Components) -> ratatui::style::Style {
let mut style = ratatui::style::Style::default();
if let Some(color) = value.foreground {
style = style.fg(color);
};
if let Some(color) = value.background {
style = style.bg(color);
}
if let Some(color) = value.underline {
style = style.underline_color(color);
}
style = match value.bold {
Some(true) => style.bold(),
Some(_) => style.not_bold(),
_ => style,
};
style = match value.crossed_out {
Some(true) => style.crossed_out(),
Some(_) => style.not_crossed_out(),
_ => style,
};
style = match value.italic {
Some(true) => style.italic(),
Some(_) => style.not_italic(),
_ => style,
};
style = match value.underlined {
Some(true) => style.underlined(),
Some(_) => style.not_underlined(),
_ => style,
};
style
}
}
#[derive(Clone, Debug, Deserialize, Default)]
pub struct Sync {
pub records: bool,
}
#[derive(Clone, Debug, Deserialize, Default)]
pub struct Keys {
pub scroll_exits: bool,
}
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
pub struct Settings { pub struct Settings {
pub dialect: Dialect, // Behaviour
pub timezone: Timezone, pub exit_mode: ExitMode,
pub style: Style,
pub auto_sync: bool,
pub update_check: bool,
pub sync_address: String,
pub sync_frequency: String,
pub db_path: String,
pub record_store_path: String,
pub key_path: String,
pub session_path: String,
pub search_mode: SearchMode,
pub filter_mode: FilterMode, pub filter_mode: FilterMode,
pub filter_mode_shell_up_key_binding: Option<FilterMode>, pub filter_mode_shell_up_key_binding: Option<FilterMode>,
pub search_mode: SearchMode,
pub search_mode_shell_up_key_binding: Option<SearchMode>, pub search_mode_shell_up_key_binding: Option<SearchMode>,
pub shell_up_key_binding: bool,
pub inline_height: u16,
pub invert: bool,
pub show_preview: bool,
pub max_preview_height: u16,
pub show_help: bool,
pub exit_mode: ExitMode,
pub keymap_mode: KeymapMode,
pub keymap_mode_shell: KeymapMode,
pub keymap_cursor: HashMap<String, CursorStyle>,
pub word_jump_mode: WordJumpMode,
pub word_chars: String,
pub scroll_context_lines: usize,
pub history_format: String,
pub prefers_reduced_motion: bool,
#[serde(with = "serde_regex", default = "RegexSet::empty")] // Display
pub history_filter: RegexSet, #[serde(default, flatten)]
pub display: display::Settings,
// Filters
#[serde(with = "serde_regex", default = "RegexSet::empty")] #[serde(with = "serde_regex", default = "RegexSet::empty")]
pub cwd_filter: RegexSet, pub cwd_filter: RegexSet,
#[serde(with = "serde_regex", default = "RegexSet::empty")]
pub history_filter: RegexSet,
pub secrets_filter: bool, pub secrets_filter: bool,
pub workspaces: bool, pub workspaces: bool,
pub ctrl_n_shortcuts: bool,
pub network_connect_timeout: u64, // Input
pub network_timeout: u64,
pub local_timeout: f64,
pub enter_accept: bool, pub enter_accept: bool,
pub keymap_cursor: HashMap<String, CursorStyle>,
pub keymap_mode: KeymapMode,
pub keymap_mode_shell: KeymapMode,
#[serde(default)]
pub keys: Keys,
pub shell_up_key_binding: bool,
pub word_jump_mode: WordJumpMode,
// Paths
pub db_path: String,
pub key_path: String,
pub record_store_path: String,
pub session_path: String,
// Stats
pub dialect: Dialect,
#[serde(default)] #[serde(default)]
pub stats: Stats, pub stats: Stats,
#[serde(default)] // Sync
pub styles: Styles, pub auto_sync: bool,
pub sync_address: String,
pub sync_frequency: String,
#[serde(default)] #[serde(default)]
pub sync: Sync, pub sync: Sync,
#[serde(default)] // Time
pub keys: Keys, pub timezone: Timezone,
// Timeout
pub local_timeout: f64,
pub network_connect_timeout: u64,
pub network_timeout: u64,
pub update_check: bool,
pub word_chars: String,
pub scroll_context_lines: usize,
pub history_format: String,
pub ctrl_n_shortcuts: bool,
// This is automatically loaded when settings is created. Do not set in // This is automatically loaded when settings is created. Do not set in
// config! Keep secrets and settings apart. // config! Keep secrets and settings apart.
@ -595,7 +208,7 @@ impl Settings {
match parse(self.sync_frequency.as_str()) { match parse(self.sync_frequency.as_str()) {
Ok(d) => { Ok(d) => {
let d = time::Duration::try_from(d).unwrap(); let d = Duration::try_from(d).unwrap();
Ok(OffsetDateTime::now_utc() - Settings::last_sync()? >= d) Ok(OffsetDateTime::now_utc() - Settings::last_sync()? >= d)
} }
Err(e) => Err(eyre!("failed to check sync: {}", e)), Err(e) => Err(eyre!("failed to check sync: {}", e)),
@ -679,7 +292,10 @@ impl Settings {
let key_path = data_dir.join("key"); let key_path = data_dir.join("key");
let session_path = data_dir.join("session"); let session_path = data_dir.join("session");
Ok(Config::builder() let builder = Config::builder();
let builder = display::defaults(builder)?;
Ok(builder
.set_default("history_format", "{time}\t{command}\t{duration}")? .set_default("history_format", "{time}\t{command}\t{duration}")?
.set_default("db_path", db_path.to_str())? .set_default("db_path", db_path.to_str())?
.set_default("record_store_path", record_store_path.to_str())? .set_default("record_store_path", record_store_path.to_str())?
@ -693,12 +309,6 @@ impl Settings {
.set_default("sync_frequency", "10m")? .set_default("sync_frequency", "10m")?
.set_default("search_mode", "fuzzy")? .set_default("search_mode", "fuzzy")?
.set_default("filter_mode", "global")? .set_default("filter_mode", "global")?
.set_default("style", "auto")?
.set_default("inline_height", 0)?
.set_default("show_preview", false)?
.set_default("max_preview_height", 4)?
.set_default("show_help", true)?
.set_default("invert", false)?
.set_default("exit_mode", "return-original")? .set_default("exit_mode", "return-original")?
.set_default("word_jump_mode", "emacs")? .set_default("word_jump_mode", "emacs")?
.set_default( .set_default(
@ -725,13 +335,6 @@ impl Settings {
.set_default("keymap_mode", "emacs")? .set_default("keymap_mode", "emacs")?
.set_default("keymap_mode_shell", "auto")? .set_default("keymap_mode_shell", "auto")?
.set_default("keymap_cursor", HashMap::<String, String>::new())? .set_default("keymap_cursor", HashMap::<String, String>::new())?
.set_default(
"prefers_reduced_motion",
std::env::var("NO_MOTION")
.ok()
.map(|_| config::Value::new(None, config::ValueKind::Boolean(true)))
.unwrap_or_else(|| config::Value::new(None, config::ValueKind::Boolean(false))),
)?
.add_source( .add_source(
Environment::with_prefix("atuin") Environment::with_prefix("atuin")
.prefix_separator("_") .prefix_separator("_")
@ -819,42 +422,3 @@ impl Default for Settings {
.expect("Could not deserialize config") .expect("Could not deserialize config")
} }
} }
#[cfg(test)]
mod tests {
use std::str::FromStr;
use eyre::Result;
use super::Timezone;
#[test]
fn can_parse_offset_timezone_spec() -> Result<()> {
assert_eq!(Timezone::from_str("+02")?.0.as_hms(), (2, 0, 0));
assert_eq!(Timezone::from_str("-04")?.0.as_hms(), (-4, 0, 0));
assert_eq!(Timezone::from_str("+05:30")?.0.as_hms(), (5, 30, 0));
assert_eq!(Timezone::from_str("-09:30")?.0.as_hms(), (-9, -30, 0));
// single digit hours are allowed
assert_eq!(Timezone::from_str("+2")?.0.as_hms(), (2, 0, 0));
assert_eq!(Timezone::from_str("-4")?.0.as_hms(), (-4, 0, 0));
assert_eq!(Timezone::from_str("+5:30")?.0.as_hms(), (5, 30, 0));
assert_eq!(Timezone::from_str("-9:30")?.0.as_hms(), (-9, -30, 0));
// fully qualified form
assert_eq!(Timezone::from_str("+09:30:00")?.0.as_hms(), (9, 30, 0));
assert_eq!(Timezone::from_str("-09:30:00")?.0.as_hms(), (-9, -30, 0));
// these offsets don't really exist but are supported anyway
assert_eq!(Timezone::from_str("+0:5")?.0.as_hms(), (0, 5, 0));
assert_eq!(Timezone::from_str("-0:5")?.0.as_hms(), (0, -5, 0));
assert_eq!(Timezone::from_str("+01:23:45")?.0.as_hms(), (1, 23, 45));
assert_eq!(Timezone::from_str("-01:23:45")?.0.as_hms(), (-1, -23, -45));
// require a leading sign for clarity
assert!(Timezone::from_str("5").is_err());
assert!(Timezone::from_str("10:30").is_err());
Ok(())
}
}

View File

@ -0,0 +1,86 @@
use clap::ValueEnum;
use serde::Deserialize;
use super::Settings;
// Exit
#[derive(Clone, Debug, Deserialize, Copy)]
pub enum ExitMode {
#[serde(rename = "return-original")]
ReturnOriginal,
#[serde(rename = "return-query")]
ReturnQuery,
}
// Filter
#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum)]
pub enum FilterMode {
#[serde(rename = "global")]
Global = 0,
#[serde(rename = "host")]
Host = 1,
#[serde(rename = "session")]
Session = 2,
#[serde(rename = "directory")]
Directory = 3,
#[serde(rename = "workspace")]
Workspace = 4,
}
impl FilterMode {
pub fn as_str(&self) -> &'static str {
match self {
FilterMode::Global => "GLOBAL",
FilterMode::Host => "HOST",
FilterMode::Session => "SESSION",
FilterMode::Directory => "DIRECTORY",
FilterMode::Workspace => "WORKSPACE",
}
}
}
// Search
#[derive(Clone, Debug, Deserialize, Copy, ValueEnum, PartialEq)]
pub enum SearchMode {
#[serde(rename = "prefix")]
Prefix,
#[serde(rename = "fulltext")]
#[clap(aliases = &["fulltext"])]
FullText,
#[serde(rename = "fuzzy")]
Fuzzy,
#[serde(rename = "skim")]
Skim,
}
impl SearchMode {
pub fn as_str(&self) -> &'static str {
match self {
SearchMode::Prefix => "PREFIX",
SearchMode::FullText => "FULLTXT",
SearchMode::Fuzzy => "FUZZY",
SearchMode::Skim => "SKIM",
}
}
pub fn next(&self, settings: &Settings) -> Self {
match self {
SearchMode::Prefix => SearchMode::FullText,
// if the user is using skim, we go to skim
SearchMode::FullText if settings.search_mode == SearchMode::Skim => SearchMode::Skim,
// otherwise fuzzy.
SearchMode::FullText => SearchMode::Fuzzy,
SearchMode::Fuzzy | SearchMode::Skim => SearchMode::Prefix,
}
}
}

View File

@ -0,0 +1,161 @@
use std::env;
use config::{builder::DefaultState, ConfigBuilder, Value, ValueKind};
use eyre::Result;
use ratatui::style::{Color, Style, Stylize};
use serde::{Deserialize, Deserializer};
// Settings
#[derive(Clone, Debug, Deserialize)]
pub struct Settings {
pub inline_height: u16,
pub invert: bool,
pub max_preview_height: u16,
pub prefers_reduced_motion: bool,
pub show_preview: bool,
pub show_help: bool,
#[serde(alias = "display")]
pub style: Display,
#[serde(default)]
pub styles: Styles,
}
// Defaults
pub(crate) fn defaults(
builder: ConfigBuilder<DefaultState>,
) -> Result<ConfigBuilder<DefaultState>> {
Ok(builder
.set_default("inline_height", 0)?
.set_default("invert", false)?
.set_default("max_preview_height", 4)?
.set_default(
"prefers_reduced_motion",
env::var("NO_MOTION")
.ok()
.map(|_| Value::new(None, ValueKind::Boolean(true)))
.unwrap_or_else(|| Value::new(None, ValueKind::Boolean(false))),
)?
.set_default("show_preview", false)?
.set_default("show_help", true)?
.set_default("style", "auto")?)
}
// Display (previously Style, still "style" as a configuration value, with an
// optional alias of "display" - potentially deprecate "style" in future)
#[derive(Clone, Debug, Deserialize, Copy)]
pub enum Display {
#[serde(rename = "auto")]
Auto,
#[serde(rename = "full")]
Full,
#[serde(rename = "compact")]
Compact,
}
// Styles
#[derive(Clone, Debug, Default, Deserialize)]
pub struct Styles {
#[serde(default, deserialize_with = "Variants::deserialize_style")]
pub command: Option<Style>,
#[serde(default, deserialize_with = "Variants::deserialize_style")]
pub command_selected: Option<Style>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum Variants {
Color(Color),
Components(Components),
}
impl Variants {
fn deserialize_style<'de, D>(deserializer: D) -> Result<Option<Style>, D::Error>
where
D: Deserializer<'de>,
{
let variants: Option<Variants> = Deserialize::deserialize(deserializer)?;
let style: Option<Style> = variants.map(|variants| variants.into());
Ok(style)
}
}
impl From<Variants> for Style {
fn from(value: Variants) -> Style {
match value {
Variants::Components(complex_style) => complex_style.into(),
Variants::Color(color) => color.into(),
}
}
}
#[derive(Debug, Default, Deserialize)]
struct Components {
// Colors
#[serde(default)]
pub foreground: Option<Color>,
#[serde(default)]
pub background: Option<Color>,
#[serde(default)]
pub underline: Option<Color>,
// Modifiers
#[serde(default)]
pub bold: Option<bool>,
#[serde(default)]
pub crossed_out: Option<bool>,
#[serde(default)]
pub italic: Option<bool>,
#[serde(default)]
pub underlined: Option<bool>,
}
impl From<Components> for Style {
fn from(value: Components) -> Style {
let mut style = Style::default();
if let Some(color) = value.foreground {
style = style.fg(color);
};
if let Some(color) = value.background {
style = style.bg(color);
}
if let Some(color) = value.underline {
style = style.underline_color(color);
}
style = match value.bold {
Some(true) => style.bold(),
Some(_) => style.not_bold(),
_ => style,
};
style = match value.crossed_out {
Some(true) => style.crossed_out(),
Some(_) => style.not_crossed_out(),
_ => style,
};
style = match value.italic {
Some(true) => style.italic(),
Some(_) => style.not_italic(),
_ => style,
};
style = match value.underlined {
Some(true) => style.underlined(),
Some(_) => style.not_underlined(),
_ => style,
};
style
}
}

View File

@ -0,0 +1,85 @@
use clap::ValueEnum;
use serde::Deserialize;
#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum)]
pub enum KeymapMode {
#[serde(rename = "emacs")]
Emacs,
#[serde(rename = "vim-normal")]
VimNormal,
#[serde(rename = "vim-insert")]
VimInsert,
#[serde(rename = "auto")]
Auto,
}
impl KeymapMode {
pub fn as_str(&self) -> &'static str {
match self {
KeymapMode::Emacs => "EMACS",
KeymapMode::VimNormal => "VIMNORMAL",
KeymapMode::VimInsert => "VIMINSERT",
KeymapMode::Auto => "AUTO",
}
}
}
// We want to translate the config to crossterm::cursor::SetCursorStyle, but
// the original type does not implement trait serde::Deserialize unfortunately.
// It seems impossible to implement Deserialize for external types when it is
// used in HashMap (https://stackoverflow.com/questions/67142663). We instead
// define an adapter type.
#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum)]
pub enum CursorStyle {
#[serde(rename = "default")]
DefaultUserShape,
#[serde(rename = "blink-block")]
BlinkingBlock,
#[serde(rename = "steady-block")]
SteadyBlock,
#[serde(rename = "blink-underline")]
BlinkingUnderScore,
#[serde(rename = "steady-underline")]
SteadyUnderScore,
#[serde(rename = "blink-bar")]
BlinkingBar,
#[serde(rename = "steady-bar")]
SteadyBar,
}
impl CursorStyle {
pub fn as_str(&self) -> &'static str {
match self {
CursorStyle::DefaultUserShape => "DEFAULT",
CursorStyle::BlinkingBlock => "BLINKBLOCK",
CursorStyle::SteadyBlock => "STEADYBLOCK",
CursorStyle::BlinkingUnderScore => "BLINKUNDERLINE",
CursorStyle::SteadyUnderScore => "STEADYUNDERLINE",
CursorStyle::BlinkingBar => "BLINKBAR",
CursorStyle::SteadyBar => "STEADYBAR",
}
}
}
#[derive(Clone, Debug, Deserialize, Default)]
pub struct Keys {
pub scroll_exits: bool,
}
#[derive(Clone, Debug, Deserialize, Copy)]
pub enum WordJumpMode {
#[serde(rename = "emacs")]
Emacs,
#[serde(rename = "subl")]
Subl,
}

View File

@ -0,0 +1,78 @@
use serde::Deserialize;
#[derive(Clone, Debug, Deserialize)]
pub struct Stats {
#[serde(default = "Stats::common_prefix_default")]
pub common_prefix: Vec<String>, // sudo, etc. commands we want to strip off
#[serde(default = "Stats::common_subcommands_default")]
pub common_subcommands: Vec<String>, // kubectl, commands we should consider subcommands for
#[serde(default = "Stats::ignored_commands_default")]
pub ignored_commands: Vec<String>, // cd, ls, etc. commands we want to completely hide from stats
}
impl Stats {
fn common_prefix_default() -> Vec<String> {
vec!["sudo", "doas"].into_iter().map(String::from).collect()
}
fn common_subcommands_default() -> Vec<String> {
vec![
"apt",
"cargo",
"composer",
"dnf",
"docker",
"git",
"go",
"ip",
"kubectl",
"nix",
"nmcli",
"npm",
"pecl",
"pnpm",
"podman",
"port",
"systemctl",
"tmux",
"yarn",
]
.into_iter()
.map(String::from)
.collect()
}
fn ignored_commands_default() -> Vec<String> {
vec![]
}
}
impl Default for Stats {
fn default() -> Self {
Self {
common_prefix: Self::common_prefix_default(),
common_subcommands: Self::common_subcommands_default(),
ignored_commands: Self::ignored_commands_default(),
}
}
}
// FIXME: Can use upstream Dialect enum if https://github.com/stevedonovan/chrono-english/pull/16 is merged
// FIXME: Above PR was merged, but dependency was changed to interim (fork of chrono-english) in the ... interim
#[derive(Clone, Debug, Deserialize, Copy)]
pub enum Dialect {
#[serde(rename = "us")]
Us,
#[serde(rename = "uk")]
Uk,
}
impl From<Dialect> for interim::Dialect {
fn from(d: Dialect) -> interim::Dialect {
match d {
Dialect::Uk => interim::Dialect::Uk,
Dialect::Us => interim::Dialect::Us,
}
}
}

View File

@ -0,0 +1,6 @@
use serde::Deserialize;
#[derive(Clone, Debug, Deserialize, Default)]
pub struct Sync {
pub records: bool,
}

View File

@ -0,0 +1,91 @@
use std::{fmt, str::FromStr};
use eyre::{bail, Error, Result};
use serde_with::DeserializeFromStr;
use time::{format_description::FormatItem, macros::format_description, UtcOffset};
/// format: <+|-><hour>[:<minute>[:<second>]]
static OFFSET_FMT: &[FormatItem<'_>] =
format_description!("[offset_hour sign:mandatory padding:none][optional [:[offset_minute padding:none][optional [:[offset_second padding:none]]]]]");
/// Type wrapper around `time::UtcOffset` to support a wider variety of timezone formats.
///
/// Note that the parsing of this struct needs to be done before starting any
/// multithreaded runtime, otherwise it will fail on most Unix systems.
///
/// See: https://github.com/atuinsh/atuin/pull/1517#discussion_r1447516426
#[derive(Clone, Copy, Debug, Eq, PartialEq, DeserializeFromStr)]
pub struct Timezone(pub UtcOffset);
impl fmt::Display for Timezone {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
impl FromStr for Timezone {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
// local timezone
if matches!(s.to_lowercase().as_str(), "l" | "local") {
let offset = UtcOffset::current_local_offset()?;
return Ok(Self(offset));
}
if matches!(s.to_lowercase().as_str(), "0" | "utc") {
let offset = UtcOffset::UTC;
return Ok(Self(offset));
}
// offset from UTC
if let Ok(offset) = UtcOffset::parse(s, OFFSET_FMT) {
return Ok(Self(offset));
}
// IDEA: Currently named timezones are not supported, because the well-known crate
// for this is `chrono_tz`, which is not really interoperable with the datetime crate
// that we currently use - `time`. If ever we migrate to using `chrono`, this would
// be a good feature to add.
bail!(r#""{s}" is not a valid timezone spec"#)
}
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use eyre::Result;
use super::Timezone;
#[test]
fn can_parse_offset_timezone_spec() -> Result<()> {
assert_eq!(Timezone::from_str("+02")?.0.as_hms(), (2, 0, 0));
assert_eq!(Timezone::from_str("-04")?.0.as_hms(), (-4, 0, 0));
assert_eq!(Timezone::from_str("+05:30")?.0.as_hms(), (5, 30, 0));
assert_eq!(Timezone::from_str("-09:30")?.0.as_hms(), (-9, -30, 0));
// single digit hours are allowed
assert_eq!(Timezone::from_str("+2")?.0.as_hms(), (2, 0, 0));
assert_eq!(Timezone::from_str("-4")?.0.as_hms(), (-4, 0, 0));
assert_eq!(Timezone::from_str("+5:30")?.0.as_hms(), (5, 30, 0));
assert_eq!(Timezone::from_str("-9:30")?.0.as_hms(), (-9, -30, 0));
// fully qualified form
assert_eq!(Timezone::from_str("+09:30:00")?.0.as_hms(), (9, 30, 0));
assert_eq!(Timezone::from_str("-09:30:00")?.0.as_hms(), (-9, -30, 0));
// these offsets don't really exist but are supported anyway
assert_eq!(Timezone::from_str("+0:5")?.0.as_hms(), (0, 5, 0));
assert_eq!(Timezone::from_str("-0:5")?.0.as_hms(), (0, -5, 0));
assert_eq!(Timezone::from_str("+01:23:45")?.0.as_hms(), (1, 23, 45));
assert_eq!(Timezone::from_str("-01:23:45")?.0.as_hms(), (-1, -23, -45));
// require a leading sign for clarity
assert!(Timezone::from_str("5").is_err());
assert!(Timezone::from_str("10:30").is_err());
Ok(())
}
}

View File

@ -158,7 +158,7 @@ impl Cmd {
settings.filter_mode = self.filter_mode.unwrap(); settings.filter_mode = self.filter_mode.unwrap();
} }
if self.inline_height.is_some() { if self.inline_height.is_some() {
settings.inline_height = self.inline_height.unwrap(); settings.display.inline_height = self.inline_height.unwrap();
} }
settings.shell_up_key_binding = self.shell_up_key_binding; settings.shell_up_key_binding = self.shell_up_key_binding;

View File

@ -22,7 +22,9 @@ use unicode_width::UnicodeWidthStr;
use atuin_client::{ use atuin_client::{
database::{current_context, Database}, database::{current_context, Database},
history::{store::HistoryStore, History, HistoryStats}, history::{store::HistoryStore, History, HistoryStats},
settings::{CursorStyle, ExitMode, FilterMode, KeymapMode, SearchMode, Settings, Styles}, settings::{
CursorStyle, Display, ExitMode, FilterMode, KeymapMode, SearchMode, Settings, Styles,
},
}; };
use super::{ use super::{
@ -240,11 +242,11 @@ impl State {
} }
fn handle_search_up(&mut self, settings: &Settings, enable_exit: bool) -> InputAction { fn handle_search_up(&mut self, settings: &Settings, enable_exit: bool) -> InputAction {
self.handle_search_scroll_one_line(settings, enable_exit, settings.invert) self.handle_search_scroll_one_line(settings, enable_exit, settings.display.invert)
} }
fn handle_search_down(&mut self, settings: &Settings, enable_exit: bool) -> InputAction { fn handle_search_down(&mut self, settings: &Settings, enable_exit: bool) -> InputAction {
self.handle_search_scroll_one_line(settings, enable_exit, !settings.invert) self.handle_search_scroll_one_line(settings, enable_exit, !settings.display.invert)
} }
fn handle_search_accept(&mut self, settings: &Settings) -> InputAction { fn handle_search_accept(&mut self, settings: &Settings) -> InputAction {
@ -450,19 +452,19 @@ impl State {
KeyCode::Char(c) => { KeyCode::Char(c) => {
self.search.input.insert(c); self.search.input.insert(c);
} }
KeyCode::PageDown if !settings.invert => { KeyCode::PageDown if !settings.display.invert => {
let scroll_len = self.results_state.max_entries() - settings.scroll_context_lines; let scroll_len = self.results_state.max_entries() - settings.scroll_context_lines;
self.scroll_down(scroll_len); self.scroll_down(scroll_len);
} }
KeyCode::PageDown if settings.invert => { KeyCode::PageDown if settings.display.invert => {
let scroll_len = self.results_state.max_entries() - settings.scroll_context_lines; let scroll_len = self.results_state.max_entries() - settings.scroll_context_lines;
self.scroll_up(scroll_len); self.scroll_up(scroll_len);
} }
KeyCode::PageUp if !settings.invert => { KeyCode::PageUp if !settings.display.invert => {
let scroll_len = self.results_state.max_entries() - settings.scroll_context_lines; let scroll_len = self.results_state.max_entries() - settings.scroll_context_lines;
self.scroll_up(scroll_len); self.scroll_up(scroll_len);
} }
KeyCode::PageUp if settings.invert => { KeyCode::PageUp if settings.display.invert => {
let scroll_len = self.results_state.max_entries() - settings.scroll_context_lines; let scroll_len = self.results_state.max_entries() - settings.scroll_context_lines;
self.scroll_down(scroll_len); self.scroll_down(scroll_len);
} }
@ -492,21 +494,21 @@ impl State {
stats: Option<HistoryStats>, stats: Option<HistoryStats>,
settings: &Settings, settings: &Settings,
) { ) {
let compact = match settings.style { let compact = match settings.display.style {
atuin_client::settings::Style::Auto => f.size().height < 14, Display::Auto => f.size().height < 14,
atuin_client::settings::Style::Compact => true, Display::Compact => true,
atuin_client::settings::Style::Full => false, Display::Full => false,
}; };
let invert = settings.invert; let invert = settings.display.invert;
let border_size = if compact { 0 } else { 1 }; let border_size = if compact { 0 } else { 1 };
let preview_width = f.size().width - 2; let preview_width = f.size().width - 2;
let preview_height = if settings.show_preview && self.tab_index == 0 { let preview_height = if settings.display.show_preview && self.tab_index == 0 {
let longest_command = results let longest_command = results
.iter() .iter()
.max_by(|h1, h2| h1.command.len().cmp(&h2.command.len())); .max_by(|h1, h2| h1.command.len().cmp(&h2.command.len()));
longest_command.map_or(0, |v| { longest_command.map_or(0, |v| {
std::cmp::min( std::cmp::min(
settings.max_preview_height, settings.display.max_preview_height,
v.command v.command
.split('\n') .split('\n')
.map(|line| { .map(|line| {
@ -521,7 +523,7 @@ impl State {
} else { } else {
1 1
}; };
let show_help = settings.show_help && (!compact || f.size().height > 1); let show_help = settings.display.show_help && (!compact || f.size().height > 1);
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.margin(0) .margin(0)
@ -601,7 +603,7 @@ impl State {
results, results,
self.keymap_mode, self.keymap_mode,
&self.now, &self.now,
&settings.styles, &settings.display.styles,
); );
f.render_stateful_widget(results_list, results_list_chunk, &mut self.results_state); f.render_stateful_widget(results_list, results_list_chunk, &mut self.results_state);
} }
@ -896,13 +898,13 @@ pub async fn history(
mut db: impl Database, mut db: impl Database,
history_store: &HistoryStore, history_store: &HistoryStore,
) -> Result<String> { ) -> Result<String> {
let stdout = Stdout::new(settings.inline_height > 0)?; let stdout = Stdout::new(settings.display.inline_height > 0)?;
let backend = CrosstermBackend::new(stdout); let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::with_options( let mut terminal = Terminal::with_options(
backend, backend,
TerminalOptions { TerminalOptions {
viewport: if settings.inline_height > 0 { viewport: if settings.display.inline_height > 0 {
Viewport::Inline(settings.inline_height) Viewport::Inline(settings.display.inline_height)
} else { } else {
Viewport::Fullscreen Viewport::Fullscreen
}, },
@ -955,7 +957,7 @@ pub async fn history(
value => value, value => value,
}, },
current_cursor: None, current_cursor: None,
now: if settings.prefers_reduced_motion { now: if settings.display.prefers_reduced_motion {
let now = OffsetDateTime::now_utc(); let now = OffsetDateTime::now_utc();
Box::new(move || now) Box::new(move || now)
} else { } else {
@ -1041,7 +1043,7 @@ pub async fn history(
app.finalize_keymap_cursor(settings); app.finalize_keymap_cursor(settings);
if settings.inline_height > 0 { if settings.display.inline_height > 0 {
terminal.clear()?; terminal.clear()?;
} }