diff options
Diffstat (limited to 'crates')
44 files changed, 1312 insertions, 359 deletions
diff --git a/crates/atuin-client/Cargo.toml b/crates/atuin-client/Cargo.toml index 407ff491..2e65409f 100644 --- a/crates/atuin-client/Cargo.toml +++ b/crates/atuin-client/Cargo.toml @@ -19,7 +19,7 @@ daemon = [] check-update = [] [dependencies] -atuin-common = { path = "../atuin-common", version = "18.10.0" } +atuin-common = { path = "../atuin-common", version = "18.11.0" } log = { workspace = true } base64 = { workspace = true } diff --git a/crates/atuin-client/config.toml b/crates/atuin-client/config.toml index c40461ab..117ea066 100644 --- a/crates/atuin-client/config.toml +++ b/crates/atuin-client/config.toml @@ -49,7 +49,7 @@ ## in any directory within a git repository tree (default: false). ## ## To use workspace mode by default when available, set this to true and -## set filter_mode to "workspace" or leave it unspecified and +## set filter_mode to "workspace" or leave it unspecified and ## set search.filters to include "workspace" before other filter modes. # workspaces = false @@ -154,8 +154,10 @@ ## 5. Stripe live/test keys # secrets_filter = true -## Defaults to true. If enabled, upon hitting enter Atuin will immediately execute the command. Press tab to return to the shell and edit. -# This applies for new installs. Old installs will keep the old behaviour unless configured otherwise. +## Defaults to true. If enabled, upon hitting enter Atuin will immediately execute the command, +## whereas tab will put the command in the prompt for editing. +## If set to false, both enter and tab will place the command in the prompt for editing. +## This applies for new installs. Old installs will keep the old behaviour unless configured otherwise. enter_accept = true ## Defaults to false. If enabled, when triggered after &&, || or |, Atuin will complete commands to chain rather than replace the current line. @@ -287,3 +289,44 @@ records = true ## The "workspace" mode is skipped when not in a workspace or workspaces = false. ## Default filter mode can be overridden with the filter_mode setting. # filters = [ "global", "host", "session", "session-preload", "workspace", "directory" ] + +[ui] +## Columns to display in the interactive search, from left to right. +## The selection indicator (" > ") is always shown first implicitly. +## +## Each column can be specified as a simple string (uses default width) +## or as an object with type, width, and expand: +## { type = "directory", width = 30, expand = true } +## +## Available column types (with default widths): +## duration (5) - Command execution duration (e.g., "123ms") +## time (8) - Relative time since execution (e.g., "59m ago") +## datetime (16) - Absolute timestamp (e.g., "2025-01-22 14:35") +## directory (20) - Working directory (truncated if too long) +## host (15) - Hostname where command was run +## user (10) - Username +## exit (3) - Exit code (colored by success/failure) +## command (*) - The command itself (expands by default) +## +## The "expand" option (default: true for command, false for others) makes a +## column fill remaining space. Only one column should have expand = true. +## +## Default: +# columns = ["duration", "time", "command"] +## +## Examples: +## +## Minimal - more space for commands: +# columns = ["duration", "command"] +## +## With wider directory column: +# columns = ["duration", { type = "directory", width = 30 }, "command"] +## +## Show host for multi-machine sync users: +# columns = ["duration", "time", "host", "command"] +## +## Show exit codes prominently: +# columns = ["exit", "duration", "command"] +## +## Make directory expand instead of command: +# columns = ["duration", "time", { type = "directory", expand = true }, { type = "command", expand = false }] diff --git a/crates/atuin-client/src/import/replxx.rs b/crates/atuin-client/src/import/replxx.rs index dd7030ad..47d566cf 100644 --- a/crates/atuin-client/src/import/replxx.rs +++ b/crates/atuin-client/src/import/replxx.rs @@ -19,8 +19,23 @@ fn default_histpath() -> Result<PathBuf> { let home_dir = user_dirs.home_dir(); // There is no default histfile for replxx. - // For simplicity let's use the most common one. - Ok(home_dir.join(".histfile")) + // Here we try a couple of common names. + let mut candidates = ["replxx_history.txt", ".histfile"].iter(); + loop { + match candidates.next() { + Some(candidate) => { + let histpath = home_dir.join(candidate); + if histpath.exists() { + break Ok(histpath); + } + } + None => { + break Err(eyre!( + "Could not find history file. Try setting and exporting $HISTFILE" + )); + } + } + } } #[async_trait] diff --git a/crates/atuin-client/src/import/zsh.rs b/crates/atuin-client/src/import/zsh.rs index b65e2608..11e2f371 100644 --- a/crates/atuin-client/src/import/zsh.rs +++ b/crates/atuin-client/src/import/zsh.rs @@ -70,7 +70,7 @@ impl Importer for Zsh { if let Some(s) = s.strip_suffix('\\') { line.push_str(s); - line.push_str("\\\n"); + line.push('\n'); } else { line.push_str(&s); let command = std::mem::take(&mut line); @@ -188,7 +188,7 @@ mod test { #[tokio::test] async fn test_parse_file() { let bytes = r": 1613322469:0;cargo install atuin -: 1613322469:10;cargo install atuin; \ +: 1613322469:10;cargo install atuin; \\ cargo update : 1613322469:10;cargo :b̷i̶t̴r̵o̴t̴ ̵i̷s̴ ̷r̶e̵a̸l̷ " diff --git a/crates/atuin-client/src/secrets.rs b/crates/atuin-client/src/secrets.rs index 25e8db9a..459e6238 100644 --- a/crates/atuin-client/src/secrets.rs +++ b/crates/atuin-client/src/secrets.rs @@ -12,7 +12,7 @@ pub enum TestValue<'a> { pub static SECRET_PATTERNS: &[(&str, &str, TestValue)] = &[ ( "AWS Access Key ID", - "AKIA[0-9A-Z]{16}", + "A[KS]IA[0-9A-Z]{16}", TestValue::Single("AKIAIOSFODNN7EXAMPLE"), ), ( diff --git a/crates/atuin-client/src/settings.rs b/crates/atuin-client/src/settings.rs index 489c1a83..916172ba 100644 --- a/crates/atuin-client/src/settings.rs +++ b/crates/atuin-client/src/settings.rs @@ -451,6 +451,184 @@ pub enum PreviewStrategy { Fixed, } +/// Column types available for the interactive search UI. +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum UiColumnType { + /// Command execution duration (e.g., "123ms") + Duration, + /// Relative time since execution (e.g., "59s ago") + Time, + /// Absolute timestamp (e.g., "2025-01-22 14:35") + Datetime, + /// Working directory + Directory, + /// Hostname + Host, + /// Username + User, + /// Exit code + Exit, + /// The command itself (should be last, expands to fill) + Command, +} + +impl UiColumnType { + /// Returns the default width for this column type (in characters). + /// The Command column returns 0 as it expands to fill remaining space. + pub fn default_width(&self) -> u16 { + match self { + UiColumnType::Duration => 5, + UiColumnType::Time => 9, // "459ms ago" with padding + UiColumnType::Datetime => 16, // "2025-01-22 14:35" + UiColumnType::Directory => 20, + UiColumnType::Host => 15, + UiColumnType::User => 10, + UiColumnType::Exit => 3, + UiColumnType::Command => 0, // Expands to fill + } + } +} + +/// A column configuration with type and optional custom width. +/// Can be specified as just a string (uses default width) or as an object with type and width. +#[derive(Clone, Debug, Serialize)] +pub struct UiColumn { + pub column_type: UiColumnType, + pub width: u16, + /// If true, this column expands to fill remaining space. Only one column should expand. + pub expand: bool, +} + +impl UiColumn { + pub fn new(column_type: UiColumnType) -> Self { + Self { + width: column_type.default_width(), + expand: column_type == UiColumnType::Command, + column_type, + } + } + + pub fn with_width(column_type: UiColumnType, width: u16) -> Self { + Self { + column_type, + width, + expand: column_type == UiColumnType::Command, + } + } +} + +// Custom deserialize to handle both string and object formats: +// "duration" or { type = "duration", width = 8, expand = true } +impl<'de> serde::Deserialize<'de> for UiColumn { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + use serde::de::{self, MapAccess, Visitor}; + + struct UiColumnVisitor; + + impl<'de> Visitor<'de> for UiColumnVisitor { + type Value = UiColumn; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str( + "a column type string or an object with 'type' and optional 'width'/'expand'", + ) + } + + fn visit_str<E>(self, value: &str) -> Result<UiColumn, E> + where + E: de::Error, + { + let column_type: UiColumnType = + serde::Deserialize::deserialize(serde::de::value::StrDeserializer::new(value))?; + Ok(UiColumn::new(column_type)) + } + + fn visit_map<M>(self, mut map: M) -> Result<UiColumn, M::Error> + where + M: MapAccess<'de>, + { + let mut column_type: Option<UiColumnType> = None; + let mut width: Option<u16> = None; + let mut expand: Option<bool> = None; + + while let Some(key) = map.next_key::<String>()? { + match key.as_str() { + "type" => { + column_type = Some(map.next_value()?); + } + "width" => { + width = Some(map.next_value()?); + } + "expand" => { + expand = Some(map.next_value()?); + } + _ => { + let _: serde::de::IgnoredAny = map.next_value()?; + } + } + } + + let column_type = column_type.ok_or_else(|| de::Error::missing_field("type"))?; + let width = width.unwrap_or_else(|| column_type.default_width()); + let expand = expand.unwrap_or(column_type == UiColumnType::Command); + Ok(UiColumn { + column_type, + width, + expand, + }) + } + } + + deserializer.deserialize_any(UiColumnVisitor) + } +} + +/// UI-specific settings for the interactive search. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Ui { + /// Columns to display in interactive search, from left to right. + /// The indicator column (" > ") is always shown first implicitly. + /// The "command" column should be last as it expands to fill remaining space. + /// Can be simple strings or objects with type and width. + #[serde(default = "Ui::default_columns")] + pub columns: Vec<UiColumn>, +} + +impl Ui { + fn default_columns() -> Vec<UiColumn> { + vec![ + UiColumn::new(UiColumnType::Duration), + UiColumn::new(UiColumnType::Time), + UiColumn::new(UiColumnType::Command), + ] + } + + /// Validate the UI configuration. + /// Returns an error if more than one column has expand = true. + pub fn validate(&self) -> Result<()> { + let expand_count = self.columns.iter().filter(|c| c.expand).count(); + if expand_count > 1 { + bail!( + "Only one column can have expand = true, but {} columns are set to expand", + expand_count + ); + } + Ok(()) + } +} + +impl Default for Ui { + fn default() -> Self { + Self { + columns: Self::default_columns(), + } + } +} + #[derive(Clone, Debug, Deserialize, Serialize)] pub struct Settings { pub dialect: Dialect, @@ -531,6 +709,9 @@ pub struct Settings { pub theme: Theme, #[serde(default)] + pub ui: Ui, + + #[serde(default)] pub scripts: scripts::Settings, #[serde(default)] @@ -735,10 +916,20 @@ impl Settings { None } - pub fn default_filter_mode(&self) -> FilterMode { + pub fn default_filter_mode(&self, git_root: bool) -> FilterMode { self.filter_mode .filter(|x| self.search.filters.contains(x)) - .or(self.search.filters.first().copied()) + .or_else(|| { + self.search + .filters + .iter() + .find(|x| match (x, git_root, self.workspaces) { + (FilterMode::Workspace, true, true) => true, + (FilterMode::Workspace, _, _) => false, + (_, _, _) => true, + }) + .copied() + }) .unwrap_or(FilterMode::Global) } @@ -895,6 +1086,9 @@ impl Settings { settings.session_path = Self::expand_path(settings.session_path)?; settings.daemon.socket_path = Self::expand_path(settings.daemon.socket_path)?; + // Validate UI settings + settings.ui.validate()?; + Ok(settings) } @@ -979,4 +1173,58 @@ mod tests { Ok(()) } + + #[test] + fn can_choose_workspace_filters_when_in_git_context() -> Result<()> { + let mut settings = super::Settings::default(); + settings.search.filters = vec![ + super::FilterMode::Workspace, + super::FilterMode::Host, + super::FilterMode::Directory, + super::FilterMode::Session, + super::FilterMode::Global, + ]; + settings.workspaces = true; + + assert_eq!( + settings.default_filter_mode(true), + super::FilterMode::Workspace, + ); + + Ok(()) + } + + #[test] + fn wont_choose_workspace_filters_when_not_in_git_context() -> Result<()> { + let mut settings = super::Settings::default(); + settings.search.filters = vec![ + super::FilterMode::Workspace, + super::FilterMode::Host, + super::FilterMode::Directory, + super::FilterMode::Session, + super::FilterMode::Global, + ]; + settings.workspaces = true; + + assert_eq!(settings.default_filter_mode(false), super::FilterMode::Host,); + + Ok(()) + } + + #[test] + fn wont_choose_workspace_filters_when_workspaces_disabled() -> Result<()> { + let mut settings = super::Settings::default(); + settings.search.filters = vec![ + super::FilterMode::Workspace, + super::FilterMode::Host, + super::FilterMode::Directory, + super::FilterMode::Session, + super::FilterMode::Global, + ]; + settings.workspaces = false; + + assert_eq!(settings.default_filter_mode(true), super::FilterMode::Host,); + + Ok(()) + } } diff --git a/crates/atuin-common/src/api.rs b/crates/atuin-common/src/api.rs index 4e897811..1aaf9859 100644 --- a/crates/atuin-common/src/api.rs +++ b/crates/atuin-common/src/api.rs @@ -106,7 +106,6 @@ pub struct ErrorResponse<'a> { pub struct IndexResponse { pub homage: String, pub version: String, - pub total_history: i64, } #[derive(Debug, Serialize, Deserialize)] diff --git a/crates/atuin-daemon/Cargo.toml b/crates/atuin-daemon/Cargo.toml index 3a791a47..3c828205 100644 --- a/crates/atuin-daemon/Cargo.toml +++ b/crates/atuin-daemon/Cargo.toml @@ -14,9 +14,9 @@ readme.workspace = true # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -atuin-client = { path = "../atuin-client", version = "18.10.0" } -atuin-dotfiles = { path = "../atuin-dotfiles", version = "18.10.0" } -atuin-history = { path = "../atuin-history", version = "18.10.0" } +atuin-client = { path = "../atuin-client", version = "18.11.0" } +atuin-dotfiles = { path = "../atuin-dotfiles", version = "18.11.0" } +atuin-history = { path = "../atuin-history", version = "18.11.0" } time = { workspace = true } uuid = { workspace = true } diff --git a/crates/atuin-dotfiles/Cargo.toml b/crates/atuin-dotfiles/Cargo.toml index 84b2d823..1f5d990e 100644 --- a/crates/atuin-dotfiles/Cargo.toml +++ b/crates/atuin-dotfiles/Cargo.toml @@ -14,8 +14,8 @@ readme.workspace = true # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -atuin-common = { path = "../atuin-common", version = "18.10.0" } -atuin-client = { path = "../atuin-client", version = "18.10.0" } +atuin-common = { path = "../atuin-common", version = "18.11.0" } +atuin-client = { path = "../atuin-client", version = "18.11.0" } eyre = { workspace = true } tokio = { workspace = true } diff --git a/crates/atuin-history/Cargo.toml b/crates/atuin-history/Cargo.toml index 7153ed83..fe597303 100644 --- a/crates/atuin-history/Cargo.toml +++ b/crates/atuin-history/Cargo.toml @@ -14,7 +14,7 @@ readme.workspace = true # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -atuin-client = { path = "../atuin-client", version = "18.10.0" } +atuin-client = { path = "../atuin-client", version = "18.11.0" } time = { workspace = true } serde = { workspace = true } diff --git a/crates/atuin-kv/Cargo.toml b/crates/atuin-kv/Cargo.toml index aeedfa59..e41c7b47 100644 --- a/crates/atuin-kv/Cargo.toml +++ b/crates/atuin-kv/Cargo.toml @@ -14,8 +14,8 @@ readme.workspace = true # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -atuin-client = { path = "../atuin-client", version = "18.10.0" } -atuin-common = { path = "../atuin-common", version = "18.10.0" } +atuin-client = { path = "../atuin-client", version = "18.11.0" } +atuin-common = { path = "../atuin-common", version = "18.11.0" } tracing = { workspace = true } tracing-subscriber = { workspace = true } diff --git a/crates/atuin-scripts/Cargo.toml b/crates/atuin-scripts/Cargo.toml index 007ff860..aed696ba 100644 --- a/crates/atuin-scripts/Cargo.toml +++ b/crates/atuin-scripts/Cargo.toml @@ -14,8 +14,8 @@ readme.workspace = true # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -atuin-client = { path = "../atuin-client", version = "18.10.0" } -atuin-common = { path = "../atuin-common", version = "18.10.0" } +atuin-client = { path = "../atuin-client", version = "18.11.0" } +atuin-common = { path = "../atuin-common", version = "18.11.0" } tracing = { workspace = true } tracing-subscriber = { workspace = true } diff --git a/crates/atuin-server-database/Cargo.toml b/crates/atuin-server-database/Cargo.toml index 9766cc71..537e34a2 100644 --- a/crates/atuin-server-database/Cargo.toml +++ b/crates/atuin-server-database/Cargo.toml @@ -10,7 +10,7 @@ homepage = { workspace = true } repository = { workspace = true } [dependencies] -atuin-common = { path = "../atuin-common", version = "18.10.0" } +atuin-common = { path = "../atuin-common", version = "18.11.0" } tracing = { workspace = true } time = { workspace = true } diff --git a/crates/atuin-server-database/src/lib.rs b/crates/atuin-server-database/src/lib.rs index e70c755c..a4ddf23c 100644 --- a/crates/atuin-server-database/src/lib.rs +++ b/crates/atuin-server-database/src/lib.rs @@ -51,6 +51,8 @@ pub enum DbType { #[derive(Clone, Deserialize, Serialize)] pub struct DbSettings { pub db_uri: String, + /// Optional URI for read replicas. If set, read-only queries will use this connection. + pub read_db_uri: Option<String>, } impl DbSettings { @@ -65,22 +67,29 @@ impl DbSettings { } } +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 = url::Url::parse(&self.db_uri) - .map(|mut url| { - let _ = url.set_password(Some("****")); - url.to_string() - }) - .unwrap_or(self.db_uri.clone()); + 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() } } @@ -104,7 +113,6 @@ pub trait Database: Sized + Clone + Send + Sync + 'static { async fn update_user_password(&self, u: &User) -> DbResult<()>; - async fn total_history(&self) -> DbResult<i64>; async fn count_history(&self, user: &User) -> DbResult<i64>; async fn count_history_cached(&self, user: &User) -> DbResult<i64>; diff --git a/crates/atuin-server-postgres/Cargo.toml b/crates/atuin-server-postgres/Cargo.toml index 5abcd931..c73e4a52 100644 --- a/crates/atuin-server-postgres/Cargo.toml +++ b/crates/atuin-server-postgres/Cargo.toml @@ -10,8 +10,8 @@ homepage = { workspace = true } repository = { workspace = true } [dependencies] -atuin-common = { path = "../atuin-common", version = "18.10.0" } -atuin-server-database = { path = "../atuin-server-database", version = "18.10.0" } +atuin-common = { path = "../atuin-common", version = "18.11.0" } +atuin-server-database = { path = "../atuin-server-database", version = "18.11.0" } eyre = { workspace = true } tracing = { workspace = true } @@ -20,6 +20,6 @@ serde = { workspace = true } sqlx = { workspace = true } async-trait = { workspace = true } uuid = { workspace = true } -metrics = "0.21.1" +metrics = "0.24" futures-util = "0.3" rand.workspace = true
\ No newline at end of file diff --git a/crates/atuin-server-postgres/src/lib.rs b/crates/atuin-server-postgres/src/lib.rs index 39c25256..54ba2ee8 100644 --- a/crates/atuin-server-postgres/src/lib.rs +++ b/crates/atuin-server-postgres/src/lib.rs @@ -24,6 +24,16 @@ const MIN_PG_VERSION: u32 = 14; #[derive(Clone)] pub struct Postgres { pool: sqlx::Pool<sqlx::postgres::Postgres>, + /// Optional read replica pool for read-only queries + read_pool: Option<sqlx::Pool<sqlx::postgres::Postgres>>, +} + +impl Postgres { + /// 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> { + self.read_pool.as_ref().unwrap_or(&self.pool) + } } fn fix_error(error: sqlx::Error) -> DbError { @@ -65,14 +75,45 @@ impl Database for Postgres { .await .map_err(|error| DbError::Other(error.into()))?; - Ok(Self { pool }) + // Create read replica pool if configured + let read_pool = if let Some(read_db_uri) = &settings.read_db_uri { + tracing::info!("Connecting to read replica database"); + let read_pool = PgPoolOptions::new() + .max_connections(100) + .connect(read_db_uri.as_str()) + .await + .map_err(fix_error)?; + + // Verify the read replica is also a supported PostgreSQL version + let read_pg_major_version: u32 = read_pool + .acquire() + .await + .map_err(fix_error)? + .server_version_num() + .ok_or(DbError::Other(eyre::Report::msg( + "could not get PostgreSQL version from read replica", + )))? + / 10000; + + if read_pg_major_version < MIN_PG_VERSION { + return Err(DbError::Other(eyre::Report::msg(format!( + "unsupported PostgreSQL version {read_pg_major_version} on read replica, minimum required is {MIN_PG_VERSION}" + )))); + } + + Some(read_pool) + } else { + None + }; + + Ok(Self { pool, read_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) + .fetch_one(self.read_pool()) .await .map_err(fix_error) .map(|DbSession(session)| session) @@ -84,7 +125,7 @@ impl Database for Postgres { "select id, username, email, password, verified_at from users where username = $1", ) .bind(username) - .fetch_one(&self.pool) + .fetch_one(self.read_pool()) .await .map_err(fix_error) .map(|DbUser(user)| user) @@ -95,7 +136,7 @@ impl Database for Postgres { let res: (bool,) = sqlx::query_as("select verified_at is not null from users where id = $1") .bind(id) - .fetch_one(&self.pool) + .fetch_one(self.read_pool()) .await .map_err(fix_error)?; @@ -173,13 +214,13 @@ impl Database for Postgres { #[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, users.verified_at from users - inner join sessions - on users.id = sessions.user_id + "select users.id, users.username, users.email, users.password, users.verified_at from users + inner join sessions + on users.id = sessions.user_id and sessions.token = $1", ) .bind(token) - .fetch_one(&self.pool) + .fetch_one(self.read_pool()) .await .map_err(fix_error) .map(|DbUser(user)| user) @@ -196,7 +237,7 @@ impl Database for Postgres { where user_id = $1", ) .bind(user.id) - .fetch_one(&self.pool) + .fetch_one(self.read_pool()) .await .map_err(fix_error)?; @@ -204,28 +245,13 @@ impl Database for Postgres { } #[instrument(skip_all)] - async fn total_history(&self) -> 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 sum(total) from total_history_count_user") - .fetch_optional(&self.pool) - .await - .map_err(fix_error)? - .unwrap_or((0,)); - - Ok(res.0) - } - - #[instrument(skip_all)] 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", ) .bind(user.id) - .fetch_one(&self.pool) + .fetch_one(self.read_pool()) .await .map_err(fix_error)?; @@ -283,12 +309,12 @@ impl Database for Postgres { // edge case. let res = sqlx::query( - "select client_id from history + "select client_id from history where user_id = $1 and deleted_at is not null", ) .bind(user.id) - .fetch_all(&self.pool) + .fetch_all(self.read_pool()) .await .map_err(fix_error)?; @@ -315,7 +341,7 @@ impl Database for Postgres { .bind(user.id) .bind(into_utc(range.start)) .bind(into_utc(range.end)) - .fetch_one(&self.pool) + .fetch_one(self.read_pool()) .await .map_err(fix_error)?; @@ -332,7 +358,7 @@ impl Database for Postgres { page_size: i64, ) -> DbResult<Vec<History>> { let res = sqlx::query_as( - "select id, client_id, user_id, hostname, timestamp, data, created_at from history + "select id, client_id, user_id, hostname, timestamp, data, created_at from history where user_id = $1 and hostname != $2 and created_at >= $3 @@ -345,7 +371,7 @@ impl Database for Postgres { .bind(into_utc(created_after)) .bind(into_utc(since)) .bind(page_size) - .fetch(&self.pool) + .fetch(self.read_pool()) .map_ok(|DbHistory(h)| h) .try_collect() .await @@ -486,7 +512,7 @@ impl Database for Postgres { 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) + .fetch_one(self.read_pool()) .await .map_err(fix_error) .map(|DbSession(session)| session) @@ -495,13 +521,13 @@ impl Database for Postgres { #[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 + "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) + .fetch_one(self.read_pool()) .await .map_err(fix_error) .map(|DbHistory(h)| h) @@ -606,7 +632,7 @@ impl Database for Postgres { .bind(host) .bind(start as i64) .bind(count as i64) - .fetch_all(&self.pool) + .fetch_all(self.read_pool()) .await .map_err(fix_error); @@ -650,14 +676,14 @@ impl Database for Postgres { tracing::debug!("using idx cache for user {}", user.id); sqlx::query_as("select host, tag, idx from store_idx_cache where user_id = $1") .bind(user.id) - .fetch_all(&self.pool) + .fetch_all(self.read_pool()) .await .map_err(fix_error)? } else { tracing::debug!("using aggregate query for user {}", user.id); sqlx::query_as(STATUS_SQL) .bind(user.id) - .fetch_all(&self.pool) + .fetch_all(self.read_pool()) .await .map_err(fix_error)? }; diff --git a/crates/atuin-server-sqlite/Cargo.toml b/crates/atuin-server-sqlite/Cargo.toml index 5d22b6db..d9acf39a 100644 --- a/crates/atuin-server-sqlite/Cargo.toml +++ b/crates/atuin-server-sqlite/Cargo.toml @@ -10,8 +10,8 @@ homepage = { workspace = true } repository = { workspace = true } [dependencies] -atuin-common = { path = "../atuin-common", version = "18.10.0" } -atuin-server-database = { path = "../atuin-server-database", version = "18.10.0" } +atuin-common = { path = "../atuin-common", version = "18.11.0" } +atuin-server-database = { path = "../atuin-server-database", version = "18.11.0" } eyre = { workspace = true } tracing = { workspace = true } @@ -20,5 +20,5 @@ serde = { workspace = true } sqlx = { workspace = true, features = ["sqlite", "regexp"] } async-trait = { workspace = true } uuid = { workspace = true } -metrics = "0.21.1" +metrics = "0.24" futures-util = "0.3" diff --git a/crates/atuin-server-sqlite/src/lib.rs b/crates/atuin-server-sqlite/src/lib.rs index 9cc1e8a7..83d05ea5 100644 --- a/crates/atuin-server-sqlite/src/lib.rs +++ b/crates/atuin-server-sqlite/src/lib.rs @@ -232,17 +232,6 @@ impl Database for Sqlite { } #[instrument(skip_all)] - async fn total_history(&self) -> DbResult<i64> { - let res: (i64,) = sqlx::query_as("select count(1) from history") - .fetch_optional(&self.pool) - .await - .map_err(fix_error)? - .unwrap_or((0,)); - - Ok(res.0) - } - - #[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 diff --git a/crates/atuin-server/Cargo.toml b/crates/atuin-server/Cargo.toml index ad94c379..915ceb14 100644 --- a/crates/atuin-server/Cargo.toml +++ b/crates/atuin-server/Cargo.toml @@ -11,8 +11,8 @@ homepage = { workspace = true } repository = { workspace = true } [dependencies] -atuin-common = { path = "../atuin-common", version = "18.10.0" } -atuin-server-database = { path = "../atuin-server-database", version = "18.10.0" } +atuin-common = { path = "../atuin-common", version = "18.11.0" } +atuin-server-database = { path = "../atuin-server-database", version = "18.11.0" } tracing = { workspace = true } time = { workspace = true } @@ -24,14 +24,12 @@ rand = { workspace = true } tokio = { workspace = true } async-trait = { workspace = true } axum = "0.7" -axum-server = { version = "0.7", features = ["tls-rustls-no-provider"] } fs-err = { workspace = true } tower = { workspace = true } tower-http = { version = "0.6", features = ["trace"] } reqwest = { workspace = true } -rustls = { version = "0.23", features = ["ring"], default-features = false } argon2 = "0.5" semver = { workspace = true } -metrics-exporter-prometheus = "0.12.1" -metrics = "0.21.1" +metrics-exporter-prometheus = "0.18" +metrics = "0.24" postmark = {version= "0.11", features=["reqwest", "reqwest-rustls-tls"]} diff --git a/crates/atuin-server/server.toml b/crates/atuin-server/server.toml index 1eff5b72..9ff95890 100644 --- a/crates/atuin-server/server.toml +++ b/crates/atuin-server/server.toml @@ -11,6 +11,10 @@ # db_uri="postgres://username:password@localhost/atuin" # db_uri="sqlite:///config/atuin-server.db" +## Optional: URI for read replica database +## If set, read-only queries will be routed to this database +# read_db_uri="postgres://username:password@localhost-replica/atuin" + ## Maximum size for one history entry # max_history_length = 8192 @@ -29,7 +33,6 @@ # host = 127.0.0.1 # port = 9001 -# [tls] -# enable = false -# cert_path = "" -# pkey_path = "" +## Enable legacy sync v1 routes (history-based sync) +## Set to false to disable and use only the newer record-based sync +# sync_v1_enabled = true diff --git a/crates/atuin-server/src/handlers/history.rs b/crates/atuin-server/src/handlers/history.rs index 5547a180..bdafcc60 100644 --- a/crates/atuin-server/src/handlers/history.rs +++ b/crates/atuin-server/src/handlers/history.rs @@ -65,7 +65,7 @@ pub async fn list<DB: Database>( 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", 1); + counter!("atuin_history_epoch_before_zero").increment(1); return Err( ErrorResponse::reply("asked for history from before epoch 0") @@ -95,7 +95,7 @@ pub async fn list<DB: Database>( user.id ); - counter!("atuin_history_returned", history.len() as u64); + counter!("atuin_history_returned").increment(history.len() as u64); Ok(Json(SyncHistoryResponse { history })) } @@ -131,7 +131,7 @@ pub async fn add<DB: Database>( let State(AppState { database, settings }) = state; debug!("request to add {} history items", req.len()); - counter!("atuin_history_uploaded", req.len() as u64); + counter!("atuin_history_uploaded").increment(req.len() as u64); let mut history: Vec<NewHistory> = req .into_iter() @@ -151,7 +151,7 @@ pub async fn add<DB: Database>( // 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", 1); + counter!("atuin_history_too_long").increment(1); tracing::warn!( "history too long, got length {}, max {}", diff --git a/crates/atuin-server/src/handlers/mod.rs b/crates/atuin-server/src/handlers/mod.rs index 1b9fd162..2176ac5e 100644 --- a/crates/atuin-server/src/handlers/mod.rs +++ b/crates/atuin-server/src/handlers/mod.rs @@ -16,10 +16,6 @@ const VERSION: &str = env!("CARGO_PKG_VERSION"); pub async fn index<DB: Database>(state: State<AppState<DB>>) -> 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"#; - // Error with a -1 response - // It's super unlikely this will happen - let count = state.database.total_history().await.unwrap_or(-1); - let version = state .settings .fake_version @@ -28,7 +24,6 @@ pub async fn index<DB: Database>(state: State<AppState<DB>>) -> Json<IndexRespon Json(IndexResponse { homage: homage.to_string(), - total_history: count, version, }) } diff --git a/crates/atuin-server/src/handlers/user.rs b/crates/atuin-server/src/handlers/user.rs index e493e714..4edd1787 100644 --- a/crates/atuin-server/src/handlers/user.rs +++ b/crates/atuin-server/src/handlers/user.rs @@ -146,7 +146,7 @@ pub async fn register<DB: Database>( .await; } - counter!("atuin_users_registered", 1); + counter!("atuin_users_registered").increment(1); match db.add_session(&new_session).await { Ok(_) => Ok(Json(RegisterResponse { session: token })), @@ -173,7 +173,7 @@ pub async fn delete<DB: Database>( .with_status(StatusCode::INTERNAL_SERVER_ERROR)); }; - counter!("atuin_users_deleted", 1); + counter!("atuin_users_deleted").increment(1); Ok(Json(DeleteUserResponse {})) } diff --git a/crates/atuin-server/src/handlers/v0/record.rs b/crates/atuin-server/src/handlers/v0/record.rs index 01b91599..5c57910b 100644 --- a/crates/atuin-server/src/handlers/v0/record.rs +++ b/crates/atuin-server/src/handlers/v0/record.rs @@ -25,14 +25,14 @@ pub async fn post<DB: Database>( "request to add records" ); - counter!("atuin_record_uploaded", records.len() as u64); + counter!("atuin_record_uploaded").increment(records.len() as u64); let keep = records .iter() .all(|r| r.data.data.len() <= settings.max_record_size || settings.max_record_size == 0); if !keep { - counter!("atuin_record_too_large", 1); + counter!("atuin_record_too_large").increment(1); return Err( ErrorResponse::reply("could not add records; record too large") @@ -108,7 +108,7 @@ pub async fn next<DB: Database>( } }; - counter!("atuin_record_downloaded", records.len() as u64); + counter!("atuin_record_downloaded").increment(records.len() as u64); Ok(Json(records)) } diff --git a/crates/atuin-server/src/handlers/v0/store.rs b/crates/atuin-server/src/handlers/v0/store.rs index 941f2487..6ca455d7 100644 --- a/crates/atuin-server/src/handlers/v0/store.rs +++ b/crates/atuin-server/src/handlers/v0/store.rs @@ -24,14 +24,14 @@ pub async fn delete<DB: Database>( }) = state; if let Err(e) = database.delete_store(&user).await { - counter!("atuin_store_delete_failed", 1); + counter!("atuin_store_delete_failed").increment(1); error!("failed to delete store {e:?}"); return Err(ErrorResponse::reply("failed to delete store") .with_status(StatusCode::INTERNAL_SERVER_ERROR)); } - counter!("atuin_store_deleted", 1); + counter!("atuin_store_deleted").increment(1); Ok(()) } diff --git a/crates/atuin-server/src/lib.rs b/crates/atuin-server/src/lib.rs index f1d616f2..fcf5dde6 100644 --- a/crates/atuin-server/src/lib.rs +++ b/crates/atuin-server/src/lib.rs @@ -5,9 +5,7 @@ use std::net::SocketAddr; use atuin_server_database::Database; use axum::{Router, serve}; -use axum_server::Handle; -use axum_server::tls_rustls::RustlsConfig; -use eyre::{Context, Result, eyre}; +use eyre::{Context, Result}; mod handlers; mod metrics; @@ -46,18 +44,14 @@ async fn shutdown_signal() { } pub async fn launch<Db: Database>(settings: Settings, addr: SocketAddr) -> Result<()> { - if settings.tls.enable { - launch_with_tls::<Db>(settings, addr, shutdown_signal()).await - } else { - launch_with_tcp_listener::<Db>( - settings, - TcpListener::bind(addr) - .await - .context("could not connect to socket")?, - shutdown_signal(), - ) - .await - } + launch_with_tcp_listener::<Db>( + settings, + TcpListener::bind(addr) + .await + .context("could not connect to socket")?, + shutdown_signal(), + ) + .await } pub async fn launch_with_tcp_listener<Db: Database>( @@ -74,43 +68,6 @@ pub async fn launch_with_tcp_listener<Db: Database>( Ok(()) } -async fn launch_with_tls<Db: Database>( - settings: Settings, - addr: SocketAddr, - shutdown: impl Future<Output = ()>, -) -> Result<()> { - let crypto_provider = rustls::crypto::ring::default_provider().install_default(); - if crypto_provider.is_err() { - return Err(eyre!("Failed to install default crypto provider")); - } - let rustls_config = RustlsConfig::from_pem_file( - settings.tls.cert_path.clone(), - settings.tls.pkey_path.clone(), - ) - .await; - if rustls_config.is_err() { - return Err(eyre!("Failed to load TLS key and/or certificate")); - } - let rustls_config = rustls_config.unwrap(); - - let r = make_router::<Db>(settings).await?; - - let handle = Handle::new(); - - let server = axum_server::bind_rustls(addr, rustls_config) - .handle(handle.clone()) - .serve(r.into_make_service()); - - tokio::select! { - _ = server => {} - _ = shutdown => { - handle.graceful_shutdown(None); - } - } - - Ok(()) -} - // The separate listener means it's much easier to ensure metrics are not accidentally exposed to // the public. pub async fn launch_metrics_server(host: String, port: u16) -> Result<()> { diff --git a/crates/atuin-server/src/metrics.rs b/crates/atuin-server/src/metrics.rs index ff0fe925..ebd0dd2d 100644 --- a/crates/atuin-server/src/metrics.rs +++ b/crates/atuin-server/src/metrics.rs @@ -48,8 +48,8 @@ pub async fn track_metrics(req: Request, next: Next) -> impl IntoResponse { ("status", status), ]; - metrics::increment_counter!("http_requests_total", &labels); - metrics::histogram!("http_requests_duration_seconds", latency, &labels); + metrics::counter!("http_requests_total", &labels).increment(1); + metrics::histogram!("http_requests_duration_seconds", &labels).record(latency); response } diff --git a/crates/atuin-server/src/router.rs b/crates/atuin-server/src/router.rs index 1118ab29..9d4f7d44 100644 --- a/crates/atuin-server/src/router.rs +++ b/crates/atuin-server/src/router.rs @@ -109,15 +109,22 @@ pub struct AppState<DB: Database> { } pub fn router<DB: Database>(database: DB, settings: Settings) -> Router { - let routes = Router::new() + let mut routes = Router::new() .route("/", get(handlers::index)) - .route("/healthz", get(handlers::health::health_check)) - .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)) + .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)) .route("/account/password", patch(handlers::user::change_password)) diff --git a/crates/atuin-server/src/settings.rs b/crates/atuin-server/src/settings.rs index 7221d4dd..98d1d69f 100644 --- a/crates/atuin-server/src/settings.rs +++ b/crates/atuin-server/src/settings.rs @@ -65,9 +65,12 @@ pub struct Settings { pub register_webhook_url: Option<String>, pub register_webhook_username: String, pub metrics: Metrics, - pub tls: Tls, pub mail: Mail, + /// Enable legacy sync v1 routes (history-based sync) + /// Set to false to use only the newer record-based sync + pub 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. @@ -106,9 +109,7 @@ impl Settings { .set_default("metrics.host", "127.0.0.1")? .set_default("metrics.port", 9001)? .set_default("mail.enable", false)? - .set_default("tls.enable", false)? - .set_default("tls.cert_path", "")? - .set_default("tls.pkey_path", "")? + .set_default("sync_v1_enabled", true)? .add_source( Environment::with_prefix("atuin") .prefix_separator("_") @@ -139,12 +140,3 @@ impl Settings { pub fn example_config() -> &'static str { EXAMPLE_CONFIG } - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -pub struct Tls { - #[serde(alias = "enabled")] - pub enable: bool, - - pub cert_path: PathBuf, - pub pkey_path: PathBuf, -} diff --git a/crates/atuin/Cargo.toml b/crates/atuin/Cargo.toml index 52f245ac..9c4e3de8 100644 --- a/crates/atuin/Cargo.toml +++ b/crates/atuin/Cargo.toml @@ -47,17 +47,17 @@ clipboard = ["arboard"] check-update = ["atuin-client/check-update"] [dependencies] -atuin-server-database = { path = "../atuin-server-database", version = "18.10.0", optional = true } -atuin-server-postgres = { path = "../atuin-server-postgres", version = "18.10.0", optional = true } -atuin-server-sqlite = { path = "../atuin-server-sqlite", version = "18.10.0", optional = true } -atuin-server = { path = "../atuin-server", version = "18.10.0", optional = true } -atuin-client = { path = "../atuin-client", version = "18.10.0", optional = true, default-features = false } -atuin-common = { path = "../atuin-common", version = "18.10.0" } -atuin-dotfiles = { path = "../atuin-dotfiles", version = "18.10.0" } -atuin-history = { path = "../atuin-history", version = "18.10.0" } -atuin-daemon = { path = "../atuin-daemon", version = "18.10.0", optional = true, default-features = false } -atuin-scripts = { path = "../atuin-scripts", version = "18.10.0" } -atuin-kv = { path = "../atuin-kv", version = "18.10.0" } +atuin-server-database = { path = "../atuin-server-database", version = "18.11.0", optional = true } +atuin-server-postgres = { path = "../atuin-server-postgres", version = "18.11.0", optional = true } +atuin-server-sqlite = { path = "../atuin-server-sqlite", version = "18.11.0", optional = true } +atuin-server = { path = "../atuin-server", version = "18.11.0", optional = true } +atuin-client = { path = "../atuin-client", version = "18.11.0", optional = true, default-features = false } +atuin-common = { path = "../atuin-common", version = "18.11.0" } +atuin-dotfiles = { path = "../atuin-dotfiles", version = "18.11.0" } +atuin-history = { path = "../atuin-history", version = "18.11.0" } +atuin-daemon = { path = "../atuin-daemon", version = "18.11.0", optional = true, default-features = false } +atuin-scripts = { path = "../atuin-scripts", version = "18.11.0" } +atuin-kv = { path = "../atuin-kv", version = "18.11.0" } log = { workspace = true } time = { workspace = true } diff --git a/crates/atuin/src/command/client.rs b/crates/atuin/src/command/client.rs index 330fef0c..a0d4373f 100644 --- a/crates/atuin/src/command/client.rs +++ b/crates/atuin/src/command/client.rs @@ -176,7 +176,7 @@ impl Cmd { Ok(()) } - Self::Wrapped { year } => wrapped::run(year, &db, &settings, theme).await, + Self::Wrapped { year } => wrapped::run(year, &db, &settings, sqlite_store, theme).await, #[cfg(feature = "daemon")] Self::Daemon => daemon::run(settings, sqlite_store, db).await, diff --git a/crates/atuin/src/command/client/account/verify.rs b/crates/atuin/src/command/client/account/verify.rs index 7c707117..1533c283 100644 --- a/crates/atuin/src/command/client/account/verify.rs +++ b/crates/atuin/src/command/client/account/verify.rs @@ -36,7 +36,7 @@ pub async fn run(settings: &Settings, token: Option<String>) -> Result<()> { (false, false) => { println!( - "Your Atuin server does not have mail setup. This is not required, though your account cannot be verified. Speak to your admin." + "Your Atuin server does not have mail set up. This is not required, though your account cannot be verified. Speak to your admin." ); } diff --git a/crates/atuin/src/command/client/history.rs b/crates/atuin/src/command/client/history.rs index 028db5f1..c85f6c49 100644 --- a/crates/atuin/src/command/client/history.rs +++ b/crates/atuin/src/command/client/history.rs @@ -85,7 +85,7 @@ pub enum Cmd { #[arg(long, visible_alias = "tz")] timezone: Option<Timezone>, - /// Available variables: {command}, {directory}, {duration}, {user}, {host}, {exit} and {time}. + /// Available variables: {command}, {directory}, {duration}, {user}, {host}, {exit}, {time}, {session}, and {uuid} /// Example: --format "{time} - [{duration}] - {directory}$\t{command}" #[arg(long, short)] format: Option<String>, @@ -108,7 +108,7 @@ pub enum Cmd { #[arg(long, visible_alias = "tz")] timezone: Option<Timezone>, - /// Available variables: {command}, {directory}, {duration}, {user}, {host} and {time}. + /// Available variables: {command}, {directory}, {duration}, {user}, {host}, {time}, {session}, {uuid} and {relativetime}. /// Example: --format "{time} - [{duration}] - {directory}$\t{command}" #[arg(long, short)] format: Option<String>, @@ -320,6 +320,8 @@ impl FormatKey for FmtHistory<'_> { .split_once(':') .map_or("", |(_, user)| user), )?, + "session" => f.write_str(&self.history.session)?, + "uuid" => f.write_str(&self.history.id.0)?, _ => return Err(FormatKeyError::UnknownKey), } Ok(()) @@ -514,7 +516,10 @@ impl Cmd { (true, true) => [Session, Directory], (true, false) => [Session, Global], (false, true) => [Global, Directory], - (false, false) => [settings.default_filter_mode(), Global], + (false, false) => [ + settings.default_filter_mode(context.git_root.is_some()), + Global, + ], }; let history = db diff --git a/crates/atuin/src/command/client/scripts.rs b/crates/atuin/src/command/client/scripts.rs index 65ceabde..851755af 100644 --- a/crates/atuin/src/command/client/scripts.rs +++ b/crates/atuin/src/command/client/scripts.rs @@ -230,7 +230,7 @@ impl Cmd { let context = atuin_client::database::current_context(); // Get the last N+1 commands, filtering by the default mode - let filters = [settings.default_filter_mode()]; + let filters = [settings.default_filter_mode(context.git_root.is_some())]; let mut history = history_db .list(&filters, &context, Some(count), false, false) diff --git a/crates/atuin/src/command/client/search.rs b/crates/atuin/src/command/client/search.rs index 4103901a..be00ee99 100644 --- a/crates/atuin/src/command/client/search.rs +++ b/crates/atuin/src/command/client/search.rs @@ -314,7 +314,7 @@ async fn run_non_interactive( ..filter_options }; - let filter_mode = settings.default_filter_mode(); + let filter_mode = settings.default_filter_mode(context.git_root.is_some()); let results = db .search( diff --git a/crates/atuin/src/command/client/search/engines/skim.rs b/crates/atuin/src/command/client/search/engines/skim.rs index 1af49574..cb7ce24f 100644 --- a/crates/atuin/src/command/client/search/engines/skim.rs +++ b/crates/atuin/src/command/client/search/engines/skim.rs @@ -203,7 +203,7 @@ fn path_dist(a: &Path, b: &Path) -> usize { let mut dist = 0; - // pop a until there's a common anscestor + // pop a until there's a common ancestor while !b.starts_with(&a) { dist += 1; a.pop(); diff --git a/crates/atuin/src/command/client/search/history_list.rs b/crates/atuin/src/command/client/search/history_list.rs index 899308db..565a7972 100644 --- a/crates/atuin/src/command/client/search/history_list.rs +++ b/crates/atuin/src/command/client/search/history_list.rs @@ -4,6 +4,7 @@ use super::duration::format_duration; use super::engines::SearchEngine; use atuin_client::{ history::History, + settings::{UiColumn, UiColumnType}, theme::{Meaning, Theme}, }; use atuin_common::utils::Escapable as _; @@ -40,6 +41,8 @@ pub struct HistoryList<'a> { theme: &'a Theme, history_highlighter: HistoryHighlighter<'a>, show_numeric_shortcuts: bool, + /// Columns to display (in order, after the indicator) + columns: &'a [UiColumn], } #[derive(Default)] @@ -58,6 +61,10 @@ impl ListState { self.max_entries } + pub fn offset(&self) -> usize { + self.offset + } + pub fn select(&mut self, index: usize) { self.selected = index; } @@ -95,13 +102,11 @@ impl StatefulWidget for HistoryList<'_> { theme: self.theme, history_highlighter: self.history_highlighter, show_numeric_shortcuts: self.show_numeric_shortcuts, + columns: self.columns, }; for item in self.history.iter().skip(state.offset).take(end - start) { - s.index(); - s.duration(item); - s.time(item); - s.command(item); + s.render_row(item); // reset line s.y += 1; @@ -121,6 +126,7 @@ impl<'a> HistoryList<'a> { theme: &'a Theme, history_highlighter: HistoryHighlighter<'a>, show_numeric_shortcuts: bool, + columns: &'a [UiColumn], ) -> Self { Self { history, @@ -132,6 +138,7 @@ impl<'a> HistoryList<'a> { theme, history_highlighter, show_numeric_shortcuts, + columns, } } @@ -168,19 +175,53 @@ struct DrawState<'a> { theme: &'a Theme, history_highlighter: HistoryHighlighter<'a>, show_numeric_shortcuts: bool, + columns: &'a [UiColumn], } -// longest line prefix I could come up with -#[allow(clippy::cast_possible_truncation)] // we know that this is <65536 length -pub const PREFIX_LENGTH: u16 = " > 123ms 59s ago".len() as u16; -static SPACES: &str = " "; -static _ASSERT: () = assert!(SPACES.len() == PREFIX_LENGTH as usize); - // these encode the slices of `" > "`, `" {n} "`, or `" "` in a compact form. // Yes, this is a hack, but it makes me feel happy static SLICES: &str = " > 1 2 3 4 5 6 7 8 9 "; impl DrawState<'_> { + /// Render a complete row for a history item based on configured columns. + fn render_row(&mut self, h: &History) { + // Always render the indicator first (width 3) + self.index(); + + // Calculate the width for the expanding column + // Fixed columns use their configured width + 1 (trailing space) + let indicator_width: u16 = 3; + let fixed_width: u16 = self + .columns + .iter() + .filter(|c| !c.expand) + .map(|c| c.width + 1) + .sum(); + let expand_width = self + .list_area + .width + .saturating_sub(indicator_width + fixed_width); + + // Render each configured column + for column in self.columns { + let width = if column.expand { + expand_width + } else { + column.width + }; + match column.column_type { + UiColumnType::Duration => self.duration(h, width), + UiColumnType::Time => self.time(h, width), + UiColumnType::Datetime => self.datetime(h, width), + UiColumnType::Directory => self.directory(h, width), + UiColumnType::Host => self.host(h, width), + UiColumnType::User => self.user(h, width), + UiColumnType::Exit => self.exit_code(h, width), + UiColumnType::Command => self.command(h), + } + } + } + fn index(&mut self) { if !self.show_numeric_shortcuts { let i = self.y as usize + self.state.offset; @@ -204,18 +245,21 @@ impl DrawState<'_> { self.draw(prompt, Style::default()); } - fn duration(&mut self, h: &History) { - let status = self.theme.as_style(if h.success() { + fn duration(&mut self, h: &History, width: u16) { + let style = self.theme.as_style(if h.success() { Meaning::AlertInfo } else { Meaning::AlertError }); let duration = Duration::from_nanos(u64::try_from(h.duration).unwrap_or(0)); - self.draw(&format_duration(duration), status.into()); + let formatted = format_duration(duration); + let w = width as usize; + // Right-align duration within its column width, plus trailing space + let display = format!("{formatted:>w$} "); + self.draw(&display, style.into()); } - #[allow(clippy::cast_possible_truncation)] // we know that time.len() will be <6 - fn time(&mut self, h: &History) { + fn time(&mut self, h: &History, width: u16) { let style = self.theme.as_style(Meaning::Guidance); // Account for the chance that h.timestamp is "in the future" @@ -226,14 +270,11 @@ impl DrawState<'_> { let since = (self.now)() - h.timestamp; let time = format_duration(since.try_into().unwrap_or_default()); - // pad the time a little bit before we write. this aligns things nicely - // skip padding if for some reason it is already too long to align nicely - let padding = - usize::from(PREFIX_LENGTH).saturating_sub(usize::from(self.x) + 4 + time.len()); - self.draw(&SPACES[..padding], Style::default()); - - self.draw(&time, style.into()); - self.draw(" ago", style.into()); + // Format as "Xs ago" right-aligned within column width, plus trailing space + let w = width as usize; + let time_str = format!("{time} ago"); + let display = format!("{time_str:>w$} "); + self.draw(&display, style.into()); } fn command(&mut self, h: &History) { @@ -257,7 +298,9 @@ impl DrawState<'_> { let mut pos = 0; for section in h.command.escape_control().split_ascii_whitespace() { - self.draw(" ", style.into()); + if pos != 0 { + self.draw(" ", style.into()); + } for ch in section.chars() { if self.x > self.list_area.width { // Avoid attempting to draw a command section beyond the width @@ -273,13 +316,93 @@ impl DrawState<'_> { } style.attributes.set(style::Attribute::Bold); } - self.draw(&ch.to_string(), style.into()); - pos += 1; + let s = ch.to_string(); + self.draw(&s, style.into()); + pos += s.len(); } pos += 1; } } + /// Render the absolute datetime column (e.g., "2025-01-22 14:35") + fn datetime(&mut self, h: &History, width: u16) { + let style = self.theme.as_style(Meaning::Annotation); + // Format: YYYY-MM-DD HH:MM + let formatted = h + .timestamp + .format( + &time::format_description::parse("[year]-[month]-[day] [hour]:[minute]") + .expect("valid format"), + ) + .unwrap_or_else(|_| "????-??-?? ??:??".to_string()); + let w = width as usize; + let display = format!("{formatted:w$} "); + self.draw(&display, style.into()); + } + + /// Render the directory column (working directory, truncated) + fn directory(&mut self, h: &History, width: u16) { + let style = self.theme.as_style(Meaning::Annotation); + let w = width as usize; + let cwd = &h.cwd; + let char_count = cwd.chars().count(); + // Truncate from the left with "..." if too long, plus trailing space + // Use character count for comparison and skip for UTF-8 safety + let display = if char_count > w && w >= 4 { + let truncated: String = cwd.chars().skip(char_count - (w - 3)).collect(); + format!("...{truncated} ") + } else { + format!("{cwd:w$} ") + }; + self.draw(&display, style.into()); + } + + /// Render the host column (just the hostname) + fn host(&mut self, h: &History, width: u16) { + let style = self.theme.as_style(Meaning::Annotation); + let w = width as usize - 1; + // Database stores hostname as "hostname:username" + let host = h.hostname.split(':').next().unwrap_or(&h.hostname); + let char_count = host.chars().count(); + // Use character count for comparison and take for UTF-8 safety + let display = if char_count > w && w >= 4 { + let truncated: String = host.chars().take(w.saturating_sub(4)).collect(); + format!("{truncated}... ") + } else { + format!("{host:w$} ") + }; + self.draw(&display, style.into()); + } + + /// Render the user column + fn user(&mut self, h: &History, width: u16) { + let style = self.theme.as_style(Meaning::Annotation); + let w = width as usize; + // Database stores hostname as "hostname:username" + let user = h.hostname.split(':').nth(1).unwrap_or(""); + let char_count = user.chars().count(); + // Use character count for comparison and take for UTF-8 safety + let display = if char_count > w && w >= 4 { + let truncated: String = user.chars().take(w.saturating_sub(4)).collect(); + format!("{truncated}... ") + } else { + format!("{user:w$} ") + }; + self.draw(&display, style.into()); + } + + /// Render the exit code column + fn exit_code(&mut self, h: &History, width: u16) { + let style = if h.success() { + self.theme.as_style(Meaning::AlertInfo) + } else { + self.theme.as_style(Meaning::AlertError) + }; + let w = width as usize; + let display = format!("{:>w$} ", h.exit); + self.draw(&display, style.into()); + } + fn draw(&mut self, s: &str, mut style: Style) { let cx = self.list_area.left() + self.x; diff --git a/crates/atuin/src/command/client/search/inspector.rs b/crates/atuin/src/command/client/search/inspector.rs index 34d22eba..890f7ff7 100644 --- a/crates/atuin/src/command/client/search/inspector.rs +++ b/crates/atuin/src/command/client/search/inspector.rs @@ -138,8 +138,8 @@ pub fn draw_stats_table( format_duration(avg_duration), ]), Row::new(vec!["Exit".to_string(), history.exit.to_string()]), - Row::new(vec!["Directory".to_string(), history.cwd.to_string()]), - Row::new(vec!["Session".to_string(), history.session.to_string()]), + Row::new(vec!["Directory".to_string(), history.cwd.clone()]), + Row::new(vec!["Session".to_string(), history.session.clone()]), Row::new(vec!["Total runs".to_string(), stats.total.to_string()]), ]; diff --git a/crates/atuin/src/command/client/search/interactive.rs b/crates/atuin/src/command/client/search/interactive.rs index 930f634c..bda4873d 100644 --- a/crates/atuin/src/command/client/search/interactive.rs +++ b/crates/atuin/src/command/client/search/interactive.rs @@ -13,13 +13,13 @@ use unicode_width::UnicodeWidthStr; use super::{ cursor::Cursor, engines::{SearchEngine, SearchState}, - history_list::{HistoryList, ListState, PREFIX_LENGTH}, + history_list::{HistoryList, ListState}, }; use atuin_client::{ database::{Database, current_context}, history::{History, HistoryId, HistoryStats, store::HistoryStore}, settings::{ - CursorStyle, ExitMode, FilterMode, KeymapMode, PreviewStrategy, SearchMode, Settings, + CursorStyle, ExitMode, KeymapMode, PreviewStrategy, SearchMode, Settings, UiColumn, }, }; @@ -119,6 +119,7 @@ pub struct State { prefix: bool, current_cursor: Option<CursorStyle>, tab_index: usize, + pending_vim_key: Option<char>, pub inspecting_state: InspectingState, @@ -306,6 +307,9 @@ impl State { { Some(InputAction::Accept(self.results_state.selected())) } + KeyCode::Left | KeyCode::Backspace if self.search.input.as_str().is_empty() => { + Some(InputAction::Accept(self.results_state.selected())) + } KeyCode::Char('o') if ctrl => { self.tab_index = (self.tab_index + 1) % TAB_TITLES.len(); Some(InputAction::Continue) @@ -398,60 +402,175 @@ impl State { // handle keymap specific keybindings. match self.keymap_mode { - KeymapMode::VimNormal => match input.code { - KeyCode::Char('?' | '/') if !ctrl => { - self.search.input.clear(); - self.set_keymap_cursor(settings, "vim_insert"); - self.keymap_mode = KeymapMode::VimInsert; - return InputAction::Continue; - } - KeyCode::Char('j') if !ctrl => { - return self.handle_search_down(settings, true); - } - KeyCode::Char('k') if !ctrl => { - return self.handle_search_up(settings, true); - } - KeyCode::Char('h') if !ctrl => { - self.search.input.left(); - return InputAction::Continue; - } - KeyCode::Char('l') if !ctrl => { - self.search.input.right(); - return InputAction::Continue; - } - KeyCode::Char('a') if !ctrl => { - self.search.input.right(); - self.set_keymap_cursor(settings, "vim_insert"); - self.keymap_mode = KeymapMode::VimInsert; - return InputAction::Continue; - } - KeyCode::Char('A') if !ctrl => { - self.search.input.end(); - self.set_keymap_cursor(settings, "vim_insert"); - self.keymap_mode = KeymapMode::VimInsert; - return InputAction::Continue; - } - KeyCode::Char('i') if !ctrl => { - self.set_keymap_cursor(settings, "vim_insert"); - self.keymap_mode = KeymapMode::VimInsert; - return InputAction::Continue; - } - KeyCode::Char('I') if !ctrl => { - self.search.input.start(); - self.set_keymap_cursor(settings, "vim_insert"); - self.keymap_mode = KeymapMode::VimInsert; - return InputAction::Continue; - } - KeyCode::Char(c @ '1'..='9') => { - return c.to_digit(10).map_or(InputAction::Continue, |c| { - InputAction::Accept(self.results_state.selected() + c as usize) - }); + KeymapMode::VimNormal => { + // Reset pending key unless this is 'g' (for gg sequence) + if !matches!(input.code, KeyCode::Char('g')) || ctrl { + self.pending_vim_key = None; } - KeyCode::Char(_) if !ctrl => { - return InputAction::Continue; + + match input.code { + KeyCode::Char('?' | '/') if !ctrl => { + self.search.input.clear(); + self.set_keymap_cursor(settings, "vim_insert"); + self.keymap_mode = KeymapMode::VimInsert; + return InputAction::Continue; + } + KeyCode::Char('j') if !ctrl => { + return self.handle_search_down(settings, true); + } + KeyCode::Char('k') if !ctrl => { + return self.handle_search_up(settings, true); + } + KeyCode::Char('h') if !ctrl => { + self.search.input.left(); + return InputAction::Continue; + } + KeyCode::Char('l') if !ctrl => { + self.search.input.right(); + return InputAction::Continue; + } + KeyCode::Char('a') if !ctrl => { + self.search.input.right(); + self.set_keymap_cursor(settings, "vim_insert"); + self.keymap_mode = KeymapMode::VimInsert; + return InputAction::Continue; + } + KeyCode::Char('A') if !ctrl => { + self.search.input.end(); + self.set_keymap_cursor(settings, "vim_insert"); + self.keymap_mode = KeymapMode::VimInsert; + return InputAction::Continue; + } + KeyCode::Char('i') if !ctrl => { + self.set_keymap_cursor(settings, "vim_insert"); + self.keymap_mode = KeymapMode::VimInsert; + return InputAction::Continue; + } + KeyCode::Char('I') if !ctrl => { + self.search.input.start(); + self.set_keymap_cursor(settings, "vim_insert"); + self.keymap_mode = KeymapMode::VimInsert; + return InputAction::Continue; + } + KeyCode::Char(c @ '1'..='9') => { + return c.to_digit(10).map_or(InputAction::Continue, |c| { + InputAction::Accept(self.results_state.selected() + c as usize) + }); + } + KeyCode::Char('u') if ctrl => { + // Half-page up (toward visual top) + let scroll_len = self + .results_state + .max_entries() + .saturating_sub(settings.scroll_context_lines) + / 2; + if settings.invert { + self.scroll_down(scroll_len); + } else { + self.scroll_up(scroll_len); + } + return InputAction::Continue; + } + KeyCode::Char('d') if ctrl => { + // Half-page down (toward visual bottom) + let scroll_len = self + .results_state + .max_entries() + .saturating_sub(settings.scroll_context_lines) + / 2; + if settings.invert { + self.scroll_up(scroll_len); + } else { + self.scroll_down(scroll_len); + } + return InputAction::Continue; + } + KeyCode::Char('b') if ctrl => { + // Full-page up (toward visual top) + let scroll_len = self + .results_state + .max_entries() + .saturating_sub(settings.scroll_context_lines); + if settings.invert { + self.scroll_down(scroll_len); + } else { + self.scroll_up(scroll_len); + } + return InputAction::Continue; + } + KeyCode::Char('f') if ctrl => { + // Full-page down (toward visual bottom) + let scroll_len = self + .results_state + .max_entries() + .saturating_sub(settings.scroll_context_lines); + if settings.invert { + self.scroll_up(scroll_len); + } else { + self.scroll_down(scroll_len); + } + return InputAction::Continue; + } + KeyCode::Char('G') if !ctrl => { + // Jump to visual bottom of history + if settings.invert { + let last_idx = self.results_len.saturating_sub(1); + self.results_state.select(last_idx); + } else { + self.results_state.select(0); + } + self.inspecting_state.reset(); + return InputAction::Continue; + } + KeyCode::Char('g') if !ctrl => { + if self.pending_vim_key == Some('g') { + // gg - jump to visual top of history + if settings.invert { + self.results_state.select(0); + } else { + let last_idx = self.results_len.saturating_sub(1); + self.results_state.select(last_idx); + } + self.inspecting_state.reset(); + self.pending_vim_key = None; + } else { + self.pending_vim_key = Some('g'); + } + return InputAction::Continue; + } + KeyCode::Char('H') if !ctrl => { + // Jump to top of visible screen + let top = self.results_state.offset(); + let visible = self.results_state.max_entries().min(self.results_len); + let bottom = top + visible.saturating_sub(1); + self.results_state + .select(bottom.min(self.results_len.saturating_sub(1))); + self.inspecting_state.reset(); + return InputAction::Continue; + } + KeyCode::Char('M') if !ctrl => { + // Jump to middle of visible screen + let top = self.results_state.offset(); + let visible = self.results_state.max_entries().min(self.results_len); + let middle = top + visible / 2; + self.results_state + .select(middle.min(self.results_len.saturating_sub(1))); + self.inspecting_state.reset(); + return InputAction::Continue; + } + KeyCode::Char('L') if !ctrl => { + // Jump to bottom of visible screen + let top_visible = self.results_state.offset(); + self.results_state.select(top_visible); + self.inspecting_state.reset(); + return InputAction::Continue; + } + KeyCode::Char(_) if !ctrl => { + return InputAction::Continue; + } + _ => {} } - _ => {} - }, + } KeymapMode::VimInsert => { if input.code == KeyCode::Esc || (ctrl && input.code == KeyCode::Char('[')) { self.set_keymap_cursor(settings, "vim_normal"); @@ -825,6 +944,7 @@ impl State { theme, history_highlighter, settings.show_numeric_shortcuts, + &settings.ui.columns, ); f.render_stateful_widget(results_list, results_list_chunk, &mut self.results_state); } @@ -884,11 +1004,27 @@ impl State { preview_chunk.width.into(), theme, ); - self.draw_preview(f, style, input_chunk, compactness, preview_chunk, preview); + #[allow(clippy::cast_possible_truncation)] + let prefix_width = settings + .ui + .columns + .iter() + .filter_map(|col| if col.expand { None } else { Some(col.width) }) + .sum::<u16>() + + " > ".len() as u16; + self.draw_preview( + f, + style, + input_chunk, + compactness, + preview_chunk, + preview, + prefix_width, + ); } } - #[allow(clippy::cast_possible_truncation)] + #[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)] fn draw_preview( &self, f: &mut Frame, @@ -897,8 +1033,9 @@ impl State { compactness: Compactness, preview_chunk: Rect, preview: Paragraph, + prefix_width: u16, ) { - let input = self.build_input(style); + let input = self.build_input(style, prefix_width - 2); f.render_widget(input, input_chunk); f.render_widget(preview, preview_chunk); @@ -911,7 +1048,7 @@ impl State { }; f.set_cursor_position(( // Put cursor past the end of the input text - input_chunk.x + extra_width as u16 + PREFIX_LENGTH + 1 + cursor_offset, + input_chunk.x + extra_width as u16 + prefix_width + 1 + cursor_offset, input_chunk.y + cursor_offset, )); } @@ -920,7 +1057,7 @@ impl State { let title = if self.update_needed.is_some() { let error_style: Style = theme.get_error().into(); Paragraph::new(Text::from(Span::styled( - format!("Atuin v{VERSION} - UPGRADE"), + format!("Atuin v{VERSION} - UPDATE"), error_style.add_modifier(Modifier::BOLD), ))) } else { @@ -991,6 +1128,7 @@ impl State { theme: &'a Theme, history_highlighter: HistoryHighlighter<'a>, show_numeric_shortcuts: bool, + columns: &'a [UiColumn], ) -> HistoryList<'a> { let results_list = HistoryList::new( results, @@ -1001,6 +1139,7 @@ impl State { theme, history_highlighter, show_numeric_shortcuts, + columns, ); match style.compactness { @@ -1024,15 +1163,13 @@ impl State { } } - fn build_input(&self, style: StyleState) -> Paragraph<'_> { - /// Max width of the UI box showing current mode - const MAX_WIDTH: usize = 14; + fn build_input(&self, style: StyleState, max_width: u16) -> Paragraph<'_> { let (pref, mode) = if self.switched_search_mode { (" SRCH:", self.search_mode.as_str()) } else { ("", self.search.filter_mode.as_str()) }; - let mode_width = MAX_WIDTH - pref.len(); + let mode_width = usize::from(max_width) - pref.len(); // sanity check to ensure we don't exceed the layout limits debug_assert!(mode_width >= mode.len(), "mode name '{mode}' is too long!"); let input = format!("[{pref}{mode:^mode_width$}] {}", self.search.input.as_str(),); @@ -1260,9 +1397,7 @@ pub async fn history( filter_mode: settings .filter_mode_shell_up_key_binding .filter(|_| settings.shell_up_key_binding) - .or_else(|| Some(settings.default_filter_mode())) - .filter(|&x| x != FilterMode::Workspace || context.git_root.is_some()) - .unwrap_or(FilterMode::Global), + .unwrap_or_else(|| settings.default_filter_mode(context.git_root.is_some())), context, }, engine: engines::engine(search_mode), @@ -1280,6 +1415,7 @@ pub async fn history( Box::new(OffsetDateTime::now_utc) }, prefix: false, + pending_vim_key: None, }; app.initialize_keymap_cursor(settings); @@ -1355,7 +1491,9 @@ pub async fn history( } } update_needed = &mut update_needed => { - app.update_needed = update_needed?; + // Don't fail interactive search if update check fails + // The update check is a nice-to-have feature, not critical + app.update_needed = update_needed.ok().flatten(); } } @@ -1666,6 +1804,7 @@ mod tests { prefix: false, current_cursor: None, tab_index: 0, + pending_vim_key: None, inspecting_state: InspectingState { current: None, next: None, @@ -1717,6 +1856,7 @@ mod tests { prefix: false, current_cursor: None, tab_index: 0, + pending_vim_key: None, inspecting_state: InspectingState { current: None, next: None, @@ -1744,42 +1884,62 @@ mod tests { "Tab should always accept" ); - // Test left arrow with accept_past_line_start disabled (should continue) + // Test left arrow with empty search should accept (new default behavior) let left_event = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE); let result = state.handle_key_input(&settings, &left_event); assert!( - matches!(result, super::InputAction::Continue), - "Left arrow should continue when disabled" + matches!(result, super::InputAction::Accept(_)), + "Left arrow should accept when search is empty" ); - // Test left arrow with accept_past_line_start enabled (should accept at start of line) - settings.keys.accept_past_line_start = true; - let result = state.handle_key_input(&settings, &left_event); + // Test backspace with empty search should accept (new default behavior) + let backspace_event = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE); + let result = state.handle_key_input(&settings, &backspace_event); assert!( matches!(result, super::InputAction::Accept(_)), - "Left arrow should accept at start of line when enabled" + "Backspace should accept when search is empty" + ); + + // Test left/backspace with non-empty search at cursor start should NOT accept + state.search.input.insert('t'); + state.search.input.insert('e'); + state.search.input.insert('s'); + state.search.input.insert('t'); + state.search.input.start(); // Move cursor to start of non-empty search + + let left_event = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE); + let result = state.handle_key_input(&settings, &left_event); + assert!( + matches!(result, super::InputAction::Continue), + "Left arrow should continue when search is not empty (even at cursor start)" ); - settings.keys.accept_past_line_start = false; let backspace_event = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE); let result = state.handle_key_input(&settings, &backspace_event); assert!( matches!(result, super::InputAction::Continue), - "Backspace should continue when disabled" + "Backspace should continue when search is not empty (even at cursor start)" ); + // Test that accept_past_line_start flag still works with non-empty search at start + settings.keys.accept_past_line_start = true; + let result = state.handle_key_input(&settings, &left_event); + assert!( + matches!(result, super::InputAction::Accept(_)), + "Left arrow should accept at cursor start when flag enabled (even with non-empty search)" + ); + settings.keys.accept_past_line_start = false; + + // Test that accept_with_backspace flag still works with non-empty search at start settings.keys.accept_with_backspace = true; let result = state.handle_key_input(&settings, &backspace_event); assert!( matches!(result, super::InputAction::Accept(_)), - "Backspace should accept at start of line when enabled" + "Backspace should accept at cursor start when flag enabled (even with non-empty search)" ); + settings.keys.accept_with_backspace = false; - state.search.input.insert('t'); - state.search.input.insert('e'); - state.search.input.insert('s'); - state.search.input.insert('t'); - state.search.input.end(); + state.search.input.end(); // Move cursor back to end for remaining tests let right_event = KeyEvent::new(KeyCode::Right, KeyModifiers::NONE); let result = state.handle_key_input(&settings, &right_event); @@ -1806,4 +1966,274 @@ mod tests { ); settings.keys.accept_with_backspace = false; } + + #[test] + fn test_vim_gg_multikey_sequence() { + use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + let settings = Settings::utc(); + + let mut state = State { + history_count: 100, + update_needed: None, + results_state: ListState::default(), + switched_search_mode: false, + search_mode: SearchMode::Fuzzy, + results_len: 100, + accept: false, + keymap_mode: KeymapMode::VimNormal, + prefix: false, + current_cursor: None, + tab_index: 0, + pending_vim_key: None, + inspecting_state: InspectingState { + current: None, + next: None, + previous: None, + }, + search: SearchState { + input: String::new().into(), + filter_mode: FilterMode::Global, + context: Context { + session: String::new(), + cwd: String::new(), + hostname: String::new(), + host_id: String::new(), + git_root: None, + }, + }, + engine: engines::engine(SearchMode::Fuzzy), + now: Box::new(OffsetDateTime::now_utc), + }; + + // Start in the middle of the list + state.results_state.select(50); + + // First 'g' should set pending state + let g_event = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE); + let result = state.handle_key_input(&settings, &g_event); + assert!(matches!(result, super::InputAction::Continue)); + assert_eq!(state.pending_vim_key, Some('g')); + assert_eq!(state.results_state.selected(), 50); // Position unchanged + + // Second 'g' should jump to end (visual top in non-inverted mode) + let result = state.handle_key_input(&settings, &g_event); + assert!(matches!(result, super::InputAction::Continue)); + assert_eq!(state.pending_vim_key, None); + assert_eq!(state.results_state.selected(), 99); // Jumped to last index (visual top) + } + + #[test] + fn test_vim_g_key_clears_on_other_input() { + use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + let settings = Settings::utc(); + + let mut state = State { + history_count: 100, + update_needed: None, + results_state: ListState::default(), + switched_search_mode: false, + search_mode: SearchMode::Fuzzy, + results_len: 100, + accept: false, + keymap_mode: KeymapMode::VimNormal, + prefix: false, + current_cursor: None, + tab_index: 0, + pending_vim_key: None, + inspecting_state: InspectingState { + current: None, + next: None, + previous: None, + }, + search: SearchState { + input: String::new().into(), + filter_mode: FilterMode::Global, + context: Context { + session: String::new(), + cwd: String::new(), + hostname: String::new(), + host_id: String::new(), + git_root: None, + }, + }, + engine: engines::engine(SearchMode::Fuzzy), + now: Box::new(OffsetDateTime::now_utc), + }; + + state.results_state.select(50); + + // Press 'g' to set pending state + let g_event = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE); + state.handle_key_input(&settings, &g_event); + assert_eq!(state.pending_vim_key, Some('g')); + + // Press 'j' - should clear pending state + let j_event = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE); + state.handle_key_input(&settings, &j_event); + assert_eq!(state.pending_vim_key, None); + } + + #[test] + fn test_vim_big_g_jump_to_bottom() { + use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + let settings = Settings::utc(); + + let mut state = State { + history_count: 100, + update_needed: None, + results_state: ListState::default(), + switched_search_mode: false, + search_mode: SearchMode::Fuzzy, + results_len: 100, + accept: false, + keymap_mode: KeymapMode::VimNormal, + prefix: false, + current_cursor: None, + tab_index: 0, + pending_vim_key: None, + inspecting_state: InspectingState { + current: None, + next: None, + previous: None, + }, + search: SearchState { + input: String::new().into(), + filter_mode: FilterMode::Global, + context: Context { + session: String::new(), + cwd: String::new(), + hostname: String::new(), + host_id: String::new(), + git_root: None, + }, + }, + engine: engines::engine(SearchMode::Fuzzy), + now: Box::new(OffsetDateTime::now_utc), + }; + + state.results_state.select(50); + + // 'G' should jump to visual bottom (index 0 in non-inverted mode) + let big_g_event = KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE); + let result = state.handle_key_input(&settings, &big_g_event); + assert!(matches!(result, super::InputAction::Continue)); + assert_eq!(state.results_state.selected(), 0); + } + + #[test] + fn test_vim_ctrl_u_d_half_page_scroll() { + use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + let settings = Settings::utc(); + + let mut state = State { + history_count: 100, + update_needed: None, + results_state: ListState::default(), + switched_search_mode: false, + search_mode: SearchMode::Fuzzy, + results_len: 100, + accept: false, + keymap_mode: KeymapMode::VimNormal, + prefix: false, + current_cursor: None, + tab_index: 0, + pending_vim_key: None, + inspecting_state: InspectingState { + current: None, + next: None, + previous: None, + }, + search: SearchState { + input: String::new().into(), + filter_mode: FilterMode::Global, + context: Context { + session: String::new(), + cwd: String::new(), + hostname: String::new(), + host_id: String::new(), + git_root: None, + }, + }, + engine: engines::engine(SearchMode::Fuzzy), + now: Box::new(OffsetDateTime::now_utc), + }; + + state.results_state.select(50); + + // Ctrl+d should return Continue and clear pending key + // (scroll amount depends on max_entries which is 0 in tests) + state.pending_vim_key = Some('g'); + let ctrl_d_event = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL); + let result = state.handle_key_input(&settings, &ctrl_d_event); + assert!(matches!(result, super::InputAction::Continue)); + assert_eq!(state.pending_vim_key, None); + + // Ctrl+u should return Continue and clear pending key + state.pending_vim_key = Some('g'); + let ctrl_u_event = KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL); + let result = state.handle_key_input(&settings, &ctrl_u_event); + assert!(matches!(result, super::InputAction::Continue)); + assert_eq!(state.pending_vim_key, None); + } + + #[test] + fn test_vim_ctrl_f_b_full_page_scroll() { + use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + let settings = Settings::utc(); + + let mut state = State { + history_count: 100, + update_needed: None, + results_state: ListState::default(), + switched_search_mode: false, + search_mode: SearchMode::Fuzzy, + results_len: 100, + accept: false, + keymap_mode: KeymapMode::VimNormal, + prefix: false, + current_cursor: None, + tab_index: 0, + pending_vim_key: None, + inspecting_state: InspectingState { + current: None, + next: None, + previous: None, + }, + search: SearchState { + input: String::new().into(), + filter_mode: FilterMode::Global, + context: Context { + session: String::new(), + cwd: String::new(), + hostname: String::new(), + host_id: String::new(), + git_root: None, + }, + }, + engine: engines::engine(SearchMode::Fuzzy), + now: Box::new(OffsetDateTime::now_utc), + }; + + state.results_state.select(50); + + // Ctrl+f should return Continue and clear pending key + // (scroll amount depends on max_entries which is 0 in tests) + state.pending_vim_key = Some('g'); + let ctrl_f_event = KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL); + let result = state.handle_key_input(&settings, &ctrl_f_event); + assert!(matches!(result, super::InputAction::Continue)); + assert_eq!(state.pending_vim_key, None); + + // Ctrl+b should return Continue and clear pending key + state.pending_vim_key = Some('g'); + let ctrl_b_event = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL); + let result = state.handle_key_input(&settings, &ctrl_b_event); + assert!(matches!(result, super::InputAction::Continue)); + assert_eq!(state.pending_vim_key, None); + } } diff --git a/crates/atuin/src/command/client/wrapped.rs b/crates/atuin/src/command/client/wrapped.rs index 12357ece..ad578f7b 100644 --- a/crates/atuin/src/command/client/wrapped.rs +++ b/crates/atuin/src/command/client/wrapped.rs @@ -3,7 +3,11 @@ use eyre::Result; use std::collections::{HashMap, HashSet}; use time::{Date, Duration, Month, OffsetDateTime, Time}; -use atuin_client::{database::Database, settings::Settings, theme::Theme}; +use atuin_client::{ + database::Database, encryption, record::sqlite_store::SqliteStore, settings::Settings, + theme::Theme, +}; +use atuin_dotfiles::store::AliasStore; use atuin_history::stats::{Stats, compute}; @@ -20,7 +24,26 @@ struct WrappedStats { impl WrappedStats { #[allow(clippy::too_many_lines, clippy::cast_precision_loss)] - fn new(settings: &Settings, stats: &Stats, history: &[atuin_client::history::History]) -> Self { + fn new( + settings: &Settings, + stats: &Stats, + history: &[atuin_client::history::History], + alias_map: &HashMap<String, String>, + ) -> Self { + // Helper to expand alias to its first command word + let expand_alias = |cmd: &str| -> String { + alias_map.get(cmd).map_or_else( + || cmd.to_string(), + |expanded| { + expanded + .split_whitespace() + .next() + .unwrap_or(cmd) + .to_string() + }, + ) + }; + let nav_commands = stats .top .iter() @@ -96,12 +119,13 @@ impl WrappedStats { let mut hours: HashMap<String, usize> = HashMap::new(); for entry in history { - let cmd = entry + let raw_cmd = entry .command .split_whitespace() .next() .unwrap_or("") .to_string(); + let cmd = expand_alias(&raw_cmd); let (total, errors) = command_errors.entry(cmd.clone()).or_insert((0, 0)); *total += 1; if entry.exit != 0 { @@ -266,6 +290,7 @@ pub async fn run( year: Option<i32>, db: &impl Database, settings: &Settings, + store: SqliteStore, theme: &Theme, ) -> Result<()> { let now = OffsetDateTime::now_utc().to_offset(settings.timezone.0); @@ -299,9 +324,30 @@ pub async fn run( return Ok(()); } + // Load aliases for expansion + let alias_map: HashMap<String, String> = if settings.dotfiles.enabled { + if let Ok(encryption_key) = encryption::load_key(settings) { + let encryption_key: [u8; 32] = encryption_key.into(); + let host_id = Settings::host_id().expect("failed to get host_id"); + let alias_store = AliasStore::new(store, host_id, encryption_key); + + alias_store + .aliases() + .await + .unwrap_or_default() + .into_iter() + .map(|a| (a.name, a.value)) + .collect() + } else { + HashMap::new() + } + } else { + HashMap::new() + }; + // Compute overall stats using existing functionality let stats = compute(settings, &history, 10, 1).expect("Failed to compute stats"); - let wrapped_stats = WrappedStats::new(settings, &stats, &history); + let wrapped_stats = WrappedStats::new(settings, &stats, &history, &alias_map); // Print wrapped format print_wrapped_header(year); diff --git a/crates/atuin/src/main.rs b/crates/atuin/src/main.rs index 8b6947e3..1a45988a 100644 --- a/crates/atuin/src/main.rs +++ b/crates/atuin/src/main.rs @@ -2,6 +2,8 @@ #![allow(clippy::use_self, clippy::missing_const_for_fn)] // not 100% reliable use clap::Parser; +use clap::builder::Styles; +use clap::builder::styling::{AnsiColor, Effects}; use eyre::Result; use command::AtuinCmd; @@ -26,6 +28,12 @@ static HELP_TEMPLATE: &str = "\ {all-args}{after-help}"; +const STYLES: Styles = Styles::styled() + .header(AnsiColor::Yellow.on_default().effects(Effects::BOLD)) + .usage(AnsiColor::Green.on_default().effects(Effects::BOLD)) + .literal(AnsiColor::Green.on_default().effects(Effects::BOLD)) + .placeholder(AnsiColor::Green.on_default()); + /// Magical shell history #[derive(Parser)] #[command( @@ -33,6 +41,7 @@ static HELP_TEMPLATE: &str = "\ version = VERSION, long_version = LONG_VERSION, help_template(HELP_TEMPLATE), + styles = STYLES, )] struct Atuin { #[command(subcommand)] diff --git a/crates/atuin/src/shell/atuin.bash b/crates/atuin/src/shell/atuin.bash index 26d63d85..88b6af3a 100644 --- a/crates/atuin/src/shell/atuin.bash +++ b/crates/atuin/src/shell/atuin.bash @@ -312,7 +312,7 @@ __atuin_initialize_blesh() { # 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) + 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" } @@ -377,16 +377,16 @@ __atuin_widget_run() { # 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. For this, we consider -# the key sequences of the form \e[0;<m>A. This is a variant of the key -# sequences for the [up] key. A single [up] keypress is usually transmitted as -# \e[A in the input stream, but it switches to the form \e[<n>;<m>A in the -# presence of modifier keys (such as Control or Shift), where <m> represents -# the 1 + (modifier flags) and <n> represents the number of [up] keypresses. -# The number <n> is fixed to be 1 in the input stream, so we may use <n> = 0 -# (which is unlikely be used) as our special key sequences. +# sequences that are unlikely to conflict with others. In addition, we want to +# avoid a key sequence containing \e because keymap "vi-insert" stops +# processing key sequences containing \e in older versions of Bash. We have +# used \e[0;<m>A (a variant of the [up] key with modifier <m>) in Atuin 3.10.0 +# for intermediate key sequences, but this contains \e and caused a problem. +# Instead, we use \C-x\C-_A<n>\a, which starts with \C-x\C-_ (an unlikely +# two-byte combination) and A (represents the initial letter of Atuin), +# followed by the payload <n> and the terminator \a (BEL, \C-g). -__atuin_macro_chain='\e[0;0A' +__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 @@ -394,6 +394,7 @@ unset -v __atuin_keymap if ((BASH_VERSINFO[0] >= 5 || BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 3)); then # In Bash >= 4.3 + __atuin_macro_accept_line=accept-line __atuin_bind_impl() { @@ -408,9 +409,20 @@ if ((BASH_VERSINFO[0] >= 5 || BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 3)); local REPLY __atuin_widget_save "$keymap:$command" local widget=$REPLY - local ikeyseq1='\e[0;'$((1 + widget))'A' + local ikeyseq1='\C-x\C-_A'$((1 + widget))'\a' local ikeyseq2=$__atuin_macro_chain + if ((BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] == 1)); then + # Workaround for Bash 5.1: Bash 5.1 has a bug that overwriting an + # existing "bind -x" keybinding breaks other existing "bind -x" + # keybindings [1,2]. To work around the problem, we explicitly + # unbind an existing keybinding before overwriting it. + # + # [1] https://lists.gnu.org/archive/html/bug-bash/2021-04/msg00135.html + # [2] https://github.com/atuinsh/atuin/issues/962#issuecomment-3451132291 + bind -m "$keymap" -r "$keyseq" + fi + bind -m "$keymap" "\"$keyseq\": \"$ikeyseq1$ikeyseq2\"" bind -m "$keymap" -x "\"$ikeyseq1\": __atuin_widget_run $widget" } @@ -489,21 +501,33 @@ else # `shell-expand-line'. # # Note: Concerning the key sequences to invoke bindable functions - # such as "\e[0;1A", another option is to use + # 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 \e[0;1A. + # 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" '"\e[0;1A": beginning-of-line' - bind -m "$__atuin_keymap" '"\e[0;2A": kill-line' - bind -m "$__atuin_keymap" '"\e[0;3A": shell-expand-line' - bind -m "$__atuin_keymap" '"\e[0;4A": accept-line' + 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 - # shellcheck disable=SC2016 - __atuin_macro_accept_line='"\e[0;1A\e[0;2A$READLINE_LINE\e[0;3A\e[0;4A"' - # shellcheck disable=SC2016 - __atuin_macro_insert_line='"\e[0;1A\e[0;2A$READLINE_LINE\e[0;3A"' + + bind -m vi-command '"\C-x\C-_A7\a": vi-insertion-mode' + bind -m vi-insert '"\C-x\C-_A7\a": vi-movement-mode' + + # "\C-x\C-_A10\a": Replace the command line with READLINE_LINE. When we are + # in the vi-command keymap, we go to vi-insert, input + # "$READLINE_LINE", and come back to vi-command. + bind -m emacs '"\C-x\C-_A10\a": "\C-x\C-_A1\a\C-x\C-_A2\a\C-x\C-_A3\a\C-x\C-_A4\a"' + bind -m vi-insert '"\C-x\C-_A10\a": "\C-x\C-_A1\a\C-x\C-_A2\a\C-x\C-_A3\a\C-x\C-_A4\a"' + bind -m vi-command '"\C-x\C-_A10\a": "\C-x\C-_A1\a\C-x\C-_A2\a\C-x\C-_A7\a\C-x\C-_A3\a\C-x\C-_A7\a\C-x\C-_A4\a"' + + __atuin_macro_accept_line='"\C-x\C-_A10\a\C-x\C-_A5\a"' + __atuin_macro_insert_line='"\C-x\C-_A10\a\C-x\C-_A6\a"' fi __atuin_bash42_dispatch_selector= diff --git a/crates/atuin/src/shell/atuin.ps1 b/crates/atuin/src/shell/atuin.ps1 index f1caee86..37753f8a 100644 --- a/crates/atuin/src/shell/atuin.ps1 +++ b/crates/atuin/src/shell/atuin.ps1 @@ -10,8 +10,13 @@ # It is initialized from the current prompt line count if not set when the first Atuin search is performed. if (Get-Module Atuin -ErrorAction Ignore) { - Write-Warning "The Atuin module is already loaded." - return + if ($PSVersionTable.PSVersion.Major -ge 7) { + Write-Warning "The Atuin module is already loaded, replacing it." + Remove-Module Atuin + } else { + Write-Warning "The Atuin module is already loaded, skipping." + return + } } if (!(Get-Command atuin -ErrorAction Ignore)) { @@ -33,6 +38,19 @@ New-Module -Name Atuin -ScriptBlock { # The ReadLine overloads changed with breaking changes over time, make sure the one we expect is available. $script:hasExpectedReadLineOverload = ([Microsoft.PowerShell.PSConsoleReadLine]::ReadLine).OverloadDefinitions.Contains("static string ReadLine(runspace runspace, System.Management.Automation.EngineIntrinsics engineIntrinsics, System.Threading.CancellationToken cancellationToken, System.Nullable[bool] lastRunStatus)") + function Get-CommandLine { + $commandLine = "" + [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$commandLine, [ref]$null) + return $commandLine + } + + function Set-CommandLine { + param([string]$Text) + + $commandLine = Get-CommandLine + [Microsoft.PowerShell.PSConsoleReadLine]::Replace(0, $commandLine.Length, $Text) + } + # This function name is called by PSReadLine to read the next command line to execute. # We replace it with a custom implementation which adds Atuin support. function PSConsoleHostReadLine { @@ -47,14 +65,32 @@ New-Module -Name Atuin -ScriptBlock { ## 2. Report the status of the previous command to Atuin (atuin history end). if ($script:atuinHistoryId) { - # The duration is not recorded in old PowerShell versions, let Atuin handle it. $null arguments are ignored. - $duration = (Get-History -Count 1).Duration.Ticks * 100 - $durationArg = if ($duration) { "--duration=$duration" } else { $null } + try { + # The duration is not recorded in old PowerShell versions, let Atuin handle it. $null arguments are ignored. + $duration = (Get-History -Count 1).Duration.Ticks * 100 + $durationArg = if ($duration) { "--duration=$duration" } else { $null } - atuin history end --exit=$exitCode $durationArg -- $script:atuinHistoryId | Out-Null - - $global:LASTEXITCODE = $exitCode - $script:atuinHistoryId = $null + # Fire and forget the atuin history end command to avoid blocking the shell during a potential sync. + $process = New-Object System.Diagnostics.Process + $process.StartInfo.FileName = "atuin" + $process.StartInfo.Arguments = "history end --exit=$exitCode $durationArg -- $script:atuinHistoryId" + $process.StartInfo.UseShellExecute = $false + $process.StartInfo.CreateNoWindow = $true + $process.StartInfo.RedirectStandardInput = $true + $process.StartInfo.RedirectStandardOutput = $true + $process.StartInfo.RedirectStandardError = $true + $process.Start() | Out-Null + $process.StandardInput.Close() + $process.BeginOutputReadLine() + $process.BeginErrorReadLine() + } + catch { + # Ignore errors to avoid breaking the shell. + # An error would occur if the user removes atuin from the PATH, for instance. + } + finally { + $script:atuinHistoryId = $null + } } ## 3. Read the next command line to execute. @@ -79,6 +115,9 @@ New-Module -Name Atuin -ScriptBlock { $env:ATUIN_COMMAND_LINE = $line $script:atuinHistoryId = atuin history start --command-from-env } + catch { + # Ignore errors to avoid breaking the shell, see above. + } finally { $env:ATUIN_COMMAND_LINE = $null } @@ -95,12 +134,9 @@ New-Module -Name Atuin -ScriptBlock { try { [System.Console]::OutputEncoding = [System.Text.Encoding]::UTF8 - $query = $null - [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$query, [ref]$null) - # Atuin is started through Start-Process to avoid interfering with the current shell. $env:ATUIN_SHELL = "powershell" - $env:ATUIN_QUERY = $query + $env:ATUIN_QUERY = Get-CommandLine $argString = "search -i --result-file ""$resultFile"" $ExtraArgs" Start-Process -PassThru -NoNewWindow -FilePath atuin -ArgumentList $argString | Wait-Process $suggestion = (Get-Content -Raw $resultFile -Encoding UTF8 | Out-String).Trim() @@ -130,12 +166,10 @@ New-Module -Name Atuin -ScriptBlock { $acceptPrefix = "__atuin_accept__:" if ( $suggestion.StartsWith($acceptPrefix)) { - [Microsoft.PowerShell.PSConsoleReadLine]::RevertLine() - [Microsoft.PowerShell.PSConsoleReadLine]::Insert($suggestion.Substring($acceptPrefix.Length)) + Set-CommandLine $suggestion.Substring($acceptPrefix.Length) [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine() } else { - [Microsoft.PowerShell.PSConsoleReadLine]::RevertLine() - [Microsoft.PowerShell.PSConsoleReadLine]::Insert($suggestion) + Set-CommandLine $suggestion } } finally { @@ -157,8 +191,7 @@ New-Module -Name Atuin -ScriptBlock { if ($UpArrow) { Set-PSReadLineKeyHandler -Chord "UpArrow" -BriefDescription "Runs Atuin search" -ScriptBlock { - $line = $null - [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$line, [ref]$null) + $line = Get-CommandLine if (!$line.Contains("`n")) { Invoke-AtuinSearch -ExtraArgs "--shell-up-key-binding" diff --git a/crates/atuin/tests/common/mod.rs b/crates/atuin/tests/common/mod.rs index d79c13d6..6cc4e443 100644 --- a/crates/atuin/tests/common/mod.rs +++ b/crates/atuin/tests/common/mod.rs @@ -30,15 +30,18 @@ pub async fn start_server(path: &str) -> (String, oneshot::Sender<()>, JoinHandl host: "127.0.0.1".to_owned(), port: 0, path: path.to_owned(), + sync_v1_enabled: true, open_registration: true, max_history_length: 8192, max_record_size: 1024 * 1024 * 1024, page_size: 1100, register_webhook_url: None, register_webhook_username: String::new(), - db_settings: DbSettings { db_uri }, + db_settings: DbSettings { + db_uri: db_uri, + read_db_uri: None, + }, metrics: atuin_server::settings::Metrics::default(), - tls: atuin_server::settings::Tls::default(), mail: atuin_server::settings::Mail::default(), fake_version: None, }; |
