summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAndrew Cherry <andrew@xyncro.com>2024-02-28 00:29:20 +0000
committerEllie Huxtable <ellie@elliehuxtable.com>2024-02-28 13:13:20 +0000
commit32f092046369eafac79babf9e7b58fc2c61d23aa (patch)
tree3727917b7be73fae9c66ff0f30816e6c7f793d93
parentdd587201ca3de0bbf90707d4a39123a825f9fcdf (diff)
-rw-r--r--atuin-client/src/settings.rs572
-rw-r--r--atuin-client/src/settings/behaviour.rs86
-rw-r--r--atuin-client/src/settings/display.rs161
-rw-r--r--atuin-client/src/settings/input.rs85
-rw-r--r--atuin-client/src/settings/stats.rs78
-rw-r--r--atuin-client/src/settings/sync.rs6
-rw-r--r--atuin-client/src/settings/time.rs91
-rw-r--r--atuin/src/command/client/search.rs2
-rw-r--r--atuin/src/command/client/search/interactive.rs44
9 files changed, 599 insertions, 526 deletions
diff --git a/atuin-client/src/settings.rs b/atuin-client/src/settings.rs
index 179666f2..463a203b 100644
--- a/atuin-client/src/settings.rs
+++ b/atuin-client/src/settings.rs
@@ -1,32 +1,41 @@
+mod behaviour;
+pub mod display;
+mod input;
+mod stats;
+mod sync;
+mod time;
+
+use ::time as time_lib;
+
use std::{
collections::HashMap,
- convert::TryFrom,
- fmt,
io::prelude::*,
path::{Path, PathBuf},
str::FromStr,
};
use atuin_common::record::HostId;
-use clap::ValueEnum;
use config::{
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 parse_duration::parse;
-use ratatui::style::{Color, Stylize};
use regex::RegexSet;
use semver::Version;
-use serde::{Deserialize, Deserializer};
-use serde_with::DeserializeFromStr;
-use time::{
- format_description::{well_known::Rfc3339, FormatItem},
- macros::format_description,
- OffsetDateTime, UtcOffset,
-};
+use serde::Deserialize;
+use time_lib::{format_description::well_known::Rfc3339, Duration, OffsetDateTime};
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 LAST_SYNC_FILENAME: &str = "last_sync_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";
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)]
pub struct Settings {
- pub dialect: Dialect,
- pub timezone: Timezone,
- 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,
+ // Behaviour
+ pub exit_mode: ExitMode,
pub filter_mode: FilterMode,
pub filter_mode_shell_up_key_binding: Option<FilterMode>,
+ pub search_mode: 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")]
- pub history_filter: RegexSet,
+ // Display
+ #[serde(default, flatten)]
+ pub display: display::Settings,
+ // Filters
#[serde(with = "serde_regex", default = "RegexSet::empty")]
pub cwd_filter: RegexSet,
-
+ #[serde(with = "serde_regex", default = "RegexSet::empty")]
+ pub history_filter: RegexSet,
pub secrets_filter: bool,
pub workspaces: bool,
- pub ctrl_n_shortcuts: bool,
- pub network_connect_timeout: u64,
- pub network_timeout: u64,
- pub local_timeout: f64,
+ // Input
pub enter_accept: bool,
-
+ pub keymap_cursor: HashMap<String, CursorStyle>,
+ pub keymap_mode: KeymapMode,
+ pub keymap_mode_shell: KeymapMode,
#[serde(default)]
- pub stats: Stats,
+ 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)]
- pub styles: Styles,
+ pub stats: Stats,
+ // Sync
+ pub auto_sync: bool,
+ pub sync_address: String,
+ pub sync_frequency: String,
#[serde(default)]
pub sync: Sync,
- #[serde(default)]
- pub keys: Keys,
+ // Time
+ 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
// config! Keep secrets and settings apart.
@@ -595,7 +208,7 @@ impl Settings {
match parse(self.sync_frequency.as_str()) {
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)
}
Err(e) => Err(eyre!("failed to check sync: {}", e)),
@@ -679,7 +292,10 @@ impl Settings {
let key_path = data_dir.join("key");
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("db_path", db_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("search_mode", "fuzzy")?
.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("word_jump_mode", "emacs")?
.set_default(
@@ -725,13 +335,6 @@ impl Settings {
.set_default("keymap_mode", "emacs")?
.set_default("keymap_mode_shell", "auto")?
.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(
Environment::with_prefix("atuin")
.prefix_separator("_")
@@ -819,42 +422,3 @@ impl Default for Settings {
.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(())
- }
-}
diff --git a/atuin-client/src/settings/behaviour.rs b/atuin-client/src/settings/behaviour.rs
new file mode 100644
index 00000000..0819ac3c
--- /dev/null
+++ b/atuin-client/src/settings/behaviour.rs
@@ -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,
+ }
+ }
+}
diff --git a/atuin-client/src/settings/display.rs b/atuin-client/src/settings/display.rs
new file mode 100644
index 00000000..e35cfd44
--- /dev/null
+++ b/atuin-client/src/settings/display.rs
@@ -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
+ }
+}
diff --git a/atuin-client/src/settings/input.rs b/atuin-client/src/settings/input.rs
new file mode 100644
index 00000000..69f9244a
--- /dev/null
+++ b/atuin-client/src/settings/input.rs
@@ -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,
+}
diff --git a/atuin-client/src/settings/stats.rs b/atuin-client/src/settings/stats.rs
new file mode 100644
index 00000000..b0704eff
--- /dev/null
+++ b/atuin-client/src/settings/stats.rs
@@ -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 s