aboutsummaryrefslogtreecommitdiffstats
path: root/crates/client/src
diff options
context:
space:
mode:
Diffstat (limited to 'crates/client/src')
-rw-r--r--crates/client/src/atuin_client/api_client.rs197
-rw-r--r--crates/client/src/atuin_client/database.rs1313
-rw-r--r--crates/client/src/atuin_client/encryption.rs142
-rw-r--r--crates/client/src/atuin_client/history.rs686
-rw-r--r--crates/client/src/atuin_client/history/builder.rs154
-rw-r--r--crates/client/src/atuin_client/history/store.rs437
-rw-r--r--crates/client/src/atuin_client/meta.rs182
-rw-r--r--crates/client/src/atuin_client/mod.rs10
-rw-r--r--crates/client/src/atuin_client/ordering.rs31
-rw-r--r--crates/client/src/atuin_client/record/encryption.rs379
-rw-r--r--crates/client/src/atuin_client/record/mod.rs3
-rw-r--r--crates/client/src/atuin_client/record/sqlite_store.rs563
-rw-r--r--crates/client/src/atuin_client/record/sync.rs456
-rw-r--r--crates/client/src/atuin_client/secrets.rs223
-rw-r--r--crates/client/src/atuin_client/settings/meta.rs2
-rw-r--r--crates/client/src/atuin_client/settings/mod.rs (renamed from crates/client/src/atuin_client/settings.rs)15
-rw-r--r--crates/client/src/atuin_client/settings/watcher.rs4
-rw-r--r--crates/client/src/atuin_client/utils.rs92
-rw-r--r--crates/client/src/command/client.rs198
-rw-r--r--crates/client/src/command/client/daemon.rs583
-rw-r--r--crates/client/src/command/client/init.rs129
-rw-r--r--crates/client/src/command/client/init/bash.rs13
-rw-r--r--crates/client/src/command/client/init/fish.rs74
-rw-r--r--crates/client/src/command/client/init/powershell.rs20
-rw-r--r--crates/client/src/command/client/init/xonsh.rs19
-rw-r--r--crates/client/src/command/client/init/zsh.rs26
-rw-r--r--crates/client/src/command/client/search.rs366
-rw-r--r--crates/client/src/command/client/search/cursor.rs404
-rw-r--r--crates/client/src/command/client/search/duration.rs63
-rw-r--r--crates/client/src/command/client/search/engines.rs86
-rw-r--r--crates/client/src/command/client/search/engines/daemon.rs213
-rw-r--r--crates/client/src/command/client/search/engines/db.rs107
-rw-r--r--crates/client/src/command/client/search/engines/skim.rs222
-rw-r--r--crates/client/src/command/client/search/history_list.rs431
-rw-r--r--crates/client/src/command/client/search/inspector.rs414
-rw-r--r--crates/client/src/command/client/search/interactive.rs3024
-rw-r--r--crates/client/src/command/client/search/keybindings/actions.rs322
-rw-r--r--crates/client/src/command/client/search/keybindings/conditions.rs801
-rw-r--r--crates/client/src/command/client/search/keybindings/defaults.rs1274
-rw-r--r--crates/client/src/command/client/search/keybindings/key.rs633
-rw-r--r--crates/client/src/command/client/search/keybindings/keymap.rs233
-rw-r--r--crates/client/src/command/client/search/keybindings/mod.rs14
-rw-r--r--crates/client/src/command/client/stats.rs5
-rw-r--r--crates/client/src/shell/.gitattributes1
-rw-r--r--crates/client/src/shell/atuin.bash672
-rw-r--r--crates/client/src/shell/atuin.fish102
-rw-r--r--crates/client/src/shell/atuin.nu121
-rw-r--r--crates/client/src/shell/atuin.ps1240
-rw-r--r--crates/client/src/shell/atuin.xsh86
-rw-r--r--crates/client/src/shell/atuin.zsh167
50 files changed, 18 insertions, 15934 deletions
diff --git a/crates/client/src/atuin_client/api_client.rs b/crates/client/src/atuin_client/api_client.rs
deleted file mode 100644
index bd5bf59e..00000000
--- a/crates/client/src/atuin_client/api_client.rs
+++ /dev/null
@@ -1,197 +0,0 @@
-use std::env;
-use std::time::Duration;
-
-use eyre::{Result, bail, eyre};
-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::{
- api::{ATUIN_CARGO_VERSION, ATUIN_HEADER_VERSION, ATUIN_VERSION},
- record::{EncryptedData, HostId, Record, RecordIdx},
- tls::ensure_crypto_provider,
-};
-
-use semver::Version;
-
-static APP_USER_AGENT: &str = concat!("atuin/", env!("CARGO_PKG_VERSION"),);
-
-pub(crate) struct Client<'a> {
- sync_addr: &'a str,
- user_id: Uuid,
- inner: reqwest::Client,
-}
-
-fn make_url(address: &str, path: &str, user_id: Uuid) -> Result<String> {
- let address = address.strip_suffix('/').unwrap_or(address);
-
- // `join()` expects a trailing `/` in order to join paths
- // e.g. it treats `http://host:port/subdir` as a file called `subdir`
- let address = &format!("{address}/api/v0/{user_id}/");
-
- // passing a path with a leading `/` will cause `join()` to replace the entire URL path
- let path = path.strip_prefix("/").unwrap_or(path);
-
- let url = Url::parse(address)
- .map(|url| url.join(path))?
- .map_err(|_| eyre!("invalid address"))?;
-
- Ok(url.to_string())
-}
-
-pub(crate) fn ensure_version(response: &Response) -> Result<bool> {
- let version = response.headers().get(ATUIN_HEADER_VERSION);
-
- let version = if let Some(version) = version {
- match version.to_str() {
- Ok(v) => Version::parse(v),
- Err(e) => bail!("failed to parse server version: {:?}", e),
- }
- } else {
- bail!("Server not reporting its version: it is either too old or unhealthy");
- }?;
-
- // If the client is newer than the server
- if version.major < ATUIN_VERSION.major {
- println!(
- "Atuin version mismatch! In order to successfully sync, the server needs to run a newer version of Atuin"
- );
- println!("Client: {ATUIN_CARGO_VERSION}");
- println!("Server: {version}");
-
- return Ok(false);
- }
-
- Ok(true)
-}
-
-async fn handle_resp_error(resp: Response) -> Result<Response> {
- let status = resp.status();
- let url = resp.url().to_string();
-
- if status == StatusCode::SERVICE_UNAVAILABLE {
- bail!(
- "Service unavailable: check https://status.atuin.sh (or get in touch with your host)"
- );
- }
-
- if status == StatusCode::TOO_MANY_REQUESTS {
- bail!("Rate limited; please wait before doing that again");
- }
-
- if !status.is_success() {
- if let Ok(error) = resp.json::<ErrorResponse<'_>>().await {
- let reason = error.reason;
-
- if status.is_client_error() {
- bail!("Invalid request to the service at {url}, {status} - {reason}.")
- }
-
- bail!(
- "There was an error with the atuin sync service at {url}, server error {status}: {reason}.\nIf the problem persists, contact the host"
- )
- }
-
- bail!(
- "There was an error with the atuin sync service at {url}, Status {status:?}.\nIf the problem persists, contact the host"
- )
- }
-
- Ok(resp)
-}
-
-impl<'a> Client<'a> {
- pub(crate) fn new(
- sync_addr: &'a str,
- connect_timeout: u64,
- timeout: u64,
- user_id: Uuid,
- ) -> Result<Self> {
- ensure_crypto_provider();
- let mut headers = HeaderMap::new();
-
- // used for semver server check
- headers.insert(ATUIN_HEADER_VERSION, ATUIN_CARGO_VERSION.parse()?);
-
- Ok(Client {
- user_id,
- sync_addr,
- inner: reqwest::Client::builder()
- .user_agent(APP_USER_AGENT)
- .default_headers(headers)
- .connect_timeout(Duration::new(connect_timeout, 0))
- .timeout(Duration::new(timeout, 0))
- .build()?,
- })
- }
-
- pub(crate) async fn delete_store(&self) -> Result<()> {
- let url = make_url(self.sync_addr, "/store", self.user_id)?;
- let url = Url::parse(url.as_str())?;
-
- let resp = self.inner.delete(url).send().await?;
-
- handle_resp_error(resp).await?;
-
- Ok(())
- }
-
- pub(crate) async fn post_records(&self, records: &[Record<EncryptedData>]) -> Result<()> {
- let url = make_url(self.sync_addr, "/record", self.user_id)?;
- let url = Url::parse(url.as_str())?;
-
- debug!("uploading {} records to {url}", records.len());
-
- let resp = self.inner.post(url).json(records).send().await?;
- handle_resp_error(resp).await?;
-
- Ok(())
- }
-
- pub(crate) async fn next_records(
- &self,
- host: HostId,
- tag: String,
- start: RecordIdx,
- count: u64,
- ) -> Result<Vec<Record<EncryptedData>>> {
- debug!("fetching record/s from host {}/{}/{}", host.0, tag, start);
-
- let url = make_url(
- self.sync_addr,
- &format!(
- "/record/next?host={}&tag={}&count={}&start={}",
- host.0, tag, count, start
- ),
- self.user_id,
- )?;
-
- let url = Url::parse(url.as_str())?;
-
- let resp = self.inner.get(url).send().await?;
- let resp = handle_resp_error(resp).await?;
-
- let records = resp.json::<Vec<Record<EncryptedData>>>().await?;
-
- Ok(records)
- }
-
- pub(crate) async fn record_status(&self) -> Result<RecordStatus> {
- let url = make_url(self.sync_addr, "/record", self.user_id)?;
- let url = Url::parse(url.as_str())?;
-
- let resp = self.inner.get(url).send().await?;
- let resp = handle_resp_error(resp).await?;
-
- if !ensure_version(&resp)? {
- bail!("could not sync records due to version mismatch");
- }
-
- let index = resp.json().await?;
-
- debug!("got remote index {index:?}");
-
- Ok(index)
- }
-}
diff --git a/crates/client/src/atuin_client/database.rs b/crates/client/src/atuin_client/database.rs
deleted file mode 100644
index a9eb2058..00000000
--- a/crates/client/src/atuin_client/database.rs
+++ /dev/null
@@ -1,1313 +0,0 @@
-use std::{
- env,
- path::{Path, PathBuf},
- str::FromStr,
-};
-
-use crate::{atuin_client::utils::setup_db, atuin_common::utils};
-use fs_err::{self as fs};
-use itertools::Itertools;
-use sql_builder::{SqlBuilder, SqlName, bind::Bind, esc, quote};
-use sqlx::{
- Result, Row,
- sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqliteRow, SqliteSynchronous},
-};
-use time::OffsetDateTime;
-use tracing::debug;
-use uuid::Uuid;
-
-use crate::atuin_client::{
- history::{HistoryId, HistoryStats},
- utils::get_host_user,
-};
-
-use super::{
- history::History,
- ordering,
- settings::{FilterMode, SearchMode, Settings},
-};
-
-#[derive(Clone)]
-pub(crate) struct Context {
- pub(crate) session: String,
- pub(crate) cwd: String,
- pub(crate) hostname: String,
- pub(crate) host_id: String,
- pub(crate) git_root: Option<PathBuf>,
-}
-
-#[derive(Default, Clone)]
-pub(crate) struct OptFilters {
- pub(crate) exit: Option<i64>,
- pub(crate) exclude_exit: Option<i64>,
- pub(crate) cwd: Option<String>,
- pub(crate) exclude_cwd: Option<String>,
- pub(crate) before: Option<String>,
- pub(crate) after: Option<String>,
- pub(crate) limit: Option<i64>,
- pub(crate) offset: Option<i64>,
- pub(crate) reverse: bool,
- pub(crate) include_duplicates: bool,
-}
-
-pub(crate) async fn current_context() -> eyre::Result<Context> {
- let session = env::var("ATUIN_SESSION").map_err(|_| {
- eyre::eyre!("Failed to find $ATUIN_SESSION in the environment. Check that you have correctly set up your shell.")
- })?;
- let hostname = get_host_user();
- let cwd = utils::get_current_dir();
- let host_id = Settings::host_id().await?;
- let git_root = utils::in_git_repo(cwd.as_str());
-
- Ok(Context {
- session,
- hostname,
- cwd,
- git_root,
- host_id: host_id.0.as_simple().to_string(),
- })
-}
-
-impl Context {
- pub(crate) fn from_history(entry: &History) -> Self {
- Self {
- session: entry.session.clone(),
- cwd: entry.cwd.clone(),
- hostname: entry.hostname.clone(),
- host_id: String::new(),
- git_root: utils::in_git_repo(entry.cwd.as_str()),
- }
- }
-}
-
-fn get_session_start_time(session_id: &str) -> Option<i64> {
- if let Ok(uuid) = Uuid::parse_str(session_id)
- && let Some(timestamp) = uuid.get_timestamp()
- {
- let (seconds, nanos) = timestamp.to_unix();
- return Some(seconds as i64 * 1_000_000_000 + i64::from(nanos));
- }
- None
-}
-
-// Intended for use on a developer machine and not a sync server.
-// TODO: implement IntoIterator
-#[derive(Debug, Clone)]
-pub(crate) struct ClientSqlite {
- pub(crate) pool: SqlitePool,
-}
-
-impl ClientSqlite {
- pub(crate) 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)
- .optimize_on_close(true, None)
- .synchronous(SqliteSynchronous::Normal)
- .with_regexp()
- .create_if_missing(true);
-
- Ok(opts)
- }
-
- let path = path.as_ref();
- debug!("opening sqlite database at {path:?}");
-
- if utils::broken_symlink(path) {
- eprintln!(
- "Atuin: Sqlite db path ({}) is a broken symlink. Unable to read or create replacement.",
- path.display()
- );
- std::process::exit(1);
- }
-
- if !path.exists()
- && let Some(dir) = path.parent()
- {
- fs::create_dir_all(dir)?;
- }
-
- let pool = setup_db!(path, timeout, mk_opts, "./db/client-migrations").await?;
- Ok(Self { pool })
- }
-
- async fn save_raw(tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, h: &History) -> Result<()> {
- sqlx::query(
- "insert or ignore into history(id, timestamp, duration, exit, command, cwd, session, hostname, author, intent, deleted_at)
- values(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
- )
- .bind(h.id.0.as_str())
- .bind(h.timestamp.unix_timestamp_nanos() as i64)
- .bind(h.duration)
- .bind(h.exit)
- .bind(h.command.as_str())
- .bind(h.cwd.as_str())
- .bind(h.session.as_str())
- .bind(h.hostname.as_str())
- .bind(h.author.as_str())
- .bind(h.intent.as_deref())
- .bind(h.deleted_at.map(|t|t.unix_timestamp_nanos() as i64))
- .execute(&mut **tx)
- .await?;
-
- Ok(())
- }
-
- async fn delete_row_raw(
- tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
- id: HistoryId,
- ) -> Result<()> {
- sqlx::query("delete from history where id = ?1")
- .bind(id.0.as_str())
- .execute(&mut **tx)
- .await?;
-
- Ok(())
- }
-
- #[expect(clippy::needless_pass_by_value)]
- fn query_history_inner(row: SqliteRow) -> History {
- let deleted_at: Option<i64> = row.get("deleted_at");
- let hostname: String = row.get("hostname");
- let author: Option<String> = row.try_get("author").ok().flatten();
- let author = author
- .filter(|author| !author.trim().is_empty())
- .unwrap_or_else(|| History::author_from_hostname(hostname.as_str()));
- let intent: Option<String> = row.try_get("intent").ok().flatten();
- let intent = intent.filter(|intent| !intent.trim().is_empty());
-
- History::from_db()
- .id(row.get("id"))
- .timestamp(
- OffsetDateTime::from_unix_timestamp_nanos(i128::from(
- row.get::<i64, _>("timestamp"),
- ))
- .unwrap(),
- )
- .duration(row.get("duration"))
- .exit(row.get("exit"))
- .command(row.get("command"))
- .cwd(row.get("cwd"))
- .session(row.get("session"))
- .hostname(hostname)
- .author(author)
- .intent(intent)
- .deleted_at(
- deleted_at
- .and_then(|t| OffsetDateTime::from_unix_timestamp_nanos(i128::from(t)).ok()),
- )
- .build()
- .into()
- }
-}
-
-impl ClientSqlite {
- pub(crate) async fn save(&self, h: &History) -> Result<()> {
- debug!("saving history to sqlite");
- let mut tx = self.pool.begin().await?;
- Self::save_raw(&mut tx, h).await?;
- tx.commit().await?;
-
- Ok(())
- }
-
- pub(crate) async fn save_bulk(&self, h: &[History]) -> Result<()> {
- debug!("saving history to sqlite");
-
- let mut tx = self.pool.begin().await?;
-
- for i in h {
- Self::save_raw(&mut tx, i).await?;
- }
-
- tx.commit().await?;
-
- Ok(())
- }
-
- pub(crate) async fn load(&self, id: &str) -> Result<Option<History>> {
- debug!("loading history item {}", id);
-
- let res = sqlx::query("select * from history where id = ?1")
- .bind(id)
- .map(Self::query_history_inner)
- .fetch_optional(&self.pool)
- .await?;
-
- Ok(res)
- }
-
- // make a unique list, that only shows the *newest* version of things
- pub(crate) async fn list(
- &self,
- filters: &[FilterMode],
- context: &Context,
- max: Option<usize>,
- unique: bool,
- include_deleted: bool,
- ) -> Result<Vec<History>> {
- debug!("listing history");
-
- let mut query = SqlBuilder::select_from(SqlName::new("history").alias("h").baquoted());
- query.field("*").order_desc("timestamp");
- if !include_deleted {
- query.and_where_is_null("deleted_at");
- }
-
- let git_root = context.git_root.clone().map_or_else(
- || context.cwd.clone(),
- |git_root| git_root.to_str().unwrap_or("/").to_string(),
- );
-
- let session_start = get_session_start_time(&context.session);
-
- for filter in filters {
- match filter {
- FilterMode::Global => &mut query,
- FilterMode::Host => query.and_where_eq("hostname", quote(&context.hostname)),
- FilterMode::Session => query.and_where_eq("session", quote(&context.session)),
- FilterMode::SessionPreload => {
- query.and_where_eq("session", quote(&context.session));
- if let Some(session_start) = session_start {
- query.or_where_lt("timestamp", session_start);
- }
- &mut query
- }
- FilterMode::Directory => query.and_where_eq("cwd", quote(&context.cwd)),
- FilterMode::Workspace => query.and_where_like_left("cwd", &git_root),
- };
- }
-
- if unique {
- query.group_by("command").having("max(timestamp)");
- }
-
- if let Some(max) = max {
- query.limit(max);
- }
-
- let query = query.sql().expect("bug in list query. please report");
-
- let res = sqlx::query(&query)
- .map(Self::query_history_inner)
- .fetch_all(&self.pool)
- .await?;
-
- Ok(res)
- }
-
- pub(crate) async fn range(
- &self,
- from: OffsetDateTime,
- to: OffsetDateTime,
- ) -> Result<Vec<History>> {
- debug!("listing history from {:?} to {:?}", from, to);
-
- let res = sqlx::query(
- "select * from history where timestamp >= ?1 and timestamp <= ?2 order by timestamp asc",
- )
- .bind(from.unix_timestamp_nanos() as i64)
- .bind(to.unix_timestamp_nanos() as i64)
- .map(Self::query_history_inner)
- .fetch_all(&self.pool)
- .await?;
-
- Ok(res)
- }
-
- pub(crate) async fn last(&self) -> Result<Option<History>> {
- let res = sqlx::query(
- "select * from history where duration >= 0 order by timestamp desc limit 1",
- )
- .map(Self::query_history_inner)
- .fetch_optional(&self.pool)
- .await?;
-
- Ok(res)
- }
-
- pub(crate) async fn history_count(&self, include_deleted: bool) -> Result<i64> {
- let query = if include_deleted {
- "select count(1) from history"
- } else {
- "select count(1) from history where deleted_at is null"
- };
-
- let res: (i64,) = sqlx::query_as(query).fetch_one(&self.pool).await?;
- Ok(res.0)
- }
-
- // Yes I know, it's a lot.
- // Could maybe break it down to a searchparams struct or smth but that feels a little... pointless.
- // Been debating maybe a DSL for search? eg "before:time limit:1 the query"
- #[expect(clippy::too_many_lines)]
- pub(crate) async fn search(
- &self,
- search_mode: SearchMode,
- filter: FilterMode,
- context: &Context,
- query: &str,
- filter_options: OptFilters,
- ) -> Result<Vec<History>> {
- let mut sql = SqlBuilder::select_from("history");
-
- if !filter_options.include_duplicates {
- sql.group_by("command").having("max(timestamp)");
- }
-
- if let Some(limit) = filter_options.limit {
- sql.limit(limit);
- }
-
- if let Some(offset) = filter_options.offset {
- sql.offset(offset);
- }
-
- if filter_options.reverse {
- sql.order_asc("timestamp");
- } else {
- sql.order_desc("timestamp");
- }
-
- let git_root = context.git_root.clone().map_or_else(
- || context.cwd.clone(),
- |git_root| git_root.to_str().unwrap_or("/").to_string(),
- );
-
- let session_start = get_session_start_time(&context.session);
-
- match filter {
- FilterMode::Global => &mut sql,
- FilterMode::Host => {
- sql.and_where_eq("lower(hostname)", quote(context.hostname.to_lowercase()))
- }
- FilterMode::Session => sql.and_where_eq("session", quote(&context.session)),
- FilterMode::SessionPreload => {
- sql.and_where_eq("session", quote(&context.session));
- if let Some(session_start) = session_start {
- sql.or_where_lt("timestamp", session_start);
- }
- &mut sql
- }
- FilterMode::Directory => sql.and_where_eq("cwd", quote(&context.cwd)),
- FilterMode::Workspace => sql.and_where_like_left("cwd", git_root),
- };
-
- let orig_query = query;
-
- let mut regexes = Vec::new();
- if search_mode == SearchMode::Prefix {
- sql.and_where_like_left("command", query.replace('*', "%"))
- } else {
- let mut is_or = false;
- for token in QueryTokenizer::new(query) {
- // TODO smart case mode could be made configurable like in fzf
- let (is_glob, glob) = if token.has_uppercase() {
- (true, "*")
- } else {
- (false, "%")
- };
- let param = match token {
- QueryToken::Regex(r) => {
- regexes.push(String::from(r));
- continue;
- }
- QueryToken::Or => {
- if !is_or {
- is_or = true;
- continue;
- }
-
- format!("{glob}|{glob}")
- }
- QueryToken::MatchStart(term, _) => {
- format!("{term}{glob}")
- }
- QueryToken::MatchEnd(term, _) => {
- format!("{glob}{term}")
- }
- QueryToken::MatchFull(term, _) => {
- format!("{glob}{term}{glob}")
- }
- QueryToken::Match(term, _) => {
- if search_mode == SearchMode::FullText {
- format!("{glob}{term}{glob}")
- } else {
- term.split("").join(glob)
- }
- }
- };
-
- sql.fuzzy_condition("command", param, token.is_inverse(), is_glob, is_or);
- is_or = false;
- }
-
- &mut sql
- };
-
- for regex in regexes {
- sql.and_where("command regexp ?".bind(&regex));
- }
-
- filter_options
- .exit
- .map(|exit| sql.and_where_eq("exit", exit));
-
- filter_options
- .exclude_exit
- .map(|exclude_exit| sql.and_where_ne("exit", exclude_exit));
-
- filter_options
- .cwd
- .map(|cwd| sql.and_where_eq("cwd", quote(cwd)));
-
- filter_options
- .exclude_cwd
- .map(|exclude_cwd| sql.and_where_ne("cwd", quote(exclude_cwd)));
-
- filter_options.before.map(|before| {
- interim::parse_date_string(
- before.as_str(),
- OffsetDateTime::now_utc(),
- interim::Dialect::Uk,
- )
- .map(|before| {
- sql.and_where_lt("timestamp", quote(before.unix_timestamp_nanos() as i64))
- })
- });
-
- filter_options.after.map(|after| {
- interim::parse_date_string(
- after.as_str(),
- OffsetDateTime::now_utc(),
- interim::Dialect::Uk,
- )
- .map(|after| sql.and_where_gt("timestamp", quote(after.unix_timestamp_nanos() as i64)))
- });
-
- sql.and_where_is_null("deleted_at");
-
- let query = sql.sql().expect("bug in search query. please report");
-
- let res = sqlx::query(&query)
- .map(Self::query_history_inner)
- .fetch_all(&self.pool)
- .await?;
-
- Ok(ordering::reorder_fuzzy(search_mode, orig_query, res))
- }
-
- pub(crate) async fn query_history(&self, query: &str) -> Result<Vec<History>> {
- let res = sqlx::query(query)
- .map(Self::query_history_inner)
- .fetch_all(&self.pool)
- .await?;
-
- Ok(res)
- }
-
- pub(crate) async fn all_with_count(&self) -> Result<Vec<(History, i32)>> {
- debug!("listing history");
-
- let mut query = SqlBuilder::select_from(SqlName::new("history").alias("h").baquoted());
-
- query
- .fields(&[
- "id",
- "max(timestamp) as timestamp",
- "max(duration) as duration",
- "exit",
- "command",
- "deleted_at",
- "null as author",
- "null as intent",
- "group_concat(cwd, ':') as cwd",
- "group_concat(session) as session",
- "group_concat(hostname, ',') as hostname",
- "count(*) as count",
- ])
- .group_by("command")
- .group_by("exit")
- .and_where("deleted_at is null")
- .order_desc("timestamp");
-
- let query = query.sql().expect("bug in list query. please report");
-
- let res = sqlx::query(&query)
- .map(|row: SqliteRow| {
- let count: i32 = row.get("count");
- (Self::query_history_inner(row), count)
- })
- .fetch_all(&self.pool)
- .await?;
-
- Ok(res)
- }
-
- pub(crate) fn all_paged(&self, page_size: usize, include_deleted: bool, unique: bool) -> Paged {
- Paged::new(self.clone(), page_size, include_deleted, unique)
- }
-
- pub(crate) async fn delete_rows(&self, ids: &[HistoryId]) -> Result<()> {
- let mut tx = self.pool.begin().await?;
-
- for id in ids {
- Self::delete_row_raw(&mut tx, id.clone()).await?;
- }
-
- tx.commit().await?;
-
- Ok(())
- }
-
- pub(crate) async fn stats(&self, h: &History) -> Result<HistoryStats> {
- // We select the previous in the session by time
- let mut prev = SqlBuilder::select_from("history");
- prev.field("*")
- .and_where("timestamp < ?1")
- .and_where("session = ?2")
- .order_by("timestamp", true)
- .limit(1);
-
- let mut next = SqlBuilder::select_from("history");
- next.field("*")
- .and_where("timestamp > ?1")
- .and_where("session = ?2")
- .order_by("timestamp", false)
- .limit(1);
-
- let mut total = SqlBuilder::select_from("history");
- total.field("count(1)").and_where("command = ?1");
-
- let mut average = SqlBuilder::select_from("history");
- average.field("avg(duration)").and_where("command = ?1");
-
- let mut exits = SqlBuilder::select_from("history");
- exits
- .fields(&["exit", "count(1) as count"])
- .and_where("command = ?1")
- .group_by("exit");
-
- // rewrite the following with sqlbuilder
- let mut day_of_week = SqlBuilder::select_from("history");
- day_of_week
- .fields(&[
- "strftime('%w', ROUND(timestamp / 1000000000), 'unixepoch') AS day_of_week",
- "count(1) as count",
- ])
- .and_where("command = ?1")
- .group_by("day_of_week");
-
- // Intentionally format the string with 01 hardcoded. We want the average runtime for the
- // _entire month_, but will later parse it as a datetime for sorting
- // Sqlite has no datetime so we cannot do it there, and otherwise sorting will just be a
- // string sort, which won't be correct.
- let mut duration_over_time = SqlBuilder::select_from("history");
- duration_over_time
- .fields(&[
- "strftime('01-%m-%Y', ROUND(timestamp / 1000000000), 'unixepoch') AS month_year",
- "avg(duration) as duration",
- ])
- .and_where("command = ?1")
- .group_by("month_year")
- .having("duration > 0");
-
- let prev = prev.sql().expect("issue in stats previous query");
- let next = next.sql().expect("issue in stats next query");
- let total = total.sql().expect("issue in stats average query");
- let average = average.sql().expect("issue in stats previous query");
- let exits = exits.sql().expect("issue in stats exits query");
- let day_of_week = day_of_week.sql().expect("issue in stats day of week query");
- let duration_over_time = duration_over_time
- .sql()
- .expect("issue in stats duration over time query");
-
- let prev = sqlx::query(&prev)
- .bind(h.timestamp.unix_timestamp_nanos() as i64)
- .bind(&h.session)
- .map(Self::query_history_inner)
- .fetch_optional(&self.pool)
- .await?;
-
- let next = sqlx::query(&next)
- .bind(h.timestamp.unix_timestamp_nanos() as i64)
- .bind(&h.session)
- .map(Self::query_history_inner)
- .fetch_optional(&self.pool)
- .await?;
-
- let total: (i64,) = sqlx::query_as(&total)
- .bind(&h.command)
- .fetch_one(&self.pool)
- .await?;
-
- let average: (f64,) = sqlx::query_as(&average)
- .bind(&h.command)
- .fetch_one(&self.pool)
- .await?;
-
- let exits: Vec<(i64, i64)> = sqlx::query_as(&exits)
- .bind(&h.command)
- .fetch_all(&self.pool)
- .await?;
-
- let day_of_week: Vec<(String, i64)> = sqlx::query_as(&day_of_week)
- .bind(&h.command)
- .fetch_all(&self.pool)
- .await?;
-
- let duration_over_time: Vec<(String, f64)> = sqlx::query_as(&duration_over_time)
- .bind(&h.command)
- .fetch_all(&self.pool)
- .await?;
-
- let duration_over_time = duration_over_time
- .iter()
- .map(|f| (f.0.clone(), f.1.round() as i64))
- .collect();
-
- Ok(HistoryStats {
- next,
- previous: prev,
- total: total.0 as u64,
- average_duration: average.0 as u64,
- exits,
- day_of_week,
- duration_over_time,
- })
- }
-
- pub(crate) async fn get_dups(&self, before: i64, dupkeep: u32) -> Result<Vec<History>> {
- let res = sqlx::query(
- "SELECT * FROM (
- SELECT *, ROW_NUMBER()
- OVER (PARTITION BY command, cwd, hostname ORDER BY timestamp DESC)
- AS rn
- FROM history
- ) sub
- WHERE rn > ?1 and timestamp < ?2;
- ",
- )
- .bind(dupkeep)
- .bind(before)
- .map(Self::query_history_inner)
- .fetch_all(&self.pool)
- .await?;
-
- Ok(res)
- }
-}
-
-pub(crate) struct Paged {
- database: ClientSqlite,
- page_size: usize,
- last_id: Option<String>,
- include_deleted: bool,
- unique: bool,
-}
-
-impl Paged {
- pub(crate) fn new(
- database: ClientSqlite,
- page_size: usize,
- include_deleted: bool,
- unique: bool,
- ) -> Self {
- Self {
- database,
- page_size,
- last_id: None,
- include_deleted,
- unique,
- }
- }
-
- pub(crate) async fn next(&mut self) -> Result<Option<Vec<History>>> {
- let mut query = SqlBuilder::select_from(SqlName::new("history").alias("h").baquoted());
-
- query.field("*").order_desc("id");
-
- if !self.include_deleted {
- query.and_where_is_null("deleted_at");
- }
-
- if self.unique {
- // We want to deduplicate on command, but the user can search via cwd, hostname, and session.
- // Without those fields, filter modes won't work right. With those fields, we get duplicates.
- // This must be handled upstream.
- query
- .group_by("command, cwd, hostname, session")
- .having("max(timestamp)");
- }
-
- query.limit(self.page_size);
-
- if let Some(last_id) = &self.last_id {
- query.and_where_lt("id", quote(last_id));
- }
-
- let query = query.sql().expect("bug in list query. please report");
- let res = self.database.query_history(&query).await?;
-
- if res.is_empty() {
- Ok(None)
- } else {
- self.last_id = Some(res.last().unwrap().id.0.clone());
- Ok(Some(res))
- }
- }
-}
-
-trait SqlBuilderExt {
- fn fuzzy_condition<S: ToString, T: ToString>(
- &mut self,
- field: S,
- mask: T,
- inverse: bool,
- glob: bool,
- is_or: bool,
- ) -> &mut Self;
-}
-
-impl SqlBuilderExt for SqlBuilder {
- /// adapted from the sql-builder *like functions
- fn fuzzy_condition<S: ToString, T: ToString>(
- &mut self,
- field: S,
- mask: T,
- inverse: bool,
- glob: bool,
- is_or: bool,
- ) -> &mut Self {
- let mut cond = field.to_string();
- if inverse {
- cond.push_str(" NOT");
- }
- if glob {
- cond.push_str(" GLOB '");
- } else {
- cond.push_str(" LIKE '");
- }
- cond.push_str(&esc(mask.to_string()));
- cond.push('\'');
- if is_or {
- self.or_where(cond)
- } else {
- self.and_where(cond)
- }
- }
-}
-
-#[cfg(test)]
-mod test {
- use crate::atuin_client::settings::test_local_timeout;
-
- use super::{
- ClientSqlite, Context, FilterMode, History, OffsetDateTime, OptFilters, Result, SearchMode,
- };
- use std::time::{Duration, Instant};
-
- async fn assert_search_eq(
- db: &ClientSqlite,
- mode: SearchMode,
- filter_mode: FilterMode,
- query: &str,
- expected: usize,
- ) -> Result<Vec<History>> {
- let context = Context {
- hostname: "test:host".to_string(),
- session: "beepboopiamasession".to_string(),
- cwd: "/home/ellie".to_string(),
- host_id: "test-host".to_string(),
- git_root: None,
- };
-
- let results = db
- .search(
- mode,
- filter_mode,
- &context,
- query,
- OptFilters {
- ..Default::default()
- },
- )
- .await?;
-
- assert_eq!(
- results.len(),
- expected,
- "query \"{}\", commands: {:?}",
- query,
- results.iter().map(|a| &a.command).collect::<Vec<&String>>()
- );
- Ok(results)
- }
-
- async fn assert_search_commands(
- db: &ClientSqlite,
- mode: SearchMode,
- filter_mode: FilterMode,
- query: &str,
- expected_commands: Vec<&str>,
- ) {
- let results = assert_search_eq(db, mode, filter_mode, query, expected_commands.len())
- .await
- .unwrap();
- let commands: Vec<&str> = results.iter().map(|a| a.command.as_str()).collect();
- assert_eq!(commands, expected_commands);
- }
-
- async fn new_history_item(db: &mut ClientSqlite, cmd: &str) -> Result<()> {
- let mut captured: History = History::capture()
- .timestamp(OffsetDateTime::now_utc())
- .command(cmd)
- .cwd("/home/ellie")
- .build()
- .into();
-
- captured.exit = 0;
- captured.duration = 1;
- captured.session = "beep boop".to_string();
- captured.hostname = "booop".to_string();
-
- db.save(&captured).await
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn test_search_prefix() {
- let mut db = ClientSqlite::new("sqlite::memory:", test_local_timeout())
- .await
- .unwrap();
- new_history_item(&mut db, "ls /home/ellie").await.unwrap();
-
- assert_search_eq(&db, SearchMode::Prefix, FilterMode::Global, "ls", 1)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Prefix, FilterMode::Global, "/home", 0)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Prefix, FilterMode::Global, "ls ", 0)
- .await
- .unwrap();
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn test_search_fulltext() {
- let mut db = ClientSqlite::new("sqlite::memory:", test_local_timeout())
- .await
- .unwrap();
- new_history_item(&mut db, "ls /home/ellie").await.unwrap();
-
- assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, "ls", 1)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, "/home", 1)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, "ls ho", 1)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, "hm", 0)
- .await
- .unwrap();
-
- // regex
- assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, "r/^ls ", 1)
- .await
- .unwrap();
- assert_search_eq(
- &db,
- SearchMode::FullText,
- FilterMode::Global,
- "r/ls / ie$",
- 1,
- )
- .await
- .unwrap();
- assert_search_eq(
- &db,
- SearchMode::FullText,
- FilterMode::Global,
- "r/ls / !ie",
- 0,
- )
- .await
- .unwrap();
- assert_search_eq(
- &db,
- SearchMode::FullText,
- FilterMode::Global,
- "meow r/ls/",
- 0,
- )
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, "r//hom/", 1)
- .await
- .unwrap();
- assert_search_eq(
- &db,
- SearchMode::FullText,
- FilterMode::Global,
- "r//home//",
- 1,
- )
- .await
- .unwrap();
- assert_search_eq(
- &db,
- SearchMode::FullText,
- FilterMode::Global,
- "r//home///",
- 0,
- )
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::FullText, FilterMode::Global, "/home.*e", 0)
- .await
- .unwrap();
- assert_search_eq(
- &db,
- SearchMode::FullText,
- FilterMode::Global,
- "r/home.*e",
- 1,
- )
- .await
- .unwrap();
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn test_search_fuzzy() {
- let mut db = ClientSqlite::new("sqlite::memory:", test_local_timeout())
- .await
- .unwrap();
- new_history_item(&mut db, "ls /home/ellie").await.unwrap();
- new_history_item(&mut db, "ls /home/frank").await.unwrap();
- new_history_item(&mut db, "cd /home/Ellie").await.unwrap();
- new_history_item(&mut db, "/home/ellie/.bin/rustup")
- .await
- .unwrap();
-
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "ls /", 3)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "ls/", 2)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "l/h/", 2)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "/h/e", 3)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "/hmoe/", 0)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "ellie/home", 0)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "lsellie", 1)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, " ", 4)
- .await
- .unwrap();
-
- // single term operators
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "^ls", 2)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "'ls", 2)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "ellie$", 2)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "!^ls", 2)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "!ellie", 1)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "!ellie$", 2)
- .await
- .unwrap();
-
- // multiple terms
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "ls !ellie", 1)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "^ls !e$", 1)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "home !^ls", 2)
- .await
- .unwrap();
- assert_search_eq(
- &db,
- SearchMode::Fuzzy,
- FilterMode::Global,
- "'frank | 'rustup",
- 2,
- )
- .await
- .unwrap();
- assert_search_eq(
- &db,
- SearchMode::Fuzzy,
- FilterMode::Global,
- "'frank | 'rustup 'ls",
- 1,
- )
- .await
- .unwrap();
-
- // case matching
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "Ellie", 1)
- .await
- .unwrap();
-
- // regex
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "r/^ls ", 2)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "r/[Ee]llie", 3)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "/h/e r/^ls ", 1)
- .await
- .unwrap();
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn test_search_reordered_fuzzy() {
- let mut db = ClientSqlite::new("sqlite::memory:", test_local_timeout())
- .await
- .unwrap();
- // test ordering of results: we should choose the first, even though it happened longer ago.
-
- new_history_item(&mut db, "curl").await.unwrap();
- new_history_item(&mut db, "corburl").await.unwrap();
-
- // if fuzzy reordering is on, it should come back in a more sensible order
- assert_search_commands(
- &db,
- SearchMode::Fuzzy,
- FilterMode::Global,
- "curl",
- vec!["curl", "corburl"],
- )
- .await;
-
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "xxxx", 0)
- .await
- .unwrap();
- assert_search_eq(&db, SearchMode::Fuzzy, FilterMode::Global, "", 2)
- .await
- .unwrap();
- }
-
- #[tokio::test(flavor = "multi_thread")]
- #[expect(clippy::similar_names)]
- async fn test_paged_basic() {
- let mut db = ClientSqlite::new("sqlite::memory:", test_local_timeout())
- .await
- .unwrap();
-
- // Add 5 history items
- for i in 0..5 {
- new_history_item(&mut db, &format!("command{}", i))
- .await
- .unwrap();
- }
-
- // Create a paged iterator with page_size of 2
- let mut paged = db.all_paged(2, false, false);
-
- // First page should have 2 items
- let page1 = paged.next().await.unwrap();
- assert!(page1.is_some());
- assert_eq!(page1.unwrap().len(), 2);
-
- // Second page should have 2 items
- let page2 = paged.next().await.unwrap();
- assert!(page2.is_some());
- assert_eq!(page2.unwrap().len(), 2);
-
- // Third page should have 1 item
- let page3 = paged.next().await.unwrap();
- assert!(page3.is_some());
- assert_eq!(page3.unwrap().len(), 1);
-
- // Fourth page should be None (exhausted)
- let page4 = paged.next().await.unwrap();
- assert!(page4.is_none());
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn test_paged_empty() {
- let db = ClientSqlite::new("sqlite::memory:", test_local_timeout())
- .await
- .unwrap();
-
- // Create a paged iterator on empty database
- let mut paged = db.all_paged(10, false, false);
-
- // Should return None immediately
- let page = paged.next().await.unwrap();
- assert!(page.is_none());
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn test_paged_unique() {
- let mut db = ClientSqlite::new("sqlite::memory:", test_local_timeout())
- .await
- .unwrap();
-
- // Add duplicate commands
- new_history_item(&mut db, "duplicate").await.unwrap();
- new_history_item(&mut db, "duplicate").await.unwrap();
- new_history_item(&mut db, "unique1").await.unwrap();
- new_history_item(&mut db, "unique2").await.unwrap();
-
- // Without unique flag - should get all 4
- let mut paged = db.all_paged(10, false, false);
- let page = paged.next().await.unwrap().unwrap();
- assert_eq!(page.len(), 4);
-
- // With unique flag - should get 3 (duplicates collapsed)
- let mut paged_unique = db.all_paged(10, false, true);
- let paged_unique = paged_unique.next().await.unwrap().unwrap();
- assert_eq!(paged_unique.len(), 3);
- }
-
- #[tokio::test(flavor = "multi_thread")]
- async fn test_search_bench_dupes() {
- let context = Context {
- hostname: "test:host".to_string(),
- session: "beepboopiamasession".to_string(),
- cwd: "/home/ellie".to_string(),
- host_id: "test-host".to_string(),
- git_root: None,
- };
-
- let mut db = ClientSqlite::new("sqlite::memory:", test_local_timeout())
- .await
- .unwrap();
- for _i in 1..10000 {
- new_history_item(&mut db, "i am a duplicated command")
- .await
- .unwrap();
- }
- let start = Instant::now();
- let _results = db
- .search(
- SearchMode::Fuzzy,
- FilterMode::Global,
- &context,
- "",
- OptFilters {
- ..Default::default()
- },
- )
- .await
- .unwrap();
- let duration = start.elapsed();
-
- assert!(duration < Duration::from_secs(15));
- }
-}
-
-pub(crate) struct QueryTokenizer<'a> {
- query: &'a str,
- last_pos: usize,
-}
-
-pub(crate) enum QueryToken<'a> {
- Match(&'a str, bool),
- MatchStart(&'a str, bool),
- MatchEnd(&'a str, bool),
- MatchFull(&'a str, bool),
- Or,
- Regex(&'a str),
-}
-
-impl QueryToken<'_> {
- pub(crate) fn has_uppercase(&self) -> bool {
- match self {
- Self::Match(term, _)
- | Self::MatchStart(term, _)
- | Self::MatchEnd(term, _)
- | Self::MatchFull(term, _) => term.contains(char::is_uppercase),
- _ => false,
- }
- }
-
- pub(crate) fn is_inverse(&self) -> bool {
- match self {
- Self::Match(_, inv)
- | Self::MatchStart(_, inv)
- | Self::MatchEnd(_, inv)
- | Self::MatchFull(_, inv) => *inv,
- _ => false,
- }
- }
-}
-
-impl<'a> QueryTokenizer<'a> {
- pub(crate) fn new(query: &'a str) -> Self {
- Self { query, last_pos: 0 }
- }
-}
-
-impl<'a> Iterator for QueryTokenizer<'a> {
- type Item = QueryToken<'a>;
- fn next(&mut self) -> Option<Self::Item> {
- let remaining = &self.query[self.last_pos..];
- if remaining.is_empty() {
- return None;
- }
-
- if let Some(remaining) = remaining.strip_prefix("r/") {
- let (regex, next_pos) = if let Some(end) = remaining.find("/ ") {
- (&remaining[..end], self.last_pos + 2 + end + 2)
- } else if let Some(remaining) = remaining.strip_suffix('/') {
- (remaining, self.query.len())
- } else {
- (remaining, self.query.len())
- };
- self.last_pos = next_pos;
- Some(QueryToken::Regex(regex))
- } else {
- let (mut part, next_pos) = if let Some(sp) = remaining.find(' ') {
- (&remaining[..sp], self.last_pos + sp + 1)
- } else {
- (remaining, self.query.len())
- };
- self.last_pos = next_pos;
-
- if part == "|" {
- return Some(QueryToken::Or);
- }
-
- let is_inverse = part.strip_prefix('!').is_some_and(|s| {
- part = s;
- true
- });
-
- #[expect(clippy::option_if_let_else, reason = "It's too ugly")]
- let token = if let Some(s) = part.strip_prefix('^') {
- QueryToken::MatchStart(s, is_inverse)
- } else if let Some(s) = part.strip_suffix('$') {
- QueryToken::MatchEnd(s, is_inverse)
- } else if let Some(s) = part.strip_prefix('\'') {
- QueryToken::MatchFull(s, is_inverse)
- } else {
- QueryToken::Match(part, is_inverse)
- };
- Some(token)
- }
- }
-}
diff --git a/crates/client/src/atuin_client/encryption.rs b/crates/client/src/atuin_client/encryption.rs
deleted file mode 100644
index f1c921cb..00000000
--- a/crates/client/src/atuin_client/encryption.rs
+++ /dev/null
@@ -1,142 +0,0 @@
-// The general idea is that we NEVER send cleartext history to the server
-// This way the odds of anything private ending up where it should not are
-// very low
-// The server authenticates via the usual username and password. This has
-// nothing to do with the encryption, and is purely authentication! The client
-// generates its own secret key, and encrypts all shell history with libsodium's
-// secretbox. The data is then sent to the server, where it is stored. All
-// clients must share the secret in order to be able to sync, as it is needed
-// to decrypt
-
-use std::io::prelude::Write;
-
-use base64::prelude::{BASE64_STANDARD, Engine};
-pub(crate) use crypto_secretbox::Key;
-use crypto_secretbox::{KeyInit, XSalsa20Poly1305, aead::OsRng};
-use eyre::{Context, Result, bail, ensure, eyre};
-use fs_err as fs;
-use rmp::Marker;
-
-use crate::atuin_client::settings::Settings;
-
-pub(crate) fn generate_encoded_key() -> Result<(Key, String)> {
- let key = XSalsa20Poly1305::generate_key(&mut OsRng);
- let encoded = encode_key(&key)?;
-
- Ok((key, encoded))
-}
-
-pub(crate) fn new_key(settings: &Settings) -> Result<Key> {
- if settings.sync.encryption_key()?.is_some() {
- bail!("key already exists! cannot overwrite");
- } else if let Some(path) = settings.sync.encryption_key_path.as_ref() {
- let (key, encoded) = generate_encoded_key()?;
-
- let mut file = fs::File::create(path)?;
- file.write_all(encoded.as_bytes())?;
-
- Ok(key)
- } else {
- bail!("No key-path set, cannot generate key")
- }
-}
-
-// Loads the secret key, will create + save if it doesn't exist
-pub(crate) fn load_key(settings: &Settings) -> Result<Key> {
- if let Some(key) = settings.sync.encryption_key()? {
- Ok(key)
- } else {
- Ok(new_key(settings)?)
- }
-}
-
-pub(crate) fn encode_key(key: &Key) -> Result<String> {
- let mut buf = vec![];
- rmp::encode::write_array_len(&mut buf, key.len() as u32)
- .wrap_err("could not encode key to message pack")?;
- for b in key {
- rmp::encode::write_uint(&mut buf, u64::from(*b))
- .wrap_err("could not encode key to message pack")?;
- }
- let buf = BASE64_STANDARD.encode(buf);
-
- Ok(buf)
-}
-
-pub(crate) fn decode_key(key: &str) -> Result<Key> {
- use rmp::decode;
-
- let buf = BASE64_STANDARD
- .decode(key.trim_end())
- .wrap_err("encryption key is not a valid base64 encoding")?;
-
- // old code wrote the key as a fixed length array of 32 bytes
- // new code writes the key with a length prefix
- if let Ok(key) = <[u8; 32]>::try_from(&*buf) {
- Ok(key.into())
- } else {
- let mut bytes = decode::Bytes::new(&buf);
-
- match Marker::from_u8(buf[0]) {
- Marker::Bin8 => {
- let len = decode::read_bin_len(&mut bytes).map_err(|err| eyre!("{err:?}"))?;
- ensure!(len == 32, "encryption key is not the correct size");
- let key = <[u8; 32]>::try_from(bytes.remaining_slice())
- .context("could not decode encryption key")?;
- Ok(key.into())
- }
- Marker::Array16 => {
- let len = decode::read_array_len(&mut bytes).map_err(|err| eyre!("{err:?}"))?;
- ensure!(len == 32, "encryption key is not the correct size");
-
- let mut key = Key::default();
- for i in &mut key {
- *i = decode::read_int(&mut bytes).map_err(|err| eyre!("{err:?}"))?;
- }
- Ok(key)
- }
- _ => bail!("could not decode encryption key"),
- }
- }
-}
-
-#[cfg(test)]
-mod test {
- #[test]
- fn key_encodings() {
- use super::{Key, decode_key, encode_key};
-
- // a history of our key encodings.
- // v11.0.0 xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q==
- // v12.0.0 xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q==
- // v13.0.0 xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q==
- // v13.0.1 xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q==
- // v14.0.0 xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q==
- // v14.0.1 xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q==
- // c7d89c1 3AAgG1sqW8zSawnM2MyqzL7M8j4GVEXMlMyUNcz7dczizKfMrTRSIsyKbsypfFzM5Q== (https://github.com/ellie/atuin/pull/805)
- // b53ca35 3AAgG1sqW8zSawnM2MyqzL7M8j4GVEXMlMyUNcz7dczizKfMrTRSIsyKbsypfFzM5Q== (https://github.com/ellie/atuin/pull/974)
- // v15.0.0 3AAgG1sqW8zSawnM2MyqzL7M8j4GVEXMlMyUNcz7dczizKfMrTRSIsyKbsypfFzM5Q==
- // b8b57c8 xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q== (https://github.com/ellie/atuin/pull/1057)
- // 8c94d79 3AAgG1sqW8zSawnM2MyqzL7M8j4GVEXMlMyUNcz7dczizKfMrTRSIsyKbsypfFzM5Q== (https://github.com/ellie/atuin/pull/1089)
-
- let key = Key::from([
- 27, 91, 42, 91, 210, 107, 9, 216, 170, 190, 242, 62, 6, 84, 69, 148, 148, 53, 251, 117,
- 226, 167, 173, 52, 82, 34, 138, 110, 169, 124, 92, 229,
- ]);
-
- assert_eq!(
- encode_key(&key).unwrap(),
- "3AAgG1sqW8zSawnM2MyqzL7M8j4GVEXMlMyUNcz7dczizKfMrTRSIsyKbsypfFzM5Q=="
- );
-
- // key encodings we have to support
- let valid_encodings = [
- "xCAbWypb0msJ2Kq+8j4GVEWUlDX7deKnrTRSIopuqXxc5Q==",
- "3AAgG1sqW8zSawnM2MyqzL7M8j4GVEXMlMyUNcz7dczizKfMrTRSIsyKbsypfFzM5Q==",
- ];
-
- for k in valid_encodings {
- assert_eq!(decode_key(k).expect(k), key);
- }
- }
-}
diff --git a/crates/client/src/atuin_client/history.rs b/crates/client/src/atuin_client/history.rs
deleted file mode 100644
index c38d8ccc..00000000
--- a/crates/client/src/atuin_client/history.rs
+++ /dev/null
@@ -1,686 +0,0 @@
-use core::fmt::Formatter;
-use rmp::decode::DecodeStringError;
-use rmp::decode::ValueReadError;
-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 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 time::OffsetDateTime;
-
-mod builder;
-pub(crate) mod store;
-
-pub(crate) const HISTORY_VERSION_V0: &str = "v0";
-pub(crate) const HISTORY_VERSION_V1: &str = "v1";
-const HISTORY_RECORD_VERSION_V0: u16 = 0;
-const HISTORY_RECORD_VERSION_V1: u16 = 1;
-pub(crate) const HISTORY_VERSION: &str = HISTORY_VERSION_V1;
-pub(crate) const HISTORY_TAG: &str = "history";
-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);
-
-impl Display for HistoryId {
- fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
- write!(f, "{}", self.0)
- }
-}
-
-impl From<String> for HistoryId {
- fn from(s: String) -> Self {
- Self(s)
- }
-}
-
-/// Client-side history entry.
-///
-/// Client stores data unencrypted, and only encrypts it before sending to the server.
-///
-/// To create a new history entry, use one of the builders:
-/// - [`History::import()`] to import an entry from the shell history file
-/// - [`History::capture()`] to capture an entry via hook
-/// - [`History::from_db()`] to create an instance from the database entry
-//
-// ## Implementation Notes
-//
-// 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 {
- /// A client-generated ID, used to identify the entry when syncing.
- ///
- /// Stored as `client_id` in the database.
- pub(crate) id: HistoryId,
-
- /// When the command was run.
- pub(crate) timestamp: OffsetDateTime,
-
- /// How long the command took to run.
- pub(crate) duration: i64,
-
- /// The exit code of the command.
- pub(crate) exit: i64,
-
- /// The command that was run.
- pub(crate) command: String,
-
- /// The current working directory when the command was run.
- pub(crate) cwd: String,
-
- /// The session ID, associated with a terminal session.
- pub(crate) session: String,
-
- /// The hostname of the machine the command was run on.
- pub(crate) hostname: String,
-
- /// Who wrote this command (human user or automation/agent identity).
- pub(crate) author: String,
-
- /// Optional rationale for why the command was executed.
- pub(crate) intent: Option<String>,
-
- /// Timestamp, which is set when the entry is deleted, allowing a soft delete.
- pub(crate) deleted_at: Option<OffsetDateTime>,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)]
-pub(crate) struct HistoryStats {
- /// The command that was ran after this one in the session
- pub(crate) next: Option<History>,
-
- /// The command that was ran before this one in the session
- pub(crate) previous: Option<History>,
-
- /// How many times has this command been ran?
- pub(crate) total: u64,
-
- pub(crate) average_duration: u64,
-
- pub(crate) exits: Vec<(i64, i64)>,
-
- pub(crate) day_of_week: Vec<(String, i64)>,
-
- pub(crate) duration_over_time: Vec<(String, i64)>,
-}
-
-impl History {
- pub(crate) fn author_from_hostname(hostname: &str) -> String {
- hostname
- .split_once(':')
- .map_or_else(|| hostname.to_owned(), |(_, user)| user.to_owned())
- }
-
- fn normalize_optional_field(field: Option<String>) -> Option<String> {
- field.and_then(|value| {
- let trimmed = value.trim();
- if trimmed.is_empty() {
- None
- } else {
- Some(trimmed.to_owned())
- }
- })
- }
-
- #[expect(clippy::too_many_arguments)]
- fn new(
- timestamp: OffsetDateTime,
- command: String,
- cwd: String,
- exit: i64,
- duration: i64,
- session: Option<String>,
- hostname: Option<String>,
- author: Option<String>,
- intent: Option<String>,
- deleted_at: Option<OffsetDateTime>,
- ) -> Self {
- let session = session
- .or_else(|| env::var("ATUIN_SESSION").ok())
- .unwrap_or_else(|| uuid_v7().as_simple().to_string());
- let hostname = hostname.unwrap_or_else(get_host_user);
- let author = Self::normalize_optional_field(author)
- .or_else(|| Self::normalize_optional_field(env::var(HISTORY_AUTHOR_ENV).ok()))
- .unwrap_or_else(|| Self::author_from_hostname(hostname.as_str()));
- let intent = Self::normalize_optional_field(intent)
- .or_else(|| Self::normalize_optional_field(env::var(HISTORY_INTENT_ENV).ok()));
-
- Self {
- id: uuid_v7().as_simple().to_string().into(),
- timestamp,
- command,
- cwd,
- exit,
- duration,
- session,
- hostname,
- author,
- intent,
- deleted_at,
- }
- }
-
- pub(crate) fn serialize(&self) -> Result<DecryptedData> {
- // This is pretty much the same as what we used for the old history, with one difference -
- // it uses integers for timestamps rather than a string format.
-
- use rmp::encode;
-
- let mut output = vec![];
-
- // write the version
- encode::write_u16(&mut output, HISTORY_RECORD_VERSION_V1)?;
- let include_intent = self.intent.is_some();
- encode::write_array_len(&mut output, 10 + u32::from(include_intent))?;
-
- encode::write_str(&mut output, &self.id.0)?;
- encode::write_u64(&mut output, self.timestamp.unix_timestamp_nanos() as u64)?;
- encode::write_sint(&mut output, self.duration)?;
- encode::write_sint(&mut output, self.exit)?;
- encode::write_str(&mut output, &self.command)?;
- encode::write_str(&mut output, &self.cwd)?;
- encode::write_str(&mut output, &self.session)?;
- encode::write_str(&mut output, &self.hostname)?;
-
- match self.deleted_at {
- Some(d) => encode::write_u64(&mut output, d.unix_timestamp_nanos() as u64)?,
- None => encode::write_nil(&mut output)?,
- }
-
- encode::write_str(&mut output, self.author.as_str())?;
- if let Some(intent) = &self.intent {
- encode::write_str(&mut output, intent.as_str())?;
- }
-
- Ok(DecryptedData(output))
- }
-
- fn read_optional_string(bytes: &[u8]) -> Result<(Option<String>, &[u8])> {
- use rmp::decode;
-
- fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report {
- eyre!("{err:?}")
- }
-
- match decode::read_str_from_slice(bytes) {
- Ok((value, bytes)) => Ok((Some(value.to_owned()), bytes)),
- Err(DecodeStringError::TypeMismatch(Marker::Null)) => {
- let mut cursor = Bytes::new(bytes);
- decode::read_nil(&mut cursor).map_err(error_report)?;
-
- Ok((None, cursor.remaining_slice()))
- }
- Err(err) => Err(error_report(err)),
- }
- }
-
- fn deserialize_v0(bytes: &[u8]) -> Result<Self> {
- use rmp::decode;
-
- fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report {
- eyre!("{err:?}")
- }
-
- let mut bytes = Bytes::new(bytes);
-
- let version = decode::read_u16(&mut bytes).map_err(error_report)?;
-
- if version != HISTORY_RECORD_VERSION_V0 {
- bail!("expected decoding v0 record, found v{version}");
- }
-
- let nfields = decode::read_array_len(&mut bytes).map_err(error_report)?;
-
- if nfields != 9 {
- bail!("cannot decrypt history from a different version of Atuin");
- }
-
- let bytes = bytes.remaining_slice();
- let (id, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
-
- let mut bytes = Bytes::new(bytes);
- let timestamp = decode::read_u64(&mut bytes).map_err(error_report)?;
- let duration = decode::read_int(&mut bytes).map_err(error_report)?;
- let exit = decode::read_int(&mut bytes).map_err(error_report)?;
-
- let bytes = bytes.remaining_slice();
- let (command, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
- let (cwd, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
- let (session, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
- let (hostname, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
-
- let mut bytes = Bytes::new(bytes);
-
- let (deleted_at, bytes) = match decode::read_u64(&mut bytes) {
- Ok(unix) => (Some(unix), bytes.remaining_slice()),
- // we accept null here
- Err(ValueReadError::TypeMismatch(Marker::Null)) => (None, bytes.remaining_slice()),
- Err(err) => return Err(error_report(err)),
- };
- if !bytes.is_empty() {
- bail!("trailing bytes in encoded history. malformed")
- }
-
- Ok(Self {
- id: id.to_owned().into(),
- timestamp: OffsetDateTime::from_unix_timestamp_nanos(i128::from(timestamp))?,
- duration,
- exit,
- command: command.to_owned(),
- cwd: cwd.to_owned(),
- session: session.to_owned(),
- hostname: hostname.to_owned(),
- author: Self::author_from_hostname(hostname),
- intent: None,
- deleted_at: deleted_at
- .map(|t| OffsetDateTime::from_unix_timestamp_nanos(i128::from(t)))
- .transpose()?,
- })
- }
-
- fn deserialize_v1(bytes: &[u8]) -> Result<Self> {
- use rmp::decode;
-
- fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report {
- eyre!("{err:?}")
- }
-
- let mut bytes = Bytes::new(bytes);
-
- let version = decode::read_u16(&mut bytes).map_err(error_report)?;
-
- if version != HISTORY_RECORD_VERSION_V1 {
- bail!("expected decoding v1 record, found v{version}");
- }
-
- let nfields = decode::read_array_len(&mut bytes).map_err(error_report)?;
-
- if !(10..=11).contains(&nfields) {
- bail!("cannot decrypt history from a different version of Atuin");
- }
-
- let bytes = bytes.remaining_slice();
- let (id, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
-
- let mut bytes = Bytes::new(bytes);
- let timestamp = decode::read_u64(&mut bytes).map_err(error_report)?;
- let duration = decode::read_int(&mut bytes).map_err(error_report)?;
- let exit = decode::read_int(&mut bytes).map_err(error_report)?;
-
- let bytes = bytes.remaining_slice();
- let (command, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
- let (cwd, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
- let (session, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
- let (hostname, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
-
- let mut bytes = Bytes::new(bytes);
-
- let (deleted_at, bytes) = match decode::read_u64(&mut bytes) {
- Ok(unix) => (Some(unix), bytes.remaining_slice()),
- // we accept null here
- Err(ValueReadError::TypeMismatch(Marker::Null)) => (None, bytes.remaining_slice()),
- Err(err) => return Err(error_report(err)),
- };
- let (author, bytes) = Self::read_optional_string(bytes)?;
- let (intent, bytes) = if nfields > 10 {
- Self::read_optional_string(bytes)?
- } else {
- (None, bytes)
- };
-
- if !bytes.is_empty() {
- bail!("trailing bytes in encoded history. malformed")
- }
-
- Ok(Self {
- id: id.to_owned().into(),
- timestamp: OffsetDateTime::from_unix_timestamp_nanos(i128::from(timestamp))?,
- duration,
- exit,
- command: command.to_owned(),
- cwd: cwd.to_owned(),
- session: session.to_owned(),
- hostname: hostname.to_owned(),
- author: author.unwrap_or_else(|| Self::author_from_hostname(hostname)),
- intent,
- deleted_at: deleted_at
- .map(|t| OffsetDateTime::from_unix_timestamp_nanos(i128::from(t)))
- .transpose()?,
- })
- }
-
- pub(crate) fn deserialize(bytes: &[u8], version: &str) -> Result<Self> {
- match version {
- HISTORY_VERSION_V0 => Self::deserialize_v0(bytes),
- HISTORY_VERSION_V1 => Self::deserialize_v1(bytes),
-
- _ => bail!("unknown version {version:?}"),
- }
- }
-
- /// Builder for a history entry that is captured via hook.
- ///
- /// This builder is used only at the `start` step of the hook,
- /// so it doesn't have any fields which are known only after
- /// the command is finished, such as `exit` or `duration`.
- ///
- /// ## Examples
- /// ```rust
- /// use crate::atuin_client::history::History;
- ///
- /// let history: History = History::capture()
- /// .timestamp(time::OffsetDateTime::now_utc())
- /// .command("ls -la")
- /// .cwd("/home/user")
- /// .build()
- /// .into();
- /// ```
- ///
- /// Command without any required info cannot be captured, which is forced at compile time:
- ///
- /// ```compile_fail
- /// use crate::atuin_client::history::History;
- ///
- /// // this will not compile because `cwd` is missing
- /// let history: History = History::capture()
- /// .timestamp(time::OffsetDateTime::now_utc())
- /// .command("ls -la")
- /// .build()
- /// .into();
- /// ```
- pub(crate) fn capture() -> builder::HistoryCapturedBuilder {
- builder::HistoryCaptured::builder()
- }
-
- /// Builder for a history entry that is captured via hook, and sent to the daemon.
- ///
- /// This builder is used only at the `start` step of the hook,
- /// so it doesn't have any fields which are known only after
- /// the command is finished, such as `exit` or `duration`.
- ///
- /// It does, however, include information that can usually be inferred.
- ///
- /// This is because the daemon we are sending a request to lacks the context of the command
- ///
- /// ## Examples
- /// ```rust
- /// use crate::atuin_client::history::History;
- ///
- /// let history: History = History::daemon()
- /// .timestamp(time::OffsetDateTime::now_utc())
- /// .command("ls -la")
- /// .cwd("/home/user")
- /// .session("018deb6e8287781f9973ef40e0fde76b")
- /// .hostname("computer:ellie")
- /// .build()
- /// .into();
- /// ```
- ///
- /// Command without any required info cannot be captured, which is forced at compile time:
- ///
- /// ```compile_fail
- /// use crate::atuin_client::history::History;
- ///
- /// // this will not compile because `hostname` is missing
- /// let history: History = History::daemon()
- /// .timestamp(time::OffsetDateTime::now_utc())
- /// .command("ls -la")
- /// .cwd("/home/user")
- /// .session("018deb6e8287781f9973ef40e0fde76b")
- /// .build()
- /// .into();
- /// ```
- pub(crate) fn daemon() -> builder::HistoryDaemonCaptureBuilder {
- builder::HistoryDaemonCapture::builder()
- }
-
- /// Builder for a history entry that is imported from the database.
- ///
- /// All fields are required, as they are all present in the database.
- ///
- /// ```compile_fail
- /// use crate::atuin_client::history::History;
- ///
- /// // this will not compile because `id` field is missing
- /// let history: History = History::from_db()
- /// .timestamp(time::OffsetDateTime::now_utc())
- /// .command("ls -la".to_string())
- /// .cwd("/home/user".to_string())
- /// .exit(0)
- /// .duration(100)
- /// .session("somesession".to_string())
- /// .hostname("localhost".to_string())
- /// .author("user".to_string())
- /// .intent(None)
- /// .deleted_at(None)
- /// .build()
- /// .into();
- /// ```
- pub(crate) fn from_db() -> builder::HistoryFromDbBuilder {
- builder::HistoryFromDb::builder()
- }
-
- pub(crate) fn success(&self) -> bool {
- self.exit == 0 || self.duration == -1
- }
-
- pub(crate) fn should_save(&self, settings: &Settings) -> bool {
- !(self.command.is_empty()
- || settings.history_filter.is_match(&self.command)
- || settings.cwd_filter.is_match(&self.cwd)
- || (settings.secrets_filter && SECRET_PATTERNS_RE.is_match(&self.command)))
- }
-}
-
-#[cfg(test)]
-mod tests {
- use regex::RegexSet;
- use time::macros::datetime;
-
- use crate::atuin_client::{history::HISTORY_VERSION, settings::Settings};
-
- use super::History;
-
- // Test that we don't save history where necessary
- #[test]
- fn privacy_test() {
- let settings = Settings {
- cwd_filter: RegexSet::new(["^/supasecret"]).unwrap(),
- history_filter: RegexSet::new(["^psql"]).unwrap(),
- ..Settings::default()
- };
-
- let normal_command: History = History::capture()
- .timestamp(time::OffsetDateTime::now_utc())
- .command("echo foo")
- .cwd("/")
- .build()
- .into();
-
- let with_space: History = History::capture()
- .timestamp(time::OffsetDateTime::now_utc())
- .command(" echo bar")
- .cwd("/")
- .build()
- .into();
-
- let empty: History = History::capture()
- .timestamp(time::OffsetDateTime::now_utc())
- .command("")
- .cwd("/")
- .build()
- .into();
-
- let stripe_key: History = History::capture()
- .timestamp(time::OffsetDateTime::now_utc())
- .command("curl foo.com/bar?key=sk_test_1234567890abcdefghijklmnop")
- .cwd("/")
- .build()
- .into();
-
- let secret_dir: History = History::capture()
- .timestamp(time::OffsetDateTime::now_utc())
- .command("echo ohno")
- .cwd("/supasecret")
- .build()
- .into();
-
- let with_psql: History = History::capture()
- .timestamp(time::OffsetDateTime::now_utc())
- .command("psql")
- .cwd("/supasecret")
- .build()
- .into();
-
- assert!(normal_command.should_save(&settings));
- assert!(!with_space.should_save(&settings));
- assert!(!empty.should_save(&settings));
- assert!(!stripe_key.should_save(&settings));
- assert!(!secret_dir.should_save(&settings));
- assert!(!with_psql.should_save(&settings));
- }
-
- #[test]
- fn disable_secrets() {
- let settings = Settings {
- secrets_filter: false,
- ..Settings::new().unwrap()
- };
-
- let stripe_key: History = History::capture()
- .timestamp(time::OffsetDateTime::now_utc())
- .command("curl foo.com/bar?key=sk_test_1234567890abcdefghijklmnop")
- .cwd("/")
- .build()
- .into();
-
- assert!(stripe_key.should_save(&settings));
- }
-
- #[test]
- fn test_serialize_deserialize() {
- let history = History {
- id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(),
- timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00),
- duration: 49_206_000,
- exit: 0,
- command: "git status".to_owned(),
- cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(),
- session: "b97d9a306f274473a203d2eba41f9457".to_owned(),
- hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(),
- author: "conrad.ludgate".to_owned(),
- intent: None,
- deleted_at: None,
- };
-
- let serialized = history.serialize().expect("failed to serialize history");
- assert_eq!(
- &serialized.0[0..3],
- [205, 0, 1],
- "should encode as history v1"
- );
-
- let deserialized = History::deserialize(&serialized.0, HISTORY_VERSION)
- .expect("failed to deserialize history");
- assert_eq!(history, deserialized);
- }
-
- #[test]
- fn test_serialize_deserialize_deleted() {
- let history = History {
- id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(),
- timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00),
- duration: 49_206_000,
- exit: 0,
- command: "git status".to_owned(),
- cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(),
- session: "b97d9a306f274473a203d2eba41f9457".to_owned(),
- hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(),
- author: "conrad.ludgate".to_owned(),
- intent: None,
- deleted_at: Some(datetime!(2023-11-19 20:18 +00:00)),
- };
-
- let serialized = history.serialize().expect("failed to serialize history");
-
- let deserialized = History::deserialize(&serialized.0, HISTORY_VERSION)
- .expect("failed to deserialize history");
-
- assert_eq!(history, deserialized);
- }
-
- #[test]
- fn test_serialize_deserialize_with_author_and_intent() {
- let history = History {
- id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(),
- timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00),
- duration: 49_206_000,
- exit: 0,
- command: "git status".to_owned(),
- cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(),
- session: "b97d9a306f274473a203d2eba41f9457".to_owned(),
- hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(),
- author: "claude".to_owned(),
- intent: Some("check repository status".to_owned()),
- deleted_at: None,
- };
-
- let serialized = history.serialize().expect("failed to serialize history");
- let deserialized = History::deserialize(&serialized.0, HISTORY_VERSION)
- .expect("failed to deserialize history");
-
- assert_eq!(history, deserialized);
- }
-
- #[test]
- fn test_serialize_deserialize_version() {
- // v0
- let bytes_v0 = [
- 205, 0, 0, 153, 217, 32, 54, 54, 100, 49, 54, 99, 98, 101, 101, 55, 99, 100, 52, 55,
- 53, 51, 56, 101, 53, 99, 53, 98, 56, 98, 52, 52, 101, 57, 48, 48, 54, 101, 207, 23, 99,
- 98, 117, 24, 210, 246, 128, 206, 2, 238, 210, 240, 0, 170, 103, 105, 116, 32, 115, 116,
- 97, 116, 117, 115, 217, 42, 47, 85, 115, 101, 114, 115, 47, 99, 111, 110, 114, 97, 100,
- 46, 108, 117, 100, 103, 97, 116, 101, 47, 68, 111, 99, 117, 109, 101, 110, 116, 115,
- 47, 99, 111, 100, 101, 47, 97, 116, 117, 105, 110, 217, 32, 98, 57, 55, 100, 57, 97,
- 51, 48, 54, 102, 50, 55, 52, 52, 55, 51, 97, 50, 48, 51, 100, 50, 101, 98, 97, 52, 49,
- 102, 57, 52, 53, 55, 187, 102, 118, 102, 103, 57, 51, 54, 99, 48, 107, 112, 102, 58,
- 99, 111, 110, 114, 97, 100, 46, 108, 117, 100, 103, 97, 116, 101, 192,
- ];
-
- let deserialized = History::deserialize(&bytes_v0, "v0");
- assert!(deserialized.is_ok());
-
- let deserialized = History::deserialize(&bytes_v0, HISTORY_VERSION);
- assert!(deserialized.is_err());
-
- let current = History {
- id: "66d16cbee7cd47538e5c5b8b44e9006e".to_owned().into(),
- timestamp: datetime!(2023-05-28 18:35:40.633872 +00:00),
- duration: 49_206_000,
- exit: 0,
- command: "git status".to_owned(),
- cwd: "/Users/conrad.ludgate/Documents/code/atuin".to_owned(),
- session: "b97d9a306f274473a203d2eba41f9457".to_owned(),
- hostname: "fvfg936c0kpf:conrad.ludgate".to_owned(),
- author: "conrad.ludgate".to_owned(),
- intent: None,
- deleted_at: None,
- };
-
- let bytes_v1 = current.serialize().expect("failed to serialize history");
- let deserialized = History::deserialize(&bytes_v1.0, HISTORY_VERSION);
- assert!(deserialized.is_ok());
-
- let deserialized = History::deserialize(&bytes_v1.0, "v0");
- assert!(deserialized.is_err());
- }
-}
diff --git a/crates/client/src/atuin_client/history/builder.rs b/crates/client/src/atuin_client/history/builder.rs
deleted file mode 100644
index daa4ef49..00000000
--- a/crates/client/src/atuin_client/history/builder.rs
+++ /dev/null
@@ -1,154 +0,0 @@
-use typed_builder::TypedBuilder;
-
-use super::History;
-
-/// Builder for a history entry that is imported from shell history.
-///
-/// The only two required fields are `timestamp` and `command`.
-#[derive(Debug, Clone, TypedBuilder)]
-pub(crate) struct HistoryImported {
- timestamp: time::OffsetDateTime,
- #[builder(setter(into))]
- command: String,
- #[builder(default = "unknown".into(), setter(into))]
- cwd: String,
- #[builder(default = -1)]
- exit: i64,
- #[builder(default = -1)]
- duration: i64,
- #[builder(default, setter(strip_option, into))]
- session: Option<String>,
- #[builder(default, setter(strip_option, into))]
- hostname: Option<String>,
- #[builder(default, setter(strip_option, into))]
- author: Option<String>,
- #[builder(default, setter(strip_option, into))]
- intent: Option<String>,
-}
-
-impl From<HistoryImported> for History {
- fn from(imported: HistoryImported) -> Self {
- Self::new(
- imported.timestamp,
- imported.command,
- imported.cwd,
- imported.exit,
- imported.duration,
- imported.session,
- imported.hostname,
- imported.author,
- imported.intent,
- None,
- )
- }
-}
-
-/// Builder for a history entry that is captured via hook.
-///
-/// This builder is used only at the `start` step of the hook,
-/// so it doesn't have any fields which are known only after
-/// the command is finished, such as `exit` or `duration`.
-#[derive(Debug, Clone, TypedBuilder)]
-pub(crate) struct HistoryCaptured {
- timestamp: time::OffsetDateTime,
- #[builder(setter(into))]
- command: String,
- #[builder(setter(into))]
- cwd: String,
- #[builder(default, setter(strip_option, into))]
- author: Option<String>,
- #[builder(default, setter(strip_option, into))]
- intent: Option<String>,
-}
-
-impl From<HistoryCaptured> for History {
- fn from(captured: HistoryCaptured) -> Self {
- Self::new(
- captured.timestamp,
- captured.command,
- captured.cwd,
- -1,
- -1,
- None,
- None,
- captured.author,
- captured.intent,
- None,
- )
- }
-}
-
-/// Builder for a history entry that is loaded from the database.
-///
-/// All fields are required, as they are all present in the database.
-#[derive(Debug, Clone, TypedBuilder)]
-pub(crate) struct HistoryFromDb {
- id: String,
- timestamp: time::OffsetDateTime,
- command: String,
- cwd: String,
- exit: i64,
- duration: i64,
- session: String,
- hostname: String,
- author: String,
- intent: Option<String>,
- deleted_at: Option<time::OffsetDateTime>,
-}
-
-impl From<HistoryFromDb> for History {
- fn from(from_db: HistoryFromDb) -> Self {
- Self {
- id: from_db.id.into(),
- timestamp: from_db.timestamp,
- exit: from_db.exit,
- command: from_db.command,
- cwd: from_db.cwd,
- duration: from_db.duration,
- session: from_db.session,
- hostname: from_db.hostname,
- author: from_db.author,
- intent: from_db.intent,
- deleted_at: from_db.deleted_at,
- }
- }
-}
-
-/// Builder for a history entry that is captured via hook and sent to the daemon
-///
-/// This builder is similar to Capture, but we just require more information up front.
-/// For the old setup, we could just rely on `History::new` to read some of the missing
-/// data. This is no longer the case.
-#[derive(Debug, Clone, TypedBuilder)]
-pub(crate) struct HistoryDaemonCapture {
- timestamp: time::OffsetDateTime,
- #[builder(setter(into))]
- command: String,
- #[builder(setter(into))]
- cwd: String,
- #[builder(setter(into))]
- session: String,
- #[builder(setter(into))]
- hostname: String,
- #[builder(default, setter(strip_option, into))]
- author: Option<String>,
- #[builder(default, setter(strip_option, into))]
- intent: Option<String>,
-}
-
-impl From<HistoryDaemonCapture> for History {
- fn from(captured: HistoryDaemonCapture) -> Self {
- Self::new(
- captured.timestamp,
- captured.command,
- captured.cwd,
- -1,
- -1,
- Some(captured.session),
- Some(captured.hostname),
- captured.author,
- captured.intent,
- None,
- )
- }
-}
diff --git a/crates/client/src/atuin_client/history/store.rs b/crates/client/src/atuin_client/history/store.rs
deleted file mode 100644
index 9c7771cc..00000000
--- a/crates/client/src/atuin_client/history/store.rs
+++ /dev/null
@@ -1,437 +0,0 @@
-use std::{collections::HashSet, fmt::Write, time::Duration};
-
-use eyre::{Result, bail, eyre};
-use indicatif::{ProgressBar, ProgressState, ProgressStyle};
-use rmp::decode::Bytes;
-use tracing::debug;
-
-use crate::atuin_client::{
- database::{ClientSqlite, current_context},
- record::{encryption::PASETO_V4, sqlite_store::SqliteStore},
-};
-use crate::atuin_common::record::{DecryptedData, Host, HostId, Record, RecordId, RecordIdx};
-
-use super::{HISTORY_TAG, HISTORY_VERSION, HISTORY_VERSION_V0, History, HistoryId};
-
-#[derive(Debug, Clone)]
-pub(crate) struct HistoryStore {
- pub(crate) store: SqliteStore,
- pub(crate) host_id: HostId,
- pub(crate) encryption_key: [u8; 32],
-}
-
-#[derive(Debug, Eq, PartialEq, Clone)]
-pub(crate) enum HistoryRecord {
- Create(History), // Create a history record
- Delete(HistoryId), // Delete a history record, identified by ID
-}
-
-impl HistoryRecord {
- /// Serialize a history record, returning `DecryptedData`
- /// The record will be of a certain type
- /// We map those like so:
- ///
- /// `HistoryRecord::Create` -> 0
- /// `HistoryRecord::Delete`-> 1
- ///
- /// This numeric identifier is then written as the first byte to the buffer. For history, we
- /// append the serialized history right afterwards, to avoid having to handle serialization
- /// twice.
- ///
- /// Deletion simply refers to the history by ID
- pub(crate) fn serialize(&self) -> Result<DecryptedData> {
- // probably don't actually need to use rmp here, but if we ever need to extend it, it's a
- // nice wrapper around raw byte stuff
- use rmp::encode;
-
- let mut output = vec![];
-
- match self {
- Self::Create(history) => {
- // 0 -> a history create
- encode::write_u8(&mut output, 0)?;
-
- let bytes = history.serialize()?;
-
- encode::write_bin(&mut output, &bytes.0)?;
- }
- Self::Delete(id) => {
- // 1 -> a history delete
- encode::write_u8(&mut output, 1)?;
- encode::write_str(&mut output, id.0.as_str())?;
- }
- }
-
- Ok(DecryptedData(output))
- }
-
- pub(crate) fn deserialize(bytes: &DecryptedData, version: &str) -> Result<Self> {
- use rmp::decode;
-
- fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report {
- eyre!("{err:?}")
- }
-
- let mut bytes = Bytes::new(&bytes.0);
-
- let record_type = decode::read_u8(&mut bytes).map_err(error_report)?;
-
- match record_type {
- // 0 -> HistoryRecord::Create
- 0 => {
- // not super useful to us atm, but perhaps in the future
- // written by write_bin above
- decode::read_bin_len(&mut bytes).map_err(error_report)?;
-
- let record = History::deserialize(bytes.remaining_slice(), version)?;
-
- Ok(Self::Create(record))
- }
-
- // 1 -> HistoryRecord::Delete
- 1 => {
- let bytes = bytes.remaining_slice();
- let (id, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?;
-
- if !bytes.is_empty() {
- bail!(
- "trailing bytes decoding HistoryRecord::Delete - malformed? got {bytes:?}"
- );
- }
-
- Ok(Self::Delete(id.to_string().into()))
- }
-
- n => {
- bail!("unknown HistoryRecord type {n}")
- }
- }
- }
-}
-
-impl HistoryStore {
- pub(crate) fn new(store: SqliteStore, host_id: HostId, encryption_key: [u8; 32]) -> Self {
- Self {
- store,
- host_id,
- encryption_key,
- }
- }
-
- async fn push_record(&self, record: HistoryRecord) -> Result<(RecordId, RecordIdx)> {
- let bytes = record.serialize()?;
- let idx = self
- .store
- .last(self.host_id, HISTORY_TAG)
- .await?
- .map_or(0, |p| p.idx + 1);
-
- let record = Record::builder()
- .host(Host::new(self.host_id))
- .version(HISTORY_VERSION.to_string())
- .tag(HISTORY_TAG.to_string())
- .idx(idx)
- .data(bytes)
- .build();
-
- let id = record.id;
-
- self.store
- .push(&record.encrypt::<PASETO_V4>(&self.encryption_key))
- .await?;
-
- Ok((id, idx))
- }
-
- async fn push_batch(&self, records: impl Iterator<Item = HistoryRecord>) -> Result<()> {
- let mut ret = Vec::new();
-
- let idx = self
- .store
- .last(self.host_id, HISTORY_TAG)
- .await?
- .map_or(0, |p| p.idx + 1);
-
- // Could probably _also_ do this as an iterator, but let's see how this is for now.
- // optimizing for minimal sqlite transactions, this code can be optimised later
- for (n, record) in records.enumerate() {
- let bytes = record.serialize()?;
-
- let record = Record::builder()
- .host(Host::new(self.host_id))
- .version(HISTORY_VERSION.to_string())
- .tag(HISTORY_TAG.to_string())
- .idx(idx + n as u64)
- .data(bytes)
- .build();
-
- let record = record.encrypt::<PASETO_V4>(&self.encryption_key);
-
- ret.push(record);
- }
-
- self.store.push_batch(ret.iter()).await?;
-
- Ok(())
- }
-
- pub(crate) async fn delete(&self, id: HistoryId) -> Result<(RecordId, RecordIdx)> {
- let record = HistoryRecord::Delete(id);
-
- self.push_record(record).await
- }
-
- /// Delete a batch of history entries via the record store.
- /// Returns the record IDs so the caller can run `incremental_build` when ready.
- pub(crate) async fn delete_entries(
- &self,
- entries: impl IntoIterator<Item = History>,
- ) -> Result<Vec<RecordId>> {
- let mut record_ids = Vec::new();
- for entry in entries {
- let (id, _) = self.delete(entry.id).await?;
- record_ids.push(id);
- }
- Ok(record_ids)
- }
-
- pub(crate) async fn push(&self, history: History) -> Result<(RecordId, RecordIdx)> {
- // TODO(ellie): move the history store to its own file
- // it's tiny rn so fine as is
- let record = HistoryRecord::Create(history);
-
- self.push_record(record).await
- }
-
- pub(crate) async fn history(&self) -> Result<Vec<HistoryRecord>> {
- // Atm this loads all history into memory
- // Not ideal as that is potentially quite a lot, although history will be small.
- let records = self.store.all_tagged(HISTORY_TAG).await?;
- let mut ret = Vec::with_capacity(records.len());
-
- for record in records {
- let hist = match record.version.as_str() {
- HISTORY_VERSION_V0 | HISTORY_VERSION => {
- let version = record.version.clone();
- let decrypted = record.decrypt::<PASETO_V4>(&self.encryption_key)?;
-
- HistoryRecord::deserialize(&decrypted.data, version.as_str())
- }
- version => bail!("unknown history version {version:?}"),
- }?;
-
- ret.push(hist);
- }
-
- Ok(ret)
- }
-
- pub(crate) async fn build(&self, database: &ClientSqlite) -> Result<()> {
- // I'd like to change how we rebuild and not couple this with the database, but need to
- // consider the structure more deeply. This will be easy to change.
-
- // TODO(ellie): page or iterate this
- let history = self.history().await?;
-
- // In theory we could flatten this here
- // The current issue is that the database may have history in it already, from the old sync
- // This didn't actually delete old history
- // If we're sure we have a DB only maintained by the new store, we can flatten
- // create/delete before we even get to sqlite
- let mut creates = Vec::new();
- let mut deletes = Vec::new();
-
- for i in history {
- match i {
- HistoryRecord::Create(h) => {
- creates.push(h);
- }
- HistoryRecord::Delete(id) => {
- deletes.push(id);
- }
- }
- }
-
- database.save_bulk(&creates).await?;
- database.delete_rows(&deletes).await?;
-
- Ok(())
- }
-
- pub(crate) async fn incremental_build(
- &self,
- database: &ClientSqlite,
- ids: &[RecordId],
- ) -> Result<()> {
- for id in ids {
- let record = self.store.get(*id).await;
-
- if let Ok(record) = record {
- if record.tag != HISTORY_TAG {
- continue;
- }
-
- let version = record.version.clone();
- let decrypted = record.decrypt::<PASETO_V4>(&self.encryption_key)?;
- let record = match version.as_str() {
- HISTORY_VERSION_V0 | HISTORY_VERSION => {
- HistoryRecord::deserialize(&decrypted.data, version.as_str())?
- }
- version => bail!("unknown history version {version:?}"),
- };
-
- match record {
- HistoryRecord::Create(h) => {
- // TODO: benchmark CPU time/memory tradeoff of batch commit vs one at a time
- database.save(&h).await?;
- }
- HistoryRecord::Delete(id) => {
- database.delete_rows(&[id]).await?;
- }
- }
- }
- }
-
- Ok(())
- }
-
- /// Get a list of history IDs that exist in the store
- /// Note: This currently involves loading all history into memory. This is not going to be a
- /// large amount in absolute terms, but do not all it in a hot loop.
- pub(crate) async fn history_ids(&self) -> Result<HashSet<HistoryId>> {
- let history = self.history().await?;
-
- let ret = history
- .iter()
- .map(|h| match h {
- HistoryRecord::Create(h) => h.id.clone(),
- HistoryRecord::Delete(id) => id.clone(),
- })
- .collect::<HashSet<_>>();
-
- Ok(ret)
- }
-
- pub(crate) async fn init_store(&self, db: &ClientSqlite) -> Result<()> {
- let pb = ProgressBar::new_spinner();
- pb.set_style(
- ProgressStyle::with_template("{spinner:.blue} {msg}")
- .unwrap()
- .with_key("eta", |state: &ProgressState, w: &mut dyn Write| {
- write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap();
- })
- .progress_chars("#>-"),
- );
- pb.enable_steady_tick(Duration::from_millis(500));
-
- pb.set_message("Fetching history from old database");
-
- let context = current_context().await?;
- let history = db.list(&[], &context, None, false, true).await?;
-
- pb.set_message("Fetching history already in store");
- let store_ids = self.history_ids().await?;
-
- pb.set_message("Converting old history to new store");
- let mut records = Vec::new();
-
- for i in history {
- debug!("loaded {}", i.id);
-
- if store_ids.contains(&i.id) {
- debug!("skipping {} - already exists", i.id);
- continue;
- }
-
- if i.deleted_at.is_some() {
- records.push(HistoryRecord::Delete(i.id));
- } else {
- records.push(HistoryRecord::Create(i));
- }
- }
-
- pb.set_message("Writing to db");
-
- if !records.is_empty() {
- self.push_batch(records.into_iter()).await?;
- }
-
- pb.finish_with_message("Import complete");
-
- Ok(())
- }
-}
-
-#[cfg(test)]
-mod tests {
- use crate::atuin_common::record::DecryptedData;
- use time::macros::datetime;
-
- use crate::atuin_client::history::{HISTORY_VERSION, store::HistoryRecord};
-
- use super::History;
-
- #[test]
- fn test_serialize_deserialize_create() {
- let bytes = [
- 204, 0, 196, 147, 205, 0, 1, 154, 217, 32, 48, 49, 56, 99, 100, 52, 102, 101, 56, 49,
- 55, 53, 55, 99, 100, 50, 97, 101, 101, 54, 53, 99, 100, 55, 56, 54, 49, 102, 57, 99,
- 56, 49, 207, 23, 166, 251, 212, 181, 82, 0, 0, 100, 0, 162, 108, 115, 217, 41, 47, 85,
- 115, 101, 114, 115, 47, 101, 108, 108, 105, 101, 47, 115, 114, 99, 47, 103, 105, 116,
- 104, 117, 98, 46, 99, 111, 109, 47, 97, 116, 117, 105, 110, 115, 104, 47, 97, 116, 117,
- 105, 110, 217, 32, 48, 49, 56, 99, 100, 52, 102, 101, 97, 100, 56, 57, 55, 53, 57, 55,
- 56, 53, 50, 53, 50, 55, 97, 51, 49, 99, 57, 57, 56, 48, 53, 57, 170, 98, 111, 111, 112,
- 58, 101, 108, 108, 105, 101, 192, 165, 101, 108, 108, 105, 101,
- ];
-
- let history = History {
- id: "018cd4fe81757cd2aee65cd7861f9c81".to_owned().into(),
- timestamp: datetime!(2024-01-04 00:00:00.000000 +00:00),
- duration: 100,
- exit: 0,
- command: "ls".to_owned(),
- cwd: "/Users/ellie/src/github.com/atuinsh/atuin".to_owned(),
- session: "018cd4fead897597852527a31c998059".to_owned(),
- hostname: "boop:ellie".to_owned(),
- author: "ellie".to_owned(),
- intent: None,
- deleted_at: None,
- };
-
- let record = HistoryRecord::Create(history);
-
- let serialized = record.serialize().expect("failed to serialize history");
- assert_eq!(serialized.0, bytes);
-
- let deserialized = HistoryRecord::deserialize(&serialized, HISTORY_VERSION)
- .expect("failed to deserialize HistoryRecord");
- assert_eq!(deserialized, record);
-
- // check the snapshot too
- let deserialized =
- HistoryRecord::deserialize(&DecryptedData(Vec::from(bytes)), HISTORY_VERSION)
- .expect("failed to deserialize HistoryRecord");
- assert_eq!(deserialized, record);
- }
-
- #[test]
- fn test_serialize_deserialize_delete() {
- let bytes = [
- 204, 1, 217, 32, 48, 49, 56, 99, 100, 52, 102, 101, 56, 49, 55, 53, 55, 99, 100, 50,
- 97, 101, 101, 54, 53, 99, 100, 55, 56, 54, 49, 102, 57, 99, 56, 49,
- ];
- let record = HistoryRecord::Delete("018cd4fe81757cd2aee65cd7861f9c81".to_string().into());
-
- let serialized = record.serialize().expect("failed to serialize history");
- assert_eq!(serialized.0, bytes);
-
- let deserialized = HistoryRecord::deserialize(&serialized, HISTORY_VERSION)
- .expect("failed to deserialize HistoryRecord");
- assert_eq!(deserialized, record);
-
- let deserialized =
- HistoryRecord::deserialize(&DecryptedData(Vec::from(bytes)), HISTORY_VERSION)
- .expect("failed to deserialize HistoryRecord");
- assert_eq!(deserialized, record);
- }
-}
diff --git a/crates/client/src/atuin_client/meta.rs b/crates/client/src/atuin_client/meta.rs
deleted file mode 100644
index 079c9926..00000000
--- a/crates/client/src/atuin_client/meta.rs
+++ /dev/null
@@ -1,182 +0,0 @@
-use std::path::Path;
-use std::str::FromStr;
-use std::time::Duration;
-
-use crate::atuin_common::record::HostId;
-use eyre::{Result, eyre};
-use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions};
-use time::{OffsetDateTime, format_description::well_known::Rfc3339};
-use tokio::sync::OnceCell;
-use tracing::debug;
-use uuid::Uuid;
-
-const KEY_HOST_ID: &str = "host_id";
-const KEY_LAST_SYNC: &str = "last_sync_time";
-
-pub(crate) struct MetaStore {
- pool: SqlitePool,
- cached_host_id: OnceCell<HostId>,
-}
-
-impl MetaStore {
- pub(crate) async fn new(path: impl AsRef<Path>, timeout: f64) -> Result<Self> {
- let path = path.as_ref();
- let path_str = path
- .as_os_str()
- .to_str()
- .ok_or_else(|| eyre!("meta database path is not valid UTF-8: {path:?}"))?;
- debug!("opening meta sqlite database at {path:?}");
-
- let is_memory = path_str.contains(":memory:");
-
- if !is_memory
- && !path.exists()
- && let Some(dir) = path.parent()
- {
- fs_err::create_dir_all(dir)?;
- }
-
- // Use DELETE journal mode instead of WAL. This is a small, infrequently-
- // written KV store — WAL's concurrency benefits aren't needed, and DELETE
- // mode avoids creating auxiliary -wal/-shm files that complicate
- // permission handling.
- let opts = SqliteConnectOptions::from_str(path_str)?
- .journal_mode(SqliteJournalMode::Delete)
- .optimize_on_close(true, None)
- .create_if_missing(true);
-
- let pool = SqlitePoolOptions::new()
- .acquire_timeout(Duration::from_secs_f64(timeout))
- .connect_with(opts)
- .await?;
-
- sqlx::migrate!("./db/client-meta-migrations")
- .run(&pool)
- .await?;
-
- // Session tokens are stored in this database, so restrict permissions.
- #[cfg(unix)]
- if !is_memory {
- use std::os::unix::fs::PermissionsExt;
- std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
- }
-
- let store = Self {
- pool,
- cached_host_id: OnceCell::const_new(),
- };
-
- Ok(store)
- }
-
- // Generic key-value operations
-
- pub(crate) async fn get(&self, key: &str) -> Result<Option<String>> {
- let row: Option<(String,)> = sqlx::query_as("SELECT value FROM meta WHERE key = ?1")
- .bind(key)
- .fetch_optional(&self.pool)
- .await?;
-
- Ok(row.map(|r| r.0))
- }
-
- pub(crate) async fn set(&self, key: &str, value: &str) -> Result<()> {
- sqlx::query(
- "
- INSERT INTO meta (key, value, updated_at)
- VALUES (?1, ?2, strftime('%s', 'now'))
- ON CONFLICT(key) DO UPDATE
- SET value = ?2, updated_at = strftime('%s', 'now')
- ",
- )
- .bind(key)
- .bind(value)
- .execute(&self.pool)
- .await?;
-
- Ok(())
- }
-
- // Typed accessors
-
- pub(crate) async fn host_id(&self) -> Result<HostId> {
- self.cached_host_id
- .get_or_try_init(|| async {
- if let Some(id) = self.get(KEY_HOST_ID).await? {
- let parsed = Uuid::from_str(id.as_str())
- .map_err(|e| eyre!("failed to parse host ID: {e}"))?;
- return Ok(HostId(parsed));
- }
-
- let uuid = crate::atuin_common::utils::uuid_v7();
- self.set(KEY_HOST_ID, uuid.as_simple().to_string().as_ref())
- .await?;
-
- Ok(HostId(uuid))
- })
- .await
- .copied()
- }
-
- pub(crate) async fn last_sync(&self) -> Result<OffsetDateTime> {
- match self.get(KEY_LAST_SYNC).await? {
- Some(v) => Ok(OffsetDateTime::parse(v.as_str(), &Rfc3339)?),
- None => Ok(OffsetDateTime::UNIX_EPOCH),
- }
- }
-
- pub(crate) async fn save_sync_time(&self) -> Result<()> {
- self.set(
- KEY_LAST_SYNC,
- OffsetDateTime::now_utc().format(&Rfc3339)?.as_str(),
- )
- .await
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::{MetaStore, OffsetDateTime};
-
- async fn new_test_store() -> MetaStore {
- MetaStore::new("sqlite::memory:", 2.0).await.unwrap()
- }
-
- #[tokio::test]
- async fn test_get_set_delete() {
- let store = new_test_store().await;
-
- assert_eq!(store.get("foo").await.unwrap(), None);
-
- store.set("foo", "bar").await.unwrap();
- assert_eq!(store.get("foo").await.unwrap(), Some("bar".to_string()));
-
- store.set("foo", "baz").await.unwrap();
- assert_eq!(store.get("foo").await.unwrap(), Some("baz".to_string()));
-
- store.delete("foo").await.unwrap();
- assert_eq!(store.get("foo").await.unwrap(), None);
- }
-
- #[tokio::test]
- async fn test_host_id_generation_and_stability() {
- let store = new_test_store().await;
-
- let id1 = store.host_id().await.unwrap();
- let id2 = store.host_id().await.unwrap();
-
- assert_eq!(id1, id2, "host_id should be stable across calls");
- }
-
- #[tokio::test]
- async fn test_sync_time() {
- let store = new_test_store().await;
-
- let t = store.last_sync().await.unwrap();
- assert_eq!(t, OffsetDateTime::UNIX_EPOCH);
-
- store.save_sync_time().await.unwrap();
- let t = store.last_sync().await.unwrap();
- assert!(t > OffsetDateTime::UNIX_EPOCH);
- }
-}
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/ordering.rs b/crates/client/src/atuin_client/ordering.rs
deleted file mode 100644
index 84001f52..00000000
--- a/crates/client/src/atuin_client/ordering.rs
+++ /dev/null
@@ -1,31 +0,0 @@
-use minspan::minspan;
-
-use super::{history::History, settings::SearchMode};
-
-pub(crate) fn reorder_fuzzy(mode: SearchMode, query: &str, res: Vec<History>) -> Vec<History> {
- match mode {
- SearchMode::Fuzzy => reorder(query, |x| &x.command, res),
- _ => res,
- }
-}
-
-#[expect(clippy::needless_pass_by_value, reason = "makes things easier")]
-fn reorder<F, A>(query: &str, f: F, res: Vec<A>) -> Vec<A>
-where
- F: Fn(&A) -> &String,
- A: Clone,
-{
- let mut r = res.clone();
- let qvec = &query.chars().collect();
- r.sort_by_cached_key(|h| {
- // TODO for fzf search we should sum up scores for each matched term
- //
- // The fallback is a little unfortunate: when we are asked to match a query that is found nowhere,
- // we don't want to return a None, as the comparison behaviour would put the worst matches
- // at the front. Therefore, we'll return a set of indices that are one larger than the longest
- // possible legitimate match. This is meaningless except as a comparison.
- let (from, to) = minspan::span(qvec, &(f(h).chars().collect())).unwrap_or((0, res.len()));
- 1 + to - from
- });
- r
-}
diff --git a/crates/client/src/atuin_client/record/encryption.rs b/crates/client/src/atuin_client/record/encryption.rs
deleted file mode 100644
index d8587cf6..00000000
--- a/crates/client/src/atuin_client/record/encryption.rs
+++ /dev/null
@@ -1,379 +0,0 @@
-use crate::atuin_common::record::{
- AdditionalData, DecryptedData, EncryptedData, Encryption, HostId, RecordId, RecordIdx,
-};
-use base64::{Engine, engine::general_purpose};
-use eyre::{Context, Result, ensure};
-use rusty_paserk::{Key, KeyId, Local, PieWrappedKey};
-use rusty_paseto::core::{
- ImplicitAssertion, Key as DataKey, Local as LocalPurpose, Paseto, PasetoNonce, Payload, V4,
-};
-use serde::{Deserialize, Serialize};
-
-/// Use PASETO V4 Local encryption using the additional data as an implicit assertion.
-#[expect(non_camel_case_types)]
-pub(crate) struct PASETO_V4;
-
-/*
-Why do we use a random content-encryption key?
-Originally I was planning on using a derived key for encryption based on additional data.
-This would be a lot more secure than using the master key directly.
-
-However, there's an established norm of using a random key. This scheme might be otherwise known as
-- client-side encryption
-- envelope encryption
-- key wrapping
-
-A HSM (Hardware Security Module) provider, eg: AWS, Azure, GCP, or even a physical device like a YubiKey
-will have some keys that they keep to themselves. These keys never leave their physical hardware.
-If they never leave the hardware, then encrypting large amounts of data means giving them the data and waiting.
-This is not a practical solution. Instead, generate a unique key for your data, encrypt that using your HSM
-and then store that with your data.
-
-See
- - <https://docs.aws.amazon.com/wellarchitected/latest/financial-services-industry-lens/use-envelope-encryption-with-customer-master-keys.html>
- - <https://cloud.google.com/kms/docs/envelope-encryption>
- - <https://learn.microsoft.com/en-us/azure/storage/blobs/client-side-encryption?tabs=dotnet#encryption-and-decryption-via-the-envelope-technique>
- - <https://www.yubico.com/gb/product/yubihsm-2-fips/>
- - <https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html#encrypting-stored-keys>
-
-Why would we care? In the past we have received some requests for company solutions. If in future we can configure a
-KMS service with little effort, then that would solve a lot of issues for their security team.
-
-Even for personal use, if a user is not comfortable with sharing keys between hosts,
-GCP HSM costs $1/month and $0.03 per 10,000 key operations. Assuming an active user runs
-1000 atuin records a day, that would only cost them $1 and 10 cent a month.
-
-Additionally, key rotations are much simpler using this scheme. Rotating a key is as simple as re-encrypting the CEK, and not the message contents.
-This makes it very fast to rotate a key in bulk.
-
-For future reference, with asymmetric encryption, you can encrypt the CEK without the HSM's involvement, but decrypting
-will need the HSM. This allows the encryption path to still be extremely fast (no network calls) but downloads/decryption
-that happens in the background can make the network calls to the HSM
-*/
-
-impl Encryption for PASETO_V4 {
- fn re_encrypt(
- mut data: EncryptedData,
- _ad: AdditionalData<'_>,
- old_key: &[u8; 32],
- new_key: &[u8; 32],
- ) -> Result<EncryptedData> {
- let cek = Self::decrypt_cek(&data.content_encryption_key, old_key)?;
- data.content_encryption_key = Self::encrypt_cek(cek, new_key);
- Ok(data)
- }
-
- fn encrypt(data: DecryptedData, ad: AdditionalData<'_>, key: &[u8; 32]) -> EncryptedData {
- // generate a random key for this entry
- // aka content-encryption-key (CEK)
- let random_key = Key::<V4, Local>::new_os_random();
-
- let assertions = Assertions::from(ad).encode();
-
- // build the payload and encrypt the token
- let payload = serde_json::to_string(&AtuinPayload {
- data: general_purpose::URL_SAFE_NO_PAD.encode(data.0),
- })
- .expect("json encoding can't fail");
- let nonce = DataKey::<32>::try_new_random().expect("could not source from random");
- let nonce = PasetoNonce::<V4, LocalPurpose>::from(&nonce);
-
- let token = Paseto::<V4, LocalPurpose>::builder()
- .set_payload(Payload::from(payload.as_str()))
- .set_implicit_assertion(ImplicitAssertion::from(assertions.as_str()))
- .try_encrypt(&random_key.into(), &nonce)
- .expect("error encrypting atuin data");
-
- EncryptedData {
- data: token,
- content_encryption_key: Self::encrypt_cek(random_key, key),
- }
- }
-
- fn decrypt(
- data: EncryptedData,
- ad: AdditionalData<'_>,
- key: &[u8; 32],
- ) -> Result<DecryptedData> {
- let token = data.data;
- let cek = Self::decrypt_cek(&data.content_encryption_key, key)?;
-
- // encode the implicit assertions
- let assertions = Assertions::from(ad).encode();
-
- // decrypt the payload with the footer and implicit assertions
- let payload = Paseto::<V4, LocalPurpose>::try_decrypt(
- &token,
- &cek.into(),
- None,
- ImplicitAssertion::from(&*assertions),
- )
- .context("could not decrypt entry")?;
-
- let payload: AtuinPayload = serde_json::from_str(&payload)?;
- let data = general_purpose::URL_SAFE_NO_PAD.decode(payload.data)?;
- Ok(DecryptedData(data))
- }
-}
-
-impl PASETO_V4 {
- fn decrypt_cek(wrapped_cek: &str, key: &[u8; 32]) -> Result<Key<V4, Local>> {
- let wrapping_key = Key::<V4, Local>::from_bytes(*key);
-
- // let wrapping_key = PasetoSymmetricKey::from(Key::from(key));
-
- let AtuinFooter { kid, wpk } = serde_json::from_str(wrapped_cek)
- .context("wrapped cek did not contain the correct contents")?;
-
- // check that the wrapping key matches the required key to decrypt.
- // In future, we could support multiple keys and use this key to
- // look up the key rather than only allow one key.
- // For now though we will only support the one key and key rotation will
- // have to be a hard reset
- let current_kid = wrapping_key.to_id();
-
- ensure!(
- current_kid == kid,
- "attempting to decrypt with incorrect key. currently using {current_kid}, expecting {kid}"
- );
-
- // decrypt the random key
- Ok(wpk.unwrap_key(&wrapping_key)?)
- }
-
- fn encrypt_cek(cek: Key<V4, Local>, key: &[u8; 32]) -> String {
- // aka key-encryption-key (KEK)
- let wrapping_key = Key::<V4, Local>::from_bytes(*key);
- let kid = wrapping_key.to_id();
-
- // wrap the random key so we can decrypt it later
- let wrapped_cek = AtuinFooter {
- wpk: cek.wrap_pie(&wrapping_key),
- kid,
- };
- serde_json::to_string(&wrapped_cek).expect("could not serialize wrapped cek")
- }
-}
-
-#[derive(Serialize, Deserialize)]
-struct AtuinPayload {
- data: String,
-}
-
-#[derive(Serialize, Deserialize)]
-/// Well-known footer claims for decrypting. This is not encrypted but is stored in the record.
-/// <https://github.com/paseto-standard/paseto-spec/blob/master/docs/02-Implementation-Guide/04-Claims.md#optional-footer-claims>
-struct AtuinFooter {
- /// Wrapped key
- wpk: PieWrappedKey<V4, Local>,
- /// ID of the key which was used to wrap
- kid: KeyId<V4, Local>,
-}
-
-/// Used in the implicit assertions. This is not encrypted and not stored in the data blob.
-// This cannot be changed, otherwise it breaks the authenticated encryption.
-#[derive(Debug, Copy, Clone, Serialize)]
-struct Assertions<'a> {
- id: &'a RecordId,
- idx: &'a RecordIdx,
- version: &'a str,
- tag: &'a str,
- host: &'a HostId,
-}
-
-impl<'a> From<AdditionalData<'a>> for Assertions<'a> {
- fn from(ad: AdditionalData<'a>) -> Self {
- Self {
- id: ad.id,
- version: ad.version,
- tag: ad.tag,
- host: ad.host,
- idx: ad.idx,
- }
- }
-}
-
-impl Assertions<'_> {
- fn encode(&self) -> String {
- serde_json::to_string(self).expect("could not serialize implicit assertions")
- }
-}
-
-#[cfg(test)]
-mod tests {
- use crate::atuin_common::{
- record::{Host, Record},
- utils::uuid_v7,
- };
-
- use super::{
- AdditionalData, DecryptedData, Encryption, HostId, Key, Local, PASETO_V4, RecordId, V4,
- };
-
- #[test]
- fn round_trip() {
- let key = Key::<V4, Local>::new_os_random();
-
- let ad = AdditionalData {
- id: &RecordId(uuid_v7()),
- version: "v0",
- tag: "kv",
- host: &HostId(uuid_v7()),
- idx: &0,
- };
-
- let data = DecryptedData(vec![1, 2, 3, 4]);
-
- let encrypted = PASETO_V4::encrypt(data.clone(), ad, &key.to_bytes());
- let decrypted = PASETO_V4::decrypt(encrypted, ad, &key.to_bytes()).unwrap();
- assert_eq!(decrypted, data);
- }
-
- #[test]
- fn same_entry_different_output() {
- let key = Key::<V4, Local>::new_os_random();
-
- let ad = AdditionalData {
- id: &RecordId(uuid_v7()),
- version: "v0",
- tag: "kv",
- host: &HostId(uuid_v7()),
- idx: &0,
- };
-
- let data = DecryptedData(vec![1, 2, 3, 4]);
-
- let encrypted = PASETO_V4::encrypt(data.clone(), ad, &key.to_bytes());
- let encrypted2 = PASETO_V4::encrypt(data, ad, &key.to_bytes());
-
- assert_ne!(
- encrypted.data, encrypted2.data,
- "re-encrypting the same contents should have different output due to key randomization"
- );
- }
-
- #[test]
- fn cannot_decrypt_different_key() {
- let key = Key::<V4, Local>::new_os_random();
- let fake_key = Key::<V4, Local>::new_os_random();
-
- let ad = AdditionalData {
- id: &RecordId(uuid_v7()),
- version: "v0",
- tag: "kv",
- host: &HostId(uuid_v7()),
- idx: &0,
- };
-
- let data = DecryptedData(vec![1, 2, 3, 4]);
-
- let encrypted = PASETO_V4::encrypt(data, ad, &key.to_bytes());
- drop(PASETO_V4::decrypt(encrypted, ad, &fake_key.to_bytes()).unwrap_err());
- }
-
- #[test]
- fn cannot_decrypt_different_id() {
- let key = Key::<V4, Local>::new_os_random();
-
- let ad = AdditionalData {
- id: &RecordId(uuid_v7()),
- version: "v0",
- tag: "kv",
- host: &HostId(uuid_v7()),
- idx: &0,
- };
-
- let data = DecryptedData(vec![1, 2, 3, 4]);
-
- let encrypted = PASETO_V4::encrypt(data, ad, &key.to_bytes());
-
- let ad = AdditionalData {
- id: &RecordId(uuid_v7()),
- ..ad
- };
- drop(PASETO_V4::decrypt(encrypted, ad, &key.to_bytes()).unwrap_err());
- }
-
- #[test]
- fn re_encrypt_round_trip() {
- let key1 = Key::<V4, Local>::new_os_random();
- let key2 = Key::<V4, Local>::new_os_random();
-
- let ad = AdditionalData {
- id: &RecordId(uuid_v7()),
- version: "v0",
- tag: "kv",
- host: &HostId(uuid_v7()),
- idx: &0,
- };
-
- let data = DecryptedData(vec![1, 2, 3, 4]);
-
- let encrypted1 = PASETO_V4::encrypt(data.clone(), ad, &key1.to_bytes());
- let encrypted2 =
- PASETO_V4::re_encrypt(encrypted1.clone(), ad, &key1.to_bytes(), &key2.to_bytes())
- .unwrap();
-
- // we only re-encrypt the content keys
- assert_eq!(encrypted1.data, encrypted2.data);
- assert_ne!(
- encrypted1.content_encryption_key,
- encrypted2.content_encryption_key
- );
-
- let decrypted = PASETO_V4::decrypt(encrypted2, ad, &key2.to_bytes()).unwrap();
-
- assert_eq!(decrypted, data);
- }
-
- #[test]
- fn full_record_round_trip() {
- let key = [0x55; 32];
- let record = Record::builder()
- .id(RecordId(uuid_v7()))
- .version("v0".to_owned())
- .tag("kv".to_owned())
- .host(Host::new(HostId(uuid_v7())))
- .timestamp(1_687_244_806_000_000)
- .data(DecryptedData(vec![1, 2, 3, 4]))
- .idx(0)
- .build();
-
- let encrypted = record.encrypt::<PASETO_V4>(&key);
-
- assert!(!encrypted.data.data.is_empty());
- assert!(!encrypted.data.content_encryption_key.is_empty());
-
- let decrypted = encrypted.decrypt::<PASETO_V4>(&key).unwrap();
-
- assert_eq!(decrypted.data.0, [1, 2, 3, 4]);
- }
-
- #[test]
- fn full_record_round_trip_fail() {
- let key = [0x55; 32];
- let record = Record::builder()
- .id(RecordId(uuid_v7()))
- .version("v0".to_owned())
- .tag("kv".to_owned())
- .host(Host::new(HostId(uuid_v7())))
- .timestamp(1_687_244_806_000_000)
- .data(DecryptedData(vec![1, 2, 3, 4]))
- .idx(0)
- .build();
-
- let encrypted = record.encrypt::<PASETO_V4>(&key);
-
- let mut enc1 = encrypted.clone();
- enc1.host = Host::new(HostId(uuid_v7()));
- let _ = enc1
- .decrypt::<PASETO_V4>(&key)
- .expect_err("tampering with the host should result in auth failure");
-
- let mut enc2 = encrypted;
- enc2.id = RecordId(uuid_v7());
- let _ = enc2
- .decrypt::<PASETO_V4>(&key)
- .expect_err("tampering with the id should result in auth failure");
- }
-}
diff --git a/crates/client/src/atuin_client/record/mod.rs b/crates/client/src/atuin_client/record/mod.rs
deleted file mode 100644
index 4e5774ea..00000000
--- a/crates/client/src/atuin_client/record/mod.rs
+++ /dev/null
@@ -1,3 +0,0 @@
-pub(crate) mod encryption;
-pub(crate) mod sqlite_store;
-pub(crate) mod sync;
diff --git a/crates/client/src/atuin_client/record/sqlite_store.rs b/crates/client/src/atuin_client/record/sqlite_store.rs
deleted file mode 100644
index 18f5c869..00000000
--- a/crates/client/src/atuin_client/record/sqlite_store.rs
+++ /dev/null
@@ -1,563 +0,0 @@
-// Here we are using sqlite as a pretty dumb store, and will not be running any complex queries.
-// Multiple stores of multiple types are all stored in one chonky table (for now), and we just index
-// by tag/host
-
-use std::path::Path;
-use std::str::FromStr;
-
-use eyre::{Result, eyre};
-use fs_err as fs;
-
-use sqlx::{
- Row,
- sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqliteRow},
-};
-use tracing::debug;
-
-use crate::atuin_client::utils::setup_db;
-use crate::atuin_common::record::{
- EncryptedData, Host, HostId, Record, RecordId, RecordIdx, RecordStatus,
-};
-use crate::atuin_common::utils;
-use uuid::Uuid;
-
-use super::encryption::PASETO_V4;
-
-#[derive(Debug, Clone)]
-pub(crate) struct SqliteStore {
- pool: SqlitePool,
-}
-
-impl SqliteStore {
- pub(crate) 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)
- .foreign_keys(true)
- .create_if_missing(true);
-
- Ok(opts)
- }
-
- let path = path.as_ref();
-
- debug!("opening sqlite database at {path:?}");
-
- if utils::broken_symlink(path) {
- eprintln!(
- "Atuin: Sqlite db path ({}) is a broken symlink. Unable to read or create replacement.",
- path.display()
- );
- std::process::exit(1);
- }
-
- if !path.exists()
- && let Some(dir) = path.parent()
- {
- fs::create_dir_all(dir)?;
- }
-
- let pool = setup_db!(path, timeout, mk_opts, "./db/client-record-migrations").await?;
-
- Ok(Self { pool })
- }
-
- async fn save_raw(
- tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
- r: &Record<EncryptedData>,
- ) -> Result<()> {
- // In sqlite, we are "limited" to i64. But that is still fine, until 2262.
- sqlx::query(
- "insert or ignore into store(id, idx, host, tag, timestamp, version, data, cek)
- values(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
- )
- .bind(r.id.0.as_hyphenated().to_string())
- .bind(r.idx as i64)
- .bind(r.host.id.0.as_hyphenated().to_string())
- .bind(r.tag.as_str())
- .bind(r.timestamp as i64)
- .bind(r.version.as_str())
- .bind(r.data.data.as_str())
- .bind(r.data.content_encryption_key.as_str())
- .execute(&mut **tx)
- .await?;
-
- Ok(())
- }
-
- #[expect(
- clippy::needless_pass_by_value,
- reason = "this is used in a place with fixed function signature"
- )]
- fn query_row(row: SqliteRow) -> Record<EncryptedData> {
- let idx: i64 = row.get("idx");
- let timestamp: i64 = row.get("timestamp");
-
- // tbh at this point things are pretty fucked so just panic
- let id = Uuid::from_str(row.get("id")).expect("invalid id UUID format in sqlite DB");
- let host = Uuid::from_str(row.get("host")).expect("invalid host UUID format in sqlite DB");
-
- Record {
- id: RecordId(id),
- idx: idx as u64,
- host: Host::new(HostId(host)),
- timestamp: timestamp as u64,
- tag: row.get("tag"),
- version: row.get("version"),
- data: EncryptedData {
- data: row.get("data"),
- content_encryption_key: row.get("cek"),
- },
- }
- }
-
- async fn load_all(&self) -> Result<Vec<Record<EncryptedData>>> {
- let res = sqlx::query("select * from store ")
- .map(Self::query_row)
- .fetch_all(&self.pool)
- .await?;
-
- Ok(res)
- }
-}
-
-/// A record store stores records
-/// In more detail - we tend to need to process this into _another_ format to actually query it.
-/// As is, the record store is intended as the source of truth for arbitrary data, which could
-/// be shell history, kvs, etc.
-impl SqliteStore {
- /// Push a record
- pub(crate) async fn push(&self, record: &Record<EncryptedData>) -> Result<()> {
- self.push_batch(std::iter::once(record)).await
- }
-
- /// Push a batch of records, all in one transaction
- pub(crate) async fn push_batch(
- &self,
- records: impl Iterator<Item = &Record<EncryptedData>> + Send + Sync,
- ) -> Result<()> {
- let mut tx = self.pool.begin().await?;
-
- for record in records {
- Self::save_raw(&mut tx, record).await?;
- }
-
- tx.commit().await?;
-
- Ok(())
- }
-
- pub(crate) async fn get(&self, id: RecordId) -> Result<Record<EncryptedData>> {
- let res = sqlx::query("select * from store where store.id = ?1")
- .bind(id.0.as_hyphenated().to_string())
- .map(Self::query_row)
- .fetch_one(&self.pool)
- .await?;
-
- Ok(res)
- }
-
- pub(crate) async fn delete(&self, id: RecordId) -> Result<()> {
- sqlx::query("delete from store where id = ?1")
- .bind(id.0.as_hyphenated().to_string())
- .execute(&self.pool)
- .await?;
-
- Ok(())
- }
-
- pub(crate) async fn delete_all(&self) -> Result<()> {
- sqlx::query("delete from store").execute(&self.pool).await?;
-
- Ok(())
- }
-
- pub(crate) async fn last(
- &self,
- host: HostId,
- tag: &str,
- ) -> Result<Option<Record<EncryptedData>>> {
- let res =
- sqlx::query("select * from store where host=?1 and tag=?2 order by idx desc limit 1")
- .bind(host.0.as_hyphenated().to_string())
- .bind(tag)
- .map(Self::query_row)
- .fetch_one(&self.pool)
- .await;
-
- match res {
- Err(sqlx::Error::RowNotFound) => Ok(None),
- Err(e) => Err(eyre!("an error occurred: {}", e)),
- Ok(record) => Ok(Some(record)),
- }
- }
-
- pub(crate) async fn first(
- &self,
- host: HostId,
- tag: &str,
- ) -> Result<Option<Record<EncryptedData>>> {
- self.idx(host, tag, 0).await
- }
-
- pub(crate) async fn len_tag(&self, tag: &str) -> Result<u64> {
- let res: Result<(i64,), sqlx::Error> =
- sqlx::query_as("select count(*) from store where tag=?1")
- .bind(tag)
- .fetch_one(&self.pool)
- .await;
- match res {
- Err(e) => Err(eyre!("failed to fetch local store len: {}", e)),
- Ok(v) => Ok(v.0 as u64),
- }
- }
-
- /// Get the next `limit` records, after and including the given index
- pub(crate) async fn next(
- &self,
- host: HostId,
- tag: &str,
- idx: RecordIdx,
- limit: u64,
- ) -> Result<Vec<Record<EncryptedData>>> {
- let res = sqlx::query(
- "select * from store where idx >= ?1 and host = ?2 and tag = ?3 order by idx asc limit ?4",
- )
- .bind(idx as i64)
- .bind(host.0.as_hyphenated().to_string())
- .bind(tag)
- .bind(limit as i64)
- .map(Self::query_row)
- .fetch_all(&self.pool)
- .await?;
-
- Ok(res)
- }
-
- /// Get the first record for a given host and tag
- pub(crate) async fn idx(
- &self,
- host: HostId,
- tag: &str,
- idx: RecordIdx,
- ) -> Result<Option<Record<EncryptedData>>> {
- let res = sqlx::query("select * from store where idx = ?1 and host = ?2 and tag = ?3")
- .bind(idx as i64)
- .bind(host.0.as_hyphenated().to_string())
- .bind(tag)
- .map(Self::query_row)
- .fetch_one(&self.pool)
- .await;
-
- match res {
- Err(sqlx::Error::RowNotFound) => Ok(None),
- Err(e) => Err(eyre!("an error occurred: {}", e)),
- Ok(v) => Ok(Some(v)),
- }
- }
-
- pub(crate) async fn status(&self) -> Result<RecordStatus> {
- let mut status = RecordStatus::new();
-
- let res: Result<Vec<(String, String, i64)>, sqlx::Error> =
- sqlx::query_as("select host, tag, max(idx) from store group by host, tag")
- .fetch_all(&self.pool)
- .await;
-
- let res = match res {
- Err(e) => return Err(eyre!("failed to fetch local store status: {}", e)),
- Ok(v) => v,
- };
-
- for i in res {
- let host = HostId(
- Uuid::from_str(i.0.as_str()).expect("failed to parse uuid for local store status"),
- );
-
- status.set_raw(host, i.1, i.2 as u64);
- }
-
- Ok(status)
- }
-
- /// Get all records for a given tag
- pub(crate) async fn all_tagged(&self, tag: &str) -> Result<Vec<Record<EncryptedData>>> {
- let res = sqlx::query("select * from store where tag = ?1 order by timestamp asc")
- .bind(tag)
- .map(Self::query_row)
- .fetch_all(&self.pool)
- .await?;
-
- Ok(res)
- }
-
- /// Reencrypt every single item in this store with a new key
- /// Be careful - this may mess with sync.
- pub(crate) async fn re_encrypt(&self, old_key: &[u8; 32], new_key: &[u8; 32]) -> Result<()> {
- // Load all the records
- // In memory like some of the other code here
- // This will never be called in a hot loop, and only under the following circumstances
- // 1. The user has logged into a new account, with a new key. They are unlikely to have a
- // lot of data
- // 2. The user has encountered some sort of issue, and runs a maintenance command that
- // invokes this
- let all = self.load_all().await?;
-
- let re_encrypted = all
- .into_iter()
- .map(|record| record.re_encrypt::<PASETO_V4>(old_key, new_key))
- .collect::<Result<Vec<_>>>()?;
-
- // next up, we delete all the old data and reinsert the new stuff
- // do it in one transaction, so if anything fails we rollback OK
-
- let mut tx = self.pool.begin().await?;
-
- let res = sqlx::query("delete from store").execute(&mut *tx).await?;
-
- let rows = res.rows_affected();
- debug!("deleted {rows} rows");
-
- // don't call push_batch, as it will start its own transaction
- // call the underlying save_raw
-
- for record in re_encrypted {
- Self::save_raw(&mut tx, &record).await?;
- }
-
- tx.commit().await?;
-
- Ok(())
- }
-
- /// Verify that every record in this store can be decrypted with the current key
- /// Someday maybe also check each tag/record can be deserialized, but not for now.
- pub(crate) async fn verify(&self, key: &[u8; 32]) -> Result<()> {
- let all = self.load_all().await?;
-
- all.into_iter()
- .map(|record| record.decrypt::<PASETO_V4>(key))
- .collect::<Result<Vec<_>>>()?;
-
- Ok(())
- }
-
- /// Verify that every record in this store can be decrypted with the current key
- /// Someday maybe also check each tag/record can be deserialized, but not for now.
- pub(crate) async fn purge(&self, key: &[u8; 32]) -> Result<()> {
- let all = self.load_all().await?;
-
- for record in &all {
- if record.clone().decrypt::<PASETO_V4>(key).is_ok() {
- continue;
- }
-
- println!(
- "Failed to decrypt {}, deleting",
- record.id.0.as_hyphenated()
- );
-
- self.delete(record.id).await?;
- }
-
- Ok(())
- }
-}
-
-#[cfg(test)]
-mod tests {
- use crate::{
- atuin_client::{
- encryption::generate_encoded_key, record::encryption::PASETO_V4,
- settings::test_local_timeout,
- },
- atuin_common::{
- record::{DecryptedData, EncryptedData, Host, HostId, Record},
- utils::uuid_v7,
- },
- };
-
- use super::SqliteStore;
-
- fn test_record() -> Record<EncryptedData> {
- Record::builder()
- .host(Host::new(HostId(uuid_v7())))
- .version("v1".into())
- .tag(uuid_v7().simple().to_string())
- .data(EncryptedData {
- data: "1234".into(),
- content_encryption_key: "1234".into(),
- })
- .idx(0)
- .build()
- }
-
- #[tokio::test]
- async fn create_db() {
- let db = SqliteStore::new(":memory:", test_local_timeout()).await;
-
- assert!(
- db.is_ok(),
- "db could not be created, {:?}",
- db.err().unwrap()
- );
- }
-
- #[tokio::test]
- async fn push_record() {
- let db = SqliteStore::new(":memory:", test_local_timeout())
- .await
- .unwrap();
- let record = test_record();
-
- db.push(&record).await.expect("failed to insert record");
- }
-
- #[tokio::test]
- async fn get_record() {
- let db = SqliteStore::new(":memory:", test_local_timeout())
- .await
- .unwrap();
- let record = test_record();
- db.push(&record).await.unwrap();
-
- let new_record = db.get(record.id).await.expect("failed to fetch record");
-
- assert_eq!(record, new_record, "records are not equal");
- }
-
- #[tokio::test]
- async fn last() {
- let db = SqliteStore::new(":memory:", test_local_timeout())
- .await
- .unwrap();
- let record = test_record();
- db.push(&record).await.unwrap();
-
- let last = db
- .last(record.host.id, record.tag.as_str())
- .await
- .expect("failed to get store len");
-
- assert_eq!(
- last.unwrap().id,
- record.id,
- "expected to get back the same record that was inserted"
- );
- }
-
- #[tokio::test]
- async fn first() {
- let db = SqliteStore::new(":memory:", test_local_timeout())
- .await
- .unwrap();
- let record = test_record();
- db.push(&record).await.unwrap();
-
- let first = db
- .first(record.host.id, record.tag.as_str())
- .await
- .expect("failed to get store len");
-
- assert_eq!(
- first.unwrap().id,
- record.id,
- "expected to get back the same record that was inserted"
- );
- }
-
- #[tokio::test]
- async fn len() {
- let db = SqliteStore::new(":memory:", test_local_timeout())
- .await
- .unwrap();
- let record = test_record();
- db.push(&record).await.unwrap();
-
- let len = db
- .len(record.host.id, record.tag.as_str())
- .await
- .expect("failed to get store len");
-
- assert_eq!(len, 1, "expected length of 1 after insert");
- }
-
- #[tokio::test]
- async fn len_tag() {
- let db = SqliteStore::new(":memory:", test_local_timeout())
- .await
- .unwrap();
- let record = test_record();
- db.push(&record).await.unwrap();
-
- let len = db
- .len_tag(record.tag.as_str())
- .await
- .expect("failed to get store len");
-
- assert_eq!(len, 1, "expected length of 1 after insert");
- }
-
- #[tokio::test]
- async fn re_encrypt() {
- let store = SqliteStore::new(":memory:", test_local_timeout())
- .await
- .unwrap();
- let (key, _) = generate_encoded_key().unwrap();
- let data = vec![0u8, 1u8, 2u8, 3u8];
- let host_id = HostId(uuid_v7());
-
- for i in 0..10 {
- let record = Record::builder()
- .host(Host::new(host_id))
- .version(String::from("test"))
- .tag(String::from("test"))
- .idx(i)
- .data(DecryptedData(data.clone()))
- .build();
-
- let record = record.encrypt::<PASETO_V4>(&key.into());
- store
- .push(&record)
- .await
- .expect("failed to push encrypted record");
- }
-
- // first, check that we can decrypt the data with the current key
- let all = store.all_tagged("test").await.unwrap();
-
- assert_eq!(all.len(), 10, "failed to fetch all records");
-
- for record in all {
- let decrypted = record.decrypt::<PASETO_V4>(&key.into()).unwrap();
- assert_eq!(decrypted.data.0, data);
- }
-
- // reencrypt the store, then check if
- // 1) it cannot be decrypted with the old key
- // 2) it can be decrypted with the new key
-
- let (new_key, _) = generate_encoded_key().unwrap();
- store
- .re_encrypt(&key.into(), &new_key.into())
- .await
- .expect("failed to re-encrypt store");
-
- let all = store.all_tagged("test").await.unwrap();
-
- for record in all.iter() {
- let decrypted = record.clone().decrypt::<PASETO_V4>(&key.into());
- assert!(
- decrypted.is_err(),
- "did not get error decrypting with old key after re-encrypt"
- )
- }
-
- for record in all {
- let decrypted = record.decrypt::<PASETO_V4>(&new_key.into()).unwrap();
- assert_eq!(decrypted.data.0, data);
- }
-
- assert_eq!(store.len(host_id, "test").await.unwrap(), 10);
- }
-}
diff --git a/crates/client/src/atuin_client/record/sync.rs b/crates/client/src/atuin_client/record/sync.rs
deleted file mode 100644
index da05533c..00000000
--- a/crates/client/src/atuin_client/record/sync.rs
+++ /dev/null
@@ -1,456 +0,0 @@
-// do a sync :O
-use std::{cmp::Ordering, fmt::Write};
-
-use eyre::{OptionExt, Result};
-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::atuin_common::record::{Diff, HostId, RecordId, RecordIdx, RecordStatus};
-use indicatif::{ProgressBar, ProgressState, ProgressStyle};
-
-#[derive(Error, Debug)]
-pub(crate) enum SyncError {
- #[error("an issue with the local database occurred: {msg:?}")]
- LocalStoreError { msg: String },
-
- #[error("something has gone wrong with the sync logic: {msg:?}")]
- SyncLogicError { msg: String },
-
- #[error("operational error: {msg:?}")]
- OperationalError { msg: String },
-
- #[error("a request to the sync server failed: {msg}")]
- RemoteRequestError { msg: String },
-
- #[error(
- "the encryption key on this machine does not match the data on the server. \
- this usually means a new machine was set up without copying the existing key. \
- to fix: run `atuin key` on a machine that already syncs correctly, then run \
- `atuin store rekey <key>` on this machine with the value from the other machine"
- )]
- WrongKey,
-}
-
-#[derive(Debug, Eq, PartialEq)]
-pub(crate) enum Operation {
- // Either upload or download until the states matches the below
- Upload {
- local: RecordIdx,
- remote: Option<RecordIdx>,
- host: HostId,
- tag: String,
- },
- Download {
- local: Option<RecordIdx>,
- remote: RecordIdx,
- host: HostId,
- tag: String,
- },
- Noop {
- host: HostId,
- tag: String,
- },
-}
-
-pub(crate) fn build_client(settings: &Settings) -> Result<Client<'_>, SyncError> {
- Client::new(
- &settings.sync.address,
- settings.network_connect_timeout,
- settings.network_timeout,
- settings
- .sync
- .user_id()
- .map_err(|e| SyncError::RemoteRequestError { msg: e.to_string() })?
- .ok_or_eyre("No sync user-id set")
- .map_err(|e| SyncError::RemoteRequestError { msg: e.to_string() })?,
- )
- .map_err(|e| SyncError::OperationalError { msg: e.to_string() })
-}
-
-pub(crate) async fn diff(
- client: &Client<'_>,
- store: &SqliteStore,
-) -> Result<(Vec<Diff>, RecordStatus), SyncError> {
- let local_index = store
- .status()
- .await
- .map_err(|e| SyncError::LocalStoreError { msg: e.to_string() })?;
-
- let remote_index = client
- .record_status()
- .await
- .map_err(|e| SyncError::RemoteRequestError { msg: e.to_string() })?;
-
- let diff = local_index.diff(&remote_index);
-
- Ok((diff, remote_index))
-}
-
-// Take a diff, along with a local store, and resolve it into a set of operations.
-// With the store as context, we can determine if a tail exists locally or not and therefore if it needs uploading or download.
-// In theory this could be done as a part of the diffing stage, but it's easier to reason
-// about and test this way
-pub(crate) fn operations(
- diffs: Vec<Diff>,
- _store: &SqliteStore,
-) -> Result<Vec<Operation>, SyncError> {
- let mut operations = Vec::with_capacity(diffs.len());
-
- for diff in diffs {
- let op = match (diff.local, diff.remote) {
- // We both have it! Could be either. Compare.
- (Some(local), Some(remote)) => match local.cmp(&remote) {
- Ordering::Equal => Operation::Noop {
- host: diff.host,
- tag: diff.tag,
- },
- Ordering::Greater => Operation::Upload {
- local,
- remote: Some(remote),
- host: diff.host,
- tag: diff.tag,
- },
- Ordering::Less => Operation::Download {
- local: Some(local),
- remote,
- host: diff.host,
- tag: diff.tag,
- },
- },
-
- // Remote has it, we don't. Gotta be download
- (None, Some(remote)) => Operation::Download {
- local: None,
- remote,
- host: diff.host,
- tag: diff.tag,
- },
-
- // We have it, remote doesn't. Gotta be upload.
- (Some(local), None) => Operation::Upload {
- local,
- remote: None,
- host: diff.host,
- tag: diff.tag,
- },
-
- // something is pretty fucked.
- (None, None) => {
- return Err(SyncError::SyncLogicError {
- msg: String::from(
- "diff has nothing for local or remote - (host, tag) does not exist",
- ),
- });
- }
- };
-
- operations.push(op);
- }
-
- // sort them - purely so we have a stable testing order, and can rely on
- // same input = same output
- // We can sort by ID so long as we continue to use UUIDv7 or something
- // with the same properties
-
- operations.sort_by_key(|op| match op {
- Operation::Noop { host, tag } => (0, *host, tag.clone()),
-
- Operation::Upload { host, tag, .. } => (1, *host, tag.clone()),
-
- Operation::Download { host, tag, .. } => (2, *host, tag.clone()),
- });
-
- Ok(operations)
-}
-
-async fn sync_upload(
- store: &SqliteStore,
- client: &Client<'_>,
- host: HostId,
- tag: String,
- local: RecordIdx,
- remote: Option<RecordIdx>,
- page_size: u64,
-) -> Result<i64, SyncError> {
- let remote = remote.unwrap_or(0);
- let expected = local - remote;
- let mut progress = 0;
-
- let pb = ProgressBar::new(expected);
- pb.set_style(ProgressStyle::with_template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {human_pos}/{human_len} ({eta})")
- .unwrap()
- .with_key("eta", |state: &ProgressState, w: &mut dyn Write| write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap())
- .progress_chars("#>-"));
-
- println!(
- "Uploading {} records to {}/{}",
- expected,
- host.0.as_simple(),
- tag
- );
-
- loop {
- let page = store
- .next(host, tag.as_str(), remote + progress, page_size)
- .await
- .map_err(|e| {
- error!("failed to read upload page: {e:?}");
-
- SyncError::LocalStoreError { msg: e.to_string() }
- })?;
-
- if page.is_empty() {
- break;
- }
-
- client.post_records(&page).await.map_err(|e| {
- error!("failed to post records: {e:?}");
-
- SyncError::RemoteRequestError { msg: e.to_string() }
- })?;
-
- progress += page.len() as u64;
- pb.set_position(progress);
-
- if progress >= expected {
- break;
- }
- }
-
- pb.finish_with_message("Uploaded records");
-
- Ok(progress as i64)
-}
-
-async fn sync_download(
- store: &SqliteStore,
- client: &Client<'_>,
- host: HostId,
- tag: String,
- local: Option<RecordIdx>,
- remote: RecordIdx,
- page_size: u64,
-) -> Result<Vec<RecordId>, SyncError> {
- let local = local.unwrap_or(0);
- let expected = remote - local;
- let mut progress = 0;
- let mut ret = Vec::new();
-
- println!(
- "Downloading {} records from {}/{}",
- expected,
- host.0.as_simple(),
- tag
- );
-
- let pb = ProgressBar::new(expected);
- pb.set_style(ProgressStyle::with_template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {human_pos}/{human_len} ({eta})")
- .unwrap()
- .with_key("eta", |state: &ProgressState, w: &mut dyn Write| write!(w, "{:.1}s", state.eta().as_secs_f64()).unwrap())
- .progress_chars("#>-"));
-
- loop {
- let page = client
- .next_records(host, tag.clone(), local + progress, page_size)
- .await
- .map_err(|e| SyncError::RemoteRequestError { msg: e.to_string() })?;
-
- if page.is_empty() {
- break;
- }
-
- store
- .push_batch(page.iter())
- .await
- .map_err(|e| SyncError::LocalStoreError { msg: e.to_string() })?;
-
- ret.extend(page.iter().map(|f| f.id));
-
- progress += page.len() as u64;
- pb.set_position(progress);
-
- if progress >= expected {
- break;
- }
- }
-
- pb.finish_with_message("Downloaded records");
-
- Ok(ret)
-}
-
-pub(crate) async fn sync_remote(
- client: &Client<'_>,
- operations: Vec<Operation>,
- local_store: &SqliteStore,
- page_size: u64,
-) -> Result<(i64, Vec<RecordId>), SyncError> {
- let mut uploaded = 0;
- let mut downloaded = Vec::new();
-
- // this can totally run in parallel, but lets get it working first
- for i in operations {
- match i {
- Operation::Upload {
- host,
- tag,
- local,
- remote,
- } => {
- uploaded +=
- sync_upload(local_store, client, host, tag, local, remote, page_size).await?;
- }
-
- Operation::Download {
- host,
- tag,
- local,
- remote,
- } => {
- let mut d =
- sync_download(local_store, client, host, tag, local, remote, page_size).await?;
- downloaded.append(&mut d);
- }
-
- Operation::Noop { .. } => (),
- }
- }
-
- Ok((uploaded, downloaded))
-}
-
-pub(crate) async fn check_encryption_key(
- client: &Client<'_>,
- remote_index: &RecordStatus,
- encryption_key: &[u8; 32],
-) -> Result<(), SyncError> {
- let sample = remote_index
- .hosts
- .iter()
- .flat_map(|(host, tags)| tags.keys().map(move |tag| (*host, tag.clone())))
- .next();
-
- let Some((host, tag)) = sample else {
- return Ok(());
- };
-
- let records = client
- .next_records(host, tag, 0, 1)
- .await
- .map_err(|e| SyncError::RemoteRequestError { msg: e.to_string() })?;
-
- let Some(record) = records.into_iter().next() else {
- return Ok(());
- };
-
- record.decrypt::<PASETO_V4>(encryption_key).map_err(|err| {
- error!("Wrong key error: {err}");
- SyncError::WrongKey
- })?;
-
- Ok(())
-}
-
-pub(crate) async fn sync(
- settings: &Settings,
- store: &SqliteStore,
- encryption_key: &[u8; 32],
-) -> Result<(i64, Vec<RecordId>), SyncError> {
- let client = build_client(settings)?;
- let (diff, remote_index) = diff(&client, store).await?;
-
- // Bail before mutating either side if the local key can't read the remote.
- check_encryption_key(&client, &remote_index, encryption_key).await?;
-
- let operations = operations(diff, store)?;
- let (uploaded, downloaded) = sync_remote(&client, operations, store, 100).await?;
-
- Ok((uploaded, downloaded))
-}
-
-#[cfg(test)]
-mod tests {
- use crate::atuin_client::record::sync::Operation;
- use crate::atuin_common::record::{Diff, EncryptedData, HostId, Record};
-
- use crate::atuin_client::{
- record::{
- sqlite_store::SqliteStore,
- sync::{self},
- },
- settings::test_local_timeout,
- };
-
- fn test_record() -> Record<EncryptedData> {
- Record::builder()
- .host(crate::atuin_common::record::Host::new(HostId(
- crate::atuin_common::utils::uuid_v7(),
- )))
- .version("v1".into())
- .tag(crate::atuin_common::utils::uuid_v7().simple().to_string())
- .data(EncryptedData {
- data: String::new(),
- content_encryption_key: String::new(),
- })
- .idx(0)
- .build()
- }
-
- // Take a list of local records, and a list of remote records.
- // Return the local database, and a diff of local/remote, ready to build
- // ops
- async fn build_test_diff(
- local_records: Vec<Record<EncryptedData>>,
- remote_records: Vec<Record<EncryptedData>>,
- ) -> (SqliteStore, Vec<Diff>) {
- let local_store = SqliteStore::new(":memory:", test_local_timeout())
- .await
- .expect("failed to open in memory sqlite");
- let remote_store = SqliteStore::new(":memory:", test_local_timeout())
- .await
- .expect("failed to open in memory sqlite"); // "remote"
-
- for i in local_records {
- local_store.push(&i).await.unwrap();
- }
-
- for i in remote_records {
- remote_store.push(&i).await.unwrap();
- }
-
- let local_index = local_store.status().await.unwrap();
- let remote_index = remote_store.status().await.unwrap();
-
- let diff = local_index.diff(&remote_index);
-
- (local_store, diff)
- }
-
- #[tokio::test]
- async fn test_basic_diff() {
- // a diff where local is ahead of remote. nothing else.
-
- let record = test_record();
- let (store, diff) = build_test_diff(vec![record.clone()], vec![]).await;
-
- assert_eq!(diff.len(), 1);
-
- let operations = sync::operations(diff, &store).unwrap();
-
- assert_eq!(operations.len(), 1);
-
- assert_eq!(
- operations[0],
- Operation::Upload {
- host: record.host.id,
- tag: record.tag,
- local: record.idx,
- remote: None,
- }
- );
- }
-}
diff --git a/crates/client/src/atuin_client/secrets.rs b/crates/client/src/atuin_client/secrets.rs
deleted file mode 100644
index 74d47ea6..00000000
--- a/crates/client/src/atuin_client/secrets.rs
+++ /dev/null
@@ -1,223 +0,0 @@
-// This file will probably trigger a lot of scanners. Sorry.
-
-use regex::RegexSet;
-use std::sync::LazyLock;
-
-#[cfg(test)]
-pub(crate) enum TestValue<'a> {
- Single(&'a str),
- Multiple(&'a [&'a str]),
-}
-
-#[cfg(test)]
-type SpType<'a> = &'a [(&'a str, &'a str, TestValue<'a>)];
-
-#[cfg(not(test))]
-type SpType<'a> = &'a [(&'a str, &'a str)];
-
-/// A list of `(name, regex, test)`, where `test` should match against `regex`.
-pub(crate) static SECRET_PATTERNS: SpType<'_> = &[
- (
- "AWS Access Key ID",
- "A[KS]IA[0-9A-Z]{16}",
- #[cfg(test)]
- TestValue::Single("AKIAIOSFODNN7EXAMPLE"),
- ),
- (
- "AWS Secret Access Key env var",
- "AWS_SECRET_ACCESS_KEY",
- #[cfg(test)]
- TestValue::Single("AWS_SECRET_ACCESS_KEY=KEYDATA"),
- ),
- (
- "AWS Session Token env var",
- "AWS_SESSION_TOKEN",
- #[cfg(test)]
- TestValue::Single("AWS_SESSION_TOKEN=KEYDATA"),
- ),
- (
- "Microsoft Azure secret access key env var",
- "AZURE_.*_KEY",
- #[cfg(test)]
- TestValue::Single("export AZURE_STORAGE_ACCOUNT_KEY=KEYDATA"),
- ),
- (
- "Google cloud platform key env var",
- "GOOGLE_SERVICE_ACCOUNT_KEY",
- #[cfg(test)]
- TestValue::Single("export GOOGLE_SERVICE_ACCOUNT_KEY=KEYDATA"),
- ),
- (
- "Atuin login",
- r"atuin\s+login",
- #[cfg(test)]
- TestValue::Single(
- "atuin login -u mycoolusername -p mycoolpassword -k \"lots of random words\"",
- ),
- ),
- (
- "GitHub PAT (old)",
- "ghp_[a-zA-Z0-9]{36}",
- #[cfg(test)]
- TestValue::Single("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"), // legit, I expired it
- ),
- (
- "GitHub PAT (new)",
- "gh1_[A-Za-z0-9]{21}_[A-Za-z0-9]{59}|github_pat_[0-9][A-Za-z0-9]{21}_[A-Za-z0-9]{59}",
- #[cfg(test)]
- TestValue::Multiple(&[
- "gh1_1234567890abcdefghijk_1234567890abcdefghijklmnopqrstuvwxyz1234567890abcdefghijklm",
- "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", // also legit, also expired
- ]),
- ),
- (
- "GitHub OAuth Access Token",
- "gho_[A-Za-z0-9]{36}",
- #[cfg(test)]
- TestValue::Single("gho_1234567890abcdefghijklmnopqrstuvwx000"), // not a real token
- ),
- (
- "GitHub OAuth Access Token (user)",
- "ghu_[A-Za-z0-9]{36}",
- #[cfg(test)]
- TestValue::Single("ghu_1234567890abcdefghijklmnopqrstuvwx000"), // not a real token
- ),
- (
- "GitHub App Installation Access Token",
- "ghs_[A-Za-z0-9._-]{36,}",
- #[cfg(test)]
- TestValue::Multiple(&[
- "ghs_1234567890abcdefghijklmnopqrstuvwx000", // not a real token
- "ghs_abc-def.ghi_jklMNOP0123456789qrstuv-wxyzABCD", // new token format, fake data
- ]),
- ),
- (
- "GitHub Refresh Token",
- "ghr_[A-Za-z0-9]{76}",
- #[cfg(test)]
- TestValue::Single(
- "ghr_1234567890abcdefghijklmnopqrstuvwx1234567890abcdefghijklmnopqrstuvwx1234567890abcdefghijklmnopqrstuvwx",
- ), // not a real token
- ),
- (
- "GitHub App Installation Access Token v1",
- "v1\\.[0-9A-Fa-f]{40}",
- #[cfg(test)]
- TestValue::Single("v1.1234567890abcdef1234567890abcdef12345678"), // not a real token
- ),
- (
- "GitLab PAT",
- "glpat-[a-zA-Z0-9_]{20}",
- #[cfg(test)]
- TestValue::Single("glpat-RkE_BG5p_bbjML21WSfy"),
- ),
- (
- "Slack OAuth v2 bot",
- "xoxb-[0-9]{11}-[0-9]{11}-[0-9a-zA-Z]{24}",
- #[cfg(test)]
- TestValue::Single("xoxb-17653672481-19874698323-pdFZKVeTuE8sk7oOcBrzbqgy"),
- ),
- (
- "Slack OAuth v2 user token",
- "xoxp-[0-9]{11}-[0-9]{11}-[0-9a-zA-Z]{24}",
- #[cfg(test)]
- TestValue::Single("xoxp-17653672481-19874698323-pdFZKVeTuE8sk7oOcBrzbqgy"),
- ),
- (
- "Slack webhook",
- "T[a-zA-Z0-9_]{8}/B[a-zA-Z0-9_]{8}/[a-zA-Z0-9_]{24}",
- #[cfg(test)]
- TestValue::Single(
- "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX",
- ),
- ),
- (
- "Stripe test key",
- "sk_test_[0-9a-zA-Z]{24}",
- #[cfg(test)]
- TestValue::Single("sk_test_1234567890abcdefghijklmnop"),
- ),
- (
- "Stripe live key",
- "sk_live_[0-9a-zA-Z]{24}",
- #[cfg(test)]
- TestValue::Single("sk_live_1234567890abcdefghijklmnop"),
- ),
- (
- "Netlify authentication token",
- "nf[pcoub]_[0-9a-zA-Z]{36}",
- #[cfg(test)]
- TestValue::Single("nfp_nBh7BdJxUwyaBBwFzpyD29MMFT6pZ9wq5634"),
- ),
- (
- "npm token",
- "npm_[A-Za-z0-9]{36}",
- #[cfg(test)]
- TestValue::Single("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"),
- ),
- (
- "Pulumi personal access token",
- "pul-[0-9a-f]{40}",
- #[cfg(test)]
- TestValue::Single("pul-683c2770662c51d960d72ec27613be7653c5cb26"),
- ),
-];
-
-/// The `regex` expressions from [`SECRET_PATTERNS`] compiled into a `RegexSet`.
-pub(crate) static SECRET_PATTERNS_RE: LazyLock<RegexSet> = LazyLock::new(|| {
- let exprs = SECRET_PATTERNS.iter().map(|f| f.1);
- RegexSet::new(exprs).expect("Failed to build secrets regex")
-});
-
-#[cfg(test)]
-mod tests {
- use regex::Regex;
-
- use crate::atuin_client::secrets::{SECRET_PATTERNS, TestValue};
-
- #[test]
- fn test_secrets() {
- for (name, regex, test) in SECRET_PATTERNS {
- let re =
- Regex::new(regex).unwrap_or_else(|_| panic!("Failed to compile regex for {name}"));
-
- match test {
- TestValue::Single(test) => {
- assert!(re.is_match(test), "{name} test failed!");
- }
- TestValue::Multiple(tests) => {
- for test_str in tests.iter() {
- assert!(
- re.is_match(test_str),
- "{name} test with value \"{test_str}\" failed!"
- );
- }
- }
- }
- }
- }
-
- #[test]
- fn test_secrets_embedded() {
- for (name, regex, test) in SECRET_PATTERNS {
- let re =
- Regex::new(regex).unwrap_or_else(|_| panic!("Failed to compile regex for {name}"));
-
- match test {
- TestValue::Single(test) => {
- let embedded = format!("some random text {test} some more random text");
- assert!(re.is_match(&embedded), "{name} embedded test failed!");
- }
- TestValue::Multiple(tests) => {
- for test_str in tests.iter() {
- let embedded = format!("some random text {test_str} some more random text");
- assert!(
- re.is_match(&embedded),
- "{name} embedded test with value \"{test_str}\" failed!"
- );
- }
- }
- }
- }
- }
-}
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/atuin_client/utils.rs b/crates/client/src/atuin_client/utils.rs
deleted file mode 100644
index 989f9fc1..00000000
--- a/crates/client/src/atuin_client/utils.rs
+++ /dev/null
@@ -1,92 +0,0 @@
-pub(crate) fn get_hostname() -> String {
- std::env::var("ATUIN_HOST_NAME")
- .unwrap_or_else(|_| whoami::hostname().unwrap_or_else(|_| "unknown-host".to_string()))
-}
-
-pub(crate) fn get_username() -> String {
- std::env::var("ATUIN_HOST_USER")
- .unwrap_or_else(|_| whoami::username().unwrap_or_else(|_| "unknown-user".to_string()))
-}
-
-/// Returns a pair of the hostname and username, separated by a colon.
-pub(crate) fn get_host_user() -> String {
- format!("{}:{}", get_hostname(), get_username())
-}
-
-/// Setup a [`SQLite`] database.
-///
-/// This takes care of correct locking, so that we avoid a race when setting up the database.
-macro_rules! setup_db {
- (
- $db_path:expr,
- $a_timeout:expr,
- $opts:expr,
- $m_name:literal $(,)?
- ) => {{
- async fn migrate(pool: &SqlitePool) -> sqlx::Result<()> {
- { sqlx::sqlx_macros::migrate!($m_name) }.run(pool).await?;
- Ok(())
- }
-
- crate::atuin_client::utils::setup_db_inner($db_path, $a_timeout, $opts, migrate)
- }};
-}
-pub(crate) use setup_db;
-
-use std::{os::fd::AsRawFd, path::Path, time::Duration};
-
-use fs_err::OpenOptions;
-use sqlx::{
- SqlitePool,
- sqlite::{SqliteConnectOptions, SqlitePoolOptions},
-};
-use tracing::debug;
-
-/// Helper for `setup_db!`
-pub(crate) async fn setup_db_inner(
- db_path: &Path,
- acquire_timeout: f64,
- mk_opts: fn(&str) -> sqlx::Result<SqliteConnectOptions>,
- migrate: impl AsyncFn(&SqlitePool) -> sqlx::Result<()>,
-) -> sqlx::Result<SqlitePool> {
- async fn open_db(timeout: f64, opts: SqliteConnectOptions) -> sqlx::Result<SqlitePool> {
- let pool = SqlitePoolOptions::new()
- .acquire_timeout(Duration::from_secs_f64(timeout))
- .connect_with(opts)
- .await?;
-
- Ok(pool)
- }
-
- {
- let file = OpenOptions::new()
- .read(true)
- .write(true)
- .create(true)
- .open(db_path)?;
-
- // Lock the db file while we are running the migrations.
- // Why? Because there is a small chance that we start running migrations (e.g. as the daemon)
- // and then another process is started, which will also try to run migrations.
- // Essentially, one of the processes will receive with a SQLite UNIQUE constraint failure.
- // So let's avoid that possibility from the start.
- file.lock()?;
-
- let pool = open_db(
- acquire_timeout,
- mk_opts(format!("/proc/self/fd/{}", file.as_raw_fd()).as_str())?,
- )
- .await?;
-
- debug!("running sqlite database setup");
-
- migrate(&pool).await?;
-
- file.unlock()?;
- }
-
- let real_opts = mk_opts(db_path.to_str().expect("Should be utf-8"))?;
- let pool = open_db(acquire_timeout, real_opts).await?;
-
- Ok(pool)
-}
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/daemon.rs b/crates/client/src/command/client/daemon.rs
deleted file mode 100644
index 39aa1b1e..00000000
--- a/crates/client/src/command/client/daemon.rs
+++ /dev/null
@@ -1,583 +0,0 @@
-use std::fs::{self, File, OpenOptions};
-use std::io::{ErrorKind, Write};
-#[cfg(unix)]
-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;
-
-#[derive(clap::Args, Debug)]
-pub(crate) struct Cmd {
- #[command(subcommand)]
- subcmd: SubCmd,
-}
-
-#[derive(Subcommand, Debug)]
-#[command(infer_subcommands = true)]
-pub(crate) enum SubCmd {
- /// Start the daemon server
- Start {
- #[arg(long, hide = true)]
- daemonize: bool,
-
- /// 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
- Status,
-
- /// 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,
- }
- }
-}
-
-const DAEMON_VERSION: &str = env!("CARGO_PKG_VERSION");
-const DAEMON_PROTOCOL_VERSION: u32 = 1;
-const STARTUP_POLL: Duration = Duration::from_millis(40);
-const LOCK_POLL: Duration = Duration::from_millis(20);
-const LEGACY_DAEMON_RESTART_MESSAGE: &str = "legacy daemon detected; restart daemon manually";
-
-struct PidfileGuard {
- file: File,
-}
-
-impl PidfileGuard {
- fn acquire(path: &Path) -> Result<Self> {
- let mut file = open_lock_file(path)?;
-
- if !file.try_lock_exclusive()? {
- bail!(
- "daemon already running (pidfile lock busy at {})",
- path.display()
- );
- }
-
- file.set_len(0)
- .wrap_err_with(|| format!("could not truncate daemon pidfile {}", path.display()))?;
- writeln!(file, "{}", std::process::id())
- .and_then(|()| writeln!(file, "{DAEMON_VERSION}"))
- .wrap_err_with(|| format!("could not write daemon pidfile {}", path.display()))?;
-
- Ok(Self { file })
- }
-}
-
-impl Drop for PidfileGuard {
- fn drop(&mut self) {
- drop(self.file.unlock());
- }
-}
-
-enum Probe {
- Ready(HistoryClient),
- NeedsRestart(String),
- Unreachable(eyre::Report),
-}
-
-fn daemon_matches_expected(version: &str, protocol: u32) -> bool {
- version == DAEMON_VERSION && protocol == DAEMON_PROTOCOL_VERSION
-}
-
-fn daemon_mismatch_message(version: &str, protocol: u32) -> String {
- if protocol == DAEMON_PROTOCOL_VERSION {
- format!("daemon is out of date: expected {DAEMON_VERSION}, got {version}")
- } else {
- format!("daemon protocol mismatch: expected {DAEMON_PROTOCOL_VERSION}, got {protocol}")
- }
-}
-
-fn is_legacy_daemon_error(err: &eyre::Report) -> bool {
- matches!(classify_error(err), DaemonClientErrorKind::Unimplemented)
-}
-
-fn open_lock_file(path: &Path) -> Result<File> {
- if let Some(parent) = path.parent() {
- fs::create_dir_all(parent)
- .wrap_err_with(|| format!("could not create lock directory {}", parent.display()))?;
- }
-
- OpenOptions::new()
- .read(true)
- .write(true)
- .create(true)
- .truncate(false)
- .open(path)
- .wrap_err_with(|| format!("could not open lock file {}", path.display()))
-}
-
-async fn wait_for_lock(path: &Path, timeout: Duration) -> Result<File> {
- let file = open_lock_file(path)?;
- let start = Instant::now();
-
- loop {
- match file.try_lock_exclusive() {
- Ok(true) => return Ok(file),
- Ok(false) => {
- if start.elapsed() >= timeout {
- bail!("timed out waiting for lock at {}", path.display());
- }
-
- sleep(LOCK_POLL).await;
- }
- Err(err) => {
- return Err(eyre!("could not lock {}: {err}", path.display()));
- }
- }
- }
-}
-
-async fn wait_for_pidfile_available(path: &Path, timeout: Duration) -> Result<()> {
- let file = wait_for_lock(path, timeout).await?;
- file.unlock()
- .wrap_err_with(|| format!("failed to unlock {}", path.display()))?;
- Ok(())
-}
-
-async fn connect_client(settings: &Settings) -> Result<HistoryClient> {
- HistoryClient::new(
- #[cfg(unix)]
- settings.daemon.socket_path.clone(),
- )
- .await
-}
-
-async fn probe(settings: &Settings) -> Probe {
- let mut client = match connect_client(settings).await {
- Ok(client) => client,
- Err(err) => return Probe::Unreachable(err),
- };
-
- match client.status().await {
- Ok(status) => {
- if daemon_matches_expected(&status.version, status.protocol) {
- Probe::Ready(client)
- } else {
- Probe::NeedsRestart(daemon_mismatch_message(&status.version, status.protocol))
- }
- }
- Err(err) => Probe::Unreachable(err),
- }
-}
-
-async fn request_shutdown(settings: &Settings) {
- if let Ok(mut client) = connect_client(settings).await {
- drop(client.shutdown().await);
- }
-}
-
-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)
- .await?
- .start_history(history.clone())
- .await
- }
- .await
- {
- Ok(resp) => {
- if daemon_matches_expected(&resp.version, resp.protocol) {
- return Ok(resp.id);
- }
-
- Err(eyre!(
- "{}. Restart the daemon manually",
- daemon_mismatch_message(&resp.version, resp.protocol)
- ))
- }
- Err(err) => Err(err),
- }
-}
-
-pub(crate) async fn end_history(
- settings: &Settings,
- id: String,
- duration: u64,
- exit: i64,
-) -> Result<()> {
- match async {
- connect_client(settings)
- .await?
- .end_history(id.clone(), duration, exit)
- .await
- }
- .await
- {
- Ok(resp) => {
- if daemon_matches_expected(&resp.version, resp.protocol) {
- return Ok(());
- }
-
- Err(eyre!(
- "{}. Restart the daemon manually",
- daemon_mismatch_message(&resp.version, resp.protocol)
- ))
- }
- Err(err) => Err(err),
- }
-}
-
-/// Emit a daemon event.
-pub(crate) async fn emit_event(settings: &Settings, event: DaemonEvent) {
- // Try to connect and send
- match ControlClient::from_settings(settings).await {
- Ok(mut client) => {
- if let Err(e) = client.send_event(event).await {
- tracing::debug!(?e, "failed to send event to daemon");
- }
- }
- Err(e) => {
- tracing::debug!(?e, "daemon not available, skipping event emission");
- }
- }
-}
-
-pub(crate) async fn tail_client(settings: &Settings) -> Result<HistoryClient> {
- match probe(settings).await {
- Probe::Ready(client) => Ok(client),
- Probe::NeedsRestart(reason) => {
- bail!("{reason}. Restart the daemon manually");
- }
- Probe::Unreachable(err) if is_legacy_daemon_error(&err) => {
- Err(err.wrap_err(LEGACY_DAEMON_RESTART_MESSAGE))
- }
- Probe::Unreachable(err) => Err(err),
- }
-}
-
-async fn status_cmd(settings: &Settings) -> Result<()> {
- match probe(settings).await {
- Probe::Ready(mut client) => {
- let status = client.status().await?;
- println!("Daemon running");
- println!(" PID: {}", status.pid);
- println!(" Version: {}", status.version);
- println!(" Protocol: {}", status.protocol);
- println!(" Healthy: {}", status.healthy);
- #[cfg(unix)]
- println!(" Socket: {}", settings.daemon.socket_path);
- }
- Probe::NeedsRestart(reason) => {
- println!("Daemon running (needs restart)");
- println!(" Reason: {reason}");
- }
- Probe::Unreachable(_) => {
- println!("Daemon is not running");
- }
- }
-
- Ok(())
-}
-
-async fn stop_cmd(settings: &Settings) -> Result<()> {
- let Ok(mut client) = connect_client(settings).await else {
- println!("Daemon is not running");
- return Ok(());
- };
-
- match client.shutdown().await {
- Ok(true) => {
- println!("Shutdown requested");
-
- let pidfile_path = PathBuf::from(&settings.daemon.pidfile_path);
- let timeout = Duration::from_secs(5);
- match wait_for_pidfile_available(&pidfile_path, timeout).await {
- Ok(()) => println!("Daemon stopped"),
- Err(_) => println!("Daemon may still be shutting down"),
- }
-
- Ok(())
- }
- Ok(false) => bail!("Daemon rejected shutdown request"),
- Err(err) => Err(err.wrap_err("Failed to send shutdown request")),
- }
-}
-
-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);
- }
-
- 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?;
-
- 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::{
- DAEMON_PROTOCOL_VERSION, DAEMON_VERSION, daemon_matches_expected, daemon_mismatch_message,
- };
-
- #[test]
- fn test_version_matches() {
- assert!(daemon_matches_expected(
- DAEMON_VERSION,
- DAEMON_PROTOCOL_VERSION
- ));
- }
-
- #[test]
- fn test_version_mismatch() {
- assert!(!daemon_matches_expected("0.0.0", DAEMON_PROTOCOL_VERSION));
- assert!(!daemon_matches_expected(DAEMON_VERSION, 999));
- assert!(!daemon_matches_expected("0.0.0", 999));
- }
-
- #[test]
- fn test_mismatch_message_version() {
- let msg = daemon_mismatch_message("0.0.0", DAEMON_PROTOCOL_VERSION);
- assert!(msg.contains("out of date"), "got: {msg}");
- assert!(msg.contains("0.0.0"));
- assert!(msg.contains(DAEMON_VERSION));
- }
-
- #[test]
- fn test_mismatch_message_protocol() {
- let msg = daemon_mismatch_message(DAEMON_VERSION, 999);
- assert!(msg.contains("protocol mismatch"), "got: {msg}");
- }
-}
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