feat: Interactive Inspector (#2319)

### What does this PR do?

Adds simple navigation to the inspector, to explore a session starting
from a single command. This creates a new user flow, where a user can
find a history entry in the interactive view (in, say, Global mode), and
hit Ctrl+o to navigate back and forward through that command's session.

IMAGINED USE-CASE: I remembered that I did a sequence of git steps but I
can't remember the order and forgot to document it. I remember that
`reflog` was involved and want to see the actual sequence, and only
those commands.

IMAGINED USE-CASE: I used a curl command to get my IP address for
greenlisting before I connected to the bastion server `abc.xyz` over SSH
- I could easily find the SSH command with abc.xyz, and go back one step
in the session, but without this change, scrolling through all my curl
commands ever run to find a forgotten URL/domain would be too much work.

Since this gives the inspector tab a broader purpose than viewing
analytics, it needs to function even when there are not enough screen
rows for charts -- hence, this PR also introduces an ultracompact mode
for the inspector that _just_ shows the neighbouring history commands
(as simple scrolling three-entry list, with no panes) if there are fewer
than `auto_hide_height` rows (default: 8). Otherwise, the inspector
behaves as normal, except that Up / Down will change the focused command
by navigating through the session. That means there is no "compact" mode
for the inspector - when the interactive search is compact (but not
ultracompact), the inspector shows its usual chart view.

The UX for this could be improved - to keep this PR as lean as it
realistically can be, I have tried to keep the flow very minimal, but a
follow-up PR could introduce some tooltips, nicer ultracompact
formatting, etc.

A minor QoL improvement that comes with this - since I had to deal with
bold text and would otherwise have need a theming exception, I took the
opportunity to ensure the theme engine sets styles completely (so a
theme can have bold), not just colours. To limit scope creep, I do not
add TOML syntax so (for now) you can only customize colours from config
files, but it means that default-bold text (etc.) can now use the
theming engine if the code-defined default Meaning is bolded.

Key changes:

* introduces a simplified inspector tab, with only previous-current-next
commands as rows, in compact mode
* allows navigation through session history within the inspector (so
compact inspector view is still useful)

It also (see comments below):

* makes `compact` into `compactness`, an enum (to better standardize
across inspector/interactive)
* makes the inspector _only_ change layout for ultracompact mode, which
is still compact+(height<8)
* clippy's complexity limit wanted draw split up a little, so not sure
if this is a reasonable minimal way to do so for now
* adds a `(none)` theme to the theming to enable output testing without
styling
* ~~additional tests, although keen for input on how best to do these~~
one functional test, as a starting point
* ~~documentation~~ [minor doc changes
only](https://github.com/atuinsh/docs/pull/72), as I am not sure there
is much to say

<!-- Thank you for making a PR! Bug fixes are always welcome, but if
you're adding a new feature or changing an existing one, we'd really
appreciate if you open an issue, post on the forum, or drop in on
Discord -->

_Was stacked on #2357, which is now in `main`_

## Checks
- [x] I am happy for maintainers to push small adjustments to this PR,
to speed up the review cycle
- [x] I have checked that there are no existing pull requests for the
same thing
This commit is contained in:
P T Weir
2025-10-20 21:02:40 +01:00
committed by GitHub
parent a6abc717b4
commit d53ad84e57
3 changed files with 477 additions and 141 deletions

View File

@@ -51,7 +51,7 @@ pub struct ThemeDefinitionConfigBlock {
pub parent: Option<String>,
}
use crossterm::style::{Color, ContentStyle};
use crossterm::style::{Attribute, Attributes, Color, ContentStyle};
// For now, a theme is loaded as a mapping of meanings to colors, but it may be desirable to
// expand that in the future to general styles, so we populate a Meaning->ContentStyle hashmap.
@@ -228,6 +228,14 @@ impl StyleFactory {
..ContentStyle::default()
}
}
fn from_fg_color_and_attributes(color: Color, attributes: Attributes) -> ContentStyle {
ContentStyle {
foreground_color: Some(color),
attributes,
..ContentStyle::default()
}
}
}
// Built-in themes. Rather than having extra files added before any theming
@@ -275,7 +283,10 @@ lazy_static! {
),
(
Meaning::Important,
StyleFactory::from_fg_color(Color::White),
StyleFactory::from_fg_color_and_attributes(
Color::White,
Attributes::from(Attribute::Bold),
),
),
(Meaning::Muted, StyleFactory::from_fg_color(Color::Grey)),
(Meaning::Base, ContentStyle::default()),
@@ -285,6 +296,19 @@ lazy_static! {
static ref BUILTIN_THEMES: HashMap<&'static str, Theme> = {
HashMap::from([
("default", HashMap::new()),
(
"(none)",
HashMap::from([
(Meaning::AlertError, ContentStyle::default()),
(Meaning::AlertWarn, ContentStyle::default()),
(Meaning::AlertInfo, ContentStyle::default()),
(Meaning::Annotation, ContentStyle::default()),
(Meaning::Guidance, ContentStyle::default()),
(Meaning::Important, ContentStyle::default()),
(Meaning::Muted, ContentStyle::default()),
(Meaning::Base, ContentStyle::default()),
]),
),
(
"autumn",
HashMap::from([
@@ -430,7 +454,7 @@ impl ThemeManager {
}
Some(self.load_theme(parent_name.as_str(), Some(max_depth - 1)))
}
None => None,
None => Some(self.load_theme("default", Some(max_depth - 1))),
};
if debug && name != theme_config.theme.name {
@@ -461,7 +485,7 @@ impl ThemeManager {
Ok(theme) => theme,
Err(err) => {
log::warn!("Could not load theme {name}: {err}");
built_ins.get("default").unwrap()
built_ins.get("(none)").unwrap()
}
},
}
@@ -669,7 +693,8 @@ mod theme_tests {
testing_logger::validate(|captured_logs| assert_eq!(captured_logs.len(), 0));
// If the parent is not found, we end up with the base theme colors
// If the parent is not found, we end up with the no theme colors or styling
// as this is considered a (soft) error state.
let nunsolarized = Config::builder()
.add_source(ConfigFile::from_str(
"
@@ -692,7 +717,7 @@ mod theme_tests {
nunsolarized_theme
.as_style(Meaning::Guidance)
.foreground_color,
Some(Color::DarkBlue)
None
);
testing_logger::validate(|captured_logs| {

View File

@@ -11,13 +11,14 @@ use ratatui::{
layout::Rect,
prelude::{Constraint, Direction, Layout},
style::Style,
text::{Span, Text},
widgets::{Bar, BarChart, BarGroup, Block, Borders, Padding, Paragraph, Row, Table},
};
use super::duration::format_duration;
use super::super::theme::{Meaning, Theme};
use super::interactive::{InputAction, State};
use super::interactive::{Compactness, InputAction, State, to_compactness};
#[allow(clippy::cast_sign_loss)]
fn u64_or_zero(num: i64) -> u64 {
@@ -29,52 +30,83 @@ pub fn draw_commands(
parent: Rect,
history: &History,
stats: &HistoryStats,
compact: bool,
theme: &Theme,
) {
let commands = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Ratio(1, 4),
Constraint::Ratio(1, 2),
Constraint::Ratio(1, 4),
])
.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(history.command.clone()).block(
let command = Paragraph::new(Text::from(Span::styled(
history.command.clone(),
theme.as_style(Meaning::Important),
)))
.block(if compact {
Block::new()
.borders(Borders::NONE)
.style(theme.as_style(Meaning::Base))
} else {
Block::new()
.borders(Borders::ALL)
.title("Command")
.style(theme.as_style(Meaning::Base))
.padding(Padding::horizontal(1)),
);
.title("Command")
.padding(Padding::horizontal(1))
});
let previous = Paragraph::new(
stats
.previous
.clone()
.map_or_else(|| "No previous command".to_string(), |prev| prev.command),
.map_or_else(|| "[No previous command]".to_string(), |prev| prev.command),
)
.block(
.block(if compact {
Block::new()
.borders(Borders::NONE)
.style(theme.as_style(Meaning::Annotation))
} else {
Block::new()
.borders(Borders::ALL)
.title("Previous command")
.style(theme.as_style(Meaning::Annotation))
.padding(Padding::horizontal(1)),
);
.title("Previous command")
.padding(Padding::horizontal(1))
});
// Add [] around blank text, as when this is shown in a list
// compacted, it makes it more obviously control text.
let next = Paragraph::new(
stats
.next
.clone()
.map_or_else(|| "No next command".to_string(), |next| next.command),
.map_or_else(|| "[No next command]".to_string(), |next| next.command),
)
.block(
.block(if compact {
Block::new()
.borders(Borders::NONE)
.style(theme.as_style(Meaning::Annotation))
} else {
Block::new()
.borders(Borders::ALL)
.title("Next command")
.padding(Padding::horizontal(1))
.style(theme.as_style(Meaning::Annotation))
.padding(Padding::horizontal(1)),
);
});
f.render_widget(previous, commands[0]);
f.render_widget(command, commands[1]);
@@ -254,6 +286,33 @@ fn draw_stats_charts(f: &mut Frame<'_>, parent: Rect, stats: &HistoryStats, them
}
pub fn draw(
f: &mut Frame<'_>,
chunk: Rect,
history: &History,
stats: &HistoryStats,
settings: &Settings,
theme: &Theme,
tz: Timezone,
) {
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,
@@ -271,7 +330,7 @@ pub fn draw(
.constraints([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)])
.split(vert_layout[1]);
draw_commands(f, vert_layout[0], history, stats, theme);
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);
}
@@ -279,7 +338,7 @@ pub fn draw(
// 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,
state: &mut State,
_settings: &Settings,
selected: usize,
input: &KeyEvent,
@@ -288,6 +347,93 @@ pub fn input(
match input.code {
KeyCode::Char('d') if ctrl => InputAction::Delete(selected),
KeyCode::Up => {
state.inspecting_state.move_to_previous();
InputAction::Redraw
}
KeyCode::Down => {
state.inspecting_state.move_to_next();
InputAction::Redraw
}
_ => InputAction::Continue,
}
}
#[cfg(test)]
mod tests {
use super::draw_ultracompact;
use atuin_client::{
history::{History, HistoryId, HistoryStats},
theme::ThemeManager,
};
use ratatui::{backend::TestBackend, prelude::*};
use time::OffsetDateTime;
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(),
hostname: "hostn".to_string(),
deleted_at: 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(),
hostname: "hostn".to_string(),
deleted_at: 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(),
hostname: "hostn".to_string(),
deleted_at: None,
};
let stats = HistoryStats {
next: Some(next.clone()),
previous: Some(prev.clone()),
total: 2,
average_duration: 3,
exits: Vec::new(),
day_of_week: Vec::new(),
duration_over_time: Vec::new(),
};
(history, stats)
}
#[test]
fn test_output_looks_correct_for_ultracompact() {
let backend = TestBackend::new(22, 5);
let mut terminal = Terminal::new(backend).expect("Could not create terminal");
let chunk = Rect::new(0, 0, 22, 5);
let (history, stats) = mock_history_stats();
let prev = stats.previous.clone().unwrap();
let next = stats.next.clone().unwrap();
let mut manager = ThemeManager::new(Some(true), Some("".to_string()));
let theme = manager.load_theme("(none)", None);
let _ = terminal.draw(|f| draw_ultracompact(f, chunk, &history, &stats, &theme));
let mut lines = [" "; 5].map(|l| Line::from(l));
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);
}
}

View File

@@ -17,7 +17,7 @@ use super::{
};
use atuin_client::{
database::{Database, current_context},
history::{History, HistoryStats, store::HistoryStore},
history::{History, HistoryId, HistoryStats, store::HistoryStore},
settings::{
CursorStyle, ExitMode, FilterMode, KeymapMode, PreviewStrategy, SearchMode, Settings,
},
@@ -54,6 +54,7 @@ const TAB_TITLES: [&str; 2] = ["Search", "Inspect"];
pub enum InputAction {
Accept(usize),
AcceptInspecting,
Copy(usize),
Delete(usize),
ReturnOriginal,
@@ -62,6 +63,49 @@ pub enum InputAction {
Redraw,
}
#[derive(Clone)]
pub struct InspectingState {
current: Option<HistoryId>,
next: Option<HistoryId>,
previous: Option<HistoryId>,
}
impl InspectingState {
pub fn move_to_previous(&mut self) {
let previous = self.previous.clone();
self.reset();
self.current = previous;
}
pub fn move_to_next(&mut self) {
let next = self.next.clone();
self.reset();
self.current = next;
}
pub fn reset(&mut self) {
self.current = None;
self.next = None;
self.previous = None;
}
}
pub fn to_compactness(f: &Frame, settings: &Settings) -> Compactness {
if match settings.style {
atuin_client::settings::Style::Auto => f.area().height < 14,
atuin_client::settings::Style::Compact => true,
atuin_client::settings::Style::Full => false,
} {
if settings.auto_hide_height != 0 && f.area().height <= settings.auto_hide_height {
Compactness::Ultracompact
} else {
Compactness::Compact
}
} else {
Compactness::Full
}
}
#[allow(clippy::struct_field_names)]
pub struct State {
history_count: i64,
@@ -76,14 +120,23 @@ pub struct State {
current_cursor: Option<CursorStyle>,
tab_index: usize,
pub inspecting_state: InspectingState,
search: SearchState,
engine: Box<dyn SearchEngine>,
now: Box<dyn Fn() -> OffsetDateTime + Send>,
}
#[derive(Clone, Copy)]
pub enum Compactness {
Ultracompact,
Compact,
Full,
}
#[derive(Clone, Copy)]
struct StyleState {
compact: bool,
compactness: Compactness,
invert: bool,
inner_width: usize,
}
@@ -96,6 +149,11 @@ impl State {
) -> Result<Vec<History>> {
let results = self.engine.query(&self.search, db).await?;
self.inspecting_state = InspectingState {
current: None,
next: None,
previous: None,
};
self.results_state.select(0);
self.results_len = results.len();
@@ -227,7 +285,13 @@ impl State {
KeyCode::Char('c' | 'g') if ctrl => Some(InputAction::ReturnOriginal),
KeyCode::Esc if esc_allow_exit => Some(Self::handle_key_exit(settings)),
KeyCode::Char('[') if ctrl && esc_allow_exit => Some(Self::handle_key_exit(settings)),
KeyCode::Tab => Some(InputAction::Accept(self.results_state.selected())),
KeyCode::Tab => match self.tab_index {
0 => Some(InputAction::Accept(self.results_state.selected())),
1 => Some(InputAction::AcceptInspecting),
_ => panic!("invalid tab index on input"),
},
KeyCode::Right if cursor_at_end_of_line && settings.keys.accept_past_line_end => {
Some(InputAction::Accept(self.results_state.selected()))
}
@@ -532,6 +596,7 @@ impl State {
fn scroll_down(&mut self, scroll_len: usize) {
let i = self.results_state.selected().saturating_sub(scroll_len);
self.inspecting_state.reset();
self.results_state.select(i);
}
@@ -539,6 +604,7 @@ impl State {
let i = self.results_state.selected() + scroll_len;
self.results_state
.select(i.min(self.results_len.saturating_sub(1)));
self.inspecting_state.reset();
}
#[allow(clippy::cast_possible_truncation)]
@@ -548,7 +614,7 @@ impl State {
results: &[History],
selected: usize,
tab_index: usize,
compact: bool,
compactness: Compactness,
border_size: u16,
preview_width: u16,
) -> u16 {
@@ -608,14 +674,13 @@ impl State {
}) + border_size * 2
} else if settings.show_preview && settings.preview.strategy == PreviewStrategy::Fixed {
settings.max_preview_height + border_size * 2
} else if compact || tab_index == 1 {
} else if !matches!(compactness, Compactness::Full) || tab_index == 1 {
0
} else {
1
}
}
#[allow(clippy::cast_possible_truncation)]
#[allow(clippy::bool_to_int_with_if)]
#[allow(clippy::too_many_lines)]
fn draw(
@@ -623,33 +688,31 @@ impl State {
f: &mut Frame,
results: &[History],
stats: Option<HistoryStats>,
inspecting: Option<&History>,
settings: &Settings,
theme: &Theme,
) {
let compact = match settings.style {
atuin_client::settings::Style::Auto => f.area().height < 14,
atuin_client::settings::Style::Compact => true,
atuin_client::settings::Style::Full => false,
};
let compactness = to_compactness(f, settings);
let invert = settings.invert;
let border_size = if compact { 0 } else { 1 };
let border_size = match compactness {
Compactness::Full => 1,
_ => 0,
};
let preview_width = f.area().width - 2;
let preview_height = Self::calc_preview_height(
settings,
results,
self.results_state.selected(),
self.tab_index,
compact,
compactness,
border_size,
preview_width,
);
let show_help = settings.show_help && (!compact || f.area().height > 1);
let show_help =
settings.show_help && (matches!(compactness, Compactness::Full) || f.area().height > 1);
// This is an OR, as it seems more likely for someone to wish to override
// tabs unexpectedly being missed, than unexpectedly present.
let hide_extra = settings.auto_hide_height != 0
&& compact
&& f.area().height <= settings.auto_hide_height;
let show_tabs = settings.show_tabs && !hide_extra;
let show_tabs = settings.show_tabs && !matches!(compactness, Compactness::Ultracompact);
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(0)
@@ -663,22 +726,23 @@ impl State {
Constraint::Length(if show_tabs { 1 } else { 0 }), // tabs
Constraint::Length(if show_help { 1 } else { 0 }), // header (sic)
]
} else if hide_extra {
[
Constraint::Length(if show_help { 1 } else { 0 }), // header
Constraint::Length(0), // tabs
Constraint::Min(1), // results list
Constraint::Length(0),
Constraint::Length(0),
]
} else {
[
Constraint::Length(if show_help { 1 } else { 0 }), // header
Constraint::Length(if show_tabs { 1 } else { 0 }), // tabs
Constraint::Min(1), // results list
Constraint::Length(1 + border_size), // input
Constraint::Length(preview_height), // preview
]
match compactness {
Compactness::Ultracompact => [
Constraint::Length(if show_help { 1 } else { 0 }), // header
Constraint::Length(0), // tabs
Constraint::Min(1), // results list
Constraint::Length(0),
Constraint::Length(0),
],
_ => [
Constraint::Length(if show_help { 1 } else { 0 }), // header
Constraint::Length(if show_tabs { 1 } else { 0 }), // tabs
Constraint::Min(1), // results list
Constraint::Length(1 + border_size), // input
Constraint::Length(preview_height), // preview
],
}
}
.as_ref(),
)
@@ -700,13 +764,13 @@ impl State {
.block(Block::default().borders(Borders::NONE))
.select(self.tab_index)
.style(Style::default())
.highlight_style(Style::default().bold().white().on_black());
.highlight_style(theme.as_style(Meaning::Important));
f.render_widget(tabs, tabs_chunk);
}
let style = StyleState {
compact,
compactness,
invert,
inner_width: input_chunk.width.into(),
};
@@ -732,15 +796,18 @@ impl State {
let stats_tab = self.build_stats(theme);
f.render_widget(stats_tab, header_chunks[2]);
let indicator: String = if !hide_extra {
" > ".to_string()
} else if self.switched_search_mode {
format!("S{}>", self.search_mode.as_str().chars().next().unwrap())
} else {
format!(
"{}> ",
self.search.filter_mode.as_str().chars().next().unwrap()
)
let indicator: String = match compactness {
Compactness::Ultracompact => {
if self.switched_search_mode {
format!("S{}>", self.search_mode.as_str().chars().next().unwrap())
} else {
format!(
"{}> ",
self.search.filter_mode.as_str().chars().next().unwrap()
)
}
}
_ => " > ".to_string(),
};
match self.tab_index {
@@ -775,11 +842,16 @@ impl State {
.alignment(Alignment::Center);
f.render_widget(message, results_list_chunk);
} else {
let inspecting = match inspecting {
Some(inspecting) => inspecting,
None => &results[self.results_state.selected()],
};
super::inspector::draw(
f,
results_list_chunk,
&results[self.results_state.selected()],
inspecting,
&stats.expect("Drawing inspector, but no stats"),
settings,
theme,
settings.timezone,
);
@@ -800,35 +872,50 @@ impl State {
}
}
if !hide_extra {
let input = self.build_input(style);
f.render_widget(input, input_chunk);
let preview_width = if compact {
preview_width
} else {
preview_width - 2
if !matches!(compactness, Compactness::Ultracompact) {
let preview_width = match compactness {
Compactness::Full => preview_width - 2,
_ => preview_width,
};
let preview = self.build_preview(
results,
compact,
compactness,
preview_width,
preview_chunk.width.into(),
theme,
);
f.render_widget(preview, preview_chunk);
let extra_width = UnicodeWidthStr::width(self.search.input.substring());
let cursor_offset = if compact { 0 } else { 1 };
f.set_cursor_position((
// Put cursor past the end of the input text
input_chunk.x + extra_width as u16 + PREFIX_LENGTH + 1 + cursor_offset,
input_chunk.y + cursor_offset,
));
self.draw_preview(f, style, input_chunk, compactness, preview_chunk, preview);
}
}
#[allow(clippy::cast_possible_truncation)]
fn draw_preview(
&self,
f: &mut Frame,
style: StyleState,
input_chunk: Rect,
compactness: Compactness,
preview_chunk: Rect,
preview: Paragraph,
) {
let input = self.build_input(style);
f.render_widget(input, input_chunk);
f.render_widget(preview, preview_chunk);
let extra_width = UnicodeWidthStr::width(self.search.input.substring());
let cursor_offset = match compactness {
Compactness::Full => 1,
_ => 0,
};
f.set_cursor_position((
// Put cursor past the end of the input text
input_chunk.x + extra_width as u16 + PREFIX_LENGTH + 1 + cursor_offset,
input_chunk.y + cursor_offset,
));
}
fn build_title(&self, theme: &Theme) -> Paragraph<'_> {
let title = if self.update_needed.is_some() {
let error_style: Style = theme.get_error().into();
@@ -916,21 +1003,24 @@ impl State {
show_numeric_shortcuts,
);
if style.compact {
results_list
} else if style.invert {
results_list.block(
Block::default()
.borders(Borders::LEFT | Borders::RIGHT)
.border_type(BorderType::Rounded)
.title(format!("{:─>width$}", "", width = style.inner_width - 2)),
)
} else {
results_list.block(
Block::default()
.borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
.border_type(BorderType::Rounded),
)
match style.compactness {
Compactness::Full => {
if style.invert {
results_list.block(
Block::default()
.borders(Borders::LEFT | Borders::RIGHT)
.border_type(BorderType::Rounded)
.title(format!("{:─>width$}", "", width = style.inner_width - 2)),
)
} else {
results_list.block(
Block::default()
.borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
.border_type(BorderType::Rounded),
)
}
}
_ => results_list,
}
}
@@ -947,28 +1037,31 @@ impl State {
debug_assert!(mode_width >= mode.len(), "mode name '{mode}' is too long!");
let input = format!("[{pref}{mode:^mode_width$}] {}", self.search.input.as_str(),);
let input = Paragraph::new(input);
if style.compact {
input
} else if style.invert {
input.block(
Block::default()
.borders(Borders::LEFT | Borders::RIGHT | Borders::TOP)
.border_type(BorderType::Rounded),
)
} else {
input.block(
Block::default()
.borders(Borders::LEFT | Borders::RIGHT)
.border_type(BorderType::Rounded)
.title(format!("{:─>width$}", "", width = style.inner_width - 2)),
)
match style.compactness {
Compactness::Full => {
if style.invert {
input.block(
Block::default()
.borders(Borders::LEFT | Borders::RIGHT | Borders::TOP)
.border_type(BorderType::Rounded),
)
} else {
input.block(
Block::default()
.borders(Borders::LEFT | Borders::RIGHT)
.border_type(BorderType::Rounded)
.title(format!("{:─>width$}", "", width = style.inner_width - 2)),
)
}
}
_ => input,
}
}
fn build_preview(
&self,
results: &[History],
compact: bool,
compactness: Compactness,
preview_width: u16,
chunk_width: usize,
theme: &Theme,
@@ -991,15 +1084,14 @@ impl State {
.join("\n")
};
if compact {
Paragraph::new(command).style(theme.as_style(Meaning::Annotation))
} else {
Paragraph::new(command).block(
match compactness {
Compactness::Full => Paragraph::new(command).block(
Block::default()
.borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT)
.border_type(BorderType::Rounded)
.title(format!("{:─>width$}", "", width = chunk_width - 2)),
)
),
_ => Paragraph::new(command).style(theme.as_style(Meaning::Annotation)),
}
}
}
@@ -1158,6 +1250,11 @@ pub async fn history(
switched_search_mode: false,
search_mode,
tab_index: 0,
inspecting_state: InspectingState {
current: None,
next: None,
previous: None,
},
search: SearchState {
input,
filter_mode: settings
@@ -1194,9 +1291,19 @@ pub async fn history(
}
let mut stats: Option<HistoryStats> = None;
let mut inspecting: Option<History> = None;
let accept;
let result = 'render: loop {
terminal.draw(|f| app.draw(f, &results, stats.clone(), settings, theme))?;
terminal.draw(|f| {
app.draw(
f,
&results,
stats.clone(),
inspecting.as_ref(),
settings,
theme,
);
})?;
let initial_input = app.search.input.as_str().to_owned();
let initial_filter_mode = app.search.filter_mode;
@@ -1217,6 +1324,7 @@ pub async fn history(
app.results_len -= 1;
let selected = app.results_state.selected();
if selected == app.results_len {
app.inspecting_state.reset();
app.results_state.select(selected - 1);
}
@@ -1233,7 +1341,7 @@ pub async fn history(
},
InputAction::Redraw => {
terminal.clear()?;
terminal.draw(|f| app.draw(f, &results, stats.clone(), settings, theme))?;
terminal.draw(|f| app.draw(f, &results, stats.clone(), inspecting.as_ref(), settings, theme))?;
},
r => {
accept = app.accept;
@@ -1258,11 +1366,39 @@ pub async fn history(
results = app.query_results(&mut db, settings.smart_sort).await?;
}
let inspecting_id = app.inspecting_state.clone().current;
// If inspecting ID is not the current inspecting History, update it.
match inspecting_id {
Some(inspecting_id) => {
if inspecting.is_none() || inspecting_id != inspecting.clone().unwrap().id {
inspecting = db.load(inspecting_id.0.as_str()).await?;
}
}
_ => {
inspecting = None;
}
}
stats = if app.tab_index == 0 {
None
} else if !results.is_empty() {
let selected = results[app.results_state.selected()].clone();
Some(db.stats(&selected).await?)
// If we have stats, then we can indicate next available IDs. This avoids passing
// around a database object, or a full stats object.
let selected = match inspecting.clone() {
Some(insp) => insp,
None => results[app.results_state.selected()].clone(),
};
let stats = db.stats(&selected).await?;
app.inspecting_state.current = Some(selected.id);
app.inspecting_state.previous = match stats.previous.clone() {
Some(p) => Some(p.id),
_ => None,
};
app.inspecting_state.next = match stats.next.clone() {
Some(p) => Some(p.id),
_ => None,
};
Some(stats)
} else {
None
};
@@ -1275,6 +1411,25 @@ pub async fn history(
}
match result {
InputAction::AcceptInspecting => {
match inspecting {
Some(result) => {
let mut command = result.command;
if accept
&& matches!(
Shell::from_env(),
Shell::Zsh | Shell::Fish | Shell::Bash | Shell::Xonsh
)
{
command = String::from("__atuin_accept__:") + &command;
}
// index is in bounds so we return that entry
Ok(command)
}
None => Ok(String::new()),
}
}
InputAction::Accept(index) if index < results.len() => {
let mut command = results.swap_remove(index).command;
@@ -1341,7 +1496,7 @@ mod tests {
use crate::command::client::search::engines::{self, SearchState};
use crate::command::client::search::history_list::ListState;
use super::State;
use super::{Compactness, InspectingState, State};
#[test]
#[allow(clippy::too_many_lines)]
@@ -1410,7 +1565,7 @@ mod tests {
&results,
0_usize,
0_usize,
false,
Compactness::Full,
1,
80,
);
@@ -1420,7 +1575,7 @@ mod tests {
&results,
1_usize,
0_usize,
false,
Compactness::Full,
1,
80,
);
@@ -1430,7 +1585,7 @@ mod tests {
&results,
2_usize,
0_usize,
false,
Compactness::Full,
1,
80,
);
@@ -1440,7 +1595,7 @@ mod tests {
&results,
0_usize,
0_usize,
false,
Compactness::Full,
1,
66,
);
@@ -1450,7 +1605,7 @@ mod tests {
&results,
2_usize,
0_usize,
false,
Compactness::Full,
1,
80,
);
@@ -1460,7 +1615,7 @@ mod tests {
&results,
1_usize,
0_usize,
false,
Compactness::Full,
1,
80,
);
@@ -1470,7 +1625,7 @@ mod tests {
&results,
1_usize,
0_usize,
false,
Compactness::Full,
1,
20,
);
@@ -1480,7 +1635,7 @@ mod tests {
&results,
1_usize,
0_usize,
false,
Compactness::Full,
1,
20,
);
@@ -1512,6 +1667,11 @@ mod tests {
prefix: false,
current_cursor: None,
tab_index: 0,
inspecting_state: InspectingState {
current: None,
next: None,
previous: None,
},
search: SearchState {
input: String::new().into(),
filter_mode: FilterMode::Directory,
@@ -1558,6 +1718,11 @@ mod tests {
prefix: false,
current_cursor: None,
tab_index: 0,
inspecting_state: InspectingState {
current: None,
next: None,
previous: None,
},
search: SearchState {
input: String::new().into(),
filter_mode: FilterMode::Global,