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 d4bae8cf61.
This commit is contained in:
Ellie Huxtable 2024-01-12 16:02:08 +00:00 committed by GitHub
parent a60d8934c5
commit 99249ea319
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 564 additions and 39 deletions

View File

@ -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<Vec<History>>;
async fn all_with_count(&self) -> Result<Vec<(History, i32)>>;
async fn stats(&self, h: &History) -> Result<HistoryStats>;
}
// 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<HistoryStats> {
// 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)]

View File

@ -71,6 +71,26 @@ pub struct History {
pub deleted_at: Option<OffsetDateTime>,
}
#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)]
pub struct HistoryStats {
/// The command that was ran after this one in the session
pub next: Option<History>,
///
/// The command that was ran before this one in the session
pub previous: Option<History>,
/// 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(

View File

@ -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)]

View File

@ -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],

View File

@ -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<Bar> = 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<Bar> = 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<Bar> = 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,
}
}

View File

@ -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<Version>,
results_state: ListState,
@ -56,6 +62,7 @@ struct State {
search_mode: SearchMode,
results_len: usize,
accept: bool,
tab_index: usize,
search: SearchState,
engine: Box<dyn SearchEngine>,
@ -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<HistoryStats>,
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]);
let results_list = Self::build_results_list(style, results);
f.render_stateful_widget(results_list, results_list_chunk, &mut self.results_state);
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;
}
_ => {
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("<esc>", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(": exit"),
Span::raw(", "),
Span::styled("<tab>", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(": edit"),
Span::raw(", "),
Span::styled("<enter>", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(": execute"),
Span::raw(", "),
Span::styled("<ctrl-r>", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(": filter toggle"),
])))
.style(Style::default().fg(Color::DarkGray))
.alignment(Alignment::Center);
fn build_help(&self) -> Paragraph {
match self.tab_index {
// search
0 => Paragraph::new(Text::from(Line::from(vec![
Span::styled("<esc>", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(": exit"),
Span::raw(", "),
Span::styled("<tab>", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(": edit"),
Span::raw(", "),
Span::styled("<enter>", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(": execute"),
Span::raw(", "),
Span::styled("<ctrl-r>", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(": filter toggle"),
Span::raw(", "),
Span::styled("<ctrl-i>", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(": open inspector"),
]))),
help
1 => Paragraph::new(Text::from(Line::from(vec![
Span::styled("<esc>", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(": exit"),
Span::raw(", "),
Span::styled("<ctrl-i>", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(": search"),
Span::raw(", "),
Span::styled("<ctrl-d>", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(": delete"),
]))),
_ => unreachable!("invalid tab index"),
}
.style(Style::default().fg(Color::DarkGray))
.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<HistoryStats> = 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!")
}
}

View File

@ -5,6 +5,7 @@ use clap::Parser;
use eyre::Result;
use command::AtuinCmd;
mod command;
const VERSION: &str = env!("CARGO_PKG_VERSION");