mirror of
https://github.com/atuinsh/atuin.git
synced 2025-02-25 06:43:59 +01:00
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:
parent
a60d8934c5
commit
99249ea319
@ -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)]
|
||||
|
@ -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(
|
||||
|
@ -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)]
|
||||
|
@ -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],
|
||||
|
259
atuin/src/command/client/search/inspector.rs
Normal file
259
atuin/src/command/client/search/inspector.rs
Normal 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,
|
||||
}
|
||||
}
|
@ -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!")
|
||||
}
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ use clap::Parser;
|
||||
use eyre::Result;
|
||||
|
||||
use command::AtuinCmd;
|
||||
|
||||
mod command;
|
||||
|
||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
Loading…
Reference in New Issue
Block a user