aboutsummaryrefslogtreecommitdiffstats
path: root/src/command/client/search.rs
diff options
context:
space:
mode:
authorConrad Ludgate <conradludgate@gmail.com>2022-09-11 16:24:16 +0100
committerGitHub <noreply@github.com>2022-09-11 16:24:16 +0100
commit702a644f68c687142c9a03b48cf451665ed41b62 (patch)
tree5621dc20001662f556532745d800ed5dc3607673 /src/command/client/search.rs
parentAdd index for interactive search (#493) (diff)
downloadatuin-702a644f68c687142c9a03b48cf451665ed41b62.zip
better cursor search (#473)
* improve cursor code * proper unicode support * refactor and test * fmt * clippy * move methods to state * refactor search modules
Diffstat (limited to 'src/command/client/search.rs')
-rw-r--r--src/command/client/search.rs579
1 files changed, 6 insertions, 573 deletions
diff --git a/src/command/client/search.rs b/src/command/client/search.rs
index c50c492c..7b84d410 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<History>,
-
- 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<T: tui::backend::Backend>(
- &mut self,
- f: &mut tui::Frame<T>,
- 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<ListItem> = 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<String> {
- 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<T: Backend>(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::<String>()
- .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<T: Backend>(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::<String>()
- .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<String> {
- 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)]