aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorEllie Huxtable <ellie@elliehuxtable.com>2024-01-12 16:02:08 +0000
committerGitHub <noreply@github.com>2024-01-12 16:02:08 +0000
commit99249ea319fca96ace8f3f4962534dc7a4bc5923 (patch)
tree61103701633c6e4afec21d1108faddbc20e90d9e
parentchore: update funding (#1543) (diff)
downloadatuin-99249ea319fca96ace8f3f4962534dc7a4bc5923.zip
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.
-rw-r--r--atuin-client/src/database.rs121
-rw-r--r--atuin-client/src/history.rs20
-rw-r--r--atuin/src/command/client/search.rs4
-rw-r--r--atuin/src/command/client/search/history_list.rs2
-rw-r--r--atuin/src/command/client/search/inspector.rs259
-rw-r--r--atuin/src/command/client/search/interactive.rs196
-rw-r--r--atuin/src/main.rs1
7 files changed, 564 insertions, 39 deletions
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<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)]
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<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(
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<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,
+ }
+}
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<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]);
+
+ 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("<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"),
+ ]))),
+
+ 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"),
+ ]))),
- help
+ _ => 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!")
}
}
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");