diff --git a/Cargo.lock b/Cargo.lock index 7eb5b0b..cf70ac8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -106,7 +106,7 @@ dependencies = [ [[package]] name = "atuin" -version = "0.3.3" +version = "0.4.0" dependencies = [ "chrono", "chrono-english", @@ -116,6 +116,7 @@ dependencies = [ "eyre", "hostname", "indicatif", + "itertools", "log 0.4.14", "pretty_env_logger", "rocket", @@ -124,6 +125,9 @@ dependencies = [ "serde_derive", "shellexpand", "structopt", + "termion", + "tui", + "unicode-width", "uuid", ] @@ -214,6 +218,12 @@ version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae44d1a3d5a19df61dd0c8beb138458ac2a53a7ac09eba97d55592540004306b" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + [[package]] name = "cc" version = "1.0.66" @@ -449,6 +459,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "either" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" + [[package]] name = "encode_unicode" version = "0.3.6" @@ -682,6 +698,15 @@ dependencies = [ "regex", ] +[[package]] +name = "itertools" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37d572918e350e82412fe766d24b15e6682fb2ed2bbe018280caa810397cb319" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "0.4.7" @@ -846,6 +871,12 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17b02fc0ff9a9e4b35b3342880f48e896ebf69f2967921fe8646bf5b7125956a" +[[package]] +name = "numtoa" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8f8bdf33df195859076e54ab11ee78a1b208382d3a26ec40d142ffc1ecc49ef" + [[package]] name = "once_cell" version = "1.5.2" @@ -1046,6 +1077,15 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_termios" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8440d8acb4fd3d277125b4bd01a6f38aee8d814b3b5fc09b3f2b825d37d3fe8f" +dependencies = [ + "redox_syscall 0.2.4", +] + [[package]] name = "redox_users" version = "0.3.5" @@ -1367,6 +1407,18 @@ dependencies = [ "winapi", ] +[[package]] +name = "termion" +version = "1.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "077185e2eac69c3f8379a4298e1e07cd36beb962290d4a51199acf0fdc10607e" +dependencies = [ + "libc", + "numtoa", + "redox_syscall 0.2.4", + "redox_termios", +] + [[package]] name = "textwrap" version = "0.11.0" @@ -1434,6 +1486,19 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efd1f82c56340fdf16f2a953d7bda4f8fdffba13d93b00844c25572110b26079" +[[package]] +name = "tui" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ced152a8e9295a5b168adc254074525c17ac4a83c90b2716274cc38118bddc9" +dependencies = [ + "bitflags", + "cassowary", + "termion", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "typeable" version = "0.1.2" diff --git a/Cargo.toml b/Cargo.toml index 19bb0d2..783c020 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "atuin" -version = "0.3.3" +version = "0.4.0" authors = ["Ellie Huxtable "] edition = "2018" license = "MIT" @@ -23,6 +23,10 @@ cli-table = "0.4" config = "0.10" serde_derive = "1.0.124" serde = "1.0.124" +tui = "0.14" +termion = "1.5" +unicode-width = "0.1" +itertools = "0.10.0" [dependencies.rusqlite] version = "0.24" diff --git a/README.md b/README.md index 30e6dd9..7874789 100644 --- a/README.md +++ b/README.md @@ -29,10 +29,6 @@ As well as the expected command, A'tuin stores - zsh -## Requirements - -- [fzf](https://github.com/junegunn/fzf) - ## Install ### AUR @@ -77,9 +73,9 @@ to your `.zshrc`/`.bashrc`/whatever your shell uses. ### History search -By default A'tuin will rebind ctrl-r to use fzf to fuzzy search your history. -It will also rebind the up arrow to use fzf, just without sorting. You can -prevent this by putting +By default A'tuin will rebind ctrl-r and the up arrow to search your history. + +You can prevent this by putting ``` export ATUIN_BINDKEYS="false" @@ -87,28 +83,6 @@ export ATUIN_BINDKEYS="false" into your shell config. -You may also change the default history selection. The default behaviour will search your entire history, however - -``` -export ATUIN_HISTORY="atuin history list --cwd" -``` - -will switch to only searching history for the current directory. - -Similarly, - -``` -export ATUIN_HISTORY="atuin history list --session" -``` - -will search for the current session only, and - -``` -export ATUIN_HISTORY="atuin history list --session --cwd" -``` - -will do both! - ### Import history ``` diff --git a/src/command/event.rs b/src/command/event.rs new file mode 100644 index 0000000..b205be7 --- /dev/null +++ b/src/command/event.rs @@ -0,0 +1,68 @@ +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +use termion::event::Key; +use termion::input::TermRead; + +pub enum Event { + Input(I), + Tick, +} + +/// A small event handler that wrap termion input and tick events. Each event +/// type is handled in its own thread and returned to a common `Receiver` +pub struct Events { + rx: mpsc::Receiver>, +} + +#[derive(Debug, Clone, Copy)] +pub struct Config { + pub exit_key: Key, + pub tick_rate: Duration, +} + +impl Default for Config { + fn default() -> Config { + Config { + exit_key: Key::Char('q'), + tick_rate: Duration::from_millis(250), + } + } +} + +impl Events { + pub fn new() -> Events { + Events::with_config(Config::default()) + } + + pub fn with_config(config: Config) -> Events { + let (tx, rx) = mpsc::channel(); + + { + let tx = tx.clone(); + thread::spawn(move || { + let tty = termion::get_tty().expect("Could not find tty"); + for key in tty.keys().flatten() { + if let Err(err) = tx.send(Event::Input(key)) { + eprintln!("{}", err); + return; + } + } + }) + }; + + thread::spawn(move || loop { + if tx.send(Event::Tick).is_err() { + break; + } + thread::sleep(config.tick_rate); + }); + + Events { rx } + } + + pub fn next(&self) -> Result, mpsc::RecvError> { + self.rx.recv() + } +} diff --git a/src/command/history.rs b/src/command/history.rs index bd44016..af8aef7 100644 --- a/src/command/history.rs +++ b/src/command/history.rs @@ -35,6 +35,12 @@ pub enum Cmd { #[structopt(long, short)] session: bool, }, + + #[structopt( + about="search for a command", + aliases=&["se", "sea", "sear", "searc"], + )] + Search { query: Vec }, } fn print_list(h: &[History]) { @@ -102,6 +108,13 @@ impl Cmd { Ok(()) } + + Self::Search { query } => { + let history = db.prefix_search(&query.join(""))?; + print_list(&history); + + Ok(()) + } } } } diff --git a/src/command/mod.rs b/src/command/mod.rs index c74b138..3ebb92e 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -5,9 +5,11 @@ use uuid::Uuid; use crate::local::database::Database; use crate::settings::Settings; +mod event; mod history; mod import; mod init; +mod search; mod server; mod stats; @@ -33,6 +35,9 @@ pub enum AtuinCmd { #[structopt(about = "generates a UUID")] Uuid, + + #[structopt(about = "interactive history search")] + Search { query: Vec }, } pub fn uuid_v4() -> String { @@ -47,6 +52,7 @@ impl AtuinCmd { Self::Server(server) => server.run(), Self::Stats(stats) => stats.run(db, settings), Self::Init => init::init(), + Self::Search { query } => search::run(&query, db), Self::Uuid => { println!("{}", uuid_v4()); diff --git a/src/command/search.rs b/src/command/search.rs new file mode 100644 index 0000000..d51e29e --- /dev/null +++ b/src/command/search.rs @@ -0,0 +1,220 @@ +use eyre::Result; +use itertools::Itertools; +use std::io::stdout; +use termion::{event::Key, input::MouseTerminal, raw::IntoRawMode, screen::AlternateScreen}; +use tui::{ + backend::TermionBackend, + layout::{Alignment, Constraint, Corner, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Span, Spans, Text}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, + Terminal, +}; +use unicode_width::UnicodeWidthStr; + +use crate::command::event::{Event, Events}; +use crate::local::database::Database; +use crate::local::history::History; + +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +struct State { + input: String, + + results: Vec, + + results_state: ListState, +} + +fn query_results(app: &mut State, db: &mut impl Database) { + let results = match app.input.as_str() { + "" => db.list(), + i => db.prefix_search(i), + }; + + if let Ok(results) = results { + app.results = results.into_iter().rev().unique().collect(); + } + + if app.results.is_empty() { + app.results_state.select(None); + } else { + app.results_state.select(Some(0)); + } +} + +fn key_handler(input: Key, db: &mut impl Database, app: &mut State) -> Option { + match input { + Key::Esc | Key::Char('\n') => { + let i = app.results_state.selected().unwrap_or(0); + + return Some(app.results.get(i).unwrap().command.clone()); + } + Key::Char(c) => { + app.input.push(c); + query_results(app, db); + } + Key::Backspace => { + app.input.pop(); + query_results(app, db); + } + Key::Down => { + let i = match app.results_state.selected() { + Some(i) => { + if i == 0 { + 0 + } else { + i - 1 + } + } + None => 0, + }; + app.results_state.select(Some(i)); + } + Key::Up => { + 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 +} + +// 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::clippy::cast_possible_truncation)] +fn select_history(query: &[String], 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 app = State { + input: query.join(" "), + results: Vec::new(), + results_state: ListState::default(), + }; + + query_results(&mut app, db); + + loop { + // Handle input + if let Event::Input(input) = events.next()? { + if let Some(output) = key_handler(input, db, &mut app) { + return Ok(output); + } + } + + terminal.draw(|f| { + 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!("A'tuin 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(app.input.as_ref()) + .block(Block::default().borders(Borders::ALL).title("Search")); + + let results: Vec = app + .results + .iter() + .enumerate() + .map(|(i, m)| { + let mut content = Span::raw(m.command.to_string()); + + if let Some(selected) = app.results_state.selected() { + if selected == i { + content.style = + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD); + } + } + + ListItem::new(content) + }) + .collect(); + + let results = List::new(results) + .block(Block::default().borders(Borders::ALL).title("History")) + .start_corner(Corner::BottomLeft) + .highlight_symbol(">> "); + + let stats = Paragraph::new(Text::from(Span::raw(format!( + "history count: {}", + db.history_count().unwrap() + )))) + .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]); + f.render_stateful_widget(results, chunks[1], &mut app.results_state); + f.render_widget(input, chunks[2]); + + f.set_cursor( + // Put cursor past the end of the input text + chunks[2].x + app.input.width() as u16 + 1, + // Move one line down, from the border to the input line + chunks[2].y + 1, + ); + })?; + } +} + +pub fn run(query: &[String], db: &mut impl Database) -> Result<()> { + let item = select_history(query, db)?; + eprintln!("{}", item); + + Ok(()) +} diff --git a/src/local/database.rs b/src/local/database.rs index 0c31566..cba7142 100644 --- a/src/local/database.rs +++ b/src/local/database.rs @@ -15,12 +15,17 @@ pub enum QueryParam { pub trait Database { fn save(&mut self, h: &History) -> Result<()>; fn save_bulk(&mut self, h: &[History]) -> Result<()>; + fn load(&self, id: &str) -> Result; fn list(&self) -> Result>; fn range(&self, from: chrono::DateTime, to: chrono::DateTime) -> Result>; - fn update(&self, h: &History) -> Result<()>; + fn query(&self, query: &str, params: &[QueryParam]) -> Result>; + fn update(&self, h: &History) -> Result<()>; + fn history_count(&self) -> Result; + + fn prefix_search(&self, query: &str) -> Result>; } // Intended for use on a developer machine and not a sync server. @@ -199,6 +204,21 @@ impl Database for Sqlite { Ok(history_iter.filter_map(Result::ok).collect()) } + + fn prefix_search(&self, query: &str) -> Result> { + self.query( + "select * from history where command like ?1 || '%' order by timestamp asc", + &[QueryParam::Text(query.to_string())], + ) + } + + fn history_count(&self) -> Result { + let res: i64 = + self.conn + .query_row_and_then("select count(1) from history;", params![], |row| row.get(0))?; + + Ok(res) + } } fn history_from_sqlite_row( diff --git a/src/local/history.rs b/src/local/history.rs index 05600b8..0ca112b 100644 --- a/src/local/history.rs +++ b/src/local/history.rs @@ -1,8 +1,9 @@ use std::env; +use std::hash::{Hash, Hasher}; use crate::command::uuid_v4; -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct History { pub id: String, pub timestamp: i64, @@ -42,3 +43,21 @@ impl History { } } } + +impl PartialEq for History { + // for the sakes of listing unique history only, we do not care about + // anything else + // obviously this does not refer to the *same* item of history, but when + // we only render the command, it looks the same + fn eq(&self, other: &Self) -> bool { + self.command == other.command + } +} + +impl Eq for History {} + +impl Hash for History { + fn hash(&self, state: &mut H) { + self.command.hash(state); + } +} diff --git a/src/main.rs b/src/main.rs index 78e1073..d47866f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,7 +29,7 @@ mod settings; #[derive(StructOpt)] #[structopt( author = "Ellie Huxtable ", - version = "0.3.2", + version = "0.4.0", about = "Magical shell history" )] struct Atuin { diff --git a/src/shell/atuin.zsh b/src/shell/atuin.zsh index 840015d..8407efd 100644 --- a/src/shell/atuin.zsh +++ b/src/shell/atuin.zsh @@ -20,7 +20,9 @@ _atuin_search(){ emulate -L zsh zle -I - output=$(eval $ATUIN_HISTORY | fzf) + # swap stderr and stdout, so that the tui stuff works + # TODO: not this + output=$(atuin search $BUFFER 3>&1 1>&2 2>&3) if [[ -n $output ]] ; then LBUFFER=$output @@ -33,7 +35,9 @@ _atuin_up_search(){ emulate -L zsh zle -I - output=$(eval $ATUIN_HISTORY | fzf --no-sort --tac) + # swap stderr and stdout, so that the tui stuff works + # TODO: not this + output=$(atuin search $BUFFER 3>&1 1>&2 2>&3) if [[ -n $output ]] ; then LBUFFER=$output