diff options
| author | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2026-06-11 16:10:29 +0200 |
|---|---|---|
| committer | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2026-06-11 16:10:29 +0200 |
| commit | 97f207b771b94c5285faae4810d6eeda1b78926b (patch) | |
| tree | 4482544233c30e0e9a62be6afcfe92c8e01b0a50 | |
| parent | chore: Remove all `pub`s (diff) | |
| download | atuin-97f207b771b94c5285faae4810d6eeda1b78926b.zip | |
chore(server): Simplify the database support
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() { |
