aboutsummaryrefslogtreecommitdiffstats
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
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
-rw-r--r--atuin-client/src/database.rs2
-rw-r--r--atuin-client/src/settings.rs19
-rw-r--r--src/command/client.rs1
-rw-r--r--src/command/client/search.rs579
-rw-r--r--src/command/client/search/cursor.rs156
-rw-r--r--src/command/client/search/event.rs (renamed from src/command/client/event.rs)0
-rw-r--r--src/command/client/search/interactive.rs493
7 files changed, 671 insertions, 579 deletions
diff --git a/atuin-client/src/database.rs b/atuin-client/src/database.rs
index 7b3ab3be..ba28daf3 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 d8720574..f836ce02 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 b9d43b39..ae49b857 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 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)]
diff --git a/src/command/client/search/cursor.rs b/src/command/client/search/cursor.rs
new file mode 100644
index 00000000..1d0e6b8e
--- /dev/null
+++ b/src/command/client/search/cursor.rs
@@ -0,0 +1,156 @@
+pub struct Cursor {
+ source: String,
+ index: usize,
+}
+
+impl From<String> 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<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 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<char> {
+ 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
index 8044e278..8044e278 100644
--- a/src/command/client/event.rs
+++ b/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 00000000..e355b3f2
--- /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<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);
+ }
+}
+
+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<T: Backend>(&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<T: Backend>(&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<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 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))?;
+ }
+ }
+}