aboutsummaryrefslogtreecommitdiffstats
path: root/crates/atuin-client/src
diff options
context:
space:
mode:
Diffstat (limited to 'crates/atuin-client/src')
-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
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(())
+ }
}