aboutsummaryrefslogtreecommitdiffstats
path: root/src/command/client
diff options
context:
space:
mode:
authorConrad Ludgate <conrad.ludgate@truelayer.com>2022-04-21 10:12:56 +0100
committerGitHub <noreply@github.com>2022-04-21 09:12:56 +0000
commitd57f549855caf8ab90b5ea0ae7cc9445f3abedfc (patch)
tree0818ff405a3b697a0ca981d215ceb4dbb30cd15a /src/command/client
parentFix SQL cache query (#318) (diff)
downloadatuin-d57f549855caf8ab90b5ea0ae7cc9445f3abedfc.zip
refactor commands for better separation (#313)
* refactor commands for better separation * fmt
Diffstat (limited to 'src/command/client')
-rw-r--r--src/command/client/event.rs68
-rw-r--r--src/command/client/history.rs202
-rw-r--r--src/command/client/import.rs166
-rw-r--r--src/command/client/init.rs36
-rw-r--r--src/command/client/login.rs75
-rw-r--r--src/command/client/logout.rs15
-rw-r--r--src/command/client/register.rs50
-rw-r--r--src/command/client/search.rs623
-rw-r--r--src/command/client/stats.rs101
-rw-r--r--src/command/client/sync.rs19
10 files changed, 1355 insertions, 0 deletions
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<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: crossbeam_channel::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) = 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<Event<Key>, 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<String> },
+
+ /// 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::<Zsh<_>, _>(db, BATCH_SIZE).await
+ } else if shell.ends_with("/fish") {
+ println!("Detected Fish");
+ import::<Fish<_>, _>(db, BATCH_SIZE).await
+ } else {
+ println!("cannot import {} history", shell);
+ Ok(())
+ }
+ }
+
+ Self::Zsh => import::<Zsh<_>, _>(db, BATCH_SIZE).await,
+ Self::Bash => import::<Bash<_>, _>(db, BATCH_SIZE).await,
+ Self::Resh => import::<Resh, _>(db, BATCH_SIZE).await,
+ Self::Fish => import::<Fish<_>, _>(db, BATCH_SIZE).await,
+ }
+ }
+}
+
+async fn import<I: Importer + Send, DB: Database + Send + Sync>(
+ db: &mut DB,
+ buf_size: usize,
+) -> Result<()>
+where
+ I::IntoIter: Send,
+{
+ println!("Importing history from {}", I::NAME);
+
+ let histpath = get_histpath::<I>()?;
+ 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::<History>::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<I: Importer>() -> Result<PathBuf> {
+ if let Ok(p) = env::var("HISTFILE") {
+ is_file(PathBuf::from(p))
+ } else {
+ is_file(I::histpath()?)
+ }
+}
+
+fn is_file(p: PathBuf) -> Result<PathBuf> {
+ if p.is_file() {
+ Ok(p)
+ } else {
+ Err(eyre!(
+ "Could not find history file {:?}. Try setting $HISTFILE",
+ p
+ ))
+ }
+}
+
+fn fill_buf<T, E>(buf: &mut Vec<T>, iter: &mut impl Iterator<Item = Result<T, E>>) -> 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<String>,
+
+ #[clap(long, short)]
+ pub password: Option<String>,
+
+ /// The encryption key for your account
+ #[clap(long, short)]
+ pub key: Option<String>,
+}
+
+fn get_input() -> Result<String> {
+ 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<String>, 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<String>,
+
+ #[clap(long, short)]
+ pub email: Option<String>,
+
+ #[clap(long, short)]
+ pub password: Option<String>,
+}
+
+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<String>,
+ email: &Option<String>,
+ password: &Option<String>,
+) -> 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<String>,
+
+ /// Exclude directory from results
+ #[clap(long = "exclude-cwd")]
+ exclude_cwd: Option<String>,
+
+ /// Filter search result by exit code
+ #[clap(long, short)]
+ exit: Option<i64>,
+
+ /// Exclude results with this exit code
+ #[clap(long = "exclude-exit")]
+ exclude_exit: Option<i64>,
+
+ /// Only include results added before this date
+ #[clap(long, short)]
+ before: Option<String>,
+
+ /// Only include results after this date
+ #[clap(long)]
+ after: Option<String>,
+
+ /// Open interactive search UI
+ #[clap(long, short)]
+ interactive: bool,
+
+ /// Use human-readable formatting for time
+ #[clap(long)]
+ human: bool,
+
+ query: Vec<String>,
+
+ /// 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<History>,
+
+ 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<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.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<String> {
+ 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<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 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<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 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<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, 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<String>,
+ exit: Option<i64>,
+ interactive: bool,
+ human: bool,
+ exclude_exit: Option<i64>,
+ exclude_cwd: Option<String>,
+ before: Option<String>,
+ after: Option<String>,
+ 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<History> = 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<String> },
+}
+
+fn compute_stats(history: &[History]) -> Result<()> {
+ let mut commands = HashMap::<String, i64>::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(())
+}