use std::{
io::{IsTerminal, Write, stdout},
time::Duration,
};
#[cfg(unix)]
use std::io::Read as _;
use atuin_common::{shell::Shell, utils::Escapable as _};
use eyre::Result;
use futures_util::FutureExt;
use semver::Version;
use time::OffsetDateTime;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use super::{
cursor::Cursor,
engines::{SearchEngine, SearchState},
history_list::{HistoryList, ListState},
};
use atuin_client::{
database::{Context, Database, 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::command::client::theme::{Meaning, Theme};
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::*,
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,
};
#[cfg(windows)]
use windows_sys::Win32::System::Console::{GetConsoleOutputCP, SetConsoleOutputCP};
const TAB_TITLES: [&str; 2] = ["Search", "Inspect"];
pub enum InputAction {
Accept(usize),
AcceptInspecting,
Copy(usize),
Delete(usize),
DeleteAllMatching(usize),
ReturnOriginal,
ReturnQuery,
Continue,
Redraw,
SwitchContext(Option<usize>),
}
#[derive(Clone)]
pub struct InspectingState {
current: Option<HistoryId>,
next: Option<HistoryId>,
previous: Option<HistoryId>,
}
impl InspectingState {
pub fn move_to_previous(&mut self) {
let previous = self.previous.clone();
self.reset();
self.current = previous;
}
pub fn move_to_next(&mut self) {
let next = self.next.clone();
self.reset();
self.current = next;
}
pub fn reset(&mut self) {
self.current = None;
self.next = None;
self.previous = None;
}
}
pub fn to_compactness(f: &Frame, settings: &Settings) -> Compactness {
if match settings.style {
atuin_client::settings::Style::Auto => f.area().height < 14,
atuin_client::settings::Style::Compact => true,
atuin_client::settings::Style::Full => false,
} {
if settings.auto_hide_height != 0 && f.area().height <= settings.auto_hide_height {
Compactness::Ultracompact
} else {
Compactness::Compact
}
} else {
Compactness::Full
}
}
#[allow(clippy::struct_field_names)]
#[allow(clippy::struct_excessive_bools)]
pub struct State {
history_count: i64,
update_needed: Option<Version>,
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 inspecting_state: InspectingState,
keymaps: KeymapSet,
search: SearchState,
engine: Box<dyn SearchEngine>,
now: Box<dyn Fn() -> OffsetDateTime + Send>,
}
#[derive(Clone, Copy)]
pub enum Compactness {
Ultracompact,
Compact,
Full,
}
#[derive(Clone, Copy)]
struct StyleState {
compactness: Compactness,
invert: bool,
inner_width: usize,
}
impl State {
async fn query_results(
&mut self,
db: &mut dyn Database,
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(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;
let _ = execute!(stdout(), Self::cast_cursor_style(style));
}
}
pub 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 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,
}
}
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,
}
}
}
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};
if input.kind == event::KeyEventKind::Release {
return InputAction::Continue;
}
self.switched_search_mode = false;
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(),
};
let Some(single) = SingleKey::from_event(input) else {
return InputAction::Continue;
};
let pending = self.pending_vim_key.take();
let prefix_action = if self.prefix {
let ki = KeyInput::Single(single.clone());
self.keymaps.prefix.resolve(&ki, &ctx)
} else {
None
};
#[allow(clippy::option_if_let_else)]
let (action, new_pending) = if prefix_action.is_some() {
(prefix_action, None)
} else {
let keymap = self.mode_keymap();
if let Some(pending_char) = pending {
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
{
let KeyCodeValue::Char(c) = single.code else {
unreachable!()
};
(Some(Action::Noop), Some(c))
} else {
(
keymap.resolve(&KeyInput::Single(single.clone()), &ctx),
None,
)
}
};
self.pending_vim_key = new_pending;
self.prefix = false;
if let Some(action) = action {
self.execute_action(&action, settings)
} else {
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();
}
#[allow(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 {
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
}
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 => {
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
}
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
}
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
}
Action::ScrollToTop => {
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 => {
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 => {
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 => {
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 => {
let top_visible = self.results_state.offset();
self.results_state.select(top_visible);
self.inspecting_state.reset();
InputAction::Continue
}
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
}
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
}
Action::InspectPrevious => {
self.inspecting_state.move_to_previous();
InputAction::Redraw
}
Action::InspectNext => {
self.inspecting_state.move_to_next();
InputAction::Redraw
}
Action::Noop => InputAction::Continue,
}
}
#[allow(clippy::cast_possible_truncation)]
#[allow(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;
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
}
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
}
}
#[allow(clippy::bool_to_int_with_if)]
#[allow(clippy::too_many_lines)]
#[allow(clippy::too_many_arguments)]
fn draw(
&mut self,
f: &mut Frame,
results: &[History],
stats: Option<HistoryStats>,
inspecting: Option<&History>,
settings: &Settings,
theme: &Theme,
popup_mode: bool,
) {
let area = f.area();
if popup_mode {
f.render_widget(Clear, area);
}
self.draw_inner(f, area, results, stats, inspecting, settings, theme);
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_lines)]
#[allow(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,
theme: &Theme,
) {
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);
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), Constraint::Min(1), Constraint::Length(preview_height), Constraint::Length(if show_tabs { 1 } else { 0 }), Constraint::Length(if show_help { 1 } else { 0 }), ]
} else {
match compactness {
Compactness::Ultracompact => [
Constraint::Length(if show_help { 1 } else { 0 }), Constraint::Length(0), Constraint::Min(1), Constraint::Length(0),
Constraint::Length(0),
],
_ => [
Constraint::Length(if show_help { 1 } else { 0 }), Constraint::Length(if show_tabs { 1 } else { 0 }), Constraint::Min(1), Constraint::Length(1 + border_size), Constraint::Length(preview_height), ],
}
}
.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] };
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(theme.as_style(Meaning::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(theme);
f.render_widget(title, header_chunks[0]);
let help = self.build_help(settings, theme);
f.render_widget(help, header_chunks[1]);
let stats_tab = self.build_stats(theme);
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(),
theme,
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,
theme,
settings.timezone,
);
}
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(),
theme,
);
#[allow(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;
#[allow(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),
);
}
}
#[allow(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((
input_chunk.x + extra_width as u16 + prefix_width + cursor_offset,
input_chunk.y + cursor_offset,
));
}
fn build_title(&self, theme: &Theme) -> Paragraph<'_> {
let title = if self.update_needed.is_some() {
let error_style: Style = Style::from_crossterm(theme.get_error());
Paragraph::new(Text::from(Span::styled(
format!("Atuin v{VERSION} - UPDATE"),
error_style.add_modifier(Modifier::BOLD),
)))
} else {
let style: Style = Style::from_crossterm(theme.as_style(Meaning::Base));
Paragraph::new(Text::from(Span::styled(
format!("Atuin v{VERSION}"),
style.add_modifier(Modifier::BOLD),
)))
};
title.alignment(Alignment::Left)
}
#[allow(clippy::unused_self)]
fn build_help(&self, settings: &Settings, theme: &Theme) -> Paragraph<'_> {
match self.tab_index {
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(theme.as_style(Meaning::Annotation)))
.alignment(Alignment::Center)
}
fn build_stats(&self, theme: &Theme) -> Paragraph<'_> {
Paragraph::new(Text::from(Span::raw(format!(
"history count: {}",
self.history_count,
))))
.style(Style::from_crossterm(theme.as_style(Meaning::Annotation)))
.alignment(Alignment::Right)
}
#[allow(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,
theme: &'a Theme,
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,
theme,
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())
};
let mode_width = usize::from(prefix_width) - pref.len() - 3;
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,
theme: &Theme,
) -> 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); 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(theme.as_style(Meaning::Annotation))),
}
}
}
enum TerminalWriter {
Stdout(std::io::Stdout),
#[cfg(unix)]
Tty(std::fs::File),
#[cfg(windows)]
ConOut(std::io::LineWriter<std::fs::File>, u32),
}
impl TerminalWriter {
#[cfg(windows)]
const CP_UTF8: u32 = 65001;
fn new() -> std::io::Result<Self> {
let stdout = stdout();
if stdout.is_terminal() {
return Ok(TerminalWriter::Stdout(stdout));
}
#[cfg(unix)]
{
Ok(TerminalWriter::Tty(
std::fs::File::options()
.read(true)
.write(true)
.open("/dev/tty")?,
))
}
#[cfg(windows)]
{
let file = std::fs::File::options()
.read(true)
.write(true)
.open("CONOUT$")?;
let initial_console_output_cp = unsafe { GetConsoleOutputCP() };
if initial_console_output_cp != Self::CP_UTF8 {
unsafe {
SetConsoleOutputCP(Self::CP_UTF8);
}
}
Ok(TerminalWriter::ConOut(
std::io::LineWriter::new(file),
initial_console_output_cp,
))
}
#[cfg(not(any(unix, windows)))]
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"Interactive mode requires a terminal",
))
}
}
impl Write for TerminalWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
match self {
TerminalWriter::Stdout(stdout) => stdout.write(buf),
#[cfg(unix)]
TerminalWriter::Tty(file) => file.write(buf),
#[cfg(windows)]
TerminalWriter::ConOut(writer, _) => writer.write(buf),
}
}
fn flush(&mut self) -> std::io::Result<()> {
match self {
TerminalWriter::Stdout(stdout) => stdout.flush(),
#[cfg(unix)]
TerminalWriter::Tty(file) => file.flush(),
#[cfg(windows)]
TerminalWriter::ConOut(writer, _) => writer.flush(),
}
}
}
impl Drop for TerminalWriter {
fn drop(&mut self) {
#[cfg(windows)]
if let TerminalWriter::ConOut(_, initial_console_output_cp) = self
&& *initial_console_output_cp != Self::CP_UTF8
{
unsafe {
SetConsoleOutputCP(*initial_console_output_cp);
}
}
}
}
#[cfg(unix)]
struct SavedScreen {
#[allow(dead_code)]
rows: u16,
#[allow(dead_code)]
cols: u16,
cursor_row: u16,
cursor_col: u16,
rows_data: Vec<Vec<u8>>,
}
#[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]]);
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,
})
}
#[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;
let _ = execute!(
stdout,
MoveTo(popup_rect.x, target_row),
ratatui::crossterm::style::SetAttribute(ratatui::crossterm::style::Attribute::Reset),
);
let _ = write!(stdout, "{:width$}", "", width = popup_rect.width as usize);
let _ = execute!(stdout, MoveTo(popup_rect.x, target_row));
if let Some(row_bytes) = saved.rows_data.get(source_row) {
let _ = stdout.write_all(row_bytes);
}
}
let _ = execute!(
stdout,
MoveTo(
saved.cursor_col,
saved.cursor_row.saturating_sub(scroll_offset)
)
);
let _ = stdout.flush();
}
struct Stdout {
writer: TerminalWriter,
inline_mode: bool,
no_mouse: bool,
}
impl Stdout {
pub 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"))]
execute!(self.writer, PopKeyboardEnhancementFlags).unwrap();
if !self.inline_mode {
execute!(self.writer, terminal::LeaveAlternateScreen).unwrap();
}
if !self.no_mouse {
execute!(self.writer, event::DisableMouseCapture).unwrap();
}
execute!(self.writer, event::DisableBracketedPaste).unwrap();
terminal::disable_raw_mode().unwrap();
}
}
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()
}
}
#[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 {
(cursor_row, 0u16)
} else if cursor_row >= term_rows / 2 {
(cursor_row.saturating_sub(popup_h), 0u16)
} else {
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)
}
#[allow(
clippy::cast_possible_truncation,
clippy::too_many_lines,
clippy::cognitive_complexity
)]
pub async fn history(
query: &[String],
settings: &Settings,
mut db: impl Database,
history_store: &HistoryStore,
theme: &Theme,
) -> 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
};
let inline_height = if !stdout().is_terminal() {
0
} else if let Ok(size) = terminal::size()
&& inline_height >= size.1
{
0
} else {
inline_height
};
#[cfg(unix)]
let (saved_screen, popup_rect, popup_scroll_offset) = {
let socket_path = 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);
if scroll > 0 {
use ratatui::crossterm::cursor::MoveTo;
let mut stdout = stdout();
let _ = execute!(stdout, MoveTo(0, term_rows - 1));
for _ in 0..scroll {
let _ = writeln!(stdout);
}
let _ = stdout.flush();
}
(saved, popup_rect, scroll)
} else {
(None, Rect::default(), 0u16)
}
} else {
(None, Rect::default(), 0u16)
}
};
#[cfg(not(unix))]
let (saved_screen, popup_rect, _popup_scroll_offset): (Option<()>, Rect, u16) =
(None, Rect::default(), 0);
let popup_mode = saved_screen.is_some();
let stdout = Stdout::new(inline_height > 0, settings.no_mouse)?;
if popup_mode {
use ratatui::crossterm::cursor::MoveTo;
let mut raw_stdout = std::io::stdout();
let _ = queue!(
raw_stdout,
ratatui::crossterm::style::SetAttribute(ratatui::crossterm::style::Attribute::Reset)
);
for row in popup_rect.y..popup_rect.y.saturating_add(popup_rect.height) {
let _ = queue!(raw_stdout, MoveTo(popup_rect.x, row));
let _ = write!(
raw_stdout,
"{:width$}",
"",
width = popup_rect.width as usize
);
}
let _ = 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(" ");
let is_command_chaining = if settings.command_chaining {
let trimmed = original_query.trim_end();
trimmed.ends_with("&&") || trimmed.ends_with('|')
} else {
false
};
let search_input = if is_command_chaining {
String::new()
} else {
original_query.clone()
};
let mut input = Cursor::from(search_input);
input.end();
let settings2 = settings.clone();
let update_needed = tokio::spawn(async move { settings2.needs_update().await }).fuse();
tokio::pin!(update_needed);
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(),
update_needed: None,
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,
theme,
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();
results.retain(|e| e.command != command);
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, theme, popup_mode);
})?;
},
r => {
accept = app.accept;
break 'render r;
},
}
if !event::poll(Duration::ZERO)? {
break;
}
}
}
}
update_needed = &mut update_needed => {
app.update_needed = update_needed.ok().flatten();
}
}
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?;
}
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;
match inspecting_id {
Some(inspecting_id) => {
if inspecting.is_none() || inspecting_id != inspecting.clone().unwrap().id {
inspecting = db.load(inspecting_id.0.as_str()).await?;
}
}
_ => {
inspecting = None;
}
}
stats = if app.tab_index == 0 {
None
} else if !results.is_empty() {
let selected = 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 {
#[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;
}
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;
}
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(_) => {
Ok(app.search.input.into_inner())
}
InputAction::Continue
| InputAction::Redraw
| InputAction::Delete(_)
| InputAction::DeleteAllMatching(_)
| InputAction::SwitchContext(_) => {
unreachable!("should have been handled!")
}
}
}
#[cfg(all(
feature = "clipboard",
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();
ctx.get_text().unwrap();
}
#[cfg(not(all(
feature = "clipboard",
any(target_os = "windows", target_os = "macos", target_os = "linux")
)))]
fn set_clipboard(_s: String) {}
#[cfg(test)]
mod tests {
use atuin_client::database::Context;
use atuin_client::history::History;
use 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]
#[allow(clippy::too_many_lines)]
fn calc_preview_height_test() {
let settings_preview_auto = Settings {
preview: Preview {
strategy: PreviewStrategy::Auto,
},
show_preview: true,
..Settings::utc()
};
let settings_preview_auto_h2 = Settings {
preview: Preview {
strategy: PreviewStrategy::Auto,
},
show_preview: true,
max_preview_height: 2,
..Settings::utc()
};
let settings_preview_h4 = Settings {
preview: Preview {
strategy: PreviewStrategy::Static,
},
show_preview: true,
max_preview_height: 4,
..Settings::utc()
};
let settings_preview_fixed = Settings {
preview: Preview {
strategy: PreviewStrategy::Fixed,
},
show_preview: true,
max_preview_height: 15,
..Settings::utc()
};
let cmd_60: History = History::capture()
.timestamp(time::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(time::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(time::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];
let no_preview = State::calc_preview_height(
&settings_preview_auto,
&results,
0_usize,
0_usize,
Compactness::Full,
1,
80,
);
let preview_h2 = State::calc_preview_height(
&settings_preview_auto,
&results,
1_usize,
0_usize,
Compactness::Full,
1,
80,
);
let preview_h3 = State::calc_preview_height(
&settings_preview_auto,
&results,
2_usize,
0_usize,
Compactness::Full,
1,
80,
);
let preview_one_line = State::calc_preview_height(
&settings_preview_auto,
&results,
0_usize,
0_usize,
Compactness::Full,
1,
66,
);
let preview_limit_at_2 = State::calc_preview_height(
&settings_preview_auto_h2,
&results,
2_usize,
0_usize,
Compactness::Full,
1,
80,
);
let preview_static_h3 = State::calc_preview_height(
&settings_preview_h4,
&results,
1_usize,
0_usize,
Compactness::Full,
1,
80,
);
let preview_static_limit_at_4 = State::calc_preview_height(
&settings_preview_h4,
&results,
1_usize,
0_usize,
Compactness::Full,
1,
20,
);
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);
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]
fn state_scroll_up_underflow() {
let settings = Settings::utc();
let mut state = State {
history_count: 0,
update_needed: None,
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 atuin_client::settings::Keys;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let mut settings = Settings::utc();
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,
update_needed: None,
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"
);
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"
);
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::utc();
let mut state = State {
history_count: 100,
update_needed: None,
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);
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);
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); }
#[test]
fn test_vim_g_key_clears_on_other_input() {
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let settings = Settings::utc();
let mut state = State {
history_count: 100,
update_needed: None,
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);
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'));
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::utc();
let mut state = State {
history_count: 100,
update_needed: None,
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);
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]
fn test_vim_ctrl_u_d_half_page_scroll() {
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let settings = Settings::utc();
let mut state = State {
history_count: 100,
update_needed: None,
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);
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);
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]
fn test_vim_ctrl_f_b_full_page_scroll() {
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let settings = Settings::utc();
let mut state = State {
history_count: 100,
update_needed: None,
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);
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);
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);
}
fn make_executor_state(results_len: usize, selected: usize) -> State {
let settings = Settings::utc();
let mut state = State {
history_count: results_len as i64,
update_needed: None,
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::utc();
let result = state.execute_action(&Action::SelectNext, &settings);
assert!(matches!(result, super::InputAction::Continue));
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::utc();
settings.invert = true;
let result = state.execute_action(&Action::SelectNext, &settings);
assert!(matches!(result, super::InputAction::Continue));
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::utc();
let result = state.execute_action(&Action::SelectPrevious, &settings);
assert!(matches!(result, super::InputAction::Continue));
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::utc();
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::utc();
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::utc();
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::utc();
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::utc();
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::utc();
let result = state.execute_action(&Action::ScrollToTop, &settings);
assert!(matches!(result, super::InputAction::Continue));
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::utc();
settings.invert = true;
let result = state.execute_action(&Action::ScrollToTop, &settings);
assert!(matches!(result, super::InputAction::Continue));
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::utc();
let result = state.execute_action(&Action::ScrollToBottom, &settings);
assert!(matches!(result, super::InputAction::Continue));
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::utc();
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::utc();
assert!(!state.prefix);
state.execute_action(&Action::EnterPrefixMode, &settings);
assert!(state.prefix);
}
#[test]
fn execute_exit_returns_based_on_exit_mode() {
use crate::command::client::search::keybindings::Action;
use atuin_client::settings::ExitMode;
let mut state = make_executor_state(100, 0);
let mut settings = Settings::utc();
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::utc();
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::utc();
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::utc();
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::utc();
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::utc();
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::utc();
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::utc();
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::utc();
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::utc();
let result = state.execute_action(&Action::VimSearchInsert, &settings);
assert!(matches!(result, super::InputAction::Continue));
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::utc();
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');
state.execute_action(&Action::CursorLeft, &settings);
assert_eq!(state.search.input.position(), 4);
state.execute_action(&Action::CursorStart, &settings);
assert_eq!(state.search.input.position(), 0);
state.execute_action(&Action::CursorEnd, &settings);
assert_eq!(state.search.input.position(), 5);
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::utc();
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');
state.execute_action(&Action::DeleteCharBefore, &settings);
assert_eq!(state.search.input.as_str(), "hell");
state.execute_action(&Action::ClearLine, &settings);
assert_eq!(state.search.input.as_str(), "");
}
#[test]
fn keymap_config_return_query() {
use atuin_client::settings::KeyBindingConfig;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::collections::HashMap;
let mut settings = Settings::utc();
settings.keymap.emacs = HashMap::from([(
"tab".to_string(),
KeyBindingConfig::Simple("return-query".to_string()),
)]);
let mut state = State {
history_count: 100,
update_needed: None,
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"
);
}
}