diff --git a/atuin-client/src/database.rs b/atuin-client/src/database.rs index 7b3ab3beb28..ba28daf3069 100644 --- a/atuin-client/src/database.rs +++ b/atuin-client/src/database.rs @@ -461,7 +461,7 @@ mod test { Some("beep boop".to_string()), Some("booop".to_string()), ); - return db.save(&history).await; + db.save(&history).await } #[tokio::test(flavor = "multi_thread")] diff --git a/atuin-client/src/settings.rs b/atuin-client/src/settings.rs index d8720574da7..f836ce022a4 100644 --- a/atuin-client/src/settings.rs +++ b/atuin-client/src/settings.rs @@ -27,16 +27,27 @@ pub enum SearchMode { #[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq)] pub enum FilterMode { #[serde(rename = "global")] - Global, + Global = 0, #[serde(rename = "host")] - Host, + Host = 1, #[serde(rename = "session")] - Session, + Session = 2, #[serde(rename = "directory")] - Directory, + Directory = 3, +} + +impl FilterMode { + pub fn as_str(&self) -> &'static str { + match self { + FilterMode::Global => "GLOBAL", + FilterMode::Host => "HOST", + FilterMode::Session => "SESSION", + FilterMode::Directory => "DIRECTORY", + } + } } // FIXME: Can use upstream Dialect enum if https://github.com/stevedonovan/chrono-english/pull/16 is merged diff --git a/src/command/client.rs b/src/command/client.rs index b9d43b399a3..ae49b857c9a 100644 --- a/src/command/client.rs +++ b/src/command/client.rs @@ -10,7 +10,6 @@ use atuin_common::utils::uuid_v4; #[cfg(feature = "sync")] mod sync; -mod event; mod history; mod import; mod init; diff --git a/src/command/client/search.rs b/src/command/client/search.rs index c50c492c3f9..7b84d410d53 100644 --- a/src/command/client/search.rs +++ b/src/command/client/search.rs @@ -1,36 +1,16 @@ -use std::{env, io::stdout, ops::Sub, time::Duration}; - use chrono::Utc; use clap::Parser; use eyre::Result; -use termion::{ - event::Event as TermEvent, event::Key, event::MouseButton, event::MouseEvent, - input::MouseTerminal, raw::IntoRawMode, screen::AlternateScreen, -}; -use tui::{ - backend::{Backend, TermionBackend}, - layout::{Alignment, Constraint, Corner, Direction, Layout}, - style::{Color, Modifier, Style}, - text::{Span, Spans, Text}, - widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, - Frame, Terminal, -}; -use unicode_width::UnicodeWidthStr; use atuin_client::{ - database::current_context, - database::Context, - database::Database, - history::History, - settings::{FilterMode, SearchMode, Settings}, + database::current_context, database::Database, history::History, settings::Settings, }; -use super::{ - event::{Event, Events}, - history::ListMode, -}; +use super::history::ListMode; -const VERSION: &str = env!("CARGO_PKG_VERSION"); +mod cursor; +mod event; +mod interactive; #[derive(Parser)] pub struct Cmd { @@ -80,7 +60,7 @@ pub struct Cmd { impl Cmd { pub async fn run(self, db: &mut impl Database, settings: &Settings) -> Result<()> { if self.interactive { - let item = select_history( + let item = interactive::history( &self.query, settings.search_mode, settings.filter_mode, @@ -110,553 +90,6 @@ impl Cmd { } } -struct State { - input: String, - - cursor_index: usize, - - filter_mode: FilterMode, - - results: Vec, - - results_state: ListState, - - context: Context, -} - -impl State { - #[allow(clippy::cast_sign_loss)] - fn durations(&self) -> Vec<(String, String)> { - self.results - .iter() - .map(|h| { - let duration = - Duration::from_millis(std::cmp::max(h.duration, 0) as u64 / 1_000_000); - let duration = humantime::format_duration(duration).to_string(); - let duration: Vec<&str> = duration.split(' ').collect(); - - let ago = chrono::Utc::now().sub(h.timestamp); - - // Account for the chance that h.timestamp is "in the future" - // This would mean that "ago" is negative, and the unwrap here - // would fail. - // If the timestamp would otherwise be in the future, display - // the time ago as 0. - let ago = humantime::format_duration( - ago.to_std().unwrap_or_else(|_| Duration::new(0, 0)), - ) - .to_string(); - let ago: Vec<&str> = ago.split(' ').collect(); - - ( - duration[0] - .to_string() - .replace("days", "d") - .replace("day", "d") - .replace("weeks", "w") - .replace("week", "w") - .replace("months", "mo") - .replace("month", "mo") - .replace("years", "y") - .replace("year", "y"), - ago[0] - .to_string() - .replace("days", "d") - .replace("day", "d") - .replace("weeks", "w") - .replace("week", "w") - .replace("months", "mo") - .replace("month", "mo") - .replace("years", "y") - .replace("year", "y") - + " ago", - ) - }) - .collect() - } - - fn render_results( - &mut self, - f: &mut tui::Frame, - r: tui::layout::Rect, - b: tui::widgets::Block, - ) { - let durations = self.durations(); - let max_length = durations.iter().fold(0, |largest, i| { - std::cmp::max(largest, i.0.len() + i.1.len()) - }); - - let results: Vec = self - .results - .iter() - .enumerate() - .map(|(i, m)| { - let command = m.command.to_string().replace('\n', " ").replace('\t', " "); - - let mut command = Span::raw(command); - - let (duration, mut ago) = durations[i].clone(); - - while (duration.len() + ago.len()) < max_length { - ago = format!(" {}", ago); - } - - let selected_index = match self.results_state.selected() { - None => Span::raw(" "), - Some(selected) => match i.checked_sub(selected) { - None => Span::raw(" "), - Some(diff) => { - if 0 < diff && diff < 10 { - Span::raw(format!(" {} ", diff)) - } else { - Span::raw(" ") - } - } - }, - }; - - let duration = Span::styled( - duration, - Style::default().fg(if m.success() { - Color::Green - } else { - Color::Red - }), - ); - - let ago = Span::styled(ago, Style::default().fg(Color::Blue)); - - if let Some(selected) = self.results_state.selected() { - if selected == i { - command.style = - Style::default().fg(Color::Red).add_modifier(Modifier::BOLD); - } - } - - let spans = Spans::from(vec![ - selected_index, - duration, - Span::raw(" "), - ago, - Span::raw(" "), - command, - ]); - - ListItem::new(spans) - }) - .collect(); - - let results = List::new(results) - .block(b) - .start_corner(Corner::BottomLeft) - .highlight_symbol(">> "); - - f.render_stateful_widget(results, r, &mut self.results_state); - } -} - -async fn query_results( - app: &mut State, - search_mode: SearchMode, - db: &mut impl Database, -) -> Result<()> { - let results = match app.input.as_str() { - "" => { - db.list(app.filter_mode, &app.context, Some(200), true) - .await? - } - i => { - db.search(Some(200), search_mode, app.filter_mode, &app.context, i) - .await? - } - }; - - app.results = results; - - if app.results.is_empty() { - app.results_state.select(None); - } else { - app.results_state.select(Some(0)); - } - - Ok(()) -} - -fn get_input_prefix(app: &mut State, i: usize) -> String { - return app.input.chars().take(i).collect(); -} -fn get_input_suffix(app: &mut State, i: usize) -> String { - return app.input.chars().skip(i).collect(); -} - -fn insert_char_into_input(app: &mut State, i: usize, c: char) { - let mut result = String::from(""); - result.push_str(&get_input_prefix(app, i)); - result.push_str(&c.to_string()); - result.push_str(&get_input_suffix(app, i)); - app.input = result; -} - -fn remove_char_from_input(app: &mut State, i: usize) -> char { - let mut result = String::from(""); - result.push_str(&get_input_prefix(app, i - 1)); - result.push_str(&get_input_suffix(app, i)); - let c = app.input.chars().nth(i - 1).unwrap(); - app.input = result; - c -} - -#[allow(clippy::too_many_lines)] -fn key_handler(input: &TermEvent, app: &mut State) -> Option { - match input { - TermEvent::Key(Key::Esc | Key::Ctrl('c' | 'd' | 'g')) => return Some(String::from("")), - TermEvent::Key(Key::Char('\n')) => { - let i = app.results_state.selected().unwrap_or(0); - - return Some( - app.results - .get(i) - .map_or(app.input.clone(), |h| h.command.clone()), - ); - } - TermEvent::Key(Key::Alt(c)) if ('1'..='9').contains(c) => { - let c = c.to_digit(10)? as usize; - let i = app.results_state.selected()? + c; - - return Some( - app.results - .get(i) - .map_or(app.input.clone(), |h| h.command.clone()), - ); - } - TermEvent::Key(Key::Left | Key::Ctrl('h')) => { - if app.cursor_index != 0 { - app.cursor_index -= 1; - } - } - TermEvent::Key(Key::Right | Key::Ctrl('l')) => { - if app.cursor_index < app.input.width() { - app.cursor_index += 1; - } - } - TermEvent::Key(Key::Ctrl('a')) => { - app.cursor_index = 0; - } - TermEvent::Key(Key::Ctrl('e')) => { - app.cursor_index = app.input.chars().count(); - } - TermEvent::Key(Key::Char(c)) => { - insert_char_into_input(app, app.cursor_index, *c); - app.cursor_index += 1; - } - TermEvent::Key(Key::Backspace) => { - if app.cursor_index == 0 { - return None; - } - remove_char_from_input(app, app.cursor_index); - app.cursor_index -= 1; - } - TermEvent::Key(Key::Ctrl('w')) => { - let mut stop_on_next_whitespace = false; - loop { - if app.cursor_index == 0 { - break; - } - if app.input.chars().nth(app.cursor_index - 1) == Some(' ') - && stop_on_next_whitespace - { - break; - } - if !remove_char_from_input(app, app.cursor_index).is_whitespace() { - stop_on_next_whitespace = true; - } - app.cursor_index -= 1; - } - } - TermEvent::Key(Key::Ctrl('u')) => { - app.input = String::from(""); - app.cursor_index = 0; - } - TermEvent::Key(Key::Ctrl('r')) => { - app.filter_mode = match app.filter_mode { - FilterMode::Global => FilterMode::Host, - FilterMode::Host => FilterMode::Session, - FilterMode::Session => FilterMode::Directory, - FilterMode::Directory => FilterMode::Global, - }; - } - TermEvent::Key(Key::Down | Key::Ctrl('n' | 'j')) - | TermEvent::Mouse(MouseEvent::Press(MouseButton::WheelDown, _, _)) => { - let i = match app.results_state.selected() { - Some(i) => { - if i == 0 { - 0 - } else { - i - 1 - } - } - None => 0, - }; - app.results_state.select(Some(i)); - } - TermEvent::Key(Key::Up | Key::Ctrl('p' | 'k')) - | TermEvent::Mouse(MouseEvent::Press(MouseButton::WheelUp, _, _)) => { - let i = match app.results_state.selected() { - Some(i) => { - if i >= app.results.len() - 1 { - app.results.len() - 1 - } else { - i + 1 - } - } - None => 0, - }; - app.results_state.select(Some(i)); - } - _ => {} - }; - - None -} - -#[allow(clippy::cast_possible_truncation)] -fn draw(f: &mut Frame<'_, T>, history_count: i64, app: &mut State) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(1) - .constraints( - [ - Constraint::Length(2), - Constraint::Min(1), - Constraint::Length(3), - ] - .as_ref(), - ) - .split(f.size()); - - let top_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) - .split(chunks[0]); - - let top_left_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(1), Constraint::Length(1)].as_ref()) - .split(top_chunks[0]); - - let top_right_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(1), Constraint::Length(1)].as_ref()) - .split(top_chunks[1]); - - let title = Paragraph::new(Text::from(Span::styled( - format!("Atuin v{}", VERSION), - Style::default().add_modifier(Modifier::BOLD), - ))); - - let help = vec![ - Span::raw("Press "), - Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(" to exit."), - ]; - - let help = Text::from(Spans::from(help)); - let help = Paragraph::new(help); - - let filter_mode = match app.filter_mode { - FilterMode::Global => "GLOBAL", - FilterMode::Host => "HOST", - FilterMode::Session => "SESSION", - FilterMode::Directory => "DIRECTORY", - }; - - let input = Paragraph::new(app.input.clone()) - .block(Block::default().borders(Borders::ALL).title(filter_mode)); - - let stats = Paragraph::new(Text::from(Span::raw(format!( - "history count: {}", - history_count, - )))) - .alignment(Alignment::Right); - - f.render_widget(title, top_left_chunks[0]); - f.render_widget(help, top_left_chunks[1]); - f.render_widget(stats, top_right_chunks[0]); - - app.render_results( - f, - chunks[1], - Block::default().borders(Borders::ALL).title("History"), - ); - f.render_widget(input, chunks[2]); - - let width = UnicodeWidthStr::width( - app.input - .chars() - .take(app.cursor_index) - .collect::() - .as_str(), - ); - f.set_cursor( - // Put cursor past the end of the input text - chunks[2].x + width as u16 + 1, - // Move one line down, from the border to the input line - chunks[2].y + 1, - ); -} - -#[allow(clippy::cast_possible_truncation)] -fn draw_compact(f: &mut Frame<'_, T>, history_count: i64, app: &mut State) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .margin(0) - .horizontal_margin(1) - .constraints( - [ - Constraint::Length(1), - Constraint::Min(1), - Constraint::Length(1), - ] - .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 = Paragraph::new(Text::from(Span::styled( - format!("Atuin v{}", VERSION), - Style::default().fg(Color::DarkGray), - ))); - - 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); - - let stats = Paragraph::new(Text::from(Span::raw(format!( - "history count: {}", - history_count, - )))) - .style(Style::default().fg(Color::DarkGray)) - .alignment(Alignment::Right); - - let filter_mode = match app.filter_mode { - FilterMode::Global => "GLOBAL", - FilterMode::Host => "HOST", - FilterMode::Session => "SESSION", - FilterMode::Directory => "DIRECTORY", - }; - - let input = - Paragraph::new(format!("{}] {}", filter_mode, app.input.clone())).block(Block::default()); - - f.render_widget(title, header_chunks[0]); - f.render_widget(help, header_chunks[1]); - f.render_widget(stats, header_chunks[2]); - - app.render_results(f, chunks[1], Block::default()); - f.render_widget(input, chunks[2]); - - let extra_width = UnicodeWidthStr::width( - app.input - .chars() - .take(app.cursor_index) - .collect::() - .as_str(), - ) + filter_mode.len(); - - f.set_cursor( - // Put cursor past the end of the input text - chunks[2].x + extra_width as u16 + 2, - // Move one line down, from the border to the input line - chunks[2].y + 1, - ); -} - -// 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)] -async fn select_history( - query: &[String], - search_mode: SearchMode, - filter_mode: FilterMode, - style: atuin_client::settings::Style, - db: &mut impl Database, -) -> Result { - let stdout = stdout().into_raw_mode()?; - let stdout = MouseTerminal::from(stdout); - let stdout = AlternateScreen::from(stdout); - let backend = TermionBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; - - // Setup event handlers - let events = Events::new(); - - let input = query.join(" "); - // Put the cursor at the end of the query by default - let cursor_index = input.chars().count(); - let mut app = State { - input, - cursor_index, - results: Vec::new(), - results_state: ListState::default(), - context: current_context(), - filter_mode, - }; - - query_results(&mut app, search_mode, db).await?; - - loop { - let history_count = db.history_count().await?; - let initial_input = app.input.clone(); - let initial_filter_mode = app.filter_mode; - - // Handle input - if let Event::Input(input) = events.next()? { - if let Some(output) = key_handler(&input, &mut app) { - return Ok(output); - } - } - - // After we receive input process the whole event channel before query/render. - while let Ok(Event::Input(input)) = events.try_next() { - if let Some(output) = key_handler(&input, &mut app) { - return Ok(output); - } - } - - if initial_input != app.input || initial_filter_mode != app.filter_mode { - query_results(&mut app, search_mode, db).await?; - } - - let compact = match 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, - }; - if compact { - terminal.draw(|f| draw_compact(f, history_count, &mut app))?; - } else { - terminal.draw(|f| draw(f, history_count, &mut app))?; - } - } -} - // This is supposed to more-or-less mirror the command line version, so ofc // it is going to have a lot of args #[allow(clippy::too_many_arguments)] diff --git a/src/command/client/search/cursor.rs b/src/command/client/search/cursor.rs new file mode 100644 index 00000000000..1d0e6b8e500 --- /dev/null +++ b/src/command/client/search/cursor.rs @@ -0,0 +1,156 @@ +pub struct Cursor { + source: String, + index: usize, +} + +impl From for Cursor { + fn from(source: String) -> Self { + Self { source, index: 0 } + } +} + +impl Cursor { + pub fn as_str(&self) -> &str { + self.source.as_str() + } + + /// 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 { + 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 insert(&mut self, c: char) { + self.source.insert(self.index, c); + self.index += c.len_utf8(); + } + + pub fn remove(&mut self) -> char { + self.source.remove(self.index) + } + + pub fn back(&mut self) -> Option { + if self.left() { + Some(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; + + #[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 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/event.rs b/src/command/client/search/event.rs similarity index 100% rename from src/command/client/event.rs rename to src/command/client/search/event.rs diff --git a/src/command/client/search/interactive.rs b/src/command/client/search/interactive.rs new file mode 100644 index 00000000000..e355b3f2600 --- /dev/null +++ b/src/command/client/search/interactive.rs @@ -0,0 +1,493 @@ +use std::{io::stdout, ops::Sub, time::Duration}; + +use eyre::Result; +use termion::{ + event::Event as TermEvent, event::Key, event::MouseButton, event::MouseEvent, + input::MouseTerminal, raw::IntoRawMode, screen::AlternateScreen, +}; +use tui::{ + backend::{Backend, TermionBackend}, + layout::{Alignment, Constraint, Corner, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Span, Spans, Text}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, + Frame, Terminal, +}; +use unicode_width::UnicodeWidthStr; + +use atuin_client::{ + database::current_context, + database::Context, + database::Database, + history::History, + settings::{FilterMode, SearchMode}, +}; + +use super::{ + cursor::Cursor, + event::{Event, Events}, +}; +use crate::VERSION; + +struct State { + input: Cursor, + + filter_mode: FilterMode, + + results: Vec, + + results_state: ListState, + + context: Context, +} + +impl State { + #[allow(clippy::cast_sign_loss)] + fn durations(&self) -> Vec<(String, String)> { + self.results + .iter() + .map(|h| { + let duration = + Duration::from_millis(std::cmp::max(h.duration, 0) as u64 / 1_000_000); + let duration = humantime::format_duration(duration).to_string(); + let duration: Vec<&str> = duration.split(' ').collect(); + + let ago = chrono::Utc::now().sub(h.timestamp); + + // Account for the chance that h.timestamp is "in the future" + // This would mean that "ago" is negative, and the unwrap here + // would fail. + // If the timestamp would otherwise be in the future, display + // the time ago as 0. + let ago = humantime::format_duration( + ago.to_std().unwrap_or_else(|_| Duration::new(0, 0)), + ) + .to_string(); + let ago: Vec<&str> = ago.split(' ').collect(); + + ( + duration[0] + .to_string() + .replace("days", "d") + .replace("day", "d") + .replace("weeks", "w") + .replace("week", "w") + .replace("months", "mo") + .replace("month", "mo") + .replace("years", "y") + .replace("year", "y"), + ago[0] + .to_string() + .replace("days", "d") + .replace("day", "d") + .replace("weeks", "w") + .replace("week", "w") + .replace("months", "mo") + .replace("month", "mo") + .replace("years", "y") + .replace("year", "y") + + " ago", + ) + }) + .collect() + } + + fn render_results( + &mut self, + f: &mut tui::Frame, + r: tui::layout::Rect, + b: tui::widgets::Block, + ) { + let durations = self.durations(); + let max_length = durations.iter().fold(0, |largest, i| { + std::cmp::max(largest, i.0.len() + i.1.len()) + }); + + let results: Vec = self + .results + .iter() + .enumerate() + .map(|(i, m)| { + let command = m.command.to_string().replace('\n', " ").replace('\t', " "); + + let mut command = Span::raw(command); + + let (duration, mut ago) = durations[i].clone(); + + while (duration.len() + ago.len()) < max_length { + ago = format!(" {}", ago); + } + + let selected_index = match self.results_state.selected() { + None => Span::raw(" "), + Some(selected) => match i.checked_sub(selected) { + None => Span::raw(" "), + Some(diff) => { + if 0 < diff && diff < 10 { + Span::raw(format!(" {} ", diff)) + } else { + Span::raw(" ") + } + } + }, + }; + + let duration = Span::styled( + duration, + Style::default().fg(if m.success() { + Color::Green + } else { + Color::Red + }), + ); + + let ago = Span::styled(ago, Style::default().fg(Color::Blue)); + + if let Some(selected) = self.results_state.selected() { + if selected == i { + command.style = + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD); + } + } + + let spans = Spans::from(vec![ + selected_index, + duration, + Span::raw(" "), + ago, + Span::raw(" "), + command, + ]); + + ListItem::new(spans) + }) + .collect(); + + let results = List::new(results) + .block(b) + .start_corner(Corner::BottomLeft) + .highlight_symbol(">> "); + + f.render_stateful_widget(results, r, &mut self.results_state); + } +} + +impl State { + async fn query_results( + &mut self, + search_mode: SearchMode, + db: &mut impl Database, + ) -> Result<()> { + let i = self.input.as_str(); + let results = if i.is_empty() { + db.list(self.filter_mode, &self.context, Some(200), true) + .await? + } else { + db.search(Some(200), search_mode, self.filter_mode, &self.context, i) + .await? + }; + + self.results = results; + + if self.results.is_empty() { + self.results_state.select(None); + } else { + self.results_state.select(Some(0)); + } + + Ok(()) + } + + fn handle_input(&mut self, input: &TermEvent) -> Option<&str> { + match input { + TermEvent::Key(Key::Esc | Key::Ctrl('c' | 'd' | 'g')) => return Some(""), + TermEvent::Key(Key::Char('\n')) => { + let i = self.results_state.selected().unwrap_or(0); + + return Some( + self.results + .get(i) + .map_or(self.input.as_str(), |h| h.command.as_str()), + ); + } + TermEvent::Key(Key::Alt(c @ '1'..='9')) => { + let c = c.to_digit(10)? as usize; + let i = self.results_state.selected()? + c; + + return Some( + self.results + .get(i) + .map_or(self.input.as_str(), |h| h.command.as_str()), + ); + } + TermEvent::Key(Key::Left | Key::Ctrl('h')) => { + self.input.left(); + } + TermEvent::Key(Key::Right | Key::Ctrl('l')) => self.input.right(), + TermEvent::Key(Key::Ctrl('a')) => self.input.start(), + TermEvent::Key(Key::Ctrl('e')) => self.input.end(), + TermEvent::Key(Key::Char(c)) => self.input.insert(*c), + TermEvent::Key(Key::Backspace) => { + self.input.back(); + } + TermEvent::Key(Key::Ctrl('w')) => { + // remove the first batch of whitespace + while matches!(self.input.back(), Some(c) if c.is_whitespace()) {} + while self.input.left() { + if self.input.char().unwrap().is_whitespace() { + self.input.right(); // found whitespace, go back right + break; + } + self.input.remove(); + } + } + TermEvent::Key(Key::Ctrl('u')) => self.input.clear(), + TermEvent::Key(Key::Ctrl('r')) => { + pub static FILTER_MODES: [FilterMode; 4] = [ + FilterMode::Global, + FilterMode::Host, + FilterMode::Session, + FilterMode::Directory, + ]; + let i = self.filter_mode as usize; + let i = (i + 1) % FILTER_MODES.len(); + self.filter_mode = FILTER_MODES[i]; + } + TermEvent::Key(Key::Down | Key::Ctrl('n' | 'j')) + | TermEvent::Mouse(MouseEvent::Press(MouseButton::WheelDown, _, _)) => { + let i = self + .results_state + .selected() // try get current selection + .map_or(0, |i| i.saturating_sub(1)); // subtract 1 if possible + self.results_state.select(Some(i)); + } + TermEvent::Key(Key::Up | Key::Ctrl('p' | 'k')) + | TermEvent::Mouse(MouseEvent::Press(MouseButton::WheelUp, _, _)) => { + let i = self + .results_state + .selected() + .map_or(0, |i| i + 1) // increment the selected index + .min(self.results.len() - 1); // clamp it to the last entry + self.results_state.select(Some(i)); + } + _ => {} + }; + + None + } + + #[allow(clippy::cast_possible_truncation)] + fn draw(&mut self, f: &mut Frame<'_, T>, history_count: i64) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints( + [ + Constraint::Length(2), + Constraint::Min(1), + Constraint::Length(3), + ] + .as_ref(), + ) + .split(f.size()); + + let top_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) + .split(chunks[0]); + + let top_left_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Length(1)].as_ref()) + .split(top_chunks[0]); + + let top_right_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Length(1)].as_ref()) + .split(top_chunks[1]); + + let title = Paragraph::new(Text::from(Span::styled( + format!("Atuin v{}", VERSION), + Style::default().add_modifier(Modifier::BOLD), + ))); + + let help = vec![ + Span::raw("Press "), + Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(" to exit."), + ]; + + let help = Text::from(Spans::from(help)); + let help = Paragraph::new(help); + + let input = Paragraph::new(self.input.as_str().to_owned()).block( + Block::default() + .borders(Borders::ALL) + .title(self.filter_mode.as_str()), + ); + + let stats = Paragraph::new(Text::from(Span::raw(format!( + "history count: {}", + history_count, + )))) + .alignment(Alignment::Right); + + f.render_widget(title, top_left_chunks[0]); + f.render_widget(help, top_left_chunks[1]); + f.render_widget(stats, top_right_chunks[0]); + + self.render_results( + f, + chunks[1], + Block::default().borders(Borders::ALL).title("History"), + ); + f.render_widget(input, chunks[2]); + + let width = UnicodeWidthStr::width(self.input.substring()); + f.set_cursor( + // Put cursor past the end of the input text + chunks[2].x + width as u16 + 1, + // Move one line down, from the border to the input line + chunks[2].y + 1, + ); + } + + #[allow(clippy::cast_possible_truncation)] + fn draw_compact(&mut self, f: &mut Frame<'_, T>, history_count: i64) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(0) + .horizontal_margin(1) + .constraints( + [ + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + ] + .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 = Paragraph::new(Text::from(Span::styled( + format!("Atuin v{}", VERSION), + Style::default().fg(Color::DarkGray), + ))); + + 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); + + let stats = Paragraph::new(Text::from(Span::raw(format!( + "history count: {}", + history_count, + )))) + .style(Style::default().fg(Color::DarkGray)) + .alignment(Alignment::Right); + + let filter_mode = self.filter_mode.as_str(); + let input = Paragraph::new(format!("{}] {}", filter_mode, self.input.as_str())) + .block(Block::default()); + + f.render_widget(title, header_chunks[0]); + f.render_widget(help, header_chunks[1]); + f.render_widget(stats, header_chunks[2]); + + self.render_results(f, chunks[1], Block::default()); + f.render_widget(input, chunks[2]); + + let extra_width = UnicodeWidthStr::width(self.input.substring()) + filter_mode.len(); + + f.set_cursor( + // Put cursor past the end of the input text + chunks[2].x + extra_width as u16 + 2, + // Move one line down, from the border to the input line + chunks[2].y + 1, + ); + } +} + +// 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], + search_mode: SearchMode, + filter_mode: FilterMode, + style: atuin_client::settings::Style, + db: &mut impl Database, +) -> Result { + let stdout = stdout().into_raw_mode()?; + let stdout = MouseTerminal::from(stdout); + let stdout = AlternateScreen::from(stdout); + let backend = TermionBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + // Setup event handlers + let events = Events::new(); + + let mut input = Cursor::from(query.join(" ")); + // Put the cursor at the end of the query by default + input.end(); + let mut app = State { + input, + results: Vec::new(), + results_state: ListState::default(), + context: current_context(), + filter_mode, + }; + + app.query_results(search_mode, db).await?; + + loop { + let history_count = db.history_count().await?; + let initial_input = app.input.as_str().to_owned(); + let initial_filter_mode = app.filter_mode; + + // Handle input + if let Event::Input(input) = events.next()? { + if let Some(output) = app.handle_input(&input) { + return Ok(output.to_owned()); + } + } + + // After we receive input process the whole event channel before query/render. + while let Ok(Event::Input(input)) = events.try_next() { + if let Some(output) = app.handle_input(&input) { + return Ok(output.to_owned()); + } + } + + if initial_input != app.input.as_str() || initial_filter_mode != app.filter_mode { + app.query_results(search_mode, db).await?; + } + + let compact = match 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, + }; + if compact { + terminal.draw(|f| app.draw_compact(f, history_count))?; + } else { + terminal.draw(|f| app.draw(f, history_count))?; + } + } +}