Add TUI, resolve #19, #17, #16 (#21)

This commit is contained in:
Ellie Huxtable 2021-03-20 00:50:31 +00:00 committed by GitHub
parent 61607e023f
commit 716c7722cd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 429 additions and 36 deletions

67
Cargo.lock generated
View File

@ -106,7 +106,7 @@ dependencies = [
[[package]] [[package]]
name = "atuin" name = "atuin"
version = "0.3.3" version = "0.4.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"chrono-english", "chrono-english",
@ -116,6 +116,7 @@ dependencies = [
"eyre", "eyre",
"hostname", "hostname",
"indicatif", "indicatif",
"itertools",
"log 0.4.14", "log 0.4.14",
"pretty_env_logger", "pretty_env_logger",
"rocket", "rocket",
@ -124,6 +125,9 @@ dependencies = [
"serde_derive", "serde_derive",
"shellexpand", "shellexpand",
"structopt", "structopt",
"termion",
"tui",
"unicode-width",
"uuid", "uuid",
] ]
@ -214,6 +218,12 @@ version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae44d1a3d5a19df61dd0c8beb138458ac2a53a7ac09eba97d55592540004306b" checksum = "ae44d1a3d5a19df61dd0c8beb138458ac2a53a7ac09eba97d55592540004306b"
[[package]]
name = "cassowary"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.0.66" version = "1.0.66"
@ -449,6 +459,12 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "either"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457"
[[package]] [[package]]
name = "encode_unicode" name = "encode_unicode"
version = "0.3.6" version = "0.3.6"
@ -682,6 +698,15 @@ dependencies = [
"regex", "regex",
] ]
[[package]]
name = "itertools"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37d572918e350e82412fe766d24b15e6682fb2ed2bbe018280caa810397cb319"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "0.4.7" version = "0.4.7"
@ -846,6 +871,12 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17b02fc0ff9a9e4b35b3342880f48e896ebf69f2967921fe8646bf5b7125956a" checksum = "17b02fc0ff9a9e4b35b3342880f48e896ebf69f2967921fe8646bf5b7125956a"
[[package]]
name = "numtoa"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8f8bdf33df195859076e54ab11ee78a1b208382d3a26ec40d142ffc1ecc49ef"
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.5.2" version = "1.5.2"
@ -1046,6 +1077,15 @@ dependencies = [
"bitflags", "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]] [[package]]
name = "redox_users" name = "redox_users"
version = "0.3.5" version = "0.3.5"
@ -1367,6 +1407,18 @@ dependencies = [
"winapi", "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]] [[package]]
name = "textwrap" name = "textwrap"
version = "0.11.0" version = "0.11.0"
@ -1434,6 +1486,19 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efd1f82c56340fdf16f2a953d7bda4f8fdffba13d93b00844c25572110b26079" 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]] [[package]]
name = "typeable" name = "typeable"
version = "0.1.2" version = "0.1.2"

View File

@ -1,6 +1,6 @@
[package] [package]
name = "atuin" name = "atuin"
version = "0.3.3" version = "0.4.0"
authors = ["Ellie Huxtable <e@elm.sh>"] authors = ["Ellie Huxtable <e@elm.sh>"]
edition = "2018" edition = "2018"
license = "MIT" license = "MIT"
@ -23,6 +23,10 @@ cli-table = "0.4"
config = "0.10" config = "0.10"
serde_derive = "1.0.124" serde_derive = "1.0.124"
serde = "1.0.124" serde = "1.0.124"
tui = "0.14"
termion = "1.5"
unicode-width = "0.1"
itertools = "0.10.0"
[dependencies.rusqlite] [dependencies.rusqlite]
version = "0.24" version = "0.24"

View File

@ -29,10 +29,6 @@ As well as the expected command, A'tuin stores
- zsh - zsh
## Requirements
- [fzf](https://github.com/junegunn/fzf)
## Install ## Install
### AUR ### AUR
@ -77,9 +73,9 @@ to your `.zshrc`/`.bashrc`/whatever your shell uses.
### History search ### History search
By default A'tuin will rebind ctrl-r to use fzf to fuzzy search your history. By default A'tuin will rebind ctrl-r and the up arrow to search your history.
It will also rebind the up arrow to use fzf, just without sorting. You can
prevent this by putting You can prevent this by putting
``` ```
export ATUIN_BINDKEYS="false" export ATUIN_BINDKEYS="false"
@ -87,28 +83,6 @@ export ATUIN_BINDKEYS="false"
into your shell config. 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 ### Import history
``` ```

68
src/command/event.rs Normal file
View File

@ -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()
}
}

View File

@ -35,6 +35,12 @@ pub enum Cmd {
#[structopt(long, short)] #[structopt(long, short)]
session: bool, session: bool,
}, },
#[structopt(
about="search for a command",
aliases=&["se", "sea", "sear", "searc"],
)]
Search { query: Vec<String> },
} }
fn print_list(h: &[History]) { fn print_list(h: &[History]) {
@ -102,6 +108,13 @@ impl Cmd {
Ok(()) Ok(())
} }
Self::Search { query } => {
let history = db.prefix_search(&query.join(""))?;
print_list(&history);
Ok(())
}
} }
} }
} }

View File

@ -5,9 +5,11 @@ use uuid::Uuid;
use crate::local::database::Database; use crate::local::database::Database;
use crate::settings::Settings; use crate::settings::Settings;
mod event;
mod history; mod history;
mod import; mod import;
mod init; mod init;
mod search;
mod server; mod server;
mod stats; mod stats;
@ -33,6 +35,9 @@ pub enum AtuinCmd {
#[structopt(about = "generates a UUID")] #[structopt(about = "generates a UUID")]
Uuid, Uuid,
#[structopt(about = "interactive history search")]
Search { query: Vec<String> },
} }
pub fn uuid_v4() -> String { pub fn uuid_v4() -> String {
@ -47,6 +52,7 @@ impl AtuinCmd {
Self::Server(server) => server.run(), Self::Server(server) => server.run(),
Self::Stats(stats) => stats.run(db, settings), Self::Stats(stats) => stats.run(db, settings),
Self::Init => init::init(), Self::Init => init::init(),
Self::Search { query } => search::run(&query, db),
Self::Uuid => { Self::Uuid => {
println!("{}", uuid_v4()); println!("{}", uuid_v4());

220
src/command/search.rs Normal file
View File

@ -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(())
}

View File

@ -15,12 +15,17 @@ pub enum QueryParam {
pub trait Database { pub trait Database {
fn save(&mut self, h: &History) -> Result<()>; fn save(&mut self, h: &History) -> Result<()>;
fn save_bulk(&mut self, h: &[History]) -> Result<()>; fn save_bulk(&mut self, h: &[History]) -> Result<()>;
fn load(&self, id: &str) -> Result<History>; fn load(&self, id: &str) -> Result<History>;
fn list(&self) -> Result<Vec<History>>; fn list(&self) -> Result<Vec<History>>;
fn range(&self, from: chrono::DateTime<Utc>, to: chrono::DateTime<Utc>) fn range(&self, from: chrono::DateTime<Utc>, to: chrono::DateTime<Utc>)
-> Result<Vec<History>>; -> Result<Vec<History>>;
fn update(&self, h: &History) -> Result<()>;
fn query(&self, query: &str, params: &[QueryParam]) -> Result<Vec<History>>; 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. // 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()) 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( fn history_from_sqlite_row(

View File

@ -1,8 +1,9 @@
use std::env; use std::env;
use std::hash::{Hash, Hasher};
use crate::command::uuid_v4; use crate::command::uuid_v4;
#[derive(Debug)] #[derive(Debug, Clone)]
pub struct History { pub struct History {
pub id: String, pub id: String,
pub timestamp: i64, 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);
}
}

View File

@ -29,7 +29,7 @@ mod settings;
#[derive(StructOpt)] #[derive(StructOpt)]
#[structopt( #[structopt(
author = "Ellie Huxtable <e@elm.sh>", author = "Ellie Huxtable <e@elm.sh>",
version = "0.3.2", version = "0.4.0",
about = "Magical shell history" about = "Magical shell history"
)] )]
struct Atuin { struct Atuin {

View File

@ -20,7 +20,9 @@ _atuin_search(){
emulate -L zsh emulate -L zsh
zle -I 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 if [[ -n $output ]] ; then
LBUFFER=$output LBUFFER=$output
@ -33,7 +35,9 @@ _atuin_up_search(){
emulate -L zsh emulate -L zsh
zle -I 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 if [[ -n $output ]] ; then
LBUFFER=$output LBUFFER=$output