diff options
Diffstat (limited to 'src/command/client/search')
| -rw-r--r-- | src/command/client/search/cursor.rs | 333 | ||||
| -rw-r--r-- | src/command/client/search/duration.rs | 62 | ||||
| -rw-r--r-- | src/command/client/search/engines.rs | 46 | ||||
| -rw-r--r-- | src/command/client/search/engines/db.rs | 33 | ||||
| -rw-r--r-- | src/command/client/search/engines/skim.rs | 145 | ||||
| -rw-r--r-- | src/command/client/search/history_list.rs | 183 | ||||
| -rw-r--r-- | src/command/client/search/interactive.rs | 588 |
7 files changed, 0 insertions, 1390 deletions
diff --git a/src/command/client/search/cursor.rs b/src/command/client/search/cursor.rs deleted file mode 100644 index 2bce4f37..00000000 --- a/src/command/client/search/cursor.rs +++ /dev/null @@ -1,333 +0,0 @@ -use atuin_client::settings::WordJumpMode; - -pub struct Cursor { - source: String, - index: usize, -} - -impl From<String> for Cursor { - fn from(source: String) -> Self { - Self { source, index: 0 } - } -} - -pub struct WordJumper<'a> { - word_chars: &'a str, - word_jump_mode: WordJumpMode, -} - -impl WordJumper<'_> { - fn is_word_boundary(&self, c: char, next_c: char) -> bool { - (c.is_whitespace() && !next_c.is_whitespace()) - || (!c.is_whitespace() && next_c.is_whitespace()) - || (self.word_chars.contains(c) && !self.word_chars.contains(next_c)) - || (!self.word_chars.contains(c) && self.word_chars.contains(next_c)) - } - - fn emacs_get_next_word_pos(&self, source: &str, index: usize) -> usize { - let index = (index + 1..source.len().saturating_sub(1)) - .find(|&i| self.word_chars.contains(source.chars().nth(i).unwrap())) - .unwrap_or(source.len()); - (index + 1..source.len().saturating_sub(1)) - .find(|&i| !self.word_chars.contains(source.chars().nth(i).unwrap())) - .unwrap_or(source.len()) - } - - fn emacs_get_prev_word_pos(&self, source: &str, index: usize) -> usize { - let index = (1..index) - .rev() - .find(|&i| self.word_chars.contains(source.chars().nth(i).unwrap())) - .unwrap_or(0); - (1..index) - .rev() - .find(|&i| !self.word_chars.contains(source.chars().nth(i).unwrap())) - .map_or(0, |i| i + 1) - } - - fn subl_get_next_word_pos(&self, source: &str, index: usize) -> usize { - let index = (index..source.len().saturating_sub(1)).find(|&i| { - self.is_word_boundary( - source.chars().nth(i).unwrap(), - source.chars().nth(i + 1).unwrap(), - ) - }); - if index.is_none() { - return source.len(); - } - (index.unwrap() + 1..source.len()) - .find(|&i| !source.chars().nth(i).unwrap().is_whitespace()) - .unwrap_or(source.len()) - } - - fn subl_get_prev_word_pos(&self, source: &str, index: usize) -> usize { - let index = (1..index) - .rev() - .find(|&i| !source.chars().nth(i).unwrap().is_whitespace()); - if index.is_none() { - return 0; - } - (1..index.unwrap()) - .rev() - .find(|&i| { - self.is_word_boundary( - source.chars().nth(i - 1).unwrap(), - source.chars().nth(i).unwrap(), - ) - }) - .unwrap_or(0) - } - - fn get_next_word_pos(&self, source: &str, index: usize) -> usize { - match self.word_jump_mode { - WordJumpMode::Emacs => self.emacs_get_next_word_pos(source, index), - WordJumpMode::Subl => self.subl_get_next_word_pos(source, index), - } - } - - fn get_prev_word_pos(&self, source: &str, index: usize) -> usize { - match self.word_jump_mode { - WordJumpMode::Emacs => self.emacs_get_prev_word_pos(source, index), - WordJumpMode::Subl => self.subl_get_prev_word_pos(source, index), - } - } -} - -impl Cursor { - pub fn as_str(&self) -> &str { - self.source.as_str() - } - - pub fn into_inner(self) -> String { - self.source - } - - /// Returns the string before the cursor - pub fn substring(&self) -> &str { - &self.source[..self.index] - } - - /// Returns the currently selected [`char`] - pub fn char(&self) -> Option<char> { - self.source[self.index..].chars().next() - } - - pub fn right(&mut self) { - if self.index < self.source.len() { - loop { - self.index += 1; - if self.source.is_char_boundary(self.index) { - break; - } - } - } - } - - pub fn left(&mut self) -> bool { - if self.index > 0 { - loop { - self.index -= 1; - if self.source.is_char_boundary(self.index) { - break true; - } - } - } else { - false - } - } - - pub fn next_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) { - let word_jumper = WordJumper { - word_chars, - word_jump_mode, - }; - self.index = word_jumper.get_next_word_pos(&self.source, self.index); - } - - pub fn prev_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) { - let word_jumper = WordJumper { - word_chars, - word_jump_mode, - }; - self.index = word_jumper.get_prev_word_pos(&self.source, self.index); - } - - pub fn insert(&mut self, c: char) { - self.source.insert(self.index, c); - self.index += c.len_utf8(); - } - - pub fn remove(&mut self) -> Option<char> { - if self.index < self.source.len() { - Some(self.source.remove(self.index)) - } else { - None - } - } - - pub fn remove_next_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) { - let word_jumper = WordJumper { - word_chars, - word_jump_mode, - }; - let next_index = word_jumper.get_next_word_pos(&self.source, self.index); - self.source.replace_range(self.index..next_index, ""); - } - - pub fn remove_prev_word(&mut self, word_chars: &str, word_jump_mode: WordJumpMode) { - let word_jumper = WordJumper { - word_chars, - word_jump_mode, - }; - let next_index = word_jumper.get_prev_word_pos(&self.source, self.index); - self.source.replace_range(next_index..self.index, ""); - self.index = next_index; - } - - pub fn back(&mut self) -> Option<char> { - if self.left() { - self.remove() - } else { - None - } - } - - pub fn clear(&mut self) { - self.source.clear(); - self.index = 0; - } - - pub fn end(&mut self) { - self.index = self.source.len(); - } - - pub fn start(&mut self) { - self.index = 0; - } -} - -#[cfg(test)] -mod cursor_tests { - use super::Cursor; - use super::*; - - static EMACS_WORD_JUMPER: WordJumper = WordJumper { - word_chars: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", - word_jump_mode: WordJumpMode::Emacs, - }; - - static SUBL_WORD_JUMPER: WordJumper = WordJumper { - word_chars: "./\\()\"'-:,.;<>~!@#$%^&*|+=[]{}`~?", - word_jump_mode: WordJumpMode::Subl, - }; - - #[test] - fn right() { - // ö is 2 bytes - let mut c = Cursor::from(String::from("öaöböcödöeöfö")); - let indices = [0, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18, 20, 20, 20, 20]; - for i in indices { - assert_eq!(c.index, i); - c.right(); - } - } - - #[test] - fn left() { - // ö is 2 bytes - let mut c = Cursor::from(String::from("öaöböcödöeöfö")); - c.end(); - let indices = [20, 18, 17, 15, 14, 12, 11, 9, 8, 6, 5, 3, 2, 0, 0, 0, 0]; - for i in indices { - assert_eq!(c.index, i); - c.left(); - } - } - - #[test] - fn test_emacs_get_next_word_pos() { - let s = String::from(" aaa ((()))bbb ((())) "); - let indices = [(0, 6), (3, 6), (7, 18), (19, 30)]; - for (i_src, i_dest) in indices { - assert_eq!(EMACS_WORD_JUMPER.get_next_word_pos(&s, i_src), i_dest); - } - assert_eq!(EMACS_WORD_JUMPER.get_next_word_pos("", 0), 0); - } - - #[test] - fn test_emacs_get_prev_word_pos() { - let s = String::from(" aaa ((()))bbb ((())) "); - let indices = [(30, 15), (29, 15), (15, 3), (3, 0)]; - for (i_src, i_dest) in indices { - assert_eq!(EMACS_WORD_JUMPER.get_prev_word_pos(&s, i_src), i_dest); - } - assert_eq!(EMACS_WORD_JUMPER.get_prev_word_pos("", 0), 0); - } - - #[test] - fn test_subl_get_next_word_pos() { - let s = String::from(" aaa ((()))bbb ((())) "); - let indices = [(0, 3), (1, 3), (3, 9), (9, 15), (15, 21), (21, 30)]; - for (i_src, i_dest) in indices { - assert_eq!(SUBL_WORD_JUMPER.get_next_word_pos(&s, i_src), i_dest); - } - assert_eq!(SUBL_WORD_JUMPER.get_next_word_pos("", 0), 0); - } - - #[test] - fn test_subl_get_prev_word_pos() { - let s = String::from(" aaa ((()))bbb ((())) "); - let indices = [(30, 21), (21, 15), (15, 9), (9, 3), (3, 0)]; - for (i_src, i_dest) in indices { - assert_eq!(SUBL_WORD_JUMPER.get_prev_word_pos(&s, i_src), i_dest); - } - assert_eq!(SUBL_WORD_JUMPER.get_prev_word_pos("", 0), 0); - } - - #[test] - fn pop() { - let mut s = String::from("öaöböcödöeöfö"); - let mut c = Cursor::from(s.clone()); - c.end(); - while !s.is_empty() { - let c1 = s.pop(); - let c2 = c.back(); - assert_eq!(c1, c2); - assert_eq!(s.as_str(), c.substring()); - } - let c1 = s.pop(); - let c2 = c.back(); - assert_eq!(c1, c2); - } - - #[test] - fn back() { - let mut c = Cursor::from(String::from("öaöböcödöeöfö")); - // move to ^ - for _ in 0..4 { - c.right(); - } - assert_eq!(c.substring(), "öaöb"); - assert_eq!(c.back(), Some('b')); - assert_eq!(c.back(), Some('ö')); - assert_eq!(c.back(), Some('a')); - assert_eq!(c.back(), Some('ö')); - assert_eq!(c.back(), None); - assert_eq!(c.as_str(), "öcödöeöfö"); - } - - #[test] - fn insert() { - let mut c = Cursor::from(String::from("öaöböcödöeöfö")); - // move to ^ - for _ in 0..4 { - c.right(); - } - assert_eq!(c.substring(), "öaöb"); - c.insert('ö'); - c.insert('g'); - c.insert('ö'); - c.insert('h'); - assert_eq!(c.substring(), "öaöbögöh"); - assert_eq!(c.as_str(), "öaöbögöhöcödöeöfö"); - } -} diff --git a/src/command/client/search/duration.rs b/src/command/client/search/duration.rs deleted file mode 100644 index 08dadb95..00000000 --- a/src/command/client/search/duration.rs +++ /dev/null @@ -1,62 +0,0 @@ -use core::fmt; -use std::{ops::ControlFlow, time::Duration}; - -#[allow(clippy::module_name_repetitions)] -pub fn format_duration_into(dur: Duration, f: &mut fmt::Formatter<'_>) -> fmt::Result { - fn item(unit: &'static str, value: u64) -> ControlFlow<(&'static str, u64)> { - if value > 0 { - ControlFlow::Break((unit, value)) - } else { - ControlFlow::Continue(()) - } - } - - // impl taken and modified from - // https://github.com/tailhook/humantime/blob/master/src/duration.rs#L295-L331 - // Copyright (c) 2016 The humantime Developers - fn fmt(f: Duration) -> ControlFlow<(&'static str, u64), ()> { - let secs = f.as_secs(); - let nanos = f.subsec_nanos(); - - let years = secs / 31_557_600; // 365.25d - let year_days = secs % 31_557_600; - let months = year_days / 2_630_016; // 30.44d - let month_days = year_days % 2_630_016; - let days = month_days / 86400; - let day_secs = month_days % 86400; - let hours = day_secs / 3600; - let minutes = day_secs % 3600 / 60; - let seconds = day_secs % 60; - - let millis = nanos / 1_000_000; - - // a difference from our impl than the original is that - // we only care about the most-significant segment of the duration. - // If the item call returns `Break`, then the `?` will early-return. - // This allows for a very consise impl - item("y", years)?; - item("mo", months)?; - item("d", days)?; - item("h", hours)?; - item("m", minutes)?; - item("s", seconds)?; - item("ms", u64::from(millis))?; - ControlFlow::Continue(()) - } - - match fmt(dur) { - ControlFlow::Break((unit, value)) => write!(f, "{value}{unit}"), - ControlFlow::Continue(()) => write!(f, "0s"), - } -} - -#[allow(clippy::module_name_repetitions)] -pub fn format_duration(f: Duration) -> String { - struct F(Duration); - impl fmt::Display for F { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - format_duration_into(self.0, f) - } - } - F(f).to_string() -} diff --git a/src/command/client/search/engines.rs b/src/command/client/search/engines.rs deleted file mode 100644 index 878b1431..00000000 --- a/src/command/client/search/engines.rs +++ /dev/null @@ -1,46 +0,0 @@ -use async_trait::async_trait; -use atuin_client::{ - database::{Context, Database}, - history::History, - settings::{FilterMode, SearchMode}, -}; -use eyre::Result; - -use super::cursor::Cursor; - -pub mod db; -pub mod skim; - -pub fn engine(search_mode: SearchMode) -> Box<dyn SearchEngine> { - match search_mode { - SearchMode::Skim => Box::new(skim::Search::new()) as Box<_>, - mode => Box::new(db::Search(mode)) as Box<_>, - } -} - -pub struct SearchState { - pub input: Cursor, - pub filter_mode: FilterMode, - pub context: Context, -} - -#[async_trait] -pub trait SearchEngine: Send + Sync + 'static { - async fn full_query( - &mut self, - state: &SearchState, - db: &mut dyn Database, - ) -> Result<Vec<History>>; - - async fn query(&mut self, state: &SearchState, db: &mut dyn Database) -> Result<Vec<History>> { - if state.input.as_str().is_empty() { - Ok(db - .list(state.filter_mode, &state.context, Some(200), true) - .await? - .into_iter() - .collect::<Vec<_>>()) - } else { - self.full_query(state, db).await - } - } -} diff --git a/src/command/client/search/engines/db.rs b/src/command/client/search/engines/db.rs deleted file mode 100644 index b4f24561..00000000 --- a/src/command/client/search/engines/db.rs +++ /dev/null @@ -1,33 +0,0 @@ -use async_trait::async_trait; -use atuin_client::{ - database::Database, database::OptFilters, history::History, settings::SearchMode, -}; -use eyre::Result; - -use super::{SearchEngine, SearchState}; - -pub struct Search(pub SearchMode); - -#[async_trait] -impl SearchEngine for Search { - async fn full_query( - &mut self, - state: &SearchState, - db: &mut dyn Database, - ) -> Result<Vec<History>> { - Ok(db - .search( - self.0, - state.filter_mode, - &state.context, - state.input.as_str(), - OptFilters { - limit: Some(200), - ..Default::default() - }, - ) - .await? - .into_iter() - .collect::<Vec<_>>()) - } -} diff --git a/src/command/client/search/engines/skim.rs b/src/command/client/search/engines/skim.rs deleted file mode 100644 index 76049312..00000000 --- a/src/command/client/search/engines/skim.rs +++ /dev/null @@ -1,145 +0,0 @@ -use std::path::Path; - -use async_trait::async_trait; -use atuin_client::{database::Database, history::History, settings::FilterMode}; -use chrono::Utc; -use eyre::Result; -use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher}; -use tokio::task::yield_now; - -use super::{SearchEngine, SearchState}; - -pub struct Search { - all_history: Vec<(History, i32)>, - engine: SkimMatcherV2, -} - -impl Search { - pub fn new() -> Self { - Search { - all_history: vec![], - engine: SkimMatcherV2::default(), - } - } -} - -#[async_trait] -impl SearchEngine for Search { - async fn full_query( - &mut self, - state: &SearchState, - db: &mut dyn Database, - ) -> Result<Vec<History>> { - if self.all_history.is_empty() { - self.all_history = db.all_with_count().await.unwrap(); - } - - Ok(fuzzy_search(&self.engine, state, &self.all_history).await) - } -} - -async fn fuzzy_search( - engine: &SkimMatcherV2, - state: &SearchState, - all_history: &[(History, i32)], -) -> Vec<History> { - let mut set = Vec::with_capacity(200); - let mut ranks = Vec::with_capacity(200); - let query = state.input.as_str(); - let now = Utc::now(); - - for (i, (history, count)) in all_history.iter().enumerate() { - if i % 256 == 0 { - yield_now().await; - } - match state.filter_mode { - FilterMode::Global => {} - FilterMode::Host if history.hostname == state.context.hostname => {} - FilterMode::Session if history.session == state.context.session => {} - FilterMode::Directory if history.cwd == state.context.cwd => {} - _ => continue, - } - #[allow(clippy::cast_lossless, clippy::cast_precision_loss)] - if let Some((score, indices)) = engine.fuzzy_indices(&history.command, query) { - let begin = indices.first().copied().unwrap_or_default(); - - let mut duration = ((now - history.timestamp).num_seconds() as f64).log2(); - if !duration.is_finite() || duration <= 1.0 { - duration = 1.0; - } - // these + X.0 just make the log result a bit smoother. - // log is very spiky towards 1-4, but I want a gradual decay. - // eg: - // log2(4) = 2, log2(5) = 2.3 (16% increase) - // log2(8) = 3, log2(9) = 3.16 (5% increase) - // log2(16) = 4, log2(17) = 4.08 (2% increase) - let count = (*count as f64 + 8.0).log2(); - let begin = (begin as f64 + 16.0).log2(); - let path = path_dist(history.cwd.as_ref(), state.context.cwd.as_ref()); - let path = (path as f64 + 8.0).log2(); - - // reduce longer durations, raise higher counts, raise matches close to the start - let score = (-score as f64) * count / path / duration / begin; - - 'insert: { - // algorithm: - // 1. find either the position that this command ranks - // 2. find the same command positioned better than our rank. - for i in 0..set.len() { - // do we out score the corrent position? - if ranks[i] > score { - ranks.insert(i, score); - set.insert(i, history.clone()); - let mut j = i + 1; - while j < set.len() { - // remove duplicates that have a worse score - if set[j].command == history.command { - ranks.remove(j); - set.remove(j); - - // break this while loop because there won't be any other - // duplicates. - break; - } - j += 1; - } - - // keep it limited - if ranks.len() > 200 { - ranks.pop(); - set.pop(); - } - - break 'insert; - } - // don't continue if this command has a better score already - if set[i].command == history.command { - break 'insert; - } - } - - if set.len() < 200 { - ranks.push(score); - set.push(history.clone()); - } - } - } - } - - set -} - -fn path_dist(a: &Path, b: &Path) -> usize { - let mut a: Vec<_> = a.components().collect(); - let b: Vec<_> = b.components().collect(); - - let mut dist = 0; - - // pop a until there's a common anscestor - while !b.starts_with(&a) { - dist += 1; - a.pop(); - } - - b.len() - a.len() + dist -} diff --git a/src/command/client/search/history_list.rs b/src/command/client/search/history_list.rs deleted file mode 100644 index eedab1a5..00000000 --- a/src/command/client/search/history_list.rs +++ /dev/null @@ -1,183 +0,0 @@ -use std::time::Duration; - -use crate::ratatui::{ - buffer::Buffer, - layout::Rect, - style::{Color, Modifier, Style}, - widgets::{Block, StatefulWidget, Widget}, -}; -use atuin_client::history::History; - -use super::format_duration; - -pub struct HistoryList<'a> { - history: &'a [History], - block: Option<Block<'a>>, -} - -#[derive(Default)] -pub struct ListState { - offset: usize, - selected: usize, - max_entries: usize, -} - -impl ListState { - pub fn selected(&self) -> usize { - self.selected - } - - pub fn max_entries(&self) -> usize { - self.max_entries - } - - pub fn select(&mut self, index: usize) { - self.selected = index; - } -} - -impl<'a> StatefulWidget for HistoryList<'a> { - type State = ListState; - - fn render(mut self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { - let list_area = self.block.take().map_or(area, |b| { - let inner_area = b.inner(area); - b.render(area, buf); - inner_area - }); - - if list_area.width < 1 || list_area.height < 1 || self.history.is_empty() { - return; - } - let list_height = list_area.height as usize; - - let (start, end) = self.get_items_bounds(state.selected, state.offset, list_height); - state.offset = start; - state.max_entries = end - start; - - let mut s = DrawState { - buf, - list_area, - x: 0, - y: 0, - state, - }; - - for item in self.history.iter().skip(state.offset).take(end - start) { - s.index(); - s.duration(item); - s.time(item); - s.command(item); - - // reset line - s.y += 1; - s.x = 0; - } - } -} - -impl<'a> HistoryList<'a> { - pub fn new(history: &'a [History]) -> Self { - Self { - history, - block: None, - } - } - - pub fn block(mut self, block: Block<'a>) -> Self { - self.block = Some(block); - self - } - - fn get_items_bounds(&self, selected: usize, offset: usize, height: usize) -> (usize, usize) { - let offset = offset.min(self.history.len().saturating_sub(1)); - - let max_scroll_space = height.min(10); - if offset + height < selected + max_scroll_space { - let end = selected + max_scroll_space; - (end - height, end) - } else if selected < offset { - (selected, selected + height) - } else { - (offset, offset + height) - } - } -} - -struct DrawState<'a> { - buf: &'a mut Buffer, - list_area: Rect, - x: u16, - y: u16, - state: &'a ListState, -} - -// 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; - -impl DrawState<'_> { - fn index(&mut self) { - // 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 "; - - let i = self.y as usize + self.state.offset; - let i = i.checked_sub(self.state.selected); - let i = i.unwrap_or(10).min(10) * 2; - self.draw(&SLICES[i..i + 3], Style::default()); - } - - fn duration(&mut self, h: &History) { - let status = Style::default().fg(if h.success() { - Color::Green - } else { - Color::Red - }); - let duration = Duration::from_nanos(u64::try_from(h.duration).unwrap_or(0)); - self.draw(&format_duration(duration), status); - } - - #[allow(clippy::cast_possible_truncation)] // we know that time.len() will be <6 - fn time(&mut self, h: &History) { - let style = Style::default().fg(Color::Blue); - - // Account for the chance that h.timestamp is "in the future" - // This would mean that "since" is negative, and the unwrap here - // would fail. - // If the timestamp would otherwise be in the future, display - // the time since as 0. - let since = chrono::Utc::now() - h.timestamp; - let time = format_duration(since.to_std().unwrap_or_default()); - - // pad the time a little bit before we write. this aligns things nicely - self.x = PREFIX_LENGTH - 4 - time.len() as u16; - - self.draw(&time, style); - self.draw(" ago", style); - } - - fn command(&mut self, h: &History) { - let mut style = Style::default(); - if self.y as usize + self.state.offset == self.state.selected { - style = style.fg(Color::Red).add_modifier(Modifier::BOLD); - } - - for section in h.command.split_ascii_whitespace() { - self.x += 1; - if self.x > self.list_area.width { - // Avoid attempting to draw a command section beyond the width - // of the list - return; - } - self.draw(section, style); - } - } - - fn draw(&mut self, s: &str, style: Style) { - let cx = self.list_area.left() + self.x; - let cy = self.list_area.bottom() - self.y - 1; - let w = (self.list_area.width - self.x) as usize; - self.x += self.buf.set_stringn(cx, cy, s, w, style).0 - cx; - } -} diff --git a/src/command/client/search/interactive.rs b/src/command/client/search/interactive.rs deleted file mode 100644 index 300bc791..00000000 --- a/src/command/client/search/interactive.rs +++ /dev/null @@ -1,588 +0,0 @@ -use std::{ - io::{stdout, Write}, - time::Duration, -}; - -use crossterm::{ - event::{self, Event, KeyCode, KeyEvent, KeyModifiers, MouseEvent}, - execute, terminal, -}; -use eyre::Result; -use futures_util::FutureExt; -use semver::Version; -use unicode_width::UnicodeWidthStr; - -use atuin_client::{ - database::{current_context, Database}, - history::History, - settings::{ExitMode, FilterMode, SearchMode, Settings}, -}; - -use super::{ - cursor::Cursor, - engines::{SearchEngine, SearchState}, - history_list::{HistoryList, ListState, PREFIX_LENGTH}, -}; -use crate::ratatui::{ - backend::{Backend, CrosstermBackend}, - layout::{Alignment, Constraint, Direction, Layout}, - style::{Color, Modifier, Style}, - text::{Span, Spans, Text}, - widgets::{Block, BorderType, Borders, Paragraph}, - Frame, Terminal, TerminalOptions, Viewport, -}; -use crate::{command::client::search::engines, VERSION}; - -const RETURN_ORIGINAL: usize = usize::MAX; -const RETURN_QUERY: usize = usize::MAX - 1; - -struct State { - history_count: i64, - update_needed: Option<Version>, - results_state: ListState, - switched_search_mode: bool, - search_mode: SearchMode, - - search: SearchState, - engine: Box<dyn SearchEngine>, -} - -impl State { - async fn query_results(&mut self, db: &mut dyn Database) -> Result<Vec<History>> { - let results = self.engine.query(&self.search, db).await?; - self.results_state.select(0); - Ok(results) - } - - fn handle_input(&mut self, settings: &Settings, input: &Event, len: usize) -> Option<usize> { - match input { - Event::Key(k) => self.handle_key_input(settings, k, len), - Event::Mouse(m) => self.handle_mouse_input(*m, len), - Event::Paste(d) => self.handle_paste_input(d), - _ => None, - } - } - - fn handle_mouse_input(&mut self, input: MouseEvent, len: usize) -> Option<usize> { - match input.kind { - event::MouseEventKind::ScrollDown => { - let i = self.results_state.selected().saturating_sub(1); - self.results_state.select(i); - } - event::MouseEventKind::ScrollUp => { - let i = self.results_state.selected() + 1; - self.results_state.select(i.min(len - 1)); - } - _ => {} - } - None - } - - fn handle_paste_input(&mut self, input: &str) -> Option<usize> { - for i in input.chars() { - self.search.input.insert(i); - } - None - } - - #[allow(clippy::too_many_lines)] - #[allow(clippy::cognitive_complexity)] - fn handle_key_input( - &mut self, - settings: &Settings, - input: &KeyEvent, - len: usize, - ) -> Option<usize> { - if input.kind == event::KeyEventKind::Release { - return None; - } - - let ctrl = input.modifiers.contains(KeyModifiers::CONTROL); - let alt = input.modifiers.contains(KeyModifiers::ALT); - // reset the state, will be set to true later if user really did change it - self.switched_search_mode = false; - match input.code { - KeyCode::Char('c' | 'd' | 'g') if ctrl => return Some(RETURN_ORIGINAL), - KeyCode::Esc => { - return Some(match settings.exit_mode { - ExitMode::ReturnOriginal => RETURN_ORIGINAL, - ExitMode::ReturnQuery => RETURN_QUERY, - }) - } - KeyCode::Enter => { - return Some(self.results_state.selected()); - } - KeyCode::Char(c @ '1'..='9') if alt => { - let c = c.to_digit(10)? as usize; - return Some(self.results_state.selected() + c); - } - KeyCode::Left if ctrl => self - .search - .input - .prev_word(&settings.word_chars, settings.word_jump_mode), - KeyCode::Char('b') if alt => self - .search - .input - .prev_word(&settings.word_chars, settings.word_jump_mode), - KeyCode::Left => { - self.search.input.left(); - } - KeyCode::Char('h') if ctrl => { - self.search.input.left(); - } - KeyCode::Char('b') if ctrl => { - self.search.input.left(); - } - KeyCode::Right if ctrl => self - .search - .input - .next_word(&settings.word_chars, settings.word_jump_mode), - KeyCode::Char('f') if alt => self - .search - .input - .next_word(&settings.word_chars, settings.word_jump_mode), - KeyCode::Right => self.search.input.right(), - KeyCode::Char('l') if ctrl => self.search.input.right(), - KeyCode::Char('f') if ctrl => self.search.input.right(), - KeyCode::Char('a') if ctrl => self.search.input.start(), - KeyCode::Home => self.search.input.start(), - KeyCode::Char('e') if ctrl => self.search.input.end(), - KeyCode::End => self.search.input.end(), - KeyCode::Backspace if ctrl => self - .search - .input - .remove_prev_word(&settings.word_chars, settings.word_jump_mode), - KeyCode::Backspace => { - self.search.input.back(); - } - KeyCode::Delete if ctrl => self - .search - .input - .remove_next_word(&settings.word_chars, settings.word_jump_mode), - KeyCode::Delete => { - self.search.input.remove(); - } - KeyCode::Char('w') if ctrl => { - // remove the first batch of whitespace - while matches!(self.search.input.back(), Some(c) if c.is_whitespace()) {} - while self.search.input.left() { - if self.search.input.char().unwrap().is_whitespace() { - self.search.input.right(); // found whitespace, go back right - break; - } - self.search.input.remove(); - } - } - KeyCode::Char('u') if ctrl => self.search.input.clear(), - KeyCode::Char('r') if ctrl => { - pub static FILTER_MODES: [FilterMode; 4] = [ - FilterMode::Global, - FilterMode::Host, - FilterMode::Session, - FilterMode::Directory, - ]; - let i = self.search.filter_mode as usize; - let i = (i + 1) % FILTER_MODES.len(); - self.search.filter_mode = FILTER_MODES[i]; - } - KeyCode::Char('s') if ctrl => { - self.switched_search_mode = true; - self.search_mode = self.search_mode.next(settings); - self.engine = engines::engine(self.search_mode); - } - KeyCode::Down if self.results_state.selected() == 0 => { - return Some(match settings.exit_mode { - ExitMode::ReturnOriginal => RETURN_ORIGINAL, - ExitMode::ReturnQuery => RETURN_QUERY, - }) - } - KeyCode::Down => { - let i = self.results_state.selected().saturating_sub(1); - self.results_state.select(i); - } - KeyCode::Char('n' | 'j') if ctrl => { - let i = self.results_state.selected().saturating_sub(1); - self.results_state.select(i); - } - KeyCode::Up => { - let i = self.results_state.selected() + 1; - self.results_state.select(i.min(len - 1)); - } - KeyCode::Char('p' | 'k') if ctrl => { - let i = self.results_state.selected() + 1; - self.results_state.select(i.min(len - 1)); - } - KeyCode::Char(c) => self.search.input.insert(c), - KeyCode::PageDown => { - let scroll_len = self.results_state.max_entries() - settings.scroll_context_lines; - let i = self.results_state.selected().saturating_sub(scroll_len); - self.results_state.select(i); - } - KeyCode::PageUp => { - let scroll_len = self.results_state.max_entries() - settings.scroll_context_lines; - let i = self.results_state.selected() + scroll_len; - self.results_state.select(i.min(len - 1)); - } - _ => {} - }; - - None - } - - #[allow(clippy::cast_possible_truncation)] - #[allow(clippy::bool_to_int_with_if)] - fn draw<T: Backend>( - &mut self, - f: &mut Frame<'_, T>, - results: &[History], - compact: bool, - show_preview: bool, - ) { - let border_size = if compact { 0 } else { 1 }; - let preview_width = f.size().width - 2; - let preview_height = if show_preview { - let longest_command = results - .iter() - .max_by(|h1, h2| h1.command.len().cmp(&h2.command.len())); - longest_command.map_or(0, |v| { - std::cmp::min( - 4, - (v.command.len() as u16 + preview_width - 1 - border_size) - / (preview_width - border_size), - ) - }) + border_size * 2 - } else if compact { - 0 - } else { - 1 - }; - let show_help = !compact || f.size().height > 1; - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(0) - .horizontal_margin(1) - .constraints( - [ - Constraint::Length(if show_help { 1 } else { 0 }), - Constraint::Min(1), - Constraint::Length(1 + border_size), - Constraint::Length(preview_height), - ] - .as_ref(), - ) - .split(f.size()); - - let header_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints( - [ - Constraint::Ratio(1, 3), - Constraint::Ratio(1, 3), - Constraint::Ratio(1, 3), - ] - .as_ref(), - ) - .split(chunks[0]); - - let title = self.build_title(); - f.render_widget(title, header_chunks[0]); - - let help = self.build_help(); - f.render_widget(help, header_chunks[1]); - - let stats = self.build_stats(); - f.render_widget(stats, header_chunks[2]); - - let results_list = Self::build_results_list(compact, results); - f.render_stateful_widget(results_list, chunks[1], &mut self.results_state); - - let input = self.build_input(compact, chunks[2].width.into()); - f.render_widget(input, chunks[2]); - - let preview = self.build_preview(results, compact, preview_width, chunks[3].width.into()); - f.render_widget(preview, chunks[3]); - - let extra_width = UnicodeWidthStr::width(self.search.input.substring()); - - let cursor_offset = if compact { 0 } else { 1 }; - f.set_cursor( - // Put cursor past the end of the input text - chunks[2].x + extra_width as u16 + PREFIX_LENGTH + 1 + cursor_offset, - chunks[2].y + cursor_offset, - ); - } - - fn build_title(&mut self) -> Paragraph { - let title = if self.update_needed.is_some() { - let version = self.update_needed.clone().unwrap(); - - Paragraph::new(Text::from(Span::styled( - format!(" Atuin v{VERSION} - UPDATE AVAILABLE {version}"), - Style::default().add_modifier(Modifier::BOLD).fg(Color::Red), - ))) - } else { - Paragraph::new(Text::from(Span::styled( - format!(" Atuin v{VERSION}"), - Style::default().add_modifier(Modifier::BOLD), - ))) - }; - title - } - - #[allow(clippy::unused_self)] - fn build_help(&mut self) -> Paragraph { - let help = Paragraph::new(Text::from(Spans::from(vec![ - Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(" to exit"), - ]))) - .style(Style::default().fg(Color::DarkGray)) - .alignment(Alignment::Center); - help - } - - fn build_stats(&mut self) -> Paragraph { - let stats = Paragraph::new(Text::from(Span::raw(format!( - "history count: {}", - self.history_count, - )))) - .style(Style::default().fg(Color::DarkGray)) - .alignment(Alignment::Right); - stats - } - - fn build_results_list(compact: bool, results: &[History]) -> HistoryList { - let results_list = if compact { - HistoryList::new(results) - } else { - HistoryList::new(results).block( - Block::default() - .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT) - .border_type(BorderType::Rounded), - ) - }; - results_list - } - - fn build_input(&mut self, compact: bool, chunk_width: usize) -> Paragraph { - /// Max width of the UI box showing current mode - const MAX_WIDTH: usize = 14; - 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(); - // 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(),); - let input = if compact { - Paragraph::new(input) - } else { - Paragraph::new(input).block( - Block::default() - .borders(Borders::LEFT | Borders::RIGHT) - .border_type(BorderType::Rounded) - .title(format!("{:─>width$}", "", width = chunk_width - 2)), - ) - }; - input - } - - fn build_preview( - &mut self, - results: &[History], - compact: bool, - preview_width: u16, - chunk_width: usize, - ) -> Paragraph { - let selected = self.results_state.selected(); - let command = if results.is_empty() { - String::new() - } else { - use itertools::Itertools as _; - let s = &results[selected].command; - s.char_indices() - .step_by(preview_width.into()) - .map(|(i, _)| i) - .chain(Some(s.len())) - .tuple_windows() - .map(|(a, b)| &s[a..b]) - .join("\n") - }; - let preview = if compact { - Paragraph::new(command).style(Style::default().fg(Color::DarkGray)) - } else { - Paragraph::new(command).block( - Block::default() - .borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT) - .border_type(BorderType::Rounded) - .title(format!("{:─>width$}", "", width = chunk_width - 2)), - ) - }; - preview - } -} - -struct Stdout { - stdout: std::io::Stdout, - inline_mode: bool, -} - -impl Stdout { - pub fn new(inline_mode: bool) -> std::io::Result<Self> { - terminal::enable_raw_mode()?; - let mut stdout = stdout(); - if !inline_mode { - execute!(stdout, terminal::EnterAlternateScreen)?; - } - execute!( - stdout, - event::EnableMouseCapture, - event::EnableBracketedPaste, - )?; - Ok(Self { - stdout, - inline_mode, - }) - } -} - -impl Drop for Stdout { - fn drop(&mut self) { - if !self.inline_mode { - execute!(self.stdout, terminal::LeaveAlternateScreen).unwrap(); - } - execute!( - self.stdout, - event::DisableMouseCapture, - event::DisableBracketedPaste, - ) - .unwrap(); - terminal::disable_raw_mode().unwrap(); - } -} - -impl Write for Stdout { - fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> { - self.stdout.write(buf) - } - - fn flush(&mut self) -> std::io::Result<()> { - self.stdout.flush() - } -} - -// this is a big blob of horrible! clean it up! -// for now, it works. But it'd be great if it were more easily readable, and -// modular. I'd like to add some more stats and stuff at some point -#[allow(clippy::cast_possible_truncation)] -pub async fn history( - query: &[String], - settings: &Settings, - mut db: impl Database, -) -> Result<String> { - let stdout = Stdout::new(settings.inline_height > 0)?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::with_options( - backend, - TerminalOptions { - viewport: if settings.inline_height > 0 { - Viewport::Inline(settings.inline_height) - } else { - Viewport::Fullscreen - }, - }, - )?; - - let mut input = Cursor::from(query.join(" ")); - // Put the cursor at the end of the query by default - input.end(); - - let settings2 = settings.clone(); - let update_needed = tokio::spawn(async move { settings2.needs_update().await }).fuse(); - tokio::pin!(update_needed); - - let context = current_context(); - - let history_count = db.history_count().await?; - - let mut app = State { - history_count, - results_state: ListState::default(), - update_needed: None, - switched_search_mode: false, - search_mode: settings.search_mode, - search: SearchState { - input, - context, - filter_mode: if settings.shell_up_key_binding { - settings - .filter_mode_shell_up_key_binding - .unwrap_or(settings.filter_mode) - } else { - settings.filter_mode - }, - }, - engine: engines::engine(settings.search_mode), - }; - - let mut results = app.query_results(&mut db).await?; - - let index = 'render: loop { - let compact = match settings.style { - atuin_client::settings::Style::Auto => { - terminal.size().map(|size| size.height < 14).unwrap_or(true) - } - atuin_client::settings::Style::Compact => true, - atuin_client::settings::Style::Full => false, - }; - terminal.draw(|f| app.draw(f, &results, compact, settings.show_preview))?; - - let initial_input = app.search.input.as_str().to_owned(); - let initial_filter_mode = app.search.filter_mode; - let initial_search_mode = app.search_mode; - - let event_ready = tokio::task::spawn_blocking(|| event::poll(Duration::from_millis(250))); - - tokio::select! { - event_ready = event_ready => { - if event_ready?? { - loop { - if let Some(i) = app.handle_input(settings, &event::read()?, results.len()) { - break 'render i; - } - if !event::poll(Duration::ZERO)? { - break; - } - } - } - } - update_needed = &mut update_needed => { - app.update_needed = update_needed?; - } - } - - if initial_input != app.search.input.as_str() - || initial_filter_mode != app.search.filter_mode - || initial_search_mode != app.search_mode - { - results = app.query_results(&mut db).await?; - } - }; - - if settings.inline_height > 0 { - terminal.clear()?; - } - - if index < results.len() { - // index is in bounds so we return that entry - Ok(results.swap_remove(index).command) - } else if index == RETURN_ORIGINAL { - Ok(String::new()) - } else { - // Either: - // * index == RETURN_QUERY, in which case we should return the input - // * out of bounds -> usually implies no selected entry so we return the input - Ok(app.search.input.into_inner()) - } -} |
