From d57f549855caf8ab90b5ea0ae7cc9445f3abedfc Mon Sep 17 00:00:00 2001 From: Conrad Ludgate Date: Thu, 21 Apr 2022 10:12:56 +0100 Subject: refactor commands for better separation (#313) * refactor commands for better separation * fmt --- src/command/client/event.rs | 68 +++++ src/command/client/history.rs | 202 +++++++++++++ src/command/client/import.rs | 166 +++++++++++ src/command/client/init.rs | 36 +++ src/command/client/login.rs | 75 +++++ src/command/client/logout.rs | 15 + src/command/client/register.rs | 50 ++++ src/command/client/search.rs | 623 +++++++++++++++++++++++++++++++++++++++++ src/command/client/stats.rs | 101 +++++++ src/command/client/sync.rs | 19 ++ 10 files changed, 1355 insertions(+) create mode 100644 src/command/client/event.rs create mode 100644 src/command/client/history.rs create mode 100644 src/command/client/import.rs create mode 100644 src/command/client/init.rs create mode 100644 src/command/client/login.rs create mode 100644 src/command/client/logout.rs create mode 100644 src/command/client/register.rs create mode 100644 src/command/client/search.rs create mode 100644 src/command/client/stats.rs create mode 100644 src/command/client/sync.rs (limited to 'src/command/client') diff --git a/src/command/client/event.rs b/src/command/client/event.rs new file mode 100644 index 00000000..f09752d6 --- /dev/null +++ b/src/command/client/event.rs @@ -0,0 +1,68 @@ +use std::thread; +use std::time::Duration; + +use crossbeam_channel::unbounded; +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: crossbeam_channel::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) = unbounded(); + + { + 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, crossbeam_channel::RecvError> { + self.rx.recv() + } +} diff --git a/src/command/client/history.rs b/src/command/client/history.rs new file mode 100644 index 00000000..6eaa6407 --- /dev/null +++ b/src/command/client/history.rs @@ -0,0 +1,202 @@ +use std::env; +use std::io::Write; +use std::time::Duration; + +use clap::Subcommand; +use eyre::Result; +use tabwriter::TabWriter; + +use atuin_client::database::Database; +use atuin_client::history::History; +use atuin_client::settings::Settings; +use atuin_client::sync; + +#[derive(Subcommand)] +#[clap(infer_subcommands = true)] +pub enum Cmd { + /// Begins a new command in the history + Start { command: Vec }, + + /// Finishes a new command in the history (adds time, exit code) + End { + id: String, + #[clap(long, short)] + exit: i64, + }, + + /// List all items in history + List { + #[clap(long, short)] + cwd: bool, + + #[clap(long, short)] + session: bool, + + #[clap(long)] + human: bool, + + /// Show only the text of the command + #[clap(long)] + cmd_only: bool, + }, + + /// Get the last command ran + Last { + #[clap(long)] + human: bool, + + /// Show only the text of the command + #[clap(long)] + cmd_only: bool, + }, +} + +#[allow(clippy::cast_sign_loss)] +pub fn print_list(h: &[History], human: bool, cmd_only: bool) { + let mut writer = TabWriter::new(std::io::stdout()).padding(2); + + let lines = h.iter().map(|h| { + if human { + let duration = humantime::format_duration(Duration::from_nanos(std::cmp::max( + h.duration, 0, + ) as u64)) + .to_string(); + let duration: Vec<&str> = duration.split(' ').collect(); + let duration = duration[0]; + + format!( + "{}\t{}\t{}\n", + h.timestamp.format("%Y-%m-%d %H:%M:%S"), + h.command.trim(), + duration, + ) + } else if cmd_only { + format!("{}\n", h.command.trim()) + } else { + format!( + "{}\t{}\t{}\n", + h.timestamp.timestamp_nanos(), + h.command.trim(), + h.duration + ) + } + }); + + for i in lines.rev() { + writer + .write_all(i.as_bytes()) + .expect("failed to write to tab writer"); + } + + writer.flush().expect("failed to flush tab writer"); +} + +impl Cmd { + pub async fn run( + &self, + settings: &Settings, + db: &mut (impl Database + Send + Sync), + ) -> Result<()> { + match self { + Self::Start { command: words } => { + let command = words.join(" "); + + if command.starts_with(' ') { + return Ok(()); + } + + // It's better for atuin to silently fail here and attempt to + // store whatever is ran, than to throw an error to the terminal + let cwd = match env::current_dir() { + Ok(dir) => dir.display().to_string(), + Err(_) => String::from(""), + }; + + let h = History::new(chrono::Utc::now(), command, cwd, -1, -1, None, None); + + // print the ID + // we use this as the key for calling end + println!("{}", h.id); + db.save(&h).await?; + Ok(()) + } + + Self::End { id, exit } => { + if id.trim() == "" { + return Ok(()); + } + + let mut h = db.load(id).await?; + + if h.duration > 0 { + debug!("cannot end history - already has duration"); + + // returning OK as this can occur if someone Ctrl-c a prompt + return Ok(()); + } + + h.exit = *exit; + h.duration = chrono::Utc::now().timestamp_nanos() - h.timestamp.timestamp_nanos(); + + db.update(&h).await?; + + if settings.should_sync()? { + debug!("running periodic background sync"); + sync::sync(settings, false, db).await?; + } else { + debug!("sync disabled! not syncing"); + } + + Ok(()) + } + + Self::List { + session, + cwd, + human, + cmd_only, + } => { + let session = if *session { + Some(env::var("ATUIN_SESSION")?) + } else { + None + }; + let cwd = if *cwd { + Some(env::current_dir()?.display().to_string()) + } else { + None + }; + + let history = match (session, cwd) { + (None, None) => db.list(None, false).await?, + (None, Some(cwd)) => { + let query = format!("select * from history where cwd = '{}';", cwd); + db.query_history(&query).await? + } + (Some(session), None) => { + let query = format!("select * from history where session = {};", session); + db.query_history(&query).await? + } + (Some(session), Some(cwd)) => { + let query = format!( + "select * from history where cwd = '{}' and session = {};", + cwd, session + ); + db.query_history(&query).await? + } + }; + + print_list(&history, *human, *cmd_only); + + Ok(()) + } + + Self::Last { human, cmd_only } => { + let last = db.last().await?; + print_list(&[last], *human, *cmd_only); + + Ok(()) + } + } + } +} diff --git a/src/command/client/import.rs b/src/command/client/import.rs new file mode 100644 index 00000000..7e2f5c5c --- /dev/null +++ b/src/command/client/import.rs @@ -0,0 +1,166 @@ +use std::{env, path::PathBuf}; + +use atuin_client::import::fish::Fish; +use clap::Parser; +use eyre::{eyre, Result}; + +use atuin_client::import::{bash::Bash, zsh::Zsh}; +use atuin_client::{database::Database, import::Importer}; +use atuin_client::{history::History, import::resh::Resh}; +use indicatif::ProgressBar; + +#[derive(Parser)] +#[clap(infer_subcommands = true)] +pub enum Cmd { + /// Import history for the current shell + Auto, + + /// Import history from the zsh history file + Zsh, + + /// Import history from the bash history file + Bash, + + /// Import history from the resh history file + Resh, + + /// Import history from the fish history file + Fish, +} + +const BATCH_SIZE: usize = 100; + +impl Cmd { + pub async fn run(&self, db: &mut (impl Database + Send + Sync)) -> Result<()> { + println!(" Atuin "); + println!("======================"); + println!(" \u{1f30d} "); + println!(" \u{1f418}\u{1f418}\u{1f418}\u{1f418} "); + println!(" \u{1f422} "); + println!("======================"); + println!("Importing history..."); + + match self { + Self::Auto => { + let shell = env::var("SHELL").unwrap_or_else(|_| String::from("NO_SHELL")); + + if shell.ends_with("/zsh") { + println!("Detected ZSH"); + import::, _>(db, BATCH_SIZE).await + } else if shell.ends_with("/fish") { + println!("Detected Fish"); + import::, _>(db, BATCH_SIZE).await + } else { + println!("cannot import {} history", shell); + Ok(()) + } + } + + Self::Zsh => import::, _>(db, BATCH_SIZE).await, + Self::Bash => import::, _>(db, BATCH_SIZE).await, + Self::Resh => import::(db, BATCH_SIZE).await, + Self::Fish => import::, _>(db, BATCH_SIZE).await, + } + } +} + +async fn import( + db: &mut DB, + buf_size: usize, +) -> Result<()> +where + I::IntoIter: Send, +{ + println!("Importing history from {}", I::NAME); + + let histpath = get_histpath::()?; + let contents = I::parse(histpath)?; + + let iter = contents.into_iter(); + let progress = if let (_, Some(upper_bound)) = iter.size_hint() { + ProgressBar::new(upper_bound as u64) + } else { + ProgressBar::new_spinner() + }; + + let mut buf = Vec::::with_capacity(buf_size); + let mut iter = progress.wrap_iter(iter); + loop { + // fill until either no more entries + // or until the buffer is full + let done = fill_buf(&mut buf, &mut iter); + + // flush + db.save_bulk(&buf).await?; + + if done { + break; + } + } + + println!("Import complete!"); + + Ok(()) +} + +fn get_histpath() -> Result { + if let Ok(p) = env::var("HISTFILE") { + is_file(PathBuf::from(p)) + } else { + is_file(I::histpath()?) + } +} + +fn is_file(p: PathBuf) -> Result { + if p.is_file() { + Ok(p) + } else { + Err(eyre!( + "Could not find history file {:?}. Try setting $HISTFILE", + p + )) + } +} + +fn fill_buf(buf: &mut Vec, iter: &mut impl Iterator>) -> bool { + buf.clear(); + loop { + match iter.next() { + Some(Ok(t)) => buf.push(t), + Some(Err(_)) => (), + None => break true, + } + + if buf.len() == buf.capacity() { + break false; + } + } +} + +#[cfg(test)] +mod tests { + use super::fill_buf; + + #[test] + fn test_fill_buf() { + let mut buf = Vec::with_capacity(4); + let mut iter = vec![ + Ok(1), + Err(2), + Ok(3), + Ok(4), + Err(5), + Ok(6), + Ok(7), + Err(8), + Ok(9), + ] + .into_iter(); + + assert!(!fill_buf(&mut buf, &mut iter)); + assert_eq!(buf, vec![1, 3, 4, 6]); + + assert!(fill_buf(&mut buf, &mut iter)); + assert_eq!(buf, vec![7, 9]); + } +} diff --git a/src/command/client/init.rs b/src/command/client/init.rs new file mode 100644 index 00000000..a2c6378c --- /dev/null +++ b/src/command/client/init.rs @@ -0,0 +1,36 @@ +use clap::Parser; + +#[derive(Parser)] +pub enum Cmd { + /// Zsh setup + Zsh, + /// Bash setup + Bash, + /// Fish setup + Fish, +} + +fn init_zsh() { + let full = include_str!("../../shell/atuin.zsh"); + println!("{}", full); +} + +fn init_bash() { + let full = include_str!("../../shell/atuin.bash"); + println!("{}", full); +} + +fn init_fish() { + let full = include_str!("../../shell/atuin.fish"); + println!("{}", full); +} + +impl Cmd { + pub fn run(&self) { + match self { + Self::Zsh => init_zsh(), + Self::Bash => init_bash(), + Self::Fish => init_fish(), + } + } +} diff --git a/src/command/client/login.rs b/src/command/client/login.rs new file mode 100644 index 00000000..efc9c590 --- /dev/null +++ b/src/command/client/login.rs @@ -0,0 +1,75 @@ +use std::io; + +use atuin_common::api::LoginRequest; +use clap::AppSettings; +use clap::Parser; +use eyre::Result; +use tokio::{fs::File, io::AsyncWriteExt}; + +use atuin_client::api_client; +use atuin_client::settings::Settings; + +#[derive(Parser)] +#[clap(setting(AppSettings::DeriveDisplayOrder))] +pub struct Cmd { + #[clap(long, short)] + pub username: Option, + + #[clap(long, short)] + pub password: Option, + + /// The encryption key for your account + #[clap(long, short)] + pub key: Option, +} + +fn get_input() -> Result { + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + Ok(input.trim_end_matches(&['\r', '\n'][..]).to_string()) +} + +impl Cmd { + pub async fn run(&self, settings: &Settings) -> Result<()> { + let session_path = atuin_common::utils::data_dir().join("session"); + + if session_path.exists() { + println!( + "You are already logged in! Please run 'atuin logout' if you wish to login again" + ); + + return Ok(()); + } + + let username = or_user_input(&self.username, "username"); + let password = or_user_input(&self.password, "password"); + let key = or_user_input(&self.key, "encryption key"); + + let session = api_client::login( + settings.sync_address.as_str(), + LoginRequest { username, password }, + ) + .await?; + + let session_path = settings.session_path.as_str(); + let mut file = File::create(session_path).await?; + file.write_all(session.session.as_bytes()).await?; + + let key_path = settings.key_path.as_str(); + let mut file = File::create(key_path).await?; + file.write_all(key.as_bytes()).await?; + + println!("Logged in!"); + + Ok(()) + } +} + +pub(super) fn or_user_input(value: &'_ Option, name: &'static str) -> String { + value.clone().unwrap_or_else(|| read_user_input(name)) +} + +fn read_user_input(name: &'static str) -> String { + eprint!("Please enter {}: ", name); + get_input().expect("Failed to read from input") +} diff --git a/src/command/client/logout.rs b/src/command/client/logout.rs new file mode 100644 index 00000000..a7e9541d --- /dev/null +++ b/src/command/client/logout.rs @@ -0,0 +1,15 @@ +use eyre::{Context, Result}; +use fs_err::remove_file; + +pub fn run() -> Result<()> { + let session_path = atuin_common::utils::data_dir().join("session"); + + if session_path.exists() { + remove_file(session_path.as_path()).context("Failed to remove session file")?; + println!("You have logged out!"); + } else { + println!("You are not logged in"); + } + + Ok(()) +} diff --git a/src/command/client/register.rs b/src/command/client/register.rs new file mode 100644 index 00000000..2c60a2e9 --- /dev/null +++ b/src/command/client/register.rs @@ -0,0 +1,50 @@ +use clap::AppSettings; +use clap::Parser; +use eyre::Result; +use tokio::{fs::File, io::AsyncWriteExt}; + +use atuin_client::api_client; +use atuin_client::settings::Settings; + +#[derive(Parser)] +#[clap(setting(AppSettings::DeriveDisplayOrder))] +pub struct Cmd { + #[clap(long, short)] + pub username: Option, + + #[clap(long, short)] + pub email: Option, + + #[clap(long, short)] + pub password: Option, +} + +impl Cmd { + pub async fn run(self, settings: &Settings) -> Result<()> { + run(settings, &self.username, &self.email, &self.password).await + } +} + +pub async fn run( + settings: &Settings, + username: &Option, + email: &Option, + password: &Option, +) -> Result<()> { + use super::login::or_user_input; + let username = or_user_input(username, "username"); + let email = or_user_input(email, "email"); + let password = or_user_input(password, "password"); + + let session = + api_client::register(settings.sync_address.as_str(), &username, &email, &password).await?; + + let path = settings.session_path.as_str(); + let mut file = File::create(path).await?; + file.write_all(session.session.as_bytes()).await?; + + // Create a new key, and save it to disk + let _key = atuin_client::encryption::new_key(settings)?; + + Ok(()) +} diff --git a/src/command/client/search.rs b/src/command/client/search.rs new file mode 100644 index 00000000..a1dc5aa9 --- /dev/null +++ b/src/command/client/search.rs @@ -0,0 +1,623 @@ +use chrono::Utc; +use clap::Parser; +use eyre::Result; +use std::{io::stdout, ops::Sub, time::Duration}; +use termion::{event::Key, 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::Database, + history::History, + settings::{SearchMode, Settings}, +}; + +use super::event::{Event, Events}; + +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[derive(Parser)] +pub struct Cmd { + /// Filter search result by directory + #[clap(long, short)] + cwd: Option, + + /// Exclude directory from results + #[clap(long = "exclude-cwd")] + exclude_cwd: Option, + + /// Filter search result by exit code + #[clap(long, short)] + exit: Option, + + /// Exclude results with this exit code + #[clap(long = "exclude-exit")] + exclude_exit: Option, + + /// Only include results added before this date + #[clap(long, short)] + before: Option, + + /// Only include results after this date + #[clap(long)] + after: Option, + + /// Open interactive search UI + #[clap(long, short)] + interactive: bool, + + /// Use human-readable formatting for time + #[clap(long)] + human: bool, + + query: Vec, + + /// Show only the text of the command + #[clap(long)] + cmd_only: bool, +} + +impl Cmd { + pub async fn run( + self, + db: &mut (impl Database + Send + Sync), + settings: &Settings, + ) -> Result<()> { + run( + settings, + self.cwd, + self.exit, + self.interactive, + self.human, + self.exclude_exit, + self.exclude_cwd, + self.before, + self.after, + self.cmd_only, + &self.query, + db, + ) + .await + } +} + +struct State { + input: String, + + results: Vec, + + results_state: ListState, +} + +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( + &mut self, + f: &mut tui::Frame, + 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 = 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.exit == 0 || m.duration == -1 { + 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 + Send + Sync), +) -> Result<()> { + let results = match app.input.as_str() { + "" => db.list(Some(200), true).await?, + i => db.search(Some(200), search_mode, i).await?, + }; + + app.results = results; + + if app.results.is_empty() { + app.results_state.select(None); + } else { + app.results_state.select(Some(0)); + } + + Ok(()) +} + +async fn key_handler( + input: Key, + search_mode: SearchMode, + db: &mut (impl Database + Send + Sync), + app: &mut State, +) -> Option { + match input { + Key::Esc | Key::Ctrl('c' | 'd' | 'g') => return Some(String::from("")), + 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()), + ); + } + 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()), + ); + } + Key::Char(c) => { + app.input.push(c); + query_results(app, search_mode, db).await.unwrap(); + } + Key::Backspace => { + app.input.pop(); + query_results(app, search_mode, db).await.unwrap(); + } + // \u{7f} is escape sequence for backspace + Key::Alt('\u{7f}') => { + let words: Vec<&str> = app.input.split(' ').collect(); + if words.is_empty() { + return None; + } + if words.len() == 1 { + app.input = String::from(""); + } else { + app.input = words[0..(words.len() - 1)].join(" "); + } + query_results(app, search_mode, db).await.unwrap(); + } + Key::Ctrl('u') => { + app.input = String::from(""); + query_results(app, search_mode, db).await.unwrap(); + } + Key::Down | Key::Ctrl('n') => { + 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 | Key::Ctrl('p') => { + 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(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 input = Paragraph::new(app.input.clone()) + .block(Block::default().borders(Borders::ALL).title("Query")); + + 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]); + + 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, + ); +} + +#[allow(clippy::cast_possible_truncation)] +fn draw_compact(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 input = Paragraph::new(format!("] {}", 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]); + + f.set_cursor( + // Put cursor past the end of the input text + chunks[2].x + app.input.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, + style: atuin_client::settings::Style, + db: &mut (impl Database + Send + Sync), +) -> 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, search_mode, db).await?; + + loop { + let history_count = db.history_count().await?; + // Handle input + if let Event::Input(input) = events.next()? { + if let Some(output) = key_handler(input, search_mode, db, &mut app).await { + return Ok(output); + } + } + + 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)] +pub async fn run( + settings: &Settings, + cwd: Option, + exit: Option, + interactive: bool, + human: bool, + exclude_exit: Option, + exclude_cwd: Option, + before: Option, + after: Option, + cmd_only: bool, + query: &[String], + db: &mut (impl Database + Send + Sync), +) -> Result<()> { + let dir = if let Some(cwd) = cwd { + if cwd == "." { + let current = std::env::current_dir()?; + let current = current.as_os_str(); + let current = current.to_str().unwrap(); + + Some(current.to_owned()) + } else { + Some(cwd) + } + } else { + None + }; + + if interactive { + let item = select_history(query, settings.search_mode, settings.style, db).await?; + eprintln!("{}", item); + } else { + let results = db + .search(None, settings.search_mode, query.join(" ").as_str()) + .await?; + + // TODO: This filtering would be better done in the SQL query, I just + // need a nice way of building queries. + let results: Vec = results + .iter() + .filter(|h| { + if let Some(exit) = exit { + if h.exit != exit { + return false; + } + } + + if let Some(exit) = exclude_exit { + if h.exit == exit { + return false; + } + } + + if let Some(cwd) = &exclude_cwd { + if h.cwd.as_str() == cwd.as_str() { + return false; + } + } + + if let Some(cwd) = &dir { + if h.cwd.as_str() != cwd.as_str() { + return false; + } + } + + if let Some(before) = &before { + let before = chrono_english::parse_date_string( + before.as_str(), + Utc::now(), + chrono_english::Dialect::Uk, + ); + + if before.is_err() || h.timestamp.gt(&before.unwrap()) { + return false; + } + } + + if let Some(after) = &after { + let after = chrono_english::parse_date_string( + after.as_str(), + Utc::now(), + chrono_english::Dialect::Uk, + ); + + if after.is_err() || h.timestamp.lt(&after.unwrap()) { + return false; + } + } + + true + }) + .map(std::borrow::ToOwned::to_owned) + .collect(); + + super::history::print_list(&results, human, cmd_only); + } + + Ok(()) +} diff --git a/src/command/client/stats.rs b/src/command/client/stats.rs new file mode 100644 index 00000000..6d342c19 --- /dev/null +++ b/src/command/client/stats.rs @@ -0,0 +1,101 @@ +use std::collections::HashMap; + +use chrono::prelude::*; +use chrono::Duration; +use chrono_english::parse_date_string; + +use clap::Parser; +use cli_table::{format::Justify, print_stdout, Cell, Style, Table}; +use eyre::{bail, Result}; + +use atuin_client::database::Database; +use atuin_client::history::History; +use atuin_client::settings::Settings; + +#[derive(Parser)] +#[clap(infer_subcommands = true)] +pub enum Cmd { + /// Compute statistics for all of time + All, + + /// Compute statistics for a single day + Day { words: Vec }, +} + +fn compute_stats(history: &[History]) -> Result<()> { + let mut commands = HashMap::::new(); + + for i in history { + *commands.entry(i.command.clone()).or_default() += 1; + } + + let most_common_command = commands.iter().max_by(|a, b| a.1.cmp(b.1)); + + if most_common_command.is_none() { + bail!("No commands found"); + } + + let table = vec![ + vec![ + "Most used command".cell(), + most_common_command + .unwrap() + .0 + .cell() + .justify(Justify::Right), + ], + vec![ + "Commands ran".cell(), + history.len().to_string().cell().justify(Justify::Right), + ], + vec![ + "Unique commands ran".cell(), + commands.len().to_string().cell().justify(Justify::Right), + ], + ] + .table() + .title(vec![ + "Statistic".cell().bold(true), + "Value".cell().bold(true), + ]) + .bold(true); + + print_stdout(table)?; + + Ok(()) +} + +impl Cmd { + pub async fn run( + &self, + db: &mut (impl Database + Send + Sync), + settings: &Settings, + ) -> Result<()> { + match self { + Self::Day { words } => { + let words = if words.is_empty() { + String::from("yesterday") + } else { + words.join(" ") + }; + + let start = parse_date_string(&words, Local::now(), settings.dialect.into())?; + let end = start + Duration::days(1); + + let history = db.range(start.into(), end.into()).await?; + + compute_stats(&history)?; + + Ok(()) + } + + Self::All => { + let history = db.list(None, false).await?; + + compute_stats(&history)?; + + Ok(()) + } + } + } +} diff --git a/src/command/client/sync.rs b/src/command/client/sync.rs new file mode 100644 index 00000000..f8bfd5e2 --- /dev/null +++ b/src/command/client/sync.rs @@ -0,0 +1,19 @@ +use eyre::Result; + +use atuin_client::database::Database; +use atuin_client::settings::Settings; +use atuin_client::sync; + +pub async fn run( + settings: &Settings, + force: bool, + db: &mut (impl Database + Send + Sync), +) -> Result<()> { + sync::sync(settings, force, db).await?; + println!( + "Sync complete! {} items in database, force: {}", + db.history_count().await?, + force + ); + Ok(()) +} -- cgit v1.3.1