diff options
Diffstat (limited to 'crates/atuin-client/src')
| -rw-r--r-- | crates/atuin-client/src/import/replxx.rs | 19 | ||||
| -rw-r--r-- | crates/atuin-client/src/import/zsh.rs | 4 | ||||
| -rw-r--r-- | crates/atuin-client/src/secrets.rs | 2 | ||||
| -rw-r--r-- | crates/atuin-client/src/settings.rs | 252 |
4 files changed, 270 insertions, 7 deletions
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(()) + } } |
