summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorEllie Huxtable <e@elm.sh>2021-03-20 00:50:31 +0000
committerGitHub <noreply@github.com>2021-03-20 00:50:31 +0000
commit716c7722cda29bf612508bb96f51822a86e0f69e (patch)
treefa3c4c192fc05b078397fcd510d39ae78e46abfa
parent61607e023fbb916f376a7070f8b1ffd6ffe16849 (diff)
Add TUI, resolve #19, #17, #16 (#21)
-rw-r--r--Cargo.lock67
-rw-r--r--Cargo.toml6
-rw-r--r--README.md32
-rw-r--r--src/command/event.rs68
-rw-r--r--src/command/history.rs13
-rw-r--r--src/command/mod.rs6
-rw-r--r--src/command/search.rs220
-rw-r--r--src/local/database.rs22
-rw-r--r--src/local/history.rs21
-rw-r--r--src/main.rs2
-rw-r--r--src/shell/atuin.zsh8
11 files changed, 429 insertions, 36 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 7eb5b0b3..cf70ac8e 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",
]
@@ -215,6 +219,12 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -450,6 +460,12 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -683,6 +699,15 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -847,6 +872,12 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1047,6 +1078,15 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1368,6 +1408,18 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1435,6 +1487,19 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index 19bb0d21..783c0206 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 <e@elm.sh>"]
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 30e6dd97..7874789b 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 00000000..b205be70
--- /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<I> {
+ 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<Event<Key>>,
+}
+
+#[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<Event<Key>, mpsc::RecvError> {
+ self.rx.recv()
+ }
+}
diff --git a/src/command/history.rs b/src/command/history.rs
index bd440163..af8aef7d 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<String> },
}
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 c74b138f..3ebb92e0 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<String> },
}
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 00000000..d51e29ef
--- /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<History>,
+
+ 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<String> {
+ 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<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 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<ListItem> = 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 0c31566d..cba7142c 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<History>;
fn list(&self) -> Result<Vec<History>>;
fn range(&self, from: chrono::DateTime<Utc>, to: chrono::DateTime<Utc>)
-> Result<Vec<History>>;
- fn update(&self, h: &History) -> Result<()>;
+
fn query(&self, query: &str, params: &[QueryParam]) -> Result<Vec<History>>;
+ fn update(&self, h: &History) -> Result<()>;
+ fn history_count(&self) -> Result<i64>;
+
+ fn prefix_search(&self, query: &str) -> Result<Vec<History>>;
}
// 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<Vec<History>> {
+ self.query(
+ "select * from history where command like ?1 || '%' order by timestamp asc",
+ &[QueryParam::Text(query.to_string())],
+ )
+ }
+
+ fn history_count(&self) -> Result<i64> {
+ 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 05600b80..0ca112bd 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<H: Hasher>(&self, state: &mut H) {
+ self.command.hash(state);
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index 78e10731..d47866f4 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -29,7 +29,7 @@ mod settings;
#[derive(StructOpt)]
#[structopt(
author = "Ellie Huxtable <e@elm.sh>",
- 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 840015dc..8407efd2 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