aboutsummaryrefslogtreecommitdiffstats
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to 'crates')
-rw-r--r--crates/atuin-client/Cargo.toml2
-rw-r--r--crates/atuin-client/config.toml49
-rw-r--r--crates/atuin-client/src/import/replxx.rs19
-rw-r--r--crates/atuin-client/src/import/zsh.rs4
-rw-r--r--crates/atuin-client/src/secrets.rs2
-rw-r--r--crates/atuin-client/src/settings.rs252
-rw-r--r--crates/atuin-common/src/api.rs1
-rw-r--r--crates/atuin-daemon/Cargo.toml6
-rw-r--r--crates/atuin-dotfiles/Cargo.toml4
-rw-r--r--crates/atuin-history/Cargo.toml2
-rw-r--r--crates/atuin-kv/Cargo.toml4
-rw-r--r--crates/atuin-scripts/Cargo.toml4
-rw-r--r--crates/atuin-server-database/Cargo.toml2
-rw-r--r--crates/atuin-server-database/src/lib.rs22
-rw-r--r--crates/atuin-server-postgres/Cargo.toml6
-rw-r--r--crates/atuin-server-postgres/src/lib.rs98
-rw-r--r--crates/atuin-server-sqlite/Cargo.toml6
-rw-r--r--crates/atuin-server-sqlite/src/lib.rs11
-rw-r--r--crates/atuin-server/Cargo.toml10
-rw-r--r--crates/atuin-server/server.toml11
-rw-r--r--crates/atuin-server/src/handlers/history.rs8
-rw-r--r--crates/atuin-server/src/handlers/mod.rs5
-rw-r--r--crates/atuin-server/src/handlers/user.rs4
-rw-r--r--crates/atuin-server/src/handlers/v0/record.rs6
-rw-r--r--crates/atuin-server/src/handlers/v0/store.rs4
-rw-r--r--crates/atuin-server/src/lib.rs61
-rw-r--r--crates/atuin-server/src/metrics.rs4
-rw-r--r--crates/atuin-server/src/router.rs23
-rw-r--r--crates/atuin-server/src/settings.rs18
-rw-r--r--crates/atuin/Cargo.toml22
-rw-r--r--crates/atuin/src/command/client.rs2
-rw-r--r--crates/atuin/src/command/client/account/verify.rs2
-rw-r--r--crates/atuin/src/command/client/history.rs11
-rw-r--r--crates/atuin/src/command/client/scripts.rs2
-rw-r--r--crates/atuin/src/command/client/search.rs2
-rw-r--r--crates/atuin/src/command/client/search/engines/skim.rs2
-rw-r--r--crates/atuin/src/command/client/search/history_list.rs175
-rw-r--r--crates/atuin/src/command/client/search/inspector.rs4
-rw-r--r--crates/atuin/src/command/client/search/interactive.rs594
-rw-r--r--crates/atuin/src/command/client/wrapped.rs54
-rw-r--r--crates/atuin/src/main.rs9
-rw-r--r--crates/atuin/src/shell/atuin.bash66
-rw-r--r--crates/atuin/src/shell/atuin.ps171
-rw-r--r--crates/atuin/tests/common/mod.rs7
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,
};