use std::time::Duration;
use atuin_client::history::{History, HistoryStats};
use atuin_client::settings::Settings;
use atuin_common::string::EscapeNonPrintablePosixExt as _;
use atuin_common::time::{DurationExt, UtcOffsetSpec};
use ratatui::Frame;
use ratatui::backend::FromCrossterm;
use ratatui::layout::Rect;
use ratatui::prelude::{Constraint, Direction, Layout};
use ratatui::style::Style;
use ratatui::text::{Span, Text};
use ratatui::widgets::{Bar, BarChart, BarGroup, Block, Borders, Padding, Paragraph, Row, Table};
use time::macros::format_description;
use super::super::theme::{Meaning, Theme};
use super::interactive::{Compactness, to_compactness};
#[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,
compact: bool,
theme: &Theme,
) {
let commands = Layout::default()
.direction(if compact {
Direction::Vertical
} else {
Direction::Horizontal
})
.constraints(if compact {
[Constraint::Length(1), Constraint::Length(1), Constraint::Min(0)]
} else {
[Constraint::Ratio(1, 4), Constraint::Ratio(1, 2), Constraint::Ratio(1, 4)]
})
.split(parent);
let command = Paragraph::new(Text::from(Span::styled(
history.command.escape_non_printable(),
Style::from_crossterm(theme.as_style(Meaning::Important)),
)))
.block(if compact {
Block::new()
.borders(Borders::NONE)
.style(Style::from_crossterm(theme.as_style(Meaning::Base)))
} else {
Block::new()
.borders(Borders::ALL)
.style(Style::from_crossterm(theme.as_style(Meaning::Base)))
.title("Command")
.padding(Padding::horizontal(1))
});
let previous = Paragraph::new(stats.previous.clone().map_or_else(
|| "[No previous command]".to_string(),
|prev| prev.command.escape_non_printable().into_owned(),
))
.block(if compact {
Block::new()
.borders(Borders::NONE)
.style(Style::from_crossterm(theme.as_style(Meaning::Annotation)))
} else {
Block::new()
.borders(Borders::ALL)
.style(Style::from_crossterm(theme.as_style(Meaning::Annotation)))
.title("Previous command")
.padding(Padding::horizontal(1))
});
let next = Paragraph::new(stats.next.clone().map_or_else(
|| "[No next command]".to_string(),
|next| next.command.escape_non_printable().into_owned(),
))
.block(if compact {
Block::new()
.borders(Borders::NONE)
.style(Style::from_crossterm(theme.as_style(Meaning::Annotation)))
} else {
Block::new()
.borders(Borders::ALL)
.title("Next command")
.padding(Padding::horizontal(1))
.style(Style::from_crossterm(theme.as_style(Meaning::Annotation)))
});
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,
tz: UtcOffsetSpec,
stats: &HistoryStats,
theme: &Theme,
) {
let duration = Duration::saturating_from_nanos_i64(history.duration);
let avg_duration = Duration::from_nanos(stats.average_duration);
let host = history.cmd_origin.host().into_inner();
let user = history.cmd_origin.user().into_inner();
let rows = [
Row::new(vec!["Host".to_string(), host.to_string()]),
Row::new(vec!["User".to_string(), user.to_string()]),
Row::new(vec!["Time".to_string(), history.timestamp.to_offset(tz.0).to_string()]),
Row::new(vec!["Duration".to_string(), duration.display().largest_unit().to_string()]),
Row::new(vec![
"Avg duration".to_string(),
avg_duration.display().largest_unit().to_string(),
]),
Row::new(vec!["Exit".to_string(), history.exit.to_string()]),
Row::new(vec!["Directory".to_string(), history.cwd.clone()]),
Row::new(vec!["Intent".to_string(), history.intent.clone().unwrap_or_default()]),
Row::new(vec!["Session".to_string(), history.session.clone()]),
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)
.style(Style::from_crossterm(theme.as_style(Meaning::Base)))
.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_key(|a| a.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, theme: &Theme) {
let exits: Vec<Bar> = stats
.exits
.iter()
.map(|(exit, count)| Bar::default().label(exit.to_string()).value(u64_or_zero(*count)))
.collect();
let exits = BarChart::default()
.block(
Block::default()
.title("Exit code distribution")
.style(Style::from_crossterm(theme.as_style(Meaning::Base)))
.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())).value(u64_or_zero(*count))
})
.collect();
let day_of_week = BarChart::default()
.block(
Block::default()
.title("Runs per day")
.style(Style::from_crossterm(theme.as_style(Meaning::Base)))
.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::saturating_from_nanos_i64(*duration);
Bar::default()
.label(date.clone())
.value(u64_or_zero(*duration))
.text_value(d.display().largest_unit().to_string())
})
.collect();
let duration_over_time = BarChart::default()
.block(
Block::default()
.title("Duration over time")
.style(Style::from_crossterm(theme.as_style(Meaning::Base)))
.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,
settings: &Settings,
theme: &Theme,
tz: UtcOffsetSpec,
) {
let compactness = to_compactness(f, settings);
match compactness {
Compactness::Ultracompact => draw_ultracompact(f, chunk, history, stats, theme),
_ => draw_full(f, chunk, history, stats, theme, tz),
}
}
pub fn draw_ultracompact(
f: &mut Frame<'_>,
chunk: Rect,
history: &History,
stats: &HistoryStats,
theme: &Theme,
) {
draw_commands(f, chunk, history, stats, true, theme);
}
pub fn draw_full(
f: &mut Frame<'_>,
chunk: Rect,
history: &History,
stats: &HistoryStats,
theme: &Theme,
tz: UtcOffsetSpec,
) {
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, false, theme);
draw_stats_table(f, stats_layout[0], history, tz, stats, theme);
draw_stats_charts(f, stats_layout[1], stats, theme);
}
#[cfg(test)]
mod tests {
use atuin_client::history::{History, HistoryId, HistoryStats};
use atuin_client::theme::ThemeManager;
use atuin_domain::record::CmdOrigin;
use ratatui::backend::TestBackend;
use ratatui::prelude::*;
use rstest::*;
use time::OffsetDateTime;
use super::draw_ultracompact;
#[fixture]
fn mock_history_stats() -> (History, HistoryStats) {
let history = History {
id: HistoryId::from("test1".to_string()),
timestamp: OffsetDateTime::now_utc(),
duration: 3,
exit: 0,
command: "/bin/cmd".to_string(),
cwd: "/toot".to_string(),
session: "sesh1".to_string(),
#[allow(deprecated)]
cmd_origin: CmdOrigin::parse_lenient("hostn"),
author: "hostn".to_string(),
intent: None,
deleted_at: None,
shell: None,
author_kind: None,
};
let next = History {
id: HistoryId::from("test2".to_string()),
timestamp: OffsetDateTime::now_utc(),
duration: 2,
exit: 0,
command: "/bin/cmd -os".to_string(),
cwd: "/toot".to_string(),
session: "sesh1".to_string(),
#[allow(deprecated)]
cmd_origin: CmdOrigin::parse_lenient("hostn"),
author: "hostn".to_string(),
intent: None,
deleted_at: None,
shell: Some("bash".into()),
author_kind: None,
};
let prev = History {
id: HistoryId::from("test3".to_string()),
timestamp: OffsetDateTime::now_utc(),
duration: 1,
exit: 0,
command: "/bin/cmd -a".to_string(),
cwd: "/toot".to_string(),
session: "sesh1".to_string(),
#[allow(deprecated)]
cmd_origin: CmdOrigin::parse_lenient("hostn"),
author: "hostn".to_string(),
intent: None,
deleted_at: None,
shell: Some("nu".into()),
author_kind: None,
};
let stats = HistoryStats {
next: Some(next),
previous: Some(prev),
total: 2,
average_duration: 3,
exits: Vec::new(),
day_of_week: Vec::new(),
duration_over_time: Vec::new(),
};
(history, stats)
}
#[fixture]
fn theme_manager() -> ThemeManager {
ThemeManager::new(Some(true), Some(String::new()))
}
#[fixture]
fn terminal(
#[default(22u16)] w: u16,
#[default(5u16)] h: u16,
) -> (Terminal<TestBackend>, Rect) {
let t = Terminal::new(TestBackend::new(w, h)).expect("Could not create terminal");
let r = Rect::new(0, 0, w, h);
(t, r)
}
#[rstest]
fn test_output_looks_correct_for_ultracompact(
mock_history_stats: (History, HistoryStats),
mut theme_manager: ThemeManager,
#[from(terminal)] (mut terminal, chunk): (Terminal<TestBackend>, Rect),
) {
let (history, stats) = mock_history_stats;
let prev = stats.previous.clone().unwrap();
let next = stats.next.clone().unwrap();
let theme = theme_manager.load_theme("(none)", None);
let _ = terminal.draw(|f| draw_ultracompact(f, chunk, &history, &stats, theme));
let mut lines = [" "; 5].map(Line::from);
for (n, entry) in [prev, history, next].iter().enumerate() {
let mut l = lines[n].to_string();
l.replace_range(0..entry.command.len(), &entry.command);
lines[n] = Line::from(l);
}
terminal.backend().assert_buffer_lines(lines);
}
#[rstest]
fn control_chars_are_escaped_in_commands(
mock_history_stats: (History, HistoryStats),
mut theme_manager: ThemeManager,
#[from(terminal)]
#[with(40u16, 8u16)]
(mut terminal, chunk): (Terminal<TestBackend>, Rect),
) {
let (mut history, mut stats) = mock_history_stats;
history.command = "echo\0hi".to_string();
stats.previous.as_mut().unwrap().command = "prev\0cmd".to_string();
stats.next.as_mut().unwrap().command = "next\0cmd".to_string();
let theme = theme_manager.load_theme("(none)", None);
let _ = terminal.draw(|f| draw_ultracompact(f, chunk, &history, &stats, theme));
let rendered: String = terminal
.backend()
.buffer()
.content()
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect();
assert!(rendered.contains("^@"), "expected caret-escaped NUL (^@) in rendered output");
assert!(!rendered.contains('\u{0000}'), "raw NUL byte leaked to the terminal");
}
}