better cursor search (#473)
* improve cursor code * proper unicode support * refactor and test * fmt * clippy * move methods to state * refactor search modules
This commit is contained in:
parent
8478a598db
commit
702a644f68
7 changed files with 671 additions and 579 deletions
|
@ -461,7 +461,7 @@ mod test {
|
||||||
Some("beep boop".to_string()),
|
Some("beep boop".to_string()),
|
||||||
Some("booop".to_string()),
|
Some("booop".to_string()),
|
||||||
);
|
);
|
||||||
return db.save(&history).await;
|
db.save(&history).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
|
|
@ -27,16 +27,27 @@ pub enum SearchMode {
|
||||||
#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq)]
|
#[derive(Clone, Debug, Deserialize, Copy, PartialEq, Eq)]
|
||||||
pub enum FilterMode {
|
pub enum FilterMode {
|
||||||
#[serde(rename = "global")]
|
#[serde(rename = "global")]
|
||||||
Global,
|
Global = 0,
|
||||||
|
|
||||||
#[serde(rename = "host")]
|
#[serde(rename = "host")]
|
||||||
Host,
|
Host = 1,
|
||||||
|
|
||||||
#[serde(rename = "session")]
|
#[serde(rename = "session")]
|
||||||
Session,
|
Session = 2,
|
||||||
|
|
||||||
#[serde(rename = "directory")]
|
#[serde(rename = "directory")]
|
||||||
Directory,
|
Directory = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FilterMode {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
FilterMode::Global => "GLOBAL",
|
||||||
|
FilterMode::Host => "HOST",
|
||||||
|
FilterMode::Session => "SESSION",
|
||||||
|
FilterMode::Directory => "DIRECTORY",
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: Can use upstream Dialect enum if https://github.com/stevedonovan/chrono-english/pull/16 is merged
|
// FIXME: Can use upstream Dialect enum if https://github.com/stevedonovan/chrono-english/pull/16 is merged
|
||||||
|
|
|
@ -10,7 +10,6 @@ use atuin_common::utils::uuid_v4;
|
||||||
#[cfg(feature = "sync")]
|
#[cfg(feature = "sync")]
|
||||||
mod sync;
|
mod sync;
|
||||||
|
|
||||||
mod event;
|
|
||||||
mod history;
|
mod history;
|
||||||
mod import;
|
mod import;
|
||||||
mod init;
|
mod init;
|
||||||
|
|
|
@ -1,36 +1,16 @@
|
||||||
use std::{env, io::stdout, ops::Sub, time::Duration};
|
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use eyre::Result;
|
use eyre::Result;
|
||||||
use termion::{
|
|
||||||
event::Event as TermEvent, event::Key, event::MouseButton, event::MouseEvent,
|
|
||||||
input::MouseTerminal, raw::IntoRawMode, screen::AlternateScreen,
|
|
||||||
};
|
|
||||||
use tui::{
|
|
||||||
backend::{Backend, TermionBackend},
|
|
||||||
layout::{Alignment, Constraint, Corner, Direction, Layout},
|
|
||||||
style::{Color, Modifier, Style},
|
|
||||||
text::{Span, Spans, Text},
|
|
||||||
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
|
|
||||||
Frame, Terminal,
|
|
||||||
};
|
|
||||||
use unicode_width::UnicodeWidthStr;
|
|
||||||
|
|
||||||
use atuin_client::{
|
use atuin_client::{
|
||||||
database::current_context,
|
database::current_context, database::Database, history::History, settings::Settings,
|
||||||
database::Context,
|
|
||||||
database::Database,
|
|
||||||
history::History,
|
|
||||||
settings::{FilterMode, SearchMode, Settings},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::history::ListMode;
|
||||||
event::{Event, Events},
|
|
||||||
history::ListMode,
|
|
||||||
};
|
|
||||||
|
|
||||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
mod cursor;
|
||||||
|
mod event;
|
||||||
|
mod interactive;
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
pub struct Cmd {
|
pub struct Cmd {
|
||||||
|
@ -80,7 +60,7 @@ pub struct Cmd {
|
||||||
impl Cmd {
|
impl Cmd {
|
||||||
pub async fn run(self, db: &mut impl Database, settings: &Settings) -> Result<()> {
|
pub async fn run(self, db: &mut impl Database, settings: &Settings) -> Result<()> {
|
||||||
if self.interactive {
|
if self.interactive {
|
||||||
let item = select_history(
|
let item = interactive::history(
|
||||||
&self.query,
|
&self.query,
|
||||||
settings.search_mode,
|
settings.search_mode,
|
||||||
settings.filter_mode,
|
settings.filter_mode,
|
||||||
|
@ -110,553 +90,6 @@ impl Cmd {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct State {
|
|
||||||
input: String,
|
|
||||||
|
|
||||||
cursor_index: usize,
|
|
||||||
|
|
||||||
filter_mode: FilterMode,
|
|
||||||
|
|
||||||
results: Vec<History>,
|
|
||||||
|
|
||||||
results_state: ListState,
|
|
||||||
|
|
||||||
context: Context,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl State {
|
|
||||||
#[allow(clippy::cast_sign_loss)]
|
|
||||||
fn durations(&self) -> Vec<(String, String)> {
|
|
||||||
self.results
|
|
||||||
.iter()
|
|
||||||
.map(|h| {
|
|
||||||
let duration =
|
|
||||||
Duration::from_millis(std::cmp::max(h.duration, 0) as u64 / 1_000_000);
|
|
||||||
let duration = humantime::format_duration(duration).to_string();
|
|
||||||
let duration: Vec<&str> = duration.split(' ').collect();
|
|
||||||
|
|
||||||
let ago = chrono::Utc::now().sub(h.timestamp);
|
|
||||||
|
|
||||||
// Account for the chance that h.timestamp is "in the future"
|
|
||||||
// This would mean that "ago" is negative, and the unwrap here
|
|
||||||
// would fail.
|
|
||||||
// If the timestamp would otherwise be in the future, display
|
|
||||||
// the time ago as 0.
|
|
||||||
let ago = humantime::format_duration(
|
|
||||||
ago.to_std().unwrap_or_else(|_| Duration::new(0, 0)),
|
|
||||||
)
|
|
||||||
.to_string();
|
|
||||||
let ago: Vec<&str> = ago.split(' ').collect();
|
|
||||||
|
|
||||||
(
|
|
||||||
duration[0]
|
|
||||||
.to_string()
|
|
||||||
.replace("days", "d")
|
|
||||||
.replace("day", "d")
|
|
||||||
.replace("weeks", "w")
|
|
||||||
.replace("week", "w")
|
|
||||||
.replace("months", "mo")
|
|
||||||
.replace("month", "mo")
|
|
||||||
.replace("years", "y")
|
|
||||||
.replace("year", "y"),
|
|
||||||
ago[0]
|
|
||||||
.to_string()
|
|
||||||
.replace("days", "d")
|
|
||||||
.replace("day", "d")
|
|
||||||
.replace("weeks", "w")
|
|
||||||
.replace("week", "w")
|
|
||||||
.replace("months", "mo")
|
|
||||||
.replace("month", "mo")
|
|
||||||
.replace("years", "y")
|
|
||||||
.replace("year", "y")
|
|
||||||
+ " ago",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_results<T: tui::backend::Backend>(
|
|
||||||
&mut self,
|
|
||||||
f: &mut tui::Frame<T>,
|
|
||||||
r: tui::layout::Rect,
|
|
||||||
b: tui::widgets::Block,
|
|
||||||
) {
|
|
||||||
let durations = self.durations();
|
|
||||||
let max_length = durations.iter().fold(0, |largest, i| {
|
|
||||||
std::cmp::max(largest, i.0.len() + i.1.len())
|
|
||||||
});
|
|
||||||
|
|
||||||
let results: Vec<ListItem> = self
|
|
||||||
.results
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, m)| {
|
|
||||||
let command = m.command.to_string().replace('\n', " ").replace('\t', " ");
|
|
||||||
|
|
||||||
let mut command = Span::raw(command);
|
|
||||||
|
|
||||||
let (duration, mut ago) = durations[i].clone();
|
|
||||||
|
|
||||||
while (duration.len() + ago.len()) < max_length {
|
|
||||||
ago = format!(" {}", ago);
|
|
||||||
}
|
|
||||||
|
|
||||||
let selected_index = match self.results_state.selected() {
|
|
||||||
None => Span::raw(" "),
|
|
||||||
Some(selected) => match i.checked_sub(selected) {
|
|
||||||
None => Span::raw(" "),
|
|
||||||
Some(diff) => {
|
|
||||||
if 0 < diff && diff < 10 {
|
|
||||||
Span::raw(format!(" {} ", diff))
|
|
||||||
} else {
|
|
||||||
Span::raw(" ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let duration = Span::styled(
|
|
||||||
duration,
|
|
||||||
Style::default().fg(if m.success() {
|
|
||||||
Color::Green
|
|
||||||
} else {
|
|
||||||
Color::Red
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
let ago = Span::styled(ago, Style::default().fg(Color::Blue));
|
|
||||||
|
|
||||||
if let Some(selected) = self.results_state.selected() {
|
|
||||||
if selected == i {
|
|
||||||
command.style =
|
|
||||||
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let spans = Spans::from(vec![
|
|
||||||
selected_index,
|
|
||||||
duration,
|
|
||||||
Span::raw(" "),
|
|
||||||
ago,
|
|
||||||
Span::raw(" "),
|
|
||||||
command,
|
|
||||||
]);
|
|
||||||
|
|
||||||
ListItem::new(spans)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let results = List::new(results)
|
|
||||||
.block(b)
|
|
||||||
.start_corner(Corner::BottomLeft)
|
|
||||||
.highlight_symbol(">> ");
|
|
||||||
|
|
||||||
f.render_stateful_widget(results, r, &mut self.results_state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn query_results(
|
|
||||||
app: &mut State,
|
|
||||||
search_mode: SearchMode,
|
|
||||||
db: &mut impl Database,
|
|
||||||
) -> Result<()> {
|
|
||||||
let results = match app.input.as_str() {
|
|
||||||
"" => {
|
|
||||||
db.list(app.filter_mode, &app.context, Some(200), true)
|
|
||||||
.await?
|
|
||||||
}
|
|
||||||
i => {
|
|
||||||
db.search(Some(200), search_mode, app.filter_mode, &app.context, i)
|
|
||||||
.await?
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
app.results = results;
|
|
||||||
|
|
||||||
if app.results.is_empty() {
|
|
||||||
app.results_state.select(None);
|
|
||||||
} else {
|
|
||||||
app.results_state.select(Some(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_input_prefix(app: &mut State, i: usize) -> String {
|
|
||||||
return app.input.chars().take(i).collect();
|
|
||||||
}
|
|
||||||
fn get_input_suffix(app: &mut State, i: usize) -> String {
|
|
||||||
return app.input.chars().skip(i).collect();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn insert_char_into_input(app: &mut State, i: usize, c: char) {
|
|
||||||
let mut result = String::from("");
|
|
||||||
result.push_str(&get_input_prefix(app, i));
|
|
||||||
result.push_str(&c.to_string());
|
|
||||||
result.push_str(&get_input_suffix(app, i));
|
|
||||||
app.input = result;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn remove_char_from_input(app: &mut State, i: usize) -> char {
|
|
||||||
let mut result = String::from("");
|
|
||||||
result.push_str(&get_input_prefix(app, i - 1));
|
|
||||||
result.push_str(&get_input_suffix(app, i));
|
|
||||||
let c = app.input.chars().nth(i - 1).unwrap();
|
|
||||||
app.input = result;
|
|
||||||
c
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_lines)]
|
|
||||||
fn key_handler(input: &TermEvent, app: &mut State) -> Option<String> {
|
|
||||||
match input {
|
|
||||||
TermEvent::Key(Key::Esc | Key::Ctrl('c' | 'd' | 'g')) => return Some(String::from("")),
|
|
||||||
TermEvent::Key(Key::Char('\n')) => {
|
|
||||||
let i = app.results_state.selected().unwrap_or(0);
|
|
||||||
|
|
||||||
return Some(
|
|
||||||
app.results
|
|
||||||
.get(i)
|
|
||||||
.map_or(app.input.clone(), |h| h.command.clone()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
TermEvent::Key(Key::Alt(c)) if ('1'..='9').contains(c) => {
|
|
||||||
let c = c.to_digit(10)? as usize;
|
|
||||||
let i = app.results_state.selected()? + c;
|
|
||||||
|
|
||||||
return Some(
|
|
||||||
app.results
|
|
||||||
.get(i)
|
|
||||||
.map_or(app.input.clone(), |h| h.command.clone()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
TermEvent::Key(Key::Left | Key::Ctrl('h')) => {
|
|
||||||
if app.cursor_index != 0 {
|
|
||||||
app.cursor_index -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TermEvent::Key(Key::Right | Key::Ctrl('l')) => {
|
|
||||||
if app.cursor_index < app.input.width() {
|
|
||||||
app.cursor_index += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TermEvent::Key(Key::Ctrl('a')) => {
|
|
||||||
app.cursor_index = 0;
|
|
||||||
}
|
|
||||||
TermEvent::Key(Key::Ctrl('e')) => {
|
|
||||||
app.cursor_index = app.input.chars().count();
|
|
||||||
}
|
|
||||||
TermEvent::Key(Key::Char(c)) => {
|
|
||||||
insert_char_into_input(app, app.cursor_index, *c);
|
|
||||||
app.cursor_index += 1;
|
|
||||||
}
|
|
||||||
TermEvent::Key(Key::Backspace) => {
|
|
||||||
if app.cursor_index == 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
remove_char_from_input(app, app.cursor_index);
|
|
||||||
app.cursor_index -= 1;
|
|
||||||
}
|
|
||||||
TermEvent::Key(Key::Ctrl('w')) => {
|
|
||||||
let mut stop_on_next_whitespace = false;
|
|
||||||
loop {
|
|
||||||
if app.cursor_index == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if app.input.chars().nth(app.cursor_index - 1) == Some(' ')
|
|
||||||
&& stop_on_next_whitespace
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if !remove_char_from_input(app, app.cursor_index).is_whitespace() {
|
|
||||||
stop_on_next_whitespace = true;
|
|
||||||
}
|
|
||||||
app.cursor_index -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TermEvent::Key(Key::Ctrl('u')) => {
|
|
||||||
app.input = String::from("");
|
|
||||||
app.cursor_index = 0;
|
|
||||||
}
|
|
||||||
TermEvent::Key(Key::Ctrl('r')) => {
|
|
||||||
app.filter_mode = match app.filter_mode {
|
|
||||||
FilterMode::Global => FilterMode::Host,
|
|
||||||
FilterMode::Host => FilterMode::Session,
|
|
||||||
FilterMode::Session => FilterMode::Directory,
|
|
||||||
FilterMode::Directory => FilterMode::Global,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
TermEvent::Key(Key::Down | Key::Ctrl('n' | 'j'))
|
|
||||||
| TermEvent::Mouse(MouseEvent::Press(MouseButton::WheelDown, _, _)) => {
|
|
||||||
let i = match app.results_state.selected() {
|
|
||||||
Some(i) => {
|
|
||||||
if i == 0 {
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
i - 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => 0,
|
|
||||||
};
|
|
||||||
app.results_state.select(Some(i));
|
|
||||||
}
|
|
||||||
TermEvent::Key(Key::Up | Key::Ctrl('p' | 'k'))
|
|
||||||
| TermEvent::Mouse(MouseEvent::Press(MouseButton::WheelUp, _, _)) => {
|
|
||||||
let i = match app.results_state.selected() {
|
|
||||||
Some(i) => {
|
|
||||||
if i >= app.results.len() - 1 {
|
|
||||||
app.results.len() - 1
|
|
||||||
} else {
|
|
||||||
i + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => 0,
|
|
||||||
};
|
|
||||||
app.results_state.select(Some(i));
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
};
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::cast_possible_truncation)]
|
|
||||||
fn draw<T: Backend>(f: &mut Frame<'_, T>, history_count: i64, app: &mut State) {
|
|
||||||
let chunks = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.margin(1)
|
|
||||||
.constraints(
|
|
||||||
[
|
|
||||||
Constraint::Length(2),
|
|
||||||
Constraint::Min(1),
|
|
||||||
Constraint::Length(3),
|
|
||||||
]
|
|
||||||
.as_ref(),
|
|
||||||
)
|
|
||||||
.split(f.size());
|
|
||||||
|
|
||||||
let top_chunks = Layout::default()
|
|
||||||
.direction(Direction::Horizontal)
|
|
||||||
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
|
|
||||||
.split(chunks[0]);
|
|
||||||
|
|
||||||
let top_left_chunks = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.constraints([Constraint::Length(1), Constraint::Length(1)].as_ref())
|
|
||||||
.split(top_chunks[0]);
|
|
||||||
|
|
||||||
let top_right_chunks = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.constraints([Constraint::Length(1), Constraint::Length(1)].as_ref())
|
|
||||||
.split(top_chunks[1]);
|
|
||||||
|
|
||||||
let title = Paragraph::new(Text::from(Span::styled(
|
|
||||||
format!("Atuin v{}", VERSION),
|
|
||||||
Style::default().add_modifier(Modifier::BOLD),
|
|
||||||
)));
|
|
||||||
|
|
||||||
let help = vec![
|
|
||||||
Span::raw("Press "),
|
|
||||||
Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)),
|
|
||||||
Span::raw(" to exit."),
|
|
||||||
];
|
|
||||||
|
|
||||||
let help = Text::from(Spans::from(help));
|
|
||||||
let help = Paragraph::new(help);
|
|
||||||
|
|
||||||
let filter_mode = match app.filter_mode {
|
|
||||||
FilterMode::Global => "GLOBAL",
|
|
||||||
FilterMode::Host => "HOST",
|
|
||||||
FilterMode::Session => "SESSION",
|
|
||||||
FilterMode::Directory => "DIRECTORY",
|
|
||||||
};
|
|
||||||
|
|
||||||
let input = Paragraph::new(app.input.clone())
|
|
||||||
.block(Block::default().borders(Borders::ALL).title(filter_mode));
|
|
||||||
|
|
||||||
let stats = Paragraph::new(Text::from(Span::raw(format!(
|
|
||||||
"history count: {}",
|
|
||||||
history_count,
|
|
||||||
))))
|
|
||||||
.alignment(Alignment::Right);
|
|
||||||
|
|
||||||
f.render_widget(title, top_left_chunks[0]);
|
|
||||||
f.render_widget(help, top_left_chunks[1]);
|
|
||||||
f.render_widget(stats, top_right_chunks[0]);
|
|
||||||
|
|
||||||
app.render_results(
|
|
||||||
f,
|
|
||||||
chunks[1],
|
|
||||||
Block::default().borders(Borders::ALL).title("History"),
|
|
||||||
);
|
|
||||||
f.render_widget(input, chunks[2]);
|
|
||||||
|
|
||||||
let width = UnicodeWidthStr::width(
|
|
||||||
app.input
|
|
||||||
.chars()
|
|
||||||
.take(app.cursor_index)
|
|
||||||
.collect::<String>()
|
|
||||||
.as_str(),
|
|
||||||
);
|
|
||||||
f.set_cursor(
|
|
||||||
// Put cursor past the end of the input text
|
|
||||||
chunks[2].x + width as u16 + 1,
|
|
||||||
// Move one line down, from the border to the input line
|
|
||||||
chunks[2].y + 1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(clippy::cast_possible_truncation)]
|
|
||||||
fn draw_compact<T: Backend>(f: &mut Frame<'_, T>, history_count: i64, app: &mut State) {
|
|
||||||
let chunks = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.margin(0)
|
|
||||||
.horizontal_margin(1)
|
|
||||||
.constraints(
|
|
||||||
[
|
|
||||||
Constraint::Length(1),
|
|
||||||
Constraint::Min(1),
|
|
||||||
Constraint::Length(1),
|
|
||||||
]
|
|
||||||
.as_ref(),
|
|
||||||
)
|
|
||||||
.split(f.size());
|
|
||||||
|
|
||||||
let header_chunks = Layout::default()
|
|
||||||
.direction(Direction::Horizontal)
|
|
||||||
.constraints(
|
|
||||||
[
|
|
||||||
Constraint::Ratio(1, 3),
|
|
||||||
Constraint::Ratio(1, 3),
|
|
||||||
Constraint::Ratio(1, 3),
|
|
||||||
]
|
|
||||||
.as_ref(),
|
|
||||||
)
|
|
||||||
.split(chunks[0]);
|
|
||||||
|
|
||||||
let title = Paragraph::new(Text::from(Span::styled(
|
|
||||||
format!("Atuin v{}", VERSION),
|
|
||||||
Style::default().fg(Color::DarkGray),
|
|
||||||
)));
|
|
||||||
|
|
||||||
let help = Paragraph::new(Text::from(Spans::from(vec![
|
|
||||||
Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)),
|
|
||||||
Span::raw(" to exit"),
|
|
||||||
])))
|
|
||||||
.style(Style::default().fg(Color::DarkGray))
|
|
||||||
.alignment(Alignment::Center);
|
|
||||||
|
|
||||||
let stats = Paragraph::new(Text::from(Span::raw(format!(
|
|
||||||
"history count: {}",
|
|
||||||
history_count,
|
|
||||||
))))
|
|
||||||
.style(Style::default().fg(Color::DarkGray))
|
|
||||||
.alignment(Alignment::Right);
|
|
||||||
|
|
||||||
let filter_mode = match app.filter_mode {
|
|
||||||
FilterMode::Global => "GLOBAL",
|
|
||||||
FilterMode::Host => "HOST",
|
|
||||||
FilterMode::Session => "SESSION",
|
|
||||||
FilterMode::Directory => "DIRECTORY",
|
|
||||||
};
|
|
||||||
|
|
||||||
let input =
|
|
||||||
Paragraph::new(format!("{}] {}", filter_mode, app.input.clone())).block(Block::default());
|
|
||||||
|
|
||||||
f.render_widget(title, header_chunks[0]);
|
|
||||||
f.render_widget(help, header_chunks[1]);
|
|
||||||
f.render_widget(stats, header_chunks[2]);
|
|
||||||
|
|
||||||
app.render_results(f, chunks[1], Block::default());
|
|
||||||
f.render_widget(input, chunks[2]);
|
|
||||||
|
|
||||||
let extra_width = UnicodeWidthStr::width(
|
|
||||||
app.input
|
|
||||||
.chars()
|
|
||||||
.take(app.cursor_index)
|
|
||||||
.collect::<String>()
|
|
||||||
.as_str(),
|
|
||||||
) + filter_mode.len();
|
|
||||||
|
|
||||||
f.set_cursor(
|
|
||||||
// Put cursor past the end of the input text
|
|
||||||
chunks[2].x + extra_width as u16 + 2,
|
|
||||||
// Move one line down, from the border to the input line
|
|
||||||
chunks[2].y + 1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// this is a big blob of horrible! clean it up!
|
|
||||||
// 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
|
|
||||||
#[allow(clippy::cast_possible_truncation)]
|
|
||||||
async fn select_history(
|
|
||||||
query: &[String],
|
|
||||||
search_mode: SearchMode,
|
|
||||||
filter_mode: FilterMode,
|
|
||||||
style: atuin_client::settings::Style,
|
|
||||||
db: &mut impl Database,
|
|
||||||
) -> Result<String> {
|
|
||||||
let stdout = stdout().into_raw_mode()?;
|
|
||||||
let stdout = MouseTerminal::from(stdout);
|
|
||||||
let stdout = AlternateScreen::from(stdout);
|
|
||||||
let backend = TermionBackend::new(stdout);
|
|
||||||
let mut terminal = Terminal::new(backend)?;
|
|
||||||
|
|
||||||
// Setup event handlers
|
|
||||||
let events = Events::new();
|
|
||||||
|
|
||||||
let input = query.join(" ");
|
|
||||||
// Put the cursor at the end of the query by default
|
|
||||||
let cursor_index = input.chars().count();
|
|
||||||
let mut app = State {
|
|
||||||
input,
|
|
||||||
cursor_index,
|
|
||||||
results: Vec::new(),
|
|
||||||
results_state: ListState::default(),
|
|
||||||
context: current_context(),
|
|
||||||
filter_mode,
|
|
||||||
};
|
|
||||||
|
|
||||||
query_results(&mut app, search_mode, db).await?;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let history_count = db.history_count().await?;
|
|
||||||
let initial_input = app.input.clone();
|
|
||||||
let initial_filter_mode = app.filter_mode;
|
|
||||||
|
|
||||||
// Handle input
|
|
||||||
if let Event::Input(input) = events.next()? {
|
|
||||||
if let Some(output) = key_handler(&input, &mut app) {
|
|
||||||
return Ok(output);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// After we receive input process the whole event channel before query/render.
|
|
||||||
while let Ok(Event::Input(input)) = events.try_next() {
|
|
||||||
if let Some(output) = key_handler(&input, &mut app) {
|
|
||||||
return Ok(output);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if initial_input != app.input || initial_filter_mode != app.filter_mode {
|
|
||||||
query_results(&mut app, search_mode, db).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let compact = match style {
|
|
||||||
atuin_client::settings::Style::Auto => {
|
|
||||||
terminal.size().map(|size| size.height < 14).unwrap_or(true)
|
|
||||||
}
|
|
||||||
atuin_client::settings::Style::Compact => true,
|
|
||||||
atuin_client::settings::Style::Full => false,
|
|
||||||
};
|
|
||||||
if compact {
|
|
||||||
terminal.draw(|f| draw_compact(f, history_count, &mut app))?;
|
|
||||||
} else {
|
|
||||||
terminal.draw(|f| draw(f, history_count, &mut app))?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is supposed to more-or-less mirror the command line version, so ofc
|
// This is supposed to more-or-less mirror the command line version, so ofc
|
||||||
// it is going to have a lot of args
|
// it is going to have a lot of args
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
|
156
src/command/client/search/cursor.rs
Normal file
156
src/command/client/search/cursor.rs
Normal file
|
@ -0,0 +1,156 @@
|
||||||
|
pub struct Cursor {
|
||||||
|
source: String,
|
||||||
|
index: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<String> for Cursor {
|
||||||
|
fn from(source: String) -> Self {
|
||||||
|
Self { source, index: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cursor {
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
self.source.as_str()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the string before the cursor
|
||||||
|
pub fn substring(&self) -> &str {
|
||||||
|
&self.source[..self.index]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the currently selected [`char`]
|
||||||
|
pub fn char(&self) -> Option<char> {
|
||||||
|
self.source[self.index..].chars().next()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn right(&mut self) {
|
||||||
|
if self.index < self.source.len() {
|
||||||
|
loop {
|
||||||
|
self.index += 1;
|
||||||
|
if self.source.is_char_boundary(self.index) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn left(&mut self) -> bool {
|
||||||
|
if self.index > 0 {
|
||||||
|
loop {
|
||||||
|
self.index -= 1;
|
||||||
|
if self.source.is_char_boundary(self.index) {
|
||||||
|
break true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert(&mut self, c: char) {
|
||||||
|
self.source.insert(self.index, c);
|
||||||
|
self.index += c.len_utf8();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove(&mut self) -> char {
|
||||||
|
self.source.remove(self.index)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn back(&mut self) -> Option<char> {
|
||||||
|
if self.left() {
|
||||||
|
Some(self.remove())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.source.clear();
|
||||||
|
self.index = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn end(&mut self) {
|
||||||
|
self.index = self.source.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start(&mut self) {
|
||||||
|
self.index = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod cursor_tests {
|
||||||
|
use super::Cursor;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn right() {
|
||||||
|
// ö is 2 bytes
|
||||||
|
let mut c = Cursor::from(String::from("öaöböcödöeöfö"));
|
||||||
|
let indices = [0, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18, 20, 20, 20, 20];
|
||||||
|
for i in indices {
|
||||||
|
assert_eq!(c.index, i);
|
||||||
|
c.right();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn left() {
|
||||||
|
// ö is 2 bytes
|
||||||
|
let mut c = Cursor::from(String::from("öaöböcödöeöfö"));
|
||||||
|
c.end();
|
||||||
|
let indices = [20, 18, 17, 15, 14, 12, 11, 9, 8, 6, 5, 3, 2, 0, 0, 0, 0];
|
||||||
|
for i in indices {
|
||||||
|
assert_eq!(c.index, i);
|
||||||
|
c.left();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pop() {
|
||||||
|
let mut s = String::from("öaöböcödöeöfö");
|
||||||
|
let mut c = Cursor::from(s.clone());
|
||||||
|
c.end();
|
||||||
|
while !s.is_empty() {
|
||||||
|
let c1 = s.pop();
|
||||||
|
let c2 = c.back();
|
||||||
|
assert_eq!(c1, c2);
|
||||||
|
assert_eq!(s.as_str(), c.substring());
|
||||||
|
}
|
||||||
|
let c1 = s.pop();
|
||||||
|
let c2 = c.back();
|
||||||
|
assert_eq!(c1, c2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn back() {
|
||||||
|
let mut c = Cursor::from(String::from("öaöböcödöeöfö"));
|
||||||
|
// move to ^
|
||||||
|
for _ in 0..4 {
|
||||||
|
c.right();
|
||||||
|
}
|
||||||
|
assert_eq!(c.substring(), "öaöb");
|
||||||
|
assert_eq!(c.back(), Some('b'));
|
||||||
|
assert_eq!(c.back(), Some('ö'));
|
||||||
|
assert_eq!(c.back(), Some('a'));
|
||||||
|
assert_eq!(c.back(), Some('ö'));
|
||||||
|
assert_eq!(c.back(), None);
|
||||||
|
assert_eq!(c.as_str(), "öcödöeöfö");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn insert() {
|
||||||
|
let mut c = Cursor::from(String::from("öaöböcödöeöfö"));
|
||||||
|
// move to ^
|
||||||
|
for _ in 0..4 {
|
||||||
|
c.right();
|
||||||
|
}
|
||||||
|
assert_eq!(c.substring(), "öaöb");
|
||||||
|
c.insert('ö');
|
||||||
|
c.insert('g');
|
||||||
|
c.insert('ö');
|
||||||
|
c.insert('h');
|
||||||
|
assert_eq!(c.substring(), "öaöbögöh");
|
||||||
|
assert_eq!(c.as_str(), "öaöbögöhöcödöeöfö");
|
||||||
|
}
|
||||||
|
}
|
493
src/command/client/search/interactive.rs
Normal file
493
src/command/client/search/interactive.rs
Normal file
|
@ -0,0 +1,493 @@
|
||||||
|
use std::{io::stdout, ops::Sub, time::Duration};
|
||||||
|
|
||||||
|
use eyre::Result;
|
||||||
|
use termion::{
|
||||||
|
event::Event as TermEvent, event::Key, event::MouseButton, event::MouseEvent,
|
||||||
|
input::MouseTerminal, raw::IntoRawMode, screen::AlternateScreen,
|
||||||
|
};
|
||||||
|
use tui::{
|
||||||
|
backend::{Backend, TermionBackend},
|
||||||
|
layout::{Alignment, Constraint, Corner, Direction, Layout},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Span, Spans, Text},
|
||||||
|
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
|
||||||
|
Frame, Terminal,
|
||||||
|
};
|
||||||
|
use unicode_width::UnicodeWidthStr;
|
||||||
|
|
||||||
|
use atuin_client::{
|
||||||
|
database::current_context,
|
||||||
|
database::Context,
|
||||||
|
database::Database,
|
||||||
|
history::History,
|
||||||
|
settings::{FilterMode, SearchMode},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
cursor::Cursor,
|
||||||
|
event::{Event, Events},
|
||||||
|
};
|
||||||
|
use crate::VERSION;
|
||||||
|
|
||||||
|
struct State {
|
||||||
|
input: Cursor,
|
||||||
|
|
||||||
|
filter_mode: FilterMode,
|
||||||
|
|
||||||
|
results: Vec<History>,
|
||||||
|
|
||||||
|
results_state: ListState,
|
||||||
|
|
||||||
|
context: Context,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl State {
|
||||||
|
#[allow(clippy::cast_sign_loss)]
|
||||||
|
fn durations(&self) -> Vec<(String, String)> {
|
||||||
|
self.results
|
||||||
|
.iter()
|
||||||
|
.map(|h| {
|
||||||
|
let duration =
|
||||||
|
Duration::from_millis(std::cmp::max(h.duration, 0) as u64 / 1_000_000);
|
||||||
|
let duration = humantime::format_duration(duration).to_string();
|
||||||
|
let duration: Vec<&str> = duration.split(' ').collect();
|
||||||
|
|
||||||
|
let ago = chrono::Utc::now().sub(h.timestamp);
|
||||||
|
|
||||||
|
// Account for the chance that h.timestamp is "in the future"
|
||||||
|
// This would mean that "ago" is negative, and the unwrap here
|
||||||
|
// would fail.
|
||||||
|
// If the timestamp would otherwise be in the future, display
|
||||||
|
// the time ago as 0.
|
||||||
|
let ago = humantime::format_duration(
|
||||||
|
ago.to_std().unwrap_or_else(|_| Duration::new(0, 0)),
|
||||||
|
)
|
||||||
|
.to_string();
|
||||||
|
let ago: Vec<&str> = ago.split(' ').collect();
|
||||||
|
|
||||||
|
(
|
||||||
|
duration[0]
|
||||||
|
.to_string()
|
||||||
|
.replace("days", "d")
|
||||||
|
.replace("day", "d")
|
||||||
|
.replace("weeks", "w")
|
||||||
|
.replace("week", "w")
|
||||||
|
.replace("months", "mo")
|
||||||
|
.replace("month", "mo")
|
||||||
|
.replace("years", "y")
|
||||||
|
.replace("year", "y"),
|
||||||
|
ago[0]
|
||||||
|
.to_string()
|
||||||
|
.replace("days", "d")
|
||||||
|
.replace("day", "d")
|
||||||
|
.replace("weeks", "w")
|
||||||
|
.replace("week", "w")
|
||||||
|
.replace("months", "mo")
|
||||||
|
.replace("month", "mo")
|
||||||
|
.replace("years", "y")
|
||||||
|
.replace("year", "y")
|
||||||
|
+ " ago",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_results<T: tui::backend::Backend>(
|
||||||
|
&mut self,
|
||||||
|
f: &mut tui::Frame<T>,
|
||||||
|
r: tui::layout::Rect,
|
||||||
|
b: tui::widgets::Block,
|
||||||
|
) {
|
||||||
|
let durations = self.durations();
|
||||||
|
let max_length = durations.iter().fold(0, |largest, i| {
|
||||||
|
std::cmp::max(largest, i.0.len() + i.1.len())
|
||||||
|
});
|
||||||
|
|
||||||
|
let results: Vec<ListItem> = self
|
||||||
|
.results
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, m)| {
|
||||||
|
let command = m.command.to_string().replace('\n', " ").replace('\t', " ");
|
||||||
|
|
||||||
|
let mut command = Span::raw(command);
|
||||||
|
|
||||||
|
let (duration, mut ago) = durations[i].clone();
|
||||||
|
|
||||||
|
while (duration.len() + ago.len()) < max_length {
|
||||||
|
ago = format!(" {}", ago);
|
||||||
|
}
|
||||||
|
|
||||||
|
let selected_index = match self.results_state.selected() {
|
||||||
|
None => Span::raw(" "),
|
||||||
|
Some(selected) => match i.checked_sub(selected) {
|
||||||
|
None => Span::raw(" "),
|
||||||
|
Some(diff) => {
|
||||||
|
if 0 < diff && diff < 10 {
|
||||||
|
Span::raw(format!(" {} ", diff))
|
||||||
|
} else {
|
||||||
|
Span::raw(" ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let duration = Span::styled(
|
||||||
|
duration,
|
||||||
|
Style::default().fg(if m.success() {
|
||||||
|
Color::Green
|
||||||
|
} else {
|
||||||
|
Color::Red
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let ago = Span::styled(ago, Style::default().fg(Color::Blue));
|
||||||
|
|
||||||
|
if let Some(selected) = self.results_state.selected() {
|
||||||
|
if selected == i {
|
||||||
|
command.style =
|
||||||
|
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let spans = Spans::from(vec![
|
||||||
|
selected_index,
|
||||||
|
duration,
|
||||||
|
Span::raw(" "),
|
||||||
|
ago,
|
||||||
|
Span::raw(" "),
|
||||||
|
command,
|
||||||
|
]);
|
||||||
|
|
||||||
|
ListItem::new(spans)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let results = List::new(results)
|
||||||
|
.block(b)
|
||||||
|
.start_corner(Corner::BottomLeft)
|
||||||
|
.highlight_symbol(">> ");
|
||||||
|
|
||||||
|
f.render_stateful_widget(results, r, &mut self.results_state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl State {
|
||||||
|
async fn query_results(
|
||||||
|
&mut self,
|
||||||
|
search_mode: SearchMode,
|
||||||
|
db: &mut impl Database,
|
||||||
|
) -> Result<()> {
|
||||||
|
let i = self.input.as_str();
|
||||||
|
let results = if i.is_empty() {
|
||||||
|
db.list(self.filter_mode, &self.context, Some(200), true)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
db.search(Some(200), search_mode, self.filter_mode, &self.context, i)
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
|
||||||
|
self.results = results;
|
||||||
|
|
||||||
|
if self.results.is_empty() {
|
||||||
|
self.results_state.select(None);
|
||||||
|
} else {
|
||||||
|
self.results_state.select(Some(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_input(&mut self, input: &TermEvent) -> Option<&str> {
|
||||||
|
match input {
|
||||||
|
TermEvent::Key(Key::Esc | Key::Ctrl('c' | 'd' | 'g')) => return Some(""),
|
||||||
|
TermEvent::Key(Key::Char('\n')) => {
|
||||||
|
let i = self.results_state.selected().unwrap_or(0);
|
||||||
|
|
||||||
|
return Some(
|
||||||
|
self.results
|
||||||
|
.get(i)
|
||||||
|
.map_or(self.input.as_str(), |h| h.command.as_str()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
TermEvent::Key(Key::Alt(c @ '1'..='9')) => {
|
||||||
|
let c = c.to_digit(10)? as usize;
|
||||||
|
let i = self.results_state.selected()? + c;
|
||||||
|
|
||||||
|
return Some(
|
||||||
|
self.results
|
||||||
|
.get(i)
|
||||||
|
.map_or(self.input.as_str(), |h| h.command.as_str()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
TermEvent::Key(Key::Left | Key::Ctrl('h')) => {
|
||||||
|
self.input.left();
|
||||||
|
}
|
||||||
|
TermEvent::Key(Key::Right | Key::Ctrl('l')) => self.input.right(),
|
||||||
|
TermEvent::Key(Key::Ctrl('a')) => self.input.start(),
|
||||||
|
TermEvent::Key(Key::Ctrl('e')) => self.input.end(),
|
||||||
|
TermEvent::Key(Key::Char(c)) => self.input.insert(*c),
|
||||||
|
TermEvent::Key(Key::Backspace) => {
|
||||||
|
self.input.back();
|
||||||
|
}
|
||||||
|
TermEvent::Key(Key::Ctrl('w')) => {
|
||||||
|
// remove the first batch of whitespace
|
||||||
|
while matches!(self.input.back(), Some(c) if c.is_whitespace()) {}
|
||||||
|
while self.input.left() {
|
||||||
|
if self.input.char().unwrap().is_whitespace() {
|
||||||
|
self.input.right(); // found whitespace, go back right
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
self.input.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TermEvent::Key(Key::Ctrl('u')) => self.input.clear(),
|
||||||
|
TermEvent::Key(Key::Ctrl('r')) => {
|
||||||
|
pub static FILTER_MODES: [FilterMode; 4] = [
|
||||||
|
FilterMode::Global,
|
||||||
|
FilterMode::Host,
|
||||||
|
FilterMode::Session,
|
||||||
|
FilterMode::Directory,
|
||||||
|
];
|
||||||
|
let i = self.filter_mode as usize;
|
||||||
|
let i = (i + 1) % FILTER_MODES.len();
|
||||||
|
self.filter_mode = FILTER_MODES[i];
|
||||||
|
}
|
||||||
|
TermEvent::Key(Key::Down | Key::Ctrl('n' | 'j'))
|
||||||
|
| TermEvent::Mouse(MouseEvent::Press(MouseButton::WheelDown, _, _)) => {
|
||||||
|
let i = self
|
||||||
|
.results_state
|
||||||
|
.selected() // try get current selection
|
||||||
|
.map_or(0, |i| i.saturating_sub(1)); // subtract 1 if possible
|
||||||
|
self.results_state.select(Some(i));
|
||||||
|
}
|
||||||
|
TermEvent::Key(Key::Up | Key::Ctrl('p' | 'k'))
|
||||||
|
| TermEvent::Mouse(MouseEvent::Press(MouseButton::WheelUp, _, _)) => {
|
||||||
|
let i = self
|
||||||
|
.results_state
|
||||||
|
.selected()
|
||||||
|
.map_or(0, |i| i + 1) // increment the selected index
|
||||||
|
.min(self.results.len() - 1); // clamp it to the last entry
|
||||||
|
self.results_state.select(Some(i));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
};
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::cast_possible_truncation)]
|
||||||
|
fn draw<T: Backend>(&mut self, f: &mut Frame<'_, T>, history_count: i64) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.margin(1)
|
||||||
|
.constraints(
|
||||||
|
[
|
||||||
|
Constraint::Length(2),
|
||||||
|
Constraint::Min(1),
|
||||||
|
Constraint::Length(3),
|
||||||
|
]
|
||||||
|
.as_ref(),
|
||||||
|
)
|
||||||
|
.split(f.size());
|
||||||
|
|
||||||
|
let top_chunks = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
|
||||||
|
.split(chunks[0]);
|
||||||
|
|
||||||
|
let top_left_chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([Constraint::Length(1), Constraint::Length(1)].as_ref())
|
||||||
|
.split(top_chunks[0]);
|
||||||
|
|
||||||
|
let top_right_chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([Constraint::Length(1), Constraint::Length(1)].as_ref())
|
||||||
|
.split(top_chunks[1]);
|
||||||
|
|
||||||
|
let title = Paragraph::new(Text::from(Span::styled(
|
||||||
|
format!("Atuin v{}", VERSION),
|
||||||
|
Style::default().add_modifier(Modifier::BOLD),
|
||||||
|
)));
|
||||||
|
|
||||||
|
let help = vec![
|
||||||
|
Span::raw("Press "),
|
||||||
|
Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)),
|
||||||
|
Span::raw(" to exit."),
|
||||||
|
];
|
||||||
|
|
||||||
|
let help = Text::from(Spans::from(help));
|
||||||
|
let help = Paragraph::new(help);
|
||||||
|
|
||||||
|
let input = Paragraph::new(self.input.as_str().to_owned()).block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(self.filter_mode.as_str()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let stats = Paragraph::new(Text::from(Span::raw(format!(
|
||||||
|
"history count: {}",
|
||||||
|
history_count,
|
||||||
|
))))
|
||||||
|
.alignment(Alignment::Right);
|
||||||
|
|
||||||
|
f.render_widget(title, top_left_chunks[0]);
|
||||||
|
f.render_widget(help, top_left_chunks[1]);
|
||||||
|
f.render_widget(stats, top_right_chunks[0]);
|
||||||
|
|
||||||
|
self.render_results(
|
||||||
|
f,
|
||||||
|
chunks[1],
|
||||||
|
Block::default().borders(Borders::ALL).title("History"),
|
||||||
|
);
|
||||||
|
f.render_widget(input, chunks[2]);
|
||||||
|
|
||||||
|
let width = UnicodeWidthStr::width(self.input.substring());
|
||||||
|
f.set_cursor(
|
||||||
|
// Put cursor past the end of the input text
|
||||||
|
chunks[2].x + width as u16 + 1,
|
||||||
|
// Move one line down, from the border to the input line
|
||||||
|
chunks[2].y + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::cast_possible_truncation)]
|
||||||
|
fn draw_compact<T: Backend>(&mut self, f: &mut Frame<'_, T>, history_count: i64) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.margin(0)
|
||||||
|
.horizontal_margin(1)
|
||||||
|
.constraints(
|
||||||
|
[
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Min(1),
|
||||||
|
Constraint::Length(1),
|
||||||
|
]
|
||||||
|
.as_ref(),
|
||||||
|
)
|
||||||
|
.split(f.size());
|
||||||
|
|
||||||
|
let header_chunks = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints(
|
||||||
|
[
|
||||||
|
Constraint::Ratio(1, 3),
|
||||||
|
Constraint::Ratio(1, 3),
|
||||||
|
Constraint::Ratio(1, 3),
|
||||||
|
]
|
||||||
|
.as_ref(),
|
||||||
|
)
|
||||||
|
.split(chunks[0]);
|
||||||
|
|
||||||
|
let title = Paragraph::new(Text::from(Span::styled(
|
||||||
|
format!("Atuin v{}", VERSION),
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
)));
|
||||||
|
|
||||||
|
let help = Paragraph::new(Text::from(Spans::from(vec![
|
||||||
|
Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)),
|
||||||
|
Span::raw(" to exit"),
|
||||||
|
])))
|
||||||
|
.style(Style::default().fg(Color::DarkGray))
|
||||||
|
.alignment(Alignment::Center);
|
||||||
|
|
||||||
|
let stats = Paragraph::new(Text::from(Span::raw(format!(
|
||||||
|
"history count: {}",
|
||||||
|
history_count,
|
||||||
|
))))
|
||||||
|
.style(Style::default().fg(Color::DarkGray))
|
||||||
|
.alignment(Alignment::Right);
|
||||||
|
|
||||||
|
let filter_mode = self.filter_mode.as_str();
|
||||||
|
let input = Paragraph::new(format!("{}] {}", filter_mode, self.input.as_str()))
|
||||||
|
.block(Block::default());
|
||||||
|
|
||||||
|
f.render_widget(title, header_chunks[0]);
|
||||||
|
f.render_widget(help, header_chunks[1]);
|
||||||
|
f.render_widget(stats, header_chunks[2]);
|
||||||
|
|
||||||
|
self.render_results(f, chunks[1], Block::default());
|
||||||
|
f.render_widget(input, chunks[2]);
|
||||||
|
|
||||||
|
let extra_width = UnicodeWidthStr::width(self.input.substring()) + filter_mode.len();
|
||||||
|
|
||||||
|
f.set_cursor(
|
||||||
|
// Put cursor past the end of the input text
|
||||||
|
chunks[2].x + extra_width as u16 + 2,
|
||||||
|
// Move one line down, from the border to the input line
|
||||||
|
chunks[2].y + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// this is a big blob of horrible! clean it up!
|
||||||
|
// 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
|
||||||
|
#[allow(clippy::cast_possible_truncation)]
|
||||||
|
pub async fn history(
|
||||||
|
query: &[String],
|
||||||
|
search_mode: SearchMode,
|
||||||
|
filter_mode: FilterMode,
|
||||||
|
style: atuin_client::settings::Style,
|
||||||
|
db: &mut impl Database,
|
||||||
|
) -> Result<String> {
|
||||||
|
let stdout = stdout().into_raw_mode()?;
|
||||||
|
let stdout = MouseTerminal::from(stdout);
|
||||||
|
let stdout = AlternateScreen::from(stdout);
|
||||||
|
let backend = TermionBackend::new(stdout);
|
||||||
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
|
// Setup event handlers
|
||||||
|
let events = Events::new();
|
||||||
|
|
||||||
|
let mut input = Cursor::from(query.join(" "));
|
||||||
|
// Put the cursor at the end of the query by default
|
||||||
|
input.end();
|
||||||
|
let mut app = State {
|
||||||
|
input,
|
||||||
|
results: Vec::new(),
|
||||||
|
results_state: ListState::default(),
|
||||||
|
context: current_context(),
|
||||||
|
filter_mode,
|
||||||
|
};
|
||||||
|
|
||||||
|
app.query_results(search_mode, db).await?;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let history_count = db.history_count().await?;
|
||||||
|
let initial_input = app.input.as_str().to_owned();
|
||||||
|
let initial_filter_mode = app.filter_mode;
|
||||||
|
|
||||||
|
// Handle input
|
||||||
|
if let Event::Input(input) = events.next()? {
|
||||||
|
if let Some(output) = app.handle_input(&input) {
|
||||||
|
return Ok(output.to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// After we receive input process the whole event channel before query/render.
|
||||||
|
while let Ok(Event::Input(input)) = events.try_next() {
|
||||||
|
if let Some(output) = app.handle_input(&input) {
|
||||||
|
return Ok(output.to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if initial_input != app.input.as_str() || initial_filter_mode != app.filter_mode {
|
||||||
|
app.query_results(search_mode, db).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let compact = match style {
|
||||||
|
atuin_client::settings::Style::Auto => {
|
||||||
|
terminal.size().map(|size| size.height < 14).unwrap_or(true)
|
||||||
|
}
|
||||||
|
atuin_client::settings::Style::Compact => true,
|
||||||
|
atuin_client::settings::Style::Full => false,
|
||||||
|
};
|
||||||
|
if compact {
|
||||||
|
terminal.draw(|f| app.draw_compact(f, history_count))?;
|
||||||
|
} else {
|
||||||
|
terminal.draw(|f| app.draw(f, history_count))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue