From d53ad84e5727f7cad09eee09a2444da81a88b643 Mon Sep 17 00:00:00 2001 From: P T Weir Date: Mon, 20 Oct 2025 21:02:40 +0100 Subject: 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 _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 --- crates/atuin-client/src/theme.rs | 37 +- .../atuin/src/command/client/search/inspector.rs | 190 ++++++++-- .../atuin/src/command/client/search/interactive.rs | 389 +++++++++++++++------ 3 files changed, 476 insertions(+), 140 deletions(-) diff --git a/crates/atuin-client/src/theme.rs b/crates/atuin-client/src/theme.rs index fc15bfd8..76ddbb22 100644 --- a/crates/atuin-client/src/theme.rs +++ b/crates/atuin-client/src/theme.rs @@ -51,7 +51,7 @@ pub struct ThemeDefinitionConfigBlock { pub parent: Option, } -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| { diff --git a/crates/atuin/src/command/client/search/inspector.rs b/crates/atuin/src/command/client/search/inspector.rs index a39e63a8..34d22eba 100644 --- a/crates/atuin/src/command/client/search/inspector.rs +++ b/crates/atuin/src/command/client/search/inspector.rs @@ -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); + } +} diff --git a/crates/atuin/src/command/client/search/interactive.rs b/crates/atuin/src/command/client/search/interactive.rs index f2471879..f50568e7 100644 --- a/crates/atuin/src/command/client/search/interactive.rs +++ b/crates/atuin/src/command/client/search/interactive.rs @@ -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, + next: Option, + previous: Option, +} + +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, tab_index: usize, + pub inspecting_state: InspectingState, + search: SearchState, engine: Box, now: Box 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> { 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, + 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,33 +872,48 @@ 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); + self.draw_preview(f, style, input_chunk, compactness, preview_chunk, preview); + } + } - let extra_width = UnicodeWidthStr::width(self.search.input.substring()); + #[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); - 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, - )); - } + 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<'_> { @@ -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 = None; + let mut inspecting: Option = 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, -- cgit v1.3.1