use std::{ io::{IsTerminal, Write, stdout}, time::Duration, }; #[cfg(unix)] use std::io::Read as _; use crate::{ atuin_client::{ database::ClientSqlite, theme::{style_annotation, style_base, style_important}, }, atuin_common::{shell::Shell, utils::Escapable as _}, }; use eyre::Result; use time::OffsetDateTime; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use super::{ cursor::Cursor, engines::{SearchEngine, SearchState}, history_list::{HistoryList, ListState}, }; use crate::atuin_client::{ database::{Context, current_context}, history::{History, HistoryId, HistoryStats, store::HistoryStore}, settings::{ CursorStyle, ExitMode, FilterMode, KeymapMode, PreviewStrategy, SearchMode, Settings, UiColumn, }, }; use crate::command::client::search::history_list::HistoryHighlighter; use crate::command::client::search::keybindings::KeymapSet; use crate::{VERSION, command::client::search::engines}; use ratatui::{ Frame, Terminal, TerminalOptions, Viewport, backend::{CrosstermBackend, FromCrossterm}, crossterm::{ cursor::SetCursorStyle, event::{self, Event, KeyEvent, MouseEvent}, execute, queue, terminal, }, layout::{Alignment, Constraint, Direction, Layout}, prelude::Rect, style::{Modifier, Style}, text::{Line, Span, Text}, widgets::{Block, BorderType, Borders, Clear, Padding, Paragraph, Tabs}, }; #[cfg(not(target_os = "windows"))] use ratatui::crossterm::event::{ KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, }; const TAB_TITLES: [&str; 2] = ["Search", "Inspect"]; pub(crate) enum InputAction { Accept(usize), AcceptInspecting, Copy(usize), Delete(usize), DeleteAllMatching(usize), ReturnOriginal, ReturnQuery, Continue, Redraw, SwitchContext(Option), } #[derive(Clone)] pub(crate) struct InspectingState { current: Option, next: Option, previous: Option, } impl InspectingState { pub(crate) fn move_to_previous(&mut self) { let previous = self.previous.clone(); self.reset(); self.current = previous; } pub(crate) fn move_to_next(&mut self) { let next = self.next.clone(); self.reset(); self.current = next; } pub(crate) fn reset(&mut self) { self.current = None; self.next = None; self.previous = None; } } pub(crate) fn to_compactness(f: &Frame<'_>, settings: &Settings) -> Compactness { if match settings.style { crate::atuin_client::settings::Style::Auto => f.area().height < 14, crate::atuin_client::settings::Style::Compact => true, crate::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 } } #[expect(clippy::struct_field_names)] #[expect(clippy::struct_excessive_bools)] pub(crate) struct State { history_count: i64, results_state: ListState, switched_search_mode: bool, search_mode: SearchMode, results_len: usize, accept: bool, keymap_mode: KeymapMode, prefix: bool, current_cursor: Option, tab_index: usize, pending_vim_key: Option, original_input_empty: bool, pub(crate) inspecting_state: InspectingState, keymaps: KeymapSet, search: SearchState, engine: Box, now: Box OffsetDateTime + Send>, } #[derive(Clone, Copy)] pub(crate) enum Compactness { Ultracompact, Compact, Full, } #[derive(Clone, Copy)] struct StyleState { compactness: Compactness, invert: bool, inner_width: usize, } impl State { async fn query_results( &mut self, db: &mut ClientSqlite, smart_sort: bool, ) -> 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(); if smart_sort { Ok(crate::atuin_history::sort::sort( self.search.input.as_str(), results, )) } else { Ok(results) } } fn handle_input(&mut self, settings: &Settings, input: &Event) -> InputAction { match input { Event::Key(k) => self.handle_key_input(settings, k), Event::Mouse(m) => self.handle_mouse_input(*m, settings.invert), Event::Paste(d) => self.handle_paste_input(d), _ => InputAction::Continue, } } fn handle_mouse_input(&mut self, input: MouseEvent, inverted: bool) -> InputAction { match (input.kind, inverted) { (event::MouseEventKind::ScrollDown, false) | (event::MouseEventKind::ScrollUp, true) => { self.scroll_down(1); } (event::MouseEventKind::ScrollDown, true) | (event::MouseEventKind::ScrollUp, false) => { self.scroll_up(1); } _ => {} } InputAction::Continue } fn handle_paste_input(&mut self, input: &str) -> InputAction { for i in input.chars() { self.search.input.insert(i); } InputAction::Continue } fn cast_cursor_style(style: CursorStyle) -> SetCursorStyle { match style { CursorStyle::DefaultUserShape => SetCursorStyle::DefaultUserShape, CursorStyle::BlinkingBlock => SetCursorStyle::BlinkingBlock, CursorStyle::SteadyBlock => SetCursorStyle::SteadyBlock, CursorStyle::BlinkingUnderScore => SetCursorStyle::BlinkingUnderScore, CursorStyle::SteadyUnderScore => SetCursorStyle::SteadyUnderScore, CursorStyle::BlinkingBar => SetCursorStyle::BlinkingBar, CursorStyle::SteadyBar => SetCursorStyle::SteadyBar, } } fn set_keymap_cursor(&mut self, settings: &Settings, keymap_name: &str) { let cursor_style = if keymap_name == "__clear__" { None } else { settings.keymap_cursor.get(keymap_name).copied() } .or_else(|| self.current_cursor.map(|_| CursorStyle::DefaultUserShape)); if cursor_style != self.current_cursor && let Some(style) = cursor_style { self.current_cursor = cursor_style; drop(execute!(stdout(), Self::cast_cursor_style(style))); } } pub(crate) fn initialize_keymap_cursor(&mut self, settings: &Settings) { match self.keymap_mode { KeymapMode::Emacs => self.set_keymap_cursor(settings, "emacs"), KeymapMode::VimNormal => self.set_keymap_cursor(settings, "vim_normal"), KeymapMode::VimInsert => self.set_keymap_cursor(settings, "vim_insert"), KeymapMode::Auto => {} } } pub(crate) fn finalize_keymap_cursor(&mut self, settings: &Settings) { match settings.keymap_mode_shell { KeymapMode::Emacs => self.set_keymap_cursor(settings, "emacs"), KeymapMode::VimNormal => self.set_keymap_cursor(settings, "vim_normal"), KeymapMode::VimInsert => self.set_keymap_cursor(settings, "vim_insert"), KeymapMode::Auto => self.set_keymap_cursor(settings, "__clear__"), } } fn handle_key_exit(settings: &Settings) -> InputAction { match settings.exit_mode { ExitMode::ReturnOriginal => InputAction::ReturnOriginal, ExitMode::ReturnQuery => InputAction::ReturnQuery, } } /// Select the keymap for the current mode (ignoring prefix). fn mode_keymap(&self) -> &super::keybindings::Keymap { if self.tab_index == 1 { &self.keymaps.inspector } else { match self.keymap_mode { KeymapMode::Emacs | KeymapMode::Auto => &self.keymaps.emacs, KeymapMode::VimNormal => &self.keymaps.vim_normal, KeymapMode::VimInsert => &self.keymaps.vim_insert, } } } /// Whether the current mode supports character insertion on unmatched keys. fn is_insert_mode(&self) -> bool { matches!( self.keymap_mode, KeymapMode::Emacs | KeymapMode::Auto | KeymapMode::VimInsert ) } fn handle_key_input(&mut self, settings: &Settings, input: &KeyEvent) -> InputAction { use super::keybindings::Action; use super::keybindings::EvalContext; use super::keybindings::key::{KeyCodeValue, KeyInput, SingleKey}; // Skip release events if input.kind == event::KeyEventKind::Release { return InputAction::Continue; } // Reset switched_search_mode at start of each key event self.switched_search_mode = false; // Build evaluation context from current state let ctx = EvalContext { cursor_position: self.search.input.position(), input_width: UnicodeWidthStr::width(self.search.input.as_str()), input_byte_len: self.search.input.as_str().len(), selected_index: self.results_state.selected(), results_len: self.results_len, original_input_empty: self.original_input_empty, has_context: self.search.custom_context.is_some(), }; // Convert KeyEvent to SingleKey let Some(single) = SingleKey::from_event(input) else { return InputAction::Continue; }; // --- Phase 1: Resolve (take pending key first, then immutable borrows) --- // Take pending key before any immutable borrows of self let pending = self.pending_vim_key.take(); // If in prefix mode, try prefix keymap first (single keys only) let prefix_action = if self.prefix { let ki = KeyInput::Single(single.clone()); self.keymaps.prefix.resolve(&ki, &ctx) } else { None }; // The if-let/else-if chain here is clearer than map_or_else with nested closures. #[expect(clippy::option_if_let_else)] let (action, new_pending) = if prefix_action.is_some() { (prefix_action, None) } else { // Use mode keymap (handles both single and multi-key sequences) let keymap = self.mode_keymap(); if let Some(pending_char) = pending { // We have a pending key from a previous press (e.g., first 'g' of 'gg') let pending_single = SingleKey { code: KeyCodeValue::Char(pending_char), ctrl: false, alt: false, shift: false, super_key: false, }; let seq = KeyInput::Sequence(vec![pending_single, single.clone()]); let action = keymap .resolve(&seq, &ctx) .or_else(|| keymap.resolve(&KeyInput::Single(single.clone()), &ctx)); (action, None) } else if keymap.has_sequence_starting_with(&single) && matches!(single.code, KeyCodeValue::Char(_)) && !single.ctrl && !single.alt { // This key starts a multi-key sequence; wait for next key let KeyCodeValue::Char(c) = single.code else { unreachable!() }; (Some(Action::Noop), Some(c)) } else { ( keymap.resolve(&KeyInput::Single(single.clone()), &ctx), None, ) } }; // --- Phase 2: Apply mutations --- self.pending_vim_key = new_pending; // Reset prefix (before execute, so EnterPrefixMode can re-set it) self.prefix = false; if let Some(action) = action { self.execute_action(&action, settings) } else { // No action matched. In insert-capable modes, insert the character. if self.is_insert_mode() && !single.ctrl && !single.alt { match single.code { KeyCodeValue::Char(c) => { self.search.input.insert(c); } KeyCodeValue::Space => { self.search.input.insert(' '); } _ => {} } } InputAction::Continue } } 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); } fn scroll_up(&mut self, scroll_len: usize) { let i = self.results_state.selected() + scroll_len; self.results_state .select(i.min(self.results_len.saturating_sub(1))); self.inspecting_state.reset(); } /// Execute a resolved action, performing all side effects and returning the /// appropriate `InputAction` for the event loop. /// /// This is the "do it" half of the resolve+execute pipeline. The resolver /// decides *what* to do (which `Action`), and this function carries it out. /// /// Invert handling: scroll actions (`SelectNext`, `ScrollPageDown`, etc.) account /// for `settings.invert` so that keybindings are always in "visual" terms — /// users never need to think about invert in their keybinding config. #[expect(clippy::too_many_lines)] pub(crate) fn execute_action( &mut self, action: &super::keybindings::Action, settings: &Settings, ) -> InputAction { use crate::command::client::search::keybindings::Action; match action { // -- Cursor movement -- Action::CursorLeft => { self.search.input.left(); InputAction::Continue } Action::CursorRight => { self.search.input.right(); InputAction::Continue } Action::CursorWordLeft => { self.search .input .prev_word(&settings.word_chars, settings.word_jump_mode); InputAction::Continue } Action::CursorWordRight => { self.search .input .next_word(&settings.word_chars, settings.word_jump_mode); InputAction::Continue } Action::CursorWordEnd => { self.search.input.word_end(&settings.word_chars); InputAction::Continue } Action::CursorStart => { self.search.input.start(); InputAction::Continue } Action::CursorEnd => { self.search.input.end(); InputAction::Continue } // -- Editing -- Action::DeleteCharBefore => { self.search.input.back(); InputAction::Continue } Action::DeleteCharAfter => { self.search.input.remove(); InputAction::Continue } Action::DeleteWordBefore => { self.search .input .remove_prev_word(&settings.word_chars, settings.word_jump_mode); InputAction::Continue } Action::DeleteWordAfter => { self.search .input .remove_next_word(&settings.word_chars, settings.word_jump_mode); InputAction::Continue } Action::DeleteToWordBoundary => { // ctrl-w: remove trailing whitespace, then delete to word boundary while matches!(self.search.input.back(), Some(c) if c.is_whitespace()) {} while self.search.input.left() { if self.search.input.char().unwrap().is_whitespace() { self.search.input.right(); break; } self.search.input.remove(); } InputAction::Continue } Action::ClearLine => { self.search.input.clear(); InputAction::Continue } Action::ClearToStart => { self.search.input.clear_to_start(); InputAction::Continue } Action::ClearToEnd => { self.search.input.clear_to_end(); InputAction::Continue } // -- List navigation (invert-aware) -- Action::SelectNext => { if settings.invert { self.scroll_up(1); } else { self.scroll_down(1); } InputAction::Continue } Action::SelectPrevious => { if settings.invert { self.scroll_down(1); } else { self.scroll_up(1); } InputAction::Continue } // -- Page/half-page scroll (invert-aware) -- Action::ScrollHalfPageUp => { let scroll_len = self .results_state .max_entries() .saturating_sub(settings.scroll_context_lines) / 2; if settings.invert { self.scroll_down(scroll_len); } else { self.scroll_up(scroll_len); } InputAction::Continue } Action::ScrollHalfPageDown => { let scroll_len = self .results_state .max_entries() .saturating_sub(settings.scroll_context_lines) / 2; if settings.invert { self.scroll_up(scroll_len); } else { self.scroll_down(scroll_len); } InputAction::Continue } Action::ScrollPageUp => { let scroll_len = self .results_state .max_entries() .saturating_sub(settings.scroll_context_lines); if settings.invert { self.scroll_down(scroll_len); } else { self.scroll_up(scroll_len); } InputAction::Continue } Action::ScrollPageDown => { let scroll_len = self .results_state .max_entries() .saturating_sub(settings.scroll_context_lines); if settings.invert { self.scroll_up(scroll_len); } else { self.scroll_down(scroll_len); } InputAction::Continue } // -- Absolute jumps (invert-aware) -- Action::ScrollToTop => { // Visual top of history if settings.invert { self.results_state.select(0); } else { let last_idx = self.results_len.saturating_sub(1); self.results_state.select(last_idx); } self.inspecting_state.reset(); InputAction::Continue } Action::ScrollToBottom => { // Visual bottom of history if settings.invert { let last_idx = self.results_len.saturating_sub(1); self.results_state.select(last_idx); } else { self.results_state.select(0); } self.inspecting_state.reset(); InputAction::Continue } Action::ScrollToScreenTop => { // H — jump to top of visible screen let top = self.results_state.offset(); let visible = self.results_state.max_entries().min(self.results_len); let bottom = top + visible.saturating_sub(1); self.results_state .select(bottom.min(self.results_len.saturating_sub(1))); self.inspecting_state.reset(); InputAction::Continue } Action::ScrollToScreenMiddle => { // M — jump to middle of visible screen let top = self.results_state.offset(); let visible = self.results_state.max_entries().min(self.results_len); let middle = top + visible / 2; self.results_state .select(middle.min(self.results_len.saturating_sub(1))); self.inspecting_state.reset(); InputAction::Continue } Action::ScrollToScreenBottom => { // L — jump to bottom of visible screen let top_visible = self.results_state.offset(); self.results_state.select(top_visible); self.inspecting_state.reset(); InputAction::Continue } // -- Commands -- Action::Accept => { if self.tab_index == 1 { return InputAction::AcceptInspecting; } self.accept = true; InputAction::Accept(self.results_state.selected()) } Action::AcceptNth(n) => { self.accept = true; InputAction::Accept(self.results_state.selected() + *n as usize) } Action::ReturnSelection => { if self.tab_index == 1 { return InputAction::AcceptInspecting; } InputAction::Accept(self.results_state.selected()) } Action::ReturnSelectionNth(n) => { InputAction::Accept(self.results_state.selected() + *n as usize) } Action::Copy => InputAction::Copy(self.results_state.selected()), Action::Delete => InputAction::Delete(self.results_state.selected()), Action::DeleteAll => InputAction::DeleteAllMatching(self.results_state.selected()), Action::ReturnOriginal => InputAction::ReturnOriginal, Action::ReturnQuery => InputAction::ReturnQuery, Action::Exit => Self::handle_key_exit(settings), Action::Redraw => InputAction::Redraw, Action::CycleFilterMode => { self.search.rotate_filter_mode(settings, 1); InputAction::Continue } Action::CycleSearchMode => { self.switched_search_mode = true; self.search_mode = self.search_mode.next(settings); self.engine = engines::engine(self.search_mode, settings); InputAction::Continue } Action::SwitchContext => { InputAction::SwitchContext(Some(self.results_state.selected())) } Action::ClearContext => InputAction::SwitchContext(None), Action::ToggleTab => { self.tab_index = (self.tab_index + 1) % TAB_TITLES.len(); InputAction::Continue } // -- Mode changes -- Action::VimEnterNormal => { self.set_keymap_cursor(settings, "vim_normal"); self.keymap_mode = KeymapMode::VimNormal; InputAction::Continue } Action::VimEnterInsert => { self.set_keymap_cursor(settings, "vim_insert"); self.keymap_mode = KeymapMode::VimInsert; InputAction::Continue } Action::VimEnterInsertAfter => { self.search.input.right(); self.set_keymap_cursor(settings, "vim_insert"); self.keymap_mode = KeymapMode::VimInsert; InputAction::Continue } Action::VimEnterInsertAtStart => { self.search.input.start(); self.set_keymap_cursor(settings, "vim_insert"); self.keymap_mode = KeymapMode::VimInsert; InputAction::Continue } Action::VimEnterInsertAtEnd => { self.search.input.end(); self.set_keymap_cursor(settings, "vim_insert"); self.keymap_mode = KeymapMode::VimInsert; InputAction::Continue } Action::VimSearchInsert => { self.search.input.clear(); self.set_keymap_cursor(settings, "vim_insert"); self.keymap_mode = KeymapMode::VimInsert; InputAction::Continue } Action::VimChangeToEnd => { self.search.input.clear_to_end(); self.set_keymap_cursor(settings, "vim_insert"); self.keymap_mode = KeymapMode::VimInsert; InputAction::Continue } Action::EnterPrefixMode => { self.prefix = true; InputAction::Continue } // -- Inspector -- Action::InspectPrevious => { self.inspecting_state.move_to_previous(); InputAction::Redraw } Action::InspectNext => { self.inspecting_state.move_to_next(); InputAction::Redraw } // -- Special -- Action::Noop => InputAction::Continue, } } #[expect(clippy::cast_possible_truncation)] #[expect(clippy::bool_to_int_with_if)] fn calc_preview_height( settings: &Settings, results: &[History], selected: usize, tab_index: usize, compactness: Compactness, border_size: u16, preview_width: u16, ) -> u16 { if settings.show_preview && settings.preview.strategy == PreviewStrategy::Auto && tab_index == 0 && !results.is_empty() { let length_current_cmd = results[selected].command.len() as u16; // calculate the number of newlines in the command let num_newlines = results[selected] .command .chars() .filter(|&c| c == '\n') .count() as u16; if num_newlines > 0 { std::cmp::min( settings.max_preview_height, results[selected] .command .split('\n') .map(|line| { (line.len() as u16 + preview_width - 1 - border_size) / (preview_width - border_size) }) .sum(), ) + border_size * 2 } // The '- 19' takes the characters before the command (duration and time) into account else if length_current_cmd > preview_width - 19 { std::cmp::min( settings.max_preview_height, (length_current_cmd + preview_width - 1 - border_size) / (preview_width - border_size), ) + border_size * 2 } else { 1 } } else if settings.show_preview && settings.preview.strategy == PreviewStrategy::Static && tab_index == 0 { let longest_command = results .iter() .max_by(|h1, h2| h1.command.len().cmp(&h2.command.len())); longest_command.map_or(0, |v| { std::cmp::min( settings.max_preview_height, v.command .split('\n') .map(|line| { (line.len() as u16 + preview_width - 1 - border_size) / (preview_width - border_size) }) .sum(), ) }) + border_size * 2 } else if settings.show_preview && settings.preview.strategy == PreviewStrategy::Fixed { settings.max_preview_height + border_size * 2 } else if !matches!(compactness, Compactness::Full) || tab_index == 1 { 0 } else { 1 } } fn draw( &mut self, f: &mut Frame<'_>, results: &[History], stats: Option, inspecting: Option<&History>, settings: &Settings, popup_mode: bool, ) { let area = f.area(); if popup_mode { f.render_widget(Clear, area); } self.draw_inner(f, area, results, stats, inspecting, settings); } #[expect(clippy::too_many_lines)] #[expect(clippy::bool_to_int_with_if)] fn draw_inner( &mut self, f: &mut Frame<'_>, area: Rect, results: &[History], stats: Option, inspecting: Option<&History>, settings: &Settings, ) { let compactness = to_compactness(f, settings); let invert = settings.invert; let border_size = match compactness { Compactness::Full => 1, _ => 0, }; let preview_width = area.width.saturating_sub(2); let preview_height = Self::calc_preview_height( settings, results, self.results_state.selected(), self.tab_index, compactness, border_size, preview_width, ); let show_help = settings.show_help && (matches!(compactness, Compactness::Full) || 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 show_tabs = settings.show_tabs && !matches!(compactness, Compactness::Ultracompact); let chunks = Layout::default() .direction(Direction::Vertical) .margin(0) .horizontal_margin(1) .constraints::<&[Constraint]>( if invert { [ Constraint::Length(1 + border_size), // input Constraint::Min(1), // results list Constraint::Length(preview_height), // preview Constraint::Length(if show_tabs { 1 } else { 0 }), // tabs Constraint::Length(if show_help { 1 } else { 0 }), // header (sic) ] } else { 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(), ) .split(area); 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: Vec<_> = TAB_TITLES.iter().copied().map(Line::from).collect(); if show_tabs { let tabs = Tabs::new(titles) .block(Block::default().borders(Borders::NONE)) .select(self.tab_index) .style(Style::default()) .highlight_style(Style::from_crossterm(style_important())); f.render_widget(tabs, tabs_chunk); } let style = StyleState { compactness, invert, inner_width: input_chunk.width.into(), }; let header_chunks = Layout::default() .direction(Direction::Horizontal) .constraints::<&[Constraint]>( [ Constraint::Ratio(1, 5), Constraint::Ratio(3, 5), Constraint::Ratio(1, 5), ] .as_ref(), ) .split(header_chunk); let title = Self::build_title(); f.render_widget(title, header_chunks[0]); let help = self.build_help(settings); f.render_widget(help, header_chunks[1]); let stats_tab = self.build_stats(); f.render_widget(stats_tab, header_chunks[2]); let indicator: String = match compactness { Compactness::Ultracompact => { if self.switched_search_mode { format!("S{}>", self.search_mode.as_str().chars().next().unwrap()) } else if self.search.custom_context.is_some() { format!( "C{}>", self.search.filter_mode.as_str().chars().next().unwrap() ) } else { format!( "{}> ", self.search.filter_mode.as_str().chars().next().unwrap() ) } } _ => " > ".to_string(), }; match self.tab_index { 0 => { let history_highlighter = HistoryHighlighter { engine: self.engine.as_ref(), search_input: self.search.input.as_str(), }; let results_list = Self::build_results_list( style, results, self.keymap_mode, &self.now, indicator.as_str(), history_highlighter, settings.show_numeric_shortcuts, &settings.ui.columns, ); f.render_stateful_widget(results_list, results_list_chunk, &mut self.results_state); } 1 => { if results.is_empty() { let message = Paragraph::new("Nothing to inspect") .block( Block::new() .title(Line::from(" Info ".to_string())) .title_alignment(Alignment::Center) .borders(Borders::ALL) .padding(Padding::vertical(2)), ) .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, inspecting, &stats.expect("Drawing inspector, but no stats"), settings, settings.timezone, ); } // 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"); } } if !matches!(compactness, Compactness::Ultracompact) { let preview_width = match compactness { Compactness::Full => preview_width - 2, _ => preview_width, }; let preview = self.build_preview( results, compactness, preview_width, preview_chunk.width.into(), ); #[expect(clippy::cast_possible_truncation)] let prefix_width = settings .ui .columns .iter() .take_while(|col| !col.expand) .map(|col| col.width + 1) .sum::() + " > ".len() as u16; #[expect(clippy::cast_possible_truncation)] let min_prefix_width = "[ SRCH: FULLTXT ] ".len() as u16; self.draw_preview( f, style, input_chunk, compactness, preview_chunk, preview, std::cmp::max(prefix_width, min_prefix_width), ); } } #[expect(clippy::cast_possible_truncation, clippy::too_many_arguments)] fn draw_preview( &self, f: &mut Frame<'_>, style: StyleState, input_chunk: Rect, compactness: Compactness, preview_chunk: Rect, preview: Paragraph<'_>, prefix_width: u16, ) { let input = self.build_input(style, prefix_width); 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_width + cursor_offset, input_chunk.y + cursor_offset, )); } fn build_title<'a>() -> Paragraph<'a> { let title = { let style: Style = Style::from_crossterm(style_base()); Paragraph::new(Text::from(Span::styled( format!("Atuin v{VERSION}"), style.add_modifier(Modifier::BOLD), ))) }; title.alignment(Alignment::Left) } fn build_help(&self, settings: &Settings) -> Paragraph<'_> { match self.tab_index { // search 0 => Paragraph::new(Text::from(Line::from(vec![ Span::styled("", Style::default().add_modifier(Modifier::BOLD)), Span::raw(": exit"), Span::raw(", "), Span::styled("", Style::default().add_modifier(Modifier::BOLD)), Span::raw(": edit"), Span::raw(", "), Span::styled("", Style::default().add_modifier(Modifier::BOLD)), Span::raw(if settings.enter_accept { ": run" } else { ": edit" }), Span::raw(", "), Span::styled("", Style::default().add_modifier(Modifier::BOLD)), Span::raw(": inspect"), ]))), 1 => Paragraph::new(Text::from(Line::from(vec![ Span::styled("", Style::default().add_modifier(Modifier::BOLD)), Span::raw(": exit"), Span::raw(", "), Span::styled("", Style::default().add_modifier(Modifier::BOLD)), Span::raw(": search"), Span::raw(", "), Span::styled("", Style::default().add_modifier(Modifier::BOLD)), Span::raw(": delete"), ]))), _ => unreachable!("invalid tab index"), } .style(Style::from_crossterm(style_annotation())) .alignment(Alignment::Center) } fn build_stats(&self) -> Paragraph<'_> { Paragraph::new(Text::from(Span::raw(format!( "history count: {}", self.history_count, )))) .style(Style::from_crossterm(style_annotation())) .alignment(Alignment::Right) } #[expect(clippy::too_many_arguments)] fn build_results_list<'a>( style: StyleState, results: &'a [History], keymap_mode: KeymapMode, now: &'a dyn Fn() -> OffsetDateTime, indicator: &'a str, history_highlighter: HistoryHighlighter<'a>, show_numeric_shortcuts: bool, columns: &'a [UiColumn], ) -> HistoryList<'a> { let results_list = HistoryList::new( results, style.invert, keymap_mode == KeymapMode::VimNormal, now, indicator, history_highlighter, show_numeric_shortcuts, columns, ); 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, } } fn build_input(&self, style: StyleState, prefix_width: u16) -> Paragraph<'_> { let (pref, mode) = if self.switched_search_mode { (" SRCH:", self.search_mode.as_str()) } else if self.search.custom_context.is_some() { (" CTX:", self.search.filter_mode.as_str()) } else { ("", self.search.filter_mode.as_str()) }; // 3: surrounding "[" "] " let mode_width = usize::from(prefix_width) - pref.len() - 3; // sanity check to ensure we don't exceed the layout limits 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); 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], compactness: Compactness, preview_width: u16, chunk_width: usize, ) -> Paragraph<'_> { let selected = self.results_state.selected(); let command = if results.is_empty() { String::new() } else { let s = &results[selected].command; let mut lines = Vec::new(); for line in s.split('\n') { let line = line.escape_control(); let mut width = 0; let mut start = 0; for (idx, ch) in line.char_indices() { let w = ch.width().unwrap_or(0); // None for control chars which should not happen if width + w > preview_width.into() { lines.push(line[start..idx].to_owned()); start = idx; width = w; } else { width += w; } } if width != 0 { lines.push(line[start..].to_owned()); } } lines.join("\n") }; 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(Style::from_crossterm(style_annotation())), } } } /// The writer used for terminal output - either stdout or /dev/tty enum TerminalWriter { Stdout(std::io::Stdout), #[cfg(unix)] Tty(std::fs::File), } impl TerminalWriter { fn new() -> std::io::Result { let stdout = stdout(); if stdout.is_terminal() { return Ok(Self::Stdout(stdout)); } // If stdout is not a terminal (e.g., captured by command substitution), // fall back to /dev/tty so the TUI can still render. // This allows usage like: VAR=$(atuin search -i) #[cfg(unix)] { Ok(Self::Tty( std::fs::File::options() .read(true) .write(true) .open("/dev/tty")?, )) } } } impl Write for TerminalWriter { fn write(&mut self, buf: &[u8]) -> std::io::Result { match self { Self::Stdout(stdout) => stdout.write(buf), #[cfg(unix)] Self::Tty(file) => file.write(buf), } } fn flush(&mut self) -> std::io::Result<()> { match self { Self::Stdout(stdout) => stdout.flush(), #[cfg(unix)] Self::Tty(file) => file.flush(), } } } /// Screen state captured from atuin pty-proxy's screen server. #[cfg(unix)] struct SavedScreen { rows: u16, cols: u16, cursor_row: u16, cursor_col: u16, /// Pre-formatted ANSI bytes for each screen row, ready to write to stdout. rows_data: Vec>, } /// Connect to atuin pty-proxy's Unix socket and fetch the current screen state. /// /// The wire format is: /// ```text /// [rows: u16 BE][cols: u16 BE][cursor_row: u16 BE][cursor_col: u16 BE] /// [row_0_len: u32 BE][row_0_bytes...] /// [row_1_len: u32 BE][row_1_bytes...] /// ... /// ``` #[cfg(unix)] fn fetch_screen_state(socket_path: &str) -> Option { use std::os::unix::net::UnixStream; let mut stream = UnixStream::connect(socket_path).ok()?; stream.set_read_timeout(Some(Duration::from_secs(2))).ok()?; let mut data = Vec::new(); stream.read_to_end(&mut data).ok()?; if data.len() < 8 { return None; } let rows = u16::from_be_bytes([data[0], data[1]]); let cols = u16::from_be_bytes([data[2], data[3]]); let cursor_row = u16::from_be_bytes([data[4], data[5]]); let cursor_col = u16::from_be_bytes([data[6], data[7]]); // Parse length-prefixed rows let mut rows_data = Vec::with_capacity(rows as usize); let mut offset = 8; while offset + 4 <= data.len() { let row_len = u32::from_be_bytes([ data[offset], data[offset + 1], data[offset + 2], data[offset + 3], ]) as usize; offset += 4; if offset + row_len > data.len() { break; } rows_data.push(data[offset..offset + row_len].to_vec()); offset += row_len; } Some(SavedScreen { rows, cols, cursor_row, cursor_col, rows_data, }) } /// Restore the screen area that was covered by the popup. /// /// Writes the pre-formatted per-row ANSI bytes received from atuin pty-proxy /// directly to stdout, which correctly handles wide characters, colors, and /// all text attributes without needing a client-side vt100 parser. #[cfg(unix)] fn restore_popup_area(saved: &SavedScreen, popup_rect: Rect, scroll_offset: u16) { use ratatui::crossterm::cursor::MoveTo; let mut stdout = stdout(); for dy in 0..popup_rect.height { let target_row = popup_rect.y + dy; let source_row = (target_row + scroll_offset) as usize; // Clear only the popup region. The server-side rows_formatted() skips // default cells (spaces with default attributes) using cursor jumps, so // any popup content at those positions would remain if not cleared // beforehand. We write `popup_rect.width` spaces instead of // ClearType::CurrentLine so that only the popup area is cleared, not // the entire terminal line. drop(execute!( stdout, MoveTo(popup_rect.x, target_row), crossterm::style::SetAttribute(crossterm::style::Attribute::Reset), )); drop(write!( stdout, "{:width$}", "", width = popup_rect.width as usize )); drop(execute!(stdout, MoveTo(popup_rect.x, target_row))); if let Some(row_bytes) = saved.rows_data.get(source_row) { drop(stdout.write_all(row_bytes)); } } drop(execute!( stdout, MoveTo( saved.cursor_col, saved.cursor_row.saturating_sub(scroll_offset) ) )); drop(stdout.flush()); } struct Stdout { writer: TerminalWriter, inline_mode: bool, no_mouse: bool, } impl Stdout { pub(crate) fn new(inline_mode: bool, no_mouse: bool) -> std::io::Result { terminal::enable_raw_mode()?; let mut writer = TerminalWriter::new()?; if !inline_mode { execute!(writer, terminal::EnterAlternateScreen)?; } if !no_mouse { execute!(writer, event::EnableMouseCapture)?; } execute!(writer, event::EnableBracketedPaste)?; #[cfg(not(target_os = "windows"))] execute!( writer, PushKeyboardEnhancementFlags( KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES | KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS ), )?; Ok(Self { writer, inline_mode, no_mouse, }) } } impl Drop for Stdout { fn drop(&mut self) { #[cfg(not(target_os = "windows"))] if let Err(e) = execute!(self.writer, PopKeyboardEnhancementFlags) { tracing::error!(?e, "Failed to pop keyboard enhancement flags"); } if !self.inline_mode && let Err(e) = execute!(self.writer, terminal::LeaveAlternateScreen) { tracing::error!(?e, "Failed to leave alt screen mode"); } if !self.no_mouse && let Err(e) = execute!(self.writer, event::DisableMouseCapture) { tracing::error!(?e, "Failed to disable mouse capture"); } if let Err(e) = execute!(self.writer, event::DisableBracketedPaste) { tracing::error!(?e, "Failed to disable bracketed paste"); } if let Err(e) = terminal::disable_raw_mode() { tracing::error!(?e, "Failed to disable raw mode"); } } } impl Write for Stdout { fn write(&mut self, buf: &[u8]) -> std::io::Result { self.writer.write(buf) } fn flush(&mut self) -> std::io::Result<()> { self.writer.flush() } } // this is a big blob of horrible! clean it up! /// Compute the popup position and any scroll offset needed to make room. /// /// Given the cursor row, terminal dimensions, and desired popup height, /// returns `(popup_rect, scroll_offset)` where `scroll_offset` is the number /// of lines the caller should scroll the terminal up before rendering. /// /// This function performs no I/O — it is a pure computation. #[cfg(unix)] fn compute_popup_placement( cursor_row: u16, term_rows: u16, term_cols: u16, inline_height: u16, ) -> (Rect, u16) { let popup_w = term_cols; let popup_h = inline_height.min(term_rows); let space_below = term_rows.saturating_sub(cursor_row); let (popup_y, scroll) = if popup_h <= space_below { // Fits below cursor (cursor_row, 0u16) } else if cursor_row >= term_rows / 2 { // Bottom half — render above cursor (overlay on existing text) (cursor_row.saturating_sub(popup_h), 0u16) } else { // Top half, not enough space — scroll terminal to make room let scroll = popup_h.saturating_sub(space_below); let popup_y = cursor_row.saturating_sub(scroll); (popup_y, scroll) }; (Rect::new(0, popup_y, popup_w, popup_h), scroll) } // for now, it works. But it'd be great if it were more easily readable, and // modular. I'd like to add some more stats and stuff at some point #[expect(clippy::too_many_lines, clippy::cognitive_complexity)] pub(crate) async fn history( query: &[String], settings: &Settings, mut db: ClientSqlite, history_store: &HistoryStore, ) -> Result { let inline_height = if settings.shell_up_key_binding { settings .inline_height_shell_up_key_binding .unwrap_or(settings.inline_height) } else { settings.inline_height }; // Use fullscreen mode if the inline height doesn't fit in the terminal, // this will preserve the scroll position upon exit. // Also force fullscreen when stdout isn't a terminal (e.g., command substitution // like VAR=$(atuin search -i)). In that case, we need to use /dev/tty for the TUI and force // fullscreen mode (inline mode won't work as it requires cursor position queries // that don't work when stdout is captured). let inline_height = if !stdout().is_terminal() { 0 } else if let Ok(size) = terminal::size() && inline_height >= size.1 { 0 } else { inline_height }; // Popup mode: if running under atuin pty-proxy and inline mode is requested, // fetch the screen state and render as a centered overlay. #[cfg(unix)] let (saved_screen, popup_rect, popup_scroll_offset) = { let socket_path = std::env::var("ATUIN_PTY_PROXY_SOCKET") .or_else(|_| std::env::var("ATUIN_HEX_SOCKET")) .ok(); if let Some(ref path) = socket_path && inline_height > 0 { let saved = fetch_screen_state(path); if let Some(ref s) = saved { let (term_cols, term_rows) = terminal::size().unwrap_or((s.cols, s.rows)); let (popup_rect, scroll) = compute_popup_placement(s.cursor_row, term_rows, term_cols, inline_height); // Scroll terminal content up to make room if needed if scroll > 0 { use ratatui::crossterm::cursor::MoveTo; let mut stdout = stdout(); drop(execute!(stdout, MoveTo(0, term_rows - 1))); for _ in 0..scroll { drop(writeln!(stdout)); } drop(stdout.flush()); } (saved, popup_rect, scroll) } else { (None, Rect::default(), 0u16) } } else { (None, Rect::default(), 0u16) } }; let popup_mode = saved_screen.is_some(); let stdout = Stdout::new(inline_height > 0, settings.no_mouse)?; // In popup mode, clear the popup region on the physical terminal before // ratatui takes over. Ratatui's diff-based rendering compares against an // initially-empty buffer, so cells that remain "empty" (spaces with default // style) won't be written — leaving underlying terminal text visible. // By pre-clearing with spaces, those cells are already correct on screen. if popup_mode { use ratatui::crossterm::cursor::MoveTo; let mut raw_stdout = std::io::stdout(); // Queue all commands without flushing so the terminal receives them // as a single write — no intermediate cursor positions are visible. drop(queue!( raw_stdout, crossterm::style::SetAttribute(crossterm::style::Attribute::Reset) )); for row in popup_rect.y..popup_rect.y.saturating_add(popup_rect.height) { drop(queue!(raw_stdout, MoveTo(popup_rect.x, row))); drop(write!( raw_stdout, "{:width$}", "", width = popup_rect.width as usize )); } drop(raw_stdout.flush()); } let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::with_options( backend, TerminalOptions { viewport: if popup_mode { Viewport::Fixed(popup_rect) } else if inline_height > 0 { Viewport::Inline(inline_height) } else { Viewport::Fullscreen }, }, )?; let original_query = query.join(" "); // Check if this is a command chaining scenario let is_command_chaining = if settings.command_chaining { let trimmed = original_query.trim_end(); trimmed.ends_with("&&") || trimmed.ends_with('|') } else { false }; // For command chaining, start with empty input to allow searching for new commands let search_input = if is_command_chaining { String::new() } else { original_query.clone() }; let mut input = Cursor::from(search_input); // Put the cursor at the end of the query by default input.end(); let initial_context = current_context().await?; let history_count = db.history_count(false).await?; let search_mode = if settings.shell_up_key_binding { settings .search_mode_shell_up_key_binding .unwrap_or(settings.search_mode) } else { settings.search_mode }; let default_filter_mode = settings .filter_mode_shell_up_key_binding .filter(|_| settings.shell_up_key_binding) .unwrap_or_else(|| settings.default_filter_mode(initial_context.git_root.is_some())); let mut app = State { history_count, results_state: ListState::default(), switched_search_mode: false, search_mode, tab_index: 0, inspecting_state: InspectingState { current: None, next: None, previous: None, }, keymaps: KeymapSet::from_settings(settings), search: SearchState { input, filter_mode: default_filter_mode, context: initial_context.clone(), custom_context: None, }, engine: engines::engine(search_mode, settings), results_len: 0, accept: false, keymap_mode: match settings.keymap_mode { KeymapMode::Auto => KeymapMode::Emacs, value => value, }, current_cursor: None, now: if settings.prefers_reduced_motion { let now = OffsetDateTime::now_utc(); Box::new(move || now) } else { Box::new(OffsetDateTime::now_utc) }, prefix: false, pending_vim_key: None, original_input_empty: original_query.is_empty(), }; app.initialize_keymap_cursor(settings); let mut results = app.query_results(&mut db, settings.smart_sort).await?; if inline_height > 0 && !popup_mode { terminal.clear()?; } 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(), inspecting.as_ref(), settings, popup_mode, ); })?; let initial_input = app.search.input.as_str().to_owned(); let initial_filter_mode = app.search.filter_mode; let initial_search_mode = app.search_mode; let initial_custom_context = app.search.custom_context.clone(); let event_ready = tokio::task::spawn_blocking(|| event::poll(Duration::from_millis(250))); tokio::select! { event_ready = event_ready => { if event_ready?? { loop { match app.handle_input(settings, &event::read()?) { InputAction::Continue => {}, InputAction::Delete(index) => { if results.is_empty() { break; } 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); } let entry = results.remove(index); let ids = history_store.delete_entries([entry]).await?; history_store.incremental_build(&db, &ids).await?; app.tab_index = 0; }, InputAction::DeleteAllMatching(index) => { if results.is_empty() { break; } let command = results[index].command.clone(); // Remove matching entries from the visible results results.retain(|e| e.command != command); // Query the DB for ALL entries with this command and delete them let all_matching = db.query_history( &format!( "select * from history where command = '{}' and deleted_at is null", command.replace('\'', "''") ) ).await?; let ids = history_store.delete_entries(all_matching).await?; history_store.incremental_build(&db, &ids).await?; app.results_len = results.len(); app.results_state = ListState::default(); app.inspecting_state.reset(); app.tab_index = 0; }, InputAction::SwitchContext(index) => { if let Some(index) = index && let Some(entry) = results.get(index) { app.search.custom_context = Some(entry.id.clone()); app.search.context = Context::from_history(entry); app.search.filter_mode = FilterMode::Session; app.search.input = Cursor::from(String::new()); app.results_state = ListState::default(); } else { app.search.custom_context = None; app.search.context = initial_context.clone(); app.search.filter_mode = default_filter_mode; } }, InputAction::Redraw => { if !popup_mode { terminal.clear()?; } terminal.draw(|f| { app.draw(f, &results, stats.clone(), inspecting.as_ref(), settings, popup_mode); })?; }, r => { accept = app.accept; break 'render r; }, } if !event::poll(Duration::ZERO)? { break; } } } } } if initial_input != app.search.input.as_str() || initial_filter_mode != app.search.filter_mode || initial_search_mode != app.search_mode || initial_custom_context != app.search.custom_context { results = app.query_results(&mut db, settings.smart_sort).await?; } // In custom context mode, when no filter is applied, highlight the entry which was used // to enter the context when changing modes. This helps to find your way around. if app.search.custom_context.is_some() && app.search.input.as_str().is_empty() && (initial_custom_context != app.search.custom_context || initial_filter_mode != app.search.filter_mode) && let Some(history_id) = app.search.custom_context.clone() && let Some(pos) = results.iter().position(|entry| entry.id == history_id) { app.results_state.select(pos); } 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() { // 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 }; }; app.finalize_keymap_cursor(settings); if popup_mode { // In popup mode, restore the screen area that was covered by the popup. // This must happen before Stdout is dropped (which disables raw mode). #[cfg(unix)] if let Some(ref saved) = saved_screen { restore_popup_area(saved, popup_rect, popup_scroll_offset); } } else if inline_height > 0 { terminal.clear()?; } let accept = accept && matches!( Shell::from_env(), Shell::Zsh | Shell::Fish | Shell::Bash | Shell::Xonsh | Shell::Nu | Shell::Powershell ); let accept_prefix = "__atuin_accept__:"; match result { InputAction::AcceptInspecting => { match inspecting { Some(result) => { let mut command = result.command; if accept { command = String::from(accept_prefix) + &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; if is_command_chaining { command = format!("{} {}", original_query.trim_end(), command); } else if accept { command = String::from(accept_prefix) + &command; } // index is in bounds so we return that entry Ok(command) } InputAction::ReturnOriginal => Ok(String::new()), InputAction::Copy(index) => { let cmd = results.swap_remove(index).command; set_clipboard(cmd); Ok(String::new()) } InputAction::ReturnQuery | InputAction::Accept(_) => { // Either: // * index == RETURN_QUERY, in which case we should return the input // * out of bounds -> usually implies no selected entry so we return the input Ok(app.search.input.into_inner()) } InputAction::Continue | InputAction::Redraw | InputAction::Delete(_) | InputAction::DeleteAllMatching(_) | InputAction::SwitchContext(_) => { unreachable!("should have been handled!") } } } // cli-clipboard only works on Windows, Mac, and Linux. #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] fn set_clipboard(s: String) { let mut ctx = arboard::Clipboard::new().unwrap(); ctx.set_text(s).unwrap(); // Use the clipboard context to make sure it is saved ctx.get_text().unwrap(); } #[cfg(test)] mod tests { use crate::atuin_client::database::Context; use crate::atuin_client::history::History; use crate::atuin_client::settings::{ FilterMode, KeymapMode, Preview, PreviewStrategy, SearchMode, Settings, }; use time::OffsetDateTime; use crate::command::client::search::engines::{self, SearchState}; use crate::command::client::search::history_list::ListState; use super::{Compactness, InspectingState, KeymapSet, State}; #[test] #[expect(clippy::too_many_lines)] fn calc_preview_height_test() { let settings_preview_auto = Settings { preview: Preview { strategy: PreviewStrategy::Auto, }, show_preview: true, ..Settings::new().unwrap() }; let settings_preview_auto_h2 = Settings { preview: Preview { strategy: PreviewStrategy::Auto, }, show_preview: true, max_preview_height: 2, ..Settings::new().unwrap() }; let settings_preview_h4 = Settings { preview: Preview { strategy: PreviewStrategy::Static, }, show_preview: true, max_preview_height: 4, ..Settings::new().unwrap() }; let settings_preview_fixed = Settings { preview: Preview { strategy: PreviewStrategy::Fixed, }, show_preview: true, max_preview_height: 15, ..Settings::new().unwrap() }; let cmd_60: History = History::capture() .timestamp(OffsetDateTime::now_utc()) .command("for i in $(seq -w 10); do echo \"item number $i - abcd\"; done") .cwd("/") .build() .into(); let cmd_124: History = History::capture() .timestamp(OffsetDateTime::now_utc()) .command("echo 'Aurea prima sata est aetas, quae vindice nullo, sponte sua, sine lege fidem rectumque colebat. Poena metusque aberant'") .cwd("/") .build() .into(); let cmd_200: History = History::capture() .timestamp(OffsetDateTime::now_utc()) .command("CREATE USER atuin WITH ENCRYPTED PASSWORD 'supersecretpassword'; CREATE DATABASE atuin WITH OWNER = atuin; \\c atuin; REVOKE ALL PRIVILEGES ON SCHEMA public FROM PUBLIC; echo 'All done. 200 characters'") .cwd("/") .build() .into(); let results: Vec = vec![cmd_60, cmd_124, cmd_200]; // the selected command does not require a preview let no_preview = State::calc_preview_height( &settings_preview_auto, &results, 0_usize, 0_usize, Compactness::Full, 1, 80, ); // the selected command requires 2 lines let preview_h2 = State::calc_preview_height( &settings_preview_auto, &results, 1_usize, 0_usize, Compactness::Full, 1, 80, ); // the selected command requires 3 lines let preview_h3 = State::calc_preview_height( &settings_preview_auto, &results, 2_usize, 0_usize, Compactness::Full, 1, 80, ); // the selected command requires a preview of 1 line (happens when the command is between preview_width-19 and preview_width) let preview_one_line = State::calc_preview_height( &settings_preview_auto, &results, 0_usize, 0_usize, Compactness::Full, 1, 66, ); // the selected command requires 3 lines, but we have a max preview height limit of 2 let preview_limit_at_2 = State::calc_preview_height( &settings_preview_auto_h2, &results, 2_usize, 0_usize, Compactness::Full, 1, 80, ); // the longest command requires 3 lines let preview_static_h3 = State::calc_preview_height( &settings_preview_h4, &results, 1_usize, 0_usize, Compactness::Full, 1, 80, ); // the longest command requires 10 lines, but we have a max preview height limit of 4 let preview_static_limit_at_4 = State::calc_preview_height( &settings_preview_h4, &results, 1_usize, 0_usize, Compactness::Full, 1, 20, ); // the longest command requires 10 lines, but we have a max preview height of 15 and a fixed preview strategy let settings_preview_fixed = State::calc_preview_height( &settings_preview_fixed, &results, 1_usize, 0_usize, Compactness::Full, 1, 20, ); assert_eq!(no_preview, 1); // 1 * 2 is the space for the border let border_space = 2; assert_eq!(preview_h2, 2 + border_space); assert_eq!(preview_h3, 3 + border_space); assert_eq!(preview_one_line, 1 + border_space); assert_eq!(preview_limit_at_2, 2 + border_space); assert_eq!(preview_static_h3, 3 + border_space); assert_eq!(preview_static_limit_at_4, 4 + border_space); assert_eq!(settings_preview_fixed, 15 + border_space); } // Test when there's no results, scrolling up or down doesn't underflow #[test] fn state_scroll_up_underflow() { let settings = Settings::new().unwrap(); let mut state = State { history_count: 0, results_state: ListState::default(), switched_search_mode: false, search_mode: SearchMode::Fuzzy, results_len: 0, accept: false, keymap_mode: KeymapMode::Auto, prefix: false, current_cursor: None, tab_index: 0, pending_vim_key: None, original_input_empty: false, inspecting_state: InspectingState { current: None, next: None, previous: None, }, keymaps: KeymapSet::defaults(&settings), search: SearchState { input: String::new().into(), filter_mode: FilterMode::Directory, context: Context { session: String::new(), cwd: String::new(), hostname: String::new(), host_id: String::new(), git_root: None, }, custom_context: None, }, engine: engines::engine(SearchMode::Fuzzy, &settings), now: Box::new(OffsetDateTime::now_utc), }; state.scroll_up(1); state.scroll_down(1); } #[test] fn test_accept_keybindings() { use crate::atuin_client::settings::Keys; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let mut settings = Settings::new().unwrap(); settings.keys = Keys { scroll_exits: true, exit_past_line_start: false, accept_past_line_end: true, accept_past_line_start: false, accept_with_backspace: false, prefix: "a".to_string(), }; let mut state = State { history_count: 1, results_state: ListState::default(), switched_search_mode: false, search_mode: SearchMode::Fuzzy, results_len: 1, accept: false, keymap_mode: KeymapMode::Emacs, prefix: false, current_cursor: None, tab_index: 0, pending_vim_key: None, original_input_empty: false, inspecting_state: InspectingState { current: None, next: None, previous: None, }, keymaps: KeymapSet::defaults(&settings), search: SearchState { input: String::new().into(), filter_mode: FilterMode::Global, context: Context { session: String::new(), cwd: String::new(), hostname: String::new(), host_id: String::new(), git_root: None, }, custom_context: None, }, engine: engines::engine(SearchMode::Fuzzy, &settings), now: Box::new(OffsetDateTime::now_utc), }; let tab_event = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE); let result = state.handle_key_input(&settings, &tab_event); assert!( matches!(result, super::InputAction::Accept(_)), "Tab should always accept" ); // Test left arrow with accept_past_line_start disabled (should continue) let left_event = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE); let result = state.handle_key_input(&settings, &left_event); assert!( matches!(result, super::InputAction::Continue), "Left arrow should continue when disabled" ); // Test left arrow with accept_past_line_start enabled (should accept at start of line) settings.keys.accept_past_line_start = true; state.keymaps = KeymapSet::defaults(&settings); let result = state.handle_key_input(&settings, &left_event); assert!( matches!(result, super::InputAction::Accept(_)), "Left arrow should accept at start of line when enabled" ); settings.keys.accept_past_line_start = false; state.keymaps = KeymapSet::defaults(&settings); let backspace_event = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE); let result = state.handle_key_input(&settings, &backspace_event); assert!( matches!(result, super::InputAction::Continue), "Backspace should continue when disabled" ); settings.keys.accept_with_backspace = true; state.keymaps = KeymapSet::defaults(&settings); let result = state.handle_key_input(&settings, &backspace_event); assert!( matches!(result, super::InputAction::Accept(_)), "Backspace should accept at start of line when enabled" ); state.search.input.insert('t'); state.search.input.insert('e'); state.search.input.insert('s'); state.search.input.insert('t'); state.search.input.end(); let right_event = KeyEvent::new(KeyCode::Right, KeyModifiers::NONE); let result = state.handle_key_input(&settings, &right_event); assert!( matches!(result, super::InputAction::Accept(_)), "Right arrow should accept at end of line when enabled" ); settings.keys.accept_past_line_start = true; state.keymaps = KeymapSet::defaults(&settings); let left_event = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE); let result = state.handle_key_input(&settings, &left_event); assert!( matches!(result, super::InputAction::Continue), "Left arrow should continue and end of line, even when enabled" ); settings.keys.accept_past_line_start = false; state.keymaps = KeymapSet::defaults(&settings); settings.keys.accept_with_backspace = true; state.keymaps = KeymapSet::defaults(&settings); let backspace_event = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE); let result = state.handle_key_input(&settings, &backspace_event); assert!( matches!(result, super::InputAction::Continue), "Backspace should continue at end of line, even when enabled" ); settings.keys.accept_with_backspace = false; state.keymaps = KeymapSet::defaults(&settings); } #[test] fn test_vim_gg_multikey_sequence() { use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let settings = Settings::new().unwrap(); let mut state = State { history_count: 100, results_state: ListState::default(), switched_search_mode: false, search_mode: SearchMode::Fuzzy, results_len: 100, accept: false, keymap_mode: KeymapMode::VimNormal, prefix: false, current_cursor: None, tab_index: 0, pending_vim_key: None, original_input_empty: false, inspecting_state: InspectingState { current: None, next: None, previous: None, }, keymaps: KeymapSet::defaults(&settings), search: SearchState { input: String::new().into(), filter_mode: FilterMode::Global, context: Context { session: String::new(), cwd: String::new(), hostname: String::new(), host_id: String::new(), git_root: None, }, custom_context: None, }, engine: engines::engine(SearchMode::Fuzzy, &settings), now: Box::new(OffsetDateTime::now_utc), }; // Start in the middle of the list state.results_state.select(50); // First 'g' should set pending state let g_event = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE); let result = state.handle_key_input(&settings, &g_event); assert!(matches!(result, super::InputAction::Continue)); assert_eq!(state.pending_vim_key, Some('g')); assert_eq!(state.results_state.selected(), 50); // Position unchanged // Second 'g' should jump to end (visual top in non-inverted mode) let result = state.handle_key_input(&settings, &g_event); assert!(matches!(result, super::InputAction::Continue)); assert_eq!(state.pending_vim_key, None); assert_eq!(state.results_state.selected(), 99); // Jumped to last index (visual top) } #[test] fn test_vim_g_key_clears_on_other_input() { use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let settings = Settings::new().unwrap(); let mut state = State { history_count: 100, results_state: ListState::default(), switched_search_mode: false, search_mode: SearchMode::Fuzzy, results_len: 100, accept: false, keymap_mode: KeymapMode::VimNormal, prefix: false, current_cursor: None, tab_index: 0, pending_vim_key: None, original_input_empty: false, inspecting_state: InspectingState { current: None, next: None, previous: None, }, keymaps: KeymapSet::defaults(&settings), search: SearchState { input: String::new().into(), filter_mode: FilterMode::Global, context: Context { session: String::new(), cwd: String::new(), hostname: String::new(), host_id: String::new(), git_root: None, }, custom_context: None, }, engine: engines::engine(SearchMode::Fuzzy, &settings), now: Box::new(OffsetDateTime::now_utc), }; state.results_state.select(50); // Press 'g' to set pending state let g_event = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE); state.handle_key_input(&settings, &g_event); assert_eq!(state.pending_vim_key, Some('g')); // Press 'j' - should clear pending state let j_event = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE); state.handle_key_input(&settings, &j_event); assert_eq!(state.pending_vim_key, None); } #[test] fn test_vim_big_g_jump_to_bottom() { use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let settings = Settings::new().unwrap(); let mut state = State { history_count: 100, results_state: ListState::default(), switched_search_mode: false, search_mode: SearchMode::Fuzzy, results_len: 100, accept: false, keymap_mode: KeymapMode::VimNormal, prefix: false, current_cursor: None, tab_index: 0, pending_vim_key: None, original_input_empty: false, inspecting_state: InspectingState { current: None, next: None, previous: None, }, keymaps: KeymapSet::defaults(&settings), search: SearchState { input: String::new().into(), filter_mode: FilterMode::Global, context: Context { session: String::new(), cwd: String::new(), hostname: String::new(), host_id: String::new(), git_root: None, }, custom_context: None, }, engine: engines::engine(SearchMode::Fuzzy, &settings), now: Box::new(OffsetDateTime::now_utc), }; state.results_state.select(50); // 'G' should jump to visual bottom (index 0 in non-inverted mode) let big_g_event = KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE); let result = state.handle_key_input(&settings, &big_g_event); assert!(matches!(result, super::InputAction::Continue)); assert_eq!(state.results_state.selected(), 0); } #[test] #[expect(clippy::similar_names)] fn test_vim_ctrl_u_d_half_page_scroll() { use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let settings = Settings::new().unwrap(); let mut state = State { history_count: 100, results_state: ListState::default(), switched_search_mode: false, search_mode: SearchMode::Fuzzy, results_len: 100, accept: false, keymap_mode: KeymapMode::VimNormal, prefix: false, current_cursor: None, tab_index: 0, pending_vim_key: None, original_input_empty: false, inspecting_state: InspectingState { current: None, next: None, previous: None, }, keymaps: KeymapSet::defaults(&settings), search: SearchState { input: String::new().into(), filter_mode: FilterMode::Global, context: Context { session: String::new(), cwd: String::new(), hostname: String::new(), host_id: String::new(), git_root: None, }, custom_context: None, }, engine: engines::engine(SearchMode::Fuzzy, &settings), now: Box::new(OffsetDateTime::now_utc), }; state.results_state.select(50); // Ctrl+d should return Continue and clear pending key // (scroll amount depends on max_entries which is 0 in tests) state.pending_vim_key = Some('g'); let ctrl_d_event = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL); let result = state.handle_key_input(&settings, &ctrl_d_event); assert!(matches!(result, super::InputAction::Continue)); assert_eq!(state.pending_vim_key, None); // Ctrl+u should return Continue and clear pending key state.pending_vim_key = Some('g'); let ctrl_u_event = KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL); let result = state.handle_key_input(&settings, &ctrl_u_event); assert!(matches!(result, super::InputAction::Continue)); assert_eq!(state.pending_vim_key, None); } #[test] #[expect(clippy::similar_names)] fn test_vim_ctrl_f_b_full_page_scroll() { use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let settings = Settings::new().unwrap(); let mut state = State { history_count: 100, results_state: ListState::default(), switched_search_mode: false, search_mode: SearchMode::Fuzzy, results_len: 100, accept: false, keymap_mode: KeymapMode::VimNormal, prefix: false, current_cursor: None, tab_index: 0, pending_vim_key: None, original_input_empty: false, inspecting_state: InspectingState { current: None, next: None, previous: None, }, keymaps: KeymapSet::defaults(&settings), search: SearchState { input: String::new().into(), filter_mode: FilterMode::Global, context: Context { session: String::new(), cwd: String::new(), hostname: String::new(), host_id: String::new(), git_root: None, }, custom_context: None, }, engine: engines::engine(SearchMode::Fuzzy, &settings), now: Box::new(OffsetDateTime::now_utc), }; state.results_state.select(50); // Ctrl+f should return Continue and clear pending key // (scroll amount depends on max_entries which is 0 in tests) state.pending_vim_key = Some('g'); let ctrl_f_event = KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL); let result = state.handle_key_input(&settings, &ctrl_f_event); assert!(matches!(result, super::InputAction::Continue)); assert_eq!(state.pending_vim_key, None); // Ctrl+b should return Continue and clear pending key state.pending_vim_key = Some('g'); let ctrl_b_event = KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL); let result = state.handle_key_input(&settings, &ctrl_b_event); assert!(matches!(result, super::InputAction::Continue)); assert_eq!(state.pending_vim_key, None); } // ----------------------------------------------------------------------- // Executor tests (execute_action) // ----------------------------------------------------------------------- /// Helper to build a State for executor tests. fn make_executor_state(results_len: usize, selected: usize) -> State { let settings = Settings::new().unwrap(); let mut state = State { history_count: results_len as i64, results_state: ListState::default(), switched_search_mode: false, search_mode: SearchMode::Fuzzy, results_len, accept: false, keymap_mode: KeymapMode::Emacs, prefix: false, current_cursor: None, tab_index: 0, pending_vim_key: None, original_input_empty: false, inspecting_state: InspectingState { current: None, next: None, previous: None, }, keymaps: KeymapSet::defaults(&settings), search: SearchState { input: String::new().into(), filter_mode: FilterMode::Global, context: Context { session: String::new(), cwd: String::new(), hostname: String::new(), host_id: String::new(), git_root: None, }, custom_context: None, }, engine: engines::engine(SearchMode::Fuzzy, &settings), now: Box::new(OffsetDateTime::now_utc), }; state.results_state.select(selected); state } #[test] fn execute_select_next_no_invert() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 50); let settings = Settings::new().unwrap(); let result = state.execute_action(&Action::SelectNext, &settings); assert!(matches!(result, super::InputAction::Continue)); // Non-inverted: SelectNext = scroll_down = selected - 1 assert_eq!(state.results_state.selected(), 49); } #[test] fn execute_select_next_with_invert() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 50); let mut settings = Settings::new().unwrap(); settings.invert = true; let result = state.execute_action(&Action::SelectNext, &settings); assert!(matches!(result, super::InputAction::Continue)); // Inverted: SelectNext = scroll_up = selected + 1 assert_eq!(state.results_state.selected(), 51); } #[test] fn execute_select_previous_no_invert() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 50); let settings = Settings::new().unwrap(); let result = state.execute_action(&Action::SelectPrevious, &settings); assert!(matches!(result, super::InputAction::Continue)); // Non-inverted: SelectPrevious = scroll_up = selected + 1 assert_eq!(state.results_state.selected(), 51); } #[test] fn execute_vim_enter_normal() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 0); let settings = Settings::new().unwrap(); let result = state.execute_action(&Action::VimEnterNormal, &settings); assert!(matches!(result, super::InputAction::Continue)); assert_eq!(state.keymap_mode, KeymapMode::VimNormal); } #[test] fn execute_vim_enter_insert() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 0); state.keymap_mode = KeymapMode::VimNormal; let settings = Settings::new().unwrap(); let result = state.execute_action(&Action::VimEnterInsert, &settings); assert!(matches!(result, super::InputAction::Continue)); assert_eq!(state.keymap_mode, KeymapMode::VimInsert); } #[test] fn execute_accept_sets_accept_flag() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 5); let mut settings = Settings::new().unwrap(); settings.enter_accept = true; let result = state.execute_action(&Action::Accept, &settings); assert!(matches!(result, super::InputAction::Accept(5))); assert!(state.accept); } #[test] fn execute_return_selection_does_not_set_accept() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 5); let settings = Settings::new().unwrap(); let result = state.execute_action(&Action::ReturnSelection, &settings); assert!(matches!(result, super::InputAction::Accept(5))); assert!(!state.accept); } #[test] fn execute_accept_nth() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 5); let settings = Settings::new().unwrap(); let result = state.execute_action(&Action::AcceptNth(3), &settings); assert!(matches!(result, super::InputAction::Accept(8))); } #[test] fn execute_scroll_to_top_no_invert() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 50); let settings = Settings::new().unwrap(); let result = state.execute_action(&Action::ScrollToTop, &settings); assert!(matches!(result, super::InputAction::Continue)); // Non-inverted: visual top = highest index assert_eq!(state.results_state.selected(), 99); } #[test] fn execute_scroll_to_top_with_invert() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 50); let mut settings = Settings::new().unwrap(); settings.invert = true; let result = state.execute_action(&Action::ScrollToTop, &settings); assert!(matches!(result, super::InputAction::Continue)); // Inverted: visual top = index 0 assert_eq!(state.results_state.selected(), 0); } #[test] fn execute_scroll_to_bottom_no_invert() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 50); let settings = Settings::new().unwrap(); let result = state.execute_action(&Action::ScrollToBottom, &settings); assert!(matches!(result, super::InputAction::Continue)); // Non-inverted: visual bottom = index 0 assert_eq!(state.results_state.selected(), 0); } #[test] fn execute_toggle_tab() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 0); let settings = Settings::new().unwrap(); assert_eq!(state.tab_index, 0); state.execute_action(&Action::ToggleTab, &settings); assert_eq!(state.tab_index, 1); state.execute_action(&Action::ToggleTab, &settings); assert_eq!(state.tab_index, 0); } #[test] fn execute_enter_prefix_mode() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 0); let settings = Settings::new().unwrap(); assert!(!state.prefix); state.execute_action(&Action::EnterPrefixMode, &settings); assert!(state.prefix); } #[test] fn execute_exit_returns_based_on_exit_mode() { use crate::atuin_client::settings::ExitMode; use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 0); let mut settings = Settings::new().unwrap(); settings.exit_mode = ExitMode::ReturnOriginal; let result = state.execute_action(&Action::Exit, &settings); assert!(matches!(result, super::InputAction::ReturnOriginal)); settings.exit_mode = ExitMode::ReturnQuery; let result = state.execute_action(&Action::Exit, &settings); assert!(matches!(result, super::InputAction::ReturnQuery)); } #[test] fn execute_return_original() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 0); let settings = Settings::new().unwrap(); let result = state.execute_action(&Action::ReturnOriginal, &settings); assert!(matches!(result, super::InputAction::ReturnOriginal)); } #[test] fn execute_copy() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 7); let settings = Settings::new().unwrap(); let result = state.execute_action(&Action::Copy, &settings); assert!(matches!(result, super::InputAction::Copy(7))); } #[test] fn execute_delete() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 7); let settings = Settings::new().unwrap(); let result = state.execute_action(&Action::Delete, &settings); assert!(matches!(result, super::InputAction::Delete(7))); } #[test] fn execute_switch_context() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 7); let settings = Settings::new().unwrap(); let result = state.execute_action(&Action::SwitchContext, &settings); assert!(matches!(result, super::InputAction::SwitchContext(Some(7)))); } #[test] fn execute_clear_context() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 7); let settings = Settings::new().unwrap(); let result = state.execute_action(&Action::ClearContext, &settings); assert!(matches!(result, super::InputAction::SwitchContext(None))); } #[test] fn execute_noop() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 50); let settings = Settings::new().unwrap(); let result = state.execute_action(&Action::Noop, &settings); assert!(matches!(result, super::InputAction::Continue)); assert_eq!(state.results_state.selected(), 50); } #[test] fn execute_accept_in_inspector_tab() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 5); state.tab_index = 1; let settings = Settings::new().unwrap(); let result = state.execute_action(&Action::Accept, &settings); assert!(matches!(result, super::InputAction::AcceptInspecting)); } #[test] fn execute_cycle_search_mode() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 0); let settings = Settings::new().unwrap(); let original_mode = state.search_mode; let result = state.execute_action(&Action::CycleSearchMode, &settings); assert!(matches!(result, super::InputAction::Continue)); assert!(state.switched_search_mode); assert_ne!(state.search_mode, original_mode); } #[test] fn execute_vim_search_insert() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 0); state.search.input.insert('h'); state.search.input.insert('i'); state.keymap_mode = KeymapMode::VimNormal; let settings = Settings::new().unwrap(); let result = state.execute_action(&Action::VimSearchInsert, &settings); assert!(matches!(result, super::InputAction::Continue)); // Should clear input and switch to insert mode assert_eq!(state.search.input.as_str(), ""); assert_eq!(state.keymap_mode, KeymapMode::VimInsert); } #[test] fn execute_cursor_movement() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 0); let settings = Settings::new().unwrap(); // Insert some text state.search.input.insert('h'); state.search.input.insert('e'); state.search.input.insert('l'); state.search.input.insert('l'); state.search.input.insert('o'); // cursor is at end (position 5) // CursorLeft state.execute_action(&Action::CursorLeft, &settings); assert_eq!(state.search.input.position(), 4); // CursorStart state.execute_action(&Action::CursorStart, &settings); assert_eq!(state.search.input.position(), 0); // CursorEnd state.execute_action(&Action::CursorEnd, &settings); assert_eq!(state.search.input.position(), 5); // CursorRight at end does nothing state.execute_action(&Action::CursorRight, &settings); assert_eq!(state.search.input.position(), 5); } #[test] fn execute_editing() { use crate::command::client::search::keybindings::Action; let mut state = make_executor_state(100, 0); let settings = Settings::new().unwrap(); // Insert "hello" state.search.input.insert('h'); state.search.input.insert('e'); state.search.input.insert('l'); state.search.input.insert('l'); state.search.input.insert('o'); // DeleteCharBefore (backspace) state.execute_action(&Action::DeleteCharBefore, &settings); assert_eq!(state.search.input.as_str(), "hell"); // ClearLine state.execute_action(&Action::ClearLine, &settings); assert_eq!(state.search.input.as_str(), ""); } #[test] fn keymap_config_return_query() { use crate::atuin_client::settings::KeyBindingConfig; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use std::collections::HashMap; let mut settings = Settings::new().unwrap(); // Configure tab to return-query settings.keymap.emacs = HashMap::from([( "tab".to_string(), KeyBindingConfig::Simple("return-query".to_string()), )]); let mut state = State { history_count: 100, results_state: ListState::default(), switched_search_mode: false, search_mode: SearchMode::Fuzzy, results_len: 100, accept: false, keymap_mode: KeymapMode::Emacs, prefix: false, current_cursor: None, tab_index: 0, pending_vim_key: None, original_input_empty: false, inspecting_state: InspectingState { current: None, next: None, previous: None, }, keymaps: KeymapSet::from_settings(&settings), search: SearchState { input: "test query".to_string().into(), filter_mode: FilterMode::Global, context: Context { session: String::new(), cwd: String::new(), hostname: String::new(), host_id: String::new(), git_root: None, }, custom_context: None, }, engine: engines::engine(SearchMode::Fuzzy, &settings), now: Box::new(OffsetDateTime::now_utc), }; let tab_event = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE); let result = state.handle_key_input(&settings, &tab_event); assert!( matches!(result, super::InputAction::ReturnQuery), "Tab configured as return-query should return InputAction::ReturnQuery" ); } }