diff options
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/atuin-client/src/database.rs | 181 | ||||
| -rw-r--r-- | crates/atuin-client/src/settings.rs | 2 | ||||
| -rw-r--r-- | crates/atuin/src/command/client/search/engines/db.rs | 57 | ||||
| -rw-r--r-- | crates/atuin/src/command/client/search/history_list.rs | 10 | ||||
| -rw-r--r-- | crates/atuin/src/command/client/search/interactive.rs | 33 |
5 files changed, 206 insertions, 77 deletions
diff --git a/crates/atuin-client/src/database.rs b/crates/atuin-client/src/database.rs index 82644182..408e8e52 100644 --- a/crates/atuin-client/src/database.rs +++ b/crates/atuin-client/src/database.rs @@ -1,5 +1,4 @@ use std::{ - borrow::Cow, env, path::{Path, PathBuf}, str::FromStr, @@ -483,76 +482,47 @@ impl Database for Sqlite { SearchMode::Prefix => sql.and_where_like_left("command", query.replace('*', "%")), _ => { let mut is_or = false; - let mut regex = None; - for part in query.split_inclusive(' ') { - let query_part: Cow<str> = match (&mut regex, part.starts_with("r/")) { - (None, false) => { - if part.trim_end().is_empty() { - continue; - } - Cow::Owned(part.trim_end().replace('*', "%")) // allow wildcard char - } - (None, true) => { - if part[2..].trim_end().ends_with('/') { - let end_pos = part.trim_end().len() - 1; - regexes.push(String::from(&part[2..end_pos])); - } else { - regex = Some(String::from(&part[2..])); - } - continue; - } - (Some(r), _) => { - if part.trim_end().ends_with('/') { - let end_pos = part.trim_end().len() - 1; - r.push_str(&part.trim_end()[..end_pos]); - regexes.push(regex.take().unwrap()); - } else { - r.push_str(part); - } - continue; - } - }; - + for token in QueryTokenizer::new(query) { // TODO smart case mode could be made configurable like in fzf - let (is_glob, glob) = if query_part.contains(char::is_uppercase) { + let (is_glob, glob) = if token.has_uppercase() { (true, "*") } else { (false, "%") }; - - let (is_inverse, query_part) = match query_part.strip_prefix('!') { - Some(stripped) => (true, Cow::Borrowed(stripped)), - None => (false, query_part), - }; - - #[allow(clippy::if_same_then_else)] - let param = if query_part == "|" { - if !is_or { - is_or = true; + let param = match token { + QueryToken::Regex(r) => { + regexes.push(String::from(r)); continue; - } else { - format!("{glob}|{glob}") } - } else if let Some(term) = query_part.strip_prefix('^') { - format!("{term}{glob}") - } else if let Some(term) = query_part.strip_suffix('$') { - format!("{glob}{term}") - } else if let Some(term) = query_part.strip_prefix('\'') { - format!("{glob}{term}{glob}") - } else if is_inverse { - format!("{glob}{query_part}{glob}") - } else if search_mode == SearchMode::FullText { - format!("{glob}{query_part}{glob}") - } else { - query_part.split("").join(glob) + QueryToken::Or => { + if !is_or { + is_or = true; + continue; + } else { + format!("{glob}|{glob}") + } + } + QueryToken::MatchStart(term, _) => { + format!("{term}{glob}") + } + QueryToken::MatchEnd(term, _) => { + format!("{glob}{term}") + } + QueryToken::MatchFull(term, _) => { + format!("{glob}{term}{glob}") + } + QueryToken::Match(term, _) => { + if search_mode == SearchMode::FullText { + format!("{glob}{term}{glob}") + } else { + term.split("").join(glob) + } + } }; - sql.fuzzy_condition("command", param, is_inverse, is_glob, is_or); + sql.fuzzy_condition("command", param, token.is_inverse(), is_glob, is_or); is_or = false; } - if let Some(r) = regex { - regexes.push(r); - } &mut sql } @@ -1206,3 +1176,94 @@ mod test { assert!(duration < Duration::from_secs(15)); } } + +pub struct QueryTokenizer<'a> { + query: &'a str, + last_pos: usize, +} + +pub enum QueryToken<'a> { + Match(&'a str, bool), + MatchStart(&'a str, bool), + MatchEnd(&'a str, bool), + MatchFull(&'a str, bool), + Or, + Regex(&'a str), +} + +impl<'a> QueryToken<'a> { + pub fn has_uppercase(&self) -> bool { + match self { + Self::Match(term, _) + | Self::MatchStart(term, _) + | Self::MatchEnd(term, _) + | Self::MatchFull(term, _) => term.contains(char::is_uppercase), + _ => false, + } + } + + pub fn is_inverse(&self) -> bool { + match self { + Self::Match(_, inv) + | Self::MatchStart(_, inv) + | Self::MatchEnd(_, inv) + | Self::MatchFull(_, inv) => *inv, + _ => false, + } + } +} + +impl<'a> QueryTokenizer<'a> { + pub fn new(query: &'a str) -> Self { + Self { query, last_pos: 0 } + } +} + +impl<'a> Iterator for QueryTokenizer<'a> { + type Item = QueryToken<'a>; + fn next(&mut self) -> Option<Self::Item> { + let remaining = &self.query[self.last_pos..]; + if remaining.is_empty() { + return None; + } + + if let Some(remaining) = remaining.strip_prefix("r/") { + let (regex, next_pos) = if let Some(end) = remaining.find("/ ") { + (&remaining[..end], self.last_pos + 2 + end + 2) + } else if let Some(remaining) = remaining.strip_suffix('/') { + (remaining, self.query.len()) + } else { + (remaining, self.query.len()) + }; + self.last_pos = next_pos; + Some(QueryToken::Regex(regex)) + } else { + let (mut part, next_pos) = if let Some(sp) = remaining.find(' ') { + (&remaining[..sp], self.last_pos + sp + 1) + } else { + (remaining, self.query.len()) + }; + self.last_pos = next_pos; + + if part == "|" { + return Some(QueryToken::Or); + } + + let mut is_inverse = false; + if let Some(s) = part.strip_prefix('!') { + part = s; + is_inverse = true; + } + let token = if let Some(s) = part.strip_prefix('^') { + QueryToken::MatchStart(s, is_inverse) + } else if let Some(s) = part.strip_suffix('$') { + QueryToken::MatchEnd(s, is_inverse) + } else if let Some(s) = part.strip_prefix('\'') { + QueryToken::MatchFull(s, is_inverse) + } else { + QueryToken::Match(part, is_inverse) + }; + Some(token) + } + } +} diff --git a/crates/atuin-client/src/settings.rs b/crates/atuin-client/src/settings.rs index bfe9278d..916172ba 100644 --- a/crates/atuin-client/src/settings.rs +++ b/crates/atuin-client/src/settings.rs @@ -479,7 +479,7 @@ impl UiColumnType { pub fn default_width(&self) -> u16 { match self { UiColumnType::Duration => 5, - UiColumnType::Time => 8, // "59m ago" with padding + UiColumnType::Time => 9, // "459ms ago" with padding UiColumnType::Datetime => 16, // "2025-01-22 14:35" UiColumnType::Directory => 20, UiColumnType::Host => 15, diff --git a/crates/atuin/src/command/client/search/engines/db.rs b/crates/atuin/src/command/client/search/engines/db.rs index 9358ee58..f0ed424e 100644 --- a/crates/atuin/src/command/client/search/engines/db.rs +++ b/crates/atuin/src/command/client/search/engines/db.rs @@ -1,7 +1,11 @@ use super::{SearchEngine, SearchState}; use async_trait::async_trait; use atuin_client::{ - database::Database, database::OptFilters, history::History, settings::SearchMode, + database::Database, + database::OptFilters, + database::{QueryToken, QueryTokenizer}, + history::History, + settings::SearchMode, }; use eyre::Result; use norm::Metric; @@ -36,6 +40,8 @@ impl SearchEngine for Search { fn get_highlight_indices(&self, command: &str, search_input: &str) -> Vec<usize> { if self.0 == SearchMode::Prefix { return vec![]; + } else if self.0 == SearchMode::FullText { + return get_highlight_indices_fulltext(command, search_input); } let mut fzf = FzfV2::new(); let mut parser = FzfParser::new(); @@ -47,3 +53,52 @@ impl SearchEngine for Search { ranges.into_iter().flatten().collect() } } + +fn get_highlight_indices_fulltext(command: &str, search_input: &str) -> Vec<usize> { + let mut ranges = vec![]; + let lower_command = command.to_ascii_lowercase(); + + for token in QueryTokenizer::new(search_input) { + let matchee = if token.has_uppercase() { + command + } else { + &lower_command + }; + + if token.is_inverse() { + continue; + } + + match token { + QueryToken::Or => {} + QueryToken::Regex(r) => { + if let Ok(re) = regex::Regex::new(r) { + for m in re.find_iter(command) { + ranges.push(m.range()); + } + } + } + QueryToken::MatchStart(term, _) => { + if matchee.starts_with(term) { + ranges.push(0..term.len()); + } + } + QueryToken::MatchEnd(term, _) => { + if matchee.ends_with(term) { + let l = matchee.len(); + ranges.push((l - term.len())..l); + } + } + QueryToken::Match(term, _) | QueryToken::MatchFull(term, _) => { + for (idx, m) in matchee.match_indices(term) { + ranges.push(idx..(idx + m.len())); + } + } + } + } + + let mut ret: Vec<_> = ranges.into_iter().flatten().collect(); + ret.sort_unstable(); + ret.dedup(); + ret +} diff --git a/crates/atuin/src/command/client/search/history_list.rs b/crates/atuin/src/command/client/search/history_list.rs index b1bf8176..565a7972 100644 --- a/crates/atuin/src/command/client/search/history_list.rs +++ b/crates/atuin/src/command/client/search/history_list.rs @@ -178,10 +178,6 @@ struct DrawState<'a> { columns: &'a [UiColumn], } -// Default prefix length for backwards compatibility (used by interactive.rs) -#[allow(clippy::cast_possible_truncation)] // we know that this is <65536 length -pub const PREFIX_LENGTH: u16 = " > 123ms 59s ago".len() as u16; - // 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 "; @@ -302,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 @@ -362,7 +360,7 @@ impl DrawState<'_> { /// 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; + 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(); diff --git a/crates/atuin/src/command/client/search/interactive.rs b/crates/atuin/src/command/client/search/interactive.rs index e28323c8..bda4873d 100644 --- a/crates/atuin/src/command/client/search/interactive.rs +++ b/crates/atuin/src/command/client/search/interactive.rs @@ -13,7 +13,7 @@ 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}, @@ -1004,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, @@ -1017,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); @@ -1031,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, )); } @@ -1146,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(),); |
