From 99249ea319fca96ace8f3f4962534dc7a4bc5923 Mon Sep 17 00:00:00 2001 From: Ellie Huxtable Date: Fri, 12 Jan 2024 16:02:08 +0000 Subject: feat: Add interactive command inspector (#1296) * Begin work on command inspector This is a separate pane in the interactive mode that allows for exploration and inspecting of specific commands. I've restructured things a bit. It made logical sense that things were nested under commands, however the whole point of `atuin` is to provide commands. Breaking things out like this enables a bit less crazy nesting as we add more functionality to things like interactive search. I'd like to add a few more interactive things and it was starting to feel very cluttered * Some vague tab things * functioning inspector with stats * add interactive delete to inspector * things * clippy * borders * sus * revert restructure for another pr * Revert "sus" This reverts commit d4bae8cf614d93b728621f7985cf4e387b6dc113. --- atuin-client/src/database.rs | 121 +++++++++++ atuin-client/src/history.rs | 20 ++ atuin/src/command/client/search.rs | 4 +- atuin/src/command/client/search/history_list.rs | 2 +- atuin/src/command/client/search/inspector.rs | 259 ++++++++++++++++++++++++ atuin/src/command/client/search/interactive.rs | 196 ++++++++++++++---- atuin/src/main.rs | 1 + 7 files changed, 564 insertions(+), 39 deletions(-) create mode 100644 atuin/src/command/client/search/inspector.rs diff --git a/atuin-client/src/database.rs b/atuin-client/src/database.rs index 6a6f5991..376c7b75 100644 --- a/atuin-client/src/database.rs +++ b/atuin-client/src/database.rs @@ -18,6 +18,8 @@ use sqlx::{ }; use time::OffsetDateTime; +use crate::history::HistoryStats; + use super::{ history::History, ordering, @@ -109,6 +111,8 @@ pub trait Database: Send + Sync + 'static { async fn query_history(&self, query: &str) -> Result>; async fn all_with_count(&self) -> Result>; + + async fn stats(&self, h: &History) -> Result; } // Intended for use on a developer machine and not a sync server. @@ -562,6 +566,123 @@ impl Database for Sqlite { Ok(()) } + + async fn stats(&self, h: &History) -> Result { + // We select the previous in the session by time + let mut prev = SqlBuilder::select_from("history"); + prev.field("*") + .and_where("timestamp < ?1") + .and_where("session = ?2") + .order_by("timestamp", true) + .limit(1); + + let mut next = SqlBuilder::select_from("history"); + next.field("*") + .and_where("timestamp > ?1") + .and_where("session = ?2") + .order_by("timestamp", false) + .limit(1); + + let mut total = SqlBuilder::select_from("history"); + total.field("count(1)").and_where("command = ?1"); + + let mut average = SqlBuilder::select_from("history"); + average.field("avg(duration)").and_where("command = ?1"); + + let mut exits = SqlBuilder::select_from("history"); + exits + .fields(&["exit", "count(1) as count"]) + .and_where("command = ?1") + .group_by("exit"); + + // rewrite the following with sqlbuilder + let mut day_of_week = SqlBuilder::select_from("history"); + day_of_week + .fields(&[ + "strftime('%w', ROUND(timestamp / 1000000000), 'unixepoch') AS day_of_week", + "count(1) as count", + ]) + .and_where("command = ?1") + .group_by("day_of_week"); + + // Intentionally format the string with 01 hardcoded. We want the average runtime for the + // _entire month_, but will later parse it as a datetime for sorting + // Sqlite has no datetime so we cannot do it there, and otherwise sorting will just be a + // string sort, which won't be correct. + let mut duration_over_time = SqlBuilder::select_from("history"); + duration_over_time + .fields(&[ + "strftime('01-%m-%Y', ROUND(timestamp / 1000000000), 'unixepoch') AS month_year", + "avg(duration) as duration", + ]) + .and_where("command = ?1") + .group_by("month_year") + .having("duration > 0"); + + let prev = prev.sql().expect("issue in stats previous query"); + let next = next.sql().expect("issue in stats next query"); + let total = total.sql().expect("issue in stats average query"); + let average = average.sql().expect("issue in stats previous query"); + let exits = exits.sql().expect("issue in stats exits query"); + let day_of_week = day_of_week.sql().expect("issue in stats day of week query"); + let duration_over_time = duration_over_time + .sql() + .expect("issue in stats duration over time query"); + + let prev = sqlx::query(&prev) + .bind(h.timestamp.unix_timestamp_nanos() as i64) + .bind(&h.session) + .map(Self::query_history) + .fetch_optional(&self.pool) + .await?; + + let next = sqlx::query(&next) + .bind(h.timestamp.unix_timestamp_nanos() as i64) + .bind(&h.session) + .map(Self::query_history) + .fetch_optional(&self.pool) + .await?; + + let total: (i64,) = sqlx::query_as(&total) + .bind(&h.command) + .fetch_one(&self.pool) + .await?; + + let average: (f64,) = sqlx::query_as(&average) + .bind(&h.command) + .fetch_one(&self.pool) + .await?; + + let exits: Vec<(i64, i64)> = sqlx::query_as(&exits) + .bind(&h.command) + .fetch_all(&self.pool) + .await?; + + let day_of_week: Vec<(String, i64)> = sqlx::query_as(&day_of_week) + .bind(&h.command) + .fetch_all(&self.pool) + .await?; + + let duration_over_time: Vec<(String, f64)> = sqlx::query_as(&duration_over_time) + .bind(&h.command) + .fetch_all(&self.pool) + .await?; + + let duration_over_time = duration_over_time + .iter() + .map(|f| (f.0.clone(), f.1.round() as i64)) + .collect(); + + Ok(HistoryStats { + next, + previous: prev, + total: total.0 as u64, + average_duration: average.0 as u64, + exits, + day_of_week, + duration_over_time, + }) + } } #[cfg(test)] diff --git a/atuin-client/src/history.rs b/atuin-client/src/history.rs index 8c312dc2..0147e25b 100644 --- a/atuin-client/src/history.rs +++ b/atuin-client/src/history.rs @@ -71,6 +71,26 @@ pub struct History { pub deleted_at: Option, } +#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)] +pub struct HistoryStats { + /// The command that was ran after this one in the session + pub next: Option, + /// + /// The command that was ran before this one in the session + pub previous: Option, + + /// How many times has this command been ran? + pub total: u64, + + pub average_duration: u64, + + pub exits: Vec<(i64, i64)>, + + pub day_of_week: Vec<(String, i64)>, + + pub duration_over_time: Vec<(String, i64)>, +} + impl History { #[allow(clippy::too_many_arguments)] fn new( diff --git a/atuin/src/command/client/search.rs b/atuin/src/command/client/search.rs index b9904def..0875f3ad 100644 --- a/atuin/src/command/client/search.rs +++ b/atuin/src/command/client/search.rs @@ -15,8 +15,10 @@ mod cursor; mod duration; mod engines; mod history_list; +mod inspector; mod interactive; -pub use duration::{format_duration, format_duration_into}; + +pub use duration::format_duration_into; #[allow(clippy::struct_excessive_bools, clippy::struct_field_names)] #[derive(Parser, Debug)] diff --git a/atuin/src/command/client/search/history_list.rs b/atuin/src/command/client/search/history_list.rs index ed23f639..de4b46ce 100644 --- a/atuin/src/command/client/search/history_list.rs +++ b/atuin/src/command/client/search/history_list.rs @@ -9,7 +9,7 @@ use ratatui::{ }; use time::OffsetDateTime; -use super::format_duration; +use super::duration::format_duration; pub struct HistoryList<'a> { history: &'a [History], diff --git a/atuin/src/command/client/search/inspector.rs b/atuin/src/command/client/search/inspector.rs new file mode 100644 index 00000000..060b4df6 --- /dev/null +++ b/atuin/src/command/client/search/inspector.rs @@ -0,0 +1,259 @@ +use std::time::Duration; +use time::macros::format_description; + +use atuin_client::{ + history::{History, HistoryStats}, + settings::Settings, +}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{ + layout::Rect, + prelude::{Constraint, Direction, Layout}, + style::Style, + widgets::{Bar, BarChart, BarGroup, Block, Borders, Padding, Paragraph, Row, Table}, + Frame, +}; + +use super::duration::format_duration; + +use super::interactive::{InputAction, State}; + +#[allow(clippy::cast_sign_loss)] +fn u64_or_zero(num: i64) -> u64 { + if num < 0 { + 0 + } else { + num as u64 + } +} + +pub fn draw_commands(f: &mut Frame<'_>, parent: Rect, history: &History, stats: &HistoryStats) { + let commands = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Ratio(1, 4), + Constraint::Ratio(1, 2), + Constraint::Ratio(1, 4), + ]) + .split(parent); + + let command = Paragraph::new(history.command.clone()).block( + Block::new() + .borders(Borders::ALL) + .title("Command") + .padding(Padding::horizontal(1)), + ); + + let previous = Paragraph::new( + stats + .previous + .clone() + .map_or("No previous command".to_string(), |prev| prev.command), + ) + .block( + Block::new() + .borders(Borders::ALL) + .title("Previous command") + .padding(Padding::horizontal(1)), + ); + + let next = Paragraph::new( + stats + .next + .clone() + .map_or("No next command".to_string(), |next| next.command), + ) + .block( + Block::new() + .borders(Borders::ALL) + .title("Next command") + .padding(Padding::horizontal(1)), + ); + + f.render_widget(previous, commands[0]); + f.render_widget(command, commands[1]); + f.render_widget(next, commands[2]); +} + +pub fn draw_stats_table(f: &mut Frame<'_>, parent: Rect, history: &History, stats: &HistoryStats) { + let duration = Duration::from_nanos(u64_or_zero(history.duration)); + let avg_duration = Duration::from_nanos(stats.average_duration); + + let rows = [ + Row::new(vec!["Time".to_string(), history.timestamp.to_string()]), + Row::new(vec!["Duration".to_string(), format_duration(duration)]), + Row::new(vec![ + "Avg duration".to_string(), + format_duration(avg_duration), + ]), + Row::new(vec!["Exit".to_string(), history.exit.to_string()]), + Row::new(vec!["Directory".to_string(), history.cwd.to_string()]), + Row::new(vec!["Session".to_string(), history.session.to_string()]), + Row::new(vec!["Total runs".to_string(), stats.total.to_string()]), + ]; + + let widths = [Constraint::Ratio(1, 5), Constraint::Ratio(4, 5)]; + + let table = Table::new(rows, widths).column_spacing(1).block( + Block::default() + .title("Command stats") + .borders(Borders::ALL) + .padding(Padding::vertical(1)), + ); + + f.render_widget(table, parent); +} + +fn num_to_day(num: &str) -> String { + match num { + "0" => "Sunday".to_string(), + "1" => "Monday".to_string(), + "2" => "Tuesday".to_string(), + "3" => "Wednesday".to_string(), + "4" => "Thursday".to_string(), + "5" => "Friday".to_string(), + "6" => "Saturday".to_string(), + _ => "Invalid day".to_string(), + } +} + +fn sort_duration_over_time(durations: &[(String, i64)]) -> Vec<(String, i64)> { + let format = format_description!("[day]-[month]-[year]"); + let output = format_description!("[month]/[year repr:last_two]"); + + let mut durations: Vec<(time::Date, i64)> = durations + .iter() + .map(|d| { + ( + time::Date::parse(d.0.as_str(), &format).expect("invalid date string from sqlite"), + d.1, + ) + }) + .collect(); + + durations.sort_by(|a, b| a.0.cmp(&b.0)); + + durations + .iter() + .map(|(date, duration)| { + ( + date.format(output).expect("failed to format sqlite date"), + *duration, + ) + }) + .collect() +} + +fn draw_stats_charts(f: &mut Frame<'_>, parent: Rect, stats: &HistoryStats) { + let exits: Vec = stats + .exits + .iter() + .map(|(exit, count)| { + Bar::default() + .label(exit.to_string().into()) + .value(u64_or_zero(*count)) + }) + .collect(); + + let exits = BarChart::default() + .block( + Block::default() + .title("Exit code distribution") + .borders(Borders::ALL), + ) + .bar_width(3) + .bar_gap(1) + .bar_style(Style::default()) + .value_style(Style::default()) + .label_style(Style::default()) + .data(BarGroup::default().bars(&exits)); + + let day_of_week: Vec = stats + .day_of_week + .iter() + .map(|(day, count)| { + Bar::default() + .label(num_to_day(day.as_str()).into()) + .value(u64_or_zero(*count)) + }) + .collect(); + + let day_of_week = BarChart::default() + .block(Block::default().title("Runs per day").borders(Borders::ALL)) + .bar_width(3) + .bar_gap(1) + .bar_style(Style::default()) + .value_style(Style::default()) + .label_style(Style::default()) + .data(BarGroup::default().bars(&day_of_week)); + + let duration_over_time = sort_duration_over_time(&stats.duration_over_time); + let duration_over_time: Vec = duration_over_time + .iter() + .map(|(date, duration)| { + let d = Duration::from_nanos(u64_or_zero(*duration)); + Bar::default() + .label(date.clone().into()) + .value(u64_or_zero(*duration)) + .text_value(format_duration(d)) + }) + .collect(); + + let duration_over_time = BarChart::default() + .block( + Block::default() + .title("Duration over time") + .borders(Borders::ALL), + ) + .bar_width(5) + .bar_gap(1) + .bar_style(Style::default()) + .value_style(Style::default()) + .label_style(Style::default()) + .data(BarGroup::default().bars(&duration_over_time)); + + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Ratio(1, 3), + Constraint::Ratio(1, 3), + Constraint::Ratio(1, 3), + ]) + .split(parent); + + f.render_widget(exits, layout[0]); + f.render_widget(day_of_week, layout[1]); + f.render_widget(duration_over_time, layout[2]); +} + +pub fn draw(f: &mut Frame<'_>, chunk: Rect, history: &History, stats: &HistoryStats) { + let vert_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Ratio(1, 5), Constraint::Ratio(4, 5)]) + .split(chunk); + + let stats_layout = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)]) + .split(vert_layout[1]); + + draw_commands(f, vert_layout[0], history, stats); + draw_stats_table(f, stats_layout[0], history, stats); + draw_stats_charts(f, stats_layout[1], stats); +} + +// I'm going to break this out more, but just starting to move things around before changing +// structure and making it nicer. +pub fn input( + _state: &mut State, + _settings: &Settings, + selected: usize, + input: &KeyEvent, +) -> InputAction { + let ctrl = input.modifiers.contains(KeyModifiers::CONTROL); + + match input.code { + KeyCode::Char('d') if ctrl => InputAction::Delete(selected), + _ => InputAction::Continue, + } +} diff --git a/atuin/src/command/client/search/interactive.rs b/atuin/src/command/client/search/interactive.rs index 7d0c2959..3e7e89e1 100644 --- a/atuin/src/command/client/search/interactive.rs +++ b/atuin/src/command/client/search/interactive.rs @@ -19,7 +19,7 @@ use unicode_width::UnicodeWidthStr; use atuin_client::{ database::{current_context, Database}, - history::History, + history::{History, HistoryStats}, settings::{ExitMode, FilterMode, SearchMode, Settings}, }; @@ -28,19 +28,25 @@ use super::{ engines::{SearchEngine, SearchState}, history_list::{HistoryList, ListState, PREFIX_LENGTH}, }; + use crate::{command::client::search::engines, VERSION}; + use ratatui::{ backend::CrosstermBackend, layout::{Alignment, Constraint, Direction, Layout}, + prelude::*, style::{Color, Modifier, Style}, text::{Line, Span, Text}, - widgets::{Block, BorderType, Borders, Paragraph}, + widgets::{Block, BorderType, Borders, Paragraph, Tabs}, Frame, Terminal, TerminalOptions, Viewport, }; -enum InputAction { +const TAB_TITLES: [&str; 2] = ["Search", "Inspect"]; + +pub enum InputAction { Accept(usize), Copy(usize), + Delete(usize), ReturnOriginal, ReturnQuery, Continue, @@ -48,7 +54,7 @@ enum InputAction { } #[allow(clippy::struct_field_names)] -struct State { +pub struct State { history_count: i64, update_needed: Option, results_state: ListState, @@ -56,6 +62,7 @@ struct State { search_mode: SearchMode, results_len: usize, accept: bool, + tab_index: usize, search: SearchState, engine: Box, @@ -131,8 +138,7 @@ impl State { // Use Ctrl-n instead of Alt-n? let modfr = if settings.ctrl_n_shortcuts { ctrl } else { alt }; - // reset the state, will be set to true later if user really did change it - self.switched_search_mode = false; + // core input handling, common for all tabs match input.code { KeyCode::Char('c' | 'g') if ctrl => return InputAction::ReturnOriginal, KeyCode::Esc => { @@ -144,6 +150,35 @@ impl State { KeyCode::Tab => { return InputAction::Accept(self.results_state.selected()); } + KeyCode::Char('i') if ctrl => { + self.tab_index = (self.tab_index + 1) % TAB_TITLES.len(); + + return InputAction::Continue; + } + + _ => {} + } + + // handle tab-specific input + // todo: split out search + match self.tab_index { + 0 => {} + + 1 => { + return super::inspector::input( + self, + settings, + self.results_state.selected(), + input, + ); + } + + _ => panic!("invalid tab index on input"), + } + // reset the state, will be set to true later if user really did change it + self.switched_search_mode = false; + + match input.code { KeyCode::Enter => { if settings.enter_accept { self.accept = true; @@ -321,7 +356,14 @@ impl State { #[allow(clippy::cast_possible_truncation)] #[allow(clippy::bool_to_int_with_if)] - fn draw(&mut self, f: &mut Frame, results: &[History], settings: &Settings) { + #[allow(clippy::too_many_lines)] + fn draw( + &mut self, + f: &mut Frame, + results: &[History], + stats: Option, + settings: &Settings, + ) { let compact = match settings.style { atuin_client::settings::Style::Auto => f.size().height < 14, atuin_client::settings::Style::Compact => true, @@ -330,7 +372,7 @@ impl State { let invert = settings.invert; let border_size = if compact { 0 } else { 1 }; let preview_width = f.size().width - 2; - let preview_height = if settings.show_preview { + let preview_height = if settings.show_preview && self.tab_index == 0 { let longest_command = results .iter() .max_by(|h1, h2| h1.command.len().cmp(&h2.command.len())); @@ -346,7 +388,7 @@ impl State { .sum(), ) }) + border_size * 2 - } else if compact { + } else if compact || self.tab_index == 1 { 0 } else { 1 @@ -362,11 +404,13 @@ impl State { Constraint::Length(1 + border_size), // input Constraint::Min(1), // results list Constraint::Length(preview_height), // preview + Constraint::Length(1), // tabs Constraint::Length(if show_help { 1 } else { 0 }), // header (sic) ] } else { [ Constraint::Length(if show_help { 1 } else { 0 }), // header + Constraint::Length(1), // tabs Constraint::Min(1), // results list Constraint::Length(1 + border_size), // input Constraint::Length(preview_height), // preview @@ -375,10 +419,25 @@ impl State { .as_ref(), ) .split(f.size()); - let input_chunk = if invert { chunks[0] } else { chunks[2] }; - let results_list_chunk = chunks[1]; - let preview_chunk = if invert { chunks[2] } else { chunks[3] }; - let header_chunk = if invert { chunks[3] } else { chunks[0] }; + + let input_chunk = if invert { chunks[0] } else { chunks[3] }; + let results_list_chunk = if invert { chunks[1] } else { chunks[2] }; + let preview_chunk = if invert { chunks[2] } else { chunks[4] }; + let tabs_chunk = if invert { chunks[3] } else { chunks[1] }; + let header_chunk = if invert { chunks[4] } else { chunks[0] }; + + // TODO: this should be split so that we have one interactive search container that is + // EITHER a search box or an inspector. But I'm not doing that now, way too much atm. + // also allocate less 🙈 + let titles = TAB_TITLES.iter().copied().map(Line::from).collect(); + + let tabs = Tabs::new(titles) + .block(Block::default().borders(Borders::NONE)) + .select(self.tab_index) + .style(Style::default()) + .highlight_style(Style::default().bold().on_black()); + + f.render_widget(tabs, tabs_chunk); let style = StyleState { compact, @@ -404,11 +463,35 @@ impl State { let help = self.build_help(); f.render_widget(help, header_chunks[1]); - let stats = self.build_stats(); - f.render_widget(stats, header_chunks[2]); + let stats_tab = self.build_stats(); + f.render_widget(stats_tab, header_chunks[2]); + + match self.tab_index { + 0 => { + let results_list = Self::build_results_list(style, results); + f.render_stateful_widget(results_list, results_list_chunk, &mut self.results_state); + } + + 1 => { + super::inspector::draw( + f, + results_list_chunk, + &results[self.results_state.selected()], + &stats.expect("Drawing inspector, but no stats"), + ); + + // HACK: I'm following up with abstracting this into the UI container, with a + // sub-widget for search + for inspector + let feedback = Paragraph::new("The inspector is new - please give feedback (good, or bad) at https://forum.atuin.sh"); + f.render_widget(feedback, input_chunk); + + return; + } - let results_list = Self::build_results_list(style, results); - f.render_stateful_widget(results_list, results_list_chunk, &mut self.results_state); + _ => { + panic!("invalid tab index"); + } + } let input = self.build_input(style); f.render_widget(input, input_chunk); @@ -443,24 +526,41 @@ impl State { } #[allow(clippy::unused_self)] - fn build_help(&mut self) -> Paragraph { - let help = Paragraph::new(Text::from(Line::from(vec![ - Span::styled("", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(": exit"), - Span::raw(", "), - Span::styled("", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(": edit"), - Span::raw(", "), - Span::styled("", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(": execute"), - Span::raw(", "), - Span::styled("", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(": filter toggle"), - ]))) + fn build_help(&self) -> Paragraph { + match self.tab_index { + // search + 0 => Paragraph::new(Text::from(Line::from(vec![ + Span::styled("", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(": exit"), + Span::raw(", "), + Span::styled("", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(": edit"), + Span::raw(", "), + Span::styled("", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(": execute"), + Span::raw(", "), + Span::styled("", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(": filter toggle"), + Span::raw(", "), + Span::styled("", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(": open inspector"), + ]))), + + 1 => Paragraph::new(Text::from(Line::from(vec![ + Span::styled("", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(": exit"), + Span::raw(", "), + Span::styled("", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(": search"), + Span::raw(", "), + Span::styled("", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(": delete"), + ]))), + + _ => unreachable!("invalid tab index"), + } .style(Style::default().fg(Color::DarkGray)) - .alignment(Alignment::Center); - - help + .alignment(Alignment::Center) } fn build_stats(&mut self) -> Paragraph { @@ -475,6 +575,7 @@ impl State { fn build_results_list(style: StyleState, results: &[History]) -> HistoryList { let results_list = HistoryList::new(results, style.invert); + if style.compact { results_list } else if style.invert { @@ -665,6 +766,7 @@ pub async fn history( update_needed: None, switched_search_mode: false, search_mode, + tab_index: 0, search: SearchState { input, filter_mode: if settings.workspaces && context.git_root.is_some() { @@ -685,9 +787,10 @@ pub async fn history( let mut results = app.query_results(&mut db).await?; + let mut stats: Option = None; let accept; let result = 'render: loop { - terminal.draw(|f| app.draw(f, &results, settings))?; + terminal.draw(|f| app.draw(f, &results, stats.clone(), settings))?; let initial_input = app.search.input.as_str().to_owned(); let initial_filter_mode = app.search.filter_mode; @@ -701,9 +804,21 @@ pub async fn history( loop { match app.handle_input(settings, &event::read()?, &mut std::io::stdout())? { InputAction::Continue => {}, + InputAction::Delete(index) => { + app.results_len -= 1; + let selected = app.results_state.selected(); + if selected == app.results_len { + app.results_state.select(selected - 1); + } + + let entry = results.remove(index); + db.delete(entry).await?; + + app.tab_index = 0; + }, InputAction::Redraw => { terminal.clear()?; - terminal.draw(|f| app.draw(f, &results, settings))?; + terminal.draw(|f| app.draw(f, &results, stats.clone(), settings))?; }, r => { accept = app.accept; @@ -727,6 +842,13 @@ pub async fn history( { results = app.query_results(&mut db).await?; } + + stats = if app.tab_index == 0 { + None + } else { + let selected = results[app.results_state.selected()].clone(); + Some(db.stats(&selected).await?) + }; }; if settings.inline_height > 0 { @@ -755,7 +877,7 @@ pub async fn history( // * out of bounds -> usually implies no selected entry so we return the input Ok(app.search.input.into_inner()) } - InputAction::Continue | InputAction::Redraw => { + InputAction::Continue | InputAction::Redraw | InputAction::Delete(_) => { unreachable!("should have been handled!") } } diff --git a/atuin/src/main.rs b/atuin/src/main.rs index 8a00177a..e24b0120 100644 --- a/atuin/src/main.rs +++ b/atuin/src/main.rs @@ -5,6 +5,7 @@ use clap::Parser; use eyre::Result; use command::AtuinCmd; + mod command; const VERSION: &str = env!("CARGO_PKG_VERSION"); -- cgit v1.3.1