aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-06-11 16:10:29 +0200
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-06-11 16:10:29 +0200
commit97f207b771b94c5285faae4810d6eeda1b78926b (patch)
tree4482544233c30e0e9a62be6afcfe92c8e01b0a50
parentchore: Remove all `pub`s (diff)
downloadatuin-97f207b771b94c5285faae4810d6eeda1b78926b.zip
chore(server): Simplify the database support
-rw-r--r--crates/turtle/db/server-sqlite-migrations/20231203124112_create-store.sql17
-rw-r--r--crates/turtle/db/server-sqlite-migrations/20240108124830_create-history.sql15
-rw-r--r--crates/turtle/db/server-sqlite-migrations/20240108124831_create-sessions.sql6
-rw-r--r--crates/turtle/db/server-sqlite-migrations/20240621110730_create-users.sql12
-rw-r--r--crates/turtle/db/server-sqlite-migrations/20240621110731_create-user-verification-token.sql6
-rw-r--r--crates/turtle/db/server-sqlite-migrations/20240702094825_create-store-idx-cache.sql10
-rw-r--r--crates/turtle/db/server-sqlite-migrations/20260127000000_remove-email-verification.sql2
-rw-r--r--crates/turtle/src/atuin_client/api_client.rs116
-rw-r--r--crates/turtle/src/atuin_client/meta.rs156
-rw-r--r--crates/turtle/src/atuin_client/mod.rs2
-rw-r--r--crates/turtle/src/atuin_client/settings.rs151
-rw-r--r--crates/turtle/src/atuin_client/sync.rs214
-rw-r--r--crates/turtle/src/atuin_common/api.rs17
-rw-r--r--crates/turtle/src/atuin_common/utils.rs90
-rw-r--r--crates/turtle/src/atuin_server/database/calendar.rs (renamed from crates/turtle/src/atuin_server_database/calendar.rs)0
-rw-r--r--crates/turtle/src/atuin_server/database/db/mod.rs (renamed from crates/turtle/src/atuin_server_postgres/mod.rs)146
-rw-r--r--crates/turtle/src/atuin_server/database/db/wrappers.rs (renamed from crates/turtle/src/atuin_server_postgres/wrappers.rs)14
-rw-r--r--crates/turtle/src/atuin_server/database/mod.rs123
-rw-r--r--crates/turtle/src/atuin_server/database/models.rs (renamed from crates/turtle/src/atuin_server_database/models.rs)0
-rw-r--r--crates/turtle/src/atuin_server/handlers/history.rs237
-rw-r--r--crates/turtle/src/atuin_server/handlers/mod.rs5
-rw-r--r--crates/turtle/src/atuin_server/handlers/status.rs45
-rw-r--r--crates/turtle/src/atuin_server/handlers/user.rs32
-rw-r--r--crates/turtle/src/atuin_server/handlers/v0/record.rs13
-rw-r--r--crates/turtle/src/atuin_server/handlers/v0/store.rs5
-rw-r--r--crates/turtle/src/atuin_server/mod.rs16
-rw-r--r--crates/turtle/src/atuin_server/router.rs45
-rw-r--r--crates/turtle/src/atuin_server/settings.rs8
-rw-r--r--crates/turtle/src/atuin_server/utils.rs15
-rw-r--r--crates/turtle/src/atuin_server_database/mod.rs266
-rw-r--r--crates/turtle/src/atuin_server_sqlite/mod.rs430
-rw-r--r--crates/turtle/src/atuin_server_sqlite/wrappers.rs72
-rw-r--r--crates/turtle/src/command/client/init.rs19
-rw-r--r--crates/turtle/src/command/client/init/bash.rs14
-rw-r--r--crates/turtle/src/command/client/init/fish.rs14
-rw-r--r--crates/turtle/src/command/client/init/powershell.rs5
-rw-r--r--crates/turtle/src/command/client/init/xonsh.rs5
-rw-r--r--crates/turtle/src/command/client/init/zsh.rs14
-rw-r--r--crates/turtle/src/command/client/server.rs8
-rw-r--r--crates/turtle/src/main.rs16
-rw-r--r--crates/turtle/src/shell/atuin.bash1161
-rw-r--r--crates/turtle/src/shell/atuin.fish86
-rw-r--r--crates/turtle/src/shell/atuin.zsh56
43 files changed, 894 insertions, 2790 deletions
diff --git a/crates/turtle/db/server-sqlite-migrations/20231203124112_create-store.sql b/crates/turtle/db/server-sqlite-migrations/20231203124112_create-store.sql
deleted file mode 100644
index ca19ed62..00000000
--- a/crates/turtle/db/server-sqlite-migrations/20231203124112_create-store.sql
+++ /dev/null
@@ -1,17 +0,0 @@
-create table store (
- id text primary key, -- remember to use uuidv7 for happy indices <3
- client_id text not null, -- I am too uncomfortable with the idea of a client-generated primary key, even though it's fine mathematically
- host text not null, -- a unique identifier for the host
- idx bigint not null, -- the index of the record in this store, identified by (host, tag)
- timestamp bigint not null, -- not a timestamp type, as those do not have nanosecond precision
- version text not null,
- tag text not null, -- what is this? history, kv, whatever. Remember clients get a log per tag per host
- data text not null, -- store the actual history data, encrypted. I don't wanna know!
- cek text not null,
-
- user_id bigint not null, -- allow multiple users
- created_at timestamp not null default current_timestamp
-);
-
-create unique index record_uniq ON store(user_id, host, tag, idx);
-
diff --git a/crates/turtle/db/server-sqlite-migrations/20240108124830_create-history.sql b/crates/turtle/db/server-sqlite-migrations/20240108124830_create-history.sql
deleted file mode 100644
index 7bd653ba..00000000
--- a/crates/turtle/db/server-sqlite-migrations/20240108124830_create-history.sql
+++ /dev/null
@@ -1,15 +0,0 @@
-create table history (
- id integer primary key autoincrement,
- client_id text not null unique, -- the client-generated ID
- user_id bigserial not null, -- allow multiple users
- hostname text not null, -- a unique identifier from the client (can be hashed, random, whatever)
- timestamp timestamp not null, -- one of the few non-encrypted metadatas
-
- data text not null, -- store the actual history data, encrypted. I don't wanna know!
-
- created_at timestamp not null default current_timestamp,
- deleted_at timestamp
-);
-
-create unique index history_deleted_index on history(client_id, user_id, deleted_at);
-
diff --git a/crates/turtle/db/server-sqlite-migrations/20240108124831_create-sessions.sql b/crates/turtle/db/server-sqlite-migrations/20240108124831_create-sessions.sql
deleted file mode 100644
index 3120c35d..00000000
--- a/crates/turtle/db/server-sqlite-migrations/20240108124831_create-sessions.sql
+++ /dev/null
@@ -1,6 +0,0 @@
-create table sessions (
- id integer primary key autoincrement,
- user_id integer,
- token text unique not null
-);
-
diff --git a/crates/turtle/db/server-sqlite-migrations/20240621110730_create-users.sql b/crates/turtle/db/server-sqlite-migrations/20240621110730_create-users.sql
deleted file mode 100644
index 852c159d..00000000
--- a/crates/turtle/db/server-sqlite-migrations/20240621110730_create-users.sql
+++ /dev/null
@@ -1,12 +0,0 @@
-create table users (
- id integer primary key autoincrement, -- also store our own ID
- username text not null unique, -- being able to contact users is useful
- email text not null unique, -- being able to contact users is useful
- password text not null unique,
- created_at timestamp not null default (datetime('now','localtime')),
- verified_at timestamp with time zone default null
-);
-
--- the prior index is case sensitive :(
-CREATE UNIQUE INDEX email_unique_idx on users (LOWER(email));
-CREATE UNIQUE INDEX username_unique_idx on users (LOWER(username));
diff --git a/crates/turtle/db/server-sqlite-migrations/20240621110731_create-user-verification-token.sql b/crates/turtle/db/server-sqlite-migrations/20240621110731_create-user-verification-token.sql
deleted file mode 100644
index 36eb14de..00000000
--- a/crates/turtle/db/server-sqlite-migrations/20240621110731_create-user-verification-token.sql
+++ /dev/null
@@ -1,6 +0,0 @@
-create table user_verification_token(
- id integer primary key autoincrement,
- user_id bigint unique references users(id),
- token text,
- valid_until timestamp with time zone
-);
diff --git a/crates/turtle/db/server-sqlite-migrations/20240702094825_create-store-idx-cache.sql b/crates/turtle/db/server-sqlite-migrations/20240702094825_create-store-idx-cache.sql
deleted file mode 100644
index cd54cb18..00000000
--- a/crates/turtle/db/server-sqlite-migrations/20240702094825_create-store-idx-cache.sql
+++ /dev/null
@@ -1,10 +0,0 @@
-create table store_idx_cache(
- id integer primary key autoincrement,
- user_id bigint,
-
- host uuid,
- tag text,
- idx bigint
-);
-
-create unique index store_idx_cache_uniq on store_idx_cache(user_id, host, tag);
diff --git a/crates/turtle/db/server-sqlite-migrations/20260127000000_remove-email-verification.sql b/crates/turtle/db/server-sqlite-migrations/20260127000000_remove-email-verification.sql
deleted file mode 100644
index 0bde89d7..00000000
--- a/crates/turtle/db/server-sqlite-migrations/20260127000000_remove-email-verification.sql
+++ /dev/null
@@ -1,2 +0,0 @@
-drop table if exists user_verification_token;
-alter table users drop column verified_at;
diff --git a/crates/turtle/src/atuin_client/api_client.rs b/crates/turtle/src/atuin_client/api_client.rs
index f3f2428a..46995c9a 100644
--- a/crates/turtle/src/atuin_client/api_client.rs
+++ b/crates/turtle/src/atuin_client/api_client.rs
@@ -16,41 +16,26 @@ use crate::atuin_common::{
};
use crate::atuin_common::{
api::{
- AddHistoryRequest, ChangePasswordRequest, CountResponse, DeleteHistoryRequest,
- ErrorResponse, LoginRequest, LoginResponse, MeResponse, RegisterResponse, StatusResponse,
- SyncHistoryResponse,
+ ChangePasswordRequest, ErrorResponse, LoginRequest, LoginResponse, MeResponse,
+ RegisterResponse,
},
record::RecordStatus,
};
use semver::Version;
-use time::OffsetDateTime;
-use time::format_description::well_known::Rfc3339;
-
-use crate::atuin_client::{history::History, sync::hash_str, utils::get_host_user};
static APP_USER_AGENT: &str = concat!("atuin/", env!("CARGO_PKG_VERSION"),);
/// Authentication token for sync API requests.
///
-/// The sync API supports two authentication methods:
-/// - `Bearer`: Hub API tokens (for users authenticated via Atuin Hub)
-/// - `Token`: Legacy CLI session tokens (for users registered via CLI or self-hosted)
-///
-/// When both are available, Hub tokens are preferred as they provide unified
-/// authentication across CLI and Hub features.
+/// Used with `Token <token>` header.
#[derive(Debug, Clone)]
-pub(crate) enum AuthToken {
- /// Legacy CLI session token, used with "Token {token}" header
- Token(String),
-}
+pub(crate) struct AuthToken(pub(crate) String);
impl AuthToken {
/// Format the token as an Authorization header value
fn to_header_value(&self) -> String {
- match self {
- AuthToken::Token(token) => format!("Token {token}"),
- }
+ format!("Token {}", self.0)
}
}
@@ -62,7 +47,7 @@ pub(crate) struct Client<'a> {
fn make_url(address: &str, path: &str) -> Result<String> {
// `join()` expects a trailing `/` in order to join paths
// e.g. it treats `http://host:port/subdir` as a file called `subdir`
- let address = if address.ends_with("/") {
+ let address = if address.ends_with('/') {
address
} else {
&format!("{address}/")
@@ -223,42 +208,6 @@ impl<'a> Client<'a> {
})
}
- pub(crate) async fn count(&self) -> Result<i64> {
- let url = make_url(self.sync_addr, "/sync/count")?;
- let url = Url::parse(url.as_str())?;
-
- let resp = self.client.get(url).send().await?;
- let resp = handle_resp_error(resp).await?;
-
- if !ensure_version(&resp)? {
- bail!("could not sync due to version mismatch");
- }
-
- if resp.status() != StatusCode::OK {
- bail!("failed to get count (are you logged in?)");
- }
-
- let count = resp.json::<CountResponse>().await?;
-
- Ok(count.count)
- }
-
- pub(crate) async fn status(&self) -> Result<StatusResponse> {
- let url = make_url(self.sync_addr, "/sync/status")?;
- let url = Url::parse(url.as_str())?;
-
- let resp = self.client.get(url).send().await?;
- let resp = handle_resp_error(resp).await?;
-
- if !ensure_version(&resp)? {
- bail!("could not sync due to version mismatch");
- }
-
- let status = resp.json::<StatusResponse>().await?;
-
- Ok(status)
- }
-
pub(crate) async fn me(&self) -> Result<MeResponse> {
let url = make_url(self.sync_addr, "/api/v0/me")?;
let url = Url::parse(url.as_str())?;
@@ -271,59 +220,6 @@ impl<'a> Client<'a> {
Ok(status)
}
- pub(crate) async fn get_history(
- &self,
- sync_ts: OffsetDateTime,
- history_ts: OffsetDateTime,
- host: Option<String>,
- ) -> Result<SyncHistoryResponse> {
- let host = host.unwrap_or_else(|| hash_str(&get_host_user()));
-
- let url = make_url(
- self.sync_addr,
- &format!(
- "/sync/history?sync_ts={}&history_ts={}&host={}",
- urlencoding::encode(sync_ts.format(&Rfc3339)?.as_str()),
- urlencoding::encode(history_ts.format(&Rfc3339)?.as_str()),
- host,
- ),
- )?;
-
- let resp = self.client.get(url).send().await?;
- let resp = handle_resp_error(resp).await?;
-
- let history = resp.json::<SyncHistoryResponse>().await?;
- Ok(history)
- }
-
- pub(crate) async fn post_history(&self, history: &[AddHistoryRequest]) -> Result<()> {
- let url = make_url(self.sync_addr, "/history")?;
- let url = Url::parse(url.as_str())?;
-
- let resp = self.client.post(url).json(history).send().await?;
- handle_resp_error(resp).await?;
-
- Ok(())
- }
-
- pub(crate) async fn delete_history(&self, h: History) -> Result<()> {
- let url = make_url(self.sync_addr, "/history")?;
- let url = Url::parse(url.as_str())?;
-
- let resp = self
- .client
- .delete(url)
- .json(&DeleteHistoryRequest {
- client_id: h.id.to_string(),
- })
- .send()
- .await?;
-
- handle_resp_error(resp).await?;
-
- Ok(())
- }
-
pub(crate) async fn delete_store(&self) -> Result<()> {
let url = make_url(self.sync_addr, "/api/v0/store")?;
let url = Url::parse(url.as_str())?;
diff --git a/crates/turtle/src/atuin_client/meta.rs b/crates/turtle/src/atuin_client/meta.rs
index 502d7421..92902c08 100644
--- a/crates/turtle/src/atuin_client/meta.rs
+++ b/crates/turtle/src/atuin_client/meta.rs
@@ -7,22 +7,12 @@ 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, warn};
+use tracing::debug;
use uuid::Uuid;
-// Filenames for the legacy plain-text files that we migrate from.
-const LEGACY_HOST_ID_FILENAME: &str = "host_id";
-const LEGACY_LAST_SYNC_FILENAME: &str = "last_sync_time";
-const LEGACY_LAST_VERSION_CHECK_FILENAME: &str = "last_version_check_time";
-const LEGACY_LATEST_VERSION_FILENAME: &str = "latest_version";
-const LEGACY_SESSION_FILENAME: &str = "session";
-
const KEY_HOST_ID: &str = "host_id";
const KEY_LAST_SYNC: &str = "last_sync_time";
-const KEY_LAST_VERSION_CHECK: &str = "last_version_check_time";
-const KEY_LATEST_VERSION: &str = "latest_version";
const KEY_SESSION: &str = "session";
-const KEY_FILES_MIGRATED: &str = "files_migrated";
pub(crate) struct MetaStore {
pool: SqlitePool,
@@ -77,10 +67,6 @@ impl MetaStore {
cached_host_id: OnceCell::const_new(),
};
- if !is_memory {
- store.migrate_files().await?;
- }
-
Ok(store)
}
@@ -97,8 +83,12 @@ impl MetaStore {
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')",
+ "
+ 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)
@@ -153,29 +143,6 @@ impl MetaStore {
.await
}
- pub(crate) async fn last_version_check(&self) -> Result<OffsetDateTime> {
- match self.get(KEY_LAST_VERSION_CHECK).await? {
- Some(v) => Ok(OffsetDateTime::parse(v.as_str(), &Rfc3339)?),
- None => Ok(OffsetDateTime::UNIX_EPOCH),
- }
- }
-
- pub(crate) async fn save_version_check_time(&self) -> Result<()> {
- self.set(
- KEY_LAST_VERSION_CHECK,
- OffsetDateTime::now_utc().format(&Rfc3339)?.as_str(),
- )
- .await
- }
-
- pub(crate) async fn latest_version(&self) -> Result<Option<String>> {
- self.get(KEY_LATEST_VERSION).await
- }
-
- pub(crate) async fn save_latest_version(&self, version: &str) -> Result<()> {
- self.set(KEY_LATEST_VERSION, version).await
- }
-
pub(crate) async fn session_token(&self) -> Result<Option<String>> {
self.get(KEY_SESSION).await
}
@@ -191,90 +158,6 @@ impl MetaStore {
pub(crate) async fn logged_in(&self) -> Result<bool> {
Ok(self.session_token().await?.is_some())
}
-
- // File migration: on first open, migrate old plain-text files into the database.
- // Old files are left in place for safe downgrades.
-
- async fn migrate_files(&self) -> Result<()> {
- if self.get(KEY_FILES_MIGRATED).await?.is_some() {
- return Ok(());
- }
-
- let data_dir = crate::atuin_client::settings::Settings::effective_data_dir();
-
- // host_id — validate as UUID
- let host_id_path = data_dir.join(LEGACY_HOST_ID_FILENAME);
- if host_id_path.exists()
- && let Ok(value) = fs_err::read_to_string(&host_id_path)
- {
- let value = value.trim();
- if !value.is_empty() {
- if Uuid::from_str(value).is_ok() {
- self.set(KEY_HOST_ID, value).await?;
- } else {
- warn!("skipping migration of host_id: invalid UUID {value:?}");
- }
- }
- }
-
- // last_sync_time — validate as RFC3339
- let sync_path = data_dir.join(LEGACY_LAST_SYNC_FILENAME);
- if sync_path.exists()
- && let Ok(value) = fs_err::read_to_string(&sync_path)
- {
- let value = value.trim();
- if !value.is_empty() {
- if OffsetDateTime::parse(value, &Rfc3339).is_ok() {
- self.set(KEY_LAST_SYNC, value).await?;
- } else {
- warn!("skipping migration of last_sync_time: invalid RFC3339 {value:?}");
- }
- }
- }
-
- // last_version_check_time — validate as RFC3339
- let version_check_path = data_dir.join(LEGACY_LAST_VERSION_CHECK_FILENAME);
- if version_check_path.exists()
- && let Ok(value) = fs_err::read_to_string(&version_check_path)
- {
- let value = value.trim();
- if !value.is_empty() {
- if OffsetDateTime::parse(value, &Rfc3339).is_ok() {
- self.set(KEY_LAST_VERSION_CHECK, value).await?;
- } else {
- warn!(
- "skipping migration of last_version_check_time: invalid RFC3339 {value:?}"
- );
- }
- }
- }
-
- // latest_version — no strict validation, just non-empty
- let latest_version_path = data_dir.join(LEGACY_LATEST_VERSION_FILENAME);
- if latest_version_path.exists()
- && let Ok(value) = fs_err::read_to_string(&latest_version_path)
- {
- let value = value.trim();
- if !value.is_empty() {
- self.set(KEY_LATEST_VERSION, value).await?;
- }
- }
-
- // session token — no strict validation, just non-empty
- let session_path = data_dir.join(LEGACY_SESSION_FILENAME);
- if session_path.exists()
- && let Ok(value) = fs_err::read_to_string(&session_path)
- {
- let value = value.trim();
- if !value.is_empty() {
- self.set(KEY_SESSION, value).await?;
- }
- }
-
- self.set(KEY_FILES_MIGRATED, "true").await?;
-
- Ok(())
- }
}
#[cfg(test)]
@@ -324,18 +207,6 @@ mod tests {
}
#[tokio::test]
- async fn test_version_check_time() {
- let store = new_test_store().await;
-
- let t = store.last_version_check().await.unwrap();
- assert_eq!(t, OffsetDateTime::UNIX_EPOCH);
-
- store.save_version_check_time().await.unwrap();
- let t = store.last_version_check().await.unwrap();
- assert!(t > OffsetDateTime::UNIX_EPOCH);
- }
-
- #[tokio::test]
async fn test_session_crud() {
let store = new_test_store().await;
@@ -352,17 +223,4 @@ mod tests {
store.delete_session().await.unwrap();
assert!(!store.logged_in().await.unwrap());
}
-
- #[tokio::test]
- async fn test_latest_version() {
- let store = new_test_store().await;
-
- assert_eq!(store.latest_version().await.unwrap(), None);
-
- store.save_latest_version("1.2.3").await.unwrap();
- assert_eq!(
- store.latest_version().await.unwrap(),
- Some("1.2.3".to_string())
- );
- }
}
diff --git a/crates/turtle/src/atuin_client/mod.rs b/crates/turtle/src/atuin_client/mod.rs
index 0ac294cf..ff376c0c 100644
--- a/crates/turtle/src/atuin_client/mod.rs
+++ b/crates/turtle/src/atuin_client/mod.rs
@@ -6,8 +6,6 @@ pub(crate) mod auth;
pub(crate) mod login;
#[cfg(feature = "sync")]
pub(crate) mod register;
-#[cfg(feature = "sync")]
-pub(crate) mod sync;
pub(crate) mod database;
pub(crate) mod distro;
diff --git a/crates/turtle/src/atuin_client/settings.rs b/crates/turtle/src/atuin_client/settings.rs
index 046aad1a..5ee7cb77 100644
--- a/crates/turtle/src/atuin_client/settings.rs
+++ b/crates/turtle/src/atuin_client/settings.rs
@@ -15,8 +15,6 @@ use serde::{Deserialize, Serialize};
use serde_with::DeserializeFromStr;
use time::{OffsetDateTime, UtcOffset, format_description::FormatItem, macros::format_description};
-pub(crate) const HISTORY_PAGE_SIZE: i64 = 100;
-
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();
@@ -24,9 +22,6 @@ static META_STORE: OnceCell<crate::atuin_client::meta::MetaStore> = OnceCell::co
pub(crate) mod meta;
pub(crate) mod watcher;
-/// Default sync address for Atuin's hosted service
-pub(crate) const DEFAULT_SYNC_ADDRESS: &str = "https://api.atuin.sh";
-
#[derive(Clone, Debug, Deserialize, Copy, ValueEnum, PartialEq, Serialize)]
pub(crate) enum SearchMode {
#[serde(rename = "prefix")]
@@ -335,23 +330,6 @@ impl Default for Stats {
}
}
-/// Sync protocol type for authentication.
-///
-/// This setting is primarily for development/testing. When not explicitly set,
-/// the protocol is inferred from the sync_address:
-/// - Default sync address (api.atuin.sh) → Hub protocol
-/// - Custom sync address → Legacy protocol
-///
-/// Set explicitly to "hub" to use Hub authentication with a custom sync_address
-/// (useful for local development against a Hub instance).
-#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize, Default)]
-#[serde(rename_all = "lowercase")]
-pub(crate) enum SyncProtocol {
- /// Use legacy CLI authentication (Token from CLI register/login)
- #[default]
- Legacy,
-}
-
/// Resolved authentication state for sync operations.
///
/// Determined at runtime by examining which tokens are available and what
@@ -376,13 +354,14 @@ impl SyncAuth {
pub(crate) fn into_auth_token(self) -> Result<crate::atuin_client::api_client::AuthToken> {
use crate::atuin_client::api_client::AuthToken;
match self {
- SyncAuth::Legacy { token } => Ok(AuthToken::Token(token)),
+ SyncAuth::Legacy { token } => Ok(AuthToken(token)),
SyncAuth::NotLoggedIn { reason } => Err(eyre!(reason)),
}
}
}
#[derive(Clone, Debug, Deserialize, Default, Serialize)]
+#[expect(clippy::struct_excessive_bools)]
pub(crate) struct Keys {
pub(crate) scroll_exits: bool,
pub(crate) exit_past_line_start: bool,
@@ -527,18 +506,6 @@ pub(crate) struct Search {
pub(crate) frecency_score_multiplier: f64,
}
-#[derive(Clone, Debug, Deserialize, Serialize)]
-pub(crate) struct Tmux {
- /// Enable using atuin with tmux popup (tmux >= 3.2)
- pub(crate) enabled: bool,
-
- /// Width of the tmux popup (percentage)
- pub(crate) width: String,
-
- /// Height of the tmux popup (percentage)
- pub(crate) height: String,
-}
-
/// Log level for file logging. Maps to tracing's LevelFilter.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
@@ -605,63 +572,6 @@ pub(crate) struct Logs {
/// Daemon log settings
#[serde(default)]
pub(crate) daemon: LogConfig,
-
- /// AI log settings
- #[serde(default)]
- pub(crate) ai: LogConfig,
-}
-
-#[derive(Default, Clone, Debug, Deserialize, Serialize)]
-pub(crate) struct Ai {
- /// Whether or not the AI features are enabled.
- pub(crate) enabled: Option<bool>,
-
- /// The address of the Atuin AI endpoint. Used for AI features like command generation.
- /// Only necessary for custom AI endpoints.
- pub(crate) endpoint: Option<String>,
-
- /// The API token for the Atuin AI endpoint. Used for AI features like command generation.
- /// Only necessary for custom AI endpoints.
- pub(crate) api_token: Option<String>,
-
- /// Path to the AI sessions database.
- pub(crate) db_path: String,
-
- /// The maximum time in minutes that an AI session can be automatically resumed.
- pub(crate) session_continue_minutes: i64,
-
- /// Deprecated: use opening.send_cwd instead. Kept for backwards compatibility.
- #[serde(default)]
- pub(crate) send_cwd: Option<bool>,
-
- /// Configuration for what context is sent in the opening AI request.
- #[serde(default)]
- pub(crate) opening: AiOpening,
-
- /// Tool capability flags.
- #[serde(default)]
- pub(crate) capabilities: AiCapabilities,
-}
-
-#[derive(Default, Clone, Debug, Deserialize, Serialize)]
-pub(crate) struct AiCapabilities {
- /// Whether the AI can request to search Atuin history. `None` = unset (defaults to enabled, and the ai will ask for permission).
- pub(crate) enable_history_search: Option<bool>,
- /// Whether the AI can request to view the stored output, if any, for Atuin history entries. `None` = unset (defaults to enabled, and the ai will ask for permission).
- pub(crate) enable_history_output: Option<bool>,
- /// Whether the AI can request to read and write files. `None` = unset (defaults to enabled, and the ai will ask for permission).
- pub(crate) enable_file_tools: Option<bool>,
- /// Whether the AI can request to execute bash commands. `None` = unset (defaults to enabled, and the ai will ask for permission).
- pub(crate) enable_command_execution: Option<bool>,
-}
-
-#[derive(Default, Clone, Debug, Deserialize, Serialize)]
-pub(crate) struct AiOpening {
- /// Whether or not to send the current working directory to the AI endpoint.
- pub(crate) send_cwd: Option<bool>,
-
- /// Whether or not to send the last command as context in the opening AI request.
- pub(crate) send_last_command: Option<bool>,
}
impl Default for Preview {
@@ -711,10 +621,6 @@ impl Default for Logs {
file: "daemon.log".to_string(),
..Default::default()
},
- ai: LogConfig {
- file: "ai.log".to_string(),
- ..Default::default()
- },
}
}
}
@@ -740,12 +646,6 @@ impl Logs {
self.daemon.enabled.unwrap_or(self.enabled)
}
- /// Returns whether AI logging is enabled.
- /// Uses AI-specific setting if set, otherwise falls back to global.
- pub(crate) fn ai_enabled(&self) -> bool {
- self.ai.enabled.unwrap_or(self.enabled)
- }
-
/// Returns the log level for search logging.
/// Uses search-specific setting if set, otherwise falls back to global.
pub(crate) fn search_level(&self) -> LogLevel {
@@ -758,12 +658,6 @@ impl Logs {
self.daemon.level.unwrap_or(self.level)
}
- /// Returns the log level for AI logging.
- /// Uses AI-specific setting if set, otherwise falls back to global.
- pub(crate) fn ai_level(&self) -> LogLevel {
- self.ai.level.unwrap_or(self.level)
- }
-
/// Returns the retention days for search logging.
/// Uses search-specific setting if set, otherwise falls back to global.
pub(crate) fn search_retention(&self) -> u64 {
@@ -776,12 +670,6 @@ impl Logs {
self.daemon.retention.unwrap_or(self.retention)
}
- /// Returns the retention days for AI logging.
- /// Uses AI-specific setting if set, otherwise falls back to global.
- pub(crate) fn ai_retention(&self) -> u64 {
- self.ai.retention.unwrap_or(self.retention)
- }
-
/// Returns the full path for the search log file.
pub(crate) fn search_path(&self) -> PathBuf {
let path = PathBuf::from(&self.search.file);
@@ -793,12 +681,6 @@ impl Logs {
let path = PathBuf::from(&self.daemon.file);
PathBuf::from(&self.dir).join(path)
}
-
- /// Returns the full path for the AI log file.
- pub(crate) fn ai_path(&self) -> PathBuf {
- let path = PathBuf::from(&self.ai.file);
- PathBuf::from(&self.dir).join(path)
- }
}
impl Default for Search {
@@ -820,16 +702,6 @@ impl Default for Search {
}
}
-impl Default for Tmux {
- fn default() -> Self {
- Self {
- enabled: false,
- width: "80%".to_string(),
- height: "60%".to_string(),
- }
- }
-}
-
// The preview height strategy also takes max_preview_height into account.
#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq, ValueEnum, Serialize)]
pub(crate) enum PreviewStrategy {
@@ -1031,6 +903,7 @@ impl Default for Ui {
}
#[derive(Clone, Debug, Deserialize, Serialize)]
+#[expect(clippy::struct_excessive_bools)]
pub(crate) struct Settings {
pub(crate) data_dir: Option<String>,
pub(crate) dialect: Dialect,
@@ -1041,9 +914,6 @@ pub(crate) struct Settings {
/// The sync address for atuin.
pub(crate) sync_address: String,
- #[serde(default)]
- pub(crate) sync_protocol: SyncProtocol,
-
pub(crate) sync_frequency: String,
pub(crate) db_path: String,
pub(crate) record_store_path: String,
@@ -1117,9 +987,6 @@ pub(crate) struct Settings {
pub(crate) ui: Ui,
#[serde(default)]
- pub(crate) tmux: Tmux,
-
- #[serde(default)]
pub(crate) logs: Logs,
#[serde(default)]
@@ -1170,14 +1037,6 @@ impl Settings {
Self::meta_store().await?.save_sync_time().await
}
- pub(crate) async fn last_version_check() -> Result<OffsetDateTime> {
- Self::meta_store().await?.last_version_check().await
- }
-
- pub(crate) async fn save_version_check_time() -> Result<()> {
- Self::meta_store().await?.save_version_check_time().await
- }
-
pub(crate) async fn should_sync(&self) -> Result<bool> {
if !self.auto_sync || !Self::meta_store().await?.logged_in().await? {
return Ok(false);
@@ -1238,7 +1097,9 @@ impl Settings {
/// `AuthToken`. Callers that need to distinguish between auth states
/// (e.g. to show different UI) should call `resolve_sync_auth` directly.
#[cfg(feature = "sync")]
- pub(crate) async fn sync_auth_token(&self) -> Result<crate::atuin_client::api_client::AuthToken> {
+ pub(crate) async fn sync_auth_token(
+ &self,
+ ) -> Result<crate::atuin_client::api_client::AuthToken> {
self.resolve_sync_auth().await.into_auth_token()
}
diff --git a/crates/turtle/src/atuin_client/sync.rs b/crates/turtle/src/atuin_client/sync.rs
deleted file mode 100644
index 46efdab9..00000000
--- a/crates/turtle/src/atuin_client/sync.rs
+++ /dev/null
@@ -1,214 +0,0 @@
-use std::collections::HashSet;
-use std::iter::FromIterator;
-
-use eyre::Result;
-use tracing::{debug, info};
-
-use crate::atuin_common::api::AddHistoryRequest;
-use crypto_secretbox::Key;
-use time::OffsetDateTime;
-
-use crate::atuin_client::{
- api_client,
- database::Database,
- encryption::{decrypt, encrypt, load_key},
- settings::Settings,
-};
-
-pub(crate) fn hash_str(string: &str) -> String {
- use sha2::{Digest, Sha256};
- let mut hasher = Sha256::new();
- hasher.update(string.as_bytes());
- hex::encode(hasher.finalize())
-}
-
-// Currently sync is kinda naive, and basically just pages backwards through
-// history. This means newly added stuff shows up properly! We also just use
-// the total count in each database to indicate whether a sync is needed.
-// I think this could be massively improved! If we had a way of easily
-// indicating count per time period (hour, day, week, year, etc) then we can
-// easily pinpoint where we are missing data and what needs downloading. Start
-// with year, then find the week, then the day, then the hour, then download it
-// all! The current naive approach will do for now.
-
-// Check if remote has things we don't, and if so, download them.
-// Returns (num downloaded, total local)
-async fn sync_download(
- key: &Key,
- force: bool,
- client: &api_client::Client<'_>,
- db: &impl Database,
-) -> Result<(i64, i64)> {
- debug!("starting sync download");
-
- let remote_status = client.status().await?;
- let remote_count = remote_status.count;
-
- // useful to ensure we don't even save something that hasn't yet been synced + deleted
- let remote_deleted =
- HashSet::<&str>::from_iter(remote_status.deleted.iter().map(String::as_str));
-
- let initial_local = db.history_count(true).await?;
- let mut local_count = initial_local;
-
- let mut last_sync = if force {
- OffsetDateTime::UNIX_EPOCH
- } else {
- Settings::last_sync().await?
- };
-
- let mut last_timestamp = OffsetDateTime::UNIX_EPOCH;
-
- let host = if force { Some(String::from("")) } else { None };
-
- while remote_count > local_count {
- let page = client
- .get_history(last_sync, last_timestamp, host.clone())
- .await?;
-
- let history: Vec<_> = page
- .history
- .iter()
- // TODO: handle deletion earlier in this chain
- .map(|h| serde_json::from_str(h).expect("invalid base64"))
- .map(|h| decrypt(h, key).expect("failed to decrypt history! check your key"))
- .map(|mut h| {
- if remote_deleted.contains(h.id.0.as_str()) {
- h.deleted_at = Some(time::OffsetDateTime::now_utc());
- h.command = String::from("");
- }
-
- h
- })
- .collect();
-
- db.save_bulk(&history).await?;
-
- local_count = db.history_count(true).await?;
- let remote_page_size = std::cmp::max(remote_status.page_size, 0) as usize;
-
- if history.len() < remote_page_size {
- break;
- }
-
- let page_last = history
- .last()
- .expect("could not get last element of page")
- .timestamp;
-
- // in the case of a small sync frequency, it's possible for history to
- // be "lost" between syncs. In this case we need to rewind the sync
- // timestamps
- if page_last == last_timestamp {
- last_timestamp = OffsetDateTime::UNIX_EPOCH;
- last_sync -= time::Duration::hours(1);
- } else {
- last_timestamp = page_last;
- }
- }
-
- for i in remote_status.deleted {
- // we will update the stored history to have this data
- // pretty much everything can be nullified
- match db.load(i.as_str()).await? {
- Some(h) => {
- db.delete(h).await?;
- }
- _ => {
- info!(
- "could not delete history with id {}, not found locally",
- i.as_str()
- );
- }
- }
- }
-
- Ok((local_count - initial_local, local_count))
-}
-
-// Check if we have things remote doesn't, and if so, upload them
-async fn sync_upload(
- key: &Key,
- _force: bool,
- client: &api_client::Client<'_>,
- db: &impl Database,
-) -> Result<()> {
- debug!("starting sync upload");
-
- let remote_status = client.status().await?;
- let remote_deleted: HashSet<String> = HashSet::from_iter(remote_status.deleted.clone());
-
- let initial_remote_count = client.count().await?;
- let mut remote_count = initial_remote_count;
-
- let local_count = db.history_count(true).await?;
-
- debug!("remote has {remote_count}, we have {local_count}");
-
- // first just try the most recent set
- let mut cursor = OffsetDateTime::now_utc();
-
- while local_count > remote_count {
- let last = db.before(cursor, remote_status.page_size).await?;
- let mut buffer = Vec::new();
-
- if last.is_empty() {
- break;
- }
-
- for i in last {
- let data = encrypt(&i, key)?;
- let data = serde_json::to_string(&data)?;
-
- let add_hist = AddHistoryRequest {
- id: i.id.to_string(),
- timestamp: i.timestamp,
- data,
- hostname: hash_str(&i.hostname),
- };
-
- buffer.push(add_hist);
- }
-
- // anything left over outside of the 100 block size
- client.post_history(&buffer).await?;
- cursor = buffer.last().unwrap().timestamp;
- remote_count = client.count().await?;
-
- debug!("upload cursor: {cursor:?}");
- }
-
- let deleted = db.deleted().await?;
-
- for i in deleted {
- if remote_deleted.contains(&i.id.to_string()) {
- continue;
- }
-
- info!("deleting {} on remote", i.id);
- client.delete_history(i).await?;
- }
-
- Ok(())
-}
-
-pub(crate) async fn sync(settings: &Settings, force: bool, db: &impl Database) -> Result<()> {
- let client = api_client::Client::new(
- &settings.sync_address,
- settings.sync_auth_token().await?,
- settings.network_connect_timeout,
- settings.network_timeout,
- )?;
-
- Settings::save_sync_time().await?;
-
- let key = load_key(settings)?; // encryption key
-
- sync_upload(&key, force, &client, db).await?;
-
- let download = sync_download(&key, force, &client, db).await?;
-
- debug!("sync downloaded {}", download.0);
-
- Ok(())
-}
diff --git a/crates/turtle/src/atuin_common/api.rs b/crates/turtle/src/atuin_common/api.rs
index 2f909676..56adbcc5 100644
--- a/crates/turtle/src/atuin_common/api.rs
+++ b/crates/turtle/src/atuin_common/api.rs
@@ -125,20 +125,3 @@ pub(crate) struct MessageResponse {
pub(crate) struct MeResponse {
pub(crate) username: String,
}
-
-// Hub CLI authentication types
-
-/// Response from POST /auth/cli/code - generates a code for CLI auth
-#[derive(Debug, Serialize, Deserialize)]
-pub(crate) struct CliCodeResponse {
- pub(crate) code: String,
-}
-
-/// Response from GET /auth/cli/verify?code=<code> - polls for authorization
-#[derive(Debug, Serialize, Deserialize)]
-pub(crate) struct CliVerifyResponse {
- /// Session token, present only when authorization is complete
- pub(crate) token: Option<String>,
- pub(crate) success: Option<bool>,
- pub(crate) error: Option<String>,
-}
diff --git a/crates/turtle/src/atuin_common/utils.rs b/crates/turtle/src/atuin_common/utils.rs
index 81eb1178..09718241 100644
--- a/crates/turtle/src/atuin_common/utils.rs
+++ b/crates/turtle/src/atuin_common/utils.rs
@@ -2,8 +2,6 @@ use std::borrow::Cow;
use std::env;
use std::path::{Path, PathBuf};
-use eyre::{Result, eyre};
-
use base64::prelude::{BASE64_URL_SAFE_NO_PAD, Engine};
use getrandom::getrandom;
use uuid::Uuid;
@@ -32,10 +30,6 @@ pub(crate) fn uuid_v7() -> Uuid {
Uuid::now_v7()
}
-pub(crate) fn uuid_v4() -> String {
- Uuid::new_v4().as_simple().to_string()
-}
-
pub(crate) fn has_git_dir(path: &str) -> bool {
let mut gitdir = PathBuf::from(path);
gitdir.push(".git");
@@ -128,14 +122,6 @@ pub(crate) fn logs_dir() -> PathBuf {
home_dir().join(".atuin").join("logs")
}
-pub(crate) fn dotfiles_cache_dir() -> PathBuf {
- // In most cases, this will be ~/.local/share/atuin/dotfiles/cache
- let data_dir = std::env::var("XDG_DATA_HOME")
- .map_or_else(|_| home_dir().join(".local").join("share"), PathBuf::from);
-
- data_dir.join("atuin").join("dotfiles").join("cache")
-}
-
pub(crate) fn get_current_dir() -> String {
// Prefer PWD environment variable over cwd if available to better support symbolic links
match env::var("PWD") {
@@ -182,33 +168,8 @@ pub(crate) trait Escapable: AsRef<str> {
}
}
-pub(crate) fn unquote(s: &str) -> Result<String> {
- if s.chars().count() < 2 {
- return Err(eyre!("not enough chars"));
- }
-
- let quote = s.chars().next().unwrap();
-
- // not quoted, do nothing
- if quote != '"' && quote != '\'' && quote != '`' {
- return Ok(s.to_string());
- }
-
- if s.chars().last().unwrap() != quote {
- return Err(eyre!("unexpected eof, quotes do not match"));
- }
-
- // removes quote characters
- // the sanity checks performed above ensure that the quotes will be ASCII and this will not
- // panic
- let s = &s[1..s.len() - 1];
-
- Ok(s.to_string())
-}
-
impl<T: AsRef<str>> Escapable for T {}
-#[expect(unsafe_code)]
#[cfg(test)]
mod tests {
use pretty_assertions::assert_ne;
@@ -227,58 +188,9 @@ mod tests {
test_data_dir();
}
- #[cfg(not(windows))]
- fn test_config_dir_xdg() {
- // TODO: Audit that the environment access only happens in single-threaded code.
- unsafe { env::remove_var("HOME") };
- // TODO: Audit that the environment access only happens in single-threaded code.
- unsafe { env::set_var("XDG_CONFIG_HOME", "/home/user/custom_config") };
- assert_eq!(
- config_dir(),
- PathBuf::from("/home/user/custom_config/atuin")
- );
- // TODO: Audit that the environment access only happens in single-threaded code.
- unsafe { env::remove_var("XDG_CONFIG_HOME") };
- }
-
- #[cfg(not(windows))]
- fn test_config_dir() {
- // TODO: Audit that the environment access only happens in single-threaded code.
- unsafe { env::set_var("HOME", "/home/user") };
- // TODO: Audit that the environment access only happens in single-threaded code.
- unsafe { env::remove_var("XDG_CONFIG_HOME") };
-
- assert_eq!(config_dir(), PathBuf::from("/home/user/.config/atuin"));
-
- // TODO: Audit that the environment access only happens in single-threaded code.
- unsafe { env::remove_var("HOME") };
- }
-
- #[cfg(not(windows))]
- fn test_data_dir_xdg() {
- // TODO: Audit that the environment access only happens in single-threaded code.
- unsafe { env::remove_var("HOME") };
- // TODO: Audit that the environment access only happens in single-threaded code.
- unsafe { env::set_var("XDG_DATA_HOME", "/home/user/custom_data") };
- assert_eq!(data_dir(), PathBuf::from("/home/user/custom_data/atuin"));
- // TODO: Audit that the environment access only happens in single-threaded code.
- unsafe { env::remove_var("XDG_DATA_HOME") };
- }
-
- #[cfg(not(windows))]
- fn test_data_dir() {
- // TODO: Audit that the environment access only happens in single-threaded code.
- unsafe { env::set_var("HOME", "/home/user") };
- // TODO: Audit that the environment access only happens in single-threaded code.
- unsafe { env::remove_var("XDG_DATA_HOME") };
- assert_eq!(data_dir(), PathBuf::from("/home/user/.local/share/atuin"));
- // TODO: Audit that the environment access only happens in single-threaded code.
- unsafe { env::remove_var("HOME") };
- }
-
#[test]
fn uuid_is_unique() {
- let how_many: usize = 1000000;
+ let how_many: usize = 1_000_000;
// for peace of mind
let mut uuids: HashSet<Uuid> = HashSet::with_capacity(how_many);
diff --git a/crates/turtle/src/atuin_server_database/calendar.rs b/crates/turtle/src/atuin_server/database/calendar.rs
index f1c78262..f1c78262 100644
--- a/crates/turtle/src/atuin_server_database/calendar.rs
+++ b/crates/turtle/src/atuin_server/database/calendar.rs
diff --git a/crates/turtle/src/atuin_server_postgres/mod.rs b/crates/turtle/src/atuin_server/database/db/mod.rs
index e06f8721..22d69d3c 100644
--- a/crates/turtle/src/atuin_server_postgres/mod.rs
+++ b/crates/turtle/src/atuin_server/database/db/mod.rs
@@ -3,17 +3,20 @@ use std::ops::Range;
use rand::Rng;
-use crate::atuin_common::record::{EncryptedData, HostId, Record, RecordIdx, RecordStatus};
-use crate::atuin_server_database::models::{
- History, NewHistory, NewSession, NewUser, Session, User,
+use crate::{
+ atuin_common::record::{EncryptedData, HostId, Record, RecordIdx, RecordStatus},
+ atuin_server::database::{
+ DbError, DbResult, DbSettings,
+ calendar::{TimePeriod, TimePeriodInfo},
+ into_utc,
+ models::{History, NewHistory, NewSession, NewUser, Session, User},
+ },
};
-use crate::atuin_server_database::{Database, DbError, DbResult, DbSettings, into_utc};
-use async_trait::async_trait;
use futures_util::TryStreamExt;
use sqlx::Row;
use sqlx::postgres::PgPoolOptions;
+use time::{Date, Duration, Month, OffsetDateTime, Time, UtcOffset};
-use time::OffsetDateTime;
use tracing::instrument;
use uuid::Uuid;
use wrappers::{DbHistory, DbRecord, DbSession, DbUser};
@@ -23,13 +26,13 @@ mod wrappers;
const MIN_PG_VERSION: u32 = 14;
#[derive(Clone)]
-pub(crate) struct Postgres {
+pub struct Database {
pool: sqlx::Pool<sqlx::postgres::Postgres>,
/// Optional read replica pool for read-only queries
read_pool: Option<sqlx::Pool<sqlx::postgres::Postgres>>,
}
-impl Postgres {
+impl Database {
/// Returns the appropriate pool for read operations.
/// Uses read_pool if available, otherwise falls back to the primary pool.
fn read_pool(&self) -> &sqlx::Pool<sqlx::postgres::Postgres> {
@@ -37,9 +40,8 @@ impl Postgres {
}
}
-#[async_trait]
-impl Database for Postgres {
- async fn new(settings: &DbSettings) -> DbResult<Self> {
+impl Database {
+ pub(crate) async fn new(settings: &DbSettings) -> DbResult<Self> {
let pool = PgPoolOptions::new()
.max_connections(100)
.connect(settings.db_uri.as_str())
@@ -100,7 +102,85 @@ impl Database for Postgres {
}
#[instrument(skip_all)]
- async fn get_session(&self, token: &str) -> DbResult<Session> {
+ pub(crate) async fn calendar(
+ &self,
+ user: &User,
+ period: TimePeriod,
+ tz: UtcOffset,
+ ) -> DbResult<HashMap<u64, TimePeriodInfo>> {
+ let mut ret = HashMap::new();
+ let iter: Box<dyn Iterator<Item = DbResult<(u64, Range<Date>)>> + Send> = match period {
+ TimePeriod::Year => {
+ // First we need to work out how far back to calculate. Get the
+ // oldest history item
+ let oldest = self
+ .oldest_history(user)
+ .await?
+ .timestamp
+ .to_offset(tz)
+ .year();
+ let current_year = OffsetDateTime::now_utc().to_offset(tz).year();
+
+ // All the years we need to get data for
+ // The upper bound is exclusive, so include current +1
+ let years = oldest..current_year + 1;
+
+ Box::new(years.map(|year| {
+ let start = Date::from_calendar_date(year, time::Month::January, 1)?;
+ let end = Date::from_calendar_date(year + 1, time::Month::January, 1)?;
+
+ Ok((year as u64, start..end))
+ }))
+ }
+
+ TimePeriod::Month { year } => {
+ let months =
+ std::iter::successors(Some(Month::January), |m| Some(m.next())).take(12);
+
+ Box::new(months.map(move |month| {
+ let start = Date::from_calendar_date(year, month, 1)?;
+ let days = start.month().length(year);
+ let end = start + Duration::days(days as i64);
+
+ Ok((month as u64, start..end))
+ }))
+ }
+
+ TimePeriod::Day { year, month } => {
+ let days = 1..month.length(year);
+ Box::new(days.map(move |day| {
+ let start = Date::from_calendar_date(year, month, day)?;
+ let end = start
+ .next_day()
+ .ok_or_else(|| DbError::Other(eyre::eyre!("no next day?")))?;
+
+ Ok((day as u64, start..end))
+ }))
+ }
+ };
+
+ for x in iter {
+ let (index, range) = x?;
+
+ let start = range.start.with_time(Time::MIDNIGHT).assume_offset(tz);
+ let end = range.end.with_time(Time::MIDNIGHT).assume_offset(tz);
+
+ let count = self.count_history_range(user, start..end).await?;
+
+ ret.insert(
+ index,
+ TimePeriodInfo {
+ count: count as u64,
+ hash: "".to_string(),
+ },
+ );
+ }
+
+ Ok(ret)
+ }
+
+ #[instrument(skip_all)]
+ pub(crate) async fn get_session(&self, token: &str) -> DbResult<Session> {
sqlx::query_as("select id, user_id, token from sessions where token = $1")
.bind(token)
.fetch_one(self.read_pool())
@@ -110,7 +190,7 @@ impl Database for Postgres {
}
#[instrument(skip_all)]
- async fn get_user(&self, username: &str) -> DbResult<User> {
+ pub(crate) async fn get_user(&self, username: &str) -> DbResult<User> {
sqlx::query_as("select id, username, email, password from users where username = $1")
.bind(username)
.fetch_one(self.read_pool())
@@ -120,7 +200,7 @@ impl Database for Postgres {
}
#[instrument(skip_all)]
- async fn get_session_user(&self, token: &str) -> DbResult<User> {
+ pub(crate) async fn get_session_user(&self, token: &str) -> DbResult<User> {
sqlx::query_as(
"select users.id, users.username, users.email, users.password from users
inner join sessions
@@ -135,7 +215,7 @@ impl Database for Postgres {
}
#[instrument(skip_all)]
- async fn count_history(&self, user: &User) -> DbResult<i64> {
+ pub(crate) async fn count_history(&self, user: &User) -> DbResult<i64> {
// The cache is new, and the user might not yet have a cache value.
// They will have one as soon as they post up some new history, but handle that
// edge case.
@@ -152,7 +232,7 @@ impl Database for Postgres {
}
#[instrument(skip_all)]
- async fn count_history_cached(&self, user: &User) -> DbResult<i64> {
+ pub(crate) async fn count_history_cached(&self, user: &User) -> DbResult<i64> {
let res: (i32,) = sqlx::query_as(
"select total from total_history_count_user
where user_id = $1",
@@ -164,7 +244,7 @@ impl Database for Postgres {
Ok(res.0 as i64)
}
- async fn delete_store(&self, user: &User) -> DbResult<()> {
+ pub(crate) async fn delete_store(&self, user: &User) -> DbResult<()> {
let mut tx = self.pool.begin().await?;
sqlx::query(
@@ -188,7 +268,7 @@ impl Database for Postgres {
Ok(())
}
- async fn delete_history(&self, user: &User, id: String) -> DbResult<()> {
+ pub(crate) async fn delete_history(&self, user: &User, id: String) -> DbResult<()> {
sqlx::query(
"update history
set deleted_at = $3
@@ -206,7 +286,7 @@ impl Database for Postgres {
}
#[instrument(skip_all)]
- async fn deleted_history(&self, user: &User) -> DbResult<Vec<String>> {
+ pub(crate) async fn deleted_history(&self, user: &User) -> DbResult<Vec<String>> {
// The cache is new, and the user might not yet have a cache value.
// They will have one as soon as they post up some new history, but handle that
// edge case.
@@ -229,7 +309,7 @@ impl Database for Postgres {
}
#[instrument(skip_all)]
- async fn count_history_range(
+ pub(crate) async fn count_history_range(
&self,
user: &User,
range: Range<OffsetDateTime>,
@@ -250,7 +330,7 @@ impl Database for Postgres {
}
#[instrument(skip_all)]
- async fn list_history(
+ pub(crate) async fn list_history(
&self,
user: &User,
created_after: OffsetDateTime,
@@ -281,7 +361,7 @@ impl Database for Postgres {
}
#[instrument(skip_all)]
- async fn add_history(&self, history: &[NewHistory]) -> DbResult<()> {
+ pub(crate) async fn add_history(&self, history: &[NewHistory]) -> DbResult<()> {
let mut tx = self.pool.begin().await?;
for i in history {
@@ -311,7 +391,7 @@ impl Database for Postgres {
}
#[instrument(skip_all)]
- async fn delete_user(&self, u: &User) -> DbResult<()> {
+ pub(crate) async fn delete_user(&self, u: &User) -> DbResult<()> {
sqlx::query("delete from sessions where user_id = $1")
.bind(u.id)
.execute(&self.pool)
@@ -341,7 +421,7 @@ impl Database for Postgres {
}
#[instrument(skip_all)]
- async fn update_user_password(&self, user: &User) -> DbResult<()> {
+ pub(crate) async fn update_user_password(&self, user: &User) -> DbResult<()> {
sqlx::query(
"update users
set password = $1
@@ -356,7 +436,7 @@ impl Database for Postgres {
}
#[instrument(skip_all)]
- async fn add_user(&self, user: &NewUser) -> DbResult<i64> {
+ pub(crate) async fn add_user(&self, user: &NewUser) -> DbResult<i64> {
let email: &str = &user.email;
let username: &str = &user.username;
let password: &str = &user.password;
@@ -377,7 +457,7 @@ impl Database for Postgres {
}
#[instrument(skip_all)]
- async fn add_session(&self, session: &NewSession) -> DbResult<()> {
+ pub(crate) async fn add_session(&self, session: &NewSession) -> DbResult<()> {
let token: &str = &session.token;
sqlx::query(
@@ -394,7 +474,7 @@ impl Database for Postgres {
}
#[instrument(skip_all)]
- async fn get_user_session(&self, u: &User) -> DbResult<Session> {
+ pub(crate) async fn get_user_session(&self, u: &User) -> DbResult<Session> {
sqlx::query_as("select id, user_id, token from sessions where user_id = $1")
.bind(u.id)
.fetch_one(self.read_pool())
@@ -404,7 +484,7 @@ impl Database for Postgres {
}
#[instrument(skip_all)]
- async fn oldest_history(&self, user: &User) -> DbResult<History> {
+ pub(crate) async fn oldest_history(&self, user: &User) -> DbResult<History> {
sqlx::query_as(
"select id, client_id, user_id, hostname, timestamp, data, created_at from history
where user_id = $1
@@ -419,7 +499,11 @@ impl Database for Postgres {
}
#[instrument(skip_all)]
- async fn add_records(&self, user: &User, records: &[Record<EncryptedData>]) -> DbResult<()> {
+ pub(crate) async fn add_records(
+ &self,
+ user: &User,
+ records: &[Record<EncryptedData>],
+ ) -> DbResult<()> {
let mut tx = self.pool.begin().await?;
// We won't have uploaded this data if it wasn't the max. Therefore, we can deduce the max
@@ -491,7 +575,7 @@ impl Database for Postgres {
}
#[instrument(skip_all)]
- async fn next_records(
+ pub(crate) async fn next_records(
&self,
user: &User,
host: HostId,
@@ -542,7 +626,7 @@ impl Database for Postgres {
Ok(ret)
}
- async fn status(&self, user: &User) -> DbResult<RecordStatus> {
+ pub(crate) async fn status(&self, user: &User) -> DbResult<RecordStatus> {
const STATUS_SQL: &str =
"select host, tag, max(idx) from store where user_id = $1 group by host, tag";
diff --git a/crates/turtle/src/atuin_server_postgres/wrappers.rs b/crates/turtle/src/atuin_server/database/db/wrappers.rs
index ba7a9435..de4c5814 100644
--- a/crates/turtle/src/atuin_server_postgres/wrappers.rs
+++ b/crates/turtle/src/atuin_server/database/db/wrappers.rs
@@ -1,13 +1,15 @@
+use crate::{
+ atuin_common::record::{EncryptedData, Host, Record},
+ atuin_server::database::models::{History, Session, User},
+};
use ::sqlx::{FromRow, Result};
-use crate::atuin_common::record::{EncryptedData, Host, Record};
-use crate::atuin_server_database::models::{History, Session, User};
use sqlx::{Row, postgres::PgRow};
use time::PrimitiveDateTime;
-pub(crate) struct DbUser(pub(crate) User);
-pub(crate) struct DbSession(pub(crate) Session);
-pub(crate) struct DbHistory(pub(crate) History);
-pub(crate) struct DbRecord(pub(crate) Record<EncryptedData>);
+pub struct DbUser(pub User);
+pub struct DbSession(pub Session);
+pub struct DbHistory(pub History);
+pub struct DbRecord(pub Record<EncryptedData>);
impl<'a> FromRow<'a, PgRow> for DbUser {
fn from_row(row: &'a PgRow) -> Result<Self> {
diff --git a/crates/turtle/src/atuin_server/database/mod.rs b/crates/turtle/src/atuin_server/database/mod.rs
new file mode 100644
index 00000000..845d67d7
--- /dev/null
+++ b/crates/turtle/src/atuin_server/database/mod.rs
@@ -0,0 +1,123 @@
+pub(crate) mod calendar;
+pub(crate) mod db;
+pub(crate) mod models;
+
+use std::fmt::{Debug, Display};
+
+use serde::{Deserialize, Serialize};
+use time::{OffsetDateTime, PrimitiveDateTime, UtcOffset};
+
+#[derive(Debug)]
+pub(crate) enum DbError {
+ NotFound,
+ Other(eyre::Report),
+}
+
+impl Display for DbError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{self:?}")
+ }
+}
+
+impl From<time::error::ComponentRange> for DbError {
+ fn from(error: time::error::ComponentRange) -> Self {
+ DbError::Other(error.into())
+ }
+}
+
+impl From<time::error::Error> for DbError {
+ fn from(error: time::error::Error) -> Self {
+ DbError::Other(error.into())
+ }
+}
+
+impl From<sqlx::Error> for DbError {
+ fn from(error: sqlx::Error) -> Self {
+ match error {
+ sqlx::Error::RowNotFound => DbError::NotFound,
+ error => DbError::Other(error.into()),
+ }
+ }
+}
+
+impl std::error::Error for DbError {}
+
+pub(crate) type DbResult<T> = Result<T, DbError>;
+
+#[derive(Debug, PartialEq)]
+pub(crate) enum DbType {
+ Postgres,
+ Unknown,
+}
+
+#[derive(Clone, Deserialize, Serialize)]
+pub(crate) struct DbSettings {
+ pub(crate) db_uri: String,
+ /// Optional URI for read replicas. If set, read-only queries will use this connection.
+ pub(crate) read_db_uri: Option<String>,
+}
+
+impl DbSettings {
+ pub(crate) fn db_type(&self) -> DbType {
+ if self.db_uri.starts_with("postgres://") || self.db_uri.starts_with("postgresql://") {
+ DbType::Postgres
+ } else {
+ DbType::Unknown
+ }
+ }
+}
+
+fn redact_db_uri(uri: &str) -> String {
+ url::Url::parse(uri)
+ .map(|mut url| {
+ let _ = url.set_password(Some("****"));
+ url.to_string()
+ })
+ .unwrap_or_else(|_| uri.to_string())
+}
+
+// Do our best to redact passwords so they're not logged in the event of an error.
+impl Debug for DbSettings {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ if self.db_type() == DbType::Postgres {
+ let redacted_uri = redact_db_uri(&self.db_uri);
+ let redacted_read_uri = self.read_db_uri.as_ref().map(|uri| redact_db_uri(uri));
+ f.debug_struct("DbSettings")
+ .field("db_uri", &redacted_uri)
+ .field("read_db_uri", &redacted_read_uri)
+ .finish()
+ } else {
+ f.debug_struct("DbSettings")
+ .field("db_uri", &self.db_uri)
+ .field("read_db_uri", &self.read_db_uri)
+ .finish()
+ }
+ }
+}
+
+pub(crate) fn into_utc(x: OffsetDateTime) -> PrimitiveDateTime {
+ let x = x.to_offset(UtcOffset::UTC);
+ PrimitiveDateTime::new(x.date(), x.time())
+}
+
+#[cfg(test)]
+mod tests {
+ use time::macros::datetime;
+
+ use super::into_utc;
+
+ #[test]
+ fn utc() {
+ let dt = datetime!(2023-09-26 15:11:02 +05:30);
+ assert_eq!(into_utc(dt), datetime!(2023-09-26 09:41:02));
+ assert_eq!(into_utc(dt).assume_utc(), dt);
+
+ let dt = datetime!(2023-09-26 15:11:02 -07:00);
+ assert_eq!(into_utc(dt), datetime!(2023-09-26 22:11:02));
+ assert_eq!(into_utc(dt).assume_utc(), dt);
+
+ let dt = datetime!(2023-09-26 15:11:02 +00:00);
+ assert_eq!(into_utc(dt), datetime!(2023-09-26 15:11:02));
+ assert_eq!(into_utc(dt).assume_utc(), dt);
+ }
+}
diff --git a/crates/turtle/src/atuin_server_database/models.rs b/crates/turtle/src/atuin_server/database/models.rs
index e47d614d..e47d614d 100644
--- a/crates/turtle/src/atuin_server_database/models.rs
+++ b/crates/turtle/src/atuin_server/database/models.rs
diff --git a/crates/turtle/src/atuin_server/handlers/history.rs b/crates/turtle/src/atuin_server/handlers/history.rs
deleted file mode 100644
index e5057bcb..00000000
--- a/crates/turtle/src/atuin_server/handlers/history.rs
+++ /dev/null
@@ -1,237 +0,0 @@
-use std::{collections::HashMap, convert::TryFrom};
-
-use axum::{
- Json,
- extract::{Path, Query, State},
- http::{HeaderMap, StatusCode},
-};
-use metrics::counter;
-use time::{Month, UtcOffset};
-use tracing::{debug, error, instrument};
-
-use super::{ErrorResponse, ErrorResponseStatus, RespExt};
-use crate::atuin_server::{
- router::{AppState, UserAuth},
- utils::client_version_min,
-};
-use crate::atuin_server_database::{
- Database,
- calendar::{TimePeriod, TimePeriodInfo},
- models::NewHistory,
-};
-
-use crate::atuin_common::api::*;
-
-#[instrument(skip_all, fields(user.id = user.id))]
-pub(crate) async fn count<DB: Database>(
- UserAuth(user): UserAuth,
- state: State<AppState<DB>>,
-) -> Result<Json<CountResponse>, ErrorResponseStatus<'static>> {
- let db = &state.0.database;
- match db.count_history_cached(&user).await {
- // By default read out the cached value
- Ok(count) => Ok(Json(CountResponse { count })),
-
- // If that fails, fallback on a full COUNT. Cache is built on a POST
- // only
- Err(_) => match db.count_history(&user).await {
- Ok(count) => Ok(Json(CountResponse { count })),
- Err(_) => Err(ErrorResponse::reply("failed to query history count")
- .with_status(StatusCode::INTERNAL_SERVER_ERROR)),
- },
- }
-}
-
-#[instrument(skip_all, fields(user.id = user.id))]
-pub(crate) async fn list<DB: Database>(
- req: Query<SyncHistoryRequest>,
- UserAuth(user): UserAuth,
- headers: HeaderMap,
- state: State<AppState<DB>>,
-) -> Result<Json<SyncHistoryResponse>, ErrorResponseStatus<'static>> {
- let db = &state.0.database;
-
- let agent = headers
- .get("user-agent")
- .map_or("", |v| v.to_str().unwrap_or(""));
-
- let variable_page_size = client_version_min(agent, ">=15.0.0").unwrap_or(false);
-
- let page_size = if variable_page_size {
- state.settings.page_size
- } else {
- 100
- };
-
- if req.sync_ts.unix_timestamp_nanos() < 0 || req.history_ts.unix_timestamp_nanos() < 0 {
- error!("client asked for history from < epoch 0");
- counter!("atuin_history_epoch_before_zero").increment(1);
-
- return Err(
- ErrorResponse::reply("asked for history from before epoch 0")
- .with_status(StatusCode::BAD_REQUEST),
- );
- }
-
- let history = db
- .list_history(&user, req.sync_ts, req.history_ts, &req.host, page_size)
- .await;
-
- if let Err(e) = history {
- error!("failed to load history: {}", e);
- return Err(ErrorResponse::reply("failed to load history")
- .with_status(StatusCode::INTERNAL_SERVER_ERROR));
- }
-
- let history: Vec<String> = history
- .unwrap()
- .iter()
- .map(|i| i.data.to_string())
- .collect();
-
- debug!(
- "loaded {} items of history for user {}",
- history.len(),
- user.id
- );
-
- counter!("atuin_history_returned").increment(history.len() as u64);
-
- Ok(Json(SyncHistoryResponse { history }))
-}
-
-#[instrument(skip_all, fields(user.id = user.id))]
-pub(crate) async fn delete<DB: Database>(
- UserAuth(user): UserAuth,
- state: State<AppState<DB>>,
- Json(req): Json<DeleteHistoryRequest>,
-) -> Result<Json<MessageResponse>, ErrorResponseStatus<'static>> {
- let db = &state.0.database;
-
- // user_id is the ID of the history, as set by the user (the server has its own ID)
- let deleted = db.delete_history(&user, req.client_id).await;
-
- if let Err(e) = deleted {
- error!("failed to delete history: {}", e);
- return Err(ErrorResponse::reply("failed to delete history")
- .with_status(StatusCode::INTERNAL_SERVER_ERROR));
- }
-
- Ok(Json(MessageResponse {
- message: String::from("deleted OK"),
- }))
-}
-
-#[instrument(skip_all, fields(user.id = user.id))]
-pub(crate) async fn add<DB: Database>(
- UserAuth(user): UserAuth,
- state: State<AppState<DB>>,
- Json(req): Json<Vec<AddHistoryRequest>>,
-) -> Result<(), ErrorResponseStatus<'static>> {
- let State(AppState { database, settings }) = state;
-
- debug!("request to add {} history items", req.len());
- counter!("atuin_history_uploaded").increment(req.len() as u64);
-
- let mut history: Vec<NewHistory> = req
- .into_iter()
- .map(|h| NewHistory {
- client_id: h.id,
- user_id: user.id,
- hostname: h.hostname,
- timestamp: h.timestamp,
- data: h.data,
- })
- .collect();
-
- history.retain(|h| {
- // keep if within limit, or limit is 0 (unlimited)
- let keep = h.data.len() <= settings.max_history_length || settings.max_history_length == 0;
-
- // Don't return an error here. We want to insert as much of the
- // history list as we can, so log the error and continue going.
- if !keep {
- counter!("atuin_history_too_long").increment(1);
-
- tracing::warn!(
- "history too long, got length {}, max {}",
- h.data.len(),
- settings.max_history_length
- );
- }
-
- keep
- });
-
- if let Err(e) = database.add_history(&history).await {
- error!("failed to add history: {}", e);
-
- return Err(ErrorResponse::reply("failed to add history")
- .with_status(StatusCode::INTERNAL_SERVER_ERROR));
- };
-
- Ok(())
-}
-
-#[derive(serde::Deserialize, Debug)]
-pub(crate) struct CalendarQuery {
- #[serde(default = "serde_calendar::zero")]
- year: i32,
- #[serde(default = "serde_calendar::one")]
- month: u8,
-
- #[serde(default = "serde_calendar::utc")]
- tz: UtcOffset,
-}
-
-mod serde_calendar {
- use time::UtcOffset;
-
- pub(crate) fn zero() -> i32 {
- 0
- }
-
- pub(crate) fn one() -> u8 {
- 1
- }
-
- pub(crate) fn utc() -> UtcOffset {
- UtcOffset::UTC
- }
-}
-
-#[instrument(skip_all, fields(user.id = user.id))]
-pub(crate) async fn calendar<DB: Database>(
- Path(focus): Path<String>,
- Query(params): Query<CalendarQuery>,
- UserAuth(user): UserAuth,
- state: State<AppState<DB>>,
-) -> Result<Json<HashMap<u64, TimePeriodInfo>>, ErrorResponseStatus<'static>> {
- let focus = focus.as_str();
-
- let year = params.year;
- let month = Month::try_from(params.month).map_err(|e| ErrorResponseStatus {
- error: ErrorResponse {
- reason: e.to_string().into(),
- },
- status: StatusCode::BAD_REQUEST,
- })?;
-
- let period = match focus {
- "year" => TimePeriod::Year,
- "month" => TimePeriod::Month { year },
- "day" => TimePeriod::Day { year, month },
- _ => {
- return Err(ErrorResponse::reply("invalid focus: use year/month/day")
- .with_status(StatusCode::BAD_REQUEST));
- }
- };
-
- let db = &state.0.database;
- let focus = db.calendar(&user, period, params.tz).await.map_err(|_| {
- ErrorResponse::reply("failed to query calendar")
- .with_status(StatusCode::INTERNAL_SERVER_ERROR)
- })?;
-
- Ok(Json(focus))
-}
diff --git a/crates/turtle/src/atuin_server/handlers/mod.rs b/crates/turtle/src/atuin_server/handlers/mod.rs
index 322324c4..3b935834 100644
--- a/crates/turtle/src/atuin_server/handlers/mod.rs
+++ b/crates/turtle/src/atuin_server/handlers/mod.rs
@@ -1,19 +1,16 @@
use crate::atuin_common::api::{ErrorResponse, IndexResponse};
-use crate::atuin_server_database::Database;
use axum::{Json, extract::State, http, response::IntoResponse};
use crate::atuin_server::router::AppState;
pub(crate) mod health;
-pub(crate) mod history;
pub(crate) mod record;
-pub(crate) mod status;
pub(crate) mod user;
pub(crate) mod v0;
const VERSION: &str = env!("CARGO_PKG_VERSION");
-pub(crate) async fn index<DB: Database>(state: State<AppState<DB>>) -> Json<IndexResponse> {
+pub(crate) async fn index(state: State<AppState>) -> Json<IndexResponse> {
let homage = r#""Through the fathomless deeps of space swims the star turtle Great A'Tuin, bearing on its back the four giant elephants who carry on their shoulders the mass of the Discworld." -- Sir Terry Pratchett"#;
let version = state
diff --git a/crates/turtle/src/atuin_server/handlers/status.rs b/crates/turtle/src/atuin_server/handlers/status.rs
deleted file mode 100644
index 59be1e5c..00000000
--- a/crates/turtle/src/atuin_server/handlers/status.rs
+++ /dev/null
@@ -1,45 +0,0 @@
-use axum::{Json, extract::State, http::StatusCode};
-use tracing::instrument;
-
-use super::{ErrorResponse, ErrorResponseStatus, RespExt};
-use crate::atuin_server::router::{AppState, UserAuth};
-use crate::atuin_server_database::Database;
-
-use crate::atuin_common::api::*;
-
-const VERSION: &str = env!("CARGO_PKG_VERSION");
-
-#[instrument(skip_all, fields(user.id = user.id))]
-pub(crate) async fn status<DB: Database>(
- UserAuth(user): UserAuth,
- state: State<AppState<DB>>,
-) -> Result<Json<StatusResponse>, ErrorResponseStatus<'static>> {
- let db = &state.0.database;
-
- let deleted = db.deleted_history(&user).await.unwrap_or(vec![]);
-
- let count = match db.count_history_cached(&user).await {
- // By default read out the cached value
- Ok(count) => count,
-
- // If that fails, fallback on a full COUNT. Cache is built on a POST
- // only
- Err(_) => match db.count_history(&user).await {
- Ok(count) => count,
- Err(_) => {
- return Err(ErrorResponse::reply("failed to query history count")
- .with_status(StatusCode::INTERNAL_SERVER_ERROR));
- }
- },
- };
-
- tracing::debug!(user = user.username, "requested sync status");
-
- Ok(Json(StatusResponse {
- count,
- deleted,
- username: user.username,
- version: VERSION.to_string(),
- page_size: state.settings.page_size,
- }))
-}
diff --git a/crates/turtle/src/atuin_server/handlers/user.rs b/crates/turtle/src/atuin_server/handlers/user.rs
index 7708d43e..28cebfab 100644
--- a/crates/turtle/src/atuin_server/handlers/user.rs
+++ b/crates/turtle/src/atuin_server/handlers/user.rs
@@ -16,14 +16,16 @@ use metrics::counter;
use rand::rngs::OsRng;
use tracing::{debug, error, info, instrument};
-use crate::atuin_common::tls::ensure_crypto_provider;
+use crate::{
+ atuin_common::tls::ensure_crypto_provider,
+ atuin_server::database::{
+ DbError,
+ models::{NewSession, NewUser},
+ },
+};
use super::{ErrorResponse, ErrorResponseStatus, RespExt};
use crate::atuin_server::router::{AppState, UserAuth};
-use crate::atuin_server_database::{
- Database, DbError,
- models::{NewSession, NewUser},
-};
use reqwest::header::CONTENT_TYPE;
@@ -63,9 +65,9 @@ async fn send_register_hook(url: &str, username: String, registered: String) {
}
#[instrument(skip_all, fields(user.username = username.as_str()))]
-pub(crate) async fn get<DB: Database>(
+pub(crate) async fn get(
Path(username): Path<String>,
- state: State<AppState<DB>>,
+ state: State<AppState>,
) -> Result<Json<UserResponse>, ErrorResponseStatus<'static>> {
let db = &state.0.database;
let user = match db.get_user(username.as_ref()).await {
@@ -87,8 +89,8 @@ pub(crate) async fn get<DB: Database>(
}
#[instrument(skip_all)]
-pub(crate) async fn register<DB: Database>(
- state: State<AppState<DB>>,
+pub(crate) async fn register(
+ state: State<AppState>,
Json(register): Json<RegisterRequest>,
) -> Result<Json<RegisterResponse>, ErrorResponseStatus<'static>> {
if !state.settings.open_registration {
@@ -163,9 +165,9 @@ pub(crate) async fn register<DB: Database>(
}
#[instrument(skip_all, fields(user.id = user.id))]
-pub(crate) async fn delete<DB: Database>(
+pub(crate) async fn delete(
UserAuth(user): UserAuth,
- state: State<AppState<DB>>,
+ state: State<AppState>,
) -> Result<Json<DeleteUserResponse>, ErrorResponseStatus<'static>> {
debug!("request to delete user {}", user.id);
@@ -183,9 +185,9 @@ pub(crate) async fn delete<DB: Database>(
}
#[instrument(skip_all, fields(user.id = user.id, change_password))]
-pub(crate) async fn change_password<DB: Database>(
+pub(crate) async fn change_password(
UserAuth(mut user): UserAuth,
- state: State<AppState<DB>>,
+ state: State<AppState>,
Json(change_password): Json<ChangePasswordRequest>,
) -> Result<Json<ChangePasswordResponse>, ErrorResponseStatus<'static>> {
let db = &state.0.database;
@@ -213,8 +215,8 @@ pub(crate) async fn change_password<DB: Database>(
}
#[instrument(skip_all, fields(user.username = login.username.as_str()))]
-pub(crate) async fn login<DB: Database>(
- state: State<AppState<DB>>,
+pub(crate) async fn login(
+ state: State<AppState>,
login: Json<LoginRequest>,
) -> Result<Json<LoginResponse>, ErrorResponseStatus<'static>> {
let db = &state.0.database;
diff --git a/crates/turtle/src/atuin_server/handlers/v0/record.rs b/crates/turtle/src/atuin_server/handlers/v0/record.rs
index 2cc09118..88027547 100644
--- a/crates/turtle/src/atuin_server/handlers/v0/record.rs
+++ b/crates/turtle/src/atuin_server/handlers/v0/record.rs
@@ -7,14 +7,13 @@ use crate::atuin_server::{
handlers::{ErrorResponse, ErrorResponseStatus, RespExt},
router::{AppState, UserAuth},
};
-use crate::atuin_server_database::Database;
use crate::atuin_common::record::{EncryptedData, HostId, Record, RecordIdx, RecordStatus};
#[instrument(skip_all, fields(user.id = user.id))]
-pub(crate) async fn post<DB: Database>(
+pub(crate) async fn post(
UserAuth(user): UserAuth,
- state: State<AppState<DB>>,
+ state: State<AppState>,
Json(records): Json<Vec<Record<EncryptedData>>>,
) -> Result<(), ErrorResponseStatus<'static>> {
let State(AppState { database, settings }) = state;
@@ -51,9 +50,9 @@ pub(crate) async fn post<DB: Database>(
}
#[instrument(skip_all, fields(user.id = user.id))]
-pub(crate) async fn index<DB: Database>(
+pub(crate) async fn index(
UserAuth(user): UserAuth,
- state: State<AppState<DB>>,
+ state: State<AppState>,
) -> Result<Json<RecordStatus>, ErrorResponseStatus<'static>> {
let State(AppState {
database,
@@ -84,10 +83,10 @@ pub(crate) struct NextParams {
}
#[instrument(skip_all, fields(user.id = user.id))]
-pub(crate) async fn next<DB: Database>(
+pub(crate) async fn next(
params: Query<NextParams>,
UserAuth(user): UserAuth,
- state: State<AppState<DB>>,
+ state: State<AppState>,
) -> Result<Json<Vec<Record<EncryptedData>>>, ErrorResponseStatus<'static>> {
let State(AppState {
database,
diff --git a/crates/turtle/src/atuin_server/handlers/v0/store.rs b/crates/turtle/src/atuin_server/handlers/v0/store.rs
index 8269d6b3..f0aa1b36 100644
--- a/crates/turtle/src/atuin_server/handlers/v0/store.rs
+++ b/crates/turtle/src/atuin_server/handlers/v0/store.rs
@@ -7,16 +7,15 @@ use crate::atuin_server::{
handlers::{ErrorResponse, ErrorResponseStatus, RespExt},
router::{AppState, UserAuth},
};
-use crate::atuin_server_database::Database;
#[derive(Deserialize)]
pub(crate) struct DeleteParams {}
#[instrument(skip_all, fields(user.id = user.id))]
-pub(crate) async fn delete<DB: Database>(
+pub(crate) async fn delete(
_params: Query<DeleteParams>,
UserAuth(user): UserAuth,
- state: State<AppState<DB>>,
+ state: State<AppState>,
) -> Result<(), ErrorResponseStatus<'static>> {
let State(AppState {
database,
diff --git a/crates/turtle/src/atuin_server/mod.rs b/crates/turtle/src/atuin_server/mod.rs
index ad480e1d..c96a13bc 100644
--- a/crates/turtle/src/atuin_server/mod.rs
+++ b/crates/turtle/src/atuin_server/mod.rs
@@ -1,14 +1,14 @@
use std::future::Future;
use std::net::SocketAddr;
-use crate::atuin_server_database::Database;
use axum::{Router, serve};
+use database::db::Database;
use eyre::{Context, Result};
+pub(crate) mod database;
mod handlers;
mod metrics;
mod router;
-mod utils;
pub(crate) use settings::Settings;
@@ -31,8 +31,8 @@ async fn shutdown_signal() {
eprintln!("Shutting down gracefully...");
}
-pub(crate) async fn launch<Db: Database>(settings: Settings, addr: SocketAddr) -> Result<()> {
- launch_with_tcp_listener::<Db>(
+pub(crate) async fn launch(settings: Settings, addr: SocketAddr) -> Result<()> {
+ launch_with_tcp_listener(
settings,
TcpListener::bind(addr)
.await
@@ -42,12 +42,12 @@ pub(crate) async fn launch<Db: Database>(settings: Settings, addr: SocketAddr) -
.await
}
-pub(crate) async fn launch_with_tcp_listener<Db: Database>(
+pub(crate) async fn launch_with_tcp_listener(
settings: Settings,
listener: TcpListener,
shutdown: impl Future<Output = ()> + Send + 'static,
) -> Result<()> {
- let r = make_router::<Db>(settings).await?;
+ let r = make_router(settings).await?;
serve(listener, r.into_make_service())
.with_graceful_shutdown(shutdown)
@@ -77,8 +77,8 @@ pub(crate) async fn launch_metrics_server(host: String, port: u16) -> Result<()>
Ok(())
}
-async fn make_router<Db: Database>(settings: Settings) -> Result<Router, eyre::Error> {
- let db = Db::new(&settings.db_settings)
+async fn make_router(settings: Settings) -> Result<Router, eyre::Error> {
+ let db = Database::new(&settings.db_settings)
.await
.wrap_err_with(|| format!("failed to connect to db: {:?}", settings.db_settings))?;
let r = router::router(db, settings);
diff --git a/crates/turtle/src/atuin_server/router.rs b/crates/turtle/src/atuin_server/router.rs
index ed3d1e55..778e699a 100644
--- a/crates/turtle/src/atuin_server/router.rs
+++ b/crates/turtle/src/atuin_server/router.rs
@@ -1,4 +1,7 @@
-use crate::atuin_common::api::{ATUIN_CARGO_VERSION, ATUIN_HEADER_VERSION, ErrorResponse};
+use crate::{
+ atuin_common::api::{ATUIN_CARGO_VERSION, ATUIN_HEADER_VERSION, ErrorResponse},
+ atuin_server::database::{DbError, db::Database, models::User},
+};
use axum::{
Router,
extract::{FromRequestParts, Request},
@@ -17,19 +20,15 @@ use crate::atuin_server::{
metrics,
settings::Settings,
};
-use crate::atuin_server_database::{Database, DbError, models::User};
pub(crate) struct UserAuth(pub(crate) User);
-impl<DB: Send + Sync> FromRequestParts<AppState<DB>> for UserAuth
-where
- DB: Database,
-{
+impl FromRequestParts<AppState> for UserAuth {
type Rejection = ErrorResponseStatus<'static>;
async fn from_request_parts(
req: &mut Parts,
- state: &AppState<DB>,
+ state: &AppState,
) -> Result<Self, Self::Rejection> {
let auth_header = req
.headers
@@ -78,18 +77,6 @@ async fn teapot() -> impl IntoResponse {
(http::StatusCode::NOT_FOUND, "404 not found")
}
-async fn clacks_overhead(request: Request, next: Next) -> Response {
- let mut response = next.run(request).await;
-
- let gnu_terry_value = "GNU Terry Pratchett, Kris Nova";
- let gnu_terry_header = "X-Clacks-Overhead";
-
- response
- .headers_mut()
- .insert(gnu_terry_header, gnu_terry_value.parse().unwrap());
- response
-}
-
/// Ensure that we only try and sync with clients on the same major version
async fn semver(request: Request, next: Next) -> Response {
let mut response = next.run(request).await;
@@ -101,27 +88,16 @@ async fn semver(request: Request, next: Next) -> Response {
}
#[derive(Clone)]
-pub(crate) struct AppState<DB: Database> {
- pub(crate) database: DB,
+pub(crate) struct AppState {
+ pub(crate) database: Database,
pub(crate) settings: Settings,
}
-pub(crate) fn router<DB: Database>(database: DB, settings: Settings) -> Router {
- let mut routes = Router::new()
+pub(crate) fn router(database: Database, settings: Settings) -> Router {
+ let routes = Router::new()
.route("/", get(handlers::index))
.route("/healthz", get(handlers::health::health_check));
- // Sync v1 routes - can be disabled in favor of record-based sync
- if settings.sync_v1_enabled {
- routes = routes
- .route("/sync/count", get(handlers::history::count))
- .route("/sync/history", get(handlers::history::list))
- .route("/sync/calendar/{focus}", get(handlers::history::calendar))
- .route("/sync/status", get(handlers::status::status))
- .route("/history", post(handlers::history::add))
- .route("/history", delete(handlers::history::delete));
- }
-
let routes = routes
.route("/user/{username}", get(handlers::user::get))
.route("/account", delete(handlers::user::delete))
@@ -147,7 +123,6 @@ pub(crate) fn router<DB: Database>(database: DB, settings: Settings) -> Router {
.with_state(AppState { database, settings })
.layer(
ServiceBuilder::new()
- .layer(axum::middleware::from_fn(clacks_overhead))
.layer(TraceLayer::new_for_http())
.layer(axum::middleware::from_fn(metrics::track_metrics))
.layer(axum::middleware::from_fn(semver)),
diff --git a/crates/turtle/src/atuin_server/settings.rs b/crates/turtle/src/atuin_server/settings.rs
index 1d0ac2d0..b62f24e1 100644
--- a/crates/turtle/src/atuin_server/settings.rs
+++ b/crates/turtle/src/atuin_server/settings.rs
@@ -1,11 +1,12 @@
use std::{io::prelude::*, path::PathBuf};
-use crate::atuin_server_database::DbSettings;
use config::{Config, Environment, File as ConfigFile, FileFormat};
use eyre::{Result, eyre};
use fs_err::{File, create_dir_all};
use serde::{Deserialize, Serialize};
+use crate::atuin_server::database::DbSettings;
+
#[derive(Clone, Debug, Deserialize, Serialize)]
pub(crate) struct Metrics {
#[serde(alias = "enabled")]
@@ -37,10 +38,6 @@ pub(crate) struct Settings {
pub(crate) register_webhook_username: String,
pub(crate) metrics: Metrics,
- /// Enable legacy sync v1 routes (history-based sync)
- /// Set to false to use only the newer record-based sync
- pub(crate) sync_v1_enabled: bool,
-
/// Advertise a version that is not what we are _actually_ running
/// Many clients compare their version with api.atuin.sh, and if they differ, notify the user
/// that an update is available.
@@ -78,7 +75,6 @@ impl Settings {
.set_default("metrics.enable", false)?
.set_default("metrics.host", "127.0.0.1")?
.set_default("metrics.port", 9001)?
- .set_default("sync_v1_enabled", true)?
.add_source(
Environment::with_prefix("atuin")
.prefix_separator("_")
diff --git a/crates/turtle/src/atuin_server/utils.rs b/crates/turtle/src/atuin_server/utils.rs
deleted file mode 100644
index cceef3ed..00000000
--- a/crates/turtle/src/atuin_server/utils.rs
+++ /dev/null
@@ -1,15 +0,0 @@
-use eyre::Result;
-use semver::{Version, VersionReq};
-
-pub(crate) fn client_version_min(user_agent: &str, req: &str) -> Result<bool> {
- if user_agent.is_empty() {
- return Ok(false);
- }
-
- let version = user_agent.replace("atuin/", "");
-
- let req = VersionReq::parse(req)?;
- let version = Version::parse(version.as_str())?;
-
- Ok(req.matches(&version))
-}
diff --git a/crates/turtle/src/atuin_server_database/mod.rs b/crates/turtle/src/atuin_server_database/mod.rs
deleted file mode 100644
index e4672bb0..00000000
--- a/crates/turtle/src/atuin_server_database/mod.rs
+++ /dev/null
@@ -1,266 +0,0 @@
-pub(crate) mod calendar;
-pub(crate) mod models;
-
-use std::{
- collections::HashMap,
- fmt::{Debug, Display},
- ops::Range,
-};
-
-use self::{
- calendar::{TimePeriod, TimePeriodInfo},
- models::{History, NewHistory, NewSession, NewUser, Session, User},
-};
-use async_trait::async_trait;
-use crate::atuin_common::record::{EncryptedData, HostId, Record, RecordIdx, RecordStatus};
-use serde::{Deserialize, Serialize};
-use time::{Date, Duration, Month, OffsetDateTime, PrimitiveDateTime, Time, UtcOffset};
-use tracing::instrument;
-
-#[derive(Debug)]
-pub(crate) enum DbError {
- NotFound,
- Other(eyre::Report),
-}
-
-impl Display for DbError {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- write!(f, "{self:?}")
- }
-}
-
-impl From<time::error::ComponentRange> for DbError {
- fn from(error: time::error::ComponentRange) -> Self {
- DbError::Other(error.into())
- }
-}
-
-impl From<time::error::Error> for DbError {
- fn from(error: time::error::Error) -> Self {
- DbError::Other(error.into())
- }
-}
-
-impl From<sqlx::Error> for DbError {
- fn from(error: sqlx::Error) -> Self {
- match error {
- sqlx::Error::RowNotFound => DbError::NotFound,
- error => DbError::Other(error.into()),
- }
- }
-}
-
-impl std::error::Error for DbError {}
-
-pub(crate) type DbResult<T> = Result<T, DbError>;
-
-#[derive(Debug, PartialEq)]
-pub(crate) enum DbType {
- Postgres,
- Sqlite,
- Unknown,
-}
-
-#[derive(Clone, Deserialize, Serialize)]
-pub(crate) struct DbSettings {
- pub(crate) db_uri: String,
- /// Optional URI for read replicas. If set, read-only queries will use this connection.
- pub(crate) read_db_uri: Option<String>,
-}
-
-impl DbSettings {
- pub(crate) fn db_type(&self) -> DbType {
- if self.db_uri.starts_with("postgres://") || self.db_uri.starts_with("postgresql://") {
- DbType::Postgres
- } else if self.db_uri.starts_with("sqlite://") {
- DbType::Sqlite
- } else {
- DbType::Unknown
- }
- }
-}
-
-fn redact_db_uri(uri: &str) -> String {
- url::Url::parse(uri)
- .map(|mut url| {
- let _ = url.set_password(Some("****"));
- url.to_string()
- })
- .unwrap_or_else(|_| uri.to_string())
-}
-
-// Do our best to redact passwords so they're not logged in the event of an error.
-impl Debug for DbSettings {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- if self.db_type() == DbType::Postgres {
- let redacted_uri = redact_db_uri(&self.db_uri);
- let redacted_read_uri = self.read_db_uri.as_ref().map(|uri| redact_db_uri(uri));
- f.debug_struct("DbSettings")
- .field("db_uri", &redacted_uri)
- .field("read_db_uri", &redacted_read_uri)
- .finish()
- } else {
- f.debug_struct("DbSettings")
- .field("db_uri", &self.db_uri)
- .field("read_db_uri", &self.read_db_uri)
- .finish()
- }
- }
-}
-
-#[async_trait]
-pub(crate) trait Database: Sized + Clone + Send + Sync + 'static {
- async fn new(settings: &DbSettings) -> DbResult<Self>;
-
- async fn get_session(&self, token: &str) -> DbResult<Session>;
- async fn get_session_user(&self, token: &str) -> DbResult<User>;
- async fn add_session(&self, session: &NewSession) -> DbResult<()>;
-
- async fn get_user(&self, username: &str) -> DbResult<User>;
- async fn get_user_session(&self, u: &User) -> DbResult<Session>;
- async fn add_user(&self, user: &NewUser) -> DbResult<i64>;
-
- async fn update_user_password(&self, u: &User) -> DbResult<()>;
-
- async fn count_history(&self, user: &User) -> DbResult<i64>;
- async fn count_history_cached(&self, user: &User) -> DbResult<i64>;
-
- async fn delete_user(&self, u: &User) -> DbResult<()>;
- async fn delete_history(&self, user: &User, id: String) -> DbResult<()>;
- async fn deleted_history(&self, user: &User) -> DbResult<Vec<String>>;
- async fn delete_store(&self, user: &User) -> DbResult<()>;
-
- async fn add_records(&self, user: &User, record: &[Record<EncryptedData>]) -> DbResult<()>;
- async fn next_records(
- &self,
- user: &User,
- host: HostId,
- tag: String,
- start: Option<RecordIdx>,
- count: u64,
- ) -> DbResult<Vec<Record<EncryptedData>>>;
-
- // Return the tail record ID for each store, so (HostID, Tag, TailRecordID)
- async fn status(&self, user: &User) -> DbResult<RecordStatus>;
-
- async fn count_history_range(&self, user: &User, range: Range<OffsetDateTime>)
- -> DbResult<i64>;
-
- async fn list_history(
- &self,
- user: &User,
- created_after: OffsetDateTime,
- since: OffsetDateTime,
- host: &str,
- page_size: i64,
- ) -> DbResult<Vec<History>>;
-
- async fn add_history(&self, history: &[NewHistory]) -> DbResult<()>;
-
- async fn oldest_history(&self, user: &User) -> DbResult<History>;
-
- #[instrument(skip_all)]
- async fn calendar(
- &self,
- user: &User,
- period: TimePeriod,
- tz: UtcOffset,
- ) -> DbResult<HashMap<u64, TimePeriodInfo>> {
- let mut ret = HashMap::new();
- let iter: Box<dyn Iterator<Item = DbResult<(u64, Range<Date>)>> + Send> = match period {
- TimePeriod::Year => {
- // First we need to work out how far back to calculate. Get the
- // oldest history item
- let oldest = self
- .oldest_history(user)
- .await?
- .timestamp
- .to_offset(tz)
- .year();
- let current_year = OffsetDateTime::now_utc().to_offset(tz).year();
-
- // All the years we need to get data for
- // The upper bound is exclusive, so include current +1
- let years = oldest..current_year + 1;
-
- Box::new(years.map(|year| {
- let start = Date::from_calendar_date(year, time::Month::January, 1)?;
- let end = Date::from_calendar_date(year + 1, time::Month::January, 1)?;
-
- Ok((year as u64, start..end))
- }))
- }
-
- TimePeriod::Month { year } => {
- let months =
- std::iter::successors(Some(Month::January), |m| Some(m.next())).take(12);
-
- Box::new(months.map(move |month| {
- let start = Date::from_calendar_date(year, month, 1)?;
- let days = start.month().length(year);
- let end = start + Duration::days(days as i64);
-
- Ok((month as u64, start..end))
- }))
- }
-
- TimePeriod::Day { year, month } => {
- let days = 1..month.length(year);
- Box::new(days.map(move |day| {
- let start = Date::from_calendar_date(year, month, day)?;
- let end = start
- .next_day()
- .ok_or_else(|| DbError::Other(eyre::eyre!("no next day?")))?;
-
- Ok((day as u64, start..end))
- }))
- }
- };
-
- for x in iter {
- let (index, range) = x?;
-
- let start = range.start.with_time(Time::MIDNIGHT).assume_offset(tz);
- let end = range.end.with_time(Time::MIDNIGHT).assume_offset(tz);
-
- let count = self.count_history_range(user, start..end).await?;
-
- ret.insert(
- index,
- TimePeriodInfo {
- count: count as u64,
- hash: "".to_string(),
- },
- );
- }
-
- Ok(ret)
- }
-}
-
-pub(crate) fn into_utc(x: OffsetDateTime) -> PrimitiveDateTime {
- let x = x.to_offset(UtcOffset::UTC);
- PrimitiveDateTime::new(x.date(), x.time())
-}
-
-#[cfg(test)]
-mod tests {
- use time::macros::datetime;
-
- use crate::into_utc;
-
- #[test]
- fn utc() {
- let dt = datetime!(2023-09-26 15:11:02 +05:30);
- assert_eq!(into_utc(dt), datetime!(2023-09-26 09:41:02));
- assert_eq!(into_utc(dt).assume_utc(), dt);
-
- let dt = datetime!(2023-09-26 15:11:02 -07:00);
- assert_eq!(into_utc(dt), datetime!(2023-09-26 22:11:02));
- assert_eq!(into_utc(dt).assume_utc(), dt);
-
- let dt = datetime!(2023-09-26 15:11:02 +00:00);
- assert_eq!(into_utc(dt), datetime!(2023-09-26 15:11:02));
- assert_eq!(into_utc(dt).assume_utc(), dt);
- }
-}
diff --git a/crates/turtle/src/atuin_server_sqlite/mod.rs b/crates/turtle/src/atuin_server_sqlite/mod.rs
deleted file mode 100644
index b1de511d..00000000
--- a/crates/turtle/src/atuin_server_sqlite/mod.rs
+++ /dev/null
@@ -1,430 +0,0 @@
-use std::str::FromStr;
-
-use crate::atuin_common::record::{EncryptedData, HostId, Record, RecordIdx, RecordStatus};
-use crate::atuin_server_database::{
- Database, DbError, DbResult, DbSettings, into_utc,
- models::{History, NewHistory, NewSession, NewUser, Session, User},
-};
-use async_trait::async_trait;
-use futures_util::TryStreamExt;
-use sqlx::{
- Row,
- sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions},
- types::Uuid,
-};
-use tracing::instrument;
-use wrappers::{DbHistory, DbRecord, DbSession, DbUser};
-
-mod wrappers;
-
-#[derive(Clone)]
-pub(crate) struct Sqlite {
- pool: sqlx::Pool<sqlx::sqlite::Sqlite>,
-}
-
-#[async_trait]
-impl Database for Sqlite {
- async fn new(settings: &DbSettings) -> DbResult<Self> {
- let opts = SqliteConnectOptions::from_str(&settings.db_uri)?
- .journal_mode(SqliteJournalMode::Wal)
- .create_if_missing(true);
-
- let pool = SqlitePoolOptions::new().connect_with(opts).await?;
-
- sqlx::migrate!("./db/server-sqlite-migrations")
- .run(&pool)
- .await
- .map_err(|error| DbError::Other(error.into()))?;
-
- Ok(Self { pool })
- }
-
- #[instrument(skip_all)]
- async fn get_session(&self, token: &str) -> DbResult<Session> {
- sqlx::query_as("select id, user_id, token from sessions where token = $1")
- .bind(token)
- .fetch_one(&self.pool)
- .await
- .map_err(Into::into)
- .map(|DbSession(session)| session)
- }
-
- #[instrument(skip_all)]
- async fn get_session_user(&self, token: &str) -> DbResult<User> {
- sqlx::query_as(
- "select users.id, users.username, users.email, users.password from users
- inner join sessions
- on users.id = sessions.user_id
- and sessions.token = $1",
- )
- .bind(token)
- .fetch_one(&self.pool)
- .await
- .map_err(Into::into)
- .map(|DbUser(user)| user)
- }
-
- #[instrument(skip_all)]
- async fn add_session(&self, session: &NewSession) -> DbResult<()> {
- let token: &str = &session.token;
-
- sqlx::query(
- "insert into sessions
- (user_id, token)
- values($1, $2)",
- )
- .bind(session.user_id)
- .bind(token)
- .execute(&self.pool)
- .await?;
-
- Ok(())
- }
-
- #[instrument(skip_all)]
- async fn get_user(&self, username: &str) -> DbResult<User> {
- sqlx::query_as("select id, username, email, password from users where username = $1")
- .bind(username)
- .fetch_one(&self.pool)
- .await
- .map_err(Into::into)
- .map(|DbUser(user)| user)
- }
-
- #[instrument(skip_all)]
- async fn get_user_session(&self, u: &User) -> DbResult<Session> {
- sqlx::query_as("select id, user_id, token from sessions where user_id = $1")
- .bind(u.id)
- .fetch_one(&self.pool)
- .await
- .map_err(Into::into)
- .map(|DbSession(session)| session)
- }
-
- #[instrument(skip_all)]
- async fn add_user(&self, user: &NewUser) -> DbResult<i64> {
- let email: &str = &user.email;
- let username: &str = &user.username;
- let password: &str = &user.password;
-
- let res: (i64,) = sqlx::query_as(
- "insert into users
- (username, email, password)
- values($1, $2, $3)
- returning id",
- )
- .bind(username)
- .bind(email)
- .bind(password)
- .fetch_one(&self.pool)
- .await?;
-
- Ok(res.0)
- }
-
- #[instrument(skip_all)]
- async fn update_user_password(&self, user: &User) -> DbResult<()> {
- sqlx::query(
- "update users
- set password = $1
- where id = $2",
- )
- .bind(&user.password)
- .bind(user.id)
- .execute(&self.pool)
- .await?;
-
- Ok(())
- }
-
- #[instrument(skip_all)]
- async fn count_history(&self, user: &User) -> DbResult<i64> {
- // The cache is new, and the user might not yet have a cache value.
- // They will have one as soon as they post up some new history, but handle that
- // edge case.
-
- let res: (i64,) = sqlx::query_as(
- "select count(1) from history
- where user_id = $1",
- )
- .bind(user.id)
- .fetch_one(&self.pool)
- .await?;
-
- Ok(res.0)
- }
-
- #[instrument(skip_all)]
- async fn count_history_cached(&self, _user: &User) -> DbResult<i64> {
- Err(DbError::NotFound)
- }
-
- #[instrument(skip_all)]
- async fn delete_user(&self, u: &User) -> DbResult<()> {
- sqlx::query("delete from sessions where user_id = $1")
- .bind(u.id)
- .execute(&self.pool)
- .await?;
-
- sqlx::query("delete from users where id = $1")
- .bind(u.id)
- .execute(&self.pool)
- .await?;
-
- sqlx::query("delete from history where user_id = $1")
- .bind(u.id)
- .execute(&self.pool)
- .await?;
-
- Ok(())
- }
-
- async fn delete_history(&self, user: &User, id: String) -> DbResult<()> {
- sqlx::query(
- "update history
- set deleted_at = $3
- where user_id = $1
- and client_id = $2
- and deleted_at is null", // don't just keep setting it
- )
- .bind(user.id)
- .bind(id)
- .bind(time::OffsetDateTime::now_utc())
- .fetch_all(&self.pool)
- .await?;
-
- Ok(())
- }
-
- #[instrument(skip_all)]
- async fn deleted_history(&self, user: &User) -> DbResult<Vec<String>> {
- // The cache is new, and the user might not yet have a cache value.
- // They will have one as soon as they post up some new history, but handle that
- // edge case.
-
- let res = sqlx::query(
- "select client_id from history
- where user_id = $1
- and deleted_at is not null",
- )
- .bind(user.id)
- .fetch_all(&self.pool)
- .await?;
-
- let res = res.iter().map(|row| row.get("client_id")).collect();
-
- Ok(res)
- }
-
- async fn delete_store(&self, user: &User) -> DbResult<()> {
- sqlx::query(
- "delete from store
- where user_id = $1",
- )
- .bind(user.id)
- .execute(&self.pool)
- .await?;
-
- Ok(())
- }
-
- #[instrument(skip_all)]
- async fn add_records(&self, user: &User, records: &[Record<EncryptedData>]) -> DbResult<()> {
- let mut tx = self.pool.begin().await?;
-
- for i in records {
- let id = crate::atuin_common::utils::uuid_v7();
-
- sqlx::query(
- "insert into store
- (id, client_id, host, idx, timestamp, version, tag, data, cek, user_id)
- values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
- on conflict do nothing
- ",
- )
- .bind(id)
- .bind(i.id)
- .bind(i.host.id)
- .bind(i.idx as i64)
- .bind(i.timestamp as i64) // throwing away some data, but i64 is still big in terms of time
- .bind(&i.version)
- .bind(&i.tag)
- .bind(&i.data.data)
- .bind(&i.data.content_encryption_key)
- .bind(user.id)
- .execute(&mut *tx)
- .await?;
- }
-
- tx.commit().await?;
-
- Ok(())
- }
-
- #[instrument(skip_all)]
- async fn next_records(
- &self,
- user: &User,
- host: HostId,
- tag: String,
- start: Option<RecordIdx>,
- count: u64,
- ) -> DbResult<Vec<Record<EncryptedData>>> {
- tracing::debug!("{:?} - {:?} - {:?}", host, tag, start);
- let start = start.unwrap_or(0);
-
- let records: Result<Vec<DbRecord>, DbError> = sqlx::query_as(
- "select client_id, host, idx, timestamp, version, tag, data, cek from store
- where user_id = $1
- and tag = $2
- and host = $3
- and idx >= $4
- order by idx asc
- limit $5",
- )
- .bind(user.id)
- .bind(tag.clone())
- .bind(host)
- .bind(start as i64)
- .bind(count as i64)
- .fetch_all(&self.pool)
- .await
- .map_err(Into::into);
-
- let ret = match records {
- Ok(records) => {
- let records: Vec<Record<EncryptedData>> = records
- .into_iter()
- .map(|f| {
- let record: Record<EncryptedData> = f.into();
- record
- })
- .collect();
-
- records
- }
- Err(DbError::NotFound) => {
- tracing::debug!("no records found in store: {:?}/{}", host, tag);
- return Ok(vec![]);
- }
- Err(e) => return Err(e),
- };
-
- Ok(ret)
- }
-
- async fn status(&self, user: &User) -> DbResult<RecordStatus> {
- const STATUS_SQL: &str =
- "select host, tag, max(idx) from store where user_id = $1 group by host, tag";
-
- let res: Vec<(Uuid, String, i64)> = sqlx::query_as(STATUS_SQL)
- .bind(user.id)
- .fetch_all(&self.pool)
- .await?;
-
- let mut status = RecordStatus::new();
-
- for i in res {
- status.set_raw(HostId(i.0), i.1, i.2 as u64);
- }
-
- Ok(status)
- }
-
- #[instrument(skip_all)]
- async fn count_history_range(
- &self,
- user: &User,
- range: std::ops::Range<time::OffsetDateTime>,
- ) -> DbResult<i64> {
- let res: (i64,) = sqlx::query_as(
- "select count(1) from history
- where user_id = $1
- and timestamp >= $2::date
- and timestamp < $3::date",
- )
- .bind(user.id)
- .bind(into_utc(range.start))
- .bind(into_utc(range.end))
- .fetch_one(&self.pool)
- .await?;
-
- Ok(res.0)
- }
-
- #[instrument(skip_all)]
- async fn list_history(
- &self,
- user: &User,
- created_after: time::OffsetDateTime,
- since: time::OffsetDateTime,
- host: &str,
- page_size: i64,
- ) -> DbResult<Vec<History>> {
- let res = sqlx::query_as(
- "select id, client_id, user_id, hostname, timestamp, data, created_at from history
- where user_id = $1
- and hostname != $2
- and created_at >= $3
- and timestamp >= $4
- order by timestamp asc
- limit $5",
- )
- .bind(user.id)
- .bind(host)
- .bind(into_utc(created_after))
- .bind(into_utc(since))
- .bind(page_size)
- .fetch(&self.pool)
- .map_ok(|DbHistory(h)| h)
- .try_collect()
- .await?;
-
- Ok(res)
- }
-
- #[instrument(skip_all)]
- async fn add_history(&self, history: &[NewHistory]) -> DbResult<()> {
- let mut tx = self.pool.begin().await?;
-
- for i in history {
- let client_id: &str = &i.client_id;
- let hostname: &str = &i.hostname;
- let data: &str = &i.data;
-
- sqlx::query(
- "insert into history
- (client_id, user_id, hostname, timestamp, data)
- values ($1, $2, $3, $4, $5)
- on conflict do nothing
- ",
- )
- .bind(client_id)
- .bind(i.user_id)
- .bind(hostname)
- .bind(i.timestamp)
- .bind(data)
- .execute(&mut *tx)
- .await?;
- }
-
- tx.commit().await?;
-
- Ok(())
- }
-
- #[instrument(skip_all)]
- async fn oldest_history(&self, user: &User) -> DbResult<History> {
- sqlx::query_as(
- "select id, client_id, user_id, hostname, timestamp, data, created_at from history
- where user_id = $1
- order by timestamp asc
- limit 1",
- )
- .bind(user.id)
- .fetch_one(&self.pool)
- .await
- .map_err(Into::into)
- .map(|DbHistory(h)| h)
- }
-}
diff --git a/crates/turtle/src/atuin_server_sqlite/wrappers.rs b/crates/turtle/src/atuin_server_sqlite/wrappers.rs
deleted file mode 100644
index e7380bce..00000000
--- a/crates/turtle/src/atuin_server_sqlite/wrappers.rs
+++ /dev/null
@@ -1,72 +0,0 @@
-use ::sqlx::{FromRow, Result};
-use crate::atuin_common::record::{EncryptedData, Host, Record};
-use crate::atuin_server_database::models::{History, Session, User};
-use sqlx::{Row, sqlite::SqliteRow};
-
-pub(crate) struct DbUser(pub(crate) User);
-pub(crate) struct DbSession(pub(crate) Session);
-pub(crate) struct DbHistory(pub(crate) History);
-pub(crate) struct DbRecord(pub(crate) Record<EncryptedData>);
-
-impl<'a> FromRow<'a, SqliteRow> for DbUser {
- fn from_row(row: &'a SqliteRow) -> Result<Self> {
- Ok(Self(User {
- id: row.try_get("id")?,
- username: row.try_get("username")?,
- email: row.try_get("email")?,
- password: row.try_get("password")?,
- }))
- }
-}
-
-impl<'a> ::sqlx::FromRow<'a, SqliteRow> for DbSession {
- fn from_row(row: &'a SqliteRow) -> ::sqlx::Result<Self> {
- Ok(Self(Session {
- id: row.try_get("id")?,
- user_id: row.try_get("user_id")?,
- token: row.try_get("token")?,
- }))
- }
-}
-
-impl<'a> ::sqlx::FromRow<'a, SqliteRow> for DbHistory {
- fn from_row(row: &'a SqliteRow) -> ::sqlx::Result<Self> {
- Ok(Self(History {
- id: row.try_get("id")?,
- client_id: row.try_get("client_id")?,
- user_id: row.try_get("user_id")?,
- hostname: row.try_get("hostname")?,
- timestamp: row.try_get("timestamp")?,
- data: row.try_get("data")?,
- created_at: row.try_get("created_at")?,
- }))
- }
-}
-
-impl<'a> ::sqlx::FromRow<'a, SqliteRow> for DbRecord {
- fn from_row(row: &'a SqliteRow) -> ::sqlx::Result<Self> {
- let idx: i64 = row.try_get("idx")?;
- let timestamp: i64 = row.try_get("timestamp")?;
-
- let data = EncryptedData {
- data: row.try_get("data")?,
- content_encryption_key: row.try_get("cek")?,
- };
-
- Ok(Self(Record {
- id: row.try_get("client_id")?,
- host: Host::new(row.try_get("host")?),
- idx: idx as u64,
- timestamp: timestamp as u64,
- version: row.try_get("version")?,
- tag: row.try_get("tag")?,
- data,
- }))
- }
-}
-
-impl From<DbRecord> for Record<EncryptedData> {
- fn from(other: DbRecord) -> Record<EncryptedData> {
- Record { ..other.0 }
- }
-}
diff --git a/crates/turtle/src/command/client/init.rs b/crates/turtle/src/command/client/init.rs
index 776100cf..0643cb73 100644
--- a/crates/turtle/src/command/client/init.rs
+++ b/crates/turtle/src/command/client/init.rs
@@ -1,4 +1,4 @@
-use crate::atuin_client::settings::{Settings, Tmux};
+use crate::atuin_client::settings::Settings;
use clap::{Parser, ValueEnum};
mod bash;
@@ -43,10 +43,9 @@ pub(crate) enum Shell {
}
impl Cmd {
- fn init_nu(&self, _tmux: &Tmux) {
+ fn init_nu(&self) {
let full = include_str!("../../shell/atuin.nu");
- // TODO: tmux popup for Nu
println!("{full}");
if std::env::var("ATUIN_NOBIND").is_err() {
@@ -91,26 +90,24 @@ $env.config = (
}
fn static_init(&self, settings: &Settings) {
- let tmux = &settings.tmux;
-
match self.shell {
Shell::Zsh => {
- zsh::init_static(self.disable_up_arrow, self.disable_ctrl_r, tmux);
+ zsh::init_static(self.disable_up_arrow, self.disable_ctrl_r);
}
Shell::Bash => {
- bash::init_static(self.disable_up_arrow, self.disable_ctrl_r, tmux);
+ bash::init_static(self.disable_up_arrow, self.disable_ctrl_r);
}
Shell::Fish => {
- fish::init_static(self.disable_up_arrow, self.disable_ctrl_r, tmux);
+ fish::init_static(self.disable_up_arrow, self.disable_ctrl_r);
}
Shell::Nu => {
- self.init_nu(tmux);
+ self.init_nu();
}
Shell::Xonsh => {
- xonsh::init_static(self.disable_up_arrow, self.disable_ctrl_r, tmux);
+ xonsh::init_static(self.disable_up_arrow, self.disable_ctrl_r);
}
Shell::PowerShell => {
- powershell::init_static(self.disable_up_arrow, self.disable_ctrl_r, tmux);
+ powershell::init_static(self.disable_up_arrow, self.disable_ctrl_r);
}
}
}
diff --git a/crates/turtle/src/command/client/init/bash.rs b/crates/turtle/src/command/client/init/bash.rs
index a5f6eb8d..c16663e2 100644
--- a/crates/turtle/src/command/client/init/bash.rs
+++ b/crates/turtle/src/command/client/init/bash.rs
@@ -1,15 +1,4 @@
-use crate::atuin_client::settings::Tmux;
-
-fn print_tmux_config(tmux: &Tmux) {
- if tmux.enabled {
- println!("export ATUIN_TMUX_POPUP_WIDTH='{}'", tmux.width);
- println!("export ATUIN_TMUX_POPUP_HEIGHT='{}'", tmux.height);
- } else {
- println!("export ATUIN_TMUX_POPUP=false");
- }
-}
-
-pub(crate) fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool, tmux: &Tmux) {
+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() {
@@ -18,7 +7,6 @@ pub(crate) fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool, tmux: &T
(!disable_ctrl_r, !disable_up_arrow)
};
- print_tmux_config(tmux);
println!("__atuin_bind_ctrl_r={bind_ctrl_r}");
println!("__atuin_bind_up_arrow={bind_up_arrow}");
println!("{base}");
diff --git a/crates/turtle/src/command/client/init/fish.rs b/crates/turtle/src/command/client/init/fish.rs
index 27325bcd..0a992b9c 100644
--- a/crates/turtle/src/command/client/init/fish.rs
+++ b/crates/turtle/src/command/client/init/fish.rs
@@ -1,14 +1,3 @@
-use crate::atuin_client::settings::Tmux;
-
-fn print_tmux_config(tmux: &Tmux) {
- if tmux.enabled {
- println!("set -gx ATUIN_TMUX_POPUP_WIDTH '{}'", tmux.width);
- println!("set -gx ATUIN_TMUX_POPUP_HEIGHT '{}'", tmux.height);
- } else {
- println!("set -gx ATUIN_TMUX_POPUP false");
- }
-}
-
fn print_bindings(
indent: &str,
disable_up_arrow: bool,
@@ -35,12 +24,11 @@ fn print_bindings(
println!("{indent}end");
}
-pub(crate) fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool, tmux: &Tmux) {
+pub(crate) fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool) {
let indent = " ".repeat(4);
let base = include_str!("../../../shell/atuin.fish");
- print_tmux_config(tmux);
println!("{base}");
if std::env::var("ATUIN_NOBIND").is_err() {
diff --git a/crates/turtle/src/command/client/init/powershell.rs b/crates/turtle/src/command/client/init/powershell.rs
index 8deb9a3b..94d89c67 100644
--- a/crates/turtle/src/command/client/init/powershell.rs
+++ b/crates/turtle/src/command/client/init/powershell.rs
@@ -1,6 +1,4 @@
-use crate::atuin_client::settings::Tmux;
-
-pub(crate) fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool, _tmux: &Tmux) {
+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() {
@@ -9,7 +7,6 @@ pub(crate) fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool, _tmux: &
(!disable_ctrl_r, !disable_up_arrow)
};
- // TODO: tmux popup for Powershell
println!("{base}");
println!(
"Enable-AtuinSearchKeys -CtrlR {} -UpArrow {}",
diff --git a/crates/turtle/src/command/client/init/xonsh.rs b/crates/turtle/src/command/client/init/xonsh.rs
index ccb71880..25f867f7 100644
--- a/crates/turtle/src/command/client/init/xonsh.rs
+++ b/crates/turtle/src/command/client/init/xonsh.rs
@@ -1,6 +1,4 @@
-use crate::atuin_client::settings::Tmux;
-
-pub(crate) fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool, _tmux: &Tmux) {
+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() {
@@ -9,7 +7,6 @@ pub(crate) fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool, _tmux: &
(!disable_ctrl_r, !disable_up_arrow)
};
- // TODO: tmux popup for xonsh
println!(
"_ATUIN_BIND_CTRL_R={}",
if bind_ctrl_r { "True" } else { "False" }
diff --git a/crates/turtle/src/command/client/init/zsh.rs b/crates/turtle/src/command/client/init/zsh.rs
index 60d0138f..96a817d0 100644
--- a/crates/turtle/src/command/client/init/zsh.rs
+++ b/crates/turtle/src/command/client/init/zsh.rs
@@ -1,18 +1,6 @@
-use crate::atuin_client::settings::Tmux;
-
-fn print_tmux_config(tmux: &Tmux) {
- if tmux.enabled {
- println!("export ATUIN_TMUX_POPUP_WIDTH='{}'", tmux.width);
- println!("export ATUIN_TMUX_POPUP_HEIGHT='{}'", tmux.height);
- } else {
- println!("export ATUIN_TMUX_POPUP=false");
- }
-}
-
-pub(crate) fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool, tmux: &Tmux) {
+pub(crate) fn init_static(disable_up_arrow: bool, disable_ctrl_r: bool) {
let base = include_str!("../../../shell/atuin.zsh");
- print_tmux_config(tmux);
println!("{base}");
if std::env::var("ATUIN_NOBIND").is_err() {
diff --git a/crates/turtle/src/command/client/server.rs b/crates/turtle/src/command/client/server.rs
index 4c2036d8..def1dfb3 100644
--- a/crates/turtle/src/command/client/server.rs
+++ b/crates/turtle/src/command/client/server.rs
@@ -1,9 +1,6 @@
use std::net::SocketAddr;
-use crate::atuin_server::{Settings, launch, launch_metrics_server};
-use crate::atuin_server_database::DbType;
-use crate::atuin_server_postgres::Postgres;
-use crate::atuin_server_sqlite::Sqlite;
+use crate::atuin_server::{Settings, database::DbType, launch, launch_metrics_server};
use clap::Subcommand;
use eyre::{Context, Result, eyre};
@@ -44,8 +41,7 @@ impl Cmd {
}
match settings.db_settings.db_type() {
- DbType::Postgres => launch::<Postgres>(settings, addr).await,
- DbType::Sqlite => launch::<Sqlite>(settings, addr).await,
+ DbType::Postgres => launch(settings, addr).await,
DbType::Unknown => {
Err(eyre!("db_uri must start with postgres:// or sqlite://"))
}
diff --git a/crates/turtle/src/main.rs b/crates/turtle/src/main.rs
index e5b80ee8..fb3405be 100644
--- a/crates/turtle/src/main.rs
+++ b/crates/turtle/src/main.rs
@@ -2,6 +2,7 @@
#![allow(clippy::use_self, clippy::missing_const_for_fn)] // not 100% reliable
// #![deny(unsafe_code)]
#![forbid(unsafe_code)]
+#![expect(clippy::redundant_pub_crate)]
use clap::Parser;
use clap::builder::Styles;
@@ -12,15 +13,12 @@ use command::AtuinCmd;
mod command;
-mod atuin_client;
-mod atuin_common;
-mod atuin_daemon;
-mod atuin_history;
-mod atuin_pty_proxy;
-mod atuin_server;
-mod atuin_server_database;
-mod atuin_server_postgres;
-mod atuin_server_sqlite;
+pub(crate) mod atuin_client;
+pub(crate) mod atuin_common;
+pub(crate) mod atuin_daemon;
+pub(crate) mod atuin_history;
+pub(crate) mod atuin_pty_proxy;
+pub(crate) mod atuin_server;
#[cfg(feature = "sync")]
mod print_error;
diff --git a/crates/turtle/src/shell/atuin.bash b/crates/turtle/src/shell/atuin.bash
index 8b540bd7..703e8fe2 100644
--- a/crates/turtle/src/shell/atuin.bash
+++ b/crates/turtle/src/shell/atuin.bash
@@ -9,717 +9,664 @@ elif ((BASH_VERSINFO[0] < 3 || BASH_VERSINFO[0] == 3 && BASH_VERSINFO[1] < 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
+ #------------------------------------------------------------------------------
+ __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=""
+ 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
+ __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'
-}
+ 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
+ __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:-}"
-}
+ 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_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/}"
+ __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
-}
+ 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
-}
+ 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_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
+ return 0
+ ;;
+ esac
+ fi
fi
- fi
- # Note: We update ATUIN_PREEXEC_BACKEND on every preexec because blesh's
- # attaching state can dynamically change.
- __atuin_update_preexec_backend
+ # 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-}
+ 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_osc133_wrap_prompt
+ __atuin_precmd() {
+ local EXIT=$? __atuin_precmd_time=${EPOCHREALTIME-}
- [[ ! $ATUIN_HISTORY_ID ]] && return
+ __atuin_osc133_wrap_prompt
- # 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
+ [[ ! $ATUIN_HISTORY_ID ]] && return
- 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
+ # 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
- 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']}
+ 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
- # Count the number of newlines contained in $__atuin_prompt
- __atuin_prompt_offset=${__atuin_prompt//[!$'\n']}
- __atuin_prompt_offset=${#__atuin_prompt_offset}
+ [[ -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=""
}
-else
- __atuin_evaluate_prompt() {
- __atuin_prompt='$ '
- __atuin_prompt_offset=0
+
+ __atuin_set_ret_value() {
+ return ${1:+"$1"}
}
-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]}"
-}
+ #------------------------------------------------------------------------------
+ # 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.
-__atuin_accept_line() {
- local __atuin_command=$1
+ # 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}
- # 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"
+ # 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']/}
- # Add it to the bash history
- history -s "$__atuin_command"
+ # 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
- # 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
+ # 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
- 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
-}
+ printf '%s' "${__atuin_clear_prompt_cache[offset]}"
+ }
-#------------------------------------------------------------------------------
+ __atuin_accept_line() {
+ local __atuin_command=$1
-# Check if tmux popup is available (tmux >= 3.2)
-__atuin_tmux_popup_check() {
- [[ -n "${TMUX-}" ]] || return 1
- [[ "${ATUIN_TMUX_POPUP:-true}" != "false" ]] || return 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"
- # https://github.com/tmux/tmux/wiki/FAQ#how-often-is-tmux-released-what-is-the-version-number-scheme
- local tmux_version
- tmux_version=$(tmux -V 2>/dev/null | sed -n 's/^[^0-9]*\([0-9][0-9]*\.[0-9][0-9]*\).*/\1/p') # Could have used grep...
- [[ -z "$tmux_version" ]] && return 1
+ # Add it to the bash history
+ history -s "$__atuin_command"
- local m1 m2
- m1=${tmux_version%%.*}
- m2=${tmux_version#*.}
- m2=${m2%%.*}
- [[ "$m1" =~ ^[0-9]+$ ]] || return 1
- [[ "$m2" =~ ^[0-9]+$ ]] || m2=0
- (( m1 > 3 || (m1 == 3 && m2 >= 2) ))
-}
+ # 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
-# Use global variable to fix scope issues with traps
-__atuin_popup_tmpdir=""
-__atuin_tmux_popup_cleanup() {
- [[ -n "$__atuin_popup_tmpdir" && -d "$__atuin_popup_tmpdir" ]] && command rm -rf "$__atuin_popup_tmpdir"
- __atuin_popup_tmpdir=""
-}
+ # 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
-__atuin_search_cmd() {
- local -a search_args=("$@")
+ # Juggle the terminal settings so that the command can be interacted
+ # with
+ local __atuin_stty_backup
+ __atuin_stty_backup=$(stty -g)
+ stty "$ATUIN_STTY"
- if __atuin_tmux_popup_check; then
- __atuin_popup_tmpdir=$(mktemp -d) || return 1
- local result_file="$__atuin_popup_tmpdir/result"
+ # 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=$_'
- trap '__atuin_tmux_popup_cleanup' EXIT HUP INT TERM
+ stty "$__atuin_stty_backup"
+ fi
- local escaped_query escaped_args
- escaped_query=$(printf '%s' "$READLINE_LINE" | sed "s/'/'\\\\''/g")
- escaped_args=""
- for arg in "${search_args[@]}"; do
- escaped_args+=" '$(printf '%s' "$arg" | sed "s/'/'\\\\''/g")'"
+ # 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
+ }
- # In the popup, atuin goes to terminal, stderr goes to file
- local cdir popup_width popup_height
- cdir=$(pwd)
- popup_width="${ATUIN_TMUX_POPUP_WIDTH:-80%}" # Keep default value anyways
- popup_height="${ATUIN_TMUX_POPUP_HEIGHT:-60%}"
- tmux display-popup -d "$cdir" -w "$popup_width" -h "$popup_height" -E -E -- \
- sh -c "PATH='$PATH' ATUIN_SESSION='$ATUIN_SESSION' ATUIN_SHELL=bash ATUIN_LOG=error ATUIN_QUERY='$escaped_query' atuin search $escaped_args -i 2>'$result_file'"
+ #------------------------------------------------------------------------------
- if [[ -f "$result_file" ]]; then
- cat "$result_file"
- fi
+ __atuin_search_cmd() {
+ local -a search_args=("$@")
- __atuin_tmux_popup_cleanup
- trap - EXIT HUP INT TERM
- else
ATUIN_SHELL=bash ATUIN_LOG=error ATUIN_QUERY=$READLINE_LINE atuin search "${search_args[@]}" -i 3>&1 1>&2 2>&3 3>&-
- fi
-}
+ }
-__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"
+ __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
- 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
+ # 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=""
+ local __atuin_output
+ if ! __atuin_output=$(__atuin_search_cmd "$@"); then
+ [[ $__atuin_output ]] && printf '%s\n' "$__atuin_output" >&2
+ return 1
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
-}
+ # We do nothing when the search is canceled.
+ [[ $__atuin_output ]] || return 0
-__atuin_initialize_blesh() {
- # shellcheck disable=SC2154
- [[ ${BLE_VERSION-} ]] && ((_ble_version >= 400)) || return 0
+ if [[ $__atuin_output == __atuin_accept__:* ]]; then
+ __atuin_output=${__atuin_output#__atuin_accept__:}
- ble-import contrib/integration/bash-preexec
+ 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
- # 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"
+ 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
}
- 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)
+ __atuin_initialize_blesh() {
+ # shellcheck disable=SC2154
+ [[ ${BLE_VERSION-} ]] && ((_ble_version >= 400)) || return 0
-#------------------------------------------------------------------------------
-# section: atuin-bind
+ ble-import contrib/integration/bash-preexec
-__atuin_widget=()
+ # 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'
-__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
-}
+ # @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)
-__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"
-}
+ #------------------------------------------------------------------------------
+ # section: atuin-bind
-# 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_widget=()
-__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
+ __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
+ }
-if ((BASH_VERSINFO[0] >= 5 || BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 3)); then
- # In Bash >= 4.3
+ __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"
+ }
- __atuin_macro_accept_line=accept-line
+ # 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_bind_impl() {
- local keymap=$1 keyseq=$2 command=$3
+ __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
- # 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[0] == 4 && BASH_VERSINFO[1] >= 3)); then
+ # In Bash >= 4.3
- 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
+ __atuin_macro_accept_line=accept-line
- bind -m "$keymap" "\"$keyseq\": \"$ikeyseq1$ikeyseq2\""
- bind -m "$keymap" -x "\"$ikeyseq1\": __atuin_widget_run $widget"
- }
+ __atuin_bind_impl() {
+ local keymap=$1 keyseq=$2 command=$3
- __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'
+ # 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
- __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
+ 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
- (((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)
+ bind -m "$keymap" "\"$keyseq\": \"$ikeyseq1$ikeyseq2\""
+ bind -m "$keymap" -x "\"$ikeyseq1\": __atuin_widget_run $widget"
+ }
- if ((BASH_VERSINFO[0] >= 4)); then
- __atuin_macro_accept_line=accept-line
+ __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
- # 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'.
+ # 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.
#
- # 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
+ # 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'
- bind -m vi-command '"\C-x\C-_A7\a": vi-insertion-mode'
- bind -m vi-insert '"\C-x\C-_A7\a": vi-movement-mode'
+ __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
+ }
- # "\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_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)
- __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
+ 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
- __atuin_bash42_dispatch_selector=
+ 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() {
- 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_bash42_dispatch() {
+ local s=$__atuin_bash42_dispatch_selector
+ __atuin_bash42_dispatch_selector=
+ __atuin_widget_run "$((2#0$s))"
+ }
- __atuin_widget_save "$keymap:$command"
- __atuin_bash42_encode "$REPLY"
- local macro=$REPLY$__atuin_bash42_code2$__atuin_macro_chain
+ __atuin_bind_impl() {
+ local keymap=$1 keyseq=$2 command=$3
- bind -m "$keymap" "\"$keyseq\": \"$macro\""
- }
-fi
+ __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
+ 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))"
+ esac
+ done
+ shift "$((OPTIND - 1))"
- if (($# != 2)); then
- printf '%s\n' 'usage: atuin-bind [-m keymap] keyseq widget' >&2
- return 2
- fi
+ 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
+ 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 ;;
+ emacs*) keymap=emacs ;;
+ vi-insert) ;;
+ vi*) keymap=vi-command ;;
*)
printf '%s\n' "atuin-bind: unknown keymap '$keymap'" >&2
- return 2 ;;
- esac
+ 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} ;;
+ 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
+ esac
- __atuin_bind_impl "$keymap" "$keyseq" "$command"
-}
+ __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_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
+ # 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/turtle/src/shell/atuin.fish b/crates/turtle/src/shell/atuin.fish
index 15b33451..2b469383 100644
--- a/crates/turtle/src/shell/atuin.fish
+++ b/crates/turtle/src/shell/atuin.fish
@@ -37,49 +37,6 @@ function _atuin_postexec --on-event fish_postexec
set --erase ATUIN_HISTORY_ID
end
-# Check if tmux popup is available (tmux >= 3.2)
-function _atuin_tmux_popup_check
- if not test -n "$TMUX"
- echo 0
- return
- end
-
- if test "$ATUIN_TMUX_POPUP" = false
- echo 0
- return
- end
-
- set -l tmux_version (tmux -V 2>/dev/null | string match -r '\d+\.\d+')
- if not test -n "$tmux_version"
- echo 0
- return
- end
-
- set -l parts (string split '.' $tmux_version)
- set -l m1 $parts[1]
- set -l m2 0
- if test (count $parts) -ge 2
- set m2 $parts[2]
- end
-
- if not string match -rq '^[0-9]+$' -- "$m1"
- echo 0
- return
- end
-
- if not string match -rq '^[0-9]+$' -- "$m2"
- set m2 0
- end
-
- if test "$m1" -gt 3 2>/dev/null; or begin
- test "$m1" -eq 3 2>/dev/null; and test "$m2" -ge 2 2>/dev/null
- end
- echo 1
- else
- echo 0
- end
-end
-
function _atuin_search
set -l keymap_mode
switch $fish_key_bindings
@@ -94,47 +51,14 @@ function _atuin_search
set keymap_mode emacs
end
- set -l use_tmux_popup (_atuin_tmux_popup_check)
-
set -l ATUIN_H
set -l ATUIN_STATUS 0
- if test "$use_tmux_popup" -eq 1
- set -l tmpdir (mktemp -d)
- if not test -d "$tmpdir"
- # if mktemp got errors
- 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]
- else
- set -l result_file "$tmpdir/result"
- set -l query (commandline -b | string replace -a "'" "'\\''")
- set -l escaped_args ""
- for arg in $argv
- set escaped_args "$escaped_args '"(string replace -a "'" "'\\''" -- $arg)"'"
- end
-
- # In the popup, atuin goes to terminal, stderr goes to file
- set -l cdir (pwd)
- # Keep default value anyways
- set -l popup_width (test -n "$ATUIN_TMUX_POPUP_WIDTH" && echo "$ATUIN_TMUX_POPUP_WIDTH" || echo "80%")
- set -l popup_height (test -n "$ATUIN_TMUX_POPUP_HEIGHT" && echo "$ATUIN_TMUX_POPUP_HEIGHT" || echo "60%")
- tmux display-popup -d "$cdir" -w "$popup_width" -h "$popup_height" -E -E -- \
- sh -c "PATH='$PATH' ATUIN_SESSION='$ATUIN_SESSION' ATUIN_SHELL=fish ATUIN_LOG=error ATUIN_QUERY='$query' atuin search --keymap-mode=$keymap_mode$escaped_args -i 2>'$result_file'"
- set ATUIN_STATUS $status
-
- if test -f "$result_file"
- set ATUIN_H (cat "$result_file" | string collect)
- end
-
- command rm -rf "$tmpdir"
- end
- else
- # 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]
- end
+ # 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
diff --git a/crates/turtle/src/shell/atuin.zsh b/crates/turtle/src/shell/atuin.zsh
index 7a7375aa..7e7fef27 100644
--- a/crates/turtle/src/shell/atuin.zsh
+++ b/crates/turtle/src/shell/atuin.zsh
@@ -91,65 +91,11 @@ _atuin_precmd() {
export ATUIN_HISTORY_ID=""
}
-# Check if tmux popup is available (tmux >= 3.2)
-__atuin_tmux_popup_check() {
- [[ -n "${TMUX-}" ]] || return 1
- [[ "${ATUIN_TMUX_POPUP:-true}" != "false" ]] || return 1
-
- # https://github.com/tmux/tmux/wiki/FAQ#how-often-is-tmux-released-what-is-the-version-number-scheme
- local tmux_version
- tmux_version=$(tmux -V 2>/dev/null | sed -n 's/^[^0-9]*\([0-9][0-9]*\.[0-9][0-9]*\).*/\1/p') # Could have used grep...
- [[ -z "$tmux_version" ]] && return 1
-
- local m1 m2
- m1=${tmux_version%%.*}
- m2=${tmux_version#*.}
- m2=${m2%%.*}
- [[ "$m1" =~ ^[0-9]+$ ]] || return 1
- [[ "$m2" =~ ^[0-9]+$ ]] || m2=0
- (( m1 > 3 || (m1 == 3 && m2 >= 2) ))
-}
-
-# Use global variable to fix scope issues with traps
-__atuin_popup_tmpdir=""
-__atuin_tmux_popup_cleanup() {
- [[ -n "$__atuin_popup_tmpdir" && -d "$__atuin_popup_tmpdir" ]] && command rm -rf "$__atuin_popup_tmpdir"
- __atuin_popup_tmpdir=""
-}
-
__atuin_search_cmd() {
local -a search_args=("$@")
- if __atuin_tmux_popup_check; then
- __atuin_popup_tmpdir=$(mktemp -d) || return 1
- local result_file="$__atuin_popup_tmpdir/result"
-
- trap '__atuin_tmux_popup_cleanup' EXIT HUP INT TERM
- local escaped_query escaped_args
- escaped_query=$(printf '%s' "$BUFFER" | sed "s/'/'\\\\''/g")
- escaped_args=""
- for arg in "${search_args[@]}"; do
- escaped_args+=" '$(printf '%s' "$arg" | sed "s/'/'\\\\''/g")'"
- done
-
- # In the popup, atuin goes to terminal, stderr goes to file
- local cdir popup_width popup_height
- cdir=$(pwd)
- popup_width="${ATUIN_TMUX_POPUP_WIDTH:-80%}" # Keep default value anyways
- popup_height="${ATUIN_TMUX_POPUP_HEIGHT:-60%}"
- tmux display-popup -d "$cdir" -w "$popup_width" -h "$popup_height" -E -E -- \
- sh -c "PATH='$PATH' ATUIN_SESSION='$ATUIN_SESSION' ATUIN_SHELL=zsh ATUIN_LOG=error ATUIN_QUERY='$escaped_query' atuin search $escaped_args -i 2>'$result_file'"
-
- if [[ -f "$result_file" ]]; then
- cat "$result_file"
- fi
-
- __atuin_tmux_popup_cleanup
- trap - EXIT HUP INT TERM
- else
- ATUIN_SHELL=zsh ATUIN_LOG=error ATUIN_QUERY=$BUFFER atuin search "${search_args[@]}" -i 3>&1 1>&2 2>&3 3>&-
- fi
+ ATUIN_SHELL=zsh ATUIN_LOG=error ATUIN_QUERY=$BUFFER atuin search "${search_args[@]}" -i 3>&1 1>&2 2>&3 3>&-
}
_atuin_search() {