diff options
| author | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2026-07-09 21:43:23 +0200 |
|---|---|---|
| committer | Benedikt Peetz <benedikt.peetz@b-peetz.de> | 2026-07-09 21:43:23 +0200 |
| commit | 3223d93cb3c77ab02aa0a35f2a8314e447cee9a4 (patch) | |
| tree | 3971a1f37f5fe3cadb747c5229a0d54e868e45e3 /crates/turtle/src/command/client/search/interactive.rs | |
| parent | fix(client/sync): Pass through precise error on `SyncError::WrongKey` (diff) | |
| download | atuin-3223d93cb3c77ab02aa0a35f2a8314e447cee9a4.zip | |
chore: Separate daemon, client, server, and lib into crates
Diffstat (limited to 'crates/turtle/src/command/client/search/interactive.rs')
| -rw-r--r-- | crates/turtle/src/command/client/search/interactive.rs | 3024 |
1 files changed, 0 insertions, 3024 deletions
diff --git a/crates/turtle/src/command/client/search/interactive.rs b/crates/turtle/src/command/client/search/interactive.rs deleted file mode 100644 index b8cf53db..00000000 --- a/crates/turtle/src/command/client/search/interactive.rs +++ /dev/null @@ -1,3024 +0,0 @@ -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<usize>), -} - -#[derive(Clone)] -pub(crate) struct InspectingState { - current: Option<HistoryId>, - next: Option<HistoryId>, - previous: Option<HistoryId>, -} - -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<CursorStyle>, - tab_index: usize, - pending_vim_key: Option<char>, - original_input_empty: bool, - - pub(crate) inspecting_state: InspectingState, - - keymaps: KeymapSet, - search: SearchState, - engine: Box<dyn SearchEngine>, - now: Box<dyn Fn() -> 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<Vec<History>> { - let results = self.engine.query(&self.search, db).await?; - - self.inspecting_state = InspectingState { - current: None, - next: None, - previous: None, - }; - self.results_state.select(0); - self.results_len = results.len(); - - 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<HistoryStats>, - 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<HistoryStats>, - 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::<u16>() - + " > ".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("<esc>", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(": exit"), - Span::raw(", "), - Span::styled("<tab>", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(": edit"), - Span::raw(", "), - Span::styled("<enter>", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(if settings.enter_accept { - ": run" - } else { - ": edit" - }), - Span::raw(", "), - Span::styled("<ctrl-o>", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(": inspect"), - ]))), - - 1 => Paragraph::new(Text::from(Line::from(vec![ - Span::styled("<esc>", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(": exit"), - Span::raw(", "), - Span::styled("<ctrl-o>", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(": search"), - Span::raw(", "), - Span::styled("<ctrl-d>", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(": delete"), - ]))), - - _ => unreachable!("invalid tab index"), - } - .style(Style::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<Self> { - 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<usize> { - 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<Vec<u8>>, -} - -/// 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<SavedScreen> { - 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<Self> { - 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<usize> { - 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<String> { - 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<HistoryStats> = None; - let mut inspecting: Option<History> = 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<History> = 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" - ); - } -} |
