Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions crates/atuin-client/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,12 @@ records = true
## Default filter mode can be overridden with the filter_mode setting.
# filters = [ "global", "host", "session", "session-preload", "workspace", "directory" ]

## Use smart-case matching for search queries.
## When true (default), all-lowercase queries match case-insensitively, while queries
## containing uppercase characters match case-sensitively. Similar to fzf's --smart-case.
## Set to false to always match case-insensitively, regardless of query casing.
# smart_case = true

[tmux]
## Enable using atuin with tmux popup (requires tmux >= 3.2)
## When enabled and running inside tmux, Atuin will use a popup window for interactive search.
Expand Down
94 changes: 92 additions & 2 deletions crates/atuin-client/src/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ pub trait Database: Send + Sync + 'static {
context: &Context,
query: &str,
filter_options: OptFilters,
smart_case: bool,
) -> Result<Vec<History>>;

async fn query_history(&self, query: &str) -> Result<Vec<History>>;
Expand Down Expand Up @@ -456,6 +457,7 @@ impl Database for Sqlite {
context: &Context,
query: &str,
filter_options: OptFilters,
smart_case: bool,
) -> Result<Vec<History>> {
let mut sql = SqlBuilder::select_from("history");

Expand Down Expand Up @@ -510,8 +512,9 @@ impl Database for Sqlite {
_ => {
let mut is_or = false;
for token in QueryTokenizer::new(query) {
// TODO smart case mode could be made configurable like in fzf
let (is_glob, glob) = if token.has_uppercase() {
// Use GLOB (case-sensitive) when smart_case is enabled and the
// token contains uppercase chars, otherwise LIKE (case-insensitive).
let (is_glob, glob) = if smart_case && token.has_uppercase() {
(true, "*")
} else {
(false, "%")
Expand Down Expand Up @@ -959,6 +962,7 @@ mod test {
OptFilters {
..Default::default()
},
true, // smart_case: use default behavior in tests
)
.await?;

Expand Down Expand Up @@ -1237,6 +1241,91 @@ mod test {
.unwrap();
}

#[tokio::test(flavor = "multi_thread")]
async fn test_search_smart_case_disabled() {
let mut db = Sqlite::new("sqlite::memory:", test_local_timeout())
.await
.unwrap();
new_history_item(&mut db, "ls /home/ellie").await.unwrap();
new_history_item(&mut db, "cd /home/Ellie").await.unwrap();
new_history_item(&mut db, "/home/ellie/.bin/rustup")
.await
.unwrap();

let context = Context {
hostname: "test:host".to_string(),
session: "beepboopiamasession".to_string(),
cwd: "/home/ellie".to_string(),
host_id: "test-host".to_string(),
git_root: None,
};

// With smart_case=true (default), "Ellie" should only match the one with uppercase E
let results = db
.search(
SearchMode::Fuzzy,
FilterMode::Global,
&context,
"Ellie",
OptFilters::default(),
true,
)
.await
.unwrap();
assert_eq!(
results.len(),
1,
"smart_case=true: uppercase query should match case-sensitively"
);

// With smart_case=false, "Ellie" should match all entries containing ellie/Ellie
let results = db
.search(
SearchMode::Fuzzy,
FilterMode::Global,
&context,
"Ellie",
OptFilters::default(),
false,
)
.await
.unwrap();
assert_eq!(
results.len(),
3,
"smart_case=false: uppercase query should still match case-insensitively"
);

// Lowercase query should always match case-insensitively regardless of smart_case
let results_smart = db
.search(
SearchMode::Fuzzy,
FilterMode::Global,
&context,
"ellie",
OptFilters::default(),
true,
)
.await
.unwrap();
let results_no_smart = db
.search(
SearchMode::Fuzzy,
FilterMode::Global,
&context,
"ellie",
OptFilters::default(),
false,
)
.await
.unwrap();
assert_eq!(
results_smart.len(),
results_no_smart.len(),
"lowercase queries should behave identically regardless of smart_case"
);
}

#[tokio::test(flavor = "multi_thread")]
async fn test_paged_basic() {
let mut db = Sqlite::new("sqlite::memory:", test_local_timeout())
Expand Down Expand Up @@ -1385,6 +1474,7 @@ mod test {
OptFilters {
..Default::default()
},
true,
)
.await
.unwrap();
Expand Down
14 changes: 14 additions & 0 deletions crates/atuin-client/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ use serde_with::DeserializeFromStr;
use time::{OffsetDateTime, UtcOffset, format_description::FormatItem, macros::format_description};

pub const HISTORY_PAGE_SIZE: i64 = 100;

/// Serde default helper that returns `true`.
fn default_true() -> bool {
true
}
static EXAMPLE_CONFIG: &str = include_str!("../config.toml");

static DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
Expand Down Expand Up @@ -565,6 +570,14 @@ pub struct Search {
/// The overall frecency score multiplier for the search index (default: 1.0).
/// Applied after combining recency and frequency scores.
pub frecency_score_multiplier: f64,

/// Use smart-case matching for search queries (default: true).
/// When true, queries with all lowercase characters match case-insensitively,
/// while queries containing uppercase characters match case-sensitively.
/// When false, all queries match case-insensitively regardless of casing.
/// Similar to fzf's --smart-case / --no-smart-case options.
#[serde(default = "default_true")]
pub smart_case: bool,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
Expand Down Expand Up @@ -820,6 +833,7 @@ impl Default for Search {
recency_score_multiplier: 1.0,
frequency_score_multiplier: 1.0,
frecency_score_multiplier: 1.0,
smart_case: true,
}
}
}
Expand Down
23 changes: 22 additions & 1 deletion crates/atuin-daemon/src/components/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ pub struct SearchComponent {
handle: tokio::sync::RwLock<Option<DaemonHandle>>,
loader_handle: Option<tokio::task::JoinHandle<()>>,
frecency_handle: Option<tokio::task::JoinHandle<()>>,
/// Shared smart_case setting — updated on SettingsReloaded, read by the gRPC service.
smart_case: Arc<tokio::sync::RwLock<bool>>,
}

impl SearchComponent {
Expand All @@ -49,13 +51,16 @@ impl SearchComponent {
handle: tokio::sync::RwLock::new(None),
loader_handle: None,
frecency_handle: None,
// Default true (smart-case on) — overwritten when settings load.
smart_case: Arc::new(tokio::sync::RwLock::new(true)),
}
}

/// Get the gRPC service for this component.
pub fn grpc_service(&self) -> SearchServer<SearchGrpcService> {
SearchServer::new(SearchGrpcService {
index: self.index.clone(),
smart_case: self.smart_case.clone(),
})
}

Expand Down Expand Up @@ -117,10 +122,16 @@ impl Component for SearchComponent {
async fn start(&mut self, handle: DaemonHandle) -> Result<()> {
*self.handle.write().await = Some(handle.clone());

// Apply user's smart_case setting immediately so searches before
// the history loader finishes use the correct case mode.
let settings = handle.settings().await;
*self.smart_case.write().await = settings.search.smart_case;

// Spawn background task to load history into index
let index = self.index.clone();
let db = handle.history_db().clone();
let handle_for_loader = handle.clone();
let smart_case_for_loader = self.smart_case.clone();

self.loader_handle = Some(tokio::spawn(async move {
info!(
Expand All @@ -144,6 +155,7 @@ impl Component for SearchComponent {
);
// Build initial frecency map with current settings
let settings = handle_for_loader.settings().await;
*smart_case_for_loader.write().await = settings.search.smart_case;
index.read().await.rebuild_frecency(&settings.search).await;
info!("Initial frecency map built");
break;
Expand Down Expand Up @@ -244,6 +256,7 @@ impl Component for SearchComponent {
let handle_guard = self.handle.read().await;
if let Some(handle) = handle_guard.as_ref() {
let settings = handle.settings().await;
*self.smart_case.write().await = settings.search.smart_case;
self.index
.read()
.await
Expand Down Expand Up @@ -275,6 +288,7 @@ impl Component for SearchComponent {
/// The gRPC service implementation.
pub struct SearchGrpcService {
index: Arc<RwLock<SearchIndex>>,
smart_case: Arc<tokio::sync::RwLock<bool>>,
}

#[tonic::async_trait]
Expand All @@ -288,6 +302,7 @@ impl SearchSvc for SearchGrpcService {
) -> Result<Response<Self::SearchStream>, Status> {
let mut in_stream = request.into_inner();
let index = self.index.clone();
let smart_case = self.smart_case.clone();

// Create output channel
let (tx, rx) = tokio::sync::mpsc::channel::<Result<SearchResponse, Status>>(128);
Expand Down Expand Up @@ -332,7 +347,13 @@ impl SearchSvc for SearchGrpcService {
.in_scope(|| async {
let index = index.read().await;
index
.search(&query, index_filter, &query_context, RESULTS_LIMIT)
.search(
&query,
index_filter,
&query_context,
RESULTS_LIMIT,
*smart_case.read().await,
)
.await
})
.await;
Expand Down
90 changes: 88 additions & 2 deletions crates/atuin-daemon/src/search/index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,7 @@ impl SearchIndex {
filter_mode: IndexFilterMode,
_context: &QueryContext,
limit: u32,
smart_case: bool,
) -> Vec<String> {
let mut nucleo = self.nucleo.write().await;

Expand All @@ -352,10 +353,15 @@ impl SearchIndex {
nucleo.set_scorer(scorer);

// Update pattern
let case_matching = if smart_case {
pattern::CaseMatching::Smart
} else {
pattern::CaseMatching::Ignore
};
nucleo.pattern.reparse(
0,
query,
pattern::CaseMatching::Smart,
case_matching,
pattern::Normalization::Smart,
false,
);
Expand Down Expand Up @@ -661,7 +667,13 @@ mod tests {

// Search for "git" - should match 2 commands
let results = index
.search("git", IndexFilterMode::Global, &QueryContext::default(), 10)
.search(
"git",
IndexFilterMode::Global,
&QueryContext::default(),
10,
true,
)
.await;
assert_eq!(results.len(), 2);

Expand All @@ -672,8 +684,82 @@ mod tests {
IndexFilterMode::Directory(with_trailing_slash("/home/user/project")),
&QueryContext::default(),
10,
true,
)
.await;
assert_eq!(results.len(), 2); // git status and git commit
}

#[tokio::test]
async fn search_index_smart_case() {
let index = SearchIndex::new();

let h1 = make_history(
"Git status",
"/home/user/project",
datetime!(2024-01-01 10:00 UTC),
);
let h2 = make_history(
"git commit -m 'test'",
"/home/user/project",
datetime!(2024-01-01 10:05 UTC),
);
let h3 = make_history(
"GIT PUSH",
"/home/user/project",
datetime!(2024-01-01 10:10 UTC),
);

index.add_history(&h1);
index.add_history(&h2);
index.add_history(&h3);

// smart_case=true: uppercase query "Git" should be case-sensitive,
// matching only "Git status" (not "git commit" or "GIT PUSH")
let results = index
.search(
"Git",
IndexFilterMode::Global,
&QueryContext::default(),
10,
true,
)
.await;
assert_eq!(results.len(), 1);

// smart_case=false: same uppercase query "Git" should match
// case-insensitively, finding all three commands
let results = index
.search(
"Git",
IndexFilterMode::Global,
&QueryContext::default(),
10,
false,
)
.await;
assert_eq!(results.len(), 3);

// lowercase query matches all regardless of smart_case setting
let results_smart = index
.search(
"git",
IndexFilterMode::Global,
&QueryContext::default(),
10,
true,
)
.await;
let results_ignore = index
.search(
"git",
IndexFilterMode::Global,
&QueryContext::default(),
10,
false,
)
.await;
assert_eq!(results_smart.len(), 3);
assert_eq!(results_ignore.len(), 3);
}
}
Loading
Loading