diff options
| author | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2026-07-20 12:57:36 +0200 |
|---|---|---|
| committer | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2026-07-20 12:57:36 +0200 |
| commit | 73be69cd99f5a4784fe69f5d78f423c72e837284 (patch) | |
| tree | cb47246d2c7edd8cd828abb25a033b732d2289ae /crates/client/src | |
| parent | chore: Move more stuff out of atuin-client (diff) | |
| download | atuin-73be69cd99f5a4784fe69f5d78f423c72e837284.zip | |
chore: Commit
Diffstat (limited to '')
50 files changed, 98 insertions, 10777 deletions
diff --git a/crates/client/src/atuin_client/mod.rs b/crates/client/src/atuin_client/mod.rs index 851dfbdb..0678d8b7 100644 --- a/crates/client/src/atuin_client/mod.rs +++ b/crates/client/src/atuin_client/mod.rs @@ -1,12 +1,2 @@ -pub(crate) mod api_client; -pub(crate) mod database; -pub(crate) mod encryption; -pub(crate) mod history; -pub(crate) mod meta; -pub(crate) mod ordering; -pub(crate) mod record; -pub(crate) mod secrets; pub(crate) mod settings; pub(crate) mod theme; - -mod utils; diff --git a/crates/client/src/atuin_client/settings/meta.rs b/crates/client/src/atuin_client/settings/meta.rs index cc5afcf7..7993ef6d 100644 --- a/crates/client/src/atuin_client/settings/meta.rs +++ b/crates/client/src/atuin_client/settings/meta.rs @@ -7,7 +7,7 @@ pub(crate) struct Settings { impl Default for Settings { fn default() -> Self { - let dir = crate::atuin_common::utils::data_dir(); + let dir = turtle_common::utils::data_dir(); let path = dir.join("meta.db"); Self { diff --git a/crates/client/src/atuin_client/settings.rs b/crates/client/src/atuin_client/settings/mod.rs index 9e14c4c8..0bddc09c 100644 --- a/crates/client/src/atuin_client/settings.rs +++ b/crates/client/src/atuin_client/settings/mod.rs @@ -6,8 +6,7 @@ use tokio::sync::OnceCell; use tracing::info; use uuid::Uuid; -use crate::atuin_common::utils; -use crate::{atuin_client::encryption::decode_key, atuin_common::record::HostId}; +use crate::aclient::encryption::decode_key; use clap::ValueEnum; use config::{ Config, ConfigBuilder, Environment, File as ConfigFile, FileFormat, builder::DefaultState, @@ -18,10 +17,12 @@ use regex::RegexSet; use serde::{Deserialize, Serialize}; use serde_with::DeserializeFromStr; use time::{OffsetDateTime, UtcOffset, format_description::FormatItem, macros::format_description}; +use turtle_common::record::HostId; +use turtle_common::utils; static DATA_DIR: OnceLock<PathBuf> = OnceLock::new(); static META_CONFIG: OnceLock<(String, f64)> = OnceLock::new(); -static META_STORE: OnceCell<crate::atuin_client::meta::MetaStore> = OnceCell::const_new(); +static META_STORE: OnceCell<crate::aclient::meta::MetaStore> = OnceCell::const_new(); pub(crate) mod meta; pub(crate) mod watcher; @@ -852,7 +853,7 @@ impl Sync { #[derive(Clone, Debug, Deserialize, Serialize)] #[expect(clippy::struct_excessive_bools)] -pub(crate) struct Settings { +pub struct Settings { pub(crate) data_dir: Option<String>, pub(crate) dialect: Dialect, pub(crate) timezone: Timezone, @@ -938,13 +939,13 @@ pub(crate) struct Settings { impl Settings { // -- Meta store: lazily initialized on first access -- - pub(crate) async fn meta_store() -> Result<&'static crate::atuin_client::meta::MetaStore> { + pub(crate) async fn meta_store() -> Result<&'static crate::aclient::meta::MetaStore> { META_STORE .get_or_try_init(|| async { let (db_path, timeout) = META_CONFIG.get().ok_or_else(|| { eyre!("meta store config not set — Settings::new() has not been called") })?; - crate::atuin_client::meta::MetaStore::new(db_path, *timeout).await + crate::aclient::meta::MetaStore::new(db_path, *timeout).await }) .await } @@ -1464,7 +1465,7 @@ mod tests { assert_eq!(meta_db_path, custom_dir.join("meta.db").to_str().unwrap()); assert_eq!( daemon_socket_path, - crate::atuin_common::utils::runtime_dir() + turtle_common::utils::runtime_dir() .join("atuin.sock") .to_str() .unwrap() diff --git a/crates/client/src/atuin_client/settings/watcher.rs b/crates/client/src/atuin_client/settings/watcher.rs index 5eec3692..01d20855 100644 --- a/crates/client/src/atuin_client/settings/watcher.rs +++ b/crates/client/src/atuin_client/settings/watcher.rs @@ -6,7 +6,7 @@ //! # Example //! //! ```no_run -//! use crate::atuin_client::settings::watcher::global_settings_watcher; +//! use crate::aclient::settings::watcher::global_settings_watcher; //! //! async fn example() -> eyre::Result<()> { //! let watcher = global_settings_watcher()?; @@ -96,7 +96,7 @@ impl SettingsWatcher { /// Get the config file path. fn config_path() -> PathBuf { let config_dir = std::env::var("ATUIN_CONFIG_DIR") - .map_or_else(|_| crate::atuin_common::utils::config_dir(), PathBuf::from); + .map_or_else(|_| turtle_common::utils::config_dir(), PathBuf::from); config_dir.join("config.toml") } diff --git a/crates/client/src/command/client.rs b/crates/client/src/command/client.rs index 6c0e70e8..42e72e21 100644 --- a/crates/client/src/command/client.rs +++ b/crates/client/src/command/client.rs @@ -1,19 +1,12 @@ -use std::fs::{self, OpenOptions}; +use std::fs::{self}; use std::path::{Path, PathBuf}; use clap::Subcommand; use eyre::{Result, WrapErr}; -use tracing_subscriber::util::SubscriberInitExt; -use tracing_appender::rolling::{RollingFileAppender, Rotation}; -use tracing_subscriber::{ - Layer, filter::EnvFilter, filter::LevelFilter, fmt, fmt::format::FmtSpan, - prelude::__tracing_subscriber_SubscriberExt, -}; +use tracing_subscriber::filter::EnvFilter; -use crate::atuin_client::{ - database::ClientSqlite, record::sqlite_store::SqliteStore, settings::Settings, -}; +use crate::atuin_client::settings::Settings; fn cleanup_old_logs(log_dir: &Path, prefix: &str, retention_days: u64) { let cutoff = std::time::SystemTime::now() @@ -44,13 +37,9 @@ fn cleanup_old_logs(log_dir: &Path, prefix: &str, retention_days: u64) { } mod config; -mod daemon; mod default_config; mod history; mod info; -mod init; -mod search; -mod server; mod stats; mod store; mod sync; @@ -63,25 +52,14 @@ pub(crate) enum Cmd { #[command(subcommand)] History(history::Cmd), - /// Interactive history search - Search(search::Cmd), - #[command(subcommand)] /// Request a sync or view sync status Sync(sync::Cmd), - /// Manage the atuin server - #[command(subcommand)] - Server(server::Cmd), - /// Manage the atuin data store #[command(subcommand)] Store(store::Cmd), - /// Print Atuin's shell init script - #[command()] - Init(init::Cmd), - /// Information about dotfiles locations and ENV vars #[command()] Info, @@ -93,10 +71,6 @@ pub(crate) enum Cmd { /// Display a recap of your last year's history Wrapped { year: Option<i32> }, - /// *Experimental* Manage the background daemon - #[command()] - Daemon(daemon::Cmd), - /// Print the default atuin configuration (config.toml) #[command()] DefaultConfig, @@ -108,28 +82,11 @@ pub(crate) enum Cmd { impl Cmd { pub(crate) fn run(self) -> Result<()> { - // Daemonize before creating the async runtime – fork() inside a live - // tokio runtime corrupts its internal state. - #[cfg(unix)] - if let Self::Daemon(ref cmd) = self - && cmd.should_daemonize() - { - daemon::daemonize_current_process()?; - } - let mut runtime = tokio::runtime::Builder::new_current_thread(); let runtime = runtime.enable_all().build().unwrap(); - // Start the server before descending into the client-specific setup code. - // We simply cannot setup settings or a theme on the server, because the client-specific - // stuff will error out. - let res = if let Self::Server(server) = self { - runtime.block_on(server.run()) - } else { - // For non-history commands, we want to initialize logging and the theme manager before - // doing anything else. History commands are performance-sensitive and run before and after - // every shell command, so we want to skip any unnecessary initialization for them. + let res = { let settings = Settings::new().wrap_err("could not load client settings")?; runtime.block_on(self.run_inner(settings)) @@ -140,7 +97,6 @@ impl Cmd { res } - #[expect(clippy::too_many_lines)] async fn run_inner(self, mut settings: Settings) -> Result<()> { // ATUIN_LOG env var overrides config file level settings let env_log_set = std::env::var("ATUIN_LOG").is_ok(); @@ -149,137 +105,6 @@ impl Cmd { let base_filter = EnvFilter::from_env("ATUIN_LOG").add_directive("sqlx_sqlite::regexp=off".parse()?); - let is_interactive_search = matches!(&self, Self::Search(cmd) if cmd.is_interactive()); - // Use file-based logging for interactive search (TUI mode) - let use_search_logging = is_interactive_search && settings.logs.search_enabled(); - - // Use file-based logging for daemon - let use_daemon_logging = matches!(&self, Self::Daemon(_)) && settings.logs.daemon_enabled(); - - // Check if daemon should also log to console - let daemon_show_logs = matches!(&self, Self::Daemon(cmd) if cmd.show_logs()); - - // Set up span timing JSON logs if ATUIN_SPAN is set - let span_path = std::env::var("ATUIN_SPAN").ok().map(|p| { - if p.is_empty() { - "atuin-spans.json".to_string() - } else { - p - } - }); - - // Helper to create span timing layer - macro_rules! make_span_layer { - ($path:expr) => {{ - let span_file = OpenOptions::new() - .create(true) - .truncate(true) - .write(true) - .open($path)?; - Some( - fmt::layer() - .json() - .with_writer(span_file) - .with_span_events(FmtSpan::NEW | FmtSpan::CLOSE) - .with_filter(LevelFilter::TRACE), - ) - }}; - } - - // Build the subscriber with all configured layers - if use_search_logging { - let search_filename = settings.logs.search.file.clone(); - let log_dir = PathBuf::from(&settings.logs.dir); - fs::create_dir_all(&log_dir)?; - - // Clean up old log files - cleanup_old_logs(&log_dir, &search_filename, settings.logs.search_retention()); - - let file_appender = - RollingFileAppender::new(Rotation::DAILY, &log_dir, &search_filename); - - // Use config level unless ATUIN_LOG is set - let filter = if env_log_set { - base_filter - } else { - EnvFilter::default() - .add_directive(settings.logs.search_level().as_directive().parse()?) - .add_directive("sqlx_sqlite::regexp=off".parse()?) - }; - - let base = tracing_subscriber::registry().with( - fmt::layer() - .with_writer(file_appender) - .with_ansi(false) - .with_filter(filter), - ); - - match &span_path { - Some(sp) => { - base.with(make_span_layer!(sp)).init(); - } - None => { - base.init(); - } - } - } else if use_daemon_logging { - let daemon_filename = settings.logs.daemon.file.clone(); - let log_dir = PathBuf::from(&settings.logs.dir); - fs::create_dir_all(&log_dir)?; - - // Clean up old log files - cleanup_old_logs(&log_dir, &daemon_filename, settings.logs.daemon_retention()); - - let file_appender = - RollingFileAppender::new(Rotation::DAILY, &log_dir, &daemon_filename); - - // Use config level unless ATUIN_LOG is set - let file_filter = if env_log_set { - base_filter - } else { - EnvFilter::default() - .add_directive(settings.logs.daemon_level().as_directive().parse()?) - .add_directive("sqlx_sqlite::regexp=off".parse()?) - }; - - let file_layer = fmt::layer() - .with_writer(file_appender) - .with_ansi(false) - .with_filter(file_filter); - - // Optionally add console layer for --show-logs - if daemon_show_logs { - let console_filter = EnvFilter::from_env("ATUIN_LOG") - .add_directive("sqlx_sqlite::regexp=off".parse()?); - - let console_layer = fmt::layer().with_filter(console_filter); - - let base = tracing_subscriber::registry() - .with(file_layer) - .with(console_layer); - - match &span_path { - Some(sp) => { - base.with(make_span_layer!(sp)).init(); - } - None => { - base.init(); - } - } - } else { - let base = tracing_subscriber::registry().with(file_layer); - - match &span_path { - Some(sp) => { - base.with(make_span_layer!(sp)).init(); - } - None => { - base.init(); - } - } - } - } - tracing::trace!(command = ?self, "client command"); // Skip initializing any databases for history @@ -287,23 +112,12 @@ impl Cmd { // runs match self { Self::History(history) => return history.run(&settings).await, - Self::Init(init) => { - init.run(&settings); - return Ok(()); - } Self::Config(config) => return config.run(&settings).await, _ => {} } - let db_path = PathBuf::from(settings.db_path.as_str()); - let record_store_path = PathBuf::from(settings.record_store_path.as_str()); - - let db = ClientSqlite::new(db_path, settings.local_timeout).await?; - let sqlite_store = SqliteStore::new(record_store_path, settings.local_timeout).await?; - match self { Self::Stats(stats) => stats.run(&db, &settings).await, - Self::Search(search) => search.run(db, &mut settings, sqlite_store).await, Self::Wrapped { year } => wrapped::run(year, &db, &settings).await, Self::Sync(sync) => sync.run(settings, &db, sqlite_store).await, @@ -317,9 +131,7 @@ impl Cmd { Ok(()) } - Self::Daemon(cmd) => cmd.run(settings, sqlite_store, db).await, - - Self::History(_) | Self::Init(_) | Self::Config(_) | Self::Server(_) => { + Self::History(_) | Self::Config(_) => { unreachable!() } } diff --git a/crates/client/src/command/client/init.rs b/crates/client/src/command/client/init.rs deleted file mode 100644 index 0db9b143..00000000 --- a/crates/client/src/command/client/init.rs +++ /dev/null @@ -1,129 +0,0 @@ -use crate::atuin_client::settings::Settings; -use clap::{Parser, ValueEnum}; - -mod bash; -mod fish; -mod powershell; -mod xonsh; -mod zsh; - -#[derive(Parser, Debug)] -pub(crate) struct Cmd { - shell: Shell, - - /// Disable the binding of CTRL-R to atuin - #[clap(long)] - disable_ctrl_r: bool, - - /// Disable the binding of the Up Arrow key to atuin - #[clap(long)] - disable_up_arrow: bool, - - /// Disable the binding of ? to Atuin AI - #[clap(long)] - disable_ai: bool, -} - -#[derive(Clone, Copy, ValueEnum, Debug)] -#[value(rename_all = "lower")] -#[expect(clippy::enum_variant_names)] -pub(crate) enum Shell { - /// Zsh setup - Zsh, - - /// Bash setup - Bash, - - /// Fish setup - Fish, - - /// Nu setup - Nu, - - /// Xonsh setup - Xonsh, - - /// PowerShell setup - PowerShell, -} - -impl Cmd { - fn init_nu(&self) { - let full = include_str!("../../shell/atuin.nu"); - - println!("{full}"); - - if std::env::var("ATUIN_NOBIND").is_err() { - const BIND_CTRL_R: &str = r"$env.config = ( - $env.config | upsert keybindings ( - $env.config.keybindings - | append { - name: atuin - modifier: control - keycode: char_r - mode: [emacs, vi_normal, vi_insert] - event: { send: executehostcommand cmd: (_atuin_search_cmd) } - } - ) -)"; - const BIND_UP_ARROW: &str = r" -$env.config = ( - $env.config | upsert keybindings ( - $env.config.keybindings - | append { - name: atuin - modifier: none - keycode: up - mode: [emacs, vi_normal, vi_insert] - event: { - until: [ - {send: menuup} - {send: executehostcommand cmd: (_atuin_search_cmd '--shell-up-key-binding') } - ] - } - } - ) -) -"; - if !self.disable_ctrl_r { - println!("{BIND_CTRL_R}"); - } - if !self.disable_up_arrow { - println!("{BIND_UP_ARROW}"); - } - } - } - - fn static_init(&self) { - match self.shell { - Shell::Zsh => { - zsh::init_static(self.disable_up_arrow, self.disable_ctrl_r); - } - Shell::Bash => { - bash::init_static(self.disable_up_arrow, self.disable_ctrl_r); - } - Shell::Fish => { - fish::init_static(self.disable_up_arrow, self.disable_ctrl_r); - } - Shell::Nu => { - self.init_nu(); - } - Shell::Xonsh => { - xonsh::init_static(self.disable_up_arrow, self.disable_ctrl_r); - } - Shell::PowerShell => { - powershell::init_static(self.disable_up_arrow, self.disable_ctrl_r); - } - } - } - - pub(crate) fn run(self, settings: &Settings) { - if !settings.paths_ok() { - eprintln!( - "Atuin settings paths are broken. Disabling atuin shell hooks. Run `atuin doctor` to diagnose." - ); - } - - self.static_init(); - } -} diff --git a/crates/client/src/command/client/init/bash.rs b/crates/client/src/command/client/init/bash.rs deleted file mode 100644 index c16663e2..00000000 --- a/crates/client/src/command/client/init/bash.rs +++ /dev/null @@ -1,13 +0,0 @@ -pub(crate) fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool) { - let base = include_str!("../../../shell/atuin.bash"); - - let (bind_ctrl_r, bind_up_arrow) = if std::env::var("ATUIN_NOBIND").is_ok() { - (false, false) - } else { - (!disable_ctrl_r, !disable_up_arrow) - }; - - println!("__atuin_bind_ctrl_r={bind_ctrl_r}"); - println!("__atuin_bind_up_arrow={bind_up_arrow}"); - println!("{base}"); -} diff --git a/crates/client/src/command/client/init/fish.rs b/crates/client/src/command/client/init/fish.rs deleted file mode 100644 index 0a992b9c..00000000 --- a/crates/client/src/command/client/init/fish.rs +++ /dev/null @@ -1,74 +0,0 @@ -fn print_bindings( - indent: &str, - disable_up_arrow: bool, - disable_ctrl_r: bool, - bind_ctrl_r: &str, - bind_up_arrow: &str, - bind_ctrl_r_ins: &str, - bind_up_arrow_ins: &str, -) { - if !disable_ctrl_r { - println!("{indent}{bind_ctrl_r}"); - } - if !disable_up_arrow { - println!("{indent}{bind_up_arrow}"); - } - - println!("{indent}if bind -M insert >/dev/null 2>&1"); - if !disable_ctrl_r { - println!("{indent}{indent}{bind_ctrl_r_ins}"); - } - if !disable_up_arrow { - println!("{indent}{indent}{bind_up_arrow_ins}"); - } - println!("{indent}end"); -} - -pub(crate) fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool) { - let indent = " ".repeat(4); - - let base = include_str!("../../../shell/atuin.fish"); - - println!("{base}"); - - if std::env::var("ATUIN_NOBIND").is_err() { - println!("if string match -q '4.*' $version"); - - // In fish 4.0 and above the option bind -k doesn't exist anymore, - // instead we can use key names and modifiers directly. - print_bindings( - &indent, - disable_up_arrow, - disable_ctrl_r, - "bind ctrl-r _atuin_search", - "bind up _atuin_bind_up", - "bind -M insert ctrl-r _atuin_search", - "bind -M insert up _atuin_bind_up", - ); - - println!("else"); - - // We keep these for compatibility with fish 3.x - print_bindings( - &indent, - disable_up_arrow, - disable_ctrl_r, - r"bind \cr _atuin_search", - &[ - r"bind -k up _atuin_bind_up", - r"bind \eOA _atuin_bind_up", - r"bind \e\[A _atuin_bind_up", - ] - .join("; "), - r"bind -M insert \cr _atuin_search", - &[ - r"bind -M insert -k up _atuin_bind_up", - r"bind -M insert \eOA _atuin_bind_up", - r"bind -M insert \e\[A _atuin_bind_up", - ] - .join("; "), - ); - - println!("end"); - } -} diff --git a/crates/client/src/command/client/init/powershell.rs b/crates/client/src/command/client/init/powershell.rs deleted file mode 100644 index 94d89c67..00000000 --- a/crates/client/src/command/client/init/powershell.rs +++ /dev/null @@ -1,20 +0,0 @@ -pub(crate) fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool) { - let base = include_str!("../../../shell/atuin.ps1"); - - let (bind_ctrl_r, bind_up_arrow) = if std::env::var("ATUIN_NOBIND").is_ok() { - (false, false) - } else { - (!disable_ctrl_r, !disable_up_arrow) - }; - - println!("{base}"); - println!( - "Enable-AtuinSearchKeys -CtrlR {} -UpArrow {}", - ps_bool(bind_ctrl_r), - ps_bool(bind_up_arrow) - ); -} - -fn ps_bool(value: bool) -> &'static str { - if value { "$true" } else { "$false" } -} diff --git a/crates/client/src/command/client/init/xonsh.rs b/crates/client/src/command/client/init/xonsh.rs deleted file mode 100644 index 25f867f7..00000000 --- a/crates/client/src/command/client/init/xonsh.rs +++ /dev/null @@ -1,19 +0,0 @@ -pub(crate) fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool) { - let base = include_str!("../../../shell/atuin.xsh"); - - let (bind_ctrl_r, bind_up_arrow) = if std::env::var("ATUIN_NOBIND").is_ok() { - (false, false) - } else { - (!disable_ctrl_r, !disable_up_arrow) - }; - - println!( - "_ATUIN_BIND_CTRL_R={}", - if bind_ctrl_r { "True" } else { "False" } - ); - println!( - "_ATUIN_BIND_UP_ARROW={}", - if bind_up_arrow { "True" } else { "False" } - ); - println!("{base}"); -} diff --git a/crates/client/src/command/client/init/zsh.rs b/crates/client/src/command/client/init/zsh.rs deleted file mode 100644 index 96a817d0..00000000 --- a/crates/client/src/command/client/init/zsh.rs +++ /dev/null @@ -1,26 +0,0 @@ -pub(crate) fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool) { - let base = include_str!("../../../shell/atuin.zsh"); - - println!("{base}"); - - if std::env::var("ATUIN_NOBIND").is_err() { - const BIND_CTRL_R: &str = r"bindkey -M emacs '^r' atuin-search -bindkey -M viins '^r' atuin-search-viins -bindkey -M vicmd '/' atuin-search"; - - const BIND_UP_ARROW: &str = r"bindkey -M emacs '^[[A' atuin-up-search -bindkey -M vicmd '^[[A' atuin-up-search-vicmd -bindkey -M viins '^[[A' atuin-up-search-viins -bindkey -M emacs '^[OA' atuin-up-search -bindkey -M vicmd '^[OA' atuin-up-search-vicmd -bindkey -M viins '^[OA' atuin-up-search-viins -bindkey -M vicmd 'k' atuin-up-search-vicmd"; - - if !disable_ctrl_r { - println!("{BIND_CTRL_R}"); - } - if !disable_up_arrow { - println!("{BIND_UP_ARROW}"); - } - } -} diff --git a/crates/client/src/command/client/search.rs b/crates/client/src/command/client/search.rs deleted file mode 100644 index 359864cb..00000000 --- a/crates/client/src/command/client/search.rs +++ /dev/null @@ -1,366 +0,0 @@ -use std::fs::File; -use std::io::{IsTerminal as _, Write, stderr, stdout}; - -use crate::atuin_client::database::ClientSqlite; -use crate::atuin_common::utils::{self, Escapable as _}; -use clap::Parser; -use eyre::Result; - -use crate::atuin_client::{ - database::{OptFilters, current_context}, - encryption, - history::{History, store::HistoryStore}, - record::sqlite_store::SqliteStore, - settings::{FilterMode, KeymapMode, SearchMode, Settings, Timezone}, -}; - -use super::history::ListMode; - -mod cursor; -mod duration; -mod engines; -mod history_list; -mod inspector; -mod interactive; -pub(crate) mod keybindings; - -pub(crate) use duration::format_duration_into; - -#[expect(clippy::struct_excessive_bools, clippy::struct_field_names)] -#[derive(Parser, Debug)] -pub(crate) struct Cmd { - /// Filter search result by directory - #[arg(long, short)] - cwd: Option<String>, - - /// Exclude directory from results - #[arg(long = "exclude-cwd")] - exclude_cwd: Option<String>, - - /// Filter search result by exit code - #[arg(long, short)] - exit: Option<i64>, - - /// Exclude results with this exit code - #[arg(long = "exclude-exit")] - exclude_exit: Option<i64>, - - /// Only include results added before this date - #[arg(long, short)] - before: Option<String>, - - /// Only include results after this date - #[arg(long)] - after: Option<String>, - - /// How many entries to return at most - #[arg(long)] - limit: Option<i64>, - - /// Offset from the start of the results - #[arg(long)] - offset: Option<i64>, - - /// Open interactive search UI - #[arg(long, short)] - interactive: bool, - - /// Allow overriding filter mode over config - #[arg(long = "filter-mode")] - filter_mode: Option<FilterMode>, - - /// Allow overriding search mode over config - #[arg(long = "search-mode")] - search_mode: Option<SearchMode>, - - /// Marker argument used to inform atuin that it was invoked from a shell up-key binding (hidden from help to avoid confusion) - #[arg(long = "shell-up-key-binding", hide = true)] - shell_up_key_binding: bool, - - /// Notify the keymap at the shell's side - #[arg(long = "keymap-mode", default_value = "auto")] - keymap_mode: KeymapMode, - - /// Use human-readable formatting for time - #[arg(long)] - human: bool, - - #[arg(allow_hyphen_values = true)] - query: Option<Vec<String>>, - - /// Show only the text of the command - #[arg(long)] - cmd_only: bool, - - /// Terminate the output with a null, for better multiline handling - #[arg(long)] - print0: bool, - - /// Delete anything matching this query. Will not print out the match - #[arg(long)] - delete: bool, - - /// Delete EVERYTHING! - #[arg(long)] - delete_it_all: bool, - - /// Reverse the order of results, oldest first - #[arg(long, short)] - reverse: bool, - - /// Display the command time in another timezone other than the configured default. - /// - /// This option takes one of the following kinds of values: - /// - the special value "local" (or "l") which refers to the system time zone - /// - an offset from UTC (e.g. "+9", "-2:30") - #[arg(long, visible_alias = "tz")] - #[arg(allow_hyphen_values = true)] - // Clippy warns about `Option<Option<T>>`, but we suppress it because we need - // this distinction for proper argument handling. - #[expect(clippy::option_option)] - timezone: Option<Option<Timezone>>, - - /// Available variables: {command}, {directory}, {duration}, {user}, {host}, {time}, {exit} and - /// {relativetime}. - /// Example: --format "{time} - [{duration}] - {directory}$\t{command}" - #[arg(long, short)] - format: Option<String>, - - /// Set the maximum number of lines Atuin's interface should take up. - #[arg(long = "inline-height")] - inline_height: Option<u16>, - - /// Filter by author. Supports $all-user (non-agents), $all-agent, or literal names. - /// Can be specified multiple times. - #[arg(long)] - author: Option<Vec<String>>, - - /// Include duplicate commands in the output (non-interactive only) - #[arg(long)] - include_duplicates: bool, - - /// File name to write the result to (hidden from help as this is meant to be used from a script) - #[arg(long = "result-file", hide = true)] - result_file: Option<String>, -} - -impl Cmd { - /// Returns true if this search command will run in interactive (TUI) mode - pub(crate) fn is_interactive(&self) -> bool { - self.interactive - } - - // clippy: please write this instead - // clippy: now it has too many lines - // me: I'll do it later OKAY - #[expect(clippy::too_many_lines)] - pub(crate) async fn run( - self, - db: ClientSqlite, - settings: &mut Settings, - store: SqliteStore, - ) -> Result<()> { - let query = self.query.unwrap_or_else(|| { - std::env::var("ATUIN_QUERY").map_or_else( - |_| vec![], - |query| query.split(' ').map(ToString::to_string).collect(), - ) - }); - - if (self.delete_it_all || self.delete) && self.limit.is_some() { - // Because of how deletion is implemented, it will always delete all matches - // and disregard the limit option. It is also not clear what deletion with a - // limit would even mean. Deleting the LIMIT most recent entries that match - // the search query would make sense, but that wouldn't match what's displayed - // when running the equivalent search, but deleting those entries that are - // displayed with the search would leave any duplicates of those lines which may - // or may not have been intended to be deleted. - eprintln!("\"--limit\" is not compatible with deletion."); - return Ok(()); - } - - if self.delete && query.is_empty() { - eprintln!( - "Please specify a query to match the items you wish to delete. If you wish to delete all history, pass --delete-it-all" - ); - return Ok(()); - } - - if self.delete_it_all && !query.is_empty() { - eprintln!( - "--delete-it-all will delete ALL of your history! It does not require a query." - ); - return Ok(()); - } - - if let Some(search_mode) = self.search_mode { - settings.search_mode = search_mode; - } - if let Some(filter_mode) = self.filter_mode { - settings.filter_mode = Some(filter_mode); - } - if let Some(inline_height) = self.inline_height { - settings.inline_height = inline_height; - } - - settings.shell_up_key_binding = self.shell_up_key_binding; - - // `keymap_mode` specified in config.toml overrides the `--keymap-mode` - // option specified in the keybindings. - settings.keymap_mode = match settings.keymap_mode { - KeymapMode::Auto => self.keymap_mode, - value => value, - }; - settings.keymap_mode_shell = self.keymap_mode; - - let encryption_key: [u8; 32] = encryption::load_key(settings)?.into(); - - let host_id = Settings::host_id().await?; - let history_store = HistoryStore::new(store.clone(), host_id, encryption_key); - - if self.interactive { - let item = interactive::history(&query, settings, db, &history_store).await?; - - if let Some(result_file) = self.result_file { - let mut file = File::create(result_file)?; - write!(file, "{item}")?; - } else if !stdout().is_terminal() { - // stdout is not a terminal - likely command substitution like VAR=$(atuin search -i) - // Write to stdout so it gets captured. This requires some care on Windows, as the current - // console code page or `[Console]::OutputEncoding` on PowerShell may be different from UTF-8. - println!("{item}"); - } else if stderr().is_terminal() { - eprintln!("{}", item.escape_control()); - } else { - eprintln!("{item}"); - } - } else { - let opt_filter = OptFilters { - exit: self.exit, - exclude_exit: self.exclude_exit, - cwd: self.cwd, - exclude_cwd: self.exclude_cwd, - before: self.before, - after: self.after, - limit: self.limit, - offset: self.offset, - reverse: self.reverse, - include_duplicates: self.include_duplicates, - }; - - let mut entries = - run_non_interactive(settings, opt_filter.clone(), &query, &db).await?; - - if entries.is_empty() { - std::process::exit(1) - } - - // if we aren't deleting, print it all - if self.delete || self.delete_it_all { - // delete it - // it only took me _years_ to add this - // sorry - while !entries.is_empty() { - for entry in &entries { - eprintln!("deleting {}", entry.id); - } - - let ids = history_store.delete_entries(entries).await?; - history_store.incremental_build(&db, &ids).await?; - - entries = - run_non_interactive(settings, opt_filter.clone(), &query, &db).await?; - } - } else { - let format = match self.format { - None => Some(settings.history_format.as_str()), - _ => self.format.as_deref(), - }; - let tz = match self.timezone { - Some(Some(tz)) => tz, // User provided a value - Some(None) | None => settings.timezone, // No value was provided - }; - - super::history::print_list( - &entries, - ListMode::from_flags(self.human, self.cmd_only), - format, - self.print0, - true, - tz, - ); - } - } - Ok(()) - } -} - -// This is supposed to more-or-less mirror the command line version, so ofc -// it is going to have a lot of args -async fn run_non_interactive( - settings: &Settings, - filter_options: OptFilters, - query: &[String], - db: &ClientSqlite, -) -> Result<Vec<History>> { - let dir = if filter_options.cwd.as_deref() == Some(".") { - Some(utils::get_current_dir()) - } else { - filter_options.cwd - }; - - let context = current_context().await?; - - let opt_filter = OptFilters { - cwd: dir.clone(), - ..filter_options - }; - - let filter_mode = settings.default_filter_mode(context.git_root.is_some()); - - let results = db - .search( - settings.search_mode, - filter_mode, - &context, - query.join(" ").as_str(), - opt_filter, - ) - .await?; - - Ok(results) -} - -#[cfg(test)] -mod tests { - use super::Cmd; - use clap::Parser; - - #[test] - fn search_for_triple_dash() { - // Issue #3028: searching for `---` should not be treated as a CLI flag - let cmd = Cmd::try_parse_from(["search", "---"]); - assert!(cmd.is_ok(), "Failed to parse '---' as a query: {cmd:?}"); - let cmd = cmd.unwrap(); - assert_eq!(cmd.query, Some(vec!["---".to_string()])); - } - - #[test] - fn search_for_double_dash_value() { - // Searching for strings starting with -- should also work - let cmd = Cmd::try_parse_from(["search", "--", "--foo"]); - assert!(cmd.is_ok()); - let cmd = cmd.unwrap(); - assert_eq!(cmd.query, Some(vec!["--foo".to_string()])); - } - - #[test] - fn search_author_cli_flag() { - let cmd = - Cmd::try_parse_from(["search", "--author", "codex", "--author", "ellie"]).unwrap(); - assert_eq!( - cmd.author, - Some(vec!["codex".to_string(), "ellie".to_string()]) - ); - } -} diff --git a/crates/client/src/command/client/search/cursor.rs b/crates/client/src/command/client/search/cursor.rs deleted file mode 100644 index e13e52b3..00000000 --- a/crates/client/src/command/client/search/cursor.rs +++ /dev/null @@ -1,404 +0,0 @@ -use crate::atuin_client::settings::WordJumpMode; - -pub(crate) struct Cursor { - source: String, - index: usize, -} - -impl From<String> for Cursor { - fn from(source: String) -> Self { - Self { source, index: 0 } - } -} - -pub(crate) struct WordJumper<'a> { - word_chars: &'a str, - word_jump_mode: WordJumpMode, -} - -impl WordJumper<'_> { - fn is_word_boundary(&self, c: char, next_c: char) -> bool { - (c.is_whitespace() && !next_c.is_whitespace()) - || (!c.is_whitespace() && next_c.is_whitespace()) - || (self.word_chars.contains(c) && !self.word_chars.contains(next_c)) - || (!self.word_chars.contains(c) && self.word_chars.contains(next_c)) - } - - fn emacs_get_next_word_pos(&self, source: &str, index: usize) -> usize { - let index = (index + 1..source.len().saturating_sub(1)) - .find(|&i| self.word_chars.contains(source.chars().nth(i).unwrap())) - .unwrap_or(source.len()); - (index + 1..source.len().saturating_sub(1)) - .find(|&i| !self.word_chars.contains(source.chars().nth(i).unwrap())) - .unwrap_or(source.len()) - } - - fn emacs_get_prev_word_pos(&self, source: &str, index: usize) -> usize { - let index = (1..index) - .rev() - .find(|&i| self.word_chars.contains(source.chars().nth(i).unwrap())) - .unwrap_or(0); - (1..index) - .rev() - .find(|&i| !self.word_chars.contains(source.chars().nth(i).unwrap())) - .map_or(0, |i| i + 1) - } - - fn subl_get_next_word_pos(&self, source: &str, index: usize) -> usize { - let index = (index..source.len().saturating_sub(1)).find(|&i| { - self.is_word_boundary( - source.chars().nth(i).unwrap(), - source.chars().nth(i + 1).unwrap(), - ) - }); - if index.is_none() { - return source.len(); - } - (index.unwrap() + 1..source.len()) - .find(|&i| !source.chars().nth(i).unwrap().is_whitespace()) - .unwrap_or(source.len()) - } - - fn subl_get_prev_word_pos(&self, source: &str, index: usize) -> usize { - let index = (1..index) - .rev() - .find(|&i| !source.chars().nth(i).unwrap().is_whitespace()); - if index.is_none() { - return 0; - } - (1..index.unwrap()) - .rev() - .find(|&i| { - self.is_word_boundary( - source.chars().nth(i - 1).unwrap(), - source.chars().nth(i).unwrap(), - ) - }) - .unwrap_or(0) - } - - fn get_next_word_pos(&self, source: &str, index: usize) -> usize { - match self.word_jump_mode { - WordJumpMode::Emacs => self.emacs_get_next_word_pos(source, index), - WordJumpMode::Subl => self.subl_get_next_word_pos(source, index), - } - } - - fn get_prev_word_pos(&self, source: &str, index: usize) -> usize { - match self.word_jump_mode { - WordJumpMode::Emacs => self.emacs_get_prev_word_pos(source, index), - WordJumpMode::Subl => self.subl_get_prev_word_pos(source, index), - } - } -} - -impl Cursor { - pub(crate) fn as_str(&self) -> &str { - self.source.as_str() - } - - pub(crate) fn into_inner(self) -> String { - self.source - } - - /// Returns the string before the cursor - pub(crate) fn substring(&self) -> &str { - &self.source[..self.index] - } - - /// Returns the currently selected [`char`] - pub(crate) fn char(&self) -> Option<char> { - self.source[self.index..].chars().next() - } - - pub(crate) fn right(&mut self) { - if self.index < self.source.len() { - loop { - self.index += 1; - if self.source.is_char_boundary(self.index) { - break; - } - } - } - } - - pub(crate) fn left(&mut self) -> bool { - if self.index > 0 { - loop { - self.index -= 1; - if self.source.is_char_boundary(self.index) { - break true; - } - } - } else { - false - } - } - - pub(crate) fn next_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) { - let word_jumper = WordJumper { - word_chars, - word_jump_mode, - }; - self.index = word_jumper.get_next_word_pos(&self.source, self.index); - } - - pub(crate) fn prev_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) { - let word_jumper = WordJumper { - word_chars, - word_jump_mode, - }; - self.index = word_jumper.get_prev_word_pos(&self.source, self.index); - } - - /// Move cursor to the end of the current/next word (vim `e` motion). - /// - /// If cursor is in the middle of a word, moves to the end of that word. - /// If cursor is at the end of a word (or on whitespace), moves to the - /// end of the next word. - pub(crate) fn word_end(&mut self, word_chars: &str) { - let len = self.source.len(); - if self.index >= len { - return; - } - - let chars: Vec<char> = self.source.chars().collect(); - let mut char_idx = self.source[..self.index].chars().count(); - - if char_idx >= chars.len() { - return; - } - - let current = chars[char_idx]; - - // Check if we're at a word boundary (end of current word or on whitespace) - let at_word_boundary = current.is_whitespace() || char_idx + 1 >= chars.len() || { - let next = chars[char_idx + 1]; - next.is_whitespace() || (word_chars.contains(current) != word_chars.contains(next)) - }; - - // If at word boundary, advance past it and skip whitespace to find next word - if at_word_boundary { - char_idx += 1; - while char_idx < chars.len() && chars[char_idx].is_whitespace() { - char_idx += 1; - } - } - - // If we've gone past end, go to end of string - if char_idx >= chars.len() { - self.index = len; - return; - } - - // Find end of word: advance until next char is whitespace or different word type - let in_word_chars = word_chars.contains(chars[char_idx]); - while char_idx < chars.len() { - let next_idx = char_idx + 1; - if next_idx >= chars.len() { - // At last char, move past it - char_idx = next_idx; - break; - } - let next_c = chars[next_idx]; - if next_c.is_whitespace() || (word_chars.contains(next_c) != in_word_chars) { - // Next char is start of new word/whitespace, so current char is end - char_idx = next_idx; - break; - } - char_idx += 1; - } - - // Convert char index back to byte index - self.index = chars.iter().take(char_idx).map(|c| c.len_utf8()).sum(); - } - - pub(crate) fn insert(&mut self, c: char) { - self.source.insert(self.index, c); - self.index += c.len_utf8(); - } - - pub(crate) fn remove(&mut self) -> Option<char> { - if self.index < self.source.len() { - Some(self.source.remove(self.index)) - } else { - None - } - } - - pub(crate) fn remove_next_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) { - let word_jumper = WordJumper { - word_chars, - word_jump_mode, - }; - let next_index = word_jumper.get_next_word_pos(&self.source, self.index); - self.source.replace_range(self.index..next_index, ""); - } - - pub(crate) fn remove_prev_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) { - let word_jumper = WordJumper { - word_chars, - word_jump_mode, - }; - let next_index = word_jumper.get_prev_word_pos(&self.source, self.index); - self.source.replace_range(next_index..self.index, ""); - self.index = next_index; - } - - pub(crate) fn back(&mut self) -> Option<char> { - if self.left() { self.remove() } else { None } - } - - pub(crate) fn clear(&mut self) { - self.source.clear(); - self.index = 0; - } - - pub(crate) fn clear_to_start(&mut self) { - self.source.replace_range(..self.index, ""); - self.index = 0; - } - - pub(crate) fn clear_to_end(&mut self) { - self.source.replace_range(self.index.., ""); - self.index = self.source.len(); - } - - pub(crate) fn end(&mut self) { - self.index = self.source.len(); - } - - pub(crate) fn start(&mut self) { - self.index = 0; - } - - pub(crate) fn position(&self) -> usize { - self.index - } -} - -#[cfg(test)] -mod cursor_tests { - use super::{Cursor, WordJumpMode, WordJumper}; - - static EMACS_WORD_JUMPER: WordJumper<'_> = WordJumper { - word_chars: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", - word_jump_mode: WordJumpMode::Emacs, - }; - - static SUBL_WORD_JUMPER: WordJumper<'_> = WordJumper { - word_chars: "./\\()\"'-:,.;<>~!@#$%^&*|+=[]{}`~?", - word_jump_mode: WordJumpMode::Subl, - }; - - #[test] - fn right() { - // ö is 2 bytes - let mut c = Cursor::from(String::from("öaöböcödöeöfö")); - let indices = [0, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18, 20, 20, 20, 20]; - for i in indices { - assert_eq!(c.index, i); - c.right(); - } - } - - #[test] - fn left() { - // ö is 2 bytes - let mut c = Cursor::from(String::from("öaöböcödöeöfö")); - c.end(); - let indices = [20, 18, 17, 15, 14, 12, 11, 9, 8, 6, 5, 3, 2, 0, 0, 0, 0]; - for i in indices { - assert_eq!(c.index, i); - c.left(); - } - } - - #[test] - fn test_emacs_get_next_word_pos() { - let s = String::from(" aaa ((()))bbb ((())) "); - let indices = [(0, 6), (3, 6), (7, 18), (19, 30)]; - for (i_src, i_dest) in indices { - assert_eq!(EMACS_WORD_JUMPER.get_next_word_pos(&s, i_src), i_dest); - } - assert_eq!(EMACS_WORD_JUMPER.get_next_word_pos("", 0), 0); - } - - #[test] - fn test_emacs_get_prev_word_pos() { - let s = String::from(" aaa ((()))bbb ((())) "); - let indices = [(30, 15), (29, 15), (15, 3), (3, 0)]; - for (i_src, i_dest) in indices { - assert_eq!(EMACS_WORD_JUMPER.get_prev_word_pos(&s, i_src), i_dest); - } - assert_eq!(EMACS_WORD_JUMPER.get_prev_word_pos("", 0), 0); - } - - #[test] - fn test_subl_get_next_word_pos() { - let s = String::from(" aaa ((()))bbb ((())) "); - let indices = [(0, 3), (1, 3), (3, 9), (9, 15), (15, 21), (21, 30)]; - for (i_src, i_dest) in indices { - assert_eq!(SUBL_WORD_JUMPER.get_next_word_pos(&s, i_src), i_dest); - } - assert_eq!(SUBL_WORD_JUMPER.get_next_word_pos("", 0), 0); - } - - #[test] - fn test_subl_get_prev_word_pos() { - let s = String::from(" aaa ((()))bbb ((())) "); - let indices = [(30, 21), (21, 15), (15, 9), (9, 3), (3, 0)]; - for (i_src, i_dest) in indices { - assert_eq!(SUBL_WORD_JUMPER.get_prev_word_pos(&s, i_src), i_dest); - } - assert_eq!(SUBL_WORD_JUMPER.get_prev_word_pos("", 0), 0); - } - - #[test] - fn pop() { - let mut s = String::from("öaöböcödöeöfö"); - let mut c = Cursor::from(s.clone()); - c.end(); - while !s.is_empty() { - let c1 = s.pop(); - let c2 = c.back(); - assert_eq!(c1, c2); - assert_eq!(s.as_str(), c.substring()); - } - let c1 = s.pop(); - let c2 = c.back(); - assert_eq!(c1, c2); - } - - #[test] - fn back() { - let mut c = Cursor::from(String::from("öaöböcödöeöfö")); - // move to ^ - for _ in 0..4 { - c.right(); - } - assert_eq!(c.substring(), "öaöb"); - assert_eq!(c.back(), Some('b')); - assert_eq!(c.back(), Some('ö')); - assert_eq!(c.back(), Some('a')); - assert_eq!(c.back(), Some('ö')); - assert_eq!(c.back(), None); - assert_eq!(c.as_str(), "öcödöeöfö"); - } - - #[test] - fn insert() { - let mut c = Cursor::from(String::from("öaöböcödöeöfö")); - // move to ^ - for _ in 0..4 { - c.right(); - } - assert_eq!(c.substring(), "öaöb"); - c.insert('ö'); - c.insert('g'); - c.insert('ö'); - c.insert('h'); - assert_eq!(c.substring(), "öaöbögöh"); - assert_eq!(c.as_str(), "öaöbögöhöcödöeöfö"); - } -} diff --git a/crates/client/src/command/client/search/duration.rs b/crates/client/src/command/client/search/duration.rs deleted file mode 100644 index 0d70353a..00000000 --- a/crates/client/src/command/client/search/duration.rs +++ /dev/null @@ -1,63 +0,0 @@ -use core::fmt; -use std::{ops::ControlFlow, time::Duration}; - -pub(crate) fn format_duration_into(dur: Duration, f: &mut fmt::Formatter<'_>) -> fmt::Result { - fn item(unit: &'static str, value: u64) -> ControlFlow<(&'static str, u64)> { - if value > 0 { - ControlFlow::Break((unit, value)) - } else { - ControlFlow::Continue(()) - } - } - - // impl taken and modified from - // https://github.com/tailhook/humantime/blob/master/src/duration.rs#L295-L331 - // Copyright (c) 2016 The humantime Developers - fn fmt(f: Duration) -> ControlFlow<(&'static str, u64), ()> { - let secs = f.as_secs(); - let nanos = f.subsec_nanos(); - - let years = secs / 31_557_600; // 365.25d - let year_days = secs % 31_557_600; - let months = year_days / 2_630_016; // 30.44d - let month_days = year_days % 2_630_016; - let days = month_days / 86400; - let day_secs = month_days % 86400; - let hours = day_secs / 3600; - let minutes = day_secs % 3600 / 60; - let seconds = day_secs % 60; - - let millis = nanos / 1_000_000; - let micros = nanos / 1_000; - - // a difference from our impl than the original is that - // we only care about the most-significant segment of the duration. - // If the item call returns `Break`, then the `?` will early-return. - // This allows for a very consise impl - item("y", years)?; - item("mo", months)?; - item("d", days)?; - item("h", hours)?; - item("m", minutes)?; - item("s", seconds)?; - item("ms", u64::from(millis))?; - item("us", u64::from(micros))?; - item("ns", u64::from(nanos))?; - ControlFlow::Continue(()) - } - - match fmt(dur) { - ControlFlow::Break((unit, value)) => write!(f, "{value}{unit}"), - ControlFlow::Continue(()) => write!(f, "0s"), - } -} - -pub(crate) fn format_duration(f: Duration) -> String { - struct F(Duration); - impl fmt::Display for F { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - format_duration_into(self.0, f) - } - } - F(f).to_string() -} diff --git a/crates/client/src/command/client/search/engines.rs b/crates/client/src/command/client/search/engines.rs deleted file mode 100644 index 9fbff278..00000000 --- a/crates/client/src/command/client/search/engines.rs +++ /dev/null @@ -1,86 +0,0 @@ -use crate::atuin_client::{ - database::{ClientSqlite, Context, OptFilters}, - history::{History, HistoryId}, - settings::{FilterMode, SearchMode, Settings}, -}; -use async_trait::async_trait; -use eyre::Result; - -use super::cursor::Cursor; - -pub(crate) mod daemon; -pub(crate) mod db; -pub(crate) mod skim; - -pub(crate) fn engine(search_mode: SearchMode, settings: &Settings) -> Box<dyn SearchEngine> { - match search_mode { - SearchMode::Skim => Box::new(skim::Search::new()), - SearchMode::DaemonFuzzy => Box::new(daemon::Search::new(settings)), - mode => Box::new(db::Search(mode)), - } -} - -pub(crate) struct SearchState { - pub(crate) input: Cursor, - pub(crate) filter_mode: FilterMode, - pub(crate) context: Context, - pub(crate) custom_context: Option<HistoryId>, -} - -impl SearchState { - pub(crate) fn rotate_filter_mode(&mut self, settings: &Settings, offset: isize) { - let mut i = settings - .search - .filters - .iter() - .position(|&m| m == self.filter_mode) - .unwrap_or_default(); - for _ in 0..settings.search.filters.len() { - i = (i.wrapping_add_signed(offset)) % settings.search.filters.len(); - let mode = settings.search.filters[i]; - if self.filter_mode_available(mode, settings) { - self.filter_mode = mode; - break; - } - } - } - - fn filter_mode_available(&self, mode: FilterMode, settings: &Settings) -> bool { - match mode { - FilterMode::Global | FilterMode::SessionPreload => self.custom_context.is_none(), - FilterMode::Workspace => settings.workspaces && self.context.git_root.is_some(), - _ => true, - } - } -} - -#[async_trait] -pub(crate) trait SearchEngine: Send + Sync + 'static { - async fn full_query( - &mut self, - state: &SearchState, - db: &mut ClientSqlite, - ) -> Result<Vec<History>>; - - async fn query(&mut self, state: &SearchState, db: &mut ClientSqlite) -> Result<Vec<History>> { - if state.input.as_str().is_empty() { - Ok(db - .search( - SearchMode::FullText, - state.filter_mode, - &state.context, - "", - OptFilters { - limit: Some(200), - ..Default::default() - }, - ) - .await? - .into_iter() - .collect::<Vec<_>>()) - } else { - self.full_query(state, db).await - } - } - fn get_highlight_indices(&self, command: &str, search_input: &str) -> Vec<usize>; -} diff --git a/crates/client/src/command/client/search/engines/daemon.rs b/crates/client/src/command/client/search/engines/daemon.rs deleted file mode 100644 index ee92ebaf..00000000 --- a/crates/client/src/command/client/search/engines/daemon.rs +++ /dev/null @@ -1,213 +0,0 @@ -use crate::atuin_client::{ - database::{ClientSqlite, OptFilters}, - history::History, - settings::{SearchMode, Settings}, -}; -use crate::atuin_daemon::client::SearchClient; -use async_trait::async_trait; -use atuin_nucleo_matcher::{ - Config, Matcher, Utf32Str, - pattern::{CaseMatching, Normalization, Pattern}, -}; -use eyre::Result; -use tracing::{Level, debug, instrument, span}; -use uuid::Uuid; - -use super::{SearchEngine, SearchState}; - -pub(crate) struct Search { - client: Option<SearchClient>, - query_id: u64, - #[cfg(unix)] - socket_path: String, -} - -impl Search { - pub(crate) fn new(settings: &Settings) -> Self { - Self { - client: None, - query_id: 0, - #[cfg(unix)] - socket_path: settings.daemon.socket_path.clone(), - } - } - - #[instrument(skip_all, level = Level::TRACE, name = "get_daemon_client")] - async fn get_client(&mut self) -> Result<&mut SearchClient> { - if self.client.is_none() { - self.connect().await?; - } - Ok(self.client.as_mut().unwrap()) - } - - async fn connect(&mut self) -> Result<()> { - #[cfg(unix)] - let client = SearchClient::new(self.socket_path.clone()).await?; - - self.client = Some(client); - Ok(()) - } - - fn next_query_id(&mut self) -> u64 { - self.query_id += 1; - self.query_id - } - - /// Check if query contains regex pattern (r/.../) - /// Nucleo doesn't support regex, so we fall back to database search - fn contains_regex_pattern(query: &str) -> bool { - query.starts_with("r/") || query.contains(" r/") - } - - #[instrument(skip_all, level = Level::TRACE, name = "daemon_db_fallback")] - async fn fallback_to_db_search( - &self, - state: &SearchState, - db: &ClientSqlite, - ) -> Result<Vec<History>> { - let results = db - .search( - SearchMode::FullText, - state.filter_mode, - &state.context, - state.input.as_str(), - OptFilters { - limit: Some(200), - ..Default::default() - }, - ) - .await - .map_or(Vec::new(), |r| r.into_iter().collect()); - Ok(results) - } - - #[instrument(skip_all, level = Level::TRACE, name = "hydrate_from_db", fields(count = ids.len()))] - async fn hydrate_from_db(&self, db: &ClientSqlite, ids: &[String]) -> Result<Vec<History>> { - let placeholders: Vec<String> = ids.iter().map(|id| format!("'{id}'")).collect(); - let sql_query = format!( - "SELECT * FROM history WHERE id IN ({}) ORDER BY timestamp DESC", - placeholders.join(",") - ); - Ok(db.query_history(&sql_query).await?) - } -} - -#[async_trait] -impl SearchEngine for Search { - #[instrument(skip_all, level = Level::TRACE, name = "daemon_search", fields(query = %state.input.as_str()))] - async fn full_query( - &mut self, - state: &SearchState, - db: &mut ClientSqlite, - ) -> Result<Vec<History>> { - let query = state.input.as_str().to_string(); - - // Fall back to database for regex queries (Nucleo doesn't support regex) - if Self::contains_regex_pattern(&query) { - debug!(query = %query, "[daemon-client] regex detected, falling back to db"); - return self.fallback_to_db_search(state, db).await; - } - - let query_id = self.next_query_id(); - - let span = - span!(Level::TRACE, "daemon_search.req_resp", query = %query, query_id = query_id); - - // Try to connect and search; if it fails with a retriable error, - // auto-start the daemon and retry once. - let first_attempt = async { - let client = self.get_client().await?; - client - .search( - query.clone(), - query_id, - state.filter_mode, - Some(state.context.clone()), - ) - .await - } - .await; - - let mut stream = match first_attempt { - Ok(stream) => stream, - Err(err) => return Err(err), - }; - - let mut ids = Vec::with_capacity(200); - span!(Level::TRACE, "daemon_search.resp") - .in_scope(async || { - while let Ok(Some(response)) = stream.message().await { - let span2 = span!( - Level::TRACE, - "daemon_search.resp.item", - query_id = response.query_id - ); - let _span2 = span2.enter(); - // Only process if the query_id matches (prevents stale responses) - if response.query_id == query_id { - let uuids = response - .ids - .iter() - .map(|id| { - let bytes: [u8; 16] = - id.as_slice().try_into().expect("id should be 16 bytes"); - Uuid::from_bytes(bytes).as_simple().to_string() - }) - .collect::<Vec<_>>(); - ids.extend(uuids); - } - drop(_span2); - drop(span2); - } - }) - .await; - drop(span); - - if ids.is_empty() { - debug!(query = %query, results = 0, "[daemon-client] empty results"); - return Ok(Vec::new()); - } - - // // Hydrate from local database - let results = self.hydrate_from_db(db, &ids).await?; - - // // Reorder results to match the order from the daemon (which is ranked by relevance) - let ordered_results = span!(Level::TRACE, "reorder_results").in_scope(|| { - let mut ordered_results = Vec::with_capacity(results.len()); - for id in &ids { - if let Some(history) = results.iter().find(|h| h.id.0 == *id) { - ordered_results.push(history.clone()); - } - } - ordered_results - }); - - debug!( - query = %query, - results = results.len(), - "[daemon-client]" - ); - - Ok(ordered_results) - } - - #[instrument(skip_all, level = Level::TRACE, name = "daemon_highlight")] - fn get_highlight_indices(&self, command: &str, search_input: &str) -> Vec<usize> { - // Use fulltext highlighting for regex queries - if Self::contains_regex_pattern(search_input) { - return super::db::get_highlight_indices_fulltext(command, search_input); - } - - let mut matcher = Matcher::new(Config::DEFAULT); - let pattern = Pattern::parse(search_input, CaseMatching::Smart, Normalization::Smart); - - let mut indices: Vec<u32> = Vec::new(); - let mut haystack_buf = Vec::new(); - - let haystack = Utf32Str::new(command, &mut haystack_buf); - pattern.indices(haystack, &mut matcher, &mut indices); - - // Convert u32 indices to usize - indices.into_iter().map(|i| i as usize).collect() - } -} diff --git a/crates/client/src/command/client/search/engines/db.rs b/crates/client/src/command/client/search/engines/db.rs deleted file mode 100644 index 0eb86878..00000000 --- a/crates/client/src/command/client/search/engines/db.rs +++ /dev/null @@ -1,107 +0,0 @@ -use super::{SearchEngine, SearchState}; -use crate::atuin_client::{ - database::{ClientSqlite, OptFilters, QueryToken, QueryTokenizer}, - history::History, - settings::SearchMode, -}; -use async_trait::async_trait; -use eyre::Result; -use norm::Metric; -use norm::fzf::{FzfParser, FzfV2}; -use std::ops::Range; -use tracing::{Level, instrument}; - -pub(crate) struct Search(pub(crate) SearchMode); - -#[async_trait] -impl SearchEngine for Search { - #[instrument(skip_all, level = Level::TRACE, name = "db_search", fields(mode = ?self.0, query = %state.input.as_str()))] - async fn full_query( - &mut self, - state: &SearchState, - db: &mut ClientSqlite, - ) -> Result<Vec<History>> { - let results = db - .search( - self.0, - state.filter_mode, - &state.context, - state.input.as_str(), - OptFilters { - limit: Some(200), - ..Default::default() - }, - ) - .await - // ignore errors as it may be caused by incomplete regex - .map_or(Vec::new(), |r| r.into_iter().collect()); - Ok(results) - } - - #[instrument(skip_all, level = Level::TRACE, name = "db_highlight")] - fn get_highlight_indices(&self, command: &str, search_input: &str) -> Vec<usize> { - if self.0 == SearchMode::Prefix { - return vec![]; - } else if self.0 == SearchMode::FullText { - return get_highlight_indices_fulltext(command, search_input); - } - let mut fzf = FzfV2::new(); - let mut parser = FzfParser::new(); - let query = parser.parse(search_input); - let mut ranges: Vec<Range<usize>> = Vec::new(); - fzf.distance_and_ranges(query, command, &mut ranges); - - // convert ranges to all indices - ranges.into_iter().flatten().collect() - } -} - -#[instrument(skip_all, level = Level::TRACE, name = "db_highlight_fulltext")] -pub(crate) fn get_highlight_indices_fulltext(command: &str, search_input: &str) -> Vec<usize> { - let mut ranges = vec![]; - let lower_command = command.to_ascii_lowercase(); - - for token in QueryTokenizer::new(search_input) { - let matchee = if token.has_uppercase() { - command - } else { - &lower_command - }; - - if token.is_inverse() { - continue; - } - - match token { - QueryToken::Or => {} - QueryToken::Regex(r) => { - if let Ok(re) = regex::Regex::new(r) { - for m in re.find_iter(command) { - ranges.push(m.range()); - } - } - } - QueryToken::MatchStart(term, _) => { - if matchee.starts_with(term) { - ranges.push(0..term.len()); - } - } - QueryToken::MatchEnd(term, _) => { - if matchee.ends_with(term) { - let l = matchee.len(); - ranges.push((l - term.len())..l); - } - } - QueryToken::Match(term, _) | QueryToken::MatchFull(term, _) => { - for (idx, m) in matchee.match_indices(term) { - ranges.push(idx..(idx + m.len())); - } - } - } - } - - let mut ret: Vec<_> = ranges.into_iter().flatten().collect(); - ret.sort_unstable(); - ret.dedup(); - ret -} diff --git a/crates/client/src/command/client/search/engines/skim.rs b/crates/client/src/command/client/search/engines/skim.rs deleted file mode 100644 index e090e40d..00000000 --- a/crates/client/src/command/client/search/engines/skim.rs +++ /dev/null @@ -1,222 +0,0 @@ -use std::path::Path; - -use crate::atuin_client::{database::ClientSqlite, history::History, settings::FilterMode}; -use async_trait::async_trait; -use eyre::Result; -use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2}; -use itertools::Itertools; -use time::OffsetDateTime; -use tokio::task::yield_now; -use tracing::{Level, instrument, warn}; - -use super::{SearchEngine, SearchState}; - -pub(crate) struct Search { - all_history: Vec<(History, i32)>, - engine: SkimMatcherV2, -} - -impl Search { - pub(crate) fn new() -> Self { - Self { - all_history: vec![], - engine: SkimMatcherV2::default(), - } - } -} - -#[async_trait] -impl SearchEngine for Search { - #[instrument(skip_all, level = Level::TRACE, name = "skim_search", fields(query = %state.input.as_str()))] - async fn full_query( - &mut self, - state: &SearchState, - db: &mut ClientSqlite, - ) -> Result<Vec<History>> { - if self.all_history.is_empty() { - self.all_history = load_all_history(db).await; - } - - Ok(fuzzy_search(&self.engine, state, &self.all_history).await) - } - - #[instrument(skip_all, level = Level::TRACE, name = "skim_highlight")] - fn get_highlight_indices(&self, command: &str, search_input: &str) -> Vec<usize> { - let (_, indices) = self - .engine - .fuzzy_indices(command, search_input) - .unwrap_or_default(); - indices - } -} - -#[instrument(skip_all, level = Level::TRACE, name = "load_all_history")] -async fn load_all_history(db: &ClientSqlite) -> Vec<(History, i32)> { - db.all_with_count().await.unwrap() -} - -#[expect(clippy::too_many_lines)] -#[instrument(skip_all, level = Level::TRACE, name = "fuzzy_match", fields(history_count = all_history.len()))] -async fn fuzzy_search( - engine: &SkimMatcherV2, - state: &SearchState, - all_history: &[(History, i32)], -) -> Vec<History> { - let mut set = Vec::with_capacity(200); - let mut ranks = Vec::with_capacity(200); - let query = state.input.as_str(); - let now = OffsetDateTime::now_utc(); - - for (i, (history, count)) in all_history.iter().enumerate() { - if i % 256 == 0 { - yield_now().await; - } - - let context = &state.context; - let git_root = context - .git_root - .as_ref() - .and_then(|git_root| git_root.to_str()) - .unwrap_or(&context.cwd); - match state.filter_mode { - FilterMode::Global => {} - // we aggregate host by ',' separating them - FilterMode::Host - if history - .hostname - .split(',') - .contains(&context.hostname.as_str()) => {} - // we aggregate session by concattenating them. - // sessions are 32 byte simple uuid formats - FilterMode::Session - if history - .session - .as_bytes() - .chunks(32) - .contains(&context.session.as_bytes()) => {} - // SessionPreload: include current session + global history from before session start - FilterMode::SessionPreload => { - let is_current_session = { - history - .session - .as_bytes() - .chunks(32) - .any(|chunk| chunk == context.session.as_bytes()) - }; - - if !is_current_session { - let Ok(uuid) = uuid::Uuid::parse_str(&context.session) else { - warn!("failed to parse session id '{}'", context.session); - continue; - }; - let Some(timestamp) = uuid.get_timestamp() else { - warn!( - "failed to get timestamp from uuid '{}'", - uuid.as_hyphenated() - ); - continue; - }; - let (seconds, nanos) = timestamp.to_unix(); - let Ok(session_start) = OffsetDateTime::from_unix_timestamp_nanos( - i128::from(seconds) * 1_000_000_000 + i128::from(nanos), - ) else { - warn!( - "failed to create OffsetDateTime from second: {seconds}, nanosecond: {nanos}" - ); - continue; - }; - - if history.timestamp >= session_start { - continue; - } - } - } - // we aggregate directory by ':' separating them - FilterMode::Directory if history.cwd.split(':').contains(&context.cwd.as_str()) => {} - FilterMode::Workspace if history.cwd.split(':').contains(&git_root) => {} - _ => continue, - } - #[expect(clippy::cast_lossless, clippy::cast_precision_loss)] - if let Some((score, indices)) = engine.fuzzy_indices(&history.command, query) { - let begin = indices.first().copied().unwrap_or_default(); - - let mut duration = (now - history.timestamp).as_seconds_f64().log2(); - if !duration.is_finite() || duration <= 1.0 { - duration = 1.0; - } - // these + X.0 just make the log result a bit smoother. - // log is very spiky towards 1-4, but I want a gradual decay. - // eg: - // log2(4) = 2, log2(5) = 2.3 (16% increase) - // log2(8) = 3, log2(9) = 3.16 (5% increase) - // log2(16) = 4, log2(17) = 4.08 (2% increase) - let count = (*count as f64 + 8.0).log2(); - let begin = (begin as f64 + 16.0).log2(); - let path = path_dist(history.cwd.as_ref(), state.context.cwd.as_ref()); - let path = (path as f64 + 8.0).log2(); - - // reduce longer durations, raise higher counts, raise matches close to the start - let score = (-score as f64) * count / path / duration / begin; - - 'insert: { - // algorithm: - // 1. find either the position that this command ranks - // 2. find the same command positioned better than our rank. - for i in 0..set.len() { - // do we out score the current position? - if ranks[i] > score { - ranks.insert(i, score); - set.insert(i, history.clone()); - let mut j = i + 1; - while j < set.len() { - // remove duplicates that have a worse score - if set[j].command == history.command { - ranks.remove(j); - set.remove(j); - - // break this while loop because there won't be any other - // duplicates. - break; - } - j += 1; - } - - // keep it limited - if ranks.len() > 200 { - ranks.pop(); - set.pop(); - } - - break 'insert; - } - // don't continue if this command has a better score already - if set[i].command == history.command { - break 'insert; - } - } - - if set.len() < 200 { - ranks.push(score); - set.push(history.clone()); - } - } - } - } - - set -} - -fn path_dist(a: &Path, b: &Path) -> usize { - let mut a: Vec<_> = a.components().collect(); - let b: Vec<_> = b.components().collect(); - - let mut dist = 0; - - // pop a until there's a common ancestor - while !b.starts_with(&a) { - dist += 1; - a.pop(); - } - - b.len() - a.len() + dist -} diff --git a/crates/client/src/command/client/search/history_list.rs b/crates/client/src/command/client/search/history_list.rs deleted file mode 100644 index e46f37b7..00000000 --- a/crates/client/src/command/client/search/history_list.rs +++ /dev/null @@ -1,431 +0,0 @@ -use std::time::Duration; - -use super::duration::format_duration; -use super::engines::SearchEngine; -use crate::atuin_client::{ - history::History, - settings::{UiColumn, UiColumnType}, - theme::{ - style_alerterror, style_alertinfo, style_alertwarn, style_annotation, style_base, - style_guidance, - }, -}; -use crate::atuin_common::utils::Escapable as _; -use itertools::Itertools; -use ratatui::{ - backend::FromCrossterm, - buffer::Buffer, - crossterm::style, - layout::Rect, - style::{Modifier, Style}, - widgets::{Block, StatefulWidget, Widget}, -}; -use time::OffsetDateTime; - -pub(crate) struct HistoryHighlighter<'a> { - pub(crate) engine: &'a dyn SearchEngine, - pub(crate) search_input: &'a str, -} - -impl HistoryHighlighter<'_> { - pub(crate) fn get_highlight_indices(&self, command: &str) -> Vec<usize> { - self.engine - .get_highlight_indices(command, self.search_input) - } -} - -pub(crate) struct HistoryList<'a> { - history: &'a [History], - block: Option<Block<'a>>, - inverted: bool, - /// Apply an alternative highlighting to the selected row - alternate_highlight: bool, - now: &'a dyn Fn() -> OffsetDateTime, - indicator: &'a str, - - history_highlighter: HistoryHighlighter<'a>, - show_numeric_shortcuts: bool, - /// Columns to display (in order, after the indicator) - columns: &'a [UiColumn], -} - -#[derive(Default)] -pub(crate) struct ListState { - offset: usize, - selected: usize, - max_entries: usize, -} - -impl ListState { - pub(crate) fn selected(&self) -> usize { - self.selected - } - - pub(crate) fn max_entries(&self) -> usize { - self.max_entries - } - - pub(crate) fn offset(&self) -> usize { - self.offset - } - - pub(crate) fn select(&mut self, index: usize) { - self.selected = index; - } -} - -impl StatefulWidget for HistoryList<'_> { - type State = ListState; - - fn render(mut self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { - let list_area = self.block.take().map_or(area, |b| { - let inner_area = b.inner(area); - b.render(area, buf); - inner_area - }); - - if list_area.width < 1 || list_area.height < 1 || self.history.is_empty() { - return; - } - let list_height = list_area.height as usize; - - let (start, end) = self.get_items_bounds(state.selected, state.offset, list_height); - state.offset = start; - state.max_entries = end - start; - - let mut s = DrawState { - buf, - list_area, - x: 0, - y: 0, - state, - inverted: self.inverted, - alternate_highlight: self.alternate_highlight, - now: &self.now, - indicator: self.indicator, - - history_highlighter: self.history_highlighter, - show_numeric_shortcuts: self.show_numeric_shortcuts, - columns: self.columns, - }; - - for item in self.history.iter().skip(state.offset).take(end - start) { - s.render_row(item); - - // reset line - s.y += 1; - s.x = 0; - } - } -} - -impl<'a> HistoryList<'a> { - #[expect(clippy::too_many_arguments)] - pub(crate) fn new( - history: &'a [History], - inverted: bool, - alternate_highlight: bool, - now: &'a dyn Fn() -> OffsetDateTime, - indicator: &'a str, - - history_highlighter: HistoryHighlighter<'a>, - show_numeric_shortcuts: bool, - columns: &'a [UiColumn], - ) -> Self { - Self { - history, - block: None, - inverted, - alternate_highlight, - now, - indicator, - history_highlighter, - show_numeric_shortcuts, - columns, - } - } - - pub(crate) fn block(mut self, block: Block<'a>) -> Self { - self.block = Some(block); - self - } - - fn get_items_bounds(&self, selected: usize, offset: usize, height: usize) -> (usize, usize) { - let offset = offset.min(self.history.len().saturating_sub(1)); - - let max_scroll_space = height.min(10).min(self.history.len() - selected); - if offset + height < selected + max_scroll_space { - let end = selected + max_scroll_space; - (end - height, end) - } else if selected < offset { - (selected, selected + height) - } else { - (offset, offset + height) - } - } -} - -struct DrawState<'a> { - buf: &'a mut Buffer, - list_area: Rect, - x: u16, - y: u16, - state: &'a ListState, - inverted: bool, - alternate_highlight: bool, - now: &'a dyn Fn() -> OffsetDateTime, - indicator: &'a str, - - history_highlighter: HistoryHighlighter<'a>, - show_numeric_shortcuts: bool, - columns: &'a [UiColumn], -} - -// these encode the slices of `" > "`, `" {n} "`, or `" "` in a compact form. -// Yes, this is a hack, but it makes me feel happy -static SLICES: &str = " > 1 2 3 4 5 6 7 8 9 "; - -impl DrawState<'_> { - /// Render a complete row for a history item based on configured columns. - fn render_row(&mut self, h: &History) { - // Always render the indicator first (width 3) - self.index(); - - // Calculate the width for the expanding column - // Fixed columns use their configured width + 1 (trailing space) - let indicator_width: u16 = 3; - let fixed_width: u16 = self - .columns - .iter() - .filter(|c| !c.expand) - .map(|c| c.width + 1) - .sum(); - let expand_width = self - .list_area - .width - .saturating_sub(indicator_width + fixed_width); - - let style = style_base(); - // Render each configured column - for (idx, column) in self.columns.iter().enumerate() { - if idx != 0 { - self.draw(" ", Style::from_crossterm(style)); - } - let width = if column.expand { - expand_width - } else { - column.width - }; - match column.column_type { - UiColumnType::Duration => self.duration(h, width), - UiColumnType::Time => self.time(h, width), - UiColumnType::Datetime => self.datetime(h, width), - UiColumnType::Directory => self.directory(h, width), - UiColumnType::Host => self.host(h, width), - UiColumnType::User => self.user(h, width), - UiColumnType::Exit => self.exit_code(h, width), - UiColumnType::Command => self.command(h), - } - } - } - - fn index(&mut self) { - if !self.show_numeric_shortcuts { - let i = self.y as usize + self.state.offset; - let is_selected = i == self.state.selected(); - let prompt: &str = if is_selected { self.indicator } else { " " }; - self.draw(prompt, Style::default()); - return; - } - - // these encode the slices of `" > "`, `" {n} "`, or `" "` in a compact form. - // Yes, this is a hack, but it makes me feel happy - - let i = self.y as usize + self.state.offset; - let i = i.checked_sub(self.state.selected); - let i = i.unwrap_or(10).min(10) * 2; - let prompt: &str = if i == 0 { - self.indicator - } else { - &SLICES[i..i + 3] - }; - self.draw(prompt, Style::default()); - } - - fn duration(&mut self, h: &History, width: u16) { - let style = if h.success() { - style_alertinfo() - } else { - style_alerterror() - }; - let duration = Duration::from_nanos(u64::try_from(h.duration).unwrap_or(0)); - let formatted = format_duration(duration); - let w = width as usize; - // Right-align duration within its column width, plus trailing space - let display = format!("{formatted:>w$}"); - self.draw(&display, Style::from_crossterm(style)); - } - - fn time(&mut self, h: &History, width: u16) { - let style = style_guidance(); - - // Account for the chance that h.timestamp is "in the future" - // This would mean that "since" is negative, and the unwrap here - // would fail. - // If the timestamp would otherwise be in the future, display - // the time since as 0. - let since = (self.now)() - h.timestamp; - let time = format_duration(since.try_into().unwrap_or_default()); - - // Format as "Xs ago" right-aligned within column width - let w = width as usize; - let time_str = format!("{time} ago"); - - let display = format!("{time_str:>w$}"); - self.draw(&display, Style::from_crossterm(style)); - } - - fn command(&mut self, h: &History) { - let mut style = style_base(); - let mut row_highlighted = false; - if !self.alternate_highlight && (self.y as usize + self.state.offset == self.state.selected) - { - row_highlighted = true; - // if not applying alternative highlighting to the whole row, color the command - style = style_alerterror(); - style.attributes.set(style::Attribute::Bold); - } - - let highlight_indices = self.history_highlighter.get_highlight_indices( - h.command - .escape_control() - .split_ascii_whitespace() - .join(" ") - .as_str(), - ); - - let mut pos = 0; - for section in h.command.escape_control().split_ascii_whitespace() { - if pos != 0 { - self.draw(" ", Style::from_crossterm(style)); - } - for ch in section.chars() { - if self.x > self.list_area.width { - // Avoid attempting to draw a command section beyond the width - // of the list - return; - } - let mut style = style; - if highlight_indices.contains(&pos) { - if row_highlighted { - // if the row is highlighted bold is not enough as the whole row is bold - // change the color too - style = style_alertwarn(); - } - style.attributes.set(style::Attribute::Bold); - } - let s = ch.to_string(); - self.draw(&s, Style::from_crossterm(style)); - pos += s.len(); - } - pos += 1; - } - } - - /// Render the absolute datetime column (e.g., "2025-01-22 14:35") - fn datetime(&mut self, h: &History, width: u16) { - let style = style_annotation(); - // Format: YYYY-MM-DD HH:MM - let formatted = h - .timestamp - .format( - &time::format_description::parse("[year]-[month]-[day] [hour]:[minute]") - .expect("valid format"), - ) - .unwrap_or_else(|_| "????-??-?? ??:??".to_string()); - let w = width as usize; - let display = format!("{formatted:w$}"); - self.draw(&display, Style::from_crossterm(style)); - } - - /// Render the directory column (working directory, truncated) - fn directory(&mut self, h: &History, width: u16) { - let style = style_annotation(); - let w = width as usize; - let cwd = &h.cwd; - let char_count = cwd.chars().count(); - // Truncate from the left with "..." if too long, plus trailing space - // Use character count for comparison and skip for UTF-8 safety - let display = if char_count > w && w >= 4 { - let truncated: String = cwd.chars().skip(char_count - (w - 3)).collect(); - format!("...{truncated}") - } else { - format!("{cwd:w$}") - }; - self.draw(&display, Style::from_crossterm(style)); - } - - /// Render the host column (just the hostname) - fn host(&mut self, h: &History, width: u16) { - let style = style_annotation(); - let w = width as usize; - // Database stores hostname as "hostname:username" - let host = h.hostname.split(':').next().unwrap_or(&h.hostname); - let char_count = host.chars().count(); - // Use character count for comparison and take for UTF-8 safety - let display = if char_count > w && w >= 4 { - let truncated: String = host.chars().take(w.saturating_sub(4)).collect(); - format!("{truncated}...") - } else { - format!("{host:w$}") - }; - self.draw(&display, Style::from_crossterm(style)); - } - - /// Render the user column - fn user(&mut self, h: &History, width: u16) { - let style = style_annotation(); - let w = width as usize; - // Database stores hostname as "hostname:username" - let user = h.hostname.split(':').nth(1).unwrap_or(""); - let char_count = user.chars().count(); - // Use character count for comparison and take for UTF-8 safety - let display = if char_count > w && w >= 4 { - let truncated: String = user.chars().take(w.saturating_sub(4)).collect(); - format!("{truncated}...") - } else { - format!("{user:w$}") - }; - self.draw(&display, Style::from_crossterm(style)); - } - - /// Render the exit code column - fn exit_code(&mut self, h: &History, width: u16) { - let style = if h.success() { - style_alertinfo() - } else { - style_alerterror() - }; - let w = width as usize; - let display = format!("{:>w$}", h.exit); - self.draw(&display, Style::from_crossterm(style)); - } - - fn draw(&mut self, s: &str, mut style: Style) { - let cx = self.list_area.left() + self.x; - - let cy = if self.inverted { - self.list_area.top() + self.y - } else { - self.list_area.bottom() - self.y - 1 - }; - - if self.alternate_highlight && (self.y as usize + self.state.offset == self.state.selected) - { - style = style.add_modifier(Modifier::REVERSED); - } - - let w = (self.list_area.width - self.x) as usize; - self.x += self.buf.set_stringn(cx, cy, s, w, style).0 - cx; - } -} diff --git a/crates/client/src/command/client/search/inspector.rs b/crates/client/src/command/client/search/inspector.rs deleted file mode 100644 index 186dcd3a..00000000 --- a/crates/client/src/command/client/search/inspector.rs +++ /dev/null @@ -1,414 +0,0 @@ -use std::time::Duration; -use time::macros::format_description; - -use crate::atuin_client::{ - history::{History, HistoryStats}, - settings::{Settings, Timezone}, - theme::{style_annotation, style_base, style_important}, -}; -use ratatui::{ - Frame, - backend::FromCrossterm, - layout::Rect, - prelude::{Constraint, Direction, Layout}, - style::Style, - text::{Span, Text}, - widgets::{Bar, BarChart, BarGroup, Block, Borders, Padding, Paragraph, Row, Table}, -}; - -use super::duration::format_duration; - -use super::interactive::{Compactness, to_compactness}; - -#[expect(clippy::cast_sign_loss)] -fn u64_or_zero(num: i64) -> u64 { - if num < 0 { 0 } else { num as u64 } -} - -pub(crate) fn draw_commands( - f: &mut Frame<'_>, - parent: Rect, - history: &History, - stats: &HistoryStats, - compact: bool, -) { - let commands = Layout::default() - .direction(if compact { - Direction::Vertical - } else { - Direction::Horizontal - }) - .constraints(if compact { - [ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(0), - ] - } else { - [ - Constraint::Ratio(1, 4), - Constraint::Ratio(1, 2), - Constraint::Ratio(1, 4), - ] - }) - .split(parent); - - let command = Paragraph::new(Text::from(Span::styled( - history.command.clone(), - Style::from_crossterm(style_important()), - ))) - .block(if compact { - Block::new() - .borders(Borders::NONE) - .style(Style::from_crossterm(style_base())) - } else { - Block::new() - .borders(Borders::ALL) - .style(Style::from_crossterm(style_base())) - .title("Command") - .padding(Padding::horizontal(1)) - }); - - let previous = Paragraph::new( - stats - .previous - .clone() - .map_or_else(|| "[No previous command]".to_string(), |prev| prev.command), - ) - .block(if compact { - Block::new() - .borders(Borders::NONE) - .style(Style::from_crossterm(style_annotation())) - } else { - Block::new() - .borders(Borders::ALL) - .style(Style::from_crossterm(style_annotation())) - .title("Previous command") - .padding(Padding::horizontal(1)) - }); - - // Add [] around blank text, as when this is shown in a list - // compacted, it makes it more obviously control text. - let next = Paragraph::new( - stats - .next - .clone() - .map_or_else(|| "[No next command]".to_string(), |next| next.command), - ) - .block(if compact { - Block::new() - .borders(Borders::NONE) - .style(Style::from_crossterm(style_annotation())) - } else { - Block::new() - .borders(Borders::ALL) - .title("Next command") - .padding(Padding::horizontal(1)) - .style(Style::from_crossterm(style_annotation())) - }); - - f.render_widget(previous, commands[0]); - f.render_widget(command, commands[1]); - f.render_widget(next, commands[2]); -} - -pub(crate) fn draw_stats_table( - f: &mut Frame<'_>, - parent: Rect, - history: &History, - tz: Timezone, - stats: &HistoryStats, -) { - let duration = Duration::from_nanos(u64_or_zero(history.duration)); - let avg_duration = Duration::from_nanos(stats.average_duration); - let (host, user) = history.hostname.split_once(':').unwrap_or(("", "")); - - let rows = [ - Row::new(vec!["Host".to_string(), host.to_string()]), - Row::new(vec!["User".to_string(), user.to_string()]), - Row::new(vec![ - "Time".to_string(), - history.timestamp.to_offset(tz.0).to_string(), - ]), - Row::new(vec!["Duration".to_string(), format_duration(duration)]), - Row::new(vec![ - "Avg duration".to_string(), - format_duration(avg_duration), - ]), - Row::new(vec!["Exit".to_string(), history.exit.to_string()]), - Row::new(vec!["Directory".to_string(), history.cwd.clone()]), - Row::new(vec!["Session".to_string(), history.session.clone()]), - Row::new(vec!["Total runs".to_string(), stats.total.to_string()]), - ]; - - let widths = [Constraint::Ratio(1, 5), Constraint::Ratio(4, 5)]; - - let table = Table::new(rows, widths).column_spacing(1).block( - Block::default() - .title("Command stats") - .borders(Borders::ALL) - .style(Style::from_crossterm(style_base())) - .padding(Padding::vertical(1)), - ); - - f.render_widget(table, parent); -} - -fn num_to_day(num: &str) -> String { - match num { - "0" => "Sunday".to_string(), - "1" => "Monday".to_string(), - "2" => "Tuesday".to_string(), - "3" => "Wednesday".to_string(), - "4" => "Thursday".to_string(), - "5" => "Friday".to_string(), - "6" => "Saturday".to_string(), - _ => "Invalid day".to_string(), - } -} - -fn sort_duration_over_time(durations: &[(String, i64)]) -> Vec<(String, i64)> { - let format = format_description!("[day]-[month]-[year]"); - let output = format_description!("[month]/[year repr:last_two]"); - - let mut durations: Vec<(time::Date, i64)> = durations - .iter() - .map(|d| { - ( - time::Date::parse(d.0.as_str(), &format).expect("invalid date string from sqlite"), - d.1, - ) - }) - .collect(); - - durations.sort_by_key(|a| a.0); - - durations - .iter() - .map(|(date, duration)| { - ( - date.format(output).expect("failed to format sqlite date"), - *duration, - ) - }) - .collect() -} - -fn draw_stats_charts(f: &mut Frame<'_>, parent: Rect, stats: &HistoryStats) { - let exits: Vec<Bar<'_>> = stats - .exits - .iter() - .map(|(exit, count)| { - Bar::default() - .label(exit.to_string()) - .value(u64_or_zero(*count)) - }) - .collect(); - - let exits = BarChart::default() - .block( - Block::default() - .title("Exit code distribution") - .style(Style::from_crossterm(style_base())) - .borders(Borders::ALL), - ) - .bar_width(3) - .bar_gap(1) - .bar_style(Style::default()) - .value_style(Style::default()) - .label_style(Style::default()) - .data(BarGroup::default().bars(&exits)); - - let day_of_week: Vec<Bar<'_>> = stats - .day_of_week - .iter() - .map(|(day, count)| { - Bar::default() - .label(num_to_day(day.as_str())) - .value(u64_or_zero(*count)) - }) - .collect(); - - let day_of_week = BarChart::default() - .block( - Block::default() - .title("Runs per day") - .style(Style::from_crossterm(style_base())) - .borders(Borders::ALL), - ) - .bar_width(3) - .bar_gap(1) - .bar_style(Style::default()) - .value_style(Style::default()) - .label_style(Style::default()) - .data(BarGroup::default().bars(&day_of_week)); - - let duration_over_time = sort_duration_over_time(&stats.duration_over_time); - let duration_over_time: Vec<Bar<'_>> = duration_over_time - .iter() - .map(|(date, duration)| { - let d = Duration::from_nanos(u64_or_zero(*duration)); - Bar::default() - .label(date.clone()) - .value(u64_or_zero(*duration)) - .text_value(format_duration(d)) - }) - .collect(); - - let duration_over_time = BarChart::default() - .block( - Block::default() - .title("Duration over time") - .style(Style::from_crossterm(style_base())) - .borders(Borders::ALL), - ) - .bar_width(5) - .bar_gap(1) - .bar_style(Style::default()) - .value_style(Style::default()) - .label_style(Style::default()) - .data(BarGroup::default().bars(&duration_over_time)); - - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Ratio(1, 3), - Constraint::Ratio(1, 3), - Constraint::Ratio(1, 3), - ]) - .split(parent); - - f.render_widget(exits, layout[0]); - f.render_widget(day_of_week, layout[1]); - f.render_widget(duration_over_time, layout[2]); -} - -pub(crate) fn draw( - f: &mut Frame<'_>, - chunk: Rect, - history: &History, - stats: &HistoryStats, - settings: &Settings, - tz: Timezone, -) { - let compactness = to_compactness(f, settings); - - match compactness { - Compactness::Ultracompact => draw_ultracompact(f, chunk, history, stats), - _ => draw_full(f, chunk, history, stats, tz), - } -} - -pub(crate) fn draw_ultracompact( - f: &mut Frame<'_>, - chunk: Rect, - history: &History, - stats: &HistoryStats, -) { - draw_commands(f, chunk, history, stats, true); -} - -pub(crate) fn draw_full( - f: &mut Frame<'_>, - chunk: Rect, - history: &History, - stats: &HistoryStats, - tz: Timezone, -) { - let vert_layout = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Ratio(1, 5), Constraint::Ratio(4, 5)]) - .split(chunk); - - let stats_layout = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)]) - .split(vert_layout[1]); - - draw_commands(f, vert_layout[0], history, stats, false); - draw_stats_table(f, stats_layout[0], history, tz, stats); - draw_stats_charts(f, stats_layout[1], stats); -} - -#[cfg(test)] -mod tests { - use super::draw_ultracompact; - use crate::atuin_client::history::{History, HistoryId, HistoryStats}; - use ratatui::{ - backend::TestBackend, - prelude::{Line, Rect, Terminal}, - }; - use time::OffsetDateTime; - - fn mock_history_stats() -> (History, HistoryStats) { - let history = History { - id: HistoryId::from("test1".to_string()), - timestamp: OffsetDateTime::now_utc(), - duration: 3, - exit: 0, - command: "/bin/cmd".to_string(), - cwd: "/toot".to_string(), - session: "sesh1".to_string(), - hostname: "hostn".to_string(), - author: "hostn".to_string(), - intent: None, - deleted_at: None, - }; - let next = History { - id: HistoryId::from("test2".to_string()), - timestamp: OffsetDateTime::now_utc(), - duration: 2, - exit: 0, - command: "/bin/cmd -os".to_string(), - cwd: "/toot".to_string(), - session: "sesh1".to_string(), - hostname: "hostn".to_string(), - author: "hostn".to_string(), - intent: None, - deleted_at: None, - }; - let prev = History { - id: HistoryId::from("test3".to_string()), - timestamp: OffsetDateTime::now_utc(), - duration: 1, - exit: 0, - command: "/bin/cmd -a".to_string(), - cwd: "/toot".to_string(), - session: "sesh1".to_string(), - hostname: "hostn".to_string(), - author: "hostn".to_string(), - intent: None, - deleted_at: None, - }; - let stats = HistoryStats { - next: Some(next.clone()), - previous: Some(prev.clone()), - total: 2, - average_duration: 3, - exits: Vec::new(), - day_of_week: Vec::new(), - duration_over_time: Vec::new(), - }; - (history, stats) - } - - #[test] - fn test_output_looks_correct_for_ultracompact() { - let backend = TestBackend::new(22, 5); - let mut terminal = Terminal::new(backend).expect("Could not create terminal"); - let chunk = Rect::new(0, 0, 22, 5); - let (history, stats) = mock_history_stats(); - let prev = stats.previous.clone().unwrap(); - let next = stats.next.clone().unwrap(); - - drop(terminal.draw(|f| draw_ultracompact(f, chunk, &history, &stats))); - let mut lines = [" "; 5].map(|l| Line::from(l)); - for (n, entry) in [prev, history, next].iter().enumerate() { - let mut l = lines[n].to_string(); - l.replace_range(0..entry.command.len(), &entry.command); - lines[n] = Line::from(l); - } - - terminal.backend().assert_buffer_lines(lines); - } -} diff --git a/crates/client/src/command/client/search/interactive.rs b/crates/client/src/command/client/search/interactive.rs deleted file mode 100644 index b8cf53db..00000000 --- a/crates/client/src/command/client/search/interactive.rs +++ /dev/null @@ -1,3024 +0,0 @@ -use std::{ - io::{IsTerminal, Write, stdout}, - time::Duration, -}; - -#[cfg(unix)] -use std::io::Read as _; - -use crate::{ - atuin_client::{ - database::ClientSqlite, - theme::{style_annotation, style_base, style_important}, - }, - atuin_common::{shell::Shell, utils::Escapable as _}, -}; -use eyre::Result; -use time::OffsetDateTime; -use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; - -use super::{ - cursor::Cursor, - engines::{SearchEngine, SearchState}, - history_list::{HistoryList, ListState}, -}; -use crate::atuin_client::{ - database::{Context, current_context}, - history::{History, HistoryId, HistoryStats, store::HistoryStore}, - settings::{ - CursorStyle, ExitMode, FilterMode, KeymapMode, PreviewStrategy, SearchMode, Settings, - UiColumn, - }, -}; - -use crate::command::client::search::history_list::HistoryHighlighter; -use crate::command::client::search::keybindings::KeymapSet; -use crate::{VERSION, command::client::search::engines}; - -use ratatui::{ - Frame, Terminal, TerminalOptions, Viewport, - backend::{CrosstermBackend, FromCrossterm}, - crossterm::{ - cursor::SetCursorStyle, - event::{self, Event, KeyEvent, MouseEvent}, - execute, queue, terminal, - }, - layout::{Alignment, Constraint, Direction, Layout}, - prelude::Rect, - style::{Modifier, Style}, - text::{Line, Span, Text}, - widgets::{Block, BorderType, Borders, Clear, Padding, Paragraph, Tabs}, -}; - -#[cfg(not(target_os = "windows"))] -use ratatui::crossterm::event::{ - KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, -}; - -const TAB_TITLES: [&str; 2] = ["Search", "Inspect"]; - -pub(crate) enum InputAction { - Accept(usize), - AcceptInspecting, - Copy(usize), - Delete(usize), - DeleteAllMatching(usize), - ReturnOriginal, - ReturnQuery, - Continue, - Redraw, - SwitchContext(Option<usize>), -} - -#[derive(Clone)] -pub(crate) struct InspectingState { - current: Option<HistoryId>, - next: Option<HistoryId>, - previous: Option<HistoryId>, -} - -impl InspectingState { - pub(crate) fn move_to_previous(&mut self) { - let previous = self.previous.clone(); - self.reset(); - self.current = previous; - } - - pub(crate) fn move_to_next(&mut self) { - let next = self.next.clone(); - self.reset(); - self.current = next; - } - - pub(crate) fn reset(&mut self) { - self.current = None; - self.next = None; - self.previous = None; - } -} - -pub(crate) fn to_compactness(f: &Frame<'_>, settings: &Settings) -> Compactness { - if match settings.style { - crate::atuin_client::settings::Style::Auto => f.area().height < 14, - crate::atuin_client::settings::Style::Compact => true, - crate::atuin_client::settings::Style::Full => false, - } { - if settings.auto_hide_height != 0 && f.area().height <= settings.auto_hide_height { - Compactness::Ultracompact - } else { - Compactness::Compact - } - } else { - Compactness::Full - } -} - -#[expect(clippy::struct_field_names)] -#[expect(clippy::struct_excessive_bools)] -pub(crate) struct State { - history_count: i64, - results_state: ListState, - switched_search_mode: bool, - search_mode: SearchMode, - results_len: usize, - accept: bool, - keymap_mode: KeymapMode, - prefix: bool, - current_cursor: Option<CursorStyle>, - tab_index: usize, - pending_vim_key: Option<char>, - original_input_empty: bool, - - pub(crate) inspecting_state: InspectingState, - - keymaps: KeymapSet, - search: SearchState, - engine: Box<dyn SearchEngine>, - now: Box<dyn Fn() -> OffsetDateTime + Send>, -} - -#[derive(Clone, Copy)] -pub(crate) enum Compactness { - Ultracompact, - Compact, - Full, -} - -#[derive(Clone, Copy)] -struct StyleState { - compactness: Compactness, - invert: bool, - inner_width: usize, -} - -impl State { - async fn query_results( - &mut self, - db: &mut ClientSqlite, - smart_sort: bool, - ) -> Result<Vec<History>> { - let results = self.engine.query(&self.search, db).await?; - - self.inspecting_state = InspectingState { - current: None, - next: None, - previous: None, - }; - self.results_state.select(0); - self.results_len = results.len(); - - if smart_sort { - Ok(crate::atuin_history::sort::sort( - self.search.input.as_str(), - results, - )) - } else { - Ok(results) - } - } - - fn handle_input(&mut self, settings: &Settings, input: &Event) -> InputAction { - match input { - Event::Key(k) => self.handle_key_input(settings, k), - Event::Mouse(m) => self.handle_mouse_input(*m, settings.invert), - Event::Paste(d) => self.handle_paste_input(d), - _ => InputAction::Continue, - } - } - - fn handle_mouse_input(&mut self, input: MouseEvent, inverted: bool) -> InputAction { - match (input.kind, inverted) { - (event::MouseEventKind::ScrollDown, false) - | (event::MouseEventKind::ScrollUp, true) => { - self.scroll_down(1); - } - (event::MouseEventKind::ScrollDown, true) - | (event::MouseEventKind::ScrollUp, false) => { - self.scroll_up(1); - } - _ => {} - } - InputAction::Continue - } - - fn handle_paste_input(&mut self, input: &str) -> InputAction { - for i in input.chars() { - self.search.input.insert(i); - } - InputAction::Continue - } - - fn cast_cursor_style(style: CursorStyle) -> SetCursorStyle { - match style { - CursorStyle::DefaultUserShape => SetCursorStyle::DefaultUserShape, - CursorStyle::BlinkingBlock => SetCursorStyle::BlinkingBlock, - CursorStyle::SteadyBlock => SetCursorStyle::SteadyBlock, - CursorStyle::BlinkingUnderScore => SetCursorStyle::BlinkingUnderScore, - CursorStyle::SteadyUnderScore => SetCursorStyle::SteadyUnderScore, - CursorStyle::BlinkingBar => SetCursorStyle::BlinkingBar, - CursorStyle::SteadyBar => SetCursorStyle::SteadyBar, - } - } - - fn set_keymap_cursor(&mut self, settings: &Settings, keymap_name: &str) { - let cursor_style = if keymap_name == "__clear__" { - None - } else { - settings.keymap_cursor.get(keymap_name).copied() - } - .or_else(|| self.current_cursor.map(|_| CursorStyle::DefaultUserShape)); - - if cursor_style != self.current_cursor - && let Some(style) = cursor_style - { - self.current_cursor = cursor_style; - drop(execute!(stdout(), Self::cast_cursor_style(style))); - } - } - - pub(crate) fn initialize_keymap_cursor(&mut self, settings: &Settings) { - match self.keymap_mode { - KeymapMode::Emacs => self.set_keymap_cursor(settings, "emacs"), - KeymapMode::VimNormal => self.set_keymap_cursor(settings, "vim_normal"), - KeymapMode::VimInsert => self.set_keymap_cursor(settings, "vim_insert"), - KeymapMode::Auto => {} - } - } - - pub(crate) fn finalize_keymap_cursor(&mut self, settings: &Settings) { - match settings.keymap_mode_shell { - KeymapMode::Emacs => self.set_keymap_cursor(settings, "emacs"), - KeymapMode::VimNormal => self.set_keymap_cursor(settings, "vim_normal"), - KeymapMode::VimInsert => self.set_keymap_cursor(settings, "vim_insert"), - KeymapMode::Auto => self.set_keymap_cursor(settings, "__clear__"), - } - } - - fn handle_key_exit(settings: &Settings) -> InputAction { - match settings.exit_mode { - ExitMode::ReturnOriginal => InputAction::ReturnOriginal, - ExitMode::ReturnQuery => InputAction::ReturnQuery, - } - } - - /// Select the keymap for the current mode (ignoring prefix). - fn mode_keymap(&self) -> &super::keybindings::Keymap { - if self.tab_index == 1 { - &self.keymaps.inspector - } else { - match self.keymap_mode { - KeymapMode::Emacs | KeymapMode::Auto => &self.keymaps.emacs, - KeymapMode::VimNormal => &self.keymaps.vim_normal, - KeymapMode::VimInsert => &self.keymaps.vim_insert, - } - } - } - - /// Whether the current mode supports character insertion on unmatched keys. - fn is_insert_mode(&self) -> bool { - matches!( - self.keymap_mode, - KeymapMode::Emacs | KeymapMode::Auto | KeymapMode::VimInsert - ) - } - - fn handle_key_input(&mut self, settings: &Settings, input: &KeyEvent) -> InputAction { - use super::keybindings::Action; - use super::keybindings::EvalContext; - use super::keybindings::key::{KeyCodeValue, KeyInput, SingleKey}; - - // Skip release events - if input.kind == event::KeyEventKind::Release { - return InputAction::Continue; - } - - // Reset switched_search_mode at start of each key event - self.switched_search_mode = false; - - // Build evaluation context from current state - let ctx = EvalContext { - cursor_position: self.search.input.position(), - input_width: UnicodeWidthStr::width(self.search.input.as_str()), - input_byte_len: self.search.input.as_str().len(), - selected_index: self.results_state.selected(), - results_len: self.results_len, - original_input_empty: self.original_input_empty, - has_context: self.search.custom_context.is_some(), - }; - - // Convert KeyEvent to SingleKey - let Some(single) = SingleKey::from_event(input) else { - return InputAction::Continue; - }; - - // --- Phase 1: Resolve (take pending key first, then immutable borrows) --- - - // Take pending key before any immutable borrows of self - let pending = self.pending_vim_key.take(); - - // If in prefix mode, try prefix keymap first (single keys only) - let prefix_action = if self.prefix { - let ki = KeyInput::Single(single.clone()); - self.keymaps.prefix.resolve(&ki, &ctx) - } else { - None - }; - - // The if-let/else-if chain here is clearer than map_or_else with nested closures. - #[expect(clippy::option_if_let_else)] - let (action, new_pending) = if prefix_action.is_some() { - (prefix_action, None) - } else { - // Use mode keymap (handles both single and multi-key sequences) - let keymap = self.mode_keymap(); - - if let Some(pending_char) = pending { - // We have a pending key from a previous press (e.g., first 'g' of 'gg') - let pending_single = SingleKey { - code: KeyCodeValue::Char(pending_char), - ctrl: false, - alt: false, - shift: false, - super_key: false, - }; - let seq = KeyInput::Sequence(vec![pending_single, single.clone()]); - let action = keymap - .resolve(&seq, &ctx) - .or_else(|| keymap.resolve(&KeyInput::Single(single.clone()), &ctx)); - (action, None) - } else if keymap.has_sequence_starting_with(&single) - && matches!(single.code, KeyCodeValue::Char(_)) - && !single.ctrl - && !single.alt - { - // This key starts a multi-key sequence; wait for next key - let KeyCodeValue::Char(c) = single.code else { - unreachable!() - }; - (Some(Action::Noop), Some(c)) - } else { - ( - keymap.resolve(&KeyInput::Single(single.clone()), &ctx), - None, - ) - } - }; - - // --- Phase 2: Apply mutations --- - self.pending_vim_key = new_pending; - - // Reset prefix (before execute, so EnterPrefixMode can re-set it) - self.prefix = false; - - if let Some(action) = action { - self.execute_action(&action, settings) - } else { - // No action matched. In insert-capable modes, insert the character. - if self.is_insert_mode() && !single.ctrl && !single.alt { - match single.code { - KeyCodeValue::Char(c) => { - self.search.input.insert(c); - } - KeyCodeValue::Space => { - self.search.input.insert(' '); - } - _ => {} - } - } - InputAction::Continue - } - } - - fn scroll_down(&mut self, scroll_len: usize) { - let i = self.results_state.selected().saturating_sub(scroll_len); - self.inspecting_state.reset(); - self.results_state.select(i); - } - - fn scroll_up(&mut self, scroll_len: usize) { - let i = self.results_state.selected() + scroll_len; - self.results_state - .select(i.min(self.results_len.saturating_sub(1))); - self.inspecting_state.reset(); - } - - /// Execute a resolved action, performing all side effects and returning the - /// appropriate `InputAction` for the event loop. - /// - /// This is the "do it" half of the resolve+execute pipeline. The resolver - /// decides *what* to do (which `Action`), and this function carries it out. - /// - /// Invert handling: scroll actions (`SelectNext`, `ScrollPageDown`, etc.) account - /// for `settings.invert` so that keybindings are always in "visual" terms — - /// users never need to think about invert in their keybinding config. - #[expect(clippy::too_many_lines)] - pub(crate) fn execute_action( - &mut self, - action: &super::keybindings::Action, - settings: &Settings, - ) -> InputAction { - use crate::command::client::search::keybindings::Action; - - match action { - // -- Cursor movement -- - Action::CursorLeft => { - self.search.input.left(); - InputAction::Continue - } - Action::CursorRight => { - self.search.input.right(); - InputAction::Continue - } - Action::CursorWordLeft => { - self.search - .input - .prev_word(&settings.word_chars, settings.word_jump_mode); - InputAction::Continue - } - Action::CursorWordRight => { - self.search - .input - .next_word(&settings.word_chars, settings.word_jump_mode); - InputAction::Continue - } - Action::CursorWordEnd => { - self.search.input.word_end(&settings.word_chars); - InputAction::Continue - } - Action::CursorStart => { - self.search.input.start(); - InputAction::Continue - } - Action::CursorEnd => { - self.search.input.end(); - InputAction::Continue - } - - // -- Editing -- - Action::DeleteCharBefore => { - self.search.input.back(); - InputAction::Continue - } - Action::DeleteCharAfter => { - self.search.input.remove(); - InputAction::Continue - } - Action::DeleteWordBefore => { - self.search - .input - .remove_prev_word(&settings.word_chars, settings.word_jump_mode); - InputAction::Continue - } - Action::DeleteWordAfter => { - self.search - .input - .remove_next_word(&settings.word_chars, settings.word_jump_mode); - InputAction::Continue - } - Action::DeleteToWordBoundary => { - // ctrl-w: remove trailing whitespace, then delete to word boundary - while matches!(self.search.input.back(), Some(c) if c.is_whitespace()) {} - while self.search.input.left() { - if self.search.input.char().unwrap().is_whitespace() { - self.search.input.right(); - break; - } - self.search.input.remove(); - } - InputAction::Continue - } - Action::ClearLine => { - self.search.input.clear(); - InputAction::Continue - } - Action::ClearToStart => { - self.search.input.clear_to_start(); - InputAction::Continue - } - Action::ClearToEnd => { - self.search.input.clear_to_end(); - InputAction::Continue - } - - // -- List navigation (invert-aware) -- - Action::SelectNext => { - if settings.invert { - self.scroll_up(1); - } else { - self.scroll_down(1); - } - InputAction::Continue - } - Action::SelectPrevious => { - if settings.invert { - self.scroll_down(1); - } else { - self.scroll_up(1); - } - InputAction::Continue - } - // -- Page/half-page scroll (invert-aware) -- - Action::ScrollHalfPageUp => { - let scroll_len = self - .results_state - .max_entries() - .saturating_sub(settings.scroll_context_lines) - / 2; - if settings.invert { - self.scroll_down(scroll_len); - } else { - self.scroll_up(scroll_len); - } - InputAction::Continue - } - Action::ScrollHalfPageDown => { - let scroll_len = self - .results_state - .max_entries() - .saturating_sub(settings.scroll_context_lines) - / 2; - if settings.invert { - self.scroll_up(scroll_len); - } else { - self.scroll_down(scroll_len); - } - InputAction::Continue - } - Action::ScrollPageUp => { - let scroll_len = self - .results_state - .max_entries() - .saturating_sub(settings.scroll_context_lines); - if settings.invert { - self.scroll_down(scroll_len); - } else { - self.scroll_up(scroll_len); - } - InputAction::Continue - } - Action::ScrollPageDown => { - let scroll_len = self - .results_state - .max_entries() - .saturating_sub(settings.scroll_context_lines); - if settings.invert { - self.scroll_up(scroll_len); - } else { - self.scroll_down(scroll_len); - } - InputAction::Continue - } - - // -- Absolute jumps (invert-aware) -- - Action::ScrollToTop => { - // Visual top of history - if settings.invert { - self.results_state.select(0); - } else { - let last_idx = self.results_len.saturating_sub(1); - self.results_state.select(last_idx); - } - self.inspecting_state.reset(); - InputAction::Continue - } - Action::ScrollToBottom => { - // Visual bottom of history - if settings.invert { - let last_idx = self.results_len.saturating_sub(1); - self.results_state.select(last_idx); - } else { - self.results_state.select(0); - } - self.inspecting_state.reset(); - InputAction::Continue - } - Action::ScrollToScreenTop => { - // H — jump to top of visible screen - let top = self.results_state.offset(); - let visible = self.results_state.max_entries().min(self.results_len); - let bottom = top + visible.saturating_sub(1); - self.results_state - .select(bottom.min(self.results_len.saturating_sub(1))); - self.inspecting_state.reset(); - InputAction::Continue - } - Action::ScrollToScreenMiddle => { - // M — jump to middle of visible screen - let top = self.results_state.offset(); - let visible = self.results_state.max_entries().min(self.results_len); - let middle = top + visible / 2; - self.results_state - .select(middle.min(self.results_len.saturating_sub(1))); - self.inspecting_state.reset(); - InputAction::Continue - } - Action::ScrollToScreenBottom => { - // L — jump to bottom of visible screen - let top_visible = self.results_state.offset(); - self.results_state.select(top_visible); - self.inspecting_state.reset(); - InputAction::Continue - } - - // -- Commands -- - Action::Accept => { - if self.tab_index == 1 { - return InputAction::AcceptInspecting; - } - self.accept = true; - InputAction::Accept(self.results_state.selected()) - } - Action::AcceptNth(n) => { - self.accept = true; - InputAction::Accept(self.results_state.selected() + *n as usize) - } - Action::ReturnSelection => { - if self.tab_index == 1 { - return InputAction::AcceptInspecting; - } - InputAction::Accept(self.results_state.selected()) - } - Action::ReturnSelectionNth(n) => { - InputAction::Accept(self.results_state.selected() + *n as usize) - } - Action::Copy => InputAction::Copy(self.results_state.selected()), - Action::Delete => InputAction::Delete(self.results_state.selected()), - Action::DeleteAll => InputAction::DeleteAllMatching(self.results_state.selected()), - Action::ReturnOriginal => InputAction::ReturnOriginal, - Action::ReturnQuery => InputAction::ReturnQuery, - Action::Exit => Self::handle_key_exit(settings), - Action::Redraw => InputAction::Redraw, - Action::CycleFilterMode => { - self.search.rotate_filter_mode(settings, 1); - InputAction::Continue - } - Action::CycleSearchMode => { - self.switched_search_mode = true; - self.search_mode = self.search_mode.next(settings); - self.engine = engines::engine(self.search_mode, settings); - InputAction::Continue - } - Action::SwitchContext => { - InputAction::SwitchContext(Some(self.results_state.selected())) - } - Action::ClearContext => InputAction::SwitchContext(None), - Action::ToggleTab => { - self.tab_index = (self.tab_index + 1) % TAB_TITLES.len(); - InputAction::Continue - } - - // -- Mode changes -- - Action::VimEnterNormal => { - self.set_keymap_cursor(settings, "vim_normal"); - self.keymap_mode = KeymapMode::VimNormal; - InputAction::Continue - } - Action::VimEnterInsert => { - self.set_keymap_cursor(settings, "vim_insert"); - self.keymap_mode = KeymapMode::VimInsert; - InputAction::Continue - } - Action::VimEnterInsertAfter => { - self.search.input.right(); - self.set_keymap_cursor(settings, "vim_insert"); - self.keymap_mode = KeymapMode::VimInsert; - InputAction::Continue - } - Action::VimEnterInsertAtStart => { - self.search.input.start(); - self.set_keymap_cursor(settings, "vim_insert"); - self.keymap_mode = KeymapMode::VimInsert; - InputAction::Continue - } - Action::VimEnterInsertAtEnd => { - self.search.input.end(); - self.set_keymap_cursor(settings, "vim_insert"); - self.keymap_mode = KeymapMode::VimInsert; - InputAction::Continue - } - Action::VimSearchInsert => { - self.search.input.clear(); - self.set_keymap_cursor(settings, "vim_insert"); - self.keymap_mode = KeymapMode::VimInsert; - InputAction::Continue - } - Action::VimChangeToEnd => { - self.search.input.clear_to_end(); - self.set_keymap_cursor(settings, "vim_insert"); - self.keymap_mode = KeymapMode::VimInsert; - InputAction::Continue - } - Action::EnterPrefixMode => { - self.prefix = true; - InputAction::Continue - } - - // -- Inspector -- - Action::InspectPrevious => { - self.inspecting_state.move_to_previous(); - InputAction::Redraw - } - Action::InspectNext => { - self.inspecting_state.move_to_next(); - InputAction::Redraw - } - - // -- Special -- - Action::Noop => InputAction::Continue, - } - } - - #[expect(clippy::cast_possible_truncation)] - #[expect(clippy::bool_to_int_with_if)] - fn calc_preview_height( - settings: &Settings, - results: &[History], - selected: usize, - tab_index: usize, - compactness: Compactness, - border_size: u16, - preview_width: u16, - ) -> u16 { - if settings.show_preview - && settings.preview.strategy == PreviewStrategy::Auto - && tab_index == 0 - && !results.is_empty() - { - let length_current_cmd = results[selected].command.len() as u16; - // calculate the number of newlines in the command - let num_newlines = results[selected] - .command - .chars() - .filter(|&c| c == '\n') - .count() as u16; - if num_newlines > 0 { - std::cmp::min( - settings.max_preview_height, - results[selected] - .command - .split('\n') - .map(|line| { - (line.len() as u16 + preview_width - 1 - border_size) - / (preview_width - border_size) - }) - .sum(), - ) + border_size * 2 - } - // The '- 19' takes the characters before the command (duration and time) into account - else if length_current_cmd > preview_width - 19 { - std::cmp::min( - settings.max_preview_height, - (length_current_cmd + preview_width - 1 - border_size) - / (preview_width - border_size), - ) + border_size * 2 - } else { - 1 - } - } else if settings.show_preview - && settings.preview.strategy == PreviewStrategy::Static - && tab_index == 0 - { - let longest_command = results - .iter() - .max_by(|h1, h2| h1.command.len().cmp(&h2.command.len())); - longest_command.map_or(0, |v| { - std::cmp::min( - settings.max_preview_height, - v.command - .split('\n') - .map(|line| { - (line.len() as u16 + preview_width - 1 - border_size) - / (preview_width - border_size) - }) - .sum(), - ) - }) + border_size * 2 - } else if settings.show_preview && settings.preview.strategy == PreviewStrategy::Fixed { - settings.max_preview_height + border_size * 2 - } else if !matches!(compactness, Compactness::Full) || tab_index == 1 { - 0 - } else { - 1 - } - } - - fn draw( - &mut self, - f: &mut Frame<'_>, - results: &[History], - stats: Option<HistoryStats>, - inspecting: Option<&History>, - settings: &Settings, - - popup_mode: bool, - ) { - let area = f.area(); - if popup_mode { - f.render_widget(Clear, area); - } - self.draw_inner(f, area, results, stats, inspecting, settings); - } - - #[expect(clippy::too_many_lines)] - #[expect(clippy::bool_to_int_with_if)] - fn draw_inner( - &mut self, - f: &mut Frame<'_>, - area: Rect, - results: &[History], - stats: Option<HistoryStats>, - inspecting: Option<&History>, - settings: &Settings, - ) { - let compactness = to_compactness(f, settings); - let invert = settings.invert; - let border_size = match compactness { - Compactness::Full => 1, - _ => 0, - }; - let preview_width = area.width.saturating_sub(2); - let preview_height = Self::calc_preview_height( - settings, - results, - self.results_state.selected(), - self.tab_index, - compactness, - border_size, - preview_width, - ); - let show_help = - settings.show_help && (matches!(compactness, Compactness::Full) || area.height > 1); - // This is an OR, as it seems more likely for someone to wish to override - // tabs unexpectedly being missed, than unexpectedly present. - let show_tabs = settings.show_tabs && !matches!(compactness, Compactness::Ultracompact); - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(0) - .horizontal_margin(1) - .constraints::<&[Constraint]>( - if invert { - [ - Constraint::Length(1 + border_size), // input - Constraint::Min(1), // results list - Constraint::Length(preview_height), // preview - Constraint::Length(if show_tabs { 1 } else { 0 }), // tabs - Constraint::Length(if show_help { 1 } else { 0 }), // header (sic) - ] - } else { - match compactness { - Compactness::Ultracompact => [ - Constraint::Length(if show_help { 1 } else { 0 }), // header - Constraint::Length(0), // tabs - Constraint::Min(1), // results list - Constraint::Length(0), - Constraint::Length(0), - ], - _ => [ - Constraint::Length(if show_help { 1 } else { 0 }), // header - Constraint::Length(if show_tabs { 1 } else { 0 }), // tabs - Constraint::Min(1), // results list - Constraint::Length(1 + border_size), // input - Constraint::Length(preview_height), // preview - ], - } - } - .as_ref(), - ) - .split(area); - - let input_chunk = if invert { chunks[0] } else { chunks[3] }; - let results_list_chunk = if invert { chunks[1] } else { chunks[2] }; - let preview_chunk = if invert { chunks[2] } else { chunks[4] }; - let tabs_chunk = if invert { chunks[3] } else { chunks[1] }; - let header_chunk = if invert { chunks[4] } else { chunks[0] }; - - // TODO: this should be split so that we have one interactive search container that is - // EITHER a search box or an inspector. But I'm not doing that now, way too much atm. - // also allocate less 🙈 - let titles: Vec<_> = TAB_TITLES.iter().copied().map(Line::from).collect(); - - if show_tabs { - let tabs = Tabs::new(titles) - .block(Block::default().borders(Borders::NONE)) - .select(self.tab_index) - .style(Style::default()) - .highlight_style(Style::from_crossterm(style_important())); - - f.render_widget(tabs, tabs_chunk); - } - - let style = StyleState { - compactness, - invert, - inner_width: input_chunk.width.into(), - }; - - let header_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints::<&[Constraint]>( - [ - Constraint::Ratio(1, 5), - Constraint::Ratio(3, 5), - Constraint::Ratio(1, 5), - ] - .as_ref(), - ) - .split(header_chunk); - - let title = Self::build_title(); - f.render_widget(title, header_chunks[0]); - - let help = self.build_help(settings); - f.render_widget(help, header_chunks[1]); - - let stats_tab = self.build_stats(); - f.render_widget(stats_tab, header_chunks[2]); - - let indicator: String = match compactness { - Compactness::Ultracompact => { - if self.switched_search_mode { - format!("S{}>", self.search_mode.as_str().chars().next().unwrap()) - } else if self.search.custom_context.is_some() { - format!( - "C{}>", - self.search.filter_mode.as_str().chars().next().unwrap() - ) - } else { - format!( - "{}> ", - self.search.filter_mode.as_str().chars().next().unwrap() - ) - } - } - _ => " > ".to_string(), - }; - - match self.tab_index { - 0 => { - let history_highlighter = HistoryHighlighter { - engine: self.engine.as_ref(), - search_input: self.search.input.as_str(), - }; - let results_list = Self::build_results_list( - style, - results, - self.keymap_mode, - &self.now, - indicator.as_str(), - history_highlighter, - settings.show_numeric_shortcuts, - &settings.ui.columns, - ); - f.render_stateful_widget(results_list, results_list_chunk, &mut self.results_state); - } - - 1 => { - if results.is_empty() { - let message = Paragraph::new("Nothing to inspect") - .block( - Block::new() - .title(Line::from(" Info ".to_string())) - .title_alignment(Alignment::Center) - .borders(Borders::ALL) - .padding(Padding::vertical(2)), - ) - .alignment(Alignment::Center); - f.render_widget(message, results_list_chunk); - } else { - let inspecting = match inspecting { - Some(inspecting) => inspecting, - None => &results[self.results_state.selected()], - }; - super::inspector::draw( - f, - results_list_chunk, - inspecting, - &stats.expect("Drawing inspector, but no stats"), - settings, - settings.timezone, - ); - } - - // HACK: I'm following up with abstracting this into the UI container, with a - // sub-widget for search + for inspector - let feedback = Paragraph::new( - "The inspector is new - please give feedback (good, or bad) at https://forum.atuin.sh", - ); - f.render_widget(feedback, input_chunk); - - return; - } - - _ => { - panic!("invalid tab index"); - } - } - - if !matches!(compactness, Compactness::Ultracompact) { - let preview_width = match compactness { - Compactness::Full => preview_width - 2, - _ => preview_width, - }; - let preview = self.build_preview( - results, - compactness, - preview_width, - preview_chunk.width.into(), - ); - #[expect(clippy::cast_possible_truncation)] - let prefix_width = settings - .ui - .columns - .iter() - .take_while(|col| !col.expand) - .map(|col| col.width + 1) - .sum::<u16>() - + " > ".len() as u16; - #[expect(clippy::cast_possible_truncation)] - let min_prefix_width = "[ SRCH: FULLTXT ] ".len() as u16; - self.draw_preview( - f, - style, - input_chunk, - compactness, - preview_chunk, - preview, - std::cmp::max(prefix_width, min_prefix_width), - ); - } - } - - #[expect(clippy::cast_possible_truncation, clippy::too_many_arguments)] - fn draw_preview( - &self, - f: &mut Frame<'_>, - style: StyleState, - input_chunk: Rect, - compactness: Compactness, - preview_chunk: Rect, - preview: Paragraph<'_>, - prefix_width: u16, - ) { - let input = self.build_input(style, prefix_width); - f.render_widget(input, input_chunk); - - f.render_widget(preview, preview_chunk); - - let extra_width = UnicodeWidthStr::width(self.search.input.substring()); - - let cursor_offset = match compactness { - Compactness::Full => 1, - _ => 0, - }; - f.set_cursor_position(( - // Put cursor past the end of the input text - input_chunk.x + extra_width as u16 + prefix_width + cursor_offset, - input_chunk.y + cursor_offset, - )); - } - - fn build_title<'a>() -> Paragraph<'a> { - let title = { - let style: Style = Style::from_crossterm(style_base()); - Paragraph::new(Text::from(Span::styled( - format!("Atuin v{VERSION}"), - style.add_modifier(Modifier::BOLD), - ))) - }; - title.alignment(Alignment::Left) - } - - fn build_help(&self, settings: &Settings) -> Paragraph<'_> { - match self.tab_index { - // search - 0 => Paragraph::new(Text::from(Line::from(vec![ - Span::styled("<esc>", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(": exit"), - Span::raw(", "), - Span::styled("<tab>", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(": edit"), - Span::raw(", "), - Span::styled("<enter>", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(if settings.enter_accept { - ": run" - } else { - ": edit" - }), - Span::raw(", "), - Span::styled("<ctrl-o>", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(": inspect"), - ]))), - - 1 => Paragraph::new(Text::from(Line::from(vec![ - Span::styled("<esc>", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(": exit"), - Span::raw(", "), - Span::styled("<ctrl-o>", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(": search"), - Span::raw(", "), - Span::styled("<ctrl-d>", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(": delete"), - ]))), - - _ => unreachable!("invalid tab index"), - } - .style(Style::from_crossterm(style_annotation())) - .alignment(Alignment::Center) - } - - fn build_stats(&self) -> Paragraph<'_> { - Paragraph::new(Text::from(Span::raw(format!( - "history count: {}", - self.history_count, - )))) - .style(Style::from_crossterm(style_annotation())) - .alignment(Alignment::Right) - } - - #[expect(clippy::too_many_arguments)] - fn build_results_list<'a>( - style: StyleState, - results: &'a [History], - keymap_mode: KeymapMode, - now: &'a dyn Fn() -> OffsetDateTime, - indicator: &'a str, - - history_highlighter: HistoryHighlighter<'a>, - show_numeric_shortcuts: bool, - columns: &'a [UiColumn], - ) -> HistoryList<'a> { - let results_list = HistoryList::new( - results, - style.invert, - keymap_mode == KeymapMode::VimNormal, - now, - indicator, - history_highlighter, - show_numeric_shortcuts, - columns, - ); - - match style.compactness { - Compactness::Full => { - if style.invert { - results_list.block( - Block::default() - .borders(Borders::LEFT | Borders::RIGHT) - .border_type(BorderType::Rounded) - .title(format!("{:─>width$}", "", width = style.inner_width - 2)), - ) - } else { - results_list.block( - Block::default() - .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT) - .border_type(BorderType::Rounded), - ) - } - } - _ => results_list, - } - } - - fn build_input(&self, style: StyleState, prefix_width: u16) -> Paragraph<'_> { - let (pref, mode) = if self.switched_search_mode { - (" SRCH:", self.search_mode.as_str()) - } else if self.search.custom_context.is_some() { - (" CTX:", self.search.filter_mode.as_str()) - } else { - ("", self.search.filter_mode.as_str()) - }; - // 3: surrounding "[" "] " - let mode_width = usize::from(prefix_width) - pref.len() - 3; - // sanity check to ensure we don't exceed the layout limits - debug_assert!(mode_width >= mode.len(), "mode name '{mode}' is too long!"); - let input = format!("[{pref}{mode:^mode_width$}] {}", self.search.input.as_str()); - let input = Paragraph::new(input); - match style.compactness { - Compactness::Full => { - if style.invert { - input.block( - Block::default() - .borders(Borders::LEFT | Borders::RIGHT | Borders::TOP) - .border_type(BorderType::Rounded), - ) - } else { - input.block( - Block::default() - .borders(Borders::LEFT | Borders::RIGHT) - .border_type(BorderType::Rounded) - .title(format!("{:─>width$}", "", width = style.inner_width - 2)), - ) - } - } - _ => input, - } - } - - fn build_preview( - &self, - results: &[History], - compactness: Compactness, - preview_width: u16, - chunk_width: usize, - ) -> Paragraph<'_> { - let selected = self.results_state.selected(); - let command = if results.is_empty() { - String::new() - } else { - let s = &results[selected].command; - let mut lines = Vec::new(); - for line in s.split('\n') { - let line = line.escape_control(); - let mut width = 0; - let mut start = 0; - for (idx, ch) in line.char_indices() { - let w = ch.width().unwrap_or(0); // None for control chars which should not happen - if width + w > preview_width.into() { - lines.push(line[start..idx].to_owned()); - start = idx; - width = w; - } else { - width += w; - } - } - if width != 0 { - lines.push(line[start..].to_owned()); - } - } - lines.join("\n") - }; - - match compactness { - Compactness::Full => Paragraph::new(command).block( - Block::default() - .borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT) - .border_type(BorderType::Rounded) - .title(format!("{:─>width$}", "", width = chunk_width - 2)), - ), - _ => Paragraph::new(command).style(Style::from_crossterm(style_annotation())), - } - } -} - -/// The writer used for terminal output - either stdout or /dev/tty -enum TerminalWriter { - Stdout(std::io::Stdout), - #[cfg(unix)] - Tty(std::fs::File), -} - -impl TerminalWriter { - fn new() -> std::io::Result<Self> { - let stdout = stdout(); - if stdout.is_terminal() { - return Ok(Self::Stdout(stdout)); - } - - // If stdout is not a terminal (e.g., captured by command substitution), - // fall back to /dev/tty so the TUI can still render. - // This allows usage like: VAR=$(atuin search -i) - #[cfg(unix)] - { - Ok(Self::Tty( - std::fs::File::options() - .read(true) - .write(true) - .open("/dev/tty")?, - )) - } - } -} - -impl Write for TerminalWriter { - fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> { - match self { - Self::Stdout(stdout) => stdout.write(buf), - #[cfg(unix)] - Self::Tty(file) => file.write(buf), - } - } - - fn flush(&mut self) -> std::io::Result<()> { - match self { - Self::Stdout(stdout) => stdout.flush(), - #[cfg(unix)] - Self::Tty(file) => file.flush(), - } - } -} - -/// Screen state captured from atuin pty-proxy's screen server. -#[cfg(unix)] -struct SavedScreen { - rows: u16, - cols: u16, - cursor_row: u16, - cursor_col: u16, - /// Pre-formatted ANSI bytes for each screen row, ready to write to stdout. - rows_data: Vec<Vec<u8>>, -} - -/// Connect to atuin pty-proxy's Unix socket and fetch the current screen state. -/// -/// The wire format is: -/// ```text -/// [rows: u16 BE][cols: u16 BE][cursor_row: u16 BE][cursor_col: u16 BE] -/// [row_0_len: u32 BE][row_0_bytes...] -/// [row_1_len: u32 BE][row_1_bytes...] -/// ... -/// ``` -#[cfg(unix)] -fn fetch_screen_state(socket_path: &str) -> Option<SavedScreen> { - use std::os::unix::net::UnixStream; - - let mut stream = UnixStream::connect(socket_path).ok()?; - stream.set_read_timeout(Some(Duration::from_secs(2))).ok()?; - - let mut data = Vec::new(); - stream.read_to_end(&mut data).ok()?; - - if data.len() < 8 { - return None; - } - - let rows = u16::from_be_bytes([data[0], data[1]]); - let cols = u16::from_be_bytes([data[2], data[3]]); - let cursor_row = u16::from_be_bytes([data[4], data[5]]); - let cursor_col = u16::from_be_bytes([data[6], data[7]]); - - // Parse length-prefixed rows - let mut rows_data = Vec::with_capacity(rows as usize); - let mut offset = 8; - while offset + 4 <= data.len() { - let row_len = u32::from_be_bytes([ - data[offset], - data[offset + 1], - data[offset + 2], - data[offset + 3], - ]) as usize; - offset += 4; - if offset + row_len > data.len() { - break; - } - rows_data.push(data[offset..offset + row_len].to_vec()); - offset += row_len; - } - - Some(SavedScreen { - rows, - cols, - cursor_row, - cursor_col, - rows_data, - }) -} - -/// Restore the screen area that was covered by the popup. -/// -/// Writes the pre-formatted per-row ANSI bytes received from atuin pty-proxy -/// directly to stdout, which correctly handles wide characters, colors, and -/// all text attributes without needing a client-side vt100 parser. -#[cfg(unix)] -fn restore_popup_area(saved: &SavedScreen, popup_rect: Rect, scroll_offset: u16) { - use ratatui::crossterm::cursor::MoveTo; - - let mut stdout = stdout(); - - for dy in 0..popup_rect.height { - let target_row = popup_rect.y + dy; - let source_row = (target_row + scroll_offset) as usize; - - // Clear only the popup region. The server-side rows_formatted() skips - // default cells (spaces with default attributes) using cursor jumps, so - // any popup content at those positions would remain if not cleared - // beforehand. We write `popup_rect.width` spaces instead of - // ClearType::CurrentLine so that only the popup area is cleared, not - // the entire terminal line. - drop(execute!( - stdout, - MoveTo(popup_rect.x, target_row), - crossterm::style::SetAttribute(crossterm::style::Attribute::Reset), - )); - drop(write!( - stdout, - "{:width$}", - "", - width = popup_rect.width as usize - )); - drop(execute!(stdout, MoveTo(popup_rect.x, target_row))); - - if let Some(row_bytes) = saved.rows_data.get(source_row) { - drop(stdout.write_all(row_bytes)); - } - } - - drop(execute!( - stdout, - MoveTo( - saved.cursor_col, - saved.cursor_row.saturating_sub(scroll_offset) - ) - )); - drop(stdout.flush()); -} - -struct Stdout { - writer: TerminalWriter, - inline_mode: bool, - no_mouse: bool, -} - -impl Stdout { - pub(crate) fn new(inline_mode: bool, no_mouse: bool) -> std::io::Result<Self> { - terminal::enable_raw_mode()?; - - let mut writer = TerminalWriter::new()?; - - if !inline_mode { - execute!(writer, terminal::EnterAlternateScreen)?; - } - - if !no_mouse { - execute!(writer, event::EnableMouseCapture)?; - } - - execute!(writer, event::EnableBracketedPaste)?; - - #[cfg(not(target_os = "windows"))] - execute!( - writer, - PushKeyboardEnhancementFlags( - KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES - | KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES - | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS - ), - )?; - - Ok(Self { - writer, - inline_mode, - no_mouse, - }) - } -} - -impl Drop for Stdout { - fn drop(&mut self) { - #[cfg(not(target_os = "windows"))] - if let Err(e) = execute!(self.writer, PopKeyboardEnhancementFlags) { - tracing::error!(?e, "Failed to pop keyboard enhancement flags"); - } - - if !self.inline_mode - && let Err(e) = execute!(self.writer, terminal::LeaveAlternateScreen) - { - tracing::error!(?e, "Failed to leave alt screen mode"); - } - - if !self.no_mouse - && let Err(e) = execute!(self.writer, event::DisableMouseCapture) - { - tracing::error!(?e, "Failed to disable mouse capture"); - } - - if let Err(e) = execute!(self.writer, event::DisableBracketedPaste) { - tracing::error!(?e, "Failed to disable bracketed paste"); - } - - if let Err(e) = terminal::disable_raw_mode() { - tracing::error!(?e, "Failed to disable raw mode"); - } - } -} - -impl Write for Stdout { - fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> { - self.writer.write(buf) - } - - fn flush(&mut self) -> std::io::Result<()> { - self.writer.flush() - } -} - -// this is a big blob of horrible! clean it up! -/// Compute the popup position and any scroll offset needed to make room. -/// -/// Given the cursor row, terminal dimensions, and desired popup height, -/// returns `(popup_rect, scroll_offset)` where `scroll_offset` is the number -/// of lines the caller should scroll the terminal up before rendering. -/// -/// This function performs no I/O — it is a pure computation. -#[cfg(unix)] -fn compute_popup_placement( - cursor_row: u16, - term_rows: u16, - term_cols: u16, - inline_height: u16, -) -> (Rect, u16) { - let popup_w = term_cols; - let popup_h = inline_height.min(term_rows); - let space_below = term_rows.saturating_sub(cursor_row); - - let (popup_y, scroll) = if popup_h <= space_below { - // Fits below cursor - (cursor_row, 0u16) - } else if cursor_row >= term_rows / 2 { - // Bottom half — render above cursor (overlay on existing text) - (cursor_row.saturating_sub(popup_h), 0u16) - } else { - // Top half, not enough space — scroll terminal to make room - let scroll = popup_h.saturating_sub(space_below); - let popup_y = cursor_row.saturating_sub(scroll); - (popup_y, scroll) - }; - - (Rect::new(0, popup_y, popup_w, popup_h), scroll) -} - -// for now, it works. But it'd be great if it were more easily readable, and -// modular. I'd like to add some more stats and stuff at some point -#[expect(clippy::too_many_lines, clippy::cognitive_complexity)] -pub(crate) async fn history( - query: &[String], - settings: &Settings, - mut db: ClientSqlite, - history_store: &HistoryStore, -) -> Result<String> { - let inline_height = if settings.shell_up_key_binding { - settings - .inline_height_shell_up_key_binding - .unwrap_or(settings.inline_height) - } else { - settings.inline_height - }; - - // Use fullscreen mode if the inline height doesn't fit in the terminal, - // this will preserve the scroll position upon exit. - // Also force fullscreen when stdout isn't a terminal (e.g., command substitution - // like VAR=$(atuin search -i)). In that case, we need to use /dev/tty for the TUI and force - // fullscreen mode (inline mode won't work as it requires cursor position queries - // that don't work when stdout is captured). - let inline_height = if !stdout().is_terminal() { - 0 - } else if let Ok(size) = terminal::size() - && inline_height >= size.1 - { - 0 - } else { - inline_height - }; - - // Popup mode: if running under atuin pty-proxy and inline mode is requested, - // fetch the screen state and render as a centered overlay. - #[cfg(unix)] - let (saved_screen, popup_rect, popup_scroll_offset) = { - let socket_path = std::env::var("ATUIN_PTY_PROXY_SOCKET") - .or_else(|_| std::env::var("ATUIN_HEX_SOCKET")) - .ok(); - if let Some(ref path) = socket_path - && inline_height > 0 - { - let saved = fetch_screen_state(path); - if let Some(ref s) = saved { - let (term_cols, term_rows) = terminal::size().unwrap_or((s.cols, s.rows)); - let (popup_rect, scroll) = - compute_popup_placement(s.cursor_row, term_rows, term_cols, inline_height); - - // Scroll terminal content up to make room if needed - if scroll > 0 { - use ratatui::crossterm::cursor::MoveTo; - let mut stdout = stdout(); - drop(execute!(stdout, MoveTo(0, term_rows - 1))); - for _ in 0..scroll { - drop(writeln!(stdout)); - } - drop(stdout.flush()); - } - - (saved, popup_rect, scroll) - } else { - (None, Rect::default(), 0u16) - } - } else { - (None, Rect::default(), 0u16) - } - }; - - let popup_mode = saved_screen.is_some(); - - let stdout = Stdout::new(inline_height > 0, settings.no_mouse)?; - - // In popup mode, clear the popup region on the physical terminal before - // ratatui takes over. Ratatui's diff-based rendering compares against an - // initially-empty buffer, so cells that remain "empty" (spaces with default - // style) won't be written — leaving underlying terminal text visible. - // By pre-clearing with spaces, those cells are already correct on screen. - if popup_mode { - use ratatui::crossterm::cursor::MoveTo; - let mut raw_stdout = std::io::stdout(); - // Queue all commands without flushing so the terminal receives them - // as a single write — no intermediate cursor positions are visible. - drop(queue!( - raw_stdout, - crossterm::style::SetAttribute(crossterm::style::Attribute::Reset) - )); - for row in popup_rect.y..popup_rect.y.saturating_add(popup_rect.height) { - drop(queue!(raw_stdout, MoveTo(popup_rect.x, row))); - drop(write!( - raw_stdout, - "{:width$}", - "", - width = popup_rect.width as usize - )); - } - drop(raw_stdout.flush()); - } - - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::with_options( - backend, - TerminalOptions { - viewport: if popup_mode { - Viewport::Fixed(popup_rect) - } else if inline_height > 0 { - Viewport::Inline(inline_height) - } else { - Viewport::Fullscreen - }, - }, - )?; - - let original_query = query.join(" "); - - // Check if this is a command chaining scenario - let is_command_chaining = if settings.command_chaining { - let trimmed = original_query.trim_end(); - trimmed.ends_with("&&") || trimmed.ends_with('|') - } else { - false - }; - - // For command chaining, start with empty input to allow searching for new commands - let search_input = if is_command_chaining { - String::new() - } else { - original_query.clone() - }; - - let mut input = Cursor::from(search_input); - // Put the cursor at the end of the query by default - input.end(); - - let initial_context = current_context().await?; - - let history_count = db.history_count(false).await?; - let search_mode = if settings.shell_up_key_binding { - settings - .search_mode_shell_up_key_binding - .unwrap_or(settings.search_mode) - } else { - settings.search_mode - }; - let default_filter_mode = settings - .filter_mode_shell_up_key_binding - .filter(|_| settings.shell_up_key_binding) - .unwrap_or_else(|| settings.default_filter_mode(initial_context.git_root.is_some())); - let mut app = State { - history_count, - results_state: ListState::default(), - switched_search_mode: false, - search_mode, - tab_index: 0, - inspecting_state: InspectingState { - current: None, - next: None, - previous: None, - }, - keymaps: KeymapSet::from_settings(settings), - search: SearchState { - input, - filter_mode: default_filter_mode, - context: initial_context.clone(), - custom_context: None, - }, - engine: engines::engine(search_mode, settings), - results_len: 0, - accept: false, - keymap_mode: match settings.keymap_mode { - KeymapMode::Auto => KeymapMode::Emacs, - value => value, - }, - current_cursor: None, - now: if settings.prefers_reduced_motion { - let now = OffsetDateTime::now_utc(); - Box::new(move || now) - } else { - Box::new(OffsetDateTime::now_utc) - }, - prefix: false, - pending_vim_key: None, - original_input_empty: original_query.is_empty(), - }; - - app.initialize_keymap_cursor(settings); - - let mut results = app.query_results(&mut db, settings.smart_sort).await?; - - if inline_height > 0 && !popup_mode { - terminal.clear()?; - } - - let mut stats: Option<HistoryStats> = None; - let mut inspecting: Option<History> = None; - let accept; - let result = 'render: loop { - terminal.draw(|f| { - app.draw( - f, - &results, - stats.clone(), - inspecting.as_ref(), - settings, - popup_mode, - ); - })?; - - let initial_input = app.search.input.as_str().to_owned(); - let initial_filter_mode = app.search.filter_mode; - let initial_search_mode = app.search_mode; - let initial_custom_context = app.search.custom_context.clone(); - - let event_ready = tokio::task::spawn_blocking(|| event::poll(Duration::from_millis(250))); - - tokio::select! { - event_ready = event_ready => { - if event_ready?? { - loop { - match app.handle_input(settings, &event::read()?) { - InputAction::Continue => {}, - InputAction::Delete(index) => { - if results.is_empty() { - break; - } - app.results_len -= 1; - let selected = app.results_state.selected(); - if selected == app.results_len { - app.inspecting_state.reset(); - app.results_state.select(selected - 1); - } - - let entry = results.remove(index); - - let ids = history_store.delete_entries([entry]).await?; - history_store.incremental_build(&db, &ids).await?; - - app.tab_index = 0; - }, - InputAction::DeleteAllMatching(index) => { - if results.is_empty() { - break; - } - - let command = results[index].command.clone(); - - // Remove matching entries from the visible results - results.retain(|e| e.command != command); - - // Query the DB for ALL entries with this command and delete them - let all_matching = db.query_history( - &format!( - "select * from history where command = '{}' and deleted_at is null", - command.replace('\'', "''") - ) - ).await?; - - let ids = history_store.delete_entries(all_matching).await?; - history_store.incremental_build(&db, &ids).await?; - - app.results_len = results.len(); - app.results_state = ListState::default(); - app.inspecting_state.reset(); - app.tab_index = 0; - }, - InputAction::SwitchContext(index) => { - if let Some(index) = index && let Some(entry) = results.get(index) { - app.search.custom_context = Some(entry.id.clone()); - app.search.context = Context::from_history(entry); - app.search.filter_mode = FilterMode::Session; - app.search.input = Cursor::from(String::new()); - app.results_state = ListState::default(); - } else { - app.search.custom_context = None; - app.search.context = initial_context.clone(); - app.search.filter_mode = default_filter_mode; - } - }, - InputAction::Redraw => { - if !popup_mode { - terminal.clear()?; - } - terminal.draw(|f| { - app.draw(f, &results, stats.clone(), inspecting.as_ref(), settings, popup_mode); - })?; - }, - r => { - accept = app.accept; - break 'render r; - }, - } - if !event::poll(Duration::ZERO)? { - break; - } - } - } - } - } - - if initial_input != app.search.input.as_str() - || initial_filter_mode != app.search.filter_mode - || initial_search_mode != app.search_mode - || initial_custom_context != app.search.custom_context - { - results = app.query_results(&mut db, settings.smart_sort).await?; - } - - // In custom context mode, when no filter is applied, highlight the entry which was used - // to enter the context when changing modes. This helps to find your way around. - if app.search.custom_context.is_some() - && app.search.input.as_str().is_empty() - && (initial_custom_context != app.search.custom_context - || initial_filter_mode != app.search.filter_mode) - && let Some(history_id) = app.search.custom_context.clone() - && let Some(pos) = results.iter().position(|entry| entry.id == history_id) - { - app.results_state.select(pos); - } - - let inspecting_id = app.inspecting_state.clone().current; - // If inspecting ID is not the current inspecting History, update it. - match inspecting_id { - Some(inspecting_id) => { - if inspecting.is_none() || inspecting_id != inspecting.clone().unwrap().id { - inspecting = db.load(inspecting_id.0.as_str()).await?; - } - } - _ => { - inspecting = None; - } - } - - stats = if app.tab_index == 0 { - None - } else if !results.is_empty() { - // If we have stats, then we can indicate next available IDs. This avoids passing - // around a database object, or a full stats object. - let selected = match inspecting.clone() { - Some(insp) => insp, - None => results[app.results_state.selected()].clone(), - }; - let stats = db.stats(&selected).await?; - app.inspecting_state.current = Some(selected.id); - app.inspecting_state.previous = match stats.previous.clone() { - Some(p) => Some(p.id), - _ => None, - }; - app.inspecting_state.next = match stats.next.clone() { - Some(p) => Some(p.id), - _ => None, - }; - Some(stats) - } else { - None - }; - }; - - app.finalize_keymap_cursor(settings); - - if popup_mode { - // In popup mode, restore the screen area that was covered by the popup. - // This must happen before Stdout is dropped (which disables raw mode). - #[cfg(unix)] - if let Some(ref saved) = saved_screen { - restore_popup_area(saved, popup_rect, popup_scroll_offset); - } - } else if inline_height > 0 { - terminal.clear()?; - } - - let accept = accept - && matches!( - Shell::from_env(), - Shell::Zsh | Shell::Fish | Shell::Bash | Shell::Xonsh | Shell::Nu | Shell::Powershell - ); - - let accept_prefix = "__atuin_accept__:"; - - match result { - InputAction::AcceptInspecting => { - match inspecting { - Some(result) => { - let mut command = result.command; - - if accept { - command = String::from(accept_prefix) + &command; - } - - // index is in bounds so we return that entry - Ok(command) - } - None => Ok(String::new()), - } - } - InputAction::Accept(index) if index < results.len() => { - let mut command = results.swap_remove(index).command; - - if is_command_chaining { - command = format!("{} {}", original_query.trim_end(), command); - } else if accept { - command = String::from(accept_prefix) + &command; - } - - // index is in bounds so we return that entry - Ok(command) - } - InputAction::ReturnOriginal => Ok(String::new()), - InputAction::Copy(index) => { - let cmd = results.swap_remove(index).command; - set_clipboard(cmd); - Ok(String::new()) - } - InputAction::ReturnQuery | InputAction::Accept(_) => { - // Either: - // * index == RETURN_QUERY, in which case we should return the input - // * out of bounds -> usually implies no selected entry so we return the input - Ok(app.search.input.into_inner()) - } - InputAction::Continue - | InputAction::Redraw - | InputAction::Delete(_) - | InputAction::DeleteAllMatching(_) - | InputAction::SwitchContext(_) => { - unreachable!("should have been handled!") - } - } -} - -// cli-clipboard only works on Windows, Mac, and Linux. - -#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] -fn set_clipboard(s: String) { - let mut ctx = arboard::Clipboard::new().unwrap(); - ctx.set_text(s).unwrap(); - // Use the clipboard context to make sure it is saved - ctx.get_text().unwrap(); -} - -#[cfg(test)] -mod tests { - use crate::atuin_client::database::Context; - use crate::atuin_client::history::History; - use crate::atuin_client::settings::{ - FilterMode, KeymapMode, Preview, PreviewStrategy, SearchMode, Settings, - }; - use time::OffsetDateTime; - - use crate::command::client::search::engines::{self, SearchState}; - use crate::command::client::search::history_list::ListState; - - use super::{Compactness, InspectingState, KeymapSet, State}; - - #[test] - #[expect(clippy::too_many_lines)] - fn calc_preview_height_test() { - let settings_preview_auto = Settings { - preview: Preview { - strategy: PreviewStrategy::Auto, - }, - show_preview: true, - ..Settings::new().unwrap() - }; - - let settings_preview_auto_h2 = Settings { - preview: Preview { - strategy: PreviewStrategy::Auto, - }, - show_preview: true, - max_preview_height: 2, - ..Settings::new().unwrap() - }; - - let settings_preview_h4 = Settings { - preview: Preview { - strategy: PreviewStrategy::Static, - }, - show_preview: true, - max_preview_height: 4, - ..Settings::new().unwrap() - }; - - let settings_preview_fixed = Settings { - preview: Preview { - strategy: PreviewStrategy::Fixed, - }, - show_preview: true, - max_preview_height: 15, - ..Settings::new().unwrap() - }; - - let cmd_60: History = History::capture() - .timestamp(OffsetDateTime::now_utc()) - .command("for i in $(seq -w 10); do echo \"item number $i - abcd\"; done") - .cwd("/") - .build() - .into(); - - let cmd_124: History = History::capture() - .timestamp(OffsetDateTime::now_utc()) - .command("echo 'Aurea prima sata est aetas, quae vindice nullo, sponte sua, sine lege fidem rectumque colebat. Poena metusque aberant'") - .cwd("/") - .build() - .into(); - - let cmd_200: History = History::capture() - .timestamp(OffsetDateTime::now_utc()) - .command("CREATE USER atuin WITH ENCRYPTED PASSWORD 'supersecretpassword'; CREATE DATABASE atuin WITH OWNER = atuin; \\c atuin; REVOKE ALL PRIVILEGES ON SCHEMA public FROM PUBLIC; echo 'All done. 200 characters'") - .cwd("/") - .build() - .into(); - - let results: Vec<History> = vec![cmd_60, cmd_124, cmd_200]; - - // the selected command does not require a preview - let no_preview = State::calc_preview_height( - &settings_preview_auto, - &results, - 0_usize, - 0_usize, - Compactness::Full, - 1, - 80, - ); - // the selected command requires 2 lines - let preview_h2 = State::calc_preview_height( - &settings_preview_auto, - &results, - 1_usize, - 0_usize, - Compactness::Full, - 1, - 80, - ); - // the selected command requires 3 lines - let preview_h3 = State::calc_preview_height( - &settings_preview_auto, - &results, - 2_usize, - 0_usize, - Compactness::Full, - 1, - 80, - ); - // the selected command requires a preview of 1 line (happens when the command is between preview_width-19 and preview_width) - let preview_one_line = State::calc_preview_height( - &settings_preview_auto, - &results, - 0_usize, - 0_usize, - Compactness::Full, - 1, - 66, - ); - // the selected command requires 3 lines, but we have a max preview height limit of 2 - let preview_limit_at_2 = State::calc_preview_height( - &settings_preview_auto_h2, - &results, - 2_usize, - 0_usize, - Compactness::Full, - 1, - 80, - ); - // the longest command requires 3 lines - let preview_static_h3 = State::calc_preview_height( - &settings_preview_h4, - &results, - 1_usize, - 0_usize, - Compactness::Full, - 1, - 80, - ); - // the longest command requires 10 lines, but we have a max preview height limit of 4 - let preview_static_limit_at_4 = State::calc_preview_height( - &settings_preview_h4, - &results, - 1_usize, - 0_usize, - Compactness::Full, - 1, - 20, - ); - // the longest command requires 10 lines, but we have a max preview height of 15 and a fixed preview strategy - let settings_preview_fixed = State::calc_preview_height( - &settings_preview_fixed, - &results, - 1_usize, - 0_usize, - Compactness::Full, - 1, - 20, - ); - - assert_eq!(no_preview, 1); - // 1 * 2 is the space for the border - let border_space = 2; - assert_eq!(preview_h2, 2 + border_space); - assert_eq!(preview_h3, 3 + border_space); - assert_eq!(preview_one_line, 1 + border_space); - assert_eq!(preview_limit_at_2, 2 + border_space); - assert_eq!(preview_static_h3, 3 + border_space); - assert_eq!(preview_static_limit_at_4, 4 + border_space); - assert_eq!(settings_preview_fixed, 15 + border_space); - } - - // Test when there's no results, scrolling up or down doesn't underflow - #[test] - fn state_scroll_up_underflow() { - let settings = Settings::new().unwrap(); - let mut state = State { - history_count: 0, - results_state: ListState::default(), - switched_search_mode: false, - search_mode: SearchMode::Fuzzy, - results_len: 0, - accept: false, - keymap_mode: KeymapMode::Auto, - prefix: false, - current_cursor: None, - tab_index: 0, - pending_vim_key: None, - original_input_empty: false, - inspecting_state: InspectingState { - current: None, - next: None, - previous: None, - }, - keymaps: KeymapSet::defaults(&settings), - search: SearchState { - input: String::new().into(), - filter_mode: FilterMode::Directory, - context: Context { - session: String::new(), - cwd: String::new(), - hostname: String::new(), - host_id: String::new(), - git_root: None, - }, - custom_context: None, - }, - engine: engines::engine(SearchMode::Fuzzy, &settings), - now: Box::new(OffsetDateTime::now_utc), - }; - - state.scroll_up(1); - state.scroll_down(1); - } - - #[test] - fn test_accept_keybindings() { - use crate::atuin_client::settings::Keys; - use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - - let mut settings = Settings::new().unwrap(); - settings.keys = Keys { - scroll_exits: true, - exit_past_line_start: false, - accept_past_line_end: true, - accept_past_line_start: false, - accept_with_backspace: false, - prefix: "a".to_string(), - }; - - let mut state = State { - history_count: 1, - results_state: ListState::default(), - switched_search_mode: false, - search_mode: SearchMode::Fuzzy, - results_len: 1, - accept: false, - keymap_mode: KeymapMode::Emacs, - prefix: false, - current_cursor: None, - tab_index: 0, - pending_vim_key: None, - original_input_empty: false, - inspecting_state: InspectingState { - current: None, - next: None, - previous: None, - }, - keymaps: KeymapSet::defaults(&settings), - search: SearchState { - input: String::new().into(), - filter_mode: FilterMode::Global, - context: Context { - session: String::new(), - cwd: String::new(), - hostname: String::new(), - host_id: String::new(), - git_root: None, - }, - custom_context: None, - }, - engine: engines::engine(SearchMode::Fuzzy, &settings), - now: Box::new(OffsetDateTime::now_utc), - }; - - let tab_event = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE); - let result = state.handle_key_input(&settings, &tab_event); - assert!( - matches!(result, super::InputAction::Accept(_)), - "Tab should always accept" - ); - - // Test left arrow with accept_past_line_start disabled (should continue) - let left_event = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE); - let result = state.handle_key_input(&settings, &left_event); - assert!( - matches!(result, super::InputAction::Continue), - "Left arrow should continue when disabled" - ); - - // Test left arrow with accept_past_line_start enabled (should accept at start of line) - settings.keys.accept_past_line_start = true; - state.keymaps = KeymapSet::defaults(&settings); - let result = state.handle_key_input(&settings, &left_event); - assert!( - matches!(result, super::InputAction::Accept(_)), - "Left arrow should accept at start of line when enabled" - ); - settings.keys.accept_past_line_start = false; - state.keymaps = KeymapSet::defaults(&settings); - - let backspace_event = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE); - let result = state.handle_key_input(&settings, &backspace_event); - assert!( - matches!(result, super::InputAction::Continue), - "Backspace should continue when disabled" - ); - - settings.keys.accept_with_backspace = true; - state.keymaps = KeymapSet::defaults(&settings); - let result = state.handle_key_input(&settings, &backspace_event); - assert!( - matches!(result, super::InputAction::Accept(_)), - "Backspace should accept at start of line when enabled" - ); - - state.search.input.insert('t'); - state.search.input.insert('e'); - state.search.input.insert('s'); - state.search.input.insert('t'); - state.search.input.end(); - - let right_event = KeyEvent::new(KeyCode::Right, KeyModifiers::NONE); - let result = state.handle_key_input(&settings, &right_event); - assert!( - matches!(result, super::InputAction::Accept(_)), - "Right arrow should accept at end of line when enabled" - ); - - settings.keys.accept_past_line_start = true; - state.keymaps = KeymapSet::defaults(&settings); - let left_event = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE); - let result = state.handle_key_input(&settings, &left_event); - assert!( - matches!(result, super::InputAction::Continue), - "Left arrow should continue and end of line, even when enabled" - ); - settings.keys.accept_past_line_start = false; - state.keymaps = KeymapSet::defaults(&settings); - - settings.keys.accept_with_backspace = true; - state.keymaps = KeymapSet::defaults(&settings); - let backspace_event = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE); - let result = state.handle_key_input(&settings, &backspace_event); - assert!( - matches!(result, super::InputAction::Continue), - "Backspace should continue at end of line, even when enabled" - ); - settings.keys.accept_with_backspace = false; - state.keymaps = KeymapSet::defaults(&settings); - } - - #[test] - fn test_vim_gg_multikey_sequence() { - use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - - let settings = Settings::new().unwrap(); - - let mut state = State { - history_count: 100, - results_state: ListState::default(), - switched_search_mode: false, - search_mode: SearchMode::Fuzzy, - results_len: 100, - accept: false, - keymap_mode: KeymapMode::VimNormal, - prefix: false, - current_cursor: None, - tab_index: 0, - pending_vim_key: None, - original_input_empty: false, - inspecting_state: InspectingState { - current: None, - next: None, - previous: None, - }, - keymaps: KeymapSet::defaults(&settings), - search: SearchState { - input: String::new().into(), - filter_mode: FilterMode::Global, - context: Context { - session: String::new(), - cwd: String::new(), - hostname: String::new(), - host_id: String::new(), - git_root: None, - }, - custom_context: None, - }, - engine: engines::engine(SearchMode::Fuzzy, &settings), - now: Box::new(OffsetDateTime::now_utc), - }; - - // Start in the middle of the list - state.results_state.select(50); - - // First 'g' should set pending state - let g_event = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE); - let result = state.handle_key_input(&settings, &g_event); - assert!(matches!(result, super::InputAction::Continue)); - assert_eq!(state.pending_vim_key, Some('g')); - assert_eq!(state.results_state.selected(), 50); // Position unchanged - - // Second 'g' should jump to end (visual top in non-inverted mode) - let result = state.handle_key_input(&settings, &g_event); - assert!(matches!(result, super::InputAction::Continue)); - assert_eq!(state.pending_vim_key, None); - assert_eq!(state.results_state.selected(), 99); // Jumped to last index (visual top) - } - - #[test] - fn test_vim_g_key_clears_on_other_input() { - use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - - let settings = Settings::new().unwrap(); - - let mut state = State { - history_count: 100, - results_state: ListState::default(), - switched_search_mode: false, - search_mode: SearchMode::Fuzzy, - results_len: 100, - accept: false, - keymap_mode: KeymapMode::VimNormal, - prefix: false, - current_cursor: None, - tab_index: 0, - pending_vim_key: None, - original_input_empty: false, - inspecting_state: InspectingState { - current: None, - next: None, - previous: None, - }, - keymaps: KeymapSet::defaults(&settings), - search: SearchState { - input: String::new().into(), - filter_mode: FilterMode::Global, - context: Context { - session: String::new(), - cwd: String::new(), - hostname: String::new(), - host_id: String::new(), - git_root: None, - }, - custom_context: None, - }, - engine: engines::engine(SearchMode::Fuzzy, &settings), - now: Box::new(OffsetDateTime::now_utc), - }; - - state.results_state.select(50); - - // Press 'g' to set pending state - let g_event = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE); - state.handle_key_input(&settings, &g_event); - assert_eq!(state.pending_vim_key, Some('g')); - - // Press 'j' - should clear pending state - let j_event = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE); - state.handle_key_input(&settings, &j_event); - assert_eq!(state.pending_vim_key, None); - } - - #[test] - fn test_vim_big_g_jump_to_bottom() { - use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - - let settings = Settings::new().unwrap(); - - let mut state = State { - history_count: 100, - results_state: ListState::default(), - switched_search_mode: false, - search_mode: SearchMode::Fuzzy, - results_len: 100, - accept: false, - keymap_mode: KeymapMode::VimNormal, - prefix: false, - current_cursor: None, - tab_index: 0, - pending_vim_key: None, - original_input_empty: false, - inspecting_state: InspectingState { - current: None, - next: None, - previous: None, - }, - keymaps: KeymapSet::defaults(&settings), - search: SearchState { - input: String::new().into(), - filter_mode: FilterMode::Global, - context: Context { - session: String::new(), - cwd: String::new(), - hostname: String::new(), - host_id: String::new(), - git_root: None, - }, - custom_context: None, - }, - engine: engines::engine(SearchMode::Fuzzy, &settings), - now: Box::new(OffsetDateTime::now_utc), - }; - - state.results_state.select(50); - - // 'G' should jump to visual bottom (index 0 in non-inverted mode) - let big_g_event = KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE); - let result = state.handle_key_input(&settings, &big_g_event); - assert!(matches!(result, super::InputAction::Continue)); - assert_eq!(state.results_state.selected(), 0); - } - - #[test] - #[expect(clippy::similar_names)] - fn test_vim_ctrl_u_d_half_page_scroll() { - use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - - let settings = Settings::new().unwrap(); - - let mut state = State { - history_count: 100, - results_state: ListState::default(), - switched_search_mode: false, - search_mode: SearchMode::Fuzzy, - results_len: 100, - accept: false, - keymap_mode: KeymapMode::VimNormal, - prefix: false, - current_cursor: None, - tab_index: 0, - pending_vim_key: None, - original_input_empty: false, - inspecting_state: InspectingState { - current: None, - next: None, - previous: None, - }, - keymaps: KeymapSet::defaults(&settings), - search: SearchState { - input: String::new().into(), - filter_mode: FilterMode::Global, - context: Context { - session: String::new(), - cwd: String::new(), - hostname: String::new(), - host_id: String::new(), - git_root: None, - }, - custom_context: None, - }, - engine: engines::engine(SearchMode::Fuzzy, &settings), - now: Box::new(OffsetDateTime::now_utc), - }; - - state.results_state.select(50); - - // Ctrl+d should return Continue and clear pending key - // (scroll amount depends on max_entries which is 0 in tests) - state.pending_vim_key = Some('g'); - let ctrl_d_event = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL); - let result = state.handle_key_input(&settings, &ctrl_d_event); - assert!(matches!(result, super::InputAction::Continue)); - assert_eq!(state.pending_vim_key, None); - - // Ctrl+u should return Continue and clear pending key - state.pending_vim_key = Some('g'); - let ctrl_u_event = KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL); - let result = state.handle_key_input(&settings, &ctrl_u_event); - assert!(matches!(result, super::InputAction::Continue)); - assert_eq!(state.pending_vim_key, None); - } - - #[test] - #[expect(clippy::similar_names)] - fn test_vim_ctrl_f_b_full_page_scroll() { - use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - - let settings = Settings::new().unwrap(); - - let mut state = State { - history_count: 100, - results_state: ListState::default(), - switched_search_mode: false, - search_mode: SearchMode::Fuzzy, - results_len: 100, - accept: false, - keymap_mode: KeymapMode::VimNormal, - prefix: false, - current_cursor: None, - tab_index: 0, - pending_vim_key: None, - original_input_empty: false, - inspecting_state: InspectingState { - current: None, - next: None, - previous: None, - }, - keymaps: KeymapSet::defaults(&settings), - search: SearchState { - input: String::new().into(), - filter_mode: FilterMode::Global, - context: Context { - session: String::new(), - cwd: String::new(), - hostname: String::new(), - host_id: String::new(), - git_root: None, - }, - custom_context: None, - }, - engine: engines::engine(SearchMode::Fuzzy, &settings), - now: Box::new(OffsetDateTime::now_utc), - }; - - state.results_state.select(50); - - // Ctrl+f should return Continue and clear pending key - // (scroll amount depends on max_entries which is 0 in tests) - state.pending_vim_key = Some('g'); - let ctrl_f_event = KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL); - let result = state.handle_key_input(&settings, &ctrl_f_event); - assert!(matches!(result, super::InputAction::Continue)); - assert_eq!(state.pending_vim_key, None); - - // Ctrl+b should return Continue and clear pending key - state.pending_vim_key = Some('g'); - let ctrl_b_event = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL); - let result = state.handle_key_input(&settings, &ctrl_b_event); - assert!(matches!(result, super::InputAction::Continue)); - assert_eq!(state.pending_vim_key, None); - } - - // ----------------------------------------------------------------------- - // Executor tests (execute_action) - // ----------------------------------------------------------------------- - - /// Helper to build a State for executor tests. - fn make_executor_state(results_len: usize, selected: usize) -> State { - let settings = Settings::new().unwrap(); - let mut state = State { - history_count: results_len as i64, - results_state: ListState::default(), - switched_search_mode: false, - search_mode: SearchMode::Fuzzy, - results_len, - accept: false, - keymap_mode: KeymapMode::Emacs, - prefix: false, - current_cursor: None, - tab_index: 0, - pending_vim_key: None, - original_input_empty: false, - inspecting_state: InspectingState { - current: None, - next: None, - previous: None, - }, - keymaps: KeymapSet::defaults(&settings), - search: SearchState { - input: String::new().into(), - filter_mode: FilterMode::Global, - context: Context { - session: String::new(), - cwd: String::new(), - hostname: String::new(), - host_id: String::new(), - git_root: None, - }, - custom_context: None, - }, - engine: engines::engine(SearchMode::Fuzzy, &settings), - now: Box::new(OffsetDateTime::now_utc), - }; - state.results_state.select(selected); - state - } - - #[test] - fn execute_select_next_no_invert() { - use crate::command::client::search::keybindings::Action; - - let mut state = make_executor_state(100, 50); - let settings = Settings::new().unwrap(); - let result = state.execute_action(&Action::SelectNext, &settings); - assert!(matches!(result, super::InputAction::Continue)); - // Non-inverted: SelectNext = scroll_down = selected - 1 - assert_eq!(state.results_state.selected(), 49); - } - - #[test] - fn execute_select_next_with_invert() { - use crate::command::client::search::keybindings::Action; - - let mut state = make_executor_state(100, 50); - let mut settings = Settings::new().unwrap(); - settings.invert = true; - let result = state.execute_action(&Action::SelectNext, &settings); - assert!(matches!(result, super::InputAction::Continue)); - // Inverted: SelectNext = scroll_up = selected + 1 - assert_eq!(state.results_state.selected(), 51); - } - - #[test] - fn execute_select_previous_no_invert() { - use crate::command::client::search::keybindings::Action; - - let mut state = make_executor_state(100, 50); - let settings = Settings::new().unwrap(); - let result = state.execute_action(&Action::SelectPrevious, &settings); - assert!(matches!(result, super::InputAction::Continue)); - // Non-inverted: SelectPrevious = scroll_up = selected + 1 - assert_eq!(state.results_state.selected(), 51); - } - - #[test] - fn execute_vim_enter_normal() { - use crate::command::client::search::keybindings::Action; - - let mut state = make_executor_state(100, 0); - let settings = Settings::new().unwrap(); - let result = state.execute_action(&Action::VimEnterNormal, &settings); - assert!(matches!(result, super::InputAction::Continue)); - assert_eq!(state.keymap_mode, KeymapMode::VimNormal); - } - - #[test] - fn execute_vim_enter_insert() { - use crate::command::client::search::keybindings::Action; - - let mut state = make_executor_state(100, 0); - state.keymap_mode = KeymapMode::VimNormal; - let settings = Settings::new().unwrap(); - let result = state.execute_action(&Action::VimEnterInsert, &settings); - assert!(matches!(result, super::InputAction::Continue)); - assert_eq!(state.keymap_mode, KeymapMode::VimInsert); - } - - #[test] - fn execute_accept_sets_accept_flag() { - use crate::command::client::search::keybindings::Action; - - let mut state = make_executor_state(100, 5); - let mut settings = Settings::new().unwrap(); - settings.enter_accept = true; - let result = state.execute_action(&Action::Accept, &settings); - assert!(matches!(result, super::InputAction::Accept(5))); - assert!(state.accept); - } - - #[test] - fn execute_return_selection_does_not_set_accept() { - use crate::command::client::search::keybindings::Action; - - let mut state = make_executor_state(100, 5); - let settings = Settings::new().unwrap(); - let result = state.execute_action(&Action::ReturnSelection, &settings); - assert!(matches!(result, super::InputAction::Accept(5))); - assert!(!state.accept); - } - - #[test] - fn execute_accept_nth() { - use crate::command::client::search::keybindings::Action; - - let mut state = make_executor_state(100, 5); - let settings = Settings::new().unwrap(); - let result = state.execute_action(&Action::AcceptNth(3), &settings); - assert!(matches!(result, super::InputAction::Accept(8))); - } - - #[test] - fn execute_scroll_to_top_no_invert() { - use crate::command::client::search::keybindings::Action; - - let mut state = make_executor_state(100, 50); - let settings = Settings::new().unwrap(); - let result = state.execute_action(&Action::ScrollToTop, &settings); - assert!(matches!(result, super::InputAction::Continue)); - // Non-inverted: visual top = highest index - assert_eq!(state.results_state.selected(), 99); - } - - #[test] - fn execute_scroll_to_top_with_invert() { - use crate::command::client::search::keybindings::Action; - - let mut state = make_executor_state(100, 50); - let mut settings = Settings::new().unwrap(); - settings.invert = true; - let result = state.execute_action(&Action::ScrollToTop, &settings); - assert!(matches!(result, super::InputAction::Continue)); - // Inverted: visual top = index 0 - assert_eq!(state.results_state.selected(), 0); - } - - #[test] - fn execute_scroll_to_bottom_no_invert() { - use crate::command::client::search::keybindings::Action; - - let mut state = make_executor_state(100, 50); - let settings = Settings::new().unwrap(); - let result = state.execute_action(&Action::ScrollToBottom, &settings); - assert!(matches!(result, super::InputAction::Continue)); - // Non-inverted: visual bottom = index 0 - assert_eq!(state.results_state.selected(), 0); - } - - #[test] - fn execute_toggle_tab() { - use crate::command::client::search::keybindings::Action; - - let mut state = make_executor_state(100, 0); - let settings = Settings::new().unwrap(); - assert_eq!(state.tab_index, 0); - state.execute_action(&Action::ToggleTab, &settings); - assert_eq!(state.tab_index, 1); - state.execute_action(&Action::ToggleTab, &settings); - assert_eq!(state.tab_index, 0); - } - - #[test] - fn execute_enter_prefix_mode() { - use crate::command::client::search::keybindings::Action; - - let mut state = make_executor_state(100, 0); - let settings = Settings::new().unwrap(); - assert!(!state.prefix); - state.execute_action(&Action::EnterPrefixMode, &settings); - assert!(state.prefix); - } - - #[test] - fn execute_exit_returns_based_on_exit_mode() { - use crate::atuin_client::settings::ExitMode; - use crate::command::client::search::keybindings::Action; - - let mut state = make_executor_state(100, 0); - let mut settings = Settings::new().unwrap(); - - settings.exit_mode = ExitMode::ReturnOriginal; - let result = state.execute_action(&Action::Exit, &settings); - assert!(matches!(result, super::InputAction::ReturnOriginal)); - - settings.exit_mode = ExitMode::ReturnQuery; - let result = state.execute_action(&Action::Exit, &settings); - assert!(matches!(result, super::InputAction::ReturnQuery)); - } - - #[test] - fn execute_return_original() { - use crate::command::client::search::keybindings::Action; - - let mut state = make_executor_state(100, 0); - let settings = Settings::new().unwrap(); - let result = state.execute_action(&Action::ReturnOriginal, &settings); - assert!(matches!(result, super::InputAction::ReturnOriginal)); - } - - #[test] - fn execute_copy() { - use crate::command::client::search::keybindings::Action; - - let mut state = make_executor_state(100, 7); - let settings = Settings::new().unwrap(); - let result = state.execute_action(&Action::Copy, &settings); - assert!(matches!(result, super::InputAction::Copy(7))); - } - - #[test] - fn execute_delete() { - use crate::command::client::search::keybindings::Action; - - let mut state = make_executor_state(100, 7); - let settings = Settings::new().unwrap(); - let result = state.execute_action(&Action::Delete, &settings); - assert!(matches!(result, super::InputAction::Delete(7))); - } - - #[test] - fn execute_switch_context() { - use crate::command::client::search::keybindings::Action; - - let mut state = make_executor_state(100, 7); - let settings = Settings::new().unwrap(); - let result = state.execute_action(&Action::SwitchContext, &settings); - assert!(matches!(result, super::InputAction::SwitchContext(Some(7)))); - } - - #[test] - fn execute_clear_context() { - use crate::command::client::search::keybindings::Action; - - let mut state = make_executor_state(100, 7); - let settings = Settings::new().unwrap(); - let result = state.execute_action(&Action::ClearContext, &settings); - assert!(matches!(result, super::InputAction::SwitchContext(None))); - } - - #[test] - fn execute_noop() { - use crate::command::client::search::keybindings::Action; - - let mut state = make_executor_state(100, 50); - let settings = Settings::new().unwrap(); - let result = state.execute_action(&Action::Noop, &settings); - assert!(matches!(result, super::InputAction::Continue)); - assert_eq!(state.results_state.selected(), 50); - } - - #[test] - fn execute_accept_in_inspector_tab() { - use crate::command::client::search::keybindings::Action; - - let mut state = make_executor_state(100, 5); - state.tab_index = 1; - let settings = Settings::new().unwrap(); - let result = state.execute_action(&Action::Accept, &settings); - assert!(matches!(result, super::InputAction::AcceptInspecting)); - } - - #[test] - fn execute_cycle_search_mode() { - use crate::command::client::search::keybindings::Action; - - let mut state = make_executor_state(100, 0); - let settings = Settings::new().unwrap(); - let original_mode = state.search_mode; - let result = state.execute_action(&Action::CycleSearchMode, &settings); - assert!(matches!(result, super::InputAction::Continue)); - assert!(state.switched_search_mode); - assert_ne!(state.search_mode, original_mode); - } - - #[test] - fn execute_vim_search_insert() { - use crate::command::client::search::keybindings::Action; - - let mut state = make_executor_state(100, 0); - state.search.input.insert('h'); - state.search.input.insert('i'); - state.keymap_mode = KeymapMode::VimNormal; - let settings = Settings::new().unwrap(); - let result = state.execute_action(&Action::VimSearchInsert, &settings); - assert!(matches!(result, super::InputAction::Continue)); - // Should clear input and switch to insert mode - assert_eq!(state.search.input.as_str(), ""); - assert_eq!(state.keymap_mode, KeymapMode::VimInsert); - } - - #[test] - fn execute_cursor_movement() { - use crate::command::client::search::keybindings::Action; - - let mut state = make_executor_state(100, 0); - let settings = Settings::new().unwrap(); - - // Insert some text - state.search.input.insert('h'); - state.search.input.insert('e'); - state.search.input.insert('l'); - state.search.input.insert('l'); - state.search.input.insert('o'); - // cursor is at end (position 5) - - // CursorLeft - state.execute_action(&Action::CursorLeft, &settings); - assert_eq!(state.search.input.position(), 4); - - // CursorStart - state.execute_action(&Action::CursorStart, &settings); - assert_eq!(state.search.input.position(), 0); - - // CursorEnd - state.execute_action(&Action::CursorEnd, &settings); - assert_eq!(state.search.input.position(), 5); - - // CursorRight at end does nothing - state.execute_action(&Action::CursorRight, &settings); - assert_eq!(state.search.input.position(), 5); - } - - #[test] - fn execute_editing() { - use crate::command::client::search::keybindings::Action; - - let mut state = make_executor_state(100, 0); - let settings = Settings::new().unwrap(); - - // Insert "hello" - state.search.input.insert('h'); - state.search.input.insert('e'); - state.search.input.insert('l'); - state.search.input.insert('l'); - state.search.input.insert('o'); - - // DeleteCharBefore (backspace) - state.execute_action(&Action::DeleteCharBefore, &settings); - assert_eq!(state.search.input.as_str(), "hell"); - - // ClearLine - state.execute_action(&Action::ClearLine, &settings); - assert_eq!(state.search.input.as_str(), ""); - } - - #[test] - fn keymap_config_return_query() { - use crate::atuin_client::settings::KeyBindingConfig; - use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - use std::collections::HashMap; - - let mut settings = Settings::new().unwrap(); - // Configure tab to return-query - settings.keymap.emacs = HashMap::from([( - "tab".to_string(), - KeyBindingConfig::Simple("return-query".to_string()), - )]); - - let mut state = State { - history_count: 100, - results_state: ListState::default(), - switched_search_mode: false, - search_mode: SearchMode::Fuzzy, - results_len: 100, - accept: false, - keymap_mode: KeymapMode::Emacs, - prefix: false, - current_cursor: None, - tab_index: 0, - pending_vim_key: None, - original_input_empty: false, - inspecting_state: InspectingState { - current: None, - next: None, - previous: None, - }, - keymaps: KeymapSet::from_settings(&settings), - search: SearchState { - input: "test query".to_string().into(), - filter_mode: FilterMode::Global, - context: Context { - session: String::new(), - cwd: String::new(), - hostname: String::new(), - host_id: String::new(), - git_root: None, - }, - custom_context: None, - }, - engine: engines::engine(SearchMode::Fuzzy, &settings), - now: Box::new(OffsetDateTime::now_utc), - }; - - let tab_event = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE); - let result = state.handle_key_input(&settings, &tab_event); - assert!( - matches!(result, super::InputAction::ReturnQuery), - "Tab configured as return-query should return InputAction::ReturnQuery" - ); - } -} diff --git a/crates/client/src/command/client/search/keybindings/actions.rs b/crates/client/src/command/client/search/keybindings/actions.rs deleted file mode 100644 index 2842d618..00000000 --- a/crates/client/src/command/client/search/keybindings/actions.rs +++ /dev/null @@ -1,322 +0,0 @@ -use std::fmt; - -use serde::{Deserialize, Deserializer, Serialize, Serializer}; - -/// All possible actions that can be triggered by a keybinding. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum Action { - // Cursor movement - CursorLeft, - CursorRight, - CursorWordLeft, - CursorWordRight, - CursorWordEnd, - CursorStart, - CursorEnd, - - // Editing - DeleteCharBefore, - DeleteCharAfter, - DeleteWordBefore, - DeleteWordAfter, - DeleteToWordBoundary, - ClearLine, - ClearToStart, - ClearToEnd, - - // List navigation - SelectNext, - SelectPrevious, - ScrollHalfPageUp, - ScrollHalfPageDown, - ScrollPageUp, - ScrollPageDown, - ScrollToTop, - ScrollToBottom, - ScrollToScreenTop, - ScrollToScreenMiddle, - ScrollToScreenBottom, - - // Commands — accept selection and execute immediately - Accept, - AcceptNth(u8), - // Commands — return selection to command line without executing - ReturnSelection, - ReturnSelectionNth(u8), - // Commands — other - Copy, - Delete, - DeleteAll, - ReturnOriginal, - ReturnQuery, - Exit, - Redraw, - CycleFilterMode, - CycleSearchMode, - SwitchContext, - ClearContext, - ToggleTab, - - // Mode changes - VimEnterNormal, - VimEnterInsert, - VimEnterInsertAfter, - VimEnterInsertAtStart, - VimEnterInsertAtEnd, - VimSearchInsert, - VimChangeToEnd, - EnterPrefixMode, - - // Inspector - InspectPrevious, - InspectNext, - - // Special - Noop, -} - -impl Action { - /// Convert from a kebab-case string. - pub(crate) fn from_str(s: &str) -> Result<Self, String> { - // Handle accept-N and return-selection-N patterns - if let Some(rest) = s.strip_prefix("accept-") - && let Ok(n) = rest.parse::<u8>() - && (1..=9).contains(&n) - { - return Ok(Self::AcceptNth(n)); - } - if let Some(rest) = s.strip_prefix("return-selection-") - && let Ok(n) = rest.parse::<u8>() - && (1..=9).contains(&n) - { - return Ok(Self::ReturnSelectionNth(n)); - } - - match s { - "cursor-left" => Ok(Self::CursorLeft), - "cursor-right" => Ok(Self::CursorRight), - "cursor-word-left" => Ok(Self::CursorWordLeft), - "cursor-word-right" => Ok(Self::CursorWordRight), - "cursor-word-end" => Ok(Self::CursorWordEnd), - "cursor-start" => Ok(Self::CursorStart), - "cursor-end" => Ok(Self::CursorEnd), - - "delete-char-before" => Ok(Self::DeleteCharBefore), - "delete-char-after" => Ok(Self::DeleteCharAfter), - "delete-word-before" => Ok(Self::DeleteWordBefore), - "delete-word-after" => Ok(Self::DeleteWordAfter), - "delete-to-word-boundary" => Ok(Self::DeleteToWordBoundary), - "clear-line" => Ok(Self::ClearLine), - "clear-to-start" => Ok(Self::ClearToStart), - "clear-to-end" => Ok(Self::ClearToEnd), - - "select-next" => Ok(Self::SelectNext), - "select-previous" => Ok(Self::SelectPrevious), - "scroll-half-page-up" => Ok(Self::ScrollHalfPageUp), - "scroll-half-page-down" => Ok(Self::ScrollHalfPageDown), - "scroll-page-up" => Ok(Self::ScrollPageUp), - "scroll-page-down" => Ok(Self::ScrollPageDown), - "scroll-to-top" => Ok(Self::ScrollToTop), - "scroll-to-bottom" => Ok(Self::ScrollToBottom), - "scroll-to-screen-top" => Ok(Self::ScrollToScreenTop), - "scroll-to-screen-middle" => Ok(Self::ScrollToScreenMiddle), - "scroll-to-screen-bottom" => Ok(Self::ScrollToScreenBottom), - - "accept" => Ok(Self::Accept), - "return-selection" => Ok(Self::ReturnSelection), - "copy" => Ok(Self::Copy), - "delete" => Ok(Self::Delete), - "delete-all" => Ok(Self::DeleteAll), - "return-original" => Ok(Self::ReturnOriginal), - "return-query" => Ok(Self::ReturnQuery), - "exit" => Ok(Self::Exit), - "redraw" => Ok(Self::Redraw), - "cycle-filter-mode" => Ok(Self::CycleFilterMode), - "cycle-search-mode" => Ok(Self::CycleSearchMode), - "switch-context" => Ok(Self::SwitchContext), - "clear-context" => Ok(Self::ClearContext), - "toggle-tab" => Ok(Self::ToggleTab), - - "vim-enter-normal" => Ok(Self::VimEnterNormal), - "vim-enter-insert" => Ok(Self::VimEnterInsert), - "vim-enter-insert-after" => Ok(Self::VimEnterInsertAfter), - "vim-enter-insert-at-start" => Ok(Self::VimEnterInsertAtStart), - "vim-enter-insert-at-end" => Ok(Self::VimEnterInsertAtEnd), - "vim-search-insert" => Ok(Self::VimSearchInsert), - "vim-change-to-end" => Ok(Self::VimChangeToEnd), - "enter-prefix-mode" => Ok(Self::EnterPrefixMode), - - "inspect-previous" => Ok(Self::InspectPrevious), - "inspect-next" => Ok(Self::InspectNext), - - "noop" => Ok(Self::Noop), - - _ => Err(format!("unknown action: {s}")), - } - } - - /// Convert to a kebab-case string. - pub(crate) fn as_str(&self) -> String { - match self { - Self::CursorLeft => "cursor-left".to_string(), - Self::CursorRight => "cursor-right".to_string(), - Self::CursorWordLeft => "cursor-word-left".to_string(), - Self::CursorWordRight => "cursor-word-right".to_string(), - Self::CursorWordEnd => "cursor-word-end".to_string(), - Self::CursorStart => "cursor-start".to_string(), - Self::CursorEnd => "cursor-end".to_string(), - - Self::DeleteCharBefore => "delete-char-before".to_string(), - Self::DeleteCharAfter => "delete-char-after".to_string(), - Self::DeleteWordBefore => "delete-word-before".to_string(), - Self::DeleteWordAfter => "delete-word-after".to_string(), - Self::DeleteToWordBoundary => "delete-to-word-boundary".to_string(), - Self::ClearLine => "clear-line".to_string(), - Self::ClearToStart => "clear-to-start".to_string(), - Self::ClearToEnd => "clear-to-end".to_string(), - - Self::SelectNext => "select-next".to_string(), - Self::SelectPrevious => "select-previous".to_string(), - Self::ScrollHalfPageUp => "scroll-half-page-up".to_string(), - Self::ScrollHalfPageDown => "scroll-half-page-down".to_string(), - Self::ScrollPageUp => "scroll-page-up".to_string(), - Self::ScrollPageDown => "scroll-page-down".to_string(), - Self::ScrollToTop => "scroll-to-top".to_string(), - Self::ScrollToBottom => "scroll-to-bottom".to_string(), - Self::ScrollToScreenTop => "scroll-to-screen-top".to_string(), - Self::ScrollToScreenMiddle => "scroll-to-screen-middle".to_string(), - Self::ScrollToScreenBottom => "scroll-to-screen-bottom".to_string(), - - Self::Accept => "accept".to_string(), - Self::AcceptNth(n) => format!("accept-{n}"), - Self::ReturnSelection => "return-selection".to_string(), - Self::ReturnSelectionNth(n) => format!("return-selection-{n}"), - Self::Copy => "copy".to_string(), - Self::Delete => "delete".to_string(), - Self::DeleteAll => "delete-all".to_string(), - Self::ReturnOriginal => "return-original".to_string(), - Self::ReturnQuery => "return-query".to_string(), - Self::Exit => "exit".to_string(), - Self::Redraw => "redraw".to_string(), - Self::CycleFilterMode => "cycle-filter-mode".to_string(), - Self::CycleSearchMode => "cycle-search-mode".to_string(), - Self::SwitchContext => "switch-context".to_string(), - Self::ClearContext => "clear-context".to_string(), - Self::ToggleTab => "toggle-tab".to_string(), - - Self::VimEnterNormal => "vim-enter-normal".to_string(), - Self::VimEnterInsert => "vim-enter-insert".to_string(), - Self::VimEnterInsertAfter => "vim-enter-insert-after".to_string(), - Self::VimEnterInsertAtStart => "vim-enter-insert-at-start".to_string(), - Self::VimEnterInsertAtEnd => "vim-enter-insert-at-end".to_string(), - Self::VimSearchInsert => "vim-search-insert".to_string(), - Self::VimChangeToEnd => "vim-change-to-end".to_string(), - Self::EnterPrefixMode => "enter-prefix-mode".to_string(), - - Self::InspectPrevious => "inspect-previous".to_string(), - Self::InspectNext => "inspect-next".to_string(), - - Self::Noop => "noop".to_string(), - } - } -} - -impl fmt::Display for Action { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.as_str()) - } -} - -impl Serialize for Action { - fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { - serializer.serialize_str(&self.as_str()) - } -} - -impl<'de> Deserialize<'de> for Action { - fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { - let s = String::deserialize(deserializer)?; - Self::from_str(&s).map_err(serde::de::Error::custom) - } -} - -#[cfg(test)] -mod tests { - use super::Action; - - #[test] - fn parse_basic_actions() { - assert_eq!(Action::from_str("cursor-left").unwrap(), Action::CursorLeft); - assert_eq!(Action::from_str("accept").unwrap(), Action::Accept); - assert_eq!(Action::from_str("exit").unwrap(), Action::Exit); - assert_eq!(Action::from_str("noop").unwrap(), Action::Noop); - assert_eq!( - Action::from_str("vim-enter-normal").unwrap(), - Action::VimEnterNormal - ); - } - - #[test] - fn parse_accept_nth() { - assert_eq!(Action::from_str("accept-1").unwrap(), Action::AcceptNth(1)); - assert_eq!(Action::from_str("accept-9").unwrap(), Action::AcceptNth(9)); - } - - #[test] - fn parse_return_selection() { - assert_eq!( - Action::from_str("return-selection").unwrap(), - Action::ReturnSelection - ); - assert_eq!( - Action::from_str("return-selection-1").unwrap(), - Action::ReturnSelectionNth(1) - ); - assert_eq!( - Action::from_str("return-selection-9").unwrap(), - Action::ReturnSelectionNth(9) - ); - } - - #[test] - fn parse_unknown_action() { - assert!(Action::from_str("unknown-action").is_err()); - assert!(Action::from_str("accept-0").is_err()); - assert!(Action::from_str("accept-10").is_err()); - assert!(Action::from_str("return-selection-0").is_err()); - assert!(Action::from_str("return-selection-10").is_err()); - } - - #[test] - fn round_trip() { - let actions = vec![ - Action::CursorLeft, - Action::Accept, - Action::AcceptNth(5), - Action::ReturnSelection, - Action::ReturnSelectionNth(3), - Action::VimSearchInsert, - Action::ScrollToScreenMiddle, - ]; - for action in actions { - let s = action.as_str(); - let parsed = Action::from_str(&s).unwrap(); - assert_eq!(action, parsed); - } - } - - #[test] - fn serde_round_trip() { - let action = Action::CursorLeft; - let json = serde_json::to_string(&action).unwrap(); - assert_eq!(json, "\"cursor-left\""); - let parsed: Action = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed, Action::CursorLeft); - - let action = Action::AcceptNth(3); - let json = serde_json::to_string(&action).unwrap(); - assert_eq!(json, "\"accept-3\""); - let parsed: Action = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed, Action::AcceptNth(3)); - } -} diff --git a/crates/client/src/command/client/search/keybindings/conditions.rs b/crates/client/src/command/client/search/keybindings/conditions.rs deleted file mode 100644 index fd993f2b..00000000 --- a/crates/client/src/command/client/search/keybindings/conditions.rs +++ /dev/null @@ -1,801 +0,0 @@ -use std::fmt; - -use serde::{Deserialize, Deserializer, Serialize, Serializer}; - -/// Atomic (leaf) conditions that can be evaluated against state. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum ConditionAtom { - CursorAtStart, - CursorAtEnd, - InputEmpty, - OriginalInputEmpty, - ListAtEnd, - ListAtStart, - NoResults, - HasResults, - HasContext, -} - -/// Boolean expression tree over condition atoms. -/// -/// Supports negation, conjunction, and disjunction with standard precedence: -/// `!` binds tightest, then `&&`, then `||`. -/// -/// Examples of valid expression strings: -/// - `"cursor-at-start"` (bare atom) -/// - `"!no-results"` (negation) -/// - `"cursor-at-start && input-empty"` (conjunction) -/// - `"list-at-start || no-results"` (disjunction) -/// - `"(cursor-at-start && !input-empty) || no-results"` (grouping) -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum ConditionExpr { - Atom(ConditionAtom), - Not(Box<Self>), - And(Box<Self>, Box<Self>), - Or(Box<Self>, Box<Self>), -} - -/// Context needed to evaluate conditions. This is a pure snapshot of state — -/// no references to mutable data. -pub(crate) struct EvalContext { - /// Current cursor position (unicode width units). - pub(crate) cursor_position: usize, - /// Width of the input string in unicode width units. - pub(crate) input_width: usize, - /// Byte length of the input string. - pub(crate) input_byte_len: usize, - /// Currently selected index in the results list. - pub(crate) selected_index: usize, - /// Total number of results. - pub(crate) results_len: usize, - /// Whether the original input (query passed to the TUI) was empty. - pub(crate) original_input_empty: bool, - /// Whether we use a search context of a command from the history. - pub(crate) has_context: bool, -} - -// --------------------------------------------------------------------------- -// ConditionAtom -// --------------------------------------------------------------------------- - -impl ConditionAtom { - /// Evaluate this atom against the given context. - pub(crate) fn evaluate(&self, ctx: &EvalContext) -> bool { - match self { - Self::CursorAtStart => ctx.cursor_position == 0, - Self::CursorAtEnd => ctx.cursor_position == ctx.input_width, - Self::InputEmpty => ctx.input_byte_len == 0, - Self::OriginalInputEmpty => ctx.original_input_empty, - Self::ListAtEnd => { - ctx.results_len == 0 || ctx.selected_index >= ctx.results_len.saturating_sub(1) - } - Self::ListAtStart => ctx.results_len == 0 || ctx.selected_index == 0, - Self::NoResults => ctx.results_len == 0, - Self::HasResults => ctx.results_len > 0, - Self::HasContext => ctx.has_context, - } - } - - /// Parse from a kebab-case string. - pub(crate) fn from_str(s: &str) -> Result<Self, String> { - match s { - "cursor-at-start" => Ok(Self::CursorAtStart), - "cursor-at-end" => Ok(Self::CursorAtEnd), - "input-empty" => Ok(Self::InputEmpty), - "original-input-empty" => Ok(Self::OriginalInputEmpty), - "list-at-end" => Ok(Self::ListAtEnd), - "list-at-start" => Ok(Self::ListAtStart), - "no-results" => Ok(Self::NoResults), - "has-results" => Ok(Self::HasResults), - "has-context" => Ok(Self::HasContext), - _ => Err(format!("unknown condition: {s}")), - } - } - - /// Convert to a kebab-case string. - pub(crate) fn as_str(&self) -> &'static str { - match self { - Self::CursorAtStart => "cursor-at-start", - Self::CursorAtEnd => "cursor-at-end", - Self::InputEmpty => "input-empty", - Self::OriginalInputEmpty => "original-input-empty", - Self::ListAtEnd => "list-at-end", - Self::ListAtStart => "list-at-start", - Self::NoResults => "no-results", - Self::HasResults => "has-results", - Self::HasContext => "has-context", - } - } -} - -impl fmt::Display for ConditionAtom { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.as_str()) - } -} - -// --------------------------------------------------------------------------- -// ConditionExpr — evaluation -// --------------------------------------------------------------------------- - -impl ConditionExpr { - /// Evaluate this expression against the given context. - pub(crate) fn evaluate(&self, ctx: &EvalContext) -> bool { - match self { - Self::Atom(atom) => atom.evaluate(ctx), - Self::Not(inner) => !inner.evaluate(ctx), - Self::And(lhs, rhs) => lhs.evaluate(ctx) && rhs.evaluate(ctx), - Self::Or(lhs, rhs) => lhs.evaluate(ctx) || rhs.evaluate(ctx), - } - } -} - -// --------------------------------------------------------------------------- -// ConditionExpr — ergonomic builders -// --------------------------------------------------------------------------- - -impl From<ConditionAtom> for ConditionExpr { - fn from(atom: ConditionAtom) -> Self { - Self::Atom(atom) - } -} - -#[expect(dead_code)] -impl ConditionExpr { - /// Negate this expression: `!self`. - pub(crate) fn not(self) -> Self { - Self::Not(Box::new(self)) - } - - /// Conjoin with another expression: `self && other`. - pub(crate) fn and(self, other: Self) -> Self { - Self::And(Box::new(self), Box::new(other)) - } - - /// Disjoin with another expression: `self || other`. - pub(crate) fn or(self, other: Self) -> Self { - Self::Or(Box::new(self), Box::new(other)) - } -} - -// --------------------------------------------------------------------------- -// ConditionExpr — parser -// --------------------------------------------------------------------------- - -/// Recursive descent parser for boolean condition expressions. -/// -/// Grammar (standard boolean precedence): -/// ```text -/// expr = or_expr -/// or_expr = and_expr ("||" and_expr)* -/// and_expr = unary ("&&" unary)* -/// unary = "!" unary | primary -/// primary = atom | "(" expr ")" -/// atom = [a-z][a-z0-9-]* -/// ``` -struct ExprParser<'a> { - input: &'a str, - pos: usize, -} - -impl<'a> ExprParser<'a> { - fn new(input: &'a str) -> Self { - Self { input, pos: 0 } - } - - fn skip_whitespace(&mut self) { - while self.pos < self.input.len() && self.input.as_bytes()[self.pos].is_ascii_whitespace() { - self.pos += 1; - } - } - - fn starts_with(&mut self, s: &str) -> bool { - self.skip_whitespace(); - self.input[self.pos..].starts_with(s) - } - - fn consume(&mut self, s: &str) -> bool { - self.skip_whitespace(); - if self.input[self.pos..].starts_with(s) { - self.pos += s.len(); - true - } else { - false - } - } - - /// Parse a full expression, expecting to consume all input. - fn parse(mut self) -> Result<ConditionExpr, String> { - let expr = self.parse_or()?; - self.skip_whitespace(); - if self.pos < self.input.len() { - return Err(format!( - "unexpected input at position {}: {:?}", - self.pos, - &self.input[self.pos..] - )); - } - Ok(expr) - } - - /// `or_expr` = `and_expr` ("||" `and_expr`)* - fn parse_or(&mut self) -> Result<ConditionExpr, String> { - let mut left = self.parse_and()?; - while self.starts_with("||") { - self.consume("||"); - let right = self.parse_and()?; - left = ConditionExpr::Or(Box::new(left), Box::new(right)); - } - Ok(left) - } - - /// `and_expr` = unary ("&&" unary)* - fn parse_and(&mut self) -> Result<ConditionExpr, String> { - let mut left = self.parse_unary()?; - while self.starts_with("&&") { - self.consume("&&"); - let right = self.parse_unary()?; - left = ConditionExpr::And(Box::new(left), Box::new(right)); - } - Ok(left) - } - - /// unary = "!" unary | primary - fn parse_unary(&mut self) -> Result<ConditionExpr, String> { - if self.consume("!") { - let inner = self.parse_unary()?; - Ok(ConditionExpr::Not(Box::new(inner))) - } else { - self.parse_primary() - } - } - - /// primary = "(" expr ")" | atom - fn parse_primary(&mut self) -> Result<ConditionExpr, String> { - if self.consume("(") { - let expr = self.parse_or()?; - if !self.consume(")") { - return Err(format!("expected ')' at position {}", self.pos)); - } - Ok(expr) - } else { - self.parse_atom() - } - } - - /// atom = [a-z][a-z0-9-]* - fn parse_atom(&mut self) -> Result<ConditionExpr, String> { - self.skip_whitespace(); - let start = self.pos; - while self.pos < self.input.len() { - let b = self.input.as_bytes()[self.pos]; - if b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' { - self.pos += 1; - } else { - break; - } - } - if self.pos == start { - return Err(format!("expected condition name at position {}", self.pos)); - } - let name = &self.input[start..self.pos]; - let atom = ConditionAtom::from_str(name)?; - Ok(ConditionExpr::Atom(atom)) - } -} - -impl ConditionExpr { - /// Parse a condition expression from a string. - pub(crate) fn parse(s: &str) -> Result<Self, String> { - let parser = ExprParser::new(s); - parser.parse() - } -} - -// --------------------------------------------------------------------------- -// ConditionExpr — Display -// --------------------------------------------------------------------------- - -/// Precedence levels for minimal-parentheses display. -#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] -enum Prec { - Or = 0, - And = 1, - Not = 2, - Atom = 3, -} - -impl ConditionExpr { - fn prec(&self) -> Prec { - match self { - Self::Or(..) => Prec::Or, - Self::And(..) => Prec::And, - Self::Not(..) => Prec::Not, - Self::Atom(..) => Prec::Atom, - } - } - - fn fmt_with_prec(&self, f: &mut fmt::Formatter<'_>, parent_prec: Prec) -> fmt::Result { - let needs_parens = self.prec() < parent_prec; - if needs_parens { - write!(f, "(")?; - } - match self { - Self::Atom(atom) => write!(f, "{atom}")?, - Self::Not(inner) => { - write!(f, "!")?; - inner.fmt_with_prec(f, Prec::Not)?; - } - Self::And(lhs, rhs) => { - lhs.fmt_with_prec(f, Prec::And)?; - write!(f, " && ")?; - rhs.fmt_with_prec(f, Prec::And)?; - } - Self::Or(lhs, rhs) => { - lhs.fmt_with_prec(f, Prec::Or)?; - write!(f, " || ")?; - rhs.fmt_with_prec(f, Prec::Or)?; - } - } - if needs_parens { - write!(f, ")")?; - } - Ok(()) - } -} - -impl fmt::Display for ConditionExpr { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.fmt_with_prec(f, Prec::Or) - } -} - -// --------------------------------------------------------------------------- -// Serde -// --------------------------------------------------------------------------- - -impl Serialize for ConditionExpr { - fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { - serializer.serialize_str(&self.to_string()) - } -} - -impl<'de> Deserialize<'de> for ConditionExpr { - fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { - let s = String::deserialize(deserializer)?; - Self::parse(&s).map_err(serde::de::Error::custom) - } -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::{ConditionAtom, ConditionExpr, EvalContext}; - - fn ctx( - cursor: usize, - width: usize, - byte_len: usize, - selected: usize, - len: usize, - ) -> EvalContext { - ctx_with_original(cursor, width, byte_len, selected, len, false) - } - - fn ctx_with_original( - cursor: usize, - width: usize, - byte_len: usize, - selected: usize, - len: usize, - original_input_empty: bool, - ) -> EvalContext { - EvalContext { - cursor_position: cursor, - input_width: width, - input_byte_len: byte_len, - selected_index: selected, - results_len: len, - original_input_empty, - has_context: false, - } - } - - // -- Atom evaluation (carried over from Phase 0) -- - - #[test] - fn atom_cursor_at_start() { - assert!(ConditionAtom::CursorAtStart.evaluate(&ctx(0, 5, 5, 0, 10))); - assert!(!ConditionAtom::CursorAtStart.evaluate(&ctx(3, 5, 5, 0, 10))); - } - - #[test] - fn atom_cursor_at_end() { - assert!(ConditionAtom::CursorAtEnd.evaluate(&ctx(5, 5, 5, 0, 10))); - assert!(!ConditionAtom::CursorAtEnd.evaluate(&ctx(3, 5, 5, 0, 10))); - assert!(ConditionAtom::CursorAtEnd.evaluate(&ctx(0, 0, 0, 0, 10))); - } - - #[test] - fn atom_input_empty() { - assert!(ConditionAtom::InputEmpty.evaluate(&ctx(0, 0, 0, 0, 10))); - assert!(!ConditionAtom::InputEmpty.evaluate(&ctx(0, 5, 5, 0, 10))); - } - - #[test] - fn atom_original_input_empty() { - // original_input_empty = true - assert!( - ConditionAtom::OriginalInputEmpty.evaluate(&ctx_with_original(0, 0, 0, 0, 10, true)) - ); - // original_input_empty = false - assert!( - !ConditionAtom::OriginalInputEmpty.evaluate(&ctx_with_original(0, 0, 0, 0, 10, false)) - ); - // original_input_empty is independent of current input state - assert!( - ConditionAtom::OriginalInputEmpty.evaluate(&ctx_with_original(0, 5, 5, 0, 10, true)) - ); - } - - #[test] - fn atom_list_at_end() { - assert!(ConditionAtom::ListAtEnd.evaluate(&ctx(0, 0, 0, 99, 100))); - assert!(!ConditionAtom::ListAtEnd.evaluate(&ctx(0, 0, 0, 50, 100))); - assert!(ConditionAtom::ListAtEnd.evaluate(&ctx(0, 0, 0, 0, 0))); - } - - #[test] - fn atom_list_at_start() { - assert!(ConditionAtom::ListAtStart.evaluate(&ctx(0, 0, 0, 0, 100))); - assert!(!ConditionAtom::ListAtStart.evaluate(&ctx(0, 0, 0, 50, 100))); - assert!(ConditionAtom::ListAtStart.evaluate(&ctx(0, 0, 0, 0, 0))); - } - - #[test] - fn atom_no_results_and_has_results() { - assert!(ConditionAtom::NoResults.evaluate(&ctx(0, 0, 0, 0, 0))); - assert!(!ConditionAtom::NoResults.evaluate(&ctx(0, 0, 0, 0, 5))); - assert!(ConditionAtom::HasResults.evaluate(&ctx(0, 0, 0, 0, 5))); - assert!(!ConditionAtom::HasResults.evaluate(&ctx(0, 0, 0, 0, 0))); - } - - #[test] - fn atom_has_context() { - let mut context = ctx(0, 0, 0, 0, 0); - assert!(!ConditionAtom::HasContext.evaluate(&context)); - context.has_context = true; - assert!(ConditionAtom::HasContext.evaluate(&context)); - } - - #[test] - fn atom_parse_round_trip() { - let conditions = [ - "cursor-at-start", - "cursor-at-end", - "input-empty", - "original-input-empty", - "list-at-end", - "list-at-start", - "no-results", - "has-results", - ]; - for s in conditions { - let c = ConditionAtom::from_str(s).unwrap(); - assert_eq!(c.as_str(), s); - } - } - - #[test] - fn atom_parse_unknown() { - assert!(ConditionAtom::from_str("unknown-condition").is_err()); - } - - // -- Parser tests -- - - #[test] - fn parse_bare_atom() { - let expr = ConditionExpr::parse("cursor-at-start").unwrap(); - assert_eq!(expr, ConditionExpr::Atom(ConditionAtom::CursorAtStart)); - } - - #[test] - fn parse_negation() { - let expr = ConditionExpr::parse("!no-results").unwrap(); - assert_eq!( - expr, - ConditionExpr::Not(Box::new(ConditionExpr::Atom(ConditionAtom::NoResults))) - ); - } - - #[test] - fn parse_double_negation() { - let expr = ConditionExpr::parse("!!no-results").unwrap(); - assert_eq!( - expr, - ConditionExpr::Not(Box::new(ConditionExpr::Not(Box::new(ConditionExpr::Atom( - ConditionAtom::NoResults - ))))) - ); - } - - #[test] - fn parse_and() { - let expr = ConditionExpr::parse("cursor-at-start && input-empty").unwrap(); - assert_eq!( - expr, - ConditionExpr::And( - Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)), - Box::new(ConditionExpr::Atom(ConditionAtom::InputEmpty)), - ) - ); - } - - #[test] - fn parse_or() { - let expr = ConditionExpr::parse("list-at-start || no-results").unwrap(); - assert_eq!( - expr, - ConditionExpr::Or( - Box::new(ConditionExpr::Atom(ConditionAtom::ListAtStart)), - Box::new(ConditionExpr::Atom(ConditionAtom::NoResults)), - ) - ); - } - - #[test] - fn parse_precedence_and_binds_tighter_than_or() { - // "a || b && c" should parse as "a || (b && c)" - let expr = ConditionExpr::parse("cursor-at-start || input-empty && no-results").unwrap(); - assert_eq!( - expr, - ConditionExpr::Or( - Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)), - Box::new(ConditionExpr::And( - Box::new(ConditionExpr::Atom(ConditionAtom::InputEmpty)), - Box::new(ConditionExpr::Atom(ConditionAtom::NoResults)), - )), - ) - ); - } - - #[test] - fn parse_parens_override_precedence() { - // "(a || b) && c" - let expr = ConditionExpr::parse("(cursor-at-start || input-empty) && no-results").unwrap(); - assert_eq!( - expr, - ConditionExpr::And( - Box::new(ConditionExpr::Or( - Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)), - Box::new(ConditionExpr::Atom(ConditionAtom::InputEmpty)), - )), - Box::new(ConditionExpr::Atom(ConditionAtom::NoResults)), - ) - ); - } - - #[test] - fn parse_complex_nested() { - // "(a && !b) || c" - let expr = ConditionExpr::parse("(cursor-at-start && !input-empty) || no-results").unwrap(); - assert_eq!( - expr, - ConditionExpr::Or( - Box::new(ConditionExpr::And( - Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)), - Box::new(ConditionExpr::Not(Box::new(ConditionExpr::Atom( - ConditionAtom::InputEmpty - )))), - )), - Box::new(ConditionExpr::Atom(ConditionAtom::NoResults)), - ) - ); - } - - #[test] - fn parse_whitespace_tolerance() { - let a = ConditionExpr::parse("cursor-at-start||input-empty").unwrap(); - let b = ConditionExpr::parse("cursor-at-start || input-empty").unwrap(); - let c = ConditionExpr::parse(" cursor-at-start || input-empty ").unwrap(); - assert_eq!(a, b); - assert_eq!(b, c); - } - - #[test] - fn parse_error_unknown_atom() { - assert!(ConditionExpr::parse("unknown-thing").is_err()); - } - - #[test] - fn parse_error_trailing_input() { - assert!(ConditionExpr::parse("cursor-at-start blah").is_err()); - } - - #[test] - fn parse_error_unmatched_paren() { - assert!(ConditionExpr::parse("(cursor-at-start").is_err()); - } - - #[test] - fn parse_error_empty() { - assert!(ConditionExpr::parse("").is_err()); - } - - // -- Expression evaluation -- - - #[test] - fn eval_not() { - let expr = ConditionExpr::parse("!no-results").unwrap(); - // Has results → !no-results is true - assert!(expr.evaluate(&ctx(0, 0, 0, 0, 5))); - // No results → !no-results is false - assert!(!expr.evaluate(&ctx(0, 0, 0, 0, 0))); - } - - #[test] - fn eval_and() { - let expr = ConditionExpr::parse("cursor-at-start && input-empty").unwrap(); - // Both true - assert!(expr.evaluate(&ctx(0, 0, 0, 0, 10))); - // First true, second false (non-empty input) - assert!(!expr.evaluate(&ctx(0, 5, 5, 0, 10))); - // First false (cursor not at start) - assert!(!expr.evaluate(&ctx(3, 5, 5, 0, 10))); - } - - #[test] - fn eval_or() { - let expr = ConditionExpr::parse("list-at-start || no-results").unwrap(); - // list at bottom (selected=0) - assert!(expr.evaluate(&ctx(0, 0, 0, 0, 10))); - // no results - assert!(expr.evaluate(&ctx(0, 0, 0, 0, 0))); - // neither - assert!(!expr.evaluate(&ctx(0, 0, 0, 5, 10))); - } - - #[test] - fn eval_complex_nested() { - // (cursor-at-start && !input-empty) || no-results - let expr = ConditionExpr::parse("(cursor-at-start && !input-empty) || no-results").unwrap(); - - // cursor at start, input not empty → true (left branch) - assert!(expr.evaluate(&ctx(0, 5, 5, 0, 10))); - // no results → true (right branch) - assert!(expr.evaluate(&ctx(3, 5, 5, 0, 0))); - // cursor not at start, has results → false - assert!(!expr.evaluate(&ctx(3, 5, 5, 0, 10))); - // cursor at start, input empty → false (left: && fails; right: has results) - assert!(!expr.evaluate(&ctx(0, 0, 0, 0, 10))); - } - - // -- Display -- - - #[test] - fn display_atom() { - let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart); - assert_eq!(expr.to_string(), "cursor-at-start"); - } - - #[test] - fn display_not() { - let expr = ConditionExpr::Atom(ConditionAtom::NoResults).not(); - assert_eq!(expr.to_string(), "!no-results"); - } - - #[test] - fn display_and() { - let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart) - .and(ConditionExpr::Atom(ConditionAtom::InputEmpty)); - assert_eq!(expr.to_string(), "cursor-at-start && input-empty"); - } - - #[test] - fn display_or() { - let expr = ConditionExpr::Atom(ConditionAtom::ListAtStart) - .or(ConditionExpr::Atom(ConditionAtom::NoResults)); - assert_eq!(expr.to_string(), "list-at-start || no-results"); - } - - #[test] - fn display_parens_when_needed() { - // (a || b) && c — the Or inside And needs parens - let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart) - .or(ConditionExpr::Atom(ConditionAtom::InputEmpty)) - .and(ConditionExpr::Atom(ConditionAtom::NoResults)); - assert_eq!( - expr.to_string(), - "(cursor-at-start || input-empty) && no-results" - ); - } - - #[test] - fn display_no_parens_when_not_needed() { - // a || b && c — no parens needed (and binds tighter) - let inner_and = ConditionExpr::Atom(ConditionAtom::InputEmpty) - .and(ConditionExpr::Atom(ConditionAtom::NoResults)); - let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart).or(inner_and); - assert_eq!( - expr.to_string(), - "cursor-at-start || input-empty && no-results" - ); - } - - // -- Display round-trip -- - - #[test] - fn display_round_trip() { - let cases = [ - "cursor-at-start", - "!no-results", - "cursor-at-start && input-empty", - "list-at-start || no-results", - "(cursor-at-start || input-empty) && no-results", - "(cursor-at-start && !input-empty) || no-results", - ]; - for s in cases { - let expr = ConditionExpr::parse(s).unwrap(); - let displayed = expr.to_string(); - let reparsed = ConditionExpr::parse(&displayed).unwrap(); - assert_eq!(expr, reparsed, "round-trip failed for: {s}"); - } - } - - // -- Serde -- - - #[test] - fn serde_simple_atom() { - let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart); - let json = serde_json::to_string(&expr).unwrap(); - assert_eq!(json, "\"cursor-at-start\""); - let parsed: ConditionExpr = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed, expr); - } - - #[test] - fn serde_compound_expression() { - let json = "\"cursor-at-start && !input-empty\""; - let parsed: ConditionExpr = serde_json::from_str(json).unwrap(); - let expected = ConditionExpr::And( - Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)), - Box::new(ConditionExpr::Not(Box::new(ConditionExpr::Atom( - ConditionAtom::InputEmpty, - )))), - ); - assert_eq!(parsed, expected); - } - - #[test] - fn serde_round_trip() { - let expr = ConditionExpr::parse("(cursor-at-start && !input-empty) || no-results").unwrap(); - let json = serde_json::to_string(&expr).unwrap(); - let parsed: ConditionExpr = serde_json::from_str(&json).unwrap(); - assert_eq!(expr, parsed); - } - - // -- From<ConditionAtom> -- - - #[test] - fn from_atom_into_expr() { - let expr: ConditionExpr = ConditionAtom::CursorAtStart.into(); - assert_eq!(expr, ConditionExpr::Atom(ConditionAtom::CursorAtStart)); - } - - // -- Builder helpers -- - - #[test] - fn builder_chain() { - let expr = ConditionExpr::from(ConditionAtom::CursorAtStart) - .and(ConditionExpr::from(ConditionAtom::InputEmpty).not()) - .or(ConditionExpr::from(ConditionAtom::NoResults)); - // And binds tighter than Or, so no parens needed around the And - assert_eq!( - expr.to_string(), - "cursor-at-start && !input-empty || no-results" - ); - } -} diff --git a/crates/client/src/command/client/search/keybindings/defaults.rs b/crates/client/src/command/client/search/keybindings/defaults.rs deleted file mode 100644 index 4d00b475..00000000 --- a/crates/client/src/command/client/search/keybindings/defaults.rs +++ /dev/null @@ -1,1274 +0,0 @@ -use std::collections::HashMap; - -use crate::atuin_client::settings::{KeyBindingConfig, Settings}; -use tracing::warn; - -use super::actions::Action; -use super::conditions::{ConditionAtom, ConditionExpr}; -use super::key::KeyInput; -use super::keymap::{KeyBinding, KeyRule, Keymap}; - -/// Helper to bind a scroll key with optional exit behavior. -/// -/// When `scroll_exits` is true AND the key scrolls toward index 0 (the newest -/// entry), we add a conditional rule: at `ListAtStart` → `Exit`, otherwise → -/// the scroll action. -/// -/// Whether a key scrolls toward index 0 depends on the `invert` setting: -/// - Non-inverted: "down" / "j" move toward index 0, "up" / "k" move away -/// - Inverted: "up" / "k" move toward index 0, "down" / "j" move away -/// -/// If `toward_index_zero` is false, or `scroll_exits` is false, we just bind -/// the key to the plain scroll action (no exit). -fn bind_scroll_key( - km: &mut Keymap, - key_str: &str, - action: Action, - toward_index_zero: bool, - scroll_exits: bool, -) { - let k = key(key_str); - if scroll_exits && toward_index_zero { - km.bind_conditional( - k, - vec![ - KeyRule::when(ConditionAtom::ListAtStart, Action::Exit), - KeyRule::always(action), - ], - ); - } else { - km.bind(k, action); - } -} - -/// Helper to parse a key string, panicking on invalid keys (these are all -/// compile-time-known strings). -fn key(s: &str) -> KeyInput { - KeyInput::parse(s).unwrap_or_else(|e| panic!("invalid default key {s:?}: {e}")) -} - -/// All five keymaps bundled together. -#[derive(Debug, Clone)] -pub(crate) struct KeymapSet { - pub(crate) emacs: Keymap, - pub(crate) vim_normal: Keymap, - pub(crate) vim_insert: Keymap, - pub(crate) inspector: Keymap, - pub(crate) prefix: Keymap, -} - -// --------------------------------------------------------------------------- -// Common bindings shared across search-tab keymaps -// --------------------------------------------------------------------------- - -/// Add the bindings that are common to all search-tab keymaps: -/// ctrl-c, ctrl-g, ctrl-o, and tab. -/// -/// Note: `esc`/`ctrl-[` are NOT included here because their behavior differs -/// between emacs (exit), vim-normal (exit), and vim-insert (enter normal mode). -fn add_common_bindings(km: &mut Keymap) { - km.bind(key("ctrl-c"), Action::ReturnOriginal); - km.bind(key("ctrl-g"), Action::ReturnOriginal); - km.bind(key("ctrl-o"), Action::ToggleTab); - - // Tab: always returns selection without executing (unlike Enter which respects enter_accept) - km.bind(key("tab"), Action::ReturnSelection); -} - -/// Returns `Accept` or `ReturnSelection` based on the `enter_accept` setting. -fn accept_action(settings: &Settings) -> Action { - if settings.enter_accept { - Action::Accept - } else { - Action::ReturnSelection - } -} - -// --------------------------------------------------------------------------- -// Emacs keymap (also base for vim-insert) -// --------------------------------------------------------------------------- - -/// Build the default emacs keymap. This encodes the behavior from -/// `handle_key_input` common section + `handle_search_input` shared section. -/// -/// The `settings` parameter is used for: -/// - `keys.prefix` — which ctrl-key enters prefix mode -/// - `keys.scroll_exits`, `invert` — scroll-at-boundary exit behavior -/// - `keys.accept_past_line_end` — right arrow at end of line accepts -/// - `keys.exit_past_line_start` — left arrow at start of line exits -/// - `keys.accept_past_line_start` — left arrow at start accepts (overrides exit) -/// - `keys.accept_with_backspace` — backspace at start of line accepts -/// - `ctrl_n_shortcuts` — whether alt or ctrl is used for numeric shortcuts -// Keymap builder that enumerates every default binding; not worth splitting. -#[expect(clippy::too_many_lines)] -pub(crate) fn default_emacs_keymap(settings: &Settings) -> Keymap { - let mut km = Keymap::new(); - add_common_bindings(&mut km); - - let accept = accept_action(settings); - - // esc / ctrl-[ → exit - km.bind(key("esc"), Action::Exit); - km.bind(key("ctrl-["), Action::Exit); - - // Prefix key: ctrl-<prefix_char> → enter prefix mode - let prefix_char = settings.keys.prefix.chars().next().unwrap_or('a'); - km.bind(key(&format!("ctrl-{prefix_char}")), Action::EnterPrefixMode); - - // --- Accept / navigation edge behaviors (from [keys] settings) --- - - // right: behavior at end of line - if settings.keys.accept_past_line_end { - km.bind_conditional( - key("right"), - vec![ - KeyRule::when(ConditionAtom::CursorAtEnd, Action::ReturnSelection), - KeyRule::always(Action::CursorRight), - ], - ); - } else { - km.bind(key("right"), Action::CursorRight); - } - - // left: behavior at start of line - // accept_past_line_start takes precedence over exit_past_line_start - if settings.keys.accept_past_line_start { - km.bind_conditional( - key("left"), - vec![ - KeyRule::when(ConditionAtom::CursorAtStart, Action::ReturnSelection), - KeyRule::always(Action::CursorLeft), - ], - ); - } else if settings.keys.exit_past_line_start { - km.bind_conditional( - key("left"), - vec![ - KeyRule::when(ConditionAtom::CursorAtStart, Action::Exit), - KeyRule::always(Action::CursorLeft), - ], - ); - } else { - km.bind(key("left"), Action::CursorLeft); - } - - // down/up: scroll with optional exit at boundary. - // Non-inverted: down moves toward index 0 (can exit); up moves away (no exit). - // Inverted: up moves toward index 0 (can exit); down moves away (no exit). - let scroll_exits = settings.keys.scroll_exits; - let invert = settings.invert; - bind_scroll_key(&mut km, "down", Action::SelectNext, !invert, scroll_exits); - bind_scroll_key(&mut km, "up", Action::SelectPrevious, invert, scroll_exits); - - // backspace: behavior at start of line - if settings.keys.accept_with_backspace { - km.bind_conditional( - key("backspace"), - vec![ - KeyRule::when(ConditionAtom::CursorAtStart, Action::ReturnSelection), - KeyRule::always(Action::DeleteCharBefore), - ], - ); - } else { - km.bind(key("backspace"), Action::DeleteCharBefore); - } - - // --- Accept --- - km.bind(key("enter"), accept.clone()); - km.bind(key("ctrl-m"), accept); - - // --- Copy --- - km.bind(key("ctrl-y"), Action::Copy); - - // --- Numeric shortcuts (alt-1..9 by default, ctrl-1..9 if ctrl_n_shortcuts) --- - // These return the selection without executing, regardless of enter_accept. - let num_mod = if settings.ctrl_n_shortcuts { - "ctrl" - } else { - "alt" - }; - for n in 1..=9u8 { - km.bind( - key(&format!("{num_mod}-{n}")), - Action::ReturnSelectionNth(n), - ); - } - - // --- Cursor movement --- - km.bind(key("ctrl-left"), Action::CursorWordLeft); - km.bind(key("alt-b"), Action::CursorWordLeft); - km.bind(key("ctrl-b"), Action::CursorLeft); - km.bind(key("ctrl-right"), Action::CursorWordRight); - km.bind(key("alt-f"), Action::CursorWordRight); - km.bind(key("ctrl-f"), Action::CursorRight); - km.bind(key("home"), Action::CursorStart); - // ctrl-a → CursorStart only if prefix char is NOT 'a' - // (otherwise ctrl-a is already bound to EnterPrefixMode above) - if prefix_char != 'a' { - km.bind(key("ctrl-a"), Action::CursorStart); - } - km.bind(key("ctrl-e"), Action::CursorEnd); - km.bind(key("end"), Action::CursorEnd); - - // --- Editing --- - km.bind(key("ctrl-backspace"), Action::DeleteWordBefore); - km.bind(key("ctrl-h"), Action::DeleteCharBefore); - km.bind(key("ctrl-?"), Action::DeleteCharBefore); - km.bind(key("ctrl-delete"), Action::DeleteWordAfter); - km.bind(key("delete"), Action::DeleteCharAfter); - // ctrl-d: if input empty → return original, otherwise delete char - km.bind_conditional( - key("ctrl-d"), - vec![ - KeyRule::when(ConditionAtom::InputEmpty, Action::ReturnOriginal), - KeyRule::always(Action::DeleteCharAfter), - ], - ); - km.bind(key("ctrl-w"), Action::DeleteToWordBoundary); - km.bind(key("ctrl-u"), Action::ClearLine); - - // --- Search mode --- - km.bind(key("ctrl-r"), Action::CycleFilterMode); - km.bind(key("ctrl-s"), Action::CycleSearchMode); - - // --- Scroll (no exit) --- - km.bind(key("ctrl-n"), Action::SelectNext); - km.bind(key("ctrl-j"), Action::SelectNext); - km.bind(key("ctrl-p"), Action::SelectPrevious); - km.bind(key("ctrl-k"), Action::SelectPrevious); - - // --- Redraw --- - km.bind(key("ctrl-l"), Action::Redraw); - - // --- Page scroll --- - km.bind(key("pagedown"), Action::ScrollPageDown); - km.bind(key("pageup"), Action::ScrollPageUp); - - km -} - -// --------------------------------------------------------------------------- -// Vim Normal keymap -// --------------------------------------------------------------------------- - -/// Build the default vim-normal keymap. -pub(crate) fn default_vim_normal_keymap(settings: &Settings) -> Keymap { - let mut km = Keymap::new(); - add_common_bindings(&mut km); - - // esc / ctrl-[ → exit (vim-normal exits, unlike vim-insert) - km.bind(key("esc"), Action::Exit); - km.bind(key("ctrl-["), Action::Exit); - - // Prefix key - let prefix_char = settings.keys.prefix.chars().next().unwrap_or('a'); - km.bind(key(&format!("ctrl-{prefix_char}")), Action::EnterPrefixMode); - - // --- Vim navigation --- - // j/k: scroll with optional exit at boundary. - let scroll_exits = settings.keys.scroll_exits; - let invert = settings.invert; - bind_scroll_key(&mut km, "j", Action::SelectNext, !invert, scroll_exits); - bind_scroll_key(&mut km, "k", Action::SelectPrevious, invert, scroll_exits); - km.bind(key("h"), Action::CursorLeft); - km.bind(key("l"), Action::CursorRight); - - // --- Vim cursor movement --- - km.bind(key("0"), Action::CursorStart); - km.bind(key("$"), Action::CursorEnd); - km.bind(key("w"), Action::CursorWordRight); - km.bind(key("b"), Action::CursorWordLeft); - km.bind(key("e"), Action::CursorWordEnd); - - // --- Vim editing --- - km.bind(key("x"), Action::DeleteCharAfter); - km.bind(key("d d"), Action::ClearLine); - km.bind(key("D"), Action::ClearToEnd); - km.bind(key("C"), Action::VimChangeToEnd); - - // --- Mode switching --- - km.bind(key("?"), Action::VimSearchInsert); - km.bind(key("/"), Action::VimSearchInsert); - km.bind(key("a"), Action::VimEnterInsertAfter); - km.bind(key("A"), Action::VimEnterInsertAtEnd); - km.bind(key("i"), Action::VimEnterInsert); - km.bind(key("I"), Action::VimEnterInsertAtStart); - - // --- Numeric shortcuts (return selection without executing) --- - for n in 1..=9u8 { - km.bind(key(&n.to_string()), Action::ReturnSelectionNth(n)); - } - - // --- Half/full page scroll --- - km.bind(key("ctrl-u"), Action::ScrollHalfPageUp); - km.bind(key("ctrl-d"), Action::ScrollHalfPageDown); - km.bind(key("ctrl-b"), Action::ScrollPageUp); - km.bind(key("ctrl-f"), Action::ScrollPageDown); - - // --- Jump --- - km.bind(key("G"), Action::ScrollToBottom); - km.bind(key("g g"), Action::ScrollToTop); - km.bind(key("H"), Action::ScrollToScreenTop); - km.bind(key("M"), Action::ScrollToScreenMiddle); - km.bind(key("L"), Action::ScrollToScreenBottom); - - // --- Arrow keys (same as emacs for convenience) --- - bind_scroll_key(&mut km, "down", Action::SelectNext, !invert, scroll_exits); - bind_scroll_key(&mut km, "up", Action::SelectPrevious, invert, scroll_exits); - - // --- Page scroll --- - km.bind(key("pagedown"), Action::ScrollPageDown); - km.bind(key("pageup"), Action::ScrollPageUp); - - // --- Accept --- - let accept = accept_action(settings); - km.bind(key("enter"), accept); - - km -} - -// --------------------------------------------------------------------------- -// Vim Insert keymap -// --------------------------------------------------------------------------- - -/// Build the default vim-insert keymap. This clones the emacs keymap and -/// overlays vim-insert-specific bindings (esc → enter normal mode). -pub(crate) fn default_vim_insert_keymap(settings: &Settings) -> Keymap { - let mut km = default_emacs_keymap(settings); - - // Override esc and ctrl-[ to enter normal mode instead of exiting - km.bind(key("esc"), Action::VimEnterNormal); - km.bind(key("ctrl-["), Action::VimEnterNormal); - - km -} - -// --------------------------------------------------------------------------- -// Inspector keymap -// --------------------------------------------------------------------------- - -/// Build the default inspector keymap (tab index 1). -/// -/// The inspector shows details about the selected history item and has no -/// text input, so we build a minimal keymap with only inspector-relevant -/// bindings. We respect the user's `keymap_mode` to provide vim-style j/k -/// navigation for vim users. -pub(crate) fn default_inspector_keymap(settings: &Settings) -> Keymap { - use crate::atuin_client::settings::KeymapMode; - - let mut km = Keymap::new(); - - // Common bindings (same as search tab) - km.bind(key("ctrl-c"), Action::ReturnOriginal); - km.bind(key("ctrl-g"), Action::ReturnOriginal); - km.bind(key("esc"), Action::Exit); - km.bind(key("ctrl-["), Action::Exit); - km.bind(key("tab"), Action::ReturnSelection); - km.bind(key("ctrl-o"), Action::ToggleTab); - - // Accept behavior respects enter_accept setting - let accept = if settings.enter_accept { - Action::Accept - } else { - Action::ReturnSelection - }; - km.bind(key("enter"), accept); - - // Inspector-specific: delete history entry - km.bind(key("ctrl-d"), Action::Delete); - - // Inspector navigation - km.bind(key("up"), Action::InspectPrevious); - km.bind(key("down"), Action::InspectNext); - km.bind(key("pageup"), Action::InspectPrevious); - km.bind(key("pagedown"), Action::InspectNext); - - // For vim users, add j/k navigation - if matches!( - settings.keymap_mode, - KeymapMode::VimNormal | KeymapMode::VimInsert - ) { - km.bind(key("j"), Action::InspectNext); - km.bind(key("k"), Action::InspectPrevious); - } - - km -} - -// --------------------------------------------------------------------------- -// Prefix keymap -// --------------------------------------------------------------------------- - -/// Build the default prefix keymap (active after ctrl-a prefix). -pub(crate) fn default_prefix_keymap() -> Keymap { - let mut km = Keymap::new(); - - km.bind(key("d"), Action::Delete); - km.bind(key("D"), Action::DeleteAll); - km.bind(key("a"), Action::CursorStart); - km.bind_conditional( - key("c"), - vec![ - KeyRule::when(ConditionAtom::HasContext, Action::ClearContext), - KeyRule::always(Action::SwitchContext), - ], - ); - - km -} - -// --------------------------------------------------------------------------- -// KeymapSet construction -// --------------------------------------------------------------------------- - -// --------------------------------------------------------------------------- -// Config → Keymap conversion -// --------------------------------------------------------------------------- - -/// Convert a `KeyBindingConfig` (from TOML) into a `KeyBinding`. -/// Returns `Err` if an action name or condition expression is invalid. -fn parse_binding_config(config: &KeyBindingConfig) -> Result<KeyBinding, String> { - match config { - KeyBindingConfig::Simple(action_str) => { - let action = Action::from_str(action_str)?; - Ok(KeyBinding::simple(action)) - } - KeyBindingConfig::Rules(rules) => { - let mut parsed_rules = Vec::with_capacity(rules.len()); - for rule_cfg in rules { - let action = Action::from_str(&rule_cfg.action)?; - let rule = match &rule_cfg.when { - None => KeyRule::always(action), - Some(cond_str) => { - let cond = ConditionExpr::parse(cond_str)?; - KeyRule::when(cond, action) - } - }; - parsed_rules.push(rule); - } - Ok(KeyBinding::conditional(parsed_rules)) - } - } -} - -/// Apply a map of key-string → binding-config overrides to a keymap. -/// Per-key override replaces the entire rule list for that key. -/// Invalid keys or action names are logged and skipped. -fn apply_config_to_keymap(keymap: &mut Keymap, overrides: &HashMap<String, KeyBindingConfig>) { - for (key_str, binding_cfg) in overrides { - let key = match KeyInput::parse(key_str) { - Ok(k) => k, - Err(e) => { - warn!("invalid key in keymap config: {key_str:?}: {e}"); - continue; - } - }; - match parse_binding_config(binding_cfg) { - Ok(binding) => { - keymap.bindings.insert(key, binding); - } - Err(e) => { - warn!("invalid binding for {key_str:?} in keymap config: {e}"); - } - } - } -} - -impl KeymapSet { - /// Build the complete set of default keymaps from settings. - pub(crate) fn defaults(settings: &Settings) -> Self { - Self { - emacs: default_emacs_keymap(settings), - vim_normal: default_vim_normal_keymap(settings), - vim_insert: default_vim_insert_keymap(settings), - inspector: default_inspector_keymap(settings), - prefix: default_prefix_keymap(), - } - } - - /// Build keymaps from settings, applying any user `[keymap]` overrides. - /// - /// Precedence rules: - /// - If `[keymap]` has any entries, `[keys]` is **ignored entirely**. - /// Defaults are built with standard `[keys]` values, then `[keymap]` - /// overrides are applied per-key. - /// - If `[keymap]` is empty/absent, `[keys]` customizes the defaults - /// (current behavior for backward compatibility). - pub(crate) fn from_settings(settings: &Settings) -> Self { - use crate::atuin_client::settings::Keys; - - if settings.keymap.is_empty() { - // No [keymap] section → use [keys] to customize defaults - Self::defaults(settings) - } else { - // [keymap] present → ignore [keys], use standard defaults as base - let mut base_settings = settings.clone(); - base_settings.keys = Keys::standard_defaults(); - let mut set = Self::defaults(&base_settings); - set.apply_config(settings); - set - } - } - - /// Apply user keymap config overrides to all modes. - fn apply_config(&mut self, settings: &Settings) { - let config = &settings.keymap; - apply_config_to_keymap(&mut self.emacs, &config.emacs); - apply_config_to_keymap(&mut self.vim_normal, &config.vim_normal); - apply_config_to_keymap(&mut self.vim_insert, &config.vim_insert); - apply_config_to_keymap(&mut self.inspector, &config.inspector); - apply_config_to_keymap(&mut self.prefix, &config.prefix); - } -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::{ - Action, HashMap, KeymapSet, Settings, default_emacs_keymap, default_inspector_keymap, - default_prefix_keymap, default_vim_insert_keymap, default_vim_normal_keymap, key, - parse_binding_config, - }; - use crate::command::client::search::keybindings::conditions::EvalContext; - - fn make_ctx(cursor: usize, width: usize, selected: usize, len: usize) -> EvalContext { - EvalContext { - cursor_position: cursor, - input_width: width, - input_byte_len: width, - selected_index: selected, - results_len: len, - original_input_empty: false, - has_context: false, - } - } - - fn default_settings() -> Settings { - Settings::new().unwrap() - } - - // -- Emacs keymap tests -- - - #[test] - fn emacs_ctrl_c_returns_original() { - let km = default_emacs_keymap(&default_settings()); - let ctx = make_ctx(0, 0, 0, 10); - assert_eq!( - km.resolve(&key("ctrl-c"), &ctx), - Some(Action::ReturnOriginal) - ); - } - - #[test] - fn emacs_esc_exits() { - let km = default_emacs_keymap(&default_settings()); - let ctx = make_ctx(0, 0, 0, 10); - assert_eq!(km.resolve(&key("esc"), &ctx), Some(Action::Exit)); - } - - #[test] - fn emacs_tab_returns_selection() { - // enter_accept=false in test defaults → ReturnSelection - let km = default_emacs_keymap(&default_settings()); - let ctx = make_ctx(0, 0, 0, 10); - assert_eq!(km.resolve(&key("tab"), &ctx), Some(Action::ReturnSelection)); - } - - #[test] - fn emacs_enter_returns_selection() { - // enter_accept=false in test defaults → ReturnSelection - let km = default_emacs_keymap(&default_settings()); - let ctx = make_ctx(0, 0, 0, 10); - assert_eq!( - km.resolve(&key("enter"), &ctx), - Some(Action::ReturnSelection) - ); - } - - #[test] - fn emacs_enter_accept_true_uses_accept() { - let mut settings = default_settings(); - settings.enter_accept = true; - let km = default_emacs_keymap(&settings); - let ctx = make_ctx(0, 0, 0, 10); - assert_eq!(km.resolve(&key("enter"), &ctx), Some(Action::Accept)); - assert_eq!(km.resolve(&key("tab"), &ctx), Some(Action::ReturnSelection)); - } - - #[test] - fn emacs_right_at_end_returns_selection() { - let km = default_emacs_keymap(&default_settings()); - // cursor at end of "hello" (width 5) - let ctx = make_ctx(5, 5, 0, 10); - assert_eq!( - km.resolve(&key("right"), &ctx), - Some(Action::ReturnSelection) - ); - } - - #[test] - fn emacs_right_not_at_end_moves() { - let km = default_emacs_keymap(&default_settings()); - let ctx = make_ctx(2, 5, 0, 10); - assert_eq!(km.resolve(&key("right"), &ctx), Some(Action::CursorRight)); - } - - #[test] - fn emacs_left_at_start_exits() { - let km = default_emacs_keymap(&default_settings()); - let ctx = make_ctx(0, 5, 0, 10); - assert_eq!(km.resolve(&key("left"), &ctx), Some(Action::Exit)); - } - - #[test] - fn emacs_left_not_at_start_moves() { - let km = default_emacs_keymap(&default_settings()); - let ctx = make_ctx(3, 5, 0, 10); - assert_eq!(km.resolve(&key("left"), &ctx), Some(Action::CursorLeft)); - } - - #[test] - fn emacs_down_at_start_exits() { - let km = default_emacs_keymap(&default_settings()); - // selected=0 → ListAtStart → Exit - let ctx = make_ctx(0, 0, 0, 10); - assert_eq!(km.resolve(&key("down"), &ctx), Some(Action::Exit)); - } - - #[test] - fn emacs_down_not_at_start_selects_next() { - let km = default_emacs_keymap(&default_settings()); - // selected=5 → not at start → SelectNext - let ctx = make_ctx(0, 0, 5, 10); - assert_eq!(km.resolve(&key("down"), &ctx), Some(Action::SelectNext)); - } - - #[test] - fn emacs_up_selects_previous() { - let km = default_emacs_keymap(&default_settings()); - // Non-inverted: up never exits (moves away from index 0) - let ctx = make_ctx(0, 0, 5, 10); - assert_eq!(km.resolve(&key("up"), &ctx), Some(Action::SelectPrevious)); - } - - #[test] - fn emacs_ctrl_d_empty_returns_original() { - let km = default_emacs_keymap(&default_settings()); - // input empty (byte_len = 0) - let ctx = make_ctx(0, 0, 0, 10); - assert_eq!( - km.resolve(&key("ctrl-d"), &ctx), - Some(Action::ReturnOriginal) - ); - } - - #[test] - fn emacs_ctrl_d_nonempty_deletes() { - let km = default_emacs_keymap(&default_settings()); - let ctx = make_ctx(2, 5, 0, 10); - assert_eq!( - km.resolve(&key("ctrl-d"), &ctx), - Some(Action::DeleteCharAfter) - ); - } - - #[test] - fn emacs_ctrl_n_selects_next_no_exit_condition() { - let km = default_emacs_keymap(&default_settings()); - // at start, but ctrl-n should NOT exit (no exit condition bound) - let ctx = make_ctx(0, 0, 0, 10); - assert_eq!(km.resolve(&key("ctrl-n"), &ctx), Some(Action::SelectNext)); - } - - #[test] - fn emacs_prefix_key_enters_prefix() { - let km = default_emacs_keymap(&default_settings()); - let ctx = make_ctx(0, 0, 0, 10); - assert_eq!( - km.resolve(&key("ctrl-a"), &ctx), - Some(Action::EnterPrefixMode) - ); - } - - #[test] - fn emacs_home_cursor_start() { - let km = default_emacs_keymap(&default_settings()); - let ctx = make_ctx(5, 10, 0, 10); - assert_eq!(km.resolve(&key("home"), &ctx), Some(Action::CursorStart)); - } - - // -- Vim Normal keymap tests -- - - #[test] - fn vim_normal_j_at_start_exits() { - let km = default_vim_normal_keymap(&default_settings()); - // selected=0 → ListAtStart → Exit (non-inverted: j moves toward index 0) - let ctx = make_ctx(0, 0, 0, 10); - assert_eq!(km.resolve(&key("j"), &ctx), Some(Action::Exit)); - } - - #[test] - fn vim_normal_j_not_at_start_selects_next() { - let km = default_vim_normal_keymap(&default_settings()); - let ctx = make_ctx(0, 0, 5, 10); - assert_eq!(km.resolve(&key("j"), &ctx), Some(Action::SelectNext)); - } - - #[test] - fn vim_normal_k_selects_previous() { - let km = default_vim_normal_keymap(&default_settings()); - // Non-inverted: k never exits (moves away from index 0) - let ctx = make_ctx(0, 0, 5, 10); - assert_eq!(km.resolve(&key("k"), &ctx), Some(Action::SelectPrevious)); - } - - #[test] - fn vim_normal_i_enters_insert() { - let km = default_vim_normal_keymap(&default_settings()); - let ctx = make_ctx(0, 0, 0, 10); - assert_eq!(km.resolve(&key("i"), &ctx), Some(Action::VimEnterInsert)); - } - - #[test] - fn vim_normal_slash_search_insert() { - let km = default_vim_normal_keymap(&default_settings()); - let ctx = make_ctx(0, 0, 0, 10); - assert_eq!(km.resolve(&key("/"), &ctx), Some(Action::VimSearchInsert)); - } - - #[test] - fn vim_normal_gg_scroll_to_top() { - let km = default_vim_normal_keymap(&default_settings()); - let ctx = make_ctx(0, 0, 50, 100); - assert_eq!(km.resolve(&key("g g"), &ctx), Some(Action::ScrollToTop)); - } - - #[test] - fn vim_normal_big_g_scroll_to_bottom() { - let km = default_vim_normal_keymap(&default_settings()); - let ctx = make_ctx(0, 0, 50, 100); - assert_eq!(km.resolve(&key("G"), &ctx), Some(Action::ScrollToBottom)); - } - - #[test] - fn vim_normal_numeric_returns_selection() { - let km = default_vim_normal_keymap(&default_settings()); - let ctx = make_ctx(0, 0, 0, 10); - assert_eq!( - km.resolve(&key("3"), &ctx), - Some(Action::ReturnSelectionNth(3)) - ); - } - - #[test] - fn vim_normal_ctrl_u_half_page_up() { - let km = default_vim_normal_keymap(&default_settings()); - let ctx = make_ctx(0, 0, 50, 100); - assert_eq!( - km.resolve(&key("ctrl-u"), &ctx), - Some(Action::ScrollHalfPageUp) - ); - } - - #[test] - fn vim_normal_screen_jumps() { - let km = default_vim_normal_keymap(&default_settings()); - let ctx = make_ctx(0, 0, 50, 100); - assert_eq!(km.resolve(&key("H"), &ctx), Some(Action::ScrollToScreenTop)); - assert_eq!( - km.resolve(&key("M"), &ctx), - Some(Action::ScrollToScreenMiddle) - ); - assert_eq!( - km.resolve(&key("L"), &ctx), - Some(Action::ScrollToScreenBottom) - ); - } - - #[test] - fn vim_normal_enter_returns_selection() { - // enter_accept=false in test defaults → ReturnSelection - let km = default_vim_normal_keymap(&default_settings()); - let ctx = make_ctx(0, 0, 0, 10); - assert_eq!( - km.resolve(&key("enter"), &ctx), - Some(Action::ReturnSelection) - ); - } - - #[test] - fn vim_normal_enter_accept_true_uses_accept() { - let mut settings = default_settings(); - settings.enter_accept = true; - let km = default_vim_normal_keymap(&settings); - let ctx = make_ctx(0, 0, 0, 10); - assert_eq!(km.resolve(&key("enter"), &ctx), Some(Action::Accept)); - } - - // -- Vim Insert keymap tests -- - - #[test] - fn vim_insert_inherits_emacs_enter() { - let km = default_vim_insert_keymap(&default_settings()); - let ctx = make_ctx(0, 0, 0, 10); - // enter_accept=false → ReturnSelection - assert_eq!( - km.resolve(&key("enter"), &ctx), - Some(Action::ReturnSelection) - ); - } - - #[test] - fn vim_insert_esc_enters_normal() { - let km = default_vim_insert_keymap(&default_settings()); - let ctx = make_ctx(0, 0, 0, 10); - assert_eq!(km.resolve(&key("esc"), &ctx), Some(Action::VimEnterNormal)); - } - - #[test] - fn vim_insert_ctrl_bracket_enters_normal() { - let km = default_vim_insert_keymap(&default_settings()); - let ctx = make_ctx(0, 0, 0, 10); - assert_eq!( - km.resolve(&key("ctrl-["), &ctx), - Some(Action::VimEnterNormal) - ); - } - - #[test] - fn vim_insert_inherits_emacs_ctrl_d() { - let km = default_vim_insert_keymap(&default_settings()); - let ctx = make_ctx(0, 0, 0, 10); - // input empty → return original - assert_eq!( - km.resolve(&key("ctrl-d"), &ctx), - Some(Action::ReturnOriginal) - ); - } - - // -- Inspector keymap tests -- - - #[test] - fn inspector_ctrl_d_deletes() { - let km = default_inspector_keymap(&default_settings()); - let ctx = make_ctx(0, 0, 0, 10); - assert_eq!(km.resolve(&key("ctrl-d"), &ctx), Some(Action::Delete)); - } - - #[test] - fn inspector_up_inspects_previous() { - let km = default_inspector_keymap(&default_settings()); - let ctx = make_ctx(0, 0, 0, 10); - assert_eq!(km.resolve(&key("up"), &ctx), Some(Action::InspectPrevious)); - } - - #[test] - fn inspector_down_inspects_next() { - let km = default_inspector_keymap(&default_settings()); - let ctx = make_ctx(0, 0, 0, 10); - assert_eq!(km.resolve(&key("down"), &ctx), Some(Action::InspectNext)); - } - - #[test] - fn inspector_esc_exits() { - let km = default_inspector_keymap(&default_settings()); - let ctx = make_ctx(0, 0, 0, 10); - assert_eq!(km.resolve(&key("esc"), &ctx), Some(Action::Exit)); - } - - #[test] - fn inspector_tab_returns_selection() { - // enter_accept=false → ReturnSelection - let km = default_inspector_keymap(&default_settings()); - let ctx = make_ctx(0, 0, 0, 10); - assert_eq!(km.resolve(&key("tab"), &ctx), Some(Action::ReturnSelection)); - } - - // -- Prefix keymap tests -- - - #[test] - fn prefix_d_deletes() { - let km = default_prefix_keymap(); - let ctx = make_ctx(0, 0, 0, 10); - assert_eq!(km.resolve(&key("d"), &ctx), Some(Action::Delete)); - } - - #[test] - fn prefix_a_cursor_start() { - let km = default_prefix_keymap(); - let ctx = make_ctx(0, 0, 0, 10); - assert_eq!(km.resolve(&key("a"), &ctx), Some(Action::CursorStart)); - } - - #[test] - fn prefix_unknown_key_returns_none() { - let km = default_prefix_keymap(); - let ctx = make_ctx(0, 0, 0, 10); - assert_eq!(km.resolve(&key("x"), &ctx), None); - } - - // -- KeymapSet tests -- - - #[test] - fn keymap_set_defaults_builds() { - let settings = default_settings(); - let set = KeymapSet::defaults(&settings); - let ctx = make_ctx(0, 0, 0, 10); - - // Sanity check each keymap has bindings - assert!(set.emacs.resolve(&key("ctrl-c"), &ctx).is_some()); - assert!(set.vim_normal.resolve(&key("ctrl-c"), &ctx).is_some()); - assert!(set.vim_insert.resolve(&key("ctrl-c"), &ctx).is_some()); - assert!(set.inspector.resolve(&key("ctrl-c"), &ctx).is_some()); - assert!(set.prefix.resolve(&key("d"), &ctx).is_some()); - } - - // -- Settings-dependent behavior -- - - #[test] - fn custom_prefix_char() { - let mut settings = default_settings(); - settings.keys.prefix = "x".to_string(); - let km = default_emacs_keymap(&settings); - let ctx = make_ctx(0, 0, 0, 10); - - // ctrl-x should be prefix mode - assert_eq!( - km.resolve(&key("ctrl-x"), &ctx), - Some(Action::EnterPrefixMode) - ); - // ctrl-a should now be CursorStart (not prefix) - assert_eq!(km.resolve(&key("ctrl-a"), &ctx), Some(Action::CursorStart)); - } - - #[test] - fn ctrl_n_shortcuts_changes_numeric_modifier() { - let mut settings = default_settings(); - settings.ctrl_n_shortcuts = true; - let km = default_emacs_keymap(&settings); - let ctx = make_ctx(0, 0, 0, 10); - - // ctrl-1 should work - assert_eq!( - km.resolve(&key("ctrl-1"), &ctx), - Some(Action::ReturnSelectionNth(1)) - ); - // alt-1 should NOT be bound - assert_eq!(km.resolve(&key("alt-1"), &ctx), None); - } - - #[test] - fn default_alt_numeric_shortcuts() { - let settings = default_settings(); - let km = default_emacs_keymap(&settings); - let ctx = make_ctx(0, 0, 0, 10); - - // alt-1 should work by default - assert_eq!( - km.resolve(&key("alt-1"), &ctx), - Some(Action::ReturnSelectionNth(1)) - ); - } - - // ----------------------------------------------------------------------- - // Config parsing and merging tests - // ----------------------------------------------------------------------- - - #[test] - fn parse_simple_binding_config() { - use crate::atuin_client::settings::KeyBindingConfig; - let cfg = KeyBindingConfig::Simple("accept".to_string()); - let binding = parse_binding_config(&cfg).unwrap(); - assert_eq!(binding.rules.len(), 1); - assert!(binding.rules[0].condition.is_none()); - assert_eq!(binding.rules[0].action, Action::Accept); - } - - #[test] - fn parse_conditional_binding_config() { - use crate::atuin_client::settings::{KeyBindingConfig, KeyRuleConfig}; - let cfg = KeyBindingConfig::Rules(vec![ - KeyRuleConfig { - when: Some("cursor-at-start".to_string()), - action: "exit".to_string(), - }, - KeyRuleConfig { - when: None, - action: "cursor-left".to_string(), - }, - ]); - let binding = parse_binding_config(&cfg).unwrap(); - assert_eq!(binding.rules.len(), 2); - assert!(binding.rules[0].condition.is_some()); - assert_eq!(binding.rules[0].action, Action::Exit); - assert!(binding.rules[1].condition.is_none()); - assert_eq!(binding.rules[1].action, Action::CursorLeft); - } - - #[test] - fn parse_binding_config_invalid_action() { - use crate::atuin_client::settings::KeyBindingConfig; - let cfg = KeyBindingConfig::Simple("not-a-real-action".to_string()); - assert!(parse_binding_config(&cfg).is_err()); - } - - #[test] - fn parse_binding_config_invalid_condition() { - use crate::atuin_client::settings::{KeyBindingConfig, KeyRuleConfig}; - let cfg = KeyBindingConfig::Rules(vec![KeyRuleConfig { - when: Some("not-a-real-condition".to_string()), - action: "exit".to_string(), - }]); - assert!(parse_binding_config(&cfg).is_err()); - } - - #[test] - fn config_override_replaces_key() { - use crate::atuin_client::settings::KeyBindingConfig; - use std::collections::HashMap; - - let mut settings = default_settings(); - let set = KeymapSet::defaults(&settings); - - // Default: ctrl-c → ReturnOriginal - let ctx = make_ctx(0, 0, 0, 10); - assert_eq!( - set.emacs.resolve(&key("ctrl-c"), &ctx), - Some(Action::ReturnOriginal) - ); - - // Override ctrl-c → Exit via config - settings.keymap.emacs = HashMap::from([( - "ctrl-c".to_string(), - KeyBindingConfig::Simple("exit".to_string()), - )]); - - let set = KeymapSet::from_settings(&settings); - assert_eq!(set.emacs.resolve(&key("ctrl-c"), &ctx), Some(Action::Exit)); - } - - #[test] - fn config_override_preserves_unoverridden_keys() { - use crate::atuin_client::settings::KeyBindingConfig; - use std::collections::HashMap; - - let mut settings = default_settings(); - // Override only ctrl-c; enter should keep its default - settings.keymap.emacs = HashMap::from([( - "ctrl-c".to_string(), - KeyBindingConfig::Simple("exit".to_string()), - )]); - - let set = KeymapSet::from_settings(&settings); - let ctx = make_ctx(0, 0, 0, 10); - - // ctrl-c overridden - assert_eq!(set.emacs.resolve(&key("ctrl-c"), &ctx), Some(Action::Exit)); - // enter still has default (enter_accept=false → ReturnSelection) - assert_eq!( - set.emacs.resolve(&key("enter"), &ctx), - Some(Action::ReturnSelection) - ); - } - - #[test] - fn config_conditional_override() { - use crate::atuin_client::settings::{KeyBindingConfig, KeyRuleConfig}; - use std::collections::HashMap; - - let mut settings = default_settings(); - // Override "up" with a custom conditional - settings.keymap.emacs = HashMap::from([( - "up".to_string(), - KeyBindingConfig::Rules(vec![ - KeyRuleConfig { - when: Some("no-results".to_string()), - action: "exit".to_string(), - }, - KeyRuleConfig { - when: None, - action: "select-previous".to_string(), - }, - ]), - )]); - - let set = KeymapSet::from_settings(&settings); - - // With no results → exit - let ctx = make_ctx(0, 0, 0, 0); - assert_eq!(set.emacs.resolve(&key("up"), &ctx), Some(Action::Exit)); - - // With results → select-previous - let ctx = make_ctx(0, 0, 0, 10); - assert_eq!( - set.emacs.resolve(&key("up"), &ctx), - Some(Action::SelectPrevious) - ); - } - - #[test] - fn from_settings_with_empty_config_equals_defaults() { - let settings = default_settings(); - let defaults = KeymapSet::defaults(&settings); - let from_settings = KeymapSet::from_settings(&settings); - - // Verify a sample of keys produce the same results - let ctx = make_ctx(0, 0, 0, 10); - let test_keys = [ - "ctrl-c", "enter", "esc", "tab", "up", "down", "left", "right", - ]; - for k in &test_keys { - assert_eq!( - defaults.emacs.resolve(&key(k), &ctx), - from_settings.emacs.resolve(&key(k), &ctx), - "mismatch for emacs key {k}" - ); - } - } - - // ----------------------------------------------------------------------- - // Phase 5: [keys] vs [keymap] backward compatibility - // ----------------------------------------------------------------------- - - #[test] - fn keymap_overrides_ignore_keys_section() { - use crate::atuin_client::settings::KeyBindingConfig; - - // Set up: [keys] disables scroll_exits, but [keymap] is present - let mut settings = default_settings(); - settings.keys.scroll_exits = false; - - // Without [keymap], scroll_exits=false means no exit condition on down - let set_legacy = KeymapSet::defaults(&settings); - // At list-at-start (selected=0), down should still be SelectNext (no exit) - let ctx_at_boundary = make_ctx(0, 0, 0, 10); - assert_eq!( - set_legacy.emacs.resolve(&key("down"), &ctx_at_boundary), - Some(Action::SelectNext), - "legacy: down at boundary should be SelectNext with scroll_exits=false" - ); - - // With [keymap] present (even just one override), [keys] is ignored - // so the standard defaults (scroll_exits=true) apply - settings.keymap.emacs = HashMap::from([( - "ctrl-c".to_string(), - KeyBindingConfig::Simple("exit".to_string()), - )]); - let set_keymap = KeymapSet::from_settings(&settings); - - // Not at boundary (selected=5): should SelectNext normally - let ctx_not_at_boundary = make_ctx(0, 0, 5, 10); - assert_eq!( - set_keymap.emacs.resolve(&key("down"), &ctx_not_at_boundary), - Some(Action::SelectNext), - "keymap: down not at boundary should SelectNext" - ); - // At list-at-start (selected=0): should Exit (standard scroll_exits=true) - assert_eq!( - set_keymap.emacs.resolve(&key("down"), &ctx_at_boundary), - Some(Action::Exit), - "keymap: down at boundary should Exit (standard defaults restored)" - ); - } - - #[test] - fn keymap_present_resets_to_standard_keys_defaults() { - use crate::atuin_client::settings::KeyBindingConfig; - - let mut settings = default_settings(); - // Disable all [keys] behaviors - settings.keys.exit_past_line_start = false; - settings.keys.accept_past_line_end = false; - - // Without [keymap], left should be plain CursorLeft - let set_legacy = KeymapSet::defaults(&settings); - let ctx_at_start = make_ctx(0, 5, 0, 10); - assert_eq!( - set_legacy.emacs.resolve(&key("left"), &ctx_at_start), - Some(Action::CursorLeft), - "legacy: left should be plain CursorLeft without exit_past_line_start" - ); - - // Add a [keymap] entry (for a different key) - settings.keymap.emacs = HashMap::from([( - "ctrl-c".to_string(), - KeyBindingConfig::Simple("exit".to_string()), - )]); - let set_keymap = KeymapSet::from_settings(&settings); - - // Now left should use standard defaults (exit_past_line_start=true) - // At cursor start → Exit - assert_eq!( - set_keymap.emacs.resolve(&key("left"), &ctx_at_start), - Some(Action::Exit), - "keymap: left at cursor start should exit (standard defaults)" - ); - - // Right at cursor end should return selection (standard defaults: accept_past_line_end=true, enter_accept=false) - let ctx_at_end = make_ctx(5, 5, 0, 10); - assert_eq!( - set_keymap.emacs.resolve(&key("right"), &ctx_at_end), - Some(Action::ReturnSelection), - "keymap: right at cursor end should return selection (standard defaults)" - ); - } - - #[test] - fn original_input_empty_condition_in_config() { - use crate::atuin_client::settings::{KeyBindingConfig, KeyRuleConfig}; - use std::collections::HashMap; - - let mut settings = default_settings(); - // Configure esc to: if original-input-empty -> return-query, else return-original - settings.keymap.emacs = HashMap::from([( - "esc".to_string(), - KeyBindingConfig::Rules(vec![ - KeyRuleConfig { - when: Some("original-input-empty".to_string()), - action: "return-query".to_string(), - }, - KeyRuleConfig { - when: None, - action: "return-original".to_string(), - }, - ]), - )]); - - let set = KeymapSet::from_settings(&settings); - - // When original input was empty, should return-query - let ctx_original_empty = EvalContext { - cursor_position: 0, - input_width: 5, - input_byte_len: 5, - selected_index: 0, - results_len: 10, - original_input_empty: true, - has_context: false, - }; - assert_eq!( - set.emacs.resolve(&key("esc"), &ctx_original_empty), - Some(Action::ReturnQuery), - "esc with original_input_empty=true should return-query" - ); - - // When original input was not empty, should return-original - let ctx_original_not_empty = EvalContext { - cursor_position: 0, - input_width: 5, - input_byte_len: 5, - selected_index: 0, - results_len: 10, - original_input_empty: false, - has_context: false, - }; - assert_eq!( - set.emacs.resolve(&key("esc"), &ctx_original_not_empty), - Some(Action::ReturnOriginal), - "esc with original_input_empty=false should return-original" - ); - } -} diff --git a/crates/client/src/command/client/search/keybindings/key.rs b/crates/client/src/command/client/search/keybindings/key.rs deleted file mode 100644 index 5e772238..00000000 --- a/crates/client/src/command/client/search/keybindings/key.rs +++ /dev/null @@ -1,633 +0,0 @@ -use std::fmt; - -use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MediaKeyCode}; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; - -/// A single key press with modifiers (e.g. `ctrl-c`, `alt-f`, `enter`). -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -#[expect(clippy::struct_excessive_bools)] -pub(crate) struct SingleKey { - pub(crate) code: KeyCodeValue, - pub(crate) ctrl: bool, - pub(crate) alt: bool, - pub(crate) shift: bool, - pub(crate) super_key: bool, -} - -/// The key code portion of a key press. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -#[expect( - variant_size_differences, - reason = "It's not that much. So should be ok?" -)] -pub(crate) enum KeyCodeValue { - Char(char), - Enter, - Esc, - Tab, - Backspace, - Delete, - Insert, - Up, - Down, - Left, - Right, - Home, - End, - PageUp, - PageDown, - Space, - F(u8), - Media(MediaKeyCode), -} - -/// A key input that may be a single key or a multi-key sequence (e.g. `g g`). -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub(crate) enum KeyInput { - Single(SingleKey), - Sequence(Vec<SingleKey>), -} - -impl SingleKey { - /// Convert a crossterm `KeyEvent` into a `SingleKey`. - pub(crate) fn from_event(event: &KeyEvent) -> Option<Self> { - let ctrl = event.modifiers.contains(KeyModifiers::CONTROL); - let alt = event.modifiers.contains(KeyModifiers::ALT); - let shift = event.modifiers.contains(KeyModifiers::SHIFT); - let super_key = event.modifiers.contains(KeyModifiers::SUPER); - - let code = match event.code { - KeyCode::Char(' ') => KeyCodeValue::Space, - KeyCode::Char(c) => { - // If shift is the only modifier and it's an uppercase letter, - // we store the uppercase char directly and clear the shift flag - // since the case already encodes it. - if shift && !ctrl && !alt && !super_key && c.is_ascii_uppercase() { - return Some(Self { - code: KeyCodeValue::Char(c), - ctrl: false, - alt: false, - shift: false, - super_key: false, - }); - } - KeyCodeValue::Char(c) - } - KeyCode::Enter => KeyCodeValue::Enter, - KeyCode::Esc => KeyCodeValue::Esc, - KeyCode::Tab => KeyCodeValue::Tab, - // BackTab is sent by many terminals for Shift+Tab - KeyCode::BackTab => { - return Some(Self { - code: KeyCodeValue::Tab, - ctrl, - alt, - shift: true, - super_key, - }); - } - KeyCode::Backspace => KeyCodeValue::Backspace, - KeyCode::Delete => KeyCodeValue::Delete, - KeyCode::Insert => KeyCodeValue::Insert, - KeyCode::Up => KeyCodeValue::Up, - KeyCode::Down => KeyCodeValue::Down, - KeyCode::Left => KeyCodeValue::Left, - KeyCode::Right => KeyCodeValue::Right, - KeyCode::Home => KeyCodeValue::Home, - KeyCode::End => KeyCodeValue::End, - KeyCode::PageUp => KeyCodeValue::PageUp, - KeyCode::PageDown => KeyCodeValue::PageDown, - KeyCode::F(n) => KeyCodeValue::F(n), - KeyCode::Media(m) => KeyCodeValue::Media(m), - _ => return None, - }; - - Some(Self { - code, - ctrl, - alt, - shift: if matches!(code, KeyCodeValue::Char(_)) { - false - } else { - shift - }, - super_key, - }) - } - - /// Parse a key string like `"ctrl-c"`, `"alt-f"`, `"enter"`, `"G"`. - pub(crate) fn parse(s: &str) -> Result<Self, String> { - let s = s.trim(); - let parts: Vec<&str> = s.split('-').collect(); - - let mut ctrl = false; - let mut alt = false; - let mut shift = false; - let mut super_key = false; - - // All parts except the last are modifiers - for &part in &parts[..parts.len() - 1] { - match part.to_lowercase().as_str() { - "ctrl" => ctrl = true, - "alt" => alt = true, - "shift" => shift = true, - "super" | "cmd" | "win" => super_key = true, - _ => return Err(format!("unknown modifier: {part}")), - } - } - - let key_part = parts[parts.len() - 1]; - let code = match key_part.to_lowercase().as_str() { - "enter" | "return" => KeyCodeValue::Enter, - "esc" | "escape" => KeyCodeValue::Esc, - "tab" => KeyCodeValue::Tab, - "backspace" => KeyCodeValue::Backspace, - "delete" | "del" => KeyCodeValue::Delete, - "insert" | "ins" => KeyCodeValue::Insert, - "up" => KeyCodeValue::Up, - "down" => KeyCodeValue::Down, - "left" => KeyCodeValue::Left, - "right" => KeyCodeValue::Right, - "home" => KeyCodeValue::Home, - "end" => KeyCodeValue::End, - "pageup" => KeyCodeValue::PageUp, - "pagedown" => KeyCodeValue::PageDown, - "space" => KeyCodeValue::Space, - s if s.starts_with('f') && s.len() > 1 => { - // Parse function keys like "f1", "f12" - if let Ok(n) = s[1..].parse::<u8>() { - if (1..=24).contains(&n) { - KeyCodeValue::F(n) - } else { - return Err(format!("function key out of range: {key_part}")); - } - } else { - return Err(format!("unknown key: {key_part}")); - } - } - "[" => KeyCodeValue::Char('['), - "]" => KeyCodeValue::Char(']'), - "?" => KeyCodeValue::Char('?'), - "/" => KeyCodeValue::Char('/'), - "$" => KeyCodeValue::Char('$'), - // Media keys (no dashes - the parser splits on dash for modifiers) - "play" => KeyCodeValue::Media(MediaKeyCode::Play), - "pause" => KeyCodeValue::Media(MediaKeyCode::Pause), - "playpause" => KeyCodeValue::Media(MediaKeyCode::PlayPause), - "stop" => KeyCodeValue::Media(MediaKeyCode::Stop), - "fastforward" => KeyCodeValue::Media(MediaKeyCode::FastForward), - "rewind" => KeyCodeValue::Media(MediaKeyCode::Rewind), - "tracknext" => KeyCodeValue::Media(MediaKeyCode::TrackNext), - "trackprevious" => KeyCodeValue::Media(MediaKeyCode::TrackPrevious), - "record" => KeyCodeValue::Media(MediaKeyCode::Record), - "lowervolume" => KeyCodeValue::Media(MediaKeyCode::LowerVolume), - "raisevolume" => KeyCodeValue::Media(MediaKeyCode::RaiseVolume), - "mutevolume" | "mute" => KeyCodeValue::Media(MediaKeyCode::MuteVolume), - _ => { - let chars: Vec<char> = key_part.chars().collect(); - if chars.len() == 1 { - let c = chars[0]; - // An uppercase letter implies shift (unless shift already specified) - if c.is_ascii_uppercase() && !ctrl && !alt && !super_key { - return Ok(Self { - code: KeyCodeValue::Char(c), - ctrl: false, - alt: false, - shift: false, - super_key: false, - }); - } - KeyCodeValue::Char(c) - } else { - return Err(format!("unknown key: {key_part}")); - } - } - }; - - Ok(Self { - code, - ctrl, - alt, - shift, - super_key, - }) - } -} - -impl fmt::Display for SingleKey { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - if self.super_key { - write!(f, "super-")?; - } - if self.ctrl { - write!(f, "ctrl-")?; - } - if self.alt { - write!(f, "alt-")?; - } - if self.shift { - write!(f, "shift-")?; - } - match &self.code { - KeyCodeValue::Char(c) => write!(f, "{c}"), - KeyCodeValue::Enter => write!(f, "enter"), - KeyCodeValue::Esc => write!(f, "esc"), - KeyCodeValue::Tab => write!(f, "tab"), - KeyCodeValue::Backspace => write!(f, "backspace"), - KeyCodeValue::Delete => write!(f, "delete"), - KeyCodeValue::Insert => write!(f, "insert"), - KeyCodeValue::Up => write!(f, "up"), - KeyCodeValue::Down => write!(f, "down"), - KeyCodeValue::Left => write!(f, "left"), - KeyCodeValue::Right => write!(f, "right"), - KeyCodeValue::Home => write!(f, "home"), - KeyCodeValue::End => write!(f, "end"), - KeyCodeValue::PageUp => write!(f, "pageup"), - KeyCodeValue::PageDown => write!(f, "pagedown"), - KeyCodeValue::Space => write!(f, "space"), - KeyCodeValue::F(n) => write!(f, "f{n}"), - KeyCodeValue::Media(m) => match m { - MediaKeyCode::Play => write!(f, "play"), - MediaKeyCode::Pause => write!(f, "media-pause"), - MediaKeyCode::PlayPause => write!(f, "playpause"), - MediaKeyCode::Stop => write!(f, "stop"), - MediaKeyCode::FastForward => write!(f, "fastforward"), - MediaKeyCode::Rewind => write!(f, "rewind"), - MediaKeyCode::TrackNext => write!(f, "tracknext"), - MediaKeyCode::TrackPrevious => write!(f, "trackprevious"), - MediaKeyCode::Record => write!(f, "record"), - MediaKeyCode::LowerVolume => write!(f, "lowervolume"), - MediaKeyCode::RaiseVolume => write!(f, "raisevolume"), - MediaKeyCode::MuteVolume => write!(f, "mutevolume"), - MediaKeyCode::Reverse => write!(f, "reverse"), - }, - } - } -} - -impl KeyInput { - /// Parse a key input string. Supports multi-key sequences separated by spaces - /// (e.g. `"g g"`). - pub(crate) fn parse(s: &str) -> Result<Self, String> { - let s = s.trim(); - // Check for space-separated multi-key sequences - // But don't split "space" or modifier combos like "ctrl-a" - let parts: Vec<&str> = s.split_whitespace().collect(); - if parts.len() > 1 { - let keys: Result<Vec<SingleKey>, String> = - parts.iter().map(|p| SingleKey::parse(p)).collect(); - Ok(Self::Sequence(keys?)) - } else { - Ok(Self::Single(SingleKey::parse(s)?)) - } - } -} - -impl fmt::Display for KeyInput { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Single(k) => write!(f, "{k}"), - Self::Sequence(keys) => { - for (i, k) in keys.iter().enumerate() { - if i > 0 { - write!(f, " ")?; - } - write!(f, "{k}")?; - } - Ok(()) - } - } - } -} - -impl Serialize for KeyInput { - fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { - serializer.serialize_str(&self.to_string()) - } -} - -impl<'de> Deserialize<'de> for KeyInput { - fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { - let s = String::deserialize(deserializer)?; - Self::parse(&s).map_err(serde::de::Error::custom) - } -} - -#[cfg(test)] -mod tests { - use super::{KeyCodeValue, KeyInput, SingleKey}; - use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - - #[test] - fn parse_simple_keys() { - let k = SingleKey::parse("a").unwrap(); - assert_eq!(k.code, KeyCodeValue::Char('a')); - assert!(!k.ctrl && !k.alt && !k.shift); - - let k = SingleKey::parse("enter").unwrap(); - assert_eq!(k.code, KeyCodeValue::Enter); - - let k = SingleKey::parse("esc").unwrap(); - assert_eq!(k.code, KeyCodeValue::Esc); - - let k = SingleKey::parse("tab").unwrap(); - assert_eq!(k.code, KeyCodeValue::Tab); - - let k = SingleKey::parse("space").unwrap(); - assert_eq!(k.code, KeyCodeValue::Space); - } - - #[test] - fn parse_modifiers() { - let k = SingleKey::parse("ctrl-c").unwrap(); - assert_eq!(k.code, KeyCodeValue::Char('c')); - assert!(k.ctrl); - assert!(!k.alt); - - let k = SingleKey::parse("alt-f").unwrap(); - assert_eq!(k.code, KeyCodeValue::Char('f')); - assert!(k.alt); - assert!(!k.ctrl); - - let k = SingleKey::parse("ctrl-alt-x").unwrap(); - assert_eq!(k.code, KeyCodeValue::Char('x')); - assert!(k.ctrl && k.alt); - } - - #[test] - fn parse_uppercase_implies_no_shift_flag() { - let k = SingleKey::parse("G").unwrap(); - assert_eq!(k.code, KeyCodeValue::Char('G')); - assert!(!k.shift); - assert!(!k.ctrl); - } - - #[test] - fn parse_special_chars() { - let k = SingleKey::parse("ctrl-[").unwrap(); - assert_eq!(k.code, KeyCodeValue::Char('[')); - assert!(k.ctrl); - - let k = SingleKey::parse("?").unwrap(); - assert_eq!(k.code, KeyCodeValue::Char('?')); - - let k = SingleKey::parse("/").unwrap(); - assert_eq!(k.code, KeyCodeValue::Char('/')); - } - - #[test] - fn parse_multi_key_sequence() { - let ki = KeyInput::parse("g g").unwrap(); - match ki { - KeyInput::Sequence(keys) => { - assert_eq!(keys.len(), 2); - assert_eq!(keys[0].code, KeyCodeValue::Char('g')); - assert_eq!(keys[1].code, KeyCodeValue::Char('g')); - } - _ => panic!("expected sequence"), - } - } - - #[test] - fn display_round_trip() { - let cases = ["ctrl-c", "alt-f", "enter", "G", "tab", "pageup"]; - for s in cases { - let k = KeyInput::parse(s).unwrap(); - let display = k.to_string(); - let k2 = KeyInput::parse(&display).unwrap(); - assert_eq!(k, k2, "round-trip failed for {s}"); - } - - let ki = KeyInput::parse("g g").unwrap(); - assert_eq!(ki.to_string(), "g g"); - } - - #[test] - fn from_event_basic() { - let event = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); - let k = SingleKey::from_event(&event).unwrap(); - assert_eq!(k.code, KeyCodeValue::Char('c')); - assert!(k.ctrl); - assert!(!k.alt); - - let event = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE); - let k = SingleKey::from_event(&event).unwrap(); - assert_eq!(k.code, KeyCodeValue::Enter); - } - - #[test] - fn from_event_uppercase() { - // Crossterm sends uppercase chars with SHIFT modifier - let event = KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT); - let k = SingleKey::from_event(&event).unwrap(); - assert_eq!(k.code, KeyCodeValue::Char('G')); - // shift flag should be cleared since the case encodes it - assert!(!k.shift); - } - - #[test] - fn from_event_matches_parsed() { - // Verify that from_event and parse produce the same SingleKey - let event = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); - let from_event = SingleKey::from_event(&event).unwrap(); - let parsed = SingleKey::parse("ctrl-c").unwrap(); - assert_eq!(from_event, parsed); - - let event = KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT); - let from_event = SingleKey::from_event(&event).unwrap(); - let parsed = SingleKey::parse("G").unwrap(); - assert_eq!(from_event, parsed); - } - - #[test] - fn parse_super_modifier() { - let k = SingleKey::parse("super-a").unwrap(); - assert_eq!(k.code, KeyCodeValue::Char('a')); - assert!(k.super_key); - assert!(!k.ctrl && !k.alt && !k.shift); - - // "cmd" is an alias for "super" - let k2 = SingleKey::parse("cmd-a").unwrap(); - assert_eq!(k, k2); - - // "win" is an alias for "super" - let k3 = SingleKey::parse("win-a").unwrap(); - assert_eq!(k, k3); - } - - #[test] - fn parse_super_with_other_modifiers() { - let k = SingleKey::parse("super-ctrl-c").unwrap(); - assert_eq!(k.code, KeyCodeValue::Char('c')); - assert!(k.super_key && k.ctrl); - assert!(!k.alt && !k.shift); - } - - #[test] - fn display_super_modifier() { - let k = SingleKey::parse("super-a").unwrap(); - assert_eq!(k.to_string(), "super-a"); - - let k = SingleKey::parse("super-ctrl-x").unwrap(); - assert_eq!(k.to_string(), "super-ctrl-x"); - } - - #[test] - fn display_round_trip_super() { - let k = KeyInput::parse("super-a").unwrap(); - let display = k.to_string(); - let k2 = KeyInput::parse(&display).unwrap(); - assert_eq!(k, k2, "round-trip failed for super-a"); - } - - #[test] - fn from_event_super() { - let event = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SUPER); - let k = SingleKey::from_event(&event).unwrap(); - assert_eq!(k.code, KeyCodeValue::Char('a')); - assert!(k.super_key); - assert!(!k.ctrl && !k.alt && !k.shift); - } - - #[test] - fn from_event_super_matches_parsed() { - let event = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SUPER); - let from_event = SingleKey::from_event(&event).unwrap(); - let parsed = SingleKey::parse("super-a").unwrap(); - assert_eq!(from_event, parsed); - } - - #[test] - fn super_uppercase_preserves_super() { - // super-G should keep the super flag (unlike bare "G" which clears shift) - let k = SingleKey::parse("super-G").unwrap(); - assert_eq!(k.code, KeyCodeValue::Char('G')); - assert!(k.super_key); - } - - #[test] - fn parse_errors() { - assert!(SingleKey::parse("ctrl-alt-shift-xxx").is_err()); - assert!(SingleKey::parse("foobar-a").is_err()); - } - - #[test] - fn parse_function_keys() { - let k = SingleKey::parse("f1").unwrap(); - assert_eq!(k.code, KeyCodeValue::F(1)); - assert!(!k.ctrl && !k.alt && !k.shift); - - let k = SingleKey::parse("F12").unwrap(); - assert_eq!(k.code, KeyCodeValue::F(12)); - - let k = SingleKey::parse("ctrl-f5").unwrap(); - assert_eq!(k.code, KeyCodeValue::F(5)); - assert!(k.ctrl); - - // F24 is valid (some keyboards have extended function keys) - let k = SingleKey::parse("f24").unwrap(); - assert_eq!(k.code, KeyCodeValue::F(24)); - - // F0 and F25+ are invalid - assert!(SingleKey::parse("f0").is_err()); - assert!(SingleKey::parse("f25").is_err()); - } - - #[test] - fn from_event_function_keys() { - let event = KeyEvent::new(KeyCode::F(1), KeyModifiers::NONE); - let k = SingleKey::from_event(&event).unwrap(); - assert_eq!(k.code, KeyCodeValue::F(1)); - - let event = KeyEvent::new(KeyCode::F(12), KeyModifiers::CONTROL); - let k = SingleKey::from_event(&event).unwrap(); - assert_eq!(k.code, KeyCodeValue::F(12)); - assert!(k.ctrl); - } - - #[test] - fn display_function_keys() { - let k = SingleKey::parse("f1").unwrap(); - assert_eq!(k.to_string(), "f1"); - - let k = SingleKey::parse("ctrl-f12").unwrap(); - assert_eq!(k.to_string(), "ctrl-f12"); - } - - #[test] - fn function_key_round_trip() { - let cases = ["f1", "f12", "ctrl-f5", "alt-f10"]; - for s in cases { - let k = KeyInput::parse(s).unwrap(); - let display = k.to_string(); - let k2 = KeyInput::parse(&display).unwrap(); - assert_eq!(k, k2, "round-trip failed for {s}"); - } - } - - #[test] - fn from_event_function_key_matches_parsed() { - let event = KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE); - let from_event = SingleKey::from_event(&event).unwrap(); - let parsed = SingleKey::parse("f12").unwrap(); - assert_eq!(from_event, parsed); - } - - #[test] - fn from_event_backtab_becomes_shift_tab() { - // Many terminals send BackTab for Shift+Tab - let event = KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE); - let k = SingleKey::from_event(&event).unwrap(); - assert_eq!(k.code, KeyCodeValue::Tab); - assert!(k.shift); - assert!(!k.ctrl && !k.alt); - } - - #[test] - fn from_event_backtab_matches_parsed_shift_tab() { - let event = KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE); - let from_event = SingleKey::from_event(&event).unwrap(); - let parsed = SingleKey::parse("shift-tab").unwrap(); - assert_eq!(from_event, parsed); - } - - #[test] - fn from_event_backtab_with_ctrl() { - // BackTab with ctrl modifier - let event = KeyEvent::new(KeyCode::BackTab, KeyModifiers::CONTROL); - let k = SingleKey::from_event(&event).unwrap(); - assert_eq!(k.code, KeyCodeValue::Tab); - assert!(k.shift); - assert!(k.ctrl); - } - - #[test] - fn parse_insert_key() { - let k = SingleKey::parse("insert").unwrap(); - assert_eq!(k.code, KeyCodeValue::Insert); - assert!(!k.ctrl && !k.alt && !k.shift); - - let k = SingleKey::parse("ins").unwrap(); - assert_eq!(k.code, KeyCodeValue::Insert); - - let k = SingleKey::parse("ctrl-insert").unwrap(); - assert_eq!(k.code, KeyCodeValue::Insert); - assert!(k.ctrl); - } - - #[test] - fn from_event_insert_key() { - let event = KeyEvent::new(KeyCode::Insert, KeyModifiers::NONE); - let k = SingleKey::from_event(&event).unwrap(); - assert_eq!(k.code, KeyCodeValue::Insert); - } - - #[test] - fn insert_key_round_trip() { - let k = KeyInput::parse("insert").unwrap(); - let display = k.to_string(); - assert_eq!(display, "insert"); - let k2 = KeyInput::parse(&display).unwrap(); - assert_eq!(k, k2); - } -} diff --git a/crates/client/src/command/client/search/keybindings/keymap.rs b/crates/client/src/command/client/search/keybindings/keymap.rs deleted file mode 100644 index 067d9403..00000000 --- a/crates/client/src/command/client/search/keybindings/keymap.rs +++ /dev/null @@ -1,233 +0,0 @@ -use std::collections::HashMap; - -use super::actions::Action; -use super::conditions::{ConditionExpr, EvalContext}; -use super::key::{KeyInput, SingleKey}; - -/// A single rule within a keybinding: an optional condition and an action. -/// If the condition is `None`, the rule always matches. -#[derive(Debug, Clone)] -pub(crate) struct KeyRule { - pub(crate) condition: Option<ConditionExpr>, - pub(crate) action: Action, -} - -/// A keybinding is an ordered list of rules. The first rule whose condition -/// matches (or has no condition) wins. -#[derive(Debug, Clone)] -pub(crate) struct KeyBinding { - pub(crate) rules: Vec<KeyRule>, -} - -/// A keymap is a collection of keybindings indexed by key input. -#[derive(Debug, Clone)] -pub(crate) struct Keymap { - pub(crate) bindings: HashMap<KeyInput, KeyBinding>, -} - -impl KeyRule { - /// Create an unconditional rule. - pub(crate) fn always(action: Action) -> Self { - Self { - condition: None, - action, - } - } - - /// Create a conditional rule. Accepts any type convertible to `ConditionExpr`, - /// including bare `ConditionAtom` values. - pub(crate) fn when(condition: impl Into<ConditionExpr>, action: Action) -> Self { - Self { - condition: Some(condition.into()), - action, - } - } -} - -impl KeyBinding { - /// Create a simple (unconditional) binding. - pub(crate) fn simple(action: Action) -> Self { - Self { - rules: vec![KeyRule::always(action)], - } - } - - /// Create a conditional binding from a list of rules. - pub(crate) fn conditional(rules: Vec<KeyRule>) -> Self { - Self { rules } - } -} - -impl Keymap { - /// Create an empty keymap. - pub(crate) fn new() -> Self { - Self { - bindings: HashMap::new(), - } - } - - /// Bind a key input to a simple (unconditional) action. - pub(crate) fn bind(&mut self, key: KeyInput, action: Action) { - self.bindings.insert(key, KeyBinding::simple(action)); - } - - /// Bind a key input to a conditional set of rules. - pub(crate) fn bind_conditional(&mut self, key: KeyInput, rules: Vec<KeyRule>) { - self.bindings.insert(key, KeyBinding::conditional(rules)); - } - - /// Resolve a key input to an action given the current evaluation context. - /// Returns `None` if the key has no binding or no rule's condition matches. - pub(crate) fn resolve(&self, key: &KeyInput, ctx: &EvalContext) -> Option<Action> { - let binding = self.bindings.get(key)?; - for rule in &binding.rules { - match &rule.condition { - None => return Some(rule.action.clone()), - Some(cond) if cond.evaluate(ctx) => return Some(rule.action.clone()), - Some(_) => {} - } - } - None - } - - /// Check if any binding starts with the given single key as the first key - /// of a multi-key sequence. Used to detect pending multi-key sequences. - pub(crate) fn has_sequence_starting_with(&self, prefix: &SingleKey) -> bool { - self.bindings.keys().any(|ki| match ki { - KeyInput::Sequence(keys) => keys.first() == Some(prefix), - KeyInput::Single(_) => false, - }) - } - - /// Merge another keymap into this one. Keys from `other` override keys in `self`. - #[expect(dead_code)] - pub(crate) fn merge(&mut self, other: &Self) { - for (key, binding) in &other.bindings { - self.bindings.insert(key.clone(), binding.clone()); - } - } -} - -impl Default for Keymap { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::super::conditions::ConditionAtom; - use super::{Action, EvalContext, KeyInput, KeyRule, Keymap, SingleKey}; - - fn make_ctx(cursor: usize, width: usize, selected: usize, len: usize) -> EvalContext { - EvalContext { - cursor_position: cursor, - input_width: width, - input_byte_len: width, - selected_index: selected, - results_len: len, - original_input_empty: false, - has_context: false, - } - } - - #[test] - fn simple_binding_resolves() { - let mut keymap = Keymap::new(); - let key = KeyInput::parse("ctrl-c").unwrap(); - keymap.bind(key.clone(), Action::ReturnOriginal); - - let ctx = make_ctx(0, 0, 0, 10); - assert_eq!(keymap.resolve(&key, &ctx), Some(Action::ReturnOriginal)); - } - - #[test] - fn conditional_first_match_wins() { - let mut keymap = Keymap::new(); - let key = KeyInput::parse("left").unwrap(); - keymap.bind_conditional( - key.clone(), - vec![ - KeyRule::when(ConditionAtom::CursorAtStart, Action::Exit), - KeyRule::always(Action::CursorLeft), - ], - ); - - // Cursor at start → Exit - let ctx = make_ctx(0, 5, 0, 10); - assert_eq!(keymap.resolve(&key, &ctx), Some(Action::Exit)); - - // Cursor not at start → CursorLeft - let ctx = make_ctx(3, 5, 0, 10); - assert_eq!(keymap.resolve(&key, &ctx), Some(Action::CursorLeft)); - } - - #[test] - fn no_match_returns_none() { - let keymap = Keymap::new(); - let key = KeyInput::parse("ctrl-c").unwrap(); - let ctx = make_ctx(0, 0, 0, 0); - assert_eq!(keymap.resolve(&key, &ctx), None); - } - - #[test] - fn conditional_no_condition_matches_returns_none() { - let mut keymap = Keymap::new(); - let key = KeyInput::parse("left").unwrap(); - // Only one rule with a condition that won't match - keymap.bind_conditional( - key.clone(), - vec![KeyRule::when(ConditionAtom::CursorAtStart, Action::Exit)], - ); - - // Cursor not at start → no match - let ctx = make_ctx(3, 5, 0, 10); - assert_eq!(keymap.resolve(&key, &ctx), None); - } - - #[test] - fn has_sequence_starting_with() { - let mut keymap = Keymap::new(); - let seq = KeyInput::parse("g g").unwrap(); - keymap.bind(seq, Action::ScrollToTop); - - let g = SingleKey::parse("g").unwrap(); - assert!(keymap.has_sequence_starting_with(&g)); - - let h = SingleKey::parse("h").unwrap(); - assert!(!keymap.has_sequence_starting_with(&h)); - } - - #[test] - fn merge_overrides() { - let mut base = Keymap::new(); - let key = KeyInput::parse("ctrl-c").unwrap(); - base.bind(key.clone(), Action::ReturnOriginal); - - let mut overlay = Keymap::new(); - overlay.bind(key.clone(), Action::Exit); - - base.merge(&overlay); - - let ctx = make_ctx(0, 0, 0, 0); - assert_eq!(base.resolve(&key, &ctx), Some(Action::Exit)); - } - - #[test] - fn merge_preserves_unoverridden() { - let mut base = Keymap::new(); - let key1 = KeyInput::parse("ctrl-c").unwrap(); - let key2 = KeyInput::parse("ctrl-d").unwrap(); - base.bind(key1.clone(), Action::ReturnOriginal); - base.bind(key2.clone(), Action::DeleteCharAfter); - - let mut overlay = Keymap::new(); - overlay.bind(key1.clone(), Action::Exit); - - base.merge(&overlay); - - let ctx = make_ctx(0, 0, 0, 0); - assert_eq!(base.resolve(&key1, &ctx), Some(Action::Exit)); - assert_eq!(base.resolve(&key2, &ctx), Some(Action::DeleteCharAfter)); - } -} diff --git a/crates/client/src/command/client/search/keybindings/mod.rs b/crates/client/src/command/client/search/keybindings/mod.rs deleted file mode 100644 index cdca0406..00000000 --- a/crates/client/src/command/client/search/keybindings/mod.rs +++ /dev/null @@ -1,14 +0,0 @@ -pub(crate) mod actions; -pub(crate) mod conditions; -pub(crate) mod defaults; -pub(crate) mod key; -pub(crate) mod keymap; - -pub(crate) use actions::Action; -#[expect(unused_imports)] -pub(crate) use conditions::{ConditionAtom, ConditionExpr, EvalContext}; -pub(crate) use defaults::KeymapSet; -#[expect(unused_imports)] -pub(crate) use key::{KeyCodeValue, KeyInput, SingleKey}; -#[expect(unused_imports)] -pub(crate) use keymap::{KeyBinding, KeyRule, Keymap}; diff --git a/crates/client/src/command/client/stats.rs b/crates/client/src/command/client/stats.rs index 9ea5e283..9b8ebdff 100644 --- a/crates/client/src/command/client/stats.rs +++ b/crates/client/src/command/client/stats.rs @@ -3,8 +3,7 @@ use eyre::Result; use interim::parse_date_string; use time::{Duration, OffsetDateTime, Time}; -use crate::atuin_client::database::ClientSqlite; -use crate::atuin_client::{database::current_context, settings::Settings}; +use crate::atuin_client::settings::Settings; use crate::atuin_history::stats::{compute, pretty_print}; @@ -36,7 +35,7 @@ pub(crate) struct Cmd { } impl Cmd { - pub(crate) async fn run(&self, db: &ClientSqlite, settings: &Settings) -> Result<()> { + pub(crate) async fn run(&self, settings: &Settings) -> Result<()> { let context = current_context().await?; let words = if self.period.is_empty() { String::from("all") diff --git a/crates/client/src/shell/.gitattributes b/crates/client/src/shell/.gitattributes deleted file mode 100644 index fae8897c..00000000 --- a/crates/client/src/shell/.gitattributes +++ /dev/null @@ -1 +0,0 @@ -* eol=lf diff --git a/crates/client/src/shell/atuin.bash b/crates/client/src/shell/atuin.bash deleted file mode 100644 index 703e8fe2..00000000 --- a/crates/client/src/shell/atuin.bash +++ /dev/null @@ -1,672 +0,0 @@ -# Include guard -if [[ ${__atuin_initialized-} == true ]]; then - false -elif [[ $- != *i* ]]; then - # Enable only in interactive shells - false -elif ((BASH_VERSINFO[0] < 3 || BASH_VERSINFO[0] == 3 && BASH_VERSINFO[1] < 1)); then - # Require bash >= 3.1 - [[ -t 2 ]] && printf 'atuin: requires bash >= 3.1 for the integration.\n' >&2 - false -else # (include guard) beginning of main content - #------------------------------------------------------------------------------ - __atuin_initialized=true - - if [[ -z "${ATUIN_SESSION:-}" || "${ATUIN_SHLVL:-}" != "$SHLVL" ]]; then - ATUIN_SESSION=$(atuin uuid) - export ATUIN_SESSION - export ATUIN_SHLVL=$SHLVL - fi - ATUIN_STTY=$(stty -g) - ATUIN_HISTORY_ID="" - - __atuin_osc133_command_executed() { - [[ -n "${ATUIN_PTY_PROXY_ACTIVE:-}" ]] || return - [[ -n "${ATUIN_HISTORY_ID:-}" && "$ATUIN_HISTORY_ID" != "__bash_preexec_failure__" ]] || return - - printf '\033]133;C\a' - } - - __atuin_osc133_command_finished() { - [[ -n "${ATUIN_PTY_PROXY_ACTIVE:-}" ]] || return - [[ -n "${ATUIN_HISTORY_ID:-}" && "$ATUIN_HISTORY_ID" != "__bash_preexec_failure__" ]] || return - - printf '\033]133;D;%s;history_id=%s;session_id=%s\a' "$1" "$ATUIN_HISTORY_ID" "${ATUIN_SESSION:-}" - } - - __atuin_osc133_prompt_start=$'\001\033]133;A;cl=line\a\002' - __atuin_osc133_prompt_end=$'\001\033]133;B\a\002' - - __atuin_osc133_wrap_prompt() { - local __atuin_prompt="${PS1-}" - __atuin_prompt="${__atuin_prompt//$__atuin_osc133_prompt_start/}" - __atuin_prompt="${__atuin_prompt//$__atuin_osc133_prompt_end/}" - - if [[ -n "${ATUIN_PTY_PROXY_ACTIVE:-}" ]]; then - PS1="${__atuin_osc133_prompt_start}${__atuin_prompt}${__atuin_osc133_prompt_end}" - else - PS1="$__atuin_prompt" - fi - } - - export ATUIN_PREEXEC_BACKEND=$SHLVL:none - __atuin_update_preexec_backend() { - if [[ ${BLE_ATTACHED-} ]]; then - ATUIN_PREEXEC_BACKEND=$SHLVL:blesh-${BLE_VERSION-} - elif [[ ${bash_preexec_imported-} ]]; then - ATUIN_PREEXEC_BACKEND=$SHLVL:bash-preexec - elif [[ ${__bp_imported-} ]]; then - ATUIN_PREEXEC_BACKEND="$SHLVL:bash-preexec (old)" - else - ATUIN_PREEXEC_BACKEND=$SHLVL:unknown - fi - } - - __atuin_preexec() { - # Workaround for old versions of bash-preexec - if [[ ! ${BLE_ATTACHED-} ]]; then - # In older versions of bash-preexec, the preexec hook may be called - # even for the commands run by keybindings. There is no general and - # robust way to detect the command for keybindings, but at least we - # want to exclude Atuin's keybindings. When the preexec hook is called - # for a keybinding, the preexec hook for the user command will not - # fire, so we instead set a fake ATUIN_HISTORY_ID here to notify - # __atuin_precmd of this failure. - if [[ $BASH_COMMAND != "$1" ]]; then - case $BASH_COMMAND in - '__atuin_history'* | '__atuin_widget_run'* | '__atuin_bash42_dispatch'*) - ATUIN_HISTORY_ID=__bash_preexec_failure__ - return 0 - ;; - esac - fi - fi - - # Note: We update ATUIN_PREEXEC_BACKEND on every preexec because blesh's - # attaching state can dynamically change. - __atuin_update_preexec_backend - - local id - id=$(atuin history start -- "$1" 2>/dev/null) - export ATUIN_HISTORY_ID=$id - [[ -n ${__atuin_skip_osc133:-} ]] || __atuin_osc133_command_executed - __atuin_preexec_time=${EPOCHREALTIME-} - } - - __atuin_precmd() { - local EXIT=$? __atuin_precmd_time=${EPOCHREALTIME-} - - __atuin_osc133_wrap_prompt - - [[ ! $ATUIN_HISTORY_ID ]] && return - - # If the previous preexec hook failed, we manually call __atuin_preexec - local __atuin_skip_osc133="" - if [[ $ATUIN_HISTORY_ID == __bash_preexec_failure__ ]]; then - # This is the command extraction code taken from bash-preexec - local previous_command - previous_command=$( - export LC_ALL=C HISTTIMEFORMAT='' - builtin history 1 | sed '1 s/^ *[0-9][0-9]*[* ] //' - ) - __atuin_skip_osc133=1 - __atuin_preexec "$previous_command" - fi - - local duration="" - # shellcheck disable=SC2154,SC2309 - if [[ ${BLE_ATTACHED-} && ${_ble_exec_time_ata-} ]]; then - # With ble.sh, we utilize the shell variable `_ble_exec_time_ata` - # recorded by ble.sh. It is more accurate than the measurements by - # Atuin, which includes the spawn cost of Atuin. ble.sh uses the - # special shell variable `EPOCHREALTIME` in bash >= 5.0 with the - # microsecond resolution, or the builtin `time` in bash < 5.0 with the - # millisecond resolution. - duration=${_ble_exec_time_ata}000 - elif ((BASH_VERSINFO[0] >= 5)); then - # We calculate the high-resolution duration based on EPOCHREALTIME - # (bash >= 5.0) recorded by precmd/preexec, though it might not be as - # accurate as `_ble_exec_time_ata` provided by ble.sh because it - # includes the extra time of the precmd/preexec handling. Since Bash - # does not offer floating-point arithmetic, we remove the non-digit - # characters and perform the integral arithmetic. The fraction part of - # EPOCHREALTIME is fixed to have 6 digits in Bash. We remove all the - # non-digit characters because the decimal point is not necessarily a - # period depending on the locale. - duration=$((${__atuin_precmd_time//[!0-9]/} - ${__atuin_preexec_time//[!0-9]/})) - if ((duration >= 0)); then - duration=${duration}000 - else - duration="" # clear the result on overflow - fi - fi - - [[ -n ${__atuin_skip_osc133:-} ]] || __atuin_osc133_command_finished "$EXIT" - (ATUIN_LOG=error atuin history end --exit "$EXIT" ${duration:+"--duration=$duration"} -- "$ATUIN_HISTORY_ID" &) >/dev/null 2>&1 - export ATUIN_HISTORY_ID="" - } - - __atuin_set_ret_value() { - return ${1:+"$1"} - } - - #------------------------------------------------------------------------------ - # section: __atuin_accept_line - # - # The function "__atuin_accept_line" is kept for backward compatibility of the - # direct use of __atuin_history in keybindings by users. - - # The shell function `__atuin_evaluate_prompt` evaluates prompt sequences in - # $PS1. We switch the implementation of the shell function - # `__atuin_evaluate_prompt` based on the Bash version because the expansion - # ${PS1@P} is only available in bash >= 4.4. - if ((BASH_VERSINFO[0] >= 5 || BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 4)); then - __atuin_evaluate_prompt() { - __atuin_set_ret_value "${__bp_last_ret_value-}" "${__bp_last_argument_prev_command-}" - __atuin_prompt=${PS1@P} - - # Note: Strip the control characters ^A (\001) and ^B (\002), which - # Bash internally uses to enclose the escape sequences. They are - # produced by '\[' and '\]', respectively, in $PS1 and used to tell - # Bash that the strings inbetween do not contribute to the prompt - # width. After the prompt width calculation, Bash strips those control - # characters before outputting it to the terminal. We here strip these - # characters following Bash's behavior. - __atuin_prompt=${__atuin_prompt//[$'\001\002']/} - - # Count the number of newlines contained in $__atuin_prompt - __atuin_prompt_offset=${__atuin_prompt//[!$'\n']/} - __atuin_prompt_offset=${#__atuin_prompt_offset} - } - else - __atuin_evaluate_prompt() { - __atuin_prompt='$ ' - __atuin_prompt_offset=0 - } - fi - - # The shell function `__atuin_clear_prompt N` outputs terminal control - # sequences to clear the contents of the current and N previous lines. After - # clearing, the cursor is placed at the beginning of the N-th previous line. - __atuin_clear_prompt_cache=() - __atuin_clear_prompt() { - local offset=$1 - if [[ ! ${__atuin_clear_prompt_cache[offset]+set} ]]; then - if [[ ! ${__atuin_clear_prompt_cache[0]+set} ]]; then - __atuin_clear_prompt_cache[0]=$'\r'$(tput el 2>/dev/null || tput ce 2>/dev/null) - fi - if ((offset > 0)); then - __atuin_clear_prompt_cache[offset]=${__atuin_clear_prompt_cache[0]}$( - tput cuu "$offset" 2>/dev/null || tput UP "$offset" 2>/dev/null - tput dl "$offset" 2>/dev/null || tput DL "$offset" 2>/dev/null - tput il "$offset" 2>/dev/null || tput AL "$offset" 2>/dev/null - ) - fi - fi - printf '%s' "${__atuin_clear_prompt_cache[offset]}" - } - - __atuin_accept_line() { - local __atuin_command=$1 - - # Reprint the prompt, accounting for multiple lines - local __atuin_prompt __atuin_prompt_offset - __atuin_evaluate_prompt - __atuin_clear_prompt "$__atuin_prompt_offset" - printf '%s\n' "$__atuin_prompt$__atuin_command" - - # Add it to the bash history - history -s "$__atuin_command" - - # Assuming bash-preexec - # Invoke every function in the preexec array - local __atuin_preexec_function - local __atuin_preexec_function_ret_value - local __atuin_preexec_ret_value=0 - for __atuin_preexec_function in "${preexec_functions[@]:-}"; do - if type -t "$__atuin_preexec_function" 1>/dev/null; then - __atuin_set_ret_value "${__bp_last_ret_value:-}" - "$__atuin_preexec_function" "$__atuin_command" - __atuin_preexec_function_ret_value=$? - if [[ $__atuin_preexec_function_ret_value != 0 ]]; then - __atuin_preexec_ret_value=$__atuin_preexec_function_ret_value - fi - fi - done - - # If extdebug is turned on and any preexec function returns non-zero - # exit status, we do not run the user command. - if ! { shopt -q extdebug && ((__atuin_preexec_ret_value)); }; then - # Note: When a child Bash session is started by enter_accept, if the - # environment variable READLINE_POINT is present, bash-preexec in the - # child session does not fire preexec at all because it considers we - # are inside Atuin's keybinding of the current session. To avoid - # propagating the environment variable to the child session, we remove - # the export attribute of READLINE_LINE and READLINE_POINT. - export -n READLINE_LINE READLINE_POINT - - # Juggle the terminal settings so that the command can be interacted - # with - local __atuin_stty_backup - __atuin_stty_backup=$(stty -g) - stty "$ATUIN_STTY" - - # Execute the command. Note: We need to record $? and $_ after the - # user command within the same call of "eval" because $_ is otherwise - # overwritten by the last argument of "eval". - __atuin_set_ret_value "${__bp_last_ret_value-}" "${__bp_last_argument_prev_command-}" - eval -- "$__atuin_command"$'\n__bp_last_ret_value=$? __bp_last_argument_prev_command=$_' - - stty "$__atuin_stty_backup" - fi - - # Execute preprompt commands - local __atuin_prompt_command - for __atuin_prompt_command in "${PROMPT_COMMAND[@]}"; do - __atuin_set_ret_value "${__bp_last_ret_value-}" "${__bp_last_argument_prev_command-}" - eval -- "$__atuin_prompt_command" - done - # Bash will redraw only the line with the prompt after we finish, - # so to work for a multiline prompt we need to print it ourselves, - # then go to the beginning of the last line. - __atuin_evaluate_prompt - printf '%s' "$__atuin_prompt" - __atuin_clear_prompt 0 - } - - #------------------------------------------------------------------------------ - - __atuin_search_cmd() { - local -a search_args=("$@") - - ATUIN_SHELL=bash ATUIN_LOG=error ATUIN_QUERY=$READLINE_LINE atuin search "${search_args[@]}" -i 3>&1 1>&2 2>&3 3>&- - } - - __atuin_history() { - # Default action of the up key: When this function is called with the first - # argument `--shell-up-key-binding`, we perform Atuin's history search only - # when the up key is supposed to cause the history movement in the original - # binding. We do this only for ble.sh because the up key always invokes - # the history movement in the plain Bash. - if [[ ${BLE_ATTACHED-} && ${1-} == --shell-up-key-binding ]]; then - # When the current cursor position is not in the first line, the up key - # should move the cursor to the previous line. While the selection is - # performed, the up key should not start the history search. - # shellcheck disable=SC2154 # Note: these variables are set by ble.sh - if [[ ${_ble_edit_str::_ble_edit_ind} == *$'\n'* || $_ble_edit_mark_active ]]; then - ble/widget/@nomarked backward-line - local status=$? - READLINE_LINE=$_ble_edit_str - READLINE_POINT=$_ble_edit_ind - READLINE_MARK=$_ble_edit_mark - return "$status" - fi - fi - - # READLINE_LINE and READLINE_POINT are only supported by bash >= 4.0 or - # ble.sh. When it is not supported, we clear them to suppress strange - # behaviors. - [[ ${BLE_ATTACHED-} ]] || ((BASH_VERSINFO[0] >= 4)) || - READLINE_LINE="" READLINE_POINT=0 - - local __atuin_output - if ! __atuin_output=$(__atuin_search_cmd "$@"); then - [[ $__atuin_output ]] && printf '%s\n' "$__atuin_output" >&2 - return 1 - fi - - # We do nothing when the search is canceled. - [[ $__atuin_output ]] || return 0 - - if [[ $__atuin_output == __atuin_accept__:* ]]; then - __atuin_output=${__atuin_output#__atuin_accept__:} - - if [[ ${BLE_ATTACHED-} ]]; then - ble-edit/content/reset-and-check-dirty "$__atuin_output" - ble/widget/accept-line - READLINE_LINE="" - elif [[ ${__atuin_macro_chain_keymap-} ]]; then - READLINE_LINE=$__atuin_output - bind -m "$__atuin_macro_chain_keymap" '"'"$__atuin_macro_chain"'": '"$__atuin_macro_accept_line" - else - __atuin_accept_line "$__atuin_output" - READLINE_LINE="" - fi - - READLINE_POINT=${#READLINE_LINE} - else - READLINE_LINE=$__atuin_output - READLINE_POINT=${#READLINE_LINE} - if [[ ! ${BLE_ATTACHED-} ]] && ((BASH_VERSINFO[0] < 4)) && [[ ${__atuin_macro_chain_keymap-} ]]; then - bind -m "$__atuin_macro_chain_keymap" '"'"$__atuin_macro_chain"'": '"$__atuin_macro_insert_line" - fi - fi - } - - __atuin_initialize_blesh() { - # shellcheck disable=SC2154 - [[ ${BLE_VERSION-} ]] && ((_ble_version >= 400)) || return 0 - - ble-import contrib/integration/bash-preexec - - # Define and register an autosuggestion source for ble.sh's auto-complete. - # If you'd like to overwrite this, define the same name of shell function - # after the $(atuin init bash) line in your .bashrc. If you do not need - # the auto-complete source by Atuin, please add the following code to - # remove the entry after the $(atuin init bash) line in your .bashrc: - # - # ble/util/import/eval-after-load core-complete ' - # ble/array#remove _ble_complete_auto_source atuin-history' - # - function ble/complete/auto-complete/source:atuin-history { - local suggestion - suggestion=$(ATUIN_QUERY="$_ble_edit_str" atuin search --cmd-only --limit 1 --search-mode prefix 2>/dev/null) - [[ $suggestion == "$_ble_edit_str"?* ]] || return 1 - ble/complete/auto-complete/enter h 0 "${suggestion:${#_ble_edit_str}}" '' "$suggestion" - } - ble/util/import/eval-after-load core-complete ' - ble/array#unshift _ble_complete_auto_source atuin-history' - - # @env BLE_SESSION_ID: `atuin doctor` references the environment variable - # BLE_SESSION_ID. We explicitly export the variable because it was not - # exported in older versions of ble.sh. - [[ ${BLE_SESSION_ID-} ]] && export BLE_SESSION_ID - } - __atuin_initialize_blesh - BLE_ONLOAD+=(__atuin_initialize_blesh) - precmd_functions+=(__atuin_precmd) - preexec_functions+=(__atuin_preexec) - - #------------------------------------------------------------------------------ - # section: atuin-bind - - __atuin_widget=() - - __atuin_widget_save() { - local data=$1 - for REPLY in "${!__atuin_widget[@]}"; do - if [[ ${__atuin_widget[REPLY]} == "$data" ]]; then - return 0 - fi - done - # shellcheck disable=SC2154 - REPLY=${#__atuin_widget[*]} - __atuin_widget[REPLY]=$data - } - - __atuin_widget_run() { - local data=${__atuin_widget[$1]} - local keymap=${data%%:*} widget=${data#*:} - local __atuin_macro_chain_keymap=$keymap - bind -m "$keymap" '"'"$__atuin_macro_chain"'": ""' - builtin eval -- "$widget" - } - - # To realize the enter_accept feature in a robust way, we need to call the - # readline bindable function `accept-line'. However, there is no way to call - # `accept-line' from the shell script. To call the bindable function - # `accept-line', we may utilize string macros of readline. When we bind KEYSEQ - # to a WIDGET that wants to conditionally call `accept-line' at the end, we - # perform two-step dispatching: - # - # 1. [KEYSEQ -> IKEYSEQ1 IKEYSEQ2]---We first translate KEYSEQ to two - # intermediate key sequences IKEYSEQ1 and IKEYSEQ2 using string macros. For - # example, when we bind `__atuin_history` to \C-r, this step can be set up by - # `bind '"\C-r": "IKEYSEQ1IKEYSEQ2"'`. - # - # 2. [IKEYSEQ1 -> WIDGET]---Then, IKEYSEQ1 is bound to the WIDGET, and the - # binding of IKEYSEQ2 is dynamically determined by WIDGET. For example, when - # we bind `__atuin_history` to \C-r, this step can be set up by `bind -x - # '"IKEYSEQ1": WIDGET'`. - # - # 3. [IKEYSEQ2 -> accept-line] or [IKEYSEQ2 -> ""]---To request the execution - # of `accept-line', WIDGET can change the binding of IKEYSEQ2 by running - # `bind '"IKEYSEQ2": accept-line''. Otherwise, WIDGET can change the binding - # of IKEYSEQ2 to no-op by running `bind '"IKEYSEQ2": ""'`. - # - # For the choice of the intermediate key sequences, we want to choose key - # sequences that are unlikely to conflict with others. In addition, we want to - # avoid a key sequence containing \e because keymap "vi-insert" stops - # processing key sequences containing \e in older versions of Bash. We have - # used \e[0;<m>A (a variant of the [up] key with modifier <m>) in Atuin 3.10.0 - # for intermediate key sequences, but this contains \e and caused a problem. - # Instead, we use \C-x\C-_A<n>\a, which starts with \C-x\C-_ (an unlikely - # two-byte combination) and A (represents the initial letter of Atuin), - # followed by the payload <n> and the terminator \a (BEL, \C-g). - - __atuin_macro_chain='\C-x\C-_A0\a' - for __atuin_keymap in emacs vi-insert vi-command; do - bind -m "$__atuin_keymap" "\"$__atuin_macro_chain\": \"\"" - done - unset -v __atuin_keymap - - if ((BASH_VERSINFO[0] >= 5 || BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 3)); then - # In Bash >= 4.3 - - __atuin_macro_accept_line=accept-line - - __atuin_bind_impl() { - local keymap=$1 keyseq=$2 command=$3 - - # Note: In Bash <= 5.0, the table for `bind -x` from the keyseq to the - # command is shared by all the keymaps (emacs, vi-insert, and - # vi-command), so one cannot safely bind different command strings to - # the same keyseq in different keymaps. Therefore, the command string - # and the keyseq need to be globally in one-to-one correspondence in - # all the keymaps. - local REPLY - __atuin_widget_save "$keymap:$command" - local widget=$REPLY - local ikeyseq1='\C-x\C-_A'$((1 + widget))'\a' - local ikeyseq2=$__atuin_macro_chain - - if ((BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] == 1)); then - # Workaround for Bash 5.1: Bash 5.1 has a bug that overwriting an - # existing "bind -x" keybinding breaks other existing "bind -x" - # keybindings [1,2]. To work around the problem, we explicitly - # unbind an existing keybinding before overwriting it. - # - # [1] https://lists.gnu.org/archive/html/bug-bash/2021-04/msg00135.html - # [2] https://github.com/atuinsh/atuin/issues/962#issuecomment-3451132291 - bind -m "$keymap" -r "$keyseq" - fi - - bind -m "$keymap" "\"$keyseq\": \"$ikeyseq1$ikeyseq2\"" - bind -m "$keymap" -x "\"$ikeyseq1\": __atuin_widget_run $widget" - } - - __atuin_bind_blesh_onload() { - # In ble.sh, we need to enable unrecognized CSI sequences like \e[0;0A, - # which are discarded by ble.sh by default. Note: In Bash <= 4.2, we - # do not need to unset "decode_error_cseq_discard" because \e[0;<m>A is - # used only for the macro chaining (which is unused by ble.sh) in Bash - # <= 4.2. - bleopt decode_error_cseq_discard= - } - if [[ ${BLE_VERSION-} ]]; then - __atuin_bind_blesh_onload - fi - BLE_ONLOAD+=(__atuin_bind_blesh_onload) - else - # In Bash <= 4.2, "bind -x" cannot bind a shell command to a keyseq having - # more than two bytes, so we need to work with only two-byte sequences. - # - # However, the number of available combinations of two-byte sequences is - # limited. To minimize the number of key sequences used by Atuin, instead - # of specifying a widget by its own intermediate sequence, we specify a - # widget by a fixed-length sequence of multiple two-byte sequences. More - # specifically, instead of IKEYSEQ1, we use IKS1 IKS2 IKS3 [IKS4 IKS5] - # IKSX, where IKS1..IKS5 just stores its information to a global variable, - # and IKSX collects all the information and determine and call the actual - # widget based on the stored information. Each of IKn (n=1..5) is one of - # the two reserved sequences, $__atuin_bash42_code0 and - # $__atuin_bash42_code1. IKSX is fixed to be $__atuin_bash42_code2. - # - # For the choices of the special key sequences, we consider \C-xQ, \C-xR, - # and \C-xS. In the emacs editing mode of Bash, \C-x is used as a prefix - # key, i.e., it is used for the beginning key of the keybindings with - # multiple keys, so \C-x is unlikely to be used for a single-key binding by - # the user. Also, \C-x is not used in the vi editing mode by default. The - # combinations \C-xQ..\C-xS are also unlikely be used because we need to - # switch the modifier keys from Control to Shift to input these sequences, - # and these are not easy to input. - __atuin_bash42_code0='\C-xQ' - __atuin_bash42_code1='\C-xR' - __atuin_bash42_code2='\C-xS' - - __atuin_bash42_encode() { - REPLY= - local n=$1 min_width=${2-} - while - if ((n % 2 == 0)); then - REPLY=$__atuin_bash42_code0$REPLY - else - REPLY=$__atuin_bash42_code1$REPLY - fi - (((n /= 2) || ${#REPLY} / ${#__atuin_bash42_code0} < min_width)) - do :; done - } - - __atuin_bash42_bind() { - local __atuin_keymap - for __atuin_keymap in emacs vi-insert vi-command; do - bind -m "$__atuin_keymap" -x '"'"$__atuin_bash42_code0"'": __atuin_bash42_dispatch_selector+=0' - bind -m "$__atuin_keymap" -x '"'"$__atuin_bash42_code1"'": __atuin_bash42_dispatch_selector+=1' - bind -m "$__atuin_keymap" -x '"'"$__atuin_bash42_code2"'": __atuin_bash42_dispatch' - done - } - __atuin_bash42_bind - # In Bash <= 4.2, there is no way to read users' "bind -x" settings, so we - # need to explicitly perform "bind -x" when ble.sh is loaded. - BLE_ONLOAD+=(__atuin_bash42_bind) - - if ((BASH_VERSINFO[0] >= 4)); then - __atuin_macro_accept_line=accept-line - else - # Note: We rewrite the command line and invoke `accept-line'. In - # bash <= 3.2, there is no way to rewrite the command line from the - # shell script, so we rewrite it using a macro and - # `shell-expand-line'. - # - # Note: Concerning the key sequences to invoke bindable functions - # such as "\C-x\C-_A1\a", another option is to use - # "\exbegginning-of-line\r", etc. to make it consistent with bash - # >= 5.3. However, an older Bash configuration can still conflict - # on [M-x]. The conflict is more likely than \C-x\C-_A1\a. - for __atuin_keymap in emacs vi-insert vi-command; do - bind -m "$__atuin_keymap" '"\C-x\C-_A1\a": beginning-of-line' - bind -m "$__atuin_keymap" '"\C-x\C-_A2\a": kill-line' - # shellcheck disable=SC2016 - bind -m "$__atuin_keymap" '"\C-x\C-_A3\a": "$READLINE_LINE"' - bind -m "$__atuin_keymap" '"\C-x\C-_A4\a": shell-expand-line' - bind -m "$__atuin_keymap" '"\C-x\C-_A5\a": accept-line' - bind -m "$__atuin_keymap" '"\C-x\C-_A6\a": end-of-line' - done - unset -v __atuin_keymap - - bind -m vi-command '"\C-x\C-_A7\a": vi-insertion-mode' - bind -m vi-insert '"\C-x\C-_A7\a": vi-movement-mode' - - # "\C-x\C-_A10\a": Replace the command line with READLINE_LINE. When we are - # in the vi-command keymap, we go to vi-insert, input - # "$READLINE_LINE", and come back to vi-command. - bind -m emacs '"\C-x\C-_A10\a": "\C-x\C-_A1\a\C-x\C-_A2\a\C-x\C-_A3\a\C-x\C-_A4\a"' - bind -m vi-insert '"\C-x\C-_A10\a": "\C-x\C-_A1\a\C-x\C-_A2\a\C-x\C-_A3\a\C-x\C-_A4\a"' - bind -m vi-command '"\C-x\C-_A10\a": "\C-x\C-_A1\a\C-x\C-_A2\a\C-x\C-_A7\a\C-x\C-_A3\a\C-x\C-_A7\a\C-x\C-_A4\a"' - - __atuin_macro_accept_line='"\C-x\C-_A10\a\C-x\C-_A5\a"' - __atuin_macro_insert_line='"\C-x\C-_A10\a\C-x\C-_A6\a"' - fi - - __atuin_bash42_dispatch_selector= - - __atuin_bash42_dispatch() { - local s=$__atuin_bash42_dispatch_selector - __atuin_bash42_dispatch_selector= - __atuin_widget_run "$((2#0$s))" - } - - __atuin_bind_impl() { - local keymap=$1 keyseq=$2 command=$3 - - __atuin_widget_save "$keymap:$command" - __atuin_bash42_encode "$REPLY" - local macro=$REPLY$__atuin_bash42_code2$__atuin_macro_chain - - bind -m "$keymap" "\"$keyseq\": \"$macro\"" - } - fi - - atuin-bind() { - local keymap= - local OPTIND=1 OPTARG="" OPTERR=0 flag - while getopts ':m:' flag "$@"; do - case $flag in - m) keymap=$OPTARG ;; - *) - printf '%s\n' "atuin-bind: unrecognized option '-$flag'" >&2 - return 2 - ;; - esac - done - shift "$((OPTIND - 1))" - - if (($# != 2)); then - printf '%s\n' 'usage: atuin-bind [-m keymap] keyseq widget' >&2 - return 2 - fi - - local keyseq=$1 - [[ $keymap ]] || keymap=$(bind -v | awk '$2 == "keymap" { print $3 }') - case $keymap in - emacs-meta) keymap=emacs keyseq='\e'$keyseq ;; - emacs-ctlx) keymap=emacs keyseq='\C-x'$keyseq ;; - emacs*) keymap=emacs ;; - vi-insert) ;; - vi*) keymap=vi-command ;; - *) - printf '%s\n' "atuin-bind: unknown keymap '$keymap'" >&2 - return 2 - ;; - esac - - local command=$2 widget=${2%%[[:blank:]]*} - case $widget in - atuin-search) command=${2/#"$widget"/__atuin_history} ;; - atuin-search-emacs) command=${2/#"$widget"/__atuin_history --keymap-mode=emacs} ;; - atuin-search-viins) command=${2/#"$widget"/__atuin_history --keymap-mode=vim-insert} ;; - atuin-search-vicmd) command=${2/#"$widget"/__atuin_history --keymap-mode=vim-normal} ;; - atuin-up-search) command=${2/#"$widget"/__atuin_history --shell-up-key-binding} ;; - atuin-up-search-emacs) command=${2/#"$widget"/__atuin_history --shell-up-key-binding --keymap-mode=emacs} ;; - atuin-up-search-viins) command=${2/#"$widget"/__atuin_history --shell-up-key-binding --keymap-mode=vim-insert} ;; - atuin-up-search-vicmd) command=${2/#"$widget"/__atuin_history --shell-up-key-binding --keymap-mode=vim-normal} ;; - esac - - __atuin_bind_impl "$keymap" "$keyseq" "$command" - } - - #------------------------------------------------------------------------------ - - # shellcheck disable=SC2154 - if [[ $__atuin_bind_ctrl_r == true ]]; then - # Note: We do not overwrite [C-r] in the vi-command keymap because we do - # not want to overwrite "redo", which is already bound to [C-r] in the - # vi_nmap keymap in ble.sh. - atuin-bind -m emacs '\C-r' atuin-search-emacs - atuin-bind -m vi-insert '\C-r' atuin-search-viins - atuin-bind -m vi-command '/' atuin-search-emacs - fi - - # shellcheck disable=SC2154 - if [[ $__atuin_bind_up_arrow == true ]]; then - atuin-bind -m emacs '\e[A' atuin-up-search-emacs - atuin-bind -m emacs '\eOA' atuin-up-search-emacs - atuin-bind -m vi-insert '\e[A' atuin-up-search-viins - atuin-bind -m vi-insert '\eOA' atuin-up-search-viins - atuin-bind -m vi-command '\e[A' atuin-up-search-vicmd - atuin-bind -m vi-command '\eOA' atuin-up-search-vicmd - atuin-bind -m vi-command 'k' atuin-up-search-vicmd - fi - -#------------------------------------------------------------------------------ -fi # (include guard) end of main content diff --git a/crates/client/src/shell/atuin.fish b/crates/client/src/shell/atuin.fish deleted file mode 100644 index 2b469383..00000000 --- a/crates/client/src/shell/atuin.fish +++ /dev/null @@ -1,102 +0,0 @@ -if not set -q ATUIN_SESSION; or test "$ATUIN_SHLVL" != "$SHLVL" - set -gx ATUIN_SESSION (atuin uuid) - set -gx ATUIN_SHLVL $SHLVL -end -set --erase ATUIN_HISTORY_ID - -function _atuin_osc133_command_executed - set -q ATUIN_PTY_PROXY_ACTIVE; or return - test -n "$ATUIN_HISTORY_ID"; or return - - printf '\033]133;C\a' -end - -function _atuin_osc133_command_finished --argument-names exit_code - set -q ATUIN_PTY_PROXY_ACTIVE; or return - test -n "$ATUIN_HISTORY_ID"; or return - - printf '\033]133;D;%s;history_id=%s;session_id=%s\a' "$exit_code" "$ATUIN_HISTORY_ID" "$ATUIN_SESSION" -end - -function _atuin_preexec --on-event fish_preexec - if not test -n "$fish_private_mode" - set -g ATUIN_HISTORY_ID (atuin history start -- "$argv[1]" 2>/dev/null) - _atuin_osc133_command_executed - end -end - -function _atuin_postexec --on-event fish_postexec - set -l s $status - - if test -n "$ATUIN_HISTORY_ID" - _atuin_osc133_command_finished $s - ATUIN_LOG=error atuin history end --exit $s -- $ATUIN_HISTORY_ID &>/dev/null & - disown - end - - set --erase ATUIN_HISTORY_ID -end - -function _atuin_search - set -l keymap_mode - switch $fish_key_bindings - case fish_vi_key_bindings fish_hybrid_key_bindings - switch $fish_bind_mode - case default - set keymap_mode vim-normal - case insert - set keymap_mode vim-insert - end - case '*' - set keymap_mode emacs - end - - set -l ATUIN_H - set -l ATUIN_STATUS 0 - - # In fish 3.4 and above we can use `"$(some command)"` to keep multiple lines separate; - # but to support fish 3.3 we need to use `(some command | string collect)`. - # https://fishshell.com/docs/current/relnotes.html#id24 (fish 3.4 "Notable improvements and fixes") - set ATUIN_H (ATUIN_SHELL=fish ATUIN_LOG=error ATUIN_QUERY=(commandline -b) atuin search --keymap-mode=$keymap_mode $argv -i 3>&1 1>&2 2>&3 3>&- | string collect) - set ATUIN_STATUS $pipestatus[1] - - if test "$ATUIN_STATUS" -ne 0 - test -n "$ATUIN_H"; and printf '%s\n' "$ATUIN_H" >&2 - commandline -f repaint - return "$ATUIN_STATUS" - end - - set ATUIN_H (string trim -- $ATUIN_H | string collect) # trim whitespace - - if test -n "$ATUIN_H" - if string match --quiet '__atuin_accept__:*' "$ATUIN_H" - set -l ATUIN_HIST (string replace "__atuin_accept__:" "" -- "$ATUIN_H" | string collect) - commandline -r "$ATUIN_HIST" - commandline -f repaint - commandline -f execute - return - else - commandline -r "$ATUIN_H" - end - end - - commandline -f repaint -end - -function _atuin_bind_up - # Fallback to fish's builtin up-or-search if we're in search or paging mode - if commandline --search-mode; or commandline --paging-mode - up-or-search - return - end - - # Only invoke atuin if we're on the top line of the command - set -l lineno (commandline --line) - - switch $lineno - case 1 - _atuin_search --shell-up-key-binding - case '*' - up-or-search - end -end diff --git a/crates/client/src/shell/atuin.nu b/crates/client/src/shell/atuin.nu deleted file mode 100644 index d37457e4..00000000 --- a/crates/client/src/shell/atuin.nu +++ /dev/null @@ -1,121 +0,0 @@ -# Source this in your ~/.config/nushell/config.nu -# minimum supported version = 0.93.0 -module compat { - export def --wrapped "random uuid -v 7" [...rest] { atuin uuid } -} -use (if not ( - (version).major > 0 or - (version).minor >= 103 -) { "compat" }) * - -if 'ATUIN_SESSION' not-in $env or ('ATUIN_SHLVL' not-in $env) or ($env.ATUIN_SHLVL != ($env.SHLVL? | default "")) { - $env.ATUIN_SESSION = (random uuid -v 7 | str replace -a "-" "") - $env.ATUIN_SHLVL = ($env.SHLVL? | default "") -} -hide-env -i ATUIN_HISTORY_ID - -def _atuin_osc133_command_executed [] { - if 'ATUIN_PTY_PROXY_ACTIVE' not-in $env { - return - } - if 'ATUIN_HISTORY_ID' not-in $env or ($env.ATUIN_HISTORY_ID | is-empty) { - return - } - - print -n $"(char esc)]133;C(char bel)" -} - -def _atuin_osc133_command_finished [exit_code: int] { - if 'ATUIN_PTY_PROXY_ACTIVE' not-in $env { - return - } - if 'ATUIN_HISTORY_ID' not-in $env or ($env.ATUIN_HISTORY_ID | is-empty) { - return - } - - print -n $"(char esc)]133;D;($exit_code);history_id=($env.ATUIN_HISTORY_ID);session_id=($env.ATUIN_SESSION)(char bel)" -} - -# Magic token to make sure we don't record commands run by keybindings -let ATUIN_KEYBINDING_TOKEN = $"# (random uuid)" - -let _atuin_pre_execution = {|| - if ($nu | get history-enabled?) == false { - return - } - let cmd = (commandline) - if ($cmd | is-empty) { - return - } - if not ($cmd | str starts-with $ATUIN_KEYBINDING_TOKEN) { - $env.ATUIN_HISTORY_ID = (atuin history start -- $cmd | complete | get stdout | str trim) - _atuin_osc133_command_executed - } -} - -let _atuin_pre_prompt = {|| - let last_exit = $env.LAST_EXIT_CODE - if 'ATUIN_HISTORY_ID' not-in $env { - return - } - _atuin_osc133_command_finished $last_exit - with-env { ATUIN_LOG: error } { - if (version).minor >= 104 or (version).major > 0 { - job spawn { - ^atuin history end $'--exit=($env.LAST_EXIT_CODE)' -- $env.ATUIN_HISTORY_ID | complete - } | ignore - } else { - do { atuin history end $'--exit=($last_exit)' -- $env.ATUIN_HISTORY_ID } | complete - } - - } - hide-env ATUIN_HISTORY_ID -} - -def _atuin_search_cmd [...flags: string] { - if (version).minor >= 106 or (version).major > 0 { - [ - $ATUIN_KEYBINDING_TOKEN, - ([ - `with-env { ATUIN_LOG: error, ATUIN_QUERY: (commandline), ATUIN_SHELL: nu } {`, - ([ - 'let output = (run-external atuin search', - ($flags | append [--interactive] | each {|e| $'"($e)"'}), - 'e>| str trim)', - ] | flatten | str join ' '), - 'if ($output | str starts-with "__atuin_accept__:") {', - 'commandline edit --accept ($output | str replace "__atuin_accept__:" "")', - '} else {', - 'commandline edit $output', - '}', - `}`, - ] | flatten | str join "\n"), - ] - } else { - [ - $ATUIN_KEYBINDING_TOKEN, - ([ - `with-env { ATUIN_LOG: error, ATUIN_QUERY: (commandline) } {`, - 'commandline edit', - '(run-external atuin search', - ($flags | append [--interactive] | each {|e| $'"($e)"'}), - ' e>| str trim)', - `}`, - ] | flatten | str join ' '), - ] - } | str join "\n" -} - -$env.config = ($env | default {} config).config -$env.config = ($env.config | default {} hooks) -$env.config = ( - $env.config | upsert hooks ( - $env.config.hooks - | upsert pre_execution ( - $env.config.hooks | get pre_execution? | default [] | append $_atuin_pre_execution) - | upsert pre_prompt ( - $env.config.hooks | get pre_prompt? | default [] | append $_atuin_pre_prompt) - ) -) - -$env.config = ($env.config | default [] keybindings) diff --git a/crates/client/src/shell/atuin.ps1 b/crates/client/src/shell/atuin.ps1 deleted file mode 100644 index 431ee2c3..00000000 --- a/crates/client/src/shell/atuin.ps1 +++ /dev/null @@ -1,240 +0,0 @@ -# Atuin PowerShell module -# -# This should support PowerShell 5.1 (which is shipped with Windows) and later versions, on Windows and Linux. -# -# Usage: atuin init powershell | Out-String | Invoke-Expression -# -# Settings: -# - $env:ATUIN_POWERSHELL_PROMPT_OFFSET - Number of lines to offset the prompt position after exiting search. -# This is useful when using a multi-line prompt: e.g. set this to -1 when using a 2-line prompt. -# It is initialized from the current prompt line count if not set when the first Atuin search is performed. - -if (Get-Module Atuin -ErrorAction Ignore) { - if ($PSVersionTable.PSVersion.Major -ge 7) { - Write-Warning "The Atuin module is already loaded, replacing it." - Remove-Module Atuin - } else { - Write-Warning "The Atuin module is already loaded, skipping." - return - } -} - -if (!(Get-Command atuin -ErrorAction Ignore)) { - Write-Error "The 'atuin' executable needs to be available in the PATH." - return -} - -if (!(Get-Module PSReadLine -ErrorAction Ignore)) { - Write-Error "Atuin requires the PSReadLine module to be installed." - return -} - -New-Module -Name Atuin -ScriptBlock { - if (-not $env:ATUIN_SESSION -or $env:ATUIN_PID -ne $PID) { - $env:ATUIN_SESSION = atuin uuid - $env:ATUIN_PID = $PID - } - - $script:atuinHistoryId = $null - $script:previousPSConsoleHostReadLine = $Function:PSConsoleHostReadLine - - # The ReadLine overloads changed with breaking changes over time, make sure the one we expect is available. - $script:hasExpectedReadLineOverload = ([Microsoft.PowerShell.PSConsoleReadLine]::ReadLine).OverloadDefinitions.Contains("static string ReadLine(runspace runspace, System.Management.Automation.EngineIntrinsics engineIntrinsics, System.Threading.CancellationToken cancellationToken, System.Nullable[bool] lastRunStatus)") - - function Get-CommandLine { - $commandLine = "" - [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$commandLine, [ref]$null) - return $commandLine - } - - function Set-CommandLine { - param([string]$Text) - - $commandLine = Get-CommandLine - [Microsoft.PowerShell.PSConsoleReadLine]::Replace(0, $commandLine.Length, $Text) - } - - # This function name is called by PSReadLine to read the next command line to execute. - # We replace it with a custom implementation which adds Atuin support. - function PSConsoleHostReadLine { - ## 1. Collect the exit code of the previous command. - - # This needs to be done as the first thing because any script run will flush $?. - $lastRunStatus = $? - - # Exit statuses are maintained separately for native and PowerShell commands, this needs to be taken into account. - $lastNativeExitCode = $global:LASTEXITCODE - $exitCode = if ($lastRunStatus) { 0 } elseif ($lastNativeExitCode) { $lastNativeExitCode } else { 1 } - - ## 2. Report the status of the previous command to Atuin (atuin history end). - - if ($script:atuinHistoryId) { - try { - # The duration is not recorded in old PowerShell versions, let Atuin handle it. $null arguments are ignored. - $duration = (Get-History -Count 1).Duration.Ticks * 100 - $durationArg = if ($duration) { "--duration=$duration" } else { $null } - - # Fire and forget the atuin history end command to avoid blocking the shell during a potential sync. - $process = New-Object System.Diagnostics.Process - $process.StartInfo.FileName = "atuin" - $process.StartInfo.Arguments = "history end --exit=$exitCode $durationArg -- $script:atuinHistoryId" - $process.StartInfo.UseShellExecute = $false - $process.StartInfo.CreateNoWindow = $true - $process.StartInfo.RedirectStandardInput = $true - $process.StartInfo.RedirectStandardOutput = $true - $process.StartInfo.RedirectStandardError = $true - $process.Start() | Out-Null - $process.StandardInput.Close() - $process.BeginOutputReadLine() - $process.BeginErrorReadLine() - } - catch { - # Ignore errors to avoid breaking the shell. - # An error would occur if the user removes atuin from the PATH, for instance. - } - finally { - $script:atuinHistoryId = $null - } - } - - ## 3. Read the next command line to execute. - - # PSConsoleHostReadLine implementation from PSReadLine, adjusted to support old versions. - Microsoft.PowerShell.Core\Set-StrictMode -Off - - $line = if ($script:hasExpectedReadLineOverload) { - # When the overload we expect is available, we can pass $lastRunStatus to it. - [Microsoft.PowerShell.PSConsoleReadLine]::ReadLine($Host.Runspace, $ExecutionContext, [System.Threading.CancellationToken]::None, $lastRunStatus) - } else { - # Either PSReadLine is older than v2.2.0-beta3, or maybe newer than we expect, so use the function from PSReadLine as-is. - & $script:previousPSConsoleHostReadLine - } - - ## 4. Report the next command line to Atuin (atuin history start). - - # PowerShell doesn't handle double quotes in native command line arguments the same way depending on its version, - # and the value of $PSNativeCommandArgumentPassing - see the about_Parsing help page which explains the breaking changes. - # This makes it unreliable, so we go through an environment variable, which should always be consistent across versions. - try { - $env:ATUIN_COMMAND_LINE = $line - $script:atuinHistoryId = atuin history start --command-from-env - } - catch { - # Ignore errors to avoid breaking the shell, see above. - } - finally { - $env:ATUIN_COMMAND_LINE = $null - } - - $global:LASTEXITCODE = $lastNativeExitCode - return $line - } - - function Invoke-AtuinSearch { - param([string]$ExtraArgs = "") - - $previousOutputEncoding = [System.Console]::OutputEncoding - $resultFile = New-TemporaryFile - $suggestion = "" - $errorOutput = "" - - try { - [System.Console]::OutputEncoding = [System.Text.Encoding]::UTF8 - - # Start-Process does some crazy stuff, just use the Process class directly to have more control. - $process = New-Object System.Diagnostics.Process - $process.StartInfo.FileName = "atuin" - $process.StartInfo.Arguments = "search -i --result-file ""$($resultFile.FullName)"" $ExtraArgs" - $process.StartInfo.UseShellExecute = $false - $process.StartInfo.RedirectStandardError = $true - $process.StartInfo.StandardErrorEncoding = [System.Text.Encoding]::UTF8 - $process.StartInfo.EnvironmentVariables["ATUIN_SHELL"] = "powershell" - $process.StartInfo.EnvironmentVariables["ATUIN_QUERY"] = Get-CommandLine - # PowerShell's Set-Location (cd) doesn't update the process-level working directory, set it explicitly - $process.StartInfo.WorkingDirectory = (Get-Location -PSProvider FileSystem).ProviderPath - - try { - $process.Start() | Out-Null - - # A single stream is redirected, so we can read it synchronously, but we have to start reading it - # before waiting for the process to exit, otherwise the buffer could fill up and cause a deadlock. - $errorOutput = $process.StandardError.ReadToEnd().Trim() - $process.WaitForExit() - - $suggestion = (Get-Content -LiteralPath $resultFile.FullName -Raw -Encoding UTF8 | Out-String).Trim() - } - catch { - $errorOutput = $_ - } - - if ($errorOutput) { - Write-Host -ForegroundColor Red "Atuin error:" - Write-Host -ForegroundColor DarkRed $errorOutput - } - - # If no shell prompt offset is set, initialize it from the current prompt line count. - if ($null -eq $env:ATUIN_POWERSHELL_PROMPT_OFFSET) { - try { - $promptLines = (& $Function:prompt | Out-String | Measure-Object -Line).Lines - $env:ATUIN_POWERSHELL_PROMPT_OFFSET = -1 * ($promptLines - 1) - } - catch { - $env:ATUIN_POWERSHELL_PROMPT_OFFSET = 0 - } - } - - # PSReadLine maintains its own cursor position, which will no longer be valid if Atuin scrolls the display in inline mode. - # Fortunately, InvokePrompt can receive a new Y position and reset the internal state. - $y = $Host.UI.RawUI.CursorPosition.Y + [int]$env:ATUIN_POWERSHELL_PROMPT_OFFSET - $y = [System.Math]::Max([System.Math]::Min($y, [System.Console]::BufferHeight - 1), 0) - [Microsoft.PowerShell.PSConsoleReadLine]::InvokePrompt($null, $y) - - if ($suggestion -eq "") { - # The previous input was already rendered by InvokePrompt - return - } - - $acceptPrefix = "__atuin_accept__:" - - if ( $suggestion.StartsWith($acceptPrefix)) { - Set-CommandLine $suggestion.Substring($acceptPrefix.Length) - [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine() - } else { - Set-CommandLine $suggestion - } - } - finally { - [System.Console]::OutputEncoding = $previousOutputEncoding - $resultFile.Delete() - } - } - - function Enable-AtuinSearchKeys { - param([bool]$CtrlR = $true, [bool]$UpArrow = $true) - - if ($CtrlR) { - Set-PSReadLineKeyHandler -Chord "Ctrl+r" -BriefDescription "Runs Atuin search" -ScriptBlock { - Invoke-AtuinSearch - } - } - - if ($UpArrow) { - Set-PSReadLineKeyHandler -Chord "UpArrow" -BriefDescription "Runs Atuin search" -ScriptBlock { - $line = Get-CommandLine - - if (!$line.Contains("`n")) { - Invoke-AtuinSearch -ExtraArgs "--shell-up-key-binding" - } else { - [Microsoft.PowerShell.PSConsoleReadLine]::PreviousLine() - } - } - } - } - - $ExecutionContext.SessionState.Module.OnRemove += { - $env:ATUIN_SESSION = $null - $Function:PSConsoleHostReadLine = $script:previousPSConsoleHostReadLine - } - - Export-ModuleMember -Function @("Enable-AtuinSearchKeys", "PSConsoleHostReadLine") -} | Import-Module -Global diff --git a/crates/client/src/shell/atuin.xsh b/crates/client/src/shell/atuin.xsh deleted file mode 100644 index a0283402..00000000 --- a/crates/client/src/shell/atuin.xsh +++ /dev/null @@ -1,86 +0,0 @@ -import os -import subprocess - -from prompt_toolkit.application.current import get_app -from prompt_toolkit.filters import Condition -from prompt_toolkit.keys import Keys - - -if "ATUIN_SESSION" not in ${...} or ${...}.get("ATUIN_SHLVL", "") != ${...}.get("SHLVL", ""): - $ATUIN_SESSION=$(atuin uuid).rstrip('\n') - $ATUIN_SHLVL = ${...}.get("SHLVL", "") - -@events.on_precommand -def _atuin_precommand(cmd: str): - cmd = cmd.rstrip("\n") - try: - $ATUIN_HISTORY_ID = $(atuin history start -- @(cmd) 2>@(os.devnull)).rstrip("\n") - except: - $ATUIN_HISTORY_ID = "" - - -@events.on_postcommand -def _atuin_postcommand(cmd: str, rtn: int, out, ts): - if "ATUIN_HISTORY_ID" not in ${...}: - return - - duration = ts[1] - ts[0] - # Duration is float representing seconds, but atuin expects integer of nanoseconds - nanos = round(duration * 10 ** 9) - with ${...}.swap(ATUIN_LOG="error"): - # This causes the entire .xonshrc to be re-executed, which is incredibly slow - # This happens when using a subshell and using output redirection at the same time - # For more details, see https://github.com/xonsh/xonsh/issues/5224 - # (atuin history end --exit @(rtn) -- $ATUIN_HISTORY_ID &) > /dev/null 2>&1 - atuin history end --exit @(rtn) --duration @(nanos) -- $ATUIN_HISTORY_ID > @(os.devnull) 2>&1 - del $ATUIN_HISTORY_ID - - -def _search(event, extra_args: list[str]): - buffer = event.current_buffer - cmd = ["atuin", "search", "--interactive", *extra_args] - # We need to explicitly pass in xonsh env, in case user has set XDG_HOME or something else that matters - env = ${...}.detype() - env["ATUIN_SHELL"] = "xonsh" - env["ATUIN_QUERY"] = buffer.text - - p = subprocess.run(cmd, stderr=subprocess.PIPE, encoding="utf-8", env=env) - result = p.stderr.rstrip("\n") - # redraw prompt - necessary if atuin is configured to run inline, rather than fullscreen - event.cli.renderer.erase() - - if not result: - return - - buffer.reset() - if result.startswith("__atuin_accept__:"): - buffer.insert_text(result[17:]) - buffer.validate_and_handle() - else: - buffer.insert_text(result) - - -@events.on_ptk_create -def _custom_keybindings(bindings, **kw): - if _ATUIN_BIND_CTRL_R: - @bindings.add(Keys.ControlR) - def r_search(event): - _search(event, extra_args=[]) - - if _ATUIN_BIND_UP_ARROW: - @Condition - def should_search(): - buffer = get_app().current_buffer - # disable keybind when there is an active completion, so - # that up arrow can be used to navigate completion menu - if buffer.complete_state is not None: - return False - # similarly, disable when buffer text contains multiple lines - if '\n' in buffer.text: - return False - - return True - - @bindings.add(Keys.Up, filter=should_search) - def up_search(event): - _search(event, extra_args=["--shell-up-key-binding"]) diff --git a/crates/client/src/shell/atuin.zsh b/crates/client/src/shell/atuin.zsh deleted file mode 100644 index 7e7fef27..00000000 --- a/crates/client/src/shell/atuin.zsh +++ /dev/null @@ -1,167 +0,0 @@ -# shellcheck disable=SC2034,SC2153,SC2086,SC2155 - -# Above line is because shellcheck doesn't support zsh, per -# https://github.com/koalaman/shellcheck/wiki/SC1071, and the ignore: param in -# ludeeus/action-shellcheck only supports _directories_, not _files_. So -# instead, we manually add any error the shellcheck step finds in the file to -# the above line ... - -# Source this in your ~/.zshrc -autoload -U add-zsh-hook - -zmodload zsh/datetime 2>/dev/null - -# If zsh-autosuggestions is installed, configure it to use Atuin's search. If -# you'd like to override this, then add your config after the $(atuin init zsh) -# in your .zshrc -_zsh_autosuggest_strategy_atuin() { - # silence errors, since we don't want to spam the terminal prompt while typing. - suggestion=$(ATUIN_QUERY="$1" atuin search --cmd-only --limit 1 --search-mode prefix 2>/dev/null) -} - -if [ -n "${ZSH_AUTOSUGGEST_STRATEGY:-}" ]; then - ZSH_AUTOSUGGEST_STRATEGY=("atuin" "${ZSH_AUTOSUGGEST_STRATEGY[@]}") -else - ZSH_AUTOSUGGEST_STRATEGY=("atuin") -fi - -if [[ -z "${ATUIN_SESSION:-}" || "${ATUIN_SHLVL:-}" != "$SHLVL" ]]; then - export ATUIN_SESSION=$(atuin uuid) - export ATUIN_SHLVL=$SHLVL -fi -ATUIN_HISTORY_ID="" - -__atuin_osc133_command_executed() { - [[ -n "${ATUIN_PTY_PROXY_ACTIVE:-}" ]] || return - [[ -n "${ATUIN_HISTORY_ID:-}" ]] || return - - printf '\033]133;C\a' -} - -__atuin_osc133_command_finished() { - [[ -n "${ATUIN_PTY_PROXY_ACTIVE:-}" ]] || return - [[ -n "${ATUIN_HISTORY_ID:-}" ]] || return - - printf '\033]133;D;%s;history_id=%s;session_id=%s\a' "$1" "$ATUIN_HISTORY_ID" "${ATUIN_SESSION:-}" -} - -__atuin_osc133_prompt_start=$'%{\033]133;A;cl=line\a%}' -__atuin_osc133_prompt_end=$'%{\033]133;B\a%}' - -__atuin_osc133_wrap_prompt() { - local __atuin_prompt="${PROMPT-}" - local __atuin_rprompt="${RPROMPT-}" - - __atuin_prompt="${__atuin_prompt//$__atuin_osc133_prompt_start/}" - __atuin_prompt="${__atuin_prompt//$__atuin_osc133_prompt_end/}" - __atuin_rprompt="${__atuin_rprompt//$__atuin_osc133_prompt_start/}" - __atuin_rprompt="${__atuin_rprompt//$__atuin_osc133_prompt_end/}" - - if [[ -n "${ATUIN_PTY_PROXY_ACTIVE:-}" ]]; then - PROMPT="${__atuin_osc133_prompt_start}${__atuin_prompt}" - RPROMPT="${__atuin_rprompt}${__atuin_osc133_prompt_end}" - else - PROMPT="$__atuin_prompt" - RPROMPT="$__atuin_rprompt" - fi -} - -_atuin_preexec() { - local id - id=$(atuin history start -- "$1" 2>/dev/null) - export ATUIN_HISTORY_ID="$id" - __atuin_osc133_command_executed - __atuin_preexec_time=${EPOCHREALTIME-} -} - -_atuin_precmd() { - local EXIT="$?" __atuin_precmd_time=${EPOCHREALTIME-} - - __atuin_osc133_wrap_prompt - - [[ -z "${ATUIN_HISTORY_ID:-}" ]] && return - - local duration="" - if [[ -n $__atuin_preexec_time && -n $__atuin_precmd_time ]]; then - printf -v duration %.0f $(((__atuin_precmd_time - __atuin_preexec_time) * 1000000000)) - fi - - __atuin_osc133_command_finished "$EXIT" - (ATUIN_LOG=error atuin history end --exit $EXIT ${duration:+--duration=$duration} -- $ATUIN_HISTORY_ID &) >/dev/null 2>&1 - export ATUIN_HISTORY_ID="" -} - -__atuin_search_cmd() { - local -a search_args=("$@") - - - ATUIN_SHELL=zsh ATUIN_LOG=error ATUIN_QUERY=$BUFFER atuin search "${search_args[@]}" -i 3>&1 1>&2 2>&3 3>&- -} - -_atuin_search() { - emulate -L zsh - zle -I - - # swap stderr and stdout, so that the tui stuff works - # TODO: not this - local output __atuin_status - # shellcheck disable=SC2048 - output=$(__atuin_search_cmd $*) - __atuin_status=$? - - zle reset-prompt - # re-enable bracketed paste - # shellcheck disable=SC2154 - echo -n ${zle_bracketed_paste[1]} >/dev/tty - - if (( __atuin_status != 0 )); then - [[ -n $output ]] && print -r -- "$output" >/dev/tty - return $__atuin_status - fi - - if [[ -n $output ]]; then - RBUFFER="" - LBUFFER=$output - - if [[ $LBUFFER == __atuin_accept__:* ]] - then - LBUFFER=${LBUFFER#__atuin_accept__:} - zle accept-line - fi - fi -} -_atuin_search_vicmd() { - _atuin_search --keymap-mode=vim-normal -} -_atuin_search_viins() { - _atuin_search --keymap-mode=vim-insert -} - -_atuin_up_search() { - # Only trigger if the buffer is a single line - if [[ ! $BUFFER == *$'\n'* ]]; then - _atuin_search --shell-up-key-binding "$@" - else - zle up-line - fi -} -_atuin_up_search_vicmd() { - _atuin_up_search --keymap-mode=vim-normal -} -_atuin_up_search_viins() { - _atuin_up_search --keymap-mode=vim-insert -} - -add-zsh-hook preexec _atuin_preexec -add-zsh-hook precmd _atuin_precmd - -zle -N atuin-search _atuin_search -zle -N atuin-search-vicmd _atuin_search_vicmd -zle -N atuin-search-viins _atuin_search_viins -zle -N atuin-up-search _atuin_up_search -zle -N atuin-up-search-vicmd _atuin_up_search_vicmd -zle -N atuin-up-search-viins _atuin_up_search_viins - -# These are compatibility widget names for "atuin <= 17.2.1" users. -zle -N _atuin_search_widget _atuin_search -zle -N _atuin_up_search_widget _atuin_up_search diff --git a/crates/client/src/atuin_client/api_client.rs b/crates/daemon/src/aclient/api_client.rs index bd5bf59e..666e6dce 100644 --- a/crates/client/src/atuin_client/api_client.rs +++ b/crates/daemon/src/aclient/api_client.rs @@ -6,8 +6,8 @@ use reqwest::{Response, StatusCode, Url, header::HeaderMap}; use tracing::debug; use uuid::Uuid; -use crate::atuin_common::{api::ErrorResponse, record::RecordStatus}; -use crate::atuin_common::{ +use turtle_common::{api::ErrorResponse, record::RecordStatus}; +use turtle_common::{ api::{ATUIN_CARGO_VERSION, ATUIN_HEADER_VERSION, ATUIN_VERSION}, record::{EncryptedData, HostId, Record, RecordIdx}, tls::ensure_crypto_provider, diff --git a/crates/client/src/atuin_client/database.rs b/crates/daemon/src/aclient/database/mod.rs index a9eb2058..36049f80 100644 --- a/crates/client/src/atuin_client/database.rs +++ b/crates/daemon/src/aclient/database/mod.rs @@ -4,7 +4,7 @@ use std::{ str::FromStr, }; -use crate::{atuin_client::utils::setup_db, atuin_common::utils}; +use crate::aclient::utils::setup_db; use fs_err::{self as fs}; use itertools::Itertools; use sql_builder::{SqlBuilder, SqlName, bind::Bind, esc, quote}; @@ -14,9 +14,10 @@ use sqlx::{ }; use time::OffsetDateTime; use tracing::debug; +use turtle_common::utils; use uuid::Uuid; -use crate::atuin_client::{ +use crate::aclient::{ history::{HistoryId, HistoryStats}, utils::get_host_user, }; @@ -93,12 +94,12 @@ fn get_session_start_time(session_id: &str) -> Option<i64> { // Intended for use on a developer machine and not a sync server. // TODO: implement IntoIterator #[derive(Debug, Clone)] -pub(crate) struct ClientSqlite { +pub struct ClientSqlite { pub(crate) pool: SqlitePool, } impl ClientSqlite { - pub(crate) async fn new(path: impl AsRef<Path>, timeout: f64) -> Result<Self> { + pub async fn new(path: impl AsRef<Path>, timeout: f64) -> Result<Self> { fn mk_opts(path: &str) -> Result<SqliteConnectOptions> { let opts = SqliteConnectOptions::from_str(path)? .journal_mode(SqliteJournalMode::Wal) @@ -800,7 +801,7 @@ impl SqlBuilderExt for SqlBuilder { #[cfg(test)] mod test { - use crate::atuin_client::settings::test_local_timeout; + use crate::aclient::settings::test_local_timeout; use super::{ ClientSqlite, Context, FilterMode, History, OffsetDateTime, OptFilters, Result, SearchMode, diff --git a/crates/client/src/atuin_client/encryption.rs b/crates/daemon/src/aclient/encryption.rs index f1c921cb..220ac74e 100644 --- a/crates/client/src/atuin_client/encryption.rs +++ b/crates/daemon/src/aclient/encryption.rs @@ -17,7 +17,7 @@ use eyre::{Context, Result, bail, ensure, eyre}; use fs_err as fs; use rmp::Marker; -use crate::atuin_client::settings::Settings; +use crate::aclient::settings::Settings; pub(crate) fn generate_encoded_key() -> Result<(Key, String)> { let key = XSalsa20Poly1305::generate_key(&mut OsRng); diff --git a/crates/client/src/atuin_client/history/builder.rs b/crates/daemon/src/aclient/history/builder.rs index daa4ef49..daa4ef49 100644 --- a/crates/client/src/atuin_client/history/builder.rs +++ b/crates/daemon/src/aclient/history/builder.rs diff --git a/crates/client/src/atuin_client/history.rs b/crates/daemon/src/aclient/history/mod.rs index c38d8ccc..09d24169 100644 --- a/crates/client/src/atuin_client/history.rs +++ b/crates/daemon/src/aclient/history/mod.rs @@ -5,14 +5,14 @@ use rmp::{Marker, decode::Bytes}; use std::env; use std::fmt::Display; -use crate::atuin_common::record::DecryptedData; -use crate::atuin_common::utils::uuid_v7; +use turtle_common::record::DecryptedData; +use turtle_common::utils::uuid_v7; use eyre::{Result, bail, eyre}; -use crate::atuin_client::secrets::SECRET_PATTERNS_RE; -use crate::atuin_client::settings::Settings; -use crate::atuin_client::utils::get_host_user; +use crate::aclient::secrets::SECRET_PATTERNS_RE; +use crate::aclient::settings::Settings; +use crate::aclient::utils::get_host_user; use time::OffsetDateTime; mod builder; @@ -28,7 +28,7 @@ const HISTORY_AUTHOR_ENV: &str = "ATUIN_HISTORY_AUTHOR"; const HISTORY_INTENT_ENV: &str = "ATUIN_HISTORY_INTENT"; #[derive(Clone, Debug, Eq, PartialEq, Hash)] -pub(crate) struct HistoryId(pub(crate) String); +pub struct HistoryId(pub(crate) String); impl Display for HistoryId { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { @@ -56,7 +56,7 @@ impl From<String> for HistoryId { // New fields must be added to `History::{serialize,deserialize}` in a backwards // compatible way (sensible defaults and careful `nfields` handling). #[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)] -pub(crate) struct History { +pub struct History { /// A client-generated ID, used to identify the entry when syncing. /// /// Stored as `client_id` in the database. @@ -375,7 +375,7 @@ impl History { /// /// ## Examples /// ```rust - /// use crate::atuin_client::history::History; + /// use crate::aclient::history::History; /// /// let history: History = History::capture() /// .timestamp(time::OffsetDateTime::now_utc()) @@ -388,7 +388,7 @@ impl History { /// Command without any required info cannot be captured, which is forced at compile time: /// /// ```compile_fail - /// use crate::atuin_client::history::History; + /// use crate::aclient::history::History; /// /// // this will not compile because `cwd` is missing /// let history: History = History::capture() @@ -413,7 +413,7 @@ impl History { /// /// ## Examples /// ```rust - /// use crate::atuin_client::history::History; + /// use crate::aclient::history::History; /// /// let history: History = History::daemon() /// .timestamp(time::OffsetDateTime::now_utc()) @@ -428,7 +428,7 @@ impl History { /// Command without any required info cannot be captured, which is forced at compile time: /// /// ```compile_fail - /// use crate::atuin_client::history::History; + /// use crate::aclient::history::History; /// /// // this will not compile because `hostname` is missing /// let history: History = History::daemon() @@ -448,7 +448,7 @@ impl History { /// All fields are required, as they are all present in the database. /// /// ```compile_fail - /// use crate::atuin_client::history::History; + /// use crate::aclient::history::History; /// /// // this will not compile because `id` field is missing /// let history: History = History::from_db() @@ -486,7 +486,7 @@ mod tests { use regex::RegexSet; use time::macros::datetime; - use crate::atuin_client::{history::HISTORY_VERSION, settings::Settings}; + use crate::aclient::{history::HISTORY_VERSION, settings::Settings}; use super::History; diff --git a/crates/client/src/atuin_client/history/store.rs b/crates/daemon/src/aclient/history/store.rs index 9c7771cc..db692590 100644 --- a/crates/client/src/atuin_client/history/store.rs +++ b/crates/daemon/src/aclient/history/store.rs @@ -5,11 +5,11 @@ use indicatif::{ProgressBar, ProgressState, ProgressStyle}; use rmp::decode::Bytes; use tracing::debug; -use crate::atuin_client::{ +use crate::aclient::{ database::{ClientSqlite, current_context}, record::{encryption::PASETO_V4, sqlite_store::SqliteStore}, }; -use crate::atuin_common::record::{DecryptedData, Host, HostId, Record, RecordId, RecordIdx}; +use turtle_common::record::{DecryptedData, Host, HostId, Record, RecordId, RecordIdx}; use super::{HISTORY_TAG, HISTORY_VERSION, HISTORY_VERSION_V0, History, HistoryId}; @@ -364,10 +364,10 @@ impl HistoryStore { #[cfg(test)] mod tests { - use crate::atuin_common::record::DecryptedData; + use turtle_common::record::DecryptedData; use time::macros::datetime; - use crate::atuin_client::history::{HISTORY_VERSION, store::HistoryRecord}; + use crate::aclient::history::{HISTORY_VERSION, store::HistoryRecord}; use super::History; diff --git a/crates/client/src/atuin_client/meta.rs b/crates/daemon/src/aclient/meta.rs index 079c9926..00dabcce 100644 --- a/crates/client/src/atuin_client/meta.rs +++ b/crates/daemon/src/aclient/meta.rs @@ -2,7 +2,7 @@ use std::path::Path; use std::str::FromStr; use std::time::Duration; -use crate::atuin_common::record::HostId; +use turtle_common::record::HostId; use eyre::{Result, eyre}; use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions}; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; @@ -108,7 +108,7 @@ impl MetaStore { return Ok(HostId(parsed)); } - let uuid = crate::atuin_common::utils::uuid_v7(); + let uuid = turtle_common::utils::uuid_v7(); self.set(KEY_HOST_ID, uuid.as_simple().to_string().as_ref()) .await?; diff --git a/crates/client/src/atuin_client/ordering.rs b/crates/daemon/src/aclient/ordering.rs index 84001f52..84001f52 100644 --- a/crates/client/src/atuin_client/ordering.rs +++ b/crates/daemon/src/aclient/ordering.rs diff --git a/crates/client/src/atuin_client/record/encryption.rs b/crates/daemon/src/aclient/record/encryption.rs index d8587cf6..6851a99f 100644 --- a/crates/client/src/atuin_client/record/encryption.rs +++ b/crates/daemon/src/aclient/record/encryption.rs @@ -1,4 +1,4 @@ -use crate::atuin_common::record::{ +use turtle_common::record::{ AdditionalData, DecryptedData, EncryptedData, Encryption, HostId, RecordId, RecordIdx, }; use base64::{Engine, engine::general_purpose}; @@ -201,7 +201,7 @@ impl Assertions<'_> { #[cfg(test)] mod tests { - use crate::atuin_common::{ + use turtle_common::{ record::{Host, Record}, utils::uuid_v7, }; diff --git a/crates/client/src/atuin_client/record/mod.rs b/crates/daemon/src/aclient/record/mod.rs index 4e5774ea..2ace26f5 100644 --- a/crates/client/src/atuin_client/record/mod.rs +++ b/crates/daemon/src/aclient/record/mod.rs @@ -1,3 +1,4 @@ pub(crate) mod encryption; -pub(crate) mod sqlite_store; pub(crate) mod sync; + +pub mod sqlite_store; diff --git a/crates/client/src/atuin_client/record/sqlite_store.rs b/crates/daemon/src/aclient/record/sqlite_store.rs index 18f5c869..f2fc9d84 100644 --- a/crates/client/src/atuin_client/record/sqlite_store.rs +++ b/crates/daemon/src/aclient/record/sqlite_store.rs @@ -14,22 +14,22 @@ use sqlx::{ }; use tracing::debug; -use crate::atuin_client::utils::setup_db; -use crate::atuin_common::record::{ +use crate::aclient::utils::setup_db; +use turtle_common::record::{ EncryptedData, Host, HostId, Record, RecordId, RecordIdx, RecordStatus, }; -use crate::atuin_common::utils; +use turtle_common::utils; use uuid::Uuid; use super::encryption::PASETO_V4; #[derive(Debug, Clone)] -pub(crate) struct SqliteStore { +pub struct SqliteStore { pool: SqlitePool, } impl SqliteStore { - pub(crate) async fn new(path: impl AsRef<Path>, timeout: f64) -> Result<Self> { + pub async fn new(path: impl AsRef<Path>, timeout: f64) -> Result<Self> { fn mk_opts(path: &str) -> sqlx::Result<SqliteConnectOptions> { let opts = SqliteConnectOptions::from_str(path)? .journal_mode(SqliteJournalMode::Wal) diff --git a/crates/client/src/atuin_client/record/sync.rs b/crates/daemon/src/aclient/record/sync.rs index da05533c..94764f67 100644 --- a/crates/client/src/atuin_client/record/sync.rs +++ b/crates/daemon/src/aclient/record/sync.rs @@ -6,10 +6,10 @@ use thiserror::Error; use tracing::error; use super::encryption::PASETO_V4; -use crate::atuin_client::record::sqlite_store::SqliteStore; -use crate::atuin_client::{api_client::Client, settings::Settings}; +use crate::aclient::record::sqlite_store::SqliteStore; +use crate::aclient::{api_client::Client, settings::Settings}; -use crate::atuin_common::record::{Diff, HostId, RecordId, RecordIdx, RecordStatus}; +use turtle_common::record::{Diff, HostId, RecordId, RecordIdx, RecordStatus}; use indicatif::{ProgressBar, ProgressState, ProgressStyle}; #[derive(Error, Debug)] @@ -374,10 +374,10 @@ pub(crate) async fn sync( #[cfg(test)] mod tests { - use crate::atuin_client::record::sync::Operation; - use crate::atuin_common::record::{Diff, EncryptedData, HostId, Record}; + use crate::aclient::record::sync::Operation; + use turtle_common::record::{Diff, EncryptedData, HostId, Record}; - use crate::atuin_client::{ + use crate::aclient::{ record::{ sqlite_store::SqliteStore, sync::{self}, @@ -387,11 +387,11 @@ mod tests { fn test_record() -> Record<EncryptedData> { Record::builder() - .host(crate::atuin_common::record::Host::new(HostId( - crate::atuin_common::utils::uuid_v7(), + .host(turtle_common::record::Host::new(HostId( + turtle_common::utils::uuid_v7(), ))) .version("v1".into()) - .tag(crate::atuin_common::utils::uuid_v7().simple().to_string()) + .tag(turtle_common::utils::uuid_v7().simple().to_string()) .data(EncryptedData { data: String::new(), content_encryption_key: String::new(), diff --git a/crates/client/src/atuin_client/secrets.rs b/crates/daemon/src/aclient/secrets.rs index 74d47ea6..08d24339 100644 --- a/crates/client/src/atuin_client/secrets.rs +++ b/crates/daemon/src/aclient/secrets.rs @@ -173,7 +173,7 @@ pub(crate) static SECRET_PATTERNS_RE: LazyLock<RegexSet> = LazyLock::new(|| { mod tests { use regex::Regex; - use crate::atuin_client::secrets::{SECRET_PATTERNS, TestValue}; + use crate::aclient::secrets::{SECRET_PATTERNS, TestValue}; #[test] fn test_secrets() { diff --git a/crates/client/src/atuin_client/utils.rs b/crates/daemon/src/aclient/utils.rs index 989f9fc1..cf515183 100644 --- a/crates/client/src/atuin_client/utils.rs +++ b/crates/daemon/src/aclient/utils.rs @@ -28,7 +28,7 @@ macro_rules! setup_db { Ok(()) } - crate::atuin_client::utils::setup_db_inner($db_path, $a_timeout, $opts, migrate) + crate::aclient::utils::setup_db_inner($db_path, $a_timeout, $opts, migrate) }}; } pub(crate) use setup_db; diff --git a/crates/client/src/command/client/daemon.rs b/crates/daemon/src/main.rs index 39aa1b1e..26a5cafd 100644 --- a/crates/client/src/command/client/daemon.rs +++ b/crates/daemon/src/main.rs @@ -1,3 +1,7 @@ +#[allow(unused_imports)] +use clap::Parser; +use eyre::{Result, WrapErr, bail, eyre}; +use fs4::fs_std::FileExt; use std::fs::{self, File, OpenOptions}; use std::io::{ErrorKind, Write}; #[cfg(unix)] @@ -5,30 +9,19 @@ use std::os::unix::net::UnixStream as StdUnixStream; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; - -use crate::atuin_client::{ - database::ClientSqlite, history::History, record::sqlite_store::SqliteStore, settings::Settings, -}; -use crate::atuin_daemon::DaemonEvent; -use crate::atuin_daemon::client::{ - ControlClient, DaemonClientErrorKind, HistoryClient, classify_error, -}; -use clap::Subcommand; -#[cfg(unix)] -use daemonize::Daemonize; -use eyre::{Result, WrapErr, bail, eyre}; -use fs4::fs_std::FileExt; use tokio::time::sleep; +use turtle_daemon::{ + DaemonEvent, + aclient::{ + database::ClientSqlite, history::History, record::sqlite_store::SqliteStore, + settings::Settings, + }, + client::{ControlClient, DaemonClientErrorKind, HistoryClient, classify_error}, +}; -#[derive(clap::Args, Debug)] -pub(crate) struct Cmd { - #[command(subcommand)] - subcmd: SubCmd, -} - -#[derive(Subcommand, Debug)] +#[derive(Parser, Debug)] #[command(infer_subcommands = true)] -pub(crate) enum SubCmd { +pub(crate) enum Cmd { /// Start the daemon server Start { #[arg(long, hide = true)] @@ -37,10 +30,6 @@ pub(crate) enum SubCmd { /// Also write daemon logs to the console (useful for debugging) #[arg(long)] show_logs: bool, - - /// Force start: kill existing daemon process and reset the socket - #[arg(long)] - force: bool, }, /// Show the daemon's current status @@ -48,45 +37,35 @@ pub(crate) enum SubCmd { /// Stop the daemon gracefully Stop, - - /// Restart the daemon (stop, then start in background) - Restart, } impl Cmd { - /// Returns `true` when the process should daemonize before creating the - /// async runtime or opening any database connections. - #[cfg(unix)] - pub(crate) fn should_daemonize(&self) -> bool { - match &self.subcmd { - SubCmd::Start { daemonize, .. } => *daemonize, - _ => false, - } - } - - /// Returns `true` when logs should also be written to the console. - pub(crate) fn show_logs(&self) -> bool { - match &self.subcmd { - SubCmd::Start { show_logs, .. } => *show_logs, - _ => false, - } - } - pub(crate) async fn run( self, settings: Settings, store: SqliteStore, history_db: ClientSqlite, ) -> Result<()> { - match self.subcmd { - SubCmd::Start { force, .. } => run(settings, store, history_db, force).await, - SubCmd::Status => status_cmd(&settings).await, - SubCmd::Stop => stop_cmd(&settings).await, - SubCmd::Restart => restart_cmd(&settings).await, + match self { + Cmd::Start { .. } => run(settings, store, history_db).await, + Cmd::Status => status_cmd(&settings).await, + Cmd::Stop => stop_cmd(&settings).await, } } } +#[tokio::main] +async fn main() -> Result<()> { + let settings = Settings::new().wrap_err("could not load client settings")?; + let db_path = PathBuf::from(settings.db_path.as_str()); + let record_store_path = PathBuf::from(settings.record_store_path.as_str()); + + let db = ClientSqlite::new(db_path, settings.local_timeout).await?; + let sqlite_store = SqliteStore::new(record_store_path, settings.local_timeout).await?; + + Cmd::parse().run(settings, sqlite_store, db).await +} + const DAEMON_VERSION: &str = env!("CARGO_PKG_VERSION"); const DAEMON_PROTOCOL_VERSION: u32 = 1; const STARTUP_POLL: Duration = Duration::from_millis(40); @@ -221,87 +200,10 @@ async fn request_shutdown(settings: &Settings) { } } -fn spawn_daemon_process() -> Result<()> { - let exe = std::env::current_exe().wrap_err("could not locate atuin executable")?; - - let mut cmd = Command::new(exe); - cmd.arg("daemon") - .arg("start") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()); - - #[cfg(unix)] - cmd.arg("--daemonize"); - - cmd.spawn().wrap_err("failed to spawn daemon process")?; - - Ok(()) -} - fn startup_timeout(settings: &Settings) -> Duration { Duration::from_secs_f64(settings.local_timeout.max(0.5) + 2.0) } -#[cfg(unix)] -fn remove_stale_socket_if_present(settings: &Settings) -> Result<()> { - if settings.daemon.systemd_socket { - return Ok(()); - } - - let socket_path = Path::new(&settings.daemon.socket_path); - if !socket_path.exists() { - return Ok(()); - } - - match StdUnixStream::connect(socket_path) { - Ok(stream) => { - drop(stream); - Ok(()) - } - Err(err) if err.kind() == ErrorKind::ConnectionRefused => { - fs::remove_file(socket_path).wrap_err_with(|| { - format!( - "failed to remove stale daemon socket {}", - socket_path.display() - ) - })?; - Ok(()) - } - Err(err) if err.kind() == ErrorKind::NotFound => Ok(()), - Err(_) => Ok(()), - } -} - -async fn wait_until_ready(settings: &Settings, timeout: Duration) -> Result<HistoryClient> { - let start = Instant::now(); - let mut last_error = eyre!("daemon did not become ready"); - - loop { - match probe(settings).await { - Probe::Ready(client) => return Ok(client), - Probe::NeedsRestart(reason) => { - last_error = eyre!(reason); - } - Probe::Unreachable(err) => { - if is_legacy_daemon_error(&err) { - return Err(err.wrap_err(LEGACY_DAEMON_RESTART_MESSAGE)); - } - last_error = err; - } - } - - if start.elapsed() >= timeout { - return Err(last_error.wrap_err(format!( - "timed out waiting for daemon startup after {}ms", - timeout.as_millis() - ))); - } - - sleep(STARTUP_POLL).await; - } -} - pub(crate) async fn start_history(settings: &Settings, history: History) -> Result<String> { match async { connect_client(settings) @@ -429,123 +331,15 @@ async fn stop_cmd(settings: &Settings) -> Result<()> { } } -async fn restart_cmd(settings: &Settings) -> Result<()> { - // Stop if running - match probe(settings).await { - Probe::Ready(_) | Probe::NeedsRestart(_) => { - request_shutdown(settings).await; - println!("Stopping daemon..."); - - let pidfile_path = PathBuf::from(&settings.daemon.pidfile_path); - let timeout = Duration::from_secs(5); - wait_for_pidfile_available(&pidfile_path, timeout) - .await - .wrap_err("Timed out waiting for old daemon to stop")?; - } - Probe::Unreachable(_) => { - println!("No daemon running"); - } - } - - #[cfg(unix)] - remove_stale_socket_if_present(settings)?; - - spawn_daemon_process()?; - println!("Starting daemon..."); - - let timeout = startup_timeout(settings); - let status = wait_until_ready(settings, timeout).await?.status().await?; - - println!("Daemon restarted"); - println!(" PID: {}", status.pid); - println!(" Version: {}", status.version); - - Ok(()) -} - -/// Daemonize the current process. Must be called before creating the tokio -/// runtime or opening database connections, since `fork()` inside an async -/// runtime corrupts its internal state. -#[cfg(unix)] -pub(crate) fn daemonize_current_process() -> Result<()> { - let cwd = - std::env::current_dir().wrap_err("could not determine current directory for daemon")?; - - Daemonize::new() - .working_directory(cwd) - .start() - .wrap_err("failed to daemonize process")?; - - Ok(()) -} - -async fn run( - settings: Settings, - store: SqliteStore, - history_db: ClientSqlite, - force: bool, -) -> Result<()> { - if force { - force_cleanup(&settings); - } - +async fn run(settings: Settings, store: SqliteStore, history_db: ClientSqlite) -> Result<()> { let pidfile_path = PathBuf::from(&settings.daemon.pidfile_path); let _pidfile_guard = PidfileGuard::acquire(&pidfile_path)?; - crate::atuin_daemon::boot(settings, store, history_db).await?; + turtle_daemon::boot(settings, store, history_db).await?; Ok(()) } -/// Force cleanup: kill existing daemon process and remove socket. -fn force_cleanup(settings: &Settings) { - let pidfile_path = Path::new(&settings.daemon.pidfile_path); - - // Read and kill the existing process if pidfile exists - if pidfile_path.exists() { - if let Ok(contents) = fs::read_to_string(pidfile_path) - && let Some(pid_str) = contents.lines().next() - && let Ok(pid) = pid_str.parse::<u32>() - { - kill_process(pid); - // Give it a moment to release resources - std::thread::sleep(Duration::from_millis(100)); - } - - // Remove the pidfile - if let Err(e) = fs::remove_file(pidfile_path) - && e.kind() != ErrorKind::NotFound - { - tracing::warn!("failed to remove pidfile: {e}"); - } - } - - // Remove the socket file - #[cfg(unix)] - { - let socket_path = Path::new(&settings.daemon.socket_path); - if socket_path.exists() - && let Err(e) = fs::remove_file(socket_path) - && e.kind() != ErrorKind::NotFound - { - tracing::warn!("failed to remove socket: {e}"); - } - } -} - -/// Kill a process by PID. -#[cfg(unix)] -fn kill_process(pid: u32) { - // Use kill command to send SIGTERM for graceful shutdown - drop( - Command::new("kill") - .args(["-TERM", &pid.to_string()]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status(), - ); -} - #[cfg(test)] mod tests { use super::{ |
